- 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 packagespull/3227/head
parent
017e52dd63
commit
bac8908691
@ -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
|
||||
}
|
||||
Loading…
Reference in new issue