diff --git a/service/share/og_template.go b/service/share/og_template.go index e55eed08..266b3762 100644 --- a/service/share/og_template.go +++ b/service/share/og_template.go @@ -4,54 +4,165 @@ import ( "bytes" "fmt" "html/template" + "regexp" + "strings" ) -// ShareOGData contains data for rendering Open Graph HTML page +// ShareOGData contains data for rendering Open Graph HTML page. type ShareOGData struct { - SiteName string - FileName string - FileSize string - OwnerName string - ShareURL string - ThumbnailURL string - RedirectURL string + SiteName string + SiteDescription string + SiteURL string + ShareURL string + ShareID string + FileName string + FileSize string + FileExt string + FolderName string + OwnerName string + Status string + Title string + Description string + DisplayName string + ThumbnailURL string + RedirectURL string } const ogHTMLTemplate = ` - - + + - - + + - {{.FileName}} - {{.SiteName}} + {{.Title}} - {{.SiteName}} ` var ogTemplate = template.Must(template.New("og").Parse(ogHTMLTemplate)) +var ogMagicVarRe = regexp.MustCompile(`\{[^{}]+\}`) +var ogOwnerSeparatorRe = regexp.MustCompile(`\s*·\s*·+\s*`) + +const ( + ogFileTitleTemplate = "{file_name}" + ogFileDescTemplate = "{file_size} · {owner_name}" + ogFolderTitleTemplate = "{folder_name}" + ogFolderDescTemplate = "Folder · {owner_name}" + ogStatusTitleTemplate = "{site_name}" + ogStatusDescTemplate = "{status}" +) + +// RenderOGHTML renders the Open Graph HTML page for file shares. +func RenderOGHTML(data *ShareOGData, titleTemplate, descTemplate string) (string, error) { + title := replaceOGFileMagicVars(titleTemplate, data) + description := replaceOGFileMagicVars(descTemplate, data) + return renderOGHTML(data, title, description) +} + +// RenderFolderOGHTML renders the Open Graph HTML page for folder shares. +func RenderFolderOGHTML(data *ShareOGData, titleTemplate, descTemplate string) (string, error) { + title := replaceOGFolderMagicVars(titleTemplate, data) + description := replaceOGFolderMagicVars(descTemplate, data) + return renderOGHTML(data, title, description) +} + +// RenderStatusOGHTML renders the Open Graph HTML page for share status scenarios. +func RenderStatusOGHTML(data *ShareOGData, titleTemplate, descTemplate string) (string, error) { + title := replaceOGStatusMagicVars(titleTemplate, data) + description := replaceOGStatusMagicVars(descTemplate, data) + return renderOGHTML(data, title, description) +} + +func renderOGHTML(data *ShareOGData, title, description string) (string, error) { + renderData := *data + renderData.Title = title + renderData.Description = description -// RenderOGHTML renders the Open Graph HTML page -func RenderOGHTML(data *ShareOGData) (string, error) { var buf bytes.Buffer - if err := ogTemplate.Execute(&buf, data); err != nil { + if err := ogTemplate.Execute(&buf, &renderData); err != nil { return "", fmt.Errorf("failed to render OG template: %w", err) } return buf.String(), nil } +func replaceOGFileMagicVars(tmpl string, data *ShareOGData) string { + vars := map[string]string{ + "{site_name}": data.SiteName, + "{site_description}": data.SiteDescription, + "{site_url}": data.SiteURL, + "{file_name}": data.FileName, + "{file_size}": data.FileSize, + "{file_ext}": data.FileExt, + "{owner_name}": data.OwnerName, + "{share_url}": data.ShareURL, + "{share_id}": data.ShareID, + } + return replaceOGMagicVars(tmpl, vars, data.OwnerName, true) +} + +func replaceOGFolderMagicVars(tmpl string, data *ShareOGData) string { + vars := map[string]string{ + "{site_name}": data.SiteName, + "{site_description}": data.SiteDescription, + "{site_url}": data.SiteURL, + "{folder_name}": data.FolderName, + "{owner_name}": data.OwnerName, + "{share_url}": data.ShareURL, + "{share_id}": data.ShareID, + } + return replaceOGMagicVars(tmpl, vars, data.OwnerName, true) +} + +func replaceOGStatusMagicVars(tmpl string, data *ShareOGData) string { + vars := map[string]string{ + "{site_name}": data.SiteName, + "{site_description}": data.SiteDescription, + "{site_url}": data.SiteURL, + "{share_url}": data.ShareURL, + "{share_id}": data.ShareID, + "{status}": data.Status, + } + return replaceOGMagicVars(tmpl, vars, "", false) +} + +func replaceOGMagicVars(tmpl string, vars map[string]string, ownerName string, trimOwner bool) string { + rendered := ogMagicVarRe.ReplaceAllStringFunc(tmpl, func(match string) string { + if value, ok := vars[match]; ok { + return value + } + return match + }) + if trimOwner { + return trimEmptyOwnerSeparator(tmpl, rendered, ownerName) + } + return rendered +} + +func trimEmptyOwnerSeparator(tmpl, rendered, ownerName string) string { + if ownerName != "" || !strings.Contains(tmpl, "{owner_name}") { + return rendered + } + + trimmed := strings.TrimSpace(rendered) + trimmed = ogOwnerSeparatorRe.ReplaceAllString(trimmed, " · ") + trimmed = strings.TrimSpace(trimmed) + trimmed = strings.Trim(trimmed, "\u00b7") + return strings.TrimSpace(trimmed) +} + // FormatFileSize formats file size to human readable format func FormatFileSize(size int64) string { const ( diff --git a/service/share/visit.go b/service/share/visit.go index e941a0c3..d66a5534 100644 --- a/service/share/visit.go +++ b/service/share/visit.go @@ -3,6 +3,7 @@ package share import ( "context" "net/url" + "path" "strings" "github.com/cloudreve/Cloudreve/v4/application/dependency" @@ -26,6 +27,15 @@ 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) @@ -86,53 +96,50 @@ func (s *ShortLinkRedirectService) RenderOGPage(c *gin.Context) (string, error) 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) - var fileName, fileSize, ownerName string - if shareNotFound { - fileName = siteBasic.Name - fileSize = "Invalid Link" - } else if needLogin { - fileName = siteBasic.Name - fileSize = "Need Login" - } else if shareExpired { - fileName = siteBasic.Name - fileSize = "Share Expired" - } else if share.Password != "" && !isShareUnlocked(share, s.Password, u) { - // Password required but not provided or incorrect - fileName = siteBasic.Name - fileSize = "Password Required" - } else if share.Edges.File != nil { - fileName = share.Edges.File.Name - if fileName == "" { - fileName = "Shared File" - } - // Show "Folder" for directories, file size for files - if types.FileType(share.Edges.File.Type) == types.FileTypeFolder { - fileSize = "Folder" - } else { - fileSize = FormatFileSize(share.Edges.File.Size) - } - // Get owner name (don't expose email for privacy) - if share.Edges.User != nil { - ownerName = share.Edges.User.Nick - } - } else { - fileName = siteBasic.Name - fileSize = "Invalid Link" + 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) } - // Prepare OG data - ogData := &ShareOGData{ - SiteName: siteBasic.Name, - FileName: fileName, - FileSize: fileSize, - OwnerName: ownerName, - ShareURL: shareURL.String(), - ThumbnailURL: thumbnailURL, - RedirectURL: s.RedirectTo(c), + // 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) } - return RenderOGHTML(ogData) + 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://) @@ -153,6 +160,19 @@ func isShareUnlocked(share *ent.Share, password string, viewer *ent.User) bool { 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 +} + type ( ShareInfoService struct { Password string `form:"password"`