From bacffc759cfd3d8c84c2f7a3010bfc3675370d0d Mon Sep 17 00:00:00 2001 From: Patrick Britton Date: Mon, 9 Feb 2026 19:34:45 -0600 Subject: [PATCH] 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/.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 --- go-backend/cmd/api/main.go | 29 ++--- go-backend/internal/handlers/post_handler.go | 2 + .../internal/services/link_preview_service.go | 102 +++++++++++++++++- .../services/official_accounts_service.go | 30 +++--- 4 files changed, 131 insertions(+), 32 deletions(-) diff --git a/go-backend/cmd/api/main.go b/go-backend/cmd/api/main.go index 1d2fd55..660cee7 100644 --- a/go-backend/cmd/api/main.go +++ b/go-backend/cmd/api/main.go @@ -127,22 +127,9 @@ func main() { // Initialize content filter (hard blocklist + strike system) contentFilter := services.NewContentFilter(dbPool) - // Initialize link preview service - linkPreviewService := services.NewLinkPreviewService(dbPool) - hub := realtime.NewHub() 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 if cfg.R2AccessKey != "" && cfg.R2SecretKey != "" && cfg.R2Endpoint != "" { 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 - officialAccountsService := services.NewOfficialAccountsService(dbPool, openRouterService) + officialAccountsService := services.NewOfficialAccountsService(dbPool, openRouterService, linkPreviewService) officialAccountsService.StartScheduler() defer officialAccountsService.StopScheduler() diff --git a/go-backend/internal/handlers/post_handler.go b/go-backend/internal/handlers/post_handler.go index 574015d..89b4940 100644 --- a/go-backend/internal/handlers/post_handler.go +++ b/go-backend/internal/handlers/post_handler.go @@ -637,6 +637,7 @@ func (h *PostHandler) CreatePost(c *gin.Context) { fastCancel() if lpErr == nil && lp != nil { + h.linkPreviewService.ProxyImageToR2(c.Request.Context(), lp) _ = h.linkPreviewService.SaveLinkPreview(c.Request.Context(), post.ID.String(), lp) post.LinkPreviewURL = &lp.URL post.LinkPreviewTitle = &lp.Title @@ -651,6 +652,7 @@ func (h *PostHandler) CreatePost(c *gin.Context) { defer bgCancel() bgLp, bgErr := h.linkPreviewService.FetchPreview(bgCtx, linkURL, isOfficial) if bgErr == nil && bgLp != nil { + h.linkPreviewService.ProxyImageToR2(bgCtx, bgLp) _ = h.linkPreviewService.SaveLinkPreview(bgCtx, postID, bgLp) } }() diff --git a/go-backend/internal/services/link_preview_service.go b/go-backend/internal/services/link_preview_service.go index 0727944..7ffba1a 100644 --- a/go-backend/internal/services/link_preview_service.go +++ b/go-backend/internal/services/link_preview_service.go @@ -1,17 +1,22 @@ package services import ( + "bytes" "context" + "crypto/sha256" + "encoding/hex" "fmt" "html" "io" "net" "net/http" "net/url" + "path" "regexp" "strings" "time" + "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/jackc/pgx/v5/pgxpool" "github.com/rs/zerolog/log" ) @@ -32,13 +37,19 @@ type LinkPreview struct { // LinkPreviewService fetches and parses OpenGraph metadata from URLs. type LinkPreviewService struct { - pool *pgxpool.Pool - httpClient *http.Client + pool *pgxpool.Pool + 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{ - pool: pool, + pool: pool, + s3Client: s3Client, + mediaBucket: mediaBucket, + imgDomain: imgDomain, httpClient: &http.Client{ Timeout: 8 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { @@ -327,6 +338,89 @@ func (s *LinkPreviewService) SaveLinkPreview(ctx context.Context, postID string, 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 ───────────────────────────────────── // SafeDomain represents a row in the safe_domains table. diff --git a/go-backend/internal/services/official_accounts_service.go b/go-backend/internal/services/official_accounts_service.go index 53e8383..a1494bf 100644 --- a/go-backend/internal/services/official_accounts_service.go +++ b/go-backend/internal/services/official_accounts_service.go @@ -128,17 +128,19 @@ type CachedArticle struct { // OfficialAccountsService manages official account automation type OfficialAccountsService struct { - pool *pgxpool.Pool - openRouterService *OpenRouterService - httpClient *http.Client - stopCh chan struct{} - wg sync.WaitGroup + pool *pgxpool.Pool + openRouterService *OpenRouterService + linkPreviewService *LinkPreviewService + httpClient *http.Client + stopCh chan struct{} + wg sync.WaitGroup } -func NewOfficialAccountsService(pool *pgxpool.Pool, openRouterService *OpenRouterService) *OfficialAccountsService { +func NewOfficialAccountsService(pool *pgxpool.Pool, openRouterService *OpenRouterService, linkPreviewService *LinkPreviewService) *OfficialAccountsService { return &OfficialAccountsService{ - pool: pool, - openRouterService: openRouterService, + pool: pool, + openRouterService: openRouterService, + linkPreviewService: linkPreviewService, httpClient: &http.Client{ Timeout: 30 * time.Second, }, @@ -771,14 +773,14 @@ func (s *OfficialAccountsService) CreatePostForAccount(ctx context.Context, conf linkURL = article.Link } if linkURL != "" { - lps := NewLinkPreviewService(s.pool) - lp, lpErr := lps.FetchPreview(bgCtx, linkURL, true) + lp, lpErr := s.linkPreviewService.FetchPreview(bgCtx, linkURL, true) if lpErr != nil { log.Warn().Err(lpErr).Str("post_id", postID.String()).Str("url", linkURL).Msg("[OfficialAccounts] Link preview fetch failed") return } 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") } else { 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 } if linkURL != "" { - lps := NewLinkPreviewService(s.pool) - lp, lpErr := lps.FetchPreview(bgCtx, linkURL, true) + lp, lpErr := s.linkPreviewService.FetchPreview(bgCtx, linkURL, true) if lpErr != nil { log.Warn().Err(lpErr).Str("post_id", postID.String()).Str("url", linkURL).Msg("[OfficialAccounts] Link preview fetch failed") return } 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") } else { log.Info().Str("post_id", postID.String()).Str("url", linkURL).Str("title", lp.Title).Msg("[OfficialAccounts] Link preview saved")