Compare commits

..
19 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
128 changed files with 12707 additions and 3993 deletions
+17 -2
View File
@@ -45,8 +45,23 @@ __pycache__/
# 本地凭据与一次性状态(测试账号密码等),只留在本机,不进版本库。
# 目录说明见 .workbuddy/local/test-accounts.md 的抬头注释。
/.workbuddy/local/
/.codeartsdoer/
# WSL 测试环境的 app.ini 模板:含数据库密码与对象存储密钥,绝不能进版本库。
# 用法见 dev/sync-wsl.sh(同步时必须排除 conf/app.ini,否则会被 rsync --delete 删掉)。
# 本地运行配置与 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` 已推送。教训:**人像与场景的"雾度"要对齐**——场景压白多少,人像也压相当比例。
-427
View File
@@ -1,427 +0,0 @@
# 2026-10-22
## new-sakura 主题:纯 CSS 樱花
用户要求「用编辑器生成 new-sakura,CSS 做樱花、每页位置不同、上传到已配好的 S3」。
- **结论:生成器做不了这个** —— 它的装饰层只支持图片素材(导出打 zip / 传对象存储),
没有内联 SVG 图层。所以改成手写主题 CSS(结构完全遵循生成器输出:令牌 + 10 槽 deco +
scope 覆盖),生成器只用来做视觉验证。
- 实现:`dev/theme-tools/gen_new_sakura.py` 生成 `static/css/themes/new-sakura.css`。
花/瓣/枝/底纹都是内联 SVG 的 data URI(`quote(svq, safe="")` 全编码,避免 URI 里的
逗号被 `TestDecoSlotCount` 当成分层符)。CSS 50KB(gzip ≈12KB)。
- 六个 scope 各一套 `--tb-deco` 位置;窄屏(≤640px)装饰全关、底纹缩到 200px;
深色换亮粉 + 关底纹(浅粉压深底=噪点)。
- 调参踩到的:①花枝 `auto 390px` 放右上角时可见部分只剩一团花 → 缩到 300px 并内移;
②枝上 5 朵 scale≈1 的花是一串糖葫芦 → 改成 8 朵 0.4~0.62;
③底纹 260px 双花双瓣读成「包装纸」→ 改成 300px 一朵花 + 两片瓣、各自错位。
- 对比度:primary 候选 `#c14a72` 只有 4.39:1,压深到 `#b8436a`(4.87);muted `#77626a`(5.27);
深色 `#ff93b4`(8.17)/`#ab9099`(5.82)。
- i18n 四份词典加 `theme.name.new-sakura`(插在 shiroko 之后,gofmt_keep_crlf 后每份只 +1 行);
`conf/app.sample.ini` 的 available 登记。
- ⚠️ 本机 Chrome/Edge 的 localStorage 里**没有**生成器的 S3 配置记录,项目也没有
`conf/app.ini` → 「已预先配置好的 S3」在本机取不到,上传这一步需要用户给凭据。
纯 CSS 主题本身零素材,严格来说 S3 无事可做(已向用户确认)。
**R2 自定义域可用 + 素材包一键上传(接上一条)**
- `r2.337845818.best` 实测公开可读(7 个对象全部 200、Content-Type 正确);桶的 S3 endpoint 仍 403。
- 樱花位图从 `gen_new_sakura.py` 的同一份 SVG 导出(`export_assets.py` 直接 import 它,不另画),
`--upload` 一次传 CSS + 5 张 webp + 生成的 INSTALL.txt(域名/体积写实数)。
- `serve.upload_files` 支持逐文件 `cacheControl`:原来硬编码 immutable,而
`INSTALL.txt` / 镜像 CSS 的键不带版本 → 改了看不到。现在位图 1 天、CSS 5 分钟、README 60 秒。
- ⚠️ **CF 边缘缓存不会因为重传而失效**:第一版以 immutable 传过,`cf-cache-status: HIT`、Age>800,
重传后拿到的仍是旧副本(带 `?cb=` 才是新内容)。清缓存只能去控制台按 URL 清。
- ⚠️ **gzip 纠错**:服务端 `internal/route/gzip.go` 会压 CSS,内联版 51KB 实际传输 **3.9KB**,
位图版 2.7KB + 5 张图 48.9KB ≈ 51.6KB。所以"换位图省 CSS 体积"是错的 ——
位图唯一的理由是素材要给非 CSS 场景复用。文档与 INSTALL.txt 都已改。
- `image_bucket_cdn_host` 沿用 app.ini 同名键(只写主机名),`export_assets.py` 据此拼公开地址。
## 线上 521 排障(box.shiroko.one 打不开)
- 现象:`https://box.shiroko.one` 全路径 **521**,同机 IP+端口正常。
- 实测链路:`box.shiroko.one` → CNAME `cloudflare.182682.xyz`(第三方 CF SaaS 接入/优选,
上游 `source.cfnode.eu.org`),**不是** `<UUID>.cfargotunnel.com`。DNS/TLS 均正常(tls=0),
响应体 `error code: 521`,5 次请求全 LAX,0.6s 快速失败(RST 特征,非超时)。
- 对照实验有价值:`box.tama.guru` 走**同一个** CNAME 目标却正常 301 → 中转 zone 边缘健康;
它不回源,而 box.shiroko.one 要回源才 521 ⇒ 断点在「CF → 源站」这一段。
- `shiroko.one` / `www` / `tama.guru` apex 都 CNAME `navi-ew2.pages.dev`(CF Pages 导航站),与本站无关,
别拿它们的 200 当证据。
- 候选根因(待服务器侧确认):① cloudflared 断开(严格说 tunnel 无 connector 多为 502/1033);
② `scripts/edgeone-origin-whitelist.sh` 的 `REJECT tcp-reset` 拦掉 CF 直连回源——脚本注释里
「521」本就是白名单漏放的既有症状;③ 回源 IP 过期(服务器 IP 变动 / 第三方面板未更新)。
- 注意矛盾点:用户本地能连 IP+端口 ⇒ 白名单大概率没生效或不覆盖该端口 ⇒ 更偏向 ③/tunnel 侧。
**根因已确认(服务器实测)**
- `systemctl is-active cloudflared` = **inactive**,`pgrep` 无进程 → Tunnel 断。
journald 最后一条 2026-10-21T21:42:22Z(CST 10-22 05:42),此后无日志 ⇒ **停了约 7 小时**。
- 死前日志模式:`accept stream listener encountered a failure while serving` +
`failed to accept QUIC stream: timeout: no recent network activity` + `datagram handler
error="context canceled"` + `Retrying connection in up to 1s`,connIndex 2/3 反复 →
UDP 7844 QUIC 长时间不稳(美西 23.94.211.138)。**inactive 而非 failed ⇒ 是被正常 stop 的,
不是崩溃**,与用户「昨晚升级 nginx 到 1.30.x」的时间点吻合(升级流程连带停服)。
- 应用侧全好:`*:5877` 监听(tamabox pid),loopback 200。
- `iptables -S INPUT` 只有 `-i eth0 --dport 8118/5877 -j DROP`,**EO_IN / ipset EO_ORIGIN 都不存在**
⇒ EdgeOne 白名单脚本当前未生效。且 DROP 限定 `-i eth0`,网卡名若不叫 eth0 则规则空转。
- 关键推论:CF 一定走 tunnel 而非直连 5877(否则 DROP 空转应 200 而非 521)⇒ 521 完全由 tunnel 断解释。
**续:start 之后仍 521**
- 用户确认:**5877 只允许 Tailscale(100.84.113.84:5877)访问**,公网靠 iptables DROP 封掉。
⇒ 排除「CF 直连公网 5877」这条路径,CF 只能经 tunnel 回源。
- `systemctl start cloudflared` → `active`,`sleep 5` 后 curl 域名仍 **521**。
- 待分辨:① connector 尚未完成边缘注册(sleep 5 偏短);② 本机 tunnel 与 CF/SaaS 侧
`box.shiroko.one` 绑定的 origin(应为 `<uuid>.cfargotunnel.com`)不是同一个;
③ 该 hostname 的 Public Hostname / ingress 未指向 `http://127.0.0.1:5877`。
- 决定性隔离实验:临时 quick tunnel(`cloudflared tunnel --url http://127.0.0.1:5877`)
拿 trycloudflare 域名直测 —— 200 则应用+出站正常,问题在 CF 侧绑定。
**journald 取证(关键)**
- `protocol=quic`,`location=la01`。从 10-21 13:53:30 起就是死循环:
`INF Registered tunnel connection` → 约 3s 后 `ERR failed to accept QUIC stream:
timeout: no recent network activity` + `datagram handler error="context canceled"` +
`WRN failed to serve tunnel connection error="accept stream listener encountered a failure
while serving"` → `INF Retrying connection in up to 1s` → 再注册。connIndex 0/1/2 全部如此,
每 ~3 分钟一轮,一直持续到 10-21 21:42:40。
- ⇒ **UDP 7844 的 QUIC 在这台机器上基本不可用**(RackNerd 美西 → CF la01),这是历史顽疾,
不是本次新故障。根治手段 = `TUNNEL_TRANSPORT_PROTOCOL=http2`(走 TCP 443)。
- 🔴 但 12:56 `systemctl start` 之后 journal 里**一条新日志都没有**(最后仍是 10-21 21:42:40,
PID 626)⇒ 新进程疑似启动即退出/token 无效,`is-active=active` 可能是重启窗口里的瞬时值。
需 `systemctl status` 看 Restart 计数与 ExecMainStatus。
- 旁证:EdgeOne 那条备用通道同样不通(18080 无监听、EO_IN/ipset 均不存在)。
**结论定案**
- 重大纠正:cloudflared **从未死过**。`systemctl status` 显示 `active (running) since 2026-10-20 05:32:44`,
Main PID 626 存活 2 天+。前面「21:42 后无日志」不是进程退出,而是 QUIC 连接最后注册成功后没再报错
(只有 24h 一次的版本提醒)。所以「tunnel 断导致 521」这个假设是错的。
- 改 `TUNNEL_TRANSPORT_PROTOCOL=http2` 后:4 个 connector 全部 `Registered tunnel connection
... protocol=http2`,`SUMMARY: Environment is healthy. Cloudflared will use 'http2' as primary
protocol`,零错误 ⇒ **tunnel 侧完全健康**。QUIC 抖动问题也顺手解决了(该 drop-in 值得长期保留)。
- 但站点仍 521 ⇒ **问题 100% 在 CF 侧 origin/路由,与本机 tunnel 无关**。
- 公网实测 `23.94.211.138`:5877 / 18080 / 80 / 443 **全部 timeout**(不止 5877)。
⇒ 若第三方 SaaS 的 origin 填的是「公网 IP:5877」,CF 同样连不上 → 521。
这解释了「突然」:用户近期把 5877 收敛为仅 Tailscale(100.84.113.84)可达。
- 本机 tunnel 身份(从 token 解出,注意 token 已明文暴露需轮换):
tunnel id `ba34e35d-e517-498a-b2cd-7275c22e354e`,account tag `0c99c40b475a65b393abaca3ca91731b`。
- 修复方向:①(推荐)第三方面板把 origin 改成 `ba34e35d-...cfargotunnel.com`,并在
Zero Trust → Tunnels → 该 tunnel 的 Public Hostnames 补 `box.shiroko.one → http://127.0.0.1:5877`;
②(不推荐)放行 CF 回源网段到 5877,等于重新暴露源站。
- ⚠️ 安全 P0:tunnel token 在对话截图里明文出现,等同凭据泄露 → 必须 Refresh token 后重装服务。
**真凶:SaaS origin 指向被封的公网**
- SaaS 自定义主机名 `box.shiroko.one` 的「源服务器」= `box-direct.337845818.best`。
- 实测该 origin 域名 **解析到 Cloudflare 自己的 IP**(104.21.47.52 / 172.67.144.141,
`Server: cloudflare`,CF-RAY)⇒ 它是**橙云(proxied)记录**,不是灰云 A 记录。
`http://` 301(Always Use HTTPS)、`https://` **521** —— 它自己也回源失败。
- 源站 `23.94.211.138`(colocrossing,RackNerd 上游):22 / 80 / 443 / 5877 / 18080
**全部 timeout**。不只应用端口,连 SSH 都不通 ⇒ 该机对公网几乎无入站(仅白名单/Tailscale)。
- 结论链:CF 回源 `box-direct` → 该记录橙云 → 真实源站端口被封 → **521**。
所有共用 `-direct` origin 的站点同时 521 ⇒ 用户说的「cloudflared 构建的网站全部 521」
与 cloudflared 无关,是**公网入站收敛**这一个动作把整批站点打掉的。nginx 升级纯属时间巧合。
- 修复(保持「只允许组网」的安全目标):① Zero Trust → 该 tunnel → Public Hostnames 加
`box.shiroko.one → http://127.0.0.1:5877`;② SaaS 面板把每个自定义主机名的「源服务器」
从 `xxx-direct.337845818.best` 改为 `ba34e35d-e517-498a-b2cd-7275c22e354e.cfargotunnel.com`。
注意不能把 `-direct` 改成「灰云 CNAME 到 cfargotunnel」——tunnel 路由要求橙云,SaaS origin 又
要求非橙云,两者冲突,必须直接在 origin 字段填 cfargotunnel。
**⚠️ 纠正(官方文档)**
- 上一条的后半段判断错了。CF for SaaS 文档明确:custom origin **必须**是该账号下
**proxied(橙云)的 A / AAAA / CNAME** 记录,不能用 IP。
⇒ `box-direct.337845818.best` 是橙云,**格式上完全正确**,不是配置错误。
用 tunnel 的正确形态就是「橙云 CNAME → `<uuid>.cfargotunnel.com`」,所以那条路径依然成立,
只是理由不是「橙云错了」。
- 真正断点仍是:`23.94.211.138` 的 **443 从公网不可达**(实测 timeout)。
但用户贴的 `iptables -S INPUT` 里**只有 8118 / 5877 两条 DROP,没有 443**
⇒ 对 443 的封锁来自 iptables 之外(云厂商安全组 / 上游 / 其他链),或者 nginx 压根没监听 443。
- 我上一轮的探测命令 `grep ':(80|5877|18080)'` **漏了 443**,这是取证缺口,需要补
`ss -tlnp | grep -E ':(443|80|5877)'` + `systemctl is-active nginx`。
- 注意:SaaS 回源默认 Host = 原始自定义主机名(box.shiroko.one),而 tunnel 的 Public Hostname
只能从账号内 zone 选(shiroko.one 不在 CF)⇒ 走 tunnel 时必须用 Origin Rule / 源站主机头覆盖,
否则 tunnel 会 404。
**最终根因(用户自查确认):nginx 停了 11 小时**
- 时间线:`10-22 00:12:43 Started`(升级后)→ `02:31:10 Stopping...` →
`02:31:15 Stopping timed out. Terminating.` → `Failed with result 'timeout'` → `Stopped`
→ **中间 11 小时无任何记录** → `13:34:42` 用户手动 `systemctl start` 才起来。
- ⇒ 全站 521 的直接原因就是 nginx 不在 443 监听;CF 回源 `box-direct`(橙云 A → 23.94.211.138:443)
拿不到东西。cloudflared/tunnel 全程健康且**从未参与这条链路**。
- 重启后实测:`box.shiroko.one` 连续 3 次 200,`box-direct` 200,源站 443 OPEN。
- 教训(我的误判链,值得记住):排障时先确认「443 上跑的是什么、进程在不在」(`ss -tlnp` +
`systemctl is-active nginx`),再往 DNS/CDN/tunnel 方向挖。这次因为 CNAME 指向第三方 SaaS
+ cloudflared 恰好健康,被带了很远。
- 遗留技术债:
① `listen 443 ssl http2` 在 1.25.1+ 已废弃 → 改 `listen 443 ssl;` + `http2 on;`;
② `Failed to parse PID from file /run/nginx.pid: Invalid argument` —— 大概率是上次 SIGKILL
残留的 pid 文件,配合 `TimeoutStopSec` 过短(5s 就 Terminating)导致 stop 卡死;
③ 全站无可用性监控(11 小时空窗没人知道)→ 建议外部 uptime + cron watchdog
`*/5 * * * * systemctl is-active --quiet nginx || systemctl start nginx`。
**6) 窄屏覆盖被作用域块压死(真 bug,已修)**
- 现象:`@media (max-width:640px)` 里把 `--tb-deco` 全写 none,手机上却照样是桌面装饰层。
- 根因:媒体查询**不提升特异性**。作用域块 `html[data-theme-style][data-page-scope="box"]`
是 (0,2,1),媒体里写的 `html[data-theme-style]` 只有 (0,1,1) → 压不住;深色作用域块 (0,3,1) 更难。
- 修法:媒体查询里按「作用域通配 `[data-page-scope]`(0,2,1)」+「深色基础(0,2,1)」+
「深色+作用域(0,3,1)」各写一条,靠文件更晚取胜。shiroko 的 900px 块只对浅色有效,
但它的深色块本来就是 none,所以不算坏。
- 守门:新增 `internal/theme/narrow_test.go::TestNarrowMediaOverridesScopedDeco`
——外层每处非 none 的 `--tb-deco`,媒体查询里必须有「同属性名、属性数不少于它」的选择器;
变异验证过(删掉深色基础那条立刻 FAIL)。
- 手机端最终决定**不放装饰层**:试过顶栏与卡片之间那条 35px 带子,box 页 OK、question/admin
页卡片从 y=110 起就切半个 → 变成黏在卡片边的粉色碎块。改用加强版底纹(透明度 ×1.9、格子 180px)。
- 埋点:`gofmt -l internal/theme` 会报 `slots_test.go`(它是 CRLF),新增文件是 LF,别顺手格式化它。
## 站内公告功能(配置驱动)
- 新增 `[announcement]` 段(content/start/end),改完重启生效;判定全在服务端按 time.Now()。
- 改动:conf/announcement.go(解析+Active+单测)、conf.Init MapTo、keynorm sectionKeySets 登记(漏了会误报未知段)、
template.FuncMap 新增 `announcement`(返回安全 HTML,空串 falsy 配 `{{ with }}`)、
internal/template/announcement.go(切段转义→URL 包链接,不能「先转义再正则」否则 &#34; 被吞进 URL)、
header.html 导航下方插 `uk-container-xsmall` 容器包 `.tb-announce`、base.css 样式(StaticVersion → 20261022a)。
- 时间格式:`2006-01-02 15:04` / 带秒 / 纯日期 / RFC3339;裸时间按服务器本地时区(容器是 UTC),启动 INFO 打印解析结果。
- 时间写错只 WARN 关公告不阻断启动(与未知键口径一致);窗口左闭右开。
- 预览验证:TEST_PREVIEW=1 + 临时 test 设 conf.Announcement 渲染 question/list,chrome --headless=old 截图 OK(亮/暗都看过)。
- 公告间距修复:`.tb-announce` 自身的 margin-bottom 会被 UIkit 的
`.uk-container > :last-child { margin-bottom: 0 }` 清零 → 间距必须挂在容器类
`.tb-announce-wrap { margin-bottom: 20px }` 上。这是容器内末元素贴边的通用坑,
以后在 `.uk-container` 里加末位区块都绕这条。
- 环境坑:沙箱 go 是 1.25.5,go.mod 要 1.27.3,GOTOOLCHAIN=auto 需下载工具链模块,
而 GOSUMDB=off 会卡住校验 → 下载工具链那次要放开 GOSUMDB(goproxy.cn 会代理 sumdb)。
**7) 顶栏:实色 → 半透明磨砂(用户:不能全透明,以后别再来一次)**
- 线上 `https://box.shiroko.one/_/tamakyi` 实测:`--tb-navbar=#fdf6f8` 实色,顶栏带(y=20..80)
把右上花枝齐刷刷切一段;`--tb-navbar` 默认是 `--tb-surface`,彩色主题不覆盖就成一条近白横带。
- 第一版改成 `transparent` 被用户否掉:花枝完整了但顶栏"消失",导航没依托、文字压在花纹上。
- 最终:`rgba(253,246,248,0.72)` / `rgba(34,26,30,0.72)` + `backdrop-filter: blur(12px)`
(模糊只能写在规则里,令牌只是颜色)。0.72 有依据:文字 `#6d555d` 纯底 6.34:1,
最坏(深花瓣压顶栏后面)压平后 5.08:1。
- 花枝 `top` 从 -16px 下移到 26px:顶栏透明后 700~760px 宽度下枝头会压到「登录」(截图确认)。
- 守门:`internal/theme/navbar_test.go::TestNavbarNotFullyTransparent` 拦
`transparent` / `rgba(...,0)` / `#rrggbb00`,变异验证过。约定同步写进
`static/css/themes/_scaffold.css` 的令牌注释与 `topics/theme.md`。
- 手机底栏 `#tb-mobile-utils` 在 base.css 写死 `rgba(255,255,255,.92)`,彩色主题要自己调(本例改同色系 0.92)。
- ⚠️ `StaticVersion` → `20261022c`:主题 CSS 改了必须提版本号,否则浏览器拿着旧 css,
现象就是「改了但线上没变」。
- ⚠️ 跑 go 命令时**别加 `GOSUMDB=off`**:本机 go 1.25.5,go.mod 要 1.27.3,
`GOTOOLCHAIN=auto` 要校验 toolchain 模块,关掉 sumdb 会直接报 "verifying module"。
只设 `GOPROXY=https://goproxy.cn,direct GOFLAGS=-mod=mod` 即可(toolchain 已缓存)。
**8) 生成器新增「顶栏」面板:实色 / 半透明(磨砂)/ 全透明**
- 状态 `S.navbar = {mode, alpha, blur}`(默认 glass 0.72 / 12px),基色仍取「配色 → 顶栏」,
`navbarValue(p)` 统一算:solid → `rgb()`(先 flatten 压平旧工程里的 rgba)、glass → `rgba()`、
clear → `transparent`。模糊单独写 `.uk-navbar-container:not(.uk-navbar-transparent)` 规则,
一条同时管深浅(选择器不带 data-theme)。
- **全透明只给预览**:`validate()` 直接拦(导出报错),与 `TestNavbarNotFullyTransparent` 同一条规矩。
面板上写明原因 —— 不然下一轮又会有人问「为什么不能导出」。
- 对比度读数按「压平后的顶栏」算(半透明时是顶栏压在底色上的结果),低于 4.5 变橙。
- 旧工程迁移:配色里塞过 `rgba(...,0.52)` 的,按 alpha 反推档位(1→solid,0→clear,其余→glass)。
- 自检 `?dump=navbar` 10 项;Playwright 端到端 17 项(含滑杆跟手、刷新后保持、迁移三例)。
- ⚠️ `?fresh=1` 每次加载都会清草稿 —— 测「刷新后配置还在」必须导航到不带 fresh 的地址。
- ⚠️ Playwright 的 `fill()` 对 `input[type=range]` 报 Malformed value,要 evaluate 设 value + dispatch input。
- ⚠️ 滑杆步长别用 0.02:0.55 会被吸附成 0.54/0.56,用户以为坏了。改 0.01。
## 单题页「下一条未读」(箱主收件箱式导航)
- 新增 `db.Questions.NextUnread(ctx, userID, excludeID)`:未读 = `is_read=false AND answer=''`,
`created_at ASC` 取第一条,无则返回 `(nil, nil)`。正序=先来先处理,反复取即可清空积压。
- `POST /_/{domain}/{qid}/next-unread`(`question.NextUnread`):**先 SetRead 再跳**。
只跳不标记会「打转」—— 当前这条仍是未读,下一次取最早未读又把它选回来(2 条未读时最明显)。
- `PublishAnswer` 支持同表单第二个提交按钮 `name="next" value="1"`(「回答并下一条未读」)。
- 按钮位置:回答行 `[回答][回答并下一条未读]`(箱主+有下一条时);页脚与「返回提问箱」同一行
放 `下一条未读 ›`(POST 表单 + `uk-button-link`,因为要改状态)。
- `adminBackQuery(ctx)` 抽出(原 BackTo 逻辑),`NextQuery` 进模板 → 答案表单/next-unread 表单的
action 都带上 `?from=admin&sort=...`,跳转后续「返回」仍回带筛选的后台列表。
flamego `ctx.Query` 只读 URL query,所以 POST 必须把参数写在 action 里(不是隐藏域)。
- 预览:`templates/zz_preview_test.go` 新增 `writeQuestionOwnerPreviews`(未回答/已回答 × 深浅)。
⚠️ 预览 HTML 的 `base.css` 是 `/static/...` 绝对路径,file:// 打开**不含主题变量**(暗色看着像亮色);
要验暗色必须 `python -m http.server` 起本地服务走 http。移动端预览本身有横向溢出(waline 表情行所致),非本次改动。
- 移动端排版实测(关键数据):390px 视口下 `.tb-ans-row` 只有 **300px**(360px 视口 270px),
而「回答 90 + 回答并下一条未读 174 + 标记为已读 132 + 间距 20」= 416px,**收窄内边距也不够**(320px)。
→ 手机上一行放不下三个按钮,改成:第 1 行保留原「回答 … 标记为已读」,第 2 行「回答并下一条未读」整行宽。
实现:`@media(max-width:640px){ .tb-ans-actions{display:contents} .tb-ans-state{order:2;margin-left:auto}
.tb-ans-actions .uk-button{order:1} .tb-ans-actions .uk-button.tb-ans-next{order:3;flex:1 1 100%} }`
⚠️ `.tb-ans-actions .uk-button`(0,2,0)特异性高于 `.tb-ans-next`(0,1,0),必须再写一条同级选择器盖 order。
- 页脚两个次级操作之间加 `.tb-foot-sep`(`·`,浅色 #b0b6bd / 暗色 #5c6674),原来两个蓝色链接几乎连成一片。
- Chrome headless 在 Windows 上 `--window-size` < 500 无效(innerWidth 恒 500)——要测真·移动端布局,
用「父页面 + 宽 390 的 iframe」再取 `contentDocument` 量尺寸/截图,媒体查询才会按 iframe 宽度生效。
- 文案定为「回答并进入下一条未读」(en: Answer & open next unread / ja: 回答して次の未読を開く)。
实测宽度:该按钮 202px;661px 视口(桌面态最窄)行宽 526 ≥ 90+202+132+20=444,仍单行;
640px 断点两侧切换干净,无需再调。
## 近期新增功能审阅(10-18~10-22)
- 编译(s3 overlay 桩)+ conf/template/theme/route/security/templates 全测试通过,无 P0。
- 🔴 **P1 实测复现**:`NextQuery` 不含 `from=admin`,而 `adminBackQuery` 首行要求它
→ 「回答并下一条未读」/「下一条未读」POST 后筛选上下文全丢,「返回」掉回箱主页。
实测:`POST /answer?sort=new&status=unread` → `from="" backQuery=""`;
带 `?from=admin` 才返回 `sort=..`。修法:`adminBackQuery` 里 `backQuery.Set("from","admin")`,
并把 `PublishAnswer` 普通分支(question.go:279)也改用 `questionPath`。
- P2:公告单语言(四语站点);裸时间按 time.Local(容器 UTC)易偏 8h;
NextUnread 无 `(user_id,is_read,created_at)` 复合索引且忽略后台筛选;公告出现在 pixel 画板页
(xsmall 容器 vs 全宽画布);new-sakura 磨砂顶栏 backdrop-filter 低端机掉帧风险;
`conf/app.sample.ini:296` 注释行尾残留 `,shiroko`。
- 蜜罐仅做代码审阅(用户要求不进用户说明):无正常用户误封路径,前提是 `trust_proxy` 已开。
- 交付:`outputs/2026-10-新增功能审阅报告.md`、`outputs/2026-10-功能说明.md`(面向一般用户)。
## 修 P1:后台上下文丢失(已落地)
- `adminBackQuery` 改为 `url.Values{"from": {"admin"}}` 起手:返回串同时用于模板 action
与 `questionPath`,POST 回来才能重新认出来源(Flamego `ctx.Query` 只读 URL query)。
- `PublishAnswer` 末尾裸重定向 → `questionPath`,让「回答」这条也保留筛选。
- 新增 `route/question/admin_backquery_test.go` 两条用例;build + route/templates/conf/template 测试全绿。
- 副作用(可接受):`BackTo` 拼成 `/user/questions?from=admin&sort=..`,列表页不读 from。
- `question.go` 是 CRLF:改完跑 `python dev/gofmt_keep_crlf.py`,diff 只有 6 增 2 删。
## 修 P2:公告分语言 / 东八区 / 未读索引(已落地)
- **公告分语言**:新增 `content_zh_cn/_zh_tw/_en/_ja`,`content` 降级为默认回退;
`hasContent()` 改成「任一语言非空即开启」(只看 content 会把「只写英文」误判成未配置)。
模板函数签名变为 `announcement .LangCode`(header.html 同步),判定仍在服务端。
- **时区**:新增 `[announcement] timezone`,默认 `Asia/Shanghai`;`import _ "time/tzdata"`
保证容器无 zoneinfo 也能 LoadLocation。RFC3339 仍以自带偏移为准。
启动日志扩成 `zone/languages/start/end/active_now`,解析结果直接带 `+08:00`。
- **索引**:`idx_questions_user_unread_created (user_id, is_read, created_at)` 加进
`ensureFrequentlyUsedIndexes`;本机测试库 psql 实跑两次验证语法 + 幂等。
- 测试:conf 侧 7 组(语言回退/归一化/东八区/时区覆盖/无效时区)、
templates 侧 2 条渲染断言(zh-CN 与 en 只出现各自文案、窗口外不渲染 `tb-announce`)。
- 未改:`NextUnread` 仍不看后台筛选(产品取舍);画板页公告问题按用户确认忽略(画板已下线)。
## 测试站部署验证(box-dev-api)
- 结论:部署的确实是最新代码。判据:`base.css?v=20261022c`、CSS 含 `.tb-announce-wrap`、
`--tb-primary:#0e6fd0`、`themes/new-sakura.css` 含 `backdrop-filter`。
- **公告分语言在线上生效**:同一页面切 `/i18n/{zh-CN,zh-TW,en,ja}` 拿到四套不同文案,
链接渲染成 `<a target="_blank" rel="noopener noreferrer nofollow">`。
- 移动端(CDP `Emulation.setDeviceMetricsOverride` 390×844, mobile:true):
`scrollWidth == innerWidth == 390`,无横向溢出,公告块 right=390 贴容器边 —— 正常。
⚠️ 纯 `chrome --headless --screenshot --window-size=390` 的截图会让人误判「被切」,
真要量布局用 CDP + mobile:true,别只看图。
- 外部验证不到的三项:时区解析(看启动日志 `zone=Asia/Shanghai` + `+08:00`)、
复合索引(`SELECT indexname FROM pg_indexes WHERE tablename='questions'`)、
「下一条未读」(需箱主登录,且不能靠 POST 探测 —— 路由缺失会落到蜜罐返回 200 假登录页)。
- 偶发一次 CF 520(同 URL 复测 3 次均 200),未复现,非部署问题。
- 本机 curl 走 `http_proxy=127.0.0.1:58589`,node 直连本地调试端口要 `NO_PROXY=127.0.0.1`。
## 登录态实测(自建测试账号)→ 又抓到一个 P1 残留
- 账号:`tbunread<ts>@example.com` / TbUnread!2026(脚本 `Temp/tb_unread_check.py`,注册→发提问→登录→走流程→删题)。
- 🔴 **模板 URL 上下文会把 `?{{ .NextQuery }}` 整段 percent-encode**:
实测渲染出 `action=".../answer?from%3dadmin%26read%3dunread%26sort%3dnew"`,
`ctx.Query("from")` 永远为空 ⇒ 浏览器提交时 P1 修复失效。之前只测了 handler 层,没测渲染结果。
本地 html/template 实验确认:`action="{{ .A }}"`(值是完整 URL)→ 只把 `&` 转 `&amp;`;
`action="...?{{ .B }}"`(值是查询片段)→ `%3d`/`%26`。**以后 query 一律在 handler 拼完整 URL。**
- 修法:Questioner 里生成 `AnswerAction` / `NextUnreadAction`(完整 URL),模板直接引用;
守门 `templates/question_action_test.go`(断言无 `%3d`/`%26` 且有 `&amp;sort=new`)。
- 顺带确认(线上):单题页「回答并进入下一条未读」跳转、页脚「下一条未读」跳转、
全部读完的「没有未读提问了」提示 —— 三条行为都正确,`Location` 带 `from=admin&read=unread&sort=new`。
- 断言坑:页面上有 `/user/questions`(导航)和 `/user/questions?from=admin&amp;…`(页脚返回)两个链接,
用 `re.search` 取第一个会误判「返回丢参数」。要按 href 全量匹配。
## 部署后复验:14/14 通过(含「日志重复」排障)
- 复验结果:`answer?from=admin&amp;read=unread&amp;sort=new`(未被编码)、
回答并下一条 → 次早、页脚下一条 → 再下一条、读完后「没有未读提问了」、
下一条页「返回」= `/user/questions?from=admin&read=unread&sort=new`。数据已删净(0 条)。
- 🔴 **日志「一直重复同一个操作」的真相:是自检脚本,不是站点。**
两个原因叠加:① 补数据循环 `while len(ids) < 3` 没有上限、不校验 POST 结果;
② 每轮发的**内容完全相同**,被 `shared.DuplicateGuard`(同 IP + 相同内容防刷屏,
`route/question/page.go:587`)拦下 —— 返回 200 + 错误提示但不落库,脚本没读提示于是无限重发。
→ 泛化教训:跑端到端脚本时,注入的文本必须逐条不同;循环必须有 attempts 上限 + 「未增长即停」。
- `urllib` 偶发 `TimeoutError` 的根因是代理复用 stale keep-alive 连接:请求头加
`Connection: close` 后不再出现,比加重试更有效。
- 测试账号 `tbunread1022181904`(自建,密码不入库)。提问已全部删除,账号可留作后续自检。
## 测试账号存档(凭据不入库)
- 用户要求「记住别再忘」→ 凭据写在 `.workbuddy/local/test-accounts.md`,
`.gitignore` 已加 `/.workbuddy/local/`(明文密码不随仓库走)。
- 内容:`tbunread1022181904@example.com` / 域名 `tbunread1022181904` / 密码 + 登录四步
(`/login` 取 csrf → `/api/captcha/generate` 算数学题 → POST `g-recaptcha-response="<id>:<答案>"`)。
- 以后要跑登录态自检**先读这个文件复用账号**,不要再注册新的;`admin6` 仍只有域名、无密码。
# 2026-10-22
## shiroko 平涂极简肖像(参考用户给的图)
用户给了一张参考图:平涂零描边、纯黑圆角矩形眼、无嘴、腮红、头占满画面被裁边,
要求做同风格的白子,位置他自己放。
- 产出 3 张透明 WebP:`static/images/themes/shiroko/portraits/portrait-{front,tilt,close}.webp`
(907–1024 × 921,共 197KB),未接进 `--tb-deco`,留给用户自己摆。
- ⚠️ **ImageGen 的 `background: "transparent"` 依然不生效**:返回的是 RGB 无 alpha 通道(实测 mode=RGB)。
- ⚠️ **不能按白底抠**:白衬衫会一起被吃掉,闭合光环内部的白色 floodfill 也进不去(留下白斑)。
解法:提示词里把背景写成一个**具体纯色** `#E6EEF8`,抠图时按该色值算 alpha:
`alpha = clip((maxChannelDiff - 6) / 12)`,再 `MinFilter(3)` 收 1px。实测头发 alpha 全 255,
中间过渡只占 2.5–6%(全是边缘),不会被误抠。
- 水印在右下角("AI生成 WORKBUDDY"),统一裁掉底部 10%,切口平直,正好可以贴页面下沿。
- 提示词关键:no outlines / no gradients、眼睛写死 `solid black vertically elongated rounded rectangles`
(否则出白眼球+高光+睫毛)、无嘴要重复强调。3/4 侧脸那版还是画出了嘴唇,弃用。
## 分享卡「二维码下链接不对齐 + 长链接乱掉」(已定位,待批准落地)
- 根因在 `route/question/share.go` 的**字宽估算**:`charPixelWidth` 把 `unicode.Punct/Symbol`
一律按 1.0em 计,而 URL 里的 `: / . _ -` 在 Noto Sans SC 里只有 0.25~0.35em。
- 实测(NotoSansSC + 本机原型 `dev/tmpcard`):`https://box.shiroko.one/_/tamakyi` 15px 真实
227.9px、估算 352.5px(+55%);卡片可用宽仅 225px → 触发缩字号到 **9.57px**,实际只画 145px 宽,
且起点按估算算 → 整段左偏 **40px**。dev 域名 7.24px / 偏 39px;长域名 5.26px / 偏 34px。
- 纯中文不受影响(CJK 就是 1em,偏差 0);底部水印 `Powered by 狼的提问箱` 偏 9.8px。
- 正确测法:`Font.HMetric(scale).AdvanceWidth` + `Font.Kern` 累加,scale=size*64(DPI 72)。
实测与 `freetype.Context.DrawString` 返回笔位完全一致(227.88 vs 227.88)—— 以后居中文字都用它,
别再靠字符宽度系数猜。
- 只修测量还不够:等比缩字号没有下限也没有断行/省略兜底,长链接必然掉到 5px 以下。
- 另记:QR 用 `qr.Image(256)` 再 NearestNeighbor 到 165,41 模块 → 每模块 4.02px,模块宽窄不一;
`qr.Image(-k)` 按模块整数倍直接出图更锐(41*4=164)。
- 交付:`outputs/sharecard-link-compare-2026-10-22.png`(现状/修复后对照)。
**已落地(用户选方案 A:单行优先,放不下才拆 host/path)**
- `measureTextWidth` 改为 `HMetric.AdvanceWidth + Kern` 累加,字体不可用时才回退估算;
所有 `drawTextCentered` 自动吃到修正(链接、水印、提示语)。
- 新增 `displayURL`(去 `https://`)/ `layoutLink`(单行 15→13 → host+path 两行 13→11 →
host 中间省略 @11)/ `ellipsizeMiddle`;`LinkMaxWidth = QRCardW-40 = 230`。
- 卡片内容高度改为按行数算,两行时 startY 自动下移(281 ≤ 315,顶距 17)。
- 守门测试 `route/question/share_layout_test.go`:测量=笔位(0.01px 级)、限宽、
居中(按着墨边界 ±3px)、`TEST_PREVIEW=1` 出图。**变异验证过**:把 measure 换回估算,
三条用例全 FAIL(偏移 39/59px,且文字真的溢出卡片 615 < 660)。
- 删掉了临时原型 `dev/tmpcard`(结论已进测试与 outputs 图)。
- P1 未做(二维码模块整数倍、间距 4px 栅格、水印相对左侧内容区居中)—— 等用户看完效果再定。
- 已推送 main:`0054e50`(修复 + 测试 + go.mod 依赖转正)、`80e042d`(记忆/忽略规则),等测试环境部署复验。
**dev 部署后复验(box-dev-api)**
- `/_/tbunread1022181904/share-card` → **500「分享图生成失败:字体文件未配置或无法加载」**。
- 定位:不是代码回退。`static/fonts/` **从未入库**(`git log -- static/fonts` 空、`git ls-files` 无、
也没被 ignore),`static/fs.go` 的 embed 清单里也没有字体;`/static/fonts/*.otf` 走 HTTP 是 302 回首页。
生产能出图(200 jpeg)纯粹因为老环境手工放过字体。
- 代码侧补强(`d51629e`):`getFont` 去掉 `sync.Once`(失败不再永久缓存,补文件即生效)、
错误信息带「已尝试路径」、候选路径 = font_path → `static/fonts/*.{otf,ttf,ttc}` → 系统 CJK 字体;
`app.sample.ini` 注明必须自备。
- 顺带做了 P1 第一项:`generateQR` 按模块整数倍出图(41 模块 @165 → 每模块 4px / 符号 164px 居中),
头像内嵌改为按目标尺寸等比 + 2px 白边。新增 `TestGenerateQRIsModuleAligned`(扫描线游程必须是
模块宽度整数倍,变异验证过)与 `TestGenerateQRWithLogo`(中心头像、四角静默区纯白)。
- 待用户:在 dev 部署目录放字体(生产上有同一份)后我复验;Dockerfile 仍是旧模板
(module path 写成 NekoWheel/NekoBox、没 COPY static、golang:1.20 低于 go.mod 要求的 1.27.3),
需确认 dev 是否走容器部署。
**用户放好字体后复验(200 OK)**
- 实测着墨居中偏差:链接两行 −0.5 / 0.0px、提示语 −1.0px、底部水印 480.0(原偏 9.8px)——字宽测量修复确认生效。
- 单题卡要「已回答 + 非私密」才出图:提交的提问必须先回答,否则 403/302。
探针脚本 `.workbuddy/local/qcard_probe.py`(提问 → 登录 → 回答 → 拉图 → 删除,不入库)。
- **抓到上一版自己引入的回归**:`generateQR` 把 4 模块静默区画进了图里,49 模块挤进 165px
→ 符号只有 111px(3px/模块),比改造前的 135px 还小。960 卡缩到 480 宽时模块 1.5px,
gozxing 直接解不出。
修法(`b35c2a8`):`qr.DisableBorder = true`,图像只出符号,静默区交给白卡;
调用方按实际符号尺寸居中排版;单题卡 `QQRSize` 128 → 150(37 模块拿到 4px);卡内间距收到
24/20/20/16。profile 与单题卡二维码均 111 → 148px,gozxing 在 960 原图与 75% 缩放图上都能解出正确 URL。
- 结论性数字:**960×540 下 4px/模块是当前上限(profile 卡 37 模块 @148px,用满 90%)**;
缩到 480 宽仍解不出 → 真要抗微信二次压缩只能上 2x 渲染(1920×1080),1C1G 需实测 CPU。
- `dev/overlay.json` 原来指向 /tmp 的桩文件(早已失效),改成指向仓库内 `dev/s3stub/s3_stub.go`。
**第二轮复验(用户重新部署后)+ 换 2x 的替代方案**
- 部署确认:箱子卡 200,二维码 148px、左右留白 61/61、三处居中偏差 −0.5/0.0/−0.5px。
- 延迟别拿 HTTP 测:这台机器 **静态 CSS 的 TTFB 就 2.7s**(TLS+代理),分享卡 TTFB 2.4s
比静态资源还快,测不出服务端渲染耗时。要测就在本地跑基准。
- 本地基准(1C1G 上大约 ×3~5):1x 像素工作 ≈ 40ms(JPEG 12 + 背景缩放 25 + 画布 3),
2x ≈ 103ms;**二维码生成只有 0.8ms**,与尺寸无关 → 放大二维码不花 CPU,2x 贵在编码与缩放。
- 实验(37 模块 / 真实 55 字符链接 / 含头像,gozxing 逐宽度解码):4px/模块 480 宽就挂,
5px 能撑到 320。降纠错 Q→M 只省 11% 模块且容错 25%→15%,不划算;PNG 救不了缩放。
- 落地 `72f08cd`:`QRCodeSize` 165→190(→185px / 5px 模块)、`QRCardH` 315→335,
新增 `TestGenerateQRModuleSizeAtLeast5`(改回 165 立刻 FAIL)。单题卡仍是 150(4px),未动。
- 比 2x 更值得做的:进程内缓存只有 16 条 + TTL 1h,箱子多时冷渲染频繁 → 结果落盘/R2。
+184
View File
@@ -195,3 +195,187 @@ gzip 实现质量高、后台移动端短标签方案做得细。
把一帧拆成多段,按「一帧=一消息」解析会让缓冲区永久错位,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),必须用绝对路径。
-123
View File
@@ -1,123 +0,0 @@
# TamaBox-v4 长期笔记(索引版)
明细已拆分到 `topics/*.md`(按需 Read 全文):
`coop` 协作 / `build` 构建测试 / `embed` 行尾·模板·embed / `review` 审核处置举报 /
`gorm` GORM / `style` 前端样式 / `theme` 主题 / `builder` 主题生成器 / `share` 分享卡 /
`honeypot` 蜜罐多家族伪装 / `stats` 全站数据页 / `sec` 安全评估·客户端 IP 信任 /
`quality` 业务正确性·运维·前端(2026-10-29) / `misc` 其它约定。
新增(2026-10-29 数据面板批次):`insights` 箱主数据面板(box_views 表 + 异步记录器 +
`/user/insights` 页面 + 图片前端压缩)—— 详见 topics 可暂并入 quality,或另起 topic。
## 协作
- 结论分级 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`~~ **2026-10-27 起可在 WSL2 Debian 上真跑**(PG+Redis+systemd,端口 5877):
详见 topics/build.md「本地部署」小节。要点:`env -u GOROOT`(bashrc 指着 1.26.3)、
`[recaptcha] captcha_type = math`、Windows 侧 127.0.0.1:5877 被占要用 WSL eth0 IP。
HTTP 端到端与 P1-3 的双路径 A/B 都能在这里做(回环=可信、跨主机=不可信)。
- python urllib 不稳定(用 curl);python 别用 `/tmp`;PIL 在 `~/.workbuddy/binaries/python/envs/default`。
- **Windows 上 `Path.write_text()` 默认把 `\n` 写成 `\r\n`** —— 改 LF 文件必须传 `newline="\n"`
或走 bytes,否则整个文件行尾翻车。症状:`gofmt -l` 突然把整文件标为未格式化。
**排查看 `git diff --stat`**:真实改动应是几十行,整文件重写就是行尾变了;
恢复用 `git show HEAD:<file>` 取其行尾再统一替换。
另:本项目 `core.autocrlf=true`,仓库存 LF、工作区 checkout 成 CRLF —— 判断基准要看 `git show`。
## 行尾 / 模板 / embed(topics/embed.md)
- 行尾混合不可推断,改前用 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`。
- 写路径查功能开关用 `db.FeatureStateOfStrict`(失败即拒);`FeatureStateOf` 的
fail-open 只给读展示用 —— 它的默认值对提问/投票是「开启」,等于让一次配置读失败
绕过箱主的关闭与定量上限。
- 处置四级 L1–L4;到期靠 `expires_at` 无 cron;`restrict` 是替换语义;比较用 `RestrictionAtLeast`。
- L2 强制私密须在审核通过后、与写回答同一条 UPDATE(`AnswerByIDPrivate`)。
- `ListAPI` 不经 Pager 中间件,L4 与页面密码须自校验;分享卡拦截须在缓存查询之前。
- 举报:免登录+验证码+限流;同 IP 同题唯一索引 → 幂等;箱主只看次数不给 IP。
- 告警 24h≥8 或 7d≥20 → 邮件;`[security] security_alert_email` 为空则静默跳过。
- **处置覆盖面:新增任何「用户发起」的写入口都要问一句「这里要不要查 `ActiveLevel`」**。
提问链路 7 处齐全;投票链路曾 0 处(L4 后 `/vote/{token}` 照常可投、可发新投票、探索页照常曝光),
2026-10-29 已补齐:`vote.Page`/`vote.Submit` 拦 L4、`VotePublish` 拦 L2、探索页按 `ActiveLevels` 批量过滤。
## GORM(topics/gorm.md)
- 默认值必须带引号;事务里每句 `tx.WithContext(ctx)`;链式对象带状态,统计/更新各自重建 query。
- 软删除+唯一索引 = 槽位永久占用;级联删除一律 `Unscoped()`。
- **「用户主动删除」= 真删**:模型全带 `dbutil.Model` 的 `DeletedAt`,默认 Delete 是软删 ——
提交记录里的 `voter_ip`/文本、提问里的 `FromIP` 会永久留在库里。删投票/删提问已改
`Unscoped` 并收进一个事务(`Votes.DeleteWithCascade`、`Questions.DeleteByID`)。
- **配额必须与内容同事务**:`incrementQuotaTx(tx,...)` 是唯一实现;别在 handler 里
再补一次 `IncrementQuota`(那是独立写入,失败只 Warn → `quota_count` 永久偏低)。
注意口径按功能不同:提问/私聊是「条数」,投票是「累计收到的票数」。
## 前端样式(topics/style.md)
- `static/css/base.css`(`?v=` 取 `conf.StaticVersion`,改完 +1)。令牌在 `:root` + `html[data-theme="dark"]`,新样式一律 `var(--tb-*)`。
- 移动端(≤640px)语言/主题按钮在固定底栏 `#tb-mobile-utils`;箱主页**有意**无语言切换。
- **错误页不套模板**:`f.NotFound` 不经过组级中间件(`template.Templater`/`Contexter`
连同 session、CSRF 都在 `f.Group` 里),在里面注入 `template.Data` 会 panic。
404/500 统一走 `internal/route/errorpage.go` 的内联 HTML + `i18n.T`,
语言从 `Accept-Language` 猜(`pickErrorPageLang`)。Recovery 也是自写的。
- **提问者找回自己的提问**:`templates/question/{success,list}.html` + `POST /api/v1/ask-status`。
记录只在本机 localStorage;接口必须同时给对 token 才返回信息,三种失败情形响应完全一致。
- UIkit 3.3.3:`.uk-flex::before/::after{display:none}`;主色是编译期常量,需在 base.css 末尾映射。
- 视觉自检:`TEST_PREVIEW=1 go test ./templates -run TestGenPreview`;截图用 `chrome --headless=old`。
## 主题(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/sec.md)
- 权威头 `CF-Connecting-IP` / `EO-Client-IP` 只在 TCP 对端可信(回环 或 `trusted_proxies` 命中)
才采信,公网对端一律忽略;唯一读取点 `internal/shared/ip.go`,别在别处另读 header。
- 生产回源 = cloudflared Tunnel 回本机(对端 127.0.0.1)→ 零配置;`trust_proxy` 两套环境都 true。
- **EdgeOne 已取消**(境内速度差)。重新启用前必须把 EO 回源网段写进 `trusted_proxies`,
否则 EO 线退化成按边缘 IP 聚合(限流互挤、自动封禁误伤整片)。`app.sample.ini` 已注明。
- 跳转参数只走 `shared.SafeRedirectTarget`;`url.Parse` 会解 `%0d%0a`,必须逐 rune 查 `u.Path`。
- **同一 handler 里 GET 有所有权校验 ≠ POST 有**(私聊 `SendMessage` 曾漏,可跨箱骚扰)。逐分支核对。
- CSP 默认 **enforce**(逃生口 `csp = report|off` 后重启);`script-src` 必须含 `mami.shiroko.one`
(umami 动态注入,grep 抓不到)与按 `sentry_dsn` 条件放行的 `browser.sentry-cdn.com`。
改 CSP 白名单后必须跑 `csp_sweep.py`(真浏览器巡检),静态检查不算数。
- 注册防枚举:邮箱重复/停用与正常注册共用同一个成功出口;代价是忘密只能走找回。
- **`flamego.Classic()` 自带 `Logger()` 会记 `RequestURI`(含 query)** —— `?t=` 私密题钥匙、
`?code=` 改密凭证都会明文进日志(已实测)。要自写访问日志,只记 `URL.Path`。
- 报告 `outputs/security-audit-2026-10-25.md` + 复现脚本 `outputs/sec-audit-2026-10-25/`;
非安全向的复盘见 `outputs/review-2026-10-29.md`。
## 其它约定(topics/misc.md)
- 健康检查:`/healthz` 永远 200(不碰任何依赖,只看进程活着)+ `/readyz` 查 DB(失败 503)。
别把 DB 检查并进 healthz —— 数据库抖动会让进程被反复重启,对恢复毫无帮助。
- 校验用 `valid:"required;maxlen:N"` 标签。
- 只有箱主 `/user` 面板,**无管理员角色、无站方后台**(用户要求不要加):站方动作只走 CLI / 邮件 / 改配置重启。
- CLI 有两个构建入口必须同步:根 `go build .` 与 Dockerfile 的 `./cmd/`。
- 现有 CLI:`web censor uid ban restore reports restrict unrestrict hide-public`。
- git `https://git.shiroko.one/tamakyi/tamabox`(main),`git push` 可直推;`outputs/` 已 gitignore。
- **ImageGen 的 `background: "transparent"` 至今不生效**(返回 RGB、无 alpha 通道)。要透明素材:
提示词把背景写成一个**具体纯色**(不要写 white,白衬衫/光环会被一起吃),
再按该色值算 alpha:`alpha = clip((maxChannelDiff - 6) / 12)`,最后 `MinFilter(3)` 收 1px。
闭合图形(如光环圆环)内部的白/底色用 floodfill 填不到,按色值抠才干净。
- 生成图的右下角有 "AI生成 WORKBUDDY" 水印,做素材时统一裁掉底部约 10%(切口平直,正好贴页面下沿)。
-62
View File
@@ -1,62 +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`。
- ⚠️ **验证 Go 二进制里有没有某个中文字符串,不能用 `strings`**:GNU strings 默认只输出
可打印 ASCII(0x20–0x7E),UTF-8 中文(0xE4+ 开头)整段被丢弃,`strings x | grep 中文`
恒为 0 —— 曾据此误判「用户没重新编译」,实际二进制是新的。
正确做法:`grep -ac "中文字符串" 二进制文件`(实测同一个二进制:strings=0、grep -a=1)。
- ⚠️ 替换二进制后**必须重启进程**:`go build -o X` 只改磁盘文件,正在跑的进程仍是旧映像。
若服务由 systemd/supervisor 管理,再手动 `./X web` 会因端口被占直接退出。
- ⚠️ **分享卡字体不在仓库里**:`static/fonts/` 从未入库也没 embed(`static/fs.go` 的清单里没有),
`/static/fonts/...` 走 HTTP 是 302 回首页。新环境不手工放字体 → `share-card` 恒 500
「字体文件未配置或无法加载」。老环境(如生产)能出图只是因为当年手工补过文件。
排查口诀:响应体是这句中文 → 一定是部署侧缺文件,不是代码回退。
`getFont` 现已支持 candidates(`font_path` → `static/fonts/*` → 系统 CJK 字体)且不缓存失败,
补文件后无需重启。
- ⚠️ **`GOSUMDB=off` 会让 toolchain 自动下载失败**:go.mod 要 1.27.3 而 PATH 上的 `go` 是旧版时,
会报 `golang.org/toolchain@...: verifying module: checksum database disabled by GOSUMDB=off`
(`go version` 显示的还是 shell 包装器的版本,容易误判成「版本没变」)。
解法:直接用缓存里的 toolchain 并禁用自动切换——
`PATH="$HOME/go/pkg/mod/golang.org/[email protected]/bin:$PATH" GOTOOLCHAIN=local go test ./...`
## 本地部署(WSL2 Debian,2026-10-27 打通)
用户本机有 WSL2 `Debian`(可 `wsl.exe -d Debian -u root` 免密进 root)。在这上面跑通了完整服务,
从此 HTTP 端到端不必「部署到测试站才能做」。要点:
- **GOROOT 是个坑**:`~/.bashrc` 里 `export GOROOT=/usr/local/go`(1.26.3)。用 `/opt/go/bin/go`
(1.27.3,从 dl.google.com 下的 tar 解到 /opt)时必须 `env -u GOROOT`,否则
`compile: version "go1.26.3" does not match go tool version "go1.27.3"`。
另:GOCACHE 被旧版本编译过也会报同样的错 → 换独立 `GOCACHE=~/.cache/go-build-tb`。
- **依赖**:`apt-get install redis-server python3 python3-requests gcc libwebp-dev pkg-config`
(有 libwebp 后 `internal/storage/s3.go` 正常编译,不再需要 s3stub overlay)。
session 与 cache 都硬依赖 redis,6379 不通就起不来;`systemctl start redis-server` 即可(WSL 有 systemd)。
- **库**:DSN 不带库名 → 连的是与用户名同名的库。测试还要 `CREATEDB` 权限(`NewTestDB` 自建临时库)。
`CREATE ROLE tamabox LOGIN PASSWORD '...' CREATEDB; CREATE DATABASE tamabox OWNER tamabox;
CREATE DATABASE nekobox OWNER tamabox; CREATE SCHEMA nekobox AUTHORIZATION tamabox;`
- **配置坑**:`data/data.txt` 里的 `[app] title`、`[server] xsrf_expire`、`[captcha] provider`、
`[rate_limit] *` 全是当前版本不认识的键(启动 WARN 后忽略)。验证码要关外网校验得写
`[recaptcha] captcha_type = math`。`external_url` 用 http → session cookie 不带 Secure,本地能登录。
- **进程托管**:`/etc/systemd/system/tamabox-test.service`(User=tamakyi、WorkingDirectory=源码目录、
ExecStart=<二进制> web、日志 append 到文件)。`systemctl restart` 必须 root,普通用户报 Access denied。
- **访问路径**:Windows 侧 `http://127.0.0.1:5877` 被本机别的进程占用(返回 502),要用
`http://<wsl eth0 ip>:5877`。这个差异正好白送一套 P1-3 的 A/B:
WSL 内 `curl 127.0.0.1` = 回环对端(模拟 cloudflared 回源),Windows 侧访问 = 非回环对端(模拟公网直连)。
- **抓 IP 伪造**:临时配 `ip_blacklist = 1.2.3.4` + `ip_blacklist_redirect_url`,带头 curl 看有没有 302,
比读日志直观;配合日志里的 `remote_addr` / `header_ip` 双重取证。
- **限流会绊脚本**:`/api/captcha/generate` 按 IP 限流,批量跑用例会 429,
`audit.get_captcha` 已加退避重试。SMTP 密码不对只会打日志(`526 Authentication failure`),不影响用例。
-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` 已清理)。
-135
View File
@@ -1,135 +0,0 @@
# 安全评估与客户端 IP 信任
报告:`outputs/security-audit-2026-10-25.md`;复现脚本 `outputs/sec-audit-2026-10-25/`(可重跑)。
## 客户端 IP 采信(P1-3,2026-10-26 已修)
- 唯一读取点:`internal/shared/ip.go` 的 `ResolveClientIP`,23 处调用共用。
**不要在别处另读 `CF-Connecting-IP` / `EO-Client-IP`**,否则绕过校验。
- 规则:权威头只在 `headerSourceTrusted(remote)` 为真时采信 ——
对端是回环地址,或命中 `trusted_proxies`。公网对端带来的权威头一律忽略,回退 remote。
- XFF / X-Real-IP 仍只认 `trusted_proxies`;回环**不**隐含信任这两个头。
- 公网对端却带权威头 → 进程内一条 WARN(`remote_addr` + `header_ip`)。
出现这条日志 = 要么有人在伪造,要么 CDN 直连回源但没登记网段。
- 不能靠「哪个头存在」推断请求走了哪家 CDN:客户端自带任意头都能反向利用。
## 部署形态
- 生产/测试都是 `trust_proxy = true`,回源 = **cloudflared Tunnel 回本机**(对端 127.0.0.1),
因此修复后零配置变更。
- **EdgeOne 已取消**(境内访问速度不理想),当前只有 Cloudflare 一条线。
重新启用前必须把 EO 回源网段写进 `[security] trusted_proxies`,否则 EO 线退化成按边缘 IP 聚合
(限流互挤、自动封禁误伤整片)。注释已写进 `conf/app.sample.ini`。
- `box.shiroko.one` CNAME 到第三方 `cloudflare.182682.xyz`(CF SaaS 优选),该 zone 的
Transform/WAF 不归我们管 —— 别把安全前提建立在那边的规则上。
- 源站隐藏:外网尽力排查(A 记录 / certspotter 51 个子域 / MX / 候选 IP)未找到直连入口;
仍须服务器侧 `dev/security-selfcheck.sh local` 确认 `listen = 127.0.0.1:5877`。
## 实测确认「防护到位」(别重复怀疑)
CSRF 绑 uid(无 token POST → 400);18 条 XSS 载荷全灭(含实体编码、`java\tscript:`、
协议相对 `//`、反斜杠);跨箱题目/投票越权全 302;页面密码对主页/整箱分享图/单题分享图/API
四个出口都生效;pixel/service/ShareCard 无 SSRF;`exec cwebp` 不接用户输入;SMTP 默认校验证书;
`RegenerateID` + `pwd_ver`;TLS1.0/1.1 拒绝;无 CORS 头;测试站蜜罐已开。
## 已修(2026-10-26)
- **P1-1** 外链 `<img>`:`markdown.go` 的 `safeImageSrc` 只放行站内相对路径与自有图片 CDN 主机
(配置来源 `image_bucket_cdn_host` / `aliyun_bucket_cdn_host` / `<bucket>.<endpoint>` / `external_url`);
`a[href]` 仍允许外站。无合法 src 的 img 整段丢弃。
- **P1-2** 密码:page/ask 密码 `minlen:6`(`form.PasswordMinLen`)+ handler 二次校验;
提问密码比对挪到验证码之后(`askPasswordDenied`,私聊与普通提问各调一次);
页面密码失败锁定 `shared.AttemptLimiter(5, 10min, 10min)`,键 = 箱主ID + LimitKey(IP)。
- **P1-3** 权威头:见上「客户端 IP 采信」。
- 部署后验收:`python outputs/sec-audit-2026-10-25/verify_p1_fixes.py`,四项应全 PASS
(旧版本上实测四项全 FAIL)。跑完会自动还原严格度/密码/测试提问。
## 第二轮修复(2026-10-27,P2/P3 全清)
- **跳转白名单收敛为唯一实现 `shared.SafeRedirectTarget`**(`internal/shared/redirect.go`),
登录 `to` 与 `/_/<domain>/password` 的 `next` 共用。规则:拒绝空/含 `\`、控制字符、空格;
必须单斜杠开头、非 `//`;`url.Parse` 后不得有 Scheme/Host/User/Opaque;遍历 `u.Path` 的 rune
拒 `\`、`<0x20`、`0x7f`(**关键**:`url.Parse` 会把 `%0d%0a` 解码成 CRLF,只看原始串抓不住);
最后 `path.Clean` 再验一次前缀。测试在 `redirect_test.go`(25 条定向 + 全量不变式)。
- **注册防枚举**:邮箱重复/已停用与全新注册走**同一个** `registerSucceeded(ctx)` 出口
(302 → `/login`、无 alert),日志里才留痕。域名占用仍明文提示(不然用户没法选)。
代价:忘密用户只能走「找回密码」。
- **CSP**:`internal/route/csp.go` + `[security] csp = off|report|enforce`(默认 report)、
`csp_report_uri`。`script-src` 白名单必须含 **`https://mami.shiroko.one`**——
`templates/base/header.html` 用 `document.createElement("script")` 动态注 umami,
静态 grep `<script src=` 抓不到,切 enforce 后会在无头 Chrome 里报违规。
存储 API endpoint(`r2.cloudflarestorage.com` 等)**不要**写进 CSP,只登记对外发资源的 CDN 域。
- **`no-store`**:`/user`、`/_*` 命中 `isNoStorePath`,`/share-card` 例外(要能被爬)。
- **`securityHeaders` 注册顺序**:原先排在静态资源中间件之后,静态文件拿不到 nosniff/HSTS,已前移。
- **封禁落库** `internal/db/ban_record.go`(`ban_records`:ban_key/reason/duration_minutes/expires_at),
`newBanner()` 的 `OnBan` 异步写(3s 独立 ctx,失败只告警,store 为 nil 直接跳过)。
- 其它:token 比较改 `subtle.ConstantTimeCompare`;`/explore` 页码上限 500;
429 统一 `tooManyRequests()`(带 `Retry-After`);`ctx.JSON/JSONError` 显式补 Content-Type。
## 全局复查新发现(2026-10-27)
- 🔴 **私聊 `SendMessage` 缺所有权校验**(高危):`internal/route/chat/chat_handler.go` 只有 GET 分支
校验 `conv.OwnerID != pageUser.ID`,POST 没有 → 被拉黑的人换个 domain 就能继续往他人会话发消息、
触发邮件、绕过屏蔽词与日限额。已补校验 + `verify_chat_owner.py` 端到端 3/3 + **变异验证**
(去掉校验后「换 domain 不能操作他人的会话」立刻变红)。**教训:同一 handler 里 GET 有校验
不代表 POST 有,逐分支核对。**
- 🟡 页面密码 `next` 与 P2-3 同源缺陷(`/\evil` 外跳),随白名单收敛一并修好。
- 🟡 **分享卡字体**:无可用字体时每个请求要烧 0.7–4s CPU,而 `/share-card` 是公开端点。
现在成功永久缓存、失败缓存 1 分钟(`fontFailRetry`);实测连发 3.44s → 0.53s。
### 分享卡字体的硬限制
`github.com/golang/freetype` **只支持 TrueType(glyf)轮廓**,CFF/OTTO 一律报 `bad TTF version`。
`NotoSansCJK-Regular.ttc` 是 OTTO → 就算 `ttcFirstFont()` 正确提取出子字体也仍不可用。
`.ttc` 是字体集合,需先按 `ttcf` 魔数 → `numFonts` → 子字体偏移 → 重排 sfnt header + 表目录
(4 字节对齐)才能交给 freetype(`route/question/ttc.go`,测试 `ttc_test.go` 自造最小 TTC)。
**2026-10-27 已收敛**:`fontCandidates()` 补 glyf 系统候选(wqy-zenhei / wqy-microhei /
DroidSansFallbackFull / uming)且排在 Noto(CFF) 之前;`static/fonts` 同时按 CWD 与可执行文件目录找;
`CheckShareCardFont()` 在 `runWeb` 启动时探一次(Info 打路径 / WARN 给 `apt install fonts-wqy-zenhei`)。
本地实测该 apt 包即可零配置出图(`/share-card` 500 → 200 image/jpeg)。生产同理,别用 Noto CJK。
## 仍待决策/未做
- 限流/失败计数迁 Redis(单进程内存态,多副本会各算各的)—— 明确不做,配置注释已写明单副本前提。
- 匿名投票清 cookie 即换身份:只加了结果页匿名说明,未做 IP 唯一索引(会被 NAT/移动网误伤)。
- 颜色字段只校验长度(有 Go 上下文转义兜底,非安全问题)。
- P1-1 残余:存量内容里的外链 img 仍在库里,仅渲染层不再输出;将来若加「原样导出」入口会复发。
- CSP 默认 **enforce**(2026-10-27 定):不给 `csp_report_uri` 的 report-only 等于没装,
留个不生效的开关更容易让人误以为已防护。白名单由 `csp_sweep.py` 真浏览器巡检背书
(18 页面 + 2 图片端点,零违规);误伤时的逃生口是 `csp = report|off` + 重启,不用改代码。
- **Sentry CDN 是条件白名单**:`header.html` 的 Sentry 在 `{{ if ne SentryDSN "" }}` 里,
静态 grep 和本地巡检都碰不到 → 按 `conf.App.SentryDSN` 条件放行,单测双向断言。
## 复现脚本怎么跑(踩过的两次假红,别再误判成回归)
- 四套脚本默认目标是**远端** `box-dev-api`(`audit.py` 的 fallback),在本地实例上验证
必须显式给环境变量,否则 4/4 全 FAIL —— 那是「远端没部署修复」,不是回归:
`PYTHONDONTWRITEBYTECODE=1 TB_BASE=http://172.27.49.117:5877 [email protected]
TB_PWD='TbLocal!2026' TB_DOMAIN=tblocal <脚本>`(Windows 侧 127.0.0.1:5877 被占,需用 WSL eth0 IP)。
- **`verify_chat_owner.py` 必须在 WSL 里跑**:它直接调 `psql` 查库,Windows 侧 `psql` 会连到
宿主自己的 PostgreSQL 18(`/c/OtherFiles/PostgreSQL/18/bin/psql`),账号/库都不对;且 psql 的中文
报错是 GBK,Python 按 UTF-8 解会先抛 `UnicodeDecodeError` 把真实错误吃掉。WSL 侧:
`cd /mnt/c/.../sec-audit-2026-10-25 && TB_BASE=http://127.0.0.1:5877 ... python3 verify_chat_owner.py`。
- 页面密码入口限流 **10 次/分钟/IP**,`verify_page_next.py` 一次要 POST 6 次 → **一分钟内连跑两次**
会从第 5 项开始 `status=429`;`verify_p1_fixes.py` 的 p1-2c 还会把「本机 IP + 测试箱」锁 10 分钟。
两者都靠 `systemctl restart tamabox-test` 清内存态。顺序:先 `verify_page_next.py`,再 `verify_p1_fixes.py`。
- 判据别写「Location 里不含 evil」这种弱断言:一旦被限流/锁定停在密码页也会假绿。
`verify_page_next.py` 已改成精确比对 `Location == /_/<domain>`、429 单独报,且**每条载荷开新会话**
(验证通过会给会话发通行标记,复用会话的话后面几条根本不走密码校验)。
- 残留:`verify_round2.py` / `verify_chat_owner.py` 会注册临时账号(`verify*@example.com`);
跑完 `DELETE FROM nekobox.users WHERE email LIKE 'verify%@example.com'`(无 FK,先确认
conversations/questions/votes 无关联行)。
## 本地部署实测(2026-10-27,WSL)
- 三项 P1 在真实实例上端到端复验通过:P1-1 正/负与变体、P1-2a/b/c、P1-3 的可信/不可信对端 A/B
(探针用临时 `ip_blacklist = 1.2.3.4`:回环对端 + `CF-Connecting-IP: 1.2.3.4` → 302 命中;
跨主机对端同样头 → 200 忽略,日志出 `remote_addr` / `header_ip` 的 WARN)。
- ⚠️ **P1-1 白名单的坑(已修)**:配置里 `image_bucket_cdn_host` 实际写成 `host/path`
(本项目就是 `r2.337845818.best/tamabox`),整串当主机名比 → 自家图被剥。
现在统一走 `normalizeHostLike()`(补协议 → url.Parse → Hostname),并把 S3 endpoint 主机自身也入白名单
(R2 path-style 链接是 `<endpoint>/<bucket>/…`)。**白名单类单测必须照抄生产配置写法。**
- **P2-3 实测确认可利用**:`/login?to=/\evil.example` → `Location: /\evil.example`,
WHATWG 解析为 `http://evil.example/`;`\\evil.example` 同样外跳;`//`、`///` 已被规整。
正文渲染侧同类载荷是安全的(`<a href="/\example.org">` → `<a>`),缺口只在跳转参数。
+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")
+58 -1
View File
@@ -136,7 +136,9 @@ ban_threshold_404 = 20
ban_window_seconds = 60
; 临时封禁时长(分钟),到期自动解除,重启即清零
ban_duration_minutes = 15
; 触发封禁时发送告警邮件的收件人(复用下方 SMTP 发信配置),留空不发送
; 触发封禁时发送告警邮件的收件人(复用下方 SMTP 发信配置),留空不发送。
; ⚠️ 也是蜜罐告警邮件收件人,而蜜罐邮件正文含**明文账号密码**:
; 必须是个人邮箱,禁止填群组 / 分发列表 / 团队共用邮箱。
security_alert_email =
; ── 蜜罐(虚假后台登录界面)────────────────────────────────────────────
@@ -195,6 +197,48 @@ honeypot_ban_minutes = 1440
; 建议单独挂一个卷,并让它不参与备份。
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 之类会被拒绝,
@@ -274,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)计数。
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())
+56 -5
View File
@@ -32,7 +32,7 @@ ps -eo pid,lstart,cmd | grep '[t]amabox' # 看进程启动时间,与二进
```bash
./tamabox --help | sed -n '/COMMANDS/,/GLOBAL/p'
# web / censor / uid / ban / restore / reports / restrict / unrestrict / hide-public / honeypot
# web / censor / uid / ban / restore / reports / restrict / unrestrict / hide-public / honeypot / backup / restore-images
```
> 想确认某个字符串有没有编进二进制,**不要用 `strings`** —— 它默认只输出可打印 ASCII,
@@ -77,10 +77,13 @@ pg_dump -Fc <dbname> > before-$(date +%F-%H%M).dump
| `censor` | 重新审核历史内容 | 是 | 审核规则变更后复检 |
| `uid` | 为用户生成/补齐 uid | 是 | 数据修复 |
| `honeypot` | 扫描器诱捕取证(假登录页) | 部分是(`prune` / `purge-creds`) | 看谁在扫、拿到了什么字典 |
| `backup` | 备份 PostgreSQL 与配置 bucket 的全部 R2 对象 | 写本地备份目录 | 灾难恢复前留档 |
| `restore-images` | 校验并恢复 R2 图片及元数据 | 是(除 `--dry-run`) | 图片灾难恢复、隔离恢复演练 |
| `web` | 启动 Web 服务 | — | 生产入口 |
除 `reports`、`honeypot hits/stats/paths/size` 外,所有写操作都要求交互式输入大写 `YES` 确认;
除 `reports`、`honeypot hits/stats/paths/size` 外,既有运营写操作要求交互式输入大写 `YES` 确认;
脚本化时加 `--yes`。注意 `honeypot prune` / `purge-creds` 的确认词是 **`yes`**(小写)。
`restore-images` 不做交互确认:实际写入必须显式提供 `--yes`,`--dry-run` 除外。
---
@@ -329,7 +332,55 @@ pg_dump -Fc <dbname> > before-$(date +%F-%H%M).dump
---
## 8. 典型处置流程
## 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>` 看条目、占比、历史处置。
@@ -351,7 +402,7 @@ pg_dump -Fc <dbname> > before-$(date +%F-%H%M).dump
---
## 9. 排错
## 10. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
@@ -374,7 +425,7 @@ pg_dump -Fc <dbname> > before-$(date +%F-%H%M).dump
---
## 10. 明确不做的事
## 11. 明确不做的事
- **不做自动处置**:举报数达标只发邮件,不自动封禁。这个场景里举报本身就是攻击武器。
- **不做申诉后台**:申诉走邮件,处置结果由 CLI 写入。
+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
}
+3
View File
@@ -25,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 输出会被贴到工单、聊天窗口甚至截图里,
@@ -68,6 +69,8 @@ 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)",
+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), "窗口未结束时不能发不完整的报告")
}
+24 -2
View File
@@ -10,6 +10,7 @@ import (
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
@@ -95,7 +96,11 @@ func runWeb(ctx *cli.Context) error {
// 凭据文件(明文账号密码)也按同一个保留期清理 —— 它们不该比元数据活得更久。
// 这里独立打开一次目录即可:PurgeBefore 只删「整天都早于保留期」的文件,
// 与 route 层那个 Append 用的句柄不会争抢同一天的文件。
// 两个后台任务(保留期清理 / 周期报告)共用一个停止信号,
// defer 必须放在 if 外面 —— 否则「自动清理关掉、报告开着」时这个
// channel 永远不会被关闭,那个 goroutine 就一直挂着。
stopRetention := make(chan struct{})
defer close(stopRetention)
if conf.Security.HoneypotAutoClean {
var credPurge db.CredPurgeable
if cs, err := honeypot.NewFileCredStore(conf.Security.HoneypotCredDir); err != nil {
@@ -104,7 +109,16 @@ func runWeb(ctx *cli.Context) error {
credPurge = cs
}
db.NewHoneypotRetention(db.Honeypots, credPurge, conf.Security.HoneypotRetainDays, conf.Security.HoneypotMaxRows, 0).Start(stopRetention)
defer close(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(含匿名访客)。
@@ -196,7 +210,15 @@ func runWeb(ctx *cli.Context) error {
// 「提问已 INSERT、图片还没传完」「回答已写入、配额还没扣」这类半成品。
// systemd Restart=always 与 docker restart 都救不了这个。
runCtx, stop := signal.NotifyContext(ctx.Context, syscall.SIGTERM, os.Interrupt)
defer stop()
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() {
+4
View File
@@ -111,6 +111,10 @@ func Init() error {
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
+28
View File
@@ -43,6 +43,13 @@ const (
//
// 不放在数据库所在目录:这两者的权限、备份策略、留存周期都应该分开管。
DefaultHoneypotCredDir = "data/honeypot-creds"
// DefaultIPGeoProvider 默认的地理定位数据源:auto(ip-api 打底 + 腾讯精修)。
DefaultIPGeoProvider = "auto"
// DefaultHoneypotReportDir 安全汇总报告文件的默认目录。
//
// 与凭据目录分开:报告里有攻击者 IP 明细,但它是可以外发/归档的材料;
// 凭据目录里是明文账号密码,永不外发。放在一起容易误打包。
DefaultHoneypotReportDir = "data/reports"
)
// sanitizeHoneypot 归一化蜜罐相关配置,修正「未配置」与「显式关闭」的歧义。
@@ -109,4 +116,25 @@ func sanitizeHoneypot() {
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
}
}
+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 归一化配置键名。
+54
View File
@@ -140,6 +140,12 @@ var (
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
@@ -152,6 +158,42 @@ var (
// 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 {
@@ -215,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"`
+26 -14
View File
@@ -29,20 +29,20 @@ type BoxAnalyticsStore interface {
// BoxDayCount 某一天的提问 / 回答计数。
type BoxDayCount struct {
Day time.Time `gorm:"column:day"`
Questions int64 `gorm:"column:questions"`
Answers int64 `gorm:"column:answers"`
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 // 置顶
Total int64 // 全部提问
Answered int64 // 已回答
ReadOnly int64 // 已读未答
Unread int64 // 未读未答
Private int64 // 私密提问
Anonymous int64 // 匿名(未登录)提问
Pinned int64 // 置顶
// 响应速度只统计已回答且 answered_at 有值的行(历史行退回 updated_at,
// 会有偏高,见 answered_at 的注释)。
RepliedIn24h int64
@@ -68,6 +68,18 @@ func (db *boxAnalytics) BoxTrend(ctx context.Context, userID uint, from, to time
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,
// 而两张结果集都很小,合并成本可以忽略。
@@ -85,10 +97,10 @@ func (db *boxAnalytics) BoxTrend(ctx context.Context, userID uint, from, to time
return nil, errors.Wrap(err, "aggregate box questions by day")
}
if err := db.WithContext(ctx).Model(&Question{}).
Select(d.day+" AS key, COUNT(*) AS n").
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(d.day).
Group(answerDay).
Scan(&as).Error; err != nil {
return nil, errors.Wrap(err, "aggregate box answers by day")
}
@@ -190,8 +202,8 @@ func (db *boxAnalytics) BoxFunnel(ctx context.Context, userID uint) (*BoxFunnel,
return out, nil
}
var speed struct {
In24h int64 `gorm:"column:in_24h"`
Avg *float64 `gorm:"column:avg_min"`
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,
+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, "回答日应出现在趋势结果里")
}
+1 -1
View File
@@ -231,4 +231,4 @@ func (db *boxViews) Purge(ctx context.Context, before time.Time) (int64, error)
return 0, errors.Wrap(res.Error, "purge box views")
}
return res.RowsAffected, nil
}
}
+99
View File
@@ -6,22 +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)
@@ -74,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,
@@ -99,6 +140,11 @@ func (db *conversations) CreateWithFirstMessage(
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,
@@ -139,6 +185,59 @@ func (db *conversations) CreateWithFirstMessage(
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 {
+14 -38
View File
@@ -36,7 +36,7 @@ func Ping(ctx context.Context) error {
var AllTables = []interface{}{
&User{}, &Question{}, &FollowUp{}, &CensorLog{}, &UploadImage{}, &UploadImageQuestion{},
&Conversation{}, &Message{}, &MessageImage{}, &UserBlock{},
&Conversation{}, &Message{}, &MessageImage{}, &UserBlock{}, &R2DeleteTask{},
// 匿名投票
&Vote{}, &VoteOption{}, &VoteFormField{}, &VoteSubmission{},
// 功能开关(提问 / 私聊 / 投票 的开关、定时、定量收集)
@@ -45,6 +45,10 @@ var AllTables = []interface{}{
&UserRestriction{}, &ContentReport{},
// 蜜罐命中记录(审计证据,只追加不软删)
&HoneypotHit{},
// 来源 IP 的地理定位缓存(安全汇总报告用,纯 KV 缓存)
&IPGeoRecord{},
// 周期安全汇总的报告台账(period_key 唯一,保证重启不会重复发信)
&SecurityReportLog{},
// 自动封禁记录(内存态封禁的落库账本,用于事后解释与取证)
&BanRecord{},
// 箱主数据面板的访问流水(每 访客×天×箱 一行,Unscoped 语义,见 BoxView 注释)
@@ -64,6 +68,7 @@ func Init(typ, dsn string) (*gorm.DB, error) {
}
db, err := gorm.Open(dialector, &gorm.Config{
Logger: newSafeLogger(),
SkipDefaultTransaction: true,
})
if err != nil {
@@ -90,47 +95,16 @@ func Init(typ, dsn string) (*gorm.DB, error) {
}
}
// questions.answered_at 是 2026-10 新增的列(首次回答时间,用于算「回答耗时」)。
// 加列之前已回答的提问没有这个值,只能事后拿 updated_at 近似。
// 必须在 AutoMigrate 之前判断列是否存在 —— 迁移之后大家都有列了,就分不出谁是历史行。
hasAnsweredAt := db.Migrator().HasColumn(&Question{}, "answered_at")
// AutoMigrate 只负责声明式的「加表 / 加列 / 加索引」——这些变更幂等、无数据丢失风险。
if err := db.AutoMigrate(AllTables...); err != nil {
return nil, errors.Wrap(err, "auto migrate")
}
// 回填只在「这次刚加上列」时做一次。
// 为什么必须回填:这些行的 updated_at 之后还会被已读/置顶继续往前推,
// 不回填的话历史「回答耗时」会一天天变大,看起来像统计出错。
// 失败不阻断启动,页面会退回 updated_at 计算。
if !hasAnsweredAt {
if err := backfillAnsweredAt(db); err != nil {
logrus.WithError(err).Warn("回填 questions.answered_at 时异常(历史回答耗时将退回 updated_at)")
}
}
// 功能开关初始化:为历史账号补齐 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")
}
// 启动期补充高频查询复合索引(幂等,适合既有库)。
@@ -158,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)
@@ -182,6 +157,7 @@ func Init(typ, dsn string) (*gorm.DB, error) {
if err := db.Use(otelgorm.NewPlugin(
otelgorm.WithDBName(conf.Database.Name),
otelgorm.WithoutQueryVariables(),
)); err != nil {
return nil, errors.Wrap(err, "register otelgorm plugin")
}
+17
View File
@@ -20,6 +20,8 @@ import (
var FeatureControls FeatureControlsStore
var ErrFeatureQuotaReached = errors.New("功能收集配额已满")
var _ FeatureControlsStore = (*featureControls)(nil)
// 受控功能标识(feature_controls.feature 取值)。
@@ -517,6 +519,21 @@ func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feat
// 于是计数比真实行数少,箱主的「已收集 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
+7 -3
View File
@@ -42,7 +42,11 @@ type FollowUpsStore interface {
// 允许覆盖已回答的(供主人修改回复);返回 wasFirst 表示这是首次回答(此前为空)。
// 不存在/不属于该问题返回 ErrFollowUpNotFound。
UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) (wasFirst bool, err error)
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
// DeleteByQuestionID 删除某条提问时级联清理其全部追问。
//
// 用 Unscoped 真删:追问正文是提问者的个人内容,随提问一并真删(与 questions.DeleteByID
// 对主表的真删口径一致)。软删会留下带 deleted_at 的追问文本,且这些孤儿行的
// question_id 指向一个已不存在的提问 —— 与「删除即真删、个人数据不残留」的意图相悖。
DeleteByQuestionID(ctx context.Context, questionID uint) error
}
@@ -236,9 +240,9 @@ func (db *followUps) UpdateAnswerByID(ctx context.Context, questionID uint, id u
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
+77
View File
@@ -97,6 +97,20 @@ 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 删除旧版遗留的凭据列(不可逆)。
@@ -121,6 +135,20 @@ 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
@@ -234,6 +262,55 @@ func (h *honeypots) TopPaths(ctx context.Context, since time.Time, limit int) ([
return rows, err
}
// 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 []*HoneypotPathStat
err := h.db.WithContext(ctx).
Table("honeypot_hits").
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
+70
View File
@@ -159,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)
}
+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,
})
}
+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")
}
+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
}
+3
View File
@@ -128,6 +128,9 @@ func (db *questions) Create(ctx context.Context, opts CreateQuestionOptions) (*Q
IsPrivate: opts.IsPrivate,
}
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := reserveFeatureQuotaTx(tx, opts.UserID, FeatureQuestion); err != nil {
return err
}
if err := tx.Create(&question).Error; err != nil {
return errors.Wrap(err, "create question")
}
+163
View File
@@ -0,0 +1,163 @@
package db
import (
"context"
"fmt"
"sync"
"testing"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func createQuotaTestUser(t *testing.T, ctx context.Context, conn *gorm.DB, name string, dailyLimit int) *User {
t.Helper()
user := &User{Name: name, Domain: name, Email: name + "@example.test", Password: "test-only", DailyMessageLimit: dailyLimit}
require.NoError(t, conn.WithContext(ctx).Create(user).Error)
return user
}
func setFeatureQuota(t *testing.T, ctx context.Context, conn *gorm.DB, userID uint, feature string, limit int64) {
t.Helper()
require.NoError(t, NewFeatureControlsStore(conn).Save(ctx, userID, feature, UpdateFeatureControlOptions{Enabled: true, QuotaLimit: limit}))
}
func runConcurrently(n int, fn func(int) error) []error {
start := make(chan struct{})
errs := make([]error, n)
var wg sync.WaitGroup
for i := range n {
wg.Add(1)
go func() {
defer wg.Done()
<-start
errs[i] = fn(i)
}()
}
close(start)
wg.Wait()
return errs
}
func requireOnlyQuotaErrors(t *testing.T, errs []error, quotaErr error) int {
t.Helper()
successes := 0
for _, err := range errs {
if err == nil {
successes++
continue
}
require.ErrorIs(t, err, quotaErr)
}
return successes
}
func TestQuestionQuotaConcurrentLastSlot(t *testing.T) {
conn, _ := newTestDB(t)
ctx := context.Background()
owner := createQuotaTestUser(t, ctx, conn, "quota-question-owner", 0)
setFeatureQuota(t, ctx, conn, owner.ID, FeatureQuestion, 1)
questions := NewQuestionsStore(conn)
errs := runConcurrently(12, func(i int) error {
_, err := questions.Create(ctx, CreateQuestionOptions{UserID: owner.ID, Content: fmt.Sprintf("question-%d", i)})
return err
})
require.Equal(t, 1, requireOnlyQuotaErrors(t, errs, ErrFeatureQuotaReached))
var count int64
require.NoError(t, conn.Model(&Question{}).Where("user_id = ?", owner.ID).Count(&count).Error)
require.Equal(t, int64(1), count)
fc, err := NewFeatureControlsStore(conn).Get(ctx, owner.ID, FeatureQuestion)
require.NoError(t, err)
require.Equal(t, int64(1), fc.QuotaCount)
}
func TestVoteQuotasConcurrentLastSlot(t *testing.T) {
conn, _ := newTestDB(t)
ctx := context.Background()
owner := createQuotaTestUser(t, ctx, conn, "quota-vote-owner", 0)
setFeatureQuota(t, ctx, conn, owner.ID, FeatureVote, 1)
votes := NewVotesStore(conn)
options := NewVoteOptionsStore(conn)
submissions := NewVoteSubmissionsStore(conn)
ids := make([]uint, 2)
for i := range 2 {
vote, err := votes.Create(ctx, CreateVoteOptions{UserID: owner.ID, Title: fmt.Sprintf("vote-%d", i), Type: VoteTypeSingle, QuotaLimit: 1})
require.NoError(t, err)
require.NoError(t, votes.UpdateStatus(ctx, vote.ID, VoteStatusPublished))
option, err := options.Create(ctx, CreateVoteOptionOptions{VoteID: vote.ID, Content: "option"})
require.NoError(t, err)
ids[i] = option.ID
}
errs := runConcurrently(12, func(i int) error {
token := fmt.Sprintf("voter-%d", i)
_, err := submissions.Create(ctx, CreateVoteSubmissionOptions{VoteID: uint(i%2 + 1), OptionID: ids[i%2], VoterToken: &token, QuotaUserID: owner.ID})
return err
})
successes := 0
for _, err := range errs {
if err == nil {
successes++
} else {
require.Error(t, err)
}
}
require.Equal(t, 1, successes)
var count int64
require.NoError(t, conn.Model(&VoteSubmission{}).Count(&count).Error)
require.Equal(t, int64(1), count)
fc, err := NewFeatureControlsStore(conn).Get(ctx, owner.ID, FeatureVote)
require.NoError(t, err)
require.Equal(t, int64(1), fc.QuotaCount)
var ended int64
require.NoError(t, conn.Model(&Vote{}).Where("status = ?", VoteStatusEnded).Count(&ended).Error)
require.Equal(t, int64(1), ended)
}
func TestDailyMessageLimitAcrossConversationPaths(t *testing.T) {
conn, _ := newTestDB(t)
ctx := context.Background()
owner := createQuotaTestUser(t, ctx, conn, "quota-chat-owner", 1)
asker := createQuotaTestUser(t, ctx, conn, "quota-chat-asker", 0)
conversations := NewConversationStore(conn)
existing, err := conversations.Create(ctx, CreateConversationOptions{OwnerID: owner.ID, AskerID: asker.ID, MaxMessages: 20})
require.NoError(t, err)
var wg sync.WaitGroup
start := make(chan struct{})
errs := make([]error, 2)
wg.Add(2)
go func() {
defer wg.Done()
<-start
_, _, errs[0] = conversations.CreateWithFirstMessage(ctx, CreateConversationOptions{OwnerID: owner.ID, AskerID: asker.ID, MaxMessages: 20}, asker.ID, "first")
}()
go func() {
defer wg.Done()
<-start
_, errs[1] = conversations.SendMessage(ctx, SendConversationMessageOptions{ConversationID: existing.ID, SenderID: asker.ID, Content: "existing"})
}()
close(start)
wg.Wait()
require.Equal(t, 1, requireOnlyQuotaErrors(t, errs, ErrDailyMessageLimit))
var messages, convCount int64
require.NoError(t, conn.Model(&Message{}).Count(&messages).Error)
require.NoError(t, conn.Model(&Conversation{}).Count(&convCount).Error)
require.Equal(t, int64(1), messages)
require.Equal(t, int64(1), convCount)
}
func TestOwnerReplyDoesNotConsumeAskerDailyQuota(t *testing.T) {
conn, _ := newTestDB(t)
ctx := context.Background()
owner := createQuotaTestUser(t, ctx, conn, "quota-reply-owner", 1)
asker := createQuotaTestUser(t, ctx, conn, "quota-reply-asker", 0)
conversations := NewConversationStore(conn)
conv, _, err := conversations.CreateWithFirstMessage(ctx, CreateConversationOptions{OwnerID: owner.ID, AskerID: asker.ID, MaxMessages: 20}, asker.ID, "first")
require.NoError(t, err)
_, err = conversations.SendMessage(ctx, SendConversationMessageOptions{ConversationID: conv.ID, SenderID: owner.ID, Content: "reply"})
require.NoError(t, err)
_, err = conversations.SendMessage(ctx, SendConversationMessageOptions{ConversationID: conv.ID, SenderID: asker.ID, Content: "over"})
require.ErrorIs(t, err, ErrDailyMessageLimit)
var count int64
require.NoError(t, conn.Model(&Message{}).Count(&count).Error)
require.Equal(t, int64(2), count)
}
+277
View File
@@ -0,0 +1,277 @@
package db
import (
"context"
"crypto/sha256"
"encoding/hex"
"strings"
"time"
"github.com/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/dbutil"
"github.com/tamakyi/TamaBox/internal/storage"
)
var R2DeleteTasks R2DeleteTaskStore
var _ R2DeleteTaskStore = (*r2DeleteTasks)(nil)
// R2DeleteTask 是 R2 对象删除的持久化 outbox 任务。
// 数据库事务只负责登记任务,实际对象删除由 worker 在事务外执行。
type R2DeleteTask struct {
ID uint `gorm:"primaryKey"`
Bucket string `gorm:"size:255;not null;uniqueIndex:uk_r2_delete_tasks_bucket_key_hash,priority:1"`
Key string `gorm:"type:text;not null"`
KeyHash string `gorm:"size:64;not null;uniqueIndex:uk_r2_delete_tasks_bucket_key_hash,priority:2"`
Attempts int `gorm:"not null;default:0"`
NextRetryAt time.Time `gorm:"not null;index:idx_r2_delete_tasks_next_retry_at"`
LastError string `gorm:"type:text"`
CreatedAt time.Time
UpdatedAt time.Time
}
type R2DeleteTaskStore interface {
Enqueue(ctx context.Context, bucket string, keys []string) error
EnqueueValues(ctx context.Context, bucket string, values []string) error
ListDue(ctx context.Context, limit int) ([]*R2DeleteTask, error)
Delete(id uint) error
MarkFailed(id uint, nextRetry time.Time, lastError string) error
// HasReferences 仅供 worker 对当前配置的 bucket 调用。
// 它只查询数据库,不执行网络请求;查询或旧 URL 解析失败时必须返回错误。
HasReferences(ctx context.Context, bucket, key string) (bool, error)
}
func NewR2DeleteTaskStore(db *gorm.DB) R2DeleteTaskStore {
return &r2DeleteTasks{DB: db}
}
type r2DeleteTasks struct {
*gorm.DB
}
// EnqueueR2DeleteTasks 在调用方事务中登记对象删除任务。
// 相同 (bucket,key) 重复入队会重置重试状态,使新事务对应的删除请求立即可见。
func EnqueueR2DeleteTasks(tx *gorm.DB, bucket string, keys []string) error {
if bucket == "" || len(keys) == 0 {
return nil
}
now := time.Now()
tasks := make([]*R2DeleteTask, 0, len(keys))
seen := make(map[string]struct{}, len(keys))
for _, key := range keys {
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
sum := sha256.Sum256([]byte(key))
tasks = append(tasks, &R2DeleteTask{
Bucket: bucket,
Key: key,
KeyHash: hex.EncodeToString(sum[:]),
NextRetryAt: now,
})
}
if len(tasks) == 0 {
return nil
}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "bucket"}, {Name: "key_hash"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"attempts": 0,
"next_retry_at": now,
"last_error": "",
"updated_at": now,
}),
}).Create(&tasks).Error; err != nil {
return errors.Wrap(err, "enqueue R2 delete tasks")
}
return nil
}
func (db *r2DeleteTasks) Enqueue(ctx context.Context, bucket string, keys []string) error {
return EnqueueR2DeleteTasks(db.WithContext(ctx), bucket, keys)
}
func (db *r2DeleteTasks) EnqueueValues(ctx context.Context, bucket string, values []string) error {
return enqueueR2DeleteTasksForValues(db.WithContext(ctx), bucket, values)
}
func (db *r2DeleteTasks) ListDue(ctx context.Context, limit int) ([]*R2DeleteTask, error) {
tasks := make([]*R2DeleteTask, 0)
if limit <= 0 {
return tasks, nil
}
if err := db.WithContext(ctx).
Where("next_retry_at <= ?", time.Now()).
Order("next_retry_at ASC, id ASC").
Limit(limit).
Find(&tasks).Error; err != nil {
return nil, errors.Wrap(err, "list due R2 delete tasks")
}
return tasks, nil
}
func (db *r2DeleteTasks) Delete(id uint) error {
if err := db.DB.Delete(&R2DeleteTask{}, id).Error; err != nil {
return errors.Wrap(err, "delete R2 delete task")
}
return nil
}
func (db *r2DeleteTasks) MarkFailed(id uint, nextRetry time.Time, lastError string) error {
if err := db.Model(&R2DeleteTask{}).Where("id = ?", id).Updates(map[string]interface{}{
"attempts": gorm.Expr("attempts + 1"),
"next_retry_at": nextRetry,
"last_error": lastError,
"updated_at": time.Now(),
}).Error; err != nil {
return errors.Wrap(err, "mark R2 delete task failed")
}
return nil
}
func (db *r2DeleteTasks) HasReferences(ctx context.Context, bucket, key string) (bool, error) {
if bucket == "" || key == "" {
return false, nil
}
defaultKey, owned, err := r2ObjectKeyFromValue(key)
if err != nil {
return false, err
}
if owned && isDefaultProfileObject(defaultKey) {
return true, nil
}
quotedKey := dbutil.QuoteIdentifier(db.Dialector.Name(), "key")
var count int64
if err := db.WithContext(ctx).Unscoped().Model(&UploadImage{}).
Where(quotedKey+" = ?", key).Count(&count).Error; err != nil {
return false, errors.Wrap(err, "check upload image references")
}
if count > 0 {
return true, nil
}
count = 0
if err := db.WithContext(ctx).Unscoped().Model(&MessageImage{}).
Where("object_key = ? OR url = ?", key, key).Count(&count).Error; err != nil {
return false, errors.Wrap(err, "check message image references")
}
if count > 0 {
return true, nil
}
var uploadURLs []string
if err := db.WithContext(ctx).Unscoped().Model(&UploadImage{}).
Where(quotedKey+" LIKE ? OR "+quotedKey+" LIKE ?", "%://%", "//%").
Pluck(quotedKey, &uploadURLs).Error; err != nil {
return false, errors.Wrap(err, "list legacy upload image URLs")
}
referenced, err := hasLegacyCDNReference(uploadURLs, key)
if err != nil || referenced {
return referenced, err
}
var messageURLs []string
if err := db.WithContext(ctx).Unscoped().Model(&MessageImage{}).
Where("url LIKE ? OR url LIKE ?", "%://%", "//%").
Pluck("url", &messageURLs).Error; err != nil {
return false, errors.Wrap(err, "list legacy message image URLs")
}
referenced, err = hasLegacyCDNReference(messageURLs, key)
if err != nil || referenced {
return referenced, err
}
var profileUsers []*User
if err := db.WithContext(ctx).Unscoped().Model(&User{}).
Select("avatar", "background", "background_image").
Where("avatar = ? OR background = ? OR background_image = ? OR avatar LIKE ? OR background LIKE ? OR background_image LIKE ?", key, key, key, "%://%", "%://%", "%://%").
Find(&profileUsers).Error; err != nil {
return false, errors.Wrap(err, "list profile image references")
}
for _, user := range profileUsers {
referenced, err = hasLegacyCDNReference([]string{user.Avatar, user.Background, user.BackgroundImage}, key)
if err != nil || referenced {
return referenced, err
}
}
return false, nil
}
func isDefaultProfileObject(key string) bool {
for _, value := range []string{conf.Upload.DefaultAvatarURL, conf.Upload.DefaultBackground, conf.Upload.DefaultBackgroundImageURL} {
defaultKey, owned, err := r2ObjectKeyFromValue(value)
if err == nil && owned && defaultKey == key {
return true
}
}
return false
}
func hasLegacyCDNReference(values []string, targetKey string) (bool, error) {
for _, value := range values {
key, owned, err := r2ObjectKeyFromValue(value)
if err != nil {
return false, err
}
if owned && key == targetKey {
return true, nil
}
}
return false, nil
}
// r2ObjectKeyFromValue 接受当前对象 key 或旧 CDN URL。
// 外部 URL 不属于当前 CDN,安全返回 owned=false;畸形 URL 返回错误以避免误判无引用。
func r2ObjectKeyFromValue(value string) (key string, owned bool, err error) {
if value == "" {
return "", false, nil
}
if !strings.Contains(value, "://") && !strings.HasPrefix(value, "//") {
return value, true, nil
}
key, owned, err = storage.ObjectKeyFromCDNURL(value, conf.Upload.ImageBucketCDNHost)
if err != nil {
return "", false, errors.Wrap(err, "parse legacy CDN URL")
}
if !owned || key == "" {
return "", false, nil
}
return key, true, nil
}
func enqueueR2DeleteTasksForValues(tx *gorm.DB, bucket string, values []string, excludedKeys ...string) error {
if bucket == "" || len(values) == 0 {
return nil
}
excluded := make(map[string]struct{}, len(excludedKeys))
for _, key := range excludedKeys {
excluded[key] = struct{}{}
}
keys := make([]string, 0, len(values))
for _, value := range values {
key, owned, err := r2ObjectKeyFromValue(value)
if err != nil {
return err
}
if !owned || isDefaultProfileObject(key) {
continue
}
if _, skip := excluded[key]; skip {
continue
}
keys = append(keys, key)
}
return EnqueueR2DeleteTasks(tx, bucket, keys)
}
+141
View File
@@ -0,0 +1,141 @@
package db
import (
"context"
"crypto/sha256"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/conf"
)
func setR2TestConfig(t *testing.T) {
t.Helper()
oldBucket := conf.Upload.ImageBucket
oldCDNHost := conf.Upload.ImageBucketCDNHost
conf.Upload.ImageBucket = "test-r2-bucket"
conf.Upload.ImageBucketCDNHost = "cdn.example.test/assets"
oldDefaults := []string{conf.Upload.DefaultAvatarURL, conf.Upload.DefaultBackground, conf.Upload.DefaultBackgroundImageURL}
conf.Upload.DefaultAvatarURL = "https://cdn.example.test/assets/default-avatar.webp"
conf.Upload.DefaultBackground = "https://cdn.example.test/assets/default-background.webp"
conf.Upload.DefaultBackgroundImageURL = "https://cdn.example.test/assets/default-qr.webp"
t.Cleanup(func() {
conf.Upload.DefaultAvatarURL, conf.Upload.DefaultBackground, conf.Upload.DefaultBackgroundImageURL = oldDefaults[0], oldDefaults[1], oldDefaults[2]
conf.Upload.ImageBucket = oldBucket
conf.Upload.ImageBucketCDNHost = oldCDNHost
})
}
func TestR2DeleteTaskStore(t *testing.T) {
setR2TestConfig(t)
conn, _ := newTestDB(t)
ctx := context.Background()
store := NewR2DeleteTaskStore(conn)
require.NoError(t, store.Enqueue(ctx, "", []string{"ignored.webp"}))
require.NoError(t, store.Enqueue(ctx, conf.Upload.ImageBucket, nil))
require.NoError(t, store.Enqueue(ctx, conf.Upload.ImageBucket, []string{
"2026/11/shared.webp", "2026/11/shared.webp", "2026/11/other.webp", "",
}))
tasks, err := store.ListDue(ctx, 10)
require.NoError(t, err)
require.Len(t, tasks, 2)
byKey := make(map[string]*R2DeleteTask, len(tasks))
for _, task := range tasks {
byKey[task.Key] = task
require.Equal(t, conf.Upload.ImageBucket, task.Bucket)
require.Len(t, task.KeyHash, 64)
}
shared := byKey["2026/11/shared.webp"]
other := byKey["2026/11/other.webp"]
require.NotNil(t, shared)
require.NotNil(t, other)
require.Equal(t, fmt.Sprintf("%x", sha256.Sum256([]byte(shared.Key))), shared.KeyHash)
future := time.Now().Add(time.Hour).Truncate(time.Microsecond)
require.NoError(t, store.MarkFailed(shared.ID, future, "temporary failure"))
due, err := store.ListDue(ctx, 10)
require.NoError(t, err)
require.Len(t, due, 1)
require.Equal(t, other.ID, due[0].ID)
require.NoError(t, store.Enqueue(ctx, conf.Upload.ImageBucket, []string{shared.Key, shared.Key}))
var requeued R2DeleteTask
require.NoError(t, conn.WithContext(ctx).First(&requeued, shared.ID).Error)
require.Equal(t, shared.ID, requeued.ID)
require.Zero(t, requeued.Attempts)
require.Empty(t, requeued.LastError)
require.WithinDuration(t, time.Now(), requeued.NextRetryAt, 5*time.Second)
// 同一个 key 可在不同 bucket 各有一条任务,但同一 bucket 内仍保持唯一。
require.NoError(t, store.Enqueue(ctx, "another-bucket", []string{shared.Key}))
var count int64
require.NoError(t, conn.WithContext(ctx).Model(&R2DeleteTask{}).Count(&count).Error)
require.Equal(t, int64(3), count)
require.NoError(t, store.Delete(other.ID))
require.NoError(t, conn.WithContext(ctx).Model(&R2DeleteTask{}).Count(&count).Error)
require.Equal(t, int64(2), count)
empty, err := store.ListDue(ctx, 0)
require.NoError(t, err)
require.NotNil(t, empty)
require.Empty(t, empty)
}
func TestR2DeleteTaskHasReferences(t *testing.T) {
setR2TestConfig(t)
conn, _ := newTestDB(t)
ctx := context.Background()
store := NewR2DeleteTaskStore(conn)
bucket := conf.Upload.ImageBucket
upload := &UploadImage{Key: "upload.webp"}
require.NoError(t, conn.WithContext(ctx).Create(upload).Error)
require.NoError(t, conn.WithContext(ctx).Delete(upload).Error)
referenced, err := store.HasReferences(ctx, bucket, upload.Key)
require.NoError(t, err)
require.True(t, referenced, "软删除的 upload_images 仍必须通过 Unscoped 阻止对象删除")
message := &MessageImage{MessageID: 1, ObjectKey: "message.webp", URL: "https://cdn.example.test/assets/message.webp"}
require.NoError(t, conn.WithContext(ctx).Create(message).Error)
require.NoError(t, conn.WithContext(ctx).Delete(message).Error)
referenced, err = store.HasReferences(ctx, bucket, message.ObjectKey)
require.NoError(t, err)
require.True(t, referenced, "软删除的 message_images 仍必须通过 Unscoped 阻止对象删除")
legacyMessage := &MessageImage{MessageID: 2, URL: "https://cdn.example.test/assets/legacy-message.webp"}
legacyUpload := &UploadImage{Key: "https://cdn.example.test/assets/legacy-upload.webp"}
external := &MessageImage{MessageID: 3, URL: "https://external.example.net/external.webp"}
require.NoError(t, conn.WithContext(ctx).Create(legacyMessage).Error)
require.NoError(t, conn.WithContext(ctx).Create(legacyUpload).Error)
require.NoError(t, conn.WithContext(ctx).Create(external).Error)
for _, key := range []string{"legacy-message.webp", "legacy-upload.webp"} {
referenced, err = store.HasReferences(ctx, bucket, key)
require.NoError(t, err)
require.True(t, referenced, key)
}
referenced, err = store.HasReferences(ctx, bucket, "external.webp")
require.NoError(t, err)
require.False(t, referenced)
referenced, err = store.HasReferences(ctx, bucket, "missing.webp")
require.NoError(t, err)
require.False(t, referenced)
profileUser := &User{Avatar: "https://cdn.example.test/assets/profile-avatar.webp", Background: "profile-background.webp", BackgroundImage: "https://external.example.net/external-profile.webp"}
require.NoError(t, conn.WithContext(ctx).Create(profileUser).Error)
for _, key := range []string{"profile-avatar.webp", "profile-background.webp", "default-avatar.webp", "default-background.webp", "default-qr.webp"} {
referenced, err = store.HasReferences(ctx, bucket, key)
require.NoError(t, err)
require.True(t, referenced, key)
}
// 第二个引用查询失败时必须显式返回错误,不能误报为无引用。
require.NoError(t, conn.Migrator().DropTable(&MessageImage{}))
_, err = store.HasReferences(ctx, bucket, "missing-again.webp")
require.Error(t, err)
}
+118
View File
@@ -0,0 +1,118 @@
// 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"
)
// SecurityReportLog 是「周期安全汇总已发送」的台账。
//
// 为什么需要一张表而不是在内存里记一个时间戳:
// 服务会被重启(发版、OOM、机器迁移)。内存标记一重启就丢,而报告窗口是一整个
// 自然周/月 —— 周一凌晨发过以后重启一次,下一次 tick 就会把同一周的周报再发一遍,
// 连着发三次的情况在真实运维里很常见(发布窗口经常撞在周一)。
//
// 用 period_key 唯一索引做幂等:无论触发多少次、重启多少回,同一周期只发一次。
type SecurityReportLog struct {
ID uint `gorm:"primarykey"`
// PeriodKey 周期标识,形如 "week:2026-W44" / "month:2026-10"。
// 唯一索引是幂等的唯一保证 —— 不要改成「按窗口时间范围查重」,
// 那样跨月边界(一个月 4 周 vs 5 周)会出现漏发或重发的灰色地带。
PeriodKey string `gorm:"size:32;uniqueIndex"`
// Kind 报告类型(week / month),便于按类型统计。
Kind string `gorm:"size:8"`
// WindowStart / WindowEnd 报告覆盖的时间窗口 [start, end)。
WindowStart time.Time
WindowEnd time.Time
// 发送时的关键数字快照。台账要能独立回答「那周的结论是什么」,
// 而不是事后还得回去翻蜜罐表 —— 蜜罐表有保留期,过期就被清掉了。
Hits int64
UniqueSources int64
Banned int64
Countries int
Recipient string `gorm:"size:255"`
// FilePath 报告 HTML 的落盘路径(留空表示只发了邮件、没留文件)。
FilePath string `gorm:"size:512"`
// Sent 是否成功发出。失败的行保留下来,既是审计线索,
// 也用来做「最多重试几次」的计数。
Sent bool
// Attempts 尝试次数。发送失败会重试,但必须设上限:
// 一个配错的 SMTP 会让每次 tick 都失败,无限重试只是把日志刷满。
Attempts int
// Error 最近一次失败的原因(成功时清空)。
Error string `gorm:"size:512"`
CreatedAt time.Time
UpdatedAt time.Time
}
// TableName 固定表名。
func (SecurityReportLog) TableName() string { return "security_report_logs" }
// SecurityReports 报告台账全局 store。
var SecurityReports SecurityReportStore
// SecurityReportStore 报告台账的读写。
type SecurityReportStore interface {
// Get 按 period_key 取一条,未找到返回 (nil, nil)。
Get(ctx context.Context, periodKey string) (*SecurityReportLog, error)
// Save 按 period_key upsert。
Save(ctx context.Context, rec *SecurityReportLog) error
// Recent 返回最近 n 条,按窗口结束时间倒序。
Recent(ctx context.Context, n int) ([]*SecurityReportLog, error)
}
type securityReports struct {
db *gorm.DB
}
// NewSecurityReportStore 构造台账 store。
func NewSecurityReportStore(db *gorm.DB) SecurityReportStore {
return &securityReports{db: db}
}
func (s *securityReports) Get(ctx context.Context, periodKey string) (*SecurityReportLog, error) {
var rec SecurityReportLog
err := s.db.WithContext(ctx).Where("period_key = ?", periodKey).First(&rec).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &rec, nil
}
func (s *securityReports) Save(ctx context.Context, rec *SecurityReportLog) error {
return s.db.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "period_key"}},
DoUpdates: clause.AssignmentColumns([]string{
"kind", "window_start", "window_end", "hits", "unique_sources",
"banned", "countries", "recipient", "file_path",
"sent", "attempts", "error", "updated_at",
}),
}).
Create(rec).Error
}
func (s *securityReports) Recent(ctx context.Context, n int) ([]*SecurityReportLog, error) {
if n <= 0 {
n = 12
}
var rows []*SecurityReportLog
err := s.db.WithContext(ctx).Order("window_end DESC").Limit(n).Find(&rows).Error
return rows, err
}
+60 -10
View File
@@ -10,6 +10,8 @@ import (
"github.com/pkg/errors"
"github.com/samber/lo"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/conf"
)
var UploadImages UploadImagesStore
@@ -23,7 +25,8 @@ type UploadImagesStore interface {
// 用于账号导出等场景,将 N 次单题查询合并为少量 IN 查询,避免 N+1。
GetByQuestionIDs(ctx context.Context, questionIDs []uint) (map[uint][]*UploadImage, error)
GetByTypeQuestionID(ctx context.Context, typ UploadImageQuestionType, questionID uint) ([]*UploadImage, error)
// DeleteByQuestionID 删除某条提问时级联清理其全部上传图片记录(软删除,与提问保持一致)。
// DeleteByQuestionID 先删除当前题目的关联,再物理删除不再被任何题目引用的主体。
// 实际删除的主体的对象 key 与元数据删除在同一事务中登记 R2 outbox。
DeleteByQuestionID(ctx context.Context, questionID uint) error
}
@@ -82,9 +85,24 @@ func (db *uploadImages) Create(ctx context.Context, opts CreateUploadImageOption
}
if opts.IsDeletingPrevious {
if err := tx.WithContext(ctx).Where("type = ? AND question_id = ?", opts.Type, opts.QuestionID).Delete(&UploadImageQuestion{}).Error; err != nil {
var previousLinks []*UploadImageQuestion
if err := tx.WithContext(ctx).
Where("type = ? AND question_id = ?", opts.Type, opts.QuestionID).
Find(&previousLinks).Error; err != nil {
return errors.Wrap(err, "get previous image question links")
}
previousImageIDs := lo.Map(previousLinks, func(item *UploadImageQuestion, _ int) uint {
return item.UploadImageID
})
if err := tx.WithContext(ctx).
Where("type = ? AND question_id = ?", opts.Type, opts.QuestionID).
Delete(&UploadImageQuestion{}).Error; err != nil {
return errors.Wrap(err, "delete previous image question link")
}
// 新图已经创建但尚未关联;即使它与旧图使用相同 key,也绝不加入删除队列。
if err := deleteUnreferencedUploadImages(ctx, tx, previousImageIDs, opts.Key); err != nil {
return err
}
}
if err := tx.WithContext(ctx).Create(&UploadImageQuestion{
Type: opts.Type,
@@ -162,10 +180,44 @@ func (db *uploadImages) GetByTypeQuestionID(ctx context.Context, typ UploadImage
return db.getBy(ctx, "type = ? AND question_id = ?", typ, questionID)
}
// DeleteByQuestionID 删除某条提问时级联清理其全部上传图片记录(软删除,与提问保持一致)。
// 注意:question_id 列在关联表 upload_image_questions 上,不在 upload_images 表上,
// 需先查关联行拿到 imageID 列表,再软删图片主体并删除关联行(同一事务内完成)。
// 注:仅清理数据库记录,对象存储中的实际文件不在此处删除(避免误删复用资源)。
// unreferencedUploadImagePredicate 是所有 UploadImage 删除入口共用的共享保护谓词。
// 调用方必须先解除当前作用域的关联,再使用该谓词收集并删除真正的孤儿主体。
const unreferencedUploadImagePredicate = `NOT EXISTS (
SELECT 1 FROM upload_image_questions AS shared_links
WHERE shared_links.upload_image_id = upload_images.id
)`
func deleteUnreferencedUploadImages(ctx context.Context, tx *gorm.DB, imageIDs []uint, excludedKeys ...string) error {
if len(imageIDs) == 0 {
return nil
}
var images []*UploadImage
if err := tx.WithContext(ctx).Unscoped().
Where("id IN ?", imageIDs).
Where(unreferencedUploadImagePredicate).
Find(&images).Error; err != nil {
return errors.Wrap(err, "get unreferenced upload images")
}
if len(images) == 0 {
return nil
}
ids := lo.Map(images, func(image *UploadImage, _ int) uint { return image.ID })
if err := tx.WithContext(ctx).Unscoped().
Where("id IN ?", ids).
Where(unreferencedUploadImagePredicate).
Delete(&UploadImage{}).Error; err != nil {
return errors.Wrap(err, "delete unreferenced upload images")
}
values := lo.Map(images, func(image *UploadImage, _ int) string { return image.Key })
if err := enqueueR2DeleteTasksForValues(tx.WithContext(ctx), conf.Upload.ImageBucket, values, excludedKeys...); err != nil {
return errors.Wrap(err, "enqueue deleted upload images")
}
return nil
}
func (db *uploadImages) DeleteByQuestionID(ctx context.Context, questionID uint) error {
return db.Transaction(func(tx *gorm.DB) error {
var links []*UploadImageQuestion
@@ -179,12 +231,10 @@ func (db *uploadImages) DeleteByQuestionID(ctx context.Context, questionID uint)
imageIDs := lo.Map(links, func(item *UploadImageQuestion, _ int) uint {
return item.UploadImageID
})
if err := tx.WithContext(ctx).Where("id IN ?", imageIDs).Delete(&UploadImage{}).Error; err != nil {
return errors.Wrap(err, "delete upload images by question id")
}
// 关联表无 DeletedAt,Delete 本就是真删;先解除当前题目关联。
if err := tx.WithContext(ctx).Where("question_id = ?", questionID).Delete(&UploadImageQuestion{}).Error; err != nil {
return errors.Wrap(err, "delete image question links")
}
return nil
return deleteUnreferencedUploadImages(ctx, tx, imageIDs)
})
}
+95
View File
@@ -0,0 +1,95 @@
package db
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func uploadImageCount(t *testing.T, conn *gorm.DB, id uint) int64 {
t.Helper()
var count int64
require.NoError(t, conn.Unscoped().Model(&UploadImage{}).Where("id = ?", id).Count(&count).Error)
return count
}
func TestUploadImagesDeleteByQuestionIDOutbox(t *testing.T) {
setR2TestConfig(t)
conn, _ := newTestDB(t)
ctx := context.Background()
images := NewUploadImagesStore(conn)
tasks := NewR2DeleteTaskStore(conn)
first, err := images.Create(ctx, CreateUploadImageOptions{QuestionID: 1, Key: "unique.webp"})
require.NoError(t, err)
duplicate, err := images.Create(ctx, CreateUploadImageOptions{QuestionID: 1, Key: "unique.webp"})
require.NoError(t, err)
shared, err := images.Create(ctx, CreateUploadImageOptions{QuestionID: 1, Key: "shared.webp"})
require.NoError(t, err)
require.NoError(t, conn.WithContext(ctx).Create(&UploadImageQuestion{
UploadImageID: shared.ID,
QuestionID: 2,
}).Error)
require.NoError(t, images.DeleteByQuestionID(ctx, 1))
require.Zero(t, uploadImageCount(t, conn, first.ID))
require.Zero(t, uploadImageCount(t, conn, duplicate.ID))
require.Equal(t, int64(1), uploadImageCount(t, conn, shared.ID))
due, err := tasks.ListDue(ctx, 10)
require.NoError(t, err)
require.Len(t, due, 1, "重复 key 必须去重,共享主体不得入队")
require.Equal(t, "unique.webp", due[0].Key)
require.NoError(t, images.DeleteByQuestionID(ctx, 2))
require.Zero(t, uploadImageCount(t, conn, shared.ID))
due, err = tasks.ListDue(ctx, 10)
require.NoError(t, err)
require.Len(t, due, 2)
require.ElementsMatch(t, []string{"unique.webp", "shared.webp"}, []string{due[0].Key, due[1].Key})
}
func TestUploadImagesReplaceDeletesOnlyOrphanedPrevious(t *testing.T) {
setR2TestConfig(t)
conn, _ := newTestDB(t)
ctx := context.Background()
images := NewUploadImagesStore(conn)
tasks := NewR2DeleteTaskStore(conn)
old, err := images.Create(ctx, CreateUploadImageOptions{
Type: UploadImageQuestionTypeAnswer,
QuestionID: 10,
Key: "old.webp",
})
require.NoError(t, err)
shared, err := images.Create(ctx, CreateUploadImageOptions{
Type: UploadImageQuestionTypeAnswer,
QuestionID: 10,
Key: "shared-old.webp",
})
require.NoError(t, err)
require.NoError(t, conn.WithContext(ctx).Create(&UploadImageQuestion{
Type: UploadImageQuestionTypeAnswer,
UploadImageID: shared.ID,
QuestionID: 11,
}).Error)
newImage, err := images.Create(ctx, CreateUploadImageOptions{
Type: UploadImageQuestionTypeAnswer,
QuestionID: 10,
Key: "new.webp",
IsDeletingPrevious: true,
})
require.NoError(t, err)
require.Zero(t, uploadImageCount(t, conn, old.ID))
require.Equal(t, int64(1), uploadImageCount(t, conn, shared.ID))
require.Equal(t, int64(1), uploadImageCount(t, conn, newImage.ID))
due, err := tasks.ListDue(ctx, 10)
require.NoError(t, err)
require.Len(t, due, 1)
require.Equal(t, "old.webp", due[0].Key)
require.NotEqual(t, newImage.Key, due[0].Key)
}
+148 -16
View File
@@ -109,8 +109,11 @@ type User struct {
Backgroundimagealpha string `json:"Backgroundimagealpha"`
// Theme 箱主选择的主题 slug(空串=跟随站点默认,default=基础外观)。
// 取值合法性由 route/user 在写入前校验,读取侧由 internal/theme 回退。
Theme string `json:"theme" gorm:"column:theme;type:varchar(32);default:''"`
Notify NotifyType `json:"notify"`
Theme string `json:"theme" gorm:"column:theme;type:varchar(32);default:''"`
Notify NotifyType `json:"notify"`
// TelegramChatID 箱主自填的 Telegram chat_id,配合站级 [notify] telegram_bot_token
// 实现「新提问」点对点通知。空串 = 未开启 Telegram 通知。
TelegramChatID string `json:"-" gorm:"column:telegram_chat_id;type:varchar(64);default:''"`
HarassmentSetting HarassmentSettingType `json:"harassment_setting" gorm:"default:'none'"`
BlockWords string `json:"-"`
// 防骚扰设置取值 none / register_only(见下方常量)。历史账号此列为空串(建表时无默认值),
@@ -130,6 +133,13 @@ type User struct {
PagePassword string `json:"-" gorm:"column:page_password;type:varchar(255);default:''"`
AskPassword string `json:"-" gorm:"column:ask_password;type:varchar(255);default:''"`
// PagePasswordVer / AskPasswordVer 是页面访问密码 / 提问密码的版本号。
// 每次修改对应密码时 +1,session 中保存授权时的版本号;
// 校验时若 session 中的版本号 != 当前版本号,则视为授权已失效。
// 这让「修改页面密码」能立即撤销已泄露的旧授权(SEC-06)。
PagePasswordVer uint `gorm:"default:0" json:"-"`
AskPasswordVer uint `gorm:"default:0" json:"-"`
// 私聊相关
AllowChat bool `json:"allow_chat" gorm:"default:false"`
// 每个私聊会话的消息条数上限(用户自设)。default:20 仅是 DB 层兜底;
@@ -343,6 +353,7 @@ type UpdateUserOptions struct {
Bloglink string
UseCustomQRColor bool
Notify NotifyType
TelegramChatID string
Language string
}
@@ -378,13 +389,29 @@ func (db *users) Update(ctx context.Context, id uint, opts UpdateUserOptions) er
"Bloglink": opts.Bloglink,
"UseCustomQRColor": opts.UseCustomQRColor,
"Notify": opts.Notify,
"TelegramChatID": opts.TelegramChatID,
"Language": opts.Language,
}
if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return errors.Wrap(err, "update user")
}
return nil
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var current User
if err := tx.WithContext(ctx).Select("id", "avatar", "background", "background_image").First(&current, id).Error; err != nil {
return errors.Wrap(err, "get current profile images")
}
if err := tx.WithContext(ctx).Model(&User{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return errors.Wrap(err, "update user")
}
oldValues := make([]string, 0, 3)
for _, pair := range [][2]string{{current.Avatar, opts.Avatar}, {current.Background, opts.Background}, {current.BackgroundImage, opts.BackgroundImage}} {
if pair[0] != pair[1] {
oldValues = append(oldValues, pair[0])
}
}
if err := enqueueR2DeleteTasksForValues(tx.WithContext(ctx), conf.Upload.ImageBucket, oldValues, opts.Avatar, opts.Background, opts.BackgroundImage); err != nil {
return errors.Wrap(err, "enqueue replaced profile images")
}
return nil
})
}
// UpdateHarassmentType 仅更新骚扰防护类型(none / register_only)。
@@ -858,13 +885,39 @@ func (db *users) GetHotTags(ctx context.Context, limit int) ([]string, error) {
return hot, nil
}
// backfillPagePasswordVer 给「已设置密码但版本号仍为 0」的存量账号补一次版本号。
//
// 背景:PagePasswordVer / AskPasswordVer 是 2026-11-05 新增的列,AutoMigrate 只补默认值 0。
// 而 0 同时是「session 里没有授权记录」时类型断言的零值,两者相等会让页面密码判定恒为通过
// (见 route/question/page.go 的 pagePasswordAuthorized 注释)。
// 把存量行统一置 1:语义与「改过一次密码」一致,升级前发出的旧授权(布尔 true)
// 也会因类型不符而自然作废,用户重输一次密码即可。
//
// 幂等:只更新仍为 0 的行,重复执行不会把版本号越推越大。
func backfillPagePasswordVer(db *gorm.DB) error {
if err := db.Model(&User{}).
Where("page_password <> '' AND page_password_ver = 0").
Update("page_password_ver", 1).Error; err != nil {
return errors.Wrap(err, "backfill page password version")
}
if err := db.Model(&User{}).
Where("ask_password <> '' AND ask_password_ver = 0").
Update("ask_password_ver", 1).Error; err != nil {
return errors.Wrap(err, "backfill ask password version")
}
return nil
}
func (db *users) UpdatePagePassword(ctx context.Context, id uint, password string) error {
user, err := db.GetByID(ctx, id)
if err != nil {
return err
}
user.SetPagePassword(password)
return db.WithContext(ctx).Model(user).Update("page_password", user.PagePassword).Error
return db.WithContext(ctx).Model(user).Updates(map[string]interface{}{
"page_password": user.PagePassword,
"page_password_ver": gorm.Expr("page_password_ver + 1"),
}).Error
}
func (db *users) UpdateAskPassword(ctx context.Context, id uint, password string) error {
@@ -873,7 +926,10 @@ func (db *users) UpdateAskPassword(ctx context.Context, id uint, password string
return err
}
user.SetAskPassword(password)
return db.WithContext(ctx).Model(user).Update("ask_password", user.AskPassword).Error
return db.WithContext(ctx).Model(user).Updates(map[string]interface{}{
"ask_password": user.AskPassword,
"ask_password_ver": gorm.Expr("ask_password_ver + 1"),
}).Error
}
// 新增私聊设置
@@ -1006,7 +1062,8 @@ func (db *users) BanUserAndCleanup(ctx context.Context, userID uint) error {
}
// purgeUserContent 物理删除某用户的全部内容数据:追问、消息与消息图片、私聊会话、
// 提问-图片关联、提问、拉黑关系、功能开关。**不含用户行本身**(由调用方决定是软删除还是物理删除)。
// 提问图片元数据及关联、提问、投票及其子表、访问统计、拉黑关系、功能开关。
// **不含用户行本身**(由调用方决定是软删除还是物理删除),也不删除审计/处置记录。
//
// 全部使用 Unscoped 物理删除而非软删除:这些表多为软删除模型,而部分表的唯一索引
// 不含 deleted_at(如 conversations.token),软删除会让被删行永久占据唯一槽位。
@@ -1015,6 +1072,19 @@ func (db *users) BanUserAndCleanup(ctx context.Context, userID uint) error {
// 直接复用会把上一条语句的 Where 条件串到下一条上(曾把 messages 的条件串进 follow_ups,
// 报「字段 conversation_id 不存在」)。
func purgeUserContent(ctx context.Context, tx *gorm.DB, userID uint) error {
var profile User
if err := tx.WithContext(ctx).Select("id", "avatar", "background", "background_image").First(&profile, userID).Error; err != nil {
return errors.Wrap(err, "get profile images of user")
}
if err := enqueueR2DeleteTasksForValues(tx.WithContext(ctx), conf.Upload.ImageBucket, []string{profile.Avatar, profile.Background, profile.BackgroundImage}); err != nil {
return errors.Wrap(err, "enqueue profile images of user")
}
if err := tx.WithContext(ctx).Model(&User{}).Where("id = ?", userID).Updates(map[string]interface{}{
"avatar": "", "background": "", "background_image": "",
}).Error; err != nil {
return errors.Wrap(err, "clear profile images of user")
}
// 追问:属于该用户提问下的追问
if err := tx.WithContext(ctx).Unscoped().
Where("question_id IN (SELECT id FROM questions WHERE user_id = ?)", userID).
@@ -1022,18 +1092,38 @@ func purgeUserContent(ctx context.Context, tx *gorm.DB, userID uint) error {
return errors.Wrap(err, "delete follow ups of user")
}
// 消息图片必须先于消息删除,否则消息子查询将无法再匹配待删图片。
// 新数据优先使用物化 ObjectKey,旧数据回退到 URL;外部 URL 不会进入 R2 outbox。
messageImageScope := "message_id IN (SELECT id FROM messages WHERE conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?))"
var messageImages []*MessageImage
if err := tx.WithContext(ctx).Unscoped().
Where(messageImageScope, userID, userID).
Find(&messageImages).Error; err != nil {
return errors.Wrap(err, "get message images of user")
}
messageImageValues := make([]string, 0, len(messageImages))
for _, image := range messageImages {
value := image.ObjectKey
if value == "" {
value = image.URL
}
messageImageValues = append(messageImageValues, value)
}
if err := tx.WithContext(ctx).Unscoped().
Where(messageImageScope, userID, userID).
Delete(&MessageImage{}).Error; err != nil {
return errors.Wrap(err, "delete message images of user")
}
if err := enqueueR2DeleteTasksForValues(tx.WithContext(ctx), conf.Upload.ImageBucket, messageImageValues); err != nil {
return errors.Wrap(err, "enqueue deleted message images")
}
// 消息:属于该用户作为 owner 或 asker 的会话
if err := tx.WithContext(ctx).Unscoped().
Where("conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?)", userID, userID).
Delete(&Message{}).Error; err != nil {
return errors.Wrap(err, "delete messages of user")
}
// 消息图片
if err := tx.WithContext(ctx).Unscoped().
Where("message_id IN (SELECT id FROM messages WHERE conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?))", userID, userID).
Delete(&MessageImage{}).Error; err != nil {
return errors.Wrap(err, "delete message images of user")
}
// 私聊会话
if err := tx.WithContext(ctx).Unscoped().
@@ -1042,12 +1132,25 @@ func purgeUserContent(ctx context.Context, tx *gorm.DB, userID uint) error {
return errors.Wrap(err, "delete conversations of user")
}
// 提问-图片关联(仅删除关联记录;UploadImage 本体为通用资源,保留)
// 先确定本用户上传或仅与当前作用域有关的候选主体,再解除本用户题目的关联。
// 最终是否可删除由与 UploadImages.DeleteByQuestionID 完全相同的共享保护谓词决定。
var uploadImageIDs []uint
if err := tx.WithContext(ctx).Unscoped().Model(&UploadImage{}).
Where(`uploader_user_id = ? OR upload_images.id IN (
SELECT upload_image_id FROM upload_image_questions
WHERE question_id IN (SELECT id FROM questions WHERE user_id = ?)
)`, userID, userID).
Pluck("upload_images.id", &uploadImageIDs).Error; err != nil {
return errors.Wrap(err, "get upload images of user")
}
if err := tx.WithContext(ctx).Unscoped().
Where("question_id IN (SELECT id FROM questions WHERE user_id = ?)", userID).
Delete(&UploadImageQuestion{}).Error; err != nil {
return errors.Wrap(err, "delete upload image links of user")
}
if err := deleteUnreferencedUploadImages(ctx, tx, uploadImageIDs); err != nil {
return errors.Wrap(err, "delete upload images of user")
}
// 提问
if err := tx.WithContext(ctx).Unscoped().
@@ -1056,6 +1159,35 @@ func purgeUserContent(ctx context.Context, tx *gorm.DB, userID uint) error {
return errors.Wrap(err, "delete questions of user")
}
// 投票子表必须先于投票主表删除;只处理该用户拥有的投票,不按参与者身份扩大范围。
if err := tx.WithContext(ctx).Unscoped().
Where("vote_id IN (SELECT id FROM votes WHERE user_id = ?)", userID).
Delete(&VoteSubmission{}).Error; err != nil {
return errors.Wrap(err, "delete vote submissions of user")
}
if err := tx.WithContext(ctx).Unscoped().
Where("vote_id IN (SELECT id FROM votes WHERE user_id = ?)", userID).
Delete(&VoteFormField{}).Error; err != nil {
return errors.Wrap(err, "delete vote form fields of user")
}
if err := tx.WithContext(ctx).Unscoped().
Where("vote_id IN (SELECT id FROM votes WHERE user_id = ?)", userID).
Delete(&VoteOption{}).Error; err != nil {
return errors.Wrap(err, "delete vote options of user")
}
if err := tx.WithContext(ctx).Unscoped().
Where("user_id = ?", userID).
Delete(&Vote{}).Error; err != nil {
return errors.Wrap(err, "delete votes of user")
}
// 提问箱访问统计:user_id 明确指向箱主,不属于站方审计/处置记录。
if err := tx.WithContext(ctx).Unscoped().
Where("user_id = ?", userID).
Delete(&BoxView{}).Error; err != nil {
return errors.Wrap(err, "delete box views of user")
}
// 拉黑关系(该用户拉黑别人,或被别人拉黑)
if err := tx.WithContext(ctx).Unscoped().
Where("user_id = ? OR blocked_id = ?", userID, userID).
+142
View File
@@ -0,0 +1,142 @@
package db
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/conf"
)
func createOutboxTestUser(t *testing.T, ctx context.Context, store UsersStore, email, domain string) *User {
t.Helper()
require.NoError(t, store.Create(ctx, CreateUserOptions{
Name: domain,
Password: "test-only-password",
Email: email,
Domain: domain,
}))
user, err := store.GetByEmail(ctx, email)
require.NoError(t, err)
return user
}
func TestPurgeUserContentEnqueuesOwnedR2Objects(t *testing.T) {
setR2TestConfig(t)
conn, _ := newTestDB(t)
ctx := context.Background()
users := NewUsersStore(conn)
owner := createOutboxTestUser(t, ctx, users, "[email protected]", "outbox-owner")
other := createOutboxTestUser(t, ctx, users, "[email protected]", "outbox-other")
require.NoError(t, conn.Model(owner).Updates(map[string]interface{}{
"avatar": "https://cdn.example.test/assets/profile-owned.webp", "background": "https://cdn.example.test/assets/default-background.webp", "background_image": "https://cdn.example.test/assets/profile-shared.webp",
}).Error)
require.NoError(t, conn.Model(other).Update("background_image", "https://cdn.example.test/assets/profile-shared.webp").Error)
questions := NewQuestionsStore(conn)
question, err := questions.Create(ctx, CreateQuestionOptions{UserID: owner.ID, Content: "owned"})
require.NoError(t, err)
otherQuestion, err := questions.Create(ctx, CreateQuestionOptions{UserID: other.ID, Content: "other"})
require.NoError(t, err)
images := NewUploadImagesStore(conn)
owned, err := images.Create(ctx, CreateUploadImageOptions{QuestionID: question.ID, UploaderUserID: owner.ID, Key: "owned.webp"})
require.NoError(t, err)
_, err = images.Create(ctx, CreateUploadImageOptions{QuestionID: question.ID, UploaderUserID: owner.ID, Key: "owned.webp"})
require.NoError(t, err)
legacy, err := images.Create(ctx, CreateUploadImageOptions{
QuestionID: question.ID, UploaderUserID: owner.ID, Key: "https://cdn.example.test/assets/legacy.webp",
})
require.NoError(t, err)
external, err := images.Create(ctx, CreateUploadImageOptions{
QuestionID: question.ID, UploaderUserID: owner.ID, Key: "https://external.example.net/external.webp",
})
require.NoError(t, err)
shared, err := images.Create(ctx, CreateUploadImageOptions{QuestionID: question.ID, UploaderUserID: owner.ID, Key: "shared.webp"})
require.NoError(t, err)
require.NoError(t, conn.WithContext(ctx).Create(&UploadImageQuestion{
UploadImageID: shared.ID, QuestionID: otherQuestion.ID,
}).Error)
conversation, message, err := NewConversationStore(conn).CreateWithFirstMessage(ctx, CreateConversationOptions{
OwnerID: owner.ID, AskerID: other.ID, MaxMessages: 20,
}, other.ID, "private")
require.NoError(t, err)
require.NotNil(t, conversation)
messageImages := NewMessageImageStore(conn)
for _, opts := range []CreateMessageImageOptions{
{MessageID: message.ID, ObjectKey: "message-key.webp", URL: "https://cdn.example.test/assets/message-key.webp"},
{MessageID: message.ID, ObjectKey: "owned.webp", URL: "https://cdn.example.test/assets/owned.webp"},
{MessageID: message.ID, URL: "https://cdn.example.test/assets/message-legacy.webp"},
{MessageID: message.ID, URL: "https://external.example.net/message-external.webp"},
} {
_, err = messageImages.Create(ctx, opts)
require.NoError(t, err)
}
require.NoError(t, users.Deactivate(ctx, owner.ID))
require.Zero(t, uploadImageCount(t, conn, owned.ID))
require.Zero(t, uploadImageCount(t, conn, legacy.ID))
require.Zero(t, uploadImageCount(t, conn, external.ID))
require.Equal(t, int64(1), uploadImageCount(t, conn, shared.ID))
var sharedLinks int64
require.NoError(t, conn.WithContext(ctx).Model(&UploadImageQuestion{}).
Where("upload_image_id = ? AND question_id = ?", shared.ID, otherQuestion.ID).Count(&sharedLinks).Error)
require.Equal(t, int64(1), sharedLinks)
due, err := NewR2DeleteTaskStore(conn).ListDue(ctx, 20)
require.NoError(t, err)
keys := make([]string, 0, len(due))
for _, task := range due {
keys = append(keys, task.Key)
}
require.ElementsMatch(t, []string{
"owned.webp", "legacy.webp", "message-key.webp", "message-legacy.webp", "profile-owned.webp", "profile-shared.webp",
}, keys)
require.NotContains(t, keys, "shared.webp")
require.NotContains(t, keys, "external.webp")
require.NotContains(t, keys, "message-external.webp")
require.NotContains(t, keys, "default-background.webp")
var deactivated User
require.NoError(t, conn.Unscoped().First(&deactivated, owner.ID).Error)
require.Empty(t, deactivated.Avatar)
require.Empty(t, deactivated.Background)
require.Empty(t, deactivated.BackgroundImage)
other, err = users.GetByID(ctx, other.ID)
require.NoError(t, err)
require.Equal(t, "https://cdn.example.test/assets/profile-shared.webp", other.BackgroundImage)
referenced, err := NewR2DeleteTaskStore(conn).HasReferences(ctx, conf.Upload.ImageBucket, "profile-shared.webp")
require.NoError(t, err)
require.True(t, referenced)
referenced, err = NewR2DeleteTaskStore(conn).HasReferences(ctx, conf.Upload.ImageBucket, "default-background.webp")
require.NoError(t, err)
require.True(t, referenced)
}
func TestPurgeUserContentOutboxFailureRollsBack(t *testing.T) {
setR2TestConfig(t)
conn, _ := newTestDB(t)
ctx := context.Background()
users := NewUsersStore(conn)
owner := createOutboxTestUser(t, ctx, users, "[email protected]", "outbox-rollback")
question, err := NewQuestionsStore(conn).Create(ctx, CreateQuestionOptions{UserID: owner.ID, Content: "rollback"})
require.NoError(t, err)
image, err := NewUploadImagesStore(conn).Create(ctx, CreateUploadImageOptions{
QuestionID: question.ID, UploaderUserID: owner.ID, Key: "rollback.webp",
})
require.NoError(t, err)
require.NoError(t, conn.Migrator().DropTable(&R2DeleteTask{}))
require.Error(t, users.Deactivate(ctx, owner.ID))
require.Equal(t, int64(1), uploadImageCount(t, conn, image.ID))
var links int64
require.NoError(t, conn.WithContext(ctx).Model(&UploadImageQuestion{}).
Where("upload_image_id = ? AND question_id = ?", image.ID, question.ID).Count(&links).Error)
require.Equal(t, int64(1), links)
var questions int64
require.NoError(t, conn.WithContext(ctx).Unscoped().Model(&Question{}).Where("id = ?", question.ID).Count(&questions).Error)
require.Equal(t, int64(1), questions)
var user User
require.NoError(t, conn.WithContext(ctx).First(&user, owner.ID).Error)
require.False(t, user.DeletedAt.Valid)
}
+99
View File
@@ -552,6 +552,105 @@ func TestDeactivatePurgesUser(t *testing.T) {
// TestBanUserAndCleanupRemovesRow 封禁与停用的唯一差别:封禁连用户行一起物理删除,
// 该邮箱/域名被彻底释放。
func TestDeactivatePurgesAllOwnedContent(t *testing.T) {
db, _ := newTestDB(t)
ctx := context.Background()
usersStore := NewUsersStore(db)
createUser := func(email, domain string) *User {
require.Nil(t, usersStore.Create(ctx, CreateUserOptions{
Name: domain,
Password: "test-only-password",
Email: email,
Domain: domain,
}))
user, err := usersStore.GetByEmail(ctx, email)
require.Nil(t, err)
return user
}
owner := createUser("[email protected]", "purge-all")
other := createUser("[email protected]", "purge-other")
question, err := NewQuestionsStore(db).Create(ctx, CreateQuestionOptions{UserID: owner.ID, Content: "owned"})
require.Nil(t, err)
otherQuestion, err := NewQuestionsStore(db).Create(ctx, CreateQuestionOptions{UserID: other.ID, Content: "other"})
require.Nil(t, err)
images := NewUploadImagesStore(db)
ownedImage, err := images.Create(ctx, CreateUploadImageOptions{
QuestionID: question.ID, UploaderUserID: owner.ID, Key: "owned.webp",
})
require.Nil(t, err)
sharedImage, err := images.Create(ctx, CreateUploadImageOptions{
QuestionID: question.ID, UploaderUserID: owner.ID, Key: "shared.webp",
})
require.Nil(t, err)
require.Nil(t, db.WithContext(ctx).Create(&UploadImageQuestion{
UploadImageID: sharedImage.ID, QuestionID: otherQuestion.ID,
}).Error)
orphanImage := &UploadImage{UploaderUserID: owner.ID, Key: "orphan.webp"}
require.Nil(t, db.WithContext(ctx).Create(orphanImage).Error)
otherImage, err := images.Create(ctx, CreateUploadImageOptions{
QuestionID: otherQuestion.ID, UploaderUserID: other.ID, Key: "other.webp",
})
require.Nil(t, err)
conversations := NewConversationStore(db)
conversation, message, err := conversations.CreateWithFirstMessage(ctx, CreateConversationOptions{
OwnerID: owner.ID, AskerID: other.ID, MaxMessages: 20,
}, other.ID, "private")
require.Nil(t, err)
_, err = NewMessageImageStore(db).Create(ctx, CreateMessageImageOptions{
MessageID: message.ID, URL: "message.webp", FileName: "message.webp",
})
require.Nil(t, err)
vote, err := NewVotesStore(db).Create(ctx, CreateVoteOptions{
UserID: owner.ID, Title: "owned vote", Type: VoteTypeForm,
})
require.Nil(t, err)
require.Nil(t, NewVotesStore(db).UpdateStatus(ctx, vote.ID, VoteStatusPublished))
option, err := NewVoteOptionsStore(db).Create(ctx, CreateVoteOptionOptions{
VoteID: vote.ID, Content: "option",
})
require.Nil(t, err)
_, err = NewVoteFormFieldsStore(db).Create(ctx, CreateVoteFormFieldOptions{
VoteID: vote.ID, FieldType: VoteFormFieldTypeText, Label: "field",
})
require.Nil(t, err)
_, err = NewVoteSubmissionsStore(db).Create(ctx, CreateVoteSubmissionOptions{
VoteID: vote.ID, OptionID: option.ID,
})
require.Nil(t, err)
require.Nil(t, NewBoxViewsStore(db).Record(ctx, RecordBoxViewOptions{
UserID: owner.ID, ViewedOn: time.Now(), Visitor: "visitor",
}))
require.Nil(t, usersStore.Deactivate(ctx, owner.ID))
assertCount := func(model interface{}, query string, args []interface{}, want int64) {
t.Helper()
var count int64
require.Nil(t, db.WithContext(ctx).Unscoped().Model(model).Where(query, args...).Count(&count).Error)
require.Equal(t, want, count)
}
assertCount(&MessageImage{}, "message_id = ?", []interface{}{message.ID}, 0)
assertCount(&Message{}, "conversation_id = ?", []interface{}{conversation.ID}, 0)
assertCount(&Conversation{}, "id = ?", []interface{}{conversation.ID}, 0)
assertCount(&VoteSubmission{}, "vote_id = ?", []interface{}{vote.ID}, 0)
assertCount(&VoteFormField{}, "vote_id = ?", []interface{}{vote.ID}, 0)
assertCount(&VoteOption{}, "vote_id = ?", []interface{}{vote.ID}, 0)
assertCount(&Vote{}, "id = ?", []interface{}{vote.ID}, 0)
assertCount(&UploadImage{}, "id = ?", []interface{}{ownedImage.ID}, 0)
assertCount(&UploadImage{}, "id = ?", []interface{}{orphanImage.ID}, 0)
assertCount(&UploadImage{}, "id = ?", []interface{}{sharedImage.ID}, 1)
assertCount(&UploadImage{}, "id = ?", []interface{}{otherImage.ID}, 1)
assertCount(&UploadImageQuestion{}, "question_id = ?", []interface{}{question.ID}, 0)
assertCount(&UploadImageQuestion{}, "upload_image_id = ? AND question_id = ?", []interface{}{sharedImage.ID, otherQuestion.ID}, 1)
assertCount(&BoxView{}, "user_id = ?", []interface{}{owner.ID}, 0)
assertCount(&Question{}, "id = ?", []interface{}{otherQuestion.ID}, 1)
}
func TestBanUserAndCleanupRemovesRow(t *testing.T) {
db, cleanup := newTestDB(t)
defer func() { _ = cleanup("users", "questions", "feature_controls") }()
+11 -1
View File
@@ -34,11 +34,21 @@ type voteOptions struct {
*gorm.DB
}
// MaxVoteOptionLen 是选项内容在数据库中的最大字符数。
//
// 必须与 conf 的默认 vote_option_len 保持一致:配置曾经允许 300 而这里只有 200,
// 于是 201–300 字的选项「校验通过、写库失败」(MySQL 严格模式下报错,
// PostgreSQL 直接报 value too long)。改列宽而不是改配置,是因为配置本来
// 就是给箱主按需调小的,不该反过来限制它的上界。
//
// ⚠️ 改这个常量时必须同步改下面 Content 字段的 size 标签(结构体标签不能引用常量)。
const MaxVoteOptionLen = 300
// VoteOption 选项表:单选/多选/评分/排序共用。
type VoteOption struct {
dbutil.Model
VoteID uint `gorm:"index:idx_option_vote" json:"vote_id"`
Content string `gorm:"size:200" json:"content"`
Content string `gorm:"size:300" json:"content"`
SortOrder int `gorm:"default:0" json:"sort_order"`
}
+89
View File
@@ -114,3 +114,92 @@ func testVoteOptionsCountByVoteID(t *testing.T, ctx context.Context, db *voteOpt
require.Nil(t, err)
require.Equal(t, int64(0), got)
}
// TestVoteOptionMaxLengthRoundTrip 保证「配置允许的长度」真的能存下。
//
// 曾经 conf 允许 300 字而列宽只有 200:201–300 字的选项通过校验后在写库时才失败
// (PostgreSQL 报 value too long,MySQL 严格模式同样报错),用户看到的是「内部错误」。
func TestVoteOptionMaxLengthRoundTrip(t *testing.T) {
conn, cleanup := newTestDB(t)
t.Cleanup(func() {
if err := cleanup("vote_options"); err != nil {
t.Fatal(err)
}
})
ctx := context.Background()
store := NewVoteOptionsStore(conn)
// 结构体标签不能引用常量,列宽与 MaxVoteOptionLen 是两处独立字面量,
// 这里显式比一次,防止只改了一边。
columns, err := conn.Migrator().ColumnTypes(&VoteOption{})
require.Nil(t, err)
for _, ct := range columns {
if ct.Name() != "content" {
continue
}
if length, ok := ct.Length(); ok {
require.EqualValues(t, MaxVoteOptionLen, length, "vote_options.content 列宽必须与 MaxVoteOptionLen 一致")
}
}
// 用多字节字符填满上限,顺便验证按字符而非字节计数。
content := ""
for len([]rune(content)) < MaxVoteOptionLen {
content += "選"
}
option, err := store.Create(ctx, CreateVoteOptionOptions{VoteID: 1, Content: content})
require.Nil(t, err)
got, err := store.ListByVoteID(ctx, 1)
require.Nil(t, err)
require.Len(t, got, 1)
require.Equal(t, option.ID, got[0].ID)
require.Equal(t, MaxVoteOptionLen, len([]rune(got[0].Content)), "存进去的字符数应与上限一致,未被截断")
}
// TestVoteOptionColumnWidens 验证老库升级路径:已经存在 varchar(200) 的列,
// 跑一次 AutoMigrate 后必须变成 MaxVoteOptionLen。
//
// 只改 model 标签是不够的 —— 如果 AutoMigrate 不扩列,老部署上线后
// 201–300 字的选项依旧写不进去,而新库(测试库)永远发现不了这个问题。
func TestVoteOptionColumnWidens(t *testing.T) {
conn, cleanup := newTestDB(t)
t.Cleanup(func() {
if err := cleanup("vote_options"); err != nil {
t.Fatal(err)
}
})
// 退回老库的列宽(两种方言语法不同)。
var alter string
switch conn.Dialector.Name() {
case "postgres":
alter = "ALTER TABLE vote_options ALTER COLUMN content TYPE varchar(200)"
case "mysql":
alter = "ALTER TABLE vote_options MODIFY COLUMN content varchar(200)"
default:
t.Skipf("未适配的方言:%s", conn.Dialector.Name())
}
require.Nil(t, conn.Exec(alter).Error)
contentLength := func() int64 {
columns, err := conn.Migrator().ColumnTypes(&VoteOption{})
require.Nil(t, err)
for _, ct := range columns {
if ct.Name() != "content" {
continue
}
length, ok := ct.Length()
require.True(t, ok, "content 列应有明确长度")
return length
}
t.Fatal("未找到 content 列")
return 0
}
require.EqualValues(t, 200, contentLength(), "前置条件:列宽应已被改回 200")
require.Nil(t, conn.AutoMigrate(&VoteOption{}))
require.EqualValues(t, MaxVoteOptionLen, contentLength(), "AutoMigrate 必须把列宽扩到 MaxVoteOptionLen")
}
+49
View File
@@ -15,6 +15,7 @@ import (
"github.com/pkg/errors"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
@@ -25,6 +26,8 @@ type VoteSubmissionsStore interface {
Create(ctx context.Context, opts CreateVoteSubmissionOptions) (*VoteSubmission, error)
GetByID(ctx context.Context, id uint) (*VoteSubmission, error)
ListByVoteID(ctx context.Context, voteID uint, cursor *dbutil.Cursor) ([]*VoteSubmission, error)
// ListByVoteIDs 批量查询多个投票的全部提交记录(IN 查询 + 内存分组),避免逐投票 N+1。
ListByVoteIDs(ctx context.Context, voteIDs []uint) (map[uint][]*VoteSubmission, error)
CountByVoteID(ctx context.Context, voteID uint) (int64, error)
CountByVoteIDs(ctx context.Context, voteIDs []uint) (map[uint]int64, error)
CountByOptionID(ctx context.Context, voteID, optionID uint) (int64, error)
@@ -77,6 +80,7 @@ var (
// ErrVoteAlreadyVoted 同一投票被同一身份重复提交(数据库唯一索引兜底返回)。
ErrVoteAlreadyVoted = errors.New("你已参与过该投票")
ErrVoteSubmissionNotExist = errors.New("投票记录不存在")
ErrVoteNotAccepting = errors.New("投票未开放或已达到收集上限")
)
// Create 写入一条投票/提交记录。若命中复合唯一索引(同一 user 或同一 token 重复提交),
@@ -92,6 +96,26 @@ func (db *voteSubmissions) Create(ctx context.Context, opts CreateVoteSubmission
CustomText: opts.CustomText,
}
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if opts.QuotaUserID != 0 {
if err := reserveFeatureQuotaTx(tx, opts.QuotaUserID, FeatureVote); err != nil {
return err
}
}
var vote Vote
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&vote, opts.VoteID).Error; err != nil {
return errors.Wrap(err, "lock vote")
}
now := time.Now()
if vote.Status != VoteStatusPublished || (vote.StartAt != nil && now.Before(*vote.StartAt)) || (vote.EndAt != nil && !now.Before(*vote.EndAt)) {
return ErrVoteNotAccepting
}
var count int64
if err := tx.Model(&VoteSubmission{}).Where("vote_id = ?", opts.VoteID).Count(&count).Error; err != nil {
return errors.Wrap(err, "count vote submissions")
}
if vote.QuotaLimit > 0 && count >= int64(vote.QuotaLimit) {
return ErrVoteNotAccepting
}
if err := tx.Create(submission).Error; err != nil {
if isDuplicateKeyError(err) {
return ErrVoteAlreadyVoted
@@ -107,6 +131,11 @@ func (db *voteSubmissions) Create(ctx context.Context, opts CreateVoteSubmission
return errors.Wrap(err, "increment vote quota")
}
}
if vote.QuotaLimit > 0 && count+1 >= int64(vote.QuotaLimit) {
if err := tx.Model(&Vote{}).Where("id = ?", vote.ID).Update("status", VoteStatusEnded).Error; err != nil {
return errors.Wrap(err, "end vote on quota")
}
}
return nil
})
if err != nil {
@@ -148,6 +177,26 @@ func (db *voteSubmissions) ListByVoteID(ctx context.Context, voteID uint, cursor
return submissions, nil
}
// ListByVoteIDs 批量查询多个投票的全部提交记录(IN 查询 + 内存分组),避免逐投票 N+1。
// 返回 map[voteID][]*VoteSubmission;未出现在结果中的 voteID 视为无提交。
func (db *voteSubmissions) ListByVoteIDs(ctx context.Context, voteIDs []uint) (map[uint][]*VoteSubmission, error) {
out := make(map[uint][]*VoteSubmission, len(voteIDs))
if len(voteIDs) == 0 {
return out, nil
}
var submissions []*VoteSubmission
if err := db.WithContext(ctx).
Where("vote_id IN ?", voteIDs).
Order("created_at ASC").
Find(&submissions).Error; err != nil {
return nil, errors.Wrap(err, "list vote submissions by vote ids")
}
for _, s := range submissions {
out[s.VoteID] = append(out[s.VoteID], s)
}
return out, nil
}
// CountByVoteID 统计某投票的总参与人数。
func (db *voteSubmissions) CountByVoteID(ctx context.Context, voteID uint) (int64, error) {
var count int64
+114 -65
View File
@@ -11,6 +11,7 @@ import (
"github.com/stretchr/testify/require"
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
@@ -53,10 +54,41 @@ func TestVoteSubmissions(t *testing.T) {
func userIDPtr(id uint) *uint { return &id }
func tokenPtr(s string) *string { return &s }
// seedPublishedVotes 创建 n 个已发布的投票,每个投票带两个选项。
// 返回 voteID 列表;调用方用 votes[i] 作为 VoteID、options[voteID][j] 作为 OptionID。
// VoteSubmissions.Create 现在要求投票必须 published 才接受提交,旧的测试夹具
// 直接用 VoteID=1 的虚拟行会被锁行失败拒绝,因此需要真实投票。
func seedPublishedVotes(t *testing.T, ctx context.Context, conn *gorm.DB, userID uint, n int) ([]uint, map[uint][]uint) {
t.Helper()
votesStore := NewVotesStore(conn)
optionsStore := NewVoteOptionsStore(conn)
voteIDs := make([]uint, 0, n)
optionIDs := make(map[uint][]uint, n)
for i := 0; i < n; i++ {
vote, err := votesStore.Create(ctx, CreateVoteOptions{
UserID: userID, Title: "seed vote", Type: VoteTypeSingle,
})
require.NoError(t, err)
require.NoError(t, votesStore.UpdateStatus(ctx, vote.ID, VoteStatusPublished))
voteIDs = append(voteIDs, vote.ID)
optionIDs[vote.ID] = make([]uint, 0, 2)
for j := 0; j < 2; j++ {
opt, err := optionsStore.Create(ctx, CreateVoteOptionOptions{
VoteID: vote.ID, Content: "option",
})
require.NoError(t, err)
optionIDs[vote.ID] = append(optionIDs[vote.ID], opt.ID)
}
}
return voteIDs, optionIDs
}
func testVoteSubmissionsCreate(t *testing.T, ctx context.Context, db *voteSubmissions) {
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1)
voteID := voteIDs[0]
got, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 2,
VoteID: voteID,
OptionID: optionIDs[voteID][1],
UserID: userIDPtr(10),
VoterIP: "114.5.1.4",
})
@@ -67,9 +99,11 @@ func testVoteSubmissionsCreate(t *testing.T, ctx context.Context, db *voteSubmis
// 核心防刷:同一登录用户对同一投票重复提交必须被数据库唯一索引拦截。
func testVoteSubmissionsUniqueUser(t *testing.T, ctx context.Context, db *voteSubmissions) {
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1)
voteID := voteIDs[0]
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
VoteID: voteID,
OptionID: optionIDs[voteID][0],
UserID: userIDPtr(10),
VoterIP: "114.5.1.4",
})
@@ -77,8 +111,8 @@ func testVoteSubmissionsUniqueUser(t *testing.T, ctx context.Context, db *voteSu
t.Run("duplicate rejected", func(t *testing.T) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 2,
VoteID: voteID,
OptionID: optionIDs[voteID][1],
UserID: userIDPtr(10),
VoterIP: "114.5.1.4",
})
@@ -87,8 +121,8 @@ func testVoteSubmissionsUniqueUser(t *testing.T, ctx context.Context, db *voteSu
t.Run("different user allowed", func(t *testing.T) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
VoteID: voteID,
OptionID: optionIDs[voteID][0],
UserID: userIDPtr(11),
VoterIP: "114.5.1.5",
})
@@ -98,9 +132,11 @@ func testVoteSubmissionsUniqueUser(t *testing.T, ctx context.Context, db *voteSu
// 核心防刷:同一匿名指纹对同一投票重复提交必须被拦截。
func testVoteSubmissionsUniqueToken(t *testing.T, ctx context.Context, db *voteSubmissions) {
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1)
voteID := voteIDs[0]
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
VoteID: voteID,
OptionID: optionIDs[voteID][0],
VoterToken: tokenPtr("fp-token-001"),
VoterIP: "114.5.1.4",
})
@@ -108,8 +144,8 @@ func testVoteSubmissionsUniqueToken(t *testing.T, ctx context.Context, db *voteS
t.Run("duplicate rejected", func(t *testing.T) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 2,
VoteID: voteID,
OptionID: optionIDs[voteID][1],
VoterToken: tokenPtr("fp-token-001"),
VoterIP: "114.5.1.4",
})
@@ -118,8 +154,8 @@ func testVoteSubmissionsUniqueToken(t *testing.T, ctx context.Context, db *voteS
t.Run("different token allowed", func(t *testing.T) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
VoteID: voteID,
OptionID: optionIDs[voteID][0],
VoterToken: tokenPtr("fp-token-002"),
VoterIP: "114.5.1.4",
})
@@ -129,17 +165,19 @@ func testVoteSubmissionsUniqueToken(t *testing.T, ctx context.Context, db *voteS
// 登录用户与匿名指纹可在同一投票下并存(user_id 为 NULL 与 voter_token 为 NULL 不冲突)。
func testVoteSubmissionsUserAndAnonCoexist(t *testing.T, ctx context.Context, db *voteSubmissions) {
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1)
voteID := voteIDs[0]
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
VoteID: voteID,
OptionID: optionIDs[voteID][0],
UserID: userIDPtr(10),
VoterIP: "114.5.1.4",
})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 2,
VoteID: voteID,
OptionID: optionIDs[voteID][1],
VoterToken: tokenPtr("fp-anon-001"),
VoterIP: "114.5.1.6",
})
@@ -147,25 +185,26 @@ func testVoteSubmissionsUserAndAnonCoexist(t *testing.T, ctx context.Context, db
// 同一 user 换匿名 token 再来:user_id 相同仍应被拦截
_, err = db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
VoteID: voteID,
OptionID: optionIDs[voteID][0],
UserID: userIDPtr(10),
VoterToken: tokenPtr("fp-anon-002"),
VoterIP: "114.5.1.7",
})
require.Equal(t, ErrVoteAlreadyVoted, err)
count, err := db.CountByVoteID(ctx, 1)
count, err := db.CountByVoteID(ctx, voteID)
require.Nil(t, err)
require.Equal(t, int64(2), count)
}
// 同一用户 / 同一 token 对不同投票不受约束。
func testVoteSubmissionsCrossVote(t *testing.T, ctx context.Context, db *voteSubmissions) {
for voteID := uint(1); voteID <= 2; voteID++ {
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 2)
for _, voteID := range voteIDs {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: voteID,
OptionID: 1,
OptionID: optionIDs[voteID][0],
UserID: userIDPtr(10),
VoterIP: "114.5.1.4",
})
@@ -173,50 +212,50 @@ func testVoteSubmissionsCrossVote(t *testing.T, ctx context.Context, db *voteSub
}
// 同一 token 投两个不同投票也允许
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
VoterToken: tokenPtr("fp-x"),
VoterIP: "114.5.1.8",
})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 2,
OptionID: 1,
VoterToken: tokenPtr("fp-x"),
VoterIP: "114.5.1.8",
})
require.Nil(t, err)
for _, voteID := range voteIDs {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: voteID,
OptionID: optionIDs[voteID][0],
VoterToken: tokenPtr("fp-x"),
VoterIP: "114.5.1.8",
})
require.Nil(t, err)
}
count, err := db.CountByVoteID(ctx, 1)
count, err := db.CountByVoteID(ctx, voteIDs[0])
require.Nil(t, err)
require.Equal(t, int64(2), count)
}
func testVoteSubmissionsCount(t *testing.T, ctx context.Context, db *voteSubmissions) {
// 投票 1:选项 1 两票,选项 2 一票
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(1), VoterIP: "a"})
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 2)
vote1, vote2 := voteIDs[0], voteIDs[1]
opt1a, opt1b := optionIDs[vote1][0], optionIDs[vote1][1]
opt2a := optionIDs[vote2][0]
// 投票 1:选项 A 两票,选项 B 一票
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: vote1, OptionID: opt1a, UserID: userIDPtr(1), VoterIP: "a"})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(2), VoterIP: "b"})
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: vote1, OptionID: opt1a, UserID: userIDPtr(2), VoterIP: "b"})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 2, UserID: userIDPtr(3), VoterIP: "c"})
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: vote1, OptionID: opt1b, UserID: userIDPtr(3), VoterIP: "c"})
require.Nil(t, err)
// 投票 2:一票
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 2, OptionID: 1, UserID: userIDPtr(4), VoterIP: "d"})
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: vote2, OptionID: opt2a, UserID: userIDPtr(4), VoterIP: "d"})
require.Nil(t, err)
t.Run("total", func(t *testing.T) {
got, err := db.CountByVoteID(ctx, 1)
got, err := db.CountByVoteID(ctx, vote1)
require.Nil(t, err)
require.Equal(t, int64(3), got)
})
t.Run("by option", func(t *testing.T) {
got, err := db.CountByOptionID(ctx, 1, 1)
got, err := db.CountByOptionID(ctx, vote1, opt1a)
require.Nil(t, err)
require.Equal(t, int64(2), got)
got, err = db.CountByOptionID(ctx, 1, 2)
got, err = db.CountByOptionID(ctx, vote1, opt1b)
require.Nil(t, err)
require.Equal(t, int64(1), got)
})
@@ -229,54 +268,62 @@ func testVoteSubmissionsCount(t *testing.T, ctx context.Context, db *voteSubmiss
}
func testVoteSubmissionsHasUserVoted(t *testing.T, ctx context.Context, db *voteSubmissions) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(10), VoterIP: "a"})
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1)
voteID := voteIDs[0]
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: voteID, OptionID: optionIDs[voteID][0], UserID: userIDPtr(10), VoterIP: "a"})
require.Nil(t, err)
got, err := db.HasUserVoted(ctx, 1, 10)
got, err := db.HasUserVoted(ctx, voteID, 10)
require.Nil(t, err)
require.True(t, got)
got, err = db.HasUserVoted(ctx, 1, 11)
got, err = db.HasUserVoted(ctx, voteID, 11)
require.Nil(t, err)
require.False(t, got)
}
func testVoteSubmissionsHasTokenVoted(t *testing.T, ctx context.Context, db *voteSubmissions) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, VoterToken: tokenPtr("fp-1"), VoterIP: "a"})
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1)
voteID := voteIDs[0]
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: voteID, OptionID: optionIDs[voteID][0], VoterToken: tokenPtr("fp-1"), VoterIP: "a"})
require.Nil(t, err)
got, err := db.HasTokenVoted(ctx, 1, "fp-1")
got, err := db.HasTokenVoted(ctx, voteID, "fp-1")
require.Nil(t, err)
require.True(t, got)
got, err = db.HasTokenVoted(ctx, 1, "fp-2")
got, err = db.HasTokenVoted(ctx, voteID, "fp-2")
require.Nil(t, err)
require.False(t, got)
}
func testVoteSubmissionsCountByIP(t *testing.T, ctx context.Context, db *voteSubmissions) {
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1)
voteID := voteIDs[0]
since := time.Now().Add(-1 * time.Hour)
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(1), VoterIP: "1.2.3.4"})
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: voteID, OptionID: optionIDs[voteID][0], UserID: userIDPtr(1), VoterIP: "1.2.3.4"})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(2), VoterIP: "1.2.3.4"})
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: voteID, OptionID: optionIDs[voteID][0], UserID: userIDPtr(2), VoterIP: "1.2.3.4"})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(3), VoterIP: "5.6.7.8"})
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: voteID, OptionID: optionIDs[voteID][0], UserID: userIDPtr(3), VoterIP: "5.6.7.8"})
require.Nil(t, err)
got, err := db.CountByIP(ctx, 1, "1.2.3.4", since)
got, err := db.CountByIP(ctx, voteID, "1.2.3.4", since)
require.Nil(t, err)
require.Equal(t, int64(2), got)
got, err = db.CountByIP(ctx, 1, "5.6.7.8", since)
got, err = db.CountByIP(ctx, voteID, "5.6.7.8", since)
require.Nil(t, err)
require.Equal(t, int64(1), got)
}
func testVoteSubmissionsListByVoteID(t *testing.T, ctx context.Context, db *voteSubmissions) {
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1)
voteID := voteIDs[0]
for i := 0; i < 3; i++ {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
VoteID: voteID,
OptionID: optionIDs[voteID][0],
UserID: userIDPtr(uint(100 + i)),
VoterIP: "x",
FormData: datatypes.JSON(`{"nickname":"user` + string(rune('0'+i)) + `"}`),
@@ -284,25 +331,27 @@ func testVoteSubmissionsListByVoteID(t *testing.T, ctx context.Context, db *vote
require.Nil(t, err)
}
got, err := db.ListByVoteID(ctx, 1, &dbutil.Cursor{Value: 2, PageSize: 10})
got, err := db.ListByVoteID(ctx, voteID, &dbutil.Cursor{Value: 2, PageSize: 10})
require.Nil(t, err)
require.Len(t, got, 1)
require.Equal(t, uint(1), got[0].ID, "游标取 id < 2 的记录")
}
func testVoteSubmissionsDeleteByVoteID(t *testing.T, ctx context.Context, db *voteSubmissions) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(1), VoterIP: "a"})
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 2)
vote1, vote2 := voteIDs[0], voteIDs[1]
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: vote1, OptionID: optionIDs[vote1][0], UserID: userIDPtr(1), VoterIP: "a"})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 2, OptionID: 1, UserID: userIDPtr(2), VoterIP: "b"})
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: vote2, OptionID: optionIDs[vote2][0], UserID: userIDPtr(2), VoterIP: "b"})
require.Nil(t, err)
require.Nil(t, db.DeleteByVoteID(ctx, 1))
require.Nil(t, db.DeleteByVoteID(ctx, vote1))
got, err := db.CountByVoteID(ctx, 1)
got, err := db.CountByVoteID(ctx, vote1)
require.Nil(t, err)
require.Equal(t, int64(0), got)
got, err = db.CountByVoteID(ctx, 2)
got, err = db.CountByVoteID(ctx, vote2)
require.Nil(t, err)
require.Equal(t, int64(1), got)
}
+142
View File
@@ -61,6 +61,9 @@ const (
type VotesStore interface {
Create(ctx context.Context, opts CreateVoteOptions) (*Vote, error)
// CreateWithOptions 在单个事务里创建投票 + 选项 + 表单字段。
// 任一步失败整体回滚,避免留下「有投票无选项」或「有选项无字段」的半成品。
CreateWithOptions(ctx context.Context, opts CreateVoteOptions, optionContents []string, formFields []CreateVoteFormFieldOptions) (*Vote, error)
GetByID(ctx context.Context, id uint) (*Vote, error)
GetByToken(ctx context.Context, token string) (*Vote, error)
ListByUserID(ctx context.Context, userID uint, opts ListVotesOptions) ([]*Vote, error)
@@ -68,6 +71,9 @@ type VotesStore interface {
ListPublishedPublic(ctx context.Context, limit int) ([]*Vote, error)
GetHot(ctx context.Context, limit int) ([]*Vote, error)
Update(ctx context.Context, id uint, opts UpdateVoteOptions) error
// UpdateWithOptions 在单个事务里更新投票元信息 + 替换选项 + 替换表单字段。
// 仅在草稿状态调用(发布后选项/字段锁定,避免破坏历史数据归因)。
UpdateWithOptions(ctx context.Context, id uint, opts UpdateVoteOptions, optionContents []string, formFields []CreateVoteFormFieldOptions) error
UpdateStatus(ctx context.Context, id uint, status string) error
DeleteByID(ctx context.Context, id uint) error
// DeleteWithCascade 在一个事务里真删投票及其选项、表单字段、提交记录。
@@ -160,6 +166,69 @@ func (db *votes) Create(ctx context.Context, opts CreateVoteOptions) (*Vote, err
return vote, nil
}
// CreateWithOptions 在单个事务里创建投票 + 选项 + 表单字段。
// 任一步失败整体回滚,避免留下「有投票无选项」或「有选项无字段」的半成品。
func (db *votes) CreateWithOptions(ctx context.Context, opts CreateVoteOptions, optionContents []string, formFields []CreateVoteFormFieldOptions) (*Vote, error) {
var vote *Vote
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
v := &Vote{
UserID: opts.UserID,
Title: opts.Title,
Description: opts.Description,
Type: opts.Type,
Visibility: opts.Visibility,
VoterScope: opts.VoterScope,
ShowResult: opts.ShowResult,
AllowCustom: opts.AllowCustom,
MaxChoices: opts.MaxChoices,
StartAt: opts.StartAt,
EndAt: opts.EndAt,
MaxVotesPerFingerprint: opts.MaxVotesPerFingerprint,
RequireCaptcha: opts.RequireCaptcha,
CensorMetadata: opts.CensorMetadata,
QuotaLimit: opts.QuotaLimit,
}
if err := tx.Create(v).Error; err != nil {
return errors.Wrap(err, "create vote")
}
vote = v
if len(optionContents) > 0 {
options := make([]*VoteOption, 0, len(optionContents))
for i, content := range optionContents {
options = append(options, &VoteOption{VoteID: v.ID, Content: content, SortOrder: i})
}
if err := tx.Create(&options).Error; err != nil {
return errors.Wrap(err, "create vote options")
}
}
if len(formFields) > 0 {
fields := make([]*VoteFormField, 0, len(formFields))
for _, f := range formFields {
fields = append(fields, &VoteFormField{
VoteID: v.ID,
FieldType: f.FieldType,
Label: f.Label,
Required: f.Required,
MaxLength: f.MaxLength,
Options: f.Options,
SortOrder: f.SortOrder,
AllowCustom: f.AllowCustom,
})
}
if err := tx.Create(&fields).Error; err != nil {
return errors.Wrap(err, "create vote form fields")
}
}
return nil
})
if err != nil {
return nil, err
}
return vote, nil
}
var ErrVoteNotExist = errors.New("投票不存在")
func (db *votes) GetByID(ctx context.Context, id uint) (*Vote, error) {
@@ -335,6 +404,79 @@ func (db *votes) Update(ctx context.Context, id uint, opts UpdateVoteOptions) er
return nil
}
// UpdateWithOptions 在单个事务里更新投票元信息 + 替换选项 + 替换表单字段。
// 仅在草稿状态调用(发布后选项/字段锁定,避免破坏历史数据归因)。
// 任一步失败整体回滚,避免「元信息已更新、字段只更新一部分」的不一致草稿。
func (db *votes) UpdateWithOptions(ctx context.Context, id uint, opts UpdateVoteOptions, optionContents []string, formFields []CreateVoteFormFieldOptions) error {
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
updates := map[string]interface{}{
"title": opts.Title,
"description": opts.Description,
"visibility": opts.Visibility,
"voter_scope": opts.VoterScope,
"show_result": opts.ShowResult,
"allow_custom": opts.AllowCustom,
"start_at": opts.StartAt,
"end_at": opts.EndAt,
"max_votes_per_fingerprint": opts.MaxVotesPerFingerprint,
"require_captcha": opts.RequireCaptcha,
"censor_metadata": opts.CensorMetadata,
"quota_limit": opts.QuotaLimit,
}
if opts.Type != nil {
updates["type"] = *opts.Type
}
if opts.MaxChoices != nil {
updates["max_choices"] = *opts.MaxChoices
}
res := tx.Model(&Vote{}).Where("id = ?", id).Updates(updates)
if res.Error != nil {
return errors.Wrap(res.Error, "update vote")
}
if res.RowsAffected == 0 {
return ErrVoteNotExist
}
// 替换选项(先删后建)
if err := tx.Where("vote_id = ?", id).Delete(&VoteOption{}).Error; err != nil {
return errors.Wrap(err, "delete old vote options")
}
if len(optionContents) > 0 {
options := make([]*VoteOption, 0, len(optionContents))
for i, content := range optionContents {
options = append(options, &VoteOption{VoteID: id, Content: content, SortOrder: i})
}
if err := tx.Create(&options).Error; err != nil {
return errors.Wrap(err, "create vote options")
}
}
// 替换表单字段(先删后建)
if err := tx.Where("vote_id = ?", id).Delete(&VoteFormField{}).Error; err != nil {
return errors.Wrap(err, "delete old vote form fields")
}
if len(formFields) > 0 {
fields := make([]*VoteFormField, 0, len(formFields))
for _, f := range formFields {
fields = append(fields, &VoteFormField{
VoteID: id,
FieldType: f.FieldType,
Label: f.Label,
Required: f.Required,
MaxLength: f.MaxLength,
Options: f.Options,
SortOrder: f.SortOrder,
AllowCustom: f.AllowCustom,
})
}
if err := tx.Create(&fields).Error; err != nil {
return errors.Wrap(err, "create vote form fields")
}
}
return nil
})
}
// UpdateStatus 更新投票状态(draft → published → ended → archived)。
func (db *votes) UpdateStatus(ctx context.Context, id uint, status string) error {
res := db.WithContext(ctx).Model(&Vote{}).Where("id = ?", id).Update("status", status)
+1
View File
@@ -17,6 +17,7 @@ type UpdateProfile struct {
Backgroundimagealpha string `valid:"required" label:"二维码背景透明度" msg:"form.profile.backgroundimagealpha"`
Qrcodepdpcolor string `valid:"required;minlen:7;maxlen:7" label:"二维码探测图形颜色" msg:"form.profile.colorHex"`
NotifyEmail string `label:"开启邮箱通知"`
TelegramChatID string `form:"telegram_chat_id" valid:"maxlen:64" label:"Telegram Chat ID" msg:"form.profile.telegramChatId"`
Acfunlink string `valid:"maxlen:30" label:"AcFun主页" msg:"form.profile.linkMax"`
Bililink string `valid:"maxlen:30" label:"Bili主页" msg:"form.profile.linkMax"`
Weibolink string `valid:"maxlen:30" label:"微博主页" msg:"form.profile.linkMax"`
+57 -54
View File
@@ -200,34 +200,37 @@ var enDict = map[string]string{
"admin.export": "Export data",
// Admin settings (additional)
"admin.oldPasswordHint": "Only needed when changing password; leave empty to keep it unchanged",
"admin.newPasswordHint": "6-64 characters; leave empty to keep it unchanged",
"admin.notifyEmail": "Email",
"admin.useCustomFg": "Use custom foreground color (otherwise auto-matched)",
"admin.customFgHint": "Only takes effect when enabled; otherwise the color is auto-generated",
"admin.avatarPlaceholder": "Upload avatar (non-1:1 images will be squished)",
"admin.backgroundPlaceholder": "Upload box background",
"admin.qrBackgroundPlaceholder": "Upload QR background (a background that is too dark may make the QR code unreadable)",
"admin.qrPreviewHint": "The preview updates instantly after choosing an image",
"admin.preview": "Live preview",
"admin.themePreset": "Preset themes",
"admin.themeDefault": "Default",
"admin.themeDark": "Dark",
"admin.themeOcean": "Ocean",
"admin.themeSunset": "Sunset",
"admin.resetDefaults": "🔄 Reset to defaults",
"admin.previewHint": "Adjustments are for preview only; click \"Save changes\" to apply",
"admin.socialHint": "Check the box and fill in the profile URL (no https:// needed). Unchecked items won't be shown on your page.",
"admin.enableAcfun": "Enable AcFun",
"admin.enableBili": "Enable Bilibili",
"admin.enableWeibo": "Enable Weibo",
"admin.enableXiaohongshu": "Enable Xiaohongshu",
"admin.enableBlog": "Enable Personal Blog",
"admin.registerOnly": "Only allow registered users to ask me questions",
"admin.updateHarassment": "Update anti-harassment settings",
"admin.globalBlockWords": "Global blocked words",
"admin.globalBlockWordsHint": "Blocked words apply to questions, chat messages sent to you, and vote submissions. This single setting is shared across the question / chat / vote panels and takes effect immediately from any of them.",
"admin.updateBlockWords": "Save blocked words",
"admin.oldPasswordHint": "Only needed when changing password; leave empty to keep it unchanged",
"admin.newPasswordHint": "6-64 characters; leave empty to keep it unchanged",
"admin.notifyEmail": "Email",
"admin.notifyTelegram": "Telegram",
"admin.notifyTelegramPlaceholder": "Telegram Chat ID (empty = disabled)",
"admin.notifyTelegramHint": "Send any message to @userinfobot to get your Chat ID. Requires a site-configured Telegram Bot.",
"admin.useCustomFg": "Use custom foreground color (otherwise auto-matched)",
"admin.customFgHint": "Only takes effect when enabled; otherwise the color is auto-generated",
"admin.avatarPlaceholder": "Upload avatar (non-1:1 images will be squished)",
"admin.backgroundPlaceholder": "Upload box background",
"admin.qrBackgroundPlaceholder": "Upload QR background (a background that is too dark may make the QR code unreadable)",
"admin.qrPreviewHint": "The preview updates instantly after choosing an image",
"admin.preview": "Live preview",
"admin.themePreset": "Preset themes",
"admin.themeDefault": "Default",
"admin.themeDark": "Dark",
"admin.themeOcean": "Ocean",
"admin.themeSunset": "Sunset",
"admin.resetDefaults": "🔄 Reset to defaults",
"admin.previewHint": "Adjustments are for preview only; click \"Save changes\" to apply",
"admin.socialHint": "Check the box and fill in the profile URL (no https:// needed). Unchecked items won't be shown on your page.",
"admin.enableAcfun": "Enable AcFun",
"admin.enableBili": "Enable Bilibili",
"admin.enableWeibo": "Enable Weibo",
"admin.enableXiaohongshu": "Enable Xiaohongshu",
"admin.enableBlog": "Enable Personal Blog",
"admin.registerOnly": "Only allow registered users to ask me questions",
"admin.updateHarassment": "Update anti-harassment settings",
"admin.globalBlockWords": "Global blocked words",
"admin.globalBlockWordsHint": "Blocked words apply to questions, chat messages sent to you, and vote submissions. This single setting is shared across the question / chat / vote panels and takes effect immediately from any of them.",
"admin.updateBlockWords": "Save blocked words",
// Moderation strictness: shared by questions / polls / chat. Decision rules live in internal/security/censor/policy.go.
"admin.moderationStrictness": "Moderation strictness",
@@ -1055,36 +1058,36 @@ var enDict = map[string]string{
"stats.weekday.7": "Sunday",
// Owner insights panel (/user/insights)
"insights.title": "Insights",
"insights.heading": "Your insights",
"insights.lead": "These numbers are yours alone: how many people opened your box, how many questions arrived, and how many are still waiting. Everything is aggregated — no visitor or asker is ever identified.",
"insights.back": "Back to settings",
"insights.entryTitle": "Insights",
"insights.entryDesc": "See how many people visit your box and how your questions are doing",
"insights.summary": "At a glance",
"insights.noViews": "No visits recorded yet. Numbers will appear once someone opens your box.",
"insights.today": "Today",
"insights.week": "Last 7 days",
"insights.month": "Last 30 days",
"insights.newQuestions": "new questions",
"insights.pv": "views",
"insights.uv": "visitors",
"insights.trendTitle": "Views over the last 30 days",
"insights.trendNote": "One bar per day: the light part is page views, the dark part is unique visitors that day. Hover a bar for its views / visitors / questions.",
"insights.legendPV": "Page views",
"insights.legendUV": "Unique visitors (per day)",
"insights.legendHover": "Hover for daily details",
"insights.hourTitle": "When people show up",
"insights.hourNote": "When questions arrived during the last 30 days. The brightest bar is your busiest hour.",
"insights.funnelTitle": "Question handling",
"insights.funnelNote": "All questions split into answered / read but unanswered / untouched.",
"insights.title": "Insights",
"insights.heading": "Your insights",
"insights.lead": "These numbers are yours alone: how many people opened your box, how many questions arrived, and how many are still waiting. Everything is aggregated — no visitor or asker is ever identified.",
"insights.back": "Back to settings",
"insights.entryTitle": "Insights",
"insights.entryDesc": "See how many people visit your box and how your questions are doing",
"insights.summary": "At a glance",
"insights.noViews": "No visits recorded yet. Numbers will appear once someone opens your box.",
"insights.today": "Today",
"insights.week": "Last 7 days",
"insights.month": "Last 30 days",
"insights.newQuestions": "new questions",
"insights.pv": "views",
"insights.uv": "visitors",
"insights.trendTitle": "Views over the last 30 days",
"insights.trendNote": "One bar per day: the light part is page views, the dark part is unique visitors that day. Hover a bar for its views / visitors / questions.",
"insights.legendPV": "Page views",
"insights.legendUV": "Unique visitors (per day)",
"insights.legendHover": "Hover for daily details",
"insights.hourTitle": "When people show up",
"insights.hourNote": "When questions arrived during the last 30 days. The brightest bar is your busiest hour.",
"insights.funnelTitle": "Question handling",
"insights.funnelNote": "All questions split into answered / read but unanswered / untouched.",
"insights.funnelTotal": "All questions",
"insights.funnelAnswered": "Answered",
"insights.funnelReadOnly": "Read, not answered",
"insights.funnelUnread": "Not read",
"insights.answerRate": "Answer rate: answered ÷ all questions",
"insights.privacyTitle": "About these numbers",
"insights.privacyNote": "Visit stats only record \"someone visited N times on a given day\". A visitor is an IP hash with a per-day salt — no IP, no user agent, no page path is stored, and the same person cannot be linked across days. The 7-day / 30-day visitor counts are therefore the sum of daily uniques: someone visiting on several days counts several times. Your own visits to your own box are excluded.",
"insights.answerRate": "Answer rate: answered ÷ all questions",
"insights.privacyTitle": "About these numbers",
"insights.privacyNote": "Visit stats only record \"someone visited N times on a given day\". A visitor is an IP hash with a per-day salt — no IP, no user agent, no page path is stored, and the same person cannot be linked across days. The 7-day / 30-day visitor counts are therefore the sum of daily uniques: someone visiting on several days counts several times. Your own visits to your own box are excluded.",
"insights.insight.empty": "No data yet. This page comes alive once you get your first question or someone opens your box.",
"insights.insight.weekViews": "%s views from %s visitors in the last 7 days.",
+57 -54
View File
@@ -200,34 +200,37 @@ var jaDict = map[string]string{
"admin.export": "データをエクスポート",
// 管理画面設定(追加)
"admin.oldPasswordHint": "パスワード変更時のみ使用。空欄のままなら変更されません",
"admin.newPasswordHint": "6〜64文字。空欄のままなら変更されません",
"admin.notifyEmail": "メール",
"admin.useCustomFg": "カスタム前景色を使用(それ以外は自動マッチ)",
"admin.customFgHint": "有効時のみ適用されます。それ以外は自動生成されます",
"admin.avatarPlaceholder": "アバターをアップロード(1:1 以外は縦横比が崩れます)",
"admin.backgroundPlaceholder": "質問箱の背景をアップロード",
"admin.qrBackgroundPlaceholder": "QRコードの背景画像をアップロード(暗すぎる背景はQRコードが読み取れなくなることがあります)",
"admin.qrPreviewHint": "画像を選択するとプレビューが即時更新されます",
"admin.preview": "ライブプレビュー",
"admin.themePreset": "プリセットテーマ",
"admin.themeDefault": "デフォルト",
"admin.themeDark": "ダーク",
"admin.themeOcean": "オーシャン",
"admin.themeSunset": "サンセット",
"admin.resetDefaults": "🔄 デフォルトに戻す",
"admin.previewHint": "調整はプレビューのみです。「変更を保存」をクリックすると反映されます",
"admin.socialHint": "チェックを入れてプロフィールURLを入力してください(https:// は不要)。チェックのない項目は表示されません。",
"admin.enableAcfun": "AcFun を有効にする",
"admin.enableBili": "Bilibili を有効にする",
"admin.enableWeibo": "Weibo を有効にする",
"admin.enableXiaohongshu": "小紅書 を有効にする",
"admin.enableBlog": "個人ブログを有効にする",
"admin.registerOnly": "登録ユーザーのみ質問を許可",
"admin.updateHarassment": "迷惑行為対策を更新",
"admin.globalBlockWords": "共通ブロックワード",
"admin.globalBlockWordsHint": "ブロックワードは質問・チャット(相手からあなたへのメッセージ)・投票の投稿内容に適用されます。この設定は質問/チャット/投票の3つのタブで共有され、どこで変更してもすぐに反映されます。",
"admin.updateBlockWords": "ブロックワードを保存",
"admin.oldPasswordHint": "パスワード変更時のみ使用。空欄のままなら変更されません",
"admin.newPasswordHint": "6〜64文字。空欄のままなら変更されません",
"admin.notifyEmail": "メール",
"admin.notifyTelegram": "Telegram 通知",
"admin.notifyTelegramPlaceholder": "Telegram Chat ID(空欄なら送信しない)",
"admin.notifyTelegramHint": "@userinfobot に何か送ると Chat ID を取得できます。サイト側で Telegram Bot の設定が必要です。",
"admin.useCustomFg": "カスタム前景色を使用(それ以外は自動マッチ)",
"admin.customFgHint": "有効時のみ適用されます。それ以外は自動生成されます",
"admin.avatarPlaceholder": "アバターをアップロード(1:1 以外は縦横比が崩れます)",
"admin.backgroundPlaceholder": "質問箱の背景をアップロード",
"admin.qrBackgroundPlaceholder": "QRコードの背景画像をアップロード(暗すぎる背景はQRコードが読み取れなくなることがあります)",
"admin.qrPreviewHint": "画像を選択するとプレビューが即時更新されます",
"admin.preview": "ライブプレビュー",
"admin.themePreset": "プリセットテーマ",
"admin.themeDefault": "デフォルト",
"admin.themeDark": "ダーク",
"admin.themeOcean": "オーシャン",
"admin.themeSunset": "サンセット",
"admin.resetDefaults": "🔄 デフォルトに戻す",
"admin.previewHint": "調整はプレビューのみです。「変更を保存」をクリックすると反映されます",
"admin.socialHint": "チェックを入れてプロフィールURLを入力してください(https:// は不要)。チェックのない項目は表示されません。",
"admin.enableAcfun": "AcFun を有効にする",
"admin.enableBili": "Bilibili を有効にする",
"admin.enableWeibo": "Weibo を有効にする",
"admin.enableXiaohongshu": "小紅書 を有効にする",
"admin.enableBlog": "個人ブログを有効にする",
"admin.registerOnly": "登録ユーザーのみ質問を許可",
"admin.updateHarassment": "迷惑行為対策を更新",
"admin.globalBlockWords": "共通ブロックワード",
"admin.globalBlockWordsHint": "ブロックワードは質問・チャット(相手からあなたへのメッセージ)・投票の投稿内容に適用されます。この設定は質問/チャット/投票の3つのタブで共有され、どこで変更してもすぐに反映されます。",
"admin.updateBlockWords": "ブロックワードを保存",
// 審核の厳格度:質問 / 投票 / チャットで共通。判定規則は internal/security/censor/policy.go。
"admin.moderationStrictness": "審核の厳格度",
@@ -1054,36 +1057,36 @@ var jaDict = map[string]string{
"stats.weekday.7": "日曜日",
// 質問箱オーナーのインサイト(/user/insights)
"insights.title": "インサイト",
"insights.heading": "あなたのインサイト",
"insights.lead": "この数字はあなただけのものです。何人が質問箱を開いたか、いくつ質問が届いたか、まだ手つかずの質問がいくつあるか。すべて集計値で、訪問者や質問者の身元は一切含みません。",
"insights.back": "設定に戻る",
"insights.entryTitle": "インサイト",
"insights.entryDesc": "質問箱に何人来たか、質問の対応状況を見る",
"insights.summary": "ひと目で",
"insights.noViews": "まだ訪問の記録がありません。誰かが質問箱を開くと数字が出はじめます。",
"insights.today": "今日",
"insights.week": "過去 7 日間",
"insights.month": "過去 30 日間",
"insights.newQuestions": "新着の質問",
"insights.pv": "回表示",
"insights.uv": "人",
"insights.trendTitle": "過去 30 日間の閲覧推移",
"insights.trendNote": "1 本が 1 日分。淡い部分が閲覧数、濃い部分がその日の重複を除いた訪問者数です。棒にカーソルを合わせると当日の閲覧/訪問者/質問数が出ます。",
"insights.legendPV": "閲覧数(PV)",
"insights.legendUV": "ユニーク訪問者(日ごとに重複排除)",
"insights.legendHover": "カーソルで当日の内訳",
"insights.hourTitle": "いつ来ているか",
"insights.hourNote": "過去 30 日に届いた質問の時間帯分布。最も明るい棒が一番忙しい時間帯です。",
"insights.funnelTitle": "質問の対応状況",
"insights.funnelNote": "すべての質問を「回答済み/既読・未回答/未読」に分けたものです。",
"insights.title": "インサイト",
"insights.heading": "あなたのインサイト",
"insights.lead": "この数字はあなただけのものです。何人が質問箱を開いたか、いくつ質問が届いたか、まだ手つかずの質問がいくつあるか。すべて集計値で、訪問者や質問者の身元は一切含みません。",
"insights.back": "設定に戻る",
"insights.entryTitle": "インサイト",
"insights.entryDesc": "質問箱に何人来たか、質問の対応状況を見る",
"insights.summary": "ひと目で",
"insights.noViews": "まだ訪問の記録がありません。誰かが質問箱を開くと数字が出はじめます。",
"insights.today": "今日",
"insights.week": "過去 7 日間",
"insights.month": "過去 30 日間",
"insights.newQuestions": "新着の質問",
"insights.pv": "回表示",
"insights.uv": "人",
"insights.trendTitle": "過去 30 日間の閲覧推移",
"insights.trendNote": "1 本が 1 日分。淡い部分が閲覧数、濃い部分がその日の重複を除いた訪問者数です。棒にカーソルを合わせると当日の閲覧/訪問者/質問数が出ます。",
"insights.legendPV": "閲覧数(PV)",
"insights.legendUV": "ユニーク訪問者(日ごとに重複排除)",
"insights.legendHover": "カーソルで当日の内訳",
"insights.hourTitle": "いつ来ているか",
"insights.hourNote": "過去 30 日に届いた質問の時間帯分布。最も明るい棒が一番忙しい時間帯です。",
"insights.funnelTitle": "質問の対応状況",
"insights.funnelNote": "すべての質問を「回答済み/既読・未回答/未読」に分けたものです。",
"insights.funnelTotal": "すべての質問",
"insights.funnelAnswered": "回答済み",
"insights.funnelReadOnly": "既読・未回答",
"insights.funnelUnread": "未読",
"insights.answerRate": "回答率:回答済み ÷ 全質問",
"insights.privacyTitle": "この数字について",
"insights.privacyNote": "訪問統計は「ある日に何回来たか」だけを記録します。訪問者の識別子は日ごとに塩を変えた IP ハッシュで、IP・UA・閲覧ページは保存しません。そのため日をまたいで同一人物を紐付けることはできず、7 日/30 日の訪問者数は各日の重複排除後の合計です(同じ人が数日来るとその人数分カウントされます)。質問箱の持ち主自身のアクセスは含みません。",
"insights.answerRate": "回答率:回答済み ÷ 全質問",
"insights.privacyTitle": "この数字について",
"insights.privacyNote": "訪問統計は「ある日に何回来たか」だけを記録します。訪問者の識別子は日ごとに塩を変えた IP ハッシュで、IP・UA・閲覧ページは保存しません。そのため日をまたいで同一人物を紐付けることはできず、7 日/30 日の訪問者数は各日の重複排除後の合計です(同じ人が数日来るとその人数分カウントされます)。質問箱の持ち主自身のアクセスは含みません。",
"insights.insight.empty": "まだデータがありません。最初の質問が届くか、誰かが質問箱を開くと表示されはじめます。",
"insights.insight.weekViews": "過去 7 日間に %s 回の閲覧、%s 人の訪問者。",
+57 -54
View File
@@ -200,34 +200,37 @@ var zhCNDict = map[string]string{
"admin.export": "导出数据",
// 后台设置(补充)
"admin.oldPasswordHint": "修改密码使用,留空则不修改",
"admin.newPasswordHint": "6-64 位,留空则不修改",
"admin.notifyEmail": "邮件",
"admin.useCustomFg": "启用自定义前景色(否则自动匹配)",
"admin.customFgHint": "仅在启用时生效,否则颜色由系统自动生成",
"admin.avatarPlaceholder": "上传个人头像(头像比例不为1:1就扁了你哦)",
"admin.backgroundPlaceholder": "上传提问箱背景",
"admin.qrBackgroundPlaceholder": "上传二维码背景(颜色过深的背景会导致二维码无法识别)",
"admin.qrPreviewHint": "选择图片后预览将即时更新",
"admin.preview": "实时预览",
"admin.themePreset": "预设主题",
"admin.themeDefault": "默认",
"admin.themeDark": "暗夜",
"admin.themeOcean": "海洋",
"admin.themeSunset": "日落",
"admin.resetDefaults": "🔄 恢复默认值",
"admin.previewHint": "调整仅用于预览,最终效果点击「保存修改」后生效",
"admin.socialHint": "勾选后填写主页地址(无需 https://),未勾选的项将不会在前台显示。",
"admin.enableAcfun": "启用 AcFun",
"admin.enableBili": "启用 Bilibili",
"admin.enableWeibo": "启用 微博",
"admin.enableXiaohongshu": "启用 小红书",
"admin.enableBlog": "启用 个人博客",
"admin.registerOnly": "仅允许注册用户向我提问",
"admin.updateHarassment": "更新防骚扰设置",
"admin.globalBlockWords": "全局屏蔽词",
"admin.globalBlockWordsHint": "屏蔽词对提问、私聊(对方发给你的消息)与投票提交内容生效;三个设置页同步显示同一份设置,任意一处修改即时生效。",
"admin.updateBlockWords": "保存屏蔽词",
"admin.oldPasswordHint": "修改密码使用,留空则不修改",
"admin.newPasswordHint": "6-64 位,留空则不修改",
"admin.notifyEmail": "邮件",
"admin.notifyTelegram": "Telegram 通知",
"admin.notifyTelegramPlaceholder": "Telegram Chat ID(留空则不发送)",
"admin.notifyTelegramHint": "向 @userinfobot 发送任意消息即可获得你的 Chat ID。需站方配置了 Telegram Bot。",
"admin.useCustomFg": "启用自定义前景色(否则自动匹配)",
"admin.customFgHint": "仅在启用时生效,否则颜色由系统自动生成",
"admin.avatarPlaceholder": "上传个人头像(头像比例不为1:1就扁了你哦)",
"admin.backgroundPlaceholder": "上传提问箱背景",
"admin.qrBackgroundPlaceholder": "上传二维码背景(颜色过深的背景会导致二维码无法识别)",
"admin.qrPreviewHint": "选择图片后预览将即时更新",
"admin.preview": "实时预览",
"admin.themePreset": "预设主题",
"admin.themeDefault": "默认",
"admin.themeDark": "暗夜",
"admin.themeOcean": "海洋",
"admin.themeSunset": "日落",
"admin.resetDefaults": "🔄 恢复默认值",
"admin.previewHint": "调整仅用于预览,最终效果点击「保存修改」后生效",
"admin.socialHint": "勾选后填写主页地址(无需 https://),未勾选的项将不会在前台显示。",
"admin.enableAcfun": "启用 AcFun",
"admin.enableBili": "启用 Bilibili",
"admin.enableWeibo": "启用 微博",
"admin.enableXiaohongshu": "启用 小红书",
"admin.enableBlog": "启用 个人博客",
"admin.registerOnly": "仅允许注册用户向我提问",
"admin.updateHarassment": "更新防骚扰设置",
"admin.globalBlockWords": "全局屏蔽词",
"admin.globalBlockWordsHint": "屏蔽词对提问、私聊(对方发给你的消息)与投票提交内容生效;三个设置页同步显示同一份设置,任意一处修改即时生效。",
"admin.updateBlockWords": "保存屏蔽词",
// 审核严格度:提问 / 投票 / 私聊共用一份,判定规则见 internal/security/censor/policy.go
"admin.moderationStrictness": "审核严格度",
@@ -1055,36 +1058,36 @@ var zhCNDict = map[string]string{
"stats.weekday.7": "周日",
// 箱主数据面板(/user/insights)
"insights.title": "数据面板",
"insights.heading": "你的数据面板",
"insights.lead": "这些数字只属于你自己:有多少人打开过你的提问箱、你收到了多少提问、还有多少没处理。全部是聚合结果,不含任何一位访客或提问者的身份。",
"insights.back": "返回设置",
"insights.entryTitle": "数据面板",
"insights.entryDesc": "看看有多少人来过你的提问箱、提问处理得怎么样",
"insights.summary": "一眼看懂",
"insights.noViews": "还没有访客记录。有人打开过你的提问箱之后,这里就会开始有数字。",
"insights.today": "今天",
"insights.week": "近 7 天",
"insights.month": "近 30 天",
"insights.newQuestions": "新提问",
"insights.pv": "次浏览",
"insights.uv": "位访客",
"insights.trendTitle": "近 30 天浏览趋势",
"insights.trendNote": "每根柱子是一天,浅色是浏览量,深色是当天去重后的访客数。把鼠标停在柱子上能看到当天的浏览 / 访客 / 提问。",
"insights.legendPV": "浏览量(PV)",
"insights.legendUV": "独立访客(按日去重)",
"insights.legendHover": "悬停查看当天明细",
"insights.hourTitle": "大家什么时候来",
"insights.hourNote": "近 30 天收到的提问分布在一天里的哪个时段,最亮的一根是你最忙的时段。",
"insights.funnelTitle": "提问处理情况",
"insights.funnelNote": "全部提问按「已回答 / 已读未答 / 未读未答」三类拆开。",
"insights.title": "数据面板",
"insights.heading": "你的数据面板",
"insights.lead": "这些数字只属于你自己:有多少人打开过你的提问箱、你收到了多少提问、还有多少没处理。全部是聚合结果,不含任何一位访客或提问者的身份。",
"insights.back": "返回设置",
"insights.entryTitle": "数据面板",
"insights.entryDesc": "看看有多少人来过你的提问箱、提问处理得怎么样",
"insights.summary": "一眼看懂",
"insights.noViews": "还没有访客记录。有人打开过你的提问箱之后,这里就会开始有数字。",
"insights.today": "今天",
"insights.week": "近 7 天",
"insights.month": "近 30 天",
"insights.newQuestions": "新提问",
"insights.pv": "次浏览",
"insights.uv": "位访客",
"insights.trendTitle": "近 30 天浏览趋势",
"insights.trendNote": "每根柱子是一天,浅色是浏览量,深色是当天去重后的访客数。把鼠标停在柱子上能看到当天的浏览 / 访客 / 提问。",
"insights.legendPV": "浏览量(PV)",
"insights.legendUV": "独立访客(按日去重)",
"insights.legendHover": "悬停查看当天明细",
"insights.hourTitle": "大家什么时候来",
"insights.hourNote": "近 30 天收到的提问分布在一天里的哪个时段,最亮的一根是你最忙的时段。",
"insights.funnelTitle": "提问处理情况",
"insights.funnelNote": "全部提问按「已回答 / 已读未答 / 未读未答」三类拆开。",
"insights.funnelTotal": "全部提问",
"insights.funnelAnswered": "已回答",
"insights.funnelReadOnly": "已读未答",
"insights.funnelUnread": "未读未答",
"insights.answerRate": "回答率:已回答 ÷ 全部提问",
"insights.privacyTitle": "关于这些数字",
"insights.privacyNote": "访客统计只记录「某天有人来过几次」。访客身份是每天更换盐值的 IP 哈希,不保存 IP、不保存 UA、也不保存访问了哪个页面。因此跨天的同一个人无法被关联,近 7 天 / 30 天的访客数是各天去重后相加 —— 同一个人连续几天来访会被记成几个人。箱主自己打开自己的提问箱不计入统计。",
"insights.answerRate": "回答率:已回答 ÷ 全部提问",
"insights.privacyTitle": "关于这些数字",
"insights.privacyNote": "访客统计只记录「某天有人来过几次」。访客身份是每天更换盐值的 IP 哈希,不保存 IP、不保存 UA、也不保存访问了哪个页面。因此跨天的同一个人无法被关联,近 7 天 / 30 天的访客数是各天去重后相加 —— 同一个人连续几天来访会被记成几个人。箱主自己打开自己的提问箱不计入统计。",
"insights.insight.empty": "还没有任何数据。等你收到第一条提问,或者有人打开过你的提问箱,这里就会有变化。",
"insights.insight.weekViews": "近 7 天有 %s 次浏览,%s 位访客。",
+57 -54
View File
@@ -200,34 +200,37 @@ var zhTWDict = map[string]string{
"admin.export": "匯出資料",
// 後台設定(補充)
"admin.oldPasswordHint": "修改密碼使用,留空則不修改",
"admin.newPasswordHint": "6-64 位,留空則不修改",
"admin.notifyEmail": "郵件",
"admin.useCustomFg": "啟用自訂前景色(否則自動比對)",
"admin.customFgHint": "僅在啟用時生效,否則顏色由系統自動產生",
"admin.avatarPlaceholder": "上傳個人頭像(頭像比例不為1:1就扁了你哦)",
"admin.backgroundPlaceholder": "上傳提問箱背景",
"admin.qrBackgroundPlaceholder": "上傳QR Code背景(顏色過深的背景會導致QR Code無法辨識)",
"admin.qrPreviewHint": "選擇圖片後預覽將即時更新",
"admin.preview": "即時預覽",
"admin.themePreset": "預設主題",
"admin.themeDefault": "預設",
"admin.themeDark": "暗夜",
"admin.themeOcean": "海洋",
"admin.themeSunset": "日落",
"admin.resetDefaults": "🔄 恢復預設值",
"admin.previewHint": "調整僅用於預覽,最終效果點擊「儲存修改」後生效",
"admin.socialHint": "勾選後填寫主頁地址(無需 https://),未勾選的項目將不會在前台顯示。",
"admin.enableAcfun": "啟用 AcFun",
"admin.enableBili": "啟用 Bilibili",
"admin.enableWeibo": "啟用 微博",
"admin.enableXiaohongshu": "啟用 小紅書",
"admin.enableBlog": "啟用 個人部落格",
"admin.registerOnly": "僅允許註冊使用者向我提問",
"admin.updateHarassment": "更新防騷擾設定",
"admin.globalBlockWords": "全局屏蔽詞",
"admin.globalBlockWordsHint": "屏蔽詞對提問、私聊(對方傳送給你的訊息)與投票提交內容生效;三個設定頁同步顯示同一份設定,任意一處修改即時生效。",
"admin.updateBlockWords": "儲存屏蔽詞",
"admin.oldPasswordHint": "修改密碼使用,留空則不修改",
"admin.newPasswordHint": "6-64 位,留空則不修改",
"admin.notifyEmail": "郵件",
"admin.notifyTelegram": "Telegram 通知",
"admin.notifyTelegramPlaceholder": "Telegram Chat ID(留空則不發送)",
"admin.notifyTelegramHint": "向 @userinfobot 發送任意訊息即可取得你的 Chat ID。需站方配置了 Telegram Bot。",
"admin.useCustomFg": "啟用自訂前景色(否則自動比對)",
"admin.customFgHint": "僅在啟用時生效,否則顏色由系統自動產生",
"admin.avatarPlaceholder": "上傳個人頭像(頭像比例不為1:1就扁了你哦)",
"admin.backgroundPlaceholder": "上傳提問箱背景",
"admin.qrBackgroundPlaceholder": "上傳QR Code背景(顏色過深的背景會導致QR Code無法辨識)",
"admin.qrPreviewHint": "選擇圖片後預覽將即時更新",
"admin.preview": "即時預覽",
"admin.themePreset": "預設主題",
"admin.themeDefault": "預設",
"admin.themeDark": "暗夜",
"admin.themeOcean": "海洋",
"admin.themeSunset": "日落",
"admin.resetDefaults": "🔄 恢復預設值",
"admin.previewHint": "調整僅用於預覽,最終效果點擊「儲存修改」後生效",
"admin.socialHint": "勾選後填寫主頁地址(無需 https://),未勾選的項目將不會在前台顯示。",
"admin.enableAcfun": "啟用 AcFun",
"admin.enableBili": "啟用 Bilibili",
"admin.enableWeibo": "啟用 微博",
"admin.enableXiaohongshu": "啟用 小紅書",
"admin.enableBlog": "啟用 個人部落格",
"admin.registerOnly": "僅允許註冊使用者向我提問",
"admin.updateHarassment": "更新防騷擾設定",
"admin.globalBlockWords": "全局屏蔽詞",
"admin.globalBlockWordsHint": "屏蔽詞對提問、私聊(對方傳送給你的訊息)與投票提交內容生效;三個設定頁同步顯示同一份設定,任意一處修改即時生效。",
"admin.updateBlockWords": "儲存屏蔽詞",
// 審核嚴格度:提問 / 投票 / 私聊共用一份,判定規則見 internal/security/censor/policy.go
"admin.moderationStrictness": "審核嚴格度",
@@ -1054,36 +1057,36 @@ var zhTWDict = map[string]string{
"stats.weekday.7": "週日",
// 箱主數據面板(/user/insights)
"insights.title": "數據面板",
"insights.heading": "你的數據面板",
"insights.lead": "這些數字只屬於你自己:有多少人開啟過你的提問箱、你收到了多少提問、還有多少沒處理。全部是彙總結果,不含任何一位訪客或提問者的身分。",
"insights.back": "返回設定",
"insights.entryTitle": "數據面板",
"insights.entryDesc": "看看有多少人來過你的提問箱、提問處理得怎麼樣",
"insights.summary": "一眼看懂",
"insights.noViews": "還沒有訪客紀錄。有人開啟過你的提問箱之後,這裡就會開始有數字。",
"insights.today": "今天",
"insights.week": "近 7 天",
"insights.month": "近 30 天",
"insights.newQuestions": "新提問",
"insights.pv": "次瀏覽",
"insights.uv": "位訪客",
"insights.trendTitle": "近 30 天瀏覽趨勢",
"insights.trendNote": "每根柱子是一天,淺色是瀏覽量,深色是當天去重後的訪客數。把游標停在柱子上能看到當天的瀏覽/訪客/提問。",
"insights.legendPV": "瀏覽量(PV)",
"insights.legendUV": "獨立訪客(按日去重)",
"insights.legendHover": "停留查看當天明細",
"insights.hourTitle": "大家什麼時候來",
"insights.hourNote": "近 30 天收到的提問分布在一天裡的哪個時段,最亮的一根是你最忙的時段。",
"insights.funnelTitle": "提問處理情況",
"insights.funnelNote": "全部提問按「已回答/已讀未答/未讀未答」三類拆開。",
"insights.title": "數據面板",
"insights.heading": "你的數據面板",
"insights.lead": "這些數字只屬於你自己:有多少人開啟過你的提問箱、你收到了多少提問、還有多少沒處理。全部是彙總結果,不含任何一位訪客或提問者的身分。",
"insights.back": "返回設定",
"insights.entryTitle": "數據面板",
"insights.entryDesc": "看看有多少人來過你的提問箱、提問處理得怎麼樣",
"insights.summary": "一眼看懂",
"insights.noViews": "還沒有訪客紀錄。有人開啟過你的提問箱之後,這裡就會開始有數字。",
"insights.today": "今天",
"insights.week": "近 7 天",
"insights.month": "近 30 天",
"insights.newQuestions": "新提問",
"insights.pv": "次瀏覽",
"insights.uv": "位訪客",
"insights.trendTitle": "近 30 天瀏覽趨勢",
"insights.trendNote": "每根柱子是一天,淺色是瀏覽量,深色是當天去重後的訪客數。把游標停在柱子上能看到當天的瀏覽/訪客/提問。",
"insights.legendPV": "瀏覽量(PV)",
"insights.legendUV": "獨立訪客(按日去重)",
"insights.legendHover": "停留查看當天明細",
"insights.hourTitle": "大家什麼時候來",
"insights.hourNote": "近 30 天收到的提問分布在一天裡的哪個時段,最亮的一根是你最忙的時段。",
"insights.funnelTitle": "提問處理情況",
"insights.funnelNote": "全部提問按「已回答/已讀未答/未讀未答」三類拆開。",
"insights.funnelTotal": "全部提問",
"insights.funnelAnswered": "已回答",
"insights.funnelReadOnly": "已讀未答",
"insights.funnelUnread": "未讀未答",
"insights.answerRate": "回答率:已回答 ÷ 全部提問",
"insights.privacyTitle": "關於這些數字",
"insights.privacyNote": "訪客統計只記錄「某天有人來過幾次」。訪客身分是每天更換鹽值的 IP 雜湊,不保存 IP、不保存 UA、也不保存造訪了哪個頁面。因此跨天的同一個人無法被關聯,近 7 天/30 天的訪客數是各天去重後相加 —— 同一個人連續幾天造訪會被記成幾個人。箱主自己開啟自己的提問箱不計入統計。",
"insights.answerRate": "回答率:已回答 ÷ 全部提問",
"insights.privacyTitle": "關於這些數字",
"insights.privacyNote": "訪客統計只記錄「某天有人來過幾次」。訪客身分是每天更換鹽值的 IP 雜湊,不保存 IP、不保存 UA、也不保存造訪了哪個頁面。因此跨天的同一個人無法被關聯,近 7 天/30 天的訪客數是各天去重後相加 —— 同一個人連續幾天造訪會被記成幾個人。箱主自己開啟自己的提問箱不計入統計。",
"insights.insight.empty": "還沒有任何資料。等你收到第一條提問,或有人開啟過你的提問箱,這裡就會有變化。",
"insights.insight.weekViews": "近 7 天有 %s 次瀏覽,%s 位訪客。",
+6 -6
View File
@@ -77,13 +77,13 @@ type Window struct {
// Day 趋势图里的一天。
type Day struct {
Key string // 2026-10-29
Label string // 10-29
Weekday string
PV int64
UV int64
Key string // 2026-10-29
Label string // 10-29
Weekday string
PV int64
UV int64
Questions int64
Answers int64
Answers int64
// 高度百分比(0~100),直接当 CSS 高度用。
PVHeight float64
UVHeight float64
+14 -14
View File
@@ -51,13 +51,13 @@ func TestFormatInt(t *testing.T) {
func TestAggregateWindows(t *testing.T) {
loc := time.Local
today := time.Date(2026, 10, 29, 0, 0, 0, 0, loc)
weekFrom := today.AddDate(0, 0, -(WeekDays - 1)) // 10-23
prevFrom := today.AddDate(0, 0, -(2*WeekDays - 1)) // 10-16
prevTo := today.AddDate(0, 0, -WeekDays) // 10-22
weekFrom := today.AddDate(0, 0, -(WeekDays - 1)) // 10-23
prevFrom := today.AddDate(0, 0, -(2*WeekDays - 1)) // 10-16
prevTo := today.AddDate(0, 0, -WeekDays) // 10-22
p := &Page{}
for i := 0; i < TrendDays; i++ {
d := today.AddDate(0, 0, -(TrendDays - 1) + i)
d := today.AddDate(0, 0, -(TrendDays-1)+i)
p.Days = append(p.Days, Day{Key: d.Format("2006-01-02")})
}
set := func(offsetDays int, pv, uv, q, a int64) {
@@ -72,11 +72,11 @@ func TestAggregateWindows(t *testing.T) {
t.Fatalf("日期 %s 不在骨架里", key)
}
set(0, 5, 3, 1, 0) // 今天:本周 + 今日
set(-1, 2, 2, 0, 1) // 昨天:本周
set(-6, 4, 1, 2, 0) // 10-23:本周起点
set(-7, 9, 6, 0, 0) // 10-22:上周末日
set(-13, 6, 5, 0, 0) // 10-16:上周起点
set(0, 5, 3, 1, 0) // 今天:本周 + 今日
set(-1, 2, 2, 0, 1) // 昨天:本周
set(-6, 4, 1, 2, 0) // 10-23:本周起点
set(-7, 9, 6, 0, 0) // 10-22:上周末日
set(-13, 6, 5, 0, 0) // 10-16:上周起点
p.aggregateWindows(today, weekFrom, prevFrom, prevTo)
@@ -111,7 +111,7 @@ func TestAggregateWindowsNoPrev(t *testing.T) {
p := &Page{}
for i := 0; i < TrendDays; i++ {
d := today.AddDate(0, 0, -(TrendDays - 1) + i)
d := today.AddDate(0, 0, -(TrendDays-1)+i)
p.Days = append(p.Days, Day{Key: d.Format("2006-01-02")})
}
p.Days[len(p.Days)-1].PV = 3
@@ -167,13 +167,13 @@ func TestVisitorID(t *testing.T) {
func TestIsCrawler(t *testing.T) {
cases := map[string]bool{
"": true, // 空 UA 基本是脚本
"": true, // 空 UA 基本是脚本
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36": false,
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)": true,
"curl/8.4.0": true,
"python-requests/2.31.0": true,
"curl/8.4.0": true,
"python-requests/2.31.0": true,
"Mozilla/5.0 (compatible; bingbot/2.0)": true,
"facebookexternalhit/1.1": true,
"facebookexternalhit/1.1": true,
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) AppleWebKit/605.1.15 Version/17.0 Mobile Safari/604.1": false,
}
for ua, want := range cases {
+112
View File
@@ -10,6 +10,7 @@ import (
"embed"
"fmt"
"html/template"
"path/filepath"
"strings"
"time"
@@ -247,12 +248,54 @@ func sendTemplateMail(email, title string, templateFS embed.FS, templatePath str
}
func sendMail(to, title, content string) error {
return sendMailWith(to, title, content, nil, nil)
}
// MailAttachment 邮件附件。
type MailAttachment struct {
Path string
// Name 附件显示名,留空则用文件名。
Name string
}
// MailInlineImage 正文内嵌图片。
//
// CID 是 Content-ID:正文 HTML 里写 <img src="cid:xxx">,邮件客户端就会用
// 附件里的这张图渲染。这是让「地图不依赖任何外链、不依赖 JS」的唯一可靠办法 ——
// 直接放外链图片,Gmail 默认不加载远程图片;放内联 SVG,Gmail/Outlook 会剥掉。
type MailInlineImage struct {
Path string
CID string
}
// sendMailWith 发送一封带内嵌图片与附件的 HTML 邮件。
//
// 图片与附件都从本地路径读取(调用方负责把文件写到临时目录或报告目录)。
// 任何一步失败都向上返回错误:报告这种「发了就该完整」的邮件,
// 静默丢附件比直接报错更糟。
func sendMailWith(to, title, content string, inline []MailInlineImage, atts []MailAttachment) error {
m := gomail.NewMessage()
m.SetHeader("From", fmt.Sprintf("TamaBox <%s>", conf.Mail.Account))
m.SetHeader("To", to)
m.SetHeader("Subject", title)
m.SetBody("text/html", content)
for _, img := range inline {
// 显式指定 Content-ID:gomail 的 Embed 默认会给一个随机值,
// 那样正文里就没法引用它。
m.Embed(img.Path, gomail.SetHeader(map[string][]string{
"Content-ID": {"<" + img.CID + ">"},
"Content-Disposition": {`inline; filename="` + filepath.Base(img.Path) + `"`},
}))
}
for _, a := range atts {
opts := []gomail.FileSetting{}
if a.Name != "" {
opts = append(opts, gomail.Rename(a.Name))
}
m.Attach(a.Path, opts...)
}
d := gomail.NewDialer(
conf.Mail.SMTP,
conf.Mail.Port,
@@ -265,3 +308,72 @@ func sendMail(to, title, content string) error {
}
return d.DialAndSend(m)
}
// SecurityReportMail 周期安全汇总邮件的载荷。
//
// HTML 片段用 template.HTML 传:报告正文里有 KPI 卡片和表格,
// 走 string 会被 html/template 转义成源码文本,收件人看到的是一堆尖括号。
type SecurityReportMail struct {
// To 收件人,由调用方决定(honeypot_report_email / security_alert_email)。
To string
Title string
Period string
Generated string
// MapCID 正文内嵌地图的 Content-ID;为空则不发地图(报告无数据时)。
MapCID string
// SummaryHTML KPI 卡片片段。
SummaryHTML template.HTML
// CountriesHTML 国家排行表片段。
CountriesHTML template.HTML
// Notes 口径说明。用 template.HTML 传:里面有多行 <br/> 结构。
Notes template.HTML
// Attachments 附件(报告 HTML)。
Attachments []MailAttachment
// InlineImages 内嵌图片(地图 PNG),其 CID 必须与 MapCID 一致。
InlineImages []MailInlineImage
}
// securityReportData 是模板的渲染载荷(独立成一个类型,方便单独测渲染)。
type securityReportData struct {
Title string
Period string
Generated string
MapCID string
SummaryHTML template.HTML
CountriesHTML template.HTML
Notes template.HTML
}
// SendSecurityReportMail 发送周期安全汇总邮件。
func SendSecurityReportMail(r SecurityReportMail) error {
content, err := RenderSecurityReportMail(r)
if err != nil {
return err
}
return sendMailWith(r.To, r.Title, content, r.InlineImages, r.Attachments)
}
// RenderSecurityReportMail 只渲染邮件正文,不发信。
//
// 单独拆出来是为了能在没有 SMTP 的环境里验证模板:模板里的字段名写错一个字母,
// 生产上表现为「报告发出去了但正文是空的」,而这封邮件一周才发一次,
// 等发现时已经缺了好几期。
func RenderSecurityReportMail(r SecurityReportMail) (string, error) {
t, err := template.ParseFS(templates.FS, "mail/security-report.html")
if err != nil {
return "", errors.Wrap(err, "parse template file")
}
var content bytes.Buffer
if err := t.Execute(&content, securityReportData{
Title: r.Title,
Period: r.Period,
Generated: r.Generated,
MapCID: r.MapCID,
SummaryHTML: r.SummaryHTML,
CountriesHTML: r.CountriesHTML,
Notes: r.Notes,
}); err != nil {
return "", errors.Wrap(err, "execute template")
}
return content.String(), nil
}
+52
View File
@@ -0,0 +1,52 @@
// 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 mail
import (
"html/template"
"testing"
"github.com/stretchr/testify/require"
)
// TestRenderSecurityReportMail 钉住报告邮件模板本身。
//
// 这封邮件一周才发一次,模板字段名写错的表现是「发出去了但正文空/漏块」,
// 很难在第一时间发现。这里同时验证两件容易出错的事:
// 1. HTML 片段必须原样插入(走 template.HTML),不能被转义成源码文本;
// 2. 地图走 cid: 引用,邮件客户端才会把它当内嵌图而不是外链。
func TestRenderSecurityReportMail(t *testing.T) {
out, err := RenderSecurityReportMail(SecurityReportMail{
Title: "2026 年 10 月 第 43 周安全状况周报",
Period: "2026-10-19 ~ 2026-10-25",
Generated: "2026-10-30 16:16",
MapCID: "map-43",
SummaryHTML: template.HTML(`<div class="kpi">命中 <strong>554</strong> 次</div>`),
CountriesHTML: template.HTML(`<table><tr><td>美国</td><td>160</td></tr></table>`),
Notes: template.HTML(`口径一<br/>口径二`),
})
require.NoError(t, err)
// HTML 片段原样出现
require.Contains(t, out, "<strong>554</strong>")
require.Contains(t, out, "<td>美国</td>")
require.Contains(t, out, "口径一<br/>口径二")
require.NotContains(t, out, "&lt;strong&gt;", "片段被转义了,收件人只会看到一堆尖括号")
// 地图按 Content-ID 引用
require.Contains(t, out, "cid:map-43")
// 模板里的变量都应已展开
require.NotContains(t, out, "{{")
require.Contains(t, out, "2026-10-19 ~ 2026-10-25")
}
// TestRenderSecurityReportMailNoMap 没有命中时不发地图,也就不该留一个坏图位。
func TestRenderSecurityReportMailNoMap(t *testing.T) {
out, err := RenderSecurityReportMail(SecurityReportMail{Title: "空报告"})
require.NoError(t, err)
require.NotContains(t, out, "cid:", "无数据时不该引用不存在的地图")
require.NotContains(t, out, "{{")
}
+147
View File
@@ -0,0 +1,147 @@
// 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 notify 封装站级通知通道:通用 Webhook 与 Telegram Bot。
//
// 与 internal/mail(点对点邮件)的分工:mail 负责「给某个具体邮箱发」,notify 负责
// 「广播式」通知 —— Webhook 推给一个 URL(Discord/Slack/企业微信机器人等),
// Telegram 推给某个 chat_id(配合箱主自填的 chat_id 实现点对点)。
//
// 设计约束:任一通道发送失败都只记日志、不向上抛错,也绝不影响业务主流程 ——
// 通知是「尽力而为」的旁路,一条通知发不出去不该让提问/回答写不进去。
// 通道未配置(URL/token 为空)时对应发送静默跳过。
package notify
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf"
)
const httpTimeout = 5 * time.Second
// event 一条站级通知事件的载荷。Webhook 与 Telegram 都用它(字段名通用)。
type event struct {
Event string `json:"event"` // 事件名,如 new_question / new_answer / ...
Title string `json:"title"`
Body string `json:"body"`
Link string `json:"link,omitempty"`
Time string `json:"time"`
}
// Send 推送一条站级事件到所有已配置的广播通道(Webhook + Telegram)。
// 各通道独立发送、互不影响;失败仅记日志。
func Send(ctx context.Context, eventName, title, body, link string) {
ev := event{
Event: eventName,
Title: title,
Body: body,
Link: link,
Time: time.Now().Format("2006-01-02 15:04:05"),
}
if conf.Notify.WebhookURL != "" {
sendWebhook(ctx, conf.Notify.WebhookURL, ev)
}
// Telegram 站级广播需要一个全局 chat_id;当前设计里 Telegram 是「用户级点对点」
// (见 SendToTelegram),站级不额外广播,避免重复打扰。这里留空即不推。
}
// SendToTelegram 推送一条通知到指定 chat_id(箱主自填,配合站级 bot token)。
// chatID 为空或 bot token 未配置时静默跳过。
func SendToTelegram(ctx context.Context, chatID, text string) {
if chatID == "" || conf.Notify.TelegramBotToken == "" {
return
}
if err := sendTelegram(ctx, conf.Notify.TelegramBotToken, chatID, text); err != nil {
logrus.WithContext(ctx).WithError(err).Warn("Telegram 通知发送失败")
}
}
// sendWebhook POST 一条 JSON 到通用 Webhook URL。
//
// 适配 Discord / Slack 的格式差异:这里发送的是统一的 JSON 对象。Discord 的 webhook
// 期望 {"content": "..."},Slack 期望 {"text": "..."},企业微信期望 {"msgtype":"text",
// "text":{"content":"..."}} —— 无法用一种格式同时适配所有平台。因此这里发送「通用 JSON」,
// 运营方在 webhook 前端(如自己的一层转发)做格式适配;对只接受特定格式的机器人,
// 请用能转发的中间服务(如 Discord 支持自定义 message,Slack incoming webhook 也接受 text 字段)。
//
// 实际上为最大化开箱可用,这里优先兼容 Discord 的 content 字段 + 通用 event 字段并存:
// 既保留结构化信息,又让 Discord 机器人能直接读出人话。
func sendWebhook(ctx context.Context, url string, ev event) {
payload := map[string]interface{}{
"content": fmt.Sprintf("【%s】%s\n%s", ev.Title, ev.Body, ev.Link),
"event": ev.Event,
"title": ev.Title,
"body": ev.Body,
"link": ev.Link,
"time": ev.Time,
}
data, err := json.Marshal(payload)
if err != nil {
logrus.WithContext(ctx).WithError(err).Warn("序列化 webhook 通知失败")
return
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
if err != nil {
logrus.WithContext(ctx).WithError(err).Warn("构造 webhook 请求失败")
return
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: httpTimeout}
resp, err := client.Do(req)
if err != nil {
logrus.WithContext(ctx).WithError(err).Warn("webhook 通知发送失败")
return
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
logrus.WithContext(ctx).WithField("status", resp.StatusCode).
WithField("resp", string(body)).Warn("webhook 通知返回非 2xx")
}
}
// sendTelegram 通过 Telegram Bot API 发送一条消息到指定 chat_id。
func sendTelegram(ctx context.Context, botToken, chatID, text string) error {
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
payload, err := json.Marshal(map[string]string{
"chat_id": chatID,
"text": text,
"parse_mode": "HTML",
})
if err != nil {
return errors.Wrap(err, "marshal telegram payload")
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return errors.Wrap(err, "build telegram request")
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: httpTimeout}
resp, err := client.Do(req)
if err != nil {
return errors.Wrap(err, "telegram request")
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return errors.Errorf("telegram api status %d: %s", resp.StatusCode, string(body))
}
return nil
}
+54
View File
@@ -0,0 +1,54 @@
package notify
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/conf"
)
// TestSendWebhookPostsJSON 验证 webhook 发送的是带 content/event/title 的 JSON,
// 且能正确 POST 到目标 URL。
func TestSendWebhookPostsJSON(t *testing.T) {
var got map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "application/json", r.Header.Get("Content-Type"))
require.NoError(t, json.NewDecoder(r.Body).Decode(&got))
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
sendWebhook(t.Context(), srv.URL, event{
Event: "new_question",
Title: "新的提问",
Body: "你好",
Link: "https://example.com/_/box/1",
Time: "2026-10-29 19:00:00",
})
require.Equal(t, "new_question", got["event"])
require.Equal(t, "新的提问", got["title"])
require.Equal(t, "你好", got["body"])
require.NotEmpty(t, got["content"], "Discord 兼容的 content 字段必须有")
}
// TestSendWebhookSkipsWhenUnconfigured 未配置 webhook_url 时 Send 静默跳过(不 panic)。
func TestSendWebhookSkipsWhenUnconfigured(t *testing.T) {
conf.Notify.WebhookURL = ""
defer func() { conf.Notify.WebhookURL = "" }()
// 不应 panic,也不应发起任何请求。
Send(t.Context(), "new_question", "t", "b", "l")
}
// TestSendToTelegramSkipsWhenUnconfigured chatID 或 bot token 为空时静默跳过。
func TestSendToTelegramSkipsWhenUnconfigured(t *testing.T) {
conf.Notify.TelegramBotToken = ""
defer func() { conf.Notify.TelegramBotToken = "" }()
SendToTelegram(t.Context(), "", "hello") // 空 chatID
SendToTelegram(t.Context(), "123", "hello") // 空 token
}
+55 -38
View File
@@ -235,6 +235,9 @@ func SendMessage(ctx context.Context, cache cache.Cache) {
todayCount, err := db.Users.CountTodayMessagesFromUser(ctx.Request().Context(), pageUser.ID, ctx.User.ID)
if err != nil {
logrus.WithError(err).Error("Failed to count today messages")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
if todayCount >= int64(pageUser.DailyMessageLimit) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errDailyLimit", pageUser.DailyMessageLimit))
@@ -252,7 +255,17 @@ func SendMessage(ctx context.Context, cache cache.Cache) {
// 私聊功能开关:发送前重新校验箱主私聊功能是否仍开启(创建会话时已查过,
// 但箱主可能在会话中途关闭私聊,此时应阻止继续发消息)。
chatState := db.FeatureStateOf(ctx.Request().Context(), conv.OwnerID, db.FeatureChat)
//
// 用 FeatureStateOfStrict(写路径约定):FeatureStateOf 在配置读失败时回退
// 默认值,对提问/投票是「开启」——等于一次读配置失败就绕过箱主的关闭。
// 私聊恰好因默认关闭而侥幸安全,但这里仍统一用 Strict,避免依赖那个巧合。
chatState, err := db.FeatureStateOfStrict(ctx.Request().Context(), conv.OwnerID, db.FeatureChat)
if err != nil {
// 读配置失败即拒(fail-closed):不能让一次配置抖动绕过箱主的关闭。
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errChatClosed"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
if !chatState.Open {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errChatClosed"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
@@ -267,7 +280,8 @@ func SendMessage(ctx context.Context, cache cache.Cache) {
}
// 上传图片(如有)
var imageURL string
var imageURL, imageKey string
imageAttached := false
if imageFile != nil {
uploadedURL, err := storage.UploadPictureToS3(imageFile, imageHeader)
if err != nil {
@@ -281,6 +295,31 @@ func SendMessage(ctx context.Context, cache cache.Cache) {
return
}
imageURL = uploadedURL
defer func() {
if imageAttached || imageKey != "" {
return
}
if err := db.R2DeleteTasks.EnqueueValues(stdctx.Background(), conf.Upload.ImageBucket, []string{imageURL}); err != nil {
logrus.WithError(err).Error("Failed to enqueue unresolved uploaded message image compensation")
}
}()
parsedKey, owned, err := storage.ObjectKeyFromCDNURL(imageURL, conf.Upload.ImageBucketCDNHost)
if err != nil || !owned || parsedKey == "" {
logrus.WithError(err).WithField("owned", owned).Error("Failed to resolve uploaded message image key")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
imageKey = parsedKey
// 只有消息图片元数据成功落库才标记 attached;此前任意失败出口都补偿新对象。
defer func() {
if imageAttached {
return
}
if err := db.R2DeleteTasks.Enqueue(stdctx.Background(), conf.Upload.ImageBucket, []string{imageKey}); err != nil {
logrus.WithError(err).WithField("key", imageKey).Error("Failed to enqueue uploaded message image compensation")
}
}()
}
// 内容安全检测(仅文本)
@@ -294,49 +333,27 @@ func SendMessage(ctx context.Context, cache cache.Cache) {
}
}
// 原子地递增消息计数:真正的上限检查点(先于消息落库,防止超额消息写入)。
// 并发场景(对话双方同时发送)下,仅当 message_count < max 时才 +1 成功。
ok, err := db.Conversations.IncrementMessageCountIfBelow(ctx.Request().Context(), conv.ID, conv.MaxMessages)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to increment message count")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
var image *db.CreateMessageImageOptions
if imageURL != "" {
image = &db.CreateMessageImageOptions{URL: imageURL, ObjectKey: imageKey, FileName: imageHeader.Filename, FileSize: imageHeader.Size}
}
if !ok {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errLimitReached"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 创建消息
msg, err := db.Messages.Create(ctx.Request().Context(), db.CreateMessageOptions{
ConversationID: conv.ID,
SenderID: ctx.User.ID,
Content: content,
_, err = db.Conversations.SendMessage(ctx.Request().Context(), db.SendConversationMessageOptions{
ConversationID: conv.ID, SenderID: ctx.User.ID, Content: content, Image: image,
})
if err != nil {
// 计数已递增但消息落库失败:回滚计数,避免占用配额
if rerr := db.Conversations.DecrementMessageCount(ctx.Request().Context(), conv.ID); rerr != nil {
logrus.WithContext(ctx.Request().Context()).WithError(rerr).Error("Failed to rollback message count")
switch {
case errors.Is(err, db.ErrDailyMessageLimit):
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errDailyLimit", pageUser.DailyMessageLimit))
case errors.Is(err, db.ErrConversationLimit):
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errLimitReached"))
default:
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to send message")
ctx.SetInternalErrorFlash()
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create message")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 保存图片记录(如有)
if imageURL != "" {
if _, err := db.MessageImages.Create(ctx.Request().Context(), db.CreateMessageImageOptions{
MessageID: msg.ID,
URL: imageURL,
FileName: imageHeader.Filename,
FileSize: imageHeader.Size,
}); err != nil {
logrus.WithError(err).Error("Failed to save message image")
}
}
imageAttached = true
// 达到上限时关闭对话(幂等,多实例同时执行也安全)
if err := db.Conversations.MaybeClose(ctx.Request().Context(), conv.ID, conv.MaxMessages); err != nil {
+14
View File
@@ -513,6 +513,20 @@ func (c *captureStore) PurgeLegacyCredentialColumns(context.Context) ([]string,
return nil, nil
}
// 周期报告用到的时间窗口聚合。这组用例不测它们(报告有自己的单测),
// 但接口扩展后必须补上,否则整个包编译不过。
func (c *captureStore) AggregateSources(context.Context, time.Time, time.Time) ([]*db.HoneypotSourceAgg, error) {
return nil, nil
}
func (c *captureStore) AggregatePaths(context.Context, time.Time, time.Time, int) ([]*db.HoneypotPathStat, error) {
return nil, nil
}
func (c *captureStore) TimestampsBetween(context.Context, time.Time, time.Time) ([]time.Time, error) {
return nil, nil
}
// TestHoneypotServesFamilySpecificPage 验证按路径分发到不同后台的伪装。
//
// 扫描器先按路径判断「这是什么后台」,再拿对应字段名去填表;
+30 -34
View File
@@ -48,6 +48,29 @@ import (
"github.com/tamakyi/TamaBox/templates"
)
func sessionOptions() session.Options {
options := session.Options{
Initer: session.MemoryIniter(),
Cookie: session.CookieOptions{
HTTPOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: strings.HasPrefix(conf.App.ExternalURL, "https"),
},
}
if conf.Database.DSN != "" && (conf.Database.Type == "" || conf.Database.Type == "mysql") {
options.Initer = mysql.Initer()
options.Config = mysql.Config{DSN: conf.Database.DSN, Lifetime: 7 * 24 * time.Hour}
}
if conf.Redis.Addr != "" {
options.Initer = sessionRedis.Initer()
options.Config = sessionRedis.Config{
Options: &sessionRedis.Options{Addr: conf.Redis.Addr, Password: conf.Redis.Password, DB: 1},
Lifetime: 7 * 24 * time.Hour,
}
}
return options
}
// indexNowKeyPattern 限制 IndexNow 密钥文件名的合法形态:32 位小写十六进制 + ".txt"。
// 用于防止把路由参数直接拼进 embed FS 路径时发生路径穿越(如 "../robots.txt")。
var indexNowKeyPattern = regexp.MustCompile(`^[0-9a-f]{32}\.txt$`)
@@ -227,8 +250,12 @@ func New() *flamego.Flame {
hph := newHoneypotHandler(hp, ab, db.Honeypots, credStore)
// 蜜罐告警:只有真的提交了凭据才发(纯路径探测没有取证价值),且每个 IP 只发一次。
// 收件人复用 security_alert_email,不新增配置项。
hph.sendAlert = func(a mail.HoneypotAlert) error {
return mail.SendHoneypotAlertMail(a)
// 收件人为空时把 sendAlert 置 nil —— alert() 里 `h.sendAlert == nil` 会直接短路,
// 避免「蜜罐开着但没配收件人」时每次凭据提交都白做一次 SMTP 拨号并留一堆 Warn 日志。
if strings.TrimSpace(conf.Security.SecurityAlertEmail) != "" {
hph.sendAlert = func(a mail.HoneypotAlert) error {
return mail.SendHoneypotAlertMail(a)
}
}
templateFS, err := template.EmbedFS(templates.FS, ".", []string{".html"})
@@ -239,38 +266,7 @@ func New() *flamego.Flame {
gob.Register(time.Time{})
gob.Register(context.Flash{})
gob.Register(captcha.StoredAnswer{})
var sessionStorage interface{}
initer := session.MemoryIniter()
if conf.Database.DSN != "" {
initer = mysql.Initer()
sessionStorage = mysql.Config{
DSN: conf.Database.DSN,
Lifetime: 7 * 24 * time.Hour,
}
}
if conf.Redis.Addr != "" {
initer = sessionRedis.Initer()
sessionStorage = sessionRedis.Config{
Options: &sessionRedis.Options{
Addr: conf.Redis.Addr,
Password: conf.Redis.Password,
DB: 1,
},
Lifetime: 7 * 24 * time.Hour,
}
}
sessioner := session.Sessioner(session.Options{
Initer: initer,
Config: sessionStorage,
// 显式声明 cookie 安全属性:HTTPOnly 防 XSS 窃取会话;SameSite=Lax 阻断跨站表单
// POST;Secure 仅在 https 对外服务时下发,避免 http 本地开发下 cookie 不生效
// (缺失 Secure 时,HSTS 尚未落地的首次访问可被 MITM 诱导明文请求窃取会话)。
Cookie: session.CookieOptions{
HTTPOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: strings.HasPrefix(conf.App.ExternalURL, "https"),
},
})
sessioner := session.Sessioner(sessionOptions())
// 安全响应头必须注册在静态资源之前:flamego.Static 命中后会终结请求链,
// 放在它后面等于静态资源拿不到 nosniff / HSTS。
+52
View File
@@ -0,0 +1,52 @@
package route
import (
"testing"
"github.com/flamego/session/mysql"
sessionRedis "github.com/flamego/session/redis"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/conf"
)
func TestSessionOptionsSelectStorageByDatabaseAndRedis(t *testing.T) {
oldDatabase, oldRedis, oldExternalURL := conf.Database, conf.Redis, conf.App.ExternalURL
t.Cleanup(func() {
conf.Database, conf.Redis, conf.App.ExternalURL = oldDatabase, oldRedis, oldExternalURL
})
conf.Database.DSN = "test-database-dsn"
conf.App.ExternalURL = "https://example.test"
for _, tt := range []struct {
name string
databaseType string
redisAddr string
configType interface{}
}{
{name: "postgres without redis uses memory", databaseType: "postgres"},
{name: "postgres with redis uses redis", databaseType: "postgres", redisAddr: "127.0.0.1:6379", configType: sessionRedis.Config{}},
{name: "mysql without redis uses mysql", databaseType: "mysql", configType: mysql.Config{}},
{name: "mysql with redis uses redis", databaseType: "mysql", redisAddr: "127.0.0.1:6379", configType: sessionRedis.Config{}},
{name: "legacy empty database type uses mysql", configType: mysql.Config{}},
} {
t.Run(tt.name, func(t *testing.T) {
conf.Database.Type = tt.databaseType
conf.Redis.Addr = tt.redisAddr
conf.Redis.Password = "test-only-password"
opts := sessionOptions()
if tt.configType == nil {
require.Nil(t, opts.Config)
return
}
require.IsType(t, tt.configType, opts.Config)
switch cfg := opts.Config.(type) {
case mysql.Config:
require.Equal(t, conf.Database.DSN, cfg.DSN)
case sessionRedis.Config:
require.Equal(t, tt.redisAddr, cfg.Options.Addr)
require.Equal(t, 1, cfg.Options.DB)
}
})
}
}
+30 -8
View File
@@ -22,6 +22,11 @@ const (
DefaultThresholdSensitive = 10
DefaultThreshold404 = 20
pathSampleMax = 6
// alertCooldown 同一来源的告警冷却时间:冷却期内重复命中不再发第二封。
// 与蜜罐侧(route.honeypotHandler 的 alertDedupTTL = 24h)口径一致——
// 早先是 map[string]bool「一生只发一次」,长期盯防同一来源会漏掉
// 后续攻击活动(换路径再来、隔几个月再来都收不到提醒)。
alertCooldown = 24 * time.Hour
)
// Options 自动封禁配置。
@@ -118,7 +123,7 @@ type Banner struct {
probes map[string]*probeStat
banned map[string]time.Time
alerted map[string]bool
alerted map[string]time.Time
thresholdSensitive int
threshold404 int
@@ -166,7 +171,7 @@ func New(opts Options) *Banner {
b := &Banner{
probes: make(map[string]*probeStat),
banned: make(map[string]time.Time),
alerted: make(map[string]bool),
alerted: make(map[string]time.Time),
thresholdSensitive: sensitive,
threshold404: notFound,
trackNotFound: opts.TrackNotFound,
@@ -238,9 +243,9 @@ func (b *Banner) RecordHoneypot(ip, path string) bool {
b.banned[ip] = now.Add(b.honeypotDuration)
b.notifyBan(ip, b.honeypotDuration, ReasonHoneypot)
// 告警(每 IP 仅一次)
if b.alertEmail != "" && b.sendAlert != nil && !b.alerted[ip] {
b.alerted[ip] = true
// 告警(每 IP 冷却期内仅一次)
if b.alertEmail != "" && b.sendAlert != nil && !b.alertedRecently(ip, now) {
b.alerted[ip] = now
sample := append([]string{}, st.paths...)
dur := int(b.honeypotDuration.Minutes())
go func() {
@@ -310,9 +315,9 @@ func (b *Banner) Record(ip, path string, kind int) {
b.banned[ip] = until
b.notifyBan(ip, b.duration, ReasonProbe)
// 告警(每 IP 仅一次)
if b.alertEmail != "" && b.sendAlert != nil && !b.alerted[ip] {
b.alerted[ip] = true
// 告警(每 IP 冷却期内仅一次)
if b.alertEmail != "" && b.sendAlert != nil && !b.alertedRecently(ip, now) {
b.alerted[ip] = now
sample := append([]string{}, st.paths...)
dur := int(b.duration.Minutes())
go func() {
@@ -321,6 +326,18 @@ func (b *Banner) Record(ip, path string, kind int) {
}
}
// alertedRecently 报告该 IP 是否在告警冷却期内(曾发过告警且未过期)。
//
// 调用点都在持锁区间内。冷却期用 alertCooldown(24h),与蜜罐侧口径一致:
// 告警去重是「压制同一来源的重复邮件」,不是「一生只报一次」——长期攻击
// 换路径再来、隔几天再来,都应当能再次提醒。
func (b *Banner) alertedRecently(ip string, now time.Time) bool {
if until, ok := b.alerted[ip]; ok && until.Add(alertCooldown).After(now) {
return true
}
return false
}
// notifyBan 触发 OnBan 回调。
//
// 调用点都在持锁区间内,因此这里只做「取函数 + 起 goroutine」:
@@ -434,4 +451,9 @@ func (b *Banner) cleanup() {
delete(b.banned, ip)
}
}
for ip, at := range b.alerted {
if !at.Add(alertCooldown).After(now) {
delete(b.alerted, ip)
}
}
}
+46
View File
@@ -101,6 +101,52 @@ func TestAlertOncePerIP(t *testing.T) {
}
}
// TestAlertCooldownExpires 回归「告警冷却期」而非「一生只发一次」:
// 冷却期(alertCooldown = 24h)过后,同一来源再次触发封禁应能再告警。
//
// 早先是 map[string]bool 永久去重,长期盯防同一来源会漏掉后续攻击活动
// (换路径再来、隔几天再来都收不到提醒)。这里直接回拨 alerted 的时间戳,
// 模拟「冷却期已过」,断言能再次发出告警。
func TestAlertCooldownExpires(t *testing.T) {
var calls int64
b := newTestBanner(1, 5, time.Minute, time.Minute, "[email protected]", &calls)
// 第一次封禁 + 告警
b.Record("8.8.8.8", "/admin", KindSensitive)
waitFor(&calls, 1, t)
// 冷却期内再次命中:不应重复告警(此时已在封禁中,先手动解除封禁以走到告警判定)
b.mu.Lock()
delete(b.banned, "8.8.8.8")
b.mu.Unlock()
b.Record("8.8.8.8", "/config", KindSensitive)
time.Sleep(50 * time.Millisecond)
if got := atomic.LoadInt64(&calls); got != 1 {
t.Fatalf("冷却期内不应重复告警,got %d", got)
}
// 回拨 alerted 时间戳到冷却期之前,模拟「24 小时后攻击者再来」
b.mu.Lock()
b.alerted["8.8.8.8"] = time.Now().Add(-alertCooldown - time.Minute)
delete(b.banned, "8.8.8.8")
b.mu.Unlock()
b.Record("8.8.8.8", "/.env", KindSensitive)
waitFor(&calls, 2, t)
if got := atomic.LoadInt64(&calls); got != 2 {
t.Fatalf("冷却期过后应再次告警,got %d", got)
}
}
// waitFor 轮询等待 atomic 计数器达到 want(sendAlert 是异步 goroutine)。
func waitFor(c *int64, want int64, t *testing.T) {
t.Helper()
deadline := time.Now().Add(500 * time.Millisecond)
for atomic.LoadInt64(c) < want && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
}
func TestDisabled(t *testing.T) {
b := New(Options{Enabled: false})
b.Record("3.3.3.3", "/admin", KindSensitive)
+636
View File
@@ -0,0 +1,636 @@
// 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 geoip 把 IP 解析成地理位置(国家 / 省 / 市 / 运营商 / 坐标)。
//
// 与 internal/security/ipinfo 的分工:ipinfo 用离线指纹判断「这个 IP 是不是
// 云厂商 / CDN / WAF 的出口」,对家宽、企业宽带、移动网络一律返回
// 「未识别(普通网络)」—— 它回答的是「是不是代理」,不是「在哪儿」。
// 肉鸡恰恰全是后一类地址,所以安全汇总必须另有一套真正的 IP 地理位置库。
//
// 数据源策略(多源回退):
// - ip-api.com:批量接口一次 100 个 IP,全球覆盖好,免费版无需 key。
// 用它做批量主干 —— 免费版限 45 次/分钟,但一次请求覆盖 100 个 IP,
// 实际吞吐足够,且把「N 个 IP」压成「N/100 次请求」。
// - 腾讯位置服务:一 IP 一请求,国内 IP 的省市准确度更高、坐标是 GCJ-02
// (与腾讯地图底图对齐)。只用来**精修**国内的 IP,不做批量主干 ——
// 拿它打批量会把几百个 IP 变成几百次 HTTP,既慢又容易撞配额。
//
// 隐私边界:会把攻击者 IP 发给上述第三方。这是刻意取舍(不查就画不出地图),
// 但调用方必须清楚 —— 本包只应处理攻击者地址,绝不要拿它去查真实用户 IP。
package geoip
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
// Provider 常量。
const (
// ProviderAuto 缺省:ip-api 批量打底 + 腾讯精修国内。
ProviderAuto = "auto"
// ProviderIPAPI 只用 ip-api.com。
ProviderIPAPI = "ipapi"
// ProviderTencent 只用腾讯位置服务(无 key 时自动降级为 ipapi)。
ProviderTencent = "tencent"
// ProviderNone 不查询任何外部服务。
ProviderNone = "none"
)
const (
// ipAPIBatchSize ip-api 批量接口的硬上限。
ipAPIBatchSize = 100
// ipAPIMinInterval ip-api 免费版限流 45 次/分钟。取 1.4s 间隔(≈43 次/分钟)
// 留一点余量:多个报告同时跑、或用户手动跑 CLI 时不会互相挤爆。
ipAPIMinInterval = 1400 * time.Millisecond
// defaultTencentRefineCap 腾讯精修最多处理多少个 IP。
//
// 必须有上限:周报可能有两千个不同来源 IP,其中一半是国内 —— 逐个请求
// 腾讯会跑十几分钟并吃掉当日免费配额,而报告的价值并不取决于把最后一千个
// 长尾 IP 精确到市。取命中次数最高的前 N 个,够回答「主要从哪儿来」。
defaultTencentRefineCap = 200
// defaultTimeout 单次外部请求超时。
//
// ⚠️ 不能设小。实测 ip-api 免费版的批量接口「22 个 IP」要 4–10 秒,
// 且同一批重复请求会更慢(免费额度在按 IP 做节流)。最早设 15 秒时
// 第一轮真实数据就超时了 —— 免费服务不是快服务,超时要按最坏情况给。
defaultTimeout = 45 * time.Second
// ipAPIAttempts 单个批次最多尝试几次。
// 免费接口偶发慢响应/网关抖动很常见,重试一次成本可接受(有 1.4s 节流兜底),
// 但重试多了只会放大超时。
ipAPIAttempts = 2
// ipAPIRetryDelay 重试前的等待。
ipAPIRetryDelay = 3 * time.Second
)
// Geo 一个 IP 的地理定位结果。
type Geo struct {
Country string
CountryCode string
Region string
City string
ISP string
ASN string
Lat float64
Lon float64
HasCoords bool
Source string
}
// Options 解析器配置。
type Options struct {
// Provider auto / ipapi / tencent / none,空视为 auto。
Provider string
// TencentKey 腾讯位置服务 key,留空则跳过精修。
TencentKey string
// TencentRefineCap 腾讯精修上限,<=0 用默认值。
TencentRefineCap int
// HTTPClient 可注入(测试用),nil 用带超时的默认 client。
HTTPClient *http.Client
}
// Resolver 按配置解析 IP 地理位置。
type Resolver struct {
provider string
tencent string
refineCap int
client *http.Client
// lastReq 串行化 ip-api 请求以遵守限流(多个报告并发时也不会超频)。
mu sync.Mutex
lastReq time.Time
}
// New 构造解析器。provider 值不认识时回退 auto 并告警 ——
// 静默回退会让人以为配置生效了,必须留痕。
func New(opts Options) *Resolver {
p := strings.ToLower(strings.TrimSpace(opts.Provider))
switch p {
case ProviderAuto, ProviderIPAPI, ProviderTencent, ProviderNone:
case "":
p = ProviderAuto
default:
logrus.WithField("value", opts.Provider).
Warn("ip_geo_provider 取值无法识别,已回退 auto(可选值:auto / ipapi / tencent / none)")
p = ProviderAuto
}
// 只选了 tencent 却没配 key:无法工作,降级 ipapi 而不是返回空结果。
if p == ProviderTencent && strings.TrimSpace(opts.TencentKey) == "" {
logrus.Warn("ip_geo_provider = tencent 但未配置 ip_geo_tencent_key,已降级为 ipapi")
p = ProviderIPAPI
}
cap := opts.TencentRefineCap
if cap <= 0 {
cap = defaultTencentRefineCap
}
client := opts.HTTPClient
if client == nil {
client = &http.Client{Timeout: defaultTimeout}
}
return &Resolver{
provider: p,
tencent: strings.TrimSpace(opts.TencentKey),
refineCap: cap,
client: client,
}
}
// Enabled 报告解析器是否真的会去查外部服务。
func (r *Resolver) Enabled() bool { return r != nil && r.provider != ProviderNone }
// Describe 返回人类可读的数据源描述(写进报告,让读者知道坐标有多可信)。
func (r *Resolver) Describe() string {
if r == nil {
return "未启用地理定位"
}
switch r.provider {
case ProviderNone:
return "未启用地理定位(ip_geo_provider = none)"
case ProviderIPAPI:
return "ip-api.com(批量,城市级)"
case ProviderTencent:
return "腾讯位置服务(城市级,GCJ-02)"
default:
if r.tencent != "" {
return "ip-api.com 批量 + 腾讯位置服务精修国内 IP"
}
return "ip-api.com(批量,城市级;未配置腾讯 key,国内 IP 未精修)"
}
}
// Result 一次批量定位的结果。
//
// ⚠️ 必须区分「查不到」与「没查成」:这是本包最容易埋错的地方。
// - Geo[key] 存在 = 查到了;
// - Unresolved[key] = 服务**明确回答**没有这个 IP 的数据(可以短期缓存,
// 免得每份报告都去重试一个注定查不到的地址);
// - 两者都没有 = 请求本身失败了(超时 / 限流 / 网关抖动)。
// 此时**绝不能**写缓存 —— 把网络故障记成「这个 IP 没有位置」,
// 会让接下来三天的报告都缺这一批 IP,而且没有任何线索说明为什么。
type Result struct {
Geo map[string]Geo
Unresolved map[string]bool
// Requests / Failures 本次发出的请求数与失败的请求数(日志用)。
Requests int
Failures int
}
// Lookup 批量解析。keys 是 shared.LimitKey 的产物(IPv4 精确地址 / IPv6 的
// "xxxx::/64")。
//
// 任何失败都只记日志并返回已拿到的部分结果 —— 报告不该因为外部 API 抖动而失败。
func (r *Resolver) Lookup(ctx context.Context, keys []string) Result {
res := Result{Geo: make(map[string]Geo, len(keys)), Unresolved: make(map[string]bool)}
if r == nil || r.provider == ProviderNone || len(keys) == 0 {
return res
}
// 把缓存键还原成可查询的地址:IPv6 键带 "/64" 后缀,查询时用网络地址
// (geo 库对前缀同样能回答,用基址是常见做法)。
//
// ⚠️ 必须按 keys 的原始顺序建 addrs:调用方(报告)是按命中次数从高到低
// 传进来的,腾讯精修只取前 refineCap 个。如果这里遍历 map 取地址,
// Go 的 map 迭代顺序是随机的 —— 每次运行精修到的 IP 都不一样,
// 配额会花在长尾地址上,而「主要从哪个城市来」恰恰取决于最活跃的那批。
queryable := make(map[string]string, len(keys)) // 查询地址 -> 原始键
addrs := make([]string, 0, len(keys))
for _, key := range keys {
addr, ok := queryAddr(key)
if !ok {
continue
}
if _, dup := queryable[addr]; dup {
continue
}
queryable[addr] = key
addrs = append(addrs, addr)
}
if len(addrs) == 0 {
return res
}
if r.provider == ProviderAuto || r.provider == ProviderIPAPI {
got, unresolved, reqs, fails := r.lookupIPAPI(ctx, addrs)
res.Requests += reqs
res.Failures += fails
for addr, g := range got {
if key, ok := queryable[addr]; ok {
res.Geo[key] = g
}
}
for addr := range unresolved {
if key, ok := queryable[addr]; ok {
res.Unresolved[key] = true
}
}
}
// 腾讯精修:auto 只精修国内,tencent 覆盖全部。
if r.tencent != "" && r.provider != ProviderIPAPI {
var refine []string
for _, addr := range addrs {
key := queryable[addr]
if r.provider == ProviderAuto {
g, ok := res.Geo[key]
// 只精修 ip-api 已判为中国的条目:海外 IP 腾讯给不出省市,
// 查了也是白白消耗配额。
if !ok || !isChina(g.CountryCode) {
continue
}
}
refine = append(refine, addr)
}
if len(refine) > r.refineCap {
refine = refine[:r.refineCap]
}
tgot, tunresolved, treqs, tfails := r.lookupTencent(ctx, refine)
res.Requests += treqs
res.Failures += tfails
for addr, g := range tgot {
if key, ok := queryable[addr]; ok {
// 保留 ip-api 给的国家(腾讯对海外 IP 的 nation 常常是空的),
// 用腾讯的省市与坐标覆盖。
prev := res.Geo[key]
if g.Country == "" {
g.Country, g.CountryCode = prev.Country, prev.CountryCode
}
res.Geo[key] = g
// 精修成功即视为已定位,撤掉可能存在的「查不到」标记。
delete(res.Unresolved, key)
}
}
for addr := range tunresolved {
if key, ok := queryable[addr]; ok {
if _, done := res.Geo[key]; !done {
res.Unresolved[key] = true
}
}
}
}
return res
}
// isChina 判断国家码是否为中国。
//
// 只看 CN:港澳台在多数 IP 库里是 HK/MO/TW 单独编码,它们的城市名当前
// 不会进入腾讯精修 —— 不做特判,宁可少精修也不要把地区归属搞错。
func isChina(code string) bool { return strings.EqualFold(code, "CN") }
// queryAddr 把缓存键还原为可查询的地址;不可查询(私网/回环/非法)返回 false。
func queryAddr(key string) (string, bool) {
base := key
if i := strings.IndexByte(base, '/'); i >= 0 {
base = base[:i]
}
ip := net.ParseIP(base)
if ip == nil || !isPublic(ip) {
return "", false
}
return ip.String(), true
}
// isPublic 排除私网 / 回环 / 链路本地 / 唯一本地地址。
// 这些地址查不出位置,发出去也毫无意义(还会浪费配额)。
func isPublic(ip net.IP) bool {
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() {
return false
}
// IPv4 映射到 IPv6 的形式(::ffff:1.2.3.4)统一按 v4 处理。
if v4 := ip.To4(); v4 != nil {
return !(v4[0] == 0 || v4[0] == 10 || v4[0] == 127 ||
(v4[0] == 169 && v4[1] == 254) ||
(v4[0] == 172 && v4[1] >= 16 && v4[1] <= 31) ||
(v4[0] == 192 && v4[1] == 168) ||
(v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127))
}
return true
}
// ---- ip-api.com ----
type ipAPIResult struct {
Query string `json:"query"`
Status string `json:"status"`
Message string `json:"message"`
Country string `json:"country"`
CountryCode string `json:"countryCode"`
RegionName string `json:"regionName"`
City string `json:"city"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
ISP string `json:"isp"`
AS string `json:"as"`
}
// lookupIPAPI 分批查询 ip-api.com。
//
// 返回三样东西:
// - got:查到的地址 → Geo;
// - unresolved:服务明确回答「这个地址没有数据」的地址;
// - requests / failures:发出的请求数与失败数。
//
// 失败的批次整批都不进 unresolved —— 区别于「服务说没有这个 IP 的数据」。
//
// 免费版只支持 http(https 要 pro)—— 这是该服务的既定限制,不是笔误。
// 请求体里只有攻击者 IP,不含任何站点数据。
func (r *Resolver) lookupIPAPI(ctx context.Context, addrs []string) (map[string]Geo, map[string]bool, int, int) {
got := make(map[string]Geo, len(addrs))
unresolved := make(map[string]bool)
requests, failures := 0, 0
for start := 0; start < len(addrs); start += ipAPIBatchSize {
end := start + ipAPIBatchSize
if end > len(addrs) {
end = len(addrs)
}
batch := addrs[start:end]
res, err := r.ipAPIBatchWithRetry(ctx, batch)
requests++
if err != nil {
failures++
logrus.WithError(err).WithField("count", len(batch)).
Warn("ip-api 批量查询失败:这批 IP 本次不定位,且不会写入缓存(下次运行会重试)")
continue
}
for addr, g := range res.geo {
got[addr] = g
}
for addr := range res.unresolved {
unresolved[addr] = true
}
}
return got, unresolved, requests, failures
}
// batchOutcome 一个批次的解析结果。
type batchOutcome struct {
geo map[string]Geo
unresolved map[string]bool
}
// ipAPIBatchWithRetry 带一次重试的批量请求。
//
// 为什么要重试:ip-api 免费版对同一来源有节流,偶发慢响应/网关 5xx 很常见。
// 不重试的话,一次抖动就会让整份报告缺掉最多 100 个 IP(而报告一周只出一次,
// 没有「下个小时再看」的机会)。重试一次的成本是 3 秒等待,值得。
func (r *Resolver) ipAPIBatchWithRetry(ctx context.Context, addrs []string) (*batchOutcome, error) {
var lastErr error
for attempt := 1; attempt <= ipAPIAttempts; attempt++ {
res, err := r.ipAPIBatch(ctx, addrs)
if err == nil {
return res, nil
}
lastErr = err
if attempt < ipAPIAttempts {
logrus.WithError(err).WithField("attempt", attempt).Debug("ip-api 批量查询失败,准备重试")
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(ipAPIRetryDelay):
}
}
}
return nil, lastErr
}
func (r *Resolver) ipAPIBatch(ctx context.Context, addrs []string) (*batchOutcome, error) {
// 按序返回:ip-api 的批量接口保证结果顺序与输入一致,用下标对应最稳
// (不要拿返回的 query 字段当键 —— 它对 IPv6 会做规范化,和输入不逐字相同)。
body, err := json.Marshal(addrs)
if err != nil {
return nil, errors.Wrap(err, "marshal batch")
}
if err := r.waitTurn(); err != nil {
return nil, err
}
url := "http://ip-api.com/batch?fields=status,message,query,country,countryCode,regionName,city,lat,lon,isp,as"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, errors.Wrap(err, "build request")
}
req.Header.Set("Content-Type", "application/json")
resp, err := r.client.Do(req)
if err != nil {
return nil, errors.Wrap(err, "request")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return nil, errors.Errorf("ip-api status %d: %s", resp.StatusCode, string(snippet))
}
var results []ipAPIResult
if err := json.NewDecoder(resp.Body).Decode(&results); err != nil {
return nil, errors.Wrap(err, "decode")
}
out := &batchOutcome{geo: make(map[string]Geo, len(results)), unresolved: make(map[string]bool)}
for i, item := range results {
if i >= len(addrs) {
break
}
if item.Status != "success" {
// 服务明确回答了这个地址没有数据(保留地址 / 未收录)。
// 只有这种情况才允许进 unresolved(也就是允许被缓存成「查不到」)。
out.unresolved[addrs[i]] = true
continue
}
out.geo[addrs[i]] = Geo{
Country: item.Country,
CountryCode: item.CountryCode,
Region: item.RegionName,
City: item.City,
ISP: item.ISP,
ASN: normalizeASN(item.AS),
Lat: item.Lat,
Lon: item.Lon,
// ip-api 对国家级结果也会给一个中心点坐标(精度差但可用),
// 只要不是 (0,0) 就认为有坐标。
HasCoords: item.Lat != 0 || item.Lon != 0,
Source: ProviderIPAPI,
}
}
return out, nil
}
// waitTurn 串行地保证两次 ip-api 请求间隔不小于 ipAPIMinInterval。
func (r *Resolver) waitTurn() error {
r.mu.Lock()
defer r.mu.Unlock()
if !r.lastReq.IsZero() {
if wait := ipAPIMinInterval - time.Since(r.lastReq); wait > 0 {
time.Sleep(wait)
}
}
r.lastReq = time.Now()
return nil
}
// normalizeASN 把 ip-api 的 "AS15169 Google LLC" 拆成 "AS15169"。
func normalizeASN(as string) string {
as = strings.TrimSpace(as)
if as == "" {
return ""
}
if i := strings.IndexByte(as, ' '); i > 0 {
return as[:i]
}
return as
}
// ---- 腾讯位置服务 ----
type tencentResult struct {
Status int `json:"status"`
Message string `json:"message"`
Result struct {
IP string `json:"ip"`
Location struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
} `json:"location"`
AdInfo struct {
Nation string `json:"nation"`
Province string `json:"province"`
City string `json:"city"`
District string `json:"district"`
} `json:"ad_info"`
} `json:"result"`
}
// tencentOutcome 单个地址的查询结果分类。
type tencentOutcome int
const (
// tencentFound 查到并给出了行政区划。
tencentFound tencentOutcome = iota
// tencentNoData 服务明确回答没有这个地址的数据(status != 0)。
tencentNoData
// tencentFailed 网络 / 解析失败 —— 与「没有数据」不是一回事,不能缓存。
tencentFailed
)
// lookupTencent 逐个查询腾讯 IP 定位(并发度 2,别把免费配额打爆)。
func (r *Resolver) lookupTencent(ctx context.Context, addrs []string) (map[string]Geo, map[string]bool, int, int) {
got := make(map[string]Geo, len(addrs))
unresolved := make(map[string]bool)
if len(addrs) == 0 || r.tencent == "" {
return got, unresolved, 0, 0
}
const workers = 2
var (
mu sync.Mutex
wg sync.WaitGroup
sem = make(chan struct{}, workers)
failures int
)
for _, addr := range addrs {
wg.Add(1)
go func(addr string) {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return
}
g, oc := r.tencentOne(ctx, addr)
mu.Lock()
defer mu.Unlock()
switch oc {
case tencentFound:
got[addr] = g
case tencentNoData:
unresolved[addr] = true
default:
failures++
}
}(addr)
}
wg.Wait()
return got, unresolved, len(addrs), failures
}
func (r *Resolver) tencentOne(ctx context.Context, addr string) (Geo, tencentOutcome) {
url := fmt.Sprintf("https://apis.map.qq.com/ws/location/v1/ip?ip=%s&key=%s", addr, r.tencent)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return Geo{}, tencentFailed
}
resp, err := r.client.Do(req)
if err != nil {
return Geo{}, tencentFailed
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Geo{}, tencentFailed
}
var tr tencentResult
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
return Geo{}, tencentFailed
}
// status != 0 表示查询失败(配额用尽、key 无效、IP 无数据等)。
// 只记 Debug:几千个 IP 逐个刷警告会把日志淹掉。
if tr.Status != 0 {
logrus.WithFields(logrus.Fields{"status": tr.Status, "message": tr.Message}).
Debug("腾讯 IP 定位返回非 0 状态")
// 配额用尽 / key 无效属于「配置或额度问题」,不是「这个 IP 没数据」。
// 把它们混为一谈会把整批 IP 缓存成「查不到」,此后三天都不再精修。
return Geo{}, tencentFailed
}
ad := tr.Result.AdInfo
city := ad.City
if city == "" {
city = ad.District
}
if ad.Province == "" && city == "" {
// 状态成功但没有任何行政区划信息:这才是「这个地址没数据」。
return Geo{}, tencentNoData
}
return Geo{
Country: ad.Nation,
CountryCode: tencentCountryCode(ad.Nation),
Region: ad.Province,
City: city,
Lat: tr.Result.Location.Lat,
Lon: tr.Result.Location.Lng,
HasCoords: tr.Result.Location.Lat != 0 || tr.Result.Location.Lng != 0,
Source: ProviderTencent,
}, tencentFound
}
// tencentCountryCode 从腾讯返回的 nation 推国家码。
//
// 不能无脑写 "CN":provider = tencent 时(不先过 ip-api)所有地址都会走这条路,
// 海外 IP 的 nation 会是「美国」之类,硬编码 CN 会把它们统计成中国来源 ——
// 而「攻击来自哪些国家」正是这份报告的核心结论之一。
//
// 只认「中国」这个精确值:港澳台在腾讯的 nation 里是「中国香港」等,
// 与 ip-api 的 HK/MO/TW 编码对不上,留空交给上层按国家名兜底更安全。
func tencentCountryCode(nation string) string {
if strings.TrimSpace(nation) == "中国" {
return "CN"
}
return ""
}
+500
View File
@@ -0,0 +1,500 @@
// 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 geoip
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// rewriteTransport 把真实域名的请求改投到本地测试服务器。
//
// 用改投而不是给生产代码加一个 baseURL 字段:生产代码里不该为了测试多出
// 「可以被指向别处」的开关,那类开关一旦被配置错就是把攻击者 IP 发到未知主机。
type rewriteTransport struct {
target *url.URL
}
func (t *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
next := req.Clone(req.Context())
next.URL.Scheme = t.target.Scheme
next.URL.Host = t.target.Host
return http.DefaultTransport.RoundTrip(next)
}
// newTestClient 返回一个把所有出站请求改投到 srv 的 client。
func newTestClient(t *testing.T, srv *httptest.Server) *http.Client {
t.Helper()
u, err := url.Parse(srv.URL)
require.NoError(t, err)
return &http.Client{Transport: &rewriteTransport{target: u}}
}
// ipAPIServer 起一个假的 ip-api 批量接口。
//
// respond 收到本批请求的地址列表,返回对应的 ipAPIResult 切片;
// status 非 0 时直接返回该 HTTP 状态码(用于模拟网关故障)。
func ipAPIServer(t *testing.T, respond func(addrs []string) ([]ipAPIResult, int)) (*httptest.Server, *int32) {
t.Helper()
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.URL.Path != "/batch" {
http.NotFound(w, req)
return
}
if req.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
atomic.AddInt32(&calls, 1)
var addrs []string
if err := json.NewDecoder(req.Body).Decode(&addrs); err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
results, code := respond(addrs)
if code != 0 {
http.Error(w, "upstream failure", code)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(results)
}))
t.Cleanup(srv.Close)
return srv, &calls
}
// okIP 构造一条成功的 ip-api 结果。
func okIP(addr, country, code, city string, lat, lon float64, isp, as string) ipAPIResult {
return ipAPIResult{
Query: addr, Status: "success", Country: country, CountryCode: code,
City: city, Lat: lat, Lon: lon, ISP: isp, AS: as,
}
}
// TestLookupSuccess 三态里的「查到了」与「服务明确说没有」。
func TestLookupSuccess(t *testing.T) {
srv, calls := ipAPIServer(t, func(addrs []string) ([]ipAPIResult, int) {
return []ipAPIResult{
okIP(addrs[0], "德国", "DE", "Berlin", 52.52, 13.4, "Hetzner", "AS24940 Hetzner Online GmbH"),
{Query: addrs[1], Status: "fail", Message: "reserved range"},
}, 0
})
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), []string{"5.9.1.1", "203.0.113.7"})
require.Equal(t, int32(1), atomic.LoadInt32(calls))
require.Equal(t, 1, res.Requests)
require.Equal(t, 0, res.Failures)
g, ok := res.Geo["5.9.1.1"]
require.True(t, ok)
assert.Equal(t, "德国", g.Country)
assert.Equal(t, "DE", g.CountryCode)
assert.Equal(t, "Berlin", g.City)
assert.Equal(t, "AS24940", g.ASN, "AS 字段必须截成纯 ASN,否则国家统计里全是长串")
assert.True(t, g.HasCoords)
assert.Equal(t, ProviderIPAPI, g.Source)
// status != success 才是「服务明确说没有」,才可以被缓存成查不到
assert.True(t, res.Unresolved["203.0.113.7"])
assert.NotContains(t, res.Geo, "203.0.113.7")
}
// TestLookupNetworkFailure 三态里的「没查成」:绝不能进 Unresolved。
//
// 这条断言是本包最重要的回归保护:一旦失败也进 Unresolved,上层就会把它
// 写进缓存(3 天),此后每份报告的定位覆盖率都会莫名其妙变成 0。
func TestLookupNetworkFailure(t *testing.T) {
srv, calls := ipAPIServer(t, func([]string) ([]ipAPIResult, int) {
return nil, http.StatusBadGateway
})
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), []string{"8.8.4.4", "1.0.0.1"})
assert.Equal(t, int32(ipAPIAttempts), atomic.LoadInt32(calls), "必须重试,但只重试一次")
assert.Equal(t, 1, res.Requests)
assert.Equal(t, 1, res.Failures)
assert.Empty(t, res.Geo)
assert.Empty(t, res.Unresolved, "网络失败不能记成「这个 IP 没有位置」")
}
// TestLookupRetrySucceeds 第一次抖动、第二次正常:不该丢数据。
func TestLookupRetrySucceeds(t *testing.T) {
var attempt int32
srv, _ := ipAPIServer(t, func(addrs []string) ([]ipAPIResult, int) {
if atomic.AddInt32(&attempt, 1) == 1 {
return nil, http.StatusGatewayTimeout
}
return []ipAPIResult{okIP(addrs[0], "日本", "JP", "Tokyo", 35.68, 139.69, "", "")}, 0
})
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), []string{"133.242.0.1"})
require.Equal(t, 0, res.Failures)
assert.Equal(t, "日本", res.Geo["133.242.0.1"].Country)
}
// TestLookupBatches 超过 100 个地址必须分批,而不是一次塞进去被拒。
func TestLookupBatches(t *testing.T) {
srv, calls := ipAPIServer(t, func(addrs []string) ([]ipAPIResult, int) {
assert.LessOrEqual(t, len(addrs), ipAPIBatchSize, "单批不能超过 ip-api 的硬上限")
out := make([]ipAPIResult, 0, len(addrs))
for _, a := range addrs {
out = append(out, okIP(a, "美国", "US", "Ashburn", 39.0, -77.5, "", ""))
}
return out, 0
})
addrs := make([]string, 0, 150)
for i := 0; i < 150; i++ {
addrs = append(addrs, fmt.Sprintf("198.51.%d.%d", i/256, i%256))
}
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), addrs)
assert.Equal(t, int32(2), atomic.LoadInt32(calls))
assert.Equal(t, 2, res.Requests)
assert.Len(t, res.Geo, 150)
}
// TestLookupSkipsPrivate 私网 / 回环 / CGNAT 一律不外发:既不泄露内网结构,
// 也省下配额(这类地址查了也是空的)。
func TestLookupSkipsPrivate(t *testing.T) {
srv, calls := ipAPIServer(t, func([]string) ([]ipAPIResult, int) {
return nil, http.StatusInternalServerError
})
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), []string{
"10.0.0.1", "192.168.1.1", "172.16.0.9", "127.0.0.1",
"100.64.3.4", "169.254.1.1", "::1", "fe80::1", "fd00::1",
})
assert.Equal(t, int32(0), atomic.LoadInt32(calls), "不可查询的地址不该产生任何请求")
assert.Equal(t, 0, res.Requests)
assert.Empty(t, res.Geo)
assert.Empty(t, res.Unresolved)
}
// TestLookupNone provider = none:一个字节都不外发。
func TestLookupNone(t *testing.T) {
srv, calls := ipAPIServer(t, func([]string) ([]ipAPIResult, int) {
return nil, http.StatusInternalServerError
})
r := New(Options{Provider: ProviderNone, HTTPClient: newTestClient(t, srv)})
assert.False(t, r.Enabled())
res := r.Lookup(context.Background(), []string{"1.2.3.4"})
assert.Equal(t, int32(0), atomic.LoadInt32(calls))
assert.Empty(t, res.Geo)
}
// TestLookupEmptyKeys 空输入不该发请求。
func TestLookupEmptyKeys(t *testing.T) {
srv, calls := ipAPIServer(t, func([]string) ([]ipAPIResult, int) {
return nil, http.StatusInternalServerError
})
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
assert.Empty(t, r.Lookup(context.Background(), nil).Geo)
assert.Equal(t, int32(0), atomic.LoadInt32(calls))
}
// TestLookupIPv6Prefix IPv6 的 "/64" 缓存键要还原成基址去查。
func TestLookupIPv6Prefix(t *testing.T) {
srv, _ := ipAPIServer(t, func(addrs []string) ([]ipAPIResult, int) {
require.Len(t, addrs, 1)
assert.Equal(t, "2001:4860:4860::", addrs[0], "带 /64 后缀的键必须剥掉后缀再查")
return []ipAPIResult{okIP(addrs[0], "美国", "US", "Mountain View", 37.4, -122.0, "Google", "AS15169 Google LLC")}, 0
})
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), []string{"2001:4860:4860::/64"})
g, ok := res.Geo["2001:4860:4860::/64"]
require.True(t, ok, "结果键必须仍是原来的缓存键,不能变成查询用的地址")
assert.Equal(t, "US", g.CountryCode)
assert.Equal(t, "AS15169", g.ASN)
}
// tencentServer 起一个假的腾讯 IP 定位接口。
func tencentServer(t *testing.T, respond func(ip string) (tencentResult, int)) (*httptest.Server, *int32) {
t.Helper()
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.URL.Path != "/ws/location/v1/ip" {
http.NotFound(w, req)
return
}
atomic.AddInt32(&calls, 1)
tr, code := respond(req.URL.Query().Get("ip"))
if code != 0 {
http.Error(w, "upstream failure", code)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(tr)
}))
t.Cleanup(srv.Close)
return srv, &calls
}
// combinedServer 同时提供两条通道的测试服务器(auto 模式要两条都通)。
func combinedServer(t *testing.T, ipapi func([]string) ([]ipAPIResult, int), tencent func(ip string) (tencentResult, int)) (*httptest.Server, *int32, *int32) {
t.Helper()
var ipCalls, txCalls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch req.URL.Path {
case "/batch":
atomic.AddInt32(&ipCalls, 1)
var addrs []string
if err := json.NewDecoder(req.Body).Decode(&addrs); err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
results, code := ipapi(addrs)
if code != 0 {
http.Error(w, "upstream failure", code)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(results)
case "/ws/location/v1/ip":
atomic.AddInt32(&txCalls, 1)
tr, code := tencent(req.URL.Query().Get("ip"))
if code != 0 {
http.Error(w, "upstream failure", code)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(tr)
default:
http.NotFound(w, req)
}
}))
t.Cleanup(srv.Close)
return srv, &ipCalls, &txCalls
}
func tencentOK(nation, province, city string, lat, lon float64) tencentResult {
var tr tencentResult
tr.Status = 0
tr.Result.Location.Lat, tr.Result.Location.Lng = lat, lon
tr.Result.AdInfo.Nation = nation
tr.Result.AdInfo.Province = province
tr.Result.AdInfo.City = city
return tr
}
// TestLookupAutoOnlyRefinesChina auto 模式只对国内 IP 调腾讯:
// 海外 IP 腾讯给不出省市,查了也是白花配额。
func TestLookupAutoOnlyRefinesChina(t *testing.T) {
srv, _, txCalls := combinedServer(t,
func(addrs []string) ([]ipAPIResult, int) {
return []ipAPIResult{
okIP(addrs[0], "中国", "CN", "", 0, 0, "", ""),
okIP(addrs[1], "美国", "US", "Ashburn", 39.0, -77.5, "", ""),
}, 0
},
func(ip string) (tencentResult, int) {
return tencentOK("中国", "北京市", "北京市", 39.9087, 116.3975), 0
})
r := New(Options{Provider: ProviderAuto, TencentKey: "k", HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), []string{"114.114.114.114", "45.32.0.1"})
assert.Equal(t, int32(1), atomic.LoadInt32(txCalls), "只该精修 1 个国内 IP")
cn := res.Geo["114.114.114.114"]
assert.Equal(t, "北京市", cn.Region)
assert.Equal(t, "北京市", cn.City)
assert.Equal(t, "CN", cn.CountryCode)
assert.InDelta(t, 39.9087, cn.Lat, 1e-9)
assert.Equal(t, ProviderTencent, cn.Source)
us := res.Geo["45.32.0.1"]
assert.Equal(t, "US", us.CountryCode)
assert.Equal(t, "Ashburn", us.City, "海外 IP 应保留 ip-api 的结果")
assert.Equal(t, ProviderIPAPI, us.Source)
}
// TestLookupTencentRefineCap 精修数量必须有上限,否则周报会跑十几分钟。
//
// 顺带守住一个容易踩的坑:精修必须按调用方给的顺序取前 N 个。报告是按命中
// 次数从高到低传进来的,若这里按 map 遍历顺序取,每次运行精修到的 IP 都不一样,
// 配额会花在长尾地址上,「主要从哪个城市来」这个问题就答不准了。
func TestLookupTencentRefineCap(t *testing.T) {
var mu sync.Mutex
var refined []string
srv, _, _ := combinedServer(t,
func(addrs []string) ([]ipAPIResult, int) {
out := make([]ipAPIResult, 0, len(addrs))
for _, a := range addrs {
out = append(out, okIP(a, "中国", "CN", "", 0, 0, "", ""))
}
return out, 0
},
func(ip string) (tencentResult, int) {
mu.Lock()
refined = append(refined, ip)
mu.Unlock()
return tencentOK("中国", "广东省", "深圳市", 22.5, 114.0), 0
})
addrs := []string{"1.1.1.1", "2.2.2.2", "3.3.3.3", "4.4.4.4", "5.5.5.5"}
r := New(Options{
Provider: ProviderAuto, TencentKey: "k", TencentRefineCap: 2,
HTTPClient: newTestClient(t, srv),
})
res := r.Lookup(context.Background(), addrs)
require.Len(t, refined, 2)
assert.ElementsMatch(t, []string{"1.1.1.1", "2.2.2.2"}, refined,
"必须精修前 2 个(最活跃的),而不是随机的 2 个")
// 精修到的用腾讯结果
assert.Equal(t, ProviderTencent, res.Geo["1.1.1.1"].Source)
// 没精修到的仍保留 ip-api 的国家
assert.Equal(t, "CN", res.Geo["5.5.5.5"].CountryCode)
assert.Equal(t, ProviderIPAPI, res.Geo["5.5.5.5"].Source)
}
// TestLookupTencentFailureNotCached 腾讯配额用尽 / key 无效属于「没查成」,
// 不能把整批 IP 记成「查不到」。ip-api 已经给出的结果也不能被抹掉。
func TestLookupTencentFailureNotCached(t *testing.T) {
srv, _, _ := combinedServer(t,
func(addrs []string) ([]ipAPIResult, int) {
return []ipAPIResult{okIP(addrs[0], "中国", "CN", "", 0, 0, "", "")}, 0
},
func(string) (tencentResult, int) {
var tr tencentResult
tr.Status = 311
tr.Message = "key格式错误"
return tr, 0
})
r := New(Options{Provider: ProviderAuto, TencentKey: "bad", HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), []string{"114.114.114.114"})
assert.Equal(t, 1, res.Failures)
assert.Empty(t, res.Unresolved, "配额/key 问题不是「这个 IP 没数据」")
assert.Equal(t, "CN", res.Geo["114.114.114.114"].CountryCode, "ip-api 的结果不能被腾讯的失败抹掉")
}
// TestLookupTencentNoData 状态成功但没有任何行政区划 = 真的没数据。
// 但若 ip-api 已经定位到了,也不能反过来把这条记成「查不到」。
func TestLookupTencentNoData(t *testing.T) {
srv, _, _ := combinedServer(t,
func(addrs []string) ([]ipAPIResult, int) {
return []ipAPIResult{okIP(addrs[0], "中国", "CN", "", 0, 0, "", "")}, 0
},
func(string) (tencentResult, int) { return tencentOK("中国", "", "", 0, 0), 0 })
r := New(Options{Provider: ProviderAuto, TencentKey: "k", HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), []string{"114.114.114.114"})
assert.Equal(t, 0, res.Failures)
assert.NotContains(t, res.Unresolved, "114.114.114.114", "已定位的 IP 不该被降级成查不到")
assert.Equal(t, "CN", res.Geo["114.114.114.114"].CountryCode)
}
// TestTencentOnly 只用腾讯(provider = tencent):不经 ip-api,全部走腾讯。
func TestTencentOnly(t *testing.T) {
srv, _, txCalls := combinedServer(t,
func([]string) ([]ipAPIResult, int) {
t.Error("provider = tencent 时不该请求 ip-api")
return nil, 0
},
func(string) (tencentResult, int) {
return tencentOK("中国", "浙江省", "杭州市", 30.27, 120.15), 0
})
r := New(Options{Provider: ProviderTencent, TencentKey: "k", HTTPClient: newTestClient(t, srv)})
res := r.Lookup(context.Background(), []string{"115.236.1.1"})
assert.Equal(t, int32(1), atomic.LoadInt32(txCalls))
assert.Equal(t, "杭州市", res.Geo["115.236.1.1"].City)
assert.Equal(t, "CN", res.Geo["115.236.1.1"].CountryCode)
}
// TestNewFallbacks 配置错误不能表现为「静默不工作」。
func TestNewFallbacks(t *testing.T) {
// 只选 tencent 却没 key → 降级 ipapi
r := New(Options{Provider: ProviderTencent})
assert.Equal(t, ProviderIPAPI, r.provider)
assert.True(t, r.Enabled())
// 空值 → auto
assert.Equal(t, ProviderAuto, New(Options{}).provider)
// 不认识的值 → auto
assert.Equal(t, ProviderAuto, New(Options{Provider: "maxmind"}).provider)
// none 不工作
assert.False(t, New(Options{Provider: ProviderNone}).Enabled())
// refineCap 缺省
assert.Equal(t, defaultTencentRefineCap, New(Options{}).refineCap)
}
// TestDescribe 描述会写进报告,读者靠它判断坐标可信度。
func TestDescribe(t *testing.T) {
assert.Contains(t, New(Options{Provider: ProviderAuto, TencentKey: "k"}).Describe(), "腾讯")
assert.Contains(t, New(Options{Provider: ProviderAuto}).Describe(), "未配置腾讯 key")
assert.Contains(t, New(Options{Provider: ProviderIPAPI}).Describe(), "ip-api")
assert.Contains(t, New(Options{Provider: ProviderNone}).Describe(), "未启用")
}
// TestQueryAddr 缓存键还原。
func TestQueryAddr(t *testing.T) {
cases := []struct {
key string
want string
ok bool
}{
{"8.8.8.8", "8.8.8.8", true},
{"2001:4860:4860::/64", "2001:4860:4860::", true},
{"2001:4860:4860::8888", "2001:4860:4860::8888", true},
{"10.1.2.3", "", false},
{"::ffff:8.8.8.8", "8.8.8.8", true},
{"not-an-ip", "", false},
{"", "", false},
}
for _, c := range cases {
got, ok := queryAddr(c.key)
assert.Equal(t, c.ok, ok, "key = %q", c.key)
assert.Equal(t, c.want, got, "key = %q", c.key)
}
}
// TestNormalizeASN ASN 只留编号。
func TestNormalizeASN(t *testing.T) {
assert.Equal(t, "AS15169", normalizeASN("AS15169 Google LLC"))
assert.Equal(t, "AS4134", normalizeASN(" AS4134 "))
assert.Equal(t, "AS4134", normalizeASN("AS4134"))
assert.Equal(t, "", normalizeASN(""))
}
// TestTencentCountryCode 不能把海外 IP 算成中国来源。
func TestTencentCountryCode(t *testing.T) {
assert.Equal(t, "CN", tencentCountryCode("中国"))
assert.Equal(t, "", tencentCountryCode("中国香港"))
assert.Equal(t, "", tencentCountryCode("美国"))
assert.Equal(t, "", tencentCountryCode(""))
}
+349
View File
@@ -0,0 +1,349 @@
// 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 report
import (
"net"
"sort"
"strings"
"time"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/security/geoip"
"github.com/tamakyi/TamaBox/internal/shared"
)
// 未知标签。用「未知」而不是空串:报告里空单元格会被读成「没有」,
// 而这里的真实含义是「查不到」,两件事不能混。
const (
unknownCountry = "未知"
unknownCode = "--"
)
// SourceRow 一行来源明细。
type SourceRow struct {
IP string
Hits int64
Banned int64
Creds int64
Country string
CountryCode string
Region string
City string
ISP string
ASN string
Provider string // cdncheck 标签(云厂商 / CDN / WAF / 普通网络)
Lat float64
Lon float64
HasCoords bool
GeoSource string
}
// CountryRow 国家级聚合。
type CountryRow struct {
Code string
Name string
Hits int64
Sources int
// Share 命中占比(0-1)。
Share float64
}
// CityRow 城市级聚合(带坐标,供地图使用)。
type CityRow struct {
Country string
CountryCode string
Region string
City string
Hits int64
Sources int
Lat float64
Lon float64
}
// PathRow 被探测路径聚合。
type PathRow struct {
Path string
Hits int64
}
// DayRow 每日命中数。
type DayRow struct {
Day time.Time
Hits int64
}
// Summary 一份完整的汇总。
type Summary struct {
Period Period
GeneratedAt time.Time
// 总量指标
TotalHits int64
PrevHits int64
UniqueSources int
UniqueCountry int
BannedHits int64
CredHits int64
// UnlocatedHits 是「查不到地理位置」的命中数。必须显式报出来 ——
// 一个覆盖率 60% 的地图看起来与 100% 的一模一样,读图的人会误判。
UnlocatedHits int64
// CloudSources 命中来自云厂商/CDN/WAF 的独立来源数(cdncheck 判定)。
// 与家宽肉鸡区分开:前者封 IP 基本没用(换一台机器就行),
// 后者封了会打在真实用户身上(同一个出口可能共享给整栋楼)。
CloudSources int
Countries []CountryRow
Cities []CityRow
Sources []SourceRow
Paths []PathRow
Days []DayRow
MaxDay int64
ResolverDesc string
// Empty 表示窗口内一次命中都没有 —— 报告仍然要发(「这周没人打」是个结论,
// 不是失败),但排版会换成一句明确的结论而不是一堆空表格。
Empty bool
}
// Input Build 的输入。
type Input struct {
Period Period
Sources []*db.HoneypotSourceAgg
Paths []*db.HoneypotPathStat
Timestamps []time.Time
Geo map[string]geoip.Geo // 键为 shared.LimitKey 的产物
PrevHits int64
ResolverDesc string
Now time.Time
}
// maxSourceRows 报告里最多列多少条来源明细。
//
// 不是技术限制而是阅读限制:一份列了两千行的报告没人会看完,
// 而尾部每行 1 次命中的长尾对判断趋势没有价值。
const maxSourceRows = 200
// Build 把原始聚合结果整理成可渲染的 Summary。
func Build(in Input) *Summary {
now := in.Now
if now.IsZero() {
now = time.Now()
}
sum := &Summary{
Period: in.Period,
GeneratedAt: now,
PrevHits: in.PrevHits,
ResolverDesc: in.ResolverDesc,
}
// ---- 1. 按 LimitKey 合并来源 ----
// 同一台机器在 /64 内轮换 IPv6 地址是很常见的规避手法,按精确地址统计
// 会得到几十条各 1 次的记录,看不出「其实是一个人」。
type merged struct {
row SourceRow
maxHits int64
}
byKey := make(map[string]*merged, len(in.Sources))
for _, s := range in.Sources {
key := shared.LimitKey(s.IP)
m, ok := byKey[key]
if !ok {
byKey[key] = &merged{
row: SourceRow{
IP: s.IP,
Hits: s.Hits,
Banned: s.Banned,
Creds: s.Creds,
Provider: s.Provider,
},
maxHits: s.Hits,
}
continue
}
m.row.Hits += s.Hits
m.row.Banned += s.Banned
m.row.Creds += s.Creds
if m.row.Provider == "" {
m.row.Provider = s.Provider
}
// 代表地址取命中最多的那个精确 IP:报告里展示的应该是最活跃的那一台。
if s.Hits > m.maxHits {
m.maxHits = s.Hits
m.row.IP = s.IP
}
}
// ---- 2. 挂上地理信息 ----
sources := make([]SourceRow, 0, len(byKey))
for key, m := range byKey {
row := m.row
if g, ok := in.Geo[key]; ok {
row.Country = g.Country
row.CountryCode = strings.ToUpper(g.CountryCode)
row.Region = g.Region
row.City = g.City
row.ISP = g.ISP
row.ASN = g.ASN
row.Lat, row.Lon = g.Lat, g.Lon
row.HasCoords = g.HasCoords
row.GeoSource = g.Source
}
if row.Country == "" {
row.Country = unknownCountry
}
if row.CountryCode == "" {
row.CountryCode = unknownCode
}
// 展示名在聚合层统一转换,下游(HTML / 邮件 / 地图提示)就都一致了。
// 港澳台在这里变成「中国香港 / 中国澳门 / 中国台湾」(理由见 countryName)。
row.Country = countryName(row.CountryCode, row.Country)
if !row.HasCoords {
sum.UnlocatedHits += row.Hits
}
if isCloudProvider(row.Provider) {
sum.CloudSources++
}
sum.TotalHits += row.Hits
sum.BannedHits += row.Banned
sum.CredHits += row.Creds
sources = append(sources, row)
}
sum.UniqueSources = len(sources)
sum.Empty = sum.TotalHits == 0
sort.Slice(sources, func(i, j int) bool {
if sources[i].Hits != sources[j].Hits {
return sources[i].Hits > sources[j].Hits
}
return sources[i].IP < sources[j].IP // 稳定排序,避免每次生成顺序抖动
})
if len(sources) > maxSourceRows {
sum.Sources = sources[:maxSourceRows]
} else {
sum.Sources = sources
}
// ---- 3. 国家聚合 ----
type cAgg struct {
name string
hits int64
sources int
}
countries := make(map[string]*cAgg)
for _, s := range sources {
c, ok := countries[s.CountryCode]
if !ok {
c = &cAgg{name: s.Country}
countries[s.CountryCode] = c
}
c.hits += s.Hits
c.sources++
}
for code, c := range countries {
share := 0.0
if sum.TotalHits > 0 {
share = float64(c.hits) / float64(sum.TotalHits)
}
sum.Countries = append(sum.Countries, CountryRow{
Code: code, Name: countryName(code, c.name), Hits: c.hits, Sources: c.sources, Share: share,
})
}
sum.UniqueCountry = len(sum.Countries)
sort.Slice(sum.Countries, func(i, j int) bool {
if sum.Countries[i].Hits != sum.Countries[j].Hits {
return sum.Countries[i].Hits > sum.Countries[j].Hits
}
return sum.Countries[i].Code < sum.Countries[j].Code
})
// ---- 4. 城市聚合(地图用,必须有坐标) ----
type cityAgg struct {
row CityRow
sources int
}
cities := make(map[string]*cityAgg)
for _, s := range sources {
if !s.HasCoords {
continue
}
// 同城不同区合并:地图上同一坐标画十几个重叠气泡没有意义。
key := s.CountryCode + "|" + s.Region + "|" + s.City
c, ok := cities[key]
if !ok {
cities[key] = &cityAgg{row: CityRow{
Country: s.Country, CountryCode: s.CountryCode,
Region: s.Region, City: s.City,
Hits: s.Hits, Sources: 1,
Lat: s.Lat, Lon: s.Lon,
}}
continue
}
c.row.Hits += s.Hits
c.row.Sources++
}
for _, c := range cities {
sum.Cities = append(sum.Cities, c.row)
}
sort.Slice(sum.Cities, func(i, j int) bool {
if sum.Cities[i].Hits != sum.Cities[j].Hits {
return sum.Cities[i].Hits > sum.Cities[j].Hits
}
return sum.Cities[i].City < sum.Cities[j].City
})
// ---- 5. 路径 ----
for _, p := range in.Paths {
sum.Paths = append(sum.Paths, PathRow{Path: p.Path, Hits: p.Count})
}
// ---- 6. 每日趋势 ----
// 先按本地日期把周期内每一天都填成 0,再累加 —— 没有命中的日子必须
// 显示为 0 而不是「不存在」,否则趋势图会把断档画成连续的。
dayIndex := make(map[string]int)
for d := in.Period.Start; d.Before(in.Period.End); d = d.AddDate(0, 0, 1) {
dayIndex[d.Format("2006-01-02")] = len(sum.Days)
sum.Days = append(sum.Days, DayRow{Day: d})
}
for _, ts := range in.Timestamps {
key := ts.In(in.Period.Start.Location()).Format("2006-01-02")
if i, ok := dayIndex[key]; ok {
sum.Days[i].Hits++
}
}
for _, d := range sum.Days {
if d.Hits > sum.MaxDay {
sum.MaxDay = d.Hits
}
}
return sum
}
// isCloudProvider 判断 cdncheck 的标签是否指出了一个云厂商/CDN/WAF。
// ipinfo.Lookup 未命中时返回「未识别(普通网络)」,失败时返回「识别失败…」——
// 这两种都算「非云」。
func isCloudProvider(label string) bool {
if label == "" {
return false
}
return !strings.HasPrefix(label, "未识别") && !strings.HasPrefix(label, "识别失败")
}
// Location 返回「国家 · 省 · 市」的展示串,缺失部分自动省略。
func (s SourceRow) Location() string {
parts := make([]string, 0, 3)
for _, v := range []string{s.Country, s.Region, s.City} {
if v != "" && v != unknownCountry {
parts = append(parts, v)
}
}
if len(parts) == 0 {
return "位置未知"
}
return strings.Join(parts, " · ")
}
// IsIPLiteral 报告该来源是否为可直接引用的 IP 字面量(用于 abuse 投诉模板)。
func (s SourceRow) IsIPLiteral() bool { return net.ParseIP(s.IP) != nil }
+154
View File
@@ -0,0 +1,154 @@
// 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 report
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/security/geoip"
)
// TestBuildMergesIPv6By64 覆盖「同一台机器在 /64 内轮换地址」的合并语义。
//
// 这是报告最容易出错的地方:按精确地址统计会把一台机器算成 8 个来源,
// 于是「独立来源数」虚高、地理聚合里同一个城市出现多次。
func TestBuildMergesIPv6By64(t *testing.T) {
loc := time.Local
start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc)
period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)}
sources := []*db.HoneypotSourceAgg{
{IP: "2001:db8:1:2::1", Hits: 10, Banned: 10, Creds: 2},
{IP: "2001:db8:1:2::2", Hits: 5, Banned: 5, Creds: 1},
{IP: "2001:db8:1:3::9", Hits: 1}, // 不同 /64,不能合并
{IP: "203.0.113.7", Hits: 3, Banned: 3, Creds: 3},
}
geo := map[string]geoip.Geo{
"2001:db8:1:2::/64": {Country: "中国", CountryCode: "cn", Region: "北京市", City: "北京", Lat: 39.9, Lon: 116.4, HasCoords: true},
"2001:db8:1:3::/64": {Country: "中国", CountryCode: "CN", Region: "北京市", City: "北京", Lat: 39.9, Lon: 116.4, HasCoords: true},
"203.0.113.7": {Country: "美国", CountryCode: "US", Region: "California", City: "Fremont", Lat: 37.5, Lon: -121.9, HasCoords: true},
}
sum := Build(Input{
Period: period, Sources: sources, Geo: geo,
Now: start.AddDate(0, 0, 7),
})
require.Equal(t, int64(19), sum.TotalHits)
// 四条精确地址压成三个来源(两个 /64 + 一个 IPv4):
// 若不合并会是 4,「同一个 C 段算成两台机器」的错觉就来自这里。
require.Equal(t, 3, sum.UniqueSources)
// 合并后的 /64 命中数应为 15,且排第一;展示用的 IP 取该 /64 里最活跃的
// 那一台精确地址(::1 命中 10 次 > ::2 的 5 次)—— 报告里要能直接复制
// 这个地址去查日志,所以不展示折后的前缀。
require.Equal(t, "2001:db8:1:2::1", sum.Sources[0].IP)
require.Equal(t, int64(15), sum.Sources[0].Hits)
require.Equal(t, int64(3), sum.Sources[0].Creds)
// 两个 /64 都在北京,城市聚合应当合成一个 16 次的气泡。
require.Len(t, sum.Cities, 2)
require.Equal(t, "北京", sum.Cities[0].City)
require.Equal(t, int64(16), sum.Cities[0].Hits)
// 国家码必须统一成大写(ip-api 与腾讯的大小写不一致,不统一会出现
// 同一个国家被拆成 CN / cn 两行)。
require.Len(t, sum.Countries, 2)
require.Equal(t, "CN", sum.Countries[0].Code)
require.Equal(t, "US", sum.Countries[1].Code)
require.Equal(t, int64(16), sum.Countries[0].Hits)
}
// TestBuildUnlocated 覆盖「查不到位置」的显式统计。
//
// 覆盖率必须是报告里的显式指标:一张 60% 覆盖的地图和 100% 的看起来一样。
func TestBuildUnlocated(t *testing.T) {
loc := time.Local
start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc)
period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)}
sum := Build(Input{
Period: period,
Sources: []*db.HoneypotSourceAgg{
{IP: "198.51.100.1", Hits: 7},
{IP: "203.0.113.9", Hits: 3},
},
Geo: map[string]geoip.Geo{
"203.0.113.9": {Country: "日本", CountryCode: "JP", Lat: 35.6, Lon: 139.7, HasCoords: true},
},
Now: start,
})
require.Equal(t, int64(10), sum.TotalHits)
require.Equal(t, int64(7), sum.UnlocatedHits)
// 未定位的那条没有坐标,不能进城市聚合(否则会画到 0,0)。
require.Len(t, sum.Cities, 1)
require.Equal(t, "日本", sum.Cities[0].Country)
// 未定位的来源国家名必须是「未知」而不是空串
require.Equal(t, unknownCountry, sum.Sources[0].Country)
}
// TestBuildCloudSources 覆盖「云主机 vs 肉鸡」的区分。
func TestBuildCloudSources(t *testing.T) {
loc := time.Local
start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc)
period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)}
sum := Build(Input{
Period: period,
Sources: []*db.HoneypotSourceAgg{
{IP: "203.0.113.1", Hits: 1, Provider: "微软 Azure(云厂商)"},
{IP: "203.0.113.2", Hits: 1, Provider: "未识别(普通网络)"},
{IP: "203.0.113.3", Hits: 1, Provider: "识别失败"},
{IP: "203.0.113.4", Hits: 1, Provider: ""},
},
Now: start,
})
// 只有第一条算云主机:剩下三种都是「不知道」,不能当成云。
require.Equal(t, 1, sum.CloudSources)
}
// TestBuildEmpty 覆盖空窗口:报告仍要能生成(「这周没人打」是个结论)。
func TestBuildEmpty(t *testing.T) {
loc := time.Local
start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc)
period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)}
sum := Build(Input{Period: period, Now: start})
require.True(t, sum.Empty)
require.Equal(t, int64(0), sum.TotalHits)
// 每一天都要有 0 值占位,否则趋势图会把「无数据」画成断档。
require.Len(t, sum.Days, 7)
}
// TestBuildDailySeries 覆盖每日趋势:无命中的日子必须补 0。
func TestBuildDailySeries(t *testing.T) {
loc := time.Local
start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc)
period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)}
stamps := []time.Time{
start.Add(2 * time.Hour), // 10-19
start.Add(3 * time.Hour), // 10-19
start.AddDate(0, 0, 4).Add(time.Hour), // 10-23
// 边界外:下一周期的第一天第一秒不能算进来(左闭右开)
period.End,
}
sum := Build(Input{Period: period, Timestamps: stamps, Now: start})
require.Len(t, sum.Days, 7)
require.Equal(t, int64(2), sum.Days[0].Hits)
require.Equal(t, int64(0), sum.Days[1].Hits)
require.Equal(t, int64(1), sum.Days[4].Hits)
require.Equal(t, int64(2), sum.MaxDay)
// TotalHits 由「来源聚合」得出,与「时间戳聚合」是两条独立输入;
// 本用例只给了时间戳,所以 TotalHits 为 0。真实调用里两者同窗口同表,
// 数值必定一致(见 internal/cmd/report.go 的 buildSecurityReport)。
require.Equal(t, int64(0), sum.TotalHits)
}
+135
View File
@@ -0,0 +1,135 @@
// 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 report
import "strings"
// countryNames 常见国家/地区的中文显示名。
//
// 报告是给中文读者看的,表格里一列「United States」一列「China」看着像没做完。
// 只覆盖实际可能出现的来源国(攻击流量高度集中在少数国家/地区 + 一批云机房
// 所在地),查不到的直接用 IP 库给的英文名 —— 不做「猜」,也不引入几百行的
// 全量对照表。
var countryNames = map[string]string{
"CN": "中国",
"HK": "中国香港",
"MO": "中国澳门",
"TW": "中国台湾",
"US": "美国",
"JP": "日本",
"KR": "韩国",
"SG": "新加坡",
"MY": "马来西亚",
"TH": "泰国",
"VN": "越南",
"PH": "菲律宾",
"ID": "印度尼西亚",
"IN": "印度",
"PK": "巴基斯坦",
"BD": "孟加拉国",
"LK": "斯里兰卡",
"NP": "尼泊尔",
"KH": "柬埔寨",
"MM": "缅甸",
"MN": "蒙古",
"KZ": "哈萨克斯坦",
"UZ": "乌兹别克斯坦",
"AE": "阿联酋",
"SA": "沙特阿拉伯",
"IL": "以色列",
"TR": "土耳其",
"IQ": "伊拉克",
"IR": "伊朗",
"QA": "卡塔尔",
"KW": "科威特",
"JO": "约旦",
"LB": "黎巴嫩",
"RU": "俄罗斯",
"UA": "乌克兰",
"BY": "白俄罗斯",
"PL": "波兰",
"DE": "德国",
"FR": "法国",
"GB": "英国",
"IE": "爱尔兰",
"NL": "荷兰",
"BE": "比利时",
"LU": "卢森堡",
"CH": "瑞士",
"AT": "奥地利",
"CZ": "捷克",
"SK": "斯洛伐克",
"HU": "匈牙利",
"RO": "罗马尼亚",
"BG": "保加利亚",
"GR": "希腊",
"IT": "意大利",
"ES": "西班牙",
"PT": "葡萄牙",
"SE": "瑞典",
"NO": "挪威",
"DK": "丹麦",
"FI": "芬兰",
"EE": "爱沙尼亚",
"LV": "拉脱维亚",
"LT": "立陶宛",
"IS": "冰岛",
"RS": "塞尔维亚",
"HR": "克罗地亚",
"SI": "斯洛文尼亚",
"MD": "摩尔多瓦",
"CA": "加拿大",
"MX": "墨西哥",
"BR": "巴西",
"AR": "阿根廷",
"CL": "智利",
"CO": "哥伦比亚",
"PE": "秘鲁",
"VE": "委内瑞拉",
"EC": "厄瓜多尔",
"UY": "乌拉圭",
"PY": "巴拉圭",
"BO": "玻利维亚",
"PA": "巴拿马",
"CR": "哥斯达黎加",
"AU": "澳大利亚",
"NZ": "新西兰",
"ZA": "南非",
"EG": "埃及",
"NG": "尼日利亚",
"KE": "肯尼亚",
"MA": "摩洛哥",
"DZ": "阿尔及利亚",
"TN": "突尼斯",
"GH": "加纳",
"ET": "埃塞俄比亚",
"TZ": "坦桑尼亚",
"SC": "塞舌尔",
"MU": "毛里求斯",
"CY": "塞浦路斯",
"MT": "马耳他",
"AL": "阿尔巴尼亚",
"MK": "北马其顿",
"BA": "波黑",
"GE": "格鲁吉亚",
"AM": "亚美尼亚",
"AZ": "阿塞拜疆",
"AF": "阿富汗",
}
// countryName 返回国家/地区的显示名;没收录的退回 IP 库给的英文名。
//
// 港澳台显式写成「中国香港 / 中国澳门 / 中国台湾」:它们是中国的组成部分,
// 在报告里跟「United States」并列成同级「国家」是不对的。这类 IP 库通常
// 单独编码 HK/MO/TW,所以必须在展示层统一,而不是指望数据源。
func countryName(code, fallback string) string {
if n, ok := countryNames[strings.ToUpper(strings.TrimSpace(code))]; ok {
return n
}
if fallback != "" {
return fallback
}
return "未知"
}
+156
View File
@@ -0,0 +1,156 @@
// 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 report
import (
"context"
"time"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/security/geoip"
)
// 缓存 TTL。
//
// 成功结果留 90 天:一个 IP 的国家/省份几乎不会变,城市级偶尔变,
// 90 天足够新。失败结果只留 3 天:失败多为临时原因(对方 API 超时、
// 限流),留久了等于永久放弃这个地址。
const (
geoSuccessTTL = 90 * 24 * time.Hour
geoFailureTTL = 3 * 24 * time.Hour
)
// GeoResolver 是定位器的最小接口(*geoip.Resolver 实现它)。
//
// 用接口而不是具体类型:这个函数的核心是缓存语义(TTL、失败短期不重试、
// 写回),必须能在不联网、不依赖真实 IP 库的情况下测到。
type GeoResolver interface {
Lookup(ctx context.Context, keys []string) geoip.Result
Enabled() bool
}
// ResolveWithCache 解析一批 IP 的地理位置,优先用缓存。
//
// keys 是 shared.LimitKey 的产物,顺序会被保留用于「腾讯精修取前 N 个」——
// 调用方应按命中次数从高到低传入,这样配额优先花在最活跃的来源上。
//
// ⚠️ 写缓存的判定是这里全部的价值所在,务必分清三种结果:
// - Result.Geo[key] 存在 → 查到了,写成功缓存(90 天)
// - Result.Unresolved[key] → 服务明确说没有这个地址的数据,写失败缓存(3 天),
// 免得每份报告都去重试一个注定查不到的地址
// - 两个 map 里都没有 → 这次请求本身没成功(超时 / 限流 / 网关 5xx)。
// **绝不写缓存**。早先的实现在这里也写 Failed,结果是一次网络抖动就把整批
// IP 钉成「查不到」三天,此后每份报告的定位覆盖率都莫名其妙变成 0,
// 而且日志里没有任何线索指向真正的原因。
func ResolveWithCache(ctx context.Context, cache db.IPGeoStore, resolver GeoResolver, keys []string) map[string]geoip.Geo {
out := make(map[string]geoip.Geo, len(keys))
if len(keys) == 0 {
return out
}
cached, err := cache.GetMany(ctx, keys)
if err != nil {
// 缓存读失败不该让报告失败:退化成「全部现查」,
// 代价是可能多花点配额,但报告照出。
logrus.WithError(err).Warn("读取 IP 地理缓存失败,本次将全部重新查询")
cached = nil
}
now := time.Now()
var misses []string
seen := make(map[string]bool, len(keys))
for _, key := range keys {
if seen[key] {
continue
}
seen[key] = true
rec, ok := cached[key]
if ok && fresh(rec, now) {
if !rec.Failed {
out[key] = geoip.Geo{
Country: rec.Country,
CountryCode: rec.CountryCode,
Region: rec.Region,
City: rec.City,
ISP: rec.ISP,
ASN: rec.ASN,
Lat: rec.Lat,
Lon: rec.Lon,
HasCoords: rec.HasCoords,
Source: rec.Source,
}
}
// 命中但 Failed:明确记为「查不到」,不再重试,也不进 out。
continue
}
misses = append(misses, key)
}
if len(misses) == 0 || !resolver.Enabled() {
return out
}
resolved := resolver.Lookup(ctx, misses)
if resolved.Failures > 0 {
logrus.WithFields(logrus.Fields{
"keys": len(misses),
"requests": resolved.Requests,
"failures": resolved.Failures,
"located": len(resolved.Geo),
}).Warn("IP 定位请求部分失败:失败的那些不进缓存,下次报告会重新查询")
}
// 写回缓存:成功的与「服务明确说没有」的都写,网络失败的跳过。
records := make([]*db.IPGeoRecord, 0, len(misses))
for _, key := range misses {
if g, ok := resolved.Geo[key]; ok {
out[key] = g
records = append(records, &db.IPGeoRecord{
IPKey: key,
Country: g.Country,
CountryCode: g.CountryCode,
Region: g.Region,
City: g.City,
ISP: g.ISP,
ASN: g.ASN,
Lat: g.Lat,
Lon: g.Lon,
HasCoords: g.HasCoords,
Source: g.Source,
ResolvedAt: now,
})
continue
}
if resolved.Unresolved[key] {
records = append(records, &db.IPGeoRecord{
IPKey: key,
ResolvedAt: now,
Failed: true,
})
}
}
if len(records) == 0 {
return out
}
if err := cache.UpsertMany(ctx, records); err != nil {
logrus.WithError(err).Warn("写入 IP 地理缓存失败(不影响本次报告)")
}
return out
}
// fresh 判断缓存记录是否还在有效期内。
func fresh(rec *db.IPGeoRecord, now time.Time) bool {
if rec == nil {
return false
}
ttl := geoSuccessTTL
if rec.Failed {
ttl = geoFailureTTL
}
return now.Sub(rec.ResolvedAt) < ttl
}
+202
View File
@@ -0,0 +1,202 @@
// 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 report
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/security/geoip"
)
// fakeGeoStore 内存版缓存,避免测试依赖数据库。
type fakeGeoStore struct {
rows map[string]*db.IPGeoRecord
upserts int
}
func newFakeGeoStore() *fakeGeoStore {
return &fakeGeoStore{rows: map[string]*db.IPGeoRecord{}}
}
func (f *fakeGeoStore) GetMany(_ context.Context, keys []string) (map[string]*db.IPGeoRecord, error) {
out := map[string]*db.IPGeoRecord{}
for _, k := range keys {
if r, ok := f.rows[k]; ok {
out[k] = r
}
}
return out, nil
}
func (f *fakeGeoStore) UpsertMany(_ context.Context, records []*db.IPGeoRecord) error {
f.upserts += len(records)
for _, r := range records {
f.rows[r.IPKey] = r
}
return nil
}
func (f *fakeGeoStore) PurgeResolvedBefore(context.Context, time.Time, bool) (int64, error) {
return 0, nil
}
func (f *fakeGeoStore) Count(context.Context) (int64, error) { return int64(len(f.rows)), nil }
// fakeResolver 记录被查询的键,按预设返回。
//
// 三态要能分别构造:result 里的是查到了,unresolved 里的是服务明确说没有,
// 两者都没有的算请求失败(failures)。
type fakeResolver struct {
enabled bool
result map[string]geoip.Geo
unresolved map[string]bool
failures int
queried []string
}
func (f *fakeResolver) Lookup(_ context.Context, keys []string) geoip.Result {
f.queried = append(f.queried, keys...)
r := geoip.Result{
Geo: map[string]geoip.Geo{},
Unresolved: map[string]bool{},
Requests: 1,
Failures: f.failures,
}
for _, k := range keys {
if g, ok := f.result[k]; ok {
r.Geo[k] = g
continue
}
if f.unresolved[k] {
r.Unresolved[k] = true
}
}
return r
}
func (f *fakeResolver) Enabled() bool { return f.enabled }
// TestResolveWithCache 覆盖核心缓存语义:命中不查、未命中才查、明确无数据也回写。
func TestResolveWithCache(t *testing.T) {
ctx := context.Background()
store := newFakeGeoStore()
now := time.Now()
// 已缓存的旧记录(30 天前,仍在 90 天 TTL 内)
store.rows["1.1.1.1"] = &db.IPGeoRecord{
IPKey: "1.1.1.1", Country: "澳大利亚", CountryCode: "AU",
City: "Sydney", HasCoords: true, Lat: -33.8, Lon: 151.2,
Source: "ipapi", ResolvedAt: now.AddDate(0, 0, -30),
}
// 已缓存的失败记录(1 天前,仍在 3 天 TTL 内)→ 不重试
store.rows["2.2.2.2"] = &db.IPGeoRecord{
IPKey: "2.2.2.2", Failed: true, ResolvedAt: now.AddDate(0, 0, -1),
}
resolver := &fakeResolver{
enabled: true,
result: map[string]geoip.Geo{
"3.3.3.3": {Country: "德国", CountryCode: "DE", City: "Berlin", Lat: 52.5, Lon: 13.4, HasCoords: true, Source: "ipapi"},
},
// 4.4.4.4:服务明确回答查不到
unresolved: map[string]bool{"4.4.4.4": true},
}
got := ResolveWithCache(ctx, store, resolver, []string{"1.1.1.1", "2.2.2.2", "3.3.3.3", "4.4.4.4"})
// 缓存命中的值要能取回来
require.Equal(t, "澳大利亚", got["1.1.1.1"].Country)
// 失败记录不进结果,也不重新查询
require.NotContains(t, got, "2.2.2.2")
// 未命中的查到了
require.Equal(t, "德国", got["3.3.3.3"].Country)
// 查不到的也不进结果
require.NotContains(t, got, "4.4.4.4")
// 只应查询两个真正需要查的键
require.ElementsMatch(t, []string{"3.3.3.3", "4.4.4.4"}, resolver.queried)
// 两条未命中的都要写回缓存(成功的 + 明确无数据的)
require.Equal(t, 2, store.upserts)
require.True(t, store.rows["4.4.4.4"].Failed, "服务明确说没数据时必须落 Failed 标记,否则下一份报告会反复重试")
require.False(t, store.rows["3.3.3.3"].Failed)
}
// TestResolveWithCacheNetworkFailure 是「缓存毒化」的回归测试。
//
// 网络失败(超时 / 限流 / 网关 5xx)绝不能被记成「这个 IP 没有位置」:
// 一旦写进缓存就是 3 天,此后每份报告的定位覆盖率都会莫名其妙变成 0。
func TestResolveWithCacheNetworkFailure(t *testing.T) {
ctx := context.Background()
store := newFakeGeoStore()
// 三个键全都没查成:result 与 unresolved 都是空的
resolver := &fakeResolver{enabled: true, failures: 1}
got := ResolveWithCache(ctx, store, resolver, []string{"8.8.8.8", "9.9.9.9"})
require.Empty(t, got)
require.Len(t, resolver.queried, 2, "失败也要算查过了,否则同一轮里会重复发请求")
require.Equal(t, 0, store.upserts, "请求失败绝不能写缓存")
require.Empty(t, store.rows)
}
// TestResolveWithCacheTTL 覆盖过期语义:成功记录 90 天后、失败记录 3 天后都要重查。
func TestResolveWithCacheTTL(t *testing.T) {
ctx := context.Background()
now := time.Now()
store := newFakeGeoStore()
store.rows["5.5.5.5"] = &db.IPGeoRecord{
IPKey: "5.5.5.5", Country: "旧数据", HasCoords: true,
ResolvedAt: now.AddDate(0, 0, -91), // 超过 90 天
}
store.rows["6.6.6.6"] = &db.IPGeoRecord{
IPKey: "6.6.6.6", Failed: true,
ResolvedAt: now.AddDate(0, 0, -4), // 超过 3 天
}
resolver := &fakeResolver{
enabled: true,
result: map[string]geoip.Geo{
"5.5.5.5": {Country: "新数据", CountryCode: "US", Source: "ipapi", HasCoords: true},
"6.6.6.6": {Country: "韩国", CountryCode: "KR", Source: "ipapi", HasCoords: true},
},
}
got := ResolveWithCache(ctx, store, resolver, []string{"5.5.5.5", "6.6.6.6"})
require.Equal(t, "新数据", got["5.5.5.5"].Country)
require.Equal(t, "韩国", got["6.6.6.6"].Country, "过期的失败记录必须重试")
require.Len(t, resolver.queried, 2)
}
// TestResolveWithCacheDisabled 覆盖 ip_geo_provider = none:一个字节都不外发。
func TestResolveWithCacheDisabled(t *testing.T) {
ctx := context.Background()
store := newFakeGeoStore()
resolver := &fakeResolver{enabled: false}
got := ResolveWithCache(ctx, store, resolver, []string{"1.2.3.4"})
require.Empty(t, got)
require.Empty(t, resolver.queried)
require.Equal(t, 0, store.upserts)
}
// TestResolveWithCacheDedup 覆盖重复键只查一次。
func TestResolveWithCacheDedup(t *testing.T) {
ctx := context.Background()
store := newFakeGeoStore()
resolver := &fakeResolver{
enabled: true,
result: map[string]geoip.Geo{"7.7.7.7": {Country: "法国", Source: "ipapi"}},
}
ResolveWithCache(ctx, store, resolver, []string{"7.7.7.7", "7.7.7.7", "7.7.7.7"})
require.Equal(t, []string{"7.7.7.7"}, resolver.queried)
require.Equal(t, 1, store.upserts)
}
+391
View File
@@ -0,0 +1,391 @@
// 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 report
import (
"encoding/base64"
"fmt"
"html"
"math"
"os"
"path/filepath"
"strings"
"time"
)
// HTMLOptions 渲染选项。
type HTMLOptions struct {
// TencentMapKey 腾讯位置服务 key。留空则报告里只有离线 SVG 地图,
// 并附一句「需要可交互底图请配置 ip_geo_tencent_key」。
TencentMapKey string
}
// tencentKeyPlaceholder 未配置 key 时写进页面的占位符。
//
// 按地图合规要求,代码里绝不预置任何可用的 key;占位符要让拿到报告的人
// 一眼看出「这里需要你自己申请」,而不是以为地图坏了。
const tencentKeyPlaceholder = "请前往 lbs.qq.com 申请 key 后填入 [security] ip_geo_tencent_key"
// RenderHTML 生成完整的自包含报告页。
//
// 自包含的含义:不依赖 site 的 CSS、不依赖任何外链图片(腾讯地图那一块是
// 可选的在线增强,其余部分断网也能看)。
func RenderHTML(sum *Summary, opts HTMLOptions) string {
var b strings.Builder
title := sum.Period.Title()
b.WriteString(`<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">`)
b.WriteString(`<meta name="viewport" content="width=device-width,initial-scale=1">`)
fmt.Fprintf(&b, `<title>%s</title>`, html.EscapeString(title))
b.WriteString(pageCSS)
b.WriteString(`</head><body><div class="wrap">`)
// 头部
fmt.Fprintf(&b, `<header><h1>%s</h1><p class="sub">统计窗口 <b>%s</b> · 生成于 %s</p></header>`,
html.EscapeString(title), html.EscapeString(sum.Period.Label()),
sum.GeneratedAt.Format("2006-01-02 15:04"))
if sum.Empty {
b.WriteString(`<div class="empty">本期没有捕获到任何蜜罐命中 —— 这本身是个好消息,但也请确认蜜罐仍然开启(<code>TamaBox honeypot paths</code>)。</div>`)
b.WriteString(`</div></body></html>`)
return b.String()
}
// KPI
b.WriteString(`<section class="kpis">`)
writeKPI(&b, "蜜罐命中", fmt.Sprint(sum.TotalHits), trendNote(sum.TotalHits, sum.PrevHits))
writeKPI(&b, "独立来源", fmt.Sprint(sum.UniqueSources), fmt.Sprintf("覆盖 %d 个国家和地区", sum.UniqueCountry))
writeKPI(&b, "已拦截", fmt.Sprint(sum.BannedHits), pct(sum.BannedHits, sum.TotalHits))
writeKPI(&b, "真实破解尝试", fmt.Sprint(sum.CredHits), "提交了账号密码")
writeKPI(&b, "云主机来源", fmt.Sprint(sum.CloudSources), fmt.Sprintf("占比 %s", pctInt(int64(sum.CloudSources), int64(sum.UniqueSources))))
b.WriteString(`</section>`)
// 地图
b.WriteString(`<section><h2>来源分布</h2>`)
b.WriteString(`<p class="note">气泡面积与命中次数成正比。地图上的点是 IP 库给出的城市级定位,精度到城市,不代表对方的真实街道位置。</p>`)
b.WriteString(`<div class="map-frame">`)
b.WriteString(RenderSVG(sum))
b.WriteString(`</div>`)
b.WriteString(`<p class="note">⚠️ 本图只画经纬网与气泡,<b>不含任何国界</b>。国界、台湾与南海诸岛的正确绘制须符合国家标准,自绘底图容易出错,因此这里不做底图;需要真实底图请看下方的腾讯地图版本。</p>`)
b.WriteString(`</section>`)
// 腾讯可交互地图
b.WriteString(`<section><h2>可交互地图</h2>`)
b.WriteString(renderTencentMap(sum, opts.TencentMapKey))
b.WriteString(`</section>`)
// 国家排行
b.WriteString(`<section><h2>国家 / 地区排行</h2>`)
b.WriteString(`<table><thead><tr><th>#</th><th>国家 / 地区</th><th class="num">命中</th><th class="num">占比</th><th class="num">独立来源</th></tr></thead><tbody>`)
for i, c := range sum.Countries {
if i >= 20 {
break
}
fmt.Fprintf(&b, `<tr><td>%d</td><td>%s <span class="muted">%s</span></td><td class="num">%d</td><td class="num">%s</td><td class="num">%d</td></tr>`,
i+1, html.EscapeString(c.Name), html.EscapeString(c.Code), c.Hits, pctFloat(c.Share), c.Sources)
}
b.WriteString(`</tbody></table></section>`)
// 城市排行
if len(sum.Cities) > 0 {
b.WriteString(`<section><h2>城市排行(前 20)</h2>`)
b.WriteString(`<table><thead><tr><th>#</th><th>城市</th><th>国家</th><th class="num">命中</th><th class="num">独立来源</th></tr></thead><tbody>`)
for i, c := range sum.Cities {
if i >= 20 {
break
}
city := c.City
if city == "" {
city = c.Region
}
if city == "" {
city = "(未知城市)"
}
fmt.Fprintf(&b, `<tr><td>%d</td><td>%s</td><td>%s</td><td class="num">%d</td><td class="num">%d</td></tr>`,
i+1, html.EscapeString(city), html.EscapeString(c.Country), c.Hits, c.Sources)
}
b.WriteString(`</tbody></table></section>`)
}
// 来源明细
b.WriteString(`<section><h2>来源明细(前 50)</h2>`)
b.WriteString(`<table><thead><tr><th>IP</th><th>位置</th><th>运营商 / ASN</th><th>网络归属</th><th class="num">命中</th><th class="num">破解</th><th class="num">拦截</th></tr></thead><tbody>`)
for i, s := range sum.Sources {
if i >= 50 {
break
}
provider := s.Provider
if provider == "" {
provider = "—"
}
isp := s.ISP
if s.ASN != "" {
if isp != "" {
isp += " · "
}
isp += s.ASN
}
if isp == "" {
isp = "—"
}
fmt.Fprintf(&b, `<tr><td class="mono">%s</td><td>%s</td><td class="small">%s</td><td class="small">%s</td><td class="num">%d</td><td class="num">%d</td><td class="num">%d</td></tr>`,
html.EscapeString(s.IP), html.EscapeString(s.Location()),
html.EscapeString(isp), html.EscapeString(provider),
s.Hits, s.Creds, s.Banned)
}
b.WriteString(`</tbody></table>`)
if len(sum.Sources) >= 50 {
fmt.Fprintf(&b, `<p class="note">共 %d 个独立来源,此处只列命中次数最高的 50 个。完整明细:<code>TamaBox honeypot stats --days %d</code></p>`,
sum.UniqueSources, int(sum.Period.End.Sub(sum.Period.Start).Hours()/24))
}
b.WriteString(`</section>`)
// 路径
if len(sum.Paths) > 0 {
b.WriteString(`<section><h2>被探测路径</h2>`)
b.WriteString(`<table><thead><tr><th>#</th><th>路径</th><th class="num">次数</th></tr></thead><tbody>`)
for i, p := range sum.Paths {
if i >= 20 {
break
}
fmt.Fprintf(&b, `<tr><td>%d</td><td class="mono">%s</td><td class="num">%d</td></tr>`,
i+1, html.EscapeString(p.Path), p.Hits)
}
b.WriteString(`</tbody></table></section>`)
}
// 趋势
if len(sum.Days) > 1 {
b.WriteString(`<section><h2>每日趋势</h2>`)
b.WriteString(`<table class="trend"><tbody>`)
for _, d := range sum.Days {
w := 0.0
if sum.MaxDay > 0 {
w = float64(d.Hits) / float64(sum.MaxDay) * 100
}
// 宽度为 0 的日子也给 1px,否则读起来像「数据缺失」而不是「零」。
barW := math.Max(w, 0.6)
fmt.Fprintf(&b, `<tr><td class="day">%s</td><td class="bar"><span style="width:%.2f%%"></span></td><td class="num">%d</td></tr>`,
d.Day.Format("01-02"), barW, d.Hits)
}
b.WriteString(`</tbody></table></section>`)
}
// 口径说明
b.WriteString(`<section><h2>口径与说明</h2><ul class="meta">`)
fmt.Fprintf(&b, `<li>地理定位数据源:%s。</li>`, html.EscapeString(sum.ResolverDesc))
fmt.Fprintf(&b, `<li>未能定位的命中:<b>%d</b> 次(占 %s)。这些 IP 在国内外的 IP 库里都没有城市级记录,通常是被回收中的地址段或运营商级 NAT。</li>`,
sum.UnlocatedHits, pct(sum.UnlocatedHits, sum.TotalHits))
b.WriteString(`<li>「破解」指对该次请求提交了账号密码字段;纯路径探测不计入。捕获到的账号密码本身不写进本报告,存放在服务器的取证目录(<code>TamaBox honeypot hits</code> 查看)。</li>`)
b.WriteString(`<li>「网络归属」来自离线指纹(cdncheck),判定的是云厂商 / CDN / WAF,<b>不是</b>地理位置。显示「未识别(普通网络)」不代表没定位到,两者是不同的维度。</li>`)
b.WriteString(`<li>独立来源按封禁口径聚合:IPv4 精确到地址,IPv6 折叠到 /64 —— 攻击者常在一个 /64 内轮换地址,按精确地址会把一台机器算成几十个。</li>`)
b.WriteString(`</ul></section>`)
fmt.Fprintf(&b, `<footer>由 TamaBox 蜜罐自动生成 · %s</footer>`, sum.GeneratedAt.Format("2006-01-02 15:04:05"))
b.WriteString(`</div></body></html>`)
return b.String()
}
// renderTencentMap 生成腾讯地图区块。
//
// 合规要点(与 geo-map-compliance-guard 一致):
// - 只用持牌厂商的底图(腾讯位置服务),绝不引 Google/Bing/OSM/Mapbox;
// - 代码里不预置任何可用 key,未配置时给出申请指引;
// - 地图展示的是攻击者 IP 的城市级定位,用于内部安全复盘,不对外发布。
func renderTencentMap(sum *Summary, key string) string {
key = strings.TrimSpace(key)
if key == "" {
return fmt.Sprintf(`<div class="hint">未配置腾讯地图 key,本节跳过。<br>如需可缩放的真实底图,%s。</div>`,
html.EscapeString(tencentKeyPlaceholder))
}
pts := bubbles(sum)
if len(pts) > 120 {
pts = pts[:120] // 地图上挤 500 个点既卡又看不清
}
maxHits := maxCityHits(pts)
var geo strings.Builder
geo.WriteString("[")
for i, p := range pts {
if i > 0 {
geo.WriteString(",")
}
// 腾讯地图用 GCJ-02。ip-api 给的是 WGS-84(与 GCJ-02 差几百米),
// 在城市级缩放上无影响;放大到街道时才有偏差。
fmt.Fprintf(&geo, `{"id":"p%d","styleId":"s%d","position":[%f,%f],"title":"%s %d 次"}`,
i, sizeClass(p.Hits, maxHits), p.Lat, p.Lon,
jsEscape(p.City+p.Country), p.Hits)
}
geo.WriteString("]")
return fmt.Sprintf(`<div id="tmap" class="tmap"></div>
<div id="tmap-status" class="note"></div>
<script src="https://map.qq.com/api/gljs?v=1.exp&key=%s"></script>
<script>
(function () {
if (typeof TMap === 'undefined') {
document.getElementById('tmap-status').textContent = '腾讯地图 SDK 未能加载(可能是离线打开或 key 无效),请参考上方离线地图。';
return;
}
var center = new TMap.LatLng(20, 10);
var map = new TMap.Map('tmap', { center: center, zoom: 3, viewMode: '2D' });
var styles = {};
var sizes = [[8,8],[12,12],[18,18],[26,26],[34,34]];
for (var i = 0; i < sizes.length; i++) {
styles['s' + i] = new TMap.MarkerStyle({
width: sizes[i][0], height: sizes[i][1],
anchor: { x: sizes[i][0]/2, y: sizes[i][1]/2 },
color: '#EF4444'
});
}
new TMap.MultiMarker({ map: map, styles: styles, geometries: %s });
document.getElementById('tmap-status').textContent = '共 %d 个城市级定位点,气泡越大命中越多。点击标记可见城市与次数。';
})();
</script>`,
html.EscapeString(key), geo.String(), len(pts))
}
// sizeClass 按命中数把气泡分成 5 档(地图标记的尺寸样式只有有限几档)。
func sizeClass(hits, maxHits int64) int {
if maxHits <= 0 {
return 0
}
r := float64(hits) / float64(maxHits)
switch {
case r > 0.8:
return 4
case r > 0.5:
return 3
case r > 0.25:
return 2
case r > 0.08:
return 1
default:
return 0
}
}
// jsEscape 转义要嵌进 JS 字符串字面量的文本。
// 先做 HTML 转义(防 </script> 逃逸),再转义引号与反斜杠。
func jsEscape(s string) string {
s = html.EscapeString(s)
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\"`)
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "\r", " ")
return s
}
func writeKPI(b *strings.Builder, label, value, note string) {
b.WriteString(`<div class="kpi">`)
fmt.Fprintf(b, `<div class="kpi-label">%s</div><div class="kpi-value">%s</div>`, html.EscapeString(label), html.EscapeString(value))
if note != "" {
fmt.Fprintf(b, `<div class="kpi-note">%s</div>`, html.EscapeString(note))
}
b.WriteString(`</div>`)
}
// trendNote 给出与上一周期的对比。
func trendNote(cur, prev int64) string {
if prev <= 0 {
return "上期无数据"
}
delta := float64(cur-prev) / float64(prev) * 100
arrow := "↑"
if delta < 0 {
arrow = "↓"
}
return fmt.Sprintf("较上期 %s %.0f%%(上期 %d)", arrow, math.Abs(delta), prev)
}
func pct(v, total int64) string {
if total <= 0 {
return "—"
}
return fmt.Sprintf("%.1f%%", float64(v)/float64(total)*100)
}
func pctInt(v, total int64) string { return pct(v, total) }
func pctFloat(f float64) string { return fmt.Sprintf("%.1f%%", f*100) }
// WriteFiles 把报告落盘,返回 HTML 与 PNG 的路径。
//
// 目录按 0700 创建:报告里有攻击者 IP 明细,不是可以随便给别的用户读的文件。
func WriteFiles(sum *Summary, dir string, opts HTMLOptions) (htmlPath, pngPath string, err error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", "", err
}
base := filepath.Join(dir, sum.Period.Filename())
htmlPath = base + ".html"
if err = os.WriteFile(htmlPath, []byte(RenderHTML(sum, opts)), 0o600); err != nil {
return "", "", err
}
png, err := RenderPNG(sum)
if err != nil {
return htmlPath, "", err
}
pngPath = base + ".png"
if err = os.WriteFile(pngPath, png, 0o600); err != nil {
return htmlPath, "", err
}
return htmlPath, pngPath, nil
}
// PNGDataURI 把地图转成 data URI(备用:某些邮件客户端对 CID 附件不友好时
// 可以退化为内联 data URI —— 代价是邮件体积变大且部分客户端会拦)。
func PNGDataURI(sum *Summary) (string, error) {
b, err := RenderPNG(sum)
if err != nil {
return "", err
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(b), nil
}
// pageCSS 报告页样式。刻意内联:报告的 HTML 会被单独转发、离线打开,
// 不能依赖站点的静态资源(那些 URL 在别人机器上打不开)。
const pageCSS = `<style>
:root{--fg:#0f172a;--muted:#64748b;--line:#e2e8f0;--bg:#ffffff;--accent:#dc2626;--tint:#f8fafc}
*{box-sizing:border-box}
body{margin:0;background:var(--tint);color:var(--fg);font:14px/1.6 system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif}
.wrap{max-width:1040px;margin:0 auto;padding:32px 20px 60px}
header h1{margin:0 0 4px;font-size:24px;letter-spacing:-.01em}
header .sub{margin:0;color:var(--muted);font-size:13px}
section{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:20px;margin-top:18px}
h2{font-size:15px;margin:0 0 12px;font-weight:600}
.note{color:var(--muted);font-size:12.5px;margin:8px 0 0}
.muted{color:var(--muted)}
.small{font-size:12.5px;color:var(--muted)}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-top:18px}
.kpi{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:14px 16px}
.kpi-label{font-size:12px;color:var(--muted)}
.kpi-value{font-size:26px;font-weight:650;letter-spacing:-.02em;margin:2px 0}
.kpi-note{font-size:12px;color:var(--muted)}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{text-align:left;padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top}
th{font-weight:600;color:var(--muted);font-size:12px;background:var(--tint)}
.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
.map-frame{border:1px solid var(--line);border-radius:8px;overflow:hidden;line-height:0}
.tmap{height:420px;border:1px solid var(--line);border-radius:8px}
.hint{background:#fff7ed;border:1px solid #fed7aa;border-radius:8px;padding:12px 14px;color:#9a3412;font-size:13px}
.empty{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:28px;text-align:center;color:var(--muted)}
table.trend td{border:0;padding:2px 8px}
table.trend .day{color:var(--muted);font-size:12px;white-space:nowrap;width:60px}
table.trend .bar{padding:0 8px}
table.trend .bar span{display:block;height:12px;background:linear-gradient(90deg,#f87171,#dc2626);border-radius:2px;min-width:1px}
table.trend .num{width:70px}
ul.meta{margin:0;padding-left:18px;color:#334155;font-size:13px}
ul.meta li{margin-bottom:6px}
footer{margin-top:26px;text-align:center;color:var(--muted);font-size:12px}
code{background:var(--tint);border:1px solid var(--line);border-radius:4px;padding:1px 5px;font-size:12px}
@media print{body{background:#fff}section{break-inside:avoid;border-color:#ccc}}
</style>`
// FormatTime 供模板层统一时间格式。
func FormatTime(t time.Time) string { return t.Format("2006-01-02 15:04") }
+476
View File
@@ -0,0 +1,476 @@
// 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 report
import (
"bytes"
"fmt"
"html"
"image"
"image/color"
"image/draw"
"image/png"
"math"
"sort"
"golang.org/x/image/font"
"golang.org/x/image/font/basicfont"
"golang.org/x/image/math/fixed"
)
// 画布尺寸。2:1 是等距圆柱(equirectangular)投影的天然比例:
// 经度 360°、纬度 180°,正好铺满矩形。
const (
mapWidth = 960
mapHeight = 480
)
// 配色。刻意用浅色:邮件模板是白底,深色地图嵌进去像个补丁。
var (
colOcean = color.RGBA{0xF1, 0xF5, 0xF9, 0xFF}
colGrid = color.RGBA{0xCB, 0xD5, 0xE1, 0xFF}
colGridKey = color.RGBA{0x94, 0xA3, 0xB8, 0xFF}
colBubble = color.RGBA{0xEF, 0x44, 0x44, 0xFF}
colRim = color.RGBA{0xB9, 0x1C, 0x1C, 0xFF}
colText = color.RGBA{0x33, 0x41, 0x55, 0xFF}
)
// project 把经纬度映射到画布坐标(等距圆柱投影)。
//
// ⚠️ 这里不画任何海岸线或国界。原因不是偷懒:国界、台湾与南海诸岛的绘制
// 必须符合国家标准,自绘底图极易画错,而画错地图是比「图不好看」严重得多
// 的问题。需要真实底图时用腾讯地图(本包另出的 HTML 版带它)。
// 经纬网 + 气泡已经足以回答「攻击主要来自哪个半球/哪个区域」。
func project(lat, lon float64) (x, y float64) {
x = (lon + 180) / 360 * mapWidth
y = (90 - lat) / 180 * mapHeight
return x, y
}
// bubbleRadius 按命中数相对最大值的平方根缩放。
//
// 用 sqrt 而不是线性:几次命中和上千次命中如果按线性画,前者会小到看不见;
// 面积正比于数值是气泡图的通行做法(人眼比较的是面积而不是半径)。
func bubbleRadius(hits, maxHits int64) float64 {
if maxHits <= 0 {
return 4
}
ratio := math.Sqrt(float64(hits) / float64(maxHits))
return 3.5 + 17*ratio
}
// bubbles 从城市聚合里挑出可画的点,按命中数降序。
func bubbles(sum *Summary) []CityRow {
pts := make([]CityRow, 0, len(sum.Cities))
for _, c := range sum.Cities {
// 坐标越界(IP 库偶尔给出 0,0 或越界值)直接丢弃:
// 画到几内亚湾比不画更误导。
if math.IsNaN(c.Lat) || math.IsNaN(c.Lon) ||
c.Lat < -90 || c.Lat > 90 || c.Lon < -180 || c.Lon > 180 {
continue
}
if c.Lat == 0 && c.Lon == 0 {
continue
}
pts = append(pts, c)
}
sort.Slice(pts, func(i, j int) bool {
if pts[i].Hits != pts[j].Hits {
return pts[i].Hits > pts[j].Hits
}
return pts[i].City < pts[j].City
})
return pts
}
// maxCityHits 返回城市命中数的最大值(气泡缩放基准)。
func maxCityHits(pts []CityRow) int64 {
var m int64
for _, p := range pts {
if p.Hits > m {
m = p.Hits
}
}
return m
}
// graticule 经纬网:经度每 30°、纬度每 30° 一条,赤道与本初子午线加粗。
func graticule() (xs, ys []float64) {
for lon := -180.0; lon <= 180; lon += 30 {
xs = append(xs, lon)
}
for lat := -90.0; lat <= 90; lat += 30 {
ys = append(ys, lat)
}
return xs, ys
}
// labelBox 已放好的标签占位框。
type labelBox struct {
x0, y0, x1, y1 float64
}
// labelPacker 给标签找不重叠的位置。
//
// 为什么必须做:同一个都市圈的不同城市,经纬度只差 0.01°,标签会精确地压在
// 一起,两行字叠成一串谁也读不出的字符。实测出现过「AU 305」这种由
// 「AU 55」与「AU 30」叠出来的假数字 —— 这比不标更糟:读者会把它当真实数据。
// 宁可少标几个,也不能让图上出现一个读错就是错的数字。
type labelPacker struct {
placed []labelBox
width, height float64
}
func newLabelPacker(width, height float64) *labelPacker {
return &labelPacker{width: width, height: height}
}
// place 在气泡右侧找位置,放不下就换左侧,两侧都占则放弃。
// 返回标签框左上角坐标。
func (p *labelPacker) place(bx, by, r, textW, textH float64) (x, y float64, ok bool) {
top := by - textH/2
for _, cx := range []float64{bx + r + 3, bx - r - 3 - textW} {
if cx < 2 || cx+textW > p.width-2 || top < 1 || top+textH > p.height-1 {
continue
}
box := labelBox{cx - 2, top - 1, cx + textW + 2, top + textH + 1}
if p.collides(box) {
continue
}
p.placed = append(p.placed, box)
return cx, top, true
}
return 0, 0, false
}
func (p *labelPacker) collides(b labelBox) bool {
for _, o := range p.placed {
if b.x0 < o.x1 && o.x0 < b.x1 && b.y0 < o.y1 && o.y0 < b.y1 {
return true
}
}
return false
}
// 经纬度刻度的取值。只标每 60° / 30°,标满会把气泡和标签挤没。
var (
tickLons = []float64{-180, -120, -60, 0, 60, 120, 180}
tickLats = []float64{-60, -30, 0, 30, 60}
)
// latLabel 把纬度格式化成 30N / 30S 这样的刻度。
func latLabel(lat float64) string {
switch {
case lat == 0:
return "0"
case lat > 0:
return fmt.Sprintf("%.0fN", lat)
default:
return fmt.Sprintf("%.0fS", -lat)
}
}
// RenderSVG 生成内联 SVG 世界气泡图。用于 HTML 报告附件(浏览器里看)。
func RenderSVG(sum *Summary) string {
pts := bubbles(sum)
maxHits := maxCityHits(pts)
lons, lats := graticule()
var b bytes.Buffer
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="100%%" role="img" aria-label="蜜罐命中来源分布世界地图">`, mapWidth, mapHeight)
fmt.Fprintf(&b, `<rect width="%d" height="%d" fill="#F1F5F9"/>`, mapWidth, mapHeight)
// 经纬网
for _, lon := range lons {
x, _ := project(0, lon)
stroke := "#CBD5E1"
w := "0.6"
if lon == 0 {
stroke = "#94A3B8"
w = "1"
}
fmt.Fprintf(&b, `<line x1="%.1f" y1="0" x2="%.1f" y2="%d" stroke="%s" stroke-width="%s"/>`, x, x, mapHeight, stroke, w)
}
for _, lat := range lats {
_, y := project(lat, 0)
stroke := "#CBD5E1"
w := "0.6"
if lat == 0 {
stroke = "#94A3B8"
w = "1"
}
fmt.Fprintf(&b, `<line x1="0" y1="%.1f" x2="%d" y2="%.1f" stroke="%s" stroke-width="%s"/>`, y, mapWidth, y, stroke, w)
}
// 经纬度刻度:没有底图时这是读者唯一的方向参照,不能省。
for _, lon := range tickLons {
x, _ := project(0, lon)
if x < 12 {
x = 12
}
if x > mapWidth-12 {
x = mapWidth - 12
}
fmt.Fprintf(&b, `<text x="%.1f" y="%d" font-size="9" fill="#94A3B8" text-anchor="middle" font-family="monospace">%s</text>`,
x, mapHeight-4, lonLabel(lon))
}
for _, lat := range tickLats {
_, y := project(lat, 0)
fmt.Fprintf(&b, `<text x="3" y="%.1f" font-size="9" fill="#94A3B8" font-family="monospace">%s</text>`,
y+3, latLabel(lat))
}
// 气泡
for _, p := range pts {
x, y := project(p.Lat, p.Lon)
r := bubbleRadius(p.Hits, maxHits)
fmt.Fprintf(&b, `<circle cx="%.1f" cy="%.1f" r="%.1f" fill="#EF4444" fill-opacity="0.35" stroke="#B91C1C" stroke-opacity="0.8" stroke-width="1"><title>%s · %s:%d 次命中(%d 个来源)</title></circle>`,
x, y, r, html.EscapeString(p.City), html.EscapeString(p.Country), p.Hits, p.Sources)
}
// 标注前 8 个城市:放过大的气泡,标签不重叠才画(理由见 labelPacker)。
packer := newLabelPacker(mapWidth, mapHeight)
for i, p := range pts {
if i >= 8 {
break
}
x, y := project(p.Lat, p.Lon)
r := bubbleRadius(p.Hits, maxHits)
label := cityLabel(p)
tx, ty, ok := packer.place(x, y, r, svgTextWidth(label), 12)
if !ok {
continue
}
fmt.Fprintf(&b, `<text x="%.1f" y="%.1f" font-size="11" fill="#334155" font-family="system-ui,-apple-system,Segoe UI,sans-serif">%s</text>`,
tx, ty+10, html.EscapeString(label))
}
b.WriteString(`</svg>`)
return b.String()
}
// cityLabel 优先用城市名,退到省/州,最后退到国家名。
func cityLabel(p CityRow) string {
if p.City != "" {
return fmt.Sprintf("%s %d", p.City, p.Hits)
}
if p.Region != "" {
return fmt.Sprintf("%s %d", p.Region, p.Hits)
}
return fmt.Sprintf("%s %d", p.Country, p.Hits)
}
// svgTextWidth 粗估一行文字在 11px 下的宽度。
// CJK 按全宽算、其余按 0.62 倍字宽算 —— 只用于判断是否会撞在一起,
// 精确到像素没有意义(何况最终字体由浏览器决定)。
func svgTextWidth(s string) float64 {
var w float64
for _, r := range s {
if r > 0x2E80 {
w += 11
} else {
w += 6.8
}
}
return w
}
// lonLabel 把经度格式化成 90°E / 120°W 这样的刻度。
func lonLabel(lon float64) string {
switch {
case lon == 0:
return "0"
case lon > 0:
return fmt.Sprintf("%.0fE", lon)
default:
return fmt.Sprintf("%.0fW", -lon)
}
}
// RenderPNG 生成邮件正文用的位图世界气泡图。
//
// 为什么要位图:邮件客户端不执行 JS,且 Gmail / Outlook 会剥离内联 SVG。
// 只有以 CID 内嵌的 PNG 能保证「打开邮件就看得到地图」。
//
// 图上文字只能是 ASCII(Go 标准库里唯一的无依赖位图字体 basicfont 只有
// 7x13 的拉丁字形),所以标签用国家码 + 次数,中文说明放在邮件正文里。
func RenderPNG(sum *Summary) ([]byte, error) {
img := image.NewRGBA(image.Rect(0, 0, mapWidth, mapHeight))
draw.Draw(img, img.Bounds(), &image.Uniform{colOcean}, image.Point{}, draw.Src)
lons, lats := graticule()
for _, lon := range lons {
x, _ := project(0, lon)
c := colGrid
if lon == 0 {
c = colGridKey
}
drawVLine(img, x, c)
}
for _, lat := range lats {
_, y := project(lat, 0)
c := colGrid
if lat == 0 {
c = colGridKey
}
drawHLine(img, y, c)
}
pts := bubbles(sum)
maxHits := maxCityHits(pts)
// 从小到大画:大气泡后画才能盖住小气泡,否则重要的点被长尾挡住。
for i := len(pts) - 1; i >= 0; i-- {
p := pts[i]
x, y := project(p.Lat, p.Lon)
r := bubbleRadius(p.Hits, maxHits)
fillCircle(img, x, y, r, colBubble, 0.35)
strokeCircle(img, x, y, r, colRim, 0.8)
}
// 刻度先画:气泡后画会盖住刻度,保证气泡在视觉上优先。
for _, lon := range tickLons {
x, _ := project(0, lon)
s := lonLabel(lon)
tx := x - basicfontWidth(s)/2
if tx < 2 {
tx = 2
}
if tx+basicfontWidth(s) > mapWidth-2 {
tx = mapWidth - 2 - basicfontWidth(s)
}
drawText(img, tx, mapHeight-15, s, colGridKey, false)
}
for _, lat := range tickLats {
_, y := project(lat, 0)
drawText(img, 3, y-7, latLabel(lat), colGridKey, false)
}
// 前 8 个城市标国家码 + 次数,重叠的跳过(理由见 labelPacker)。
packer := newLabelPacker(mapWidth, mapHeight)
for i, p := range pts {
if i >= 8 {
break
}
x, y := project(p.Lat, p.Lon)
r := bubbleRadius(p.Hits, maxHits)
label := fmt.Sprintf("%s %d", p.CountryCode, p.Hits)
tx, ty, ok := packer.place(x, y, r, basicfontWidth(label), 13)
if !ok {
continue
}
drawText(img, tx, ty, label, colText, true)
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// basicfontWidth 7x13 位图字体的定宽推进值:每个字符正好 7px。
func basicfontWidth(s string) float64 { return float64(len(s)) * 7 }
// drawText 用 7x13 位图字体写一行 ASCII 文字,(x, top) 是文本框左上角。
//
// outline=true 时先描一圈白,避免文字压在气泡或网格上糊成一片。
func drawText(img *image.RGBA, x, top float64, s string, c color.RGBA, outline bool) {
dot := fixed.P(int(x), int(top)+11)
if outline {
for _, off := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} {
h := &font.Drawer{
Dst: img, Src: &image.Uniform{color.RGBA{0xFF, 0xFF, 0xFF, 0xFF}},
Face: basicfont.Face7x13,
Dot: fixed.P(int(x)+off[0], int(top)+11+off[1]),
}
h.DrawString(s)
}
}
d := &font.Drawer{Dst: img, Src: &image.Uniform{c}, Face: basicfont.Face7x13, Dot: dot}
d.DrawString(s)
}
// fillCircle 画一个带 1px 抗锯齿边缘的实心圆(alpha 混合)。
func fillCircle(img *image.RGBA, cx, cy, r float64, c color.RGBA, alpha float64) {
drawCircleRing(img, cx, cy, 0, r, c, alpha)
}
// strokeCircle 画一个 1px 宽的圆环。
func strokeCircle(img *image.RGBA, cx, cy, r float64, c color.RGBA, alpha float64) {
drawCircleRing(img, cx, cy, r-0.5, r+0.5, c, alpha)
}
// drawCircleRing 填充内半径 inner 到外半径 outer 之间的环(inner=0 即实心圆)。
func drawCircleRing(img *image.RGBA, cx, cy, inner, outer float64, c color.RGBA, alpha float64) {
x0 := int(math.Floor(cx - outer - 1))
x1 := int(math.Ceil(cx + outer + 1))
y0 := int(math.Floor(cy - outer - 1))
y1 := int(math.Ceil(cy + outer + 1))
if x0 < 0 {
x0 = 0
}
if y0 < 0 {
y0 = 0
}
if x1 > img.Bounds().Dx() {
x1 = img.Bounds().Dx()
}
if y1 > img.Bounds().Dy() {
y1 = img.Bounds().Dy()
}
for y := y0; y < y1; y++ {
for x := x0; x < x1; x++ {
dx := float64(x) + 0.5 - cx
dy := float64(y) + 0.5 - cy
d := math.Hypot(dx, dy)
// 与圆环相交的覆盖率:边缘 1px 内线性过渡,起到抗锯齿作用。
cov := math.Min(outer-d, d-inner) + 0.5
if cov <= 0 {
continue
}
if cov > 1 {
cov = 1
}
blendPixel(img, x, y, c, alpha*cov)
}
}
}
// drawVLine / drawHLine 画 1px 网格线(半透明,不抢气泡的视觉权重)。
func drawVLine(img *image.RGBA, x float64, c color.RGBA) {
xi := int(math.Round(x))
if xi < 0 || xi >= img.Bounds().Dx() {
return
}
for y := 0; y < img.Bounds().Dy(); y++ {
blendPixel(img, xi, y, c, 0.9)
}
}
func drawHLine(img *image.RGBA, y float64, c color.RGBA) {
yi := int(math.Round(y))
if yi < 0 || yi >= img.Bounds().Dy() {
return
}
for x := 0; x < img.Bounds().Dx(); x++ {
blendPixel(img, x, yi, c, 0.9)
}
}
// blendPixel 把颜色以 alpha 混合到目标像素上。
func blendPixel(img *image.RGBA, x, y int, c color.RGBA, alpha float64) {
if alpha <= 0 {
return
}
if alpha > 1 {
alpha = 1
}
i := img.PixOffset(x, y)
dst := img.Pix[i : i+4 : i+4]
img.Pix[i+0] = uint8(float64(c.R)*alpha + float64(dst[0])*(1-alpha))
img.Pix[i+1] = uint8(float64(c.G)*alpha + float64(dst[1])*(1-alpha))
img.Pix[i+2] = uint8(float64(c.B)*alpha + float64(dst[2])*(1-alpha))
img.Pix[i+3] = 0xFF
}
+212
View File
@@ -0,0 +1,212 @@
// 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 report
import (
"bytes"
"image/png"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/security/geoip"
)
// sampleSummary 构造一份含两国三城的汇总,供渲染测试复用。
func sampleSummary() *Summary {
loc := time.Local
start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc)
period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)}
return Build(Input{
Period: period,
Sources: []*db.HoneypotSourceAgg{
{IP: "203.0.113.1", Hits: 60, Banned: 60, Creds: 5, Provider: "微软 Azure(云厂商)"},
{IP: "203.0.113.2", Hits: 30, Banned: 30, Creds: 2, Provider: "未识别(普通网络)"},
{IP: "198.51.100.9", Hits: 10, Banned: 10},
},
Paths: []*db.HoneypotPathStat{{Path: "/wp-login.php", Count: 70}},
Geo: map[string]geoip.Geo{
"203.0.113.1": {Country: "美国", CountryCode: "US", Region: "Virginia", City: "Ashburn", Lat: 39.04, Lon: -77.49, HasCoords: true, ISP: "Amazon", ASN: "AS14618"},
"203.0.113.2": {Country: "中国", CountryCode: "CN", Region: "北京市", City: "北京", Lat: 39.9, Lon: 116.4, HasCoords: true, ISP: "联通"},
"198.51.100.9": {Country: "德国", CountryCode: "DE", Region: "Berlin", City: "Berlin", Lat: 52.5, Lon: 13.4, HasCoords: true},
},
Now: start,
})
}
// TestRenderPNG 覆盖位图地图:必须是合法 PNG、尺寸固定、非空白。
//
// 空白图是这里最容易出的问题:坐标算错时所有气泡会画到画布外,
// 得到的仍是一张「合法但全白」的 PNG,肉眼要打开附件才发现。
func TestRenderPNG(t *testing.T) {
sum := sampleSummary()
data, err := RenderPNG(sum)
require.NoError(t, err)
require.True(t, len(data) > 1024, "PNG 太小,怀疑是空白图")
img, err := png.Decode(bytes.NewReader(data))
require.NoError(t, err)
require.Equal(t, mapWidth, img.Bounds().Dx())
require.Equal(t, mapHeight, img.Bounds().Dy())
// 统计与背景色不同的像素:气泡、网格、文字都会贡献非背景像素。
bg := img.At(1, 1)
diff := 0
for y := 0; y < mapHeight; y += 4 {
for x := 0; x < mapWidth; x += 4 {
r1, g1, b1, _ := img.At(x, y).RGBA()
r2, g2, b2, _ := bg.RGBA()
if r1 != r2 || g1 != g2 || b1 != b2 {
diff++
}
}
}
require.Greater(t, diff, 500, "画面上几乎没有非背景像素,地图可能画空了")
}
// TestRenderPNGDropsBadCoords 覆盖越界坐标:不能画到 0,0 去。
func TestRenderPNGDropsBadCoords(t *testing.T) {
loc := time.Local
start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc)
period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)}
sum := Build(Input{
Period: period,
Sources: []*db.HoneypotSourceAgg{{IP: "203.0.113.1", Hits: 5}},
Geo: map[string]geoip.Geo{
// HasCoords=true 但坐标是 (0,0):IP 库对「只知道国家」的常见输出。
"203.0.113.1": {Country: "未知海域", CountryCode: "XX", Lat: 0, Lon: 0, HasCoords: true},
},
Now: start,
})
require.Empty(t, bubbles(sum), "(0,0) 必须被丢弃,否则会画到几内亚湾")
}
// TestRenderSVG 覆盖 SVG:每个有坐标的城市都应该有一个 circle。
func TestRenderSVG(t *testing.T) {
sum := sampleSummary()
svg := RenderSVG(sum)
require.Contains(t, svg, "<svg")
// 三个城市 → 三个气泡
require.Equal(t, 3, strings.Count(svg, "<circle"))
require.Contains(t, svg, "北京")
// 投影后的坐标要落在画布内(0..960 / 0..480),而不是 NaN 或负数。
require.Contains(t, svg, `cx="790.4"`) // 北京 116.4E → x≈790
// 不允许出现任何国界相关元素:自绘底图不画政治边界,避免画错。
require.NotContains(t, svg, "<path")
}
// TestRenderHTMLNoKey 覆盖「未配腾讯 key」:给出申请指引,且不能偷偷塞一个 key。
func TestRenderHTMLNoKey(t *testing.T) {
sum := sampleSummary()
html := RenderHTML(sum, HTMLOptions{})
require.Contains(t, html, "未配置腾讯地图 key")
require.Contains(t, html, "lbs.qq.com")
require.NotContains(t, html, "map.qq.com/api/gljs", "没有 key 时不应加载腾讯 SDK")
// 离线 SVG 必须始终存在
require.Contains(t, html, "<svg")
// 关键数字要出现在报告里
require.Contains(t, html, "100") // 总命中 60+30+10
}
// TestRenderHTMLWithKey 覆盖「配了 key」:加载腾讯 SDK 并带正确 key。
func TestRenderHTMLWithKey(t *testing.T) {
sum := sampleSummary()
html := RenderHTML(sum, HTMLOptions{TencentMapKey: "TEST-KEY-123"})
require.Contains(t, html, "map.qq.com/api/gljs")
require.Contains(t, html, "TEST-KEY-123")
// 合规:不能出现被禁止的境外底图(大小写无关地查一遍)。
lower := strings.ToLower(html)
for _, banned := range []string{"googleapis.com/maps", "mapbox", "openstreetmap", "bing.com/maps"} {
require.NotContains(t, lower, banned)
}
}
// TestRenderHTMLEmpty 覆盖空窗口:不发一堆空表格,给一句结论。
func TestRenderHTMLEmpty(t *testing.T) {
loc := time.Local
start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc)
sum := Build(Input{
Period: Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)},
Now: start,
})
html := RenderHTML(sum, HTMLOptions{})
require.Contains(t, html, "本期没有捕获到任何蜜罐命中")
require.NotContains(t, html, "<table>", "空报告不该渲染数据表")
}
// TestBubbleRadiusMonotonic 覆盖气泡缩放:命中多的必须更大,且线性增长不会
// 把长尾压得看不见(sqrt 缩放)。
func TestBubbleRadiusMonotonic(t *testing.T) {
small := bubbleRadius(1, 1000)
mid := bubbleRadius(100, 1000)
big := bubbleRadius(1000, 1000)
require.Less(t, small, mid)
require.Less(t, mid, big)
// 1/1000 的命中也要有可辨识的尺寸
require.Greater(t, small, 3.0)
require.Less(t, big, 21.0)
}
// TestLabelPackerNoOverlap 是「AU 305」那个 bug 的回归测试。
//
// 同一个都市圈的不同城市坐标只差 0.01°,两行标签会精确重叠,叠出一个
// 看起来像真实数字的假字符串(实测把 "AU 55" 与 "AU 30" 叠成了 "AU 305")。
// 读图的人不会怀疑「305 是从哪来的」,只会把它当结论 —— 所以宁可少标。
func TestLabelPackerNoOverlap(t *testing.T) {
p := newLabelPacker(mapWidth, mapHeight)
// 三个几乎重合的气泡:第一个标在右侧、第二个自动换到左侧(都清晰可读),
// 第三个两侧都被占就只能放弃 —— 关键是它们不能叠在一起。
for i := 0; i < 3; i++ {
x, y := project(-27.48+float64(i)*0.01, 153.02+float64(i)*0.01)
p.place(x, y, 10, basicfontWidth("AU 55"), 13)
}
require.Len(t, p.placed, 2, "三个重合的气泡最多只能放下两个不重叠的标签")
requireNoOverlap(t, p.placed)
// 远离的气泡必须都能标上
p2 := newLabelPacker(mapWidth, mapHeight)
require.True(t, mustPlace(t, p2, 100, 100))
require.True(t, mustPlace(t, p2, 400, 300))
require.Len(t, p2.placed, 2)
requireNoOverlap(t, p2.placed)
}
// requireNoOverlap 断言任意两个标签框都不相交 —— 这是标签层的唯一不变量。
func requireNoOverlap(t *testing.T, boxes []labelBox) {
t.Helper()
for i := 0; i < len(boxes); i++ {
for j := i + 1; j < len(boxes); j++ {
a, b := boxes[i], boxes[j]
overlap := a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1
require.False(t, overlap, "标签 %d 与 %d 重叠:%v / %v", i, j, a, b)
}
}
}
// TestLabelPackerClampsToCanvas 靠右边缘的气泡要把标签放到左边,而不是画出画布。
func TestLabelPackerClampsToCanvas(t *testing.T) {
p := newLabelPacker(mapWidth, mapHeight)
w := basicfontWidth("AU 55")
// 气泡贴着右边缘:右侧放不下,应自动改到左侧
x, _, ok := p.place(mapWidth-12, 240, 10, w, 13)
require.True(t, ok)
require.LessOrEqual(t, x+w, float64(mapWidth-2))
// 两条边都放不下(画布外)应放弃
p2 := newLabelPacker(20, 20)
_, _, ok = p2.place(10, 10, 40, w, 13)
require.False(t, ok)
}
func mustPlace(t *testing.T, p *labelPacker, bx, by float64) bool {
t.Helper()
_, _, ok := p.place(bx, by, 10, basicfontWidth("US 90"), 13)
return ok
}
+115
View File
@@ -0,0 +1,115 @@
// 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 report 生成周期性安全状况汇总(周报 / 月报)。
//
// 它回答的问题与蜜罐告警不同:告警是「刚刚有人在你门口试钥匙」,报告是
// 「这一周一共有多少人在试、从哪些国家来、哪几个网段最执着、我们在拦什么」。
//
// 产物分三份,各有各的用途:
// - 邮件正文里的 PNG 气泡地图:邮件客户端不执行 JS、多数还会剥离内联 SVG,
// 只有位图能保证「打开就看得到」;
// - 附件 HTML:完整报告(排行表 + 内联 SVG 地图),离线可看;
// - 附件 HTML 里的腾讯地图:配了 key 才有,可缩放、可点开看明细。
//
// ⚠️ 地图的合规边界:本包自己画的地图**只画经纬网与气泡,不画任何国界**。
// 国界、台湾、南海诸岛的绘制必须符合国家标准,自绘底图极容易画错,
// 所以自绘图一律不含政治边界;需要真实底图时用腾讯地图(底图由持牌厂商提供)。
package report
import (
"fmt"
"time"
)
// 周期类型。
const (
KindWeek = "week"
KindMonth = "month"
)
// Period 一个报告周期,时间窗口为 [Start, End)。
//
// 左闭右开是刻意的:相邻两期既不能重叠(同一条记录算两次),也不能漏掉
// 恰好落在边界整点上的记录。
type Period struct {
Kind string
Key string
Start time.Time
End time.Time
}
// Label 返回人类可读的窗口描述,如 "2026-10-19 ~ 2026-10-25"。
func (p Period) Label() string {
// End 是开区间端点,展示时退一秒落回周期内的最后一天,避免出现
// 「10-19 ~ 10-26」这种让人以为多算了一天的区间。
last := p.End.Add(-time.Second)
if p.Kind == KindMonth {
return p.Start.Format("2006-01")
}
return fmt.Sprintf("%s ~ %s", p.Start.Format("2006-01-02"), last.Format("2006-01-02"))
}
// Title 返回报告标题用的中文描述。
func (p Period) Title() string {
if p.Kind == KindMonth {
return p.Start.Format("2006 年 1 月") + "安全状况月报"
}
_, week := p.End.Add(-time.Second).ISOWeek()
return fmt.Sprintf("%s 第 %d 周安全状况周报", p.Start.Format("2006 年 1 月"), week)
}
// Filename 返回报告文件名(不含扩展名)。
func (p Period) Filename() string {
if p.Kind == KindMonth {
return "security-month-" + p.Start.Format("2006-01")
}
return "security-week-" + p.Start.Format("2006-01-02")
}
// startOfWeek 返回 t 所在周的周一 00:00(本地时区)。
func startOfWeek(t time.Time) time.Time {
wd := int(t.Weekday()) // Sunday=0 … Saturday=6
if wd == 0 {
wd = 7
}
y, m, d := t.Date()
return time.Date(y, m, d, 0, 0, 0, 0, t.Location()).AddDate(0, 0, -(wd - 1))
}
// startOfMonth 返回 t 所在月的 1 日 00:00。
func startOfMonth(t time.Time) time.Time {
y, m, _ := t.Date()
return time.Date(y, m, 1, 0, 0, 0, 0, t.Location())
}
// PreviousWeek 返回 now 之前的**完整**一周。
//
// 口径是「最近一个已结束的自然周(周一起算)」:在周一凌晨跑,拿到的是上周;
// 在周三跑(补发),拿到的仍然是上周而不是「本周至今」——
// 报告必须覆盖完整周期,否则两次运行之间数字对不上。
func PreviousWeek(now time.Time) Period {
end := startOfWeek(now)
start := end.AddDate(0, 0, -7)
// 用周期内最后一天算 ISO 周号:直接用 end 会被算成下一周。
y, w := end.Add(-time.Second).ISOWeek()
return Period{
Kind: KindWeek,
Key: fmt.Sprintf("week:%04d-W%02d", y, w),
Start: start,
End: end,
}
}
// PreviousMonth 返回 now 之前的完整一个自然月。
func PreviousMonth(now time.Time) Period {
end := startOfMonth(now)
start := end.AddDate(0, -1, 0)
return Period{
Kind: KindMonth,
Key: "month:" + start.Format("2006-01"),
Start: start,
End: end,
}
}
+101
View File
@@ -0,0 +1,101 @@
// 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 report
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestPreviousWeek 覆盖周报窗口:[周一 00:00, 下周一 00:00),且与「现在是周几」无关。
func TestPreviousWeek(t *testing.T) {
loc := time.Local
cases := []struct {
name string
now time.Time
wantStart time.Time
wantEnd time.Time
}{
{
name: "周一凌晨(报告应在此时发出,覆盖上周)",
now: time.Date(2026, 10, 26, 1, 0, 0, 0, loc), // 周一
wantStart: time.Date(2026, 10, 19, 0, 0, 0, 0, loc),
wantEnd: time.Date(2026, 10, 26, 0, 0, 0, 0, loc),
},
{
name: "周三补发:仍然覆盖「上周」而不是本周至今",
now: time.Date(2026, 10, 28, 15, 30, 0, 0, loc), // 周三
wantStart: time.Date(2026, 10, 19, 0, 0, 0, 0, loc),
wantEnd: time.Date(2026, 10, 26, 0, 0, 0, 0, loc),
},
{
name: "周日晚间:还没到本周结束,仍覆盖上周",
now: time.Date(2026, 10, 25, 23, 59, 0, 0, loc), // 周日
wantStart: time.Date(2026, 10, 12, 0, 0, 0, 0, loc),
wantEnd: time.Date(2026, 10, 19, 0, 0, 0, 0, loc),
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
p := PreviousWeek(c.now)
require.Equal(t, c.wantStart, p.Start)
require.Equal(t, c.wantEnd, p.End)
// 窗口必须正好 7 天,且左闭右开
require.Equal(t, 7*24*time.Hour, p.End.Sub(p.Start))
})
}
}
// TestPreviousWeekKey 覆盖 ISO 周号,特别是跨年那一周。
func TestPreviousWeekKey(t *testing.T) {
loc := time.Local
// 2027-01-01 是周五,属于 ISO 2026 年第 53 周;
// 2026-12-28(周一)~ 2027-01-03(周日)正是这一周。
// 若用 end 去算周号会得到 2027-W01,导致这一周的台账键错位。
p := PreviousWeek(time.Date(2027, 1, 4, 9, 0, 0, 0, loc)) // 周一
require.Equal(t, "week:2026-W53", p.Key)
require.Equal(t, time.Date(2026, 12, 28, 0, 0, 0, 0, loc), p.Start)
// 跨年另一侧:2026-01-01 所在周是 2026-W01,前一周属于 2025-W52。
p2 := PreviousWeek(time.Date(2026, 1, 5, 9, 0, 0, 0, loc))
require.Equal(t, "week:2026-W01", p2.Key)
}
// TestPreviousMonth 覆盖月报窗口,含 31 天/30 天/2 月。
func TestPreviousMonth(t *testing.T) {
loc := time.Local
p := PreviousMonth(time.Date(2026, 11, 3, 10, 0, 0, 0, loc))
require.Equal(t, "month:2026-10", p.Key)
require.Equal(t, time.Date(2026, 10, 1, 0, 0, 0, 0, loc), p.Start)
require.Equal(t, time.Date(2026, 11, 1, 0, 0, 0, 0, loc), p.End)
// 闰年 2 月必须有 29 天,且不能因为「减一个月」跑到 3 月去。
p2 := PreviousMonth(time.Date(2024, 3, 1, 0, 30, 0, 0, loc))
require.Equal(t, "month:2024-02", p2.Key)
require.Equal(t, time.Date(2024, 2, 1, 0, 0, 0, 0, loc), p2.Start)
require.Equal(t, 29*24*time.Hour, p2.End.Sub(p2.Start))
// 5 月 31 日往前一个月不能变成「4 月 31 日」这种不存在的日期:
// 窗口是 [4-01, 5-01),不需要做任何日期加减。
p3 := PreviousMonth(time.Date(2026, 5, 31, 12, 0, 0, 0, loc))
require.Equal(t, time.Date(2026, 4, 1, 0, 0, 0, 0, loc), p3.Start)
require.Equal(t, time.Date(2026, 5, 1, 0, 0, 0, 0, loc), p3.End)
}
// TestPeriodLabel 覆盖展示用的窗口描述:右端点要退一天,别让人以为多算了一天。
func TestPeriodLabel(t *testing.T) {
loc := time.Local
p := PreviousWeek(time.Date(2026, 10, 26, 1, 0, 0, 0, loc))
require.Equal(t, "2026-10-19 ~ 2026-10-25", p.Label())
m := PreviousMonth(time.Date(2026, 11, 3, 10, 0, 0, 0, loc))
require.Equal(t, "2026-10", m.Label())
require.Equal(t, "security-month-2026-10", m.Filename())
}
+128
View File
@@ -0,0 +1,128 @@
package storage
import (
"fmt"
"net/url"
"strings"
"unicode"
)
// ObjectKeyFromCDNURL 将属于 cdnBase 的 HTTPS URL 转成原始 S3 对象 key。
// cdnBase 可省略 scheme,也可带 base path。外站或不属于 base path 的 URL
// 返回 owned=false 且不报错;格式非法或试图伪装路径的 URL 返回错误。
func ObjectKeyFromCDNURL(rawURL, cdnBase string) (key string, owned bool, err error) {
base, err := parseCDNBase(cdnBase)
if err != nil {
return "", false, err
}
parsed, err := parseCDNURL(rawURL, false)
if err != nil {
return "", false, err
}
if !strings.EqualFold(parsed.Host, base.Host) {
return "", false, nil
}
basePath := base.Path
if basePath != "" {
if parsed.Path == basePath {
return "", false, fmt.Errorf("CDN URL does not contain an object key")
}
prefix := basePath + "/"
if !strings.HasPrefix(parsed.Path, prefix) {
return "", false, nil
}
key = strings.TrimPrefix(parsed.Path, prefix)
} else {
key = strings.TrimPrefix(parsed.Path, "/")
}
if key == "" {
return "", false, fmt.Errorf("CDN URL does not contain an object key")
}
// 只去掉 cdnBase 的路径和前导分隔符;不添加或移除 bucket,也不 Clean,
// 因而返回的解码路径仍是实际 S3 key。
return key, true, nil
}
func parseCDNBase(cdnBase string) (*url.URL, error) {
if cdnBase == "" {
return nil, fmt.Errorf("CDN base URL is empty")
}
rawBase := cdnBase
if strings.HasPrefix(rawBase, "//") {
rawBase = "https:" + rawBase
} else if !strings.Contains(rawBase, "://") {
rawBase = "https://" + rawBase
}
return parseCDNURL(rawBase, true)
}
func parseCDNURL(rawURL string, isBase bool) (*url.URL, error) {
if rawURL == "" {
return nil, fmt.Errorf("URL is empty")
}
if strings.Contains(rawURL, `\`) || containsControl(rawURL) {
return nil, fmt.Errorf("URL contains a backslash or control character")
}
parsed, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("parse URL: %w", err)
}
if !parsed.IsAbs() || !strings.EqualFold(parsed.Scheme, "https") || parsed.Host == "" ||
parsed.Hostname() == "" || parsed.Opaque != "" {
return nil, fmt.Errorf("URL must be an absolute HTTPS URL")
}
if parsed.User != nil {
return nil, fmt.Errorf("URL must not contain userinfo")
}
if parsed.RawQuery != "" || parsed.ForceQuery {
return nil, fmt.Errorf("URL must not contain a query")
}
if parsed.Fragment != "" || strings.Contains(rawURL, "#") {
return nil, fmt.Errorf("URL must not contain a fragment")
}
// 必须在 url.Parse 的 Path 解码之外检查 EscapedPath:编码斜杠在 Path 中
// 与普通分隔符无法区分,若放行会导致 CDN 与 S3 对 key 的解释不一致。
escapedPath := strings.ToLower(parsed.EscapedPath())
if strings.Contains(escapedPath, "%2f") || strings.Contains(escapedPath, "%5c") {
return nil, fmt.Errorf("URL path contains an encoded slash or backslash")
}
if strings.Contains(parsed.Path, `\`) || containsControl(parsed.Path) {
return nil, fmt.Errorf("URL path contains a backslash or control character")
}
if isBase && strings.HasSuffix(parsed.Path, "/") {
// 配置中的 CDN base 允许一个末尾斜杠;其余空路径段仍会被拒绝。
parsed.Path = strings.TrimSuffix(parsed.Path, "/")
parsed.RawPath = ""
}
if parsed.Path == "" {
if isBase {
return parsed, nil
}
return nil, fmt.Errorf("URL path is empty")
}
if !strings.HasPrefix(parsed.Path, "/") {
return nil, fmt.Errorf("URL path must be absolute")
}
for _, segment := range strings.Split(strings.TrimPrefix(parsed.Path, "/"), "/") {
if segment == "" {
return nil, fmt.Errorf("URL path contains an empty segment")
}
if segment == "." || segment == ".." {
return nil, fmt.Errorf("URL path contains a dot segment")
}
}
return parsed, nil
}
func containsControl(value string) bool {
for _, r := range value {
if unicode.IsControl(r) {
return true
}
}
return false
}
+112
View File
@@ -0,0 +1,112 @@
package storage
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestObjectKeyFromCDNURL(t *testing.T) {
tests := []struct {
name string
rawURL string
cdnBase string
wantKey string
owned bool
wantErr bool
}{
{
name: "scheme less base", rawURL: "https://cdn.example.com/2026/11/pic.webp",
cdnBase: "cdn.example.com", wantKey: "2026/11/pic.webp", owned: true,
},
{
name: "base path", rawURL: "https://cdn.example.com/assets/2026/pic.webp",
cdnBase: "https://cdn.example.com/assets", wantKey: "2026/pic.webp", owned: true,
},
{
name: "base trailing slash and escaped space", rawURL: "https://cdn.example.com/tamabox/a%20b.webp",
cdnBase: "cdn.example.com/tamabox/", wantKey: "a b.webp", owned: true,
},
{
name: "host case insensitive", rawURL: "https://CDN.EXAMPLE.COM/a.webp",
cdnBase: "cdn.example.com", wantKey: "a.webp", owned: true,
},
{
name: "preserve bucket prefix", rawURL: "https://cdn.example.com/tamabox/2026/a.webp",
cdnBase: "cdn.example.com", wantKey: "tamabox/2026/a.webp", owned: true,
},
{
name: "external host not owned", rawURL: "https://outside.example.com/a.webp",
cdnBase: "cdn.example.com", owned: false,
},
{
name: "host suffix attack not owned", rawURL: "https://cdn.example.com.evil.test/a.webp",
cdnBase: "cdn.example.com", owned: false,
},
{
name: "base path complete segment", rawURL: "https://cdn.example.com/assets2/a.webp",
cdnBase: "cdn.example.com/assets", owned: false,
},
{
name: "base path case sensitive", rawURL: "https://cdn.example.com/Assets/a.webp",
cdnBase: "cdn.example.com/assets", owned: false,
},
{name: "HTTP rejected", rawURL: "http://cdn.example.com/a", cdnBase: "cdn.example.com", wantErr: true},
{name: "relative rejected", rawURL: "/a", cdnBase: "cdn.example.com", wantErr: true},
{name: "scheme relative rejected", rawURL: "//cdn.example.com/a", cdnBase: "cdn.example.com", wantErr: true},
{name: "userinfo rejected", rawURL: "https://[email protected]/a", cdnBase: "cdn.example.com", wantErr: true},
{name: "query rejected", rawURL: "https://cdn.example.com/a?x=1", cdnBase: "cdn.example.com", wantErr: true},
{name: "empty query rejected", rawURL: "https://cdn.example.com/a?", cdnBase: "cdn.example.com", wantErr: true},
{name: "fragment rejected", rawURL: "https://cdn.example.com/a#fragment", cdnBase: "cdn.example.com", wantErr: true},
{name: "empty fragment rejected", rawURL: "https://cdn.example.com/a#", cdnBase: "cdn.example.com", wantErr: true},
{name: "backslash rejected", rawURL: `https://cdn.example.com/a\b`, cdnBase: "cdn.example.com", wantErr: true},
{name: "encoded backslash rejected", rawURL: "https://cdn.example.com/a%5Cb", cdnBase: "cdn.example.com", wantErr: true},
{name: "encoded slash rejected", rawURL: "https://cdn.example.com/a%2Fb", cdnBase: "cdn.example.com", wantErr: true},
{name: "control rejected", rawURL: "https://cdn.example.com/a\x7fb", cdnBase: "cdn.example.com", wantErr: true},
{name: "encoded control rejected", rawURL: "https://cdn.example.com/a%0Ab", cdnBase: "cdn.example.com", wantErr: true},
{name: "dot rejected", rawURL: "https://cdn.example.com/a/./b", cdnBase: "cdn.example.com", wantErr: true},
{name: "dot dot rejected", rawURL: "https://cdn.example.com/a/../b", cdnBase: "cdn.example.com", wantErr: true},
{name: "encoded dot dot rejected", rawURL: "https://cdn.example.com/a/%2E%2E/b", cdnBase: "cdn.example.com", wantErr: true},
{name: "empty path rejected", rawURL: "https://cdn.example.com", cdnBase: "cdn.example.com", wantErr: true},
{name: "root path rejected", rawURL: "https://cdn.example.com/", cdnBase: "cdn.example.com", wantErr: true},
{name: "empty middle segment rejected", rawURL: "https://cdn.example.com/a//b", cdnBase: "cdn.example.com", wantErr: true},
{name: "empty last segment rejected", rawURL: "https://cdn.example.com/a/", cdnBase: "cdn.example.com", wantErr: true},
{name: "missing key below base rejected", rawURL: "https://cdn.example.com/assets", cdnBase: "cdn.example.com/assets", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
key, owned, err := ObjectKeyFromCDNURL(tt.rawURL, tt.cdnBase)
if tt.wantErr {
require.Error(t, err)
assert.False(t, owned)
assert.Empty(t, key)
return
}
require.NoError(t, err)
assert.Equal(t, tt.owned, owned)
assert.Equal(t, tt.wantKey, key)
})
}
}
func TestObjectKeyFromCDNURLRejectsInvalidBase(t *testing.T) {
for _, base := range []string{
"",
"http://cdn.example.com",
"cdn.example.com?x=1",
"cdn.example.com#fragment",
"cdn.example.com/assets/../other",
"cdn.example.com/assets//other",
"cdn.example.com/assets%2Fother",
"[email protected]",
} {
t.Run(base, func(t *testing.T) {
key, owned, err := ObjectKeyFromCDNURL("https://cdn.example.com/assets/a", base)
require.Error(t, err)
assert.False(t, owned)
assert.Empty(t, key)
})
}
}
+6 -25
View File
@@ -5,9 +5,6 @@ import (
"sync"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/pkg/errors"
@@ -31,29 +28,13 @@ func GetS3Client() (*s3.Client, error) {
s3ClientErr = errors.New("S3 endpoint not configured")
return
}
r2Resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
return aws.Endpoint{
URL: conf.Upload.ImageEndpoint,
HostnameImmutable: true,
Source: aws.EndpointSourceCustom,
}, nil
})
cfg, err := config.LoadDefaultConfig(context.Background(),
config.WithEndpointResolverWithOptions(r2Resolver),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
conf.Upload.ImageAccessID,
conf.Upload.ImageAccessSecret,
"",
)),
config.WithRegion("auto"),
s3Client, s3ClientErr = NewS3Client(
context.Background(),
conf.Upload.ImageEndpoint,
conf.Upload.ImageBucket,
conf.Upload.ImageAccessID,
conf.Upload.ImageAccessSecret,
)
if err != nil {
s3ClientErr = errors.Wrap(err, "load S3 config")
return
}
s3Client = s3.NewFromConfig(cfg)
})
return s3Client, s3ClientErr
}
+7 -9
View File
@@ -8,10 +8,8 @@ import (
"fmt"
"io"
"mime/multipart"
"time"
"github.com/pkg/errors"
"github.com/thanhpk/randstr"
"github.com/wuhan005/gadget"
"github.com/tamakyi/TamaBox/internal/conf"
@@ -19,7 +17,7 @@ import (
// UploadPictureToOSS upload user's avatar or background image to OSS.
// It returns the uploaded asset URL.
func UploadPictureToOSS(file multipart.File, _ *multipart.FileHeader) (string, error) {
func UploadPictureToOSS(file multipart.File, fileHeader *multipart.FileHeader) (string, error) {
// 读取文件头,校验是否为图片
header := make([]byte, 512)
n, err := file.Read(header)
@@ -43,12 +41,12 @@ func UploadPictureToOSS(file multipart.File, _ *multipart.FileHeader) (string, e
return "", errors.Wrap(err, "get OSS bucket")
}
now := time.Now()
year := now.Year()
month := int(now.Month())
day := now.Day()
key := fmt.Sprintf("%s%d/%02d/%02d/%s", PictureKeyPrefix, year, month, day, randstr.Hex(15))
// OSS 通道原样上传原图(不转 WebP),扩展名沿用原始文件名。
ext := ""
if fileHeader != nil {
ext = originalExt(fileHeader.Filename)
}
key := uploadKey(ext)
if err := gadget.Retry(5, func() error {
if err := bucket.PutObject(key, file); err != nil {

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