- 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 (
|
import (
|
||||||
"bytes"
|
"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