From 572859fd1fa5f586be99bf503765f256bd73e075 Mon Sep 17 00:00:00 2001 From: WittF Date: Sat, 24 Jan 2026 20:10:35 +0000 Subject: [PATCH] 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. --- inventory/share.go | 8 ++ middleware/share_preview.go | 255 ++++++++++++++++++++++++++++++++++++ routers/router.go | 1 + 3 files changed, 264 insertions(+) create mode 100644 middleware/share_preview.go diff --git a/inventory/share.go b/inventory/share.go index 3f18b40c..d6928bd4 100644 --- a/inventory/share.go +++ b/inventory/share.go @@ -23,6 +23,14 @@ type ( LoadShareUser struct{} ) +// WithShareEagerLoad returns a context with share eager loading options enabled. +// This loads the share's associated user and file when querying. +func WithShareEagerLoad(ctx context.Context) context.Context { + ctx = context.WithValue(ctx, LoadShareUser{}, true) + ctx = context.WithValue(ctx, LoadShareFile{}, true) + return ctx +} + var ( ErrShareLinkExpired = fmt.Errorf("share link expired") ErrOwnerInactive = fmt.Errorf("owner is inactive") diff --git a/middleware/share_preview.go b/middleware/share_preview.go new file mode 100644 index 00000000..a49b1ef1 --- /dev/null +++ b/middleware/share_preview.go @@ -0,0 +1,255 @@ +package middleware + +import ( + "bytes" + "crypto/subtle" + "fmt" + "html/template" + "net/url" + "strings" + + "github.com/cloudreve/Cloudreve/v4/application/dependency" + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/inventory" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes" + "github.com/cloudreve/Cloudreve/v4/pkg/hashid" + "github.com/gin-gonic/gin" +) + +const ( + ogStatusInvalidLink = "Invalid Link" + ogStatusShareUnavailable = "Share Unavailable" + ogStatusPasswordRequired = "Password Required" + ogStatusNeedLogin = "Login Required" +) + +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") + if strings.HasPrefix(rawPath, "cloudreve://") { + u, err := url.Parse(rawPath) + if err != nil { + return "", "" + } + if strings.ToLower(u.Host) != "share" || u.User == nil { + return "", "" + } + id = u.User.Username() + password, _ = u.User.Password() + } + } + + if len(id) > 32 || len(password) > 32 { + return "", "" + } + 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, "").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) + } + + share, err := loadShareForOG(c, dep, shareID) + if err != nil { + if ent.IsNotFound(err) { + data.Description = ogStatusInvalidLink + } else { + data.Description = ogStatusShareUnavailable + } + return renderOGHTML(data) + } + + if err := inventory.IsValidShare(share); err != nil { + data.Description = ogStatusShareUnavailable + return renderOGHTML(data) + } + + if !canAnonymousAccessShare(c, dep) { + data.Description = ogStatusNeedLogin + return renderOGHTML(data) + } + + if share.Password != "" && subtle.ConstantTimeCompare([]byte(share.Password), []byte(password)) != 1 { + data.Description = ogStatusPasswordRequired + return renderOGHTML(data) + } + + targetFile := share.Edges.File + if targetFile != nil { + fileName := targetFile.Name + fileType := types.FileType(targetFile.Type) + + data.Title = fileName + if fileType == types.FileTypeFolder { + data.Description = "Folder" + } else { + data.Description = formatFileSize(targetFile.Size) + } + + if share.Edges.User != nil && share.Edges.User.Nick != "" { + data.Description += " · " + share.Edges.User.Nick + } + } + + return renderOGHTML(data) +} + +func loadShareForOG(c *gin.Context, dep dependency.Dep, shareID int) (*ent.Share, error) { + ctx := inventory.WithShareEagerLoad(c) + return dep.ShareClient().GetByID(ctx, shareID) +} + +func canAnonymousAccessShare(c *gin.Context, dep dependency.Dep) bool { + anonymousGroup, err := dep.GroupClient().AnonymousGroup(c) + if err != nil { + return false + } + return anonymousGroup.Permissions.Enabled(int(types.GroupPermissionShareDownload)) +} + +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)