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:
Patrick Britton 2026-02-09 19:34:45 -06:00
parent d3d1e371c1
commit bacffc759c
4 changed files with 131 additions and 32 deletions

View file

@ -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()

View file

@ -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)
}
}()

View file

@ -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.

View file

@ -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")