feat(share): add Open Graph support for social media previews

- Detect social media bots (Facebook, Twitter, Discord, etc.)
- Render OG meta tags with file name, size, and owner info
- Auto-redirect to share page after preview is rendered
pull/3227/head
WittF 2 weeks ago
parent 47218607ff
commit 99d1c05339
No known key found for this signature in database

@ -1,11 +1,13 @@
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/service/share"
"github.com/gin-gonic/gin"
"net/http"
)
// CreateShare 创建分享
@ -74,5 +76,48 @@ func DeleteShare(c *gin.Context) {
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")) {
html, err := service.RenderOGPage(c)
if err == nil {
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, html)
return
}
// Fall back to redirect on error
}
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",
"applebot",
"pinterest",
"googlebot",
"bingbot",
"yandexbot",
"duckduckbot",
"baiduspider",
"sogou",
"exabot",
"facebot",
"ia_archiver",
}
ua = strings.ToLower(ua)
for _, bot := range bots {
if strings.Contains(ua, bot) {
return true
}
}
return false
}

@ -0,0 +1,77 @@
package share
import (
"bytes"
"fmt"
"html/template"
)
// 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
}
const ogHTMLTemplate = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta property="og:title" content="{{.FileName}}">
<meta property="og:description" content="{{.FileSize}}{{if .OwnerName}} · {{.OwnerName}}{{end}}">
<meta property="og:image" content="{{.ThumbnailURL}}">
<meta property="og:url" content="{{.ShareURL}}">
<meta property="og:type" content="website">
<meta property="og:site_name" content="{{.SiteName}}">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{.FileName}}">
<meta name="twitter:description" content="{{.FileSize}}{{if .OwnerName}} · {{.OwnerName}}{{end}}">
<meta name="twitter:image" content="{{.ThumbnailURL}}">
<title>{{.FileName}} - {{.SiteName}}</title>
</head>
<body>
<script>window.location.href = "{{.RedirectURL}}";</script>
<noscript>
<p><a href="{{.RedirectURL}}">{{.FileName}}</a></p>
</noscript>
</body>
</html>`
var ogTemplate = template.Must(template.New("og").Parse(ogHTMLTemplate))
// 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 {
return "", fmt.Errorf("failed to render OG template: %w", err)
}
return buf.String(), nil
}
// FormatFileSize formats file size to human readable format
func FormatFileSize(size int64) string {
const (
B = 1
KB = 1024 * B
MB = 1024 * KB
GB = 1024 * MB
TB = 1024 * GB
)
switch {
case size >= TB:
return fmt.Sprintf("%.2f TB", float64(size)/TB)
case size >= GB:
return fmt.Sprintf("%.2f GB", float64(size)/GB)
case size >= MB:
return fmt.Sprintf("%.2f MB", float64(size)/MB)
case size >= KB:
return fmt.Sprintf("%.2f KB", float64(size)/KB)
default:
return fmt.Sprintf("%d B", size)
}
}

@ -2,6 +2,7 @@ package share
import (
"context"
"net/url"
"strings"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
@ -48,6 +49,68 @@ func (s *ShortLinkRedirectService) RedirectTo(c *gin.Context) string {
return shareLongUrl.String()
}
// 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()
// 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)
if err != nil {
return "", err
}
// Check if share is valid
if err := inventory.IsValidShare(share); err != nil {
return "", err
}
// 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()
}
// Get file info
fileName := share.Edges.File.Name
fileSize := FormatFileSize(share.Edges.File.Size)
// Get owner name (don't expose email for privacy)
ownerName := share.Edges.User.Nick
// Prepare OG data
ogData := &ShareOGData{
SiteName: siteBasic.Name,
FileName: fileName,
FileSize: fileSize,
OwnerName: ownerName,
ShareURL: shareURL.String(),
ThumbnailURL: thumbnailURL,
RedirectURL: s.RedirectTo(c),
}
return RenderOGHTML(ogData)
}
// 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://")
}
type (
ShareInfoService struct {
Password string `form:"password"`

Loading…
Cancel
Save