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/test-accounts.md 的抬头注释。
/.workbuddy/local/ /.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` 造的数据对不上, - 顺手修预览测试:`GrowthMoM` 原来写死 12.3,与 `monthly` 造的数据对不上,
而新句会同时印出两个月的条数 → 截图会显示「5620 比 4890 多了 12.3%」这种假证据。 而新句会同时印出两个月的条数 → 截图会显示「5620 比 4890 多了 12.3%」这种假证据。
改成从 `monthly[10]/monthly[9]` 算。 改成从 `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 全文): 明细已拆分到 `topics/*.md`(按需 Read 全文):
`coop` 协作 / `build` 构建测试 / `embed` 行尾·模板·embed / `review` 审核处置举报 / `coop` 协作 / `build` 构建测试 / `embed` 行尾·模板·embed / `review` 审核处置举报 /
`gorm` GORM / `style` 前端样式 / `theme` 主题 / `builder` 主题生成器 / `share` 分享卡 / `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`。 `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 不带库名)。 - DB 测试:`DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=15207786347hXPm DB_HOST=127.0.0.1 DB_PORT=5432`(DSN 不带库名)。
- store 方法内用 `NewXStore(db.DB)`,别引用包级单例。 - 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`。 - 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) ## 行尾 / 模板 / embed(topics/embed.md)
@@ -32,21 +44,39 @@
- 线上通道 = 七牛;裁决在 `censor/policy.go` + `moderation.Check`,新增调用点必须走 `moderation.Check`,不可用时 fail-closed。 - 线上通道 = 七牛;裁决在 `censor/policy.go` + `moderation.Check`,新增调用点必须走 `moderation.Check`,不可用时 fail-closed。
- 严格度存 `users.moderation_strictness`;受处置用户强制 strict(`moderation.EffectiveStrictness`),查询失败回退用户设置。 - 严格度存 `users.moderation_strictness`;受处置用户强制 strict(`moderation.EffectiveStrictness`),查询失败回退用户设置。
- 同请求渲染只能用 `ctx.SetError`/`SetInternalError`,不能 `*Flash`。 - 同请求渲染只能用 `ctx.SetError`/`SetInternalError`,不能 `*Flash`。
- 写路径查功能开关用 `db.FeatureStateOfStrict`(失败即拒);`FeatureStateOf` 的
fail-open 只给读展示用 —— 它的默认值对提问/投票是「开启」,等于让一次配置读失败
绕过箱主的关闭与定量上限。
- 处置四级 L1–L4;到期靠 `expires_at` 无 cron;`restrict` 是替换语义;比较用 `RestrictionAtLeast`。 - 处置四级 L1–L4;到期靠 `expires_at` 无 cron;`restrict` 是替换语义;比较用 `RestrictionAtLeast`。
- L2 强制私密须在审核通过后、与写回答同一条 UPDATE(`AnswerByIDPrivate`)。 - L2 强制私密须在审核通过后、与写回答同一条 UPDATE(`AnswerByIDPrivate`)。
- `ListAPI` 不经 Pager 中间件,L4 与页面密码须自校验;分享卡拦截须在缓存查询之前。 - `ListAPI` 不经 Pager 中间件,L4 与页面密码须自校验;分享卡拦截须在缓存查询之前。
- 举报:免登录+验证码+限流;同 IP 同题唯一索引 → 幂等;箱主只看次数不给 IP。 - 举报:免登录+验证码+限流;同 IP 同题唯一索引 → 幂等;箱主只看次数不给 IP。
- 告警 24h≥8 或 7d≥20 → 邮件;`[security] security_alert_email` 为空则静默跳过。 - 告警 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) ## GORM(topics/gorm.md)
- 默认值必须带引号;事务里每句 `tx.WithContext(ctx)`;链式对象带状态,统计/更新各自重建 query。 - 默认值必须带引号;事务里每句 `tx.WithContext(ctx)`;链式对象带状态,统计/更新各自重建 query。
- 软删除+唯一索引 = 槽位永久占用;级联删除一律 `Unscoped()`。 - 软删除+唯一索引 = 槽位永久占用;级联删除一律 `Unscoped()`。
- **「用户主动删除」= 真删**:模型全带 `dbutil.Model` 的 `DeletedAt`,默认 Delete 是软删 ——
提交记录里的 `voter_ip`/文本、提问里的 `FromIP` 会永久留在库里。删投票/删提问已改
`Unscoped` 并收进一个事务(`Votes.DeleteWithCascade`、`Questions.DeleteByID`)。
- **配额必须与内容同事务**:`incrementQuotaTx(tx,...)` 是唯一实现;别在 handler 里
再补一次 `IncrementQuota`(那是独立写入,失败只 Warn → `quota_count` 永久偏低)。
注意口径按功能不同:提问/私聊是「条数」,投票是「累计收到的票数」。
## 前端样式(topics/style.md) ## 前端样式(topics/style.md)
- `static/css/base.css`(`?v=` 取 `conf.StaticVersion`,改完 +1)。令牌在 `:root` + `html[data-theme="dark"]`,新样式一律 `var(--tb-*)`。 - `static/css/base.css`(`?v=` 取 `conf.StaticVersion`,改完 +1)。令牌在 `:root` + `html[data-theme="dark"]`,新样式一律 `var(--tb-*)`。
- 移动端(≤640px)语言/主题按钮在固定底栏 `#tb-mobile-utils`;箱主页**有意**无语言切换。 - 移动端(≤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 末尾映射。 - UIkit 3.3.3:`.uk-flex::before/::after{display:none}`;主色是编译期常量,需在 base.css 末尾映射。
- 视觉自检:`TEST_PREVIEW=1 go test ./templates -run TestGenPreview`;截图用 `chrome --headless=old`。 - 视觉自检:`TEST_PREVIEW=1 go test ./templates -run TestGenPreview`;截图用 `chrome --headless=old`。
@@ -59,8 +89,28 @@
- 主题生成器 `dev/theme-builder/`:非破坏性图片处理、`layerSize`/`unitGeom` 必须共用同一函数、 - 主题生成器 `dev/theme-builder/`:非破坏性图片处理、`layerSize`/`unitGeom` 必须共用同一函数、
`hideDeco` 会把 @media 10 槽全写 none(窄屏素材消失的头号来源)。 `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) ## 其它约定(topics/misc.md)
- 健康检查:`/healthz` 永远 200(不碰任何依赖,只看进程活着)+ `/readyz` 查 DB(失败 503)。
别把 DB 检查并进 healthz —— 数据库抖动会让进程被反复重启,对恢复毫无帮助。
- 校验用 `valid:"required;maxlen:N"` 标签。 - 校验用 `valid:"required;maxlen:N"` 标签。
- 只有箱主 `/user` 面板,**无管理员角色、无站方后台**(用户要求不要加):站方动作只走 CLI / 邮件 / 改配置重启。 - 只有箱主 `/user` 面板,**无管理员角色、无站方后台**(用户要求不要加):站方动作只走 CLI / 邮件 / 改配置重启。
- CLI 有两个构建入口必须同步:根 `go build .` 与 Dockerfile 的 `./cmd/`。 - CLI 有两个构建入口必须同步:根 `go build .` 与 Dockerfile 的 `./cmd/`。
+28
View File
@@ -32,3 +32,31 @@
(`go version` 显示的还是 shell 包装器的版本,容易误判成「版本没变」)。 (`go version` 显示的还是 shell 包装器的版本,容易误判成「版本没变」)。
解法:直接用缓存里的 toolchain 并禁用自动切换—— 解法:直接用缓存里的 toolchain 并禁用自动切换——
`PATH="$HOME/go/pkg/mod/golang.org/[email protected]/bin:$PATH" GOTOOLCHAIN=local go test ./...` `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)场景 —— ; —— 双 CDN(境内腾讯 EdgeOne + 境外 Cloudflare)场景 ——
; EdgeOne 回源会用 EO-Client-IP 携带客户端真实 IP(等效 CF-Connecting-IP), ; EdgeOne 回源会用 EO-Client-IP 携带客户端真实 IP(等效 CF-Connecting-IP),
; 本程序在 trust_proxy=true 时已同时信任这两个权威头。 ; 本程序在 trust_proxy = true 时同时支持这两个权威头。
; 两家 CDN 并存时必须:① 防火墙仅放行两家回源网段;② 在各家 CDN 的规则引擎中 ; ⚠️ 权威头只在「TCP 对端可信」时才被采信(回环地址,或命中 trusted_proxies)。
; 删除回源请求里「对方 CDN」的权威头(经 EdgeOne 回源删 CF-Connecting-IP、 ; 这条是代码里的硬约束,不再依赖运维纪律 —— EdgeOne 不剥离客户端自带的 CF-Connecting-IP,
; 经 Cloudflare 回源删 EO-Client-IP),否则攻击者可借道另一家伪造客户端身份, ; 旧实现无条件优先读它,导致经 EdgeOne 进来的请求客户端 IP 完全可控:
; 绕过限流 / 自动封禁 / 单 IP 投票限制。详细步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md。 ; 限流 / IP 黑名单失效,且 autoban 按假 IP 记账可被用来栽赃任意正常用户。
; 双 CDN 并存时:① 两家回源网段都登记到 trusted_proxies;② 防火墙仅放行两家回源网段;
; ③ 仍建议在各家规则引擎里删掉「对方 CDN」的权威头(纵深防御)。
; 仅用 cloudflared Tunnel 回本机时对端是 127.0.0.1,无需任何额外配置。
; 重新启用 EdgeOne 前请先按本节核对,步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md。
trusted_proxies = trusted_proxies =
; 逗号分隔的「你自己的」反向代理/负载均衡 IP 或 CIDR(如 127.0.0.1、 ; 逗号分隔的「你自己的」反向代理/负载均衡 IP 或 CIDR(如 10.0.0.0/8),
; 10.0.0.0/8)。仅当 TCP 对端落在该清单内时才解析 X-Forwarded-For / X-Real-IP。 ; 或「CDN 直连回源」形态下该 CDN 的回源网段。
; 留空(默认)则这两个头永不被信任,仅采用 CDN 权威头 CF-Connecting-IP / ; 回环地址(127.0.0.1 / ::1)无需登记即视为可信对端:cloudflared / 本机 nginx
; EO-Client-IP(需源站已锁定)。 ; 回源的连接必然由本机进程发起,外部无法伪造。
; 注意:此清单用于你自己的基础设施,不是 CDN 的全量 CIDR 清单。 ; 除回环以外,CDN 权威头也必须对端命中本清单才会被采信 —— 公网对端带来的
; 自动封禁(fail2ban 风格):检测到批量扫描/探测后临时封禁来源 IP ; 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 enable_auto_ban = true
; 是否把「未注册路径的 404」计入封禁统计(默认 false)。正常业务 ; 是否把「未注册路径的 404」计入封禁统计(默认 false)。正常业务
ban_track_notfound = false ban_track_notfound = false
@@ -313,4 +355,4 @@ timezone = Asia/Shanghai
default = default default = default
available = default,sakura,shiroko,new-sakura available = default,sakura,shiroko,new-sakura
; ⚠️ 没登记在 available 里的主题,即使 css 已随二进制发布也不会出现在设置页; ; ⚠️ 没登记在 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/blackrock v0.0.2 // indirect
github.com/projectdiscovery/retryabledns v1.0.115 // indirect github.com/projectdiscovery/retryabledns v1.0.115 // indirect
github.com/projectdiscovery/utils v0.11.1 // 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/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.1 // indirect github.com/richardlehane/msoleps v1.0.1 // indirect
github.com/rivo/uniseg v0.4.7 // 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 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 写入缓存并设定过期时间。 // Set 写入缓存并设定过期时间。
func (c *TTLCache) Set(key string, value interface{}, ttl time.Duration) { func (c *TTLCache) Set(key string, value interface{}, ttl time.Duration) {
c.mu.Lock() c.mu.Lock()
+66
View File
@@ -59,3 +59,69 @@ func TestTTLCacheConcurrent(t *testing.T) {
} }
wg.Wait() 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")
}
}
+96 -4
View File
@@ -5,8 +5,12 @@
package cmd package cmd
import ( import (
"context"
"fmt" "fmt"
"net/http" "net/http"
"os"
"os/signal"
"syscall"
"time" "time"
"github.com/pkg/errors" "github.com/pkg/errors"
@@ -17,11 +21,13 @@ import (
"github.com/tamakyi/TamaBox/internal/conf" "github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db" "github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/insights"
"github.com/tamakyi/TamaBox/internal/route" "github.com/tamakyi/TamaBox/internal/route"
"github.com/tamakyi/TamaBox/internal/security/honeypot" "github.com/tamakyi/TamaBox/internal/security/honeypot"
"github.com/tamakyi/TamaBox/internal/shared" "github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/internal/stats" "github.com/tamakyi/TamaBox/internal/stats"
"github.com/tamakyi/TamaBox/internal/tracing" "github.com/tamakyi/TamaBox/internal/tracing"
"github.com/tamakyi/TamaBox/route/question"
) )
var Web = &cli.Command{ var Web = &cli.Command{
@@ -69,10 +75,18 @@ func runWeb(ctx *cli.Context) error {
} }
conf.Database.DSN = dsn conf.Database.DSN = dsn
_, err := db.Init(dbType, dsn) gdb, err := db.Init(dbType, dsn)
if err != nil { if err != nil {
return errors.Wrap(err, "connect to database") 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 是只追加的审计表,写入完全由 // 蜜罐命中记录保留期清理:honeypot_hits 是只追加的审计表,写入完全由
// 外部扫描流量驱动(不可预期、不可节流)。没有自动清理的话,表体积只增不减。 // 外部扫描流量驱动(不可预期、不可节流)。没有自动清理的话,表体积只增不减。
@@ -93,11 +107,45 @@ func runWeb(ctx *cli.Context) error {
defer close(stopRetention) 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") 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) // 站点默认语言初始化(default_lang 配置,未配置时回退 zh-CN)
shared.App.SetLang(conf.App.DefaultLang) 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 := route.New()
r.Use(tracing.Middleware("TamaBox")) r.Use(tracing.Middleware("TamaBox"))
@@ -112,11 +160,20 @@ func runWeb(ctx *cli.Context) error {
}() }()
// 最外层挂载 gzip 压缩,并在 http.Server 上配置超时: // 最外层挂载 gzip 压缩,并在 http.Server 上配置超时:
// - ReadHeaderTimeout 防止慢速客户端占用连接 // - ReadHeaderTimeout 防止慢速客户端占用连接(slowloris 的防线在这里)
// - ReadTimeout / WriteTimeout 防止读写挂死 // - ReadTimeout / WriteTimeout 防止读写挂死
// - IdleTimeout 复用 keep-alive 连接 // - IdleTimeout 复用 keep-alive 连接
// listen 留空时回退到 ":port"(监听所有网卡);Cloudflare Tunnel 部署建议设为 // listen 留空时回退到 ":port"(监听所有网卡);Cloudflare Tunnel 部署建议设为
// "127.0.0.1:<port>",使源站端口仅对本地 cloudflared 可见,即使防火墙误配也无法被外部直连。 // "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 addr := conf.Server.Listen
if addr == "" { if addr == "" {
addr = fmt.Sprintf(":%d", conf.Server.Port) addr = fmt.Sprintf(":%d", conf.Server.Port)
@@ -125,14 +182,49 @@ func runWeb(ctx *cli.Context) error {
Addr: addr, Addr: addr,
Handler: route.GzipHandler(r), Handler: route.GzipHandler(r),
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second, ReadTimeout: 120 * time.Second,
WriteTimeout: 60 * time.Second, WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second, IdleTimeout: 120 * time.Second,
} }
logrus.WithContext(ctx.Context).WithField("port", conf.Server.Port).Info("Listening on") logrus.WithContext(ctx.Context).WithField("port", conf.Server.Port).Info("Listening on")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
// 优雅关闭:收到 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 errors.Wrap(err, "start http server")
} }
return nil 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 // TrustProxy 为 true 时信任 CDN 权威客户端头 CF-Connecting-IP / EO-Client-IP
// (以及受信代理设置的 X-Real-IP / X-Forwarded-For);否则一律用 RemoteAddr, // (以及受信代理设置的 X-Real-IP / X-Forwarded-For);否则一律用 RemoteAddr,
// 防止伪造 header 绕过速率限制与 IP 黑名单。 // 防止伪造 header 绕过速率限制与 IP 黑名单。
// ⚠️ 权威头只在 TCP 对端可信(回环,或命中 TrustedProxies)时才会被采信 ——
// 见 shared.ResolveClientIP:公网对端带来的权威头一律忽略(P1-3 修复)。
TrustProxy bool `ini:"trust_proxy"` TrustProxy bool `ini:"trust_proxy"`
// TrustedProxies 逗号分隔的 IP / CIDR,仅限自己的反向代理 / LB。仅当 TCP 对端 // TrustedProxies 逗号分隔的 IP / CIDR,仅限自己的反向代理 / LB。仅当 TCP 对端
// 落在该清单内才解析 X-Forwarded-For / X-Real-IP;留空则这两个头永不被信任 // 落在该清单内才解析 X-Forwarded-For / X-Real-IP;留空则这两个头永不被信任。
// (CDN 权威头仍会采用,前提是源站已由 Cloudflare Tunnel / 回源网段锁定, // 回环地址(127.0.0.1 / ::1)无需登记即视为可信对端:cloudflared / 本机 nginx
// 从而免于维护上游 CDN 的庞大 CIDR 清单)。 // 回源的连接必然由本机进程发起,外部无法伪造。
// CDN 直连回源(非 Tunnel)的部署形态下,必须把该 CDN 的回源网段登记到这里,
// 否则它的权威头会被忽略、客户端 IP 退化为 CDN 边缘 IP。
TrustedProxies string `ini:"trusted_proxies"` TrustedProxies string `ini:"trusted_proxies"`
EnableAutoBan bool `ini:"enable_auto_ban"` // fail2ban 风格:检测到扫描特征后临时封禁来源 IP EnableAutoBan bool `ini:"enable_auto_ban"` // fail2ban 风格:检测到扫描特征后临时封禁来源 IP
// BanTrackNotFound 是否把未注册路径的 404 计入封禁统计。默认 false:仅命中后台 // BanTrackNotFound 是否把未注册路径的 404 计入封禁统计。默认 false:仅命中后台
@@ -135,6 +139,19 @@ var (
// 否则等于把配置文件当响应体吐给每一个被封的攻击者。 // 否则等于把配置文件当响应体吐给每一个被封的攻击者。
BanImagePath string `ini:"ban_image_path"` BanImagePath string `ini:"ban_image_path"`
SecurityAlertEmail string `ini:"security_alert_email"` // 触发封禁时告警收件人(留空不发送) 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 { Server struct {
+15 -3
View File
@@ -66,7 +66,10 @@ func (c *Context) HasError() bool {
if !ok { if !ok {
return false 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{}) { func (c *Context) SetError(err error, f ...interface{}) {
@@ -135,6 +138,11 @@ func (c *Context) Refresh() {
} }
func (c *Context) JSON(data interface{}) error { 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{}{ resp := map[string]interface{}{
"code": 0, "code": 0,
"data": data, "data": data,
@@ -149,6 +157,9 @@ func (c *Context) ServerError() error {
} }
func (c *Context) JSONError(errorCode int, message string) 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()) span := trace.SpanFromContext(c.Request().Context())
resp := map[string]interface{}{ resp := map[string]interface{}{
@@ -367,9 +378,10 @@ func pageScope(path string) string {
path == "/register" || strings.HasPrefix(path, "/register/"): path == "/register" || strings.HasPrefix(path, "/register/"):
return "auth" return "auth"
case path == "/user" || strings.HasPrefix(path, "/user/"): case path == "/user" || strings.HasPrefix(path, "/user/"):
// 题目/私聊管理列表是「翻找内容」的页面,和「设置表单」的信息密度差很多, // 题目/私聊管理列表、数据面板都是「读数据」的页面,和「设置表单」的信息密度差很多,
// 装饰要分开配:admin 更该退到背景,profile 可以保留一点氛围。 // 装饰要分开配: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 "admin"
} }
return "profile" return "profile"
+1
View File
@@ -42,6 +42,7 @@ func TestPageScope(t *testing.T) {
"/user/profile": "profile", "/user/profile": "profile",
"/user/questions": "admin", // 题目列表:信息密,装饰要退 "/user/questions": "admin", // 题目列表:信息密,装饰要退
"/user/votes/1/results": "admin", "/user/votes/1/results": "admin",
"/user/insights": "admin", // 数据面板同属「读数据」
"/_/admin6": "box", "/_/admin6": "box",
"/_/admin6/": "box", "/_/admin6/": "box",
"/_/admin6/tag/杂谈": "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 { type ConversationStore interface {
Create(ctx context.Context, opts CreateConversationOptions) (*Conversation, error) 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) GetByID(ctx context.Context, id uint) (*Conversation, error)
GetByToken(ctx context.Context, token string) (*Conversation, error) GetByToken(ctx context.Context, token string) (*Conversation, error)
GetByIDAndOwner(ctx context.Context, id, ownerID uint) (*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 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) { func (db *conversations) GetByID(ctx context.Context, id uint) (*Conversation, error) {
var conv Conversation var conv Conversation
if err := db.WithContext(ctx).First(&conv, id).Error; err != nil { 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 return nil
} }
if err := db.WithContext(ctx).Model(&Conversation{}).Where("id = ?", id).Update("is_deleted_by_owner", true).Error; err != nil { // 用「条件更新 + RowsAffected」代替纯 Update:
return errors.Wrap(err, "mark conversation deleted by owner") // 原来的写法是 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" "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{}{ var AllTables = []interface{}{
&User{}, &Question{}, &FollowUp{}, &CensorLog{}, &UploadImage{}, &UploadImageQuestion{}, &User{}, &Question{}, &FollowUp{}, &CensorLog{}, &UploadImage{}, &UploadImageQuestion{},
&Conversation{}, &Message{}, &MessageImage{}, &UserBlock{}, &Conversation{}, &Message{}, &MessageImage{}, &UserBlock{},
@@ -26,6 +45,10 @@ var AllTables = []interface{}{
&UserRestriction{}, &ContentReport{}, &UserRestriction{}, &ContentReport{},
// 蜜罐命中记录(审计证据,只追加不软删) // 蜜罐命中记录(审计证据,只追加不软删)
&HoneypotHit{}, &HoneypotHit{},
// 自动封禁记录(内存态封禁的落库账本,用于事后解释与取证)
&BanRecord{},
// 箱主数据面板的访问流水(每 访客×天×箱 一行,Unscoped 语义,见 BoxView 注释)
&BoxView{},
} }
func Init(typ, dsn string) (*gorm.DB, error) { func Init(typ, dsn string) (*gorm.DB, error) {
@@ -46,6 +69,7 @@ func Init(typ, dsn string) (*gorm.DB, error) {
if err != nil { if err != nil {
return nil, errors.Wrap(err, "connect to database") return nil, errors.Wrap(err, "connect to database")
} }
handle = db
sqlDB, err := db.DB() sqlDB, err := db.DB()
if err != nil { if err != nil {
@@ -142,7 +166,10 @@ func Init(typ, dsn string) (*gorm.DB, error) {
Restrictions = NewRestrictionsStore(db) Restrictions = NewRestrictionsStore(db)
Reports = NewReportsStore(db) Reports = NewReportsStore(db)
Honeypots = NewHoneypotStore(db) Honeypots = NewHoneypotStore(db)
BanRecords = NewBanRecordsStore(db)
Analytics = NewSiteAnalyticsStore(db) Analytics = NewSiteAnalyticsStore(db)
BoxViews = NewBoxViewsStore(db)
BoxAnalytics = NewBoxAnalyticsStore(db)
// 旧版本把消息/密码明文存在 honeypot_hits 上;结构体已删掉这些字段, // 旧版本把消息/密码明文存在 honeypot_hits 上;结构体已删掉这些字段,
// 但 AutoMigrate 不会删除既有列 —— 老库升级上来后历史明文仍在。 // 但 AutoMigrate 不会删除既有列 —— 老库升级上来后历史明文仍在。
+34 -3
View File
@@ -360,6 +360,26 @@ func FeatureStateOf(ctx context.Context, userID uint, feature string) FeatureSta
return fc.Evaluate(time.Now()) 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 便捷入口:某用户某功能当前是否可用。 // FeatureOpen 便捷入口:某用户某功能当前是否可用。
func FeatureOpen(ctx context.Context, userID uint, feature string) bool { func FeatureOpen(ctx context.Context, userID uint, feature string) bool {
return FeatureStateOf(ctx, userID, feature).Open return FeatureStateOf(ctx, userID, feature).Open
@@ -487,11 +507,22 @@ func (db *featureControls) ResetQuota(ctx context.Context, userID uint, feature
// IncrementQuota 累加收集计数(delta 可为负,用于删除内容时回退计数)。 // IncrementQuota 累加收集计数(delta 可为负,用于删除内容时回退计数)。
// 负 delta 不会把计数减到 0 以下(WHERE quota_count > 0),避免误删历史内容导致负计数。 // 负 delta 不会把计数减到 0 以下(WHERE quota_count > 0),避免误删历史内容导致负计数。
func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feature string, delta int64) error { 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 { if delta == 0 {
return nil return nil
} }
now := time.Now() now := time.Now()
q := db.WithContext(ctx).Model(&FeatureControl{}). q := tx.Model(&FeatureControl{}).
Where("user_id = ? AND feature = ?", userID, feature) Where("user_id = ? AND feature = ?", userID, feature)
if delta < 0 { if delta < 0 {
q = q.Where("quota_count > 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.QuotaCount = delta
} }
fc.QuotaResetAt = &now fc.QuotaResetAt = &now
created := db.WithContext(ctx). created := tx.
Clauses(clause.OnConflict{DoNothing: true}). Clauses(clause.OnConflict{DoNothing: true}).
Create(fc) Create(fc)
if created.Error != nil { if created.Error != nil {
@@ -523,7 +554,7 @@ func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feat
return nil return nil
} }
// 并发下配置行可能刚被其它请求创建:补一次累加,保证计数不丢。 // 并发下配置行可能刚被其它请求创建:补一次累加,保证计数不丢。
retry := db.WithContext(ctx).Model(&FeatureControl{}). retry := tx.Model(&FeatureControl{}).
Where("user_id = ? AND feature = ?", userID, feature). Where("user_id = ? AND feature = ?", userID, feature).
Updates(map[string]interface{}{ Updates(map[string]interface{}{
"quota_count": gorm.Expr("quota_count + ?", delta), "quota_count": gorm.Expr("quota_count + ?", delta),
+45 -11
View File
@@ -18,6 +18,9 @@ var (
ErrFollowUpLimitReached = errors.New("follow up limit reached") ErrFollowUpLimitReached = errors.New("follow up limit reached")
ErrFollowUpNotFound = errors.New("follow up not found") ErrFollowUpNotFound = errors.New("follow up not found")
ErrFollowUpAlreadyAnswered = errors.New("follow up already answered") ErrFollowUpAlreadyAnswered = errors.New("follow up already answered")
// ErrFollowUpAwaitingReply 上一条追问尚未被箱主回复,不允许继续追问。
// 由 CreateIfBelowLimit 在事务内判定(见该方法的注释)。
ErrFollowUpAwaitingReply = errors.New("previous follow up awaiting reply")
) )
var FollowUps FollowUpsStore var FollowUps FollowUpsStore
@@ -35,9 +38,10 @@ type FollowUpsStore interface {
// AnswerByID 回答某条追问;questionID 防越权(必须属于该问题),仅允许回答尚未回答的(answer 为空)。 // AnswerByID 回答某条追问;questionID 防越权(必须属于该问题),仅允许回答尚未回答的(answer 为空)。
// 已回答返回 ErrFollowUpAlreadyAnswered,不存在/不属于该问题返回 ErrFollowUpNotFound。 // 已回答返回 ErrFollowUpAlreadyAnswered,不存在/不属于该问题返回 ErrFollowUpNotFound。
AnswerByID(ctx context.Context, questionID uint, id uint, answer string) error AnswerByID(ctx context.Context, questionID uint, id uint, answer string) error
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防越权),允许覆盖已回答的(供主人修改回复)。 // UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防越权),
// 允许覆盖已回答的(供主人修改回复);返回 wasFirst 表示这是首次回答(此前为空)。
// 不存在/不属于该问题返回 ErrFollowUpNotFound。 // 不存在/不属于该问题返回 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 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
DeleteByQuestionID(ctx context.Context, questionID uint) error DeleteByQuestionID(ctx context.Context, questionID uint) error
} }
@@ -84,6 +88,23 @@ func (db *followUps) CreateIfBelowLimit(ctx context.Context, opts CreateFollowUp
return ErrFollowUpLimitReached 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{ f := &FollowUp{
QuestionID: opts.QuestionID, QuestionID: opts.QuestionID,
Content: opts.Content, Content: opts.Content,
@@ -188,18 +209,31 @@ func (db *followUps) AnswerByID(ctx context.Context, questionID uint, id uint, a
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防止越权), // UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防止越权),
// 允许覆盖已回答的追问(用于主人修改回复)。不存在/不属于该问题返回 ErrFollowUpNotFound。 // 允许覆盖已回答的追问(用于主人修改回复)。不存在/不属于该问题返回 ErrFollowUpNotFound。
func (db *followUps) UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) error { func (db *followUps) UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) (bool, error) {
res := db.WithContext(ctx). var wasFirst bool
Model(&FollowUp{}). err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
Where("id = ? AND question_id = ?", id, questionID). // 先读旧值判断「是否首次回答」。handler 里拿不到可靠旧值:
Update("answer", answer) // 它手上的 question 是请求开始时读的,并发下可能已经过期。
if res.Error != nil { var fu FollowUp
return errors.Wrap(res.Error, "update follow up answer") if err := tx.Where("id = ? AND question_id = ?", id, questionID).First(&fu).Error; err != nil {
} if errors.Is(err, gorm.ErrRecordNotFound) {
if res.RowsAffected == 0 {
return ErrFollowUpNotFound 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 return nil
})
if err != nil {
return false, err
}
return wasFirst, nil
} }
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。 // 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" "github.com/pkg/errors"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/dbutil" "github.com/tamakyi/TamaBox/internal/dbutil"
) )
// minChatMessagesPerPage 是会话消息单次加载的兜底条数。
//
// 正常情况下取 conf.Limits.ChatMaxMessages(默认 200,即一次把整个会话读满)。
// 这个常量只在「配置未初始化」(零值,测试里直接构造 store 时会遇到)时生效,
// 不会覆盖箱主显式配置的更大或更小的值。
const minChatMessagesPerPage = 50
var Messages MessageStore var Messages MessageStore
type MessageStore interface { type MessageStore interface {
@@ -60,20 +68,42 @@ func (db *messages) GetByConversationID(ctx context.Context, conversationID uint
var messages []*Message var messages []*Message
q := db.WithContext(ctx).Where("conversation_id = ?", conversationID) 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 != nil {
if cursor.Value != nil && fmt.Sprintf("%v", cursor.Value) != "" { if cursor.Value != nil && fmt.Sprintf("%v", cursor.Value) != "" {
// 游标语义是「加载更早的消息」,与下面的 ORDER BY id DESC 配套;
// 若沿用旧的 ASC 排序,id < cursor 每次都会命中同一批最老的消息。
q = q.Where("id < ?", cursor.Value) q = q.Where("id < ?", cursor.Value)
} }
limit = cursor.Limit() 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) q = q.Limit(limit)
if err := q.Find(&messages).Error; err != nil { if err := q.Find(&messages).Error; err != nil {
return nil, errors.Wrap(err, "get messages") 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 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)
}
+79 -12
View File
@@ -14,7 +14,6 @@ import (
"time" "time"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/thanhpk/randstr" "github.com/thanhpk/randstr"
"gorm.io/datatypes" "gorm.io/datatypes"
"gorm.io/gorm" "gorm.io/gorm"
@@ -27,6 +26,8 @@ var Questions QuestionsStore
type QuestionsStore interface { type QuestionsStore interface {
Create(ctx context.Context, opts CreateQuestionOptions) (*Question, error) Create(ctx context.Context, opts CreateQuestionOptions) (*Question, error)
GetByID(ctx context.Context, id uint) (*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) GetByUserID(ctx context.Context, userID uint, opts GetQuestionsByUserIDOptions) ([]*Question, error)
GetByAskUserID(ctx context.Context, userID uint, opts GetQuestionsByAskUserIDOptions) ([]*Question, error) GetByAskUserID(ctx context.Context, userID uint, opts GetQuestionsByAskUserIDOptions) ([]*Question, error)
AnswerByID(ctx context.Context, id uint, answer string) error AnswerByID(ctx context.Context, id uint, answer string) error
@@ -42,6 +43,13 @@ type QuestionsStore interface {
SetPrivate(ctx context.Context, id uint) error SetPrivate(ctx context.Context, id uint) error
SetPublic(ctx context.Context, id uint) error SetPublic(ctx context.Context, id uint) error
SetPinned(ctx context.Context, id uint, pinned bool) 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 SetRead(ctx context.Context, id uint) error
SetUnread(ctx context.Context, id uint) error SetUnread(ctx context.Context, id uint) error
// NextUnread 取该用户最早的一条未读提问(未读 = is_read=false 且未回答)。 // NextUnread 取该用户最早的一条未读提问(未读 = is_read=false 且未回答)。
@@ -103,6 +111,12 @@ type CreateQuestionOptions struct {
IsPrivate bool IsPrivate bool
} }
// Create 新建提问,并在**同一个事务**里累加箱主的提问收集计数。
//
// 为什么配额要和提问同事务:原来是 handler 里「先 Create、再 IncrementQuota」
// 两次独立写入,后者失败只打一条 Warn。于是 quota_count 比真实提问数少,
// 箱主看到的「已收集 7/10」永远偏低;而达到上限后因为计数偏小,实际还能继续收,
// 等于定量收集被静默绕过。重置计数也校准不回来(重置是把计数清零,更脱节)。
func (db *questions) Create(ctx context.Context, opts CreateQuestionOptions) (*Question, error) { func (db *questions) Create(ctx context.Context, opts CreateQuestionOptions) (*Question, error) {
question := Question{ question := Question{
FromIP: opts.FromIP, FromIP: opts.FromIP,
@@ -113,7 +127,20 @@ func (db *questions) Create(ctx context.Context, opts CreateQuestionOptions) (*Q
AskerUserID: opts.AskerUserID, AskerUserID: opts.AskerUserID,
IsPrivate: opts.IsPrivate, 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 { type UpdateQuestionCensorOptions struct {
@@ -162,6 +189,25 @@ func checkTextCensorResponseValid(raw json.RawMessage) bool {
var ErrQuestionNotExist = errors.New("提问不存在") 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) { func (db *questions) GetByID(ctx context.Context, id uint) (*Question, error) {
var question Question var question Question
if err := db.WithContext(ctx).First(&question, id).Error; err != nil { 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 return updates
} }
// DeleteByID 删除提问及其追问、图片关联,并回退箱主的收集计数。
//
// 三步在同一个事务里完成:原来删除本体后,级联清理与配额回退还各自独立执行、
// 失败只打日志 —— 中途失败会留下「提问没了但追问还在」的孤儿记录,
// 或者计数没退回(达上限后功能一直关闭,箱主只能手动重置)。
//
// 用 Unscoped 真删:用户点删除的预期就是删掉。软删只是让界面上看不见,
// 提问里的 FromIP 与正文仍留在库里,等于「删了但没删干净」。
func (db *questions) DeleteByID(ctx context.Context, id uint) error { func (db *questions) DeleteByID(ctx context.Context, id uint) error {
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var question Question var question Question
if err := db.WithContext(ctx).First(&question, id).Error; err != nil { if err := tx.First(&question, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrQuestionNotExist 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 { if err := tx.Unscoped().Delete(&Question{}, id).Error; err != nil {
return errors.Wrap(err, "delete question") return errors.Wrap(err, "delete question")
} }
// 级联清理子表:追问与上传图片,避免删除提问后残留孤儿记录。 // 级联清理子表:追问与上传图片,避免删除提问后残留孤儿记录。
// 直接用本 store 的 *gorm.DB 构造子 store,避免依赖包级全局单例 // 直接用事务句柄构造子 store(NewXStore(tx)),子操作才会落在同一个事务里;
// (全局单例仅在 db.Init 装配,测试/部分初始化场景下为 nil 会 panic)。 // 用包级全局单例在测试/部分初始化场景下还会是 nil。
if err := NewFollowUpsStore(db.DB).DeleteByQuestionID(ctx, id); err != nil { if err := NewFollowUpsStore(tx).DeleteByQuestionID(ctx, id); err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to cascade delete follow ups for question %d", id) return errors.Wrap(err, "delete follow ups of question")
} }
if err := NewUploadImagesStore(db.DB).DeleteByQuestionID(ctx, id); err != nil { if err := NewUploadImagesStore(tx).DeleteByQuestionID(ctx, id); err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to cascade delete upload images for question %d", id) return errors.Wrap(err, "delete upload images of question")
} }
// 回退定量收集计数:删除提问等价于少一条已收集内容, // 回退定量收集计数:删除提问等价于少一条已收集内容,
// 否则达上限后即便删掉旧提问,功能仍会显示 quota_reached 关闭,须手动重置计数。 // 否则达上限后即便删掉旧提问,功能仍会显示 quota_reached 关闭,须手动重置计数。
if question.UserID != 0 { if question.UserID != 0 {
if err := NewFeatureControlsStore(db.DB).IncrementQuota(ctx, question.UserID, FeatureQuestion, -1); err != nil { if err := incrementQuotaTx(tx, question.UserID, FeatureQuestion, -1); err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to decrement question quota for user %d", question.UserID) return errors.Wrap(err, "decrement question quota")
} }
} }
return nil return nil
})
} }
type GetQuestionsCountOptions struct { 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 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 { func (db *questions) SetPublic(ctx context.Context, id uint) error {
return db.WithContext(ctx).Model(&Question{}).Where("id = ?", id).Update("is_private", false).Error return db.WithContext(ctx).Model(&Question{}).Where("id = ?", id).Update("is_private", false).Error
} }
+21 -3
View File
@@ -68,6 +68,9 @@ type CreateVoteSubmissionOptions struct {
VoterIP string VoterIP string
FormData datatypes.JSON FormData datatypes.JSON
CustomText string CustomText string
// QuotaUserID 是投票的发起人(箱主)。非 0 时在同一事务里把票数计入
// 该用户「投票」功能的定量收集计数;为 0 表示不计数(测试/内部调用)。
QuotaUserID uint
} }
var ( var (
@@ -88,11 +91,26 @@ func (db *voteSubmissions) Create(ctx context.Context, opts CreateVoteSubmission
FormData: opts.FormData, FormData: opts.FormData,
CustomText: opts.CustomText, CustomText: opts.CustomText,
} }
if err := db.WithContext(ctx).Create(submission).Error; err != nil { err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(submission).Error; err != nil {
if isDuplicateKeyError(err) { if isDuplicateKeyError(err) {
return nil, ErrVoteAlreadyVoted return ErrVoteAlreadyVoted
} }
return nil, errors.Wrap(err, "create vote submission") return 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 return submission, nil
} }
+46
View File
@@ -70,6 +70,8 @@ type VotesStore interface {
Update(ctx context.Context, id uint, opts UpdateVoteOptions) error Update(ctx context.Context, id uint, opts UpdateVoteOptions) error
UpdateStatus(ctx context.Context, id uint, status string) error UpdateStatus(ctx context.Context, id uint, status string) error
DeleteByID(ctx context.Context, id uint) error DeleteByID(ctx context.Context, id uint) error
// DeleteWithCascade 在一个事务里真删投票及其选项、表单字段、提交记录。
DeleteWithCascade(ctx context.Context, id uint) error
CountByUserID(ctx context.Context, userID uint) (int64, 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 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 统计箱主的投票总数(全部状态)。 // CountByUserID 统计箱主的投票总数(全部状态)。
func (db *votes) CountByUserID(ctx context.Context, userID uint) (int64, error) { func (db *votes) CountByUserID(ctx context.Context, userID uint) (int64, error) {
var count int64 var count int64
+10 -2
View File
@@ -56,12 +56,20 @@ type UpdateTags struct {
ShowInHome string `form:"show_in_home"` ShowInHome string `form:"show_in_home"`
} }
// 页面访问密码 / 提问密码的最小长度。
//
// P1-2:这两个字段此前只有 maxlen,箱主可以设成 1 位或 1234 —— 而页面密码正是产品
// 对外宣传的访问控制,提问密码是箱主过滤骚扰的唯一门槛。6 位是最低可接受值
// (纯数字 10^6,配合失败锁定后穷举不可行);留空仍然合法,表示关闭该密码
// (govalid 对空值跳过 minlen / maxlen)。
const PasswordMinLen = 6
// 新增:页面访问密码表单 // 新增:页面访问密码表单
type UpdatePagePassword struct { 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 { 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"` // 留空表示关闭
} }
+70 -2
View File
@@ -21,6 +21,7 @@ var enDict = map[string]string{
"common.submit": "Submit", "common.submit": "Submit",
"common.cancel": "Cancel", "common.cancel": "Cancel",
"common.save": "Save", "common.save": "Save",
"common.tooManyRequests": "Too many requests. Please try again later.",
"common.close": "Close", "common.close": "Close",
"common.delete": "Delete", "common.delete": "Delete",
"common.moveUp": "Move up", "common.moveUp": "Move up",
@@ -112,6 +113,14 @@ var enDict = map[string]string{
"ask.chatDisabled": "Chat is disabled on this box", "ask.chatDisabled": "Chat is disabled on this box",
"ask.chatLimit": "Message limit: %d", "ask.chatLimit": "Message limit: %d",
"ask.history": "Previously answered by @%s", "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.loginRequired": "Log in required",
"ask.loginRequiredChat": "You need to log in to start a chat", "ask.loginRequiredChat": "You need to log in to start a chat",
"ask.goLogin": "Go to login", "ask.goLogin": "Go to login",
@@ -182,9 +191,9 @@ var enDict = map[string]string{
"admin.tagInputHint": "Type and press Enter or comma to add", "admin.tagInputHint": "Type and press Enter or comma to add",
"admin.tagInputDuplicate": "Already exists, ignored", "admin.tagInputDuplicate": "Already exists, ignored",
"admin.pagePassword": "Page access password", "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.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.saveProfile": "Save changes",
"admin.updated": "Profile updated successfully", "admin.updated": "Profile updated successfully",
"admin.logout": "Log out", "admin.logout": "Log out",
@@ -298,6 +307,9 @@ var enDict = map[string]string{
"qlist.unpinShort": "Unpin", "qlist.unpinShort": "Unpin",
"qlist.myVotes": "My votes", "qlist.myVotes": "My votes",
"qlist.loadMore": "Load more", "qlist.loadMore": "Load more",
"qlist.noMore": "No more posts",
"qlist.loading": "Loading...",
"qlist.viewAnswer": "View answer",
"qlist.newVote": "New vote", "qlist.newVote": "New vote",
"qlist.edit": "Edit", "qlist.edit": "Edit",
"qlist.untitledVote": "Untitled vote", "qlist.untitledVote": "Untitled vote",
@@ -619,6 +631,7 @@ var enDict = map[string]string{
"vote.page.submit": "Submit vote", "vote.page.submit": "Submit vote",
"vote.page.alreadyVoted": "You have already participated in this poll. Thanks!", "vote.page.alreadyVoted": "You have already participated in this poll. Thanks!",
"vote.page.resultsTitle": "Poll Results", "vote.page.resultsTitle": "Poll Results",
"vote.page.anonymousNotice": "Anonymous poll — one vote per person, results are indicative only.",
"vote.page.totalVotes": "Total", "vote.page.totalVotes": "Total",
"vote.page.avgRating": "Average", "vote.page.avgRating": "Average",
"vote.page.avgRank": "Avg rank", "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.rankHint": "Rank each option 1-N (smaller = higher, no duplicates)",
"vote.page.rankPlaceholder": "Rank", "vote.page.rankPlaceholder": "Rank",
"vote.page.multipleHint": "Multiple choice", "vote.page.multipleHint": "Multiple choice",
"vote.page.multipleHintWithMax": "Multiple choice (up to %d)",
"vote.page.customOption": "Custom option (optional)", "vote.page.customOption": "Custom option (optional)",
"vote.page.customPlaceholder": "Type your own option", "vote.page.customPlaceholder": "Type your own option",
"vote.page.customOther": "Other", "vote.page.customOther": "Other",
@@ -757,6 +771,7 @@ var enDict = map[string]string{
// Question box page: visitor-visible (P0) // Question box page: visitor-visible (P0)
"question.errWrongPassword": "Wrong password", "question.errWrongPassword": "Wrong password",
"question.errPasswordLocked": "Too many wrong attempts. Please try again in %d minutes.",
"question.errAskPasswordWrong": "Wrong ask password", "question.errAskPasswordWrong": "Wrong ask password",
"question.errLoginRequired": "The owner only allows registered users to ask questions. Please log in first.", "question.errLoginRequired": "The owner only allows registered users to ask questions. Please log in first.",
"question.errSelfChat": "You cannot start a chat with yourself", "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.fieldSerialize": "Failed to serialize field options",
"vote.err.editClosed": "Votes that are ended or archived cannot be edited", "vote.err.editClosed": "Votes that are ended or archived cannot be edited",
"vote.err.publishDraftOnly": "Only draft polls can be published", "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.closePublishedOnly": "Only active polls can be closed",
"vote.err.publishTitleRequired": "Please fill in the poll title before publishing", "vote.err.publishTitleRequired": "Please fill in the poll title before publishing",
"vote.err.publishFormFieldsRequired": "Form polls need at least 1 field", "vote.err.publishFormFieldsRequired": "Form polls need at least 1 field",
@@ -903,6 +920,12 @@ var enDict = map[string]string{
"chat.flash.operated": "Operation successful", "chat.flash.operated": "Operation successful",
"image.errTooLarge": "Image is too large, maximum supported size is %d×%d pixels, please compress and re-upload", "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.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", "internal.errServer": "Internal server error",
"home.errStats": "Failed to retrieve statistics", "home.errStats": "Failed to retrieve statistics",
"pixel.errPath": "Invalid request path", "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.name": "Nickname is required and must be at most 20 characters",
"form.profile.newPassword": "New password must be at most 30 characters", "form.profile.newPassword": "New password must be at most 30 characters",
"form.profile.passwordMax": "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.content": "Question content is required",
"form.question.contentTooLong": "Question content must be within %d characters", "form.question.contentTooLong": "Question content must be within %d characters",
"form.question.receiveReplyEmail": "Please enter a valid email address to receive replies", "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.6": "Saturday",
"stats.weekday.7": "Sunday", "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.updated": "Snapshot:",
"stats.foot.prevMonth": "Last question:", "stats.foot.prevMonth": "Last question:",
"stats.foot.prevAnswer": "Last answer:", "stats.foot.prevAnswer": "Last answer:",
+70 -2
View File
@@ -21,6 +21,7 @@ var jaDict = map[string]string{
"common.submit": "送信", "common.submit": "送信",
"common.cancel": "キャンセル", "common.cancel": "キャンセル",
"common.save": "保存", "common.save": "保存",
"common.tooManyRequests": "リクエストが多すぎます。しばらくしてから再度お試しください。",
"common.close": "閉じる", "common.close": "閉じる",
"common.delete": "削除", "common.delete": "削除",
"common.moveUp": "上へ移動", "common.moveUp": "上へ移動",
@@ -112,6 +113,14 @@ var jaDict = map[string]string{
"ask.chatDisabled": "この質問箱ではチャットが無効です", "ask.chatDisabled": "この質問箱ではチャットが無効です",
"ask.chatLimit": "メッセージ上限:%d件", "ask.chatLimit": "メッセージ上限:%d件",
"ask.history": "@%s が以前回答した質問", "ask.history": "@%s が以前回答した質問",
"askHistory.banner": "この端末に {n} 件の質問履歴があります",
"askHistory.answeredSuffix": "(うち {m} 件は回答済み)",
"askHistory.expand": "表示",
"askHistory.collapse": "閉じる",
"askHistory.answered": "回答済み",
"askHistory.pending": "回答待ち",
"askHistory.open": "開く",
"askHistory.note": "履歴はこのブラウザにのみ保存されます。端末の変更やブラウザデータの削除で失われます。",
"ask.loginRequired": "ログインが必要", "ask.loginRequired": "ログインが必要",
"ask.loginRequiredChat": "チャットを開始するにはログインが必要です", "ask.loginRequiredChat": "チャットを開始するにはログインが必要です",
"ask.goLogin": "ログインへ", "ask.goLogin": "ログインへ",
@@ -182,9 +191,9 @@ var jaDict = map[string]string{
"admin.tagInputHint": "入力して Enter またはカンマで追加", "admin.tagInputHint": "入力して Enter またはカンマで追加",
"admin.tagInputDuplicate": "既に存在するため無視しました", "admin.tagInputDuplicate": "既に存在するため無視しました",
"admin.pagePassword": "ページアクセスパスワード", "admin.pagePassword": "ページアクセスパスワード",
"admin.pagePasswordHint": "アクセスパスワードを設定(空欄で無効)", "admin.pagePasswordHint": "アクセスパスワードを設定(6〜30文字、空欄で無効)",
"admin.askPassword": "質問パスワード", "admin.askPassword": "質問パスワード",
"admin.askPasswordHint": "質問用パスワードを設定(空欄で無効)", "admin.askPasswordHint": "質問用パスワードを設定(6〜30文字、空欄で無効)",
"admin.saveProfile": "変更を保存", "admin.saveProfile": "変更を保存",
"admin.updated": "プロフィールを更新しました", "admin.updated": "プロフィールを更新しました",
"admin.logout": "ログアウト", "admin.logout": "ログアウト",
@@ -298,6 +307,9 @@ var jaDict = map[string]string{
"qlist.unpinShort": "解除", "qlist.unpinShort": "解除",
"qlist.myVotes": "作成した投票", "qlist.myVotes": "作成した投票",
"qlist.loadMore": "もっと読み込む", "qlist.loadMore": "もっと読み込む",
"qlist.noMore": "これ以上の投稿はありません",
"qlist.loading": "読み込み中...",
"qlist.viewAnswer": "回答を見る",
"qlist.newVote": "投票を作成", "qlist.newVote": "投票を作成",
"qlist.edit": "編集", "qlist.edit": "編集",
"qlist.untitledVote": "無題の投票", "qlist.untitledVote": "無題の投票",
@@ -618,6 +630,7 @@ var jaDict = map[string]string{
"vote.page.submit": "投票する", "vote.page.submit": "投票する",
"vote.page.alreadyVoted": "この投票にはすでに参加済みです。ありがとうございます!", "vote.page.alreadyVoted": "この投票にはすでに参加済みです。ありがとうございます!",
"vote.page.resultsTitle": "投票結果", "vote.page.resultsTitle": "投票結果",
"vote.page.anonymousNotice": "匿名投票(お一人一票)のため、結果は参考値です。",
"vote.page.totalVotes": "総参加", "vote.page.totalVotes": "総参加",
"vote.page.avgRating": "平均点", "vote.page.avgRating": "平均点",
"vote.page.avgRank": "平均順位", "vote.page.avgRank": "平均順位",
@@ -627,6 +640,7 @@ var jaDict = map[string]string{
"vote.page.rankHint": "各選択肢に 1-N の順位を入力してください(小さいほど上位、重複不可)", "vote.page.rankHint": "各選択肢に 1-N の順位を入力してください(小さいほど上位、重複不可)",
"vote.page.rankPlaceholder": "順位", "vote.page.rankPlaceholder": "順位",
"vote.page.multipleHint": "複数選択可", "vote.page.multipleHint": "複数選択可",
"vote.page.multipleHintWithMax": "複数選択可(最大 %d 件)",
"vote.page.customOption": "カスタム選択肢(任意)", "vote.page.customOption": "カスタム選択肢(任意)",
"vote.page.customPlaceholder": "あなたの選択肢を入力", "vote.page.customPlaceholder": "あなたの選択肢を入力",
"vote.page.customOther": "その他", "vote.page.customOther": "その他",
@@ -756,6 +770,7 @@ var jaDict = map[string]string{
// 質問箱ページ:訪問者に表示(P0) // 質問箱ページ:訪問者に表示(P0)
"question.errWrongPassword": "パスワードが正しくありません", "question.errWrongPassword": "パスワードが正しくありません",
"question.errPasswordLocked": "パスワードの誤入力が多すぎます。%d 分後にもう一度お試しください。",
"question.errAskPasswordWrong": "質問用パスワードが正しくありません", "question.errAskPasswordWrong": "質問用パスワードが正しくありません",
"question.errLoginRequired": "質問箱の所有者は登録ユーザーのみ質問できるように設定しています。先にログインしてください。", "question.errLoginRequired": "質問箱の所有者は登録ユーザーのみ質問できるように設定しています。先にログインしてください。",
"question.errSelfChat": "自分自身とのチャットは開始できません", "question.errSelfChat": "自分自身とのチャットは開始できません",
@@ -837,6 +852,8 @@ var jaDict = map[string]string{
"vote.err.fieldSerialize": "項目の選択肢のシリアライズに失敗しました", "vote.err.fieldSerialize": "項目の選択肢のシリアライズに失敗しました",
"vote.err.editClosed": "終了またはアーカイブされた投票は編集できません", "vote.err.editClosed": "終了またはアーカイブされた投票は編集できません",
"vote.err.publishDraftOnly": "草稿状態の投票のみ公開できます", "vote.err.publishDraftOnly": "草稿状態の投票のみ公開できます",
"vote.err.restrictedPublish": "現在はプラットフォームによる措置期間中のため、投票の公開を停止しています",
"feature.stateUnavailable": "機能の状態を確認できませんでした。しばらくしてから再度お試しください",
"vote.err.closePublishedOnly": "進行中の投票のみ終了できます", "vote.err.closePublishedOnly": "進行中の投票のみ終了できます",
"vote.err.publishTitleRequired": "公開前に投票のタイトルを入力してください", "vote.err.publishTitleRequired": "公開前に投票のタイトルを入力してください",
"vote.err.publishFormFieldsRequired": "フォーム型投票には最低 1 つの項目が必要です", "vote.err.publishFormFieldsRequired": "フォーム型投票には最低 1 つの項目が必要です",
@@ -902,6 +919,12 @@ var jaDict = map[string]string{
"chat.flash.operated": "操作が完了しました", "chat.flash.operated": "操作が完了しました",
"image.errTooLarge": "画像が大きすぎます。最大%d×%dピクセルまで対応しています。圧縮して再アップロードしてください", "image.errTooLarge": "画像が大きすぎます。最大%d×%dピクセルまで対応しています。圧縮して再アップロードしてください",
"internal.errFlash": "サーバー内部エラーが発生しました。後でもう一度お試しください。", "internal.errFlash": "サーバー内部エラーが発生しました。後でもう一度お試しください。",
"internal.errBadRequest": "リクエストの内容が正しくありません",
"error.notFoundTitle": "ページが見つかりません",
"error.notFoundDesc": "このアドレスにコンテンツはありません。リンクが間違っているか、削除された可能性があります。",
"error.internalTitle": "問題が発生しました",
"error.internalDesc": "このリクエストを完了できませんでした。多くは一時的なものです。しばらくしてから再度お試しください。",
"error.traceHint": "ご報告の際はこの番号を添えてください:",
"internal.errServer": "サーバー内部エラー", "internal.errServer": "サーバー内部エラー",
"home.errStats": "統計情報の取得に失敗しました", "home.errStats": "統計情報の取得に失敗しました",
"pixel.errPath": "不正なリクエストパス", "pixel.errPath": "不正なリクエストパス",
@@ -925,6 +948,7 @@ var jaDict = map[string]string{
"form.profile.name": "ニックネームは必須で、20文字以内である必要があります", "form.profile.name": "ニックネームは必須で、20文字以内である必要があります",
"form.profile.newPassword": "新しいパスワードは30文字以内である必要があります", "form.profile.newPassword": "新しいパスワードは30文字以内である必要があります",
"form.profile.passwordMax": "パスワードは30文字以内である必要があります", "form.profile.passwordMax": "パスワードは30文字以内である必要があります",
"form.profile.passwordLen": "パスワードは6〜30文字で設定してください",
"form.question.content": "質問内容を入力してください", "form.question.content": "質問内容を入力してください",
"form.question.contentTooLong": "質問内容は%d文字以内にしてください", "form.question.contentTooLong": "質問内容は%d文字以内にしてください",
"form.question.receiveReplyEmail": "返信を受け取るための有効なメールアドレスを入力してください", "form.question.receiveReplyEmail": "返信を受け取るための有効なメールアドレスを入力してください",
@@ -1029,6 +1053,50 @@ var jaDict = map[string]string{
"stats.weekday.6": "土曜日", "stats.weekday.6": "土曜日",
"stats.weekday.7": "日曜日", "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.updated": "集計時刻:",
"stats.foot.prevMonth": "最後の質問:", "stats.foot.prevMonth": "最後の質問:",
"stats.foot.prevAnswer": "最後の回答:", "stats.foot.prevAnswer": "最後の回答:",
+70 -2
View File
@@ -21,6 +21,7 @@ var zhCNDict = map[string]string{
"common.submit": "提交", "common.submit": "提交",
"common.cancel": "取消", "common.cancel": "取消",
"common.save": "保存", "common.save": "保存",
"common.tooManyRequests": "请求过于频繁,请稍后再试。",
"common.close": "关闭", "common.close": "关闭",
"common.delete": "删除", "common.delete": "删除",
"common.moveUp": "上移", "common.moveUp": "上移",
@@ -112,6 +113,14 @@ var zhCNDict = map[string]string{
"ask.chatDisabled": "该提问箱未开启私聊", "ask.chatDisabled": "该提问箱未开启私聊",
"ask.chatLimit": "消息上限:%d条", "ask.chatLimit": "消息上限:%d条",
"ask.history": "@%s 以前回答过的问题", "ask.history": "@%s 以前回答过的问题",
"askHistory.banner": "这台设备上有 {n} 条提问记录",
"askHistory.answeredSuffix": ",其中 {m} 条已有回答",
"askHistory.expand": "查看",
"askHistory.collapse": "收起",
"askHistory.answered": "已有回答",
"askHistory.pending": "等待回答",
"askHistory.open": "打开",
"askHistory.note": "记录只保存在这台设备的浏览器里,换设备或清除浏览器数据后会丢失。",
"ask.loginRequired": "需要登录", "ask.loginRequired": "需要登录",
"ask.loginRequiredChat": "发起私聊需要登录账号", "ask.loginRequiredChat": "发起私聊需要登录账号",
"ask.goLogin": "去登录", "ask.goLogin": "去登录",
@@ -182,9 +191,9 @@ var zhCNDict = map[string]string{
"admin.tagInputHint": "输入后按回车或逗号添加", "admin.tagInputHint": "输入后按回车或逗号添加",
"admin.tagInputDuplicate": "已存在,已忽略", "admin.tagInputDuplicate": "已存在,已忽略",
"admin.pagePassword": "页面访问密码", "admin.pagePassword": "页面访问密码",
"admin.pagePasswordHint": "设置访问密码(留空则关闭)", "admin.pagePasswordHint": "设置访问密码(6–30 位,留空则关闭)",
"admin.askPassword": "提问密码", "admin.askPassword": "提问密码",
"admin.askPasswordHint": "设置提问密码(留空则关闭)", "admin.askPasswordHint": "设置提问密码(6–30 位,留空则关闭)",
"admin.saveProfile": "保存修改", "admin.saveProfile": "保存修改",
"admin.updated": "更新个人信息成功", "admin.updated": "更新个人信息成功",
"admin.logout": "退出登录", "admin.logout": "退出登录",
@@ -298,6 +307,9 @@ var zhCNDict = map[string]string{
"qlist.unpinShort": "取消置顶", "qlist.unpinShort": "取消置顶",
"qlist.myVotes": "我创建的投票", "qlist.myVotes": "我创建的投票",
"qlist.loadMore": "加载更多", "qlist.loadMore": "加载更多",
"qlist.noMore": "无更多留言",
"qlist.loading": "加载中...",
"qlist.viewAnswer": "查看回答",
"qlist.newVote": "新建投票", "qlist.newVote": "新建投票",
"qlist.edit": "编辑", "qlist.edit": "编辑",
"qlist.untitledVote": "未命名投票", "qlist.untitledVote": "未命名投票",
@@ -619,6 +631,7 @@ var zhCNDict = map[string]string{
"vote.page.submit": "提交投票", "vote.page.submit": "提交投票",
"vote.page.alreadyVoted": "你已参与过该投票,感谢参与!", "vote.page.alreadyVoted": "你已参与过该投票,感谢参与!",
"vote.page.resultsTitle": "投票结果", "vote.page.resultsTitle": "投票结果",
"vote.page.anonymousNotice": "匿名投票,每人一票,结果仅供参考。",
"vote.page.totalVotes": "总参与", "vote.page.totalVotes": "总参与",
"vote.page.avgRating": "平均分", "vote.page.avgRating": "平均分",
"vote.page.avgRank": "平均排名", "vote.page.avgRank": "平均排名",
@@ -628,6 +641,7 @@ var zhCNDict = map[string]string{
"vote.page.rankHint": "请为每个选项填写 1-N 的排名(数字越小越靠前,不可重复)", "vote.page.rankHint": "请为每个选项填写 1-N 的排名(数字越小越靠前,不可重复)",
"vote.page.rankPlaceholder": "排名", "vote.page.rankPlaceholder": "排名",
"vote.page.multipleHint": "可多选", "vote.page.multipleHint": "可多选",
"vote.page.multipleHintWithMax": "可多选(最多 %d 项)",
"vote.page.customOption": "补充选项(可选)", "vote.page.customOption": "补充选项(可选)",
"vote.page.customPlaceholder": "填写你心中的其他选项", "vote.page.customPlaceholder": "填写你心中的其他选项",
"vote.page.customOther": "其他", "vote.page.customOther": "其他",
@@ -757,6 +771,7 @@ var zhCNDict = map[string]string{
// 提问箱页:访客可见(P0) // 提问箱页:访客可见(P0)
"question.errWrongPassword": "密码错误", "question.errWrongPassword": "密码错误",
"question.errPasswordLocked": "密码错误次数过多,请 %d 分钟后再试",
"question.errAskPasswordWrong": "提问密码错误", "question.errAskPasswordWrong": "提问密码错误",
"question.errLoginRequired": "提问箱的主人设置了仅注册用户才能提问,请先登录。", "question.errLoginRequired": "提问箱的主人设置了仅注册用户才能提问,请先登录。",
"question.errSelfChat": "不能给自己发起私聊", "question.errSelfChat": "不能给自己发起私聊",
@@ -838,6 +853,8 @@ var zhCNDict = map[string]string{
"vote.err.fieldSerialize": "字段选项序列化失败", "vote.err.fieldSerialize": "字段选项序列化失败",
"vote.err.editClosed": "已结束或归档的投票不允许编辑", "vote.err.editClosed": "已结束或归档的投票不允许编辑",
"vote.err.publishDraftOnly": "只有草稿状态的投票可以发布", "vote.err.publishDraftOnly": "只有草稿状态的投票可以发布",
"vote.err.restrictedPublish": "当前处于平台处置期,已暂停发布投票",
"feature.stateUnavailable": "暂时无法确认该功能的状态,请稍后重试",
"vote.err.closePublishedOnly": "只有进行中的投票可以结束", "vote.err.closePublishedOnly": "只有进行中的投票可以结束",
"vote.err.publishTitleRequired": "请先填写投票标题再发布", "vote.err.publishTitleRequired": "请先填写投票标题再发布",
"vote.err.publishFormFieldsRequired": "表单类型投票至少需要 1 个字段", "vote.err.publishFormFieldsRequired": "表单类型投票至少需要 1 个字段",
@@ -915,6 +932,12 @@ var zhCNDict = map[string]string{
"chat.flash.operated": "操作成功", "chat.flash.operated": "操作成功",
"image.errTooLarge": "图片尺寸过大,最大支持 %d×%d 像素,请压缩后重新上传", "image.errTooLarge": "图片尺寸过大,最大支持 %d×%d 像素,请压缩后重新上传",
"internal.errFlash": "服务内部错误,请稍后重试。", "internal.errFlash": "服务内部错误,请稍后重试。",
"internal.errBadRequest": "请求参数不正确",
"error.notFoundTitle": "页面不存在",
"error.notFoundDesc": "这个地址没有内容,可能是链接输错了,或者内容已经被删除。",
"error.internalTitle": "服务出了点问题",
"error.internalDesc": "这次请求没能完成,通常是临时的。稍后重试一般就好了。",
"error.traceHint": "反馈时请带上这个编号:",
"internal.errServer": "服务器内部错误", "internal.errServer": "服务器内部错误",
"home.errStats": "获取统计信息失败", "home.errStats": "获取统计信息失败",
"pixel.errPath": "非法的请求路径", "pixel.errPath": "非法的请求路径",
@@ -938,6 +961,7 @@ var zhCNDict = map[string]string{
"form.profile.name": "昵称不能为空,且不超过 20 个字符", "form.profile.name": "昵称不能为空,且不超过 20 个字符",
"form.profile.newPassword": "新密码长度不能超过 30 个字符", "form.profile.newPassword": "新密码长度不能超过 30 个字符",
"form.profile.passwordMax": "密码长度不能超过 30 个字符", "form.profile.passwordMax": "密码长度不能超过 30 个字符",
"form.profile.passwordLen": "请设置 6 至 30 位的密码",
"form.question.content": "问题内容不能为空", "form.question.content": "问题内容不能为空",
"form.question.contentTooLong": "问题内容不能超过 %d 个字符", "form.question.contentTooLong": "问题内容不能超过 %d 个字符",
"form.question.receiveReplyEmail": "请填写有效的电子邮箱地址以接收回复", "form.question.receiveReplyEmail": "请填写有效的电子邮箱地址以接收回复",
@@ -1030,6 +1054,50 @@ var zhCNDict = map[string]string{
"stats.weekday.6": "周六", "stats.weekday.6": "周六",
"stats.weekday.7": "周日", "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.updated": "快照时间:",
"stats.foot.prevMonth": "最后一条提问:", "stats.foot.prevMonth": "最后一条提问:",
"stats.foot.prevAnswer": "最后一条回答:", "stats.foot.prevAnswer": "最后一条回答:",
+70 -2
View File
@@ -21,6 +21,7 @@ var zhTWDict = map[string]string{
"common.submit": "送出", "common.submit": "送出",
"common.cancel": "取消", "common.cancel": "取消",
"common.save": "儲存", "common.save": "儲存",
"common.tooManyRequests": "請求過於頻繁,請稍後再試。",
"common.close": "關閉", "common.close": "關閉",
"common.delete": "刪除", "common.delete": "刪除",
"common.moveUp": "上移", "common.moveUp": "上移",
@@ -112,6 +113,14 @@ var zhTWDict = map[string]string{
"ask.chatDisabled": "該提問箱未開啟私聊", "ask.chatDisabled": "該提問箱未開啟私聊",
"ask.chatLimit": "訊息上限:%d則", "ask.chatLimit": "訊息上限:%d則",
"ask.history": "@%s 以前回答過的問題", "ask.history": "@%s 以前回答過的問題",
"askHistory.banner": "這台裝置上有 {n} 筆提問紀錄",
"askHistory.answeredSuffix": ",其中 {m} 筆已有回答",
"askHistory.expand": "查看",
"askHistory.collapse": "收合",
"askHistory.answered": "已有回答",
"askHistory.pending": "等待回答",
"askHistory.open": "開啟",
"askHistory.note": "紀錄只保存在這台裝置的瀏覽器裡,換裝置或清除瀏覽器資料後會遺失。",
"ask.loginRequired": "需要登入", "ask.loginRequired": "需要登入",
"ask.loginRequiredChat": "發起私聊需要登入帳號", "ask.loginRequiredChat": "發起私聊需要登入帳號",
"ask.goLogin": "去登入", "ask.goLogin": "去登入",
@@ -182,9 +191,9 @@ var zhTWDict = map[string]string{
"admin.tagInputHint": "輸入後按 Enter 或逗號新增", "admin.tagInputHint": "輸入後按 Enter 或逗號新增",
"admin.tagInputDuplicate": "已存在,已忽略", "admin.tagInputDuplicate": "已存在,已忽略",
"admin.pagePassword": "頁面訪問密碼", "admin.pagePassword": "頁面訪問密碼",
"admin.pagePasswordHint": "設定訪問密碼(留空則關閉)", "admin.pagePasswordHint": "設定訪問密碼(6–30 位,留空則關閉)",
"admin.askPassword": "提問密碼", "admin.askPassword": "提問密碼",
"admin.askPasswordHint": "設定提問密碼(留空則關閉)", "admin.askPasswordHint": "設定提問密碼(6–30 位,留空則關閉)",
"admin.saveProfile": "儲存修改", "admin.saveProfile": "儲存修改",
"admin.updated": "更新個人資訊成功", "admin.updated": "更新個人資訊成功",
"admin.logout": "登出", "admin.logout": "登出",
@@ -298,6 +307,9 @@ var zhTWDict = map[string]string{
"qlist.unpinShort": "取消置頂", "qlist.unpinShort": "取消置頂",
"qlist.myVotes": "我建立的投票", "qlist.myVotes": "我建立的投票",
"qlist.loadMore": "載入更多", "qlist.loadMore": "載入更多",
"qlist.noMore": "無更多留言",
"qlist.loading": "載入中...",
"qlist.viewAnswer": "查看回答",
"qlist.newVote": "新建投票", "qlist.newVote": "新建投票",
"qlist.edit": "編輯", "qlist.edit": "編輯",
"qlist.untitledVote": "未命名投票", "qlist.untitledVote": "未命名投票",
@@ -618,6 +630,7 @@ var zhTWDict = map[string]string{
"vote.page.submit": "提交投票", "vote.page.submit": "提交投票",
"vote.page.alreadyVoted": "你已參與過該投票,感謝參與!", "vote.page.alreadyVoted": "你已參與過該投票,感謝參與!",
"vote.page.resultsTitle": "投票結果", "vote.page.resultsTitle": "投票結果",
"vote.page.anonymousNotice": "匿名投票,每人一票,結果僅供參考。",
"vote.page.totalVotes": "總參與", "vote.page.totalVotes": "總參與",
"vote.page.avgRating": "平均分", "vote.page.avgRating": "平均分",
"vote.page.avgRank": "平均排名", "vote.page.avgRank": "平均排名",
@@ -627,6 +640,7 @@ var zhTWDict = map[string]string{
"vote.page.rankHint": "請為每個選項填寫 1-N 的排名(數字越小越靠前,不可重複)", "vote.page.rankHint": "請為每個選項填寫 1-N 的排名(數字越小越靠前,不可重複)",
"vote.page.rankPlaceholder": "排名", "vote.page.rankPlaceholder": "排名",
"vote.page.multipleHint": "可多選", "vote.page.multipleHint": "可多選",
"vote.page.multipleHintWithMax": "可多選(最多 %d 項)",
"vote.page.customOption": "補充選項(可選)", "vote.page.customOption": "補充選項(可選)",
"vote.page.customPlaceholder": "填寫你心中的其他選項", "vote.page.customPlaceholder": "填寫你心中的其他選項",
"vote.page.customOther": "其他", "vote.page.customOther": "其他",
@@ -756,6 +770,7 @@ var zhTWDict = map[string]string{
// 提問箱頁:訪客可見(P0) // 提問箱頁:訪客可見(P0)
"question.errWrongPassword": "密碼錯誤", "question.errWrongPassword": "密碼錯誤",
"question.errPasswordLocked": "密碼錯誤次數過多,請 %d 分鐘後再試",
"question.errAskPasswordWrong": "提問密碼錯誤", "question.errAskPasswordWrong": "提問密碼錯誤",
"question.errLoginRequired": "提問箱的主人設定了僅註冊使用者才能提問,請先登入。", "question.errLoginRequired": "提問箱的主人設定了僅註冊使用者才能提問,請先登入。",
"question.errSelfChat": "不能給自己發起私聊", "question.errSelfChat": "不能給自己發起私聊",
@@ -837,6 +852,8 @@ var zhTWDict = map[string]string{
"vote.err.fieldSerialize": "欄位選項序列化失敗", "vote.err.fieldSerialize": "欄位選項序列化失敗",
"vote.err.editClosed": "已結束或歸檔的投票不允許編輯", "vote.err.editClosed": "已結束或歸檔的投票不允許編輯",
"vote.err.publishDraftOnly": "只有草稿狀態的投票可以發布", "vote.err.publishDraftOnly": "只有草稿狀態的投票可以發布",
"vote.err.restrictedPublish": "當前處於平台處置期,已暫停發布投票",
"feature.stateUnavailable": "暫時無法確認該功能的狀態,請稍後重試",
"vote.err.closePublishedOnly": "只有進行中的投票可以結束", "vote.err.closePublishedOnly": "只有進行中的投票可以結束",
"vote.err.publishTitleRequired": "請先填寫投票標題再發布", "vote.err.publishTitleRequired": "請先填寫投票標題再發布",
"vote.err.publishFormFieldsRequired": "表單類型投票至少需要 1 個欄位", "vote.err.publishFormFieldsRequired": "表單類型投票至少需要 1 個欄位",
@@ -902,6 +919,12 @@ var zhTWDict = map[string]string{
"chat.flash.operated": "操作成功", "chat.flash.operated": "操作成功",
"image.errTooLarge": "圖片尺寸過大,最大支援 %d×%d 像素,請壓縮後重新上傳", "image.errTooLarge": "圖片尺寸過大,最大支援 %d×%d 像素,請壓縮後重新上傳",
"internal.errFlash": "服務內部錯誤,請稍後重試。", "internal.errFlash": "服務內部錯誤,請稍後重試。",
"internal.errBadRequest": "請求參數不正確",
"error.notFoundTitle": "頁面不存在",
"error.notFoundDesc": "這個位址沒有內容,可能是連結輸入錯誤,或者內容已經被刪除。",
"error.internalTitle": "服務出了點問題",
"error.internalDesc": "這次請求沒能完成,通常是暫時的。稍後重試一般就好了。",
"error.traceHint": "回報時請帶上這個編號:",
"internal.errServer": "伺服器內部錯誤", "internal.errServer": "伺服器內部錯誤",
"home.errStats": "獲取統計資訊失敗", "home.errStats": "獲取統計資訊失敗",
"pixel.errPath": "非法的請求路徑", "pixel.errPath": "非法的請求路徑",
@@ -925,6 +948,7 @@ var zhTWDict = map[string]string{
"form.profile.name": "暱稱不能為空,且不超過 20 個字元", "form.profile.name": "暱稱不能為空,且不超過 20 個字元",
"form.profile.newPassword": "新密碼長度不能超過 30 個字元", "form.profile.newPassword": "新密碼長度不能超過 30 個字元",
"form.profile.passwordMax": "密碼長度不能超過 30 個字元", "form.profile.passwordMax": "密碼長度不能超過 30 個字元",
"form.profile.passwordLen": "請設定 6 至 30 位的密碼",
"form.question.content": "問題內容不能為空", "form.question.content": "問題內容不能為空",
"form.question.contentTooLong": "問題內容不能超過 %d 個字元", "form.question.contentTooLong": "問題內容不能超過 %d 個字元",
"form.question.receiveReplyEmail": "請填寫有效的電子郵件地址以接收回覆", "form.question.receiveReplyEmail": "請填寫有效的電子郵件地址以接收回覆",
@@ -1029,6 +1053,50 @@ var zhTWDict = map[string]string{
"stats.weekday.6": "週六", "stats.weekday.6": "週六",
"stats.weekday.7": "週日", "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.updated": "快照時間:",
"stats.foot.prevMonth": "最後一則提問:", "stats.foot.prevMonth": "最後一則提問:",
"stats.foot.prevAnswer": "最後一則回答:", "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 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 isOwner := ctx.IsLogged && ctx.User.ID == conv.OwnerID
isAsker := ctx.IsLogged && ctx.User.ID == conv.AskerID isAsker := ctx.IsLogged && ctx.User.ID == conv.AskerID
if !isOwner && !isAsker { 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 ( import (
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"sync" "sync"
"time" "time"
"github.com/flamego/flamego" "github.com/flamego/flamego"
"github.com/flamego/session" "github.com/flamego/session"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/shared" "github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/route/vote" "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. // rateLimiter provides a simple in-memory rate limiter using a sliding window.
type rateLimiter struct { type rateLimiter struct {
mu sync.Mutex mu sync.Mutex
@@ -140,8 +162,7 @@ func rateLimitMiddleware(limit int, window time.Duration) flamego.Handler {
} }
if !rl.allow(ip) { if !rl.allow(ip) {
ctx.ResponseWriter().WriteHeader(http.StatusTooManyRequests) tooManyRequests(ctx, window)
_, _ = ctx.ResponseWriter().Write([]byte("Too many requests. Please try again later."))
return return
} }
} }
@@ -156,8 +177,7 @@ func voteSubmitRateLimitMiddleware(limit int, window time.Duration) flamego.Hand
anonymousIPRL := newRateLimiter(limit*10, window) anonymousIPRL := newRateLimiter(limit*10, window)
tooMany := func(ctx flamego.Context) { tooMany := func(ctx flamego.Context) {
ctx.ResponseWriter().WriteHeader(http.StatusTooManyRequests) tooManyRequests(ctx, window)
_, _ = ctx.ResponseWriter().Write([]byte("Too many requests. Please try again later."))
} }
return func(ctx flamego.Context, s session.Session) { return func(ctx flamego.Context, s session.Session) {
+123 -15
View File
@@ -5,6 +5,7 @@
package route package route
import ( import (
stdctx "context"
"encoding/gob" "encoding/gob"
"io" "io"
"net/http" "net/http"
@@ -64,13 +65,49 @@ func securityHeaders() flamego.Handler {
w := ctx.ResponseWriter() w := ctx.ResponseWriter()
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY") 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("Referrer-Policy", "strict-origin-when-cross-origin")
// 站点不需要这些能力,显式全禁,避免被嵌入页 / 第三方脚本索要权限。
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()")
// HSTS:仅当站点以 https 对外提供服务时下发,避免 http 部署下把用户锁死在 https。 // HSTS:仅当站点以 https 对外提供服务时下发,避免 http 部署下把用户锁死在 https。
if strings.HasPrefix(conf.App.ExternalURL, "https") { if strings.HasPrefix(conf.App.ExternalURL, "https") {
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") 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 装配自动封禁器。 // newBanner 按 conf.Security 装配自动封禁器。
@@ -115,18 +152,49 @@ func newBanner() *autoban.Banner {
if reason == autoban.ReasonHoneypot { if reason == autoban.ReasonHoneypot {
reasonText = "蜜罐提交凭据" reasonText = "蜜罐提交凭据"
} }
expiresAt := time.Now().Add(duration)
logrus.WithFields(logrus.Fields{ logrus.WithFields(logrus.Fields{
"ban_key": key, "ban_key": key,
"reason": reasonText, "reason": reasonText,
"duration": duration.String(), "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)") }).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 { 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 { if conf.App.Production {
flamego.SetEnv(flamego.EnvTypeProd) 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{ f.Use(flamego.Static(flamego.StaticOptions{
FileSystem: http.FS(static.FS), FileSystem: http.FS(static.FS),
Prefix: "/static", Prefix: "/static",
@@ -213,17 +285,47 @@ func New() *flamego.Flame {
}, },
})) }))
f.Use(securityHeaders())
f.Use(ipBlacklistMiddleware()) f.Use(ipBlacklistMiddleware())
f.Use(autobanMiddleware(ab, hp)) f.Use(autobanMiddleware(ab, hp))
// 全局请求体上限:必须在任何会读取 body 的中间件(尤其 Contexter 内的 CSRF 校验) // 全局请求体上限:必须在任何会读取 body 的中间件(尤其 Contexter 内的 CSRF 校验)
// 之前执行,否则超大 multipart 会在 Bind 的路由级限制生效前落盘临时文件耗尽磁盘。 // 之前执行,否则超大 multipart 会在 Bind 的路由级限制生效前落盘临时文件耗尽磁盘。
f.Use(requestBodyLimitMiddleware()) 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}) reqUserSignOut := context.Toggle(&context.ToggleOptions{UserSignOutRequired: true})
reqUserSignIn := context.Toggle(&context.ToggleOptions{UserSignInRequired: true}) reqUserSignIn := context.Toggle(&context.ToggleOptions{UserSignInRequired: true})
f.Group("", func() { f.Group("", func() {
// 健康检查:存活(永远 200)与就绪(DB 可用才 200,否则 503)。
// 免鉴权、不限流、不查业务数据;响应体不含任何内部错误详情。
// 注意:这两个端点仍会经过全局中间件(安全头、IP 黑名单、自动封禁),
// 这是有意保留的 —— 异常来源不该因为「探针路径」就绕过封禁。
f.Get("/healthz", route.Healthz)
f.Get("/readyz", route.Readyz)
f.Get("/", route.Home) f.Get("/", route.Home)
f.Get("/pixel", reqUserSignIn, pixel.Index) f.Get("/pixel", reqUserSignIn, pixel.Index)
f.Get("/sponsor", route.Sponsor) f.Get("/sponsor", route.Sponsor)
@@ -397,6 +499,10 @@ func New() *flamego.Flame {
f.Group("/user", func() { f.Group("/user", func() {
f.Get("/questions", user.QuestionList) f.Get("/questions", user.QuestionList)
// 箱主自己的数据面板:访问量 / 提问趋势 / 处理漏斗。
// 与公开的 /stats(全站)不同,这里只看当前登录用户自己的箱。
f.Get("/insights", user.Insights)
// 匿名投票管理(箱主侧) // 匿名投票管理(箱主侧)
f.Group("/votes", func() { f.Group("/votes", func() {
f.Get("", user.VoteList) f.Get("", user.VoteList)
@@ -461,18 +567,13 @@ func New() *flamego.Flame {
f.Get("/explore/random", rateLimitMiddleware(60, 1*time.Minute), user.RandomRecommend) f.Get("/explore/random", rateLimitMiddleware(60, 1*time.Minute), user.RandomRecommend)
// 首页"提问箱信息统计"公开接口(服务端 30 分钟缓存),供前端定时刷新 // 首页"提问箱信息统计"公开接口(服务端 30 分钟缓存),供前端定时刷新
f.Get("/site-stats", rateLimitMiddleware(60, 1*time.Minute), route.SiteStatsAPI) 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) }, context.APIEndpoint)
}, },
cache.Cacher(cache.Options{ cacheHandler,
Initer: cacheRedis.Initer(),
Config: cacheRedis.Config{
Options: &cacheRedis.Options{
Addr: conf.Redis.Addr,
Password: conf.Redis.Password,
DB: 0,
},
},
}),
recaptcha.V3( recaptcha.V3(
recaptcha.Options{ recaptcha.Options{
Secret: conf.Recaptcha.ServerKey, Secret: conf.Recaptcha.ServerKey,
@@ -531,7 +632,14 @@ func New() *flamego.Flame {
if ip := shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request)); ip != "" { if ip := shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request)); ip != "" {
ab.Record(ip, ctx.Request().URL.Path, autoban.KindNotFound) 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 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"
"net/http" "net/http"
"strings" "strings"
"sync"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf" "github.com/tamakyi/TamaBox/internal/conf"
) )
@@ -100,33 +102,84 @@ func isTrustedProxy(ipStr string) bool {
return false return false
} }
// ResolveClientIP 统一解析客户端真实 IP,供速率限制、IP 黑名单与审计记录共用。 // headerSourceTrusted 判断本次请求的 TCP 对端是否有资格「决定」客户端 IP。
// //
// 策略:未开 TrustProxy 时一律用不可伪造的 TCP RemoteAddr;开启后 (a) 先信 CDN 权威头 // 只有两种情况成立:
// CF-Connecting-IP → EO-Client-IP,(b) 仅当 TCP 对端落在 trusted_proxies 内才从右向左 // 1. 对端是回环地址 —— cloudflared / 本机反代回源,连接必然由本机进程发起,外部造不出来;
// 解析 XFF / X-Real-IP,(c) 否则回退 RemoteAddr,绝不信任客户端自供的头。 // 2. 对端命中 trusted_proxies —— 你自己的反代 / LB,或明确登记过的 CDN 回源网段。
// //
// ⚠️ trust_proxy=true 但源站可被直连(未用 Tunnel / 未锁源站)时,攻击者可自造 // 公网对端一律不采信,哪怕它带了格式完全合法的 CF-Connecting-IP。
// CF-Connecting-IP / EO-Client-IP 并被本函数信任 —— 生产开启必须锁定源站只接受 CDN 回源。 // 这条约束就是 P1-3 的修复核心:双 CDN(境内 EdgeOne + 境外 Cloudflare)并存时,
// EdgeOne **不剥离**客户端自带的 CF-Connecting-IP,而旧实现无条件优先读这个头,
// 于是「经 EdgeOne 进来的请求」其客户端 IP 完全由请求方决定 —— 限流 / IP 黑名单全失效,
// 且 autoban 按这个假 IP 记账,可被用来栽赃任意正常用户(连打 10 次敏感路径即封 15 分钟)。
// //
// ⚠️ 双 CDN(境内 EdgeOne + 境外 Cloudflare)时必须:① 防火墙仅放行两家回源网段; // 反过来说,只要要求「权威头只能由 CDN 写入」这一前提无法被代码验证,就不能把它当公理;
// ② 在各家规则引擎中删除入站请求里「对方 CDN」的权威头,否则攻击者可借道另一家伪造身份, // 唯一能被代码验证的依据是 TCP 对端地址,因此这里 fail-closed。
// 绕过限流 / 自动封禁 / 单 IP 投票限制。步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md。 func headerSourceTrusted(remote string) bool {
func ResolveClientIP(r *http.Request) string { if remote == "" {
if !conf.Security.TrustProxy { return false
return NormalizeIP(r.RemoteAddr)
} }
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 != "" { if cfIP := NormalizeIP(r.Header.Get("CF-Connecting-IP")); cfIP != "" {
return cfIP return cfIP
} }
if eoIP := NormalizeIP(r.Header.Get("EO-Client-IP")); eoIP != "" { return NormalizeIP(r.Header.Get("EO-Client-IP"))
return eoIP }
// 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。 // (a) CDN 权威客户端头:只有对端可信时才采信。
remote := NormalizeIP(r.RemoteAddr) 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 isTrustedProxy(remote) {
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); forwarded != "" { if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); forwarded != "" {
// 从右向左取第一个非可信代理跳:客户端的真实 IP。 // 从右向左取第一个非可信代理跳:客户端的真实 IP。
+105 -10
View File
@@ -7,13 +7,31 @@ import (
"github.com/tamakyi/TamaBox/internal/conf" "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) { func TestResolveClientIPCloudflareHeader(t *testing.T) {
oldTrustProxy := conf.Security.TrustProxy oldTrustProxy := conf.Security.TrustProxy
defer func() { conf.Security.TrustProxy = oldTrustProxy }() defer func() { conf.Security.TrustProxy = oldTrustProxy }()
conf.Security.TrustProxy = true conf.Security.TrustProxy = true
// 回环对端 = cloudflared / 本机反代回源,这是生产实际形态。
r := httptest.NewRequest("GET", "https://example.com/", nil) 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("CF-Connecting-IP", "198.51.100.20")
r.Header.Set("X-Real-IP", "198.51.100.30") r.Header.Set("X-Real-IP", "198.51.100.30")
if got := ResolveClientIP(r); got != "198.51.100.20" { 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) { func TestResolveClientIPIgnoresHeadersWithoutTrust(t *testing.T) {
oldTrustProxy := conf.Security.TrustProxy oldTrustProxy := conf.Security.TrustProxy
defer func() { conf.Security.TrustProxy = oldTrustProxy }() defer func() { conf.Security.TrustProxy = oldTrustProxy }()
conf.Security.TrustProxy = false conf.Security.TrustProxy = false
r := httptest.NewRequest("GET", "https://example.com/", nil) 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("CF-Connecting-IP", "198.51.100.20")
r.Header.Set("EO-Client-IP", "198.51.100.99") 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) t.Fatalf("ResolveClientIP() = %q, want remote address", got)
} }
} }
// TestResolveClientIPEdgeOneHeader 验证腾讯 EdgeOne 的权威客户端头 EO-Client-IP // TestResolveClientIPRejectsForgedHeaderFromPublicPeer 是 P1-3 的回归测试。
// 在 TrustProxy=true 时被信任(双 CDN 部署:境内 EdgeOne 回源不带 CF-Connecting-IP)。 //
func TestResolveClientIPEdgeOneHeader(t *testing.T) { // 攻击场景:双 CDN 并存时经 EdgeOne 打进来,EdgeOne 不剥离客户端自带的
// CF-Connecting-IP,旧实现无条件优先读它 → 客户端 IP 完全可控(限流 / 黑名单失效,
// 且可借 autoban 栽赃任意 IP)。修复后公网对端一律不采信权威头。
func TestResolveClientIPRejectsForgedHeaderFromPublicPeer(t *testing.T) {
oldTrustProxy := conf.Security.TrustProxy oldTrustProxy := conf.Security.TrustProxy
defer func() { conf.Security.TrustProxy = oldTrustProxy }() defer func() { conf.Security.TrustProxy = oldTrustProxy }()
conf.Security.TrustProxy = true 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 := 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("EO-Client-IP", "198.51.100.99")
r.Header.Set("X-Forwarded-For", "203.0.113.99") r.Header.Set("X-Forwarded-For", "203.0.113.99")
if got := ResolveClientIP(r); got != "198.51.100.99" { if got := ResolveClientIP(r); got != "198.51.100.99" {
@@ -59,7 +136,7 @@ func TestResolveClientIPPrefersCloudflareOverEdgeOne(t *testing.T) {
conf.Security.TrustProxy = true conf.Security.TrustProxy = true
r := httptest.NewRequest("GET", "https://example.com/", nil) 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("CF-Connecting-IP", "198.51.100.20")
r.Header.Set("EO-Client-IP", "198.51.100.99") r.Header.Set("EO-Client-IP", "198.51.100.99")
if got := ResolveClientIP(r); got != "198.51.100.20" { if got := ResolveClientIP(r); got != "198.51.100.20" {
@@ -75,13 +152,31 @@ func TestResolveClientIPEdgeOneInvalidFallsBack(t *testing.T) {
conf.Security.TrustProxy = true conf.Security.TrustProxy = true
r := httptest.NewRequest("GET", "https://example.com/", nil) 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") 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) 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 的回归测试:限流 / 自动封禁用的聚合键必须 // TestLimitKey 是 P1-4 的回归测试:限流 / 自动封禁用的聚合键必须
// - IPv4:原样返回(每地址独立计数); // - IPv4:原样返回(每地址独立计数);
// - IPv6:掩码到 /64(防止单 /64 内轮换海量地址绕过阈值、并避免键无限膨胀); // - 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" "bytes"
stdhtml "html" stdhtml "html"
"html/template" "html/template"
"net/url"
"regexp" "regexp"
"strings" "strings"
"github.com/russross/blackfriday/v2" "github.com/russross/blackfriday/v2"
"golang.org/x/net/html" "golang.org/x/net/html"
"github.com/tamakyi/TamaBox/internal/conf"
) )
// blockEndTags / reBlockEndBrList 删除块级元素闭合标签紧前的 <br>:HardLineBreak 会把 // blockEndTags / reBlockEndBrList 删除块级元素闭合标签紧前的 <br>:HardLineBreak 会把
@@ -44,7 +47,8 @@ func PlainText(markdown string) string {
// MarkdownToHTML 渲染为安全的 HTML 片段。先由 blackfriday 渲染,再经白名单过滤: // MarkdownToHTML 渲染为安全的 HTML 片段。先由 blackfriday 渲染,再经白名单过滤:
// 只保留常用排版标签与属性;丢弃 script / style / iframe / form 等危险标签; // 只保留常用排版标签与属性;丢弃 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 { func MarkdownToHTML(markdown string) template.HTML {
if strings.TrimSpace(markdown) == "" { if strings.TrimSpace(markdown) == "" {
return "" return ""
@@ -155,6 +159,12 @@ func writeSanitizedTag(out *bytes.Buffer, tok html.Token, isEnd bool) {
return return
} }
// img 必须有合法的 src,否则整段丢弃:只删掉 src 属性会留下 <img alt="...">,
// 浏览器渲染成空占位(破图),既难看又没意义。
if name == "img" && !imgSrcAllowed(tok.Attr) {
return
}
// 反向 tabnabbing 防护:a 标签若使用 target="_blank",强制附带 rel="noopener noreferrer"。 // 反向 tabnabbing 防护:a 标签若使用 target="_blank",强制附带 rel="noopener noreferrer"。
hasTargetBlank := false hasTargetBlank := false
if name == "a" { if name == "a" {
@@ -184,7 +194,14 @@ func writeSanitizedTag(out *bytes.Buffer, tok html.Token, isEnd bool) {
} }
switch key { switch key {
case "href", "src": 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 continue
} }
case "target": case "target":
@@ -250,3 +267,119 @@ func safeURL(u string) bool {
} }
return false 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 ( import (
"strings" "strings"
"testing" "testing"
"github.com/tamakyi/TamaBox/internal/conf"
) )
func TestMarkdownToHTML_Basic(t *testing.T) { 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 package auth
import ( import (
"path"
"github.com/flamego/recaptcha" "github.com/flamego/recaptcha"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
@@ -56,8 +54,7 @@ func LoginAction(ctx context.Context, f form.Login, recaptcha recaptcha.Recaptch
return return
} }
to := ctx.Query("to") to := shared.SafeRedirectTarget(ctx.Query("to"))
to = path.Clean("/" + to)
if to == "" { if to == "" {
to = "/_/" + user.Domain 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), // 注册时选择的界面语言;留空回退站点默认 Language: i18n.Resolve(f.Language), // 注册时选择的界面语言;留空回退站点默认
}); err != nil { }); err != nil {
switch { switch {
// 防枚举:邮箱是否已注册,对外必须与"注册成功"不可区分。
//
// 注册接口原本会直接回"该邮箱已被注册",而找回密码接口(ForgotPasswordAction)
// 早就统一成"已发送"。两处口径不一致的结果是:任何人都能批量确认
// "某个邮箱是不是这个匿名提问箱的用户"—— 这是成员关系泄露,比一般站更敏感。
// 真实原因只写服务端日志(运营排查要看得到到底撞的是哪一类)。
case errors.Is(err, db.ErrDuplicateEmail): 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): case errors.Is(err, db.ErrDuplicateDomain):
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.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): case errors.Is(err, db.ErrDeactivatedDomain):
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errDeactivatedDomain"))) ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errDeactivatedDomain")))
case errors.Is(err, db.ErrBadCredential): case errors.Is(err, db.ErrBadCredential):
@@ -88,6 +102,15 @@ func RegisterAction(ctx context.Context, f form.Register, recaptcha recaptcha.Re
return return
} }
registerSucceeded(ctx)
}
// registerSucceeded 是注册接口唯一的"成功"出口。
//
// 抽成函数是为了让"真的建成功了"和"因为邮箱已存在而没建"走同一条路径 ——
// 一旦两处分别写,响应体的措辞、跳转目标、flash 文案迟早会漂移出差异,
// 差异就是枚举信号。新增失败分支时若想遮蔽,直接调用本函数即可。
func registerSucceeded(ctx context.Context) {
// 成功提示跟随用户注册时选择的语言 // 成功提示跟随用户注册时选择的语言
lang := "zh-CN" lang := "zh-CN"
if l, ok := ctx.Data["Lang"].(string); ok && l != "" { 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 ( import (
stdctx "context" stdctx "context"
"crypto/subtle"
"fmt" "fmt"
"unicode/utf8" "unicode/utf8"
@@ -21,7 +22,11 @@ import (
// isQuestioner 判断当前请求者是否为该提问的提问者。 // isQuestioner 判断当前请求者是否为该提问的提问者。
// 匿名提问者通过 token(URL 或表单隐藏域)验证;登录提问者通过账号 ID 验证。 // 匿名提问者通过 token(URL 或表单隐藏域)验证;登录提问者通过账号 ID 验证。
func isQuestioner(ctx context.Context, question *db.Question, token string) bool { 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 return true
} }
if ctx.IsLogged && question.AskerUserID != 0 && ctx.User.ID == question.AskerUserID { 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 可被直接构造绕过,因此必须检查最新一条追问是否已回复。 // 但 POST 可被直接构造绕过,因此必须检查最新一条追问是否已回复。
//
// 这一段只是「快速失败」省掉一次事务开销;并发/双击下的正确性由
// db.FollowUps.CreateIfBelowLimit 在事务内重新判定(见 ErrFollowUpAwaitingReply)。
latest, err := db.FollowUps.GetLatestByQuestionID(ctx.Request().Context(), question.ID) latest, err := db.FollowUps.GetLatestByQuestionID(ctx.Request().Context(), question.ID)
if err != nil { if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get latest follow up") 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)) ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return 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") logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create follow up")
ctx.SetInternalErrorFlash() ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token)) 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 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) { if errors.Is(err, db.ErrFollowUpNotFound) {
// 同请求渲染需用 SetError(SetErrorFlash 要等下一次请求才显示)。 // 同请求渲染需用 SetError(SetErrorFlash 要等下一次请求才显示)。
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "followup.errNotFound")), f) 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 return
} }
// 邮件通知提问者(留下邮箱时):复用"您的提问有了回复"模板,带 token 链接 // 邮件通知提问者(留下邮箱时):复用"您的提问有了回复"模板,带 token 链接。
//
// 只在**首次**回答时发:箱主改一次措辞、补一句、修错别字都会重新保存,
// 每次都发的话提问者会连着收到好几封一模一样的「您的提问有回复」。
// 主回答那边早就有同样的判定(question.Answer == ""),这里补齐。
shared.SafeGo(func() { 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 { 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") 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 轮廓")
}
}
+113 -49
View File
@@ -35,6 +35,7 @@ import (
"github.com/tamakyi/TamaBox/internal/form" "github.com/tamakyi/TamaBox/internal/form"
"github.com/tamakyi/TamaBox/internal/i18n" "github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/imagecheck" "github.com/tamakyi/TamaBox/internal/imagecheck"
"github.com/tamakyi/TamaBox/internal/insights"
"github.com/tamakyi/TamaBox/internal/mail" "github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/security/moderation" "github.com/tamakyi/TamaBox/internal/security/moderation"
"github.com/tamakyi/TamaBox/internal/shared" "github.com/tamakyi/TamaBox/internal/shared"
@@ -201,6 +202,15 @@ func Pager(ctx context.Context) {
} else { } else {
ctx.Data["PageQuestionCursor"] = "" 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 用于在列表展示私聊时附带第一条消息 // conversationWithFirstMessage 用于在列表展示私聊时附带第一条消息
@@ -217,6 +227,14 @@ func formatFeatureTime(t *time.Time) string {
return t.Format("2006-01-02 15:04") 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) { func PasswordPage(ctx context.Context) {
domain := ctx.Param("domain") domain := ctx.Param("domain")
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain) pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
@@ -243,17 +261,33 @@ func VerifyPagePassword(ctx context.Context) {
ctx.Redirect("/") ctx.Redirect("/")
return 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") password := ctx.Request().FormValue("password")
if pageUser.CheckPagePassword(password) { if pageUser.CheckPagePassword(password) {
pagePasswordGuard.Reset(guardKey)
passKey := fmt.Sprintf("page_pass_%d", pageUser.ID) passKey := fmt.Sprintf("page_pass_%d", pageUser.ID)
ctx.Session.Set(passKey, true) ctx.Session.Set(passKey, true)
// 验证成功后跳回原始页面(如带 token 的单题链接),避免一律回到首页 // 验证成功后跳回原始页面(如带 token 的单题链接),避免一律回到首页。
next := ctx.Request().FormValue("next") // 必须走 shared.SafeRedirectTarget:这里原先自己判「以 / 开头、非 //、不含 ://」,
if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.Contains(next, "://") { // 但没拒反斜杠 —— `next=/\evil.example` 会被浏览器当成 https://evil.example/。
next := shared.SafeRedirectTarget(ctx.Request().FormValue("next"))
if next == "" {
next = "/_/" + domain next = "/_/" + domain
} }
ctx.Redirect(next) ctx.Redirect(next)
} else { } else {
pagePasswordGuard.Fail(guardKey)
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errWrongPassword")) ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errWrongPassword"))
ctx.Redirect("/_/" + domain + "/password") ctx.Redirect("/_/" + domain + "/password")
} }
@@ -341,21 +375,38 @@ func ListAPI(ctx context.Context) error {
return ctx.JSON(resp) return ctx.JSON(resp)
} }
func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha recaptcha.RecaptchaV3) { // askPasswordDenied 校验提问密码,失败时写入表单错误并返回 true(调用方直接 return)。
//
// 提问密码检查 // ⚠️ 必须排在验证码校验之后调用。旧实现把它放在 New() 的第一行,于是「猜密码」的请求
if pageUser.AskPassword != "" { // 完全不消耗验证码:实测错误密码 + 完全无效的验证码,返回的是「提问密码错误」,
if !pageUser.CheckAskPassword(f.AskPassword) { // 验证码在这个入口等于不存在 —— 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.SetError(errors.New(i18n.T(ctx.Lang(), "question.errAskPasswordWrong")), f)
ctx.Success("question/list") ctx.Success("question/list")
return return true
} }
}
func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha recaptcha.RecaptchaV3) {
// 提问功能开关 / 定时 / 定量:任一条件不满足即拒绝普通提问(纵深防御,页面入口已隐藏表单)。 // 提问功能开关 / 定时 / 定量:任一条件不满足即拒绝普通提问(纵深防御,页面入口已隐藏表单)。
// 私聊请求不受影响:提问与私聊是相互独立的功能,提问关闭不应阻断已开启的私聊。 // 私聊请求不受影响:提问与私聊是相互独立的功能,提问关闭不应阻断已开启的私聊。
isChatRequest := f.StartChat == "true" && ctx.IsLogged 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 { if !askState.Open && !isChatRequest {
lang := i18n.Default lang := i18n.Default
if l, ok := ctx.Data["Lang"].(string); ok && l != "" { 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 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 { if !chatState.Open {
lang := i18n.Default lang := i18n.Default
if l, ok := ctx.Data["Lang"].(string); ok && l != "" { 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 return
} }
// 提问密码:验证码通过之后才比对(见 askPasswordDenied 注释)。
// 私聊与提问共用同一个提问密码,这里不能漏,否则发起私聊可绕过它。
if askPasswordDenied(ctx, pageUser, f) {
return
}
// 同 IP + 相同内容重复拦截(防刷屏) // 同 IP + 相同内容重复拦截(防刷屏)
if !shared.DuplicateGuard.Allow(shared.DuplicateKey(shared.ResolveClientIP(ctx.Request().Request), f.Content)) { if !shared.DuplicateGuard.Allow(shared.DuplicateKey(shared.ResolveClientIP(ctx.Request().Request), f.Content)) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errDuplicateSubmit")) 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 maxMessages = conf.Limits.ChatMaxMessages
} }
conv, err := db.Conversations.Create(ctx.Request().Context(), db.CreateConversationOptions{ // 建会话 + 首条消息,一个事务写完。
// 原来三步分开写:第二步失败时会话已经落库,
// 留下一条 message_count=0 、没有任何消息的「空会话」
// (箱主的私聊列表里能看到、点进去什么都没有),
// 而且 BeforeCreate 已经占掉一个 6 字符 token(唯一索引),孤儿会话会永久占用它。
conv, _, err := db.Conversations.CreateWithFirstMessage(
ctx.Request().Context(),
db.CreateConversationOptions{
OwnerID: pageUser.ID, OwnerID: pageUser.ID,
AskerID: ctx.User.ID, AskerID: ctx.User.ID,
MaxMessages: maxMessages, MaxMessages: maxMessages,
}) },
ctx.User.ID,
f.Content,
)
if err != nil { 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.SetInternalError(f)
ctx.Success("question/list") ctx.Success("question/list")
return 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 // 请求态数据提前提取,避免 goroutine 在请求结束后访问 ctx.User
askerName := ctx.User.Name askerName := ctx.User.Name
askerEmail := ctx.User.Email 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()) ok, err := captcha.Verify(f.Recaptcha, shared.ResolveClientIP(ctx.Request().Request), ctx.Session, recaptcha, ctx.Lang())
if err != nil { if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to check captcha") logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to check captcha")
ctx.SetErrorFlash(err.Error()) // 不把 err.Error() 直接给用户:里面可能带上游返回的原始信息。
ctx.Redirect("/_/" + pageUser.Domain) ctx.SetError(errors.New(i18n.T(ctx.Lang(), "captcha.error")), f)
ctx.Success("question/list")
return return
} }
if !ok { if !ok {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "captcha.error")) // 验证码失败是这里最常见的失败(token 过期、网络抖动)。
ctx.Redirect("/_/" + pageUser.Domain) // 原来走 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 return
} }
@@ -628,11 +688,6 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
return 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 { if len(f.Images) > 0 {
image := f.Images[0] image := f.Images[0]
if err := uploadImage(ctx, uploadImageOptions{ if err := uploadImage(ctx, uploadImageOptions{
@@ -641,18 +696,27 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
QuestionID: question.ID, QuestionID: question.ID,
UploaderUserID: askerUserID, UploaderUserID: askerUserID,
}); err != nil { }); 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) { if errors.Is(err, ErrUploadImageSizeTooLarge) || errors.Is(err, imagecheck.ErrImageTooLarge) {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errImageTooLarge")), f) ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errImageTooLarge")), f)
ctx.Success("question/list") ctx.Success("question/list")
return return
} else { }
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to upload image") logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to upload image")
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errUploadImageFailed")), f) ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errUploadImageFailed")), f)
ctx.Success("question/list") ctx.Success("question/list")
return return
} }
} }
}
if err := db.Questions.UpdateCensor(ctx.Request().Context(), question.ID, db.UpdateQuestionCensorOptions{ if err := db.Questions.UpdateCensor(ctx.Request().Context(), question.ID, db.UpdateQuestionCensorOptions{
ContentCensorMetadata: censorResponse.ToJSON(), ContentCensorMetadata: censorResponse.ToJSON(),
+9 -2
View File
@@ -6,6 +6,7 @@ package question
import ( import (
stdctx "context" stdctx "context"
"crypto/subtle"
"fmt" "fmt"
"net/url" "net/url"
"unicode/utf8" "unicode/utf8"
@@ -62,7 +63,8 @@ func Questioner(ctx context.Context, pageUser *db.User) {
if question.UserID != pageUser.ID || if question.UserID != pageUser.ID ||
((question.Answer == "" || question.IsPrivate) && ((question.Answer == "" || question.IsPrivate) &&
(!ctx.IsLogged || ctx.User.ID != question.UserID) && (!ctx.IsLogged || ctx.User.ID != question.UserID) &&
!(question.Token != "" && question.Token == token)) { !(question.Token != "" &&
subtle.ConstantTimeCompare([]byte(question.Token), []byte(token)) == 1)) {
ctx.Redirect("/") ctx.Redirect("/")
return return
} }
@@ -368,7 +370,12 @@ func TogglePin(ctx context.Context, pageUser *db.User, question *db.Question, ca
return 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") logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to toggle question pinned")
ctx.SetInternalError() ctx.SetInternalError()
ctx.Success("question/item") ctx.Success("question/item")
+127 -17
View File
@@ -123,20 +123,51 @@ var (
OverlayColor = color.NRGBA{R: 255, G: 255, B: 255, A: 165} OverlayColor = color.NRGBA{R: 255, G: 255, B: 255, A: 165}
) )
// 全局字体缓存 // 全局字体缓存。
//
// fontFail* 记录"上一次加载失败"与时间:失败结果要短期缓存,不能每次请求都重来。
// 缺字体时单次尝试的代价是遍历候选路径 + 读进 19 MB 的 .ttc 再解析失败(实测 0.7–4 秒纯 CPU),
// 而分享卡是**公开端点**——不缓存失败就等于给任何人一个"发一个 GET 烧几秒 CPU"的放大器。
// 仍保留 1 分钟重试窗口:字体文件是部署产物,补上后无需重启即可恢复。
var ( var (
fontMu sync.Mutex fontMu sync.Mutex
fontCache *truetype.Font fontCache *truetype.Font
fontFailAt time.Time
fontFailErr error
) )
// getFont 返回绘图字体。失败结果不缓存:字体文件属于部署产物、不在仓库里, // fontFailRetry 是加载失败后的重试间隔。
// 新环境最常见的就是漏放;补上文件后无需重启即可恢复分享卡。 const fontFailRetry = time.Minute
// getFont 返回绘图字体。成功结果永久缓存;失败结果缓存 fontFailRetry 后重试。
func getFont() (*truetype.Font, error) { func getFont() (*truetype.Font, error) {
fontMu.Lock() fontMu.Lock()
defer fontMu.Unlock() defer fontMu.Unlock()
if fontCache != nil { if fontCache != nil {
return 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 var tried []string
for _, path := range fontCandidates() { for _, path := range fontCandidates() {
tried = append(tried, path) tried = append(tried, path)
@@ -144,38 +175,97 @@ func getFont() (*truetype.Font, error) {
if err != nil { if err != nil {
continue continue
} }
ft, err := freetype.ParseFont(data) if ft, err := freetype.ParseFont(data); err == nil {
if err != nil { return ft, path, nil
continue }
// .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 { 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 字体候选路径:配置项优先,其次是部署目录里的字体文件 // fontCandidates 字体候选路径:配置项优先,其次是部署目录里的字体文件
// (换过文件名也能用上),最后是常见系统 CJK 字体路径。 // (换过文件名也能用上),最后是系统里能用的 CJK 字体。
//
// 顺序是有意的:
// - static/fonts 是部署产物,最可能就是「运维补的那个」;
// - 系统候选把 glyf 轮廓的放前面,Noto Sans CJK 压到最后 —— 后者是 CFF/OTTO,
// freetype 一定解析失败,只是偶尔会有 glyf 变体才留着当兜底。
//
// 相对路径按「当前工作目录」与「可执行文件所在目录」各查一次:systemd 单元
// 未必配 WorkingDirectory,只按 CWD 找会出现「文件明明在却找不到」。
func fontCandidates() []string { func fontCandidates() []string {
var paths []string var paths []string
if p := strings.TrimSpace(conf.App.FontPath); p != "" { if p := strings.TrimSpace(conf.App.FontPath); p != "" {
paths = append(paths, p) paths = append(paths, p)
} }
for _, pattern := range []string{"static/fonts/*.otf", "static/fonts/*.ttf", "static/fonts/*.ttc"} { for _, dir := range staticFontDirs() {
if matches, err := filepath.Glob(pattern); err == nil { for _, pattern := range []string{"*.ttf", "*.otf", "*.ttc"} {
if matches, err := filepath.Glob(filepath.Join(dir, pattern)); err == nil {
paths = append(paths, matches...) paths = append(paths, matches...)
} }
} }
}
return append(paths, 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/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf", "/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 均非内网, // shareDialer 与 shareDialContext:SSRF 防护——解析域名后校验所有解析 IP 均非内网,
// 再交由 shared.DialFirstReachable 对公网候选 IP 并发拨号取最先成功者(happy-eyeballs 风格)。 // 再交由 shared.DialFirstReachable 对公网候选 IP 并发拨号取最先成功者(happy-eyeballs 风格)。
// 防 DNS rebinding(IP 字面量直连、无二次解析)的同时,避免固定拨单一 IP 抽中劣化边缘 // 防 DNS rebinding(IP 字面量直连、无二次解析)的同时,避免固定拨单一 IP 抽中劣化边缘
@@ -279,6 +369,15 @@ func (c *ttlCache) Get(key string) (interface{}, bool) {
return item.value, true 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) { func (c *ttlCache) Set(key string, value interface{}, ttl time.Duration) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -357,9 +456,14 @@ func ShareCard(ctx context.Context) {
// 命中缓存直接返回,避免重复下载图片与重新绘制 // 命中缓存直接返回,避免重复下载图片与重新绘制
cacheKey := shareCardCacheKey(user) cacheKey := shareCardCacheKey(user)
if cached, ok := shareCardCache.Get(cacheKey); ok { if cached, ok := shareCardCache.Get(cacheKey); ok {
writeShareCardResponse(ctx, cached.([]byte)) if data, isBytes := cached.([]byte); isBytes {
writeShareCardResponse(ctx, data)
return return
} }
// 类型不符:理论上不会发生(key 由本文件独占写入),但裸断言遇到不符就是 panic。
// 丢弃这条脏缓存,走下面重新绘制。
shareCardCache.Delete(cacheKey)
}
canvas := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight)) 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) key := fmt.Sprintf("%s|%dx%d", url, w, h)
if cached, ok := imageCache.Get(key); ok { 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) resp, err := shareHTTPClient.Get(url)
@@ -997,9 +1104,12 @@ func QuestionShareCard(ctx context.Context) {
cacheKey := questionShareCardCacheKey(user, question) cacheKey := questionShareCardCacheKey(user, question)
if cached, ok := questionShareCache.Get(cacheKey); ok { if cached, ok := questionShareCache.Get(cacheKey); ok {
writeShareCardResponse(ctx, cached.([]byte)) if data, isBytes := cached.([]byte); isBytes {
writeShareCardResponse(ctx, data)
return return
} }
questionShareCache.Delete(cacheKey)
}
canvas := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight)) canvas := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight))
innerRect := image.Rect(QContentX, QContentY, QContentX+QContentW, QContentY+QContentH) innerRect := image.Rect(QContentX, QContentY, QContentX+QContentW, QContentY+QContentH)
+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 return votes, nil
} }
// filterVotesByOwnerFeature 过滤掉箱主已关闭投票功能的投票(开关 / 定时 / 定量收集), // filterVotesForExplore 过滤掉不该出现在探索页的投票:
// 避免探索页出现点了打不开的死链。功能配置批量加载,避免逐条查询造成 N+1; //
// 加载失败时按原样返回,依赖表抖动不应导致探索页空白。 // 1. 箱主已关闭投票功能(开关 / 定时 / 定量收集)—— 避免点开是死链;
func filterVotesByOwnerFeature(ctx context.Context, votes []*db.Vote) []*db.Vote { // 2. 箱主处于站方处置中 —— 与首页箱主列表(GetShowInHome 用
// activeRestrictionsSubQuery)保持同一口径。此前只过滤了功能开关,
// 于是同一张探索页里会出现「箱主本人在列表里消失了,他的投票还在曝光」。
//
// 两次配置都是批量加载,避免逐条查询造成 N+1;任一侧加载失败时按「该项不过滤」
// 处理,依赖表抖动不应让探索页整页空白。
func filterVotesForExplore(ctx context.Context, votes []*db.Vote) []*db.Vote {
if len(votes) == 0 { if len(votes) == 0 {
return votes 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) controls, err := db.FeatureControls.ListByUserIDs(ctx.Request().Context(), ownerIDs, db.FeatureVote)
if err != nil { if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to batch load vote feature controls") 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() now := time.Now()
out := make([]*db.Vote, 0, len(votes)) out := make([]*db.Vote, 0, len(votes))
for _, v := range votes { for _, v := range votes {
if restricted[v.UserID] {
continue
}
fc, ok := controls[v.UserID] fc, ok := controls[v.UserID]
if !ok { if !ok {
fc = db.DefaultFeatureControl(v.UserID, db.FeatureVote) fc = db.DefaultFeatureControl(v.UserID, db.FeatureVote)
@@ -143,7 +164,14 @@ func Explore(ctx context.Context, c cache.Cache) {
pageStr := ctx.Query("page") pageStr := ctx.Query("page")
page := 1 page := 1
if pageStr != "" { 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, err := strconv.Atoi(pageStr); err == nil && p > 0 {
if p > maxPage {
p = maxPage
}
page = p 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") logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to get hot votes")
hotVotes = []*db.Vote{} hotVotes = []*db.Vote{}
} }
ctx.Data["HotVotes"] = filterVotesByOwnerFeature(ctx, hotVotes) ctx.Data["HotVotes"] = filterVotesForExplore(ctx, hotVotes)
allVotes, err := db.Votes.ListPublishedPublic(ctx.Request().Context(), 30) allVotes, err := db.Votes.ListPublishedPublic(ctx.Request().Context(), 30)
if err != nil { if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to get public votes for explore") logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to get public votes for explore")
allVotes = []*db.Vote{} allVotes = []*db.Vote{}
} }
ctx.Data["AllVotes"] = filterVotesByOwnerFeature(ctx, allVotes) ctx.Data["AllVotes"] = filterVotesForExplore(ctx, allVotes)
// 计算总页数(向上取整) // 计算总页数(向上取整)
totalPages := (int(total) + pageSize - 1) / pageSize 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 package user
import ( import (
"unicode/utf8"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/context" "github.com/tamakyi/TamaBox/internal/context"
@@ -13,6 +15,23 @@ import (
"github.com/tamakyi/TamaBox/internal/i18n" "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 更新页面访问密码 // UpdatePagePassword 更新页面访问密码
func UpdatePagePassword(ctx context.Context, f form.UpdatePagePassword) { func UpdatePagePassword(ctx context.Context, f form.UpdatePagePassword) {
// 反序列化失败(如请求体超限)时零值 Password="" 会把密码静默解除——先中止, // 反序列化失败(如请求体超限)时零值 Password="" 会把密码静默解除——先中止,
@@ -21,6 +40,9 @@ func UpdatePagePassword(ctx context.Context, f form.UpdatePagePassword) {
if abortOnFormError(ctx, returnTab(ctx)) { if abortOnFormError(ctx, returnTab(ctx)) {
return return
} }
if rejectTooShortPassword(ctx, f.Password) {
return
}
if err := db.Users.UpdatePagePassword(ctx.Request().Context(), ctx.User.ID, f.Password); err != nil { 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") 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)) { if abortOnFormError(ctx, returnTab(ctx)) {
return return
} }
if rejectTooShortPassword(ctx, f.Password) {
return
}
if err := db.Users.UpdateAskPassword(ctx.Request().Context(), ctx.User.ID, f.Password); err != nil { 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") logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update ask password")
+56 -21
View File
@@ -915,6 +915,21 @@ func VotePublish(ctx context.Context) {
return 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 { if err := validateForPublish(ctx, vote); err != nil {
ctx.SetErrorFlash(err.Error()) ctx.SetErrorFlash(err.Error())
ctx.Redirect("/user/questions#votes") ctx.Redirect("/user/questions#votes")
@@ -1045,6 +1060,8 @@ func VoteClose(ctx context.Context) {
} }
// VoteDelete 删除投票(级联删除选项、表单字段与提交记录)。 // VoteDelete 删除投票(级联删除选项、表单字段与提交记录)。
//
// 实际删除动作全部在 db.Votes.DeleteWithCascade 的一个事务里完成,且是真删(Unscoped)。
func VoteDelete(ctx context.Context) { func VoteDelete(ctx context.Context) {
vote, err := getOwnedVote(ctx, ctx.Param("voteID")) vote, err := getOwnedVote(ctx, ctx.Param("voteID"))
if err != nil { if err != nil {
@@ -1053,24 +1070,24 @@ func VoteDelete(ctx context.Context) {
return return
} }
if err := db.VoteOptions.DeleteByVoteID(ctx.Request().Context(), vote.ID); err != nil { // 级联删除必须是一个事务:原来四步独立写入、前三步失败只打 Warn,
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete vote options") // 中途失败会留下「选项没了但投票记录还在」这种界面无法修复的状态 ——
} // 而重试删除时投票已不存在,只能报 not exist,脏数据永远留着。
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") // 顺带修掉一个隐私问题:这些表都带软删除,原来的 Delete 全是软删,
} // 提交记录里参与者填写的文本、voter_ip、voter_token 只是「界面上看不到」,
if err := db.VoteSubmissions.DeleteByVoteID(ctx.Request().Context(), vote.ID); err != nil { // 库里永久留着。DeleteWithCascade 对四张表统一走 Unscoped 真删。
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete vote submissions") 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")
// 投票已删除:失效其结果缓存(即便竞态下仍能命中也已无意义)。
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")
ctx.SetInternalErrorFlash() ctx.SetInternalErrorFlash()
ctx.Redirect("/user/questions#votes") ctx.Redirect("/user/questions#votes")
return return
} }
// 删除成功后再失效缓存。放在事务之后是有意的:
// 若先删缓存而事务回滚,读请求会立刻重新算一遍并再次填进旧值。
votepkg.InvalidateResultsCache(vote.ID)
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "vote.flash.deleted")) ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "vote.flash.deleted"))
ctx.Redirect("/user/questions#votes") ctx.Redirect("/user/questions#votes")
} }
@@ -1152,9 +1169,18 @@ func VoteResults(ctx context.Context) {
formFields, _ = db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID) formFields, _ = db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID)
// 短 TTL 缓存回复明细解析结果(逐条 json.Unmarshal 全部提交记录的 O(N) 开销)。 // 短 TTL 缓存回复明细解析结果(逐条 json.Unmarshal 全部提交记录的 O(N) 开销)。
replyKey := strconv.FormatUint(uint64(vote.ID), 10) replyKey := strconv.FormatUint(uint64(vote.ID), 10)
// 带 ok 的断言:缓存里若混入别的类型,裸断言会直接 panic 掉整个结果页。
// 类型不符时丢弃该条缓存并走重建分支(所以不能用 else 接在外层 if 后)。
fromCache := false
if cached, ok := voteTextRepliesCache.Get(replyKey); ok { if cached, ok := voteTextRepliesCache.Get(replyKey); ok {
textReplies = cached.(map[string][]textReplyView) if parsed, isMap := cached.(map[string][]textReplyView); isMap {
textReplies = parsed
fromCache = true
} else { } else {
voteTextRepliesCache.Delete(replyKey)
}
}
if !fromCache {
submissions, _ := db.VoteSubmissions.ListByVoteID(ctx.Request().Context(), vote.ID, nil) submissions, _ := db.VoteSubmissions.ListByVoteID(ctx.Request().Context(), vote.ID, nil)
for _, s := range submissions { for _, s := range submissions {
var data map[string]interface{} var data map[string]interface{}
@@ -1182,13 +1208,21 @@ func VoteResults(ctx context.Context) {
ctx.Success("user/vote-results") 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 { func sanitizeCSVCell(s string) string {
if s == "" { trimmed := strings.TrimLeft(s, " \t\r\n")
if trimmed == "" {
return s return s
} }
r := []rune(s)[0] r := []rune(trimmed)[0]
if r == '=' || r == '+' || r == '-' || r == '@' { if r == '=' || r == '+' || r == '-' || r == '@' {
return "'" + s return "'" + s
} }
@@ -1237,9 +1271,10 @@ func VoteExportCSV(ctx context.Context) {
if vote.Type == db.VoteTypeForm { if vote.Type == db.VoteTypeForm {
fields, _ := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID) fields, _ := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID)
// 字段名是箱主自己填的,同样可能是 =HYPERLINK(...) 形式。
header := []string{"提交时间"} header := []string{"提交时间"}
for _, f := range fields { for _, f := range fields {
header = append(header, f.Label) header = append(header, sanitizeCSVCell(f.Label))
} }
_ = w.Write(header) _ = w.Write(header)
@@ -1264,7 +1299,7 @@ func VoteExportCSV(ctx context.Context) {
switch res.Type { switch res.Type {
case db.VoteTypeSingle, db.VoteTypeMultiple: case db.VoteTypeSingle, db.VoteTypeMultiple:
for _, o := range res.Options { 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: case db.VoteTypeRating:
for _, r := range res.Ratings { for _, r := range res.Ratings {
@@ -1273,7 +1308,7 @@ func VoteExportCSV(ctx context.Context) {
_ = w.Write([]string{"平均分", fmt.Sprintf("%.2f", res.AvgRating)}) _ = w.Write([]string{"平均分", fmt.Sprintf("%.2f", res.AvgRating)})
case db.VoteTypeRank: case db.VoteTypeRank:
for _, r := range res.Rankings { 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)}) _ = 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)) 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 对开放文本做内容审核。 // censorCheck 对开放文本做内容审核。
// //
// 与提问 / 私聊共用同一个守卫(internal/security/moderation.Check):是否拦截由 // 与提问 / 私聊共用同一个守卫(internal/security/moderation.Check):是否拦截由
@@ -252,147 +262,17 @@ type Results struct {
FormFieldStats []*FormFieldStat // form:各字段聚合统计(混合问卷) 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 统计表单投票(混合问卷)各字段的聚合结果: // aggregateFormFieldStats 统计表单投票(混合问卷)各字段的聚合结果:
// radio/select/checkbox → 选项票数;rating → 分布 + 平均分;rank → 各选项被排次数 + 平均排名; // radio/select/checkbox → 选项票数;rating → 分布 + 平均分;rank → 各选项被排次数 + 平均排名;
// number → 平均值;text/textarea → 填写数(内容不聚合展示,保护隐私)。 // 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 { if len(fields) == 0 {
return nil return nil
} }
// 必须加载全部提交记录:radio/select/checkbox/rating/rank/number 等字段的统计 // 必须加载全部提交记录:radio/select/checkbox/rating/rank/number 等字段的统计
// 同样依赖 submissions,不能只在存在 text/textarea 字段时才加载。 // 同样依赖 submissions,不能只在存在 text/textarea 字段时才加载。
// 否则无文本字段的表单投票结果页会全部显示为 0(缺陷)。 // 否则无文本字段的表单投票结果页会全部显示为 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) return aggregateFormFieldStatsWithSubs(fields, submissions)
} }
@@ -566,8 +446,8 @@ func addCustomStat(stats []CustomStat, text string) []CustomStat {
} }
// aggregateCustomStats 统计自定义补充选项(按文本去重计数,保持首现顺序)。 // aggregateCustomStats 统计自定义补充选项(按文本去重计数,保持首现顺序)。
func aggregateCustomStats(ctx context.Context, v *db.Vote) []CustomStat { func aggregateCustomStats(reqCtx stdctx.Context, v *db.Vote) []CustomStat {
submissions, err := db.VoteSubmissions.ListByVoteID(ctx.Request().Context(), v.ID, nil) submissions, err := db.VoteSubmissions.ListByVoteID(reqCtx, v.ID, nil)
if err != nil { if err != nil {
return nil return nil
} }
@@ -607,6 +487,18 @@ func Page(ctx context.Context) {
return 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() now := time.Now()
isOwner := ctx.IsLogged && ctx.User.ID == vote.UserID isOwner := ctx.IsLogged && ctx.User.ID == vote.UserID
@@ -703,6 +595,16 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
return 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) owner, err := db.Users.GetByID(ctx.Request().Context(), vote.UserID)
if err != nil { if err != nil {
@@ -715,7 +617,14 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
lang := ctxLang(ctx) lang := ctxLang(ctx)
// 箱主关闭投票功能(开关 / 定时 / 定量)时拒绝参与,避免绕过页面上的禁用态直接 POST。 // 箱主关闭投票功能(开关 / 定时 / 定量)时拒绝参与,避免绕过页面上的禁用态直接 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.SetErrorFlash(i18n.FeatureClosedMessage(lang, db.FeatureVote, voteState.Reason))
ctx.Redirect("/vote/" + vote.LinkToken) ctx.Redirect("/vote/" + vote.LinkToken)
return return
@@ -809,6 +718,8 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
} }
submission.VoterToken = voterToken submission.VoterToken = voterToken
// QuotaUserID 让「票数计入箱主的定量收集」与提交本身落在同一个事务里。
submission.QuotaUserID = vote.UserID
if _, err := db.VoteSubmissions.Create(ctx.Request().Context(), *submission); err != nil { if _, err := db.VoteSubmissions.Create(ctx.Request().Context(), *submission); err != nil {
if errors.Is(err, db.ErrVoteAlreadyVoted) { if errors.Is(err, db.ErrVoteAlreadyVoted) {
ctx.SetErrorFlash(i18n.T(lang, "vote.page.alreadyVoted")) ctx.SetErrorFlash(i18n.T(lang, "vote.page.alreadyVoted"))
@@ -822,12 +733,12 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
// 写穿式失效:新提交成功后立即清除该投票的结果缓存, // 写穿式失效:新提交成功后立即清除该投票的结果缓存,
// 避免结果页在 TTL 内显示陈旧计数,并把 O(N) 重算成本收敛到"每次新投票一次"。 // 避免结果页在 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 { // (箱主级的累计计数已由 VoteSubmissions.Create 在同一事务里 +1,这里不再重复)
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to increment vote feature quota")
}
if vote.QuotaLimit > 0 { if vote.QuotaLimit > 0 {
count, err := db.VoteSubmissions.CountByVoteID(ctx.Request().Context(), vote.ID) count, err := db.VoteSubmissions.CountByVoteID(ctx.Request().Context(), vote.ID)
if err != nil { if err != nil {
+5 -1
View File
@@ -854,6 +854,10 @@ a.tb-chip:hover {
} }
/* 给页面底部预留空间,避免页脚内容被固定底栏遮挡 */ /* 给页面底部预留空间,避免页脚内容被固定底栏遮挡 */
body { 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) { document.addEventListener('submit', function(e) {
var form = e.target; var form = e.target;
if (!form || form.tagName !== 'FORM') return; if (!form || form.tagName !== 'FORM') return;
@@ -73,34 +116,197 @@
return; return;
} }
// 下载 / 新窗口类表单(target="_blank",如导出):当前页不发生导航,也没有 bfcache tbBeginSubmit(form, e.submitter);
// 复位机会,进入“提交中”状态后按钮会永远卡在转圈 + 禁用。这类表单直接跳过提交反馈。
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');
});
}); });
// bfcache 还原时复位按钮 // bfcache 还原时复位按钮
window.addEventListener('pageshow', tbResetSubmitButtons); window.addEventListener('pageshow', tbResetSubmitButtons);
</script> </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> </body>
</html> </html>
+16 -5
View File
@@ -24,11 +24,9 @@
<link rel="stylesheet" href="/static/css/base.css?v={{ .StaticVersion }}"/> <link rel="stylesheet" href="/static/css/base.css?v={{ .StaticVersion }}"/>
{{ if .ThemeSlug }}<!-- 箱主主题:只覆盖配色/图案/页面背景,不涉及布局;仅提问箱相关页面加载 --> {{ if .ThemeSlug }}<!-- 箱主主题:只覆盖配色/图案/页面背景,不涉及布局;仅提问箱相关页面加载 -->
<link rel="stylesheet" href="/static/css/themes/{{ .ThemeSlug }}.css?v={{ .StaticVersion }}"/>{{ end }} <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.min.js"></script>
<script defer src="https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/js/uikit-icons.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/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> <script defer src="https://cdnjs.snrat.com/ajax/libs/alpinejs/3.9.0/cdn.min.js"></script>
{{ if .VConsole }} {{ if .VConsole }}
@@ -36,7 +34,6 @@
<script>new window.VConsole()</script> <script>new window.VConsole()</script>
{{ end }} {{ end }}
<!-- jQuery 已改为按需加载:仅 easy.qrcode 二维码页(list/question-not-found/profile)自行引入 --> <!-- 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" }} {{ if eq .CaptchaType "math" }}
<!-- 算数验证码:弹窗模式,不影响原布局 --> <!-- 算数验证码:弹窗模式,不影响原布局 -->
<style> <style>
@@ -105,7 +102,21 @@
}</script> }</script>
{{ else }} {{ else }}
<script src="{{.RecaptchaDomain}}/recaptcha/api.js" async defer></script> <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 }} {{ end }}
{{ if ne SentryDSN "" }} {{ if ne SentryDSN "" }}
<script <script
+3
View File
@@ -169,6 +169,9 @@
</ul> </ul>
</details> </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> <div id="waline"></div>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
+5 -5
View File
@@ -4,7 +4,7 @@
<div data-qid="{{$elem.ID}}"> <div data-qid="{{$elem.ID}}">
<hr> <hr>
<a class="uk-button uk-button-default uk-button-small uk-float-right" <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"> <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}} {{if $elem.IsPinned}}<span class="tb-pin-flag" title="{{t $.LangCode "ask.pinned"}}">⭐</span>{{end}}
{{Date $elem.CreatedAt "Y-m-d H:i:s"}} {{Date $elem.CreatedAt "Y-m-d H:i:s"}}
@@ -58,7 +58,7 @@
<div> <div>
<hr> <hr>
<a class='uk-button uk-button-default uk-button-small uk-float-right' <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'> <div class='uk-text-left uk-text-small uk-text-muted'>
${question.is_pinned ? '<span class=\'tb-pin-flag\' title=\'{{$pinTitle}}\'>⭐</span>' : ''} ${question.is_pinned ? '<span class=\'tb-pin-flag\' title=\'{{$pinTitle}}\'>⭐</span>' : ''}
${dayjs(question.created_at).format('YYYY-MM-DD HH:mm:ss')} ${dayjs(question.created_at).format('YYYY-MM-DD HH:mm:ss')}
@@ -72,12 +72,12 @@
// 网络失败/响应非法 JSON 时复位 loading,避免按钮永远停留在加载中状态 // 网络失败/响应非法 JSON 时复位 loading,避免按钮永远停留在加载中状态
.catch(() => { loading = false }) .catch(() => { loading = false })
}" class="uk-button uk-button-default uk-width-1-1 uk-margin-small-bottom"> }" class="uk-button uk-button-default uk-width-1-1 uk-margin-small-bottom">
<span x-show="!loading">加载更多</span> <span x-show="!loading">{{t .LangCode "qlist.loadMore"}}</span>
<span x-show="loading"><div uk-spinner="ratio: 0.8"></div> 加载中...</span> <span x-show="loading"><div uk-spinner="ratio: 0.8"></div> {{t .LangCode "qlist.loading"}}</span>
</button> </button>
<div x-show="!more" class="uk-text-meta uk-text-center"> <div x-show="!more" class="uk-text-meta uk-text-center">
<hr> <hr>
无更多留言 {{t .LangCode "qlist.noMore"}}
<br><br> <br><br>
</div> </div>
</div> </div>
+5 -2
View File
@@ -78,7 +78,7 @@
{{ range $index, $elem := .AskUploadImages }} {{ range $index, $elem := .AskUploadImages }}
<div uk-lightbox> <div uk-lightbox>
<a href="https://{{ ImageBucketCDNHost }}/{{ $elem.Key }}"> <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> </a>
</div> </div>
{{ end }} {{ end }}
@@ -96,7 +96,7 @@
{{ range $index, $elem := .AnswerUploadImages }} {{ range $index, $elem := .AnswerUploadImages }}
<div uk-lightbox> <div uk-lightbox>
<a href="https://{{ ImageBucketCDNHost }}/{{ $elem.Key }}"> <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> </a>
</div> </div>
{{ end }} {{ end }}
@@ -147,6 +147,9 @@
{{ end }} {{ end }}
</div> </div>
{{ if not .CanDelete }} {{ 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> <div id="waline"></div>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
+217
View File
@@ -92,6 +92,57 @@
border-color: var(--tb-border); border-color: var(--tb-border);
background: var(--tb-surface-2); 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 { 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-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; } 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 }} {{ end }}
{{template "base/alert" .}} {{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 覆盖 --> <!-- 提问 / 投票 切换:默认显示提问,支持 ?tab=votes 直达(服务端初始态)与 #votes hash 覆盖 -->
<div class="profile-tabs" role="tablist"> <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> <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>
<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" .}} {{template "base/footer" .}}
+1 -1
View File
@@ -98,7 +98,7 @@ document.getElementById("introcolor").style.color = {{.PageUser.Introcolor}};
<hr class="uk-divider-icon"> <hr class="uk-divider-icon">
{{ if ne (len .PageQuestions) 0 }} {{ 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" . }} {{ template "question/history-template" . }}
{{ end }} {{ end }}
</div> </div>
+27
View File
@@ -36,6 +36,33 @@
<script> <script>
document.addEventListener('DOMContentLoaded', function () { 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 input = document.getElementById('private-url-input');
var btn = document.getElementById('copy-btn'); var btn = document.getElementById('copy-btn');
if (!input || !btn) return; 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-card uk-card-default uk-card-hover uk-card-body">
<div class="uk-flex uk-flex-top"> <div class="uk-flex uk-flex-top">
<div> <div>
<img class="explore-avatar" src="{{$user.Avatar}}" alt="avatar"> <img class="explore-avatar" src="{{$user.Avatar}}" alt="avatar" loading="lazy">
</div> </div>
<div class="explore-info"> <div class="explore-info">
<div class="explore-name truncate"> <div class="explore-name truncate">
@@ -587,7 +587,7 @@ document.addEventListener('DOMContentLoaded', function() {
html += ` html += `
<div> <div>
<a href="/_/${escapeHTML(user.domain)}" class="recommend-card"> <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-info">
<div class="recommend-name truncate">${escapeHTML(user.name)}</div> <div class="recommend-name truncate">${escapeHTML(user.name)}</div>
<div class="recommend-tags">${tagsHtml}</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; font-weight: 600;
} }
.profile-panel[hidden] { display: none !important; } .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 { html[data-theme="dark"] .profile-tabs {
border-color: var(--tb-border); border-color: var(--tb-border);
background: var(--tb-surface-2); background: var(--tb-surface-2);
@@ -471,6 +509,16 @@
<!-- 提示条置于面板之外:任何面板保存后的 flash 都可见(面板会被 hidden 隐藏) --> <!-- 提示条置于面板之外:任何面板保存后的 flash 都可见(面板会被 hidden 隐藏) -->
{{template "base/alert" .}} {{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):确认后把裁剪结果写回对应的文件域,随主表单一并提交 --> <!-- 头像/背景裁剪弹窗(Cropper.js):确认后把裁剪结果写回对应的文件域,随主表单一并提交 -->
<div id="media-crop-modal" uk-modal> <div id="media-crop-modal" uk-modal>
<div class="uk-modal-dialog"> <div class="uk-modal-dialog">
@@ -897,7 +945,7 @@
<legend class="uk-legend">{{t $.LangCode "admin.pagePassword"}}</legend> <legend class="uk-legend">{{t $.LangCode "admin.pagePassword"}}</legend>
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label">{{t $.LangCode "admin.pagePasswordHint"}}</label> <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>
<div class="tb-card-save"> <div class="tb-card-save">
<button type="submit" class="uk-button uk-button-primary">{{t $.LangCode "admin.updatePagePassword"}}</button> <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> <legend class="uk-legend">{{t $.LangCode "admin.askPassword"}}</legend>
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label">{{t $.LangCode "admin.askPasswordHint"}}</label> <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>
<div class="tb-card-save"> <div class="tb-card-save">
<button type="submit" class="uk-button uk-button-primary">{{t $.LangCode "admin.updateAskPassword"}}</button> <button type="submit" class="uk-button uk-button-primary">{{t $.LangCode "admin.updateAskPassword"}}</button>
+2 -1
View File
@@ -137,7 +137,7 @@
{{ end }} {{ end }}
{{ else if eq .Vote.Type "multiple" }} {{ 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"> <div id="multiple-options">
{{ range .Options }} {{ range .Options }}
<label class="tb-choice"> <label class="tb-choice">
@@ -268,6 +268,7 @@
{{ if .ShowResults }} {{ if .ShowResults }}
<div class="uk-card uk-card-default uk-card-body"> <div class="uk-card uk-card-default uk-card-body">
<h4 class="uk-margin-small-bottom">{{t .LangCode "vote.page.resultsTitle"}}</h4> <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 .Results }}
{{ if or (eq .Results.Type "single") (eq .Results.Type "multiple") }} {{ if or (eq .Results.Type "single") (eq .Results.Type "multiple") }}