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)
|
||||
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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue