From 99d1c053390b4d4f11ff18a8f0fa1b3b6a8dee9e Mon Sep 17 00:00:00 2001 From: WittF Date: Thu, 22 Jan 2026 11:12:44 +0000 Subject: [PATCH] feat(share): add Open Graph support for social media previews - Detect social media bots (Facebook, Twitter, Discord, etc.) - Render OG meta tags with file name, size, and owner info - Auto-redirect to share page after preview is rendered --- routers/controllers/share.go | 47 +++++++++++++++++++++- service/share/og_template.go | 77 ++++++++++++++++++++++++++++++++++++ service/share/visit.go | 63 +++++++++++++++++++++++++++++ 3 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 service/share/og_template.go diff --git a/routers/controllers/share.go b/routers/controllers/share.go index 3aa549ca..eda524c2 100644 --- a/routers/controllers/share.go +++ b/routers/controllers/share.go @@ -1,11 +1,13 @@ package controllers import ( + "net/http" + "strings" + "github.com/cloudreve/Cloudreve/v4/pkg/hashid" "github.com/cloudreve/Cloudreve/v4/pkg/serializer" "github.com/cloudreve/Cloudreve/v4/service/share" "github.com/gin-gonic/gin" - "net/http" ) // CreateShare 创建分享 @@ -74,5 +76,48 @@ func DeleteShare(c *gin.Context) { func ShareRedirect(c *gin.Context) { service := ParametersFromContext[*share.ShortLinkRedirectService](c, share.ShortLinkRedirectParamCtx{}) + + // Check if the request is from a social media bot + if isSocialMediaBot(c.GetHeader("User-Agent")) { + html, err := service.RenderOGPage(c) + if err == nil { + c.Header("Content-Type", "text/html; charset=utf-8") + c.String(http.StatusOK, html) + return + } + // Fall back to redirect on error + } + c.Redirect(http.StatusFound, service.RedirectTo(c)) } + +// isSocialMediaBot checks if the User-Agent belongs to a social media crawler +func isSocialMediaBot(ua string) bool { + bots := []string{ + "facebookexternalhit", + "twitterbot", + "linkedinbot", + "whatsapp", + "slackbot", + "discordbot", + "telegrambot", + "applebot", + "pinterest", + "googlebot", + "bingbot", + "yandexbot", + "duckduckbot", + "baiduspider", + "sogou", + "exabot", + "facebot", + "ia_archiver", + } + ua = strings.ToLower(ua) + for _, bot := range bots { + if strings.Contains(ua, bot) { + return true + } + } + return false +} diff --git a/service/share/og_template.go b/service/share/og_template.go new file mode 100644 index 00000000..e55eed08 --- /dev/null +++ b/service/share/og_template.go @@ -0,0 +1,77 @@ +package share + +import ( + "bytes" + "fmt" + "html/template" +) + +// ShareOGData contains data for rendering Open Graph HTML page +type ShareOGData struct { + SiteName string + FileName string + FileSize string + OwnerName string + ShareURL string + ThumbnailURL string + RedirectURL string +} + +const ogHTMLTemplate = ` + + + + + + + + + + + + + + {{.FileName}} - {{.SiteName}} + + + + + +` + +var ogTemplate = template.Must(template.New("og").Parse(ogHTMLTemplate)) + +// RenderOGHTML renders the Open Graph HTML page +func RenderOGHTML(data *ShareOGData) (string, error) { + var buf bytes.Buffer + if err := ogTemplate.Execute(&buf, data); err != nil { + return "", fmt.Errorf("failed to render OG template: %w", err) + } + return buf.String(), nil +} + +// FormatFileSize formats file size to human readable format +func FormatFileSize(size int64) string { + const ( + B = 1 + KB = 1024 * B + MB = 1024 * KB + GB = 1024 * MB + TB = 1024 * GB + ) + + switch { + case size >= TB: + return fmt.Sprintf("%.2f TB", float64(size)/TB) + case size >= GB: + return fmt.Sprintf("%.2f GB", float64(size)/GB) + case size >= MB: + return fmt.Sprintf("%.2f MB", float64(size)/MB) + case size >= KB: + return fmt.Sprintf("%.2f KB", float64(size)/KB) + default: + return fmt.Sprintf("%d B", size) + } +} diff --git a/service/share/visit.go b/service/share/visit.go index c47f10fb..ee69fded 100644 --- a/service/share/visit.go +++ b/service/share/visit.go @@ -2,6 +2,7 @@ package share import ( "context" + "net/url" "strings" "github.com/cloudreve/Cloudreve/v4/application/dependency" @@ -48,6 +49,68 @@ func (s *ShortLinkRedirectService) RedirectTo(c *gin.Context) string { return shareLongUrl.String() } +// RenderOGPage renders an Open Graph HTML page for social media previews +func (s *ShortLinkRedirectService) RenderOGPage(c *gin.Context) (string, error) { + dep := dependency.FromContext(c) + shareClient := dep.ShareClient() + settings := dep.SettingProvider() + + // Load share with user and file info + ctx := context.WithValue(c, inventory.LoadShareUser{}, true) + ctx = context.WithValue(ctx, inventory.LoadShareFile{}, true) + share, err := shareClient.GetByHashID(ctx, s.ID) + if err != nil { + return "", err + } + + // Check if share is valid + if err := inventory.IsValidShare(share); err != nil { + return "", err + } + + // Get site settings + siteBasic := settings.SiteBasic(c) + pwa := settings.PWA(c) + base := settings.SiteURL(c) + + // Build share URL + shareURL := routes.MasterShareUrl(base, s.ID, "") + + // Build thumbnail URL - use PWA large icon (PNG), fallback to medium icon + thumbnailURL := pwa.LargeIcon + if thumbnailURL == "" { + thumbnailURL = pwa.MediumIcon + } + if thumbnailURL != "" && !isAbsoluteURL(thumbnailURL) { + thumbnailURL = base.ResolveReference(&url.URL{Path: thumbnailURL}).String() + } + + // Get file info + fileName := share.Edges.File.Name + fileSize := FormatFileSize(share.Edges.File.Size) + + // Get owner name (don't expose email for privacy) + ownerName := share.Edges.User.Nick + + // Prepare OG data + ogData := &ShareOGData{ + SiteName: siteBasic.Name, + FileName: fileName, + FileSize: fileSize, + OwnerName: ownerName, + ShareURL: shareURL.String(), + ThumbnailURL: thumbnailURL, + RedirectURL: s.RedirectTo(c), + } + + return RenderOGHTML(ogData) +} + +// isAbsoluteURL checks if the URL is absolute (starts with http:// or https://) +func isAbsoluteURL(u string) bool { + return strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") +} + type ( ShareInfoService struct { Password string `form:"password"`