1005 lines
33 KiB
Go
1005 lines
33 KiB
Go
package question
|
||
|
||
import (
|
||
"bytes"
|
||
stdctx "context"
|
||
"crypto/md5"
|
||
"fmt"
|
||
"image"
|
||
"image/color"
|
||
"image/draw"
|
||
"image/jpeg"
|
||
"net"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
"unicode"
|
||
|
||
"github.com/disintegration/imaging"
|
||
"github.com/golang/freetype"
|
||
"github.com/golang/freetype/truetype"
|
||
"github.com/sirupsen/logrus"
|
||
qrcode "github.com/skip2/go-qrcode"
|
||
|
||
"github.com/tamakyi/TamaBox/internal/conf"
|
||
"github.com/tamakyi/TamaBox/internal/context"
|
||
"github.com/tamakyi/TamaBox/internal/db"
|
||
"github.com/tamakyi/TamaBox/internal/shared"
|
||
"github.com/tamakyi/TamaBox/internal/template"
|
||
)
|
||
|
||
// 卡片尺寸与留白
|
||
const (
|
||
CardWidth = 960
|
||
CardHeight = 540
|
||
Margin = 15
|
||
)
|
||
|
||
// 内容区域边界
|
||
const (
|
||
ContentX = Margin
|
||
ContentY = Margin
|
||
ContentW = CardWidth - 2*Margin
|
||
ContentH = CardHeight - 2*Margin
|
||
)
|
||
|
||
// 头像参数
|
||
const (
|
||
AvatarSize = 112
|
||
AvatarCX = ContentX + 90
|
||
AvatarCY = ContentY + 90
|
||
AvatarRingW = 6
|
||
AvatarRingR = AvatarSize/2 + AvatarRingW
|
||
)
|
||
|
||
// 左侧文字区域
|
||
const (
|
||
TextStartX = AvatarCX + AvatarRingR + 30
|
||
TextStartY = ContentY + 75
|
||
)
|
||
|
||
// 右侧二维码卡片尺寸
|
||
const (
|
||
QRCardW = 270
|
||
QRCardH = 315
|
||
QRCardX = ContentX + ContentW - QRCardW - 15
|
||
QRCardY = ContentY + (ContentH-QRCardH)/2
|
||
)
|
||
|
||
// 二维码尺寸
|
||
const (
|
||
QRCodeSize = 165
|
||
QRCodeX = (QRCardW - QRCodeSize) / 2
|
||
)
|
||
|
||
// 社交信息
|
||
const (
|
||
SocialIconW = 24
|
||
SocialIconH = 24
|
||
SocialTextX = TextStartX + SocialIconW + 8
|
||
SocialLineH = 32
|
||
)
|
||
|
||
// 底部水印
|
||
const (
|
||
FooterY = ContentY + ContentH - 20
|
||
)
|
||
|
||
// 字体大小
|
||
const (
|
||
FontSizeName = 36
|
||
FontSizeIntro = 20
|
||
FontSizeLink = 15
|
||
FontSizeSocial = 17
|
||
FontSizeQRHint = 17
|
||
FontSizeFooter = 14
|
||
)
|
||
|
||
// 颜色系统
|
||
var (
|
||
ColorWhite = color.RGBA{255, 255, 255, 255}
|
||
ColorBlack = color.RGBA{0, 0, 0, 255}
|
||
ColorDarkText = color.RGBA{30, 30, 30, 255}
|
||
ColorGrayText = color.RGBA{100, 100, 100, 255}
|
||
ColorSocialGray = color.RGBA{80, 80, 80, 255}
|
||
ColorHintGray = color.RGBA{90, 90, 90, 255}
|
||
ColorFooterGray = color.RGBA{150, 150, 150, 255}
|
||
OverlayColor = color.NRGBA{R: 255, G: 255, B: 255, A: 165}
|
||
)
|
||
|
||
// 全局字体缓存
|
||
var (
|
||
fontOnce sync.Once
|
||
fontCache *truetype.Font
|
||
fontErr error
|
||
)
|
||
|
||
func getFont() (*truetype.Font, error) {
|
||
fontOnce.Do(func() {
|
||
fontBytes, err := os.ReadFile(conf.App.FontPath)
|
||
if err != nil {
|
||
fontErr = err
|
||
return
|
||
}
|
||
fontCache, fontErr = freetype.ParseFont(fontBytes)
|
||
})
|
||
return fontCache, fontErr
|
||
}
|
||
|
||
// shareDialer 与 shareDialContext:SSRF 防护——解析域名后校验所有解析 IP 均非内网,
|
||
// 再交由 shared.DialFirstReachable 对公网候选 IP 并发拨号取最先成功者(happy-eyeballs 风格)。
|
||
// 防 DNS rebinding(IP 字面量直连、无二次解析)的同时,避免固定拨单一 IP 抽中劣化边缘
|
||
// 导致分享卡缺图(分享图源同样在 Cloudflare/R2,链路质量差时多 IP 容错尤为必要)。
|
||
var shareDialer = &net.Dialer{Timeout: 8 * time.Second, KeepAlive: 30 * time.Second}
|
||
|
||
// shareDialMaxCandidates 限制并发拨号扇出上限。
|
||
const shareDialMaxCandidates = 6
|
||
|
||
func shareDialContext(ctx stdctx.Context, network, addr string) (net.Conn, error) {
|
||
host, port, err := net.SplitHostPort(addr)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
ips, err := net.LookupIP(host)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var candidates []net.IP
|
||
for _, ip := range ips {
|
||
if isBlockedIP(ip) {
|
||
return nil, fmt.Errorf("blocked ip address: %s", ip)
|
||
}
|
||
candidates = append(candidates, ip)
|
||
}
|
||
if len(candidates) == 0 {
|
||
return nil, fmt.Errorf("no public ip address resolved for %s", host)
|
||
}
|
||
return shared.DialFirstReachable(ctx, shareDialer, network, candidates, port, shareDialMaxCandidates)
|
||
}
|
||
|
||
// 全局 HTTP 客户端:连接池复用,避免每次请求都重新建立 TCP/TLS 连接。
|
||
// 超时预算说明:分享卡图源(头像/背景/社交图标)同样位于 Cloudflare/R2,服务器到该链路
|
||
// 质量差(实测单次约 6.5s),总预算放宽以容纳多 IP 并发拨号与慢速首字节;R2 图缺失时
|
||
// 分享卡会静默降级(无头像/背景),宁可等待也不轻易放弃。
|
||
var shareHTTPClient = &http.Client{
|
||
Timeout: 20 * time.Second,
|
||
Transport: &http.Transport{
|
||
MaxIdleConns: 100,
|
||
MaxIdleConnsPerHost: 10,
|
||
IdleConnTimeout: 90 * time.Second,
|
||
TLSHandshakeTimeout: 8 * time.Second,
|
||
ResponseHeaderTimeout: 8 * time.Second,
|
||
DialContext: shareDialContext,
|
||
},
|
||
// SSRF 防护:重定向逐跳校验,防止 302 跳转到内网地址(如 169.254.169.254 云元数据)绕过初始校验
|
||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||
if len(via) >= 5 {
|
||
return fmt.Errorf("too many redirects")
|
||
}
|
||
if isBlockedImageURL(req.URL.String()) {
|
||
return fmt.Errorf("blocked redirect to %s", req.URL)
|
||
}
|
||
return nil
|
||
},
|
||
}
|
||
|
||
// ttlCache 是一个简单的并发安全带 TTL 缓存。
|
||
// 带 maxItems 条数上限,超限时先清过期、再淘汰最早过期的一条,
|
||
// 防止低配服务器(1C1G)上缓存无限膨胀。
|
||
type ttlCache struct {
|
||
mu sync.Mutex
|
||
items map[string]ttlCacheItem
|
||
maxItems int
|
||
}
|
||
|
||
type ttlCacheItem struct {
|
||
value interface{}
|
||
expiresAt time.Time
|
||
}
|
||
|
||
func newTTLCache(maxItems int) *ttlCache {
|
||
c := &ttlCache{items: make(map[string]ttlCacheItem), maxItems: maxItems}
|
||
// 常驻清理循环:每轮迭代单独 recover,panic 后循环不中断
|
||
go func() {
|
||
for range time.Tick(5 * time.Minute) {
|
||
func() {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
logrus.WithField("panic", r).Error("ttlCache cleanup panic recovered")
|
||
}
|
||
}()
|
||
c.cleanup()
|
||
}()
|
||
}
|
||
}()
|
||
return c
|
||
}
|
||
|
||
func (c *ttlCache) Get(key string) (interface{}, bool) {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
item, ok := c.items[key]
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
if time.Now().After(item.expiresAt) {
|
||
delete(c.items, key)
|
||
return nil, false
|
||
}
|
||
return item.value, true
|
||
}
|
||
|
||
func (c *ttlCache) Set(key string, value interface{}, ttl time.Duration) {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
|
||
// 超限时先清过期,仍满则淘汰最早过期的一条
|
||
if len(c.items) >= c.maxItems {
|
||
now := time.Now()
|
||
for k, item := range c.items {
|
||
if now.After(item.expiresAt) {
|
||
delete(c.items, k)
|
||
}
|
||
}
|
||
if len(c.items) >= c.maxItems {
|
||
var victim string
|
||
var earliest time.Time
|
||
first := true
|
||
for k, item := range c.items {
|
||
if first || item.expiresAt.Before(earliest) {
|
||
earliest = item.expiresAt
|
||
victim = k
|
||
first = false
|
||
}
|
||
}
|
||
if victim != "" {
|
||
delete(c.items, victim)
|
||
}
|
||
}
|
||
}
|
||
|
||
c.items[key] = ttlCacheItem{value: value, expiresAt: time.Now().Add(ttl)}
|
||
}
|
||
|
||
func (c *ttlCache) cleanup() {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
now := time.Now()
|
||
for k, item := range c.items {
|
||
if now.After(item.expiresAt) {
|
||
delete(c.items, k)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 远程图片缓存(背景图、头像、社交图标),TTL 1 小时。
|
||
// 存的是解码后的 image.Image,最吃内存(1280×720 RGBA ≈ 3.7MB/张),
|
||
// 1C1G 环境下限制为 24 条:正常站点同一时刻活跃用户远小于此值。
|
||
// 社交图标 URL 固定不变,基本永远命中;头像/背景变更时最多延迟 1 小时生效。
|
||
var imageCache = newTTLCache(24)
|
||
|
||
// 分享图结果缓存,TTL 30 分钟。key 含用户资料指纹,资料变更后自动失效。
|
||
// 存的是 PNG 编码字节(约 0.2~1MB/张),限制 32 条,总内存 ≤ 32MB。
|
||
var shareCardCache = newTTLCache(32)
|
||
|
||
func ShareCard(ctx context.Context) {
|
||
domain := ctx.Param("domain")
|
||
user, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
|
||
if err != nil {
|
||
ctx.Redirect("/")
|
||
return
|
||
}
|
||
|
||
// 站方处置:任何级别都不再生成整箱分享图(与单题分享卡一致)。
|
||
// 放在缓存之前 —— 拦截必须即时生效,否则处置后 30 分钟内仍能拿到旧图。
|
||
// DB 异常时按已处置处理(fail-closed):这张图会带上箱主信息与二维码,是外传载体。
|
||
if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), user.ID); err != nil {
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query user restriction")
|
||
ctx.Redirect("/")
|
||
return
|
||
} else if level != "" {
|
||
logrus.WithContext(ctx.Request().Context()).WithField("domain", domain).WithField("level", level).
|
||
Info("Profile share card blocked by restriction")
|
||
ctx.Redirect("/")
|
||
return
|
||
}
|
||
|
||
// 命中缓存直接返回,避免重复下载图片与重新绘制
|
||
cacheKey := shareCardCacheKey(user)
|
||
if cached, ok := shareCardCache.Get(cacheKey); ok {
|
||
writeShareCardResponse(ctx, cached.([]byte))
|
||
return
|
||
}
|
||
|
||
canvas := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight))
|
||
|
||
// 1. 背景
|
||
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ColorWhite}, image.Point{}, draw.Src)
|
||
innerRect := image.Rect(ContentX, ContentY, ContentX+ContentW, ContentY+ContentH)
|
||
|
||
// ---- 并发下载所有远程图片 ----
|
||
var (
|
||
bgImg image.Image
|
||
avImg image.Image
|
||
)
|
||
icons := []struct {
|
||
show bool
|
||
url string
|
||
icon string
|
||
}{
|
||
{user.Acfunlink != "" && user.Acfunlink != "none", user.Acfunlink, "https://r2.337845818.best/tamabox/icon/social/ailicili.png"},
|
||
{user.Bililink != "" && user.Bililink != "none", user.Bililink, "https://r2.337845818.best/tamabox/icon/social/bili.png"},
|
||
{user.Weibolink != "" && user.Weibolink != "none", user.Weibolink, "https://r2.337845818.best/tamabox/icon/social/weibo.png"},
|
||
{user.Bloglink != "" && user.Bloglink != "none", user.Bloglink, "https://r2.337845818.best/tamabox/icon/social/blog.png"},
|
||
{user.Xiaohongshulink != "" && user.Xiaohongshulink != "none", user.Xiaohongshulink, "https://r2.337845818.best/tamabox/icon/social/xiaohongshu.png"},
|
||
}
|
||
iconImgs := make([]image.Image, len(icons))
|
||
|
||
var wg sync.WaitGroup
|
||
|
||
// 背景图
|
||
bgURL := user.Background
|
||
if bgURL == "" {
|
||
bgURL = conf.App.ShareBackground
|
||
}
|
||
wg.Add(1)
|
||
shared.SafeGo(func() {
|
||
defer wg.Done()
|
||
img, err := loadImage(bgURL, ContentW, ContentH)
|
||
if err == nil {
|
||
bgImg = img
|
||
}
|
||
})
|
||
|
||
// 头像
|
||
wg.Add(1)
|
||
shared.SafeGo(func() {
|
||
defer wg.Done()
|
||
img, err := loadImage(user.Avatar, AvatarSize, AvatarSize)
|
||
if err == nil {
|
||
avImg = img
|
||
}
|
||
})
|
||
|
||
// 社交图标
|
||
for i, item := range icons {
|
||
if !item.show {
|
||
continue
|
||
}
|
||
idx, iconURL := i, item.icon
|
||
wg.Add(1)
|
||
shared.SafeGo(func() {
|
||
defer wg.Done()
|
||
img, err := loadImage(iconURL, SocialIconW, SocialIconH)
|
||
if err == nil {
|
||
iconImgs[idx] = img
|
||
}
|
||
})
|
||
}
|
||
|
||
wg.Wait()
|
||
// ---- 下载结束 ----
|
||
|
||
// 绘制背景
|
||
if bgImg != nil {
|
||
draw.Draw(canvas, innerRect, bgImg, image.Point{}, draw.Src)
|
||
}
|
||
overlay := image.NewUniform(OverlayColor)
|
||
draw.Draw(canvas, innerRect, overlay, image.Point{}, draw.Over)
|
||
|
||
// 2. 加载字体(复用缓存)
|
||
ft, err := getFont()
|
||
if err != nil {
|
||
http.Error(ctx.ResponseWriter(), "分享图生成失败:字体文件未配置或无法加载", http.StatusInternalServerError)
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to load font for share card")
|
||
return
|
||
}
|
||
fontCtx := freetype.NewContext()
|
||
fontCtx.SetFont(ft)
|
||
fontCtx.SetFontSize(24)
|
||
fontCtx.SetDPI(72)
|
||
|
||
// 3. 头像
|
||
drawCircleFilled(canvas, AvatarCX, AvatarCY, AvatarRingR, ColorWhite)
|
||
if avImg != nil {
|
||
avImg = imaging.Fill(avImg, AvatarSize, AvatarSize, imaging.Center, imaging.Lanczos)
|
||
mask := image.NewAlpha(image.Rect(0, 0, AvatarSize, AvatarSize))
|
||
drawCircle(mask, AvatarSize/2, AvatarSize/2, AvatarSize/2, color.Alpha{255})
|
||
draw.DrawMask(canvas,
|
||
image.Rect(AvatarCX-AvatarSize/2, AvatarCY-AvatarSize/2, AvatarCX+AvatarSize/2, AvatarCY+AvatarSize/2),
|
||
avImg, image.Point{}, mask, image.Point{}, draw.Over)
|
||
} else {
|
||
mask := image.NewAlpha(image.Rect(0, 0, AvatarSize, AvatarSize))
|
||
drawCircle(mask, AvatarSize/2, AvatarSize/2, AvatarSize/2, color.Alpha{255})
|
||
draw.DrawMask(canvas,
|
||
image.Rect(AvatarCX-AvatarSize/2, AvatarCY-AvatarSize/2, AvatarCX+AvatarSize/2, AvatarCY+AvatarSize/2),
|
||
&image.Uniform{color.Gray{180}}, image.Point{}, mask, image.Point{}, draw.Over)
|
||
}
|
||
|
||
// 4. 左侧文字
|
||
// 文字区裁剪边界:左侧文字不得侵入右侧二维码卡片(QRCardX 左侧留 20px 间距)。
|
||
leftClip := image.Rect(TextStartX, ContentY, QRCardX-20, ContentY+ContentH)
|
||
leftMaxW := QRCardX - TextStartX - 60
|
||
y := TextStartY
|
||
drawTextWithShadow(canvas, user.Name, TextStartX, y, FontSizeName, ColorDarkText, color.NRGBA{0, 0, 0, 50}, 1, fontCtx)
|
||
y += int(FontSizeName) + 15
|
||
// 简介最多 4 行,超出截断加省略号,避免长简介向下覆盖社交区/水印
|
||
y = drawTextMultilineLimited(canvas, user.Intro, TextStartX, y, leftMaxW, FontSizeIntro, ColorGrayText, fontCtx, 46, 4, leftClip)
|
||
y += 30
|
||
|
||
// 5. 社交信息
|
||
socialY := y + 22
|
||
for i, item := range icons {
|
||
if !item.show || iconImgs[i] == nil {
|
||
continue
|
||
}
|
||
draw.Draw(canvas,
|
||
image.Rect(TextStartX, socialY, TextStartX+SocialIconW, socialY+SocialIconH),
|
||
iconImgs[i], image.Point{}, draw.Over)
|
||
drawTextClipped(canvas, item.url, SocialTextX, socialY+SocialIconH-6, FontSizeSocial, ColorSocialGray, fontCtx, leftClip)
|
||
socialY += SocialLineH
|
||
}
|
||
|
||
// 6. 右侧二维码卡片
|
||
cardRect := image.Rect(QRCardX, QRCardY, QRCardX+QRCardW, QRCardY+QRCardH)
|
||
cardCenterX := QRCardX + QRCardW/2
|
||
|
||
drawRoundedRect(canvas, cardRect, color.NRGBA{245, 245, 245, 255}, 18)
|
||
drawRoundedRect(canvas, cardRect, ColorWhite, 18)
|
||
|
||
boxURL := fmt.Sprintf("%s/_/%s", conf.App.ExternalURL, user.Domain)
|
||
qr, err := generateQR(boxURL, user, avImg)
|
||
if err != nil {
|
||
http.Error(ctx.ResponseWriter(), "二维码生成失败", http.StatusInternalServerError)
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to generate QR code")
|
||
return
|
||
}
|
||
qrResized := imaging.Resize(qr, QRCodeSize, QRCodeSize, imaging.NearestNeighbor)
|
||
|
||
const (
|
||
qrToHintGap = 26
|
||
hintLineHeight = 17
|
||
hintToSepGap = 21
|
||
sepHeight = 1
|
||
sepToLinkGap = 21
|
||
linkLineHeight = 15
|
||
)
|
||
totalContentH := QRCodeSize + qrToHintGap + hintLineHeight + hintToSepGap + sepHeight + sepToLinkGap + linkLineHeight
|
||
startY := QRCardY + (QRCardH-totalContentH)/2
|
||
|
||
qrDrawX := QRCardX + QRCodeX
|
||
qrDrawY := startY
|
||
draw.Draw(canvas, image.Rect(qrDrawX, qrDrawY, qrDrawX+QRCodeSize, qrDrawY+QRCodeSize), qrResized, image.Point{}, draw.Over)
|
||
|
||
hintY := qrDrawY + QRCodeSize + qrToHintGap + hintLineHeight
|
||
drawTextCentered(canvas, "扫码向我匿名提问", cardCenterX, hintY, FontSizeQRHint, ColorHintGray, fontCtx)
|
||
|
||
sepY := hintY + hintToSepGap
|
||
drawLine(canvas, cardCenterX-90, sepY, cardCenterX+90, sepY, color.NRGBA{200, 200, 200, 180})
|
||
|
||
linkY := sepY + sepToLinkGap + linkLineHeight
|
||
linkText := boxURL
|
||
linkFontSize := float64(FontSizeLink)
|
||
availableW := QRCardW - 45
|
||
actualW := measureTextWidth(linkText, linkFontSize)
|
||
if actualW > float64(availableW) {
|
||
linkFontSize = linkFontSize * float64(availableW) / actualW
|
||
}
|
||
drawTextCentered(canvas, linkText, cardCenterX, linkY, linkFontSize, ColorDarkText, fontCtx)
|
||
|
||
// 7. 底部水印
|
||
drawTextCentered(canvas, "Powered by 狼的提问箱", CardWidth/2, FooterY, FontSizeFooter, ColorFooterGray, fontCtx)
|
||
|
||
// 8. 输出(缓存 + 响应)
|
||
buf := new(bytes.Buffer)
|
||
if err := jpeg.Encode(buf, canvas, &jpeg.Options{Quality: 88}); err != nil {
|
||
http.Error(ctx.ResponseWriter(), "图片生成失败", http.StatusInternalServerError)
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to encode share card PNG")
|
||
return
|
||
}
|
||
|
||
shareCardCache.Set(cacheKey, buf.Bytes(), time.Hour)
|
||
writeShareCardResponse(ctx, buf.Bytes())
|
||
}
|
||
|
||
// writeShareCardResponse 统一输出分享图响应。
|
||
// 加 Cache-Control 让浏览器与 CDN(如 Cloudflare)缓存,进一步减轻服务器压力。
|
||
func writeShareCardResponse(ctx context.Context, data []byte) {
|
||
ctx.ResponseWriter().Header().Set("Content-Type", "image/jpeg")
|
||
ctx.ResponseWriter().Header().Set("Content-Disposition", "attachment; filename=\"share.jpg\"")
|
||
ctx.ResponseWriter().Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||
ctx.ResponseWriter().Header().Set("Cache-Control", "public, max-age=300")
|
||
ctx.ResponseWriter().WriteHeader(http.StatusOK)
|
||
_, _ = ctx.ResponseWriter().Write(data)
|
||
}
|
||
|
||
// shareCardCacheKey 基于用户资料生成缓存指纹。
|
||
// 资料(名称/简介/头像/背景/社交链接)任何一项变更都会导致 key 变化,缓存自动失效。
|
||
func shareCardCacheKey(user *db.User) string {
|
||
h := md5.New()
|
||
fmt.Fprintf(h, "%s|%s|%s|%s|%s|%s|%s|%s|%s|%s",
|
||
user.Domain, user.Name, user.Intro, user.Avatar, user.Background,
|
||
user.Acfunlink, user.Bililink, user.Weibolink, user.Bloglink, user.Xiaohongshulink)
|
||
return fmt.Sprintf("sharecard:%x", h.Sum(nil))
|
||
}
|
||
|
||
// ---------- 工具函数 ----------
|
||
|
||
// maxImagePixels 限制远程图片解码后的最大像素总量(4096×4096≈67MB RGBA),
|
||
// 防止恶意超大图耗尽服务器内存。
|
||
const maxImagePixels = 4096 * 4096
|
||
|
||
// isBlockedImageURL SSRF 防护:仅允许 http/https,且主机不能解析到私网/环回等内网地址。
|
||
func isBlockedImageURL(rawURL string) bool {
|
||
u, err := url.Parse(rawURL)
|
||
if err != nil {
|
||
return true
|
||
}
|
||
if u.Scheme != "http" && u.Scheme != "https" {
|
||
return true
|
||
}
|
||
host := u.Hostname()
|
||
if host == "" {
|
||
return true
|
||
}
|
||
// 直接是 IP 字面量:私网/环回/链路本地/未指定/组播一律拒绝
|
||
if ip := net.ParseIP(host); ip != nil {
|
||
return isBlockedIP(ip)
|
||
}
|
||
// 域名:解析出任一内网 IP 即拒绝
|
||
ips, err := net.LookupIP(host)
|
||
if err != nil {
|
||
// 解析失败交给 HTTP 层报错(不阻断),避免 DNS 临时故障误伤正常图片
|
||
return false
|
||
}
|
||
for _, ip := range ips {
|
||
if isBlockedIP(ip) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func isBlockedIP(ip net.IP) bool {
|
||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
|
||
ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast()
|
||
}
|
||
|
||
func loadImage(url string, w, h int) (image.Image, error) {
|
||
// SSRF 防护:拒绝非 http/https 与内网地址(127.0.0.1、10.x、192.168.x 等)
|
||
if isBlockedImageURL(url) {
|
||
return nil, fmt.Errorf("blocked image url: %s", url)
|
||
}
|
||
|
||
key := fmt.Sprintf("%s|%dx%d", url, w, h)
|
||
if cached, ok := imageCache.Get(key); ok {
|
||
return cached.(image.Image), nil
|
||
}
|
||
|
||
resp, err := shareHTTPClient.Get(url)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("bad status: %s", resp.Status)
|
||
}
|
||
img, _, err := image.Decode(resp.Body)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// 解码后限制像素总量,防止超大图耗尽内存
|
||
b := img.Bounds()
|
||
if b.Dx()*b.Dy() > maxImagePixels {
|
||
return nil, fmt.Errorf("image too large: %dx%d", b.Dx(), b.Dy())
|
||
}
|
||
filled := imaging.Fill(img, w, h, imaging.Center, imaging.Lanczos)
|
||
imageCache.Set(key, filled, time.Hour)
|
||
return filled, nil
|
||
}
|
||
|
||
func drawCircle(mask *image.Alpha, cx, cy, r int, c color.Alpha) {
|
||
for x := -r; x <= r; x++ {
|
||
for y := -r; y <= r; y++ {
|
||
if x*x+y*y <= r*r {
|
||
mask.SetAlpha(cx+x, cy+y, c)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func drawCircleFilled(dst *image.RGBA, cx, cy, r int, clr color.Color) {
|
||
for x := -r; x <= r; x++ {
|
||
for y := -r; y <= r; y++ {
|
||
if x*x+y*y <= r*r {
|
||
dst.Set(cx+x, cy+y, clr)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func drawRoundedRect(dst *image.RGBA, rect image.Rectangle, clr color.Color, radius int) {
|
||
draw.Draw(dst, image.Rect(rect.Min.X+radius, rect.Min.Y, rect.Max.X-radius, rect.Max.Y),
|
||
&image.Uniform{clr}, image.Point{}, draw.Over)
|
||
draw.Draw(dst, image.Rect(rect.Min.X, rect.Min.Y+radius, rect.Max.X, rect.Max.Y-radius),
|
||
&image.Uniform{clr}, image.Point{}, draw.Over)
|
||
drawCircleFilledRGBA(dst, rect.Min.X+radius, rect.Min.Y+radius, radius, clr)
|
||
drawCircleFilledRGBA(dst, rect.Max.X-radius, rect.Min.Y+radius, radius, clr)
|
||
drawCircleFilledRGBA(dst, rect.Min.X+radius, rect.Max.Y-radius, radius, clr)
|
||
drawCircleFilledRGBA(dst, rect.Max.X-radius, rect.Max.Y-radius, radius, clr)
|
||
}
|
||
|
||
func drawCircleFilledRGBA(dst *image.RGBA, cx, cy, r int, clr color.Color) {
|
||
for x := -r; x <= r; x++ {
|
||
for y := -r; y <= r; y++ {
|
||
if x*x+y*y <= r*r {
|
||
dst.Set(cx+x, cy+y, clr)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func drawLine(dst *image.RGBA, x1, y1, x2, y2 int, clr color.Color) {
|
||
if y1 != y2 {
|
||
return
|
||
}
|
||
for x := x1; x <= x2; x++ {
|
||
dst.Set(x, y1, clr)
|
||
}
|
||
}
|
||
|
||
func drawText(dst *image.RGBA, text string, x, y int, fontSize float64, col color.Color, ftCtx *freetype.Context) {
|
||
ftCtx.SetClip(dst.Bounds())
|
||
ftCtx.SetSrc(image.NewUniform(col))
|
||
ftCtx.SetFontSize(fontSize)
|
||
ftCtx.SetDst(dst)
|
||
_, _ = ftCtx.DrawString(text, freetype.Pt(x, y))
|
||
}
|
||
|
||
// drawTextClipped 同 drawText,但将绘制裁剪到 clip 区域。
|
||
// 用于左侧文字区:即使宽度估算偏差导致某行文字偏宽,也只会被裁掉,不会侵入右侧二维码卡片。
|
||
func drawTextClipped(dst *image.RGBA, text string, x, y int, fontSize float64, col color.Color, ftCtx *freetype.Context, clip image.Rectangle) {
|
||
ftCtx.SetClip(clip)
|
||
ftCtx.SetSrc(image.NewUniform(col))
|
||
ftCtx.SetFontSize(fontSize)
|
||
ftCtx.SetDst(dst)
|
||
_, _ = ftCtx.DrawString(text, freetype.Pt(x, y))
|
||
}
|
||
|
||
// drawTextWithShadow 先绘制偏移阴影再绘制原色文字,增强在复杂背景上的可读性。
|
||
func drawTextWithShadow(dst *image.RGBA, text string, x, y int, fontSize float64, col, shadow color.Color, offset int, ftCtx *freetype.Context) {
|
||
drawText(dst, text, x+offset, y+offset, fontSize, shadow, ftCtx)
|
||
drawText(dst, text, x, y, fontSize, col, ftCtx)
|
||
}
|
||
|
||
func splitText(text string, fontSize float64, maxWidth int) []string {
|
||
var lines []string
|
||
var currentLine []rune
|
||
var currentWidth float64
|
||
for _, r := range text {
|
||
charW := charPixelWidth(r, fontSize)
|
||
if currentWidth+charW > float64(maxWidth) && len(currentLine) > 0 {
|
||
lines = append(lines, string(currentLine))
|
||
currentLine = []rune{r}
|
||
currentWidth = charW
|
||
} else {
|
||
currentLine = append(currentLine, r)
|
||
currentWidth += charW
|
||
}
|
||
}
|
||
if len(currentLine) > 0 {
|
||
lines = append(lines, string(currentLine))
|
||
}
|
||
return lines
|
||
}
|
||
|
||
func charPixelWidth(r rune, fontSize float64) float64 {
|
||
// 宽度系数取「保守上界」:中文字体是方形,实际渲染宽度≈fontSize;
|
||
// 若估算偏窄,splitText 换行后的行宽会超出 maxWidth,文字侵入相邻区域。
|
||
// 这里按实际宽度的上界估算,保证 splitText 的行数是安全的(宁可多换行,配合行数截断)。
|
||
if unicode.Is(unicode.Han, r) || unicode.Is(unicode.Punct, r) || unicode.Is(unicode.Symbol, r) {
|
||
return fontSize * 1.0
|
||
}
|
||
return fontSize * 0.62
|
||
}
|
||
|
||
// measureTextWidth 用逐字符宽度估算(与 splitText 的 charPixelWidth 一致),
|
||
// 避免每次测量都创建 freetype.Context 并执行 DrawString(显著降低生成耗时)。
|
||
func measureTextWidth(text string, fontSize float64) float64 {
|
||
return measureTextEstimate(text, fontSize)
|
||
}
|
||
|
||
func measureTextEstimate(text string, fontSize float64) float64 {
|
||
total := 0.0
|
||
for _, r := range text {
|
||
total += charPixelWidth(r, fontSize)
|
||
}
|
||
return total
|
||
}
|
||
|
||
func drawTextCentered(dst *image.RGBA, text string, cx, y int, fontSize float64, col color.Color, ftCtx *freetype.Context) {
|
||
ftCtx.SetClip(dst.Bounds())
|
||
ftCtx.SetSrc(image.NewUniform(col))
|
||
ftCtx.SetFontSize(fontSize)
|
||
actualW := measureTextWidth(text, fontSize)
|
||
x := cx - int(actualW/2)
|
||
if x < 0 {
|
||
x = 0
|
||
}
|
||
ftCtx.SetDst(dst)
|
||
_, _ = ftCtx.DrawString(text, freetype.Pt(x, y))
|
||
}
|
||
|
||
func generateQR(content string, user *db.User, logoImg image.Image) (image.Image, error) {
|
||
qr, err := qrcode.New(content, qrcode.High)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 强制传统黑白,忽略用户所有颜色设置
|
||
qr.BackgroundColor = color.RGBA{255, 255, 255, 255}
|
||
qr.ForegroundColor = color.RGBA{0, 0, 0, 255}
|
||
|
||
qrPaletted := qr.Image(256)
|
||
rgba := image.NewRGBA(qrPaletted.Bounds())
|
||
draw.Draw(rgba, rgba.Bounds(), qrPaletted, image.Point{}, draw.Src)
|
||
|
||
if logoImg != nil {
|
||
logo := imaging.Fill(logoImg, 48, 48, imaging.Center, imaging.Lanczos)
|
||
bounds := rgba.Bounds()
|
||
logoPos := image.Pt(
|
||
bounds.Min.X+(bounds.Dx()-logo.Bounds().Dx())/2,
|
||
bounds.Min.Y+(bounds.Dy()-logo.Bounds().Dy())/2,
|
||
)
|
||
draw.Draw(rgba, logo.Bounds().Add(logoPos), logo, image.Point{}, draw.Over)
|
||
}
|
||
return rgba, nil
|
||
}
|
||
|
||
// ---------- 单题分享图 ----------
|
||
|
||
// 单题分享图布局常量(1280x720)
|
||
const (
|
||
QContentX = 45
|
||
QContentY = 45
|
||
QContentW = CardWidth - 2*QContentX
|
||
QContentH = CardHeight - 2*QContentY
|
||
|
||
QAvatarSize = 33
|
||
QAvatarX = QContentX + 2
|
||
QAvatarY = QContentY + 2
|
||
QNameX = QAvatarX + QAvatarSize + 12
|
||
QNameY = QContentY + 28
|
||
|
||
QSepY = QContentY + 50
|
||
|
||
QLabelFont = 20.0
|
||
QQuestionFont = 26.0
|
||
QQuestionLineH = 34
|
||
QQuestionMaxLine = 3
|
||
QAnswerFont = 22.0
|
||
QAnswerLineH = 30
|
||
QAnswerMaxLine = 7
|
||
|
||
// 右侧二维码卡片
|
||
QQRCardW = 210
|
||
QQRCardH = 255
|
||
QQRCardX = QContentX + QContentW - QQRCardW
|
||
QQRCardY = QContentY + (QContentH-QQRCardH)/2
|
||
QQRSize = 128
|
||
QQRCodeX = (QQRCardW - QQRSize) / 2
|
||
|
||
// 内容区最大宽度(左侧,避开二维码卡片)
|
||
QTextMaxW = QQRCardX - QContentX - 30
|
||
|
||
QWatermarkY = CardHeight - 30
|
||
)
|
||
|
||
// 单题分享图结果缓存,TTL 30 分钟,上限 16 条(1C1G 内存友好)。
|
||
var questionShareCache = newTTLCache(16)
|
||
|
||
// QuestionShareCard 生成单个问题(问题+回答)的分享卡片图。
|
||
func QuestionShareCard(ctx context.Context) {
|
||
domain := ctx.Param("domain")
|
||
user, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
|
||
if err != nil {
|
||
ctx.Redirect("/")
|
||
return
|
||
}
|
||
|
||
questionID := uint(ctx.ParamInt("questionID"))
|
||
question, err := db.Questions.GetByID(ctx.Request().Context(), questionID)
|
||
if err != nil {
|
||
ctx.Redirect("/")
|
||
return
|
||
}
|
||
|
||
// 仅公开且已回答的问题可以生成分享图(Questioner 中间件已挡住私密/未回答,这里再兜底一次)
|
||
if question.UserID != user.ID || question.Answer == "" || question.IsPrivate {
|
||
ctx.Redirect("/")
|
||
return
|
||
}
|
||
|
||
// 站方处置:任何级别都禁止生成分享图。
|
||
// 分享卡会把「提问 + 回答」渲染成一张带箱主水印与二维码的图片,是挂人内容外流的主要通道
|
||
// —— 图片离开原页面后没有上下文、没有原链接、无法回溯,比页面本身传播得更远。
|
||
// 因此 L1「停止分发」首先就落在这里。查询失败按已处置处理(fail-closed):
|
||
// 宁可少出一张卡,也不要让处置在 DB 抖动时静默失效。
|
||
level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), user.ID)
|
||
if err != nil {
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query user restriction")
|
||
ctx.Redirect("/")
|
||
return
|
||
}
|
||
if level != "" {
|
||
logrus.WithContext(ctx.Request().Context()).
|
||
WithField("domain", domain).
|
||
WithField("level", level).
|
||
Info("Share card blocked by restriction")
|
||
ctx.Redirect("/")
|
||
return
|
||
}
|
||
|
||
cacheKey := questionShareCardCacheKey(user, question)
|
||
if cached, ok := questionShareCache.Get(cacheKey); ok {
|
||
writeShareCardResponse(ctx, cached.([]byte))
|
||
return
|
||
}
|
||
|
||
canvas := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight))
|
||
innerRect := image.Rect(QContentX, QContentY, QContentX+QContentW, QContentY+QContentH)
|
||
|
||
// ---- 并发加载背景与头像 ----
|
||
var (
|
||
bgImg image.Image
|
||
avImg image.Image
|
||
)
|
||
var wg sync.WaitGroup
|
||
bgURL := user.Background
|
||
if bgURL == "" {
|
||
bgURL = conf.App.ShareBackground
|
||
}
|
||
wg.Add(1)
|
||
shared.SafeGo(func() {
|
||
defer wg.Done()
|
||
img, err := loadImage(bgURL, QContentW, QContentH)
|
||
if err == nil {
|
||
bgImg = img
|
||
}
|
||
})
|
||
wg.Add(1)
|
||
shared.SafeGo(func() {
|
||
defer wg.Done()
|
||
img, err := loadImage(user.Avatar, QAvatarSize, QAvatarSize)
|
||
if err == nil {
|
||
avImg = img
|
||
}
|
||
})
|
||
wg.Wait()
|
||
|
||
// 背景 + 白色遮罩
|
||
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ColorWhite}, image.Point{}, draw.Src)
|
||
if bgImg != nil {
|
||
draw.Draw(canvas, innerRect, bgImg, image.Point{}, draw.Src)
|
||
}
|
||
draw.Draw(canvas, innerRect, image.NewUniform(OverlayColor), image.Point{}, draw.Over)
|
||
|
||
// 字体
|
||
ft, err := getFont()
|
||
if err != nil {
|
||
http.Error(ctx.ResponseWriter(), "分享图生成失败:字体文件未配置或无法加载", http.StatusInternalServerError)
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to load font for question share card")
|
||
return
|
||
}
|
||
fontCtx := freetype.NewContext()
|
||
fontCtx.SetFont(ft)
|
||
fontCtx.SetFontSize(24)
|
||
fontCtx.SetDPI(72)
|
||
|
||
// 头部:头像 + 名字
|
||
drawCircleFilled(canvas, QAvatarX+QAvatarSize/2, QAvatarY+QAvatarSize/2, QAvatarSize/2+2, ColorWhite)
|
||
if avImg != nil {
|
||
avImg = imaging.Fill(avImg, QAvatarSize, QAvatarSize, imaging.Center, imaging.Lanczos)
|
||
mask := image.NewAlpha(image.Rect(0, 0, QAvatarSize, QAvatarSize))
|
||
drawCircle(mask, QAvatarSize/2, QAvatarSize/2, QAvatarSize/2, color.Alpha{255})
|
||
draw.DrawMask(canvas,
|
||
image.Rect(QAvatarX, QAvatarY, QAvatarX+QAvatarSize, QAvatarY+QAvatarSize),
|
||
avImg, image.Point{}, mask, image.Point{}, draw.Over)
|
||
} else {
|
||
mask := image.NewAlpha(image.Rect(0, 0, QAvatarSize, QAvatarSize))
|
||
drawCircle(mask, QAvatarSize/2, QAvatarSize/2, QAvatarSize/2, color.Alpha{255})
|
||
draw.DrawMask(canvas,
|
||
image.Rect(QAvatarX, QAvatarY, QAvatarX+QAvatarSize, QAvatarY+QAvatarSize),
|
||
&image.Uniform{color.Gray{180}}, image.Point{}, mask, image.Point{}, draw.Over)
|
||
}
|
||
drawTextWithShadow(canvas, user.Name+" 的提问箱", QNameX, QNameY, 22, ColorDarkText, color.NRGBA{0, 0, 0, 45}, 1, fontCtx)
|
||
|
||
// 分隔线
|
||
drawLine(canvas, QContentX+2, QSepY, QContentX+QContentW-2, QSepY, color.NRGBA{190, 190, 190, 200})
|
||
|
||
// 左侧文字区裁剪边界:问题/回答文字不得侵入右侧二维码卡片(QQRCardX 左侧留 10px 间距)。
|
||
qLeftClip := image.Rect(QContentX, QContentY, QQRCardX-10, QContentY+QContentH)
|
||
|
||
// 问题区
|
||
labelY := QSepY + 32
|
||
drawText(canvas, "提问", QContentX+2, labelY, QLabelFont, ColorGrayText, fontCtx)
|
||
|
||
textY := labelY + 30
|
||
textY = drawTextMultilineLimited(canvas, sanitizeText(template.MarkdownToPlainText(question.Content)), QContentX+2, textY, QTextMaxW, QQuestionFont, ColorDarkText, fontCtx, QQuestionLineH, QQuestionMaxLine, qLeftClip)
|
||
|
||
// 回答区
|
||
labelY = textY + 22
|
||
drawText(canvas, "回答", QContentX+2, labelY, QLabelFont, ColorGrayText, fontCtx)
|
||
|
||
textY = labelY + 28
|
||
textY = drawTextMultilineLimited(canvas, sanitizeText(template.MarkdownToPlainText(question.Answer)), QContentX+2, textY, QTextMaxW, QAnswerFont, ColorDarkText, fontCtx, QAnswerLineH, QAnswerMaxLine, qLeftClip)
|
||
_ = textY
|
||
|
||
// 右侧二维码卡片
|
||
cardRect := image.Rect(QQRCardX, QQRCardY, QQRCardX+QQRCardW, QQRCardY+QQRCardH)
|
||
drawRoundedRect(canvas, cardRect, color.NRGBA{245, 245, 245, 255}, 20)
|
||
drawRoundedRect(canvas, cardRect, ColorWhite, 20)
|
||
|
||
questionURL := fmt.Sprintf("%s/_/%s/%d", conf.App.ExternalURL, user.Domain, question.ID)
|
||
qr, err := generateQR(questionURL, user, avImg)
|
||
if err != nil {
|
||
http.Error(ctx.ResponseWriter(), "二维码生成失败", http.StatusInternalServerError)
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to generate QR code for question share card")
|
||
return
|
||
}
|
||
qrResized := imaging.Resize(qr, QQRSize, QQRSize, imaging.NearestNeighbor)
|
||
qrDrawX := QQRCardX + QQRCodeX
|
||
// 二维码与提示语整体垂直居中(已删除底部冗余的长链接,卡片更简洁)
|
||
qrDrawY := QQRCardY + 40
|
||
draw.Draw(canvas, image.Rect(qrDrawX, qrDrawY, qrDrawX+QQRSize, qrDrawY+QQRSize), qrResized, image.Point{}, draw.Over)
|
||
|
||
hintY := qrDrawY + QQRSize + 30
|
||
drawTextCentered(canvas, "扫码查看回答", QQRCardX+QQRCardW/2, hintY, 17, ColorHintGray, fontCtx)
|
||
|
||
// 底部水印
|
||
drawTextCentered(canvas, "Powered by 狼的提问箱", CardWidth/2, QWatermarkY, FontSizeFooter, ColorFooterGray, fontCtx)
|
||
|
||
// 输出(缓存 + 响应)
|
||
buf := new(bytes.Buffer)
|
||
if err := jpeg.Encode(buf, canvas, &jpeg.Options{Quality: 88}); err != nil {
|
||
http.Error(ctx.ResponseWriter(), "图片生成失败", http.StatusInternalServerError)
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to encode question share card PNG")
|
||
return
|
||
}
|
||
|
||
questionShareCache.Set(cacheKey, buf.Bytes(), time.Hour)
|
||
writeShareCardResponse(ctx, buf.Bytes())
|
||
}
|
||
|
||
// questionShareCardCacheKey 基于问题内容与用户资料生成缓存指纹,内容/资料变更自动失效。
|
||
func questionShareCardCacheKey(user *db.User, question *db.Question) string {
|
||
h := md5.New()
|
||
fmt.Fprintf(h, "q|%d|%s|%s|%s|%s|%s", question.ID, question.Content, question.Answer, user.Name, user.Avatar, user.Background)
|
||
return fmt.Sprintf("qshare:%x", h.Sum(nil))
|
||
}
|
||
|
||
// sanitizeText 将换行符替换为空格,避免绘图时排版错乱。
|
||
func sanitizeText(text string) string {
|
||
return strings.ReplaceAll(text, "\n", " ")
|
||
}
|
||
|
||
// drawTextMultilineLimited 多行绘制,超过 maxLines 行时截断并追加省略号。
|
||
// clip 限定绘制区域,防止估算换行偏差导致文字溢出到相邻元素(如右侧二维码卡片)。
|
||
func drawTextMultilineLimited(dst *image.RGBA, text string, x, y int, maxWidth int, fontSize float64, col color.Color, ftCtx *freetype.Context, lineHeight int, maxLines int, clip image.Rectangle) int {
|
||
lines := splitText(text, fontSize, maxWidth)
|
||
if len(lines) > maxLines {
|
||
lines = lines[:maxLines]
|
||
lines[maxLines-1] = lines[maxLines-1] + "…"
|
||
}
|
||
curY := y
|
||
for _, line := range lines {
|
||
drawTextClipped(dst, line, x, curY, fontSize, col, ftCtx, clip)
|
||
curY += lineHeight
|
||
}
|
||
return curY
|
||
}
|