From e4e6beb52dd066524727865b7594e1f376b4689f Mon Sep 17 00:00:00 2001 From: WittF Date: Sun, 25 Jan 2026 12:48:14 +0800 Subject: [PATCH] feat(share): add Open Graph preview for social media crawlers (#3234) * feat(share): add Open Graph preview for social media crawlers Add middleware to intercept social media bot requests and return OG meta tags for share links, enabling rich previews on platforms like Facebook, Twitter, Discord, etc. * feat(og): reuse existing share service and show thumbnail if possible * resolve comments --------- Co-authored-by: Aaron Liu --- inventory/share.go | 3 +- middleware/share_preview.go | 252 +++++++++++++++++++++++++++++++++++ routers/router.go | 1 + service/explorer/response.go | 5 + 4 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 middleware/share_preview.go diff --git a/inventory/share.go b/inventory/share.go index 3f18b40c..642ce21c 100644 --- a/inventory/share.go +++ b/inventory/share.go @@ -3,9 +3,10 @@ package inventory import ( "context" "fmt" - "github.com/cloudreve/Cloudreve/v4/inventory/types" "time" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "entgo.io/ent/dialect/sql" "github.com/cloudreve/Cloudreve/v4/ent" "github.com/cloudreve/Cloudreve/v4/ent/file" diff --git a/middleware/share_preview.go b/middleware/share_preview.go new file mode 100644 index 00000000..1537dfff --- /dev/null +++ b/middleware/share_preview.go @@ -0,0 +1,252 @@ +package middleware + +import ( + "bytes" + "errors" + "fmt" + "html/template" + "net/url" + "strings" + + "github.com/cloudreve/Cloudreve/v4/application/constants" + "github.com/cloudreve/Cloudreve/v4/application/dependency" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes" + "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs" + "github.com/cloudreve/Cloudreve/v4/pkg/hashid" + "github.com/cloudreve/Cloudreve/v4/pkg/serializer" + "github.com/cloudreve/Cloudreve/v4/pkg/util" + "github.com/cloudreve/Cloudreve/v4/service/explorer" + "github.com/cloudreve/Cloudreve/v4/service/share" + "github.com/gin-gonic/gin" +) + +const ( + ogStatusInvalidLink = "Invalid Link" +) + +type ogData struct { + SiteName string + Title string + Description string + ImageURL string + ShareURL string + RedirectURL string +} + +const ogHTMLTemplate = ` + + + + + + + + + + + + + + {{.Title}} - {{.SiteName}} + + + + + +` + +var ogTemplate = template.Must(template.New("og").Parse(ogHTMLTemplate)) + +var socialMediaBots = []string{ + "facebookexternalhit", + "facebookcatalog", + "facebot", + "twitterbot", + "linkedinbot", + "discordbot", + "telegrambot", + "slackbot", + "whatsapp", +} + +func isSocialMediaBot(ua string) bool { + ua = strings.ToLower(ua) + for _, bot := range socialMediaBots { + if strings.Contains(ua, bot) { + return true + } + } + return false +} + +// SharePreview 为社交媒体爬虫渲染OG预览页面 +func SharePreview(dep dependency.Dep) gin.HandlerFunc { + return func(c *gin.Context) { + if !isSocialMediaBot(c.GetHeader("User-Agent")) { + c.Next() + return + } + + id, password := extractShareParams(c) + if id == "" { + c.Next() + return + } + + html := renderShareOGPage(c, dep, id, password) + c.Header("Content-Type", "text/html; charset=utf-8") + c.Header("Cache-Control", "public, no-cache") + c.String(200, html) + c.Abort() + } +} + +func extractShareParams(c *gin.Context) (id, password string) { + urlPath := c.Request.URL.Path + + if strings.HasPrefix(urlPath, "/s/") { + parts := strings.Split(strings.TrimPrefix(urlPath, "/s/"), "/") + if len(parts) >= 1 && parts[0] != "" { + id = parts[0] + if len(parts) >= 2 { + password = parts[1] + } + } + } else if urlPath == "/home" || urlPath == "/home/" { + rawPath := c.Query("path") + uri, err := fs.NewUriFromString(rawPath) + if err != nil || uri.FileSystem() != constants.FileSystemShare { + return "", "" + } + + return uri.ID(""), uri.Password() + } + + return id, password +} + +func renderShareOGPage(c *gin.Context, dep dependency.Dep, id, password string) string { + settings := dep.SettingProvider() + siteBasic := settings.SiteBasic(c) + pwa := settings.PWA(c) + base := settings.SiteURL(c) + + data := &ogData{ + SiteName: siteBasic.Name, + Title: siteBasic.Name, + Description: siteBasic.Description, + ShareURL: routes.MasterShareUrl(base, id, password).String(), + RedirectURL: routes.MasterShareLongUrl(id, password).String(), + } + + if pwa.LargeIcon != "" { + data.ImageURL = resolveURL(base, pwa.LargeIcon) + } else if pwa.MediumIcon != "" { + data.ImageURL = resolveURL(base, pwa.MediumIcon) + } + + shareID, err := dep.HashIDEncoder().Decode(id, hashid.ShareID) + if err != nil { + data.Description = ogStatusInvalidLink + return renderOGHTML(data) + } + + shareInfo, err := loadShareForOG(c, shareID, password) + if err != nil { + var appErr serializer.AppError + if errors.As(err, &appErr) { + data.Description = appErr.Msg + } else { + data.Description = ogStatusInvalidLink + } + return renderOGHTML(data) + } + + data.Title = shareInfo.Name + if shareInfo.SourceType != nil && *shareInfo.SourceType == types.FileTypeFolder { + data.Description = "Folder" + } else if shareInfo.Unlocked { + data.Description = formatFileSize(shareInfo.Size) + thumbnail, err := loadShareThumbnail(c, id, password, shareInfo) + if err == nil { + data.ImageURL = thumbnail + } + } + + data.Description += " · " + shareInfo.Owner.Nickname + return renderOGHTML(data) +} + +func loadShareThumbnail(c *gin.Context, shareID, password string, shareInfo *explorer.Share) (string, error) { + shareUri, err := fs.NewUriFromString(fs.NewShareUri(shareID, password)) + if err != nil { + return "", fmt.Errorf("failed to construct share uri: %w", err) + } + + subService := &explorer.FileThumbService{ + Uri: shareUri.Join(shareInfo.Name).String(), + } + + if err := SetUserCtx(c, 0); err != nil { + return "", err + } + + res, err := subService.Get(c) + if err != nil { + return "", err + } + + return res.Url, nil +} + +func loadShareForOG(c *gin.Context, shareID int, password string) (*explorer.Share, error) { + subService := &share.ShareInfoService{ + Password: password, + CountViews: false, + } + + if err := SetUserCtx(c, 0); err != nil { + return nil, err + } + + util.WithValue(c, hashid.ObjectIDCtx{}, shareID) + return subService.Get(c) +} + +func renderOGHTML(data *ogData) string { + var buf bytes.Buffer + if err := ogTemplate.Execute(&buf, data); err != nil { + return "" + } + return buf.String() +} + +func resolveURL(base *url.URL, path string) string { + if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { + return path + } + return base.ResolveReference(&url.URL{Path: path}).String() +} + +func formatFileSize(size int64) string { + const ( + KB = 1024 + 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/routers/router.go b/routers/router.go index 54e4bd00..6e87b0b4 100644 --- a/routers/router.go +++ b/routers/router.go @@ -208,6 +208,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { 静态资源 */ r.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{"/api/"}))) + r.Use(middleware.SharePreview(dep)) r.Use(middleware.FrontendFileHandler(dep)) r.GET("manifest.json", controllers.Manifest) diff --git a/service/explorer/response.go b/service/explorer/response.go index 7049d4c8..d3887924 100644 --- a/service/explorer/response.go +++ b/service/explorer/response.go @@ -310,6 +310,7 @@ type Share struct { Expired bool `json:"expired"` Url string `json:"url"` ShowReadMe bool `json:"show_readme,omitempty"` + Size int64 `json:"size"` // Only viewable by owner IsPrivate bool `json:"is_private,omitempty"` @@ -345,6 +346,10 @@ func BuildShare(s *ent.Share, base *url.URL, hasher hashid.Encoder, requester *e res.Expires = s.Expires res.Password = s.Password res.ShowReadMe = s.Props != nil && s.Props.ShowReadMe + + if t == types.FileTypeFile && s.Edges.File != nil { + res.Size = s.Edges.File.Size + } } if requester.ID == owner.ID {