feat: link preview system - OG tag fetching, safe URL validation, full-width thumbnail card
This commit is contained in:
parent
d623320256
commit
e9e140df5e
|
|
@ -127,11 +127,14 @@ 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)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
-- Add link preview columns to posts table
|
||||
ALTER TABLE public.posts
|
||||
ADD COLUMN IF NOT EXISTS link_preview_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS link_preview_title TEXT,
|
||||
ADD COLUMN IF NOT EXISTS link_preview_description TEXT,
|
||||
ADD COLUMN IF NOT EXISTS link_preview_image_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS link_preview_site_name TEXT;
|
||||
|
||||
-- Index for quick lookups when enriching posts
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_link_preview ON public.posts (id) WHERE link_preview_url IS NOT NULL;
|
||||
|
|
@ -25,9 +25,10 @@ type PostHandler struct {
|
|||
moderationService *services.ModerationService
|
||||
contentFilter *services.ContentFilter
|
||||
openRouterService *services.OpenRouterService
|
||||
linkPreviewService *services.LinkPreviewService
|
||||
}
|
||||
|
||||
func NewPostHandler(postRepo *repository.PostRepository, userRepo *repository.UserRepository, feedService *services.FeedService, assetService *services.AssetService, notificationService *services.NotificationService, moderationService *services.ModerationService, contentFilter *services.ContentFilter, openRouterService *services.OpenRouterService) *PostHandler {
|
||||
func NewPostHandler(postRepo *repository.PostRepository, userRepo *repository.UserRepository, feedService *services.FeedService, assetService *services.AssetService, notificationService *services.NotificationService, moderationService *services.ModerationService, contentFilter *services.ContentFilter, openRouterService *services.OpenRouterService, linkPreviewService *services.LinkPreviewService) *PostHandler {
|
||||
return &PostHandler{
|
||||
postRepo: postRepo,
|
||||
userRepo: userRepo,
|
||||
|
|
@ -37,6 +38,49 @@ func NewPostHandler(postRepo *repository.PostRepository, userRepo *repository.Us
|
|||
moderationService: moderationService,
|
||||
contentFilter: contentFilter,
|
||||
openRouterService: openRouterService,
|
||||
linkPreviewService: linkPreviewService,
|
||||
}
|
||||
}
|
||||
|
||||
// enrichLinkPreviews populates link_preview fields on a slice of posts via batch query.
|
||||
func (h *PostHandler) enrichLinkPreviews(ctx context.Context, posts []models.Post) {
|
||||
if h.linkPreviewService == nil || len(posts) == 0 {
|
||||
return
|
||||
}
|
||||
ids := make([]string, len(posts))
|
||||
for i, p := range posts {
|
||||
ids[i] = p.ID.String()
|
||||
}
|
||||
previews, err := h.linkPreviewService.EnrichPostsWithLinkPreviews(ctx, ids)
|
||||
if err != nil || len(previews) == 0 {
|
||||
return
|
||||
}
|
||||
for i := range posts {
|
||||
if lp, ok := previews[posts[i].ID.String()]; ok {
|
||||
posts[i].LinkPreviewURL = &lp.URL
|
||||
posts[i].LinkPreviewTitle = &lp.Title
|
||||
posts[i].LinkPreviewDescription = &lp.Description
|
||||
posts[i].LinkPreviewImageURL = &lp.ImageURL
|
||||
posts[i].LinkPreviewSiteName = &lp.SiteName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// enrichSinglePostLinkPreview populates link_preview fields on a single post.
|
||||
func (h *PostHandler) enrichSinglePostLinkPreview(ctx context.Context, post *models.Post) {
|
||||
if h.linkPreviewService == nil || post == nil {
|
||||
return
|
||||
}
|
||||
previews, err := h.linkPreviewService.EnrichPostsWithLinkPreviews(ctx, []string{post.ID.String()})
|
||||
if err != nil || len(previews) == 0 {
|
||||
return
|
||||
}
|
||||
if lp, ok := previews[post.ID.String()]; ok {
|
||||
post.LinkPreviewURL = &lp.URL
|
||||
post.LinkPreviewTitle = &lp.Title
|
||||
post.LinkPreviewDescription = &lp.Description
|
||||
post.LinkPreviewImageURL = &lp.ImageURL
|
||||
post.LinkPreviewSiteName = &lp.SiteName
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -531,6 +575,24 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
|
|||
h.moderationService.LogAIDecision(c.Request.Context(), "post", post.ID, userID, req.Body, scores, nil, decision, flagReason, orDecision, nil)
|
||||
}
|
||||
|
||||
// Auto-extract link preview from post body (async — don't block response)
|
||||
if h.linkPreviewService != nil {
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
linkURL := services.ExtractFirstURL(req.Body)
|
||||
if linkURL != "" {
|
||||
// Check if author is an official account (trusted = skip safety checks)
|
||||
var isOfficial bool
|
||||
_ = h.postRepo.Pool().QueryRow(ctx, `SELECT COALESCE(is_official, false) FROM profiles WHERE id = $1`, userID).Scan(&isOfficial)
|
||||
|
||||
lp, err := h.linkPreviewService.FetchPreview(ctx, linkURL, isOfficial)
|
||||
if err == nil && lp != nil {
|
||||
_ = h.linkPreviewService.SaveLinkPreview(ctx, post.ID.String(), lp)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Check for @mentions and notify mentioned users
|
||||
go func() {
|
||||
if h.notificationService != nil && strings.Contains(req.Body, "@") {
|
||||
|
|
@ -569,6 +631,7 @@ func (h *PostHandler) GetFeed(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
h.enrichLinkPreviews(c.Request.Context(), posts)
|
||||
c.JSON(http.StatusOK, gin.H{"posts": posts})
|
||||
}
|
||||
|
||||
|
|
@ -602,6 +665,7 @@ func (h *PostHandler) GetProfilePosts(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
h.enrichLinkPreviews(c.Request.Context(), posts)
|
||||
c.JSON(http.StatusOK, gin.H{"posts": posts})
|
||||
}
|
||||
|
||||
|
|
@ -636,6 +700,7 @@ func (h *PostHandler) GetPost(c *gin.Context) {
|
|||
post.ThumbnailURL = &signed
|
||||
}
|
||||
|
||||
h.enrichSinglePostLinkPreview(c.Request.Context(), post)
|
||||
c.JSON(http.StatusOK, gin.H{"post": post})
|
||||
}
|
||||
|
||||
|
|
@ -863,6 +928,7 @@ func (h *PostHandler) GetSavedPosts(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
h.enrichLinkPreviews(c.Request.Context(), posts)
|
||||
c.JSON(http.StatusOK, gin.H{"posts": posts})
|
||||
}
|
||||
|
||||
|
|
@ -897,6 +963,7 @@ func (h *PostHandler) GetLikedPosts(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
h.enrichLinkPreviews(c.Request.Context(), posts)
|
||||
c.JSON(http.StatusOK, gin.H{"posts": posts})
|
||||
}
|
||||
|
||||
|
|
@ -933,6 +1000,7 @@ func (h *PostHandler) GetPostChain(c *gin.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
h.enrichLinkPreviews(c.Request.Context(), posts)
|
||||
c.JSON(http.StatusOK, gin.H{"posts": posts})
|
||||
}
|
||||
|
||||
|
|
@ -958,6 +1026,13 @@ func (h *PostHandler) GetPostFocusContext(c *gin.Context) {
|
|||
h.signPostMedia(&focusContext.Children[i])
|
||||
}
|
||||
|
||||
// Enrich link previews for all posts in focus context
|
||||
h.enrichSinglePostLinkPreview(c.Request.Context(), focusContext.TargetPost)
|
||||
h.enrichSinglePostLinkPreview(c.Request.Context(), focusContext.ParentPost)
|
||||
for i := range focusContext.Children {
|
||||
h.enrichSinglePostLinkPreview(c.Request.Context(), &focusContext.Children[i])
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, focusContext)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,13 @@ type Post struct {
|
|||
IsNSFW bool `json:"is_nsfw" db:"is_nsfw"`
|
||||
NSFWReason string `json:"nsfw_reason" db:"nsfw_reason"`
|
||||
ExpiresAt *time.Time `json:"expires_at" db:"expires_at"`
|
||||
|
||||
// Link preview (populated via enrichment, not in every query)
|
||||
LinkPreviewURL *string `json:"link_preview_url,omitempty" db:"link_preview_url"`
|
||||
LinkPreviewTitle *string `json:"link_preview_title,omitempty" db:"link_preview_title"`
|
||||
LinkPreviewDescription *string `json:"link_preview_description,omitempty" db:"link_preview_description"`
|
||||
LinkPreviewImageURL *string `json:"link_preview_image_url,omitempty" db:"link_preview_image_url"`
|
||||
LinkPreviewSiteName *string `json:"link_preview_site_name,omitempty" db:"link_preview_site_name"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
EditedAt *time.Time `json:"edited_at,omitempty" db:"edited_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty" db:"deleted_at"`
|
||||
|
|
|
|||
322
go-backend/internal/services/link_preview_service.go
Normal file
322
go-backend/internal/services/link_preview_service.go
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// LinkPreview represents the OG metadata extracted from a URL.
|
||||
type LinkPreview struct {
|
||||
URL string `json:"link_preview_url"`
|
||||
Title string `json:"link_preview_title"`
|
||||
Description string `json:"link_preview_description"`
|
||||
ImageURL string `json:"link_preview_image_url"`
|
||||
SiteName string `json:"link_preview_site_name"`
|
||||
}
|
||||
|
||||
// LinkPreviewService fetches and parses OpenGraph metadata from URLs.
|
||||
type LinkPreviewService struct {
|
||||
pool *pgxpool.Pool
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewLinkPreviewService(pool *pgxpool.Pool) *LinkPreviewService {
|
||||
return &LinkPreviewService{
|
||||
pool: pool,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 8 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// blockedIPRanges are private/internal IP ranges that untrusted URLs must not resolve to.
|
||||
var blockedIPRanges = []string{
|
||||
"127.0.0.0/8",
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"169.254.0.0/16",
|
||||
"::1/128",
|
||||
"fc00::/7",
|
||||
"fe80::/10",
|
||||
}
|
||||
|
||||
var blockedNets []*net.IPNet
|
||||
|
||||
func init() {
|
||||
for _, cidr := range blockedIPRanges {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err == nil {
|
||||
blockedNets = append(blockedNets, ipNet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isPrivateIP(ip net.IP) bool {
|
||||
for _, n := range blockedNets {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ExtractFirstURL finds the first http/https URL in a text string.
|
||||
func ExtractFirstURL(text string) string {
|
||||
re := regexp.MustCompile(`https?://[^\s<>"')\]]+`)
|
||||
match := re.FindString(text)
|
||||
// Clean trailing punctuation that's not part of the URL
|
||||
match = strings.TrimRight(match, ".,;:!?")
|
||||
return match
|
||||
}
|
||||
|
||||
// FetchPreview fetches OG metadata from a URL.
|
||||
// If trusted is false, performs safety checks (no internal IPs, domain validation).
|
||||
func (s *LinkPreviewService) FetchPreview(ctx context.Context, rawURL string, trusted bool) (*LinkPreview, error) {
|
||||
if rawURL == "" {
|
||||
return nil, fmt.Errorf("empty URL")
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, fmt.Errorf("unsupported scheme: %s", parsed.Scheme)
|
||||
}
|
||||
|
||||
// Safety checks for untrusted URLs
|
||||
if !trusted {
|
||||
if err := s.validateURL(parsed); err != nil {
|
||||
return nil, fmt.Errorf("unsafe URL: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Sojorn/1.0; +https://sojorn.net)")
|
||||
req.Header.Set("Accept", "text/html")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(ct, "text/html") && !strings.Contains(ct, "application/xhtml") {
|
||||
return nil, fmt.Errorf("not HTML: %s", ct)
|
||||
}
|
||||
|
||||
// Read max 1MB
|
||||
limited := io.LimitReader(resp.Body, 1*1024*1024)
|
||||
body, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
preview := s.parseOGTags(string(body), rawURL)
|
||||
if preview.Title == "" && preview.Description == "" && preview.ImageURL == "" {
|
||||
return nil, fmt.Errorf("no OG metadata found")
|
||||
}
|
||||
|
||||
preview.URL = rawURL
|
||||
if preview.SiteName == "" {
|
||||
preview.SiteName = parsed.Hostname()
|
||||
}
|
||||
|
||||
return preview, nil
|
||||
}
|
||||
|
||||
// validateURL checks that an untrusted URL doesn't point to internal resources.
|
||||
func (s *LinkPreviewService) validateURL(u *url.URL) error {
|
||||
host := u.Hostname()
|
||||
|
||||
// Block bare IPs for untrusted requests
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if isPrivateIP(ip) {
|
||||
return fmt.Errorf("private IP not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve DNS and check all IPs
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DNS lookup failed: %w", err)
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if isPrivateIP(ip) {
|
||||
return fmt.Errorf("resolves to private IP")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseOGTags extracts OpenGraph meta tags from raw HTML.
|
||||
func (s *LinkPreviewService) parseOGTags(html string, sourceURL string) *LinkPreview {
|
||||
preview := &LinkPreview{}
|
||||
|
||||
// Use regex to extract meta tags — lightweight, no dependency needed
|
||||
metaRe := regexp.MustCompile(`(?i)<meta\s+[^>]*>`)
|
||||
metas := metaRe.FindAllString(html, -1)
|
||||
|
||||
for _, tag := range metas {
|
||||
prop := extractAttr(tag, "property")
|
||||
if prop == "" {
|
||||
prop = extractAttr(tag, "name")
|
||||
}
|
||||
content := extractAttr(tag, "content")
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch strings.ToLower(prop) {
|
||||
case "og:title":
|
||||
if preview.Title == "" {
|
||||
preview.Title = content
|
||||
}
|
||||
case "og:description":
|
||||
if preview.Description == "" {
|
||||
preview.Description = content
|
||||
}
|
||||
case "og:image":
|
||||
if preview.ImageURL == "" {
|
||||
preview.ImageURL = resolveImageURL(content, sourceURL)
|
||||
}
|
||||
case "og:site_name":
|
||||
if preview.SiteName == "" {
|
||||
preview.SiteName = content
|
||||
}
|
||||
case "description":
|
||||
// Fallback if no og:description
|
||||
if preview.Description == "" {
|
||||
preview.Description = content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try <title> tag if no og:title
|
||||
if preview.Title == "" {
|
||||
titleRe := regexp.MustCompile(`(?i)<title[^>]*>(.*?)</title>`)
|
||||
if m := titleRe.FindStringSubmatch(html); len(m) > 1 {
|
||||
preview.Title = strings.TrimSpace(m[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate long fields
|
||||
if len(preview.Title) > 300 {
|
||||
preview.Title = preview.Title[:300]
|
||||
}
|
||||
if len(preview.Description) > 500 {
|
||||
preview.Description = preview.Description[:500]
|
||||
}
|
||||
|
||||
return preview
|
||||
}
|
||||
|
||||
// extractAttr pulls a named attribute value from a raw HTML tag string.
|
||||
func extractAttr(tag string, name string) string {
|
||||
// Match name="value" or name='value'
|
||||
re := regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(name) + `\s*=\s*["']([^"']*?)["']`)
|
||||
m := re.FindStringSubmatch(tag)
|
||||
if len(m) > 1 {
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveImageURL makes relative image URLs absolute.
|
||||
func resolveImageURL(imgURL string, sourceURL string) string {
|
||||
if strings.HasPrefix(imgURL, "http://") || strings.HasPrefix(imgURL, "https://") {
|
||||
return imgURL
|
||||
}
|
||||
base, err := url.Parse(sourceURL)
|
||||
if err != nil {
|
||||
return imgURL
|
||||
}
|
||||
ref, err := url.Parse(imgURL)
|
||||
if err != nil {
|
||||
return imgURL
|
||||
}
|
||||
return base.ResolveReference(ref).String()
|
||||
}
|
||||
|
||||
// EnrichPostsWithLinkPreviews does a batch query to populate link_preview fields
|
||||
// on a slice of posts. This avoids modifying every existing SELECT query.
|
||||
func (s *LinkPreviewService) EnrichPostsWithLinkPreviews(ctx context.Context, postIDs []string) (map[string]*LinkPreview, error) {
|
||||
if len(postIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT id::text, link_preview_url, link_preview_title,
|
||||
link_preview_description, link_preview_image_url, link_preview_site_name
|
||||
FROM public.posts
|
||||
WHERE id = ANY($1::uuid[]) AND link_preview_url IS NOT NULL AND link_preview_url != ''
|
||||
`
|
||||
rows, err := s.pool.Query(ctx, query, postIDs)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to fetch link previews for posts")
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]*LinkPreview)
|
||||
for rows.Next() {
|
||||
var postID string
|
||||
var lp LinkPreview
|
||||
var title, desc, imgURL, siteName *string
|
||||
if err := rows.Scan(&postID, &lp.URL, &title, &desc, &imgURL, &siteName); err != nil {
|
||||
continue
|
||||
}
|
||||
if title != nil {
|
||||
lp.Title = *title
|
||||
}
|
||||
if desc != nil {
|
||||
lp.Description = *desc
|
||||
}
|
||||
if imgURL != nil {
|
||||
lp.ImageURL = *imgURL
|
||||
}
|
||||
if siteName != nil {
|
||||
lp.SiteName = *siteName
|
||||
}
|
||||
result[postID] = &lp
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SaveLinkPreview stores the link preview data for a post.
|
||||
func (s *LinkPreviewService) SaveLinkPreview(ctx context.Context, postID string, lp *LinkPreview) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE public.posts
|
||||
SET link_preview_url = $2, link_preview_title = $3, link_preview_description = $4,
|
||||
link_preview_image_url = $5, link_preview_site_name = $6
|
||||
WHERE id = $1
|
||||
`, postID, lp.URL, lp.Title, lp.Description, lp.ImageURL, lp.SiteName)
|
||||
return err
|
||||
}
|
||||
|
|
@ -419,14 +419,8 @@ func (s *OfficialAccountsService) CreatePostForAccount(ctx context.Context, conf
|
|||
}
|
||||
}
|
||||
|
||||
// Get user_id from profile_id
|
||||
var authorID string
|
||||
err = s.pool.QueryRow(ctx, `SELECT user_id FROM public.profiles WHERE id = $1`, cfg.ProfileID).Scan(&authorID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get user_id for profile: %w", err)
|
||||
}
|
||||
|
||||
authorUUID, _ := uuid.Parse(authorID)
|
||||
// profile_id IS the author_id (profiles.id = users.id in this schema)
|
||||
authorUUID, _ := uuid.Parse(cfg.ProfileID)
|
||||
postID := uuid.New()
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
|
|
@ -476,6 +470,23 @@ func (s *OfficialAccountsService) CreatePostForAccount(ctx context.Context, conf
|
|||
return "", err
|
||||
}
|
||||
|
||||
// Fetch and store link preview for posts with URLs (trusted — official account)
|
||||
go func() {
|
||||
bgCtx := context.Background()
|
||||
linkURL := ExtractFirstURL(body)
|
||||
if linkURL == "" && article != nil {
|
||||
linkURL = article.Link
|
||||
}
|
||||
if linkURL != "" {
|
||||
lps := NewLinkPreviewService(s.pool)
|
||||
lp, err := lps.FetchPreview(bgCtx, linkURL, true)
|
||||
if err == nil && lp != nil {
|
||||
_ = lps.SaveLinkPreview(bgCtx, postID.String(), lp)
|
||||
log.Debug().Str("post_id", postID.String()).Str("url", linkURL).Msg("Saved link preview for official account post")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return postID.String(), nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -91,6 +91,15 @@ class Post {
|
|||
final bool isNsfw;
|
||||
final String? nsfwReason;
|
||||
|
||||
// Link preview (OG metadata)
|
||||
final String? linkPreviewUrl;
|
||||
final String? linkPreviewTitle;
|
||||
final String? linkPreviewDescription;
|
||||
final String? linkPreviewImageUrl;
|
||||
final String? linkPreviewSiteName;
|
||||
|
||||
bool get hasLinkPreview => linkPreviewUrl != null && linkPreviewUrl!.isNotEmpty;
|
||||
|
||||
Post({
|
||||
required this.id,
|
||||
required this.authorId,
|
||||
|
|
@ -140,6 +149,11 @@ class Post {
|
|||
this.ctaText,
|
||||
this.isNsfw = false,
|
||||
this.nsfwReason,
|
||||
this.linkPreviewUrl,
|
||||
this.linkPreviewTitle,
|
||||
this.linkPreviewDescription,
|
||||
this.linkPreviewImageUrl,
|
||||
this.linkPreviewSiteName,
|
||||
});
|
||||
|
||||
static int? _parseInt(dynamic value) {
|
||||
|
|
@ -283,6 +297,11 @@ class Post {
|
|||
ctaText: json['advertiser_cta_text'] as String?,
|
||||
isNsfw: json['is_nsfw'] as bool? ?? false,
|
||||
nsfwReason: json['nsfw_reason'] as String?,
|
||||
linkPreviewUrl: json['link_preview_url'] as String?,
|
||||
linkPreviewTitle: json['link_preview_title'] as String?,
|
||||
linkPreviewDescription: json['link_preview_description'] as String?,
|
||||
linkPreviewImageUrl: json['link_preview_image_url'] as String?,
|
||||
linkPreviewSiteName: json['link_preview_site_name'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -333,6 +352,11 @@ class Post {
|
|||
'reaction_users': reactionUsers,
|
||||
'is_nsfw': isNsfw,
|
||||
'nsfw_reason': nsfwReason,
|
||||
'link_preview_url': linkPreviewUrl,
|
||||
'link_preview_title': linkPreviewTitle,
|
||||
'link_preview_description': linkPreviewDescription,
|
||||
'link_preview_image_url': linkPreviewImageUrl,
|
||||
'link_preview_site_name': linkPreviewSiteName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
159
sojorn_app/lib/widgets/post/post_link_preview.dart
Normal file
159
sojorn_app/lib/widgets/post/post_link_preview.dart
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../models/post.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import 'post_view_mode.dart';
|
||||
|
||||
/// Full-width link preview card shown below the post body.
|
||||
/// Displays OG image as a full-width thumbnail (same sizing as post images),
|
||||
/// with title, description, and site name overlaid/below.
|
||||
class PostLinkPreview extends StatelessWidget {
|
||||
final Post post;
|
||||
final PostViewMode mode;
|
||||
|
||||
const PostLinkPreview({
|
||||
super.key,
|
||||
required this.post,
|
||||
this.mode = PostViewMode.feed,
|
||||
});
|
||||
|
||||
double get _imageHeight {
|
||||
switch (mode) {
|
||||
case PostViewMode.feed:
|
||||
return 220.0;
|
||||
case PostViewMode.detail:
|
||||
return 280.0;
|
||||
case PostViewMode.compact:
|
||||
return 160.0;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!post.hasLinkPreview) return const SizedBox.shrink();
|
||||
|
||||
final hasImage = post.linkPreviewImageUrl != null &&
|
||||
post.linkPreviewImageUrl!.isNotEmpty;
|
||||
final title = post.linkPreviewTitle ?? '';
|
||||
final description = post.linkPreviewDescription ?? '';
|
||||
final siteName = post.linkPreviewSiteName ?? '';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: GestureDetector(
|
||||
onTap: () => _launchUrl(post.linkPreviewUrl!),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||
border: Border.all(
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.15),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Full-width thumbnail image
|
||||
if (hasImage)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: _imageHeight,
|
||||
child: Image.network(
|
||||
post.linkPreviewImageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Container(
|
||||
color: AppTheme.queenPink.withValues(alpha: 0.15),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.08),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.link_rounded,
|
||||
size: 32,
|
||||
color: AppTheme.textTertiary,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Title + description + site name
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.04),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Site name
|
||||
if (siteName.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
siteName.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8,
|
||||
color: AppTheme.textTertiary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
// Title
|
||||
if (title.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppTheme.textPrimary,
|
||||
height: 1.3,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
// Description
|
||||
if (description.isNotEmpty)
|
||||
Text(
|
||||
description,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
height: 1.4,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _launchUrl(String url) async {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri != null && await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import '../theme/app_theme.dart';
|
|||
import 'post/post_actions.dart';
|
||||
import 'post/post_body.dart';
|
||||
import 'post/post_header.dart';
|
||||
import 'post/post_link_preview.dart';
|
||||
import 'post/post_media.dart';
|
||||
import 'post/post_menu.dart';
|
||||
import 'post/post_view_mode.dart';
|
||||
|
|
@ -292,6 +293,16 @@ class _sojornPostCardState extends ConsumerState<sojornPostCard> {
|
|||
],
|
||||
],
|
||||
|
||||
// Link preview card (if post has OG metadata and no image/video)
|
||||
if (post.hasLinkPreview &&
|
||||
(post.imageUrl == null || post.imageUrl!.isEmpty) &&
|
||||
(post.videoUrl == null || post.videoUrl!.isEmpty)) ...[
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: _padding.left),
|
||||
child: PostLinkPreview(post: post, mode: mode),
|
||||
),
|
||||
],
|
||||
|
||||
// NSFW warning banner with tap-to-reveal
|
||||
if (_shouldBlurNsfw) ...[
|
||||
GestureDetector(
|
||||
|
|
|
|||
Loading…
Reference in a new issue