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 e78a1d19..cf364b80 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 {