Files
tamabox/route/question/share.go
T
2026-09-08 10:10:44 +08:00

971 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
// 命中缓存直接返回,避免重复下载图片与重新绘制
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
}
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
}