diff --git a/pkg/share/share.go b/pkg/share/share.go new file mode 100644 index 00000000..5c7f7f9f --- /dev/null +++ b/pkg/share/share.go @@ -0,0 +1,87 @@ +package share + +import ( + "context" + "net/url" + "path" + "strings" + + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/inventory" + "github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes" +) + +type LoadStatus int + +const ( + LoadOK LoadStatus = iota + LoadNotFound + LoadExpired + LoadError +) + +// LoadShareForInfo loads share info for public preview/metadata access. +func LoadShareForInfo(ctx context.Context, shareClient inventory.ShareClient, shareID int, viewer *ent.User, password string) (*ent.Share, bool, LoadStatus, error) { + ctx = context.WithValue(ctx, inventory.LoadShareUser{}, true) + ctx = context.WithValue(ctx, inventory.LoadShareFile{}, true) + share, err := shareClient.GetByID(ctx, shareID) + if err != nil { + if ent.IsNotFound(err) { + return nil, false, LoadNotFound, nil + } + return nil, false, LoadError, err + } + + if err := inventory.IsValidShare(share); err != nil { + return share, false, LoadExpired, err + } + + unlocked := isShareUnlocked(share, password, viewer) + return share, unlocked, LoadOK, nil +} + +// BuildRedirectURL builds the long share URL and merges query params safely. +func BuildRedirectURL(id, password, sharePath string, extraQuery url.Values) string { + sharePath = SanitizeSharePath(sharePath) + shareLongURL := routes.MasterShareLongUrl(id, password) + shareLongURLQuery := shareLongURL.Query() + + if sharePath != "" { + masterPath := shareLongURLQuery.Get("path") + masterPath += "/" + strings.TrimPrefix(sharePath, "/") + shareLongURLQuery.Set("path", masterPath) + } + + for key, vals := range extraQuery { + if key == "path" { + continue + } + shareLongURLQuery[key] = append(shareLongURLQuery[key], vals...) + } + + shareLongURL.RawQuery = shareLongURLQuery.Encode() + return shareLongURL.String() +} + +// SanitizeSharePath normalizes a share path for use in URLs. +func SanitizeSharePath(raw string) string { + if raw == "" { + return "" + } + + cleaned := path.Clean("/" + raw) + return strings.TrimPrefix(cleaned, "/") +} + +func isShareUnlocked(share *ent.Share, password string, viewer *ent.User) bool { + if share.Password == "" { + return true + } + if password == share.Password { + return true + } + if viewer != nil && share.Edges.User != nil && share.Edges.User.ID == viewer.ID { + return true + } + return false +} diff --git a/service/share/og_template.go b/pkg/sharepreview/og_template.go similarity index 99% rename from service/share/og_template.go rename to pkg/sharepreview/og_template.go index 266b3762..873813b4 100644 --- a/service/share/og_template.go +++ b/pkg/sharepreview/og_template.go @@ -1,4 +1,4 @@ -package share +package sharepreview import ( "bytes" diff --git a/pkg/sharepreview/preview.go b/pkg/sharepreview/preview.go new file mode 100644 index 00000000..082ad9f5 --- /dev/null +++ b/pkg/sharepreview/preview.go @@ -0,0 +1,189 @@ +package sharepreview + +import ( + "context" + "net/url" + "path" + "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/filemanager/fs" + "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager" + "github.com/cloudreve/Cloudreve/v4/pkg/hashid" + sharepkg "github.com/cloudreve/Cloudreve/v4/pkg/share" + "github.com/gin-gonic/gin" +) + +const ( + ogStatusInvalidLink = "Invalid Link" + ogStatusShareExpired = "Share Expired" + ogStatusPasswordRequired = "Password Required" + ogDefaultFileName = "Shared File" + ogDefaultFolderName = "Shared Folder" +) + +type Options struct { + ID string + Password string + SharePath string +} + +// RenderOGPage renders an Open Graph HTML page for social media previews. +func RenderOGPage(c *gin.Context, opts Options) (string, error) { + dep := dependency.FromContext(c) + shareClient := dep.ShareClient() + settings := dep.SettingProvider() + u := inventory.UserFromContext(c) + + // Check if anonymous users have permission to access share links. + anonymousGroup, err := dep.GroupClient().AnonymousGroup(c) + needLogin := err != nil || anonymousGroup.Permissions == nil || !anonymousGroup.Permissions.Enabled(int(types.GroupPermissionShareDownload)) + + // Get site settings. + siteBasic := settings.SiteBasic(c) + pwa := settings.PWA(c) + base := settings.SiteURL(c) + + sharePath := sharepkg.SanitizeSharePath(opts.SharePath) + + // Build share URL. + shareURL := routes.MasterShareUrl(base, opts.ID, "") + if sharePath != "" { + shareURLQuery := shareURL.Query() + shareURLQuery.Set("path", sharePath) + shareURL.RawQuery = shareURLQuery.Encode() + } + + // Build thumbnail URL - use PWA large icon (PNG), fallback to medium icon. + thumbnailURL := pwa.LargeIcon + if thumbnailURL == "" { + thumbnailURL = pwa.MediumIcon + } + if thumbnailURL != "" && !isAbsoluteURL(thumbnailURL) { + thumbnailURL = base.ResolveReference(&url.URL{Path: thumbnailURL}).String() + } + + // Prepare OG data. + ogData := &ShareOGData{ + SiteName: siteBasic.Name, + SiteDescription: siteBasic.Description, + SiteURL: base.String(), + ShareURL: shareURL.String(), + ShareID: opts.ID, + ThumbnailURL: thumbnailURL, + RedirectURL: sharepkg.BuildRedirectURL(opts.ID, opts.Password, sharePath, c.Request.URL.Query()), + } + + // Get file info (hide for invalid/expired/password-protected/login-required shares). + shareID, err := dep.HashIDEncoder().Decode(opts.ID, hashid.ShareID) + if err != nil { + return renderStatusOG(ogData, siteBasic.Name, ogStatusInvalidLink) + } + + share, unlocked, status, _ := sharepkg.LoadShareForInfo(c, shareClient, shareID, u, opts.Password) + switch status { + case sharepkg.LoadNotFound: + return renderStatusOG(ogData, siteBasic.Name, ogStatusInvalidLink) + case sharepkg.LoadExpired: + return renderStatusOG(ogData, siteBasic.Name, ogStatusShareExpired) + case sharepkg.LoadError: + return renderStatusOG(ogData, siteBasic.Name, ogStatusInvalidLink) + } + + if needLogin { + return renderStatusOG(ogData, siteBasic.Name, ogStatusShareExpired) + } + + if share.Password != "" && !unlocked { + // Password required but not provided or incorrect. + return renderStatusOG(ogData, siteBasic.Name, ogStatusPasswordRequired) + } + + if share.Edges.File == nil { + return renderStatusOG(ogData, siteBasic.Name, ogStatusInvalidLink) + } + + // Get owner name (don't expose email for privacy). + if share.Edges.User != nil { + ogData.OwnerName = share.Edges.User.Nick + } + + fileType := types.FileType(share.Edges.File.Type) + fileName := share.Edges.File.Name + fileSize := share.Edges.File.Size + if sharePath != "" { + if fileType != types.FileTypeFolder { + return renderStatusOG(ogData, siteBasic.Name, ogStatusInvalidLink) + } + + resolvedFile, err := resolveSharePathFile(c, dep, u, opts.ID, opts.Password, sharePath) + if err != nil { + return renderStatusOG(ogData, siteBasic.Name, ogStatusInvalidLink) + } + + fileType = resolvedFile.Type() + fileName = resolvedFile.Name() + if displayName := resolvedFile.DisplayName(); displayName != "" { + fileName = displayName + } + fileSize = resolvedFile.Size() + } + + if fileType == types.FileTypeFolder { + ogData.FolderName = defaultIfEmpty(fileName, ogDefaultFolderName) + ogData.DisplayName = ogData.FolderName + return RenderFolderOGHTML(ogData, ogFolderTitleTemplate, ogFolderDescTemplate) + } + + ogData.FileName = defaultIfEmpty(fileName, ogDefaultFileName) + ogData.FileSize = FormatFileSize(fileSize) + ogData.FileExt = strings.TrimPrefix(path.Ext(ogData.FileName), ".") + ogData.DisplayName = ogData.FileName + + return RenderOGHTML(ogData, ogFileTitleTemplate, ogFileDescTemplate) +} + +func renderStatusOG(data *ShareOGData, displayName, status string) (string, error) { + data.Status = status + data.DisplayName = displayName + return RenderStatusOGHTML(data, ogStatusTitleTemplate, ogStatusDescTemplate) +} + +func defaultIfEmpty(value, fallback string) string { + if value == "" { + return fallback + } + return value +} + +func resolveSharePathFile(ctx context.Context, dep dependency.Dep, viewer *ent.User, id, password, sharePath string) (fs.File, error) { + if viewer == nil { + anonymous, err := dep.UserClient().AnonymousUser(ctx) + if err != nil { + return nil, err + } + viewer = anonymous + } + + fm := manager.NewFileManager(dep, viewer) + defer fm.Recycle() + + shareURI, err := fs.NewUriFromString(fs.NewShareUri(id, password)) + if err != nil { + return nil, err + } + if sharePath != "" { + shareURI = shareURI.JoinRaw(sharePath) + } + + return fm.Get(ctx, shareURI) +} + +// isAbsoluteURL checks if the URL is absolute (starts with http:// or https://). +func isAbsoluteURL(u string) bool { + return strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") +} diff --git a/pkg/util/user_agent.go b/pkg/util/user_agent.go new file mode 100644 index 00000000..5355ca34 --- /dev/null +++ b/pkg/util/user_agent.go @@ -0,0 +1,24 @@ +package util + +import "strings" + +// IsSocialMediaBot checks if the User-Agent belongs to a social media crawler. +func IsSocialMediaBot(ua string) bool { + bots := []string{ + "facebookexternalhit", + "twitterbot", + "linkedinbot", + "whatsapp", + "slackbot", + "discordbot", + "telegrambot", + "facebot", + } + ua = strings.ToLower(ua) + for _, bot := range bots { + if strings.Contains(ua, bot) { + return true + } + } + return false +} diff --git a/routers/controllers/share.go b/routers/controllers/share.go index d70b6a03..9200cd7e 100644 --- a/routers/controllers/share.go +++ b/routers/controllers/share.go @@ -2,10 +2,10 @@ package controllers import ( "net/http" - "strings" "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/share" "github.com/gin-gonic/gin" ) @@ -78,7 +78,7 @@ func ShareRedirect(c *gin.Context) { service := ParametersFromContext[*share.ShortLinkRedirectService](c, share.ShortLinkRedirectParamCtx{}) // Check if the request is from a social media bot - if isSocialMediaBot(c.GetHeader("User-Agent")) { + if util.IsSocialMediaBot(c.GetHeader("User-Agent")) { html, err := service.RenderOGPage(c) if err == nil { c.Header("Content-Type", "text/html; charset=utf-8") @@ -90,24 +90,3 @@ func ShareRedirect(c *gin.Context) { c.Redirect(http.StatusFound, service.RedirectTo(c)) } - -// isSocialMediaBot checks if the User-Agent belongs to a social media crawler -func isSocialMediaBot(ua string) bool { - bots := []string{ - "facebookexternalhit", - "twitterbot", - "linkedinbot", - "whatsapp", - "slackbot", - "discordbot", - "telegrambot", - "facebot", - } - ua = strings.ToLower(ua) - for _, bot := range bots { - if strings.Contains(ua, bot) { - return true - } - } - return false -} diff --git a/service/share/visit.go b/service/share/visit.go index d66a5534..e1afc513 100644 --- a/service/share/visit.go +++ b/service/share/visit.go @@ -2,19 +2,16 @@ package share import ( "context" - "net/url" - "path" - "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/filemanager/fs" "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager" "github.com/cloudreve/Cloudreve/v4/pkg/hashid" "github.com/cloudreve/Cloudreve/v4/pkg/serializer" + sharepkg "github.com/cloudreve/Cloudreve/v4/pkg/share" + "github.com/cloudreve/Cloudreve/v4/pkg/sharepreview" "github.com/cloudreve/Cloudreve/v4/service/explorer" "github.com/gin-gonic/gin" ) @@ -27,150 +24,17 @@ type ( ShortLinkRedirectParamCtx struct{} ) -const ( - ogStatusInvalidLink = "Invalid Link" - ogStatusNeedLogin = "Need Login" - ogStatusShareExpired = "Share Expired" - ogStatusPasswordRequired = "Password Required" - ogDefaultFileName = "Shared File" - ogDefaultFolderName = "Shared Folder" -) - func (s *ShortLinkRedirectService) RedirectTo(c *gin.Context) string { - shareLongUrl := routes.MasterShareLongUrl(s.ID, s.Password) - - shortLinkQuery := c.Request.URL.Query() // Query in ShortLink, adapt to Cloudreve V3 - shareLongUrlQuery := shareLongUrl.Query() - - userSpecifiedPath := shortLinkQuery.Get("path") - if userSpecifiedPath != "" { - masterPath := shareLongUrlQuery.Get("path") - masterPath += "/" + strings.TrimPrefix(userSpecifiedPath, "/") - - shareLongUrlQuery.Set("path", masterPath) - } - - shortLinkQuery.Del("path") // 防止用户指定的 Path 就是空字符串 - for k, vals := range shortLinkQuery { - shareLongUrlQuery[k] = append(shareLongUrlQuery[k], vals...) - } - - shareLongUrl.RawQuery = shareLongUrlQuery.Encode() - return shareLongUrl.String() + return sharepkg.BuildRedirectURL(s.ID, s.Password, c.Query("path"), c.Request.URL.Query()) } // RenderOGPage renders an Open Graph HTML page for social media previews func (s *ShortLinkRedirectService) RenderOGPage(c *gin.Context) (string, error) { - dep := dependency.FromContext(c) - shareClient := dep.ShareClient() - settings := dep.SettingProvider() - u := inventory.UserFromContext(c) - - // Check if anonymous users have permission to access share links - anonymousGroup, err := dep.GroupClient().AnonymousGroup(c) - needLogin := err != nil || anonymousGroup.Permissions == nil || !anonymousGroup.Permissions.Enabled(int(types.GroupPermissionShareDownload)) - - // Load share with user and file info - ctx := context.WithValue(c, inventory.LoadShareUser{}, true) - ctx = context.WithValue(ctx, inventory.LoadShareFile{}, true) - share, err := shareClient.GetByHashID(ctx, s.ID) - - // Determine share status - shareNotFound := err != nil - shareExpired := !shareNotFound && inventory.IsValidShare(share) != nil - - // Get site settings - siteBasic := settings.SiteBasic(c) - pwa := settings.PWA(c) - base := settings.SiteURL(c) - - // Build share URL - shareURL := routes.MasterShareUrl(base, s.ID, "") - - // Build thumbnail URL - use PWA large icon (PNG), fallback to medium icon - thumbnailURL := pwa.LargeIcon - if thumbnailURL == "" { - thumbnailURL = pwa.MediumIcon - } - if thumbnailURL != "" && !isAbsoluteURL(thumbnailURL) { - thumbnailURL = base.ResolveReference(&url.URL{Path: thumbnailURL}).String() - } - - // Prepare OG data - ogData := &ShareOGData{ - SiteName: siteBasic.Name, - SiteDescription: siteBasic.Description, - SiteURL: base.String(), - ShareURL: shareURL.String(), - ShareID: s.ID, - ThumbnailURL: thumbnailURL, - RedirectURL: s.RedirectTo(c), - } - - // Get file info (hide for invalid/expired/password-protected/login-required shares) - switch { - case shareNotFound: - return renderStatusOG(ogData, siteBasic.Name, ogStatusInvalidLink) - case needLogin: - return renderStatusOG(ogData, siteBasic.Name, ogStatusNeedLogin) - case shareExpired: - return renderStatusOG(ogData, siteBasic.Name, ogStatusShareExpired) - case share.Password != "" && !isShareUnlocked(share, s.Password, u): - // Password required but not provided or incorrect. - return renderStatusOG(ogData, siteBasic.Name, ogStatusPasswordRequired) - case share.Edges.File == nil: - return renderStatusOG(ogData, siteBasic.Name, ogStatusInvalidLink) - } - - // Get owner name (don't expose email for privacy). - if share.Edges.User != nil { - ogData.OwnerName = share.Edges.User.Nick - } - - shareFile := share.Edges.File - if types.FileType(shareFile.Type) == types.FileTypeFolder { - ogData.FolderName = defaultIfEmpty(shareFile.Name, ogDefaultFolderName) - ogData.DisplayName = ogData.FolderName - return RenderFolderOGHTML(ogData, ogFolderTitleTemplate, ogFolderDescTemplate) - } - - ogData.FileName = defaultIfEmpty(shareFile.Name, ogDefaultFileName) - ogData.FileSize = FormatFileSize(shareFile.Size) - ogData.FileExt = strings.TrimPrefix(path.Ext(ogData.FileName), ".") - ogData.DisplayName = ogData.FileName - - return RenderOGHTML(ogData, ogFileTitleTemplate, ogFileDescTemplate) -} - -// isAbsoluteURL checks if the URL is absolute (starts with http:// or https://) -func isAbsoluteURL(u string) bool { - return strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") -} - -func isShareUnlocked(share *ent.Share, password string, viewer *ent.User) bool { - if share.Password == "" { - return true - } - if password == share.Password { - return true - } - if viewer != nil && share.Edges.User != nil && share.Edges.User.ID == viewer.ID { - return true - } - return false -} - -func renderStatusOG(data *ShareOGData, displayName, status string) (string, error) { - data.Status = status - data.DisplayName = displayName - return RenderStatusOGHTML(data, ogStatusTitleTemplate, ogStatusDescTemplate) -} - -func defaultIfEmpty(value, fallback string) string { - if value == "" { - return fallback - } - return value + return sharepreview.RenderOGPage(c, sharepreview.Options{ + ID: s.ID, + Password: s.Password, + SharePath: c.Query("path"), + }) } type ( @@ -187,26 +51,21 @@ func (s *ShareInfoService) Get(c *gin.Context) (*explorer.Share, error) { u := inventory.UserFromContext(c) shareClient := dep.ShareClient() - ctx := context.WithValue(c, inventory.LoadShareUser{}, true) - ctx = context.WithValue(ctx, inventory.LoadShareFile{}, true) - share, err := shareClient.GetByID(ctx, hashid.FromContext(c)) - if err != nil { - if ent.IsNotFound(err) { - return nil, serializer.NewError(serializer.CodeNotFound, "Share not found", nil) - } - return nil, serializer.NewError(serializer.CodeDBError, "Failed to get share", err) - } - - if err := inventory.IsValidShare(share); err != nil { + shareID := hashid.FromContext(c) + share, unlocked, status, err := sharepkg.LoadShareForInfo(c, shareClient, shareID, u, s.Password) + switch status { + case sharepkg.LoadNotFound: + return nil, serializer.NewError(serializer.CodeNotFound, "Share not found", nil) + case sharepkg.LoadExpired: return nil, serializer.NewError(serializer.CodeNotFound, "Share link expired", err) + case sharepkg.LoadError: + return nil, serializer.NewError(serializer.CodeDBError, "Failed to get share", err) } if s.CountViews { _ = shareClient.Viewed(c, share) } - unlocked := isShareUnlocked(share, s.Password, u) - base := dep.SettingProvider().SiteURL(c) res := explorer.BuildShare(share, base, dep.HashIDEncoder(), u, share.Edges.User, share.Edges.File.Name, types.FileType(share.Edges.File.Type), unlocked, false)