首页底部那张「站点统计」卡片下线,完整数据移到公开只读页 /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 对齐
251 lines
18 KiB
Markdown
251 lines
18 KiB
Markdown
# 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/`。
|