Files
tamabox/.workbuddy/memory/2026-10-23.md
T
tamakyi a5441c99fd feat(stats): 新增公开数据页 /stats,入口收敛到页脚
首页底部那张「站点统计」卡片下线,完整数据移到公开只读页 /stats:
今日实时数、概览六格、近 30 天折线与 12 个月柱状、三个占比环、
7×24 时段热力图、提问字数与回答耗时分布。

- 聚合在 internal/db/analytics.go(SUM(CASE WHEN),方言差异收敛在 analyticsDialectFor)
- 结果按自然日缓存,跨日重算一次后全站共用;重算失败降级返回上一份快照
- 图表零前端依赖:服务端算坐标的 SVG + CSS grid,禁用 JS 页面仍完整
- 环比取「上月 vs 上上月」:本月未过完,否则每月 1 号会得出 -95%
- 入口只在页脚「社区规则」与「找站长」之间;route.Home 不再计算统计
- 补 questions(created_at) 索引;启动后异步预热当天快照
- 测试:analytics 连真库验证软删除不计入、分桶下标、DOW 对齐
2026-10-24 00:57:08 +08:00

251 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 2026-10-23
## 单题分享卡二维码提到 5px/模块(`310f851`)
用户决定「单题一起做」,同时否掉方案 5(结果落盘/R2)——理由:功能使用率不高,不值得大动干戈。
- 单题卡卡片 210×255 放不下 5px 模块(37 模块要 185px):`QQRCardW` 236、`QQRCardH` 300、
`QQRSize` 190。X 由 `QContentX+QContentW-QQRCardW` 派生 → 679,Y 居中 → 120。
- `QTextMaxW` / `qLeftClip` 都从 `QQRCardX` 派生,左栏自动收窄 26px,无需另改。
- 抽了 `drawQuestionQRCard(canvas, user, question, logo, fontCtx)`(handler 只剩调用),
这样测试能直接渲染整卡:`TestQuestionQRCardLayout` 断言正方形 / 模块≥5 / 四边留白≥20 /
居中≤1px,`TEST_PREVIEW=1` 出 `outputs/sharecard-preview/question-card.png`。
- 实测:二维码 185px、居中偏差 0.5px、上下留白 34px;gozxing 在 960/720/480/320 全通过。
变异验证:`QQRSize` 改回 150 → "QR module is 4px, want >=5" FAIL。
- 未做(用户明确不要):分享卡结果落盘/上 R2。进程内 LRU 仍是 16 条 + TTL 1h,
冷渲染成本接受现状。
## 部署后复验(箱子卡 + 单题卡,`310f851`)
- 箱子卡:QR 185×185 @ y120-304 / x702-886,卡中心 795 → 着墨中心 794.0(−1.0px);
提示语 794.0、分隔线 795.0、链接两行 795.0 / 794.5;上下留白各 18px。
- 单题卡:QR 185×185 @ y154-338 / x704-888,卡中心 797 → 着墨中心 796.0(−1.0px);
提示语 797.0;上下留白 34 / 33px。
- 端到端解码(部署图,含真实背景与内嵌头像):两张卡在 960 / 720 / 480 / 320 宽全部 OK,
内容正确(单题卡 `/114`)。
- 顺带确认 **不是** bug:删除提问后分享卡立刻 302(`GetByID` 在缓存查询之前),
不存在「删了还能靠缓存取到图」的窗口。
## 缓存 key 排查结论(`675cecb`)
用户要求「有影响就修」。查下来**当前没有实际影响**,只有一条理论缺口:
| 可能场景 | 是否真有影响 | 原因 |
|---|---|---|
| 用户改域名 → 二维码指向旧域名 | ❌ 当前无 | Domain 只在注册时写(`register.go` / `CreateUser`),无改域名入口 |
| 改 ExternalURL / ShareBackground | ❌ 无 | 配置需重启,缓存是进程内的,重启即清 |
| 换头像/背景后仍是旧图 | ❌ 无 | R2 key = `picture/日期/随机 hex`,内容不可变,换图必换 URL |
| 域名将来可改 | ⚠️ 理论 | 单题卡 key 未含 `user.Domain` |
处理:给 `questionShareCardCacheKey` 补 `user.Domain`(箱子卡本来就有);
两处「TTL 30 分钟」注释改成 1 小时(代码一直是 `time.Hour`)。
新增 `TestShareCardCacheKeysCoverDrawnFields`——逐字段变异断言 key 必须变,
去掉 Domain 立刻 FAIL。**以后改分享卡要加新字段时,先看这张测试。**
## 正式环境「蜜罐封了但还能访问」排查(IPv6)
本地用 IPv6 全程复现了一遍 autoban 链路(CF-Connecting-IP 注入 + trust_proxy=true):
封禁前 200 → POST 凭据后 `IsBanned`=true → 同地址 403 → 同 /64 换地址仍 403 →
不同 /64 放行 200。**判定逻辑与中间件都正确,不是代码 bug。**
封禁键 = `shared.LimitKey(ResolveClientIP(req))`:
- IPv4 原样
- IPv6 折成 `xxxx:xxxx:xxxx:xxxx::/64`
写入(honeypot.Serve / route.NotFound)与检查(autobanMiddleware)用的是同一个函数,一致。
⇒ 「邮件说封了但能访问」只能是**键不一致**或**进程重启(封禁纯内存态)**。
三种可能:① 部署后重启过;② trust_proxy 未配 → 键是反代地址(`127.0.0.1` / `::/64`);
③ 前后请求地址族或 /64 不同(双栈、IPv6 前缀轮换、多实例)。
判定方法:`honeypot hits` / `data/honeypot-creds/creds-YYYY-MM-DD.jsonl` 里的 `ip` 字段
就是**被封的键**,与本机 `curl -6 ifconfig.co` 比对即可。
**发现的真实取证缺陷**:`db.HoneypotHit.IP` 与 creds 文件存的是折叠后的 `/64` 键,
IPv6 丢掉了精确地址(IPv4 无损);邮件里也只给键,运维会误以为「这不是我的 IP」。
建议:取证记精确 IP,封禁仍用 /64;建封与 403 命中各打一条日志。
### 据此动手改的三处(精确 IP / 封禁键分离 + 可观测性)
1. `honeypotHandler.Serve` 拆两个变量:`clientIP = ResolveClientIP`(精确,取证/邮件用)、
`banKey = LimitKey(clientIP)`(/64,封禁/采样/告警去重用)。DB 与凭据文件都写 clientIP。
2. `autoban.Options.OnBan(key, duration, reason)` 回调(新增 `ReasonProbe` / `ReasonHoneypot`),
保持 autoban 纯标准库、不再依赖 logrus。route.newBanner 注入 → 建封打 WARN
(ban_key / reason / duration / expires_at)。中间件 403 分支打 Debug。
3. 邮件同时给「来源 IP」(精确)与「封禁范围」(ban_key,仅 `ne .ip .ban_key` 时渲染);
顺手把模板里过时的「已入库」改成「写入取证文件」(上一轮凭据外置后没同步)。
副作用修掉:`honeypot stats` 的来源排行改按 `LimitKey` 合并(否则 IPv6 轮换地址会把排行碎成
几十条各 1 次)。SQL 取 `limit*4` 再合并,属近似。
测试:`TestHoneypotRecordsExactIPv6ButBansByPrefix`(精确 IP + /64 键 + store 断言)、
`TestOnBanCallback`、`TestHoneypotAlertTemplateRenders`、`TestMergeSourcesByKey`。
四处变异验证有效(改回存聚合键 / 封禁用精确地址 / 去掉 notifyBan / 不合并排行)。
**变异验证的坑**:把 `shared.LimitKey(r.IP)` 改成 `r.IP` 会因 `shared` 未使用而
**编译失败**(不是断言失败);变异要保证仍能编译(改成 `LimitKey(r.IP) + r.IP` 才测到逻辑)。
### 测试环境验收(部署 29ddee0 后)
伪造 `CF-Connecting-IP: 2001:db8:…`(RFC 文档保留段,封了不影响任何人)直连源站端口:
- 基线 200 → POST 凭据 200(假页面)→ **同 /64 换地址 403**(聚合封禁生效);
- 凭据文件 `ip` = `2001:db8:dead:beef::1234`(精确,无 /64 后缀),`banned:true`;
- 换另一个 /64(`2001:db8:cafe::1`)→ 200,说明封的是 /64 不是全局;
- `od -c` 复核 `"path":"/wp-login.php"` **无前导空格** → cleanPath 生效。
**教训**:上一次那条 `" /wp-login.php"` 是**那次请求的 URI 本身带空格**(本地实测
`Match(" /wp-login.php")=true` 靠 TrimSpace 命中、`Match("/ wp-login.php")=false`),
不是代码问题;判断这类"看不见的字符"一律用 `od -c`,别靠终端渲染。
测试环境服务是**前台跑在 /dev/pts/1**,日志不在文件里 —— 拿日志要 `tmux capture-pane` 或
下次启动加 `>> logs/tamabox.log 2>&1`。
### 蜜罐升级为 9 个伪装家族
新增 `family.go`(皮肤表)/ `asset.go`(静态资源占位)/ 重写 `page.go`:
- 按路径分发:wordpress / phpmyadmin / tomcat(401+Basic) / jenkins / grafana / gitlab /
luci / joomla / generic;每类独立字段名、配色、指纹响应头、Set-Cookie;
- `credentialKeys` 扩 `pma_*` / `j_*` / `luci_*` / `user[login]`;
`token` 只用于判 HadFields,**不进 isPasswordKey**(否则 PMA 的 hidden token 会随机顶掉真密码);
- 静态资源(`/wp-admin/css/…` 等)回占位内容,**不落库不计数** —— 一次页面加载 5–10 个请求;
- `honeypot paths` 加家族汇总(实测 110 条:generic 84 / wordpress 10 / tomcat 6 / gitlab 3 / pma 3 …);
- 性能:RenderFamily 12µs→3.7µs、22→2 allocs、19.5KB→5.2KB(禁 `"a"+b+"c"`、Replacer 提包级)。
**新工具** `dev/preview-honeypot`:渲染 9 个家族到 `outputs/honeypot-preview/index.html`,
配 chrome headless 截图核对外观。靠它发现两处「源码看不出、截图一眼假」:
Tomcat 的 skin 忘了 css(按钮白字无底色)、WP 登录页底部挂着 404 文案。
### 测试环境验收:9 家族伪装(部署 7225f68 后,只读 GET)
全部符合预期:8 个家族 title/generator 各不相同、Tomcat 401+Basic realm、
静态资源 200 且 MIME 正确(128B css / 81B js / 70B png)、插件目录 .php 回 404、
业务路由(/login /user /static /api)无 canary、界外路径仍 302 → /。
发现的漏洞(按优先级):
1. **P1 `/xmlrpc.php` 回 HTML 登录页** —— 而我们自己发了 `X-Pingback: /xmlrpc.php`,
真实 WP 回 text/xml,等于自己指给自己一个明显假的地址;同时丢掉 WP 爆破最有价值的
捕获通道(xmlrpc system.multicall 一次可试上千组,绕开登录限速)。
2. **P2 每次请求恒定值**:PMA `token=e1a4c0b7d2f58g93`(还带非 hex 的 g)、
generic `csrf_token=a7f3c1e9b2d4`、`X-Jenkins-Session: 8b1f2c4d`。
两次请求完全相同 → 跨请求指纹即可识别。应改每请求随机。
3. **P2 `X-Pingback` 用相对路径**,真实 WP 给绝对 URL。
4. **P3 `/phpinfo.php` 漏匹配**:路径清单里是 `/phpinfo`、`/info.php` 精确项,
段前缀匹配不覆盖 `.php` 变体 → 302。可加「p 去掉 .php 后在集合内」的兜底。
5. **P3 `/wp-json/wp/v2/users` 回 HTML**,真实 WP 回 JSON(用户枚举接口)。
6. P3 `X-Frame-Options: DENY` / `Cache-Control: no-store` 与真实 WP 不同,
但属全局中间件,改动影响全站。
### 修 P1/P2:XML-RPC 端点 + 动态 token + .php 兜底
- 新增 `internal/security/honeypot/xmlrpc.go`:`/xmlrpc.php` 不再回登录页。
GET→405+XML fault「XML-RPC services are disabled」;POST methodCall→200+fault
「Incorrect username or password」;POST system.listMethods→方法列表模板。
凭据提取用正则(不上 encoding/xml,蜜罐高频命中):抓 `<string>`、剔除
methodName 与 http(s) URL、两两配对;`blog_id=1` 这种「奇数长度+首项短数字」
会丢首项纠正错位;第一组进标准字段,其余进 `cred2.*`,原始串全量进 `xmlrpc.N`
(配置上限 16 对 / raw 8KB)。
- 动态占位符:`LoginPageVars.Nonce`(32 hex,`math/rand/v2`,刻意不用 crypto/rand,
这些是假 token 只需「每次不同」)。支持 `$hex8/16/32` 与 `$base`;
页头、hidden、Set-Cookie 共用同一套 nonce。
- `X-Pingback` 改绝对地址(`$base/xmlrpc.php`),没配 `external_url` 就不发。
- `Cache-Control` 从 `no-store` 改 `no-cache, must-revalidate, max-age=0`(更像 WP/PMA)。
- Grafana/GitLab 补真实会种的会话 cookie(`grafana_sess`、`_gitlab_session`,动态)。
- `Match` 增加 `.php` 变体兜底:`/phpinfo.php` 之前漏匹配 → 302。
- 性能:RenderFamily 3.7µs→4.18µs、2→3 allocs(多的一次是 hex 编码),可接受。
四处变异验证有效(固定 token / xmlrpc 分支失效 / Pingback 相对路径 / .php 兜底失效)。
### 测试环境验收:XML-RPC 端点(部署 9d3f5a7 后)
- `GET /xmlrpc.php` → **405 + text/xml**(与真实 WP 一致,XML-RPC 只接受 POST),
用户误以为这是报错;405 的 body 是 `<fault>XML-RPC services are disabled…`。
- `POST system.listMethods` → 200 + 方法列表 ✓。
- `POST wp.getUsersBlogs`(伪造 `CF-Connecting-IP: 2001:db8:beef::7`)→ 凭据落文件:
`username=xruser / password=xrpass`、`fields.xmlrpc.0/1`、`xmlrpc.method`、`banned:true` ✓。
- 紧接的下一次请求 `403 Forbidden` = 已封禁(第一次 POST 就封了该 /64),属预期。
- 动态 token 两次不同(`d85a8c…` / `76a404…`)✓。
**再次出现的 `"path":" /xmlrpc.php"` 已判定为粘贴/终端产物**:`IsXMLRPCPath` 不做 trim,
若 `path` 真带前导空格根本进不了 XML-RPC 分支,而记录里有 `xmlrpc.*` 字段 ⇒ 落盘值必是
`/xmlrpc.php`。这类"看不见的字符"结论一律靠 `od -c`,别靠终端回显。
### 403 响应体换成图片
用户给了 R2 预签名链接(`X-Amz-Expires=604800`,7 天后失效)——**不能直接热链**。
下载后(256×256 webp,8.6 KB)用 `//go:embed` 编进二进制:
`internal/route/assets/403.webp` + `internal/route/banned.go: writeBanned()`。
- 状态码仍是 403(语义不变),只换 body;`Content-Type: image/webp` + 显式 `Content-Length`
(8 KB 超过 Go 的 2 KB 缓冲,不设会退回 chunked)+ `no-store`;HEAD 不写 body。
- 放在 `internal/route/assets/` 而不是 `static/`:static 会多出一个可公开访问的 URL。
- 换图方式:覆盖同目录 403.webp → 重新编译,无配置项。
- 体积代价:每封禁请求 8.6 KB(原 13 字节)。被封来源重试频繁时要留意带宽,
已压到 256×256;别塞几百 KB 原图。
### 关于「403 图放 R2 是否更好」的结论
结论:**保持 embed**。关键事实是「服务端代理/重定向并不省我们带宽」——
字节最终还是从本机发给被封客户端;想真省只能让客户端自己去 R2 取,
那就要放弃「403 直接是图片」,改成 403 + 一个 `<img src=外链>` 的 HTML。
而本项目 R2 目前只有预签名 URL(7 天过期),长期用必须开 r2.dev 或绑自定义域
(docs/theme-new-sakura.md 已记:直连 `*.r2.cloudflarestorage.com` 会 403)。
8.6 KB 的单张图不值得引入外部依赖;被 ban 的多是脚本,根本不会加载图片。
若动机只是「换图不想重编译」,加 `[security] ban_image_path` 磁盘覆盖更划算。
### 改为 ban_image_path(配置驱动,默认纯文字)
按用户要求撤掉 embed:`internal/route/assets/`(含 403.webp)已删除,
行为改为「配了合法图 → 回图;留空 / 读不到 / 不是图 / >2MB → 纯文字 403」。
- `[security] ban_image_path`(相对路径以进程工作目录为基准);
- `loadBanImage()` 在 `route.New()` 里启动时读一次 → `atomic.Pointer[banResponse]`,
热路径零磁盘 IO;任何失败只 Warn 不改启动(封禁响应是最后一道兜底,不能变 500);
- **只认内容不认扩展名**(`http.DetectContentType` 必须是 `image/*`):配成
app.ini/.env 会被拒绝,否则等于把配置文件吐给攻击者(变异验证过这条);
- 上限 2 MB(被封来源反复重试,大图放大出网带宽);`Content-Length` 显式给;
HEAD 不写 body;`no-store`。
- 实测 sniff:真 webp → `image/webp`;`app.sample.ini` → `text/plain`(被拒)。
**教训**:同一功能改了两版(先 embed 后配置化),第二版要把上一版的资源一起清掉,
否则留一个没人引用的 `assets` 包。`go vet ./...` 不报、测试也不报(该包仍被旧测试引用时)。
## /user/questions:筛选项「已读 是/否」+ 手机端标签行不折行
- 筛选栏第 5 列标签「阅读状态」四字,在 ~53px 的窄列里必折两行。
→ 词条 `qlist.readStatus` 删除,换 `qlist.readFilter`(已读/Read/既読/已讀)+
`qlist.filterYes`/`qlist.filterNo`(是/否)。**只改标签,option 的 value 仍是
read/unread**(后端 `readParam` 校验依赖它)。`qlist.all`(全部)保留作默认不筛选。
- 手机端「时间 + 状态标签 + 标记/置顶」挤一行:实测(360/390/375 iframe + dump-dom 量 rect)
一行只有 306–336px,完整时间占 127px,2 标签 + 2 按钮要 343px → 必折。
→ 时间改 `tb-short`=m-d H:i / `tb-text`=Y-m-d H:i:s(复用 pill 的 640px 断点);
→ `.qlist-pill` 基础值 min-width 40→34、padding 0 8→6(手机端省 20px),
桌面 `@media(min-width:640px)` 仍覆盖回 62px/10px。
结果:2 标签 + 2 按钮在 360 就单行;「私密+待答+未读」三标签仍会在 ≤375 折行(可接受)。
- 量手机宽度别用 `chrome --window-size=360`:Windows headless=old 有 ~500px 最小窗宽,
实测 innerWidth 恒为 500。用 360px 宽 iframe + `--allow-file-access-from-files`,
结果由 iframe 里 `window.top.document.title` 带回,`--dump-dom` 读。
### 手机端时间的年份:逐条补年,不做按年分组(DateCompact)
- `Template.FuncMap` 新增 `DateCompact`(内部 `dateCompact(t, now)`):同年 → `01-02 15:04`,
跨年 → `2006-01-02 15:04`。桌面端仍走 `tb-text` 的完整格式,不受影响。
- 用户问过「省了年要不要按年把问题隔开」——**不做**。理由是列表带游标分页(`*_cursor`)+ 可切
排序 + 4 个筛选 + 关键词搜索:组标题只按当前页算,翻页会重复出现同一年标题;换了排序
(最早→最新)或搜索时又会出现空组/错位。逐条补年份零结构成本、无耦合。
真要年份维度,顺序应是:①跨年补年(已做)②筛选栏加「年份」下拉(distinct year,走 SQL)。
- 实测(360 宽 306px):跨年格式占 109px(vs 同年 73px)。跨年 + 2 标签 + 2 按钮 = 309px,
差 3px 会在 360 宽折行;390 及以上的 2 标签组合都单行。预览数据里放了一条 2025 年条目用于核对。
## 全站数据页 /stats(替换首页统计卡)
- 首页底部「站点统计」三行小卡 → 换成一行摘要 + 入口按钮;完整数据在新公开页 `/stats`。
明细见 `topics/stats.md`。
- 新增:`internal/db/analytics.go`(聚合 SQL,方言收敛)、`internal/stats/overview.go`
(自然日缓存 + 补齐空日期)、`internal/stats/page.go`(i18n 标签 + 洞察文案)、
`route/stats.go`、`templates/stats.html`、`internal/template/chart.go`(折线坐标等 5 个函数)。
- 图表零依赖:折线 SVG,柱状/分布条用百分比高度,环形用 `pathLength=100` 的 dasharray,
热力图 CSS grid + 主色 opacity 分 4 档。
- 口径修正:环比改成「上月 vs 上上月」(本月残月比整月会在每月 1 号算出 -95%)。
- 补索引 `questions(created_at)`;启动后 3s 异步预热当天快照。
- 测试:`internal/db/analytics_test.go` 连真库验证(软删除不计入、分桶下标、DOW 对齐)、
`internal/stats/overview_test.go`(补齐日期、月底不溢出、除零)。全绿。
- 踩坑:预览页 base.css 用 `href="/static/..."` 绝对路径,file:// 下取不到 →
令牌全缺、图表全透明。`writePreview` 已改成同时重写 `href="/static/`。