package question import ( "bytes" stdctx "context" "crypto/md5" "errors" "fmt" "image" "image/color" "image/draw" "image/jpeg" "net" "net/http" "net/url" "os" "path/filepath" "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" "golang.org/x/image/math/fixed" "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 = 335 QRCardX = ContentX + ContentW - QRCardW - 15 QRCardY = ContentY + (ContentH-QRCardH)/2 ) // 二维码尺寸。190 在 37 模块(High 纠错、55 字符链接)下取整为 5px/模块: // 实测 5px/模块的卡缩到 480、320 宽仍可解码,4px 时 480 就解不出。 const ( QRCodeSize = 190 ) // 社交信息 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 ) // 链接区排版:可用宽度按卡片内边距 20px 计算;字号下限保证长域名仍然可读, // 单行放不下时改为 host/path 两行,而不是把字号一路缩到看不清。 const ( LinkMaxWidth = QRCardW - 40 LinkSingleMinSize = 13.0 LinkMinSize = 11.0 LinkSizeStep = 0.5 ) // 颜色系统 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} ) // 全局字体缓存。 // // fontFail* 记录"上一次加载失败"与时间:失败结果要短期缓存,不能每次请求都重来。 // 缺字体时单次尝试的代价是遍历候选路径 + 读进 19 MB 的 .ttc 再解析失败(实测 0.7–4 秒纯 CPU), // 而分享卡是**公开端点**——不缓存失败就等于给任何人一个"发一个 GET 烧几秒 CPU"的放大器。 // 仍保留 1 分钟重试窗口:字体文件是部署产物,补上后无需重启即可恢复。 var ( fontMu sync.Mutex fontCache *truetype.Font fontFailAt time.Time fontFailErr error ) // fontFailRetry 是加载失败后的重试间隔。 const fontFailRetry = time.Minute // getFont 返回绘图字体。成功结果永久缓存;失败结果缓存 fontFailRetry 后重试。 func getFont() (*truetype.Font, error) { fontMu.Lock() defer fontMu.Unlock() if fontCache != nil { return fontCache, nil } if fontFailErr != nil && time.Since(fontFailAt) < fontFailRetry { return nil, fontFailErr } font, err := loadFont() if err != nil { fontFailAt, fontFailErr = time.Now(), err return nil, err } fontCache = font fontFailErr = nil return font, nil } // loadFont 遍历候选路径,返回第一个能被解析的字体。 func loadFont() (*truetype.Font, error) { ft, _, err := loadFontWithPath() return ft, err } // loadFontWithPath 同 loadFont,但额外返回命中的文件路径(启动日志要打出来)。 func loadFontWithPath() (*truetype.Font, string, error) { var tried []string for _, path := range fontCandidates() { tried = append(tried, path) data, err := os.ReadFile(path) if err != nil { continue } if ft, err := freetype.ParseFont(data); err == nil { return ft, path, nil } // .ttc(TrueType Collection)是「字体集合」,ParseFont 读不了它; // 先提取第一个字面再试。注意:这只对 TrueType 轮廓(glyf)的集合有效, // Noto Sans CJK 那种 CFF/OTTO 轮廓 freetype 本身就不支持,提出来照样失败。 if extracted, ttcErr := ttcFirstFont(data); ttcErr == nil { if ft, err := freetype.ParseFont(extracted); err == nil { return ft, path + "(ttc 首字面)", nil } } } if len(tried) == 0 { return nil, "", errors.New("font_path 未配置,且 static/fonts 下没有 .otf/.ttf/.ttc") } return nil, "", fmt.Errorf("没有可用字体(需 TrueType 轮廓的 ttf/otf/ttc,Noto Sans CJK 是 CFF 轮廓不支持),已尝试:%s", strings.Join(tried, "、")) } // CheckShareCardFont 在启动时探一次字体并把结果写进缓存,返回命中的路径。 // // 为什么要提前探:分享卡是公开端点,缺字体只会在「第一个真实请求」时才 500, // 而那已经是线上事故;启动日志里一条 WARN + 补救命令能让人当场发现。 // 顺带把首次请求的解析开销(读十几 MB + 解析)挪到启动阶段。 func CheckShareCardFont() (string, error) { fontMu.Lock() defer fontMu.Unlock() ft, path, err := loadFontWithPath() if err != nil { fontFailAt, fontFailErr = time.Now(), err return "", err } fontCache, fontFailErr = ft, nil return path, nil } // fontCandidates 字体候选路径:配置项优先,其次是部署目录里的字体文件 // (换过文件名也能用上),最后是系统里能用的 CJK 字体。 // // 顺序是有意的: // - static/fonts 是部署产物,最可能就是「运维补的那个」; // - 系统候选把 glyf 轮廓的放前面,Noto Sans CJK 压到最后 —— 后者是 CFF/OTTO, // freetype 一定解析失败,只是偶尔会有 glyf 变体才留着当兜底。 // // 相对路径按「当前工作目录」与「可执行文件所在目录」各查一次:systemd 单元 // 未必配 WorkingDirectory,只按 CWD 找会出现「文件明明在却找不到」。 func fontCandidates() []string { var paths []string if p := strings.TrimSpace(conf.App.FontPath); p != "" { paths = append(paths, p) } for _, dir := range staticFontDirs() { for _, pattern := range []string{"*.ttf", "*.otf", "*.ttc"} { if matches, err := filepath.Glob(filepath.Join(dir, pattern)); err == nil { paths = append(paths, matches...) } } } return append(paths, // glyf 轮廓的 CJK 字体:apt install fonts-wqy-zenhei / fonts-wqy-microhei / // fonts-droid-fallback / fonts-arphic-uming 任装一个即可工作 "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf", "/usr/share/fonts/truetype/arphic/uming.ttc", // 兜底:CFF 轮廓,通常会解析失败,仅在存在 glyf 变体时有意义 "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf", ) } // staticFontDirs 返回要去扫字体文件的目录(去重、跳过不存在的)。 func staticFontDirs() []string { dirs := []string{filepath.Join("static", "fonts")} if exe, err := os.Executable(); err == nil { dirs = append(dirs, filepath.Join(filepath.Dir(exe), "static", "fonts")) } seen := make(map[string]struct{}, len(dirs)) out := make([]string, 0, len(dirs)) for _, d := range dirs { if _, ok := seen[d]; ok { continue } seen[d] = struct{}{} if st, err := os.Stat(d); err == nil && st.IsDir() { out = append(out, d) } } return out } // 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 } // Delete 移除单条缓存。 // 用于读到「类型不符的脏数据」时丢弃这一条 —— 没有它就只能等下次 Set 覆盖, // 而重建路径万一再次失败,这条脏数据会一直留在缓存里被反复读到。 func (c *ttlCache) Delete(key string) { c.mu.Lock() defer c.mu.Unlock() delete(c.items, key) } 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 1 小时。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 { if data, isBytes := cached.([]byte); isBytes { writeShareCardResponse(ctx, data) return } // 类型不符:理论上不会发生(key 由本文件独占写入),但裸断言遇到不符就是 panic。 // 丢弃这条脏缓存,走下面重新绘制。 shareCardCache.Delete(cacheKey) } 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, avImg, QRCodeSize) if err != nil { http.Error(ctx.ResponseWriter(), "二维码生成失败", http.StatusInternalServerError) logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to generate QR code") return } qrSize := qr.Bounds().Dx() const ( qrToHintGap = 24 hintLineHeight = 17 hintToSepGap = 20 sepHeight = 1 sepToLinkGap = 20 linkLineHeight = 16 ) linkLines, linkFontSize := layoutLink(displayURL(boxURL)) totalContentH := qrSize + qrToHintGap + hintLineHeight + hintToSepGap + sepHeight + sepToLinkGap + linkLineHeight*len(linkLines) startY := QRCardY + (QRCardH-totalContentH)/2 qrDrawX := QRCardX + (QRCardW-qrSize)/2 qrDrawY := startY draw.Draw(canvas, image.Rect(qrDrawX, qrDrawY, qrDrawX+qrSize, qrDrawY+qrSize), qr, image.Point{}, draw.Over) hintY := qrDrawY + qrSize + 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 for i, line := range linkLines { drawTextCentered(canvas, line, cardCenterX, linkY+i*linkLineHeight, 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 变化,缓存自动失效。 // conf 里的 ExternalURL / ShareBackground 不入 key:改配置必须重启,进程内缓存随之清空。 // 头像、背景走 R2 随机 key(内容不可变),换图即换 URL,也不会出现同名不同图。 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 { if img, isImage := cached.(image.Image); isImage { return img, nil } imageCache.Delete(key) } 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 返回文本在给定字号下的真实推进宽度(像素)。 // 逐字累加字形 advance 与 kern,scale 与 freetype.Context 在 72 DPI 下的换算一致 // (fontSize * 64);用它做居中,误差从原来估算的几十像素降到亚像素。 // 字体不可用时回退到字符宽度估算,保证分享卡不会因字体缺失而完全不可用。 func measureTextWidth(text string, fontSize float64) float64 { ft, err := getFont() if err != nil || ft == nil { return measureTextEstimate(text, fontSize) } scale := fixed.Int26_6(fontSize * 64.0) total := fixed.Int26_6(0) var prev truetype.Index hasPrev := false for _, r := range text { idx := ft.Index(r) if hasPrev { total += ft.Kern(scale, prev, idx) } total += ft.HMetric(scale, idx).AdvanceWidth prev, hasPrev = idx, true } return float64(total) / 64.0 } // measureTextEstimate 按字符宽度系数粗估,仅作字体不可用时的回退。 // 注意:标点/符号按 1em 计,对 URL 会高估 50% 以上,不能用于居中定位。 func measureTextEstimate(text string, fontSize float64) float64 { total := 0.0 for _, r := range text { total += charPixelWidth(r, fontSize) } return total } // displayURL 去掉协议前缀与结尾斜杠:二维码里仍编码完整地址, // 人眼需要辨认的只是 host 与路径。 func displayURL(rawURL string) string { s := strings.TrimPrefix(rawURL, "https://") s = strings.TrimPrefix(s, "http://") return strings.TrimSuffix(s, "/") } // layoutLink 把展示用地址收进卡片宽度,返回逐行文本与统一字号。 // 顺序:单行 15→13px;仍放不下按 host/path 拆两行(13→11px,而不是在 // host 中间的连字符处断开);host 自身超宽才做中间省略。 func layoutLink(text string) ([]string, float64) { maxW := float64(LinkMaxWidth) for size := float64(FontSizeLink); size >= LinkSingleMinSize; size -= LinkSizeStep { if measureTextWidth(text, size) <= maxW { return []string{text}, size } } if i := strings.Index(text, "/"); i > 0 { host, path := text[:i], text[i:] for size := LinkSingleMinSize; size >= LinkMinSize; size -= LinkSizeStep { if measureTextWidth(host, size) <= maxW && measureTextWidth(path, size) <= maxW { return []string{host, path}, size } } return []string{ ellipsizeMiddle(host, maxW, LinkMinSize), ellipsizeMiddle(path, maxW, LinkMinSize), }, LinkMinSize } return []string{ellipsizeMiddle(text, maxW, LinkMinSize)}, LinkMinSize } // ellipsizeMiddle 中间省略:保住开头与结尾(域名结尾通常是 TLD,最需要留下)。 func ellipsizeMiddle(text string, maxWidth, fontSize float64) string { if measureTextWidth(text, fontSize) <= maxWidth { return text } r := []rune(text) for keep := len(r) - 1; keep >= 3; keep-- { head := keep * 6 / 10 tail := keep - head s := string(r[:head]) + "…" + string(r[len(r)-tail:]) if measureTextWidth(s, fontSize) <= maxWidth { return s } } return text } 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)) } // generateQR 生成边长固定为 size 的二维码:按模块整数倍出图再居中留白, // 避免直接缩放到任意尺寸造成的模块宽窄相差 1px(小尺寸下最影响可扫性)。 func generateQR(content string, logoImg image.Image, size int) (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} // 静默区由承载二维码的白色卡片提供(四周留白远超 4 模块),图像本身只输出符号: // 若把静默区画进图里,再叠加居中留白,目标 165px 实际只剩 111px,模块被压到 3px, // 微信/QQ 二次压缩后极易扫不动。 qr.DisableBorder = true modules := len(qr.Bitmap()) perModule := size / modules if perModule < 1 { perModule = 1 } symbol := qr.Image(-perModule) dst := image.NewRGBA(symbol.Bounds()) draw.Draw(dst, dst.Bounds(), symbol, image.Point{}, draw.Src) if logoImg != nil { w := dst.Bounds().Dx() logoSize := w * 3 / 16 logo := imaging.Fill(logoImg, logoSize, logoSize, imaging.Center, imaging.Lanczos) // 白边保证头像与模块之间有对比度,H 级容错(30%)足以覆盖这点面积 holder := image.Rect(0, 0, logoSize+4, logoSize+4) holder = holder.Add(image.Pt((w-logoSize-4)/2, (w-logoSize-4)/2)) draw.Draw(dst, holder, &image.Uniform{ColorWhite}, image.Point{}, draw.Src) draw.Draw(dst, logo.Bounds().Add(holder.Min.Add(image.Pt(2, 2))), logo, image.Point{}, draw.Over) } return dst, 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 = 236 QQRCardH = 300 QQRCardX = QContentX + QContentW - QQRCardW QQRCardY = QContentY + (QContentH-QQRCardH)/2 QQRSize = 190 // 内容区最大宽度(左侧,避开二维码卡片) QTextMaxW = QQRCardX - QContentX - 30 QWatermarkY = CardHeight - 30 ) // drawQuestionQRCard 绘制单题卡右侧的二维码卡片:二维码与提示语整体垂直居中。 func drawQuestionQRCard(canvas *image.RGBA, user *db.User, question *db.Question, logo image.Image, fontCtx *freetype.Context) error { 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, logo, QQRSize) if err != nil { return err } const qrToHintGap, hintLineHeight = 30, 17 qrSize := qr.Bounds().Dx() contentH := qrSize + qrToHintGap + hintLineHeight qrX := QQRCardX + (QQRCardW-qrSize)/2 qrY := QQRCardY + (QQRCardH-contentH)/2 draw.Draw(canvas, image.Rect(qrX, qrY, qrX+qrSize, qrY+qrSize), qr, image.Point{}, draw.Over) drawTextCentered(canvas, "扫码查看回答", QQRCardX+QQRCardW/2, qrY+qrSize+qrToHintGap+hintLineHeight, FontSizeQRHint, ColorHintGray, fontCtx) return nil } // 单题分享图结果缓存,TTL 1 小时,上限 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 { if data, isBytes := cached.([]byte); isBytes { writeShareCardResponse(ctx, data) return } questionShareCache.Delete(cacheKey) } 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 // 右侧二维码卡片 if err := drawQuestionQRCard(canvas, user, question, avImg, fontCtx); 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 } // 底部水印 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 基于问题内容与用户资料生成缓存指纹,内容/资料变更自动失效。 // user.Domain 虽然当前不可改(只在注册时写入),但它进了二维码内容:一旦将来开放改域名, // 漏掉它就会出现「卡片二维码指向旧域名」的一小时窗口,这里先按事实把它算进去。 func questionShareCardCacheKey(user *db.User, question *db.Question) string { h := md5.New() fmt.Fprintf(h, "q|%d|%s|%s|%s|%s|%s|%s", question.ID, question.Content, question.Answer, user.Domain, 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 }