feat: proxy OG images to R2 to fix CORS on Flutter Web
- Added S3 client, mediaBucket, imgDomain fields to LinkPreviewService - Added ProxyImageToR2 method: downloads external OG image, uploads to R2 with deterministic key (og/<sha256>.ext), replaces ImageURL with R2 path - Called ProxyImageToR2 in post_handler.go (both sync and async paths) - Refactored OfficialAccountsService to use shared LinkPreviewService instead of creating inline instances - Reordered main.go init: S3 client setup before LinkPreviewService
This commit is contained in:
parent
d3d1e371c1
commit
bacffc759c
|
|
@ -127,22 +127,9 @@ func main() {
|
||||||
// Initialize content filter (hard blocklist + strike system)
|
// Initialize content filter (hard blocklist + strike system)
|
||||||
contentFilter := services.NewContentFilter(dbPool)
|
contentFilter := services.NewContentFilter(dbPool)
|
||||||
|
|
||||||
// Initialize link preview service
|
|
||||||
linkPreviewService := services.NewLinkPreviewService(dbPool)
|
|
||||||
|
|
||||||
hub := realtime.NewHub()
|
hub := realtime.NewHub()
|
||||||
wsHandler := handlers.NewWSHandler(hub, cfg.JWTSecret)
|
wsHandler := handlers.NewWSHandler(hub, cfg.JWTSecret)
|
||||||
|
|
||||||
userHandler := handlers.NewUserHandler(userRepo, postRepo, notificationService, assetService)
|
|
||||||
postHandler := handlers.NewPostHandler(postRepo, userRepo, feedService, assetService, notificationService, moderationService, contentFilter, openRouterService, linkPreviewService)
|
|
||||||
chatHandler := handlers.NewChatHandler(chatRepo, notificationService, hub)
|
|
||||||
authHandler := handlers.NewAuthHandler(userRepo, cfg, emailService, sendPulseService)
|
|
||||||
categoryHandler := handlers.NewCategoryHandler(categoryRepo)
|
|
||||||
keyHandler := handlers.NewKeyHandler(userRepo)
|
|
||||||
backupHandler := handlers.NewBackupHandler(repository.NewBackupRepository(dbPool))
|
|
||||||
settingsHandler := handlers.NewSettingsHandler(userRepo, notifRepo)
|
|
||||||
analysisHandler := handlers.NewAnalysisHandler()
|
|
||||||
appealHandler := handlers.NewAppealHandler(appealService)
|
|
||||||
var s3Client *s3.Client
|
var s3Client *s3.Client
|
||||||
if cfg.R2AccessKey != "" && cfg.R2SecretKey != "" && cfg.R2Endpoint != "" {
|
if cfg.R2AccessKey != "" && cfg.R2SecretKey != "" && cfg.R2Endpoint != "" {
|
||||||
resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
|
resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
|
||||||
|
|
@ -161,8 +148,22 @@ func main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize link preview service (after S3 client setup)
|
||||||
|
linkPreviewService := services.NewLinkPreviewService(dbPool, s3Client, cfg.R2MediaBucket, cfg.R2ImgDomain)
|
||||||
|
|
||||||
|
userHandler := handlers.NewUserHandler(userRepo, postRepo, notificationService, assetService)
|
||||||
|
postHandler := handlers.NewPostHandler(postRepo, userRepo, feedService, assetService, notificationService, moderationService, contentFilter, openRouterService, linkPreviewService)
|
||||||
|
chatHandler := handlers.NewChatHandler(chatRepo, notificationService, hub)
|
||||||
|
authHandler := handlers.NewAuthHandler(userRepo, cfg, emailService, sendPulseService)
|
||||||
|
categoryHandler := handlers.NewCategoryHandler(categoryRepo)
|
||||||
|
keyHandler := handlers.NewKeyHandler(userRepo)
|
||||||
|
backupHandler := handlers.NewBackupHandler(repository.NewBackupRepository(dbPool))
|
||||||
|
settingsHandler := handlers.NewSettingsHandler(userRepo, notifRepo)
|
||||||
|
analysisHandler := handlers.NewAnalysisHandler()
|
||||||
|
appealHandler := handlers.NewAppealHandler(appealService)
|
||||||
|
|
||||||
// Initialize official accounts service
|
// Initialize official accounts service
|
||||||
officialAccountsService := services.NewOfficialAccountsService(dbPool, openRouterService)
|
officialAccountsService := services.NewOfficialAccountsService(dbPool, openRouterService, linkPreviewService)
|
||||||
officialAccountsService.StartScheduler()
|
officialAccountsService.StartScheduler()
|
||||||
defer officialAccountsService.StopScheduler()
|
defer officialAccountsService.StopScheduler()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -637,6 +637,7 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
|
||||||
fastCancel()
|
fastCancel()
|
||||||
|
|
||||||
if lpErr == nil && lp != nil {
|
if lpErr == nil && lp != nil {
|
||||||
|
h.linkPreviewService.ProxyImageToR2(c.Request.Context(), lp)
|
||||||
_ = h.linkPreviewService.SaveLinkPreview(c.Request.Context(), post.ID.String(), lp)
|
_ = h.linkPreviewService.SaveLinkPreview(c.Request.Context(), post.ID.String(), lp)
|
||||||
post.LinkPreviewURL = &lp.URL
|
post.LinkPreviewURL = &lp.URL
|
||||||
post.LinkPreviewTitle = &lp.Title
|
post.LinkPreviewTitle = &lp.Title
|
||||||
|
|
@ -651,6 +652,7 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
|
||||||
defer bgCancel()
|
defer bgCancel()
|
||||||
bgLp, bgErr := h.linkPreviewService.FetchPreview(bgCtx, linkURL, isOfficial)
|
bgLp, bgErr := h.linkPreviewService.FetchPreview(bgCtx, linkURL, isOfficial)
|
||||||
if bgErr == nil && bgLp != nil {
|
if bgErr == nil && bgLp != nil {
|
||||||
|
h.linkPreviewService.ProxyImageToR2(bgCtx, bgLp)
|
||||||
_ = h.linkPreviewService.SaveLinkPreview(bgCtx, postID, bgLp)
|
_ = h.linkPreviewService.SaveLinkPreview(bgCtx, postID, bgLp)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,22 @@
|
||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"path"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
@ -34,11 +39,17 @@ type LinkPreview struct {
|
||||||
type LinkPreviewService struct {
|
type LinkPreviewService struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
s3Client *s3.Client
|
||||||
|
mediaBucket string
|
||||||
|
imgDomain string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewLinkPreviewService(pool *pgxpool.Pool) *LinkPreviewService {
|
func NewLinkPreviewService(pool *pgxpool.Pool, s3Client *s3.Client, mediaBucket string, imgDomain string) *LinkPreviewService {
|
||||||
return &LinkPreviewService{
|
return &LinkPreviewService{
|
||||||
pool: pool,
|
pool: pool,
|
||||||
|
s3Client: s3Client,
|
||||||
|
mediaBucket: mediaBucket,
|
||||||
|
imgDomain: imgDomain,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 8 * time.Second,
|
Timeout: 8 * time.Second,
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
|
@ -327,6 +338,89 @@ func (s *LinkPreviewService) SaveLinkPreview(ctx context.Context, postID string,
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProxyImageToR2 downloads an external OG image and uploads it to R2.
|
||||||
|
// On success, lp.ImageURL is replaced with the R2 object key (e.g. "og/abc123.jpg").
|
||||||
|
// If S3 is not configured or the download fails, the original URL is left unchanged.
|
||||||
|
func (s *LinkPreviewService) ProxyImageToR2(ctx context.Context, lp *LinkPreview) {
|
||||||
|
if s.s3Client == nil || s.mediaBucket == "" || lp == nil || lp.ImageURL == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only proxy external http(s) URLs
|
||||||
|
if !strings.HasPrefix(lp.ImageURL, "http://") && !strings.HasPrefix(lp.ImageURL, "https://") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the image with a short timeout
|
||||||
|
dlCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(dlCtx, "GET", lp.ImageURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Err(err).Str("url", lp.ImageURL).Msg("[LinkPreview] Failed to create image download request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||||
|
|
||||||
|
resp, err := s.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Err(err).Str("url", lp.ImageURL).Msg("[LinkPreview] Failed to download OG image")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
log.Warn().Int("status", resp.StatusCode).Str("url", lp.ImageURL).Msg("[LinkPreview] OG image download returned non-200")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read max 5MB
|
||||||
|
imgBytes, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024))
|
||||||
|
if err != nil || len(imgBytes) == 0 {
|
||||||
|
log.Warn().Err(err).Str("url", lp.ImageURL).Msg("[LinkPreview] Failed to read OG image bytes")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine content type and extension
|
||||||
|
ct := resp.Header.Get("Content-Type")
|
||||||
|
ext := ".jpg"
|
||||||
|
switch {
|
||||||
|
case strings.Contains(ct, "png"):
|
||||||
|
ext = ".png"
|
||||||
|
case strings.Contains(ct, "gif"):
|
||||||
|
ext = ".gif"
|
||||||
|
case strings.Contains(ct, "webp"):
|
||||||
|
ext = ".webp"
|
||||||
|
case strings.Contains(ct, "svg"):
|
||||||
|
ext = ".svg"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a deterministic key from the source URL hash
|
||||||
|
hash := sha256.Sum256([]byte(lp.ImageURL))
|
||||||
|
hashStr := hex.EncodeToString(hash[:12])
|
||||||
|
objectKey := path.Join("og", hashStr+ext)
|
||||||
|
|
||||||
|
// Upload to R2
|
||||||
|
contentType := ct
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "image/jpeg"
|
||||||
|
}
|
||||||
|
reader := bytes.NewReader(imgBytes)
|
||||||
|
_, err = s.s3Client.PutObject(ctx, &s3.PutObjectInput{
|
||||||
|
Bucket: &s.mediaBucket,
|
||||||
|
Key: &objectKey,
|
||||||
|
Body: reader,
|
||||||
|
ContentType: &contentType,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Err(err).Str("key", objectKey).Msg("[LinkPreview] Failed to upload OG image to R2")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Str("key", objectKey).Str("original", lp.ImageURL).Msg("[LinkPreview] OG image proxied to R2")
|
||||||
|
lp.ImageURL = objectKey
|
||||||
|
}
|
||||||
|
|
||||||
// ── Safe Domains ─────────────────────────────────────
|
// ── Safe Domains ─────────────────────────────────────
|
||||||
|
|
||||||
// SafeDomain represents a row in the safe_domains table.
|
// SafeDomain represents a row in the safe_domains table.
|
||||||
|
|
|
||||||
|
|
@ -130,15 +130,17 @@ type CachedArticle struct {
|
||||||
type OfficialAccountsService struct {
|
type OfficialAccountsService struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
openRouterService *OpenRouterService
|
openRouterService *OpenRouterService
|
||||||
|
linkPreviewService *LinkPreviewService
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOfficialAccountsService(pool *pgxpool.Pool, openRouterService *OpenRouterService) *OfficialAccountsService {
|
func NewOfficialAccountsService(pool *pgxpool.Pool, openRouterService *OpenRouterService, linkPreviewService *LinkPreviewService) *OfficialAccountsService {
|
||||||
return &OfficialAccountsService{
|
return &OfficialAccountsService{
|
||||||
pool: pool,
|
pool: pool,
|
||||||
openRouterService: openRouterService,
|
openRouterService: openRouterService,
|
||||||
|
linkPreviewService: linkPreviewService,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
},
|
},
|
||||||
|
|
@ -771,14 +773,14 @@ func (s *OfficialAccountsService) CreatePostForAccount(ctx context.Context, conf
|
||||||
linkURL = article.Link
|
linkURL = article.Link
|
||||||
}
|
}
|
||||||
if linkURL != "" {
|
if linkURL != "" {
|
||||||
lps := NewLinkPreviewService(s.pool)
|
lp, lpErr := s.linkPreviewService.FetchPreview(bgCtx, linkURL, true)
|
||||||
lp, lpErr := lps.FetchPreview(bgCtx, linkURL, true)
|
|
||||||
if lpErr != nil {
|
if lpErr != nil {
|
||||||
log.Warn().Err(lpErr).Str("post_id", postID.String()).Str("url", linkURL).Msg("[OfficialAccounts] Link preview fetch failed")
|
log.Warn().Err(lpErr).Str("post_id", postID.String()).Str("url", linkURL).Msg("[OfficialAccounts] Link preview fetch failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if lp != nil {
|
if lp != nil {
|
||||||
if saveErr := lps.SaveLinkPreview(bgCtx, postID.String(), lp); saveErr != nil {
|
s.linkPreviewService.ProxyImageToR2(bgCtx, lp)
|
||||||
|
if saveErr := s.linkPreviewService.SaveLinkPreview(bgCtx, postID.String(), lp); saveErr != nil {
|
||||||
log.Warn().Err(saveErr).Str("post_id", postID.String()).Msg("[OfficialAccounts] Link preview save failed")
|
log.Warn().Err(saveErr).Str("post_id", postID.String()).Msg("[OfficialAccounts] Link preview save failed")
|
||||||
} else {
|
} else {
|
||||||
log.Info().Str("post_id", postID.String()).Str("url", linkURL).Str("title", lp.Title).Msg("[OfficialAccounts] Link preview saved")
|
log.Info().Str("post_id", postID.String()).Str("url", linkURL).Str("title", lp.Title).Msg("[OfficialAccounts] Link preview saved")
|
||||||
|
|
@ -851,14 +853,14 @@ func (s *OfficialAccountsService) CreatePostForArticle(ctx context.Context, conf
|
||||||
linkURL = article.Link
|
linkURL = article.Link
|
||||||
}
|
}
|
||||||
if linkURL != "" {
|
if linkURL != "" {
|
||||||
lps := NewLinkPreviewService(s.pool)
|
lp, lpErr := s.linkPreviewService.FetchPreview(bgCtx, linkURL, true)
|
||||||
lp, lpErr := lps.FetchPreview(bgCtx, linkURL, true)
|
|
||||||
if lpErr != nil {
|
if lpErr != nil {
|
||||||
log.Warn().Err(lpErr).Str("post_id", postID.String()).Str("url", linkURL).Msg("[OfficialAccounts] Link preview fetch failed")
|
log.Warn().Err(lpErr).Str("post_id", postID.String()).Str("url", linkURL).Msg("[OfficialAccounts] Link preview fetch failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if lp != nil {
|
if lp != nil {
|
||||||
if saveErr := lps.SaveLinkPreview(bgCtx, postID.String(), lp); saveErr != nil {
|
s.linkPreviewService.ProxyImageToR2(bgCtx, lp)
|
||||||
|
if saveErr := s.linkPreviewService.SaveLinkPreview(bgCtx, postID.String(), lp); saveErr != nil {
|
||||||
log.Warn().Err(saveErr).Str("post_id", postID.String()).Msg("[OfficialAccounts] Link preview save failed")
|
log.Warn().Err(saveErr).Str("post_id", postID.String()).Msg("[OfficialAccounts] Link preview save failed")
|
||||||
} else {
|
} else {
|
||||||
log.Info().Str("post_id", postID.String()).Str("url", linkURL).Str("title", lp.Title).Msg("[OfficialAccounts] Link preview saved")
|
log.Info().Str("post_id", postID.String()).Str("url", linkURL).Str("title", lp.Title).Msg("[OfficialAccounts] Link preview saved")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue