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)