refactor(share): extract share logic to reusable packages

- Extract LoadShareForInfo() and BuildRedirectURL() to pkg/share
- Move OG template and preview logic to pkg/sharepreview
- Extract IsSocialMediaBot() to pkg/util
- Simplify service/share/visit.go to use new packages
pull/3227/head
WittF 2 weeks ago
parent 017e52dd63
commit bac8908691
No known key found for this signature in database

@ -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
}

@ -1,4 +1,4 @@
package share
package sharepreview
import (
"bytes"

@ -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://")
}

@ -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
}

@ -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
}

@ -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)

Loading…
Cancel
Save