Compare commits

...
113 Commits
Author SHA1 Message Date
tamakyi 5fdc3df22d 修复: 评论组件泄露私密 token、cwebp 无超时、选项长度与去重
Waline(安全)
- item.html 的 path 从 window.location.href 改为剔除 ?t= 后的 URL。
  私密题 /_/<domain>/<id>?t=<token> 的钥匙原本会整条发往 Waline 服务端
  并落进其访问日志。只删 t、保留其他参数,公开题的评论线程键不变。

cwebp(可靠性)
- ConvertGifToAnimatedWebP 改用 exec.CommandContext + 30s 上限。
  原来裸 exec.Command 无超时,畸形/超大 GIF 会把子进程和上传 goroutine 一起挂住。
  超时杀进程并记 Warn,调用方按既有逻辑回退为原 GIF,不影响上传成功。

投票选项长度(正确性)
- vote_options.content 由 varchar(200) 放宽到 300,与配置默认值一致。
  此前 201–300 字的选项通过校验后在写库阶段失败。
- 新增 db.MaxVoteOptionLen;voteOptionMaxLen() 取配置与存储上限的较小值,
  校验与前端 maxlength 同源,避免配置调大后再次越界。

多选 checkbox 去重(正确性)
- 抽出 appendUniqueValue,预置值与「其他」文本共用同一 seen 集合。
  此前重复提交同名字段会让同一选项在统计里被计多次。

测试
- vote_dedupe_test.go:去重与顺序。
- vote_option_len_test.go:配置小于/等于/大于存储上限三种情形。
- TestVoteOptionMaxLengthRoundTrip:300 个多字节字符存回不截断。
- TestVoteOptionColumnWidens:把列改回 200 后 AutoMigrate 能扩回 300(老库升级路径)。

验证:WSL2 Debian + PostgreSQL 全量 go test ./... 27 个包全绿;
隔离实例渲染单题页确认新的 path 逻辑。
2026-11-05 17:04:47 +08:00
tamakyi 7ffab24025 安全: 修复页面密码零值绕过,导出不再泄露投票者 IP
页面密码(P0,2026-11-05 复核发现)
- 旧实现 `ver, _ := Session.Get(key).(uint); if ver != user.PagePasswordVer`
  把「session 里没有授权记录」(断言失败得 0)与「新增列默认值 0」判成相等,
  升级瞬间所有存量页面密码静默失效:匿名访客不带 cookie 即可读箱主页。
- 收口为 pagePasswordAuthorized:缺 key 一律视为未授权,
  key 名由 pagePassKey 统一生成,Pager / ListAPI / AskStatusAPI 三处共用。
- 新增迁移 v6 回填 page_password_ver / ask_password_ver(仅仍为 0 且已设密码的行,
  幂等),让模型语义与「改过一次密码」一致,并作废升级前的布尔授权。
- AskStatusAPI 的 L4 查询补日志(保持与 Pager 一致的 fail-open 口径)。

导出(P1)
- JSON 导出的投票提交不再输出 voter_ip。库里照常保留,
  防刷、取证与站方 CLI 处置不受影响,只是不进箱主的自助导出文件。

测试
- route/question/page_password_test.go:8 个用例钉住零值/类型不符/轮换失效等边界。
- internal/db/page_password_migration_test.go:回填只碰该碰的行且幂等。

验证:WSL2 Debian + PostgreSQL 全量测试通过;隔离实例实测
ver=0 匿名访问返回密码页、API 401,输对密码后放行、改密后旧会话立即失效,
删除迁移账本后重启可正确回填。
2026-11-05 16:31:54 +08:00
tamakyi 25aee35030 安全与可靠性: 修复配额并发、投票事务、密码授权与导出完整性 2026-11-05 12:17:05 +08:00
tamakyi 7636456d4e 可靠性: PostgreSQL 无 Redis 时 session 不再误用 MySQL 存储 2026-11-05 12:16:46 +08:00
tamakyi 937e688cfa 可靠性: 完善 R2 备份恢复与对象生命周期 2026-11-05 08:41:55 +08:00
tamakyi 3e7719f200 安全: 修复凭据、遥测与账号数据清理风险 2026-11-04 14:38:19 +08:00
tamakyi c633789a92 fix footer 2026-11-02 17:17:06 +08:00
tamakyi ea9ba3e8fc 首页: 修复功能卡下方各卡片之间缺少间距
tb-grid 之后的三张独立卡片(域名迁移/一点提醒/近期更新)彼此相邻
却没有垂直间距,挤成一整块。用 .tb-grid ~ .uk-card 统一加 16px 上边距。
2026-10-30 17:56:21 +08:00
tamakyi 036156b6fb 蜜罐告警: 修复去重口径与空收件人短路
审阅蜜罐邮件提醒功能发现的问题,三项一并修:

1. autoban.alerted 从 map[string]bool(永不过期)改为带时间戳的
   map[string]time.Time,冷却期 24h 与蜜罐侧对齐。cleanup 里清理过期项,
   长期盯防同一来源不再漏掉后续攻击活动(此前一生只报一次)。
2. 蜜罐告警在 security_alert_email 为空时短路:route.go 注入 sendAlert
   前判空,收件人缺失置 nil,避免每次凭据提交白做 SMTP 拨号 + 刷 Warn。
3. 强化 security_alert_email 的配置警示(蜜罐邮件含明文密码,
   必须是个人邮箱、禁群组/分发列表)。
2026-10-30 17:17:25 +08:00
tamakyi 48eea98a27 docs(memory): 记录安全汇总批次的关键教训(ipinfo 维度差异 / 缓存毒化 / 地图合规) 2026-10-30 16:41:29 +08:00
tamakyi d690355cdc feat(honeypot): 蜜罐安全状况周期汇总(周报/月报 + 地图 + 邮件)
以周/月为单位汇总蜜罐命中,把来源 IP 通过公共服务查地理位置,画到地图上,
生成 HTML 报告并发邮件。同时补齐 ipinfo 无法回答「这个 IP 在哪儿」的问题。

新增 IP 地理定位层 internal/security/geoip:
- 与 internal/security/ipinfo 正交。ipinfo 走 cdncheck 离线指纹,只认云厂商/CDN/WAF,
  答的是「是不是代理」;肉鸡(家宽、企业宽带、移动网络)必然被判为
  「未识别(普通网络)」,所以「在哪儿」必须另建一套。
- ip-api.com 批量打底(100 个/请求、1.4s 节流)+ 腾讯位置服务精修国内
  (一 IP 一请求,只精修命中数最高的前 N 个,受 ip_geo_tencent_key 控制)。
- Result{Geo, Unresolved, Requests, Failures} 三态。服务明确回答「没有这个地址」
  才允许写失败缓存;请求超时/限流/5xx 一律不写 —— 否则一次抖动会把整批 IP
  钉成「查不到」三天,此后每份报告的定位覆盖率都莫名变成 0。
- addrs 按调用方给的顺序构建(不是 map 遍历):腾讯精修只取前 N 个,乱序会让
  每次运行精修到不同 IP,配额花在长尾上。

报告生成 internal/security/report:
- period:PreviousWeek / PreviousMonth,窗口一律 [start, end) 左闭右开,
  相邻两期不重叠也不漏边界整点;period_key(week:2026-W44)做幂等。
- aggregate:按 shared.LimitKey 合并(IPv4 精确 / IPv6 折 /64);国家、城市、
  路径、每日趋势;显式统计未定位命中数(覆盖率 60% 的图看起来和 100% 一样)。
- mapdraw:自绘等距圆柱投影气泡图。零海岸线零国界 —— 国界/台湾/南海诸岛绘制
  必须符合国家标准,自绘极易画错,真实底图交给腾讯。经纬网 + 度数刻度 +
  标签防重叠(同一都市圈两城市坐标差 0.01°,曾把「AU 55」和「AU 30」叠成
  看起来像 AU 305 的假数字)。
- html:自包含报告页,内嵌 SVG + 腾讯交互地图(无 key 时给申请指引,
  代码里不预置任何 key)。
- countryname:港澳台统一显示「中国香港/中国澳门/中国台湾」,常见国家用中文名。

存储与调度:
- ip_geo_cache(主键 = LimitKey,成功 TTL 90 天 / 明确无数据 TTL 3 天)
- security_report_logs(period_key 唯一索引 → 重启、错过时间窗都能正确补发,
  失败重试上限 5 次后停止)
- honeypot report --period week|month [--to] [--dir] [--no-email]
- web 启动时按配置挂 30 分钟一轮的调度器(与保留期清理共用停止信号,
  defer 移出 if 外,避免「清理关掉、报告开着」时 goroutine 泄漏)

配置([security]):ip_geo_provider=auto|ipapi|tencent|none、ip_geo_tencent_key、
honeypot_report_enabled / _weekly / _monthly / _email / _dir。

顺带修复:
- route/honeypot_test.go 的 captureStore 缺 AggregateSources/AggregatePaths/
  TimestampsBetween,整个 route 包编译不过。
- templates/render_test.go 的 mockLoggedUser 缺 TelegramChatID,3 个渲染测试失败。
2026-10-30 16:40:44 +08:00
tamakyi 08bbd17751 storage: 对齐图片对象 key 到生产口径
生产真实 key 为 `YYYY/MM/<UnixNano>.<ext>`(无前缀、无 day 层),
与 route/question/page.go:uploadImage 一致;internal/storage 里三个
上传函数仍用旧的 `picture/ 前缀 + YYYY/MM/DD + 15hex + 无扩展名`,从未上线。

- PictureKeyPrefix 置空,统一由 uploadKey 生成 key
- 扩展名跟随最终 ContentType(webp 转成功用 .webp,回退原图用原格式)
- OSS 原图直传通道取原文件名扩展名
- 新增 picture_test.go 锁定 key 格式,防回归
2026-10-29 20:04:31 +08:00
tamakyi c6966138b4 docs(memory): 记录迁移系统/备份脚本/通知的实现与 R2 存储结构 2026-10-29 19:27:29 +08:00
tamakyi b9060c982b feat(notify): 新增站级 Webhook 与用户级 Telegram 通知通道
在现有邮件通知之外补两条广播/点对点通道:

- 站级 Webhook([notify] webhook_url):新提问 / 新回答 / 新追问 / 新私聊 /
  新投票回复统一 POST JSON 到通用 Webhook(Discord / Slack / 企业微信机器人等)。
  消息体同时带 content(Discord 直接可用)与结构化 event/title/body/link 字段。
- 用户级 Telegram([notify] telegram_bot_token + users.telegram_chat_id):箱主
  自填 Chat ID,新提问 / 新投票回复点对点推送。

设计约束:通知是「尽力而为」旁路 —— 任一通道发送失败只记日志、不向上抛错,
绝不影响提问/回答的主流程;通道未配置时静默跳过。发送放在 shared.SafeGo 异步
执行,与现有邮件通知同一条路径。

6 处业务触发点接入 notify;私聊消息保留仅邮件(已有 5 分钟节流,避免广播刷屏)。
profile 页新增 Telegram Chat ID 输入框 + 四语言字典。app.sample.ini 加 [notify] 段。

测试覆盖 webhook 的 JSON 结构与未配置时的静默跳过。
2026-10-29 19:27:23 +08:00
tamakyi 1e9ffc0718 feat(ops): 新增 TamaBox backup 命令,同时备份数据库与 R2 图片
此前没有任何备份手段,而站点的图片全在 Cloudflare R2、数据库只存 key/URL ——
只备份 DB 的话,恢复后 key 指向的图片若已不在 R2(bucket 误删 / 账号丢失 /
图片被清理),页面就全是裂图。

新增 TamaBox backup --output <dir>:
- 数据库:pg_dump 全量导出(schema + data,标准恢复格式),密码经 PGPASSWORD
  环境变量传递避免出现在 ps 进程参数里
- 图片:从 R2 下载 tamabox 站全部图片,保持原 key 路径,并写 images-manifest.json
  (key → size → md5)用于恢复后完整性校验
- --db-only / --images-only 分开关

R2 访问方式的坑:storage.GetS3Client 的 endpoint 带 /<bucket> 后缀,对
GetObject/PutObject 恰好可用,但 ListObjectsV2 会把 URL 拼成 /<bucket>/<bucket>
而 404 NoSuchKey。备份脚本独立构造客户端(剥离 endpoint 的 bucket 后缀 +
UsePathStyle),List prefix="tamabox/" 后用完整 key 下载 —— 这是 R2 上 List
唯一稳定可用的姿势。

实测:db-only 导出 46KB(21 表全含 schema_migrations);images-only 下载的文件
大小与 R2 List 返回完全一致,路径保持 tamabox/ 结构。
2026-10-29 19:27:13 +08:00
tamakyi 62edf88029 feat(db): 版本化数据库迁移,改列名/删列不再静默丢数据
GORM 的 AutoMigrate 只会加表/加列/加索引,从不改列名/删列/改约束。此前这类
破坏性变更靠「HasColumn 判断 + 幂等补丁」硬撑,既没有账本记录「跑过什么」,
也把 5 个一次性回填(backfillAnsweredAt / backfillFeatureControls /
reconcileLegacyChatEnabled / normalizeChatQuota / normalizeHarassmentSetting)
留成了每次启动都全表扫一遍。

新增 schema_migrations 账本表 + runMigrations:
- 迁移按版本号升序执行,每个迁移在事务里「跑 up + 记版本」,任一失败阻断启动
- 那 5 个幂等补丁收编为 v1~v5,收编后只跑一次,之后启动直接跳过
- AutoMigrate 仍负责声明式的加表/加列/加索引,版本迁移负责它做不了的破坏性变更
  与一次性回填,两者分工互不替代

测试覆盖:幂等(跑两遍不重复)、失败阻断后续、清单严格升序。实测真实库首次
启动建账本 + v1~v5 落库,二次重启账本不变。
2026-10-29 19:27:03 +08:00
tamakyi 693b1102c2 fix: 封堵 ask-status domain 枚举、私聊写路径 fail-open、压缩兜底重复提交
三处小修 + 两个格式清理,均来自全盘复查:

1. ask-status domain 枚举:ErrUserNotExists 分支原返回空数组、正常分支返回带元素
   数组,凭空/非空可枚举 domain 是否存在(含被 L1 隐藏或设了页面密码的半公开箱)。
   改为箱主不存在时也回显 ok:false,四种失败响应完全同形。

2. 私聊 SendMessage 写路径用 FeatureStateOf(fail-open 变体):读配置失败会回退默认值,
   与「写路径必须 FeatureStateOfStrict(失败即拒)」的约定不符。此前靠 chat 默认关闭
   的巧合侥幸安全,现统一改 Strict。

3. 图片压缩 submit 兜底可重复提交:capture 监听器里 tbBeginSubmit 从 Promise 回调提前
   到 preventDefault 同步栈,物理阻止压缩窗口内双击重复提交。

顺带:box_views.go / message_image.go / insights 两个文件补末尾换行与 struct 字段对齐
(gofmt 干净)。
2026-10-29 18:52:38 +08:00
tamakyi c9b426acbb fix(insights): 数据面板「每日回答数」按 answered_at 分组,而非 created_at
BoxTrend 的回答那条 SQL 复用了 d.day(基于 created_at 的日期表达式)做 GROUP BY,
但 WHERE 过滤用的是 answered_at。于是「40 天前创建、昨天才回答」的提问被排除在
窗口外(created_at < from),昨天的回答数里不出现;「今天创建、明天回答」的又
被记到今天。趋势图里的「每日回答数」根本不是「某天被回答了多少」。

单独构造 answerDay(基于 COALESCE(answered_at, updated_at)),WHERE / GROUP BY /
SELECT 三者口径一致。PG 用 to_char(... AT TIME ZONE INTERVAL ...),MySQL 用
CONVERT_TZ,与 analyticsDialectFor 对 created_at 的处理对称。

新增 box_analytics_test.go:构造「40 天前创建、昨天回答」的提问,断言计入昨天,
并做变异验证(改回 d.day → 测试失败)。
2026-10-29 18:52:29 +08:00
tamakyi 87176516f3 fix(data): 删除提问时追问与图片一并真删,不再软删残留
questions.DeleteByID 对主表用 Unscoped 真删,但级联清理的两个子 store 仍用软删:
FollowUp(内嵌 dbutil.Model)与 UploadImage(内嵌 gorm.Model)都带 DeletedAt,
裸 Delete 是软删。于是提问正文(含 FromIP、邮箱)删干净了,追问文本与图片元数据
(name/md5/key)却带着 deleted_at 永久残留,孤儿行的 question_id 指向已不存在的提问
—— 正是「真删以免个人数据删不干净」想消灭的问题,却在子表漏改。

两个 DeleteByQuestionID 改为 Unscoped 真删。关联表 UploadImageQuestion 无 DeletedAt,
本来就真删,无需 Unscoped(已注释)。

新增 question_delete_hard_test.go:用 Unscoped 计数断言删除后追问/图片/关联行物理上=0,
并做变异验证(改回软删 → 测试失败)证明测试真的能抓住这个回归。
2026-10-29 18:52:19 +08:00
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
tamakyi a3cf99a8da fix(stats): 环比句点明月份与条数,不再写「上月…环比 -75%」
「上月提问量环比 -75.0%」这句要读者做两次翻译:先把「上月」翻成具体月份,
再把「环比」翻成「比上上月」。两个相对词叠在一起,站长本人看到就以为
是「上月同期 vs 本月同期」—— 而它其实比的是两个完整自然月。

  改前:上月提问量环比 -75.0%。
  改后:9 月收到 5,620 条提问,比 8 月的 4,890 条多了 14.9%。

口径一个字没改(仍是上月 vs 上上月,本月没过完不参与),只是把月份和条数
写进句子:读者不需要知道「环比」的定义也能判断这句话在说什么,还能顺手
核对百分比(5620/4890 确实是 +14.9%)。

实现:
- `monthText` 按语言出月份名 —— CJK 一律「9 月」,英文要 "September"
  ("month 9" 不是英语的说法)。不带年份:相比的两个月相邻。
- 一个词条拆成 momUp / momDown / momFlat:方向词塞不进同一个模板,
  四种语言里「多了 / 少了」的位置和词形都不一样。
- `|pct| < 0.05` 走持平,否则会出现「多了 0.0%」。

测试:`TestInsightMoMNamesBothMonths` 锁住「句子里同时有两个月份名和两个条数」,
覆盖上升/下降/持平;`TestMonthText` 锁语言差异。

顺手修预览数据:`zz_preview_test.go` 的 `GrowthMoM` 原来写死 12.3,和它造的
monthly 对不上。以前句子里只有百分比无所谓,现在会连条数一起印出来,
截图就成了「5,620 比 4,890 多了 12.3%」这种假证据,改成从数据算。
2026-10-25 09:27:57 +08:00
tamakyi 79199ac3cd fix(stats): 删掉「没有推测」这类对站长说的话,文案统一以访客为读者
「数据速览」下面那句「下面几句是从上面的数字直接读出来的,没有推测」,
是在向读者解释「我没有编」—— 那是作者视角的免责,不是访客需要的信息。
读者看到「没有推测」只会想:那我该怀疑什么?删掉,卡片留标题 + 结论列表
即可(`stats.insight.title` 已经说明这组是什么)。

顺着把整页 40 余条文案过了一遍,同类的还有页脚:

  「累计数字与图表是当天重算一次的快照(当天第一次访问时算,服务重启也会重算)」
  → 「累计数字与图表每天更新一次」

访客不需要知道缓存是懒重算、更不需要知道重启会触发重算;真要精确的新鲜度,
上一行「快照时间」已经给了到秒的值。这条注的职责只是说清「累计按天、今日实时」。

保留的两处,判断过不属于这个问题:
- `stats.foot.privacy` 与顶部 `stats.lead` 后半句近乎同义 —— 页脚隐私声明是惯例,
  重复一次不算噪音,删了反而有人以为底部没声明。
- 「(实时)」在 60 秒缓存下仍成立。

删掉说明行后标题与列表之间只剩 4px,给 `.tb-insights` 补 `margin: 8px 0 0`
(原来靠 `tb-card-note` 的 16px 撑开)。顺手修掉模板里一条过期注释 ——
它还写着「四条时间」「哪个是基准日」,上一轮已改成三行「快照时间」。

i18n 四语言同步(有词条数量对等测试兜底);build + i18n/stats/template/templates
测试全绿,中英页脚与数据速览卡片截图确认过间距。
2026-10-25 09:18:00 +08:00
tamakyi af39651734 fix(stats): 页脚时间标签改成「快照时间」,去掉重复的「数据基准日」
原来四行时间里,前两行说的是同一件事:
「数据基准日:2026-10-24」+「计算于:2026-10-24 14:05:22」。
`DataDate` 就是 `UpdatedAt` 的日期部分(同一个 now 格式化出来的),
两行并列只是把同一个值拆成粗/细两种粒度。

而且「计算于」没有主语:读者站在页脚最底部看到它,会当成「整页数据
算到这一刻」,可今日两项恰恰不在这份快照里 —— 命中缓存时
`withToday` 会用更新的读数替换它们,页面上就出现「标注 14:05、
今日提问却是 22:35 的数」。

改成三行:
  快照时间:     2026-10-24 14:05:22
  最后一条提问: ...
  最后一条回答: ...
「快照只含累计数字与图表、今日两项实时」由下面那条 cacheNote 说明,
不塞进标签里(标签越短越好扫)。词条同步:快照时间 / 快照時間 /
Snapshot: / 集計時刻。

顺带删掉 `Overview.DataDate` 字段:模板不用它之后就没有读者了,
留着它会诱使以后的人再拼出「基准日 + 计算于」这种重复行。

无 schema/接口变更,纯展示层。i18n 有词条数量对等测试,四个语言已
一起改;`go build ./...` 与 i18n / stats / template / templates /
route 测试全绿。
2026-10-24 22:39:47 +08:00
tamakyi 219adefc95 fix(stats): 「今日回答」改按 answered_at 计,不再把置顶算成回答
`TodayCounts` 的今日回答数用 `updated_at >= 0 点` 过滤。GORM 的 Update
一律刷新 updated_at,于是箱主今晚整理一批三个月前的老问题(置顶、
公开/私密切换,一次回答都没发生),/stats 顶部「今日回答」就凭空多出几十条。

实测(临时测试,跑完已删):一条三天前回答的提问,依次 SetPinned /
SetPrivate / SetPublic 之后,today answers 从 1 变成 2。

改成 `answered_at >= since`(首次回答时间,改写回答不重置):
口径变成「今天首次被回答的提问数」,与页面文案「今日回答 / Answered
today」和「回答耗时按首次回答算」都对齐。历史行 answered_at 为空时
`>=` 不成立、自然不计数 —— 那些行本来也不可能「今天被回答」。

性能:answered_at 没有索引,这条 COUNT 是全表过滤;但改动前的
updated_at 同样没有索引,本来就全是 Seq Scan。20 万行实测
旧 36.7ms / 新 38.0ms(差 3.5%,噪声级),执行计划都是
Seq Scan + Aggregate。页面另有 60 秒缓存,不需要为它加索引;
注释里写了真慢了再加 idx_questions_answered_at。

新增 TestTodayAnswersIgnoreNonAnswerWrites(改前红:期望 1 得到 2;
改后绿)。
2026-10-24 10:37:36 +08:00
tamakyi 8380fc9e2b fix(db): 「最后一个回答」不再被置顶/公开切换推到现在
`QuestionStats` 的 `last_a` 取的是 `MAX(updated_at)`,而 GORM 的 Update
一律刷新 updated_at:置顶、公开/私密切换、标记已读都会走 Update。
后果是箱主整理一下旧问题(一次回答都没发生),公开接口
`/api/v1/site-stats` 就说「刚刚有人回答了」。

实测(真库,临时测试已删):一条 9 天前回答的提问,置顶/公开切换后
LastAnswerAt 从 `10-15 10:23` 变成 `10-24 10:23`,偏差 216 小时。

改成 `COALESCE(answered_at, updated_at)`:
- 新数据取 answered_at(首次回答时间,改写回答不重置);
- 加列之前的历史行 answered_at 为空,退回 updated_at —— 与改动前口径
  一致,不会让这些行从统计里消失(有测试锁住这一半)。

同时删掉 `GetHotQuestions`:全仓零调用点(只有接口声明 + 实现,route/
里 Questions 的 15 个调用不含它),且同样按 updated_at 排序 ——
死代码 + 错误排序,留着就是给以后的人埋坑,要用时按 answered_at 重写。

影响面:`/api/v1/site-stats` 的 `last_answer_at` 字段与 `text` 里的
「最后一个回答发生在」,字段名与结构不变,只是值更准(更早)。无 schema
变更、无接口变更。性能:执行计划仍是 Seq Scan + Aggregate,5 万行实测
7.9ms vs 8.0ms,差异在噪声带内(0~1.8ms,折合每行约 30ns),
而该接口有 30 分钟进程内缓存。

新增两个回归测试:
- TestQuestionStatsLastAnswerIgnoresLaterEdits(改前红、改后绿)
- TestQuestionStatsLastAnswerFallsBackToUpdatedAt(历史行回退)
2026-10-24 10:30:43 +08:00
tamakyi cbb8908275 feat(stats): 近 30 天折线补纵轴刻度
原来这条折线只有横轴日期,纵轴没有任何参照:读者看到峰谷不知道自己
在看 3 次还是 300 次的起伏。现在补上三条刻度(上限 / 一半 / 0)加对应
的横向虚线。

纵轴上限取 niceMax(峰值),不是峰值本身。峰值正好压在顶格里时,读者
分不清那到底是确切的数还是被裁掉的数;向上取整到 1/2/1.5/3/4/6/8×10^n
之后顶部留白固定,三条刻度又都是整数和好读的数(1.5 倍只在量级≥100
时参与,否则算出来是 7.5 这种半格)。上限恒为偶数,中间刻度必然是整数。

刻度文字不放进 SVG:viewBox 用 preserveAspectRatio="none" 横向拉伸填满
卡片,里面的文字会跟着被拉扁,窄屏尤其明显。改成左边一列 HTML 标签 +
横向虚线仍在 SVG 里(描边锁 non-scaling-stroke)。列宽靠一份看不见的
「最宽刻度」占位撑开,不写死 em。

同时把 Polyline/AreaPath 收敛成单个 NewLineChart:坐标、面积、刻度必须
共用同一个上限,分两次算就会各按自己看到的峰值归一化,出现刻度和线错开
的图。面积路径改为沿基线闭合,不再漏进轴标签的留白区。

新增 chart_test.go 锁住三条不变式:中间刻度必为整数、上限不离开峰值一倍、
刻度/折线/面积共用上限;另有 0..2000 的穷举检查。
2026-10-24 01:57:36 +08:00
tamakyi 0226956feb fix(stats): 回答耗时改用 answered_at,修测试环境实测出的三处问题
在测试环境(box-dev-api)上逐项验算后发现「回答耗时」这一口径是错的。

1. 回答耗时不能用 updated_at - created_at(P0)
   GORM 的 Update 会自动刷新 updated_at,而已读、置顶、公开/私密切换全走 Update。
   实测:一条 10 天前的提问被 SetRead 标记已读后,updated_at 被推到当下(delta=240h)。
   于是「回答耗时」实际测的是「提问到最后一次被碰」:线上那 6 条「超过 7 天」
   和偏低的 24 小时回复占比都是这么来的。
   修法:新增 questions.answered_at(首次回答时间,可空),只在 answer 由空变非空时
   写一次,改写回答不重置;聚合改用 COALESCE(answered_at, updated_at),
   首次加列时用 updated_at 冻结历史一次(Migrator().HasColumn 判断是否刚加列),
   否则历史值会随着后续的已读/置顶继续变大。

2. 环比句式读不通
   中文「上月提问量比前一个月 -69.6%」、英文「...: -69.6% questions.」都是病句。
   改为「上月提问量环比 %s%%」/「Month-over-month change for last month: %s%%」。

3. 脚注说「当日 0 点的快照」不实
   实际是当天第一次访问时算、服务重启也会重算(实测算出时刻 01:11:50,
   基准日却写 2026-10-24,说明它含了 0 点之后的数据)。改成准确描述。

4. 数据库故障时请求会排成 N×30s
   ovRefresh 串行化 + 30 秒超时意味着数据库挂起时第 N 个请求要等 N×30 秒。
   加 30 秒失败冷却:冷却期内直接降级(有旧快照用旧快照),不再碰数据库。
   todayNumbers 失败也盖时间戳,不再每个请求都打两条 COUNT。

测试:新增 internal/db/answered_at_test.go(5 例:只记一次、私密路径也记、
分桶以 answered_at 为准、历史行回退、回填),并做过变异验证 —— 把口径改回
updated_at 后测试立即失败。internal/stats 补 3 例冷却测试。全量构建与测试通过
(internal/storage 的构建失败是 overlay 桩的既有问题)。
2026-10-24 01:45:34 +08:00
tamakyi 0c282914c2 fix(stats): 今日计数不再被每日缓存冻结,日期分组不依赖库会话时区
自查部署结果时发现两处正确性问题,都是本页上线时引入的:

1. 「今日提问 / 今日回答」原本随整页聚合一整天缓存,页面却写着「实时」,
   实际当天第一次访问后数字就冻住到次日。现拆出 TodayCounts 单独取,
   60 秒 TTL、跨日按日期失效;命中整页缓存时返回副本,不原地改缓存指针
   (那会被并发读踩成数据竞争)。

2. 日/月/时分组用 to_char/EXTRACT 直接作用在 timestamptz 上,结果跟着数据库
   会话时区走,而 PG 官方镜像默认 UTC、Go 侧按容器时区(Asia/Shanghai)切今天。
   实测同一条 2026-10-24 01:30+08 的提问,UTC 会话下被算成 10-23 17 点 ——
   日报表整体偏 8 小时、热力图时段错位,且与「今日」对不上。
   现统一经 analyticsLocalize() 加 AT TIME ZONE INTERVAL 换算,偏移取自
   time.Time.Zone()(Go 未设 TZ 时给 "Local",PG 不认该名,故不用 IANA 名)。
   过滤条件仍是裸列,索引照用。

另外两处顺手修:

- PeakDay 用 >= 比较,并列时会漂到最近的同值日期,与 busiestSlot 的取值规则不一致。
- 聚合改用 context.WithoutCancel + 30s 超时:第一个访客在聚合途中关掉页面时,
  排在后面等锁的请求不会各自重跑一遍全表 GROUP BY。

测试:新增 TestTodayNumbersNotFrozen(缓存命中后今日数仍更新、整页聚合当天只算一次)
与 TestTodayCacheExpiresAcrossMidnight;analytics 连真库通过。
2026-10-24 01:20:02 +08:00
tamakyi 64436d9d61 fix(stats): 数据速览用完整星期名,脚注时间改逐行对齐
- 「提问最集中在 三 19 点前后」:洞察复用了热力图列头的短标签
  stats.wd.*(一…日 / Mon…),那是为窄列头设计的单字词条,
  进句子就成了「三」。新增 stats.weekday.1..7 完整名(周一 / Monday /
  月曜日),洞察改用该组,stats.wd.* 只留给列头;补测试断言锁住。
- 页脚入口文案「数据」→「统计数据」(四语言同步)。
- 页面脚注四条时间由「基准日 x · 计算于 y」一行改为两列网格逐行排列,
  标签统一带冒号,下面两段说明保持独立段落。
2026-10-24 01:08:50 +08:00
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
tamakyi c680671b7e feat(user/questions): 手机端时间跨年自动补年份,避免省略年份后产生歧义
DateCompact:与当前同一年的渲染「月-日 时:分」,跨年补回完整「年-月-日 时:分」。
只砍年份会让「去年今天」和「今年今天」显示成同一串,逐条补年份比按年分组便宜得多
(列表带游标分页 + 可切排序/筛选,组标题会跨页重复或错位)。
桌面端不受影响,仍显示 Y-m-d H:i:s。
测试覆盖年份边界(同年 1/1 与 12/31、跨年);预览数据里放了一条 2025 年的提问用于肉眼核对。
2026-10-23 21:04:50 +08:00
tamakyi 812b70cb4c fix(user/questions): 筛选标签改为「已读 是/否」,手机端条目标签行不再折行
- 筛选栏第 5 列标签「阅读状态」四字,在手机约 53px 的窄列里必然折成两行,
  改为「已读」,选项改为 全部/是/否(option value 仍是 read/unread,后端不变)。
  词条 qlist.readStatus 由 qlist.readFilter + qlist.filterYes/filterNo 取代,四语言同步。
- 条目行「时间 + 状态标签 + 标记/置顶」在手机上放不下:实测 360 宽可用 306px,
  完整时间单条就占 127px,2 标签 + 2 按钮需 343px。手机端时间改为「月-日 时:分」
  (tb-short/tb-text 复用状态标签的 640px 断点,桌面端仍显示完整年月日时分秒),
  同时把 .qlist-pill 的基础内边距 40/8 收到 34/6(桌面 @media 仍覆盖回 62/10)。
  结果:2 标签 + 2 按钮在 360 宽即单行;三标签(私密+待答+未读)在 ≤375 仍会折行。
2026-10-23 21:02:37 +08:00
tamakyi 86ea96e5ca feat(autoban): 封禁响应图改为 ban_image_path 配置,默认仍是纯文字
上一版把 403 图 //go:embed 进了二进制。实际部署里图会集中放在 R2 统一管理、
由运维分发到各机器,进仓库反而成了负担:换一张图就要重新编译。

改为配置驱动:
- 新增 [security] ban_image_path(相对路径以进程工作目录为基准);
- 留空 / 文件读不到 / 是目录 / 不是图片 / 超过 2 MB → 一律回退为纯文字
  "403 Forbidden",即改造前的老行为。封禁响应是最后一道兜底,不能因为
  一张图缺失就变成 500,更不能把站点拦在启动阶段(失败只 Warn);
- 启动时读一次装进 atomic.Pointer,热路径零磁盘 IO;换图 = 换文件 + 重启。

安全细节:按内容嗅探(http.DetectContentType)而不是扩展名,必须是 image/*。
把 ban_image_path 配成 app.ini / .env 会被拒绝并告警 —— 否则等于把配置文件
原样吐给每一个被封的攻击者。这条有专门的用例与变异验证。

顺带删掉 internal/route/assets(webp + embed),已无引用。

测试:
- TestWriteBannedTextFallback:未配图 → 403 + text/plain + 13 字节明文;
- TestWriteBannedServesConfiguredImage:配了 1×1 PNG → image/png(按内容嗅探)、
  Content-Length 与文件一致、body 与文件逐字节相同;
- TestLoadBanImageRejectsBadInput:不存在 / 是目录 / 非图片 / 空文件四种配置错误
  都不得装载,且**不得把文件内容当响应体吐出去**;
- TestLoadBanImageTooLarge:超 2 MB 拒绝;
- TestWriteBannedHead:两条分支的 HEAD 都只回头不给 body。
变异验证:去掉 image/* 校验 → 用例报「把文件内容当响应体吐了出去:
"[server]\nsalt = secret"」;强制走纯文字分支 → 配图用例按预期变红。

实测:真 webp 嗅探为 image/webp;app.sample.ini 嗅探为 text/plain 被拒。
2026-10-23 15:25:07 +08:00
tamakyi 6fad0cbe50 docs: 说明封禁响应的图片体与替换方式 2026-10-23 15:04:14 +08:00
tamakyi 0318ba81b4 feat(autoban): 封禁响应体改成一张 403 图片
被封禁来源访问任何路径时,原来只回 13 字节的 "403 Forbidden":既没有信息量,
人肉访问(尤其是共享出口被连累的误封)时看着像站点挂了;扫描/爆破脚本也常靠
读这行明文确认「自己已被拦」。

改为返回一张 256×256 的 webp(约 8.6 KB,画面写着 403 Forbidden),状态码不变:
- 图片用 //go:embed 编进二进制,不依赖外部 URL、不占公开路径;
- 显式给 Content-Length(8 KB 超过 Go 的 2 KB 写缓冲,不设会退回 chunked);
- Cache-Control: no-store —— 中间层缓存 403 没有意义,还会让解封后的响应继续命中;
- HEAD 只回头不写 body;
- 放在 internal/route/assets/ 而不是 static/:static 下的东西有可公开访问的 URL,
  等于多一个指纹入口。

注意链接:给的 R2 预签名地址 X-Amz-Expires=604800(7 天),热链到期就会挂,
所以是把字节取回来打包,而不是重定向或代理。

测试:
- TestWriteBanned:状态码 403、image/webp、Content-Length 与内嵌字节数一致、
  body 前 12 字节是 RIFF/WEBP(防止被换成 HTML 或明文这类回归);
- TestWriteBannedHead:HEAD 无 body 但有 Content-Length;
- 原 TestHoneypotBansEvenWhenAutoBanDisabled 里断言 "403 Forbidden" 明文的用例
  改为断言 image/webp 与体积。
变异验证:把 writeBanned 改回写明文 + text/plain,两条用例均按预期变红。
2026-10-23 15:04:13 +08:00
tamakyi 9d3f5a72fa docs: 补 XML-RPC 端点行为、动态 token 与 .php 变体说明,记实现取舍 2026-10-23 12:58:49 +08:00
tamakyi 307bd94ed1 feat(honeypot): /xmlrpc.php 走真实 XML-RPC,动态 token 不再恒定
线上验收发现两个「能被指纹识别」的破绽,都不影响原有功能但削弱伪装:

一、/xmlrpc.php 回的是 HTML 登录页(P1)
我们自己发的响应头里就有 X-Pingback: /xmlrpc.php —— 扫描器顺着跟过来,
一看是 HTML 就知道整站是假的(真实 WordPress 在这里回 text/xml)。
更可惜的是 system.multicall 这条通道被浪费:一次请求能带上千组用户名/密码、
且绕开登录页限速插件,是 WP 爆破里产出最高的入口。

新增 xmlrpc.go:
- 非 POST → 405 + fault「XML-RPC services are disabled on this site.」(与真实 WP 一致)
- POST methodCall → 200 + fault「Incorrect username or password.」(不区分用户是否存在)
- POST system.listMethods → 200 + 方法列表(枚举类工具靠它确认对面是真 WP)
- 凭据提取刻意不上 encoding/xml(蜜罐被高频命中):抓所有 <string>、剔除
  methodName 与站点 URL 后两两配对。wp.getUsersBlogs 的 blog_id 若是短数字会让
  配对错位一位,故「奇数长度 + 首项短数字」时丢首项;另外原始串全量写进
  fields(xmlrpc.0/1/…),配对失败也不丢原文。
  multicall 的第一组进标准字段,其余进 cred2/cred3… —— 一个用户名配一长串
  密码是常见字典形态,只留第一组等于丢掉整份字典。
- 封禁与表单提交同规则:带凭据走 RecordHoneypot(立即封),否则走常规阈值。

二、进入页面的隐藏值每次请求都一样(P2)
两次请求拿到同一个 pma token / csrf_token / X-Jenkins-Session,任何脚本都能
判定对面是伪造的。改为按请求生成 32 位 hex(math/rand/v2 —— 这些是假 token,
不需要不可预测,只需要每次不同;crypto/rand 在 Windows 要走系统调用,贵一个量级),
页头、hidden 与 Set-Cookie 共用同一套。

顺带:
- X-Pingback 改绝对地址($base/xmlrpc.php)。真实 WP 给绝对 URL,相对路径本身
  就是破绽;没配 external_url 时干脆不发这个头。
- Cache-Control 从 no-store 改 no-cache, must-revalidate, max-age=0
  (no-store 更像安全设备/代理加的,不像登录页)。
- Grafana / GitLab 补上真实产品会种的会话 cookie(grafana_sess、_gitlab_session)。
- Match 增加 .php 变体兜底:清单里是 /phpinfo,扫描器打 /phpinfo.php,
  原来漏掉 → 302 回首页,等于明确告诉对方「这个文件不存在」。

测试:xmlrpc_test(IsXMLRPCPath / getUsersBlogs / multicall / 数字 blog_id /
非 XML-RPC body / fault 与 listMethods 输出)、family_test(动态 token 两次不同且
是合法 hex、X-Jenkins-Session 与 correlation_id 动态、Pingback 绝对地址与缺省不发、
所有家族渲染后不残留 $ 占位符)、route 层(POST multicall 捕获凭据 + 立即封 +
回 text/xml;GET 回 405 fault 而非登录页)、honeypot(.php 变体命中且不牵连业务路径)。
四处变异验证均按预期变红:固定 token、xmlrpc 分支失效、Pingback 退化成相对路径、
.php 兜底失效。

性能:RenderFamily 3.7µs→4.18µs、2→3 allocs(多的一次是 hex 编码),
AssetFor 仍 0 分配。
2026-10-23 12:58:48 +08:00
tamakyi 7225f680d4 docs(memory): 记录蜜罐家族伪装的实现取舍与预览工具的用途 2026-10-23 12:38:43 +08:00
tamakyi db97ac13ed docs: 蜜罐文档补家族伪装与资源开销
honeypot.md:
- 开篇从「一个假 WordPress 后台」改为「按扫描目标伪装成对应后台」,列出 9 个家族
  与各自触发路径;
- §1 补静态资源的处理(回占位内容、不落库、插件 .php 回 404 的理由);
- §3 封禁路径表补一行「Tomcat Basic 认证」的归属,说明 401 也走凭据通道;
- 补资源开销的实际数字,便于运维判断这功能值不值。

cli.md:paths 说明补家族汇总。
2026-10-23 12:38:42 +08:00
tamakyi 811578dcf1 feat(honeypot): 按扫描目标伪装成 9 类后台,静态资源不落库
原来所有诱饵路径都回同一个 WordPress 登录页。爆破工具的标准流程是
「先按路径判断这是什么后台,再拿它认识的字段名填表」——在 /phpmyadmin/index.php
上回 WP 表单,等于告诉对方「这里没有 PMA」,对方直接换目标,既拿不到凭据
也没有取证价值。

新增 family.go(皮肤表)+ asset.go(静态资源占位),page.go 改为按家族渲染:

  wordpress   /wp-login.php /wp-admin /wp-json    log/pwd、wordpress_test_cookie
  phpmyadmin  /phpmyadmin /pma /adminer.php       pma_username/pma_password、hidden token
  tomcat      /manager/html /host-manager         401 + WWW-Authenticate: Basic(无表单)
  jenkins     /jenkins /jnlpJars                  j_username/j_password、X-Jenkins
  grafana     /grafana                            user/password、深色主题
  gitlab      /users/sign_in                      user[login]/user[password]
  luci        /cgi-bin/luci /luci                 luci_username/luci_password
  joomla      /administrator                      username/passwd、hidden option=com_login
  generic     /admin /console /zabbix /jira…      username/password

Tomcat 刻意不给 HTML 表单:真实 Manager 未认证就是 401 + Basic 弹框,给表单
反而更假;Authorization: Basic 本就在 ExtractCredentials 覆盖范围内,捕获能力不减。

凭据字段:
- credentialKeys 扩 pma_* / j_* / luci_* / user[login] / user[password];
- token 只用于判 HadFields,**移出 isPasswordKey** —— PMA/GitLab 的登录表单带
  hidden CSRF token,而 Fields 是 map、遍历无序,原来的实现会让 token 随机
  顶掉真密码,记录下来的「密码」时对时错。

资源占用(这是本次主要约束):
- 渲染一页 3.7µs / 2 次分配 / 5.2KB(改前 12µs / 22 次 / 19.5KB):
  不用 html/template(每次 ParseFS 是毫秒级 IO+编译),改单次 strings.Builder
  并避免 "a"+b+"c" 反复分配,Replacer 提到包级;
- 静态资源请求 55ns / 0 分配:CSS/JS 回常量片段、图片回真 1×1 PNG,
  **不解析 IP、不判封禁、不写库**——真实后台页一次加载 5–10 个资源请求,
  否则等于把页面浏览放大成十几条 INSERT。插件目录下的 .php 回 404(webshell 探测)。

honeypot paths 增加家族汇总,便于确认实际生效的分布。
新增 dev/preview-honeypot:把 9 个家族渲染到 outputs/honeypot-preview/index.html
再配 headless 截图核对外观——本轮两个「源码看不出、截图一眼假」的问题
(Tomcat 忘配 css 导致按钮白字无底色、WP 登录页底部挂着 404 文案)都是它发现的。

测试:TestFamilyOf / TestEveryFamilyRendersItsOwnFields / TestTomcatUses401Basic /
TestAssetFor / TestRenderIsCheap(4KB 上限)/ BenchmarkRenderFamily /
BenchmarkAssetFor,route 层 TestHoneypotServesFamilySpecificPage /
TestHoneypotCapturesFamilyFields / TestHoneypotAssetRequestsAreNotRecorded。
变异验证:资源短路失效 → 3 个资源全变 text/html 且落库;FamilyOf 恒返回
Generic → 页面缺特征字段。两项均按预期变红。
2026-10-23 12:38:35 +08:00
tamakyi edde075406 update workbuddy file 2026-10-23 11:14:27 +08:00
tamakyi 29ddee07db docs(memory): 记录封禁键/取证地址分离的根因、测试与变异验证经验 2026-10-23 10:53:13 +08:00
tamakyi 90a284a3dd docs: 写清「封的是一把键」与取证地址的区别
honeypot.md:
- §3 新增小节:LimitKey 折叠规则(IPv4 原样 / IPv6 折 /64)、邮件里两个地址
  各自含义、封禁只在内存里、建封 WARN 的字段与 grep 方法;
- §4 注明取证存的是精确地址,不是 /64 聚合键;
- §8 排障首行补「邮件说封了但还能访问」的三种原因与判定路径,
  并提醒「邮件里的来源 IP 不是封禁键,拿它对 ban_key 永远对不上」。
cli.md:stats 说明改为「来源排行(IPv6 按 /64 聚合)」,补 ip 语义。
2026-10-23 10:53:12 +08:00
tamakyi 818fa74160 fix(honeypot): 取证记精确地址、封禁用 /64 键,并补建封与拦截日志
线上实测:IPv6 访问蜜罐后收到「你的 IP 已被封禁」,邮件里却是
`2408:…::/64` —— 既不像自己的地址,也说不清封了多大范围;同时用户自己
仍能正常访问,看日志也无从判断到底有没有封。

根因是「取证地址」与「封禁键」混用同一个变量(都取 LimitKey 的折叠值):
- 取证丢掉精确 IPv6 后 64 位,事后无法定位到具体主机、没法和其他日志对账;
- 邮件只给聚合键,运维第一反应是「不是我」,排查方向直接被带偏。

改动:
- Serve 拆 clientIP(ResolveClientIP,精确)与 banKey(LimitKey,/64):
  DB / 凭据文件 / 告警邮件用前者,封禁 / 探测采样 / 告警去重用后者;
- autoban.Options 新增 OnBan 回调(保持纯标准库、不引入 logrus),
  route.newBanner 注入后每次建封打 WARN:ban_key / reason / duration / expires_at;
  中间件 403 分支加 Debug 日志(被封者会持续请求,Info 会被刷屏);
- 告警邮件新增「封禁范围」行,仅 ip != ban_key 时渲染(IPv4 不重复显示),
  顺带把过时的「已入库」改为「写入取证文件」——凭据早就外置了;
- honeypot stats 的来源排行改按 LimitKey 合并,否则 IPv6 轮换地址会把
  排行碎成几十条各 1 次的记录;合并口径与封禁完全一致。

测试(每项都做了变异验证:改坏→确认变红→恢复):
- TestHoneypotRecordsExactIPv6ButBansByPrefix:精确地址进 DB 与凭据文件、
  /64 才是封禁键、同 /64 换地址仍 403;
- TestOnBanCallback:建封回调带键/时长/原因,封禁期内重复命中不重复回调;
- TestHoneypotAlertTemplateRenders:IPv6 渲染封禁范围、IPv4 不重复;
- TestMergeSourcesByKey:同 /64 合并、IPv4 原样、limit 与空输入。
2026-10-23 10:53:03 +08:00
tamakyi aa758884f9 docs(memory): 分享卡缓存 key 排查结论 2026-10-23 00:58:43 +08:00
tamakyi 675cecb21d fix(share): 单题卡缓存 key 补 user.Domain;纠正过期的 TTL 注释
先查了「缓存 key 漏项」到底有没有实际影响,结论是**当前没有**:

- 用户域名只在注册时写入(register.go / CreateUser),没有改域名的入口,
  所以两张卡的二维码内容不会在运行期变化
- ExternalURL / ShareBackground 来自配置,改了必须重启,而缓存是进程内的,
  重启即清空 —— 不存在「配置变了缓存还在」的窗口
- 头像、背景上传走 R2 随机 key(picture/日期/随机 hex,内容不可变),
  换图必然换 URL,key 随之变化,不会出现「同名不同图」

只有一条理论缺口:域名要是将来能被修改,单题卡 key 没算 user.Domain,
会出现最长一小时的「二维码指向旧域名」。补上这一项成本为零,先按事实算进去。
shareCardCacheKey 本来就含 Domain,两张卡现在一致。

顺带把两处「TTL 30 分钟」的注释改成 1 小时(代码一直是 time.Hour)。
新增 TestShareCardCacheKeysCoverDrawnFields:逐个改动画进图里的字段,
断言 key 必须随之变化;去掉 user.Domain 后立刻报
"user.Domain is drawn on the question card but not in questionShareCardCacheKey"。
2026-10-23 00:58:31 +08:00
tamakyi 8f849123f7 docs(memory): 两张分享卡部署复验(几何 + 四档宽度解码) 2026-10-23 00:38:53 +08:00
tamakyi d5398f9c2f docs(memory): 单题卡二维码 5px 与不落盘的决定 2026-10-23 00:27:27 +08:00
tamakyi 310f85156a perf(share): 单题卡二维码同样提到 5px/模块
单题卡右侧卡片只有 210×255,二维码最大只能到 148px(37 模块 = 4px/模块),
缩到 480 宽照样解不出。

- QQRCardW 210 → 236、QQRCardH 255 → 300、QQRSize 150 → 190(37 模块 → 185px)
  卡片仍居中对齐(x 679、y 120),左侧文字区随 QQRCardX 派生,自动收窄 26px
- 二维码卡片绘制抽成 drawQuestionQRCard,可在测试里直接调用出图
- 新增 TestQuestionQRCardLayout:二维码必须正方形、模块 ≥5px、四边留白 ≥20px、
  居中偏差 ≤1px,TEST_PREVIEW=1 输出整卡 PNG;把 QQRSize 改回 150 立刻 FAIL

实测:二维码 185px(5px/模块)、居中偏差 0.5px、上下留白 34px;
gozxing 在 960 / 720 / 480 / 320 宽下都能解出正确链接。
2026-10-23 00:27:07 +08:00
tamakyi d0a1bb8435 docs(memory): 分享卡模块像素实验矩阵与 2x 成本基准 2026-10-22 23:37:04 +08:00
tamakyi 72f08cd7c9 perf(share): 用「同画布放大二维码」替代 2x 渲染,模块 4px → 5px
960×540 的卡被聊天软件缩到 480 宽时,二维码每模块只剩 2px,解码器读不出。
2x 渲染能解决,但实测成本不低(见下),先用零代价的办法把模块做大。

实测矩阵(go-qrcode 纠错 Q / 真实 55 字符链接 / 37 模块,含内嵌头像):
  尺寸 148px = 4.0px/模块 -> 960 o 720 o 480 x 320 x
  尺寸 190px = 5.0px/模块 -> 960 o 720 o 480 o 320 o 240 x
纠错降到 M(33 模块)只省 11% 模块数,容错却从 25% 掉到 15%,不划算;
PNG 只能保住原图,缩到 480 一样解不出,体积还是 JPEG 的 5 倍。

- QRCodeSize 165 → 190(37 模块取整为 5px/模块;生产短域名 33 模块为 165px)
- QRCardH 315 → 335 容纳变高的二维码,卡片整体仍垂直居中
- 新增 TestGenerateQRModuleSizeAtLeast5:三种常见链接长度下模块必须 ≥5px,
  把 QRCodeSize 改回 165 立刻 FAIL

渲染成本基准(1x vs 2x):JPEG 编码 12 → 48ms、背景 Lanczos 缩放 25 → 41ms、
画布分配 3 → 13ms,二维码生成本身 0.8ms —— 2x 的代价主要在编码与缩放,
与二维码尺寸无关,所以放大二维码不增加任何 CPU。
2026-10-22 23:36:02 +08:00
tamakyi 9fb003cc39 docs(memory): 分享卡复验结论(着墨偏差实测、二维码静默区回归、4px/模块上限) 2026-10-22 23:24:32 +08:00
tamakyi b35c2a8f45 fix(share): 二维码不再被静默区吃掉三分之一尺寸;卡内间距收到 4px 栅格
上一版改成按模块整数倍出图时,静默区仍然画在图像里:49 个模块(41 + 4×2)
挤进 165px 只剩 3px/模块、符号 111px,比改造前的 135px 还小 —— 这是自己引入的
回归。测试卡上验证时才发现:二维码在 960 宽的卡里只占 111px,微信/QQ 二次
压缩到 480 宽后模块不足 1.5px,gozxing 直接解不出来。

- generateQR 改 qr.DisableBorder:图像只输出符号,静默区交给承载它的白色卡片
  (四周留白 30px 以上,远超 4 模块)。符号尺寸按 size/modules 取整,最多
  浪费一个模块宽度
- 调用方按实际符号尺寸居中与排版,不再假设等于目标尺寸
- 单题卡 QQRSize 128 → 150:37 模块的题目链接因此拿到 4px/模块(原 3px)
- 卡内垂直间距 26/21/21/15 → 24/20/20/16,落在 4px 栅格上

验证:profile 卡二维码 111px → 148px(37 模块 × 4px,用满 90%),单题卡
111px → 148px;gozxing 解码 960 原图、75% 缩放图均能读出正确 URL(含内嵌
头像)。守门测试加了两条:符号宽度必须等于 modules×perModule 且吃掉目标尺寸
≥80%(变异成「含静默区」立刻 FAIL),头像测试改判四角必须是定位图案而非
白色(防止符号被裁切)。
2026-10-22 23:22:34 +08:00
tamakyi a215c2ba2b docs(memory): dev 分享卡 500 的根因(字体从未入库)与二维码模块对齐 2026-10-22 22:33:53 +08:00
tamakyi d51629ef2f fix(share): 字体缺失不再永久失败;二维码按模块整数倍绘制
测试环境部署后分享卡整站 500「字体文件未配置或无法加载」。根因不是代码
回退,而是字体从来就不在仓库里:static/fonts/ 既没有入库(git 历史为空),
也没有 embed,凡是按仓库新建的环境都缺这个文件,只有手工补过字体的老环境
才正常。

- getFont 不再用 sync.Once 永久缓存失败:补上字体文件即生效,无需重启;
  失败时错误信息带上「已尝试的路径」,排障不用再猜是配置还是文件
- 候选路径:font_path → static/fonts/*.{otf,ttf,ttc} → 常见系统 CJK 字体,
  换过字体文件名也能用
- app.sample.ini 注明字体必须自备、缺失的后果

顺带做 P1 的第一项:generateQR 改为按模块整数倍出图(41 模块 @165px →
每模块 4px、符号 164px 居中)。原先是 256px 生成后最近邻缩到 165px,
每模块 4.02px,模块宽窄相差 1px,小尺寸二次压缩后更容易扫不动。
内嵌头像改为按目标尺寸等比(size*48/256)+ 2px 白边,静默区不受影响。

守门测试新增两条:TestGenerateQRIsModuleAligned(扫描线游程必须是模块
宽度的整数倍,变异成等比缩放后立刻 FAIL)、TestGenerateQRWithLogo
(中心是头像、四角静默区纯白)。
2026-10-22 22:33:22 +08:00
tamakyi 80e042d154 chore: 分享卡排查记录;.gitignore 忽略 .workbuddy/local 2026-10-22 21:42:56 +08:00
tamakyi 0054e508b7 fix(share): 分享卡链接按真实字宽居中,长链接按 host/path 收敛
原实现用字符宽度系数估算文本宽度,把标点/符号一律按 1em 计,
而 URL 里的 : / . _ - 实际只有 0.25~0.35em,整句高估 55%:
15px 的 box.shiroko.one/_/tamakyi 估算 352.5px、真实 227.9px,
越过卡片可用宽 230px 后触发等比缩字号,最终只画到 9.6px 且左偏 40px;
链接越长字号越小(长域名实测 5.3px),整段挤在卡片左侧。

- measureTextWidth 改为逐字累加 HMetric.AdvanceWidth + Kern,
  scale = fontSize*64 与 freetype 在 72 DPI 下的换算一致,测量结果与
  DrawString 返回笔位逐位相等;字体不可用时回退估算
- drawTextCentered 随之修正,底部水印原本偏 9.8px 一并归位
- 新增 displayURL(去协议前缀)/ layoutLink / ellipsizeMiddle:
  单行 15→13px;放不下按 host/path 拆两行 13→11px;host 超宽才中间省略;
  字号下限 11px,行长恒定不超过 QRCardW-40
- 卡片内容高度按链接行数计算,两行时整体重新居中

守门测试见 route/question/share_layout_test.go(测量=笔位、限宽、按着墨
边界判定居中 ±3px、TEST_PREVIEW=1 出图)。
2026-10-22 21:42:50 +08:00
tamakyi 90c0cfd27f docs(memory): 透明底素材的生成与抠图口径 2026-10-22 19:59:23 +08:00
tamakyi ebc4586ef4 theme(shiroko): 新增平涂极简风肖像素材(透明底)
按用户给的参考图风格生成三张白子肖像:平涂、零描边、纯黑圆角矩形眼、无嘴、腮红、头部裁边。

- static/images/themes/shiroko/portraits/portrait-{front,tilt,close}.webp(907–1024×921,共 197KB)
- 未接进 --tb-deco,留给用户自行摆位;docs 里写了接入示例与提示词骨架。
- 抠图走"指定纯色底 + 按色值算 alpha":ImageGen 的 transparent 至今返回无 alpha 的 RGB,
  按白底抠会吃掉白衬衫、也填不到闭合光环内部。
2026-10-22 19:58:45 +08:00
tamakyi 1addeb56b8 fix templates v4 2026-10-22 18:50:15 +08:00
tamakyi 0181fc5c66 fix templates v3 2026-10-22 18:26:30 +08:00
tamakyi b939d26ff0 fix templates v2 2026-10-22 17:49:25 +08:00
tamakyi bd02eb7ed2 fix templates v1 2026-10-22 17:04:10 +08:00
tamakyi 8fcb23f7b1 记忆:生成器顶栏面板 2026-10-22 16:16:05 +08:00
tamakyi e4810040a7 生成器新增「顶栏」面板:实色 / 半透明磨砂 / 全透明(仅预览)
以前顶栏只能在配色里填一个颜色字符串:写 rgba 拿不到模糊,档位和颜色又分在两处,
改了一处就忘了另一处。现在三档一个面板说话。

- S.navbar = {mode, alpha, blur},基色仍取「配色 → 顶栏」,navbarValue() 统一计算:
  solid 先 flatten 压平(旧工程里可能自带 alpha)、glass 加 alpha、clear 出 transparent。
  半透明档额外写 .uk-navbar-container{backdrop-filter:blur(N)} —— 令牌只能给颜色,
  模糊必须落成规则;选择器不带 data-theme,深浅两套共用一条。
- 全透明**只给预览**:validate() 拦导出,与 internal/theme 的 TestNavbarNotFullyTransparent
  同一条规矩,面板上直接写明原因。
- 面板带「导航文字 x.x:1」读数,按压平后的顶栏算(半透明时是顶栏压在底色上的结果),
  低于 4.5 变橙。
- 旧工程迁移:配色里塞过 rgba(...,0.52) 的,按 alpha 反推档位(1→实色 / 0→全透明 / 其余→半透明),
  打开旧工程不会突然变实色。
- 自检 ?dump=navbar(10 项:三档产出、blur 有无、全透明被拦、alpha 0.2/0.95 边界)。
- 滑杆步长 0.02 → 0.01:0.55 会被吸附成 0.54/0.56,手感像坏了。
2026-10-22 16:15:49 +08:00
tamakyi 8941744621 new-sakura:顶栏改半透明磨砂,并立规矩禁止全透明
线上(box.shiroko.one/_/tamakyi)两个现象来自同一处:`--tb-navbar` 是实色,
顶栏那条带(y=20..80)把从右上角长出来的花枝齐刷刷切掉一段。

第一版顺手改成 transparent —— 花枝完整了,但顶栏"消失":导航失去依托,
文字直接压在花纹上。用户当场否掉,要求半透明。

- 顶栏:`rgba(253,246,248,0.72)` / 深色 `rgba(34,26,30,0.72)`,配
  `.uk-navbar-container{backdrop-filter:blur(12px)}`(令牌只能是颜色,模糊写在规则里)。
  0.72 有依据:导航文字 #6d555d 纯底 6.34:1,最坏情况(深花瓣在顶栏后面)压平后 5.08:1。
- 花枝 top -16px → 26px:顶栏半透明后,700~760px 宽度下枝头正好压在「登录」上(截图确认)。
- 手机底栏 #tb-mobile-utils 在 base.css 写死 rgba(255,255,255,.92),粉底上是一条白杠,
  主题里改成同色系 0.92。
- 新增 TestNavbarNotFullyTransparent:拦 `transparent` / `rgba(...,0)` / `#rrggbb00`,
  并说明"这不是审美偏好,是踩过之后立的规矩"。变异验证过(改回 transparent 立即 FAIL)。
  约定同时写进 static/css/themes/_scaffold.css 的令牌说明。
- StaticVersion 20261022b → 20261022c:css 改了不提版本号,浏览器拿着旧样式,
  现象就是"改了但线上没变"。
2026-10-22 16:05:37 +08:00
tamakyi 2981731d4f announcement v3 2026-10-22 15:28:04 +08:00
tamakyi 33fa643465 announcement v2 2026-10-22 15:09:44 +08:00
tamakyi e1e840f895 announcement v1 2026-10-22 15:08:24 +08:00
tamakyi c9f22cb81b 修窄屏装饰层压不住作用域块(手机上一直显示桌面版樱花)
@media 不提升特异性:作用域块 html[data-theme-style][data-page-scope="box"] 是 (0,2,1),
媒体查询里写的 html[data-theme-style] 只有 (0,1,1),于是「窄屏装饰层全置 none」这行
从来没生效 —— 手机上照样铺开桌面的花枝与花朵。深色作用域块是 (0,3,1),更压不住。

- 媒体查询里补三条同特异性的通配规则(作用域 0,2,1 / 深色基础 0,2,1 / 深色+作用域 0,3,1),
  媒体块在文件更后,同特异性即胜出。
- 新增 TestNarrowMediaOverridesScopedDeco:外层每处非 none 的 --tb-deco,都要求窄屏里有
  同属性名、属性数不少于它的声明。变异验证过(删掉深层那条立即 FAIL)。
  它同时报出 shiroko 的 900px 块只覆盖浅色 —— 但深色块本就是 none,不算缺陷,故跳过。
- 窄屏改由加强版底纹承担樱花(透明度 ×1.9、格子 180px):桌面那套 0.18~0.34 的透明度
  在 390px 上实测接近纯粉。顶栏与卡片之间那条 35px 带子试过放小花,question/admin 页卡片
  从 y=110 起会切掉一半,最终不放装饰层。
2026-10-22 11:18:29 +08:00
tamakyi 5c0de805c6 new-sakura:花枝朝向与分叉修正,素材包一键导出上传到对象存储
- gen_new_sakura.py:花朵加各自旋转角(原来一律朝上,像贴纸);分叉从垂直下垂改成向右上斜;
  新增 --asset-base / --out —— 填前缀就引用对象存储位图,留空仍是内联 SVG。
- export_assets.py(新增):从 gen_new_sakura 的同一份 SVG 出图(直接 import,不另画一套),
  用无头 Chrome 透明底渲染后转 WebP;--upload 一次传 CSS + 5 张 webp + 生成的 INSTALL.txt,
  公开地址按 data/r2.txt 的 image_bucket_cdn_host(与 app.ini 同名)拼。
- serve.upload_files 支持逐文件 cacheControl。原来硬编码 immutable,而 INSTALL.txt 与镜像
  CSS 的键不带版本 —— 重新生成后浏览器/CDN 一年内都还是旧内容。现在位图 1 天、CSS 5 分钟、
  README 60 秒;selftest 补一条覆盖断言。
- 文档更正两处事实:①自定义域 r2.337845818.best 实测可匿名读(S3 endpoint 仍 403);
  ②服务端有 gzip,内联版 51KB 实际传输 3.9KB,比位图版(2.7KB + 5 图 48.9KB)小得多,
  所以「换位图省 CSS 体积」不成立,位图只在素材要复用到非 CSS 场景时才有意义。
  另记:曾以 immutable 传过的键必须去控制台清缓存,重传不会让边缘失效。
2026-10-22 11:06:13 +08:00
tamakyi 48a1938fb2 新增 new-sakura 主题:纯 CSS 樱花,每个页面位置不同
樱花不依赖任何图片素材:整朵花、带缺口的花瓣、带枝的花朵树都是内联 SVG 的
data URI,写在主题 CSS 里。好处是零请求、任意缩放不糊、换花色只改一个色值;
代价是 CSS 约 50KB(gzip 约 12KB),走 StaticVersion 缓存。

- 六个页面作用域各一套 --tb-deco 位置(global / box / question / admin /
  profile / vote),10 槽三组等长;位置一律「关键字 + 偏移」两值写法。
- 窄屏(≤640px)装饰层全关、底纹缩到 200px:卡片占满宽度后枝条会糊在字上。
- 深色换亮一档的粉、关掉底纹(浅粉压在深底上会变噪点)、背景用渐变避免死黑。
- 亮色 primary 从候选 #c14a72(4.39:1)压深到 #b8436a(4.87:1),
  muted 5.27:1,都踩住 AA;深色对应 8.17:1 / 5.82:1。
- 生成脚本 dev/theme-tools/gen_new_sakura.py(花形、位置表、配色都在里面),
  CSS 是产物,不手改。
- 四份词典加 theme.name.new-sakura;conf/app.sample.ini 的 available 登记。

为什么不用生成器:生成器的装饰层只认图片素材,没有内联 SVG 图层,
见 docs/theme-new-sakura.md「为什么不用主题生成器出图」。
2026-10-22 10:45:39 +08:00
tamakyi 8daa5b0bec 生成器默认监听 0.0.0.0,局域网/手机可直接打开
- serve.py:--host 默认改 0.0.0.0;启动时列出所有网卡地址(0.0.0.0 不能直接点开);
  --host 127.0.0.1 仍是「只给本机」的收紧方式,README 说明公共网络用它。
- 改单线程为 ThreadingTCPServer:上传几 MB 的图会占住连接,单线程时 iframe 预览
  和其它请求都得排队。
- 写接口加同源校验(Origin 与 Host 比对,无 Origin 放行):挡住同网段里「另一个网页
  借访客浏览器发写请求」,curl / 脚本不受影响。
- index.html:提示文案不再写死 127.0.0.1,改用 location.host —— 用局域网地址打开时
  不该反过来提示「请用 127.0.0.1 打开」。
- mock_s3.py 加 --host(默认仍回环),便于在别的设备上联调。
2026-10-21 21:46:34 +08:00
tamakyi 12c4f3e1be 生成器可检测与管理远端主题素材(列表 / 差异 / 覆盖 / 删除)
「传上去」之后还得知道「传得对不对」:同一 slug 传过三次、换过 slug 旧图还在、
重新导出后远端仍是旧版,这些在页面上都看不出来。

- serve.py:s3_request 支持 query;新增 ListObjectsV2(含分页)与 DeleteObject,
  端点 /api/s3/list、/api/s3/delete。
- index.html:远端素材清单按对象键名跟本地比 —— 一致 / 旧图(同名但大小不同)/
  多余(当前主题已不引用)/ 缺失。「补齐差异」只传缺的与旧图,「删除多余」批量删,
  单行可 ↻ 覆盖或 ✕ 删除;删除走 confirm 二次确认。改过 slug 会提示重新检测。
- 修:list 返回完整键而 upload/delete 收相对键,不剥前缀会删到不存在的键
  (S3 回 204,看起来删成功其实没删)。加 relKey() 统一。
- check_sigv4.py 补带 query 的用例(ListObjectsV2 三参数、乱序 token)→ 8/8;
  selftest_upload.py 补列表/分页/幂等删除 → 20 项全过。
2026-10-21 21:29:35 +08:00
tamakyi 9c961b3c05 主题素材可放任意 S3 兼容存储,生成器内测通后一键上传
素材位置改成两选一(本地随二进制 / 自行填 Endpoint 的 S3 兼容存储),
R2、MinIO、B2、OSS-COS 的 S3 端点都只差 Endpoint/Region/路径风格。

- serve.py:手写 SigV4(PUT/GET/DELETE/HEAD),新增 /api/s3/test 与
  /api/s3/upload。测试连接依次验连通、写权限、读回一致、匿名可读,
  失败把 S3 的 <Code> 翻成可执行的建议;上传带 immutable 缓存头。
- index.html:新增「素材托管」面板(模式切换 + 7 个字段 + 测试/上传按钮),
  上传集合与导出共用 usedAssetIds();成功后自动回填「素材前缀」。
  密钥只进 sessionStorage 与内存,不进草稿/工程 json/导出 zip。
- 修:usedAssetIds 返回 Set,误用 length/map 导致「还没有素材」。
- 修:字段填完 700ms 内刷新会丢配置 → 失焦与模式切换时立即落盘。
- 自检:check_sigv4.py 与 aws-sdk-go-v2 signer 逐字节对拍(6/6),
  selftest_upload.py 起假 S3 跑通测试连接→上传→读回(16 项),
  mock_s3.py 供手动点面板用。
2026-10-21 21:08:39 +08:00
tamakyi 6c21184733 主题生成器支持素材前缀(主题素材可放 R2/OSS)
主题素材只有一处引用:主题 css 的 url()(分享卡是 Go 用 imaging 画的,不读素材),
所以只要让 CSS 里的地址指向对象存储即可。

- 导出面板新增「素材前缀」:空 = /static/images/themes/<slug>/(图片随二进制发布,与以前一致);
  填 https://cdn.example.com/themes = CSS 写绝对 URL,zip 里的图片改为「上传用」
- 地址统一由 urlOf() 决定。修了一个重复实现:layerCSS 里复制过一份路径拼接,
  只改 urlOf 会出现「背景走 CDN、装饰还指本地」
- 前缀随意切换不影响预览(预览恒用内联 dataURL);存进工程 json;?assetbase= 可自检
- INSTALL 文案按是否托管分叉,写清图片传到哪、换域名要重新导出
- 新增 docs/theme-assets-cdn.md:三种做法(生成器前缀 / 应用内 301 / 边缘改写)、
  缓存与 ?v=、CORS 不需要、深色无素材、迁移用的 sed 命令
- README 更新;UI 修正:240px 面板里 4 字标签会被 flex 压窄,需 flex:0 0 auto + 84px
2026-10-21 19:32:55 +08:00
tamakyi f37ff28a46 提问管理页面板补卡片底:换主题后内容不再裸铺在场景上
现象:只有 /user/questions 这一页的「卡片」像完全透明的,其它后台页正常。

根因不是主题,是模板本来就没有卡片容器:提问列表是
<div class=uk-margin-medium-bottom> + <hr> 直接铺在页面上,整个模板只有 1 处 .uk-card
(还是投票 tab 里的「我的投票」标题条)。默认主题底色是纯白,所以看不出来;
换成带整页插画的主题后,场景整片透上来,就成了「卡片透明」。

给 .qlist-panel 兜一层卡片底(--tb-surface + 圆角 + 阴影),三个 tab 共用;
面板内已有的一张 .uk-card-default 降成 --tb-surface-2 去阴影,避免卡片套卡片。
≤640px 收紧内边距。与主题无关,默认外观也会多这层卡(和其它后台页一致)。
2026-10-21 19:32:29 +08:00
tamakyi 34a262091b 修 shiroko 单车/步枪素材「白色没抠」:白上白只能全白转透明
根因不是抠图参数:素材是白底+白车架,车架涂装与背景白是同一个值(都是 #f9f9f9),
颜色上无法分离。四角 floodfill 只吃掉外圈,轮圈内、车架三角、车筐、枪与车架之间的白整块留着,
量化就是不透明像素里近白(>240) 占 50%。

- 处置:min(r,g,b)≥238 软过渡 + 饱和度>14 保留彩色 → 白全透明。近白降到 0.0%,
  不透明率 19.4%,29KB;轮辐/车架/车筐成为描边,粉轮圈、蓝枪托、接触阴影保留
- 试过「按近白厚度区分白洞与白涂装」(腐蚀测厚度)→ 失败:轮辐间扇形白楔 9~13px 与车架管
  5~10px 重叠,阈值怎么调都会留白楔,比原图更难看
- 画布保持 397×259 不裁:deco-size 是 auto 259px,宽度按比例算,裁了锚点会漂
- css 图片 ?v= → 20261021a;StaticVersion 20261021c → 20261021d(css 内容变了必须换缓存键)
- docs:assets-spec 补「白上白」取舍与验收项「不透明像素近白占比≈0」,theme-shiroko 素材表同步
2026-10-21 19:18:50 +08:00
tamakyi e9ac72dd7d docs(memory): 复检部署结论(色块已修、四条作用域主题链路、window-size 陷阱) 2026-10-21 19:03:09 +08:00
tamakyi 1f29d3eb2e 修设置页主题色块被模板过滤器替换成 ZgotmplZ
html/template 对 style 属性做 CSS 过滤,linear-gradient(...) 未标记安全会被整体替换成
ZgotmplZ —— 线上设置页四个主题色块一直是灰底(既有问题,不是这次配色改动引入)。

- FuncMap 新增 safeCSS(string → template.CSS);值只来自 internal/theme.swatchOf,
  由两个已校验的色值拼成,不接受用户输入,所以这里放开是安全的
- user/profile.html 的色块改 {{ safeCSS .Swatch }}
- 新增 TestThemeSwatchRenders:渲染 user/profile,断言不含 ZgotmplZ 且含渐变串;
  已做变异验证(去掉 safeCSS 后该用例失败)
2026-10-21 18:53:10 +08:00
tamakyi 27856dd4a2 字号阶梯收尾 + 主色补到 WCAG AA(新增 --tb-on-primary)
一号事项(剩余令牌化):
- templates/user/profile.html 20 处、base.css 9 处 font-size 全部落到 --tb-fs-*
- base.css 导航四档(0.95/0.88/0.82/0.78)合并成「桌面 md → ≤1000 xs」;logo 三档统一 lg
- 有理由保留的:.tb-pin-flag / .md-body code 的 0.9em(相对父级)、验证码 1.4/1.2rem(等宽字形)、
  chat 关闭按钮 40px、password 页 3em、mail/*(邮件客户端不保证 var());行内 12px/0.75rem 等值顺手换掉

二号事项(主色对比度):
- base/default #1e87f0(3.42) → #0e6fd0(4.70);shiroko #4a7fc1(3.69) → #3b6dac(4.74);
  sakura #e5678f(2.80) → #ba3e66(4.70,饱和度压到 .5 保持柔和)。同色相只压亮度,目标 4.7 留余量
- 深色块 muted 之前只对 --tb-bg 算(4.7 以为过了),对压平后的卡片其实 4.31/4.17 → 提到 #a18790 / #7f8ea1
- 新增 --tb-on-primary(亮 #ffffff / 暗 #0b1016):深色模式主色偏亮,白字只有 2.2~3.1:1,
  用同一个令牌兼顾「链接色要深」和「按钮底要深」是矛盾的。.uk-button-primary 与
  .uk-label:not(语义色) 改吃它 → 深色按钮变成浅底深字(6.10 / 8.65 / 7.27)
- 顺手封掉几处主色泄漏:header 验证码 focus、profile .feat-day.is-on、explore 随机投票的 JS outline、
  vote-results 行内色,全部改成令牌(否则换主题时不跟着变)

生成器同步:paletteVars 导出 --tb-on-primary(白字/深字择优);?dump=palette 增加
onPrimaryCr / onPrimaryPickedBest / warnings —— 主色卡中间调时只警告不判失败;
配色面板给橙色提示。_scaffold 令牌 29 → 30。StaticVersion → 20261021c。

验证:TestSwatch 断言同步为 #ba3e66;8 种组合(base 亮暗 + 三主题亮暗)对比度全部 ≥4.5;
go test internal/{theme,templates,conf,i18n} 全绿;生成器 validate/fit/quality/palette/layers/fx/fxpipe 全过。
2026-10-21 18:39:08 +08:00
tamakyi 252b22a342 docs(memory): 记录模板令牌化提交与四处有意的观感变化 2026-10-21 18:28:52 +08:00
tamakyi 001306d940 模板样式令牌化:字号落到 5 档阶梯、圆角改走 --tb-radius
11 个模板里的硬编码字号/圆角换成令牌,为之后的深色与主题一致性打底。

字号(base.css 的 --tb-fs-xs/md/lg/xl/hero = 12/14/16/24/28):
- 0.70~0.88rem、0.72rem 等五六档零碎值 → --tb-fs-xs(标签/meta) / --tb-fs-md(正文、列表、按钮)
- 0.95~1rem → --tb-fs-lg;1.5rem 大数字 → --tb-fs-xl;1.75rem 首页主标题 → --tb-fs-hero
- 以下几处**故意改变了观感**(不是等价替换,先记下来免得以后当 regression 查):
  explore 的 .explore-name 1.2rem → 1rem;.hot-vote-title 0.98 → 0.875;
  首页 .tb-stat-num 手机端 1.25 → 1rem;.hot-vote-action 0.82 → 0.875(变大)。

圆角:
- 8/9/10px → --tb-radius-sm(8) / --tb-radius(12,对话框与气泡);3/4/5/6px → --tb-radius-xs(6)
- 头像 50%、胶囊 999px 保留原样(语义值,不是视觉随意值)

其它:header 验证码输入框边框 #ccc → var(--tb-border);chat 气泡与图片的圆角走内联 var()。

仍未令牌化(留给下一轮):templates/user/profile.html 还有 16 处 font-size、base.css 自身 11 处
(0.78/0.82/0.88/0.95/1.1),邮件模板 mail/* 保持 px 不动(邮件客户端不保证 var())。
2026-10-21 18:28:26 +08:00
tamakyi dc90d96bde 删除生成参考图 shiroko-cutout.png,文档改为「参考图不入库」
- docs/theme-shiroko.md 三处引用不再指向具体文件(它只是 image-to-image 的 image1,从不进 embed)
- 顺手修过时说明:--tb-deco 是 10 个槽位,不是 6 个
2026-10-21 18:24:16 +08:00
tamakyi 8364a2fa3d docs(memory): 记录 test01 删除已提交 2026-10-21 18:20:16 +08:00
tamakyi 2d13c31d62 删除验证用的 test01 主题
test01 是为验证生成器导出的临时主题(css + 2 张 webp),已从仓库移除。
删主题只需删文件:build() 读不到 css 会打印 warning 并从可用列表忽略;
仍选着 test01 的箱主会静默回退到站点默认外观。
2026-10-21 18:20:00 +08:00
tamakyi db4b3fa095 主题体系跟上设计令牌化:派生文字色按对比度下限、主题 muted 补到 AA
生成器(dev/theme-builder):
- paletteVars 的 text-secondary/text-muted 从「往底色混 36%/55%」改成 mixToContrast(5.5/4.5):二分找最淡且达标的位置,文字本身不达标就原样返回
- 半透明卡片先 flatten 压平到底色再算对比度;参考背景取「页面底色 + 压平后的卡片」中更不利的
- 配色面板加读数(正文/次要/淡文字/主色),低于 4.5 变橙;默认 primary #4a7fc1(3.69) → #3d6fac(4.63)
- 新增 ?dump=palette 四种配色回归;README 补配色一节

主题/base(含用户本轮 base.css 改动):
- sakura muted #a08c94(2.8:1) → #78696e(4.61)、shiroko #8794a7(2.76:1) → #656e7a(4.63):主题覆盖令牌,基座的 AA 修复到不了主题页
- 深色顶栏链接补 secondary 覆盖:亮色块已是次要文字色,深色块原来 !important 定成正文色,同一顶栏权重跳一档
- _scaffold.css 令牌清单 23 → 29(补 fs 阶梯与 page-position),muted 说明 AA 下限
- StaticVersion 20261021a → 20261021b
2026-10-21 17:42:43 +08:00
tamakyi 54df166d4e docs(memory): 记录蜜罐文档产出与 path 脏字符的最终定位 2026-10-21 16:26:48 +08:00
tamakyi 3d453749eb docs: 新增蜜罐部署运维文档,cli.md 补 honeypot 命令与排障条目
docs/honeypot.md(新增):
- 生效范围与匹配规则:挂在 NotFound、业务前缀强制剔除、三类匹配方式;
- 配置项表,并单独警告「行尾注释会被并入值」(IgnoreInlineComment 的坑);
- 两条封禁路径:GET 探测走常规阈值、提交凭据才一次即封,附原因
  (GET 就封等于拿不到字典,这是最容易误解的一点);
- 凭据文件格式/权限/保留期,以及为什么不入库(备份、主从、BI);
- trust_proxy 部署必读:回环 IP 会让取证失真且一次封禁封掉整站;
- 升级清理旧版明文列(purge-creds 不可逆);
- 排障表 + 设计取舍(GET 不封、canary 位置、邮件带明文等)。

docs/cli.md:
- §0 补两条实测踩坑:「替换二进制必须重启进程」「strings 不认中文,
  用 grep -ac」;
- 命令总览加 honeypot;
- 新增 §7 honeypot 命令(hits/stats/paths/size/prune/purge-creds),
  原 7/8/9 顺延为 8/9/10;
- 排错表加 5 条:回环 IP、整站 403、进程未重启、strings 误判、命令不存在。
2026-10-21 16:25:58 +08:00
tamakyi 74312fba58 fix(honeypot): cleanPath 处理零宽字符,并在清洗生效时打诊断日志
上一版只用 strings.TrimSpace,实测漏掉「看不见但不算 White_Space」的字符:
  cleanPath("\u200b/wp-login.php") = "/\u200b/wp-login.php"
(零宽没被删,还因为首字符不是 '/' 多补了一个斜杠,拼出更怪的路径)

改为 TrimFunc 同时匹配 unicode.IsSpace 与 U+200B/200C/200D/FEFF,且只处理首尾
—— 中间的零宽字符可能是攻击 payload,取证要保留原样。

另加诊断日志:清洗前后不一致时 WARN,用 %q 打出原始值。
测试环境出现过「记录里 path 带前导空白、本地链路却复现不出来」的情况,
不可见字符必须转义输出才定位得到。

测试:TestCleanPath 补零宽空格 / BOM / 中间零宽字符保留三个用例。
2026-10-21 16:18:25 +08:00
tamakyi 846bb8a3d9 fix(honeypot): 清洗反代链路的脏路径,回环来源 IP 打配置告警
测试环境实测(Cloudflare → nginx → 源站)发现两处:
1. 凭据文件里记录的 path 是 " /wp-login.php"(前导空格);
2. 来源 IP 记成 127.0.0.1。

path:Match 内部会 TrimSpace 所以仍能命中,但落库与假表单 action 用的是原始
req.URL.Path —— 取证数据带脏字符,爆破脚本还可能把表单提交到错误路径。
新增 cleanPath(trim + 补前导斜杠,不改大小写) 在 Serve 入口清洗一次。

IP:记录到回环地址说明站点在反代后面但 [security] trust_proxy 未开启。
此时所有访客在源站眼里是同一个 IP:取证数据失去意义,且一次蜜罐封禁会
连带封掉整站(含普通访客)。新增一次性 WARN(24h 冷却),只提示不改行为 ——
本地直连调试时回环 IP 属正常。sample.ini 的 trust_proxy 段补对应说明。

测试:TestCleanPath / TestIsLoopbackIP 覆盖前导空格、制表符、空串、
无前导斜杠、大小写保持,以及 shared.LimitKey 折叠后的 "::/64" 形式。
2026-10-21 15:54:39 +08:00
tamakyi 5e8decab8f docs(memory): 记录蜜罐审计修复、凭据外置与提交状态 2026-10-21 15:47:13 +08:00
tamakyi c29e1d071f fix(honeypot): 补齐封禁装配与诱捕语义,凭据外置为文件不再入库
一、封禁链路整条没接上(P0,已实测复现)
route.New 的 autoban.New 漏传 HoneypotEnabled/Threshold/Duration,而
RecordHoneypot 首行就是 `if !b.honeypotEnabled { return false }`
→ 蜜罐命中从不封禁、落库 banned 恒 false、IsBanned 永不为真。
同一批改动又把中间件里蜜罐路径的常规计数也跳过了,净效果是蜜罐路径
从「10 次封禁」退化为零计数零封禁 —— 是 regression 不是修复。
处理:抽 newBanner() 作为唯一装配入口并补齐参数;新增 banner_config_test
四个用例(全部经由 newBanner,不再自建 Banner)。变异验证:分别把
HoneypotEnabled 置 false、HoneypotThreshold 置 0,对应用例按预期失败。

二、诱捕语义(审计时漏掉,比上面更影响功能价值)
原实现对**所有方法**计数且阈值 1 → 扫描器 GET 一次假登录页就吃 403,
永远走不到「POST 提交账号密码」那一步,蜜罐的核心价值(拿到字典)归零。
处理:提交凭据 → RecordHoneypot(不受 enable_auto_ban 约束,立即封);
纯探测 → 常规 Record(阈值 10)。新增 TestHoneypotProbeDoesNotBanImmediately,
变异验证:把 GET 也塞进 RecordHoneypot,测试按预期失败。

三、落库边界(实测取证,非推断)
- Path 原样落库而列宽 512 → PG `SQLSTATE 22001 值太长了(512)`,整条证据丢失;
  实测 600 字符失败、500 字符成功;
- 三处按字节截断 → `SQLSTATE 22021 无效的 "UTF8" 编码字节顺序`(中文密码/URL
  可稳定触发)。统一改 rune 截断并按列宽逐项对齐。

四、资源与噪音
- alerted 无界 map → 带 TTL 的 deduper(上限 2 万,过期清理 + 超限硬裁),
  同时承担告警 24h 冷却与纯探测落库采样(每 IP 每分钟一条)——
  GET 洪泛不再变成等量 INSERT;
- credentialKeys 去掉 id/name,普通 JSON 探测不再被误判为凭据提交并触发假告警;
- canary 从 meta generator 移到 HTML 注释,generator 伪装成 WordPress,
  不再同时向扫描器自曝「这是蜜罐」和「这站是 TamaBox」;
- serveLoginFailure 复用 WriteLoginPage,不再手抄响应头(已开始漂移);
- Describer 死代码 → Prefixes(),接到新命令 honeypot paths;
- honeypot_paths 解析挪到 honeypot.SplitPaths(CLI 与 route 共用),
  补前导斜杠、拒绝 "/"(会让所有 404 变假登录页)。

五、凭据外置:数据库不再存用户名/密码
明文账号密码一旦入库就会跟着进备份、主从复制与任何能连库的报表工具,
事后无法彻底清除。改为:
- 新增 honeypot.CredRecord/CredStore:按天文件 creds-YYYY-MM-DD.jsonl,
  目录 0700、文件 0600、坏行跳过、PurgeBefore 只删整天过期的文件;
- db.HoneypotHit 删掉 Username/Password/Fields/RawBody,只留 HadFields 元数据;
- HoneypotStore 删掉 TopCredentials/ListSinceByIP(CLI 改读文件后零调用);
- 新增 LegacyCredentialColumns/PurgeLegacyCredentialColumns:AutoMigrate 不删
  既有列,老库升级后历史明文仍在 → 启动 WARN + `honeypot purge-creds` 显式 DROP;
- 清理器 HoneypotRetention 增加 CredPurgeable 参数,与 DB 记录共用保留期;
  honeypot prune 也一并删过期凭据文件;
- CLI:hits 不再连库(DB 挂了也能查),stats 的凭据排行读文件;
- 配置 honeypot_cred_dir(缺省 data/honeypot-creds),.gitignore 加 /data/。

验证:go build ./... / go vet ./internal/... / go test ./...(含 PostgreSQL)全绿;
新增 credstore_test(追加/倒序/IP 过滤/坏行/Top/Purge/权限)、
TestHoneypotLegacyCredentialColumns(ADD COLUMN 模拟老库 → 检测 → DROP)、
TestHoneypotWritesCredentialsToFile;CLI 端到端手动验证 hits/--ip/--show-password。
2026-10-21 15:43:25 +08:00
tamakyi 566b13eedd fix(conf): 样例配置去掉行尾注释(会被并入值导致静默失效)
conf.Init 用 ini.LoadOptions{IgnoreInlineComment: true},该选项的语义是
**不剥离**行尾注释:`port = 1440 ; 注释` 解析出来的值是 "1440 ; 注释",
int 解析失败,而 MapTo 仍然返回 nil error —— 配置项静默回退零值。

实测照抄 app.sample.ini 的后果:
- text_censor_source 的值带注释,被 validateTextCensorSource 拒绝,直接起不来;
- ip_blacklist / security_alert_email 变成带注释的垃圾字符串;
- honeypot_max_rows / honeypot_auto_clean 静默失效(后者靠 sanitize 兜底才没出事)。

改动:
- 17 处 `key = value ; 注释` 改成「注释独占一行 + 裸值」,保留 CRLF;
- 蜜罐段补 honeypot_cred_dir,以及「提交凭据才封禁」的语义说明;
- 新增 TestSampleIniValuesHaveNoInlineComment:遍历所有段的键,断言值不含 ';'
  (比逐字段断言彻底,新增配置项自动纳入)。
  变异验证:给 ban_window_seconds 加回行尾注释,测试按预期失败;
- keynorm_test 补 HoneypotBanMinutes 的缺省值断言(缺省 1440 / 显式 0 交给 autoban 回退)。
2026-10-21 15:43:13 +08:00
270 changed files with 34970 additions and 4743 deletions
+33
View File
@@ -32,3 +32,36 @@ go.work.sum
# outputs 里全是 TestGenPreview / 主题生成器 / 截图这类一次性产物,
# 每次跑都会变,跟版本库无关。
/outputs/
# 蜜罐凭据取证文件:明文账号密码,绝不能进版本库。
# 默认目录由 [security] honeypot_cred_dir 决定(缺省 data/honeypot-creds),
# 若改成别的盘外路径则不受这条影响(那本来也不在仓库里)。
/data/
# 主题生成器的自测脚本是 Python:跑一次就会留 __pycache__,别提交。
__pycache__/
*.pyc
# 本地凭据与一次性状态(测试账号密码等),只留在本机,不进版本库。
# 目录说明见 .workbuddy/local/test-accounts.md 的抬头注释。
/.workbuddy/local/
/.codeartsdoer/
# 本地运行配置与 WSL 测试环境模板:含数据库密码与对象存储密钥,绝不能进版本库。
# dev/wsl-app.ini 的用法见 dev/sync-wsl.sh;同步时必须排除 conf/app.ini,避免被 rsync --delete 删掉。
/conf/app.ini
/dev/wsl-app.ini
/dev/_*.py
# 含真实测试凭据的历史记录与巡检脚本:保留本地文件,但不再纳入版本控制。
/.workbuddy/memory/MEMORY.md
/.workbuddy/memory/topics/build.md
/.workbuddy/memory/topics/sec.md
/.workbuddy/memory/topics/misc.md
/.workbuddy/memory/2026-09-15.md
/.workbuddy/memory/2026-10-19.md
/.workbuddy/memory/2026-10-22.md
/dev/devcheck-e2e.py
/dev/devcheck-avatar.py
/dev/devcheck-image.py
/dev/devcheck-upload.py
-421
View File
@@ -1,421 +0,0 @@
# 2026-09-15 工作日志
## 落地 P2 推荐批次 #1–3(审核链路一致性 / 文档)
承接 09-11 的全库审阅(无 P0/P1,8 项 P2)。上次征询修复批次未获选择,本次按「继续 +
我标注的建议先做」自主推进**最低风险的一致性/文档类 #1–3**,未动 #4–8(需另行确认)。
### 已改(`internal/security/censor/`)
1. **#1 缓存/送审归一化统一**(censor.go:66-68,76,122,131)
- 原先:缓存按**原文** `text` 查 `GetByText`,实际送审 `RemoveTrustedURL(text)`,落库 `Input:text`。
→ 审计记录与实际送审内容不符;归一化后相同的原文会各占一条缓存、重复调 API。
- 现在:循环外算一次 `normalized := RemoveTrustedURL(text)`,缓存查找 / 送审 / `Input` 三者统一。
- 安全性论证:归一化是纯函数确定性映射;`TrustedHosts` 增减只会让旧条目 miss(重新送审),
不会让「已通过」的旧缓存被错配到新文本上 → 无漏放风险。
2. **#2 阿里云缓存回放分支与七牛对齐**(censor.go:99-110)
- 原先:aliyun 命中缓存**不看 Pass 直接 return**,依赖「aliyun 恒为链尾」这一隐式前提。
- 现在:与 qiniu 一致 —— 仅 `Pass` 才 return,否则 append + continue 交给下一通道复核。
- 等价性:aliyun 现为链尾,continue 后循环结束仍由 `responses[len-1]` 返回**同一份响应**,行为不变;
但消除了顺序耦合,将来若前置 aliyun 也不会短路后续通道。
3. **#3 修正失真注释**(text.go:66)
- 原注释称 `Details`「回放缓存结果时为 nil」**是错的**:
`AliyunTextCensorParser`(aliyun.go:433-461) 与 `QiniuTextCensorParser`(qiniu.go:133-170)
都会从 `RawResponse` 重建 `Details` → 缓存回放同样受严格度策略约束,改档位对历史缓存也生效。
- 已改为准确描述。**结论:审核缓存回放无 fail-open**(09-11 的怀疑已排除)。
### 验证
- `gofmt_keep_crlf.py` 两个文件 exit=0,CRLF 保留(censor 147 / text 141,loneLF=0)。
- `go build ./internal/security/censor/...` **EXIT=0**(定向构建,~秒级;用 goproxy.cn + overlay)。
- `go test ./internal/security/censor/` **ok 2.467s** —— 确认严格度策略与回放行为未被破坏。
### 顺带已完成 #6
- **#6 `User.Email` 改 `json:"-"`**(users.go:89)。
查证:`route/` 与 `internal/` 中**无任何非测试代码读取序列化后的 `.Email`**(模板里的 email 均为表单字段名 /
邮件模板,属 Go template 服务端渲染,不受 json tag 影响;输入走 `CreateUserOptions`/`UpdateUserOptions`,
不经 `User` 模型反序列化)→ 无消费方受损。
验证:gofmt exit=0,`go build ./internal/db/` **EXIT=0**。
### 待确认(P2 #4/#5/#7/#8)
| # | 项 | 我的建议 |
|---|---|---|
| 4 | 投票配额「插入后计数」非原子(route/vote/vote.go:814-822) | 暂缓:需 DB 层条件插入,超额有界且自动结束自愈 |
| 5 | ~~profile Name/Intro/link 无长度校验~~ | **误报,已关闭**:表单层已有校验,见下方更正 |
| 7 | `conf.Database.Schema` 拼 CREATE SEQUENCE(db.go:215-222) | 可选:配置来源非远端输入,纵深防御 |
| 8 | `RemoveTrustedURL` 24 次整串重建(text.go:114-141) | 可跳过:短文本开销可忽略(#1 已顺带把调用从循环内降到 1 次) |
#### ✅ #5 更正:是**误报**,已按「报错式校验」实现后**全部回滚**
- 先按用户选择实现了一版(Name 50 / Intro 500 / link 500 rune,4 语 i18n 词条),随后核对
`internal/form/user.go` 时发现**表单层早已有校验**:
- `Name` → `valid:"required;maxlen:20"`
- `Intro` → `valid:"required;maxlen:100"`
- 各 link → `valid:"maxlen:30"`
- 且确实生效:`internal/form/form.go:16` 引入 `govalid`,`:131` 在 `Bind()` 中 `govalid.Check()`;
handler 顶部 `ctx.HasError()` 承接;报错文案走标签 `msg:` 对应的 i18n 键
(`form.profile.name`="不超过 20 个字符"、`form.profile.intro`=100、`form.profile.linkMax`=30)。
- 结论:我那版是**永不触发的死代码**(超限在 Bind 阶段就被拒),且上限与真实值冲突 → 已完整回滚
(profile.go 的 utf8 导入 / 常量 / 校验块 + 4 份字典词条),`git status` 确认 5 个文件零 diff。
- 教训已写入 MEMORY.md「表单校验(勿重复造轮子)」:查某字段有无校验,**先看 `internal/form/*.go` 的 struct 标签**。
- 附带修正:Postgres 下 `string` 映射为 `text`(无 DB 层长度上限)这一点本身**没错**,
但不构成漏洞 —— 因为表单层已经挡住了。
### 环境
- ~~重包 `./route/user/ ./internal/route/ ./internal/db/` 仍会 30 分钟超时 → 判定为 1C1G 沙箱编译整图慢~~
**❌ 该结论已被推翻,见文末「编译卡死」误判更正。全库 `go build ./...` 实测 EXIT=0。**
---
## 「编译卡死」误判更正(用户指出:1C1G 是正式环境,不是沙箱)
用户明确纠正:**1C1G 是正式生产环境**,我不该拿「沙箱慢」当挡箭牌解释构建超时。重新取证后确认:
**根本没有编译问题,是我把代理挂起误判成了编译慢。**
### 取证过程
| 步骤 | 命令 | 结果 | 结论 |
|---|---|---|---|
| 隔离模块解析 | `go list -deps ./route/user/` | **2.3s EXIT=0** | 模块全在本地缓存,网络不是瓶颈 |
| 编译目标包 | `go build -overlay=... ./route/user/` | **7s EXIT=0** | 之前 240s 超时是伪命题 |
| 关键包逐个 | route/ internal/route/ question/ auth/ security/... db/ mail/ | **全 OK** | |
| 全量 | `go build -overlay=... ./...` | **EXIT=0** | |
| 静态检查 | `go vet -overlay=...` 7 个包 | **EXIT=0** | |
| 测试 | censor/autoban/ipinfo/route/conf/form | **全部 ok**,EXIT=0 | |
**真因**:前几次超时是 `GOPROXY` 未导出 → 卡在 `proxy.golang.org` 连接超时(HTTP=000)。
与机器性能、与 1C1G 都无关。**只要每条 go 命令前 `export GOPROXY=https://goproxy.cn,direct
GOSUMDB=off GOFLAGS=-mod=mod` 就秒级完成。**
⚠️ 方法论教训:**超时 ≠ 慢**。要用 `go list -deps`(纯元数据、不编译)先把「模块解析」和
「编译」两层拆开,否则会把网络挂起误判成性能问题,进而拿「环境」当借口。
---
## 复核:追问邮件 token + 其他严重错误
### 1. 追问邮件确实没带 token(用户怀疑成立)
- `internal/mail/mail.go:58-65` `SendNewFollowUpMail` 链接 `%s/_/%s/%d`,**无 `?t=`**。
- 收件人是**箱主** `pageUser.Email`(follow_up.go:131),箱主靠登录态查看 → **不是权限漏洞**。
- 但有两处真实问题:
1. **不一致**:同样发给箱主的 `SendNewQuestionMail`(mail.go:25)**带** `?t=`。
2. **具体失效场景**:若该提问被箱主设为私密(`IsPrivate=true`),箱主在**未登录设备**点邮件链接
→ `Questioner` 中间件(question.go:61-66)判定 `IsPrivate && !IsLogged && token 不匹配`
→ **302 回首页,看不到追问**。新提问邮件因带 token 则不会。
- 建议:给 `SendNewFollowUpMail` 补 token 参数,与 `SendNewQuestionMail` 对齐。
- 关键的那封(**发给匿名提问者的回答通知**)`question.ReceiveReplyEmail` 是**带 token 的**:
follow_up.go:185、question.go:214 → `SendNewAnswerMail(..., question.Token)` ✓ 无问题。
### 2. 新发现 P2:提问 token 仅 6 位且爆破无限流
- `internal/db/questions.go:95` `Token: randstr.String(6)`;`randstr` 用 `crypto/rand` +
base62 → 62⁶ ≈ 5.68×10¹⁰,**仅 35.7 bit**(随机源没问题,是长度短)。
- `GET /_/{domain}/{questionID}?t=` **无 rateLimitMiddleware**;猜错由 `Questioner` 返回
**302 → /**,而 `autoban` 只统计「敏感路径」与「404」(404 默认关闭)→ **完全不计入封禁**。
- token 仅在「未回答 或 私密」且非箱主时才需要,攻破即=读私密/未公开提问。
- 可行性:单条定向 ~2.8×10¹⁰ 次不可行;但 questionID 可枚举、只需命中**任意一条**,
受保护提问 N 条时期望 5.68×10¹⁰/N 次 → 量大时理论可行。
- 低成本修复:`randstr.String(6)` → `randstr.String(12)`(≈71 bit)。存量 6 位 token 不受影响
(列是 string,无长度校验)。或对该 GET 路径加限流。
### 3. 已复核无问题(勿再重复查)
- **密码**(`internal/db/password.go`):bcrypt cost 10;旧 HMAC-SHA1 用 `subtle.ConstantTimeCompare`
常量时间比较并透明升级;哈希失败**不降级**为弱哈希(返回空串=锁定);bcrypt 72 字节截断 ✓
- **越权**:`Delete/SetPrivate/SetPublic/TogglePin/SetRead/SetUnread` 全部由 `canDelete`
(question.go:72 `ctx.IsLogged && ctx.User.ID == pageUser.ID`)把关;
`AnswerFollowUp`(follow_up.go:143) 与 `PublishAnswer`(question.go:154,161) 双重校验 ✓
(`ctx.Redirect` 会写响应从而终止后续 handler,早退有效)
- **CSRF**:全局挂载,SessionKey=uid 正确,有回归测试 ✓
- **会话**:登录 RegenerateID、pwd_ver 失效、HTTPOnly + SameSite=Lax ✓
- **密码找回**:验证码 24h TTL + 用后即删 ✓
- **SQL 注入**:全参数化 ✓
- **token/邮箱泄露**:`Question.Token` 与 `User.Email` 均为 `json:"-"` ✓
- **审核**:提问/回答/追问/投票/私聊统一走 `moderation.Check` ✓
---
## 落地 A+B(追问邮件补 token + 提问 token 6→12)
按「继续未完成的任务」指示,执行上一条报告的 A+B 两项变更。已用 gofmt_keep_crlf 保 CRLF。
### A. 追问邮件(发给箱主)补 token,与 `SendNewQuestionMail` 对齐
- `internal/mail/mail.go:58` `SendNewFollowUpMail` 签名加 `token string`,链接改为 `%s/_/%s/%d?t=%s`。
- `route/question/follow_up.go:131` 调用处补传 `question.Token`。
- 目的:箱主在**未登录设备**点开**私密**提问的追问邮件时也能凭 token 查看(否则被 Questioner 重定向首页)。
- 仅一个调用方,构建已验证签名一致。
### B. 提问 token 长度 6 → 12
- `internal/db/questions.go:95` `randstr.String(6)` → `randstr.String(12)`(≈35.7→71 bit)。
- 存量 6 位 token 不受影响(列是 string,无长度校验,旧 token 仍有效)。
### 验证
- `go build ./route/question/ ./internal/mail/ ./internal/db/` → EXIT=0
- `go build ./...`(overlay)→ EXIT=0
- `go vet` 三包 → EXIT=0
- `internal/db` 单元测试 FAIL 为已知环境限制(需真实 PostgreSQL/MySQL,报 `Unknown database type: ""`),
与本次改动无关,可跳过;security/autoban/censor/ipinfo、route 测试均 ok。
### 待定(未做,按需)
- CHANGELOG.json 是否补一条 entry(未做:需确认版本号与条目格式)。
---
## 用本地 Postgres 跑通 db 测试并修真实缺陷(用户提供 127.0.0.1:5432)
### 测试库环境变量(`internal/dbutil/testing.go:NewTestDB`)
- `DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=<pwd> DB_HOST=127.0.0.1 DB_PORT=5432`
DSN = `postgres://$DB_USER:$DB_PASSWORD@$DB_HOST:$DB_PORT?sslmode=disable`(不带库名,连上后
自动 `CREATE DATABASE tamabox-test-<rand>`,测试结束(未失败时)DROP)。
- 仍需 `GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod` + webp overlay。
### 修的真实缺陷(生产代码,2 处)
- `internal/db/questions.go:DeleteByID`:级联删追问/上传图 + 回退提问配额时**直接调包级全局单例**
`FollowUps`/`UploadImages`/`FeatureControls`。这些单例只在 `db.Init` 装配 → 测试/部分初始化场景 nil → **panic**。
改为 `NewFollowUpsStore(db.DB)` / `NewUploadImagesStore(db.DB)` / `NewFeatureControlsStore(db.DB)`,
用自身 store 的 `*gorm.DB`。生产行为不变(`Init` 里所有 store 共用同一 `*gorm.DB`),但去掉了隐藏全局依赖。
- `internal/db/conversation.go:MarkDeletedByOwner`:同类问题(`FeatureControls.IncrementQuota`),同样修。
- 同包 `feature_control.go:355` 的 `FeatureControls.Get` **已有** `if FeatureControls == nil` 守卫,故未动。
### 修的测试缺陷(全在 internal/db,非生产 bug)
1. `users_test.go`:5 处 `want.EncodePassword(); require.Equal(t, want, got)` —— bcrypt 随机盐,
哈希**永不相等**。新建 helper `requireUserEqual`:先 `got.Authenticate(明文)` 校验密码,再对齐
`Password` 与 `ChatMaxMessages`(= `conf.Limits.ChatMaxMessages`,默认 200)后比较其余字段。
2. `testUsersDeactivate`:复用已被 `testUsersCreate` 占用的 `[email protected]`/`e99` → 改唯一值。
3. `users_test.go`/`upload_images_test.go` 去掉顶层 `t.Parallel()`:子测试共用一张表、cleanup 会
`TRUNCATE ... RESTART IDENTITY`,并行时多个子测试拿到相同 id/邮箱 → 撞唯一键。
4. `upload_images_test.go`:cleanup 只 truncate `upload_images`,而链接表 `upload_image_questions`
**无 FK 级联**(复合主键 `upload_image_id+question_id`),残留行让下一子测试撞 `_pkey`
→ 补 `cleanup("upload_images", "upload_image_questions")`。
5. `UpdateBlockWords/normal`:期望 `HarassmentSettingNone`("none")但新用户实际 `""`
(模型字段无 `gorm:"default:"` 标签)。`""` 与 `"none"` 生产行为等价(只有 `==register_only` 才限制;
模板 profile.html:851 也只在 `register_only` 时勾选)→ **非生产 bug**。改成先显式
`UpdateHarassmentType(RegisterOnly)` 再断言未被误改(更强的测试,且不把 `""` 奉为期望)。
### 验证
- `go build ./...` **EXIT=0**、`go vet ./...` **EXIT=0**
- `go test ./...`(含 DB)**全绿 EXIT=0**:internal/db 57s、route/vote、internal/route、templates、
i18n、conf、form、cache、imagecheck、mail、shared、template、security/* 均 ok。
- 行尾:`internal/db/*` 与 `internal/mail/mail.go` 原本就是 LF,`route/question/*`、`route/vote/vote.go`、
`internal/route/route.go` 是 CRLF —— **仓库本来就是混合行尾**;本次编辑未改变任何文件原行尾(无 diff 污染)。
### 结论
- 除上述「全局单例 nil 依赖」(健壮性隐患:测试与部分初始化场景必崩)外,**未发现新的生产逻辑 bug**。
- db 测试套件此前的一堆失败**全部是测试自身缺陷**:bcrypt 直接比哈希、测试数据撞车、并行共享表、漏清链接表。
---
## 修复 HarassmentSetting 潜在隐患(schema 默认值 + 启动期归一)
用户确认「影响不大就做」。改动面很小(3 处源码 + 1 处测试期望 + 1 个新回归测试)。
### 线索
- 模型 `HarassmentSetting HarassmentSettingType` **无 `gorm:"default:"` 标签** → 新用户该列为空串;
而规范取值是 none / register_only。全仓库**没有任何代码比较 `== HarassmentSettingNone`**
(production 只有 `== register_only`,模板 profile.html:851 也只看 register_only)→ **运行期无 bug**,
但将来若有人写 `== None` 判断就会漏判。
### 改动
1. `internal/db/users.go` 模型:`HarassmentSetting` 补 `gorm:"default:'none'"`。
⚠️ **字符串默认值必须带单引号**(`default:'none'`),参考既有 `ModerationStrictness` 的 `default:''`;
裸写 `default:none` 会生成非法 SQL。
2. `internal/db/users.go:Create`:显式 `HarassmentSetting: HarassmentSettingNone`
(确定写库,不依赖 DB 默认值回读)。
3. `internal/db/users.go`:新增 `normalizeHarassmentSetting(db)`,把历史空值(''/NULL)归一为 none,幂等。
4. `internal/db/db.go:Init`:在 `normalizeChatQuota` 之后调用,失败仅告警不阻断启动
(与 `backfillFeatureControls`/`reconcileLegacyChatEnabled` 等既有惯例一致)。
### 测试
- `users_test.go` helper `requireUserEqual` 补 `want.HarassmentSetting = HarassmentSettingNone`。
- 新增 `TestHarassmentSettingNormalize`:新建用户即 none + 历史空值经归一后为 none。
### 实测证据
- 临时探针查 `information_schema`:`harassment_setting` 的 `column_default = "'none'::text"` ✅
(AutoMigrate 确实把列默认值改成了 none;探针文件跑完即删)。
- `go build ./...` / `go vet ./...` EXIT=0;`go test ./...`(含 DB)**全绿**;gofmt 无 diff;
行尾未变(users.go CRLF、db.go/users_test.go LF)。
### ⚠️ 新踩到的 gofmt 陷阱
在**文档注释**里写 `''` 会被 gofmt 重排成 `”`(Go 1.19+ 的 doc comment 规范化:`` `` ``→`“`、`''`→`”`),
导致 `gofmt -l` 反复报未格式化。**注释里避免使用 `''`**(改写为「空字符串」等文字)。
---
## 账号停用体验修复:独立成功页(/deactivated)
### 问题
停用账号后 `DeactivateProfileAction` 把用户重定向到 `/login`,只在登录页顶部弹一条 flash。
用户会觉得「被丢回登录页」,下意识以为操作没生效。
诊断结论:flash **确实渲染**(Flush 清 session 数据但保留 session,flash 能带到下一页),
真正的体验错配是「停用后落点仍是登录页」——而账号已删/停,用户根本不可能再登录。
### 方案(用户选「独立成功页(推荐)」)
1. `route/user/profile.go`
- 新增公开 handler `DeactivatedProfile`:`ctx.SetTitle(i18n.T(ctx.Lang(), "deactivated.title"))` + `ctx.Success("user/deactivated")`。
- `DeactivateProfileAction` 末尾 `ctx.Redirect("/login")` → `ctx.Redirect("/deactivated")`,**并移除原来的 `SetSuccessFlash`**(页面自身即提示)。
- 顺手修正 `UpdateProfile` 里一处陈旧注释(原写「顺序与 DeactivateProfileAction 保持一致…随后跳登录页」,停用路径已不再如此)。
2. `internal/route/route.go`:在公开区(`/explore` 旁)加 `f.Get("/deactivated", user.DeactivatedProfile)`。
**必须是公开路由**:停用后会话已 Flush、`reqUserSignIn` 会把人弹回登录页,正是要避免的现象。
3. 新建 `templates/user/deactivated.html`:复用 `question/success.html` 的范式(大 ✓ 图标 + 卡片 + 单按钮返回首页)。
`templates/embed.go` 是整目录 `//go:embed ... user ...`,新模板**自动被嵌入**,无需改 embed 指令。
4. i18n:四份字典都加 `deactivated.title` / `deactivated.desc` / `deactivated.backHome`
(`TestDictionaryComplete` 要求 zh_cn/zh_tw/en/ja **键集合完全一致**,漏一份必挂)。
同时**删除已无引用的 `admin.flash.deactivated`**(4 份字典同步删;删它不影响对齐组,因为该组最长键是 `admin.flash.blockWordsTooMany`)。
### 验证
- `go build ./...` / `go vet ./...` EXIT=0;`go test ./...`(含本地 PG)**全绿**,
其中 `internal/i18n`(键一致性)、`internal/route`、`templates`、`internal/template`(模板解析)均 ok → 新模板可被解析、键齐备。
- gofmt 无 diff;行尾未变(4 份 dict + route.go 保持 CRLF,`route/user/profile.go` 实为 **LF**)。
### 备注
- `route/user/profile.go` 的行尾是 **LF**(与「route/** 皆 CRLF」的旧印象不符,按文件单独确认即可)。
---
## 🔴 P0 修复:账号停用后同一邮箱/域名永远无法重新注册
用户在测试环境 `https://box-dev-api.337845818.best` 注册 `1223383917@qq.com` / `tamakyi` 报 500:
```
SELECT ... WHERE email = '...' AND deleted_at IS NULL -> rows:0
INSERT INTO users ... -> ERROR: duplicate key value violates unique constraint "idx_users_email_unique" (SQLSTATE 23505)
ERRO Failed to create new user error="create user: ERROR: duplicate key ..."
```
### 根因(本地已实测复现,日志形态与线上一致)
`internal/db/users.go` 的 `Deactivate` 用的是 **GORM 默认的软删除**:
`db.WithContext(ctx).Model(&User{}).Delete("id = ?", u.ID)` → 只写 `deleted_at`,**行仍在库里**。
而 `User` 的 `email` / `domain` 是 `uniqueIndex`(**索引不含 deleted_at**),于是:
- 查重 `validate()` 走 gorm 软删除作用域 → 看不见这行 → 放行;
- INSERT 被数据库唯一索引拦下 → `Create` 的 TOCTOU 兜底再查一次仍看不见 → 抛底层 23505 → handler `default` 分支 → 500。
复现证据(临时测试,已删):停用后 `Unscoped` 查该邮箱 → 行仍在且 `deleted_at.Valid=true`;同邮箱重新注册 → 与线上同样的 23505。
**旁证**:`internal/cmd/ban.go:64` 早有注释「用 Unscoped 查找,支持封禁已软删除(自助停用)的账号」——
说明「自助停用会留软删除行」是既有事实,但没人把「唯一索引占位」这层后果接上。
同类先例:`internal/db/vote_submissions.go:234` 已因同一原因改用 Unscoped 物理删除。
顺带发现:`BanUserAndCleanup` 的「连带清理」也只是软删除子表(Question/FollowUp/Conversation/Message… 都嵌 `dbutil.Model`),
数据实际仍在库;也与停用页「所有数据将被删除」的承诺不符。
### 改动(`internal/db/users.go`)
1. `Deactivate` → 调 `db.purgeUser(ctx, u.ID)`;`BanUserAndCleanup` 同样委托给它。
2. 抽私有 `purgeUser`:级联删除**全部改为 `Unscoped()` 物理删除**(追问/消息/消息图片/会话/提问-图片关联/提问/拉黑/功能开关/用户行)。
3. `Create` 兜底加固:唯一键冲突且 `validate` 仍查不到时,用新增的 `duplicateFieldErr()`(Unscoped)判定是 email 还是 domain 被占,
返回 `ErrDuplicateEmail`/`ErrDuplicateDomain` 友好提示,而不是把 23505 抛成 500。(`isDuplicateKeyError` 复用 vote_submissions.go 里的)
4. 回归测试 `TestDeactivatePurgesUser`:停用后 Unscoped 也查不到用户行、提问被清理、同一邮箱可重新注册。
### ⚠️ 过程中自己踩的 GORM 大坑
把级联删除改成「先 `tx = tx.WithContext(ctx).Unscoped()` 再逐条 `tx.Where(...)`」——
**事务句柄 clone==0,复用同一个句柄会让上一条语句的 `Where` 条件串到下一条上**,
SQL 变成 `DELETE FROM follow_ups WHERE <follow_ups条件> AND <messages条件>` → `字段 conversation_id 不存在 (42703)`。
✅ 正确写法:**每条语句都重新取句柄** `tx.WithContext(ctx).Unscoped().Where(...)`(原代码本来就是每条 `tx.WithContext(ctx)`,是我改坏的)。
这条已写进 MEMORY.md。
### 验证
- `go build ./...` / `go vet ./...` EXIT=0;`go test ./...`(含本地 PG)**全绿**;gofmt clean;行尾未变(users.go CRLF、users_test.go LF)。
- 测试环境实测(脚本 `dev/devcheck-e2e.py`):
- ✅ 注册 / 登录 / `/user/profile` / `/user/questions` / `/user/votes`(302→`/user/questions#votes`) / 提问(返回提问成功页)/ 新建投票页 / 停用页 / **停用后跳 `/deactivated` 成功页** 全部正常;
- ✅ `/deactivated` 四语言标题渲染正确(账号已停用 / 帳號已停用 / Account deactivated / アカウントを停止しました);
- ❌ **停用后同邮箱重新注册仍 500** —— 测试环境跑的是**旧构建**(`/deactivated` 已在,但没有 purgeUser 修复),需重新部署。
- ⚠️ 测试环境 DB 里已留下本次测试产生的孤儿行,需清理:
`tbe2e0915122816@example.com`(仍在线)、`tbe2e0915122934@example.com`、`tbcheck0915122657@example.com`(均已停用=软删除占位);
用户自己的 `tamakyi` / `1223383917@qq.com` 同样是软删除占位(这就是他报错的直接原因)。
清理方式:`tamabox ban --email <邮箱> --yes`(CLI 支持查软删除账号),或直接 SQL 删 `users.deleted_at IS NOT NULL` 的行。
---
## ⚠️ 方案变更(覆盖上一节):停用改为「保留身份占位 + 清空内容」
用户表示**尚未部署**,并提出:「曾经注册过的账户停用后,再次注册时应提示『该邮箱已被占用但账户已停用,如需重新使用请联系站长』」。
经询问确认两点:
1. 停用形态 = **保留身份占位**(用户行软删除)+ **物理删除内容数据**(停用页「所有数据将被删除」的承诺仍然成立);
2. 「联系站长」**复用页脚已有的「找站长」入口**(`site.footer.admin` → `https://box.shiroko.one/_/tamakyi`),不新增站长邮箱配置项。
→ **上面「Deactivate 改成物理删除」的做法已被取代**,但其中「级联内容必须 Unscoped 物理删除」的结论仍然有效。
### 最终实现
- `internal/db/users.go`
- `purgeUserContent(ctx, tx, userID)`(包级函数):把内容级联删除抽出来,全部 `Unscoped()` 物理删除。
- `Deactivate` = `purgeUserContent` + **软删除用户行**(保留 email/domain 占位)。
- `BanUserAndCleanup` = `purgeUserContent` + **物理删除用户行**(唯一差别;彻底释放邮箱/域名)。
- `validate` 重写为两轮:先查活跃账号(ErrDuplicateEmail/Domain),再 **Unscoped** 查已停用占位行
→ 新增 `ErrDeactivatedEmail` / `ErrDeactivatedDomain`;辅助方法 `fieldTaken(ctx, column, value, includeDeleted)`。
- 新增 `Reactivate(ctx, id)`(清 `deleted_at`,未停用时返回 ErrUserNotExists),已加进 `UsersStore` 接口。
- 上一轮加的 `duplicateFieldErr` 已删除:`validate` 现在本身就覆盖了软删除行,无需在 Create 兜底里重复判定。
- `route/auth/register.go`:把两个新错误映射到 `auth.errDeactivatedEmail` / `auth.errDeactivatedDomain`。
- i18n ×4:新增 `auth.errDeactivatedEmail`、`auth.errDeactivatedDomain`、`deactivate.note`(键集合保持一致)。
- `templates/user/deactivate.html`:desc 下方加一行 `deactivate.note`(uk-text-small uk-text-muted),
提前告知「邮箱/域名会作为身份占位保留,无法用同一邮箱重新注册,如需恢复请找站长」。
- **新增 CLI `internal/cmd/restore.go`(`TamaBox restore`)**:`--email|--domain [--password] [--yes]`,
清掉 deleted_at 完成恢复;`--password` 会重置密码(必须先恢复再改密码——`UpdatePassword` 走普通作用域)。
### 🐛 顺带修掉的隐藏问题
**`cmd/tamabox.go` 里根本没注册 `cmd.Ban`** —— `internal/cmd/ban.go` 写了但命令不可达(`TamaBox ban` 不存在)。
现已把 `cmd.Ban` 与 `cmd.Restore` 一起注册进 `app.Commands`。
### 验证
- `go build ./...` / `go vet ./...` EXIT=0;`go test ./...`(含本地 PG)**全绿**;gofmt 全部 clean;行尾未变。
- 新增/改写测试:`TestDeactivatePurgesUser`(占位行保留 + 内容清空 + 同邮箱得 ErrDeactivatedEmail + 同域名得
ErrDeactivatedDomain + Reactivate 后可正常注册 + 重复 Reactivate 报 ErrUserNotExists)、
`TestBanUserAndCleanupRemovesRow`(封禁物理删除且可重新注册)。
- **CLI 端到端实测**(临时库 `tamabox-clitest`,用 `NEKOBOX_CONFIG_PATH` 指向临时 ini,跑完已 DROP + 删除产物):
- `restore --email clitest@example.com --yes` → deleted_at 变 NULL ✅;重复恢复 → 正确拒绝「未被停用,无需恢复」✅
- `restore --password newpass12345` → 密码重哈希 + password_version=1 ✅;密码过短/邮箱不存在 → 明确报错 ✅
- `ban --domain banme --yes` → users 与 questions 计数归零(物理删除)✅
- ⚠️ `conf.Init` 还需要 `server.salt` / `server.xsrf_key` / `app.external_url` 非空,配置路径可用
`NEKOBOX_CONFIG_PATH` 覆盖 —— 这是本地跑 CLI 冒烟的关键(仓库里只有 `conf/app.sample.ini`)。
- 测试环境 `devcheck-e2e.py` 的核心回归项已改为断言「重注册被明确拒绝并出现『已停用』提示、且不是 500 兜底」;
在**未重新部署的旧构建**上会 FAIL 并打印「サーバー内部エラー」,正是需要重新部署的信号。
---
## 部署后巡检扩容:投票全链路 + 登录态资产(28/28 全绿)
**背景**:用户重新部署后要求「所有功能都试一遍」。此前 `devcheck-e2e.py` 只验证了「新建投票页 GET」,
**投票创建(POST)这条主链路从未端到端跑过**,是覆盖盲区。
**改动**:`dev/devcheck-e2e.py` 扩到 **28 项**,新增两组:
1. `check_vote_flow(owner, base)` —— 投票全链路
- POST `/user/votes` 建单选投票并 `action=publish` → 断言 302 → `/user/questions#votes`
- GET `/user/questions` 断言标题可见,并从 `copyShareURL('...')` 取 **LinkToken**、从 `/user/votes/(\d+)/edit` 取 **ID**
- 匿名 GET `/vote/<token>` → 200 且含标题;从 `name="option_id" value="..."` 取选项 ID
- 匿名 POST `/vote/<token>/submit`(`option_id` + 数学验证码 + `_csrf`)→ 302
- GET `/user/votes/<id>/export` 断言 **`总参与,N` 这行的 N == 1**
- 草稿:`action=save` → 302 到 `/user/votes/<id>/edit?saved=1`
- 表单型:`type=form` + `form_fields` JSON(field_type/label/required/max_length)→ 发布 302
2. `check_user_assets(owner)` —— 登录态接口/导出
- `/api/v1/user` 返回 JSON、`/explore` 200、`POST /user/profile/export-json`(JSON)、
`POST /user/profile/export`(xlsx,校验 content-type)、停用页 `deactivate.note` 结构断言
**踩到的坑(都已在脚本里修掉)**:
- 单选投票导出 CSV 的**末行是 `总参与,N`**,与各选项票数行同为 `,数字` 结尾 →
用 `,\d+$` 一把捞会把总参与也累加进去(实测 `[1,0,0,1]` 求和成了 2)。
**必须单独匹配 `总参与,(\d+)`**。
- `urllib` 读 **HTTPError 的响应体本身也会超时**(`c.get("/i18n/xx?...")` 这类 302 最易触发),
异常从 except 块里抛出会把整轮巡检打断。已把 body 读取包在 try/except 里,并在 `__main__` 外层加兜底
——中断时仍打印已完成项,而不是只留一段 traceback。
- ⚠️ 复盘我自己的一个假警报:用 `T=/c/Users/Tamakyi\ Gekka/...` 做 gofmt 比对循环时,
变量未加引号导致路径断裂 → 全部文件假报 DIFF。**脚本里带空格的路径变量一定要整体加引号**,
单文件手跑同一个文件即为 OK,说明是脚本问题而非代码问题。
**结果**:测试环境 `https://box-dev-api.337845818.best` 跑 `python dev/devcheck-e2e.py` → **28 项,失败 0 项**。
10 个改动过的 Go 文件 gofmt 比对全部 OK(无重排),行尾未变。
**遗留**:巡检每轮会在测试环境留下一个**已停用的身份占位**(邮箱/域名被占用)。
本轮新增 `tbe2e*` 若干;用户自己的 `1223383917@qq.com` / `tamakyi` 也仍是占位状态。
清理:`TamaBox ban --email <邮箱> --yes`(连行物理删,彻底释放)或 `TamaBox restore --email <邮箱> --yes`(恢复)。
-614
View File
@@ -1,614 +0,0 @@
# 2026-10-19
## 部署后复测(强制严格档那批)
- 规则页返回首页已居中:线上 `<div class="uk-margin-large-top uk-text-center">` ✔
- 验证码弹窗按钮已缩短:线上 `>送信<` ✔(旧的「認証して送信」已消失)
- 箱主面板 `/user/questions`:限制期条幅 + 「查看社区规则」链接都在 ✔
- 严格度锁定(admin6 当前 L2):
- `/user/profile` 出现「处置期间审核严格度已锁定为最高档,解除后自动恢复你的原设置」✔
- `select[name=strictness]` 与「保存审核严格度」按钮均带 `disabled` ✔
- 直接 POST `/user/moderation-strictness/update?tab=security` + `strictness=lax` → 302 回 profile,
flash 仍是「已锁定」,且选中档仍为 `strict`(说明未写入)✔
- 踩坑:表单 action 是 `/user/moderation-strictness/update`(**没有** `/profile` 段);用错路径返回 302 到 `/`,
容易误判成「被中间件拦」。另外每次 POST 都要重新取 `_csrf`,用上一页(已跳首页)的 token 会 400。
## 记忆维护
MEMORY.md 一度 19KB 被注入截断,已重写压缩到约 4.9k 字符(8.1KB),保留构建/行尾/embed/审核/处置/CLI 等硬约束,
删去已在日志里的过程细节。
## CLI 文档 + 全量代码审阅(同日)
新增 `docs/cli.md`:命令总览、reports 三种用法与口径、restrict 级别语义/副作用、unrestrict、
hide-public、ban/restore、典型处置流程、排错表、明确不做的事。
审阅中发现并修复(全部本地临时库实测):
1. **`hide-public` 举报闭环静默失效**(真 bug):`build()` 带 `is_private=false`,在 Update 之后拿它当子查询恒为空集
→ 隐藏成功但 `content_reports` 仍 pending。改为 Update 前 `Pluck("id")` 快照,再按 ID 更新/标记。
实测:3 条 pending → accepted,未波及其他箱主。
2. **整箱分享卡未拦截**:`ShareCard` 直接走缓存,L1–L3 仍能生成整箱图。已在缓存查询前加拦截(fail-closed)。
3. **L2 通知邮件误导**:强制私密后仍给提问者发「你的提问有回答」,链接点开 302。改为 `!forcedPrivate` 才发。
4. **L2 写入顺序**:`SetPrivate` + `AnswerByID` 两条语句有「公开+已作答」窗口,第二条失败还会留下「私密+无回答」。
新增 `Questions.AnswerByIDPrivate`(单条 UPDATE 同时写 answer/is_read/is_private),并补测试 `TestQuestions/AnswerByIDPrivate`。
5. **`ListAPI` 绕过 Pager**:L4 停用与页面访问密码只挡 HTML,`/api/v1/user/{domain}/questions` 仍能整页拉。
已补两道校验(均返回「用户不存在」,不暴露原因)。**页面密码可被 API 绕过是升级前就存在的问题。**
6. **L1 曝光残留**:`GetShowInHome` 的 Count 未过滤(分页总数虚高、末页空白)、热门标签查询未过滤。已补同一子查询。
7. **性能**:`ActiveLevel` 由两次查询改一次(取最高 rank);新增 `moderation.EffectiveStrictnessFor(level, raw)`,
发布路径上不再重复查库(原先一次发布最多 4 次处置查询)。
8. **证据包口径**:分母是「累计公开回答」不是窗口产出 → 文案加「累计」;新增 `待处理 N` / `已处理` 标记,
避免把已闭合的举报误读成还挂着;补 `q.deleted_at IS NULL`;`--stats` 说明最长回溯 30 天;CLI 对 `active` 做 nil 守卫。
验证:`go build ./...` EXIT=0;db/i18n/templates 测试全绿;CLI 全流程(restrict L3 → 证据包 → unrestrict)实测通过;
临时库已 DROP。
遗留(未改,已向用户说明):L4 不覆盖 `/vote/{token}`;`restrict` 没有 `--days` 上限;处置不发邮件给箱主(只有登录后条幅);
`abuseAlertCooldowns` 进程内 map 无清理(重启即失效)。
## 第二轮:处置通知邮件 + 超长确认 + 冷却表清理
- 新增 `templates/mail/restriction-notice.html` + `mail.SendRestrictionNoticeMail(RestrictionNotice)`:
文案由调用方按**箱主自己的语言**(`users.language`,空则 `conf.App.DefaultLang`)预翻译后传入,
模板本身单语言。**不含 `--reason` 原文**,只有级别名/到期/规则链接/申诉渠道。
i18n 新增 10 个键 ×4 语言:`mail.restriction.*`(6)+ `restriction.level.L1..L4`。
- `restrict`:新增 `--force`(`--days > 90` 必填,否则直接报错)与 `--no-notify`;交互模式下超长处置
还要再输一次级别名(`--force --yes` 视为两次确认)。发信失败只 warn + 终端提示,**不回滚处置**;
发信加了 20s 超时(gomail 无 Timeout 字段,裸 Dial 会等系统 TCP 超时)。
- `abuseAlertCooldowns` 加惰性清理(>1024 条时删除已过 24h 的项)。
- 顺带:`/user/questions` 限制期条幅改用 `restriction.level.*` 的名称(「收窄公开」)而不是裸 `L2`。
验证:真实 SMTP sink 实收两封(ja 与 zh-CN),标题/级别名/到期/规则链接均正确、`--reason` 未出现、
链接未被 `ZgotmplZ` 转义;`--days 120` 无 `--force` 被拒;黑洞 SMTP 下 23s 内返回(未阻塞两分钟)。
`go build ./...`、`go vet`、db/i18n/templates 测试、checktpl 全绿。
遗留:`unrestrict` 不发通知(只解禁);`restrict` 对 `--days` 无上限(设计如此)。
## 第三轮:解除处置也发通知
- 复用同一封模板 `mail/restriction-notice.html`:`appeal` 段改为 `{{if .appeal}}` 可选,
解除通知不传该字段就整段省略。
- 新增 i18n 键 ×4:`mail.unrestrict.heading/body/note`(body 带域与级别名;
note 明确写「处置记录保留用于审计、私密内容不会自动恢复」)。
- `unrestrict` 新增 `--no-notify`;撤销前先 `ActiveLevel` 取级别(撤销后读不到),
撤销后按箱主语言发解除通知。`affected==0` 直接返回、不发信。
- 抽出公共件:`sendNotice`(20s 超时)、`reportNoticeResult`(统一成功/失败输出)。
- 文档补充:`unrestrict` 的 `--no-notify`、解除通知内容、以及**到期自动解除不会发信**(无调度器)。
实测(本地 SMTP sink,2 封):处置信含级别名/到期/申诉段;解除信含「已解除」+ 私密内容提示、
**无申诉段**;两封都不含 `--reason`。`--no-notify` 两处都不发信;无生效处置时不发信。
`go build ./...`、db/i18n/templates 测试、checktpl 全绿。
## 上线前最终验收(部署后实测)
- 公开页:`/` `/rules` `/restricted` `/deactivated` 全部 200;`/rules` 四语言标题正确。
- 箱主面板(admin6,L2):条幅显示「收窄公开」+ 规则链接;严格度面板锁定提示 1 处、下拉 disabled。
- **L2 写入路径实测**(关键,因为改成了 `AnswerByIDPrivate` 单条 UPDATE):
对未回答题 q95 作答 → 302 回题目页 → flash「已保存为私密:当前处于平台处置期,回答不会公开」→ 匿名访问 302。
- 公开列表 API `/api/v1/user/{domain}/questions` 正常返回 `{code,data:{data,next_cursor,has_more}}`(新增的 L4/密码校验没有打断正常路径)。
- 整箱分享卡:受处置箱主 302(拦截生效);未处置箱主 500,原因是**测试站未配置字体**(`分享图生成失败:字体文件未配置或无法加载`),与本次改动无关。
这一点反而证明拦截位置在生成之前。
- 未能自测:CLI 发信(restrict/unrestrict 通知)需要在服务器上跑命令,只能由用户执行。
注意:admin6 现有测试题 73/88/89/90/94/95;其中 95 已被我回答并存为私密(内容带 `restrict self-check`)。
admin6 的 L2 于 2026-10-19 21:29 自动到期。
## 邮件模板统一(用户反馈:页脚与其它邮件不一致)
- 原因:`restriction-notice.html` 是我新写的,页脚用了自造的 `© 2026 TamaBox`,与项目其它 11 封邮件的
「此邮件由系统自动发送,请勿直接回复。<br/>{{ .year }} 狼的提问箱」不一致。
- 处理:整封模板按 `question-reported.html` 的结构重写(24px 标题 + `{{.time}}` 副行、同样的卡片/页脚/外间距),
`time` 由 `mail.SendRestrictionNoticeMail` 统一注入(调用方不用各自拼时间)。
- 实测(本地 SMTP sink):页脚文本与 question-reported 完全一致,`© ... TamaBox` 已消失,头部时间行存在。
- 注意:站点名在所有邮件模板里都是硬编码「狼的提问箱」,不是配置项;改站名要一起改 12 个模板。
## 页脚精简(移动端一行放不下)
- 去掉「盘」链接(`https://share.shiroko.one`,zh 文案就叫「盘」),并删除已无引用的
`site.footer.drive` 键(四语言同步删,保证 `TestDictionaryComplete` 词条数一致)。
- `site.footer.rules`:zh-CN「社区规则」→「规则」,zh-TW「社群規則」→「規則」(en/ja 本就短)。
- 现在页脚一行:规则 | 找站长 | 公告 | 聊天 | 状态 | 主页。
- 注意:`templates/base/footer.html` 用 `|` 分隔,行首行尾各带一个,删整行时不会留下悬空分隔符(已核对)。
## 页脚改两行(用户改主意:盘恢复)
- 恢复 `https://share.shiroko.one`「盘」链接与 `site.footer.drive` 键(zh-CN 盘 / zh-TW 盤 / en Drive / ja ドライブ)。
- `site.footer.rules` 文案恢复:社区规则 / 社群規則(en/ja 不变)。
- 布局两行(靠 `<br>` 断行,不用额外 CSS):
第一行 `社区规则 | 找站长`,第二行 `盘 | 公告板 | 聊天室 | 监控板 | 导航页`。
- 键插在 `site.footer.rules` 之后(组末),空格数按同长度键复制,gofmt 对齐不变;dict 仍 CRLF。
- 验证:build / checktpl / i18n / templates 测试全绿。
## 页脚改为「从少到多」四行
版权行与存活计时移到最后两行(原来在最上面),整块按文字量递增:
`社区规则 | 找站长` → `盘 | 公告板 | 聊天室 | 监控板 | 导航页` → `Powered by NekoBox | ICP` → `提问箱已成功存活:…`。
实现只调整了 `<br>` 的位置,仍共 4 个 `<br>`,所以块高与顶部空行跟原来一致(未新增占位)。
注意:`box_runtime` 的脚本按 id 取元素,移动位置不影响;但 `<br>` 数量别随意增删,否则页脚高度会变。
## 前端样式体检(只出结论,未改代码)
量化事实(脚本统计 templates/**/*.html):
- 全站 **0 个 .css 文件**,样式全部内联:`base/header.html` 内含 **17.5KB CSS + 10.6KB JS**(模板总 42.5KB),
footer 3.0KB JS;全模板共 **24 个 `<style>` 块**,`user/profile.html` 自身还有 11.2KB CSS + 22.8KB JS(总 62KB)。
- `--tb-*` 设计变量 **只在 `html[data-theme="dark"]` 里定义**,light 主题没有 `:root` 一套;
调用处全靠 fallback(`#e9e9e9` / `#e5e5e5` / `#fafafa` / `#333` / `#666` 混用),explore 等页面还写死色值。
- **深色模式 FOUC**:`applyTheme(getTheme())` 在 body 内、`<nav>` 之后执行(header.html:812),
深色用户刷新时顶栏会先白闪一下。修法:把 theme 初始化内联脚本提到 `<head>`。
- 结构:`uk-container-xsmall`(750px)由 header 统一输出,仅 explore/vote/chat 页面自套 small;
`question/list.html` 仍用 `<center>`、内联 `style="min-height:260px"`,用户名/简介颜色由用户自选压在背景图上,无 scrim → 可读性无保障。
- 首页 `home.html` 是纯 UIkit 卡片 + `<hr>` + `style="color: red"` + `<pre>` 统计 + 首页挂 Waline(250 行 locale)。
建议分档(未实施):P0 = light/dark 双套 token + 圆角/阴影/中文字体栈 + 首页重排 + 背景图 scrim;
P1 = header CSS 抽成 `static/css/*.css`(注意 `embed.go`/`static/fs.go` 都是显式清单)+ FOUC 修复;
P2 = 图标统一(UIkit icons 取代 emoji/位图 png)、标签减量、viewpoint/meta 去重。
## P0 前端美化(已实施)
用户拍板:卡片**结构不变,只加圆角和柔阴影**;先做 P0,不抽 CSS 文件。
改动文件:
- `templates/base/header.html`
- head 顶部新增**主题初始化内联脚本**(深色用户不再白闪;原 `applyTheme` 在 body 里、nav 之后执行)。
顺手删掉 body 脚本里的 `applyTheme(getTheme())`,只保留切换逻辑。
- 新增**设计令牌块**:`:root` 亮色 + `html[data-theme=dark]` 暗色,同名 14 个(bg/surface/surface-2/surface-hover/
border/text/text-secondary/text-muted/primary/primary-weak/radius/radius-sm/radius-xs/shadow/shadow-hover/font)。
原暗色块里的 7 个变量定义已删除(现在统一在令牌块)。
- 新增**基础质感层**:html+body 底色与字体栈(system-ui + 苹方/冬青黑/雅黑)、卡片 12px 圆角 + 双层柔投影、
按钮/输入 8px、标签 6px、alert 10px、modal 14px、导航栏白底、`:focus-visible` 描边、`tabular-nums`、
`prefers-reduced-motion`。
- `.tb-cover`:封面遮罩;默认浅色(配 `.uk-light` 的黑字兜底),`.tb-cover-dark` 为深色。
- `.tb-img-uploader` 上收到 header(原来 item / new-question 各写一份**内联 style**,写死 #f8f8f8 → 深色下露白)。
- `templates/home.html`:整页重排为 hero(标题+一句话+探索/规则按钮)+ 3 张功能卡 + 域名迁移公告
+ 提醒列表 + `<details>` 折叠更新日志 + 3 个数字统计卡(`.SiteStats` 数值字段,30 分钟 JS 刷新改为按字段更新)。
全部原有信息保留;页内 `style=` 归零。
- `templates/question/list.html`:封面加 `.tb-cover`(仅当有背景图),新增 12 行 JS 按用户名颜色亮度
决定遮罩方向;`min-height:260px` 内联改为 `.tb-profile-cover`。
- 6 个页面样式块里的硬编码色值收敛到令牌(explore/question-list/profile/list/item/vote-page,共 ~84 行),
CRLF 文件用字节级替换(未改动行尾)。
验证:`go test ./templates/... ./internal/i18n/...` 全绿;`go build`(overlay)通过。
新增 `templates/zz_preview_test.go`:`TEST_PREVIEW=1 go test ./templates -run TestGenPreview` 产出
`outputs/preview-{home,box,explore}-{light,dark}.html`,用无头 Chrome 截图核对过亮暗两套。
⚠️ 本机 **Chrome 新版 headless 会挂住**,必须 `--headless=old`,且页面外部资源要本地化
(`outputs/preview-assets/`,从 cdnjs.snrat.com 取 UIkit 3.3.3;cloudflare/jsdelivr 在本机不通)。
## 封面遮罩 P0 缺陷修复(重要)
**症状**:`.tb-cover::after` 遮罩不渲染,且**完全不报错**——截图对比像素只差 ±1(只看着像"没效果")。
**根因**:UIkit 3.3.3 自带
`.uk-flex::after,.uk-flex::before{display:none}`(优先级 0,2,0),
而封面元素是 `uk-flex`,我写的 `.tb-cover::after`(0,1,1)优先级不够 → 被压成 display:none。
定位方法:`getComputedStyle(el,'::after').display` 得到 `none`;再逐个 `classList.remove` 试 →
去掉 `uk-flex` 立刻变 `block`。最后在 uikit.min.css 里 grep 到那条规则。
**修法**:选择器写成 `.tb-cover::after, .tb-cover.uk-flex::after` 并显式 `display:block`。
**实测效果**(无头 Chrome 截图 + 像素测量,非目测):
- 深色渐变背景 + 白字:封面底部亮度 0.229→0.102,白字对比度 **3.77→6.89**(过 WCAG AA 4.5)
- 线上默认背景(`box_background.png` 2048×1152,接近全白)+ 黑字:0.912→0.971,对比度 19.25→20.42,
即**对默认背景几乎没有可见影响**
- 亮度判定 JS 已验证:`#fff` 会加 `tb-cover-dark`,`#000` 不加。
**教训**:CSS 改动不能只看"规则写上了/类加上了",必须量像素。伪元素 + UIkit 工具类组合要在计算样式层验证。
## 账号背景图的真实链路(回答用户提问时查证)
- 注册时把 `[upload] default_background` **一次性复制**进 `users.background`(`route/auth/register.go:51`),
不是每次渲染兜底 → 之后改配置**不影响存量用户**。
- 页面只用 `.PageUser.Background` 渲染 `data-src`;为空则无背景图、无遮罩。
- 分享卡是另一条路:`route/question/share.go:345` 用 `user.Background`,为空时回退 `conf.App.ShareBackground`。
- 线上 `box.shiroko.one/_/tamakyi` 实际值 = `https://r2.337845818.best/tamabox/box_background.png`。
- 预览里那张"渐变图"是 `zz_preview_test.go` 造的测试数据(data: URI 渐变),**不是产品行为**;
顺带发现 Go 模板会把 `data:` URI 在属性上下文里替换成 `#ZgotmplZ`(真实 http URL 不受影响)。
## 设置页「防护设置」缺白底(用户报)
根因:其它面板都包在 `<section class="tb-pcard">`,只有内容防护面板(profile.html:842)是裸 `div.profile-panel`,
直接露出页面灰底 `--tb-bg`。修法:包一层 `section.tb-pcard`,并把 `.tb-pcard` 的 `background:#fff` 改成
`var(--tb-surface)`(删掉随之冗余的 dark 覆盖)。
实测(计算样式):account 与 security 面板 bg 亮色均 `rgb(255,255,255)`、暗色均 `rgb(36,40,48)`,圆角 10px,完全一致。
顺手把 `security` 加入 `render_test.go` 的面板渲染测试列表(原来只测 account/question/chat/vote)。
⚠️ 教训:用脚本批量缩进/hash 插入时先 dry-run。这次第一版脚本把「空行」也当成需要写 `<section>` 的行,
一次性插了 5 个多余标签;靠立刻 `sed` 复查发现并回滚。
## P1 第一步:CSS 抽离(已完成)
- 新增 `static/css/base.css`(23KB,含注释;gzip 后约 6KB),内容 = 原 header 里的
设计令牌 / UIkit 补丁 / 深色覆盖 / 质感层 / 顶栏响应式 / 标签 / Markdown / 进度条 / 移动端底栏。
- `header.html`:内联 `<style>` 从 17.5KB 降到只剩 1 个(算数验证码,跟着 `{{ if CaptchaType }}` 条件渲染);
改为 `<link href="/static/css/base.css?v=20261019a">`,位置在 UIkit 样式之后(保证覆盖顺序)。
单页 HTML 42.5KB → 30.9KB。
- `static/fs.go` 的 `//go:embed` 显式清单加 `css/base.css`(漏了就是运行期 404 + 全站无样式,编译期不报错)。
- 静态缓存沿用既有 `CacheControl: public, max-age=86400`;靠 `?v=` 版本号破缓存。
**验证方式**:改动前后各截一次同样尺寸的截图做像素比对。
home-light / home-dark / explore-light 三页差异均 **0.024%**,bbox 只有 (11,56)-(29,74) 一个 18×18 方块
= 导航栏随机动物 emoji(每次加载都变),不是样式回归。go build + 模板/i18n 测试全绿。
## 测试环境复测(P1 CSS 抽离 + 防护面板白底,已上线)
- `GET /static/css/base.css?v=20261019a` → 200 / `text/css` / **23088 字节,与本地 `static/css/base.css` 逐字节一致** /
`Cache-Control: public, max-age=86400` / gzip 后 7109 字节。
- 首页 32747 字节(改动前 52747),gzip 10534(改动前 13642);页面内联 `<style>` 只剩验证码 + 首页自己的样式块。
- 设置页(登录 admin6):`data-profile-panel="security"` 下已有 `<section class="tb-pcard">`;
用线上 HTML + 本地 CSS 复算样式 → account / security 两块 `bg=rgb(255,255,255)`、`border-radius:10px`,页面底 `rgb(247,248,250)`。
- `/_/admin6` 公开页:`class="... uk-light tb-cover"`,`:after` 计算值 **display=block, width=689px**
→ 遮罩在真实部署上确实渲染了(UIkit `.uk-flex::after{display:none}` 的坑已解决)。
该用户用户名为 `rgb(0,0,0)`,走浅色遮罩(符合预期)。
- 各页 200:`/`、`/explore`、`/rules`、`/user/questions`;`/user/vote-form` 无参 302 属正常。
- 首页首次访问总字节略增(10.5+7.1 vs 13.6KB gzip),但 CSS 跨页缓存后每页少 3.1KB,且服务端少渲染 17.5KB 模板。
## 专属测试账号(用户指定)
- `https://box-dev-api.337845818.best/_/admin6`,邮箱 `[email protected]`,密码 `adminadmin`。
- 登录需算数验证码,curl 流程:GET /login 取 `_csrf`(同时存 cookie)→ `GET /api/captcha/generate` 拿 `{id, question}`
→ 算答案 → `POST /api/captcha/verify`(头 `X-CSRF-Token`,体 `{id, answer}`)→
`POST /login`(email/password/_csrf/`g-recaptcha-response=id:answer`)→ 302 到 `/`,cookie 已带登录态。
## P2 一致性收尾(已实施,未部署)
1. **导航图标**:🌐🌙☀️ emoji → 内联单色 SVG(`stroke: currentColor`,跟随主题/悬停色)。
⚠️ 原计划用 UIkit icons,实测 **UIkit 3.3.3 的图标集里没有 moon / sun**(只有 world),
照写会渲染成空白按钮——所以三个图标全部改成内联 SVG,也不依赖 JS。
2. **标签**:投票类型 20 处 `uk-label uk-label-primary`(实心蓝)→ `.tb-chip` 轻量胶囊;
探索页热门标签原来的 5 色轮换(success/warning/danger/primary/secondary)也统一成 `.tb-chip`。
箱主面板 `question-list.html` 另 6 处字节级替换(CRLF 保留)。
保留:置顶/私密/被举报/已读 这些**语义状态**仍是彩色 uk-label(有含义,不该降级)。
3. **遗留写法**:`<center>` ×2 → `div.uk-text-center`;占比条从
`div.uk-progress > div.uk-progressbar(style:width)` 改成 UIkit 3 原生
`<progress class="uk-progress" value=".." max="100">`,**删掉 25 行 UIkit2 兼容补丁**,
只保留 10px 高/6px 圆角的外观收窄。测试 `percent_check_test.go` 的断言同步改成 `value="66" max="100"`。
4. **meta**:删掉重复的 `msvalidate.01`(保留 head 顶部一份)、viewport 补 `initial-scale=1`。
**踩坑**:暗色主题图标原来写 `display:inline`,span 里放块级 `<svg>` 会让导航栏高度变化 → 整页下移 36px。
用行亮度曲线做互相关定位到 dy=36,改成 `inline-flex` 后复测差异回到 0.059%(只剩随机 emoji)。
教训:**改图标尺寸/显示模式一定要量整页位移**,不能只看局部截图。
## P2 线上复测(2026-10-19 12:2x)
- `GET /static/css/base.css?v=20261019c` → 200 / text/css / **23918 字节,与本地逐字节一致**,页面已引用该版本。
- meta:viewport 已带 `initial-scale=1`;`msvalidate.01` 只剩 1 份。
- 图标:首页 emoji 🌐🌙☀️ 归零,`<svg ... stroke="currentColor">` ×5(语言 1 + 桌面主题 2 + 移动主题 2);
计算样式暗色下 `#theme-toggle .tb-icon-dark` = `display:flex`、亮色图标 `none`;
**导航高度亮/暗均 80px** → 上次 36px 位移问题确认修好。
- 探索页:`.tb-chip` ×25(全部有底色 `rgb(244,246,248)`、文字 `rgb(92,99,112)`、圆角 6px、字号 11.52px);
残留彩色 `uk-label` 4 个,均为置顶/私密等语义状态。
- 结果页:`/user/votes/{id}/results` 原生 `<progress class="uk-progress" value max>`,计算高度 **10px**、圆角 6px;
遍历 10 个结果页 `uk-progressbar` 全部为 0。
- 巡检 `/ /explore /rules /login /register /_/admin6 /_/admin3` 均 200。
## P2 补漏(已改,待部署)
复测时发现 **结果页与参与页的题型标签仍是实心彩色** —— 那两处的 class 由 Go 模板函数
`fieldTypeLabelClass` 生成,我上一轮只按字面 `class="uk-label uk-label-primary"` 搜索,漏了它们。
- `templates/user/vote-results.html`(2 处)、`templates/vote/page.html`(2 处):序号 + 题型 → `.tb-chip`;
状态标签(未开始/已结束/进行中/已参与)保留彩色,属语义。
- 删除 `internal/template/template.go` 的 `fieldTypeLabelClass`(19 行,已无引用)。
顺带查明:该函数 `text/textarea` 走 `return ""`,注释说"默认灰",**实际渲染成 UIkit 默认蓝**,
所以文本框标签一直是蓝的——注释与实现不符。
- `.uk-progress::-webkit-progress-value` 圆角改成两端都圆(`-moz-progress-bar` 同步)。
⚠️ 起因是误判:我先前测"右端方角"时像素扫描循环上界写成 `range(110,830)`,恰好等于条宽,
于是右端永远"恒定不变"→ 假象。放宽到整幅宽度后两版端点曲线完全一致(830/832/833/834/833/832/830),
即**没有视觉 bug**。保留改动只为 Firefox 的 `-moz-progress-bar` 保险。
- `base.css?v=` → `20261019d`。`go build` + `./templates ./internal/i18n ./internal/template` 全绿。
**教训(重要)**:像素测量脚本的扫描范围必须覆盖整幅图,循环上界不能与被测对象边界重合,
否则会把"边界截断"读成"图形特征"。之前 3.77→6.89 那组对比不知是否也受此影响,需复查时再确认。
## P2 补漏部署后复测(12:48)
- `base.css?v=20261019d` 上线,24023 字节与本地逐字节一致。
- 删掉 `fieldTypeLabelClass` 后**无运行期报错**:公开投票页 3 个(200,tb-chip 8/16/4,uk-label 各 1 = 状态)、
结果页 4 个(200,tb-chip 8/2/0/16,彩色 label 全 0,progress 7/0/10/19)、
草稿预览页 2 个(200,tb-chip 2/6)。
- 巡检 `/ /explore /rules /user/questions /user/profile?tab=security /_/admin6 /_/admin3` 全 200 无模板错误。
- `/user/votes/{id}/preview` 对**已发布**投票 302 到 `/vote/{token}` 是设计如此(vote.go:1093),不是回归。
- 截图核对:结果页「1 単選 / 2 多選」已为浅色胶囊,进度条 10px 圆角 6px;参与页字段头同样统一。
## "移动端语言/主题按钮不见了"排查(结论:非缺陷)
实测(线上测试环境,`--force-device-scale-factor=1`):
- 宽 360/390/414/640 → 顶栏 `.tb-nav-utils` = `display:none`,底栏 `#tb-mobile-utils` = `flex`,
rect `0,639,W,46`,语言 `block`、主题按钮 `block`(48×30,SVG 16×16),命中测试落在 `mobile-lang-select`。
- 宽 700/768/980 → 底栏 `none`,顶栏 `flex`,右上角地球 + 月亮都可见(截图 `outputs/t-nav-right.png`)。
- 页面级差异:`/_/admin6` 底栏**只有月亮没有语言下拉** —— `IsUserPage=true` 让语言块整体不渲染(桌面同样没有)。
- 生产 `box.shiroko.one` 仍是旧构建(页面无 `base.css` 引用,底栏是 emoji 月亮),移动端同样正常。
用户确认是忘了「移动端移到固定底栏」这个设计,不是 bug。
留下的真实缺口:**箱主页(最常见的外部入口)完全没有语言切换**,见 MEMORY.md 前端样式条目。
## 修正:箱主页无语言切换是设计,不是缺口
上一节把它记成"真实缺口"是错的。用户明确:**箱主页属箱主个性化,以箱主配置为主**,
语言入口不出现是有意为之(`IsUserPage` 门控)。
MEMORY.md 对应条目已改为"有意设计、别当 bug 修、要动先问"。
## 备份(用户要求)
`../TamaBox-v4-mainline-20261019-1445.tar.gz`(940K,302 条目,排除 .git/.workbuddy/outputs)。
P1 剩余两项(首屏跟随系统深色、首页 Waline 懒加载)用户明确**先不做**。
## 主题系统方案(只出设计,未实现)
用户诉求:箱主在后台选主题、仅对箱主相关页面生效;站点级默认主题在配置里;要一份可复用的主题底稿。
已确认的现状事实(设计依据):
- 设计令牌只有 16 个,集中在 `static/css/base.css` 的 `:root`(亮) + `html[data-theme="dark"]`(暗)。
- `<html lang>` 在 `templates/base/header.html:2`,服务端渲染;主题属性可放在这里(无 FOUC)。
- `question.Pager`(`route/question/page.go:45`)是 `/_/{domain}` 与 `/_/{domain}/{id}` 的共同中间件
→ 打主题标记的唯一合适位置。
- `/user/*` 与 `/_/*` 共用 `IsUserPage`(context.go:241 按 URL 前缀判定),所以「箱主相关页面」
≠ IsUserPage,必须单独区分,否则设置页会被主题化。
- `static/fs.go` 与 `templates/embed.go` 都是显式 embed 清单(新增主题 css 必须手加)。
- 用户模型已有大量个性化字段(usernamecolor/introcolor/background…),主题作为 `users.theme` 同族最自然。
- 分享卡是服务端绘制图片(`route/question/share.go`),不经过 CSS → 主题不覆盖它。
关键结论(写进方案的核心):令牌覆盖必须用
`html[data-theme-style="x"]`(0,1,1,压过 `:root` 的 0,1,0)+
`html[data-theme="dark"][data-theme-style="x"]`(0,2,1,压过 base 的 dark)。
只写浅色块会让深色模式被浅色令牌污染(同特异性 + 主题文件后加载)。
## 主题系统初版(已实现,未部署)
用户拍板:①作用范围跟随箱主配置(即 /_/ 箱主相关页面)②主题只做配色/图案/整页背景,**不涉及布局**
③主题名可本地化 ④分享卡不参与。
实现:
- `internal/theme`:注册表 + 三级回退。`[theme] default/available` → 白名单(slug 正则 + css 是否存在),
`Resolve(用户值)`:`default`→空串(不加载)/命中→该 slug/其余→站点默认。"" 与 "default" 语义不同:
""=跟随站点默认,"default"=箱主明确选基础外观。
设置页色块从主题 css 浅色块里抓 `--tb-bg`/`--tb-primary` 拼渐变(值必须过色值正则,防注入)。
- `users.theme varchar(32) default ''`(AutoMigrate 加列)+ `UpdateTheme` 单列更新。
- `question.Pager` 注入 `Data["ThemeSlug"]`(`/_/{domain}`、单题、密码页、分享卡路由共用这一个中间件);
header.html 在 `<html>` 输出 `data-theme-style` 并条件加载 `themes/<slug>.css`。
- 设置页账号面板末尾新增「主题外观」卡片(独立表单 `POST /user/profile/theme`,不塞进 UpdateProfile,
避免与该面板其它字段互相覆盖);`{{ if .Themes }}` 包裹,未配置主题时不显示。
- 静态版本号统一 `conf.StaticVersion = "20261019e"`(base.css 与主题 css 共用,改样式只改一处)。
- `static/fs.go` 改嵌目录 `css/themes`(以后加主题文件不用再改 embed 清单)。
- 主题文件:`_scaffold.css`(底稿,含 19 令牌说明 + 素材规格 + 特异性规范)、`default.css`(恒等,仅色块预览)、
`sakura.css`(示例,纯 CSS 网点图案 + 粉调)。
- base.css:body 接 `--tb-page-image` / `--tb-page-pattern` / `--tb-page-pattern-size` 两层背景;
**UIkit 主色是编译期常量 #1e87f0**,新增映射块把 a/uk-link、uk-button-primary、checkbox/radio、input:focus、
uk-tab active 接到 `--tb-primary`(基础主题值相同,默认外观零变化)。
实测(无头 Chrome,box 页 data-theme-style=sakura):
- 浅色 primary `#e5678f`/bg `#fff6f8`/pattern 16px 网点命中;深色 `#ff8ab0`/`#241a1f`,卡片表面各自跟随。
- 未选主题的 box 页:无 data 属性、无 themes link、主按钮仍 `rgb(30,135,240)` → 零回归。
踩坑:
1. 第一版 sakura 页「发送提问」还是蓝色 —— UIkit 组件色写死,令牌覆盖不到,必须按组件映射。
2. 预览里 `{{ .StaticVersion }}` 为空(预览夹具没这个键),导致按固定串替换主题 css 失败;改用正则。
3. `writePreview` 用全文搜 `data-theme="dark"` 判断是否已加属性 → 命中内联主题 css 里的选择器,深色预览没生效;
改为只看 `<html ...>` 开标签。
4. 脚本改 form/user.go 时把插入块写在结构体中间,拼出 `} Language ...`;靠 Read 复查发现并重写。
## 主题 follow-up(部署后反馈两处)
1. **/user/* 不生效主题**:根因是 `ctx.Data["ThemeSlug"]` 只在 `question.Pager`(`/_/{domain}`)里注入,
`/user/*` 完全没走这条链路。修法:`internal/context/context.go` 的 Contexter 里按
`isThemeScope(path) && c.IsLogged` 注入 `theme.Resolve(c.User.Theme)`;
`isThemeScope` 只认 `/user` 与 `/user/`(不能用裸 `HasPrefix("/user")`,会误吞 `/username`)。
新增 `internal/context/context_test.go` 守住路径白名单;page.go 的注释同步改。
公开页(`/`、`/explore`、`/vote/{token}`)**仍不跟随**登录用户主题,保持站点默认。
2. **sakura 显示奇怪**(用户选择:顶栏白与粉底割裂 / 卡片纯白太突 / 底色分布不均):
- base.css 新增令牌 `--tb-navbar`(`:root` 里 `var(--tb-surface)`,亮暗两条 navbar 规则都改成
`var(--tb-navbar, var(--tb-surface))`),主题可单独给顶栏上色。
- sakura v2:底色改**纯色** `#fceef3`(不用渐变——渐变会被读成"不均匀"),
顶栏 `#fff6f9`、卡片 `#fffafc`(暖白,不再是纯白硬切),网点从 16px/0.14 放宽到 24px/0.05。
- 静态版本 `20261019f`;`_scaffold.css` 令牌说明 19→20。
- `profile.theme.hint` 四语言文案改成「提问箱页面 + 本人后台生效,公开页保持默认」。
- `zz_preview_test.go` 抽出 `inlineThemeCSS()`,并新增 `preview-profile-sakura-*`(后台页面也要在主题下检查)。
验证:`go build ./...`(须带 webp overlay)、i18n/theme/context/templates 四个包测试全绿。
渲染法:抓线上 `/_/admin6` HTML → 把 base.css 指到本地新文件、主题 css 内联 → 无头 Chrome `--headless=old` 截图。
## 投票页 + 标签主色(部署复核后第二轮)
- `/vote/{token}` 主题:`route/vote/vote.go` 的 `Page` 里本来就会 `db.Users.GetByID(vote.UserID)` 取 owner,
直接在同一分支补 `ctx.Data["ThemeSlug"] = theme.Resolve(owner.Theme)`;box 页 / /user 后台 / 投票页三处
全部由发布者配置决定。GetByID 失败(箱主已删)保持站点默认外观。
- **`uk-label` 默认色就是 UIkit 主色 #1e87f0**:主题下提问管理的「未回答 / 未读」两枚胶囊还是蓝的。
base.css 追加
`.uk-label:not(.uk-label-danger):not(.uk-label-warning):not(.uk-label-success):not(.uk-label-secondary)`
→ `--tb-primary`。**必须用 :not() 排除语义色**:同特异性且 base.css 在 UIkit 之后,不排除会把
danger/warning/success 一起刷成主色。默认主题实测仍是 (30,135,240),零回归。
- StaticVersion → `20261019g`。
- 线上复核(admin6 专属账号,curl 走完整登录:GET /login 取 _csrf → /api/captcha/generate →
/api/captcha/verify(**answer 必须是数字,传字符串会 400 请求格式错误**)→ POST /login):
`/user/profile`、`/user/questions` 均已输出 `data-theme-style="sakura"` 与 themes link(v=20261019f)。
投票页在 f 版本仍无主题 → 需再部署一次。
## 投票页语言跟随发布者(第三轮)
- 抽出 `applyOwnerLocale(data map[string]interface{}, owner *db.User)`(route/vote/vote.go 顶部):
同时写 `Owner` / `Lang` / `LangCode` / `ThemeSlug`。
签名故意收 Data 而不是 `context.Context`:`Context` 内部字段不可在包外构造,收 map 才能单测
(`route/vote/locale_test.go`,纯内存,不碰 DB)。
- `Page`:owner 查询提到 `FeatureStateOf` **之前**——否则「投票功能已关闭」这类提示先按访客语言生成。
- `Submit`:同样先取 owner 再取 `lang`,保证提交后回跳页的 flash 与页面语言一致;
顺带删掉尾部重复的 `GetByID(owner)`(表单类型通知邮件复用同一个 owner),少一次查询。
- 四语言 `profile.theme.hint` 与 changelog 范围说明补上「你发布的投票页」。
- 验证:vote/i18n/context/theme/templates 全绿;线上 `lang="ja"` + 中文内容的不一致待部署后复测。
### 部署后复测(v=20261019g)
- `/vote/7kcP5kR8YnJC`(admin6):`lang="zh-CN" data-theme-style="sakura"`,base/theme css 均 v=g,
日文串(投票する / 匿名での参加 / ログイン)全部消失 → 语言与主题同步跟随发布者。
- `/ _/admin6`、`/user/profile`:仍为 `zh-CN` + sakura,未回归。
- 隔离性:另取三个 token 抽查 —— 4G3i79OCYa2E / 7l0MwF7cLz4T 为 `lang="ja"`(无语言偏好、无主题),
9J1zgXwQ05Ai 为 `lang="zh-CN"` 且**无** data-theme-style → 主题不会跨箱主泄漏。
- 遗留观感问题(未改,等用户决定):投票页的「允许匿名参与」提示条是 `uk-alert-primary`,
仍是 UIkit 浅蓝,在粉底上偏冷。改它需要「浅底 + 深主色文字」的对比度,现有 `--tb-primary-weak`
叠白后对比度只有 ~2.6:1,得先加一个 primary-strong 令牌,故未动。
### 顶栏配色再修(v=20261019h)
用户反馈「探索/注册链接底色粉色,但整条顶栏是灰色,很突兀」。实测部署版:顶栏 `#fff6f9`、
页面 `#fceef3` —— 近白横带压在饱和粉底上,肉眼就读成「白色/灰色的一条」。无头 Chrome 截图 +
像素采样确认不是 UIkit 默认 #f8f8f8 回退(那说明变量没生效,现象会连带整页失效)。
- sakura 浅色 `--tb-navbar` 直接取页面底色 `#fceef3`(深色同样 `#241a1f`),横带消失;
- 顶栏链接 `#999` 在粉底上偏淡 → 主题内微调为 `--tb-text-secondary`,hover 用 `--tb-primary`
(深色被 base.css 的 `!important` 规则接管,不受影响);
- 顺带 `uk-alert-primary`(匿名参与提示/空列表)的 UIkit 浅蓝是页面上唯一的冷色块,
改为 `--tb-primary-weak` 底 + `#a63a5e` 文字(≈5.3:1)。
- 以上三条都写在 sakura.css 的「组件微调」段,只对 sakura 生效,默认主题零影响。
## 主题「砂狼白子 / shiroko」(v=20261019i)
- `static/css/themes/shiroko.css`:灰蓝银白令牌(浅 #edf2f8 / 主色 #4a7fc1),
组件微调只有两条 —— `.uk-card-default` 右下角水印立绘(`background-size: auto 176px`,纯背景层不改布局)、
顶栏链接色提到 `--tb-text-secondary`;深色不铺立绘(浅色构图在深色下会糊)。
- 素材 `static/images/themes/shiroko/`(68KB):`bg-desktop.webp`(2400×1350) / `bg-mobile.webp`(900×1600) /
`hero-fade.webp`(275×620,16% alpha) / `pattern.svg` / `pattern-dark.svg`。
- **`static/fs.go` 的 embed 清单加了 `images/themes`**(目录项,子目录自动覆盖)——
这是「新增顶层静态目录必须手工登记」的又一次踩点。
- 背景是 PIL 合成的:1280 行的竖向渐变 + 白色径向光晕 + 立绘 30% 贴右下 + 狼 10% / 光环 20% / 纸飞机 18%。
实测 mean≈244、std≈9;右侧留白区 std 13(立绘可见),中间 std 1.9(正文区干净)。
- ⚠️ **`cover` 会按视口裁剪**:竖屏/超宽屏会把贴在图片最右的立绘裁掉,所以立绘不能太靠边 ——
位置要留在图片横向 24%~76% 之外就得配 `@media` 换图(本次桌面 16:9 + 移动 9:16 两张)。
- 生成素材(ImageGen)8 张里只有 3 张可用:线稿图案(狼/光环/纸飞机)抠白底干净;
2 张人像抠底失败(背景不是纯白,`background: transparent` 实际没生效),2 张场景背景几乎没有内容(std<5)。
教训:**没有视觉能力时,生成图要用 `mean/std/edge` 先验内容量**,别直接拿去合成。
- 素材来源与版权写在 `docs/theme-shiroko.md`(角色版权 NEXON / Yostar,非商用同人)。
- 启用需要在 `conf/app.ini` 的 `[theme] available` 里加 `shiroko`(示例配置已更新)。
## shiroko 插画重做:动物线稿 → Q 版人物(v=20261019k)
用户反馈「简笔画不是画动物,是人物形象」,插画层整体重做。
- 新增 `dev/gen_shiroko_chibi.py`:手写 SVG 生成 6 个 Q 版白子(wave/cheer/think/run/doze/peek),
3.8KB/张,造型逐项对齐用户立绘(灰发蘑菇头 + 圆钝狼耳 + 倾斜光环 + 青围巾 + 深蓝外套 + 灰裙 + 青黑球鞋)。
**生成模型这条路走不通**:`background: transparent` 不生效、无视觉反馈时无法判断好坏;
代码画路径反而可控、体积小、任意尺寸不糊。
- ⚠️ **cover 必然裁掉贴边插画**:1280×1400 视口下,2400×1350 的背景图只露出中部 1234px,
右下立绘被裁成 38px 一条。→ base.css 新增装饰层令牌 `--tb-deco / --tb-deco-size / --tb-deco-position`
(固定像素 + 四角定位 + fixed,不随视口比例变化)。
- ⚠️ **多背景层的值个数必须恒定**:deco 先写成 4 个值,background-image 变 6 层而 size/position 只有 3 个,
CSS 循环取值 → 整页重复小人。改成固定 6 个槽位(1 图案 + 6 装饰 + 1 背景 = 8 层),
主题必须写满 6 个(缺的填 none),这条写进了 `_scaffold.css`。
- 删掉 `bg-desktop.webp` / `bg-mobile.webp` / `hero.webp` / `dev/gen_shiroko_bg.py`;
整页底色改成纯 CSS 渐变(不用图片,也就没有 cover 缩放差异)。
- 深色关掉 deco(浅色线稿在深色底上糊成灰块);pattern 里的爪印换成 Q 版头像,
opacity 0.42→0.2、tile 240→320(原来铺得太密,像壁纸)。
- 验证:默认主题背景仍 `#f7f8fa`、顶栏纯白、卡片纯白(零回归);箱主页与 `/user` 后台四角装饰都不压内容;
移动端只留立绘 + peek。
## shiroko 插画二次返工:手绘 SVG → 参考立绘图生图(v=20261019l)
用户反馈上次的简笔画"太抽象"。根因判断:用户的立绘是**平涂无描边色块风**,
我用手绘线稿去配它,风格本身就冲突,而且没有真人手感,形准也做不到。
- 新方案:ImageGen **图生图**(`image1` = `static/images/shiroko-cutout.png`,`input_fidelity: high`),
提示词统一为「same character, same art style... flat vector illustration, no outlines」+ 换动作句,
生成 4 个姿态(wave / cheer / think / doze),1024×1024、纯白背景。
**四张角色特征与画风一致**,这一步是手绘做不到的。
- 抠图:四角 + 四边中点 8 点 floodfill(容差 16)+ alpha 1px MinFilter 收边,裁 bbox 后统一高 620、
WebP q90。实测无白边,光环中间的孔也正确透出。
- 删除 `dev/gen_shiroko_chibi.py` 与 6 个手绘 SVG;`chibi/*.webp` 4 张共 106KB。
图案里的手绘小头像也删了(和位图人物并排显得最抽象),改成雪花 + 光环 + 圆环 + 点簇。
- ⚠️ **deco 的 `center` 定位在矮视口会撞车**:右下立绘 520px 高,1366×768 下从 y=248 一直到底,
托腮放 `right center` 会直接站在立绘头顶。百分比是「(视口-图高)×pct」,改用 `top 46%` 并挪到左侧中部。
验证视口:1366×768 / 1440×900 / 1280×1400 三档,人物互不重叠。
- docs 补了完整提示词骨架、动作句与抠图参数,说明"必须平涂无描边"这条经验。
## shiroko 布局重排 + 新坐姿(v=20261019m)
用户反馈:左右不协调、最上边的头被顶栏挡住。
- 布局改为**左右各两个**(左上 wave / 左下 sit,右上 cheer / 右下立绘),
`≥920px` 真实视口才补中间两个(think / doze),矮屏不放(否则压到立绘)。
- 顶部定位从百分比改成**固定 `top 96px`**:顶栏实际高 60px(不是 80),
百分比是「(视口-图高)×pct」,小视口下算出来的值会落进顶栏里。
`bottom 0` 也改成 `bottom 6px`,避免脚贴页底。
- 新增坐姿(盘腿),ImageGen 图生图第 2 次才可用:第 1 张脚被画到画布底边(底部 78 像素贴边),
提示词补 `centered composition, whole body fully visible with generous empty margin` 后正常。
- ⚠️ **Chrome headless 的 `--window-size` ≠ CSS 视口**:实测 1080 → innerHeight 985、768 → 673
(差约 95px 的窗口边框)。所以 `@media (min-height: 1050px)` 在 1080p 下永远不触发。
判断媒体查询门槛要按「真实视口」写,截图验证必须加 `--force-device-scale-factor=1`,
否则系统 DPI 缩放会让截图尺寸和 window-size 也对不上(曾出现 1920 请求出 1088 宽)。
- git:仓库可用(origin = https://git.shiroko.one/tamakyi/tamabox,main),
已本地提交 `0c39aff`,但 `credential.helper=store` 里的凭据过期 → `Failed to authenticate user`。
打包 `../TamaBox-v4-mainline-20261019-2037.tar.gz`(1.5M,含图片素材)供直接部署。
### git push 补推
上次的 `Failed to authenticate user` 是临时态 —— 用户本机 Git for Windows 认证过之后 store 凭据恢复,
本轮 `git push origin main` 直接成功(`38cd41a..0c39aff`),`git rev-list --left-right --count origin/main...HEAD` = 0 0。
远端 8 个 shiroko 素材文件齐全。教训:**不要用一次 push 失败就断言无凭据**,先看一眼提交是否已存在、再试一次。
### 部署后「设置页找不到 shiroko」
线上实测:`/static/css/themes/shiroko.css?v=20261019m` 200 / 5038B 且与本地逐字节一致,
说明二进制确实带了新主题;但 `/user/profile` 渲染出来的主题单选只有 `""`、`default`、`sakura`
→ **根因是 `[theme] available` 白名单没登记 shiroko**(列表在 `theme.load()` 里按配置构建,启动时读一次)。
- `internal/theme/theme.go` 新增 `reportRegistry()` / `discover()`:启动打 info「主题注册表已加载 enabled=...」,
并把「css 在、但没登记」的主题以 warning 打出建议的 available 值。
没有这段时,这个故障在日志里完全没有痕迹,只能靠猜。
`discover()` 用 `fs.Glob(static.FS, "css/themes/*.css")`,用 slugRe 过滤掉 `_scaffold.css`(下划线开头)。
- `conf/app.sample.ini`:`available = default,sakura,shiroko` + 补充「没登记就不显示」的注释。
- `docs/theme-shiroko.md` 补「部署后看不到主题」两段排查(先 curl css 看 200/404,再看 available)。
- 排查口径:**先分「文件没发布」与「没登记」两类**,别一上来怀疑缓存;`StaticVersion` 只影响缓存,不影响列表。
### shiroko 装饰返工:散射 → 左下角一组(v=20261019n)
用户反馈「主题正常了,但展示方式怪怪的」。实测线上 1512×982 截图确认三处:
1. 四角各一个人 = 贴纸墙纸(人物之间、人物与内容都没有关系);
2. 半透明立绘(16%)叠在实心人物旁边 → 虚实混搭,像重影;
3. 右下角人物和右对齐的页脚文案(社区规则 / 备案号 / 存活时长)挤在一起。
改法:只留左下角一组(sit 330 + wave 190,`left 0/bottom 0`),删掉 hero-fade,
纹样减到雪花 + 光环 + 两点(opacity .16、tile 340),卡片补 1px 冷调描边(box-shadow 不占布局)。
990px 以下缩小左移,900px 以下(卡片占满宽)不出人物。
⚠️ **`background-position` 的四值语法必须以边关键字开头**:写成 `max(8px, calc(50% - 555px)) bottom 0`
整条声明作废并回落到左上角(表现为人物跑到顶上、背景出现错位色带)。
要贴卡片边只能 `left max(8px, calc(50% - 555px)) bottom 0`。
### 纹样横向条纹(v=20261019o)
图案元素原先全在 tile 上半部(雪花 y=52 / 光环 62 / 点 150、176),铺开后在整页形成
**横向条纹带**(左栏 60px 带宽均值 245→237→249,跳变 12),这是"看着怪"的第二个来源。
改成 3 行均布(52 / 132 / 152-212)后收敛为单调 245→236。
⚠️ 图片不走 `StaticVersion` 缓存键(`Cache-Control: max-age=86400`),换图案要自己给 url 加 `?v=`。
### 定稿:半隐在卡片后的单人(v=20261019p)
用户从 A/B/C/D 四版里选了 D(半隐在卡片后),并追加两条:人物离开底边一点、纹样再淡一点。
- `sit` 单人,`left max(6px, calc(50% - 515px)) bottom 26px`;宽度 177px,右缘压进卡片 40px。
- **离底边后必须配接触阴影**:一层 `radial-gradient(ellipse, rgba(63,96,138,.22), transparent 72%)`
128×15px,垫在脚下一格(deco 第 2 个槽位,绘制顺序在人物之下)。没有它,抬高 26px 会读成悬空。
- 纹样 opacity 0.13→0.085(深色 0.09→0.055);`?v=20261019p` 同步改。
- 剩余素材(wave/cheer/think/doze)保留但不上页面,换姿态只改 `--tb-deco` 的 url。
### shiroko 按二次元官网重做:整页场景 + 悬浮磨砂 UI(v=20261019q)
用户反馈「还是很怪,去网上学一下布局」。实际去看站:
- 蔚蓝档案 JP 首页(bluearchive.jp,截图存 outputs/ref/bluearchive_jp_.png):**整屏一张场景插画,
logo/按钮/横幅浮在画面上**,没有"白卡片"这层东西;
- marshmallow-qa / peing 这类真正的提问箱服务被 Cloudflare 拦,拿不到参照。
结论:前几版都卡在"白底 + 留白摆人物"这个结构上,人物没有环境,怎么调位置都像贴纸。
改法:生成教室场景(ImageGen,1536×1024)→ 裁掉底部 160px 水印 → 混白 38% → 模糊 1.4px →
WebP 1920×1080(22KB)/ 720×1003 竖版(6KB);卡片与顶栏改 `rgba(255,255,255,.86)` +
`backdrop-filter: blur(16px)`;人物 340px 站在左侧地面、离底 36px + 椭圆接触阴影;浅色不再叠纹样。
深色仍用渐变 + pattern-dark(白天教室压在深底上会脏)。
⚠️ **`ruleBody` 必须剥 CSS 注释**:令牌前写一行注释时,`tokenValue` 按 ";" 切完再按 ":" 取键,
会拿到 `/* ... */ --tb-surface`,色块预览静默变空、`TestThemeCSSComplete` 报"缺少令牌"。
已加 `commentRe` + `TestRuleBodyIgnoresComments`。
⚠️ ImageGen 出的图右下角有 `AI生成 WORKBUDDY` 水印,用作整页背景必须裁掉。
## 23:40 人像换成「窗边背影」
- 用户否掉了盘腿坐的正面人像("和背景没那么突兀的")。根因:正面直视 + 无环境 + 饱和度高,
在压白 38% 的空教室里就是一块贴纸。
- 新素材 `chibi/window.webp`(135×460, 13KB):文生图出侧后方 3/4 视角(双手背在身后)→ 再
image-to-image 转"纯色块无描边 + 去窗框"→ 抠白底 → **RGB 压白 16%** → 高 460 / q86。
第一步文生图会把窗框画进背景,抠不干净,必须靠第二步重绘。
- CSS:`--tb-deco` 首槽换 window.webp,高 380→400px,接触阴影 96×13 复位到脚底;`StaticVersion` → `20261019r`
(图片另带 `?v=20261019r`)。删除 `sit.webp`;`docs/theme-shiroko.md` 补两步做法与摆位说明。
- 提交 `e95671f` 已推送。教训:**人像与场景的"雾度"要对齐**——场景压白多少,人像也压相当比例。
+420
View File
@@ -195,3 +195,423 @@
- ⚠️ `gofmt -l` 对 CRLF 文件一律报警(gofmt 要求 LF)—— 那是既有状态,不是我的改动。
- ⚠️ 我自己的测试预期写错过两次(别名生效后的期望值、static_version 不是 ini 键)。
**测试失败要先怀疑自己的预期**,别急着改产品代码。
## 蜜罐实现审计(第三方 agent 写完,我复审)
### P0:蜜罐封禁整条链路未接线(已实测复现)
`internal/route/route.go:85` 的 `autoban.New(...)` **没传** `HoneypotEnabled / HoneypotThreshold /
HoneypotDuration`,而 `autoban.RecordHoneypot` 首行就是 `if !b.honeypotEnabled { return false }`
→ 蜜罐命中永不封禁、落库 `banned` 恒 false、`IsBanned` 永不为真。
同一提交又把 `autobanMiddleware` 里蜜罐路径的**常规计数也跳过**了
(`route/autoban.go:38 if hp == nil || !hp.Match(path)`,理由「蜜罐处理器自己计数」)
→ 净效果:蜜罐路径从「10 次封禁」退化为**零计数零封禁**,是 regression。
`honeypot_threshold` / `honeypot_ban_minutes` 两个配置项全代码库**零读取**(死配置,样例 ini 里却写了)。
测试为什么绿:`route/honeypot_test.go` 自建 banner 时显式传了 `HoneypotEnabled`,
注释还写着「与 route.go 一致」—— 实际不一致,测试绕过了生产装配。
### P1
- 蜜罐 GET 探测(无凭据)也逐条 INSERT,把「零成本 404」变成「每请求一次 DB 写」,
且 NotFound 链路上没有 rate limit → 扫描器可直接放大成 DB 写压力;20 万行闸每小时才跑一轮。
- `honeypotHandler.alerted`(map[string]bool)无 TTL、无清理,随源 IP 数量无界增长;
autoban 的同名 map 也是(既有问题,蜜罐放大)。
### P2
- `Path` 原样落库但列是 `size:512` → 超长路径直接 PG 报错丢证据;`Query` 用 `query[:1000]`
字节截断,`honeypot.go:243/296` 的 `v[:limit]`、`raw[:limit]` 同样是字节截断
→ 多字节字符被切半产生非法 UTF-8,PG 拒插。同文件里已有按 rune 的 `truncate()` 却没用于这些点。
- `credentialKeys` 含 `id` / `name` / `token` → 任意 JSON 探测带 `{"id":1}` 就会被当成「破解凭据」
触发告警邮件 + 记为 HadFields,污染取证与告警。
- `page.go:54` `<meta name="generator" content="TamaBox honeypot tb-hp-7f3a1c9e">` 把「这是蜜罐」
和「这是 TamaBox」同时明示给扫描器(nuclei 类模板可据此跳过)。
- `serveLoginFailure` 手抄了一遍 `WriteLoginPage` 的响应头逻辑(没复用),已开始漂移
(Title 一处写 `‹`、一处写 `\u2039`)。
- `Honeypot.Describer()` 零调用死代码,且 `path.Join(keys...)` 语义错误(会把前缀拼成一条路径)。
### 结论
总体设计(挂 NotFound、零业务交集、只回假内容、脱敏 CLI、独立取证表)方向正确;
问题集中在「装配层没接上」与「写入侧的边界处理」。P0 必须先修,否则蜜罐只是个记日志的旁观者。
## 蜜罐审计问题的修复实施(已改完,未提交)
### 1. P0 装配(route.go)
抽 `newBanner()` 作为唯一装配入口,补 `HoneypotEnabled / HoneypotThreshold / HoneypotDuration`。
新增 `internal/route/banner_config_test.go` 四个用例,全部经由 `newBanner()` 断言。
**变异验证**:分别把 HoneypotEnabled 改成 false、Threshold 改成 0,对应用例确实失败。
### 2. 诱捕语义修正(审计时漏掉、比 P0 更影响功能价值)
原实现 `RecordHoneypot` 对**所有方法**计数且阈值 1 → 扫描器 GET 一次假登录页就吃 403,
永远走不到「POST 提交凭据」那一步,蜜罐核心价值(拿字典)直接归零。
改为:`creds.HadFields` → RecordHoneypot(立即封);纯探测 → 常规 `Record`(阈值 10)。
新增 `TestHoneypotProbeDoesNotBanImmediately`,同上做了变异验证。
连带把 `honeypot_threshold` 的语义在 static.go / defaults / autoban / sample.ini 统一为
「提交凭据次数」。
### 3. 落库边界(实测取证)
- `Path` 原样落库、列宽 512 → PG `SQLSTATE 22001 值太长了(512)`,整条证据丢失;
实测 600 字符失败、500 字符成功。
- 三处按字节截断 → `SQLSTATE 22021 无效的 "UTF8" 编码字节顺序`(中文密码/URL 可稳定触发)。
全部改为 rune 截断(`clipRunes` / route 的 `truncate`),并按列宽逐个对齐。
### 4. 资源与噪音
- `honeypotHandler.alerted`(无界 map)→ 换成带 TTL 的 `deduper`(容量上限 2 万,
过期清理 + 超限硬裁),同时承担「告警 24h 冷却」与「纯探测落库采样(每 IP 每分钟一条)」。
- `credentialKeys` 去掉 `id` / `name`(普通 JSON 探测不再被当成凭据提交触发假告警)。
- canary 从 `<meta name="generator">` 移到 HTML 注释(`<!-- build: ... -->`),
generator 伪装成 `WordPress 6.4.3`;不再向扫描器自曝「TamaBox 蜜罐」。
- `serveLoginFailure` 不再手抄响应头,统一走 `WriteLoginPage`;`LoginPageTitle` 抽常量。
- `Describer()` 死代码 → 改为 `Prefixes()` 并接到新 CLI `TamaBox honeypot paths`。
- `honeypot_paths` 解析挪到 `honeypot.SplitPaths`(CLI 与 route 共用),补前导斜杠、拒绝 `/`。
### 5. 新发现的配置陷阱(影响面比蜜罐大)
`conf.Init` 用 `ini.LoadOptions{IgnoreInlineComment: true}`,该选项语义是
**不剥离行尾注释**(实测:`port = 1440 ; 注释` → 值 `"1440 ; 注释"`,int 解析失败,
`MapTo` 却返回 nil error)。后果:
- `app.sample.ini` 照抄**无法启动**(`text_censor_source` 值带注释被 validate 拒绝);
- 所有带行尾注释的 int/bool 静默回退零值 —— `enable_auto_ban`、`honeypot_max_rows`、
`honeypot_auto_clean` 全部中招(后两者靠 sanitize 兜底才没出事,max_rows 则真的失效)。
已把 sample 的**蜜罐段**改成「注释独占一行 + 裸值」,并在段首写明原因。
其余段落仍是老写法,未动(需用户决定是否全面清理)。
### 待办
- 用户确认是否提交这批改动。
- `app.sample.ini` 其余段落的行尾注释问题未修,建议后续统一。
- 蜜罐 GET 探测目前受 `enable_auto_ban` 约束(关了就不计数),这是有意的:
纯探测无害,不该在运维关掉自动封禁后仍凭空封人。
## 后续:sample 注释清理 + 凭据外置(同日晚)
### 1. app.sample.ini 行尾注释全量清理
17 处 `key = value ; 注释` 改成「注释独占一行 + 裸值」,保留 CRLF。
新增 `TestSampleIniValuesHaveNoInlineComment`:遍历所有段的键,断言 `Value()` 不含 `;`。
变异验证:把 `ban_window_seconds = 60` 改成带行尾注释 → 测试按预期失败。
清理后 `conf.Init` 读 sample 的报错从「值含注释」变成「server.xsrf_key 未填」,
说明解析路径已通(必填项留空是模板的预期行为)。
### 2. 凭据改为外部文件,数据库不再存用户名/密码
- `honeypot.CredRecord` + `CredStore`(`internal/security/honeypot/credstore.go`):
按天分文件 `creds-YYYY-MM-DD.jsonl`,一行一条 JSON;目录 0700、文件 0600;
坏行跳过;`PurgeBefore` 只删「整天都早于保留期」的文件。
- `db.HoneypotHit` 删掉 `Username/Password/Fields/RawBody`,只留 `HadFields` 元数据。
- `HoneypotStore` 删掉 `TopCredentials` / `ListSinceByIP`(CLI 已改读文件,零调用)。
- 新增 `LegacyCredentialColumns` / `PurgeLegacyCredentialColumns`:
AutoMigrate 不删既有列,老库会残留历史明文 → 启动时 WARN,`honeypot purge-creds` 显式 DROP。
- 清理器 `HoneypotRetention` 加 `CredPurgeable` 参数,与 DB 记录共用保留期;
`honeypot prune` 也一并删过期凭据文件。
- CLI:`hits` 不连 DB(DB 挂了也能查),`stats` 的凭据排行读文件,新增 `purge-creds`。
- 配置 `[security] honeypot_cred_dir`,缺省 `data/honeypot-creds`;`.gitignore` 加 `/data/`。
### 测试
`credstore_test.go`(追加/倒序/IP 过滤/坏行/Top/Purge/权限)、
`TestHoneypotLegacyCredentialColumns`(ADD COLUMN 模拟老库 → 检测 → DROP)、
`TestHoneypotWritesCredentialsToFile`(POST 后文件有记录)。
CLI 端到端手动验证:`honeypot hits` / `--ip` / `--show-password` 输出正确。
全量 `go test ./...`(含 PostgreSQL)通过。
### 遗留
- **历史备份/主从复制里可能仍有旧明文凭据**,代码无法处理,需运维自行清理。
- 邮件告警仍会带明文用户名/密码(用户未要求改)。
### 已提交(2026-10-21,未推送)
- `566b13e` fix(conf): 样例配置去掉行尾注释(2 文件)
- `c29e1d0` fix(honeypot): 补齐封禁装配与诱捕语义,凭据外置为文件不再入库(21 文件)
- `main` ahead of `origin/main` 2;用户要部署测试环境验证后再决定推送。
### 测试环境实测反馈(同日晚,已修并推送)
现象:purge-creds 删 4 列成功、GET 200、POST 后 GET 403、文件已生成。
但凭据文件里 `"path":" /wp-login.php"`(前导空格)、`"ip":"127.0.0.1"`。
- **path 脏字符**:反代链路(CF → nginx → 源站)传出的 `req.URL.Path` 带前导空白。
`Match` 内部 normalize 会 TrimSpace 所以仍命中,但落库与假表单 action 用原始值
→ 取证数据脏、爆破脚本可能提交到错误路径。本地 httptest 复现不出来,是环境相关。
修复:`cleanPath()` 在 Serve 入口 trim + 补前导斜杠(不改大小写)。
- **IP = 127.0.0.1**:说明 `trust_proxy` 未开。后果不只是取证失真 ——
**一次蜜罐封禁会封掉 127.0.0.1,等于封掉整站所有访客**(封禁是内存态,重启即恢复)。
修复:命中回环地址且 `!trust_proxy` 时打一次性 WARN(24h 冷却),不改封禁行为。
sample.ini 的 trust_proxy 段补了蜜罐相关说明。
- `846bb8a` 已推送(`5e8deca..846bb8a`)。
### 收尾:path 脏字符最终定位(同日晚)
用户重启进程后,`od -c` 显示新记录的 `"path":"/wp-login.php"` —— 干净。
说明之前那条带空格的是**旧进程**写的(16:07 那次 `./tamabox web` 并没有真正替换掉在跑的实例)。
期间补了两处加固并推送:
- `74312fb`:`cleanPath` 改用 `TrimFunc` 覆盖 U+200B/200C/200D/FEFF
(`TrimSpace` 只认 White_Space,零宽字符会漏,且会因首字符不是 `/` 多补斜杠);
清洗前后不一致时 WARN 打出 `%q` 原始值,不可见字符靠日志才定位得到。
- 教训:`strings` 不认 UTF-8 中文(已记入 topics/build.md);替换二进制后必须重启进程。
### 文档(`3d45374`,已推送)
- 新增 `docs/honeypot.md`:生效范围、配置、**两条封禁路径**(GET 走常规阈值 /
提交凭据才即封)、凭据文件格式与不入库的理由、`trust_proxy` 部署必读、
旧列清理、排障表、设计取舍。
- `docs/cli.md`:§0 补「替换二进制必须重启」「strings 用 grep -ac」;
命令总览加 honeypot;新增 §7 honeypot 命令(原 7/8/9 顺延为 8/9/10);
排错表加 5 条。
# 2026-10-21
## 新旧前端对比(正式 vs 测试环境),只测量未改码
**方法**:curl 抓两环境 4 个页面 → 本地注入脚本取计算样式 / 资源体积 / 对比度 / 点按区;
无头 Chrome 截图(`--headless=old`)后用 Pillow 算像素指标。
⚠️ 本模型读不了图片,所以结论全部来自数值与结构,不靠"看截图"。
**踩坑**:file:// 打开抓下来的页面时,`href="/static/css/base.css"` 会解析成 `file:///static/...` → CSS 不加载,
于是 dev 页量出来全是 UIkit 旧样式(圆角 0、旧阴影)。改法:抓下来后把 `/static/` 重写成 dev 域名绝对地址,
再重新采集。**以后本地复现线上页面必须先重写站内绝对路径,否则测的是"无 CSS 的裸 HTML"。**
另:`body.scrollWidth` 会被隐藏面板撑大(dev explore 1499),判断横向溢出要看 `documentElement.scrollWidth`(实测 1249 = 无溢出)。
**实测差异(1280 宽)**
| 指标 | 正式(旧) | 测试(新) |
|---|---|---|
| 首页 HTML | 42.9KB | 34.2KB |
| 探索页 HTML | 77.4KB | 53.4KB |
| `<style>` 块 | 9–10 | 2 |
| 行内 style 属性 | 8–16 | 3–5 |
| 卡片圆角 / 阴影 | 0px / 单层硬阴影 | 12px / 双层柔阴影 |
| 圆角取值种类 | 2px×73、50%、8、6、4、9 | 6、8、12、14、5、50% |
| 正文色 | #333/#666/#999 混用 | 令牌 #1f2329/#5c6370/#8a919c |
| 字体栈 | UIkit 默认 | system-ui + 苹方/雅黑 |
| 图标 | emoji(🌙☀️) | 内联 SVG |
| 首页截图像素:色彩数 / 边缘密度 | 2206 / 10.3% | 1174 / 5.0% |
**共同短板(新旧都没解决)**
- `--tb-text-muted #8a919c` 在 #fff 上只有 **3.18:1**(AA 需 4.5);旧版 #999 更低(2.85)。
建议改 #6b7280(4.83:1);暗色 muted #8b93a1 在 #242830 上 4.77 已达标。
- 顶栏导航链接(UIkit rgba(0,0,0,.5))2.68–2.85:1,同样是低对比。
- 移动端点按区:两版都有大量 19px 高的纯文字链接(不满足 44px 建议值)。
**新版自身的毛病(我写的,待修)**
- `templates/home.html` 字号用了 0.75/0.78/0.86/0.9/0.95/0.98 rem → 7 个肉眼难辨的档位;
`static/css/base.css` 里还有 0.78/0.8/0.82/0.84/0.88/0.95。**排版层级比旧版更碎**,应收敛到 4 档。
- 残留圆角 5px(header 验证码框、sponsor)、9px(profile-tabs,list.html/explore.html 各一处)。
## 按上轮结论改代码(字号收敛 / 对比度 / 圆角收敛)
先确认过:本地 `static/css/base.css` 与 dev 部署版 **diff 0 行**,本地就是新版基线。
1. **新增字号阶梯令牌**(base.css `:root`):`--tb-fs-xs/md/lg/xl/hero` = 12/14/16/24/28px。
`lg` 取 **1rem 而非 17px** —— UIkit 的 `.uk-card-title`/h4 就是 16px,顺着它才能让自定义标题
与组件标题同档,否则会留下 16 与 17 差 1px 的两档并存(第一版就是这么写的,实测发现后改回)。
2. **替换范围**:home.html 全部 7 档 → 5 档;explore / question-list / question-list(list.html) 三页
的 0.7/0.72/0.82/0.84/0.85/0.9/0.98/1/1.15/1.2 全换令牌。实测 explore 由 **9 档 → 5 档**。
3. **`--tb-text-muted` #8a919c → #6b7280**(#fff 上 4.83:1)。**关键坑**:
UIkit 自带 `.uk-text-muted{color:#999!important}`,我原先的普通声明**从未生效**,全站仍是 #999(2.85:1)。
必须 `!important` 才能压过。改完 box 页 fails 15→11、explore 14→9。
4. **顶栏导航**:UIkit 默认 rgba(0,0,0,.5) = 2.68:1 → 改 `var(--tb-text-secondary)` = **6.05:1**,
hover/active 走主色;暗色分支原有 `!important` 规则自然接管(实测 10.75:1)。
5. **圆角全量收敛**:9px/5px/4px/2px/7px/3px 以及等值的 6/8/10/12/14px 全部换成
`var(--tb-radius[-sm|-xs])`。全站(排除邮件模板)现在只剩 3 处 `999px`(胶囊,语义正确)。
6. `conf.StaticVersion` 20261020g → **20261021a**(base.css 改了必须 +1)。
**验证**:go vet / go test(templates+i18n+conf)/ go build 全绿;预览页内联本地 base.css 后用无头 Chrome
量计算样式,首页 12/14/16/24/28、explore 12/14/16/24/32,nav 6.05:1。
**遗留(未改,属设计取舍)**:主色链接 `#1e87f0` 在纯白上 3.37–3.42:1(<4.5)。改深会影响品牌观感,
需用户决定;Waline 第三方赞数绿色 2.87 不在本站控制内。
**注意**:工作区有 4 个**非本次改动**的删除(`static/css/themes/test01.css`、`static/images/shiroko-cutout.png`、
`static/images/themes/test01/*.webp`),是之前就存在的未提交删除,提交时勿一并 add。
## 17:4x 用户前端设计令牌化 → 主题/生成器的连带影响
用户改了 12 个模板 + base.css(未提交):`--tb-text-muted` #8a919c→#6b7280(AA 4.83:1)、
新增 `--tb-fs-xs/md/lg/xl/hero` 阶梯、圆角改走 `--tb-radius(-sm/-xs)`、顶栏链接改 `--tb-text-secondary`、
`.uk-text-muted` 加 `!important`(压 UIkit 的 `#999!important`)。
**关键结论:主题会把这套修复盖回原样。** 主题 css 覆盖同名令牌 → 页面用主题的值,不是 base.css 的。
实测(worse of bg / 压平后的 surface):
- default.css 不定义文字色 → 跟随 base ✓;sakura muted `#a08c94` **2.80:1**、shiroko `#8794a7` **2.76:1**(都看不见)。
- 已调到 AA 下限:sakura `#78696e`(4.61)、shiroko `#656e7a`(4.63),同色相只压深;深色块两个主题本来就 ≥4.7,不动。
- 主色仍不达标(未改,等用户定):base/shiroko `#4a7fc1` 3.69、base `#1e87f0` 3.26、sakura `#e5678f` 2.80。
候选:`#3d6fac`(4.63)、`#0f6ac6`(4.83)、`#b53d61`(4.91)。
**生成器的必修项**:`paletteVars` 原来 `text-muted = mix(text,bg,0.55)` 硬算 → 浅底掉到 2.9:1,
等于每导一次主题就把 base.css 的修复覆盖掉。改成 `mixToContrast(text, [bg, flatten(surface,bg)], 4.5)`:
二分找「最淡但达标」的比例;文字本身不达标时原样返回(宁可不够淡,不能看不见)。
- 半透明卡片(`rgba(255,255,255,0.86)`)必须**压平到底色**再算,否则按纯白算会偏低。
- 次要 5.5 / 弱化 4.5 两档,保证层级不倒挂。
- 配色面板加读数 `正文 12.5:1 · 次要 5.5:1 · 淡文字 4.6:1 · 主色 4.6:1`,<4.5 变橙(手填主色只提醒不拦)。
- 默认 primary 由 `#4a7fc1`(3.69) 压到 `#3d6fac`(4.63) —— 默认值不该一上手就踩线。
- 新增 `?dump=palette`(四种配色:默认亮/默认深/低对比手填/白卡彩底),`ok:true`。
**顺手补的一致性**:深色块顶栏原来 `!important` 定成 `--tb-text`(正文色),亮色块新的 secondary 规则
让同一顶栏在深浅两模式权重跳一档 → 加 `html[data-theme="dark"] .uk-navbar-nav > li > a { secondary !important }`。
实测计算值:无主题 nav #5c6370 / muted #6b7280;shiroko #51607a / #656e7a;sakura #6b545c / #78696e。
**⚠️ 用户删了 test01(css + 2 张图)与 `shiroko-cutout.png`,未提交,我没动。** 若测试站 `[theme] available`
还登记着 test01,启动日志会打「主题文件不存在,已从可用列表忽略」,设置页不再出现,箱主已选 test01 的会静默回退站点默认。
`shiroko-cutout.png` 只在 `docs/theme-shiroko.md` 里作参考图引用,不参与 embed。
**令牌化的残留**(只提醒,没改):`templates/user/profile.html` 还有 16 处 `font-size: 0.XXrem`;
`base.css` 自身 11 处(0.78/0.82/0.88/0.95/1.1 —— 正是被点名要消掉的那档);圆角只剩 50%/999px(语义正确)。
`--tb-fs-*` 在 base.css 里只用了 3 处(主题格、chip),阶梯主要落在模板层。
`StaticVersion` 20261021a → **20261021b**(主题 css 内容变了,必须换键)。
## 18:2x 提交 test01 删除
`2d13c31` 删掉 `static/css/themes/test01.css` + `static/images/themes/test01/` 两张 webp(用户 18:19 要求)。
`go test ./internal/theme` 仍全绿(测试里的 "test01" 只作 DisplayName 回退样例,不依赖文件)。
⚠️ `static/images/shiroko-cutout.png` 的删除**仍未提交**(属另一件事,docs/theme-shiroko.md 还引用它)。
## 18:2x 删除 shiroko-cutout.png + 清文档引用
用户确认一并删。`docs/theme-shiroko.md` 三处引用改成「参考图不入库」(不再指向具体文件路径);
顺手修了同页一处过时说法:**「--tb-deco 保持 6 个值」→ 10 个**(槽位早就扩到 10,漏改会让人以为写 6 个就够)。
删除后 embed 无影响(`static/fs.go` 只嵌 `images/themes`,这个文件本来就不参与打包)。
## 18:2x 提交模板令牌化(用户改动,一次性提交)
`001306d`:11 个模板的硬编码字号/圆角换成 `--tb-fs-*` / `--tb-radius*`,工作区已干净。
`TEST_PREVIEW=1 go test ./templates ./internal/i18n` 全绿(顺带刷新了生成器用的快照)。
提交信息里记了 4 处**故意改变观感**的替换,免得以后被当成 regression 查:
explore `.explore-name` 1.2rem→1rem、`.hot-vote-title` 0.98→0.875、首页 `.tb-stat-num` 手机 1.25→1rem、
`.hot-vote-action` 0.82→0.875(变大)。头像 50% / 胶囊 999px 有意保留。
下一轮若要彻底统一:`templates/user/profile.html` 16 处 + `base.css` 自身 11 处;`mail/*` 不要动(邮件客户端不保证 var())。
## 18:3x 收尾:字号全令牌化 + 主色补到 AA(新增 --tb-on-primary)
**字号**:`templates/user/profile.html` 20 处、`base.css` 9 处全部换成 `--tb-fs-*`。
base.css 里那 4 档导航字号(0.95/0.88/0.82/0.78)合并成「桌面 md → ≤1000 xs」,logo 三档统一 lg。
**保留未改**(有理由):`.tb-pin-flag` / `.md-body code` 的 `0.9em`(相对父级,换成 rem 会破坏继承)、
验证码的 `1.4rem/1.2rem`(等宽字形尺寸,不是正文阶梯)、chat 关闭按钮 `40px`、`password.html` 的 `3em`、mail/*(邮件客户端不保证 var())。
行内 `12px` / `0.75rem` 这几处恰好等值,顺手换了;chat 里还剩一个 `13px`(13→12 会缩,没动)。
**主色**(同色相压亮度,目标 4.7 而非 4.5,留余量):base/screw `#1e87f0`(3.26~3.42) → **`#0e6fd0`**(4.70),
shiroko `#4a7fc1`(3.69) → **`#3b6dac`**(4.74),sakura `#e5678f`(2.80) → **`#ba3e66`**(4.70,饱和度压到 .5 保持柔和粉)。
`--tb-primary-weak` 与 shiroko 的两条 box-shadow 一并换 RGB;`TestSwatch` 的断言改成 `#ba3e66`。
⚠️ **主色有两个互相矛盾的角色**:当链接色要「深」,当按钮底色配白字却要「更深的底」——
但深色模式的主色是亮强调(`#5c8dff`/`#ff8ab0`/`#6fa3e0`),白字只有 **2.2~3.1:1**。
所以新增 **`--tb-on-primary`**(亮 `#ffffff` / 暗 `#0b1016`),`.uk-button-primary` 与
`.uk-label:not(danger|warning|success|secondary)` 都吃它 → 深色模式按钮是「浅底深字」。实测 6.10/8.65/7.27。
其它泄漏点:header 验证码 focus 的 `#1e87f0`/`rgba(30,135,240)` → 令牌;profile `.feat-day.is-on` 写死的
`rgba(30,135,240,.14)` → `var(--tb-primary-weak)`;explore 随机投票的 JS outline、vote-results 的行内色 → 令牌。
**深色 muted 之前算漏了**:只对 `--tb-bg` 算(4.7~4.8 以为过了),对**压平后的卡片**才是真实背景
(sakura 4.31 / shiroko 4.17)。已提亮到 `#a18790` / `#7f8ea1`(4.62),base 深色 4.77 本来就过。
**生成器**:`paletteVars` 增加 `--tb-on-primary`(白字/深字里挑更优);`?dump=palette` 增加
`onPrimaryCr` / `onPrimaryPickedBest` / `warnings` —— 主色卡在中间调时**不断言失败**(那是主色要调,
不是挑错),列进 warnings;配色面板给橙色提示「压深配白字 / 提亮配深字」。
`_scaffold.css` 令牌数 29 → 30。`StaticVersion` → **`20261021c`**。
最终审计 8 种组合(base 亮暗 + 三主题亮暗)全部 ≥4.5。⚠️ 写 CSS 用 python 改时注意:`git checkout` 会把
base.css 还原成 CRLF,插入行要用同一行尾,否则变 MIXED。
## 18:5x 检查测试环境部署 → 抓到设置页色块 ZgotmplZ
**部署核对(都对)**:`base.css?v=20261021c`、三份 CSS(base/sakura/shiroko)与本地**逐字节一致**;
设置页主题列表 = 跟随站点默认 / 基础外观 / 樱花 / 砂狼白子,**test01 已消失**、名称无 `theme.name.*` key;
登录按钮是新蓝(#0e6fd0)白字,箱主页排版正常。
⚠️ **admin6 的主题被重置成「跟随站点默认」**(它原来选的是已删除的 test01)→ 箱主页 `<html>` 没有
`data-theme-style`,现在是基础外观。要验证主题得重新选 sakura/shiroko。
**P1 缺陷(既有,非本次改动引入)**:设置页四个主题色块全是 `style="background: ZgotmplZ"`。
`html/template` 会对 `style` 属性做 CSS 过滤,未标记安全的 `linear-gradient(...)` 一律替换成 ZgotmplZ。
`swatchOf` 一直在生成渐变串,所以这个色块**从来没显示过**。
修法:FuncMap 加 `safeCSS` → `template.CSS`,模板 `{{ safeCSS .Swatch }}`(值只来自 swatchOf,不接受用户输入)。
加了 `TestThemeSwatchRenders`(渲染 user/profile,断言不含 ZgotmplZ 且含渐变串),并做变异验证:
把 `safeCSS` 去掉 → 测试如期 FAIL。
⚠️ 教训:任何 `style="..."` 里塞模板值都要过一遍这个过滤器;模板名在测试里**不带 `.html`**(`user/profile`)。
## 19:0x 复检部署:色块修复已上线 + 主题链路验证
`1f29d3e` 部署后复检,**全部通过**:
- 设置页 HTML 里 `ZgotmplZ` 计数 **0**,四个 `tb-theme-swatch` 都是真渐变
(跟随默认灰斜块 / `#f7f8fa→#0e6fd0` / `#fceef3→#ba3e66` / `#eef3f9→#3b6dac`),截图确认有色块。
- 主题链路:把 admin6 的 `theme` 设为 `shiroko`(`POST /user/profile/theme`,该表单只有 `_csrf`+`theme`
两个字段,单独提交安全),随后
`/_/admin6` → `data-page-scope="box"`、`/_/admin6/95` → `question`、
`/user/questions` → `admin`、`/user/profile` → `profile`,四处都带 `data-theme-style="shiroko"`
与 `themes/shiroko.css?v=20261021c`。桌面渲染正常(人物站地面、车与人同向、光环完整、卡片磨砂)。
- ⚠️ **admin6 现在选着 shiroko**(为验证链路设的),要还原就把主题改回「跟随站点默认」。
- ⚠️ **`--window-size=390,844` 得到的不是 390 CSS 视口**:同一参数截本地快照也会「右侧被切」,
真实排版没问题 —— 移动端必须用**宽度锁定的 iframe**(生成器那套)验,别据 window-size 结论判 bug。
shiroko 的窄屏块(≤900px)只有一条 `html[data-theme-style]`(0,1,1),但它没有任何
`[data-page-scope]` 块,所以不会被压住;深色块的 `--tb-page-image` 覆盖窄屏属预期(深色不用实景照)。
## 19:2x shiroko 单车/步枪「白色没抠」→ 白上白,只能全白转透明
**根因不是抠图参数**:素材是「白底 + 白车架」。采样确认车架涂装与背景白是同一个值
(都在 `#f9f9f9`,差 0~3 级),颜色上完全分不开;四角 floodfill 只吃掉外圈,
轮圈内、车架三角、车筐、枪与车架之间的白整块留着 → 看着像没抠。
量化:修复前**不透明像素里近白(>240) 占 50%**,全透明只有 43%。
试过三种程序化方案(都写了脚本):
1. 只按「近白厚度」区分白洞/白涂装(腐蚀测厚度)→ **失败**:轮辐间的扇形白楔宽 9~13px,
与车架管 5~10px 重叠,阈值怎么调都会留下白色楔子(更难看)。
2. 全白转透明(`min(r,g,b)≥238` 软过渡 + 饱和度>14 保留彩色)→ 采用。成果:
不透明率 19.4%、近白 0.0%、29KB;轮辐/车架/车筐全成描边,粉色轮圈 + 蓝枪托 + 阴影仍在。
3. 手绘 SVG 替换:未做(不如现成素材真实)。
**代价要记住**:车身填色一起没了,成品是**线稿风**,在 1512 整页里偏淡(远看像铅笔稿)。
若用户要「实心车」,唯一正解是重出一张**绿幕/透明背景 + 车身用浅灰蓝等中间调**的素材
(白车在浅场景里本来就发飘),已写进 docs/theme-shiroko-assets-spec.md 的「白上白」小节,
验收清单也加了「不透明像素近白占比应≈0」这条。
改动:`props/bike.webp`(397×259 画布不变,位置公式不用动)、css 里图片 `?v=20261020d→20261021a`、
`StaticVersion 20261021c→20261021d`、两份 docs 同步。`go test ./internal/theme` 全绿。
⚠️ 画布尺寸**不能裁**:裁完 `auto 259px` 的显示宽度变了,`left calc(50% - 612px)` 的锚点会跟着漂。
## 19:4x 两件事:/user/questions「卡片透明」根因 + 主题素材上 R2
**1) /user/questions 卡片透明 —— 不是主题,是模板没有卡片**
`templates/user/question-list.html` 整个文件只有 1 处 `.uk-card`(投票 tab 的「我的投票」标题条);
提问列表是 `<div class="uk-margin-medium-bottom">` + `<hr>` **裸铺在页面上**。
默认主题底色 #fff 看不出来,换带整页插画的主题后场景整片透上来 = 「卡片透明」。
修:`.qlist-panel` 加卡片底(`--tb-surface` + `--tb-radius` + `--tb-shadow` + padding,
≤640px 收紧),内部那张 `.uk-card-default` 降成 `--tb-surface-2` 去阴影(防卡片套卡片)。
验证:本地快照注入 shiroko 后,行内容落在卡上,场景只从卡外透出;默认外观只是多一层白卡。
⚠️ 页面级样式仍写在模板 `<style>` 里(不搬 base.css),符合既有约定。
**2) 主题素材放 R2 —— 三种做法,生成器已支持 A**
素材只有一处引用:主题 css 的 `url()`(分享卡是 Go 用 imaging 画的,不读素材)。
- A 生成器「素材前缀」:导出面板新增输入(`S.assetBase`,存进工程 json,`?assetbase=` 可测),
`urlOf()` 一处决定地址 → 绝对 URL。**踩到过坑:`layerCSS` 里复制了一份路径拼接**,
只改 urlOf 会导致「背景走 CDN、装饰还指本地」,已统一到 urlOf。
- B 应用内 301:`[theme] asset_base` + 中间件(必须注册在 `flamego.Static` **之前**)。
- C 边缘/反代改写(CF Origin Rule / nginx)。
细节与取舍写进新文档 `docs/theme-assets-cdn.md`(含缓存、CORS、深色无素材、迁移 sed 命令)。
⚠️ UI 坑:面板 240px 里 4 个中文字的 label 会被 flex 压缩、末字被输入框切掉 →
label 要 `flex:0 0 auto; white-space:nowrap`,宽度给 84px 才够(68px 仍会切)。
**3) 素材托管:本地 / 任意 S3 兼容,生成器内测通即可上传**
用户要求「放本地还是放 S3 兼容(不只 R2)由部署者自己定,并且能配好即测、测通即传」。
- `serve.py` 手写 SigV4(零依赖,PUT/GET/DELETE/HEAD),新增 `/api/s3/ping|test|upload`。
测试连接四步:HEAD 桶 → PUT 探针 → GET 读回比对 → 匿名 GET(能否被浏览器直读)→ 删探针。
4xx 把 `<Code>` 翻成人话(`S3_HINTS`),签名失败直接指向密钥/Region/时钟三件事。
- `index.html` 新增「素材托管」面板,上传集合复用导出用的 `usedAssetIds()`;
成功后自动回填「素材前缀」。密钥只进 sessionStorage,不进工程 json(有断言)。
- **两个真 bug**:①`usedAssetIds()` 返回 Set,`!ids.length` 恒真 → 永远「还没有素材」;
②字段填完 700ms 内刷新会丢(scheduleSave 防抖)→ 失焦/切模式/上传成功时立即 `saveDraft()`。
- **验证手段**:`check_sigv4.py` 用 aws-sdk-go-v2 的 signer 逐字节对拍(6/6)——
裸 SignHTTP 会二次转义 URI、而 S3 client 关了它,所以用例只用 ASCII,
中文 key 的编码规则跟随 S3 client(单次 RFC3986)。
`selftest_upload.py` 假 S3 跑 16 项;`mock_s3.py` 可手动点面板。
- ⚠️ **8777 端口陷阱**:Windows `SO_REUSEADDR` 让新进程 bind 成功却抢不到 accept,
症状是「代码改了但接口还是 404」。查 `Get-NetTCPConnection -LocalPort 8777`,或换端口验证。
**4) 远端素材检测与管理(用户「还没测,想先看到 S3 上有什么」)**
- serve.py:`s3_request` 支持 query,新增 `list_objects`(ListObjectsV2 + 分页)与
`delete_objects`;端点 `/api/s3/list`、`/api/s3/delete`。
- index.html:远端素材区,按对象键名跟本地比 → 一致 / 旧图(同名大小不同)/ 多余 / 缺失;
「补齐差异」只传缺的与旧图,「删除多余」批量删,单行 ↻ 覆盖、✕ 删除。
- ⚠️ **键的两种形态**:list 返回**完整键**(含 `<路径前缀>/`),upload/delete 接口收
**相对键**。不剥这一层,服务端会再拼一次前缀 → 删一个不存在的键,S3 还回 204,
表现为「显示删除成功但东西还在」。加 `relKey()` 统一转换。
- ⚠️ `check_sigv4.py` 补了带 query 的用例(ListObjectsV2 三参数、乱序传入的分页 token),
8/8 一致 —— canonical query 的排序与百分号编码也必须对拍,漏测会静默失效。
**5) 生成器默认监听 0.0.0.0(局域网/手机可用)**
- `serve.py --host` 默认由 127.0.0.1 改成 0.0.0.0;启动时用 `local_addresses()` 列出所有
网卡 IP(getaddrinfo + UDP connect 双路兜底),0.0.0.0 本身不打印成可点地址。
- 顺带修两个真问题:①`TCPServer` 是单线程 —— 传一张几 MB 的图会卡住 iframe 预览与其它请求,
换 `ThreadingTCPServer`;②写接口加 `_same_origin()`(Origin 与 Host 比对,无 Origin 放行),
挡住「同网段另一个网页借访客浏览器发写请求」。curl/脚本不受影响。
- `index.html` 提示文案里写死的 `127.0.0.1:8777` 改成 `HERE`(`location.host`)——
用局域网 IP 打开时不能反过来告诉用户「请用 127.0.0.1 打开」。
- ⚠️ **浏览器按 origin 隔离存储**:`127.0.0.1` 与 `192.168.x.x` 是两个站点,
localStorage 草稿与 sessionStorage 里的 S3 密钥不共享,换地址要重填一次(README 已写)。
- `mock_s3.py` 加 `--host`(默认仍 127.0.0.1):生成器在别的设备上打开时,假 S3 也要 0.0.0.0。
- 验证:局域网 IP 访问 200、`--host 127.0.0.1` 时局域网访问连不上、跨站 Origin 被拒(403)、
局域网 origin 下检测/补齐全走通。⚠️ 本机 curl 访问 192.168.x.x 必须加 `--noproxy '*'`,
否则被系统代理接走报 502。
+250
View File
@@ -0,0 +1,250 @@
# 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/`。
+144
View File
@@ -0,0 +1,144 @@
# 2026-10-24
## /stats 入口收敛到页脚
- 用户要求:首页那张统计入口卡取消,入口只放页脚「社区规则」与「找站长」之间。
- 改动:
- `templates/home.html`:删掉统计入口卡整块 + `.tb-stats-entry*` 样式(原 `</details>` 后那一段)。
- `route/home.go`:`Home` 不再调 `stats.Get`(模板已不读 `.SiteStats`),去掉 `logrus` import;
`/api/v1/site-stats` 与 `SiteStatsAPI` 保留未动。
- 四份 i18n 字典删掉已无引用的 `site.stats.open`(`site.stats` 老词条保留)。
- 页脚 `templates/base/footer.html` 的 `site.footer.stats` 位置本就在 rules 与 admin 之间,无需再动。
- 验证:`go build` + `./internal/i18n` / `./templates` / `./route/...` 测试全绿;gofmt 无差异;
重新生成预览确认首页无 `tb-stats-entry`,页脚渲染为「社区规则 | 数据 | 找站长」。
- 教训:删掉页面唯一消费方后,服务端的取数调用(`stats.Get`)要同步删,
否则每次访问首页白算一次快照;对应 i18n 词条也要一起清,避免留死条目。
## /stats 文案与脚注排版修正
- 页脚入口文案「数据」→「统计数据」(`site.footer.stats`,四语言 `Statistics` / `統計データ` / `統計數據`)。
- **「提问最集中在三 19 点前后」的 bug**:洞察句子复用了热力图列头的 `stats.wd.*`
("一"…"日" / "Mon"…),那是为窄列头设计的单字标签,进句子就成了"三"、英文是 "Wed"。
新增一组完整名 `stats.weekday.1..7`(周一/週一/Monday/月曜日),洞察改用这组;
`stats.wd.*` 只留给热力图列头。已在 `overview_test.go` 加断言锁住,防止回退。
→ 教训:**短标签和完整名必须分两套词条**,别为了少写一组 key 复用。
- 页面脚注四条时间(基准日 / 计算于 / 最后一条提问 / 最后一条回答)由
「A:x · B:y」挤成一行改为 `grid-template-columns: max-content 1fr` 各占一行,
标签列与值列对齐;下面两段说明仍为独立段落。四个标签统一带冒号。
## 部署前自查:两个 P0 都已修
用户部署到测试环境后要求自查。查出的问题都是我自己上一轮引入的:
1. **「今日」两项被每日缓存冻住**(P0,与页面「实时」说明矛盾)。
→ `TodayCounts` 拆出,60 秒 TTL + 跨日按日期失效;命中缓存时返回副本。
新增 `TestTodayNumbersNotFrozen` / `TestTodayCacheExpiresAcrossMidnight`。
2. **日期分组跟着 PG 会话时区(默认 UTC)**(P0,隐蔽:数字都"合理",只是整体偏 8 小时)。
psql 实测:`2026-10-24 01:30+08` 在 UTC 会话下 to_char 得 `2026-10-23`、hour=17。
→ `analyticsLocalize()` 用 `AT TIME ZONE INTERVAL '+08:00'`(偏移取 `time.At()` 的 Zone,
不用 IANA 名)。EXPLAIN 确认过滤仍走 created_at 索引。
3. PeakDay 用 `>=` 会让「最忙的一天」随更新漂移到最近的同值日期 → 改 `>`。
4. `ovRefresh` 里的聚合改用 `context.WithoutCancel` + 30s 超时:第一个访客中途关页面时,
排队的请求不会各自重跑全表 GROUP BY。
性能实测(20 万行):totals 337ms / 字数分布 313ms / 热力图 100ms / 日趋势 47ms,
本地合计约 0.8s,1C1G 估 2s,靠预热兜住。百万行才需要合并扫描。
顺带发现(未改,与本次无关):`Dockerfile` 基础镜像是 `golang:1.20-alpine`,
而 `go.mod` 要求 `go 1.27.3` —— 1.20 不支持 GOTOOLCHAIN 自动下载,Docker 构建必然失败;
`-X` 里的包路径还是 `NekoWheel/NekoBox`。说明实际部署不走这个 Dockerfile。
## 测试环境实测(box-dev-api.337845818.best,用户时区 UTC+8)
- `/stats` 200,首访 5.2s(含 TLS),稳定后 TTFB 1.6s,同站 `/rules` 1.34s
→ 本页只多约 250ms,1C1G 上可接受,无需合并聚合 SQL。
- 环境默认语言是 ja(`/`、`/rules` 同样是 ja),与本次改动无关;语言存 session
(`/i18n/{lang}`),故 /stats **绝不能加 CDN 缓存头** —— 内容随 session 变化且无 Vary: Cookie。
- 逐项验算自洽:月柱合计 95 = 累计提问;热力图 7×24=168 格合计 95;
最忙时段周三 19 点 = 22 与小时合计 26 峰值一致;折线 30 点峰值 10-19 共 5 条。
- `/stats/` 尾斜杠 302 跳首页 —— 与 `/rules/`、`/sponsor/` 一致,框架行为。
## P0:回答耗时口径是错的(本次修)
`SetRead` 把 updated_at 往前推(实测 10 天前的提问,delta = 240h)。
所以「回答耗时 = updated_at − created_at」实际测的是「提问到最后一次被碰」,
线上「6 条超过 7 天」就是这么来的,24 小时占比 66.7% 同样偏低。
修法:`questions.answered_at`(首次回答时间),只在 answer 由空变非空时写,
改写回答不重置;聚合 `COALESCE(answered_at, updated_at)`;加列时回填一次冻结历史。
测试:`internal/db/answered_at_test.go` 5 个用例,含变异验证(改回 updated_at 即红)。
## 顺带修
- 环比句式:中文「比前一个月 -69.6%」、英文「...: -69.6% questions.」读不通 →
「上月提问量环比 %s%%」/「Month-over-month change for last month: %s%%」。
- 脚注「当日 0 点的快照」不实(实际是当天首访/重启时算)→ 改成准确描述。
- DB 故障时 `ovRefresh` 串行 + 30s 超时会让请求排成 N×30s → 加 30s 失败冷却
(`ovFailAt`),`todayNumbers` 失败也盖时间戳,不再每请求打两条 COUNT。
## 30 天折线补纵轴刻度(`cbb8908`)
- `internal/template/chart.go`:`Polyline`/`AreaPath` 两个函数收敛成 `NewLineChart`
返回 `LineChart{HasData,Max,Points,Area,Ticks}`。**坐标/面积/刻度必须一次算**,
分两次各自归一化会让线和刻度错开半格。
- 纵轴上限 `niceMax(峰值)`(1/2/1.5/3/4/6/8×10^n 向上取整),刻度 上限/一半/0。
1.5 倍只在 mag≥100 时参与(否则算出 7.5 半格);上限恒为偶数 → 中间刻度必为整数。
- 刻度文字**不能放进 SVG**:viewBox 是 `preserveAspectRatio="none"` 横向拉伸的,
文字会被拉扁。改左边一列 HTML 标签(`.tb-line-y` + 绝对定位 `.tb-line-tick`,
top 用 Go 算好的 `TopPct`),横向虚线仍在 SVG 里(`non-scaling-stroke`)。
- 列宽靠看不见的 `.tb-line-ruler`(最宽那条刻度)在流内占位撑开,不写死 em。
- 面积路径改为沿基线 `h-padBottom` 闭合(原来落到 viewBox 底边,会漏进轴标签留白)。
- 全零/空数据只出轴不出线(`HasData`),否则一条贴基线的直线看着像"数据都是 0"。
- `internal/template/chart_test.go`:锁三条不变式 + 0..2000 穷举。
- 视觉自检:900px 亮/暗 + 390px iframe(Windows headless 最小窗宽 ~485px,不能直接缩窗)。
## 取证:`GetHotQuestions` 的 `ORDER BY updated_at DESC`(未改代码)
- **先纠正前提:这个方法全仓零调用点**,只有接口声明 + 实现(`route/` 里 Questions 的
15 个调用方法不含它;`git log -S` 自 baseline 起也没删过调用)。属死代码,线上零影响。
- 连真库实测(临时测试,跑完已删):
- 两条公开已答问题:old 9 天前回答 / new 1 小时前回答 → `GetHotQuestions` 得 `new old`;
- `SetRead(old)` 后 → `old new`(名次被翻);`SetPinned(old)` 同样;
- 换 `ORDER BY COALESCE(answered_at, updated_at) DESC` → 稳定 `new old`;
- old 的 `answered_at` / `updated_at` 相差 216h。
- 机制:`dbutil.Model.UpdatedAt` 是 GORM 约定字段,任何 `Update/Updates/Save` 都会刷新。
所以污染源不止「标记已读」:`SetPinned` / `SetPublic` / `SetPrivate` / `UpdateCensor` 全是。
- **同类残留(仍在线上生效)**:`QuestionStats` 的 `LastAnswerAt = MAX(updated_at)`,
被老的 `/api/v1/site-stats` 用着(首页已不 fetch,但公开接口还在)。实测被推到当下,
比真实最后回答晚 1 小时。新 `/stats` 页已改用 `COALESCE(answered_at, updated_at)`。
- 另:`analytics.go` 的「今日回答数」也用 `updated_at >= since`(注释已承认偏大),未改。
## 已改(`8380fc9`):删死代码 + 修 LastAnswerAt
- 删 `GetHotQuestions`(接口声明 + 实现)。零调用点、唯一实现者 `*questions`、无 fake/mock。
- `QuestionStats.last_a` 改 `MAX(CASE WHEN answer <> '' THEN COALESCE(answered_at, updated_at))`。
先写测试跑红(偏差 216h)再改绿,回退路径另有一条测试。
- 影响面只有 `/api/v1/site-stats`(`last_answer_at` + `text`),字段名/结构不变,值变准。
- 性能:5 万行实测 7.9ms vs 8.0ms,3 次重复差 −0.5~1.8ms(噪声带内,约 30ns/行);
执行计划仍 Seq Scan + Aggregate,且该接口有 30 分钟缓存。
- 仍未改:`analytics.go:202` 今日回答数用 `updated_at >= since`(置顶会算进"今日回答"),
改它会直接改变 /stats 首页数字,留待用户决定。
## 已改(`219adef`):今日回答数也换 answered_at
- `TodayCounts` 今日回答:`answer <> '' AND updated_at >= ?` → `answered_at >= ?`。
实测:三天前回答的旧问题依次 SetPinned/SetPrivate/SetPublic → today answers 1→2(改前)。
- 口径 = 「今天**首次**被回答的提问数」,与文案「今日回答 / Answered today」一致;
改写回答不重置 answered_at,所以改写不算今日回答(有意)。
- 性能:`answered_at` 无索引 → Seq Scan;但 `updated_at` 本来也没索引,同样是 Seq Scan。
20 万行实测 36.7ms vs 38.0ms(3.5%,噪声级)。**没加索引**,注释里留了
「真慢了再加 idx_questions_answered_at」。
- ⚠️ 顺手发现:`analytics.go` 注释原写「只扫当天数据(走 created_at 索引)」,
但「今日回答」那半句一直不是索引扫(updated_at 无索引)。已改正注释。
- 测试:`TestTodayAnswersIgnoreNonAnswerWrites`(改前红 1 vs 2,改后绿)。
## 已改:/stats 脚注去掉「数据基准日」、「计算于」→「快照时间」
- 用户反馈「计算于」表达怪:它其实只是**累计数字与图表**那份快照的生成时间,
而今日两项在缓存命中时会被 `withToday` 换成更新的读数 —— 放在页脚最底部读起来像「整页时间」。
- 「数据基准日」与它 100% 重复:`DataDate` 就是 `UpdatedAt` 的日期部分(同一个 `now`),
删掉 `stats.foot.dataDate` 词条 + `Overview.DataDate` 字段(模板/预览测试同步改)。
- 页脚从 4 行变 3 行:快照时间 / 最后一条提问 / 最后一条回答;「今日两项实时」由下面
的 `stats.foot.cacheNote` 说明,不塞进标签里。
- 词条:快照时间 / 快照時間 / Snapshot: / 集計時刻(`stats.foot.updated` 改名改值)。
- i18n 有词条数量对等测试,删键必须四个语言一起删。
+126
View File
@@ -0,0 +1,126 @@
# 2026-10-25
## /stats 文案清理:把「写给运营者看」的话删掉
用户指出「数据速览」下面那句「下面几句是从上面的数字直接读出来的,没有推测」怪 ——
那是对他自己说的(作者视角的免责),不是对访客说的。顺着扫了页面全部 40 余条文案。
改动:
- **删 `stats.insight.note`**(四语言 + 模板那行 `<p class="tb-card-note">`)。
卡片留「数据速览」+ 列表即可;`stats.insight.empty` 保留。
删掉说明行后标题与列表之间只剩 4px,给 `.tb-insights` 补 `margin: 8px 0 0`。
- **`stats.foot.cacheNote` 去掉实现细节**:「当天重算一次的快照(当天第一次访问时算,
服务重启也会重算)」→「累计数字与图表每天更新一次」。访客不需要知道缓存是懒重算、
也不需要知道重启会触发重算;准确的新鲜度由「快照时间」那行给出。
- 顺手修了 `templates/stats.html` 里一条过期注释(还写着「四条时间」「基准日」,
上一轮已改成三行「快照时间」)。
保留未动(判断过,不属于这个问题):
- `stats.foot.privacy` 与顶部 `stats.lead` 的后半句近乎同义(隐私声明重复一次)——
页脚隐私声明是惯例,重复不算噪音,故保留。
- `stats.today.tag` 的「(实时)」在 60s TTL 下仍成立,不改。
## 约定(已写进 topics/stats.md)
页面文案一律以**访客**为第二人称对象:不写实现细节(缓存、重启、索引),
不写作者视角的免责(「没有推测」「这里只有」「本文」这类把读者当审稿人的话)。
数据来源/隐私/新鲜度这些确实要对访客交代的事,放 `stats.lead` 与页脚注。
## 环比句改成「点明月份 + 条数」
用户问「上月提问量环比 -75.0%」是不是「上月同期比本月同期」。**不是**——实际比的是
两个**完整自然月**(9 月 vs 8 月)。但这个问题本身就是证据:句子里「上月」和「环比」
两个相对词叠着,读者得先做两次翻译,很容易读成「同期对比」。
- 新句:`9 月收到 5,620 条提问,比 8 月的 4,890 条多了 14.9%。`
EN:`September had 5,620 questions; August had 4,890, up 14.9%.`
- 口径没变(仍是上月 vs 上上月,本月没过完不参与),只换表达。
- `page.go` 新增 `monthText`:CJK 一律「9 月」,英文用 `time.Month().String()`
("month 9" 不是英语),不带年份——相比的两个月相邻。
- 三个词条 `momUp/momDown/momFlat` 取代 `mom`:方向词没法塞进同一个模板,
四种语言语序不同。参数顺序统一(本月份名/条数、对比月名/条数、|pct|)。
- `|pct| < 0.05` 走持平,避免「多了 0.0%」。
- 测试:`TestInsightMoMNamesBothMonths`(上升/下降/持平三分支,断言两个月份名 +
两个条数都在句子里)、`TestMonthText`。
- 顺手修预览测试:`GrowthMoM` 原来写死 12.3,与 `monthly` 造的数据对不上,
而新句会同时印出两个月的条数 → 截图会显示「5620 比 4890 多了 12.3%」这种假证据。
改成从 `monthly[10]/monthly[9]` 算。
## 安全评估(AI 挖洞背景下的一次全量自检)
报告:`outputs/security-audit-2026-10-25.md`,脚本在 `outputs/sec-audit-2026-10-25/`。
结论:无 getshell / SQLi / 未授权读数据;但 2 个 P1 直击「匿名」承诺。
### P1-1 匿名提问可植入远程图片(去匿名化)
- `markdown.go` 的 `safeURL` 只校验协议,`<img src=https://任意域>` 能原样输出。
- 唯一挡住它的是审核「广告_引流类」维度,而该维度**只在 strict 档生效**
(`censor/policy.go`:standard 只有 Abuse/Contraband,lax 为空)。
- 实测:账号切 standard → `<img src="https://tb871870.example.org/standard.png">` 直接入库并渲染;
切回 strict 才被拒。→ 修法必须在 `markdown.go` 层限制 img src 为站内/白名单,不能依赖档位。
- 同时确认 8 种混淆(实体编码、`java\tscript:`、协议相对 `//`、反斜杠)都被审核识别。
### P1-2 访问密码可爆破 + 提问密码校验早于验证码
- `UpdatePagePassword`/`UpdateAskPassword` 只有 `maxlen:30`,**无 minlen**,可设 1 位。
- `page.go` 的 `New()` 第一件事就比对 AskPassword,验证码要到后面才校验 →
实测「错误提问密码 + `deadbeef:999999`」返回「提问密码错误」而不是验证码错误 →
**猜密码完全不消耗验证码**。限额 30/min(ask)、10/min(page)。
### 其余P2/P3
- 登录开放重定向:`to=/\evil.example` → `Location: /\evil.example`,浏览器按 WHATWG
把 `\` 当 `/` → `https://evil.example/`。`path.Clean` 不处理反斜杠。
- 注册接口枚举邮箱(返回「已注册」),与 forgot-password 的防枚举口径不一致。
- 无 CSP;`X-XSS-Protection: 1` 过时;`/user` 无 `Cache-Control: no-store`。
- 限流/自动封禁是进程内存态(多副本叠加、重启清零)。
- 匿名投票身份 = `vote_fp` cookie,清 cookie 即换身份,仅 200/min/IP 兜底。
- 登录 `to` 兜底是死代码:`path.Clean("/"+to)` 恒非空 → 登录后永远落 `/` 而非箱主页。
- JSON 响应无 `Content-Type`(实测 text/plain);429 无 `Retry-After`。
### 无法从外网确认的一项
`trust_proxy` 真实值。三种伪造头(XFF / X-Real-IP / EO-Client-IP)各 22 次都不被采信
(好的方向:无法伪造绕限流),但这既可能是 `trust_proxy=false`(→ 按 CF 边缘 IP 聚合,
限流与封禁会误伤整片区域),也可能是 true 且在 CF 侧删了 EO-Client-IP。需登服务器看 app.ini。
### 已验证没事(别重复怀疑)
CSRF 绑 uid(无 token POST → 400);18 条 XSS 载荷全灭;跨箱题目/投票越权全 302;
页面密码对主页/整箱分享图/单题分享图/API 四个出口都生效;pixel/service/ShareCard 无 SSRF;
`exec cwebp` 不接用户输入;SMTP 默认校验证书;`RegenerateID` + `pwd_ver`;TLS1.0/1.1 拒绝;
无 CORS 头;测试站蜜罐已开(`/.git/HEAD`、`/.env` 返回假后台)。
### 测试卫生
- 测试站账号 `tbunread1022181904`,脚本已把严格度恢复 strict、清空 page/ask 密码、
删除本轮 15 条测试提问(115–129)。下次复用前先确认这三项是干净的。
- 隔离 venv 里装了 `requests`(`~/.workbuddy/binaries/python/envs/default`),
比 urllib 顺手;`/api/captcha/generate` 限流 20/min,批量提交脚本必须带 429 重试。
## 安全评估(续):trust_proxy=true 引出的 P1-3
用户确认两套环境 `trust_proxy = true`。据此重做了 IP 采信那一块,发现比原来的推断严重:
### P1-3 双 CDN 下权威头优先次序反了
- `internal/shared/ip.go` 的 `ResolveClientIP` **无条件**先读 `CF-Connecting-IP`,再读 `EO-Client-IP`。
- 前提「权威头只能由 CDN 写入」只对 Cloudflare 成立:
- CF:强制覆盖 `CF-Connecting-IP`,客户端伪造直接 **403**(生产/测试站实测一致)。
- **EdgeOne:不剥离客户端传入的 `CF-Connecting-IP`**,`share.shiroko.one` 实测带该头返回 200
(响应头 `Server: edgeone makers` / `EO-LOG-UUID`)。
- 代码优先读 CF 那个头 → 走 EO 线路时客户端 IP 完全可控。后果:限流/IP 黑名单全失效,
且 `autoban.Record` 可被用来**栽赃任意 IP**(填别人的 IP 连打 10 次敏感路径 → 对方被封)。
- **今天打不通**:`box.shiroko.one` 境内递归(AliDNS DoH)只解析到 `cloudflare.182682.xyz` → 104.18.x/172.64.x,
文档里写的境内 `eo.dnse4.com` 线路已不在;`box.tama.guru` 301 跳新域名也走 CF。差一条 DNS 记录。
- 修法:权威头也套 `isTrustedProxy(remote)`(回环可用 cloudflared 本机场景),fail-closed;
EO 侧删 `CF-Connecting-IP`;日志同时记 `remote_addr` 与解析后 IP。
### 源站定位(外网已尽力,未找到)
- `box.shiroko.one` CNAME `cloudflare.182682.xyz`(第三方 CF SaaS 优选,zone 不在我们手里,
其 Transform/WAF 规则无法自查)。
- certspotter 枚举 `*.shiroko.one` 11 个、`*.337845818.best` 19 个子域,逐个解析全部落
Cloudflare / 腾讯 EdgeOne / Vercel(216.198.79.x、64.29.17.x),**无一条直连记录**。
- `status.shiroko.one`、`share.shiroko.one` → 43.174.246.43/247.43,用 box 的 SNI 访问回 418 Teapot
→ 是 EO 边缘不是源站。
- 结论:源站隐藏目前成功,但「找不到」≠「不可达」,仍需服务器侧 `security-selfcheck.sh local` 确认
`listen = 127.0.0.1:5877`。
### 顺带修正
上一版报告曾把「三种伪造头都不生效」推断为 `trust_proxy` 可能为 false —— 那是错的解读:
无论 true/false,限流键都落在我自己的真实 IP 上,外部不可分辨。已在报告第四节改正。
+67
View File
@@ -0,0 +1,67 @@
# 2026-10-26
## 修复 P1-3:CDN 权威客户端头未校验来源
用户确认:生产/测试 `trust_proxy = true`,回源是 **cloudflared Tunnel 回本机**(对端 127.0.0.1);
境内 **EdgeOne 已取消**(实测期国内速度不理想),将来可能重新启用。
### 改动
- `internal/shared/ip.go`
- 新增 `headerSourceTrusted(remote)`:对端是回环 或 命中 `trusted_proxies` 才允许采信权威头。
- `ResolveClientIP` 把 `CF-Connecting-IP` / `EO-Client-IP` 读取整体收进该判断;公网对端忽略。
- 公网对端带权威头 → `sync.Once` 一条 WARN(`remote_addr` + `header_ip`),覆盖「伪造」与
「CDN 直连回源忘登记网段」两种情形。
- XFF / X-Real-IP 信任范围未变(仍只认 `trusted_proxies`,回环不隐含信任)。
- `internal/shared/ip_test.go`:原 4 个用例按新契约调整;新增
`RejectsForgedHeaderFromPublicPeer`(4 种伪造组合)`TrustedProxyHonorsCDNHeader`
`IPv6LoopbackHeader` `LoopbackDoesNotTrustXFF`;加 `setTrustedProxies` 助手(还原包级状态)。
- `conf/app.sample.ini`(CRLF,用脚本按行块改,未破坏行尾)+ `internal/conf/static.go` 注释同步。
### 验证
- 变异验证:把 `headerSourceTrusted(remote)` 临时改成 `true`(旧逻辑)→ 回归用例 3 个子项失败;
还原后全绿。确认测试咬得住。
- `go build -overlay dev/overlay.json ./...` 通过;排除 `internal/storage`(overlay 桩缺
`fitsWithin`/`aspect` 等辅助函数,属既有问题)后全包测试通过。
### 环境坑(值得记)
- 本机 `go` 是 1.25.5,`go.mod` 要 1.27.3;`GOSUMDB=off` 会让工具链校验失败(
"verifying module: checksum database disabled by GOSUMDB=off"),`GOTOOLCHAIN=local` 又降级到 1.25.5。
解法:直接调缓存里的工具链
`~/go/pkg/mod/golang.org/[email protected]/bin/go.exe`,配 `GOTOOLCHAIN=local`。
- Python 读 CRLF 文件要显式 `newline=''`,否则 universal newline 把 `\r\n` 归一成 `\n`,
`split('\r\n')` 只会得到 1 行(踩过一次,误判文件被写坏)。
### 待办
P1-1(img src 白名单)、P1-2(密码 minlen + 验证码顺序)、P2-3(`to` 开放重定向)均未动。
## 修复 P1-1 / P1-2
### P1-1 外链图片(`internal/template/markdown.go`)
- 新增 `safeImageSrc()`:`<img src>` 只允许站内相对路径(无协议部分)或主机命中自有图片出口
(`image_bucket_cdn_host` / `aliyun_bucket_cdn_host` / `<bucket>.<endpoint>` / `external_url` 主机)。
- 含反斜杠一律拒(`\` 在浏览器等价 `/`);`//` 先于「以 / 开头=站内」判断;
`url.Parse` + `Hostname()` 比对 → 后缀 / userinfo 把戏失效。
- `img` 无合法 src 时**整段丢弃**(只删 src 会留 `<img alt>` 变成破图)。
- `a[href]` 仍走 `safeURL`(外链需点击,产品上要允许)。
- 测试:`TestSafeImageSrc`(12 条拒绝)+ `TestMarkdownToHTML_ImageSource`(端到端);
变异验证:`safeImageSrc` 直接 return true → 7 条拒绝用例失败。
### P1-2 密码(5 个文件)
- `minlen:6` + `form.PasswordMinLen`;handler 里 `rejectTooShortPassword()` 二次校验(rune 计数);
前端 `minlength=6 maxlength=30` + 提示文案「6–30 位,留空则关闭」;i18n 新增
`form.profile.passwordLen` / `question.errPasswordLocked`(4 语言)。
- 校验顺序:抽 `askPasswordDenied()`,从 `New()` 首行挪到**两个分支验证码之后**(私聊 + 普通提问各一次)。
没把验证码提到最前——会让「箱已关闭」「需登录」变成验证码错误。
- 失败锁定:新增 `shared.AttemptLimiter`(无后台 goroutine,惰性清理 + 键数上限);
`pagePasswordGuard(5, 10min, 10min)`,键 = 箱主ID + LimitKey(IP)。锁定期内不比对密码。
### 端到端验收脚本
`outputs/sec-audit-2026-10-25/verify_p1_fixes.py`(`--only p1-1,p1-2a,p1-2b,p1-2c`)。
**在未部署的旧版本上四项全 FAIL,已实测** → 证明检测有效;部署后应全 PASS。
踩到的两个判定坑(写脚本时记下来):
- 提问成功页是 **200**(不是 302),不能用状态码判断提交成功。
- 受保护的箱是**在 `/_/<domain>` 内联渲染密码表单(200)**,不是跳转 `/password`;
判断「有没有密码闸门」只能看页面里有没有 `action="/_/<domain>/password"`。
- 箱主页与 `/api/v1/user/<domain>/questions` 都**不暴露题目 id**(无后台),
要定位刚建的提问只能扫 `/_/<domain>/<id>`(脚本里 `question_ids_with_marker`)。
+92
View File
@@ -0,0 +1,92 @@
# 2026-10-27
## 在 WSL Debian 上把 TamaBox 跑起来,做 P1 修复的端到端复验
用户新搭了 WSL2 Debian,给了 `data/data.txt`(测试环境的 PG / Redis / OSS 凭据),要求「用它部署测试一下」。
**环境**(详细踩坑见 `topics/build.md` 的「本地部署」小节):
- `wsl.exe -d Debian -u root` 可免密进 root;apt 装了 `redis-server / python3 / python3-requests /
gcc / libwebp-dev`(有 libwebp 后 `s3.go` 正常编译,不用 s3stub overlay)。
- Go:`/opt/go` 装 1.27.3,但 `~/.bashrc` 里 `GOROOT=/usr/local/go`(1.26.3)→ 必须 `env -u GOROOT`。
- PG:新建 role `tamabox`(CREATEDB)+ 库 `tamabox`、`nekobox`(schema `nekobox`)。
- 服务:`/etc/systemd/system/tamabox-test.service`,源码在 `~/tb/src`,二进制 `~/tb/tamabox`,
日志 `~/tb/server.log`,端口 5877。配置 `~/tb/src/conf/app.ini`(`production=true`、
`external_url=http://127.0.0.1:5877`、`captcha_type=math`、`enable_text_censor=false`、`trust_proxy=true`)。
**验收结果(全 PASS)**:
- P1-1 外链图片剥离 / 白名单图片保留 / 实体与反斜杠变体;
- P1-2a 4 位密码被拒、6 位生效;P1-2b 验证码先于密码;P1-2c 6 次失败后正确密码仍被锁;
- P1-3 A/B:回环对端 + `CF-Connecting-IP: 1.2.3.4` → 命中黑名单 302;Windows 侧(非回环)同样头 → 忽略并 WARN;
- 回归:18 条 XSS 载荷无可执行/无外联、CSRF 无 token 400、越权全 302;
- 单测:`internal/{template,shared,form,conf,db,route}` + `route/{question,vote}` 全绿。
**这次揪出的新问题(已修)**:P1-1 的白名单把配置串整串当主机名,而真实配置是
`image_bucket_cdn_host = "r2.337845818.best/tamabox"`(host/path)→ 自家 R2 图片被一起剥掉。
修法:`normalizeHostLike()` 归一化(补协议 → url.Parse → Hostname),endpoint 主机自身也入白名单
(R2 path-style 链接是 `<endpoint>/<bucket>/…`);新增 `TestSafeImageSrc_RealConfiguredHosts`,
变异验证(退回旧逻辑 → 4 条放行用例变红)。**教训:白名单类单测必须照抄生产配置的写法,不能自己编一个好写的形态。**
**P2-3 从推断升级为实测**:`/login?to=/\evil.example` 登录后 `Location: /\evil.example`,
按 WHATWG 解析 = `http://evil.example/`;`\\evil.example` 同样外跳(`//`、`///` 已被规整)。
同一载荷在正文渲染里是安全的(`<a href="/\example.org">` 被剥成 `<a>`)。修复未做,等用户指示。
**产物**:
- 报告新增「六、本地部署实测」章节(`outputs/security-audit-2026-10-25.md`);
- 脚本新增 `verify_p1_allow.py`(正向放行)、`local_seed.py`(本地注册审计账号),
`audit.py` 改为 `TB_BASE/TB_EMAIL/TB_PWD/TB_DOMAIN` 可覆盖 + 验证码 429 退避。
**待办**:改动仍未 commit / push;P2-3、P2-4、P2-5、P2-6、P2-7 未动。
---
## 第二轮:修完 P2/P3 + 全局复查
用户:「把你发现的问题修了,然后全局检查一遍有无其他问题」。
**已修(P2-3~P2-8、P3-9~P3-15)**:都写进 `topics/sec.md` 的「第二轮修复」。要点:
跳转白名单收敛到 `shared.SafeRedirectTarget`(`url.Parse` 会解 `%0d%0a`,必须逐 rune 查 `u.Path`);
注册三条失败分支共用 `registerSucceeded`;CSP 三档 + umami 的 `mami.shiroko.one` 必须入 `script-src`;
`no-store`;`securityHeaders` 前移;封禁落库 `ban_records`;常量时间比较;页码上限;429 统一出口。
**全局复查新发现 3 个**(详见 `topics/sec.md`):
1. 🔴 `chat_handler.SendMessage` 的 POST 缺 `conv.OwnerID != pageUser.ID` —— 被拉黑者换 domain 即可继续骚扰
+ 触发邮件 + 绕过屏蔽词/日限额。已修,3/3 PASS,并做变异验证(去掉校验后对应用例变红)。
2. 🟡 页面密码 `next` 的反斜杠外跳(与 P2-3 同源)。
3. 🟡 分享卡无字体时每请求烧数秒 CPU(公开端点)→ 失败结果缓存 1 分钟,实测 3.44s → 0.53s。
**验收**:`verify_p1_fixes.py` 4/4、`verify_round2.py` 19/19、`verify_page_next.py` 6/6、
`verify_chat_owner.py` 3/3;单测全绿;全项目编译通过。测试数据已清(库里只剩 `tb-local@example.com`)。
**报告**:`outputs/security-audit-2026-10-25.md` 新增「七、第二轮修复与全局复查」。
**再待办**:仍未 commit / push(26 改 + 9 新);CSP 是否切 `enforce`;生产机补 glyf 轮廓字体。
**收尾复跑发现两个「假红」**(已写进 `topics/sec.md`):① 不给 `TB_BASE` 时脚本打的是远端
box-dev-api,没部署修复所以 4/4 FAIL;② 页面密码入口限流 10 次/分钟 + p1-2c 的 10 分钟锁定
会让 `verify_page_next.py` 后两项 429。顺手把该脚本判据从「Location 不含 evil」改成精确比对
`/_/<domain>`、每条载荷开新会话、429 单独报错。四套最终:4/4、19/19、6/6、3/3。库已清回只剩 `tb-local`。
---
## 第三轮:用户「按最优解决定」→ CSP 切 enforce + 字体落地 + 提交
**CSP 默认改 enforce**(`cspMode()` 空值 = enforce;写错值仍退 report)。依据不是猜:
写了 `outputs/sec-audit-2026-10-25/csp_sweep.py` + `csp_sweep.cjs`(Playwright + 本机 Chrome,
监听 `securitypolicyviolation`),report 模式先测一遍、enforce 再测一遍,**18 页面 + 2 图片端点零违规**。
巡检器自己做了变异自检(页面里主动 `fetch("http://csp-selftest.example/x")` → 确实报 connect-src),
否则「0 违规」可能只是监听器没挂上。
**补的漏项**:`browser.sentry-cdn.com` —— header.html 里 Sentry 在 `{{ if ne SentryDSN "" }}` 里,
本地没配 DSN 所以巡检碰不到,线上一开就会静默失效;现在按 `conf.App.SentryDSN` 条件放行 + 单测双向断言。
**分享卡字体**:补 glyf 系统候选(wqy-zenhei / wqy-microhei / DroidSansFallback / uming)并压到
Noto(CFF) 之前;`static/fonts` 同时按 CWD 和可执行文件目录找;`CheckShareCardFont()` 在 runWeb 里
启动即探(命中 Info 带路径,缺失 WARN 给 apt 命令)。实测 `apt install fonts-wqy-zenhei` 后
零配置即 `font=…wqy-zenhei.ttc`,`/share-card` 与单题分享卡从 500 → `200 image/jpeg`,
中文/二维码正常(图存 `outputs/sec-audit-2026-10-25/sharecard-*.jpg`)。
**提交**:三个 commit(7802bb2 安全修复 / e807dea 字体 / 430c6ef 记忆)。**push 失败**:
`git.shiroko.one` 的 https 存取凭据被拒(`Failed to authenticate user`),SSH 路(`id_ed25519_wsl`)
也未被该仓库授权 —— 需要用户更新 token 后自己 push。
**全量单测**:带 `DB_TYPE=postgres DB_USER=tamabox DB_PASSWORD=… DB_HOST=127.0.0.1` 全绿;
不带 DB 变量时一批 DB 门控用例会报 `Unknown database type: ""`(不是回归)。
+381
View File
@@ -0,0 +1,381 @@
# 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」。
## 全盘复查(数据面板/图片压缩之后的第二轮)
用户要求「再做一次全盘复查」。两个并行子代理扫了本轮新增代码(数据面板、图片压缩、
删除事务化、错误页、找回提问),交叉核对后确认的问题与处置:
### 已修(本轮)
1. **P1 提问真删不彻底**(`questions.go` DeleteByID 对主表 Unscoped,但追问/图片仍软删):
- `follow_ups.go DeleteByQuestionID`、`upload_images.go DeleteByQuestionID` 改 Unscoped 真删。
- 关联表 UploadImageQuestion 无 DeletedAt,无需 Unscoped(已注释)。
- 新增 `internal/db/question_delete_hard_test.go`:用 Unscoped 计数断言删除后追问/图片/关联行
物理上=0。**变异验证**:把追问改回软删,测试失败(expected 0 actual 1)。
2. **P1 BoxTrend 每日回答数按 created_at 分组**(应为 answered_at):`box_analytics.go` 加 answerDay
表达式(PG 用 `to_char(COALESCE(answered_at,updated_at) AT TIME ZONE ...)`,MySQL 用 CONVERT_TZ),
WHERE/GROUP/SELECT 三者口径一致。新增 `internal/db/box_analytics_test.go`:构造「40 天前创建、
昨天回答」的提问,断言计入昨天。**变异验证**:改回 d.day 后测试失败。
3. **P2 ask-status domain 存在性可枚举**:`ErrUserNotExists` 分支原返回空数组、正常分支返回带元素数组,
凭空/非空可枚举 domain。改为箱主不存在时也回显 `ok:false`(与「存在但 token 错」完全同形)。
4. **P2 私聊 SendMessage 写路径用 FeatureStateOf**(fail-open 变体):改 FeatureStateOfStrict,读配置失败即拒。
(此前靠「chat 默认关闭」巧合侥幸安全,现在按写路径约定统一。)
5. **P2 图片压缩 submit 兜底可重复提交**:capture 监听器里 tbBeginSubmit 从 Promise 回调提前到
preventDefault 的同步栈,物理阻止压缩窗口内双击重复提交。
### 判定不修(子代理误报,或有意设计)
- **投票删除不回退配额**:子代理当 bug,但这是**有意为之**——投票配额口径是「累计收到的票数」,
删投票不代表票没发生过(记忆里早记过:一度加 -1 是错的)。注释已写清。
- **recomputeHeights 里 `d.UV > maxPV` 死代码**:UV≤PV 恒成立,该行永不触发,但当前「PV/UV 同刻度」
恰好是设计意图,不算错。
- **TopDays 死代码**:`BoxViewsStore.TopDays` 无调用方,best-day 只回看 30 天。属口径/注释自相矛盾,
但不改数据安全,留待后续。
### 踩坑(本轮新)
- **gofmt -l 把「缺末尾换行」也标为未格式化**:Edit 工具写文件时可能丢末尾换行符,`gofmt -l`
会报 `\ No newline at end of file`。与行尾问题不同,补一个 `\n` 即可。
- **struct 字段对齐**:手写 struct 时字段没对齐(`Questions int64` 少一个空格),gofmt 会标。
用 `dev/gofmt_keep_crlf.py` 统一修。
- **CSRF token 登录后会轮换**:flamego/csrf 在登录成功后轮换 token(防固定),所以登录前 GET /login
拿到的 token 在登录后失效,POST 接口测试要登录后再从带表单的页面重新取。这导致 ask-status 的
真实 HTTP 验证没跑通(靠代码 diff 确认修复正确)。
- **`git show HEAD:file` 输出写到 /tmp 后 Windows Python 读不到**:/tmp 是 Git Bash 的虚拟路径,
Windows Python 看不到。要落盘验证行尾,写到仓库内(如 outputs/)再读。
## 三项待办落地:AutoMigrate 版本化 + 备份脚本 + Telegram/Webhook 通知
用户「先按1-2-3的顺序做」,期间两次纠正 R2 存储结构认知。
### 1. AutoMigrate 版本化迁移(internal/db/migrations.go)
- 新增 `schema_migrations` 表(version 主键 + name + applied_at),`runMigrations(db)` 按版本升序
执行未应用的迁移,每个迁移在事务里「跑 up + 记版本」。
- 把 db.Init 里散落的 5 个幂等补丁收编为版本迁移 v1~v5:
backfillAnsweredAt / backfillFeatureControls / reconcileLegacyChatEnabled /
normalizeChatQuota / normalizeHarassmentSetting。收编后**只跑一次**(原来每次启动都跑)。
- AutoMigrate 仍负责「加表/加列/加索引」(声明式、幂等、无数据丢失),版本迁移负责
AutoMigrate 做不了的破坏性变更(改列名/删列)与一次性回填。两者分工,互不替代。
- 迁移失败阻断启动(半途而废比静默跳过更危险)。
- 测试:migrations_test.go(幂等跑两遍不重复 / 失败阻断后续 / 清单严格升序)。
- 实测:真实库首次启动建表 + v1~v5 落账本,二次重启账本仍 5 行(幂等)。
### 2. 备份脚本(internal/cmd/backup.go,TamaBox backup)
- `--output <dir>` 输出;`--db-only` / `--images-only` 分开关。
- DB:调 pg_dump(PGPASSWORD 环境变量传密码避免 ps 泄漏),输出 db-<ts>.sql(标准恢复格式)。
- 图片:从 R2 下载 tamabox 站全部图片 + 写 images-manifest.json(key→size→md5)。
- 两个 CLI 入口(根 tamabox.go + cmd/tamabox.go)都注册了 Backup。
- 实测:db-only 导出 46KB(21 表全含 schema_migrations);images-only 30 秒下 15 个文件、
路径保持 tamabox/ 结构、大小与 List 一致。
### 3. Telegram/Webhook 通知(internal/notify/notify.go)
- 站级 Webhook([notify] webhook_url):新提问/新回答/新追问/新私聊/新投票回复统一 POST JSON。
- 用户级 Telegram([notify] telegram_bot_token + users.telegram_chat_id 字段):新提问/新投票点对点推送。
- 通知是「尽力而为」旁路:任一通道失败只记日志不抛错,不影响业务主流程;未配置则静默跳过。
- 6 处业务触发点接入 notify:page.go(新提问+新私聊)、question.go(新回答)、
follow_up.go(新追问+追问回答)、vote.go(新投票)。私聊消息保留仅邮件(已有节流,避免刷屏)。
- UI:profile 加 telegram_chat_id 输入框 + 四语言字典(admin.notifyTelegram* 3 条)。
- 测试:notify_test.go(webhook JSON 结构 / 未配置跳过)。
### 踩坑(本轮新)
- **R2 存储结构(用户两次纠正,重要!)**:图片实际存在 Cloudflare R2,CDN URL 形如
`https://r2.337845818.best/tamabox/2025/12/1767082504316655685.jpg`(bucket=tamabox,
key=年月/时间戳.扩展名,**无 picture/ 前缀**)。但 R2 API 侧(ListObjectsV2)返回的 key
**带 bucket 名作前缀**(`tamabox/2025/12/...jpg`)——这是 path-style bucket 的 R2 行为。
- 关键:`storage.GetS3Client` 的 endpoint 配置(image_endpoint 带 `/tamabox` 后缀 +
Bucket 参数 tamabox)对 GetObject/PutObject 恰好可用(aws-sdk 拼出 /tamabox/tamabox/<key>,
R2 容错),但 **ListObjectsV2 会拼成 /tamabox/tamabox?list-type=2 → 404 NoSuchKey**。
- 备份脚本的正确姿势:剥离 endpoint 的 `/tamabox` 后缀 + UsePathStyle=true + Bucket=tamabox,
List prefix="tamabox/",GetObject 直接用 List 返回的完整 key(含 tamabox/ 前缀)。
- 同一 R2 account(0c99...)下还有 blog/ 前缀的另一个站点内容(共享 account)。
- **代码的 PictureKeyPrefix="picture/" 与线上不一致**:mainline 代码上传 key 用 `picture/` 前缀,
但线上历史图片 key 是 `tamabox/` 前缀(即实际 key 无前缀、靠 bucket 隔离)。这是待澄清的
「代码 vs 线上」不一致,本轮未动,需用户确认是否要统一。
- **Windows Edit 又把 3 个 LF 文件转成 CRLF**(conf.go / users.go / form/user.go):
老坑复发,用 Python `b.replace(b'\r\n', b'\n')` 统一转回 LF 解决。
## R2 存储结构最终定论(用户贴出生产 app.ini [upload] 段后)
生产 app.ini 关键段:
```
[upload]
default_avatar = "https://r2.337845818.best/tamabox/tamabox_low.png"
default_qrbackgroundimage = "https://r2.337845818.best/tamabox/picture/2024/01/01.jpg"
image_endpoint = "https://<acct>.r2.cloudflarestorage.com/tamabox" # 注意带 /tamabox 后缀
image_bucket = "tamabox"
image_bucket_cdn_host = "r2.337845818.best/tamabox"
```
实测铁证(GetObject 精确验证):
- 生产配置(endpoint 带 /tamabox + Bucket=tamabox + Key)下,`key=2025/12/1767082504316655685.jpg`
返回 OK(187274),而 `key=picture/2024/01/01.jpg` 返回 404。
- **结论:R2 bucket tamabox 的真实 key = `YYYY/MM/<17位雪花ID>.<原扩展名>`,无 picture/ 前缀、
无 tamabox/ 前缀。**
- 代码 `PictureKeyPrefix="picture/"` + `randstr.Hex(15)` 生成的 key 在 R2 里**根本不存在**。
- `default_qrbackgroundimage` 里的 `picture/2024/01/01.jpg` 是**手动上传的站级静态资源**(孤立的),
不是代码生成的,且该 key 实测 404(可能已失效)。
**重大结论:生产部署的存储层代码 ≠ mainline 仓库的存储层。**
mainline 的 `picture/YYYY/MM/DD/<15hex>`(无扩展名)是一套**从未上线、与生产脱节**的实现;
生产真实用的是 `YYYY/MM/<雪花ID>.<原扩展名>`(无前缀)。差异太大不可能是小改动,
是存储层被整体替换过但改动没合回 mainline。
对备份脚本的影响:
- 备份脚本用「无后缀 endpoint + pathStyle + Bucket=tamabox + prefix=tamabox/」List,
返回的 key 带 tamabox/ 前缀(pathStyle 的 R2 行为),用同一 key GetObject 下载是自洽的、
能下全所有图片(含 picture/ 之外的真实 key)。
- 但落盘路径带 tamabox/ 前缀,与「真实 key 无前缀」在语义上不一致——恢复回传时需注意。
- 待用户决策:是否把 mainline 的 PictureKeyPrefix 和 key 生成逻辑改回生产真实格式
(`YYYY/MM/<雪花ID>.<ext>`),还是维持现状。这是存储层正确性问题,未动。
## 存储层 key 口径对齐生产(已落地)
用户确认「生产就是按 mainline 在 10-25 前最后一次修改部署的,有调整 git 查得到」,并拍板
「先备份当前源码,再按生产口径统一代码」。
### 根因最终定论(纠正之前「雪花ID」的说法)
- 生产 URL 里 `1767082504316655685` 不是雪花 ID,而是 **UnixNano 纳秒时间戳**(19 位):
`datetime.fromtimestamp(n/1e9)` = 2025-12-30 16:15:04,与路径 `2025/12` 完全吻合。
- **生产口径 key = `YYYY/MM/<UnixNano>.<原扩展名>`**(无前缀、无 day 层)。
- 铁证:`route/question/page.go:uploadImage`(提问配图入口)**早就是这个格式**:
`uploadKey = fmt.Sprintf("%d/%d/%d%s", now.Year(), now.Month(), now.UnixNano(), fileExt)`。
脱节的只有 `internal/storage` 包里三个函数(UploadAvatarToS3 / UploadBackgroundToS3 /
UploadPictureToS3 及 UploadPictureToOSS),它们还在用老的 `picture/ 前缀 + YYYY/MM/DD + 15hex + 无扩展名`。
- git 历史是**浅快照**:`PictureKeyPrefix` 只在 `e3e59e7 "chore: baseline snapshot"`(2026-09-10,
却是「最早提交」)出现一次,之前之后都没改过 —— 完整历史在快照之外,10-25 前的改动追溯不到。
### 改动(internal/storage/)
1. `picture.go`:`PictureKeyPrefix` 改 `""`;新增三个辅助函数:
- `uploadKey(ext)` → `YYYY/MM/<UnixNano><ext>`(唯一 key 生成点)
- `extFromContentType(contentType)` → MIME→扩展名映射(保证 key 扩展名与 ContentType 严格一致,
避免「.jpg 实为 webp」错配)
- `originalExt(filename)` → 取原扩展名并小写(OSS 原图直传通道用)
2. `s3.go`:key 生成改 `uploadKey(extFromContentType(contentType))`;删除 day 变量与 randstr 依赖。
注意转换成功用 `.webp`(contentType=image/webp),回退原图用原格式扩展名(contentType=image/jpeg→.jpg)。
3. `oss.go`:`UploadPictureToOSS` 的 `_ *multipart.FileHeader` 改 `fileHeader`(取原扩展名),
原样上传不转 webp,key 用 `uploadKey(originalExt(...))`。
4. 新增 `picture_test.go`:锁定 key 格式正则 `^\d{4}/\d{2}/\d{16,19}\.jpg$` + ext 映射 + originalExt。
### 验证
- gofmt 通过;`go build ./...` 通过;storage 全量测试 + 新增测试 PASS。
- 行尾:三个文件都是 LF(工作区 LF),Edit 未破坏(HEAD 对比行数变化正常,非整文件重写)。
### 踩坑(本轮新)
- **go 版本假象**:`go version` 报 1.27.3(GOROOT 指向 toolchain 目录),但 `GOTOOLCHAIN=local`
时暴露真实工具链是 **1.25.5**;go.mod 要求 1.27.3。正确编译姿势:直接调已下载的 toolchain 二进制
`$HOME/go/pkg/mod/golang.org/[email protected]/bin/go.exe`,
或去掉 GOSUMDB=off 让 toolchain 校验通过。用 `GOTOOLCHAIN=local` + 系统 go 会报
`go.mod requires go >= 1.27.3 (running go 1.25.5)`。
- **备份目录别放 .go 文件**:把 storage 源码备份到 `outputs/backup-*/` 后 `go build ./...`
会把备份当 Go 包编译(undefined: announcementConfig 等)。备份的 .go 文件要改 `.go.bak` 后缀,
或放到仓库外。
- **fmt 的 `%s` 对 int64**:`fmt.Sprintf` 用 `%s` 拼 int64 能编译但 `go vet`(测试时自动跑)报
`%s has arg of wrong type int64`,且语义模糊。纳秒时间戳必须用 `%d`。
+106
View File
@@ -0,0 +1,106 @@
# 2026-10-30
## 蜜罐周期安全汇总(周报/月报 + 地图 + 邮件)
用户需求:以周/月为单位汇总安全状况,把嗅探 IP 通过**公共服务**查地理位置,汇总到**地图**,
发到邮箱。起因是他发现线上有人隔一段时间换不同 IP 探测蜜罐。
### 关键认知:ipinfo 与 geoip 是两个维度(用户亲自纠正)
用户反馈「ipinfo 只能查机房,肉鸡全显示『未识别(普通网络)』」。原因是
`internal/security/ipinfo` 走 `projectdiscovery/cdncheck` 的离线 ASN 指纹,只认云厂商/CDN/WAF。
它答的是「**是不是代理**」,不是「**在哪儿**」。肉鸡(家宽、企业宽带、移动网络)必然是后者,
所以安全汇总必须另建一套真正的 IP 地理库 → 新包 `internal/security/geoip`。
### 新增文件
- `internal/security/geoip/geoip.go` — ip-api 批量打底(100/批、1.4s 节流)+ 腾讯精修国内
(只精修前 200 个,一 IP 一请求)。`Result{Geo, Unresolved, Requests, Failures}` 三态。
- `internal/security/report/{period,aggregate,mapdraw,html,geo,countryname}.go`
- `internal/db/{ipgeo,security_report}.go` — `ip_geo_cache`(主键 = `LimitKey`)、
`security_report_logs`(`period_key` 唯一索引保证幂等)
- `internal/cmd/report.go` — `honeypot report --period week|month` + 30 分钟一轮的调度器
- `templates/mail/security-report.html`
### 踩过的坑(都要记住)
1. **ip-api 免费版很慢**:22 个 IP 实测 4–10 秒,且同一批重复请求更慢。最初设 15s 超时 →
第一次真实数据就整批超时、定位率 0%。现在 `defaultTimeout = 45s` + 重试一次。
2. **缓存毒化**:早先把所有 miss 都写缓存并 `Failed=true`(TTL 3 天)→ 一次网络抖动
等于把整批 IP 钉成「查不到」三天,报告定位率莫名变 0 且无任何线索。
现在只有服务**明确回答**没数据才允许写失败缓存;请求失败绝不写。
3. **`addrs` 不能从 map 取顺序**:腾讯精修只取前 N 个,若按 map 迭代(随机)取,
每次运行精修到的 IP 都不同,配额花在长尾上。必须按 `keys` 原始顺序(= 命中数降序)建。
4. **地图标签会叠出假数字**:同一都市圈两个城市坐标只差 0.01°,标签精确重叠,
「AU 55」+「AU 30」叠成了看起来像 **AU 305** 的字符串 —— 读图的人会当结论。
已加 `labelPacker`(不重叠才画,右侧放不下换左侧)。
5. **不能无脑把腾讯结果的国家码写成 CN**:`provider=tencent` 时海外 IP 也走这条路。
现在从 `ad_info.nation` 精确判「中国」才给 CN。
### 合规(地图)
自绘底图**零国界**,只画经纬网 + 气泡 + 度数刻度;真实底图交给腾讯地图(配 key 才有,
未配给申请指引)。代码里不预置任何 key,测试里有禁词断言(googleapis/mapbox/osm/bing)。
报告里 HK/MO/TW 统一显示为「中国香港 / 中国澳门 / 中国台湾」(`countryname.go`),
且常见国家改用中文名(报告是给中文读者看的)。
### 端到端验证(WSL Debian + PG 17.11)
854 条仿真数据 / 22 个来源:首跑 36s(真实 ip-api)、次跑 1.3s(缓存命中),
定位覆盖 22/22、14 个国家/地区、未能定位 0 次。
### 顺手修的(别人改动留下的)
- `internal/route/honeypot_test.go` 的 `captureStore` 缺 3 个新方法 → 整个 route 包编译不过
- `templates/render_test.go` 的 `mockLoggedUser` 缺 `TelegramChatID` → 3 个渲染测试失败
## 蜜罐邮件提醒功能审阅(用户要求,重点找 bug 与改进点)
审阅报告:`outputs/review-honeypot-mail-2026-10-30.md`。基线测试全绿(mail + route 蜜罐相关)。
### 结论分级
- **P1(应修)`autoban.alerted` 去重口径不一致**:`map[string]bool` 永不过期,且 `cleanup()`
从不清理它 → 同一 IP 的告警**一生只发一次**;而蜜罐侧 `dedup` 用 24h TTL。两条链路口径不同,
长期盯防同一来源会漏掉后续攻击活动。建议改成带时间戳的 map + cleanup 按窗口清理。
- **P2(建议修)蜜罐告警空收件人不短路**:`route/honeypot.go:alert()` 没有
`SecurityAlertEmail == ""` 前置判断(autoban 侧有 `alertEmail != ""`)。蜜罐开着但没配收件人时,
每次凭据提交都走一遍无效 SMTP 拨号 + 留 Warn 日志,与「未配置静默跳过」预期不符。
- **P2(边界)邮件明文密码**:`SendHoneypotAlertMail` 把明文 password 渲染进 HTML。代码已意识到
是敏感物。建议维持明文(功能价值=运维立刻拿到字典)+ 强化「收件人必须是个人邮箱、禁群组」的
配置警示,而非一刀切掩码。
### 审阅中确认「已做对、无需改」的点(值得记住的设计)
- 凭据**类型层面**不入库:`db.HoneypotHit` 结构体没有 Username/Password 字段,编译期保证;
明文只落 0600 按天 JSONL。
- 蜜罐独立于 `enable_auto_ban`(`RecordHoneypot` 只看 `HoneypotEnabled`)。
- 纯 GET 探测不立即封(留「GET 表单→POST 提交」的咬钩机会)。
- 精确 IP 与 /64 封禁键分离;body 只读一次先提取凭据再判封禁;`clipRunes` 按 rune 截断防非法 UTF-8。
### 三项已修(commit 036156b)
1. **P1 `autoban.alerted` 去重口径**:`map[string]bool` → `map[string]time.Time`,新增
`alertCooldown = 24h` 常量 + `alertedRecently()` 辅助 + `cleanup()` 清理过期项。
与蜜罐侧 `alertDedupTTL=24h` 对齐。新增 `TestAlertCooldownExpires`(回拨 alerted 时间戳
模拟冷却期已过,断言能再次告警)。
2. **P2 空收件人短路**:`route.go` 注入 `sendAlert` 前判 `SecurityAlertEmail` 空值,空则置 nil,
`alert()` 的 `h.sendAlert == nil` 自然短路。(autoban 侧本就有 `alertEmail != ""` 判断,无需改。)
3. **P2 明文密码警示**:`conf/static.go` 注释 + `app.sample.ini` 注释强化「蜜罐邮件含明文密码,
收件人必须个人邮箱、禁群组/分发列表」。维持明文不改掩码(功能价值=运维立刻拿到字典)。
验证:autoban/route/mail/conf 测试全绿,`go build ./...` 通过,gofmt 干净,行尾未破坏(LF)。
## 更新日志文档 + 首页间距修复
1. **更新日志**:`outputs/changelog-2026-10-30.md`,站长口吻、口语化,只写用户可感知的功能
(数据面板/Telegram 通知/找回提问/图片压缩/404 页/验证码不丢正文/私聊完整记录)。
蜜罐、备份、迁移等纯后台内容刻意不写。注意:Telegram 条目依赖生产配好 [notify] 的 bot token。
2. **生产配置答疑**:蜜罐子项全有 sanitizeHoneypot 兜底,无需补;真正要显式加的是
honeypot_cred_dir、报告配置段(report_email 留空回退 security_alert_email)、[notify] 可选段。
生产 ban_window_seconds=10 比默认 60 短,已提示用户确认是否有意。
3. **首页间距修复(ea9ba3e)**:`templates/home.html` 里 tb-grid 之后的三张独立卡片
(域名迁移/一点提醒/近期更新)彼此零间距挤成一团。修法:`.tb-grid ~ .uk-card { margin-top: 16px }`
(模板内联 <style>,不碰 base.css,无需 bump StaticVersion)。
截图验证:headless=old + file:// 预览页,亮暗两主题间距均正常。
**坑:headless Chrome 的 --screenshot 相对路径写不进**(Failed to write file),必须用绝对路径。
-67
View File
@@ -1,67 +0,0 @@
# TamaBox-v4 长期笔记(索引版)
明细已拆分到 `topics/*.md`(按需 Read 全文):
`coop` 协作 / `build` 构建测试 / `embed` 行尾·模板·embed / `review` 审核处置举报 /
`gorm` GORM / `style` 前端样式 / `theme` 主题 / `builder` 主题生成器 / `misc` 其它约定。
## 协作
- 结论分级 P0/P1/P2;先根因取证 + 实测复现,别只静态推断。
- 改代码前先出变更清单;重大问题显式指出。用户常授权自主推进。
- 用户是运营者也是圈内人:处置必须可解释、可留痕、可申诉,不做自动级联封号。
## 构建与测试(详见 topics/build.md)
- 无 libwebp/GCC:`internal/storage/s3.go` 挡编译 → overlay 换桩 `dev/s3stub/s3_stub.go`。
- 「编译卡死」= `proxy.golang.org` 不可达。每条 go 命令前
`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod`。
- DB 测试:`DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=15207786347hXPm DB_HOST=127.0.0.1 DB_PORT=5432`(DSN 不带库名)。
- store 方法内用 `NewXStore(db.DB)`,别引用包级单例。
- 本沙箱起不了 `TamaBox web`(session 固定 redis,6379 未开)→ HTTP 端到端只能部署后做。
- python urllib 不稳定(用 curl);python 别用 `/tmp`;PIL 在 `~/.workbuddy/binaries/python/envs/default`。
## 行尾 / 模板 / embed(topics/embed.md)
- 行尾混合不可推断,改前用 Python 数 `b'\r\n'`;gofmt 走 `dev/gofmt_keep_crlf.py`。
- `templates/embed.go` 是显式清单:新增**顶层**模板必须加 `//go:embed`。
- `//go:embed` 的 `*` 不匹配 `_`/`.` 开头文件。
## 审核 / 处置 / 举报(topics/review.md)
- 线上通道 = 七牛;裁决在 `censor/policy.go` + `moderation.Check`,新增调用点必须走 `moderation.Check`,不可用时 fail-closed。
- 严格度存 `users.moderation_strictness`;受处置用户强制 strict(`moderation.EffectiveStrictness`),查询失败回退用户设置。
- 同请求渲染只能用 `ctx.SetError`/`SetInternalError`,不能 `*Flash`。
- 处置四级 L1–L4;到期靠 `expires_at` 无 cron;`restrict` 是替换语义;比较用 `RestrictionAtLeast`。
- L2 强制私密须在审核通过后、与写回答同一条 UPDATE(`AnswerByIDPrivate`)。
- `ListAPI` 不经 Pager 中间件,L4 与页面密码须自校验;分享卡拦截须在缓存查询之前。
- 举报:免登录+验证码+限流;同 IP 同题唯一索引 → 幂等;箱主只看次数不给 IP。
- 告警 24h≥8 或 7d≥20 → 邮件;`[security] security_alert_email` 为空则静默跳过。
## GORM(topics/gorm.md)
- 默认值必须带引号;事务里每句 `tx.WithContext(ctx)`;链式对象带状态,统计/更新各自重建 query。
- 软删除+唯一索引 = 槽位永久占用;级联删除一律 `Unscoped()`。
## 前端样式(topics/style.md)
- `static/css/base.css`(`?v=` 取 `conf.StaticVersion`,改完 +1)。令牌在 `:root` + `html[data-theme="dark"]`,新样式一律 `var(--tb-*)`。
- 移动端(≤640px)语言/主题按钮在固定底栏 `#tb-mobile-utils`;箱主页**有意**无语言切换。
- UIkit 3.3.3:`.uk-flex::before/::after{display:none}`;主色是编译期常量,需在 base.css 末尾映射。
- 视觉自检:`TEST_PREVIEW=1 go test ./templates -run TestGenPreview`;截图用 `chrome --headless=old`。
## 主题(topics/theme.md、topics/builder.md)
- 三个注入点按箱主/发布者的 `Theme`;投票页语言也跟发布者(`applyOwnerLocale`)。
- 一套主题 = `static/css/themes/<slug>.css`,只覆盖令牌;显示名用 `{{ themeName ... }}`,别直接 `printf "theme.name.%s"`。
- `[theme] available` 是硬白名单(启动时读一次);css 必须成对写亮/暗两个选择器。
- 装饰层固定 10 槽,三组 `--tb-deco/-size/-position` 必须等长;位置用两值 `calc(0%|50%|100% ± N)`。
- 主题生成器 `dev/theme-builder/`:非破坏性图片处理、`layerSize`/`unitGeom` 必须共用同一函数、
`hideDeco` 会把 @media 10 槽全写 none(窄屏素材消失的头号来源)。
## 其它约定(topics/misc.md)
- 校验用 `valid:"required;maxlen:N"` 标签。
- 只有箱主 `/user` 面板,**无管理员角色、无站方后台**(用户要求不要加):站方动作只走 CLI / 邮件 / 改配置重启。
- CLI 有两个构建入口必须同步:根 `go build .` 与 Dockerfile 的 `./cmd/`。
- 现有 CLI:`web censor uid ban restore reports restrict unrestrict hide-public`。
- git `https://git.shiroko.one/tamakyi/tamabox`(main),`git push` 可直推;`outputs/` 已 gitignore。
-17
View File
@@ -1,17 +0,0 @@
# 构建与测试(长版明细)
## 构建与测试
- **无 libwebp/GCC**:cgo 的 `internal/storage/s3.go` 挡编译 → overlay 换桩
`dev/s3stub/s3_stub.go`(`-overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json"`)。
要检查 s3.go 本身得 tar 复制项目 + `go mod edit -replace`(overlay 换不了 GOMODCACHE)。
- **「编译卡死」= `proxy.golang.org` 不可达**。每条 go 命令前
`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod`(`go env -w` 沙箱失败)。
1C1G 是正式生产环境,别用「沙箱慢」解释超时。
- DB 测试:`DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=15207786347hXPm DB_HOST=127.0.0.1 DB_PORT=5432`
(DSN 不带库名,`NewTestDB` 自建临时库后 DROP)。带 DB 的 `go test ./...` 全绿。
- store 方法内用 `NewXStore(db.DB)`,别引用包级单例(测试下 nil panic;`FeatureStateOf` 有守卫)。
- ⚠️ **本沙箱起不了 `TamaBox web`**(session 固定 redis,6379 未开,启动 panic)→ HTTP 端到端只能部署后做。
探测端口 200 可能是别的进程,必须看日志确认 `Starting web server` 且无 panic。
- ⚠️ **本机 python urllib 不稳定**(代理下偶发读超时,curl 正常);python 别用 `/tmp`
(Windows 解析成 `C:\tmp`,与 MSYS 不通)。PIL 在 `~/.workbuddy/binaries/python/envs/default`。
+42 -1
View File
@@ -20,7 +20,17 @@
⚠️ `@media` 里**每个独立配置的 scope 都要写一条** `[data-page-scope="x"]`:页面覆盖块特异性 (0,2,1),
只写 `html[data-theme-style]`(0,1,1) 的话独立配置页在手机上完全不生效。
⚠️ 竖版图只替换**本来有背景图**的页面(`cfg.bg` 非空),否则等于给纯色页塞了张图。
- 自检钩子:`?dump=css|zip|validate|install|fx|fxpipe|fxkey|layers|fit|quality`,`?demo=1&fresh=1&rows=N&scope=&loadurl=`
- ⚠️ **派生文字色必须走对比度下限,不能按固定比例混**:`paletteVars` 里
`text-secondary = mixToContrast(text, backdrops, 5.5)`、`text-muted = 4.5`(WCAG AA)。
旧的 `mix(text,bg,0.55)` 在浅底只有 2.9:1 —— 每导一次主题就把 base.css 的 AA 修复覆盖回去。
- 半透明卡片(`--tb-surface: rgba(255,255,255,0.86)`)必须 `flatten()` 压到底色再算,否则按纯白算偏低;
- 参考背景取「页面底色 + 压平后的卡片」两者更不利的那个;
- 手填正文本身不达标时**原样返回**(宁可不够淡,不能产出看不见的字)。
- 配色面板有读数(正文/次要/淡文字/主色),<4.5 变橙;默认 primary 已压到 `#3d6fac`(4.63)。
- 还要导出 **`--tb-on-primary`**(白字 `#ffffff` / 深字 `#0b1016` 里挑对比度更高的):
深色模式主色偏亮,白字只有 2.2~3.1:1。主色卡中间调时两种字都不够 → 面板橙色提示 +
`?dump=palette` 的 `warnings`,**不判失败**(是主色要调,不是工具挑错)。
- 自检钩子:`?dump=css|zip|validate|install|fx|fxpipe|fxkey|layers|fit|quality|palette`,`?demo=1&fresh=1&rows=N&scope=&loadurl=`
(`?demo=1` 已含 fit 层 + 竖版背景,`@media` 才会生成)。用法:
`chrome --headless=old --virtual-time-budget=12000 --dump-dom "http://127.0.0.1:8777/dev/theme-builder/index.html?demo=1&fresh=1&dump=fit"`
抓 `<pre id="dump">`(正则要带属性:`<pre id="dump"[^>]*>`)。
@@ -31,3 +41,34 @@
且数值放进 label 里(第三个小 flex 项会被顶出可视区)。
- `?dump=css` 的产物要过 Go 侧校验:临时存成 `static/css/themes/<非下划线名>.css` 再 `go test ./internal/theme`,
跑完删掉。
- 导出面板「素材前缀」`S.assetBase`:空 = `/static/images/themes/<slug>/`;填了 = CSS 写绝对 URL
(R2/OSS)。**图片地址只能由 `urlOf()` 决定** —— `layerCSS` 曾复制过一份拼接,改一处会出现
「背景走 CDN、装饰还指本地」。文档见 `docs/theme-assets-cdn.md`。
- **素材托管(2026-10-21)**:面板两选一 `S.host.mode = local|s3`,字段
endpoint/region/bucket/prefix/pathStyle/publicBase 进工程,**accessKey/secretKey 只进
sessionStorage**(`SEC_SESSION_KEY`),绝不进草稿/工程 json/导出 zip(有断言)。
`publicBaseOf()` 手填优先,空则按 endpoint 推导(path 风格 `<ep>/<桶>/<前缀>`)——
对 R2 这个地址不可匿名读,测试的「匿名可读」只给 warn 不拦。
- **上传走 `serve.py` 代理**,浏览器不直连对象存储:不用配 CORS,密钥不发给第三方。
端点 `/api/s3/ping`(探测能力,`python -m http.server` 没有 → 按钮禁用)、
`/api/s3/test`、`/api/s3/upload`。上传集合与导出共用 `usedAssetIds()`。
- ⚠️ **`usedAssetIds()` 返回 Set**:`!ids.length` 恒真、`ids.map` 不存在 → 上传永远报
「还没有素材」。用 `[...usedAssetIds()]` 展开。
- ⚠️ **SigV4 手写,必须对拍**:`dev/theme-builder/check_sigv4.py` 调
`dev/sigv4ref`(aws-sdk-go-v2 的 `signer.SignHTTP`)逐字节比。6/6 一致才可信。
- 只用 ASCII 用例:裸 `SignHTTP` 默认**二次转义** URI,而 S3 client 内部把
`DisableURIPathEscaping` 设成 true(单次编码)。我们跟随 S3 client / AWS 文档 RFC3986。
- canonical headers 三件套 `host;x-amz-content-sha256;x-amz-date`,与 SDK 相同;
CanonicalHeaders 自身以 `\n` 结尾,故其后与 SignedHeaders 之间有一个空行(规范如此)。
- ⚠️ **HEAD 桶失败拿不到 `<Code>`**(HEAD 响应无 body)→ 只能按状态码归因
(403→AccessDenied / 404→NoSuchBucket / 301|307→PermanentRedirect)。想拿真错误码要靠 PUT。
- ⚠️ **Windows 上 8777 可能被上一个进程继续占着**(SO_REUSEADDR 让新进程 bind 成功但抢不到
accept)。症状:`/api/s3/ping` 返回旧版 404,而代码明明改了。用
`Get-NetTCPConnection -LocalPort 8777` 查 PID,或换端口验证。
- 自测:`python dev/theme-builder/check_sigv4.py`(签名对拍)、
`python dev/theme-builder/selftest_upload.py`(假 S3,16 项)、
`python dev/theme-builder/mock_s3.py --port 9900`(手动点面板)。
- 顶栏面板 `S.navbar={mode,alpha,blur}`:solid/glass/clear 三档,基色仍取「配色 → 顶栏」,
`navbarValue()` 统一算;glass 额外写 `.uk-navbar-container:not(.uk-navbar-transparent){backdrop-filter}`。
**clear 只给预览**,`validate()` 与 `TestNavbarNotFullyTransparent` 都拦。
自检 `?dump=navbar`。
+34
View File
@@ -0,0 +1,34 @@
# 蜜罐(多家族伪装 / 取证 / 封禁)
## 结构
- `internal/security/honeypot/family.go` 是**唯一皮肤表**:9 个 Family
(wordpress / phpmyadmin / tomcat / jenkins / grafana / gitlab / luci / joomla / generic)。
每个 skin 自带 routes、title、brand、tagline、css、fields、hidden、failure、headers、cookies。
- `FamilyOf(path)` 按 segments 前缀判定(`.` 也算边界,兼容 `/adminer.php`)。
新增路径必须**同时**进 `honeypotPrefixes`,否则 `Match` 根本不接管。
- `asset.go`:`/wp-admin/css/…`、`/wp-includes/js/…` 这类资源回占位内容,
**不落库、不计数** —— 一次页面加载会打 5–10 个资源请求。
- `page.go`:一次 `strings.Builder` 拼接,禁用 `"a" + b + "c"`(每个 + 一次分配);
`fieldIDReplacer` 必须包级,别在循环里 `NewReplacer`。
- `route/honeypot.go` 的 Serve 顺序:cleanPath → **资源短路** → 家族 → 诊断日志 → 回环告警 → 提凭据 → 封禁 → 响应 → 落库。
## 性能基线(谁改慢了谁负责)
- `RenderFamily` ≈ 3.7 µs / 2 allocs / 5.2 KB;`AssetFor` ≈ 55 ns / 0 alloc。
- 单页体积上限 4 KB,由 `TestRenderIsCheap` 守。
- 预览:`go run ./dev/preview-honeypot` → `outputs/honeypot-preview/index.html`(9 个 iframe)。
改皮肤后务必跑一次并截图,上一版就是靠它发现 Tomcat 按钮没背景色。
## 坑
- **hidden CSRF token 不能算密码键**:PMA / GitLab 的表单带 `token`,而 map 遍历无序,
一旦 token 进 `isPasswordKey`,真密码会被随机覆盖成 token 值。
所以 `credentialKeys` 收 token(只用于判 `HadFields`),`isPasswordKey` 不收。
- **Tomcat 家族 = 401 + `WWW-Authenticate`**,凭据从 `Authorization` 头取;
给它一个 HTML 表单反而不像。
- 页面里不能出现 `TamaBox` / `honeypot` 字样;canary 只放 HTML 注释,
`<meta name="generator">` 必须伪装成真实产品(扫描器必查)。
- WP 登录页底部不能挂 404 文案(真实页没有,挂上一眼就假)。
- 新增诱饵前缀前先核对业务路由(`/user`、`/login`、`/api`、`/_/`…);
`forbiddenPrefixes` 按 `/xxx` 段边界判断,所以 `/users/sign_in` 不会被 `/user` 连坐。
-54
View File
@@ -1,54 +0,0 @@
# 其它约定(长版明细)
## 其它约定
- 校验靠 `valid:"required;maxlen:N"` 标签(`internal/form/*.go`),别在 handler 手写。
- 终止性操作(停用)落独立公开页 `/deactivated`(注册在 `reqUserSignIn` 外);改密码后重登才用 flash + `/login`。
- 只有箱主 `/user` 面板,**无管理员角色、无站方后台,用户要求不要加**:站方动作只走 CLI / 邮件 / 改配置重启。
- git `https://git.shiroko.one/tamakyi/tamabox`(`main`)。**`git push` 能直接推**(本机 Git for Windows 已认证);
「Failed to authenticate user」是临时态。推前 `git log --oneline -3`。`outputs/` 已 gitignore,别整目录 add。
- 打包交付用 tar.gz(排除 `.git`/`.workbuddy`/`outputs`),放项目同级。
## 测试环境 box-dev-api.337845818.best
- 账号 `[email protected]` / `adminadmin`,公开页 `/_/admin6`。
- 登录要过算数验证码:`GET /login` 取 `_csrf`+cookie → `GET /api/captcha/generate` → 算 `a op b` →
`POST /login` 带 `g-recaptcha-response="<id>:<答案>"`。默认语言 **ja**,四语言文案要一起加。
- **部署有延迟**;模板是编译期 embed,**只重启不重编不会变**。用户说「已部署但没变化」→ 先抓线上 HTML 比对标记。
- 表单字段:提问 `content`,回答 `answer`。`GET /user/votes` 故意 302;匿名无邮箱提问成功直接 200 渲染。
- 自检:`dev/devcheck-e2e.py`(28 项)、`dev/devcheck-restrict.sh`(基线非 200 就别往下走;`RUN=1` 才真跑)。
- 图片编码三档(头像 512/q78、背景 1920/q80、通用 1600/q80),**EXIF 方向必须先归一**(转 WebP 会丢)。
## CLI(`TamaBox <cmd>`)
- ⚠️ **两个构建入口必须同步**:根 `go build .`(`tamabox.go`)与 Dockerfile 的 `./cmd/`(`cmd/tamabox.go`),
否则 `No help topic for '<cmd>'`。
- 现有:`web` `censor` `uid` `ban` `restore` `reports` `restrict` `unrestrict` `hide-public`。
- `restrict --domain|--email --level L1..L4 --days N --reason` / `unrestrict`;
`reports --domain|--email [--days]`(证据包)、`--pending`、`--stats --days N`;`hide-public [--only-reported] [--since N]`。
- 本地冒烟:`NEKOBOX_CONFIG_PATH=<ini>`;`conf.Init` 强制 `app.external_url`、`server.salt`、`server.xsrf_key` 非空。
- 蜜罐 CLI:`TamaBox honeypot hits|stats|prune`(`--since 24h` / `--show-password` / `--yes`),
注册须在 `cmd/tamabox.go` + 根 `tamabox.go` 两处同步。
- 蜜罐配置:`[security] enable_honeypot` / `honeypot_paths` / `honeypot_retain_days`(默认全关)。
## 行尾与测试坑(2026-10-21 蜜罐轮)
- ⚠️ `internal/route/route.go`、`internal/conf/static.go`、`conf/app.sample.ini` **实际是全 CRLF**;
`grep | cat -A` 不显示 `^M` 极易误判 → Python 替换用 `\r\n` 锚点,写后统计 `b'\r\n'` 复核。
- ⚠️ Go 里 `time.Duration(60)` = 60 **纳秒**,测试写裸数字会让封禁/冷却瞬间过期 → 一律 `60 * time.Minute`。
- ⚠️ 测 `IsBanned` → 403 必须注册中间件(`f.Use(autobanMiddleware(ab, hp))`),只注册路由+NotFound 会被绕过。
## 配置(ini)相关约定
- ⚠️ **ini 对未知键静默忽略** → 键名写错 = 配置不生效且无任何现场线索。
已加两道防线(`internal/conf/keynorm.go` + `unknown.go`):
键名归一化(自动纠正常见误拼)+ 未知键启动告警(附最接近的合法键名)。
- ⚠️ **键名必须与代码逐字一致**,包括警告文案里提到的键名 —— 写错等于误导运维。
- ⚠️ `MapTo` 在**键缺失/值非法时不覆盖**目标字段 → 可安全预置缺省值。
- ⚠️ **bool 的 ini 解析是「有键才赋值」** → 「未配置」与「显式 false」无法用零值区分,
必须用 `HasKey` 判定。缺省为 true 的开关尤其要小心(否则未配置 = 关闭)。
- 归一化必须在 `MapTo` **之前**执行,否则错拼的键已被丢弃。
- 合法键名白名单由**反射从结构体 ini tag 得出**,是单一事实来源;新增配置项自动纳入。
- `TestSampleIniHasNoUnknownKeys` 回归检测:官方样例配置零误报。
改结构体字段名时必须同步 `conf/app.sample.ini`,否则这个测试会失败。
- app.sample.ini 里不要留「代码中从未读取」的死配置(`title` / `xsrf_expire` 已清理)。
+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 都处理对了)、
后台移动端短标签方案。这几块质量高于平均,别为了统一风格去改。
+58
View File
@@ -0,0 +1,58 @@
# 分享卡(share card)
两处入口都在 `route/question/share.go`:`ShareCard`(箱子卡,960×540)、`QuestionShareCard`(单题卡)。
单题卡出图条件:**已回答 + 非私密 + 无站方处置**(`db.Restrictions.ActiveLevel` 非空即拒绝,fail-closed)。
## 文字排版
- 居中/限宽**必须**用 `measureTextWidth`(逐字 `HMetric().AdvanceWidth` + `Kern`,`scale = fontSize*64`),
它与 `DrawString` 返回笔位逐位相等。绝不要用「中文 1em / 其余 em 系数」估算:URL 里的 `: / . _ -`
只有 0.25~0.35em,整句会虚高 55%,居中直接偏 40px。
- 链接展示走 `displayURL`(去 `https://`)→ `layoutLink`(单行 15→13px,放不下按 host/path 两行 13→11px,
host 超宽才中间省略,字号下限 `LinkMinSize`)→ `ellipsizeMiddle`。限宽恒定 `QRCardW-40`。
## 二维码(`generateQR`)
- **图像只输出符号**:`qr.DisableBorder = true`,静默区由承载它的白色卡片提供。
把 4 模块静默区画进图里 + 再居中留白,会让 165px 目标只剩 111px(3px/模块)——这是曾经的回归。
- `perModule = size / modules` 向下取整,符号 = `modules*perModule`;**调用方必须用 `qr.Bounds().Dx()`
排版与居中**,不能假设等于传入的 size。
- 单题卡 `QQRSize = 190`(37 模块 → 185px = 5px,卡片 236×300 居中于 679,120);
箱子卡 `QRCodeSize = 190`(37 模块 → 185px = 5px,卡片 270×335)。
- 单题卡的二维码卡片绘制在 `drawQuestionQRCard`(handler 只是调用),测试可直接出图。
- 卡内垂直间距在 4px 栅格上:`24 / 17 / 20 / 1 / 20 / 16`,总高按链接行数算,整块垂直居中。
## 模块像素:唯一的硬指标
| 模块 | 960 | 720 | 480 | 320 | 240 |
|---|---|---|---|---|---|
| 4px(148px,37 模块) | o | o | **x** | x | x |
| 5px(185px,37 模块) | o | o | o | o | x |
(go-qrcode 纠错 Q、真实 55 字符链接、含内嵌头像;用 gozxing 解码,它比手机严格,可当保守下限。)
→ **目标 5px/模块**;`TestGenerateQRModuleSizeAtLeast5` 锁住三种常见链接长度,改小 QRCodeSize 立刻 FAIL。
改大模块的路线排序(成本从低到高):
1. 同画布放大二维码(已做,0 CPU)——`generateQR` 只 0.8ms,与尺寸几乎无关。
2. 降纠错 Q→M:模块 37→33 只省 11%,容错 25%→15%,不划算,别单独用。
3. 换 PNG:只保住原图,缩到 480 一样解不出,体积还是 JPEG 的 5 倍。
4. 2x 渲染:+60~100ms(本地基准:JPEG 编码 12→48ms、背景 Lanczos 25→41ms、画布 3→13ms)。
只在「原图本身也要更精细(背景/文字抗锯齿)」时值得。
## 缓存命中率才是 2x 之外更值得看的地方
进程内 LRU 只有 16 条 + TTL 1h,站点箱子多时冷渲染频繁;把生成结果落盘 / 上 R2
能让「贵」只付一次。这是比 2x 更实在的优化方向。
## 验证手法
- 守门测试 `route/question/share_layout_test.go`:测量=笔位、链接限宽、按着墨边界判居中 ±3px、
二维码 `width == modules×perModule` 且吃掉目标尺寸 ≥80%、头像不在边缘裁切。改完都做变异验证。
- `TEST_PREVIEW=1 go test ./route/question/ -run TestShareCardLinkPreview` 出 270×315 的卡区域 PNG。
- 图片实测:PIL 数着墨 bbox(居中偏差 / 二维码宽度 / 各段间距);解码用
`github.com/makiuchi-d/gozxing` 起个临时 module 跑(`_ "image/jpeg"` 和 `_ "image/png"` 都要 import)。
- 单题卡要真实出图:`.workbuddy/local/qcard_probe.py`(发提问 → 登录 → 回答 → 拉图 → 删除,不入库)。
## 缓存
进程内 TTL 1 小时 + 响应头 `Cache-Control: public, max-age=300`。改完要复验:重启(清进程内缓存)+ URL 加随机参数。
+134
View File
@@ -0,0 +1,134 @@
# 全站数据页(/stats)
2026-10 新增。完整数据在 `/stats`(公开、只读、纯服务端渲染)。
入口只有一处:**页脚「社区规则 | 数据 | 找站长」中间那格**
(`templates/base/footer.html`,词条 `site.footer.stats`)。
首页那张「站点统计」卡与后来的入口卡都已下线(用户明确要求首页不再放),
`route.Home` 也不再调 `stats.Get`;`/api/v1/site-stats` 接口保留未动。
新增入口不要再往首页加——那会让两处数字需要同步维护。
## 分层
```
db.Analytics.SiteAnalytics(ctx, now) // 纯 SQL 聚合,无缓存
→ stats.OverviewData(ctx) // 按自然日缓存(每天只算一次)
→ stats.NewPage(lang, ov) // i18n 标签 + 百分比 + 文字洞察
→ route.Stats → templates/stats.html
```
- `internal/db/analytics.go`:`SiteAnalyticsStore`。用 `SUM(CASE WHEN)` 而不是
`COUNT(*) FILTER`(MySQL 没有 FILTER)。方言差异(`to_char`/`DATE_FORMAT`、
`EXTRACT(DOW)`/`DAYOFWEEK-1`、`EPOCH`/`TIMESTAMPDIFF`)收敛在 `analyticsDialectFor`。
- `internal/stats/overview.go`:缓存 key 是**自然日字符串**,不是 TTL —— 换日即失效,
当天重算一次后全站共用。重算串行化(`ovRefresh`),失败时返回上一次成功的旧值。
- `internal/stats/page.go`:`Page` 嵌 `*Overview`。桶标签在 Go 侧拼好,别在模板里
`printf "stats.len.%s"` —— 缺词条会把 key 印到页面上。
- `internal/cmd/web.go`:启动后 3s 异步预热,把当天首访的等待挪出请求路径。
## 口径(改之前先看)
- 已回答 = `answer <> ''`;已读 = `is_read OR answer <> ''`。
- 匿名 = `asker_user_id = 0`;登录 = 总数 − 匿名。
- 回答耗时用 `answered_at - created_at`(**不是 updated_at**)。GORM 的 `Update`
会自动刷新 updated_at,已读/置顶/公开切换都走 Update —— 实测一条 10 天前的提问
被 `SetRead` 后 updated_at 直接推到当下。故新增 `questions.answered_at`(可空),
只在 answer 由空变非空时写一次,改写回答不重置;历史行为 NULL 时
`COALESCE(answered_at, updated_at)` 回退,且首次加列时用 updated_at 冻结一次
(`backfillAnsweredAt`,靠 `Migrator().HasColumn` 判断是否刚加列)。
- 环比是「上月 vs 上上月」,不是「本月 vs 上月」:本月没过完,每月 1 号会得出 -95%。
**句子必须点明月份与条数**:「9 月收到 5,620 条提问,比 8 月的 4,890 条多了 14.9%。」
不要写「上月提问量环比 -75.0%」——两个相对词(上月 / 环比)叠在一起,用户实测读成
「上月同期 vs 本月同期」。也不要用带符号的裸百分比(「环比 -75%」)当结论。
三套句式 `stats.insight.momUp/momDown/momFlat`(方向词进不了同一个模板,语法各语言不通);
参数顺序统一是 本月份名/条数、对比月份名/条数、绝对值百分比,`|pct|<0.05` 走持平。
- 累计与图表是**当天重算一次**的快照(当天首访时算,重启也会重算;不是「0 点快照」),
「今日提问/回答」两项实时(60 秒 TTL,见下)。
- 页脚时间标签因此叫「快照时间」而不是「计算于」:它只覆盖累计与图表,
今日两项会被 `withToday` 换掉。原先另有「数据基准日」一行,与它是 100% 重复
(`Overview.DataDate` 就是 `UpdatedAt` 的日期部分),已删。
- 今日两项的 60s TTL **不要**为了省查询改成 30 分钟:每分钟最多 2 条 COUNT,
20 万行实测 38ms/条 → 最坏 109 秒 DB 时间/天(0.13%),换不到什么;
而 30 分钟会让「(实时)」变成假话、提问者自己看不见刚发的那条。
- 日/周/时分组必须在 SQL 里 `AT TIME ZONE INTERVAL '+08:00'` 显式换算,
不能依赖数据库会话时区 —— 见「时区」一节。
## 两个已踩过的坑(2026-10-24 部署前自查发现)
**1. 「今日」两项曾被每日缓存冻住。** 原先 `SiteAnalytics` 里查了今日数,塞进
`Overview`,而 `Overview` 整天复用 → 页面顶着「实时」的说明显示一整天不变的常量。
现在拆出 `SiteAnalyticsStore.TodayCounts`,`stats` 层单独 60 秒缓存(`todayTTL`),
跨日按日期字符串失效(`todayDate`)。缓存命中时返回的是**副本**(`withToday`),
不能原地改 `ovCached` —— 那是数据竞争。
**2. 日期分组默认跟着 PG 会话时区走,通常是 UTC。** PG 官方镜像 `timezone=UTC`,
而 Go 按容器本地时区(Dockerfile 设 Asia/Shanghai)切「今天」。实测同一条
`2026-10-24 01:30+08` 的提问:会话 UTC 下 `to_char` 给 `2026-10-23`、`EXTRACT(HOUR)` 给 17。
→ 日报表整体偏 8 小时(中国 0–8 点被算进前一天),热力图时段全错位,且与「今日」对不上。
现在 `analyticsLocalize()` 统一包一层 `AT TIME ZONE INTERVAL '+08:00'`,偏移取自
`time.Time.Zone()`(不用 IANA 名:Go 在未设 TZ 时给 "Local",PG 不认)。
过滤条件仍是裸列,索引照用(EXPLAIN 确认走 Bitmap Index Scan)。
## 性能实测(20 万行 questions,本地 PG,冷缓存)
| 查询 | 耗时 | 扫描方式 |
|---|---|---|
| totals(全表条件聚合) | 337 ms | Seq Scan |
| 字数分布(全表) | 313 ms | Parallel Seq Scan |
| 热力图 180 天 | 100 ms | Bitmap Index Scan |
| 日趋势 30 天 | 47 ms | Bitmap Index Scan |
合计约 0.8 秒;1C1G 云机按 2–3 倍算约 2 秒。靠启动后 3 秒预热挪出请求路径,
可接受。**若 questions 到百万行**,三次全表扫描会到 6–10 秒,那时应把
totals + 字数分布 + 回答耗时合并成一条 SQL(都是同一张表的无条件 CASE 聚合,
合并后只扫一次)。现在不做:不知道真实表规模,每天只算一次,不值得为省 0.5 秒
把 SQL 拆成大块。
## 图表实现(零前端库)
站点为消除一个 1.6KB 外部 defer 就把页脚计时器内联了,这里不再挂 60KB 图表库。
- 折线:`NewLineChart` 一个函数给出 `{Max, Points, Area, Ticks}`,
SVG `viewBox` + `preserveAspectRatio="none"` + `vector-effect: non-scaling-stroke`
(否则窄屏描边被压细)。
- **坐标 / 面积 / 刻度必须同一次调用算出**:分开算会各按自己看到的峰值归一化,
线和刻度就错开半格。
- 纵轴上限 `niceMax(峰值)`(1/2/1.5/3/4/6/8×10^n),刻度 = 上限 / 一半 / 0;
上限恒为偶数 → 中间刻度必是整数。1.5 倍只在 mag≥100 时参与(否则是 7.5 半格)。
- 刻度文字放 SVG **外面**:viewBox 横向被拉伸,里面的文字会跟着变扁。
做法是左列绝对定位的 HTML 标签(top 用 Go 算好的 `TopPct`)+ 看不见的占位元素撑列宽。
- 面积沿基线(`h-padBottom`)闭合,别落到 viewBox 底边(那是轴标签的留白)。
- 全零/空数据只出轴不出线,否则贴基线的直线看着像「数据都是 0」。
- 柱状 / 分布条:`style="height:{{printf "%.1f" $pct}}%"`,CSS 直接用百分比。
- 环形:`<circle pathLength="100" stroke-dasharray="{{pct}} 100">` + `rotate(-90)`,
不用算周长。
- 热力图:CSS grid 24 列 + `aspect-ratio:1`,档位用 `--tb-primary` 的 opacity 分 4 档
(连续透明度在小方块上肉眼分不出来)。
- 7×24 的 DOW 下标:PG `EXTRACT(DOW)` 与 Go `time.Weekday()` 都是周日=0;
展示顺序(周一起)在 `weekdayOrder` 里转。
## 预览与验证
- `TEST_PREVIEW=1 go test ./templates -run TestGenPreview` 出
`outputs/preview-stats-{zh-CN,en}-{light,dark}.html`。
- `writePreview` 会把 `href="/static/` 换成本地相对路径 —— 不换的话预览页走 file://,
`:root` 令牌全缺,`var(--tb-*)` 变无效值:图表全透明、卡片没边框(一度以为是渲染 bug)。
- `internal/db/analytics_test.go` 连真库跑(`DB_TYPE=postgres ...`),验证软删除不计入、
各分桶下标、DOW 对齐。对着 mock 断言等于没验证。
- 窄屏别用 `chrome --window-size=390`:Windows headless 最小窗宽约 485,实测 innerWidth=485,
截图再裁到 390 就成了假的"溢出"。用 390px 宽 iframe 包一层再截。
## 隐私红线
公开页只给聚合数字。不显示 IP、不显示单条内容、不做箱主排行(会招骚扰)。
「收到过提问的箱子」这类计数可以,具体是谁不行。
## 文案(读者是访客,不是站长)
- 不写实现细节:缓存策略、懒重算、"服务重启也会重算"、索引、TTL —— 访客要的只是
「多久更新一次」。准确的新鲜度交给页脚「快照时间」那行。
- 不写作者视角的免责:「下面几句…没有推测」「本文」「这里只有」这类,读起来像在跟审稿人
解释,而不是在给访客看。已删 `stats.insight.note`。
- 确实要对访客交代的只有三件:数据来源/隐私(`stats.lead` + `stats.foot.privacy`)、
新鲜度(`stats.foot.cacheNote`)、口径例外(`stats.trend.monthlyNote` 的"当月还没过完")。
- 删说明行会改变间距:`.tb-insights` 自己补 `margin-top`,别指望 `tb-card-note` 的 16px。
+22
View File
@@ -13,7 +13,29 @@
- ⚠️ **UIkit 3.3.3 的 `.uk-flex::before/::after{display:none}`**:给带 `uk-flex` 的元素加伪元素必须写
复合选择器(`.tb-cover.uk-flex::after`),否则静默失效。
- 分类/题型标签一律 `.tb-chip`;彩色 `uk-label` 只留给语义状态(置顶/私密/被举报/已读、投票四态)。
- **字号阶梯 5 档**:`--tb-fs-xs/md/lg/xl/hero`(12/14/16/24/28)。散落的 `.9/.95/.98rem` 一律映射到档位,
别新增第 6 个值。⚠️ 已令牌化的是模板层;`base.css` 自身仍留 11 处硬编码(0.78/0.82/0.88/0.95/1.1),
`templates/user/profile.html` 还留 16 处 —— 想彻底统一得先扫这两处。
- 圆角只用 `--tb-radius(-sm/-xs)`;`50%` 与 `999px` 是语义(头像、胶囊),**不要**换成令牌。
- ⚠️ `.uk-text-muted/.uk-text-meta` 必须带 `!important`:UIkit 3.3.3 自带 `color:#999!important`,
普通声明压不过(现象:规则写了但全站还是 #999)。
- **文字色下限 WCAG AA 4.5:1**(正文级)。`--tb-text-muted` = `#6b7280`(白底 4.83)。
旧值 `#8a919c` 3.18、`#999` 2.85 —— 肉眼"淡一点"其实已经不合格。
主色还没达标(`#1e87f0` 3.26 / `#4a7fc1` 3.69 / sakura `#e5678f` 2.80),要动得连主题一起改。
- ⚠️ **改 base.css 的配色令牌必须同步检查 `static/css/themes/*.css`**:主题覆盖同名令牌,
基座修了但主题页纹丝不动(sakura/shiroko 的 muted 就曾是 2.8:1,是这次才补到 4.6)。
- 顶栏链接:亮色用 `--tb-text-secondary`、hover/当前页 `--tb-primary`;深色块原本 `!important` 定成
`--tb-text`,会让同一顶栏在深浅两模式权重跳一档 → 已加 dark secondary 覆盖。
- **主色要达到 AA 4.5:1**(它同时是 14px 链接/按钮文字色)。现值:base `#0e6fd0`、shiroko `#3b6dac`、
sakura `#ba3e66`(都是同色相压亮度)。**改主色必须三处一起**:`base.css :root` + `themes/default.css` + 该主题。
- ⚠️ **`--tb-on-primary`**(亮 `#ffffff` / 暗 `#0b1016`)是「主色作背景时的文字色」。
深色模式的强调色偏亮(`#5c8dff`),白字只有 2.2~3.1:1 —— 用一个令牌同时伺候「链接色要深」和
「按钮底要深」是矛盾的,所以拆成两个。`.uk-button-primary`、`.uk-label:not(语义色)` 都吃它。
- 字号阶梯已全部落地(模板 + base.css);`0.9em`(相对父级)、验证码等宽字形、`mail/*` 是**有意例外**。
- 视觉自检:`TEST_PREVIEW=1 go test ./templates -run TestGenPreview` → `outputs/preview-*.html`;
截图必须 `chrome --headless=old --force-device-scale-factor=1`(新版 headless 本机挂住)。
⚠️ `--window-size` ≠ CSS 视口;要精确视口就用 iframe 指定 width/height。验证 CSS 靠**像素比对**
(PIL 在 venv)而非目测;扫描循环上界不能与物体边界重合。
- ⚠️ **后台页不是都有卡片**:`/user/questions` 的列表是 `<hr>` 裸行,模板里唯一一处 `.uk-card`
是投票 tab 的标题条。默认主题 #fff 看不出来,换带整页插画的主题就"整页透上来"。
已在 `.qlist-panel` 上补卡片底;新增后台页时别假设外层已有卡片。
+23
View File
@@ -27,6 +27,13 @@
- ⚠️ **UIkit 3.3.3 主色是编译期常量 `#1e87f0`**,令牌注入不进去。base.css 末尾按组件映射(a/uk-link、
uk-button-primary、checkbox/radio、input:focus、uk-tab active、`.uk-progress`、`.uk-label:not(...)`)。
- 令牌 `--tb-navbar`(顶栏底色,默认 `var(--tb-surface)`):页面底色偏彩时主题必须覆盖。
⚠️ **顶栏只能半透明,不能全透明**(2026-10-22 用户明确要求):装饰层从角落长出来时实色顶栏会把
花枝切一段;但改成 `transparent` 顶栏就"消失"了,导航失去依托、文字压在花纹上。正解是
`rgba(bg, 0.72)` + `.uk-navbar-container{backdrop-filter: blur(12px)}`(模糊只能写在规则里,
令牌只是颜色)。`TestNavbarNotFullyTransparent` 拦 `transparent` / `rgba(...,0)` / `#rrggbb00`。
同理 `#tb-mobile-utils` 在 base.css 里写死 `rgba(255,255,255,.92)`,彩色主题要自己调成同色系。
- ⚠️ 装饰层会穿过顶栏区域时,`top` 别停在 0~nav 高度内:改半透明后枝头会压到「登录」上
(700~760px 宽度实测),按顶栏高度(60 + margin 20)把 `top` 下移。
- 静态资源版本号统一 `conf.StaticVersion`;**图片不走这个缓存键**(`max-age=86400`),换图要给 URL 带 `?v=`。
- 整页背景 `--tb-page-image`(cover)/ `--tb-page-pattern`(平铺,`-size` 控尺寸)/ `--tb-page-position`
(焦点,默认 `var(--tb-page-position, center center)`,旧主题不写也不坏)。底色用**纯色**别用渐变;
@@ -42,3 +49,19 @@
- ⚠️ **抠白底容差 16**:背景 253、白袜子 237 只差 16,容差 22 以上会把袜子吃穿。手内侧/两腿之间是
**闭合**白区,四边 floodfill 进不去,得按连通域单独掏(面积 ≥24),再 1px 收边。
- ⚠️ `ruleBody` 取令牌前要剥 `/* */`:注释会和后面的令牌粘成一个键,色块预览静默失效。
- ⚠️ **主题会覆盖 base.css 的同名令牌 —— 基座改配色必须同步改主题**。改 `--tb-text-muted` 时
sakura `#a08c94`(2.8:1)、shiroko `#8794a7`(2.76:1) 都还没跟上,"修了但主题页没变"就是这么来的。
现已调到 `#78696e`(4.61) / `#656e7a`(4.63),深色块 `#a18790` / `#7f8ea1`(对压平后的卡片 4.62)。
主色也已修(`#0e6fd0` / `#3b6dac` / `#ba3e66`)+ 新增 `--tb-on-primary`,见 topics/style.md。
量对比度时半透明 `--tb-surface` 要**压平到 --tb-bg** 再算,别当纯白。
- 主题选择页的色块由浅色块的 `--tb-bg` + `--tb-primary` 生成(`swatchOf`),注释里别写令牌名。
- `test01` 是用户 2026-10-21 自建又删掉的验证主题(css + 2 张 webp 已移除,未提交)。
删主题只需删文件:`build()` 读不到 css 会打 warning 并忽略,已选该主题的用户静默回退站点默认。
`static/images/shiroko-cutout.png` 也一并删除(2026-10-21):它从不进 embed,只是 docs 里的图生图参考图,
仓库不存这类大图。
- ⚠️ **白上白素材无法用颜色分开**:白底 + 白主体(如自行车)时,车身与背景是同一个白
(实测都 `#f9f9f9`),floodfill 只吃外圈,轮圈内/车架三角/车筐整块留下 = 看着"没抠干净"。
自检:`不透明像素里近白(>240) 占比`,正常应≈0(修复前 50%)。要么全部转透明(成品变线稿风),
要么重出绿幕/透明背景素材。细节见 `docs/theme-shiroko-assets-spec.md` 的「白上白」。
- ⚠️ 换 `props/*.webp` 时**不要裁画布**:`--tb-deco-size` 是 `auto 259px`,宽度按原图比例算,
裁完锚点 `left calc(50% - 612px)` 会漂;四周透明边留着不影响观感。
+3
View File
@@ -29,6 +29,9 @@ func main() {
cmd.Unrestrict,
cmd.HidePublic,
cmd.Honeypot,
cmd.Backup,
cmd.RestoreImages,
cmd.R2Outbox,
}
if err := app.Run(os.Args); err != nil {
logrus.WithError(err).Fatal("Failed to start application")
+241 -56
View File
@@ -6,22 +6,29 @@
[app]
production = true
external_url = "https://box.shiroko.one" ; 【必填】站点对外访问地址。用于生成 og:url 与邮件中的全部链接
; 【必填】站点对外访问地址。用于生成 og:url 与邮件中的全部链接
external_url = "https://box.shiroko.one"
; (找回密码、私信通知等),必须以 http(s):// 开头、结尾不带斜杠
; (程序会自动 TrimRight "/")。域名迁移时只改这一处即可全局生效。
icp = ""
default_lang = "" ; 站点默认语言:zh-CN / zh-TW / en / ja,留空默认简体中文(访客未设置语言时的兜底)
; 站点默认语言:zh-CN / zh-TW / en / ja,留空默认简体中文(访客未设置语言时的兜底)
default_lang = ""
uptrace_dsn = ""
qiniu_access_key = ""
qiniu_access_secret = ""
aliyun_access_key = ""
aliyun_access_key_secret = ""
share_background = "https://your-default-background.jpg" ; 默认分享图背景图链接(1280x720)
font_path = "static/fonts/NotoSansSC-Regular.otf" ; 中文字体文件路径(相对项目根目录)
; 默认分享图背景图链接(1280x720)
share_background = "https://your-default-background.jpg"
; 分享卡绘图用的中文字体(相对项目根目录)。字体文件不在仓库里,必须自备一份,
; 否则分享卡接口会返回 500「字体文件未配置或无法加载」。可用 Noto Sans SC / 思源黑体,
; 支持 .otf/.ttf/.ttc;放好后无需重启生效。
font_path = "static/fonts/NotoSansSC-Regular.otf"
[security]
enable_text_censor = true
text_censor_source = auto ; 文本审核服务通道:qiniu(七牛)/ aliyun(阿里云)/ auto(七牛优先、阿里云兜底)。
; 文本审核服务通道:qiniu(七牛)/ aliyun(阿里云)/ auto(七牛优先、阿里云兜底)。
text_censor_source = auto
; 留空等同于 auto,即引入该配置前的历史行为。
; 显式选 qiniu / aliyun 时必须在 [app] 配好对应凭据,否则启动即报错
; —— 避免运行期审核必然失败(审核失败是 fail-closed,拒绝提交,
@@ -40,72 +47,208 @@ text_censor_source = auto ; 文本审核服务通道:qiniu(七牛
; 调用会永久返回 code=596 "You have not opened Yundun Content Moderation Service"。
; 审核失败时日志会打印阿里云返回的 code / msg / requestId 便于定位;
; 首次出现账号级失败(未开通 / 未授权 / 密钥错误)还会额外打印一条一次性告警。
ip_blacklist = ; 逗号分隔的IP黑名单,支持精确IP(如1.2.3.4)和CIDR(如1.2.3.0/24),留空不启用
ip_blacklist_redirect_url = ; 黑名单IP访问时强制跳转到该URL
trust_proxy = false ; 是否信任反向代理设置的 CF-Connecting-IP / X-Real-IP / X-Forwarded-For 头。
; 逗号分隔的IP黑名单,支持精确IP(如1.2.3.4)和CIDR(如1.2.3.0/24),留空不启用
ip_blacklist =
; 黑名单IP访问时强制跳转到该URL
ip_blacklist_redirect_url =
; 是否信任反向代理设置的 CF-Connecting-IP / X-Real-IP / X-Forwarded-For 头。
trust_proxy = false
; 仅当站点部署在可信反向代理(如 nginx/Cloudflare)之后且代理已覆盖这些头时设为 true;
; 否则保持 false,防止客户端伪造 header 绕过速率限制与 IP 黑名单。
; ⚠️ 若站点在 Cloudflare 后且需使用下面的自动封禁,必须设为 true,
; 否则解析到的将是 Cloudflare 边缘 IP,自动封禁会误伤所有访客。
; ⚠️ 蜜罐取证同理:trust_proxy = false 时所有请求的来源都被记成反代地址
; (常见 127.0.0.1),取证数据失去意义;而且一次蜜罐封禁会连带封掉整站。
; 蜜罐命中回环地址时启动日志会打一次告警提醒检查这里。
; ⚠️ 开启 trust_proxy 时,必须配合「Cloudflare Tunnel / Authenticated Origin Pull /
; 防火墙仅放行 Cloudflare / EdgeOne 回源网段」锁定源站,否则攻击者可直连源站并伪造
; CF-Connecting-IP / EO-Client-IP 头。无需维护 CDN 庞大 CIDR 清单。
;
; —— 双 CDN(境内腾讯 EdgeOne + 境外 Cloudflare)场景 ——
; EdgeOne 回源会用 EO-Client-IP 携带客户端真实 IP(等效 CF-Connecting-IP),
; 本程序在 trust_proxy=true 时已同时信任这两个权威头。
; 两家 CDN 并存时必须:① 防火墙仅放行两家回源网段;② 在各家 CDN 的规则引擎中
; 删除回源请求里「对方 CDN」的权威头(经 EdgeOne 回源删 CF-Connecting-IP、
; 经 Cloudflare 回源删 EO-Client-IP),否则攻击者可借道另一家伪造客户端身份,
; 绕过限流 / 自动封禁 / 单 IP 投票限制。详细步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md。
; 本程序在 trust_proxy = true 时同时支持这两个权威头。
; ⚠️ 权威头只在「TCP 对端可信」时才被采信(回环地址,或命中 trusted_proxies)。
; 这条是代码里的硬约束,不再依赖运维纪律 —— EdgeOne 不剥离客户端自带的 CF-Connecting-IP,
; 旧实现无条件优先读它,导致经 EdgeOne 进来的请求客户端 IP 完全可控:
; 限流 / IP 黑名单失效,且 autoban 按假 IP 记账可被用来栽赃任意正常用户。
; 双 CDN 并存时:① 两家回源网段都登记到 trusted_proxies;② 防火墙仅放行两家回源网段;
; ③ 仍建议在各家规则引擎里删掉「对方 CDN」的权威头(纵深防御)。
; 仅用 cloudflared Tunnel 回本机时对端是 127.0.0.1,无需任何额外配置。
; 重新启用 EdgeOne 前请先按本节核对,步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md。
trusted_proxies =
; 逗号分隔的「你自己的」反向代理/负载均衡 IP 或 CIDR(如 127.0.0.1、
; 10.0.0.0/8)。仅当 TCP 对端落在该清单内时才解析 X-Forwarded-For / X-Real-IP。
; 留空(默认)则这两个头永不被信任,仅采用 CDN 权威头 CF-Connecting-IP /
; EO-Client-IP(需源站已锁定)。
; 注意:此清单用于你自己的基础设施,不是 CDN 的全量 CIDR 清单。
enable_auto_ban = true ; 自动封禁(fail2ban 风格):检测到批量扫描/探测后临时封禁来源 IP
ban_track_notfound = false ; 是否把「未注册路径的 404」计入封禁统计(默认 false)。正常业务
; 逗号分隔的「你自己的」反向代理/负载均衡 IP 或 CIDR(如 10.0.0.0/8),
; 或「CDN 直连回源」形态下该 CDN 的回源网段。
; 回环地址(127.0.0.1 / ::1)无需登记即视为可信对端:cloudflared / 本机 nginx
; 回源的连接必然由本机进程发起,外部无法伪造。
; 除回环以外,CDN 权威头也必须对端命中本清单才会被采信 —— 公网对端带来的
; CF-Connecting-IP / EO-Client-IP 一律忽略,改用 TCP 对端地址。
; 仅当 TCP 对端命中本清单时才解析 X-Forwarded-For / X-Real-IP。
; 注意:此清单用于你自己的基础设施(含你选定 CDN 的回源网段),
; 不是 CDN 的全量 CIDR 清单。
; 内容安全策略(CSP)模式:off / report / enforce,留空等同 report。
csp = enforce
; enforce(默认):真正拦截。脚本白名单由代码从 [upload] 的图片 CDN 域名与
; [recaptcha] 的域名自动派生,另内置模板实际引用的 cdnjs.snrat.com
; (UIkit/Alpine/dayjs/vConsole)、challenges.cloudflare.com 与 umami 统计域;
; 配了 sentry_dsn 时自动放行 browser.sentry-cdn.com。
; ⚠️ 日后新增第三方脚本 / 字体 / 接口域名,必须同步加进 internal/route/csp.go,
; 否则控制台会刷 "Refused to load ..." 且对应功能静默失效。
; report:只下发 Content-Security-Policy-Report-Only,页面照常加载,违规只进浏览器
; 控制台(配了 csp_report_uri 才上报)。不给上报地址时它等于没装,
; 所以只在「怀疑误伤、想先看证据」时临时用。
; off:完全不下发(排障逃生口)。
; 默认值的前提是白名单已用真浏览器逐页巡检过:
; outputs/sec-audit-2026-10-25/csp_sweep.py(20 个页面零违规)。
; 上线后发现误伤:把这里改成 report 或 off 再重启,不用改代码。
; 图片 / 样式 / 字体 / 连接放宽到 https:(箱主上传图与主题背景可能落在任意 CDN),
; 真正收紧的是 script-src、object-src、base-uri、form-action、frame-ancestors。
; report:只下发 Content-Security-Policy-Report-Only,页面照常加载,违规只进浏览器
; 控制台(配了 csp_report_uri 才上报)—— 用于上线前观察一周再切 enforce。
; enforce:真正拦截。脚本白名单由代码从 [upload] 的图片 CDN 域名与 [recaptcha] 的域名
; 自动派生,另内置模板实际引用的 cdnjs.snrat.com(UIkit/Alpine/dayjs/vConsole)
; 与 challenges.cloudflare.com。
; ⚠️ 日后新增第三方脚本 / 字体 / 接口域名,必须同步加进 internal/route/csp.go,
; 否则控制台会刷 "Refused to load ..." 且对应功能静默失效。
; off:完全不下发(排障逃生口)。
; 图片 / 样式 / 字体 / 连接放宽到 https:(箱主上传图与主题背景可能落在任意 CDN),
; 真正收紧的是 script-src、object-src、base-uri、form-action、frame-ancestors。
; CSP 违规上报地址(可选)。留空时违规只进浏览器控制台,不上报。
csp_report_uri =
; ⚠️ 限流与自动封禁的计数器都在进程内存里(internal/route/ratelimit.go、
; internal/security/autoban)。单副本部署(当前形态:cloudflared Tunnel 回一台机器)
; 下工作正常;一旦改成多副本 / 多机,每个副本各有一份计数(实际额度 × 副本数),
; 进程重启即清零。多副本前必须先把计数迁到 Redis。
; 封禁「事实」已落库(ban_records 表:键 / 原因 / 时长 / 到期时间),
; 重启丢的只是生效状态,账本还在。
enable_auto_ban = true
; 是否把「未注册路径的 404」计入封禁统计(默认 false)。正常业务
ban_track_notfound = false
; (域名/题目不存在等均走 302 业务跳转,不产生 404 计数)一律放行,
; 仅命中后台扫描特征路径(/admin、/.env 等)才参与统计封禁,避免
; 共享出口(CDN 边缘 / CGNAT)下正常用户被 404 探测连坐误伤。
; 如需恢复对纯 404 字典扫描的封禁再置 true。
ban_threshold_sensitive = 10 ; 统计窗口内命中敏感路径(/admin、/.env、/wp-admin 等)的次数阈值
ban_threshold_404 = 20 ; 统计窗口内返回 404 的次数阈值(仅 ban_track_notfound = true 时生效)
ban_window_seconds = 60 ; 统计窗口时长(秒)
ban_duration_minutes = 15 ; 临时封禁时长(分钟),到期自动解除,重启即清零
security_alert_email = ; 触发封禁时发送告警邮件的收件人(复用下方 SMTP 发信配置),留空不发送
; 统计窗口内命中敏感路径(/admin、/.env、/wp-admin 等)的次数阈值
ban_threshold_sensitive = 10
; 统计窗口内返回 404 的次数阈值(仅 ban_track_notfound = true 时生效)
ban_threshold_404 = 20
; 统计窗口时长(秒)
ban_window_seconds = 60
; 临时封禁时长(分钟),到期自动解除,重启即清零
ban_duration_minutes = 15
; 触发封禁时发送告警邮件的收件人(复用下方 SMTP 发信配置),留空不发送。
; ⚠️ 也是蜜罐告警邮件收件人,而蜜罐邮件正文含**明文账号密码**:
; 必须是个人邮箱,禁止填群组 / 分发列表 / 团队共用邮箱。
security_alert_email =
; ── 蜜罐(虚假后台登录界面)────────────────────────────────────────────
enable_honeypot = false ; 开启后,扫描器探测后台路径(/wp-admin、/.ssh、/.env 等)
; 时不再返回 302/404,而是返回一个仿 WordPress 的假登录页,
; 并把其提交的用户名/密码记录入库供取证。
; 只在「没有任何路由匹配」的路径上生效,真实业务路由零交集;
; 假页面只含 canary 标记与假凭据,绝不含任何真实信息。
honeypot_paths = ; 追加自定义诱饵路径(逗号分隔),与内置清单取并集。
honeypot_retain_days = 30 ; 蜜罐命中记录的保留天数(取证用途),到期自动清理。
; ⚠️ 键名是 honeypot_retain_days(没有 tion)。写错会被静默忽略,
; 程序会尝试自动纠正并在日志里提示,但别指望它每次都猜对。
; 缺省 30 天;写 0 或负数会回退 30(不表示「永不清理」)。
; 要关闭自动清理请用 honeypot_auto_clean = false。
; 可用 TamaBox honeypot size 查看行数与磁盘占用。
honeypot_auto_clean = true ; 是否启用蜜罐记录的后台自动保留期清理(每小时一轮)。
; honeypot_hits 是只追加的审计表,写入完全由外部扫描流量
; 驱动,不清理的话表体积只增不减。关闭后仍可手动 prune。
; 缺省为 true(开启)。
honeypot_max_rows = 200000 ; 蜜罐命中记录的行数上限(第二道闸)。缺省 200000。
; 显式写 0 = 不限(会告警提醒表只增不减)。
; 保留期是时间维度上界,但攻击者可用海量源 IP 在保留期内
; 把表撑爆。实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
; 实测数据:连续删 12000 插 12000,行数恒定 5 万,文件稳定在 15 MB 不再增长
; (VACUUM 让空间可复用,不保证文件缩小)。
honeypot_threshold = 1 ; 窗口内蜜罐命中多少次即封禁。默认 1(一次即封):蜜罐路径
; 与真实业务路由零交集,正常访客不可能命中,一次即足以判定扫描器。
honeypot_ban_minutes = ; 蜜罐封禁时长(分钟)。留空则复用 ban_duration_minutes(15)。
; ⚠️ 蜜罐封禁与 enable_auto_ban 相互独立:即使关掉自动封禁,
; 只要 enable_honeypot = true,蜜罐命中仍会封禁并记录。
default_moderation_strictness = ; 站点默认「内容审核严格度」:lax(宽松)/ standard(标准)/ strict(严格)。
; ⚠️ 本段一律「注释独占一行、值单独一行」。
; 配置加载器开启了 IgnoreInlineComment,行尾 `; 注释` 会**并入值**:
; honeypot_max_rows = 200000 ; 说明 → 值变成 "200000 ; 说明" → int 解析失败
; honeypot_paths = ; 说明 → 值变成 " ; 说明" → 被切成一堆假路径
; 后果是该项静默回退零值,而日志里只会说「配置值不对」,很难联想到是注释造成的。
; 本文件其余段落已全部改成这个写法,新增配置项时请保持一致。
; 开启后,扫描器探测后台路径(/wp-admin、/.ssh、/.env 等)时不再返回 302,
; 而是返回一个仿 WordPress 的假登录页,并把其提交的用户名/密码写入取证文件。
; 只在「没有任何路由匹配」的路径上生效,真实业务路由零交集。
; 假页面只含 canary 标记与假表单,绝不含任何真实信息。
enable_honeypot = false
; 追加自定义诱饵路径(逗号分隔),与内置清单取并集。留空 = 只用内置清单。
; 用 `TamaBox honeypot paths` 可查看最终生效的完整清单。
honeypot_paths =
; 蜜罐命中记录的保留天数(取证用途),到期自动清理。
; ⚠️ 键名是 honeypot_retain_days(没有 tion)。写错会被静默忽略,
; 程序会尝试自动纠正并在日志里提示,但别指望它每次都猜对。
; 缺省 30 天;写 0 或负数会回退 30(不表示「永不清理」)。
; 要关闭自动清理请用 honeypot_auto_clean = false。
honeypot_retain_days = 30
; 是否启用蜜罐记录的后台自动保留期清理(每小时一轮)。
; honeypot_hits 是只追加的审计表,写入完全由外部扫描流量驱动,
; 不清理的话表体积只增不减。关闭后仍可手动 `TamaBox honeypot prune`。
honeypot_auto_clean = true
; 蜜罐命中记录的行数上限(第二道闸)。显式写 0 = 不限(会告警提醒表只增不减)。
; 保留期是时间维度上界,但攻击者可用海量源 IP 在保留期内把表撑爆。
; 实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
honeypot_max_rows = 200000
; 「提交凭据」多少次即封禁。默认 1(一次即封):往假后台里填账号密码是证据最强
; 的行为,抓到一次就足够。⚠️ 不作用于纯 GET 探测 —— 那类请求走 ban_threshold_sensitive,
; 否则扫描器看一眼假登录页就被封,永远走不到提交凭据那一步。
honeypot_threshold = 1
; 蜜罐封禁时长(分钟)。缺省 1440(24 小时),刻意远长于 ban_duration_minutes:
; 命中蜜罐意味着对方已在提交账号密码,证据强度远高于单纯路径探测。
; 显式写 0 = 回退 ban_duration_minutes。
; ⚠️ 蜜罐封禁与 enable_auto_ban 相互独立:即使关掉自动封禁,
; 只要 enable_honeypot = true,蜜罐命中仍会封禁并记录。
honeypot_ban_minutes = 1440
; 凭据取证文件的存放目录。
; ⚠️ 捕获到的用户名/密码**不入库**,只追加到这个目录下的按天文件
; (creds-YYYY-MM-DD.jsonl,一行一条 JSON)。原因:明文账号密码一旦入库,
; 就会跟着进备份、主从复制和任何能连库的报表工具,事后无法彻底清除。
; 目录会被强制为 0700、文件 0600;相对路径以进程工作目录为基准。
; 目录不可写时只降级为「不保存凭据」,不影响站点启动。
; 建议单独挂一个卷,并让它不参与备份。
honeypot_cred_dir = data/honeypot-creds
; ── 安全状况汇总报告(周报 / 月报)──────────────────────────────
;
; 把周期内的蜜罐命中按来源 IP 聚合,再通过公共 IP 库定位到城市,
; 生成「世界地图 + 国家/城市排行 + 来源明细」的 HTML 报告并邮件送达。
;
; ⚠️ 与 cdncheck 离线指纹的区别:那个只认云厂商 / CDN / WAF 的 ASN,
; 肉鸡(家宽、企业宽带、移动网络)一律显示「未识别(普通网络)」。
; 下面的 IP 库回答的是「这个 IP 在哪儿」,是另一个维度。
;
; 数据源(ip_geo_provider):
; auto(缺省) = ip-api.com 批量打底(全球、无需 key)+ 腾讯精修国内
; ipapi = 只用 ip-api.com
; tencent = 只用腾讯位置服务(必须配 key,否则降级 ipapi)
; none = 不查询任何外部服务,攻击者 IP 一个字节都不外发
; 隐私边界:auto / ipapi 会把攻击者 IP 发给 ip-api.com,tencent 发给腾讯。
ip_geo_provider = auto
; 腾讯位置服务 key(lbs.qq.com 申请,个人免费额度足够)。
; 两个用途:① 精修国内 IP 的省市与坐标;② 报告里可交互地图的底图。
; 留空 → 跳过国内精修,报告里只保留离线地图(经纬网 + 气泡)。
ip_geo_tencent_key =
; 周期性安全汇总邮件。缺省全开。
; 发送时机:周报在每周一、月报在每月 1 日之后的第一轮检查发出,覆盖
; 「上一个完整周期」;服务停机错过时间窗会在重新上线后补发;
; 幂等由 security_report_logs 的 period_key 唯一索引保证,不会重复发。
; 失败最多重试 5 次后停下(SMTP 配错时无限重试只会刷满日志),
; 之后可用 TamaBox honeypot report 手动补发。
honeypot_report_enabled = true
honeypot_report_weekly = true
honeypot_report_monthly = true
; 报告收件人。留空回退 security_alert_email。
; 单独一个键是为了能把「随时发生的封禁告警」与「定期汇总」分投到不同邮箱
; (例如告警进手机推送邮箱、汇总进归档邮箱)。
honeypot_report_email =
; 报告文件(HTML + PNG)的存放目录,按 0700 创建、文件 0600。
; 里面有攻击者 IP 明细,不要放进 Web 可访问的目录。
; 想先看内容不发送:TamaBox honeypot report --period week --no-email
honeypot_report_dir = data/reports
; 被封禁(自动封禁 / 蜜罐封禁)时返回的图片路径,相对路径以进程工作目录为基准。
; 留空、文件读不到、或内容不是图片 → 一律回退为纯文字 "403 Forbidden"。
; 只接受 image/* 的内容(png / jpeg / gif / webp):指向 app.ini 之类会被拒绝,
; 否则等于把配置文件当响应体吐给攻击者。
; 建议用 256×256 左右的图并压到 20 KB 以内 —— 被封的来源会反复重试,
; 每张图都会照常占用出网带宽。
ban_image_path =
; 站点默认「内容审核严格度」:lax(宽松)/ standard(标准)/ strict(严格)。
default_moderation_strictness =
; 仅对未单独设置档位的账号生效(账号在设置页选择后以账号设置为准)。
; 留空回退 strict(最严档):保证升级不会放宽任何拦截。
; 严格档是三个档位中最严的(拦截面不小于升级前),因此部署后默认拦截面可能略有扩大;
@@ -139,7 +282,8 @@ addr = "127.0.0.1:6379"
password = ""
[recaptcha]
captcha_type = recaptcha ; recaptcha(默认), turnstile, math(本地算数验证码,国内访问友好)
; recaptcha(默认), turnstile, math(本地算数验证码,国内访问友好)
captcha_type = recaptcha
domain = "https://www.recaptcha.net"
site_key = ""
server_key = ""
@@ -174,6 +318,19 @@ password = ""
port = 465
smtp = ""
[notify]
; ── 站级广播式通知通道(可选,均留空则只走 [mail] 点对点邮件)──────────────
; webhook_url:通用 Webhook(Discord / Slack / 企业微信群机器人等),站级事件
; (新提问 / 新回答 / 新追问 / 新私聊 / 新投票回复)统一 POST JSON 推送。
; 留空 = 不推送站级 Webhook。
; 注意:不同平台的 webhook 格式不同,这里发送统一 JSON(含 content/event/title/
; body/link/time 字段),Discord 可直接用 content 字段展示;其他平台建议在
; webhook 前端加一层转发做格式适配。
; webhook_url = ""
; telegram_bot_token:Telegram Bot token,配合每个箱主自填的 Chat ID 实现
; 「新提问」点对点通知。留空 = 禁用 Telegram 通知。
; telegram_bot_token = ""
[limits]
; ── 全站内容 / 数量上限(运行时配置,修改后重启生效)──────────────────────
; 缺失本段或个别键时按内置默认值运行。长度统一按字符(rune)计数。
@@ -214,6 +371,34 @@ chat_max_messages = 200
; 单用户每日私信条数上限(用户可设范围 0..该值;0 表示不限制每日条数)
daily_message_limit_max = 999
[announcement]
; ── 站内公告:全站每页导航栏下方展示,改完重启生效 ──────────────────────
; content :默认文案,留空 = 关闭公告(任一语言文案非空也视为开启)。
; 用 \n 表示换行;http(s) 地址会自动变成链接;
; 内容按纯文本渲染,不支持 HTML 标签(写 <b> 会原样显示)。
; content_zh_cn / content_zh_tw / content_en / content_ja:
; 分语言文案,留空则回退到 content;content 也留空时该语言不显示公告。
; timezone:start / end 里不带偏移的「裸时间」按哪个时区解释,默认 Asia/Shanghai。
; 可填任意 IANA 时区名(UTC / America/New_York …);二进制已内嵌 tzdata,
; 容器不需要另装时区包。带偏移的 RFC3339 以自带偏移为准。
; start / end:展示时间窗,左闭右开 —— 到 end 那一刻下线。留空表示该侧不限:
; 只写 start = 定时上线;只写 end = 现在起展示到某时刻;都留空 = 一直展示。
; 支持 2026-10-25 02:00 / 2026-10-25 02:00:59 / 2026-10-25 / RFC3339
; (2026-10-25T02:00:00+08:00)。
; 时间或时区写错不会导致启动失败:日志 WARN 后关闭公告。
; 例:content = "10 月 25 日 02:00–04:00 机房维护,期间可能短暂无法访问。详情见 https://status.shiroko.one"
; content_en = "Maintenance 02:00-04:00 on Oct 25 (UTC+8). Details: https://status.shiroko.one"
; start = "2026-10-24 00:00"
; end = "2026-10-25 04:00"
content = ""
content_zh_cn = ""
content_zh_tw = ""
content_en = ""
content_ja = ""
start = ""
end = ""
timezone = Asia/Shanghai
[theme]
; 主题系统:只改配色 / 图案 / 整页背景,不涉及布局。
; 作用范围:提问箱页面(主页 / 单题 / 密码 / 分享卡)、箱主本人的投票页与 /user 后台;
@@ -225,6 +410,6 @@ daily_message_limit_max = 999
; 改选择器与令牌值,再把 <slug> 加进 available —— 不需要改模板。
; 需要图片素材的,放 static/images/themes/<slug>/(static/fs.go 已嵌 images/themes 目录)。
default = default
available = default,sakura,shiroko
available = default,sakura,shiroko,new-sakura
; ⚠️ 没登记在 available 里的主题,即使 css 已随二进制发布也不会出现在设置页;
; 启动日志会打出「[theme] available 未登记以下主题」提醒。,shiroko
; 启动日志会打出「[theme] available 未登记以下主题」提醒。
Binary file not shown.
-346
View File
@@ -1,346 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""头像上传链路端到端验证(批次 A 分档 + B1 EXIF 归一)。
为什么走头像而不是提问图:
- 提问图那条路要经过内容审核/提问入库才能拿到 key,链路长且易受其它因素干扰;
- 头像强制 1:1 且 POST /user/profile/update 直接返回页面,便于立即取回 CDN URL。
验证两件事:
A 分档生效:上传 1600x1600 的大图,头像档 maxSide=512 -> 存盘应为 512x512
(改前是 1920,实测体积差 ~7 倍)
B1 EXIF Orientation=6 归一:上传「像素 512x256 + EXIF 顺时针 90」的方形图,
归一后仍应是 512x512(宽高互换但比例不变,故改用体积+尺寸双断言,
并额外验证竖长条图 256x512 + Orientation=6 -> 应变成 512x256)
用法:
python dev/devcheck-avatar.py [--base https://box-dev-api.337845818.best]
依赖:Pillow(仅用于本地生成测试图片)
清理:TamaBox ban --email <输出中的测试邮箱> --yes
"""
import argparse
import http.cookiejar
import io
import json
import re
import struct
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
RESULTS = []
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
return None
class Resp:
def __init__(self, status, headers, body):
self.status = status
self.h = {k.lower(): v for k, v in headers.items()}
self.body = body
@property
def loc(self):
return self.h.get("location", "")
@property
def text(self):
return self.body.decode("utf-8", "replace")
class Client:
def __init__(self, base):
self.base = base
self.cj = http.cookiejar.CookieJar()
self.op = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(self.cj), NoRedirect())
def _send(self, req, timeout=120):
try:
with self.op.open(req, timeout=timeout) as r:
return Resp(r.status, dict(r.headers), r.read())
except urllib.error.HTTPError as e:
try:
body = e.read()
except Exception: # noqa: BLE001
body = b""
return Resp(e.code, dict(e.headers), body)
except Exception as e: # noqa: BLE001
return Resp(-1, {}, ("TRANSPORT_ERROR: %s" % e).encode())
def req(self, method, path, data=None, content_type=None, timeout=120):
url = path if path.startswith("http") else self.base + path
headers = {"User-Agent": UA, "Referer": self.base + path.split("?")[0]}
body = None
if data is not None:
body = data if isinstance(data, bytes) else urllib.parse.urlencode(data).encode()
headers["Content-Type"] = content_type or "application/x-www-form-urlencoded"
return self._send(urllib.request.Request(url, data=body, headers=headers, method=method),
timeout)
def get(self, p, **kw):
r = self.req("GET", p, **kw)
return r if r.status != -1 else self.req("GET", p, **kw)
def post(self, p, data=None, **kw):
return self.req("POST", p, data=data, **kw)
def get_binary(self, url):
return self._send(urllib.request.Request(
url, headers={"User-Agent": UA}, method="GET"), timeout=60)
def step(desc, ok, extra=""):
RESULTS.append((desc, ok, extra))
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", desc, (" (%s)" % extra) if extra else ""))
return ok
def csrf_of(html):
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
return m.group(1) if m else None
def captcha(c):
j = json.loads(c.get("/api/captcha/generate").text)
pid, q = j["data"]["id"], j["data"]["question"]
m = re.match(r"\s*(\d+)\s*([+\-×])\s*(\d+)\s*=", q)
a, op, b = int(m.group(1)), m.group(2), int(m.group(3))
return "%s:%d" % (pid, {"+": a + b, "-": a - b, "×": a * b}[op])
def multipart(fields, files):
boundary = "----tbav%s" % uuid.uuid4().hex
buf = io.BytesIO()
for k, v in fields.items():
buf.write(("--%s\r\n" % boundary).encode())
buf.write(('Content-Disposition: form-data; name="%s"\r\n\r\n' % k).encode())
buf.write(("%s\r\n" % v).encode())
for name, fn, ct, data in files:
buf.write(("--%s\r\n" % boundary).encode())
buf.write(('Content-Disposition: form-data; name="%s"; filename="%s"\r\n' % (name, fn)).encode())
buf.write(("Content-Type: %s\r\n\r\n" % ct).encode())
buf.write(data)
buf.write(b"\r\n")
buf.write(("--%s--\r\n" % boundary).encode())
return buf.getvalue(), "multipart/form-data; boundary=%s" % boundary
def sniff(d):
if len(d) >= 12 and d[:4] == b"RIFF" and d[8:12] == b"WEBP":
return "webp", webp_dims(d)
if d[:3] == b"\xff\xd8\xff":
return "jpeg", jpeg_dims(d)
if d[:8] == b"\x89PNG\r\n\x1a\n":
return "png", (struct.unpack(">II", d[16:24]) if len(d) >= 24 else None)
return "unknown", None
def webp_dims(d):
c = d[12:16]
if c == b"VP8 " and len(d) >= 30:
return (struct.unpack("<H", d[26:28])[0] & 0x3FFF,
struct.unpack("<H", d[28:30])[0] & 0x3FFF)
if c == b"VP8L" and len(d) >= 25:
b = struct.unpack("<I", d[21:25])[0]
return (b & 0x3FFF) + 1, ((b >> 14) & 0x3FFF) + 1
if c == b"VP8X" and len(d) >= 30:
return ((d[24] | d[25] << 8 | d[26] << 16) + 1,
(d[27] | d[28] << 8 | d[29] << 16) + 1)
return None
def jpeg_dims(d):
i = 2
while i + 9 < len(d):
if d[i] != 0xFF:
i += 1
continue
m = d[i + 1]
if m in (0xC0, 0xC1, 0xC2, 0xC3):
h, w = struct.unpack(">HH", d[i + 5:i + 9])
return (w, h)
if m in (0xD8, 0xD9) or 0xD0 <= m <= 0xD7:
i += 2
continue
i += 2 + struct.unpack(">H", d[i + 2:i + 4])[0]
return None
def make_avatar(w, h, orientation=None):
"""生成一张带明显方位特征的方形头像。"""
from PIL import Image, ImageDraw
im = Image.new("RGB", (w, h), (30, 120, 200))
d = ImageDraw.Draw(im)
# 顶部一条粗红条:旋转 90 度后它会跑到侧面,方向一眼可辨
d.rectangle([0, 0, w, max(2, h // 12)], fill=(230, 30, 30))
for i in range(0, w, max(8, w // 20)):
d.line([(i, h // 4), (i, h)], fill=(255, 255, 255), width=2)
buf = io.BytesIO()
if orientation:
exif = im.getexif()
exif[0x0112] = orientation
im.save(buf, "JPEG", quality=90, exif=exif)
else:
im.save(buf, "JPEG", quality=90)
return buf.getvalue()
def scrape_profile_fields(html):
"""从 profile 页抓取所有 input 的 name/value,保证回填必填项。
UpdateProfile 的必填字段有十来个(昵称/介绍/5 个二维码颜色/点大小/透明度…),
漏一个就会因 `valid:"required"` 被 Bind 挡下、头像根本不会走到上传分支。
"""
fields = {}
for m in re.finditer(r'<input\b[^>]*>', html, re.I):
tag = m.group(0)
nm = re.search(r'name="([^"]+)"', tag, re.I)
if not nm:
continue
name = nm.group(1)
if name in ("avatar", "background", "backgroundimage", "_csrf", "submit"):
continue
typ = re.search(r'type="([^"]+)"', tag, re.I)
if typ and typ.group(1).lower() in ("checkbox", "radio"):
# 只保留勾选态的,避免把「本想取消」的项又勾上
if not re.search(r'\bchecked\b', tag, re.I):
continue
val = re.search(r'value="([^"]*)"', tag, re.I)
fields[name] = val.group(1) if val else ""
# select 走第一个非空 option
for m in re.finditer(r'<select\b[^>]*name="([^"]+)"[^>]*>(.*?)</select>', html, re.I | re.S):
name, body = m.group(1), m.group(2)
opts = re.findall(r'<option\b([^>]*)>', body, re.I)
chosen = ""
for o in opts:
vm = re.search(r'value="([^"]*)"', o)
v = vm.group(1) if vm else ""
if re.search(r'\bselected\b', o, re.I):
chosen = v
break
if not chosen:
chosen = v
fields[name] = chosen
# textarea
for m in re.finditer(r'<textarea\b[^>]*name="([^"]+)"[^>]*>(.*?)</textarea>', html, re.I | re.S):
fields[m.group(1)] = re.sub(r"<[^>]+>", "", m.group(2)).strip()
return fields
def upload_avatar(c, data, fname="avatar.jpg"):
page = c.get("/user/profile")
if page.status != 200:
return None, "profile HTTP %s" % page.status
fields = scrape_profile_fields(page.text)
fields["_csrf"] = csrf_of(page.text)
body, ct = multipart(fields, [("avatar", fname, "image/jpeg", data)])
res = c.post("/user/profile/update", data=body, content_type=ct)
# 成功时是 302 跳回 /user/profile,头像 URL 在跳转后的页面里
if res.status == 302 and res.loc:
res = c.get(res.loc if res.loc.startswith("/") else "/user/profile")
if res.status != 200:
return None, "update 后页面 HTTP %s" % res.status
# 从页面里取头像 URL:<img src="https://r2.../picture/..."> 或 og:image
urls = re.findall(r'https://r2\.[^"\'\s<>]+/picture/[0-9]+/\d{2}/\d{2}/[0-9a-f]+', res.text)
if not urls:
m = re.search(r'(https://r2\.[^"\'\s<>]+/picture/[^"\'\s<>]+)', res.text)
urls = [m.group(1)] if m else []
if urls:
return urls[-1], None
# 没匹配到:落盘返回页并打印可疑片段,便于定位是「上传被拒」还是「正则没覆盖」
dump = "C:/Users/Tamakyi Gekka/AppData/Local/Temp/tbav_profile.html"
with open(dump, "wb") as f:
f.write(res.body)
alerts = re.findall(r'class="uk-alert[^"]*"[^>]*>(.*?)</div>', res.text, re.S)
hint = " | ".join(re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", a)).strip()[:80]
for a in alerts[:3])
return None, "未匹配到 picture URL(已存 %s)%s%s" % (dump, (" 提示:" + hint) if hint else "",
(" HTTP %s" % res.status))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--base", default="https://box-dev-api.337845818.best")
args = ap.parse_args()
base = args.base.rstrip("/")
pwd = "TbAv!2026"
ts = time.strftime("%m%d%H%M%S")
email, domain = "tbav%s@example.com" % ts, "tbav%s" % ts
print("== 账号:%s / %s ==" % (email, domain))
# 方形 1600x1600:验证 A 分档(应存成 512x512)
square = make_avatar(1600, 1600)
# EXIF Orientation=6(顺时针 90):像素存成横的 1536x512,归一后显示为竖的 512x1536,
# 再经头像档限边 512 -> 512x512。
# 注意必须用「像素横躺」的样本:头像有 1:1 比例校验,若像素本身就是竖的会被先拒掉,
# 那样就测不到 EXIF 分支了。
exif_shot = make_avatar(1536, 512, orientation=6)
print(" 本地样本 square 1600x1600%7d bytes" % len(square))
print(" 本地样本 exifshot 1536x512 (EXIF=6)%d bytes" % len(exif_shot))
c = Client(base)
r = c.get("/register")
reg = c.post("/register", {
"email": email, "domain": domain, "name": "TBAv%s" % ts,
"password": pwd, "repeat_password": pwd,
"g-recaptcha-response": captcha(c), "language": "zh-CN",
"_csrf": csrf_of(r.text),
})
if not step("注册", reg.status == 302, "HTTP %s -> %s" % (reg.status, reg.loc)):
return 1
c = Client(base)
r = c.get("/login")
lg = c.post("/login", {"email": email, "password": pwd,
"g-recaptcha-response": captcha(c), "_csrf": csrf_of(r.text)})
if not step("登录", lg.status == 302, "HTTP %s -> %s" % (lg.status, lg.loc)):
return 1
# ---- A 分档 ----
url, err = upload_avatar(c, square)
if not step("上传方形大头像(1600x1600)", url is not None, err or (url or "")):
return 1
img = c.get_binary(url)
st, data = img.status, img.body
if not step("拉回头像", st == 200 and bool(data), "HTTP %s" % st):
return 1
kind, dims = sniff(data)
print(" 存储结果:%s %s %d bytes" % (kind, dims, len(data)))
step("A 分档生效:大图被缩到头像档 512(改前是 1920)",
dims == (512, 512), "实际 %s" % (dims,))
# ---- B1 EXIF ----
# 关键:两次上传的 URL 必须不同,否则说明第二次没生效(回退到了旧头像)。
url2, err2 = upload_avatar(c, exif_shot)
if not step("上传 EXIF 横躺头像(1536x512, Orientation=6)", bool(url2), err2 or (url2 or "")):
return 0
if url2 == url:
step("B1 EXIF 归一(第二次上传未生效,URL 未变)", False,
"两次上传返回同一 URL,无法区分;请检查是否被 1:1 校验拒绝")
return 0
img2 = c.get_binary(url2)
kind2, dims2 = sniff(img2.body)
print(" 存储结果:%s %s %d bytes" % (kind2, dims2, len(img2.body)))
# 归一后:像素 1536x512 -> 旋转成 512x1536 -> 再限边 512 -> 512x512
step("B1 EXIF Orientation=6 归一(1536x512 -> 512x1536 -> 512x512)",
dims2 == (512, 512), "实际 %s" % (dims2,))
# 体积对照:未归一时 1536x512 的 WebP 明显更大
step("B1 归一后体积已按 512 档压过(应与方形样本同量级)",
len(img2.body) < 6000, "%d bytes" % len(img2.body))
print("\n== 结果:%d/%d 通过 ==" % (sum(1 for _, ok, _ in RESULTS if ok), len(RESULTS)))
print("清理:TamaBox ban --email %s --yes" % email)
return 0 if all(ok for _, ok, _ in RESULTS) else 1
if __name__ == "__main__":
sys.exit(main())
-360
View File
@@ -1,360 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""TamaBox 部署后冒烟巡检(端到端)。
用途:对已部署环境跑一遍核心链路,用于发版/重新部署后的回归。
覆盖:注册 -> 登录 -> 个人页/提问列表/投票面板 -> 投票全链路(建票发布/公开页/匿名提交/
结果导出计数/草稿/表单型)-> 匿名提问 -> 停用
-> /deactivated 成功页 -> **同邮箱重注册必须被明确拒绝并提示联系站长**
-> 公开页只读巡检 -> /deactivated 四语言渲染。
语义约定(2026-09-15 起):停用 = 清空内容 + 保留用户行作「身份占位」,
故邮箱/域名继续被占用,重注册应得到「账号已停用,请找站长」的提示而不是 302 或 500。
用法:
python dev/devcheck-e2e.py [--base https://box-dev-api.337845818.best]
注意:
- 会在目标环境**真实创建账号并发送提问**(使用 tbe2e<时间戳>@example.com 之类的可识别测试邮箱),
仅在测试/开发环境运行。
- 每次完整运行都会**留下一个已停用的身份占位**(邮箱/域名被占用)。清理方式:
`TamaBox ban --email <该邮箱> --yes`(连行物理删除)或 `TamaBox restore --email <该邮箱> --yes`(恢复账号)。
- 依赖数学验证码(conf.Recaptcha.CaptchaType == "math");若站点改用 recaptcha/turnstile 则无法自动通过。
"""
import argparse
import http.cookiejar
import json
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
RESULTS = []
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
return None
class Resp:
def __init__(self, status, headers, body):
self.status = status
self.h = {k.lower(): v for k, v in headers.items()}
self.body = body
@property
def loc(self):
return self.h.get("location", "")
def alert(self):
m = re.search(r'class="uk-alert[^"]*"[^>]*>(.*?)</div>', self.body, re.S)
return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", m.group(0))).strip()[:200] if m else ""
class Client:
def __init__(self, base):
self.base = base
self.cj = http.cookiejar.CookieJar()
self.op = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(self.cj), NoRedirect())
def req(self, method, path, data=None, timeout=90):
url = path if path.startswith("http") else self.base + path
headers = {"User-Agent": UA, "Referer": self.base + path.split("?")[0]}
body = urllib.parse.urlencode(data).encode() if data is not None else None
if body:
headers["Content-Type"] = "application/x-www-form-urlencoded"
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with self.op.open(req, timeout=timeout) as r:
return Resp(r.status, dict(r.headers), r.read().decode("utf-8", "replace"))
except urllib.error.HTTPError as e:
# 读取错误响应体本身也可能超时(尤其 302 无体/远端慢),不能让它把巡检整轮打断
try:
body = e.read().decode("utf-8", "replace")
except Exception: # noqa: BLE001
body = ""
return Resp(e.code, dict(e.headers), body)
except Exception as e: # noqa: BLE001
return Resp(-1, {}, "TRANSPORT_ERROR: %s: %s" % (type(e).__name__, e))
def get(self, p, **kw):
# 只读 GET 重试一次:远端偶发慢请求会产生 TRANSPORT_ERROR,避免巡检出现假失败。
r = self.req("GET", p, **kw)
if r.status == -1:
r = self.req("GET", p, **kw)
return r
def post(self, p, data=None, **kw):
return self.req("POST", p, data=data, **kw)
def csrf_of(html):
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
return m.group(1) if m else None
def captcha(c):
"""取一道数学验证码并算出答案,返回 <id>:<answer>。"""
j = json.loads(c.get("/api/captcha/generate").body)
pid, q = j["data"]["id"], j["data"]["question"]
m = re.match(r"\s*(\d+)\s*([+\-×])\s*(\d+)\s*=", q)
a, op, b = int(m.group(1)), m.group(2), int(m.group(3))
ans = {"+": a + b, "-": a - b, "×": a * b}[op]
return "%s:%d" % (pid, ans)
def step(desc, ok, extra=""):
RESULTS.append((desc, ok, extra))
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", desc, (" (%s)" % extra) if extra else ""))
# 停用提示的跨语言特征串(站点默认语言可能是 ja,断言不能只看中文)
DEACTIVATED_HINTS = ("已停用", "has been deactivated", "は停止されています")
INTERNAL_ERROR_HINTS = ("サーバー内部エラー", "Internal Server Error", "服务器内部错误")
def is_deactivated_rejected(r):
"""停用后的重注册应被明确拒绝:HTTP 200 + 提示「账号已停用」,且不得是 500 兜底。"""
if r.status != 200:
return False
if any(h in r.body for h in INTERNAL_ERROR_HINTS):
return False
return any(h in r.body for h in DEACTIVATED_HINTS)
def check_public_pages(base):
"""公开页只读巡检:期望全部 200(/user/votes 之类的登录页除外)。"""
paths = ["/", "/explore", "/register", "/login", "/forgot-password",
"/sponsor", "/change-logs", "/about-me", "/robots.txt",
"/sitemap.xml", "/llms.txt", "/llms-full.txt", "/favicon.ico",
"/deactivated", "/api/captcha/generate", "/api/v1/site-stats"]
c = Client(base)
bad = []
for p in paths:
r = c.get(p, timeout=40)
if r.status != 200:
bad.append("%s=%s" % (p, r.status))
step("公开页只读巡检(%d 个端点)" % len(paths), not bad, ", ".join(bad))
def create_vote(c, title, vtype="single", options=None, fields=None, action="publish"):
"""提交一次投票创建(POST /user/votes)。
后端对创建动作一律按 draft=true 解析(草稿可随时暂存、跳过审核),
action=publish 时再走 validateForPublish + 内容审核;因此这里两种动作都可直接测。
"""
page = c.get("/user/votes/new")
data = {
"title": title, "description": "", "visibility": "public",
"voter_scope": "anyone", "show_result": "after_vote",
"type": vtype, "max_choices": "1", "allow_custom": "",
"start_at": "", "end_at": "", "quota_limit": "0",
"options": json.dumps(options or [], ensure_ascii=False),
"form_fields": json.dumps(fields or [], ensure_ascii=False),
"action": action, "_csrf": csrf_of(page.body),
}
return c.post("/user/votes", data)
def check_user_assets(owner):
"""登录态的接口/导出,以及停用页的「身份占位」说明。
停用页说明用**结构断言**(.uk-text-muted 段落非空),不写死文案 —— 站点语言随会话变化。
"""
r = owner.get("/api/v1/user")
step("登录态 /api/v1/user 返回 JSON", r.status == 200 and r.body.lstrip().startswith("{"),
"HTTP %s" % r.status)
step("登录态 /explore", owner.get("/explore").status == 200)
csrf = csrf_of(owner.get("/user/profile").body)
r = owner.post("/user/profile/export-json", {"_csrf": csrf})
step("导出 JSON", r.status == 200 and r.body.lstrip().startswith(("{", "[")),
"HTTP %s" % r.status)
r = owner.post("/user/profile/export", {"_csrf": csrf})
step("导出 Excel", r.status == 200, "HTTP %s %s" % (r.status, r.h.get("content-type", "")))
r = owner.get("/user/profile/deactivate")
note = re.search(r'class="uk-text-small uk-text-muted">(.*?)</span>', r.body, re.S)
step("停用页含身份占位说明", r.status == 200 and bool(note) and note.group(1).strip() != "")
def check_vote_flow(owner, base):
"""投票全链路:建票发布 -> 管理列表 -> 公开页 -> 匿名提交 -> 导出计数 -> 草稿 -> 表单型。"""
ts = time.strftime("%m%d%H%M%S")
title = "巡检单选投票%s" % ts
r = create_vote(owner, title, "single", options=["选项甲", "选项乙", "选项丙"])
step("创建单选投票并发布", r.status == 302 and "questions" in r.loc,
"HTTP %s -> %s %s" % (r.status, r.loc, r.alert()))
q = owner.get("/user/questions")
step("投票出现在管理列表", title in q.body)
mt = re.search(r"copyShareURL\('([^']+)'", q.body)
mv = re.search(r"/user/votes/(\d+)/edit", q.body)
token, vid = (mt.group(1) if mt else None), (mv.group(1) if mv else None)
step("取得分享 token / 投票 ID", bool(token and vid), "%s / %s" % (token, vid))
if not (token and vid):
return
anon = Client(base)
pv = anon.get("/vote/%s" % token)
step("公开投票页渲染", pv.status == 200 and title in pv.body, "HTTP %s" % pv.status)
mo = re.search(r'name="option_id" value="(\d+)"', pv.body)
step("公开页含可选项", bool(mo))
if not mo:
return
sub = anon.post("/vote/%s/submit" % token, {
"option_id": mo.group(1), "g-recaptcha-response": captcha(anon),
"custom_option": "", "_csrf": csrf_of(pv.body),
})
step("匿名提交投票", sub.status == 302, "HTTP %s -> %s %s" % (sub.status, sub.loc, sub.alert()))
# 导出仅作只读校验:单选导出的 CSV 每行是 <选项,票数>,末行为 <总参与,N>。
# 只认「总参与」这一行,避免把各选项票数一起求和导致 2 倍计数。
exp = owner.get("/user/votes/%s/export" % vid)
mtot = re.search(r"总参与,(\d+)", exp.body)
total = int(mtot.group(1)) if mtot else -1
step("导出显示总参与 = 1", exp.status == 200 and total == 1,
"HTTP %s 总参与=%s" % (exp.status, total))
dr = create_vote(owner, "巡检草稿%s" % ts, "single", options=["甲", "乙"], action="save")
step("保存草稿跳编辑页(?saved=1)",
dr.status == 302 and "/user/votes/" in dr.loc and "saved=1" in dr.loc,
"HTTP %s -> %s" % (dr.status, dr.loc))
fr = create_vote(owner, "巡检表单投票%s" % ts, "form", fields=[
{"field_type": "text", "label": "昵称", "required": True, "max_length": 50},
{"field_type": "textarea", "label": "建议", "required": False, "max_length": 200},
])
step("创建表单型投票并发布", fr.status == 302 and "questions" in fr.loc,
"HTTP %s -> %s %s" % (fr.status, fr.loc, fr.alert()))
def check_deactivated_i18n(base):
"""停用成功页四语言渲染(用语言无关的 ratio:3 标记 + 标题非空)。"""
bad = []
for lang in ["zh-CN", "zh-TW", "en", "ja"]:
c = Client(base)
c.get("/i18n/%s?to=/deactivated" % lang, timeout=40) # 302,语言写入 session
r = c.get("/deactivated", timeout=40) # 再请求一次才渲染新语言
title = re.search(r'uk-card-body uk-text-center".*?<h3[^>]*>([^<]*)</h3>',
r.body, re.S)
if not title or not title.group(1).strip():
bad.append(lang)
step("停用成功页四语言渲染", not bad, "缺失: " + ",".join(bad) if bad else "")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--base", default="https://box-dev-api.337845818.best")
ap.add_argument("--reuse-email", default="",
help="只跑一项:用该既有邮箱重新注册(用于复测停用后能否重注册)")
args = ap.parse_args()
base = args.base.rstrip("/")
pwd = "TbCheck!2026"
if args.reuse_email:
domain = args.reuse_email.split("@")[0]
c = Client(base)
r = c.get("/register")
r = c.post("/register", {
"email": args.reuse_email, "domain": domain, "name": "TBCheck",
"password": pwd, "repeat_password": pwd,
"g-recaptcha-response": captcha(c), "language": "zh-CN", "agree_rules": "1",
"_csrf": csrf_of(r.body),
})
step("以既有邮箱重新注册(已停用则应被明确拒绝并提示找站长)", is_deactivated_rejected(r),
"HTTP %s %s" % (r.status, r.alert()))
return 0 if all(x[1] for x in RESULTS) else 1
ts = time.strftime("%m%d%H%M%S")
email, domain = "tbe2e%s@example.com" % ts, "tbe2e%s" % ts
print("== 新账号:%s / %s ==" % (email, domain))
pub = Client(base)
r = pub.get("/register")
reg = pub.post("/register", {
"email": email, "domain": domain, "name": "TB%s" % ts,
"password": pwd, "repeat_password": pwd,
"g-recaptcha-response": captcha(pub), "language": "zh-CN", "agree_rules": "1",
"_csrf": csrf_of(r.body),
})
step("注册", reg.status == 302, "HTTP %s -> %s %s" % (reg.status, reg.loc, reg.alert()))
owner = Client(base)
r = owner.get("/login")
r = owner.post("/login", {
"email": email, "password": pwd,
"g-recaptcha-response": captcha(owner), "_csrf": csrf_of(r.body),
})
step("登录", r.status == 302, "HTTP %s -> %s" % (r.status, r.loc))
step("登录态 /user/profile", owner.get("/user/profile").status == 200)
step("登录态 /user/questions", owner.get("/user/questions").status == 200)
r = owner.get("/user/votes")
step("/user/votes 302 -> /user/questions#votes",
r.status == 302 and "questions" in r.loc, "HTTP %s -> %s" % (r.status, r.loc))
step("新建投票页", owner.get("/user/votes/new").status == 200)
check_user_assets(owner)
check_vote_flow(owner, base)
# 匿名提问:无邮箱时成功后直接 200 渲染提问成功页(不重定向),故只断言提问详情可达
r = pub.get("/_/%s" % domain)
step("打开提问箱", r.status == 200)
r = pub.post("/_/%s" % domain, {
"content": "自动化巡检测试提问(可忽略)",
"g-recaptcha-response": captcha(pub),
"receive_reply_email": "", "private": "", "ask_password": "",
"start_chat_val": "", "_csrf": csrf_of(r.body),
})
step("提交提问", r.status in (200, 302), "HTTP %s -> %s" % (r.status, r.loc))
r = owner.get("/user/profile/deactivate")
step("打开停用页", r.status == 200)
r = owner.post("/user/profile/deactivate", {"_csrf": csrf_of(r.body)})
step("停用后跳 /deactivated", r.status == 302 and "deactivated" in r.loc,
"HTTP %s -> %s" % (r.status, r.loc))
r = owner.get("/deactivated")
step("停用成功页渲染", r.status == 200 and "ratio: 3" in r.body, "HTTP %s" % r.status)
pub2 = Client(base)
r = pub2.get("/register")
r = pub2.post("/register", {
"email": email, "domain": domain, "name": "TB%sb" % ts,
"password": pwd, "repeat_password": pwd,
"g-recaptcha-response": captcha(pub2), "language": "zh-CN", "agree_rules": "1",
"_csrf": csrf_of(r.body),
})
step("停用后同邮箱重注册被明确拒绝并提示找站长(核心回归)", is_deactivated_rejected(r),
"HTTP %s %s" % (r.status, r.alert()))
check_public_pages(base)
check_deactivated_i18n(base)
print("\n===== 汇总 =====")
for d, ok, ex in RESULTS:
print("%s %s%s" % ("PASS" if ok else "FAIL", d, (" (%s)" % ex) if ex else ""))
n = sum(1 for x in RESULTS if not x[1])
print("\n共 %d 项,失败 %d 项" % (len(RESULTS), n))
return 1 if n else 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
raise
except Exception as e: # noqa: BLE001
# 远端抖动等意外中断时,仍把已完成项的结论打出来,避免只剩一段 traceback
print("\n巡检意外中断:%s: %s" % (type(e).__name__, e))
for d, ok, ex in RESULTS:
print("%s %s%s" % ("PASS" if ok else "FAIL", d, (" (%s)" % ex) if ex else ""))
sys.exit(2)
-370
View File
@@ -1,370 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""图片上传链路端到端验证(批次 A / B1 / B2)。
针对已部署环境验证三件事:
B1 EXIF Orientation=6 的竖拍 JPEG 转换后必须变成竖图(核心验收点)
B2 动画 GIF 必须被转成 .webp(cwebp 可用性 + 提问路径与头像路径一致性)
A 转换后不比原图小时必须回退原图(用一张极小线框图验证)
做法:注册账号 -> 匿名提问(multipart 同时带三张图)-> 解析成功页里的 CDN URL
-> 逐张拉回字节,解析真实容器格式与尺寸。
用法:
python dev/devcheck-image.py [--base https://box-dev-api.337845818.best]
依赖:Pillow(仅用于在本地生成测试图片,不参与被测逻辑)。
注意:会在目标环境真实创建账号并发送带图提问(测试邮箱前缀 tbimg<ts>@)。
清理:TamaBox ban --email <该邮箱> --yes
"""
import argparse
import http.cookiejar
import io
import json
import os
import tempfile
import re
import struct
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
RESULTS = []
CREATED_EMAIL = ""
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
return None
class Resp:
def __init__(self, status, headers, body):
self.status = status
self.h = {k.lower(): v for k, v in headers.items()}
self.body = body
@property
def loc(self):
return self.h.get("location", "")
@property
def text(self):
return self.body.decode("utf-8", "replace")
class Client:
def __init__(self, base):
self.base = base
self.cj = http.cookiejar.CookieJar()
self.op = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(self.cj), NoRedirect())
def _send(self, req, timeout=120):
try:
with self.op.open(req, timeout=timeout) as r:
return Resp(r.status, dict(r.headers), r.read())
except urllib.error.HTTPError as e:
try:
body = e.read()
except Exception: # noqa: BLE001
body = b""
return Resp(e.code, dict(e.headers), body)
except Exception as e: # noqa: BLE001
return Resp(-1, {}, ("TRANSPORT_ERROR: %s" % e).encode())
def req(self, method, path, data=None, content_type=None, timeout=120):
url = path if path.startswith("http") else self.base + path
headers = {"User-Agent": UA, "Referer": self.base + path.split("?")[0]}
body = None
if data is not None:
body = data if isinstance(data, bytes) else urllib.parse.urlencode(data).encode()
headers["Content-Type"] = content_type or "application/x-www-form-urlencoded"
return self._send(urllib.request.Request(url, data=body, headers=headers, method=method),
timeout)
def get(self, p, **kw):
r = self.req("GET", p, **kw)
return r if r.status != -1 else self.req("GET", p, **kw)
def post(self, p, data=None, **kw):
return self.req("POST", p, data=data, **kw)
def get_binary(self, url):
return self._send(urllib.request.Request(
url, headers={"User-Agent": UA}, method="GET"), timeout=60)
def step(desc, ok, extra=""):
RESULTS.append((desc, ok, extra))
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", desc, (" (%s)" % extra) if extra else ""))
return ok
def csrf_of(html):
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
return m.group(1) if m else None
def captcha(c):
j = json.loads(c.get("/api/captcha/generate").text)
pid, q = j["data"]["id"], j["data"]["question"]
m = re.match(r"\s*(\d+)\s*([+\-×])\s*(\d+)\s*=", q)
a, op, b = int(m.group(1)), m.group(2), int(m.group(3))
return "%s:%d" % (pid, {"+": a + b, "-": a - b, "×": a * b}[op])
def multipart(fields, files):
boundary = "----tbimg%s" % uuid.uuid4().hex
buf = io.BytesIO()
for k, v in fields.items():
buf.write(("--%s\r\n" % boundary).encode())
buf.write(('Content-Disposition: form-data; name="%s"\r\n\r\n' % k).encode())
buf.write(("%s\r\n" % v).encode())
for name, fn, ct, data in files:
buf.write(("--%s\r\n" % boundary).encode())
buf.write(('Content-Disposition: form-data; name="%s"; filename="%s"\r\n' % (name, fn)).encode())
buf.write(("Content-Type: %s\r\n\r\n" % ct).encode())
buf.write(data)
buf.write(b"\r\n")
buf.write(("--%s--\r\n" % boundary).encode())
return buf.getvalue(), "multipart/form-data; boundary=%s" % boundary
# ---------- 容器格式解析(纯 stdlib) ----------
def sniff(data):
"""返回 (kind, (w,h))。kind in {webp, jpeg, gif, png, unknown}"""
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "webp", webp_dims(data)
if data[:3] == b"\xff\xd8\xff":
return "jpeg", jpeg_dims(data)
if data[:3] == b"GIF":
return "gif", gif_dims(data)
if data[:8] == b"\x89PNG\r\n\x1a\n":
return "png", png_dims(data)
return "unknown", None
def webp_dims(d):
c = d[12:16]
if c == b"VP8 " and len(d) >= 30:
return (struct.unpack("<H", d[26:28])[0] & 0x3FFF,
struct.unpack("<H", d[28:30])[0] & 0x3FFF)
if c == b"VP8L" and len(d) >= 25:
b = struct.unpack("<I", d[21:25])[0]
return (b & 0x3FFF) + 1, ((b >> 14) & 0x3FFF) + 1
if c == b"VP8X" and len(d) >= 30:
return ((d[24] | d[25] << 8 | d[26] << 16) + 1,
(d[27] | d[28] << 8 | d[29] << 16) + 1)
return None
def jpeg_dims(d):
i = 2
while i + 9 < len(d):
if d[i] != 0xFF:
i += 1
continue
m = d[i + 1]
if m in (0xC0, 0xC1, 0xC2, 0xC3):
h, w = struct.unpack(">HH", d[i + 5:i + 9])
return (w, h)
if m in (0xD8, 0xD9) or 0xD0 <= m <= 0xD7:
i += 2
continue
i += 2 + struct.unpack(">H", d[i + 2:i + 4])[0]
return None
def gif_dims(d):
return struct.unpack("<HH", d[6:10]) if len(d) >= 10 else None
def png_dims(d):
return struct.unpack(">II", d[16:24]) if len(d) >= 24 else None
# ---------- 测试图片生成(Pillow 仅此一处使用) ----------
def make_images():
from PIL import Image, ImageDraw
import io as _io
# B1:EXIF Orientation=6。像素存 240x120,正确归一后应显示 120x240。
base = Image.new("RGB", (240, 120))
dr = ImageDraw.Draw(base)
for i in range(0, 240, 20):
dr.line([(i, 0), (i, 120)], fill=(200, 30, 30), width=3)
dr.rectangle([0, 0, 239, 119], outline=(0, 0, 255), width=4)
exif = base.getexif()
exif[0x0112] = 6
buf = _io.BytesIO()
base.save(buf, "JPEG", quality=88, exif=exif)
portrait = buf.getvalue()
# B2:6 帧动画 GIF。加大到 640x480,确保转换后仍小于原图(否则会走回退)。
frames = []
pal_frames = []
for f in range(6):
im = Image.new("RGB", (640, 480), (240, 240, 240))
d = ImageDraw.Draw(im)
d.ellipse([40 + f * 60, 120, 200 + f * 60, 280], fill=(220, 40, 40))
d.rectangle([300, 60 + f * 20, 600, 200], fill=(30, 90, 220))
pal_frames.append(im.convert("P", palette=Image.ADAPTIVE, colors=64))
gbuf = _io.BytesIO()
pal_frames[0].save(gbuf, "GIF", save_all=True, append_images=pal_frames[1:],
duration=120, loop=0)
animated = gbuf.getvalue()
# A:极小线框图(200x200),WebP 有损编码后反而比 PNG 大 -> 应回退原图。
wire = Image.new("RGB", (200, 200), (255, 255, 255))
d = ImageDraw.Draw(wire)
for i in range(0, 200, 20):
d.line([(i, 0), (i, 199)], fill=(0, 0, 0))
d.line([(0, i), (199, i)], fill=(0, 0, 0))
wbuf = _io.BytesIO()
wire.save(wbuf, "PNG")
wireframe = wbuf.getvalue()
return {"portrait": portrait, "anim": animated, "wire": wireframe}
def main():
global CREATED_EMAIL
ap = argparse.ArgumentParser()
ap.add_argument("--base", default="https://box-dev-api.337845818.best")
args = ap.parse_args()
base = args.base.rstrip("/")
pwd = "TbImg!2026"
ts = time.strftime("%m%d%H%M%S")
email, domain = "tbimg%s@example.com" % ts, "tbimg%s" % ts
CREATED_EMAIL = email
print("== 账号:%s / %s ==" % (email, domain))
imgs = make_images()
for k, v in imgs.items():
print(" 本地样本 %-9s %7d bytes" % (k, len(v)))
c = Client(base)
r = c.get("/register")
reg = c.post("/register", {
"email": email, "domain": domain, "name": "TBImg%s" % ts,
"password": pwd, "repeat_password": pwd,
"g-recaptcha-response": captcha(c), "language": "zh-CN",
"_csrf": csrf_of(r.text),
})
if not step("注册", reg.status == 302, "HTTP %s -> %s" % (reg.status, reg.loc)):
return 1
c = Client(base)
r = c.get("/login")
lg = c.post("/login", {"email": email, "password": pwd,
"g-recaptcha-response": captcha(c), "_csrf": csrf_of(r.text)})
if not step("登录", lg.status == 302, "HTTP %s -> %s" % (lg.status, lg.loc)):
return 1
page = c.get("/_/%s" % domain)
if not step("打开提问箱", page.status == 200, "HTTP %s" % page.status):
return 1
# route/question/page.go 只取 f.Images[0](单张上限),
# 故每张样本各发一条提问,逐张验证。
fetched = []
for key, (fname, ctype) in (("portrait", ("portrait.jpg", "image/jpeg")),
("anim", ("anim.gif", "image/gif")),
("wire", ("wire.png", "image/png"))):
box = c.get("/_/%s" % domain)
body, ct = multipart({
"content": "图片上传链路验证 %s(可忽略)" % key,
"g-recaptcha-response": captcha(c),
"receive_reply_email": "", "private": "", "ask_password": "",
"start_chat_val": "", "_csrf": csrf_of(box.text),
}, [("images", fname, ctype, imgs[key])])
res = c.post("/_/%s" % domain, data=body, content_type=ct)
if not step("提交带图提问 [%s]" % key, res.status in (200, 302),
"HTTP %s -> %s" % (res.status, res.loc)):
continue
# 成功页不渲染图片(templates/question/success.html 无图片区),
# 故从成功页取出 question ID,再访问详情页取图片 key。
qid = re.search(r'/_/%s/(\d+)' % re.escape(domain), res.text)
if not qid:
step(" └ 取提问 ID [%s]" % key, False, "成功页未给出 /_/<domain>/<id>")
continue
detail = c.get("/_/%s/%s" % (domain, qid.group(1)))
# 提问图的 key 不带 picture/ 前缀(是 "<年>/<月>/<纳秒>.<ext>"),
# 模板拼成 https://<CDN>/<key>,故按 CDN host + 数字开头路径来匹配。
urls = sorted(set(re.findall(
r'https://r2\.[^"\'\s<>]+/\d{4}/\d{1,2}/\d+\.\w+', detail.text)))
if not urls:
step(" └ 详情页回显图片 [%s]" % key, False,
"HTTP %s,未找到 picture URL" % detail.status)
continue
step(" └ 详情页回显图片 [%s]" % key, True, "%d 个" % len(urls))
for u in urls:
st, data = c.get_binary(u)
if st != 200 or not data:
step(" └ 拉回图片 [%s]" % key, False, "HTTP %s" % st)
continue
kind, dims = sniff(data)
fetched.append((key, kind, dims, len(data)))
print(" %-14s %-7s %-12s %7d bytes" % (u.rsplit("/", 1)[-1], kind, dims, len(data)))
print("\n== 断言 ==")
# B1
portrait = [f for f in fetched if f[0] == "portrait" and f[1] in ("webp", "jpeg")
and f[2] == (120, 240)]
land = [f for f in fetched if f[0] == "portrait" and f[1] in ("webp", "jpeg")
and f[2] == (240, 120)]
if portrait:
step("B1 EXIF Orientation=6 已归一(240x120 -> 120x240)", True,
"%s, %d bytes" % (portrait[0][1], portrait[0][3]))
elif land:
step("B1 EXIF Orientation=6 已归一", False, "仍是 240x120,说明未应用 EXIF 旋转")
else:
step("B1 EXIF 方向归一", False, "未匹配到 240x120 / 120x240 的图片,实际: %s" %
[(f[1], f[2]) for f in fetched])
# B2:GIF 样本必须变成 webp;关键是按 key 归属判定,而不是看整体有没有 webp
gif_res = [f for f in fetched if f[0] == "anim"]
if not gif_res:
step("B2 动画 GIF 转 WebP", False, "未取到 anim 样本结果")
elif gif_res[0][1] == "webp":
step("B2 动画 GIF 已转为 WebP", True,
"%dx%d, %d bytes(原 GIF %d bytes,-%.0f%%)" % (
gif_res[0][2][0], gif_res[0][2][1], gif_res[0][3], len(imgs["anim"]),
(1 - gif_res[0][3] / len(imgs["anim"])) * 100))
elif gif_res[0][1] == "gif":
step("B2 动画 GIF 已转为 WebP", False,
"仍是原始 GIF %d bytes -> cwebp 不可用或转换失败" % gif_res[0][3])
else:
step("B2 动画 GIF 转 WebP", False, "得到 %s(预期 webp)" % gif_res[0][1])
# A:线框图应回退原图(PNG),而非转成更大的 WebP
wire = [f for f in fetched if f[0] == "wire"]
if not wire:
step("A 线框图兜底回退", False, "未取到 wire 样本结果")
elif wire[0][1] == "png":
step("A 转换无收益时回退原图(线框图保持 PNG)", True,
"%d bytes(原 PNG %d bytes)" % (wire[0][3], len(imgs["wire"])))
else:
step("A 线框图兜底回退", False,
"被转成了 %s(%d bytes)——本应回退原图" % (wire[0][1], wire[0][3]))
print("\n== 结果:%d/%d 通过 ==" % (sum(1 for _, ok, _ in RESULTS if ok), len(RESULTS)))
print("清理:TamaBox ban --email %s --yes" % email)
return 0 if all(ok for _, ok, _ in RESULTS) else 1
if __name__ == "__main__":
sys.exit(main())
-419
View File
@@ -1,419 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""上传链路端到端验证(A 分档 / B1 EXIF 归一 + EXIF 比例校验 / B3 单次解码)。
为什么主用例走「背景」而不是头像
--------------------------------
头像要求 1:1,是**对称**比例:EXIF 归一(5/6/7/8 交换宽高)后比例不变。
所以无论修没修比例校验的 EXIF 缺口,头像路径的表现完全一样 —— **测不出缺口**。
背景要求 16:9,非对称,且用户真实场景就是「手机竖拍一张照当背景」:
像素 1080x1920(竖,比例 0.5625)+ EXIF Orientation=6(顺时针 90)
-> 用户肉眼看到的是 1920x1080 横图,比例 1.7778,符合 16:9
-> 修复前:只看原始像素 0.5625 vs 1.7778,偏差 1.215 > 0.01 → **被误拒**
-> 修复后:先交换宽高得 1920x1080,偏差 0 → 放行
头像用例保留,但只用来回归 A 分档(不再承担 EXIF 比例校验的验证职责)。
用法:
python dev/devcheck-upload.py [--base https://box-dev-api.337845818.best]
依赖:Pillow(仅用于本地生成测试图片)
清理:TamaBox ban --email <输出中的测试邮箱> --yes
"""
import argparse
import http.cookiejar
import io
import json
import re
import struct
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from PIL import Image, ImageOps
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
RESULTS = []
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
return None
class Resp:
def __init__(self, status, headers, body):
self.status = status
self.h = {k.lower(): v for k, v in headers.items()}
self.body = body
@property
def loc(self):
return self.h.get("location", "")
@property
def text(self):
return self.body.decode("utf-8", "replace")
class Client:
def __init__(self, base):
self.base = base
self.cj = http.cookiejar.CookieJar()
self.op = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(self.cj), NoRedirect())
def _send(self, req, timeout=180):
try:
with self.op.open(req, timeout=timeout) as r:
return Resp(r.status, dict(r.headers), r.read())
except urllib.error.HTTPError as e:
try:
body = e.read()
except Exception: # noqa: BLE001
body = b""
return Resp(e.code, dict(e.headers), body)
except Exception as e: # noqa: BLE001
return Resp(-1, {}, ("TRANSPORT_ERROR: %s" % e).encode())
def req(self, method, path, data=None, content_type=None, timeout=180):
url = path if path.startswith("http") else self.base + path
headers = {"User-Agent": UA, "Referer": self.base + path.split("?")[0]}
body = None
if data is not None:
body = data if isinstance(data, bytes) else urllib.parse.urlencode(data).encode()
headers["Content-Type"] = content_type or "application/x-www-form-urlencoded"
return self._send(urllib.request.Request(url, data=body, headers=headers, method=method),
timeout)
def get(self, p, **kw):
r = self.req("GET", p, **kw)
return r if r.status != -1 else self.req("GET", p, **kw)
def post(self, p, data=None, **kw):
return self.req("POST", p, data=data, **kw)
def get_binary(self, url):
return self._send(urllib.request.Request(
url, headers={"User-Agent": UA}, method="GET"), timeout=60)
def step(desc, ok, extra=""):
RESULTS.append((desc, ok, extra))
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", desc, (" (%s)" % extra) if extra else ""))
return ok
def redness_bbox(im):
"""返回红色标记条的包围盒 (x0, y0, x1, y1)。
用来做「方向是否真的被旋转」的判据:尺寸对不代表方向对,必须看特征位置。
有损 WebP 会让红色略有偏差,故阈值放宽并按 4px 步长采样。
"""
W, H = im.size
px = im.load()
xs, ys = [], []
for y in range(0, H, 4):
for x in range(0, W, 4):
r, g, b = px[x, y]
if r > 150 and g < 90 and b < 90:
xs.append(x)
ys.append(y)
return (min(xs), min(ys), max(xs), max(ys)) if xs else None
def csrf_of(html):
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
return m.group(1) if m else None
def captcha(c):
j = json.loads(c.get("/api/captcha/generate").text)
pid, q = j["data"]["id"], j["data"]["question"]
m = re.match(r"\s*(\d+)\s*([+\-×])\s*(\d+)\s*=", q)
a, op, b = int(m.group(1)), m.group(2), int(m.group(3))
return "%s:%d" % (pid, {"+": a + b, "-": a - b, "×": a * b}[op])
def multipart(fields, files):
boundary = "----tbuv%s" % uuid.uuid4().hex
buf = io.BytesIO()
for k, v in fields.items():
buf.write(("--%s\r\n" % boundary).encode())
buf.write(('Content-Disposition: form-data; name="%s"\r\n\r\n' % k).encode())
buf.write(("%s\r\n" % v).encode())
for name, fn, ct, data in files:
buf.write(("--%s\r\n" % boundary).encode())
buf.write(('Content-Disposition: form-data; name="%s"; filename="%s"\r\n' % (name, fn)).encode())
buf.write(("Content-Type: %s\r\n\r\n" % ct).encode())
buf.write(data)
buf.write(b"\r\n")
buf.write(("--%s--\r\n" % boundary).encode())
return buf.getvalue(), "multipart/form-data; boundary=%s" % boundary
def sniff(d):
if len(d) >= 12 and d[:4] == b"RIFF" and d[8:12] == b"WEBP":
return "webp", webp_dims(d)
if d[:3] == b"\xff\xd8\xff":
return "jpeg", jpeg_dims(d)
if d[:8] == b"\x89PNG\r\n\x1a\n":
return "png", (struct.unpack(">II", d[16:24]) if len(d) >= 24 else None)
return "unknown", None
def webp_dims(d):
c = d[12:16]
if c == b"VP8 " and len(d) >= 30:
return (struct.unpack("<H", d[26:28])[0] & 0x3FFF,
struct.unpack("<H", d[28:30])[0] & 0x3FFF)
if c == b"VP8L" and len(d) >= 25:
b = struct.unpack("<I", d[21:25])[0]
return (b & 0x3FFF) + 1, ((b >> 14) & 0x3FFF) + 1
if c == b"VP8X" and len(d) >= 30:
return ((d[24] | d[25] << 8 | d[26] << 16) + 1,
(d[27] | d[28] << 8 | d[29] << 16) + 1)
return None
def jpeg_dims(d):
i = 2
while i + 9 < len(d):
if d[i] != 0xFF:
i += 1
continue
m = d[i + 1]
if m in (0xC0, 0xC1, 0xC2, 0xC3):
h, w = struct.unpack(">HH", d[i + 5:i + 9])
return (w, h)
if m in (0xD8, 0xD9) or 0xD0 <= m <= 0xD7:
i += 2
continue
i += 2 + struct.unpack(">H", d[i + 2:i + 4])[0]
return None
def make_img(w, h, orientation=None, portrait=False):
"""生成带方位特征的图片。
portrait=True 时在**左侧**画一条粗红色竖条:EXIF 顺时针 90 后它会跑到顶部,
用于像素级确认方向确实被归一了(而非仅尺寸数字对)。
"""
from PIL import ImageDraw
im = Image.new("RGB", (w, h), (30, 120, 200))
d = ImageDraw.Draw(im)
if portrait:
d.rectangle([0, 0, max(2, w // 12), h], fill=(230, 30, 30))
d.text((max(4, w // 12 + 6), h // 2), "TOP-IS-LEFT", fill=(255, 255, 255))
else:
d.rectangle([0, 0, w, max(2, h // 12)], fill=(230, 30, 30))
d.text((w // 4, max(4, h // 12 + 4)), "TOP-IS-UP", fill=(255, 255, 255))
for i in range(0, w, max(8, w // 20)):
d.line([(i, h // 4), (i, h)], fill=(255, 255, 255), width=2)
buf = io.BytesIO()
if orientation:
exif = im.getexif()
exif[0x0112] = orientation
im.save(buf, "JPEG", quality=90, exif=exif)
else:
im.save(buf, "JPEG", quality=90)
return buf.getvalue()
def scrape_profile_fields(html):
"""从 profile 页抓取所有 input 的 name/value,保证回填必填项。
UpdateProfile 的必填字段有十来个(昵称/介绍/5 个二维码颜色/点大小/透明度…),
漏一个就会因 `valid:"required"` 被Bind 挡下、图片根本不会走到上传分支。
"""
fields = {}
for m in re.finditer(r'<input\b[^>]*>', html, re.I):
tag = m.group(0)
nm = re.search(r'name="([^"]+)"', tag, re.I)
if not nm:
continue
name = nm.group(1)
if name in ("avatar", "background", "backgroundimage", "_csrf", "submit"):
continue
typ = re.search(r'type="([^"]+)"', tag, re.I)
if typ and typ.group(1).lower() in ("checkbox", "radio"):
if not re.search(r'\bchecked\b', tag, re.I):
continue
val = re.search(r'value="([^"]*)"', tag, re.I)
fields[name] = val.group(1) if val else ""
for m in re.finditer(r'<select\b[^>]*name="([^"]+)"[^>]*>(.*?)</select>', html, re.I | re.S):
name, body = m.group(1), m.group(2)
opts = re.findall(r'<option\b([^>]*)>', body, re.I)
chosen = ""
for o in opts:
vm = re.search(r'value="([^"]*)"', o, re.I)
v = vm.group(1) if vm else ""
if re.search(r'\bselected\b', o, re.I):
chosen = v
break
if not chosen:
chosen = v
fields[name] = chosen
for m in re.finditer(r'<textarea\b[^>]*name="([^"]+)"[^>]*>(.*?)</textarea>', html, re.I | re.S):
fields[m.group(1)] = re.sub(r"<[^>]+>", "", m.group(2)).strip()
return fields
def upload(c, field, data, fname="img.jpg"):
"""上传图片到 profile 表单的指定字段(avatar / background)。
返回 (图片CDN URL 或 None, 错误描述或 None)。
"""
page = c.get("/user/profile")
if page.status != 200:
return None, "profile HTTP %s" % page.status
fields = scrape_profile_fields(page.text)
fields["_csrf"] = csrf_of(page.text)
body, ct = multipart(fields, [(field, fname, "image/jpeg", data)])
res = c.post("/user/profile/update", data=body, content_type=ct)
if res.status == 302 and res.loc:
res = c.get(res.loc if res.loc.startswith("/") else "/user/profile")
if res.status != 200:
return None, "update 后页面 HTTP %s" % res.status
if field == "background":
m = re.search(r'id="bg-prev"[^>]*src="([^"]+)"', res.text) or \
re.search(r'src="([^"]+)"[^>]*id="bg-prev"', res.text)
url = m.group(1) if m else None
else:
urls = re.findall(r'https://r2\.[^"\'\s<>]+/picture/[0-9]+/\d{2}/\d{2}/[0-9a-f]+', res.text)
if not urls:
m = re.search(r'(https://r2\.[^"\'\s<>]+/picture/[^"\'\s<>]+)', res.text)
urls = [m.group(1)] if m else []
url = urls[-1] if urls else None
if url:
return url, None
dump = "C:/Users/Tamakyi Gekka/AppData/Local/Temp/tbuv_profile.html"
with open(dump, "wb") as f:
f.write(res.body)
alerts = re.findall(r'class="uk-alert[^"]*"[^>]*>(.*?)</div>', res.text, re.S)
hint = " | ".join(re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", a)).strip()[:80]
for a in alerts[:3])
return None, "未取到图片 URL(已存 %s)%s%s" % (dump, (" 提示:" + hint) if hint else "",
(" HTTP %s" % res.status))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--base", default="https://box-dev-api.337845818.best")
args = ap.parse_args()
base = args.base.rstrip("/")
pwd = "TbUv!2026"
ts = time.strftime("%m%d%H%M%S")
email, domain = "tbuv%s@example.com" % ts, "tbuv%s" % ts
print("== 账号:%s / %s ==" % (email, domain))
# ---- 本地样本 ----
square = make_img(1600, 1600) # A 分档回归:头像
bg_ok = make_img(1920, 1080) # 背景 16:9,无 EXIF(正向对照)
bg_ori6 = make_img(1080, 1920, orientation=6, portrait=True) # 核心用例
bg_bad = make_img(1080, 1920) # 无 EXIF 的竖图:应被拒(16:9 校验仍生效)
print(" 样本 avatar 1600x1600 %7d B" % len(square))
print(" 样本 bg-ok 1920x1080 %7d B" % len(bg_ok))
print(" 样本 bg-ori6 1080x1920 (EXIF=6) %7d B" % len(bg_ori6))
print(" 样本 bg-bad 1080x1920 (无 EXIF) %7d B" % len(bg_bad))
c = Client(base)
r = c.get("/register")
reg = c.post("/register", {
"email": email, "domain": domain, "name": "TBUv%s" % ts,
"password": pwd, "repeat_password": pwd,
"g-recaptcha-response": captcha(c), "language": "zh-CN",
"_csrf": csrf_of(r.text),
})
if not step("注册", reg.status == 302, "HTTP %s -> %s" % (reg.status, reg.loc)):
return 1
c = Client(base)
r = c.get("/login")
lg = c.post("/login", {"email": email, "password": pwd,
"g-recaptcha-response": captcha(c), "_csrf": csrf_of(r.text)})
if not step("登录", lg.status == 302, "HTTP %s -> %s" % (lg.status, lg.loc)):
return 1
# ================= A 分档(头像回归) =================
print("\n-- A 分档(头像 512/q78)--")
url, err = upload(c, "avatar", square)
if not step("上传方形大头像(1600x1600)", url is not None, err or (url or "")):
return 1
img = c.get_binary(url)
if not step("拉回头像", img.status == 200 and bool(img.body), "HTTP %s" % img.status):
return 1
kind, dims = sniff(img.body)
print(" 存储结果:%s %s %d bytes" % (kind, dims, len(img.body)))
step("A 分档生效:大图被缩到头像档 512", dims == (512, 512), "实际 %s" % (dims,))
# ================= B1 背景正向对照(无 EXIF) =================
print("\n-- 背景 16:9 正向对照(无 EXIF)--")
url_bg, err = upload(c, "background", bg_ok)
if not step("上传 16:9 背景(1920x1080,无 EXIF)", url_bg is not None, err or (url_bg or "")):
return 1
r1 = c.get_binary(url_bg)
k1, d1 = sniff(r1.body)
print(" 存储结果:%s %s %d bytes" % (k1, d1, len(r1.body)))
step("背景档 1920 且比例正确", k1 == "webp" and d1 == (1920, 1080), "实际 %s %s" % (k1, d1))
# ================= B1 核心:EXIF Orientation=6 的竖拍背景 =================
print("\n-- B1 核心:EXIF 比例校验缺口(手机竖拍当背景)--")
print(" 像素 1080x1920 + EXIF=6 ->肉眼看到 1920x1080 (16:9)")
print(" 修复前:按像素 0.5625 判比例 -> 偏差 1.215 >> 0.01 -> 被误拒")
url_o, err = upload(c, "background", bg_ori6)
if not step("上传 EXIF 竖拍背景(1080x1920, Orientation=6)", url_o is not None,
err or (url_o or "")):
print(" >>> 若此处 FAIL,说明 EXIF 比例校验缺口仍未修复")
return 1
if url_o == url_bg:
step("EXIF 背景确实换了新图", False, "两次上传返回同一 URL(第二次被拒、沿用旧值)")
return 1
r2 = c.get_binary(url_o)
k2, d2 = sniff(r2.body)
print(" 存储结果:%s %s %d bytes" % (k2, d2, len(r2.body)))
step("B1 EXIF 归一后按16:9 存盘(1920x1080)", k2 == "webp" and d2 == (1920, 1080),
"实际 %s %s" % (k2, d2))
step("B1 归一后限边生效(长边 1920)",
d2 is not None and max(d2) == 1920, "实际 %s" % (d2,))
# 尺寸对了不代表方向对了 —— 与 Pillow exif_transpose 作像素级黄金标准比对:
# 定位红色条的包围盒,两边必须一致(原图红条在左侧,顺时针 90 后应跑到顶部)。
ref = ImageOps.exif_transpose(Image.open(io.BytesIO(bg_ori6))).convert("RGB")
got = Image.open(io.BytesIO(r2.body)).convert("RGB")
if ref.size == got.size:
bb_ref, bb_got = redness_bbox(ref), redness_bbox(got)
step("B1 像素级方向与 Pillow exif_transpose 一致(红条包围盒)",
bb_ref is not None and bb_ref == bb_got,
"黄金标准 %s vs 存盘 %s" % (bb_ref, bb_got))
else:
step("B1 像素级方向与 Pillow exif_transpose 一致(红条包围盒)", False,
"尺寸不同:黄金标准 %s vs 存盘 %s" % (ref.size, got.size))
# ================= 反向用例:EXIF 修复不能放松比例校验 =================
print("\n-- 反向用例:无 EXIF 的竖图仍应被拒(确认没把校验放得过宽)--")
url_bad, err_bad = upload(c, "background", bg_bad)
# 期望:上传被拒 -> bg-prev 仍是上一步的 ori6 图(url_o)
if url_bad == url_o:
step("无 EXIF 的 9:16 竖图被 16:9 校验拒绝", True, "bg-prev 保持 ori6 那张")
elif url_bad is None:
step("无 EXIF 的 9:16 竖图被 16:9 校验拒绝", True, "上传被拒(%s)" % (err_bad or "")[:60])
else:
step("无 EXIF 的 9:16 竖图被 16:9 校验拒绝", False,
"竟被接受并存成 %s" % (url_bad,))
print("\n== 结果:%d/%d 通过 ==" % (sum(1 for _, ok, _ in RESULTS if ok), len(RESULTS)))
for d, ok, e in RESULTS:
if not ok:
print(" FAIL: %s %s" % (d, e))
print("清理:TamaBox ban --email %s --yes" % email)
return 0 if all(ok for _, ok, _ in RESULTS) else 1
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -1 +1 @@
{"Replace": {"C:/OtherFiles/Github/TamaBox-v4-mainline/internal/storage/s3.go": "C:/Users/TAMAKY~1/AppData/Local/Temp/tamabox_s3_stub.go"}}
{"Replace": {"C:/OtherFiles/Github/TamaBox-v4-mainline/internal/storage/s3.go": "C:/OtherFiles/Github/TamaBox-v4-mainline/dev/s3stub/s3_stub.go"}}
+77
View File
@@ -0,0 +1,77 @@
// Command preview-honeypot 把全部伪装家族渲染成静态 HTML,供人工核对外观。
//
// 用法:go run ./dev/preview-honeypot
// 产物:outputs/honeypot-preview/index.html(9 个 iframe 并排)
//
// 改动 internal/security/honeypot/family.go 的皮肤后重新跑一次,
// 比对着源码想象 CSS 可靠得多 —— 上一版就是靠它发现 Tomcat 按钮没背景色。
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/tamakyi/TamaBox/internal/security/honeypot"
)
type item struct {
fam honeypot.Family
action string
note string
}
func main() {
dir := "outputs/honeypot-preview"
if err := os.MkdirAll(dir, 0o755); err != nil {
panic(err)
}
items := []item{
{honeypot.FamilyWordPress, "/wp-login.php", "/wp-login.php · /wp-admin"},
{honeypot.FamilyPhpMyAdmin, "/phpmyadmin/index.php", "/phpmyadmin · /pma"},
{honeypot.FamilyTomcat, "/manager/html", "/manager/html · 401 Basic"},
{honeypot.FamilyJenkins, "/jenkins/login", "/jenkins"},
{honeypot.FamilyGrafana, "/grafana/login", "/grafana"},
{honeypot.FamilyGitLab, "/users/sign_in", "/users/sign_in"},
{honeypot.FamilyLuCI, "/cgi-bin/luci", "/cgi-bin/luci"},
{honeypot.FamilyJoomla, "/administrator/index.php", "/administrator"},
{honeypot.FamilyGeneric, "/admin", "/admin · /console"},
}
var cards strings.Builder
for _, it := range items {
name := it.fam.String()
body := honeypot.RenderFamily(it.fam, honeypot.LoginPageVars{
Action: it.action,
Redir: "/wp-admin/",
Failed: false,
})
if err := os.WriteFile(filepath.Join(dir, name+".html"), body, 0o644); err != nil {
panic(err)
}
fmt.Fprintf(&cards, `<figure><figcaption><b>%s</b><span>%s</span></figcaption><iframe src="%s.html" title="%s"></iframe></figure>`,
name, it.note, name, name)
}
page := `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
<title>蜜罐伪装家族预览</title>
<style>
body{margin:0;padding:28px 24px 40px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f6f7f9;color:#1d2327}
h1{font-size:18px;font-weight:600;margin:0 0 6px}
p.lead{margin:0 0 22px;font-size:13px;color:#646970}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:18px}
figure{margin:0;background:#fff;border:1px solid #dcdcde;border-radius:8px;overflow:hidden}
figcaption{display:flex;justify-content:space-between;gap:8px;padding:9px 12px;font-size:12px;border-bottom:1px solid #f0f0f1}
figcaption span{color:#787c82}
iframe{width:100%;height:520px;border:0;display:block}
</style></head><body>
<h1>蜜罐:9 个伪装家族</h1>
<p class="lead">每个 iframe 是 /wp-admin 这类诱饵路径的真实响应(同一份 RenderFamily 输出,未做任何美化)。Tomcat 家族在真实响应里是 401 + Basic Auth,这里有页面是为了看内容。</p>
<div class="grid">` + cards.String() + `</div></body></html>`
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(page), 0o644); err != nil {
panic(err)
}
fmt.Println("预览已生成:", filepath.Join(dir, "index.html"))
}
+51
View File
@@ -0,0 +1,51 @@
// 临时校验程序:用 aws-sdk-go-v2 的 SigV4 signer 给同一请求签名,
// 与 dev/theme-builder/serve.py 手写的实现比对。验证完即删。
//
// 每行 stdin:method \t url \t region \t payloadHash \t amzDate
package main
import (
"bufio"
"context"
"fmt"
"net/http"
"os"
"strings"
"time"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/aws/aws-sdk-go-v2/credentials"
)
func main() {
keyID := os.Getenv("AWS_KEY_ID")
secret := os.Getenv("AWS_SECRET")
s := bufio.NewScanner(os.Stdin)
for s.Scan() {
line := strings.TrimSpace(s.Text())
if line == "" {
continue
}
f := strings.Split(line, "\t")
method, urlStr, region, payloadHash, ts := f[0], f[1], f[2], f[3], f[4]
t, err := time.Parse("20060102T150405Z", ts)
if err != nil {
panic(err)
}
req, err := http.NewRequest(method, urlStr, nil)
if err != nil {
panic(err)
}
req.Header.Set("X-Amz-Content-Sha256", payloadHash)
creds := credentials.NewStaticCredentialsProvider(keyID, secret, "")
c, err := creds.Retrieve(context.Background())
if err != nil {
panic(err)
}
if err := v4.NewSigner().SignHTTP(context.Background(), c, req, payloadHash, "s3", region, t); err != nil {
panic(err)
}
fmt.Println(req.Header.Get("Authorization"))
}
}
+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"
'
+132 -4
View File
@@ -16,13 +16,26 @@
TEST_PREVIEW=1 go test ./templates -run TestGenPreview
# 2) 起服务(只读,不改项目任何文件)
python dev/theme-builder/serve.py # 默认 8777
python dev/theme-builder/serve.py # 默认监听 0.0.0.0:8777,同局域网可访问
python dev/theme-builder/serve.py --host 127.0.0.1 # 只给本机用(公共网络请加这个)
# 3) 打开
http://127.0.0.1:8777/dev/theme-builder/
# 3) 打开终端里打印的地址
# 本机 http://127.0.0.1:8777/dev/theme-builder/
# 局域网 http://192.168.x.x:8777/dev/theme-builder/
```
不想用脚本也行:在项目根跑 `python -m http.server 8777`,效果一样。
默认绑 `0.0.0.0`,是为了能躺在床上用手机 / 平板调主题:启动时会把本机各网卡的地址都列出来,
照着点就行(手机与电脑要在同一网段)。第一次启动 Windows 会弹防火墙询问,要允许私有网络。
⚠️ 两个必须知道的点:
- **上传接口没有账号体系**:能打开这个端口的人,都能用你填的密钥往对象存储写 / 删文件。
公共网络(咖啡馆、宿舍、公司网)一律 `--host 127.0.0.1`。写接口还会校验 `Origin` 与
`Host` 同源,挡掉「别的网页借你浏览器发请求」,但挡不住同网段主动连过来的人。
- **换地址 = 换 origin**:`127.0.0.1` 与 `192.168.x.x` 在浏览器眼里是两个站点,
草稿(localStorage)与 S3 密钥(sessionStorage)不共享 —— 第一次用新地址要重填一次密钥。
不想用脚本也行:在项目根跑 `python -m http.server 8777`,但那就只有静态预览、没有上传能力。
## 它和线上是同一条链路
@@ -133,6 +146,46 @@ http://127.0.0.1:8777/dev/theme-builder/
生成 `--tb-page-position`(`base.css` 里以 `var(--tb-page-position, center center)` 兜底,
旧主题不写这个令牌也不会坏)。
**配色** —— 只填 6 个基础色(底色 / 卡片 / 顶栏 / 正文 / 主色 / 边框),
其余 4 个派生:`surface-2` 往正文靠、`surface-hover` 往主色靠、`primary-weak` 加 12% 透明、
`text-secondary` / `text-muted` 往底色淡出。
⚠️ **派生文字色有对比度下限,不是固定比例**。正文往背景淡出时,二分找到「最淡但仍 ≥ 目标」的位置:
次要文字 5.5:1、弱化文字 4.5:1(WCAG AA 正文)。旧版按「混 55%」硬算,
在浅底上直接掉到 2.9:1 —— `base.css` 特意把 `--tb-text-muted` 从 `#8a919c` 提到 `#6b7280`(4.83:1),
生成器按固定比例算等于每导出一次就把那个修复覆盖回去。
- 卡片是半透明(`rgba(255,255,255,0.86)`)时先**压平到页面底色**再算,否则白卡会被当成纯白、对比度偏低;
- 同时检查页面底色与卡片两个背景,取更不利的那个;
- 手填的正文本身就达不到目标时**原样返回**(不再往淡里调)——宁可不够淡,不能产出看不见的字;
- 面板底部有读数 `正文 12.5:1 · 次要 5.5:1 · 淡文字 4.6:1 · 主色 4.6:1`,低于 4.5 变橙(手填色只提醒不拦)。
主色除了当链接色,还当按钮/标签的**背景**,所以还要交代「压在上面的字」:
导出时写 `--tb-on-primary`,在白字和深字(`#0b1016`)里挑对比度更高的那个。
浅色主题主色深 → 白字;深色主题主色普遍偏亮(`#5c8dff` 之流)→ 白字只有 2.2~3.1:1,必须换深字。
主色卡在**中间调**时两种字都不够 —— 面板会给橙色提示「压深一点配白字,或提亮一点配深字」,
`?dump=palette` 里也会列进 `warnings`(不判失败:那是主色要调,不是工具挑错)。
顺带:这套字号阶梯(`--tb-fs-xs…hero`)来自 `base.css`,生成器**不碰字号** ——
主题的职责是配色与背景,改字号会连带挤坏所有页面排版。
**顶栏** —— 三档,只管「配色 → 顶栏」那个基色怎么用:
| 档位 | 输出 | 适用 |
| --- | --- | --- |
| 实色 | `--tb-navbar: rgb(...)` | 页面没有装饰层穿过顶栏;最简单 |
| 半透明 | `--tb-navbar: rgba(...,0.72)` + `backdrop-filter: blur(12px)` | 装饰层从角落长出来、会经过顶栏时(推荐) |
| 全透明 | `--tb-navbar: transparent` | **只能预览**,导出会被拦 |
- 半透明档的两个滑杆:不透明度 0.2~0.95、背景模糊 0~24px。令牌只能是颜色值,
模糊另外写成 `.uk-navbar-container:not(.uk-navbar-transparent)` 规则(深浅两套共用一条)。
- 面板底部有 `导航文字 6.0:1` 的读数,按**压平后的顶栏**算(半透明时是「顶栏压在底色上」的结果,
不是顶栏色本身)。低于 4.5 变橙 —— 装饰层从顶栏后面穿过时还会更低,取最不利情况定透明度。
- ⚠️ **全透明不给导出**:顶栏一透明导航就失去依托、文字直接压在花纹上,比"被裁一段"更糟。
这一档留着的唯一用途是**预览对比**;`validate()` 会拦,仓库的 `TestNavbarNotFullyTransparent`
也会拦(两处同一条规矩,生成器里绕过也没用)。
- 旧工程(配色里直接塞 `rgba(...,0.52)`)打开时按 alpha 反推档位:1 → 实色、0 → 全透明、其余 → 半透明。
**图片池** —— 上传任意张,浏览器本地转 WebP。导出时只打包实际用到的图。
每张图左上角有 `fx` 按钮,进处理面板(处理过的会高亮)。
@@ -167,6 +220,79 @@ WebP 体积 ≈ 像素数 × 质量。**真正的大头是像素**:CSS 里只
| --- | --- | --- |
| 质量 | 0.88 | 0.85~0.9 肉眼几乎看不出差别,低于 0.8 才明显糊 |
| 长边上限 | 1600 | 只缩不放。整页背景建议 1920/不限制;装饰人物 1200 够用 |
| 素材前缀 | 空 | 空 = 图片放 `static/images/themes/<slug>/` 随二进制发布;填 `https://cdn.example.com/themes` = CSS 写绝对地址,图片放自己的对象存储(R2 / MinIO / B2 / OSS / COS,任何 S3 兼容都行)。**前缀里不要带 slug** |
导出的 CSS 只有 `urlOf()` 一处决定图片地址,图层、背景、窄屏块全走它,不会出现
「背景走 CDN、装饰还指本地」的半吊子状态。素材前缀只影响导出,预览永远是内联 dataURL。
详细取舍(生成器前缀 / 应用内 301 / 边缘改写)见 `docs/theme-assets-cdn.md`。
## 素材托管(本地 / 任意 S3 兼容)
「图片放哪」由你自己定,面板里二选一:
- **本地**(默认):打进 `static/images/themes/<slug>/`,随二进制发布。部署即生效、不依赖外部服务,
代价是换一张图要重新编译。
- **S3 兼容存储**:传到你自己的对象存储,导出的 CSS 写绝对地址。这里刻意不锁厂商 ——
R2 / MinIO / Backblaze B2 / 阿里 OSS(S3) / 腾讯 COS(S3) 用的是同一套 SigV4,
差别只在 `Endpoint`、`Region`、是否路径风格,全都做成了字段。
填好 Endpoint / Region / 桶名 / 路径前缀 / 密钥后:
1. **测试连接** —— 依次验四件事:`HEAD` 桶(连通与凭据)、`PUT` 一个探针对象(写权限)、
`GET` 读回比对(内容一致)、匿名 `GET`(传上去的图能不能被浏览器直接读到),最后删掉探针。
失败时会把 S3 的 `<Code>` 翻成人话(`SignatureDoesNotMatch` → 密钥/Region/时钟三选一)。
2. **上传素材** —— 把本次主题真正引用到的图(与导出同一个集合,`usedAssetIds()`)逐张 PUT,
键为 `<路径前缀>/<slug>/<id>.webp`,带 `Cache-Control: public, max-age=31536000, immutable`。
成功后自动把上面的「素材前缀」填成推导出的公开地址。
⚠️ 上传走的是本机 `serve.py` 的 `/api/s3/test`、`/api/s3/upload`,浏览器不直连对象存储 ——
所以**不用给桶配 CORS**,密钥也不会出现在页面发往第三方的请求里。它只存在当前标签页的
`sessionStorage` 与内存:不进 `localStorage` 草稿、不进工程 json、不进导出 zip。
用 `python -m http.server` 起的只有静态服务,探测不到接口时按钮会禁用并说明原因。
公开地址留空时按 endpoint 推导(path 风格 = `<endpoint>/<桶>/<前缀>`)。R2 与私有桶
需要自定义域或 CDN 域名,那种情况自己填「公开前缀」。测试里的「匿名可读」会给一条提醒,
但不会拦上传 —— 很多人就是靠自定义域分发的。
### 检测与清理
传上去只是第一步,「传上去之后对不对」是另一回事:同一个 slug 传过三次、换过 slug
旧图还留在桶里、重新导出后远端还是旧版 —— 这些在页面上都看不出来。所以还有一块
「远端素材」,点「**检测**」列出 `<路径前缀>/<slug>/` 下所有对象,按对象键名跟本地比:
| 状态 | 判断依据 | 能做什么 |
| --- | --- | --- |
| 一致 | 远端大小 == 本地编码后大小 | — |
| 旧图 | 同名但大小不同(多半重新导出过) | 行内 ↻ 用本地覆盖 |
| 多余 | 远端有、当前主题已不再引用(换过 slug / 删过素材) | 行内 ✕ 或「删除多余」批量删 |
| 缺失 | 本地有、远端没有 | 「补齐差异」一次传完 |
- 「**补齐差异**」只传缺失 + 旧图,一致的不重传(省流量也省时间);
- 「**删除多余**」列清单后二次确认,走 `DeleteObject`(幂等,不存在的键也算成功);
- 改过 slug 会提示「重新检测」—— 拿旧主题的清单去比新主题,会满屏「多余」;
- 只动远端,本地素材与工程文件不受影响。
厂商怎么填(2026 年的常见值,各家控制台都有「S3 API」页面):
| 厂商 | Endpoint | Region | 路径风格 |
| --- | --- | --- | --- |
| Cloudflare R2 | `https://<账号ID>.r2.cloudflarestorage.com` | `auto` | 勾选 |
| MinIO(自建) | `http://<host>:9000` | `us-east-1`(看服务端配置) | 勾选 |
| Backblaze B2 | `https://s3.<区域>.backblazeb2.com` | 区域名,如 `us-west-004` | 勾选 |
| 阿里云 OSS | `https://s3.oss-<区域>.aliyuncs.com` | 区域,如 `cn-hangzhou` | 看控制台 |
| 腾讯云 COS | `https://cos.<区域>.myqcloud.com` | 区域,如 `ap-hangzhou` | 看控制台 |
AWS S3 或已绑定的桶级自定义域可以取消「路径风格」(走 `<桶>.<endpoint>`)。
两条自检(都在本目录,不依赖 UI):
```bash
python dev/theme-builder/check_sigv4.py # 与 aws-sdk-go-v2 的 signer 逐字节对拍
python dev/theme-builder/selftest_upload.py # 起假 S3,跑「测试连接 → 上传 → 读回」
python dev/theme-builder/mock_s3.py --port 9900 # 想手动点面板时,先起这个假 S3
# 生成器在别的设备上打开时,假 S3 也要 --host 0.0.0.0,面板里的 Endpoint 填本机 IP
```
面板底部实时显示 `3 张,共 19KB;最大一张 bg.webp 7KB(1920×1080)`。
改完立刻按「原图 + fx 参数」重编码全部素材,并报出省了多少 —— 与图片处理同一条非破坏原则,
@@ -252,6 +378,8 @@ INSTALL-<slug>.txt
?dump=layers 图层/作用域回归(加层默认不绑图、行内换图、自动切独立、新建清空)
?dump=fit 尺寸自适应回归(五档视口占用、溢出检测、fitw、旧工程兼容、@media 覆盖各作用域)
?dump=quality 画质回归(q 单调性、q=1 被夹到 0.95、长边上限真的压像素)
?dump=palette 派生文字色的对比度回归(四种配色下 ≥AA、层级不倒挂、手填低对比不继续变淡)
?dump=navbar 顶栏三档回归(实色无 blur、半透明带 alpha+blur、全透明被 validate 拦、alpha 边界)
?dump=fxkey 端到端去白底:载入白底图,统计透明像素比例
?loadurl=/static/images/x.webp&fx=x
直接把项目里的图当素材载入(不必先下载再上传),fx= 打开处理面板
+103
View File
@@ -0,0 +1,103 @@
"""校验 serve.py 手写的 SigV4 与 aws-sdk-go-v2 的 signer 逐字节一致。
为什么需要这个:SigV4 错一个字节,服务端只会回一句 SignatureDoesNotMatch,
既不说哪里错、也不会在本地暴露出来 —— 上传功能就成了「点了没反应」。
这里用官方 SDK 当参照实现做对拍,改完签名相关代码跑一遍即可。
用法(项目根):
python dev/theme-builder/check_sigv4.py
⚠️ 用例只放 ASCII 键:aws-sdk-go-v2 裸 SignHTTP 默认会二次转义 URI,
而 S3 客户端内部把 DisableURIPathEscaping 设成了 true(单次编码)。
即同一个路径两种行为,把非 ASCII 键放进来只会比出「SDK 的两个模式之差」。
我们跟随 S3 客户端的单次编码(AWS 文档的 RFC3986 规则),并由生成器把
slug 限制在 [a-z0-9-_],从根上避开这个争议区。
"""
import os
import subprocess
import sys
import urllib.parse
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE))
sys.path.insert(0, HERE)
import serve # noqa: E402
KEY_ID = "AKIAIOSFODNN7EXAMPLE"
SECRET = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
TS = "20130524T000000Z"
EMPTY = serve.EMPTY_SHA256
BODY_HASH = serve._sha256(b"TamaBox theme builder probe\n")
# (说明, method, endpoint, extra cfg, key, query)
CASES = [
("path-style 桶级 HEAD", "HEAD", "http://127.0.0.1:9000", dict(bucket="tb-assets"), "", None),
("path-style 带前缀对象", "PUT", "http://127.0.0.1:9000",
dict(bucket="tb-assets", prefix="themes"), "shiroko/bg1.webp", None),
("virtual-host 对象", "GET", "https://s3.us-west-004.backblazeb2.com",
dict(bucket="my-bucket", region="us-west-004", pathStyle=False), "themes/shiroko/bg1.webp", None),
("R2 region=auto", "PUT", "https://acc.r2.cloudflarestorage.com",
dict(bucket="cdn", region="auto", prefix="themes"), "x/a.webp", None),
("R2 虚拟主机", "GET", "https://acc.r2.cloudflarestorage.com",
dict(bucket="cdn", region="auto", pathStyle=False), "themes/s/t.webp", None),
("带端口 https", "DELETE", "https://minio.lan:9443",
dict(bucket="static", region="us-east-1"), "themes/s/t.webp", None),
# 带 query:ListObjectsV2 的 canonical query 要按键排序、逐项编码(/ 编成 %2F)
("ListObjectsV2 三参数", "GET", "http://127.0.0.1:9000", dict(bucket="tb-assets"),
"", {"list-type": "2", "max-keys": "1000", "prefix": "themes/shiroko/"}),
("ListObjectsV2 分页(乱序传入)", "GET", "https://acc.r2.cloudflarestorage.com",
dict(bucket="cdn", region="auto", pathStyle=False), "",
{"prefix": "themes/", "continuation-token": "1/abc+d", "max-keys": "2", "list-type": "2"}),
]
def build(endpoint, extra, key):
cfg = serve.S3Config(dict(extra, endpoint=endpoint, accessKey=KEY_ID, secretKey=SECRET))
if key:
path = cfg.object_path(key)
else:
path = ("/" + cfg.bucket) if cfg.path_style else "/"
return cfg, path
def go_sign(payloads):
env = dict(os.environ, AWS_KEY_ID=KEY_ID, AWS_SECRET=SECRET,
GOPROXY="https://goproxy.cn,direct", GOSUMDB="off", GOFLAGS="-mod=mod")
p = subprocess.run(["go", "run", "./dev/sigv4ref"], input="\n".join(payloads),
capture_output=True, text=True, env=env, cwd=ROOT)
if p.returncode != 0:
sys.stderr.write(p.stderr[-2000:])
raise SystemExit("go run 失败(检查 GOPROXY / Go 是否可用)")
return [x for x in p.stdout.strip().splitlines() if x.strip()]
def main():
payloads = []
for _, method, endpoint, extra, key, query in CASES:
cfg, path = build(endpoint, extra, key)
body = BODY_HASH if method == "PUT" else EMPTY
url = cfg.url(path) + (("?" + serve.canonical_query(query)) if query else "")
payloads.append("\t".join([method, url, cfg.region, body, TS]))
go = go_sign(payloads)
if len(go) != len(CASES):
raise SystemExit("返回条数不对:go=%d cases=%d" % (len(go), len(CASES)))
bad = 0
for (desc, method, endpoint, extra, key, query), g in zip(CASES, go):
cfg, path = build(endpoint, extra, key)
body = BODY_HASH if method == "PUT" else EMPTY
py = serve.sign_v4(cfg, method, path, body, TS, cfg.host, serve.canonical_query(query))
if g.rsplit("Signature=", 1)[-1] == py.rsplit("Signature=", 1)[-1]:
print("OK %s" % desc)
else:
bad += 1
print("DIFF %s\n go %s\n py %s\n path %s" % (desc, g, py, path))
print("\n%d/%d 一致" % (len(CASES) - bad, len(CASES)))
return 1 if bad else 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
+184
View File
@@ -0,0 +1,184 @@
"""最小 S3 兼容假服务,只为调试生成器的「测试连接 / 上传素材」。
它不校验签名(签名正确性由 check_sigv4.py 与官方 SDK 对拍保证),只校验
请求形态与权限分支,所以能用来演练:桶名写错、密钥写错、前缀为空、
虚拟主机风格这些配置。
独立跑:python dev/theme-builder/mock_s3.py --port 9900
被复用:from mock_s3 import MockS3, BUCKET
"""
import argparse
import sys
import threading
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
BUCKET = "tb-assets"
class MockS3(BaseHTTPRequestHandler):
store = {}
requests = []
def log_message(self, fmt, *args):
pass
# ---- helpers ----
def _split(self):
path = urllib.parse.unquote(self.path.split("?")[0]).lstrip("/")
bucket, _, key = path.partition("/")
return bucket, key
def _key_id(self):
# Authorization 形如
# AWS4-HMAC-SHA256 Credential=AKID/20261021/us-east-1/s3/aws4_request, SignedHeaders=...
# 第一个逗号前既有算法名又有 Credential,不能按逗号切完再比前缀。
auth = self.headers.get("Authorization") or ""
i = auth.find("Credential=")
if i < 0:
return ""
return auth[i + len("Credential="):].split("/")[0]
def _body(self):
n = int(self.headers.get("Content-Length") or 0)
return self.rfile.read(n) if n else b""
def _xml(self, status, code, message):
body = ("<?xml version=\"1.0\"?><Error><Code>%s</Code><Message>%s</Message></Error>"
% (code, message)).encode()
self.send_response(status)
self.send_header("Content-Type", "application/xml")
if self.command != "HEAD":
# HEAD 不能有 body:真 S3 也只是返回状态行,客户端仍会按状态码报错。
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def _check(self, bucket):
MockS3.requests.append((self.command, self.path, dict(self.headers)))
if self._key_id() == "BADKEY":
self._xml(403, "SignatureDoesNotMatch",
"The request signature we calculated does not match")
return False
if bucket != BUCKET:
self._xml(404, "NoSuchBucket", "The specified bucket does not exist")
return False
if not (self.headers.get("x-amz-date") or "").endswith("Z"):
self._xml(403, "AccessDenied", "missing x-amz-date")
return False
return True
# ---- verbs ----
def do_HEAD(self):
bucket, _ = self._split()
if not self._check(bucket):
return
self.send_response(200)
self.send_header("Content-Length", "0")
self.end_headers()
def do_PUT(self):
bucket, key = self._split()
data = self._body()
if not self._check(bucket):
return
MockS3.store[key] = (data, self.headers.get("Content-Type"),
self.headers.get("Cache-Control"))
self.send_response(200)
self.send_header("ETag", '"mock"')
self.send_header("Content-Length", "0")
self.end_headers()
def do_GET(self):
parsed = urllib.parse.urlsplit(self.path)
qs = urllib.parse.parse_qs(parsed.query)
bucket, key = self._split()
if not key and qs.get("list-type") == ["2"]:
if not self._check(bucket):
return
return self._list(qs.get("prefix", [""])[0],
int(qs.get("max-keys", ["1000"])[0] or 1000),
int(qs.get("continuation-token", ["0"])[0] or 0))
MockS3.requests.append((self.command, self.path, dict(self.headers)))
if key not in MockS3.store:
self._xml(404, "NoSuchKey", "The specified key does not exist")
return
data, ctype, _ = MockS3.store[key]
self.send_response(200)
self.send_header("Content-Type", ctype or "application/octet-stream")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _list(self, prefix, max_keys, offset):
# 只实现 ListObjectsV2 里服务端用到的字段;分页用 offset 当 token,够测真实分页逻辑。
keys = sorted(k for k in MockS3.store if k.startswith(prefix))
page = keys[offset:offset + max_keys]
truncated = offset + len(page) < len(keys)
items = []
for k in page:
data = MockS3.store[k][0]
items.append(
"<Contents><Key>%s</Key><Size>%d</Size>"
"<LastModified>2026-10-21T00:00:00.000Z</LastModified><ETag>&quot;mock&quot;</ETag></Contents>"
% (k, len(data)))
token = ("<NextContinuationToken>%d</NextContinuationToken>" % (offset + len(page))) if truncated else ""
body = (
'<?xml version="1.0" encoding="UTF-8"?>'
'<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">'
"<Name>%s</Name><Prefix>%s</Prefix><KeyCount>%d</KeyCount>"
"<MaxKeys>%d</MaxKeys><IsTruncated>%s</IsTruncated>%s%s"
"</ListBucketResult>"
% (BUCKET, prefix, len(page), max_keys, "true" if truncated else "false",
token, "".join(items))
).encode()
self.send_response(200)
self.send_header("Content-Type", "application/xml")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_DELETE(self):
bucket, key = self._split()
if not self._check(bucket):
return
MockS3.store.pop(key, None)
self.send_response(204)
self.send_header("Content-Length", "0")
self.end_headers()
def spin(port=0, host="127.0.0.1"):
"""起在后台线程,返回 (server, endpoint)。port=0 让系统挑空闲端口。
host 默认只给本机用;如果生成器是在别的设备上打开的(局域网访问),
要把这里改成 0.0.0.0,并在面板里把 Endpoint 填成本机 IP。
"""
srv = ThreadingHTTPServer((host, port), MockS3)
threading.Thread(target=srv.serve_forever, daemon=True).start()
shown = "127.0.0.1" if host in ("0.0.0.0", "::") else host
return srv, "http://%s:%d" % (shown, srv.server_address[1])
def main():
ap = argparse.ArgumentParser(description="TamaBox 主题生成器用的假 S3")
ap.add_argument("--port", type=int, default=9900)
ap.add_argument("--host", default="127.0.0.1",
help="监听地址,默认 127.0.0.1;生成器在别的设备上打开时用 0.0.0.0")
args = ap.parse_args()
srv, endpoint = spin(args.port, args.host)
print("假 S3 已启动:%s" % endpoint)
print(" 桶名 %s,Access Key 随意(写 BADKEY 会返回 SignatureDoesNotMatch)" % BUCKET)
if args.host in ("0.0.0.0", "::"):
print(" 局域网设备访问时,面板里的 Endpoint 填 http://<本机IP>:%d" % args.port)
print("Ctrl+C 停止")
try:
threading.Event().wait()
except KeyboardInterrupt:
srv.shutdown()
print("\n已停止")
if __name__ == "__main__":
sys.exit(main())
+152
View File
@@ -0,0 +1,152 @@
"""上传链路自测:起一个假 S3,跑一遍「测试连接 → 上传 → 读回」。
覆盖 serve.py 里真正容易错、又不能靠对拍发现的部分:
· 桶级 HEAD 与对象级 PUT/GET/DELETE 的路径拼接(path style / 虚拟主机)
· 前缀拼接、Content-Type、immutable 缓存头
· 4xx 时能否把 S3 的 <Code>/<Message> 还原成人话(含 hint)
签名数学由 check_sigv4.py 与官方 SDK 对拍保证,这里不重复。
用法(项目根):
python dev/theme-builder/selftest_upload.py
"""
import base64
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import serve # noqa: E402
from mock_s3 import BUCKET, MockS3 # noqa: E402
def spin():
import mock_s3
return mock_s3.spin()
FAILS = []
def check(desc, cond, extra=""):
print(("OK " if cond else "FAIL ") + desc + ((" ← " + extra) if extra and not cond else ""))
if not cond:
FAILS.append(desc)
def main():
srv, endpoint = spin()
base = dict(endpoint=endpoint, bucket=BUCKET, prefix="themes", region="us-east-1",
accessKey="AKID", secretKey="SECRET")
# 1) 测试连接:4 步应全过,探针最后被删掉
cfg = serve.S3Config(dict(base))
res = serve.test_connection(cfg)
check("测试连接返回 ok", res.get("ok") is True)
check("测试连接 4 步全过", all(s["ok"] for s in res["steps"]), str(res))
check("探针已清理", not any(k.startswith("themes/.tb-probe") for k in MockS3.store),
str(list(MockS3.store)))
# 2) 上传:前缀 / 内容 / 头都要对
f1 = b"\x52\x49\x46\x46fakewebp1"
f2 = b"\x52\x49\x46\x46fakewebp2"
up = serve.upload_files(cfg, [
{"key": "shiroko/asset1.webp", "contentType": "image/webp",
"data": base64.b64encode(f1).decode()},
{"key": "shiroko/asset2.webp", "contentType": "image/webp",
"data": base64.b64encode(f2).decode()},
])
check("上传全部成功", up["ok"] and up["total"] == 2 and up["failed"] == 0, str(up))
check("前缀拼进对象键", "themes/shiroko/asset1.webp" in MockS3.store, str(list(MockS3.store)))
stored, ctype, cache = MockS3.store.get("themes/shiroko/asset1.webp", (b"", None, None))
check("内容一致", stored == f1)
check("Content-Type = image/webp", ctype == "image/webp", str(ctype))
check("Cache-Control = immutable", "immutable" in (cache or ""), str(cache))
# 2b) 逐文件覆盖缓存头:README/清单这类「键不变、内容会变」的文件不能 immutable
serve.upload_files(cfg, [
{"key": "shiroko/INSTALL.txt", "contentType": "text/plain; charset=utf-8",
"cacheControl": "public, max-age=60",
"data": base64.b64encode(b"readme").decode()},
])
_, _, cc = MockS3.store.get("themes/shiroko/INSTALL.txt", (b"", None, None))
check("可覆盖 Cache-Control", cc == "public, max-age=60", str(cc))
# 这一步只为验头,别让它污染后面的对象计数
MockS3.store.pop("themes/shiroko/INSTALL.txt", None)
# 3) 错误密钥:HEAD 拿不到响应体(规范如此),只能按状态码归因;
# 真正能读到 S3 错误码的是带 body 的 PUT,所以用上传路径验证还原能力。
bad = serve.S3Config(dict(base, accessKey="BADKEY"))
try:
serve.test_connection(bad)
check("错误密钥应抛错", False)
except serve.S3Error as e:
check("HEAD 失败按状态码归因", e.status == 403 and e.code == "AccessDenied",
"%s %s" % (e.status, e.code))
badUp = serve.upload_files(bad, [{"key": "shiroko/x.webp", "contentType": "image/webp",
"data": base64.b64encode(b"x").decode()}])
codes = [f.get("code") for f in badUp["files"]]
check("PUT 失败能还原 SignatureDoesNotMatch", codes == ["SignatureDoesNotMatch"], str(codes))
check("该错误有中文提示", "签名不匹配" in serve.S3_HINTS.get("SignatureDoesNotMatch", ""))
# 4) 桶名写错:NoSuchBucket
wrong = serve.S3Config(dict(base, bucket="nope"))
try:
serve.test_connection(wrong)
check("错桶应抛错", False)
except serve.S3Error as e:
check("错桶 → NoSuchBucket", e.code == "NoSuchBucket", e.code)
# 5) 键里出现 .. 必须被拒(防止越权写到别的前缀)
try:
serve.full_key(cfg, "../etc/passwd")
check(".. 应被拒", False)
except ValueError:
check(".. 被拒绝", True)
# 6) 前缀留空 = 传桶根
plain = serve.S3Config(dict(base, prefix=""))
up2 = serve.upload_files(plain, [{"key": "root.webp", "contentType": "image/webp",
"data": base64.b64encode(b"x").decode()}])
check("空前缀传桶根", up2["ok"] and "root.webp" in MockS3.store, str(list(MockS3.store)))
# 7) 列远端:前缀过滤、大小、分页
serve.upload_files(cfg, [{"key": "shiroko/asset3.webp", "contentType": "image/webp",
"data": base64.b64encode(b"three").decode()}])
ls = serve.list_objects(cfg, "shiroko/")
keys = sorted(o["key"] for o in ls["objects"])
check("按 <前缀>/<slug>/ 列出 3 个对象",
keys == ["themes/shiroko/asset1.webp", "themes/shiroko/asset2.webp",
"themes/shiroko/asset3.webp"], str(keys))
size1 = [o["size"] for o in ls["objects"] if o["key"].endswith("asset1.webp")]
check("对象大小与上传字节一致", size1 == [len(f1)], str(size1))
paged = serve.list_objects(cfg, "shiroko/", max_keys=2)
check("分页把 3 个都取回来了", len(paged["objects"]) == 3, str(len(paged["objects"])))
everything = serve.list_objects(cfg, "")
# 空前缀 = 列「路径前缀」之下(跨主题),不扫整个桶:桶里还可能有头像等别的东西。
check("空前缀列出 <路径前缀>/ 下全部、且不越界",
all(o["key"].startswith("themes/") for o in everything["objects"])
and len(everything["objects"]) == 3,
str([o["key"] for o in everything["objects"]]))
# 8) 删除:幂等(不存在的键也当成功),且不影响别的前缀
del_res = serve.delete_objects(cfg, ["shiroko/asset3.webp", "shiroko/nonexistent.webp"])
check("删除含不存在的键仍算成功", del_res["ok"] and del_res["total"] == 2, str(del_res))
after = serve.list_objects(cfg, "shiroko/")
check("删完只剩 2 个", len(after["objects"]) == 2,
str([o["key"] for o in after["objects"]]))
check("桶根对象没被误删", "root.webp" in MockS3.store)
# 9) 虚拟主机风格的路径(不带桶名)
vh = serve.S3Config(dict(base, pathStyle=False))
check("虚拟主机路径不含桶名", vh.object_path("themes/a.webp") == "/themes/a.webp", vh.object_path("x"))
check("虚拟主机 host 带桶名", vh.host.startswith(BUCKET + "."), vh.host)
srv.shutdown()
print("\n%d 项失败" % len(FAILS))
return 1 if FAILS else 0
if __name__ == "__main__":
raise SystemExit(main())
+531 -10
View File
@@ -1,33 +1,441 @@
#!/usr/bin/env python3
"""主题生成器的本地预览服务。
"""主题生成器的本地预览服务 + 素材上传代理。
为什么必须起服务:生成器不再自己画一个"仿制页面",而是把项目真实渲染出来的
页面(outputs/preview-*.html)放进 iframe 当画板,再往里注入正在编辑的主题 CSS。
file:// 下浏览器不允许父页面访问 iframe 的 DOM(即使同目录),预览就只能退回示意图。
起一个本地 HTTP 服务后父子同源,拖拽、注入、实时改令牌都能正常工作。
用法(在项目根目录执行任意一种):
为什么上传也走这个服务:浏览器直连对象存储要配 CORS、还要把密钥交给页面里的
fetch,各家 S3 兼容实现的 CORS 配置界面又都不一样。放到本机 Python 里代发,
生成器只跟 127.0.0.1 说话:不碰 CORS,密钥不落盘、不进工程文件、不进导出 zip。
python dev/theme-builder/serve.py
python -m http.server 8777
用法(在项目根目录执行):
然后打开 http://localhost:8777/dev/theme-builder/
python dev/theme-builder/serve.py # 默认 0.0.0.0:8777,同局域网可访问
python dev/theme-builder/serve.py --host 127.0.0.1 # 只给本机(公共网络用这个)
python -m http.server 8777 # 只有静态预览,没有上传能力
启动时终端会列出本机与各网卡的地址,照着打开即可。
这个脚本只读文件,不写任何东西,也不改项目配置。
"""
import argparse
import base64
import datetime
import functools
import hashlib
import hmac
import http.server
import json
import os
import re
import socket
import socketserver
import sys
import urllib.error
import urllib.parse
import urllib.request
# dev/theme-builder/serve.py → 上两级就是项目根
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# 单次请求体上限:一次上传十几张 1600px 的 WebP,base64 后约 1.4 倍体积,
# 64MB 足够,同时挡住误连到公网时的内存放大。
MAX_BODY_BYTES = 64 * 1024 * 1024
EMPTY_SHA256 = hashlib.sha256(b"").hexdigest()
# ---------------------------------------------------------------------------
# SigV4 签名
# ---------------------------------------------------------------------------
# 不用 boto3:这个脚本的定位是「零依赖、拷走就能跑」,装一个 SDK 只为签四个请求
# 不划算。下面是 AWS 文档里 SigV4 for S3 的最小实现,覆盖 PUT / GET / DELETE / HEAD。
def _hmac(key: bytes, msg: str) -> bytes:
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
def _sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _quote_key(key: str) -> str:
"""按 AWS 规则编码对象键:保留 `/`,其余非 unreserved 字符全部百分号编码。
用 quote(safe='/-_.~') 而不是逐段 quote:两者等价,但后者容易漏掉空段
(`a//b`)导致的路径塌缩。S3 的签名 URI 必须与请求行完全一致,差一个字符
就是 SignatureDoesNotMatch。
"""
return urllib.parse.quote(key, safe="/-_.~")
def _quote_query(s):
"""query 的编码规则比 path 更严:`/` 也要编码成 %2F,只有 A-Za-z0-9-_.~ 原样保留。"""
return urllib.parse.quote(str(s), safe="-_.~")
def canonical_query(params):
"""按 AWS 规则拼 canonical query:先按键排序,同名多值再按值排序,逐项编码。
ListObjectsV2 的 `list-type` / `prefix` / `continuation-token` 全走这里。
顺序错、或漏了编码都是 SignatureDoesNotMatch —— 而 S3 不会告诉你差在哪。
"""
pairs = []
for k, v in (params or {}).items():
for one in (v if isinstance(v, (list, tuple)) else [v]):
pairs.append((_quote_query(k), _quote_query(one)))
pairs.sort()
return "&".join("%s=%s" % kv for kv in pairs)
def sign_v4(cfg, method, canonical_path, payload_hash, amz_date, host, query=""):
region = cfg.region
date = amz_date[:8]
signed_headers = "host;x-amz-content-sha256;x-amz-date"
canonical_headers = (
"host:" + host + "\n"
"x-amz-content-sha256:" + payload_hash + "\n"
"x-amz-date:" + amz_date + "\n"
)
# CanonicalHeaders 自身每行以 \n 结尾,与 SignedHeaders 之间因此有一个空行,
# 这是规范要求的形态,不是笔误。
canonical_request = "\n".join([
method, canonical_path, query or "", canonical_headers, signed_headers, payload_hash,
])
scope = date + "/" + region + "/s3/aws4_request"
string_to_sign = "\n".join([
"AWS4-HMAC-SHA256", amz_date, scope, _sha256(canonical_request.encode("utf-8")),
])
k = _hmac(("AWS4" + cfg.secret_key).encode("utf-8"), date)
k = _hmac(k, region)
k = _hmac(k, "s3")
k = _hmac(k, "aws4_request")
signature = hmac.new(k, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
return (
"AWS4-HMAC-SHA256 Credential=" + cfg.access_key + "/" + scope
+ ", SignedHeaders=" + signed_headers + ", Signature=" + signature
)
class S3Error(Exception):
def __init__(self, step, status, code, message):
Exception.__init__(self, message or code or ("HTTP %s" % status))
self.step = step
self.status = status
self.code = code
self.message = message or ""
# 常见错误 → 人话。签名失败是最常见的,原因几乎总是密钥/region/时钟三者之一。
S3_HINTS = {
"SignatureDoesNotMatch": "签名不匹配:核对 Secret Key 是否复制完整、Region 是否与桶一致(R2 填 auto),以及本机时间偏差是否超过 15 分钟。",
"InvalidAccessKeyId": "Access Key 无效:核对 ID,或该密钥没有这个桶的权限。",
"AccessDenied": "权限不足:这个密钥没有写权限,或桶策略禁止了该操作。",
"NoSuchBucket": "桶不存在:核对桶名与 Endpoint 是否属于同一账号/区域。",
"PermanentRedirect": "Endpoint 与桶所在区域不匹配:换对该区域的 Endpoint(或改用虚拟主机风格)。",
"AuthorizationHeaderMalformed": "Region 写错了:填桶实际所在区域(R2 用 auto,MinIO 常为 us-east-1)。",
"InvalidBucketName": "桶名不合法:S3 桶名只允许小写字母、数字、点和中划线。",
"RequestTimeTooSkewed": "本机时间与服务器偏差过大:校准系统时间后重试。",
}
def _parse_s3_error(body):
text = body[:2000].decode("utf-8", "replace")
code = re.search(r"<Code>([^<]+)</Code>", text)
msg = re.search(r"<Message>([^<]+)</Message>", text)
return (code.group(1).strip() if code else ""), (msg.group(1).strip() if msg else text.strip()[:200])
class S3Config:
"""一次上传用到的全部参数。只活在内存里,不落盘。"""
def __init__(self, d):
ep = str(d.get("endpoint") or "").strip()
if not ep:
raise ValueError("Endpoint 没填")
if "://" not in ep:
ep = "https://" + ep
u = urllib.parse.urlsplit(ep)
if u.scheme not in ("http", "https"):
raise ValueError("Endpoint 只支持 http / https")
if not u.hostname:
raise ValueError("Endpoint 里没有主机名")
# 路径部分一律忽略:Endpoint 只表达「服务在哪」,桶和键由字段决定。
self.scheme = u.scheme
self.port = u.port
self.host_only = u.hostname
self.region = str(d.get("region") or "").strip() or "us-east-1"
self.bucket = str(d.get("bucket") or "").strip()
if not self.bucket:
raise ValueError("桶名没填")
self.prefix = str(d.get("prefix") or "").strip().strip("/")
self.access_key = str(d.get("accessKey") or "").strip()
self.secret_key = str(d.get("secretKey") or "").strip()
if not self.access_key or not self.secret_key:
raise ValueError("Access Key / Secret Key 没填")
self.path_style = bool(d.get("pathStyle", True))
@property
def host(self):
if self.path_style:
return self.host_only if not self.port else "%s:%d" % (self.host_only, self.port)
return "%s.%s" % (self.bucket, self.host_only) + (":%d" % self.port if self.port else "")
def object_path(self, key):
"""S3 的签名 URI 必须与请求行逐字一致,所以两者共用这一个函数。"""
path = ("/" + self.bucket + "/" + key) if self.path_style else ("/" + key)
return _quote_key(path)
def url(self, path):
return "%s://%s%s" % (self.scheme, self.host, path)
def _now():
return datetime.datetime.now(datetime.timezone.utc)
def s3_request(cfg, method, key="", body=b"", content_type=None, cache_control=None,
query=None, timeout=25):
"""发一个签名请求。key 为空表示桶级操作(HEAD bucket / ListObjectsV2)。
返回 (status, headers, body);4xx/5xx 不抛异常,交由调用方判断 —— 测试连接
需要把「403 是权限问题」和「连不上是网络问题」区分开讲。
"""
if key:
path = cfg.object_path(key)
else:
path = ("/" + cfg.bucket) if cfg.path_style else "/"
qs = canonical_query(query)
payload_hash = _sha256(body) if body else EMPTY_SHA256
amz_date = _now().strftime("%Y%m%dT%H%M%SZ")
auth = sign_v4(cfg, method, path, payload_hash, amz_date, cfg.host, qs)
url = cfg.url(path) + (("?" + qs) if qs else "")
req = urllib.request.Request(url, data=body if body else None, method=method)
req.add_header("x-amz-content-sha256", payload_hash)
req.add_header("x-amz-date", amz_date)
req.add_header("Authorization", auth)
if content_type:
req.add_header("Content-Type", content_type)
if cache_control:
req.add_header("Cache-Control", cache_control)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, dict(r.headers), r.read()
except urllib.error.HTTPError as e:
return e.code, dict(e.headers), e.read()
except Exception as e: # URLError / timeout / SSL 错误
raise S3Error("connect", 0, "NetworkError", "%s(%s)" % (e, cfg.host))
def full_key(cfg, key):
key = str(key or "").strip().lstrip("/")
if not key:
raise ValueError("对象键为空")
if ".." in key.split("/") or "\\" in key:
raise ValueError("对象键不能包含 .. 或反斜杠:%s" % key)
return (cfg.prefix + "/" + key) if cfg.prefix else key
def probe_key(cfg):
stamp = _now().strftime("%Y%m%dT%H%M%S")
return full_key(cfg, ".tb-probe-%s.txt" % stamp)
def _public_read(url, timeout=10):
"""匿名 GET 一次,判断「传上去的图能不能直接被浏览器读到」。
这一步不是必须的(很多人会用自定义域/CDN),但它能把最常见的坑当场暴露:
桶是私有的,上传全成功、页面里全是 403。
"""
try:
with urllib.request.urlopen(url, timeout=timeout) as r:
return r.status == 200
except urllib.error.HTTPError as e:
return e.code == 200
except Exception:
return False
def test_connection(cfg):
steps = []
status, _, body = s3_request(cfg, "HEAD")
if status >= 400:
code, msg = _parse_s3_error(body)
if not code:
# HEAD 响应按规范没有 body,S3 的错误码拿不到。只能按状态码归因,
# 否则前端只能显示一句「HTTP 403」,用户完全不知道该改哪。
code = {403: "AccessDenied", 404: "NoSuchBucket",
301: "PermanentRedirect", 307: "PermanentRedirect",
400: "BadRequest"}.get(status, "HTTP%d" % status)
msg = "桶级探测失败(HEAD 无响应体,按状态码判断)"
raise S3Error("HeadBucket", status, code, msg)
steps.append({"name": "连接与凭据", "ok": True, "detail": "桶可访问(HEAD %s)" % cfg.bucket})
key = probe_key(cfg)
payload = b"TamaBox theme builder probe\n"
status, _, body = s3_request(cfg, "PUT", key, payload, "text/plain; charset=utf-8", "no-store")
if status >= 400:
code, msg = _parse_s3_error(body)
raise S3Error("PutObject", status, code, msg)
steps.append({"name": "写入权限", "ok": True, "detail": "已写入探针 %s" % key})
status, _, body = s3_request(cfg, "GET", key)
ok = status == 200 and body == payload
steps.append({
"name": "读回校验", "ok": ok,
"detail": "读回一致" if ok else "读回失败(HTTP %s)" % status,
})
public = _public_read(cfg.url(cfg.object_path(key)))
steps.append({
"name": "匿名可读", "ok": True,
"detail": "该路径可直接匿名访问" if public
else "匿名访问被拒 —— 桶没开公开读时正常,靠自定义域或 CDN 分发即可",
"warn": not public,
})
s3_request(cfg, "DELETE", key)
return {"ok": True, "steps": steps, "key": key}
# ---------------------------------------------------------------------------
# 上传
# ---------------------------------------------------------------------------
def upload_files(cfg, files):
if not files:
raise ValueError("没有要上传的素材")
if len(files) > 200:
raise ValueError("一次最多 200 个文件")
results = []
for f in files:
name = str(f.get("name") or f.get("key") or "?")
try:
key = full_key(cfg, f.get("key") or f.get("name"))
data = base64.b64decode(f.get("data") or "", validate=False)
if not data:
raise ValueError("内容为空")
ctype = str(f.get("contentType") or "application/octet-stream")
# 素材键里带内容版本(导出时由 id 决定),内容不会原地变化 → immutable。
# 但清单/说明这类「键不变、内容会变」的文件不能 immutable,否则一年内看不到更新,
# 所以允许逐个文件覆盖。
cache = str(f.get("cacheControl") or "public, max-age=31536000, immutable")
status, _, body = s3_request(cfg, "PUT", key, data, ctype, cache)
if status >= 400:
code, msg = _parse_s3_error(body)
results.append({"key": key, "name": name, "ok": False,
"status": status, "code": code, "error": msg})
else:
results.append({"key": key, "name": name, "ok": True, "bytes": len(data)})
except Exception as e:
results.append({"key": name, "name": name, "ok": False, "error": str(e)})
failed = [r for r in results if not r["ok"]]
return {"ok": not failed, "files": results, "failed": len(failed), "total": len(results)}
# ---------------------------------------------------------------------------
# 远端清单 / 删除
# ---------------------------------------------------------------------------
def _local(tag):
"""去掉 XML 命名空间。各家 S3 兼容实现都带 `http://s3.amazonaws.com/doc/2006-03-01/`,
个别(老 MinIO / 某些网关)又不带 —— 只比 localname 两边都能认。"""
return tag.rsplit("}", 1)[-1]
def parse_list_xml(body):
"""解析 ListObjectsV2 响应。返回 (对象数组, 下一页 token 或 None)。"""
import xml.etree.ElementTree as ET
root = ET.fromstring(body)
objects, token, truncated = [], None, False
for node in root:
name = _local(node.tag)
if name == "Contents":
row = {}
for f in node:
row[_local(f.tag)] = f.text
objects.append({
"key": row.get("Key") or "",
"size": int(row.get("Size") or 0),
"modified": row.get("LastModified") or "",
"etag": (row.get("ETag") or "").strip('"'),
})
elif name == "NextContinuationToken":
token = node.text
elif name == "IsTruncated":
truncated = (node.text or "").strip().lower() == "true"
return objects, (token if truncated else None)
def list_objects(cfg, prefix="", max_pages=20, max_keys=1000):
"""列 `<cfg.prefix>/<prefix>` 下所有对象。分页上限只是防呆,正常一个主题不到 100 个。"""
key_prefix = full_key(cfg, prefix) if str(prefix or "").strip() else (cfg.prefix + "/" if cfg.prefix else "")
objects, token, pages = [], None, 0
while pages < max_pages:
pages += 1
query = {"list-type": "2", "max-keys": str(max_keys)}
if key_prefix:
query["prefix"] = key_prefix
if token:
query["continuation-token"] = token
status, _, body = s3_request(cfg, "GET", query=query)
if status >= 400:
code, msg = _parse_s3_error(body)
raise S3Error("ListObjects", status, code or "HTTP%d" % status, msg)
page, token = parse_list_xml(body)
objects.extend(page)
if not token:
break
return {"ok": True, "prefix": key_prefix, "objects": objects, "truncated": bool(token)}
def delete_objects(cfg, keys):
"""逐个 DeleteObject。
没用批量 POST ?delete:那要额外算 Content-MD5、拼 XML body,而一次最多几十个键,
省下的往返抵不上多一条签名路径的风险。S3 的 DELETE 本身幂等(不存在的键也回 204)。
"""
if not keys:
raise ValueError("没有要删除的对象")
if len(keys) > 500:
raise ValueError("一次最多删 500 个对象")
results = []
for k in keys:
try:
key = full_key(cfg, k)
status, _, body = s3_request(cfg, "DELETE", key)
if status >= 400 and status != 404:
code, msg = _parse_s3_error(body)
results.append({"key": key, "ok": False, "status": status,
"code": code or "HTTP%d" % status, "error": msg})
else:
results.append({"key": key, "ok": True})
except Exception as e:
results.append({"key": str(k), "ok": False, "error": str(e)})
failed = [r for r in results if not r["ok"]]
return {"ok": not failed, "files": results, "failed": len(failed), "total": len(results)}
# ---------------------------------------------------------------------------
# HTTP 层
# ---------------------------------------------------------------------------
class Handler(http.server.SimpleHTTPRequestHandler):
server_version = "TamaBoxThemeBuilder/1"
def end_headers(self):
# 预览页是刚由 go test 生成的,禁止缓存,否则改了 CSS 看不到变化
self.send_header("Cache-Control", "no-store, must-revalidate")
@@ -39,11 +447,108 @@ class Handler(http.server.SimpleHTTPRequestHandler):
if str(status).startswith(("4", "5")):
sys.stderr.write(" %s\n" % (fmt % args))
def _json(self, payload, status=200):
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _read_json(self):
n = int(self.headers.get("Content-Length") or 0)
if n <= 0:
raise ValueError("请求体为空")
if n > MAX_BODY_BYTES:
raise ValueError("请求体过大(%d 字节),一次别传太多素材" % n)
return json.loads(self.rfile.read(n).decode("utf-8"))
def _same_origin(self):
"""写接口只接受「页面自己发来的」请求。
监听 0.0.0.0 之后,同网段里的另一个网页可以借访客浏览器向这里发写请求
(删对象、改前缀)。浏览器跨站请求一定带 Origin,比对它与 Host 是否同源
就能挡掉这类「借刀」调用。curl / 脚本不带 Origin,照常放行。
"""
origin = self.headers.get("Origin")
if not origin:
return True
host = (self.headers.get("Host") or "").strip().lower()
return urllib.parse.urlsplit(origin).netloc.lower() == host
def do_GET(self):
if self.path.split("?")[0] == "/api/s3/ping":
# 生成器靠它判断「当前是不是 serve.py 起的」——python -m http.server 没有这个端点。
return self._json({"ok": True, "upload": True})
return super().do_GET()
def do_POST(self):
route = self.path.split("?")[0]
if route not in ("/api/s3/test", "/api/s3/upload", "/api/s3/list", "/api/s3/delete"):
return self._json({"ok": False, "error": "未知接口 %s" % route}, 404)
if not self._same_origin():
return self._json({"ok": False, "error": "拒绝跨站写请求(Origin 与访问地址不一致)"}, 403)
try:
payload = self._read_json()
except Exception as e:
return self._json({"ok": False, "error": "请求解析失败:%s" % e})
try:
cfg = S3Config(payload.get("config") or {})
except ValueError as e:
return self._json({"ok": False, "error": str(e)})
try:
if route == "/api/s3/test":
return self._json(test_connection(cfg))
if route == "/api/s3/list":
return self._json(list_objects(cfg, payload.get("prefix") or ""))
if route == "/api/s3/delete":
return self._json(delete_objects(cfg, payload.get("keys") or []))
return self._json(upload_files(cfg, payload.get("files") or []))
except S3Error as e:
return self._json({
"ok": False, "step": e.step, "status": e.status, "code": e.code,
"error": e.message, "hint": S3_HINTS.get(e.code, ""),
})
except ValueError as e:
return self._json({"ok": False, "error": str(e)})
except Exception as e: # 兜底:别把本地服务弄崩,把异常原样告诉页面
return self._json({"ok": False, "error": "%s: %s" % (type(e).__name__, e)})
def local_addresses():
"""本机所有可用于访问的 IPv4(回环排最后)。
绑 0.0.0.0 时监听地址本身不能直接点开(浏览器访问 http://0.0.0.0 行为各家不一),
所以启动时把真实 IP 列出来,省得人去 ipconfig 里翻。
"""
out = []
try:
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
ip = info[4][0]
if ip not in out and not ip.startswith("127."):
out.append(ip)
except Exception:
pass
try:
# 不联网也会返回默认路由网卡的地址;这一步能补上 getaddrinfo 漏掉的情况
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
if ip and not ip.startswith("127.") and ip not in out:
out.append(ip)
except Exception:
pass
return out
def main():
ap = argparse.ArgumentParser(description="TamaBox 主题生成器的本地预览服务")
ap.add_argument("--port", type=int, default=8777, help="端口,默认 8777")
ap.add_argument("--host", default="127.0.0.1", help="监听地址,默认 127.0.0.1")
ap.add_argument("--host", default="0.0.0.0",
help="监听地址,默认 0.0.0.0(同一局域网都能打开);只给本机用就写 127.0.0.1")
args = ap.parse_args()
if not os.path.isdir(os.path.join(PROJECT_ROOT, "outputs")):
@@ -52,12 +557,28 @@ def main():
" TEST_PREVIEW=1 go test ./templates -run TestGenPreview\n"
)
wildcard = args.host in ("0.0.0.0", "::")
if not wildcard and args.host not in ("127.0.0.1", "localhost", "::1"):
sys.stderr.write("⚠️ 只监听 %s,仅该地址可达。\n" % args.host)
handler = functools.partial(Handler, directory=PROJECT_ROOT)
socketserver.TCPServer.allow_reuse_address = True
with socketserver.TCPServer((args.host, args.port), handler) as httpd:
url = "http://%s:%d/dev/theme-builder/" % (args.host, args.port)
socketserver.ThreadingTCPServer.allow_reuse_address = True
# Threading:上传一张几 MB 的图会占住连接,单线程时 iframe 预览和其它请求都得排队。
with socketserver.ThreadingTCPServer((args.host, args.port), handler) as httpd:
httpd.daemon_threads = True
print("项目根:%s" % PROJECT_ROOT)
print("生成器:%s" % url)
if wildcard:
for ip in local_addresses():
print("生成器:http://%s:%d/dev/theme-builder/" % (ip, args.port))
print("生成器:http://127.0.0.1:%d/dev/theme-builder/ (本机)" % args.port)
print("提示:局域网里的其它设备用上面的 IP 地址打开;手机与电脑要在同一网段。")
sys.stderr.write(
"⚠️ 正在对局域网开放(%s:%d)。上传接口没有账号体系 —— 能打开这个端口的人,\n"
" 都能用你填的密钥往对象存储写 / 删文件。公共网络(咖啡馆、宿舍、公司网)\n"
" 请改用 --host 127.0.0.1。\n" % (args.host, args.port))
else:
print("生成器:http://%s:%d/dev/theme-builder/" % (args.host, args.port))
print("上传接口:/api/s3/test /api/s3/upload /api/s3/list /api/s3/delete")
print("Ctrl+C 停止")
try:
httpd.serve_forever()
+219
View File
@@ -0,0 +1,219 @@
"""把 new-sakura 的内联 SVG 导出成位图,并上传到对象存储。
为什么要这一步:主题本身是纯 CSS(SVG 内联在 CSS 里),线上不依赖任何外部素材。
但对象存储上留一份位图有它的用处:
· 桶开了公开域名后,可以把 CSS 里的 url() 换成绝对地址(注意:服务端有 gzip,
内联版总传输 3.9KB 比位图版的 51KB 更小,换位图不为省流量);
· 分享卡 / 其它非 CSS 场景只能用位图;
· 换人维护时不用去读那一长串 data URI。
用法(项目根):
python dev/theme-tools/export_assets.py # 只导出到 outputs/sakura-assets
python dev/theme-tools/export_assets.py --upload # 导出,并把 CSS + 位图 + INSTALL.txt 传到桶里
"""
import argparse
import io
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(ROOT, "dev", "theme-builder"))
import gen_new_sakura as G # noqa: E402
OUT_DIR = os.path.join(ROOT, "outputs", "sakura-assets")
# (文件名, svg 源, 导出像素宽, 高)
def assets():
p = G.LIGHT
return [
("branch-right", G.branch(p["wood"], p["light"], p["deep"], p["heart"], flip=False), 840, 640),
("branch-left", G.branch(p["wood"], p["light"], p["deep"], p["heart"], flip=True), 840, 640),
("blossom", G.blossom(p["light"], p["deep"], p["heart"]), 400, 400),
("petal", G.petal(p["light"]), 240, 336),
("pattern", G.pattern(p["pattern"]), 600, 600),
]
def render(svg, w, h, out_png):
"""用无头 Chrome 渲染成透明底 PNG。
走命令行而不是 Playwright:本机只有 node 版 playwright,python 侧没装;
而 `--default-background-color=00000000` 正好能给透明底。
⚠️ `--window-size` 不等于 CSS 视口(实测 1080 只有 985),所以窗口开大一圈
并让内容居中,再按目标尺寸从中心裁 —— 这样尺寸误差被吸收掉,不必去猜差多少。
"""
import subprocess
import tempfile
from PIL import Image
pad = 120
html = (
'<!doctype html><meta charset="utf-8">'
"<style>html,body{margin:0;background:transparent;height:100vh;"
"display:flex;align-items:center;justify-content:center}"
f"svg{{display:block;width:{w}px;height:{h}px}}</style>" + svg
)
fd, html_path = tempfile.mkstemp(suffix=".html")
os.close(fd)
with open(html_path, "w", encoding="utf-8") as f:
f.write(html)
chrome = "C:/Program Files/Google/Chrome/Application/chrome.exe"
cmd = [
chrome, "--headless=old", "--disable-gpu", "--hide-scrollbars",
"--force-device-scale-factor=1", "--default-background-color=00000000",
"--window-size=%d,%d" % (w + pad, h + pad),
"--screenshot=" + out_png,
"file:///" + html_path.replace("\\", "/"),
]
subprocess.run(cmd, check=True, capture_output=True, timeout=60)
os.remove(html_path)
img = Image.open(out_png).convert("RGBA")
cw, ch = img.size
left, top = max(0, (cw - w) // 2), max(0, (ch - h) // 2)
img.crop((left, top, left + w, top + h)).save(out_png)
def to_webp(png_path, webp_path, quality=92):
from PIL import Image
img = Image.open(png_path).convert("RGBA")
img.save(webp_path, "WEBP", quality=quality, method=6)
def read_r2(path):
cfg = {}
for line in open(path, encoding="utf-8"):
m = re.match(r'^\s*([a-z_]+)\s*=\s*"?([^"\n]*)"?\s*$', line.strip())
if m:
cfg[m.group(1)] = m.group(2).strip()
return cfg
def pack_readme(prefix, object_base, sizes):
"""生成包内 INSTALL.txt。域名与体积都写实数,避免 README 与实际脱节。"""
base = (object_base or "").rstrip("/")
total_kb = sum(v for k, v in sizes.items() if k.endswith(".webp")) / 1024
css_kb = sizes.get("new-sakura.css", 0) / 1024
urls = ""
if base:
urls = "\n公开地址(已验证可匿名 GET)\n--------------------------\n" + "\n".join(
" %s/%s" % (base, n) for n in ["new-sakura.css"] + [
"%s.webp" % k for k in ["branch-right", "branch-left", "blossom", "petal", "pattern"]])
return """new-sakura(新·樱花)主题包
============================
来源:TamaBox 仓库 static/css/themes/new-sakura.css(生成产物,勿手改)
生成:dev/theme-tools/gen_new_sakura.py(花形/位置/配色都在里面)
位图:dev/theme-tools/export_assets.py(从同一份 SVG 导出,不是另画的)
说明:docs/theme-new-sakura.md
对象前缀:%s/
文件
----
new-sakura.css 主题本体 %5.1f KB(纯 CSS,樱花是内联 SVG,零外部请求)
branch-right.webp 花枝 840x640 透明底(细端在右上)
branch-left.webp 同上镜像
blossom.webp 单朵樱花 400x400 透明底
petal.webp 单瓣 240x336 透明底
pattern.webp 平铺底纹 600x600 透明底
位图合计 %5.1f KB
怎么用
------
线上只需要 new-sakura.css:放进仓库 static/css/themes/,在 conf/app.ini 的
[theme] available 里加上 new-sakura,再重新 go build(主题 CSS 是编译期 embed,
只重启不重编不会生效)。
WebP 不是必需品 —— CSS 里的樱花是矢量,比位图清晰且没有额外请求。留着的用处:
1) 素材要单独复用:python dev/theme-tools/gen_new_sakura.py --asset-base %s
(CSS 从 %.1f KB 降到 10.6 KB,但多 5 个请求共 48.9 KB,总传输更大)
2) 非 CSS 场景(分享卡、海报)只能用位图
%s
注意
----
直连桶的 S3 endpoint(*.r2.cloudflarestorage.com)会 403,那里需要签名。
浏览器直读只能用自定义域 / r2.dev 或 CDN。
缓存:位图 1 天、CSS 5 分钟、本文件 60 秒 —— 对象键不带版本,所以**不用** immutable。
要一年长缓存,得把内容哈希写进文件名(届时 CSS 里的地址也要跟着换)。
""" % (prefix, css_kb, total_kb,
base or "https://<公开域名>/" + prefix.strip("/"), css_kb, urls)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--upload", action="store_true", help="导出后上传到 data/r2.txt 里的桶")
ap.add_argument("--prefix", default="themes/new-sakura", help="对象键前缀,默认 themes/new-sakura")
ap.add_argument("--public-base", default="",
help="公开访问的主机(如 https://r2.example.com,不带路径);"
"默认读 data/r2.txt 的 image_public_base 或 image_bucket_cdn_host,"
"都没有则在 INSTALL.txt 里写占位")
ap.add_argument("--r2", default=os.path.join(ROOT, "data", "r2.txt"))
args = ap.parse_args()
os.makedirs(OUT_DIR, exist_ok=True)
files, sizes = [], {}
for name, svg, w, h in assets():
png = os.path.join(OUT_DIR, name + ".png")
webp = os.path.join(OUT_DIR, name + ".webp")
render(svg, w, h, png)
to_webp(png, webp)
os.remove(png)
size = os.path.getsize(webp)
sizes[name + ".webp"] = size
print("导出 %-13s %dx%d %.1f KB" % (name, w, h, size / 1024))
files.append((name + ".webp", webp))
if not args.upload:
print("\n只导出,未上传(加 --upload 才传)。目录:%s" % OUT_DIR)
return
import base64
import serve
raw = read_r2(args.r2)
# 与 conf/app.ini 同名:image_bucket_cdn_host 只是主机名(不带 scheme)
cdn_host = raw.get("image_bucket_cdn_host", "").strip().strip("/")
public_host = (args.public_base or raw.get("image_public_base")
or ("https://" + cdn_host if cdn_host else "")).rstrip("/")
object_base = ("%s/%s" % (public_host, args.prefix.strip("/"))) if public_host else ""
cfg = serve.S3Config(dict(
endpoint=raw["image_endpoint"], region="auto", bucket=raw["image_bucket"],
prefix=args.prefix, pathStyle=True,
accessKey=raw["image_access_id"], secretKey=raw["image_access_secret"],
))
# 键名不带版本,就不配 immutable —— 否则重新导出后一年内谁看到的都还是旧图。
payload = [{"key": name, "name": name, "contentType": "image/webp",
"cacheControl": "public, max-age=86400",
"data": base64.b64encode(open(path, "rb").read()).decode()}
for name, path in files]
css_path = os.path.join(ROOT, "static", "css", "themes", "new-sakura.css")
css = open(css_path, "rb").read()
sizes["new-sakura.css"] = len(css)
payload.append({"key": "new-sakura.css", "contentType": "text/css; charset=utf-8",
"cacheControl": "public, max-age=300",
"data": base64.b64encode(css).decode()})
readme = pack_readme(args.prefix, object_base, sizes).encode("utf-8")
payload.append({"key": "INSTALL.txt", "contentType": "text/plain; charset=utf-8",
"cacheControl": "public, max-age=60",
"data": base64.b64encode(readme).decode()})
res = serve.upload_files(cfg, payload)
for r in res["files"]:
print(("OK " if r["ok"] else "FAIL ") + r["key"] + ("" if r["ok"] else " " + str(r.get("error"))))
print("\n上传 %d/%d" % (res["total"] - res["failed"], res["total"]))
if object_base:
print("公开地址:%s/" % object_base)
if __name__ == "__main__":
main()
+445
View File
@@ -0,0 +1,445 @@
"""生成 new-sakura 主题的 CSS。
为什么用脚本生成而不用生成器的图层功能:生成器的装饰层只认「图片素材」(导出时
打进 zip 或传对象存储),而这里的花瓣是**内联 SVG 的 data URI** —— 零外部请求、
任意缩放不糊、改配色只改一个色值。代价是 CSS 里会出现一长串百分号编码的 URI,
所以源 SVG 保留在本文件里,改完重跑:
python dev/theme-tools/gen_new_sakura.py
输出:static/css/themes/new-sakura.css
"""
import os
import urllib.parse
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
OUT = os.path.join(ROOT, "static", "css", "themes", "new-sakura.css")
def uri(svg):
"""转成 CSS url()。safe='' 是刻意的:连逗号、分号、括号都编码掉 ——
`--tb-deco` 是按顶层逗号切分的(internal/theme 的 TestDecoSlotCount),
URI 里留一个裸逗号就会被当成多一层的装饰。"""
return 'url("data:image/svg+xml,' + urllib.parse.quote(svg, safe="") + '")'
# ---- 花与瓣 ----------------------------------------------------------------
# 五瓣樱花:五个椭圆绕中心旋 72°,中心一团深一点的蕊。不做花瓣缺口——
# 在 30~90px 的显示尺寸上缺口只剩 2px,反而像毛边;靠五瓣轮廓就够识别。
def blossom(light, deep, heart):
petals = "".join(
f'<ellipse cx="50" cy="27" rx="14" ry="21" transform="rotate({a} 50 50)"/>'
for a in (0, 72, 144, 216, 288)
)
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">'
f'<g fill="{light}">{petals}</g>'
f'<circle cx="50" cy="50" r="7.5" fill="{deep}"/>'
f'<circle cx="50" cy="50" r="3" fill="{heart}"/>'
"</svg>"
)
# 单瓣:顶端留一个 V 形缺口(樱花花瓣的标志),比整朵花更适合当"飘落"的单位。
def petal(fill):
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 56">'
f'<path d="M20 1c9 0 15 8 15 18 0 5-2 9-4 12l-4-5-7 9-7-9-4 5c-2-3-4-7-4-12C5 9 11 1 20 1z" fill="{fill}"/>'
"</svg>"
)
# 枝条:一根主枝 + 两根小杈 + 五朵花 + 三片落瓣。
# 花是重复内联而不是 <use href="#f">:data URI 里的片段引用在各浏览器上
# 行为不一致(Safari 曾整朵不渲染),多几百字节换确定性。
def branch(wood, light, deep, heart, flip=False):
fl = ' transform="translate(420,0) scale(-1,1)"' if flip else ""
# 花要比枝小得多、数量多几朵。早先 5 朵 scale≈1 的花配一根细枝,缩到 300px
# 显示时是一串糖葫芦;真实樱花枝是"小碎花沿枝密布",所以花收到 0.42~0.62。
# 第四项是旋转角:一朵朵都朝正上方会像贴纸糊上去的,真实花枝上朝向各异
blossoms = [
(302, 74, 0.62, 14), (256, 110, 0.55, -22), (214, 138, 0.60, 34), (172, 176, 0.58, -6),
(352, 42, 0.50, 48), (140, 214, 0.46, 20), (116, 246, 0.40, -32), (326, 128, 0.42, 28),
]
petals = [(272, 158, 22), (318, 150, -18), (196, 92, 40), (150, 196, -26)]
body = []
for x, y, s, rot in blossoms:
body.append(
f'<g transform="translate({x} {y}) rotate({rot}) scale({s}) translate(-50 -50)">'
'<g fill="%s">' % light
+ "".join(f'<ellipse cx="50" cy="27" rx="14" ry="21" transform="rotate({a} 50 50)"/>'
for a in (0, 72, 144, 216, 288))
+ f'</g><circle cx="50" cy="50" r="7" fill="{deep}"/>'
f'<circle cx="50" cy="50" r="2.8" fill="{heart}"/></g>'
)
for x, y, rot in petals:
body.append(
f'<g transform="translate({x} {y}) rotate({rot}) scale(0.42) translate(-20 -28)">'
f'<path d="M20 1c9 0 15 8 15 18 0 5-2 9-4 12l-4-5-7 9-7-9-4 5c-2-3-4-7-4-12C5 9 11 1 20 1z" fill="{light}"/></g>'
)
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 320">'
f'<g{fl}>'
f'<path d="M2 318C74 276 112 212 176 184c62-28 102-72 146-118 26-28 52-46 82-58" '
f'fill="none" stroke="{wood}" stroke-width="4.2" stroke-linecap="round" opacity=".62"/>'
f'<path d="M176 184c-4-30 4-56 16-78M262 126c16-28 32-48 46-58" '
f'fill="none" stroke="{wood}" stroke-width="2.8" stroke-linecap="round" opacity=".5"/>'
+ "".join(body) + "</g></svg>"
)
# 平铺底纹:极淡的小花 + 落瓣。
# 密度是刻意的:最早一版放了 2 朵花 + 2 片瓣并且 260px 平铺,页面上读出来是
# 「整齐的包装纸」——因为每个元素都在固定格点上重复。现在只留 1 朵 + 2 片,
# 尺寸放大到 300px 且各自错位,平铺时才看不出周期。
def pattern(light, strong=False):
"""平铺底纹。strong 给窄屏用:那里装饰层全关,底纹是屏幕上唯一的樱花,
照桌面那套 0.18~0.34 的透明度在 390px 上基本看不出来(实测截图接近纯粉底)。"""
k = 1.9 if strong else 1.0
one = (
'<g fill="%s">' % light
+ "".join(f'<ellipse cx="50" cy="27" rx="14" ry="21" transform="rotate({a} 50 50)"/>'
for a in (0, 72, 144, 216, 288))
+ "</g>"
)
petal_path = ('<path d="M20 1c9 0 15 8 15 18 0 5-2 9-4 12l-4-5-7 9-7-9-4 5'
'c-2-3-4-7-4-12C5 9 11 1 20 1z" fill="%s"/>')
o = lambda v: min(0.92, round(v * k, 2))
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300">'
f'<g transform="translate(96 84) scale(0.34) translate(-50 -50)" opacity="{o(0.34)}">{one}</g>'
f'<g transform="translate(236 208) rotate(28) scale(0.26)" opacity="{o(0.24)}">'
+ (petal_path % light) +
"</g>"
f'<g transform="translate(188 52) rotate(-34) scale(0.2)" opacity="{o(0.18)}">'
+ (petal_path % light) +
"</g>"
"</svg>"
)
# ---- 一套素材(浅色 / 深色各一组色) ---------------------------------------
LIGHT = dict(
light="#f7bcd0", deep="#e989aa", heart="#fdf6f8",
wood="#9a7a70", pattern="#f2bfd0",
)
DARK = dict(
light="#f0a0b8", deep="#c96a8b", heart="#2c2227",
wood="#a8837a", pattern="#7a4e5f",
)
def uris(p, asset_base=""):
"""asset_base 非空 -> 引用对象存储上的位图(键名与 export_assets.py 的产物一致)。
内联 SVG 是默认:gzip 后约 3.9KB、零额外请求。位图版 CSS 只有 10.6KB,但要多个
5 个请求共 ~49KB,总传输反而更大 —— 它存在的理由是素材能被非 CSS 场景复用。
换之前先确认桶已对公网可读(自定义域或 r2.dev),否则主题会整片 403。
"""
if asset_base:
b = asset_base.rstrip("/")
return {
"blossom_l": 'url("%s/blossom.webp")' % b,
"petal_l": 'url("%s/petal.webp")' % b,
"branch_l": 'url("%s/branch-left.webp")' % b,
"branch_r": 'url("%s/branch-right.webp")' % b,
"pattern": 'url("%s/pattern.webp")' % b,
"pattern_narrow": 'url("%s/pattern.webp")' % b,
}
return {
"blossom_l": uri(blossom(p["light"], p["deep"], p["heart"])),
"petal_l": uri(petal(p["light"])),
"branch_l": uri(branch(p["wood"], p["light"], p["deep"], p["heart"], flip=True)),
"branch_r": uri(branch(p["wood"], p["light"], p["deep"], p["heart"], flip=False)),
"pattern": uri(pattern(p["pattern"])),
"pattern_narrow": uri(pattern(p["pattern"], strong=True)),
}
NONE10 = "none, none, none, none, none, none, none, none, none, none"
AUTO10 = "auto, auto, auto, auto, auto, auto, auto, auto, auto, auto"
TOPLEFT10 = ("left top, left top, left top, left top, left top, left top, left top, "
"left top, left top, left top")
def deco(layers):
"""layers: [(var, size, position)],不足 10 槽补 none。"""
items, sizes, poss = [], [], []
for var, size, pos in layers:
items.append(var)
sizes.append(size)
poss.append(pos)
pad = 10 - len(layers)
items += ["none"] * pad
sizes += ["auto"] * pad
poss += ["left top"] * pad
return (", ".join(items), ", ".join(sizes), ", ".join(poss))
# ---- 每个页面一套位置 ------------------------------------------------------
# 位置一律两值(`关键字 ± 偏移`)。百分比相对「容器 − 图片」,写 calc 更可控;
# 这里用 left/right + % 与 px 偏移,保证换视口时花还贴在角落而不是飘到内容上。
SCOPES = {
"global": [
("var(--sk-branch-r)", "auto 280px", "right -16px bottom -22px"),
("var(--sk-blossom)", "auto 88px", "left 6% top 12%"),
("var(--sk-petal)", "32px", "left 14% top 24%"),
("var(--sk-petal)", "26px", "right 16% top 8%"),
],
"box": [
# 枝的细端在右上、粗端朝左下:放在右上角时"从角上垂下来",
# 尺寸收到 300px 才会有枝形 —— 390px 时可见部分只剩一团花(第一次就是这个问题)
# top 从 -16px 收到 26px:顶栏改透明后,700~760px 宽度下枝头会正好压在"登录"上。
# 顶栏高 60px、外边距 20px,26px 起笔即落在顶栏下沿之上,宽屏仍保留"从右上角长出"。
("var(--sk-branch-r)", "auto 300px", "right -24px top 26px"),
("var(--sk-blossom)", "auto 80px", "left 5% bottom 16%"),
("var(--sk-petal)", "34px", "left 14% bottom 32%"),
("var(--sk-petal)", "24px", "right 22% bottom 8%"),
("var(--sk-blossom)", "auto 52px", "right 6% bottom 6%"),
],
"question": [
("var(--sk-branch-l)", "auto 300px", "left -34px bottom -24px"),
("var(--sk-blossom)", "auto 86px", "right 7% top 11%"),
("var(--sk-petal)", "30px", "right 17% top 24%"),
("var(--sk-petal)", "24px", "left 11% top 7%"),
],
"admin": [
# 列表页卡片几乎占满宽度,右侧的花会整个被卡片盖住 —— 只往左侧留白放
("var(--sk-branch-l)", "auto 260px", "left -28px top -24px"),
("var(--sk-blossom)", "auto 70px", "left 3% bottom 7%"),
("var(--sk-petal)", "26px", "left 11% bottom 15%"),
],
"profile": [
("var(--sk-branch-r)", "auto 280px", "right -28px bottom -24px"),
("var(--sk-blossom)", "auto 66px", "left 6% top 9%"),
("var(--sk-petal)", "28px", "left 15% top 19%"),
("var(--sk-petal)", "22px", "left 5% top 28%"),
],
"vote": [
("var(--sk-branch-l)", "auto 280px", "left -30px top -20px"),
("var(--sk-blossom)", "auto 78px", "right 8% top 16%"),
("var(--sk-petal)", "28px", "right 18% top 27%"),
("var(--sk-blossom)", "auto 52px", "right 6% bottom 8%"),
],
# 窄屏:卡片占满宽度,左右只剩 ~16px 边距,花枝放哪儿都糊在字上。
# 实测底纹铺在 16px 边距里根本落不到花 —— 花朵在格子里的位置固定在 x≈58,
# 所以只在「顶栏与卡片之间」那条 30px 的横带里放几朵小的,其余交给加强底纹。
# 试过在「顶栏与卡片之间」那条 ~35px 的带子里放小樱花:box 页卡片从 y=122 起还行,
# question/admin 页卡片从 y=110 起就把它切掉半个,结果是贴在卡片边上的粉色碎块。
# 手机上没有可靠的留白,干脆不放装饰层 —— 观感交给配色与底纹(加强版 pattern)。
"narrow": [],
}
def scope_block(prefix, layers, indent=" "):
img, size, pos = deco(layers)
return (
f"{prefix} {{\n"
f"{indent}--tb-deco: {img};\n"
f"{indent}--tb-deco-size: {size};\n"
f"{indent}--tb-deco-position: {pos};\n"
"}"
)
def build(asset_base=""):
u = uris(LIGHT, asset_base)
d = uris(DARK, asset_base)
vars_light = "\n".join([
f" --sk-blossom: {u['blossom_l']};",
f" --sk-petal: {u['petal_l']};",
f" --sk-branch-l: {u['branch_l']};",
f" --sk-branch-r: {u['branch_r']};",
])
vars_dark = "\n".join([
f" --sk-blossom: {d['blossom_l']};",
f" --sk-petal: {d['petal_l']};",
f" --sk-branch-l: {d['branch_l']};",
f" --sk-branch-r: {d['branch_r']};",
])
light_block = f"""html[data-theme-style="new-sakura"] {{
--tb-bg: #fdf6f8;
--tb-surface: #ffffff;
--tb-surface-2: #fdf0f4;
--tb-surface-hover: #fff7fa;
/* 顶栏做磨砂玻璃:樱花从角落长出来,实色顶栏会把它齐刷刷切一段;但也不能给
transparent —— 顶栏会"消失",导航失去依托,文字直接压在花纹上。留 28% 的透光。
模糊由下面「组件微调」里的 backdrop-filter 加(令牌只能给颜色)。 */
--tb-navbar: rgba(253, 246, 248, 0.72);
--tb-border: #f3dde5;
--tb-text: #3a2c31;
--tb-text-secondary: #6d555d;
/* 5.27:1(底色)/ 5.62:1(卡片),踩住 AA 下限 */
--tb-text-muted: #77626a;
/* #c14a72 在粉底上只有 4.39:1,同色相压深到 #b8436a = 4.87:1 */
--tb-primary: #b8436a;
--tb-primary-weak: rgba(184, 67, 106, 0.12);
--tb-on-primary: #ffffff;
--tb-shadow: 0 1px 2px rgba(150, 80, 105, 0.05), 0 12px 28px -18px rgba(150, 80, 105, 0.22);
--tb-shadow-hover: 0 1px 2px rgba(150, 80, 105, 0.07), 0 18px 40px -20px rgba(150, 80, 105, 0.30);
/* 花与瓣都是内联 SVG(data URI),没有外部素材 —— 所以这个主题不需要对象存储。
--sk-* 是花材的别名:同一个 URI 只在这里出现一次,下面各页面作用域引用变量,
否则 6 套位置会把 4 个 data URI 重复 20 多次,文件直接翻倍。 */
{vars_light}
/* 整页底纹:极淡的花与落瓣,260px 平铺。压在白底上只是一层纸纹,
不参与构图(真正的樱花由下面的装饰层负责)。 */
--tb-page-image: none;
--tb-page-pattern: {u['pattern']};
--tb-page-pattern-size: 300px 300px;
"""
# 全局默认位置:写在同一个块里(未单独配置的页面沿用这套)
body = light_block + deco_block_inline(SCOPES["global"]) + "}\n"
for key in ("box", "question", "admin", "profile", "vote"):
body += "\n" + scope_block(
f'html[data-theme-style="new-sakura"][data-page-scope="{key}"]', SCOPES[key]) + "\n"
dark_block = f"""
html[data-theme="dark"][data-theme-style="new-sakura"] {{
--tb-bg: #221a1e;
--tb-surface: #2c2227;
--tb-surface-2: #372a30;
--tb-surface-hover: rgba(255, 147, 180, 0.10);
--tb-navbar: rgba(34, 26, 30, 0.72);
--tb-border: #47333b;
--tb-text: #f3e6ea;
--tb-text-secondary: #c9aeb8;
/* 5.82:1(底色)/ 5.26:1(卡片) */
--tb-text-muted: #ab9099;
--tb-primary: #ff93b4;
--tb-primary-weak: rgba(255, 147, 180, 0.16);
--tb-on-primary: #1b1014;
--tb-shadow: 0 1px 2px rgba(0, 0, 0, 0.34), 0 12px 28px -18px rgba(0, 0, 0, 0.58);
--tb-shadow-hover: 0 1px 2px rgba(0, 0, 0, 0.40), 0 18px 40px -20px rgba(0, 0, 0, 0.68);
{vars_dark}
/* 深色不铺底纹:浅粉花瓣压在深底上会变成一层噪点 */
--tb-page-image: linear-gradient(180deg, #251c21 0%, #221a1e 62%, #1e171b 100%);
--tb-page-pattern: none;
""" + deco_block_inline(SCOPES["global"], indent=" ") + "}\n"
for key in ("box", "question", "admin", "profile", "vote"):
dark_block += "\n" + scope_block(
f'html[data-theme="dark"][data-theme-style="new-sakura"][data-page-scope="{key}"]',
SCOPES[key]) + "\n"
# 作用域块的选择器是 html[data-theme-style][data-page-scope="box"](特异性 0,2,1),
# 比 html[data-theme-style] (0,1,1)高 —— 光写一条压不住,窄屏照样是桌面版装饰。
# 所以用同等特异性的通配属性选择器再写一遍,靠「在文件里更晚」取胜;
# 深色作用域块是 0,3,1,还得再补一条。
narrow_rules = ""
for sel in (
'html[data-theme-style="new-sakura"][data-page-scope]',
'html[data-theme="dark"][data-theme-style="new-sakura"]',
'html[data-theme="dark"][data-theme-style="new-sakura"][data-page-scope]',
):
narrow_rules += " %s {\n%s }\n" % (
sel, deco_block_inline(SCOPES["narrow"], indent=" "))
narrow = (
"\n@media (max-width: 640px) {\n"
' html[data-theme-style="new-sakura"] {\n'
+ deco_block_inline(SCOPES["narrow"], indent=" ")
+ " /* 手机上装饰层整个收掉(卡片占满宽度),樱花只能由底纹承担:\n"
" 换成加强版 + 缩小格子,否则 390px 上看就是一片纯粉 */\n"
" --tb-page-pattern: %s;\n" % u["pattern_narrow"]
+ " --tb-page-pattern-size: 180px 180px;\n"
" }\n"
+ narrow_rules
+ "}\n"
)
return HEADER + body + dark_block + narrow + FOOTER
def deco_block_inline(layers, indent=" "):
img, size, pos = deco(layers)
return (
f"{indent}--tb-deco: {img};\n"
f"{indent}--tb-deco-size: {size};\n"
f"{indent}--tb-deco-position: {pos};\n"
)
HEADER = """/* 新·樱花(new-sakura):纯 CSS 樱花,不依赖任何图片素材。
和已有的 sakura 主题的区别:sakura 是"粉底 + 网点"的静态配色;这里把樱花本身
做成了可摆放的装饰层 —— 整朵花、带缺口的花瓣、带枝的花朵树,全部是内联 SVG
的 data URI,所以:换颜色只改一个色值、任意视口不糊、没有额外请求、不需要对象存储。
每个页面一套位置(--tb-deco / -size / -position 三组各 10 槽,必须等长):
global 未单独配置的页面 右下花枝 + 左上两瓣
box 箱主页 右上花枝垂下 + 左下花与落瓣
question 问题页 左下花枝 + 右上花与落瓣
admin 题目列表 左上花枝(只压顶部边距,不抢列表)
profile 设置页 右下花枝 + 左侧竖排小瓣
vote 投票页 左上花枝 + 右侧花与落瓣
≤640px 一律只留角落两朵小的 —— 卡片占满宽度后枝条会糊在字上
位置一律「两值 + 关键字」,不用三值语法(三值里混用 left/bottom 关键字是非法组合,
整条声明会作废,花瓣会静默回到左上角)。
生成:python dev/theme-tools/gen_new_sakura.py(改花形/位置请改那个脚本,别手改这里)
*/
"""
FOOTER = """
/* 组件微调(仅外观):顶栏与粉底融在一起后,UIkit 默认的 #999 链接色偏淡 */
html[data-theme-style="new-sakura"] .uk-navbar-nav > li > a {
color: var(--tb-text-secondary);
}
html[data-theme-style="new-sakura"] .uk-navbar-nav > li > a:hover {
color: var(--tb-primary);
}
/* 信息提示条:UIkit 的浅蓝在樱粉底上是唯一的冷色块,换成主色的弱化底 */
html[data-theme-style="new-sakura"] .uk-alert-primary {
background-color: var(--tb-primary-weak);
color: #9c3356;
}
html[data-theme="dark"][data-theme-style="new-sakura"] .uk-alert-primary {
color: #ffb3c8;
}
/* 顶栏磨砂:半透明底由 --tb-navbar 给,模糊必须在这里加(令牌只能是颜色值)。
和手机底栏一样是「半透明 + 模糊」,不是全透明 —— 顶栏得看得见,只是后面透得出花。 */
html[data-theme-style="new-sakura"] .uk-navbar-container:not(.uk-navbar-transparent) {
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
}
/* 手机底栏:base.css 写死 rgba(255,255,255,.92),压在粉底上是一条白杠,跟背景脱开 */
html[data-theme-style="new-sakura"] #tb-mobile-utils {
background: rgba(253, 246, 248, 0.92);
}
html[data-theme="dark"][data-theme-style="new-sakura"] #tb-mobile-utils {
background: rgba(34, 26, 30, 0.92);
}
"""
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser(description="生成 new-sakura 主题 CSS")
ap.add_argument("--asset-base", default="",
help="引用对象存储位图的前缀,如 https://r2.example.com/themes/new-sakura;"
"留空 = 内联 SVG(默认)")
ap.add_argument("--out", default=OUT, help="输出路径")
args = ap.parse_args()
css = build(args.asset_base)
with open(args.out, "w", encoding="utf-8", newline="\n") as f:
f.write(css)
print("已生成 %s(%.1f KB)%s" % (args.out, len(css.encode()) / 1024,
" [位图版]" if args.asset_base else " [内联 SVG]"))
+113 -5
View File
@@ -20,13 +20,25 @@ go build -o NekoBox ./cmd/
两处都注册在同一份 `app.Commands` 列表中。**新增命令时必须同时改 `tamabox.go` 和 `cmd/tamabox.go`**,
否则会出现「本机二进制有、线上没有」(或反之),典型报错是 `No help topic for '<cmd>'`。
⚠️ **替换二进制不等于生效**:`go build -o tamabox .` 只改磁盘文件,正在运行的进程仍是旧映像。
换完之后必须重启(systemd / supervisor / 手动拉起),否则改了代码也看不到变化。
另外 `./tamabox web` 在前台立即返回,通常是端口被旧进程占着:
```bash
ps -eo pid,lstart,cmd | grep '[t]amabox' # 看进程启动时间,与二进制 mtime 比对
```
确认手头二进制包含全部命令:
```bash
./tamabox --help | sed -n '/COMMANDS/,/GLOBAL/p'
# web / censor / uid / ban / restore / reports / restrict / unrestrict / hide-public
# web / censor / uid / ban / restore / reports / restrict / unrestrict / hide-public / honeypot / backup / restore-images
```
> 想确认某个字符串有没有编进二进制,**不要用 `strings`** —— 它默认只输出可打印 ASCII,
> UTF-8 中文整段被丢弃,`strings x | grep 中文` 恒为 0。
> 用 `grep -ac "中文字符串" x`。
### 配置文件
CLI 与 `web` 读同一份配置,路径由环境变量指定:
@@ -64,9 +76,14 @@ pg_dump -Fc <dbname> > before-$(date +%F-%H%M).dump
| `restore` | 恢复已停用账号 | 是 | 用户申诉后恢复 |
| `censor` | 重新审核历史内容 | 是 | 审核规则变更后复检 |
| `uid` | 为用户生成/补齐 uid | 是 | 数据修复 |
| `honeypot` | 扫描器诱捕取证(假登录页) | 部分是(`prune` / `purge-creds`) | 看谁在扫、拿到了什么字典 |
| `backup` | 备份 PostgreSQL 与配置 bucket 的全部 R2 对象 | 写本地备份目录 | 灾难恢复前留档 |
| `restore-images` | 校验并恢复 R2 图片及元数据 | 是(除 `--dry-run`) | 图片灾难恢复、隔离恢复演练 |
| `web` | 启动 Web 服务 | — | 生产入口 |
除 `reports` 外,所有写操作都要求交互式输入大写 `YES` 确认;脚本化时加 `--yes`。
除 `reports`、`honeypot hits/stats/paths/size` 外,既有运营写操作要求交互式输入大写 `YES` 确认;
脚本化时加 `--yes`。注意 `honeypot prune` / `purge-creds` 的确认词是 **`yes`**(小写)。
`restore-images` 不做交互确认:实际写入必须显式提供 `--yes`,`--dry-run` 除外。
---
@@ -277,7 +294,93 @@ pg_dump -Fc <dbname> > before-$(date +%F-%H%M).dump
---
## 7. 典型处置流程
## 7. `honeypot` —— 扫描器取证
> 设计取舍、凭据存储格式、部署要点见 [honeypot.md](honeypot.md)。这里只列命令。
| 子命令 | 作用 | 改数据 |
|---|---|---|
| `hits` | 列出捕获到的凭据(时间倒序,密码默认脱敏) | 否 |
| `stats` | 来源排行(IPv6 按 /64 聚合)+ 路径排行(库)+ 凭据排行(文件) | 否 |
| `paths` | 列出当前生效的诱饵路径(按伪装家族汇总) | 否 |
| `size` | 表行数、磁盘占用、凭据文件体积 | 否 |
| `prune` | 按保留期清元数据 + 凭据文件 | 是 |
| `purge-creds` | 删除旧版遗留的明文凭据列(**不可逆**) | 是 |
```bash
./tamabox honeypot hits --since 7d --limit 50
./tamabox honeypot hits --ip 203.0.113.9 --show-password
./tamabox honeypot stats --days 7
./tamabox honeypot paths
./tamabox honeypot size
./tamabox honeypot prune --days 30
./tamabox honeypot purge-creds
```
要点:
- **凭据不入库**。用户名/密码只写在 `data/honeypot-creds/creds-YYYY-MM-DD.jsonl`
(目录 0700、文件 0600),数据库里只有 IP / 路径 / UA / 是否封禁这些元数据。
所以 `hits` / `paths` **不连数据库**,DB 挂了也能查。
- 密码默认脱敏(保留首尾各 1 位)。终端输出常被贴进工单或截图,`--show-password` 只在确有取证需要时开。
- `hits` / `stats` 里的 `ip` 是**精确**客户端地址;封禁用的是 `shared.LimitKey` 折叠后的键
(IPv6 为 `/64` 前缀)。两者在 IPv6 下不一致是正常的,别拿邮件里的精确地址去对封禁键。
- `purge-creds` 用于清掉早期版本存在 `honeypot_hits` 上的明文列(AutoMigrate 不删既有列)。
执行前先 `pg_dump`;它只处理当前库,**历史备份与从库里的明文需要自行清理**。
- 封禁语义:GET 探测走常规阈值(默认 10 次 / 60 秒),**提交凭据才一次即封**。
所以「扫了几十次怎么没封」不是 bug —— 见 [honeypot.md](honeypot.md) 的「两条封禁路径」。
---
## 8. `backup` / `restore-images` —— 数据库与 R2 图片
### 8.1 备份
```bash
# 数据库 + 配置 bucket 的全部对象
./tamabox backup --output /var/backups/tamabox
# 只备份对象,或只备份数据库
./tamabox backup --images-only --output /var/backups/tamabox
./tamabox backup --db-only --output /var/backups/tamabox
```
图片备份使用**空前缀**分页列举配置 bucket 的全部对象,不再只查 `tamabox/`。根日期 key、
`picture/`、`tamabox/` 和静态资源都会按原 key 保存到 `<output>/images/`。
备份会先完成全部分页 List、key 与本地路径预检,再开始下载;非法路径、目录 key、文件/目录碰撞会中止备份。
`images-manifest.json` 记录实际下载字节的 `size`、`sha256`,以及 GET 返回的 `etag`、
`content_type`、`cache_control`。对象与 manifest 都采用临时文件原子落盘;下载流中断会整对象重试,
最终失败不会留下 `.part`。空 bucket 的 manifest 是 `[]`。
### 8.2 恢复与隔离演练
```bash
# 校验全部本地文件并检查默认防覆盖,不写远端
./tamabox restore-images --input /var/backups/tamabox --dry-run
# 实际恢复到原 key;必须显式 --yes
./tamabox restore-images --input /var/backups/tamabox --yes
# 明确允许替换已存在对象
./tamabox restore-images --input /var/backups/tamabox --yes --overwrite
# 在原完整 key 前加隔离前缀,不覆盖生产 key
./tamabox restore-images --input /var/backups/tamabox --yes \
--target-prefix tamabox-backup-test/manual-restore/
```
`--input` 可指向备份目录,也可直接指向 `images-manifest.json`。恢复会在任何远端操作前校验全部
manifest 条目和本地文件(普通文件、非 symlink、大小、实际 SHA-256)。默认先 Head 目标;任一目标已存在
即失败且零 Put,只有 `--overwrite` 才允许替换。`--dry-run` 不写对象,实际写入必须 `--yes`。
`--target-prefix` 会加在 manifest 的完整 key 前;非空前缀末尾没有 `/` 时会自动补一个。
Put 保留内容、原 key 路径(额外添加指定前缀)、`ContentType` 与 `CacheControl`,并使用单对象超时和有限重试。
**恢复不是事务**:中途失败会明确报告已成功数量,已上传对象不会自动回滚。
---
## 9. 典型处置流程
1. **发现**:收到告警邮件(同一箱主 24h ≥ 8 条 或 7d ≥ 20 条)→ 或箱主后台出现「被举报 N 次」。
2. **取证**:`reports --domain <domain>` 看条目、占比、历史处置。
@@ -299,7 +402,7 @@ pg_dump -Fc <dbname> > before-$(date +%F-%H%M).dump
---
## 8. 排错
## 10. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
@@ -312,12 +415,17 @@ pg_dump -Fc <dbname> > before-$(date +%F-%H%M).dump
| 箱主说「我改了但没生效」 | 处置期内严格度被锁定 / L2 禁止设为公开 | 看后台条幅;`reports` 查当前处置 |
| `--days 超过 90 天` 被拒绝 | 防手误设计,需要 `--force` | 确认时长后重试,或改用常规天数 |
| 处置成功但箱主说没收到邮件 | SMTP 未配 / 发送失败 / 用了 `--no-notify` | 看终端 warn;`[mail]` 配好后可 `restrict` 重发(同级别重设会替换记录) |
| 蜜罐记录的 `ip` 是 `127.0.0.1` | `trust_proxy` 未配,源站把所有人看成同一个代理 IP | 配 `trust_proxy`;**此时一次封禁会封掉整站**(见 [honeypot.md](honeypot.md) 的「部署」一节) |
| 整站都返回 403 | 蜜罐把反代 IP 封了(上一条的后果) | 重启进程(封禁是内存态)或等 `honeypot_ban_minutes` 到期 |
| 换了二进制但行为没变 | 进程没重启,跑的还是旧映像 | `ps -eo lstart,cmd \| grep tamabox` 比对启动时间与文件 mtime |
| `strings 二进制 \| grep 中文` 恒为 0 | `strings` 只输出可打印 ASCII,中文 UTF-8 被丢弃 | 用 `grep -ac "中文字符串" 二进制` |
| `honeypot` 命令不存在 | 二进制旧 / 两个构建入口未同步 | 重新编译后**重启进程** |
改完配置**必须重启**才生效(配置只在启动时读取一次)。
---
## 9. 明确不做的事
## 11. 明确不做的事
- **不做自动处置**:举报数达标只发邮件,不自动封禁。这个场景里举报本身就是攻击武器。
- **不做申诉后台**:申诉走邮件,处置结果由 CLI 写入。
+301
View File
@@ -0,0 +1,301 @@
# 蜜罐(虚假后台登录页)
给扫描器一组假的后台登录页(WordPress / phpMyAdmin / Jenkins / Tomcat / Grafana …):它探后台路径时返回对应产品外观的假页面,它提交账号密码时把凭据记下来、并把它临时封掉。
一句话取舍:**这套功能的价值是「拿到对方正在用的字典」,不是「挡住扫描」。** 挡扫描靠 CDN 和 `autoban`;蜜罐只在「有人真的往假后台里填账号密码」时才有意义。
---
## 1. 生效范围
蜜罐挂在 `f.NotFound` 上 —— flamego 只有**路由表完全落空**时才会走到这里,所以:
- `/login`、`/register`、`/_/{domain}`、`/user/**`、`/api/v1/**` 这些真实路由永远不会被接管;
- 命中后短路返回,不查业务表、不渲染业务模板、不调用 `ctx.Next()`;
- 页面是纯静态 HTML,只含 canary 标记和假表单,不含任何真实信息。
匹配规则(`internal/security/honeypot/honeypot.go`):
| 类型 | 例子 |
|---|---|
| 段前缀 | `/admin`、`/wp-admin`、`/.ssh`、`/.env`、`/phpmyadmin`、`/actuator` |
| 任意位置片段 | `/wp-includes/`、`/xmlrpc.php`、`/cgi-bin/`、`/.git/` |
| 文件后缀 | `*.zip`、`*.sql`、`*.bak`、`*.pem`、`*.key`、`*.env` |
| 自定义 | `[security] honeypot_paths`(逗号分隔,与内置清单取并集) |
业务路由前缀(`/login`、`/api`、`/static` 等)即使出现在自定义清单里也会被强制剔除,构成第二道保险。
改完路径后用 `tamabox honeypot paths` 看最终生效的完整清单(会按家族汇总条数)。
### 会伪装成哪几类后台
| 家族 | 典型路径 | 表单字段 / 响应 |
|---|---|---|
| WordPress | `/wp-login.php`、`/wp-admin`、`/wp-json` | `log` / `pwd`,带 `wordpress_test_cookie` |
| phpMyAdmin | `/phpmyadmin`、`/pma`、`/adminer.php` | `pma_username` / `pma_password` |
| Tomcat | `/manager/html`、`/host-manager` | **401 + `WWW-Authenticate`**(Basic,凭据从 `Authorization` 头取) |
| Jenkins | `/jenkins`、`/jnlpJars` | `j_username` / `j_password`,带 `X-Jenkins` 头 |
| Grafana | `/grafana` | `user` / `password`(深色主题) |
| GitLab | `/users/sign_in` | `user[login]` / `user[password]` |
| LuCI(路由器) | `/cgi-bin/luci`、`/luci` | `luci_username` / `luci_password` |
| Joomla | `/administrator` | `username` / `passwd` |
| 通用后台 | `/admin`、`/console`、`/zabbix`、`/jira` … | `username` / `password` |
**为什么要分类**:扫描器和爆破脚本都是「先按路径判断这是什么后台,再用它认识的字段名填表」。所有路径都回 WordPress 页面,等于在 `/phpmyadmin/index.php` 上告诉对方「这里不是 PMA」,对方直接换目标 —— 我们既拿不到凭据,也留不下有效取证。
**静态资源也一起伪装**:`/wp-admin/css/login.min.css`、`/wp-includes/js/*.js`、`/wp-admin/images/*.png` 这类请求回占位 CSS/JS/真 1×1 PNG。这些请求**不落库、不计数** —— 一次页面加载会打 5–10 个资源,逐个 INSERT 等于帮攻击者把写入放大十倍。插件目录下的 `.php` 则回 404(那是 webshell 探测,回登录页反而不像)。
**`/xmlrpc.php` 单独按 XML-RPC 回应**,不是登录页:
| 请求 | 响应 |
|---|---|
| `GET` / 其它非 POST | `405` + XML fault「XML-RPC services are disabled on this site.」 |
| `POST` 普通 `methodCall` | `200` + XML fault「Incorrect username or password.」 |
| `POST` `system.multicall` | 同上,但**一包里的所有凭据对都会被记下来** |
| `POST` `system.listMethods` | `200` + 方法列表(枚举类工具靠它确认对面是真 WP) |
为什么值得单独做:`system.multicall` 一次请求能塞进上千组用户名/密码,且绕开登录页的限速插件,是 WordPress 爆破里产出最高的一条通道。我们自己的 `X-Pingback` 响应头又正指着这个地址 —— 回 HTML 等于自己指路给对方看破绽。
动态值(phpMyAdmin 的 `token`、通用后台的 `csrf_token`、Jenkins 的 `X-Jenkins-Session`、GitLab 的 `_gitlab_session`)**每个请求都不同**。恒定值是最容易被指纹识别的特征:连打两次拿到一模一样的 token,任何脚本都能判定对面是伪造的。
单页渲染约 3.7 µs / 2 次分配、页面 1.5–2.5 KB,资源请求 0 分配(`BenchmarkRenderFamily` / `BenchmarkAssetFor` 守着)。
---
## 2. 配置
```ini
[security]
enable_honeypot = true
honeypot_paths =
honeypot_threshold = 1
honeypot_ban_minutes = 1440
honeypot_retain_days = 30
honeypot_auto_clean = true
honeypot_max_rows = 200000
honeypot_cred_dir = data/honeypot-creds
```
| 键 | 缺省 | 说明 |
|---|---|---|
| `enable_honeypot` | `false` | 总开关。关闭后 `/wp-admin` 等路径恢复原先的 302 → `/` |
| `honeypot_paths` | 空 | 追加诱饵路径,与内置清单取并集 |
| `honeypot_threshold` | `1` | **提交凭据**的封禁阈值(见 §3) |
| `honeypot_ban_minutes` | `1440` | 蜜罐封禁时长;显式 `0` 回退 `ban_duration_minutes` |
| `honeypot_retain_days` | `30` | 元数据与凭据文件的保留天数;`<= 0` 也回退 30 |
| `honeypot_auto_clean` | `true` | 每小时一轮后台清理;关闭后只能手动 `honeypot prune` |
| `honeypot_max_rows` | `200000` | 元数据行数上限(第二道闸);显式 `0` = 不限 |
| `honeypot_cred_dir` | `data/honeypot-creds` | 凭据文件目录,进程工作目录为基准 |
> ⚠️ **配置项一律写成「注释独占一行 + 裸值」**。配置加载器开启了 `IgnoreInlineComment`,行尾 `; 注释` 会被并入值:`honeypot_max_rows = 200000 ; 说明` 解析出来是 `"200000 ; 说明"`,int 解析失败而 `MapTo` 不报错 —— 该项静默回退零值,日志里只会说「配置值不对」。
改完配置**必须重启**(配置只在启动时读一次)。
---
## 3. 两条封禁路径(最容易误解的地方)
| 行为 | 走哪条通道 | 阈值 | 是否立即封 |
|---|---|---|---|
| `GET /wp-login.php` 看一眼假页面 | 常规 `autoban.Record` | `ban_threshold_sensitive`(默认 10 次 / 60 秒) | 否 |
| `GET /wp-admin/css/login.min.css` 等资源 | **不计数、不落库** | — | 否 |
| `POST` 带该家族的凭据字段(`log`/`pwd`、`pma_*`、`j_*`、`user[login]` …) | 蜜罐 `autoban.RecordHoneypot` | `honeypot_threshold`(默认 1) | 是,立即封 |
| `POST /xmlrpc.php` 带 `methodCall`(含 `system.multicall`) | 蜜罐 `autoban.RecordHoneypot` | 同上 | 是,立即封 |
| `GET /xmlrpc.php` / `POST system.listMethods` | 常规通道 | 同上 | 否 |
| `POST` 不带任何凭据字段 | 常规通道 | 同上 | 否 |
**为什么 GET 不立即封**:爆破脚本的标准流程是「先 GET 探表单 → 再 POST 提交」。如果 GET 就吃 403,假登录页只被看了一次,对方根本没机会填表,凭据永远拿不到 —— 而这个功能的全部价值就在那份凭据上。
蜜罐通道**不受 `enable_auto_ban` 约束**:关掉常规自动封禁(它对共享出口误伤多)不会连带让蜜罐变成只记录不封禁的旁观者。
纯探测也不会被刷爆:元数据落库按「每 IP 每分钟一条」采样,凭据则是「有就存」——那才是要保的东西。
### 被封之后会看到什么
状态码永远 `403`,body 由 `[security] ban_image_path` 决定:
```ini
[security]
ban_image_path = data/403.webp
```
| 情况 | 响应体 |
|---|---|
| 配了合法图片 | 图片本身(`Content-Type` 按内容嗅探,png/jpeg/gif/webp 都行) |
| 留空 | 纯文字 `403 Forbidden`(改造前的老行为) |
| 文件读不到 / 是目录 / 不是图片 / 超过 2 MB | 纯文字 `403 Forbidden` + 启动日志 Warn |
```
$ curl -si https://你的站点/anything | head -5
HTTP/2 403
content-type: image/webp
content-length: 8608
cache-control: no-store, no-cache, must-revalidate
```
为什么要能换成图:爆破脚本常靠读响应体里的 `403 Forbidden` 确认自己已被拦,换掉 body 让这类判断失效;人肉访问(尤其是被共享出口连累的误封)时,看到一张明确的图比白屏好判断。但这是**可选**的——配错了不会影响封禁本身,只是回到纯文字。
几个约束,都是刻意的:
- **只认内容,不认扩展名**(`http.DetectContentType`)。把 `ban_image_path` 配成 `app.ini` / `.env` 会被拒绝并告警——否则等于把配置文件原样吐给每一个被封的攻击者。
- **上限 2 MB**。被封来源会反复重试,每张图都照常占用出网带宽;建议 256×256、20 KB 以内。
- **启动时读一次**,之后每个请求只读内存,不在热路径上做磁盘 IO。换图 = 换文件 + 重启进程。
- `no-store`:中间层缓存一张 403 没有意义,还会让解封后的响应继续命中缓存。
### 封的是一把「键」,不是一个人
`autoban` 的表就是内存里的 `map[键]到期时间`,键由 `shared.LimitKey` 生成:
- IPv4 → 原样(`203.0.113.7`)
- IPv6 → 折叠到 `/64`(`2408:8207:1234:5678::/64`),防止在单个 /64 内轮换地址绕过阈值
所以告警邮件里**可能有两个地址**:
| 字段 | 含义 |
|---|---|
| 来源 IP | 精确客户端地址,取证与对账用 |
| 封禁范围 | 实际写进封禁表的键;IPv6 下是 /64 前缀,整段一起封(IPv4 下与来源 IP 相同,不重复显示) |
两个直接后果,排障时先记住:
1. **封禁只在内存里** —— 重启进程即解除,邮件不会撤回;
2. **封的键与请求算出来的键必须一致** —— 换地址族(IPv6 访问 / IPv4 回源)或换 /64 前缀就不会被拦。
每次建封都会打一条 WARN,键、原因、时长、到期时间都在里面:
```bash
journalctl -u tamabox -n 50 | grep "已自动封禁来源"
# ban_key=2408:8207:1234:5678::/64 reason=蜜罐提交凭据 duration=24h0m0s expires_at=...
```
`ban_key` 就是被封的键,与邮件里的「封禁范围」一致。若这里没有记录,说明当时根本没建封(邮件可能是别的通道发的);若有记录但仍能访问,就去比对请求算出来的键。
---
## 4. 凭据存在哪里
**用户名/密码不进数据库**,只追加到按天分文件的 JSONL:
```
data/honeypot-creds/creds-2026-10-21.jsonl
```
```json
{"time":"2026-10-21T16:13:50+08:00","ip":"117.x.x.226","method":"POST",
"path":"/wp-login.php","username":"admin2","password":"test456",
"fields":{"log":"admin2","pwd":"test456"},"raw_body":"log=admin2&pwd=test456",
"user_agent":"curl/8.22.0","banned":true}
```
| 项 | 值 |
|---|---|
| 目录权限 | `0700`(启动时强制 `chmod`) |
| 文件权限 | `0600` |
| 切分 | 按记录时间,一天一个文件 |
| 坏行 | 跳过(进程被杀留下的半条 JSON 不会让整天数据报废) |
| 清理 | 与 `honeypot_retain_days` 同步,只删「整天都过期」的文件 |
**为什么不入库**:明文账号密码一旦入库,就会跟着进备份、主从复制、以及任何有库只读权限的报表/BI 工具,事后无法彻底清除。文件可以单独挂卷、单独设权限、到期直接删。
数据库里只保留「发生过一次带凭据的提交」这个事实(`honeypot_hits.had_fields`),以及 IP / 路径 / UA / 是否封禁这些元数据。
> `ip` 两处存的都是**精确客户端地址**(IPv6 保留完整地址),不是封禁用的 `/64` 聚合键 ——
> 取证要能定位到具体主机。想知道「封了多大范围」,看告警邮件的「封禁范围」或日志里的 `ban_key`。
> ⚠️ 这些是**外部攻击者提交的真实账号密码**,很可能是从别处泄露后复用的。请按敏感数据对待:不要提交进版本库(`/data/` 已在 `.gitignore`)、不要参与备份、不要贴到公开群组。
---
## 5. 部署:反向代理必须配 `trust_proxy`
这是**部署时最容易踩、后果最严重**的一条。
如果站点在 nginx / Cloudflare 之后而 `trust_proxy = false`:
- 源站看到的所有请求都来自代理地址(常见 `127.0.0.1`),**取证数据失去意义**;
- 更糟:一次蜜罐命中封的是 `127.0.0.1`,而所有访客在源站眼里都是这个 IP —— **等于封掉整站**。
正确配置:
```ini
[security]
trust_proxy = true
trusted_proxies = 127.0.0.1
```
> ⚠️ 前提:**源站不能直接被公网访问**(Cloudflare Tunnel / Authenticated Origin Pull / 防火墙只放行回源网段)。`trust_proxy = true` 时程序会信任 `CF-Connecting-IP` 这类权威头,源站若可直连,攻击者能伪造它来绕过封禁、或反手封掉别人。
程序在「命中回环地址且 `trust_proxy` 未开」时会打一条一次性 WARN(24 小时冷却)提醒检查这里。
验证:触发一次蜜罐后看凭据文件里的 `ip` 是不是真实客户端地址。
---
## 6. 升级:清掉历史明文列
早期版本把 `username` / `password` / `fields` / `raw_body` 直接存在 `honeypot_hits` 上。结构体已经删掉这些字段,但 **GORM 的 AutoMigrate 不会删既有列** —— 老库升级上来后,历史明文还在表里。
启动时会打一条 WARN:
```
honeypot_hits 上仍残留旧版明文凭据列,请执行 TamaBox honeypot purge-creds 清除
```
清理(**不可逆**,先备份):
```bash
pg_dump -Fc <dbname> > before-purge-$(date +%F-%H%M).dump
./tamabox honeypot purge-creds # 列出四列,输入 yes 才执行
```
只处理当前库;**历史备份与从库里可能仍有明文**,需要自行处理。
---
## 7. 日常运维
```bash
./tamabox honeypot hits # 最近 24h 的凭据(密码默认脱敏)
./tamabox honeypot hits --ip 1.2.3.4 --show-password
./tamabox honeypot stats --days 7 # 来源排行(IPv6 按 /64 聚合)+ 路径排行 + 凭据排行
./tamabox honeypot paths # 当前生效的诱饵路径
./tamabox honeypot size # 表行数 + 磁盘占用 + 凭据文件体积
./tamabox honeypot prune --days 30 # 手动按保留期清(元数据 + 凭据文件)
```
`hits` / `paths` 不连数据库(DB 挂了也能查)。
---
## 8. 排障
| 现象 | 原因 | 处理 |
|---|---|---|
| **邮件说封了,但还能正常访问** | ① 进程重启过(封禁是内存态)② 后续请求换了地址族 / 换了 /64 前缀 ③ `trust_proxy` 未配,封的是反代地址 | 看日志里 `已自动封禁来源` 的 `ban_key`,与 `honeypot hits` 里的 IP、以及本机 `curl -6 ifconfig.co` 比对;见 §3「封的是一把键」 |
| `/wp-admin` 还是 302 | `enable_honeypot` 未开 / 配置未重启生效 | 看启动日志;`honeypot paths` 确认开关状态 |
| 记录里 `ip` 是 `127.0.0.1` | `trust_proxy` 未配 | 见 §5;**注意此时封禁会封全站** |
| 整站都返回 403 | 蜜罐把反代 IP 封了(上一条的后果) | 重启进程(封禁是内存态)或等 `honeypot_ban_minutes` 到期 |
| GET 假页面不封禁 | 设计如此 | 提交凭据才立即封,见 §3 |
| 记录里 `path` 带前导空白/奇怪字符 | 上游(nginx/CF)改写了 URI | 代码已清洗并打 WARN;根因建议查 `proxy_pass` / `rewrite` |
| `honeypot` 命令不存在 | 二进制旧 / 两个构建入口未同步 | `go build -o tamabox .` 后**重启进程** |
| 改了代码但行为没变 | 只替换了二进制,进程还在跑旧映像 | `kill` 旧进程后按原方式重新拉起 |
| 凭据文件没有生成 | `honeypot_cred_dir` 不可写 | 看启动日志里的「凭据目录不可用」告警 |
| 启动报 `honeypot_hits 上仍残留…` | 老库有历史凭据列 | `honeypot purge-creds`(§6) |
排障时两个容易误判的点:
- **验证二进制里有没有某个中文字符串,不能用 `strings`** —— 它默认只输出可打印 ASCII,UTF-8 中文整段被丢弃,`strings x | grep 中文` 恒为 0。用 `grep -ac "中文字符串" x`。
- **替换二进制 ≠ 生效**,进程必须重启;前台跑 `./tamabox web` 若立刻返回,通常是端口被旧进程占着。
- **邮件里的「来源 IP」不是封禁键**:IPv6 下前者是精确地址、后者是 /64 前缀,拿前者去查后者永远对不上。判「封没封」只看 `ban_key`。
---
## 9. 设计取舍(已知限制)
- **GET 探测不封**:见 §3。代价是扫描器可以反复拿假页面,收益是能拿到凭据。
- **假页面带 canary**:只放在 HTML 注释里(`<!-- build: tb-hp-... -->`),用于确认「这确实是蜜罐返回的页面」;`<meta name="generator">` 伪装成 `WordPress 6.4.3`,不主动暴露技术栈。
- **只挡 NotFound**:如果将来给 `/admin` 之类路径注册了真实路由,蜜罐会自动让位 —— 这是刻意设计(业务优先)。
- **告警邮件仍带明文用户名/密码**:这正是它的价值(看到对方在用什么字典),但也意味着邮件是敏感物,收件人只应是你自己。
- **不主动反制**:不回打、不上报第三方、不做删除/篡改,只有「记录 + 临时封禁」。
- **XML-RPC 的凭据提取是启发式的**:不上 `encoding/xml` 解析(蜜罐会被高频命中),而是抓出所有 `<string>`、剔除方法名与站点 URL 后两两配对。`wp.getUsersBlogs` 的第一个参数是 `blog_id`,若是数字会让配对错位一位——为此会把原始串全量写进取证文件的 `fields`(`xmlrpc.0`、`xmlrpc.1` …),即便配对失败原文也不丢。
- **`X-Pingback` 需要配 `external_url`**:没配就不发这个头(相对路径比不发更假)。
+135
View File
@@ -0,0 +1,135 @@
# 把主题素材放到对象存储(任意 S3 兼容)
这里的「对象存储」不特指某一家:Cloudflare R2、自建 MinIO、Backblaze B2、
阿里云 OSS 的 S3 兼容端点、腾讯云 COS 的 S3 兼容端点都走同一套 SigV4,
差别只在 Endpoint、Region、是否路径风格。**放本地还是放对象存储,由部署者自己决定** ——
主题生成器里「素材托管」就是这两选一。
现状:主题素材在 `static/images/themes/<slug>/`,通过 `static/fs.go` 的 `//go:embed images/themes`
随二进制发布。好处是部署即生效、不依赖外部服务;代价是**换一张图就要重新编译部署**,
而且一张 1920×1080 的场景图会让二进制大几百 KB。
主题素材只有一处引用:**主题 CSS 里的 `url(...)`**(`--tb-page-image` / `--tb-page-pattern` /
`--tb-deco`)。分享卡是 Go 里用 `imaging` 画出来的,不读主题素材。所以只要解决「CSS 里的地址指向哪」,
就把整套素材搬走了。
下面三种做法按「改动量从 0 到有代码」排列,选一个即可,不必叠加。
---
## A. 生成器直接写绝对地址 + 就近上传(已支持,推荐)
`dev/theme-builder` 左侧「**素材托管**」有两个选项:本地(随二进制)/ S3 兼容存储。
选 S3 后填:
```
Endpoint https://<账号>.r2.cloudflarestorage.com ← MinIO / B2 / OSS / COS 填各自的
Region auto ← R2 用 auto;MinIO 常为 us-east-1
桶名 my-theme-assets
路径前缀 themes
公开前缀 https://cdn.example.com/themes ← 留空 = 按 Endpoint 推导
Access Key / Secret Key
```
然后:
1. **测试连接** —— `HEAD` 桶(连通+凭据)→ `PUT` 探针(写权限)→ `GET` 读回(内容一致)
→ 匿名 `GET`(能不能被浏览器直接读到)→ 删除探针。失败会把 S3 的 `<Code>` 翻成人话。
2. **上传素材** —— 逐张 PUT 本次主题引用到的图,键为 `<路径前缀>/<slug>/<id>.webp`,
带 `public, max-age=31536000, immutable`。成功后自动把导出用的「素材前缀」填好。
3. **检测远端** —— `ListObjectsV2` 列出 `<前缀>/<slug>/` 下所有对象,按对象键名跟本地比:
一致 / 旧图(同名但大小不同)/ 多余(当前主题已不引用)/ 缺失。
可以「补齐差异」(只传缺的与旧图)、「删除多余」,也可以单行覆盖或删除。
上传经本机 `serve.py` 代理(`/api/s3/test`、`/api/s3/upload`),浏览器不直连对象存储:
**不需要给桶配 CORS**,密钥只在当前标签页的 sessionStorage 与内存里,
不进草稿、不进工程 json、不进导出 zip。
不想用面板也行,导出的 zip 里图片照旧在 `static/images/themes/<slug>/*.webp`,
用任何 S3 客户端推上去即可:
```bash
aws s3 sync static/images/themes/shiroko/ s3://my-bucket/themes/shiroko/ \
--endpoint-url https://<你的 Endpoint> --cache-control "public, max-age=31536000, immutable"
sed -i 's#/static/images/themes/shiroko/#https://cdn.example.com/themes/shiroko/#g' \
static/css/themes/shiroko.css # 已有主题不想重导时,等价于「填前缀再导一次」
```
- 优点:零服务端改动、零跳转、缓存最优(CDN 直接命中);
- 缺点:域名烘焙进 CSS —— 换域名/换路径要**重新导出**(旧主题可以用 `sed` 批量替换);
- 仓库里可以删掉 `static/images/themes/<slug>/` 来缩小编译产物,**确认 CSS 已是绝对地址**再删。
---
## B. 应用内 301 跳转(配置驱动,CSS 一行都不用改)
`conf/app.ini`:
```ini
[theme]
asset_base = https://cdn.example.com/themes ; 留空 = 本地静态目录
```
中间件必须在 `flamego.Static` **之前**注册(`internal/route/route.go` 里 `f.Use(flamego.Static(...))` 前面):
```go
// 主题素材转发到对象存储:命中前缀就 301 出去,其余请求原样放过。
// 必须挂在 static 中间件之前——Static 命中本地文件就直接返回了,轮不到这里。
func themeAssetRedirect() flamego.Handler {
const prefix = "/static/images/themes/"
return func(ctx flamego.Context) {
base := theme.AssetBase() // 从 conf 读,空串 = 不接管
p := ctx.Request().URL.Path
if base == "" || !strings.HasPrefix(p, prefix) {
return
}
w := ctx.ResponseWriter()
w.Header().Set("Cache-Control", "public, max-age=86400")
http.Redirect(w, ctx.Request(), base+strings.TrimPrefix(p, prefix), http.StatusMovedPermanently)
}
}
```
- 优点:已有主题、生成器导出的相对地址**都能用**,切 CDN 只改配置;
- 缺点:冷缓存时每张图多一跳;**301 会被浏览器长期缓存**,以后想切回本地,
老访客可能还在访问 CDN(介意的话用 `302`,或一开始就用方案 A);
- 本地开发记得把 `asset_base` 留空,否则断网时素材全 404。
---
## C. 边缘 / 反代改写(零代码)
素材路径有稳定前缀,最适合在边缘处理:
- **Cloudflare**:Origin Rule / Transform Rule,把 `/static/images/themes/*` 的 Host 改成
R2 自定义域(如 `cdn.example.com`),或 Redirect Rule 直接 301 过去;
- **nginx**:`location /static/images/themes/ { proxy_pass https://cdn.example.com/themes/; }`,
或 `rewrite` 成 CDN 地址后 `return 301`。
- 优点:应用完全不知道素材在哪,不占应用带宽;
- 缺点:绑定部署环境,本地跑没有等效层(要考虑 fallback)。
---
## 三种方案共同要处理的细节
1. **缓存**:对象存储上建议
`Cache-Control: public, max-age=31536000, immutable`(文件名带版本时)。
换图**不要覆盖同名对象**——要么换 `id`(生成器里改文件名),要么给 URL 加 `?v=`;
Cloudflare 默认把 query string 计入缓存键,所以 `?v=` 是有效的。
2. **CORS 不用配**:`background-image` 不触发 CORS,匿名 GET 即可。
3. **主题 CSS 本身不用上 CDN**:一个主题只有几 KB,而且随 `StaticVersion` 走缓存键,
留在 `static/css/themes/` 更省事。
4. **深色模式没有素材**(深色块 `--tb-deco` 恒为 `none`),别只传浅色图就以为漏传了。
5. **分享卡不受影响**:`route/question/share.go` 用 `imaging` 直接画,不加载主题图片。
6. 迁完之后 `go build` 会小一圈,但**主题 css 仍是编译期 embed**:改 CSS 还是要重编译部署。
---
## 建议
- 只是想让部署产物变小、换图不用重编译 → **A**(生成器里配好 S3、测通、一键上传,最省事,缓存也最好)。
- 想保留「素材可以随时换、CSS 不用重发」的灵活性 → **B**(配一个 `asset_base`,接受多一跳)。
- 站点已经在 Cloudflare 上、且愿意在边缘配规则 → **C**(最干净,但迁移成本在运维侧)。
- 就用本地目录、不想依赖任何外部服务 → **什么都不用做**(默认行为,素材随二进制发布)。
+204
View File
@@ -0,0 +1,204 @@
# new-sakura(新·樱花):纯 CSS 樱花主题
和已有的 `sakura` 的区别:`sakura` 是「粉底 + 网点」的静态配色;`new-sakura` 把樱花本身
做成了**可摆放的装饰层** —— 整朵花、带缺口的花瓣、带枝的花朵树,全部是内联 SVG 的
`data:image/svg+xml` URI,写在主题 CSS 里。
因此这个主题:
- **不需要任何图片素材**,也不依赖对象存储(`static/images/themes/new-sakura/` 是空的);
- 任意视口缩放都不糊(矢量),DPI 无关;
- 换花色只改 `dev/theme-tools/gen_new_sakura.py` 里的一个色值,重跑即得;
- 没有额外 HTTP 请求 —— SVG 就在 CSS 里。
代价是 CSS 有 54KB(服务端有 gzip,实际传输约 4KB)。主题 CSS 走 `StaticVersion` 缓存,一次性开销。
## 结构
```
static/css/themes/new-sakura.css ← 生成产物,别手改
dev/theme-tools/gen_new_sakura.py ← 改这里,重跑
```
```bash
python dev/theme-tools/gen_new_sakura.py
```
生成器脚本里有三样东西:花形(`blossom` / `petal` / `branch` / `pattern`)、
每个页面作用域的位置表(`SCOPES`)、以及配色(`LIGHT` / `DARK`)。
## 每个页面的樱花位置
`--tb-deco` / `--tb-deco-size` / `--tb-deco-position` 三组各 10 槽,必须等长
(`TestDecoSlotCount` 会逐文件逐声明检查)。
| 作用域 | 选择器 | 构图 |
| --- | --- | --- |
| global | `html[data-theme-style="new-sakura"]` | 右下花枝 + 左上大花 + 两片落瓣 |
| box | `…[data-page-scope="box"]` | 右上花枝垂下(细端在外、粗端朝内)+ 左下花与落瓣 |
| question | `…[data-page-scope="question"]` | 左下花枝斜穿 + 右上大花 + 落瓣 |
| admin | `…[data-page-scope="admin"]` | 左上花枝 + 左侧底部小花(卡片占满宽度,右侧会被盖住) |
| profile | `…[data-page-scope="profile"]` | 右下花枝 + 左侧竖排小花与落瓣 |
| vote | `…[data-page-scope="vote"]` | 左上花枝 + 右侧花与落瓣 |
位置一律「关键字 + 偏移」两值写法(`right -24px top -16px`)。三值语法里混用
`left` / `bottom` 关键字是非法的,整条声明会作废、花瓣静默回到左上角。
### 窄屏(≤640px)
装饰层全部置 `none`:卡片占满宽度,左右只剩 ~16px 边距,枝条放哪儿都会糊在字上。
樱花改由底纹承担 —— `@media` 里换成**加强版 pattern**(透明度 ×1.9)并把格子收到 180px;
桌面那套 0.18~0.34 的透明度在 390px 上实测接近一片纯粉。
试过在「顶栏与卡片之间」那条 35px 的带子里放两朵小樱花:box 页卡片从 y=122 起还行,
question / admin 页卡片从 y=110 起就切掉半个,结果是黏在卡片边上的粉色碎块。手机上没有
可靠的留白,就不放装饰层 —— 观感交给配色与底纹。
⚠️ **窄屏覆盖必须重新声明每个作用域选择器**:`@media` 不提升特异性。
作用域块是 `html[data-theme-style][data-page-scope="box"]`(0,2,1),而媒体查询里常见的
`html[data-theme-style]`(0,1,1)根本压不住它 —— 表现是「手机上装饰层没关掉,桌面版照样铺开」。
深色作用域块更是 0,3,1,还得再补一条 `[data-theme="dark"][data-theme-style][data-page-scope]`。
`TestNarrowMediaOverridesScopedDeco` 会在这里把关:外层每一处非 none 的 `--tb-deco`,
媒体查询里都要求有同属性名、属性数不少于它的选择器再声明一次。
### 深色
- 花瓣换成更亮一档的粉(`#f0a0b8`),枝条提亮到能看见;
- 底纹 `--tb-page-pattern: none`:浅粉花瓣压在深底上会变成一层噪点;
- 整页背景改深色渐变(不用纯色,避免大面积死黑)。
深色块必须写成 `html[data-theme="dark"][data-theme-style="new-sakura"]`(三重选择器),
只写浅色块会被同特异性的作用域块盖掉。
## 顶栏与手机底栏
顶栏走**磨砂玻璃**,不是实色也不是全透明:
```css
--tb-navbar: rgba(253, 246, 248, 0.72); /* 亮色;深色 rgba(34, 26, 30, 0.72) */
html[data-theme-style="new-sakura"] .uk-navbar-container:not(.uk-navbar-transparent) {
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
}
```
三种写法的实测结论:
| 写法 | 现象 |
| --- | --- |
| 实色 `#fdf6f8` | 花枝经过顶栏时被齐刷刷切掉一段,像被裁过 |
| `transparent` | 花枝完整了,但顶栏"消失":导航没有依托,文字直接压在花纹上 |
| `rgba(...,0.72)` + `blur(12px)` | 花枝透过来但被模糊成柔光,顶栏仍是一块可辨认的面 |
0.72 是怎么定的:导航文字 `#6d555d` 压在顶栏上,纯底色 6.34:1;最坏情况(深花瓣正好在顶栏后面)
压平后 5.08:1,仍过 AA。再往下调透明度就会掉到 4.5 以下。
`TestNavbarNotFullyTransparent` 会拦住 `--tb-navbar: transparent`(以及 `rgba(...,0)`、
`#rrggbb00`)—— 这条不是审美偏好,是把上面第二种写法踩过之后立的规矩。
手机底栏(`#tb-mobile-utils`)在 `base.css` 里写死 `rgba(255,255,255,.92)`,压在粉底上是
一条白杠;主题里按同一思路改成 `rgba(253,246,248,.92)` / `rgba(34,26,30,.92)`。
另外 box 作用域的花枝从 `top -16px` 下移到 `top 26px`:顶栏半透明后,700~760px 宽度下
枝头正好压在「登录」上(实测截图确认)。顶栏高 60 + 外边距 20,26px 起笔即落在顶栏下沿之上,
宽屏仍是"从右上角长出来"的观感。
## 配色与对比度
| 令牌 | 亮色 | 深色 | 说明 |
| --- | --- | --- | --- |
| `--tb-bg` | `#fdf6f8` | `#221a1e` | 纯色,不用渐变(整页色感一致) |
| `--tb-surface` | `#ffffff` | `#2c2227` | 卡片 |
| `--tb-text` | `#3a2c31` (12.4:1) | `#f3e6ea` (14.0:1) | |
| `--tb-text-secondary` | `#6d555d` (6.3:1) | `#c9aeb8` (8.3:1) | |
| `--tb-text-muted` | `#77626a` (5.3:1) | `#ab9099` (5.8:1) | 踩 AA 下限 |
| `--tb-primary` | `#b8436a` (4.9:1) | `#ff93b4` (8.2:1) | 原候选 `#c14a72` 只有 4.39:1,压深 |
| `--tb-on-primary` | `#ffffff` (5.2:1) | `#1b1014` (8.9:1) | 主色按钮上的字 |
## 素材包(可选,已传到对象存储)
主题线上**不需要**任何素材:樱花是内联矢量。但同一个花形也导出成了位图,
放在对象存储上备用(`themes/new-sakura/`,共 7 个对象):
| 键 | 内容 |
| --- | --- |
| `new-sakura.css` | 主题本体(备份,便于分发) |
| `branch-right.webp` / `branch-left.webp` | 花枝 840×640 透明底(互为镜像) |
| `blossom.webp` | 单朵樱花 400×400 透明底 |
| `petal.webp` | 带缺口的花瓣 240×336 透明底 |
| `pattern.webp` | 平铺底纹 600×600 透明底 |
| `INSTALL.txt` | 包内说明 |
重新导出 / 上传(`--upload` 会一起传 CSS、位图和生成的 `INSTALL.txt`):
```bash
python dev/theme-tools/export_assets.py # 只导出到 outputs/sakura-assets
python dev/theme-tools/export_assets.py --upload # 导出并上传,读 data/r2.txt
```
`data/r2.txt` 是部署凭据(`image_endpoint` / `image_access_id` / `image_access_secret` /
`image_bucket` / `image_bucket_cdn_host`),**不进版本库**(`.gitignore` 里的 `/data/`)。
`image_bucket_cdn_host` 与 `conf/app.ini` 同名同义:只写主机名,不带 `https://` 和路径;
没有它时 `INSTALL.txt` 里的公开地址会写成占位符。
缓存策略:位图 `max-age=86400`、CSS 镜像 `300`、`INSTALL.txt` `60`。
这些键**都不带内容版本**,所以刻意不用 `immutable` —— 否则重新导出后,浏览器与 CDN
一年内拿到的都还是旧图。想要一年长缓存,得把内容哈希写进文件名(CSS 里的地址跟着换)。
⚠️ 若某个键曾经以 `immutable` 传过(本主题第一版就是这样),CDN 会一直给旧副本,
改完必须去控制台按 URL 清一次缓存,光重传没用。
### 位图版(引用对象存储)
桶绑了自定义域 `r2.337845818.best`(实测匿名 GET 200、`image/webp`、大小一致),
所以可以把 CSS 从内联切到位图:
```bash
python dev/theme-tools/gen_new_sakura.py \
--asset-base https://r2.337845818.best/themes/new-sakura \
--out static/css/themes/new-sakura.css
```
| 版本 | CSS | gzip 后 | 额外请求 | 清晰度 |
| --- | --- | --- | --- | --- |
| 内联 SVG(默认) | 53.8KB | **4.0KB** | 0 | 矢量,任意 DPR |
| 位图 | 11.8KB | 2.8KB | 5 个 webp,共 48.9KB | 2 倍图 |
服务端挂了 gzip(`internal/route/gzip.go`),所以内联版**总传输 4.0KB**,比位图版的
2.8 + 48.9 = 51.7KB 小一个数量级 —— 换位图省不了流量,只是把开销从 CSS 挪到图片。
位图版唯一正当的理由是**素材要单独复用**(分享卡、海报、其它系统)。
⚠️ 直接连桶的 S3 endpoint(`*.r2.cloudflarestorage.com`)会 403,**只有自定义域/r2.dev 可匿名读**。
切之前先 `curl -I` 验一下前缀;切完 `go build` 部署,否则 embed 里还是旧的。
## 为什么不用主题生成器出图
生成器的装饰层只支持「图片素材」(导出时打进 zip 或传对象存储),没有内联 SVG 图层。
要把樱花做成可摆放的图层,只能在设计上二选一:
1. 生成图片素材(花瓣 PNG/WebP)→ 可以用生成器、可以放 S3,但会有位图缩放、额外请求;
2. 内联 SVG 写进 CSS → 零素材、任意缩放不糊,但生成器管不到。
`new-sakura` 选 2,因为「用 CSS 做樱花」本身就是这个主题的诉求。
生成器仍然可以用来**预览**它:`?demo=1&fresh=1` 起画板,把 `new-sakura.css` 注入即可。
## 怎么验证
```bash
go test ./internal/theme # 10 槽等长、深浅块成对、必需令牌齐全、窄屏覆盖特异性
```
视觉自检用项目真实页面快照:
```bash
TEST_PREVIEW=1 go test ./templates -run TestGenPreview
python dev/theme-builder/serve.py
# 打开 outputs/preview-box-light.html,DevTools 里给 <html> 加
# data-theme-style="new-sakura" data-page-scope="box"
```
## 上线
1. `conf/app.ini` 的 `[theme] available` 加上 `new-sakura`(不改就「设置页看不到」,现象和部署没生效一样);
2. 重新 `go build` —— 主题 CSS 是编译期 embed,只重启不重编不会变;
3. 箱主设置页 → 账号 → 外观,选择「新·樱花」。
+11 -1
View File
@@ -58,12 +58,20 @@
| 地面 | 轮子底线贴画布底部,下方留 12~16px 给接触阴影 |
| 阴影 | 可以在图内自带一层椭圆软影(不透明度 15~20%,模糊 8~12px) |
| 雾度 | RGB 压白 **26%**(比人物淡一档,它在人身后) |
| 背景 | **必须是纯色绿幕或透明**,不要白底(白色车架 + 白底 = 无法分离,见下方“白上白”) |
要点:
- 侧视图即可,但不要画成“图标”——轮辐、脚踏、链条这些细节保留,否则缩到 259px 会变成一个圈。
- 枪要细长、冷色(灰蓝),**不要木色**(会像双管猎枪)。
⚠️ **白上白(本项目踩过)**:白底 + 白车架时,车身涂装和背景是同一个白(实测都是 `#f9f9f9`,
差 0~3 级),颜色完全分不开;四角 floodfill 只能吃掉外圈,轮圈内、车架三角、车筐里的白会整块留下,
看上去就是「没抠干净」。此时的取舍只有两条:
1. 把这批白**全部**设为透明 —— 车身填色一起走,成品是**线稿风**(描边 + 粉色轮圈 + 蓝枪托仍在,
轮辐、车架、车筐网格都清楚,现有的 `bike.webp` 就是这么做出来的,不透明率 19.4%、剩余近白 0%);
2. 重新出一张**绿幕/透明背景**的素材,车身用浅灰蓝等中间调(白车在浅色场景里本来就发飘)。
## 四、场景 `scene-desktop.webp`
| 项 | 值 |
@@ -113,5 +121,7 @@ left: calc(50% - 612px) bottom 20px;
1. **白边**:放大 300% 看轮廓,出现 1px 白色描边就是没抠干净。
2. **闭合区域**:手内侧、腿间、环心应能透出背景色。
3. **接地**:脚底/轮底离画布底边 ≤ 4px;有阴影时阴影在轮子内侧。
4. **体积**:人 ≤40KB、车 ≤40KB、场景 ≤40KB(现在分别是 16 / 23 / 19 KB)。
4. **体积**:人 ≤40KB、车 ≤40KB、场景 ≤40KB(现在分别是 15 / 29 / 19 KB)。
5. **深色模式**:所有图片在深色下都不显示,不用管。
6. **白上白**:车这类「白底 + 白主体」的素材,验收要额外看「不透明像素里近白(>240)的占比」——
正常应接近 0;本项目修复前是 **50%**,说明一半的不透明区域其实是背景白。
+44 -5
View File
@@ -75,7 +75,7 @@ available = default,sakura,shiroko
| 文件 | 用途 | 尺寸 | 体积 |
| --- | --- | --- | --- |
| `chibi/window.webp` | **当前使用**:侧后方视角、双手背在身后望向箱内,压白 16% | 135×460 | 13KB |
| `props/bike.webp` | 自行车 + 步枪(镜像后车头朝右),压白 26%,自带接触阴影 | 397×259 | 22KB |
| `props/bike.webp` | 自行车 + 步枪(车头朝右),压白 26% 后**只剩描边**(车身白填色已去掉,见下),自带接触阴影 | 397×259 | 29KB |
| `chibi/wave.webp` | 备用:正面挥手 | 324×620 | 27KB |
| `chibi/cheer.webp` | 双手欢呼 | 354×620 | 27KB |
| `chibi/think.webp` | 托腮 | 283×620 | 26KB |
@@ -88,7 +88,7 @@ available = default,sakura,shiroko
| 素材 | 来源 |
| --- | --- |
| `static/images/shiroko-cutout.png` | **用户自行用 AI 生成**的立绘,仅作生成参考图,不再上页面 |
| 立绘参考图 | **用户自行用 AI 生成**,仅作 image-to-image 的 `image1`,不上页面、**不入库**(2026-10-21 移除,仓库里不存大图)|
| `chibi/window.webp` | WorkBuddy ImageGen 生成(文生图 → image-to-image 转平涂),再抠白底 |
| `chibi/wave.webp` 等 | WorkBuddy ImageGen **以用户立绘为参考图**(image-to-image)生成,再抠白底 |
| `scene-desktop.webp` / `scene-mobile.webp` | WorkBuddy ImageGen 生成(提示词见下),未使用任何外部素材 |
@@ -96,7 +96,8 @@ available = default,sakura,shiroko
### 生成与抠图参数
- 参考图:`static/images/shiroko-cutout.png`,`input_fidelity: high`,保证角色造型与平涂风格一致;
- 参考图:用户提供的立绘(**不入库**,随便放个工作目录,传给 `image1` 即可),
`input_fidelity: high`,保证角色造型与平涂风格一致;
- 提示词骨架(四个姿态共用,只改动作句):
> Same character, same art style as the reference image: flat vector illustration, no outlines,
@@ -156,9 +157,10 @@ available = default,sakura,shiroko
## 替换 / 扩素材
1. 加姿态:按上面的提示词骨架换动作句生成,白底 → 抠图 → 存 `chibi/<name>.webp`(高 620),
再塞进 `shiroko.css` 的 `--tb-deco`(记得保持 6 个值,并按上面的摆位原则定位)。
再塞进 `shiroko.css` 的 `--tb-deco`(**必须保持 10 个值**,`--tb-deco-size` / `-position` 同步补齐,
少写几个不会报错、只会让图层与尺寸错位;`TestDecoSlotCount` 会拦),并按上面的摆位原则定位。
换 `<img>` 时别忘了给 URL 带 `?v=`——图片不走 `StaticVersion` 缓存键,`max-age` 是 1 天。
2. 换立绘:透明 PNG 覆盖 `static/images/shiroko-cutout.png`,再按同一提示词骨架重新生成姿态。
2. 换立绘:准备一张透明 PNG 立绘当参考图(不入库),再按同一提示词骨架重新生成姿态。
3. 换配色:改 `shiroko.css` 的令牌即可,插画是位图,换色需重新生成。
4. 新增素材不需要改 `static/fs.go`(已嵌整个 `images/themes` 目录);但改了 CSS 必须把
`conf.StaticVersion` +1,否则老访客吃缓存看不到变化。
@@ -213,3 +215,40 @@ wide scene suitable as a website background
2. 按 16:9 取顶部 864px,整体混白 38%(`v*0.62 + 255*0.38`)—— 保证白卡片放上去仍有足够对比度;
3. 高斯模糊 1.4px,让桌椅不抢正文;
4. 导出 WebP q80(1920×1080,22KB);移动端从同一张按 9:16 中裁 620px 宽,导出 720×1003。
## 肖像素材(平涂极简风)
`static/images/themes/shiroko/portraits/` 下另有一组独立肖像,风格参照用户给的参考图:
**平涂、零描边、纯黑圆角矩形眼、无嘴、腮红、头占满画面并裁边**。已抠成透明底,可直接叠在任何底色上。
| 文件 | 构图 | 尺寸 | 体积 |
| --- | --- | --- | --- |
| `portrait-front.webp` | 正面胸像,含深蓝外套与青色领结 | 907×921 | 66KB |
| `portrait-tilt.webp` | 头微侧、眼睛偏横向,气质更松 | 975×921 | 61KB |
| `portrait-close.webp` | 极限特写,头顶与两侧被画框切掉 | 1024×921 | 70KB |
用法(和背景装饰同一套槽位):
```css
--tb-deco: url("/static/images/themes/shiroko/portraits/portrait-front.webp"), none, none, none, none, none;
--tb-deco-size: auto 320px, auto, auto, auto, auto, auto;
--tb-deco-position: left 0 bottom 0, left top, left top, left top, left top, left top;
```
注意:
- 底部是**平直切口**(原图右下角有水印,统一裁掉底部 10%),适合直接贴到页面下沿或卡片边缘,
不会出现"人浮在半空"的软边;
- 透明边缘是 1px 抗锯齿羽化,深色底上会有一圈极淡的浅边,浅色底上看不出来;
- 生成用的提示词骨架:
> Flat vector illustration, modern minimal poster style, no outlines, no gradients,
> only flat solid color shapes … 角色描述 … Eyes are solid black vertically elongated
> rounded rectangles, no highlights, no pupils, soft pink oval blush, no mouth.
> The background is one flat solid pale blue-grey color #E6EEF8.
关键三点:① 必须强调 **no outlines / no gradients**;② 眼睛要写死成**纯黑色块**,否则会画出
白眼球和高光;③ 背景指定**一个具体的纯色**(不要写 white),抠图时按这个色值算 alpha 才干净 ——
白底会连白衬衫、光环内部一起吃掉,闭合光环里的白还填不到。
原始 1024 PNG 与几版备选留在 `outputs/portraits-raw/`(该目录被 gitignore,只在本机)。
+3 -3
View File
@@ -1,6 +1,6 @@
module github.com/tamakyi/TamaBox
go 1.25.0
go 1.27.3
replace golang.org/x/image => golang.org/x/image v0.13.0
@@ -48,6 +48,7 @@ require (
go.opentelemetry.io/otel/sdk v1.9.0
go.opentelemetry.io/otel/trace v1.10.0
golang.org/x/crypto v0.54.0
golang.org/x/image v0.13.0
golang.org/x/net v0.56.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gopkg.in/ini.v1 v1.67.0
@@ -124,7 +125,7 @@ require (
github.com/projectdiscovery/blackrock v0.0.2 // indirect
github.com/projectdiscovery/retryabledns v1.0.115 // indirect
github.com/projectdiscovery/utils v0.11.1 // indirect
github.com/redis/go-redis/v9 v9.5.1 // indirect
github.com/redis/go-redis/v9 v9.5.1
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
@@ -151,7 +152,6 @@ require (
go.opentelemetry.io/proto/otlp v0.19.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/image v0.13.0 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
+33
View File
@@ -58,6 +58,39 @@ func (c *TTLCache) Get(key string) (interface{}, bool) {
return e.value, true
}
// GetWithExpiry 读取缓存值与其过期时间,**不判断、不删除**过期条目。
//
// 供 stale-while-revalidate 使用:调用方拿到过期的旧值后可以先返回给用户,
// 再在后台重算。Get 做不到这一点——它遇到过期项就直接删掉并返回 false,
// 于是「过期」和「不存在」变成同一个结果,调用方只能同步等待重算。
func (c *TTLCache) GetWithExpiry(key string) (value interface{}, expiresAt time.Time, ok bool) {
c.mu.RLock()
defer c.mu.RUnlock()
e, ok := c.items[key]
if !ok {
return nil, time.Time{}, false
}
return e.value, e.expiresAt, true
}
// Expire 把条目标记为「已过期」但**保留其值**。
//
// 与 Delete 的区别很关键:Delete 之后下次读是彻底 miss,调用方只能同步重算;
// Expire 之后下次读仍然拿得到旧值,于是可以「先返回旧值 + 后台刷新」。
// 写多读多的场景(例如投票:每来一票都要让结果失效)只有用 Expire 才不会
// 把每次写入都变成下一次读的同步 O(N) 重算。
// key 不存在或已过期时什么也不做。
func (c *TTLCache) Expire(key string) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.items[key]
if !ok {
return
}
e.expiresAt = time.Now().Add(-time.Second)
c.items[key] = e
}
// Set 写入缓存并设定过期时间。
func (c *TTLCache) Set(key string, value interface{}, ttl time.Duration) {
c.mu.Lock()
+66
View File
@@ -59,3 +59,69 @@ func TestTTLCacheConcurrent(t *testing.T) {
}
wg.Wait()
}
// TestTTLCacheGetWithExpiryKeepsEntry 锁住一个关键语义:
// GetWithExpiry **不删除**过期条目,否则 stale-while-revalidate 拿不到旧值,
// 只能退化成「过期即彻底 miss → 同步重算」,那正是投票结果 O(N²) 的成因。
func TestTTLCacheGetWithExpiryKeepsEntry(t *testing.T) {
c := NewTTLCache(8)
c.Set("k", "v", time.Nanosecond)
time.Sleep(2 * time.Millisecond)
v, exp, ok := c.GetWithExpiry("k")
if !ok {
t.Fatal("expired entry should still be readable via GetWithExpiry")
}
if v.(string) != "v" {
t.Fatalf("expected stale value v, got %v", v)
}
if !time.Now().After(exp) {
t.Fatal("entry should already be expired")
}
// 再读一次仍然在(证明上一次调用没有顺手删掉它)
if _, _, ok := c.GetWithExpiry("k"); !ok {
t.Fatal("GetWithExpiry must not evict the entry")
}
// 而普通 Get 仍按原语义返回 miss(并清掉它)
if _, ok := c.Get("k"); ok {
t.Fatal("Get should report miss for an expired entry")
}
}
// TestTTLCacheGetWithExpiryMissing 不存在的 key 必须返回 ok=false,
// 且不能凭空造出一个零值过期时间让调用方误判为「过期但有值」。
func TestTTLCacheGetWithExpiryMissing(t *testing.T) {
c := NewTTLCache(8)
if _, _, ok := c.GetWithExpiry("nope"); ok {
t.Fatal("expected miss for unknown key")
}
}
// TestTTLCacheExpireKeepsValue 锁住 Expire 与 Delete 的区别:
// Expire 后条目仍在(可拿到旧值并触发后台刷新),而 Delete 之后彻底 miss。
func TestTTLCacheExpireKeepsValue(t *testing.T) {
c := NewTTLCache(8)
c.Set("k", "v", time.Hour)
c.Expire("k")
v, exp, ok := c.GetWithExpiry("k")
if !ok || v.(string) != "v" {
t.Fatalf("Expire must keep the value, got %v %v", v, ok)
}
if !time.Now().After(exp) {
t.Fatal("Expire must push expiresAt into the past")
}
// 普通 Get 立刻视为过期
if _, ok := c.Get("k"); ok {
t.Fatal("Get should see the expired entry as a miss")
}
// 对不存在的 key 调用 Expire 不应 panic 也不应创建条目
c.Expire("absent")
if _, _, ok := c.GetWithExpiry("absent"); ok {
t.Fatal("Expire must not create an entry")
}
}
+585
View File
@@ -0,0 +1,585 @@
// 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 cmd
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/storage"
)
const (
imageObjectAttemptTimeout = 60 * time.Second
imageObjectMaxAttempts = 3
imageObjectRetryBackoff = 100 * time.Millisecond
)
// Backup 全量备份数据库与对象存储(Cloudflare R2)图片到本地目录。
//
// 为什么必须同时备份图片:站点的所有图片(头像 / 背景 / 二维码 logo / 提问图 / 回答图)
// 都以根日期 key、picture/ 或 tamabox/ 等形式存在 R2 上,数据库里只存 key 或 CDN URL。
// 只备份数据库的话,恢复后这些引用指向的图片若已不在 R2,页面就全是裂图。
//
// 输出目录结构:
//
// <output>/
// db-<20060102-150405>.sql # pg_dump 全量导出(schema + data,可用 psql 恢复)
// images/<key> # R2 对象,保持原 key 路径
// images-manifest.json # key → 实际大小 / SHA-256 / GET 元数据
//
// 用法:TamaBox backup --output /var/backups/tamabox
//
// TamaBox backup --output . --db-only # 只备份数据库
// TamaBox backup --output . --images-only # 只备份图片
var Backup = &cli.Command{
Name: "backup",
Usage: "Back up the database and all R2 objects to a local directory",
Action: runBackup,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "output",
Value: ".",
Usage: "Output directory for the backup files",
},
&cli.BoolFlag{
Name: "db-only",
Usage: "Only back up the database (skip R2 images)",
},
&cli.BoolFlag{
Name: "images-only",
Usage: "Only back up R2 images (skip the database)",
},
},
}
func runBackup(ctx *cli.Context) error {
if ctx.Bool("db-only") && ctx.Bool("images-only") {
return errors.New("--db-only and --images-only cannot be used together")
}
if err := conf.Init(); err != nil {
return errors.Wrap(err, "load configuration")
}
outDir := ctx.String("output")
if outDir == "" {
return errors.New("--output must not be empty")
}
if err := os.MkdirAll(outDir, 0o700); err != nil {
return errors.Wrapf(err, "create output directory %q", outDir)
}
dbOnly := ctx.Bool("db-only")
imagesOnly := ctx.Bool("images-only")
if !imagesOnly {
if err := backupDatabase(ctx.Context, outDir); err != nil {
return err
}
}
if !dbOnly {
if err := backupImages(ctx.Context, outDir); err != nil {
return err
}
}
logrus.WithField("dir", outDir).Info("备份完成")
return nil
}
// backupDatabase 用 pg_dump 导出 PostgreSQL 全量数据。
//
// 为什么用外部 pg_dump 而不是在进程内逐表 SELECT:pg_dump 输出的 .sql 是标准恢复格式,
// 直接用 psql 恢复,且能正确处理序列、外键、软删除行、大对象等进程内手写导出极易漏掉的
// 细节。项目实际使用 PostgreSQL(见 code-review-2026-08-28.md P1-4)。
func backupDatabase(ctx context.Context, outDir string) error {
if conf.Database.Type != "" && conf.Database.Type != "postgres" {
return errors.Errorf("备份当前仅支持 PostgreSQL,配置为 %q", conf.Database.Type)
}
pgDump, err := exec.LookPath("pg_dump")
if err != nil {
return errors.Wrap(err, "未找到 pg_dump(PostgreSQL 客户端工具),请安装 postgresql-client")
}
ts := time.Now().Format("20060102-150405")
outFile := filepath.Join(outDir, fmt.Sprintf("db-%s.sql", ts))
// 密码经环境变量传递,避免出现在进程参数里(ps 可见)。
cmd := exec.CommandContext(ctx, pgDump,
"--host", conf.Database.Host,
"--port", fmt.Sprintf("%d", conf.Database.Port),
"--username", conf.Database.User,
"--dbname", conf.Database.Name,
"--no-owner", "--no-privileges",
"--format", "plain",
)
cmd.Env = append(os.Environ(), "PGPASSWORD="+conf.Database.Password)
var dumpErr strings.Builder
if err := writeFileAtomically(outFile, func(w io.Writer) error {
cmd.Stdout = w
cmd.Stderr = &dumpErr
if err := cmd.Run(); err != nil {
return errors.Wrapf(err, "pg_dump failed: %s", strings.TrimSpace(dumpErr.String()))
}
return nil
}); err != nil {
return err
}
logrus.WithField("file", outFile).Info("数据库已备份")
return nil
}
// writeFileAtomically 保留数据库、manifest 与对象下载共用的原子写修复:
// 只有完整写入、Sync 并关闭成功后才替换目标,失败时清理临时 .part 文件。
func writeFileAtomically(outFile string, write func(io.Writer) error) error {
f, err := os.CreateTemp(filepath.Dir(outFile), "."+filepath.Base(outFile)+"-*.part")
if err != nil {
return errors.Wrapf(err, "create temporary file for %q", outFile)
}
tmpFile := f.Name()
defer os.Remove(tmpFile)
defer f.Close()
if err := write(f); err != nil {
return err
}
if err := f.Sync(); err != nil {
return errors.Wrapf(err, "sync temporary file for %q", outFile)
}
if err := f.Close(); err != nil {
return errors.Wrapf(err, "close temporary file for %q", outFile)
}
if err := os.Rename(tmpFile, outFile); err != nil {
return errors.Wrapf(err, "finalize %q", outFile)
}
return nil
}
// imageManifestEntry 记录实际下载内容的大小和 SHA-256,以及 GET 返回的原始元数据。
// ETag 只是服务商元数据,不能当作内容 MD5 或完整性校验的替代品。
type imageManifestEntry struct {
Key string `json:"key"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
ETag string `json:"etag,omitempty"`
ContentType string `json:"content_type,omitempty"`
CacheControl string `json:"cache_control,omitempty"`
}
type backupS3API interface {
ListObjectsV2(context.Context, *s3.ListObjectsV2Input, ...func(*s3.Options)) (*s3.ListObjectsV2Output, error)
GetObject(context.Context, *s3.GetObjectInput, ...func(*s3.Options)) (*s3.GetObjectOutput, error)
}
type backupDownloadPlan struct {
key string
destination string
}
type downloadedImageObject struct {
size int64
sha256 string
etag string
contentType string
cacheControl string
}
// backupImages 默认使用空前缀列举配置 bucket 的全部对象,而不是假定图片都在 tamabox/ 下。
func backupImages(ctx context.Context, outDir string) error {
client, err := backupS3Client(ctx)
if err != nil {
return errors.Wrap(err, "get R2 client for backup")
}
return backupImagesWithClient(ctx, client, conf.Upload.ImageBucket, outDir, "")
}
// backupImagesWithClient 允许测试和隔离演练显式指定 prefix。
// 所有分页 List、key 校验和本地路径预检都在第一次 GetObject 之前完成。
func backupImagesWithClient(ctx context.Context, client backupS3API, bucket, outDir, prefix string) error {
keys, err := listAllBackupKeys(ctx, client, bucket, prefix)
if err != nil {
return err
}
imageDir := filepath.Join(outDir, "images")
plans, err := planBackupDownloads(imageDir, keys)
if err != nil {
return err
}
manifest := make([]imageManifestEntry, 0, len(plans))
var totalBytes int64
for _, plan := range plans {
if err := ensureBackupDestinationDirectory(imageDir, plan.destination); err != nil {
return err
}
downloaded, err := downloadObject(ctx, client, bucket, plan.key, plan.destination)
if err != nil {
return err
}
manifest = append(manifest, imageManifestEntry{
Key: plan.key,
Size: downloaded.size,
SHA256: downloaded.sha256,
ETag: downloaded.etag,
ContentType: downloaded.contentType,
CacheControl: downloaded.cacheControl,
})
totalBytes += downloaded.size
}
manifestFile := filepath.Join(outDir, "images-manifest.json")
if err := writeFileAtomically(manifestFile, func(w io.Writer) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err := enc.Encode(manifest); err != nil {
return errors.Wrap(err, "write manifest")
}
return nil
}); err != nil {
return err
}
logrus.WithFields(logrus.Fields{
"objects": len(manifest),
"bytes": totalBytes,
"dir": imageDir,
"prefix": prefix,
}).Info("图片已备份")
return nil
}
func listAllBackupKeys(ctx context.Context, client backupS3API, bucket, prefix string) ([]string, error) {
keys := make([]string, 0)
var continuationToken *string
seenTokens := make(map[string]struct{})
for {
page, err := client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
Bucket: aws.String(bucket),
Prefix: aws.String(prefix),
ContinuationToken: continuationToken,
})
if err != nil {
return nil, errors.Wrapf(err, "list R2 objects prefix %q", prefix)
}
if page == nil {
return nil, errors.New("list R2 objects returned a nil page")
}
for _, object := range page.Contents {
// nil key 与空 key 一样是无效输入;统一转成空串,由完整 List 后的预检拒绝。
keys = append(keys, aws.ToString(object.Key))
}
if !aws.ToBool(page.IsTruncated) {
return keys, nil
}
next := aws.ToString(page.NextContinuationToken)
if next == "" {
return nil, errors.New("truncated R2 list response has no continuation token")
}
if _, exists := seenTokens[next]; exists {
return nil, errors.Errorf("R2 list continuation token %q repeated", next)
}
seenTokens[next] = struct{}{}
continuationToken = aws.String(next)
}
}
func planBackupDownloads(imageDir string, keys []string) ([]backupDownloadPlan, error) {
plans := make([]backupDownloadPlan, 0, len(keys))
files := make(map[string]string, len(keys))
directories := make(map[string]string)
root, err := filepath.Abs(imageDir)
if err != nil {
return nil, errors.Wrap(err, "resolve images directory")
}
directories[canonicalLocalPath(root)] = ""
for _, key := range keys {
destination, err := localImagePath(root, key)
if err != nil {
return nil, err
}
canonicalDestination := canonicalLocalPath(destination)
if previous, exists := files[canonicalDestination]; exists {
return nil, errors.Errorf("objects %q and %q map to the same local file", previous, key)
}
if previous, exists := directories[canonicalDestination]; exists {
return nil, errors.Errorf("file/directory collision between objects %q and %q", previous, key)
}
for parent := filepath.Dir(destination); pathWithinDirectory(root, parent); parent = filepath.Dir(parent) {
canonicalParent := canonicalLocalPath(parent)
if previous, exists := files[canonicalParent]; exists {
return nil, errors.Errorf("file/directory collision between objects %q and %q", previous, key)
}
if _, exists := directories[canonicalParent]; !exists {
directories[canonicalParent] = key
}
if parent == root {
break
}
}
files[canonicalDestination] = key
plans = append(plans, backupDownloadPlan{key: key, destination: destination})
}
if err := prepareBackupImageRoot(root); err != nil {
return nil, err
}
for _, plan := range plans {
if err := preflightBackupLocalPath(root, plan.destination); err != nil {
return nil, err
}
}
return plans, nil
}
func validateObjectKey(key string) error {
if key == "" {
return errors.New("R2 object key must not be empty")
}
if strings.ContainsRune(key, 0) {
return errors.Errorf("R2 object key %q contains NUL", key)
}
if strings.Contains(key, `\`) {
return errors.Errorf("R2 object key %q contains a backslash", key)
}
if strings.HasSuffix(key, "/") {
return errors.Errorf("R2 object key %q is a directory key", key)
}
if strings.HasPrefix(key, "/") || filepath.IsAbs(filepath.FromSlash(key)) || filepath.VolumeName(filepath.FromSlash(key)) != "" ||
(len(key) >= 2 && ((key[0] >= 'A' && key[0] <= 'Z') || (key[0] >= 'a' && key[0] <= 'z')) && key[1] == ':') {
return errors.Errorf("R2 object key %q is an absolute or volume-qualified path", key)
}
for _, segment := range strings.Split(key, "/") {
if segment == "" || segment == "." || segment == ".." {
return errors.Errorf("R2 object key %q contains an empty or dot path segment", key)
}
}
return nil
}
func localImagePath(imageDir, key string) (string, error) {
if err := validateObjectKey(key); err != nil {
return "", err
}
root, err := filepath.Abs(imageDir)
if err != nil {
return "", errors.Wrap(err, "resolve images directory")
}
destination := filepath.Join(root, filepath.FromSlash(key))
if !pathWithinDirectory(root, destination) || destination == root {
return "", errors.Errorf("R2 object key %q escapes the images directory", key)
}
return destination, nil
}
func pathWithinDirectory(root, candidate string) bool {
rel, err := filepath.Rel(root, candidate)
if err != nil || filepath.IsAbs(rel) {
return false
}
return rel == "." || rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
func canonicalLocalPath(path string) string {
cleaned := filepath.Clean(path)
if runtime.GOOS == "windows" {
return strings.ToLower(cleaned)
}
return cleaned
}
func prepareBackupImageRoot(imageDir string) error {
info, err := os.Lstat(imageDir)
if os.IsNotExist(err) {
if err := os.MkdirAll(imageDir, 0o700); err != nil {
return errors.Wrap(err, "create images directory")
}
info, err = os.Lstat(imageDir)
}
if err != nil {
return errors.Wrap(err, "inspect images directory")
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return errors.Errorf("images path %q must be a real directory, not a symlink or file", imageDir)
}
return nil
}
func preflightBackupLocalPath(imageDir, destination string) error {
info, err := os.Lstat(destination)
if err == nil && (info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular()) {
return errors.Errorf("backup destination %q is not a regular file", destination)
}
if err != nil && !os.IsNotExist(err) {
return errors.Wrapf(err, "inspect backup destination %q", destination)
}
for parent := filepath.Dir(destination); parent != imageDir; parent = filepath.Dir(parent) {
if !pathWithinDirectory(imageDir, parent) {
return errors.Errorf("backup destination %q escapes images directory", destination)
}
info, err := os.Lstat(parent)
if os.IsNotExist(err) {
continue
}
if err != nil {
return errors.Wrapf(err, "inspect backup parent %q", parent)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return errors.Errorf("backup parent %q is not a real directory", parent)
}
}
return nil
}
func ensureBackupDestinationDirectory(imageDir, destination string) error {
root, err := filepath.Abs(imageDir)
if err != nil {
return err
}
parent := filepath.Dir(destination)
rel, err := filepath.Rel(root, parent)
if err != nil || !pathWithinDirectory(root, parent) {
return errors.Errorf("invalid backup destination %q", destination)
}
if rel == "." {
return nil
}
current := root
for _, segment := range strings.Split(rel, string(filepath.Separator)) {
current = filepath.Join(current, segment)
info, err := os.Lstat(current)
if os.IsNotExist(err) {
if err := os.Mkdir(current, 0o700); err != nil && !os.IsExist(err) {
return errors.Wrapf(err, "create backup directory %q", current)
}
info, err = os.Lstat(current)
}
if err != nil {
return errors.Wrapf(err, "inspect backup directory %q", current)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return errors.Errorf("backup parent %q is not a real directory", current)
}
}
return nil
}
// backupS3Client 与上传、恢复共用 storage.NewS3Client 的 endpoint 语义:
// endpoint 可不带路径或只带 /<bucket>,请求始终以 Bucket 参数和原始完整 key 寻址。
// List 返回什么 key 就下载什么 key,不额外添加或剥离 bucket 前缀。
func backupS3Client(ctx context.Context) (*s3.Client, error) {
return storage.NewS3Client(
ctx,
conf.Upload.ImageEndpoint,
conf.Upload.ImageBucket,
conf.Upload.ImageAccessID,
conf.Upload.ImageAccessSecret,
)
}
// downloadObject 原子下载单个对象,并返回实际落盘内容的 SHA-256 和 GET 元数据。
// 每次尝试都有独立 60 秒 context;流中断会从头重试整个对象,最多 3 次。
func downloadObject(ctx context.Context, client backupS3API, bucket, key, destination string) (downloadedImageObject, error) {
var lastErr error
for attempt := 0; attempt < imageObjectMaxAttempts; attempt++ {
if err := ctx.Err(); err != nil {
return downloadedImageObject{}, err
}
attemptCtx, cancel := context.WithTimeout(ctx, imageObjectAttemptTimeout)
downloaded, err := downloadObjectOnce(attemptCtx, client, bucket, key, destination)
cancel()
if err == nil {
return downloaded, nil
}
lastErr = err
if ctx.Err() != nil {
return downloadedImageObject{}, ctx.Err()
}
if attempt == imageObjectMaxAttempts-1 {
break
}
if err := waitForImageObjectRetry(ctx, imageObjectRetryBackoff<<attempt); err != nil {
return downloadedImageObject{}, err
}
}
return downloadedImageObject{}, errors.Wrapf(lastErr, "download %q after %d attempts", key, imageObjectMaxAttempts)
}
func downloadObjectOnce(ctx context.Context, client backupS3API, bucket, key, destination string) (downloadedImageObject, error) {
out, err := client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
})
if err != nil {
return downloadedImageObject{}, errors.Wrapf(err, "get R2 object %q", key)
}
if out == nil || out.Body == nil {
return downloadedImageObject{}, errors.Errorf("get R2 object %q returned an empty response body", key)
}
hash := sha256.New()
var size int64
writeErr := writeFileAtomically(destination, func(w io.Writer) error {
var err error
size, err = io.Copy(io.MultiWriter(w, hash), out.Body)
if err != nil {
return errors.Wrapf(err, "download %q", key)
}
if out.ContentLength != nil && size != aws.ToInt64(out.ContentLength) {
return errors.Wrapf(io.ErrUnexpectedEOF, "download %q: GET content length is %d, received %d", key, aws.ToInt64(out.ContentLength), size)
}
return nil
})
_ = out.Body.Close()
if writeErr != nil {
return downloadedImageObject{}, writeErr
}
return downloadedImageObject{
size: size,
sha256: hex.EncodeToString(hash.Sum(nil)),
etag: aws.ToString(out.ETag),
contentType: aws.ToString(out.ContentType),
cacheControl: aws.ToString(out.CacheControl),
}, nil
}
func waitForImageObjectRetry(ctx context.Context, delay time.Duration) error {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
+437
View File
@@ -0,0 +1,437 @@
// 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 cmd
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/smithy-go"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/storage"
)
const maxImageManifestBytes = 64 << 20 // 64 MiB,足以容纳大量对象且避免恶意清单耗尽内存。
// RestoreImages 从 backup 生成的 images-manifest.json 和 images/ 恢复 R2 对象。
var RestoreImages = &cli.Command{
Name: "restore-images",
Usage: "Validate and restore backed-up images to R2",
Action: runRestoreImages,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "input",
Required: true,
Usage: "Backup directory or images-manifest.json path",
},
&cli.BoolFlag{
Name: "dry-run",
Usage: "Validate local files and remote targets without writing objects",
},
&cli.BoolFlag{
Name: "yes",
Usage: "Confirm actual remote writes",
},
&cli.BoolFlag{
Name: "overwrite",
Usage: "Allow replacing objects that already exist",
},
&cli.StringFlag{
Name: "target-prefix",
Value: "",
Usage: "Prefix prepended to every restored key (for isolated drills)",
},
},
}
type restoreImagesOptions struct {
Input string
DryRun bool
Yes bool
Overwrite bool
TargetPrefix string
}
// restoreObjectStore 的 PutObject 是带重试的高层操作,便于离线 fake 精确断言零写入。
type restoreObjectStore interface {
HeadObject(context.Context, *s3.HeadObjectInput, ...func(*s3.Options)) (*s3.HeadObjectOutput, error)
PutObject(context.Context, *s3.PutObjectInput) error
}
type r2RestoreObjectStore struct {
client *s3.Client
}
func (s *r2RestoreObjectStore) HeadObject(ctx context.Context, input *s3.HeadObjectInput, options ...func(*s3.Options)) (*s3.HeadObjectOutput, error) {
return s.client.HeadObject(ctx, input, options...)
}
func (s *r2RestoreObjectStore) PutObject(ctx context.Context, input *s3.PutObjectInput) error {
// storage.PutObject 提供每次独立 60 秒超时、最多 3 次重试、Body 回绕和可取消退避。
return storage.PutObject(ctx, s.client, input)
}
type restorePlannedObject struct {
entry imageManifestEntry
sourcePath string
targetKey string
}
func runRestoreImages(ctx *cli.Context) error {
opts := restoreImagesOptions{
Input: ctx.String("input"),
DryRun: ctx.Bool("dry-run"),
Yes: ctx.Bool("yes"),
Overwrite: ctx.Bool("overwrite"),
TargetPrefix: ctx.String("target-prefix"),
}
if opts.Input == "" {
return errors.New("--input must not be empty")
}
if !opts.DryRun && !opts.Yes {
return errors.New("actual restore writes require --yes (or use --dry-run)")
}
if err := conf.Init(); err != nil {
return errors.Wrap(err, "load configuration")
}
client, err := backupS3Client(ctx.Context)
if err != nil {
return errors.Wrap(err, "get R2 client for image restore")
}
return restoreImagesWithStore(ctx.Context, &r2RestoreObjectStore{client: client}, conf.Upload.ImageBucket, opts)
}
func restoreImagesWithStore(ctx context.Context, store restoreObjectStore, bucket string, opts restoreImagesOptions) error {
if opts.Input == "" {
return errors.New("restore input must not be empty")
}
if !opts.DryRun && !opts.Yes {
return errors.New("actual restore writes require --yes (or use --dry-run)")
}
manifestPath, imageDir, err := resolveRestoreInput(opts.Input)
if err != nil {
return err
}
entries, err := readImageManifest(manifestPath)
if err != nil {
return err
}
// 关键安全顺序:先验证所有 manifest 条目和全部本地文件,再做任何 Head/Put。
// 因此重复 key、非法 hash/size、缺文件、篡改或 symlink 都不会造成部分上传。
objects := make([]restorePlannedObject, 0, len(entries))
for _, entry := range entries {
targetKey, err := restoreTargetKey(opts.TargetPrefix, entry.Key)
if err != nil {
return err
}
sourcePath, err := localImagePath(imageDir, entry.Key)
if err != nil {
return err
}
if err := validateRestoreLocalFile(imageDir, sourcePath, entry); err != nil {
return err
}
objects = append(objects, restorePlannedObject{
entry: entry,
sourcePath: sourcePath,
targetKey: targetKey,
})
}
// 默认先 Head 所有目标。任何目标已存在都会在 Put 循环开始前失败,保证零 Put。
if !opts.Overwrite {
for _, object := range objects {
headCtx, cancel := context.WithTimeout(ctx, imageObjectAttemptTimeout)
_, err := store.HeadObject(headCtx, &s3.HeadObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(object.targetKey),
})
cancel()
if err == nil {
return errors.Errorf("restore target %q already exists; no objects were uploaded (use --overwrite to replace)", object.targetKey)
}
if !isS3ObjectNotFound(err) {
return errors.Wrapf(err, "check restore target %q; no objects were uploaded", object.targetKey)
}
}
}
if opts.DryRun {
logrus.WithFields(logrus.Fields{
"objects": len(objects),
"prefix": opts.TargetPrefix,
}).Info("图片恢复演练完成,未写入对象")
return nil
}
succeeded := 0
for _, object := range objects {
file, err := openRestoreFileForPut(object.sourcePath, object.entry.Size)
if err != nil {
return errors.Wrapf(err, "open validated restore file for %q after %d successful uploads; restore is not transactional", object.entry.Key, succeeded)
}
input := &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(object.targetKey),
Body: file,
ContentLength: aws.Int64(object.entry.Size),
ContentType: nonEmptyStringPointer(object.entry.ContentType),
CacheControl: nonEmptyStringPointer(object.entry.CacheControl),
}
putErr := store.PutObject(ctx, input)
closeErr := file.Close()
if putErr != nil {
return errors.Wrapf(putErr, "restore object %q to %q failed after %d successful uploads; restore is not transactional", object.entry.Key, object.targetKey, succeeded)
}
if closeErr != nil {
return errors.Wrapf(closeErr, "close restored file %q (remote object was uploaded); restore is not transactional", object.sourcePath)
}
succeeded++
}
logrus.WithFields(logrus.Fields{
"objects": succeeded,
"prefix": opts.TargetPrefix,
}).Info("图片恢复完成")
return nil
}
func resolveRestoreInput(input string) (manifestPath, imageDir string, err error) {
info, err := os.Lstat(input)
if err != nil {
return "", "", errors.Wrapf(err, "inspect restore input %q", input)
}
if info.Mode()&os.ModeSymlink != 0 {
return "", "", errors.Errorf("restore input %q must not be a symlink", input)
}
manifestPath = input
if info.IsDir() {
manifestPath = filepath.Join(input, "images-manifest.json")
} else if !info.Mode().IsRegular() {
return "", "", errors.Errorf("restore input %q is neither a directory nor a regular manifest file", input)
}
return manifestPath, filepath.Join(filepath.Dir(manifestPath), "images"), nil
}
func readImageManifest(manifestPath string) ([]imageManifestEntry, error) {
info, err := os.Lstat(manifestPath)
if err != nil {
return nil, errors.Wrapf(err, "inspect image manifest %q", manifestPath)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return nil, errors.Errorf("image manifest %q must be a regular non-symlink file", manifestPath)
}
if info.Size() > maxImageManifestBytes {
return nil, errors.Errorf("image manifest %q exceeds the %d-byte limit", manifestPath, maxImageManifestBytes)
}
file, err := os.Open(manifestPath)
if err != nil {
return nil, errors.Wrapf(err, "open image manifest %q", manifestPath)
}
data, readErr := io.ReadAll(io.LimitReader(file, maxImageManifestBytes+1))
closeErr := file.Close()
if readErr != nil {
return nil, errors.Wrap(readErr, "read image manifest")
}
if closeErr != nil {
return nil, errors.Wrap(closeErr, "close image manifest")
}
if len(data) > maxImageManifestBytes {
return nil, errors.Errorf("image manifest %q exceeds the %d-byte limit", manifestPath, maxImageManifestBytes)
}
decoder := json.NewDecoder(bytes.NewReader(data))
var entries []imageManifestEntry
if err := decoder.Decode(&entries); err != nil {
return nil, errors.Wrap(err, "decode image manifest")
}
if entries == nil {
return nil, errors.New("image manifest must be a JSON array, not null")
}
var trailing json.RawMessage
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
return nil, errors.New("image manifest contains trailing JSON")
}
return nil, errors.Wrap(err, "image manifest contains trailing data")
}
seen := make(map[string]struct{}, len(entries))
for index := range entries {
entry := &entries[index]
if err := validateObjectKey(entry.Key); err != nil {
return nil, errors.Wrapf(err, "invalid manifest entry %d", index)
}
if _, exists := seen[entry.Key]; exists {
return nil, errors.Errorf("image manifest contains duplicate key %q", entry.Key)
}
seen[entry.Key] = struct{}{}
if entry.Size < 0 {
return nil, errors.Errorf("manifest entry %q has negative size %d", entry.Key, entry.Size)
}
decodedHash, err := hex.DecodeString(entry.SHA256)
if err != nil || len(decodedHash) != sha256.Size {
return nil, errors.Errorf("manifest entry %q has invalid SHA-256 %q", entry.Key, entry.SHA256)
}
entry.SHA256 = strings.ToLower(entry.SHA256)
}
return entries, nil
}
func restoreTargetKey(prefix, key string) (string, error) {
if prefix != "" && !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
target := prefix + key
if err := validateObjectKey(target); err != nil {
return "", errors.Wrapf(err, "invalid restore target for source key %q", key)
}
return target, nil
}
func validateRestoreLocalFile(imageDir, sourcePath string, entry imageManifestEntry) error {
root, err := filepath.Abs(imageDir)
if err != nil {
return err
}
if err := validateRestoreDirectoryChain(root, filepath.Dir(sourcePath)); err != nil {
return err
}
info, err := os.Lstat(sourcePath)
if err != nil {
return errors.Wrapf(err, "inspect local image for %q", entry.Key)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return errors.Errorf("local image for %q must be a regular non-symlink file", entry.Key)
}
if info.Size() != entry.Size {
return errors.Errorf("local image size mismatch for %q: manifest=%d actual=%d", entry.Key, entry.Size, info.Size())
}
file, err := os.Open(sourcePath)
if err != nil {
return errors.Wrapf(err, "open local image for %q", entry.Key)
}
openedInfo, statErr := file.Stat()
if statErr != nil {
_ = file.Close()
return errors.Wrapf(statErr, "stat opened local image for %q", entry.Key)
}
if !openedInfo.Mode().IsRegular() || openedInfo.Size() != entry.Size {
_ = file.Close()
return errors.Errorf("opened local image for %q is not the expected regular file", entry.Key)
}
hash := sha256.New()
_, hashErr := io.Copy(hash, file)
closeErr := file.Close()
if hashErr != nil {
return errors.Wrapf(hashErr, "hash local image for %q", entry.Key)
}
if closeErr != nil {
return errors.Wrapf(closeErr, "close local image for %q", entry.Key)
}
actualHash := hex.EncodeToString(hash.Sum(nil))
if actualHash != entry.SHA256 {
return errors.Errorf("local image SHA-256 mismatch for %q: manifest=%s actual=%s", entry.Key, entry.SHA256, actualHash)
}
return nil
}
func validateRestoreDirectoryChain(root, parent string) error {
rootInfo, err := os.Lstat(root)
if err != nil {
return errors.Wrapf(err, "inspect restore images directory %q", root)
}
if rootInfo.Mode()&os.ModeSymlink != 0 || !rootInfo.IsDir() {
return errors.Errorf("restore images path %q must be a real directory", root)
}
rel, err := filepath.Rel(root, parent)
if err != nil || !pathWithinDirectory(root, parent) {
return errors.Errorf("restore file parent %q escapes images directory", parent)
}
if rel == "." {
return nil
}
current := root
for _, segment := range strings.Split(rel, string(filepath.Separator)) {
current = filepath.Join(current, segment)
info, err := os.Lstat(current)
if err != nil {
return errors.Wrapf(err, "inspect restore parent directory %q", current)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return errors.Errorf("restore parent %q must be a real directory", current)
}
}
return nil
}
func openRestoreFileForPut(sourcePath string, expectedSize int64) (*os.File, error) {
info, err := os.Lstat(sourcePath)
if err != nil {
return nil, err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Size() != expectedSize {
return nil, fmt.Errorf("local file %q changed after validation", sourcePath)
}
file, err := os.Open(sourcePath)
if err != nil {
return nil, err
}
openedInfo, err := file.Stat()
if err != nil {
_ = file.Close()
return nil, err
}
if !openedInfo.Mode().IsRegular() || openedInfo.Size() != expectedSize {
_ = file.Close()
return nil, fmt.Errorf("local file %q changed after validation", sourcePath)
}
return file, nil
}
func isS3ObjectNotFound(err error) bool {
if err == nil {
return false
}
var statusErr interface{ HTTPStatusCode() int }
if errors.As(err, &statusErr) && statusErr.HTTPStatusCode() == 404 {
return true
}
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
switch apiErr.ErrorCode() {
case "NotFound", "NoSuchKey", "404":
return true
}
}
return false
}
func nonEmptyStringPointer(value string) *string {
if value == "" {
return nil
}
return aws.String(value)
}
@@ -0,0 +1,184 @@
package cmd
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/storage"
)
// TestR2BackupRestoreIntegration 默认跳过。只有显式设置 TB_R2_INTEGRATION=1,
// 并通过 NEKOBOX_CONFIG_PATH 提供真实 R2 配置时才运行。
// 测试只访问 tamabox-backup-test/<随机>/ 下的 source/ 与 target/ 两个专属前缀,
// 绝不执行全桶 List,也不会读取、覆盖或删除其他 key。
func TestR2BackupRestoreIntegration(t *testing.T) {
if os.Getenv("TB_R2_INTEGRATION") != "1" {
t.Skip("set TB_R2_INTEGRATION=1 to run the real R2 integration test")
}
if os.Getenv("NEKOBOX_CONFIG_PATH") == "" {
t.Fatal("NEKOBOX_CONFIG_PATH must point to the R2 integration configuration")
}
require.NoError(t, conf.Init())
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
client, err := storage.NewS3Client(
ctx,
conf.Upload.ImageEndpoint,
conf.Upload.ImageBucket,
conf.Upload.ImageAccessID,
conf.Upload.ImageAccessSecret,
)
require.NoError(t, err)
random := make([]byte, 12)
_, err = rand.Read(random)
require.NoError(t, err)
basePrefix := fmt.Sprintf("tamabox-backup-test/%d-%s/", time.Now().UTC().UnixNano(), hex.EncodeToString(random))
sourcePrefix := basePrefix + "source/"
targetPrefix := basePrefix + "target/"
objects := []struct {
relativeKey string
body []byte
contentType string
cacheControl string
}{
{
relativeKey: "2026/11/04/root.webp",
body: []byte("tamabox-r2-integration-root"),
contentType: "image/webp",
cacheControl: "public, max-age=60",
},
{
relativeKey: "picture/2026/11/04/picture.webp",
body: []byte("tamabox-r2-integration-picture"),
contentType: "image/webp",
cacheControl: "public, max-age=120",
},
}
// created 只记录本测试成功创建的精确 key。任何失败清理也只删除这些 key。
created := make(map[string]bool)
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cleanupCancel()
for key := range created {
if err := storage.DeleteObject(cleanupCtx, client, conf.Upload.ImageBucket, key); err != nil {
t.Errorf("clean up own R2 key %q: %v", key, err)
}
}
})
for _, object := range objects {
key := sourcePrefix + object.relativeKey
err := storage.PutObject(ctx, client, &s3.PutObjectInput{
Bucket: aws.String(conf.Upload.ImageBucket),
Key: aws.String(key),
Body: bytes.NewReader(object.body),
ContentLength: aws.Int64(int64(len(object.body))),
ContentType: aws.String(object.contentType),
CacheControl: aws.String(object.cacheControl),
})
require.NoError(t, err)
created[key] = true
}
backupDir := t.TempDir()
require.NoError(t, backupImagesWithClient(ctx, client, conf.Upload.ImageBucket, backupDir, sourcePrefix))
manifest, err := readImageManifest(backupDir + string(os.PathSeparator) + "images-manifest.json")
require.NoError(t, err)
require.Len(t, manifest, len(objects))
manifestKeys := make(map[string]bool, len(manifest))
for _, entry := range manifest {
manifestKeys[entry.Key] = true
}
for _, object := range objects {
require.True(t, manifestKeys[sourcePrefix+object.relativeKey])
}
for _, object := range objects {
sourceKey := sourcePrefix + object.relativeKey
require.NoError(t, storage.DeleteObject(ctx, client, conf.Upload.ImageBucket, sourceKey))
delete(created, sourceKey)
}
store := &integrationTrackingRestoreStore{
base: &r2RestoreObjectStore{client: client},
onUpload: func(key string) {
created[key] = true
},
}
require.NoError(t, restoreImagesWithStore(ctx, store, conf.Upload.ImageBucket, restoreImagesOptions{
Input: backupDir,
Yes: true,
TargetPrefix: targetPrefix,
}))
for _, object := range objects {
targetKey := targetPrefix + sourcePrefix + object.relativeKey
require.True(t, created[targetKey], "restore must report its own successful Put")
output, err := client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(conf.Upload.ImageBucket),
Key: aws.String(targetKey),
})
require.NoError(t, err)
body, readErr := io.ReadAll(output.Body)
closeErr := output.Body.Close()
require.NoError(t, readErr)
require.NoError(t, closeErr)
digest := sha256.Sum256(object.body)
require.Equal(t, fmt.Sprintf("%x", digest), fmt.Sprintf("%x", sha256.Sum256(body)))
require.Equal(t, object.contentType, aws.ToString(output.ContentType))
require.Equal(t, object.cacheControl, aws.ToString(output.CacheControl))
}
for key := range created {
require.NoError(t, storage.DeleteObject(ctx, client, conf.Upload.ImageBucket, key))
delete(created, key)
}
requireOwnPrefixEmpty(ctx, t, client, conf.Upload.ImageBucket, sourcePrefix)
requireOwnPrefixEmpty(ctx, t, client, conf.Upload.ImageBucket, targetPrefix)
}
type integrationTrackingRestoreStore struct {
base restoreObjectStore
onUpload func(string)
}
func (s *integrationTrackingRestoreStore) HeadObject(ctx context.Context, input *s3.HeadObjectInput, options ...func(*s3.Options)) (*s3.HeadObjectOutput, error) {
return s.base.HeadObject(ctx, input, options...)
}
func (s *integrationTrackingRestoreStore) PutObject(ctx context.Context, input *s3.PutObjectInput) error {
if err := s.base.PutObject(ctx, input); err != nil {
return err
}
s.onUpload(aws.ToString(input.Key))
return nil
}
func requireOwnPrefixEmpty(ctx context.Context, t *testing.T, client *s3.Client, bucket, prefix string) {
t.Helper()
require.NotEmpty(t, prefix)
output, err := client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
Bucket: aws.String(bucket),
Prefix: aws.String(prefix),
})
require.NoError(t, err)
require.Empty(t, output.Contents, "专属 prefix 清理后必须为空:%q", prefix)
require.False(t, aws.ToBool(output.IsTruncated), "随机专属 prefix 下对象数不应触发分页")
}
+603
View File
@@ -0,0 +1,603 @@
package cmd
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/stretchr/testify/require"
)
type fakeBackupObject struct {
body []byte
etag string
contentType string
cacheControl string
interruptedAttempts int
wrongLengthAttempts int
}
type fakeBackupS3 struct {
pages []*s3.ListObjectsV2Output
listInputs []*s3.ListObjectsV2Input
objects map[string]fakeBackupObject
getCalls map[string]int
getOrder []string
}
func (f *fakeBackupS3) ListObjectsV2(_ context.Context, input *s3.ListObjectsV2Input, _ ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) {
f.listInputs = append(f.listInputs, input)
index := len(f.listInputs) - 1
if index >= len(f.pages) {
return nil, fmt.Errorf("unexpected ListObjectsV2 call %d", index+1)
}
return f.pages[index], nil
}
func (f *fakeBackupS3) GetObject(_ context.Context, input *s3.GetObjectInput, _ ...func(*s3.Options)) (*s3.GetObjectOutput, error) {
key := aws.ToString(input.Key)
if f.getCalls == nil {
f.getCalls = make(map[string]int)
}
f.getCalls[key]++
f.getOrder = append(f.getOrder, key)
object, exists := f.objects[key]
if !exists {
return nil, fmt.Errorf("unexpected GetObject key %q", key)
}
var body io.ReadCloser
if f.getCalls[key] <= object.interruptedAttempts {
body = io.NopCloser(&interruptedReader{data: append([]byte(nil), object.body...)})
} else {
body = io.NopCloser(strings.NewReader(string(object.body)))
}
contentLength := int64(len(object.body))
if f.getCalls[key] <= object.wrongLengthAttempts {
contentLength++
}
return &s3.GetObjectOutput{
Body: body,
ContentLength: aws.Int64(contentLength),
ETag: aws.String(object.etag),
ContentType: aws.String(object.contentType),
CacheControl: aws.String(object.cacheControl),
}, nil
}
type interruptedReader struct {
data []byte
}
func (r *interruptedReader) Read(p []byte) (int, error) {
if len(r.data) == 0 {
return 0, io.ErrUnexpectedEOF
}
n := copy(p, r.data)
r.data = r.data[n:]
if len(r.data) == 0 {
return n, io.ErrUnexpectedEOF
}
return n, nil
}
func backupListPage(keys []string, truncated bool, nextToken string) *s3.ListObjectsV2Output {
objects := make([]types.Object, 0, len(keys))
for _, key := range keys {
objects = append(objects, types.Object{
Key: aws.String(key),
Size: aws.Int64(999999), // 故意不可信;manifest 必须记录实际下载字节数。
ETag: aws.String("list-etag-must-not-be-used"),
})
}
output := &s3.ListObjectsV2Output{Contents: objects, IsTruncated: aws.Bool(truncated)}
if nextToken != "" {
output.NextContinuationToken = aws.String(nextToken)
}
return output
}
func TestBackupImagesWithClientCoversAllKeysAndPages(t *testing.T) {
keys := []string{
"2026/11/04/root.webp",
"picture/2026/11/04/picture.webp",
"tamabox/2025/12/historical.jpg",
"static/site.css",
}
objects := make(map[string]fakeBackupObject, len(keys))
for index, key := range keys {
objects[key] = fakeBackupObject{
body: []byte(fmt.Sprintf("actual-body-%d", index)),
etag: fmt.Sprintf("get-etag-%d", index),
contentType: []string{"image/webp", "image/webp", "image/jpeg", "text/css"}[index],
cacheControl: fmt.Sprintf("public, max-age=%d", index+60),
}
}
client := &fakeBackupS3{
pages: []*s3.ListObjectsV2Output{
backupListPage(keys[:2], true, "page-2"),
backupListPage(keys[2:], false, ""),
},
objects: objects,
}
outDir := t.TempDir()
require.NoError(t, backupImagesWithClient(context.Background(), client, "configured-bucket", outDir, ""))
require.Len(t, client.listInputs, 2)
for index, input := range client.listInputs {
require.Equal(t, "configured-bucket", aws.ToString(input.Bucket))
require.Equal(t, "", aws.ToString(input.Prefix), "生产默认必须列举整个 bucket")
if index == 0 {
require.Nil(t, input.ContinuationToken)
} else {
require.Equal(t, "page-2", aws.ToString(input.ContinuationToken))
}
}
require.ElementsMatch(t, keys, client.getOrder)
manifestBytes, err := os.ReadFile(filepath.Join(outDir, "images-manifest.json"))
require.NoError(t, err)
var manifest []imageManifestEntry
require.NoError(t, json.Unmarshal(manifestBytes, &manifest))
require.Len(t, manifest, len(keys))
for index, entry := range manifest {
require.Equal(t, keys[index], entry.Key)
object := objects[entry.Key]
require.Equal(t, int64(len(object.body)), entry.Size)
digest := sha256.Sum256(object.body)
require.Equal(t, fmt.Sprintf("%x", digest), entry.SHA256)
require.Equal(t, object.etag, entry.ETag)
require.Equal(t, object.contentType, entry.ContentType)
require.Equal(t, object.cacheControl, entry.CacheControl)
path, err := localImagePath(filepath.Join(outDir, "images"), entry.Key)
require.NoError(t, err)
content, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, object.body, content)
}
}
func TestBackupImagesWithClientSupportsIsolatedPrefixAndEmptyManifest(t *testing.T) {
t.Run("prefix", func(t *testing.T) {
key := "tamabox-backup-test/unit/source/root.webp"
client := &fakeBackupS3{
pages: []*s3.ListObjectsV2Output{backupListPage([]string{key}, false, "")},
objects: map[string]fakeBackupObject{
key: {body: []byte("isolated"), contentType: "image/webp"},
},
}
require.NoError(t, backupImagesWithClient(context.Background(), client, "bucket", t.TempDir(), "tamabox-backup-test/unit/source/"))
require.Len(t, client.listInputs, 1)
require.Equal(t, "tamabox-backup-test/unit/source/", aws.ToString(client.listInputs[0].Prefix))
require.Equal(t, []string{key}, client.getOrder)
})
t.Run("empty array", func(t *testing.T) {
client := &fakeBackupS3{pages: []*s3.ListObjectsV2Output{backupListPage(nil, false, "")}}
outDir := t.TempDir()
require.NoError(t, backupImagesWithClient(context.Background(), client, "bucket", outDir, ""))
content, err := os.ReadFile(filepath.Join(outDir, "images-manifest.json"))
require.NoError(t, err)
require.Equal(t, "[]", strings.TrimSpace(string(content)))
})
}
func TestBackupRejectsPathAttacksBeforeAnyDownload(t *testing.T) {
tests := []struct {
name string
key *string
}{
{name: "nil key", key: nil},
{name: "empty key", key: aws.String("")},
{name: "absolute POSIX", key: aws.String("/outside")},
{name: "absolute Windows", key: aws.String("C:/outside")},
{name: "volume relative", key: aws.String("C:outside")},
{name: "parent", key: aws.String("../outside")},
{name: "nested parent", key: aws.String("picture/../outside")},
{name: "backslash", key: aws.String(`picture\outside`)},
{name: "NUL", key: aws.String("picture/\x00outside")},
{name: "empty segment", key: aws.String("picture//outside")},
{name: "leading dot segment", key: aws.String("./outside")},
{name: "inner dot segment", key: aws.String("picture/./outside")},
{name: "directory", key: aws.String("picture/")},
{name: "dot only", key: aws.String(".")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
page := backupListPage(nil, false, "")
page.Contents = []types.Object{{Key: tt.key}}
client := &fakeBackupS3{pages: []*s3.ListObjectsV2Output{page}}
err := backupImagesWithClient(context.Background(), client, "bucket", t.TempDir(), "")
require.Error(t, err)
require.Empty(t, client.getOrder, "非法 key 必须在任何下载前被拒绝")
})
}
}
func TestBackupPreflightsAllPagesAndLocalCollisionsBeforeDownload(t *testing.T) {
t.Run("invalid key on later page", func(t *testing.T) {
client := &fakeBackupS3{
pages: []*s3.ListObjectsV2Output{
backupListPage([]string{"picture/valid.webp"}, true, "next"),
backupListPage([]string{"../escape"}, false, ""),
},
objects: map[string]fakeBackupObject{"picture/valid.webp": {body: []byte("valid")}},
}
require.Error(t, backupImagesWithClient(context.Background(), client, "bucket", t.TempDir(), ""))
require.Len(t, client.listInputs, 2)
require.Empty(t, client.getOrder)
})
for _, keys := range [][]string{{"a", "a/b"}, {"a/b", "a"}, {"same", "same"}} {
t.Run(fmt.Sprintf("collision %q", keys), func(t *testing.T) {
client := &fakeBackupS3{pages: []*s3.ListObjectsV2Output{backupListPage(keys, false, "")}}
require.Error(t, backupImagesWithClient(context.Background(), client, "bucket", t.TempDir(), ""))
require.Empty(t, client.getOrder)
})
}
t.Run("existing file blocks planned directory", func(t *testing.T) {
outDir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(outDir, "images"), 0o700))
require.NoError(t, os.WriteFile(filepath.Join(outDir, "images", "a"), []byte("existing"), 0o600))
client := &fakeBackupS3{pages: []*s3.ListObjectsV2Output{backupListPage([]string{"a/b"}, false, "")}}
require.Error(t, backupImagesWithClient(context.Background(), client, "bucket", outDir, ""))
require.Empty(t, client.getOrder)
})
t.Run("existing directory blocks planned file", func(t *testing.T) {
outDir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(outDir, "images", "a"), 0o700))
client := &fakeBackupS3{pages: []*s3.ListObjectsV2Output{backupListPage([]string{"a"}, false, "")}}
require.Error(t, backupImagesWithClient(context.Background(), client, "bucket", outDir, ""))
require.Empty(t, client.getOrder)
})
}
func TestBackupRetriesInterruptedStreamsAndLeavesNoPart(t *testing.T) {
t.Run("eventual success", func(t *testing.T) {
key := "picture/retry.webp"
body := []byte("complete-object-after-retries")
client := &fakeBackupS3{
pages: []*s3.ListObjectsV2Output{backupListPage([]string{key}, false, "")},
objects: map[string]fakeBackupObject{
key: {body: body, interruptedAttempts: 2, etag: "successful-get-etag"},
},
}
outDir := t.TempDir()
require.NoError(t, backupImagesWithClient(context.Background(), client, "bucket", outDir, ""))
require.Equal(t, 3, client.getCalls[key])
content, err := os.ReadFile(filepath.Join(outDir, "images", "picture", "retry.webp"))
require.NoError(t, err)
require.Equal(t, body, content)
requireNoPartFiles(t, filepath.Join(outDir, "images", "picture"))
})
t.Run("short body relative to GET content length", func(t *testing.T) {
key := "short-body.webp"
client := &fakeBackupS3{
pages: []*s3.ListObjectsV2Output{backupListPage([]string{key}, false, "")},
objects: map[string]fakeBackupObject{
key: {body: []byte("complete-on-second-attempt"), wrongLengthAttempts: 1},
},
}
outDir := t.TempDir()
require.NoError(t, backupImagesWithClient(context.Background(), client, "bucket", outDir, ""))
require.Equal(t, 2, client.getCalls[key])
requireNoPartFiles(t, filepath.Join(outDir, "images"))
})
t.Run("final failure", func(t *testing.T) {
key := "retry.txt"
client := &fakeBackupS3{
pages: []*s3.ListObjectsV2Output{backupListPage([]string{key}, false, "")},
objects: map[string]fakeBackupObject{
key: {body: []byte("never-complete"), interruptedAttempts: 3},
},
}
outDir := t.TempDir()
err := backupImagesWithClient(context.Background(), client, "bucket", outDir, "")
require.Error(t, err)
require.Contains(t, err.Error(), "after 3 attempts")
require.Equal(t, 3, client.getCalls[key])
require.NoFileExists(t, filepath.Join(outDir, "images", key))
requireNoPartFiles(t, filepath.Join(outDir, "images"))
require.NoFileExists(t, filepath.Join(outDir, "images-manifest.json"))
})
}
type capturedRestorePut struct {
key string
body []byte
contentType string
cacheControl string
size int64
}
type fakeRestoreStore struct {
existing map[string]bool
headErrors map[string]error
putFailures map[string]error
headCalls []string
puts []capturedRestorePut
}
func (f *fakeRestoreStore) HeadObject(_ context.Context, input *s3.HeadObjectInput, _ ...func(*s3.Options)) (*s3.HeadObjectOutput, error) {
key := aws.ToString(input.Key)
f.headCalls = append(f.headCalls, key)
if err := f.headErrors[key]; err != nil {
return nil, err
}
if f.existing[key] {
return &s3.HeadObjectOutput{}, nil
}
return nil, &types.NotFound{}
}
func (f *fakeRestoreStore) PutObject(_ context.Context, input *s3.PutObjectInput) error {
body, err := io.ReadAll(input.Body)
if err != nil {
return err
}
key := aws.ToString(input.Key)
f.puts = append(f.puts, capturedRestorePut{
key: key,
body: body,
contentType: aws.ToString(input.ContentType),
cacheControl: aws.ToString(input.CacheControl),
size: aws.ToInt64(input.ContentLength),
})
return f.putFailures[key]
}
type restoreFixtureObject struct {
key string
body []byte
storedBody []byte
manifestSize *int64
manifestSHA string
contentType string
cacheControl string
}
func createRestoreFixture(t *testing.T, objects []restoreFixtureObject) string {
t.Helper()
dir := t.TempDir()
manifest := make([]imageManifestEntry, 0, len(objects))
for _, object := range objects {
storedBody := object.storedBody
if storedBody == nil {
storedBody = object.body
}
path := filepath.Join(dir, "images", filepath.FromSlash(object.key))
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
require.NoError(t, os.WriteFile(path, storedBody, 0o600))
size := int64(len(object.body))
if object.manifestSize != nil {
size = *object.manifestSize
}
digest := sha256.Sum256(object.body)
hash := fmt.Sprintf("%x", digest)
if object.manifestSHA != "" {
hash = object.manifestSHA
}
manifest = append(manifest, imageManifestEntry{
Key: object.key,
Size: size,
SHA256: hash,
ETag: "source-get-etag",
ContentType: object.contentType,
CacheControl: object.cacheControl,
})
}
data, err := json.MarshalIndent(manifest, "", " ")
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(dir, "images-manifest.json"), data, 0o600))
return dir
}
func TestRestoreImagesPreservesKeysContentAndMetadataWithTargetPrefix(t *testing.T) {
objects := []restoreFixtureObject{
{key: "2026/11/04/root.webp", body: []byte("root-body"), contentType: "image/webp", cacheControl: "public, max-age=60"},
{key: "picture/2026/11/04/pic.webp", body: []byte("picture-body"), contentType: "image/webp", cacheControl: "public, max-age=120"},
{key: "static/site.css", body: []byte("body{}"), contentType: "text/css", cacheControl: "no-cache"},
}
input := createRestoreFixture(t, objects)
store := &fakeRestoreStore{}
err := restoreImagesWithStore(context.Background(), store, "bucket", restoreImagesOptions{
Input: input,
Yes: true,
TargetPrefix: "isolated-restore", // 不带斜杠也应规范化为恰好一个路径分隔符。
})
require.NoError(t, err)
require.Len(t, store.headCalls, len(objects))
require.Len(t, store.puts, len(objects))
byKey := make(map[string]capturedRestorePut, len(store.puts))
for _, put := range store.puts {
byKey[put.key] = put
}
for _, object := range objects {
target := "isolated-restore/" + object.key
put := byKey[target]
require.Equal(t, object.body, put.body)
require.Equal(t, object.contentType, put.contentType)
require.Equal(t, object.cacheControl, put.cacheControl)
require.Equal(t, int64(len(object.body)), put.size)
}
}
func TestRestoreImagesDefaultRefusesOverwriteWithZeroPut(t *testing.T) {
objects := []restoreFixtureObject{
{key: "first.webp", body: []byte("first")},
{key: "second.webp", body: []byte("second")},
}
store := &fakeRestoreStore{existing: map[string]bool{"second.webp": true}}
err := restoreImagesWithStore(context.Background(), store, "bucket", restoreImagesOptions{
Input: createRestoreFixture(t, objects),
Yes: true,
})
require.Error(t, err)
require.Contains(t, err.Error(), "no objects were uploaded")
require.Empty(t, store.puts)
}
func TestRestoreImagesOverwriteAndDryRun(t *testing.T) {
t.Run("overwrite", func(t *testing.T) {
store := &fakeRestoreStore{existing: map[string]bool{"picture.webp": true}}
err := restoreImagesWithStore(context.Background(), store, "bucket", restoreImagesOptions{
Input: createRestoreFixture(t, []restoreFixtureObject{{key: "picture.webp", body: []byte("replacement")}}),
Yes: true,
Overwrite: true,
})
require.NoError(t, err)
require.Empty(t, store.headCalls, "显式 overwrite 不需要默认防覆盖 Head")
require.Len(t, store.puts, 1)
require.Equal(t, []byte("replacement"), store.puts[0].body)
})
t.Run("dry run without yes", func(t *testing.T) {
store := &fakeRestoreStore{}
err := restoreImagesWithStore(context.Background(), store, "bucket", restoreImagesOptions{
Input: createRestoreFixture(t, []restoreFixtureObject{{key: "picture.webp", body: []byte("dry-run")}}),
DryRun: true,
TargetPrefix: "drill/",
})
require.NoError(t, err)
require.Equal(t, []string{"drill/picture.webp"}, store.headCalls)
require.Empty(t, store.puts)
})
t.Run("actual write requires yes", func(t *testing.T) {
store := &fakeRestoreStore{}
err := restoreImagesWithStore(context.Background(), store, "bucket", restoreImagesOptions{
Input: createRestoreFixture(t, []restoreFixtureObject{{key: "picture.webp", body: []byte("write")}}),
})
require.Error(t, err)
require.Contains(t, err.Error(), "--yes")
require.Empty(t, store.headCalls)
require.Empty(t, store.puts)
})
}
func TestRestoreRejectsTamperedFilesBeforeAnyRemoteCall(t *testing.T) {
wrongSize := int64(999)
tests := []struct {
name string
second restoreFixtureObject
}{
{
name: "SHA256 tamper with unchanged size",
second: restoreFixtureObject{
key: "second.webp", body: []byte("second"), storedBody: []byte("SECOND"),
},
},
{
name: "size tamper",
second: restoreFixtureObject{
key: "second.webp", body: []byte("second"), manifestSize: &wrongSize,
},
},
{
name: "manifest hash tamper",
second: restoreFixtureObject{
key: "second.webp", body: []byte("second"), manifestSHA: strings.Repeat("0", 64),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store := &fakeRestoreStore{}
input := createRestoreFixture(t, []restoreFixtureObject{
{key: "first.webp", body: []byte("first")},
tt.second,
})
err := restoreImagesWithStore(context.Background(), store, "bucket", restoreImagesOptions{Input: input, Yes: true})
require.Error(t, err)
require.Empty(t, store.headCalls, "全部本地文件应在任何远端操作前完成校验")
require.Empty(t, store.puts, "首个有效文件也不能在后续文件校验失败前上传")
})
}
}
func TestReadImageManifestRejectsInvalidStructure(t *testing.T) {
validHash := strings.Repeat("a", 64)
tests := []struct {
name string
json string
}{
{name: "null", json: `null`},
{name: "trailing JSON", json: `[] {}`},
{name: "trailing garbage", json: `[] garbage`},
{name: "duplicate key", json: fmt.Sprintf(`[{"key":"same","size":0,"sha256":%q},{"key":"same","size":0,"sha256":%q}]`, validHash, validHash)},
{name: "short hash", json: `[{"key":"image","size":1,"sha256":"abc"}]`},
{name: "non hex hash", json: fmt.Sprintf(`[{"key":"image","size":1,"sha256":%q}]`, strings.Repeat("z", 64))},
{name: "negative size", json: fmt.Sprintf(`[{"key":"image","size":-1,"sha256":%q}]`, validHash)},
{name: "path traversal", json: fmt.Sprintf(`[{"key":"../image","size":1,"sha256":%q}]`, validHash)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "images-manifest.json")
// 非数组用例直接保留原 JSON;其余用例同样应被解析层拒绝。
require.NoError(t, os.WriteFile(path, []byte(tt.json), 0o600))
_, err := readImageManifest(path)
require.Error(t, err)
})
}
}
func TestRestoreRejectsSymlinkBeforeRemoteCalls(t *testing.T) {
input := createRestoreFixture(t, []restoreFixtureObject{{key: "picture.webp", body: []byte("content")}})
source := filepath.Join(input, "images", "picture.webp")
outside := filepath.Join(t.TempDir(), "outside.webp")
require.NoError(t, os.WriteFile(outside, []byte("content"), 0o600))
require.NoError(t, os.Remove(source))
if err := os.Symlink(outside, source); err != nil {
t.Skipf("当前平台无法创建 symlink:%v", err)
}
store := &fakeRestoreStore{}
err := restoreImagesWithStore(context.Background(), store, "bucket", restoreImagesOptions{Input: input, DryRun: true})
require.Error(t, err)
require.Empty(t, store.headCalls)
require.Empty(t, store.puts)
}
func TestRestoreReportsPartialFailureWithoutClaimingTransaction(t *testing.T) {
store := &fakeRestoreStore{putFailures: map[string]error{"second.webp": errors.New("injected put failure")}}
err := restoreImagesWithStore(context.Background(), store, "bucket", restoreImagesOptions{
Input: createRestoreFixture(t, []restoreFixtureObject{
{key: "first.webp", body: []byte("first")},
{key: "second.webp", body: []byte("second")},
}),
Yes: true,
})
require.Error(t, err)
require.Contains(t, err.Error(), "1 successful uploads")
require.Contains(t, err.Error(), "not transactional")
require.Len(t, store.puts, 2)
}
func TestRestoreRejectsUnsafeTargetPrefixBeforeRemoteCalls(t *testing.T) {
store := &fakeRestoreStore{}
err := restoreImagesWithStore(context.Background(), store, "bucket", restoreImagesOptions{
Input: createRestoreFixture(t, []restoreFixtureObject{{key: "picture.webp", body: []byte("content")}}),
Yes: true,
TargetPrefix: "../outside/",
})
require.Error(t, err)
require.Empty(t, store.headCalls)
require.Empty(t, store.puts)
}
+96
View File
@@ -0,0 +1,96 @@
package cmd
import (
"encoding/json"
"errors"
"flag"
"io"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v2"
)
func TestRunBackupRejectsMutuallyExclusiveModes(t *testing.T) {
set := flag.NewFlagSet("backup", flag.ContinueOnError)
set.Bool("db-only", true, "")
set.Bool("images-only", true, "")
err := runBackup(cli.NewContext(nil, set, nil))
require.EqualError(t, err, "--db-only and --images-only cannot be used together")
}
func TestWriteFileAtomically(t *testing.T) {
t.Run("success", func(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "backup.sql")
require.NoError(t, writeFileAtomically(target, func(w io.Writer) error {
_, err := io.WriteString(w, "complete backup")
return err
}))
content, err := os.ReadFile(target)
require.NoError(t, err)
require.Equal(t, "complete backup", string(content))
requireNoPartFiles(t, dir)
})
t.Run("failure preserves previous backup", func(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "backup.sql")
require.NoError(t, os.WriteFile(target, []byte("known good"), 0o600))
writeErr := errors.New("dump failed")
err := writeFileAtomically(target, func(w io.Writer) error {
_, _ = io.WriteString(w, "partial")
return writeErr
})
require.ErrorIs(t, err, writeErr)
content, err := os.ReadFile(target)
require.NoError(t, err)
require.Equal(t, "known good", string(content))
requireNoPartFiles(t, dir)
})
t.Run("failure leaves no new backup", func(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "backup.sql")
writeErr := errors.New("dump failed")
err := writeFileAtomically(target, func(w io.Writer) error {
_, _ = io.WriteString(w, "partial")
return writeErr
})
require.ErrorIs(t, err, writeErr)
require.NoFileExists(t, target)
requireNoPartFiles(t, dir)
})
}
func requireNoPartFiles(t *testing.T, dir string) {
t.Helper()
matches, err := filepath.Glob(filepath.Join(dir, "*.part"))
require.NoError(t, err)
require.Empty(t, matches)
}
func TestImageManifestEntryIntegrityFields(t *testing.T) {
entry := imageManifestEntry{
Key: "tamabox/image.webp",
Size: 3,
SHA256: "actual-content-sha256",
ETag: "provider-etag",
}
encoded, err := json.Marshal(entry)
require.NoError(t, err)
require.JSONEq(t, `{
"key":"tamabox/image.webp",
"size":3,
"sha256":"actual-content-sha256",
"etag":"provider-etag"
}`, string(encoded))
require.NotContains(t, string(encoded), "md5")
}
+3 -1
View File
@@ -95,7 +95,9 @@ func runBan(ctx *cli.Context) error {
return errors.Wrap(err, "ban user and cleanup")
}
logrus.WithField("user_id", user.ID).Info("用户已封禁,关联数据已清理")
// 数据库事务已经提交,先明确输出这一事实,再进行非事务性的 R2 清理。
logrus.WithField("user_id", user.ID).Info("数据库封禁与关联数据清理已提交")
drainR2OutboxAfterBan(ctx.Context)
return nil
}
+214 -22
View File
@@ -7,6 +7,7 @@ package cmd
import (
"context"
"fmt"
"sort"
"strings"
"time"
@@ -15,6 +16,8 @@ import (
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/security/honeypot"
"github.com/tamakyi/TamaBox/internal/shared"
)
// Honeypot 提供蜜罐取证命令(只读 + 一条显式清理)。
@@ -22,6 +25,7 @@ import (
// TamaBox honeypot hits --since 24h 列出捕获到的破解凭据
// TamaBox honeypot stats --days 7 统计来源/路径/凭据排行
// TamaBox honeypot size 报告表行数与磁盘占用
// TamaBox honeypot report --period week 生成安全汇总周报(含地图)并发邮件
// TamaBox honeypot prune --days 30 清理过期记录(保留期由配置决定)
//
// 密码默认脱敏:CLI 输出会被贴到工单、聊天窗口甚至截图里,
@@ -53,7 +57,7 @@ var Honeypot = &cli.Command{
},
{
Name: "prune",
Usage: "Delete honeypot records older than the retention period",
Usage: "Delete honeypot records (DB metadata + credential files) older than the retention period",
Flags: []cli.Flag{
&cli.IntFlag{Name: "days", Value: 0, Usage: "Days to keep (0 = use honeypot_retain_days from config)"},
&cli.BoolFlag{Name: "yes", Usage: "Skip confirmation"},
@@ -65,6 +69,21 @@ var Honeypot = &cli.Command{
Usage: "Report honeypot table row count and disk usage",
Action: runHoneypotSize,
},
// 周期安全汇总:把命中按 IP 定位到城市,出地图报告并邮件送达。
reportCommand,
{
Name: "paths",
Usage: "List active decoy paths (built-in + honeypot_paths)",
Action: runHoneypotPaths,
},
{
Name: "purge-creds",
Usage: "Drop legacy credential columns (username/password/fields/raw_body) from honeypot_hits — irreversible",
Flags: []cli.Flag{
&cli.BoolFlag{Name: "yes", Usage: "Skip confirmation"},
},
Action: runHoneypotPurgeCreds,
},
},
}
@@ -96,28 +115,40 @@ func parseSince(s string) (time.Duration, error) {
return time.Duration(n) * mult, nil
}
// credStoreFromConfig 用已加载的配置打开凭据取证目录。
//
// 凭据(用户名/密码)只在文件里,不查数据库 —— 所以任何需要展示凭据的
// 命令都走这里,而不是 db.HoneypotStore。
func credStoreFromConfig() (honeypot.CredStore, error) {
return honeypot.NewFileCredStore(conf.Security.HoneypotCredDir)
}
func runHoneypotHits(ctx *cli.Context) error {
dur, err := parseSince(ctx.String("since"))
if err != nil {
return err
}
database, err := openDatabase()
if err != nil {
return err
// 凭据只在取证文件里,本命令不再连数据库 —— DB 挂了也能查。
// (元数据在库里不含凭据内容,见 db.HoneypotHit.HadFields 的注释。)
if err := conf.Init(); err != nil {
return errors.Wrap(err, "load configuration")
}
store, err := credStoreFromConfig()
if err != nil {
return errors.Wrap(err, "open honeypot credential store")
}
store := db.NewHoneypotStore(database)
since := time.Now().Add(-dur)
filterIP := strings.TrimSpace(ctx.String("ip"))
showPwd := ctx.Bool("show-password")
// 按 IP 过滤在 SQL 层做:取回最近 N 条再在内存里筛,会导致「指定 IP 的
// 历史命中基本查不出来」(它的记录被其他 IP 的更近记录挤出了窗口)。
rows, err := store.ListSinceByIP(context.Background(), since, filterIP, ctx.Int("limit"))
// IP 过滤在读取阶段做(列表本来就是流式读,先取最近 N 条再筛会让
// 指定 IP 的历史记录被其他 IP 挤出去)。
rows, err := store.ListSince(since, filterIP, ctx.Int("limit"))
if err != nil {
return errors.Wrap(err, "query honeypot hits")
return errors.Wrap(err, "read honeypot credentials")
}
fmt.Printf("蜜罐命中记录(近 %s,共 %d 条)\n", dur, len(rows))
fmt.Printf("蜜罐凭据记录(近 %s,共 %d 条,来源 %s)\n", dur, len(rows), store.Dir())
fmt.Println(strings.Repeat("-", 72))
if len(rows) == 0 {
fmt.Println(" 无")
@@ -134,11 +165,8 @@ func runHoneypotHits(ctx *cli.Context) error {
if user == "" {
user = "(空)"
}
fmt.Printf("%s %-8s %-6s %s\n", r.CreatedAt.Format("2006-01-02 15:04:05"), r.IP, r.Method, truncateForCLI(r.Path, 48))
fmt.Printf("%s %-8s %-6s %s\n", r.Time.Format("2006-01-02 15:04:05"), r.IP, r.Method, truncateForCLI(r.Path, 48))
fmt.Printf(" 用户名:%s 密码:%s\n", truncateForCLI(user, 64), truncateForCLI(pwd, 64))
if r.Provider != "" {
fmt.Printf(" 归属:%s%s\n", r.Provider, proxiedSuffix(r.Proxied))
}
if r.UserAgent != "" {
fmt.Printf(" UA:%s\n", truncateForCLI(r.UserAgent, 96))
}
@@ -170,15 +198,27 @@ func runHoneypotStats(ctx *cli.Context) error {
if err != nil {
return errors.Wrap(err, "count honeypot hits")
}
sources, err := store.TopSources(cctx, since, ctx.Int("limit"))
// 元数据表存的是精确地址(取证需要),但 IPv6 攻击者常在一个 /64 内轮换地址:
// 直接按精确 IP 排行会碎成几十条各 1 次的记录,看不出「其实是同一个人」。
// 多取一些再按封禁同口径(/64)合并,见 mergeSourcesByKey。
limit := ctx.Int("limit")
if limit <= 0 {
limit = 20
}
sources, err := store.TopSources(cctx, since, limit*4)
if err != nil {
return errors.Wrap(err, "query top sources")
}
sources = mergeSourcesByKey(sources, limit)
paths, err := store.TopPaths(cctx, since, ctx.Int("limit"))
if err != nil {
return errors.Wrap(err, "query top paths")
}
creds, err := store.TopCredentials(cctx, since, ctx.Int("limit"))
credStore, err := credStoreFromConfig()
if err != nil {
return errors.Wrap(err, "open honeypot credential store")
}
creds, err := credStore.TopCredentials(since, ctx.Int("limit"))
if err != nil {
return errors.Wrap(err, "query top credentials")
}
@@ -186,7 +226,7 @@ func runHoneypotStats(ctx *cli.Context) error {
fmt.Printf("蜜罐统计:近 %d 天,共 %d 次命中\n", days, total)
fmt.Println(strings.Repeat("-", 72))
fmt.Println("来源 IP 排行:")
fmt.Println("来源排行(IPv6 按 /64 聚合,与封禁口径一致):")
if len(sources) == 0 {
fmt.Println(" 无")
}
@@ -204,7 +244,7 @@ func runHoneypotStats(ctx *cli.Context) error {
}
fmt.Println()
fmt.Println("最常被尝试的凭据组合:")
fmt.Println("最常被尝试的凭据组合(来自取证文件,非数据库):")
if len(creds) == 0 {
fmt.Println(" 无")
}
@@ -264,6 +304,16 @@ func runHoneypotPrune(ctx *cli.Context) error {
fmt.Printf("提示:空间回收失败(%v),数据本身已正确删除;PostgreSQL 下可手动执行 VACUUM honeypot_hits。\n", err)
}
}
// 凭据文件同样会过期,一并清理:明文账号密码不该比元数据留得更久。
// 这也让「prune」在运维心里只有一个语义 —— 按保留期清干净,不用记两套。
if cs, err := credStoreFromConfig(); err != nil {
fmt.Printf("提示:无法打开凭据目录,凭据文件未清理(%v)。\n", err)
} else if m, err := cs.PurgeBefore(before); err != nil {
fmt.Printf("提示:凭据文件清理失败(%v)。\n", err)
} else if m > 0 {
fmt.Printf("已删除 %d 个过期凭据文件(%s)。\n", m, cs.Dir())
}
return nil
}
@@ -314,6 +364,111 @@ func runHoneypotSize(ctx *cli.Context) error {
} else {
fmt.Printf("保留期:未设置 —— 记录只增不减,需要手动执行 TamaBox honeypot prune\n")
}
// 凭据与元数据是两套存储,体积必须分开看:
// 数据库里只有元数据,明文账号密码在文件里。
fmt.Println()
if cs, err := credStoreFromConfig(); err == nil {
if n, size, err := cs.Size(); err == nil {
fmt.Printf("凭据取证文件:%d 个,%s\n", n, humanBytes(size))
fmt.Printf(" 目录:%s\n", cs.Dir())
fmt.Println(" 说明:用户名/密码只存在这里,不入库;按天一个文件,随保留期一并清理。")
}
}
return nil
}
// runHoneypotPaths 列出当前生效的诱饵路径。
//
// 存在的意义:honeypot_paths 是自由文本配置,写错了(拼错、写成正则、漏斜杠)
// 从任何输出里都看不出来 —— 这个命令让「我配的到底生效没」变成一条可查的事实。
func runHoneypotPaths(ctx *cli.Context) error {
// 本命令不查库(DB 挂掉时也能查配置),因此不能走 openDatabase 那条
// 顺带 conf.Init 的路径 —— 必须显式加载,否则读到的全是零值,
// 会显示「蜜罐关闭」并把 honeypot_paths 吞掉。
if err := conf.Init(); err != nil {
return errors.Wrap(err, "load configuration")
}
extra := honeypot.SplitPaths(conf.Security.HoneypotPaths)
builtin := honeypot.New(honeypot.Options{Enabled: true}).Prefixes()
active := honeypot.New(honeypot.Options{Enabled: true, ExtraPaths: extra}).Prefixes()
fmt.Printf("蜜罐开关:%s\n", onOff(conf.Security.EnableHoneypot))
if !conf.Security.EnableHoneypot {
fmt.Println("提示:enable_honeypot = false,以下路径当前不会接管任何请求。")
}
fmt.Println(strings.Repeat("-", 72))
fmt.Printf("生效诱饵路径:%d 条(内置 %d + 自定义 %d,重名已合并)\n",
len(active), len(builtin), len(extra))
// 按家族汇总:运维真正关心的是「哪几类后台在伪装」,而不是 100 条路径。
// 家族决定返回哪种登录页(WordPress / phpMyAdmin / Jenkins / Tomcat …)。
counts := make(map[honeypot.Family]int, len(honeypot.Families()))
for _, p := range active {
counts[honeypot.FamilyOf(p)]++
}
parts := make([]string, 0, len(counts))
for _, fam := range honeypot.Families() {
if n := counts[fam]; n > 0 {
parts = append(parts, fmt.Sprintf("%s %d", fam, n))
}
}
fmt.Println("伪装家族:" + strings.Join(parts, " / "))
for _, p := range active {
fmt.Println(" " + p)
}
if len(extra) > 0 {
fmt.Println()
fmt.Println("自定义路径(honeypot_paths):")
for _, p := range extra {
fmt.Println(" " + p)
}
}
return nil
}
// runHoneypotPurgeCreds 删除旧版本留在 honeypot_hits 上的明文凭据列。
//
// 为什么需要:结构体早已去掉这些字段,但 GORM 的 AutoMigrate **不删既有列**,
// 老库升级上来后历史明文仍在,还会跟着进备份与主从复制。
// DROP COLUMN 不可逆,所以做成显式命令 + 二次确认,不放启动流程里自动执行
// (启动时只会 WARN 提示)。
func runHoneypotPurgeCreds(ctx *cli.Context) error {
database, err := openDatabase()
if err != nil {
return err
}
store := db.NewHoneypotStore(database)
cols, err := store.LegacyCredentialColumns(context.Background())
if err != nil {
return errors.Wrap(err, "check legacy credential columns")
}
if len(cols) == 0 {
fmt.Println("honeypot_hits 上没有遗留的凭据列,无需清理。")
return nil
}
fmt.Println("honeypot_hits 上仍存在以下旧版明文凭据列(整列删除,不可逆):")
for _, c := range cols {
fmt.Println(" " + c)
}
fmt.Println("注意:本命令只处理数据库;磁盘上的历史备份需要你自行处理。")
if !ctx.Bool("yes") {
fmt.Print("确认继续?输入 yes 继续:")
var answer string
if _, err := fmt.Scanln(&answer); err != nil || strings.TrimSpace(answer) != "yes" {
fmt.Println("已取消。")
return nil
}
}
dropped, err := store.PurgeLegacyCredentialColumns(context.Background())
if err != nil {
return errors.Wrapf(err, "凭据列删除中途失败,已删除 %v", dropped)
}
fmt.Printf("已删除 %d 列:%s\n", len(dropped), strings.Join(dropped, ", "))
return nil
}
@@ -368,9 +523,46 @@ func maskSecret(s string) string {
}
}
func proxiedSuffix(proxied bool) string {
if proxied {
return "(CDN/代理出口)"
// mergeSourcesByKey 按「封禁 / 限流」的同一口径合并来源排行。
//
// 元数据表存的是精确地址(取证需要,见 db.HoneypotHit.IP),但 IPv6 攻击者
// 通常在一个 /64 内轮换地址:直接按精确 IP 排行会碎成几十条各 1 次的记录,
// 看不出「其实是同一台机器」。这里用 shared.LimitKey 折成 /64 ——
// 与 autoban 判定、限流的键完全一致,看到的排行就是「谁在被封」。
//
// 代价:SQL 只取了前 limit*4 条精确地址,极端长尾可能漏算,排行本身是近似展示。
func mergeSourcesByKey(rows []*db.HoneypotSourceStat, limit int) []*db.HoneypotSourceStat {
if len(rows) == 0 {
return rows
}
return ""
type agg struct {
count int64
provider string
}
merged := make(map[string]*agg, len(rows))
for _, r := range rows {
key := shared.LimitKey(r.IP)
if a, ok := merged[key]; ok {
a.count += r.Count
if a.provider == "" {
a.provider = r.Provider
}
continue
}
merged[key] = &agg{count: r.Count, provider: r.Provider}
}
out := make([]*db.HoneypotSourceStat, 0, len(merged))
for key, a := range merged {
out = append(out, &db.HoneypotSourceStat{IP: key, Count: a.count, Provider: a.provider})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Count != out[j].Count {
return out[i].Count > out[j].Count
}
return out[i].IP < out[j].IP // 同次数时稳定排序,避免每次输出顺序抖动
})
if limit > 0 && len(out) > limit {
out = out[:limit]
}
return out
}
+46
View File
@@ -0,0 +1,46 @@
// 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 cmd
import (
"testing"
"github.com/tamakyi/TamaBox/internal/db"
)
// TestMergeSourcesByKey 验证来源排行按「封禁口径」合并(IPv6 折成 /64)。
//
// 元数据表存的是精确地址(取证需要),若排行榜也按精确地址分组,
// 一个在 /64 内轮换地址的扫描器会碎成几十条各 1 次的记录 ——
// 看起来像「很多人在扫」,其实只有一台机器。合并口径必须与 autoban 一致。
func TestMergeSourcesByKey(t *testing.T) {
rows := []*db.HoneypotSourceStat{
{IP: "2408:8207:1234:5678::1", Count: 5, Provider: "联通"},
{IP: "2408:8207:1234:5678::2", Count: 3},
{IP: "1.2.3.4", Count: 4, Provider: "Azure"},
{IP: "2408:8207:9999:0001::1", Count: 2},
}
got := mergeSourcesByKey(rows, 10)
if len(got) != 3 {
t.Fatalf("合并后条目数 = %d, want 3(同 /64 必须合并)", len(got))
}
if got[0].IP != "2408:8207:1234:5678::/64" || got[0].Count != 8 {
t.Errorf("第一名 = %s / %d, want 2408:8207:1234:5678::/64 / 8", got[0].IP, got[0].Count)
}
if got[0].Provider != "联通" {
t.Errorf("合并后应保留首个非空归属,got %q", got[0].Provider)
}
if got[1].IP != "1.2.3.4" || got[1].Count != 4 {
t.Errorf("第二名 = %s / %d, want 1.2.3.4 / 4(IPv4 原样保留)", got[1].IP, got[1].Count)
}
if limited := mergeSourcesByKey(rows, 1); len(limited) != 1 {
t.Errorf("limit=1 时条目数 = %d, want 1", len(limited))
}
if out := mergeSourcesByKey(nil, 10); len(out) != 0 {
t.Errorf("空输入应返回空,got %d", len(out))
}
}
+365
View File
@@ -0,0 +1,365 @@
// 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 cmd
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/storage"
)
const (
r2OutboxDefaultLimit = 100
r2OutboxWorkerInterval = time.Minute
r2OutboxInitialBackoff = time.Minute
r2OutboxMaxBackoff = time.Hour
r2OutboxMaxLastErrorSize = 1024
)
var errR2OutboxNotConfigured = errors.New("R2 configuration is incomplete")
// R2Outbox 有界处理一批持久化的 R2 对象删除任务。
var R2Outbox = &cli.Command{
Name: "r2-outbox",
Usage: "Drain a bounded batch of pending R2 object deletion tasks",
Action: runR2Outbox,
Flags: []cli.Flag{
&cli.IntFlag{
Name: "limit",
Value: r2OutboxDefaultLimit,
Usage: "Maximum number of due tasks to process",
},
},
}
type r2OutboxTaskStore interface {
ListDue(ctx context.Context, limit int) ([]*db.R2DeleteTask, error)
Delete(id uint) error
MarkFailed(id uint, nextRetry time.Time, lastError string) error
HasReferences(ctx context.Context, bucket, key string) (bool, error)
}
type r2ObjectDeleter interface {
DeleteObject(ctx context.Context, bucket, key string) error
}
type r2S3ObjectDeleter struct {
client *s3.Client
}
func (d *r2S3ObjectDeleter) DeleteObject(ctx context.Context, bucket, key string) error {
return storage.DeleteObject(ctx, d.client, bucket, key)
}
type r2OutboxResult struct {
Listed int
Removed int
ObjectsDeleted int
ReferenceOnly int
Failed int
Skipped int
Remaining int
}
// processR2Outbox 串行处理一批到期任务。单个任务失败不会中断同批后续任务;
// context 取消则立即停止,且不会把关闭过程中的取消误记为一次任务失败。
func processR2Outbox(ctx context.Context, store r2OutboxTaskStore, deleter r2ObjectDeleter, limit int) (result r2OutboxResult, err error) {
defer func() {
result.Remaining = result.Listed - result.Removed
}()
tasks, err := store.ListDue(ctx, limit)
if err != nil {
if ctx.Err() != nil {
return result, ctx.Err()
}
return result, fmt.Errorf("list R2 outbox tasks: %s", sanitizeR2OutboxError(err))
}
result.Listed = len(tasks)
for _, task := range tasks {
if err := ctx.Err(); err != nil {
return result, err
}
if task == nil {
result.Failed++
logrus.Warn("R2 outbox 返回了空任务,已跳过")
continue
}
logger := logrus.WithField("task_id", task.ID)
if task.Bucket != conf.Upload.ImageBucket {
result.Skipped++
logger.Warn("R2 outbox 任务 bucket 与当前配置不匹配,任务已保留")
continue
}
referenced, refErr := store.HasReferences(ctx, task.Bucket, task.Key)
if ctx.Err() != nil {
return result, ctx.Err()
}
if refErr != nil {
result.Failed++
markR2OutboxTaskFailed(store, task, refErr)
continue
}
if referenced {
// 还有引用时只清理过期 outbox 记录,绝不删除对象。最后一个引用被
// 删除时,对应数据库事务会重新入队一条任务。
if err := store.Delete(task.ID); err != nil {
result.Failed++
logger.WithField("error", sanitizeR2OutboxError(err)).Warn("R2 outbox 引用任务清理失败,任务已保留")
continue
}
result.Removed++
result.ReferenceOnly++
continue
}
deleteErr := deleter.DeleteObject(ctx, task.Bucket, task.Key)
if deleteErr != nil && !isS3ObjectNotFound(deleteErr) {
if ctx.Err() != nil {
return result, ctx.Err()
}
result.Failed++
markR2OutboxTaskFailed(store, task, deleteErr)
continue
}
// storage.DeleteObject 已将 404 归一为成功;这里再次识别 404,保证其他
// deleter 实现也遵循相同的幂等语义。只有任务记录也删除后才算完成。
if err := store.Delete(task.ID); err != nil {
result.Failed++
logger.WithField("error", sanitizeR2OutboxError(err)).Warn("R2 对象已删除,但 outbox 任务清理失败;后续将幂等重试")
continue
}
result.Removed++
result.ObjectsDeleted++
}
return result, nil
}
func markR2OutboxTaskFailed(store r2OutboxTaskStore, task *db.R2DeleteTask, cause error) {
nextRetry := time.Now().Add(r2OutboxBackoff(task.Attempts))
lastError := sanitizeR2OutboxError(cause)
if err := store.MarkFailed(task.ID, nextRetry, lastError); err != nil {
logrus.WithFields(logrus.Fields{
"task_id": task.ID,
"processing_error": lastError,
"persist_error": sanitizeR2OutboxError(err),
}).Warn("R2 outbox 任务失败且重试状态写入失败,任务已保留")
return
}
logrus.WithFields(logrus.Fields{
"task_id": task.ID,
"error": lastError,
"next_retry": nextRetry,
}).Warn("R2 outbox 任务处理失败,已安排重试")
}
func r2OutboxBackoff(attempts int) time.Duration {
if attempts < 0 {
attempts = 0
}
// 1、2、4、8、16、32 分钟后,下一次本应到 64 分钟,直接封顶一小时。
if attempts >= 6 {
return r2OutboxMaxBackoff
}
return r2OutboxInitialBackoff << attempts
}
// sanitizeR2OutboxError 在持久化或记录错误前移除已知配置与凭据,并限制为
// 1024 字节。截断保持 UTF-8 边界,避免向 text 列写入半个多字节字符。
func sanitizeR2OutboxError(err error) string {
if err == nil {
return ""
}
message := err.Error()
sensitiveValues := []string{
conf.Database.DSN,
conf.Database.Password,
conf.Upload.ImageEndpoint,
conf.Upload.ImageAccessID,
conf.Upload.ImageAccessSecret,
conf.Upload.ImageBucket,
conf.Upload.ImageBucketCDNHost,
}
for _, value := range sensitiveValues {
if value != "" {
message = strings.ReplaceAll(message, value, "[redacted]")
}
}
if len(message) <= r2OutboxMaxLastErrorSize {
return message
}
end := r2OutboxMaxLastErrorSize
for end > 0 && !utf8.ValidString(message[:end]) {
end--
}
return message[:end]
}
func r2OutboxConfigured() bool {
return strings.TrimSpace(conf.Upload.ImageEndpoint) != "" &&
strings.TrimSpace(conf.Upload.ImageAccessID) != "" &&
strings.TrimSpace(conf.Upload.ImageAccessSecret) != "" &&
strings.TrimSpace(conf.Upload.ImageBucket) != ""
}
func newR2OutboxObjectDeleter(ctx context.Context) (r2ObjectDeleter, error) {
if !r2OutboxConfigured() {
return nil, errR2OutboxNotConfigured
}
client, err := storage.NewS3Client(
ctx,
conf.Upload.ImageEndpoint,
conf.Upload.ImageBucket,
conf.Upload.ImageAccessID,
conf.Upload.ImageAccessSecret,
)
if err != nil {
return nil, fmt.Errorf("initialize R2 client: %s", sanitizeR2OutboxError(err))
}
return &r2S3ObjectDeleter{client: client}, nil
}
// startR2OutboxWorker 只能在 db.Init 成功后调用。goroutine 由调用方提供的
// WaitGroup 跟踪,Web 退出时先取消 ctx,再等待正在执行的一轮收尾。
func startR2OutboxWorker(ctx context.Context, wg *sync.WaitGroup) {
if !r2OutboxConfigured() {
return
}
deleter, err := newR2OutboxObjectDeleter(ctx)
if err != nil {
logrus.WithField("error", sanitizeR2OutboxError(err)).Warn("R2 outbox worker 启动失败,Web 服务继续运行")
return
}
wg.Add(1)
go func() {
defer wg.Done()
runR2OutboxWorker(ctx, db.R2DeleteTasks, deleter)
}()
}
func runR2OutboxWorker(ctx context.Context, store r2OutboxTaskStore, deleter r2ObjectDeleter) {
ticker := time.NewTicker(r2OutboxWorkerInterval)
defer ticker.Stop()
for {
// 启动先立即跑一轮;处理期间不启动并发轮次。若一轮超过一分钟,
// ticker 只会合并唤醒,不会形成多个 worker 同时消费。
_, err := processR2Outbox(ctx, store, deleter, r2OutboxDefaultLimit)
if err != nil {
if ctx.Err() != nil {
return
}
logrus.WithField("error", sanitizeR2OutboxError(err)).Warn("R2 outbox 本轮处理失败,将在下一分钟重试")
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func drainR2Outbox(ctx context.Context, limit int) (r2OutboxResult, error) {
deleter, err := newR2OutboxObjectDeleter(ctx)
if err != nil {
return r2OutboxResult{}, err
}
return processR2Outbox(ctx, db.R2DeleteTasks, deleter, limit)
}
func runR2Outbox(ctx *cli.Context) error {
limit := ctx.Int("limit")
if limit <= 0 {
return errors.New("--limit must be greater than zero")
}
if err := conf.Init(); err != nil {
return fmt.Errorf("load configuration: %w", err)
}
if !r2OutboxConfigured() {
return errR2OutboxNotConfigured
}
dbType := conf.Database.Type
var dsn string
switch dbType {
case "mysql", "":
dsn = conf.MySQLDsn()
case "postgres":
dsn = conf.PostgresDsn()
default:
return fmt.Errorf("unknown database type: %q", dbType)
}
conf.Database.DSN = dsn
gdb, err := db.Init(dbType, dsn)
if err != nil {
return fmt.Errorf("connect to database: %w", err)
}
if sqlDB, dbErr := gdb.DB(); dbErr == nil {
defer func() {
if err := sqlDB.Close(); err != nil {
logrus.WithField("error", sanitizeR2OutboxError(err)).Warn("关闭数据库连接池失败")
}
}()
}
result, err := drainR2Outbox(ctx.Context, limit)
if err != nil {
return fmt.Errorf("drain R2 outbox: %s", sanitizeR2OutboxError(err))
}
logR2OutboxResult("R2 outbox 本轮处理完成", result)
return nil
}
func logR2OutboxResult(message string, result r2OutboxResult) {
entry := logrus.WithFields(logrus.Fields{
"listed": result.Listed,
"removed": result.Removed,
"object_deletes": result.ObjectsDeleted,
"reference_only": result.ReferenceOnly,
"failed": result.Failed,
"skipped": result.Skipped,
"remaining": result.Remaining,
})
if result.Remaining > 0 {
entry.Warn(message + ",仍有任务待重试或保留")
return
}
entry.Info(message)
}
// drainR2OutboxAfterBan 在封禁事务提交后执行。R2 失败只影响对象清理进度,
// 不向 runBan 返回错误,避免运维误以为需要重新执行不可逆的数据库封禁。
func drainR2OutboxAfterBan(ctx context.Context) {
result, err := drainR2Outbox(ctx, r2OutboxDefaultLimit)
if err != nil {
logrus.WithField("error", sanitizeR2OutboxError(err)).Warn(
"数据库封禁与清理已提交,但 R2 outbox 本轮未能处理;任务仍保留,无需重跑 ban,可稍后运行 r2-outbox",
)
return
}
if result.Remaining > 0 {
logR2OutboxResult("数据库封禁与清理已提交;R2 对象清理将在后续重试,无需重跑 ban", result)
return
}
logR2OutboxResult("数据库封禁与清理已提交;R2 outbox 本轮处理完成", result)
}
+270
View File
@@ -0,0 +1,270 @@
package cmd
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
)
type fakeR2OutboxFailure struct {
nextRetry time.Time
lastError string
}
type fakeR2OutboxStore struct {
tasks []*db.R2DeleteTask
references map[string]bool
referenceErrs map[string]error
referenceCalls []string
deleted map[uint]bool
deleteErr error
failures map[uint]fakeR2OutboxFailure
markFailedErr error
}
func newFakeR2OutboxStore(tasks ...*db.R2DeleteTask) *fakeR2OutboxStore {
return &fakeR2OutboxStore{
tasks: tasks,
references: make(map[string]bool),
referenceErrs: make(map[string]error),
deleted: make(map[uint]bool),
failures: make(map[uint]fakeR2OutboxFailure),
}
}
func (s *fakeR2OutboxStore) ListDue(ctx context.Context, limit int) ([]*db.R2DeleteTask, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
tasks := s.tasks
if limit >= 0 && len(tasks) > limit {
tasks = tasks[:limit]
}
return append([]*db.R2DeleteTask(nil), tasks...), nil
}
func (s *fakeR2OutboxStore) Delete(id uint) error {
if s.deleteErr != nil {
return s.deleteErr
}
s.deleted[id] = true
return nil
}
func (s *fakeR2OutboxStore) MarkFailed(id uint, nextRetry time.Time, lastError string) error {
if s.markFailedErr != nil {
return s.markFailedErr
}
s.failures[id] = fakeR2OutboxFailure{nextRetry: nextRetry, lastError: lastError}
return nil
}
func (s *fakeR2OutboxStore) HasReferences(ctx context.Context, _, key string) (bool, error) {
if err := ctx.Err(); err != nil {
return false, err
}
s.referenceCalls = append(s.referenceCalls, key)
return s.references[key], s.referenceErrs[key]
}
type fakeR2DeleteCall struct {
bucket string
key string
}
type fakeR2ObjectDeleter struct {
calls []fakeR2DeleteCall
errs map[string]error
onDelete func(context.Context, string, string) error
}
func (d *fakeR2ObjectDeleter) DeleteObject(ctx context.Context, bucket, key string) error {
d.calls = append(d.calls, fakeR2DeleteCall{bucket: bucket, key: key})
if d.onDelete != nil {
return d.onDelete(ctx, bucket, key)
}
return d.errs[key]
}
type fakeR2HTTPStatusError int
func (e fakeR2HTTPStatusError) Error() string {
return fmt.Sprintf("R2 returned HTTP %d", int(e))
}
func (e fakeR2HTTPStatusError) HTTPStatusCode() int {
return int(e)
}
func setR2OutboxTestConfig(t *testing.T) {
t.Helper()
old := conf.Upload
conf.Upload.ImageEndpoint = "https://r2.example.test"
conf.Upload.ImageAccessID = "test-access-id"
conf.Upload.ImageAccessSecret = "test-secret"
conf.Upload.ImageBucket = "configured-bucket"
conf.Upload.ImageBucketCDNHost = "cdn.example.test"
t.Cleanup(func() {
conf.Upload = old
})
}
func TestProcessR2OutboxSuccessAndNotFound(t *testing.T) {
setR2OutboxTestConfig(t)
for _, tt := range []struct {
name string
deleteErr error
}{
{name: "success"},
{name: "not found is success", deleteErr: fakeR2HTTPStatusError(404)},
} {
t.Run(tt.name, func(t *testing.T) {
first := &db.R2DeleteTask{ID: 1, Bucket: conf.Upload.ImageBucket, Key: "first.webp"}
second := &db.R2DeleteTask{ID: 2, Bucket: conf.Upload.ImageBucket, Key: "second.webp"}
store := newFakeR2OutboxStore(first, second)
deleter := &fakeR2ObjectDeleter{errs: map[string]error{"second.webp": tt.deleteErr}}
result, err := processR2Outbox(context.Background(), store, deleter, 100)
require.NoError(t, err)
require.Equal(t, 2, result.Listed)
require.Equal(t, 2, result.Removed)
require.Equal(t, 2, result.ObjectsDeleted)
require.Zero(t, result.Failed)
require.Zero(t, result.Remaining)
require.Equal(t, []fakeR2DeleteCall{
{bucket: conf.Upload.ImageBucket, key: "first.webp"},
{bucket: conf.Upload.ImageBucket, key: "second.webp"},
}, deleter.calls, "任务必须按 ListDue 顺序串行处理")
require.True(t, store.deleted[first.ID])
require.True(t, store.deleted[second.ID])
require.Empty(t, store.failures)
})
}
}
func TestProcessR2OutboxReferencedTaskDeletesNoObject(t *testing.T) {
setR2OutboxTestConfig(t)
task := &db.R2DeleteTask{ID: 11, Bucket: conf.Upload.ImageBucket, Key: "shared.webp"}
store := newFakeR2OutboxStore(task)
store.references[task.Key] = true
deleter := &fakeR2ObjectDeleter{}
result, err := processR2Outbox(context.Background(), store, deleter, 100)
require.NoError(t, err)
require.Empty(t, deleter.calls, "仍有引用时绝不能调用 R2 删除")
require.True(t, store.deleted[task.ID], "过期任务本身应删除,最后引用移除时会重新入队")
require.Equal(t, 1, result.ReferenceOnly)
require.Equal(t, 1, result.Removed)
require.Zero(t, result.ObjectsDeleted)
require.Zero(t, result.Remaining)
}
func TestProcessR2OutboxReferenceErrorSchedulesRetry(t *testing.T) {
setR2OutboxTestConfig(t)
task := &db.R2DeleteTask{ID: 21, Bucket: conf.Upload.ImageBucket, Key: "unknown.webp"}
store := newFakeR2OutboxStore(task)
store.referenceErrs[task.Key] = errors.New("reference query failed")
deleter := &fakeR2ObjectDeleter{}
before := time.Now()
result, err := processR2Outbox(context.Background(), store, deleter, 100)
require.NoError(t, err)
require.Empty(t, deleter.calls)
require.False(t, store.deleted[task.ID])
require.Contains(t, store.failures, task.ID)
failure := store.failures[task.ID]
require.Equal(t, "reference query failed", failure.lastError)
require.WithinDuration(t, before.Add(time.Minute), failure.nextRetry, 2*time.Second)
require.Equal(t, 1, result.Failed)
require.Equal(t, 1, result.Remaining)
}
func TestProcessR2OutboxDeleteFailureUsesBackoffAndBoundsLastError(t *testing.T) {
setR2OutboxTestConfig(t)
task := &db.R2DeleteTask{ID: 31, Bucket: conf.Upload.ImageBucket, Key: "retry.webp", Attempts: 2}
store := newFakeR2OutboxStore(task)
longErr := fmt.Errorf("delete failed endpoint=%s credential=%s %s",
conf.Upload.ImageEndpoint,
conf.Upload.ImageAccessSecret,
strings.Repeat("x", 1100),
)
deleter := &fakeR2ObjectDeleter{errs: map[string]error{task.Key: longErr}}
before := time.Now()
result, err := processR2Outbox(context.Background(), store, deleter, 100)
require.NoError(t, err)
require.Len(t, deleter.calls, 1)
require.False(t, store.deleted[task.ID])
require.Contains(t, store.failures, task.ID)
failure := store.failures[task.ID]
require.WithinDuration(t, before.Add(4*time.Minute), failure.nextRetry, 2*time.Second)
require.LessOrEqual(t, len(failure.lastError), r2OutboxMaxLastErrorSize)
require.NotContains(t, failure.lastError, conf.Upload.ImageEndpoint)
require.NotContains(t, failure.lastError, conf.Upload.ImageAccessSecret)
require.Equal(t, 1, result.Failed)
require.Equal(t, 1, result.Remaining)
}
func TestProcessR2OutboxBucketMismatchIsRetainedWithoutDeletion(t *testing.T) {
setR2OutboxTestConfig(t)
task := &db.R2DeleteTask{ID: 41, Bucket: "different-bucket", Key: "foreign.webp"}
store := newFakeR2OutboxStore(task)
deleter := &fakeR2ObjectDeleter{}
result, err := processR2Outbox(context.Background(), store, deleter, 100)
require.NoError(t, err)
require.Empty(t, store.referenceCalls, "bucket 不匹配时不应查询当前 bucket 的引用")
require.Empty(t, deleter.calls)
require.Empty(t, store.failures)
require.False(t, store.deleted[task.ID])
require.Equal(t, 1, result.Skipped)
require.Zero(t, result.Failed)
require.Equal(t, 1, result.Remaining)
}
func TestProcessR2OutboxContextCancellationStopsWithoutMarkFailed(t *testing.T) {
setR2OutboxTestConfig(t)
first := &db.R2DeleteTask{ID: 51, Bucket: conf.Upload.ImageBucket, Key: "first.webp"}
second := &db.R2DeleteTask{ID: 52, Bucket: conf.Upload.ImageBucket, Key: "second.webp"}
store := newFakeR2OutboxStore(first, second)
ctx, cancel := context.WithCancel(context.Background())
deleter := &fakeR2ObjectDeleter{
onDelete: func(ctx context.Context, _, _ string) error {
cancel()
return ctx.Err()
},
}
result, err := processR2Outbox(ctx, store, deleter, 100)
require.ErrorIs(t, err, context.Canceled)
require.Len(t, deleter.calls, 1, "取消后不得继续处理第二个任务")
require.Empty(t, store.failures, "优雅关闭导致的取消不应增加任务失败次数")
require.Empty(t, store.deleted)
require.Zero(t, result.Failed)
require.Equal(t, 2, result.Remaining)
}
func TestR2OutboxBackoffCapsAtOneHour(t *testing.T) {
for _, tt := range []struct {
attempts int
expected time.Duration
}{
{attempts: -1, expected: time.Minute},
{attempts: 0, expected: time.Minute},
{attempts: 1, expected: 2 * time.Minute},
{attempts: 2, expected: 4 * time.Minute},
{attempts: 5, expected: 32 * time.Minute},
{attempts: 6, expected: time.Hour},
{attempts: 100, expected: time.Hour},
} {
require.Equal(t, tt.expected, r2OutboxBackoff(tt.attempts))
}
}
+551
View File
@@ -0,0 +1,551 @@
// 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 cmd
import (
"context"
"fmt"
"html"
"html/template"
"sort"
"strings"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/security/geoip"
"github.com/tamakyi/TamaBox/internal/security/report"
"github.com/tamakyi/TamaBox/internal/shared"
)
// reportCommand 是 honeypot report 子命令。
var reportCommand = &cli.Command{
Name: "report",
Usage: "Generate a weekly/monthly security summary (map + email)",
Description: "统计周期内的蜜罐命中,把来源 IP 通过公共 IP 库定位到城市,\n" +
" 汇总成带世界地图的报告,并(可选)发送到 security_alert_email。",
Flags: []cli.Flag{
&cli.StringFlag{Name: "period", Value: "week", Usage: "week or month"},
&cli.StringFlag{Name: "to", Usage: "Override recipient (default: honeypot_report_email / security_alert_email)"},
&cli.StringFlag{Name: "dir", Usage: "Output directory (default: honeypot_report_dir)"},
&cli.BoolFlag{Name: "no-email", Usage: "Only generate files, do not send email"},
},
Action: runHoneypotReport,
}
// reportResult 一次生成的产物。
type reportResult struct {
Summary *report.Summary
HTMLPath string
PNGPath string
To string
Sent bool
}
// runHoneypotReport 手动生成/补发一份报告。
//
// 存在的意义不只是「测一下能不能跑」:自动周报发出去以后,运维经常需要
// 「把上周的再发我一份」或「先出一份看看内容对不对」——手动入口必须与
// 自动入口走完全相同的代码路径,否则手动验证过的东西和线上跑的不是一回事。
func runHoneypotReport(ctx *cli.Context) error {
database, err := openDatabase()
if err != nil {
return err
}
now := time.Now()
var period report.Period
switch strings.ToLower(strings.TrimSpace(ctx.String("period"))) {
case "week", "w", "":
period = report.PreviousWeek(now)
case "month", "m":
period = report.PreviousMonth(now)
default:
return errors.Errorf("--period 只能是 week 或 month(收到 %q)", ctx.String("period"))
}
dir := ctx.String("dir")
if dir == "" {
dir = conf.Security.HoneypotReportDir
}
res, err := buildSecurityReport(context.Background(), database, period, dir)
if err != nil {
return err
}
printSecurityReport(res)
if ctx.Bool("no-email") {
fmt.Println()
fmt.Printf("已生成(未发信):\n %s\n %s\n", res.HTMLPath, res.PNGPath)
return nil
}
to := strings.TrimSpace(ctx.String("to"))
if to == "" {
to = reportRecipient()
}
if to == "" {
return errors.New("未配置收件人:请在 [security] 设置 honeypot_report_email 或 security_alert_email,或用 --to 指定")
}
if err := sendSecurityReport(res, to); err != nil {
return err
}
fmt.Printf("\n已发送到 %s\n", to)
return nil
}
// reportRecipient 返回报告收件人。
//
// 优先 honeypot_report_email,回退 security_alert_email —— 后者早就存在,
// 不给已有部署增加必填项;想分开投递的人再单独配前者。
func reportRecipient() string {
if v := strings.TrimSpace(conf.Security.HoneypotReportEmail); v != "" {
return v
}
return strings.TrimSpace(conf.Security.SecurityAlertEmail)
}
// buildSecurityReport 跑完整的「读数据 → 定位 → 聚合 → 落盘」流程。
func buildSecurityReport(cctx context.Context, database *gorm.DB, period report.Period, dir string) (*reportResult, error) {
store := db.NewHoneypotStore(database)
sources, err := store.AggregateSources(cctx, period.Start, period.End)
if err != nil {
return nil, errors.Wrap(err, "聚合蜜罐来源")
}
paths, err := store.AggregatePaths(cctx, period.Start, period.End, 20)
if err != nil {
return nil, errors.Wrap(err, "聚合被探测路径")
}
stamps, err := store.TimestampsBetween(cctx, period.Start, period.End)
if err != nil {
return nil, errors.Wrap(err, "读取命中时间戳")
}
// 上一周期同为等长窗口,用于「较上期 ↑/↓」。
prevHits, err := countBetween(cctx, store, period.Start.Add(-period.End.Sub(period.Start)), period.Start)
if err != nil {
return nil, errors.Wrap(err, "统计上一周期")
}
// 先按 LimitKey 合并,再只对合并后的键做定位 —— 顺序按命中数降序,
// 因为腾讯精修有配额上限,要优先花在最活跃的来源上。
keys := mergeKeysByHits(sources)
resolver := geoip.New(geoip.Options{
Provider: conf.Security.IPGeoProvider,
TencentKey: conf.Security.IPGeoTencentKey,
})
geo := report.ResolveWithCache(cctx, db.NewIPGeoStore(database), resolver, keys)
sum := report.Build(report.Input{
Period: period,
Sources: sources,
Paths: paths,
Timestamps: stamps,
Geo: geo,
PrevHits: prevHits,
ResolverDesc: resolver.Describe(),
Now: time.Now(),
})
htmlPath, pngPath, err := report.WriteFiles(sum, dir, report.HTMLOptions{
// 报告里的可交互地图用同一个 key(腾讯位置服务)。
TencentMapKey: conf.Security.IPGeoTencentKey,
})
if err != nil {
return nil, errors.Wrap(err, "写报告文件")
}
return &reportResult{Summary: sum, HTMLPath: htmlPath, PNGPath: pngPath}, nil
}
// countBetween 统计 [from, to) 的命中数。
func countBetween(cctx context.Context, store db.HoneypotStore, from, to time.Time) (int64, error) {
after, err := store.CountSince(cctx, from)
if err != nil {
return 0, err
}
upTo, err := store.CountSince(cctx, to)
if err != nil {
return 0, err
}
// CountSince 是「>= since」,两次相减即区间内数量;
// 时钟回拨等异常情况下可能出现负数,钳到 0。
if after < upTo {
return 0, nil
}
return after - upTo, nil
}
// mergeKeysByHits 把来源按封禁口径(LimitKey)合并,返回按命中数降序的键。
func mergeKeysByHits(sources []*db.HoneypotSourceAgg) []string {
hits := make(map[string]int64, len(sources))
for _, s := range sources {
hits[shared.LimitKey(s.IP)] += s.Hits
}
keys := make([]string, 0, len(hits))
for k := range hits {
keys = append(keys, k)
}
// 按命中数降序;同次数按键名升序保证顺序稳定 —— 否则每次跑报告时
// 「腾讯精修覆盖的那 N 个 IP」会随机漂移,两个周期的数据不可比。
sort.Slice(keys, func(i, j int) bool {
if hits[keys[i]] != hits[keys[j]] {
return hits[keys[i]] > hits[keys[j]]
}
return keys[i] < keys[j]
})
return keys
}
// sendSecurityReport 发信(内嵌 PNG 地图 + 附 HTML 报告)。
func sendSecurityReport(res *reportResult, to string) error {
sum := res.Summary
payload := mail.SecurityReportMail{
To: to,
Title: fmt.Sprintf("【狼的提问箱】%s", sum.Period.Title()),
Period: sum.Period.Label(),
Generated: sum.GeneratedAt.Format("2006-01-02 15:04"),
SummaryHTML: template.HTML(buildSummaryHTML(sum)),
CountriesHTML: template.HTML(buildCountriesHTML(sum)),
Notes: template.HTML(buildNotesHTML(sum)),
Attachments: []mail.MailAttachment{{
Path: res.HTMLPath,
Name: sum.Period.Filename() + ".html",
}},
}
// 内嵌地图:只有真的有命中才发(无数据时地图是一张空白经纬网,没有信息量)。
if !sum.Empty && len(sum.Cities) > 0 {
payload.MapCID = "honeypot-map"
payload.InlineImages = []mail.MailInlineImage{{
Path: res.PNGPath,
CID: "honeypot-map",
}}
}
if err := mail.SendSecurityReportMail(payload); err != nil {
return errors.Wrap(err, "发送报告邮件")
}
res.Sent = true
res.To = to
return nil
}
// printSecurityReport 在终端打印报告摘要(手动运行时看)。
func printSecurityReport(res *reportResult) {
sum := res.Summary
fmt.Println(sum.Period.Title())
fmt.Println(strings.Repeat("-", 72))
fmt.Printf(" 窗口:%s\n", sum.Period.Label())
fmt.Printf(" 命中:%d 次(上期 %d)\n", sum.TotalHits, sum.PrevHits)
fmt.Printf(" 独立来源:%d 个,覆盖 %d 个国家/地区\n", sum.UniqueSources, sum.UniqueCountry)
fmt.Printf(" 真实破解尝试:%d 次;已拦截:%d 次\n", sum.CredHits, sum.BannedHits)
if sum.UnlocatedHits > 0 {
fmt.Printf(" 未能定位:%d 次命中(%s)\n", sum.UnlocatedHits, pctOf(sum.UnlocatedHits, sum.TotalHits))
}
if len(sum.Countries) > 0 {
fmt.Println(" 主要来源:")
for i, c := range sum.Countries {
if i >= 8 {
break
}
fmt.Printf(" %-4s %s %6d 次 %s\n", c.Code, padDisplay(truncateForCLI(c.Name, 12), 20), c.Hits, pctOf(c.Hits, sum.TotalHits))
}
}
fmt.Println()
}
// ---- 邮件正文片段 ----
// buildSummaryHTML 生成 KPI 卡片(邮件只能用内联样式,表格布局最稳)。
func buildSummaryHTML(sum *report.Summary) string {
kpis := [][2]string{
{"蜜罐命中", fmt.Sprintf("%d 次", sum.TotalHits)},
{"独立来源", fmt.Sprintf("%d 个", sum.UniqueSources)},
{"涉及国家/地区", fmt.Sprintf("%d 个", sum.UniqueCountry)},
{"真实破解尝试", fmt.Sprintf("%d 次", sum.CredHits)},
{"已拦截", fmt.Sprintf("%d 次", sum.BannedHits)},
{"云主机来源", fmt.Sprintf("%d 个", sum.CloudSources)},
}
var b strings.Builder
b.WriteString(`<table width="100%" border="0" cellspacing="0" cellpadding="0" style="border-collapse:collapse;">`)
for i := 0; i < len(kpis); i += 2 {
b.WriteString(`<tr>`)
for j := i; j < i+2 && j < len(kpis); j++ {
b.WriteString(`<td width="50%" style="padding:6px;">`)
b.WriteString(`<div style="border:1px solid #dadce0;border-radius:8px;padding:12px 14px;">`)
fmt.Fprintf(&b, `<div style="font-size:12px;color:rgba(0,0,0,0.54);">%s</div>`, kpis[j][0])
fmt.Fprintf(&b, `<div style="font-size:20px;font-weight:600;color:#202124;margin-top:2px;">%s</div>`, kpis[j][1])
b.WriteString(`</div></td>`)
}
if i+1 >= len(kpis) {
b.WriteString(`<td width="50%"></td>`)
}
b.WriteString(`</tr>`)
}
b.WriteString(`</table>`)
if sum.PrevHits > 0 {
delta := float64(sum.TotalHits-sum.PrevHits) / float64(sum.PrevHits) * 100
arrow := "上升"
if delta < 0 {
arrow = "下降"
}
fmt.Fprintf(&b, `<div style="font-size:13px;color:rgba(0,0,0,0.6);margin-top:10px;">较上一周期%s %.0f%%(上期 %d 次)。</div>`,
arrow, absFloat(delta), sum.PrevHits)
}
return b.String()
}
// buildCountriesHTML 生成国家排行表。
func buildCountriesHTML(sum *report.Summary) string {
if len(sum.Countries) == 0 {
return ""
}
var b strings.Builder
b.WriteString(`<div style="font-size:13px;color:rgba(0,0,0,0.54);margin-bottom:8px;">来源国家 / 地区(前 10)</div>`)
b.WriteString(`<table width="100%" border="0" cellspacing="0" cellpadding="0" style="border-collapse:collapse;font-size:13px;">`)
b.WriteString(`<tr>`)
for _, h := range []string{"国家/地区", "命中", "占比"} {
align := "left"
if h != "国家/地区" {
align = "right"
}
fmt.Fprintf(&b, `<th align="%s" style="text-align:right;padding:6px 8px;border-bottom:1px solid #dadce0;color:rgba(0,0,0,0.54);font-weight:600;text-align:%s;">%s</th>`,
align, align, h)
}
b.WriteString(`</tr>`)
for i, c := range sum.Countries {
if i >= 10 {
break
}
name := c.Name
if c.Code != "" && c.Code != "--" {
name = fmt.Sprintf("%s(%s)", c.Name, c.Code)
}
fmt.Fprintf(&b, `<tr><td style="padding:6px 8px;border-bottom:1px solid #f1f3f4;color:#202124;">%s</td>`,
html.EscapeString(name))
fmt.Fprintf(&b, `<td align="right" style="padding:6px 8px;border-bottom:1px solid #f1f3f4;text-align:right;color:#202124;">%d</td>`, c.Hits)
fmt.Fprintf(&b, `<td align="right" style="padding:6px 8px;border-bottom:1px solid #f1f3f4;text-align:right;color:rgba(0,0,0,0.6);">%s</td></tr>`,
html.EscapeString(pctOf(c.Hits, sum.TotalHits)))
}
b.WriteString(`</table>`)
return b.String()
}
// buildNotesHTML 生成口径说明。
func buildNotesHTML(sum *report.Summary) string {
lines := []string{
fmt.Sprintf("地理定位数据源:%s。", sum.ResolverDesc),
fmt.Sprintf("未能定位的命中:%d 次(占 %s)。", sum.UnlocatedHits, pctOf(sum.UnlocatedHits, sum.TotalHits)),
"「真实破解尝试」指提交了账号密码字段的请求;捕获到的账号密码不写进报告,只在服务器取证目录中(TamaBox honeypot hits 查看)。",
"「网络归属」来自离线指纹,判定云厂商 / CDN / WAF,与地理位置是两个维度:显示「未识别(普通网络)」不代表没定位到位置。",
}
var b strings.Builder
for _, l := range lines {
fmt.Fprintf(&b, `%s<br/>`, html.EscapeString(l))
}
return b.String()
}
func pctOf(v, total int64) string {
if total <= 0 {
return "—"
}
return fmt.Sprintf("%.1f%%", float64(v)/float64(total)*100)
}
func absFloat(f float64) float64 {
if f < 0 {
return -f
}
return f
}
// ---- 后台调度 ----
// SecurityReportScheduler 周期性检查是否有到期的周报/月报需要发送。
//
// 为什么不直接用 cron:「这一周的周报发过没」需要幂等保证,而幂等只能靠
// 数据库里的台账(security_report_logs 的 period_key 唯一索引)。有了台账,
// 调度器只需要「每半小时看一眼现在该发哪一期、发过没」——
// 服务中途重启、周一维护窗口错过、机器睡了一觉再醒来,都能正确补发。
type SecurityReportScheduler struct {
database *gorm.DB
interval time.Duration
// now 可注入(测试用)。
now func() time.Time
}
// NewSecurityReportScheduler 构造调度器。
func NewSecurityReportScheduler(database *gorm.DB) *SecurityReportScheduler {
return &SecurityReportScheduler{
database: database,
// 30 分钟一轮:周报/月报的时效要求是「周一/月初能看到」,
// 半小时的粒度远够,而且这个检查只读一条台账,成本几乎为零。
interval: 30 * time.Minute,
now: time.Now,
}
}
// Start 启动后台循环,stop 关闭时退出。
func (s *SecurityReportScheduler) Start(stop <-chan struct{}) {
if s == nil || s.database == nil {
return
}
go func() {
// 启动后先等 2 分钟再跑第一轮:让站点先完成启动、DB 连接池预热,
// 也避免与保留期清理抢同一时刻的数据库资源。
select {
case <-stop:
return
case <-time.After(2 * time.Minute):
}
s.tick()
t := time.NewTicker(s.interval)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
s.tick()
}
}
}()
logrus.WithField("interval", s.interval).Info("安全汇总报告调度器已启动")
}
// tick 检查并发送到期报告。
func (s *SecurityReportScheduler) tick() {
now := s.now()
periods := make([]report.Period, 0, 2)
if conf.Security.HoneypotReportWeekly {
periods = append(periods, report.PreviousWeek(now))
}
if conf.Security.HoneypotReportMonthly {
periods = append(periods, report.PreviousMonth(now))
}
for _, p := range periods {
if !s.due(p, now) {
continue
}
if err := s.deliver(p, now); err != nil {
logrus.WithError(err).WithField("period", p.Key).Warn("安全汇总报告发送失败")
}
}
}
// due 判断某周期现在是否应该发送。
//
// 条件:周期已结束超过 1 小时(留出跨零点写入的余量),且尚未成功发送。
// 不限制「过期多久就不发」:一台停机两周的机器重新上线时,补发上周的周报
// 仍然有意义 —— 反倒是「因为晚了就永远不发」会造成数据黑洞。
func (s *SecurityReportScheduler) due(p report.Period, now time.Time) bool {
if now.Before(p.End) {
return false
}
rec, err := db.NewSecurityReportStore(s.database).Get(context.Background(), p.Key)
if err != nil {
logrus.WithError(err).WithField("period", p.Key).Warn("读取报告台账失败,本轮跳过")
return false
}
if rec == nil {
return true
}
if rec.Sent {
return false
}
// 失败重试上限:SMTP 配错时每次 tick 都失败,无限重试只会把日志和邮箱
// 配额消耗干净。5 次之后停下来,等人工介入(CLI 手动补发会重置计数)。
const maxAttempts = 5
return rec.Attempts < maxAttempts
}
// deliver 生成并发送一份报告,同时写台账。
func (s *SecurityReportScheduler) deliver(p report.Period, now time.Time) error {
store := db.NewSecurityReportStore(s.database)
// 先读一次台账(可能为 nil),用于累计尝试次数。
rec, err := store.Get(context.Background(), p.Key)
if err != nil {
return errors.Wrap(err, "读取台账")
}
if rec == nil {
rec = &db.SecurityReportLog{PeriodKey: p.Key, Kind: p.Kind}
}
rec.WindowStart, rec.WindowEnd = p.Start, p.End
rec.Attempts++
to := reportRecipient()
if to == "" {
rec.Error = "未配置收件人(honeypot_report_email / security_alert_email 均为空)"
_ = store.Save(context.Background(), rec)
return errors.New(rec.Error)
}
rec.Recipient = to
res, err := buildSecurityReport(context.Background(), s.database, p, conf.Security.HoneypotReportDir)
if err != nil {
rec.Error = truncateForCLI(err.Error(), 480)
_ = store.Save(context.Background(), rec)
return err
}
rec.Hits = res.Summary.TotalHits
rec.UniqueSources = int64(res.Summary.UniqueSources)
rec.Banned = res.Summary.BannedHits
rec.Countries = res.Summary.UniqueCountry
rec.FilePath = res.HTMLPath
if err := sendSecurityReport(res, to); err != nil {
rec.Error = truncateForCLI(err.Error(), 480)
_ = store.Save(context.Background(), rec)
return err
}
rec.Sent = true
rec.Error = ""
if err := store.Save(context.Background(), rec); err != nil {
// 台账写失败意味着下一轮会重复发送。这里必须大声报出来,
// 否则用户会收到同一周的周报两三次而不知道原因。
logrus.WithError(err).WithField("period", p.Key).
Error("报告已发送但台账写入失败:下一轮可能重复发送同一期报告")
return nil
}
logrus.WithFields(logrus.Fields{
"period": p.Key, "hits": rec.Hits, "to": to, "file": rec.FilePath,
}).Info("安全汇总报告已发送")
return nil
}
// padDisplay 按「显示列宽」右补空格,让 CLI 表格对齐。
//
// 不能用 %-20s:Go 的宽度按字符数算,一个汉字只算 1,终端里却占两格 ——
// 国家名改成中文之后,表格会歪得看不出哪一列是哪一列。
func padDisplay(s string, width int) string {
w := 0
for _, r := range s {
if r > 0x2E80 {
w += 2
} else {
w++
}
}
if w >= width {
return s
}
return s + strings.Repeat(" ", width-w)
}
+103
View File
@@ -0,0 +1,103 @@
// 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 cmd
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/dbutil"
"github.com/tamakyi/TamaBox/internal/security/report"
)
// TestMergeKeysByHits 覆盖「按封禁口径合并后再定位」的键生成。
//
// 顺序很重要:腾讯精修有配额上限,只处理前 N 个 —— 必须优先给最活跃的来源,
// 否则配额会花在只命中一次的长尾地址上,而报告的主要来源反而定位不到。
func TestMergeKeysByHits(t *testing.T) {
rows := []*db.HoneypotSourceAgg{
{IP: "2408:8207:1234:5678::1", Hits: 5},
{IP: "2408:8207:1234:5678::2", Hits: 3}, // 同 /64 → 合成 8
{IP: "203.0.113.7", Hits: 6},
{IP: "198.51.100.1", Hits: 1},
}
got := mergeKeysByHits(rows)
require.Len(t, got, 3)
// 8 次(合并后的 /64)> 6 次 > 1 次
require.Equal(t, "2408:8207:1234:5678::/64", got[0])
require.Equal(t, "203.0.113.7", got[1])
require.Equal(t, "198.51.100.1", got[2])
}
// TestMergeKeysByHitsStable 覆盖同命中数时的稳定排序。
//
// 不稳定的话,每次跑报告被腾讯精修覆盖的那批 IP 会随机漂移,
// 两个周期的定位精度就不一致了,对比起来像是数据变了。
func TestMergeKeysByHitsStable(t *testing.T) {
rows := []*db.HoneypotSourceAgg{
{IP: "203.0.113.9", Hits: 2},
{IP: "203.0.113.2", Hits: 2},
{IP: "203.0.113.5", Hits: 2},
}
first := mergeKeysByHits(rows)
for i := 0; i < 20; i++ {
require.Equal(t, first, mergeKeysByHits(rows), "同命中数时顺序必须稳定")
}
}
// TestSchedulerDue 覆盖周报/月报的发送判定,这是「不重复发、不永远不发」的核心。
func TestSchedulerDue(t *testing.T) {
database, cleanup := dbutil.NewTestDB(t, db.AllTables...)
t.Cleanup(func() {
if err := cleanup("security_report_logs"); err != nil {
t.Fatal(err)
}
})
s := NewSecurityReportScheduler(database)
ctx := context.Background()
loc := time.Local
// now = 2026-10-26(周一)02:00,上一周是 10-19 ~ 10-25
now := time.Date(2026, 10, 26, 2, 0, 0, 0, loc)
period := report.PreviousWeek(now)
require.Equal(t, "week:2026-W43", period.Key)
// 1) 台账里没有记录 → 该发
require.True(t, s.due(period, now))
// 2) 已成功发送 → 不再发(哪怕重启一百次)
store := db.NewSecurityReportStore(database)
require.NoError(t, store.Save(ctx, &db.SecurityReportLog{
PeriodKey: period.Key, Kind: period.Kind,
WindowStart: period.Start, WindowEnd: period.End, Sent: true, Attempts: 1,
}))
require.False(t, s.due(period, now))
// 3) 发送失败但未超重试上限 → 继续重试
require.NoError(t, store.Save(ctx, &db.SecurityReportLog{
PeriodKey: period.Key, Kind: period.Kind,
WindowStart: period.Start, WindowEnd: period.End,
Sent: false, Attempts: 3, Error: "smtp auth failed",
}))
require.True(t, s.due(period, now))
// 4) 超过重试上限 → 停下来等人工介入,避免刷爆日志与邮箱配额
require.NoError(t, store.Save(ctx, &db.SecurityReportLog{
PeriodKey: period.Key, Kind: period.Kind,
WindowStart: period.Start, WindowEnd: period.End,
Sent: false, Attempts: 5, Error: "smtp auth failed",
}))
require.False(t, s.due(period, now))
// 5) 周期还没结束 → 不发(哪怕台账里没有记录)
future := report.PreviousWeek(now.AddDate(0, 0, 7))
require.False(t, s.due(future, now), "窗口未结束时不能发不完整的报告")
}
+143 -7
View File
@@ -5,8 +5,13 @@
package cmd
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/pkg/errors"
@@ -17,9 +22,13 @@ import (
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/insights"
"github.com/tamakyi/TamaBox/internal/route"
"github.com/tamakyi/TamaBox/internal/security/honeypot"
"github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/internal/stats"
"github.com/tamakyi/TamaBox/internal/tracing"
"github.com/tamakyi/TamaBox/route/question"
)
var Web = &cli.Command{
@@ -67,34 +76,118 @@ func runWeb(ctx *cli.Context) error {
}
conf.Database.DSN = dsn
_, err := db.Init(dbType, dsn)
gdb, err := db.Init(dbType, dsn)
if err != nil {
return errors.Wrap(err, "connect to database")
}
// 退出前把连接池关掉,避免优雅关闭期间连接还在池里挂着。
if sqlDB, dbErr := gdb.DB(); dbErr == nil {
defer func() {
if err := sqlDB.Close(); err != nil {
logrus.WithError(err).Warn("关闭数据库连接池失败")
}
}()
}
// 蜜罐命中记录保留期清理:honeypot_hits 是只追加的审计表,写入完全由
// 外部扫描流量驱动(不可预期、不可节流)。没有自动清理的话,表体积只增不减。
// 保留期复用 honeypot_retain_days(与 CLI prune 同一个口径),开关为 honeypot_auto_clean。
//
// 凭据文件(明文账号密码)也按同一个保留期清理 —— 它们不该比元数据活得更久。
// 这里独立打开一次目录即可:PurgeBefore 只删「整天都早于保留期」的文件,
// 与 route 层那个 Append 用的句柄不会争抢同一天的文件。
// 两个后台任务(保留期清理 / 周期报告)共用一个停止信号,
// defer 必须放在 if 外面 —— 否则「自动清理关掉、报告开着」时这个
// channel 永远不会被关闭,那个 goroutine 就一直挂着。
stopRetention := make(chan struct{})
defer close(stopRetention)
if conf.Security.HoneypotAutoClean {
db.NewHoneypotRetention(db.Honeypots, conf.Security.HoneypotRetainDays, conf.Security.HoneypotMaxRows, 0).Start(stopRetention)
defer close(stopRetention)
var credPurge db.CredPurgeable
if cs, err := honeypot.NewFileCredStore(conf.Security.HoneypotCredDir); err != nil {
logrus.WithError(err).Warn("蜜罐凭据目录不可用,凭据文件将不会被自动清理")
} else {
credPurge = cs
}
db.NewHoneypotRetention(db.Honeypots, credPurge, conf.Security.HoneypotRetainDays, conf.Security.HoneypotMaxRows, 0).Start(stopRetention)
}
// 周期安全汇总(周报 / 月报):把命中按 IP 定位到城市,出地图报告并邮件送达。
// 幂等靠 security_report_logs 的 period_key 唯一索引,所以这里只管定期看一眼
// 「该发的那期发过没」—— 重启、错过时间窗都能正确补发,也不会重复发。
if conf.Security.HoneypotReportEnabled {
if reportRecipient() == "" {
logrus.Warn("已启用安全汇总报告,但 honeypot_report_email 与 security_alert_email 都为空,将无法送达")
}
NewSecurityReportScheduler(gdb).Start(stopRetention)
}
// Redis 配了就必须连得上:session 建在它上面,宕机等于全站 500(含匿名访客)。
// 启动期 fail fast,别让运维在「站点全 500」的现场去猜是不是 Redis。
if conf.Redis.Addr != "" {
if err := shared.PingRedis(conf.Redis.Addr, conf.Redis.Password); err != nil {
return errors.Wrapf(err,
"无法连接 Redis(%s)。session 依赖它,连不上会导致全站 500,启动已中止。"+
"请检查 [redis] addr/password,或清空 addr 改用进程内 session 与缓存",
conf.Redis.Addr)
}
logrus.WithField("addr", conf.Redis.Addr).Info("Redis 连接正常")
} else {
logrus.Info("未配置 [redis] addr:session 与缓存均使用进程内实现(单副本部署可用)")
}
logrus.WithContext(ctx.Context).WithField("external_url", conf.App.ExternalURL).Info("Starting web server")
// 箱主数据面板的访问记录器:异步批量写 box_views,不阻塞页面渲染。
// 密钥复用 server.xsrf_key —— 它已经是启动期强校验过的非空随机串,
// 而且与 CSRF token 的用途不同(这里只做 HMAC 的一次性密钥),不存在混用风险。
insightsStore := db.BoxViews
if insightsStore != nil {
insights.Views = insights.NewRecorder(insightsStore, conf.Server.XSRFKey)
defer insights.Views.Close()
insights.StartMaintenance(ctx.Context, insightsStore)
}
// 站点默认语言初始化(default_lang 配置,未配置时回退 zh-CN)
shared.App.SetLang(conf.App.DefaultLang)
// 分享卡字体:缺字体只会在「第一张分享图被请求」时暴露成 500,而 /share-card 是公开端点。
// 启动时先探一次(顺带把读文件 + 解析的开销挪出请求路径),缺了直接 WARN 并给出补救办法。
if fontPath, err := question.CheckShareCardFont(); err != nil {
logrus.WithError(err).Warn("分享卡字体不可用,/share-card 将返回 500:" +
"装一个 glyf 轮廓的 CJK 字体(apt install fonts-wqy-zenhei)" +
"或把 ttf/otf/ttc 放进 static/fonts/,也可用 [app] font_path 指定")
} else {
logrus.WithField("font", fontPath).Info("分享卡字体已就绪")
}
r := route.New()
r.Use(tracing.Middleware("TamaBox"))
// 全站数据页预热:整页聚合是若干次全表 GROUP BY,若摊在当天第一个访客身上,
// 首屏要白等一秒以上。这里延后几秒在后台先算一次,把等待挪出请求路径。
// 失败只告警:页面在真正被访问时会自己重算并(若仍失败)回退到上一次成功的结果。
go func() {
time.Sleep(3 * time.Second)
if _, err := stats.OverviewData(ctx.Context); err != nil {
logrus.WithError(err).Warn("预热全站统计数据失败")
}
}()
// 最外层挂载 gzip 压缩,并在 http.Server 上配置超时:
// - ReadHeaderTimeout 防止慢速客户端占用连接
// - ReadHeaderTimeout 防止慢速客户端占用连接(slowloris 的防线在这里)
// - ReadTimeout / WriteTimeout 防止读写挂死
// - IdleTimeout 复用 keep-alive 连接
// listen 留空时回退到 ":port"(监听所有网卡);Cloudflare Tunnel 部署建议设为
// "127.0.0.1:<port>",使源站端口仅对本地 cloudflared 可见,即使防火墙误配也无法被外部直连。
//
// ReadTimeout 从 30s 提到 120s:站内允许单图 8MB(storage.MaxUploadBytes),
// 请求体上限 12MB(form.MaxRequestBodySize)。30s 传完 8MB 需要 ≥273 KB/s 上行,
// 而手机 4G 上行常在 1–2 Mbps(125–250 KB/s)—— 一张 8MB 图必然超时,且用户
// 已经写好的提问正文会一起丢掉(POST 失败不回填)。120s 对应约 68 KB/s,
// 偏远地区 3G 也能过。
//
// 放宽 ReadTimeout 不会让 slowloris 重新变得可行:那类攻击卡在「发不完 header」,
// 由 10s 的 ReadHeaderTimeout 拦住;ReadTimeout 只影响读完 header 之后传 body 的时长。
addr := conf.Server.Listen
if addr == "" {
addr = fmt.Sprintf(":%d", conf.Server.Port)
@@ -103,14 +196,57 @@ func runWeb(ctx *cli.Context) error {
Addr: addr,
Handler: route.GzipHandler(r),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
ReadTimeout: 120 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
logrus.WithContext(ctx.Context).WithField("port", conf.Server.Port).Info("Listening on")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return errors.Wrap(err, "start http server")
// 优雅关闭:收到 SIGTERM / SIGINT 后停止接受新连接,给在途请求最多 15 秒收尾。
//
// 改动前是直接 ListenAndServe:Go 对 SIGTERM 的处理就是立即退出进程,
// 于是每次部署 / 重启都会把正在写库的 POST 就地处死,留下
// 「提问已 INSERT、图片还没传完」「回答已写入、配额还没扣」这类半成品。
// systemd Restart=always 与 docker restart 都救不了这个。
runCtx, stop := signal.NotifyContext(ctx.Context, syscall.SIGTERM, os.Interrupt)
var r2WorkerWG sync.WaitGroup
defer func() {
// 先取消 worker 的 context,再等待当前一轮退出,避免无人等待的后台删除。
stop()
r2WorkerWG.Wait()
}()
// db.Init 已成功;R2 未完整配置时此函数直接返回,不影响 Web 启动。
startR2OutboxWorker(runCtx, &r2WorkerWG)
serveErr := make(chan error, 1)
go func() {
err := server.ListenAndServe()
if errors.Is(err, http.ErrServerClosed) {
// 正常的 Shutdown 路径,不算失败
err = nil
}
serveErr <- err
}()
select {
case err := <-serveErr:
// 端口被占 / 监听失败:没有在途请求要收尾,直接返回。
if err != nil {
return errors.Wrap(err, "start http server")
}
return nil
case <-runCtx.Done():
logrus.Info("收到退出信号,正在优雅关闭(最多 15 秒)")
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
logrus.WithError(err).Warn("优雅关闭超时,强制断开剩余连接")
_ = server.Close()
}
logrus.Info("HTTP 服务已关闭")
return nil
}
+214
View File
@@ -0,0 +1,214 @@
// 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 conf
import (
"strings"
"time"
// 容器镜像通常不带 tzdata,import 这个包会把一份时区库编进二进制,
// LoadLocation("Asia/Shanghai") 才不会因为找不到 zoneinfo 而失败。
_ "time/tzdata"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/i18n"
)
// defaultAnnouncementZone 裸时间(不带偏移)的默认解释时区。
// 站点面向中文用户,运维写「10-25 02:00」时心里想的是北京时间,
// 而容器里的 time.Local 是 UTC —— 默认按东八区解释可以避免整体偏移 8 小时。
const defaultAnnouncementZone = "Asia/Shanghai"
// announcementConfig 站内公告([announcement] 段)。
//
// 文案留空即关闭公告;Start / End 留空表示该侧不限。
// 展示窗口是左闭右开 [Start, End):到 end 那一刻公告消失,
// 运维不必为了「准时下线」把时间掐到秒后还担心多挂一分钟。
type announcementConfig struct {
// Content 是默认文案,也是分语言文案缺失时的回退。
Content string `ini:"content"`
ContentZhCN string `ini:"content_zh_cn"`
ContentZhTW string `ini:"content_zh_tw"`
ContentEn string `ini:"content_en"`
ContentJa string `ini:"content_ja"`
Start string `ini:"start"`
End string `ini:"end"`
// Timezone 解释裸时间用的时区名,留空取 defaultAnnouncementZone。
Timezone string `ini:"timezone"`
// 解析后的时间点,零值表示该侧不限。
// ini tag 为 "-":既不参与 ini 解析,也不会被未知键检测登记为合法键名。
StartAt time.Time `ini:"-"`
EndAt time.Time `ini:"-"`
loc *time.Location `ini:"-"`
}
// Active 判断给定时刻是否处于公告展示窗口,并返回该语言的正文。
// 窗口为左闭右开 [Start, End),两侧留空表示不限。
func (a announcementConfig) Active(now time.Time, lang string) (string, bool) {
content := a.contentFor(lang)
if content == "" || !a.inWindow(now) {
return "", false
}
return content, true
}
// contentFor 按语言取文案;该语言没配就回退到 Content。
// Content 也没配则返回空 —— 即「没翻译的语言不显示」,而不是硬塞一门语言。
func (a announcementConfig) contentFor(lang string) string {
switch i18n.Resolve(lang) {
case i18n.LangZhCN:
if a.ContentZhCN != "" {
return a.ContentZhCN
}
case i18n.LangZhTW:
if a.ContentZhTW != "" {
return a.ContentZhTW
}
case i18n.LangEn:
if a.ContentEn != "" {
return a.ContentEn
}
case i18n.LangJa:
if a.ContentJa != "" {
return a.ContentJa
}
}
return a.Content
}
// inWindow 只判断时间窗,不看语言 —— 启动日志要知道「现在到点了吗」。
func (a announcementConfig) inWindow(now time.Time) bool {
if !a.StartAt.IsZero() && now.Before(a.StartAt) {
return false
}
if !a.EndAt.IsZero() && !now.Before(a.EndAt) {
return false
}
return true
}
// hasContent 报告是否配了任何语言的文案。
// 只看 Content 会误判「只写了英文文案」为未配置,进而把整条公告关掉。
func (a announcementConfig) hasContent() bool {
return a.Content != "" || a.ContentZhCN != "" || a.ContentZhTW != "" ||
a.ContentEn != "" || a.ContentJa != ""
}
// configuredLanguages 列出真正配了文案的语言,供启动日志核对。
func (a announcementConfig) configuredLanguages() []string {
var langs []string
if a.Content != "" {
langs = append(langs, "fallback")
}
for _, l := range []struct{ code, content string }{
{i18n.LangZhCN, a.ContentZhCN},
{i18n.LangZhTW, a.ContentZhTW},
{i18n.LangEn, a.ContentEn},
{i18n.LangJa, a.ContentJa},
} {
if l.content != "" {
langs = append(langs, l.code)
}
}
return langs
}
// 支持的时间格式。裸时间(不带时区偏移)按 [announcement] timezone 解释。
var announcementTimeLayouts = []string{
"2006-01-02 15:04",
"2006-01-02 15:04:05",
"2006-01-02",
time.RFC3339,
}
// sanitizeAnnouncement 解析公告时间窗。
//
// 时间写错只告警并关闭公告,不阻断启动:公告是展示性配置,拼错一个日期不该让整站
// 502;但必须留下能定位的日志 —— 否则现象就是「配了却不显示」,和未知键被静默
// 忽略属于同一类坑(见 unknown.go)。
func sanitizeAnnouncement() {
Announcement.Content = strings.TrimSpace(Announcement.Content)
Announcement.ContentZhCN = strings.TrimSpace(Announcement.ContentZhCN)
Announcement.ContentZhTW = strings.TrimSpace(Announcement.ContentZhTW)
Announcement.ContentEn = strings.TrimSpace(Announcement.ContentEn)
Announcement.ContentJa = strings.TrimSpace(Announcement.ContentJa)
if !Announcement.hasContent() {
return
}
disable := func(key, value, reason string) {
logrus.WithFields(logrus.Fields{
"section": "announcement",
"key": key,
"value": value,
"reason": reason,
}).Warn("公告配置无效,已关闭公告(站点其它功能不受影响)")
Announcement = announcementConfig{}
}
loc, err := loadAnnouncementLocation(Announcement.Timezone)
if err != nil {
disable("timezone", Announcement.Timezone, "不是合法的 IANA 时区名(如 Asia/Shanghai / UTC)")
return
}
Announcement.loc = loc
var ok bool
if Announcement.StartAt, ok = parseAnnouncementTime(Announcement.Start, loc); !ok {
disable("start", Announcement.Start, "支持 2006-01-02 15:04 / 2006-01-02 15:04:05 / 2006-01-02 / RFC3339")
return
}
if Announcement.EndAt, ok = parseAnnouncementTime(Announcement.End, loc); !ok {
disable("end", Announcement.End, "支持 2006-01-02 15:04 / 2006-01-02 15:04:05 / 2006-01-02 / RFC3339")
return
}
if !Announcement.StartAt.IsZero() && !Announcement.EndAt.IsZero() && !Announcement.EndAt.After(Announcement.StartAt) {
disable("end", Announcement.End, "end 必须晚于 start,否则公告永远不会展示")
return
}
logrus.WithFields(logrus.Fields{
"zone": loc.String(),
"languages": strings.Join(Announcement.configuredLanguages(), ","),
"start": announcementBoundLabel(Announcement.StartAt),
"end": announcementBoundLabel(Announcement.EndAt),
"active_now": Announcement.inWindow(time.Now()),
}).Info("站内公告已配置")
}
// loadAnnouncementLocation 解析时区名;空值取默认时区(东八区)。
func loadAnnouncementLocation(name string) (*time.Location, error) {
name = strings.TrimSpace(name)
if name == "" {
name = defaultAnnouncementZone
}
return time.LoadLocation(name)
}
// parseAnnouncementTime 解析单个时间点;空串返回零值(表示不限)且视为合法。
// 带偏移的 RFC3339 以自带偏移为准,loc 只用于裸时间。
func parseAnnouncementTime(raw string, loc *time.Location) (time.Time, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, true
}
for _, layout := range announcementTimeLayouts {
if t, err := time.ParseInLocation(layout, raw, loc); err == nil {
return t, true
}
}
return time.Time{}, false
}
// announcementBoundLabel 把时间点格式化成带时区偏移的字符串,供启动日志核对
// 「我写的 10:00 到底被当成了几点」—— 裸时间按 [announcement] timezone 解释。
func announcementBoundLabel(t time.Time) string {
if t.IsZero() {
return "不限"
}
return t.Format("2006-01-02 15:04:05 -07:00")
}
+243
View File
@@ -0,0 +1,243 @@
// 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 conf
import (
"testing"
"time"
"github.com/tamakyi/TamaBox/internal/i18n"
)
// setAnnouncement 在测试期间覆盖全局公告配置,结束后恢复。
// 不设 Timezone,即走默认的东八区。
func setAnnouncement(t *testing.T, content, start, end string) {
t.Helper()
old := Announcement
t.Cleanup(func() { Announcement = old })
Announcement = announcementConfig{Content: content, Start: start, End: end}
sanitizeAnnouncement()
}
// setAnnouncementConfig 用完整配置覆盖,供分语言 / 时区用例使用。
func setAnnouncementConfig(t *testing.T, cfg announcementConfig) {
t.Helper()
old := Announcement
t.Cleanup(func() { Announcement = old })
Announcement = cfg
sanitizeAnnouncement()
}
func mustParseIn(t *testing.T, layout, value string) time.Time {
t.Helper()
loc, err := time.LoadLocation(defaultAnnouncementZone)
if err != nil {
t.Fatalf("load zone %s: %v", defaultAnnouncementZone, err)
}
parsed, err := time.ParseInLocation(layout, value, loc)
if err != nil {
t.Fatalf("parse %q: %v", value, err)
}
return parsed
}
func TestAnnouncementDisabledWhenContentEmpty(t *testing.T) {
setAnnouncement(t, " ", "2026-10-24 00:00", "2026-10-26 00:00")
if _, ok := Announcement.Active(time.Now(), i18n.LangZhCN); ok {
t.Fatal("所有语言文案都为空时公告应关闭")
}
}
func TestAnnouncementWindowIsHalfOpen(t *testing.T) {
setAnnouncement(t, "维护通知", "2026-10-24 00:00", "2026-10-26 00:00")
start := mustParseIn(t, "2006-01-02 15:04", "2026-10-24 00:00")
end := mustParseIn(t, "2006-01-02 15:04", "2026-10-26 00:00")
cases := []struct {
name string
now time.Time
want bool
}{
{"开始前一秒", start.Add(-time.Second), false},
{"开始时刻(闭区间)", start, true},
{"窗口内", start.Add(12 * time.Hour), true},
{"结束时刻(开区间)", end, false},
{"结束后", end.Add(time.Hour), false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
content, ok := Announcement.Active(c.now, i18n.LangZhCN)
if ok != c.want {
t.Fatalf("Active(%s) = %v, want %v", c.now, ok, c.want)
}
if ok && content != "维护通知" {
t.Fatalf("content = %q, want 维护通知", content)
}
})
}
}
// 只写一侧的边界必须保持「该侧不限」,不能因为另一侧为空就整体失效。
func TestAnnouncementOneSidedWindow(t *testing.T) {
now := mustParseIn(t, "2006-01-02 15:04", "2026-10-22 12:00")
setAnnouncement(t, "只有开始", "2026-10-23 00:00", "")
if _, ok := Announcement.Active(now, i18n.LangZhCN); ok {
t.Fatal("只写 start 且尚未到点时应不展示")
}
if _, ok := Announcement.Active(now.Add(48*time.Hour), i18n.LangZhCN); !ok {
t.Fatal("只写 start 时到点后应一直展示")
}
setAnnouncement(t, "只有结束", "", "2026-10-23 00:00")
if _, ok := Announcement.Active(now, i18n.LangZhCN); !ok {
t.Fatal("只写 end 且未到点时应立即展示")
}
if _, ok := Announcement.Active(now.Add(48*time.Hour), i18n.LangZhCN); ok {
t.Fatal("只写 end 时过点后应下线")
}
setAnnouncement(t, "一直展示", "", "")
if _, ok := Announcement.Active(now.AddDate(5, 0, 0), i18n.LangZhCN); !ok {
t.Fatal("起止都留空时应一直展示")
}
}
// 分语言文案:配了就用自己的,没配回退 content;content 也没配则该语言不显示。
func TestAnnouncementLanguageSelection(t *testing.T) {
setAnnouncementConfig(t, announcementConfig{
Content: "fallback",
ContentZhCN: "简中",
ContentEn: "english",
ContentJa: "日本語",
})
cases := []struct{ lang, want string }{
{i18n.LangZhCN, "简中"},
{i18n.LangEn, "english"},
{i18n.LangJa, "日本語"},
{i18n.LangZhTW, "fallback"}, // 没配繁中 → 回退默认文案
{"en-US", "english"}, // Resolve 会归一化
}
for _, c := range cases {
got, ok := Announcement.Active(time.Now(), c.lang)
if !ok || got != c.want {
t.Fatalf("Active(lang=%s) = %q ok=%v, want %q", c.lang, got, ok, c.want)
}
}
// 只配英文、content 留空:简中访客不应看到英文公告。
setAnnouncementConfig(t, announcementConfig{ContentEn: "english only"})
if _, ok := Announcement.Active(time.Now(), i18n.LangZhCN); ok {
t.Fatal("该语言未配文案且无默认文案时不应展示")
}
if got, ok := Announcement.Active(time.Now(), i18n.LangEn); !ok || got != "english only" {
t.Fatalf("英文应正常展示,got %q ok=%v", got, ok)
}
}
func TestAnnouncementTimeFormats(t *testing.T) {
loc, err := time.LoadLocation(defaultAnnouncementZone)
if err != nil {
t.Fatalf("load zone: %v", err)
}
// 带偏移的 RFC3339 必须按字面时刻解析,而不是再套一层默认时区。
at, ok := parseAnnouncementTime("2026-10-25T02:00:00+08:00", loc)
if !ok {
t.Fatal("RFC3339 应可解析")
}
want := time.Date(2026, 10, 24, 18, 0, 0, 0, time.UTC)
if !at.Equal(want) {
t.Fatalf("解析结果 = %s, want %s", at.UTC(), want)
}
if at, ok := parseAnnouncementTime("2026-10-25", loc); !ok || at.Hour() != 0 {
t.Fatalf("纯日期应解析为当天 00:00,got %s ok=%v", at, ok)
}
if at, ok := parseAnnouncementTime("2026-10-25 02:00:59", loc); !ok || at.Second() != 59 {
t.Fatalf("秒级格式应可解析,got %s ok=%v", at, ok)
}
if _, ok := parseAnnouncementTime("2026/10/25 02:00", loc); ok {
t.Fatal("错误格式应解析失败")
}
if _, ok := parseAnnouncementTime("2026-13-01", loc); ok {
t.Fatal("非法月份应解析失败")
}
}
// 裸时间必须按东八区解释:运维写「10-25 02:00」指北京时间,
// 而容器里的 time.Local 是 UTC(旧实现整体偏 8 小时)。
func TestAnnouncementBareTimeUsesBeijing(t *testing.T) {
setAnnouncement(t, "维护通知", "2026-10-25 02:00", "")
want := time.Date(2026, 10, 24, 18, 0, 0, 0, time.UTC) // 北京 10-25 02:00
if !Announcement.StartAt.Equal(want) {
t.Fatalf("裸时间应按 +08:00 解释,got %s, want %s", Announcement.StartAt.UTC(), want)
}
if _, offset := Announcement.StartAt.Zone(); offset != 8*60*60 {
t.Fatalf("解析后的偏移 = %d 秒, want 28800", offset)
}
}
// timezone 显式指定时以它为准,不能继续套东八区。
func TestAnnouncementTimezoneOverride(t *testing.T) {
setAnnouncementConfig(t, announcementConfig{
Content: "维护通知",
Start: "2026-10-25 00:00",
Timezone: "UTC",
})
want := time.Date(2026, 10, 25, 0, 0, 0, 0, time.UTC)
if !Announcement.StartAt.Equal(want) {
t.Fatalf("timezone=UTC 时应按 UTC 解析,got %s", Announcement.StartAt.UTC())
}
setAnnouncementConfig(t, announcementConfig{
Content: "维护通知",
Start: "2026-10-25 00:00",
Timezone: "America/New_York",
})
// 纽约 10-25 00:00 = UTC 10-25 04:00(EDT,UTC-4)
wantNY := time.Date(2026, 10, 25, 4, 0, 0, 0, time.UTC)
if !Announcement.StartAt.Equal(wantNY) {
t.Fatalf("timezone=America/New_York 解析错误,got %s", Announcement.StartAt.UTC())
}
}
// 时区名写错和日期写错一样:只关公告,不阻断启动。
func TestAnnouncementInvalidTimezoneDisables(t *testing.T) {
setAnnouncementConfig(t, announcementConfig{
Content: "维护通知",
Start: "2026-10-25 00:00",
Timezone: "Mars/Olympus",
})
if Announcement.Content != "" {
t.Fatal("时区无效时公告应被关闭")
}
}
// 时间写错只关公告,不能让站点起不来;同时必须清空解析结果,避免残留时间窗。
func TestAnnouncementInvalidTimeDisables(t *testing.T) {
for _, c := range []struct{ name, start, end string }{
{"start 格式错", "2026/10/25 02:00", ""},
{"end 格式错", "", "10月26日"},
{"end 早于 start", "2026-10-26 00:00", "2026-10-25 00:00"},
{"end 等于 start", "2026-10-25 00:00", "2026-10-25 00:00"},
} {
t.Run(c.name, func(t *testing.T) {
setAnnouncement(t, "内容", c.start, c.end)
if Announcement.Content != "" {
t.Fatal("配置无效时公告应被关闭")
}
if !Announcement.StartAt.IsZero() || !Announcement.EndAt.IsZero() {
t.Fatal("配置无效时应清空已解析的时间点")
}
})
}
}
+9
View File
@@ -102,10 +102,19 @@ func Init() error {
return errors.Wrap(err, "map 'theme'")
}
if err := File.Section("announcement").MapTo(&Announcement); err != nil {
return errors.Wrap(err, "map 'announcement'")
}
sanitizeAnnouncement()
if err := File.Section("mail").MapTo(&Mail); err != nil {
return errors.Wrap(err, "map 'mail'")
}
if err := File.Section("notify").MapTo(&Notify); err != nil {
return errors.Wrap(err, "map 'notify'")
}
serviceSections := File.Section("service").ChildSections()
for _, serviceSection := range serviceSections {
serviceSection := serviceSection
+61 -2
View File
@@ -5,6 +5,8 @@
package conf
import (
"strings"
"github.com/sirupsen/logrus"
)
@@ -26,9 +28,28 @@ const (
// DefaultHoneypotMaxRows 蜜罐记录默认行数上限。
// 实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
DefaultHoneypotMaxRows = 200000
// DefaultHoneypotThreshold 蜜罐命中多少次即封禁。默认 1(一次即封):
// 蜜罐路径与真实业务路由零交集,正常访客不可能命中。
// DefaultHoneypotThreshold 「提交凭据」多少次即封禁。默认 1:
// 往假后台里填账号密码是证据最强的行为,抓到一次就足够。
// 纯路径探测不走这个阈值(见 route.honeypotHandler.Serve)。
DefaultHoneypotThreshold = 1
// DefaultHoneypotBanMinutes 蜜罐封禁的默认时长(分钟)。
//
// 刻意远长于常规 ban_duration_minutes(15 分钟):蜜罐命中的不是「访问了一个
// 可疑路径」,而是「往假后台里填了账号密码」—— 对方已经付出破解成本、
// 且拿到了一次响应(知道「后台存在」)。15 分钟不足以让对方放弃,
// 24 小时能覆盖一轮扫描活动周期。
DefaultHoneypotBanMinutes = 1440
// DefaultHoneypotCredDir 凭据取证文件的默认目录(相对进程工作目录)。
//
// 不放在数据库所在目录:这两者的权限、备份策略、留存周期都应该分开管。
DefaultHoneypotCredDir = "data/honeypot-creds"
// DefaultIPGeoProvider 默认的地理定位数据源:auto(ip-api 打底 + 腾讯精修)。
DefaultIPGeoProvider = "auto"
// DefaultHoneypotReportDir 安全汇总报告文件的默认目录。
//
// 与凭据目录分开:报告里有攻击者 IP 明细,但它是可以外发/归档的材料;
// 凭据目录里是明文账号密码,永不外发。放在一起容易误打包。
DefaultHoneypotReportDir = "data/reports"
)
// sanitizeHoneypot 归一化蜜罐相关配置,修正「未配置」与「显式关闭」的歧义。
@@ -78,4 +99,42 @@ func sanitizeHoneypot() {
if !sec.HasKey("honeypot_threshold") {
Security.HoneypotThreshold = DefaultHoneypotThreshold
}
// 封禁时长:缺省 24 小时(见 DefaultHoneypotBanMinutes 的理由)。
// 显式写 <= 0 是逃生口:交给 autoban 回退到 ban_duration_minutes,
// 但那样「蜜罐按常规时长封」的意图不明显,故明确告警。
if !sec.HasKey("honeypot_ban_minutes") {
Security.HoneypotBanMinutes = DefaultHoneypotBanMinutes
} else if Security.HoneypotBanMinutes <= 0 {
logrus.WithField("fallback_key", "ban_duration_minutes").
Warn("honeypot_ban_minutes <= 0 已忽略,蜜罐封禁将回退为 ban_duration_minutes")
}
// 凭据文件目录:未配置或留空都用默认值。
// 这里不回退「不保存」——用户显式开启蜜罐就是想要取证,
// 目录写错会在 route 层触发「不可用 → 不保存凭据」的告警,而不是静默丢弃。
if strings.TrimSpace(Security.HoneypotCredDir) == "" {
Security.HoneypotCredDir = DefaultHoneypotCredDir
}
// 地理定位数据源:未配置 → auto。
// 显式写了不认识的值时保持原样,由 geoip 层解析并告警 ——
// 在配置层悄悄改成 auto 会让人以为配置生效了。
if strings.TrimSpace(Security.IPGeoProvider) == "" {
Security.IPGeoProvider = DefaultIPGeoProvider
}
// 周期报告:三个开关都用 HasKey 判定缺省值(同 HoneypotAutoClean 的理由)。
if !sec.HasKey("honeypot_report_enabled") {
Security.HoneypotReportEnabled = true
}
if !sec.HasKey("honeypot_report_weekly") {
Security.HoneypotReportWeekly = true
}
if !sec.HasKey("honeypot_report_monthly") {
Security.HoneypotReportMonthly = true
}
if strings.TrimSpace(Security.HoneypotReportDir) == "" {
Security.HoneypotReportDir = DefaultHoneypotReportDir
}
}
+25 -12
View File
@@ -50,6 +50,18 @@ var keyAliases = map[string]string{
"honeypot_retentionday": "honeypot_retain_days",
"honeypot_keep_days": "honeypot_retain_days",
"honeypot_retain_max_days": "honeypot_retain_days",
// 地理定位与周期报告:这几个键都是新增的,最容易被写成不同的词。
"ipgeo_provider": "ip_geo_provider",
"ip_geolocation_provider": "ip_geo_provider",
"geo_provider": "ip_geo_provider",
"ip_geo_key": "ip_geo_tencent_key",
"ip_geo_tencent_apikey": "ip_geo_tencent_key",
"tencent_map_key": "ip_geo_tencent_key",
"honeypot_report": "honeypot_report_enabled",
"honeypot_report_mail": "honeypot_report_email",
"honeypot_report_to": "honeypot_report_email",
"honeypot_reports_dir": "honeypot_report_dir",
"honeypot_report_path": "honeypot_report_dir",
}
// normalizeKeyName 归一化配置键名。
@@ -143,18 +155,19 @@ func legalKeysOf(v interface{}) map[string]bool {
// 枚举,为避免误报这里不登记(漏报优于误报)。
func sectionKeySets() map[string]map[string]bool {
return map[string]map[string]bool{
"app": legalKeysOf(&App),
"theme": legalKeysOf(&Theme),
"security": legalKeysOf(&Security),
"server": legalKeysOf(&Server),
"database": legalKeysOf(&Database),
"redis": legalKeysOf(&Redis),
"recaptcha": legalKeysOf(&Recaptcha),
"pixel": legalKeysOf(&Pixel),
"upload": legalKeysOf(&Upload),
"limits": legalKeysOf(&Limits),
"mail": legalKeysOf(&Mail),
"service": legalKeysOf(&Service),
"app": legalKeysOf(&App),
"theme": legalKeysOf(&Theme),
"announcement": legalKeysOf(&Announcement),
"security": legalKeysOf(&Security),
"server": legalKeysOf(&Server),
"database": legalKeysOf(&Database),
"redis": legalKeysOf(&Redis),
"recaptcha": legalKeysOf(&Recaptcha),
"pixel": legalKeysOf(&Pixel),
"upload": legalKeysOf(&Upload),
"limits": legalKeysOf(&Limits),
"mail": legalKeysOf(&Mail),
"service": legalKeysOf(&Service),
}
}
+58
View File
@@ -236,6 +236,7 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
wantMaxRows int
wantAutoClean bool
wantThreshold int
wantBan int
}{
{
name: "全未配置时回退安全默认值",
@@ -244,6 +245,7 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
wantMaxRows: DefaultHoneypotMaxRows,
wantAutoClean: true,
wantThreshold: DefaultHoneypotThreshold,
wantBan: DefaultHoneypotBanMinutes,
},
{
name: "显式关闭自动清理被尊重",
@@ -252,6 +254,7 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
wantMaxRows: DefaultHoneypotMaxRows,
wantAutoClean: false,
wantThreshold: DefaultHoneypotThreshold,
wantBan: DefaultHoneypotBanMinutes,
},
{
name: "显式保留期被采用",
@@ -260,6 +263,7 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
wantMaxRows: DefaultHoneypotMaxRows,
wantAutoClean: true,
wantThreshold: DefaultHoneypotThreshold,
wantBan: DefaultHoneypotBanMinutes,
},
{
name: "保留期写成 0 回退默认而非永不清理",
@@ -268,6 +272,7 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
wantMaxRows: DefaultHoneypotMaxRows,
wantAutoClean: true,
wantThreshold: DefaultHoneypotThreshold,
wantBan: DefaultHoneypotBanMinutes,
},
{
name: "行数上限显式 0 表示不限",
@@ -276,6 +281,27 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
wantMaxRows: 0,
wantAutoClean: true,
wantThreshold: DefaultHoneypotThreshold,
wantBan: DefaultHoneypotBanMinutes,
},
{
name: "显式封禁时长被采用",
src: "[security]\nhoneypot_ban_minutes = 90\n",
wantRetain: DefaultHoneypotRetainDays,
wantMaxRows: DefaultHoneypotMaxRows,
wantAutoClean: true,
wantThreshold: DefaultHoneypotThreshold,
wantBan: 90,
},
{
// 0 是逃生口:留着让 autoban 回退 ban_duration_minutes,
// sanitize 不做覆盖(只告警),故期望值仍是 0。
name: "封禁时长写 0 交给 autoban 回退 ban_duration_minutes",
src: "[security]\nhoneypot_ban_minutes = 0\n",
wantRetain: DefaultHoneypotRetainDays,
wantMaxRows: DefaultHoneypotMaxRows,
wantAutoClean: true,
wantThreshold: DefaultHoneypotThreshold,
wantBan: 0,
},
}
@@ -290,6 +316,7 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
Security.HoneypotMaxRows = 0
Security.HoneypotAutoClean = false
Security.HoneypotThreshold = 0
Security.HoneypotBanMinutes = 0
File = f
if err := f.Section("security").MapTo(&Security); err != nil {
t.Fatalf("map security: %v", err)
@@ -308,6 +335,9 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
if Security.HoneypotThreshold != c.wantThreshold {
t.Errorf("HoneypotThreshold = %d, want %d", Security.HoneypotThreshold, c.wantThreshold)
}
if Security.HoneypotBanMinutes != c.wantBan {
t.Errorf("HoneypotBanMinutes = %d, want %d", Security.HoneypotBanMinutes, c.wantBan)
}
})
}
}
@@ -358,6 +388,34 @@ func TestSampleIniHasNoUnknownKeys(t *testing.T) {
}
}
// TestSampleIniValuesHaveNoInlineComment 守住「行尾注释会被并入值」的陷阱。
//
// conf.Init 用 ini.LoadOptions{IgnoreInlineComment: true},该选项的语义是
// **不剥离**行尾注释(实测 `port = 1440 ; 注释` → 值 "1440 ; 注释",
// int 解析失败,而 MapTo 仍然返回 nil error)。后果是该配置项静默回退零值,
// 日志里只会说「配置值不对」,根本联想不到是注释造成的。
//
// 样例配置曾经全线是 `key = value ; 注释` 的写法:text_censor_source 因此
// 让 conf.Init 直接拒绝启动,ip_blacklist / security_alert_email 变成带注释的
// 垃圾字符串,honeypot_max_rows / honeypot_auto_clean 静默失效。
//
// 直接检查每个键的值里没有 ';',比逐个字段断言更彻底,也不会漏掉新加的配置项。
func TestSampleIniValuesHaveNoInlineComment(t *testing.T) {
path := filepath.Join("..", "..", "conf", "app.sample.ini")
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, path)
if err != nil {
t.Skipf("跳过:无法读取样例配置 %s(%v)", path, err)
}
for _, sec := range f.Sections() {
for _, k := range sec.Keys() {
if strings.Contains(k.Value(), ";") {
t.Errorf("样例配置 [%s] %s 的值含 ';':%q —— 行尾注释会被并入值,该项将静默失效",
sec.Name(), k.Name(), k.Value())
}
}
}
}
// TestNormalizeAllSectionKeysEndToEnd 走完整链路:
// 加载带错拼键的配置 → 归一化 → MapTo → 拿到正确的保留期。
//
+104 -8
View File
@@ -10,7 +10,7 @@ var (
BuildCommit = "dev"
// StaticVersion 静态资源缓存版本号:base.css 与主题 css 共用,改样式只改这一处。
StaticVersion = "20261020g"
StaticVersion = "20261022c"
)
// 文本审核通道取值([security] text_censor_source)。定义在 conf 侧,
@@ -47,6 +47,9 @@ var (
Available string `ini:"available"`
}
// Announcement 站内公告([announcement] 段)。解析与展示判定见 announcement.go。
Announcement announcementConfig
Security struct {
EnableTextCensor bool `ini:"enable_text_censor"`
IPBlacklist string `ini:"ip_blacklist"`
@@ -54,11 +57,15 @@ var (
// TrustProxy 为 true 时信任 CDN 权威客户端头 CF-Connecting-IP / EO-Client-IP
// (以及受信代理设置的 X-Real-IP / X-Forwarded-For);否则一律用 RemoteAddr,
// 防止伪造 header 绕过速率限制与 IP 黑名单。
// ⚠️ 权威头只在 TCP 对端可信(回环,或命中 TrustedProxies)时才会被采信 ——
// 见 shared.ResolveClientIP:公网对端带来的权威头一律忽略(P1-3 修复)。
TrustProxy bool `ini:"trust_proxy"`
// TrustedProxies 逗号分隔的 IP / CIDR,仅限自己的反向代理 / LB。仅当 TCP 对端
// 落在该清单内才解析 X-Forwarded-For / X-Real-IP;留空则这两个头永不被信任
// (CDN 权威头仍会采用,前提是源站已由 Cloudflare Tunnel / 回源网段锁定,
// 从而免于维护上游 CDN 的庞大 CIDR 清单)。
// 落在该清单内才解析 X-Forwarded-For / X-Real-IP;留空则这两个头永不被信任。
// 回环地址(127.0.0.1 / ::1)无需登记即视为可信对端:cloudflared / 本机 nginx
// 回源的连接必然由本机进程发起,外部无法伪造。
// CDN 直连回源(非 Tunnel)的部署形态下,必须把该 CDN 的回源网段登记到这里,
// 否则它的权威头会被忽略、客户端 IP 退化为 CDN 边缘 IP。
TrustedProxies string `ini:"trusted_proxies"`
EnableAutoBan bool `ini:"enable_auto_ban"` // fail2ban 风格:检测到扫描特征后临时封禁来源 IP
// BanTrackNotFound 是否把未注册路径的 404 计入封禁统计。默认 false:仅命中后台
@@ -90,7 +97,7 @@ var (
// 会被 ini 静默忽略 —— 现场踩过这个坑,表现为「自动清理未启用」。
// 缺省 30 天(见 sanitizeHoneypot);显式 <= 0 也回退 30。
// 要关闭自动清理请用 honeypot_auto_clean = false。
HoneypotRetainDays int `ini:"honeypot_retain_days"`
HoneypotRetainDays int `ini:"honeypot_retain_days"`
// HoneypotAutoClean 是否启用蜜罐记录的后台自动保留期清理。
// 缺省 true:honeypot_hits 是只追加的审计表,写入完全由外部扫描流量驱动,
// 没有自动清理的话表体积只增不减(这是「数据库被扫描撑爆」的真实路径)。
@@ -103,13 +110,90 @@ var (
// 保留期是时间维度的上界,但攻击者可以用海量源 IP 在保留期内把表撑爆,
// 这是第二道闸。实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
HoneypotMaxRows int `ini:"honeypot_max_rows"`
// HoneypotThreshold 窗口内蜜罐命中多少次即封禁。默认 1(一次即封):
// 蜜罐路径与真实业务路由零交集,正常访客不可能命中,一次即足以判定扫描器。
// HoneypotThreshold 蜜罐「提交凭据」多少次即封禁。默认 1(一次即封):
// 往假后台里填账号密码是证据最强的行为,抓到一次就足够。
// ⚠️ 它不作用于纯路径探测(GET 看一眼假登录页)—— 那类请求走常规
// ban_threshold_sensitive,否则扫描器 GET 一次就被封,永远走不到
// POST 提交凭据那一步,蜜罐也就拿不到字典。
// 缺省回退 1(见 sanitizeHoneypot)。
HoneypotThreshold int `ini:"honeypot_threshold"`
// HoneypotBanMinutes 蜜罐封禁时长(分钟)。留空则复用 ban_duration_minutes。
// HoneypotBanMinutes 蜜罐封禁时长(分钟)。缺省 1440(24 小时),
// 刻意远长于 ban_duration_minutes:命中蜜罐意味着对方已在提交账号密码,
// 证据强度高于单纯路径探测(见 DefaultHoneypotBanMinutes)。
// 显式 <= 0 表示回退 ban_duration_minutes。
HoneypotBanMinutes int `ini:"honeypot_ban_minutes"`
// HoneypotCredDir 凭据取证文件的存放目录。
//
// ⚠️ 捕获到的用户名/密码**不入库**,只追加到这个目录下的按天文件
// (creds-YYYY-MM-DD.jsonl,一行一条 JSON)。原因:明文账号密码一旦入库,
// 就会跟着进备份、主从复制和任何能连库的报表工具,事后无法彻底清除。
// 目录会被强制为 0700、文件 0600;留空使用默认 data/honeypot-creds。
// 目录不可写时只降级为「不保存凭据」,不会影响站点启动。
HoneypotCredDir string `ini:"honeypot_cred_dir"`
// BanImagePath 被封禁时返回的图片路径(本地文件,相对路径以进程工作目录为基准)。
//
// 留空、文件读不到、或内容不是图片时,一律回退为纯文字 "403 Forbidden" ——
// 这条路径绝不能因为一张图把「拒绝」响应弄坏,更不能让缺失的资源变成 500。
//
// 只接受 image/* 的内容:如果配成 app.ini 之类的文件会被拒绝并告警,
// 否则等于把配置文件当响应体吐给每一个被封的攻击者。
BanImagePath string `ini:"ban_image_path"`
SecurityAlertEmail string `ini:"security_alert_email"` // 触发封禁时告警收件人(留空不发送)
// ⚠️ security_alert_email 也是蜜罐告警邮件的收件人,而蜜罐邮件正文
// **含明文账号密码**(这是功能核心价值:运维立刻拿到攻击者正在用的字典)。
// 因此这里必须是**个人邮箱**,禁止填群组邮箱 / 分发列表 / 团队共用邮箱——
// 否则等于把攻击者的破解字典广播给无关人员,且经 SMTP 中转后明文密码
// 会留在第三方邮件系统的日志/归档里,超出本地 0600 取证文件的控制范围。
// 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"`
// IPGeoProvider 蜜罐/扫描来源 IP 的地理定位数据源。
//
// ⚠️ 与 ipinfo.Lookup(cdncheck 离线指纹)是两件事:那套只认云厂商/CDN/WAF
// 的 ASN,肉鸡(家宽、企业宽带、移动网络)一律显示「未识别(普通网络)」。
// 这里回答的是「这个 IP 在哪儿」,靠的是外部 IP 库。
//
// auto(缺省):ip-api.com 批量打底(全球覆盖、无需 key、一次 100 个),
// 配了 ip_geo_tencent_key 时再用腾讯位置服务精修国内 IP
// (省市 + GCJ-02 坐标)。腾讯的 IP 定位是一 IP 一请求,
// 拿它做批量主干会把 N 个 IP 变成 N 次 HTTP,故只做精修。
// ipapi:只用 ip-api.com
// tencent:只用腾讯位置服务(要求配 key,否则降级为 ipapi)
// none:不查询,报告里只给 IP 不提地理信息(攻击者 IP 一个字节都不外发)
//
// 隐私边界:auto/ipapi 会把攻击者 IP 发给 ip-api.com,tencent 会发给腾讯。
// 本报告只处理「攻击者」地址,不涉及任何用户 IP;不接受这一点请用 none。
IPGeoProvider string `ini:"ip_geo_provider"`
// IPGeoTencentKey 腾讯位置服务 key(lbs.qq.com 申请)。留空则跳过腾讯精修。
// 仅用于服务端 HTTP 请求,不下发到前端 —— 与地图 JS 用的 key 不是一回事。
IPGeoTencentKey string `ini:"ip_geo_tencent_key"`
// HoneypotReportEnabled 是否启用周期性安全状况汇总(周报 / 月报)邮件。
// 缺省 true:报告本身是运维最需要的「有没有人在打、从哪儿打」,
// 而收件人缺失时会 WARN 跳过,不会静默。
HoneypotReportEnabled bool `ini:"honeypot_report_enabled"`
// HoneypotReportWeekly / Monthly 分别控制周报与月报,缺省都开。
HoneypotReportWeekly bool `ini:"honeypot_report_weekly"`
HoneypotReportMonthly bool `ini:"honeypot_report_monthly"`
// HoneypotReportEmail 报告收件人。留空回退 security_alert_email。
// 单独开一个键是为了能把「随时可能发生的封禁告警」与「定期汇总」
// 分投到不同邮箱(例如告警进手机推送邮箱、汇总进归档邮箱)。
HoneypotReportEmail string `ini:"honeypot_report_email"`
// HoneypotReportDir 报告文件(HTML + PNG)的存放目录,缺省 data/reports。
// 报告里有攻击者 IP 明细,目录权限按 0700 创建。
HoneypotReportDir string `ini:"honeypot_report_dir"`
}
Server struct {
@@ -173,6 +257,18 @@ var (
SkipTLSVerify bool `ini:"skip_tls_verify"` // 仅自签名 / 内网 SMTP 需要
}
// Notify 站级通知通道配置([notify] 段)。与 [mail] 的「邮件给具体收件人」不同,
// 这两个是「广播式」通道:
// - WebhookURL:通用 Webhook(Discord / Slack / 企业微信群机器人等),站级事件
// (新提问 / 新回答 / 新追问 / 新私聊 / 新投票 / 安全告警 / 举报)统一推送。
// - TelegramBotToken:Telegram Bot token,配合每个箱主自填的 chat_id 实现
// 「新提问」点对点通知。
// 两者都可留空:留空的通道对应发送静默跳过(不影响邮件等其他通道)。
Notify struct {
WebhookURL string `ini:"webhook_url"`
TelegramBotToken string `ini:"telegram_bot_token"`
}
Service struct {
Backends []struct {
Prefix string `ini:"prefix"`
+15 -3
View File
@@ -66,7 +66,10 @@ func (c *Context) HasError() bool {
if !ok {
return false
}
return hasErr.(bool)
// 带 ok 的断言:Data 是 map[string]interface{},任何写入方都可能塞进非 bool 值,
// 裸断言遇到那种情况会直接 panic 掉整个请求。非 bool 一律视为「无错误」。
v, isBool := hasErr.(bool)
return isBool && v
}
func (c *Context) SetError(err error, f ...interface{}) {
@@ -135,6 +138,11 @@ func (c *Context) Refresh() {
}
func (c *Context) JSON(data interface{}) error {
// 显式声明内容类型:之前靠 Go 的 sniff 兜底,实测返回的是 text/plain。
// 有 nosniff 所以不会被当 HTML 渲染,但按 Content-Type 分派的客户端
// (fetch 的 .json()、axios、部分 SDK)会因此走错分支。
c.ResponseWriter().Header().Set("Content-Type", "application/json; charset=utf-8")
resp := map[string]interface{}{
"code": 0,
"data": data,
@@ -149,6 +157,9 @@ func (c *Context) ServerError() error {
}
func (c *Context) JSONError(errorCode int, message string) error {
// 必须在 WriteHeader 之前设置:一旦写出状态码,头就锁定了。
c.ResponseWriter().Header().Set("Content-Type", "application/json; charset=utf-8")
span := trace.SpanFromContext(c.Request().Context())
resp := map[string]interface{}{
@@ -367,9 +378,10 @@ func pageScope(path string) string {
path == "/register" || strings.HasPrefix(path, "/register/"):
return "auth"
case path == "/user" || strings.HasPrefix(path, "/user/"):
// 题目/私聊管理列表是「翻找内容」的页面,和「设置表单」的信息密度差很多,
// 题目/私聊管理列表、数据面板都是「读数据」的页面,和「设置表单」的信息密度差很多,
// 装饰要分开配:admin 更该退到背景,profile 可以保留一点氛围。
if strings.HasPrefix(path, "/user/questions") || strings.HasPrefix(path, "/user/votes") {
if strings.HasPrefix(path, "/user/questions") || strings.HasPrefix(path, "/user/votes") ||
strings.HasPrefix(path, "/user/insights") {
return "admin"
}
return "profile"
+1
View File
@@ -42,6 +42,7 @@ func TestPageScope(t *testing.T) {
"/user/profile": "profile",
"/user/questions": "admin", // 题目列表:信息密,装饰要退
"/user/votes/1/results": "admin",
"/user/insights": "admin", // 数据面板同属「读数据」
"/_/admin6": "box",
"/_/admin6/": "box",
"/_/admin6/tag/杂谈": "box",
+370
View File
@@ -0,0 +1,370 @@
// Copyright 2022 E99p1ant. 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"
)
// SiteAnalyticsStore 提供「全站公开数据页」所需的聚合查询。
//
// 与 internal/stats 的分工:本层只做数据库聚合(纯查询,不缓存、不生成文案),
// 缓存、补齐空日期与展示模型都在 stats 层。这样聚合 SQL 可以脱离 HTTP 单测。
type SiteAnalyticsStore interface {
SiteAnalytics(ctx context.Context, now time.Time) (*SiteAnalytics, error)
// TodayCounts 单独取「今日提问 / 今日回答」。
//
// 它们刻意不进 SiteAnalytics:整页聚合一整天只算一次,而这两个数要跟着
// 页面上的「实时」说明走,必须每次(或每隔几十秒)重新取。
TodayCounts(ctx context.Context, since time.Time) (questions, answers int64, err error)
}
// Analytics 全站聚合查询的包级入口,在 Init 中随其他 store 一起初始化。
var Analytics SiteAnalyticsStore
// SiteAnalytics 是一次全站聚合的原始结果,所有计数都是「累及至今」或「窗口内」的口径,
// 具体窗口由调用方传入的 now 决定(见 stats 包的 Overview)。
type SiteAnalytics struct {
RegisteredUsers int64
TotalQuestions int64
Answered int64
Private int64
Read int64
Anonymous int64
ActiveBoxes int64 // 至少收到过一条提问的箱主数
AnsweringBoxes int64 // 至少回答过一条提问的箱主数
TotalMessages int64
LastQuestionAt *time.Time
LastAnswerAt *time.Time
Daily []CountPoint // 近 30 天(按日)
Monthly []CountPoint // 近 12 个月(按月)
// Heatmap 一周 7 天 × 24 小时的提问量,下标 0 为周日(与 EXTRACT(DOW) 对齐)。
Heatmap [7][24]int64
HeatmapDays int // Heatmap 的统计窗口天数,用于在页面上说明口径
LengthBins [6]int64 // 提问字数分布
ReplyBins [6]int64 // 回答耗时分布(仅统计已回答的提问)
}
// CountPoint 一个分组键上的计数,Key 已由数据库格式化成可直接展示的字符串。
type CountPoint struct {
Key string
N int64
}
func NewSiteAnalyticsStore(db *gorm.DB) SiteAnalyticsStore {
return &siteAnalytics{db}
}
type siteAnalytics struct {
*gorm.DB
}
const (
// 趋势窗口:近 30 天按日、近 12 个月按月。
analyticsDailyDays = 30
analyticsMonths = 12
// 热力图窗口:一年太贵(全表聚合),半年足以代表「全站习惯」。
analyticsHeatmapDays = 180
// 字数分桶上限(左闭右开),最后一桶为 500 以上。
lengthBinExpr = `CASE
WHEN CHAR_LENGTH(content) < 20 THEN 0
WHEN CHAR_LENGTH(content) < 50 THEN 1
WHEN CHAR_LENGTH(content) < 100 THEN 2
WHEN CHAR_LENGTH(content) < 200 THEN 3
WHEN CHAR_LENGTH(content) < 500 THEN 4
ELSE 5 END`
)
// analyticsDialect 把两套数据库里含义相同、写法不同的表达式收敛到一处。
//
// 项目实际部署用 PostgreSQL(见 Init 的索引分支),MySQL 分支只是保留可用性:
// 配置切到 MySQL 时不应整页 500,而应安静地少几个数。
type analyticsDialect struct {
day string
month string
dow string
hour string
replyMin string
}
func analyticsDialectFor(db *gorm.DB, at time.Time) analyticsDialect {
tz := analyticsTZOffset(at)
if db.Dialector.Name() == "mysql" {
// MySQL 的 DATETIME 不带时区,驱动按 DSN 的 loc(默认 UTC)写入,
// 因此库里存的是 UTC 墙钟,CONVERT_TZ 再搬到进程时区。用偏移而不是
// 时区名:CONVERT_TZ 用时区名要求 mysql.time_zone* 表已导入。
conv := func(col string) string {
return fmt.Sprintf("CONVERT_TZ(%s, '+00:00', '%s')", col, tz)
}
day := conv("created_at")
return analyticsDialect{
day: fmt.Sprintf("DATE_FORMAT(%s, '%%Y-%%m-%%d')", day),
month: fmt.Sprintf("DATE_FORMAT(%s, '%%Y-%%m')", day),
dow: fmt.Sprintf("(DAYOFWEEK(%s) - 1)", day),
hour: fmt.Sprintf("HOUR(%s)", day),
// 回答耗时以 answered_at 为准。历史行该列为空(加列之前回答的),
// 只能退回 updated_at —— 那部分含已读/置顶带来的误差,无法追溯修正。
replyMin: "TIMESTAMPDIFF(MINUTE, created_at, COALESCE(answered_at, updated_at))",
}
}
// PostgreSQL:EXTRACT 返回 numeric,显式转 int 以免扫描进 int64 时类型不匹配。
loc := func(col string) string { return analyticsLocalize(col, at) }
return analyticsDialect{
day: fmt.Sprintf("to_char(%s, 'YYYY-MM-DD')", loc("created_at")),
month: fmt.Sprintf("to_char(%s, 'YYYY-MM')", loc("created_at")),
dow: fmt.Sprintf("CAST(EXTRACT(DOW FROM %s) AS INTEGER)", loc("created_at")),
hour: fmt.Sprintf("CAST(EXTRACT(HOUR FROM %s) AS INTEGER)", loc("created_at")),
replyMin: "CAST(EXTRACT(EPOCH FROM (COALESCE(answered_at, updated_at) - created_at)) / 60 AS INTEGER)",
}
}
// analyticsLocalize 把带时区的列转成「Go 进程所在时区的墙钟时间」再参与日期分组。
//
// 不能依赖数据库会话时区:PG 官方镜像默认 timezone=UTC,而 Go 侧按容器本地时区
// (Dockerfile 里写的是 Asia/Shanghai)切「今天」。两边不一致时,日报表与热力图会
// 整体偏 8 小时 —— 中国时间 0–8 点的提问被算进前一天,且和页面顶部「今日」对不上。
// 这类偏差肉眼看不出来(数字本身都合理),只能靠显式换算消除。
//
// 用固定偏移而不是 IANA 名:Go 在容器未设 TZ 时会给出 "Local",PG 不认这个名字。
// 中国无夏令时,固定偏移与真实时区完全等价;有夏令时的部署在窗口内可能差 1 小时。
func analyticsLocalize(col string, at time.Time) string {
return fmt.Sprintf("%s AT TIME ZONE INTERVAL '%s'", col, analyticsTZOffset(at))
}
// analyticsTZOffset 返回 +08:00 这样的偏移字面量。
func analyticsTZOffset(at time.Time) string {
_, off := at.Zone()
sign := "+"
if off < 0 {
sign, off = "-", -off
}
return fmt.Sprintf("%s%02d:%02d", sign, off/3600, off%3600/60)
}
func (db *siteAnalytics) SiteAnalytics(ctx context.Context, now time.Time) (*SiteAnalytics, error) {
loc := now.Location()
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
dailySince := startOfDay.AddDate(0, 0, -(analyticsDailyDays - 1))
// 先归到当月 1 号再回退,避免月底 AddDate 溢出(3-31 减 11 个月会滚到 3-03)。
monthSince := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc).AddDate(0, -(analyticsMonths - 1), 0)
heatmapSince := startOfDay.AddDate(0, 0, -analyticsHeatmapDays)
out := &SiteAnalytics{HeatmapDays: analyticsHeatmapDays}
if err := db.aggregateTotals(ctx, out); err != nil {
return nil, err
}
if err := db.aggregateTrends(ctx, out, now, dailySince, monthSince); err != nil {
return nil, err
}
if err := db.aggregateHeatmap(ctx, out, now, heatmapSince); err != nil {
return nil, err
}
if err := db.aggregateContent(ctx, out); err != nil {
return nil, err
}
if err := db.WithContext(ctx).Model(&User{}).Count(&out.RegisteredUsers).Error; err != nil {
return nil, errors.Wrap(err, "count registered users")
}
if err := db.WithContext(ctx).Model(&Message{}).Count(&out.TotalMessages).Error; err != nil {
return nil, errors.Wrap(err, "count messages")
}
// 「今日」两项不在这里查:本函数的结果整天缓存,把会变的数字放进来
// 会让页面顶着一个「实时」的说明显示一整天不变的值。见 TodayCounts。
return out, nil
}
// TodayCounts 取今日提问数与今日回答数。
//
// 口径:
// - 今日提问 = created_at 落在今天的提问,走 idx_questions_created_at;
// - 今日回答 = answered_at 落在今天的提问,即「今天首次被回答」。
// 不能拿 updated_at 当回答时间:置顶、公开/私密切换、标记已读都会刷新它
// (GORM 的 Update 一律更新 updated_at),箱主晚上整理一批三个月前的老问题
// —— 一次回答都没有 —— 页面上「今日回答」就会凭空多出几十条。
//
// answered_at 没有索引,这一条是全表过滤(与改动前的 updated_at 同级别);
// 十万行量级约几十毫秒,页面另有 60 秒缓存,够用。真慢了再加
// idx_questions_answered_at,不必为这个查询动表结构。
func (db *siteAnalytics) TodayCounts(ctx context.Context, since time.Time) (int64, int64, error) {
var questions, answers int64
if err := db.WithContext(ctx).Model(&Question{}).
Where("created_at >= ?", since).Count(&questions).Error; err != nil {
return 0, 0, errors.Wrap(err, "count today's questions")
}
if err := db.WithContext(ctx).Model(&Question{}).
Where("answer <> '' AND answered_at >= ?", since).Count(&answers).Error; err != nil {
return 0, 0, errors.Wrap(err, "count today's answers")
}
return questions, answers, nil
}
// aggregateTotals 用一条 SQL 取回所有「不分时间窗口」的计数。
//
// 用 SUM(CASE WHEN ...) 而不是 COUNT(*) FILTER (WHERE ...):前者 MySQL 也支持,
// 后者只在 PostgreSQL 上可用。
func (db *siteAnalytics) aggregateTotals(ctx context.Context, out *SiteAnalytics) error {
var row struct {
Total int64
Answered int64
Private int64
Read int64
Anonymous int64
ActiveBoxes int64 `gorm:"column:active_boxes"`
AnsweringBoxes int64 `gorm:"column:answering_boxes"`
LastQ *time.Time `gorm:"column:last_q"`
LastA *time.Time `gorm:"column:last_a"`
}
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 is_private THEN 1 ELSE 0 END), 0) AS private,
COALESCE(SUM(CASE WHEN is_read OR answer <> '' THEN 1 ELSE 0 END), 0) AS read,
COALESCE(SUM(CASE WHEN asker_user_id = 0 THEN 1 ELSE 0 END), 0) AS anonymous,
COUNT(DISTINCT user_id) AS active_boxes,
COUNT(DISTINCT CASE WHEN answer <> '' THEN user_id END) AS answering_boxes,
MAX(created_at) AS last_q,
MAX(CASE WHEN answer <> '' THEN COALESCE(answered_at, updated_at) END) AS last_a`).
Scan(&row).Error
if err != nil {
return errors.Wrap(err, "aggregate question totals")
}
out.TotalQuestions = row.Total
out.Answered = row.Answered
out.Private = row.Private
out.Read = row.Read
out.Anonymous = row.Anonymous
out.ActiveBoxes = row.ActiveBoxes
out.AnsweringBoxes = row.AnsweringBoxes
out.LastQuestionAt = row.LastQ
out.LastAnswerAt = row.LastA
return nil
}
// aggregateTrends 按日与按月各取一次计数。未被 GROUP BY 覆盖的空日期由 stats 层补齐。
func (db *siteAnalytics) aggregateTrends(ctx context.Context, out *SiteAnalytics, now, dailySince, monthSince time.Time) error {
d := analyticsDialectFor(db.DB, now)
var daily []struct {
Key string
N int64
}
if err := db.WithContext(ctx).Model(&Question{}).
Select(d.day+" AS key, COUNT(*) AS n").
Where("created_at >= ?", dailySince).
Group(d.day).Order(d.day).
Scan(&daily).Error; err != nil {
return errors.Wrap(err, "aggregate daily trend")
}
for _, row := range daily {
out.Daily = append(out.Daily, CountPoint{Key: row.Key, N: row.N})
}
var monthly []struct {
Key string
N int64
}
if err := db.WithContext(ctx).Model(&Question{}).
Select(d.month+" AS key, COUNT(*) AS n").
Where("created_at >= ?", monthSince).
Group(d.month).Order(d.month).
Scan(&monthly).Error; err != nil {
return errors.Wrap(err, "aggregate monthly trend")
}
for _, row := range monthly {
out.Monthly = append(out.Monthly, CountPoint{Key: row.Key, N: row.N})
}
return nil
}
func (db *siteAnalytics) aggregateHeatmap(ctx context.Context, out *SiteAnalytics, now, since time.Time) error {
d := analyticsDialectFor(db.DB, now)
var rows []struct {
Dow int
Hr int
N int64
}
if err := db.WithContext(ctx).Model(&Question{}).
Select(d.dow+" AS dow, "+d.hour+" AS hr, COUNT(*) AS n").
Where("created_at >= ?", since).
Group(d.dow + ", " + d.hour).
Scan(&rows).Error; err != nil {
return errors.Wrap(err, "aggregate weekday/hour heatmap")
}
for _, row := range rows {
// 越界值直接丢弃:MySQL 的 DAYOFWEEK 或异常时区数据都可能给出 7 以上的下标,
// 落进固定数组会 panic,而这份数据只影响一张图,不值得让整页失败。
if row.Dow < 0 || row.Dow > 6 || row.Hr < 0 || row.Hr > 23 {
continue
}
out.Heatmap[row.Dow][row.Hr] = row.N
}
return nil
}
func (db *siteAnalytics) aggregateContent(ctx context.Context, out *SiteAnalytics) error {
// 内容分布只用时长差值,不涉及日期分组,时区不影响结果。
d := analyticsDialectFor(db.DB, time.Now())
var lengths []struct {
Bin int
N int64
}
if err := db.WithContext(ctx).Model(&Question{}).
Select(lengthBinExpr + " AS bin, COUNT(*) AS n").
Group(lengthBinExpr).
Scan(&lengths).Error; err != nil {
return errors.Wrap(err, "aggregate content length distribution")
}
for _, row := range lengths {
if row.Bin >= 0 && row.Bin < len(out.LengthBins) {
out.LengthBins[row.Bin] = row.N
}
}
// 回答耗时:只用已回答的提问,按分钟分桶。
// 口径注意:updated_at 也会被置顶等写操作刷新,因此这里算出的耗时偏大;
// 展示时只呈现「24 小时内回答占比」这类粗粒度结论,不宣称精确中位数。
replyBinExpr := fmt.Sprintf(`CASE
WHEN %[1]s < 60 THEN 0
WHEN %[1]s < 360 THEN 1
WHEN %[1]s < 1440 THEN 2
WHEN %[1]s < 4320 THEN 3
WHEN %[1]s < 10080 THEN 4
ELSE 5 END`, d.replyMin)
var replies []struct {
Bin int
N int64
}
if err := db.WithContext(ctx).Model(&Question{}).
Select(replyBinExpr + " AS bin, COUNT(*) AS n").
Where("answer <> ''").
Group(replyBinExpr).
Scan(&replies).Error; err != nil {
return errors.Wrap(err, "aggregate reply latency distribution")
}
for _, row := range replies {
if row.Bin >= 0 && row.Bin < len(out.ReplyBins) {
out.ReplyBins[row.Bin] = row.N
}
}
return nil
}
+184
View File
@@ -0,0 +1,184 @@
// Copyright 2022 E99p1ant. 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"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestSiteAnalytics 验证 /stats 页的聚合口径。
//
// 这里刻意连真库跑而不是塞 mock:整段都是数据库方言相关的 SQL
// (to_char / EXTRACT / CHAR_LENGTH / SUM(CASE WHEN)),
// 对着 mock 断言等于什么都没验证。
func TestSiteAnalytics(t *testing.T) {
t.Parallel()
db, cleanup := newTestDB(t)
t.Cleanup(func() {
if err := cleanup("users", "questions", "messages"); err != nil {
t.Fatal(err)
}
})
ctx := context.Background()
now := time.Now()
store := NewSiteAnalyticsStore(db)
questions := NewQuestionsStore(db)
require.NoError(t, db.Create(&User{UID: "u1", Name: "box", Domain: "boxa", Email: "[email protected]"}).Error)
// 一条登录用户提的、已回答的长问题
answered, err := questions.Create(ctx, CreateQuestionOptions{
UserID: 1, Content: strings.Repeat("字", 120), FromIP: "1.1.1.1", AskerUserID: 7,
})
require.NoError(t, err)
require.NoError(t, questions.AnswerByID(ctx, answered.ID, "答案"))
// 一条匿名提的私密短问题,未读未答
_, err = questions.Create(ctx, CreateQuestionOptions{
UserID: 1, Content: "短问题", FromIP: "1.1.1.2", IsPrivate: true,
})
require.NoError(t, err)
// 一条被软删除的:不该出现在任何数字里
deleted, err := questions.Create(ctx, CreateQuestionOptions{
UserID: 1, Content: "会被删掉", FromIP: "1.1.1.3",
})
require.NoError(t, err)
require.NoError(t, questions.DeleteByID(ctx, deleted.ID))
got, err := store.SiteAnalytics(ctx, now)
require.NoError(t, err)
// 计数
require.EqualValues(t, 1, got.RegisteredUsers)
require.EqualValues(t, 2, got.TotalQuestions, "软删除的提问不能计入总数")
require.EqualValues(t, 1, got.Answered)
require.EqualValues(t, 1, got.Private)
require.EqualValues(t, 1, got.Read, "已回答的提问恒为已读")
require.EqualValues(t, 1, got.Anonymous)
require.EqualValues(t, 1, got.ActiveBoxes)
require.EqualValues(t, 1, got.AnsweringBoxes)
// 今日(提问是刚才写的,必然落在今天)—— 单独取,不随整页聚合缓存
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
todayQ, todayA, err := store.TodayCounts(ctx, dayStart)
require.NoError(t, err)
require.EqualValues(t, 2, todayQ)
require.EqualValues(t, 1, todayA)
// 日/月趋势里能找到今天与本月
today := now.Format("2006-01-02")
require.EqualValues(t, 2, countOf(got.Daily, today))
require.EqualValues(t, 2, countOf(got.Monthly, now.Format("2006-01")))
// 热力图:两条提问都发生在当前这一小时
dow := int(now.Weekday()) // Go 的 Weekday 与 EXTRACT(DOW) 都是周日 0
require.EqualValues(t, 2, got.Heatmap[dow][now.Hour()])
// 字数分桶:120 字落在 100–199(下标 3),"短问题" 3 字落在 <20(下标 0)
require.EqualValues(t, 1, got.LengthBins[0])
require.EqualValues(t, 1, got.LengthBins[3])
// 回答耗时:刚回答完,落在 1 小时内(下标 0);未回答的不计入
require.EqualValues(t, 1, got.ReplyBins[0])
var replyTotal int64
for _, n := range got.ReplyBins {
replyTotal += n
}
require.EqualValues(t, 1, replyTotal, "耗时分布只统计已回答的提问")
// 最后时间戳
require.NotNil(t, got.LastQuestionAt)
require.NotNil(t, got.LastAnswerAt)
}
// TestTodayAnswersIgnoreNonAnswerWrites 「今日回答」只数今天首次被回答的提问。
//
// 原来按 updated_at 算,而置顶、公开/私密切换都会刷新 updated_at:
// 箱主今晚整理一批三个月前的老问题(一次回答都没有),页面上「今日回答」
// 就凭空多出几十条。改成 answered_at 后,只有真的发生了「首次回答」才算。
func TestTodayAnswersIgnoreNonAnswerWrites(t *testing.T) {
conn, clean := newTestDB(t)
defer clean("questions")
store := NewSiteAnalyticsStore(conn)
questions := NewQuestionsStore(conn)
ctx := context.Background()
now := time.Now()
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
old := now.Add(-72 * time.Hour)
// 三天前提出并回答的历史问题
legacy, err := questions.Create(ctx, CreateQuestionOptions{UserID: 1, Content: "旧问题", FromIP: "1.1.1.1"})
require.NoError(t, err)
require.NoError(t, questions.AnswerByID(ctx, legacy.ID, "旧答案"))
// 把三个时间都冻回三天前(AnswerByID 会把 answered_at / updated_at 刷成当下)
require.NoError(t, conn.Model(&Question{}).Where("id = ?", legacy.ID).UpdateColumn("created_at", old).Error)
require.NoError(t, conn.Model(&Question{}).Where("id = ?", legacy.ID).UpdateColumn("answered_at", old).Error)
require.NoError(t, conn.Model(&Question{}).Where("id = ?", legacy.ID).UpdateColumn("updated_at", old).Error)
// 今天真的答了一条
fresh, err := questions.Create(ctx, CreateQuestionOptions{UserID: 1, Content: "新问题", FromIP: "1.1.1.2"})
require.NoError(t, err)
require.NoError(t, questions.AnswerByID(ctx, fresh.ID, "今天的答案"))
_, answers, err := store.TodayCounts(ctx, dayStart)
require.NoError(t, err)
require.EqualValues(t, 1, answers)
// 箱主今晚翻旧账:置顶、切私密、再切回公开 —— 一次回答都没发生
require.NoError(t, questions.SetPinned(ctx, legacy.ID, true))
require.NoError(t, questions.SetPrivate(ctx, legacy.ID))
require.NoError(t, questions.SetPublic(ctx, legacy.ID))
_, answers, err = store.TodayCounts(ctx, dayStart)
require.NoError(t, err)
require.EqualValues(t, 1, answers, "置顶 / 公开切换不该算成「今日回答」")
}
// TestSiteAnalyticsEmpty 空库不应报错,也不应把零值算成异常。
func TestSiteAnalyticsEmpty(t *testing.T) {
t.Parallel()
db, cleanup := newTestDB(t)
t.Cleanup(func() {
if err := cleanup("users", "questions", "messages"); err != nil {
t.Fatal(err)
}
})
got, err := NewSiteAnalyticsStore(db).SiteAnalytics(context.Background(), time.Now())
require.NoError(t, err)
require.Zero(t, got.TotalQuestions)
require.Zero(t, got.RegisteredUsers)
require.Nil(t, got.LastQuestionAt)
require.Nil(t, got.LastAnswerAt)
var heatTotal int64
for _, row := range got.Heatmap {
for _, n := range row {
heatTotal += n
}
}
require.Zero(t, heatTotal)
}
func countOf(points []CountPoint, key string) int64 {
for _, p := range points {
if p.Key == key {
return p.N
}
}
return 0
}
+202
View File
@@ -0,0 +1,202 @@
// Copyright 2022 E99p1ant. 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"
)
// TestAnsweredAtOnlySetOnFirstAnswer 回答时间只记一次。
//
// 背景:GORM 的 Update 会自动刷新 updated_at,而已读、置顶、公开/私密切换
// 都走 Update。若拿 updated_at 当回答时间,回答完再点一次「已读」,
// 耗时就会从几分钟变成几十天 —— 线上那份「6 条超过 7 天」的数据就是这么来的。
func TestAnsweredAtOnlySetOnFirstAnswer(t *testing.T) {
conn, clean := newTestDB(t)
defer clean("questions")
createdAt := time.Now().Add(-72 * time.Hour).Truncate(time.Second)
q := Question{UserID: 1, Content: "hello", CreatedAt: createdAt, UpdatedAt: createdAt}
require.NoError(t, conn.Create(&q).Error)
store := NewQuestionsStore(conn)
require.NoError(t, store.AnswerByID(context.Background(), q.ID, "first"))
var got Question
require.NoError(t, conn.First(&got, q.ID).Error)
require.NotNil(t, got.AnsweredAt, "首次回答必须记下 answered_at")
firstAt := *got.AnsweredAt
require.WithinDuration(t, time.Now(), firstAt, time.Minute)
// 改写回答:answered_at 不能被重置,否则「回答耗时」会跟着编辑动作变大。
require.NoError(t, store.AnswerByID(context.Background(), q.ID, "edited"))
require.NoError(t, conn.First(&got, q.ID).Error)
require.NotNil(t, got.AnsweredAt)
require.Equal(t, firstAt.Unix(), got.AnsweredAt.Unix(), "改写回答不该重置 answered_at")
}
// TestAnsweredAtSetWhenAnsweringAsPrivate 强制私密那条 UPDATE 同样要记时间。
func TestAnsweredAtSetWhenAnsweringAsPrivate(t *testing.T) {
conn, clean := newTestDB(t)
defer clean("questions")
q := Question{UserID: 1, Content: "secret"}
require.NoError(t, conn.Create(&q).Error)
require.NoError(t, NewQuestionsStore(conn).AnswerByIDPrivate(context.Background(), q.ID, "answer"))
var got Question
require.NoError(t, conn.First(&got, q.ID).Error)
require.NotNil(t, got.AnsweredAt)
require.True(t, got.IsPrivate)
require.True(t, got.IsRead)
}
// TestReplyBinsUseAnsweredAt 回答耗时以 answered_at 为准,不受后来的「已读」影响。
//
// 构造:72 小时前提问、10 分钟前回答(落第一档);但 updated_at 被改成当下
// (相当于回答完又被读了一次),差值是 72 小时 = 4320 分钟,正好落到第四档。
// 若仍在用 updated_at,这条会进第四档而不是第一档。
func TestReplyBinsUseAnsweredAt(t *testing.T) {
conn, clean := newTestDB(t)
defer clean("questions")
now := time.Now()
createdAt := now.Add(-72 * time.Hour)
answeredAt := createdAt.Add(10 * time.Minute) // 提问后 10 分钟回答 → 第一档
q := Question{UserID: 1, Content: "hi", CreatedAt: createdAt, UpdatedAt: createdAt}
require.NoError(t, conn.Create(&q).Error)
// 直接写列,绕开 GORM 的 UpdatedAt 自动更新。
require.NoError(t, conn.Exec(
"UPDATE questions SET answer = ?, is_read = ?, answered_at = ?, updated_at = ? WHERE id = ?",
"answer", true, answeredAt, now, q.ID).Error)
got, err := NewSiteAnalyticsStore(conn).SiteAnalytics(context.Background(), now)
require.NoError(t, err)
var total int64
for _, n := range got.ReplyBins {
total += n
}
require.EqualValues(t, 1, total)
require.EqualValues(t, 1, got.ReplyBins[0], "10 分钟的回答应落在第一档")
require.EqualValues(t, 0, got.ReplyBins[4], "不该被 pushed 到当下的 updated_at 带偏")
// 「最后一条回答」同理:取 answered_at 而不是被刷新过的 updated_at。
require.NotNil(t, got.LastAnswerAt)
require.WithinDuration(t, answeredAt, *got.LastAnswerAt, time.Minute)
}
// TestReplyBinsFallBackToUpdatedAt 加列之前回答的历史行 answered_at 为空,
// 退回 updated_at,不能让这些提问从分布里消失。
func TestReplyBinsFallBackToUpdatedAt(t *testing.T) {
conn, clean := newTestDB(t)
defer clean("questions")
now := time.Now()
// 10 天前提问、9 天前处理:差值 24 小时(1440 分钟),落在第三档。
createdAt := now.Add(-10 * 24 * time.Hour)
updatedAt := now.Add(-9 * 24 * time.Hour)
q := Question{UserID: 1, Content: "old", CreatedAt: createdAt, UpdatedAt: createdAt}
require.NoError(t, conn.Create(&q).Error)
require.NoError(t, conn.Exec(
"UPDATE questions SET answer = ?, answered_at = NULL, updated_at = ? WHERE id = ?",
"old answer", updatedAt, q.ID).Error)
got, err := NewSiteAnalyticsStore(conn).SiteAnalytics(context.Background(), now)
require.NoError(t, err)
require.EqualValues(t, 1, got.Answered)
require.EqualValues(t, 1, got.ReplyBins[3], "历史行应退回 updated_at 计入分布")
}
// TestQuestionStatsLastAnswerIgnoresLaterEdits 「最后一个回答」不能跟着置顶/公开切换跑。
//
// QuestionStats 仍被公开接口 /api/v1/site-stats 使用。它原来取 MAX(updated_at),
// 而 updated_at 会被 SetPinned / SetPrivate / SetPublic / SetRead 刷新,
// 于是箱主整理一下旧问题(哪怕一次回答都没发生),接口就说「刚刚有人回答了」。
func TestQuestionStatsLastAnswerIgnoresLaterEdits(t *testing.T) {
conn, clean := newTestDB(t)
defer clean("questions")
store := NewQuestionsStore(conn)
ctx := context.Background()
now := time.Now().Truncate(time.Second)
answeredAt := now.Add(-9 * 24 * time.Hour)
q := Question{UserID: 1, Content: "q", CreatedAt: answeredAt.Add(-time.Hour), UpdatedAt: answeredAt}
require.NoError(t, conn.Create(&q).Error)
require.NoError(t, store.AnswerByID(ctx, q.ID, "answer"))
// AnswerByID 会把两个时间刷成当下,冻回真实回答时刻(模拟一条正常的历史数据)。
require.NoError(t, conn.Model(&Question{}).Where("id = ?", q.ID).
UpdateColumn("updated_at", answeredAt).Error)
require.NoError(t, conn.Model(&Question{}).Where("id = ?", q.ID).
UpdateColumn("answered_at", answeredAt).Error)
// 全是「没回答任何东西」的写操作。
require.NoError(t, store.SetPinned(ctx, q.ID, true))
require.NoError(t, store.SetPrivate(ctx, q.ID))
require.NoError(t, store.SetPublic(ctx, q.ID))
require.NoError(t, store.SetRead(ctx, q.ID))
qs, err := store.QuestionStats(ctx)
require.NoError(t, err)
require.NotNil(t, qs.LastAnswerAt)
require.WithinDuration(t, answeredAt, *qs.LastAnswerAt, time.Minute,
"最后一个回答被置顶/公开切换刷新过的 updated_at 带偏了")
}
// TestQuestionStatsLastAnswerFallsBackToUpdatedAt 加列之前的历史行 answered_at 为空,
// 退回 updated_at —— 与改动前的行为一致,不能让这些行从统计里消失。
func TestQuestionStatsLastAnswerFallsBackToUpdatedAt(t *testing.T) {
conn, clean := newTestDB(t)
defer clean("questions")
answeredAt := time.Now().Add(-5 * 24 * time.Hour).Truncate(time.Second)
require.NoError(t, conn.Exec(
"INSERT INTO questions (user_id, content, answer, is_private, is_read, is_pinned, created_at, updated_at) VALUES (1,'legacy','a',false,true,false,?,?)",
answeredAt.Add(-time.Hour), answeredAt).Error)
qs, err := NewQuestionsStore(conn).QuestionStats(context.Background())
require.NoError(t, err)
require.NotNil(t, qs.LastAnswerAt)
require.WithinDuration(t, answeredAt, *qs.LastAnswerAt, time.Second)
}
// TestBackfillAnsweredAt 历史行回填:拿 updated_at 冻结一次,之后不再随已读漂移。
func TestBackfillAnsweredAt(t *testing.T) {
conn, clean := newTestDB(t)
defer clean("questions")
// 列由 AutoMigrate 建好,HasColumn 必须能认出来(启动时靠它判断「是不是刚加列」)。
require.True(t, conn.Migrator().HasColumn(&Question{}, "answered_at"))
now := time.Now()
createdAt := now.Add(-20 * 24 * time.Hour)
answeredAtOld := now.Add(-19 * 24 * time.Hour)
// 一条历史已回答(answered_at 为空),一条未回答(不该被回填)。
require.NoError(t, conn.Exec(
"INSERT INTO questions (user_id, content, answer, is_private, is_read, is_pinned, created_at, updated_at) VALUES (1,'old','a',false,true,false,?,?)",
createdAt, answeredAtOld).Error)
require.NoError(t, conn.Exec(
"INSERT INTO questions (user_id, content, answer, is_private, is_read, is_pinned, created_at, updated_at) VALUES (1,'open','',false,false,false,?,?)",
createdAt, answeredAtOld).Error)
require.NoError(t, backfillAnsweredAt(conn))
var answered Question
require.NoError(t, conn.Where("content = ?", "old").First(&answered).Error)
require.NotNil(t, answered.AnsweredAt)
require.WithinDuration(t, answeredAtOld, *answered.AnsweredAt, time.Second)
var open Question
require.NoError(t, conn.Where("content = ?", "open").First(&open).Error)
require.Nil(t, open.AnsweredAt, "未回答的提问不该被回填")
}
+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)
}
+233
View File
@@ -0,0 +1,233 @@
// 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)
// 提问按 created_at 分组;回答必须按 answered_at(空则退回 updated_at)分组。
// 两条 SQL 的 day 表达式不同:d.day 是 created_at 的日期,回答那条要基于
// COALESCE(answered_at, updated_at),否则「40 天前创建、昨天才回答」的提问
// 会被排除在窗口外,「今天创建、明天回答」的会被记到今天 —— 趋势图里的
// 「每日回答数」就不是「某天被回答了多少」。
answerDay := analyticsLocalize("COALESCE(answered_at, updated_at)", from)
if db.Dialector.Name() == "mysql" {
answerDay = fmt.Sprintf("DATE_FORMAT(CONVERT_TZ(COALESCE(answered_at, updated_at), '+00:00', '%s'), '%%Y-%%m-%%d')", analyticsTZOffset(from))
} else {
answerDay = fmt.Sprintf("to_char(%s, 'YYYY-MM-DD')", answerDay)
}
// 提问与回答分两条 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(answerDay+" 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(answerDay).
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
}
+75
View File
@@ -0,0 +1,75 @@
// 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"
)
// TestBoxTrendAnswerByAnsweredDate 验证「每日回答数」按 answered_at 分组,
// 而不是按 created_at。
//
// 背景:BoxTrend 一度两条 SQL 都复用 created_at 的日期表达式,导致「40 天前创建、
// 昨天才回答」的提问被排除在窗口外(created_at < from),昨天/今天的回答数里
// 根本不出现;「今天创建、明天回答」的又会被记到今天。本测试构造一条
// 「创建日远早于窗口、回答日落在窗口内」的提问,断言它出现在回答日那天。
func TestBoxTrendAnswerByAnsweredDate(t *testing.T) {
db, cleanup := newTestDB(t)
ctx := context.Background()
store := NewBoxAnalyticsStore(db)
t.Cleanup(func() {
_ = cleanup("questions")
})
now := time.Now()
// 窗口 = 最近 7 天
from := now.AddDate(0, 0, -6)
to := now
// 一条 40 天前创建、昨天才回答的提问
answeredAt := now.AddDate(0, 0, -1)
createdAt := now.AddDate(0, 0, -40)
q := &Question{
UserID: 9,
Content: "很久以前问的,昨天才回答",
Answer: "这是回答",
AnsweredAt: &answeredAt,
}
// 直接写 created_at / answered_at,绕过 Create 的自动时间戳
require.NoError(t, db.Model(&Question{}).Create(q).Error)
require.NoError(t, db.Model(&Question{}).Where("id = ?", q.ID).
Updates(map[string]interface{}{
"created_at": createdAt,
"answered_at": answeredAt,
"updated_at": answeredAt,
}).Error)
trend, err := store.BoxTrend(ctx, 9, from, to)
require.NoError(t, err)
// 找到回答日那天(昨天)的计数,答案数应 >= 1
var found bool
for _, d := range trend {
if d.Day.Year() == answeredAt.Year() &&
d.Day.Month() == answeredAt.Month() &&
d.Day.Day() == answeredAt.Day() {
require.GreaterOrEqual(t, d.Answers, int64(1),
"回答应计入 answered_at 那一天,而不是按 created_at 被排除")
found = true
}
// 创建日(40 天前)不应出现在窗口内
if d.Day.Year() == createdAt.Year() &&
d.Day.Month() == createdAt.Month() &&
d.Day.Day() == createdAt.Day() {
t.Errorf("创建日在窗口外,不应出现在趋势里")
}
}
require.True(t, found, "回答日应出现在趋势结果里")
}
+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)
}
+170 -2
View File
@@ -6,19 +6,30 @@ package db
import (
"context"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/thanhpk/randstr"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
var Conversations ConversationStore
var (
ErrDailyMessageLimit = errors.New("已达到每日私聊上限")
ErrConversationLimit = errors.New("会话已达到消息上限或已关闭")
)
type ConversationStore interface {
Create(ctx context.Context, opts CreateConversationOptions) (*Conversation, error)
// CreateWithFirstMessage 事务化地建会话 + 首条消息 + 消息计数。
// 单独写首条消息(用于「发第一条私聊」这类一次性动作),避免失败时留下空会话。
CreateWithFirstMessage(ctx context.Context, opts CreateConversationOptions, senderID uint, content string) (*Conversation, *Message, error)
SendMessage(ctx context.Context, opts SendConversationMessageOptions) (*Message, error)
GetByID(ctx context.Context, id uint) (*Conversation, error)
GetByToken(ctx context.Context, token string) (*Conversation, error)
GetByIDAndOwner(ctx context.Context, id, ownerID uint) (*Conversation, error)
@@ -71,6 +82,39 @@ type CreateConversationOptions struct {
MaxMessages int
}
type SendConversationMessageOptions struct {
ConversationID uint
SenderID uint
Content string
Image *CreateMessageImageOptions
}
func reserveDailyMessageTx(tx *gorm.DB, ownerID, senderID uint) error {
var sender User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&sender, senderID).Error; err != nil {
return errors.Wrap(err, "lock message sender")
}
var owner User
if err := tx.Select("daily_message_limit").First(&owner, ownerID).Error; err != nil {
return errors.Wrap(err, "get owner daily message limit")
}
if owner.DailyMessageLimit <= 0 {
return nil
}
now := time.Now()
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
var count int64
if err := tx.Table("messages").Joins("JOIN conversations ON conversations.id = messages.conversation_id").
Where("conversations.owner_id = ? AND messages.sender_id = ? AND messages.created_at >= ?", ownerID, senderID, start).
Count(&count).Error; err != nil {
return errors.Wrap(err, "count today messages")
}
if count >= int64(owner.DailyMessageLimit) {
return ErrDailyMessageLimit
}
return nil
}
func (db *conversations) Create(ctx context.Context, opts CreateConversationOptions) (*Conversation, error) {
conversation := &Conversation{
OwnerID: opts.OwnerID,
@@ -83,6 +127,117 @@ func (db *conversations) Create(ctx context.Context, opts CreateConversationOpti
return conversation, nil
}
// CreateWithFirstMessage 在一个事务里完成「建会话 + 写首条消息 + 消息计数」。
//
// 原来这三步是三次独立写入:第二步失败时会话已经提交,于是留下一条
// message_count=0、没有任何消息的「空会话」——箱主的私聊列表里能看到它,
// 点进去什么都没有;更麻烦的是 Conversations.Create 的 BeforeCreate 已经
// 生成并占用了一个 6 字符 token(唯一索引),孤儿会话会永久占着这个 token。
func (db *conversations) CreateWithFirstMessage(
ctx context.Context, opts CreateConversationOptions, senderID uint, content string,
) (*Conversation, *Message, error) {
var conv *Conversation
var msg *Message
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if senderID == opts.AskerID {
if err := reserveDailyMessageTx(tx, opts.OwnerID, senderID); err != nil {
return err
}
}
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) SendMessage(ctx context.Context, opts SendConversationMessageOptions) (*Message, error) {
msg := &Message{ConversationID: opts.ConversationID, SenderID: opts.SenderID, Content: opts.Content}
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 先锁发送者,再锁会话,保证与 CreateWithFirstMessage 的锁顺序一致,
// 避免并发下两条路径互相等待造成配额检查窗口。
var sender User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&sender, opts.SenderID).Error; err != nil {
return errors.Wrap(err, "lock message sender")
}
var conv Conversation
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&conv, opts.ConversationID).Error; err != nil {
return errors.Wrap(err, "lock conversation")
}
// 发送者必须是会话双方之一
if conv.OwnerID != opts.SenderID && conv.AskerID != opts.SenderID {
return errors.New("sender is not part of the conversation")
}
// 只有 asker 发送才消耗 owner 的每日配额
if conv.AskerID == opts.SenderID {
if err := reserveDailyMessageTx(tx, conv.OwnerID, opts.SenderID); err != nil {
return err
}
}
res := tx.Model(&Conversation{}).Where("id = ? AND message_count < max_messages AND is_closed = ?", conv.ID, false).
UpdateColumn("message_count", gorm.Expr("message_count + 1"))
if res.Error != nil {
return errors.Wrap(res.Error, "increment message count")
}
if res.RowsAffected == 0 {
return ErrConversationLimit
}
if err := tx.Create(msg).Error; err != nil {
return errors.Wrap(err, "create message")
}
if opts.Image != nil {
image := *opts.Image
image.MessageID = msg.ID
if err := tx.Create(&MessageImage{MessageID: image.MessageID, URL: image.URL, ObjectKey: image.ObjectKey, FileName: image.FileName, FileSize: image.FileSize}).Error; err != nil {
return errors.Wrap(err, "create message image")
}
}
return nil
})
if err != nil {
return nil, err
}
return msg, nil
}
func (db *conversations) GetByID(ctx context.Context, id uint) (*Conversation, error) {
var conv Conversation
if err := db.WithContext(ctx).First(&conv, id).Error; err != nil {
@@ -216,8 +371,21 @@ func (db *conversations) MarkDeletedByOwner(ctx context.Context, id uint) error
return nil
}
if err := db.WithContext(ctx).Model(&Conversation{}).Where("id = ?", id).Update("is_deleted_by_owner", true).Error; err != nil {
return errors.Wrap(err, "mark conversation deleted by owner")
// 用「条件更新 + RowsAffected」代替纯 Update:
// 原来的写法是 check-then-act,双击「删除会话」或前端重复提交时,两个请求
// 都能读到 is_deleted_by_owner=false,于是都执行 UPDATE、都执行一次
// IncrementQuota(-1)。IncrementQuota 有 quota_count>0 兜底不会变负,
// 但会白吃掉本用户其他会话的计数 —— 表现为定量收集提前关闭,
// 而箱主重置计数也校准不回来。
res := db.WithContext(ctx).Model(&Conversation{}).
Where("id = ? AND is_deleted_by_owner = ?", id, false).
Update("is_deleted_by_owner", true)
if res.Error != nil {
return errors.Wrap(res.Error, "mark conversation deleted by owner")
}
if res.RowsAffected == 0 {
// 并发里被另一个请求抢先删掉了:它已经做过配额回退,这里不能再来一次。
return nil
}
// 回退定量收集计数:删除私聊会话等价于少一条已收集内容,
+58 -23
View File
@@ -1,6 +1,7 @@
package db
import (
"context"
"fmt"
"time"
@@ -14,9 +15,28 @@ import (
"github.com/tamakyi/TamaBox/internal/conf"
)
// handle 保存 Init 建立的连接,供「不需要具体 store」的场景使用(如就绪探针)。
// 与下面那些业务 store 的包级变量区分开:那些是接口,这个是连接本身。
var handle *gorm.DB
// Ping 检查数据库连接是否可用。供 /readyz 就绪探针调用。
//
// 为什么需要它:原来的「探活」只能 curl /,而首页只渲染模板、不碰数据库 ——
// DB 挂了首页照样 200,反过来 Redis 挂了首页又可能 500。于是探针既会漏报也会误报。
func Ping(ctx context.Context) error {
if handle == nil {
return errors.New("database not initialized")
}
sqlDB, err := handle.DB()
if err != nil {
return errors.Wrap(err, "get sql.DB")
}
return sqlDB.PingContext(ctx)
}
var AllTables = []interface{}{
&User{}, &Question{}, &FollowUp{}, &CensorLog{}, &UploadImage{}, &UploadImageQuestion{},
&Conversation{}, &Message{}, &MessageImage{}, &UserBlock{},
&Conversation{}, &Message{}, &MessageImage{}, &UserBlock{}, &R2DeleteTask{},
// 匿名投票
&Vote{}, &VoteOption{}, &VoteFormField{}, &VoteSubmission{},
// 功能开关(提问 / 私聊 / 投票 的开关、定时、定量收集)
@@ -25,6 +45,14 @@ var AllTables = []interface{}{
&UserRestriction{}, &ContentReport{},
// 蜜罐命中记录(审计证据,只追加不软删)
&HoneypotHit{},
// 来源 IP 的地理定位缓存(安全汇总报告用,纯 KV 缓存)
&IPGeoRecord{},
// 周期安全汇总的报告台账(period_key 唯一,保证重启不会重复发信)
&SecurityReportLog{},
// 自动封禁记录(内存态封禁的落库账本,用于事后解释与取证)
&BanRecord{},
// 箱主数据面板的访问流水(每 访客×天×箱 一行,Unscoped 语义,见 BoxView 注释)
&BoxView{},
}
func Init(typ, dsn string) (*gorm.DB, error) {
@@ -40,11 +68,13 @@ func Init(typ, dsn string) (*gorm.DB, error) {
}
db, err := gorm.Open(dialector, &gorm.Config{
Logger: newSafeLogger(),
SkipDefaultTransaction: true,
})
if err != nil {
return nil, errors.Wrap(err, "connect to database")
}
handle = db
sqlDB, err := db.DB()
if err != nil {
@@ -65,32 +95,16 @@ func Init(typ, dsn string) (*gorm.DB, error) {
}
}
// AutoMigrate 只负责声明式的「加表 / 加列 / 加索引」——这些变更幂等、无数据丢失风险。
if err := db.AutoMigrate(AllTables...); err != nil {
return nil, errors.Wrap(err, "auto migrate")
}
// 功能开关初始化:为历史账号补齐 feature_controls 行(私聊沿用 users.allow_chat)。
// 幂等,已初始化的账号不会重复写入;失败不阻断启动,仅告警,避免依赖表抖动导致服务起不来。
if err := backfillFeatureControls(db); err != nil {
logrus.WithError(err).Warn("初始化功能开关配置时异常(可在功能管理页手动保存一次以生成配置)")
}
// 私聊开关纠偏:旧版 backfill 曾把未开启私聊的账号写为开启(GORM 零值陷阱),
// 启动期幂等复位;同样不阻断启动,失败仅告警。
if err := reconcileLegacyChatEnabled(db); err != nil {
logrus.WithError(err).Warn("私聊开关历史数据纠偏时异常(可在功能管理页手动调整私聊开关)")
}
// 私聊收集上限下线:私聊不再支持收集上限(UI 已隐藏,保存时强制清零),
// 启动期把历史残留的上限/计数一并清零,避免私聊「莫名关闭」。幂等,失败仅告警。
if err := normalizeChatQuota(db); err != nil {
logrus.WithError(err).Warn("私聊收集上限历史数据清理时异常(可在功能管理页保存一次私聊设置)")
}
// 防骚扰设置归一:历史账号 harassment_setting 为空串(建表时无默认值),与 none 语义等价
// 但取值不同,会导致按 == none 判断时漏判。幂等,失败仅告警。
if err := normalizeHarassmentSetting(db); err != nil {
logrus.WithError(err).Warn("防骚扰设置历史数据归一时异常(可在防骚扰设置页保存一次)")
// 版本化迁移:AutoMigrate 做不了的破坏性变更(改列名/删列/改约束)与一次性数据回填
// 都收编进这里,靠 schema_migrations 账本保证「只跑一次、顺序固定、可审计」。
// 迁移失败会阻断启动 —— 半途而废的迁移比静默跳过更危险(见 runMigrations 注释)。
if err := runMigrations(db); err != nil {
return nil, errors.Wrap(err, "run versioned migrations")
}
// 启动期补充高频查询复合索引(幂等,适合既有库)。
@@ -118,6 +132,7 @@ func Init(typ, dsn string) (*gorm.DB, error) {
Conversations = NewConversationStore(db)
Messages = NewMessageStore(db)
MessageImages = NewMessageImageStore(db)
R2DeleteTasks = NewR2DeleteTaskStore(db)
Votes = NewVotesStore(db)
VoteOptions = NewVoteOptionsStore(db)
VoteFormFields = NewVoteFormFieldsStore(db)
@@ -126,9 +141,23 @@ func Init(typ, dsn string) (*gorm.DB, error) {
Restrictions = NewRestrictionsStore(db)
Reports = NewReportsStore(db)
Honeypots = NewHoneypotStore(db)
BanRecords = NewBanRecordsStore(db)
Analytics = NewSiteAnalyticsStore(db)
BoxViews = NewBoxViewsStore(db)
BoxAnalytics = NewBoxAnalyticsStore(db)
// 旧版本把消息/密码明文存在 honeypot_hits 上;结构体已删掉这些字段,
// 但 AutoMigrate 不会删除既有列 —— 老库升级上来后历史明文仍在。
// 这里只告警不自动删:DROP COLUMN 不可逆,必须由运维确认后执行
// `TamaBox honeypot purge-creds`。
if cols, err := Honeypots.LegacyCredentialColumns(context.Background()); err == nil && len(cols) > 0 {
logrus.WithField("columns", cols).
Warn("honeypot_hits 上仍残留旧版明文凭据列,请执行 TamaBox honeypot purge-creds 清除")
}
if err := db.Use(otelgorm.NewPlugin(
otelgorm.WithDBName(conf.Database.Name),
otelgorm.WithoutQueryVariables(),
)); err != nil {
return nil, errors.Wrap(err, "register otelgorm plugin")
}
@@ -207,7 +236,13 @@ func dedupeUniqueFields(db *gorm.DB) error {
func ensureFrequentlyUsedIndexes(db *gorm.DB) error {
indexes := []string{
`CREATE INDEX IF NOT EXISTS idx_questions_asker_user_id ON questions(asker_user_id)`,
// 全站数据页(/stats):按 created_at 做范围扫描后按天/小时分组。
// 没有这个索引时,趋势与热力图会退化成全表扫描 + 排序;加上之后是范围扫描 + 聚合。
`CREATE INDEX IF NOT EXISTS idx_questions_created_at ON questions(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_questions_user_private_created ON questions(user_id, is_private, created_at)`,
// 「下一条未读」:user_id + is_read 过滤后按 created_at 取最早一条。
// 上面那条中间隔着 is_private,帮不上这个排序。
`CREATE INDEX IF NOT EXISTS idx_questions_user_unread_created ON questions(user_id, is_read, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_users_show_in_home_created ON users(show_in_home, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_votes_status_visibility_created ON votes(status, visibility, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_messages_sender_created ON messages(sender_id, created_at)`,
+51 -3
View File
@@ -20,6 +20,8 @@ import (
var FeatureControls FeatureControlsStore
var ErrFeatureQuotaReached = errors.New("功能收集配额已满")
var _ FeatureControlsStore = (*featureControls)(nil)
// 受控功能标识(feature_controls.feature 取值)。
@@ -360,6 +362,26 @@ func FeatureStateOf(ctx context.Context, userID uint, feature string) FeatureSta
return fc.Evaluate(time.Now())
}
// FeatureStateOfStrict 与 FeatureStateOf 相同,但查询失败时返回错误而不回退默认配置。
//
// 给**写入路径**用。FeatureStateOf 的 fail-open 对读展示是对的(配置表抖动不该让
// 页面白屏),但对写入是危险的:它回退到 DefaultFeatureControl —— 提问与投票的
// 默认值是「开启」,于是箱主明明设了「已关闭」或「已达上限」,只要这次读配置失败
// 就被放行,等于定量收集与功能开关都能被一次数据库抖动绕过去。
// 与审核链路「不可用即拒绝」同一口径。
func FeatureStateOfStrict(ctx context.Context, userID uint, feature string) (FeatureState, error) {
if FeatureControls == nil {
// 未装配(测试 / 部分初始化):沿用默认配置,否则所有写路径都会失败。
return DefaultFeatureControl(userID, feature).Evaluate(time.Now()), nil
}
fc, err := FeatureControls.Get(ctx, userID, feature)
if err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to get feature control %q of user %d", feature, userID)
return FeatureState{}, errors.Wrap(err, "get feature control")
}
return fc.Evaluate(time.Now()), nil
}
// FeatureOpen 便捷入口:某用户某功能当前是否可用。
func FeatureOpen(ctx context.Context, userID uint, feature string) bool {
return FeatureStateOf(ctx, userID, feature).Open
@@ -487,11 +509,37 @@ func (db *featureControls) ResetQuota(ctx context.Context, userID uint, feature
// IncrementQuota 累加收集计数(delta 可为负,用于删除内容时回退计数)。
// 负 delta 不会把计数减到 0 以下(WHERE quota_count > 0),避免误删历史内容导致负计数。
func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feature string, delta int64) error {
return incrementQuotaTx(db.WithContext(ctx), userID, feature, delta)
}
// incrementQuotaTx 是配额递增的唯一实现,接受任意 *gorm.DB(可以是事务)。
//
// 为什么要暴露一个「能用事务」的版本:配额与内容必须是同生共死的关系。
// 原来「创建提问」与「quota_count+1」是两次独立写入,后者失败只打一条 Warn ——
// 于是计数比真实行数少,箱主的「已收集 7/10」永远偏低,达到上限后仍能继续收,
// 而重置计数也校准不回来(重置是把计数清零,与实际行数更脱节)。
// 现在内容 store 把这一步放进自己的事务里,任一步失败整体回滚,计数天然对齐。
func reserveFeatureQuotaTx(tx *gorm.DB, userID uint, feature string) error {
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(DefaultFeatureControl(userID, feature)).Error; err != nil {
return errors.Wrap(err, "initialize feature quota")
}
var fc FeatureControl
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("user_id = ? AND feature = ?", userID, feature).First(&fc).Error; err != nil {
return errors.Wrap(err, "lock feature quota")
}
if fc.QuotaLimit > 0 && fc.QuotaCount >= fc.QuotaLimit {
return ErrFeatureQuotaReached
}
return nil
}
func incrementQuotaTx(tx *gorm.DB, userID uint, feature string, delta int64) error {
if delta == 0 {
return nil
}
now := time.Now()
q := db.WithContext(ctx).Model(&FeatureControl{}).
q := tx.Model(&FeatureControl{}).
Where("user_id = ? AND feature = ?", userID, feature)
if delta < 0 {
q = q.Where("quota_count > 0")
@@ -513,7 +561,7 @@ func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feat
fc.QuotaCount = delta
}
fc.QuotaResetAt = &now
created := db.WithContext(ctx).
created := tx.
Clauses(clause.OnConflict{DoNothing: true}).
Create(fc)
if created.Error != nil {
@@ -523,7 +571,7 @@ func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feat
return nil
}
// 并发下配置行可能刚被其它请求创建:补一次累加,保证计数不丢。
retry := db.WithContext(ctx).Model(&FeatureControl{}).
retry := tx.Model(&FeatureControl{}).
Where("user_id = ? AND feature = ?", userID, feature).
Updates(map[string]interface{}{
"quota_count": gorm.Expr("quota_count + ?", delta),
+54 -16
View File
@@ -18,6 +18,9 @@ var (
ErrFollowUpLimitReached = errors.New("follow up limit reached")
ErrFollowUpNotFound = errors.New("follow up not found")
ErrFollowUpAlreadyAnswered = errors.New("follow up already answered")
// ErrFollowUpAwaitingReply 上一条追问尚未被箱主回复,不允许继续追问。
// 由 CreateIfBelowLimit 在事务内判定(见该方法的注释)。
ErrFollowUpAwaitingReply = errors.New("previous follow up awaiting reply")
)
var FollowUps FollowUpsStore
@@ -35,10 +38,15 @@ type FollowUpsStore interface {
// AnswerByID 回答某条追问;questionID 防越权(必须属于该问题),仅允许回答尚未回答的(answer 为空)。
// 已回答返回 ErrFollowUpAlreadyAnswered,不存在/不属于该问题返回 ErrFollowUpNotFound。
AnswerByID(ctx context.Context, questionID uint, id uint, answer string) error
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防越权),允许覆盖已回答的(供主人修改回复)。
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防越权),
// 允许覆盖已回答的(供主人修改回复);返回 wasFirst 表示这是首次回答(此前为空)。
// 不存在/不属于该问题返回 ErrFollowUpNotFound。
UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) error
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) (wasFirst bool, err error)
// DeleteByQuestionID 删除某条提问时级联清理其全部追问。
//
// 用 Unscoped 真删:追问正文是提问者的个人内容,随提问一并真删(与 questions.DeleteByID
// 对主表的真删口径一致)。软删会留下带 deleted_at 的追问文本,且这些孤儿行的
// question_id 指向一个已不存在的提问 —— 与「删除即真删、个人数据不残留」的意图相悖。
DeleteByQuestionID(ctx context.Context, questionID uint) error
}
@@ -84,6 +92,23 @@ func (db *followUps) CreateIfBelowLimit(ctx context.Context, opts CreateFollowUp
return ErrFollowUpLimitReached
}
// 「上一条追问必须先被回复」也必须在事务内判断。
//
// 这段校验原来只在 handler 里做(事务外):双击提交时两个请求都读到
// 「最新一条还没回复」,随后被这行 FOR UPDATE 串行化,各自 count 都 < 3,
// 于是两条内容相同的追问都创建成功 —— 吃掉 2/3 名额,箱主还会收两封邮件。
// 行锁只保护「条数」,不保护「业务前置条件」,所以判断必须挪进来。
var latest FollowUp
if err := tx.Where("question_id = ?", opts.QuestionID).
Order("id DESC"). // 用 id 而非 created_at:同毫秒写入时时间排序不稳定
Limit(1).
Find(&latest).Error; err != nil {
return errors.Wrap(err, "get latest follow up for reply check")
}
if latest.ID != 0 && latest.Answer == "" {
return ErrFollowUpAwaitingReply
}
f := &FollowUp{
QuestionID: opts.QuestionID,
Content: opts.Content,
@@ -188,23 +213,36 @@ func (db *followUps) AnswerByID(ctx context.Context, questionID uint, id uint, a
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防止越权),
// 允许覆盖已回答的追问(用于主人修改回复)。不存在/不属于该问题返回 ErrFollowUpNotFound。
func (db *followUps) UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) error {
res := db.WithContext(ctx).
Model(&FollowUp{}).
Where("id = ? AND question_id = ?", id, questionID).
Update("answer", answer)
if res.Error != nil {
return errors.Wrap(res.Error, "update follow up answer")
func (db *followUps) UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) (bool, error) {
var wasFirst bool
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 先读旧值判断「是否首次回答」。handler 里拿不到可靠旧值:
// 它手上的 question 是请求开始时读的,并发下可能已经过期。
var fu FollowUp
if err := tx.Where("id = ? AND question_id = ?", id, questionID).First(&fu).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrFollowUpNotFound
}
return errors.Wrap(err, "get follow up")
}
wasFirst = fu.Answer == ""
if err := tx.Model(&FollowUp{}).
Where("id = ? AND question_id = ?", id, questionID).
Update("answer", answer).Error; err != nil {
return errors.Wrap(err, "update follow up answer")
}
return nil
})
if err != nil {
return false, err
}
if res.RowsAffected == 0 {
return ErrFollowUpNotFound
}
return nil
return wasFirst, nil
}
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(真删,与提问保持一致)。
func (db *followUps) DeleteByQuestionID(ctx context.Context, questionID uint) error {
if err := db.WithContext(ctx).Where("question_id = ?", questionID).Delete(&FollowUp{}).Error; err != nil {
if err := db.WithContext(ctx).Unscoped().Where("question_id = ?", questionID).Delete(&FollowUp{}).Error; err != nil {
return errors.Wrap(err, "delete follow ups by question id")
}
return nil
+118 -44
View File
@@ -20,7 +20,11 @@ type HoneypotHit struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
// IP 为归一化后的客户端 IP(shared.LimitKey 之后,IPv6 折叠到 /64)。
// IP 是发起请求的**精确**客户端地址(IPv6 保留完整地址,最长 45 字符)。
//
// 刻意不存 shared.LimitKey 折叠后的 /64 聚合键:那张表是取证物,
// 「哪台主机」必须留下来才能查其他日志、做 abuse 投诉。聚合键只适合
// 当计数器的 key(封禁 / 限流),它进不来这里。
IP string `gorm:"size:64;index" json:"ip"`
// Method 与 Path 记录原始请求方法与路径(未归一化的原始值)。
Method string `gorm:"size:16" json:"method"`
@@ -28,15 +32,14 @@ type HoneypotHit struct {
// Query 保存原始查询串(截断)。
Query string `gorm:"size:1024" json:"query"`
// Username / Password 为提取出的破解凭据。Password 落库前做可逆加密还是脱敏,
// 由调用方决定:这里只存明文,CLI 展示时默认脱敏。
Username string `gorm:"size:256" json:"username"`
Password string `gorm:"size:256" json:"password"`
// Fields 保存其余表单/JSON 字段(JSON 字符串),便于还原破解工具特征。
Fields string `gorm:"type:text" json:"fields"`
// RawBody 保存截断后的原始 body。
RawBody string `gorm:"type:text" json:"raw_body"`
// HadFields 标记本次提交是否真的带了凭据字段(区分「探测 GET」与「尝试破解 POST」)。
//
// ⚠️ 这里只存「有没有」这个事实,**不存凭据本身**。
// 用户名/密码/原始 body 全部落在 honeypot.CredStore(默认
// data/honeypot-creds/creds-YYYY-MM-DD.jsonl,权限 0600),原因见
// internal/security/honeypot/credstore.go 的 CredRecord 注释:
// 明文账号密码不能进数据库 —— 一旦入库就会跟着进备份、主从复制、
// 以及任何有只读权限的报表工具,事后无法彻底清除。
HadFields bool `json:"had_fields"`
// UserAgent / Referer / ContentType 用于扫描器画像。
@@ -69,19 +72,12 @@ type HoneypotStore interface {
Create(ctx context.Context, hit *HoneypotHit) error
// ListSince 返回 since 之后的命中记录,按时间倒序,最多 limit 条。
ListSince(ctx context.Context, since time.Time, limit int) ([]*HoneypotHit, error)
// ListSinceByIP 返回 since 之后、来自指定 IP 的命中记录,按时间倒序。
//
// 刻意在 SQL 层过滤而不是取回后在 Go 侧过滤:后者只能看到「最近 limit 条」
// 里恰好落在窗口内的该 IP 记录,指定 IP 的历史命中基本查不出来。
ListSinceByIP(ctx context.Context, since time.Time, ip string, limit int) ([]*HoneypotHit, error)
// CountSince 统计 since 之后的命中总数。
CountSince(ctx context.Context, since time.Time) (int64, error)
// TopSources 返回 since 之后命中次数最多的来源 IP。
TopSources(ctx context.Context, since time.Time, limit int) ([]*HoneypotSourceStat, error)
// TopPaths 返回 since 之后被命中次数最多的路径。
TopPaths(ctx context.Context, since time.Time, limit int) ([]*HoneypotPathStat, error)
// TopCredentials 返回 since 之后最常被尝试的用户名/密码组合。
TopCredentials(ctx context.Context, since time.Time, limit int) ([]*HoneypotCredentialStat, error)
// DeleteBefore 清理 before 之前的记录(保留期到期),返回删除行数。
DeleteBefore(ctx context.Context, before time.Time) (int64, error)
// DeleteOldestBeyond 保留最近 keepRows 行,删除更早的记录,返回删除行数。
@@ -101,6 +97,24 @@ type HoneypotStore interface {
// 而不是只能靠开发一次性汇报。Bytes 取整个关系的总大小(含索引),
// 因为磁盘压力来自索引和 TOAST,不只是堆表。
Size(ctx context.Context) (HoneypotSize, error)
// AggregateSources 统计 [since, until) 内按来源 IP 聚合的命中数。
//
// 安全汇总报告专用:报告要先按 IP 聚合再拿去地理定位,而不是把几十万条
// 命中明细全捞进内存再 group —— 明细只在「命中数」这一个维度上有用,
// 聚合能把它压到「独立 IP 数」的量级。
AggregateSources(ctx context.Context, since, until time.Time) ([]*HoneypotSourceAgg, error)
// AggregatePaths 统计 [since, until) 内被探测最多的路径。
AggregatePaths(ctx context.Context, since, until time.Time, limit int) ([]*HoneypotPathStat, error)
// TimestampsBetween 返回 [since, until) 内所有命中的时间戳(仅这一列)。
//
// 用于画每日趋势。刻意不在 SQL 里按天分组:DATE() 的返回类型在
// PostgreSQL(date)与 MySQL([]byte)之间不一致,跨方言解析很脆;
// 而这一列的体积极小(时间戳 8 字节),取回来在 Go 里按天归并更稳。
TimestampsBetween(ctx context.Context, since, until time.Time) ([]time.Time, error)
// LegacyCredentialColumns 返回仍残留的旧版凭据列(空表示已清理)。
LegacyCredentialColumns(ctx context.Context) ([]string, error)
// PurgeLegacyCredentialColumns 删除旧版遗留的凭据列(不可逆)。
PurgeLegacyCredentialColumns(ctx context.Context) ([]string, error)
}
// HoneypotSize 蜜罐表的体积报告。
@@ -121,17 +135,62 @@ type HoneypotSourceStat struct {
Provider string
}
// HoneypotSourceAgg 是带「处置结果」的来源聚合行(安全汇总报告用)。
//
// 与 HoneypotSourceStat 的区别:那个是给 CLI 排行看的(只要次数),
// 这个要给报告回答「打了多少次 / 有多少次被拦下 / 有多少次真的在试密码」。
type HoneypotSourceAgg struct {
IP string
Hits int64
Banned int64
// Creds 带有凭据字段的命中数(真实破解尝试,区别于纯路径探测)。
Creds int64
// Provider 是该 IP 最近一次命中时的 cdncheck 标签(云厂商/CDN/WAF)。
Provider string
}
// HoneypotPathStat 路径聚合行。
type HoneypotPathStat struct {
Path string
Count int64
}
// HoneypotCredentialStat 凭据组合聚合行。
type HoneypotCredentialStat struct {
Username string
Password string
Count int64
// LegacyHoneypotCredentialColumns 是早期版本在 honeypot_hits 上留下的凭据列。
//
// 结构体已经删掉这些字段,但 GORM 的 AutoMigrate **不会删除既有列** ——
// 老库升级上来后列还在、历史明文也还在。启动时检测到就告警,由运维执行
// `TamaBox honeypot purge-creds` 显式清除(DROP COLUMN 不可逆,不自动做)。
var LegacyHoneypotCredentialColumns = []string{"username", "password", "fields", "raw_body"}
// LegacyCredentialColumns 返回当前库里仍然存在的旧凭据列(空表示已清理干净)。
func (h *honeypots) LegacyCredentialColumns(ctx context.Context) ([]string, error) {
var present []string
for _, col := range LegacyHoneypotCredentialColumns {
if h.db.WithContext(ctx).Migrator().HasColumn(&HoneypotHit{}, col) {
present = append(present, col)
}
}
return present, nil
}
// PurgeLegacyCredentialColumns 删除旧版遗留的凭据列(不可逆)。
//
// 语义是「把历史上已经入库的明文凭据彻底清掉」,所以是 DROP COLUMN 而不是
// 置空:置空后明文仍可能残留在表的死元组/备份里,DROP 至少让新产生的
// 备份与后续查询不再包含它们。真正的历史备份需要运维自行处理。
func (h *honeypots) PurgeLegacyCredentialColumns(ctx context.Context) ([]string, error) {
present, err := h.LegacyCredentialColumns(ctx)
if err != nil {
return nil, err
}
dropped := make([]string, 0, len(present))
for _, col := range present {
if err := h.db.WithContext(ctx).Migrator().DropColumn(&HoneypotHit{}, col); err != nil {
return dropped, err
}
dropped = append(dropped, col)
}
return dropped, nil
}
type honeypots struct {
@@ -163,23 +222,6 @@ func (h *honeypots) ListSince(ctx context.Context, since time.Time, limit int) (
return rows, err
}
func (h *honeypots) ListSinceByIP(ctx context.Context, since time.Time, ip string, limit int) ([]*HoneypotHit, error) {
if limit <= 0 || limit > 1000 {
limit = 200
}
if ip == "" {
return h.ListSince(ctx, since, limit)
}
var rows []*HoneypotHit
err := h.db.WithContext(ctx).
Where("created_at >= ?", since).
Where("ip = ?", ip).
Order("created_at DESC").
Limit(limit).
Find(&rows).Error
return rows, err
}
func (h *honeypots) CountSince(ctx context.Context, since time.Time) (int64, error) {
var n int64
err := h.db.WithContext(ctx).Model(&HoneypotHit{}).
@@ -220,23 +262,55 @@ func (h *honeypots) TopPaths(ctx context.Context, since time.Time, limit int) ([
return rows, err
}
func (h *honeypots) TopCredentials(ctx context.Context, since time.Time, limit int) ([]*HoneypotCredentialStat, error) {
// AggregateSources 统计 [since, until) 内按来源 IP 聚合的命中数。
//
// [since, until) 左闭右开:周期报告的相邻两期不能有重叠,也不能漏掉
// 恰好落在整点上的记录 —— 用闭区间会让周一的 00:00:00 同时算进上周和本周。
func (h *honeypots) AggregateSources(ctx context.Context, since, until time.Time) ([]*HoneypotSourceAgg, error) {
var rows []*HoneypotSourceAgg
err := h.db.WithContext(ctx).
Table("honeypot_hits").
// SUM(CASE WHEN ...) 是 PG 与 MySQL 都支持的写法(boolean 在 PG 里
// 不能直接 sum,转 1/0 才跨方言)。
Select("ip AS ip, count(*) AS hits, "+
"sum(case when banned then 1 else 0 end) AS banned, "+
"sum(case when had_fields then 1 else 0 end) AS creds, "+
"max(provider) AS provider").
Where("created_at >= ? AND created_at < ?", since, until).
Group("ip").
Order("hits DESC").
Scan(&rows).Error
return rows, err
}
// AggregatePaths 统计 [since, until) 内被探测最多的路径。
func (h *honeypots) AggregatePaths(ctx context.Context, since, until time.Time, limit int) ([]*HoneypotPathStat, error) {
if limit <= 0 || limit > 500 {
limit = 20
}
var rows []*HoneypotCredentialStat
var rows []*HoneypotPathStat
err := h.db.WithContext(ctx).
Table("honeypot_hits").
Select("username AS username, password AS password, count(*) AS count").
Where("created_at >= ?", since).
Where("username <> '' OR password <> ''").
Group("username, password").
Select("path AS path, count(*) AS count").
Where("created_at >= ? AND created_at < ?", since, until).
Group("path").
Order("count DESC").
Limit(limit).
Scan(&rows).Error
return rows, err
}
// TimestampsBetween 返回 [since, until) 内所有命中的时间戳。
func (h *honeypots) TimestampsBetween(ctx context.Context, since, until time.Time) ([]time.Time, error) {
var ts []time.Time
err := h.db.WithContext(ctx).
Model(&HoneypotHit{}).
Where("created_at >= ? AND created_at < ?", since, until).
Order("created_at ASC").
Pluck("created_at", &ts).Error
return ts, err
}
func (h *honeypots) DeleteBefore(ctx context.Context, before time.Time) (int64, error) {
res := h.db.WithContext(ctx).Where("created_at < ?", before).Delete(&HoneypotHit{})
return res.RowsAffected, res.Error
+25 -2
View File
@@ -34,8 +34,19 @@ import (
// 此时只记一条告警日志,不删任何数据。
// - 清理失败只记日志,绝不 panic、绝不阻断主流程:蜜罐是安全增强功能,
// 不能因为它把站点搞挂。
//
// CredPurgeable 是「凭据文件按月清理」的能力(由 honeypot.CredStore 实现)。
//
// 用本地小接口而不是直接 import honeypot 包:存储层不该反向依赖安全层,
// 也能让清理器在测试里用假实现。
type CredPurgeable interface {
// PurgeBefore 删除早于 before 的整天文件,返回删除的文件数。
PurgeBefore(before time.Time) (int, error)
}
type HoneypotRetention struct {
store HoneypotStore
creds CredPurgeable
retainDays int
maxRows int
interval time.Duration
@@ -44,13 +55,14 @@ type HoneypotRetention struct {
// NewHoneypotRetention 构造保留期清理器。
//
// retainDays <= 0 表示不自动清理;maxRows <= 0 表示不设数量上限;
// interval <= 0 时使用默认 1 小时。
func NewHoneypotRetention(store HoneypotStore, retainDays int, maxRows int, interval time.Duration) *HoneypotRetention {
// interval <= 0 时使用默认 1 小时;creds 为 nil 表示不清理凭据文件。
func NewHoneypotRetention(store HoneypotStore, creds CredPurgeable, retainDays int, maxRows int, interval time.Duration) *HoneypotRetention {
if interval <= 0 {
interval = time.Hour
}
return &HoneypotRetention{
store: store,
creds: creds,
retainDays: retainDays,
maxRows: maxRows,
interval: interval,
@@ -126,6 +138,17 @@ func (r *HoneypotRetention) runOnce() {
total += m
}
// 凭据文件按同一个保留期清理:明文账号密码不该比元数据活得更久。
// 独立于 DB 的删除量 —— 即使这批数据库记录本来就已达标,文件仍可能过期。
if r.creds != nil {
if n, err := r.creds.PurgeBefore(before); err != nil {
logrus.WithError(err).Warn("蜜罐凭据文件清理失败(不影响数据库记录)")
} else if n > 0 {
logrus.WithFields(logrus.Fields{"files": n, "days": r.retainDays}).
Info("已清理过期的蜜罐凭据文件")
}
}
if total == 0 {
return
}
+4 -42
View File
@@ -47,7 +47,7 @@ func TestHoneypotRetentionRunOnce(t *testing.T) {
// 保留期 30 天、行数上限 7:
// 时间闸删掉 5 条 90 天前的旧记录 → 剩 5 条新的;
// 数量闸:5 < 7,未超上限,不删 → 最终剩 5 条。
ret := NewHoneypotRetention(store, 30, 7, 0)
ret := NewHoneypotRetention(store, nil, 30, 7, 0)
ret.runOnce()
remaining, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
@@ -82,7 +82,7 @@ func TestHoneypotRetentionDisabled(t *testing.T) {
}))
}
ret := NewHoneypotRetention(store, 0, 100, 0)
ret := NewHoneypotRetention(store, nil, 0, 100, 0)
ret.runOnce()
total, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
@@ -109,7 +109,7 @@ func TestHoneypotRetentionMaxRowsOnly(t *testing.T) {
}))
}
ret := NewHoneypotRetention(store, 30, 5, 0)
ret := NewHoneypotRetention(store, nil, 30, 5, 0)
ret.runOnce()
total, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
@@ -147,7 +147,7 @@ func TestHoneypotRetentionBothGates(t *testing.T) {
// 保留期 30 天、数量上限 4:
// 时间闸删 5 条 ancient → 剩 10 条 recent;数量闸删到 4 条(删最旧的 6 条)
ret := NewHoneypotRetention(store, 30, 4, 0)
ret := NewHoneypotRetention(store, nil, 30, 4, 0)
ret.runOnce()
total, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
@@ -208,44 +208,6 @@ func TestHoneypotDeleteOldestBeyond(t *testing.T) {
require.Equal(t, int64(0), n3)
}
// TestHoneypotListSinceByIP 验证按 IP 过滤在 SQL 层生效。
//
// 背景:原先 CLI 的 --ip 过滤是在取回最近 N 条后在内存里筛,
// 导致「指定 IP 的历史命中」基本查不出来(被其他 IP 的更近记录挤出窗口)。
func TestHoneypotListSinceByIP(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("honeypot_hits"); err != nil {
t.Fatal(err)
}
})
store := NewHoneypotStore(database)
now := time.Now()
// 目标 IP 只有 1 条很老的记录;其余 20 条都是别的 IP 且更新
require.NoError(t, store.Create(ctx, &HoneypotHit{
IP: "9.9.9.9", Method: "GET", Path: "/target", CreatedAt: now.Add(-10 * time.Hour),
}))
for i := 0; i < 20; i++ {
require.NoError(t, store.Create(ctx, &HoneypotHit{
IP: "8.8.8.8", Method: "GET", Path: "/other", CreatedAt: now.Add(-time.Duration(i) * time.Minute),
}))
}
rows, err := store.ListSinceByIP(ctx, now.Add(-24*time.Hour), "9.9.9.9", 200)
require.NoError(t, err)
require.Len(t, rows, 1, "指定 IP 的老记录应能被查出(不能被其他 IP 的新记录挤出窗口)")
require.Equal(t, "/target", rows[0].Path)
// 空 IP 退化为不过滤
all, err := store.ListSinceByIP(ctx, now.Add(-24*time.Hour), "", 200)
require.NoError(t, err)
require.Len(t, all, 21)
}
// TestHoneypotCompact 验证空间回收在 PostgreSQL 下可执行且不报错。
func TestHoneypotCompact(t *testing.T) {
database, cleanup := newTestDB(t)
+113 -11
View File
@@ -31,9 +31,9 @@ func TestHoneypotStore(t *testing.T) {
rows := []*HoneypotHit{
{IP: "1.2.3.4", Method: "GET", Path: "/wp-admin", Provider: "Azure", CreatedAt: now.Add(-3 * time.Hour)},
{IP: "1.2.3.4", Method: "POST", Path: "/wp-login.php", Username: "admin", Password: "admin123", HadFields: true, CreatedAt: now.Add(-2 * time.Hour)},
{IP: "1.2.3.4", Method: "POST", Path: "/wp-login.php", Username: "admin", Password: "admin123", HadFields: true, CreatedAt: now.Add(-time.Hour)},
{IP: "5.6.7.8", Method: "POST", Path: "/.ssh/id_rsa", Username: "root", Password: "toor", HadFields: true, CreatedAt: now.Add(-30 * time.Minute)},
{IP: "1.2.3.4", Method: "POST", Path: "/wp-login.php", HadFields: true, CreatedAt: now.Add(-2 * time.Hour)},
{IP: "1.2.3.4", Method: "POST", Path: "/wp-login.php", HadFields: true, CreatedAt: now.Add(-time.Hour)},
{IP: "5.6.7.8", Method: "POST", Path: "/.ssh/id_rsa", HadFields: true, CreatedAt: now.Add(-30 * time.Minute)},
}
for _, r := range rows {
require.NoError(t, store.Create(ctx, r))
@@ -64,13 +64,6 @@ func TestHoneypotStore(t *testing.T) {
require.Equal(t, "/wp-login.php", paths[0].Path)
require.Equal(t, int64(2), paths[0].Count)
creds, err := store.TopCredentials(ctx, since, 10)
require.NoError(t, err)
require.Len(t, creds, 2)
require.Equal(t, "admin", creds[0].Username)
require.Equal(t, "admin123", creds[0].Password)
require.Equal(t, int64(2), creds[0].Count)
// 保留期清理
deleted, err := store.DeleteBefore(ctx, now.Add(-90*time.Minute))
require.NoError(t, err)
@@ -81,6 +74,45 @@ func TestHoneypotStore(t *testing.T) {
require.Equal(t, int64(2), total)
}
// TestHoneypotLegacyCredentialColumns 覆盖「旧版凭据列」的检测与清除。
//
// 背景:凭据已改为只落文件(见 honeypot.CredRecord),结构体也删掉了
// username/password/fields/raw_body 四个字段。但 GORM 的 AutoMigrate
// 不会删除既有列,老库升级上来后历史明文仍然留在表里。
// 这个测试用 ADD COLUMN 模拟老库,验证检测与 DROP COLUMN 都真的生效。
func TestHoneypotLegacyCredentialColumns(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("honeypot_hits"); err != nil {
t.Fatal(err)
}
})
store := NewHoneypotStore(database)
cols, err := store.LegacyCredentialColumns(ctx)
require.NoError(t, err)
require.Empty(t, cols, "新建的表不应有旧版凭据列")
// 模拟旧版本留下的列。
require.NoError(t, database.Exec("ALTER TABLE honeypot_hits ADD COLUMN username varchar(256)").Error)
require.NoError(t, database.Exec("ALTER TABLE honeypot_hits ADD COLUMN password varchar(256)").Error)
cols, err = store.LegacyCredentialColumns(ctx)
require.NoError(t, err)
require.ElementsMatch(t, []string{"username", "password"}, cols)
dropped, err := store.PurgeLegacyCredentialColumns(ctx)
require.NoError(t, err)
require.ElementsMatch(t, []string{"username", "password"}, dropped)
cols, err = store.LegacyCredentialColumns(ctx)
require.NoError(t, err)
require.Empty(t, cols, "清除后不应再检测到旧版凭据列")
}
// TestHoneypotSize 覆盖体积报告:行数、时间跨度、磁盘占用。
//
// 目的:让「蜜罐表有没有把数据库撑臃肿」成为运维随时能自己查的事。
@@ -102,7 +134,7 @@ func TestHoneypotSize(t *testing.T) {
IP: "9.9.9.9",
Method: "POST",
Path: "/wp-login.php",
Username: "admin",
HadFields: true,
CreatedAt: now.Add(-time.Duration(i) * time.Hour),
}))
}
@@ -127,3 +159,73 @@ func TestHoneypotSize(t *testing.T) {
require.NoError(t, err)
require.Equal(t, int64(2), info2.Rows)
}
// TestHoneypotAggregateRange 覆盖安全汇总报告用的区间聚合。
//
// 三个方法共同回答「这一周谁在打、打了哪儿」。重点验证:
// - [since, until) 左闭右开,相邻两期不能重叠;
// - SUM(CASE WHEN ...) 在 PostgreSQL / MySQL 上都能算出处置数;
// - 只取时间戳那一列时不夹带整行(报告不需要 UA / 路径以外的字段)。
func TestHoneypotAggregateRange(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("honeypot_hits"); err != nil {
t.Fatal(err)
}
})
store := NewHoneypotStore(database)
loc := time.Local
start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc)
end := start.AddDate(0, 0, 7)
rows := []*HoneypotHit{
// 窗口内:IP A 三次(两次被拦、一次带凭据)、IP B 一次
{IP: "203.0.113.1", Path: "/wp-login.php", Banned: true, HadFields: false, Provider: "Azure", CreatedAt: start.Add(time.Hour)},
{IP: "203.0.113.1", Path: "/wp-login.php", Banned: true, HadFields: true, Provider: "Azure", CreatedAt: start.Add(2 * time.Hour)},
{IP: "203.0.113.1", Path: "/.env", Banned: true, HadFields: false, Provider: "Azure", CreatedAt: start.Add(3 * time.Hour)},
{IP: "198.51.100.2", Path: "/wp-admin", Banned: false, HadFields: false, CreatedAt: start.Add(4 * time.Hour)},
// 窗口外(右侧开区间边界):不能算进本期
{IP: "203.0.113.1", Path: "/wp-login.php", Banned: true, CreatedAt: end},
// 窗口外(早于起点)
{IP: "192.0.2.9", Path: "/phpmyadmin", Banned: true, CreatedAt: start.Add(-time.Minute)},
}
for _, r := range rows {
require.NoError(t, store.Create(ctx, r))
}
sources, err := store.AggregateSources(ctx, start, end)
require.NoError(t, err)
require.Len(t, sources, 2)
// 按命中数降序:203.0.113.1 在前
require.Equal(t, "203.0.113.1", sources[0].IP)
require.Equal(t, int64(3), sources[0].Hits)
require.Equal(t, int64(3), sources[0].Banned)
require.Equal(t, int64(1), sources[0].Creds)
require.Equal(t, "Azure", sources[0].Provider)
require.Equal(t, "198.51.100.2", sources[1].IP)
require.Equal(t, int64(1), sources[1].Hits)
require.Equal(t, int64(0), sources[1].Banned)
paths, err := store.AggregatePaths(ctx, start, end, 10)
require.NoError(t, err)
require.Len(t, paths, 3)
require.Equal(t, "/wp-login.php", paths[0].Path)
require.Equal(t, int64(2), paths[0].Count)
stamps, err := store.TimestampsBetween(ctx, start, end)
require.NoError(t, err)
require.Len(t, stamps, 4, "右边界那条不能算进来")
for _, ts := range stamps {
require.True(t, ts.Before(end), "所有时间戳都应严格早于 end")
}
// 空窗口必须返回空而不是报错(报告要能生成「本周无人扫描」)
empty, err := store.AggregateSources(ctx, start.AddDate(0, 0, 30), start.AddDate(0, 0, 37))
require.NoError(t, err)
require.Empty(t, empty)
}
+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)")
}
+151
View File
@@ -0,0 +1,151 @@
// 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"
"gorm.io/gorm/clause"
)
// IPGeoRecord 是一个来源 IP 的地理定位缓存。
//
// 为什么必须落库而不是每次现查:
// - 免费 IP 库都有配额(ip-api 免费版 45 次/分钟)。一次周报可能有几百上千个
// 不同来源 IP,现查必然撞限流,撞了就是「报告里一半 IP 没有位置」;
// - 同一个攻击者会在多份报告里反复出现,缓存能让第二次之后零外部请求;
// - 定位结果本身变化很慢(国家级几乎不变,城市级极少变),没必要重复付费。
//
// 缓存键用 shared.LimitKey(IPv4 精确、IPv6 折叠 /64)而不是原始 IP:
// 攻击者常在一个 /64 里轮换地址,按精确地址缓存会把同一个 C 段查上几十次,
// 既浪费配额又得到一模一样的结果。
type IPGeoRecord struct {
// IPKey 是缓存键(shared.LimitKey 的产物)。主键,不用自增 id:
// 这张表是纯 KV 缓存,没有任何按 id 遍历或引用的需求。
IPKey string `gorm:"size:64;primarykey"`
Country string `gorm:"size:64"`
CountryCode string `gorm:"size:8"`
Region string `gorm:"size:128"`
City string `gorm:"size:128"`
// ISP 运营商 / 网络名("China Telecom"、"Amazon.com, Inc.")。肉鸡报告里
// 这个字段比国家更有信息量:能看出是家宽还是云主机。
ISP string `gorm:"size:160"`
// ASN 自治域号("AS4134")。同 ASN 大量命中通常意味着同一片被控网络。
ASN string `gorm:"size:64"`
// Lat / Lon 为定位坐标。坐标系随来源不同:
// tencent → GCJ-02(火星坐标,与腾讯地图对齐)
// ipapi → WGS-84
// 两者相差几百米,在世界地图气泡图尺度上无影响;放大到街道级才有意义。
Lat float64
Lon float64
// HasCoords 标记坐标是否可用。IP 库对国家级、部分省级结果不给坐标,
// 用 0,0 表示「未知」会被画到几内亚湾去 —— 必须显式区分。
HasCoords bool
// Source 记录这条数据来自哪个库(tencent / ipapi),便于事后判断精度。
Source string `gorm:"size:16"`
// Failed 标记查询失败。失败也要缓存:某个 IP 查不出来通常是确定性的
// (保留地址、库未收录),反复重试只会白白消耗配额。
Failed bool
ResolvedAt time.Time `gorm:"index"`
}
// TableName 固定表名。
func (IPGeoRecord) TableName() string { return "ip_geo_cache" }
// IPGeos 地理定位缓存全局 store。
var IPGeos IPGeoStore
// IPGeoStore 地理定位缓存的读写。
type IPGeoStore interface {
// GetMany 批量取缓存,返回 map[IPKey]*IPGeoRecord。未命中的键不出现在结果里。
GetMany(ctx context.Context, keys []string) (map[string]*IPGeoRecord, error)
// UpsertMany 批量写入或覆盖。
UpsertMany(ctx context.Context, records []*IPGeoRecord) error
// PurgeResolvedBefore 删除 resolved_at 早于 before 的记录(缓存过期)。
//
// 注意「过期」与「失败」的 TTL 不同,由调用方分两次调:
// 成功结果留得久(90 天),失败结果留得短(3 天)—— 失败可能是临时故障
// (对方 API 超时),留太久等于永久放弃这个 IP。
PurgeResolvedBefore(ctx context.Context, before time.Time, onlyFailed bool) (int64, error)
// Count 返回缓存条目数。
Count(ctx context.Context) (int64, error)
}
type ipGeos struct {
db *gorm.DB
}
// NewIPGeoStore 构造缓存 store。务必传 db.DB 而不是复用什么包级单例。
func NewIPGeoStore(db *gorm.DB) IPGeoStore {
return &ipGeos{db: db}
}
func (s *ipGeos) GetMany(ctx context.Context, keys []string) (map[string]*IPGeoRecord, error) {
out := make(map[string]*IPGeoRecord, len(keys))
if len(keys) == 0 {
return out, nil
}
// 分批 IN 查询:PostgreSQL 的占位符上限是 65535,MySQL 受 max_allowed_packet
// 约束,一次塞上千个键虽然多数情况能过,但没必要赌。
const batch = 500
for start := 0; start < len(keys); start += batch {
end := start + batch
if end > len(keys) {
end = len(keys)
}
var rows []*IPGeoRecord
if err := s.db.WithContext(ctx).
Where("ip_key IN ?", keys[start:end]).
Find(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
out[r.IPKey] = r
}
}
return out, nil
}
func (s *ipGeos) UpsertMany(ctx context.Context, records []*IPGeoRecord) error {
if len(records) == 0 {
return nil
}
// 只更新定位字段,不更新 ip_key 本身(它是冲突判定列)。
cols := []string{
"country", "country_code", "region", "city", "isp", "asn",
"lat", "lon", "has_coords", "source", "failed", "resolved_at",
}
return s.db.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "ip_key"}},
DoUpdates: clause.AssignmentColumns(cols),
}).
CreateInBatches(records, 100).Error
}
func (s *ipGeos) PurgeResolvedBefore(ctx context.Context, before time.Time, onlyFailed bool) (int64, error) {
q := s.db.WithContext(ctx).Where("resolved_at < ?", before)
if onlyFailed {
q = q.Where("failed = ?", true)
} else {
// 成功记录那一轮不要连失败的一起删:两者的 TTL 不同。
q = q.Where("failed = ?", false)
}
res := q.Delete(&IPGeoRecord{})
return res.RowsAffected, res.Error
}
func (s *ipGeos) Count(ctx context.Context) (int64, error) {
var n int64
err := s.db.WithContext(ctx).Model(&IPGeoRecord{}).Count(&n).Error
return n, err
}
+174
View File
@@ -0,0 +1,174 @@
// 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"
)
// TestIPGeoStore 覆盖地理定位缓存的读写、批量取、覆盖与过期清理。
//
// 这张表是配额的保护层:同一批攻击者会在多份报告里反复出现,
// 「查一次、之后全靠缓存」能不能成立全看这里的语义对不对。
func TestIPGeoStore(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("ip_geo_cache"); err != nil {
t.Fatal(err)
}
})
store := NewIPGeoStore(database)
now := time.Now()
records := []*IPGeoRecord{
{
IPKey: "203.0.113.1", Country: "美国", CountryCode: "US",
Region: "Virginia", City: "Ashburn", ISP: "Amazon", ASN: "AS14618",
Lat: 39.04, Lon: -77.49, HasCoords: true, Source: "ipapi", ResolvedAt: now,
},
{
IPKey: "2001:db8::/64", Country: "中国", CountryCode: "CN",
Region: "北京市", City: "北京", Lat: 39.9, Lon: 116.4,
HasCoords: true, Source: "tencent", ResolvedAt: now,
},
{
// 查不到:Failed 标记要能落库,否则每次报告都会重试同一批地址
IPKey: "198.51.100.9", Failed: true, ResolvedAt: now,
},
}
require.NoError(t, store.UpsertMany(ctx, records))
got, err := store.GetMany(ctx, []string{"203.0.113.1", "2001:db8::/64", "198.51.100.9", "10.0.0.1"})
require.NoError(t, err)
require.Len(t, got, 3, "不存在的键不应出现在结果里")
require.Equal(t, "Ashburn", got["203.0.113.1"].City)
require.Equal(t, "AS14618", got["203.0.113.1"].ASN)
require.True(t, got["203.0.113.1"].HasCoords)
require.Equal(t, "tencent", got["2001:db8::/64"].Source)
require.True(t, got["198.51.100.9"].Failed)
// 覆盖写:同一个键再写一次应当更新而不是报主键冲突
require.NoError(t, store.UpsertMany(ctx, []*IPGeoRecord{{
IPKey: "203.0.113.1", Country: "美国", CountryCode: "US",
City: "Reston", Lat: 38.96, Lon: -77.35, HasCoords: true,
Source: "tencent", ResolvedAt: now.Add(time.Hour),
}}))
got2, err := store.GetMany(ctx, []string{"203.0.113.1"})
require.NoError(t, err)
require.Equal(t, "Reston", got2["203.0.113.1"].City)
require.Equal(t, "tencent", got2["203.0.113.1"].Source)
n, err := store.Count(ctx)
require.NoError(t, err)
require.Equal(t, int64(3), n)
}
// TestIPGeoStorePurge 覆盖两套 TTL 分开清理:成功留 90 天、失败留 3 天。
func TestIPGeoStorePurge(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("ip_geo_cache"); err != nil {
t.Fatal(err)
}
})
store := NewIPGeoStore(database)
now := time.Now()
require.NoError(t, store.UpsertMany(ctx, []*IPGeoRecord{
{IPKey: "1.0.0.1", Country: "澳大利亚", ResolvedAt: now.AddDate(0, 0, -100)}, // 成功且过期
{IPKey: "1.0.0.2", Country: "日本", ResolvedAt: now.AddDate(0, 0, -10)}, // 成功且新鲜
{IPKey: "1.0.0.3", Failed: true, ResolvedAt: now.AddDate(0, 0, -4)}, // 失败且过期
{IPKey: "1.0.0.4", Failed: true, ResolvedAt: now}, // 失败且新鲜
}))
// 清失败的旧记录:只删 1.0.0.3
n, err := store.PurgeResolvedBefore(ctx, now.AddDate(0, 0, -3), true)
require.NoError(t, err)
require.Equal(t, int64(1), n)
// 清成功的旧记录:只删 1.0.0.1(不能连失败的一起删,两者 TTL 不同)
n, err = store.PurgeResolvedBefore(ctx, now.AddDate(0, 0, -90), false)
require.NoError(t, err)
require.Equal(t, int64(1), n)
left, err := store.GetMany(ctx, []string{"1.0.0.1", "1.0.0.2", "1.0.0.3", "1.0.0.4"})
require.NoError(t, err)
require.Len(t, left, 2)
require.Contains(t, left, "1.0.0.2")
require.Contains(t, left, "1.0.0.4")
}
// TestSecurityReportStore 覆盖报告台账的幂等语义。
//
// 这是「重启不会重复发同一期周报」的唯一保证:period_key 唯一索引 + upsert。
func TestSecurityReportStore(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("security_report_logs"); err != nil {
t.Fatal(err)
}
})
store := NewSecurityReportStore(database)
start := time.Date(2026, 10, 19, 0, 0, 0, 0, time.Local)
end := start.AddDate(0, 0, 7)
// 首次:不存在
rec, err := store.Get(ctx, "week:2026-W43")
require.NoError(t, err)
require.Nil(t, rec)
require.NoError(t, store.Save(ctx, &SecurityReportLog{
PeriodKey: "week:2026-W43", Kind: "week",
WindowStart: start, WindowEnd: end,
Hits: 42, UniqueSources: 7, Countries: 3,
Recipient: "[email protected]", Attempts: 1, Error: "smtp timeout",
}))
rec, err = store.Get(ctx, "week:2026-W43")
require.NoError(t, err)
require.NotNil(t, rec)
require.False(t, rec.Sent)
require.Equal(t, 1, rec.Attempts)
// 第二次:同一 period_key 再写应当覆盖,而不是插入第二行 ——
// 否则「这一期发过没」会被多行记录搞乱。
require.NoError(t, store.Save(ctx, &SecurityReportLog{
PeriodKey: "week:2026-W43", Kind: "week",
WindowStart: start, WindowEnd: end,
Hits: 42, UniqueSources: 7, Countries: 3,
Recipient: "[email protected]", Attempts: 2, Sent: true,
}))
rec, err = store.Get(ctx, "week:2026-W43")
require.NoError(t, err)
require.True(t, rec.Sent)
require.Equal(t, 2, rec.Attempts)
recent, err := store.Recent(ctx, 10)
require.NoError(t, err)
require.Len(t, recent, 1, "同一期只能有一行台账")
// 不同周期应各自一行
require.NoError(t, store.Save(ctx, &SecurityReportLog{
PeriodKey: "month:2026-10", Kind: "month",
WindowStart: start, WindowEnd: end, Sent: true,
}))
recent, err = store.Recent(ctx, 10)
require.NoError(t, err)
require.Len(t, recent, 2)
}
+79
View File
@@ -0,0 +1,79 @@
package db
import (
"context"
"errors"
"fmt"
"time"
"github.com/sirupsen/logrus"
"gorm.io/gorm/logger"
)
type safeLogger struct {
logger.Config
}
func newSafeLogger() logger.Interface {
return &safeLogger{Config: logger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: logger.Warn,
IgnoreRecordNotFoundError: false,
}}
}
func (l *safeLogger) LogMode(level logger.LogLevel) logger.Interface {
newLogger := *l
newLogger.LogLevel = level
return &newLogger
}
func (l *safeLogger) Info(ctx context.Context, _ string, _ ...interface{}) {
if l.LogLevel >= logger.Info {
logrus.WithContext(ctx).WithField("component", "gorm").Info("数据库信息事件")
}
}
func (l *safeLogger) Warn(ctx context.Context, _ string, _ ...interface{}) {
if l.LogLevel >= logger.Warn {
logrus.WithContext(ctx).WithField("component", "gorm").Warn("数据库警告事件")
}
}
func (l *safeLogger) Error(ctx context.Context, _ string, _ ...interface{}) {
if l.LogLevel >= logger.Error {
logrus.WithContext(ctx).WithField("component", "gorm").Error("数据库错误事件")
}
}
func (l *safeLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
if l.LogLevel <= logger.Silent {
return
}
elapsed := time.Since(begin)
switch {
case err != nil && l.LogLevel >= logger.Error && (!errors.Is(err, logger.ErrRecordNotFound) || !l.IgnoreRecordNotFoundError):
_, rows := fc()
l.traceEntry(ctx, elapsed, rows).WithFields(logrus.Fields{
"error_type": fmt.Sprintf("%T", err),
"record_not_found": errors.Is(err, logger.ErrRecordNotFound),
}).Error("数据库操作失败")
case elapsed > l.SlowThreshold && l.SlowThreshold != 0 && l.LogLevel >= logger.Warn:
_, rows := fc()
l.traceEntry(ctx, elapsed, rows).
WithField("slow_threshold", l.SlowThreshold.String()).
Warn("数据库慢查询")
case l.LogLevel >= logger.Info:
_, rows := fc()
l.traceEntry(ctx, elapsed, rows).Info("数据库操作完成")
}
}
func (l *safeLogger) traceEntry(ctx context.Context, elapsed time.Duration, rows int64) *logrus.Entry {
return logrus.WithContext(ctx).WithFields(logrus.Fields{
"component": "gorm",
"elapsed_ms": float64(elapsed.Nanoseconds()) / 1e6,
"rows_affected": rows,
})
}
+32 -2
View File
@@ -7,9 +7,17 @@ import (
"github.com/pkg/errors"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
// minChatMessagesPerPage 是会话消息单次加载的兜底条数。
//
// 正常情况下取 conf.Limits.ChatMaxMessages(默认 200,即一次把整个会话读满)。
// 这个常量只在「配置未初始化」(零值,测试里直接构造 store 时会遇到)时生效,
// 不会覆盖箱主显式配置的更大或更小的值。
const minChatMessagesPerPage = 50
var Messages MessageStore
type MessageStore interface {
@@ -60,20 +68,42 @@ func (db *messages) GetByConversationID(ctx context.Context, conversationID uint
var messages []*Message
q := db.WithContext(ctx).Where("conversation_id = ?", conversationID)
limit := 50
// 默认一次取满整个会话,而不是固定 50 条。
//
// 原先写死 limit=50 且按 created_at ASC 取,取到的是「最早的 50 条」;
// 而会话上限是 conf.Limits.ChatMaxMessages(默认 200)。结果是第 51 条起
// 写库成功、message_count 在涨、页面显示 51/200,但列表里永远没有它 ——
// 用户无论等多久、换设备、重新登录都看不到(实测 55 条时只能渲染 #1–#50)。
limit := conf.Limits.ChatMaxMessages
if limit <= 0 {
// 仅兜底「配置未初始化」(零值)这一种情况。
// 不能写成 limit < 50 → 50:那样会把箱主显式配的小上限抬大,
// 属于替用户改配置,而不是补默认值。
limit = minChatMessagesPerPage
}
if cursor != nil {
if cursor.Value != nil && fmt.Sprintf("%v", cursor.Value) != "" {
// 游标语义是「加载更早的消息」,与下面的 ORDER BY id DESC 配套;
// 若沿用旧的 ASC 排序,id < cursor 每次都会命中同一批最老的消息。
q = q.Where("id < ?", cursor.Value)
}
limit = cursor.Limit()
}
q = q.Order("created_at ASC")
// 按 id 倒序取最新 limit 条,再反转成时间正序交给视图层
// (气泡从上到下 = 旧 → 新)。
//
// 排序键用 id 而非 created_at:同一毫秒内写入的多条消息 created_at 完全相同,
// 排序不稳定会导致同一页里顺序来回变;id 自增与写入顺序严格一致。
q = q.Order("id DESC")
q = q.Limit(limit)
if err := q.Find(&messages).Error; err != nil {
return nil, errors.Wrap(err, "get messages")
}
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
messages[i], messages[j] = messages[j], messages[i]
}
return messages, nil
}
+4 -1
View File
@@ -26,6 +26,7 @@ type MessageImage struct {
gorm.Model
MessageID uint `gorm:"index" json:"message_id"`
URL string `gorm:"size:500" json:"url"`
ObjectKey string `gorm:"type:text" json:"-"`
FileName string `gorm:"size:200" json:"file_name"`
FileSize int64 `json:"file_size"`
}
@@ -33,6 +34,7 @@ type MessageImage struct {
type CreateMessageImageOptions struct {
MessageID uint
URL string
ObjectKey string
FileName string
FileSize int64
}
@@ -41,6 +43,7 @@ func (db *messageImages) Create(ctx context.Context, opts CreateMessageImageOpti
img := &MessageImage{
MessageID: opts.MessageID,
URL: opts.URL,
ObjectKey: opts.ObjectKey,
FileName: opts.FileName,
FileSize: opts.FileSize,
}
@@ -60,4 +63,4 @@ func (db *messageImages) GetByMessageIDs(ctx context.Context, messageIDs []uint)
result[img.MessageID] = append(result[img.MessageID], img)
}
return result, nil
}
}
+39
View File
@@ -0,0 +1,39 @@
package db
import (
"context"
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestMessageImageCreatePersistsObjectKey(t *testing.T) {
conn, _ := newTestDB(t)
ctx := context.Background()
store := NewMessageImageStore(conn)
created, err := store.Create(ctx, CreateMessageImageOptions{
MessageID: 42,
URL: "https://cdn.example.test/assets/2026/11/private.webp",
ObjectKey: "2026/11/private.webp",
FileName: "private.webp",
FileSize: 1234,
})
require.NoError(t, err)
require.Equal(t, "2026/11/private.webp", created.ObjectKey)
var persisted MessageImage
require.NoError(t, conn.WithContext(ctx).First(&persisted, created.ID).Error)
require.Equal(t, created.ObjectKey, persisted.ObjectKey)
byMessage, err := store.GetByMessageIDs(ctx, []uint{42})
require.NoError(t, err)
require.Len(t, byMessage[42], 1)
require.Equal(t, created.ObjectKey, byMessage[42][0].ObjectKey)
encoded, err := json.Marshal(&persisted)
require.NoError(t, err)
require.NotContains(t, string(encoded), "ObjectKey")
require.NotContains(t, string(encoded), "object_key")
}
+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)
}
+149
View File
@@ -0,0 +1,149 @@
package db
import (
"sort"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
// SchemaMigration 记录一次已应用的数据库迁移。一行 = 一个已跑过的版本。
// 这是迁移机制的账本:启动时按版本号升序执行「账本里还没有」的迁移,已应用的跳过。
//
// 为什么需要它:GORM 的 AutoMigrate 只会「加表 / 加列 / 加索引」,从不「改列名 /
// 删列 / 改约束」。任何破坏性 schema 变更若靠 AutoMigrate 的 HasColumn 判断或幂等补丁
// 硬撑,都会在「改列名 → 新旧两列并存、新列恒空」这种形态下静默丢数据,而且没有任何
// 地方记录「这个库已经跑过哪些变更」。版本表把「跑过什么、顺序如何」固化下来,
// 让迁移可解释、可回看、可审计。
type SchemaMigration struct {
// Version 单调递增的版本号,迁移按它升序执行。
Version int `gorm:"primaryKey"`
// Name 迁移的简短描述,用于日志与人工排查。
Name string `gorm:"type:varchar(128)"`
// AppliedAt 应用时间,仅供审计。
AppliedAt time.Time
}
// Migration 一个数据库迁移步骤。
type Migration struct {
Version int
Name string
// Up 执行迁移。应当尽量幂等,因为早期版本在收编进版本表之前可能已在旧流程里
// 跑过一次;靠版本账本保证「收编后只跑一次」。
Up func(db *gorm.DB) error
}
// migrations 按版本号升序排列的迁移清单。新增迁移时追加到末尾并分配一个更大的版本号。
//
// 收编说明:下面这些迁移对应的函数原本散落在 db.Init 里,靠 HasColumn 判断或幂等硬撑,
// 每次启动都会重新执行一遍。收编进版本表后,它们只在「该版本号尚未记录」时执行一次,
// 之后启动直接跳过 —— 既省掉无谓的全表扫描,也消除了「幂等补丁某天不小心变得不幂等」
// 时对历史数据的重复改写风险。
var migrations = []Migration{
{
Version: 1,
Name: "backfill questions.answered_at",
Up: backfillAnsweredAt,
},
{
Version: 2,
Name: "backfill feature_controls",
Up: backfillFeatureControls,
},
{
Version: 3,
Name: "reconcile legacy chat enabled",
Up: reconcileLegacyChatEnabled,
},
{
Version: 4,
Name: "normalize chat quota",
Up: normalizeChatQuota,
},
{
Version: 5,
Name: "normalize harassment setting",
Up: normalizeHarassmentSetting,
},
{
// 新增列 AutoMigrate 只补 0,而 0 与「无授权」的零值撞车 —— 必须回填,
// 否则升级后存量用户的页面密码会被静默绕过。
Version: 6,
Name: "backfill page password version",
Up: backfillPagePasswordVer,
},
}
// runMigrations 按版本号升序执行尚未应用的迁移。
//
// 执行模型:先建账本表(若无),读取已应用版本集合;对每个未应用的迁移,
// 在事务里跑 Up 并写入账本行。任一步失败即返回错误并终止后续迁移 —— 迁移是有序依赖的,
// 半途而废比硬着头皮跳过更安全(调用方会阻断启动,让运维看到明确的失败版本)。
//
// 与 AutoMigrate 的分工:AutoMigrate 仍负责「新增表 / 加列 / 加索引」这类声明式、
// 幂等、无数据丢失风险的变更(它本来就擅长且安全);版本迁移负责 AutoMigrate
// 做不了的破坏性变更与一次性数据回填。两者在 db.Init 里顺序执行,互不替代。
func runMigrations(db *gorm.DB) error {
if err := db.AutoMigrate(&SchemaMigration{}); err != nil {
return errors.Wrap(err, "create schema_migrations table")
}
var applied []SchemaMigration
if err := db.Order("version ASC").Find(&applied).Error; err != nil {
return errors.Wrap(err, "load applied migrations")
}
appliedSet := make(map[int]bool, len(applied))
for _, m := range applied {
appliedSet[m.Version] = true
}
// 防御:清单必须按版本号严格升序且无重复,否则顺序执行会出错、账本会混乱。
ordered := make([]Migration, len(migrations))
copy(ordered, migrations)
sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].Version < ordered[j].Version })
for i := 1; i < len(ordered); i++ {
if ordered[i].Version == ordered[i-1].Version {
return errors.Errorf("duplicate migration version %d", ordered[i].Version)
}
}
for _, m := range ordered {
if appliedSet[m.Version] {
continue
}
logrus.WithField("version", m.Version).WithField("name", m.Name).
Info("应用数据库迁移")
// 迁移必须在一个事务里完成:Up 成功 + 记版本要么同时生效,要么都不生效。
// 否则进程在「Up 成功、账本未记」之间崩溃,重启后会重复执行一次迁移。
err := db.Transaction(func(tx *gorm.DB) error {
if err := m.Up(tx); err != nil {
return errors.Wrapf(err, "run migration %d (%s)", m.Version, m.Name)
}
rec := SchemaMigration{Version: m.Version, Name: m.Name, AppliedAt: time.Now()}
if err := tx.Create(&rec).Error; err != nil {
return errors.Wrapf(err, "record migration %d", m.Version)
}
return nil
})
if err != nil {
return err
}
}
return nil
}
// AppliedMigrations 返回已应用的迁移版本号集合,供测试与运维查询。
func AppliedMigrations(db *gorm.DB) (map[int]bool, error) {
var rows []SchemaMigration
if err := db.Order("version ASC").Find(&rows).Error; err != nil {
return nil, errors.Wrap(err, "load applied migrations")
}
set := make(map[int]bool, len(rows))
for _, r := range rows {
set[r.Version] = true
}
return set, nil
}
+71
View File
@@ -0,0 +1,71 @@
package db
import (
"testing"
"github.com/pkg/errors"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
// TestRunMigrationsIdempotent 迁移账本幂等:同一组迁移跑两遍,第二遍不重复执行。
func TestRunMigrationsIdempotent(t *testing.T) {
conn, clean := newTestDB(t)
defer clean("schema_migrations", "users", "feature_controls")
// 第一遍:全部迁移都未应用,应执行并落账本。
require.NoError(t, runMigrations(conn))
applied, err := AppliedMigrations(conn)
require.NoError(t, err)
require.Len(t, applied, len(migrations), "第一遍后所有版本都应已记录")
// 第二遍:账本齐全,runMigrations 应立即返回且不报错。
// 若已应用版本被重复执行,迁移事务会因主键冲突报错 —— 无错跑完即证明跳过了。
require.NoError(t, runMigrations(conn))
applied2, err := AppliedMigrations(conn)
require.NoError(t, err)
require.Equal(t, applied, applied2, "重复执行不应改变账本")
}
// TestRunMigrationsStopsOnFailure 迁移失败必须阻断:失败版本不落账本、后续版本不执行。
func TestRunMigrationsStopsOnFailure(t *testing.T) {
conn, clean := newTestDB(t)
defer clean("schema_migrations", "users", "feature_controls")
// 先建账本表(runMigrations 内部会建,但这里手动跑一次无迁移场景需要显式建表)。
require.NoError(t, conn.AutoMigrate(&SchemaMigration{}))
// 用一套独立的迁移清单模拟「第二个迁移失败」:
// v100 成功,v101 失败。预期:v100 落账本,v101 不落,且整体返回错误。
saved := migrations
defer func() { migrations = saved }()
migrations = []Migration{
{Version: 100, Name: "ok", Up: func(*gorm.DB) error { return nil }},
{Version: 101, Name: "boom", Up: func(*gorm.DB) error { return errors.New("intentional") }},
{Version: 102, Name: "never-runs", Up: func(*gorm.DB) error {
t.Error("v102 不应执行,因为 v101 已失败")
return nil
}},
}
err := runMigrations(conn)
require.Error(t, err, "迁移失败必须返回错误")
applied, aErr := AppliedMigrations(conn)
require.NoError(t, aErr)
require.True(t, applied[100], "成功的 v100 应已落账本")
require.False(t, applied[101], "失败的 v101 不应落账本")
require.False(t, applied[102], "被阻断的 v102 不应落账本")
}
// TestMigrationsOrdered 迁移清单必须按版本号严格升序且无重复。
func TestMigrationsOrdered(t *testing.T) {
for i := 1; i < len(migrations); i++ {
require.Less(t, migrations[i-1].Version, migrations[i].Version,
"迁移清单必须按版本号严格升序,第 %d 个版本 %d 不小于第 %d 个版本 %d",
i, migrations[i].Version, i-1, migrations[i-1].Version)
}
}
@@ -0,0 +1,58 @@
package db
import (
"testing"
"github.com/stretchr/testify/require"
)
// TestBackfillPagePasswordVer 覆盖 2026-11-05 那个 P0 的数据面:
// 新增列 AutoMigrate 只补 0,而 0 恰好等于「session 里没有授权记录」的零值,
// 不回填的话存量页面密码在升级瞬间全部失效。
func TestBackfillPagePasswordVer(t *testing.T) {
conn, clean := newTestDB(t)
defer clean("users")
mustCreate := func(domain string, pagePassword string, pageVer uint, askPassword string, askVer uint) *User {
u := &User{
Name: domain,
Email: domain + "@example.com",
Password: "x",
Domain: domain,
PagePassword: pagePassword,
PagePasswordVer: pageVer,
AskPassword: askPassword,
AskPasswordVer: askVer,
}
require.NoError(t, conn.Create(u).Error)
return u
}
// 存量:有密码但版本号仍为默认 0 —— 必须被回填。
legacy := mustCreate("legacy", "hashed-page", 0, "hashed-ask", 0)
// 已轮换过密码:版本号非 0,不能被重置。
rotated := mustCreate("rotated", "hashed-page", 3, "hashed-ask", 5)
// 没有密码:版本号保持 0,回填不应凭空造出授权。
noPassword := mustCreate("nopassword", "", 0, "", 0)
require.NoError(t, backfillPagePasswordVer(conn))
var gotLegacy, gotRotated, gotNoPassword User
require.NoError(t, conn.First(&gotLegacy, legacy.ID).Error)
require.NoError(t, conn.First(&gotRotated, rotated.ID).Error)
require.NoError(t, conn.First(&gotNoPassword, noPassword.ID).Error)
require.EqualValues(t, 1, gotLegacy.PagePasswordVer, "存量页面密码应回填为 1")
require.EqualValues(t, 1, gotLegacy.AskPasswordVer, "存量提问密码应回填为 1")
require.EqualValues(t, 3, gotRotated.PagePasswordVer, "已轮换的版本号不能被覆盖")
require.EqualValues(t, 5, gotRotated.AskPasswordVer, "已轮换的版本号不能被覆盖")
require.EqualValues(t, 0, gotNoPassword.PagePasswordVer, "没设密码的账号不应被写版本号")
require.EqualValues(t, 0, gotNoPassword.AskPasswordVer, "没设密码的账号不应被写版本号")
// 幂等:再跑一次不应把已回填的行推到 2。
require.NoError(t, backfillPagePasswordVer(conn))
require.NoError(t, conn.First(&gotLegacy, legacy.ID).Error)
require.EqualValues(t, 1, gotLegacy.PagePasswordVer, "迁移必须幂等")
}
+94
View File
@@ -0,0 +1,94 @@
// 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"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
// TestQuestionDeleteCascadesHardDelete 验证删除提问时,追问与上传图片也被**真删**,
// 而不是软删残留。
//
// 背景:questions.DeleteByID 对主表用 Unscoped 真删,但历史上一度只有主表真删、
// 追问/图片仍软删,导致提问正文删干净了、追问文本与图片元数据却带着 deleted_at
// 永久留在库里(这正是「真删以免个人数据删不干净」想消灭的问题,却在子表漏改)。
// 本测试用 Unscoped 计数兜底:软删的行在普通查询里不可见,只有 Unscoped 才能看到,
// 因此断言「删除后 Unscoped 计数 = 0」才能证明是真删。
func TestQuestionDeleteCascadesHardDelete(t *testing.T) {
db, cleanup := newTestDB(t)
ctx := context.Background()
questions := NewQuestionsStore(db)
followUps := NewFollowUpsStore(db)
uploadImages := NewUploadImagesStore(db)
t.Cleanup(func() {
// 三张表都要清:follow_ups / upload_images(含链接表)在删除后应该已经空了,
// 但其他子测试的 data 可能残留,用 cleanup 统一兜底。
_ = cleanup("questions", "follow_ups", "upload_images", "upload_image_questions")
})
// 建一条提问
q, err := questions.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "需要被彻底删除的提问",
AskerUserID: 1,
})
require.NoError(t, err)
// 给它加一条追问
created, err := followUps.CreateIfBelowLimit(ctx, CreateFollowUpOptions{
QuestionID: q.ID,
Content: "追问内容,也应被真删",
})
require.NoError(t, err)
require.True(t, created)
// 给它加一张上传图片
img, err := uploadImages.Create(ctx, CreateUploadImageOptions{
Type: UploadImageQuestionTypeAsk,
QuestionID: q.ID,
UploaderUserID: 1,
Name: "secret.png",
FileSize: 1024,
Md5: "deadbeef",
Key: "2026/10/29/secret.png",
})
require.NoError(t, err)
require.NotZero(t, img.ID)
// 删除提问
require.NoError(t, questions.DeleteByID(ctx, q.ID))
// 主表:真删
require.Equal(t, int64(0), hardCount(t, db, &Question{}, "id = ?", q.ID))
// 追问:真删(软删的话这里会是 1)
require.Equal(t, int64(0), hardCount(t, db, &FollowUp{}, "question_id = ?", q.ID))
// 上传图片主体:真删
require.Equal(t, int64(0), hardCount(t, db, &UploadImage{}, "id = ?", img.ID))
// 关联行:真删
require.Equal(t, int64(0), hardCount(t, db, &UploadImageQuestion{}, "question_id = ?", q.ID))
}
// hardCount 用 Unscoped 统计满足条件的行数,能穿透软删除过滤,
// 用于断言「物理上也不存在」。
func hardCount(t *testing.T, db *gorm.DB, model interface{}, where string, args ...interface{}) int64 {
t.Helper()
var n int64
err := db.WithContext(context.Background()).Unscoped().
Model(model).
Where(where, args...).
Count(&n).Error
require.NoError(t, err)
return n
}

Some files were not shown because too many files have changed in this diff Show More