Add middleware to intercept social media bot requests and return OG meta tags for share links, enabling rich previews on platforms like Facebook, Twitter, Discord, etc.pull/3234/head
parent
47218607ff
commit
572859fd1f
@ -0,0 +1,255 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/subtle"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/url"
|
||||||
|
"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/hashid"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ogStatusInvalidLink = "Invalid Link"
|
||||||
|
ogStatusShareUnavailable = "Share Unavailable"
|
||||||
|
ogStatusPasswordRequired = "Password Required"
|
||||||
|
ogStatusNeedLogin = "Login Required"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ogData struct {
|
||||||
|
SiteName string
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
ImageURL string
|
||||||
|
ShareURL string
|
||||||
|
RedirectURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ogHTMLTemplate = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta property="og:title" content="{{.Title}}">
|
||||||
|
<meta property="og:description" content="{{.Description}}">
|
||||||
|
<meta property="og:image" content="{{.ImageURL}}">
|
||||||
|
<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="{{.Title}}">
|
||||||
|
<meta name="twitter:description" content="{{.Description}}">
|
||||||
|
<meta name="twitter:image" content="{{.ImageURL}}">
|
||||||
|
<title>{{.Title}} - {{.SiteName}}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script>window.location.href = "{{.RedirectURL}}";</script>
|
||||||
|
<noscript><a href="{{.RedirectURL}}">{{.Title}}</a></noscript>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
var ogTemplate = template.Must(template.New("og").Parse(ogHTMLTemplate))
|
||||||
|
|
||||||
|
var socialMediaBots = []string{
|
||||||
|
"facebookexternalhit",
|
||||||
|
"facebookcatalog",
|
||||||
|
"facebot",
|
||||||
|
"twitterbot",
|
||||||
|
"linkedinbot",
|
||||||
|
"discordbot",
|
||||||
|
"telegrambot",
|
||||||
|
"slackbot",
|
||||||
|
"whatsapp",
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSocialMediaBot(ua string) bool {
|
||||||
|
ua = strings.ToLower(ua)
|
||||||
|
for _, bot := range socialMediaBots {
|
||||||
|
if strings.Contains(ua, bot) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SharePreview 为社交媒体爬虫渲染OG预览页面
|
||||||
|
func SharePreview(dep dependency.Dep) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if !isSocialMediaBot(c.GetHeader("User-Agent")) {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, password := extractShareParams(c)
|
||||||
|
if id == "" {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
html := renderShareOGPage(c, dep, id, password)
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
c.Header("Cache-Control", "public, no-cache")
|
||||||
|
c.String(200, html)
|
||||||
|
c.Abort()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractShareParams(c *gin.Context) (id, password string) {
|
||||||
|
urlPath := c.Request.URL.Path
|
||||||
|
|
||||||
|
if strings.HasPrefix(urlPath, "/s/") {
|
||||||
|
parts := strings.Split(strings.TrimPrefix(urlPath, "/s/"), "/")
|
||||||
|
if len(parts) >= 1 && parts[0] != "" {
|
||||||
|
id = parts[0]
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
password = parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if urlPath == "/home" || urlPath == "/home/" {
|
||||||
|
rawPath := c.Query("path")
|
||||||
|
if strings.HasPrefix(rawPath, "cloudreve://") {
|
||||||
|
u, err := url.Parse(rawPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
if strings.ToLower(u.Host) != "share" || u.User == nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
id = u.User.Username()
|
||||||
|
password, _ = u.User.Password()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(id) > 32 || len(password) > 32 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
return id, password
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderShareOGPage(c *gin.Context, dep dependency.Dep, id, password string) string {
|
||||||
|
settings := dep.SettingProvider()
|
||||||
|
siteBasic := settings.SiteBasic(c)
|
||||||
|
pwa := settings.PWA(c)
|
||||||
|
base := settings.SiteURL(c)
|
||||||
|
|
||||||
|
data := &ogData{
|
||||||
|
SiteName: siteBasic.Name,
|
||||||
|
Title: siteBasic.Name,
|
||||||
|
Description: siteBasic.Description,
|
||||||
|
ShareURL: routes.MasterShareUrl(base, id, "").String(),
|
||||||
|
RedirectURL: routes.MasterShareLongUrl(id, password).String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if pwa.LargeIcon != "" {
|
||||||
|
data.ImageURL = resolveURL(base, pwa.LargeIcon)
|
||||||
|
} else if pwa.MediumIcon != "" {
|
||||||
|
data.ImageURL = resolveURL(base, pwa.MediumIcon)
|
||||||
|
}
|
||||||
|
|
||||||
|
shareID, err := dep.HashIDEncoder().Decode(id, hashid.ShareID)
|
||||||
|
if err != nil {
|
||||||
|
data.Description = ogStatusInvalidLink
|
||||||
|
return renderOGHTML(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
share, err := loadShareForOG(c, dep, shareID)
|
||||||
|
if err != nil {
|
||||||
|
if ent.IsNotFound(err) {
|
||||||
|
data.Description = ogStatusInvalidLink
|
||||||
|
} else {
|
||||||
|
data.Description = ogStatusShareUnavailable
|
||||||
|
}
|
||||||
|
return renderOGHTML(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := inventory.IsValidShare(share); err != nil {
|
||||||
|
data.Description = ogStatusShareUnavailable
|
||||||
|
return renderOGHTML(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !canAnonymousAccessShare(c, dep) {
|
||||||
|
data.Description = ogStatusNeedLogin
|
||||||
|
return renderOGHTML(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if share.Password != "" && subtle.ConstantTimeCompare([]byte(share.Password), []byte(password)) != 1 {
|
||||||
|
data.Description = ogStatusPasswordRequired
|
||||||
|
return renderOGHTML(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetFile := share.Edges.File
|
||||||
|
if targetFile != nil {
|
||||||
|
fileName := targetFile.Name
|
||||||
|
fileType := types.FileType(targetFile.Type)
|
||||||
|
|
||||||
|
data.Title = fileName
|
||||||
|
if fileType == types.FileTypeFolder {
|
||||||
|
data.Description = "Folder"
|
||||||
|
} else {
|
||||||
|
data.Description = formatFileSize(targetFile.Size)
|
||||||
|
}
|
||||||
|
|
||||||
|
if share.Edges.User != nil && share.Edges.User.Nick != "" {
|
||||||
|
data.Description += " · " + share.Edges.User.Nick
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderOGHTML(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadShareForOG(c *gin.Context, dep dependency.Dep, shareID int) (*ent.Share, error) {
|
||||||
|
ctx := inventory.WithShareEagerLoad(c)
|
||||||
|
return dep.ShareClient().GetByID(ctx, shareID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func canAnonymousAccessShare(c *gin.Context, dep dependency.Dep) bool {
|
||||||
|
anonymousGroup, err := dep.GroupClient().AnonymousGroup(c)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return anonymousGroup.Permissions.Enabled(int(types.GroupPermissionShareDownload))
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderOGHTML(data *ogData) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := ogTemplate.Execute(&buf, data); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveURL(base *url.URL, path string) string {
|
||||||
|
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return base.ResolveReference(&url.URL{Path: path}).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatFileSize(size int64) string {
|
||||||
|
const (
|
||||||
|
KB = 1024
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in new issue