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 {