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)