Compare commits
188
Commits
e94c2b57ce
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fdc3df22d | ||
|
|
7ffab24025 | ||
|
|
25aee35030 | ||
|
|
7636456d4e | ||
|
|
937e688cfa | ||
|
|
3e7719f200 | ||
|
|
c633789a92 | ||
|
|
ea9ba3e8fc | ||
|
|
036156b6fb | ||
|
|
48eea98a27 | ||
|
|
d690355cdc | ||
|
|
08bbd17751 | ||
|
|
c6966138b4 | ||
|
|
b9060c982b | ||
|
|
1e9ffc0718 | ||
|
|
62edf88029 | ||
|
|
693b1102c2 | ||
|
|
c9b426acbb | ||
|
|
87176516f3 | ||
|
|
7f86db8937 | ||
|
|
f9aaef17e5 | ||
|
|
4e574ac64a | ||
|
|
5a05a61647 | ||
|
|
4fcaa197cf | ||
|
|
5def41d0f8 | ||
|
|
d5d25c804b | ||
|
|
89e263cbd4 | ||
|
|
03cb5ddcf0 | ||
|
|
f2e0c43083 | ||
|
|
0124e392c7 | ||
|
|
0041cc51f8 | ||
|
|
d4e6da76f1 | ||
|
|
c971d99bdb | ||
|
|
ced4c1635c | ||
|
|
11d6e701ee | ||
|
|
46932b4c53 | ||
|
|
430c6ef6d8 | ||
|
|
e807dea218 | ||
|
|
7802bb2078 | ||
|
|
a3cf99a8da | ||
|
|
79199ac3cd | ||
|
|
af39651734 | ||
|
|
219adefc95 | ||
|
|
8380fc9e2b | ||
|
|
cbb8908275 | ||
|
|
0226956feb | ||
|
|
0c282914c2 | ||
|
|
64436d9d61 | ||
|
|
a5441c99fd | ||
|
|
c680671b7e | ||
|
|
812b70cb4c | ||
|
|
86ea96e5ca | ||
|
|
6fad0cbe50 | ||
|
|
0318ba81b4 | ||
|
|
9d3f5a72fa | ||
|
|
307bd94ed1 | ||
|
|
7225f680d4 | ||
|
|
db97ac13ed | ||
|
|
811578dcf1 | ||
|
|
edde075406 | ||
|
|
29ddee07db | ||
|
|
90a284a3dd | ||
|
|
818fa74160 | ||
|
|
aa758884f9 | ||
|
|
675cecb21d | ||
|
|
8f849123f7 | ||
|
|
d5398f9c2f | ||
|
|
310f85156a | ||
|
|
d0a1bb8435 | ||
|
|
72f08cd7c9 | ||
|
|
9fb003cc39 | ||
|
|
b35c2a8f45 | ||
|
|
a215c2ba2b | ||
|
|
d51629ef2f | ||
|
|
80e042d154 | ||
|
|
0054e508b7 | ||
|
|
90c0cfd27f | ||
|
|
ebc4586ef4 | ||
|
|
1addeb56b8 | ||
|
|
0181fc5c66 | ||
|
|
b939d26ff0 | ||
|
|
bd02eb7ed2 | ||
|
|
8fcb23f7b1 | ||
|
|
e4810040a7 | ||
|
|
8941744621 | ||
|
|
2981731d4f | ||
|
|
33fa643465 | ||
|
|
e1e840f895 | ||
|
|
c9f22cb81b | ||
|
|
5c0de805c6 | ||
|
|
48a1938fb2 | ||
|
|
8daa5b0bec | ||
|
|
12c4f3e1be | ||
|
|
9c961b3c05 | ||
|
|
6c21184733 | ||
|
|
f37ff28a46 | ||
|
|
34a262091b | ||
|
|
e9ac72dd7d | ||
|
|
1f29d3eb2e | ||
|
|
27856dd4a2 | ||
|
|
252b22a342 | ||
|
|
001306d940 | ||
|
|
dc90d96bde | ||
|
|
8364a2fa3d | ||
|
|
2d13c31d62 | ||
|
|
db4b3fa095 | ||
|
|
54df166d4e | ||
|
|
3d453749eb | ||
|
|
74312fba58 | ||
|
|
846bb8a3d9 | ||
|
|
5e8decab8f | ||
|
|
c29e1d071f | ||
|
|
566b13eedd | ||
|
|
bfdc5089c5 | ||
|
|
d300167a30 | ||
|
|
cbb0c51a14 | ||
|
|
63e851ddae | ||
|
|
7d52155411 | ||
|
|
79051acae8 | ||
|
|
2d4609dc0e | ||
|
|
6c014b3ad2 | ||
|
|
3d0239d8f1 | ||
|
|
1b027df62e | ||
|
|
4bd600fb46 | ||
|
|
d2a5177afe | ||
|
|
937a8901b2 | ||
|
|
927f378d9d | ||
|
|
c3d34b53d2 | ||
|
|
bf1c994c8f | ||
|
|
8ffa7b0ba7 | ||
|
|
7b532574bb | ||
|
|
ef18d91920 | ||
|
|
ddee221de2 | ||
|
|
feb5b1f78d | ||
|
|
b150e4b49f | ||
|
|
84c2cab278 | ||
|
|
0eff8dffdb | ||
|
|
15aebd6d19 | ||
|
|
2e9bd6bb73 | ||
|
|
fb94b8c204 | ||
|
|
e95671f2fe | ||
|
|
b916a7f37a | ||
|
|
33e31cf4dc | ||
|
|
f876aa6f4f | ||
|
|
2f2e00ad65 | ||
|
|
96e26cf0fa | ||
|
|
59ffcb5096 | ||
|
|
bacd1b40e8 | ||
|
|
3717b77ef8 | ||
|
|
a6059bec1b | ||
|
|
1a8938f0e1 | ||
|
|
0c39afff3a | ||
|
|
38cd41afde | ||
|
|
3202ef9821 | ||
|
|
e9fba8b9ed | ||
|
|
86dc8384bc | ||
|
|
d7c1782bb6 | ||
|
|
5c7707ec0d | ||
|
|
b165995a1c | ||
|
|
d47329d505 | ||
|
|
b9a8afb2ec | ||
|
|
6789193052 | ||
|
|
899b6d7f32 | ||
|
|
dba991aa9d | ||
|
|
12a37e726f | ||
|
|
4592b6ef24 | ||
|
|
bd613a6f5a | ||
|
|
84f8afe8b1 | ||
|
|
8a0e58734c | ||
|
|
efd5fce9a7 | ||
|
|
7e0e1cba49 | ||
|
|
7a6417c33d | ||
|
|
317c96ca17 | ||
|
|
bd81398a06 | ||
|
|
d14d26df17 | ||
|
|
52f0712eda | ||
|
|
efb19606a7 | ||
|
|
6111546bbd | ||
|
|
6bd90779f8 | ||
|
|
da7e76a7db | ||
|
|
e4457e3da4 | ||
|
|
6ffc87e4a8 | ||
|
|
5ee2dc1540 | ||
|
|
0e0c16f786 | ||
|
|
adcc42cc8c | ||
|
|
e683e798aa | ||
|
|
8e2325eda5 | ||
|
|
e3e59e7c69 |
+37
-1
@@ -28,4 +28,40 @@ go.work.sum
|
||||
# otherfile
|
||||
./workbuddy
|
||||
./scripts
|
||||
./outputs
|
||||
# 注意:gitignore 不认 "./" 前缀,/outputs 才是「仓库根下的 outputs」。
|
||||
# outputs 里全是 TestGenPreview / 主题生成器 / 截图这类一次性产物,
|
||||
# 每次跑都会变,跟版本库无关。
|
||||
/outputs/
|
||||
|
||||
# 蜜罐凭据取证文件:明文账号密码,绝不能进版本库。
|
||||
# 默认目录由 [security] honeypot_cred_dir 决定(缺省 data/honeypot-creds),
|
||||
# 若改成别的盘外路径则不受这条影响(那本来也不在仓库里)。
|
||||
/data/
|
||||
|
||||
# 主题生成器的自测脚本是 Python:跑一次就会留 __pycache__,别提交。
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# 本地凭据与一次性状态(测试账号密码等),只留在本机,不进版本库。
|
||||
# 目录说明见 .workbuddy/local/test-accounts.md 的抬头注释。
|
||||
/.workbuddy/local/
|
||||
/.codeartsdoer/
|
||||
|
||||
# 本地运行配置与 WSL 测试环境模板:含数据库密码与对象存储密钥,绝不能进版本库。
|
||||
# dev/wsl-app.ini 的用法见 dev/sync-wsl.sh;同步时必须排除 conf/app.ini,避免被 rsync --delete 删掉。
|
||||
/conf/app.ini
|
||||
/dev/wsl-app.ini
|
||||
/dev/_*.py
|
||||
|
||||
# 含真实测试凭据的历史记录与巡检脚本:保留本地文件,但不再纳入版本控制。
|
||||
/.workbuddy/memory/MEMORY.md
|
||||
/.workbuddy/memory/topics/build.md
|
||||
/.workbuddy/memory/topics/sec.md
|
||||
/.workbuddy/memory/topics/misc.md
|
||||
/.workbuddy/memory/2026-09-15.md
|
||||
/.workbuddy/memory/2026-10-19.md
|
||||
/.workbuddy/memory/2026-10-22.md
|
||||
/dev/devcheck-e2e.py
|
||||
/dev/devcheck-avatar.py
|
||||
/dev/devcheck-image.py
|
||||
/dev/devcheck-upload.py
|
||||
|
||||
@@ -9,3 +9,11 @@
|
||||
- 教训(重要):本机 Windows + go1.25.5 下 `-overlay` 行为异常(静默不生效 / "deleted in overlay"),**overlay 不可靠;本地验证用磁盘换桩法**:`cp dev/s3stub/s3_stub.go internal/storage/s3.go`(先备份)→ build/test → 还原。chai2010/webp 是 cgo 库,用户生产编译在 Linux,无需本地真编译。
|
||||
- `go mod tidy` 顺带升级了部分共享依赖(lipgloss 0.13、xid 1.5、testify 1.11 等),全量构建+测试通过。
|
||||
- dev/overlay.json 现指向 dev/s3stub/s3_stub.go(旧值指向已删除的 Downloads 副本,已过时)。
|
||||
|
||||
## autoban 线上触发实测(box-dev-api.337845818.best,2026-09-08 13:38)
|
||||
|
||||
- 基线:/ → 200;敏感路径 → 302(业务跳转,但会计入统计)。
|
||||
- 60s 窗口内连发 18 个敏感路径:前 ~9 个 302,第 ~10 个起全部 403;封禁后连首页也 403 "403 Forbidden"(中间件先于业务路由,全站拦截)。
|
||||
- ⚠️ 触发点 ≈ 9~10 次,不是配置的 15:疑似部署侧仍是默认阈值(DefaultThresholdSensitive=10)。需检查服务器 conf/app.ini [security] ban_threshold_sensitive = 15 是否落盘并重启服务。
|
||||
- 本次触发应已发出一封告警邮件(每 IP 仅一次),可借机验收新的「网络归属」字段。
|
||||
- 本机出口 IP 已被临时封禁(默认 15 分钟自动解除),期间再测会 403。
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
# 2026-09-10
|
||||
|
||||
## 负面情绪提问拦截方案调研(评审,未改代码)
|
||||
|
||||
- 盘点提问链路现有 5 道防线,结论:**无任何情绪/意图维度判定**,全部是字面/合规/可用性维度。
|
||||
- 已验证的代码事实(后续复用):
|
||||
- 文本审核走七牛 `POST https://ai.qiniuapi.com/v3/text/censor`,scene=`antispam`,10s 超时(`internal/security/censor/qiniu.go:85`)。
|
||||
- `IsPass()` 只硬拦 `politics/terrorism/porn`,且 `review` 一律放行 → `abuse`(辱骂)实际漏过(`qiniu.go:55-68`)。
|
||||
- `censor.go:50-53` 命中 31 天缓存后**重新用 parser 计算 Pass**,缓存不锁死判定 → 收紧 `IsPass()` 无需清缓存。
|
||||
- 拉黑 `user_block` 只作用于私聊(`route/question/page.go:368` 在 `StartChat` 分支内),**不拦提问**。
|
||||
- 项目无 at-rest 加密(grep aes/Encrypt 无结果);无队列/Worker,异步只有 `shared.SafeGo` 裸 goroutine;Redis 仅 session/cache。
|
||||
- 有内存滑动窗口限流器 `internal/route/ratelimit.go`,可复用做单 IP 限流。
|
||||
- `conf.Limits.QuestionContentLen` 默认 1000;`AnswerContentLen`/`FollowUpContentLen` 默认 2000(= 七牛文本 2000 字上限的边界)。
|
||||
|
||||
## 重要背景更正(用户指出)
|
||||
|
||||
- **图片/文本审核服务是七牛云,不是阿里云**。阿里云通道虽仍在 `censor.go` 代码中,但 `conf/app.sample.ini` 的 `aliyun_access_key` 为空,实际不生效。后续所有审核相关评估以七牛为准。
|
||||
|
||||
## 七牛云能力(官方文档已验证)
|
||||
|
||||
- 文本审核 label 9 类:normal/spam/ad/politics/terrorism/**abuse**/porn/flood/contraband/meaningless;suggestion: block/review/pass + score;≤2000 字;不支持白名单(拉黑词库要提交工单)。
|
||||
- 七牛自有 LLM(MaaS):`https://api.qnaigc.com/v1`,兼容 OpenAI `/v1/chat/completions` 与 Anthropic `/v1/messages`,50+ 模型,**用独立 API Key(非 AK/SK)**。
|
||||
|
||||
## 产出
|
||||
|
||||
- `outputs/llm-moderation-feasibility-2026-09-10.md`:LLM 内容审核方案可行性评审(数据流/失败降级/配置校验/Key 安全/邮件告警/成本限额)。
|
||||
- 4 个 P0:SSRF(用户自填 API 地址,连通性测试即探测入口)、Key 明文落库、用户 Key 被刷造成损失、失败即发信导致邮件轰炸。
|
||||
- 核心建议:分层 L1 收紧七牛 `IsPass()`(零成本)→ L2 本地情绪词库 → L3 LLM 只补七牛覆盖不到的语义部分。
|
||||
- 待用户确认 8 点,最关键:命中后是"提示+仍提交"还是"硬拦"。
|
||||
|
||||
## 追加修订(用户实测反馈)
|
||||
|
||||
- **用户反馈:七牛对「辱骂」判定过严,正常对话经常误伤** → 撤回原 L1 建议(把 abuse 纳入硬拦),照做会放大误伤。
|
||||
- 误伤两个真实成因(已代码复核):
|
||||
1. 七牛把正常内容判 `abuse` 且 `suggestion=block` → `qiniu.go:55-68` 拒绝 → 提问/私聊直接发不出去;
|
||||
2. **审核服务不可用时 fail-closed 拒绝**(`page.go:574`、`chat_handler.go:258`)→ 七牛抖动期间全站发不出内容。
|
||||
- 关键约束:七牛文本审核**不支持白名单**、自定义词库需提交工单 → 无法调准也无法调松,**唯一可控的是"命中后怎么办"**。
|
||||
- 修订原则:**显式规则硬拦,算法推断只标记**。箱主自定义屏蔽词+高危三类(politics/terrorism/porn)硬拦;abuse/spam/ad/flood/contraband/meaningless 只打标记照常送达;审核不可用改 fail-open+标记待审。
|
||||
- 落地:questions/messages 加标记列(AutoMigrate 自动加列)+ 后台折叠/筛选 + 设置页「审核严格度」三档(严格/宽松(默认)/仅标记)+ 用 `detail.Score` 自建阈值(默认偏高)。
|
||||
- 该路线零延迟零外部依赖,顺带消掉 SSRF/Key 明文/Key 被刷/邮件轰炸 4 个 P0。
|
||||
|
||||
---
|
||||
|
||||
## 「审核严格度」实现(已落地,构建/vet/测试全绿)
|
||||
|
||||
**备份(回滚锚点)**:`C:/OtherFiles/Github/TamaBox-v4-mainline-pre-moderation-strictness-20260910.tar.gz`
|
||||
(git 仓库损坏,用户指示「不要动 git」,改用 tar.gz;304 条目,sha256 `c6cfce2b...`)。回滚 = 解压覆盖回工作目录。
|
||||
|
||||
**核心设计:把「是否拦截」从审核服务自身的 Pass 判定中剥离**,新增策略层 `internal/security/censor/policy.go`。
|
||||
- 档位:`lax`(仅底线)/ `standard`(底线 + abuse≥0.9 + contraband)/ `strict`(底线 + 全部非 normal 维度不限分 + 采信服务 block 建议)。
|
||||
- 底线 `baselineBlockLabels`:politics / terrorism / porn / **customized**;任何档位无条件拦截、不可关闭(`Decide` 中排第一、无条件 return)。
|
||||
- 判定顺序固定:底线 → 档位维度(带分数门槛)→ 服务建议(仅 strict)。
|
||||
- 缺省 `DefaultStrictness = strict`;非法值 `ParseStrictness` 一律回退 strict(保守)。
|
||||
|
||||
**⚠️ 重要事实修正(本次发现)**:strict 档 **并非**「与升级前等价」,而是 **⊇ 升级前**。
|
||||
- 升级前拦截条件 = `!Pass` = (politics|terrorism|porn) 或 服务 suggestion==block(见备份 `route/question/page.go:443`)。
|
||||
- strict 额外把 abuse/spam/ad/flood/contraband/meaningless/harmful 全部纳入(不限置信度)→ 比升级前更严。
|
||||
- 已把散落各处「升级不改行为 / 行为等价」的错误注释全部改正(policy.go / moderation.go / conf/static.go / app.sample.ini / db/users.go / i18n 文案)。
|
||||
- **待用户拍板的取舍**:DefaultStrictness 是否维持 strict。若用户首要诉求是「减少误伤」,应改为 standard(一行常量);维持 strict 则部署后默认拦截面略扩。
|
||||
|
||||
**阿里云兼容(用户本轮要求,已完成)**:
|
||||
- **rate 量纲 bug(关键)**:阿里云 `rate` 是 0~100,七牛 `score` 是 0~1;不归一化会让 `abuseScoreFloor=0.9` 在阿里云下恒成立、standard 档分级失效。已加 `normalizeAliyunRate()`(>1 则 /100,clamp 0~1)。
|
||||
- 补映射 `harmful→ForbiddenTypeHarmful`(覆盖用户最初想要的「负面情绪」,仅在 strict 档生效)、`customized→ForbiddenTypeCustomized`(进底线)。
|
||||
- 未映射 label 不进 `Details`(无法按维度裁决);`results` 为空时 `ProviderSuggestion=block`(对齐 fail-closed)。
|
||||
- 结论:**可直接复用,无需改调用方**;只需上述 parser 层归一化/映射,三家通道(七牛/阿里云)共用同一 `Decide`。
|
||||
|
||||
**统一入口**:`internal/security/moderation/moderation.go`
|
||||
- `Check(ctx, strictness, lang, text)` = 调审核 → `censor.Decide` → 返回 err(本地化)。审核不可用仍 fail-closed。
|
||||
- `StrictnessFor(raw)` = 账号设置优先,空则跟随 `conf.Security.DefaultModerationStrictness`,再回退 strict。
|
||||
- 三场景入口已全部改走 `moderation.Check`(grep 可复核):
|
||||
- 提问/回答:`route/question/page.go:441,574`、`question.go:170`、`follow_up.go:105,164`
|
||||
- 投票:`route/vote/vote.go:72`、`route/user/vote.go:67,159,1012`
|
||||
- 私聊:`internal/route/chat/chat_handler.go:259`(用 `pageUser`=箱主,非访客)
|
||||
|
||||
**改动文件清单**:
|
||||
- 新增:`internal/security/censor/policy.go`、`policy_test.go`、`internal/security/moderation/moderation.go`、`route/user/moderation.go`、`templates/user/moderation-strictness.html`
|
||||
- 修改:`censor/{text,qiniu,aliyun}.go`、`conf/static.go`、`db/users.go`、`form/user.go`、`internal/route/route.go`、`route/question/{page,follow_up,question}.go`、`internal/route/chat/chat_handler.go`、`route/vote/vote.go`、`route/user/vote.go`、`i18n/dict_{zh_cn,zh_tw,en,ja}.go`、`templates/user/profile.html`、`templates/render_test.go`、`conf/app.sample.ini`
|
||||
- 设置页表单分别挂在 profile 的 提问/私聊/投票 三个面板。
|
||||
|
||||
**验证方式(Windows 沙箱无 libwebp/gcc,需 overlay)**:
|
||||
```
|
||||
go build -overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json" ./...
|
||||
go test -overlay="..." ./internal/security/... ./internal/i18n/... ./templates/... ./route/... ./internal/route/...
|
||||
```
|
||||
结果:build 0、`go vet ./...` 0、上述测试全 ok。新增 6 个阿里云/策略用例(normalizeAliyunRate / AliyunParserFeedsPolicy / CustomizedHitsBaseline / NoResultsFailsClosed / UnmappedLabelSkipped)。
|
||||
注:`internal/db` 测试需真实 DB,环境缺失,与本次改动无关;`censor.go`、`text_test.go` 存在**升级前就有**的空格缩进(gofmt -l 会列出),未动以避噪音。
|
||||
|
||||
---
|
||||
|
||||
## 第二轮:改动回归审阅(与备份全量 diff 逐项复核)
|
||||
|
||||
方法:把备份 tar.gz 解到 `%TEMP%/tb-review/base`,对工作区做 `diff -rq` 拿到精确变更集,再逐项查 bug。
|
||||
**结论:无 P0(无安全/正确性回归)**。关键安全性质已核实:strict 档拦截条件 **⊇ 升级前**,故默认档下不可能出现「以前拦、现在放」。`censor.Text` 的 31 天缓存命中后**重新走 parser**(`censor.go:50/61`),`Details`/`ProviderSuggestion` 会被重建,不存在「缓存命中导致档位判定失效」。`EnableTextCensor=false` 时 parser 返回 `{Pass:true}` 且 Details 为空 → `Decide` 放行,开发环境不受影响。
|
||||
|
||||
### 已修(本轮)
|
||||
|
||||
- **🔴 P1 设置页档位回显错误(真实 bug)**:`moderation_strictness` 为空(存量账号,语义=跟随站点默认)时,模板三个 `option` 的 `eq` 全为假 → 浏览器默认选中第一个选项「宽松」,而实际生效是站点默认(通常 strict)。界面显示的档位与真实拦截行为**相反**,直接违背「让用户在切换前就知道会被拦什么」的需求。
|
||||
- 修法:新增模板函数 `moderationStrictness`(`internal/template/template.go`,内部调 `moderation.StrictnessFor`,与 policy 同源),模板改用 `$current := moderationStrictness $root.LoggedUser.ModerationStrictness` 回显。
|
||||
- 未用「在 Profile handler 注入 data key」的方案:全仓有 **16 处** `ctx.Success("user/profile")`(各 Update* 校验失败回显路径),漏一处就会让模板读到缺失 key。
|
||||
- 补 `TestRenderProfileStrictnessReflectsEffectiveTier`(未设置/lax/standard/strict/非法值 5 例)。
|
||||
- **🟠 P2 `censorVoteForPublish` 档位口径不一致**:同一函数内 `vote.Title` 用 `voteOwnerStrictness(ctx, vote.UserID)`,而 `Description`/选项/字段标签走 `censorCheck`(内部读 `ctx.User`)。当前因 `getOwnedVote` 强校验 `vote.UserID == ctx.User.ID` 而**不可达**(3 个调用路径 678/907/944 都归属校验过),属潜在缺陷。已改为函数开头解析一次 `strictness` 并全程复用。
|
||||
- **🟠 P2 i18n 字典 diff 污染**:插入新 flash 键时切断了原有对齐组,导致 `admin.flash.deactivated`/`admin.export.errUser`/`admin.export.errQuestion` 三行在 4 个字典里被 gofmt 重新对齐(3 行×4 文件)。已把新键移到该组之后、独立成组,恢复原 3 行。**教训:往 gofmt 对齐组中间插键会把「拆分后的剩余组」重新对齐;应插在组末尾或独立成组。**
|
||||
- **🟠 P2 死代码**:`TextCensorResponse.ErrorMessage()` 生产与测试**均无引用**(已被 `ErrorMessageFor` 取代),且其「取首个维度」的语义正是本轮要修的错误来源,留着易被误用 → 已删除。`BlockedLabels()` 仅测试使用,但注释声称「供设置页展示」→ 已改为如实描述(UI 目前用 i18n 静态文案)。
|
||||
|
||||
### 已核查为非问题(勿重复排查)
|
||||
|
||||
- `internal/cmd/censor.go:57,68` 仍直接调 `censor.Text`:这是 `tamabox censor` **回填 CLI**,只写 `content_censor_metadata` 审计字段、不做拦截放行判定,不属三场景提交闸门 → 无需改走 `moderation.Check`。
|
||||
- 全仓已无「直接判 `resp.Pass` 决定放行」的残留(仅 `db/censor_log.go` 存字段)。
|
||||
- `LoggedUser` 每个请求都由 `authenticatedUser()` 回源 `db.Users.GetByID`(`internal/context/auth.go:22`),保存后重定向读到的一定是新值,不存在「保存后下拉框还是旧值」。
|
||||
- `route/user/moderation.go` 用 `ctx.User.ID` 且挂在 `reqUserSignIn` 组内(`internal/route/route.go:374`)→ 越权改他人档位不可行;与 `UpdateHarassment/UpdateBlockWords` 同构(同样无 rateLimit,保持一致)。
|
||||
|
||||
### 待用户处理 / 决策
|
||||
|
||||
1. **`templates/user/moderation-strictness.html` 被注入 `data-page-node-id` 属性**(全仓仅此文件有,备份/其它 40+ 模板均无 → 非项目约定,疑为页面编辑器标注)。它会**污染生产 HTML**,并让 `render_test.go` 的精确子串断言失败。我未擅自删除(可能属你侧编辑器状态);但已把断言改为正则 `selectedStrictnessOptions()`,不再耦合属性顺序 → 现在两种情况下测试都稳。**请确认是否要清掉这些属性。**
|
||||
2. `DefaultStrictness` 维持 strict 还是改 standard(见上一节):待拍板,一行常量。
|
||||
3. CHANGELOG 未记入:新功能属用户可见变更,按项目惯例应在 `dev/changelog-source.md` 顶部加条目并跑 `python dev/gen_changelog.py` 重新生成 `static/tamabox.changelogs.json`。**刻意留着等你定了档位口径再写文案**(严格/标准会改变措辞)。
|
||||
|
||||
---
|
||||
|
||||
## 第三轮:用户三项指示
|
||||
|
||||
1. **已删除** `templates/user/moderation-strictness.html` 的 `data-page-node-id` 属性(全仓无任何读取方,纯渲染噪声)。
|
||||
- ⚠️ **重要机制发现**:**用 Write/Edit 工具写这个文件会被自动重新注入该属性**(且 id 每次是新的一批);用 **shell(sed)修改则不会被注入**。判断注入方是「文件工具触发的后处理钩子」,不是文件监听。→ **该文件今后一律用 shell 修改**,不要用 Write/Edit。
|
||||
- 同时把 `render_test.go` 里原本精确匹配 `<option value="standard" selected>` 的断言改成不依赖属性顺序的 `selectedStrictnessOptions()`(正则),这样属性在不在都不会误报。
|
||||
2. **`DefaultStrictness` 维持 strict**(用户已拍板)。仅更新了 `policy.go` 的注释记录该决定与理由,代码不变。
|
||||
3. **新增 `[security] text_censor_source`** —— 此前通道链是硬编码的 `[qiniu, aliyun]`,无法选择。
|
||||
- 取值:`qiniu` / `aliyun` / `auto`(**留空同 auto,即历史行为**:七牛优先、未通过则阿里云二次复核)。
|
||||
- 常量定义在 `conf`(`CensorSourceAuto/Qiniu/Aliyun`),供 `conf.Init` 校验与 `censor.censorChain()` 共用,避免两处字符串漂移。
|
||||
- `conf.Init` 启动校验(纯函数 `validateTextCensorSource`,便于单测):
|
||||
- 审核关闭 → 不校验;留空/auto → 通过(两者都没凭据时仍不阻断启动,否则 sample 原样拷贝无法启动);
|
||||
- 显式选 qiniu/aliyun 但缺凭据 → **启动即报错**(否则运行期审核必然失败,而失败是 fail-closed,会让整站发布/私信不可用且现象隐蔽);
|
||||
- 其它取值 → 报错,**不静默回退**(拼错若被当成 auto,运维会误以为已只用七牛)。
|
||||
- `censor.Text`:循环改为 `for _, censor := range censorChain()`;新增一次性告警 `warnIfNoCensorCredentials`(sync.Once,兜 auto 无凭据的漏网情况,避免按请求刷屏)。
|
||||
- 顺带把原「HACK: Qiniu's API response is not accurate…」注释改写为如实描述链式复核语义,并说明 qiniu-only 时 `continue` 不会丢判定。
|
||||
- 文档:`conf/app.sample.ini` 在 `enable_text_censor` 下新增带注释的 `text_censor_source`。
|
||||
- 测试:新增 `internal/conf/text_censor_source_test.go`(12 例)、`internal/security/censor/censor_source_test.go`(6 例),全绿。
|
||||
|
||||
---
|
||||
|
||||
## 第四轮:阿里云通道「response data is empty」排查
|
||||
|
||||
用户报障日志:
|
||||
```
|
||||
ERRO Failed to censor text censor_source=aliyun error="response data is empty"
|
||||
ERRO Failed to censor text error="no censor source available" strictness=lax
|
||||
```
|
||||
(两条同时出现 = 两个通道都没给出结论 → fail-closed 拒签,即整站发布不可用。)
|
||||
|
||||
### 根因取证(读 SDK 源码 + 比对官方文档,未凭猜测)
|
||||
|
||||
1. 我们的 `aliyun.go` 与**上游 NekoBox `wuhan005/NekoBox` 的 aliyun.go 逐字节一致**,
|
||||
也与**阿里云官方 Go 示例一致**(`green.CreateTextScanRequest()` + `SetContent(json)`,
|
||||
`{"scenes":["antispam"],"tasks":[{"content":...}]}`)。→ 请求构造不是本项目引入的问题。
|
||||
2. 走通了 SDK 调用链,确认「HTTP 2xx + 合法 JSON + `data` 为空」是唯一能产出该错误的组合:
|
||||
- `sdk/responses/response.go:44` 非 2xx 会被 `Unmarshal` 直接转成 `ServerError` → 走
|
||||
`errors.Wrap(err, "text scan")`,不会是这条消息;
|
||||
- `response.go:58` 只有 `AcceptFormat == JSON` 才尝试解析进 `TextScanResponse`(空结构体,必然成功);
|
||||
- 我们的 parser 用的是 `resp.GetHttpContentBytes()`,body 非 JSON 会报 `unmarshal response`。
|
||||
3. 顺带确认真实端点解析正确:`green.EndpointMap` **不含 `cn-shanghai`**,但会一路回落到
|
||||
`GetEndpointRules` → `green.cn-shanghai.aliyuncs.com`(正确)。
|
||||
4. 找到一处**确定的代码缺陷**(不是根因,但让根因不可见):旧 parser 在 `len(Data)==0` 时
|
||||
只报 `response data is empty`,把顶层 `code` / `msg` / `requestId` 全丢了 → 用户拿不到任何线索。
|
||||
|
||||
### 已落地改动(`internal/security/censor/aliyun.go` + `policy_test.go`)
|
||||
|
||||
- 抽出 `newTextScanRequest(text)`(便于测试断言请求形态);**显式 `req.SetContentType(requests.Json)`**。
|
||||
SDK 的 ROA 默认值是 `application/octet-stream`(`roa_signature_composer.go:88`)。此改动属
|
||||
**按接口契约对齐**(对已正常工作的环境无副作用),**是否为本例根因未证实**,以下一次日志为准。
|
||||
- 顶层 `code != 0 && != 200` → 全新分支 `aliyun rejected the request: code=.. msg=.. request_id=.. raw=..`
|
||||
(与旧「data 为空」同为错误返回,控制流/语义不变)。
|
||||
- `data` 为空 / JSON 解析失败 / 请求体回显:错误里带上 `code/msg/requestId` 与原始响应摘要
|
||||
(`aliyunRawDigest`,按字符截断 ≤512,避免切坏多字节)。
|
||||
- `Censor()` 里 SDK 返回 err 时补 `status` + `body`;`!IsSuccess()` 分支同样带 status/body。
|
||||
- `data[].code != 200` 仍 fail-closed 置 block(**语义未改**),但在真正决定 fail-closed 的位置补了
|
||||
一条 Warn(code/msg/taskId/requestId)——否则「审核失败」会伪装成「命中违规」而无迹可循。
|
||||
- 顺手修掉 `Censor()` 上错误的文档链接(原指向七牛文档 → 改为阿里云 70439)。
|
||||
- 新增测试:`TestNewTextScanRequestShape`、`TestAliyunRejectedRequestSurfacesCodeAndMsg`、
|
||||
`TestAliyunEmptyDataErrorCarriesRawResponse`、`TestAliyunInvalidJSONErrorCarriesRawResponse`、
|
||||
`TestAliyunRawDigestTruncates`。`gofmt`/`go build`(overlay)/`go vet`/`go test ./internal/security/...` 全绿。
|
||||
|
||||
### 下一步(等用户)
|
||||
|
||||
重新部署后重试一次,新日志会直接显示阿里云返回的 `code/msg/requestId` 与原始响应。
|
||||
账号侧首要排查项:①内容安全 1.0 是否已开通按量付费;②RAM 子账号是否有 Green 权限;③是否欠费停服;
|
||||
④如需跨区可在 `getClient()` 写死 `cn-shanghai` 之外调整(该处目前硬编码)。
|
||||
|
||||
### 待决策
|
||||
|
||||
- 「显式选定通道坏掉 → 整站 fail-closed 不可用」是否要加**启动预检**(启动时发一次探活请求,
|
||||
失败则告警/拒启)。成本:按条计费 + 启动依赖外部网络;收益:把「静默全站拒签」提前暴露。
|
||||
|
||||
### ✅ 根因已确认(同日 15:50,用户回传新日志)
|
||||
|
||||
诊断改动生效后一条日志即定案:
|
||||
|
||||
```
|
||||
error="text scan (request body={"scenes":["antispam"],"tasks":[{"content":"测试!"}]}):
|
||||
aliyun rejected the request: code=596
|
||||
msg="You have not opened Yundun Content Moderation Service, please go to the page
|
||||
(https://www.aliyun.com/product/lvwang) to open our service after login Ali cloud console"
|
||||
request_id=01A08A4B-CCE2-5AAF-9329-A0C51C0F5060
|
||||
raw={"code":596,"httpStatusCode":200,"msg":"...","success":false}"
|
||||
```
|
||||
|
||||
- **根因 = 阿里云账号未开通「内容安全 1.0」**(账号侧,非代码缺陷)。
|
||||
- 关键实证:响应体里 `httpStatusCode: 200` + `success: false` + 无 `data` → **阿里云「请求被拒也返回 HTTP 200」**,
|
||||
所以旧代码只会报「data 为空」这句废话。这一条坐实了第二节的推断。
|
||||
- **`SetContentType(requests.Json)` 被证伪**,不是根因;保留但已把代码注释改为「接口契约对齐」而非「修 bug」。
|
||||
- 处置:开通 https://www.aliyun.com/product/lvwang(勾选协议点「立即开通」);RAM 子账号需 Green 权限;
|
||||
**开通完成前先切回 `auto`/`qiniu` 恢复线上可用性**。
|
||||
- 防呆:`conf/app.sample.ini` 的 `text_censor_source` 注释补上「配好凭据 ≠ 可用」+ code=596 的完整说明。
|
||||
- 报告已更新为确认版:`outputs/aliyun-censor-empty-data-diagnosis-2026-09-10.md`。
|
||||
- 仍待决策:①启动预检(或折中=首次账号级拒绝时打一次性带处置指引的告警);②CHANGELOG 条目。
|
||||
|
||||
### ✅✅ 最终根因(同日 16:05,用户开通后仍报 596)
|
||||
|
||||
用户按我给的地址开了「AI 安全护栏」,仍报同样的 `code=596`。查证后确认**我上一轮给的地址是错的,且漏了硬门槛**:
|
||||
|
||||
| 服务 | 开通前提(官方原文) | 个人认证 |
|
||||
| --- | --- | --- |
|
||||
| **内容安全 1.0**(`green` 2018-05-09,本项目在用) | **需账号完成企业认证**(`document_detail/69806.html`,2026-07-23 更新) | ❌ 开不了 |
|
||||
| 内容安全增强版 2.0(`green-cip` 2022-03-02) | 需完成**实名认证**(`document_detail/477720.html`) | ✅ 可开 |
|
||||
|
||||
- 1.0 真正开通入口:`https://common-buy.aliyun.com/?commodityCode=cdi#/open`;
|
||||
`aliyun.com/product/lvwang` 只是产品介绍页(现为「AI 安全护栏」聚合页),点不出「一键开通」。
|
||||
- 所以:**个人实名认证账号 + 1.0 API = 596 永久复现**,与 RAM 权限无关
|
||||
(权限不足返回 403/Forbidden,不是 596;596 是「服务未开通」)。
|
||||
- 已修正 `conf/app.sample.ini` 的注释(换成正确入口 + 写明企业认证前提 + 三条出路)。
|
||||
- 报告更新为最终版:`outputs/aliyun-censor-empty-data-diagnosis-2026-09-10.md`(含 A/B/C 三方案对比表)。
|
||||
- 三条出路:**A** 迁增强版 2.0(个人可开,但要换 `green-cip` + `TextModeration` + 新 SDK,**风险标签体系完全不同,`policy.go` 维度清单需重写**);
|
||||
**B** 账号升级企业实名认证(代码零改动);**C** 放弃阿里云只用七牛(`auto`/`qiniu`,损失「七牛误报二次复核」能力)。
|
||||
- 已就方向征询用户(AskUserQuestion)。
|
||||
|
||||
### ✅✅✅ 已落地:阿里云通道迁移到「内容安全增强版 2.0」
|
||||
|
||||
用户通过 AskUserQuestion 选定:**迁移增强版 2.0** + **加一次性告警**(即原「启动预检」的折中方案)。当日完成编码,构建/vet/测试全绿。
|
||||
|
||||
**⚠️ 关键事实修正(最容易踩的坑)**:
|
||||
`green-20220302` SDK 中 `TextModerationResponseBodyData.Labels` 的内联注释列出的标签(10 个,且不含涉政)
|
||||
**是旧的、不完整的**。以官方《文本审核增强版标签处理建议参考》(`practice_detail/465315`)为准,
|
||||
2.0 通用服务**包含** `political_content`(涉政)、`violence`(暴恐)、`sexual_content`(色情)、
|
||||
`C_customized`(自定义词库)→ **底线四件套可完整覆盖,`policy.go` 的 `baselineBlockLabels` 无需放宽**。
|
||||
|
||||
**2.0 标签 → ForbiddenType 映射**(`formatAliyunForbiddenType`):
|
||||
`political_content`/`regional`→politics(底线);`violence`→terrorism(底线);`sexual_content`/`sexuality`→porn(底线);
|
||||
`contraband`→contraband;`ad`/`PullinTraffic`/`ad_compliance`→ad;`profanity`→abuse;`religion`→harmful;
|
||||
`negative_content`/`cyberbullying`→harmful;`nonsense`→meaningless;`spam`→spam;`C_customized`→customized(底线)。
|
||||
⚠️ 2.0 无「灌水(flood)」维度(同类归入 spam),`ForbiddenTypeFlood` 不再由阿里云产生(七牛仍会返回)。
|
||||
|
||||
**其他关键事实**:
|
||||
- 2.0 通用服务单次 `content` **硬上限 600 字符**;站点内容上限为 提问 1000 / 回答·追问·私聊 2000 /
|
||||
投票描述 5000 字 → 超长必然超限,故**按 550 字分段、段间重叠 50 字**,标签取并集、风险级别取最高。
|
||||
- 2.0 **不返回逐标签置信度**,只有整条内容的 `riskLevel` → 折算 high=1 / medium=0.6 / low=0.3,
|
||||
该响应内所有标签共用同一分值。副作用:`abuseScoreFloor=0.9` 在阿里云下的实际语义变成
|
||||
「标准档只拦 riskLevel=high 的辱骂」。
|
||||
- 场景固定 `comment_detection`(公聊评论;官方定位覆盖涉政/涉暴/涉黄底线 + 广告导流)。
|
||||
- SDK:`alibabacloud-go/green-20220302/v3 v3.5.4`;`openapiutil.Config{RegionId:"cn-shanghai"}` 由 SDK 的
|
||||
EndpointMap 解析出 `green-cip.cn-shanghai.aliyuncs.com`;`ReadTimeout` / `ConnectTimeout` **单位毫秒**,
|
||||
直接设在 Config 上即可(不必用 RuntimeOptions)。
|
||||
- SDK 错误经 `dara.TeaSDKError` 包成 `*dara.SDKError`(有 `GetCode()` 与 `StatusCode` 字段);
|
||||
账号级故障用「结构化 code + 消息文本」双重识别(`aliyunIsAccountIssue`)。
|
||||
- 2.0 开通前提:**仅需实名认证,个人账号可开通**(`document_detail/477720.html`);
|
||||
1.0 才是「需企业实名认证」,个人账号 596 永久复现。
|
||||
|
||||
**两个设计取舍(容易被后人误删,务必保留)**:
|
||||
1. 归一化响应带 `api:"TextModeration"` 格式标记,解析器拒绝一切非该格式的载荷;
|
||||
`censor.go` 阿里云缓存回放解析失败时**退回实时调用**(原来是直接把错误返回上层)。
|
||||
作用:迁移前 1.0 落库的缓存既不会被误判成「通过」(那会成为绕过审核的后门),
|
||||
也不会因 fail-closed 让这些历史文本在 31 天缓存过期前一直发不出去。
|
||||
2. 首次账号级失败(596 / Forbidden / InvalidAccessKeyId …)打**一条**带开通指引的 WARN
|
||||
(`warnAliyunAccountIssueOnce` + `sync.Once`),之后静默。
|
||||
|
||||
**改动文件**:`internal/security/censor/aliyun.go`(整文件重写)、`aliyun_test.go`(新增测试套件)、
|
||||
`censor.go`(缓存回退)、`text.go` / `policy.go`(注释同步)、`policy_test.go`(删除 1.0 格式旧测试)、
|
||||
`conf/app.sample.ini` + `internal/conf/static.go`(改为 2.0 开通指引);
|
||||
`go mod tidy` 已移除不再引用的 `github.com/aliyun/alibaba-cloud-sdk-go v1.62.26`。
|
||||
|
||||
**校验**:`go build -overlay=... ./...` 通过;`go vet ./internal/...` 通过;
|
||||
`go test ./...` 仅 `internal/db` 失败(信息均为 `Unknown database type: ""`,需真实 PostgreSQL/MySQL,与本改动无关),
|
||||
其余全绿(含 `internal/security/censor`)。
|
||||
`gofmt -l` 仅列 `censor.go` / `text_test.go`(改动前既有的空格缩进,按约定未动)。
|
||||
|
||||
**报告已更新**:`outputs/aliyun-censor-empty-data-diagnosis-2026-09-10.md` 文首新增「最终落地」章节。
|
||||
|
||||
**仍需用户处理**:①到内容安全 2.0 控制台开通「内容审核增强版」按量付费,
|
||||
RAM 子账号授予 `AliyunYundunGreenWebFullAccess`;②CHANGELOG 条目仍未记入
|
||||
(`dev/changelog-source.md` + `python dev/gen_changelog.py`)。
|
||||
|
||||
---
|
||||
|
||||
## 第五轮:私聊审核拦截 UX 修复(用户实测反馈)
|
||||
|
||||
用户实测 2.0 功能正常,但发现**私聊页面被审核拦截后「无任何提示 + 输入框内容消失」**。
|
||||
|
||||
### 根因(两处)
|
||||
|
||||
1. **无提示**:`templates/chat/conversation.html` 没挂 `{{template "base/alert" .}}`,
|
||||
导致 `SendMessage` 里所有 `SetErrorFlash`(过长/被拉黑/审核拦截…)在这页都渲染不出来。
|
||||
2. **内容消失**:审核失败走 `SetErrorFlash` + `Redirect`,重渲染的 `ConversationPage`
|
||||
从不回填 `content`,输入直接清空。
|
||||
|
||||
### 改动
|
||||
|
||||
- `internal/route/chat/chat_handler.go`:把 `ConversationPage` 拆出共享渲染函数
|
||||
`renderConversation(ctx, domain, token, prefillContent, modErr)`;`ConversationPage` 改为其薄封装。
|
||||
私聊审核失败分支由 `SetErrorFlash + Redirect` 改为 `renderConversation(..., content, err)`
|
||||
——**就地重渲染**:`canSend` 为真时把 `prefillContent` 写入 `ctx.Data["Content"]`,
|
||||
并把 `modErr` 写成 `ctx.Data["HasError"]/["Error"]`(与提问链路 `ctx.SetError` 同源)。
|
||||
- `templates/chat/conversation.html`:①加 `{{template "base/alert" .}}`(容器开头);
|
||||
②`<textarea>` 内填充 `{{ .Content }}` 回填已输入内容(html/template 自动转义,安全)。
|
||||
|
||||
### 验证(Windows 沙箱用 overlay 跑,仅 `internal/db` 因缺真实 DB 失败、与本次无关)
|
||||
|
||||
- `go build -overlay=... ./...` 0;`go vet ./internal/...` 0;
|
||||
- `internal/route`、`internal/template`(模板解析)、`internal/conf` 全 ok;
|
||||
- **审核严格度仍生效**:`TestDecideBaselineAlwaysBlocks` / `TestDecideByLevel` /
|
||||
`TestDecideUnknownLevelFallsBackToStrict` / `TestBlockedLabelsIncludesBaselineAtEveryLevel` 全过
|
||||
(底线四件套任何档位强制拦截、未知档回退 strict,未受 2.0 改动影响);
|
||||
- **2.0 迁移未破坏**:`TestAliyunParser*` / `TestExtractAliyunChunkResult` / `TestMergeAliyunChunkResults`
|
||||
/ `TestAliyunIsAccountIssue*` 全过。
|
||||
|
||||
### 备注
|
||||
|
||||
- 新增 `base/alert` 后,私聊页**其它** `SetErrorFlash` 提示(过长/被拉黑/每日上限…)现在也会显示;
|
||||
但那些仍走 Redirect,输入内容仍会丢失。本次只修了用户反馈的审核拦截路径,其余校验失败保留内容属可后续优化项。
|
||||
|
||||
---
|
||||
|
||||
## 环境事实(用户更正,长期有效)
|
||||
|
||||
- **真实运行环境是 Debian**,不要为 Windows 相关问题花时间:本机沙箱才需要 `go build -overlay=...`
|
||||
绕开 libwebp/gcc(cgo),Debian 生产/开发机 `go build ./...` 原生即可,不需要 overlay。
|
||||
- 沙箱内若要用 overlay,注意 **overlay.json 的 Replace 键盘符必须小写**(`c:/...`),
|
||||
大写 `C:/...` 会被 Go 规范化成 `c:\` 后匹配不上、报 `deleted in overlay`;stub 在
|
||||
`dev/s3stub/s3_stub.go`。本次用 `C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-overlay2.json` 跑通。
|
||||
|
||||
---
|
||||
|
||||
## 第六轮:设置面板聚合(新增「防护设置」tab)+ 审核严格度排版修复
|
||||
|
||||
用户诉求:① 把防骚扰、屏蔽词、审核严格度、提问密码聚合到 `/user/profile` 下一个独立 tab;
|
||||
② 审核严格度档位处「一个字就换行」很难看。
|
||||
|
||||
### 决策(AskUserQuestion 已确认)
|
||||
|
||||
- 新 tab 名称:**防护设置**(i18n 键 `admin.tabSecurity`,tab id `security`)。
|
||||
- **「页面访问密码」一并迁入**(原先在「用户设置」面板)。
|
||||
- 旧位置**完全移除,不留跳转提示**。
|
||||
|
||||
### 改动
|
||||
|
||||
**后端**
|
||||
- `route/user/features.go`:新增 `ProfileTabSecurity = "security"`,并纳入 `ProfileTab()` 白名单
|
||||
(不加入的话回跳会落到 account 面板)。
|
||||
- `route/user/password.go`:`UpdateAskPassword` / `UpdatePagePassword` 的硬编码回跳
|
||||
(`profileURLWithTab(ProfileTabQuestion)`、`"/user/profile"`)全部改为 `returnTab(ctx)`,
|
||||
跟随表单携带的 `?tab=`。
|
||||
|
||||
**模板**
|
||||
- `templates/user/profile.html`:tab 栏新增「防护设置」按钮;新增
|
||||
`<div class="profile-panel" data-profile-panel="security">`,内含 防骚扰 / 屏蔽词 /
|
||||
审核严格度 / 页面访问密码 / 提问密码 五个表单(action 均带 `?tab=security`)。
|
||||
提问/私聊/投票面板中这些表单全部删除(**原先屏蔽词与审核严格度被重复渲染 3 份**)。
|
||||
account 面板移除「页面访问密码」卡(原编号 7 让位,账号操作改为 7)。
|
||||
JS 面板切换是全泛型(`[data-profile-tab]` / `[data-profile-panel]`),无需改动。
|
||||
- `templates/user/moderation-strictness.html`(**只用 shell/脚本改,禁用 Write/Edit**):
|
||||
1. 档位表格:档位列 `uk-table-shrink` + `white-space:nowrap`、范围列 `uk-table-expand`
|
||||
—— 修掉右侧长文案把 4 字档位名(畅所欲言/正常水平/不听坏话)挤成逐字折行。
|
||||
2. 选择区改三行:标题(显式 `display:block`)→ 下拉(独立 div)→
|
||||
「未设置时跟随站点默认档位。」(独立 div)。
|
||||
|
||||
**i18n**:四语字典新增 `admin.tabSecurity`(防护设置 / 防護設置 / Protection / 防護設定),
|
||||
插在「功能管理」组之后的**独立组**,未切开任何既有对齐组(避免 gofmt 重排污染 diff)。
|
||||
|
||||
### 验证
|
||||
|
||||
- `go test ./templates/`(含 profile 渲染、严格度回显断言 `selectedStrictnessOptions`)ok;
|
||||
`./internal/i18n/` ok(四语键一致)。
|
||||
- `go build` + `go vet` `./route/user/` 均 0。
|
||||
- ⚠️ **gofmt 提醒(重要,易误判)**:`gofmt -l` 会列出**所有 CRLF 文件**(含本次完全没碰的
|
||||
`route/user/moderation.go`、`vote.go`),因为 gofmt 把 CRLF 规范成 LF。
|
||||
剥离 CR 后比对,本次改动**零格式差异** → 格式是对的。
|
||||
**切勿跑 `gofmt -w`**,那会把文件转成 LF、造成全量 diff 污染。
|
||||
|
||||
---
|
||||
|
||||
## 第七轮:精简注释(删复述、留 WHY)
|
||||
|
||||
用户反馈「注释太多」,要求仅保留重要部分。原则(AskUserQuestion 已确认):
|
||||
**保守尺度** —— 删除复述型/装饰型,保留全部安全约束、踩坑、外部服务怪癖,长注释压成 1-3 行。
|
||||
|
||||
### 摸底数据(脚本 `comment_survey.py`,排除 outputs/dev/.workbuddy)
|
||||
|
||||
- `.go` 139 文件 29,185 行,注释 3,102 行(10.6%);`.html` 41 文件 7,784 行,注释 152 行(2.0%)。
|
||||
- 最重:`policy.go` 38.9%、`conf/limits.go` 37.8%、`shared/ip.go` 33.0%、`conf/static.go` 27.8%、
|
||||
`harassment.go` 25.2%、`aliyun.go` 24.7%(152 行,绝对量最大)、`text.go` 22.2%、`censor.go` 21.3%、
|
||||
`form.go` 19.8%、`gzip.go` 19.3%、`markdown.go` 18.1%、`route.go` 17.8%(80 行)。
|
||||
|
||||
### 第一批已完成(-106 行,-31%)
|
||||
|
||||
| 文件 | 原 | 现 | 减 |
|
||||
|---|---|---|---|
|
||||
| `policy.go` | 84 | 52 | -32(-38%)|
|
||||
| `conf/limits.go` | 45 | 25 | -20(-44%)|
|
||||
| `shared/ip.go` | 58 | 32 | -26(-45%)|
|
||||
| `aliyun.go` | 152 | 124 | -28(-18%,**仅处理到前 180 行,余下待续**)|
|
||||
|
||||
保留的关键信息(勿再删):fail-closed 语义、严格档 ⊇ 改造前、底线维度不可关、
|
||||
阿里云 596/600 字上限与分段重叠、格式标记防历史缓存误判「通过」、SDK 标签表不可信、
|
||||
IPv6 /64 聚合仅限流不可用于黑名单、源站未锁可被伪造头、双 CDN 必须删对方权威头。
|
||||
|
||||
### ⚠️ 复用方法(重要)
|
||||
|
||||
1. **删注释会触发 gofmt 重排**:删掉字段/映射项之间的注释后,相邻行合并成一个对齐组,
|
||||
列宽会变。手算列宽易错 → 用脚本 `gofmt_keep_crlf.py <files>`:
|
||||
**跑 gofmt 后按原文件行尾还原 CRLF**(直接 `gofmt -w` 会把 CRLF 转成 LF,造成全量 diff 污染)。
|
||||
2. **仓库行尾本就不统一**(`policy.go`/`aliyun.go`/`text.go`/`dict_*`/`profile.html` 是 CRLF;
|
||||
`limits.go`/`features.go`/`password.go` 是 LF)。改动前先确认,改后保持一致。
|
||||
3. `grep -c $'\r' file` 在 Git Bash 下**不可靠**(`$'\r'` 可能被当空模式,导致「全部行命中」)。
|
||||
查行尾要用 Python 数 `b'\r\n'`。
|
||||
4. `templates/user/moderation-strictness.html` 仍**只能用 shell/脚本改**(Write/Edit 会注入
|
||||
`data-page-node-id`)。
|
||||
|
||||
### 验证
|
||||
|
||||
`go build ./internal/security/... ./internal/shared/... ./internal/conf/...` 通过;
|
||||
`go test ./internal/security/censor/` ok(严格度与 2.0 用例全过,确认删注释未影响行为)。
|
||||
|
||||
---
|
||||
|
||||
## 续:注释清理第二批 + 编译卡死根因(晚间)
|
||||
|
||||
### 第二批已清理(本轮继续)
|
||||
|
||||
`conf/static.go`、`conf/conf.go`、`security/censor/{text,censor,aliyun}.go`、`route/{gzip,route}.go`、
|
||||
`form/form.go`、`template/markdown.go`、`db/{db,feature_control}.go`、`captcha/math.go`、
|
||||
`route/user/harassment.go`、`route/user/vote.go`(最大,~149→少,N+1→2SQL、sanitizeCSVCell、BOM 等 WHY 保留)、
|
||||
`db/follow_ups.go`(部分,待构建释放后收尾)、`security/censor/policy.go`(最高比 28%→压缩,保 WHY)、
|
||||
`i18n/i18n.go`(删内联复述、修 typo)。
|
||||
|
||||
### 编译卡死根因(已定位并绕过)
|
||||
|
||||
- 真因:`proxy.golang.org` 在本沙箱 `HTTP=000`(连接超时),`go build` 触网即无限等待 → 表象「卡死」。
|
||||
`goproxy.cn` → 200。解法:`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod` + **定向包构建**(勿 `./...`)。
|
||||
- `go env -w` 失败(`%AppData% is not defined`),无法持久化,只能每条命令 export。
|
||||
- 重活儿构建(`./route/user/ ./internal/route/ ./internal/db/`,overlay 换 s3 stub 避 cgo/webp)已用上述代理跑通、进行中(1C1G 首编 20+ 分钟)。
|
||||
|
||||
### 行尾结论
|
||||
|
||||
`core.autocrlf=true` → 工作区 CRLF、blob LF,Git 归一,**无 diff 污染**,不必手动转行尾。
|
||||
`gofmt_keep_crlf.py` 重建到 `dev/gofmt_keep_crlf.py`(Temp 版被清),保留工作区 CRLF 以免 gofmt -l 误报。
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# 2026-09-11 工作日志
|
||||
|
||||
## 代码审阅(全库,安全/正确性/可优化)
|
||||
|
||||
结论:**无 P0、无 P1 可利用漏洞**;仅 P2(一致性 / 健壮性 / 性能)。
|
||||
|
||||
### 已验证「没问题」的项(勿重复排查)
|
||||
|
||||
1. **越权**:所有写接口均有归属校验 —— `getOwnedVote` 显式比对 `vote.UserID != ctx.User.ID`(vote.go:388-401);
|
||||
question 路由挂在 `question.Questioner` 中间件组(route.go:262-274),`PublishAnswer` 二次校验 `question.UserID`;
|
||||
follow-up 写操作以 `question.ID` 为作用域(`AnswerByID`/`UpdateAnswerByID`)。profile/password/harassment/tags/features 一律用 `ctx.User.ID`。
|
||||
2. **SQL 注入**:`internal/db` 全部用 `?` 绑定参数;拼接仅出现在常量/配置(dedupeUniqueFields、tagCond、randomFunc)。
|
||||
3. **XSS**:`internal/template/markdown.go` 白名单消毒扎实 —— strip `on*`、禁 `style`、`safeURL` 协议白名单
|
||||
(http/https/mailto/相对)、拒 `//` 协议相对、强制 `rel="noopener noreferrer"`。
|
||||
4. **竞态**:follow-up 用事务 + `FOR UPDATE` 行锁;投票「一人一票」用复合唯一索引。
|
||||
5. **GORM bool 默认值陷阱**:已规避(`Enabled` 不加 `gorm:"default:true"`,见 feature_control.go:79-81),
|
||||
且 `reconcileLegacyChatEnabled`(:609-618)修复旧 backfill 脏行。
|
||||
6. **审核缓存回放无 fail-open**:`AliyunTextCensorParser`(aliyun.go:433-461)与 `QiniuTextCensorParser`
|
||||
(qiniu.go:133-170)都会从 raw 重建 `Details`,故 `Decide` 仍能按当前档位裁决(改档位对历史缓存同样生效)。
|
||||
⚠️ text.go:66 注释称「回放缓存结果时为 nil」**是错的**,需修正。
|
||||
|
||||
### P2 清单(待用户选批后落地)
|
||||
|
||||
| # | 文件:行 | 问题 | 建议 |
|
||||
|---|---|---|---|
|
||||
| 1 | censor.go:72,111,118-125 | 缓存按**原文** `text` 查,实际送审 `RemoveTrustedURL(text)`,落库 `Input` 存原文 → 审计与实际送审不一致 + 归一化相同文本重复调 API | 归一化一次,查找/送审/落库统一用归一化文本,原文另存字段 |
|
||||
| 2 | censor.go:85-102 | qiniu 缓存命中仅 `Pass` 才返回,aliyun 命中即返回(不看 Pass)→ 依赖「aliyun 恒为链尾」隐式前提 | 两分支语义对齐,或显式固化链尾不变量 |
|
||||
| 3 | text.go:66 | 注释失真(称回放时 Details 为 nil,实际会重建) | 修正注释 |
|
||||
| 4 | route/vote/vote.go:814-822 | 配额用「插入后计数」判定,非原子条件插入 → 并发可短暂超额(有界、自动结束自愈) | 改 `WHERE count<quota` 条件插入或 DB 计数列 |
|
||||
| 5 | route/user/profile.go:194-215 | Name/Intro/各 link 无长度校验直传 `db.Users.Update` | 补 rune 长度校验(对齐 vote/block-word) |
|
||||
| 6 | internal/db/users.go:89 | `User.Email` 带 `json:"email"`(对比 Password/BlockWords 为 `json:"-"`)→ 潜在 PII 泄露 | 改 `json:"-"` |
|
||||
| 7 | internal/db/db.go:215-222 | 用 `conf.Database.Schema` 拼 `CREATE SEQUENCE`(配置来源,纵深防御) | 校验/白名单 schema 标识符 |
|
||||
| 8 | text.go:114-141 | `RemoveTrustedURL` 对 12 host × 2 protocol = 24 次整串重建 | 单次正则/一次遍历 |
|
||||
|
||||
另:`question.go:214` 提问人 token 在 URL 中(邮件链接),**设计如此**,会出现在邮件日志/Referer,可接受。
|
||||
|
||||
### 编译状态(延续 09-10)
|
||||
|
||||
- 重包 `./route/user/ ./internal/route/ ./internal/db/` 再次 **30 分钟超时(exit 124,无报错)**
|
||||
→ 判定为 1C1G 沙箱编译整图慢,**非代码错误**(注释改动不影响编译,20 文件 `gofmt -e` 均可解析)。
|
||||
- 已确认可编译:GROUP1(security/censor、conf、form、template、shared、captcha)EXIT=0;`./internal/db/` EXIT=0。
|
||||
- 温缓存重试(task zGnX3L)仍在跑,25 分钟上限。
|
||||
@@ -0,0 +1,30 @@
|
||||
# 2026-09-27 工作日志
|
||||
|
||||
## Serverless 迁移可行性评估(只读,未改源码)
|
||||
|
||||
用户问:TamaBox-v4 能否像 OpenListTeam/OpenList-Worker 那样改造成 TypeScript + Cloudflare Workers + D1。
|
||||
|
||||
**产出**:`outputs/serverless-migration-feasibility-2026-09-27.md`
|
||||
|
||||
**结论:技术可行,但工程性价比低——是「整体重写」不是「改造」。**
|
||||
|
||||
关键判据(两个项目形态根本不同):
|
||||
- OpenList = **无状态文件列表 + 反代**(无会话、无可变状态、存储可降级 map/KV、无图像、无服务端模板)→ 边缘天然匹配。
|
||||
- TamaBox = **有状态多用户 Web 应用**(Session/CSRF/Redis cache、PG 关系模型、cgo 图像管线、42 个服务端模板)。
|
||||
|
||||
标定数据:
|
||||
- TamaBox:34,664 行 Go(route 7645 / db 6950 / i18n 3689 / security 2675 / internal-route 1653 / template 825)。
|
||||
- OpenList 原版 9.8MB Go / 24.8k star;OpenList-Worker 21.7MB TS / 416 个 .ts(约 2.6MB 源码)/ 官方团队 / 2025-08-23 立项 → 约一年,且**代码量反超原版**。
|
||||
|
||||
P0 硬阻断(11 项)要点:
|
||||
1. `internal/storage/s3.go` cgo WebP(chai2010/webp)+ `cwebp` 子进程 + `os.CreateTemp` → Workers 无 cgo/无子进程/无磁盘。
|
||||
2. `route/question/share.go` freetype + `os.ReadFile(font_path)` 渲染中文分享图 → 需整体重写,且 CPU 密集易超 Workers 限额。
|
||||
3. Session/缓存/限流/自动封禁/去重/统计全在进程内(`internal/cache`、`autoban`、`shared/duplicate.go`、`ratelimit.go`)→ 必须迁 Durable Objects。
|
||||
4. `internal/route/route.go:386-395` cache 中间件**无条件硬编码 Redis**(不判空配置)。
|
||||
5. D1=SQLite:无 PG 序列(`db.go:211-234`)、无 `FOR UPDATE`(`follow_ups.go:68`)、无 JSONB(`datatypes.JSON`)。
|
||||
6. SMTP(`internal/mail/mail.go:129-147`)Workers 禁裸 TCP → 换 HTTP 邮件 API。
|
||||
7. OTel/Uptrace gRPC 上报不可用。
|
||||
|
||||
顺带确认(对后续有用):`internal/template/minify.go` 当前**未被路由装配使用**(`route.go:99` 用的是 flamego template.EmbedFS),属死代码。`internal/security/ipinfo` 是离线签名、零 DNS。
|
||||
|
||||
建议方案:A 不迁移(推荐);B 只把静态资源+只读接口下沉边缘(EdgeOne/Workers),写路径与图像处理留 Go 源站——项目已有 EdgeOne 回源白名单脚本,该形态已在用;C 完全重写(需 2-3 个月专职 + 功能裁剪,优先 EdgeOne 因其有大陆节点)。
|
||||
@@ -0,0 +1,13 @@
|
||||
# 2026-09-28
|
||||
|
||||
本会话的工作实际发生在**另一个仓库** `C:/OtherFiles/Github/rond-with-you`(用户指定的 data 文件夹与该备份都在那边),
|
||||
TamaBox 仓库这次没动。
|
||||
|
||||
- 任务:用真机新导出的 `data/20260928_183908.rondbackup` 在本地测试环境(`conf/app.ini`)实测「rondbackup 合并」。
|
||||
- 结论:主链路生效(新增 70 地点 / 1 活动 / 134 到访,953 条到访 UUID 全命中、时间零误差,孤儿位移 0);
|
||||
查出 3 个问题(重复地点不幂等、到访冲突被活动改名放大且不收敛、21 个长沙地点坐标系差 ~660m)。
|
||||
- 笔记与报告落在该仓库:`.workbuddy/memory/2026-09-28.md`、`.workbuddy/memory/MEMORY.md`、
|
||||
`storage/.mergetest/报告-rondbackup合并实测-20260928.md`。
|
||||
- 随后按用户要求**回档数据库 → 修复前两个问题 → 复测**:改 `internal/rond/diff.go`(`DiffPlaces` 多值索引、
|
||||
到访活动按 `ZUID_` 身份比)与 `internal/web/backup_diff.go`(多取活动 UUID),共 5 文件 +172/−22。
|
||||
复测:到访冲突 253→6,地点新增 70→67 且合并后归零,重复合并不再产生重复地点。细节见该仓库记忆。
|
||||
@@ -0,0 +1,9 @@
|
||||
# 2026-09-29
|
||||
|
||||
工作仍发生在 `C:/OtherFiles/Github/rond-with-you`(TamaBox 仓库未动)。
|
||||
|
||||
- 站长手动合并后追问差异页「地点 X → X」为何算冲突;复现定性:`地点` 比的是**坐标**、页面只印**名称**,
|
||||
同名不同坐标(连锁店重名 236.8km/8.3km,坐标系 750m)就渲染成看似没变化的一行。
|
||||
- 顺带发现默认「用实机」已把站内 `中国体育彩票`(src_pk=71) 从贺州搬到佛山(236.8km);
|
||||
提出 3 处待修方案(名称兜底加距离上限、地点字段补坐标、mergePlaces 的单槽位映射),等批准。
|
||||
- 细节见该仓库 `.workbuddy/memory/2026-09-29.md`。
|
||||
@@ -0,0 +1,226 @@
|
||||
# 2026-10-15
|
||||
|
||||
## WebP 压缩链路评审(只读,未改代码)
|
||||
|
||||
产出:`outputs/webp-compression-review-2026-10-15.md`
|
||||
|
||||
### 关键事实(已实证)
|
||||
- `internal/storage/s3.go:186` 的 `maxWidth=1920` + `Quality:80` 对**所有用途一刀切**;
|
||||
前端 `profile.html:1390` 头像 cropper 只出 512,服务端却按1920 存 → 头像实测多传 ~7倍。
|
||||
- `chai2010/webp v1.4.0` 的 `Options` **只有 Lossless/Quality/Exact**;
|
||||
C 层是裸 `WebPEncodeRGB/RGBA`(`internal/src/webp.c:194-215`)= cwebp method4 默认档。
|
||||
→ method/sns/filter/alpha_q/near_lossless 全都不可控。实测 method6 再省 3~6%。
|
||||
- 🔴 **`Dockerfile:16` 用 Alpine apk 但未装 `webp` 包** → 容器内无 `cwebp`
|
||||
→ `s3.go:201` LookPath 必失败 → GIF 动画 WebP 分支是**死代码**,线上一直原图 GIF,且无日志。
|
||||
- 🔴 `s3.go:99` / `page.go:707` 用 `image.Decode` **不读 EXIF Orientation**;
|
||||
`templates/question/item.html:206,238` 的 `accept="image/*"` 是原图直传(无 canvas 归一)
|
||||
→ 手机竖拍照片转 WebP 后横躺。
|
||||
- 缺 `len(webp) >= len(orig)` 回退兜底(小图/线框图可能越转越大)。
|
||||
- `format=="webp"` 会二次有损重编(实测 160KB→140KB,体积收益不确定但画质丢一代)。
|
||||
- `s3.go` 对同一张图**解码两遍**(99 行取format + 181 行完整解码),4096² 下峰值 ~64MB/张。
|
||||
- `route/question/page.go:740` PutObject **缺 CacheControl**(`s3.go:158` 有 immutable)。
|
||||
- `s3.go:187` 只判 `Dx()>1920`,不限制长边 → 竖图 1080×10000(10.8M 像素 < 16.7M 上限)不缩放。
|
||||
|
||||
### 实测方法
|
||||
本机无 libwebp/GCC,Go 侧未编译。体积基线用 Pillow(绑定同一份 libwebp 1.4.0)
|
||||
对 `outputs/*.png` 真实截图测 m4/m6、q75/80/85、lossless。
|
||||
`imaging.Lanczos` vs Pillow LANCZOS 的差异未纳入。
|
||||
**method=6 的编码耗时未实测** —— 这是决定是否采用的关键约束(m6 约为 m4 的 5~10 倍耗时,1C1G 需实测)。
|
||||
|
||||
### 方案分批
|
||||
- 批次 A(低风险):按用途分档(头像 512/q78、背景 1920/q80、通用 1600/q80)、
|
||||
加大小回退、webp 透传、限长边、补 CacheControl、GIF 缺 cwebp 补 Warn。
|
||||
- 批次 B:method=6(需先Dockerfile 装 webp + 实测耗时)、EXIF 归一、统一 GIF 实现、降内存。
|
||||
|
||||
---
|
||||
|
||||
## 批次 A 已落地(同日)
|
||||
|
||||
用户确认**不使用 Docker**(webp 包/cwebp 相关影响可忽略),故批次 A 全部走 Go 侧实现。
|
||||
|
||||
### 改动文件
|
||||
1. `internal/storage/s3.go`(LF)
|
||||
- 新增导出 `EncodeSpec{MaxSide, Quality}` + 档位 `avatarEncodeSpec(512/78)`、
|
||||
`backgroundEncodeSpec(1920/80)`、**导出** `GeneralEncodeSpec(1600/80)`
|
||||
(导出是因为 `route/question/page.go` 要传参给 `ConvertStaticImageToWebP`)。
|
||||
- `uploadPictureToS3` 新增第 4 参 `spec EncodeSpec`;3 个 Upload* 入口各自传档位。
|
||||
- `ConvertStaticImageToWebP(r, spec)`:签名变了,用 `imaging.Fit` **同时限宽高**
|
||||
(原 `Resize(img, maxWidth, 0, ...)` 只限宽,1080×10000 竖图不缩放)。
|
||||
- 新增 `case "webp"`:已是 WebP 且尺寸合规 → 原样透传,不再二次有损。
|
||||
- 所有转换分支加 `len(webpData) >= len(原图)` 回退兜底。
|
||||
- `cwebp` 缺失时 `logrus.Warn`(原先完全静默);新增 `sirupsen/logrus` 导入。
|
||||
2. `route/question/page.go`(CRLF,792→794 行)
|
||||
- 同款 webp 透传 + 体积兜底;用 `image.DecodeConfig` 拿尺寸避免多余全量解码。
|
||||
- PutObject 补 `CacheControl: public, max-age=31536000, immutable`。
|
||||
3. `dev/s3stub/s3_stub.go`、`dev/webpstub/webp.go`:桩签名同步。
|
||||
4. 新增 `internal/storage/encode_spec_test.go`(`fitsWithin` 边界 + 档位值断言)。
|
||||
|
||||
### 验证结果(全部实测)
|
||||
- **编译**:本机无 libwebp/GCC,用**临时副本 + `go mod edit -replace github.com/chai2010/webp=../webpstub`**
|
||||
让**真实 s3.go** 参与编译(比 `s3stub` overlay 强,后者会把 s3.go 换成桩、等于没检查它)。
|
||||
`go build ./...` EXIT=0,`go vet ./...` EXIT=0。
|
||||
- **测试**:`go test ./internal/...`(带 PG)全绿,含 `internal/storage`。
|
||||
- **兜底逻辑有效性实测**(Pillow,同 libwebp 1.4.0):
|
||||
- 头像真实路径(前端 cropper 出 512×512 **PNG**,quality:null 无损)→ WebP 10,486 vs PNG 103,054,用 WebP。
|
||||
- 线框图 200×200 → WebP 908 vs PNG 652,**回退 PNG**(兜底生效,这就是不加兜底会踩的坑)。
|
||||
- 纯色 1600×900 → WebP 2,672 vs PNG 6,390,用 WebP。
|
||||
|
||||
### 关键坑
|
||||
- **`go build -overlay` 不能替换 GOMODCACHE 下的文件**(Go 明确报错)。
|
||||
唯一可行的本机方案是**复制项目到临时目录 + `go mod edit -replace`** 到本地 stub 模块。
|
||||
- 前端头像 cropper 输出 PNG(`profile.html:1391` `fileType:'image/png', quality:null`),
|
||||
背景是 jpeg q0.92 —— 服务端转 WebP 的收益主要来自这里。
|
||||
- 站点模板实际展示尺寸:头像 120px(`.tb-frame-avatar`),背景铺满宽度,
|
||||
私聊图 `max-width:75%`,提问图 100×100 缩略。→ 头像档 512 有据可依。
|
||||
|
||||
### 批次 A 未覆盖
|
||||
- method 仍锁在 4(`chai2010/webp` 只暴露 Quality/Lossless/Exact)。
|
||||
- EXIF 方向未处理、GIF 双实现未统一、同一张图仍解码两遍 —— 见下方批次 B。
|
||||
|
||||
---
|
||||
|
||||
## 批次 B1+B2 已落地(同日,用户批准)
|
||||
|
||||
**用户澄清:生产是裸 Linux(不用 Docker),且能确保部署机装了 `cwebp`。**
|
||||
|
||||
### 🔴 重要:原报告的 method=6 结论被实测推翻
|
||||
原报告写「m6 耗时是 m4 的 5~10 倍」——**未实测,错的**。实测(取中位数、颠倒顺序防干扰):
|
||||
|
||||
| 场景 | m4 | m6 | 倍数 | 体积差 |
|
||||
|---|---|---|---|---|
|
||||
| 1600x4830 (7.7MP) | 1867ms | 2229ms | 1.2x | -2.9% |
|
||||
| 1920x1080 背景 | 464ms | 655ms | 1.4x | -3.3% |
|
||||
| 1200x800 配图 | 253ms | 492ms | 1.9x | -3.0% |
|
||||
| 512x512 头像 | 60ms | 81ms | 1.4x | -3.5% |
|
||||
|
||||
**结论:代价远小于预估,但收益只有 3%——1.2~1.9x CPU 换 3% 不划算,决定不做 method=6。**
|
||||
(另注:Pillow 绑的是 libwebp 1.6.0,项目是 1.4.0,耗时数值仅供相对比较。)
|
||||
|
||||
### B1 EXIF 方向归一
|
||||
- 新增 `internal/storage/exif.go`:`jpegOrientation`(手工解析 APP1/Exif + TIFF IFD0,
|
||||
**不引新依赖**,项目原本无 EXIF 库)、`applyJPEGOrientation`(走 `orientedImage` +
|
||||
`draw.Draw` 一次性拷贝,支持全部 8 种方向)。
|
||||
- 接入 `ConvertStaticImageToWebP`,**顺序必须是「先归一方向,再限边缩放」**——
|
||||
归一会交换宽高,顺序反了会按错误的边长做缩放判断。
|
||||
- 为何不可事后修:**实测转码成 WebP 后 EXIF 被整体丢弃**(字节流无 `Exif` 标记),
|
||||
方向信息永久消失。且 `image.Decode` 与 Pillow 默认都不读 EXIF,已实证。
|
||||
|
||||
### B2 GIF 统一
|
||||
- `convertGifToAnimatedWebP` 导出为 `ConvertGifToAnimatedWebP`,`page.go` 的 GIF 分支
|
||||
改为复用同一实现(此前 `page.go` 完全跳过转换)。
|
||||
- 新增 `maxGIFFrames = 200` + `gifFrameCount`(遍历数据块统计 Image Descriptor,
|
||||
**不解码像素**,常数级开销)。`CheckPixelLimit` 只看 GIF 逻辑屏尺寸,
|
||||
500 帧 2000x2000 的 GIF 体积小但会让 cwebp 长时间吃 CPU。
|
||||
- cwebp 参数补 `-metadata none`。
|
||||
- 两处 GIF 分支都加 `len(webp) >= len(原图)` 兜底。
|
||||
|
||||
### 验证
|
||||
- `go build ./...` EXIT=0、`go vet ./...` EXIT=0、
|
||||
`go test ./internal/... ./route/...`(带 PG)**全绿**。
|
||||
- 新增测试 `exif_test.go`(JPEG 手工构造 APP1 段,覆盖大小端/越界/截断/非 JPEG;
|
||||
8 种方向逐像素校验)、`gif_test.go`(1/12/200/201 帧 + 垃圾输入)。
|
||||
|
||||
### 踩坑记录(都是我自己写错的)
|
||||
1. **`gifFrameCount` 恒返回 0**:漏跳 7 字节 Logical Screen Descriptor,
|
||||
|
||||
`pos` 停在 LSD 首字节(宽度低位)→ 首轮命中 `default` 返回 0。
|
||||
LSD 的 packed 在 `data[10]`(=pos+4),**必须无条件 pos += 7 再按 flag 加色表**。
|
||||
2. **`buildJPEGWithOrientation` panic**:`entry[10:12]` 只有 2 字节却调 `PutUint32`。
|
||||
IFD 条目固定 12 字节,next-IFD 偏移占最后 2 字节,保持 0 即可。
|
||||
3. **`image.Config` 没有 `Dx()/Dy()`**,是 `Width`/`Height` 字段。
|
||||
4. **`color.RGBA().RGBA()` 返回 alpha 预乘值**,不能简单和 `v<<8` 比较,
|
||||
要四个通道一起比。
|
||||
5. **WebP 桩不产出字节** → 依赖真实编码器的端到端断言在本机无法成立,
|
||||
已改为 `t.Skip`(桩环境跳过),像素级映射由单测覆盖。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 部署后线上验证(用户已部署 box-dev-api)
|
||||
|
||||
新增 `dev/devcheck-avatar.py`(头像链路,可复跑)。**A 分档线上确认生效**:
|
||||
上传 1600x1600 JPEG(124,941 B)→ 存储为 **512x512 WebP, 1,496 B**。
|
||||
|
||||
### 🔴 线上验证暴露的真实缺口(待修)
|
||||
**EXIF 归一的位置太晚,比例校验看不到归一后的方向。**
|
||||
- `s3.go:110` `asp.check()` 只用 `image.DecodeConfig` 拿宽高,**不读 EXIF**;
|
||||
- EXIF 归一在 `ConvertStaticImageToWebP` 内部(第 240+ 行),**晚于比例校验**。
|
||||
- 后果:手机竖拍照片(像素 4032x3024 + Orientation=6,实际显示 3024x4032)
|
||||
直传头像会被判「比例不符 1:1(偏差 0.333)」而拒绝——**尽管用户看到的是方形**。
|
||||
实测线上确实如此:传 1536x512+EXIF=6 被拒,头像 URL 未变。
|
||||
- 前端走 cropper(canvas 会归一方向)不受影响,故只影响**绕过前端的直传**。
|
||||
- 修法方向:`asp.check` 先读 `jpegOrientation`,对 5/6/7/8 交换宽高再比比例。
|
||||
别为一次校验就完整解码(大图内存),只做 EXIF 段解析 + 宽高交换即可。
|
||||
|
||||
### 验证脚本踩坑
|
||||
- 提问成功页**不渲染图片**(`templates/question/success.html` 无图片区),
|
||||
图片只在 `question.go:50` 的**详情页**渲染;且提问图 key **不带 `picture/` 前缀**
|
||||
(是 `<年>/<月>/<纳秒>.<ext>`),头像才带 `picture/`。→ 断言正则要分开写。
|
||||
- `/user/profile/update` 是完整资料表单,`UpdateProfile` 有 12+ 个 `required` 字段
|
||||
(`internal/form/user.go:7`)。只发 `_csrf` 会被挡在 Bind 阶段、头像根本不上传。
|
||||
做法:从 profile 页**抓取所有 input/select/textarea 的 name=value 回填**。
|
||||
- 头像上传成功是 **302**,头像 URL 在跳转后的页面里(不是响应体)。
|
||||
|
||||
## 补完 B1 + B3(2026-10-15 夜)
|
||||
|
||||
### B1 补完:EXIF 比例校验缺口
|
||||
- `aspect.check` 签名改为 `(cfg image.Config, orientation int) error`——**不再自己DecodeConfig**,
|
||||
由调用方传入已解析的宽高,顺带消除一次重复的文件头读取。
|
||||
- 内部:`orientationSwapsDimensions(orientation)` 为真(5/6/7/8)则交换 w/h 再比比例。
|
||||
- `imagecheck` 新增 `CheckAspectRatioSize(w, h, wantW, wantH, tol)`;
|
||||
`CheckAspectRatio(r,...)` 改为薄封装(内部走 DecodeConfig + 调Size 版),行为不变。
|
||||
- `exif.go` 抽出 `orientationSwapsDimensions`,`applyJPEGOrientation` 里的 switch 改为调它(去重)。
|
||||
|
||||
### B3:合并重复解码(峰值内存减半)
|
||||
- 根因:`uploadPictureToS3` 里 `image.Decode` 为拿 format 解一遍 → `ConvertStaticImageToWebP` 内再解一遍。
|
||||
- 改法:`image.DecodeConfig` 一次同时拿 **format + 宽高**(不解像素),
|
||||
供「像素上限校验 / 比例校验 / WebP 透传判断」三处共用;完整解码只发生在真正要转码时。
|
||||
- `ConvertStaticImageToWebP` 签名 `(io.Reader, spec)` → `([]byte, spec)`:
|
||||
调用方手里已有原始字节,不必再包一层 `bytes.Reader`(也不必内部 `io.ReadAll` 复制)。
|
||||
内部随之删掉重复的 `CheckPixelLimit`(调用方已在解码前校验过)。
|
||||
- `route/question/page.go` 同步:删掉 `image.Decode`取 format 与 `DecodeConfig`取 cfg 的两次调用,
|
||||
改为一次 `DecodeConfig`;新增本地 `fitsWithinSide(cfg, maxSide)`。
|
||||
- `dev/s3stub/s3_stub.go` 同步签名(去掉 `io` import)。
|
||||
- **实测堆峰值增量**(runtime.MemStats,含保留引用强制不回收):
|
||||
| 输入 | 一次解码 | 两次解码 |
|
||||
|---|---|---|
|
||||
| JPEG 4000×3000 (2.3MB) | 18.1 MB | 36.1 MB |
|
||||
| PNG 3000×3000 (33KB) | 36.1 MB | 72.1 MB |
|
||||
→ 正好 2 倍。1C1G 上并发上传大图时这是决定性的。
|
||||
|
||||
## 线上验证通过(2026-10-16 上午)
|
||||
|
||||
脚本:`dev/devcheck-upload.py`(**取代 `dev/devcheck-avatar.py`**),12/12 全通过。
|
||||
|
||||
### ⚠️ 重要纠正:头像路径测不出 EXIF 比例校验缺口
|
||||
头像要求 **1:1 是对称比例** —— EXIF 归一(5/6/7/8 交换宽高)后比例**不变**。
|
||||
所以修没修缺口,头像路径表现完全一样。之前 `devcheck-avatar.py` 那个
|
||||
「1536x512 + ori6」用例**根本测不到点上**(3:1 和 1:3 都不是 1:1,两边都会被拒)。
|
||||
**能触发缺口的是背景 16:9(非对称)**。
|
||||
|
||||
### 核心用例(背景 16:9 + EXIF Orientation=6)
|
||||
像素 1080x1920(竖,比例 0.5625)+ ori6 → 肉眼看到 1920x1080(16:9)
|
||||
- 修复前:按像素判比例,偏差 |0.5625 - 1.7778| = 1.215 >> 0.01 → **被误拒**
|
||||
- 修复后:先交换宽高,偏差 0 → 放行
|
||||
|
||||
### 线上实测数据
|
||||
| 用例 | 输入 | 存盘结果 |
|
||||
|---|---|---|
|
||||
| A 分档头像 | 1600×1600 JPEG 125,865 B | webp (512,512) **1,542 B** |
|
||||
| 背景正向对照 | 1920×1080 JPEG 93,484 B | webp (1920,1080) 7,582 B |
|
||||
| **背景 EXIF ori6** | 1080×1920 JPEG 138,842 B | webp (1920,1080) 9,900 B |
|
||||
| 反向用例 | 1080×1920 无 EXIF | **被 16:9 校验拒绝** ✅ 未把校验放宽 |
|
||||
|
||||
### 像素级方向验证(尺寸对≠ 方向对)
|
||||
与 **Pillow `ImageOps.exif_transpose` 作黄金标准**比「红色标记条包围盒」:
|
||||
```
|
||||
黄金标准(Pillow exif_transpose) 红条bbox=(0, 0, 1916, 88) -> 顶部
|
||||
线上存盘(本项目 WebP) 红条bbox=(0, 0, 1916, 88) -> 顶部
|
||||
```
|
||||
**完全一致** → 方向归一正确。这条已固化进脚本(`redness_bbox()` + 一条断言)。
|
||||
|
||||
### 踩坑
|
||||
- 用「上下左右取平均亮度」判断红条位置**不可靠**:采样带(H//10=108px)比红条本身
|
||||
(w//12=90px)还宽,红色被稀释成背景色 → 误判「方向未归一」。
|
||||
正确做法是**按颜色阈值 r>150&&g<90&&b<90 定位像素包围盒**。
|
||||
- 排查时**必须先建立黄金标准**再判定「我实现错了」,否则会去改本来正确的代码。
|
||||
@@ -0,0 +1,461 @@
|
||||
# 2026-10-18
|
||||
|
||||
## 运营侧内容治理:饭圈黑话拉踩/挂人问题(诊断 + 方向)
|
||||
|
||||
用户观察到部分箱主页面出现拉踩、黑话辱骂、挑动群体对立(样例 `/_/52mxtx1314`,85 条问答,
|
||||
内容为「避雷/壁垒/挂人 + 圈内代称」,如咸菜坤/县城坤/猪肝/澄毒/忘羡姐/端水洁癖/蟹脚coser)。
|
||||
|
||||
### 已核实的事实(代码侧)
|
||||
|
||||
- `moderation.Check` 已覆盖提问入口(`route/question/page.go:441`)、回答、私聊、投票、追问;七牛只硬拦
|
||||
politics/terrorism/porn,`review` 一律放行 → **通用审核对黑话零命中**,样例文本无一个传统脏字。
|
||||
- 箱主自建屏蔽词上限:`conf.Limits.MaxBlockWords=10`、`MaxBlockWordLen=10`(`internal/conf/limits.go`),
|
||||
对「一人一个代称」的场景容量不匹配。
|
||||
- **全站无举报入口**:`grep Report` 无结果;处置能力只有箱主侧 Delete / SetPrivate / SetPublic。
|
||||
- 现有成本控制:30 次/分钟限流、数学验证码、同 IP+同内容重复拦截(`shared.DuplicateGuard`)、
|
||||
`harassment_setting` 的 register_only 档。`autoban` 只针对后台路径扫描,不涉及内容行为。
|
||||
- `Question.FromIP` 已落库但**未被任何运营/聚合逻辑消费**。
|
||||
- 单题页/提问箱页已全局 noindex(`internal/context/context.go:243` 的非公开页分支)→ SEO 不是主要传播面,
|
||||
扩散主要靠截图外传。
|
||||
|
||||
### 关键判断
|
||||
|
||||
根因是**激励结构**而非审核强度:匿名零身份 + 提问进后台默认不公开 + 箱主逐条回答后公开(样例中连「?」这类
|
||||
单字回答也算)+ 页面可分享。攻击者收益是「被朗读、被围观」,不是「发出去」。因此加词库/调最严档
|
||||
投入产出极低且放大误伤。方向按三层:结构层降低收益(已回答≠自动公开、含第三方指涉的投稿默认私密)、
|
||||
成本层用行为信号(来源配额、同 token 聚合)替代词库、处置层补举报-下架-按来源封禁并留痕。
|
||||
|
||||
本次仅给方向,未改代码。下一步待用户选定:出具体变更清单,或先落「举报 + 批量处置」最小闭环。
|
||||
|
||||
## 追加:箱主主动引导投稿(处罚口径讨论)
|
||||
|
||||
用户澄清:本例是**箱主引导用户投稿挂人**,不是被动受害;提议"禁用提问 N 天,多次累计直接封号"。
|
||||
|
||||
### 新核实的事实
|
||||
|
||||
- **单题分享卡是公开的一键外传通道**:`QuestionShareCard`(`route/question/share.go:792`)只要
|
||||
`answer != "" && !IsPrivate && question.UserID == user.ID` 就渲染整张卡片(含箱主头像/背景/水印/二维码),
|
||||
路由 `GET /_/{domain}/{questionID}/share-card` 在公开的 `Questioner` 分组内。挂人内容因此可以变成无痕图片流出站外。
|
||||
- **首页/探索分发无任何信誉门槛**:`users.show_in_home` 由箱主自选,`GetShowInHome` / `GetByTag` /
|
||||
`getRandomUsers` 只按 created_at 或随机排序,不查举报/处置记录。高互动挂人箱可直接获得平台曝光。
|
||||
- **per-user 功能开关已存在**:`feature_controls`(user_id + feature + enabled + 时间窗 + quota),
|
||||
`FeatureOpen` / `FeatureStateOf` 是唯一判断点 → 但它是**箱主自设**,`Save` 由本人调用,
|
||||
若复用为处罚,箱主可自行 `enabled=true` 解除,语义也混淆。处罚需独立 "平台限制" 层,优先级高于用户设置。
|
||||
- `User` 模型无任何限制/处罚字段;现有只有 Deactivate(自停用)、BanUserAndCleanup(物理删)、restore。
|
||||
- 「公开可见 = 已回答」(`Questioner` 已挡私密/未回答),所以**「回答」这个动作同时是生产与公开**,处罚打这里最准。
|
||||
|
||||
### 立场(待用户确认)
|
||||
|
||||
- 赞同罚箱主:主动引导投稿属滥用产品组织围攻,不是内容治理能覆盖的。
|
||||
- 反对罚「提问(收信)」:伤害来自「发布」,禁收信对已公开的 85 条无效,且给箱主"被平台打压"叙事。
|
||||
正确梯子是从传播面往下收:停止分发 → 收窄公开 → 冻结发布(回答/公开)→ 停用页面 → 封禁。
|
||||
- 反对「多次被禁自动封号」:本场景举报本身是攻击武器,自动级联等于给组织化举报装自动扳机。
|
||||
必须"举报成立(带证据快照)+ 人工判定 + 可申诉 + 留痕"。
|
||||
- 判据必须用行为指纹而非文本关键词:挂人内容占比、独立指涉对象数、处置后复发率。
|
||||
- ⚠️ 本例箱主自述被"澄毒长期辱骂",可能是互殴一方 → 流程必须双向取证,否则单边处罚会激化对立。
|
||||
|
||||
## 追加:改为运营者人工裁定(用户倾向)
|
||||
|
||||
用户提出:被举报不自动封号,交由运营者本人判断。此方向认可,但补三条前提,否则会退化成"看心情处置":
|
||||
|
||||
1. 判定标准必须**先书面化**(什么算挂人:可识别第三方 + 贬损/号召规避/曝光主页 + 无公共利益),
|
||||
否则同一人不同时间判出不同结果,比误封更伤公信力。
|
||||
2. **证据包自动生成**(内容快照 + 来源 IP 聚合 + 该箱主历史处置 + 同 token 的关联条目),
|
||||
人工只做裁决,不做取证的体力活。
|
||||
3. 处置必须**留痕 + 可申诉 + 有冷静期**(举报后 24h 再判),这是平台履行"通知-删除"义务的自证材料;
|
||||
自动封号反而更难自证。
|
||||
|
||||
分级矩阵(裁定 × 内容性质)作为处置口径:不成立→不处理(C 类转人工);成立首次→A 提示整改、
|
||||
B L1 停止分发、C L1+L2;成立复发→A L1、B L2、C L3;恶意持续→A L2、B L3、C L4→L5。
|
||||
|
||||
互殴/选边风险:无法判定过错方时**对等处置**(双方同 L1),不选边;运营者本人有圈内立场,
|
||||
封号类必须二次复核 + 统一模板公告,不写个人评价。L1 类建议静默生效(不单独通知),
|
||||
避免暴露"平台在盯内容"后转为图片/缩写等更隐蔽表达。
|
||||
|
||||
运营侧还缺一个"处置台":举报列表、证据聚合、一键执行 L1–L5、留痕查询 —— 目前完全没有。
|
||||
|
||||
## 追加:用户明确要求「不要后台」,改为无 UI 实现
|
||||
|
||||
审阅后确认项目结构:`/user` 是箱主自助面板(`reqUserSignIn`),**无 admin 角色、无站方后台**;
|
||||
CLI 骨架在 `cmd/tamabox.go`(`internal/cmd/*.go` 每个命令一个文件,范式见 `ban.go`:`conf.Init` → 拼 DSN →
|
||||
`db.Init` → 操作 → 确认 → 执行)。告警可用 `conf.Security.SecurityAlertEmail`(`internal/conf/static.go:70`,
|
||||
autoban 已在用)+ `mail.SendSecurityAlertMail`。
|
||||
|
||||
因此"处置台"改为三条非 UI 腿:
|
||||
1. **举报采集**:单题页举报按钮 → `content_reports` 表;对箱主可见走 `/user/questions?filter=reported`
|
||||
(这是他自己的收件箱,不是站方后台),举报的处置第一责任人仍是箱主。
|
||||
2. **告警**:达到阈值(同一箱主 24h 内 N 条举报)发邮件到 `security_alert_email`,这是运营者唯一需要的"入口"。
|
||||
3. **处置**:`TamaBox reports --domain` 出证据包、`TamaBox restrict --domain --level --days --reason`、
|
||||
`TamaBox unrestrict`。状态写 `user_restrictions`(带 `expires_at`,判断时比较时间 → **过期自动失效,不需要 cron**)。
|
||||
|
||||
生效点(已定位):`QuestionShareCard`(share.go:808)、`GetShowInHome`/`GetByTag`/`getRandomUsers`
|
||||
(users.go:620/643/749,注意 GetRandom 有 30s 缓存)、`FeatureOpen`(feature_control.go:364,可一处覆盖箱主自设)、
|
||||
`PublishAnswer`/`SetPublic`/`AnswerFollowUp`、`question.Pager`(L4 整页停用)。
|
||||
|
||||
关键约束(已向用户说明):不能复用 `feature_controls`(箱主可自行 `enabled=true` 解除,语义混淆);
|
||||
不用配置文件方案(要重启、无法表达到期时间);无后台 ⇒ 只能**事后处置**,不能事前审核,口径需写成
|
||||
"发布后 24–48h 内可被处置";举报必须防刷(同 IP + 同 question 唯一),否则邮箱会被刷爆。
|
||||
|
||||
## 已落地(第 1–2 步 + L1 两个出口),已实测
|
||||
|
||||
备份:`C:/OtherFiles/Github/TamaBox-v4-mainline-before-abuse-restrict-20261018.tar.gz`(3.2M,排除 .git/.workbuddy)。
|
||||
|
||||
新增/改动:
|
||||
- `internal/db/restriction.go`(新):`RestrictionLevel` L1–L4、`UserRestriction`、`ContentReport`、
|
||||
`RestrictionsStore`(Create 替换语义 / RevokeActive / ActiveLevel / ActiveLevels / ListByUserID)。
|
||||
两个模型都**刻意不嵌 dbutil.Model**:处置记录与举报都不软删(撤销走 `revoked_at`),
|
||||
且带 `DeletedAt` 的唯一索引会留下"删了仍占槽位"的陷阱。
|
||||
- `internal/cmd/restrict.go`(新):`reports` / `restrict` / `unrestrict`,复用 `fmtScan` 做 YES 确认。
|
||||
- `internal/db/db.go`:`AllTables` 加两表,`db.Init` 装配 `Restrictions`。
|
||||
- `tamabox.go` + `cmd/tamabox.go`:注册三个命令(并给根入口补上一直缺失的 `restore`)。
|
||||
- `route/question/share.go`:`QuestionShareCard` 在任何级别处置下都拒绝生成分享图(fail-closed)。**L1 核心出口**。
|
||||
- `internal/db/users.go`(CRLF,用 python 脚本改):`GetShowInHome` / `GetByTag` / `getRandomUsers`
|
||||
加 `id NOT IN (activeRestrictionsSubQuery(...))`,把被处置箱主从首页/探索/标签/随机位摘除。
|
||||
`GetRandom` 有 30s 缓存 → 随机位最多滞后 30s,其余即时。
|
||||
- `internal/db/restriction_test.go`(新):覆盖替换 / 升级 / 到期失效 / 撤销幂等 / 曝光排除。**测试全绿**(3.87s)。
|
||||
|
||||
实测踩到的两个坑:
|
||||
1. **只改 `cmd/tamabox.go` 命令不生效** —— 真正入口是根 `tamabox.go`,报 `No help topic for 'reports'`。
|
||||
2. 证据包比例首版写成「举报次数 / 公开回答数」,实测出现 **166.7%**(5 次举报 / 3 条回答)。
|
||||
已改为「被举报**条目数** / 公开回答数」,举报次数单独展示。
|
||||
|
||||
CLI 冒烟(临时 PG 库 + 临时 ini):reports 证据包 → restrict L1 → 证据包显示生效 → pending 列表 →
|
||||
unrestrict → 显示"已撤销";升级 L1→L3 后旧记录 `revoked_at` 置位、新记录 L3 生效。清理已 DROP 临时库。
|
||||
|
||||
未做(下一步):L2/L3 发布侧拦截(`PublishAnswer`/`SetPublic`/`AnswerFollowUp` + `FeatureOpen` 覆盖箱主自设)、
|
||||
L4 整页停用、举报按钮与箱主面板筛选、邮件告警。分享卡拦截未做 web 端到端验证(仅编译 + 单测覆盖 store 层)。
|
||||
|
||||
## 已落地(第二批:L2 / L3 / L4 发布侧),编译与单测通过,web e2e 受沙箱限制未做
|
||||
|
||||
- `internal/db/restriction.go`:加 `restrictionLevelRank` + `RestrictionAtLeast(level, target)`。
|
||||
显式权重而非字符串比较(将来加 L10 不会静默排错)。空/未知级别恒 false,防止脏数据锁死箱主。
|
||||
- `route/question/question.go`(CRLF,python 改):
|
||||
- `PublishAnswer`:L3+ 拒绝(`question.errRestrictedPublish`);L2 **先 SetPrivate 再 AnswerByID**,
|
||||
保证任何时刻都不出现「公开 + 已作答」的中间态(不是先答后藏)。
|
||||
- `SetPublic`:L2+ 拒绝(`question.errRestrictedPublic`),否则等于箱主自行解除处置。
|
||||
- `route/question/follow_up.go`:`AnswerFollowUp` L2+ 拒绝 —— 追问回答同样是公开内容且没有私密档可降。
|
||||
- `route/question/page.go`:`Pager` 里 L4 → `Redirect("/restricted")`。此处 **fail-open**
|
||||
(DB 出错放行):它不承担外传通道职责,fail-closed 会让所有箱子一起白屏。
|
||||
- `route/user/restriction.go`(新)+ `templates/user/restricted.html`(新)+ `internal/route/route.go`
|
||||
注册公开路由 `/restricted`(必须在 reqUserSignIn 之外)。
|
||||
- i18n 4 语言各加 5 键(`question.errRestricted*` / `question.flash.answeredPrivate` / `restricted.*`),
|
||||
`TestDictionaryComplete` 通过。i18n 与 route 文件均为 CRLF:用 python 插行 → `gofmt | 转 CRLF` 写回。
|
||||
|
||||
修正上一轮的判断:**`FeatureOpen` 不需要改**。L3 的语义是「冻结发布、仍可收信」,而 feature_controls 控制的是
|
||||
收集(收信);关掉它反而违背 L3 定义。L4 已经由 Pager 整页挡住。
|
||||
|
||||
验证:`go build ./...` EXIT=0;`TestRestrictions` / `TestRestrictionLevelValidation` / `TestRestrictionAtLeast`
|
||||
/ `TestDictionaryComplete` 全绿。**L4 与分享卡的 HTTP 行为未验证** —— 本沙箱无 redis,
|
||||
`TamaBox web` 启动即 panic,详见 MEMORY.md 的构建与测试一节。
|
||||
|
||||
## 追加:部署自检脚本(用户要部署到测试服务器)
|
||||
|
||||
新增 `dev/devcheck-restrict.sh`:串起「基线 → 施加 L4(断言 302 + /restricted 200 + 文案)→ 降级 L1
|
||||
(断言页面 200 + 分享卡 302)→ 撤销」,默认 dry-run,`RUN=1` 才执行,`trap EXIT` 兜底撤销。
|
||||
只依赖 CLI 与 curl,不需要 psql。`bash -n` 通过,LF 行尾。
|
||||
|
||||
同时核实:`Dockerfile` 的构建命令是 `go build ... ./cmd/`(不是根目录),所以两个入口必须同步 ——
|
||||
已写进 MEMORY.md。部署前必须先确认 `./NekoBox --help` 里有 `restrict`,否则是旧产物。
|
||||
手册里另注明两项只能人工手测:L2(回答应保存为私密)、L3(回答应被拒绝)。
|
||||
|
||||
### 首次实跑反馈(用户 dry-run)
|
||||
|
||||
用户给 `DOMAIN=admin56` 跑 dry-run,结果 2 项 FAIL。日志证据:`SELECT * FROM "users" WHERE domain='admin56'`
|
||||
→ **record not found**,说明该 domain 不存在(很可能把账号名当成了 domain)。所有 `/_/admin56` 的 302
|
||||
都来自 `GetByDomain` 失败后的 `Redirect("/")`,与处置无关 → **L4 那项 PASS 是假阳性**。
|
||||
同时 dry-run 不施加处置,前后状态没变,状态断言本来就无意义 —— 是脚本设计缺陷。
|
||||
|
||||
已修 `dev/devcheck-restrict.sh`:
|
||||
1. 基线不是 200 直接中止并打印排查建议(302 常见原因 + 查真实 domain 的命令);
|
||||
2. dry-run 在基线后短路,不再做状态断言(只验命令存在、基线、停用页);
|
||||
3. 新增 1b「停用页是否部署」+ 文案断言,dry-run 也能验 i18n/模板。
|
||||
|
||||
教训(已与既有坑同类):**302 / 200 都不能单独证明处置生效**,必须先确立「基线 200」这个前提。
|
||||
|
||||
### 二次实跑:7/7 全绿(真实环境)
|
||||
|
||||
`DOMAIN=admin6`:基线 200 → L4 302 → L1 200 + 分享卡 302(qid=73)→ 撤销后 200。
|
||||
证明 L1 分享卡拦截、L4 整页停用、撤销、以及 L4→L1 的**替换语义**在真实库上都按预期工作。
|
||||
|
||||
**随后发现并修掉一个顺序缺陷(L2)**:原实现把「强制私密」放在 `moderation.Check` 之前,
|
||||
一旦审核失败(内容违规或审核源不可用,fail-closed),题目已被改成私密——箱主一次失败的编辑
|
||||
会让原有公开内容凭空消失。现已移到审核通过之后、`AnswerByID` 之前,仍满足
|
||||
「任何时刻不出现公开+已作答」(此刻它是未作答的私密题)。编译 EXIT=0、gofmt 干净。
|
||||
⚠️ 该修复需重新部署才可在测试站手测 L2/L3。
|
||||
|
||||
L2/L3 的手测断言(比看 flash 可靠):
|
||||
- L2 后 `select is_private, answer from questions where id=<qid>` → `t` 且 answer 非空,公开页不显示;
|
||||
- L3 后 `answer` 仍为空串(未被写入),`is_read` 不变。
|
||||
- 干扰项:回答会走七牛审核,审核不可用会 fail-closed 报「审核服务不可用」,那不是处置问题。
|
||||
|
||||
### L2 线上实测:功能生效,但「箱主无感知」被误判成没效果
|
||||
|
||||
用户报「L2 没效果」。我用匿名视角实测(这是我能独立做的部分):
|
||||
- 公开页 `/_/admin6` 只出现 73,**不含 88**;
|
||||
- 匿名访问 `/_/admin6/88` → **302 到首页**;对比 `/_/admin6/73` → 200。
|
||||
|
||||
结论:L2 私下里是生效的。用户误判的两个原因:
|
||||
1. 他**以箱主身份**看单题页 —— 箱主本来就能看自己的私密内容,页面当然「一切正常」;
|
||||
2. **我的 bug**:`question.flash.answeredPrivate` 只写进了 4 个 i18n 字典,**代码里从未使用**,
|
||||
成功后仍提示「回答发布成功」。已修:L2 分支改用 `forcedPrivate` 布尔切换 flash 文案。
|
||||
|
||||
教训:这些 i18n 键写完必须 grep 调用点;「加了文案」不等于「接上了逻辑」。
|
||||
|
||||
### 用测试账号自测(已跑通登录态全链路,不需要用户点击)
|
||||
|
||||
用户提供测试箱主 `admin6`(密码不落盘)。用 python + urllib 跑通:
|
||||
`GET /login` 取 `_csrf` → `GET /api/captcha/generate` 取 `{id, question}`(形如 `5 × 5 =`,
|
||||
注意乘号是 `×` 不是 `*`)→ 算答案后提交 `g-recaptcha-response="<id>:<答案>"` → 登录成功;
|
||||
再 `GET /_/admin6` 取表单 csrf → `POST /_/admin6`(字段 `content`)发提问 →
|
||||
`POST /_/admin6/{id}/answer`(字段 `answer`)。
|
||||
|
||||
实测结论(qid=89):
|
||||
- **L2 服务端生效**:回答保存成功,但该题变私密 —— 匿名访问 302 到首页,公开页只有 73;
|
||||
- **flash 仍是旧文案**「回答发布成功」→ 服务器跑的是 21:22 部署的二进制,我 21:45 的 flash 修复未部署。
|
||||
- 88(用户手测)与 89(我自检)都是测试数据,均已被 L2 强制转私密,测完需在后台清理或保持私密。
|
||||
|
||||
### L3 验证(重新部署 + 切 L3 后)
|
||||
|
||||
qid=90:发提问成功(L3 允许收信 ✔);`POST /answer` **没有重定向**、URL 停在 `/answer`、
|
||||
重新加载单题页仍有回答表单 → **发布确实被拒绝、内容未写入**。
|
||||
但响应里**没有任何提示文案**(`uk-alert` 只出现在 CSS 里)→ 又一个 bug:
|
||||
|
||||
⚠️ **同请求渲染不能用 `SetErrorFlash`**。`ctx.Success("question/item")` 是直接出页面(没有 302),
|
||||
而 `SetErrorFlash` 写的是 session、要等下一次请求才读 → 提示静默消失,箱主看到的是「点了没反应」。
|
||||
已把 3 处(PublishAnswer L3 / SetPublic L2 / AnswerFollowUp L2)改成 `ctx.SetError(...)`,
|
||||
并顺手修了 `follow_up.go` 里同类的 moderation 失败分支(既有缺陷)。编译 EXIT=0。
|
||||
**该修复需再部署一次**;部署后当前仍是 L3,可直接验提示是否出现。
|
||||
|
||||
### 重新部署后 L3 提示验证:通过
|
||||
|
||||
qid=90 再答一次:`ALERT: 当前处于平台处置期,已暂停发布回答` 已渲染;仍无重定向、回答表单仍在
|
||||
(未写入)。L3 全链路闭环。待办:用户要求后续做「`SetErrorFlash + Success` 全量清理」专项。
|
||||
|
||||
剩余未验:L2 的「已保存为私密」提示(需把 admin6 切到 L2 后自测),以及举报采集链(未开始)。
|
||||
|
||||
### L2 提示验证:通过(qid=90)
|
||||
|
||||
```
|
||||
post url: /_/admin6/90 (正常 302 回题目页)
|
||||
ALERT: 已保存为私密:当前处于平台处置期,回答不会公开
|
||||
匿名访问 90 -> 首页;公开页 ids: [73]
|
||||
```
|
||||
L1/L2/L3/L4 至此全部有真实环境实测证据:
|
||||
- L1:页面 200 + 分享卡 302(qid=73)
|
||||
- L2:回答成功 + 转私密 + 匿名不可见 + 专用提示
|
||||
- L3:回答被拒(无重定向、表单仍在、未写入)+ 拒绝提示
|
||||
- L4:整页 302 → /restricted 200 + 四语言文案
|
||||
|
||||
测试数据:88(用户手测)、89(L2 自检)、90(L3/L2 自检,现已答且私密),均需在测试站清理。
|
||||
admin6 当前处于 L2(到期次日),必要时 `unrestrict` 解除。
|
||||
|
||||
## 举报采集链(已落地,编译 + 单测 + 模板检查通过,未部署验证)
|
||||
|
||||
新增/改动:
|
||||
- `internal/db/report.go`(新):`ReportsStore` —— `Create`(唯一索引冲突静默为幂等成功)、
|
||||
`QuestionIDsByUser`(箱主列表标标,排除已删除提问)、`CountByUserSince`(告警阈值)。
|
||||
理由枚举 `harassment/doxxing/divisive/other` + `IsValidReportReason`。
|
||||
- `internal/form/question.go`:`ReportQuestion{Reason}`(`valid:"required;maxlen:32"`)。
|
||||
- `route/question/report.go`(新):`Report` handler。**不要求登录**(目击者是匿名访客);
|
||||
理由白名单;同 IP 同题幂等且提示与首次一致(避免变成内容存在性探测接口);只写库不改可见性。
|
||||
- `route/question/abuse_notify.go`(新):阈值告警 24h≥8 或 7d≥20,24h 冷却(进程内 map + mutex),
|
||||
复用 `conf.Security.SecurityAlertEmail`,`shared.SafeGo` 异步发送。**只提醒不自动处置**。
|
||||
- `internal/mail/mail.go` + `templates/mail/abuse-alert.html`:`SendAbuseAlertMail`。
|
||||
邮件只给计数与下一步命令,**不贴举报内容原文**(邮件会被转发归档,等于又复制一份挂人内容)。
|
||||
- `internal/route/route.go`:`POST /_/{domain}/{questionID}/report`,`rateLimitMiddleware(10, 1*time.Minute)`。
|
||||
- `templates/question/item.html`:回答下方 `⚑ 举报这条内容`,`<details>` 折叠 + 固定理由下拉,
|
||||
仅 `{{ if not .CanDelete }}`(非箱主)可见。
|
||||
- `templates/user/question-list.html` + `route/user/question.go`:箱主列表加「被举报 N 次」红色 badge,
|
||||
**只给数量不给举报人 IP**。
|
||||
- i18n 4 语言共 11 个新键(report.* / qlist.reported*)。
|
||||
|
||||
验证:`go build ./...` EXIT=0;`TestReports` PASS(首次写入 / 同 IP 幂等 / 异 IP 计数 /
|
||||
时间窗口 / 驳回后不计入);`TestDictionaryComplete` PASS;`go run ./dev/checktpl` → all templates syntax OK
|
||||
(项目自带模板语法检查工具,改模板后必跑)。
|
||||
|
||||
未做:箱主列表的「只看被举报」筛选(目前只有 badge);举报驳回/成立的处置动作(目前 status 只有 pending)。
|
||||
|
||||
### 举报链线上验证:4/4 通过
|
||||
|
||||
- 入口:单题页 `/73` 出现「举报这条内容」+ form action `/_/admin6/73/report`;
|
||||
- 首次举报:POST 302 → flash「已收到举报,感谢反馈」;
|
||||
- 同 IP 重复:同样 302 + 同样文案,无失败提示(幂等);
|
||||
- 箱主 `/user/questions`:出现「被举报 **1** 次」(多次重复提交后计数仍为 1,反证幂等生效)。
|
||||
|
||||
**过程教训**:中途 python urllib 报「读超时 25s」,一度怀疑服务端挂起。实测 curl 同一接口
|
||||
1.6~1.7s 正常返回 302,`location=/_/admin6/73`,手动 GET 也 200。真因是 python 走本地代理
|
||||
(`https_proxy=127.0.0.1:54881`)偶发挂起 + `/tmp` 路径在 Windows python 与 MSYS 之间不互通。
|
||||
已写进 MEMORY.md:**验证线上行为优先用 curl**。
|
||||
|
||||
### 告警触发验证(进行中)
|
||||
|
||||
直接 SQL INSERT 不会触发告警(`notifyAbuseIfNeeded` 只在 HTTP 举报 `created=true` 时调用)。
|
||||
正确姿势:SQL 补到 7 条 → 删掉自己那条旧记录 → 再用 HTTP 提交一次,使 count 恰好到 8。
|
||||
已按此执行(curl 提交返回 302 + 「已收到举报」),等待确认日志
|
||||
`Abuse alert mail sent` / `Failed to send abuse alert mail` / 什么都没有(= `security_alert_email` 为空)。
|
||||
|
||||
### 邮件告警验证通过
|
||||
|
||||
配置 `security_alert_email` + 重启后,补到 7 条再 HTTP 提交第 8 条,邮件成功收到;测试数据已清理。
|
||||
举报链至此全部闭环。
|
||||
|
||||
### 静默丢失专项清理(用户要求的那项)
|
||||
|
||||
静态扫描 `route/**/*.go`:flash 调用后第一个 `Success(`/`Redirect(` 判定,共 **9 处**缺陷
|
||||
(`follow_up.go` 2、`page.go` 1、`question.go` 1、`profile.go` 5),全部改为
|
||||
`SetError(err[, f])` / `SetInternalError()`。`Set*Flash + Redirect` 112 处正常,未动;
|
||||
`vote.go:702/704` 是扫描误报(其后 switch 之后才是 Redirect)。
|
||||
`go build ./...` EXIT=0;`go test ./internal/db/ ./internal/i18n/` 全绿(36s)。
|
||||
⚠️ 这 9 处只做了编译与静态验证,未逐条构造触发条件(需传超大图/失效追问 id),
|
||||
部署后实际遇到对应错误时观察提示是否可见。
|
||||
|
||||
### 举报加验证码 + 箱主列表「只看被举报」筛选
|
||||
|
||||
- 举报验证码:`form.ReportQuestion` 加 `Recaptcha`(**刻意不加 valid:required**,空值交给
|
||||
`captcha.Verify` 判定,否则 Bind 失败会被统一提示成「请选择一个举报理由」);
|
||||
`Report` handler 签名加 `recaptcha recaptcha.RecaptchaV3`,调 `captcha.Verify`(与提问/私聊同源)。
|
||||
模板按 `CaptchaType == "math"` 双分支:math 用隐藏域 + `.math-captcha-trigger`(header 的全局弹窗
|
||||
用 `btn.closest('form')` 定位表单、`form.querySelector('[name="g-recaptcha-response"]')` 回填,
|
||||
**不依赖 id**,所以举报表单可直接复用);非 math 用 `.g-recaptcha` + 自定义 callback `tbReportCaptchaOk`。
|
||||
- 筛选:`/user/questions?reported=reported`,复用已查出的 `reportCounts` 在内存过滤,
|
||||
新增 `SelectedReported` + 模板第 5 个下拉(reportFilter / reportedOnly)+ 4 语言文案。
|
||||
|
||||
验证:`go build ./...` EXIT=0;`go run ./dev/checktpl` → all templates syntax OK;
|
||||
`go test ./internal/i18n/` 通过。**未部署**,且加了验证码之后原来的 curl 测试方式要改成先算题再提交。
|
||||
|
||||
### 部署后验证:4/4 通过
|
||||
|
||||
- 无验证码提交 → 302、**未写库**、提示「验证码不能为空」(i18n key `captcha.errEmpty`,非硬编码);
|
||||
- 带正确算数验证码 → 302 + 「已收到举报,感谢反馈」;
|
||||
- 同 IP 重复(换新验证码)→ 同样成功提示(幂等,未新增记录);
|
||||
- 筛选:全部页 `73, 88, 89, 90`,`?reported=reported` 只剩 `73`,下拉 `option value="reported" selected`。
|
||||
|
||||
测试遗留:73 号题有 1 条举报(我的测试),需 `DELETE FROM content_reports WHERE question_id = 73;` 清理。
|
||||
|
||||
## 新增 `TamaBox hide-public`(存量批量处置)
|
||||
|
||||
`internal/cmd/hide_public.go` + 两个入口注册。默认只动「公开且已回答」的题;
|
||||
`--only-reported` 只处理有 pending 举报的(推荐);`--since N` 限时间窗。
|
||||
编译 EXIT=0,gofmt 干净。**未在服务器验证**(需部署)。
|
||||
|
||||
## 待办与建议(已告知用户,按优先级)
|
||||
|
||||
- P0 举报状态闭环:箱主删除/设私密后,该题 pending 举报应置 `accepted`,否则告警阈值会被已处理的旧举报
|
||||
不断推高(狼来了),且无法统计「成立率」。当前 status 永远停留在 pending。
|
||||
- P0 箱主侧处置可见性:`/user/questions` 顶部常驻条幅(读 restrictions),避免像今天这样 L2 生效但箱主不知情。
|
||||
- P1 箱主侧举报通知(某条 ≥3 次时告知箱主自查)、`reports --stats` 全站视图(发现恶意刷举报)。
|
||||
- P2 社区规则页 `/rules`,处置时引用条款。
|
||||
- 明确不建议:举报数达标自动处置(武器化)、箱主申诉后台(违背无后台约束)。
|
||||
|
||||
## 建议落地(P0/P1/P2 全部完成,编译 + 单测 + 模板检查通过,未部署)
|
||||
|
||||
- **P0 举报闭环**:`ReportsStore.MarkAcceptedByQuestion`,在箱主 `Delete` / `SetPrivate` 成功后调用
|
||||
(失败只记日志,不回滚用户操作);`hide-public` CLI 批量后同样标记。新增 `reportPendingTTL = 30d`:
|
||||
`QuestionIDsByUser` / `CountByUserSince` / `CountByQuestion` 都按时间窗过滤,**超期 pending 视为过期**,
|
||||
用查询过滤而不是定时任务(无后台项目不需要调度器)。
|
||||
- **P0 处置期条幅**:`/user/questions` 顶部读 `Restrictions.ActiveLevel` 渲染常驻 warning
|
||||
(`qlist.restrictionBanner`,四语言,带级别与到期日)。解决「L2 生效但箱主不知情」。
|
||||
- **P1 箱主自查提醒**:单条内容待处理举报**恰好等于 3** 时给箱主发邮件
|
||||
(`mail/question-reported.html`),用「恰好等于」避免每条举报都发;箱主 `Notify=none` 时不发。
|
||||
邮件明确写「不代表平台已判定违规」。
|
||||
- **P1 `reports --stats`**:全站 Top 被举报箱主 + Top 举报 IP,用于识别「举报被武器化」。
|
||||
- **P2 `/rules` 社区规则页**:公开页 + `templates/rules.html` + 四语言 10 个键。
|
||||
内容含「明确禁止的 4 类」「处置梯度 5 级」「被举报怎么办」「被攻击怎么办」,是处置的引用依据。
|
||||
⚠️ 文案由我起草,**需用户过目**(尤其对外承诺的措辞)。
|
||||
|
||||
验证:`go build ./...` EXIT=0;`checktpl` OK;`TestReports`/`TestRestrictions` 全绿。
|
||||
|
||||
### 部署后验证:3 项通过,1 项 500 待查
|
||||
|
||||
- ✅ 处置期条幅:登录 admin6 的 `/user/questions` 出现「限制期」文案(1 处);
|
||||
- ✅ 举报 badge:「被举报 1 次」;
|
||||
- ✅ 闭环:`POST /set-private` → badge 归 0、公开页不再出现 73;
|
||||
`set-public` 返回 200(不是 302)是**预期** —— L2 禁止设公开,不是 bug。73 现处于私密,需先 `unrestrict` 才能恢复。
|
||||
- ❌ **`/rules` 返回 500**(其余公开页 `/`、`/about-me`、`/sponsor`、`/deactivated` 均 200)。
|
||||
已在本地排除:模板语法 checktpl 通过、i18n 四语言键齐全(grep 确认在 map 内)、`t` 函数返回 string 不抛错。
|
||||
**待用户提供服务端日志**(500 只回 22 字节 "Internal Server Error",无细节)。
|
||||
|
||||
顺带修掉一个**我引入的回归**:`templates/render_test.go` 的 `mockItem` 缺 `ReportCount`,
|
||||
导致 `user/question-list` 渲染测试失败(`can't evaluate field ReportCount in type interface {}`)。
|
||||
已补字段;同时把 `{{ if gt .ReportCount 0 }}` 改为 `{{ if .ReportCount }}`(更宽容,零值即假)。
|
||||
`go test ./templates/` 恢复全绿。
|
||||
|
||||
### /rules 500 根因:模板没进 embed 清单
|
||||
|
||||
日志:`template: rendering error="html/template: \"rules\" is undefined"`。
|
||||
|
||||
**`templates/embed.go` 是显式文件清单**(并非我原先以为的整目录 embed)。新增的顶层
|
||||
`rules.html` 没写进 `//go:embed ...` 那行 → 没被打包 → `ctx.Success("rules")` 找不到模板 → 500。
|
||||
编译、checktpl、单测全部无感(测试不渲染该页),只有实际访问才炸。
|
||||
|
||||
已把 `rules.html` 加进清单,编译 EXIT=0、`go test ./templates/` 通过。
|
||||
⚠️ 以后新增**顶层**模板必须改 embed 指令;子目录下的文件已被目录项覆盖。
|
||||
MEMORY.md 里那条错误认知已纠正。
|
||||
|
||||
### /rules 修复后验证:四语言全通过
|
||||
|
||||
`rules=200`(39KB);标题依次为「社区规则与内容边界 / 社群規則與內容界線 /
|
||||
Community rules and content boundaries / コミュニティルールとコンテンツの境界」;
|
||||
处置梯度段落存在。
|
||||
|
||||
发现规则页**没有任何入口**(页脚原本全是外链,无站内链接),已加
|
||||
`<a href="/rules">` 到 `templates/base/footer.html` + `site.footer.rules`(四语言)。
|
||||
编译 EXIT=0、checktpl OK、i18n 与 templates 测试全绿。**需再部署一次生效**。
|
||||
|
||||
补充(用户要求):处置条幅里「如有疑问请通过页脚找站长联系」后面再挂一个 `/rules` 链接,
|
||||
用 `·` 分隔(`qlist.restrictionRulesLink`,四语言)。模板压成单行避免多余空白。
|
||||
编译 EXIT=0、checktpl OK。仍需部署。
|
||||
|
||||
## 规则页扩展 + 注册强制同意(用户要求)
|
||||
|
||||
- **规则分两类**(关键设计):`rules.forbidden` 重写为
|
||||
「一、严重违规(直接删内容 + 按情节封禁,**不适用分级**)」6 条(涉政/暴恐/色情涉未成年/违禁品赌博诈骗/侵害未成年人/隐私),
|
||||
「二、社区冲突(**按影响面分级**)」4 条(挂人、辱骂、群体对立、垃圾广告)。
|
||||
`rules.enforce` 相应说明「分级只适用于社区冲突;严重违规不走分级,涉法配合有关部门」。
|
||||
四语言同步。这样避免「挂人只停分发、涉政也停分发」的错误预期。
|
||||
- **注册强制同意**:`form.Register` 加 `AgreeRules string`(`form:"agree_rules" valid:"required"`,
|
||||
msg `form.register.agreeRules`);注册页在 language 之后加 checkbox + `/rules` 链接(`target="_blank" rel="noopener"`)。
|
||||
**服务端校验是必须的**:前端 required 可绕过,而这个勾选是「用户已知悉规则」的唯一凭证。
|
||||
- 顺带同步 `dev/devcheck-e2e.py` 的三处注册 POST,加 `"agree_rules": "1"`,否则 e2e 注册步骤会全部失败。
|
||||
|
||||
验证:`go build ./...` EXIT=0;checktpl OK;i18n 与 templates 测试全绿。
|
||||
未用 modal 而用新标签页:规则正文很长,iframe 弹窗体验差且要重复维护内容。
|
||||
|
||||
### 部署后验证(规则扩展 + 注册同意):全通过
|
||||
|
||||
- `/rules`:含「严重违规」(6 条底线条目 + 涉政表述)、「社区冲突」(4 条);
|
||||
- 四语言标题正确;
|
||||
- `/register` 有 `agree_rules` 勾选框 + `/rules` 链接;
|
||||
- **不勾选直接 POST 被服务端拦下**(提示走站点默认语言 ja:
|
||||
`コミュニティルールをお読みのうえ同意してください`),且**未创建账号**(`/_/tbprobe` 仍 302);
|
||||
- 页脚与处置条幅里的「查看社区规则」链接都在。
|
||||
|
||||
### UI 微调(已改,未部署)
|
||||
|
||||
- `rules.html` 底部「返回首页」容器加 `uk-text-center`(原先左对齐)。
|
||||
- `question/success.html` 两个按钮改为 flex 等宽:`flex: 1 1 160px; max-width: 220px`,
|
||||
容器 `uk-flex uk-flex-center uk-flex-wrap`。**用 flex 均分而不是固定宽度** —— 各语言文案长短差异大,
|
||||
等宽不能靠文字长度。纯布局改动,不涉及 i18n,四语言自动一致。
|
||||
|
||||
补充:验证码弹窗「验证并提交 / 取消」两个按钮也不等长,按用户要求把 `captcha.submit` 改短:
|
||||
提交 / 送出 / Submit / 送信(该键只被这个弹窗使用)。
|
||||
|
||||
⚠️ 用户反馈「成功页按钮还是老样子」时,先确认**是否真的部署了**:直接抓线上 HTML 看有没有新标记
|
||||
(`grep 'uk-margin-large-top uk-text-center'` 在 `/rules` 上)。实测那次线上仍是 `uk-margin-large-top"`
|
||||
旧结构 → 与浏览器缓存无关,是代码没更新。以后别先怀疑缓存。
|
||||
(第二次复测已生效:`uk-text-center` 在、弹窗按钮 `>送信<`、成功页 `flex: 1 1 160px` 出现 2 次。)
|
||||
|
||||
## 受控用户强制最高审核档(用户要求:不分级,全部级别)
|
||||
|
||||
- `moderation.EffectiveStrictness(ctx, userID, raw)`:**有任何处置(L1–L4)就返回 strict**,忽略账号设置。
|
||||
**处置查询失败时回退账号自身设置**,而不是"失败即强制 strict" —— 否则 DB 抖动会让全站突然加严、
|
||||
正常提交被大面积误拒,影响面远大于漏放一个受控账号。
|
||||
- 7 个审核调用点全部从 `StrictnessFor(raw)` 改为 `EffectiveStrictness(...)`:chat_handler、follow_up×2、
|
||||
page、question、user/vote×3、vote/vote。`StrictnessFor` 保留为纯解析函数(模板回显用)。
|
||||
- `UpdateModerationStrictness`:处置期内直接拒绝保存并 flash `admin.flash.strictnessLocked`。
|
||||
- 设置页 `templates/user/moderation-strictness.html`:`$root.StrictnessLocked` 时提示 + select/按钮 disabled。
|
||||
⚠️ 该文件仍只能用 shell/python 改(Edit/Write 会注入 `data-page-node-id`),本次用 python 精确替换。
|
||||
- Profile handler 注入 `StrictnessLocked`(读 ActiveLevel)。
|
||||
|
||||
验证:`go build ./...` EXIT=0;checktpl OK;db/i18n/templates 测试全绿。
|
||||
性能:每次内容提交多一次 `user_restrictions` 查询(走 user_id 索引),审核本身要走七牛网络,1ms 可忽略。
|
||||
@@ -0,0 +1,290 @@
|
||||
# 2026-10-20
|
||||
|
||||
## 01:10 shiroko 主题补「空气层」
|
||||
|
||||
- 用户问「还有没有其他元素,贴合主题又不突兀」。结论:场景压白 38% 后整页亮度只有 235~246,
|
||||
再摆物件(书包/粉笔/纸飞机)一定会像贴纸,所以走**纯渐变造光**路线。
|
||||
- 做了 4 个候选截图(A 纯窗光 / B 白散景 / C 光环符号 / D 组合),用户选 D。
|
||||
⚠️ **B 是反面教材**:白色散景叠在已经压白的场景上,实测差异 0.14 级,肉眼完全不可见。
|
||||
要"提亮"必须先有暗部,真正起作用的是「底部压深 + 暖色光斑」的组合。
|
||||
- 最终 `--tb-deco` 后 4 槽:左窗光柱(112deg)、右侧 120px 暖散景(88% 30%)、
|
||||
脚下 560×170 地面光斑(28% 94%)、底部 24% 压深。亮度差 3~9 级。
|
||||
- 验证手段(本轮**模型读不了图**,全靠数值):留白区差分(与候选 D 差 0.0)、
|
||||
分区亮度表、横向扫描相邻像素差 max 4(判色阶断层)、PNG 体积比对。
|
||||
- ⚠️ 造预览页两个坑:① 覆盖块必须包在 `<style>` 里,裸写会被当正文渲染,整页布局炸掉;
|
||||
② `--tb-deco` 各层之间**漏逗号**会让整条声明失效 → 人物和所有背景层全没,且各变体看起来"完全一样"。
|
||||
以后先 diff 两个变体的 CSS 文本再截图。
|
||||
- `StaticVersion` → `20261020a`;提交 `fb94b8c` 已推送。文档 `docs/theme-shiroko.md` 加「空气层」一节。
|
||||
|
||||
## 08:30 重画光环
|
||||
|
||||
- 用户反馈「光环变形太奇怪,而且中间没有扣干净」。根因两条:
|
||||
① AI 画的环本身歪(bbox 291,93–584,232,比头还宽、偏移到左边);
|
||||
② 环内是**封闭白孔**(面积 5190px 的包围区域),四角 floodfill 进不去 → 抠完环里糊着一团白。
|
||||
- 做法:非白像素从环上一点漫水拿到独立连通域(与头发不连通,头部顶端在 y≈265,环只到 232),
|
||||
整块抹白;**先把 alpha 建好,再在 RGBA 上画新环**——环心天然就是透明的,不用给封闭孔补洞。
|
||||
- 新环参数:中心 (515,160)、100×34、线宽 10、-7° 倾斜、4 倍超采样后 LANCZOS 缩小、
|
||||
外加 45% 模糊柔光;颜色 (127,211,220),压白 16% 后实测环心 (164,220,224)。
|
||||
- 坑:擦除矩形右边界要绕开**灰色狼耳**(x>630,y 190-260,颜色 (139,140,146) 不是青色),
|
||||
差点一起抹掉。判据是连通域 bbox 而不是"上方全是空白"。
|
||||
- 验证:alpha 字符图确认环闭合、环心 alpha=0;渲染后页面里青色像素 bbox (201,467)-(271,649),
|
||||
环在 y 468-486,头顶约 505,间距正常。
|
||||
- `StaticVersion` → `20261020b`,图片 `?v=20261020b`;提交 `2e9bd6b` 已推送。
|
||||
|
||||
|
||||
## 09:40 部署反馈三修 + 自行车/步枪
|
||||
|
||||
- **狼耳顶端被削平**:上一版用矩形 `(281,83)-(594,242)` 抹旧光环,右下角正好切到左耳尖
|
||||
(耳尖在 y≈185、x 420-480)。改成只抹光环的**非白连通域**(bbox 291,93-584,232,与头发不连通)。
|
||||
- **手内侧 / 大腿内侧白没抠干净**:那是**闭合**的白色区域,四角 floodfill 进不去。
|
||||
加封闭白孔检测(>=246 且不与边缘连通、面积>=40)整块掏成透明。
|
||||
⚠️ 阈值必须 >=251 才能当"背景":背景恒为 (253,253,253),而**白袜子是 237**,
|
||||
用 246 当背景阈值会把袜子一起吃掉(第一版就吃穿了一只袜子)。
|
||||
- **人物像站在课桌上**:原场景前景全是课桌,根本没有地面。重出场景:
|
||||
前景留空地、课桌退到右后方,人物脚下才是地板。
|
||||
- **自行车 + 步枪**:独立素材 `props/bike.webp`(镜像成车头朝右、与人同向、压白 26%、
|
||||
自带接触阴影),放在**人物身后偏左**。deco 层顺序 = 画在上面的在前,所以第一层是人、
|
||||
第三层是车,车天然被她挡住。
|
||||
- ⚠️ **`background-position` 的百分比是相对 `(定位区宽 - 图片宽)`**,不是相对视口宽。
|
||||
人(117px)和车(397px)用同一个 `calc(50% - 901px)` 会差出 280px,车直接跑出屏幕只剩一片轮圈。
|
||||
按各自宽度反解后:人 `calc(50% - 524px)`、车 `calc(50% - 612px)`。
|
||||
- ⚠️ 道具**不能烘焙进背景**:背景走 `cover`,视口一变裁切量就变,和固定 px 的人物必然分家。
|
||||
- 场景重出后色调用「逐通道均值/方差对齐旧场景」保持同一雾度;移动端从同图竖裁,避免两个房间。
|
||||
- `StaticVersion` → `20261020d`;提交 `15aebd6` 已推送。
|
||||
|
||||
## 09:55 素材规格书
|
||||
|
||||
- 用户打算换工具自己生成素材 → 写了 `docs/theme-shiroko-assets-spec.md`:
|
||||
画布/导出/显示尺寸、比例(人 1:3.41、道具 1.53:1、背景 16:9)、雾度(人 16%、车 26%、场景 30~38%+模糊 1.2~1.4px)、
|
||||
构图硬约束(左侧 22% 前景必须是空地,否则又回到"站在课桌上")、换图清单与定位公式。
|
||||
- 配色给的是**压白后的显示值** + 反算公式 `原色=(显示值-40.8)/0.84`,并特别标注袜子要用 `#ededed`
|
||||
而不是纯白(纯白会和背景粘连,抠图必然吃穿)。
|
||||
|
||||
## 10:40 主题生成器(dev/theme-builder)
|
||||
|
||||
- 单文件 `dev/theme-builder/index.html`:上传背景/人物/道具 → 拖拽调位置 → 导出 zip,
|
||||
解压到项目根即可新增主题。不联网、不起服务、图片全在浏览器本地转 WebP。
|
||||
- 预览用**和线上同一套机制**渲染(pattern + deco 6 层 + page-image,size/position 对齐),
|
||||
不是另画示意图;拖出来的「左沿像素」反解成 `calc(50% - Npx)`,换视口不跑位。
|
||||
- 导出包:`static/css/themes/<slug>.css` + `static/images/themes/<slug>/*.webp` + `INSTALL-<slug>.txt`。
|
||||
CSS 同时写浅色/深色两块,10 个必需令牌齐全——**实测通过 `TestThemeCSSComplete`**。
|
||||
- zip 自己实现(STORE + CRC32,约 40 行),不引 CDN;`unzip -t` 无错。
|
||||
- 踩坑:① 面板里 `c.text2` 写成 undefined(light 对象里才算出来的派生色,要用 `light.text2`);
|
||||
② `contain` 模式如果不按内容裁画布,会留大片透明边 → 导出宽高比全错;
|
||||
③ 预览容器 `grid-template-columns:340px 1fr` 会被 1512px 的 frame 撑破 → `minmax(0,1fr)`;
|
||||
④ 无头 Chrome 下 `canvas.toBlob` 的回调在 `--dump-dom` 前不触发 → 全部改 `toDataURL` 同步化,
|
||||
自检才能跑(`?selftest=1` 输出 CSS/zip 字节,`?demo=1` 载入示例素材)。
|
||||
- ⚠️ 预览里深色必须走「暗色渐变 + deco none」,否则会拿白天场景图压深底(和导出结果不一致)。
|
||||
|
||||
## 11:05 主题生成器重做:真实页面画布 + 按页面作用域
|
||||
|
||||
用户原话:旧生成器「只针对当前遇到的问题,需要更通用」「示例页面和我的项目完全不一样」
|
||||
「想针对箱主页面、问题页面、问题列表、设置页面分别配置」。
|
||||
|
||||
**机制层(新)**
|
||||
- `internal/context.pageScope()` 按路径归类,`<html data-page-scope>`:
|
||||
`box`(/_/{domain}) / `question`(/_/{domain}/{id}) / `admin`(/user/questions,/user/votes*)
|
||||
/ `profile`(/user/*) / `vote`(/vote/{token})。首页与 `/explore` 返回了值但**不加载主题 css**,
|
||||
所以生成器里不列它们(列了就是骗人)。
|
||||
- ⚠️ 深色覆盖必须是**三重选择器** `html[data-theme="dark"][data-theme-style][data-page-scope]`:
|
||||
浅色作用域块特异性 0,2,1 与深色块相同、且写在后面,二重选择器会被它盖掉。
|
||||
- 装饰槽位 **6 → 10**(base.css 背景 8 → 12 层 = pattern1 + deco10 + image1)。
|
||||
`TestDecoSlotCount` 逐文件逐声明检查,变异验证有效(写 6 个会 FAIL)。
|
||||
|
||||
**生成器(重写)**
|
||||
- 画板 = iframe 加载 `outputs/preview-*.html`(真模板渲染)。**必须起本地服务**:
|
||||
file:// 下父页面读不到 iframe 的 DOM(即使同目录),预览只能退回示意图。
|
||||
`dev/theme-builder/serve.py` 把项目根当静态目录(只读)。
|
||||
- 每个作用域独立「整页背景 + 最多 10 层装饰」,默认跟随全局;不产覆盖块就自动回退。
|
||||
- 图层 = 图片 / 径向光斑 / 线性光;9 宫格锚点 + 像素偏移;图片层可在画板上直接拖。
|
||||
- ⚠️ 位置统一 `calc(0%|50%|100% ± Npx)` 两值:百分比是相对 (容器 − 图片)。
|
||||
**不要** `left/bottom` 关键字混搭 —— 「水平居中 + 垂直贴底」是三值语法,非法时整条声明作废
|
||||
→ 图层静默掉到左上角(这是之前踩过的坑,这次直接规避)。
|
||||
- ⚠️ 配色只让填 6 个基础色,另 4 个必需令牌派生:`surface-2` 往 **text** 混(浅色下变深、
|
||||
深色下变亮,方向自动对),`text-secondary/muted` 往 bg 混,`primary-weak` 加 alpha。
|
||||
漏了 `--tb-primary-weak` 时 `TestThemeCSSComplete` 只报「缺少」,页面看着没问题,很容易混过去。
|
||||
- 自检钩子:`?dump=css` / `?dump=zip`(写进 `<pre id="dump">`),配合 `--dump-dom` 回归。
|
||||
|
||||
**验证**(都是实测,不是推断)
|
||||
- 生成器导出的 CSS 放进 `static/css/themes/` → `TestThemeCSSComplete` + `TestDecoSlotCount` 双绿;
|
||||
删掉深色块做变异 → FAIL,说明测试真的在查。
|
||||
- 真实箱主页计算样式:image/size/position/repeat/attachment **各 12 层**,前几层尺寸与 shiroko 一致
|
||||
(auto 400 / 96x13 / auto 259 / 100%×3),确认扩槽没打乱顺序。
|
||||
- 临时主题 `zztest` 验证:box 页有人物、question 页 `--tb-deco: none×10` 后人物消失;
|
||||
不写覆盖块时回退全局(人物仍在)。
|
||||
|
||||
**顺手发现**:`.gitignore` 写的是 `./outputs`(无效写法),导致 247 个 outputs 文件被跟踪,
|
||||
每次跑 `TestGenPreview` 都会产生一堆 M。没擅自改(会让人以为要清理历史产物),留待用户决定。
|
||||
|
||||
## 12:20 outputs 不再纳入版本控制
|
||||
|
||||
- 根因:`.gitignore` 里写的是 `./outputs`,**gitignore 不认 `./` 前缀**,整条规则等于失效,
|
||||
247 个产物(预览快照、截图、历史 review 报告)一直被跟踪,每次跑 `TestGenPreview` 都产生一堆 M。
|
||||
- 改成 `/outputs/` + `git rm -r --cached outputs`:索引删除 247 个,**磁盘文件一个没动**。
|
||||
`git check-ignore -v outputs/x` 确认命中。
|
||||
- 另两条同样无效的规则没动:`./workbuddy`(目录根本不存在,笔误)、`./scripts`(有 1 个文件被跟踪)。
|
||||
用户只要求 outputs;要清理得单独确认。
|
||||
|
||||
## 13:10 生成器能力盘点(含实测)
|
||||
|
||||
用户问「有什么改进建议」,顺手实测了一个之前没验证过的点:**窄屏(390 宽)**。
|
||||
|
||||
- 没有 `@media` 规则的主题,在 390×844 下:16:9 桌面背景被 `cover` 裁成一条无意义纹理,
|
||||
人物被占满屏的卡片完全盖住 —— 等于白配。
|
||||
- shiroko 因为手写了 `@media (max-width:900px)`(换 `scene-mobile.webp` + deco 全关),
|
||||
同一视口下背景是协调的竖版、装饰退场。
|
||||
- 结论:**生成器导出的主题没有窄屏段**,这是真实缺口,不是理论问题。
|
||||
|
||||
其它已确认的缺口(grep 过,不是猜):无 localStorage / 无 json 导入导出(关掉全丢)、
|
||||
无图层上移下移(遮挡关系由顺序决定,只能删了重加)、无导出前校验、无既有主题导入。
|
||||
|
||||
⚠️ 测窄屏时必须让 **iframe 宽度**一起变(`verify.html?w=390`)。第一次只改外层 window,
|
||||
iframe 内部仍是 1512,媒体查询按 1512 判定 → 结论是错的。
|
||||
|
||||
## 14:20 生成器补 P0 四项
|
||||
|
||||
用户问建议 → 我给了 P0/P1 清单 → 他选「P0 四条一起做」。已实现并推送。
|
||||
|
||||
1. **配置留得住**:`localStorage` 自动草稿(renderAll 后 700ms debounce)+ `theme-<slug>.json`
|
||||
保存/打开(图片 base64 内嵌,单文件带走);zip 里也塞一份 json;`?fresh=1` 跳过恢复。
|
||||
2. **图层 ↑↓**:顺序即层级(`background-image` 第一层画在最上)。
|
||||
3. **窄屏段**:竖版背景图 + 隐藏装饰 + 断点(默认 900),导出 `@media` 块。
|
||||
⚠️ **只写浅色规则**:深色块特异性 0,2,1 > 浅色 0,1,1,本来就压住它;多写一份反而容易出错。
|
||||
4. **导出前校验**:slug / 图片层缺图 / 引用已删素材 / 超 10 层 /
|
||||
生成 CSS 每处装饰是否真 10 个值(与 Go `TestDecoSlotCount` 同规则,给工具自身兜底)。
|
||||
|
||||
**实测**
|
||||
- 390 视口首层装饰 `url(char.webp)` → `none`,1512 下不变;两种视口都 12 层对齐。
|
||||
- 草稿往返:带 `?demo=1` 保存 → 不带 demo 打开,`validate` 仍为空(说明恢复成功)。
|
||||
- 示例 CSS 过 `TestThemeCSSComplete` + `TestDecoSlotCount`(27 处声明)。
|
||||
|
||||
**踩到的小坑**
|
||||
- `makeDemo()` 里 `S.assets.push` 不检查重复 → 重复点「载入示例」会撞 id(`assetById` 取第一个),
|
||||
导出时静默用错图。改成开头 `S.assets = []`。
|
||||
- 拖拽标签写 `top:-17px` 会贴到画板边缘被压线/裁切,改成放在框内左上角;
|
||||
整块半透明填充会把人物罩成一层米色(看着像"框住空气"),改为纯虚线 + 角标签。
|
||||
- 面板一多左栏就滚不到底:按使用频率重排(图层 → 背景 → 窄屏 → 图片池 → 配色)。
|
||||
|
||||
## 15:20 生成器:图片处理(透明度 / 压白 / 去色 / 裁边 / 去白底)
|
||||
|
||||
用户问「图片的透明度或者一些简单的处理能否安排上」。已实现并推送 `c3d34b5`。
|
||||
|
||||
**非破坏模型(关键设计)**
|
||||
- `orig` 存上传时的**原始字节**(`FileReader.readAsDataURL`,不重编码),`fx` 只存参数,
|
||||
每次处理都从 orig 重算 → 「压白 16% → 20%」不会叠加两次 webp 压缩。
|
||||
- 打开面板看一眼就关:参数没变就**跳过重编码**,一个像素都不动(`fxSnapshot` 比对)。
|
||||
这条不加,用户点开看看就会把图越磨越糊。
|
||||
- 预览按 420px 宽算(滑杆跟手),完成时全分辨率重算;用序号 `fxSeq` 丢弃过期结果,
|
||||
否则快速拖动时旧渲染覆盖新的。
|
||||
|
||||
**去白底两个坑(都实测过)**
|
||||
- 手内侧 / 两腿之间是**闭合**白区,四边漫水进不去 → 按连通域单独掏,面积 ≥ 24 才掏
|
||||
(更小的多是眼睛高光,掏了变透明洞)。
|
||||
- **容差默认 16,不是 22**:背景 253、白袜子 237 只差 16。容差 30 实测把袜子吃穿(alpha 0)。
|
||||
|
||||
**验证(跑出来的)**
|
||||
- `?dump=fx` 13 项断言全过:封闭孔开/关、容差 16 保袜子 / 30 吃穿、收边后轮廓 1px、
|
||||
alpha 50%→128、压白 20% 时 237→241、去色后 R=G=B、裁边 200×160→80×40。
|
||||
- `?dump=fxpipe` 链路:弹窗开关、预览 300×960、参数重开后仍在、改参后 dataURL 变、
|
||||
**无改动时 dataURL 一字节不变**(这条是防回归的关键)。
|
||||
- `?dump=fxkey` 端到端:把 shiroko 立绘合成到 253 白底再去白底,透明占比 46.3%,
|
||||
与手写 floodfill 抠出来的 46.6% 差 0.3%。
|
||||
- 生成的 CSS 仍过 `TestThemeCSSComplete` + `TestDecoSlotCount`。
|
||||
|
||||
**顺带**
|
||||
- 工程文件升 v3:存 `orig` + `fx`,未处理时不重复存 dataURL(否则 json 平白翻倍);
|
||||
读 v2 旧工程把 dataURL 当 orig 用。
|
||||
- 新增 `?loadurl=` 把项目里的图直接当素材载入(不必先下载再上传),`?fxjson=` 给初始参数。
|
||||
- 截图看出两个 UI 问题:拖拽标签 `top:-17px` 贴到画板边缘被压线、半透明填充把人物罩成米色
|
||||
→ 改框内左上角标签 + 纯虚线(上一轮已修,本轮沿用)。
|
||||
|
||||
## 16:10 生成器:图层换不了图 / 背景关不掉 / 草稿清不掉
|
||||
|
||||
用户反馈三条,都是**交互设计**问题,核心逻辑其实没坏(实测换图是生效的)。已推送 `937a890`。
|
||||
|
||||
- **「每层都只有这张图、换不了」**:`add-layer` 里 `l.asset = S.assets[0].id`,连加三层全是同一张;
|
||||
而且换图的下拉只在「选中那一层的编辑器」里,用户看不出每层能各自换。
|
||||
→ 新层不再自动绑图(素材池只剩一张时才绑);**每行带缩略图 + 「图片」下拉**直接换。
|
||||
- **「背景关不掉」**:`inherit` 时背景下拉 `disabled` —— 用户在箱主页看到背景还在又点不动。
|
||||
→ 一律可点,一改自动 `ensureOwn(true)`:切成独立配置 + **把全局当前值复制过来当起点**
|
||||
(否则改背景会顺带把图案弄丢);复制图层要**重发 id**,不然两个作用域共用一批 id,拖拽/高亮会串。
|
||||
- **「上次的数据一直在」**:`localStorage` 草稿只有隐藏参数 `?fresh=1` 能绕过,UI 无出口。
|
||||
→ 顶栏加「新建」(清空 + 删草稿 + 二次确认)。
|
||||
|
||||
⚠️ **跟随全局时不能给拖拽框**:那时 `cfgOf(S.scope)` 返回的是 global 对象,一拖就改到全局去了。
|
||||
`syncOverlay` 里已加提前 return。同理 `ensureOwn()` 是「任何写操作前」的必调项。
|
||||
|
||||
回归(`?dump=layers`):加层后 `[null,null]`、行内下拉 2 个、换第 2 层后 `[null,blue]` 且 CSS 只出现 blue;
|
||||
box 页 inherit 时 `addDisabled/bgDisabled` 都 false;在 box 加层后本页 3 层而**全局仍 2 层**;
|
||||
新建后 assets 0、层 0、草稿已删、scope 回 global。
|
||||
|
||||
## 17:5x 生成器:视口自适应 + 导出画质
|
||||
|
||||
用户两个问题:插入素材后不同屏幕被裁、导出的 webp 太大。都在 `dev/theme-builder/index.html` 里解决。
|
||||
|
||||
**尺寸单位 4 档**(`l.unit`):`fit`(按视口高,人物)/ `fitw`(按视口宽,横幅)/ `px` / `vh`。
|
||||
输出 `auto clamp(min(calc(100vh - 24px), 231px), 46.67vh, 420px)` 这种。
|
||||
- 关键点 1:**内层 `min(视口 - 24px, 基准×55%)`** —— 下限让位给视口,自适应单位因此永不溢出。
|
||||
写死 55% 下限的话,390 竖屏放不下 1067 宽的图,照样裁。
|
||||
- 关键点 2:**必须有 `fitw`**。`fit` 只按高度缩,宽幅素材宽度按比例涨:1920×1080 素材 h=600 在
|
||||
390 视口下 fit 出 1001px 宽(裁),fitw 出 366px(完整)。实测见 `?dump=fit` 的 `fitw` 数组。
|
||||
- 关键点 3:`layerSize()`(出 CSS)与 `unitGeom()`(出预览框/溢出检测)**必须共用一套换算**,
|
||||
两套各算迟早漂成「框没超、图已裁」。
|
||||
- ⚠️ 下拉 value 要用 `l.unit`,不能用 `unitOf()`(把 fit 归一成 vh → 选了 fit 却显示「按视口高比例」)。
|
||||
|
||||
**视口预设带高度**(`VIEWPORTS` 改成 `{w,h,label}`,加了 390×844)。原来只有宽度、iframe 恒 900 高,
|
||||
768 的旧笔电和 844 手机根本模拟不出来 —— 而裁切几乎全由高度决定。
|
||||
|
||||
**溢出检测 + 一键修好**:`layerMetrics(l,a,VW,VH)` 给 overL/R/T/B,图层编辑区显示
|
||||
「1512×900 下显示 131×420,完整可见」或橙色「⚠ 左右各 88px 越界」+「按当前视口修好」按钮
|
||||
(切 px 并算刚好放下的尺寸)。导出前 `overflowReport()` 再扫一遍,只提醒不拦。
|
||||
|
||||
**窄屏每层策略** `l.mob`(inherit/scale/hide)+ 全局 `S.narrow.scale`(默认 0.62)。
|
||||
⚠️ 顺手修了一个既有 bug:`@media` 块原来只写 `html[data-theme-style]`(0,1,1),
|
||||
被页面覆盖块 (0,2,1) 压住 → **独立配置的页面在手机上从来没生效过**。现在每个独立 scope 各写一条。
|
||||
⚠️ 竖版图只替换 `cfg.bg` 非空的页面(否则给纯色页塞图 = 改了他的设计)。
|
||||
|
||||
**背景焦点** `cfg.bgPos` → `--tb-page-position`;base.css 里 `var(--tb-page-position, center center)` 兜底
|
||||
(旧主题不写也不坏),StaticVersion → `20261020f`。
|
||||
|
||||
**导出画质**:`S.export = {quality: 0.88, maxSide: 1600}`,改完全量 `rebakeAll()`(从 orig 重算,不叠加压缩)。
|
||||
- ⚠️ **`toDataURL('image/webp', 1)` 是坑**:Skia 在 q=1 切无损,输出反常 —— 实测同一张噪声图
|
||||
q=1 只有 52KB、q=0.95 是 440KB。`canvasAsset` 与滑块都夹到 0.95。
|
||||
- ⚠️ 平涂/线稿降 q 可能**变大**(有损引入噪点;实测平涂图各档都是 1KB,噪声图 0.6→0.95 涨 75%)。
|
||||
所以文案写「先砍长边再降质量」,不承诺「降 q 一定更小」。
|
||||
- 缩小加 `imageSmoothingQuality='high'`。
|
||||
|
||||
**自检**:新增 `?dump=fit`(视口占用/溢出/fitw/旧工程兼容/@media 覆盖各 scope)与
|
||||
`?dump=quality`(q 单调性、q=1 被夹、长边真压像素),两项 `ok:true`;`?dump=validate` 空、layers/fx/fxpipe 全绿。
|
||||
生成 CSS 临时存成 `static/css/themes/gentest.css` 跑 `go test ./internal/theme` → 30 处声明全过(`clamp` 里的逗号没被当层分隔符)。
|
||||
⚠️ 顺带发现 **`//go:embed` 的 `*` 不匹配 `_` 开头文件** —— `_scaffold.css` 与 `_gentest.css` 都不进 embed,
|
||||
所以那个校验一开始"跑了但数字没变",改名后才真正生效。
|
||||
|
||||
UI 踩坑:面板仅 ~240px,`input[type=range]` 有固有宽度 → 必须 `flex:1 1 0;width:0;min-width:0`,
|
||||
且质量数值要放进 label 里(作第三个 flex 项会被顶出可视区,表现为"滑块没有数字")。
|
||||
|
||||
## 18:2x 排查用户部署的 test01:窄屏素材消失 + 主题名显示成 key
|
||||
|
||||
用户自己导出 test01 部署到 box-dev-api(admin6),报两个问题。**两个都复现了,都不是"环境坏了"。**
|
||||
|
||||
**测试环境体检(都正常)**:`/_/admin6` 200、`test01.css` 200 且与本地 md5 一致、
|
||||
`base.css?v=20261020f`(新版,含 `--tb-page-position`)、`<html data-theme-style="test01" data-page-scope="box">`。
|
||||
部署本身是干净的。
|
||||
|
||||
**问题 1:窄屏素材消失** = 工程里「窄屏隐藏全部装饰层」勾着 → 导出 CSS 的 `@media` 把 10 槽全写 `none`。
|
||||
桌面完全不受影响,所以只看桌面发现不了。定位过程绕了两步弯路,记下来:
|
||||
- 先怀疑 CSS 表达式非法(`clamp(min(calc(...)),…)`)→ 最小复现页证明**表达式没问题**,图能显示。
|
||||
- 再怀疑层级:其实素材位置 `calc(50% - 0.67vh)` 正好在**卡片正下方**,卡片 `rgba(255,255,255,0.86)`
|
||||
只剩 14% 可见,本来就非常淡。
|
||||
- ⚠️ **本地 preview 快照没有 `data-page-scope`**(TestGenPreview 不走 Contexter),所以
|
||||
`[data-page-scope="question"]` 覆盖块在快照里根本不生效,读到的是 global 的 `--tb-deco: none`。
|
||||
本地验证主题必须手动给 `<html>` 补 `data-page-scope`,否则会误判成"主题没生效"。
|
||||
- 修:删掉 test01.css 的 @media 段(装饰是 fit 单位,浏览器自己会缩)。390 截图确认素材回来了。
|
||||
- 生成器侧:勾选时橙色警告(几层会消失)+ 图层行标「窄屏隐藏」+ 加层/导出提醒 +
|
||||
「窄屏」下拉被覆盖时标注;新增 `?narrow=hide` 复现。
|
||||
|
||||
**问题 2:设置页显示 `theme.name.test01`** = 模板直接 `{{t .LangCode (printf "theme.name.%s" .Slug)}}`,
|
||||
`i18n.T` 缺词条返回 key。修:`theme.DisplayName(lang, slug)` 缺词条回退 slug,模板函数 `themeName`。
|
||||
加了 `TestDisplayName`(自建主题必须回退 slug,不能返回 key)。这样自建主题依旧零改代码。
|
||||
|
||||
StaticVersion → `20261020g`(主题 css 内容变了,不换缓存键浏览器 1 天内还是旧文件)。
|
||||
@@ -0,0 +1,617 @@
|
||||
# 2026-10-21
|
||||
|
||||
## 扫描者处置方案设计(未实施,仅设计文档)
|
||||
|
||||
- 触发:线上频繁收到「疑似批量扫描已自动封禁」告警,命中路径以 wp 探测 + `/.ssh/**` 为主,来源多 Azure 云 IP 池。
|
||||
- **实测纠正一处错误认知**:`internal/route/route.go:432` 的注释称「flamego 的 NotFound 会先写 404 状态再 Redirect」——
|
||||
**错的**。flamego v1.9.4 的 `NotFound` 只执行 handler,不写状态码;`ctx.Redirect("/")` 实测输出 **302 → "/"`。
|
||||
最小复现验证:`/wp-admin/includes/...` 与 `/.ssh/id_rsa` 均得 302 + Location:/。
|
||||
后果:扫描器跟随重定向 → 每个探测路径都触发一次首页渲染(1C1G 纯浪费),且被判为「站点有价值」。
|
||||
同时告警邮件文案「产生大量 404」与实际 302 不符。
|
||||
- 方案分五层(L0 CDN 路径/UA 规则 → L1 真 404 分流 → L2 蜜罐假文件/假凭据 → L3 tarpit + 递进封禁 → L4 上游 abuse 举报):
|
||||
- 核心判断:**对云厂商僵尸网络做 IP 封禁无效**,有效的是按**路径**在 CDN 拦 + 蜜罐取证 + 上游举报。
|
||||
- 明确排除反向攻击/DoS/回打(法律风险 + Azure 共享出口伤及无辜 + 招致 CDN 封站)。
|
||||
- 蜜罐:命中 `/.ssh/id_rsa`、`/.env`、`/wp-login.php` 等诱饵路径 → 短路返回假内容(含 canary 标记)+ 永久封禁 + 留证。
|
||||
- 封禁需落库(当前内存 map,重启清零)+ 时长递进 15m→1h→6h→24h→7d→永久 + 多维(IP / IPv6 /64 / UA 家族)。
|
||||
- tarpit 必须带全局并发上限 + 单连接超时,否则 1C1G 自身变 DoS 入口。
|
||||
- 设计文档:`outputs/scanner-defense-design-2026-10-21.md`(含分级变更清单、文件级改动点、验收指标)。
|
||||
- 未动任何生产代码,等用户决定实施范围。
|
||||
|
||||
## 蜜罐(虚假后台登录界面 + 破解凭据记录)已实施
|
||||
|
||||
用户明确需求原文:「能不能把这些页面指向一个简单的虚假后台登录界面,然后记录这些破解的用户名密码等信息」。
|
||||
|
||||
### 落点决策(关键)
|
||||
- **蜜罐不改写成新中间件,而是改写 `internal/route/route.go` 的 `f.NotFound`**:
|
||||
- NotFound 只在「路由表完全落空」时触发 → 与 `/login` `/register` `/_/{domain}` `/user/**` `/api/v1/**` 天然零交集;
|
||||
- 位于全局 `f.Use` 链之后 → `requestBodyLimitMiddleware` 已给 body 加 12 MiB 上限;
|
||||
- 组级中间件(含 CSRF)只挂具体路由,不作用于 NotFound → POST 表单可安全解析;
|
||||
- flamego `context.run()` 在 `ResponseWriter().Written()` 为真时 return → 写完响应不调 `ctx.Next()` 即安全短路。
|
||||
- **双重计数坑**:autoban 中间件对敏感路径已 `Record` 一次,蜜罐再记一次会让阈值实际减半。
|
||||
解决:`autobanMiddleware(b, hp)` 传入蜜罐匹配器,蜜罐命中的路径由蜜罐自己计数。
|
||||
|
||||
### 新增文件
|
||||
- `internal/security/honeypot/honeypot.go` — 匹配器(段前缀 + 片段子串 + 双重保险 `forbiddenPrefixs` 排除业务路由)、
|
||||
凭据提取(表单 / JSON / BasicAuth / 查询串)、`CanaryToken = "tb-hp-7f3a1c9e"`。
|
||||
- `internal/security/honeypot/page.go` — 仿 WordPress 假登录页(经典字段 `log`/`pwd`/`redirect_to`),内联 CSS 不依赖站点静态资源。
|
||||
- `internal/db/honeypot.go` — `HoneypotHit`(**不嵌 dbutil.Model**,审计证据只追加)+ `HoneypotStore`。
|
||||
- `internal/route/honeypot.go` — 处理器:GET 假登录页 / POST 假失败响应 / 落库 / 复用 autoban 封禁 / 每 IP 告警一次。
|
||||
- `internal/cmd/honeypot.go` — `TamaBox honeypot hits|stats|prune`,密码默认脱敏(`--show-password` 显示明文)。
|
||||
- `templates/mail/honeypot-alert.html` — 告警邮件模板(`mail/` 已在 embed 清单,无需改 embed.go)。
|
||||
|
||||
### 改动文件
|
||||
- `internal/route/route.go`(CRLF):NotFound 分流 + 蜜罐实例装配 + `hph.sendAlert`。
|
||||
- `internal/route/autoban.go`(LF):`autobanMiddleware(b, hp)`,蜜罐路径跳过自身 Record。
|
||||
- `internal/db/db.go`(LF):`AllTables` 加 `&HoneypotHit{}`,`Init()` 加 `Honeypots = NewHoneypotStore(db)`。
|
||||
- `internal/conf/static.go`(CRLF):`enable_honeypot` / `honeypot_paths` / `honeypot_retain_days`。
|
||||
- `conf/app.sample.ini`(CRLF):对应说明段。
|
||||
- `internal/mail/mail.go`(LF):`SendHoneypotAlertMail` + `HoneypotAlert`。
|
||||
- `cmd/tamabox.go` + 根 `tamabox.go`:注册 `cmd.Honeypot`(两处必须同步)。
|
||||
|
||||
### 踩坑
|
||||
- `normalize()` 里 `for len(p)>1 { TrimSuffix(p,"/") }` **无限循环**(路径不含尾斜杠时字符串不变)→ 改成 `HasSuffix` 判断。
|
||||
- `url.ParseQuery` 遇非法百分号编码返回 error 但仍给出部分字段 → 蜜罐要「尽可能多拿凭据」,忽略 error 继续用部分结果。
|
||||
- JSON 凭据提取:手写解析器又脏又易错 → 改用 `jsonPairRe` 正则扫描扁平字段。
|
||||
- `templates/mail/` 已在 embed 清单内 → 新增邮件模板**不需要**改 `templates/embed.go`(顶层文件才需要)。
|
||||
|
||||
### 验证
|
||||
- `go build ./...` 通过;`go test ./...` 全绿(含 DB 测试,postgres 门控)。
|
||||
- 新增测试:`internal/security/honeypot/honeypot_test.go`(匹配/业务路由零交集/编码绕过/凭据提取)、
|
||||
`internal/route/honeypot_test.go`(假页含 canary、业务路由不被劫持、关闭时回落 302、POST 返回失败页)、
|
||||
`internal/db/honeypot_test.go`(写入/倒序/三张聚合表/保留期清理)。
|
||||
|
||||
## 测试站黑盒验证 + P0 缺陷修复(蜜罐封禁被 enable_auto_ban 连坐)
|
||||
|
||||
测试站:`https://box-dev-api.337845818.best`(Cloudflare Tunnel + CF 边缘,`cf-cache-status: DYNAMIC`、无 `Age` → 不缓存,请求确实回源)。
|
||||
生产站 `https://box.tama.guru` 强制 301 → www,本机不可达,无法做对照。
|
||||
|
||||
### 黑盒验证结果(全部通过)
|
||||
- 诱饵路径全部 200 + `text/html`:`/wp-login.php` 2529B、`/wp-admin/` 2517B、`/.ssh/id_rsa` 2525B、`/.env` 2497B、`/administrator` 2533B。
|
||||
- 响应体含 canary `tb-hp-7f3a1c9e`(2 处)、`<title>Log In ‹ WordPress</title>`、`name="log"`/`name="pwd"`/`name="redirect_to"`。
|
||||
- 响应头:`Cache-Control: no-store, no-cache, must-revalidate`、`x-robots-tag: noindex, nofollow`、`x-frame-options: DENY`。
|
||||
- POST 凭据提交 → 200 + `id="login_error">ERROR: Invalid username or password.`。
|
||||
- 路径变体全命中:`/WP-ADMIN`(大小写)、`/wp-admin%2f..%2fetc`(编码绕过)、`//wp-admin//x.php`(双斜杠)、`/.git/config`、`/.aws/credentials`、`/phpmyadmin/`、`/actuator/env`、`/xmlrpc.php`、`/vendor/phpunit/x.php`、`/console`、`/cgi-bin/test.cgi`、`/wp-content/plugins/x.php`、`/.env.bak`、`/wp-json/wp/v2/users`。
|
||||
- 凭据来源全覆盖:表单 POST、JSON body、BasicAuth、查询串、POST 到 `/.ssh/id_rsa`。
|
||||
- **业务路由零交集(红线)**:`/` 34192B、`/login` 23420B、`/register` 25194B、`/_/admin6` 39358B、`/user` 302、`/api/v1/questions` 302、`/robots.txt` 145B;非诱饵随机路径 `/nonexistent-random-xyz` 仍 302→`/`。
|
||||
|
||||
### P0 缺陷:蜜罐命中 70+ 次但站点零封禁
|
||||
- 根因:`autoban.Record()` 第一行 `if !b.enabled { return }`,`b.enabled` 来自 `enable_auto_ban`。
|
||||
运维为避免共享出口误伤而关掉 `enable_auto_ban` → **蜜罐被另一个开关连坐**,只落库不封禁。
|
||||
- 对照组证明非蜜罐专属:`/index.php`(autoban 敏感、蜜罐不匹配)连打 40 次 + 30 并发同样零封禁。
|
||||
- 排除项:CF 缓存(DYNAMIC/无 Age)、flamego NotFound 链(源码确认全局 Use 链生效)、蜜罐匹配器、阈值配置。
|
||||
|
||||
**修复(已实施,测试全绿)**:
|
||||
- `autoban.Options` 新增 `HoneypotEnabled` / `HoneypotThreshold`(默认 1)/ `HoneypotDuration`(默认复用常规)。
|
||||
- 新增 `Banner.RecordHoneypot(ip, path) bool`:独立计数、独立阈值,**不受 `enabled` 约束**;键与 `Record` 一致(同样 `shared.LimitKey` 归一化),否则 `IsBanned` 判不出来。
|
||||
- `IsBanned()` 移除 `!b.enabled` 早退 —— `enabled` 只控制「是否主动创建封禁」,不控制「是否尊重已有封禁」。
|
||||
- `internal/route/honeypot.go`:`banned = h.ab.RecordHoneypot(ip, path)`。
|
||||
- `internal/route/route.go`(CRLF)装配三个蜜罐字段;`conf/static.go` + `conf/app.sample.ini`(CRLF)新增 `honeypot_threshold` / `honeypot_ban_minutes`。
|
||||
- 告警:仍走既有 `sendAlert`(「已封禁」)+ 蜜罐自己的告警邮件(「捕获了什么凭据」),互补。
|
||||
|
||||
### 后缀匹配补缺
|
||||
- `/backup.zip`、`/db.sql`、`/id_rsa.pem` 这类泄露扫描文件名主体随机,**只有后缀稳定**。
|
||||
- 原 `honeypotFragments` 用 `"/.zip"` 子串匹配 → `.zip` 前是 `k` 不是 `/`,全漏网。
|
||||
- 新增 `honeypotSuffixes`(`.zip .tar .tar.gz .tgz .gz .rar .7z .bz2 .sql .sql.gz .dump .bak .old .orig .save .swp .env .env.bak .env.local .pem .key .p12 .pfx .jks .kdbx .ovpn`),按 `path.Base()` 结尾匹配。
|
||||
|
||||
### 踩坑
|
||||
- ⚠️ **测试里 `Duration: 60` = 60 纳秒**,封禁瞬间过期 → 表现为「时序巧合的 200/200/403」,测试假失败。一律写 `60 * time.Minute`。
|
||||
- ⚠️ 路由测试只注册路由 + NotFound 会被绕过:执行 `IsBanned` → 403 的是 **`autobanMiddleware`**,测试服务器必须 `f.Use(autobanMiddleware(ab, hp))`。
|
||||
- ⚠️ `internal/route/route.go`、`internal/conf/static.go`、`conf/app.sample.ini` **实际是全 CRLF**;`grep | cat -A` 不显示 `^M`,极易误判为 LF。Python 脚本按 `\n` 锚点替换会静默失败 → 用 `\r\n` 锚点 + 写后统计 `b'\r\n'` 复核。
|
||||
- 假页纯静态 HTML(内联 CSS,不落盘)、告警走 SMTP → **取证数据只在 `honeypot_hits` 一张表**,服务器无文件副本;autoban 封禁表是内存态(重启清零)。
|
||||
- 测试阶段产生的噪声凭据(`testadmin/S3cr3tP@ss` 等)可用 `TamaBox honeypot prune` 清理。
|
||||
|
||||
### 遗留(需用户侧)
|
||||
- 本轮修复需**重新部署**才在测试站生效;部署后 `curl` 一次 `/wp-login.php` 应 200、再一次 403。
|
||||
- 需确认测试环境 `conf/app.ini` 的 `[security] enable_auto_ban`(零封禁直接原因)+ `enable_honeypot = true`。
|
||||
- 无法 SSH(`100.79.185.45:19999` 全部账号 Permission denied),落库验证需用户执行 `TamaBox honeypot hits --since 24h --show-password`。
|
||||
|
||||
---
|
||||
|
||||
## 蜜罐表体积实测 + 自动清理(commit 63e851d,已 push)
|
||||
|
||||
用户问「这些记录会不会导致数据库很臃肿」。**不靠推断,本地 PostgreSQL 建同构表灌仿真数据实测。**
|
||||
|
||||
### 实测数字
|
||||
- 典型行 **244 字节**(heap 201 + 索引 43),10.5 万行 = 24 MB;常规 UA 下实测 281 B/行。
|
||||
- 字段均值:path 13B、user_agent 111B(单行最大头)、fields 3B、raw_body 3B。
|
||||
- `raw_body` 被 `maxCredentialFieldLen = 512` 截断,故字段数量随之受限(200 字段场景 rawBody 仍 512B)。
|
||||
- 最坏情况(手工构造 50 字段 × 500B)约 2375 B/行。
|
||||
- **UA 截短收益**:截到 100 省 1129 kB / 截到 64 省 4824 kB(10 万行)→ 仅 5–20%,
|
||||
远小于自动清理的确定性上界且损失取证信息 → **决定不做**。
|
||||
|
||||
### PostgreSQL 死元组语义(本轮最关键认知)
|
||||
- `DELETE` 只标记死元组,**表文件不会变小**;空间需 VACUUM 后才可被后续 INSERT 复用。
|
||||
- 对照(10 万行,每轮删 25% 插 25%,行数恒定):有 VACUUM 22→24 MB **收敛**;无 VACUUM 22→52 MB **膨胀**。
|
||||
- ⚠️ **VACUUM 不缩小文件,只让空间可复用**。二次实测(5 万行、连续 4 轮删 12000 插 12000):
|
||||
行数恒定 5 万、文件稳定 15 MB 不再增长 —— 这才是要的效果(止住增长,不是让文件变小)。
|
||||
想让文件立刻变小只有 `VACUUM FULL`(重建整表 + 锁表),蜜罐表万级行没必要。
|
||||
- ⚠️ `VACUUM` **不能在事务块内执行**(SQLSTATE 25001),也不能在 PL/pgSQL 函数体内执行(VACUUM 无法从函数执行)。
|
||||
GORM `db.Exec()` 走 `callbacks.Raw()` 处理器、**不包事务** → `WithContext(ctx).Exec("VACUUM ...")` 可用(已实测)。
|
||||
- PG18 已移除 `pg_stat_user_tables.n_tup_vacuum`,改用 `last_vacuum` 验证。
|
||||
|
||||
### 索引实测(EXPLAIN 驱动决策)
|
||||
- `idx_honeypot_hits_provider`:10 万行 648 kB,**全代码库零查询使用**(TopSources 用 max(provider) 随 group by 顺带取)→ 已删。
|
||||
- `idx_honeypot_hits_created_at`:EXPLAIN 显示 `Index Scan Backward`,被 `ListSince` 实际使用 → 保留。
|
||||
- `idx_honeypot_hits_ip`:4.7 MB(最大),但 GROUP BY ip 走 Seq Scan + HashAggregate;**保留**(`ListSinceByIP` 需它)。
|
||||
- ⚠️ **GORM 去掉 struct tag 的 `index` 不会删除既有库上的旧索引** → 需显式 `HasIndex` + `DropIndex`(已加 `dropUnusedHoneypotIndexes`,启动时跑,失败仅告警)。
|
||||
|
||||
### 落地改动
|
||||
- `internal/db/honeypot_retention.go`(新):`HoneypotRetention` 后台清理器,每小时一轮,两道闸
|
||||
(时间 `honeypot_retain_days` + 数量 `honeypot_max_rows`)→ 清理后调 `Compact()`。
|
||||
保留期 <=0 视为不自动清理(只告警);**`runOnce` 开头必须再挡一次 `retainDays<=0`**,
|
||||
否则 before=now 会把历史全删光(「保留 0 天」被误解释成「只留此刻」)。
|
||||
- `internal/db/honeypot.go`(新方法):`Compact()`(PG `VACUUM (ANALYZE)`)、`DeleteOldestBeyond()`
|
||||
(跨方言通用,子查询按 id 倒序取 keepRows 边界)、`ListSinceByIP()`、`Size()`。
|
||||
- `internal/cmd/web.go`:接入清理器,`stopRetention` 用 defer close。
|
||||
- `internal/cmd/honeypot.go`:新增 `honeypot size` 子命令(行数/磁盘占用/时间跨度 + 与两道闸对照)。
|
||||
- 修 CLI `hits --ip` 真 bug:原先取回最近 N 条再在 Go 侧筛 → 指定 IP 的历史命中基本查不出来 → 改 SQL 层过滤。
|
||||
- 新配置:`honeypot_auto_clean`(默认 true)、`honeypot_max_rows`(默认 200000 ≈ 53 MB)。
|
||||
|
||||
### 踩坑
|
||||
- ⚠️ 测试预期别自己推算错:`DeleteBefore` 是**严格小于**(`created_at < before`),边界那条不删。
|
||||
- ⚠️ Python 补丁脚本里写 Go struct tag 的反引号 → bash 命令替换报错,**必须写成 .py 文件执行**(不能 `python -c`)。
|
||||
- ⚠️ `psql` 传中文 SQL 报编码错 → 用英文输出串或 `PGCLIENTENCODING=UTF8`。
|
||||
- ⚠️ 库名带连字符要用双引号:`CREATE DATABASE "tamabox-cli-test"`。
|
||||
- ⚠️ Edit 工具改 CRLF 文件会保持 CRLF,但**改写时我打的字会转码损坏**(出现过「实测指事/程定/不图」)→ 改完必须逐行读回复核。
|
||||
- 仓库内统一存 LF,工作区 CRLF 由 `core.autocrlf=true` 自动转换 —— 提交时 warning 属正常,不是行尾被改。
|
||||
|
||||
---
|
||||
|
||||
## 配置键名静默失效的根治(commit d300167,已 push)
|
||||
|
||||
### 现场故障
|
||||
用户配置 `honeypot_retention_days <= 30`,日志报「蜜罐自动清理未启用:honeypot_retention_days <= 0」。
|
||||
|
||||
### 根因(探针实测 ini v1.67.0,不是读源码猜的)
|
||||
1. **键名多一个 t**(retention vs retain)→ ini 对未知键**静默忽略**,保留期成 0。
|
||||
2. **`<= 30` 这种写法**让 ini 把键名解析成 `"honeypot_retention_days <"`(带尾随比较符),
|
||||
实测输出:`key="honeypot_retention_days <" value="30"` —— 更匹配不上。
|
||||
3. **我之前那条警告文案里写的也是错键名** `honeypot_retention_days`,
|
||||
等于在误导运维往错的键上配 —— 教训:警告文案里的键名必须与代码读的键名逐字一致。
|
||||
|
||||
### 实测到的 ini 行为(重要,写 ini 相关代码前必读)
|
||||
- `MapTo` 在**键缺失或值非法时不覆盖**目标字段 → 可以安全预置缺省值。
|
||||
- 键名**大小写敏感**(`Honeypot_Auto_Clean` 不生效)。
|
||||
- `HasKey` 能区分「缺失」与「显式 0」。
|
||||
- 段不存在时 `MapTo` 返回 nil 且不修改目标。
|
||||
- bool 的解析是「有键才赋值」→ **未配置与显式 false 无法用零值区分**,必须靠 HasKey。
|
||||
|
||||
### 修复三层
|
||||
- `internal/conf/keynorm.go`(新):键名归一化(修剪空白、剥尾随 `<` `>` `=`、别名表纠错)。
|
||||
⚠️ 归一化结果必须命中该段**合法键名白名单**才采用 —— 不认识的键不猜,交给未知键告警。
|
||||
- `internal/conf/unknown.go`(新):启动逐段比对键名,未知键 WARN + 给出最接近的合法键名。
|
||||
合法键名由**反射从结构体 ini tag 得出**(单一事实来源,新增配置项自动纳入)。
|
||||
- `internal/conf/honeypot_defaults.go`(新):蜜罐缺省值语义修正
|
||||
(保留期缺省 30、auto_clean 缺省 true 用 HasKey 判、max_rows 缺省 200000、threshold 缺省 1)。
|
||||
- 归一化必须在 `MapTo` **之前**做 —— 等 MapTo 跑完,错拼的键早被丢掉了。
|
||||
|
||||
### 顺带清理
|
||||
`app.sample.ini` 里两条死配置:`app.title`(代码从未读,站点标题走 i18n `site.title`)、
|
||||
`server.xsrf_expire`(无对应字段)。留着会让新检测对官方模板误报。
|
||||
|
||||
### 踩坑
|
||||
- ⚠️ **Python 脚本里写 Go struct tag 的反引号** → bash 命令替换报错。
|
||||
改用 Edit 工具逐处改(Edit 对 CRLF 文件保持 CRLF,但**我打的字可能转码损坏** → 改完逐行读回复核)。
|
||||
- ⚠️ `gofmt -l` 对 CRLF 文件一律报警(gofmt 要求 LF)—— 那是既有状态,不是我的改动。
|
||||
- ⚠️ 我自己的测试预期写错过两次(别名生效后的期望值、static_version 不是 ini 键)。
|
||||
**测试失败要先怀疑自己的预期**,别急着改产品代码。
|
||||
|
||||
## 蜜罐实现审计(第三方 agent 写完,我复审)
|
||||
|
||||
### P0:蜜罐封禁整条链路未接线(已实测复现)
|
||||
`internal/route/route.go:85` 的 `autoban.New(...)` **没传** `HoneypotEnabled / HoneypotThreshold /
|
||||
HoneypotDuration`,而 `autoban.RecordHoneypot` 首行就是 `if !b.honeypotEnabled { return false }`
|
||||
→ 蜜罐命中永不封禁、落库 `banned` 恒 false、`IsBanned` 永不为真。
|
||||
同一提交又把 `autobanMiddleware` 里蜜罐路径的**常规计数也跳过**了
|
||||
(`route/autoban.go:38 if hp == nil || !hp.Match(path)`,理由「蜜罐处理器自己计数」)
|
||||
→ 净效果:蜜罐路径从「10 次封禁」退化为**零计数零封禁**,是 regression。
|
||||
`honeypot_threshold` / `honeypot_ban_minutes` 两个配置项全代码库**零读取**(死配置,样例 ini 里却写了)。
|
||||
测试为什么绿:`route/honeypot_test.go` 自建 banner 时显式传了 `HoneypotEnabled`,
|
||||
注释还写着「与 route.go 一致」—— 实际不一致,测试绕过了生产装配。
|
||||
|
||||
### P1
|
||||
- 蜜罐 GET 探测(无凭据)也逐条 INSERT,把「零成本 404」变成「每请求一次 DB 写」,
|
||||
且 NotFound 链路上没有 rate limit → 扫描器可直接放大成 DB 写压力;20 万行闸每小时才跑一轮。
|
||||
- `honeypotHandler.alerted`(map[string]bool)无 TTL、无清理,随源 IP 数量无界增长;
|
||||
autoban 的同名 map 也是(既有问题,蜜罐放大)。
|
||||
|
||||
### P2
|
||||
- `Path` 原样落库但列是 `size:512` → 超长路径直接 PG 报错丢证据;`Query` 用 `query[:1000]`
|
||||
字节截断,`honeypot.go:243/296` 的 `v[:limit]`、`raw[:limit]` 同样是字节截断
|
||||
→ 多字节字符被切半产生非法 UTF-8,PG 拒插。同文件里已有按 rune 的 `truncate()` 却没用于这些点。
|
||||
- `credentialKeys` 含 `id` / `name` / `token` → 任意 JSON 探测带 `{"id":1}` 就会被当成「破解凭据」
|
||||
触发告警邮件 + 记为 HadFields,污染取证与告警。
|
||||
- `page.go:54` `<meta name="generator" content="TamaBox honeypot tb-hp-7f3a1c9e">` 把「这是蜜罐」
|
||||
和「这是 TamaBox」同时明示给扫描器(nuclei 类模板可据此跳过)。
|
||||
- `serveLoginFailure` 手抄了一遍 `WriteLoginPage` 的响应头逻辑(没复用),已开始漂移
|
||||
(Title 一处写 `‹`、一处写 `\u2039`)。
|
||||
- `Honeypot.Describer()` 零调用死代码,且 `path.Join(keys...)` 语义错误(会把前缀拼成一条路径)。
|
||||
|
||||
### 结论
|
||||
总体设计(挂 NotFound、零业务交集、只回假内容、脱敏 CLI、独立取证表)方向正确;
|
||||
问题集中在「装配层没接上」与「写入侧的边界处理」。P0 必须先修,否则蜜罐只是个记日志的旁观者。
|
||||
|
||||
## 蜜罐审计问题的修复实施(已改完,未提交)
|
||||
|
||||
### 1. P0 装配(route.go)
|
||||
抽 `newBanner()` 作为唯一装配入口,补 `HoneypotEnabled / HoneypotThreshold / HoneypotDuration`。
|
||||
新增 `internal/route/banner_config_test.go` 四个用例,全部经由 `newBanner()` 断言。
|
||||
**变异验证**:分别把 HoneypotEnabled 改成 false、Threshold 改成 0,对应用例确实失败。
|
||||
|
||||
### 2. 诱捕语义修正(审计时漏掉、比 P0 更影响功能价值)
|
||||
原实现 `RecordHoneypot` 对**所有方法**计数且阈值 1 → 扫描器 GET 一次假登录页就吃 403,
|
||||
永远走不到「POST 提交凭据」那一步,蜜罐核心价值(拿字典)直接归零。
|
||||
改为:`creds.HadFields` → RecordHoneypot(立即封);纯探测 → 常规 `Record`(阈值 10)。
|
||||
新增 `TestHoneypotProbeDoesNotBanImmediately`,同上做了变异验证。
|
||||
连带把 `honeypot_threshold` 的语义在 static.go / defaults / autoban / sample.ini 统一为
|
||||
「提交凭据次数」。
|
||||
|
||||
### 3. 落库边界(实测取证)
|
||||
- `Path` 原样落库、列宽 512 → PG `SQLSTATE 22001 值太长了(512)`,整条证据丢失;
|
||||
实测 600 字符失败、500 字符成功。
|
||||
- 三处按字节截断 → `SQLSTATE 22021 无效的 "UTF8" 编码字节顺序`(中文密码/URL 可稳定触发)。
|
||||
全部改为 rune 截断(`clipRunes` / route 的 `truncate`),并按列宽逐个对齐。
|
||||
|
||||
### 4. 资源与噪音
|
||||
- `honeypotHandler.alerted`(无界 map)→ 换成带 TTL 的 `deduper`(容量上限 2 万,
|
||||
过期清理 + 超限硬裁),同时承担「告警 24h 冷却」与「纯探测落库采样(每 IP 每分钟一条)」。
|
||||
- `credentialKeys` 去掉 `id` / `name`(普通 JSON 探测不再被当成凭据提交触发假告警)。
|
||||
- canary 从 `<meta name="generator">` 移到 HTML 注释(`<!-- build: ... -->`),
|
||||
generator 伪装成 `WordPress 6.4.3`;不再向扫描器自曝「TamaBox 蜜罐」。
|
||||
- `serveLoginFailure` 不再手抄响应头,统一走 `WriteLoginPage`;`LoginPageTitle` 抽常量。
|
||||
- `Describer()` 死代码 → 改为 `Prefixes()` 并接到新 CLI `TamaBox honeypot paths`。
|
||||
- `honeypot_paths` 解析挪到 `honeypot.SplitPaths`(CLI 与 route 共用),补前导斜杠、拒绝 `/`。
|
||||
|
||||
### 5. 新发现的配置陷阱(影响面比蜜罐大)
|
||||
`conf.Init` 用 `ini.LoadOptions{IgnoreInlineComment: true}`,该选项语义是
|
||||
**不剥离行尾注释**(实测:`port = 1440 ; 注释` → 值 `"1440 ; 注释"`,int 解析失败,
|
||||
`MapTo` 却返回 nil error)。后果:
|
||||
- `app.sample.ini` 照抄**无法启动**(`text_censor_source` 值带注释被 validate 拒绝);
|
||||
- 所有带行尾注释的 int/bool 静默回退零值 —— `enable_auto_ban`、`honeypot_max_rows`、
|
||||
`honeypot_auto_clean` 全部中招(后两者靠 sanitize 兜底才没出事,max_rows 则真的失效)。
|
||||
已把 sample 的**蜜罐段**改成「注释独占一行 + 裸值」,并在段首写明原因。
|
||||
其余段落仍是老写法,未动(需用户决定是否全面清理)。
|
||||
|
||||
### 待办
|
||||
- 用户确认是否提交这批改动。
|
||||
- `app.sample.ini` 其余段落的行尾注释问题未修,建议后续统一。
|
||||
- 蜜罐 GET 探测目前受 `enable_auto_ban` 约束(关了就不计数),这是有意的:
|
||||
纯探测无害,不该在运维关掉自动封禁后仍凭空封人。
|
||||
|
||||
## 后续:sample 注释清理 + 凭据外置(同日晚)
|
||||
|
||||
### 1. app.sample.ini 行尾注释全量清理
|
||||
17 处 `key = value ; 注释` 改成「注释独占一行 + 裸值」,保留 CRLF。
|
||||
新增 `TestSampleIniValuesHaveNoInlineComment`:遍历所有段的键,断言 `Value()` 不含 `;`。
|
||||
变异验证:把 `ban_window_seconds = 60` 改成带行尾注释 → 测试按预期失败。
|
||||
清理后 `conf.Init` 读 sample 的报错从「值含注释」变成「server.xsrf_key 未填」,
|
||||
说明解析路径已通(必填项留空是模板的预期行为)。
|
||||
|
||||
### 2. 凭据改为外部文件,数据库不再存用户名/密码
|
||||
- `honeypot.CredRecord` + `CredStore`(`internal/security/honeypot/credstore.go`):
|
||||
按天分文件 `creds-YYYY-MM-DD.jsonl`,一行一条 JSON;目录 0700、文件 0600;
|
||||
坏行跳过;`PurgeBefore` 只删「整天都早于保留期」的文件。
|
||||
- `db.HoneypotHit` 删掉 `Username/Password/Fields/RawBody`,只留 `HadFields` 元数据。
|
||||
- `HoneypotStore` 删掉 `TopCredentials` / `ListSinceByIP`(CLI 已改读文件,零调用)。
|
||||
- 新增 `LegacyCredentialColumns` / `PurgeLegacyCredentialColumns`:
|
||||
AutoMigrate 不删既有列,老库会残留历史明文 → 启动时 WARN,`honeypot purge-creds` 显式 DROP。
|
||||
- 清理器 `HoneypotRetention` 加 `CredPurgeable` 参数,与 DB 记录共用保留期;
|
||||
`honeypot prune` 也一并删过期凭据文件。
|
||||
- CLI:`hits` 不连 DB(DB 挂了也能查),`stats` 的凭据排行读文件,新增 `purge-creds`。
|
||||
- 配置 `[security] honeypot_cred_dir`,缺省 `data/honeypot-creds`;`.gitignore` 加 `/data/`。
|
||||
|
||||
### 测试
|
||||
`credstore_test.go`(追加/倒序/IP 过滤/坏行/Top/Purge/权限)、
|
||||
`TestHoneypotLegacyCredentialColumns`(ADD COLUMN 模拟老库 → 检测 → DROP)、
|
||||
`TestHoneypotWritesCredentialsToFile`(POST 后文件有记录)。
|
||||
CLI 端到端手动验证:`honeypot hits` / `--ip` / `--show-password` 输出正确。
|
||||
全量 `go test ./...`(含 PostgreSQL)通过。
|
||||
|
||||
### 遗留
|
||||
- **历史备份/主从复制里可能仍有旧明文凭据**,代码无法处理,需运维自行清理。
|
||||
- 邮件告警仍会带明文用户名/密码(用户未要求改)。
|
||||
|
||||
### 已提交(2026-10-21,未推送)
|
||||
- `566b13e` fix(conf): 样例配置去掉行尾注释(2 文件)
|
||||
- `c29e1d0` fix(honeypot): 补齐封禁装配与诱捕语义,凭据外置为文件不再入库(21 文件)
|
||||
- `main` ahead of `origin/main` 2;用户要部署测试环境验证后再决定推送。
|
||||
|
||||
### 测试环境实测反馈(同日晚,已修并推送)
|
||||
现象:purge-creds 删 4 列成功、GET 200、POST 后 GET 403、文件已生成。
|
||||
但凭据文件里 `"path":" /wp-login.php"`(前导空格)、`"ip":"127.0.0.1"`。
|
||||
|
||||
- **path 脏字符**:反代链路(CF → nginx → 源站)传出的 `req.URL.Path` 带前导空白。
|
||||
`Match` 内部 normalize 会 TrimSpace 所以仍命中,但落库与假表单 action 用原始值
|
||||
→ 取证数据脏、爆破脚本可能提交到错误路径。本地 httptest 复现不出来,是环境相关。
|
||||
修复:`cleanPath()` 在 Serve 入口 trim + 补前导斜杠(不改大小写)。
|
||||
- **IP = 127.0.0.1**:说明 `trust_proxy` 未开。后果不只是取证失真 ——
|
||||
**一次蜜罐封禁会封掉 127.0.0.1,等于封掉整站所有访客**(封禁是内存态,重启即恢复)。
|
||||
修复:命中回环地址且 `!trust_proxy` 时打一次性 WARN(24h 冷却),不改封禁行为。
|
||||
sample.ini 的 trust_proxy 段补了蜜罐相关说明。
|
||||
- `846bb8a` 已推送(`5e8deca..846bb8a`)。
|
||||
|
||||
### 收尾:path 脏字符最终定位(同日晚)
|
||||
用户重启进程后,`od -c` 显示新记录的 `"path":"/wp-login.php"` —— 干净。
|
||||
说明之前那条带空格的是**旧进程**写的(16:07 那次 `./tamabox web` 并没有真正替换掉在跑的实例)。
|
||||
期间补了两处加固并推送:
|
||||
- `74312fb`:`cleanPath` 改用 `TrimFunc` 覆盖 U+200B/200C/200D/FEFF
|
||||
(`TrimSpace` 只认 White_Space,零宽字符会漏,且会因首字符不是 `/` 多补斜杠);
|
||||
清洗前后不一致时 WARN 打出 `%q` 原始值,不可见字符靠日志才定位得到。
|
||||
- 教训:`strings` 不认 UTF-8 中文(已记入 topics/build.md);替换二进制后必须重启进程。
|
||||
|
||||
### 文档(`3d45374`,已推送)
|
||||
- 新增 `docs/honeypot.md`:生效范围、配置、**两条封禁路径**(GET 走常规阈值 /
|
||||
提交凭据才即封)、凭据文件格式与不入库的理由、`trust_proxy` 部署必读、
|
||||
旧列清理、排障表、设计取舍。
|
||||
- `docs/cli.md`:§0 补「替换二进制必须重启」「strings 用 grep -ac」;
|
||||
命令总览加 honeypot;新增 §7 honeypot 命令(原 7/8/9 顺延为 8/9/10);
|
||||
排错表加 5 条。
|
||||
# 2026-10-21
|
||||
|
||||
## 新旧前端对比(正式 vs 测试环境),只测量未改码
|
||||
|
||||
**方法**:curl 抓两环境 4 个页面 → 本地注入脚本取计算样式 / 资源体积 / 对比度 / 点按区;
|
||||
无头 Chrome 截图(`--headless=old`)后用 Pillow 算像素指标。
|
||||
⚠️ 本模型读不了图片,所以结论全部来自数值与结构,不靠"看截图"。
|
||||
|
||||
**踩坑**:file:// 打开抓下来的页面时,`href="/static/css/base.css"` 会解析成 `file:///static/...` → CSS 不加载,
|
||||
于是 dev 页量出来全是 UIkit 旧样式(圆角 0、旧阴影)。改法:抓下来后把 `/static/` 重写成 dev 域名绝对地址,
|
||||
再重新采集。**以后本地复现线上页面必须先重写站内绝对路径,否则测的是"无 CSS 的裸 HTML"。**
|
||||
另:`body.scrollWidth` 会被隐藏面板撑大(dev explore 1499),判断横向溢出要看 `documentElement.scrollWidth`(实测 1249 = 无溢出)。
|
||||
|
||||
**实测差异(1280 宽)**
|
||||
| 指标 | 正式(旧) | 测试(新) |
|
||||
|---|---|---|
|
||||
| 首页 HTML | 42.9KB | 34.2KB |
|
||||
| 探索页 HTML | 77.4KB | 53.4KB |
|
||||
| `<style>` 块 | 9–10 | 2 |
|
||||
| 行内 style 属性 | 8–16 | 3–5 |
|
||||
| 卡片圆角 / 阴影 | 0px / 单层硬阴影 | 12px / 双层柔阴影 |
|
||||
| 圆角取值种类 | 2px×73、50%、8、6、4、9 | 6、8、12、14、5、50% |
|
||||
| 正文色 | #333/#666/#999 混用 | 令牌 #1f2329/#5c6370/#8a919c |
|
||||
| 字体栈 | UIkit 默认 | system-ui + 苹方/雅黑 |
|
||||
| 图标 | emoji(🌙☀️) | 内联 SVG |
|
||||
| 首页截图像素:色彩数 / 边缘密度 | 2206 / 10.3% | 1174 / 5.0% |
|
||||
|
||||
**共同短板(新旧都没解决)**
|
||||
- `--tb-text-muted #8a919c` 在 #fff 上只有 **3.18:1**(AA 需 4.5);旧版 #999 更低(2.85)。
|
||||
建议改 #6b7280(4.83:1);暗色 muted #8b93a1 在 #242830 上 4.77 已达标。
|
||||
- 顶栏导航链接(UIkit rgba(0,0,0,.5))2.68–2.85:1,同样是低对比。
|
||||
- 移动端点按区:两版都有大量 19px 高的纯文字链接(不满足 44px 建议值)。
|
||||
|
||||
**新版自身的毛病(我写的,待修)**
|
||||
- `templates/home.html` 字号用了 0.75/0.78/0.86/0.9/0.95/0.98 rem → 7 个肉眼难辨的档位;
|
||||
`static/css/base.css` 里还有 0.78/0.8/0.82/0.84/0.88/0.95。**排版层级比旧版更碎**,应收敛到 4 档。
|
||||
- 残留圆角 5px(header 验证码框、sponsor)、9px(profile-tabs,list.html/explore.html 各一处)。
|
||||
|
||||
## 按上轮结论改代码(字号收敛 / 对比度 / 圆角收敛)
|
||||
|
||||
先确认过:本地 `static/css/base.css` 与 dev 部署版 **diff 0 行**,本地就是新版基线。
|
||||
|
||||
1. **新增字号阶梯令牌**(base.css `:root`):`--tb-fs-xs/md/lg/xl/hero` = 12/14/16/24/28px。
|
||||
`lg` 取 **1rem 而非 17px** —— UIkit 的 `.uk-card-title`/h4 就是 16px,顺着它才能让自定义标题
|
||||
与组件标题同档,否则会留下 16 与 17 差 1px 的两档并存(第一版就是这么写的,实测发现后改回)。
|
||||
2. **替换范围**:home.html 全部 7 档 → 5 档;explore / question-list / question-list(list.html) 三页
|
||||
的 0.7/0.72/0.82/0.84/0.85/0.9/0.98/1/1.15/1.2 全换令牌。实测 explore 由 **9 档 → 5 档**。
|
||||
3. **`--tb-text-muted` #8a919c → #6b7280**(#fff 上 4.83:1)。**关键坑**:
|
||||
UIkit 自带 `.uk-text-muted{color:#999!important}`,我原先的普通声明**从未生效**,全站仍是 #999(2.85:1)。
|
||||
必须 `!important` 才能压过。改完 box 页 fails 15→11、explore 14→9。
|
||||
4. **顶栏导航**:UIkit 默认 rgba(0,0,0,.5) = 2.68:1 → 改 `var(--tb-text-secondary)` = **6.05:1**,
|
||||
hover/active 走主色;暗色分支原有 `!important` 规则自然接管(实测 10.75:1)。
|
||||
5. **圆角全量收敛**:9px/5px/4px/2px/7px/3px 以及等值的 6/8/10/12/14px 全部换成
|
||||
`var(--tb-radius[-sm|-xs])`。全站(排除邮件模板)现在只剩 3 处 `999px`(胶囊,语义正确)。
|
||||
6. `conf.StaticVersion` 20261020g → **20261021a**(base.css 改了必须 +1)。
|
||||
|
||||
**验证**:go vet / go test(templates+i18n+conf)/ go build 全绿;预览页内联本地 base.css 后用无头 Chrome
|
||||
量计算样式,首页 12/14/16/24/28、explore 12/14/16/24/32,nav 6.05:1。
|
||||
|
||||
**遗留(未改,属设计取舍)**:主色链接 `#1e87f0` 在纯白上 3.37–3.42:1(<4.5)。改深会影响品牌观感,
|
||||
需用户决定;Waline 第三方赞数绿色 2.87 不在本站控制内。
|
||||
|
||||
**注意**:工作区有 4 个**非本次改动**的删除(`static/css/themes/test01.css`、`static/images/shiroko-cutout.png`、
|
||||
`static/images/themes/test01/*.webp`),是之前就存在的未提交删除,提交时勿一并 add。
|
||||
|
||||
## 17:4x 用户前端设计令牌化 → 主题/生成器的连带影响
|
||||
|
||||
用户改了 12 个模板 + base.css(未提交):`--tb-text-muted` #8a919c→#6b7280(AA 4.83:1)、
|
||||
新增 `--tb-fs-xs/md/lg/xl/hero` 阶梯、圆角改走 `--tb-radius(-sm/-xs)`、顶栏链接改 `--tb-text-secondary`、
|
||||
`.uk-text-muted` 加 `!important`(压 UIkit 的 `#999!important`)。
|
||||
|
||||
**关键结论:主题会把这套修复盖回原样。** 主题 css 覆盖同名令牌 → 页面用主题的值,不是 base.css 的。
|
||||
实测(worse of bg / 压平后的 surface):
|
||||
- default.css 不定义文字色 → 跟随 base ✓;sakura muted `#a08c94` **2.80:1**、shiroko `#8794a7` **2.76:1**(都看不见)。
|
||||
- 已调到 AA 下限:sakura `#78696e`(4.61)、shiroko `#656e7a`(4.63),同色相只压深;深色块两个主题本来就 ≥4.7,不动。
|
||||
- 主色仍不达标(未改,等用户定):base/shiroko `#4a7fc1` 3.69、base `#1e87f0` 3.26、sakura `#e5678f` 2.80。
|
||||
候选:`#3d6fac`(4.63)、`#0f6ac6`(4.83)、`#b53d61`(4.91)。
|
||||
|
||||
**生成器的必修项**:`paletteVars` 原来 `text-muted = mix(text,bg,0.55)` 硬算 → 浅底掉到 2.9:1,
|
||||
等于每导一次主题就把 base.css 的修复覆盖掉。改成 `mixToContrast(text, [bg, flatten(surface,bg)], 4.5)`:
|
||||
二分找「最淡但达标」的比例;文字本身不达标时原样返回(宁可不够淡,不能看不见)。
|
||||
- 半透明卡片(`rgba(255,255,255,0.86)`)必须**压平到底色**再算,否则按纯白算会偏低。
|
||||
- 次要 5.5 / 弱化 4.5 两档,保证层级不倒挂。
|
||||
- 配色面板加读数 `正文 12.5:1 · 次要 5.5:1 · 淡文字 4.6:1 · 主色 4.6:1`,<4.5 变橙(手填主色只提醒不拦)。
|
||||
- 默认 primary 由 `#4a7fc1`(3.69) 压到 `#3d6fac`(4.63) —— 默认值不该一上手就踩线。
|
||||
- 新增 `?dump=palette`(四种配色:默认亮/默认深/低对比手填/白卡彩底),`ok:true`。
|
||||
|
||||
**顺手补的一致性**:深色块顶栏原来 `!important` 定成 `--tb-text`(正文色),亮色块新的 secondary 规则
|
||||
让同一顶栏在深浅两模式权重跳一档 → 加 `html[data-theme="dark"] .uk-navbar-nav > li > a { secondary !important }`。
|
||||
实测计算值:无主题 nav #5c6370 / muted #6b7280;shiroko #51607a / #656e7a;sakura #6b545c / #78696e。
|
||||
|
||||
**⚠️ 用户删了 test01(css + 2 张图)与 `shiroko-cutout.png`,未提交,我没动。** 若测试站 `[theme] available`
|
||||
还登记着 test01,启动日志会打「主题文件不存在,已从可用列表忽略」,设置页不再出现,箱主已选 test01 的会静默回退站点默认。
|
||||
`shiroko-cutout.png` 只在 `docs/theme-shiroko.md` 里作参考图引用,不参与 embed。
|
||||
|
||||
**令牌化的残留**(只提醒,没改):`templates/user/profile.html` 还有 16 处 `font-size: 0.XXrem`;
|
||||
`base.css` 自身 11 处(0.78/0.82/0.88/0.95/1.1 —— 正是被点名要消掉的那档);圆角只剩 50%/999px(语义正确)。
|
||||
`--tb-fs-*` 在 base.css 里只用了 3 处(主题格、chip),阶梯主要落在模板层。
|
||||
`StaticVersion` 20261021a → **20261021b**(主题 css 内容变了,必须换键)。
|
||||
|
||||
## 18:2x 提交 test01 删除
|
||||
|
||||
`2d13c31` 删掉 `static/css/themes/test01.css` + `static/images/themes/test01/` 两张 webp(用户 18:19 要求)。
|
||||
`go test ./internal/theme` 仍全绿(测试里的 "test01" 只作 DisplayName 回退样例,不依赖文件)。
|
||||
⚠️ `static/images/shiroko-cutout.png` 的删除**仍未提交**(属另一件事,docs/theme-shiroko.md 还引用它)。
|
||||
|
||||
## 18:2x 删除 shiroko-cutout.png + 清文档引用
|
||||
|
||||
用户确认一并删。`docs/theme-shiroko.md` 三处引用改成「参考图不入库」(不再指向具体文件路径);
|
||||
顺手修了同页一处过时说法:**「--tb-deco 保持 6 个值」→ 10 个**(槽位早就扩到 10,漏改会让人以为写 6 个就够)。
|
||||
删除后 embed 无影响(`static/fs.go` 只嵌 `images/themes`,这个文件本来就不参与打包)。
|
||||
|
||||
## 18:2x 提交模板令牌化(用户改动,一次性提交)
|
||||
|
||||
`001306d`:11 个模板的硬编码字号/圆角换成 `--tb-fs-*` / `--tb-radius*`,工作区已干净。
|
||||
`TEST_PREVIEW=1 go test ./templates ./internal/i18n` 全绿(顺带刷新了生成器用的快照)。
|
||||
提交信息里记了 4 处**故意改变观感**的替换,免得以后被当成 regression 查:
|
||||
explore `.explore-name` 1.2rem→1rem、`.hot-vote-title` 0.98→0.875、首页 `.tb-stat-num` 手机 1.25→1rem、
|
||||
`.hot-vote-action` 0.82→0.875(变大)。头像 50% / 胶囊 999px 有意保留。
|
||||
下一轮若要彻底统一:`templates/user/profile.html` 16 处 + `base.css` 自身 11 处;`mail/*` 不要动(邮件客户端不保证 var())。
|
||||
|
||||
## 18:3x 收尾:字号全令牌化 + 主色补到 AA(新增 --tb-on-primary)
|
||||
|
||||
**字号**:`templates/user/profile.html` 20 处、`base.css` 9 处全部换成 `--tb-fs-*`。
|
||||
base.css 里那 4 档导航字号(0.95/0.88/0.82/0.78)合并成「桌面 md → ≤1000 xs」,logo 三档统一 lg。
|
||||
**保留未改**(有理由):`.tb-pin-flag` / `.md-body code` 的 `0.9em`(相对父级,换成 rem 会破坏继承)、
|
||||
验证码的 `1.4rem/1.2rem`(等宽字形尺寸,不是正文阶梯)、chat 关闭按钮 `40px`、`password.html` 的 `3em`、mail/*(邮件客户端不保证 var())。
|
||||
行内 `12px` / `0.75rem` 这几处恰好等值,顺手换了;chat 里还剩一个 `13px`(13→12 会缩,没动)。
|
||||
|
||||
**主色**(同色相压亮度,目标 4.7 而非 4.5,留余量):base/screw `#1e87f0`(3.26~3.42) → **`#0e6fd0`**(4.70),
|
||||
shiroko `#4a7fc1`(3.69) → **`#3b6dac`**(4.74),sakura `#e5678f`(2.80) → **`#ba3e66`**(4.70,饱和度压到 .5 保持柔和粉)。
|
||||
`--tb-primary-weak` 与 shiroko 的两条 box-shadow 一并换 RGB;`TestSwatch` 的断言改成 `#ba3e66`。
|
||||
|
||||
⚠️ **主色有两个互相矛盾的角色**:当链接色要「深」,当按钮底色配白字却要「更深的底」——
|
||||
但深色模式的主色是亮强调(`#5c8dff`/`#ff8ab0`/`#6fa3e0`),白字只有 **2.2~3.1:1**。
|
||||
所以新增 **`--tb-on-primary`**(亮 `#ffffff` / 暗 `#0b1016`),`.uk-button-primary` 与
|
||||
`.uk-label:not(danger|warning|success|secondary)` 都吃它 → 深色模式按钮是「浅底深字」。实测 6.10/8.65/7.27。
|
||||
|
||||
其它泄漏点:header 验证码 focus 的 `#1e87f0`/`rgba(30,135,240)` → 令牌;profile `.feat-day.is-on` 写死的
|
||||
`rgba(30,135,240,.14)` → `var(--tb-primary-weak)`;explore 随机投票的 JS outline、vote-results 的行内色 → 令牌。
|
||||
|
||||
**深色 muted 之前算漏了**:只对 `--tb-bg` 算(4.7~4.8 以为过了),对**压平后的卡片**才是真实背景
|
||||
(sakura 4.31 / shiroko 4.17)。已提亮到 `#a18790` / `#7f8ea1`(4.62),base 深色 4.77 本来就过。
|
||||
|
||||
**生成器**:`paletteVars` 增加 `--tb-on-primary`(白字/深字里挑更优);`?dump=palette` 增加
|
||||
`onPrimaryCr` / `onPrimaryPickedBest` / `warnings` —— 主色卡在中间调时**不断言失败**(那是主色要调,
|
||||
不是挑错),列进 warnings;配色面板给橙色提示「压深配白字 / 提亮配深字」。
|
||||
`_scaffold.css` 令牌数 29 → 30。`StaticVersion` → **`20261021c`**。
|
||||
最终审计 8 种组合(base 亮暗 + 三主题亮暗)全部 ≥4.5。⚠️ 写 CSS 用 python 改时注意:`git checkout` 会把
|
||||
base.css 还原成 CRLF,插入行要用同一行尾,否则变 MIXED。
|
||||
|
||||
## 18:5x 检查测试环境部署 → 抓到设置页色块 ZgotmplZ
|
||||
|
||||
**部署核对(都对)**:`base.css?v=20261021c`、三份 CSS(base/sakura/shiroko)与本地**逐字节一致**;
|
||||
设置页主题列表 = 跟随站点默认 / 基础外观 / 樱花 / 砂狼白子,**test01 已消失**、名称无 `theme.name.*` key;
|
||||
登录按钮是新蓝(#0e6fd0)白字,箱主页排版正常。
|
||||
⚠️ **admin6 的主题被重置成「跟随站点默认」**(它原来选的是已删除的 test01)→ 箱主页 `<html>` 没有
|
||||
`data-theme-style`,现在是基础外观。要验证主题得重新选 sakura/shiroko。
|
||||
|
||||
**P1 缺陷(既有,非本次改动引入)**:设置页四个主题色块全是 `style="background: ZgotmplZ"`。
|
||||
`html/template` 会对 `style` 属性做 CSS 过滤,未标记安全的 `linear-gradient(...)` 一律替换成 ZgotmplZ。
|
||||
`swatchOf` 一直在生成渐变串,所以这个色块**从来没显示过**。
|
||||
修法:FuncMap 加 `safeCSS` → `template.CSS`,模板 `{{ safeCSS .Swatch }}`(值只来自 swatchOf,不接受用户输入)。
|
||||
加了 `TestThemeSwatchRenders`(渲染 user/profile,断言不含 ZgotmplZ 且含渐变串),并做变异验证:
|
||||
把 `safeCSS` 去掉 → 测试如期 FAIL。
|
||||
⚠️ 教训:任何 `style="..."` 里塞模板值都要过一遍这个过滤器;模板名在测试里**不带 `.html`**(`user/profile`)。
|
||||
|
||||
## 19:0x 复检部署:色块修复已上线 + 主题链路验证
|
||||
|
||||
`1f29d3e` 部署后复检,**全部通过**:
|
||||
- 设置页 HTML 里 `ZgotmplZ` 计数 **0**,四个 `tb-theme-swatch` 都是真渐变
|
||||
(跟随默认灰斜块 / `#f7f8fa→#0e6fd0` / `#fceef3→#ba3e66` / `#eef3f9→#3b6dac`),截图确认有色块。
|
||||
- 主题链路:把 admin6 的 `theme` 设为 `shiroko`(`POST /user/profile/theme`,该表单只有 `_csrf`+`theme`
|
||||
两个字段,单独提交安全),随后
|
||||
`/_/admin6` → `data-page-scope="box"`、`/_/admin6/95` → `question`、
|
||||
`/user/questions` → `admin`、`/user/profile` → `profile`,四处都带 `data-theme-style="shiroko"`
|
||||
与 `themes/shiroko.css?v=20261021c`。桌面渲染正常(人物站地面、车与人同向、光环完整、卡片磨砂)。
|
||||
- ⚠️ **admin6 现在选着 shiroko**(为验证链路设的),要还原就把主题改回「跟随站点默认」。
|
||||
- ⚠️ **`--window-size=390,844` 得到的不是 390 CSS 视口**:同一参数截本地快照也会「右侧被切」,
|
||||
真实排版没问题 —— 移动端必须用**宽度锁定的 iframe**(生成器那套)验,别据 window-size 结论判 bug。
|
||||
shiroko 的窄屏块(≤900px)只有一条 `html[data-theme-style]`(0,1,1),但它没有任何
|
||||
`[data-page-scope]` 块,所以不会被压住;深色块的 `--tb-page-image` 覆盖窄屏属预期(深色不用实景照)。
|
||||
|
||||
## 19:2x shiroko 单车/步枪「白色没抠」→ 白上白,只能全白转透明
|
||||
|
||||
**根因不是抠图参数**:素材是「白底 + 白车架」。采样确认车架涂装与背景白是同一个值
|
||||
(都在 `#f9f9f9`,差 0~3 级),颜色上完全分不开;四角 floodfill 只吃掉外圈,
|
||||
轮圈内、车架三角、车筐、枪与车架之间的白整块留着 → 看着像没抠。
|
||||
量化:修复前**不透明像素里近白(>240) 占 50%**,全透明只有 43%。
|
||||
|
||||
试过三种程序化方案(都写了脚本):
|
||||
1. 只按「近白厚度」区分白洞/白涂装(腐蚀测厚度)→ **失败**:轮辐间的扇形白楔宽 9~13px,
|
||||
与车架管 5~10px 重叠,阈值怎么调都会留下白色楔子(更难看)。
|
||||
2. 全白转透明(`min(r,g,b)≥238` 软过渡 + 饱和度>14 保留彩色)→ 采用。成果:
|
||||
不透明率 19.4%、近白 0.0%、29KB;轮辐/车架/车筐全成描边,粉色轮圈 + 蓝枪托 + 阴影仍在。
|
||||
3. 手绘 SVG 替换:未做(不如现成素材真实)。
|
||||
|
||||
**代价要记住**:车身填色一起没了,成品是**线稿风**,在 1512 整页里偏淡(远看像铅笔稿)。
|
||||
若用户要「实心车」,唯一正解是重出一张**绿幕/透明背景 + 车身用浅灰蓝等中间调**的素材
|
||||
(白车在浅场景里本来就发飘),已写进 docs/theme-shiroko-assets-spec.md 的「白上白」小节,
|
||||
验收清单也加了「不透明像素近白占比应≈0」这条。
|
||||
|
||||
改动:`props/bike.webp`(397×259 画布不变,位置公式不用动)、css 里图片 `?v=20261020d→20261021a`、
|
||||
`StaticVersion 20261021c→20261021d`、两份 docs 同步。`go test ./internal/theme` 全绿。
|
||||
⚠️ 画布尺寸**不能裁**:裁完 `auto 259px` 的显示宽度变了,`left calc(50% - 612px)` 的锚点会跟着漂。
|
||||
|
||||
## 19:4x 两件事:/user/questions「卡片透明」根因 + 主题素材上 R2
|
||||
|
||||
**1) /user/questions 卡片透明 —— 不是主题,是模板没有卡片**
|
||||
`templates/user/question-list.html` 整个文件只有 1 处 `.uk-card`(投票 tab 的「我的投票」标题条);
|
||||
提问列表是 `<div class="uk-margin-medium-bottom">` + `<hr>` **裸铺在页面上**。
|
||||
默认主题底色 #fff 看不出来,换带整页插画的主题后场景整片透上来 = 「卡片透明」。
|
||||
修:`.qlist-panel` 加卡片底(`--tb-surface` + `--tb-radius` + `--tb-shadow` + padding,
|
||||
≤640px 收紧),内部那张 `.uk-card-default` 降成 `--tb-surface-2` 去阴影(防卡片套卡片)。
|
||||
验证:本地快照注入 shiroko 后,行内容落在卡上,场景只从卡外透出;默认外观只是多一层白卡。
|
||||
⚠️ 页面级样式仍写在模板 `<style>` 里(不搬 base.css),符合既有约定。
|
||||
|
||||
**2) 主题素材放 R2 —— 三种做法,生成器已支持 A**
|
||||
素材只有一处引用:主题 css 的 `url()`(分享卡是 Go 用 imaging 画的,不读素材)。
|
||||
- A 生成器「素材前缀」:导出面板新增输入(`S.assetBase`,存进工程 json,`?assetbase=` 可测),
|
||||
`urlOf()` 一处决定地址 → 绝对 URL。**踩到过坑:`layerCSS` 里复制了一份路径拼接**,
|
||||
只改 urlOf 会导致「背景走 CDN、装饰还指本地」,已统一到 urlOf。
|
||||
- B 应用内 301:`[theme] asset_base` + 中间件(必须注册在 `flamego.Static` **之前**)。
|
||||
- C 边缘/反代改写(CF Origin Rule / nginx)。
|
||||
细节与取舍写进新文档 `docs/theme-assets-cdn.md`(含缓存、CORS、深色无素材、迁移 sed 命令)。
|
||||
⚠️ UI 坑:面板 240px 里 4 个中文字的 label 会被 flex 压缩、末字被输入框切掉 →
|
||||
label 要 `flex:0 0 auto; white-space:nowrap`,宽度给 84px 才够(68px 仍会切)。
|
||||
|
||||
**3) 素材托管:本地 / 任意 S3 兼容,生成器内测通即可上传**
|
||||
用户要求「放本地还是放 S3 兼容(不只 R2)由部署者自己定,并且能配好即测、测通即传」。
|
||||
- `serve.py` 手写 SigV4(零依赖,PUT/GET/DELETE/HEAD),新增 `/api/s3/ping|test|upload`。
|
||||
测试连接四步:HEAD 桶 → PUT 探针 → GET 读回比对 → 匿名 GET(能否被浏览器直读)→ 删探针。
|
||||
4xx 把 `<Code>` 翻成人话(`S3_HINTS`),签名失败直接指向密钥/Region/时钟三件事。
|
||||
- `index.html` 新增「素材托管」面板,上传集合复用导出用的 `usedAssetIds()`;
|
||||
成功后自动回填「素材前缀」。密钥只进 sessionStorage,不进工程 json(有断言)。
|
||||
- **两个真 bug**:①`usedAssetIds()` 返回 Set,`!ids.length` 恒真 → 永远「还没有素材」;
|
||||
②字段填完 700ms 内刷新会丢(scheduleSave 防抖)→ 失焦/切模式/上传成功时立即 `saveDraft()`。
|
||||
- **验证手段**:`check_sigv4.py` 用 aws-sdk-go-v2 的 signer 逐字节对拍(6/6)——
|
||||
裸 SignHTTP 会二次转义 URI、而 S3 client 关了它,所以用例只用 ASCII,
|
||||
中文 key 的编码规则跟随 S3 client(单次 RFC3986)。
|
||||
`selftest_upload.py` 假 S3 跑 16 项;`mock_s3.py` 可手动点面板。
|
||||
- ⚠️ **8777 端口陷阱**:Windows `SO_REUSEADDR` 让新进程 bind 成功却抢不到 accept,
|
||||
症状是「代码改了但接口还是 404」。查 `Get-NetTCPConnection -LocalPort 8777`,或换端口验证。
|
||||
|
||||
**4) 远端素材检测与管理(用户「还没测,想先看到 S3 上有什么」)**
|
||||
- serve.py:`s3_request` 支持 query,新增 `list_objects`(ListObjectsV2 + 分页)与
|
||||
`delete_objects`;端点 `/api/s3/list`、`/api/s3/delete`。
|
||||
- index.html:远端素材区,按对象键名跟本地比 → 一致 / 旧图(同名大小不同)/ 多余 / 缺失;
|
||||
「补齐差异」只传缺的与旧图,「删除多余」批量删,单行 ↻ 覆盖、✕ 删除。
|
||||
- ⚠️ **键的两种形态**:list 返回**完整键**(含 `<路径前缀>/`),upload/delete 接口收
|
||||
**相对键**。不剥这一层,服务端会再拼一次前缀 → 删一个不存在的键,S3 还回 204,
|
||||
表现为「显示删除成功但东西还在」。加 `relKey()` 统一转换。
|
||||
- ⚠️ `check_sigv4.py` 补了带 query 的用例(ListObjectsV2 三参数、乱序传入的分页 token),
|
||||
8/8 一致 —— canonical query 的排序与百分号编码也必须对拍,漏测会静默失效。
|
||||
|
||||
**5) 生成器默认监听 0.0.0.0(局域网/手机可用)**
|
||||
- `serve.py --host` 默认由 127.0.0.1 改成 0.0.0.0;启动时用 `local_addresses()` 列出所有
|
||||
网卡 IP(getaddrinfo + UDP connect 双路兜底),0.0.0.0 本身不打印成可点地址。
|
||||
- 顺带修两个真问题:①`TCPServer` 是单线程 —— 传一张几 MB 的图会卡住 iframe 预览与其它请求,
|
||||
换 `ThreadingTCPServer`;②写接口加 `_same_origin()`(Origin 与 Host 比对,无 Origin 放行),
|
||||
挡住「同网段另一个网页借访客浏览器发写请求」。curl/脚本不受影响。
|
||||
- `index.html` 提示文案里写死的 `127.0.0.1:8777` 改成 `HERE`(`location.host`)——
|
||||
用局域网 IP 打开时不能反过来告诉用户「请用 127.0.0.1 打开」。
|
||||
- ⚠️ **浏览器按 origin 隔离存储**:`127.0.0.1` 与 `192.168.x.x` 是两个站点,
|
||||
localStorage 草稿与 sessionStorage 里的 S3 密钥不共享,换地址要重填一次(README 已写)。
|
||||
- `mock_s3.py` 加 `--host`(默认仍 127.0.0.1):生成器在别的设备上打开时,假 S3 也要 0.0.0.0。
|
||||
- 验证:局域网 IP 访问 200、`--host 127.0.0.1` 时局域网访问连不上、跨站 Origin 被拒(403)、
|
||||
局域网 origin 下检测/补齐全走通。⚠️ 本机 curl 访问 192.168.x.x 必须加 `--noproxy '*'`,
|
||||
否则被系统代理接走报 502。
|
||||
@@ -0,0 +1,250 @@
|
||||
# 2026-10-23
|
||||
|
||||
## 单题分享卡二维码提到 5px/模块(`310f851`)
|
||||
|
||||
用户决定「单题一起做」,同时否掉方案 5(结果落盘/R2)——理由:功能使用率不高,不值得大动干戈。
|
||||
|
||||
- 单题卡卡片 210×255 放不下 5px 模块(37 模块要 185px):`QQRCardW` 236、`QQRCardH` 300、
|
||||
`QQRSize` 190。X 由 `QContentX+QContentW-QQRCardW` 派生 → 679,Y 居中 → 120。
|
||||
- `QTextMaxW` / `qLeftClip` 都从 `QQRCardX` 派生,左栏自动收窄 26px,无需另改。
|
||||
- 抽了 `drawQuestionQRCard(canvas, user, question, logo, fontCtx)`(handler 只剩调用),
|
||||
这样测试能直接渲染整卡:`TestQuestionQRCardLayout` 断言正方形 / 模块≥5 / 四边留白≥20 /
|
||||
居中≤1px,`TEST_PREVIEW=1` 出 `outputs/sharecard-preview/question-card.png`。
|
||||
- 实测:二维码 185px、居中偏差 0.5px、上下留白 34px;gozxing 在 960/720/480/320 全通过。
|
||||
变异验证:`QQRSize` 改回 150 → "QR module is 4px, want >=5" FAIL。
|
||||
- 未做(用户明确不要):分享卡结果落盘/上 R2。进程内 LRU 仍是 16 条 + TTL 1h,
|
||||
冷渲染成本接受现状。
|
||||
|
||||
## 部署后复验(箱子卡 + 单题卡,`310f851`)
|
||||
|
||||
- 箱子卡:QR 185×185 @ y120-304 / x702-886,卡中心 795 → 着墨中心 794.0(−1.0px);
|
||||
提示语 794.0、分隔线 795.0、链接两行 795.0 / 794.5;上下留白各 18px。
|
||||
- 单题卡:QR 185×185 @ y154-338 / x704-888,卡中心 797 → 着墨中心 796.0(−1.0px);
|
||||
提示语 797.0;上下留白 34 / 33px。
|
||||
- 端到端解码(部署图,含真实背景与内嵌头像):两张卡在 960 / 720 / 480 / 320 宽全部 OK,
|
||||
内容正确(单题卡 `/114`)。
|
||||
- 顺带确认 **不是** bug:删除提问后分享卡立刻 302(`GetByID` 在缓存查询之前),
|
||||
不存在「删了还能靠缓存取到图」的窗口。
|
||||
|
||||
## 缓存 key 排查结论(`675cecb`)
|
||||
|
||||
用户要求「有影响就修」。查下来**当前没有实际影响**,只有一条理论缺口:
|
||||
|
||||
| 可能场景 | 是否真有影响 | 原因 |
|
||||
|---|---|---|
|
||||
| 用户改域名 → 二维码指向旧域名 | ❌ 当前无 | Domain 只在注册时写(`register.go` / `CreateUser`),无改域名入口 |
|
||||
| 改 ExternalURL / ShareBackground | ❌ 无 | 配置需重启,缓存是进程内的,重启即清 |
|
||||
| 换头像/背景后仍是旧图 | ❌ 无 | R2 key = `picture/日期/随机 hex`,内容不可变,换图必换 URL |
|
||||
| 域名将来可改 | ⚠️ 理论 | 单题卡 key 未含 `user.Domain` |
|
||||
|
||||
处理:给 `questionShareCardCacheKey` 补 `user.Domain`(箱子卡本来就有);
|
||||
两处「TTL 30 分钟」注释改成 1 小时(代码一直是 `time.Hour`)。
|
||||
新增 `TestShareCardCacheKeysCoverDrawnFields`——逐字段变异断言 key 必须变,
|
||||
去掉 Domain 立刻 FAIL。**以后改分享卡要加新字段时,先看这张测试。**
|
||||
|
||||
## 正式环境「蜜罐封了但还能访问」排查(IPv6)
|
||||
|
||||
本地用 IPv6 全程复现了一遍 autoban 链路(CF-Connecting-IP 注入 + trust_proxy=true):
|
||||
封禁前 200 → POST 凭据后 `IsBanned`=true → 同地址 403 → 同 /64 换地址仍 403 →
|
||||
不同 /64 放行 200。**判定逻辑与中间件都正确,不是代码 bug。**
|
||||
|
||||
封禁键 = `shared.LimitKey(ResolveClientIP(req))`:
|
||||
- IPv4 原样
|
||||
- IPv6 折成 `xxxx:xxxx:xxxx:xxxx::/64`
|
||||
写入(honeypot.Serve / route.NotFound)与检查(autobanMiddleware)用的是同一个函数,一致。
|
||||
|
||||
⇒ 「邮件说封了但能访问」只能是**键不一致**或**进程重启(封禁纯内存态)**。
|
||||
三种可能:① 部署后重启过;② trust_proxy 未配 → 键是反代地址(`127.0.0.1` / `::/64`);
|
||||
③ 前后请求地址族或 /64 不同(双栈、IPv6 前缀轮换、多实例)。
|
||||
|
||||
判定方法:`honeypot hits` / `data/honeypot-creds/creds-YYYY-MM-DD.jsonl` 里的 `ip` 字段
|
||||
就是**被封的键**,与本机 `curl -6 ifconfig.co` 比对即可。
|
||||
|
||||
**发现的真实取证缺陷**:`db.HoneypotHit.IP` 与 creds 文件存的是折叠后的 `/64` 键,
|
||||
IPv6 丢掉了精确地址(IPv4 无损);邮件里也只给键,运维会误以为「这不是我的 IP」。
|
||||
建议:取证记精确 IP,封禁仍用 /64;建封与 403 命中各打一条日志。
|
||||
|
||||
### 据此动手改的三处(精确 IP / 封禁键分离 + 可观测性)
|
||||
|
||||
1. `honeypotHandler.Serve` 拆两个变量:`clientIP = ResolveClientIP`(精确,取证/邮件用)、
|
||||
`banKey = LimitKey(clientIP)`(/64,封禁/采样/告警去重用)。DB 与凭据文件都写 clientIP。
|
||||
2. `autoban.Options.OnBan(key, duration, reason)` 回调(新增 `ReasonProbe` / `ReasonHoneypot`),
|
||||
保持 autoban 纯标准库、不再依赖 logrus。route.newBanner 注入 → 建封打 WARN
|
||||
(ban_key / reason / duration / expires_at)。中间件 403 分支打 Debug。
|
||||
3. 邮件同时给「来源 IP」(精确)与「封禁范围」(ban_key,仅 `ne .ip .ban_key` 时渲染);
|
||||
顺手把模板里过时的「已入库」改成「写入取证文件」(上一轮凭据外置后没同步)。
|
||||
|
||||
副作用修掉:`honeypot stats` 的来源排行改按 `LimitKey` 合并(否则 IPv6 轮换地址会把排行碎成
|
||||
几十条各 1 次)。SQL 取 `limit*4` 再合并,属近似。
|
||||
|
||||
测试:`TestHoneypotRecordsExactIPv6ButBansByPrefix`(精确 IP + /64 键 + store 断言)、
|
||||
`TestOnBanCallback`、`TestHoneypotAlertTemplateRenders`、`TestMergeSourcesByKey`。
|
||||
四处变异验证有效(改回存聚合键 / 封禁用精确地址 / 去掉 notifyBan / 不合并排行)。
|
||||
|
||||
**变异验证的坑**:把 `shared.LimitKey(r.IP)` 改成 `r.IP` 会因 `shared` 未使用而
|
||||
**编译失败**(不是断言失败);变异要保证仍能编译(改成 `LimitKey(r.IP) + r.IP` 才测到逻辑)。
|
||||
|
||||
### 测试环境验收(部署 29ddee0 后)
|
||||
|
||||
伪造 `CF-Connecting-IP: 2001:db8:…`(RFC 文档保留段,封了不影响任何人)直连源站端口:
|
||||
|
||||
- 基线 200 → POST 凭据 200(假页面)→ **同 /64 换地址 403**(聚合封禁生效);
|
||||
- 凭据文件 `ip` = `2001:db8:dead:beef::1234`(精确,无 /64 后缀),`banned:true`;
|
||||
- 换另一个 /64(`2001:db8:cafe::1`)→ 200,说明封的是 /64 不是全局;
|
||||
- `od -c` 复核 `"path":"/wp-login.php"` **无前导空格** → cleanPath 生效。
|
||||
|
||||
**教训**:上一次那条 `" /wp-login.php"` 是**那次请求的 URI 本身带空格**(本地实测
|
||||
`Match(" /wp-login.php")=true` 靠 TrimSpace 命中、`Match("/ wp-login.php")=false`),
|
||||
不是代码问题;判断这类"看不见的字符"一律用 `od -c`,别靠终端渲染。
|
||||
测试环境服务是**前台跑在 /dev/pts/1**,日志不在文件里 —— 拿日志要 `tmux capture-pane` 或
|
||||
下次启动加 `>> logs/tamabox.log 2>&1`。
|
||||
|
||||
### 蜜罐升级为 9 个伪装家族
|
||||
|
||||
新增 `family.go`(皮肤表)/ `asset.go`(静态资源占位)/ 重写 `page.go`:
|
||||
- 按路径分发:wordpress / phpmyadmin / tomcat(401+Basic) / jenkins / grafana / gitlab /
|
||||
luci / joomla / generic;每类独立字段名、配色、指纹响应头、Set-Cookie;
|
||||
- `credentialKeys` 扩 `pma_*` / `j_*` / `luci_*` / `user[login]`;
|
||||
`token` 只用于判 HadFields,**不进 isPasswordKey**(否则 PMA 的 hidden token 会随机顶掉真密码);
|
||||
- 静态资源(`/wp-admin/css/…` 等)回占位内容,**不落库不计数** —— 一次页面加载 5–10 个请求;
|
||||
- `honeypot paths` 加家族汇总(实测 110 条:generic 84 / wordpress 10 / tomcat 6 / gitlab 3 / pma 3 …);
|
||||
- 性能:RenderFamily 12µs→3.7µs、22→2 allocs、19.5KB→5.2KB(禁 `"a"+b+"c"`、Replacer 提包级)。
|
||||
|
||||
**新工具** `dev/preview-honeypot`:渲染 9 个家族到 `outputs/honeypot-preview/index.html`,
|
||||
配 chrome headless 截图核对外观。靠它发现两处「源码看不出、截图一眼假」:
|
||||
Tomcat 的 skin 忘了 css(按钮白字无底色)、WP 登录页底部挂着 404 文案。
|
||||
|
||||
### 测试环境验收:9 家族伪装(部署 7225f68 后,只读 GET)
|
||||
|
||||
全部符合预期:8 个家族 title/generator 各不相同、Tomcat 401+Basic realm、
|
||||
静态资源 200 且 MIME 正确(128B css / 81B js / 70B png)、插件目录 .php 回 404、
|
||||
业务路由(/login /user /static /api)无 canary、界外路径仍 302 → /。
|
||||
|
||||
发现的漏洞(按优先级):
|
||||
1. **P1 `/xmlrpc.php` 回 HTML 登录页** —— 而我们自己发了 `X-Pingback: /xmlrpc.php`,
|
||||
真实 WP 回 text/xml,等于自己指给自己一个明显假的地址;同时丢掉 WP 爆破最有价值的
|
||||
捕获通道(xmlrpc system.multicall 一次可试上千组,绕开登录限速)。
|
||||
2. **P2 每次请求恒定值**:PMA `token=e1a4c0b7d2f58g93`(还带非 hex 的 g)、
|
||||
generic `csrf_token=a7f3c1e9b2d4`、`X-Jenkins-Session: 8b1f2c4d`。
|
||||
两次请求完全相同 → 跨请求指纹即可识别。应改每请求随机。
|
||||
3. **P2 `X-Pingback` 用相对路径**,真实 WP 给绝对 URL。
|
||||
4. **P3 `/phpinfo.php` 漏匹配**:路径清单里是 `/phpinfo`、`/info.php` 精确项,
|
||||
段前缀匹配不覆盖 `.php` 变体 → 302。可加「p 去掉 .php 后在集合内」的兜底。
|
||||
5. **P3 `/wp-json/wp/v2/users` 回 HTML**,真实 WP 回 JSON(用户枚举接口)。
|
||||
6. P3 `X-Frame-Options: DENY` / `Cache-Control: no-store` 与真实 WP 不同,
|
||||
但属全局中间件,改动影响全站。
|
||||
|
||||
### 修 P1/P2:XML-RPC 端点 + 动态 token + .php 兜底
|
||||
|
||||
- 新增 `internal/security/honeypot/xmlrpc.go`:`/xmlrpc.php` 不再回登录页。
|
||||
GET→405+XML fault「XML-RPC services are disabled」;POST methodCall→200+fault
|
||||
「Incorrect username or password」;POST system.listMethods→方法列表模板。
|
||||
凭据提取用正则(不上 encoding/xml,蜜罐高频命中):抓 `<string>`、剔除
|
||||
methodName 与 http(s) URL、两两配对;`blog_id=1` 这种「奇数长度+首项短数字」
|
||||
会丢首项纠正错位;第一组进标准字段,其余进 `cred2.*`,原始串全量进 `xmlrpc.N`
|
||||
(配置上限 16 对 / raw 8KB)。
|
||||
- 动态占位符:`LoginPageVars.Nonce`(32 hex,`math/rand/v2`,刻意不用 crypto/rand,
|
||||
这些是假 token 只需「每次不同」)。支持 `$hex8/16/32` 与 `$base`;
|
||||
页头、hidden、Set-Cookie 共用同一套 nonce。
|
||||
- `X-Pingback` 改绝对地址(`$base/xmlrpc.php`),没配 `external_url` 就不发。
|
||||
- `Cache-Control` 从 `no-store` 改 `no-cache, must-revalidate, max-age=0`(更像 WP/PMA)。
|
||||
- Grafana/GitLab 补真实会种的会话 cookie(`grafana_sess`、`_gitlab_session`,动态)。
|
||||
- `Match` 增加 `.php` 变体兜底:`/phpinfo.php` 之前漏匹配 → 302。
|
||||
- 性能:RenderFamily 3.7µs→4.18µs、2→3 allocs(多的一次是 hex 编码),可接受。
|
||||
|
||||
四处变异验证有效(固定 token / xmlrpc 分支失效 / Pingback 相对路径 / .php 兜底失效)。
|
||||
|
||||
### 测试环境验收:XML-RPC 端点(部署 9d3f5a7 后)
|
||||
|
||||
- `GET /xmlrpc.php` → **405 + text/xml**(与真实 WP 一致,XML-RPC 只接受 POST),
|
||||
用户误以为这是报错;405 的 body 是 `<fault>XML-RPC services are disabled…`。
|
||||
- `POST system.listMethods` → 200 + 方法列表 ✓。
|
||||
- `POST wp.getUsersBlogs`(伪造 `CF-Connecting-IP: 2001:db8:beef::7`)→ 凭据落文件:
|
||||
`username=xruser / password=xrpass`、`fields.xmlrpc.0/1`、`xmlrpc.method`、`banned:true` ✓。
|
||||
- 紧接的下一次请求 `403 Forbidden` = 已封禁(第一次 POST 就封了该 /64),属预期。
|
||||
- 动态 token 两次不同(`d85a8c…` / `76a404…`)✓。
|
||||
|
||||
**再次出现的 `"path":" /xmlrpc.php"` 已判定为粘贴/终端产物**:`IsXMLRPCPath` 不做 trim,
|
||||
若 `path` 真带前导空格根本进不了 XML-RPC 分支,而记录里有 `xmlrpc.*` 字段 ⇒ 落盘值必是
|
||||
`/xmlrpc.php`。这类"看不见的字符"结论一律靠 `od -c`,别靠终端回显。
|
||||
|
||||
### 403 响应体换成图片
|
||||
|
||||
用户给了 R2 预签名链接(`X-Amz-Expires=604800`,7 天后失效)——**不能直接热链**。
|
||||
下载后(256×256 webp,8.6 KB)用 `//go:embed` 编进二进制:
|
||||
`internal/route/assets/403.webp` + `internal/route/banned.go: writeBanned()`。
|
||||
- 状态码仍是 403(语义不变),只换 body;`Content-Type: image/webp` + 显式 `Content-Length`
|
||||
(8 KB 超过 Go 的 2 KB 缓冲,不设会退回 chunked)+ `no-store`;HEAD 不写 body。
|
||||
- 放在 `internal/route/assets/` 而不是 `static/`:static 会多出一个可公开访问的 URL。
|
||||
- 换图方式:覆盖同目录 403.webp → 重新编译,无配置项。
|
||||
- 体积代价:每封禁请求 8.6 KB(原 13 字节)。被封来源重试频繁时要留意带宽,
|
||||
已压到 256×256;别塞几百 KB 原图。
|
||||
|
||||
### 关于「403 图放 R2 是否更好」的结论
|
||||
|
||||
结论:**保持 embed**。关键事实是「服务端代理/重定向并不省我们带宽」——
|
||||
字节最终还是从本机发给被封客户端;想真省只能让客户端自己去 R2 取,
|
||||
那就要放弃「403 直接是图片」,改成 403 + 一个 `<img src=外链>` 的 HTML。
|
||||
而本项目 R2 目前只有预签名 URL(7 天过期),长期用必须开 r2.dev 或绑自定义域
|
||||
(docs/theme-new-sakura.md 已记:直连 `*.r2.cloudflarestorage.com` 会 403)。
|
||||
8.6 KB 的单张图不值得引入外部依赖;被 ban 的多是脚本,根本不会加载图片。
|
||||
若动机只是「换图不想重编译」,加 `[security] ban_image_path` 磁盘覆盖更划算。
|
||||
|
||||
### 改为 ban_image_path(配置驱动,默认纯文字)
|
||||
|
||||
按用户要求撤掉 embed:`internal/route/assets/`(含 403.webp)已删除,
|
||||
行为改为「配了合法图 → 回图;留空 / 读不到 / 不是图 / >2MB → 纯文字 403」。
|
||||
- `[security] ban_image_path`(相对路径以进程工作目录为基准);
|
||||
- `loadBanImage()` 在 `route.New()` 里启动时读一次 → `atomic.Pointer[banResponse]`,
|
||||
热路径零磁盘 IO;任何失败只 Warn 不改启动(封禁响应是最后一道兜底,不能变 500);
|
||||
- **只认内容不认扩展名**(`http.DetectContentType` 必须是 `image/*`):配成
|
||||
app.ini/.env 会被拒绝,否则等于把配置文件吐给攻击者(变异验证过这条);
|
||||
- 上限 2 MB(被封来源反复重试,大图放大出网带宽);`Content-Length` 显式给;
|
||||
HEAD 不写 body;`no-store`。
|
||||
- 实测 sniff:真 webp → `image/webp`;`app.sample.ini` → `text/plain`(被拒)。
|
||||
|
||||
**教训**:同一功能改了两版(先 embed 后配置化),第二版要把上一版的资源一起清掉,
|
||||
否则留一个没人引用的 `assets` 包。`go vet ./...` 不报、测试也不报(该包仍被旧测试引用时)。
|
||||
|
||||
## /user/questions:筛选项「已读 是/否」+ 手机端标签行不折行
|
||||
|
||||
- 筛选栏第 5 列标签「阅读状态」四字,在 ~53px 的窄列里必折两行。
|
||||
→ 词条 `qlist.readStatus` 删除,换 `qlist.readFilter`(已读/Read/既読/已讀)+
|
||||
`qlist.filterYes`/`qlist.filterNo`(是/否)。**只改标签,option 的 value 仍是
|
||||
read/unread**(后端 `readParam` 校验依赖它)。`qlist.all`(全部)保留作默认不筛选。
|
||||
- 手机端「时间 + 状态标签 + 标记/置顶」挤一行:实测(360/390/375 iframe + dump-dom 量 rect)
|
||||
一行只有 306–336px,完整时间占 127px,2 标签 + 2 按钮要 343px → 必折。
|
||||
→ 时间改 `tb-short`=m-d H:i / `tb-text`=Y-m-d H:i:s(复用 pill 的 640px 断点);
|
||||
→ `.qlist-pill` 基础值 min-width 40→34、padding 0 8→6(手机端省 20px),
|
||||
桌面 `@media(min-width:640px)` 仍覆盖回 62px/10px。
|
||||
结果:2 标签 + 2 按钮在 360 就单行;「私密+待答+未读」三标签仍会在 ≤375 折行(可接受)。
|
||||
- 量手机宽度别用 `chrome --window-size=360`:Windows headless=old 有 ~500px 最小窗宽,
|
||||
实测 innerWidth 恒为 500。用 360px 宽 iframe + `--allow-file-access-from-files`,
|
||||
结果由 iframe 里 `window.top.document.title` 带回,`--dump-dom` 读。
|
||||
|
||||
### 手机端时间的年份:逐条补年,不做按年分组(DateCompact)
|
||||
|
||||
- `Template.FuncMap` 新增 `DateCompact`(内部 `dateCompact(t, now)`):同年 → `01-02 15:04`,
|
||||
跨年 → `2006-01-02 15:04`。桌面端仍走 `tb-text` 的完整格式,不受影响。
|
||||
- 用户问过「省了年要不要按年把问题隔开」——**不做**。理由是列表带游标分页(`*_cursor`)+ 可切
|
||||
排序 + 4 个筛选 + 关键词搜索:组标题只按当前页算,翻页会重复出现同一年标题;换了排序
|
||||
(最早→最新)或搜索时又会出现空组/错位。逐条补年份零结构成本、无耦合。
|
||||
真要年份维度,顺序应是:①跨年补年(已做)②筛选栏加「年份」下拉(distinct year,走 SQL)。
|
||||
- 实测(360 宽 306px):跨年格式占 109px(vs 同年 73px)。跨年 + 2 标签 + 2 按钮 = 309px,
|
||||
差 3px 会在 360 宽折行;390 及以上的 2 标签组合都单行。预览数据里放了一条 2025 年条目用于核对。
|
||||
|
||||
## 全站数据页 /stats(替换首页统计卡)
|
||||
|
||||
- 首页底部「站点统计」三行小卡 → 换成一行摘要 + 入口按钮;完整数据在新公开页 `/stats`。
|
||||
明细见 `topics/stats.md`。
|
||||
- 新增:`internal/db/analytics.go`(聚合 SQL,方言收敛)、`internal/stats/overview.go`
|
||||
(自然日缓存 + 补齐空日期)、`internal/stats/page.go`(i18n 标签 + 洞察文案)、
|
||||
`route/stats.go`、`templates/stats.html`、`internal/template/chart.go`(折线坐标等 5 个函数)。
|
||||
- 图表零依赖:折线 SVG,柱状/分布条用百分比高度,环形用 `pathLength=100` 的 dasharray,
|
||||
热力图 CSS grid + 主色 opacity 分 4 档。
|
||||
- 口径修正:环比改成「上月 vs 上上月」(本月残月比整月会在每月 1 号算出 -95%)。
|
||||
- 补索引 `questions(created_at)`;启动后 3s 异步预热当天快照。
|
||||
- 测试:`internal/db/analytics_test.go` 连真库验证(软删除不计入、分桶下标、DOW 对齐)、
|
||||
`internal/stats/overview_test.go`(补齐日期、月底不溢出、除零)。全绿。
|
||||
- 踩坑:预览页 base.css 用 `href="/static/..."` 绝对路径,file:// 下取不到 →
|
||||
令牌全缺、图表全透明。`writePreview` 已改成同时重写 `href="/static/`。
|
||||
@@ -0,0 +1,144 @@
|
||||
# 2026-10-24
|
||||
|
||||
## /stats 入口收敛到页脚
|
||||
|
||||
- 用户要求:首页那张统计入口卡取消,入口只放页脚「社区规则」与「找站长」之间。
|
||||
- 改动:
|
||||
- `templates/home.html`:删掉统计入口卡整块 + `.tb-stats-entry*` 样式(原 `</details>` 后那一段)。
|
||||
- `route/home.go`:`Home` 不再调 `stats.Get`(模板已不读 `.SiteStats`),去掉 `logrus` import;
|
||||
`/api/v1/site-stats` 与 `SiteStatsAPI` 保留未动。
|
||||
- 四份 i18n 字典删掉已无引用的 `site.stats.open`(`site.stats` 老词条保留)。
|
||||
- 页脚 `templates/base/footer.html` 的 `site.footer.stats` 位置本就在 rules 与 admin 之间,无需再动。
|
||||
- 验证:`go build` + `./internal/i18n` / `./templates` / `./route/...` 测试全绿;gofmt 无差异;
|
||||
重新生成预览确认首页无 `tb-stats-entry`,页脚渲染为「社区规则 | 数据 | 找站长」。
|
||||
- 教训:删掉页面唯一消费方后,服务端的取数调用(`stats.Get`)要同步删,
|
||||
否则每次访问首页白算一次快照;对应 i18n 词条也要一起清,避免留死条目。
|
||||
|
||||
## /stats 文案与脚注排版修正
|
||||
|
||||
- 页脚入口文案「数据」→「统计数据」(`site.footer.stats`,四语言 `Statistics` / `統計データ` / `統計數據`)。
|
||||
- **「提问最集中在三 19 点前后」的 bug**:洞察句子复用了热力图列头的 `stats.wd.*`
|
||||
("一"…"日" / "Mon"…),那是为窄列头设计的单字标签,进句子就成了"三"、英文是 "Wed"。
|
||||
新增一组完整名 `stats.weekday.1..7`(周一/週一/Monday/月曜日),洞察改用这组;
|
||||
`stats.wd.*` 只留给热力图列头。已在 `overview_test.go` 加断言锁住,防止回退。
|
||||
→ 教训:**短标签和完整名必须分两套词条**,别为了少写一组 key 复用。
|
||||
- 页面脚注四条时间(基准日 / 计算于 / 最后一条提问 / 最后一条回答)由
|
||||
「A:x · B:y」挤成一行改为 `grid-template-columns: max-content 1fr` 各占一行,
|
||||
标签列与值列对齐;下面两段说明仍为独立段落。四个标签统一带冒号。
|
||||
|
||||
## 部署前自查:两个 P0 都已修
|
||||
|
||||
用户部署到测试环境后要求自查。查出的问题都是我自己上一轮引入的:
|
||||
|
||||
1. **「今日」两项被每日缓存冻住**(P0,与页面「实时」说明矛盾)。
|
||||
→ `TodayCounts` 拆出,60 秒 TTL + 跨日按日期失效;命中缓存时返回副本。
|
||||
新增 `TestTodayNumbersNotFrozen` / `TestTodayCacheExpiresAcrossMidnight`。
|
||||
2. **日期分组跟着 PG 会话时区(默认 UTC)**(P0,隐蔽:数字都"合理",只是整体偏 8 小时)。
|
||||
psql 实测:`2026-10-24 01:30+08` 在 UTC 会话下 to_char 得 `2026-10-23`、hour=17。
|
||||
→ `analyticsLocalize()` 用 `AT TIME ZONE INTERVAL '+08:00'`(偏移取 `time.At()` 的 Zone,
|
||||
不用 IANA 名)。EXPLAIN 确认过滤仍走 created_at 索引。
|
||||
3. PeakDay 用 `>=` 会让「最忙的一天」随更新漂移到最近的同值日期 → 改 `>`。
|
||||
4. `ovRefresh` 里的聚合改用 `context.WithoutCancel` + 30s 超时:第一个访客中途关页面时,
|
||||
排队的请求不会各自重跑全表 GROUP BY。
|
||||
|
||||
性能实测(20 万行):totals 337ms / 字数分布 313ms / 热力图 100ms / 日趋势 47ms,
|
||||
本地合计约 0.8s,1C1G 估 2s,靠预热兜住。百万行才需要合并扫描。
|
||||
|
||||
顺带发现(未改,与本次无关):`Dockerfile` 基础镜像是 `golang:1.20-alpine`,
|
||||
而 `go.mod` 要求 `go 1.27.3` —— 1.20 不支持 GOTOOLCHAIN 自动下载,Docker 构建必然失败;
|
||||
`-X` 里的包路径还是 `NekoWheel/NekoBox`。说明实际部署不走这个 Dockerfile。
|
||||
|
||||
## 测试环境实测(box-dev-api.337845818.best,用户时区 UTC+8)
|
||||
|
||||
- `/stats` 200,首访 5.2s(含 TLS),稳定后 TTFB 1.6s,同站 `/rules` 1.34s
|
||||
→ 本页只多约 250ms,1C1G 上可接受,无需合并聚合 SQL。
|
||||
- 环境默认语言是 ja(`/`、`/rules` 同样是 ja),与本次改动无关;语言存 session
|
||||
(`/i18n/{lang}`),故 /stats **绝不能加 CDN 缓存头** —— 内容随 session 变化且无 Vary: Cookie。
|
||||
- 逐项验算自洽:月柱合计 95 = 累计提问;热力图 7×24=168 格合计 95;
|
||||
最忙时段周三 19 点 = 22 与小时合计 26 峰值一致;折线 30 点峰值 10-19 共 5 条。
|
||||
- `/stats/` 尾斜杠 302 跳首页 —— 与 `/rules/`、`/sponsor/` 一致,框架行为。
|
||||
|
||||
## P0:回答耗时口径是错的(本次修)
|
||||
|
||||
`SetRead` 把 updated_at 往前推(实测 10 天前的提问,delta = 240h)。
|
||||
所以「回答耗时 = updated_at − created_at」实际测的是「提问到最后一次被碰」,
|
||||
线上「6 条超过 7 天」就是这么来的,24 小时占比 66.7% 同样偏低。
|
||||
|
||||
修法:`questions.answered_at`(首次回答时间),只在 answer 由空变非空时写,
|
||||
改写回答不重置;聚合 `COALESCE(answered_at, updated_at)`;加列时回填一次冻结历史。
|
||||
测试:`internal/db/answered_at_test.go` 5 个用例,含变异验证(改回 updated_at 即红)。
|
||||
|
||||
## 顺带修
|
||||
|
||||
- 环比句式:中文「比前一个月 -69.6%」、英文「...: -69.6% questions.」读不通 →
|
||||
「上月提问量环比 %s%%」/「Month-over-month change for last month: %s%%」。
|
||||
- 脚注「当日 0 点的快照」不实(实际是当天首访/重启时算)→ 改成准确描述。
|
||||
- DB 故障时 `ovRefresh` 串行 + 30s 超时会让请求排成 N×30s → 加 30s 失败冷却
|
||||
(`ovFailAt`),`todayNumbers` 失败也盖时间戳,不再每请求打两条 COUNT。
|
||||
|
||||
## 30 天折线补纵轴刻度(`cbb8908`)
|
||||
|
||||
- `internal/template/chart.go`:`Polyline`/`AreaPath` 两个函数收敛成 `NewLineChart`
|
||||
返回 `LineChart{HasData,Max,Points,Area,Ticks}`。**坐标/面积/刻度必须一次算**,
|
||||
分两次各自归一化会让线和刻度错开半格。
|
||||
- 纵轴上限 `niceMax(峰值)`(1/2/1.5/3/4/6/8×10^n 向上取整),刻度 上限/一半/0。
|
||||
1.5 倍只在 mag≥100 时参与(否则算出 7.5 半格);上限恒为偶数 → 中间刻度必为整数。
|
||||
- 刻度文字**不能放进 SVG**:viewBox 是 `preserveAspectRatio="none"` 横向拉伸的,
|
||||
文字会被拉扁。改左边一列 HTML 标签(`.tb-line-y` + 绝对定位 `.tb-line-tick`,
|
||||
top 用 Go 算好的 `TopPct`),横向虚线仍在 SVG 里(`non-scaling-stroke`)。
|
||||
- 列宽靠看不见的 `.tb-line-ruler`(最宽那条刻度)在流内占位撑开,不写死 em。
|
||||
- 面积路径改为沿基线 `h-padBottom` 闭合(原来落到 viewBox 底边,会漏进轴标签留白)。
|
||||
- 全零/空数据只出轴不出线(`HasData`),否则一条贴基线的直线看着像"数据都是 0"。
|
||||
- `internal/template/chart_test.go`:锁三条不变式 + 0..2000 穷举。
|
||||
- 视觉自检:900px 亮/暗 + 390px iframe(Windows headless 最小窗宽 ~485px,不能直接缩窗)。
|
||||
|
||||
## 取证:`GetHotQuestions` 的 `ORDER BY updated_at DESC`(未改代码)
|
||||
|
||||
- **先纠正前提:这个方法全仓零调用点**,只有接口声明 + 实现(`route/` 里 Questions 的
|
||||
15 个调用方法不含它;`git log -S` 自 baseline 起也没删过调用)。属死代码,线上零影响。
|
||||
- 连真库实测(临时测试,跑完已删):
|
||||
- 两条公开已答问题:old 9 天前回答 / new 1 小时前回答 → `GetHotQuestions` 得 `new old`;
|
||||
- `SetRead(old)` 后 → `old new`(名次被翻);`SetPinned(old)` 同样;
|
||||
- 换 `ORDER BY COALESCE(answered_at, updated_at) DESC` → 稳定 `new old`;
|
||||
- old 的 `answered_at` / `updated_at` 相差 216h。
|
||||
- 机制:`dbutil.Model.UpdatedAt` 是 GORM 约定字段,任何 `Update/Updates/Save` 都会刷新。
|
||||
所以污染源不止「标记已读」:`SetPinned` / `SetPublic` / `SetPrivate` / `UpdateCensor` 全是。
|
||||
- **同类残留(仍在线上生效)**:`QuestionStats` 的 `LastAnswerAt = MAX(updated_at)`,
|
||||
被老的 `/api/v1/site-stats` 用着(首页已不 fetch,但公开接口还在)。实测被推到当下,
|
||||
比真实最后回答晚 1 小时。新 `/stats` 页已改用 `COALESCE(answered_at, updated_at)`。
|
||||
- 另:`analytics.go` 的「今日回答数」也用 `updated_at >= since`(注释已承认偏大),未改。
|
||||
|
||||
## 已改(`8380fc9`):删死代码 + 修 LastAnswerAt
|
||||
|
||||
- 删 `GetHotQuestions`(接口声明 + 实现)。零调用点、唯一实现者 `*questions`、无 fake/mock。
|
||||
- `QuestionStats.last_a` 改 `MAX(CASE WHEN answer <> '' THEN COALESCE(answered_at, updated_at))`。
|
||||
先写测试跑红(偏差 216h)再改绿,回退路径另有一条测试。
|
||||
- 影响面只有 `/api/v1/site-stats`(`last_answer_at` + `text`),字段名/结构不变,值变准。
|
||||
- 性能:5 万行实测 7.9ms vs 8.0ms,3 次重复差 −0.5~1.8ms(噪声带内,约 30ns/行);
|
||||
执行计划仍 Seq Scan + Aggregate,且该接口有 30 分钟缓存。
|
||||
- 仍未改:`analytics.go:202` 今日回答数用 `updated_at >= since`(置顶会算进"今日回答"),
|
||||
改它会直接改变 /stats 首页数字,留待用户决定。
|
||||
|
||||
## 已改(`219adef`):今日回答数也换 answered_at
|
||||
|
||||
- `TodayCounts` 今日回答:`answer <> '' AND updated_at >= ?` → `answered_at >= ?`。
|
||||
实测:三天前回答的旧问题依次 SetPinned/SetPrivate/SetPublic → today answers 1→2(改前)。
|
||||
- 口径 = 「今天**首次**被回答的提问数」,与文案「今日回答 / Answered today」一致;
|
||||
改写回答不重置 answered_at,所以改写不算今日回答(有意)。
|
||||
- 性能:`answered_at` 无索引 → Seq Scan;但 `updated_at` 本来也没索引,同样是 Seq Scan。
|
||||
20 万行实测 36.7ms vs 38.0ms(3.5%,噪声级)。**没加索引**,注释里留了
|
||||
「真慢了再加 idx_questions_answered_at」。
|
||||
- ⚠️ 顺手发现:`analytics.go` 注释原写「只扫当天数据(走 created_at 索引)」,
|
||||
但「今日回答」那半句一直不是索引扫(updated_at 无索引)。已改正注释。
|
||||
- 测试:`TestTodayAnswersIgnoreNonAnswerWrites`(改前红 1 vs 2,改后绿)。
|
||||
|
||||
## 已改:/stats 脚注去掉「数据基准日」、「计算于」→「快照时间」
|
||||
|
||||
- 用户反馈「计算于」表达怪:它其实只是**累计数字与图表**那份快照的生成时间,
|
||||
而今日两项在缓存命中时会被 `withToday` 换成更新的读数 —— 放在页脚最底部读起来像「整页时间」。
|
||||
- 「数据基准日」与它 100% 重复:`DataDate` 就是 `UpdatedAt` 的日期部分(同一个 `now`),
|
||||
删掉 `stats.foot.dataDate` 词条 + `Overview.DataDate` 字段(模板/预览测试同步改)。
|
||||
- 页脚从 4 行变 3 行:快照时间 / 最后一条提问 / 最后一条回答;「今日两项实时」由下面
|
||||
的 `stats.foot.cacheNote` 说明,不塞进标签里。
|
||||
- 词条:快照时间 / 快照時間 / Snapshot: / 集計時刻(`stats.foot.updated` 改名改值)。
|
||||
- i18n 有词条数量对等测试,删键必须四个语言一起删。
|
||||
@@ -0,0 +1,126 @@
|
||||
# 2026-10-25
|
||||
|
||||
## /stats 文案清理:把「写给运营者看」的话删掉
|
||||
|
||||
用户指出「数据速览」下面那句「下面几句是从上面的数字直接读出来的,没有推测」怪 ——
|
||||
那是对他自己说的(作者视角的免责),不是对访客说的。顺着扫了页面全部 40 余条文案。
|
||||
|
||||
改动:
|
||||
|
||||
- **删 `stats.insight.note`**(四语言 + 模板那行 `<p class="tb-card-note">`)。
|
||||
卡片留「数据速览」+ 列表即可;`stats.insight.empty` 保留。
|
||||
删掉说明行后标题与列表之间只剩 4px,给 `.tb-insights` 补 `margin: 8px 0 0`。
|
||||
- **`stats.foot.cacheNote` 去掉实现细节**:「当天重算一次的快照(当天第一次访问时算,
|
||||
服务重启也会重算)」→「累计数字与图表每天更新一次」。访客不需要知道缓存是懒重算、
|
||||
也不需要知道重启会触发重算;准确的新鲜度由「快照时间」那行给出。
|
||||
- 顺手修了 `templates/stats.html` 里一条过期注释(还写着「四条时间」「基准日」,
|
||||
上一轮已改成三行「快照时间」)。
|
||||
|
||||
保留未动(判断过,不属于这个问题):
|
||||
|
||||
- `stats.foot.privacy` 与顶部 `stats.lead` 的后半句近乎同义(隐私声明重复一次)——
|
||||
页脚隐私声明是惯例,重复不算噪音,故保留。
|
||||
- `stats.today.tag` 的「(实时)」在 60s TTL 下仍成立,不改。
|
||||
|
||||
## 约定(已写进 topics/stats.md)
|
||||
|
||||
页面文案一律以**访客**为第二人称对象:不写实现细节(缓存、重启、索引),
|
||||
不写作者视角的免责(「没有推测」「这里只有」「本文」这类把读者当审稿人的话)。
|
||||
数据来源/隐私/新鲜度这些确实要对访客交代的事,放 `stats.lead` 与页脚注。
|
||||
|
||||
## 环比句改成「点明月份 + 条数」
|
||||
|
||||
用户问「上月提问量环比 -75.0%」是不是「上月同期比本月同期」。**不是**——实际比的是
|
||||
两个**完整自然月**(9 月 vs 8 月)。但这个问题本身就是证据:句子里「上月」和「环比」
|
||||
两个相对词叠着,读者得先做两次翻译,很容易读成「同期对比」。
|
||||
|
||||
- 新句:`9 月收到 5,620 条提问,比 8 月的 4,890 条多了 14.9%。`
|
||||
EN:`September had 5,620 questions; August had 4,890, up 14.9%.`
|
||||
- 口径没变(仍是上月 vs 上上月,本月没过完不参与),只换表达。
|
||||
- `page.go` 新增 `monthText`:CJK 一律「9 月」,英文用 `time.Month().String()`
|
||||
("month 9" 不是英语),不带年份——相比的两个月相邻。
|
||||
- 三个词条 `momUp/momDown/momFlat` 取代 `mom`:方向词没法塞进同一个模板,
|
||||
四种语言语序不同。参数顺序统一(本月份名/条数、对比月名/条数、|pct|)。
|
||||
- `|pct| < 0.05` 走持平,避免「多了 0.0%」。
|
||||
- 测试:`TestInsightMoMNamesBothMonths`(上升/下降/持平三分支,断言两个月份名 +
|
||||
两个条数都在句子里)、`TestMonthText`。
|
||||
- 顺手修预览测试:`GrowthMoM` 原来写死 12.3,与 `monthly` 造的数据对不上,
|
||||
而新句会同时印出两个月的条数 → 截图会显示「5620 比 4890 多了 12.3%」这种假证据。
|
||||
改成从 `monthly[10]/monthly[9]` 算。
|
||||
|
||||
## 安全评估(AI 挖洞背景下的一次全量自检)
|
||||
|
||||
报告:`outputs/security-audit-2026-10-25.md`,脚本在 `outputs/sec-audit-2026-10-25/`。
|
||||
结论:无 getshell / SQLi / 未授权读数据;但 2 个 P1 直击「匿名」承诺。
|
||||
|
||||
### P1-1 匿名提问可植入远程图片(去匿名化)
|
||||
- `markdown.go` 的 `safeURL` 只校验协议,`<img src=https://任意域>` 能原样输出。
|
||||
- 唯一挡住它的是审核「广告_引流类」维度,而该维度**只在 strict 档生效**
|
||||
(`censor/policy.go`:standard 只有 Abuse/Contraband,lax 为空)。
|
||||
- 实测:账号切 standard → `<img src="https://tb871870.example.org/standard.png">` 直接入库并渲染;
|
||||
切回 strict 才被拒。→ 修法必须在 `markdown.go` 层限制 img src 为站内/白名单,不能依赖档位。
|
||||
- 同时确认 8 种混淆(实体编码、`java\tscript:`、协议相对 `//`、反斜杠)都被审核识别。
|
||||
|
||||
### P1-2 访问密码可爆破 + 提问密码校验早于验证码
|
||||
- `UpdatePagePassword`/`UpdateAskPassword` 只有 `maxlen:30`,**无 minlen**,可设 1 位。
|
||||
- `page.go` 的 `New()` 第一件事就比对 AskPassword,验证码要到后面才校验 →
|
||||
实测「错误提问密码 + `deadbeef:999999`」返回「提问密码错误」而不是验证码错误 →
|
||||
**猜密码完全不消耗验证码**。限额 30/min(ask)、10/min(page)。
|
||||
|
||||
### 其余P2/P3
|
||||
- 登录开放重定向:`to=/\evil.example` → `Location: /\evil.example`,浏览器按 WHATWG
|
||||
把 `\` 当 `/` → `https://evil.example/`。`path.Clean` 不处理反斜杠。
|
||||
- 注册接口枚举邮箱(返回「已注册」),与 forgot-password 的防枚举口径不一致。
|
||||
- 无 CSP;`X-XSS-Protection: 1` 过时;`/user` 无 `Cache-Control: no-store`。
|
||||
- 限流/自动封禁是进程内存态(多副本叠加、重启清零)。
|
||||
- 匿名投票身份 = `vote_fp` cookie,清 cookie 即换身份,仅 200/min/IP 兜底。
|
||||
- 登录 `to` 兜底是死代码:`path.Clean("/"+to)` 恒非空 → 登录后永远落 `/` 而非箱主页。
|
||||
- JSON 响应无 `Content-Type`(实测 text/plain);429 无 `Retry-After`。
|
||||
|
||||
### 无法从外网确认的一项
|
||||
`trust_proxy` 真实值。三种伪造头(XFF / X-Real-IP / EO-Client-IP)各 22 次都不被采信
|
||||
(好的方向:无法伪造绕限流),但这既可能是 `trust_proxy=false`(→ 按 CF 边缘 IP 聚合,
|
||||
限流与封禁会误伤整片区域),也可能是 true 且在 CF 侧删了 EO-Client-IP。需登服务器看 app.ini。
|
||||
|
||||
### 已验证没事(别重复怀疑)
|
||||
CSRF 绑 uid(无 token POST → 400);18 条 XSS 载荷全灭;跨箱题目/投票越权全 302;
|
||||
页面密码对主页/整箱分享图/单题分享图/API 四个出口都生效;pixel/service/ShareCard 无 SSRF;
|
||||
`exec cwebp` 不接用户输入;SMTP 默认校验证书;`RegenerateID` + `pwd_ver`;TLS1.0/1.1 拒绝;
|
||||
无 CORS 头;测试站蜜罐已开(`/.git/HEAD`、`/.env` 返回假后台)。
|
||||
|
||||
### 测试卫生
|
||||
- 测试站账号 `tbunread1022181904`,脚本已把严格度恢复 strict、清空 page/ask 密码、
|
||||
删除本轮 15 条测试提问(115–129)。下次复用前先确认这三项是干净的。
|
||||
- 隔离 venv 里装了 `requests`(`~/.workbuddy/binaries/python/envs/default`),
|
||||
比 urllib 顺手;`/api/captcha/generate` 限流 20/min,批量提交脚本必须带 429 重试。
|
||||
|
||||
## 安全评估(续):trust_proxy=true 引出的 P1-3
|
||||
|
||||
用户确认两套环境 `trust_proxy = true`。据此重做了 IP 采信那一块,发现比原来的推断严重:
|
||||
|
||||
### P1-3 双 CDN 下权威头优先次序反了
|
||||
- `internal/shared/ip.go` 的 `ResolveClientIP` **无条件**先读 `CF-Connecting-IP`,再读 `EO-Client-IP`。
|
||||
- 前提「权威头只能由 CDN 写入」只对 Cloudflare 成立:
|
||||
- CF:强制覆盖 `CF-Connecting-IP`,客户端伪造直接 **403**(生产/测试站实测一致)。
|
||||
- **EdgeOne:不剥离客户端传入的 `CF-Connecting-IP`**,`share.shiroko.one` 实测带该头返回 200
|
||||
(响应头 `Server: edgeone makers` / `EO-LOG-UUID`)。
|
||||
- 代码优先读 CF 那个头 → 走 EO 线路时客户端 IP 完全可控。后果:限流/IP 黑名单全失效,
|
||||
且 `autoban.Record` 可被用来**栽赃任意 IP**(填别人的 IP 连打 10 次敏感路径 → 对方被封)。
|
||||
- **今天打不通**:`box.shiroko.one` 境内递归(AliDNS DoH)只解析到 `cloudflare.182682.xyz` → 104.18.x/172.64.x,
|
||||
文档里写的境内 `eo.dnse4.com` 线路已不在;`box.tama.guru` 301 跳新域名也走 CF。差一条 DNS 记录。
|
||||
- 修法:权威头也套 `isTrustedProxy(remote)`(回环可用 cloudflared 本机场景),fail-closed;
|
||||
EO 侧删 `CF-Connecting-IP`;日志同时记 `remote_addr` 与解析后 IP。
|
||||
|
||||
### 源站定位(外网已尽力,未找到)
|
||||
- `box.shiroko.one` CNAME `cloudflare.182682.xyz`(第三方 CF SaaS 优选,zone 不在我们手里,
|
||||
其 Transform/WAF 规则无法自查)。
|
||||
- certspotter 枚举 `*.shiroko.one` 11 个、`*.337845818.best` 19 个子域,逐个解析全部落
|
||||
Cloudflare / 腾讯 EdgeOne / Vercel(216.198.79.x、64.29.17.x),**无一条直连记录**。
|
||||
- `status.shiroko.one`、`share.shiroko.one` → 43.174.246.43/247.43,用 box 的 SNI 访问回 418 Teapot
|
||||
→ 是 EO 边缘不是源站。
|
||||
- 结论:源站隐藏目前成功,但「找不到」≠「不可达」,仍需服务器侧 `security-selfcheck.sh local` 确认
|
||||
`listen = 127.0.0.1:5877`。
|
||||
|
||||
### 顺带修正
|
||||
上一版报告曾把「三种伪造头都不生效」推断为 `trust_proxy` 可能为 false —— 那是错的解读:
|
||||
无论 true/false,限流键都落在我自己的真实 IP 上,外部不可分辨。已在报告第四节改正。
|
||||
@@ -0,0 +1,67 @@
|
||||
# 2026-10-26
|
||||
|
||||
## 修复 P1-3:CDN 权威客户端头未校验来源
|
||||
|
||||
用户确认:生产/测试 `trust_proxy = true`,回源是 **cloudflared Tunnel 回本机**(对端 127.0.0.1);
|
||||
境内 **EdgeOne 已取消**(实测期国内速度不理想),将来可能重新启用。
|
||||
|
||||
### 改动
|
||||
- `internal/shared/ip.go`
|
||||
- 新增 `headerSourceTrusted(remote)`:对端是回环 或 命中 `trusted_proxies` 才允许采信权威头。
|
||||
- `ResolveClientIP` 把 `CF-Connecting-IP` / `EO-Client-IP` 读取整体收进该判断;公网对端忽略。
|
||||
- 公网对端带权威头 → `sync.Once` 一条 WARN(`remote_addr` + `header_ip`),覆盖「伪造」与
|
||||
「CDN 直连回源忘登记网段」两种情形。
|
||||
- XFF / X-Real-IP 信任范围未变(仍只认 `trusted_proxies`,回环不隐含信任)。
|
||||
- `internal/shared/ip_test.go`:原 4 个用例按新契约调整;新增
|
||||
`RejectsForgedHeaderFromPublicPeer`(4 种伪造组合)`TrustedProxyHonorsCDNHeader`
|
||||
`IPv6LoopbackHeader` `LoopbackDoesNotTrustXFF`;加 `setTrustedProxies` 助手(还原包级状态)。
|
||||
- `conf/app.sample.ini`(CRLF,用脚本按行块改,未破坏行尾)+ `internal/conf/static.go` 注释同步。
|
||||
|
||||
### 验证
|
||||
- 变异验证:把 `headerSourceTrusted(remote)` 临时改成 `true`(旧逻辑)→ 回归用例 3 个子项失败;
|
||||
还原后全绿。确认测试咬得住。
|
||||
- `go build -overlay dev/overlay.json ./...` 通过;排除 `internal/storage`(overlay 桩缺
|
||||
`fitsWithin`/`aspect` 等辅助函数,属既有问题)后全包测试通过。
|
||||
|
||||
### 环境坑(值得记)
|
||||
- 本机 `go` 是 1.25.5,`go.mod` 要 1.27.3;`GOSUMDB=off` 会让工具链校验失败(
|
||||
"verifying module: checksum database disabled by GOSUMDB=off"),`GOTOOLCHAIN=local` 又降级到 1.25.5。
|
||||
解法:直接调缓存里的工具链
|
||||
`~/go/pkg/mod/golang.org/[email protected]/bin/go.exe`,配 `GOTOOLCHAIN=local`。
|
||||
- Python 读 CRLF 文件要显式 `newline=''`,否则 universal newline 把 `\r\n` 归一成 `\n`,
|
||||
`split('\r\n')` 只会得到 1 行(踩过一次,误判文件被写坏)。
|
||||
|
||||
### 待办
|
||||
P1-1(img src 白名单)、P1-2(密码 minlen + 验证码顺序)、P2-3(`to` 开放重定向)均未动。
|
||||
|
||||
## 修复 P1-1 / P1-2
|
||||
|
||||
### P1-1 外链图片(`internal/template/markdown.go`)
|
||||
- 新增 `safeImageSrc()`:`<img src>` 只允许站内相对路径(无协议部分)或主机命中自有图片出口
|
||||
(`image_bucket_cdn_host` / `aliyun_bucket_cdn_host` / `<bucket>.<endpoint>` / `external_url` 主机)。
|
||||
- 含反斜杠一律拒(`\` 在浏览器等价 `/`);`//` 先于「以 / 开头=站内」判断;
|
||||
`url.Parse` + `Hostname()` 比对 → 后缀 / userinfo 把戏失效。
|
||||
- `img` 无合法 src 时**整段丢弃**(只删 src 会留 `<img alt>` 变成破图)。
|
||||
- `a[href]` 仍走 `safeURL`(外链需点击,产品上要允许)。
|
||||
- 测试:`TestSafeImageSrc`(12 条拒绝)+ `TestMarkdownToHTML_ImageSource`(端到端);
|
||||
变异验证:`safeImageSrc` 直接 return true → 7 条拒绝用例失败。
|
||||
|
||||
### P1-2 密码(5 个文件)
|
||||
- `minlen:6` + `form.PasswordMinLen`;handler 里 `rejectTooShortPassword()` 二次校验(rune 计数);
|
||||
前端 `minlength=6 maxlength=30` + 提示文案「6–30 位,留空则关闭」;i18n 新增
|
||||
`form.profile.passwordLen` / `question.errPasswordLocked`(4 语言)。
|
||||
- 校验顺序:抽 `askPasswordDenied()`,从 `New()` 首行挪到**两个分支验证码之后**(私聊 + 普通提问各一次)。
|
||||
没把验证码提到最前——会让「箱已关闭」「需登录」变成验证码错误。
|
||||
- 失败锁定:新增 `shared.AttemptLimiter`(无后台 goroutine,惰性清理 + 键数上限);
|
||||
`pagePasswordGuard(5, 10min, 10min)`,键 = 箱主ID + LimitKey(IP)。锁定期内不比对密码。
|
||||
|
||||
### 端到端验收脚本
|
||||
`outputs/sec-audit-2026-10-25/verify_p1_fixes.py`(`--only p1-1,p1-2a,p1-2b,p1-2c`)。
|
||||
**在未部署的旧版本上四项全 FAIL,已实测** → 证明检测有效;部署后应全 PASS。
|
||||
|
||||
踩到的两个判定坑(写脚本时记下来):
|
||||
- 提问成功页是 **200**(不是 302),不能用状态码判断提交成功。
|
||||
- 受保护的箱是**在 `/_/<domain>` 内联渲染密码表单(200)**,不是跳转 `/password`;
|
||||
判断「有没有密码闸门」只能看页面里有没有 `action="/_/<domain>/password"`。
|
||||
- 箱主页与 `/api/v1/user/<domain>/questions` 都**不暴露题目 id**(无后台),
|
||||
要定位刚建的提问只能扫 `/_/<domain>/<id>`(脚本里 `question_ids_with_marker`)。
|
||||
@@ -0,0 +1,92 @@
|
||||
# 2026-10-27
|
||||
|
||||
## 在 WSL Debian 上把 TamaBox 跑起来,做 P1 修复的端到端复验
|
||||
|
||||
用户新搭了 WSL2 Debian,给了 `data/data.txt`(测试环境的 PG / Redis / OSS 凭据),要求「用它部署测试一下」。
|
||||
|
||||
**环境**(详细踩坑见 `topics/build.md` 的「本地部署」小节):
|
||||
- `wsl.exe -d Debian -u root` 可免密进 root;apt 装了 `redis-server / python3 / python3-requests /
|
||||
gcc / libwebp-dev`(有 libwebp 后 `s3.go` 正常编译,不用 s3stub overlay)。
|
||||
- Go:`/opt/go` 装 1.27.3,但 `~/.bashrc` 里 `GOROOT=/usr/local/go`(1.26.3)→ 必须 `env -u GOROOT`。
|
||||
- PG:新建 role `tamabox`(CREATEDB)+ 库 `tamabox`、`nekobox`(schema `nekobox`)。
|
||||
- 服务:`/etc/systemd/system/tamabox-test.service`,源码在 `~/tb/src`,二进制 `~/tb/tamabox`,
|
||||
日志 `~/tb/server.log`,端口 5877。配置 `~/tb/src/conf/app.ini`(`production=true`、
|
||||
`external_url=http://127.0.0.1:5877`、`captcha_type=math`、`enable_text_censor=false`、`trust_proxy=true`)。
|
||||
|
||||
**验收结果(全 PASS)**:
|
||||
- P1-1 外链图片剥离 / 白名单图片保留 / 实体与反斜杠变体;
|
||||
- P1-2a 4 位密码被拒、6 位生效;P1-2b 验证码先于密码;P1-2c 6 次失败后正确密码仍被锁;
|
||||
- P1-3 A/B:回环对端 + `CF-Connecting-IP: 1.2.3.4` → 命中黑名单 302;Windows 侧(非回环)同样头 → 忽略并 WARN;
|
||||
- 回归:18 条 XSS 载荷无可执行/无外联、CSRF 无 token 400、越权全 302;
|
||||
- 单测:`internal/{template,shared,form,conf,db,route}` + `route/{question,vote}` 全绿。
|
||||
|
||||
**这次揪出的新问题(已修)**:P1-1 的白名单把配置串整串当主机名,而真实配置是
|
||||
`image_bucket_cdn_host = "r2.337845818.best/tamabox"`(host/path)→ 自家 R2 图片被一起剥掉。
|
||||
修法:`normalizeHostLike()` 归一化(补协议 → url.Parse → Hostname),endpoint 主机自身也入白名单
|
||||
(R2 path-style 链接是 `<endpoint>/<bucket>/…`);新增 `TestSafeImageSrc_RealConfiguredHosts`,
|
||||
变异验证(退回旧逻辑 → 4 条放行用例变红)。**教训:白名单类单测必须照抄生产配置的写法,不能自己编一个好写的形态。**
|
||||
|
||||
**P2-3 从推断升级为实测**:`/login?to=/\evil.example` 登录后 `Location: /\evil.example`,
|
||||
按 WHATWG 解析 = `http://evil.example/`;`\\evil.example` 同样外跳(`//`、`///` 已被规整)。
|
||||
同一载荷在正文渲染里是安全的(`<a href="/\example.org">` 被剥成 `<a>`)。修复未做,等用户指示。
|
||||
|
||||
**产物**:
|
||||
- 报告新增「六、本地部署实测」章节(`outputs/security-audit-2026-10-25.md`);
|
||||
- 脚本新增 `verify_p1_allow.py`(正向放行)、`local_seed.py`(本地注册审计账号),
|
||||
`audit.py` 改为 `TB_BASE/TB_EMAIL/TB_PWD/TB_DOMAIN` 可覆盖 + 验证码 429 退避。
|
||||
|
||||
**待办**:改动仍未 commit / push;P2-3、P2-4、P2-5、P2-6、P2-7 未动。
|
||||
|
||||
---
|
||||
|
||||
## 第二轮:修完 P2/P3 + 全局复查
|
||||
|
||||
用户:「把你发现的问题修了,然后全局检查一遍有无其他问题」。
|
||||
|
||||
**已修(P2-3~P2-8、P3-9~P3-15)**:都写进 `topics/sec.md` 的「第二轮修复」。要点:
|
||||
跳转白名单收敛到 `shared.SafeRedirectTarget`(`url.Parse` 会解 `%0d%0a`,必须逐 rune 查 `u.Path`);
|
||||
注册三条失败分支共用 `registerSucceeded`;CSP 三档 + umami 的 `mami.shiroko.one` 必须入 `script-src`;
|
||||
`no-store`;`securityHeaders` 前移;封禁落库 `ban_records`;常量时间比较;页码上限;429 统一出口。
|
||||
|
||||
**全局复查新发现 3 个**(详见 `topics/sec.md`):
|
||||
1. 🔴 `chat_handler.SendMessage` 的 POST 缺 `conv.OwnerID != pageUser.ID` —— 被拉黑者换 domain 即可继续骚扰
|
||||
+ 触发邮件 + 绕过屏蔽词/日限额。已修,3/3 PASS,并做变异验证(去掉校验后对应用例变红)。
|
||||
2. 🟡 页面密码 `next` 的反斜杠外跳(与 P2-3 同源)。
|
||||
3. 🟡 分享卡无字体时每请求烧数秒 CPU(公开端点)→ 失败结果缓存 1 分钟,实测 3.44s → 0.53s。
|
||||
|
||||
**验收**:`verify_p1_fixes.py` 4/4、`verify_round2.py` 19/19、`verify_page_next.py` 6/6、
|
||||
`verify_chat_owner.py` 3/3;单测全绿;全项目编译通过。测试数据已清(库里只剩 `tb-local@example.com`)。
|
||||
|
||||
**报告**:`outputs/security-audit-2026-10-25.md` 新增「七、第二轮修复与全局复查」。
|
||||
|
||||
**再待办**:仍未 commit / push(26 改 + 9 新);CSP 是否切 `enforce`;生产机补 glyf 轮廓字体。
|
||||
|
||||
**收尾复跑发现两个「假红」**(已写进 `topics/sec.md`):① 不给 `TB_BASE` 时脚本打的是远端
|
||||
box-dev-api,没部署修复所以 4/4 FAIL;② 页面密码入口限流 10 次/分钟 + p1-2c 的 10 分钟锁定
|
||||
会让 `verify_page_next.py` 后两项 429。顺手把该脚本判据从「Location 不含 evil」改成精确比对
|
||||
`/_/<domain>`、每条载荷开新会话、429 单独报错。四套最终:4/4、19/19、6/6、3/3。库已清回只剩 `tb-local`。
|
||||
|
||||
---
|
||||
|
||||
## 第三轮:用户「按最优解决定」→ CSP 切 enforce + 字体落地 + 提交
|
||||
|
||||
**CSP 默认改 enforce**(`cspMode()` 空值 = enforce;写错值仍退 report)。依据不是猜:
|
||||
写了 `outputs/sec-audit-2026-10-25/csp_sweep.py` + `csp_sweep.cjs`(Playwright + 本机 Chrome,
|
||||
监听 `securitypolicyviolation`),report 模式先测一遍、enforce 再测一遍,**18 页面 + 2 图片端点零违规**。
|
||||
巡检器自己做了变异自检(页面里主动 `fetch("http://csp-selftest.example/x")` → 确实报 connect-src),
|
||||
否则「0 违规」可能只是监听器没挂上。
|
||||
**补的漏项**:`browser.sentry-cdn.com` —— header.html 里 Sentry 在 `{{ if ne SentryDSN "" }}` 里,
|
||||
本地没配 DSN 所以巡检碰不到,线上一开就会静默失效;现在按 `conf.App.SentryDSN` 条件放行 + 单测双向断言。
|
||||
|
||||
**分享卡字体**:补 glyf 系统候选(wqy-zenhei / wqy-microhei / DroidSansFallback / uming)并压到
|
||||
Noto(CFF) 之前;`static/fonts` 同时按 CWD 和可执行文件目录找;`CheckShareCardFont()` 在 runWeb 里
|
||||
启动即探(命中 Info 带路径,缺失 WARN 给 apt 命令)。实测 `apt install fonts-wqy-zenhei` 后
|
||||
零配置即 `font=…wqy-zenhei.ttc`,`/share-card` 与单题分享卡从 500 → `200 image/jpeg`,
|
||||
中文/二维码正常(图存 `outputs/sec-audit-2026-10-25/sharecard-*.jpg`)。
|
||||
|
||||
**提交**:三个 commit(7802bb2 安全修复 / e807dea 字体 / 430c6ef 记忆)。**push 失败**:
|
||||
`git.shiroko.one` 的 https 存取凭据被拒(`Failed to authenticate user`),SSH 路(`id_ed25519_wsl`)
|
||||
也未被该仓库授权 —— 需要用户更新 token 后自己 push。
|
||||
|
||||
**全量单测**:带 `DB_TYPE=postgres DB_USER=tamabox DB_PASSWORD=… DB_HOST=127.0.0.1` 全绿;
|
||||
不带 DB 变量时一批 DB 门控用例会报 `Unknown database type: ""`(不是回归)。
|
||||
@@ -0,0 +1,381 @@
|
||||
# 2026-10-29
|
||||
|
||||
## 第二轮综合复查(非安全向)
|
||||
|
||||
用户要求「再确认还存在什么问题与风险点」+「从用户角度有什么值得改进和增加的功能」。
|
||||
前一轮是安全专项(P1/P2/P3 全修完),这轮换到**业务正确性 / 运维 / 前端体验 / 产品能力**四个面。
|
||||
|
||||
产出:`outputs/review-2026-10-29.md`(分级清单 + 复现方式 + 产品建议)。
|
||||
|
||||
### 实测复现的两条 P0
|
||||
|
||||
1. **私聊 50 条截断**:`GetByConversationID` 是 `Order("created_at ASC") + Limit(50)`,
|
||||
唯一调用方永远传 nil cursor;会话上限 200 → 第 51 条起**写库成功但永久不可见**。
|
||||
本地造 55 条实测:页面只渲染 #1–#50。这不是性能问题,是「数据在库里但用户看不到」。
|
||||
2. **访问日志泄露 token**:`flamego.Classic()` = `Use(Logger(), Recovery(), Static())`,
|
||||
Logger 记 `RequestURI`(含 query)。实测日志出现 `path=/_/tblocal/4?t=SECRETTOKEN123`。
|
||||
`?t=` 是私密题钥匙、`?code=` 是改密凭证。**tracing 中间件早就为同一风险避开了 RequestURI
|
||||
并写了注释,框架默认 Logger 漏了。**
|
||||
|
||||
### 代码确认的 P1
|
||||
|
||||
- **无优雅关闭**:全仓 `signal.Notify`/`Shutdown` 零命中,SIGTERM 直接杀进程 → 每次重启掐断在途 POST。
|
||||
- **提问入库后图片失败不回滚**(`page.go:660` → `:680`),叠加 DuplicateGuard 5 分钟 → 用户以为没发出、5 分钟内不能重提。
|
||||
- **Redis 两种故障**:session 在 Redis 上宕机即全站 500(flamego session load 直接 panic);
|
||||
`cache.Cacher` **没有** `if conf.Redis.Addr != ""`,空 Addr 被 go-redis 回退到 localhost:6379 → 没配 Redis 时找回密码静默坏掉。
|
||||
- **处置不覆盖投票**:提问链路 7 处 `ActiveLevel`,投票链路 0 处。L4 处置后 `/_/domain` 已 302 到 /restricted,
|
||||
但 `/vote/{token}` 照常可投、可发新投票、探索页照常曝光。
|
||||
|
||||
### 产品缺口(重要,别忘)
|
||||
|
||||
- **完全没有访问量数据**:`SiteAnalytics` 是全站聚合,无 per-user 维度,也没有 page view 表。
|
||||
箱主无法知道自己被看了多少次。
|
||||
- **提问者找不回自己的提问**:不填邮箱时唯一手段是成功页那条链接,关页即失联;全仓 `localStorage` 零命中。
|
||||
→ 建议前端用 localStorage 记 `{domain, id, token}`,箱主页显示「你有 N 条提问已被回答」。
|
||||
- `NotifyType` 只有 `email` / `none`,无 Telegram / Webhook。
|
||||
- 模板零 `application/ld+json`(SEO 富摘要缺失),无 RSS。
|
||||
- 图片原图直传(无前端压缩),配合 ReadTimeout 30s → 手机端 8MB 图必超时。
|
||||
|
||||
### 顺带确认的「不用动」
|
||||
|
||||
四语言字典 917 条完全对齐(`TestDictionaryComplete` PASS)、深色模式正文对比度 4.8–6.8:1 达标、
|
||||
gzip 实现质量高、后台移动端短标签方案做得细。
|
||||
|
||||
### 已修(同日,六项全部端到端验证)
|
||||
|
||||
| # | 改动 | 验证 |
|
||||
|---|---|---|
|
||||
| 1 | `GetByConversationID` 改 `id DESC` 取最新 + 内存反转,limit 取 `conf.Limits.ChatMaxMessages`;游标配 DESC | 55 条会话实测渲染 55 条(改前 50);单测 3 项 + 变异(改回 ASC 变红) |
|
||||
| 2 | 弃 `flamego.Classic()`,`flamego.New()` + 自写 `accessLogMiddleware`(只记 `URL.Path`;静态成功不记、4xx/5xx 记) | 日志 `msg=access path=/_/tblocal/4`,token 计数 0 |
|
||||
| 3 | `signal.NotifyContext` + `srv.Shutdown(15s)` + 关连接池 | 8 个并发分享图请求中 SIGTERM → 8/8 成功 |
|
||||
| 4 | 图片失败分支 `Questions.DeleteByID` 回滚 | 6MB 图 → 提问增量 0 且 `deleted_at` 非空;无图对照增量 1 |
|
||||
| 5 | `cache.Cacher` 加 `if conf.Redis.Addr != ""`(否则 MemoryIniter);启动 `PingRedis` fail-fast | 停 Redis 后启动立即失败并指向 Redis |
|
||||
| 6 | 投票 Page/Submit 加 L4、VotePublish 加 L2、探索页用 `ActiveLevels` 批量过滤 | L4 → `/vote` 302 到 /restricted;探索页该投票 0→2(撤销后);L2 下发布停 draft,撤销后 published |
|
||||
|
||||
新增 `vote.err.restrictedPublish` 四语言 key。回归:4/4、19/19、6/6、3/3,全量单测 + `go build ./...` 通过。
|
||||
|
||||
**踩到的两个坑**(值得记):
|
||||
- 消息加载的兜底写成 `limit < 50 → 50`,会把箱主显式配置的小上限抬大,测试直接测出这个语义错误。
|
||||
兜底只该处理**零值**(未初始化),不该替用户改配置。
|
||||
- Python 的 **bytes 字面量不支持 `\uXXXX`**(只有 str 支持),写字面量 `b'...\uff08'` 会原样写入
|
||||
`\uff08` 六个字符。CRLF 文件的字节级替换要用 `\xef\xbc\x88` 或 `.encode()`。
|
||||
|
||||
### 第二批(P2 + 高价值 P3,Docker 按用户要求跳过)
|
||||
|
||||
| # | 改动 | 验证 |
|
||||
|---|---|---|
|
||||
| 7 | ReadTimeout 30s→120s(8MB 图需 ≥68KB/s;ReadHeaderTimeout 仍 10s 防 slowloris) | 参数修正 |
|
||||
| 8 | 投票结果改 stale-while-revalidate:过期先返旧值 + 后台按投票去重重算;Submit 从「删缓存」改「标记过期」(新增 `cache.Expire` / `GetWithExpiry`) | 2 万条:冷启 0.211s → 过期首读 0.023s(9.2x) |
|
||||
| 9 | 三处幂等:`MarkDeletedByOwner` 条件更新+RowsAffected、`SetPinnedIfCurrent`、追问「上一条已回复」移进事务 | DB 测试 3 项全过 |
|
||||
| 10 | 私聊首条三写入合并为一个事务(`CreateWithFirstMessage`) | message_count 与实际消息数一致 |
|
||||
| 11 | `/healthz` + `/readyz`(DB ping 失败 503) | 停 PG 后 healthz 200 / readyz 503 |
|
||||
| 12 | 验证码失败改同请求渲染,不再丢正文 | 200 + 正文回填 + 不建提问 |
|
||||
| 13 | `tbBeginSubmit()` 抽出,reCAPTCHA 回调补防重复 | 静态验证(7 个模板受益) |
|
||||
| 14 | waline 按页加载、`loading="lazy"`、safe-area、三处硬编码接字典(+4 key×4 语言) | /login 无 waline;en 页面零中文残留 |
|
||||
| 15 | 5 处裸类型断言改带 ok | 编译 + 单测 |
|
||||
|
||||
新增 `verify_round3.py`(6 项)。第二批回归:6/6、4/4、19/19、6/6、3/3,全量单测 + build 通过。
|
||||
|
||||
**判据随实现更新**:`verify_p1_fixes.py` 的 p1-2b 原来用「是否 302」判断验证码先于密码,
|
||||
第 12 项改成 200 后判据失效 → 改为「200 且页面报『验证码错误』」。语义不变,判据跟着渲染方式走。
|
||||
|
||||
### 这次踩的第三个坑(重要,我自己造成的)
|
||||
|
||||
**Windows 上 `Path.write_text()` 默认把 `\n` 转成 `\r\n`**,用它改 LF 文件会让整个文件行尾翻车。
|
||||
- 症状:`gofmt -l` 突然把整文件标为未格式化。
|
||||
- 后果:`git diff --stat` 会显示整文件重写。
|
||||
- 正确写法:`path.write_text(s, encoding="utf-8", newline="\n")`,或用 bytes。
|
||||
- **排查手段**:`git diff --stat` 是判断行尾污染最快的信号 —— 真实改动应该只有几十行,
|
||||
整文件重写一定是行尾变了。恢复方式:`git show HEAD:<file>` 取其行尾,再统一替换回来。
|
||||
|
||||
另:本项目 `core.autocrlf=true`,仓库里存 LF、工作区 checkout 成 CRLF,
|
||||
所以「工作区看到 CRLF」不等于「仓库里是 CRLF」,判断基准要看 `git show`。
|
||||
|
||||
---
|
||||
|
||||
## 第三批(数据安全 + 产品缺口)
|
||||
|
||||
1. **删除投票 / 删除提问改成一个事务 + 真删(`Unscoped`)**。
|
||||
原来四步独立写入、前几步失败只 Warn → 中途失败留下「选项没了票还在」这类
|
||||
界面修不好的状态,重试时投票已不存在、只能报 not exist。
|
||||
更关键的是:所有模型都带 `dbutil.Model` 的软删除,原来全是软删 ——
|
||||
提交记录里的参与者文本、`voter_ip`、`voter_token`、提问里的 `FromIP`
|
||||
只是「界面上看不见」,库里永久留着。用户点删除的预期是删掉。
|
||||
2. **配额与内容同事务**:抽出 `incrementQuotaTx(tx, userID, feature, delta)`,
|
||||
提问 `Questions.Create`、私聊 `CreateWithFirstMessage`、投票提交
|
||||
`VoteSubmissions.Create`(新增 `QuotaUserID` 字段)都在自己的事务里 +1。
|
||||
原来 handler 里独立写入且失败只 Warn,`quota_count` 会永久偏低。
|
||||
3. **追问改回答不再重复发邮件**:`UpdateAnswerByID` 返回 `wasFirst`(事务内读旧值判定),
|
||||
handler 只在首次回答时发。原来改三遍措辞就发三封。
|
||||
4. `isQuestioner` 的 token 比较改 `subtle.ConstantTimeCompare`(与 Pager 中间件同口径);
|
||||
CSV 导出的**表头与选项列**补 `sanitizeCSVCell`(原来只有数据列走它)。
|
||||
5. 写路径功能开关改 **fail-closed**:新增 `db.FeatureStateOfStrict`,
|
||||
读配置失败时拒绝而不是回退默认「开启」。
|
||||
6. **真 404 + HTML 500**:新增 `internal/route/errorpage.go`,`f.NotFound` 不再 302 到首页,
|
||||
改 `writeErrorPage(ctx, 404, ...)`;`flamego.Recovery()` 换成 `recoveryMiddleware()`。
|
||||
7. **提问者找回自己的提问**:成功页写 localStorage,箱主页显示提示条,
|
||||
新增 `POST /api/v1/ask-status`(批量问「哪些已被回答」)。
|
||||
|
||||
### 这批踩到 / 学到的
|
||||
|
||||
- **flamego 的 `f.NotFound` 拿不到组级中间件**:`template.Templater` 与 `Contexter`
|
||||
(含 session、CSRF)都挂在 `f.Group("", ...)` 上,NotFound 不经过它们。
|
||||
所以在 NotFound 里注入 `template.Data` 会直接 panic:
|
||||
`unable to invoke the 8th handler: value not found for type template.Data`。
|
||||
错误页最终用**内联 HTML + i18n.T**(`pickErrorPageLang` 从 Accept-Language 猜语言),
|
||||
不依赖模板系统 —— 错误页本来是最后一道防线,不该依赖可能出错的链路。
|
||||
- **`gofmt` 会把注释里的列表规范化**:`// 1) ...` 会被改写成 `// 1. ...`。
|
||||
自己写的中文注释列表如果用了 `1) 2) 3)`,`gofmt -l` 会标为未格式化 —— 不是行尾问题。
|
||||
- **`Select(...)` 漏列 = 字段静默为空**:`ListByIDsAndUser` 一开始只选了
|
||||
id/token/answer/created_at,摘要永远是空串,接口 12 项里就这一项红。
|
||||
- **配额口径要按功能分别确认**:投票的定量收集是「累计收到的票数」(在提交成功时 +1),
|
||||
不是「创建了几个投票」。所以我一度在删除投票里加了 -1,那是错的 —— 删投票不代表票没发生过。
|
||||
- **CDP 验证纯前端功能的套路**(`verify_ask_history_ui.mjs`):
|
||||
`chrome --headless=new --remote-debugging-port=9222`(必须作为独立后台进程跑,
|
||||
跟在别的命令后面 `&` 会随 shell 退出被回收)→ node 用内置 `WebSocket` 连
|
||||
`/json/list` 里任一 target → `Runtime.evaluate` 注入 localStorage → 重新 navigate →
|
||||
断言 DOM → `Page.captureScreenshot`。注意 headless=new 的空标签页 `type` 常是 `other`,
|
||||
不能只找 `type === "page"`。
|
||||
- **控制台错误要区分来源**:页面里 R2 图片的 CORS 报错是既有问题,
|
||||
把它算进「本次改动失败」只会得到假阳性。判据要按 URL/关键字过滤。
|
||||
|
||||
### 待办(仍未做)
|
||||
|
||||
- AutoMigrate 无版本化(改列名会静默丢数据)
|
||||
- 无备份脚本(图片全在对象存储,只备份 DB 恢复后全是裂图)
|
||||
- 32 个 `<label>` 无 `for=`、语义标签对比度 1.9:1
|
||||
- 箱主数据面板(全站无 page view 数据)、Telegram/Webhook 通知、JSON-LD + RSS、图片前端压缩
|
||||
|
||||
## 箱主数据面板 + 图片前端压缩(本轮完成)
|
||||
|
||||
把「待办」里的两项落地,其余(AutoMigrate 版本化、备份脚本、label/对比度、Telegram/Webhook、JSON-LD)仍未做。
|
||||
|
||||
### 箱主数据面板(/user/insights)
|
||||
|
||||
- 新表 `box_views`(`internal/db/box_views.go`):一行 = 一访客×一天×一箱,唯一索引
|
||||
`(user_id, viewed_on, visitor)`,`views` 累加。**刻意不内嵌 `dbutil.Model`**——
|
||||
软删除 + 唯一索引会让被删行永久占槽(老坑)。
|
||||
- 访客标识 = `HMAC(日盐, IP)` 前 32 hex(`internal/insights/recorder.go`):同日可去重(UV 准)、
|
||||
跨日不可关联(隐私)。代价:近 7/30 天 UV = 各日 UV 之和,文案里已写明。
|
||||
- 异步记录器:buffered channel 512 + 2s flush,构造即启动 goroutine(避免 Close/Run 竞态),
|
||||
队列满即丢(统计尽力而为)。过滤:箱主本人、空 UA、爬虫 UA 子串、预取头(Sec-Purpose 等)。
|
||||
- 聚合层 `internal/db/box_analytics.go`:BoxTrend(提问/回答按日)、BoxHours(24h 分布)、
|
||||
BoxFunnel(已回答/已读未答/未读 + 回答率 + 24h 回复占比)。复用 analyticsDialectFor 的时区处理。
|
||||
- 展示层 `internal/insights/page.go`:30 天 PV/UV 双柱趋势(CSS 高度百分比)、时段分布、
|
||||
漏斗 + 回答率进度条、自动结论(`FormatInsights`)。**百分比整数值不带 .0**(formatPct)。
|
||||
- 打点:`route/question/page.go` 的 Pager 末尾(所有错误分支/密码页已 return 之后),
|
||||
`!ctx.IsLogged || ctx.User.ID != pageUser.ID` 才记。
|
||||
- 挂载:`/user/insights` 在 reqUserSignIn 组内;`pageScope` 加 `/user/insights → admin`。
|
||||
- 密钥复用 `server.xsrf_key`;`StartMaintenance` 每日清 400 天前的明细。
|
||||
- 测试:`internal/db/box_views_test.go`(4 项,含「同批同键先内存合并」——PG 的 ON CONFLICT
|
||||
不允许同条 INSERT 二次命中同一行)、`internal/insights/page_test.go`(pct/窗口切分/visitorID/爬虫过滤)。
|
||||
|
||||
### 图片前端压缩(templates/base/footer.html)
|
||||
|
||||
- 选图即压:canvas → WebP(长边 1600、质量 0.8,与服务端 GeneralEncodeSpec 对齐)。
|
||||
- 三条边界:GIF/SVG 跳过(canvas 会把 GIF 变静止首帧)、压完不更小退回原图(<85% 才采用)、
|
||||
透明 PNG 走 WebP(不支持 WebP 编码时退回 PNG 而非 JPEG,否则透明变黑)。
|
||||
- `createImageBitmap(..., {imageOrientation:'from-image'})` 摆正 EXIF,老浏览器回退不带选项。
|
||||
- submit capture 兜底:选图立刻提交时 `preventDefault` + `Promise.all(压缩)` + `form.submit()`,
|
||||
避免二次触发防重复。
|
||||
- 实测(CDP + 自写 WebSocket 客户端 `dev/_cdp.py`):4.6MB PNG → 11.7KB WebP(0.3%),
|
||||
小图原样保留、GIF 跳过,三断言全过。
|
||||
|
||||
### 踩坑(本轮新)
|
||||
|
||||
- **同步脚本把 WSL 的 `conf/app.ini` 删了**:Windows 侧用 `tar | wsl rsync --delete` 同步源码,
|
||||
目标机那份含 DB 密码/对象存储密钥的 app.ini 不在源目录里,被当多余文件删掉 → 服务重启起不来。
|
||||
修:`dev/sync-wsl.sh` 里 `rsync --exclude "conf/app.ini"`,模板放 `dev/wsl-app.ini`(已 gitignore)。
|
||||
- **配置丢了怎么找回**:进程内存 dump(`/proc/PID/mem` 扫描 `key = "value"` 模式)能捞出启动时
|
||||
加载的全部 ini 键值(salt/xsrf_key/上传密钥/DB DSN 都在),配回 `conf/app.ini` 即可。
|
||||
- **headless 里 canvas 编码大图超时是假象**:Windows Chrome 的 `--dump-dom` + `--virtual-time-budget`
|
||||
会把 3000×2000 噪声图压得解码超时,`#out` 永远停在 running。换 WSL 内 `apt install chromium`
|
||||
走 CDP 轮询(`Runtime.evaluate` 非 await 反复读结果)才拿到真值。
|
||||
- **自写 CDP 的 WebSocket 客户端必须处理 continuation 分片**:CDP 大事件(console/DOM 属性)会
|
||||
把一帧拆成多段,按「一帧=一消息」解析会让缓冲区永久错位,evaluate 永远等不到响应。修了 `_cdp.py`。
|
||||
- **awaitPromise 长轮询会挂死自写 WS 客户端**:验证异步结果改用「navigate + 非 await 轮询读 DOM」。
|
||||
|
||||
|
||||
## 全盘复查(数据面板/图片压缩之后的第二轮)
|
||||
|
||||
用户要求「再做一次全盘复查」。两个并行子代理扫了本轮新增代码(数据面板、图片压缩、
|
||||
删除事务化、错误页、找回提问),交叉核对后确认的问题与处置:
|
||||
|
||||
### 已修(本轮)
|
||||
|
||||
1. **P1 提问真删不彻底**(`questions.go` DeleteByID 对主表 Unscoped,但追问/图片仍软删):
|
||||
- `follow_ups.go DeleteByQuestionID`、`upload_images.go DeleteByQuestionID` 改 Unscoped 真删。
|
||||
- 关联表 UploadImageQuestion 无 DeletedAt,无需 Unscoped(已注释)。
|
||||
- 新增 `internal/db/question_delete_hard_test.go`:用 Unscoped 计数断言删除后追问/图片/关联行
|
||||
物理上=0。**变异验证**:把追问改回软删,测试失败(expected 0 actual 1)。
|
||||
|
||||
2. **P1 BoxTrend 每日回答数按 created_at 分组**(应为 answered_at):`box_analytics.go` 加 answerDay
|
||||
表达式(PG 用 `to_char(COALESCE(answered_at,updated_at) AT TIME ZONE ...)`,MySQL 用 CONVERT_TZ),
|
||||
WHERE/GROUP/SELECT 三者口径一致。新增 `internal/db/box_analytics_test.go`:构造「40 天前创建、
|
||||
昨天回答」的提问,断言计入昨天。**变异验证**:改回 d.day 后测试失败。
|
||||
|
||||
3. **P2 ask-status domain 存在性可枚举**:`ErrUserNotExists` 分支原返回空数组、正常分支返回带元素数组,
|
||||
凭空/非空可枚举 domain。改为箱主不存在时也回显 `ok:false`(与「存在但 token 错」完全同形)。
|
||||
|
||||
4. **P2 私聊 SendMessage 写路径用 FeatureStateOf**(fail-open 变体):改 FeatureStateOfStrict,读配置失败即拒。
|
||||
(此前靠「chat 默认关闭」巧合侥幸安全,现在按写路径约定统一。)
|
||||
|
||||
5. **P2 图片压缩 submit 兜底可重复提交**:capture 监听器里 tbBeginSubmit 从 Promise 回调提前到
|
||||
preventDefault 的同步栈,物理阻止压缩窗口内双击重复提交。
|
||||
|
||||
### 判定不修(子代理误报,或有意设计)
|
||||
|
||||
- **投票删除不回退配额**:子代理当 bug,但这是**有意为之**——投票配额口径是「累计收到的票数」,
|
||||
删投票不代表票没发生过(记忆里早记过:一度加 -1 是错的)。注释已写清。
|
||||
- **recomputeHeights 里 `d.UV > maxPV` 死代码**:UV≤PV 恒成立,该行永不触发,但当前「PV/UV 同刻度」
|
||||
恰好是设计意图,不算错。
|
||||
- **TopDays 死代码**:`BoxViewsStore.TopDays` 无调用方,best-day 只回看 30 天。属口径/注释自相矛盾,
|
||||
但不改数据安全,留待后续。
|
||||
|
||||
### 踩坑(本轮新)
|
||||
|
||||
- **gofmt -l 把「缺末尾换行」也标为未格式化**:Edit 工具写文件时可能丢末尾换行符,`gofmt -l`
|
||||
会报 `\ No newline at end of file`。与行尾问题不同,补一个 `\n` 即可。
|
||||
- **struct 字段对齐**:手写 struct 时字段没对齐(`Questions int64` 少一个空格),gofmt 会标。
|
||||
用 `dev/gofmt_keep_crlf.py` 统一修。
|
||||
- **CSRF token 登录后会轮换**:flamego/csrf 在登录成功后轮换 token(防固定),所以登录前 GET /login
|
||||
拿到的 token 在登录后失效,POST 接口测试要登录后再从带表单的页面重新取。这导致 ask-status 的
|
||||
真实 HTTP 验证没跑通(靠代码 diff 确认修复正确)。
|
||||
- **`git show HEAD:file` 输出写到 /tmp 后 Windows Python 读不到**:/tmp 是 Git Bash 的虚拟路径,
|
||||
Windows Python 看不到。要落盘验证行尾,写到仓库内(如 outputs/)再读。
|
||||
|
||||
## 三项待办落地:AutoMigrate 版本化 + 备份脚本 + Telegram/Webhook 通知
|
||||
|
||||
用户「先按1-2-3的顺序做」,期间两次纠正 R2 存储结构认知。
|
||||
|
||||
### 1. AutoMigrate 版本化迁移(internal/db/migrations.go)
|
||||
|
||||
- 新增 `schema_migrations` 表(version 主键 + name + applied_at),`runMigrations(db)` 按版本升序
|
||||
执行未应用的迁移,每个迁移在事务里「跑 up + 记版本」。
|
||||
- 把 db.Init 里散落的 5 个幂等补丁收编为版本迁移 v1~v5:
|
||||
backfillAnsweredAt / backfillFeatureControls / reconcileLegacyChatEnabled /
|
||||
normalizeChatQuota / normalizeHarassmentSetting。收编后**只跑一次**(原来每次启动都跑)。
|
||||
- AutoMigrate 仍负责「加表/加列/加索引」(声明式、幂等、无数据丢失),版本迁移负责
|
||||
AutoMigrate 做不了的破坏性变更(改列名/删列)与一次性回填。两者分工,互不替代。
|
||||
- 迁移失败阻断启动(半途而废比静默跳过更危险)。
|
||||
- 测试:migrations_test.go(幂等跑两遍不重复 / 失败阻断后续 / 清单严格升序)。
|
||||
- 实测:真实库首次启动建表 + v1~v5 落账本,二次重启账本仍 5 行(幂等)。
|
||||
|
||||
### 2. 备份脚本(internal/cmd/backup.go,TamaBox backup)
|
||||
|
||||
- `--output <dir>` 输出;`--db-only` / `--images-only` 分开关。
|
||||
- DB:调 pg_dump(PGPASSWORD 环境变量传密码避免 ps 泄漏),输出 db-<ts>.sql(标准恢复格式)。
|
||||
- 图片:从 R2 下载 tamabox 站全部图片 + 写 images-manifest.json(key→size→md5)。
|
||||
- 两个 CLI 入口(根 tamabox.go + cmd/tamabox.go)都注册了 Backup。
|
||||
- 实测:db-only 导出 46KB(21 表全含 schema_migrations);images-only 30 秒下 15 个文件、
|
||||
路径保持 tamabox/ 结构、大小与 List 一致。
|
||||
|
||||
### 3. Telegram/Webhook 通知(internal/notify/notify.go)
|
||||
|
||||
- 站级 Webhook([notify] webhook_url):新提问/新回答/新追问/新私聊/新投票回复统一 POST JSON。
|
||||
- 用户级 Telegram([notify] telegram_bot_token + users.telegram_chat_id 字段):新提问/新投票点对点推送。
|
||||
- 通知是「尽力而为」旁路:任一通道失败只记日志不抛错,不影响业务主流程;未配置则静默跳过。
|
||||
- 6 处业务触发点接入 notify:page.go(新提问+新私聊)、question.go(新回答)、
|
||||
follow_up.go(新追问+追问回答)、vote.go(新投票)。私聊消息保留仅邮件(已有节流,避免刷屏)。
|
||||
- UI:profile 加 telegram_chat_id 输入框 + 四语言字典(admin.notifyTelegram* 3 条)。
|
||||
- 测试:notify_test.go(webhook JSON 结构 / 未配置跳过)。
|
||||
|
||||
### 踩坑(本轮新)
|
||||
|
||||
- **R2 存储结构(用户两次纠正,重要!)**:图片实际存在 Cloudflare R2,CDN URL 形如
|
||||
`https://r2.337845818.best/tamabox/2025/12/1767082504316655685.jpg`(bucket=tamabox,
|
||||
key=年月/时间戳.扩展名,**无 picture/ 前缀**)。但 R2 API 侧(ListObjectsV2)返回的 key
|
||||
**带 bucket 名作前缀**(`tamabox/2025/12/...jpg`)——这是 path-style bucket 的 R2 行为。
|
||||
- 关键:`storage.GetS3Client` 的 endpoint 配置(image_endpoint 带 `/tamabox` 后缀 +
|
||||
Bucket 参数 tamabox)对 GetObject/PutObject 恰好可用(aws-sdk 拼出 /tamabox/tamabox/<key>,
|
||||
R2 容错),但 **ListObjectsV2 会拼成 /tamabox/tamabox?list-type=2 → 404 NoSuchKey**。
|
||||
- 备份脚本的正确姿势:剥离 endpoint 的 `/tamabox` 后缀 + UsePathStyle=true + Bucket=tamabox,
|
||||
List prefix="tamabox/",GetObject 直接用 List 返回的完整 key(含 tamabox/ 前缀)。
|
||||
- 同一 R2 account(0c99...)下还有 blog/ 前缀的另一个站点内容(共享 account)。
|
||||
- **代码的 PictureKeyPrefix="picture/" 与线上不一致**:mainline 代码上传 key 用 `picture/` 前缀,
|
||||
但线上历史图片 key 是 `tamabox/` 前缀(即实际 key 无前缀、靠 bucket 隔离)。这是待澄清的
|
||||
「代码 vs 线上」不一致,本轮未动,需用户确认是否要统一。
|
||||
- **Windows Edit 又把 3 个 LF 文件转成 CRLF**(conf.go / users.go / form/user.go):
|
||||
老坑复发,用 Python `b.replace(b'\r\n', b'\n')` 统一转回 LF 解决。
|
||||
|
||||
## R2 存储结构最终定论(用户贴出生产 app.ini [upload] 段后)
|
||||
|
||||
生产 app.ini 关键段:
|
||||
```
|
||||
[upload]
|
||||
default_avatar = "https://r2.337845818.best/tamabox/tamabox_low.png"
|
||||
default_qrbackgroundimage = "https://r2.337845818.best/tamabox/picture/2024/01/01.jpg"
|
||||
image_endpoint = "https://<acct>.r2.cloudflarestorage.com/tamabox" # 注意带 /tamabox 后缀
|
||||
image_bucket = "tamabox"
|
||||
image_bucket_cdn_host = "r2.337845818.best/tamabox"
|
||||
```
|
||||
|
||||
实测铁证(GetObject 精确验证):
|
||||
- 生产配置(endpoint 带 /tamabox + Bucket=tamabox + Key)下,`key=2025/12/1767082504316655685.jpg`
|
||||
返回 OK(187274),而 `key=picture/2024/01/01.jpg` 返回 404。
|
||||
- **结论:R2 bucket tamabox 的真实 key = `YYYY/MM/<17位雪花ID>.<原扩展名>`,无 picture/ 前缀、
|
||||
无 tamabox/ 前缀。**
|
||||
- 代码 `PictureKeyPrefix="picture/"` + `randstr.Hex(15)` 生成的 key 在 R2 里**根本不存在**。
|
||||
- `default_qrbackgroundimage` 里的 `picture/2024/01/01.jpg` 是**手动上传的站级静态资源**(孤立的),
|
||||
不是代码生成的,且该 key 实测 404(可能已失效)。
|
||||
|
||||
**重大结论:生产部署的存储层代码 ≠ mainline 仓库的存储层。**
|
||||
mainline 的 `picture/YYYY/MM/DD/<15hex>`(无扩展名)是一套**从未上线、与生产脱节**的实现;
|
||||
生产真实用的是 `YYYY/MM/<雪花ID>.<原扩展名>`(无前缀)。差异太大不可能是小改动,
|
||||
是存储层被整体替换过但改动没合回 mainline。
|
||||
|
||||
对备份脚本的影响:
|
||||
- 备份脚本用「无后缀 endpoint + pathStyle + Bucket=tamabox + prefix=tamabox/」List,
|
||||
返回的 key 带 tamabox/ 前缀(pathStyle 的 R2 行为),用同一 key GetObject 下载是自洽的、
|
||||
能下全所有图片(含 picture/ 之外的真实 key)。
|
||||
- 但落盘路径带 tamabox/ 前缀,与「真实 key 无前缀」在语义上不一致——恢复回传时需注意。
|
||||
- 待用户决策:是否把 mainline 的 PictureKeyPrefix 和 key 生成逻辑改回生产真实格式
|
||||
(`YYYY/MM/<雪花ID>.<ext>`),还是维持现状。这是存储层正确性问题,未动。
|
||||
|
||||
## 存储层 key 口径对齐生产(已落地)
|
||||
|
||||
用户确认「生产就是按 mainline 在 10-25 前最后一次修改部署的,有调整 git 查得到」,并拍板
|
||||
「先备份当前源码,再按生产口径统一代码」。
|
||||
|
||||
### 根因最终定论(纠正之前「雪花ID」的说法)
|
||||
|
||||
- 生产 URL 里 `1767082504316655685` 不是雪花 ID,而是 **UnixNano 纳秒时间戳**(19 位):
|
||||
`datetime.fromtimestamp(n/1e9)` = 2025-12-30 16:15:04,与路径 `2025/12` 完全吻合。
|
||||
- **生产口径 key = `YYYY/MM/<UnixNano>.<原扩展名>`**(无前缀、无 day 层)。
|
||||
- 铁证:`route/question/page.go:uploadImage`(提问配图入口)**早就是这个格式**:
|
||||
`uploadKey = fmt.Sprintf("%d/%d/%d%s", now.Year(), now.Month(), now.UnixNano(), fileExt)`。
|
||||
脱节的只有 `internal/storage` 包里三个函数(UploadAvatarToS3 / UploadBackgroundToS3 /
|
||||
UploadPictureToS3 及 UploadPictureToOSS),它们还在用老的 `picture/ 前缀 + YYYY/MM/DD + 15hex + 无扩展名`。
|
||||
- git 历史是**浅快照**:`PictureKeyPrefix` 只在 `e3e59e7 "chore: baseline snapshot"`(2026-09-10,
|
||||
却是「最早提交」)出现一次,之前之后都没改过 —— 完整历史在快照之外,10-25 前的改动追溯不到。
|
||||
|
||||
### 改动(internal/storage/)
|
||||
|
||||
1. `picture.go`:`PictureKeyPrefix` 改 `""`;新增三个辅助函数:
|
||||
- `uploadKey(ext)` → `YYYY/MM/<UnixNano><ext>`(唯一 key 生成点)
|
||||
- `extFromContentType(contentType)` → MIME→扩展名映射(保证 key 扩展名与 ContentType 严格一致,
|
||||
避免「.jpg 实为 webp」错配)
|
||||
- `originalExt(filename)` → 取原扩展名并小写(OSS 原图直传通道用)
|
||||
2. `s3.go`:key 生成改 `uploadKey(extFromContentType(contentType))`;删除 day 变量与 randstr 依赖。
|
||||
注意转换成功用 `.webp`(contentType=image/webp),回退原图用原格式扩展名(contentType=image/jpeg→.jpg)。
|
||||
3. `oss.go`:`UploadPictureToOSS` 的 `_ *multipart.FileHeader` 改 `fileHeader`(取原扩展名),
|
||||
原样上传不转 webp,key 用 `uploadKey(originalExt(...))`。
|
||||
4. 新增 `picture_test.go`:锁定 key 格式正则 `^\d{4}/\d{2}/\d{16,19}\.jpg$` + ext 映射 + originalExt。
|
||||
|
||||
### 验证
|
||||
|
||||
- gofmt 通过;`go build ./...` 通过;storage 全量测试 + 新增测试 PASS。
|
||||
- 行尾:三个文件都是 LF(工作区 LF),Edit 未破坏(HEAD 对比行数变化正常,非整文件重写)。
|
||||
|
||||
### 踩坑(本轮新)
|
||||
|
||||
- **go 版本假象**:`go version` 报 1.27.3(GOROOT 指向 toolchain 目录),但 `GOTOOLCHAIN=local`
|
||||
时暴露真实工具链是 **1.25.5**;go.mod 要求 1.27.3。正确编译姿势:直接调已下载的 toolchain 二进制
|
||||
`$HOME/go/pkg/mod/golang.org/[email protected]/bin/go.exe`,
|
||||
或去掉 GOSUMDB=off 让 toolchain 校验通过。用 `GOTOOLCHAIN=local` + 系统 go 会报
|
||||
`go.mod requires go >= 1.27.3 (running go 1.25.5)`。
|
||||
- **备份目录别放 .go 文件**:把 storage 源码备份到 `outputs/backup-*/` 后 `go build ./...`
|
||||
会把备份当 Go 包编译(undefined: announcementConfig 等)。备份的 .go 文件要改 `.go.bak` 后缀,
|
||||
或放到仓库外。
|
||||
- **fmt 的 `%s` 对 int64**:`fmt.Sprintf` 用 `%s` 拼 int64 能编译但 `go vet`(测试时自动跑)报
|
||||
`%s has arg of wrong type int64`,且语义模糊。纳秒时间戳必须用 `%d`。
|
||||
@@ -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),必须用绝对路径。
|
||||
@@ -0,0 +1,74 @@
|
||||
# 主题生成器 `dev/theme-builder/`(长版明细)
|
||||
|
||||
## 主题生成器 `dev/theme-builder/`
|
||||
|
||||
- `serve.py` 起静态服务(8777),iframe 加载 `outputs/preview-*.html`。**file:// 下父页读不到 iframe DOM**,必须走 HTTP。
|
||||
8777 常由用户自己起着,改完直接刷新。
|
||||
- 单文件 `index.html`(约 2900 行)。图片处理**非破坏**:`orig` 存原始字节 + `fx` 存参数,每次从 orig 重算;
|
||||
参数没变则跳过重编码。画质(`S.export.quality/maxSide`)同理 —— 改完 `rebakeAll()` 从 orig 重来,不叠加压缩。
|
||||
- **尺寸单位** 4 档:`fit`(按视口高,人物)/ `fitw`(按视口宽,横幅)/ `px` / `vh`。
|
||||
`layerSize()` 出 CSS、`unitGeom()` 出预览框,**两处必须共用同一函数**,否则「框没超、图已裁」。
|
||||
⚠️ 下拉的 value 用 `l.unit`,不能用 `unitOf(l)`(后者把 fit 归一成 vh → 选了 fit 却显示 vh)。
|
||||
- 溢出检测 `layerMetrics(l, a, VW, VH)`:算 left/top/overL/R/T/B,编辑器里显示「显示 131×420,完整可见」或
|
||||
「⚠ 左右各 88px 越界」,并给「按当前视口修好」按钮(切 px + 算刚好放下的尺寸)。导出前 `overflowReport()` 再扫一遍。
|
||||
- 视口预设**带高度**(1512×900 / 1280×800 / 1024×768 / 820×1180 / 390×844):裁切几乎全由高度决定,
|
||||
只改宽度模拟不出来。`fitStage()` 按预设高渲染 iframe。
|
||||
- 窄屏:全局 `hideDeco`(急停)> 每层 `mob`(inherit/scale/hide)> 尺寸单位(fit/fitw 自己就缩)。
|
||||
⚠️ **`hideDeco` 会把 @media 里 10 槽全写 none** —— 桌面完全正常、手机上素材全没了,
|
||||
这是「素材在窄屏消失」的头号来源(test01 就是这么中招的)。UI 已在勾选时给橙色警告、
|
||||
图层行标「窄屏隐藏」、加层/导出再提醒;`?narrow=hide` 可一键复现。
|
||||
⚠️ `@media` 里**每个独立配置的 scope 都要写一条** `[data-page-scope="x"]`:页面覆盖块特异性 (0,2,1),
|
||||
只写 `html[data-theme-style]`(0,1,1) 的话独立配置页在手机上完全不生效。
|
||||
⚠️ 竖版图只替换**本来有背景图**的页面(`cfg.bg` 非空),否则等于给纯色页塞了张图。
|
||||
- ⚠️ **派生文字色必须走对比度下限,不能按固定比例混**:`paletteVars` 里
|
||||
`text-secondary = mixToContrast(text, backdrops, 5.5)`、`text-muted = 4.5`(WCAG AA)。
|
||||
旧的 `mix(text,bg,0.55)` 在浅底只有 2.9:1 —— 每导一次主题就把 base.css 的 AA 修复覆盖回去。
|
||||
- 半透明卡片(`--tb-surface: rgba(255,255,255,0.86)`)必须 `flatten()` 压到底色再算,否则按纯白算偏低;
|
||||
- 参考背景取「页面底色 + 压平后的卡片」两者更不利的那个;
|
||||
- 手填正文本身不达标时**原样返回**(宁可不够淡,不能产出看不见的字)。
|
||||
- 配色面板有读数(正文/次要/淡文字/主色),<4.5 变橙;默认 primary 已压到 `#3d6fac`(4.63)。
|
||||
- 还要导出 **`--tb-on-primary`**(白字 `#ffffff` / 深字 `#0b1016` 里挑对比度更高的):
|
||||
深色模式主色偏亮,白字只有 2.2~3.1:1。主色卡中间调时两种字都不够 → 面板橙色提示 +
|
||||
`?dump=palette` 的 `warnings`,**不判失败**(是主色要调,不是工具挑错)。
|
||||
- 自检钩子:`?dump=css|zip|validate|install|fx|fxpipe|fxkey|layers|fit|quality|palette`,`?demo=1&fresh=1&rows=N&scope=&loadurl=`
|
||||
(`?demo=1` 已含 fit 层 + 竖版背景,`@media` 才会生成)。用法:
|
||||
`chrome --headless=old --virtual-time-budget=12000 --dump-dom "http://127.0.0.1:8777/dev/theme-builder/index.html?demo=1&fresh=1&dump=fit"`
|
||||
抓 `<pre id="dump">`(正则要带属性:`<pre id="dump"[^>]*>`)。
|
||||
- ⚠️ **canvas `toDataURL('image/webp', 1)` 不能用**:Skia 在 q=1 切无损,输出反常(噪声图 q=1 只 52KB,
|
||||
q=0.95 是 440KB)。`canvasAsset` 夹到 0.95,滑块上限也是 0.95。
|
||||
- ⚠️ 平涂/线稿降 q 可能**变大**(有损引入噪点)。压体积先砍长边,再降 q。缩小走 `imageSmoothingQuality='high'`。
|
||||
- ⚠️ 面板只有 ~240px:`input[type=range]` 有固有宽度,必须 `flex:1 1 0;width:0;min-width:0`,
|
||||
且数值放进 label 里(第三个小 flex 项会被顶出可视区)。
|
||||
- `?dump=css` 的产物要过 Go 侧校验:临时存成 `static/css/themes/<非下划线名>.css` 再 `go test ./internal/theme`,
|
||||
跑完删掉。
|
||||
- 导出面板「素材前缀」`S.assetBase`:空 = `/static/images/themes/<slug>/`;填了 = CSS 写绝对 URL
|
||||
(R2/OSS)。**图片地址只能由 `urlOf()` 决定** —— `layerCSS` 曾复制过一份拼接,改一处会出现
|
||||
「背景走 CDN、装饰还指本地」。文档见 `docs/theme-assets-cdn.md`。
|
||||
- **素材托管(2026-10-21)**:面板两选一 `S.host.mode = local|s3`,字段
|
||||
endpoint/region/bucket/prefix/pathStyle/publicBase 进工程,**accessKey/secretKey 只进
|
||||
sessionStorage**(`SEC_SESSION_KEY`),绝不进草稿/工程 json/导出 zip(有断言)。
|
||||
`publicBaseOf()` 手填优先,空则按 endpoint 推导(path 风格 `<ep>/<桶>/<前缀>`)——
|
||||
对 R2 这个地址不可匿名读,测试的「匿名可读」只给 warn 不拦。
|
||||
- **上传走 `serve.py` 代理**,浏览器不直连对象存储:不用配 CORS,密钥不发给第三方。
|
||||
端点 `/api/s3/ping`(探测能力,`python -m http.server` 没有 → 按钮禁用)、
|
||||
`/api/s3/test`、`/api/s3/upload`。上传集合与导出共用 `usedAssetIds()`。
|
||||
- ⚠️ **`usedAssetIds()` 返回 Set**:`!ids.length` 恒真、`ids.map` 不存在 → 上传永远报
|
||||
「还没有素材」。用 `[...usedAssetIds()]` 展开。
|
||||
- ⚠️ **SigV4 手写,必须对拍**:`dev/theme-builder/check_sigv4.py` 调
|
||||
`dev/sigv4ref`(aws-sdk-go-v2 的 `signer.SignHTTP`)逐字节比。6/6 一致才可信。
|
||||
- 只用 ASCII 用例:裸 `SignHTTP` 默认**二次转义** URI,而 S3 client 内部把
|
||||
`DisableURIPathEscaping` 设成 true(单次编码)。我们跟随 S3 client / AWS 文档 RFC3986。
|
||||
- canonical headers 三件套 `host;x-amz-content-sha256;x-amz-date`,与 SDK 相同;
|
||||
CanonicalHeaders 自身以 `\n` 结尾,故其后与 SignedHeaders 之间有一个空行(规范如此)。
|
||||
- ⚠️ **HEAD 桶失败拿不到 `<Code>`**(HEAD 响应无 body)→ 只能按状态码归因
|
||||
(403→AccessDenied / 404→NoSuchBucket / 301|307→PermanentRedirect)。想拿真错误码要靠 PUT。
|
||||
- ⚠️ **Windows 上 8777 可能被上一个进程继续占着**(SO_REUSEADDR 让新进程 bind 成功但抢不到
|
||||
accept)。症状:`/api/s3/ping` 返回旧版 404,而代码明明改了。用
|
||||
`Get-NetTCPConnection -LocalPort 8777` 查 PID,或换端口验证。
|
||||
- 自测:`python dev/theme-builder/check_sigv4.py`(签名对拍)、
|
||||
`python dev/theme-builder/selftest_upload.py`(假 S3,16 项)、
|
||||
`python dev/theme-builder/mock_s3.py --port 9900`(手动点面板)。
|
||||
- 顶栏面板 `S.navbar={mode,alpha,blur}`:solid/glass/clear 三档,基色仍取「配色 → 顶栏」,
|
||||
`navbarValue()` 统一算;glass 额外写 `.uk-navbar-container:not(.uk-navbar-transparent){backdrop-filter}`。
|
||||
**clear 只给预览**,`validate()` 与 `TestNavbarNotFullyTransparent` 都拦。
|
||||
自检 `?dump=navbar`。
|
||||
@@ -0,0 +1,7 @@
|
||||
# 协作(长版明细)
|
||||
|
||||
## 协作
|
||||
|
||||
- 结论分级 P0/P1/P2;给建议前先根因取证 + 实测复现,别只静态推断。
|
||||
- 改代码前先出变更清单;重大问题显式指出。用户常授权自主推进。
|
||||
- 用户是运营者也是圈内人:处置必须可解释、可留痕、可申诉,不做自动级联封号。
|
||||
@@ -0,0 +1,15 @@
|
||||
# 行尾 / 模板 / embed(长版明细)
|
||||
|
||||
## 行尾 / 模板 / embed
|
||||
|
||||
- 行尾混合不可推断(`internal/db/*`、`mail.go`、`route/user/profile.go` 是 LF;`internal/route/route.go`、
|
||||
`users.go`、`i18n/dict_*.go`、`route/question/*.go`、`base.css`、`dev/theme-builder/*` 是 CRLF)。
|
||||
改前用 Python 数 `b'\r\n'`;`gofmt -l` 会把 CRLF 误报为未格式化 → 走 `dev/gofmt_keep_crlf.py`。
|
||||
Edit 工具会保留原文件行尾,可放心用;新增整份文件用 Write 时要自己补 CRLF。
|
||||
- 往 gofmt 对齐组中间插键会污染 diff,新键插组末;注释别用 `''`(gofmt 改成 `”`)。
|
||||
- ⚠️ **`templates/embed.go` 是显式清单**:新增**顶层**模板必须加进 `//go:embed`,否则运行时
|
||||
`html/template: "xxx" is undefined` → 500,编译期无提示。`static/fs.go` 同理。
|
||||
- ⚠️ **`//go:embed` 的 `*` 不匹配 `_` / `.` 开头的文件**:`static/css/themes/_scaffold.css` 与
|
||||
临时建的 `_gentest.css` **都不进 embed**,`TestDecoSlotCount` 扫不到它们(表现是"校验了但数字没变")。
|
||||
验证生成的主题 CSS 要临时改成非下划线文件名。
|
||||
- `templates/user/moderation-strictness.html` 只用 shell/python 改(Edit/Write 会注入 `data-page-node-id`)。
|
||||
@@ -0,0 +1,9 @@
|
||||
# GORM(长版明细)
|
||||
|
||||
## GORM
|
||||
|
||||
- 默认值必须带引号;`default:` 会把零值替换,空串有语义的字段别加。
|
||||
- ⚠️ 事务里复用同一 `tx` 会串条件,每句 `tx.WithContext(ctx).<链式>`。
|
||||
- ⚠️ 软删除 + 唯一索引 = 槽位永久占用:停用(可恢复)= 物理删内容 + 软删用户行,注册侧 `Unscoped` 给
|
||||
`ErrDeactivatedEmail/Domain`;封禁 = 物理删用户行,邮箱释放。级联删除一律 `Unscoped()`。
|
||||
- ⚠️ GORM 链式对象 Count/Find 后带状态,统计/预览/更新必须各自重建 query。
|
||||
@@ -0,0 +1,34 @@
|
||||
# 蜜罐(多家族伪装 / 取证 / 封禁)
|
||||
|
||||
## 结构
|
||||
|
||||
- `internal/security/honeypot/family.go` 是**唯一皮肤表**:9 个 Family
|
||||
(wordpress / phpmyadmin / tomcat / jenkins / grafana / gitlab / luci / joomla / generic)。
|
||||
每个 skin 自带 routes、title、brand、tagline、css、fields、hidden、failure、headers、cookies。
|
||||
- `FamilyOf(path)` 按 segments 前缀判定(`.` 也算边界,兼容 `/adminer.php`)。
|
||||
新增路径必须**同时**进 `honeypotPrefixes`,否则 `Match` 根本不接管。
|
||||
- `asset.go`:`/wp-admin/css/…`、`/wp-includes/js/…` 这类资源回占位内容,
|
||||
**不落库、不计数** —— 一次页面加载会打 5–10 个资源请求。
|
||||
- `page.go`:一次 `strings.Builder` 拼接,禁用 `"a" + b + "c"`(每个 + 一次分配);
|
||||
`fieldIDReplacer` 必须包级,别在循环里 `NewReplacer`。
|
||||
- `route/honeypot.go` 的 Serve 顺序:cleanPath → **资源短路** → 家族 → 诊断日志 → 回环告警 → 提凭据 → 封禁 → 响应 → 落库。
|
||||
|
||||
## 性能基线(谁改慢了谁负责)
|
||||
|
||||
- `RenderFamily` ≈ 3.7 µs / 2 allocs / 5.2 KB;`AssetFor` ≈ 55 ns / 0 alloc。
|
||||
- 单页体积上限 4 KB,由 `TestRenderIsCheap` 守。
|
||||
- 预览:`go run ./dev/preview-honeypot` → `outputs/honeypot-preview/index.html`(9 个 iframe)。
|
||||
改皮肤后务必跑一次并截图,上一版就是靠它发现 Tomcat 按钮没背景色。
|
||||
|
||||
## 坑
|
||||
|
||||
- **hidden CSRF token 不能算密码键**:PMA / GitLab 的表单带 `token`,而 map 遍历无序,
|
||||
一旦 token 进 `isPasswordKey`,真密码会被随机覆盖成 token 值。
|
||||
所以 `credentialKeys` 收 token(只用于判 `HadFields`),`isPasswordKey` 不收。
|
||||
- **Tomcat 家族 = 401 + `WWW-Authenticate`**,凭据从 `Authorization` 头取;
|
||||
给它一个 HTML 表单反而不像。
|
||||
- 页面里不能出现 `TamaBox` / `honeypot` 字样;canary 只放 HTML 注释,
|
||||
`<meta name="generator">` 必须伪装成真实产品(扫描器必查)。
|
||||
- WP 登录页底部不能挂 404 文案(真实页没有,挂上一眼就假)。
|
||||
- 新增诱饵前缀前先核对业务路由(`/user`、`/login`、`/api`、`/_/`…);
|
||||
`forbiddenPrefixes` 按 `/xxx` 段边界判断,所以 `/users/sign_in` 不会被 `/user` 连坐。
|
||||
@@ -0,0 +1,72 @@
|
||||
# 业务正确性 / 运维 / 前端体验(2026-10-29 第二轮复查)
|
||||
|
||||
安全向的结论在 `sec.md`。这里是非安全向的四类,完整清单见 `outputs/review-2026-10-29.md`。
|
||||
|
||||
> **状态**:下列「已实测确认的缺陷」「处置覆盖面」「Redis」三块已于同日修复并端到端验证。
|
||||
> 紧接着做了第二批(P2 + 高价值 P3,共 9 项):投票结果 stale-while-revalidate、
|
||||
> 三处幂等、私聊首条事务、`/healthz` `/readyz`、验证码失败保正文、`tbBeginSubmit`、
|
||||
> waline 按页加载 / lazy / safe-area / i18n、5 处裸断言。
|
||||
> 第三批(同日):删除投票/提问改事务 + 真删、配额与内容同事务、追问改回答不重复发信、
|
||||
> token 常数时间比较 + CSV 注入补齐、写路径 fail-closed、真 404/HTML 500、
|
||||
> 提问者找回自己的提问(localStorage + `/api/v1/ask-status`)。
|
||||
> 条目保留是为了记住**为什么**会踩、以及验证该怎么做。
|
||||
|
||||
## 已修:两个 P0(都是实测出来的)
|
||||
|
||||
- **私聊 50 条截断(P0,已修)**:`internal/db/message.go` 的 `GetByConversationID` 是
|
||||
`Order("created_at ASC") + Limit(50)`,而唯一调用方 `chat_handler.go:97` 永远传 `nil` cursor。
|
||||
会话上限是 200 → 第 51 条起写库成功、`message_count` 在涨、页面显示 51/200,但列表永远只有最早 50 条。
|
||||
实测造 55 条,页面只渲染 #1–#50。**取最新 N 条要 `Order("id DESC")` 再内存反转**;
|
||||
游标 `id <` 也必须配 DESC(配升序会反复返回同一页)。
|
||||
- **访问日志泄露 token(P0,已修)**:`flamego.Classic()` = `Use(Logger(), Recovery(), Static())`,
|
||||
`Logger()` 记的是 `ctx.Request().RequestURI`(含 query)。实测出现
|
||||
`path=/_/tblocal/4?t=SECRETTOKEN123`。`?t=` 是私密题钥匙,`?code=` 是改密凭证。
|
||||
**tracing 中间件早就为同一风险避开 RequestURI 并写了注释,框架默认 Logger 漏了。**
|
||||
改用 `flamego.New()` + 自写访问日志,只记 `URL.Path` 与白名单 query key。
|
||||
|
||||
## 处置(restrict)的覆盖面(投票已补齐)
|
||||
|
||||
- 提问链路 7 处 `ActiveLevel`(page L4 / question L3 / setPublic L2 / followUpAnswer L2 / shareCard 全级 / ListAPI L4 / moderation 锁定)。
|
||||
- **投票链路 0 处**:`vote.Page`、`vote.Submit`、`VotePublish` 都不查。L4 处置后 `/_/domain` 已 302 到
|
||||
`/restricted`,但 `/vote/{token}` 照常可投、可发新投票、探索页照常曝光。
|
||||
- **教训:新增任何「用户发起」的写入口,都要问一句「这里要不要查 ActiveLevel」。**
|
||||
|
||||
## 运维面
|
||||
|
||||
- **无优雅关闭**(已修):全仓 `signal.Notify` / `server.Shutdown` 零命中,SIGTERM 直接杀进程。
|
||||
`web.go` 里 `ListenAndServe` 一把梭。每次部署都掐断在途 POST。
|
||||
- **Redis 两种故障**(已修):session 走 Redis 时 load 失败会 `panic`(flamego/session),被 Recovery 兜成 **全站 500**;
|
||||
`cache.Cacher` 没写 `if conf.Redis.Addr != ""`,空 Addr 被 go-redis 回退 `localhost:6379` →
|
||||
「不配 Redis」的部署里找回密码静默坏掉、私信邮件节流失效。启动时该 `PING` 一次并 fail fast。
|
||||
- `ReadTimeout: 30s` 与 8MB 单图上限冲突(手机 4G 上行 1–2 Mbps → 8MB 必超时),正文也一起丢。
|
||||
- 无 `/healthz`:`route.Home` 不碰 DB,DB 挂了 `/` 仍 200。
|
||||
- `Dockerfile` 用 `golang:1.20` 而 `go.mod` 是 1.27.3 → **构建必失败**;`-X` 路径还是旧仓库名
|
||||
`github.com/NekoWheel/NekoBox`,`BuildCommit` 永远是 `dev`。
|
||||
- AutoMigrate 无版本化:GORM 只加列不删列,**改列名会静默留下旧列 + 新列全 NULL**,比迁移失败更难发现。
|
||||
- 备份只有 `docs/cli.md` 一句 `pg_dump`;用户图片全在对象存储,**只恢复 DB = 全站裂图**。
|
||||
|
||||
## 前端
|
||||
|
||||
- `templates/question/history-template.html` 有硬编码中文(查看回答 / 加载更多 / 无更多留言),en/ja 用户直接看到。
|
||||
`vote/page.html:140` 中英混排。**字典本身 917 条 × 4 语言完全对齐(有测试守着),问题只在模板层。**
|
||||
- 提问页验证码失败走 `SetErrorFlash + Redirect` → **整段提问内容丢弃**;其他分支都是
|
||||
`SetError + Success` 保留内容。最常见的失败反而最不该丢内容。
|
||||
- `form.submit()` 不触发 submit 事件 → 验证码流(默认 captcha_type)完全没有防重复点击。
|
||||
- `waline.js` 全站加载但只有 `home.html` / `item.html` 用;UIkit CSS 走第三方 CDN 且阻塞首屏。
|
||||
- 32 个 `<label>` 无 `for=`;语义标签白字对比度约 1.9:1;全仓零 `loading="lazy"`。
|
||||
- 移动端 `body{padding-bottom:56px}` 与 `env(safe-area-inset-bottom)` 冲突,iPhone 上遮住约 32px。
|
||||
|
||||
## 产品缺口(按 ROI)
|
||||
|
||||
1. **提问者找不回自己的提问**:不填邮箱时唯一手段是成功页那条链接,关页即失联。
|
||||
全仓 `localStorage` 零命中。最小解:前端记 `{domain, id, token}`,箱主页显示「你有 N 条提问已被回答」。
|
||||
2. **箱主没有自己的数据**:`SiteAnalytics` 是全站聚合(只有 `COUNT(DISTINCT user_id)`),
|
||||
**全站没有 page view 表**。箱主不知道自己被看了多少次、回答率多少。
|
||||
3. `NotifyType` 只有 `email` / `none`,无 Telegram / Webhook。
|
||||
4. 模板零 `application/ld+json`(SEO 富摘要),无 RSS。
|
||||
5. 图片原图直传,无前端压缩。
|
||||
|
||||
## 这一轮确认「不用动」的
|
||||
|
||||
四语言字典对齐、深色模式正文对比度 4.8–6.8:1、gzip 实现(204/304/Content-Length/Vary/Flusher 都处理对了)、
|
||||
后台移动端短标签方案。这几块质量高于平均,别为了统一风格去改。
|
||||
@@ -0,0 +1,61 @@
|
||||
# 审核 / 处置 / 举报(长版明细)
|
||||
|
||||
## 审核 / 处置 / 举报
|
||||
|
||||
- 线上通道 = 七牛(`ai.qiniuapi.com/v3/text/censor`,antispam);`[security] text_censor_source = qiniu|aliyun|auto`。
|
||||
七牛只硬拦涉政/暴恐/色情,`review` 放行 → 裁决在 `censor/policy.go` + `moderation.Check`。
|
||||
- **新增审核调用点必须走 `moderation.Check`**(直调 `censor.Text` 绕过档位)。不可用时 **fail-closed**。
|
||||
- ⚠️ 圈内黑话(代称+群体标签+避雷叙述)通用审核拦不住,别指望词库;靠处置 + 举报 + 人工判断。
|
||||
- 严格度 lax/standard/strict 存 `users.moderation_strictness`;**受处置用户强制 strict**:
|
||||
`moderation.EffectiveStrictness(ctx, userID, raw)`(7 个入口已改)。**查询失败回退用户设置**
|
||||
(否则 DB 抖动会让全站突然加严)。处置期内拒绝保存 + 设置页 disabled。
|
||||
- ⚠️ **同请求渲染只能用 `ctx.SetError` / `SetInternalError`,不能用 `*Flash`**:flash 写 session,下次请求才读 →
|
||||
提示静默丢失。`Set*Flash + Redirect` 才是正常模式。扫描法:找 flash 调用 → 向下第一个 `Success(`/`Redirect(`。
|
||||
- 处置四级:L1 停止分发/L2 收窄公开(新回答强制私密、禁 set-public)/L3 冻结发布(仍可收信)/
|
||||
L4 整页停用(`/restricted`)。到期靠 `expires_at`,**无 cron**;`restrict` 是**替换语义**;比较用 `RestrictionAtLeast`。
|
||||
- ⚠️ L2 强制私密必须在**审核通过之后**,且与写回答**同一条 UPDATE**(`AnswerByIDPrivate`):
|
||||
拆开会留下「公开+已作答」窗口,第二条失败还会变成「私密+无回答」。
|
||||
- ⚠️ **`ListAPI`(`/api/v1/user/{domain}/questions`)不经过 Pager 中间件**,L4 与页面密码必须自己校验。
|
||||
- ⚠️ **整箱分享卡 `ShareCard` 与单题分享卡都要拦**,且拦截必须在缓存查询之前(否则 30 分钟内仍给旧图)。
|
||||
- 举报 `POST /_/{domain}/{qid}/report`:免登录 + 验证码 + 限流;同 IP 同题唯一索引 →
|
||||
**幂等且提示与首次一致**(否则成了内容存在性探测器)。箱主只看「被举报 N 次」,**不给 IP**。
|
||||
- 闭环:删除/设私密/`hide-public` → `MarkAcceptedByQuestion`;待处理超 30 天视为过期。
|
||||
⚠️ `hide-public` 里必须**先快照目标 ID 再更新**(更新后带 `is_private=false` 的子查询恒为空集)。
|
||||
- 告警:24h≥8 或 7d≥20 → 邮件到 `[security] security_alert_email`(24h 冷却);单条恰好 3 人举报 → 提醒箱主。
|
||||
⚠️ 该邮箱为空时静默跳过、无日志;改配置必须重启。
|
||||
- `/rules` 是处置依据页(四语言);注册页必须勾 `agree_rules`(服务端同样校验),改 e2e 脚本要同步。
|
||||
- `restrict`/`unrestrict` 默认给箱主发通知(按 `users.language`,`mail/restriction-notice.html`),
|
||||
**只含级别名/到期/规则链接,不含 `--reason`**;`--no-notify` 跳过。`--days > 90` 要 `--force`。
|
||||
⚠️ **到期自动解除不发信**(读表比较时间)。
|
||||
|
||||
## 蜜罐(2026-10-21)
|
||||
|
||||
- 落点是**改写 `f.NotFound`**(不是新中间件):NotFound 只在路由表落空时触发 → 与业务路由零交集;
|
||||
位于全局 Use 链之后(body 已限 12 MiB);组级 CSRF 不作用于它。
|
||||
- flamego 短路机制:`context.run()` 在 `ResponseWriter().Written()` 为真时 return → 写完响应不调 `ctx.Next()` 即止。
|
||||
- 蜜罐命中的路径由蜜罐自己 `autoban.Record`,autoban 中间件须跳过(否则一次请求计两遍,阈值减半)。
|
||||
- `HoneypotHit` 不嵌 `dbutil.Model`(审计证据只追加,不软删)。
|
||||
- 假页只含 canary + 假信息;错误提示不区分「用户不存在」与「密码错误」(防用户名枚举)。
|
||||
- 密码 CLI 默认脱敏;告警邮件含明文凭据 → 收件人只能是运维本人。
|
||||
|
||||
### 蜜罐封禁与 enable_auto_ban 解耦(2026-10-21 线上实测修复)
|
||||
- ⚠️ **`Record()` 第一行 `if !b.enabled { return }` 会让 `enable_auto_ban=false` 连坐蜜罐** →
|
||||
蜜罐只落库不封禁(实测命中 70+ 次零封禁)。已修:新增 `RecordHoneypot()` 独立通道 + 独立阈值/时长。
|
||||
- `IsBanned()` 不得受 `enabled` 约束:`enabled` 只控制「是否主动创建封禁」,不控制「是否尊重已有封禁」。
|
||||
- `RecordHoneypot` 的键必须与 `Record` 一致(同样 `shared.LimitKey` 归一化),否则 `IsBanned` 判不出来。
|
||||
- 备份/配置/密钥泄露扫描(`/backup.zip`、`/db.sql`、`/id_rsa.pem`)文件名主体随机,**只有后缀稳定**
|
||||
→ 用 `path.Base()` 后缀匹配表,不能只靠子串(`"/.zip"` 匹配不到 `/backup.zip`)。
|
||||
|
||||
### 蜜罐表体积与自动清理(2026-10-21)
|
||||
- `honeypot_hits` 是**只追加**审计表,写入完全由外部扫描流量驱动(不可预期、不可节流)
|
||||
→ 必须有自动保留期清理,否则表体积只增不减。
|
||||
- 两道闸:时间(`honeypot_retain_days`)+ 数量(`honeypot_max_rows`)。数量闸不可省 ——
|
||||
攻击者可用海量源 IP 在保留期内把表撑爆。
|
||||
- ⚠️ PG 的 `DELETE` 只标记死元组,**表文件不会变小**;无 VACUUM 时表体积单调膨胀
|
||||
(实测 22→52 MB)。清理后必须 `VACUUM (ANALYZE)`,但注意 **VACUUM 不缩小文件、
|
||||
只让空间可复用**(止住增长即可,不必 `VACUUM FULL`)。
|
||||
- ⚠️ `VACUUM` 不能在事务块/函数体内执行 → GORM 走 `Exec()`(Raw 处理器,不包事务)可行。
|
||||
- ⚠️ 清理器的 `runOnce` 必须自己再挡一次 `retainDays<=0`,否则「保留 0 天」= 全删光。
|
||||
- 索引:GORM 去掉 struct tag 的 `index` **不会**删既有库上的旧索引 → 需显式 DropIndex。
|
||||
删索引前先用 EXPLAIN 确认零查询使用(`provider` 索引实测 10 万行 648 kB 且零使用)。
|
||||
- CLI `honeypot size` 报告行数/磁盘占用/时间跨度 —— 把「有没有臃肿」交给运维自查。
|
||||
@@ -0,0 +1,58 @@
|
||||
# 分享卡(share card)
|
||||
|
||||
两处入口都在 `route/question/share.go`:`ShareCard`(箱子卡,960×540)、`QuestionShareCard`(单题卡)。
|
||||
单题卡出图条件:**已回答 + 非私密 + 无站方处置**(`db.Restrictions.ActiveLevel` 非空即拒绝,fail-closed)。
|
||||
|
||||
## 文字排版
|
||||
|
||||
- 居中/限宽**必须**用 `measureTextWidth`(逐字 `HMetric().AdvanceWidth` + `Kern`,`scale = fontSize*64`),
|
||||
它与 `DrawString` 返回笔位逐位相等。绝不要用「中文 1em / 其余 em 系数」估算:URL 里的 `: / . _ -`
|
||||
只有 0.25~0.35em,整句会虚高 55%,居中直接偏 40px。
|
||||
- 链接展示走 `displayURL`(去 `https://`)→ `layoutLink`(单行 15→13px,放不下按 host/path 两行 13→11px,
|
||||
host 超宽才中间省略,字号下限 `LinkMinSize`)→ `ellipsizeMiddle`。限宽恒定 `QRCardW-40`。
|
||||
|
||||
## 二维码(`generateQR`)
|
||||
|
||||
- **图像只输出符号**:`qr.DisableBorder = true`,静默区由承载它的白色卡片提供。
|
||||
把 4 模块静默区画进图里 + 再居中留白,会让 165px 目标只剩 111px(3px/模块)——这是曾经的回归。
|
||||
- `perModule = size / modules` 向下取整,符号 = `modules*perModule`;**调用方必须用 `qr.Bounds().Dx()`
|
||||
排版与居中**,不能假设等于传入的 size。
|
||||
- 单题卡 `QQRSize = 190`(37 模块 → 185px = 5px,卡片 236×300 居中于 679,120);
|
||||
箱子卡 `QRCodeSize = 190`(37 模块 → 185px = 5px,卡片 270×335)。
|
||||
- 单题卡的二维码卡片绘制在 `drawQuestionQRCard`(handler 只是调用),测试可直接出图。
|
||||
- 卡内垂直间距在 4px 栅格上:`24 / 17 / 20 / 1 / 20 / 16`,总高按链接行数算,整块垂直居中。
|
||||
|
||||
## 模块像素:唯一的硬指标
|
||||
|
||||
| 模块 | 960 | 720 | 480 | 320 | 240 |
|
||||
|---|---|---|---|---|---|
|
||||
| 4px(148px,37 模块) | o | o | **x** | x | x |
|
||||
| 5px(185px,37 模块) | o | o | o | o | x |
|
||||
|
||||
(go-qrcode 纠错 Q、真实 55 字符链接、含内嵌头像;用 gozxing 解码,它比手机严格,可当保守下限。)
|
||||
→ **目标 5px/模块**;`TestGenerateQRModuleSizeAtLeast5` 锁住三种常见链接长度,改小 QRCodeSize 立刻 FAIL。
|
||||
|
||||
改大模块的路线排序(成本从低到高):
|
||||
1. 同画布放大二维码(已做,0 CPU)——`generateQR` 只 0.8ms,与尺寸几乎无关。
|
||||
2. 降纠错 Q→M:模块 37→33 只省 11%,容错 25%→15%,不划算,别单独用。
|
||||
3. 换 PNG:只保住原图,缩到 480 一样解不出,体积还是 JPEG 的 5 倍。
|
||||
4. 2x 渲染:+60~100ms(本地基准:JPEG 编码 12→48ms、背景 Lanczos 25→41ms、画布 3→13ms)。
|
||||
只在「原图本身也要更精细(背景/文字抗锯齿)」时值得。
|
||||
|
||||
## 缓存命中率才是 2x 之外更值得看的地方
|
||||
|
||||
进程内 LRU 只有 16 条 + TTL 1h,站点箱子多时冷渲染频繁;把生成结果落盘 / 上 R2
|
||||
能让「贵」只付一次。这是比 2x 更实在的优化方向。
|
||||
|
||||
## 验证手法
|
||||
|
||||
- 守门测试 `route/question/share_layout_test.go`:测量=笔位、链接限宽、按着墨边界判居中 ±3px、
|
||||
二维码 `width == modules×perModule` 且吃掉目标尺寸 ≥80%、头像不在边缘裁切。改完都做变异验证。
|
||||
- `TEST_PREVIEW=1 go test ./route/question/ -run TestShareCardLinkPreview` 出 270×315 的卡区域 PNG。
|
||||
- 图片实测:PIL 数着墨 bbox(居中偏差 / 二维码宽度 / 各段间距);解码用
|
||||
`github.com/makiuchi-d/gozxing` 起个临时 module 跑(`_ "image/jpeg"` 和 `_ "image/png"` 都要 import)。
|
||||
- 单题卡要真实出图:`.workbuddy/local/qcard_probe.py`(发提问 → 登录 → 回答 → 拉图 → 删除,不入库)。
|
||||
|
||||
## 缓存
|
||||
|
||||
进程内 TTL 1 小时 + 响应头 `Cache-Control: public, max-age=300`。改完要复验:重启(清进程内缓存)+ URL 加随机参数。
|
||||
@@ -0,0 +1,134 @@
|
||||
# 全站数据页(/stats)
|
||||
|
||||
2026-10 新增。完整数据在 `/stats`(公开、只读、纯服务端渲染)。
|
||||
|
||||
入口只有一处:**页脚「社区规则 | 数据 | 找站长」中间那格**
|
||||
(`templates/base/footer.html`,词条 `site.footer.stats`)。
|
||||
首页那张「站点统计」卡与后来的入口卡都已下线(用户明确要求首页不再放),
|
||||
`route.Home` 也不再调 `stats.Get`;`/api/v1/site-stats` 接口保留未动。
|
||||
新增入口不要再往首页加——那会让两处数字需要同步维护。
|
||||
|
||||
## 分层
|
||||
|
||||
```
|
||||
db.Analytics.SiteAnalytics(ctx, now) // 纯 SQL 聚合,无缓存
|
||||
→ stats.OverviewData(ctx) // 按自然日缓存(每天只算一次)
|
||||
→ stats.NewPage(lang, ov) // i18n 标签 + 百分比 + 文字洞察
|
||||
→ route.Stats → templates/stats.html
|
||||
```
|
||||
|
||||
- `internal/db/analytics.go`:`SiteAnalyticsStore`。用 `SUM(CASE WHEN)` 而不是
|
||||
`COUNT(*) FILTER`(MySQL 没有 FILTER)。方言差异(`to_char`/`DATE_FORMAT`、
|
||||
`EXTRACT(DOW)`/`DAYOFWEEK-1`、`EPOCH`/`TIMESTAMPDIFF`)收敛在 `analyticsDialectFor`。
|
||||
- `internal/stats/overview.go`:缓存 key 是**自然日字符串**,不是 TTL —— 换日即失效,
|
||||
当天重算一次后全站共用。重算串行化(`ovRefresh`),失败时返回上一次成功的旧值。
|
||||
- `internal/stats/page.go`:`Page` 嵌 `*Overview`。桶标签在 Go 侧拼好,别在模板里
|
||||
`printf "stats.len.%s"` —— 缺词条会把 key 印到页面上。
|
||||
- `internal/cmd/web.go`:启动后 3s 异步预热,把当天首访的等待挪出请求路径。
|
||||
|
||||
## 口径(改之前先看)
|
||||
|
||||
- 已回答 = `answer <> ''`;已读 = `is_read OR answer <> ''`。
|
||||
- 匿名 = `asker_user_id = 0`;登录 = 总数 − 匿名。
|
||||
- 回答耗时用 `answered_at - created_at`(**不是 updated_at**)。GORM 的 `Update`
|
||||
会自动刷新 updated_at,已读/置顶/公开切换都走 Update —— 实测一条 10 天前的提问
|
||||
被 `SetRead` 后 updated_at 直接推到当下。故新增 `questions.answered_at`(可空),
|
||||
只在 answer 由空变非空时写一次,改写回答不重置;历史行为 NULL 时
|
||||
`COALESCE(answered_at, updated_at)` 回退,且首次加列时用 updated_at 冻结一次
|
||||
(`backfillAnsweredAt`,靠 `Migrator().HasColumn` 判断是否刚加列)。
|
||||
- 环比是「上月 vs 上上月」,不是「本月 vs 上月」:本月没过完,每月 1 号会得出 -95%。
|
||||
**句子必须点明月份与条数**:「9 月收到 5,620 条提问,比 8 月的 4,890 条多了 14.9%。」
|
||||
不要写「上月提问量环比 -75.0%」——两个相对词(上月 / 环比)叠在一起,用户实测读成
|
||||
「上月同期 vs 本月同期」。也不要用带符号的裸百分比(「环比 -75%」)当结论。
|
||||
三套句式 `stats.insight.momUp/momDown/momFlat`(方向词进不了同一个模板,语法各语言不通);
|
||||
参数顺序统一是 本月份名/条数、对比月份名/条数、绝对值百分比,`|pct|<0.05` 走持平。
|
||||
- 累计与图表是**当天重算一次**的快照(当天首访时算,重启也会重算;不是「0 点快照」),
|
||||
「今日提问/回答」两项实时(60 秒 TTL,见下)。
|
||||
- 页脚时间标签因此叫「快照时间」而不是「计算于」:它只覆盖累计与图表,
|
||||
今日两项会被 `withToday` 换掉。原先另有「数据基准日」一行,与它是 100% 重复
|
||||
(`Overview.DataDate` 就是 `UpdatedAt` 的日期部分),已删。
|
||||
- 今日两项的 60s TTL **不要**为了省查询改成 30 分钟:每分钟最多 2 条 COUNT,
|
||||
20 万行实测 38ms/条 → 最坏 109 秒 DB 时间/天(0.13%),换不到什么;
|
||||
而 30 分钟会让「(实时)」变成假话、提问者自己看不见刚发的那条。
|
||||
- 日/周/时分组必须在 SQL 里 `AT TIME ZONE INTERVAL '+08:00'` 显式换算,
|
||||
不能依赖数据库会话时区 —— 见「时区」一节。
|
||||
|
||||
## 两个已踩过的坑(2026-10-24 部署前自查发现)
|
||||
|
||||
**1. 「今日」两项曾被每日缓存冻住。** 原先 `SiteAnalytics` 里查了今日数,塞进
|
||||
`Overview`,而 `Overview` 整天复用 → 页面顶着「实时」的说明显示一整天不变的常量。
|
||||
现在拆出 `SiteAnalyticsStore.TodayCounts`,`stats` 层单独 60 秒缓存(`todayTTL`),
|
||||
跨日按日期字符串失效(`todayDate`)。缓存命中时返回的是**副本**(`withToday`),
|
||||
不能原地改 `ovCached` —— 那是数据竞争。
|
||||
|
||||
**2. 日期分组默认跟着 PG 会话时区走,通常是 UTC。** PG 官方镜像 `timezone=UTC`,
|
||||
而 Go 按容器本地时区(Dockerfile 设 Asia/Shanghai)切「今天」。实测同一条
|
||||
`2026-10-24 01:30+08` 的提问:会话 UTC 下 `to_char` 给 `2026-10-23`、`EXTRACT(HOUR)` 给 17。
|
||||
→ 日报表整体偏 8 小时(中国 0–8 点被算进前一天),热力图时段全错位,且与「今日」对不上。
|
||||
现在 `analyticsLocalize()` 统一包一层 `AT TIME ZONE INTERVAL '+08:00'`,偏移取自
|
||||
`time.Time.Zone()`(不用 IANA 名:Go 在未设 TZ 时给 "Local",PG 不认)。
|
||||
过滤条件仍是裸列,索引照用(EXPLAIN 确认走 Bitmap Index Scan)。
|
||||
|
||||
## 性能实测(20 万行 questions,本地 PG,冷缓存)
|
||||
|
||||
| 查询 | 耗时 | 扫描方式 |
|
||||
|---|---|---|
|
||||
| totals(全表条件聚合) | 337 ms | Seq Scan |
|
||||
| 字数分布(全表) | 313 ms | Parallel Seq Scan |
|
||||
| 热力图 180 天 | 100 ms | Bitmap Index Scan |
|
||||
| 日趋势 30 天 | 47 ms | Bitmap Index Scan |
|
||||
|
||||
合计约 0.8 秒;1C1G 云机按 2–3 倍算约 2 秒。靠启动后 3 秒预热挪出请求路径,
|
||||
可接受。**若 questions 到百万行**,三次全表扫描会到 6–10 秒,那时应把
|
||||
totals + 字数分布 + 回答耗时合并成一条 SQL(都是同一张表的无条件 CASE 聚合,
|
||||
合并后只扫一次)。现在不做:不知道真实表规模,每天只算一次,不值得为省 0.5 秒
|
||||
把 SQL 拆成大块。
|
||||
|
||||
## 图表实现(零前端库)
|
||||
|
||||
站点为消除一个 1.6KB 外部 defer 就把页脚计时器内联了,这里不再挂 60KB 图表库。
|
||||
|
||||
- 折线:`NewLineChart` 一个函数给出 `{Max, Points, Area, Ticks}`,
|
||||
SVG `viewBox` + `preserveAspectRatio="none"` + `vector-effect: non-scaling-stroke`
|
||||
(否则窄屏描边被压细)。
|
||||
- **坐标 / 面积 / 刻度必须同一次调用算出**:分开算会各按自己看到的峰值归一化,
|
||||
线和刻度就错开半格。
|
||||
- 纵轴上限 `niceMax(峰值)`(1/2/1.5/3/4/6/8×10^n),刻度 = 上限 / 一半 / 0;
|
||||
上限恒为偶数 → 中间刻度必是整数。1.5 倍只在 mag≥100 时参与(否则是 7.5 半格)。
|
||||
- 刻度文字放 SVG **外面**:viewBox 横向被拉伸,里面的文字会跟着变扁。
|
||||
做法是左列绝对定位的 HTML 标签(top 用 Go 算好的 `TopPct`)+ 看不见的占位元素撑列宽。
|
||||
- 面积沿基线(`h-padBottom`)闭合,别落到 viewBox 底边(那是轴标签的留白)。
|
||||
- 全零/空数据只出轴不出线,否则贴基线的直线看着像「数据都是 0」。
|
||||
- 柱状 / 分布条:`style="height:{{printf "%.1f" $pct}}%"`,CSS 直接用百分比。
|
||||
- 环形:`<circle pathLength="100" stroke-dasharray="{{pct}} 100">` + `rotate(-90)`,
|
||||
不用算周长。
|
||||
- 热力图:CSS grid 24 列 + `aspect-ratio:1`,档位用 `--tb-primary` 的 opacity 分 4 档
|
||||
(连续透明度在小方块上肉眼分不出来)。
|
||||
- 7×24 的 DOW 下标:PG `EXTRACT(DOW)` 与 Go `time.Weekday()` 都是周日=0;
|
||||
展示顺序(周一起)在 `weekdayOrder` 里转。
|
||||
|
||||
## 预览与验证
|
||||
|
||||
- `TEST_PREVIEW=1 go test ./templates -run TestGenPreview` 出
|
||||
`outputs/preview-stats-{zh-CN,en}-{light,dark}.html`。
|
||||
- `writePreview` 会把 `href="/static/` 换成本地相对路径 —— 不换的话预览页走 file://,
|
||||
`:root` 令牌全缺,`var(--tb-*)` 变无效值:图表全透明、卡片没边框(一度以为是渲染 bug)。
|
||||
- `internal/db/analytics_test.go` 连真库跑(`DB_TYPE=postgres ...`),验证软删除不计入、
|
||||
各分桶下标、DOW 对齐。对着 mock 断言等于没验证。
|
||||
- 窄屏别用 `chrome --window-size=390`:Windows headless 最小窗宽约 485,实测 innerWidth=485,
|
||||
截图再裁到 390 就成了假的"溢出"。用 390px 宽 iframe 包一层再截。
|
||||
|
||||
## 隐私红线
|
||||
|
||||
公开页只给聚合数字。不显示 IP、不显示单条内容、不做箱主排行(会招骚扰)。
|
||||
「收到过提问的箱子」这类计数可以,具体是谁不行。
|
||||
|
||||
## 文案(读者是访客,不是站长)
|
||||
|
||||
- 不写实现细节:缓存策略、懒重算、"服务重启也会重算"、索引、TTL —— 访客要的只是
|
||||
「多久更新一次」。准确的新鲜度交给页脚「快照时间」那行。
|
||||
- 不写作者视角的免责:「下面几句…没有推测」「本文」「这里只有」这类,读起来像在跟审稿人
|
||||
解释,而不是在给访客看。已删 `stats.insight.note`。
|
||||
- 确实要对访客交代的只有三件:数据来源/隐私(`stats.lead` + `stats.foot.privacy`)、
|
||||
新鲜度(`stats.foot.cacheNote`)、口径例外(`stats.trend.monthlyNote` 的"当月还没过完")。
|
||||
- 删说明行会改变间距:`.tb-insights` 自己补 `margin-top`,别指望 `tb-card-note` 的 16px。
|
||||
@@ -0,0 +1,41 @@
|
||||
# 前端样式(长版明细)
|
||||
|
||||
## 前端样式
|
||||
|
||||
- `static/css/base.css`(`?v=` 取 `conf.StaticVersion`,**改完必须 +1**)。令牌在 `:root`(亮)+
|
||||
`html[data-theme="dark"]`(暗)。新样式一律 `var(--tb-*)`,别写死色值,也别为深色另写分支。
|
||||
- 页面级样式内联在各自模板 `<style>`;尽量用类不用 `style=`(深色下内联写死色值会露白)。
|
||||
- 主题属性在 head 内联脚本里打(cookie `theme`),**不能挪回 body**,否则深色用户白闪。
|
||||
- **移动端(≤640px)语言切换与主题按钮在固定底栏** `#tb-mobile-utils`(`.tb-nav-utils` 被隐藏,
|
||||
body 留 `padding-bottom:56px`)。用户曾误以为"按钮不见了",先解释位置。
|
||||
⚠️ `/_/{domain}` 箱主页 `IsUserPage = true`(`route/question/page.go:133`),语言切换包在
|
||||
`{{ if not .IsUserPage }}` 里 → **箱主页没有语言切换入口,有意设计**(用户 2026-10-19 确认),别当 bug 修。
|
||||
- ⚠️ **UIkit 3.3.3 的 `.uk-flex::before/::after{display:none}`**:给带 `uk-flex` 的元素加伪元素必须写
|
||||
复合选择器(`.tb-cover.uk-flex::after`),否则静默失效。
|
||||
- 分类/题型标签一律 `.tb-chip`;彩色 `uk-label` 只留给语义状态(置顶/私密/被举报/已读、投票四态)。
|
||||
- **字号阶梯 5 档**:`--tb-fs-xs/md/lg/xl/hero`(12/14/16/24/28)。散落的 `.9/.95/.98rem` 一律映射到档位,
|
||||
别新增第 6 个值。⚠️ 已令牌化的是模板层;`base.css` 自身仍留 11 处硬编码(0.78/0.82/0.88/0.95/1.1),
|
||||
`templates/user/profile.html` 还留 16 处 —— 想彻底统一得先扫这两处。
|
||||
- 圆角只用 `--tb-radius(-sm/-xs)`;`50%` 与 `999px` 是语义(头像、胶囊),**不要**换成令牌。
|
||||
- ⚠️ `.uk-text-muted/.uk-text-meta` 必须带 `!important`:UIkit 3.3.3 自带 `color:#999!important`,
|
||||
普通声明压不过(现象:规则写了但全站还是 #999)。
|
||||
- **文字色下限 WCAG AA 4.5:1**(正文级)。`--tb-text-muted` = `#6b7280`(白底 4.83)。
|
||||
旧值 `#8a919c` 3.18、`#999` 2.85 —— 肉眼"淡一点"其实已经不合格。
|
||||
主色还没达标(`#1e87f0` 3.26 / `#4a7fc1` 3.69 / sakura `#e5678f` 2.80),要动得连主题一起改。
|
||||
- ⚠️ **改 base.css 的配色令牌必须同步检查 `static/css/themes/*.css`**:主题覆盖同名令牌,
|
||||
基座修了但主题页纹丝不动(sakura/shiroko 的 muted 就曾是 2.8:1,是这次才补到 4.6)。
|
||||
- 顶栏链接:亮色用 `--tb-text-secondary`、hover/当前页 `--tb-primary`;深色块原本 `!important` 定成
|
||||
`--tb-text`,会让同一顶栏在深浅两模式权重跳一档 → 已加 dark secondary 覆盖。
|
||||
- **主色要达到 AA 4.5:1**(它同时是 14px 链接/按钮文字色)。现值:base `#0e6fd0`、shiroko `#3b6dac`、
|
||||
sakura `#ba3e66`(都是同色相压亮度)。**改主色必须三处一起**:`base.css :root` + `themes/default.css` + 该主题。
|
||||
- ⚠️ **`--tb-on-primary`**(亮 `#ffffff` / 暗 `#0b1016`)是「主色作背景时的文字色」。
|
||||
深色模式的强调色偏亮(`#5c8dff`),白字只有 2.2~3.1:1 —— 用一个令牌同时伺候「链接色要深」和
|
||||
「按钮底要深」是矛盾的,所以拆成两个。`.uk-button-primary`、`.uk-label:not(语义色)` 都吃它。
|
||||
- 字号阶梯已全部落地(模板 + base.css);`0.9em`(相对父级)、验证码等宽字形、`mail/*` 是**有意例外**。
|
||||
- 视觉自检:`TEST_PREVIEW=1 go test ./templates -run TestGenPreview` → `outputs/preview-*.html`;
|
||||
截图必须 `chrome --headless=old --force-device-scale-factor=1`(新版 headless 本机挂住)。
|
||||
⚠️ `--window-size` ≠ CSS 视口;要精确视口就用 iframe 指定 width/height。验证 CSS 靠**像素比对**
|
||||
(PIL 在 venv)而非目测;扫描循环上界不能与物体边界重合。
|
||||
- ⚠️ **后台页不是都有卡片**:`/user/questions` 的列表是 `<hr>` 裸行,模板里唯一一处 `.uk-card`
|
||||
是投票 tab 的标题条。默认主题 #fff 看不出来,换带整页插画的主题就"整页透上来"。
|
||||
已在 `.qlist-panel` 上补卡片底;新增后台页时别假设外层已有卡片。
|
||||
@@ -0,0 +1,67 @@
|
||||
# 主题(箱主个性化)(长版明细)
|
||||
|
||||
## 主题(箱主个性化)
|
||||
|
||||
- 三个注入点:`/_/{domain}/*` → `question.Pager` 按 `pageUser.Theme`;`/user/*` → `internal/context`
|
||||
Contexter 按 `c.User.Theme`(`isThemeScope()` 只认 `/user` 与 `/user/`);`/vote/{token}` → `route/vote.Page`
|
||||
按发布者 `owner.Theme`。都看箱主/发布者,不看访客。全站页(`/`、`/explore`、`/login`)不主题化。
|
||||
- 投票页**语言**也跟发布者:`applyOwnerLocale(data, owner)`(与主题同一函数),必须在任何 i18n 取词之前调用。
|
||||
- 一套主题 = `static/css/themes/<slug>.css`,**只覆盖令牌**,不碰布局。`static/fs.go` 已嵌整个 `themes/` 目录。
|
||||
- 主题显示名:模板用 `{{ themeName .LangCode .Slug }}` → `theme.DisplayName`,i18n 没登记
|
||||
`theme.name.<slug>` 时**回退成 slug**。⚠️ 别在模板里直接 `{{t .LangCode (printf "theme.name.%s" .Slug)}}`:
|
||||
`i18n.T` 缺词条返回 key 本身,设置页就会显示 `theme.name.test01`(用户报过)。
|
||||
- ⚠️ **`[theme] available` 是硬白名单,启动时读一次**:css 发布了但没登记,设置页看不到,现象同「部署没生效」。
|
||||
排查:`curl /static/css/themes/<slug>.css` 200 = 文件在(没登记);404 = 旧二进制。
|
||||
- ⚠️ 主题 css **必须成对写**:`html[data-theme-style="x"]`(0,1,1) + `html[data-theme="dark"][data-theme-style="x"]`(0,2,1)。
|
||||
`TestThemeCSSComplete` 查两个选择器 + 10 个颜色令牌。
|
||||
- ⚠️ **按页面作用域**:`internal/context.pageScope()` → `<html data-page-scope>`,取值
|
||||
`box`/`question`/`admin`(/user/questions,/user/votes*)/`profile`/`vote`。深色作用域覆盖必须**三重选择器**,
|
||||
否则被浅色作用域块(同特异性、写在后面)盖掉。
|
||||
- ⚠️ **装饰层固定 10 槽**(base.css 12 层 = pattern1 + deco10 + image1),三组 `--tb-deco/-size/-position`
|
||||
必须等长。`TestDecoSlotCount` 逐文件逐声明检查(只扫 embed 到的文件)。
|
||||
- 位置一律两值 `calc(0%|50%|100% ± N)`(百分比相对「容器 − 图片」)。**别用 left/bottom 关键字混搭**:
|
||||
三值语法非法时整条声明作废 → 图层静默回左上。
|
||||
- 尺寸想随视口缩用 `clamp`,**不要写死 px**:`auto clamp(min(calc(100vh - 24px), 231px), 46.67vh, 420px)`。
|
||||
内层 `min()` 是兜底 —— 屏幕小到连下限都放不下时让位给视口。宽幅素材只能按宽度缩(`vw`),
|
||||
按高度缩的话宽度按比例涨,窄屏照样裁。
|
||||
- ⚠️ **UIkit 3.3.3 主色是编译期常量 `#1e87f0`**,令牌注入不进去。base.css 末尾按组件映射(a/uk-link、
|
||||
uk-button-primary、checkbox/radio、input:focus、uk-tab active、`.uk-progress`、`.uk-label:not(...)`)。
|
||||
- 令牌 `--tb-navbar`(顶栏底色,默认 `var(--tb-surface)`):页面底色偏彩时主题必须覆盖。
|
||||
⚠️ **顶栏只能半透明,不能全透明**(2026-10-22 用户明确要求):装饰层从角落长出来时实色顶栏会把
|
||||
花枝切一段;但改成 `transparent` 顶栏就"消失"了,导航失去依托、文字压在花纹上。正解是
|
||||
`rgba(bg, 0.72)` + `.uk-navbar-container{backdrop-filter: blur(12px)}`(模糊只能写在规则里,
|
||||
令牌只是颜色)。`TestNavbarNotFullyTransparent` 拦 `transparent` / `rgba(...,0)` / `#rrggbb00`。
|
||||
同理 `#tb-mobile-utils` 在 base.css 里写死 `rgba(255,255,255,.92)`,彩色主题要自己调成同色系。
|
||||
- ⚠️ 装饰层会穿过顶栏区域时,`top` 别停在 0~nav 高度内:改半透明后枝头会压到「登录」上
|
||||
(700~760px 宽度实测),按顶栏高度(60 + margin 20)把 `top` 下移。
|
||||
- 静态资源版本号统一 `conf.StaticVersion`;**图片不走这个缓存键**(`max-age=86400`),换图要给 URL 带 `?v=`。
|
||||
- 整页背景 `--tb-page-image`(cover)/ `--tb-page-pattern`(平铺,`-size` 控尺寸)/ `--tb-page-position`
|
||||
(焦点,默认 `var(--tb-page-position, center center)`,旧主题不写也不坏)。底色用**纯色**别用渐变;
|
||||
图案 ≥24px、透明度 ≤0.06。
|
||||
- ⚠️ **多背景层不能塞变量列表**:变量自身是逗号列表会让层数与 size/position 错位。多张装饰用固定槽位。
|
||||
- 装饰摆位:**贴边、成组、避开页脚**;四角各摆一个会读成贴纸墙纸;页脚文案右对齐 → 右下角不能放人物。
|
||||
`--tb-deco` 是 fixed 视口层,贴边插画别放进 `cover` 背景图(必裁边)。
|
||||
- 主题视觉的**结构**比摆位重要:通行做法是「整屏场景插画 + UI 悬浮其上」(蔚蓝档案 JP)。
|
||||
白底留白里摆人物,无论多大都读成贴纸;卡片/顶栏配半透明白 + `backdrop-filter` 才有"浮在画面上"的关系。
|
||||
- 素材放 `static/images/themes/<slug>/`(embed 已含 `images/themes`,子目录自动覆盖)。整页图 16:9 + 竖版两张。
|
||||
- 人物素材:**图生图 + 抠白底**(`image1` 给立绘、`input_fidelity: high`、`flat vector illustration, no outlines`)。
|
||||
⚠️ 正面立绘直接贴进空场景像贴纸:改**侧后方视角**(不直视访客),RGB **压白 12~16%** 与压白 38% 的场景对齐雾度。
|
||||
- ⚠️ **抠白底容差 16**:背景 253、白袜子 237 只差 16,容差 22 以上会把袜子吃穿。手内侧/两腿之间是
|
||||
**闭合**白区,四边 floodfill 进不去,得按连通域单独掏(面积 ≥24),再 1px 收边。
|
||||
- ⚠️ `ruleBody` 取令牌前要剥 `/* */`:注释会和后面的令牌粘成一个键,色块预览静默失效。
|
||||
- ⚠️ **主题会覆盖 base.css 的同名令牌 —— 基座改配色必须同步改主题**。改 `--tb-text-muted` 时
|
||||
sakura `#a08c94`(2.8:1)、shiroko `#8794a7`(2.76:1) 都还没跟上,"修了但主题页没变"就是这么来的。
|
||||
现已调到 `#78696e`(4.61) / `#656e7a`(4.63),深色块 `#a18790` / `#7f8ea1`(对压平后的卡片 4.62)。
|
||||
主色也已修(`#0e6fd0` / `#3b6dac` / `#ba3e66`)+ 新增 `--tb-on-primary`,见 topics/style.md。
|
||||
量对比度时半透明 `--tb-surface` 要**压平到 --tb-bg** 再算,别当纯白。
|
||||
- 主题选择页的色块由浅色块的 `--tb-bg` + `--tb-primary` 生成(`swatchOf`),注释里别写令牌名。
|
||||
- `test01` 是用户 2026-10-21 自建又删掉的验证主题(css + 2 张 webp 已移除,未提交)。
|
||||
删主题只需删文件:`build()` 读不到 css 会打 warning 并忽略,已选该主题的用户静默回退站点默认。
|
||||
`static/images/shiroko-cutout.png` 也一并删除(2026-10-21):它从不进 embed,只是 docs 里的图生图参考图,
|
||||
仓库不存这类大图。
|
||||
- ⚠️ **白上白素材无法用颜色分开**:白底 + 白主体(如自行车)时,车身与背景是同一个白
|
||||
(实测都 `#f9f9f9`),floodfill 只吃外圈,轮圈内/车架三角/车筐整块留下 = 看着"没抠干净"。
|
||||
自检:`不透明像素里近白(>240) 占比`,正常应≈0(修复前 50%)。要么全部转透明(成品变线稿风),
|
||||
要么重出绿幕/透明背景素材。细节见 `docs/theme-shiroko-assets-spec.md` 的「白上白」。
|
||||
- ⚠️ 换 `props/*.webp` 时**不要裁画布**:`--tb-deco-size` 是 `auto 259px`,宽度按原图比例算,
|
||||
裁完锚点 `left calc(50% - 612px)` 会漂;四周透明边留着不影响观感。
|
||||
@@ -22,6 +22,16 @@ func main() {
|
||||
cmd.Web,
|
||||
cmd.Censor,
|
||||
cmd.Uid,
|
||||
cmd.Ban,
|
||||
cmd.Restore,
|
||||
cmd.Reports,
|
||||
cmd.Restrict,
|
||||
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")
|
||||
|
||||
+286
-28
@@ -1,57 +1,260 @@
|
||||
; ── 关于配置键名 ──────────────────────────────────────────────────────────
|
||||
; ⚠️ 键名写错时,程序启动会 WARN 提示「存在无法识别的键」并给出最接近的合法键名。
|
||||
; 对于少量已知的常见误拼(如 honeypot_retention_days → honeypot_retain_days),
|
||||
; 程序会自动纠正并在日志里说明纠正了什么。
|
||||
; 但请优先写对键名 —— 自动纠正只覆盖已知的误拼,不认识的键一律不猜。
|
||||
|
||||
[app]
|
||||
production = true
|
||||
external_url = "https://box.shiroko.one" ; 【必填】站点对外访问地址。用于生成 og:url 与邮件中的全部链接
|
||||
; 【必填】站点对外访问地址。用于生成 og:url 与邮件中的全部链接
|
||||
external_url = "https://box.shiroko.one"
|
||||
; (找回密码、私信通知等),必须以 http(s):// 开头、结尾不带斜杠
|
||||
; (程序会自动 TrimRight "/")。域名迁移时只改这一处即可全局生效。
|
||||
title = "NekoBox"
|
||||
icp = ""
|
||||
default_lang = "" ; 站点默认语言:zh-CN / zh-TW / en / ja,留空默认简体中文(访客未设置语言时的兜底)
|
||||
; 站点默认语言:zh-CN / zh-TW / en / ja,留空默认简体中文(访客未设置语言时的兜底)
|
||||
default_lang = ""
|
||||
uptrace_dsn = ""
|
||||
qiniu_access_key = ""
|
||||
qiniu_access_secret = ""
|
||||
aliyun_access_key = ""
|
||||
aliyun_access_key_secret = ""
|
||||
share_background = "https://your-default-background.jpg" ; 默认分享图背景图链接(1280x720)
|
||||
font_path = "static/fonts/NotoSansSC-Regular.otf" ; 中文字体文件路径(相对项目根目录)
|
||||
; 默认分享图背景图链接(1280x720)
|
||||
share_background = "https://your-default-background.jpg"
|
||||
; 分享卡绘图用的中文字体(相对项目根目录)。字体文件不在仓库里,必须自备一份,
|
||||
; 否则分享卡接口会返回 500「字体文件未配置或无法加载」。可用 Noto Sans SC / 思源黑体,
|
||||
; 支持 .otf/.ttf/.ttc;放好后无需重启生效。
|
||||
font_path = "static/fonts/NotoSansSC-Regular.otf"
|
||||
|
||||
[security]
|
||||
enable_text_censor = true
|
||||
ip_blacklist = ; 逗号分隔的IP黑名单,支持精确IP(如1.2.3.4)和CIDR(如1.2.3.0/24),留空不启用
|
||||
ip_blacklist_redirect_url = ; 黑名单IP访问时强制跳转到该URL
|
||||
trust_proxy = false ; 是否信任反向代理设置的 CF-Connecting-IP / X-Real-IP / X-Forwarded-For 头。
|
||||
; 文本审核服务通道:qiniu(七牛)/ aliyun(阿里云)/ auto(七牛优先、阿里云兜底)。
|
||||
text_censor_source = auto
|
||||
; 留空等同于 auto,即引入该配置前的历史行为。
|
||||
; 显式选 qiniu / aliyun 时必须在 [app] 配好对应凭据,否则启动即报错
|
||||
; —— 避免运行期审核必然失败(审核失败是 fail-closed,拒绝提交,
|
||||
; 会让发布/私信整站不可用,且现象隐蔽、只在日志可见)。
|
||||
; 凭据位置:[app] 的 qiniu_access_key / qiniu_access_secret、
|
||||
; aliyun_access_key / aliyun_access_key_secret。
|
||||
; ⚠️ 配好凭据 ≠ 可用:阿里云通道走的是「内容安全 2.0 / 文本审核增强版」
|
||||
; (接口 TextModeration,接入点 green-cip.cn-shanghai.aliyuncs.com),
|
||||
; 开通前提只要求【实名认证,个人账号即可】,不需要企业认证。
|
||||
; 开通入口:内容安全 2.0 控制台 → 开通「内容审核增强版」按量付费
|
||||
; (开通本身不收费,按实际调用量出账;文本审核标准 7.5 元/万次)。
|
||||
; 用 RAM 子账号调用需授予 AliyunYundunGreenWebFullAccess;欠费停服同理。
|
||||
; 审核场景固定为 comment_detection(公聊评论检测,覆盖涉政/涉暴/涉黄等底线维度)。
|
||||
; 单次 content 上限 600 字符,超长文本由代码分段审核(段间带重叠,避免跨段漏判)。
|
||||
; ⚠️ 不要再去开通「内容安全 1.0」:它要求企业实名认证,个人账号无法开通,
|
||||
; 调用会永久返回 code=596 "You have not opened Yundun Content Moderation Service"。
|
||||
; 审核失败时日志会打印阿里云返回的 code / msg / requestId 便于定位;
|
||||
; 首次出现账号级失败(未开通 / 未授权 / 密钥错误)还会额外打印一条一次性告警。
|
||||
; 逗号分隔的IP黑名单,支持精确IP(如1.2.3.4)和CIDR(如1.2.3.0/24),留空不启用
|
||||
ip_blacklist =
|
||||
; 黑名单IP访问时强制跳转到该URL
|
||||
ip_blacklist_redirect_url =
|
||||
; 是否信任反向代理设置的 CF-Connecting-IP / X-Real-IP / X-Forwarded-For 头。
|
||||
trust_proxy = false
|
||||
; 仅当站点部署在可信反向代理(如 nginx/Cloudflare)之后且代理已覆盖这些头时设为 true;
|
||||
; 否则保持 false,防止客户端伪造 header 绕过速率限制与 IP 黑名单。
|
||||
; ⚠️ 若站点在 Cloudflare 后且需使用下面的自动封禁,必须设为 true,
|
||||
; 否则解析到的将是 Cloudflare 边缘 IP,自动封禁会误伤所有访客。
|
||||
; ⚠️ 蜜罐取证同理:trust_proxy = false 时所有请求的来源都被记成反代地址
|
||||
; (常见 127.0.0.1),取证数据失去意义;而且一次蜜罐封禁会连带封掉整站。
|
||||
; 蜜罐命中回环地址时启动日志会打一次告警提醒检查这里。
|
||||
; ⚠️ 开启 trust_proxy 时,必须配合「Cloudflare Tunnel / Authenticated Origin Pull /
|
||||
; 防火墙仅放行 Cloudflare / EdgeOne 回源网段」锁定源站,否则攻击者可直连源站并伪造
|
||||
; CF-Connecting-IP / EO-Client-IP 头。无需维护 CDN 庞大 CIDR 清单。
|
||||
;
|
||||
; —— 双 CDN(境内腾讯 EdgeOne + 境外 Cloudflare)场景 ——
|
||||
; EdgeOne 回源会用 EO-Client-IP 携带客户端真实 IP(等效 CF-Connecting-IP),
|
||||
; 本程序在 trust_proxy=true 时已同时信任这两个权威头。
|
||||
; 两家 CDN 并存时必须:① 防火墙仅放行两家回源网段;② 在各家 CDN 的规则引擎中
|
||||
; 删除回源请求里「对方 CDN」的权威头(经 EdgeOne 回源删 CF-Connecting-IP、
|
||||
; 经 Cloudflare 回源删 EO-Client-IP),否则攻击者可借道另一家伪造客户端身份,
|
||||
; 绕过限流 / 自动封禁 / 单 IP 投票限制。详细步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md。
|
||||
; 本程序在 trust_proxy = true 时同时支持这两个权威头。
|
||||
; ⚠️ 权威头只在「TCP 对端可信」时才被采信(回环地址,或命中 trusted_proxies)。
|
||||
; 这条是代码里的硬约束,不再依赖运维纪律 —— EdgeOne 不剥离客户端自带的 CF-Connecting-IP,
|
||||
; 旧实现无条件优先读它,导致经 EdgeOne 进来的请求客户端 IP 完全可控:
|
||||
; 限流 / IP 黑名单失效,且 autoban 按假 IP 记账可被用来栽赃任意正常用户。
|
||||
; 双 CDN 并存时:① 两家回源网段都登记到 trusted_proxies;② 防火墙仅放行两家回源网段;
|
||||
; ③ 仍建议在各家规则引擎里删掉「对方 CDN」的权威头(纵深防御)。
|
||||
; 仅用 cloudflared Tunnel 回本机时对端是 127.0.0.1,无需任何额外配置。
|
||||
; 重新启用 EdgeOne 前请先按本节核对,步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md。
|
||||
|
||||
trusted_proxies =
|
||||
; 逗号分隔的「你自己的」反向代理/负载均衡 IP 或 CIDR(如 127.0.0.1、
|
||||
; 10.0.0.0/8)。仅当 TCP 对端落在该清单内时才解析 X-Forwarded-For / X-Real-IP。
|
||||
; 留空(默认)则这两个头永不被信任,仅采用 CDN 权威头 CF-Connecting-IP /
|
||||
; EO-Client-IP(需源站已锁定)。
|
||||
; 注意:此清单用于你自己的基础设施,不是 CDN 的全量 CIDR 清单。
|
||||
enable_auto_ban = true ; 自动封禁(fail2ban 风格):检测到批量扫描/探测后临时封禁来源 IP
|
||||
ban_track_notfound = false ; 是否把「未注册路径的 404」计入封禁统计(默认 false)。正常业务
|
||||
; 逗号分隔的「你自己的」反向代理/负载均衡 IP 或 CIDR(如 10.0.0.0/8),
|
||||
; 或「CDN 直连回源」形态下该 CDN 的回源网段。
|
||||
; 回环地址(127.0.0.1 / ::1)无需登记即视为可信对端:cloudflared / 本机 nginx
|
||||
; 回源的连接必然由本机进程发起,外部无法伪造。
|
||||
; 除回环以外,CDN 权威头也必须对端命中本清单才会被采信 —— 公网对端带来的
|
||||
; CF-Connecting-IP / EO-Client-IP 一律忽略,改用 TCP 对端地址。
|
||||
; 仅当 TCP 对端命中本清单时才解析 X-Forwarded-For / X-Real-IP。
|
||||
; 注意:此清单用于你自己的基础设施(含你选定 CDN 的回源网段),
|
||||
; 不是 CDN 的全量 CIDR 清单。
|
||||
; 内容安全策略(CSP)模式:off / report / enforce,留空等同 report。
|
||||
csp = enforce
|
||||
; enforce(默认):真正拦截。脚本白名单由代码从 [upload] 的图片 CDN 域名与
|
||||
; [recaptcha] 的域名自动派生,另内置模板实际引用的 cdnjs.snrat.com
|
||||
; (UIkit/Alpine/dayjs/vConsole)、challenges.cloudflare.com 与 umami 统计域;
|
||||
; 配了 sentry_dsn 时自动放行 browser.sentry-cdn.com。
|
||||
; ⚠️ 日后新增第三方脚本 / 字体 / 接口域名,必须同步加进 internal/route/csp.go,
|
||||
; 否则控制台会刷 "Refused to load ..." 且对应功能静默失效。
|
||||
; report:只下发 Content-Security-Policy-Report-Only,页面照常加载,违规只进浏览器
|
||||
; 控制台(配了 csp_report_uri 才上报)。不给上报地址时它等于没装,
|
||||
; 所以只在「怀疑误伤、想先看证据」时临时用。
|
||||
; off:完全不下发(排障逃生口)。
|
||||
; 默认值的前提是白名单已用真浏览器逐页巡检过:
|
||||
; outputs/sec-audit-2026-10-25/csp_sweep.py(20 个页面零违规)。
|
||||
; 上线后发现误伤:把这里改成 report 或 off 再重启,不用改代码。
|
||||
; 图片 / 样式 / 字体 / 连接放宽到 https:(箱主上传图与主题背景可能落在任意 CDN),
|
||||
; 真正收紧的是 script-src、object-src、base-uri、form-action、frame-ancestors。
|
||||
; report:只下发 Content-Security-Policy-Report-Only,页面照常加载,违规只进浏览器
|
||||
; 控制台(配了 csp_report_uri 才上报)—— 用于上线前观察一周再切 enforce。
|
||||
; enforce:真正拦截。脚本白名单由代码从 [upload] 的图片 CDN 域名与 [recaptcha] 的域名
|
||||
; 自动派生,另内置模板实际引用的 cdnjs.snrat.com(UIkit/Alpine/dayjs/vConsole)
|
||||
; 与 challenges.cloudflare.com。
|
||||
; ⚠️ 日后新增第三方脚本 / 字体 / 接口域名,必须同步加进 internal/route/csp.go,
|
||||
; 否则控制台会刷 "Refused to load ..." 且对应功能静默失效。
|
||||
; off:完全不下发(排障逃生口)。
|
||||
; 图片 / 样式 / 字体 / 连接放宽到 https:(箱主上传图与主题背景可能落在任意 CDN),
|
||||
; 真正收紧的是 script-src、object-src、base-uri、form-action、frame-ancestors。
|
||||
; CSP 违规上报地址(可选)。留空时违规只进浏览器控制台,不上报。
|
||||
csp_report_uri =
|
||||
; ⚠️ 限流与自动封禁的计数器都在进程内存里(internal/route/ratelimit.go、
|
||||
; internal/security/autoban)。单副本部署(当前形态:cloudflared Tunnel 回一台机器)
|
||||
; 下工作正常;一旦改成多副本 / 多机,每个副本各有一份计数(实际额度 × 副本数),
|
||||
; 进程重启即清零。多副本前必须先把计数迁到 Redis。
|
||||
; 封禁「事实」已落库(ban_records 表:键 / 原因 / 时长 / 到期时间),
|
||||
; 重启丢的只是生效状态,账本还在。
|
||||
enable_auto_ban = true
|
||||
; 是否把「未注册路径的 404」计入封禁统计(默认 false)。正常业务
|
||||
ban_track_notfound = false
|
||||
; (域名/题目不存在等均走 302 业务跳转,不产生 404 计数)一律放行,
|
||||
; 仅命中后台扫描特征路径(/admin、/.env 等)才参与统计封禁,避免
|
||||
; 共享出口(CDN 边缘 / CGNAT)下正常用户被 404 探测连坐误伤。
|
||||
; 如需恢复对纯 404 字典扫描的封禁再置 true。
|
||||
ban_threshold_sensitive = 10 ; 统计窗口内命中敏感路径(/admin、/.env、/wp-admin 等)的次数阈值
|
||||
ban_threshold_404 = 20 ; 统计窗口内返回 404 的次数阈值(仅 ban_track_notfound = true 时生效)
|
||||
ban_window_seconds = 60 ; 统计窗口时长(秒)
|
||||
ban_duration_minutes = 15 ; 临时封禁时长(分钟),到期自动解除,重启即清零
|
||||
security_alert_email = ; 触发封禁时发送告警邮件的收件人(复用下方 SMTP 发信配置),留空不发送
|
||||
; 统计窗口内命中敏感路径(/admin、/.env、/wp-admin 等)的次数阈值
|
||||
ban_threshold_sensitive = 10
|
||||
; 统计窗口内返回 404 的次数阈值(仅 ban_track_notfound = true 时生效)
|
||||
ban_threshold_404 = 20
|
||||
; 统计窗口时长(秒)
|
||||
ban_window_seconds = 60
|
||||
; 临时封禁时长(分钟),到期自动解除,重启即清零
|
||||
ban_duration_minutes = 15
|
||||
; 触发封禁时发送告警邮件的收件人(复用下方 SMTP 发信配置),留空不发送。
|
||||
; ⚠️ 也是蜜罐告警邮件收件人,而蜜罐邮件正文含**明文账号密码**:
|
||||
; 必须是个人邮箱,禁止填群组 / 分发列表 / 团队共用邮箱。
|
||||
security_alert_email =
|
||||
|
||||
; ── 蜜罐(虚假后台登录界面)────────────────────────────────────────────
|
||||
; ⚠️ 本段一律「注释独占一行、值单独一行」。
|
||||
; 配置加载器开启了 IgnoreInlineComment,行尾 `; 注释` 会**并入值**:
|
||||
; honeypot_max_rows = 200000 ; 说明 → 值变成 "200000 ; 说明" → int 解析失败
|
||||
; honeypot_paths = ; 说明 → 值变成 " ; 说明" → 被切成一堆假路径
|
||||
; 后果是该项静默回退零值,而日志里只会说「配置值不对」,很难联想到是注释造成的。
|
||||
; 本文件其余段落已全部改成这个写法,新增配置项时请保持一致。
|
||||
|
||||
; 开启后,扫描器探测后台路径(/wp-admin、/.ssh、/.env 等)时不再返回 302,
|
||||
; 而是返回一个仿 WordPress 的假登录页,并把其提交的用户名/密码写入取证文件。
|
||||
; 只在「没有任何路由匹配」的路径上生效,真实业务路由零交集。
|
||||
; 假页面只含 canary 标记与假表单,绝不含任何真实信息。
|
||||
enable_honeypot = false
|
||||
|
||||
; 追加自定义诱饵路径(逗号分隔),与内置清单取并集。留空 = 只用内置清单。
|
||||
; 用 `TamaBox honeypot paths` 可查看最终生效的完整清单。
|
||||
honeypot_paths =
|
||||
|
||||
; 蜜罐命中记录的保留天数(取证用途),到期自动清理。
|
||||
; ⚠️ 键名是 honeypot_retain_days(没有 tion)。写错会被静默忽略,
|
||||
; 程序会尝试自动纠正并在日志里提示,但别指望它每次都猜对。
|
||||
; 缺省 30 天;写 0 或负数会回退 30(不表示「永不清理」)。
|
||||
; 要关闭自动清理请用 honeypot_auto_clean = false。
|
||||
honeypot_retain_days = 30
|
||||
|
||||
; 是否启用蜜罐记录的后台自动保留期清理(每小时一轮)。
|
||||
; honeypot_hits 是只追加的审计表,写入完全由外部扫描流量驱动,
|
||||
; 不清理的话表体积只增不减。关闭后仍可手动 `TamaBox honeypot prune`。
|
||||
honeypot_auto_clean = true
|
||||
|
||||
; 蜜罐命中记录的行数上限(第二道闸)。显式写 0 = 不限(会告警提醒表只增不减)。
|
||||
; 保留期是时间维度上界,但攻击者可用海量源 IP 在保留期内把表撑爆。
|
||||
; 实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
|
||||
honeypot_max_rows = 200000
|
||||
|
||||
; 「提交凭据」多少次即封禁。默认 1(一次即封):往假后台里填账号密码是证据最强
|
||||
; 的行为,抓到一次就足够。⚠️ 不作用于纯 GET 探测 —— 那类请求走 ban_threshold_sensitive,
|
||||
; 否则扫描器看一眼假登录页就被封,永远走不到提交凭据那一步。
|
||||
honeypot_threshold = 1
|
||||
|
||||
; 蜜罐封禁时长(分钟)。缺省 1440(24 小时),刻意远长于 ban_duration_minutes:
|
||||
; 命中蜜罐意味着对方已在提交账号密码,证据强度远高于单纯路径探测。
|
||||
; 显式写 0 = 回退 ban_duration_minutes。
|
||||
; ⚠️ 蜜罐封禁与 enable_auto_ban 相互独立:即使关掉自动封禁,
|
||||
; 只要 enable_honeypot = true,蜜罐命中仍会封禁并记录。
|
||||
honeypot_ban_minutes = 1440
|
||||
|
||||
; 凭据取证文件的存放目录。
|
||||
; ⚠️ 捕获到的用户名/密码**不入库**,只追加到这个目录下的按天文件
|
||||
; (creds-YYYY-MM-DD.jsonl,一行一条 JSON)。原因:明文账号密码一旦入库,
|
||||
; 就会跟着进备份、主从复制和任何能连库的报表工具,事后无法彻底清除。
|
||||
; 目录会被强制为 0700、文件 0600;相对路径以进程工作目录为基准。
|
||||
; 目录不可写时只降级为「不保存凭据」,不影响站点启动。
|
||||
; 建议单独挂一个卷,并让它不参与备份。
|
||||
honeypot_cred_dir = data/honeypot-creds
|
||||
|
||||
; ── 安全状况汇总报告(周报 / 月报)──────────────────────────────
|
||||
;
|
||||
; 把周期内的蜜罐命中按来源 IP 聚合,再通过公共 IP 库定位到城市,
|
||||
; 生成「世界地图 + 国家/城市排行 + 来源明细」的 HTML 报告并邮件送达。
|
||||
;
|
||||
; ⚠️ 与 cdncheck 离线指纹的区别:那个只认云厂商 / CDN / WAF 的 ASN,
|
||||
; 肉鸡(家宽、企业宽带、移动网络)一律显示「未识别(普通网络)」。
|
||||
; 下面的 IP 库回答的是「这个 IP 在哪儿」,是另一个维度。
|
||||
;
|
||||
; 数据源(ip_geo_provider):
|
||||
; auto(缺省) = ip-api.com 批量打底(全球、无需 key)+ 腾讯精修国内
|
||||
; ipapi = 只用 ip-api.com
|
||||
; tencent = 只用腾讯位置服务(必须配 key,否则降级 ipapi)
|
||||
; none = 不查询任何外部服务,攻击者 IP 一个字节都不外发
|
||||
; 隐私边界:auto / ipapi 会把攻击者 IP 发给 ip-api.com,tencent 发给腾讯。
|
||||
ip_geo_provider = auto
|
||||
|
||||
; 腾讯位置服务 key(lbs.qq.com 申请,个人免费额度足够)。
|
||||
; 两个用途:① 精修国内 IP 的省市与坐标;② 报告里可交互地图的底图。
|
||||
; 留空 → 跳过国内精修,报告里只保留离线地图(经纬网 + 气泡)。
|
||||
ip_geo_tencent_key =
|
||||
|
||||
; 周期性安全汇总邮件。缺省全开。
|
||||
; 发送时机:周报在每周一、月报在每月 1 日之后的第一轮检查发出,覆盖
|
||||
; 「上一个完整周期」;服务停机错过时间窗会在重新上线后补发;
|
||||
; 幂等由 security_report_logs 的 period_key 唯一索引保证,不会重复发。
|
||||
; 失败最多重试 5 次后停下(SMTP 配错时无限重试只会刷满日志),
|
||||
; 之后可用 TamaBox honeypot report 手动补发。
|
||||
honeypot_report_enabled = true
|
||||
honeypot_report_weekly = true
|
||||
honeypot_report_monthly = true
|
||||
|
||||
; 报告收件人。留空回退 security_alert_email。
|
||||
; 单独一个键是为了能把「随时发生的封禁告警」与「定期汇总」分投到不同邮箱
|
||||
; (例如告警进手机推送邮箱、汇总进归档邮箱)。
|
||||
honeypot_report_email =
|
||||
|
||||
; 报告文件(HTML + PNG)的存放目录,按 0700 创建、文件 0600。
|
||||
; 里面有攻击者 IP 明细,不要放进 Web 可访问的目录。
|
||||
; 想先看内容不发送:TamaBox honeypot report --period week --no-email
|
||||
honeypot_report_dir = data/reports
|
||||
|
||||
; 被封禁(自动封禁 / 蜜罐封禁)时返回的图片路径,相对路径以进程工作目录为基准。
|
||||
; 留空、文件读不到、或内容不是图片 → 一律回退为纯文字 "403 Forbidden"。
|
||||
; 只接受 image/* 的内容(png / jpeg / gif / webp):指向 app.ini 之类会被拒绝,
|
||||
; 否则等于把配置文件当响应体吐给攻击者。
|
||||
; 建议用 256×256 左右的图并压到 20 KB 以内 —— 被封的来源会反复重试,
|
||||
; 每张图都会照常占用出网带宽。
|
||||
ban_image_path =
|
||||
|
||||
; 站点默认「内容审核严格度」:lax(宽松)/ standard(标准)/ strict(严格)。
|
||||
default_moderation_strictness =
|
||||
; 仅对未单独设置档位的账号生效(账号在设置页选择后以账号设置为准)。
|
||||
; 留空回退 strict(最严档):保证升级不会放宽任何拦截。
|
||||
; 严格档是三个档位中最严的(拦截面不小于升级前),因此部署后默认拦截面可能略有扩大;
|
||||
; 若希望默认更少误伤,改为 standard 即可,无需改代码。
|
||||
; 三档的拦截范围见 internal/security/censor/policy.go,
|
||||
; 适用于提问、投票、私聊三类内容。
|
||||
|
||||
[server]
|
||||
port = 80
|
||||
@@ -62,8 +265,6 @@ listen =
|
||||
salt = ""
|
||||
# ⚠️ 必填:密码哈希盐,留空会导致启动失败(已在 conf.Init 强制校验)
|
||||
xsrf_key = ""
|
||||
# ⚠️ 必填:CSRF token 密钥,留空会导致启动失败(已强制校验)
|
||||
xsrf_expire = 3600
|
||||
|
||||
[database]
|
||||
type = "postgres"
|
||||
@@ -81,7 +282,8 @@ addr = "127.0.0.1:6379"
|
||||
password = ""
|
||||
|
||||
[recaptcha]
|
||||
captcha_type = recaptcha ; recaptcha(默认), turnstile, math(本地算数验证码,国内访问友好)
|
||||
; recaptcha(默认), turnstile, math(本地算数验证码,国内访问友好)
|
||||
captcha_type = recaptcha
|
||||
domain = "https://www.recaptcha.net"
|
||||
site_key = ""
|
||||
server_key = ""
|
||||
@@ -116,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)计数。
|
||||
@@ -155,3 +370,46 @@ chat_message_len = 2000
|
||||
chat_max_messages = 200
|
||||
; 单用户每日私信条数上限(用户可设范围 0..该值;0 表示不限制每日条数)
|
||||
daily_message_limit_max = 999
|
||||
|
||||
[announcement]
|
||||
; ── 站内公告:全站每页导航栏下方展示,改完重启生效 ──────────────────────
|
||||
; content :默认文案,留空 = 关闭公告(任一语言文案非空也视为开启)。
|
||||
; 用 \n 表示换行;http(s) 地址会自动变成链接;
|
||||
; 内容按纯文本渲染,不支持 HTML 标签(写 <b> 会原样显示)。
|
||||
; content_zh_cn / content_zh_tw / content_en / content_ja:
|
||||
; 分语言文案,留空则回退到 content;content 也留空时该语言不显示公告。
|
||||
; timezone:start / end 里不带偏移的「裸时间」按哪个时区解释,默认 Asia/Shanghai。
|
||||
; 可填任意 IANA 时区名(UTC / America/New_York …);二进制已内嵌 tzdata,
|
||||
; 容器不需要另装时区包。带偏移的 RFC3339 以自带偏移为准。
|
||||
; start / end:展示时间窗,左闭右开 —— 到 end 那一刻下线。留空表示该侧不限:
|
||||
; 只写 start = 定时上线;只写 end = 现在起展示到某时刻;都留空 = 一直展示。
|
||||
; 支持 2026-10-25 02:00 / 2026-10-25 02:00:59 / 2026-10-25 / RFC3339
|
||||
; (2026-10-25T02:00:00+08:00)。
|
||||
; 时间或时区写错不会导致启动失败:日志 WARN 后关闭公告。
|
||||
; 例:content = "10 月 25 日 02:00–04:00 机房维护,期间可能短暂无法访问。详情见 https://status.shiroko.one"
|
||||
; content_en = "Maintenance 02:00-04:00 on Oct 25 (UTC+8). Details: https://status.shiroko.one"
|
||||
; start = "2026-10-24 00:00"
|
||||
; end = "2026-10-25 04:00"
|
||||
content = ""
|
||||
content_zh_cn = ""
|
||||
content_zh_tw = ""
|
||||
content_en = ""
|
||||
content_ja = ""
|
||||
start = ""
|
||||
end = ""
|
||||
timezone = Asia/Shanghai
|
||||
|
||||
[theme]
|
||||
; 主题系统:只改配色 / 图案 / 整页背景,不涉及布局。
|
||||
; 作用范围:提问箱页面(主页 / 单题 / 密码 / 分享卡)、箱主本人的投票页与 /user 后台;
|
||||
; 首页、探索等站方页面保持站点默认外观。
|
||||
; default :站点默认主题(箱主未选择时使用)。填 default 或留空 = 现有外观。
|
||||
; available :启用哪些主题,逗号分隔,同时是箱主设置页的白名单;
|
||||
; 列了但 static/css/themes/<slug>.css 不存在的会被忽略并告警。
|
||||
; 新增主题:把 static/css/themes/_scaffold.css 复制成 <slug>.css,
|
||||
; 改选择器与令牌值,再把 <slug> 加进 available —— 不需要改模板。
|
||||
; 需要图片素材的,放 static/images/themes/<slug>/(static/fs.go 已嵌 images/themes 目录)。
|
||||
default = default
|
||||
available = default,sakura,shiroko,new-sakura
|
||||
; ⚠️ 没登记在 available 里的主题,即使 css 已随二进制发布也不会出现在设置页;
|
||||
; 启动日志会打出「[theme] available 未登记以下主题」提醒。
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env bash
|
||||
# 处置链路部署后自检(L1 停止分发 / L4 整页停用 / CLI 可用性)
|
||||
#
|
||||
# 为什么需要它:本机沙箱起不了 web(无 redis),L1/L4 的 HTTP 行为只能在服务器上验证。
|
||||
# 这个脚本把「施加处置 → 观察 HTTP → 自动撤销」串起来,避免手工操作忘了解除。
|
||||
#
|
||||
# 用法:
|
||||
# BASE_URL=https://box-dev.example.com DOMAIN=<测试箱主域名> TAMABOX=/path/to/NekoBox \
|
||||
# bash dev/devcheck-restrict.sh # 只打印将要执行的命令(dry-run)
|
||||
#
|
||||
# BASE_URL=... DOMAIN=... TAMABOX=... RUN=1 \
|
||||
# bash dev/devcheck-restrict.sh # 真正执行,结束时自动撤销
|
||||
#
|
||||
# 注意:
|
||||
# - DOMAIN 必须是**测试箱主**,不要用真实用户;脚本会临时施加 L4/L1 再撤销。
|
||||
# - 站点默认语言可能是 ja,页面文案断言按日文/英文各查一次,避免误判「没生效」。
|
||||
# - 只依赖 CLI 与 curl,不需要 psql 凭据。
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
BASE_URL=${BASE_URL:?need BASE_URL, e.g. https://box-dev.example.com}
|
||||
DOMAIN=${DOMAIN:?need DOMAIN, a TEST box domain only}
|
||||
TAMABOX=${TAMABOX:-./NekoBox}
|
||||
RUN=${RUN:-0}
|
||||
DAYS=${DAYS:-1}
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
restricted=0
|
||||
|
||||
say() { printf '%s\n' "$*"; }
|
||||
|
||||
check() {
|
||||
local name=$1 want=$2 got=$3
|
||||
if [ "$want" = "$got" ]; then
|
||||
say " PASS $name (=$got)"
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
say " FAIL $name (want $want, got $got)"
|
||||
fail=$((fail + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
code() { curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$1"; }
|
||||
|
||||
do_restrict() {
|
||||
restricted=1
|
||||
if [ "$RUN" = 1 ]; then
|
||||
"$TAMABOX" restrict --domain "$DOMAIN" --level "$1" --days "$DAYS" --reason "deploy self-check" --yes
|
||||
else
|
||||
say " [dry-run] $TAMABOX restrict --domain $DOMAIN --level $1 --days $DAYS --reason 'deploy self-check' --yes"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [ "$RUN" = 1 ] && [ "$restricted" = 1 ]; then
|
||||
say ""
|
||||
say "== 兜底撤销(无论中途是否失败)=="
|
||||
"$TAMABOX" unrestrict --domain "$DOMAIN" || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
say "== 0. 二进制是否包含新命令 =="
|
||||
if "$TAMABOX" --help 2>&1 | grep -q 'restrict'; then
|
||||
say " PASS restrict 命令存在"
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
say " FAIL 二进制里没有 restrict —— 构建的是旧代码(注意入口是根目录 tamabox.go,或用 ./cmd/)"
|
||||
fail=$((fail + 1))
|
||||
say ""
|
||||
say "结果: 无法继续,先修构建入口。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
say ""
|
||||
say "== 1. 基线:处置前箱主页必须可访问 =="
|
||||
base=$(code "$BASE_URL/_/$DOMAIN")
|
||||
if [ "$base" != 200 ]; then
|
||||
say " FAIL 基线不是 200(got $base),后续断言全部无意义,已中止。"
|
||||
say ""
|
||||
say " 302 的常见原因(按概率排序):"
|
||||
say " 1) 域名不存在或账号已停用 —— 先确认 DOMAIN 拼写。查一个真实域名:"
|
||||
say " curl -s $BASE_URL/explore | grep -o 'href=\"/_/[^\"]*\"' | head -5"
|
||||
say " 或在库上执行:select domain from users where deleted_at is null order by id desc limit 5;"
|
||||
say " 2) 该箱主设置了页面密码 —— 此时首页是 200(渲染密码页),不是 302。"
|
||||
say ""
|
||||
say " 注意:302 不能证明任何处置生效。上次自检就出现过「箱主不存在 → 302」被误判成 L4 生效。"
|
||||
exit 1
|
||||
fi
|
||||
say " PASS box page = 200"
|
||||
|
||||
say ""
|
||||
say "== 1b. 停用页是否部署(与处置无关,dry-run 也验)=="
|
||||
check "restricted page reachable" 200 "$(code "$BASE_URL/restricted")"
|
||||
body=$(curl -sL --max-time 10 "$BASE_URL/restricted")
|
||||
if printf '%s' "$body" | grep -qE '一時的に利用できません|temporarily unavailable|暫時無法存取|暂时不可访问'; then
|
||||
say " PASS restricted page copy rendered"
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
say " FAIL restricted page copy not found(i18n 或模板没部署上)"
|
||||
fail=$((fail + 1))
|
||||
fi
|
||||
|
||||
if [ "$RUN" != 1 ]; then
|
||||
say ""
|
||||
say "== dry-run:不做状态断言 =="
|
||||
say " 状态没有被改动,断言 L4/L1 只会得到误导性结果。"
|
||||
say " 基线已确认可访问,用 RUN=1 重跑以执行真实验证:"
|
||||
say " RUN=1 BASE_URL=$BASE_URL DOMAIN=$DOMAIN TAMABOX=$TAMABOX bash $0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
say ""
|
||||
say "== 2. L4 整页停用 =="
|
||||
do_restrict L4
|
||||
sleep 1
|
||||
check "box page under L4" 302 "$(code "$BASE_URL/_/$DOMAIN")"
|
||||
|
||||
say ""
|
||||
say "== 3. L1 停止分发:页面恢复、分享卡被拦 =="
|
||||
do_restrict L1
|
||||
sleep 1
|
||||
check "box page under L1" 200 "$(code "$BASE_URL/_/$DOMAIN")"
|
||||
|
||||
qid=$(curl -s --max-time 10 "$BASE_URL/_/$DOMAIN" | grep -o "/_/$DOMAIN/[0-9]\+" | head -1 | grep -o '[0-9]\+$')
|
||||
if [ -n "${qid:-}" ]; then
|
||||
check "share card under L1 (qid=$qid)" 302 "$(code "$BASE_URL/_/$DOMAIN/$qid/share-card")"
|
||||
else
|
||||
say " SKIP 该箱主没有已公开回答,无法验证分享卡(换一个有已答问题的测试箱主)"
|
||||
fi
|
||||
|
||||
say ""
|
||||
say "== 4. 撤销 =="
|
||||
if [ "$RUN" = 1 ]; then
|
||||
"$TAMABOX" unrestrict --domain "$DOMAIN"
|
||||
restricted=0
|
||||
check "box page after unrestrict" 200 "$(code "$BASE_URL/_/$DOMAIN")"
|
||||
else
|
||||
say " [dry-run] $TAMABOX unrestrict --domain $DOMAIN"
|
||||
fi
|
||||
|
||||
say ""
|
||||
say "== 结果: PASS=$pass FAIL=$fail =="
|
||||
if [ "$fail" -gt 0 ]; then
|
||||
say "有失败项,把上面输出 + 服务日志(journalctl / docker logs 里搜 'Failed to query user restriction')一起发我。"
|
||||
exit 1
|
||||
fi
|
||||
say "全部通过。仍需人工手测两项:L2(回答应保存为私密)、L3(回答应被拒绝)。"
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
# dev/gofmt_keep_crlf.py
|
||||
#
|
||||
# 运行 gofmt -w,但保留文件原始行尾(CRLF / LF),避免 gofmt 把 CRLF 文件整文件
|
||||
# 重写成 LF 造成的万行级 diff 污染。
|
||||
#
|
||||
# 用法:
|
||||
# python dev/gofmt_keep_crlf.py <file1> [file2 ...]
|
||||
# git diff --name-only | grep '\.go$' | xargs python dev/gofmt_keep_crlf.py
|
||||
#
|
||||
# 实现要点:先把源文件按 LF 写入临时副本交给 gofmt,gofmt 输出也是 LF;
|
||||
# 若原文件是 CRLF,再把输出整体换回 CRLF 写回,从而只保留真实的格式化改动。
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
|
||||
GOFMT = r"C:/Program Files/Go/bin/gofmt"
|
||||
|
||||
|
||||
def fmt_one(path):
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
crlf = b"\r\n" in data
|
||||
|
||||
tmp = path + ".gofmt.tmp"
|
||||
with open(tmp, "wb") as f:
|
||||
f.write(data.replace(b"\r\n", b"\n"))
|
||||
try:
|
||||
r = subprocess.run([GOFMT, "-w", tmp], capture_output=True)
|
||||
if r.returncode != 0:
|
||||
sys.stderr.write(
|
||||
"gofmt FAILED on %s:\n%s\n" % (path, r.stderr.decode("utf-8", "replace"))
|
||||
)
|
||||
return False
|
||||
with open(tmp, "rb") as f:
|
||||
out = f.read()
|
||||
finally:
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if crlf:
|
||||
out = out.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n")
|
||||
with open(path, "wb") as f:
|
||||
f.write(out)
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
files = [a for a in sys.argv[1:] if a.strip()]
|
||||
if not files:
|
||||
sys.stderr.write("usage: gofmt_keep_crlf.py <file> [file ...]\n")
|
||||
return 2
|
||||
ok = True
|
||||
for p in files:
|
||||
if not fmt_one(p):
|
||||
ok = False
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"Replace": {"C:/OtherFiles/Github/TamaBox-v4-mainline/internal/storage/s3.go": "C:/Users/TAMAKY~1/AppData/Local/Temp/tamabox_s3_stub.go"}}
|
||||
{"Replace": {"C:/OtherFiles/Github/TamaBox-v4-mainline/internal/storage/s3.go": "C:/OtherFiles/Github/TamaBox-v4-mainline/dev/s3stub/s3_stub.go"}}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// Command preview-honeypot 把全部伪装家族渲染成静态 HTML,供人工核对外观。
|
||||
//
|
||||
// 用法:go run ./dev/preview-honeypot
|
||||
// 产物:outputs/honeypot-preview/index.html(9 个 iframe 并排)
|
||||
//
|
||||
// 改动 internal/security/honeypot/family.go 的皮肤后重新跑一次,
|
||||
// 比对着源码想象 CSS 可靠得多 —— 上一版就是靠它发现 Tomcat 按钮没背景色。
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
||||
)
|
||||
|
||||
type item struct {
|
||||
fam honeypot.Family
|
||||
action string
|
||||
note string
|
||||
}
|
||||
|
||||
func main() {
|
||||
dir := "outputs/honeypot-preview"
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
items := []item{
|
||||
{honeypot.FamilyWordPress, "/wp-login.php", "/wp-login.php · /wp-admin"},
|
||||
{honeypot.FamilyPhpMyAdmin, "/phpmyadmin/index.php", "/phpmyadmin · /pma"},
|
||||
{honeypot.FamilyTomcat, "/manager/html", "/manager/html · 401 Basic"},
|
||||
{honeypot.FamilyJenkins, "/jenkins/login", "/jenkins"},
|
||||
{honeypot.FamilyGrafana, "/grafana/login", "/grafana"},
|
||||
{honeypot.FamilyGitLab, "/users/sign_in", "/users/sign_in"},
|
||||
{honeypot.FamilyLuCI, "/cgi-bin/luci", "/cgi-bin/luci"},
|
||||
{honeypot.FamilyJoomla, "/administrator/index.php", "/administrator"},
|
||||
{honeypot.FamilyGeneric, "/admin", "/admin · /console"},
|
||||
}
|
||||
|
||||
var cards strings.Builder
|
||||
for _, it := range items {
|
||||
name := it.fam.String()
|
||||
body := honeypot.RenderFamily(it.fam, honeypot.LoginPageVars{
|
||||
Action: it.action,
|
||||
Redir: "/wp-admin/",
|
||||
Failed: false,
|
||||
})
|
||||
if err := os.WriteFile(filepath.Join(dir, name+".html"), body, 0o644); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Fprintf(&cards, `<figure><figcaption><b>%s</b><span>%s</span></figcaption><iframe src="%s.html" title="%s"></iframe></figure>`,
|
||||
name, it.note, name, name)
|
||||
}
|
||||
|
||||
page := `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
|
||||
<title>蜜罐伪装家族预览</title>
|
||||
<style>
|
||||
body{margin:0;padding:28px 24px 40px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f6f7f9;color:#1d2327}
|
||||
h1{font-size:18px;font-weight:600;margin:0 0 6px}
|
||||
p.lead{margin:0 0 22px;font-size:13px;color:#646970}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:18px}
|
||||
figure{margin:0;background:#fff;border:1px solid #dcdcde;border-radius:8px;overflow:hidden}
|
||||
figcaption{display:flex;justify-content:space-between;gap:8px;padding:9px 12px;font-size:12px;border-bottom:1px solid #f0f0f1}
|
||||
figcaption span{color:#787c82}
|
||||
iframe{width:100%;height:520px;border:0;display:block}
|
||||
</style></head><body>
|
||||
<h1>蜜罐:9 个伪装家族</h1>
|
||||
<p class="lead">每个 iframe 是 /wp-admin 这类诱饵路径的真实响应(同一份 RenderFamily 输出,未做任何美化)。Tomcat 家族在真实响应里是 401 + Basic Auth,这里有页面是为了看内容。</p>
|
||||
<div class="grid">` + cards.String() + `</div></body></html>`
|
||||
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(page), 0o644); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println("预览已生成:", filepath.Join(dir, "index.html"))
|
||||
}
|
||||
+12
-2
@@ -7,13 +7,21 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
)
|
||||
|
||||
// MaxUploadBytes 与真实实现保持一致(8MB 单图上限,bodylimit 中间件依赖此值语义)。
|
||||
const MaxUploadBytes = 8 * 1024 * 1024
|
||||
|
||||
// EncodeSpec 与真实实现的编码档位结构保持字段一致。
|
||||
type EncodeSpec struct {
|
||||
MaxSide int
|
||||
Quality float32
|
||||
}
|
||||
|
||||
// GeneralEncodeSpec 与真实实现保持一致(route/question 引用)。
|
||||
var GeneralEncodeSpec = EncodeSpec{MaxSide: 1600, Quality: 80}
|
||||
|
||||
func UploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
@@ -26,6 +34,8 @@ func UploadBackgroundToS3(file multipart.File, fileHeader *multipart.FileHeader)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func ConvertStaticImageToWebP(r io.Reader) ([]byte, error) { return nil, nil }
|
||||
func ConvertStaticImageToWebP(data []byte, spec EncodeSpec) ([]byte, error) { return nil, nil }
|
||||
|
||||
func ConvertGifToAnimatedWebP(gifData []byte) ([]byte, error) { return nil, nil }
|
||||
|
||||
func isSupportedImage(data []byte) bool { return true }
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// 临时校验程序:用 aws-sdk-go-v2 的 SigV4 signer 给同一请求签名,
|
||||
// 与 dev/theme-builder/serve.py 手写的实现比对。验证完即删。
|
||||
//
|
||||
// 每行 stdin:method \t url \t region \t payloadHash \t amzDate
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
)
|
||||
|
||||
func main() {
|
||||
keyID := os.Getenv("AWS_KEY_ID")
|
||||
secret := os.Getenv("AWS_SECRET")
|
||||
s := bufio.NewScanner(os.Stdin)
|
||||
for s.Scan() {
|
||||
line := strings.TrimSpace(s.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
f := strings.Split(line, "\t")
|
||||
method, urlStr, region, payloadHash, ts := f[0], f[1], f[2], f[3], f[4]
|
||||
|
||||
t, err := time.Parse("20060102T150405Z", ts)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
req, err := http.NewRequest(method, urlStr, nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
req.Header.Set("X-Amz-Content-Sha256", payloadHash)
|
||||
creds := credentials.NewStaticCredentialsProvider(keyID, secret, "")
|
||||
c, err := creds.Retrieve(context.Background())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := v4.NewSigner().SignHTTP(context.Background(), c, req, payloadHash, "s3", region, t); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println(req.Header.Get("Authorization"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# 把 Windows 侧工作区同步到 WSL 里的测试部署目录。
|
||||
#
|
||||
# 为什么不能直接 rsync --delete:WSL 侧有几份「只存在于目标机」的文件,
|
||||
# 源目录里没有它们,--delete 会把它们当多余文件删掉:
|
||||
# - conf/app.ini 本地测试配置(数据库密码、对象存储密钥;模板见 dev/wsl-app.ini)
|
||||
# 第一次踩这个坑时整份 app.ini 被删,服务器重启后起不来。
|
||||
#
|
||||
# 用法:在仓库根目录执行
|
||||
# bash dev/sync-wsl.sh
|
||||
set -euo pipefail
|
||||
|
||||
SRC="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
DST=/home/tamakyi/tb/src
|
||||
|
||||
cd "$SRC"
|
||||
tar -cf - \
|
||||
--exclude=.git \
|
||||
--exclude=outputs \
|
||||
--exclude=.workbuddy \
|
||||
. 2>/dev/null |
|
||||
wsl -d Debian -e bash -c '
|
||||
set -e
|
||||
DST=/home/tamakyi/tb/src
|
||||
rm -rf /tmp/tbsync && mkdir -p /tmp/tbsync
|
||||
tar -xf - -C /tmp/tbsync
|
||||
# 只同步代码与资源,绝不动目标侧的 conf/app.ini
|
||||
rsync -a --delete --exclude "conf/app.ini" /tmp/tbsync/ "$DST"/
|
||||
rm -rf /tmp/tbsync
|
||||
echo "SYNCED -> $DST"
|
||||
'
|
||||
@@ -0,0 +1,403 @@
|
||||
# 主题生成器
|
||||
|
||||
一个可视化编辑主题的小工具:**以项目真实页面为画布**,按页面分别摆装饰,导出 zip 解压即用。
|
||||
|
||||
它替换了早先那版「单文件、仿制预览」的生成器 —— 那一版的问题很直接:预览是我自己画的
|
||||
示意图,跟真实页面长得不一样;而且只能配「背景 + 人物 + 道具」三件套。
|
||||
|
||||
## 为什么需要起一个本地服务
|
||||
|
||||
生成器要把 `outputs/preview-*.html`(由 `go test` 渲染出来的**真实页面**)放进 iframe 当画板,
|
||||
再往里注入正在编辑的主题 CSS。`file://` 下浏览器不允许父页面访问 iframe 的 DOM(即使同目录),
|
||||
预览就只能退回示意图。起本地服务后父子同源,拖拽、注入、实时改令牌都能工作。
|
||||
|
||||
```bash
|
||||
# 1) 先渲染真实页面快照(改了模板/数据后要重跑)
|
||||
TEST_PREVIEW=1 go test ./templates -run TestGenPreview
|
||||
|
||||
# 2) 起服务(只读,不改项目任何文件)
|
||||
python dev/theme-builder/serve.py # 默认监听 0.0.0.0:8777,同局域网可访问
|
||||
python dev/theme-builder/serve.py --host 127.0.0.1 # 只给本机用(公共网络请加这个)
|
||||
|
||||
# 3) 打开终端里打印的地址
|
||||
# 本机 http://127.0.0.1:8777/dev/theme-builder/
|
||||
# 局域网 http://192.168.x.x:8777/dev/theme-builder/
|
||||
```
|
||||
|
||||
默认绑 `0.0.0.0`,是为了能躺在床上用手机 / 平板调主题:启动时会把本机各网卡的地址都列出来,
|
||||
照着点就行(手机与电脑要在同一网段)。第一次启动 Windows 会弹防火墙询问,要允许私有网络。
|
||||
|
||||
⚠️ 两个必须知道的点:
|
||||
|
||||
- **上传接口没有账号体系**:能打开这个端口的人,都能用你填的密钥往对象存储写 / 删文件。
|
||||
公共网络(咖啡馆、宿舍、公司网)一律 `--host 127.0.0.1`。写接口还会校验 `Origin` 与
|
||||
`Host` 同源,挡掉「别的网页借你浏览器发请求」,但挡不住同网段主动连过来的人。
|
||||
- **换地址 = 换 origin**:`127.0.0.1` 与 `192.168.x.x` 在浏览器眼里是两个站点,
|
||||
草稿(localStorage)与 S3 密钥(sessionStorage)不共享 —— 第一次用新地址要重填一次密钥。
|
||||
|
||||
不想用脚本也行:在项目根跑 `python -m http.server 8777`,但那就只有静态预览、没有上传能力。
|
||||
|
||||
## 它和线上是同一条链路
|
||||
|
||||
| 环节 | 生成器 | 线上 |
|
||||
| --- | --- | --- |
|
||||
| 画板 | `outputs/preview-*.html`(真模板渲染) | 同一套模板 |
|
||||
| 作用域 | iframe 里设 `data-page-scope` | `internal/context.pageScope()` 输出到 `<html>` |
|
||||
| 装饰层 | **固定 10 槽** | `base.css` 的 12 层背景(pattern 1 + deco 10 + image 1) |
|
||||
| 位置 | `calc(0%|50%|100% ± Npx)` | 同一套 `background-position` |
|
||||
|
||||
因为是同一套机制,工具里拖出来的位置换视口不会跑位,也不会出现「预览好看、实机错位」。
|
||||
|
||||
## 三块面板
|
||||
|
||||
**页面** —— 每个页面作用域可以单独配,各有一份背景与装饰:
|
||||
|
||||
| 作用域 | 对应路径 |
|
||||
| --- | --- |
|
||||
| 全局 | 所有页面默认 |
|
||||
| `box` | `/_/{domain}` 箱主页 |
|
||||
| `question` | `/_/{domain}/{id}` 单题页 |
|
||||
| `admin` | `/user/questions` 题目列表 |
|
||||
| `profile` | `/user/*` 设置页 |
|
||||
| `vote` | `/vote/{token}` 投票页 |
|
||||
|
||||
默认全部「跟随全局」;勾掉就变成独立配置,导出时才会产出覆盖块。
|
||||
没配的页面自动沿用全局,不会出现「某页装饰消失」。
|
||||
|
||||
以前「跟随全局」时背景下拉和「加一层」都是 disabled —— 用户在箱主页看到背景还在、
|
||||
又点不动,这就是「背景图关不掉」的来源。现在:
|
||||
|
||||
- 「加一层」「改背景」永远可点,**一改就自动把这一页切成独立配置**,并把全局当前值复制过来当起点
|
||||
(改背景不会顺带把图案和图层弄丢;图层重新发 id,避免两个作用域共用一批 id,拖拽/高亮会串);
|
||||
- 这种状态下图层列表不展示全局的层、也不给拖拽框 —— 否则一拖就改到全局去了;
|
||||
- 想主动切换用「跟随全局」复选框,或用列表里的「改为独立配置」按钮。
|
||||
|
||||
(首页 `/` 与 `/explore` 不列出来 —— 全站公开页不注入 `ThemeSlug`,不加载主题 css,配了也没效果。)
|
||||
|
||||
**装饰图层** —— 每个作用域最多 10 层,三种类型:
|
||||
|
||||
- **图片**:从图片池选,选中后可直接在画板上拖动;
|
||||
- **光斑**:`radial-gradient`,给空白一点体积,不占位、不会变成贴纸;
|
||||
- **线性光**:`linear-gradient`,做窗光/压深。
|
||||
|
||||
锚点用 9 宫格 + 像素偏移,生成 `calc(0% + 40px) calc(100% - 36px)` 这种两值形式。
|
||||
|
||||
### 尺寸单位 × 视口(「换个屏幕就被裁」的根因)
|
||||
|
||||
装饰层是**视口固定层**(`background-attachment: fixed`),所以同一个 `400px` 在 900 高的屏幕上占
|
||||
44%、在 768 的旧笔电上占 52%,窄屏上直接被切掉。尺寸单位就是回答「屏幕变小的时候这张图怎么办」:
|
||||
|
||||
| 单位 | 生成 | 适合 | 低于 100% 视口时的兜底 |
|
||||
| --- | --- | --- | --- |
|
||||
| `fit` 自适应·按高度 | `auto clamp(min(calc(100vh - 24px), 231px), 46.67vh, 420px)` | 人物立绘、竖幅道具 | 下限让位给视口,永远不纵向溢出 |
|
||||
| `fitw` 自适应·按宽度 | `clamp(min(calc(100vw - 24px), 587px), 70.57vw, 1067px) auto` | 横幅、整屏插画 | 下限让位给视口,永远不横向溢出 |
|
||||
| `px` 固定 | `auto 420px` | 需要绝对尺寸(如对齐某个 UI 元素) | 不缩,会裁 |
|
||||
| `vh` 视口比例 | `auto 46.67vh` | 一定要占同样屏高的元素 | 无下限 |
|
||||
|
||||
**为什么必须有两种自适应**:`fit` 只按高度缩,宽度跟着比例涨 —— 一张 16:9 场景 `h=600` 在 1024 宽下
|
||||
是 910px 刚好,在 390 的竖屏上按高度缩完还有 1001px,照样裁两侧。宽幅素材只能按宽度缩。
|
||||
`fitw` 就是干这个的。实测同一张 1920×1080 素材:
|
||||
|
||||
| 视口 | `px 600` | `fit`(按高) | `fitw`(按宽) |
|
||||
| --- | --- | --- | --- |
|
||||
| 1512×900 | 1067 宽 | 1067 宽 | 1067 宽 |
|
||||
| 1024×768 | 1067 宽 | 910 宽 | 723 宽 |
|
||||
| 390×844 | 1067 宽(裁) | 1001 宽(裁) | 366 宽(完整) |
|
||||
|
||||
`min(calc(100vh - 24px), 231px)` 这一段是关键:`clamp()` 的下限如果固定成「基准的 55%」,
|
||||
屏幕小到连 55% 都放不下时还是会溢出。让下限与视口取小值,自适应单位就不会把图撑出屏幕。
|
||||
|
||||
**每个图层底部有一行实测**:`1512×900 下显示 131×420,完整可见(源 300×960)`,
|
||||
溢出时会变成橙色 `⚠ 1024×768:左 88px / 右 88px 越界`,并给一个「按 1024×768 修好」按钮
|
||||
(改成固定 px 并算出刚好放下的尺寸)。视口栏可以切 1512/1280/1024/820/390 五档 —— 都是真视口高,
|
||||
不是只改宽度,因为裁切几乎全由高度决定。
|
||||
|
||||
导出前还会再扫一遍,把「哪些层在哪些视口下越界」附在结果里(只提醒,不阻止 ——
|
||||
故意让插画出血也是合法设计)。
|
||||
|
||||
**每一行都能直接换图**:行里带缩略图 + 「图片」下拉,一眼看到这层用的是哪张。
|
||||
之前换图的下拉只出现在「选中那一层」的编辑器里,加上新层又默认绑第一张素材,
|
||||
连加三层就全是同一张图,看起来像「每层只能显示这张、换不了」。
|
||||
现在新加的层**不会**自动绑第一张(只有素材池只剩一张时才绑),行里显示「(选图片)」等你选。
|
||||
|
||||
**↑ / ↓ 调层级**。顺序就是层级:`background-image` 的第一层画在最上面,
|
||||
所以「谁挡住谁」是靠顺序定的(shiroko 里自行车被人挡住就是这么来的)。
|
||||
|
||||
**窄屏** —— 手机上卡片几乎占满屏幕,桌面那套 16:9 场景会被 `cover` 裁成一条无意义纹理。
|
||||
三段优先级从高到低:
|
||||
|
||||
1. **隐藏全部装饰层(急停)** —— 一键全关,最省事;
|
||||
2. **每层的窄屏策略**:原样 / 缩小到 N%(缩放系数默认 62%)/ 隐藏。缩小是把高度与偏移
|
||||
一起乘系数,锚点不变,所以「贴底靠左」的构图缩完还在原位;
|
||||
3. **尺寸单位**:`fit` / `fitw` 的层浏览器会自己按视口缩,**不依赖这段规则**。
|
||||
|
||||
这一栏还能指定竖版背景图、改断点(默认 900px)。导出时生成 `@media` 块,只写浅色规则
|
||||
(深色块特异性更高,本来就压住它)。
|
||||
|
||||
⚠️ `@media` 里**每个独立配置的页面都要各写一条** `html[data-theme-style][data-page-scope="x"]`:
|
||||
页面覆盖块特异性是 (0,2,1),只写 `html[data-theme-style]`(0,1,1)的话,
|
||||
独立配置的页面在手机上根本不生效(旧版本就是这个 bug)。
|
||||
|
||||
⚠️ 竖版图只替换**本来就有背景图**的页面。某页故意配成纯色(`--tb-page-image: none`),
|
||||
手机上新塞一张竖图等于改了他的设计。
|
||||
|
||||
**整页背景 · 焦点** —— 背景图是 `cover`,宽高比不匹配就必裁。九宫格焦点决定裁哪一边:
|
||||
16:9 场景在竖屏上只剩中间一条,人物偏一侧时把焦点挪过去才不会裁到脸。
|
||||
生成 `--tb-page-position`(`base.css` 里以 `var(--tb-page-position, center center)` 兜底,
|
||||
旧主题不写这个令牌也不会坏)。
|
||||
|
||||
**配色** —— 只填 6 个基础色(底色 / 卡片 / 顶栏 / 正文 / 主色 / 边框),
|
||||
其余 4 个派生:`surface-2` 往正文靠、`surface-hover` 往主色靠、`primary-weak` 加 12% 透明、
|
||||
`text-secondary` / `text-muted` 往底色淡出。
|
||||
|
||||
⚠️ **派生文字色有对比度下限,不是固定比例**。正文往背景淡出时,二分找到「最淡但仍 ≥ 目标」的位置:
|
||||
次要文字 5.5:1、弱化文字 4.5:1(WCAG AA 正文)。旧版按「混 55%」硬算,
|
||||
在浅底上直接掉到 2.9:1 —— `base.css` 特意把 `--tb-text-muted` 从 `#8a919c` 提到 `#6b7280`(4.83:1),
|
||||
生成器按固定比例算等于每导出一次就把那个修复覆盖回去。
|
||||
|
||||
- 卡片是半透明(`rgba(255,255,255,0.86)`)时先**压平到页面底色**再算,否则白卡会被当成纯白、对比度偏低;
|
||||
- 同时检查页面底色与卡片两个背景,取更不利的那个;
|
||||
- 手填的正文本身就达不到目标时**原样返回**(不再往淡里调)——宁可不够淡,不能产出看不见的字;
|
||||
- 面板底部有读数 `正文 12.5:1 · 次要 5.5:1 · 淡文字 4.6:1 · 主色 4.6:1`,低于 4.5 变橙(手填色只提醒不拦)。
|
||||
|
||||
主色除了当链接色,还当按钮/标签的**背景**,所以还要交代「压在上面的字」:
|
||||
导出时写 `--tb-on-primary`,在白字和深字(`#0b1016`)里挑对比度更高的那个。
|
||||
浅色主题主色深 → 白字;深色主题主色普遍偏亮(`#5c8dff` 之流)→ 白字只有 2.2~3.1:1,必须换深字。
|
||||
主色卡在**中间调**时两种字都不够 —— 面板会给橙色提示「压深一点配白字,或提亮一点配深字」,
|
||||
`?dump=palette` 里也会列进 `warnings`(不判失败:那是主色要调,不是工具挑错)。
|
||||
|
||||
顺带:这套字号阶梯(`--tb-fs-xs…hero`)来自 `base.css`,生成器**不碰字号** ——
|
||||
主题的职责是配色与背景,改字号会连带挤坏所有页面排版。
|
||||
|
||||
**顶栏** —— 三档,只管「配色 → 顶栏」那个基色怎么用:
|
||||
|
||||
| 档位 | 输出 | 适用 |
|
||||
| --- | --- | --- |
|
||||
| 实色 | `--tb-navbar: rgb(...)` | 页面没有装饰层穿过顶栏;最简单 |
|
||||
| 半透明 | `--tb-navbar: rgba(...,0.72)` + `backdrop-filter: blur(12px)` | 装饰层从角落长出来、会经过顶栏时(推荐) |
|
||||
| 全透明 | `--tb-navbar: transparent` | **只能预览**,导出会被拦 |
|
||||
|
||||
- 半透明档的两个滑杆:不透明度 0.2~0.95、背景模糊 0~24px。令牌只能是颜色值,
|
||||
模糊另外写成 `.uk-navbar-container:not(.uk-navbar-transparent)` 规则(深浅两套共用一条)。
|
||||
- 面板底部有 `导航文字 6.0:1` 的读数,按**压平后的顶栏**算(半透明时是「顶栏压在底色上」的结果,
|
||||
不是顶栏色本身)。低于 4.5 变橙 —— 装饰层从顶栏后面穿过时还会更低,取最不利情况定透明度。
|
||||
- ⚠️ **全透明不给导出**:顶栏一透明导航就失去依托、文字直接压在花纹上,比"被裁一段"更糟。
|
||||
这一档留着的唯一用途是**预览对比**;`validate()` 会拦,仓库的 `TestNavbarNotFullyTransparent`
|
||||
也会拦(两处同一条规矩,生成器里绕过也没用)。
|
||||
- 旧工程(配色里直接塞 `rgba(...,0.52)`)打开时按 alpha 反推档位:1 → 实色、0 → 全透明、其余 → 半透明。
|
||||
|
||||
**图片池** —— 上传任意张,浏览器本地转 WebP。导出时只打包实际用到的图。
|
||||
每张图左上角有 `fx` 按钮,进处理面板(处理过的会高亮)。
|
||||
|
||||
### 图片处理
|
||||
|
||||
| 功能 | 说明 |
|
||||
| --- | --- |
|
||||
| 不透明度 | 0~100%。CSS 背景图没法单独设 `opacity`,只能烘焙进像素 |
|
||||
| 压白 / 雾度 | `v×(1-k)+255×k`。场景压白 38% 时人物常用 12~26%,否则是全页对比度最高的一块 |
|
||||
| 去色 | 往灰度混。彩色素材压不住素场景时用 |
|
||||
| 裁掉透明边 | 生成图四周留白多时裁掉,否则按高度定尺寸会让主体看着比实际小 |
|
||||
| 去白底 | 四边漫水 + 封闭白孔检测,默认容差 16 |
|
||||
| 边缘收 1px | 去白底后把残留的半透明白边收掉,代价是轮廓缩一圈 |
|
||||
|
||||
**非破坏**:原文件字节存在 `orig` 里,`fx` 只存参数,每次处理都从 `orig` 重算。
|
||||
所以「压白 16% → 改成 20%」不会叠加两次压缩,反复调参也不会把边缘越磨越糊。
|
||||
打开面板看一眼就关,一个像素都不会动(参数没变就跳过重编码)。
|
||||
|
||||
**去白底的两个关键点**(都是踩过的):
|
||||
|
||||
- **封闭白孔**:手内侧、两腿之间那种被轮廓包住的白色,四边漫水根本进不去,
|
||||
必须单独按连通域掏(面积 ≥ 24 才掏,更小的多半是眼睛高光)。可以关掉。
|
||||
- **容差别贪**:背景常见 253,白袜子 237,只差 16。容差 16 保得住袜子,30 就吃穿了。
|
||||
实测白底立绘去白底后透明占比 46.3%,与手写 floodfill 抠出来的 46.6% 差 0.3%。
|
||||
|
||||
## 导出画质
|
||||
|
||||
WebP 体积 ≈ 像素数 × 质量。**真正的大头是像素**:CSS 里只显示 400px 高的立绘,
|
||||
2 倍屏也只要 800px,上传 3000px 的图纯属白扛。所以顺序是:先砍长边,再降质量。
|
||||
|
||||
| 设置 | 默认 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 质量 | 0.88 | 0.85~0.9 肉眼几乎看不出差别,低于 0.8 才明显糊 |
|
||||
| 长边上限 | 1600 | 只缩不放。整页背景建议 1920/不限制;装饰人物 1200 够用 |
|
||||
| 素材前缀 | 空 | 空 = 图片放 `static/images/themes/<slug>/` 随二进制发布;填 `https://cdn.example.com/themes` = CSS 写绝对地址,图片放自己的对象存储(R2 / MinIO / B2 / OSS / COS,任何 S3 兼容都行)。**前缀里不要带 slug** |
|
||||
|
||||
导出的 CSS 只有 `urlOf()` 一处决定图片地址,图层、背景、窄屏块全走它,不会出现
|
||||
「背景走 CDN、装饰还指本地」的半吊子状态。素材前缀只影响导出,预览永远是内联 dataURL。
|
||||
|
||||
详细取舍(生成器前缀 / 应用内 301 / 边缘改写)见 `docs/theme-assets-cdn.md`。
|
||||
|
||||
## 素材托管(本地 / 任意 S3 兼容)
|
||||
|
||||
「图片放哪」由你自己定,面板里二选一:
|
||||
|
||||
- **本地**(默认):打进 `static/images/themes/<slug>/`,随二进制发布。部署即生效、不依赖外部服务,
|
||||
代价是换一张图要重新编译。
|
||||
- **S3 兼容存储**:传到你自己的对象存储,导出的 CSS 写绝对地址。这里刻意不锁厂商 ——
|
||||
R2 / MinIO / Backblaze B2 / 阿里 OSS(S3) / 腾讯 COS(S3) 用的是同一套 SigV4,
|
||||
差别只在 `Endpoint`、`Region`、是否路径风格,全都做成了字段。
|
||||
|
||||
填好 Endpoint / Region / 桶名 / 路径前缀 / 密钥后:
|
||||
|
||||
1. **测试连接** —— 依次验四件事:`HEAD` 桶(连通与凭据)、`PUT` 一个探针对象(写权限)、
|
||||
`GET` 读回比对(内容一致)、匿名 `GET`(传上去的图能不能被浏览器直接读到),最后删掉探针。
|
||||
失败时会把 S3 的 `<Code>` 翻成人话(`SignatureDoesNotMatch` → 密钥/Region/时钟三选一)。
|
||||
2. **上传素材** —— 把本次主题真正引用到的图(与导出同一个集合,`usedAssetIds()`)逐张 PUT,
|
||||
键为 `<路径前缀>/<slug>/<id>.webp`,带 `Cache-Control: public, max-age=31536000, immutable`。
|
||||
成功后自动把上面的「素材前缀」填成推导出的公开地址。
|
||||
|
||||
⚠️ 上传走的是本机 `serve.py` 的 `/api/s3/test`、`/api/s3/upload`,浏览器不直连对象存储 ——
|
||||
所以**不用给桶配 CORS**,密钥也不会出现在页面发往第三方的请求里。它只存在当前标签页的
|
||||
`sessionStorage` 与内存:不进 `localStorage` 草稿、不进工程 json、不进导出 zip。
|
||||
用 `python -m http.server` 起的只有静态服务,探测不到接口时按钮会禁用并说明原因。
|
||||
|
||||
公开地址留空时按 endpoint 推导(path 风格 = `<endpoint>/<桶>/<前缀>`)。R2 与私有桶
|
||||
需要自定义域或 CDN 域名,那种情况自己填「公开前缀」。测试里的「匿名可读」会给一条提醒,
|
||||
但不会拦上传 —— 很多人就是靠自定义域分发的。
|
||||
|
||||
### 检测与清理
|
||||
|
||||
传上去只是第一步,「传上去之后对不对」是另一回事:同一个 slug 传过三次、换过 slug
|
||||
旧图还留在桶里、重新导出后远端还是旧版 —— 这些在页面上都看不出来。所以还有一块
|
||||
「远端素材」,点「**检测**」列出 `<路径前缀>/<slug>/` 下所有对象,按对象键名跟本地比:
|
||||
|
||||
| 状态 | 判断依据 | 能做什么 |
|
||||
| --- | --- | --- |
|
||||
| 一致 | 远端大小 == 本地编码后大小 | — |
|
||||
| 旧图 | 同名但大小不同(多半重新导出过) | 行内 ↻ 用本地覆盖 |
|
||||
| 多余 | 远端有、当前主题已不再引用(换过 slug / 删过素材) | 行内 ✕ 或「删除多余」批量删 |
|
||||
| 缺失 | 本地有、远端没有 | 「补齐差异」一次传完 |
|
||||
|
||||
- 「**补齐差异**」只传缺失 + 旧图,一致的不重传(省流量也省时间);
|
||||
- 「**删除多余**」列清单后二次确认,走 `DeleteObject`(幂等,不存在的键也算成功);
|
||||
- 改过 slug 会提示「重新检测」—— 拿旧主题的清单去比新主题,会满屏「多余」;
|
||||
- 只动远端,本地素材与工程文件不受影响。
|
||||
|
||||
厂商怎么填(2026 年的常见值,各家控制台都有「S3 API」页面):
|
||||
|
||||
| 厂商 | Endpoint | Region | 路径风格 |
|
||||
| --- | --- | --- | --- |
|
||||
| Cloudflare R2 | `https://<账号ID>.r2.cloudflarestorage.com` | `auto` | 勾选 |
|
||||
| MinIO(自建) | `http://<host>:9000` | `us-east-1`(看服务端配置) | 勾选 |
|
||||
| Backblaze B2 | `https://s3.<区域>.backblazeb2.com` | 区域名,如 `us-west-004` | 勾选 |
|
||||
| 阿里云 OSS | `https://s3.oss-<区域>.aliyuncs.com` | 区域,如 `cn-hangzhou` | 看控制台 |
|
||||
| 腾讯云 COS | `https://cos.<区域>.myqcloud.com` | 区域,如 `ap-hangzhou` | 看控制台 |
|
||||
|
||||
AWS S3 或已绑定的桶级自定义域可以取消「路径风格」(走 `<桶>.<endpoint>`)。
|
||||
|
||||
两条自检(都在本目录,不依赖 UI):
|
||||
|
||||
```bash
|
||||
python dev/theme-builder/check_sigv4.py # 与 aws-sdk-go-v2 的 signer 逐字节对拍
|
||||
python dev/theme-builder/selftest_upload.py # 起假 S3,跑「测试连接 → 上传 → 读回」
|
||||
python dev/theme-builder/mock_s3.py --port 9900 # 想手动点面板时,先起这个假 S3
|
||||
# 生成器在别的设备上打开时,假 S3 也要 --host 0.0.0.0,面板里的 Endpoint 填本机 IP
|
||||
```
|
||||
|
||||
面板底部实时显示 `3 张,共 19KB;最大一张 bg.webp 7KB(1920×1080)`。
|
||||
改完立刻按「原图 + fx 参数」重编码全部素材,并报出省了多少 —— 与图片处理同一条非破坏原则,
|
||||
来回试不会叠加压缩。
|
||||
|
||||
⚠️ 两个反直觉的实测结论(都在 `?dump=quality` 里做了断言):
|
||||
|
||||
- **`quality = 1` 不能用**。Skia 在这个值会切到 WebP 无损模式,输出反常:同一张噪声图
|
||||
q=1 只有 52KB,q=0.95 是 440KB。滑块上限卡 0.95,`canvasAsset` 里也夹一道。
|
||||
- **平涂/线稿降质量可能变大**。有损编码对纯色会引入噪点,噪声图 q 从 0.6 到 0.95 体积涨 75%,
|
||||
而平涂图怎么调都是 1KB —— 不能承诺「降 q 一定更小」。这类素材优先调长边。
|
||||
|
||||
缩小走 `imageSmoothingQuality = 'high'`,一次缩太多也不会糊成一团。
|
||||
|
||||
## 工程文件与草稿
|
||||
|
||||
调主题是连续几十次的活,所以配置必须留得住:
|
||||
|
||||
- **新建**:顶栏按钮,清空当前工程并删掉浏览器草稿(有二次确认)。
|
||||
之前只有 `?fresh=1` 这个隐藏参数 —— 用户看到「上次的数据」一直在,就会以为关不掉。
|
||||
- **自动草稿**:每次改动后 700ms 存进 `localStorage`,下次打开自动恢复。
|
||||
图片多到顶破 5MB 配额时会提示改用 json(只提示一次)。
|
||||
- **保存工程 / 打开工程**:`theme-<slug>.json`,图片以 base64 内嵌,一个文件就能带走;
|
||||
可以进版本库,也可以发给别人接着改。
|
||||
- 不想恢复草稿就加 `?fresh=1`。
|
||||
|
||||
## 导出
|
||||
|
||||
点「导出 zip」得到:
|
||||
|
||||
```
|
||||
static/css/themes/<slug>.css
|
||||
static/images/themes/<slug>/*.webp
|
||||
INSTALL-<slug>.txt
|
||||
```
|
||||
|
||||
点导出时会先跑一遍校验(slug 合法、图片层是否选了图、有没有引用已删除的素材、
|
||||
装饰是否超过 10 层、生成出来的 CSS 每处装饰是不是真的 10 个值),有问题直接列出来,
|
||||
不会让你导出后再去等 `go test`。
|
||||
|
||||
导出结果会报「几个文件 / 总共多大 / 其中几张图共多大」,并附上「哪些层在哪些视口下越界」——
|
||||
看到 `其中 4 张图共 3.2MB` 就该回去调长边上限了。
|
||||
|
||||
解压到项目根,然后按 `INSTALL-<slug>.txt` 做三步:
|
||||
|
||||
1. `conf/app.ini` 的 `[theme] available` 加上 slug;
|
||||
2. `internal/conf/static.go` 的 `StaticVersion` +1;
|
||||
3. `go build`(模板与静态资源都是编译期 embed,只重启不重编不会变)。
|
||||
|
||||
设置页下拉里显示的**就是 slug**。想要中文/日文名,到 `internal/i18n/dict_*.go` 加
|
||||
`theme.name.<slug>` 词条(四份词典都要加)。不加也不会露 key —— 模板走 `themeName`
|
||||
函数,缺词条时回退成 slug(见 `internal/theme.DisplayName`)。
|
||||
|
||||
⚠️ **窄屏段导出前先看一眼「窄屏隐藏全部装饰层」有没有勾着**:勾着的话 `@media` 会把
|
||||
10 个槽位全写成 `none`,桌面完全正常、手机上一个素材都没有 —— 这是「素材在窄屏下消失」
|
||||
最常见的来源。现在勾选时面板会给橙色警告、图层行标「窄屏隐藏」、加层和导出时也会再提醒一次。
|
||||
|
||||
## 它不做什么
|
||||
|
||||
- **不做复杂修图**:没有笔刷、蒙版、自由裁切、调色曲线 —— 这些该在专门的工具里做完再进来。
|
||||
- **不做调色**:想统一「雾度」(人物压白 16%、场景 30~38%)得在生成端做,
|
||||
见 `docs/theme-shiroko-assets-spec.md`。
|
||||
- **不写配置**:`[theme] available` 只给要粘贴的那一行。
|
||||
- **不碰深色的图片**:深色块一律 `--tb-page-image: none` + 装饰全关(白天场景压在深底上会脏)。
|
||||
想在深色下也放图,需要手工加深色块。
|
||||
|
||||
## 自检
|
||||
|
||||
没有 UI 也能回归,两个钩子:
|
||||
|
||||
```
|
||||
?demo=1 载入示例素材(canvas 画的背景 + 剪影人物)
|
||||
?narrow=hide 直接落在「窄屏隐藏全部装饰层」状态(复现「素材在手机上不见了」)
|
||||
?rows=2 加 2 层并依次绑到不同素材(截图多图层效果用)
|
||||
?scope=admin 直接落在某个作用域
|
||||
?fresh=1 忽略 localStorage 草稿,从空白开始
|
||||
?dump=css 把最终 CSS 写进 <pre id="dump">
|
||||
?dump=zip 把 zip 的 base64 写进 <pre id="dump">
|
||||
?dump=validate 把 validate() 的结果写进 <pre id="dump">
|
||||
?dump=install 把安装说明写进 <pre id="dump">
|
||||
?dump=fx 跑处理算法自检(13 个断言,输出 JSON)
|
||||
?dump=fxpipe 跑弹窗链路冒烟(打开→改参→预览→落盘→无改动不重编码)
|
||||
?dump=layers 图层/作用域回归(加层默认不绑图、行内换图、自动切独立、新建清空)
|
||||
?dump=fit 尺寸自适应回归(五档视口占用、溢出检测、fitw、旧工程兼容、@media 覆盖各作用域)
|
||||
?dump=quality 画质回归(q 单调性、q=1 被夹到 0.95、长边上限真的压像素)
|
||||
?dump=palette 派生文字色的对比度回归(四种配色下 ≥AA、层级不倒挂、手填低对比不继续变淡)
|
||||
?dump=navbar 顶栏三档回归(实色无 blur、半透明带 alpha+blur、全透明被 validate 拦、alpha 边界)
|
||||
?dump=fxkey 端到端去白底:载入白底图,统计透明像素比例
|
||||
?loadurl=/static/images/x.webp&fx=x
|
||||
直接把项目里的图当素材载入(不必先下载再上传),fx= 打开处理面板
|
||||
?fxjson={"key":{"on":true}}
|
||||
配合 loadurl 给初始处理参数
|
||||
```
|
||||
|
||||
```bash
|
||||
chrome --headless=old --virtual-time-budget=6000 \
|
||||
--dump-dom "http://127.0.0.1:8777/dev/theme-builder/?demo=1&dump=css"
|
||||
```
|
||||
|
||||
把抓到的 CSS 存成 `static/css/themes/<slug>.css`,可跑:
|
||||
|
||||
```bash
|
||||
go test ./internal/theme -run "TestThemeCSSComplete|TestDecoSlotCount"
|
||||
```
|
||||
|
||||
`TestDecoSlotCount` 是这次新增的:它会逐个检查 `base.css` 与所有主题 css 里每一处
|
||||
`--tb-deco*` 声明是否都有 10 个值。少写一个不会报错,只会让图层与 size/position 错位
|
||||
(表现为整页重复同一张插画,或人物跑到左上角),靠注释守不住。
|
||||
@@ -0,0 +1,103 @@
|
||||
"""校验 serve.py 手写的 SigV4 与 aws-sdk-go-v2 的 signer 逐字节一致。
|
||||
|
||||
为什么需要这个:SigV4 错一个字节,服务端只会回一句 SignatureDoesNotMatch,
|
||||
既不说哪里错、也不会在本地暴露出来 —— 上传功能就成了「点了没反应」。
|
||||
这里用官方 SDK 当参照实现做对拍,改完签名相关代码跑一遍即可。
|
||||
|
||||
用法(项目根):
|
||||
python dev/theme-builder/check_sigv4.py
|
||||
|
||||
⚠️ 用例只放 ASCII 键:aws-sdk-go-v2 裸 SignHTTP 默认会二次转义 URI,
|
||||
而 S3 客户端内部把 DisableURIPathEscaping 设成了 true(单次编码)。
|
||||
即同一个路径两种行为,把非 ASCII 键放进来只会比出「SDK 的两个模式之差」。
|
||||
我们跟随 S3 客户端的单次编码(AWS 文档的 RFC3986 规则),并由生成器把
|
||||
slug 限制在 [a-z0-9-_],从根上避开这个争议区。
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(os.path.dirname(HERE))
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
import serve # noqa: E402
|
||||
|
||||
KEY_ID = "AKIAIOSFODNN7EXAMPLE"
|
||||
SECRET = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
TS = "20130524T000000Z"
|
||||
EMPTY = serve.EMPTY_SHA256
|
||||
BODY_HASH = serve._sha256(b"TamaBox theme builder probe\n")
|
||||
|
||||
# (说明, method, endpoint, extra cfg, key, query)
|
||||
CASES = [
|
||||
("path-style 桶级 HEAD", "HEAD", "http://127.0.0.1:9000", dict(bucket="tb-assets"), "", None),
|
||||
("path-style 带前缀对象", "PUT", "http://127.0.0.1:9000",
|
||||
dict(bucket="tb-assets", prefix="themes"), "shiroko/bg1.webp", None),
|
||||
("virtual-host 对象", "GET", "https://s3.us-west-004.backblazeb2.com",
|
||||
dict(bucket="my-bucket", region="us-west-004", pathStyle=False), "themes/shiroko/bg1.webp", None),
|
||||
("R2 region=auto", "PUT", "https://acc.r2.cloudflarestorage.com",
|
||||
dict(bucket="cdn", region="auto", prefix="themes"), "x/a.webp", None),
|
||||
("R2 虚拟主机", "GET", "https://acc.r2.cloudflarestorage.com",
|
||||
dict(bucket="cdn", region="auto", pathStyle=False), "themes/s/t.webp", None),
|
||||
("带端口 https", "DELETE", "https://minio.lan:9443",
|
||||
dict(bucket="static", region="us-east-1"), "themes/s/t.webp", None),
|
||||
# 带 query:ListObjectsV2 的 canonical query 要按键排序、逐项编码(/ 编成 %2F)
|
||||
("ListObjectsV2 三参数", "GET", "http://127.0.0.1:9000", dict(bucket="tb-assets"),
|
||||
"", {"list-type": "2", "max-keys": "1000", "prefix": "themes/shiroko/"}),
|
||||
("ListObjectsV2 分页(乱序传入)", "GET", "https://acc.r2.cloudflarestorage.com",
|
||||
dict(bucket="cdn", region="auto", pathStyle=False), "",
|
||||
{"prefix": "themes/", "continuation-token": "1/abc+d", "max-keys": "2", "list-type": "2"}),
|
||||
]
|
||||
|
||||
|
||||
def build(endpoint, extra, key):
|
||||
cfg = serve.S3Config(dict(extra, endpoint=endpoint, accessKey=KEY_ID, secretKey=SECRET))
|
||||
if key:
|
||||
path = cfg.object_path(key)
|
||||
else:
|
||||
path = ("/" + cfg.bucket) if cfg.path_style else "/"
|
||||
return cfg, path
|
||||
|
||||
|
||||
def go_sign(payloads):
|
||||
env = dict(os.environ, AWS_KEY_ID=KEY_ID, AWS_SECRET=SECRET,
|
||||
GOPROXY="https://goproxy.cn,direct", GOSUMDB="off", GOFLAGS="-mod=mod")
|
||||
p = subprocess.run(["go", "run", "./dev/sigv4ref"], input="\n".join(payloads),
|
||||
capture_output=True, text=True, env=env, cwd=ROOT)
|
||||
if p.returncode != 0:
|
||||
sys.stderr.write(p.stderr[-2000:])
|
||||
raise SystemExit("go run 失败(检查 GOPROXY / Go 是否可用)")
|
||||
return [x for x in p.stdout.strip().splitlines() if x.strip()]
|
||||
|
||||
|
||||
def main():
|
||||
payloads = []
|
||||
for _, method, endpoint, extra, key, query in CASES:
|
||||
cfg, path = build(endpoint, extra, key)
|
||||
body = BODY_HASH if method == "PUT" else EMPTY
|
||||
url = cfg.url(path) + (("?" + serve.canonical_query(query)) if query else "")
|
||||
payloads.append("\t".join([method, url, cfg.region, body, TS]))
|
||||
|
||||
go = go_sign(payloads)
|
||||
if len(go) != len(CASES):
|
||||
raise SystemExit("返回条数不对:go=%d cases=%d" % (len(go), len(CASES)))
|
||||
|
||||
bad = 0
|
||||
for (desc, method, endpoint, extra, key, query), g in zip(CASES, go):
|
||||
cfg, path = build(endpoint, extra, key)
|
||||
body = BODY_HASH if method == "PUT" else EMPTY
|
||||
py = serve.sign_v4(cfg, method, path, body, TS, cfg.host, serve.canonical_query(query))
|
||||
if g.rsplit("Signature=", 1)[-1] == py.rsplit("Signature=", 1)[-1]:
|
||||
print("OK %s" % desc)
|
||||
else:
|
||||
bad += 1
|
||||
print("DIFF %s\n go %s\n py %s\n path %s" % (desc, g, py, path))
|
||||
|
||||
print("\n%d/%d 一致" % (len(CASES) - bad, len(CASES)))
|
||||
return 1 if bad else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,184 @@
|
||||
"""最小 S3 兼容假服务,只为调试生成器的「测试连接 / 上传素材」。
|
||||
|
||||
它不校验签名(签名正确性由 check_sigv4.py 与官方 SDK 对拍保证),只校验
|
||||
请求形态与权限分支,所以能用来演练:桶名写错、密钥写错、前缀为空、
|
||||
虚拟主机风格这些配置。
|
||||
|
||||
独立跑:python dev/theme-builder/mock_s3.py --port 9900
|
||||
被复用:from mock_s3 import MockS3, BUCKET
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
import threading
|
||||
import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
BUCKET = "tb-assets"
|
||||
|
||||
|
||||
class MockS3(BaseHTTPRequestHandler):
|
||||
store = {}
|
||||
requests = []
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
# ---- helpers ----
|
||||
def _split(self):
|
||||
path = urllib.parse.unquote(self.path.split("?")[0]).lstrip("/")
|
||||
bucket, _, key = path.partition("/")
|
||||
return bucket, key
|
||||
|
||||
def _key_id(self):
|
||||
# Authorization 形如
|
||||
# AWS4-HMAC-SHA256 Credential=AKID/20261021/us-east-1/s3/aws4_request, SignedHeaders=...
|
||||
# 第一个逗号前既有算法名又有 Credential,不能按逗号切完再比前缀。
|
||||
auth = self.headers.get("Authorization") or ""
|
||||
i = auth.find("Credential=")
|
||||
if i < 0:
|
||||
return ""
|
||||
return auth[i + len("Credential="):].split("/")[0]
|
||||
|
||||
def _body(self):
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
return self.rfile.read(n) if n else b""
|
||||
|
||||
def _xml(self, status, code, message):
|
||||
body = ("<?xml version=\"1.0\"?><Error><Code>%s</Code><Message>%s</Message></Error>"
|
||||
% (code, message)).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/xml")
|
||||
if self.command != "HEAD":
|
||||
# HEAD 不能有 body:真 S3 也只是返回状态行,客户端仍会按状态码报错。
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
if self.command != "HEAD":
|
||||
self.wfile.write(body)
|
||||
|
||||
def _check(self, bucket):
|
||||
MockS3.requests.append((self.command, self.path, dict(self.headers)))
|
||||
if self._key_id() == "BADKEY":
|
||||
self._xml(403, "SignatureDoesNotMatch",
|
||||
"The request signature we calculated does not match")
|
||||
return False
|
||||
if bucket != BUCKET:
|
||||
self._xml(404, "NoSuchBucket", "The specified bucket does not exist")
|
||||
return False
|
||||
if not (self.headers.get("x-amz-date") or "").endswith("Z"):
|
||||
self._xml(403, "AccessDenied", "missing x-amz-date")
|
||||
return False
|
||||
return True
|
||||
|
||||
# ---- verbs ----
|
||||
def do_HEAD(self):
|
||||
bucket, _ = self._split()
|
||||
if not self._check(bucket):
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
def do_PUT(self):
|
||||
bucket, key = self._split()
|
||||
data = self._body()
|
||||
if not self._check(bucket):
|
||||
return
|
||||
MockS3.store[key] = (data, self.headers.get("Content-Type"),
|
||||
self.headers.get("Cache-Control"))
|
||||
self.send_response(200)
|
||||
self.send_header("ETag", '"mock"')
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urllib.parse.urlsplit(self.path)
|
||||
qs = urllib.parse.parse_qs(parsed.query)
|
||||
bucket, key = self._split()
|
||||
if not key and qs.get("list-type") == ["2"]:
|
||||
if not self._check(bucket):
|
||||
return
|
||||
return self._list(qs.get("prefix", [""])[0],
|
||||
int(qs.get("max-keys", ["1000"])[0] or 1000),
|
||||
int(qs.get("continuation-token", ["0"])[0] or 0))
|
||||
MockS3.requests.append((self.command, self.path, dict(self.headers)))
|
||||
if key not in MockS3.store:
|
||||
self._xml(404, "NoSuchKey", "The specified key does not exist")
|
||||
return
|
||||
data, ctype, _ = MockS3.store[key]
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", ctype or "application/octet-stream")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _list(self, prefix, max_keys, offset):
|
||||
# 只实现 ListObjectsV2 里服务端用到的字段;分页用 offset 当 token,够测真实分页逻辑。
|
||||
keys = sorted(k for k in MockS3.store if k.startswith(prefix))
|
||||
page = keys[offset:offset + max_keys]
|
||||
truncated = offset + len(page) < len(keys)
|
||||
items = []
|
||||
for k in page:
|
||||
data = MockS3.store[k][0]
|
||||
items.append(
|
||||
"<Contents><Key>%s</Key><Size>%d</Size>"
|
||||
"<LastModified>2026-10-21T00:00:00.000Z</LastModified><ETag>"mock"</ETag></Contents>"
|
||||
% (k, len(data)))
|
||||
token = ("<NextContinuationToken>%d</NextContinuationToken>" % (offset + len(page))) if truncated else ""
|
||||
body = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
'<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">'
|
||||
"<Name>%s</Name><Prefix>%s</Prefix><KeyCount>%d</KeyCount>"
|
||||
"<MaxKeys>%d</MaxKeys><IsTruncated>%s</IsTruncated>%s%s"
|
||||
"</ListBucketResult>"
|
||||
% (BUCKET, prefix, len(page), max_keys, "true" if truncated else "false",
|
||||
token, "".join(items))
|
||||
).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/xml")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_DELETE(self):
|
||||
bucket, key = self._split()
|
||||
if not self._check(bucket):
|
||||
return
|
||||
MockS3.store.pop(key, None)
|
||||
self.send_response(204)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
|
||||
def spin(port=0, host="127.0.0.1"):
|
||||
"""起在后台线程,返回 (server, endpoint)。port=0 让系统挑空闲端口。
|
||||
|
||||
host 默认只给本机用;如果生成器是在别的设备上打开的(局域网访问),
|
||||
要把这里改成 0.0.0.0,并在面板里把 Endpoint 填成本机 IP。
|
||||
"""
|
||||
srv = ThreadingHTTPServer((host, port), MockS3)
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
shown = "127.0.0.1" if host in ("0.0.0.0", "::") else host
|
||||
return srv, "http://%s:%d" % (shown, srv.server_address[1])
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="TamaBox 主题生成器用的假 S3")
|
||||
ap.add_argument("--port", type=int, default=9900)
|
||||
ap.add_argument("--host", default="127.0.0.1",
|
||||
help="监听地址,默认 127.0.0.1;生成器在别的设备上打开时用 0.0.0.0")
|
||||
args = ap.parse_args()
|
||||
srv, endpoint = spin(args.port, args.host)
|
||||
print("假 S3 已启动:%s" % endpoint)
|
||||
print(" 桶名 %s,Access Key 随意(写 BADKEY 会返回 SignatureDoesNotMatch)" % BUCKET)
|
||||
if args.host in ("0.0.0.0", "::"):
|
||||
print(" 局域网设备访问时,面板里的 Endpoint 填 http://<本机IP>:%d" % args.port)
|
||||
print("Ctrl+C 停止")
|
||||
try:
|
||||
threading.Event().wait()
|
||||
except KeyboardInterrupt:
|
||||
srv.shutdown()
|
||||
print("\n已停止")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,152 @@
|
||||
"""上传链路自测:起一个假 S3,跑一遍「测试连接 → 上传 → 读回」。
|
||||
|
||||
覆盖 serve.py 里真正容易错、又不能靠对拍发现的部分:
|
||||
· 桶级 HEAD 与对象级 PUT/GET/DELETE 的路径拼接(path style / 虚拟主机)
|
||||
· 前缀拼接、Content-Type、immutable 缓存头
|
||||
· 4xx 时能否把 S3 的 <Code>/<Message> 还原成人话(含 hint)
|
||||
签名数学由 check_sigv4.py 与官方 SDK 对拍保证,这里不重复。
|
||||
|
||||
用法(项目根):
|
||||
python dev/theme-builder/selftest_upload.py
|
||||
"""
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
import serve # noqa: E402
|
||||
from mock_s3 import BUCKET, MockS3 # noqa: E402
|
||||
|
||||
|
||||
def spin():
|
||||
import mock_s3
|
||||
return mock_s3.spin()
|
||||
|
||||
|
||||
FAILS = []
|
||||
|
||||
|
||||
def check(desc, cond, extra=""):
|
||||
print(("OK " if cond else "FAIL ") + desc + ((" ← " + extra) if extra and not cond else ""))
|
||||
if not cond:
|
||||
FAILS.append(desc)
|
||||
|
||||
|
||||
def main():
|
||||
srv, endpoint = spin()
|
||||
base = dict(endpoint=endpoint, bucket=BUCKET, prefix="themes", region="us-east-1",
|
||||
accessKey="AKID", secretKey="SECRET")
|
||||
|
||||
# 1) 测试连接:4 步应全过,探针最后被删掉
|
||||
cfg = serve.S3Config(dict(base))
|
||||
res = serve.test_connection(cfg)
|
||||
check("测试连接返回 ok", res.get("ok") is True)
|
||||
check("测试连接 4 步全过", all(s["ok"] for s in res["steps"]), str(res))
|
||||
check("探针已清理", not any(k.startswith("themes/.tb-probe") for k in MockS3.store),
|
||||
str(list(MockS3.store)))
|
||||
|
||||
# 2) 上传:前缀 / 内容 / 头都要对
|
||||
f1 = b"\x52\x49\x46\x46fakewebp1"
|
||||
f2 = b"\x52\x49\x46\x46fakewebp2"
|
||||
up = serve.upload_files(cfg, [
|
||||
{"key": "shiroko/asset1.webp", "contentType": "image/webp",
|
||||
"data": base64.b64encode(f1).decode()},
|
||||
{"key": "shiroko/asset2.webp", "contentType": "image/webp",
|
||||
"data": base64.b64encode(f2).decode()},
|
||||
])
|
||||
check("上传全部成功", up["ok"] and up["total"] == 2 and up["failed"] == 0, str(up))
|
||||
check("前缀拼进对象键", "themes/shiroko/asset1.webp" in MockS3.store, str(list(MockS3.store)))
|
||||
stored, ctype, cache = MockS3.store.get("themes/shiroko/asset1.webp", (b"", None, None))
|
||||
check("内容一致", stored == f1)
|
||||
check("Content-Type = image/webp", ctype == "image/webp", str(ctype))
|
||||
check("Cache-Control = immutable", "immutable" in (cache or ""), str(cache))
|
||||
|
||||
# 2b) 逐文件覆盖缓存头:README/清单这类「键不变、内容会变」的文件不能 immutable
|
||||
serve.upload_files(cfg, [
|
||||
{"key": "shiroko/INSTALL.txt", "contentType": "text/plain; charset=utf-8",
|
||||
"cacheControl": "public, max-age=60",
|
||||
"data": base64.b64encode(b"readme").decode()},
|
||||
])
|
||||
_, _, cc = MockS3.store.get("themes/shiroko/INSTALL.txt", (b"", None, None))
|
||||
check("可覆盖 Cache-Control", cc == "public, max-age=60", str(cc))
|
||||
# 这一步只为验头,别让它污染后面的对象计数
|
||||
MockS3.store.pop("themes/shiroko/INSTALL.txt", None)
|
||||
|
||||
# 3) 错误密钥:HEAD 拿不到响应体(规范如此),只能按状态码归因;
|
||||
# 真正能读到 S3 错误码的是带 body 的 PUT,所以用上传路径验证还原能力。
|
||||
bad = serve.S3Config(dict(base, accessKey="BADKEY"))
|
||||
try:
|
||||
serve.test_connection(bad)
|
||||
check("错误密钥应抛错", False)
|
||||
except serve.S3Error as e:
|
||||
check("HEAD 失败按状态码归因", e.status == 403 and e.code == "AccessDenied",
|
||||
"%s %s" % (e.status, e.code))
|
||||
|
||||
badUp = serve.upload_files(bad, [{"key": "shiroko/x.webp", "contentType": "image/webp",
|
||||
"data": base64.b64encode(b"x").decode()}])
|
||||
codes = [f.get("code") for f in badUp["files"]]
|
||||
check("PUT 失败能还原 SignatureDoesNotMatch", codes == ["SignatureDoesNotMatch"], str(codes))
|
||||
check("该错误有中文提示", "签名不匹配" in serve.S3_HINTS.get("SignatureDoesNotMatch", ""))
|
||||
|
||||
# 4) 桶名写错:NoSuchBucket
|
||||
wrong = serve.S3Config(dict(base, bucket="nope"))
|
||||
try:
|
||||
serve.test_connection(wrong)
|
||||
check("错桶应抛错", False)
|
||||
except serve.S3Error as e:
|
||||
check("错桶 → NoSuchBucket", e.code == "NoSuchBucket", e.code)
|
||||
|
||||
# 5) 键里出现 .. 必须被拒(防止越权写到别的前缀)
|
||||
try:
|
||||
serve.full_key(cfg, "../etc/passwd")
|
||||
check(".. 应被拒", False)
|
||||
except ValueError:
|
||||
check(".. 被拒绝", True)
|
||||
|
||||
# 6) 前缀留空 = 传桶根
|
||||
plain = serve.S3Config(dict(base, prefix=""))
|
||||
up2 = serve.upload_files(plain, [{"key": "root.webp", "contentType": "image/webp",
|
||||
"data": base64.b64encode(b"x").decode()}])
|
||||
check("空前缀传桶根", up2["ok"] and "root.webp" in MockS3.store, str(list(MockS3.store)))
|
||||
|
||||
# 7) 列远端:前缀过滤、大小、分页
|
||||
serve.upload_files(cfg, [{"key": "shiroko/asset3.webp", "contentType": "image/webp",
|
||||
"data": base64.b64encode(b"three").decode()}])
|
||||
ls = serve.list_objects(cfg, "shiroko/")
|
||||
keys = sorted(o["key"] for o in ls["objects"])
|
||||
check("按 <前缀>/<slug>/ 列出 3 个对象",
|
||||
keys == ["themes/shiroko/asset1.webp", "themes/shiroko/asset2.webp",
|
||||
"themes/shiroko/asset3.webp"], str(keys))
|
||||
size1 = [o["size"] for o in ls["objects"] if o["key"].endswith("asset1.webp")]
|
||||
check("对象大小与上传字节一致", size1 == [len(f1)], str(size1))
|
||||
paged = serve.list_objects(cfg, "shiroko/", max_keys=2)
|
||||
check("分页把 3 个都取回来了", len(paged["objects"]) == 3, str(len(paged["objects"])))
|
||||
everything = serve.list_objects(cfg, "")
|
||||
# 空前缀 = 列「路径前缀」之下(跨主题),不扫整个桶:桶里还可能有头像等别的东西。
|
||||
check("空前缀列出 <路径前缀>/ 下全部、且不越界",
|
||||
all(o["key"].startswith("themes/") for o in everything["objects"])
|
||||
and len(everything["objects"]) == 3,
|
||||
str([o["key"] for o in everything["objects"]]))
|
||||
|
||||
# 8) 删除:幂等(不存在的键也当成功),且不影响别的前缀
|
||||
del_res = serve.delete_objects(cfg, ["shiroko/asset3.webp", "shiroko/nonexistent.webp"])
|
||||
check("删除含不存在的键仍算成功", del_res["ok"] and del_res["total"] == 2, str(del_res))
|
||||
after = serve.list_objects(cfg, "shiroko/")
|
||||
check("删完只剩 2 个", len(after["objects"]) == 2,
|
||||
str([o["key"] for o in after["objects"]]))
|
||||
check("桶根对象没被误删", "root.webp" in MockS3.store)
|
||||
|
||||
# 9) 虚拟主机风格的路径(不带桶名)
|
||||
vh = serve.S3Config(dict(base, pathStyle=False))
|
||||
check("虚拟主机路径不含桶名", vh.object_path("themes/a.webp") == "/themes/a.webp", vh.object_path("x"))
|
||||
check("虚拟主机 host 带桶名", vh.host.startswith(BUCKET + "."), vh.host)
|
||||
|
||||
srv.shutdown()
|
||||
print("\n%d 项失败" % len(FAILS))
|
||||
return 1 if FAILS else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,590 @@
|
||||
#!/usr/bin/env python3
|
||||
"""主题生成器的本地预览服务 + 素材上传代理。
|
||||
|
||||
为什么必须起服务:生成器不再自己画一个"仿制页面",而是把项目真实渲染出来的
|
||||
页面(outputs/preview-*.html)放进 iframe 当画板,再往里注入正在编辑的主题 CSS。
|
||||
file:// 下浏览器不允许父页面访问 iframe 的 DOM(即使同目录),预览就只能退回示意图。
|
||||
起一个本地 HTTP 服务后父子同源,拖拽、注入、实时改令牌都能正常工作。
|
||||
|
||||
为什么上传也走这个服务:浏览器直连对象存储要配 CORS、还要把密钥交给页面里的
|
||||
fetch,各家 S3 兼容实现的 CORS 配置界面又都不一样。放到本机 Python 里代发,
|
||||
生成器只跟 127.0.0.1 说话:不碰 CORS,密钥不落盘、不进工程文件、不进导出 zip。
|
||||
|
||||
用法(在项目根目录执行):
|
||||
|
||||
python dev/theme-builder/serve.py # 默认 0.0.0.0:8777,同局域网可访问
|
||||
python dev/theme-builder/serve.py --host 127.0.0.1 # 只给本机(公共网络用这个)
|
||||
python -m http.server 8777 # 只有静态预览,没有上传能力
|
||||
|
||||
启动时终端会列出本机与各网卡的地址,照着打开即可。
|
||||
|
||||
这个脚本只读文件,不写任何东西,也不改项目配置。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import datetime
|
||||
import functools
|
||||
import hashlib
|
||||
import hmac
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import socketserver
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
# dev/theme-builder/serve.py → 上两级就是项目根
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# 单次请求体上限:一次上传十几张 1600px 的 WebP,base64 后约 1.4 倍体积,
|
||||
# 64MB 足够,同时挡住误连到公网时的内存放大。
|
||||
MAX_BODY_BYTES = 64 * 1024 * 1024
|
||||
|
||||
EMPTY_SHA256 = hashlib.sha256(b"").hexdigest()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SigV4 签名
|
||||
# ---------------------------------------------------------------------------
|
||||
# 不用 boto3:这个脚本的定位是「零依赖、拷走就能跑」,装一个 SDK 只为签四个请求
|
||||
# 不划算。下面是 AWS 文档里 SigV4 for S3 的最小实现,覆盖 PUT / GET / DELETE / HEAD。
|
||||
|
||||
def _hmac(key: bytes, msg: str) -> bytes:
|
||||
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
||||
|
||||
|
||||
def _sha256(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def _quote_key(key: str) -> str:
|
||||
"""按 AWS 规则编码对象键:保留 `/`,其余非 unreserved 字符全部百分号编码。
|
||||
|
||||
用 quote(safe='/-_.~') 而不是逐段 quote:两者等价,但后者容易漏掉空段
|
||||
(`a//b`)导致的路径塌缩。S3 的签名 URI 必须与请求行完全一致,差一个字符
|
||||
就是 SignatureDoesNotMatch。
|
||||
"""
|
||||
return urllib.parse.quote(key, safe="/-_.~")
|
||||
|
||||
|
||||
def _quote_query(s):
|
||||
"""query 的编码规则比 path 更严:`/` 也要编码成 %2F,只有 A-Za-z0-9-_.~ 原样保留。"""
|
||||
return urllib.parse.quote(str(s), safe="-_.~")
|
||||
|
||||
|
||||
def canonical_query(params):
|
||||
"""按 AWS 规则拼 canonical query:先按键排序,同名多值再按值排序,逐项编码。
|
||||
|
||||
ListObjectsV2 的 `list-type` / `prefix` / `continuation-token` 全走这里。
|
||||
顺序错、或漏了编码都是 SignatureDoesNotMatch —— 而 S3 不会告诉你差在哪。
|
||||
"""
|
||||
pairs = []
|
||||
for k, v in (params or {}).items():
|
||||
for one in (v if isinstance(v, (list, tuple)) else [v]):
|
||||
pairs.append((_quote_query(k), _quote_query(one)))
|
||||
pairs.sort()
|
||||
return "&".join("%s=%s" % kv for kv in pairs)
|
||||
|
||||
|
||||
def sign_v4(cfg, method, canonical_path, payload_hash, amz_date, host, query=""):
|
||||
region = cfg.region
|
||||
date = amz_date[:8]
|
||||
signed_headers = "host;x-amz-content-sha256;x-amz-date"
|
||||
canonical_headers = (
|
||||
"host:" + host + "\n"
|
||||
"x-amz-content-sha256:" + payload_hash + "\n"
|
||||
"x-amz-date:" + amz_date + "\n"
|
||||
)
|
||||
# CanonicalHeaders 自身每行以 \n 结尾,与 SignedHeaders 之间因此有一个空行,
|
||||
# 这是规范要求的形态,不是笔误。
|
||||
canonical_request = "\n".join([
|
||||
method, canonical_path, query or "", canonical_headers, signed_headers, payload_hash,
|
||||
])
|
||||
scope = date + "/" + region + "/s3/aws4_request"
|
||||
string_to_sign = "\n".join([
|
||||
"AWS4-HMAC-SHA256", amz_date, scope, _sha256(canonical_request.encode("utf-8")),
|
||||
])
|
||||
|
||||
k = _hmac(("AWS4" + cfg.secret_key).encode("utf-8"), date)
|
||||
k = _hmac(k, region)
|
||||
k = _hmac(k, "s3")
|
||||
k = _hmac(k, "aws4_request")
|
||||
signature = hmac.new(k, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
return (
|
||||
"AWS4-HMAC-SHA256 Credential=" + cfg.access_key + "/" + scope
|
||||
+ ", SignedHeaders=" + signed_headers + ", Signature=" + signature
|
||||
)
|
||||
|
||||
|
||||
class S3Error(Exception):
|
||||
def __init__(self, step, status, code, message):
|
||||
Exception.__init__(self, message or code or ("HTTP %s" % status))
|
||||
self.step = step
|
||||
self.status = status
|
||||
self.code = code
|
||||
self.message = message or ""
|
||||
|
||||
|
||||
# 常见错误 → 人话。签名失败是最常见的,原因几乎总是密钥/region/时钟三者之一。
|
||||
S3_HINTS = {
|
||||
"SignatureDoesNotMatch": "签名不匹配:核对 Secret Key 是否复制完整、Region 是否与桶一致(R2 填 auto),以及本机时间偏差是否超过 15 分钟。",
|
||||
"InvalidAccessKeyId": "Access Key 无效:核对 ID,或该密钥没有这个桶的权限。",
|
||||
"AccessDenied": "权限不足:这个密钥没有写权限,或桶策略禁止了该操作。",
|
||||
"NoSuchBucket": "桶不存在:核对桶名与 Endpoint 是否属于同一账号/区域。",
|
||||
"PermanentRedirect": "Endpoint 与桶所在区域不匹配:换对该区域的 Endpoint(或改用虚拟主机风格)。",
|
||||
"AuthorizationHeaderMalformed": "Region 写错了:填桶实际所在区域(R2 用 auto,MinIO 常为 us-east-1)。",
|
||||
"InvalidBucketName": "桶名不合法:S3 桶名只允许小写字母、数字、点和中划线。",
|
||||
"RequestTimeTooSkewed": "本机时间与服务器偏差过大:校准系统时间后重试。",
|
||||
}
|
||||
|
||||
|
||||
def _parse_s3_error(body):
|
||||
text = body[:2000].decode("utf-8", "replace")
|
||||
code = re.search(r"<Code>([^<]+)</Code>", text)
|
||||
msg = re.search(r"<Message>([^<]+)</Message>", text)
|
||||
return (code.group(1).strip() if code else ""), (msg.group(1).strip() if msg else text.strip()[:200])
|
||||
|
||||
|
||||
class S3Config:
|
||||
"""一次上传用到的全部参数。只活在内存里,不落盘。"""
|
||||
|
||||
def __init__(self, d):
|
||||
ep = str(d.get("endpoint") or "").strip()
|
||||
if not ep:
|
||||
raise ValueError("Endpoint 没填")
|
||||
if "://" not in ep:
|
||||
ep = "https://" + ep
|
||||
u = urllib.parse.urlsplit(ep)
|
||||
if u.scheme not in ("http", "https"):
|
||||
raise ValueError("Endpoint 只支持 http / https")
|
||||
if not u.hostname:
|
||||
raise ValueError("Endpoint 里没有主机名")
|
||||
# 路径部分一律忽略:Endpoint 只表达「服务在哪」,桶和键由字段决定。
|
||||
self.scheme = u.scheme
|
||||
self.port = u.port
|
||||
self.host_only = u.hostname
|
||||
self.region = str(d.get("region") or "").strip() or "us-east-1"
|
||||
self.bucket = str(d.get("bucket") or "").strip()
|
||||
if not self.bucket:
|
||||
raise ValueError("桶名没填")
|
||||
self.prefix = str(d.get("prefix") or "").strip().strip("/")
|
||||
self.access_key = str(d.get("accessKey") or "").strip()
|
||||
self.secret_key = str(d.get("secretKey") or "").strip()
|
||||
if not self.access_key or not self.secret_key:
|
||||
raise ValueError("Access Key / Secret Key 没填")
|
||||
self.path_style = bool(d.get("pathStyle", True))
|
||||
|
||||
@property
|
||||
def host(self):
|
||||
if self.path_style:
|
||||
return self.host_only if not self.port else "%s:%d" % (self.host_only, self.port)
|
||||
return "%s.%s" % (self.bucket, self.host_only) + (":%d" % self.port if self.port else "")
|
||||
|
||||
def object_path(self, key):
|
||||
"""S3 的签名 URI 必须与请求行逐字一致,所以两者共用这一个函数。"""
|
||||
path = ("/" + self.bucket + "/" + key) if self.path_style else ("/" + key)
|
||||
return _quote_key(path)
|
||||
|
||||
def url(self, path):
|
||||
return "%s://%s%s" % (self.scheme, self.host, path)
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.datetime.now(datetime.timezone.utc)
|
||||
|
||||
|
||||
def s3_request(cfg, method, key="", body=b"", content_type=None, cache_control=None,
|
||||
query=None, timeout=25):
|
||||
"""发一个签名请求。key 为空表示桶级操作(HEAD bucket / ListObjectsV2)。
|
||||
|
||||
返回 (status, headers, body);4xx/5xx 不抛异常,交由调用方判断 —— 测试连接
|
||||
需要把「403 是权限问题」和「连不上是网络问题」区分开讲。
|
||||
"""
|
||||
if key:
|
||||
path = cfg.object_path(key)
|
||||
else:
|
||||
path = ("/" + cfg.bucket) if cfg.path_style else "/"
|
||||
qs = canonical_query(query)
|
||||
payload_hash = _sha256(body) if body else EMPTY_SHA256
|
||||
amz_date = _now().strftime("%Y%m%dT%H%M%SZ")
|
||||
auth = sign_v4(cfg, method, path, payload_hash, amz_date, cfg.host, qs)
|
||||
|
||||
url = cfg.url(path) + (("?" + qs) if qs else "")
|
||||
req = urllib.request.Request(url, data=body if body else None, method=method)
|
||||
req.add_header("x-amz-content-sha256", payload_hash)
|
||||
req.add_header("x-amz-date", amz_date)
|
||||
req.add_header("Authorization", auth)
|
||||
if content_type:
|
||||
req.add_header("Content-Type", content_type)
|
||||
if cache_control:
|
||||
req.add_header("Cache-Control", cache_control)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return r.status, dict(r.headers), r.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, dict(e.headers), e.read()
|
||||
except Exception as e: # URLError / timeout / SSL 错误
|
||||
raise S3Error("connect", 0, "NetworkError", "%s(%s)" % (e, cfg.host))
|
||||
|
||||
|
||||
def full_key(cfg, key):
|
||||
key = str(key or "").strip().lstrip("/")
|
||||
if not key:
|
||||
raise ValueError("对象键为空")
|
||||
if ".." in key.split("/") or "\\" in key:
|
||||
raise ValueError("对象键不能包含 .. 或反斜杠:%s" % key)
|
||||
return (cfg.prefix + "/" + key) if cfg.prefix else key
|
||||
|
||||
|
||||
def probe_key(cfg):
|
||||
stamp = _now().strftime("%Y%m%dT%H%M%S")
|
||||
return full_key(cfg, ".tb-probe-%s.txt" % stamp)
|
||||
|
||||
|
||||
def _public_read(url, timeout=10):
|
||||
"""匿名 GET 一次,判断「传上去的图能不能直接被浏览器读到」。
|
||||
|
||||
这一步不是必须的(很多人会用自定义域/CDN),但它能把最常见的坑当场暴露:
|
||||
桶是私有的,上传全成功、页面里全是 403。
|
||||
"""
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as r:
|
||||
return r.status == 200
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def test_connection(cfg):
|
||||
steps = []
|
||||
|
||||
status, _, body = s3_request(cfg, "HEAD")
|
||||
if status >= 400:
|
||||
code, msg = _parse_s3_error(body)
|
||||
if not code:
|
||||
# HEAD 响应按规范没有 body,S3 的错误码拿不到。只能按状态码归因,
|
||||
# 否则前端只能显示一句「HTTP 403」,用户完全不知道该改哪。
|
||||
code = {403: "AccessDenied", 404: "NoSuchBucket",
|
||||
301: "PermanentRedirect", 307: "PermanentRedirect",
|
||||
400: "BadRequest"}.get(status, "HTTP%d" % status)
|
||||
msg = "桶级探测失败(HEAD 无响应体,按状态码判断)"
|
||||
raise S3Error("HeadBucket", status, code, msg)
|
||||
steps.append({"name": "连接与凭据", "ok": True, "detail": "桶可访问(HEAD %s)" % cfg.bucket})
|
||||
|
||||
key = probe_key(cfg)
|
||||
payload = b"TamaBox theme builder probe\n"
|
||||
status, _, body = s3_request(cfg, "PUT", key, payload, "text/plain; charset=utf-8", "no-store")
|
||||
if status >= 400:
|
||||
code, msg = _parse_s3_error(body)
|
||||
raise S3Error("PutObject", status, code, msg)
|
||||
steps.append({"name": "写入权限", "ok": True, "detail": "已写入探针 %s" % key})
|
||||
|
||||
status, _, body = s3_request(cfg, "GET", key)
|
||||
ok = status == 200 and body == payload
|
||||
steps.append({
|
||||
"name": "读回校验", "ok": ok,
|
||||
"detail": "读回一致" if ok else "读回失败(HTTP %s)" % status,
|
||||
})
|
||||
|
||||
public = _public_read(cfg.url(cfg.object_path(key)))
|
||||
steps.append({
|
||||
"name": "匿名可读", "ok": True,
|
||||
"detail": "该路径可直接匿名访问" if public
|
||||
else "匿名访问被拒 —— 桶没开公开读时正常,靠自定义域或 CDN 分发即可",
|
||||
"warn": not public,
|
||||
})
|
||||
|
||||
s3_request(cfg, "DELETE", key)
|
||||
return {"ok": True, "steps": steps, "key": key}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 上传
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def upload_files(cfg, files):
|
||||
if not files:
|
||||
raise ValueError("没有要上传的素材")
|
||||
if len(files) > 200:
|
||||
raise ValueError("一次最多 200 个文件")
|
||||
|
||||
results = []
|
||||
for f in files:
|
||||
name = str(f.get("name") or f.get("key") or "?")
|
||||
try:
|
||||
key = full_key(cfg, f.get("key") or f.get("name"))
|
||||
data = base64.b64decode(f.get("data") or "", validate=False)
|
||||
if not data:
|
||||
raise ValueError("内容为空")
|
||||
ctype = str(f.get("contentType") or "application/octet-stream")
|
||||
# 素材键里带内容版本(导出时由 id 决定),内容不会原地变化 → immutable。
|
||||
# 但清单/说明这类「键不变、内容会变」的文件不能 immutable,否则一年内看不到更新,
|
||||
# 所以允许逐个文件覆盖。
|
||||
cache = str(f.get("cacheControl") or "public, max-age=31536000, immutable")
|
||||
status, _, body = s3_request(cfg, "PUT", key, data, ctype, cache)
|
||||
if status >= 400:
|
||||
code, msg = _parse_s3_error(body)
|
||||
results.append({"key": key, "name": name, "ok": False,
|
||||
"status": status, "code": code, "error": msg})
|
||||
else:
|
||||
results.append({"key": key, "name": name, "ok": True, "bytes": len(data)})
|
||||
except Exception as e:
|
||||
results.append({"key": name, "name": name, "ok": False, "error": str(e)})
|
||||
|
||||
failed = [r for r in results if not r["ok"]]
|
||||
return {"ok": not failed, "files": results, "failed": len(failed), "total": len(results)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 远端清单 / 删除
|
||||
# ---------------------------------------------------------------------------
|
||||
def _local(tag):
|
||||
"""去掉 XML 命名空间。各家 S3 兼容实现都带 `http://s3.amazonaws.com/doc/2006-03-01/`,
|
||||
个别(老 MinIO / 某些网关)又不带 —— 只比 localname 两边都能认。"""
|
||||
return tag.rsplit("}", 1)[-1]
|
||||
|
||||
|
||||
def parse_list_xml(body):
|
||||
"""解析 ListObjectsV2 响应。返回 (对象数组, 下一页 token 或 None)。"""
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
root = ET.fromstring(body)
|
||||
objects, token, truncated = [], None, False
|
||||
for node in root:
|
||||
name = _local(node.tag)
|
||||
if name == "Contents":
|
||||
row = {}
|
||||
for f in node:
|
||||
row[_local(f.tag)] = f.text
|
||||
objects.append({
|
||||
"key": row.get("Key") or "",
|
||||
"size": int(row.get("Size") or 0),
|
||||
"modified": row.get("LastModified") or "",
|
||||
"etag": (row.get("ETag") or "").strip('"'),
|
||||
})
|
||||
elif name == "NextContinuationToken":
|
||||
token = node.text
|
||||
elif name == "IsTruncated":
|
||||
truncated = (node.text or "").strip().lower() == "true"
|
||||
return objects, (token if truncated else None)
|
||||
|
||||
|
||||
def list_objects(cfg, prefix="", max_pages=20, max_keys=1000):
|
||||
"""列 `<cfg.prefix>/<prefix>` 下所有对象。分页上限只是防呆,正常一个主题不到 100 个。"""
|
||||
key_prefix = full_key(cfg, prefix) if str(prefix or "").strip() else (cfg.prefix + "/" if cfg.prefix else "")
|
||||
|
||||
objects, token, pages = [], None, 0
|
||||
while pages < max_pages:
|
||||
pages += 1
|
||||
query = {"list-type": "2", "max-keys": str(max_keys)}
|
||||
if key_prefix:
|
||||
query["prefix"] = key_prefix
|
||||
if token:
|
||||
query["continuation-token"] = token
|
||||
status, _, body = s3_request(cfg, "GET", query=query)
|
||||
if status >= 400:
|
||||
code, msg = _parse_s3_error(body)
|
||||
raise S3Error("ListObjects", status, code or "HTTP%d" % status, msg)
|
||||
page, token = parse_list_xml(body)
|
||||
objects.extend(page)
|
||||
if not token:
|
||||
break
|
||||
return {"ok": True, "prefix": key_prefix, "objects": objects, "truncated": bool(token)}
|
||||
|
||||
|
||||
def delete_objects(cfg, keys):
|
||||
"""逐个 DeleteObject。
|
||||
|
||||
没用批量 POST ?delete:那要额外算 Content-MD5、拼 XML body,而一次最多几十个键,
|
||||
省下的往返抵不上多一条签名路径的风险。S3 的 DELETE 本身幂等(不存在的键也回 204)。
|
||||
"""
|
||||
if not keys:
|
||||
raise ValueError("没有要删除的对象")
|
||||
if len(keys) > 500:
|
||||
raise ValueError("一次最多删 500 个对象")
|
||||
|
||||
results = []
|
||||
for k in keys:
|
||||
try:
|
||||
key = full_key(cfg, k)
|
||||
status, _, body = s3_request(cfg, "DELETE", key)
|
||||
if status >= 400 and status != 404:
|
||||
code, msg = _parse_s3_error(body)
|
||||
results.append({"key": key, "ok": False, "status": status,
|
||||
"code": code or "HTTP%d" % status, "error": msg})
|
||||
else:
|
||||
results.append({"key": key, "ok": True})
|
||||
except Exception as e:
|
||||
results.append({"key": str(k), "ok": False, "error": str(e)})
|
||||
|
||||
failed = [r for r in results if not r["ok"]]
|
||||
return {"ok": not failed, "files": results, "failed": len(failed), "total": len(results)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP 层
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
server_version = "TamaBoxThemeBuilder/1"
|
||||
|
||||
def end_headers(self):
|
||||
# 预览页是刚由 go test 生成的,禁止缓存,否则改了 CSS 看不到变化
|
||||
self.send_header("Cache-Control", "no-store, must-revalidate")
|
||||
super().end_headers()
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
# 只报 4xx/5xx,正常请求不刷屏
|
||||
status = args[1] if len(args) > 1 else ""
|
||||
if str(status).startswith(("4", "5")):
|
||||
sys.stderr.write(" %s\n" % (fmt % args))
|
||||
|
||||
def _json(self, payload, status=200):
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _read_json(self):
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
if n <= 0:
|
||||
raise ValueError("请求体为空")
|
||||
if n > MAX_BODY_BYTES:
|
||||
raise ValueError("请求体过大(%d 字节),一次别传太多素材" % n)
|
||||
return json.loads(self.rfile.read(n).decode("utf-8"))
|
||||
|
||||
def _same_origin(self):
|
||||
"""写接口只接受「页面自己发来的」请求。
|
||||
|
||||
监听 0.0.0.0 之后,同网段里的另一个网页可以借访客浏览器向这里发写请求
|
||||
(删对象、改前缀)。浏览器跨站请求一定带 Origin,比对它与 Host 是否同源
|
||||
就能挡掉这类「借刀」调用。curl / 脚本不带 Origin,照常放行。
|
||||
"""
|
||||
origin = self.headers.get("Origin")
|
||||
if not origin:
|
||||
return True
|
||||
host = (self.headers.get("Host") or "").strip().lower()
|
||||
return urllib.parse.urlsplit(origin).netloc.lower() == host
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.split("?")[0] == "/api/s3/ping":
|
||||
# 生成器靠它判断「当前是不是 serve.py 起的」——python -m http.server 没有这个端点。
|
||||
return self._json({"ok": True, "upload": True})
|
||||
return super().do_GET()
|
||||
|
||||
def do_POST(self):
|
||||
route = self.path.split("?")[0]
|
||||
if route not in ("/api/s3/test", "/api/s3/upload", "/api/s3/list", "/api/s3/delete"):
|
||||
return self._json({"ok": False, "error": "未知接口 %s" % route}, 404)
|
||||
if not self._same_origin():
|
||||
return self._json({"ok": False, "error": "拒绝跨站写请求(Origin 与访问地址不一致)"}, 403)
|
||||
try:
|
||||
payload = self._read_json()
|
||||
except Exception as e:
|
||||
return self._json({"ok": False, "error": "请求解析失败:%s" % e})
|
||||
|
||||
try:
|
||||
cfg = S3Config(payload.get("config") or {})
|
||||
except ValueError as e:
|
||||
return self._json({"ok": False, "error": str(e)})
|
||||
|
||||
try:
|
||||
if route == "/api/s3/test":
|
||||
return self._json(test_connection(cfg))
|
||||
if route == "/api/s3/list":
|
||||
return self._json(list_objects(cfg, payload.get("prefix") or ""))
|
||||
if route == "/api/s3/delete":
|
||||
return self._json(delete_objects(cfg, payload.get("keys") or []))
|
||||
return self._json(upload_files(cfg, payload.get("files") or []))
|
||||
except S3Error as e:
|
||||
return self._json({
|
||||
"ok": False, "step": e.step, "status": e.status, "code": e.code,
|
||||
"error": e.message, "hint": S3_HINTS.get(e.code, ""),
|
||||
})
|
||||
except ValueError as e:
|
||||
return self._json({"ok": False, "error": str(e)})
|
||||
except Exception as e: # 兜底:别把本地服务弄崩,把异常原样告诉页面
|
||||
return self._json({"ok": False, "error": "%s: %s" % (type(e).__name__, e)})
|
||||
|
||||
|
||||
def local_addresses():
|
||||
"""本机所有可用于访问的 IPv4(回环排最后)。
|
||||
|
||||
绑 0.0.0.0 时监听地址本身不能直接点开(浏览器访问 http://0.0.0.0 行为各家不一),
|
||||
所以启动时把真实 IP 列出来,省得人去 ipconfig 里翻。
|
||||
"""
|
||||
out = []
|
||||
try:
|
||||
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
|
||||
ip = info[4][0]
|
||||
if ip not in out and not ip.startswith("127."):
|
||||
out.append(ip)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
# 不联网也会返回默认路由网卡的地址;这一步能补上 getaddrinfo 漏掉的情况
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
if ip and not ip.startswith("127.") and ip not in out:
|
||||
out.append(ip)
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="TamaBox 主题生成器的本地预览服务")
|
||||
ap.add_argument("--port", type=int, default=8777, help="端口,默认 8777")
|
||||
ap.add_argument("--host", default="0.0.0.0",
|
||||
help="监听地址,默认 0.0.0.0(同一局域网都能打开);只给本机用就写 127.0.0.1")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not os.path.isdir(os.path.join(PROJECT_ROOT, "outputs")):
|
||||
sys.stderr.write(
|
||||
"还没生成预览快照。先在项目根执行:\n"
|
||||
" TEST_PREVIEW=1 go test ./templates -run TestGenPreview\n"
|
||||
)
|
||||
|
||||
wildcard = args.host in ("0.0.0.0", "::")
|
||||
if not wildcard and args.host not in ("127.0.0.1", "localhost", "::1"):
|
||||
sys.stderr.write("⚠️ 只监听 %s,仅该地址可达。\n" % args.host)
|
||||
|
||||
handler = functools.partial(Handler, directory=PROJECT_ROOT)
|
||||
socketserver.ThreadingTCPServer.allow_reuse_address = True
|
||||
# Threading:上传一张几 MB 的图会占住连接,单线程时 iframe 预览和其它请求都得排队。
|
||||
with socketserver.ThreadingTCPServer((args.host, args.port), handler) as httpd:
|
||||
httpd.daemon_threads = True
|
||||
print("项目根:%s" % PROJECT_ROOT)
|
||||
if wildcard:
|
||||
for ip in local_addresses():
|
||||
print("生成器:http://%s:%d/dev/theme-builder/" % (ip, args.port))
|
||||
print("生成器:http://127.0.0.1:%d/dev/theme-builder/ (本机)" % args.port)
|
||||
print("提示:局域网里的其它设备用上面的 IP 地址打开;手机与电脑要在同一网段。")
|
||||
sys.stderr.write(
|
||||
"⚠️ 正在对局域网开放(%s:%d)。上传接口没有账号体系 —— 能打开这个端口的人,\n"
|
||||
" 都能用你填的密钥往对象存储写 / 删文件。公共网络(咖啡馆、宿舍、公司网)\n"
|
||||
" 请改用 --host 127.0.0.1。\n" % (args.host, args.port))
|
||||
else:
|
||||
print("生成器:http://%s:%d/dev/theme-builder/" % (args.host, args.port))
|
||||
print("上传接口:/api/s3/test /api/s3/upload /api/s3/list /api/s3/delete")
|
||||
print("Ctrl+C 停止")
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\n已停止")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,219 @@
|
||||
"""把 new-sakura 的内联 SVG 导出成位图,并上传到对象存储。
|
||||
|
||||
为什么要这一步:主题本身是纯 CSS(SVG 内联在 CSS 里),线上不依赖任何外部素材。
|
||||
但对象存储上留一份位图有它的用处:
|
||||
· 桶开了公开域名后,可以把 CSS 里的 url() 换成绝对地址(注意:服务端有 gzip,
|
||||
内联版总传输 3.9KB 比位图版的 51KB 更小,换位图不为省流量);
|
||||
· 分享卡 / 其它非 CSS 场景只能用位图;
|
||||
· 换人维护时不用去读那一长串 data URI。
|
||||
|
||||
用法(项目根):
|
||||
python dev/theme-tools/export_assets.py # 只导出到 outputs/sakura-assets
|
||||
python dev/theme-tools/export_assets.py --upload # 导出,并把 CSS + 位图 + INSTALL.txt 传到桶里
|
||||
"""
|
||||
import argparse
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(os.path.dirname(HERE))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(ROOT, "dev", "theme-builder"))
|
||||
|
||||
import gen_new_sakura as G # noqa: E402
|
||||
|
||||
OUT_DIR = os.path.join(ROOT, "outputs", "sakura-assets")
|
||||
|
||||
# (文件名, svg 源, 导出像素宽, 高)
|
||||
def assets():
|
||||
p = G.LIGHT
|
||||
return [
|
||||
("branch-right", G.branch(p["wood"], p["light"], p["deep"], p["heart"], flip=False), 840, 640),
|
||||
("branch-left", G.branch(p["wood"], p["light"], p["deep"], p["heart"], flip=True), 840, 640),
|
||||
("blossom", G.blossom(p["light"], p["deep"], p["heart"]), 400, 400),
|
||||
("petal", G.petal(p["light"]), 240, 336),
|
||||
("pattern", G.pattern(p["pattern"]), 600, 600),
|
||||
]
|
||||
|
||||
|
||||
def render(svg, w, h, out_png):
|
||||
"""用无头 Chrome 渲染成透明底 PNG。
|
||||
|
||||
走命令行而不是 Playwright:本机只有 node 版 playwright,python 侧没装;
|
||||
而 `--default-background-color=00000000` 正好能给透明底。
|
||||
|
||||
⚠️ `--window-size` 不等于 CSS 视口(实测 1080 只有 985),所以窗口开大一圈
|
||||
并让内容居中,再按目标尺寸从中心裁 —— 这样尺寸误差被吸收掉,不必去猜差多少。
|
||||
"""
|
||||
import subprocess
|
||||
import tempfile
|
||||
from PIL import Image
|
||||
|
||||
pad = 120
|
||||
html = (
|
||||
'<!doctype html><meta charset="utf-8">'
|
||||
"<style>html,body{margin:0;background:transparent;height:100vh;"
|
||||
"display:flex;align-items:center;justify-content:center}"
|
||||
f"svg{{display:block;width:{w}px;height:{h}px}}</style>" + svg
|
||||
)
|
||||
fd, html_path = tempfile.mkstemp(suffix=".html")
|
||||
os.close(fd)
|
||||
with open(html_path, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
|
||||
chrome = "C:/Program Files/Google/Chrome/Application/chrome.exe"
|
||||
cmd = [
|
||||
chrome, "--headless=old", "--disable-gpu", "--hide-scrollbars",
|
||||
"--force-device-scale-factor=1", "--default-background-color=00000000",
|
||||
"--window-size=%d,%d" % (w + pad, h + pad),
|
||||
"--screenshot=" + out_png,
|
||||
"file:///" + html_path.replace("\\", "/"),
|
||||
]
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=60)
|
||||
os.remove(html_path)
|
||||
|
||||
img = Image.open(out_png).convert("RGBA")
|
||||
cw, ch = img.size
|
||||
left, top = max(0, (cw - w) // 2), max(0, (ch - h) // 2)
|
||||
img.crop((left, top, left + w, top + h)).save(out_png)
|
||||
|
||||
|
||||
def to_webp(png_path, webp_path, quality=92):
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(png_path).convert("RGBA")
|
||||
img.save(webp_path, "WEBP", quality=quality, method=6)
|
||||
|
||||
|
||||
def read_r2(path):
|
||||
cfg = {}
|
||||
for line in open(path, encoding="utf-8"):
|
||||
m = re.match(r'^\s*([a-z_]+)\s*=\s*"?([^"\n]*)"?\s*$', line.strip())
|
||||
if m:
|
||||
cfg[m.group(1)] = m.group(2).strip()
|
||||
return cfg
|
||||
|
||||
|
||||
def pack_readme(prefix, object_base, sizes):
|
||||
"""生成包内 INSTALL.txt。域名与体积都写实数,避免 README 与实际脱节。"""
|
||||
base = (object_base or "").rstrip("/")
|
||||
total_kb = sum(v for k, v in sizes.items() if k.endswith(".webp")) / 1024
|
||||
css_kb = sizes.get("new-sakura.css", 0) / 1024
|
||||
urls = ""
|
||||
if base:
|
||||
urls = "\n公开地址(已验证可匿名 GET)\n--------------------------\n" + "\n".join(
|
||||
" %s/%s" % (base, n) for n in ["new-sakura.css"] + [
|
||||
"%s.webp" % k for k in ["branch-right", "branch-left", "blossom", "petal", "pattern"]])
|
||||
|
||||
return """new-sakura(新·樱花)主题包
|
||||
============================
|
||||
|
||||
来源:TamaBox 仓库 static/css/themes/new-sakura.css(生成产物,勿手改)
|
||||
生成:dev/theme-tools/gen_new_sakura.py(花形/位置/配色都在里面)
|
||||
位图:dev/theme-tools/export_assets.py(从同一份 SVG 导出,不是另画的)
|
||||
说明:docs/theme-new-sakura.md
|
||||
对象前缀:%s/
|
||||
|
||||
文件
|
||||
----
|
||||
new-sakura.css 主题本体 %5.1f KB(纯 CSS,樱花是内联 SVG,零外部请求)
|
||||
branch-right.webp 花枝 840x640 透明底(细端在右上)
|
||||
branch-left.webp 同上镜像
|
||||
blossom.webp 单朵樱花 400x400 透明底
|
||||
petal.webp 单瓣 240x336 透明底
|
||||
pattern.webp 平铺底纹 600x600 透明底
|
||||
位图合计 %5.1f KB
|
||||
|
||||
怎么用
|
||||
------
|
||||
线上只需要 new-sakura.css:放进仓库 static/css/themes/,在 conf/app.ini 的
|
||||
[theme] available 里加上 new-sakura,再重新 go build(主题 CSS 是编译期 embed,
|
||||
只重启不重编不会生效)。
|
||||
|
||||
WebP 不是必需品 —— CSS 里的樱花是矢量,比位图清晰且没有额外请求。留着的用处:
|
||||
1) 素材要单独复用:python dev/theme-tools/gen_new_sakura.py --asset-base %s
|
||||
(CSS 从 %.1f KB 降到 10.6 KB,但多 5 个请求共 48.9 KB,总传输更大)
|
||||
2) 非 CSS 场景(分享卡、海报)只能用位图
|
||||
%s
|
||||
注意
|
||||
----
|
||||
直连桶的 S3 endpoint(*.r2.cloudflarestorage.com)会 403,那里需要签名。
|
||||
浏览器直读只能用自定义域 / r2.dev 或 CDN。
|
||||
|
||||
缓存:位图 1 天、CSS 5 分钟、本文件 60 秒 —— 对象键不带版本,所以**不用** immutable。
|
||||
要一年长缓存,得把内容哈希写进文件名(届时 CSS 里的地址也要跟着换)。
|
||||
""" % (prefix, css_kb, total_kb,
|
||||
base or "https://<公开域名>/" + prefix.strip("/"), css_kb, urls)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--upload", action="store_true", help="导出后上传到 data/r2.txt 里的桶")
|
||||
ap.add_argument("--prefix", default="themes/new-sakura", help="对象键前缀,默认 themes/new-sakura")
|
||||
ap.add_argument("--public-base", default="",
|
||||
help="公开访问的主机(如 https://r2.example.com,不带路径);"
|
||||
"默认读 data/r2.txt 的 image_public_base 或 image_bucket_cdn_host,"
|
||||
"都没有则在 INSTALL.txt 里写占位")
|
||||
ap.add_argument("--r2", default=os.path.join(ROOT, "data", "r2.txt"))
|
||||
args = ap.parse_args()
|
||||
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
files, sizes = [], {}
|
||||
for name, svg, w, h in assets():
|
||||
png = os.path.join(OUT_DIR, name + ".png")
|
||||
webp = os.path.join(OUT_DIR, name + ".webp")
|
||||
render(svg, w, h, png)
|
||||
to_webp(png, webp)
|
||||
os.remove(png)
|
||||
size = os.path.getsize(webp)
|
||||
sizes[name + ".webp"] = size
|
||||
print("导出 %-13s %dx%d %.1f KB" % (name, w, h, size / 1024))
|
||||
files.append((name + ".webp", webp))
|
||||
|
||||
if not args.upload:
|
||||
print("\n只导出,未上传(加 --upload 才传)。目录:%s" % OUT_DIR)
|
||||
return
|
||||
|
||||
import base64
|
||||
import serve
|
||||
|
||||
raw = read_r2(args.r2)
|
||||
# 与 conf/app.ini 同名:image_bucket_cdn_host 只是主机名(不带 scheme)
|
||||
cdn_host = raw.get("image_bucket_cdn_host", "").strip().strip("/")
|
||||
public_host = (args.public_base or raw.get("image_public_base")
|
||||
or ("https://" + cdn_host if cdn_host else "")).rstrip("/")
|
||||
object_base = ("%s/%s" % (public_host, args.prefix.strip("/"))) if public_host else ""
|
||||
cfg = serve.S3Config(dict(
|
||||
endpoint=raw["image_endpoint"], region="auto", bucket=raw["image_bucket"],
|
||||
prefix=args.prefix, pathStyle=True,
|
||||
accessKey=raw["image_access_id"], secretKey=raw["image_access_secret"],
|
||||
))
|
||||
# 键名不带版本,就不配 immutable —— 否则重新导出后一年内谁看到的都还是旧图。
|
||||
payload = [{"key": name, "name": name, "contentType": "image/webp",
|
||||
"cacheControl": "public, max-age=86400",
|
||||
"data": base64.b64encode(open(path, "rb").read()).decode()}
|
||||
for name, path in files]
|
||||
|
||||
css_path = os.path.join(ROOT, "static", "css", "themes", "new-sakura.css")
|
||||
css = open(css_path, "rb").read()
|
||||
sizes["new-sakura.css"] = len(css)
|
||||
payload.append({"key": "new-sakura.css", "contentType": "text/css; charset=utf-8",
|
||||
"cacheControl": "public, max-age=300",
|
||||
"data": base64.b64encode(css).decode()})
|
||||
readme = pack_readme(args.prefix, object_base, sizes).encode("utf-8")
|
||||
payload.append({"key": "INSTALL.txt", "contentType": "text/plain; charset=utf-8",
|
||||
"cacheControl": "public, max-age=60",
|
||||
"data": base64.b64encode(readme).decode()})
|
||||
|
||||
res = serve.upload_files(cfg, payload)
|
||||
for r in res["files"]:
|
||||
print(("OK " if r["ok"] else "FAIL ") + r["key"] + ("" if r["ok"] else " " + str(r.get("error"))))
|
||||
print("\n上传 %d/%d" % (res["total"] - res["failed"], res["total"]))
|
||||
if object_base:
|
||||
print("公开地址:%s/" % object_base)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,445 @@
|
||||
"""生成 new-sakura 主题的 CSS。
|
||||
|
||||
为什么用脚本生成而不用生成器的图层功能:生成器的装饰层只认「图片素材」(导出时
|
||||
打进 zip 或传对象存储),而这里的花瓣是**内联 SVG 的 data URI** —— 零外部请求、
|
||||
任意缩放不糊、改配色只改一个色值。代价是 CSS 里会出现一长串百分号编码的 URI,
|
||||
所以源 SVG 保留在本文件里,改完重跑:
|
||||
|
||||
python dev/theme-tools/gen_new_sakura.py
|
||||
|
||||
输出:static/css/themes/new-sakura.css
|
||||
"""
|
||||
import os
|
||||
import urllib.parse
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
OUT = os.path.join(ROOT, "static", "css", "themes", "new-sakura.css")
|
||||
|
||||
|
||||
def uri(svg):
|
||||
"""转成 CSS url()。safe='' 是刻意的:连逗号、分号、括号都编码掉 ——
|
||||
`--tb-deco` 是按顶层逗号切分的(internal/theme 的 TestDecoSlotCount),
|
||||
URI 里留一个裸逗号就会被当成多一层的装饰。"""
|
||||
return 'url("data:image/svg+xml,' + urllib.parse.quote(svg, safe="") + '")'
|
||||
|
||||
|
||||
# ---- 花与瓣 ----------------------------------------------------------------
|
||||
# 五瓣樱花:五个椭圆绕中心旋 72°,中心一团深一点的蕊。不做花瓣缺口——
|
||||
# 在 30~90px 的显示尺寸上缺口只剩 2px,反而像毛边;靠五瓣轮廓就够识别。
|
||||
def blossom(light, deep, heart):
|
||||
petals = "".join(
|
||||
f'<ellipse cx="50" cy="27" rx="14" ry="21" transform="rotate({a} 50 50)"/>'
|
||||
for a in (0, 72, 144, 216, 288)
|
||||
)
|
||||
return (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">'
|
||||
f'<g fill="{light}">{petals}</g>'
|
||||
f'<circle cx="50" cy="50" r="7.5" fill="{deep}"/>'
|
||||
f'<circle cx="50" cy="50" r="3" fill="{heart}"/>'
|
||||
"</svg>"
|
||||
)
|
||||
|
||||
|
||||
# 单瓣:顶端留一个 V 形缺口(樱花花瓣的标志),比整朵花更适合当"飘落"的单位。
|
||||
def petal(fill):
|
||||
return (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 56">'
|
||||
f'<path d="M20 1c9 0 15 8 15 18 0 5-2 9-4 12l-4-5-7 9-7-9-4 5c-2-3-4-7-4-12C5 9 11 1 20 1z" fill="{fill}"/>'
|
||||
"</svg>"
|
||||
)
|
||||
|
||||
|
||||
# 枝条:一根主枝 + 两根小杈 + 五朵花 + 三片落瓣。
|
||||
# 花是重复内联而不是 <use href="#f">:data URI 里的片段引用在各浏览器上
|
||||
# 行为不一致(Safari 曾整朵不渲染),多几百字节换确定性。
|
||||
def branch(wood, light, deep, heart, flip=False):
|
||||
fl = ' transform="translate(420,0) scale(-1,1)"' if flip else ""
|
||||
# 花要比枝小得多、数量多几朵。早先 5 朵 scale≈1 的花配一根细枝,缩到 300px
|
||||
# 显示时是一串糖葫芦;真实樱花枝是"小碎花沿枝密布",所以花收到 0.42~0.62。
|
||||
# 第四项是旋转角:一朵朵都朝正上方会像贴纸糊上去的,真实花枝上朝向各异
|
||||
blossoms = [
|
||||
(302, 74, 0.62, 14), (256, 110, 0.55, -22), (214, 138, 0.60, 34), (172, 176, 0.58, -6),
|
||||
(352, 42, 0.50, 48), (140, 214, 0.46, 20), (116, 246, 0.40, -32), (326, 128, 0.42, 28),
|
||||
]
|
||||
petals = [(272, 158, 22), (318, 150, -18), (196, 92, 40), (150, 196, -26)]
|
||||
body = []
|
||||
for x, y, s, rot in blossoms:
|
||||
body.append(
|
||||
f'<g transform="translate({x} {y}) rotate({rot}) scale({s}) translate(-50 -50)">'
|
||||
'<g fill="%s">' % light
|
||||
+ "".join(f'<ellipse cx="50" cy="27" rx="14" ry="21" transform="rotate({a} 50 50)"/>'
|
||||
for a in (0, 72, 144, 216, 288))
|
||||
+ f'</g><circle cx="50" cy="50" r="7" fill="{deep}"/>'
|
||||
f'<circle cx="50" cy="50" r="2.8" fill="{heart}"/></g>'
|
||||
)
|
||||
for x, y, rot in petals:
|
||||
body.append(
|
||||
f'<g transform="translate({x} {y}) rotate({rot}) scale(0.42) translate(-20 -28)">'
|
||||
f'<path d="M20 1c9 0 15 8 15 18 0 5-2 9-4 12l-4-5-7 9-7-9-4 5c-2-3-4-7-4-12C5 9 11 1 20 1z" fill="{light}"/></g>'
|
||||
)
|
||||
return (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 320">'
|
||||
f'<g{fl}>'
|
||||
f'<path d="M2 318C74 276 112 212 176 184c62-28 102-72 146-118 26-28 52-46 82-58" '
|
||||
f'fill="none" stroke="{wood}" stroke-width="4.2" stroke-linecap="round" opacity=".62"/>'
|
||||
f'<path d="M176 184c-4-30 4-56 16-78M262 126c16-28 32-48 46-58" '
|
||||
f'fill="none" stroke="{wood}" stroke-width="2.8" stroke-linecap="round" opacity=".5"/>'
|
||||
+ "".join(body) + "</g></svg>"
|
||||
)
|
||||
|
||||
|
||||
# 平铺底纹:极淡的小花 + 落瓣。
|
||||
# 密度是刻意的:最早一版放了 2 朵花 + 2 片瓣并且 260px 平铺,页面上读出来是
|
||||
# 「整齐的包装纸」——因为每个元素都在固定格点上重复。现在只留 1 朵 + 2 片,
|
||||
# 尺寸放大到 300px 且各自错位,平铺时才看不出周期。
|
||||
def pattern(light, strong=False):
|
||||
"""平铺底纹。strong 给窄屏用:那里装饰层全关,底纹是屏幕上唯一的樱花,
|
||||
照桌面那套 0.18~0.34 的透明度在 390px 上基本看不出来(实测截图接近纯粉底)。"""
|
||||
k = 1.9 if strong else 1.0
|
||||
one = (
|
||||
'<g fill="%s">' % light
|
||||
+ "".join(f'<ellipse cx="50" cy="27" rx="14" ry="21" transform="rotate({a} 50 50)"/>'
|
||||
for a in (0, 72, 144, 216, 288))
|
||||
+ "</g>"
|
||||
)
|
||||
petal_path = ('<path d="M20 1c9 0 15 8 15 18 0 5-2 9-4 12l-4-5-7 9-7-9-4 5'
|
||||
'c-2-3-4-7-4-12C5 9 11 1 20 1z" fill="%s"/>')
|
||||
o = lambda v: min(0.92, round(v * k, 2))
|
||||
return (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300">'
|
||||
f'<g transform="translate(96 84) scale(0.34) translate(-50 -50)" opacity="{o(0.34)}">{one}</g>'
|
||||
f'<g transform="translate(236 208) rotate(28) scale(0.26)" opacity="{o(0.24)}">'
|
||||
+ (petal_path % light) +
|
||||
"</g>"
|
||||
f'<g transform="translate(188 52) rotate(-34) scale(0.2)" opacity="{o(0.18)}">'
|
||||
+ (petal_path % light) +
|
||||
"</g>"
|
||||
"</svg>"
|
||||
)
|
||||
|
||||
|
||||
# ---- 一套素材(浅色 / 深色各一组色) ---------------------------------------
|
||||
LIGHT = dict(
|
||||
light="#f7bcd0", deep="#e989aa", heart="#fdf6f8",
|
||||
wood="#9a7a70", pattern="#f2bfd0",
|
||||
)
|
||||
DARK = dict(
|
||||
light="#f0a0b8", deep="#c96a8b", heart="#2c2227",
|
||||
wood="#a8837a", pattern="#7a4e5f",
|
||||
)
|
||||
|
||||
|
||||
def uris(p, asset_base=""):
|
||||
"""asset_base 非空 -> 引用对象存储上的位图(键名与 export_assets.py 的产物一致)。
|
||||
|
||||
内联 SVG 是默认:gzip 后约 3.9KB、零额外请求。位图版 CSS 只有 10.6KB,但要多个
|
||||
5 个请求共 ~49KB,总传输反而更大 —— 它存在的理由是素材能被非 CSS 场景复用。
|
||||
换之前先确认桶已对公网可读(自定义域或 r2.dev),否则主题会整片 403。
|
||||
"""
|
||||
if asset_base:
|
||||
b = asset_base.rstrip("/")
|
||||
return {
|
||||
"blossom_l": 'url("%s/blossom.webp")' % b,
|
||||
"petal_l": 'url("%s/petal.webp")' % b,
|
||||
"branch_l": 'url("%s/branch-left.webp")' % b,
|
||||
"branch_r": 'url("%s/branch-right.webp")' % b,
|
||||
"pattern": 'url("%s/pattern.webp")' % b,
|
||||
"pattern_narrow": 'url("%s/pattern.webp")' % b,
|
||||
}
|
||||
return {
|
||||
"blossom_l": uri(blossom(p["light"], p["deep"], p["heart"])),
|
||||
"petal_l": uri(petal(p["light"])),
|
||||
"branch_l": uri(branch(p["wood"], p["light"], p["deep"], p["heart"], flip=True)),
|
||||
"branch_r": uri(branch(p["wood"], p["light"], p["deep"], p["heart"], flip=False)),
|
||||
"pattern": uri(pattern(p["pattern"])),
|
||||
"pattern_narrow": uri(pattern(p["pattern"], strong=True)),
|
||||
}
|
||||
|
||||
|
||||
NONE10 = "none, none, none, none, none, none, none, none, none, none"
|
||||
AUTO10 = "auto, auto, auto, auto, auto, auto, auto, auto, auto, auto"
|
||||
TOPLEFT10 = ("left top, left top, left top, left top, left top, left top, left top, "
|
||||
"left top, left top, left top")
|
||||
|
||||
|
||||
def deco(layers):
|
||||
"""layers: [(var, size, position)],不足 10 槽补 none。"""
|
||||
items, sizes, poss = [], [], []
|
||||
for var, size, pos in layers:
|
||||
items.append(var)
|
||||
sizes.append(size)
|
||||
poss.append(pos)
|
||||
pad = 10 - len(layers)
|
||||
items += ["none"] * pad
|
||||
sizes += ["auto"] * pad
|
||||
poss += ["left top"] * pad
|
||||
return (", ".join(items), ", ".join(sizes), ", ".join(poss))
|
||||
|
||||
|
||||
# ---- 每个页面一套位置 ------------------------------------------------------
|
||||
# 位置一律两值(`关键字 ± 偏移`)。百分比相对「容器 − 图片」,写 calc 更可控;
|
||||
# 这里用 left/right + % 与 px 偏移,保证换视口时花还贴在角落而不是飘到内容上。
|
||||
SCOPES = {
|
||||
"global": [
|
||||
("var(--sk-branch-r)", "auto 280px", "right -16px bottom -22px"),
|
||||
("var(--sk-blossom)", "auto 88px", "left 6% top 12%"),
|
||||
("var(--sk-petal)", "32px", "left 14% top 24%"),
|
||||
("var(--sk-petal)", "26px", "right 16% top 8%"),
|
||||
],
|
||||
"box": [
|
||||
# 枝的细端在右上、粗端朝左下:放在右上角时"从角上垂下来",
|
||||
# 尺寸收到 300px 才会有枝形 —— 390px 时可见部分只剩一团花(第一次就是这个问题)
|
||||
# top 从 -16px 收到 26px:顶栏改透明后,700~760px 宽度下枝头会正好压在"登录"上。
|
||||
# 顶栏高 60px、外边距 20px,26px 起笔即落在顶栏下沿之上,宽屏仍保留"从右上角长出"。
|
||||
("var(--sk-branch-r)", "auto 300px", "right -24px top 26px"),
|
||||
("var(--sk-blossom)", "auto 80px", "left 5% bottom 16%"),
|
||||
("var(--sk-petal)", "34px", "left 14% bottom 32%"),
|
||||
("var(--sk-petal)", "24px", "right 22% bottom 8%"),
|
||||
("var(--sk-blossom)", "auto 52px", "right 6% bottom 6%"),
|
||||
],
|
||||
"question": [
|
||||
("var(--sk-branch-l)", "auto 300px", "left -34px bottom -24px"),
|
||||
("var(--sk-blossom)", "auto 86px", "right 7% top 11%"),
|
||||
("var(--sk-petal)", "30px", "right 17% top 24%"),
|
||||
("var(--sk-petal)", "24px", "left 11% top 7%"),
|
||||
],
|
||||
"admin": [
|
||||
# 列表页卡片几乎占满宽度,右侧的花会整个被卡片盖住 —— 只往左侧留白放
|
||||
("var(--sk-branch-l)", "auto 260px", "left -28px top -24px"),
|
||||
("var(--sk-blossom)", "auto 70px", "left 3% bottom 7%"),
|
||||
("var(--sk-petal)", "26px", "left 11% bottom 15%"),
|
||||
],
|
||||
"profile": [
|
||||
("var(--sk-branch-r)", "auto 280px", "right -28px bottom -24px"),
|
||||
("var(--sk-blossom)", "auto 66px", "left 6% top 9%"),
|
||||
("var(--sk-petal)", "28px", "left 15% top 19%"),
|
||||
("var(--sk-petal)", "22px", "left 5% top 28%"),
|
||||
],
|
||||
"vote": [
|
||||
("var(--sk-branch-l)", "auto 280px", "left -30px top -20px"),
|
||||
("var(--sk-blossom)", "auto 78px", "right 8% top 16%"),
|
||||
("var(--sk-petal)", "28px", "right 18% top 27%"),
|
||||
("var(--sk-blossom)", "auto 52px", "right 6% bottom 8%"),
|
||||
],
|
||||
# 窄屏:卡片占满宽度,左右只剩 ~16px 边距,花枝放哪儿都糊在字上。
|
||||
# 实测底纹铺在 16px 边距里根本落不到花 —— 花朵在格子里的位置固定在 x≈58,
|
||||
# 所以只在「顶栏与卡片之间」那条 30px 的横带里放几朵小的,其余交给加强底纹。
|
||||
# 试过在「顶栏与卡片之间」那条 ~35px 的带子里放小樱花:box 页卡片从 y=122 起还行,
|
||||
# question/admin 页卡片从 y=110 起就把它切掉半个,结果是贴在卡片边上的粉色碎块。
|
||||
# 手机上没有可靠的留白,干脆不放装饰层 —— 观感交给配色与底纹(加强版 pattern)。
|
||||
"narrow": [],
|
||||
}
|
||||
|
||||
|
||||
def scope_block(prefix, layers, indent=" "):
|
||||
img, size, pos = deco(layers)
|
||||
return (
|
||||
f"{prefix} {{\n"
|
||||
f"{indent}--tb-deco: {img};\n"
|
||||
f"{indent}--tb-deco-size: {size};\n"
|
||||
f"{indent}--tb-deco-position: {pos};\n"
|
||||
"}"
|
||||
)
|
||||
|
||||
|
||||
def build(asset_base=""):
|
||||
u = uris(LIGHT, asset_base)
|
||||
d = uris(DARK, asset_base)
|
||||
|
||||
vars_light = "\n".join([
|
||||
f" --sk-blossom: {u['blossom_l']};",
|
||||
f" --sk-petal: {u['petal_l']};",
|
||||
f" --sk-branch-l: {u['branch_l']};",
|
||||
f" --sk-branch-r: {u['branch_r']};",
|
||||
])
|
||||
vars_dark = "\n".join([
|
||||
f" --sk-blossom: {d['blossom_l']};",
|
||||
f" --sk-petal: {d['petal_l']};",
|
||||
f" --sk-branch-l: {d['branch_l']};",
|
||||
f" --sk-branch-r: {d['branch_r']};",
|
||||
])
|
||||
|
||||
light_block = f"""html[data-theme-style="new-sakura"] {{
|
||||
--tb-bg: #fdf6f8;
|
||||
--tb-surface: #ffffff;
|
||||
--tb-surface-2: #fdf0f4;
|
||||
--tb-surface-hover: #fff7fa;
|
||||
/* 顶栏做磨砂玻璃:樱花从角落长出来,实色顶栏会把它齐刷刷切一段;但也不能给
|
||||
transparent —— 顶栏会"消失",导航失去依托,文字直接压在花纹上。留 28% 的透光。
|
||||
模糊由下面「组件微调」里的 backdrop-filter 加(令牌只能给颜色)。 */
|
||||
--tb-navbar: rgba(253, 246, 248, 0.72);
|
||||
--tb-border: #f3dde5;
|
||||
|
||||
--tb-text: #3a2c31;
|
||||
--tb-text-secondary: #6d555d;
|
||||
/* 5.27:1(底色)/ 5.62:1(卡片),踩住 AA 下限 */
|
||||
--tb-text-muted: #77626a;
|
||||
|
||||
/* #c14a72 在粉底上只有 4.39:1,同色相压深到 #b8436a = 4.87:1 */
|
||||
--tb-primary: #b8436a;
|
||||
--tb-primary-weak: rgba(184, 67, 106, 0.12);
|
||||
--tb-on-primary: #ffffff;
|
||||
|
||||
--tb-shadow: 0 1px 2px rgba(150, 80, 105, 0.05), 0 12px 28px -18px rgba(150, 80, 105, 0.22);
|
||||
--tb-shadow-hover: 0 1px 2px rgba(150, 80, 105, 0.07), 0 18px 40px -20px rgba(150, 80, 105, 0.30);
|
||||
|
||||
/* 花与瓣都是内联 SVG(data URI),没有外部素材 —— 所以这个主题不需要对象存储。
|
||||
--sk-* 是花材的别名:同一个 URI 只在这里出现一次,下面各页面作用域引用变量,
|
||||
否则 6 套位置会把 4 个 data URI 重复 20 多次,文件直接翻倍。 */
|
||||
{vars_light}
|
||||
|
||||
/* 整页底纹:极淡的花与落瓣,260px 平铺。压在白底上只是一层纸纹,
|
||||
不参与构图(真正的樱花由下面的装饰层负责)。 */
|
||||
--tb-page-image: none;
|
||||
--tb-page-pattern: {u['pattern']};
|
||||
--tb-page-pattern-size: 300px 300px;
|
||||
"""
|
||||
|
||||
# 全局默认位置:写在同一个块里(未单独配置的页面沿用这套)
|
||||
body = light_block + deco_block_inline(SCOPES["global"]) + "}\n"
|
||||
|
||||
for key in ("box", "question", "admin", "profile", "vote"):
|
||||
body += "\n" + scope_block(
|
||||
f'html[data-theme-style="new-sakura"][data-page-scope="{key}"]', SCOPES[key]) + "\n"
|
||||
|
||||
dark_block = f"""
|
||||
html[data-theme="dark"][data-theme-style="new-sakura"] {{
|
||||
--tb-bg: #221a1e;
|
||||
--tb-surface: #2c2227;
|
||||
--tb-surface-2: #372a30;
|
||||
--tb-surface-hover: rgba(255, 147, 180, 0.10);
|
||||
--tb-navbar: rgba(34, 26, 30, 0.72);
|
||||
--tb-border: #47333b;
|
||||
|
||||
--tb-text: #f3e6ea;
|
||||
--tb-text-secondary: #c9aeb8;
|
||||
/* 5.82:1(底色)/ 5.26:1(卡片) */
|
||||
--tb-text-muted: #ab9099;
|
||||
|
||||
--tb-primary: #ff93b4;
|
||||
--tb-primary-weak: rgba(255, 147, 180, 0.16);
|
||||
--tb-on-primary: #1b1014;
|
||||
|
||||
--tb-shadow: 0 1px 2px rgba(0, 0, 0, 0.34), 0 12px 28px -18px rgba(0, 0, 0, 0.58);
|
||||
--tb-shadow-hover: 0 1px 2px rgba(0, 0, 0, 0.40), 0 18px 40px -20px rgba(0, 0, 0, 0.68);
|
||||
|
||||
{vars_dark}
|
||||
|
||||
/* 深色不铺底纹:浅粉花瓣压在深底上会变成一层噪点 */
|
||||
--tb-page-image: linear-gradient(180deg, #251c21 0%, #221a1e 62%, #1e171b 100%);
|
||||
--tb-page-pattern: none;
|
||||
""" + deco_block_inline(SCOPES["global"], indent=" ") + "}\n"
|
||||
|
||||
for key in ("box", "question", "admin", "profile", "vote"):
|
||||
dark_block += "\n" + scope_block(
|
||||
f'html[data-theme="dark"][data-theme-style="new-sakura"][data-page-scope="{key}"]',
|
||||
SCOPES[key]) + "\n"
|
||||
|
||||
# 作用域块的选择器是 html[data-theme-style][data-page-scope="box"](特异性 0,2,1),
|
||||
# 比 html[data-theme-style] (0,1,1)高 —— 光写一条压不住,窄屏照样是桌面版装饰。
|
||||
# 所以用同等特异性的通配属性选择器再写一遍,靠「在文件里更晚」取胜;
|
||||
# 深色作用域块是 0,3,1,还得再补一条。
|
||||
narrow_rules = ""
|
||||
for sel in (
|
||||
'html[data-theme-style="new-sakura"][data-page-scope]',
|
||||
'html[data-theme="dark"][data-theme-style="new-sakura"]',
|
||||
'html[data-theme="dark"][data-theme-style="new-sakura"][data-page-scope]',
|
||||
):
|
||||
narrow_rules += " %s {\n%s }\n" % (
|
||||
sel, deco_block_inline(SCOPES["narrow"], indent=" "))
|
||||
|
||||
narrow = (
|
||||
"\n@media (max-width: 640px) {\n"
|
||||
' html[data-theme-style="new-sakura"] {\n'
|
||||
+ deco_block_inline(SCOPES["narrow"], indent=" ")
|
||||
+ " /* 手机上装饰层整个收掉(卡片占满宽度),樱花只能由底纹承担:\n"
|
||||
" 换成加强版 + 缩小格子,否则 390px 上看就是一片纯粉 */\n"
|
||||
" --tb-page-pattern: %s;\n" % u["pattern_narrow"]
|
||||
+ " --tb-page-pattern-size: 180px 180px;\n"
|
||||
" }\n"
|
||||
+ narrow_rules
|
||||
+ "}\n"
|
||||
)
|
||||
|
||||
return HEADER + body + dark_block + narrow + FOOTER
|
||||
|
||||
|
||||
def deco_block_inline(layers, indent=" "):
|
||||
img, size, pos = deco(layers)
|
||||
return (
|
||||
f"{indent}--tb-deco: {img};\n"
|
||||
f"{indent}--tb-deco-size: {size};\n"
|
||||
f"{indent}--tb-deco-position: {pos};\n"
|
||||
)
|
||||
|
||||
|
||||
HEADER = """/* 新·樱花(new-sakura):纯 CSS 樱花,不依赖任何图片素材。
|
||||
|
||||
和已有的 sakura 主题的区别:sakura 是"粉底 + 网点"的静态配色;这里把樱花本身
|
||||
做成了可摆放的装饰层 —— 整朵花、带缺口的花瓣、带枝的花朵树,全部是内联 SVG
|
||||
的 data URI,所以:换颜色只改一个色值、任意视口不糊、没有额外请求、不需要对象存储。
|
||||
|
||||
每个页面一套位置(--tb-deco / -size / -position 三组各 10 槽,必须等长):
|
||||
global 未单独配置的页面 右下花枝 + 左上两瓣
|
||||
box 箱主页 右上花枝垂下 + 左下花与落瓣
|
||||
question 问题页 左下花枝 + 右上花与落瓣
|
||||
admin 题目列表 左上花枝(只压顶部边距,不抢列表)
|
||||
profile 设置页 右下花枝 + 左侧竖排小瓣
|
||||
vote 投票页 左上花枝 + 右侧花与落瓣
|
||||
≤640px 一律只留角落两朵小的 —— 卡片占满宽度后枝条会糊在字上
|
||||
|
||||
位置一律「两值 + 关键字」,不用三值语法(三值里混用 left/bottom 关键字是非法组合,
|
||||
整条声明会作废,花瓣会静默回到左上角)。
|
||||
|
||||
生成:python dev/theme-tools/gen_new_sakura.py(改花形/位置请改那个脚本,别手改这里)
|
||||
*/
|
||||
|
||||
"""
|
||||
|
||||
FOOTER = """
|
||||
/* 组件微调(仅外观):顶栏与粉底融在一起后,UIkit 默认的 #999 链接色偏淡 */
|
||||
html[data-theme-style="new-sakura"] .uk-navbar-nav > li > a {
|
||||
color: var(--tb-text-secondary);
|
||||
}
|
||||
html[data-theme-style="new-sakura"] .uk-navbar-nav > li > a:hover {
|
||||
color: var(--tb-primary);
|
||||
}
|
||||
/* 信息提示条:UIkit 的浅蓝在樱粉底上是唯一的冷色块,换成主色的弱化底 */
|
||||
html[data-theme-style="new-sakura"] .uk-alert-primary {
|
||||
background-color: var(--tb-primary-weak);
|
||||
color: #9c3356;
|
||||
}
|
||||
html[data-theme="dark"][data-theme-style="new-sakura"] .uk-alert-primary {
|
||||
color: #ffb3c8;
|
||||
}
|
||||
/* 顶栏磨砂:半透明底由 --tb-navbar 给,模糊必须在这里加(令牌只能是颜色值)。
|
||||
和手机底栏一样是「半透明 + 模糊」,不是全透明 —— 顶栏得看得见,只是后面透得出花。 */
|
||||
html[data-theme-style="new-sakura"] .uk-navbar-container:not(.uk-navbar-transparent) {
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
/* 手机底栏:base.css 写死 rgba(255,255,255,.92),压在粉底上是一条白杠,跟背景脱开 */
|
||||
html[data-theme-style="new-sakura"] #tb-mobile-utils {
|
||||
background: rgba(253, 246, 248, 0.92);
|
||||
}
|
||||
html[data-theme="dark"][data-theme-style="new-sakura"] #tb-mobile-utils {
|
||||
background: rgba(34, 26, 30, 0.92);
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
ap = argparse.ArgumentParser(description="生成 new-sakura 主题 CSS")
|
||||
ap.add_argument("--asset-base", default="",
|
||||
help="引用对象存储位图的前缀,如 https://r2.example.com/themes/new-sakura;"
|
||||
"留空 = 内联 SVG(默认)")
|
||||
ap.add_argument("--out", default=OUT, help="输出路径")
|
||||
args = ap.parse_args()
|
||||
|
||||
css = build(args.asset_base)
|
||||
with open(args.out, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write(css)
|
||||
print("已生成 %s(%.1f KB)%s" % (args.out, len(css.encode()) / 1024,
|
||||
" [位图版]" if args.asset_base else " [内联 SVG]"))
|
||||
+10
-3
@@ -1,6 +1,7 @@
|
||||
// webpstub(package webp)是 chai2010/webp 的本地编译桩:仅用于在缺少 libwebp 的
|
||||
// Windows 沙箱中通过 `go build -overlay` 对依赖 internal/storage 的包做完整
|
||||
// 类型检查。API 只覆盖项目实际用到的 Encode/Options。
|
||||
// Windows 沙箱中通过 `go build -overlay` 替换 chai2010/webp 的全部源文件,
|
||||
// 从而让真实的 internal/storage/s3.go(而非桩)参与完整类型检查。
|
||||
// 仅覆盖项目实际用到的 API。
|
||||
package webp
|
||||
|
||||
import (
|
||||
@@ -8,13 +9,19 @@ import (
|
||||
"io"
|
||||
)
|
||||
|
||||
// Options 与 github.com/chai2010/webp 的 Options 字段保持兼容(项目仅用 Quality)。
|
||||
// Options 与 github.com/chai2010/webp 的 Options 字段保持兼容。
|
||||
type Options struct {
|
||||
Lossless bool
|
||||
Quality float32
|
||||
Exact bool
|
||||
}
|
||||
|
||||
// Encode 是 webp.Encode 的签名兼容桩(类型检查期间不会被真正调用)。
|
||||
func Encode(w io.Writer, m image.Image, o *Options) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DecodeConfig / Decode 保留解码入口签名,供 s3.go 的 image.Decode 分派路径类型检查。
|
||||
func DecodeConfig(r io.Reader) (image.Config, error) { return image.Config{}, nil }
|
||||
|
||||
func Decode(r io.Reader) (image.Image, error) { return nil, nil }
|
||||
|
||||
+434
@@ -0,0 +1,434 @@
|
||||
# TamaBox 命令行操作手册(站方运维)
|
||||
|
||||
本项目**没有管理后台**:站方的全部运营动作只落在三类操作上 —— **CLI 命令 / 邮件告警 / 改配置后重启**。
|
||||
这份文档只讲 CLI,覆盖处置、举报取证、批量隐藏与账号管理。
|
||||
|
||||
---
|
||||
|
||||
## 0. 前置
|
||||
|
||||
### 二进制从哪里来
|
||||
|
||||
```bash
|
||||
# 仓库根目录构建(与 Dockerfile 是两条不同入口,命令列表必须双向同步)
|
||||
go build -o tamabox .
|
||||
|
||||
# Docker 用的是这一条
|
||||
go build -o NekoBox ./cmd/
|
||||
```
|
||||
|
||||
两处都注册在同一份 `app.Commands` 列表中。**新增命令时必须同时改 `tamabox.go` 和 `cmd/tamabox.go`**,
|
||||
否则会出现「本机二进制有、线上没有」(或反之),典型报错是 `No help topic for '<cmd>'`。
|
||||
|
||||
⚠️ **替换二进制不等于生效**:`go build -o tamabox .` 只改磁盘文件,正在运行的进程仍是旧映像。
|
||||
换完之后必须重启(systemd / supervisor / 手动拉起),否则改了代码也看不到变化。
|
||||
另外 `./tamabox web` 在前台立即返回,通常是端口被旧进程占着:
|
||||
|
||||
```bash
|
||||
ps -eo pid,lstart,cmd | grep '[t]amabox' # 看进程启动时间,与二进制 mtime 比对
|
||||
```
|
||||
|
||||
确认手头二进制包含全部命令:
|
||||
|
||||
```bash
|
||||
./tamabox --help | sed -n '/COMMANDS/,/GLOBAL/p'
|
||||
# web / censor / uid / ban / restore / reports / restrict / unrestrict / hide-public / honeypot / backup / restore-images
|
||||
```
|
||||
|
||||
> 想确认某个字符串有没有编进二进制,**不要用 `strings`** —— 它默认只输出可打印 ASCII,
|
||||
> UTF-8 中文整段被丢弃,`strings x | grep 中文` 恒为 0。
|
||||
> 用 `grep -ac "中文字符串" x`。
|
||||
|
||||
### 配置文件
|
||||
|
||||
CLI 与 `web` 读同一份配置,路径由环境变量指定:
|
||||
|
||||
```bash
|
||||
NEKOBOX_CONFIG_PATH=/path/to/app.ini ./tamabox reports --domain xxx
|
||||
```
|
||||
|
||||
必要条件(缺失会直接启动失败):
|
||||
|
||||
- `app.external_url`
|
||||
- `server.salt`、`server.xsrf_key`
|
||||
- `[database]` 段的 type / host / port / user / password / name
|
||||
|
||||
⚠️ CLI 启动时会跑 **AutoMigrate**(与 web 相同)。这是特性也是风险:迁移有问题时连只读的
|
||||
`reports` 都跑不起来。做重要操作前先 `pg_dump`。
|
||||
|
||||
### 操作前先备份
|
||||
|
||||
```bash
|
||||
pg_dump -Fc <dbname> > before-$(date +%F-%H%M).dump
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 命令总览
|
||||
|
||||
| 命令 | 作用 | 是否改数据 | 典型用途 |
|
||||
|---|---|---|---|
|
||||
| `reports` | 举报取证 / 待处理列表 / 全站统计 | 否 | 处置前看证据 |
|
||||
| `restrict` | 施加处置(L1–L4) | 是 | 停止分发、冻结发布、停用页面 |
|
||||
| `unrestrict` | 撤销全部生效中处置并发解除通知 | 是 | 解封、误操作回滚 |
|
||||
| `hide-public` | 批量把公开回答转为私密 | 是 | 清理存量挂人内容 |
|
||||
| `ban` | 永久封禁(删用户+内容) | 是(不可逆) | 严重违规账号 |
|
||||
| `restore` | 恢复已停用账号 | 是 | 用户申诉后恢复 |
|
||||
| `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` 确认;
|
||||
脚本化时加 `--yes`。注意 `honeypot prune` / `purge-creds` 的确认词是 **`yes`**(小写)。
|
||||
`restore-images` 不做交互确认:实际写入必须显式提供 `--yes`,`--dry-run` 除外。
|
||||
|
||||
---
|
||||
|
||||
## 2. `reports` —— 处置前的取证
|
||||
|
||||
三种互斥用法(`--stats` / `--pending` 优先于 `--email/--domain`)。
|
||||
|
||||
### 2.1 单个箱主的证据包
|
||||
|
||||
```bash
|
||||
./tamabox reports --domain 52mxtx1314
|
||||
./tamabox reports --email user@example.com --days 90 --limit 50
|
||||
```
|
||||
|
||||
输出包含:
|
||||
|
||||
- **当前处置**:级别、到期时间、原因
|
||||
- **近 N 天:被举报 X 条 / 公开回答 Y 条(累计,Z%);举报 W 次,其中待处理 V 次**
|
||||
- **被举报条目**:按举报数降序,带题号、内容前 48 字与状态(`待处理 N` / `已处理`)
|
||||
- **历史处置**:每条的时间、级别、原因、状态(生效中 / 已撤销 / 已到期)
|
||||
|
||||
口径(容易误读,务必看清):
|
||||
|
||||
- 分子 = 窗口内**被举报的条目数**(不是举报次数)。同一条被 5 个人举报只算 1 条。
|
||||
- 分母 = 该箱主**当前累计**的公开回答数,**不是**窗口内产出。
|
||||
所以 L2 收窄公开之后,分母会变小,比例可能超过 100% —— 这不是 bug,是分母口径问题。
|
||||
判断「组织化挂人」时优先看**绝对条数**与**指涉对象分布**,比例只作参考。
|
||||
- 已删除的提问不计入(`deleted_at IS NULL`)。
|
||||
|
||||
### 2.2 全站待处理举报
|
||||
|
||||
```bash
|
||||
./tamabox reports --pending --limit 50
|
||||
```
|
||||
|
||||
按箱主分组列出所有 `status = pending` 的举报(不受 30 天窗口限制,方便清理积压)。
|
||||
|
||||
### 2.3 全站维度统计(识别「举报被武器化」)
|
||||
|
||||
```bash
|
||||
./tamabox reports --stats --days 7
|
||||
```
|
||||
|
||||
输出两个 Top 10:
|
||||
|
||||
- **被举报最多的箱主**(含 domain / 昵称 / 待处理举报数)
|
||||
- **举报来源 IP 最多**(同一 IP 反复举报同一批内容)
|
||||
|
||||
怎么读:
|
||||
|
||||
| 现象 | 更可能是 |
|
||||
|---|---|
|
||||
| 多个 IP 打同一个箱主,且内容指向多个不同对象 | 组织化挂人 |
|
||||
| 少量 IP 打多个不同箱主 | 举报被武器化(报复性举报) |
|
||||
| 同一 IP 单点爆发 | 脚本刷量(已由验证码 + 唯一索引限制) |
|
||||
|
||||
⚠️ 统计只覆盖**待处理**举报,且最长回溯 30 天(超期未处理的举报会自动退出统计池)。
|
||||
|
||||
### 2.4 举报计数的前提:客户端 IP
|
||||
|
||||
举报去重键是 `(question_id, reporter_ip)`,计数与统计全部依赖真实客户端 IP。
|
||||
|
||||
- 站点在反向代理 / CDN 之后时,**必须**在 `[security]` 配置 `trust_proxy = true`,
|
||||
并按需配置 `trusted_proxies`。
|
||||
- 若 `trust_proxy = false` 而站点实际在代理后,所有访客会被识别成同一个代理 IP →
|
||||
每条内容最多只有 1 条举报,阈值告警永远不会触发。
|
||||
- 验证方法:从两个不同网络各举报一次,然后 `reports --pending` 看是否出现两个不同 IP。
|
||||
|
||||
---
|
||||
|
||||
## 3. `restrict` —— 分级处置
|
||||
|
||||
```bash
|
||||
./tamabox restrict --domain <domain> --level L1 --days 7 --reason "组织挂人投稿"
|
||||
./tamabox restrict --email <email> --level L3 --days 14 --reason "复发" --yes
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 说明 |
|
||||
|---|---|
|
||||
| `--domain` / `--email` | 二选一;都填时按 email 查 |
|
||||
| `--level` | `L1` / `L2` / `L3` / `L4`,默认 L1,大小写敏感 |
|
||||
| `--days` | 生效天数,从当前时间起算,必须 > 0 |
|
||||
| `--reason` | **必填**,写入审计记录(**不会展示给用户**,只用于内部留痕) |
|
||||
| `--yes` | 跳过 `YES` 确认 |
|
||||
| `--force` | `--days` 超过 90 天时**必须**加,否则直接拒绝执行 |
|
||||
| `--no-notify` | 不给箱主发处置通知邮件(默认发) |
|
||||
|
||||
### 异常时长与二次确认
|
||||
|
||||
`--days` 不设硬上限(长期封禁是真实需求),但超过 **90 天**时:
|
||||
|
||||
- 不加 `--force` → 直接报错退出(多打一个 0 不会被执行)
|
||||
- 交互模式下还要**再输入一次级别名**(如 `L3`)才继续
|
||||
- 脚本里 `--force --yes` 两个显式开关即构成两次确认
|
||||
|
||||
### 处置通知
|
||||
|
||||
处置生效后会自动给箱主发一封邮件(按箱主自己的界面语言,四语言齐全),内容只有:
|
||||
|
||||
- 级别与级别名称(如 L3 冻结发布)
|
||||
- 到期时间
|
||||
- 社区规则链接
|
||||
- 申诉渠道(页脚「找站长」)
|
||||
|
||||
**邮件里不包含 `--reason` 原文,也不引用被举报内容** —— 沿用公告模板的口径:只写级别、期限、依据。
|
||||
|
||||
需要「静默止血」(不惊动对方、避免其转移阵地)时加 `--no-notify`。
|
||||
|
||||
邮件发送失败**不会回滚处置**:只打一条 warn 并在终端提示,处置仍然生效。
|
||||
|
||||
### 级别语义
|
||||
|
||||
| 级别 | 名称 | 实际效果 | 生效点 |
|
||||
|---|---|---|---|
|
||||
| **L1** | 停止分发 | 单题/整箱分享卡不可生成;从首页、探索、标签检索、随机位摘除;热门标签不再出现 | `share.go`、`users.go` |
|
||||
| **L2** | 收窄公开 | L1 全部 + 新回答强制私密(单条 UPDATE 同时写回答与 `is_private`)+ 禁止「设为公开」+ 禁止回答追问 | `question.go`、`follow_up.go` |
|
||||
| **L3** | 冻结发布 | L2 全部 + 禁止回答(**仍可收信**) | `question.go` |
|
||||
| **L4** | 页面停用 | 整个 `/_/{domain}` 全部 302 到 `/restricted`(单题、私聊、分享卡、JSON 列表接口一并挡住) | `page.go` Pager + `ListAPI` |
|
||||
|
||||
设计要点:
|
||||
|
||||
- **L3 不禁收信**。伤害来自「发布」,剥夺收信能力没有治理收益,还会给对方「平台封口」的说法。
|
||||
- **到期自动解除**:判定是 `expires_at > now()`,没有定时任务,也不需要手工解封。
|
||||
- **替换语义**:同一用户再次 `restrict` 会先撤销旧的生效记录再写新的,任何时刻只有一条生效。
|
||||
升级(L1→L3)与降级(L3→L1)都走这条路径。
|
||||
- **处置期间审核严格度锁定为最高档**(`strict`),箱主无法自行改档;解禁后自动恢复他原本的设置
|
||||
(`users.moderation_strictness` 全程未被改写)。
|
||||
|
||||
### 副作用(需要提前知道)
|
||||
|
||||
- 处置期间箱主在自己后台能看到橙色条幅(级别 + 到期日 + 规则页链接)。
|
||||
- **L1 就强制 strict**:L1 只停分发、内容仍能发布,但审核已按最严处理,
|
||||
正常的吐槽式提问也可能被拦。若线上发现大面积误拒,把 `moderation.EffectiveStrictness`
|
||||
改成「L1 沿用原设置」即可(一行)。
|
||||
- **处置会给箱主发通知邮件**(见上)。若用 `--no-notify` 跳过,箱主只剩登录后的橙色条幅。
|
||||
- 邮件发送依赖 `[mail]` 段的 SMTP 配置;未配置时处置仍会生效,但终端会提示发送失败。
|
||||
|
||||
---
|
||||
|
||||
## 4. `unrestrict` —— 撤销
|
||||
|
||||
```bash
|
||||
./tamabox unrestrict --domain <domain>
|
||||
./tamabox unrestrict --email <email> --no-notify
|
||||
```
|
||||
|
||||
- 把该用户**所有生效中**的处置写 `revoked_at`(保留记录,不删除,审计可追溯)。
|
||||
- 幂等:没有生效中处置时输出「没有生效中的处置,无需操作」。
|
||||
- 撤销后:页面/发布/分享卡立即恢复;`hide-public` 已经转为私密的内容**不会**自动变回公开,
|
||||
需要箱主自己逐条「设为公开」。
|
||||
- **会给箱主发一封「限制已解除」通知**(四语言,与处置通知同一模板,无申诉段),
|
||||
正文里明确写了「私密内容不会自动恢复」。`--no-notify` 可跳过。
|
||||
|
||||
⚠️ **到期自动解除不会发信**:那个判定是读表时比较 `expires_at`,没有定时任务。
|
||||
如果希望「到期也通知」,只能靠 `unrestrict` 手工解封,或将来加个调度器。
|
||||
|
||||
---
|
||||
|
||||
## 5. `hide-public` —— 清理存量内容
|
||||
|
||||
```bash
|
||||
# 最推荐:只隐藏「有未处理举报」的公开回答
|
||||
./tamabox hide-public --domain <domain> --only-reported
|
||||
|
||||
# 全部公开回答(激进的兜底)
|
||||
./tamabox hide-public --domain <domain>
|
||||
|
||||
# 只处理最近 30 天
|
||||
./tamabox hide-public --domain <domain> --only-reported --since 30
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
|---|---|
|
||||
| `--only-reported` | 只处理有 `pending` 举报的题目 |
|
||||
| `--since N` | 只处理最近 N 天创建的题目;0 = 不限 |
|
||||
| `--yes` | 跳过确认 |
|
||||
|
||||
行为:
|
||||
|
||||
- 只动「公开且已回答」的题(`answer != '' AND is_private = false`),不碰私密与未回答。
|
||||
- 执行前打印总数 + 最新 10 条预览。
|
||||
- **可逆**:只改可见性,箱主可逐条改回公开(但该箱主处于 L2 及以上时改不回去)。
|
||||
- 隐藏成功后自动把相关 `pending` 举报标记为 `accepted`,让它们退出告警统计池。
|
||||
- 单独用它不足以「停止分发」,建议配合 `restrict --level L1`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 账号管理(`ban` / `restore`)
|
||||
|
||||
```bash
|
||||
# 永久封禁:物理删除用户与其内容,邮箱释放。不可恢复。
|
||||
./tamabox ban --email user@example.com --yes
|
||||
./tamabox ban --domain xxx --yes
|
||||
|
||||
# 恢复「自助停用」的账号(软删除行),可同时重置密码
|
||||
./tamabox restore --email user@example.com
|
||||
./tamabox restore --email user@example.com --password 'NewPassw0rd' --yes
|
||||
```
|
||||
|
||||
两者的区别(涉及唯一索引,容易踩):
|
||||
|
||||
- **停用(可恢复)**:内容物理删除 + **用户行软删除**。邮箱/域名继续被占用是**有意为之**,
|
||||
恢复靠 `restore`。
|
||||
- **封禁(不可恢复)**:内容物理删除 + **用户行物理删除**,邮箱立即可重新注册。
|
||||
- `restore --password` 必须在恢复之后执行,否则改不到(普通作用域查不到软删除行)。
|
||||
|
||||
---
|
||||
|
||||
## 7. `honeypot` —— 扫描器取证
|
||||
|
||||
> 设计取舍、凭据存储格式、部署要点见 [honeypot.md](honeypot.md)。这里只列命令。
|
||||
|
||||
| 子命令 | 作用 | 改数据 |
|
||||
|---|---|---|
|
||||
| `hits` | 列出捕获到的凭据(时间倒序,密码默认脱敏) | 否 |
|
||||
| `stats` | 来源排行(IPv6 按 /64 聚合)+ 路径排行(库)+ 凭据排行(文件) | 否 |
|
||||
| `paths` | 列出当前生效的诱饵路径(按伪装家族汇总) | 否 |
|
||||
| `size` | 表行数、磁盘占用、凭据文件体积 | 否 |
|
||||
| `prune` | 按保留期清元数据 + 凭据文件 | 是 |
|
||||
| `purge-creds` | 删除旧版遗留的明文凭据列(**不可逆**) | 是 |
|
||||
|
||||
```bash
|
||||
./tamabox honeypot hits --since 7d --limit 50
|
||||
./tamabox honeypot hits --ip 203.0.113.9 --show-password
|
||||
./tamabox honeypot stats --days 7
|
||||
./tamabox honeypot paths
|
||||
./tamabox honeypot size
|
||||
./tamabox honeypot prune --days 30
|
||||
./tamabox honeypot purge-creds
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- **凭据不入库**。用户名/密码只写在 `data/honeypot-creds/creds-YYYY-MM-DD.jsonl`
|
||||
(目录 0700、文件 0600),数据库里只有 IP / 路径 / UA / 是否封禁这些元数据。
|
||||
所以 `hits` / `paths` **不连数据库**,DB 挂了也能查。
|
||||
- 密码默认脱敏(保留首尾各 1 位)。终端输出常被贴进工单或截图,`--show-password` 只在确有取证需要时开。
|
||||
- `hits` / `stats` 里的 `ip` 是**精确**客户端地址;封禁用的是 `shared.LimitKey` 折叠后的键
|
||||
(IPv6 为 `/64` 前缀)。两者在 IPv6 下不一致是正常的,别拿邮件里的精确地址去对封禁键。
|
||||
- `purge-creds` 用于清掉早期版本存在 `honeypot_hits` 上的明文列(AutoMigrate 不删既有列)。
|
||||
执行前先 `pg_dump`;它只处理当前库,**历史备份与从库里的明文需要自行清理**。
|
||||
- 封禁语义:GET 探测走常规阈值(默认 10 次 / 60 秒),**提交凭据才一次即封**。
|
||||
所以「扫了几十次怎么没封」不是 bug —— 见 [honeypot.md](honeypot.md) 的「两条封禁路径」。
|
||||
|
||||
---
|
||||
|
||||
## 8. `backup` / `restore-images` —— 数据库与 R2 图片
|
||||
|
||||
### 8.1 备份
|
||||
|
||||
```bash
|
||||
# 数据库 + 配置 bucket 的全部对象
|
||||
./tamabox backup --output /var/backups/tamabox
|
||||
|
||||
# 只备份对象,或只备份数据库
|
||||
./tamabox backup --images-only --output /var/backups/tamabox
|
||||
./tamabox backup --db-only --output /var/backups/tamabox
|
||||
```
|
||||
|
||||
图片备份使用**空前缀**分页列举配置 bucket 的全部对象,不再只查 `tamabox/`。根日期 key、
|
||||
`picture/`、`tamabox/` 和静态资源都会按原 key 保存到 `<output>/images/`。
|
||||
备份会先完成全部分页 List、key 与本地路径预检,再开始下载;非法路径、目录 key、文件/目录碰撞会中止备份。
|
||||
|
||||
`images-manifest.json` 记录实际下载字节的 `size`、`sha256`,以及 GET 返回的 `etag`、
|
||||
`content_type`、`cache_control`。对象与 manifest 都采用临时文件原子落盘;下载流中断会整对象重试,
|
||||
最终失败不会留下 `.part`。空 bucket 的 manifest 是 `[]`。
|
||||
|
||||
### 8.2 恢复与隔离演练
|
||||
|
||||
```bash
|
||||
# 校验全部本地文件并检查默认防覆盖,不写远端
|
||||
./tamabox restore-images --input /var/backups/tamabox --dry-run
|
||||
|
||||
# 实际恢复到原 key;必须显式 --yes
|
||||
./tamabox restore-images --input /var/backups/tamabox --yes
|
||||
|
||||
# 明确允许替换已存在对象
|
||||
./tamabox restore-images --input /var/backups/tamabox --yes --overwrite
|
||||
|
||||
# 在原完整 key 前加隔离前缀,不覆盖生产 key
|
||||
./tamabox restore-images --input /var/backups/tamabox --yes \
|
||||
--target-prefix tamabox-backup-test/manual-restore/
|
||||
```
|
||||
|
||||
`--input` 可指向备份目录,也可直接指向 `images-manifest.json`。恢复会在任何远端操作前校验全部
|
||||
manifest 条目和本地文件(普通文件、非 symlink、大小、实际 SHA-256)。默认先 Head 目标;任一目标已存在
|
||||
即失败且零 Put,只有 `--overwrite` 才允许替换。`--dry-run` 不写对象,实际写入必须 `--yes`。
|
||||
|
||||
`--target-prefix` 会加在 manifest 的完整 key 前;非空前缀末尾没有 `/` 时会自动补一个。
|
||||
Put 保留内容、原 key 路径(额外添加指定前缀)、`ContentType` 与 `CacheControl`,并使用单对象超时和有限重试。
|
||||
**恢复不是事务**:中途失败会明确报告已成功数量,已上传对象不会自动回滚。
|
||||
|
||||
---
|
||||
|
||||
## 9. 典型处置流程
|
||||
|
||||
1. **发现**:收到告警邮件(同一箱主 24h ≥ 8 条 或 7d ≥ 20 条)→ 或箱主后台出现「被举报 N 次」。
|
||||
2. **取证**:`reports --domain <domain>` 看条目、占比、历史处置。
|
||||
3. **冷静期**:举报后 24 小时内不作判定(避免被集中举报推着走,也避免情绪化处置)。
|
||||
4. **止血**:`restrict --level L1 --days 7 --reason "..."`。默认会通知箱主;
|
||||
若判断通知会导致其转移阵地、需要先静默收口,加 `--no-notify`。
|
||||
5. **清存量**:`hide-public --domain <domain> --only-reported`。
|
||||
6. **观察**:7 天内复发 → 升 L2/L3;双方互挂 → **两边同级别处理,不选边**。
|
||||
7. **收尾**:到期自动解除(不发信);需要提前解封用 `unrestrict`(会给箱主发「已解除」通知)。
|
||||
8. **留痕**:`reports --domain <domain>` 的历史处置段落就是处置记录(谁、何时、级别、原因)。
|
||||
|
||||
判定「挂人」的三要件(同时成立才处理):
|
||||
|
||||
- **可识别性**:能让圈内人定位到具体账号/作品/主页(昵称、代称、@、外链、作品标签)
|
||||
- **贬损或号召规避**:带「避雷/挂/别信/辱骂」这类定性,而不是中性描述
|
||||
- **无公共利益**:纯私人纠纷、粉籍立场之争
|
||||
|
||||
只成立一两条 → 不处理或仅提示。
|
||||
|
||||
---
|
||||
|
||||
## 10. 排错
|
||||
|
||||
| 现象 | 原因 | 处理 |
|
||||
|---|---|---|
|
||||
| `No help topic for 'xxx'` | 构建入口不同步(根 `tamabox.go` vs `cmd/tamabox.go`) | 两处都注册后重新编译 |
|
||||
| `find user: record not found` | `--domain` 填了昵称/登录名 | domain 是 URL 里的那一段,`/_/{domain}` |
|
||||
| 处置后页面无变化 | 二进制是旧的 / 服务没重启 | `--help` 看命令在不在;抓线上 HTML 比对标记 |
|
||||
| 待处理列表是空的 | 举报已被箱主处理(自动 accepted)或超过 30 天 | 正常;必要时直接查库 |
|
||||
| 告警邮件一直没收到 | `[security] security_alert_email` 为空(静默跳过,无日志) | 配好并**重启** |
|
||||
| 举报数始终是 1 | `trust_proxy` 未按部署环境配置,全部识别为代理 IP | 见 §2.4 |
|
||||
| 箱主说「我改了但没生效」 | 处置期内严格度被锁定 / L2 禁止设为公开 | 看后台条幅;`reports` 查当前处置 |
|
||||
| `--days 超过 90 天` 被拒绝 | 防手误设计,需要 `--force` | 确认时长后重试,或改用常规天数 |
|
||||
| 处置成功但箱主说没收到邮件 | SMTP 未配 / 发送失败 / 用了 `--no-notify` | 看终端 warn;`[mail]` 配好后可 `restrict` 重发(同级别重设会替换记录) |
|
||||
| 蜜罐记录的 `ip` 是 `127.0.0.1` | `trust_proxy` 未配,源站把所有人看成同一个代理 IP | 配 `trust_proxy`;**此时一次封禁会封掉整站**(见 [honeypot.md](honeypot.md) 的「部署」一节) |
|
||||
| 整站都返回 403 | 蜜罐把反代 IP 封了(上一条的后果) | 重启进程(封禁是内存态)或等 `honeypot_ban_minutes` 到期 |
|
||||
| 换了二进制但行为没变 | 进程没重启,跑的还是旧映像 | `ps -eo lstart,cmd \| grep tamabox` 比对启动时间与文件 mtime |
|
||||
| `strings 二进制 \| grep 中文` 恒为 0 | `strings` 只输出可打印 ASCII,中文 UTF-8 被丢弃 | 用 `grep -ac "中文字符串" 二进制` |
|
||||
| `honeypot` 命令不存在 | 二进制旧 / 两个构建入口未同步 | 重新编译后**重启进程** |
|
||||
|
||||
改完配置**必须重启**才生效(配置只在启动时读取一次)。
|
||||
|
||||
---
|
||||
|
||||
## 11. 明确不做的事
|
||||
|
||||
- **不做自动处置**:举报数达标只发邮件,不自动封禁。这个场景里举报本身就是攻击武器。
|
||||
- **不做申诉后台**:申诉走邮件,处置结果由 CLI 写入。
|
||||
- **不用配置文件当黑名单**:无法表达「N 天后自动解除」,最终会变成手工维护灾难。
|
||||
- **不要在 CLI 之外直接改库**:`user_restrictions` 的写入必须带 `revoked_at` 语义,
|
||||
手工 UPDATE 容易留下多条同时生效的记录(读取端会取最高级别,但不便于审计)。
|
||||
@@ -0,0 +1,301 @@
|
||||
# 蜜罐(虚假后台登录页)
|
||||
|
||||
给扫描器一组假的后台登录页(WordPress / phpMyAdmin / Jenkins / Tomcat / Grafana …):它探后台路径时返回对应产品外观的假页面,它提交账号密码时把凭据记下来、并把它临时封掉。
|
||||
|
||||
一句话取舍:**这套功能的价值是「拿到对方正在用的字典」,不是「挡住扫描」。** 挡扫描靠 CDN 和 `autoban`;蜜罐只在「有人真的往假后台里填账号密码」时才有意义。
|
||||
|
||||
---
|
||||
|
||||
## 1. 生效范围
|
||||
|
||||
蜜罐挂在 `f.NotFound` 上 —— flamego 只有**路由表完全落空**时才会走到这里,所以:
|
||||
|
||||
- `/login`、`/register`、`/_/{domain}`、`/user/**`、`/api/v1/**` 这些真实路由永远不会被接管;
|
||||
- 命中后短路返回,不查业务表、不渲染业务模板、不调用 `ctx.Next()`;
|
||||
- 页面是纯静态 HTML,只含 canary 标记和假表单,不含任何真实信息。
|
||||
|
||||
匹配规则(`internal/security/honeypot/honeypot.go`):
|
||||
|
||||
| 类型 | 例子 |
|
||||
|---|---|
|
||||
| 段前缀 | `/admin`、`/wp-admin`、`/.ssh`、`/.env`、`/phpmyadmin`、`/actuator` |
|
||||
| 任意位置片段 | `/wp-includes/`、`/xmlrpc.php`、`/cgi-bin/`、`/.git/` |
|
||||
| 文件后缀 | `*.zip`、`*.sql`、`*.bak`、`*.pem`、`*.key`、`*.env` |
|
||||
| 自定义 | `[security] honeypot_paths`(逗号分隔,与内置清单取并集) |
|
||||
|
||||
业务路由前缀(`/login`、`/api`、`/static` 等)即使出现在自定义清单里也会被强制剔除,构成第二道保险。
|
||||
|
||||
改完路径后用 `tamabox honeypot paths` 看最终生效的完整清单(会按家族汇总条数)。
|
||||
|
||||
### 会伪装成哪几类后台
|
||||
|
||||
| 家族 | 典型路径 | 表单字段 / 响应 |
|
||||
|---|---|---|
|
||||
| WordPress | `/wp-login.php`、`/wp-admin`、`/wp-json` | `log` / `pwd`,带 `wordpress_test_cookie` |
|
||||
| phpMyAdmin | `/phpmyadmin`、`/pma`、`/adminer.php` | `pma_username` / `pma_password` |
|
||||
| Tomcat | `/manager/html`、`/host-manager` | **401 + `WWW-Authenticate`**(Basic,凭据从 `Authorization` 头取) |
|
||||
| Jenkins | `/jenkins`、`/jnlpJars` | `j_username` / `j_password`,带 `X-Jenkins` 头 |
|
||||
| Grafana | `/grafana` | `user` / `password`(深色主题) |
|
||||
| GitLab | `/users/sign_in` | `user[login]` / `user[password]` |
|
||||
| LuCI(路由器) | `/cgi-bin/luci`、`/luci` | `luci_username` / `luci_password` |
|
||||
| Joomla | `/administrator` | `username` / `passwd` |
|
||||
| 通用后台 | `/admin`、`/console`、`/zabbix`、`/jira` … | `username` / `password` |
|
||||
|
||||
**为什么要分类**:扫描器和爆破脚本都是「先按路径判断这是什么后台,再用它认识的字段名填表」。所有路径都回 WordPress 页面,等于在 `/phpmyadmin/index.php` 上告诉对方「这里不是 PMA」,对方直接换目标 —— 我们既拿不到凭据,也留不下有效取证。
|
||||
|
||||
**静态资源也一起伪装**:`/wp-admin/css/login.min.css`、`/wp-includes/js/*.js`、`/wp-admin/images/*.png` 这类请求回占位 CSS/JS/真 1×1 PNG。这些请求**不落库、不计数** —— 一次页面加载会打 5–10 个资源,逐个 INSERT 等于帮攻击者把写入放大十倍。插件目录下的 `.php` 则回 404(那是 webshell 探测,回登录页反而不像)。
|
||||
|
||||
**`/xmlrpc.php` 单独按 XML-RPC 回应**,不是登录页:
|
||||
|
||||
| 请求 | 响应 |
|
||||
|---|---|
|
||||
| `GET` / 其它非 POST | `405` + XML fault「XML-RPC services are disabled on this site.」 |
|
||||
| `POST` 普通 `methodCall` | `200` + XML fault「Incorrect username or password.」 |
|
||||
| `POST` `system.multicall` | 同上,但**一包里的所有凭据对都会被记下来** |
|
||||
| `POST` `system.listMethods` | `200` + 方法列表(枚举类工具靠它确认对面是真 WP) |
|
||||
|
||||
为什么值得单独做:`system.multicall` 一次请求能塞进上千组用户名/密码,且绕开登录页的限速插件,是 WordPress 爆破里产出最高的一条通道。我们自己的 `X-Pingback` 响应头又正指着这个地址 —— 回 HTML 等于自己指路给对方看破绽。
|
||||
|
||||
动态值(phpMyAdmin 的 `token`、通用后台的 `csrf_token`、Jenkins 的 `X-Jenkins-Session`、GitLab 的 `_gitlab_session`)**每个请求都不同**。恒定值是最容易被指纹识别的特征:连打两次拿到一模一样的 token,任何脚本都能判定对面是伪造的。
|
||||
|
||||
单页渲染约 3.7 µs / 2 次分配、页面 1.5–2.5 KB,资源请求 0 分配(`BenchmarkRenderFamily` / `BenchmarkAssetFor` 守着)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 配置
|
||||
|
||||
```ini
|
||||
[security]
|
||||
enable_honeypot = true
|
||||
honeypot_paths =
|
||||
honeypot_threshold = 1
|
||||
honeypot_ban_minutes = 1440
|
||||
honeypot_retain_days = 30
|
||||
honeypot_auto_clean = true
|
||||
honeypot_max_rows = 200000
|
||||
honeypot_cred_dir = data/honeypot-creds
|
||||
```
|
||||
|
||||
| 键 | 缺省 | 说明 |
|
||||
|---|---|---|
|
||||
| `enable_honeypot` | `false` | 总开关。关闭后 `/wp-admin` 等路径恢复原先的 302 → `/` |
|
||||
| `honeypot_paths` | 空 | 追加诱饵路径,与内置清单取并集 |
|
||||
| `honeypot_threshold` | `1` | **提交凭据**的封禁阈值(见 §3) |
|
||||
| `honeypot_ban_minutes` | `1440` | 蜜罐封禁时长;显式 `0` 回退 `ban_duration_minutes` |
|
||||
| `honeypot_retain_days` | `30` | 元数据与凭据文件的保留天数;`<= 0` 也回退 30 |
|
||||
| `honeypot_auto_clean` | `true` | 每小时一轮后台清理;关闭后只能手动 `honeypot prune` |
|
||||
| `honeypot_max_rows` | `200000` | 元数据行数上限(第二道闸);显式 `0` = 不限 |
|
||||
| `honeypot_cred_dir` | `data/honeypot-creds` | 凭据文件目录,进程工作目录为基准 |
|
||||
|
||||
> ⚠️ **配置项一律写成「注释独占一行 + 裸值」**。配置加载器开启了 `IgnoreInlineComment`,行尾 `; 注释` 会被并入值:`honeypot_max_rows = 200000 ; 说明` 解析出来是 `"200000 ; 说明"`,int 解析失败而 `MapTo` 不报错 —— 该项静默回退零值,日志里只会说「配置值不对」。
|
||||
|
||||
改完配置**必须重启**(配置只在启动时读一次)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 两条封禁路径(最容易误解的地方)
|
||||
|
||||
| 行为 | 走哪条通道 | 阈值 | 是否立即封 |
|
||||
|---|---|---|---|
|
||||
| `GET /wp-login.php` 看一眼假页面 | 常规 `autoban.Record` | `ban_threshold_sensitive`(默认 10 次 / 60 秒) | 否 |
|
||||
| `GET /wp-admin/css/login.min.css` 等资源 | **不计数、不落库** | — | 否 |
|
||||
| `POST` 带该家族的凭据字段(`log`/`pwd`、`pma_*`、`j_*`、`user[login]` …) | 蜜罐 `autoban.RecordHoneypot` | `honeypot_threshold`(默认 1) | 是,立即封 |
|
||||
| `POST /xmlrpc.php` 带 `methodCall`(含 `system.multicall`) | 蜜罐 `autoban.RecordHoneypot` | 同上 | 是,立即封 |
|
||||
| `GET /xmlrpc.php` / `POST system.listMethods` | 常规通道 | 同上 | 否 |
|
||||
| `POST` 不带任何凭据字段 | 常规通道 | 同上 | 否 |
|
||||
|
||||
**为什么 GET 不立即封**:爆破脚本的标准流程是「先 GET 探表单 → 再 POST 提交」。如果 GET 就吃 403,假登录页只被看了一次,对方根本没机会填表,凭据永远拿不到 —— 而这个功能的全部价值就在那份凭据上。
|
||||
|
||||
蜜罐通道**不受 `enable_auto_ban` 约束**:关掉常规自动封禁(它对共享出口误伤多)不会连带让蜜罐变成只记录不封禁的旁观者。
|
||||
|
||||
纯探测也不会被刷爆:元数据落库按「每 IP 每分钟一条」采样,凭据则是「有就存」——那才是要保的东西。
|
||||
|
||||
### 被封之后会看到什么
|
||||
|
||||
状态码永远 `403`,body 由 `[security] ban_image_path` 决定:
|
||||
|
||||
```ini
|
||||
[security]
|
||||
ban_image_path = data/403.webp
|
||||
```
|
||||
|
||||
| 情况 | 响应体 |
|
||||
|---|---|
|
||||
| 配了合法图片 | 图片本身(`Content-Type` 按内容嗅探,png/jpeg/gif/webp 都行) |
|
||||
| 留空 | 纯文字 `403 Forbidden`(改造前的老行为) |
|
||||
| 文件读不到 / 是目录 / 不是图片 / 超过 2 MB | 纯文字 `403 Forbidden` + 启动日志 Warn |
|
||||
|
||||
```
|
||||
$ curl -si https://你的站点/anything | head -5
|
||||
HTTP/2 403
|
||||
content-type: image/webp
|
||||
content-length: 8608
|
||||
cache-control: no-store, no-cache, must-revalidate
|
||||
```
|
||||
|
||||
为什么要能换成图:爆破脚本常靠读响应体里的 `403 Forbidden` 确认自己已被拦,换掉 body 让这类判断失效;人肉访问(尤其是被共享出口连累的误封)时,看到一张明确的图比白屏好判断。但这是**可选**的——配错了不会影响封禁本身,只是回到纯文字。
|
||||
|
||||
几个约束,都是刻意的:
|
||||
|
||||
- **只认内容,不认扩展名**(`http.DetectContentType`)。把 `ban_image_path` 配成 `app.ini` / `.env` 会被拒绝并告警——否则等于把配置文件原样吐给每一个被封的攻击者。
|
||||
- **上限 2 MB**。被封来源会反复重试,每张图都照常占用出网带宽;建议 256×256、20 KB 以内。
|
||||
- **启动时读一次**,之后每个请求只读内存,不在热路径上做磁盘 IO。换图 = 换文件 + 重启进程。
|
||||
- `no-store`:中间层缓存一张 403 没有意义,还会让解封后的响应继续命中缓存。
|
||||
|
||||
### 封的是一把「键」,不是一个人
|
||||
|
||||
`autoban` 的表就是内存里的 `map[键]到期时间`,键由 `shared.LimitKey` 生成:
|
||||
|
||||
- IPv4 → 原样(`203.0.113.7`)
|
||||
- IPv6 → 折叠到 `/64`(`2408:8207:1234:5678::/64`),防止在单个 /64 内轮换地址绕过阈值
|
||||
|
||||
所以告警邮件里**可能有两个地址**:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| 来源 IP | 精确客户端地址,取证与对账用 |
|
||||
| 封禁范围 | 实际写进封禁表的键;IPv6 下是 /64 前缀,整段一起封(IPv4 下与来源 IP 相同,不重复显示) |
|
||||
|
||||
两个直接后果,排障时先记住:
|
||||
|
||||
1. **封禁只在内存里** —— 重启进程即解除,邮件不会撤回;
|
||||
2. **封的键与请求算出来的键必须一致** —— 换地址族(IPv6 访问 / IPv4 回源)或换 /64 前缀就不会被拦。
|
||||
|
||||
每次建封都会打一条 WARN,键、原因、时长、到期时间都在里面:
|
||||
|
||||
```bash
|
||||
journalctl -u tamabox -n 50 | grep "已自动封禁来源"
|
||||
# ban_key=2408:8207:1234:5678::/64 reason=蜜罐提交凭据 duration=24h0m0s expires_at=...
|
||||
```
|
||||
|
||||
`ban_key` 就是被封的键,与邮件里的「封禁范围」一致。若这里没有记录,说明当时根本没建封(邮件可能是别的通道发的);若有记录但仍能访问,就去比对请求算出来的键。
|
||||
|
||||
---
|
||||
|
||||
## 4. 凭据存在哪里
|
||||
|
||||
**用户名/密码不进数据库**,只追加到按天分文件的 JSONL:
|
||||
|
||||
```
|
||||
data/honeypot-creds/creds-2026-10-21.jsonl
|
||||
```
|
||||
|
||||
```json
|
||||
{"time":"2026-10-21T16:13:50+08:00","ip":"117.x.x.226","method":"POST",
|
||||
"path":"/wp-login.php","username":"admin2","password":"test456",
|
||||
"fields":{"log":"admin2","pwd":"test456"},"raw_body":"log=admin2&pwd=test456",
|
||||
"user_agent":"curl/8.22.0","banned":true}
|
||||
```
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 目录权限 | `0700`(启动时强制 `chmod`) |
|
||||
| 文件权限 | `0600` |
|
||||
| 切分 | 按记录时间,一天一个文件 |
|
||||
| 坏行 | 跳过(进程被杀留下的半条 JSON 不会让整天数据报废) |
|
||||
| 清理 | 与 `honeypot_retain_days` 同步,只删「整天都过期」的文件 |
|
||||
|
||||
**为什么不入库**:明文账号密码一旦入库,就会跟着进备份、主从复制、以及任何有库只读权限的报表/BI 工具,事后无法彻底清除。文件可以单独挂卷、单独设权限、到期直接删。
|
||||
|
||||
数据库里只保留「发生过一次带凭据的提交」这个事实(`honeypot_hits.had_fields`),以及 IP / 路径 / UA / 是否封禁这些元数据。
|
||||
|
||||
> `ip` 两处存的都是**精确客户端地址**(IPv6 保留完整地址),不是封禁用的 `/64` 聚合键 ——
|
||||
> 取证要能定位到具体主机。想知道「封了多大范围」,看告警邮件的「封禁范围」或日志里的 `ban_key`。
|
||||
|
||||
> ⚠️ 这些是**外部攻击者提交的真实账号密码**,很可能是从别处泄露后复用的。请按敏感数据对待:不要提交进版本库(`/data/` 已在 `.gitignore`)、不要参与备份、不要贴到公开群组。
|
||||
|
||||
---
|
||||
|
||||
## 5. 部署:反向代理必须配 `trust_proxy`
|
||||
|
||||
这是**部署时最容易踩、后果最严重**的一条。
|
||||
|
||||
如果站点在 nginx / Cloudflare 之后而 `trust_proxy = false`:
|
||||
|
||||
- 源站看到的所有请求都来自代理地址(常见 `127.0.0.1`),**取证数据失去意义**;
|
||||
- 更糟:一次蜜罐命中封的是 `127.0.0.1`,而所有访客在源站眼里都是这个 IP —— **等于封掉整站**。
|
||||
|
||||
正确配置:
|
||||
|
||||
```ini
|
||||
[security]
|
||||
trust_proxy = true
|
||||
trusted_proxies = 127.0.0.1
|
||||
```
|
||||
|
||||
> ⚠️ 前提:**源站不能直接被公网访问**(Cloudflare Tunnel / Authenticated Origin Pull / 防火墙只放行回源网段)。`trust_proxy = true` 时程序会信任 `CF-Connecting-IP` 这类权威头,源站若可直连,攻击者能伪造它来绕过封禁、或反手封掉别人。
|
||||
|
||||
程序在「命中回环地址且 `trust_proxy` 未开」时会打一条一次性 WARN(24 小时冷却)提醒检查这里。
|
||||
|
||||
验证:触发一次蜜罐后看凭据文件里的 `ip` 是不是真实客户端地址。
|
||||
|
||||
---
|
||||
|
||||
## 6. 升级:清掉历史明文列
|
||||
|
||||
早期版本把 `username` / `password` / `fields` / `raw_body` 直接存在 `honeypot_hits` 上。结构体已经删掉这些字段,但 **GORM 的 AutoMigrate 不会删既有列** —— 老库升级上来后,历史明文还在表里。
|
||||
|
||||
启动时会打一条 WARN:
|
||||
|
||||
```
|
||||
honeypot_hits 上仍残留旧版明文凭据列,请执行 TamaBox honeypot purge-creds 清除
|
||||
```
|
||||
|
||||
清理(**不可逆**,先备份):
|
||||
|
||||
```bash
|
||||
pg_dump -Fc <dbname> > before-purge-$(date +%F-%H%M).dump
|
||||
./tamabox honeypot purge-creds # 列出四列,输入 yes 才执行
|
||||
```
|
||||
|
||||
只处理当前库;**历史备份与从库里可能仍有明文**,需要自行处理。
|
||||
|
||||
---
|
||||
|
||||
## 7. 日常运维
|
||||
|
||||
```bash
|
||||
./tamabox honeypot hits # 最近 24h 的凭据(密码默认脱敏)
|
||||
./tamabox honeypot hits --ip 1.2.3.4 --show-password
|
||||
./tamabox honeypot stats --days 7 # 来源排行(IPv6 按 /64 聚合)+ 路径排行 + 凭据排行
|
||||
./tamabox honeypot paths # 当前生效的诱饵路径
|
||||
./tamabox honeypot size # 表行数 + 磁盘占用 + 凭据文件体积
|
||||
./tamabox honeypot prune --days 30 # 手动按保留期清(元数据 + 凭据文件)
|
||||
```
|
||||
|
||||
`hits` / `paths` 不连数据库(DB 挂了也能查)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 排障
|
||||
|
||||
| 现象 | 原因 | 处理 |
|
||||
|---|---|---|
|
||||
| **邮件说封了,但还能正常访问** | ① 进程重启过(封禁是内存态)② 后续请求换了地址族 / 换了 /64 前缀 ③ `trust_proxy` 未配,封的是反代地址 | 看日志里 `已自动封禁来源` 的 `ban_key`,与 `honeypot hits` 里的 IP、以及本机 `curl -6 ifconfig.co` 比对;见 §3「封的是一把键」 |
|
||||
| `/wp-admin` 还是 302 | `enable_honeypot` 未开 / 配置未重启生效 | 看启动日志;`honeypot paths` 确认开关状态 |
|
||||
| 记录里 `ip` 是 `127.0.0.1` | `trust_proxy` 未配 | 见 §5;**注意此时封禁会封全站** |
|
||||
| 整站都返回 403 | 蜜罐把反代 IP 封了(上一条的后果) | 重启进程(封禁是内存态)或等 `honeypot_ban_minutes` 到期 |
|
||||
| GET 假页面不封禁 | 设计如此 | 提交凭据才立即封,见 §3 |
|
||||
| 记录里 `path` 带前导空白/奇怪字符 | 上游(nginx/CF)改写了 URI | 代码已清洗并打 WARN;根因建议查 `proxy_pass` / `rewrite` |
|
||||
| `honeypot` 命令不存在 | 二进制旧 / 两个构建入口未同步 | `go build -o tamabox .` 后**重启进程** |
|
||||
| 改了代码但行为没变 | 只替换了二进制,进程还在跑旧映像 | `kill` 旧进程后按原方式重新拉起 |
|
||||
| 凭据文件没有生成 | `honeypot_cred_dir` 不可写 | 看启动日志里的「凭据目录不可用」告警 |
|
||||
| 启动报 `honeypot_hits 上仍残留…` | 老库有历史凭据列 | `honeypot purge-creds`(§6) |
|
||||
|
||||
排障时两个容易误判的点:
|
||||
|
||||
- **验证二进制里有没有某个中文字符串,不能用 `strings`** —— 它默认只输出可打印 ASCII,UTF-8 中文整段被丢弃,`strings x | grep 中文` 恒为 0。用 `grep -ac "中文字符串" x`。
|
||||
- **替换二进制 ≠ 生效**,进程必须重启;前台跑 `./tamabox web` 若立刻返回,通常是端口被旧进程占着。
|
||||
- **邮件里的「来源 IP」不是封禁键**:IPv6 下前者是精确地址、后者是 /64 前缀,拿前者去查后者永远对不上。判「封没封」只看 `ban_key`。
|
||||
|
||||
---
|
||||
|
||||
## 9. 设计取舍(已知限制)
|
||||
|
||||
- **GET 探测不封**:见 §3。代价是扫描器可以反复拿假页面,收益是能拿到凭据。
|
||||
- **假页面带 canary**:只放在 HTML 注释里(`<!-- build: tb-hp-... -->`),用于确认「这确实是蜜罐返回的页面」;`<meta name="generator">` 伪装成 `WordPress 6.4.3`,不主动暴露技术栈。
|
||||
- **只挡 NotFound**:如果将来给 `/admin` 之类路径注册了真实路由,蜜罐会自动让位 —— 这是刻意设计(业务优先)。
|
||||
- **告警邮件仍带明文用户名/密码**:这正是它的价值(看到对方在用什么字典),但也意味着邮件是敏感物,收件人只应是你自己。
|
||||
- **不主动反制**:不回打、不上报第三方、不做删除/篡改,只有「记录 + 临时封禁」。
|
||||
- **XML-RPC 的凭据提取是启发式的**:不上 `encoding/xml` 解析(蜜罐会被高频命中),而是抓出所有 `<string>`、剔除方法名与站点 URL 后两两配对。`wp.getUsersBlogs` 的第一个参数是 `blog_id`,若是数字会让配对错位一位——为此会把原始串全量写进取证文件的 `fields`(`xmlrpc.0`、`xmlrpc.1` …),即便配对失败原文也不丢。
|
||||
- **`X-Pingback` 需要配 `external_url`**:没配就不发这个头(相对路径比不发更假)。
|
||||
@@ -0,0 +1,135 @@
|
||||
# 把主题素材放到对象存储(任意 S3 兼容)
|
||||
|
||||
这里的「对象存储」不特指某一家:Cloudflare R2、自建 MinIO、Backblaze B2、
|
||||
阿里云 OSS 的 S3 兼容端点、腾讯云 COS 的 S3 兼容端点都走同一套 SigV4,
|
||||
差别只在 Endpoint、Region、是否路径风格。**放本地还是放对象存储,由部署者自己决定** ——
|
||||
主题生成器里「素材托管」就是这两选一。
|
||||
|
||||
现状:主题素材在 `static/images/themes/<slug>/`,通过 `static/fs.go` 的 `//go:embed images/themes`
|
||||
随二进制发布。好处是部署即生效、不依赖外部服务;代价是**换一张图就要重新编译部署**,
|
||||
而且一张 1920×1080 的场景图会让二进制大几百 KB。
|
||||
|
||||
主题素材只有一处引用:**主题 CSS 里的 `url(...)`**(`--tb-page-image` / `--tb-page-pattern` /
|
||||
`--tb-deco`)。分享卡是 Go 里用 `imaging` 画出来的,不读主题素材。所以只要解决「CSS 里的地址指向哪」,
|
||||
就把整套素材搬走了。
|
||||
|
||||
下面三种做法按「改动量从 0 到有代码」排列,选一个即可,不必叠加。
|
||||
|
||||
---
|
||||
|
||||
## A. 生成器直接写绝对地址 + 就近上传(已支持,推荐)
|
||||
|
||||
`dev/theme-builder` 左侧「**素材托管**」有两个选项:本地(随二进制)/ S3 兼容存储。
|
||||
|
||||
选 S3 后填:
|
||||
|
||||
```
|
||||
Endpoint https://<账号>.r2.cloudflarestorage.com ← MinIO / B2 / OSS / COS 填各自的
|
||||
Region auto ← R2 用 auto;MinIO 常为 us-east-1
|
||||
桶名 my-theme-assets
|
||||
路径前缀 themes
|
||||
公开前缀 https://cdn.example.com/themes ← 留空 = 按 Endpoint 推导
|
||||
Access Key / Secret Key
|
||||
```
|
||||
|
||||
然后:
|
||||
|
||||
1. **测试连接** —— `HEAD` 桶(连通+凭据)→ `PUT` 探针(写权限)→ `GET` 读回(内容一致)
|
||||
→ 匿名 `GET`(能不能被浏览器直接读到)→ 删除探针。失败会把 S3 的 `<Code>` 翻成人话。
|
||||
2. **上传素材** —— 逐张 PUT 本次主题引用到的图,键为 `<路径前缀>/<slug>/<id>.webp`,
|
||||
带 `public, max-age=31536000, immutable`。成功后自动把导出用的「素材前缀」填好。
|
||||
3. **检测远端** —— `ListObjectsV2` 列出 `<前缀>/<slug>/` 下所有对象,按对象键名跟本地比:
|
||||
一致 / 旧图(同名但大小不同)/ 多余(当前主题已不引用)/ 缺失。
|
||||
可以「补齐差异」(只传缺的与旧图)、「删除多余」,也可以单行覆盖或删除。
|
||||
|
||||
上传经本机 `serve.py` 代理(`/api/s3/test`、`/api/s3/upload`),浏览器不直连对象存储:
|
||||
**不需要给桶配 CORS**,密钥只在当前标签页的 sessionStorage 与内存里,
|
||||
不进草稿、不进工程 json、不进导出 zip。
|
||||
|
||||
不想用面板也行,导出的 zip 里图片照旧在 `static/images/themes/<slug>/*.webp`,
|
||||
用任何 S3 客户端推上去即可:
|
||||
|
||||
```bash
|
||||
aws s3 sync static/images/themes/shiroko/ s3://my-bucket/themes/shiroko/ \
|
||||
--endpoint-url https://<你的 Endpoint> --cache-control "public, max-age=31536000, immutable"
|
||||
sed -i 's#/static/images/themes/shiroko/#https://cdn.example.com/themes/shiroko/#g' \
|
||||
static/css/themes/shiroko.css # 已有主题不想重导时,等价于「填前缀再导一次」
|
||||
```
|
||||
|
||||
- 优点:零服务端改动、零跳转、缓存最优(CDN 直接命中);
|
||||
- 缺点:域名烘焙进 CSS —— 换域名/换路径要**重新导出**(旧主题可以用 `sed` 批量替换);
|
||||
- 仓库里可以删掉 `static/images/themes/<slug>/` 来缩小编译产物,**确认 CSS 已是绝对地址**再删。
|
||||
|
||||
---
|
||||
|
||||
## B. 应用内 301 跳转(配置驱动,CSS 一行都不用改)
|
||||
|
||||
`conf/app.ini`:
|
||||
|
||||
```ini
|
||||
[theme]
|
||||
asset_base = https://cdn.example.com/themes ; 留空 = 本地静态目录
|
||||
```
|
||||
|
||||
中间件必须在 `flamego.Static` **之前**注册(`internal/route/route.go` 里 `f.Use(flamego.Static(...))` 前面):
|
||||
|
||||
```go
|
||||
// 主题素材转发到对象存储:命中前缀就 301 出去,其余请求原样放过。
|
||||
// 必须挂在 static 中间件之前——Static 命中本地文件就直接返回了,轮不到这里。
|
||||
func themeAssetRedirect() flamego.Handler {
|
||||
const prefix = "/static/images/themes/"
|
||||
return func(ctx flamego.Context) {
|
||||
base := theme.AssetBase() // 从 conf 读,空串 = 不接管
|
||||
p := ctx.Request().URL.Path
|
||||
if base == "" || !strings.HasPrefix(p, prefix) {
|
||||
return
|
||||
}
|
||||
w := ctx.ResponseWriter()
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
http.Redirect(w, ctx.Request(), base+strings.TrimPrefix(p, prefix), http.StatusMovedPermanently)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 优点:已有主题、生成器导出的相对地址**都能用**,切 CDN 只改配置;
|
||||
- 缺点:冷缓存时每张图多一跳;**301 会被浏览器长期缓存**,以后想切回本地,
|
||||
老访客可能还在访问 CDN(介意的话用 `302`,或一开始就用方案 A);
|
||||
- 本地开发记得把 `asset_base` 留空,否则断网时素材全 404。
|
||||
|
||||
---
|
||||
|
||||
## C. 边缘 / 反代改写(零代码)
|
||||
|
||||
素材路径有稳定前缀,最适合在边缘处理:
|
||||
|
||||
- **Cloudflare**:Origin Rule / Transform Rule,把 `/static/images/themes/*` 的 Host 改成
|
||||
R2 自定义域(如 `cdn.example.com`),或 Redirect Rule 直接 301 过去;
|
||||
- **nginx**:`location /static/images/themes/ { proxy_pass https://cdn.example.com/themes/; }`,
|
||||
或 `rewrite` 成 CDN 地址后 `return 301`。
|
||||
|
||||
- 优点:应用完全不知道素材在哪,不占应用带宽;
|
||||
- 缺点:绑定部署环境,本地跑没有等效层(要考虑 fallback)。
|
||||
|
||||
---
|
||||
|
||||
## 三种方案共同要处理的细节
|
||||
|
||||
1. **缓存**:对象存储上建议
|
||||
`Cache-Control: public, max-age=31536000, immutable`(文件名带版本时)。
|
||||
换图**不要覆盖同名对象**——要么换 `id`(生成器里改文件名),要么给 URL 加 `?v=`;
|
||||
Cloudflare 默认把 query string 计入缓存键,所以 `?v=` 是有效的。
|
||||
2. **CORS 不用配**:`background-image` 不触发 CORS,匿名 GET 即可。
|
||||
3. **主题 CSS 本身不用上 CDN**:一个主题只有几 KB,而且随 `StaticVersion` 走缓存键,
|
||||
留在 `static/css/themes/` 更省事。
|
||||
4. **深色模式没有素材**(深色块 `--tb-deco` 恒为 `none`),别只传浅色图就以为漏传了。
|
||||
5. **分享卡不受影响**:`route/question/share.go` 用 `imaging` 直接画,不加载主题图片。
|
||||
6. 迁完之后 `go build` 会小一圈,但**主题 css 仍是编译期 embed**:改 CSS 还是要重编译部署。
|
||||
|
||||
---
|
||||
|
||||
## 建议
|
||||
|
||||
- 只是想让部署产物变小、换图不用重编译 → **A**(生成器里配好 S3、测通、一键上传,最省事,缓存也最好)。
|
||||
- 想保留「素材可以随时换、CSS 不用重发」的灵活性 → **B**(配一个 `asset_base`,接受多一跳)。
|
||||
- 站点已经在 Cloudflare 上、且愿意在边缘配规则 → **C**(最干净,但迁移成本在运维侧)。
|
||||
- 就用本地目录、不想依赖任何外部服务 → **什么都不用做**(默认行为,素材随二进制发布)。
|
||||
@@ -0,0 +1,204 @@
|
||||
# new-sakura(新·樱花):纯 CSS 樱花主题
|
||||
|
||||
和已有的 `sakura` 的区别:`sakura` 是「粉底 + 网点」的静态配色;`new-sakura` 把樱花本身
|
||||
做成了**可摆放的装饰层** —— 整朵花、带缺口的花瓣、带枝的花朵树,全部是内联 SVG 的
|
||||
`data:image/svg+xml` URI,写在主题 CSS 里。
|
||||
|
||||
因此这个主题:
|
||||
|
||||
- **不需要任何图片素材**,也不依赖对象存储(`static/images/themes/new-sakura/` 是空的);
|
||||
- 任意视口缩放都不糊(矢量),DPI 无关;
|
||||
- 换花色只改 `dev/theme-tools/gen_new_sakura.py` 里的一个色值,重跑即得;
|
||||
- 没有额外 HTTP 请求 —— SVG 就在 CSS 里。
|
||||
|
||||
代价是 CSS 有 54KB(服务端有 gzip,实际传输约 4KB)。主题 CSS 走 `StaticVersion` 缓存,一次性开销。
|
||||
|
||||
## 结构
|
||||
|
||||
```
|
||||
static/css/themes/new-sakura.css ← 生成产物,别手改
|
||||
dev/theme-tools/gen_new_sakura.py ← 改这里,重跑
|
||||
```
|
||||
|
||||
```bash
|
||||
python dev/theme-tools/gen_new_sakura.py
|
||||
```
|
||||
|
||||
生成器脚本里有三样东西:花形(`blossom` / `petal` / `branch` / `pattern`)、
|
||||
每个页面作用域的位置表(`SCOPES`)、以及配色(`LIGHT` / `DARK`)。
|
||||
|
||||
## 每个页面的樱花位置
|
||||
|
||||
`--tb-deco` / `--tb-deco-size` / `--tb-deco-position` 三组各 10 槽,必须等长
|
||||
(`TestDecoSlotCount` 会逐文件逐声明检查)。
|
||||
|
||||
| 作用域 | 选择器 | 构图 |
|
||||
| --- | --- | --- |
|
||||
| global | `html[data-theme-style="new-sakura"]` | 右下花枝 + 左上大花 + 两片落瓣 |
|
||||
| box | `…[data-page-scope="box"]` | 右上花枝垂下(细端在外、粗端朝内)+ 左下花与落瓣 |
|
||||
| question | `…[data-page-scope="question"]` | 左下花枝斜穿 + 右上大花 + 落瓣 |
|
||||
| admin | `…[data-page-scope="admin"]` | 左上花枝 + 左侧底部小花(卡片占满宽度,右侧会被盖住) |
|
||||
| profile | `…[data-page-scope="profile"]` | 右下花枝 + 左侧竖排小花与落瓣 |
|
||||
| vote | `…[data-page-scope="vote"]` | 左上花枝 + 右侧花与落瓣 |
|
||||
|
||||
位置一律「关键字 + 偏移」两值写法(`right -24px top -16px`)。三值语法里混用
|
||||
`left` / `bottom` 关键字是非法的,整条声明会作废、花瓣静默回到左上角。
|
||||
|
||||
### 窄屏(≤640px)
|
||||
|
||||
装饰层全部置 `none`:卡片占满宽度,左右只剩 ~16px 边距,枝条放哪儿都会糊在字上。
|
||||
樱花改由底纹承担 —— `@media` 里换成**加强版 pattern**(透明度 ×1.9)并把格子收到 180px;
|
||||
桌面那套 0.18~0.34 的透明度在 390px 上实测接近一片纯粉。
|
||||
|
||||
试过在「顶栏与卡片之间」那条 35px 的带子里放两朵小樱花:box 页卡片从 y=122 起还行,
|
||||
question / admin 页卡片从 y=110 起就切掉半个,结果是黏在卡片边上的粉色碎块。手机上没有
|
||||
可靠的留白,就不放装饰层 —— 观感交给配色与底纹。
|
||||
|
||||
⚠️ **窄屏覆盖必须重新声明每个作用域选择器**:`@media` 不提升特异性。
|
||||
作用域块是 `html[data-theme-style][data-page-scope="box"]`(0,2,1),而媒体查询里常见的
|
||||
`html[data-theme-style]`(0,1,1)根本压不住它 —— 表现是「手机上装饰层没关掉,桌面版照样铺开」。
|
||||
深色作用域块更是 0,3,1,还得再补一条 `[data-theme="dark"][data-theme-style][data-page-scope]`。
|
||||
`TestNarrowMediaOverridesScopedDeco` 会在这里把关:外层每一处非 none 的 `--tb-deco`,
|
||||
媒体查询里都要求有同属性名、属性数不少于它的选择器再声明一次。
|
||||
|
||||
### 深色
|
||||
|
||||
- 花瓣换成更亮一档的粉(`#f0a0b8`),枝条提亮到能看见;
|
||||
- 底纹 `--tb-page-pattern: none`:浅粉花瓣压在深底上会变成一层噪点;
|
||||
- 整页背景改深色渐变(不用纯色,避免大面积死黑)。
|
||||
|
||||
深色块必须写成 `html[data-theme="dark"][data-theme-style="new-sakura"]`(三重选择器),
|
||||
只写浅色块会被同特异性的作用域块盖掉。
|
||||
|
||||
## 顶栏与手机底栏
|
||||
|
||||
顶栏走**磨砂玻璃**,不是实色也不是全透明:
|
||||
|
||||
```css
|
||||
--tb-navbar: rgba(253, 246, 248, 0.72); /* 亮色;深色 rgba(34, 26, 30, 0.72) */
|
||||
html[data-theme-style="new-sakura"] .uk-navbar-container:not(.uk-navbar-transparent) {
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
```
|
||||
|
||||
三种写法的实测结论:
|
||||
|
||||
| 写法 | 现象 |
|
||||
| --- | --- |
|
||||
| 实色 `#fdf6f8` | 花枝经过顶栏时被齐刷刷切掉一段,像被裁过 |
|
||||
| `transparent` | 花枝完整了,但顶栏"消失":导航没有依托,文字直接压在花纹上 |
|
||||
| `rgba(...,0.72)` + `blur(12px)` | 花枝透过来但被模糊成柔光,顶栏仍是一块可辨认的面 |
|
||||
|
||||
0.72 是怎么定的:导航文字 `#6d555d` 压在顶栏上,纯底色 6.34:1;最坏情况(深花瓣正好在顶栏后面)
|
||||
压平后 5.08:1,仍过 AA。再往下调透明度就会掉到 4.5 以下。
|
||||
|
||||
`TestNavbarNotFullyTransparent` 会拦住 `--tb-navbar: transparent`(以及 `rgba(...,0)`、
|
||||
`#rrggbb00`)—— 这条不是审美偏好,是把上面第二种写法踩过之后立的规矩。
|
||||
|
||||
手机底栏(`#tb-mobile-utils`)在 `base.css` 里写死 `rgba(255,255,255,.92)`,压在粉底上是
|
||||
一条白杠;主题里按同一思路改成 `rgba(253,246,248,.92)` / `rgba(34,26,30,.92)`。
|
||||
|
||||
另外 box 作用域的花枝从 `top -16px` 下移到 `top 26px`:顶栏半透明后,700~760px 宽度下
|
||||
枝头正好压在「登录」上(实测截图确认)。顶栏高 60 + 外边距 20,26px 起笔即落在顶栏下沿之上,
|
||||
宽屏仍是"从右上角长出来"的观感。
|
||||
|
||||
## 配色与对比度
|
||||
|
||||
| 令牌 | 亮色 | 深色 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--tb-bg` | `#fdf6f8` | `#221a1e` | 纯色,不用渐变(整页色感一致) |
|
||||
| `--tb-surface` | `#ffffff` | `#2c2227` | 卡片 |
|
||||
| `--tb-text` | `#3a2c31` (12.4:1) | `#f3e6ea` (14.0:1) | |
|
||||
| `--tb-text-secondary` | `#6d555d` (6.3:1) | `#c9aeb8` (8.3:1) | |
|
||||
| `--tb-text-muted` | `#77626a` (5.3:1) | `#ab9099` (5.8:1) | 踩 AA 下限 |
|
||||
| `--tb-primary` | `#b8436a` (4.9:1) | `#ff93b4` (8.2:1) | 原候选 `#c14a72` 只有 4.39:1,压深 |
|
||||
| `--tb-on-primary` | `#ffffff` (5.2:1) | `#1b1014` (8.9:1) | 主色按钮上的字 |
|
||||
|
||||
## 素材包(可选,已传到对象存储)
|
||||
|
||||
主题线上**不需要**任何素材:樱花是内联矢量。但同一个花形也导出成了位图,
|
||||
放在对象存储上备用(`themes/new-sakura/`,共 7 个对象):
|
||||
|
||||
| 键 | 内容 |
|
||||
| --- | --- |
|
||||
| `new-sakura.css` | 主题本体(备份,便于分发) |
|
||||
| `branch-right.webp` / `branch-left.webp` | 花枝 840×640 透明底(互为镜像) |
|
||||
| `blossom.webp` | 单朵樱花 400×400 透明底 |
|
||||
| `petal.webp` | 带缺口的花瓣 240×336 透明底 |
|
||||
| `pattern.webp` | 平铺底纹 600×600 透明底 |
|
||||
| `INSTALL.txt` | 包内说明 |
|
||||
|
||||
重新导出 / 上传(`--upload` 会一起传 CSS、位图和生成的 `INSTALL.txt`):
|
||||
|
||||
```bash
|
||||
python dev/theme-tools/export_assets.py # 只导出到 outputs/sakura-assets
|
||||
python dev/theme-tools/export_assets.py --upload # 导出并上传,读 data/r2.txt
|
||||
```
|
||||
|
||||
`data/r2.txt` 是部署凭据(`image_endpoint` / `image_access_id` / `image_access_secret` /
|
||||
`image_bucket` / `image_bucket_cdn_host`),**不进版本库**(`.gitignore` 里的 `/data/`)。
|
||||
`image_bucket_cdn_host` 与 `conf/app.ini` 同名同义:只写主机名,不带 `https://` 和路径;
|
||||
没有它时 `INSTALL.txt` 里的公开地址会写成占位符。
|
||||
|
||||
缓存策略:位图 `max-age=86400`、CSS 镜像 `300`、`INSTALL.txt` `60`。
|
||||
这些键**都不带内容版本**,所以刻意不用 `immutable` —— 否则重新导出后,浏览器与 CDN
|
||||
一年内拿到的都还是旧图。想要一年长缓存,得把内容哈希写进文件名(CSS 里的地址跟着换)。
|
||||
|
||||
⚠️ 若某个键曾经以 `immutable` 传过(本主题第一版就是这样),CDN 会一直给旧副本,
|
||||
改完必须去控制台按 URL 清一次缓存,光重传没用。
|
||||
|
||||
### 位图版(引用对象存储)
|
||||
|
||||
桶绑了自定义域 `r2.337845818.best`(实测匿名 GET 200、`image/webp`、大小一致),
|
||||
所以可以把 CSS 从内联切到位图:
|
||||
|
||||
```bash
|
||||
python dev/theme-tools/gen_new_sakura.py \
|
||||
--asset-base https://r2.337845818.best/themes/new-sakura \
|
||||
--out static/css/themes/new-sakura.css
|
||||
```
|
||||
|
||||
| 版本 | CSS | gzip 后 | 额外请求 | 清晰度 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 内联 SVG(默认) | 53.8KB | **4.0KB** | 0 | 矢量,任意 DPR |
|
||||
| 位图 | 11.8KB | 2.8KB | 5 个 webp,共 48.9KB | 2 倍图 |
|
||||
|
||||
服务端挂了 gzip(`internal/route/gzip.go`),所以内联版**总传输 4.0KB**,比位图版的
|
||||
2.8 + 48.9 = 51.7KB 小一个数量级 —— 换位图省不了流量,只是把开销从 CSS 挪到图片。
|
||||
位图版唯一正当的理由是**素材要单独复用**(分享卡、海报、其它系统)。
|
||||
|
||||
⚠️ 直接连桶的 S3 endpoint(`*.r2.cloudflarestorage.com`)会 403,**只有自定义域/r2.dev 可匿名读**。
|
||||
切之前先 `curl -I` 验一下前缀;切完 `go build` 部署,否则 embed 里还是旧的。
|
||||
|
||||
## 为什么不用主题生成器出图
|
||||
|
||||
生成器的装饰层只支持「图片素材」(导出时打进 zip 或传对象存储),没有内联 SVG 图层。
|
||||
要把樱花做成可摆放的图层,只能在设计上二选一:
|
||||
|
||||
1. 生成图片素材(花瓣 PNG/WebP)→ 可以用生成器、可以放 S3,但会有位图缩放、额外请求;
|
||||
2. 内联 SVG 写进 CSS → 零素材、任意缩放不糊,但生成器管不到。
|
||||
|
||||
`new-sakura` 选 2,因为「用 CSS 做樱花」本身就是这个主题的诉求。
|
||||
生成器仍然可以用来**预览**它:`?demo=1&fresh=1` 起画板,把 `new-sakura.css` 注入即可。
|
||||
|
||||
## 怎么验证
|
||||
|
||||
```bash
|
||||
go test ./internal/theme # 10 槽等长、深浅块成对、必需令牌齐全、窄屏覆盖特异性
|
||||
```
|
||||
|
||||
视觉自检用项目真实页面快照:
|
||||
|
||||
```bash
|
||||
TEST_PREVIEW=1 go test ./templates -run TestGenPreview
|
||||
python dev/theme-builder/serve.py
|
||||
# 打开 outputs/preview-box-light.html,DevTools 里给 <html> 加
|
||||
# data-theme-style="new-sakura" data-page-scope="box"
|
||||
```
|
||||
|
||||
## 上线
|
||||
|
||||
1. `conf/app.ini` 的 `[theme] available` 加上 `new-sakura`(不改就「设置页看不到」,现象和部署没生效一样);
|
||||
2. 重新 `go build` —— 主题 CSS 是编译期 embed,只重启不重编不会变;
|
||||
3. 箱主设置页 → 账号 → 外观,选择「新·樱花」。
|
||||
@@ -0,0 +1,86 @@
|
||||
# 主题的页面作用域(data-page-scope)
|
||||
|
||||
一套主题过去只能有一种装饰:人物永远站在左下角,不管当前是箱主页、单题页还是题目列表。
|
||||
单题页正文长、题目列表信息密,同一套装饰在三种页面上要同时成立很难。
|
||||
|
||||
现在服务端会按请求路径给 `<html>` 打一个 `data-page-scope`,主题可以对每个页面分别配
|
||||
整页背景与装饰层。
|
||||
|
||||
## 取值
|
||||
|
||||
| 值 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| (无属性) | — | 全局默认 |
|
||||
| `box` | `/_/{domain}`、`/_/{domain}/tag/*` | 箱主页(问题列表) |
|
||||
| `question` | `/_/{domain}/{qid}` | 单个问题 |
|
||||
| `admin` | `/user/questions`、`/user/votes/*` | 题目 / 私聊管理列表 |
|
||||
| `profile` | `/user`、`/user/profile` 等 | 设置页 |
|
||||
| `vote` | `/vote/{token}` | 投票页(跟随发布者的主题) |
|
||||
| `home` / `explore` | `/`、`/explore` | Go 侧会返回,但**这两页不加载主题 css** |
|
||||
|
||||
来源:`internal/context.pageScope()`,测试在 `internal/context/context_test.go` 的 `TestPageScope`。
|
||||
|
||||
## 选择器与特异性
|
||||
|
||||
```css
|
||||
/* 全局 */
|
||||
html[data-theme-style="SLUG"] { … }
|
||||
|
||||
/* 某一页覆盖 */
|
||||
html[data-theme-style="SLUG"][data-page-scope="question"] { … }
|
||||
```
|
||||
|
||||
特异性 0,2,1 与深色块 `html[data-theme="dark"][data-theme-style="SLUG"]` 相同,
|
||||
而覆盖块写在文件后面,**会盖掉深色块**。所以深色必须再写一条三重选择器:
|
||||
|
||||
```css
|
||||
html[data-theme="dark"][data-theme-style="SLUG"][data-page-scope="question"] { … }
|
||||
```
|
||||
|
||||
不写的话,深色模式下会拿到浅色的覆盖块 —— 典型表现是「深色页面里突然出现一块白天场景」。
|
||||
|
||||
## 覆盖块写什么
|
||||
|
||||
通常只覆盖这三组:
|
||||
|
||||
```css
|
||||
html[data-theme-style="SLUG"][data-page-scope="question"] {
|
||||
--tb-page-image: none; /* 不放整页场景 */
|
||||
--tb-page-pattern: none;
|
||||
--tb-deco: …; /* 10 个值 */
|
||||
--tb-deco-size: …; /* 10 个值 */
|
||||
--tb-deco-position: …; /* 10 个值 */
|
||||
}
|
||||
```
|
||||
|
||||
配色(`--tb-bg` / `--tb-surface` / `--tb-text` / `--tb-primary`)一般不要按页面改:
|
||||
同一个主题在不同页面换配色,用户会觉得走错站了。
|
||||
|
||||
## 10 个槽位
|
||||
|
||||
`--tb-deco` / `--tb-deco-size` / `--tb-deco-position` 三组必须**等长且写满 10 个**。
|
||||
少一个不会报错,CSS 会按列表长度循环取值,于是图层与 size/position 整体错位:
|
||||
|
||||
- 整页重复同一张插画(第一次踩坑就是这个)
|
||||
- 人物跑到页面左上角(`background-position` 整条声明非法时回落初始值)
|
||||
|
||||
`internal/theme/slots_test.go` 的 `TestDecoSlotCount` 会逐个文件、逐处声明检查,
|
||||
包括 base.css 与所有主题 css(含作用域覆盖块)。写主题时不用记,跑测试就行。
|
||||
|
||||
`background-position` 一律写成两值 `calc()`:
|
||||
|
||||
```css
|
||||
--tb-deco-position: calc(0% + 40px) calc(100% - 36px),
|
||||
calc(50% + 0px) calc(50% + 0px), …;
|
||||
```
|
||||
|
||||
- 百分比是相对 **(容器 − 图片)** 的剩余空间,不是相对容器
|
||||
- `0%` 左/上、`50%` 居中、`100%` 右/下
|
||||
- **不要**混用 `left` / `bottom` 关键字:「水平居中 + 垂直贴底」会变成三值语法,
|
||||
一旦不合规范整条声明作废,图层静默掉到左上角
|
||||
|
||||
## 可视化编辑
|
||||
|
||||
手写这些值不现实,用 `dev/theme-builder/`(见同目录 README):以真实页面为画布,
|
||||
按页面切换、拖拽定位、导出 zip。它生成的 CSS 直接过 `TestThemeCSSComplete` 与
|
||||
`TestDecoSlotCount`。
|
||||
@@ -0,0 +1,127 @@
|
||||
# 主题素材规格(shiroko)
|
||||
|
||||
给外部生成工具用的参数表。照着填尺寸/比例/雾度,导出后按「换图清单」替换即可。
|
||||
所有尺寸都按 **2× 出图、1× 显示** 写,避免高分屏发虚。
|
||||
|
||||
## 一、总表
|
||||
|
||||
| 文件 | 用途 | 画布(生成) | 画布(导出) | 显示尺寸 | 透明 | 体积上限 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `chibi/window.webp` | 人物 | 270 × 920 | 135 × 460 | 高 400px(宽 ≈117) | ✅ 需要 | 40 KB |
|
||||
| `props/bike.webp` | 自行车 + 步枪 | 794 × 518 | 397 × 259 | 高 259px(宽 ≈397) | ✅ 需要 | 40 KB |
|
||||
| `scene-desktop.webp` | 教室背景(桌面) | 3840 × 2160 | 1920 × 1080 | `cover` 铺满 | ❌ 不需要 | 40 KB |
|
||||
| `scene-mobile.webp` | 教室背景(≤900px) | 1440 × 2006 | 720 × 1003 | `cover` 铺满 | ❌ 不需要 | 30 KB |
|
||||
| `pattern-dark.svg` | 深色模式平铺纹样 | 240 × 240 | 240 × 240 | 平铺 | ❌ | 2 KB |
|
||||
|
||||
比例(认准这个,比像素更重要):
|
||||
|
||||
- 人物 **1 : 3.41**(宽 : 高 ≈ 0.293)
|
||||
- 道具 **1.53 : 1**(397 : 259)
|
||||
- 桌面背景 **16 : 9**
|
||||
- 移动背景 **0.718 : 1**(≈ 9 : 12.5)
|
||||
|
||||
## 二、人物 `chibi/window.webp`
|
||||
|
||||
| 项 | 值 |
|
||||
| --- | --- |
|
||||
| 画布 | 270 × 920 px,透明背景 |
|
||||
| 姿势 | 侧后方 3/4 视角、双手背在身后、头略向右(**不要正面直视**) |
|
||||
| 脚底 | **贴画布底边**(误差 ±4px),不要留空白 |
|
||||
| 头顶 | 贴画布上沿;若画光环,环心必须透明 |
|
||||
| 左右余量 | 各留 10~20px,别让手臂被画布裁掉 |
|
||||
| 雾度 | RGB 压白 **16%**:`v' = v×(1-0.16) + 255×0.16` |
|
||||
| 色彩 | 下表是**压白 16% 之后**的显示值,生成时按 `原色 = (显示值 − 40.8) / 0.84` 反算 |
|
||||
|
||||
| 部位 | 显示值 | 生成时用 |
|
||||
| --- | --- | --- |
|
||||
| 头发 | `#c4c3c6` | `#b9b8bb` |
|
||||
| 围巾 | `#8abbc6`(青绿,别用纯蓝) | `#8fc1c9` |
|
||||
| 西装 / 上衣 | `#565e72` | `#3a4256` |
|
||||
| 鞋 | `#4f4f4f` | `#3f3f3f` |
|
||||
| 袜 | `#ffffff` | **用 `#ededed`**(纯白会和背景粘在一起,抠图会吃穿) |
|
||||
| 光环 | `#8abbc6` 细环 | `#7fd3dc` |
|
||||
|
||||
要点:
|
||||
|
||||
- **不能有描边**。平涂色块、无 line art、无渐变。
|
||||
- 抠图后检查三处必须透明:**手与背之间**、**两腿之间**、**光环环心**。
|
||||
这三处是闭合区域,只从四角抹白是抹不掉的(我们踩过)。
|
||||
- 白袜子是 `#ededed` 左右,背景是 `#fdfdfd`,两者只差 16 级——抠图时**不要把阈值开大**,否则袜子会被吃穿。
|
||||
|
||||
## 三、道具 `props/bike.webp`
|
||||
|
||||
| 项 | 值 |
|
||||
| --- | --- |
|
||||
| 画布 | 794 × 518 px,透明背景 |
|
||||
| 内容 | 银色/白色城市自行车 + 一把细长步枪斜靠车架 |
|
||||
| 朝向 | **车头朝右**(和人物同向) |
|
||||
| 地面 | 轮子底线贴画布底部,下方留 12~16px 给接触阴影 |
|
||||
| 阴影 | 可以在图内自带一层椭圆软影(不透明度 15~20%,模糊 8~12px) |
|
||||
| 雾度 | RGB 压白 **26%**(比人物淡一档,它在人身后) |
|
||||
| 背景 | **必须是纯色绿幕或透明**,不要白底(白色车架 + 白底 = 无法分离,见下方“白上白”) |
|
||||
|
||||
要点:
|
||||
|
||||
- 侧视图即可,但不要画成“图标”——轮辐、脚踏、链条这些细节保留,否则缩到 259px 会变成一个圈。
|
||||
- 枪要细长、冷色(灰蓝),**不要木色**(会像双管猎枪)。
|
||||
|
||||
⚠️ **白上白(本项目踩过)**:白底 + 白车架时,车身涂装和背景是同一个白(实测都是 `#f9f9f9`,
|
||||
差 0~3 级),颜色完全分不开;四角 floodfill 只能吃掉外圈,轮圈内、车架三角、车筐里的白会整块留下,
|
||||
看上去就是「没抠干净」。此时的取舍只有两条:
|
||||
1. 把这批白**全部**设为透明 —— 车身填色一起走,成品是**线稿风**(描边 + 粉色轮圈 + 蓝枪托仍在,
|
||||
轮辐、车架、车筐网格都清楚,现有的 `bike.webp` 就是这么做出来的,不透明率 19.4%、剩余近白 0%);
|
||||
2. 重新出一张**绿幕/透明背景**的素材,车身用浅灰蓝等中间调(白车在浅色场景里本来就发飘)。
|
||||
|
||||
## 四、场景 `scene-desktop.webp`
|
||||
|
||||
| 项 | 值 |
|
||||
| --- | --- |
|
||||
| 画布 | 3840 × 2160(16:9),不透明 |
|
||||
| 导出 | 1920 × 1080,WebP q82 |
|
||||
| 色调 | 灰蓝/暖白,均值亮度 **235~245**,标准差 6~9(几乎是白雾) |
|
||||
| 处理 | 整体压白 30~38%,高斯模糊 1.2~1.4px |
|
||||
|
||||
**构图硬约束(这条最容易翻车):**
|
||||
|
||||
1. **左侧 0~22% 的前景必须是空地(地板)**,不能有课桌、椅子、书包。
|
||||
人物是贴在最上层的独立图层,脚下必须有地面,否则会读成“站在课桌上”。
|
||||
2. 课桌、椅子、黑板放在**中部偏右**,因为中间会被卡片完全盖住。
|
||||
3. **不要有必须完整露出的元素**(`background-size: cover` 会按视口裁两侧,1080 高的屏裁掉约 5%)。
|
||||
4. 不要出现人、文字、水印。自行车也不要画进背景——它要跟着人物一起移动。
|
||||
5. 玻璃/窗框这类高频细节会干扰卡片可读性,右侧留白区尽量干净。
|
||||
|
||||
## 五、换图清单
|
||||
|
||||
1. 覆盖同名文件(格式 `WebP`,有透明的存 `q88`,场景存 `q82`)。
|
||||
2. `static/css/themes/shiroko.css`:
|
||||
- 若**显示尺寸不变**:只改三处 URL 的 `?v=`(图片不走 `StaticVersion`,缓存 1 天)。
|
||||
- 若显示尺寸变了:改 `--tb-deco-size` 的 `auto 400px`(人) / `auto 259px`(车)。
|
||||
3. `internal/conf/static.go` 的 `StaticVersion` +1(CSS 改了必须)。
|
||||
4. 重新编译部署(静态资源是编译期 embed,只重启不重编不会变)。
|
||||
|
||||
### 定位公式(改比例时才需要算)
|
||||
|
||||
`background-position` 的百分比是相对 **(定位区宽 − 图片宽)**,不是视口宽:
|
||||
|
||||
```
|
||||
实际 left = (视口宽 - 显示宽) × (pos%) + 偏移量
|
||||
```
|
||||
|
||||
当前值(视口 1512 时:人物左沿 173px、车左沿 -55px,两者重叠区由人物遮住):
|
||||
|
||||
```css
|
||||
/* 人物:显示高 400,显示宽 = 400 × 0.293 ≈ 117 */
|
||||
left: max(10px, calc(50% - 524px)) bottom 36px;
|
||||
/* 车:显示高 259,显示宽 ≈ 397 */
|
||||
left: calc(50% - 612px) bottom 20px;
|
||||
```
|
||||
|
||||
## 六、验收(不看代码也能查)
|
||||
|
||||
1. **白边**:放大 300% 看轮廓,出现 1px 白色描边就是没抠干净。
|
||||
2. **闭合区域**:手内侧、腿间、环心应能透出背景色。
|
||||
3. **接地**:脚底/轮底离画布底边 ≤ 4px;有阴影时阴影在轮子内侧。
|
||||
4. **体积**:人 ≤40KB、车 ≤40KB、场景 ≤40KB(现在分别是 15 / 29 / 19 KB)。
|
||||
5. **深色模式**:所有图片在深色下都不显示,不用管。
|
||||
6. **白上白**:车这类「白底 + 白主体」的素材,验收要额外看「不透明像素里近白(>240)的占比」——
|
||||
正常应接近 0;本项目修复前是 **50%**,说明一半的不透明区域其实是背景白。
|
||||
@@ -0,0 +1,254 @@
|
||||
# 主题「砂狼白子」(shiroko)
|
||||
|
||||
阿拜多斯学园风格的灰蓝/银白配色,附带 Q 版人物插画。启用方式:
|
||||
|
||||
```ini
|
||||
[theme]
|
||||
available = default,sakura,shiroko
|
||||
```
|
||||
|
||||
箱主在「账号设置 → 主题外观」里选择即可。作用范围与其他主题一致:提问箱页面、本人投票页、`/user` 后台。
|
||||
|
||||
## 配色
|
||||
|
||||
| 令牌 | 浅色 | 深色 |
|
||||
| --- | --- | --- |
|
||||
| `--tb-bg` / `--tb-navbar` | `#edf2f8` | `#141a22` |
|
||||
| `--tb-surface` | `#ffffff` | `#1d2530` |
|
||||
| `--tb-surface-2` | `#e5ecf5` | `#273140` |
|
||||
| `--tb-border` | `#d6e0ec` | `#334052` |
|
||||
| `--tb-text` | `#222d3b` | `#e3eaf4` |
|
||||
| `--tb-primary` | `#4a7fc1` | `#6fa3e0` |
|
||||
|
||||
## 视觉构图
|
||||
|
||||
参考对象:**蔚蓝档案 JP 官网首页**(`https://bluearchive.jp/`)。它的做法不是"白底 + 留白里贴几个人物",
|
||||
而是 **整个视口是一张场景插画,UI 浮在场景之上**。本主题照这个结构重排:
|
||||
|
||||
| 层 | 内容 | 实现 |
|
||||
| --- | --- | --- |
|
||||
| 底层 | 教室场景插画(压白 38%、轻度虚化) | `--tb-page-image`,16:9 桌面 / 9:16 移动两张 |
|
||||
| 中层 | 卡片与顶栏:半透明白 + `backdrop-filter` 磨砂 | `--tb-surface: rgba(255,255,255,.86)` / `--tb-navbar` |
|
||||
| 上层 | 人物站在场景左侧地面(400px 高)+ 椭圆接触阴影 | `--tb-deco` 前两个槽位 |
|
||||
| 道具 | 她的自行车(车头朝右、与人同向)斜靠一把步枪 | `--tb-deco` 第 3 槽位 |
|
||||
| 空气层 | 左窗光柱 / 右侧暖散景 / 脚下地面光斑 / 底部压深 | `--tb-deco` 第 4~7 槽位(全是渐变) |
|
||||
|
||||
> 装饰层共 10 个槽位(见 `internal/theme/slots_test.go`),本主题用前 7 个,其余留空。
|
||||
> 想给某一页换装饰(例如问题页让人物退场),见 [theme-page-scope.md](theme-page-scope.md)。
|
||||
| 深色 | 不用场景(白天教室压在深底上会脏),回到深色渐变 + 极淡纹样 | 深色块单独覆盖 |
|
||||
|
||||
摆位经验(都是踩过的):
|
||||
|
||||
- **不要四角各摆一个人**。人物之间、人物与内容之间都没有关系,读起来就是贴纸墙纸;
|
||||
人物必须"站在场景里"——有地面、有阴影、有环境,才不像贴纸。
|
||||
- **右下角不能放人**。页脚文案右对齐(社区规则 / 备案号 / 存活时长),会和人物挤在一起。
|
||||
- **离底边留 36px**。脚踩在窗口最后一行像素上会被读成"被裁切",抬高之后必须配接触阴影,否则变成悬空。
|
||||
- **纹样不要压在实景上**。平铺纹样叠在教室插画上会变成噪点,只在深色(无场景)时使用。
|
||||
- **背景图会被 `cover` 裁切**,所以场景里不能有必须完整露出的元素:教室的黑板和窗户都在中部,裁掉两头不影响。
|
||||
- **人物比例要跟场景对得上**。400px 是相对"站在前景"的尺度;缩到 240px 以下会显得像贴纸浮在半空。
|
||||
|
||||
### 空气层(2026-10-20 加)
|
||||
|
||||
场景压白 38% 之后整页亮度都在 235~246 之间,"再加点东西"直接摆物件一定会突兀。加了四层**纯渐变**:
|
||||
|
||||
| 槽位 | 作用 | 值 |
|
||||
| --- | --- | --- |
|
||||
| 3 | 左窗光柱(从左上斜下) | `linear-gradient(112deg, rgba(255,250,236,.50), transparent 44%)` |
|
||||
| 4 | 右侧一朵暖散景(有体积感) | `radial-gradient(circle 120px at 88% 30%, rgba(255,252,240,.42), transparent 75%)` |
|
||||
| 5 | 人物脚下的地面光斑(把她"钉"在地上) | `radial-gradient(ellipse 560px 170px at 28% 94%, rgba(255,248,228,.80), transparent 72%)` |
|
||||
| 6 | 底部轻压深(下沿不飘) | `linear-gradient(0deg, rgba(110,128,156,.10), transparent 24%)` |
|
||||
|
||||
经验:
|
||||
|
||||
- 亮度差 **3~9 级**刚好能看出"有光"又不像打了聚光灯;超过 15 级就会读成一块脏。
|
||||
- 白色散景压在已经压白的场景上等于没做(实测差异 0.14 级,肉眼不可见)——**要提亮就得先有暗部**,
|
||||
所以真正起作用的是"压深 + 暖色光斑"的组合,不是加白。
|
||||
- 所有槽位必须是单张图,**不能在一个槽位里塞逗号列表**(会让层数与 size/position 错位)。
|
||||
- 8bit 渐变要检查阶梯:横向扫描线的相邻像素差应 ≤2~3;本次实测 max 4,无可见色阶。
|
||||
|
||||
> 尺寸 / 比例 / 雾度等可直接拿去外部工具复用的参数,见 [theme-shiroko-assets-spec.md](theme-shiroko-assets-spec.md)。
|
||||
|
||||
## 素材清单
|
||||
|
||||
位于 `static/images/themes/shiroko/`,已随二进制嵌入(`static/fs.go` 的 `images/themes`)。
|
||||
|
||||
| 文件 | 用途 | 尺寸 | 体积 |
|
||||
| --- | --- | --- | --- |
|
||||
| `chibi/window.webp` | **当前使用**:侧后方视角、双手背在身后望向箱内,压白 16% | 135×460 | 13KB |
|
||||
| `props/bike.webp` | 自行车 + 步枪(车头朝右),压白 26% 后**只剩描边**(车身白填色已去掉,见下),自带接触阴影 | 397×259 | 29KB |
|
||||
| `chibi/wave.webp` | 备用:正面挥手 | 324×620 | 27KB |
|
||||
| `chibi/cheer.webp` | 双手欢呼 | 354×620 | 27KB |
|
||||
| `chibi/think.webp` | 托腮 | 283×620 | 26KB |
|
||||
| `chibi/doze.webp` | 打瞌睡 | 274×620 | 26KB |
|
||||
| `scene-desktop.webp` | 教室场景(16:9,前景是空地,便于人物站立) | 1920×1080 | 19KB |
|
||||
| `scene-mobile.webp` | 同一张场景的竖版裁切 | 720×1003 | 10KB |
|
||||
| `pattern-dark.svg` | 深色模式平铺纹样(雪花 + 光环) | 240×240 | 0.7KB |
|
||||
|
||||
## 来源与授权
|
||||
|
||||
| 素材 | 来源 |
|
||||
| --- | --- |
|
||||
| 立绘参考图 | **用户自行用 AI 生成**,仅作 image-to-image 的 `image1`,不上页面、**不入库**(2026-10-21 移除,仓库里不存大图)|
|
||||
| `chibi/window.webp` | WorkBuddy ImageGen 生成(文生图 → image-to-image 转平涂),再抠白底 |
|
||||
| `chibi/wave.webp` 等 | WorkBuddy ImageGen **以用户立绘为参考图**(image-to-image)生成,再抠白底 |
|
||||
| `scene-desktop.webp` / `scene-mobile.webp` | WorkBuddy ImageGen 生成(提示词见下),未使用任何外部素材 |
|
||||
| `pattern-dark.svg` | 手写 SVG,纯几何,无外部素材 |
|
||||
|
||||
### 生成与抠图参数
|
||||
|
||||
- 参考图:用户提供的立绘(**不入库**,随便放个工作目录,传给 `image1` 即可),
|
||||
`input_fidelity: high`,保证角色造型与平涂风格一致;
|
||||
- 提示词骨架(四个姿态共用,只改动作句):
|
||||
|
||||
> Same character, same art style as the reference image: flat vector illustration, no outlines,
|
||||
> simple shapes, soft pastel colors, minimal detail. A chibi anime girl with short fluffy
|
||||
> silver-grey bob hair, pointed grey wolf ears, a small tilted cyan halo ring above her head,
|
||||
> pale blue dot eyes, cyan scarf, dark navy blazer, light grey pleated skirt, cyan and black
|
||||
> sneakers. { 动作 } Plain pure white background (#FFFFFF), no shadow, no ground, no text, no watermark.
|
||||
|
||||
- 动作句:`Standing full body, front view, one arm raised waving hello with a small smile` /
|
||||
`both arms raised up cheering happily, big smile` /
|
||||
`one hand resting on her cheek thinking, head slightly tilted` /
|
||||
`eyes closed sleeping peacefully, head drooping slightly to one side`;
|
||||
- 抠图:从四角 + 四边中点共 8 点做 floodfill(容差 16),再对 alpha 做 1px MinFilter 收边去白边,
|
||||
裁 bbox 后统一缩放到高 620、WebP q90。
|
||||
|
||||
### 当前人像(window.webp)的两步做法
|
||||
|
||||
正面立绘站在空教室里像贴纸(无环境、直视访客、饱和度高)。改成**侧后方视角 + 二次转平涂**:
|
||||
|
||||
1. 文生图先出姿势与角色:`flat vector chibi, three-quarter back view, hands clasped behind back,
|
||||
short silver-grey bob, grey wolf ears, pale aqua fish-shaped halo, teal scarf, navy blazer,
|
||||
grey pleated skirt, black and teal sneakers`(第一版带了教室窗框,背景处理不掉);
|
||||
2. 再把这张图扔回 image-to-image(`input_fidelity: high`)转成"纯色块、无描边、去掉窗框与场景,
|
||||
纯白背景",这样既保住姿势,又能拿到和立绘一致的平涂风格;
|
||||
3. 光环单独重画:AI 给的光环不但形状歪,环内还是一块**封闭的白孔**——floodfill 只从四边漫水,
|
||||
闭合区域进水不进去,抠完就是"环里糊着一团白"。做法是先把这个连通域(非白像素从环上一点漫水,
|
||||
bbox 291,93–584,232,与头发不连通)整块抹白,抠完 alpha 后再用 PIL 画一个 -7° 倾斜的椭圆环
|
||||
(4 倍超采样 + LANCZOS 缩小求平滑 + 一层 45% 的模糊柔光),环心天然是透明的;
|
||||
4. 抠白底后把 RGB 压白 **16%**(`v*(1-k)+255*k`,alpha 不动)——场景本身压白 38%,
|
||||
人像不压白就是全页对比度最高的一块,必然"突兀";
|
||||
5. 裁 bbox、缩到高 460,WebP q86(约 14KB)。
|
||||
|
||||
### 人物与道具必须用「同一套定位公式」
|
||||
|
||||
`background-position` 里的百分比**不是相对视口宽度**,而是相对 `(定位区宽 - 图片宽)`。
|
||||
所以人物和自行车即使写同样的 `calc(50% - Npx)`,两者也会因为图宽不同而错位——
|
||||
实测把车按人的公式放,车直接跑出屏幕,只剩一片轮圈。
|
||||
|
||||
正确做法是按各自宽度反解:设人物宽 `wc`、车宽 `wb`,要让车的右沿贴住人的左沿(留 gap):
|
||||
|
||||
```
|
||||
人的 left = (W - wc)/2 + Bc
|
||||
车的 left = (W - wb)/2 + Bc - gap - wb + wc/2 - wc/2 ... 直接解 B 更省事
|
||||
```
|
||||
|
||||
本主题实测:人物 `calc(50% - 524px)`、车 `calc(50% - 612px)`。
|
||||
|
||||
另外**道具不要烘焙进背景图**:背景是 `cover`,视口一变裁切量就变,
|
||||
而人物是固定 px 定位的,两者必然分家。烘焙只适合「和人无关的背景物件」。
|
||||
|
||||
关键经验:**必须是"平涂无描边"**。先试过用手写 SVG 线稿,风格和用户那张立绘(色块、无描边)冲突,
|
||||
而且没有真人手感会画得很抽象;用参考图 + image-to-image 出来的四张,角色特征和画风都稳。
|
||||
|
||||
**版权说明**:砂狼白子(砂狼シロコ)是《蔚蓝档案》的角色,版权归 NEXON Games / Yostar。
|
||||
本主题为**非商用同人装饰**,仅作个人站点外观使用;若站点转为商业用途,请自行取得授权或换成原创素材。
|
||||
|
||||
## 替换 / 扩素材
|
||||
|
||||
1. 加姿态:按上面的提示词骨架换动作句生成,白底 → 抠图 → 存 `chibi/<name>.webp`(高 620),
|
||||
再塞进 `shiroko.css` 的 `--tb-deco`(**必须保持 10 个值**,`--tb-deco-size` / `-position` 同步补齐,
|
||||
少写几个不会报错、只会让图层与尺寸错位;`TestDecoSlotCount` 会拦),并按上面的摆位原则定位。
|
||||
换 `<img>` 时别忘了给 URL 带 `?v=`——图片不走 `StaticVersion` 缓存键,`max-age` 是 1 天。
|
||||
2. 换立绘:准备一张透明 PNG 立绘当参考图(不入库),再按同一提示词骨架重新生成姿态。
|
||||
3. 换配色:改 `shiroko.css` 的令牌即可,插画是位图,换色需重新生成。
|
||||
4. 新增素材不需要改 `static/fs.go`(已嵌整个 `images/themes` 目录);但改了 CSS 必须把
|
||||
`conf.StaticVersion` +1,否则老访客吃缓存看不到变化。
|
||||
|
||||
## 部署后设置页看不到这个主题?
|
||||
|
||||
先分两步确认,不要一上来就怀疑缓存:
|
||||
|
||||
1. **文件是否随二进制发布了** —— 直接取一次静态资源:
|
||||
|
||||
```bash
|
||||
curl -o /dev/null -w "%{http_code}\n" \
|
||||
https://<你的域名>/static/css/themes/shiroko.css?v=<StaticVersion>
|
||||
```
|
||||
|
||||
返回 200 说明 css 已经嵌进去了;返回 404 说明部署的是旧二进制。
|
||||
|
||||
2. **是否登记进 `[theme] available`** —— 主题列表是启动时按白名单构建的,
|
||||
css 在但没登记,设置页就看不到它。检查 `conf/app.ini`:
|
||||
|
||||
```ini
|
||||
[theme]
|
||||
default = default
|
||||
available = default,sakura,shiroko
|
||||
```
|
||||
|
||||
改完**重启**进程(`[theme]` 只在启动时读)。
|
||||
|
||||
启动日志会直接告诉你缺哪一行:
|
||||
|
||||
```
|
||||
level=info msg="主题注册表已加载" enabled="default,sakura"
|
||||
level=warning msg="[theme] available 未登记以下主题,它们不会出现在设置页"
|
||||
available="default,sakura,shiroko" themes=shiroko
|
||||
```
|
||||
|
||||
## 场景图的生成
|
||||
|
||||
整页背景是一张生成的教室插画,提示词:
|
||||
|
||||
```
|
||||
Empty Japanese high school classroom in the afternoon, flat pastel anime background art,
|
||||
very light and airy, pale blue-grey and warm cream palette, rows of desks softly out of focus,
|
||||
big windows with soft daylight, thin clean shapes, low contrast, minimal detail,
|
||||
large calm empty area in the center, no characters, no text, no watermark,
|
||||
wide scene suitable as a website background
|
||||
```
|
||||
|
||||
生成后处理(PIL,脚本未入库,参数记在这里):
|
||||
|
||||
1. 裁掉图片底部 160px —— 生成器会在右下角打 `AI生成 WORKBUDDY` 水印,不裁会出现在页面上;
|
||||
2. 按 16:9 取顶部 864px,整体混白 38%(`v*0.62 + 255*0.38`)—— 保证白卡片放上去仍有足够对比度;
|
||||
3. 高斯模糊 1.4px,让桌椅不抢正文;
|
||||
4. 导出 WebP q80(1920×1080,22KB);移动端从同一张按 9:16 中裁 620px 宽,导出 720×1003。
|
||||
|
||||
## 肖像素材(平涂极简风)
|
||||
|
||||
`static/images/themes/shiroko/portraits/` 下另有一组独立肖像,风格参照用户给的参考图:
|
||||
**平涂、零描边、纯黑圆角矩形眼、无嘴、腮红、头占满画面并裁边**。已抠成透明底,可直接叠在任何底色上。
|
||||
|
||||
| 文件 | 构图 | 尺寸 | 体积 |
|
||||
| --- | --- | --- | --- |
|
||||
| `portrait-front.webp` | 正面胸像,含深蓝外套与青色领结 | 907×921 | 66KB |
|
||||
| `portrait-tilt.webp` | 头微侧、眼睛偏横向,气质更松 | 975×921 | 61KB |
|
||||
| `portrait-close.webp` | 极限特写,头顶与两侧被画框切掉 | 1024×921 | 70KB |
|
||||
|
||||
用法(和背景装饰同一套槽位):
|
||||
|
||||
```css
|
||||
--tb-deco: url("/static/images/themes/shiroko/portraits/portrait-front.webp"), none, none, none, none, none;
|
||||
--tb-deco-size: auto 320px, auto, auto, auto, auto, auto;
|
||||
--tb-deco-position: left 0 bottom 0, left top, left top, left top, left top, left top;
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
- 底部是**平直切口**(原图右下角有水印,统一裁掉底部 10%),适合直接贴到页面下沿或卡片边缘,
|
||||
不会出现"人浮在半空"的软边;
|
||||
- 透明边缘是 1px 抗锯齿羽化,深色底上会有一圈极淡的浅边,浅色底上看不出来;
|
||||
- 生成用的提示词骨架:
|
||||
|
||||
> Flat vector illustration, modern minimal poster style, no outlines, no gradients,
|
||||
> only flat solid color shapes … 角色描述 … Eyes are solid black vertically elongated
|
||||
> rounded rectangles, no highlights, no pupils, soft pink oval blush, no mouth.
|
||||
> The background is one flat solid pale blue-grey color #E6EEF8.
|
||||
|
||||
关键三点:① 必须强调 **no outlines / no gradients**;② 眼睛要写死成**纯黑色块**,否则会画出
|
||||
白眼球和高光;③ 背景指定**一个具体的纯色**(不要写 white),抠图时按这个色值算 alpha 才干净 ——
|
||||
白底会连白衬衫、光环内部一起吃掉,闭合光环里的白还填不到。
|
||||
|
||||
原始 1024 PNG 与几版备选留在 `outputs/portraits-raw/`(该目录被 gitignore,只在本机)。
|
||||
@@ -1,11 +1,13 @@
|
||||
module github.com/tamakyi/TamaBox
|
||||
|
||||
go 1.25.0
|
||||
go 1.27.3
|
||||
|
||||
replace golang.org/x/image => golang.org/x/image v0.13.0
|
||||
|
||||
require (
|
||||
github.com/aliyun/alibaba-cloud-sdk-go v1.62.26
|
||||
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4
|
||||
github.com/alibabacloud-go/green-20220302/v3 v3.5.4
|
||||
github.com/alibabacloud-go/tea v1.5.2
|
||||
github.com/aliyun/aliyun-oss-go-sdk v2.2.4+incompatible
|
||||
github.com/aws/aws-sdk-go-v2 v1.24.0
|
||||
github.com/aws/aws-sdk-go-v2/config v1.26.1
|
||||
@@ -46,9 +48,10 @@ require (
|
||||
go.opentelemetry.io/otel/sdk v1.9.0
|
||||
go.opentelemetry.io/otel/trace v1.10.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/image v0.13.0
|
||||
golang.org/x/net v0.56.0
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||
gopkg.in/ini.v1 v1.66.2
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
gorm.io/datatypes v1.0.7
|
||||
gorm.io/driver/mysql v1.3.6
|
||||
gorm.io/driver/postgres v1.4.5
|
||||
@@ -58,6 +61,10 @@ require (
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/alecthomas/participle/v2 v2.0.0 // indirect
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
|
||||
github.com/alibabacloud-go/debug v1.0.1 // indirect
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 // indirect
|
||||
github.com/aliyun/credentials-go v1.4.5 // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 // indirect
|
||||
@@ -81,6 +88,7 @@ require (
|
||||
github.com/charmbracelet/lipgloss v0.13.0 // indirect
|
||||
github.com/charmbracelet/log v0.2.3 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.3.2 // indirect
|
||||
github.com/clbanning/mxj/v2 v2.7.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
@@ -90,8 +98,8 @@ require (
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-redis/redis/v8 v8.11.5 // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3 // indirect
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
||||
github.com/jackc/pgio v1.0.0 // indirect
|
||||
@@ -102,7 +110,6 @@ require (
|
||||
github.com/jackc/pgx/v4 v4.17.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
@@ -114,21 +121,20 @@ require (
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
|
||||
github.com/onsi/gomega v1.29.0 // indirect
|
||||
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/projectdiscovery/blackrock v0.0.2 // indirect
|
||||
github.com/projectdiscovery/retryabledns v1.0.115 // indirect
|
||||
github.com/projectdiscovery/utils v0.11.1 // indirect
|
||||
github.com/redis/go-redis/v9 v9.5.1 // indirect
|
||||
github.com/redis/go-redis/v9 v9.5.1
|
||||
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
|
||||
github.com/smartystreets/assertions v1.0.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.6.4 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||
github.com/uptrace/opentelemetry-go-extra/otelsql v0.1.16 // indirect
|
||||
github.com/uptrace/opentelemetry-go-extra/otelutil v0.1.16 // indirect
|
||||
github.com/weppos/publicsuffix-go v0.50.3-0.20260104170930-90713dec78f2 // indirect
|
||||
@@ -146,7 +152,6 @@ require (
|
||||
go.opentelemetry.io/proto/otlp v0.19.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
||||
golang.org/x/image v0.13.0 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
|
||||
@@ -50,10 +50,54 @@ github.com/alecthomas/participle/v2 v2.0.0/go.mod h1:rAKZdJldHu8084ojcWevWAL8KmE
|
||||
github.com/alecthomas/repr v0.0.0-20181024024818-d37bc2a10ba1/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
|
||||
github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk=
|
||||
github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/aliyun/alibaba-cloud-sdk-go v1.62.26 h1:WvhbWN/8MqYY20LVi6vqkZ1ZHUibvnUoV9IF4jKA/Kw=
|
||||
github.com/aliyun/alibaba-cloud-sdk-go v1.62.26/go.mod h1:Api2AkmMgGaSUAhmk76oaFObkoeCPc/bKAqcyplPODs=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g=
|
||||
github.com/alibabacloud-go/darabonba-array v0.1.0 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY=
|
||||
github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI=
|
||||
github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC2NG0Ax+GpOM5gtupki31XE=
|
||||
github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
|
||||
github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
|
||||
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
|
||||
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4 h1:o6veen0IZ/Fe1JawwhwQMZcbw67CVDY1pQwXcNWPyQo=
|
||||
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4/go.mod h1:eHjVxrT9g8uVYN/nyAwOFQEfkVA154ChiqRc2XnNKYU=
|
||||
github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg=
|
||||
github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ=
|
||||
github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo=
|
||||
github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA=
|
||||
github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY=
|
||||
github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
|
||||
github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg=
|
||||
github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
|
||||
github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q=
|
||||
github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE=
|
||||
github.com/alibabacloud-go/green-20220302/v3 v3.5.4 h1:ZnlHri7tTLl28JWB5S2J6MYvD0ewJFI7hedK4E7+gSk=
|
||||
github.com/alibabacloud-go/green-20220302/v3 v3.5.4/go.mod h1:PQTtoEuWOsuq7H7sgTRcXDw4Ut/Hekgy5z2mWGtO9OQ=
|
||||
github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY=
|
||||
github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
|
||||
github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg=
|
||||
github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
|
||||
github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
|
||||
github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
|
||||
github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
|
||||
github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
|
||||
github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk=
|
||||
github.com/alibabacloud-go/tea v1.5.2 h1:DUzV/cRTbJC++rmADfqVu//GuWbaD64ZJ3yW3v2ukNU=
|
||||
github.com/alibabacloud-go/tea v1.5.2/go.mod h1:hgSs82CkOiehSQMoiFN79dL6zsGX7pVGvnn9SIEs8/0=
|
||||
github.com/alibabacloud-go/tea-utils v1.3.1 h1:iWQeRzRheqCMuiF3+XkfybB3kTgUXkXX+JMrqfLeB2I=
|
||||
github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4=
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 h1:y6pUIlhjxbZl9ObDAcmA1H3c21eaAxADHTDQmBnAIgA=
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.9/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
|
||||
github.com/aliyun/aliyun-oss-go-sdk v2.2.4+incompatible h1:cD1bK/FmYTpL+r5i9lQ9EU6ScAjA173EVsii7gAc6SQ=
|
||||
github.com/aliyun/aliyun-oss-go-sdk v2.2.4+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
|
||||
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
|
||||
github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0=
|
||||
github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
|
||||
github.com/aliyun/credentials-go v1.4.5 h1:O76WYKgdy1oQYYiJkERjlA2dxGuvLRrzuO2ScrtGWSk=
|
||||
github.com/aliyun/credentials-go v1.4.5/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
@@ -125,6 +169,8 @@ github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927/go.mod h1:h/aW8ynjgk
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
|
||||
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||
@@ -210,7 +256,6 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw=
|
||||
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A=
|
||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
@@ -281,10 +326,12 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c h1:7lF+Vz0LqiRidnzC1Oq86fpX1q/iEv2KJdrCtttYjT4=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 h1:l5lAOZEym3oK3SQ2HBHWsJUfbNBiTXJDeW2QDxw9AQ0=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3 h1:lLT7ZLSzGLI08vc9cpd+tYmNWjdKDqyr/2L+f6U12Fk=
|
||||
@@ -356,12 +403,7 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr
|
||||
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
|
||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
||||
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
@@ -410,6 +452,7 @@ github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDETo
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
@@ -419,6 +462,7 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwd
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
|
||||
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
|
||||
@@ -433,8 +477,6 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
|
||||
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||
github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg=
|
||||
github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ=
|
||||
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
|
||||
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
|
||||
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -494,8 +536,8 @@ github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/assertions v1.0.1 h1:voD4ITNjPL5jjBfgR/r8fPIIBrliWrWHeiJApdr3r4w=
|
||||
github.com/smartystreets/assertions v1.0.1/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM=
|
||||
github.com/smartystreets/assertions v1.1.0 h1:MkTeG1DMwsrdH7QtLXy5W+fUxWq+vmb6cLmyJ7aRtF0=
|
||||
github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
|
||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
|
||||
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
@@ -534,10 +576,9 @@ github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhV
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o=
|
||||
github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk=
|
||||
github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg=
|
||||
github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U=
|
||||
github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
|
||||
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
|
||||
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
|
||||
github.com/unknwon/com v1.0.1 h1:3d1LTxD+Lnf3soQiD4Cp/0BRB+Rsa/+RTvz8GMMzIXs=
|
||||
github.com/unknwon/com v1.0.1/go.mod h1:tOOxU81rwgoCLoOVVPHb6T/wt8HZygqH5id+GNnlCXM=
|
||||
github.com/uptrace/opentelemetry-go-extra/otelgorm v0.1.16 h1:WBsTsLkHGaURNRsEGpJY3FxfNOId6u9fl7H/ST6UwBc=
|
||||
@@ -572,6 +613,7 @@ github.com/xuri/nfp v0.0.0-20220409054826-5e722a1d9e22/go.mod h1:WwHg+CVyzlv/TX9
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
@@ -613,8 +655,6 @@ go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA=
|
||||
go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
@@ -632,7 +672,10 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20201216223049-8b5274cf687f/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
@@ -642,6 +685,13 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
|
||||
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220408190544-5352b0902921/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
@@ -678,6 +728,9 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -709,6 +762,7 @@ golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/
|
||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
@@ -718,6 +772,15 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx
|
||||
golang.org/x/net v0.0.0-20220407224826-aac1ed45d8e3/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
@@ -738,6 +801,9 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -772,6 +838,7 @@ golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -792,12 +859,29 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -808,7 +892,11 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@@ -856,6 +944,7 @@ golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjs
|
||||
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
|
||||
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
@@ -865,6 +954,8 @@ golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -969,6 +1060,7 @@ gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gG
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
@@ -976,8 +1068,9 @@ gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMy
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
|
||||
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
|
||||
gopkg.in/ini.v1 v1.66.2 h1:XfR1dOYubytKy4Shzc2LHrrGhU0lDCfDGG1yLPmpgsI=
|
||||
gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
@@ -985,7 +1078,6 @@ gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
Vendored
+33
@@ -58,6 +58,39 @@ func (c *TTLCache) Get(key string) (interface{}, bool) {
|
||||
return e.value, true
|
||||
}
|
||||
|
||||
// GetWithExpiry 读取缓存值与其过期时间,**不判断、不删除**过期条目。
|
||||
//
|
||||
// 供 stale-while-revalidate 使用:调用方拿到过期的旧值后可以先返回给用户,
|
||||
// 再在后台重算。Get 做不到这一点——它遇到过期项就直接删掉并返回 false,
|
||||
// 于是「过期」和「不存在」变成同一个结果,调用方只能同步等待重算。
|
||||
func (c *TTLCache) GetWithExpiry(key string) (value interface{}, expiresAt time.Time, ok bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
e, ok := c.items[key]
|
||||
if !ok {
|
||||
return nil, time.Time{}, false
|
||||
}
|
||||
return e.value, e.expiresAt, true
|
||||
}
|
||||
|
||||
// Expire 把条目标记为「已过期」但**保留其值**。
|
||||
//
|
||||
// 与 Delete 的区别很关键:Delete 之后下次读是彻底 miss,调用方只能同步重算;
|
||||
// Expire 之后下次读仍然拿得到旧值,于是可以「先返回旧值 + 后台刷新」。
|
||||
// 写多读多的场景(例如投票:每来一票都要让结果失效)只有用 Expire 才不会
|
||||
// 把每次写入都变成下一次读的同步 O(N) 重算。
|
||||
// key 不存在或已过期时什么也不做。
|
||||
func (c *TTLCache) Expire(key string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
e, ok := c.items[key]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
e.expiresAt = time.Now().Add(-time.Second)
|
||||
c.items[key] = e
|
||||
}
|
||||
|
||||
// Set 写入缓存并设定过期时间。
|
||||
func (c *TTLCache) Set(key string, value interface{}, ttl time.Duration) {
|
||||
c.mu.Lock()
|
||||
|
||||
Vendored
+66
@@ -59,3 +59,69 @@ func TestTTLCacheConcurrent(t *testing.T) {
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestTTLCacheGetWithExpiryKeepsEntry 锁住一个关键语义:
|
||||
// GetWithExpiry **不删除**过期条目,否则 stale-while-revalidate 拿不到旧值,
|
||||
// 只能退化成「过期即彻底 miss → 同步重算」,那正是投票结果 O(N²) 的成因。
|
||||
func TestTTLCacheGetWithExpiryKeepsEntry(t *testing.T) {
|
||||
c := NewTTLCache(8)
|
||||
c.Set("k", "v", time.Nanosecond)
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
|
||||
v, exp, ok := c.GetWithExpiry("k")
|
||||
if !ok {
|
||||
t.Fatal("expired entry should still be readable via GetWithExpiry")
|
||||
}
|
||||
if v.(string) != "v" {
|
||||
t.Fatalf("expected stale value v, got %v", v)
|
||||
}
|
||||
if !time.Now().After(exp) {
|
||||
t.Fatal("entry should already be expired")
|
||||
}
|
||||
|
||||
// 再读一次仍然在(证明上一次调用没有顺手删掉它)
|
||||
if _, _, ok := c.GetWithExpiry("k"); !ok {
|
||||
t.Fatal("GetWithExpiry must not evict the entry")
|
||||
}
|
||||
|
||||
// 而普通 Get 仍按原语义返回 miss(并清掉它)
|
||||
if _, ok := c.Get("k"); ok {
|
||||
t.Fatal("Get should report miss for an expired entry")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTTLCacheGetWithExpiryMissing 不存在的 key 必须返回 ok=false,
|
||||
// 且不能凭空造出一个零值过期时间让调用方误判为「过期但有值」。
|
||||
func TestTTLCacheGetWithExpiryMissing(t *testing.T) {
|
||||
c := NewTTLCache(8)
|
||||
if _, _, ok := c.GetWithExpiry("nope"); ok {
|
||||
t.Fatal("expected miss for unknown key")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTTLCacheExpireKeepsValue 锁住 Expire 与 Delete 的区别:
|
||||
// Expire 后条目仍在(可拿到旧值并触发后台刷新),而 Delete 之后彻底 miss。
|
||||
func TestTTLCacheExpireKeepsValue(t *testing.T) {
|
||||
c := NewTTLCache(8)
|
||||
|
||||
c.Set("k", "v", time.Hour)
|
||||
c.Expire("k")
|
||||
|
||||
v, exp, ok := c.GetWithExpiry("k")
|
||||
if !ok || v.(string) != "v" {
|
||||
t.Fatalf("Expire must keep the value, got %v %v", v, ok)
|
||||
}
|
||||
if !time.Now().After(exp) {
|
||||
t.Fatal("Expire must push expiresAt into the past")
|
||||
}
|
||||
// 普通 Get 立刻视为过期
|
||||
if _, ok := c.Get("k"); ok {
|
||||
t.Fatal("Get should see the expired entry as a miss")
|
||||
}
|
||||
|
||||
// 对不存在的 key 调用 Expire 不应 panic 也不应创建条目
|
||||
c.Expire("absent")
|
||||
if _, _, ok := c.GetWithExpiry("absent"); ok {
|
||||
t.Fatal("Expire must not create an entry")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// Session key prefix for storing math captcha answers
|
||||
sessionKeyPrefix = "math_captcha_"
|
||||
// TTL for each captcha in session
|
||||
captchaTTL = 5 * time.Minute
|
||||
captchaTTL = 5 * time.Minute
|
||||
// maxCaptchaAttempts 同一条验证码被尝试(含模态框预校验)的最大次数,
|
||||
// 超过即销毁该验证码,迫使刷新,防止对答案做无限轮询爆破。
|
||||
maxCaptchaAttempts = 5
|
||||
@@ -52,7 +50,6 @@ type StoredAnswer struct {
|
||||
|
||||
// GenerateMath creates a new arithmetic captcha problem and stores the answer in session.
|
||||
func GenerateMath(s session.Session) (*MathProblem, error) {
|
||||
// Generate random operands
|
||||
op1, err := rand.Int(rand.Reader, big.NewInt(90))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "generate operand 1")
|
||||
@@ -64,7 +61,6 @@ func GenerateMath(s session.Session) (*MathProblem, error) {
|
||||
a := int(op1.Int64()) + 1 // 1-90
|
||||
b := int(op2.Int64()) + 1 // 1-90
|
||||
|
||||
// Pick a random operator (+, -, *)
|
||||
opIdx, err := rand.Int(rand.Reader, big.NewInt(3))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "generate operator")
|
||||
@@ -90,18 +86,16 @@ func GenerateMath(s session.Session) (*MathProblem, error) {
|
||||
answer = a * b
|
||||
}
|
||||
|
||||
// 生成唯一 ID
|
||||
idBytes := make([]byte, 8)
|
||||
if _, err := rand.Read(idBytes); err != nil {
|
||||
return nil, errors.Wrap(err, "generate captcha id")
|
||||
}
|
||||
id := hex.EncodeToString(idBytes)
|
||||
|
||||
// 清理策略说明:flamego session 接口不支持遍历,无法主动清理过期条目。
|
||||
// 泄漏可控:验证码一次性消费(VerifyMath 校验后立即删除),
|
||||
// 且每个用户同一时刻只生成 1 个新 key;session 本身过期后存储层整体清理。
|
||||
// 清理策略:flamego session 不支持遍历,无法主动清理过期条目。但验证码一次性消费
|
||||
// (校验后立即删除)、每用户同时刻只生成 1 个新 key,session 过期后存储层整体清理,
|
||||
// 泄漏可控。
|
||||
|
||||
// Store answer in session
|
||||
key := sessionKeyPrefix + id
|
||||
s.Set(key, StoredAnswer{
|
||||
Answer: answer,
|
||||
@@ -136,14 +130,12 @@ func VerifyMath(s session.Session, token string, lang string) (bool, error) {
|
||||
return false, errors.New(i18n.T(lang, "captcha.errAnswerFormat"))
|
||||
}
|
||||
|
||||
// Retrieve stored answer
|
||||
key := sessionKeyPrefix + id
|
||||
stored, ok := s.Get(key).(StoredAnswer)
|
||||
if !ok {
|
||||
return false, errors.New(i18n.T(lang, "captcha.errExpired"))
|
||||
}
|
||||
|
||||
// Check expiry
|
||||
if time.Since(stored.CreatedAt) > captchaTTL {
|
||||
s.Delete(key)
|
||||
return false, errors.New(i18n.T(lang, "captcha.errExpired"))
|
||||
@@ -186,8 +178,8 @@ func VerifyOnly(s session.Session, id string, answer int) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(strconv.Itoa(answer)), []byte(strconv.Itoa(stored.Answer))) == 1
|
||||
}
|
||||
|
||||
// sessionLang 从会话读取匿名用户显式选择的语言(与 Contexter 一致,语言偏好现托管于
|
||||
// session 而非独立 lang cookie,避免被 session 中间件的 Set-Cookie 覆盖);未设置时回退默认。
|
||||
// sessionLang 从会话读取匿名用户显式选择的语言(语言偏好托管于 session 而非独立 lang
|
||||
// cookie,避免被 session 中间件的 Set-Cookie 覆盖);未设置时回退默认。
|
||||
func sessionLang(s session.Session) string {
|
||||
if v := s.Get("lang"); v != nil {
|
||||
if str, ok := v.(string); ok && str != "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 下对象数不应触发分页")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// 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 (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
)
|
||||
|
||||
// HidePublic 把某箱主已公开的回答批量转为私密。
|
||||
//
|
||||
// 存在意义:处置一个「组织挂人」的箱主时,L1 只停分发、L2 只影响新内容,
|
||||
// 已经挂在上面的几十上百条旧内容仍然公开可访问。逐条点后台不现实,
|
||||
// 而直接封号又过重(这类账号往往同时有大量正常内容)。
|
||||
//
|
||||
// 默认**只动「公开且已回答」的题**:未回答的本来就不公开,已私密的不必再动,
|
||||
// 不会误伤草稿态数据。加上 --only-reported 可进一步只处理「有未处理举报」的条,
|
||||
// 这是最精准的用法 —— 只收掉被投诉的部分,其余正常内容保留。
|
||||
var HidePublic = &cli.Command{
|
||||
Name: "hide-public",
|
||||
Usage: "Hide all public answers of a user (set is_private = true)",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "email", Usage: "Email of the user"},
|
||||
&cli.StringFlag{Name: "domain", Usage: "Custom domain of the user"},
|
||||
&cli.BoolFlag{Name: "only-reported", Usage: "Only hide questions that have pending reports"},
|
||||
&cli.IntFlag{Name: "since", Usage: "Only hide questions created within the last N days (0 = all)"},
|
||||
&cli.BoolFlag{Name: "yes", Usage: "Skip confirmation"},
|
||||
},
|
||||
Action: runHidePublic,
|
||||
}
|
||||
|
||||
func runHidePublic(ctx *cli.Context) error {
|
||||
database, err := openDatabase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user, err := findTargetUser(database, ctx.String("email"), ctx.String("domain"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
onlyReported := ctx.Bool("only-reported")
|
||||
sinceDays := ctx.Int("since")
|
||||
if sinceDays < 0 {
|
||||
return errors.New("--since must not be negative")
|
||||
}
|
||||
|
||||
// ⚠️ 每次都重新构建 query:GORM 的链式对象在 Count/Find 之后会带上 Statement 状态,
|
||||
// 复用同一个实例做「统计 → 预览 → 更新」会导致条件互相污染。
|
||||
build := func() *gorm.DB {
|
||||
q := database.Model(&db.Question{}).
|
||||
Where("user_id = ? AND answer != '' AND is_private = false", user.ID)
|
||||
if onlyReported {
|
||||
q = q.Where("id IN (?)", database.Model(&db.ContentReport{}).
|
||||
Select("question_id").
|
||||
Where("status = ?", db.ReportStatusPending))
|
||||
}
|
||||
if sinceDays > 0 {
|
||||
q = q.Where("created_at >= ?", time.Now().AddDate(0, 0, -sinceDays))
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := build().Count(&total).Error; err != nil {
|
||||
return errors.Wrap(err, "count public questions")
|
||||
}
|
||||
|
||||
fmt.Printf("箱主: %s (@%s) id=%d\n", user.Name, user.Domain, user.ID)
|
||||
if onlyReported {
|
||||
fmt.Println("范围: 仅「有未处理举报」的公开回答")
|
||||
} else {
|
||||
fmt.Println("范围: 全部公开回答")
|
||||
}
|
||||
if sinceDays > 0 {
|
||||
fmt.Printf("时间: 仅最近 %d 天\n", sinceDays)
|
||||
}
|
||||
if total == 0 {
|
||||
fmt.Println("没有需要隐藏的内容")
|
||||
return nil
|
||||
}
|
||||
|
||||
var preview []*db.Question
|
||||
if err := build().Order("id DESC").Limit(10).Find(&preview).Error; err != nil {
|
||||
return errors.Wrap(err, "preview questions")
|
||||
}
|
||||
fmt.Printf("将把 %d 条回答转为私密(最新 10 条预览):\n", total)
|
||||
for _, q := range preview {
|
||||
fmt.Printf(" #%-6d %s %s\n", q.ID, q.CreatedAt.Format("2006-01-02 15:04"), truncateForCLI(q.Content, 44))
|
||||
}
|
||||
if total > int64(len(preview)) {
|
||||
fmt.Printf(" ...(其余 %d 条略)\n", total-int64(len(preview)))
|
||||
}
|
||||
|
||||
if !ctx.Bool("yes") {
|
||||
logrus.Warn("输入大写 YES 确认执行(可逆:箱主可在后台逐条重新设为公开):")
|
||||
var answer string
|
||||
if _, err := fmtScan(&answer); err != nil {
|
||||
return errors.Wrap(err, "read confirmation")
|
||||
}
|
||||
if answer != "YES" {
|
||||
return errors.New("已取消")
|
||||
}
|
||||
}
|
||||
|
||||
// ⚠️ 必须先快照目标 ID 再更新:build() 的条件里有 is_private = false,
|
||||
// 一旦执行了 Update,再拿 build() 当子查询就恒为空集,举报闭环会静默失效
|
||||
// (实测:隐藏成功但 content_reports 仍是 pending)。
|
||||
var targetIDs []uint
|
||||
if err := build().Pluck("id", &targetIDs).Error; err != nil {
|
||||
return errors.Wrap(err, "collect target question ids")
|
||||
}
|
||||
if len(targetIDs) == 0 {
|
||||
fmt.Println("没有需要隐藏的内容")
|
||||
return nil
|
||||
}
|
||||
|
||||
result := database.Model(&db.Question{}).Where("id IN ?", targetIDs).Update("is_private", true)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(result.Error, "hide public questions")
|
||||
}
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"user_id": user.ID,
|
||||
"affected": result.RowsAffected,
|
||||
"only_reported": onlyReported,
|
||||
}).Warn("公开回答已批量转为私密")
|
||||
|
||||
// 举报闭环:被隐藏的内容等同于已处理,对应的待处理举报一并标记为成立,
|
||||
// 否则这些举报会继续计入告警阈值(详见 internal/db/report.go 的说明)。
|
||||
accepted := database.WithContext(ctx.Context).
|
||||
Model(&db.ContentReport{}).
|
||||
Where("question_id IN ?", targetIDs).
|
||||
Where("status = ?", db.ReportStatusPending).
|
||||
Update("status", db.ReportStatusAccepted)
|
||||
if accepted.Error != nil {
|
||||
logrus.WithError(accepted.Error).Warn("标记举报为已处理时异常(内容隐藏已生效,可稍后手动处理)")
|
||||
} else if accepted.RowsAffected > 0 {
|
||||
fmt.Printf("同时把 %d 条待处理举报标记为已处理\n", accepted.RowsAffected)
|
||||
}
|
||||
|
||||
fmt.Printf("完成:%d 条已转为私密\n", result.RowsAffected)
|
||||
fmt.Println(strings.TrimSpace(`
|
||||
注意:这只改变可见性,不改变回答内容;箱主仍可在自己的后台逐条改回公开。
|
||||
如果目的是「停止分发」,还需要配合:TamaBox restrict --domain <domain> --level L1 --days N`))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
// 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"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
)
|
||||
|
||||
// Honeypot 提供蜜罐取证命令(只读 + 一条显式清理)。
|
||||
//
|
||||
// TamaBox honeypot hits --since 24h 列出捕获到的破解凭据
|
||||
// TamaBox honeypot stats --days 7 统计来源/路径/凭据排行
|
||||
// TamaBox honeypot size 报告表行数与磁盘占用
|
||||
// TamaBox honeypot report --period week 生成安全汇总周报(含地图)并发邮件
|
||||
// TamaBox honeypot prune --days 30 清理过期记录(保留期由配置决定)
|
||||
//
|
||||
// 密码默认脱敏:CLI 输出会被贴到工单、聊天窗口甚至截图里,
|
||||
// 明文只在确有取证需要时用 --show-password 显式打开。
|
||||
var Honeypot = &cli.Command{
|
||||
Name: "honeypot",
|
||||
Usage: "Inspect honeypot hits (fake admin login page credential capture)",
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "hits",
|
||||
Usage: "List captured credential submissions",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "since", Value: "24h", Usage: "Time window, e.g. 24h / 7d / 30m"},
|
||||
&cli.IntFlag{Name: "limit", Value: 50, Usage: "Max rows to print"},
|
||||
&cli.StringFlag{Name: "ip", Usage: "Only show hits from this source IP"},
|
||||
&cli.BoolFlag{Name: "show-password", Usage: "Print passwords in plaintext (default: masked)"},
|
||||
},
|
||||
Action: runHoneypotHits,
|
||||
},
|
||||
{
|
||||
Name: "stats",
|
||||
Usage: "Show honeypot statistics (top sources / paths / credentials)",
|
||||
Flags: []cli.Flag{
|
||||
&cli.IntFlag{Name: "days", Value: 7, Usage: "Statistics window in days"},
|
||||
&cli.IntFlag{Name: "limit", Value: 20, Usage: "Max rows per section"},
|
||||
&cli.BoolFlag{Name: "show-password", Usage: "Print passwords in plaintext (default: masked)"},
|
||||
},
|
||||
Action: runHoneypotStats,
|
||||
},
|
||||
{
|
||||
Name: "prune",
|
||||
Usage: "Delete honeypot records (DB metadata + credential files) older than the retention period",
|
||||
Flags: []cli.Flag{
|
||||
&cli.IntFlag{Name: "days", Value: 0, Usage: "Days to keep (0 = use honeypot_retain_days from config)"},
|
||||
&cli.BoolFlag{Name: "yes", Usage: "Skip confirmation"},
|
||||
},
|
||||
Action: runHoneypotPrune,
|
||||
},
|
||||
{
|
||||
Name: "size",
|
||||
Usage: "Report honeypot table row count and disk usage",
|
||||
Action: runHoneypotSize,
|
||||
},
|
||||
// 周期安全汇总:把命中按 IP 定位到城市,出地图报告并邮件送达。
|
||||
reportCommand,
|
||||
{
|
||||
Name: "paths",
|
||||
Usage: "List active decoy paths (built-in + honeypot_paths)",
|
||||
Action: runHoneypotPaths,
|
||||
},
|
||||
{
|
||||
Name: "purge-creds",
|
||||
Usage: "Drop legacy credential columns (username/password/fields/raw_body) from honeypot_hits — irreversible",
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{Name: "yes", Usage: "Skip confirmation"},
|
||||
},
|
||||
Action: runHoneypotPurgeCreds,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// parseSince 解析 --since 参数,支持 30m / 24h / 7d / 2w。
|
||||
func parseSince(s string) (time.Duration, error) {
|
||||
s = strings.TrimSpace(strings.ToLower(s))
|
||||
if s == "" {
|
||||
return 24 * time.Hour, nil
|
||||
}
|
||||
unit := s[len(s)-1]
|
||||
numPart := s[:len(s)-1]
|
||||
var mult time.Duration
|
||||
switch unit {
|
||||
case 'm':
|
||||
mult = time.Minute
|
||||
case 'h':
|
||||
mult = time.Hour
|
||||
case 'd':
|
||||
mult = 24 * time.Hour
|
||||
case 'w':
|
||||
mult = 7 * 24 * time.Hour
|
||||
default:
|
||||
return 0, errors.Errorf("invalid --since value %q (expect e.g. 30m / 24h / 7d)", s)
|
||||
}
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(numPart, "%d", &n); err != nil || n <= 0 {
|
||||
return 0, errors.Errorf("invalid --since value %q", s)
|
||||
}
|
||||
return time.Duration(n) * mult, nil
|
||||
}
|
||||
|
||||
// credStoreFromConfig 用已加载的配置打开凭据取证目录。
|
||||
//
|
||||
// 凭据(用户名/密码)只在文件里,不查数据库 —— 所以任何需要展示凭据的
|
||||
// 命令都走这里,而不是 db.HoneypotStore。
|
||||
func credStoreFromConfig() (honeypot.CredStore, error) {
|
||||
return honeypot.NewFileCredStore(conf.Security.HoneypotCredDir)
|
||||
}
|
||||
|
||||
func runHoneypotHits(ctx *cli.Context) error {
|
||||
dur, err := parseSince(ctx.String("since"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 凭据只在取证文件里,本命令不再连数据库 —— DB 挂了也能查。
|
||||
// (元数据在库里不含凭据内容,见 db.HoneypotHit.HadFields 的注释。)
|
||||
if err := conf.Init(); err != nil {
|
||||
return errors.Wrap(err, "load configuration")
|
||||
}
|
||||
store, err := credStoreFromConfig()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "open honeypot credential store")
|
||||
}
|
||||
since := time.Now().Add(-dur)
|
||||
filterIP := strings.TrimSpace(ctx.String("ip"))
|
||||
showPwd := ctx.Bool("show-password")
|
||||
|
||||
// IP 过滤在读取阶段做(列表本来就是流式读,先取最近 N 条再筛会让
|
||||
// 指定 IP 的历史记录被其他 IP 挤出去)。
|
||||
rows, err := store.ListSince(since, filterIP, ctx.Int("limit"))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "read honeypot credentials")
|
||||
}
|
||||
|
||||
fmt.Printf("蜜罐凭据记录(近 %s,共 %d 条,来源 %s)\n", dur, len(rows), store.Dir())
|
||||
fmt.Println(strings.Repeat("-", 72))
|
||||
if len(rows) == 0 {
|
||||
fmt.Println(" 无")
|
||||
return nil
|
||||
}
|
||||
for _, r := range rows {
|
||||
pwd := r.Password
|
||||
if pwd == "" {
|
||||
pwd = "(空)"
|
||||
} else if !showPwd {
|
||||
pwd = maskSecret(pwd)
|
||||
}
|
||||
user := r.Username
|
||||
if user == "" {
|
||||
user = "(空)"
|
||||
}
|
||||
fmt.Printf("%s %-8s %-6s %s\n", r.Time.Format("2006-01-02 15:04:05"), r.IP, r.Method, truncateForCLI(r.Path, 48))
|
||||
fmt.Printf(" 用户名:%s 密码:%s\n", truncateForCLI(user, 64), truncateForCLI(pwd, 64))
|
||||
if r.UserAgent != "" {
|
||||
fmt.Printf(" UA:%s\n", truncateForCLI(r.UserAgent, 96))
|
||||
}
|
||||
if r.Banned {
|
||||
fmt.Printf(" 处置:已触发封禁\n")
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
if !showPwd {
|
||||
fmt.Println("提示:密码默认脱敏。需要明文时加 --show-password。")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runHoneypotStats(ctx *cli.Context) error {
|
||||
days := ctx.Int("days")
|
||||
if days <= 0 {
|
||||
days = 7
|
||||
}
|
||||
database, err := openDatabase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store := db.NewHoneypotStore(database)
|
||||
cctx := context.Background()
|
||||
since := time.Now().AddDate(0, 0, -days)
|
||||
|
||||
total, err := store.CountSince(cctx, since)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "count honeypot hits")
|
||||
}
|
||||
// 元数据表存的是精确地址(取证需要),但 IPv6 攻击者常在一个 /64 内轮换地址:
|
||||
// 直接按精确 IP 排行会碎成几十条各 1 次的记录,看不出「其实是同一个人」。
|
||||
// 多取一些再按封禁同口径(/64)合并,见 mergeSourcesByKey。
|
||||
limit := ctx.Int("limit")
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
sources, err := store.TopSources(cctx, since, limit*4)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "query top sources")
|
||||
}
|
||||
sources = mergeSourcesByKey(sources, limit)
|
||||
paths, err := store.TopPaths(cctx, since, ctx.Int("limit"))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "query top paths")
|
||||
}
|
||||
credStore, err := credStoreFromConfig()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "open honeypot credential store")
|
||||
}
|
||||
creds, err := credStore.TopCredentials(since, ctx.Int("limit"))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "query top credentials")
|
||||
}
|
||||
|
||||
fmt.Printf("蜜罐统计:近 %d 天,共 %d 次命中\n", days, total)
|
||||
fmt.Println(strings.Repeat("-", 72))
|
||||
|
||||
fmt.Println("来源排行(IPv6 按 /64 聚合,与封禁口径一致):")
|
||||
if len(sources) == 0 {
|
||||
fmt.Println(" 无")
|
||||
}
|
||||
for i, s := range sources {
|
||||
fmt.Printf(" %2d. %-40s %5d 次 %s\n", i+1, s.IP, s.Count, s.Provider)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("被探测路径排行:")
|
||||
if len(paths) == 0 {
|
||||
fmt.Println(" 无")
|
||||
}
|
||||
for i, p := range paths {
|
||||
fmt.Printf(" %2d. %-50s %5d 次\n", i+1, truncateForCLI(p.Path, 50), p.Count)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("最常被尝试的凭据组合(来自取证文件,非数据库):")
|
||||
if len(creds) == 0 {
|
||||
fmt.Println(" 无")
|
||||
}
|
||||
showPwd := ctx.Bool("show-password")
|
||||
for i, c := range creds {
|
||||
pwd := c.Password
|
||||
if pwd == "" {
|
||||
pwd = "(空)"
|
||||
} else if !showPwd {
|
||||
pwd = maskSecret(pwd)
|
||||
}
|
||||
user := c.Username
|
||||
if user == "" {
|
||||
user = "(空)"
|
||||
}
|
||||
fmt.Printf(" %2d. %-30s %-30s %5d 次\n", i+1, truncateForCLI(user, 30), truncateForCLI(pwd, 30), c.Count)
|
||||
}
|
||||
if !showPwd {
|
||||
fmt.Println()
|
||||
fmt.Println("提示:密码默认脱敏。需要明文时加 --show-password。")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runHoneypotPrune(ctx *cli.Context) error {
|
||||
days := ctx.Int("days")
|
||||
if days <= 0 {
|
||||
days = conf.Security.HoneypotRetainDays
|
||||
}
|
||||
if days <= 0 {
|
||||
days = 30
|
||||
}
|
||||
database, err := openDatabase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before := time.Now().AddDate(0, 0, -days)
|
||||
if !ctx.Bool("yes") {
|
||||
fmt.Printf("将删除 %s 之前的蜜罐记录(共保留 %d 天)。\n", before.Format("2006-01-02 15:04:05"), days)
|
||||
fmt.Print("确认继续?输入 yes 继续:")
|
||||
var answer string
|
||||
if _, err := fmt.Scanln(&answer); err != nil || strings.TrimSpace(answer) != "yes" {
|
||||
fmt.Println("已取消。")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
store := db.NewHoneypotStore(database)
|
||||
n, err := store.DeleteBefore(context.Background(), before)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "prune honeypot records")
|
||||
}
|
||||
fmt.Printf("已删除 %d 条过期蜜罐记录。\n", n)
|
||||
// 回收物理空间:PostgreSQL 的 DELETE 只标记死元组,表文件不会变小。
|
||||
// 不回收的话「清理」只是逻辑变小,磁盘占用继续单调增长。
|
||||
if n > 0 {
|
||||
if err := store.Compact(context.Background()); err != nil {
|
||||
fmt.Printf("提示:空间回收失败(%v),数据本身已正确删除;PostgreSQL 下可手动执行 VACUUM honeypot_hits。\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 凭据文件同样会过期,一并清理:明文账号密码不该比元数据留得更久。
|
||||
// 这也让「prune」在运维心里只有一个语义 —— 按保留期清干净,不用记两套。
|
||||
if cs, err := credStoreFromConfig(); err != nil {
|
||||
fmt.Printf("提示:无法打开凭据目录,凭据文件未清理(%v)。\n", err)
|
||||
} else if m, err := cs.PurgeBefore(before); err != nil {
|
||||
fmt.Printf("提示:凭据文件清理失败(%v)。\n", err)
|
||||
} else if m > 0 {
|
||||
fmt.Printf("已删除 %d 个过期凭据文件(%s)。\n", m, cs.Dir())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runHoneypotSize 报告蜜罐表的行数与磁盘占用。
|
||||
//
|
||||
// 这个命令存在的意义:把「会不会把数据库撑臃肿」变成运维随时能自己查的事,
|
||||
// 而不是只能等开发一次性汇报。
|
||||
func runHoneypotSize(ctx *cli.Context) error {
|
||||
database, err := openDatabase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store := db.NewHoneypotStore(database)
|
||||
info, err := store.Size(context.Background())
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "query honeypot table size")
|
||||
}
|
||||
|
||||
fmt.Println("蜜罐表体积")
|
||||
fmt.Println(strings.Repeat("-", 72))
|
||||
fmt.Printf(" 表名:honeypot_hits\n")
|
||||
fmt.Printf(" 存活记录:%d 条\n", info.Rows)
|
||||
if info.Bytes > 0 {
|
||||
fmt.Printf(" 磁盘占用:%s(含索引)\n", humanBytes(info.Bytes))
|
||||
if info.Rows > 0 {
|
||||
fmt.Printf(" 单条均摊:%d 字节\n", info.Bytes/info.Rows)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf(" 磁盘占用:(当前数据库不支持元数据查询)\n")
|
||||
}
|
||||
if info.Oldest != nil && info.Newest != nil {
|
||||
fmt.Printf(" 时间跨度:%s → %s\n",
|
||||
info.Oldest.Format("2006-01-02 15:04"),
|
||||
info.Newest.Format("2006-01-02 15:04"))
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// 与配置中的两道闸对照,让运维一眼看出「当前是否在界内」。
|
||||
maxRows := conf.Security.HoneypotMaxRows
|
||||
retainDays := conf.Security.HoneypotRetainDays
|
||||
if maxRows > 0 {
|
||||
fmt.Printf("行数上限:%d 条(当前 %d 条,%s)\n", maxRows, info.Rows, usageRatio(info.Rows, int64(maxRows)))
|
||||
} else {
|
||||
fmt.Printf("行数上限:未设置(当前 %d 条)\n", info.Rows)
|
||||
}
|
||||
if retainDays > 0 {
|
||||
fmt.Printf("保留期:%d 天(自动清理 %s)\n", retainDays, onOff(conf.Security.HoneypotAutoClean))
|
||||
} else {
|
||||
fmt.Printf("保留期:未设置 —— 记录只增不减,需要手动执行 TamaBox honeypot prune\n")
|
||||
}
|
||||
|
||||
// 凭据与元数据是两套存储,体积必须分开看:
|
||||
// 数据库里只有元数据,明文账号密码在文件里。
|
||||
fmt.Println()
|
||||
if cs, err := credStoreFromConfig(); err == nil {
|
||||
if n, size, err := cs.Size(); err == nil {
|
||||
fmt.Printf("凭据取证文件:%d 个,%s\n", n, humanBytes(size))
|
||||
fmt.Printf(" 目录:%s\n", cs.Dir())
|
||||
fmt.Println(" 说明:用户名/密码只存在这里,不入库;按天一个文件,随保留期一并清理。")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runHoneypotPaths 列出当前生效的诱饵路径。
|
||||
//
|
||||
// 存在的意义:honeypot_paths 是自由文本配置,写错了(拼错、写成正则、漏斜杠)
|
||||
// 从任何输出里都看不出来 —— 这个命令让「我配的到底生效没」变成一条可查的事实。
|
||||
func runHoneypotPaths(ctx *cli.Context) error {
|
||||
// 本命令不查库(DB 挂掉时也能查配置),因此不能走 openDatabase 那条
|
||||
// 顺带 conf.Init 的路径 —— 必须显式加载,否则读到的全是零值,
|
||||
// 会显示「蜜罐关闭」并把 honeypot_paths 吞掉。
|
||||
if err := conf.Init(); err != nil {
|
||||
return errors.Wrap(err, "load configuration")
|
||||
}
|
||||
|
||||
extra := honeypot.SplitPaths(conf.Security.HoneypotPaths)
|
||||
builtin := honeypot.New(honeypot.Options{Enabled: true}).Prefixes()
|
||||
active := honeypot.New(honeypot.Options{Enabled: true, ExtraPaths: extra}).Prefixes()
|
||||
|
||||
fmt.Printf("蜜罐开关:%s\n", onOff(conf.Security.EnableHoneypot))
|
||||
if !conf.Security.EnableHoneypot {
|
||||
fmt.Println("提示:enable_honeypot = false,以下路径当前不会接管任何请求。")
|
||||
}
|
||||
fmt.Println(strings.Repeat("-", 72))
|
||||
fmt.Printf("生效诱饵路径:%d 条(内置 %d + 自定义 %d,重名已合并)\n",
|
||||
len(active), len(builtin), len(extra))
|
||||
|
||||
// 按家族汇总:运维真正关心的是「哪几类后台在伪装」,而不是 100 条路径。
|
||||
// 家族决定返回哪种登录页(WordPress / phpMyAdmin / Jenkins / Tomcat …)。
|
||||
counts := make(map[honeypot.Family]int, len(honeypot.Families()))
|
||||
for _, p := range active {
|
||||
counts[honeypot.FamilyOf(p)]++
|
||||
}
|
||||
parts := make([]string, 0, len(counts))
|
||||
for _, fam := range honeypot.Families() {
|
||||
if n := counts[fam]; n > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%s %d", fam, n))
|
||||
}
|
||||
}
|
||||
fmt.Println("伪装家族:" + strings.Join(parts, " / "))
|
||||
for _, p := range active {
|
||||
fmt.Println(" " + p)
|
||||
}
|
||||
if len(extra) > 0 {
|
||||
fmt.Println()
|
||||
fmt.Println("自定义路径(honeypot_paths):")
|
||||
for _, p := range extra {
|
||||
fmt.Println(" " + p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runHoneypotPurgeCreds 删除旧版本留在 honeypot_hits 上的明文凭据列。
|
||||
//
|
||||
// 为什么需要:结构体早已去掉这些字段,但 GORM 的 AutoMigrate **不删既有列**,
|
||||
// 老库升级上来后历史明文仍在,还会跟着进备份与主从复制。
|
||||
// DROP COLUMN 不可逆,所以做成显式命令 + 二次确认,不放启动流程里自动执行
|
||||
// (启动时只会 WARN 提示)。
|
||||
func runHoneypotPurgeCreds(ctx *cli.Context) error {
|
||||
database, err := openDatabase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store := db.NewHoneypotStore(database)
|
||||
|
||||
cols, err := store.LegacyCredentialColumns(context.Background())
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "check legacy credential columns")
|
||||
}
|
||||
if len(cols) == 0 {
|
||||
fmt.Println("honeypot_hits 上没有遗留的凭据列,无需清理。")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("honeypot_hits 上仍存在以下旧版明文凭据列(整列删除,不可逆):")
|
||||
for _, c := range cols {
|
||||
fmt.Println(" " + c)
|
||||
}
|
||||
fmt.Println("注意:本命令只处理数据库;磁盘上的历史备份需要你自行处理。")
|
||||
if !ctx.Bool("yes") {
|
||||
fmt.Print("确认继续?输入 yes 继续:")
|
||||
var answer string
|
||||
if _, err := fmt.Scanln(&answer); err != nil || strings.TrimSpace(answer) != "yes" {
|
||||
fmt.Println("已取消。")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
dropped, err := store.PurgeLegacyCredentialColumns(context.Background())
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "凭据列删除中途失败,已删除 %v", dropped)
|
||||
}
|
||||
fmt.Printf("已删除 %d 列:%s\n", len(dropped), strings.Join(dropped, ", "))
|
||||
return nil
|
||||
}
|
||||
|
||||
// usageRatio 给出当前用量相对上限的百分比描述。
|
||||
func usageRatio(cur int64, max int64) string {
|
||||
if max <= 0 {
|
||||
return ""
|
||||
}
|
||||
pct := float64(cur) / float64(max) * 100
|
||||
switch {
|
||||
case pct >= 100:
|
||||
return "已达上限"
|
||||
case pct >= 80:
|
||||
return "接近上限"
|
||||
default:
|
||||
return fmt.Sprintf("%.1f%%", pct)
|
||||
}
|
||||
}
|
||||
|
||||
func onOff(b bool) string {
|
||||
if b {
|
||||
return "已开启"
|
||||
}
|
||||
return "已关闭"
|
||||
}
|
||||
|
||||
// humanBytes 把字节数格式化成人类可读形式。
|
||||
func humanBytes(n int64) string {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for v := n / unit; v >= unit; v /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.2f %cB", float64(n)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
// maskSecret 对密码做保长脱敏:保留首尾各 1 位,中间固定长度星号。
|
||||
// 目的是让运维能分辨「是不是同一个密码」,但不把字典直接摊在终端上。
|
||||
func maskSecret(s string) string {
|
||||
r := []rune(s)
|
||||
switch {
|
||||
case len(r) <= 2:
|
||||
return strings.Repeat("*", len(r))
|
||||
case len(r) <= 6:
|
||||
return string(r[0]) + strings.Repeat("*", len(r)-2) + string(r[len(r)-1])
|
||||
default:
|
||||
return string(r[0]) + strings.Repeat("*", 6) + string(r[len(r)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// mergeSourcesByKey 按「封禁 / 限流」的同一口径合并来源排行。
|
||||
//
|
||||
// 元数据表存的是精确地址(取证需要,见 db.HoneypotHit.IP),但 IPv6 攻击者
|
||||
// 通常在一个 /64 内轮换地址:直接按精确 IP 排行会碎成几十条各 1 次的记录,
|
||||
// 看不出「其实是同一台机器」。这里用 shared.LimitKey 折成 /64 ——
|
||||
// 与 autoban 判定、限流的键完全一致,看到的排行就是「谁在被封」。
|
||||
//
|
||||
// 代价:SQL 只取了前 limit*4 条精确地址,极端长尾可能漏算,排行本身是近似展示。
|
||||
func mergeSourcesByKey(rows []*db.HoneypotSourceStat, limit int) []*db.HoneypotSourceStat {
|
||||
if len(rows) == 0 {
|
||||
return rows
|
||||
}
|
||||
type agg struct {
|
||||
count int64
|
||||
provider string
|
||||
}
|
||||
merged := make(map[string]*agg, len(rows))
|
||||
for _, r := range rows {
|
||||
key := shared.LimitKey(r.IP)
|
||||
if a, ok := merged[key]; ok {
|
||||
a.count += r.Count
|
||||
if a.provider == "" {
|
||||
a.provider = r.Provider
|
||||
}
|
||||
continue
|
||||
}
|
||||
merged[key] = &agg{count: r.Count, provider: r.Provider}
|
||||
}
|
||||
out := make([]*db.HoneypotSourceStat, 0, len(merged))
|
||||
for key, a := range merged {
|
||||
out = append(out, &db.HoneypotSourceStat{IP: key, Count: a.count, Provider: a.provider})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Count != out[j].Count {
|
||||
return out[i].Count > out[j].Count
|
||||
}
|
||||
return out[i].IP < out[j].IP // 同次数时稳定排序,避免每次输出顺序抖动
|
||||
})
|
||||
if limit > 0 && len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright 2026 tamakyi. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
)
|
||||
|
||||
// TestMergeSourcesByKey 验证来源排行按「封禁口径」合并(IPv6 折成 /64)。
|
||||
//
|
||||
// 元数据表存的是精确地址(取证需要),若排行榜也按精确地址分组,
|
||||
// 一个在 /64 内轮换地址的扫描器会碎成几十条各 1 次的记录 ——
|
||||
// 看起来像「很多人在扫」,其实只有一台机器。合并口径必须与 autoban 一致。
|
||||
func TestMergeSourcesByKey(t *testing.T) {
|
||||
rows := []*db.HoneypotSourceStat{
|
||||
{IP: "2408:8207:1234:5678::1", Count: 5, Provider: "联通"},
|
||||
{IP: "2408:8207:1234:5678::2", Count: 3},
|
||||
{IP: "1.2.3.4", Count: 4, Provider: "Azure"},
|
||||
{IP: "2408:8207:9999:0001::1", Count: 2},
|
||||
}
|
||||
|
||||
got := mergeSourcesByKey(rows, 10)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("合并后条目数 = %d, want 3(同 /64 必须合并)", len(got))
|
||||
}
|
||||
if got[0].IP != "2408:8207:1234:5678::/64" || got[0].Count != 8 {
|
||||
t.Errorf("第一名 = %s / %d, want 2408:8207:1234:5678::/64 / 8", got[0].IP, got[0].Count)
|
||||
}
|
||||
if got[0].Provider != "联通" {
|
||||
t.Errorf("合并后应保留首个非空归属,got %q", got[0].Provider)
|
||||
}
|
||||
if got[1].IP != "1.2.3.4" || got[1].Count != 4 {
|
||||
t.Errorf("第二名 = %s / %d, want 1.2.3.4 / 4(IPv4 原样保留)", got[1].IP, got[1].Count)
|
||||
}
|
||||
|
||||
if limited := mergeSourcesByKey(rows, 1); len(limited) != 1 {
|
||||
t.Errorf("limit=1 时条目数 = %d, want 1", len(limited))
|
||||
}
|
||||
if out := mergeSourcesByKey(nil, 10); len(out) != 0 {
|
||||
t.Errorf("空输入应返回空,got %d", len(out))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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), "窗口未结束时不能发不完整的报告")
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// 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 (
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
)
|
||||
|
||||
// Restore 恢复被自助停用的账号。
|
||||
//
|
||||
// 自助停用(Deactivate)只清空内容、保留用户行作「身份占位」(软删除),
|
||||
// 因此恢复只需清掉 deleted_at;内容已不可找回。
|
||||
//
|
||||
// 用法:TamaBox restore --email [email protected]
|
||||
//
|
||||
// TamaBox restore --domain someone --password 'new-password'
|
||||
var Restore = &cli.Command{
|
||||
Name: "restore",
|
||||
Usage: "Restore a deactivated account (clears the identity placeholder)",
|
||||
Action: runRestore,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "email",
|
||||
Usage: "Email of the deactivated account",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "domain",
|
||||
Usage: "Custom domain of the deactivated account",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "password",
|
||||
Usage: "Optional: reset the password to this value (min 8 chars)",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "yes",
|
||||
Usage: "Skip confirmation",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func runRestore(ctx *cli.Context) error {
|
||||
if err := conf.Init(); err != nil {
|
||||
return errors.Wrap(err, "load configuration")
|
||||
}
|
||||
|
||||
dbType := conf.Database.Type
|
||||
if dbType == "postgres" {
|
||||
conf.Database.DSN = conf.PostgresDsn()
|
||||
} else {
|
||||
conf.Database.DSN = conf.MySQLDsn()
|
||||
}
|
||||
|
||||
database, err := db.Init(dbType, conf.Database.DSN)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "connect to database")
|
||||
}
|
||||
|
||||
email := ctx.String("email")
|
||||
domain := ctx.String("domain")
|
||||
if email == "" && domain == "" {
|
||||
return errors.New("must provide --email or --domain")
|
||||
}
|
||||
newPassword := ctx.String("password")
|
||||
if newPassword != "" && len(newPassword) < 8 {
|
||||
return errors.New("password must be at least 8 characters")
|
||||
}
|
||||
|
||||
// 用 Unscoped 查找:待恢复的账号必然是软删除状态,普通查询看不见
|
||||
var user db.User
|
||||
query := database.WithContext(ctx.Context).Unscoped()
|
||||
if email != "" {
|
||||
query = query.Where("email = ?", email)
|
||||
} else {
|
||||
query = query.Where("domain = ?", domain)
|
||||
}
|
||||
if err := query.First(&user).Error; err != nil {
|
||||
return errors.Wrap(err, "find user to restore")
|
||||
}
|
||||
if !user.DeletedAt.Valid {
|
||||
return errors.Errorf("账号 %q 未被停用,无需恢复", user.Domain)
|
||||
}
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"id": user.ID,
|
||||
"name": user.Name,
|
||||
"email": user.Email,
|
||||
"domain": user.Domain,
|
||||
}).Warn("准备恢复以下已停用账号(提问/追问/私聊等内容在停用时已被删除,无法找回)")
|
||||
|
||||
if !ctx.Bool("yes") {
|
||||
logrus.Warn("输入大写 YES 确认执行恢复:")
|
||||
var answer string
|
||||
if _, err := fmtScan(&answer); err != nil {
|
||||
return errors.Wrap(err, "read confirmation")
|
||||
}
|
||||
if answer != "YES" {
|
||||
return errors.New("已取消")
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Users.Reactivate(ctx.Context, user.ID); err != nil {
|
||||
return errors.Wrap(err, "reactivate user")
|
||||
}
|
||||
|
||||
// 密码重置必须在恢复之后:UpdatePassword 走普通作用域,软删除状态下找不到该用户
|
||||
if newPassword != "" {
|
||||
if err := db.Users.UpdatePassword(ctx.Context, user.ID, newPassword); err != nil {
|
||||
return errors.Wrap(err, "reset password")
|
||||
}
|
||||
logrus.WithField("user_id", user.ID).Info("账号已恢复,密码已重置")
|
||||
return nil
|
||||
}
|
||||
|
||||
logrus.WithField("user_id", user.ID).Info("账号已恢复(密码未改动,如原主已忘记密码请用 --password 重置)")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
// 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"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"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/i18n"
|
||||
"github.com/tamakyi/TamaBox/internal/mail"
|
||||
)
|
||||
|
||||
// 本文件提供站方处置能力。项目刻意不做管理后台,因此运营动作全部落在 CLI:
|
||||
//
|
||||
// TamaBox reports --domain xxx 出证据包(只读)
|
||||
// TamaBox restrict --domain xxx --level L1 --days 7 --reason "..."
|
||||
// TamaBox unrestrict --domain xxx
|
||||
//
|
||||
// 处置记录由运行时读表生效(见 db.Restrictions.ActiveLevel),到期自动失效,
|
||||
// 不需要定时任务,也不需要任何后台页面。
|
||||
|
||||
// Restrict 对箱主施加一级处置。
|
||||
var Restrict = &cli.Command{
|
||||
Name: "restrict",
|
||||
Usage: "Restrict a user (L1 stop distribution / L2 narrow publishing / L3 freeze publishing / L4 suspend page)",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "email", Usage: "Email of the user"},
|
||||
&cli.StringFlag{Name: "domain", Usage: "Custom domain of the user"},
|
||||
&cli.StringFlag{Name: "level", Value: string(db.RestrictionL1), Usage: "Restriction level: L1 / L2 / L3 / L4"},
|
||||
&cli.IntFlag{Name: "days", Value: 7, Usage: "Effective days, counted from now"},
|
||||
&cli.StringFlag{Name: "reason", Usage: "Reason recorded for audit (required, not shown to the user)"},
|
||||
&cli.BoolFlag{Name: "yes", Usage: "Skip confirmation"},
|
||||
&cli.BoolFlag{Name: "force", Usage: "Required when --days exceeds 90 (abnormal duration)"},
|
||||
&cli.BoolFlag{Name: "no-notify", Usage: "Do not email the restriction notice to the user"},
|
||||
},
|
||||
Action: runRestrict,
|
||||
}
|
||||
|
||||
// Unrestrict 撤销箱主当前生效的全部处置。
|
||||
var Unrestrict = &cli.Command{
|
||||
Name: "unrestrict",
|
||||
Usage: "Revoke all active restrictions of a user",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "email", Usage: "Email of the user"},
|
||||
&cli.StringFlag{Name: "domain", Usage: "Custom domain of the user"},
|
||||
&cli.BoolFlag{Name: "no-notify", Usage: "Do not email the lift notice to the user"},
|
||||
},
|
||||
Action: runUnrestrict,
|
||||
}
|
||||
|
||||
// Reports 输出某箱主的举报证据包,或全站待处理举报列表(只读,不改数据)。
|
||||
var Reports = &cli.Command{
|
||||
Name: "reports",
|
||||
Usage: "Show abuse reports of a user, or list all pending reports",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "email", Usage: "Email of the user"},
|
||||
&cli.StringFlag{Name: "domain", Usage: "Custom domain of the user"},
|
||||
&cli.IntFlag{Name: "days", Value: 30, Usage: "Only count reports created within N days"},
|
||||
&cli.IntFlag{Name: "limit", Value: 30, Usage: "Max rows to print"},
|
||||
&cli.BoolFlag{Name: "pending", Usage: "List all pending reports across the site (ignores --email/--domain)"},
|
||||
&cli.BoolFlag{Name: "stats", Usage: "Show site-wide report statistics (ignores --email/--domain)"},
|
||||
},
|
||||
Action: runReports,
|
||||
}
|
||||
|
||||
// openDatabase 复用 ban / censor 的初始化流程:读配置 → 拼 DSN → 连库并 AutoMigrate。
|
||||
func openDatabase() (*gorm.DB, error) {
|
||||
if err := conf.Init(); err != nil {
|
||||
return nil, errors.Wrap(err, "load configuration")
|
||||
}
|
||||
|
||||
dbType := conf.Database.Type
|
||||
if dbType == "postgres" {
|
||||
conf.Database.DSN = conf.PostgresDsn()
|
||||
} else {
|
||||
conf.Database.DSN = conf.MySQLDsn()
|
||||
}
|
||||
|
||||
database, err := db.Init(dbType, conf.Database.DSN)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "connect to database")
|
||||
}
|
||||
return database, nil
|
||||
}
|
||||
|
||||
// findTargetUser 按 email / domain 定位用户。
|
||||
// 用 Unscoped 以支持已自助停用(软删除)的账号 —— 处置历史必须能追溯到停用账号。
|
||||
func findTargetUser(database *gorm.DB, email, domain string) (*db.User, error) {
|
||||
if email == "" && domain == "" {
|
||||
return nil, errors.New("must provide --email or --domain")
|
||||
}
|
||||
|
||||
var user db.User
|
||||
query := database.Unscoped()
|
||||
if email != "" {
|
||||
query = query.Where("email = ?", email)
|
||||
} else {
|
||||
query = query.Where("domain = ?", domain)
|
||||
}
|
||||
if err := query.First(&user).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "find user")
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func runRestrict(ctx *cli.Context) error {
|
||||
level := strings.TrimSpace(ctx.String("level"))
|
||||
if !db.IsValidRestrictionLevel(level) {
|
||||
return errors.Errorf("invalid --level %q (want L1 / L2 / L3 / L4)", level)
|
||||
}
|
||||
days := ctx.Int("days")
|
||||
if days <= 0 {
|
||||
return errors.New("--days must be positive")
|
||||
}
|
||||
// 不设硬上限(确实存在长期封禁场景),但超过 90 天必须显式加 --force:
|
||||
// 这类值几乎都是手误(多打一个 0),靠 --yes 挡不住重复执行的历史命令。
|
||||
if days > restrictDaysForceThreshold && !ctx.Bool("force") {
|
||||
return errors.Errorf("--days %d 超过 %d 天,属于异常时长;确认无误请加 --force 重试",
|
||||
days, restrictDaysForceThreshold)
|
||||
}
|
||||
reason := strings.TrimSpace(ctx.String("reason"))
|
||||
if reason == "" {
|
||||
return errors.New("--reason is required (recorded for audit)")
|
||||
}
|
||||
|
||||
database, err := openDatabase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user, err := findTargetUser(database, ctx.String("email"), ctx.String("domain"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
activeLevel, active, err := db.NewRestrictionsStore(database).ActiveLevel(ctx.Context, user.ID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "query active restriction")
|
||||
}
|
||||
|
||||
fmt.Printf("箱主: %s (@%s) id=%d\n", user.Name, user.Domain, user.ID)
|
||||
if activeLevel != "" {
|
||||
// active 理论上不会为 nil(有级别就有记录),但脏数据/并发下宁可少打一行也不要 panic。
|
||||
until, reason := "-", "-"
|
||||
if active != nil {
|
||||
until = active.ExpiresAt.Format("2006-01-02 15:04")
|
||||
reason = active.Reason
|
||||
}
|
||||
fmt.Printf("当前处置: %s(至 %s,原因:%s)→ 本次将替换该记录\n", activeLevel, until, reason)
|
||||
} else {
|
||||
fmt.Println("当前处置: 无")
|
||||
}
|
||||
fmt.Printf("本次处置: %s,%d 天,原因:%s\n", level, days, reason)
|
||||
if days > restrictDaysForceThreshold {
|
||||
fmt.Printf("⚠️ 超长处置:%d 天,到期 %s(常规处置为 7–30 天)\n",
|
||||
days, time.Now().AddDate(0, 0, days).Format("2006-01-02 15:04"))
|
||||
}
|
||||
|
||||
// 二次确认:异常时长时,即使已给 --force,交互模式下还要再输入一次级别。
|
||||
// 脚本里 `--force --yes` 两个显式开关本身就是两次确认,不再额外拦截。
|
||||
if days > restrictDaysForceThreshold && !ctx.Bool("yes") {
|
||||
logrus.Warn("这是超长处置。请再次输入级别名(如 L3)确认:")
|
||||
var confirmLevel string
|
||||
if _, err := fmtScan(&confirmLevel); err != nil {
|
||||
return errors.Wrap(err, "read confirmation")
|
||||
}
|
||||
if strings.TrimSpace(confirmLevel) != level {
|
||||
return errors.New("已取消(级别不匹配)")
|
||||
}
|
||||
}
|
||||
|
||||
if !ctx.Bool("yes") {
|
||||
logrus.Warn("输入大写 YES 确认执行(该操作可经 unrestrict 撤销):")
|
||||
var answer string
|
||||
if _, err := fmtScan(&answer); err != nil {
|
||||
return errors.Wrap(err, "read confirmation")
|
||||
}
|
||||
if answer != "YES" {
|
||||
return errors.New("已取消")
|
||||
}
|
||||
}
|
||||
|
||||
restriction, err := db.NewRestrictionsStore(database).Create(ctx.Context, db.CreateRestrictionOptions{
|
||||
UserID: user.ID,
|
||||
Level: level,
|
||||
Reason: reason,
|
||||
CreatedBy: "cli",
|
||||
Days: days,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "create restriction")
|
||||
}
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"user_id": user.ID,
|
||||
"level": level,
|
||||
"expires_at": restriction.ExpiresAt,
|
||||
}).Info("处置已生效")
|
||||
fmt.Printf("已生效,到期时间 %s(到期自动解除,无需手工操作)\n",
|
||||
restriction.ExpiresAt.Format("2006-01-02 15:04"))
|
||||
|
||||
if !ctx.Bool("no-notify") {
|
||||
notifyRestrictionOwner(user, level, restriction.ExpiresAt)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// restrictDaysForceThreshold 超过这个天数需要 --force。不做硬上限:长期封禁是真实需求。
|
||||
const restrictDaysForceThreshold = 90
|
||||
|
||||
// notifyRestrictionOwner 把处置结果通知箱主(按他自己的界面语言)。
|
||||
//
|
||||
// 为什么必须通知:处置是平台单方面限制用户功能,不告知就变成「暗箱」,
|
||||
// 用户既无法自查也无法申诉。邮件里只给「级别 + 到期时间 + 规则链接」,
|
||||
// 不引用 --reason(那是运维审计备注),也不引用被举报内容原文。
|
||||
//
|
||||
// 发送失败只告警、不回滚:处置已经写入,邮件不可用不应让运营者误以为没生效。
|
||||
func notifyRestrictionOwner(user *db.User, level string, expires time.Time) {
|
||||
if user.Email == "" {
|
||||
fmt.Println("(该账号没有邮箱,跳过处置通知)")
|
||||
return
|
||||
}
|
||||
|
||||
lang := user.Language
|
||||
if lang == "" {
|
||||
lang = conf.App.DefaultLang
|
||||
}
|
||||
levelDesc := i18n.T(lang, "restriction.level."+level)
|
||||
notice := mail.RestrictionNotice{
|
||||
To: user.Email,
|
||||
Heading: i18n.T(lang, "mail.restriction.heading"),
|
||||
Greeting: i18n.T(lang, "mail.restriction.greeting", user.Name),
|
||||
Body: i18n.T(lang, "mail.restriction.body", user.Domain, level, levelDesc, expires.Format("2006-01-02 15:04")),
|
||||
Note: i18n.T(lang, "mail.restriction.note"),
|
||||
RulesText: i18n.T(lang, "mail.restriction.rules"),
|
||||
RulesURL: strings.TrimRight(conf.App.ExternalURL, "/") + "/rules",
|
||||
Appeal: i18n.T(lang, "mail.restriction.appeal"),
|
||||
}
|
||||
reportNoticeResult(user, sendNotice(notice), "处置已生效")
|
||||
}
|
||||
|
||||
// notifyRestrictionLifted 告知箱主「限制已解除」。
|
||||
//
|
||||
// 与处置通知同等重要:只通知「你被罚了」而不通知「已经解除」,用户会一直以为还在限制里,
|
||||
// 既不敢发内容,也可能反复来问。这封信也顺带说明「私密内容不会自动恢复」——
|
||||
// 否则他会以为解禁后一切照旧。
|
||||
func notifyRestrictionLifted(user *db.User, level string) {
|
||||
if user.Email == "" {
|
||||
fmt.Println("(该账号没有邮箱,跳过解除通知)")
|
||||
return
|
||||
}
|
||||
|
||||
lang := user.Language
|
||||
if lang == "" {
|
||||
lang = conf.App.DefaultLang
|
||||
}
|
||||
levelDesc := i18n.T(lang, "restriction.level."+level)
|
||||
notice := mail.RestrictionNotice{
|
||||
To: user.Email,
|
||||
Heading: i18n.T(lang, "mail.unrestrict.heading"),
|
||||
Greeting: i18n.T(lang, "mail.restriction.greeting", user.Name),
|
||||
Body: i18n.T(lang, "mail.unrestrict.body", user.Domain, levelDesc),
|
||||
Note: i18n.T(lang, "mail.unrestrict.note"),
|
||||
RulesText: i18n.T(lang, "mail.restriction.rules"),
|
||||
RulesURL: strings.TrimRight(conf.App.ExternalURL, "/") + "/rules",
|
||||
// 解除通知不需要申诉入口(Appeal 留空,模板会整段省略)。
|
||||
}
|
||||
reportNoticeResult(user, sendNotice(notice), "解除已生效")
|
||||
}
|
||||
|
||||
// sendNotice 发送一封通知邮件,带 20 秒超时。
|
||||
//
|
||||
// 为什么要超时:gomail 这版没有 Timeout 字段,裸 Dial 会一直等到系统 TCP 超时(可达两分钟),
|
||||
// 运营者站在终端前会以为命令卡死。通知失败不影响已经写入的处置结果。
|
||||
func sendNotice(notice mail.RestrictionNotice) error {
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- mail.SendRestrictionNoticeMail(notice) }()
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-time.After(20 * time.Second):
|
||||
return errors.New("发送通知邮件超时")
|
||||
}
|
||||
}
|
||||
|
||||
// reportNoticeResult 统一输出通知结果(成功/失败都不影响已生效的处置)。
|
||||
func reportNoticeResult(user *db.User, err error, action string) {
|
||||
if err != nil {
|
||||
logrus.WithError(err).WithField("user_id", user.ID).Warn("通知邮件发送失败(" + action + ")")
|
||||
fmt.Printf("⚠️ 通知邮件发送失败(%s)。请检查 [mail] 配置,或用 --no-notify 跳过通知。\n", action)
|
||||
return
|
||||
}
|
||||
lang := user.Language
|
||||
if lang == "" {
|
||||
lang = conf.App.DefaultLang
|
||||
}
|
||||
fmt.Printf("已发送通知邮件至 %s(语言 %s)\n", user.Email, i18n.Resolve(lang))
|
||||
}
|
||||
|
||||
func runUnrestrict(ctx *cli.Context) error {
|
||||
database, err := openDatabase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user, err := findTargetUser(database, ctx.String("email"), ctx.String("domain"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
store := db.NewRestrictionsStore(database)
|
||||
// 先取级别:撤销之后就读不到了,而通知邮件需要写明解除的是哪一级。
|
||||
level, _, err := store.ActiveLevel(ctx.Context, user.ID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "query active restriction")
|
||||
}
|
||||
|
||||
affected, err := store.RevokeActive(ctx.Context, user.ID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "revoke restrictions")
|
||||
}
|
||||
|
||||
fmt.Printf("箱主: %s (@%s) id=%d\n", user.Name, user.Domain, user.ID)
|
||||
if affected == 0 {
|
||||
fmt.Println("没有生效中的处置,无需操作")
|
||||
return nil
|
||||
}
|
||||
logrus.WithFields(logrus.Fields{"user_id": user.ID, "count": affected, "level": level}).Info("已撤销处置")
|
||||
fmt.Printf("已撤销 %d 条生效中的处置\n", affected)
|
||||
|
||||
if !ctx.Bool("no-notify") && level != "" {
|
||||
notifyRestrictionLifted(user, string(level))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reportedQuestionRow 证据包里的一行:被举报的提问及其举报数。
|
||||
type reportedQuestionRow struct {
|
||||
ID uint
|
||||
Content string
|
||||
CreatedAt time.Time
|
||||
ReportCount int64
|
||||
PendingCount int64 // 其中仍为 pending 的条数(已处理的举报仍保留在证据里)
|
||||
}
|
||||
|
||||
// pendingReportRow 全站待处理举报的一行(按箱主聚合展示)。
|
||||
type pendingReportRow struct {
|
||||
Domain string
|
||||
QuestionID uint
|
||||
Content string
|
||||
ReportCount int64
|
||||
LastAt time.Time
|
||||
}
|
||||
|
||||
func runReports(ctx *cli.Context) error {
|
||||
database, err := openDatabase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ctx.Bool("stats") {
|
||||
return printReportStats(ctx.Context, database, ctx.Int("days"))
|
||||
}
|
||||
if ctx.Bool("pending") {
|
||||
return printPendingReports(ctx.Context, database, ctx.Int("limit"))
|
||||
}
|
||||
return printUserReport(database, ctx)
|
||||
}
|
||||
|
||||
func printUserReport(database *gorm.DB, ctx *cli.Context) error {
|
||||
user, err := findTargetUser(database, ctx.String("email"), ctx.String("domain"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
days := ctx.Int("days")
|
||||
if days <= 0 {
|
||||
days = 30
|
||||
}
|
||||
since := time.Now().AddDate(0, 0, -days)
|
||||
|
||||
fmt.Printf("箱主: %s (@%s) id=%d\n", user.Name, user.Domain, user.ID)
|
||||
fmt.Println(strings.Repeat("-", 60))
|
||||
|
||||
store := db.NewRestrictionsStore(database)
|
||||
level, active, err := store.ActiveLevel(ctx.Context, user.ID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "query active restriction")
|
||||
}
|
||||
if level == "" {
|
||||
fmt.Println("当前处置: 无")
|
||||
} else {
|
||||
until, reason := "-", "-"
|
||||
if active != nil {
|
||||
until = active.ExpiresAt.Format("2006-01-02 15:04")
|
||||
reason = active.Reason
|
||||
}
|
||||
fmt.Printf("当前处置: %s(至 %s,原因:%s)\n", level, until, reason)
|
||||
}
|
||||
|
||||
// 公开回答总数:用于计算「被举报内容占其公开产出」的比例,
|
||||
// 这是判断「组织化挂人」与「偶发吐槽」最直接的一个量。
|
||||
var publicAnswered int64
|
||||
if err := database.WithContext(ctx.Context).
|
||||
Model(&db.Question{}).
|
||||
Where("user_id = ? AND answer != '' AND is_private = false", user.ID).
|
||||
Count(&publicAnswered).Error; err != nil {
|
||||
return errors.Wrap(err, "count public answered questions")
|
||||
}
|
||||
|
||||
var rows []*reportedQuestionRow
|
||||
if err := database.WithContext(ctx.Context).
|
||||
Raw(`SELECT q.id AS id, q.content AS content, q.created_at AS created_at,
|
||||
COUNT(r.id) AS report_count,
|
||||
SUM(CASE WHEN r.status = ? THEN 1 ELSE 0 END) AS pending_count
|
||||
FROM questions q
|
||||
JOIN content_reports r ON r.question_id = q.id
|
||||
WHERE q.user_id = ? AND q.deleted_at IS NULL AND r.created_at >= ?
|
||||
GROUP BY q.id, q.content, q.created_at
|
||||
ORDER BY report_count DESC, q.created_at DESC
|
||||
LIMIT ?`, db.ReportStatusPending, user.ID, since, ctx.Int("limit")).Scan(&rows).Error; err != nil {
|
||||
return errors.Wrap(err, "query reported questions")
|
||||
}
|
||||
|
||||
var totalReports, totalPending int64
|
||||
for _, row := range rows {
|
||||
totalReports += row.ReportCount
|
||||
totalPending += row.PendingCount
|
||||
}
|
||||
// 比例的分母是「公开回答总数」,分子必须是「被举报的条目数」——
|
||||
// ⚠️ 不能拿举报次数当分子:同一条内容可被多人举报,次数可以超过条目数,
|
||||
// 实测会出现 166.7% 这种无意义的数值(5 次举报 / 3 条回答)。
|
||||
ratio := 0.0
|
||||
if publicAnswered > 0 {
|
||||
ratio = float64(len(rows)) / float64(publicAnswered) * 100
|
||||
}
|
||||
|
||||
// 分母是「当前累计公开回答」,不是窗口内的产出 —— 文案必须写清「累计」,
|
||||
// 否则读者会以为分子分母同窗口(实测箱主被 L2 收窄后分母骤降,比例会超过 100%)。
|
||||
fmt.Printf("近 %d 天: 被举报 %d 条 / 公开回答 %d 条(累计,%.1f%%);举报 %d 次,其中待处理 %d 次\n",
|
||||
days, len(rows), publicAnswered, ratio, totalReports, totalPending)
|
||||
if len(rows) == 0 {
|
||||
fmt.Println("没有举报记录")
|
||||
} else {
|
||||
fmt.Println("被举报条目(按举报数降序):")
|
||||
for _, row := range rows {
|
||||
// 明确区分「总举报」与「仍待处理」:箱主处理过内容后举报会自动变 accepted,
|
||||
// 只报总数会让人误以为还有 3 条挂着。
|
||||
state := "已处理"
|
||||
if row.PendingCount > 0 {
|
||||
state = fmt.Sprintf("待处理 %d", row.PendingCount)
|
||||
}
|
||||
fmt.Printf(" 举报 %-3d #%-6d %s %s(%s)\n",
|
||||
row.ReportCount, row.ID, row.CreatedAt.Format("2006-01-02 15:04"),
|
||||
truncateForCLI(row.Content, 48), state)
|
||||
}
|
||||
}
|
||||
|
||||
history, err := store.ListByUserID(ctx.Context, user.ID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "list restriction history")
|
||||
}
|
||||
fmt.Println(strings.Repeat("-", 60))
|
||||
if len(history) == 0 {
|
||||
fmt.Println("历史处置: 无")
|
||||
return nil
|
||||
}
|
||||
fmt.Println("历史处置:")
|
||||
for _, item := range history {
|
||||
state := "生效中"
|
||||
if item.RevokedAt != nil {
|
||||
state = "已撤销 " + item.RevokedAt.Format("2006-01-02")
|
||||
} else if item.ExpiresAt.Before(time.Now()) {
|
||||
state = "已到期"
|
||||
}
|
||||
fmt.Printf(" %s %s 至 %s %s(%s)\n",
|
||||
item.CreatedAt.Format("2006-01-02 15:04"), item.Level,
|
||||
item.ExpiresAt.Format("2006-01-02"), item.Reason, state)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printPendingReports(ctx context.Context, database *gorm.DB, limit int) error {
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
|
||||
var rows []*pendingReportRow
|
||||
if err := database.WithContext(ctx).
|
||||
Raw(`SELECT u.domain AS domain, q.id AS question_id, q.content AS content,
|
||||
COUNT(r.id) AS report_count, MAX(r.created_at) AS last_at
|
||||
FROM content_reports r
|
||||
JOIN questions q ON q.id = r.question_id
|
||||
JOIN users u ON u.id = q.user_id
|
||||
WHERE r.status = ? AND q.deleted_at IS NULL
|
||||
GROUP BY u.domain, q.id, q.content
|
||||
ORDER BY u.domain, report_count DESC, last_at DESC
|
||||
LIMIT ?`, db.ReportStatusPending, limit).Scan(&rows).Error; err != nil {
|
||||
return errors.Wrap(err, "query pending reports")
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
fmt.Println("没有待处理的举报")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("待处理举报 %d 条(按举报数降序):\n", len(rows))
|
||||
lastDomain := ""
|
||||
for _, row := range rows {
|
||||
if row.Domain != lastDomain {
|
||||
fmt.Printf("\n@%s\n", row.Domain)
|
||||
lastDomain = row.Domain
|
||||
}
|
||||
fmt.Printf(" 举报 %-3d #%-6d %s %s\n",
|
||||
row.ReportCount, row.QuestionID, row.LastAt.Format("2006-01-02 15:04"),
|
||||
truncateForCLI(row.Content, 48))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// printReportStats 输出全站维度的举报统计。
|
||||
//
|
||||
// 这是「举报是否被武器化」的唯一观测手段:正常举报是分散的,组织化举报会表现为
|
||||
// 同一批 IP 集中打同一批箱主。没有这个视图,运营者只能一个 domain 一个 domain 地猜。
|
||||
func printReportStats(ctx context.Context, database *gorm.DB, days int) error {
|
||||
if days <= 0 {
|
||||
days = 7
|
||||
}
|
||||
since := time.Now().AddDate(0, 0, -days)
|
||||
store := db.NewReportsStore(database)
|
||||
|
||||
users, err := store.TopReportedUsers(ctx, since, 10)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "query top reported users")
|
||||
}
|
||||
reporters, err := store.TopReporters(ctx, since, 10)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "query top reporters")
|
||||
}
|
||||
|
||||
fmt.Printf("统计窗口:近 %d 天(仅待处理举报;待处理最长回溯 %d 天)\n", days, 30)
|
||||
fmt.Println(strings.Repeat("-", 60))
|
||||
|
||||
fmt.Println("被举报最多的箱主:")
|
||||
if len(users) == 0 {
|
||||
fmt.Println(" 无")
|
||||
}
|
||||
for i, u := range users {
|
||||
fmt.Printf(" %2d. @%-20s %-14s %d 条\n", i+1, u.Domain, u.Name, u.Count)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("举报来源 IP 最多:")
|
||||
if len(reporters) == 0 {
|
||||
fmt.Println(" 无")
|
||||
}
|
||||
for i, r := range reporters {
|
||||
fmt.Printf(" %2d. %-18s %d 次\n", i+1, r.ReporterIP, r.Count)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("提示:同一 IP 集中举报同一批内容,或某箱主举报数远高于其他箱主时,")
|
||||
fmt.Println(" 先判断是组织化挂人还是举报被武器化,再决定是否处置。")
|
||||
return nil
|
||||
}
|
||||
|
||||
// truncateForCLI 按 rune 截断并压平换行,避免终端输出被长内容冲散。
|
||||
func truncateForCLI(s string, max int) string {
|
||||
s = strings.ReplaceAll(strings.ReplaceAll(s, "\r", " "), "\n", " ")
|
||||
if utf8.RuneCountInString(s) <= max {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
return string(runes[:max]) + "..."
|
||||
}
|
||||
+150
-5
@@ -5,8 +5,13 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
@@ -17,9 +22,13 @@ import (
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/insights"
|
||||
"github.com/tamakyi/TamaBox/internal/route"
|
||||
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
"github.com/tamakyi/TamaBox/internal/stats"
|
||||
"github.com/tamakyi/TamaBox/internal/tracing"
|
||||
"github.com/tamakyi/TamaBox/route/question"
|
||||
)
|
||||
|
||||
var Web = &cli.Command{
|
||||
@@ -67,25 +76,118 @@ func runWeb(ctx *cli.Context) error {
|
||||
}
|
||||
conf.Database.DSN = dsn
|
||||
|
||||
_, err := db.Init(dbType, dsn)
|
||||
gdb, err := db.Init(dbType, dsn)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "connect to database")
|
||||
}
|
||||
// 退出前把连接池关掉,避免优雅关闭期间连接还在池里挂着。
|
||||
if sqlDB, dbErr := gdb.DB(); dbErr == nil {
|
||||
defer func() {
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
logrus.WithError(err).Warn("关闭数据库连接池失败")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 蜜罐命中记录保留期清理:honeypot_hits 是只追加的审计表,写入完全由
|
||||
// 外部扫描流量驱动(不可预期、不可节流)。没有自动清理的话,表体积只增不减。
|
||||
// 保留期复用 honeypot_retain_days(与 CLI prune 同一个口径),开关为 honeypot_auto_clean。
|
||||
//
|
||||
// 凭据文件(明文账号密码)也按同一个保留期清理 —— 它们不该比元数据活得更久。
|
||||
// 这里独立打开一次目录即可:PurgeBefore 只删「整天都早于保留期」的文件,
|
||||
// 与 route 层那个 Append 用的句柄不会争抢同一天的文件。
|
||||
// 两个后台任务(保留期清理 / 周期报告)共用一个停止信号,
|
||||
// defer 必须放在 if 外面 —— 否则「自动清理关掉、报告开着」时这个
|
||||
// channel 永远不会被关闭,那个 goroutine 就一直挂着。
|
||||
stopRetention := make(chan struct{})
|
||||
defer close(stopRetention)
|
||||
if conf.Security.HoneypotAutoClean {
|
||||
var credPurge db.CredPurgeable
|
||||
if cs, err := honeypot.NewFileCredStore(conf.Security.HoneypotCredDir); err != nil {
|
||||
logrus.WithError(err).Warn("蜜罐凭据目录不可用,凭据文件将不会被自动清理")
|
||||
} else {
|
||||
credPurge = cs
|
||||
}
|
||||
db.NewHoneypotRetention(db.Honeypots, credPurge, conf.Security.HoneypotRetainDays, conf.Security.HoneypotMaxRows, 0).Start(stopRetention)
|
||||
}
|
||||
|
||||
// 周期安全汇总(周报 / 月报):把命中按 IP 定位到城市,出地图报告并邮件送达。
|
||||
// 幂等靠 security_report_logs 的 period_key 唯一索引,所以这里只管定期看一眼
|
||||
// 「该发的那期发过没」—— 重启、错过时间窗都能正确补发,也不会重复发。
|
||||
if conf.Security.HoneypotReportEnabled {
|
||||
if reportRecipient() == "" {
|
||||
logrus.Warn("已启用安全汇总报告,但 honeypot_report_email 与 security_alert_email 都为空,将无法送达")
|
||||
}
|
||||
NewSecurityReportScheduler(gdb).Start(stopRetention)
|
||||
}
|
||||
|
||||
// Redis 配了就必须连得上:session 建在它上面,宕机等于全站 500(含匿名访客)。
|
||||
// 启动期 fail fast,别让运维在「站点全 500」的现场去猜是不是 Redis。
|
||||
if conf.Redis.Addr != "" {
|
||||
if err := shared.PingRedis(conf.Redis.Addr, conf.Redis.Password); err != nil {
|
||||
return errors.Wrapf(err,
|
||||
"无法连接 Redis(%s)。session 依赖它,连不上会导致全站 500,启动已中止。"+
|
||||
"请检查 [redis] addr/password,或清空 addr 改用进程内 session 与缓存",
|
||||
conf.Redis.Addr)
|
||||
}
|
||||
logrus.WithField("addr", conf.Redis.Addr).Info("Redis 连接正常")
|
||||
} else {
|
||||
logrus.Info("未配置 [redis] addr:session 与缓存均使用进程内实现(单副本部署可用)")
|
||||
}
|
||||
|
||||
logrus.WithContext(ctx.Context).WithField("external_url", conf.App.ExternalURL).Info("Starting web server")
|
||||
|
||||
// 箱主数据面板的访问记录器:异步批量写 box_views,不阻塞页面渲染。
|
||||
// 密钥复用 server.xsrf_key —— 它已经是启动期强校验过的非空随机串,
|
||||
// 而且与 CSRF token 的用途不同(这里只做 HMAC 的一次性密钥),不存在混用风险。
|
||||
insightsStore := db.BoxViews
|
||||
if insightsStore != nil {
|
||||
insights.Views = insights.NewRecorder(insightsStore, conf.Server.XSRFKey)
|
||||
defer insights.Views.Close()
|
||||
insights.StartMaintenance(ctx.Context, insightsStore)
|
||||
}
|
||||
|
||||
// 站点默认语言初始化(default_lang 配置,未配置时回退 zh-CN)
|
||||
shared.App.SetLang(conf.App.DefaultLang)
|
||||
|
||||
// 分享卡字体:缺字体只会在「第一张分享图被请求」时暴露成 500,而 /share-card 是公开端点。
|
||||
// 启动时先探一次(顺带把读文件 + 解析的开销挪出请求路径),缺了直接 WARN 并给出补救办法。
|
||||
if fontPath, err := question.CheckShareCardFont(); err != nil {
|
||||
logrus.WithError(err).Warn("分享卡字体不可用,/share-card 将返回 500:" +
|
||||
"装一个 glyf 轮廓的 CJK 字体(apt install fonts-wqy-zenhei)" +
|
||||
"或把 ttf/otf/ttc 放进 static/fonts/,也可用 [app] font_path 指定")
|
||||
} else {
|
||||
logrus.WithField("font", fontPath).Info("分享卡字体已就绪")
|
||||
}
|
||||
|
||||
r := route.New()
|
||||
r.Use(tracing.Middleware("TamaBox"))
|
||||
|
||||
// 全站数据页预热:整页聚合是若干次全表 GROUP BY,若摊在当天第一个访客身上,
|
||||
// 首屏要白等一秒以上。这里延后几秒在后台先算一次,把等待挪出请求路径。
|
||||
// 失败只告警:页面在真正被访问时会自己重算并(若仍失败)回退到上一次成功的结果。
|
||||
go func() {
|
||||
time.Sleep(3 * time.Second)
|
||||
if _, err := stats.OverviewData(ctx.Context); err != nil {
|
||||
logrus.WithError(err).Warn("预热全站统计数据失败")
|
||||
}
|
||||
}()
|
||||
|
||||
// 最外层挂载 gzip 压缩,并在 http.Server 上配置超时:
|
||||
// - ReadHeaderTimeout 防止慢速客户端占用连接
|
||||
// - ReadHeaderTimeout 防止慢速客户端占用连接(slowloris 的防线在这里)
|
||||
// - ReadTimeout / WriteTimeout 防止读写挂死
|
||||
// - IdleTimeout 复用 keep-alive 连接
|
||||
// listen 留空时回退到 ":port"(监听所有网卡);Cloudflare Tunnel 部署建议设为
|
||||
// "127.0.0.1:<port>",使源站端口仅对本地 cloudflared 可见,即使防火墙误配也无法被外部直连。
|
||||
//
|
||||
// ReadTimeout 从 30s 提到 120s:站内允许单图 8MB(storage.MaxUploadBytes),
|
||||
// 请求体上限 12MB(form.MaxRequestBodySize)。30s 传完 8MB 需要 ≥273 KB/s 上行,
|
||||
// 而手机 4G 上行常在 1–2 Mbps(125–250 KB/s)—— 一张 8MB 图必然超时,且用户
|
||||
// 已经写好的提问正文会一起丢掉(POST 失败不回填)。120s 对应约 68 KB/s,
|
||||
// 偏远地区 3G 也能过。
|
||||
//
|
||||
// 放宽 ReadTimeout 不会让 slowloris 重新变得可行:那类攻击卡在「发不完 header」,
|
||||
// 由 10s 的 ReadHeaderTimeout 拦住;ReadTimeout 只影响读完 header 之后传 body 的时长。
|
||||
addr := conf.Server.Listen
|
||||
if addr == "" {
|
||||
addr = fmt.Sprintf(":%d", conf.Server.Port)
|
||||
@@ -94,14 +196,57 @@ func runWeb(ctx *cli.Context) error {
|
||||
Addr: addr,
|
||||
Handler: route.GzipHandler(r),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
ReadTimeout: 120 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
logrus.WithContext(ctx.Context).WithField("port", conf.Server.Port).Info("Listening on")
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
return errors.Wrap(err, "start http server")
|
||||
|
||||
// 优雅关闭:收到 SIGTERM / SIGINT 后停止接受新连接,给在途请求最多 15 秒收尾。
|
||||
//
|
||||
// 改动前是直接 ListenAndServe:Go 对 SIGTERM 的处理就是立即退出进程,
|
||||
// 于是每次部署 / 重启都会把正在写库的 POST 就地处死,留下
|
||||
// 「提问已 INSERT、图片还没传完」「回答已写入、配额还没扣」这类半成品。
|
||||
// systemd Restart=always 与 docker restart 都救不了这个。
|
||||
runCtx, stop := signal.NotifyContext(ctx.Context, syscall.SIGTERM, os.Interrupt)
|
||||
var r2WorkerWG sync.WaitGroup
|
||||
defer func() {
|
||||
// 先取消 worker 的 context,再等待当前一轮退出,避免无人等待的后台删除。
|
||||
stop()
|
||||
r2WorkerWG.Wait()
|
||||
}()
|
||||
|
||||
// db.Init 已成功;R2 未完整配置时此函数直接返回,不影响 Web 启动。
|
||||
startR2OutboxWorker(runCtx, &r2WorkerWG)
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() {
|
||||
err := server.ListenAndServe()
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
// 正常的 Shutdown 路径,不算失败
|
||||
err = nil
|
||||
}
|
||||
serveErr <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
// 端口被占 / 监听失败:没有在途请求要收尾,直接返回。
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "start http server")
|
||||
}
|
||||
return nil
|
||||
case <-runCtx.Done():
|
||||
logrus.Info("收到退出信号,正在优雅关闭(最多 15 秒)")
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
logrus.WithError(err).Warn("优雅关闭超时,强制断开剩余连接")
|
||||
_ = server.Close()
|
||||
}
|
||||
logrus.Info("HTTP 服务已关闭")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
// Copyright 2026 tamakyi. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package conf
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
// 容器镜像通常不带 tzdata,import 这个包会把一份时区库编进二进制,
|
||||
// LoadLocation("Asia/Shanghai") 才不会因为找不到 zoneinfo 而失败。
|
||||
_ "time/tzdata"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
)
|
||||
|
||||
// defaultAnnouncementZone 裸时间(不带偏移)的默认解释时区。
|
||||
// 站点面向中文用户,运维写「10-25 02:00」时心里想的是北京时间,
|
||||
// 而容器里的 time.Local 是 UTC —— 默认按东八区解释可以避免整体偏移 8 小时。
|
||||
const defaultAnnouncementZone = "Asia/Shanghai"
|
||||
|
||||
// announcementConfig 站内公告([announcement] 段)。
|
||||
//
|
||||
// 文案留空即关闭公告;Start / End 留空表示该侧不限。
|
||||
// 展示窗口是左闭右开 [Start, End):到 end 那一刻公告消失,
|
||||
// 运维不必为了「准时下线」把时间掐到秒后还担心多挂一分钟。
|
||||
type announcementConfig struct {
|
||||
// Content 是默认文案,也是分语言文案缺失时的回退。
|
||||
Content string `ini:"content"`
|
||||
ContentZhCN string `ini:"content_zh_cn"`
|
||||
ContentZhTW string `ini:"content_zh_tw"`
|
||||
ContentEn string `ini:"content_en"`
|
||||
ContentJa string `ini:"content_ja"`
|
||||
|
||||
Start string `ini:"start"`
|
||||
End string `ini:"end"`
|
||||
// Timezone 解释裸时间用的时区名,留空取 defaultAnnouncementZone。
|
||||
Timezone string `ini:"timezone"`
|
||||
|
||||
// 解析后的时间点,零值表示该侧不限。
|
||||
// ini tag 为 "-":既不参与 ini 解析,也不会被未知键检测登记为合法键名。
|
||||
StartAt time.Time `ini:"-"`
|
||||
EndAt time.Time `ini:"-"`
|
||||
loc *time.Location `ini:"-"`
|
||||
}
|
||||
|
||||
// Active 判断给定时刻是否处于公告展示窗口,并返回该语言的正文。
|
||||
// 窗口为左闭右开 [Start, End),两侧留空表示不限。
|
||||
func (a announcementConfig) Active(now time.Time, lang string) (string, bool) {
|
||||
content := a.contentFor(lang)
|
||||
if content == "" || !a.inWindow(now) {
|
||||
return "", false
|
||||
}
|
||||
return content, true
|
||||
}
|
||||
|
||||
// contentFor 按语言取文案;该语言没配就回退到 Content。
|
||||
// Content 也没配则返回空 —— 即「没翻译的语言不显示」,而不是硬塞一门语言。
|
||||
func (a announcementConfig) contentFor(lang string) string {
|
||||
switch i18n.Resolve(lang) {
|
||||
case i18n.LangZhCN:
|
||||
if a.ContentZhCN != "" {
|
||||
return a.ContentZhCN
|
||||
}
|
||||
case i18n.LangZhTW:
|
||||
if a.ContentZhTW != "" {
|
||||
return a.ContentZhTW
|
||||
}
|
||||
case i18n.LangEn:
|
||||
if a.ContentEn != "" {
|
||||
return a.ContentEn
|
||||
}
|
||||
case i18n.LangJa:
|
||||
if a.ContentJa != "" {
|
||||
return a.ContentJa
|
||||
}
|
||||
}
|
||||
return a.Content
|
||||
}
|
||||
|
||||
// inWindow 只判断时间窗,不看语言 —— 启动日志要知道「现在到点了吗」。
|
||||
func (a announcementConfig) inWindow(now time.Time) bool {
|
||||
if !a.StartAt.IsZero() && now.Before(a.StartAt) {
|
||||
return false
|
||||
}
|
||||
if !a.EndAt.IsZero() && !now.Before(a.EndAt) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// hasContent 报告是否配了任何语言的文案。
|
||||
// 只看 Content 会误判「只写了英文文案」为未配置,进而把整条公告关掉。
|
||||
func (a announcementConfig) hasContent() bool {
|
||||
return a.Content != "" || a.ContentZhCN != "" || a.ContentZhTW != "" ||
|
||||
a.ContentEn != "" || a.ContentJa != ""
|
||||
}
|
||||
|
||||
// configuredLanguages 列出真正配了文案的语言,供启动日志核对。
|
||||
func (a announcementConfig) configuredLanguages() []string {
|
||||
var langs []string
|
||||
if a.Content != "" {
|
||||
langs = append(langs, "fallback")
|
||||
}
|
||||
for _, l := range []struct{ code, content string }{
|
||||
{i18n.LangZhCN, a.ContentZhCN},
|
||||
{i18n.LangZhTW, a.ContentZhTW},
|
||||
{i18n.LangEn, a.ContentEn},
|
||||
{i18n.LangJa, a.ContentJa},
|
||||
} {
|
||||
if l.content != "" {
|
||||
langs = append(langs, l.code)
|
||||
}
|
||||
}
|
||||
return langs
|
||||
}
|
||||
|
||||
// 支持的时间格式。裸时间(不带时区偏移)按 [announcement] timezone 解释。
|
||||
var announcementTimeLayouts = []string{
|
||||
"2006-01-02 15:04",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02",
|
||||
time.RFC3339,
|
||||
}
|
||||
|
||||
// sanitizeAnnouncement 解析公告时间窗。
|
||||
//
|
||||
// 时间写错只告警并关闭公告,不阻断启动:公告是展示性配置,拼错一个日期不该让整站
|
||||
// 502;但必须留下能定位的日志 —— 否则现象就是「配了却不显示」,和未知键被静默
|
||||
// 忽略属于同一类坑(见 unknown.go)。
|
||||
func sanitizeAnnouncement() {
|
||||
Announcement.Content = strings.TrimSpace(Announcement.Content)
|
||||
Announcement.ContentZhCN = strings.TrimSpace(Announcement.ContentZhCN)
|
||||
Announcement.ContentZhTW = strings.TrimSpace(Announcement.ContentZhTW)
|
||||
Announcement.ContentEn = strings.TrimSpace(Announcement.ContentEn)
|
||||
Announcement.ContentJa = strings.TrimSpace(Announcement.ContentJa)
|
||||
if !Announcement.hasContent() {
|
||||
return
|
||||
}
|
||||
|
||||
disable := func(key, value, reason string) {
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"section": "announcement",
|
||||
"key": key,
|
||||
"value": value,
|
||||
"reason": reason,
|
||||
}).Warn("公告配置无效,已关闭公告(站点其它功能不受影响)")
|
||||
Announcement = announcementConfig{}
|
||||
}
|
||||
|
||||
loc, err := loadAnnouncementLocation(Announcement.Timezone)
|
||||
if err != nil {
|
||||
disable("timezone", Announcement.Timezone, "不是合法的 IANA 时区名(如 Asia/Shanghai / UTC)")
|
||||
return
|
||||
}
|
||||
Announcement.loc = loc
|
||||
|
||||
var ok bool
|
||||
if Announcement.StartAt, ok = parseAnnouncementTime(Announcement.Start, loc); !ok {
|
||||
disable("start", Announcement.Start, "支持 2006-01-02 15:04 / 2006-01-02 15:04:05 / 2006-01-02 / RFC3339")
|
||||
return
|
||||
}
|
||||
if Announcement.EndAt, ok = parseAnnouncementTime(Announcement.End, loc); !ok {
|
||||
disable("end", Announcement.End, "支持 2006-01-02 15:04 / 2006-01-02 15:04:05 / 2006-01-02 / RFC3339")
|
||||
return
|
||||
}
|
||||
if !Announcement.StartAt.IsZero() && !Announcement.EndAt.IsZero() && !Announcement.EndAt.After(Announcement.StartAt) {
|
||||
disable("end", Announcement.End, "end 必须晚于 start,否则公告永远不会展示")
|
||||
return
|
||||
}
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"zone": loc.String(),
|
||||
"languages": strings.Join(Announcement.configuredLanguages(), ","),
|
||||
"start": announcementBoundLabel(Announcement.StartAt),
|
||||
"end": announcementBoundLabel(Announcement.EndAt),
|
||||
"active_now": Announcement.inWindow(time.Now()),
|
||||
}).Info("站内公告已配置")
|
||||
}
|
||||
|
||||
// loadAnnouncementLocation 解析时区名;空值取默认时区(东八区)。
|
||||
func loadAnnouncementLocation(name string) (*time.Location, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
name = defaultAnnouncementZone
|
||||
}
|
||||
return time.LoadLocation(name)
|
||||
}
|
||||
|
||||
// parseAnnouncementTime 解析单个时间点;空串返回零值(表示不限)且视为合法。
|
||||
// 带偏移的 RFC3339 以自带偏移为准,loc 只用于裸时间。
|
||||
func parseAnnouncementTime(raw string, loc *time.Location) (time.Time, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}, true
|
||||
}
|
||||
for _, layout := range announcementTimeLayouts {
|
||||
if t, err := time.ParseInLocation(layout, raw, loc); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// announcementBoundLabel 把时间点格式化成带时区偏移的字符串,供启动日志核对
|
||||
// 「我写的 10:00 到底被当成了几点」—— 裸时间按 [announcement] timezone 解释。
|
||||
func announcementBoundLabel(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "不限"
|
||||
}
|
||||
return t.Format("2006-01-02 15:04:05 -07:00")
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// Copyright 2026 tamakyi. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package conf
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
)
|
||||
|
||||
// setAnnouncement 在测试期间覆盖全局公告配置,结束后恢复。
|
||||
// 不设 Timezone,即走默认的东八区。
|
||||
func setAnnouncement(t *testing.T, content, start, end string) {
|
||||
t.Helper()
|
||||
old := Announcement
|
||||
t.Cleanup(func() { Announcement = old })
|
||||
Announcement = announcementConfig{Content: content, Start: start, End: end}
|
||||
sanitizeAnnouncement()
|
||||
}
|
||||
|
||||
// setAnnouncementConfig 用完整配置覆盖,供分语言 / 时区用例使用。
|
||||
func setAnnouncementConfig(t *testing.T, cfg announcementConfig) {
|
||||
t.Helper()
|
||||
old := Announcement
|
||||
t.Cleanup(func() { Announcement = old })
|
||||
Announcement = cfg
|
||||
sanitizeAnnouncement()
|
||||
}
|
||||
|
||||
func mustParseIn(t *testing.T, layout, value string) time.Time {
|
||||
t.Helper()
|
||||
loc, err := time.LoadLocation(defaultAnnouncementZone)
|
||||
if err != nil {
|
||||
t.Fatalf("load zone %s: %v", defaultAnnouncementZone, err)
|
||||
}
|
||||
parsed, err := time.ParseInLocation(layout, value, loc)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", value, err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func TestAnnouncementDisabledWhenContentEmpty(t *testing.T) {
|
||||
setAnnouncement(t, " ", "2026-10-24 00:00", "2026-10-26 00:00")
|
||||
|
||||
if _, ok := Announcement.Active(time.Now(), i18n.LangZhCN); ok {
|
||||
t.Fatal("所有语言文案都为空时公告应关闭")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnouncementWindowIsHalfOpen(t *testing.T) {
|
||||
setAnnouncement(t, "维护通知", "2026-10-24 00:00", "2026-10-26 00:00")
|
||||
|
||||
start := mustParseIn(t, "2006-01-02 15:04", "2026-10-24 00:00")
|
||||
end := mustParseIn(t, "2006-01-02 15:04", "2026-10-26 00:00")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
now time.Time
|
||||
want bool
|
||||
}{
|
||||
{"开始前一秒", start.Add(-time.Second), false},
|
||||
{"开始时刻(闭区间)", start, true},
|
||||
{"窗口内", start.Add(12 * time.Hour), true},
|
||||
{"结束时刻(开区间)", end, false},
|
||||
{"结束后", end.Add(time.Hour), false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
content, ok := Announcement.Active(c.now, i18n.LangZhCN)
|
||||
if ok != c.want {
|
||||
t.Fatalf("Active(%s) = %v, want %v", c.now, ok, c.want)
|
||||
}
|
||||
if ok && content != "维护通知" {
|
||||
t.Fatalf("content = %q, want 维护通知", content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 只写一侧的边界必须保持「该侧不限」,不能因为另一侧为空就整体失效。
|
||||
func TestAnnouncementOneSidedWindow(t *testing.T) {
|
||||
now := mustParseIn(t, "2006-01-02 15:04", "2026-10-22 12:00")
|
||||
|
||||
setAnnouncement(t, "只有开始", "2026-10-23 00:00", "")
|
||||
if _, ok := Announcement.Active(now, i18n.LangZhCN); ok {
|
||||
t.Fatal("只写 start 且尚未到点时应不展示")
|
||||
}
|
||||
if _, ok := Announcement.Active(now.Add(48*time.Hour), i18n.LangZhCN); !ok {
|
||||
t.Fatal("只写 start 时到点后应一直展示")
|
||||
}
|
||||
|
||||
setAnnouncement(t, "只有结束", "", "2026-10-23 00:00")
|
||||
if _, ok := Announcement.Active(now, i18n.LangZhCN); !ok {
|
||||
t.Fatal("只写 end 且未到点时应立即展示")
|
||||
}
|
||||
if _, ok := Announcement.Active(now.Add(48*time.Hour), i18n.LangZhCN); ok {
|
||||
t.Fatal("只写 end 时过点后应下线")
|
||||
}
|
||||
|
||||
setAnnouncement(t, "一直展示", "", "")
|
||||
if _, ok := Announcement.Active(now.AddDate(5, 0, 0), i18n.LangZhCN); !ok {
|
||||
t.Fatal("起止都留空时应一直展示")
|
||||
}
|
||||
}
|
||||
|
||||
// 分语言文案:配了就用自己的,没配回退 content;content 也没配则该语言不显示。
|
||||
func TestAnnouncementLanguageSelection(t *testing.T) {
|
||||
setAnnouncementConfig(t, announcementConfig{
|
||||
Content: "fallback",
|
||||
ContentZhCN: "简中",
|
||||
ContentEn: "english",
|
||||
ContentJa: "日本語",
|
||||
})
|
||||
|
||||
cases := []struct{ lang, want string }{
|
||||
{i18n.LangZhCN, "简中"},
|
||||
{i18n.LangEn, "english"},
|
||||
{i18n.LangJa, "日本語"},
|
||||
{i18n.LangZhTW, "fallback"}, // 没配繁中 → 回退默认文案
|
||||
{"en-US", "english"}, // Resolve 会归一化
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := Announcement.Active(time.Now(), c.lang)
|
||||
if !ok || got != c.want {
|
||||
t.Fatalf("Active(lang=%s) = %q ok=%v, want %q", c.lang, got, ok, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// 只配英文、content 留空:简中访客不应看到英文公告。
|
||||
setAnnouncementConfig(t, announcementConfig{ContentEn: "english only"})
|
||||
if _, ok := Announcement.Active(time.Now(), i18n.LangZhCN); ok {
|
||||
t.Fatal("该语言未配文案且无默认文案时不应展示")
|
||||
}
|
||||
if got, ok := Announcement.Active(time.Now(), i18n.LangEn); !ok || got != "english only" {
|
||||
t.Fatalf("英文应正常展示,got %q ok=%v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnouncementTimeFormats(t *testing.T) {
|
||||
loc, err := time.LoadLocation(defaultAnnouncementZone)
|
||||
if err != nil {
|
||||
t.Fatalf("load zone: %v", err)
|
||||
}
|
||||
|
||||
// 带偏移的 RFC3339 必须按字面时刻解析,而不是再套一层默认时区。
|
||||
at, ok := parseAnnouncementTime("2026-10-25T02:00:00+08:00", loc)
|
||||
if !ok {
|
||||
t.Fatal("RFC3339 应可解析")
|
||||
}
|
||||
want := time.Date(2026, 10, 24, 18, 0, 0, 0, time.UTC)
|
||||
if !at.Equal(want) {
|
||||
t.Fatalf("解析结果 = %s, want %s", at.UTC(), want)
|
||||
}
|
||||
|
||||
if at, ok := parseAnnouncementTime("2026-10-25", loc); !ok || at.Hour() != 0 {
|
||||
t.Fatalf("纯日期应解析为当天 00:00,got %s ok=%v", at, ok)
|
||||
}
|
||||
if at, ok := parseAnnouncementTime("2026-10-25 02:00:59", loc); !ok || at.Second() != 59 {
|
||||
t.Fatalf("秒级格式应可解析,got %s ok=%v", at, ok)
|
||||
}
|
||||
if _, ok := parseAnnouncementTime("2026/10/25 02:00", loc); ok {
|
||||
t.Fatal("错误格式应解析失败")
|
||||
}
|
||||
if _, ok := parseAnnouncementTime("2026-13-01", loc); ok {
|
||||
t.Fatal("非法月份应解析失败")
|
||||
}
|
||||
}
|
||||
|
||||
// 裸时间必须按东八区解释:运维写「10-25 02:00」指北京时间,
|
||||
// 而容器里的 time.Local 是 UTC(旧实现整体偏 8 小时)。
|
||||
func TestAnnouncementBareTimeUsesBeijing(t *testing.T) {
|
||||
setAnnouncement(t, "维护通知", "2026-10-25 02:00", "")
|
||||
|
||||
want := time.Date(2026, 10, 24, 18, 0, 0, 0, time.UTC) // 北京 10-25 02:00
|
||||
if !Announcement.StartAt.Equal(want) {
|
||||
t.Fatalf("裸时间应按 +08:00 解释,got %s, want %s", Announcement.StartAt.UTC(), want)
|
||||
}
|
||||
if _, offset := Announcement.StartAt.Zone(); offset != 8*60*60 {
|
||||
t.Fatalf("解析后的偏移 = %d 秒, want 28800", offset)
|
||||
}
|
||||
}
|
||||
|
||||
// timezone 显式指定时以它为准,不能继续套东八区。
|
||||
func TestAnnouncementTimezoneOverride(t *testing.T) {
|
||||
setAnnouncementConfig(t, announcementConfig{
|
||||
Content: "维护通知",
|
||||
Start: "2026-10-25 00:00",
|
||||
Timezone: "UTC",
|
||||
})
|
||||
|
||||
want := time.Date(2026, 10, 25, 0, 0, 0, 0, time.UTC)
|
||||
if !Announcement.StartAt.Equal(want) {
|
||||
t.Fatalf("timezone=UTC 时应按 UTC 解析,got %s", Announcement.StartAt.UTC())
|
||||
}
|
||||
|
||||
setAnnouncementConfig(t, announcementConfig{
|
||||
Content: "维护通知",
|
||||
Start: "2026-10-25 00:00",
|
||||
Timezone: "America/New_York",
|
||||
})
|
||||
// 纽约 10-25 00:00 = UTC 10-25 04:00(EDT,UTC-4)
|
||||
wantNY := time.Date(2026, 10, 25, 4, 0, 0, 0, time.UTC)
|
||||
if !Announcement.StartAt.Equal(wantNY) {
|
||||
t.Fatalf("timezone=America/New_York 解析错误,got %s", Announcement.StartAt.UTC())
|
||||
}
|
||||
}
|
||||
|
||||
// 时区名写错和日期写错一样:只关公告,不阻断启动。
|
||||
func TestAnnouncementInvalidTimezoneDisables(t *testing.T) {
|
||||
setAnnouncementConfig(t, announcementConfig{
|
||||
Content: "维护通知",
|
||||
Start: "2026-10-25 00:00",
|
||||
Timezone: "Mars/Olympus",
|
||||
})
|
||||
|
||||
if Announcement.Content != "" {
|
||||
t.Fatal("时区无效时公告应被关闭")
|
||||
}
|
||||
}
|
||||
|
||||
// 时间写错只关公告,不能让站点起不来;同时必须清空解析结果,避免残留时间窗。
|
||||
func TestAnnouncementInvalidTimeDisables(t *testing.T) {
|
||||
for _, c := range []struct{ name, start, end string }{
|
||||
{"start 格式错", "2026/10/25 02:00", ""},
|
||||
{"end 格式错", "", "10月26日"},
|
||||
{"end 早于 start", "2026-10-26 00:00", "2026-10-25 00:00"},
|
||||
{"end 等于 start", "2026-10-25 00:00", "2026-10-25 00:00"},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
setAnnouncement(t, "内容", c.start, c.end)
|
||||
if Announcement.Content != "" {
|
||||
t.Fatal("配置无效时公告应被关闭")
|
||||
}
|
||||
if !Announcement.StartAt.IsZero() || !Announcement.EndAt.IsZero() {
|
||||
t.Fatal("配置无效时应清空已解析的时间点")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+60
-1
@@ -12,7 +12,7 @@ import (
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
// File is the configuration object.
|
||||
// File 是解析后的配置对象。
|
||||
var File *ini.File
|
||||
|
||||
func Init() error {
|
||||
@@ -29,6 +29,10 @@ func Init() error {
|
||||
return errors.Wrapf(err, "parse %q", configFile)
|
||||
}
|
||||
|
||||
// 键名归一化必须在 MapTo **之前**做:ini 对未知键是静默忽略的,
|
||||
// 等 MapTo 跑完,错拼的键早就被丢掉了,再也认不出来。
|
||||
normalizeAllSectionKeys(File)
|
||||
|
||||
if err := File.Section("app").MapTo(&App); err != nil {
|
||||
return errors.Wrap(err, "map 'server'")
|
||||
}
|
||||
@@ -42,6 +46,14 @@ func Init() error {
|
||||
return errors.Wrap(err, "map 'security'")
|
||||
}
|
||||
|
||||
// 蜜罐配置归一化:把「未配置」与「显式关闭」区分开,回退安全默认值。
|
||||
sanitizeHoneypot()
|
||||
|
||||
if err := validateTextCensorSource(Security.EnableTextCensor, Security.TextCensorSource,
|
||||
App.QiniuAccessKey, App.QiniuAccessSecret, App.AliyunAccessKey, App.AliyunAccessKeySecret); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := File.Section("server").MapTo(&Server); err != nil {
|
||||
return errors.Wrap(err, "map 'server'")
|
||||
}
|
||||
@@ -84,10 +96,25 @@ func Init() error {
|
||||
// ini 键留空会被 int 解析为 0;<1 的值一律回退内置默认,防止上限被静默置零。
|
||||
sanitizeLimits(&Limits)
|
||||
|
||||
// 主题:默认 slug 与启用清单。段缺失时 MapTo 得到空值,运行期回退内置 default,
|
||||
// 因此老配置文件不写 [theme] 也能正常启动。
|
||||
if err := File.Section("theme").MapTo(&Theme); err != nil {
|
||||
return errors.Wrap(err, "map 'theme'")
|
||||
}
|
||||
|
||||
if err := File.Section("announcement").MapTo(&Announcement); err != nil {
|
||||
return errors.Wrap(err, "map 'announcement'")
|
||||
}
|
||||
sanitizeAnnouncement()
|
||||
|
||||
if err := File.Section("mail").MapTo(&Mail); err != nil {
|
||||
return errors.Wrap(err, "map 'mail'")
|
||||
}
|
||||
|
||||
if err := File.Section("notify").MapTo(&Notify); err != nil {
|
||||
return errors.Wrap(err, "map 'notify'")
|
||||
}
|
||||
|
||||
serviceSections := File.Section("service").ChildSections()
|
||||
for _, serviceSection := range serviceSections {
|
||||
serviceSection := serviceSection
|
||||
@@ -103,3 +130,35 @@ func Init() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateTextCensorSource 校验文本审核通道([security] text_censor_source)与凭据的搭配。
|
||||
// 抽成纯函数(只依赖入参、不读全局)以便单测覆盖,也让 Init 保持轻薄。
|
||||
//
|
||||
// - 审核整体关闭(enabled=false)→ 不校验,不需要任何凭据。
|
||||
// - 留空 / auto → 通过。auto 是历史默认(七牛优先、阿里云兜底),两者都无凭据时
|
||||
// 维持既有运行期 fail-closed 行为,不阻断启动(否则 app.sample.ini 原样拷贝无法启动)。
|
||||
// - 显式选 qiniu / aliyun → 必须配齐该通道凭据,否则启动即失败:缺凭据时运行期审核必失败,
|
||||
// 而审核失败是 fail-closed(拒绝提交),会让发布/私信整站不可用且现象隐蔽。
|
||||
// - 其它取值 → 直接报错,不做静默回退:拼写错误若被当成 auto,运维会误以为已切通道。
|
||||
func validateTextCensorSource(enabled bool, source, qiniuKey, qiniuSecret, aliyunKey, aliyunSecret string) error {
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(source)) {
|
||||
case "", CensorSourceAuto:
|
||||
return nil
|
||||
case CensorSourceQiniu:
|
||||
if qiniuKey == "" || qiniuSecret == "" {
|
||||
return errors.New("security.text_censor_source = qiniu requires app.qiniu_access_key and app.qiniu_access_secret")
|
||||
}
|
||||
return nil
|
||||
case CensorSourceAliyun:
|
||||
if aliyunKey == "" || aliyunSecret == "" {
|
||||
return errors.New("security.text_censor_source = aliyun requires app.aliyun_access_key and app.aliyun_access_key_secret")
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return errors.Errorf("security.text_censor_source must be one of qiniu / aliyun / auto, got %q", source)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright 2026 tamakyi. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package conf
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// 蜜罐相关配置的默认值。
|
||||
//
|
||||
// 为什么要显式兜底:ini 的 MapTo 在「键缺失」或「值非法」时**不覆盖**目标字段,
|
||||
// 所以结构体的零值就是「未配置」的表现。而零值对这几个开关恰好是最危险的那个:
|
||||
//
|
||||
// - HoneypotAutoClean 零值 false → 未配置的站点自动清理是关的,
|
||||
// honeypot_hits 只增不减;
|
||||
// - HoneypotRetainDays 零值 0 → 保留期 0 天 = 永不清理;
|
||||
// - HoneypotMaxRows 零值 0 → 无行数上限。
|
||||
//
|
||||
// 现场就踩过这个坑:配置写成了 honeypot_retention_days(多一个 t),
|
||||
// 键名不匹配被 ini 静默忽略,保留期成了 0,自动清理形同关闭。
|
||||
const (
|
||||
// DefaultHoneypotRetainDays 蜜罐记录默认保留天数。
|
||||
DefaultHoneypotRetainDays = 30
|
||||
// DefaultHoneypotMaxRows 蜜罐记录默认行数上限。
|
||||
// 实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
|
||||
DefaultHoneypotMaxRows = 200000
|
||||
// DefaultHoneypotThreshold 「提交凭据」多少次即封禁。默认 1:
|
||||
// 往假后台里填账号密码是证据最强的行为,抓到一次就足够。
|
||||
// 纯路径探测不走这个阈值(见 route.honeypotHandler.Serve)。
|
||||
DefaultHoneypotThreshold = 1
|
||||
// DefaultHoneypotBanMinutes 蜜罐封禁的默认时长(分钟)。
|
||||
//
|
||||
// 刻意远长于常规 ban_duration_minutes(15 分钟):蜜罐命中的不是「访问了一个
|
||||
// 可疑路径」,而是「往假后台里填了账号密码」—— 对方已经付出破解成本、
|
||||
// 且拿到了一次响应(知道「后台存在」)。15 分钟不足以让对方放弃,
|
||||
// 24 小时能覆盖一轮扫描活动周期。
|
||||
DefaultHoneypotBanMinutes = 1440
|
||||
// DefaultHoneypotCredDir 凭据取证文件的默认目录(相对进程工作目录)。
|
||||
//
|
||||
// 不放在数据库所在目录:这两者的权限、备份策略、留存周期都应该分开管。
|
||||
DefaultHoneypotCredDir = "data/honeypot-creds"
|
||||
// DefaultIPGeoProvider 默认的地理定位数据源:auto(ip-api 打底 + 腾讯精修)。
|
||||
DefaultIPGeoProvider = "auto"
|
||||
// DefaultHoneypotReportDir 安全汇总报告文件的默认目录。
|
||||
//
|
||||
// 与凭据目录分开:报告里有攻击者 IP 明细,但它是可以外发/归档的材料;
|
||||
// 凭据目录里是明文账号密码,永不外发。放在一起容易误打包。
|
||||
DefaultHoneypotReportDir = "data/reports"
|
||||
)
|
||||
|
||||
// sanitizeHoneypot 归一化蜜罐相关配置,修正「未配置」与「显式关闭」的歧义。
|
||||
//
|
||||
// 语义约定(两个开关各管一件事,不再互相牵连):
|
||||
//
|
||||
// honeypot_auto_clean 是否启用后台自动清理。缺省 true(开)。
|
||||
// honeypot_retain_days 保留多少天。缺省 30 天。
|
||||
//
|
||||
// 也就是说:**要关闭自动清理请写 honeypot_auto_clean = false**,
|
||||
// 而不要靠把 retain_days 写成 0 —— 保留 0 天意味着「记录一进来就删」,
|
||||
// 既不是任何人的真实意图,也会让「自动清理到底开没开」变得无法判断。
|
||||
//
|
||||
// 显式写 honeypot_max_rows = 0 仍表示「不设行数上限」(这是明确的逃生口),
|
||||
// 但会告警提醒:没有时间闸也没有数量闸的表只增不减。
|
||||
func sanitizeHoneypot() {
|
||||
sec := File.Section("security")
|
||||
hasRetain := sec.HasKey("honeypot_retain_days")
|
||||
hasMaxRows := sec.HasKey("honeypot_max_rows")
|
||||
|
||||
// 保留期:未配置或显式 <= 0 → 回退默认。
|
||||
// 显式 0 的语义是「立刻删除」,与「不自动清理」混淆,判为误配。
|
||||
if !hasRetain {
|
||||
Security.HoneypotRetainDays = DefaultHoneypotRetainDays
|
||||
} else if Security.HoneypotRetainDays <= 0 {
|
||||
logrus.WithField("default", DefaultHoneypotRetainDays).
|
||||
Warn("honeypot_retain_days <= 0 已被忽略,改用默认值(如需关闭自动清理请设 honeypot_auto_clean = false)")
|
||||
Security.HoneypotRetainDays = DefaultHoneypotRetainDays
|
||||
}
|
||||
|
||||
// 行数上限:未配置 → 默认;显式 0 → 不设上限(逃生口,但告警)。
|
||||
if !hasMaxRows {
|
||||
Security.HoneypotMaxRows = DefaultHoneypotMaxRows
|
||||
} else if Security.HoneypotMaxRows <= 0 {
|
||||
logrus.Warn("honeypot_max_rows = 0 表示不设行数上限:蜜罐表将只增不减,请确认这是有意为之")
|
||||
}
|
||||
|
||||
// 自动清理:缺省开启。
|
||||
// 判据用 HasKey 而不是值 —— 因为 ini 对 bool 的解析是「有键才赋值」,
|
||||
// 缺键时零值 false 会让「未配置」与「显式关闭」无法区分。
|
||||
// 显式 false 是合法的关闭意图,予以尊重(与 retain_days 的处理不同)。
|
||||
if !sec.HasKey("honeypot_auto_clean") {
|
||||
Security.HoneypotAutoClean = true
|
||||
}
|
||||
|
||||
// 封禁阈值:缺省 1。
|
||||
if !sec.HasKey("honeypot_threshold") {
|
||||
Security.HoneypotThreshold = DefaultHoneypotThreshold
|
||||
}
|
||||
|
||||
// 封禁时长:缺省 24 小时(见 DefaultHoneypotBanMinutes 的理由)。
|
||||
// 显式写 <= 0 是逃生口:交给 autoban 回退到 ban_duration_minutes,
|
||||
// 但那样「蜜罐按常规时长封」的意图不明显,故明确告警。
|
||||
if !sec.HasKey("honeypot_ban_minutes") {
|
||||
Security.HoneypotBanMinutes = DefaultHoneypotBanMinutes
|
||||
} else if Security.HoneypotBanMinutes <= 0 {
|
||||
logrus.WithField("fallback_key", "ban_duration_minutes").
|
||||
Warn("honeypot_ban_minutes <= 0 已忽略,蜜罐封禁将回退为 ban_duration_minutes")
|
||||
}
|
||||
|
||||
// 凭据文件目录:未配置或留空都用默认值。
|
||||
// 这里不回退「不保存」——用户显式开启蜜罐就是想要取证,
|
||||
// 目录写错会在 route 层触发「不可用 → 不保存凭据」的告警,而不是静默丢弃。
|
||||
if strings.TrimSpace(Security.HoneypotCredDir) == "" {
|
||||
Security.HoneypotCredDir = DefaultHoneypotCredDir
|
||||
}
|
||||
|
||||
// 地理定位数据源:未配置 → auto。
|
||||
// 显式写了不认识的值时保持原样,由 geoip 层解析并告警 ——
|
||||
// 在配置层悄悄改成 auto 会让人以为配置生效了。
|
||||
if strings.TrimSpace(Security.IPGeoProvider) == "" {
|
||||
Security.IPGeoProvider = DefaultIPGeoProvider
|
||||
}
|
||||
|
||||
// 周期报告:三个开关都用 HasKey 判定缺省值(同 HoneypotAutoClean 的理由)。
|
||||
if !sec.HasKey("honeypot_report_enabled") {
|
||||
Security.HoneypotReportEnabled = true
|
||||
}
|
||||
if !sec.HasKey("honeypot_report_weekly") {
|
||||
Security.HoneypotReportWeekly = true
|
||||
}
|
||||
if !sec.HasKey("honeypot_report_monthly") {
|
||||
Security.HoneypotReportMonthly = true
|
||||
}
|
||||
if strings.TrimSpace(Security.HoneypotReportDir) == "" {
|
||||
Security.HoneypotReportDir = DefaultHoneypotReportDir
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
// Copyright 2026 tamakyi. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package conf
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
// 配置键名归一化:容忍运维手写时的常见失误,避免「配了但不生效」。
|
||||
//
|
||||
// 背景:ini 库对未知键是**静默忽略**的 —— 写错一个字母,程序照常启动、
|
||||
// 照常跑,但那个配置根本没生效,现场还看不出来。本次就踩了这个坑:
|
||||
// 写 `honeypot_retention_days`(多一个 t)而代码读 `honeypot_retain_days`,
|
||||
// 表现为「自动清理未启用」警告,而根因是键名不匹配。
|
||||
//
|
||||
// 这里做两层兜底:
|
||||
//
|
||||
// 1. 键名归一化(normalizeKeyName):修剪首尾空白、剥掉尾随的
|
||||
// `<` `>` `=` 等比较符号残留(`honeypot_retention_days <= 30` 会被 ini
|
||||
// 解析成键名 "honeypot_retention_days <",不是我们想要的键名)。
|
||||
// 2. 别名表(keyAliases):把已知的常见误拼映射到正确键名。
|
||||
//
|
||||
// 归一化只影响**键名**,不改动值。且归一化后的键名必须在该段的合法键名
|
||||
// 白名单内才会被采用 —— 避免把真正写错、无法识别的键「猜」成某个配置项。
|
||||
|
||||
// keyAliases 常见误拼 → 正确键名。
|
||||
//
|
||||
// 只收录「意图明确、写法唯一」的误拼。收录标准:
|
||||
// - 去掉别名后一定是该段某个合法键名(由调用方用白名单校验);
|
||||
// - 不会与任何合法键名或彼此产生歧义。
|
||||
//
|
||||
// 收录历史教训:honeypot_retain_days 被写成 honeypot_retention_days。
|
||||
var keyAliases = map[string]string{
|
||||
"honeypot_retention_days": "honeypot_retain_days",
|
||||
"honeypot_retain_day": "honeypot_retain_days",
|
||||
"honeypot_retention": "honeypot_retain_days",
|
||||
"honeypot_maxrow": "honeypot_max_rows",
|
||||
"honeypot_max_row": "honeypot_max_rows",
|
||||
"honeypot_maxrow_count": "honeypot_max_rows",
|
||||
"honeypot_autoclean": "honeypot_auto_clean",
|
||||
"honeypot_auto_cleaning": "honeypot_auto_clean",
|
||||
"enable_honeypot_hit": "enable_honeypot",
|
||||
"honeypot_retaindays": "honeypot_retain_days",
|
||||
"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 归一化配置键名。
|
||||
//
|
||||
// 处理两类问题:
|
||||
// 1. 首尾空白与尾随比较符号残留。
|
||||
// `honeypot_retention_days <= 30` 在 ini 里被解析为键名
|
||||
// "honeypot_retention_days <"(值 "30")—— 键名带了尾随的 "<",
|
||||
// 直接与任何配置项都对不上。剥掉这些字符后才是用户真正想写的名字。
|
||||
// 2. 常见误拼别名(见 keyAliases)。
|
||||
//
|
||||
// 大小写不归一:ini 的键名匹配本来就是大小写敏感的,
|
||||
// 顺手转小写反而会让「键名写错大小写」这类问题更难发现。
|
||||
func normalizeKeyName(raw string) string {
|
||||
name := strings.TrimSpace(raw)
|
||||
// 剥掉尾随的空白与比较符号残留(可能叠加多个,如 "key <=" 或 "key ==")。
|
||||
for len(name) > 0 {
|
||||
last := name[len(name)-1]
|
||||
if last == ' ' || last == '\t' || last == '<' || last == '>' || last == '=' {
|
||||
name = name[:len(name)-1]
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
// 剥完后可能又暴露出新的尾随空白,继续削。
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
// 别名映射(只做一次,不递归,避免别名链产生意外结果)。
|
||||
if canonical, ok := keyAliases[name]; ok {
|
||||
return canonical
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// legalKeysOf 收集一个段在代码中真实存在的合法键名。
|
||||
//
|
||||
// 通过反射读取结构体的 `ini` tag —— 单一事实来源,避免「白名单与代码脱节」
|
||||
// 导致新增配置项后被误报为未知键。
|
||||
//
|
||||
// 返回的键名集合同时包含 ini tag 原文与其归一化形式(归一化形式一般等于原文,
|
||||
// 因为 tag 本身不带空白与比较符号)。
|
||||
func legalKeysOf(v interface{}) map[string]bool {
|
||||
legal := map[string]bool{}
|
||||
add := func(name string) {
|
||||
if name == "" || name == "-" {
|
||||
return
|
||||
}
|
||||
legal[name] = true
|
||||
}
|
||||
collect := func(v interface{}) {
|
||||
rv := reflect.ValueOf(v)
|
||||
for rv.Kind() == reflect.Ptr {
|
||||
rv = rv.Elem()
|
||||
}
|
||||
if rv.Kind() != reflect.Struct {
|
||||
return
|
||||
}
|
||||
rt := rv.Type()
|
||||
for i := 0; i < rt.NumField(); i++ {
|
||||
field := rt.Field(i)
|
||||
tag := field.Tag.Get("ini")
|
||||
if tag == "-" {
|
||||
continue
|
||||
}
|
||||
// ini tag 形如 "name" 或 "name,opt1,opt2",取第一段。
|
||||
name := tag
|
||||
if idx := strings.Index(tag, ","); idx >= 0 {
|
||||
name = tag[:idx]
|
||||
}
|
||||
if name == "" {
|
||||
// 无 ini tag 时 ini 用字段名(小写化后的 snake_case 近似),
|
||||
// 保守起见不登记 —— 未登记只会导致漏报,不会误报。
|
||||
continue
|
||||
}
|
||||
add(name)
|
||||
add(normalizeKeyName(name))
|
||||
}
|
||||
}
|
||||
collect(v)
|
||||
return legal
|
||||
}
|
||||
|
||||
// sectionKeySets 配置文件中各段 → 该段代码里真实存在的合法键名。
|
||||
//
|
||||
// 由反射从结构体 ini tag 得出,是单一事实来源:新增配置项后自动纳入,
|
||||
// 不会出现「白名单与代码脱节」导致的误报。
|
||||
//
|
||||
// 注意:ini 对**无 ini tag** 的字段用字段名做键名,这部分无法通过 tag
|
||||
// 枚举,为避免误报这里不登记(漏报优于误报)。
|
||||
func sectionKeySets() map[string]map[string]bool {
|
||||
return map[string]map[string]bool{
|
||||
"app": legalKeysOf(&App),
|
||||
"theme": legalKeysOf(&Theme),
|
||||
"announcement": legalKeysOf(&Announcement),
|
||||
"security": legalKeysOf(&Security),
|
||||
"server": legalKeysOf(&Server),
|
||||
"database": legalKeysOf(&Database),
|
||||
"redis": legalKeysOf(&Redis),
|
||||
"recaptcha": legalKeysOf(&Recaptcha),
|
||||
"pixel": legalKeysOf(&Pixel),
|
||||
"upload": legalKeysOf(&Upload),
|
||||
"limits": legalKeysOf(&Limits),
|
||||
"mail": legalKeysOf(&Mail),
|
||||
"service": legalKeysOf(&Service),
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeAllSectionKeys 归一化配置文件中所有段的键名,并告警未知键。
|
||||
//
|
||||
// 做两件事:
|
||||
// 1. 把可识别的误拼键名(带尾随垃圾字符 / 命中别名表)改写成正确键名,
|
||||
// 改写会记 INFO 日志,便于运维确认「我配的到底生效了没」。
|
||||
// 2. 把无法识别的键名 WARN 出来(附最接近的合法键名建议)。
|
||||
// 这是防住「配了但不生效」的关键 —— ini 本身对未知键完全静默。
|
||||
//
|
||||
// 只告警不报错:未知键不应该阻止服务启动(可能只是运维写了无关的键),
|
||||
// 但必须让人看见。
|
||||
func normalizeAllSectionKeys(f *ini.File) {
|
||||
if f == nil {
|
||||
return
|
||||
}
|
||||
legal := sectionKeySets()
|
||||
|
||||
// 1) 键名归一化(改写 ini 文件里的键名,后续 MapTo 才能读到)。
|
||||
for section, keys := range legal {
|
||||
sec := f.Section(section)
|
||||
if sec == nil {
|
||||
continue
|
||||
}
|
||||
renamed := normalizeSectionKeys(sec, keys)
|
||||
for old, new := range renamed {
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"section": section,
|
||||
"from": old,
|
||||
"to": new,
|
||||
}).Info("配置键名已自动纠正(该键原本会被静默忽略)")
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 未知键告警。
|
||||
for _, u := range detectUnknownSections(f, legal) {
|
||||
for _, key := range u.Keys {
|
||||
fields := logrus.Fields{"section": u.Section, "key": key}
|
||||
if s, ok := u.Suggestions[key]; ok {
|
||||
fields["suggestion"] = s
|
||||
logrus.WithFields(fields).Warn("配置文件中存在无法识别的键(已被忽略,该项配置不会生效)")
|
||||
continue
|
||||
}
|
||||
logrus.WithFields(fields).Warn("配置文件中存在无法识别的键(已被忽略,该项配置不会生效)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeSectionKeys 归一化一个段内的键名,并把归一化结果写回 ini 文件。
|
||||
//
|
||||
// 行为:
|
||||
// - 遍历段内所有键,计算归一化键名;
|
||||
// - 若归一化键名与原键名不同,且归一化键名在该段白名单内 → 用归一化名替换;
|
||||
// - 若归一化键名是别名且白名单内 → 用正确键名替换;
|
||||
// - 否则保留原样(交给未知键告警处理,不擅自猜测)。
|
||||
//
|
||||
// 返回被改写的键名对(原名 → 新名),供调用方记日志。
|
||||
func normalizeSectionKeys(sec *ini.Section, legal map[string]bool) map[string]string {
|
||||
renamed := map[string]string{}
|
||||
if sec == nil {
|
||||
return renamed
|
||||
}
|
||||
type rename struct {
|
||||
old string
|
||||
new string
|
||||
}
|
||||
var pending []rename
|
||||
for _, key := range sec.Keys() {
|
||||
old := key.Name()
|
||||
norm := normalizeKeyName(old)
|
||||
if norm == "" || norm == old {
|
||||
continue
|
||||
}
|
||||
if !legal[norm] {
|
||||
// 归一化后的名字不是本段的合法键名 —— 可能是真写错了,
|
||||
// 也可能是别段的键。不要擅自猜测,交给未知键告警。
|
||||
continue
|
||||
}
|
||||
// 目标键名已存在时保留原键(显式的正确写法优先于需要猜测的写法)。
|
||||
if sec.HasKey(norm) {
|
||||
continue
|
||||
}
|
||||
pending = append(pending, rename{old: old, new: norm})
|
||||
}
|
||||
for _, r := range pending {
|
||||
val := sec.Key(r.old).Value()
|
||||
sec.DeleteKey(r.old)
|
||||
sec.NewKey(r.new, val)
|
||||
renamed[r.old] = r.new
|
||||
}
|
||||
return renamed
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
// Copyright 2026 tamakyi. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package conf
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
// TestNormalizeKeyName 覆盖键名归一化的各种手写失误。
|
||||
//
|
||||
// 现场背景:运维写成 `honeypot_retention_days`(多一个 t)且写成
|
||||
// `honeypot_retention_days <= 30`,ini 把键名解析成
|
||||
// "honeypot_retention_days <"(带尾随比较符),完全匹配不上任何配置项,
|
||||
// 静默失效 —— 表现为「自动清理未启用」,现场毫无线索。
|
||||
func TestNormalizeKeyName(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"正常键名不变", "honeypot_retain_days", "honeypot_retain_days"},
|
||||
{"多一个 t 的误拼", "honeypot_retention_days", "honeypot_retain_days"},
|
||||
{"误拼加尾随比较符", "honeypot_retention_days <", "honeypot_retain_days"},
|
||||
{"尾随 <= ", "honeypot_retain_days<=", "honeypot_retain_days"},
|
||||
{"首尾空白", " honeypot_retain_days ", "honeypot_retain_days"},
|
||||
{"空白加比较符", " honeypot_retain_days < ", "honeypot_retain_days"},
|
||||
{"多个尾随符号", "honeypot_retain_days<< ", "honeypot_retain_days"},
|
||||
{"空串", "", ""},
|
||||
{"纯符号", "<<<", ""},
|
||||
{"大小写不归一", "HONEYPOT_RETAIN_DAYS", "HONEYPOT_RETAIN_DAYS"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := normalizeKeyName(c.in); got != c.want {
|
||||
t.Errorf("normalizeKeyName(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeSectionKeys 验证段内键名改写:错拼的键被纠正到正确名字。
|
||||
func TestNormalizeSectionKeys(t *testing.T) {
|
||||
src := `
|
||||
[security]
|
||||
enable_honeypot = true
|
||||
honeypot_retention_days <= 30
|
||||
honeypot_auto_clean = true
|
||||
`
|
||||
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(src))
|
||||
if err != nil {
|
||||
t.Fatalf("parse ini: %v", err)
|
||||
}
|
||||
sec := f.Section("security")
|
||||
legal := legalKeysOf(&Security)
|
||||
|
||||
renamed := normalizeSectionKeys(sec, legal)
|
||||
if len(renamed) == 0 {
|
||||
t.Fatalf("期望有键名被改写,实际没有")
|
||||
}
|
||||
if !sec.HasKey("honeypot_retain_days") {
|
||||
t.Errorf("改写后应存在 honeypot_retain_days,实际键: %v", sec.KeyStrings())
|
||||
}
|
||||
// 值必须原样保留(30),不能因为改写丢值。
|
||||
if got := sec.Key("honeypot_retain_days").Value(); got != "30" {
|
||||
t.Errorf("改写后值 = %q, want %q", got, "30")
|
||||
}
|
||||
// 归一化后的键名必须真的能被 MapTo 读到 —— 这是整个修复的落点。
|
||||
if err := sec.MapTo(&Security); err != nil {
|
||||
t.Fatalf("map security: %v", err)
|
||||
}
|
||||
if Security.HoneypotRetainDays != 30 {
|
||||
t.Errorf("HoneypotRetainDays = %d, want 30", Security.HoneypotRetainDays)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeSectionKeysKeepsExplicit 验证正确键名存在时不会被别名的值覆盖。
|
||||
//
|
||||
// 显式写对的键优先级必须高于需要猜测的别名 —— 否则运维改了正确写法反而失效。
|
||||
func TestNormalizeSectionKeysKeepsExplicit(t *testing.T) {
|
||||
src := `
|
||||
[security]
|
||||
honeypot_retain_days = 7
|
||||
honeypot_retention_days = 30
|
||||
`
|
||||
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(src))
|
||||
if err != nil {
|
||||
t.Fatalf("parse ini: %v", err)
|
||||
}
|
||||
sec := f.Section("security")
|
||||
normalizeSectionKeys(sec, legalKeysOf(&Security))
|
||||
|
||||
if got := sec.Key("honeypot_retain_days").Value(); got != "7" {
|
||||
t.Errorf("显式键应保留原值, got %q, want %q", got, "7")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeSectionKeysUnknownNotGuessed 验证无法识别的键不会被「猜」成某个配置项。
|
||||
//
|
||||
// 归一化只能纠正「意图明确」的写法;对真正拼错的键必须保持原样,
|
||||
// 交由未知键告警提示 —— 擅自猜测会让配置静默变成另一个意思,比不猜更危险。
|
||||
func TestNormalizeSectionKeysUnknownNotGuessed(t *testing.T) {
|
||||
src := `
|
||||
[security]
|
||||
totally_bogus_key = 1
|
||||
`
|
||||
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(src))
|
||||
if err != nil {
|
||||
t.Fatalf("parse ini: %v", err)
|
||||
}
|
||||
sec := f.Section("security")
|
||||
normalizeSectionKeys(sec, legalKeysOf(&Security))
|
||||
|
||||
if !sec.HasKey("totally_bogus_key") {
|
||||
t.Errorf("无法识别的键应保持原样,不应被改写;实际键: %v", sec.KeyStrings())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectUnknownSections 验证未知键被检出并给出建议。
|
||||
func TestDetectUnknownSections(t *testing.T) {
|
||||
src := `
|
||||
[security]
|
||||
enable_honeypot = true
|
||||
honeypot_retention_days = 30
|
||||
honeypot_totally_bogus = 1
|
||||
`
|
||||
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(src))
|
||||
if err != nil {
|
||||
t.Fatalf("parse ini: %v", err)
|
||||
}
|
||||
unknown := detectUnknownSections(f, sectionKeySets())
|
||||
|
||||
var sec *unknownSection
|
||||
for i := range unknown {
|
||||
if unknown[i].Section == "security" {
|
||||
sec = &unknown[i]
|
||||
}
|
||||
}
|
||||
if sec == nil {
|
||||
t.Fatalf("应检出 security 段的未知键,实际: %+v", unknown)
|
||||
}
|
||||
found := false
|
||||
for _, k := range sec.Keys {
|
||||
if k == "honeypot_totally_bogus" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("未知键列表应含 honeypot_totally_bogus,实际: %v", sec.Keys)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectUnknownSectionsNoFalsePositive 验证合法键不会被误报为未知。
|
||||
//
|
||||
// 误报比漏报更烦人:每次启动都刷一屏 WARN,运维会麻木。
|
||||
func TestDetectUnknownSectionsNoFalsePositive(t *testing.T) {
|
||||
src := `
|
||||
[app]
|
||||
external_url = "http://127.0.0.1:8080"
|
||||
production = true
|
||||
|
||||
[security]
|
||||
enable_honeypot = true
|
||||
honeypot_auto_clean = true
|
||||
honeypot_max_rows = 200000
|
||||
honeypot_retain_days = 30
|
||||
honeypot_threshold = 1
|
||||
honeypot_ban_minutes = 15
|
||||
honeypot_paths = "/a,/b"
|
||||
enable_auto_ban = true
|
||||
ban_threshold_sensitive = 5
|
||||
security_alert_email = "[email protected]"
|
||||
|
||||
[server]
|
||||
port = 8080
|
||||
salt = "s"
|
||||
xsrf_key = "k"
|
||||
|
||||
[database]
|
||||
type = "postgres"
|
||||
name = "x"
|
||||
schema = "public"
|
||||
|
||||
[limits]
|
||||
max_tags = 5
|
||||
`
|
||||
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(src))
|
||||
if err != nil {
|
||||
t.Fatalf("parse ini: %v", err)
|
||||
}
|
||||
unknown := detectUnknownSections(f, sectionKeySets())
|
||||
for _, u := range unknown {
|
||||
// [service] 段是子段结构,合法键在前缀段上,不在本段。
|
||||
if u.Section == "service" {
|
||||
continue
|
||||
}
|
||||
t.Errorf("合法键被误报为未知: 段 %s 键 %v", u.Section, u.Keys)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClosestKey 验证建议算法能给出有用的近似键名。
|
||||
func TestClosestKey(t *testing.T) {
|
||||
legal := map[string]bool{
|
||||
"honeypot_retain_days": true,
|
||||
"honeypot_max_rows": true,
|
||||
"enable_honeypot": true,
|
||||
}
|
||||
if got := closestKey("honeypot_retention_days", legal); got != "honeypot_retain_days" {
|
||||
t.Errorf("closestKey = %q, want %q", got, "honeypot_retain_days")
|
||||
}
|
||||
if got := closestKey("enable_honeypot_hit", legal); got != "enable_honeypot" {
|
||||
t.Errorf("closestKey = %q, want %q", got, "enable_honeypot")
|
||||
}
|
||||
// 毫无关系的键不应给建议(避免噪音)。
|
||||
if got := closestKey("zzzzzz", legal); got != "" {
|
||||
t.Errorf("closestKey 应返回空, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeHoneypotDefaults 验证蜜罐配置的缺省值与语义修正。
|
||||
//
|
||||
// 核心是三件事:
|
||||
// 1. 未配置保留期 → 回退 30 天(而不是 0 = 永不清理);
|
||||
// 2. 显式 honeypot_auto_clean = false → 尊重为关闭(这是明确的关闭意图);
|
||||
// 3. 未配置自动清理 → 开启(ini 的 bool 零值是 false,若不区分就等于默认关闭)。
|
||||
func TestSanitizeHoneypotDefaults(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
src string
|
||||
wantRetain int
|
||||
wantMaxRows int
|
||||
wantAutoClean bool
|
||||
wantThreshold int
|
||||
wantBan int
|
||||
}{
|
||||
{
|
||||
name: "全未配置时回退安全默认值",
|
||||
src: "[security]\nenable_honeypot = true\n",
|
||||
wantRetain: DefaultHoneypotRetainDays,
|
||||
wantMaxRows: DefaultHoneypotMaxRows,
|
||||
wantAutoClean: true,
|
||||
wantThreshold: DefaultHoneypotThreshold,
|
||||
wantBan: DefaultHoneypotBanMinutes,
|
||||
},
|
||||
{
|
||||
name: "显式关闭自动清理被尊重",
|
||||
src: "[security]\nhoneypot_auto_clean = false\n",
|
||||
wantRetain: DefaultHoneypotRetainDays,
|
||||
wantMaxRows: DefaultHoneypotMaxRows,
|
||||
wantAutoClean: false,
|
||||
wantThreshold: DefaultHoneypotThreshold,
|
||||
wantBan: DefaultHoneypotBanMinutes,
|
||||
},
|
||||
{
|
||||
name: "显式保留期被采用",
|
||||
src: "[security]\nhoneypot_retain_days = 7\n",
|
||||
wantRetain: 7,
|
||||
wantMaxRows: DefaultHoneypotMaxRows,
|
||||
wantAutoClean: true,
|
||||
wantThreshold: DefaultHoneypotThreshold,
|
||||
wantBan: DefaultHoneypotBanMinutes,
|
||||
},
|
||||
{
|
||||
name: "保留期写成 0 回退默认而非永不清理",
|
||||
src: "[security]\nhoneypot_retain_days = 0\n",
|
||||
wantRetain: DefaultHoneypotRetainDays,
|
||||
wantMaxRows: DefaultHoneypotMaxRows,
|
||||
wantAutoClean: true,
|
||||
wantThreshold: DefaultHoneypotThreshold,
|
||||
wantBan: DefaultHoneypotBanMinutes,
|
||||
},
|
||||
{
|
||||
name: "行数上限显式 0 表示不限",
|
||||
src: "[security]\nhoneypot_max_rows = 0\n",
|
||||
wantRetain: DefaultHoneypotRetainDays,
|
||||
wantMaxRows: 0,
|
||||
wantAutoClean: true,
|
||||
wantThreshold: DefaultHoneypotThreshold,
|
||||
wantBan: DefaultHoneypotBanMinutes,
|
||||
},
|
||||
{
|
||||
name: "显式封禁时长被采用",
|
||||
src: "[security]\nhoneypot_ban_minutes = 90\n",
|
||||
wantRetain: DefaultHoneypotRetainDays,
|
||||
wantMaxRows: DefaultHoneypotMaxRows,
|
||||
wantAutoClean: true,
|
||||
wantThreshold: DefaultHoneypotThreshold,
|
||||
wantBan: 90,
|
||||
},
|
||||
{
|
||||
// 0 是逃生口:留着让 autoban 回退 ban_duration_minutes,
|
||||
// sanitize 不做覆盖(只告警),故期望值仍是 0。
|
||||
name: "封禁时长写 0 交给 autoban 回退 ban_duration_minutes",
|
||||
src: "[security]\nhoneypot_ban_minutes = 0\n",
|
||||
wantRetain: DefaultHoneypotRetainDays,
|
||||
wantMaxRows: DefaultHoneypotMaxRows,
|
||||
wantAutoClean: true,
|
||||
wantThreshold: DefaultHoneypotThreshold,
|
||||
wantBan: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(c.src))
|
||||
if err != nil {
|
||||
t.Fatalf("parse ini: %v", err)
|
||||
}
|
||||
// 重置全局,避免用例间互相污染。
|
||||
Security.HoneypotRetainDays = 0
|
||||
Security.HoneypotMaxRows = 0
|
||||
Security.HoneypotAutoClean = false
|
||||
Security.HoneypotThreshold = 0
|
||||
Security.HoneypotBanMinutes = 0
|
||||
File = f
|
||||
if err := f.Section("security").MapTo(&Security); err != nil {
|
||||
t.Fatalf("map security: %v", err)
|
||||
}
|
||||
sanitizeHoneypot()
|
||||
|
||||
if Security.HoneypotRetainDays != c.wantRetain {
|
||||
t.Errorf("HoneypotRetainDays = %d, want %d", Security.HoneypotRetainDays, c.wantRetain)
|
||||
}
|
||||
if Security.HoneypotMaxRows != c.wantMaxRows {
|
||||
t.Errorf("HoneypotMaxRows = %d, want %d", Security.HoneypotMaxRows, c.wantMaxRows)
|
||||
}
|
||||
if Security.HoneypotAutoClean != c.wantAutoClean {
|
||||
t.Errorf("HoneypotAutoClean = %v, want %v", Security.HoneypotAutoClean, c.wantAutoClean)
|
||||
}
|
||||
if Security.HoneypotThreshold != c.wantThreshold {
|
||||
t.Errorf("HoneypotThreshold = %d, want %d", Security.HoneypotThreshold, c.wantThreshold)
|
||||
}
|
||||
if Security.HoneypotBanMinutes != c.wantBan {
|
||||
t.Errorf("HoneypotBanMinutes = %d, want %d", Security.HoneypotBanMinutes, c.wantBan)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegalKeysOfCoversStruct 验证合法键集合确实覆盖了结构体的所有 ini tag。
|
||||
//
|
||||
// 这是「未知键告警」不误报的前提:如果 legalKeysOf 漏了某个 tag,
|
||||
// 每次启动都会误报该键。
|
||||
func TestLegalKeysOfCoversStruct(t *testing.T) {
|
||||
legal := legalKeysOf(&Security)
|
||||
for _, want := range []string{
|
||||
"enable_honeypot",
|
||||
"honeypot_paths",
|
||||
"honeypot_retain_days",
|
||||
"honeypot_auto_clean",
|
||||
"honeypot_max_rows",
|
||||
"honeypot_threshold",
|
||||
"honeypot_ban_minutes",
|
||||
} {
|
||||
if !legal[want] {
|
||||
t.Errorf("合法键集合缺少 %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSampleIniHasNoUnknownKeys 用官方样例配置文件做回归检测。
|
||||
//
|
||||
// 意义:conf/app.sample.ini 是运维照抄的模板,它里面的每个键都必须能被代码识别。
|
||||
// 一旦结构体改了字段名而 sample.ini 没同步(或反之),这个测试就会失败 ——
|
||||
// 而不是等到某次部署时启动日志里飘出一堆「无法识别的键」让人摸不着头脑。
|
||||
//
|
||||
// 同时也保证未知键检测不会对官方模板误报。
|
||||
func TestSampleIniHasNoUnknownKeys(t *testing.T) {
|
||||
path := filepath.Join("..", "..", "conf", "app.sample.ini")
|
||||
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, path)
|
||||
if err != nil {
|
||||
t.Skipf("跳过:无法读取样例配置 %s(%v)", path, err)
|
||||
}
|
||||
unknown := detectUnknownSections(f, sectionKeySets())
|
||||
for _, u := range unknown {
|
||||
// [service] 是子段结构(每个后端一个子段),键在子段上,不在本段。
|
||||
if u.Section == "service" {
|
||||
continue
|
||||
}
|
||||
for _, k := range u.Keys {
|
||||
t.Errorf("样例配置中存在无法识别的键: 段 %s 键 %q", u.Section, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSampleIniValuesHaveNoInlineComment 守住「行尾注释会被并入值」的陷阱。
|
||||
//
|
||||
// conf.Init 用 ini.LoadOptions{IgnoreInlineComment: true},该选项的语义是
|
||||
// **不剥离**行尾注释(实测 `port = 1440 ; 注释` → 值 "1440 ; 注释",
|
||||
// int 解析失败,而 MapTo 仍然返回 nil error)。后果是该配置项静默回退零值,
|
||||
// 日志里只会说「配置值不对」,根本联想不到是注释造成的。
|
||||
//
|
||||
// 样例配置曾经全线是 `key = value ; 注释` 的写法:text_censor_source 因此
|
||||
// 让 conf.Init 直接拒绝启动,ip_blacklist / security_alert_email 变成带注释的
|
||||
// 垃圾字符串,honeypot_max_rows / honeypot_auto_clean 静默失效。
|
||||
//
|
||||
// 直接检查每个键的值里没有 ';',比逐个字段断言更彻底,也不会漏掉新加的配置项。
|
||||
func TestSampleIniValuesHaveNoInlineComment(t *testing.T) {
|
||||
path := filepath.Join("..", "..", "conf", "app.sample.ini")
|
||||
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, path)
|
||||
if err != nil {
|
||||
t.Skipf("跳过:无法读取样例配置 %s(%v)", path, err)
|
||||
}
|
||||
for _, sec := range f.Sections() {
|
||||
for _, k := range sec.Keys() {
|
||||
if strings.Contains(k.Value(), ";") {
|
||||
t.Errorf("样例配置 [%s] %s 的值含 ';':%q —— 行尾注释会被并入值,该项将静默失效",
|
||||
sec.Name(), k.Name(), k.Value())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeAllSectionKeysEndToEnd 走完整链路:
|
||||
// 加载带错拼键的配置 → 归一化 → MapTo → 拿到正确的保留期。
|
||||
//
|
||||
// 这正是现场故障的完整复现路径。
|
||||
func TestNormalizeAllSectionKeysEndToEnd(t *testing.T) {
|
||||
src := `
|
||||
[security]
|
||||
enable_honeypot = true
|
||||
honeypot_auto_clean = true
|
||||
honeypot_max_rows = 200000
|
||||
honeypot_retention_days <= 30
|
||||
`
|
||||
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(src))
|
||||
if err != nil {
|
||||
t.Fatalf("parse ini: %v", err)
|
||||
}
|
||||
File = f
|
||||
Security.HoneypotRetainDays = 0
|
||||
Security.HoneypotAutoClean = false
|
||||
Security.HoneypotMaxRows = 0
|
||||
Security.HoneypotThreshold = 0
|
||||
|
||||
normalizeAllSectionKeys(f)
|
||||
if err := f.Section("security").MapTo(&Security); err != nil {
|
||||
t.Fatalf("map security: %v", err)
|
||||
}
|
||||
sanitizeHoneypot()
|
||||
|
||||
if Security.HoneypotRetainDays != 30 {
|
||||
t.Errorf("HoneypotRetainDays = %d, want 30(错拼键应被纠正并生效)", Security.HoneypotRetainDays)
|
||||
}
|
||||
if !Security.HoneypotAutoClean {
|
||||
t.Errorf("HoneypotAutoClean = false, want true")
|
||||
}
|
||||
if Security.HoneypotMaxRows != 200000 {
|
||||
t.Errorf("HoneypotMaxRows = %d, want 200000", Security.HoneypotMaxRows)
|
||||
}
|
||||
// 归一化后不应再残留「带比较符」的怪键名。
|
||||
for _, k := range f.Section("security").KeyStrings() {
|
||||
if strings.ContainsAny(k, "<>") {
|
||||
t.Errorf("残留带比较符的键名: %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-45
@@ -23,62 +23,39 @@ const (
|
||||
|
||||
// LimitsConfig 全站内容 / 数量上限([limits] 配置段)的字段集合。
|
||||
//
|
||||
// 过去这些上限以常量形式散落在 internal/db(MaxTags / MaxCustomTextLen 等)与
|
||||
// 路由层硬编码(私聊单条 2000 字、会话条数 clamp 5~200 等)中,调整需改代码重编译,
|
||||
// 且前后端、i18n 文案各自携带一份数值容易漂移。统一收口到 [limits] 段后:
|
||||
// - 后端校验、模板注入、前端提示全部引用 conf.Limits 的同一份值;
|
||||
// - 调整仅需修改 conf/app.ini 并重启进程,无需改代码;
|
||||
// - 长度类上限统一按字符(rune / Unicode 码点)计数。
|
||||
// 收口到本段前,这些上限散落在 internal/db 与路由层硬编码,改一次要重编译且前后端数值易漂移。
|
||||
// 现后端校验 / 模板注入 / 前端提示统一引用同一份值,调整只需改 app.ini 重启;
|
||||
// 长度类上限一律按字符(rune)计数。
|
||||
type LimitsConfig struct {
|
||||
// ── 标签与屏蔽词(账号设置页)──
|
||||
|
||||
// 标签个数上限
|
||||
MaxTags int `ini:"max_tags"`
|
||||
// 单个标签最大字符数
|
||||
MaxTagLen int `ini:"max_tag_len"`
|
||||
// 屏蔽词个数上限
|
||||
MaxBlockWords int `ini:"max_block_words"`
|
||||
// 单个屏蔽词最大字符数
|
||||
MaxTags int `ini:"max_tags"`
|
||||
MaxTagLen int `ini:"max_tag_len"`
|
||||
MaxBlockWords int `ini:"max_block_words"`
|
||||
MaxBlockWordLen int `ini:"max_block_word_len"`
|
||||
|
||||
// ── 投票 ──
|
||||
|
||||
// 参与者填写的「自定义补充选项」文本最大字符数。
|
||||
// 覆盖两类入口:单选/多选/排序的全局补充选项,以及表单收集字段的
|
||||
// 「其他/自定义选项」(field_{id}_custom)。
|
||||
// 参与者填写的「自定义补充选项」文本最大字符数(含表单字段的「其他」)。
|
||||
CustomAnswerLen int `ini:"custom_answer_len"`
|
||||
// 站长创建投票时单个「选项」文本的最大字符数。
|
||||
// 覆盖单选/多选/排序的预设选项,以及表单收集字段(select/radio/checkbox/rank)
|
||||
// 的预置选项。与 CustomAnswerLen 是两条独立规则(站长侧预设刻意放宽),可各自调参。
|
||||
// 站长创建投票时单个「选项」文本最大字符数。与 CustomAnswerLen 是两条独立规则
|
||||
// (站长侧预设刻意放宽),可各自调参。
|
||||
VoteOptionLen int `ini:"vote_option_len"`
|
||||
|
||||
// ── 提问 / 回答 / 追问 ──
|
||||
|
||||
// 提问正文最大字符数(访客向提问箱提交的问题内容)。
|
||||
// 旧实现依赖 govalid 的 maxlen 标签(编译期写死 2000),无法随配置调整,
|
||||
// 且与前端 maxlength=1000 不一致,现已移到路由层按本值做 rune 校验。
|
||||
// 提问正文最大字符数。旧实现靠 govalid maxlen 写死 2000 且与前端 1000 不一致,
|
||||
// 现改为路由层按本值做 rune 校验。
|
||||
QuestionContentLen int `ini:"question_content_len"`
|
||||
// 回答正文最大字符数:箱主对提问的正式回答,以及箱主对追问的回复(共用)。
|
||||
AnswerContentLen int `ini:"answer_content_len"`
|
||||
// 追问正文最大字符数(提问者在问题被回答后继续追问的内容)。
|
||||
// 回答正文最大字符数:箱主对提问的正式回答,以及对追问的回复(共用)。
|
||||
AnswerContentLen int `ini:"answer_content_len"`
|
||||
FollowUpContentLen int `ini:"followup_content_len"`
|
||||
|
||||
// ── 私聊 ──
|
||||
|
||||
// 单条消息(发送 / 回复共用)最大字符数
|
||||
ChatMessageLen int `ini:"chat_message_len"`
|
||||
// 每个私聊会话的消息条数上限(系统硬顶)。用户只能在 1..该值 之间设置自己的
|
||||
// chat_max_messages;新注册用户的默认值即该硬顶(而非固定 20)。
|
||||
// 每个会话的消息条数硬顶。用户只能在 1..该值间设置 chat_max_messages,
|
||||
// 新用户默认值即该硬顶(而非固定 20)。
|
||||
ChatMaxMessages int `ini:"chat_max_messages"`
|
||||
// 单用户每日私信条数上限(用户可设范围 0..该值;0 表示不限制每日条数)。
|
||||
// 该值只是「用户可设置的上限」,不是站点强制的每日配额。
|
||||
// 每日私信条数的「用户可设上限」(非站点强制配额):用户可设 0..该值,0 表示不限制。
|
||||
DailyMessageLimitMax int `ini:"daily_message_limit_max"`
|
||||
}
|
||||
|
||||
// Limits 保存运行时的全部上限配置。conf.Init 中会先 MapTo [limits] 段覆盖显式键,
|
||||
// 再调用 sanitizeLimits 把 <1 的值回退到内置默认,因此:
|
||||
// - 缺失 [limits] 段或个别键 → 使用内置默认;
|
||||
// - ini 键留空(int 解析为 0)或误填 0/负数 → 自动回退默认,不会把上限静默置零。
|
||||
// Limits 保存运行时的全部上限配置。conf.Init 先 MapTo [limits] 段,再由 sanitizeLimits
|
||||
// 把 <1 的值回退内置默认,因此缺段、缺键、留空(解析为 0)或填负数都不会让上限静默置零。
|
||||
var Limits = LimitsConfig{
|
||||
MaxTags: defaultMaxTags,
|
||||
MaxTagLen: defaultMaxTagLen,
|
||||
@@ -94,10 +71,9 @@ var Limits = LimitsConfig{
|
||||
DailyMessageLimitMax: defaultDailyMessageLimitMax,
|
||||
}
|
||||
|
||||
// sanitizeLimits 归一化 [limits] 配置:所有上限最小为 1(0 / 负数说明未配置或手滑,
|
||||
// 一律回退内置默认,避免「留空=0」把标签全拒、消息全超长等隐性灾难)。
|
||||
// 注意 DailyMessageLimitMax 同样要求 >=1:0 会让用户侧永远只能设为 0(=不限制),
|
||||
// 语义上等于关闭每日配额功能,需显式使用该字段其余语义时请另行设计。
|
||||
// sanitizeLimits 归一化 [limits]:所有上限最小为 1,0 / 负数一律回退内置默认,
|
||||
// 避免「留空=0」把标签全拒、消息全超长等隐性灾难。
|
||||
// DailyMessageLimitMax 同样要求 >=1:为 0 会让用户只能设为 0(=不限制),等于关闭每日配额功能。
|
||||
func sanitizeLimits(l *LimitsConfig) {
|
||||
clamp1 := func(p *int, def int) {
|
||||
if *p < 1 {
|
||||
|
||||
+179
-33
@@ -4,12 +4,21 @@
|
||||
|
||||
package conf
|
||||
|
||||
// Build time and commit information.
|
||||
//
|
||||
// ⚠️ WARNING: should only be set by "-ldflags".
|
||||
// ⚠️ 仅应由 "-ldflags" 注入。
|
||||
var (
|
||||
BuildTime string
|
||||
BuildCommit = "dev"
|
||||
|
||||
// StaticVersion 静态资源缓存版本号:base.css 与主题 css 共用,改样式只改这一处。
|
||||
StaticVersion = "20261022c"
|
||||
)
|
||||
|
||||
// 文本审核通道取值([security] text_censor_source)。定义在 conf 侧,
|
||||
// 让配置校验(conf.Init)与通道装配(internal/security/censor)共用同一份字面量。
|
||||
const (
|
||||
CensorSourceAuto = "auto" // 七牛优先、阿里云兜底(历史行为,留空同义)
|
||||
CensorSourceQiniu = "qiniu"
|
||||
CensorSourceAliyun = "aliyun"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -29,34 +38,162 @@ var (
|
||||
FontPath string `ini:"font_path"`
|
||||
}
|
||||
|
||||
// Theme 主题系统([theme] 段)。
|
||||
// default :站点默认主题 slug,留空/非法回退内置 default(= 现在的外观)。
|
||||
// available :启用哪些主题(逗号分隔),同时是箱主设置页的白名单;
|
||||
// 列了但缺少 css 文件的会被忽略并告警(见 internal/theme)。
|
||||
Theme struct {
|
||||
Default string `ini:"default"`
|
||||
Available string `ini:"available"`
|
||||
}
|
||||
|
||||
// Announcement 站内公告([announcement] 段)。解析与展示判定见 announcement.go。
|
||||
Announcement announcementConfig
|
||||
|
||||
Security struct {
|
||||
EnableTextCensor bool `ini:"enable_text_censor"`
|
||||
IPBlacklist string `ini:"ip_blacklist"`
|
||||
IPBlacklistRedirectURL string `ini:"ip_blacklist_redirect_url"`
|
||||
// TrustProxy 为 true 时信任 CDN 权威客户端头 CF-Connecting-IP / EO-Client-IP
|
||||
// (以及受信代理设置的 X-Real-IP / X-Forwarded-For);否则一律使用 RemoteAddr,
|
||||
// 防止客户端伪造 header 绕过速率限制与 IP 黑名单。
|
||||
// (以及受信代理设置的 X-Real-IP / X-Forwarded-For);否则一律用 RemoteAddr,
|
||||
// 防止伪造 header 绕过速率限制与 IP 黑名单。
|
||||
// ⚠️ 权威头只在 TCP 对端可信(回环,或命中 TrustedProxies)时才会被采信 ——
|
||||
// 见 shared.ResolveClientIP:公网对端带来的权威头一律忽略(P1-3 修复)。
|
||||
TrustProxy bool `ini:"trust_proxy"`
|
||||
// TrustedProxies 逗号分隔的 IP / CIDR 列表,仅限“你自己的”反向代理/负载均衡基础设施
|
||||
// (例如本机 nginx 的 127.0.0.1、私有网络 LB)。仅当 TCP 对端落在该清单内时才会解析
|
||||
// X-Forwarded-For / X-Real-IP。留空则这两个头永不被信任(CDN 权威头 CF-Connecting-IP /
|
||||
// EO-Client-IP 仍会被采用,前提是源站已被对应 CDN 锁定)。
|
||||
// 目的:避免维护 Cloudflare / EdgeOne 等上游 CDN 的庞大 CIDR 清单——源站应配合
|
||||
// Cloudflare Tunnel / Authenticated Origin Pull / 防火墙仅放行 CDN 回源网段锁定,
|
||||
// 使 CF-Connecting-IP / EO-Client-IP 成为权威客户端 IP。
|
||||
// TrustedProxies 逗号分隔的 IP / CIDR,仅限自己的反向代理 / LB。仅当 TCP 对端
|
||||
// 落在该清单内才解析 X-Forwarded-For / X-Real-IP;留空则这两个头永不被信任。
|
||||
// 回环地址(127.0.0.1 / ::1)无需登记即视为可信对端:cloudflared / 本机 nginx
|
||||
// 回源的连接必然由本机进程发起,外部无法伪造。
|
||||
// CDN 直连回源(非 Tunnel)的部署形态下,必须把该 CDN 的回源网段登记到这里,
|
||||
// 否则它的权威头会被忽略、客户端 IP 退化为 CDN 边缘 IP。
|
||||
TrustedProxies string `ini:"trusted_proxies"`
|
||||
// 自动封禁(fail2ban 风格):检测批量扫描/探测特征后临时封禁来源 IP。
|
||||
EnableAutoBan bool `ini:"enable_auto_ban"`
|
||||
// BanTrackNotFound 是否把「未注册路径的 404」计入封禁统计。默认 false:正常业务
|
||||
// (域名/题目不存在等均走 302 业务跳转,不产生计数)一律放行,仅命中后台扫描特征
|
||||
// 路径(KindSensitive)才参与统计封禁。避免共享出口(CDN 边缘 / CGNAT NAT444)下
|
||||
// 正常用户被 404 探测流量连坐误伤。如需恢复对纯 404 字典扫描的封禁再置 true。
|
||||
BanTrackNotFound bool `ini:"ban_track_notfound"`
|
||||
BanThresholdSensitive int `ini:"ban_threshold_sensitive"` // 窗口内敏感路径命中次数
|
||||
BanThreshold404 int `ini:"ban_threshold_404"` // 窗口内 404 次数
|
||||
BanWindowSeconds int `ini:"ban_window_seconds"` // 统计窗口(秒)
|
||||
BanDurationMinutes int `ini:"ban_duration_minutes"` // 封禁时长(分钟)
|
||||
SecurityAlertEmail string `ini:"security_alert_email"` // 触发封禁时告警收件人(留空不发送)
|
||||
EnableAutoBan bool `ini:"enable_auto_ban"` // fail2ban 风格:检测到扫描特征后临时封禁来源 IP
|
||||
// BanTrackNotFound 是否把未注册路径的 404 计入封禁统计。默认 false:仅命中后台
|
||||
// 扫描特征路径(KindSensitive)才统计,避免共享出口(CDN 边缘 / CGNAT)下
|
||||
// 正常用户被 404 探测流量连坐误伤。
|
||||
BanTrackNotFound bool `ini:"ban_track_notfound"`
|
||||
BanThresholdSensitive int `ini:"ban_threshold_sensitive"` // 窗口内敏感路径命中次数
|
||||
BanThreshold404 int `ini:"ban_threshold_404"` // 窗口内 404 次数
|
||||
BanWindowSeconds int `ini:"ban_window_seconds"` // 统计窗口(秒)
|
||||
BanDurationMinutes int `ini:"ban_duration_minutes"` // 封禁时长(分钟)
|
||||
// DefaultModerationStrictness 站点默认审核严格度(lax / standard / strict),
|
||||
// 仅对未单独设置档位的账号生效(见 moderation.StrictnessFor)。留空或非法值回退
|
||||
// strict(最严档),保证升级不会放宽拦截;默认拦截面可能较升级前略有扩大,
|
||||
// 希望更少误伤则显式写 standard。
|
||||
DefaultModerationStrictness string `ini:"default_moderation_strictness"`
|
||||
// TextCensorSource 文本审核通道:qiniu / aliyun(内容安全 2.0 TextModeration,
|
||||
// 个人实名账号即可开通)/ auto(默认,留空同义:七牛优先、阿里云复核)。
|
||||
// 仅显式选定的通道会被调用,缺凭据时启动即报错(见 conf.Init)——
|
||||
// 审核失败是 fail-closed(拒绝提交),会让发布与私信整站不可用且现象隐蔽。
|
||||
TextCensorSource string `ini:"text_censor_source"`
|
||||
// EnableHoneypot 开启「虚假后台登录界面」蜜罐:扫描器探测后台路径时
|
||||
// 返回一个仿 WordPress 的假登录页,并记录其提交的用户名/密码等破解信息。
|
||||
// 仅在「无任何路由匹配」的路径上生效,与真实业务路由零交集。
|
||||
EnableHoneypot bool `ini:"enable_honeypot"`
|
||||
// HoneypotPaths 追加自定义诱饵路径(逗号分隔),与内置清单并集。
|
||||
HoneypotPaths string `ini:"honeypot_paths"`
|
||||
// HoneypotRetainDays 蜜罐命中记录的保留天数,到期自动清理。
|
||||
// ⚠️ 键名是 honeypot_retain_days(没有 tion)。写成 honeypot_retention_days
|
||||
// 会被 ini 静默忽略 —— 现场踩过这个坑,表现为「自动清理未启用」。
|
||||
// 缺省 30 天(见 sanitizeHoneypot);显式 <= 0 也回退 30。
|
||||
// 要关闭自动清理请用 honeypot_auto_clean = false。
|
||||
HoneypotRetainDays int `ini:"honeypot_retain_days"`
|
||||
// HoneypotAutoClean 是否启用蜜罐记录的后台自动保留期清理。
|
||||
// 缺省 true:honeypot_hits 是只追加的审计表,写入完全由外部扫描流量驱动,
|
||||
// 没有自动清理的话表体积只增不减(这是「数据库被扫描撑爆」的真实路径)。
|
||||
// 关闭后仍可手动执行 TamaBox honeypot prune。
|
||||
// ⚠️ 缺省值靠 HasKey 判定(见 sanitizeHoneypot):ini 的 bool 解析是
|
||||
// 「有键才赋值」,若只看零值就无法区分「未配置」与「显式关闭」。
|
||||
HoneypotAutoClean bool `ini:"honeypot_auto_clean"`
|
||||
// HoneypotMaxRows 蜜罐命中记录的行数上限。缺省 200000(约 53 MB)。
|
||||
// 显式写 0 表示不设上限(逃生口,会告警提醒表只增不减)。
|
||||
// 保留期是时间维度的上界,但攻击者可以用海量源 IP 在保留期内把表撑爆,
|
||||
// 这是第二道闸。实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
|
||||
HoneypotMaxRows int `ini:"honeypot_max_rows"`
|
||||
// HoneypotThreshold 蜜罐「提交凭据」多少次即封禁。默认 1(一次即封):
|
||||
// 往假后台里填账号密码是证据最强的行为,抓到一次就足够。
|
||||
// ⚠️ 它不作用于纯路径探测(GET 看一眼假登录页)—— 那类请求走常规
|
||||
// ban_threshold_sensitive,否则扫描器 GET 一次就被封,永远走不到
|
||||
// POST 提交凭据那一步,蜜罐也就拿不到字典。
|
||||
// 缺省回退 1(见 sanitizeHoneypot)。
|
||||
HoneypotThreshold int `ini:"honeypot_threshold"`
|
||||
// HoneypotBanMinutes 蜜罐封禁时长(分钟)。缺省 1440(24 小时),
|
||||
// 刻意远长于 ban_duration_minutes:命中蜜罐意味着对方已在提交账号密码,
|
||||
// 证据强度高于单纯路径探测(见 DefaultHoneypotBanMinutes)。
|
||||
// 显式 <= 0 表示回退 ban_duration_minutes。
|
||||
HoneypotBanMinutes int `ini:"honeypot_ban_minutes"`
|
||||
// HoneypotCredDir 凭据取证文件的存放目录。
|
||||
//
|
||||
// ⚠️ 捕获到的用户名/密码**不入库**,只追加到这个目录下的按天文件
|
||||
// (creds-YYYY-MM-DD.jsonl,一行一条 JSON)。原因:明文账号密码一旦入库,
|
||||
// 就会跟着进备份、主从复制和任何能连库的报表工具,事后无法彻底清除。
|
||||
// 目录会被强制为 0700、文件 0600;留空使用默认 data/honeypot-creds。
|
||||
// 目录不可写时只降级为「不保存凭据」,不会影响站点启动。
|
||||
HoneypotCredDir string `ini:"honeypot_cred_dir"`
|
||||
// BanImagePath 被封禁时返回的图片路径(本地文件,相对路径以进程工作目录为基准)。
|
||||
//
|
||||
// 留空、文件读不到、或内容不是图片时,一律回退为纯文字 "403 Forbidden" ——
|
||||
// 这条路径绝不能因为一张图把「拒绝」响应弄坏,更不能让缺失的资源变成 500。
|
||||
//
|
||||
// 只接受 image/* 的内容:如果配成 app.ini 之类的文件会被拒绝并告警,
|
||||
// 否则等于把配置文件当响应体吐给每一个被封的攻击者。
|
||||
BanImagePath string `ini:"ban_image_path"`
|
||||
SecurityAlertEmail string `ini:"security_alert_email"` // 触发封禁时告警收件人(留空不发送)
|
||||
|
||||
// ⚠️ security_alert_email 也是蜜罐告警邮件的收件人,而蜜罐邮件正文
|
||||
// **含明文账号密码**(这是功能核心价值:运维立刻拿到攻击者正在用的字典)。
|
||||
// 因此这里必须是**个人邮箱**,禁止填群组邮箱 / 分发列表 / 团队共用邮箱——
|
||||
// 否则等于把攻击者的破解字典广播给无关人员,且经 SMTP 中转后明文密码
|
||||
// 会留在第三方邮件系统的日志/归档里,超出本地 0600 取证文件的控制范围。
|
||||
|
||||
// CSP 内容安全策略的生效模式:enforce / report / off,缺省 enforce。
|
||||
//
|
||||
// enforce 下发 Content-Security-Policy(真正拦截);report 只下发 Report-Only
|
||||
// (照常加载、只记违规),用于临时观察。默认拦截而不是观察,原因是:
|
||||
// 没人会盯着 console,且不给 csp_report_uri 时 Report-Only 等于什么都没做,
|
||||
// 留个不生效的开关反而制造「已防护」的错觉。默认值的安全前提是白名单已经
|
||||
// 用真浏览器逐页巡检过(csp_sweep.py:20 个页面零违规)。
|
||||
// 上线后发现误伤,把 csp 改成 report 或 off 再重启即可恢复,不用改代码。
|
||||
CSP string `ini:"csp"`
|
||||
// CSPReportURI 违规上报地址(可选)。留空时浏览器只把违规打到 console 不上报;
|
||||
// 用 report 模式观察时应当配上,否则「观察一周」就只是空谈。
|
||||
CSPReportURI string `ini:"csp_report_uri"`
|
||||
|
||||
// IPGeoProvider 蜜罐/扫描来源 IP 的地理定位数据源。
|
||||
//
|
||||
// ⚠️ 与 ipinfo.Lookup(cdncheck 离线指纹)是两件事:那套只认云厂商/CDN/WAF
|
||||
// 的 ASN,肉鸡(家宽、企业宽带、移动网络)一律显示「未识别(普通网络)」。
|
||||
// 这里回答的是「这个 IP 在哪儿」,靠的是外部 IP 库。
|
||||
//
|
||||
// auto(缺省):ip-api.com 批量打底(全球覆盖、无需 key、一次 100 个),
|
||||
// 配了 ip_geo_tencent_key 时再用腾讯位置服务精修国内 IP
|
||||
// (省市 + GCJ-02 坐标)。腾讯的 IP 定位是一 IP 一请求,
|
||||
// 拿它做批量主干会把 N 个 IP 变成 N 次 HTTP,故只做精修。
|
||||
// ipapi:只用 ip-api.com
|
||||
// tencent:只用腾讯位置服务(要求配 key,否则降级为 ipapi)
|
||||
// none:不查询,报告里只给 IP 不提地理信息(攻击者 IP 一个字节都不外发)
|
||||
//
|
||||
// 隐私边界:auto/ipapi 会把攻击者 IP 发给 ip-api.com,tencent 会发给腾讯。
|
||||
// 本报告只处理「攻击者」地址,不涉及任何用户 IP;不接受这一点请用 none。
|
||||
IPGeoProvider string `ini:"ip_geo_provider"`
|
||||
// IPGeoTencentKey 腾讯位置服务 key(lbs.qq.com 申请)。留空则跳过腾讯精修。
|
||||
// 仅用于服务端 HTTP 请求,不下发到前端 —— 与地图 JS 用的 key 不是一回事。
|
||||
IPGeoTencentKey string `ini:"ip_geo_tencent_key"`
|
||||
|
||||
// HoneypotReportEnabled 是否启用周期性安全状况汇总(周报 / 月报)邮件。
|
||||
// 缺省 true:报告本身是运维最需要的「有没有人在打、从哪儿打」,
|
||||
// 而收件人缺失时会 WARN 跳过,不会静默。
|
||||
HoneypotReportEnabled bool `ini:"honeypot_report_enabled"`
|
||||
// HoneypotReportWeekly / Monthly 分别控制周报与月报,缺省都开。
|
||||
HoneypotReportWeekly bool `ini:"honeypot_report_weekly"`
|
||||
HoneypotReportMonthly bool `ini:"honeypot_report_monthly"`
|
||||
// HoneypotReportEmail 报告收件人。留空回退 security_alert_email。
|
||||
// 单独开一个键是为了能把「随时可能发生的封禁告警」与「定期汇总」
|
||||
// 分投到不同邮箱(例如告警进手机推送邮箱、汇总进归档邮箱)。
|
||||
HoneypotReportEmail string `ini:"honeypot_report_email"`
|
||||
// HoneypotReportDir 报告文件(HTML + PNG)的存放目录,缺省 data/reports。
|
||||
// 报告里有攻击者 IP 明细,目录权限按 0700 创建。
|
||||
HoneypotReportDir string `ini:"honeypot_report_dir"`
|
||||
}
|
||||
|
||||
Server struct {
|
||||
@@ -113,12 +250,23 @@ var (
|
||||
}
|
||||
|
||||
Mail struct {
|
||||
Account string `ini:"account"`
|
||||
Password string `ini:"password"`
|
||||
Port int `ini:"port"`
|
||||
SMTP string `ini:"smtp"`
|
||||
// SkipTLSVerify 跳过 SMTP TLS 证书校验(仅自签名/内网 SMTP 需要,默认 false 校验证书)
|
||||
SkipTLSVerify bool `ini:"skip_tls_verify"`
|
||||
Account string `ini:"account"`
|
||||
Password string `ini:"password"`
|
||||
Port int `ini:"port"`
|
||||
SMTP string `ini:"smtp"`
|
||||
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 {
|
||||
@@ -126,8 +274,6 @@ var (
|
||||
Prefix string `ini:"prefix"`
|
||||
ForwardURL string `ini:"forward_url"`
|
||||
}
|
||||
// TLSInsecureSkipVerify 跳过反向代理到后端服务的 TLS 证书校验
|
||||
// (仅内网后端使用自签名证书时需要,默认 false 校验证书)
|
||||
TLSInsecureSkipVerify bool `ini:"tls_insecure_skip_verify"`
|
||||
TLSInsecureSkipVerify bool `ini:"tls_insecure_skip_verify"` // 仅内网自签名后端需要
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright 2022 E99p1ant. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package conf
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestValidateTextCensorSource 覆盖 [security] text_censor_source 与 [app] 凭据的搭配校验。
|
||||
//
|
||||
// 关注两类真实故障:
|
||||
// 1. 显式选定通道却缺凭据 —— 运行期审核必然失败,而审核失败是 fail-closed,
|
||||
// 会让整站发布/私信不可用且现象隐蔽,必须在启动阶段拦住;
|
||||
// 2. 取值拼写错误 —— 若被静默回退成 auto,运维会误以为「已经只用七牛了」。
|
||||
func TestValidateTextCensorSource(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
enabled bool
|
||||
source string
|
||||
qk, qs string
|
||||
ak, as string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "审核关闭:不校验任何通道配置", enabled: false, source: "aliyun"},
|
||||
{name: "审核关闭:非法取值也不报错", enabled: false, source: "qinui"},
|
||||
|
||||
{name: "留空:通过(历史默认行为)", enabled: true, source: ""},
|
||||
{name: "auto:通过", enabled: true, source: "auto"},
|
||||
{name: "auto:无任何凭据也通过(不阻断开箱启动)", enabled: true, source: "auto"},
|
||||
|
||||
{name: "qiniu:凭据齐备通过", enabled: true, source: "qiniu", qk: "k", qs: "s"},
|
||||
{name: "qiniu:缺 access key 报错", enabled: true, source: "qiniu", qs: "s", wantErr: true},
|
||||
{name: "qiniu:缺 access secret 报错", enabled: true, source: "qiniu", qk: "k", wantErr: true},
|
||||
|
||||
{name: "aliyun:凭据齐备通过", enabled: true, source: "aliyun", ak: "k", as: "s"},
|
||||
{name: "aliyun:缺凭据报错", enabled: true, source: "aliyun", wantErr: true},
|
||||
|
||||
{name: "大小写与空白不敏感", enabled: true, source: " QINIU ", qk: "k", qs: "s"},
|
||||
|
||||
{name: "非法取值:报错而非静默回退为 auto", enabled: true, source: "qinui", qk: "k", qs: "s", wantErr: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateTextCensorSource(tc.enabled, tc.source, tc.qk, tc.qs, tc.ak, tc.as)
|
||||
if tc.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Copyright 2026 tamakyi. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package conf
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
// 未知配置键检测:把「配了但不生效」的键在启动时喊出来。
|
||||
//
|
||||
// 为什么必须做:ini 库对未知键是静默忽略的。运维写错一个字母,
|
||||
// 程序照常启动、照常服务,但那个配置完全没生效 —— 现场没有任何线索。
|
||||
// 蜜罐保留期就踩过这个坑:写成 honeypot_retention_days(多一个 t),
|
||||
// 代码读 honeypot_retain_days,表现为「自动清理未启用」,
|
||||
// 而日志里没有任何「你的键名不认识」的提示。
|
||||
//
|
||||
// 检测方式:把 ini 文件里每一段的键名,与代码中该段结构体的 ini tag
|
||||
// 逐一比对(合法键名由 legalKeysOf 从反射中得出,是单一事实来源)。
|
||||
// 报不出来的风险点:
|
||||
// - 无 ini tag 的字段(ini 用字段名做键名)→ 保守起见不登记,
|
||||
// 会漏报但不会误报;
|
||||
// - 段名本身写错 → 整段被忽略,这由「未知段」检测覆盖(见 unknownSections)。
|
||||
|
||||
// unknownSection 描述一个无法识别的配置段或键。
|
||||
type unknownSection struct {
|
||||
// Section 段名。
|
||||
Section string
|
||||
// Keys 该段内无法识别的键名(已排序)。
|
||||
Keys []string
|
||||
// Suggestions 键名 → 最接近的合法键名建议。
|
||||
Suggestions map[string]string
|
||||
}
|
||||
|
||||
// detectUnknownSections 检测配置文件中无法识别的段与键。
|
||||
//
|
||||
// 合法段名与合法键名均由调用方通过反射传入(单一事实来源)。
|
||||
func detectUnknownSections(f *ini.File, knownSections map[string]map[string]bool) []unknownSection {
|
||||
var out []unknownSection
|
||||
if f == nil {
|
||||
return out
|
||||
}
|
||||
for _, sec := range f.Sections() {
|
||||
name := sec.Name()
|
||||
legal, known := knownSections[name]
|
||||
if !known {
|
||||
// 未登记的段:可能是拼错,也可能是本项目不关心的段。
|
||||
// 只在段内确实有键时才提示(空段无所谓)。
|
||||
keys := sec.KeyStrings()
|
||||
if len(keys) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Strings(keys)
|
||||
out = append(out, unknownSection{Section: name, Keys: keys})
|
||||
continue
|
||||
}
|
||||
var bad []string
|
||||
suggestions := map[string]string{}
|
||||
for _, key := range sec.KeyStrings() {
|
||||
if legal[key] {
|
||||
continue
|
||||
}
|
||||
bad = append(bad, key)
|
||||
if s := closestKey(key, legal); s != "" {
|
||||
suggestions[key] = s
|
||||
}
|
||||
}
|
||||
if len(bad) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Strings(bad)
|
||||
out = append(out, unknownSection{Section: name, Keys: bad, Suggestions: suggestions})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// closestKey 在合法键名中找出与 unknown 最接近的一个。
|
||||
//
|
||||
// 用「公共前缀长度 + 包含关系」这种无需导入编辑距离算法的启发式:
|
||||
// 配置键名通常是同族词(honeypot_xxx),拼错时往往仍共享较长前缀,
|
||||
// 或者其中一个是另一个的子串(retain_days vs retention_days)。
|
||||
func closestKey(unknown string, legal map[string]bool) string {
|
||||
best := ""
|
||||
bestScore := 0
|
||||
for name := range legal {
|
||||
score := 0
|
||||
if strings.Contains(unknown, name) || strings.Contains(name, unknown) {
|
||||
// 包含关系:得分按较短者的长度算,取一半折扣。
|
||||
short := len(name)
|
||||
if len(unknown) < short {
|
||||
short = len(unknown)
|
||||
}
|
||||
score = short / 2
|
||||
} else {
|
||||
score = commonPrefixLen(unknown, name)
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
best = name
|
||||
}
|
||||
}
|
||||
// 至少要有 6 个字符的公共部分才给建议,否则噪音大于价值。
|
||||
if bestScore < 6 {
|
||||
return ""
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// commonPrefixLen 返回两个字符串的公共前缀长度。
|
||||
func commonPrefixLen(a, b string) int {
|
||||
n := len(a)
|
||||
if len(b) < n {
|
||||
n = len(b)
|
||||
}
|
||||
i := 0
|
||||
for i < n && a[i] == b[i] {
|
||||
i++
|
||||
}
|
||||
return i
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
templatepkg "github.com/tamakyi/TamaBox/internal/template"
|
||||
"github.com/tamakyi/TamaBox/internal/theme"
|
||||
)
|
||||
|
||||
type EndpointType string
|
||||
@@ -65,7 +66,10 @@ func (c *Context) HasError() bool {
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return hasErr.(bool)
|
||||
// 带 ok 的断言:Data 是 map[string]interface{},任何写入方都可能塞进非 bool 值,
|
||||
// 裸断言遇到那种情况会直接 panic 掉整个请求。非 bool 一律视为「无错误」。
|
||||
v, isBool := hasErr.(bool)
|
||||
return isBool && v
|
||||
}
|
||||
|
||||
func (c *Context) SetError(err error, f ...interface{}) {
|
||||
@@ -134,6 +138,11 @@ func (c *Context) Refresh() {
|
||||
}
|
||||
|
||||
func (c *Context) JSON(data interface{}) error {
|
||||
// 显式声明内容类型:之前靠 Go 的 sniff 兜底,实测返回的是 text/plain。
|
||||
// 有 nosniff 所以不会被当 HTML 渲染,但按 Content-Type 分派的客户端
|
||||
// (fetch 的 .json()、axios、部分 SDK)会因此走错分支。
|
||||
c.ResponseWriter().Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": data,
|
||||
@@ -148,6 +157,9 @@ func (c *Context) ServerError() error {
|
||||
}
|
||||
|
||||
func (c *Context) JSONError(errorCode int, message string) error {
|
||||
// 必须在 WriteHeader 之前设置:一旦写出状态码,头就锁定了。
|
||||
c.ResponseWriter().Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
|
||||
span := trace.SpanFromContext(c.Request().Context())
|
||||
|
||||
resp := map[string]interface{}{
|
||||
@@ -239,6 +251,19 @@ func Contexter() flamego.Handler {
|
||||
isUserPage := strings.HasPrefix(path, "/user") || strings.HasPrefix(path, "/_/")
|
||||
c.Data["IsUserPage"] = isUserPage
|
||||
|
||||
// 页面作用域:让同一套主题能在不同页面摆不同装饰(箱主页/问题页/设置页…)。
|
||||
// 模板通过 <html data-page-scope> 输出,主题 css 用
|
||||
// html[data-theme-style="x"][data-page-scope="y"] 覆盖 --tb-deco 等令牌。
|
||||
// 未知路径为空串,此时只应用全局装饰,行为与引入该机制前完全一致。
|
||||
c.Data["PageScope"] = pageScope(path)
|
||||
|
||||
// 主题:/user 后台是箱主本人的页面,跟随本人配置;
|
||||
// /_/{domain} 的箱主主题由 question.Pager 覆盖(访客看到的是箱主的选择)。
|
||||
// 空串(未选且站点无默认主题)= 不加载主题 css。
|
||||
if isThemeScope(path) && c.IsLogged {
|
||||
c.Data["ThemeSlug"] = theme.Resolve(c.User.Theme)
|
||||
}
|
||||
|
||||
// SEO:仅公开页(首页/注册/登录/explore)允许被搜索引擎收录;
|
||||
// 提问内容(提问箱页、单题页)、密码页、私信、用户后台等隐私页一律 noindex。
|
||||
isPublicPage := path == "/" || path == "/register" || path == "/login" || strings.HasPrefix(path, "/explore")
|
||||
@@ -284,6 +309,8 @@ func Contexter() flamego.Handler {
|
||||
|
||||
c.Data["CurrentURI"] = ctx.Request().Request.RequestURI
|
||||
c.Data["ExternalURL"] = conf.App.ExternalURL
|
||||
// 静态资源版本号:base.css 与主题 css 共用同一个值,改样式只需改 conf.StaticVersion。
|
||||
c.Data["StaticVersion"] = conf.StaticVersion
|
||||
|
||||
// 默认头像/背景图兜底:未设置自定义头像的箱主,其二维码 logo 应与页面头像一致回退到 default_avatar。
|
||||
// 模板按原始 URL 直连(easy.qrcode 以 crossOrigin:'anonymous' 加载,配合对象存储桶 CORS
|
||||
@@ -325,3 +352,63 @@ func (c *Context) Lang() string {
|
||||
}
|
||||
return i18n.Default
|
||||
}
|
||||
|
||||
// isThemeScope 判断该路径的主题是否由「当前登录用户自己的配置」决定。
|
||||
// 只覆盖 /user 后台(本人页面);提问箱相关页面由 question.Pager 按箱主注入,
|
||||
// 公开页(首页/探索/登录)保持站点默认外观,不跟随登录状态变化。
|
||||
func isThemeScope(path string) bool {
|
||||
return path == "/user" || strings.HasPrefix(path, "/user/")
|
||||
}
|
||||
|
||||
// pageScope 把请求路径归类成「主题页面作用域」。
|
||||
//
|
||||
// 目的:同一个主题在不同页面可以摆不同装饰(箱主页放人物、问题页只留光、设置页干脆不放图),
|
||||
// 而不必为每种页面复制一整套主题。模板把它输出到 <html data-page-scope="...">,
|
||||
// 主题 css 用 html[data-theme-style="x"][data-page-scope="y"] 覆盖 --tb-deco 等令牌。
|
||||
//
|
||||
// 返回空串表示「不在主题作用域内」(首页/探索/登录等公开页也可返回具体值,
|
||||
// 只是这些页面通常不带 ThemeSlug,覆盖块不会生效)。
|
||||
func pageScope(path string) string {
|
||||
switch {
|
||||
case path == "/":
|
||||
return "home"
|
||||
case path == "/explore" || strings.HasPrefix(path, "/explore/"):
|
||||
return "explore"
|
||||
case path == "/login" || strings.HasPrefix(path, "/login/"),
|
||||
path == "/register" || strings.HasPrefix(path, "/register/"):
|
||||
return "auth"
|
||||
case path == "/user" || strings.HasPrefix(path, "/user/"):
|
||||
// 题目/私聊管理列表、数据面板都是「读数据」的页面,和「设置表单」的信息密度差很多,
|
||||
// 装饰要分开配:admin 更该退到背景,profile 可以保留一点氛围。
|
||||
if strings.HasPrefix(path, "/user/questions") || strings.HasPrefix(path, "/user/votes") ||
|
||||
strings.HasPrefix(path, "/user/insights") {
|
||||
return "admin"
|
||||
}
|
||||
return "profile"
|
||||
case strings.HasPrefix(path, "/_/"):
|
||||
// /_/{domain} → 箱主页(问题列表)
|
||||
// /_/{domain}/{qid} → 单个问题
|
||||
// /_/{domain}/tag/xxx → 仍是箱主页
|
||||
rest := strings.Trim(path[len("/_/"):], "/")
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) == 2 && isAllDigits(parts[1]) {
|
||||
return "question"
|
||||
}
|
||||
return "box"
|
||||
case path == "/vote" || strings.HasPrefix(path, "/vote/"):
|
||||
return "vote"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isAllDigits(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package context
|
||||
|
||||
import "testing"
|
||||
|
||||
// 主题作用域:/user 后台必须命中(否则设置页、题目管理页拿不到自己的主题),
|
||||
// 其余路径不得命中(提问箱页面由 question.Pager 按箱主注入,公开页保持站点默认)。
|
||||
func TestIsThemeScope(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"/user": true,
|
||||
"/user/": true,
|
||||
"/user/profile": true,
|
||||
"/user/questions": true,
|
||||
"/user/votes/1/results": true,
|
||||
"/username": false, // 前缀相近但不是用户后台
|
||||
"/_/admin6": false,
|
||||
"/_/admin6/share-card": false,
|
||||
"/": false,
|
||||
"/explore": false,
|
||||
"/vote/abc": false,
|
||||
"/api/v1/user/admin6": false,
|
||||
"/api/v1/user": false,
|
||||
"/deactivated": false,
|
||||
}
|
||||
|
||||
for path, want := range cases {
|
||||
if got := isThemeScope(path); got != want {
|
||||
t.Errorf("isThemeScope(%q) = %v, want %v", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 页面作用域:主题按页面分别摆装饰靠这个值区分。
|
||||
// 重点是把「箱主页 / 单题页 / 题目管理页」分清楚 —— 三者路径前缀很像。
|
||||
func TestPageScope(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/": "home",
|
||||
"/explore": "explore",
|
||||
"/explore/users": "explore",
|
||||
"/login": "auth",
|
||||
"/register": "auth",
|
||||
"/user": "profile",
|
||||
"/user/profile": "profile",
|
||||
"/user/questions": "admin", // 题目列表:信息密,装饰要退
|
||||
"/user/votes/1/results": "admin",
|
||||
"/user/insights": "admin", // 数据面板同属「读数据」
|
||||
"/_/admin6": "box",
|
||||
"/_/admin6/": "box",
|
||||
"/_/admin6/tag/杂谈": "box",
|
||||
"/_/admin6/answer": "box", // 非数字段 → 不是单题页
|
||||
"/_/admin6/123": "question",
|
||||
"/_/admin6/123/": "question",
|
||||
"/vote/abc123": "vote",
|
||||
"/username": "",
|
||||
"/_": "",
|
||||
"/deactivated": "",
|
||||
"/api/v1/user/admin6": "",
|
||||
"/api/v1/user/admin6/questions": "",
|
||||
}
|
||||
|
||||
for path, want := range cases {
|
||||
if got := pageScope(path); got != want {
|
||||
t.Errorf("pageScope(%q) = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
// Copyright 2022 E99p1ant. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SiteAnalyticsStore 提供「全站公开数据页」所需的聚合查询。
|
||||
//
|
||||
// 与 internal/stats 的分工:本层只做数据库聚合(纯查询,不缓存、不生成文案),
|
||||
// 缓存、补齐空日期与展示模型都在 stats 层。这样聚合 SQL 可以脱离 HTTP 单测。
|
||||
type SiteAnalyticsStore interface {
|
||||
SiteAnalytics(ctx context.Context, now time.Time) (*SiteAnalytics, error)
|
||||
// TodayCounts 单独取「今日提问 / 今日回答」。
|
||||
//
|
||||
// 它们刻意不进 SiteAnalytics:整页聚合一整天只算一次,而这两个数要跟着
|
||||
// 页面上的「实时」说明走,必须每次(或每隔几十秒)重新取。
|
||||
TodayCounts(ctx context.Context, since time.Time) (questions, answers int64, err error)
|
||||
}
|
||||
|
||||
// Analytics 全站聚合查询的包级入口,在 Init 中随其他 store 一起初始化。
|
||||
var Analytics SiteAnalyticsStore
|
||||
|
||||
// SiteAnalytics 是一次全站聚合的原始结果,所有计数都是「累及至今」或「窗口内」的口径,
|
||||
// 具体窗口由调用方传入的 now 决定(见 stats 包的 Overview)。
|
||||
type SiteAnalytics struct {
|
||||
RegisteredUsers int64
|
||||
TotalQuestions int64
|
||||
Answered int64
|
||||
Private int64
|
||||
Read int64
|
||||
Anonymous int64
|
||||
ActiveBoxes int64 // 至少收到过一条提问的箱主数
|
||||
AnsweringBoxes int64 // 至少回答过一条提问的箱主数
|
||||
TotalMessages int64
|
||||
LastQuestionAt *time.Time
|
||||
LastAnswerAt *time.Time
|
||||
|
||||
Daily []CountPoint // 近 30 天(按日)
|
||||
Monthly []CountPoint // 近 12 个月(按月)
|
||||
|
||||
// Heatmap 一周 7 天 × 24 小时的提问量,下标 0 为周日(与 EXTRACT(DOW) 对齐)。
|
||||
Heatmap [7][24]int64
|
||||
HeatmapDays int // Heatmap 的统计窗口天数,用于在页面上说明口径
|
||||
|
||||
LengthBins [6]int64 // 提问字数分布
|
||||
ReplyBins [6]int64 // 回答耗时分布(仅统计已回答的提问)
|
||||
}
|
||||
|
||||
// CountPoint 一个分组键上的计数,Key 已由数据库格式化成可直接展示的字符串。
|
||||
type CountPoint struct {
|
||||
Key string
|
||||
N int64
|
||||
}
|
||||
|
||||
func NewSiteAnalyticsStore(db *gorm.DB) SiteAnalyticsStore {
|
||||
return &siteAnalytics{db}
|
||||
}
|
||||
|
||||
type siteAnalytics struct {
|
||||
*gorm.DB
|
||||
}
|
||||
|
||||
const (
|
||||
// 趋势窗口:近 30 天按日、近 12 个月按月。
|
||||
analyticsDailyDays = 30
|
||||
analyticsMonths = 12
|
||||
// 热力图窗口:一年太贵(全表聚合),半年足以代表「全站习惯」。
|
||||
analyticsHeatmapDays = 180
|
||||
|
||||
// 字数分桶上限(左闭右开),最后一桶为 500 以上。
|
||||
lengthBinExpr = `CASE
|
||||
WHEN CHAR_LENGTH(content) < 20 THEN 0
|
||||
WHEN CHAR_LENGTH(content) < 50 THEN 1
|
||||
WHEN CHAR_LENGTH(content) < 100 THEN 2
|
||||
WHEN CHAR_LENGTH(content) < 200 THEN 3
|
||||
WHEN CHAR_LENGTH(content) < 500 THEN 4
|
||||
ELSE 5 END`
|
||||
)
|
||||
|
||||
// analyticsDialect 把两套数据库里含义相同、写法不同的表达式收敛到一处。
|
||||
//
|
||||
// 项目实际部署用 PostgreSQL(见 Init 的索引分支),MySQL 分支只是保留可用性:
|
||||
// 配置切到 MySQL 时不应整页 500,而应安静地少几个数。
|
||||
type analyticsDialect struct {
|
||||
day string
|
||||
month string
|
||||
dow string
|
||||
hour string
|
||||
replyMin string
|
||||
}
|
||||
|
||||
func analyticsDialectFor(db *gorm.DB, at time.Time) analyticsDialect {
|
||||
tz := analyticsTZOffset(at)
|
||||
|
||||
if db.Dialector.Name() == "mysql" {
|
||||
// MySQL 的 DATETIME 不带时区,驱动按 DSN 的 loc(默认 UTC)写入,
|
||||
// 因此库里存的是 UTC 墙钟,CONVERT_TZ 再搬到进程时区。用偏移而不是
|
||||
// 时区名:CONVERT_TZ 用时区名要求 mysql.time_zone* 表已导入。
|
||||
conv := func(col string) string {
|
||||
return fmt.Sprintf("CONVERT_TZ(%s, '+00:00', '%s')", col, tz)
|
||||
}
|
||||
day := conv("created_at")
|
||||
return analyticsDialect{
|
||||
day: fmt.Sprintf("DATE_FORMAT(%s, '%%Y-%%m-%%d')", day),
|
||||
month: fmt.Sprintf("DATE_FORMAT(%s, '%%Y-%%m')", day),
|
||||
dow: fmt.Sprintf("(DAYOFWEEK(%s) - 1)", day),
|
||||
hour: fmt.Sprintf("HOUR(%s)", day),
|
||||
// 回答耗时以 answered_at 为准。历史行该列为空(加列之前回答的),
|
||||
// 只能退回 updated_at —— 那部分含已读/置顶带来的误差,无法追溯修正。
|
||||
replyMin: "TIMESTAMPDIFF(MINUTE, created_at, COALESCE(answered_at, updated_at))",
|
||||
}
|
||||
}
|
||||
// PostgreSQL:EXTRACT 返回 numeric,显式转 int 以免扫描进 int64 时类型不匹配。
|
||||
loc := func(col string) string { return analyticsLocalize(col, at) }
|
||||
return analyticsDialect{
|
||||
day: fmt.Sprintf("to_char(%s, 'YYYY-MM-DD')", loc("created_at")),
|
||||
month: fmt.Sprintf("to_char(%s, 'YYYY-MM')", loc("created_at")),
|
||||
dow: fmt.Sprintf("CAST(EXTRACT(DOW FROM %s) AS INTEGER)", loc("created_at")),
|
||||
hour: fmt.Sprintf("CAST(EXTRACT(HOUR FROM %s) AS INTEGER)", loc("created_at")),
|
||||
replyMin: "CAST(EXTRACT(EPOCH FROM (COALESCE(answered_at, updated_at) - created_at)) / 60 AS INTEGER)",
|
||||
}
|
||||
}
|
||||
|
||||
// analyticsLocalize 把带时区的列转成「Go 进程所在时区的墙钟时间」再参与日期分组。
|
||||
//
|
||||
// 不能依赖数据库会话时区:PG 官方镜像默认 timezone=UTC,而 Go 侧按容器本地时区
|
||||
// (Dockerfile 里写的是 Asia/Shanghai)切「今天」。两边不一致时,日报表与热力图会
|
||||
// 整体偏 8 小时 —— 中国时间 0–8 点的提问被算进前一天,且和页面顶部「今日」对不上。
|
||||
// 这类偏差肉眼看不出来(数字本身都合理),只能靠显式换算消除。
|
||||
//
|
||||
// 用固定偏移而不是 IANA 名:Go 在容器未设 TZ 时会给出 "Local",PG 不认这个名字。
|
||||
// 中国无夏令时,固定偏移与真实时区完全等价;有夏令时的部署在窗口内可能差 1 小时。
|
||||
func analyticsLocalize(col string, at time.Time) string {
|
||||
return fmt.Sprintf("%s AT TIME ZONE INTERVAL '%s'", col, analyticsTZOffset(at))
|
||||
}
|
||||
|
||||
// analyticsTZOffset 返回 +08:00 这样的偏移字面量。
|
||||
func analyticsTZOffset(at time.Time) string {
|
||||
_, off := at.Zone()
|
||||
sign := "+"
|
||||
if off < 0 {
|
||||
sign, off = "-", -off
|
||||
}
|
||||
return fmt.Sprintf("%s%02d:%02d", sign, off/3600, off%3600/60)
|
||||
}
|
||||
|
||||
func (db *siteAnalytics) SiteAnalytics(ctx context.Context, now time.Time) (*SiteAnalytics, error) {
|
||||
loc := now.Location()
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||
dailySince := startOfDay.AddDate(0, 0, -(analyticsDailyDays - 1))
|
||||
// 先归到当月 1 号再回退,避免月底 AddDate 溢出(3-31 减 11 个月会滚到 3-03)。
|
||||
monthSince := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc).AddDate(0, -(analyticsMonths - 1), 0)
|
||||
heatmapSince := startOfDay.AddDate(0, 0, -analyticsHeatmapDays)
|
||||
|
||||
out := &SiteAnalytics{HeatmapDays: analyticsHeatmapDays}
|
||||
|
||||
if err := db.aggregateTotals(ctx, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.aggregateTrends(ctx, out, now, dailySince, monthSince); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.aggregateHeatmap(ctx, out, now, heatmapSince); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.aggregateContent(ctx, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.WithContext(ctx).Model(&User{}).Count(&out.RegisteredUsers).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "count registered users")
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Message{}).Count(&out.TotalMessages).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "count messages")
|
||||
}
|
||||
|
||||
// 「今日」两项不在这里查:本函数的结果整天缓存,把会变的数字放进来
|
||||
// 会让页面顶着一个「实时」的说明显示一整天不变的值。见 TodayCounts。
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TodayCounts 取今日提问数与今日回答数。
|
||||
//
|
||||
// 口径:
|
||||
// - 今日提问 = created_at 落在今天的提问,走 idx_questions_created_at;
|
||||
// - 今日回答 = answered_at 落在今天的提问,即「今天首次被回答」。
|
||||
// 不能拿 updated_at 当回答时间:置顶、公开/私密切换、标记已读都会刷新它
|
||||
// (GORM 的 Update 一律更新 updated_at),箱主晚上整理一批三个月前的老问题
|
||||
// —— 一次回答都没有 —— 页面上「今日回答」就会凭空多出几十条。
|
||||
//
|
||||
// answered_at 没有索引,这一条是全表过滤(与改动前的 updated_at 同级别);
|
||||
// 十万行量级约几十毫秒,页面另有 60 秒缓存,够用。真慢了再加
|
||||
// idx_questions_answered_at,不必为这个查询动表结构。
|
||||
func (db *siteAnalytics) TodayCounts(ctx context.Context, since time.Time) (int64, int64, error) {
|
||||
var questions, answers int64
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Where("created_at >= ?", since).Count(&questions).Error; err != nil {
|
||||
return 0, 0, errors.Wrap(err, "count today's questions")
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Where("answer <> '' AND answered_at >= ?", since).Count(&answers).Error; err != nil {
|
||||
return 0, 0, errors.Wrap(err, "count today's answers")
|
||||
}
|
||||
return questions, answers, nil
|
||||
}
|
||||
|
||||
// aggregateTotals 用一条 SQL 取回所有「不分时间窗口」的计数。
|
||||
//
|
||||
// 用 SUM(CASE WHEN ...) 而不是 COUNT(*) FILTER (WHERE ...):前者 MySQL 也支持,
|
||||
// 后者只在 PostgreSQL 上可用。
|
||||
func (db *siteAnalytics) aggregateTotals(ctx context.Context, out *SiteAnalytics) error {
|
||||
var row struct {
|
||||
Total int64
|
||||
Answered int64
|
||||
Private int64
|
||||
Read int64
|
||||
Anonymous int64
|
||||
ActiveBoxes int64 `gorm:"column:active_boxes"`
|
||||
AnsweringBoxes int64 `gorm:"column:answering_boxes"`
|
||||
LastQ *time.Time `gorm:"column:last_q"`
|
||||
LastA *time.Time `gorm:"column:last_a"`
|
||||
}
|
||||
err := db.WithContext(ctx).Model(&Question{}).
|
||||
Select(`COUNT(*) AS total,
|
||||
COALESCE(SUM(CASE WHEN answer <> '' THEN 1 ELSE 0 END), 0) AS answered,
|
||||
COALESCE(SUM(CASE WHEN is_private THEN 1 ELSE 0 END), 0) AS private,
|
||||
COALESCE(SUM(CASE WHEN is_read OR answer <> '' THEN 1 ELSE 0 END), 0) AS read,
|
||||
COALESCE(SUM(CASE WHEN asker_user_id = 0 THEN 1 ELSE 0 END), 0) AS anonymous,
|
||||
COUNT(DISTINCT user_id) AS active_boxes,
|
||||
COUNT(DISTINCT CASE WHEN answer <> '' THEN user_id END) AS answering_boxes,
|
||||
MAX(created_at) AS last_q,
|
||||
MAX(CASE WHEN answer <> '' THEN COALESCE(answered_at, updated_at) END) AS last_a`).
|
||||
Scan(&row).Error
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "aggregate question totals")
|
||||
}
|
||||
|
||||
out.TotalQuestions = row.Total
|
||||
out.Answered = row.Answered
|
||||
out.Private = row.Private
|
||||
out.Read = row.Read
|
||||
out.Anonymous = row.Anonymous
|
||||
out.ActiveBoxes = row.ActiveBoxes
|
||||
out.AnsweringBoxes = row.AnsweringBoxes
|
||||
out.LastQuestionAt = row.LastQ
|
||||
out.LastAnswerAt = row.LastA
|
||||
return nil
|
||||
}
|
||||
|
||||
// aggregateTrends 按日与按月各取一次计数。未被 GROUP BY 覆盖的空日期由 stats 层补齐。
|
||||
func (db *siteAnalytics) aggregateTrends(ctx context.Context, out *SiteAnalytics, now, dailySince, monthSince time.Time) error {
|
||||
d := analyticsDialectFor(db.DB, now)
|
||||
|
||||
var daily []struct {
|
||||
Key string
|
||||
N int64
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Select(d.day+" AS key, COUNT(*) AS n").
|
||||
Where("created_at >= ?", dailySince).
|
||||
Group(d.day).Order(d.day).
|
||||
Scan(&daily).Error; err != nil {
|
||||
return errors.Wrap(err, "aggregate daily trend")
|
||||
}
|
||||
for _, row := range daily {
|
||||
out.Daily = append(out.Daily, CountPoint{Key: row.Key, N: row.N})
|
||||
}
|
||||
|
||||
var monthly []struct {
|
||||
Key string
|
||||
N int64
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Select(d.month+" AS key, COUNT(*) AS n").
|
||||
Where("created_at >= ?", monthSince).
|
||||
Group(d.month).Order(d.month).
|
||||
Scan(&monthly).Error; err != nil {
|
||||
return errors.Wrap(err, "aggregate monthly trend")
|
||||
}
|
||||
for _, row := range monthly {
|
||||
out.Monthly = append(out.Monthly, CountPoint{Key: row.Key, N: row.N})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *siteAnalytics) aggregateHeatmap(ctx context.Context, out *SiteAnalytics, now, since time.Time) error {
|
||||
d := analyticsDialectFor(db.DB, now)
|
||||
|
||||
var rows []struct {
|
||||
Dow int
|
||||
Hr int
|
||||
N int64
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Select(d.dow+" AS dow, "+d.hour+" AS hr, COUNT(*) AS n").
|
||||
Where("created_at >= ?", since).
|
||||
Group(d.dow + ", " + d.hour).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return errors.Wrap(err, "aggregate weekday/hour heatmap")
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
// 越界值直接丢弃:MySQL 的 DAYOFWEEK 或异常时区数据都可能给出 7 以上的下标,
|
||||
// 落进固定数组会 panic,而这份数据只影响一张图,不值得让整页失败。
|
||||
if row.Dow < 0 || row.Dow > 6 || row.Hr < 0 || row.Hr > 23 {
|
||||
continue
|
||||
}
|
||||
out.Heatmap[row.Dow][row.Hr] = row.N
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *siteAnalytics) aggregateContent(ctx context.Context, out *SiteAnalytics) error {
|
||||
// 内容分布只用时长差值,不涉及日期分组,时区不影响结果。
|
||||
d := analyticsDialectFor(db.DB, time.Now())
|
||||
|
||||
var lengths []struct {
|
||||
Bin int
|
||||
N int64
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Select(lengthBinExpr + " AS bin, COUNT(*) AS n").
|
||||
Group(lengthBinExpr).
|
||||
Scan(&lengths).Error; err != nil {
|
||||
return errors.Wrap(err, "aggregate content length distribution")
|
||||
}
|
||||
for _, row := range lengths {
|
||||
if row.Bin >= 0 && row.Bin < len(out.LengthBins) {
|
||||
out.LengthBins[row.Bin] = row.N
|
||||
}
|
||||
}
|
||||
|
||||
// 回答耗时:只用已回答的提问,按分钟分桶。
|
||||
// 口径注意:updated_at 也会被置顶等写操作刷新,因此这里算出的耗时偏大;
|
||||
// 展示时只呈现「24 小时内回答占比」这类粗粒度结论,不宣称精确中位数。
|
||||
replyBinExpr := fmt.Sprintf(`CASE
|
||||
WHEN %[1]s < 60 THEN 0
|
||||
WHEN %[1]s < 360 THEN 1
|
||||
WHEN %[1]s < 1440 THEN 2
|
||||
WHEN %[1]s < 4320 THEN 3
|
||||
WHEN %[1]s < 10080 THEN 4
|
||||
ELSE 5 END`, d.replyMin)
|
||||
|
||||
var replies []struct {
|
||||
Bin int
|
||||
N int64
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Select(replyBinExpr + " AS bin, COUNT(*) AS n").
|
||||
Where("answer <> ''").
|
||||
Group(replyBinExpr).
|
||||
Scan(&replies).Error; err != nil {
|
||||
return errors.Wrap(err, "aggregate reply latency distribution")
|
||||
}
|
||||
for _, row := range replies {
|
||||
if row.Bin >= 0 && row.Bin < len(out.ReplyBins) {
|
||||
out.ReplyBins[row.Bin] = row.N
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Copyright 2022 E99p1ant. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestSiteAnalytics 验证 /stats 页的聚合口径。
|
||||
//
|
||||
// 这里刻意连真库跑而不是塞 mock:整段都是数据库方言相关的 SQL
|
||||
// (to_char / EXTRACT / CHAR_LENGTH / SUM(CASE WHEN)),
|
||||
// 对着 mock 断言等于什么都没验证。
|
||||
func TestSiteAnalytics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, cleanup := newTestDB(t)
|
||||
t.Cleanup(func() {
|
||||
if err := cleanup("users", "questions", "messages"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
|
||||
store := NewSiteAnalyticsStore(db)
|
||||
questions := NewQuestionsStore(db)
|
||||
|
||||
require.NoError(t, db.Create(&User{UID: "u1", Name: "box", Domain: "boxa", Email: "[email protected]"}).Error)
|
||||
|
||||
// 一条登录用户提的、已回答的长问题
|
||||
answered, err := questions.Create(ctx, CreateQuestionOptions{
|
||||
UserID: 1, Content: strings.Repeat("字", 120), FromIP: "1.1.1.1", AskerUserID: 7,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, questions.AnswerByID(ctx, answered.ID, "答案"))
|
||||
|
||||
// 一条匿名提的私密短问题,未读未答
|
||||
_, err = questions.Create(ctx, CreateQuestionOptions{
|
||||
UserID: 1, Content: "短问题", FromIP: "1.1.1.2", IsPrivate: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 一条被软删除的:不该出现在任何数字里
|
||||
deleted, err := questions.Create(ctx, CreateQuestionOptions{
|
||||
UserID: 1, Content: "会被删掉", FromIP: "1.1.1.3",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, questions.DeleteByID(ctx, deleted.ID))
|
||||
|
||||
got, err := store.SiteAnalytics(ctx, now)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 计数
|
||||
require.EqualValues(t, 1, got.RegisteredUsers)
|
||||
require.EqualValues(t, 2, got.TotalQuestions, "软删除的提问不能计入总数")
|
||||
require.EqualValues(t, 1, got.Answered)
|
||||
require.EqualValues(t, 1, got.Private)
|
||||
require.EqualValues(t, 1, got.Read, "已回答的提问恒为已读")
|
||||
require.EqualValues(t, 1, got.Anonymous)
|
||||
require.EqualValues(t, 1, got.ActiveBoxes)
|
||||
require.EqualValues(t, 1, got.AnsweringBoxes)
|
||||
|
||||
// 今日(提问是刚才写的,必然落在今天)—— 单独取,不随整页聚合缓存
|
||||
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
todayQ, todayA, err := store.TodayCounts(ctx, dayStart)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 2, todayQ)
|
||||
require.EqualValues(t, 1, todayA)
|
||||
|
||||
// 日/月趋势里能找到今天与本月
|
||||
today := now.Format("2006-01-02")
|
||||
require.EqualValues(t, 2, countOf(got.Daily, today))
|
||||
require.EqualValues(t, 2, countOf(got.Monthly, now.Format("2006-01")))
|
||||
|
||||
// 热力图:两条提问都发生在当前这一小时
|
||||
dow := int(now.Weekday()) // Go 的 Weekday 与 EXTRACT(DOW) 都是周日 0
|
||||
require.EqualValues(t, 2, got.Heatmap[dow][now.Hour()])
|
||||
|
||||
// 字数分桶:120 字落在 100–199(下标 3),"短问题" 3 字落在 <20(下标 0)
|
||||
require.EqualValues(t, 1, got.LengthBins[0])
|
||||
require.EqualValues(t, 1, got.LengthBins[3])
|
||||
|
||||
// 回答耗时:刚回答完,落在 1 小时内(下标 0);未回答的不计入
|
||||
require.EqualValues(t, 1, got.ReplyBins[0])
|
||||
var replyTotal int64
|
||||
for _, n := range got.ReplyBins {
|
||||
replyTotal += n
|
||||
}
|
||||
require.EqualValues(t, 1, replyTotal, "耗时分布只统计已回答的提问")
|
||||
|
||||
// 最后时间戳
|
||||
require.NotNil(t, got.LastQuestionAt)
|
||||
require.NotNil(t, got.LastAnswerAt)
|
||||
}
|
||||
|
||||
// TestTodayAnswersIgnoreNonAnswerWrites 「今日回答」只数今天首次被回答的提问。
|
||||
//
|
||||
// 原来按 updated_at 算,而置顶、公开/私密切换都会刷新 updated_at:
|
||||
// 箱主今晚整理一批三个月前的老问题(一次回答都没有),页面上「今日回答」
|
||||
// 就凭空多出几十条。改成 answered_at 后,只有真的发生了「首次回答」才算。
|
||||
func TestTodayAnswersIgnoreNonAnswerWrites(t *testing.T) {
|
||||
conn, clean := newTestDB(t)
|
||||
defer clean("questions")
|
||||
|
||||
store := NewSiteAnalyticsStore(conn)
|
||||
questions := NewQuestionsStore(conn)
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Now()
|
||||
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
old := now.Add(-72 * time.Hour)
|
||||
|
||||
// 三天前提出并回答的历史问题
|
||||
legacy, err := questions.Create(ctx, CreateQuestionOptions{UserID: 1, Content: "旧问题", FromIP: "1.1.1.1"})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, questions.AnswerByID(ctx, legacy.ID, "旧答案"))
|
||||
// 把三个时间都冻回三天前(AnswerByID 会把 answered_at / updated_at 刷成当下)
|
||||
require.NoError(t, conn.Model(&Question{}).Where("id = ?", legacy.ID).UpdateColumn("created_at", old).Error)
|
||||
require.NoError(t, conn.Model(&Question{}).Where("id = ?", legacy.ID).UpdateColumn("answered_at", old).Error)
|
||||
require.NoError(t, conn.Model(&Question{}).Where("id = ?", legacy.ID).UpdateColumn("updated_at", old).Error)
|
||||
|
||||
// 今天真的答了一条
|
||||
fresh, err := questions.Create(ctx, CreateQuestionOptions{UserID: 1, Content: "新问题", FromIP: "1.1.1.2"})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, questions.AnswerByID(ctx, fresh.ID, "今天的答案"))
|
||||
|
||||
_, answers, err := store.TodayCounts(ctx, dayStart)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, answers)
|
||||
|
||||
// 箱主今晚翻旧账:置顶、切私密、再切回公开 —— 一次回答都没发生
|
||||
require.NoError(t, questions.SetPinned(ctx, legacy.ID, true))
|
||||
require.NoError(t, questions.SetPrivate(ctx, legacy.ID))
|
||||
require.NoError(t, questions.SetPublic(ctx, legacy.ID))
|
||||
|
||||
_, answers, err = store.TodayCounts(ctx, dayStart)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, answers, "置顶 / 公开切换不该算成「今日回答」")
|
||||
}
|
||||
|
||||
// TestSiteAnalyticsEmpty 空库不应报错,也不应把零值算成异常。
|
||||
func TestSiteAnalyticsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, cleanup := newTestDB(t)
|
||||
t.Cleanup(func() {
|
||||
if err := cleanup("users", "questions", "messages"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
got, err := NewSiteAnalyticsStore(db).SiteAnalytics(context.Background(), time.Now())
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, got.TotalQuestions)
|
||||
require.Zero(t, got.RegisteredUsers)
|
||||
require.Nil(t, got.LastQuestionAt)
|
||||
require.Nil(t, got.LastAnswerAt)
|
||||
|
||||
var heatTotal int64
|
||||
for _, row := range got.Heatmap {
|
||||
for _, n := range row {
|
||||
heatTotal += n
|
||||
}
|
||||
}
|
||||
require.Zero(t, heatTotal)
|
||||
}
|
||||
|
||||
func countOf(points []CountPoint, key string) int64 {
|
||||
for _, p := range points {
|
||||
if p.Key == key {
|
||||
return p.N
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// Copyright 2022 E99p1ant. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestAnsweredAtOnlySetOnFirstAnswer 回答时间只记一次。
|
||||
//
|
||||
// 背景:GORM 的 Update 会自动刷新 updated_at,而已读、置顶、公开/私密切换
|
||||
// 都走 Update。若拿 updated_at 当回答时间,回答完再点一次「已读」,
|
||||
// 耗时就会从几分钟变成几十天 —— 线上那份「6 条超过 7 天」的数据就是这么来的。
|
||||
func TestAnsweredAtOnlySetOnFirstAnswer(t *testing.T) {
|
||||
conn, clean := newTestDB(t)
|
||||
defer clean("questions")
|
||||
|
||||
createdAt := time.Now().Add(-72 * time.Hour).Truncate(time.Second)
|
||||
q := Question{UserID: 1, Content: "hello", CreatedAt: createdAt, UpdatedAt: createdAt}
|
||||
require.NoError(t, conn.Create(&q).Error)
|
||||
|
||||
store := NewQuestionsStore(conn)
|
||||
require.NoError(t, store.AnswerByID(context.Background(), q.ID, "first"))
|
||||
|
||||
var got Question
|
||||
require.NoError(t, conn.First(&got, q.ID).Error)
|
||||
require.NotNil(t, got.AnsweredAt, "首次回答必须记下 answered_at")
|
||||
firstAt := *got.AnsweredAt
|
||||
require.WithinDuration(t, time.Now(), firstAt, time.Minute)
|
||||
|
||||
// 改写回答:answered_at 不能被重置,否则「回答耗时」会跟着编辑动作变大。
|
||||
require.NoError(t, store.AnswerByID(context.Background(), q.ID, "edited"))
|
||||
require.NoError(t, conn.First(&got, q.ID).Error)
|
||||
require.NotNil(t, got.AnsweredAt)
|
||||
require.Equal(t, firstAt.Unix(), got.AnsweredAt.Unix(), "改写回答不该重置 answered_at")
|
||||
}
|
||||
|
||||
// TestAnsweredAtSetWhenAnsweringAsPrivate 强制私密那条 UPDATE 同样要记时间。
|
||||
func TestAnsweredAtSetWhenAnsweringAsPrivate(t *testing.T) {
|
||||
conn, clean := newTestDB(t)
|
||||
defer clean("questions")
|
||||
|
||||
q := Question{UserID: 1, Content: "secret"}
|
||||
require.NoError(t, conn.Create(&q).Error)
|
||||
|
||||
require.NoError(t, NewQuestionsStore(conn).AnswerByIDPrivate(context.Background(), q.ID, "answer"))
|
||||
|
||||
var got Question
|
||||
require.NoError(t, conn.First(&got, q.ID).Error)
|
||||
require.NotNil(t, got.AnsweredAt)
|
||||
require.True(t, got.IsPrivate)
|
||||
require.True(t, got.IsRead)
|
||||
}
|
||||
|
||||
// TestReplyBinsUseAnsweredAt 回答耗时以 answered_at 为准,不受后来的「已读」影响。
|
||||
//
|
||||
// 构造:72 小时前提问、10 分钟前回答(落第一档);但 updated_at 被改成当下
|
||||
// (相当于回答完又被读了一次),差值是 72 小时 = 4320 分钟,正好落到第四档。
|
||||
// 若仍在用 updated_at,这条会进第四档而不是第一档。
|
||||
func TestReplyBinsUseAnsweredAt(t *testing.T) {
|
||||
conn, clean := newTestDB(t)
|
||||
defer clean("questions")
|
||||
|
||||
now := time.Now()
|
||||
createdAt := now.Add(-72 * time.Hour)
|
||||
answeredAt := createdAt.Add(10 * time.Minute) // 提问后 10 分钟回答 → 第一档
|
||||
q := Question{UserID: 1, Content: "hi", CreatedAt: createdAt, UpdatedAt: createdAt}
|
||||
require.NoError(t, conn.Create(&q).Error)
|
||||
|
||||
// 直接写列,绕开 GORM 的 UpdatedAt 自动更新。
|
||||
require.NoError(t, conn.Exec(
|
||||
"UPDATE questions SET answer = ?, is_read = ?, answered_at = ?, updated_at = ? WHERE id = ?",
|
||||
"answer", true, answeredAt, now, q.ID).Error)
|
||||
|
||||
got, err := NewSiteAnalyticsStore(conn).SiteAnalytics(context.Background(), now)
|
||||
require.NoError(t, err)
|
||||
|
||||
var total int64
|
||||
for _, n := range got.ReplyBins {
|
||||
total += n
|
||||
}
|
||||
require.EqualValues(t, 1, total)
|
||||
require.EqualValues(t, 1, got.ReplyBins[0], "10 分钟的回答应落在第一档")
|
||||
require.EqualValues(t, 0, got.ReplyBins[4], "不该被 pushed 到当下的 updated_at 带偏")
|
||||
|
||||
// 「最后一条回答」同理:取 answered_at 而不是被刷新过的 updated_at。
|
||||
require.NotNil(t, got.LastAnswerAt)
|
||||
require.WithinDuration(t, answeredAt, *got.LastAnswerAt, time.Minute)
|
||||
}
|
||||
|
||||
// TestReplyBinsFallBackToUpdatedAt 加列之前回答的历史行 answered_at 为空,
|
||||
// 退回 updated_at,不能让这些提问从分布里消失。
|
||||
func TestReplyBinsFallBackToUpdatedAt(t *testing.T) {
|
||||
conn, clean := newTestDB(t)
|
||||
defer clean("questions")
|
||||
|
||||
now := time.Now()
|
||||
// 10 天前提问、9 天前处理:差值 24 小时(1440 分钟),落在第三档。
|
||||
createdAt := now.Add(-10 * 24 * time.Hour)
|
||||
updatedAt := now.Add(-9 * 24 * time.Hour)
|
||||
q := Question{UserID: 1, Content: "old", CreatedAt: createdAt, UpdatedAt: createdAt}
|
||||
require.NoError(t, conn.Create(&q).Error)
|
||||
require.NoError(t, conn.Exec(
|
||||
"UPDATE questions SET answer = ?, answered_at = NULL, updated_at = ? WHERE id = ?",
|
||||
"old answer", updatedAt, q.ID).Error)
|
||||
|
||||
got, err := NewSiteAnalyticsStore(conn).SiteAnalytics(context.Background(), now)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, got.Answered)
|
||||
require.EqualValues(t, 1, got.ReplyBins[3], "历史行应退回 updated_at 计入分布")
|
||||
}
|
||||
|
||||
// TestQuestionStatsLastAnswerIgnoresLaterEdits 「最后一个回答」不能跟着置顶/公开切换跑。
|
||||
//
|
||||
// QuestionStats 仍被公开接口 /api/v1/site-stats 使用。它原来取 MAX(updated_at),
|
||||
// 而 updated_at 会被 SetPinned / SetPrivate / SetPublic / SetRead 刷新,
|
||||
// 于是箱主整理一下旧问题(哪怕一次回答都没发生),接口就说「刚刚有人回答了」。
|
||||
func TestQuestionStatsLastAnswerIgnoresLaterEdits(t *testing.T) {
|
||||
conn, clean := newTestDB(t)
|
||||
defer clean("questions")
|
||||
|
||||
store := NewQuestionsStore(conn)
|
||||
ctx := context.Background()
|
||||
now := time.Now().Truncate(time.Second)
|
||||
answeredAt := now.Add(-9 * 24 * time.Hour)
|
||||
|
||||
q := Question{UserID: 1, Content: "q", CreatedAt: answeredAt.Add(-time.Hour), UpdatedAt: answeredAt}
|
||||
require.NoError(t, conn.Create(&q).Error)
|
||||
require.NoError(t, store.AnswerByID(ctx, q.ID, "answer"))
|
||||
// AnswerByID 会把两个时间刷成当下,冻回真实回答时刻(模拟一条正常的历史数据)。
|
||||
require.NoError(t, conn.Model(&Question{}).Where("id = ?", q.ID).
|
||||
UpdateColumn("updated_at", answeredAt).Error)
|
||||
require.NoError(t, conn.Model(&Question{}).Where("id = ?", q.ID).
|
||||
UpdateColumn("answered_at", answeredAt).Error)
|
||||
|
||||
// 全是「没回答任何东西」的写操作。
|
||||
require.NoError(t, store.SetPinned(ctx, q.ID, true))
|
||||
require.NoError(t, store.SetPrivate(ctx, q.ID))
|
||||
require.NoError(t, store.SetPublic(ctx, q.ID))
|
||||
require.NoError(t, store.SetRead(ctx, q.ID))
|
||||
|
||||
qs, err := store.QuestionStats(ctx)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, qs.LastAnswerAt)
|
||||
require.WithinDuration(t, answeredAt, *qs.LastAnswerAt, time.Minute,
|
||||
"最后一个回答被置顶/公开切换刷新过的 updated_at 带偏了")
|
||||
}
|
||||
|
||||
// TestQuestionStatsLastAnswerFallsBackToUpdatedAt 加列之前的历史行 answered_at 为空,
|
||||
// 退回 updated_at —— 与改动前的行为一致,不能让这些行从统计里消失。
|
||||
func TestQuestionStatsLastAnswerFallsBackToUpdatedAt(t *testing.T) {
|
||||
conn, clean := newTestDB(t)
|
||||
defer clean("questions")
|
||||
|
||||
answeredAt := time.Now().Add(-5 * 24 * time.Hour).Truncate(time.Second)
|
||||
require.NoError(t, conn.Exec(
|
||||
"INSERT INTO questions (user_id, content, answer, is_private, is_read, is_pinned, created_at, updated_at) VALUES (1,'legacy','a',false,true,false,?,?)",
|
||||
answeredAt.Add(-time.Hour), answeredAt).Error)
|
||||
|
||||
qs, err := NewQuestionsStore(conn).QuestionStats(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, qs.LastAnswerAt)
|
||||
require.WithinDuration(t, answeredAt, *qs.LastAnswerAt, time.Second)
|
||||
}
|
||||
|
||||
// TestBackfillAnsweredAt 历史行回填:拿 updated_at 冻结一次,之后不再随已读漂移。
|
||||
func TestBackfillAnsweredAt(t *testing.T) {
|
||||
conn, clean := newTestDB(t)
|
||||
defer clean("questions")
|
||||
|
||||
// 列由 AutoMigrate 建好,HasColumn 必须能认出来(启动时靠它判断「是不是刚加列」)。
|
||||
require.True(t, conn.Migrator().HasColumn(&Question{}, "answered_at"))
|
||||
|
||||
now := time.Now()
|
||||
createdAt := now.Add(-20 * 24 * time.Hour)
|
||||
answeredAtOld := now.Add(-19 * 24 * time.Hour)
|
||||
|
||||
// 一条历史已回答(answered_at 为空),一条未回答(不该被回填)。
|
||||
require.NoError(t, conn.Exec(
|
||||
"INSERT INTO questions (user_id, content, answer, is_private, is_read, is_pinned, created_at, updated_at) VALUES (1,'old','a',false,true,false,?,?)",
|
||||
createdAt, answeredAtOld).Error)
|
||||
require.NoError(t, conn.Exec(
|
||||
"INSERT INTO questions (user_id, content, answer, is_private, is_read, is_pinned, created_at, updated_at) VALUES (1,'open','',false,false,false,?,?)",
|
||||
createdAt, answeredAtOld).Error)
|
||||
|
||||
require.NoError(t, backfillAnsweredAt(conn))
|
||||
|
||||
var answered Question
|
||||
require.NoError(t, conn.Where("content = ?", "old").First(&answered).Error)
|
||||
require.NotNil(t, answered.AnsweredAt)
|
||||
require.WithinDuration(t, answeredAtOld, *answered.AnsweredAt, time.Second)
|
||||
|
||||
var open Question
|
||||
require.NoError(t, conn.Where("content = ?", "open").First(&open).Error)
|
||||
require.Nil(t, open.AnsweredAt, "未回答的提问不该被回填")
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright 2026 tamakyi. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// BanRecord 是一次自动封禁的落库记录。
|
||||
//
|
||||
// 为什么需要它:自动封禁的生效状态是**进程内存**里的(见 internal/security/autoban),
|
||||
// 重启即清零,外部完全看不见。线上出现过「邮件说封了、实际还能访问」的排查困境 ——
|
||||
// 当时没有任何东西能说明封的是哪个键、因为什么、到什么时候解除,只能靠翻日志猜。
|
||||
//
|
||||
// 这张表是「封禁事实」的账本,不参与判定:IsBanned 仍然只读内存,
|
||||
// 表只用于事后解释与取证。写入失败绝不能影响封禁动作本身。
|
||||
//
|
||||
// 增长:每个键在封禁期内不会重复建封(autoban 命中已封禁直接拒绝),
|
||||
// 所以行数量级 = 封禁次数,不需要自动清理。要清可手动 DELETE。
|
||||
type BanRecord struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
|
||||
// BanKey 与 autoban.IsBanned 的判断口径完全一致(shared.LimitKey,
|
||||
// IPv6 折成 /64),否则账本与判定会各说一套。
|
||||
BanKey string `gorm:"size:64;index" json:"ban_key"`
|
||||
// Reason 是给人看的原因文案(敏感路径探测 / 蜜罐提交凭据)。
|
||||
Reason string `gorm:"size:64" json:"reason"`
|
||||
// DurationMinutes 与 ExpiresAt 一起回答「封多久、什么时候自动解除」。
|
||||
DurationMinutes int `gorm:"not null;default:0" json:"duration_minutes"`
|
||||
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
|
||||
}
|
||||
|
||||
// TableName 固定表名,避免 GORM 复数化差异。
|
||||
func (BanRecord) TableName() string { return "ban_records" }
|
||||
|
||||
// BanRecords 自动封禁记录的全局 store,由 db.Init 装配。
|
||||
var BanRecords BanRecordStore
|
||||
|
||||
// BanRecordStore 封禁记录的读写。
|
||||
type BanRecordStore interface {
|
||||
// Create 追加一条封禁记录。
|
||||
Create(ctx context.Context, record *BanRecord) error
|
||||
// ListSince 返回 since 之后的记录,按时间倒序,最多 limit 条。
|
||||
ListSince(ctx context.Context, since time.Time, limit int) ([]*BanRecord, error)
|
||||
}
|
||||
|
||||
type banRecords struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewBanRecordsStore 创建封禁记录 store。
|
||||
func NewBanRecordsStore(db *gorm.DB) BanRecordStore {
|
||||
return &banRecords{db: db}
|
||||
}
|
||||
|
||||
func (s *banRecords) Create(ctx context.Context, record *BanRecord) error {
|
||||
return s.db.WithContext(ctx).Create(record).Error
|
||||
}
|
||||
|
||||
func (s *banRecords) ListSince(ctx context.Context, since time.Time, limit int) ([]*BanRecord, error) {
|
||||
var records []*BanRecord
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("created_at >= ?", since).
|
||||
Order("created_at DESC").
|
||||
Limit(limit).
|
||||
Find(&records).Error
|
||||
return records, err
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright 2026 tamakyi. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestBanRecordStore 覆盖自动封禁账本的写入与检索。
|
||||
//
|
||||
// 这张表不参与判定(IsBanned 只读内存),所以测试只锁两件事:
|
||||
// ① 记录能落库并按时间倒序取回;② 到期时间与时长原样保留 ——
|
||||
// 事后排查全依赖这两个字段回答「封了多久、什么时候解」。
|
||||
func TestBanRecordStore(t *testing.T) {
|
||||
database, cleanup := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Cleanup(func() {
|
||||
if err := cleanup("ban_records"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
store := NewBanRecordsStore(database)
|
||||
now := time.Now()
|
||||
|
||||
rows := []*BanRecord{
|
||||
{BanKey: "1.2.3.4", Reason: "敏感路径探测", DurationMinutes: 60, ExpiresAt: now.Add(time.Hour), CreatedAt: now.Add(-2 * time.Hour)},
|
||||
{BanKey: "2001:db8::/64", Reason: "蜜罐提交凭据", DurationMinutes: 1440, ExpiresAt: now.Add(24 * time.Hour), CreatedAt: now.Add(-10 * time.Minute)},
|
||||
}
|
||||
for _, r := range rows {
|
||||
require.NoError(t, store.Create(ctx, r))
|
||||
}
|
||||
|
||||
got, err := store.ListSince(ctx, now.Add(-3*time.Hour), 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 2)
|
||||
|
||||
// 时间倒序:最新的一条在最前
|
||||
require.Equal(t, "2001:db8::/64", got[0].BanKey)
|
||||
require.Equal(t, "蜜罐提交凭据", got[0].Reason)
|
||||
require.Equal(t, 1440, got[0].DurationMinutes)
|
||||
require.False(t, got[0].ExpiresAt.IsZero(), "到期时间必须保留,否则无法回答「什么时候解」")
|
||||
|
||||
// 时间窗过滤生效
|
||||
recent, err := store.ListSince(ctx, now.Add(-30*time.Minute), 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, recent, 1)
|
||||
require.Equal(t, "2001:db8::/64", recent[0].BanKey)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// Copyright 2026 tamakyi. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// BoxAnalyticsStore 是「箱主数据面板」里提问侧的聚合查询(访问侧见 BoxViewsStore)。
|
||||
//
|
||||
// 与 SiteAnalyticsStore 分开:那个是站方看的全站数据(不含任何单箱维度),
|
||||
// 这个是箱主看自己的。两边的 SQL 都按 user_id 走,混在一个接口里会让
|
||||
// 「全站聚合整天缓存」和「箱主实时看自己的」两种缓存策略纠缠在一起。
|
||||
type BoxAnalyticsStore interface {
|
||||
// BoxTrend 返回 [from, to] 内每天的提问数与回答数。只返回有数据的天,缺日由上层补零。
|
||||
BoxTrend(ctx context.Context, userID uint, from, to time.Time) ([]BoxDayCount, error)
|
||||
// BoxHours 返回 since 之后该箱收到提问的 24 小时分布(下标 0..23)。
|
||||
BoxHours(ctx context.Context, userID uint, since time.Time) ([24]int64, error)
|
||||
// BoxFunnel 返回该箱的提问处理漏斗。
|
||||
BoxFunnel(ctx context.Context, userID uint) (*BoxFunnel, error)
|
||||
}
|
||||
|
||||
// BoxDayCount 某一天的提问 / 回答计数。
|
||||
type BoxDayCount struct {
|
||||
Day time.Time `gorm:"column:day"`
|
||||
Questions int64 `gorm:"column:questions"`
|
||||
Answers int64 `gorm:"column:answers"`
|
||||
}
|
||||
|
||||
// BoxFunnel 箱主的提问处理漏斗与响应速度。
|
||||
type BoxFunnel struct {
|
||||
Total int64 // 全部提问
|
||||
Answered int64 // 已回答
|
||||
ReadOnly int64 // 已读未答
|
||||
Unread int64 // 未读未答
|
||||
Private int64 // 私密提问
|
||||
Anonymous int64 // 匿名(未登录)提问
|
||||
Pinned int64 // 置顶
|
||||
// 响应速度只统计已回答且 answered_at 有值的行(历史行退回 updated_at,
|
||||
// 会有偏高,见 answered_at 的注释)。
|
||||
RepliedIn24h int64
|
||||
AvgReplyHour float64
|
||||
// LastQuestionAt / LastAnswerAt 用于「你多久没看提问箱了」这类提示。
|
||||
LastQuestionAt *time.Time
|
||||
LastAnswerAt *time.Time
|
||||
}
|
||||
|
||||
var BoxAnalytics BoxAnalyticsStore
|
||||
|
||||
var _ BoxAnalyticsStore = (*boxAnalytics)(nil)
|
||||
|
||||
func NewBoxAnalyticsStore(db *gorm.DB) BoxAnalyticsStore {
|
||||
return &boxAnalytics{db}
|
||||
}
|
||||
|
||||
type boxAnalytics struct {
|
||||
*gorm.DB
|
||||
}
|
||||
|
||||
func (db *boxAnalytics) BoxTrend(ctx context.Context, userID uint, from, to time.Time) ([]BoxDayCount, error) {
|
||||
d := analyticsDialectFor(db.DB, from)
|
||||
from, to = dayOnly(from), dayOnly(to).AddDate(0, 0, 1)
|
||||
|
||||
// 提问按 created_at 分组;回答必须按 answered_at(空则退回 updated_at)分组。
|
||||
// 两条 SQL 的 day 表达式不同:d.day 是 created_at 的日期,回答那条要基于
|
||||
// COALESCE(answered_at, updated_at),否则「40 天前创建、昨天才回答」的提问
|
||||
// 会被排除在窗口外,「今天创建、明天回答」的会被记到今天 —— 趋势图里的
|
||||
// 「每日回答数」就不是「某天被回答了多少」。
|
||||
answerDay := analyticsLocalize("COALESCE(answered_at, updated_at)", from)
|
||||
if db.Dialector.Name() == "mysql" {
|
||||
answerDay = fmt.Sprintf("DATE_FORMAT(CONVERT_TZ(COALESCE(answered_at, updated_at), '+00:00', '%s'), '%%Y-%%m-%%d')", analyticsTZOffset(from))
|
||||
} else {
|
||||
answerDay = fmt.Sprintf("to_char(%s, 'YYYY-MM-DD')", answerDay)
|
||||
}
|
||||
|
||||
// 提问与回答分两条 SQL 再在内存里合并:一条 SQL 里同时按 created_at 和
|
||||
// answered_at 分组要写成两条子查询再 FULL JOIN,MySQL 没有 FULL JOIN,
|
||||
// 而两张结果集都很小,合并成本可以忽略。
|
||||
type row struct {
|
||||
Key string
|
||||
N int64
|
||||
}
|
||||
|
||||
var qs, as []row
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Select(d.day+" AS key, COUNT(*) AS n").
|
||||
Where("user_id = ? AND created_at >= ? AND created_at < ?", userID, from, to).
|
||||
Group(d.day).
|
||||
Scan(&qs).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "aggregate box questions by day")
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Select(answerDay+" AS key, COUNT(*) AS n").
|
||||
Where("user_id = ? AND answer <> '' AND COALESCE(answered_at, updated_at) >= ? AND COALESCE(answered_at, updated_at) < ?",
|
||||
userID, from, to).
|
||||
Group(answerDay).
|
||||
Scan(&as).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "aggregate box answers by day")
|
||||
}
|
||||
|
||||
// 用 map 合并,最后仍交给上层按日期补零(上层最清楚要展示哪些天)。
|
||||
byDay := make(map[string]*BoxDayCount, len(qs))
|
||||
order := make([]string, 0, len(qs)+len(as))
|
||||
get := func(key string) *BoxDayCount {
|
||||
if v, ok := byDay[key]; ok {
|
||||
return v
|
||||
}
|
||||
t, err := time.ParseInLocation("2006-01-02", key, from.Location())
|
||||
if err != nil {
|
||||
t = from
|
||||
}
|
||||
v := &BoxDayCount{Day: t}
|
||||
byDay[key] = v
|
||||
order = append(order, key)
|
||||
return v
|
||||
}
|
||||
for _, r := range qs {
|
||||
get(r.Key).Questions = r.N
|
||||
}
|
||||
for _, r := range as {
|
||||
get(r.Key).Answers = r.N
|
||||
}
|
||||
|
||||
out := make([]BoxDayCount, 0, len(order))
|
||||
for _, key := range order {
|
||||
out = append(out, *byDay[key])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (db *boxAnalytics) BoxHours(ctx context.Context, userID uint, since time.Time) ([24]int64, error) {
|
||||
var out [24]int64
|
||||
d := analyticsDialectFor(db.DB, since)
|
||||
|
||||
var rows []struct {
|
||||
Hr int
|
||||
N int64
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Select(d.hour+" AS hr, COUNT(*) AS n").
|
||||
Where("user_id = ? AND created_at >= ?", userID, since).
|
||||
Group(d.hour).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return out, errors.Wrap(err, "aggregate box question hours")
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.Hr >= 0 && r.Hr < 24 {
|
||||
out[r.Hr] = r.N
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (db *boxAnalytics) BoxFunnel(ctx context.Context, userID uint) (*BoxFunnel, error) {
|
||||
out := &BoxFunnel{}
|
||||
|
||||
var totals struct {
|
||||
Total int64 `gorm:"column:total"`
|
||||
Answered int64 `gorm:"column:answered"`
|
||||
ReadOnly int64 `gorm:"column:read_only"`
|
||||
Unread int64 `gorm:"column:unread"`
|
||||
Private int64 `gorm:"column:private"`
|
||||
Anonymous int64 `gorm:"column:anonymous"`
|
||||
Pinned int64 `gorm:"column:pinned"`
|
||||
LastQ *time.Time `gorm:"column:last_q"`
|
||||
LastA *time.Time `gorm:"column:last_a"`
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Select(`COUNT(*) AS total,
|
||||
COALESCE(SUM(CASE WHEN answer <> '' THEN 1 ELSE 0 END), 0) AS answered,
|
||||
COALESCE(SUM(CASE WHEN answer = '' AND is_read THEN 1 ELSE 0 END), 0) AS read_only,
|
||||
COALESCE(SUM(CASE WHEN answer = '' AND NOT is_read THEN 1 ELSE 0 END), 0) AS unread,
|
||||
COALESCE(SUM(CASE WHEN is_private THEN 1 ELSE 0 END), 0) AS private,
|
||||
COALESCE(SUM(CASE WHEN asker_user_id = 0 THEN 1 ELSE 0 END), 0) AS anonymous,
|
||||
COALESCE(SUM(CASE WHEN is_pinned THEN 1 ELSE 0 END), 0) AS pinned,
|
||||
MAX(created_at) AS last_q,
|
||||
MAX(CASE WHEN answer <> '' THEN COALESCE(answered_at, updated_at) END) AS last_a`).
|
||||
Where("user_id = ?", userID).
|
||||
Scan(&totals).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "aggregate box funnel")
|
||||
}
|
||||
out.Total = totals.Total
|
||||
out.Answered = totals.Answered
|
||||
out.ReadOnly = totals.ReadOnly
|
||||
out.Unread = totals.Unread
|
||||
out.Private = totals.Private
|
||||
out.Anonymous = totals.Anonymous
|
||||
out.Pinned = totals.Pinned
|
||||
out.LastQuestionAt = totals.LastQ
|
||||
out.LastAnswerAt = totals.LastA
|
||||
|
||||
// 响应速度:answered_at 为空的历史行退回 updated_at(口径偏大,只用于展示大致量级)。
|
||||
replyMinutes := boxReplyMinutesExpr(db.DB, out.LastAnswerAt)
|
||||
if replyMinutes == "" {
|
||||
return out, nil
|
||||
}
|
||||
var speed struct {
|
||||
In24h int64 `gorm:"column:in_24h"`
|
||||
Avg *float64 `gorm:"column:avg_min"`
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Select(fmt.Sprintf(`COALESCE(SUM(CASE WHEN %[1]s <= 1440 THEN 1 ELSE 0 END), 0) AS in_24h,
|
||||
AVG(%[1]s) AS avg_min`, replyMinutes)).
|
||||
Where("user_id = ? AND answer <> ''", userID).
|
||||
Scan(&speed).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "aggregate box reply speed")
|
||||
}
|
||||
out.RepliedIn24h = speed.In24h
|
||||
if speed.Avg != nil && *speed.Avg > 0 {
|
||||
out.AvgReplyHour = *speed.Avg / 60
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// boxReplyMinutesExpr 返回「回答耗时(分钟)」的 SQL 表达式。
|
||||
//
|
||||
// 与 analyticsDialectFor 里的 replyMin 口径一致,但这里必须能固定到箱主自己那张表:
|
||||
// 表达式里出现的列名都来自 questions 表,不需要额外限定。
|
||||
func boxReplyMinutesExpr(db *gorm.DB, at *time.Time) string {
|
||||
now := time.Now()
|
||||
if at != nil {
|
||||
now = *at
|
||||
}
|
||||
d := analyticsDialectFor(db, now)
|
||||
return d.replyMin
|
||||
}
|
||||
@@ -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, "回答日应出现在趋势结果里")
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// Copyright 2026 tamakyi. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// BoxViewsStore 记录「提问箱主页被访问了多少次」。
|
||||
//
|
||||
// 为什么要自带一张表:全站数据页(internal/stats)只统计提问/回答/用户,
|
||||
// 完全没有「有人来看过我的箱」这个维度 —— 箱主看不到自己的任何流量。
|
||||
// 曾经用过外部计数服务(busuanzi),但它把统计放在第三方、且只能显示两个数,
|
||||
// 既不可自控也无法按天看趋势,已移除。
|
||||
//
|
||||
// 口径与隐私:
|
||||
// - 一行 = 一个访客在某一天访问了某个箱(唯一索引强制),`views` 是当天的次数;
|
||||
// - PV = SUM(views),UV = 行数(因为每行已按访客去重);
|
||||
// - 访客标识是 **带日盐的 IP HMAC**,不是 IP 本身,也无法跨天关联同一人。
|
||||
// 代价是「近 7 天 UV」= 各日 UV 之和,同一人多天来访会被重复计入 —— 这是
|
||||
// 隐私与准确性的取舍,页面上按「按日去重的独立访客」表述。
|
||||
type BoxViewsStore interface {
|
||||
// Record 累加一次访问。实现为单条 UPSERT,并发安全。
|
||||
Record(ctx context.Context, opts RecordBoxViewOptions) error
|
||||
// RecordBatch 批量累加(记录器的 flush 用)。单条 SQL,避免 N 次往返。
|
||||
RecordBatch(ctx context.Context, opts []RecordBoxViewOptions) error
|
||||
// Daily 返回 [from, to] 内每天的 PV / UV,日期按天升序,缺日不补(由上层补零)。
|
||||
Daily(ctx context.Context, userID uint, from, to time.Time) ([]BoxViewDay, error)
|
||||
// TopDays 取历史上 PV 最高的若干天,供「你的最好一天」这类结论使用。
|
||||
TopDays(ctx context.Context, userID uint, limit int) ([]BoxViewDay, error)
|
||||
// FirstDay 返回最早有记录的一天;无记录时返回零值时间。
|
||||
FirstDay(ctx context.Context, userID uint) (time.Time, error)
|
||||
// Totals 返回全期累计访问量(PV / UV)。
|
||||
Totals(ctx context.Context, userID uint) (pv, uv int64, err error)
|
||||
// Purge 删除 before 之前的明细,控制表体积。统计窗口最长 30 天,
|
||||
// 但「历史最好一天」要看全部,默认保留期由调用方决定(见 insights.Retention)。
|
||||
Purge(ctx context.Context, before time.Time) (int64, error)
|
||||
}
|
||||
|
||||
// BoxView 一次「访客 × 天 × 箱」的访问记录。
|
||||
//
|
||||
// 刻意不内嵌 dbutil.Model:统计流水不需要软删除,而软删除会让唯一索引
|
||||
// (user_id, viewed_on, visitor) 失效 —— 被「删掉」的行仍然占着槽位,
|
||||
// 同一访客当天再也插不进去(GORM 软删除 + 唯一索引的经典坑)。
|
||||
type BoxView struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
UserID uint `gorm:"uniqueIndex:idx_box_views_day_visitor,priority:1;index:idx_box_views_user_day,priority:1"`
|
||||
ViewedOn time.Time `gorm:"type:date;uniqueIndex:idx_box_views_day_visitor,priority:2;index:idx_box_views_user_day,priority:2"`
|
||||
Visitor string `gorm:"size:32;uniqueIndex:idx_box_views_day_visitor,priority:3"`
|
||||
Views int64 `gorm:"not null;default:1"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// BoxViewDay 某一天聚合后的访问量。
|
||||
type BoxViewDay struct {
|
||||
// Day 当天 0 点(调用方传入的时区)。扫描出来是 date 列,loc 由数据库会话决定,
|
||||
// 上层只取 Year/Month/Day 拼 key,不依赖时分秒。
|
||||
Day time.Time `gorm:"column:viewed_on"`
|
||||
PV int64 `gorm:"column:pv"`
|
||||
UV int64 `gorm:"column:uv"`
|
||||
}
|
||||
|
||||
type RecordBoxViewOptions struct {
|
||||
UserID uint
|
||||
ViewedOn time.Time // 只有年月日有意义,写入前会被截断到当天 0 点
|
||||
Visitor string
|
||||
}
|
||||
|
||||
var BoxViews BoxViewsStore
|
||||
|
||||
var _ BoxViewsStore = (*boxViews)(nil)
|
||||
|
||||
func NewBoxViewsStore(db *gorm.DB) BoxViewsStore {
|
||||
return &boxViews{db}
|
||||
}
|
||||
|
||||
type boxViews struct {
|
||||
*gorm.DB
|
||||
}
|
||||
|
||||
// dayOnly 把时间截断到当天 0 点。用传入时间的时区,不做 UTC 归一 ——
|
||||
// 站点按本地时间切「今天」,跨时区归一会让日报表偏一天。
|
||||
func dayOnly(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
|
||||
func (db *boxViews) Record(ctx context.Context, opts RecordBoxViewOptions) error {
|
||||
return db.RecordBatch(ctx, []RecordBoxViewOptions{opts})
|
||||
}
|
||||
|
||||
// RecordBatch 单条 INSERT ... ON CONFLICT DO UPDATE 累加。
|
||||
//
|
||||
// 冲突时只把 views 加一,visited_at 之外的列不动:同一访客当天第二次访问
|
||||
// 只是「同一行还来了」,UV 不变、PV +1。
|
||||
func (db *boxViews) RecordBatch(ctx context.Context, opts []RecordBoxViewOptions) error {
|
||||
if len(opts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
rows := make([]BoxView, 0, len(opts))
|
||||
for _, o := range opts {
|
||||
if o.UserID == 0 || o.Visitor == "" {
|
||||
// 缺用户或访客标识的行没有统计价值,直接丢弃:
|
||||
// 空 Visitor 会让同一箱所有匿名访问挤进同一行,把 UV 压成 1。
|
||||
continue
|
||||
}
|
||||
rows = append(rows, BoxView{
|
||||
UserID: o.UserID,
|
||||
ViewedOn: dayOnly(o.ViewedOn),
|
||||
Visitor: o.Visitor,
|
||||
Views: 1,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 同一批里可能出现同一个 (user, day, visitor) 多次(并发请求攒到一次 flush):
|
||||
// PostgreSQL 的 ON CONFLICT 不支持在同一条 INSERT 里两次命中同一行
|
||||
// ("ON CONFLICT DO UPDATE command cannot affect row a second time")。
|
||||
// 所以先在内存里按唯一键合并,把次数累加到 Views 上。
|
||||
merged := make([]BoxView, 0, len(rows))
|
||||
index := make(map[[3]interface{}]int, len(rows))
|
||||
for _, r := range rows {
|
||||
key := [3]interface{}{r.UserID, r.ViewedOn.Format("2006-01-02"), r.Visitor}
|
||||
if i, ok := index[key]; ok {
|
||||
merged[i].Views += r.Views
|
||||
continue
|
||||
}
|
||||
index[key] = len(merged)
|
||||
merged = append(merged, r)
|
||||
}
|
||||
|
||||
err := db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}, {Name: "viewed_on"}, {Name: "visitor"}},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
// 表名限定:PG 里裸 views 在 INSERT ... ON CONFLICT 的 SET 子句里
|
||||
// 可能被当成目标列的别名解析,加上表名前缀最稳。
|
||||
"views": gorm.Expr("box_views.views + excluded.views"),
|
||||
"updated_at": now,
|
||||
}),
|
||||
}).Create(&merged).Error
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "upsert box views")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *boxViews) Daily(ctx context.Context, userID uint, from, to time.Time) ([]BoxViewDay, error) {
|
||||
var days []BoxViewDay
|
||||
err := db.WithContext(ctx).Model(&BoxView{}).
|
||||
Select("viewed_on, SUM(views) AS pv, COUNT(*) AS uv").
|
||||
Where("user_id = ? AND viewed_on >= ? AND viewed_on <= ?", userID, dayOnly(from), dayOnly(to)).
|
||||
Group("viewed_on").
|
||||
Order("viewed_on").
|
||||
Scan(&days).Error
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "aggregate box view days")
|
||||
}
|
||||
return days, nil
|
||||
}
|
||||
|
||||
func (db *boxViews) TopDays(ctx context.Context, userID uint, limit int) ([]BoxViewDay, error) {
|
||||
if limit <= 0 {
|
||||
limit = 1
|
||||
}
|
||||
var days []BoxViewDay
|
||||
err := db.WithContext(ctx).Model(&BoxView{}).
|
||||
Select("viewed_on, SUM(views) AS pv, COUNT(*) AS uv").
|
||||
Where("user_id = ?", userID).
|
||||
Group("viewed_on").
|
||||
Order("pv DESC").
|
||||
Limit(limit).
|
||||
Scan(&days).Error
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "top box view days")
|
||||
}
|
||||
return days, nil
|
||||
}
|
||||
|
||||
func (db *boxViews) FirstDay(ctx context.Context, userID uint) (time.Time, error) {
|
||||
var day *time.Time
|
||||
err := db.WithContext(ctx).Model(&BoxView{}).
|
||||
Select("MIN(viewed_on)").
|
||||
Where("user_id = ?", userID).
|
||||
Scan(&day).Error
|
||||
if err != nil {
|
||||
return time.Time{}, errors.Wrap(err, "first box view day")
|
||||
}
|
||||
if day == nil {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
return *day, nil
|
||||
}
|
||||
|
||||
func (db *boxViews) Totals(ctx context.Context, userID uint) (int64, int64, error) {
|
||||
var row struct {
|
||||
PV int64 `gorm:"column:pv"`
|
||||
UV int64 `gorm:"column:uv"`
|
||||
}
|
||||
err := db.WithContext(ctx).Model(&BoxView{}).
|
||||
Select("COALESCE(SUM(views), 0) AS pv, COUNT(*) AS uv").
|
||||
Where("user_id = ?", userID).
|
||||
Scan(&row).Error
|
||||
if err != nil {
|
||||
return 0, 0, errors.Wrap(err, "total box views")
|
||||
}
|
||||
return row.PV, row.UV, nil
|
||||
}
|
||||
|
||||
// Purge 删除 before 之前的访问明细。返回删除行数。
|
||||
//
|
||||
// 用 Delete 而非 Unscoped 也没关系 —— BoxView 没有 DeletedAt 字段,
|
||||
// GORM 看不到软删除标记,会直接发 DELETE。
|
||||
func (db *boxViews) Purge(ctx context.Context, before time.Time) (int64, error) {
|
||||
res := db.WithContext(ctx).
|
||||
Where("viewed_on < ?", dayOnly(before)).
|
||||
Delete(&BoxView{})
|
||||
if res.Error != nil {
|
||||
return 0, errors.Wrap(res.Error, "purge box views")
|
||||
}
|
||||
return res.RowsAffected, nil
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Copyright 2026 tamakyi. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBoxViews(t *testing.T) {
|
||||
db, cleanup := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
store := NewBoxViewsStore(db)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
test func(t *testing.T, ctx context.Context, store BoxViewsStore)
|
||||
}{
|
||||
{"BatchMergesSameKey", testBoxViewsBatchMergesSameKey},
|
||||
{"AccumulatesAcrossCalls", testBoxViewsAccumulatesAcrossCalls},
|
||||
{"DailyAndTotals", testBoxViewsDailyAndTotals},
|
||||
{"PurgeKeepsRecent", testBoxViewsPurgeKeepsRecent},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
if err := cleanup("box_views"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
tc.test(t, ctx, store)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 同一批里同 (user, day, visitor) 出现多次时,必须先在内存合并再写。
|
||||
//
|
||||
// PostgreSQL 的 ON CONFLICT DO UPDATE 不允许同一条 INSERT 两次命中同一行
|
||||
// ("ON CONFLICT DO UPDATE command cannot affect row a second time"),
|
||||
// 不合并的话记录器一次 flush 就会整批失败 —— 而 flush 里恰好可能包含
|
||||
// 同一访客在 2 秒内刷新三次的记录。
|
||||
func testBoxViewsBatchMergesSameKey(t *testing.T, ctx context.Context, store BoxViewsStore) {
|
||||
now := time.Date(2026, 10, 29, 15, 4, 5, 0, time.Local)
|
||||
|
||||
// 同一访客三次 + 另一个访客一次,全在同一批里
|
||||
err := store.RecordBatch(ctx, []RecordBoxViewOptions{
|
||||
{UserID: 7, ViewedOn: now, Visitor: "aaaa"},
|
||||
{UserID: 7, ViewedOn: now, Visitor: "aaaa"},
|
||||
{UserID: 7, ViewedOn: now, Visitor: "aaaa"},
|
||||
{UserID: 7, ViewedOn: now, Visitor: "bbbb"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
pv, uv, err := store.Totals(ctx, 7)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(4), pv, "三次重复访问应累加进 views")
|
||||
require.Equal(t, int64(2), uv, "同一访客当天只算一个 UV")
|
||||
}
|
||||
|
||||
func testBoxViewsAccumulatesAcrossCalls(t *testing.T, ctx context.Context, store BoxViewsStore) {
|
||||
day := time.Date(2026, 10, 28, 9, 0, 0, 0, time.Local)
|
||||
|
||||
require.NoError(t, store.Record(ctx, RecordBoxViewOptions{UserID: 3, ViewedOn: day, Visitor: "x"}))
|
||||
require.NoError(t, store.Record(ctx, RecordBoxViewOptions{UserID: 3, ViewedOn: day, Visitor: "x"}))
|
||||
require.NoError(t, store.Record(ctx, RecordBoxViewOptions{UserID: 3, ViewedOn: day, Visitor: "y"}))
|
||||
|
||||
pv, uv, err := store.Totals(ctx, 3)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(3), pv)
|
||||
require.Equal(t, int64(2), uv)
|
||||
}
|
||||
|
||||
func testBoxViewsDailyAndTotals(t *testing.T, ctx context.Context, store BoxViewsStore) {
|
||||
base := time.Date(2026, 10, 20, 12, 0, 0, 0, time.Local)
|
||||
|
||||
// 20 日:2 访客共 3 次;21 日:1 访客 1 次;22 日:无记录
|
||||
require.NoError(t, store.RecordBatch(ctx, []RecordBoxViewOptions{
|
||||
{UserID: 11, ViewedOn: base, Visitor: "a"},
|
||||
{UserID: 11, ViewedOn: base, Visitor: "a"},
|
||||
{UserID: 11, ViewedOn: base, Visitor: "b"},
|
||||
{UserID: 11, ViewedOn: base.AddDate(0, 0, 1), Visitor: "a"},
|
||||
}))
|
||||
// 另一个箱主的数据不能串进来
|
||||
require.NoError(t, store.Record(ctx, RecordBoxViewOptions{
|
||||
UserID: 12, ViewedOn: base, Visitor: "a",
|
||||
}))
|
||||
|
||||
days, err := store.Daily(ctx, 11, base, base.AddDate(0, 0, 2))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, days, 2, "没有记录的那天不返回,由上层补零")
|
||||
|
||||
require.Equal(t, "2026-10-20", days[0].Day.Format("2006-01-02"))
|
||||
require.Equal(t, int64(3), days[0].PV)
|
||||
require.Equal(t, int64(2), days[0].UV)
|
||||
|
||||
require.Equal(t, "2026-10-21", days[1].Day.Format("2006-01-02"))
|
||||
require.Equal(t, int64(1), days[1].PV)
|
||||
require.Equal(t, int64(1), days[1].UV)
|
||||
|
||||
pv, uv, err := store.Totals(ctx, 11)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(4), pv)
|
||||
require.Equal(t, int64(3), uv)
|
||||
|
||||
first, err := store.FirstDay(ctx, 11)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "2026-10-20", first.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
func testBoxViewsPurgeKeepsRecent(t *testing.T, ctx context.Context, store BoxViewsStore) {
|
||||
old := time.Now().AddDate(0, 0, -30)
|
||||
recent := time.Now()
|
||||
|
||||
require.NoError(t, store.RecordBatch(ctx, []RecordBoxViewOptions{
|
||||
{UserID: 5, ViewedOn: old, Visitor: "a"},
|
||||
{UserID: 5, ViewedOn: recent, Visitor: "b"},
|
||||
}))
|
||||
|
||||
n, err := store.Purge(ctx, time.Now().AddDate(0, 0, -7))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), n)
|
||||
|
||||
pv, uv, err := store.Totals(ctx, 5)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), pv, "只保留保留期内的行")
|
||||
require.Equal(t, int64(1), uv)
|
||||
}
|
||||
+172
-3
@@ -6,19 +6,30 @@ package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/thanhpk/randstr"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/dbutil"
|
||||
)
|
||||
|
||||
var Conversations ConversationStore
|
||||
|
||||
var (
|
||||
ErrDailyMessageLimit = errors.New("已达到每日私聊上限")
|
||||
ErrConversationLimit = errors.New("会话已达到消息上限或已关闭")
|
||||
)
|
||||
|
||||
type ConversationStore interface {
|
||||
Create(ctx context.Context, opts CreateConversationOptions) (*Conversation, error)
|
||||
// CreateWithFirstMessage 事务化地建会话 + 首条消息 + 消息计数。
|
||||
// 单独写首条消息(用于「发第一条私聊」这类一次性动作),避免失败时留下空会话。
|
||||
CreateWithFirstMessage(ctx context.Context, opts CreateConversationOptions, senderID uint, content string) (*Conversation, *Message, error)
|
||||
SendMessage(ctx context.Context, opts SendConversationMessageOptions) (*Message, error)
|
||||
GetByID(ctx context.Context, id uint) (*Conversation, error)
|
||||
GetByToken(ctx context.Context, token string) (*Conversation, error)
|
||||
GetByIDAndOwner(ctx context.Context, id, ownerID uint) (*Conversation, error)
|
||||
@@ -71,6 +82,39 @@ type CreateConversationOptions struct {
|
||||
MaxMessages int
|
||||
}
|
||||
|
||||
type SendConversationMessageOptions struct {
|
||||
ConversationID uint
|
||||
SenderID uint
|
||||
Content string
|
||||
Image *CreateMessageImageOptions
|
||||
}
|
||||
|
||||
func reserveDailyMessageTx(tx *gorm.DB, ownerID, senderID uint) error {
|
||||
var sender User
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&sender, senderID).Error; err != nil {
|
||||
return errors.Wrap(err, "lock message sender")
|
||||
}
|
||||
var owner User
|
||||
if err := tx.Select("daily_message_limit").First(&owner, ownerID).Error; err != nil {
|
||||
return errors.Wrap(err, "get owner daily message limit")
|
||||
}
|
||||
if owner.DailyMessageLimit <= 0 {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
var count int64
|
||||
if err := tx.Table("messages").Joins("JOIN conversations ON conversations.id = messages.conversation_id").
|
||||
Where("conversations.owner_id = ? AND messages.sender_id = ? AND messages.created_at >= ?", ownerID, senderID, start).
|
||||
Count(&count).Error; err != nil {
|
||||
return errors.Wrap(err, "count today messages")
|
||||
}
|
||||
if count >= int64(owner.DailyMessageLimit) {
|
||||
return ErrDailyMessageLimit
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *conversations) Create(ctx context.Context, opts CreateConversationOptions) (*Conversation, error) {
|
||||
conversation := &Conversation{
|
||||
OwnerID: opts.OwnerID,
|
||||
@@ -83,6 +127,117 @@ func (db *conversations) Create(ctx context.Context, opts CreateConversationOpti
|
||||
return conversation, nil
|
||||
}
|
||||
|
||||
// CreateWithFirstMessage 在一个事务里完成「建会话 + 写首条消息 + 消息计数」。
|
||||
//
|
||||
// 原来这三步是三次独立写入:第二步失败时会话已经提交,于是留下一条
|
||||
// message_count=0、没有任何消息的「空会话」——箱主的私聊列表里能看到它,
|
||||
// 点进去什么都没有;更麻烦的是 Conversations.Create 的 BeforeCreate 已经
|
||||
// 生成并占用了一个 6 字符 token(唯一索引),孤儿会话会永久占着这个 token。
|
||||
func (db *conversations) CreateWithFirstMessage(
|
||||
ctx context.Context, opts CreateConversationOptions, senderID uint, content string,
|
||||
) (*Conversation, *Message, error) {
|
||||
var conv *Conversation
|
||||
var msg *Message
|
||||
|
||||
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if senderID == opts.AskerID {
|
||||
if err := reserveDailyMessageTx(tx, opts.OwnerID, senderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
conv = &Conversation{
|
||||
OwnerID: opts.OwnerID,
|
||||
AskerID: opts.AskerID,
|
||||
MaxMessages: opts.MaxMessages,
|
||||
}
|
||||
// 走 tx 而不是 db:BeforeCreate 里生成 token 的 hook 同样会被触发。
|
||||
if err := tx.Create(conv).Error; err != nil {
|
||||
return errors.Wrap(err, "create conversation")
|
||||
}
|
||||
|
||||
msg = &Message{
|
||||
ConversationID: conv.ID,
|
||||
SenderID: senderID,
|
||||
Content: content,
|
||||
}
|
||||
if err := tx.Create(msg).Error; err != nil {
|
||||
return errors.Wrap(err, "create first message")
|
||||
}
|
||||
|
||||
// 原子自增,与 IncrementMessageCount 保持同一写法
|
||||
if err := tx.Model(&Conversation{}).
|
||||
Where("id = ?", conv.ID).
|
||||
UpdateColumn("message_count", gorm.Expr("message_count + 1")).Error; err != nil {
|
||||
return errors.Wrap(err, "increment message count")
|
||||
}
|
||||
|
||||
// 私聊定量收集:新建会话即计一条(后续往来消息不重复计数)。
|
||||
// 放在事务里而不是 handler 里:原来 handler 在事务之后调 IncrementQuota 且
|
||||
// 失败只打 Warn,会话建成了但计数没加,箱主的「已收集」偏少、上限被绕过。
|
||||
if err := incrementQuotaTx(tx, opts.OwnerID, FeatureChat, 1); err != nil {
|
||||
return errors.Wrap(err, "increment chat quota")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return conv, msg, nil
|
||||
}
|
||||
|
||||
func (db *conversations) SendMessage(ctx context.Context, opts SendConversationMessageOptions) (*Message, error) {
|
||||
msg := &Message{ConversationID: opts.ConversationID, SenderID: opts.SenderID, Content: opts.Content}
|
||||
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 先锁发送者,再锁会话,保证与 CreateWithFirstMessage 的锁顺序一致,
|
||||
// 避免并发下两条路径互相等待造成配额检查窗口。
|
||||
var sender User
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&sender, opts.SenderID).Error; err != nil {
|
||||
return errors.Wrap(err, "lock message sender")
|
||||
}
|
||||
|
||||
var conv Conversation
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&conv, opts.ConversationID).Error; err != nil {
|
||||
return errors.Wrap(err, "lock conversation")
|
||||
}
|
||||
|
||||
// 发送者必须是会话双方之一
|
||||
if conv.OwnerID != opts.SenderID && conv.AskerID != opts.SenderID {
|
||||
return errors.New("sender is not part of the conversation")
|
||||
}
|
||||
|
||||
// 只有 asker 发送才消耗 owner 的每日配额
|
||||
if conv.AskerID == opts.SenderID {
|
||||
if err := reserveDailyMessageTx(tx, conv.OwnerID, opts.SenderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
res := tx.Model(&Conversation{}).Where("id = ? AND message_count < max_messages AND is_closed = ?", conv.ID, false).
|
||||
UpdateColumn("message_count", gorm.Expr("message_count + 1"))
|
||||
if res.Error != nil {
|
||||
return errors.Wrap(res.Error, "increment message count")
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrConversationLimit
|
||||
}
|
||||
if err := tx.Create(msg).Error; err != nil {
|
||||
return errors.Wrap(err, "create message")
|
||||
}
|
||||
if opts.Image != nil {
|
||||
image := *opts.Image
|
||||
image.MessageID = msg.ID
|
||||
if err := tx.Create(&MessageImage{MessageID: image.MessageID, URL: image.URL, ObjectKey: image.ObjectKey, FileName: image.FileName, FileSize: image.FileSize}).Error; err != nil {
|
||||
return errors.Wrap(err, "create message image")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (db *conversations) GetByID(ctx context.Context, id uint) (*Conversation, error) {
|
||||
var conv Conversation
|
||||
if err := db.WithContext(ctx).First(&conv, id).Error; err != nil {
|
||||
@@ -216,14 +371,28 @@ func (db *conversations) MarkDeletedByOwner(ctx context.Context, id uint) error
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := db.WithContext(ctx).Model(&Conversation{}).Where("id = ?", id).Update("is_deleted_by_owner", true).Error; err != nil {
|
||||
return errors.Wrap(err, "mark conversation deleted by owner")
|
||||
// 用「条件更新 + RowsAffected」代替纯 Update:
|
||||
// 原来的写法是 check-then-act,双击「删除会话」或前端重复提交时,两个请求
|
||||
// 都能读到 is_deleted_by_owner=false,于是都执行 UPDATE、都执行一次
|
||||
// IncrementQuota(-1)。IncrementQuota 有 quota_count>0 兜底不会变负,
|
||||
// 但会白吃掉本用户其他会话的计数 —— 表现为定量收集提前关闭,
|
||||
// 而箱主重置计数也校准不回来。
|
||||
res := db.WithContext(ctx).Model(&Conversation{}).
|
||||
Where("id = ? AND is_deleted_by_owner = ?", id, false).
|
||||
Update("is_deleted_by_owner", true)
|
||||
if res.Error != nil {
|
||||
return errors.Wrap(res.Error, "mark conversation deleted by owner")
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
// 并发里被另一个请求抢先删掉了:它已经做过配额回退,这里不能再来一次。
|
||||
return nil
|
||||
}
|
||||
|
||||
// 回退定量收集计数:删除私聊会话等价于少一条已收集内容,
|
||||
// 否则达上限后即便删掉旧会话,功能仍会显示 quota_reached 关闭,须手动重置计数。
|
||||
if conv.OwnerID != 0 {
|
||||
if err := FeatureControls.IncrementQuota(ctx, conv.OwnerID, FeatureChat, -1); err != nil {
|
||||
// 用本 store 的 *gorm.DB 构造,避免依赖包级全局单例(详见 DeleteByID 注释)。
|
||||
if err := NewFeatureControlsStore(db.DB).IncrementQuota(ctx, conv.OwnerID, FeatureChat, -1); err != nil {
|
||||
logrus.WithContext(ctx).WithError(err).Errorf("Failed to decrement chat quota for user %d", conv.OwnerID)
|
||||
}
|
||||
}
|
||||
|
||||
+92
-25
@@ -1,6 +1,7 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -14,13 +15,44 @@ import (
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
)
|
||||
|
||||
// handle 保存 Init 建立的连接,供「不需要具体 store」的场景使用(如就绪探针)。
|
||||
// 与下面那些业务 store 的包级变量区分开:那些是接口,这个是连接本身。
|
||||
var handle *gorm.DB
|
||||
|
||||
// Ping 检查数据库连接是否可用。供 /readyz 就绪探针调用。
|
||||
//
|
||||
// 为什么需要它:原来的「探活」只能 curl /,而首页只渲染模板、不碰数据库 ——
|
||||
// DB 挂了首页照样 200,反过来 Redis 挂了首页又可能 500。于是探针既会漏报也会误报。
|
||||
func Ping(ctx context.Context) error {
|
||||
if handle == nil {
|
||||
return errors.New("database not initialized")
|
||||
}
|
||||
sqlDB, err := handle.DB()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "get sql.DB")
|
||||
}
|
||||
return sqlDB.PingContext(ctx)
|
||||
}
|
||||
|
||||
var AllTables = []interface{}{
|
||||
&User{}, &Question{}, &FollowUp{}, &CensorLog{}, &UploadImage{}, &UploadImageQuestion{},
|
||||
&Conversation{}, &Message{}, &MessageImage{}, &UserBlock{},
|
||||
&Conversation{}, &Message{}, &MessageImage{}, &UserBlock{}, &R2DeleteTask{},
|
||||
// 匿名投票
|
||||
&Vote{}, &VoteOption{}, &VoteFormField{}, &VoteSubmission{},
|
||||
// 功能开关(提问 / 私聊 / 投票 的开关、定时、定量收集)
|
||||
&FeatureControl{},
|
||||
// 站方处置与举报(无管理后台,处置经 CLI 写入、运行时读表生效)
|
||||
&UserRestriction{}, &ContentReport{},
|
||||
// 蜜罐命中记录(审计证据,只追加不软删)
|
||||
&HoneypotHit{},
|
||||
// 来源 IP 的地理定位缓存(安全汇总报告用,纯 KV 缓存)
|
||||
&IPGeoRecord{},
|
||||
// 周期安全汇总的报告台账(period_key 唯一,保证重启不会重复发信)
|
||||
&SecurityReportLog{},
|
||||
// 自动封禁记录(内存态封禁的落库账本,用于事后解释与取证)
|
||||
&BanRecord{},
|
||||
// 箱主数据面板的访问流水(每 访客×天×箱 一行,Unscoped 语义,见 BoxView 注释)
|
||||
&BoxView{},
|
||||
}
|
||||
|
||||
func Init(typ, dsn string) (*gorm.DB, error) {
|
||||
@@ -36,27 +68,23 @@ func Init(typ, dsn string) (*gorm.DB, error) {
|
||||
}
|
||||
|
||||
db, err := gorm.Open(dialector, &gorm.Config{
|
||||
Logger: newSafeLogger(),
|
||||
SkipDefaultTransaction: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "connect to database")
|
||||
}
|
||||
handle = db
|
||||
|
||||
// 配置连接池
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "get sql.DB from gorm")
|
||||
}
|
||||
|
||||
// 1C1G 低配环境:调低连接池,避免每个 DB 连接占用过多内存。
|
||||
// 提问箱是低并发场景,10 个打开连接绰绰有余。
|
||||
// 最大打开连接数
|
||||
// 1C1G 低配环境:调低连接池避免单连接占用过多内存。提问箱低并发,10 个打开连接绰绰有余。
|
||||
sqlDB.SetMaxOpenConns(10)
|
||||
// 最大空闲连接数
|
||||
sqlDB.SetMaxIdleConns(4)
|
||||
// 连接最大存活时间(避免长连接被数据库服务端断开)
|
||||
sqlDB.SetConnMaxLifetime(5 * time.Minute)
|
||||
// 空闲连接最大存活时间
|
||||
sqlDB.SetConnMaxLifetime(5 * time.Minute) // 避免长连接被数据库服务端断开
|
||||
sqlDB.SetConnMaxIdleTime(3 * time.Minute)
|
||||
|
||||
// 仅在明确开启迁移清理开关时处理 users 表历史重复值,避免服务启动时静默改写账号数据。
|
||||
@@ -67,26 +95,16 @@ func Init(typ, dsn string) (*gorm.DB, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// AutoMigrate 只负责声明式的「加表 / 加列 / 加索引」——这些变更幂等、无数据丢失风险。
|
||||
if err := db.AutoMigrate(AllTables...); err != nil {
|
||||
return nil, errors.Wrap(err, "auto migrate")
|
||||
}
|
||||
|
||||
// 功能开关初始化:为历史账号补齐 feature_controls 行(私聊沿用 users.allow_chat)。
|
||||
// 幂等,已初始化的账号不会重复写入;失败不阻断启动,仅告警,避免依赖表抖动导致服务起不来。
|
||||
if err := backfillFeatureControls(db); err != nil {
|
||||
logrus.WithError(err).Warn("初始化功能开关配置时异常(可在功能管理页手动保存一次以生成配置)")
|
||||
}
|
||||
|
||||
// 私聊开关纠偏:旧版 backfill 曾把未开启私聊的账号写为开启(GORM 零值陷阱),
|
||||
// 启动期幂等复位;同样不阻断启动,失败仅告警。
|
||||
if err := reconcileLegacyChatEnabled(db); err != nil {
|
||||
logrus.WithError(err).Warn("私聊开关历史数据纠偏时异常(可在功能管理页手动调整私聊开关)")
|
||||
}
|
||||
|
||||
// 私聊收集上限下线:私聊不再支持收集上限(UI 已隐藏,保存时强制清零),
|
||||
// 启动期把历史残留的上限/计数一并清零,避免私聊「莫名关闭」。幂等,失败仅告警。
|
||||
if err := normalizeChatQuota(db); err != nil {
|
||||
logrus.WithError(err).Warn("私聊收集上限历史数据清理时异常(可在功能管理页保存一次私聊设置)")
|
||||
// 版本化迁移:AutoMigrate 做不了的破坏性变更(改列名/删列/改约束)与一次性数据回填
|
||||
// 都收编进这里,靠 schema_migrations 账本保证「只跑一次、顺序固定、可审计」。
|
||||
// 迁移失败会阻断启动 —— 半途而废的迁移比静默跳过更危险(见 runMigrations 注释)。
|
||||
if err := runMigrations(db); err != nil {
|
||||
return nil, errors.Wrap(err, "run versioned migrations")
|
||||
}
|
||||
|
||||
// 启动期补充高频查询复合索引(幂等,适合既有库)。
|
||||
@@ -100,6 +118,12 @@ func Init(typ, dsn string) (*gorm.DB, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// 蜜罐表上无查询使用的索引(provider):GORM 去掉 tag 不会自动删既有索引,
|
||||
// 这里显式清理一次。失败仅告警。
|
||||
if err := dropUnusedHoneypotIndexes(db); err != nil {
|
||||
logrus.WithError(err).Warn("清理蜜罐表无用索引时异常(不影响服务启动)")
|
||||
}
|
||||
|
||||
Users = NewUsersStore(db)
|
||||
Questions = NewQuestionsStore(db)
|
||||
FollowUps = NewFollowUpsStore(db)
|
||||
@@ -108,14 +132,32 @@ 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)
|
||||
VoteSubmissions = NewVoteSubmissionsStore(db)
|
||||
FeatureControls = NewFeatureControlsStore(db)
|
||||
Restrictions = NewRestrictionsStore(db)
|
||||
Reports = NewReportsStore(db)
|
||||
Honeypots = NewHoneypotStore(db)
|
||||
BanRecords = NewBanRecordsStore(db)
|
||||
Analytics = NewSiteAnalyticsStore(db)
|
||||
BoxViews = NewBoxViewsStore(db)
|
||||
BoxAnalytics = NewBoxAnalyticsStore(db)
|
||||
|
||||
// 旧版本把消息/密码明文存在 honeypot_hits 上;结构体已删掉这些字段,
|
||||
// 但 AutoMigrate 不会删除既有列 —— 老库升级上来后历史明文仍在。
|
||||
// 这里只告警不自动删:DROP COLUMN 不可逆,必须由运维确认后执行
|
||||
// `TamaBox honeypot purge-creds`。
|
||||
if cols, err := Honeypots.LegacyCredentialColumns(context.Background()); err == nil && len(cols) > 0 {
|
||||
logrus.WithField("columns", cols).
|
||||
Warn("honeypot_hits 上仍残留旧版明文凭据列,请执行 TamaBox honeypot purge-creds 清除")
|
||||
}
|
||||
|
||||
if err := db.Use(otelgorm.NewPlugin(
|
||||
otelgorm.WithDBName(conf.Database.Name),
|
||||
otelgorm.WithoutQueryVariables(),
|
||||
)); err != nil {
|
||||
return nil, errors.Wrap(err, "register otelgorm plugin")
|
||||
}
|
||||
@@ -194,7 +236,13 @@ func dedupeUniqueFields(db *gorm.DB) error {
|
||||
func ensureFrequentlyUsedIndexes(db *gorm.DB) error {
|
||||
indexes := []string{
|
||||
`CREATE INDEX IF NOT EXISTS idx_questions_asker_user_id ON questions(asker_user_id)`,
|
||||
// 全站数据页(/stats):按 created_at 做范围扫描后按天/小时分组。
|
||||
// 没有这个索引时,趋势与热力图会退化成全表扫描 + 排序;加上之后是范围扫描 + 聚合。
|
||||
`CREATE INDEX IF NOT EXISTS idx_questions_created_at ON questions(created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_questions_user_private_created ON questions(user_id, is_private, created_at)`,
|
||||
// 「下一条未读」:user_id + is_read 过滤后按 created_at 取最早一条。
|
||||
// 上面那条中间隔着 is_private,帮不上这个排序。
|
||||
`CREATE INDEX IF NOT EXISTS idx_questions_user_unread_created ON questions(user_id, is_read, created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_users_show_in_home_created ON users(show_in_home, created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_votes_status_visibility_created ON votes(status, visibility, created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_messages_sender_created ON messages(sender_id, created_at)`,
|
||||
@@ -208,6 +256,25 @@ func ensureFrequentlyUsedIndexes(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// dropUnusedHoneypotIndexes 删除蜜罐表上没有任何查询使用的索引。
|
||||
//
|
||||
// 背景:provider 字段原先带 gorm:"index",但全代码库没有一处按 provider
|
||||
// 过滤或排序(TopSources 用 max(provider) 随 group by 顺带取,不需要独立索引)。
|
||||
// 实测该索引占 10 万行约 648 kB,对一张「只增不减」的审计表是纯粹的空间浪费
|
||||
// 与写放大。去掉 GORM tag 只影响新建库,既有库上的旧索引要显式 DROP。
|
||||
//
|
||||
// 幂等:索引不存在时不报错。失败仅告警,不阻断启动。
|
||||
func dropUnusedHoneypotIndexes(db *gorm.DB) error {
|
||||
// GORM 生成的索引名规则:idx_<表名>_<列名>。
|
||||
if db.Migrator().HasIndex(&HoneypotHit{}, "idx_honeypot_hits_provider") {
|
||||
if err := db.Migrator().DropIndex(&HoneypotHit{}, "idx_honeypot_hits_provider"); err != nil {
|
||||
return errors.Wrap(err, "drop unused honeypot index")
|
||||
}
|
||||
logrus.Info("已删除蜜罐表上无查询使用的 provider 索引")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fixPostgresSequences(db *gorm.DB) error {
|
||||
// 使用配置的 schema(未配置时回退 public),避免硬编码 nekobox。
|
||||
schema := conf.Database.Schema
|
||||
|
||||
@@ -20,6 +20,8 @@ import (
|
||||
|
||||
var FeatureControls FeatureControlsStore
|
||||
|
||||
var ErrFeatureQuotaReached = errors.New("功能收集配额已满")
|
||||
|
||||
var _ FeatureControlsStore = (*featureControls)(nil)
|
||||
|
||||
// 受控功能标识(feature_controls.feature 取值)。
|
||||
@@ -66,29 +68,23 @@ type featureControls struct {
|
||||
*gorm.DB
|
||||
}
|
||||
|
||||
// FeatureControl 提问箱单个功能(提问 / 私聊 / 投票)的开关、定时与定量收集配置。
|
||||
// FeatureControl 单个功能(提问 / 私聊 / 投票)的开关、定时与定量收集配置。
|
||||
// Enabled 为手动总开关;OpenAt / CloseAt 为一次性定时(nil 表示不限该侧);
|
||||
// QuotaLimit / QuotaCount 为定量收集(0 表示不限制)。最终状态由 Evaluate 综合计算。
|
||||
//
|
||||
// 三类控制互不覆盖,最终可用状态由 Evaluate 综合计算:
|
||||
// - Enabled:手动总开关;
|
||||
// - OpenAt / CloseAt:定时开放与定时关闭(nil 表示不限制该侧);
|
||||
// - QuotaLimit / QuotaCount:定量收集,累计收集数达到上限后自动关闭(0 表示不限制)。
|
||||
//
|
||||
// 计数不做定时重置:一轮收集结束后由箱主在功能管理页手动「重置计数」开启下一轮,
|
||||
// 避免与定时开关叠加产生难以预期的自动恢复行为。
|
||||
// 计数不做定时重置:一轮结束后由箱主手动「重置计数」,避免与定时开关叠加产生
|
||||
// 难以预期的自动恢复行为。
|
||||
type FeatureControl struct {
|
||||
dbutil.Model
|
||||
UserID uint `gorm:"uniqueIndex:idx_feature_control_user_feature;not null" json:"-"`
|
||||
Feature string `gorm:"size:20;uniqueIndex:idx_feature_control_user_feature;not null" json:"feature"`
|
||||
// ⚠️ 不要给 Enabled 加 gorm:"default:true" 标签:
|
||||
// GORM 在 Create 时会把零值 false 替换为字段默认值(true),导致「取消勾选保存」后
|
||||
// 数据库里仍是 true、复选框自动恢复为勾选。去掉标签后 GORM 会原样写入显式设置的值。
|
||||
Enabled bool `json:"enabled"`
|
||||
// OpenAt / CloseAt:一次性定时开放 / 关闭(nil 表示不限制该侧)。
|
||||
// ⚠️ 不要给 Enabled 加 gorm:"default:true":GORM 在 Create 时会把零值 false 替换为
|
||||
// 默认值 true,导致「取消勾选保存」后库里仍是 true、复选框自动恢复勾选。
|
||||
Enabled bool `json:"enabled"`
|
||||
OpenAt *time.Time `json:"open_at"`
|
||||
CloseAt *time.Time `json:"close_at"`
|
||||
// 循环定时(每周循环):ScheduleDays 为逗号分隔的周几(0=周日…6=周六,与 time.Weekday 一致),
|
||||
// 空表示每天都开放;ScheduleStart/ScheduleEnd 为每日时间段 "HH:MM"(留空表示该日全天)。
|
||||
// 两者都为空时表示未启用循环定时。
|
||||
// 循环定时(每周):ScheduleDays 为逗号分隔的周几(0=周日…6=周六),空表示每天都开放;
|
||||
// ScheduleStart/ScheduleEnd 为每日时间段 "HH:MM"(留空表示全天)。两者皆空即未启用。
|
||||
ScheduleDays string `gorm:"size:31" json:"schedule_days"`
|
||||
ScheduleStart string `gorm:"size:5" json:"schedule_start"`
|
||||
ScheduleEnd string `gorm:"size:5" json:"schedule_end"`
|
||||
@@ -178,8 +174,8 @@ func (fc *FeatureControl) scheduleDayOpen(day time.Time) bool {
|
||||
return fc.ScheduleDays == "" || containsWeekday(fc.ScheduleDays, int(day.Weekday()))
|
||||
}
|
||||
|
||||
// scheduleWindow 返回单日开放窗口的分钟数 [startM, endM)。
|
||||
// 未设置时间段时视为全天 [0, 1440)。配置非法时返回错误(调用方按"不限制"处理)。
|
||||
// scheduleWindow 返回单日开放窗口的分钟数 [startM, endM)。未设置时间段时视为全天;
|
||||
// 配置非法时返回错误(调用方按「不限制」处理)。
|
||||
func (fc *FeatureControl) scheduleWindow() (startM, endM int, err error) {
|
||||
if fc.ScheduleStart == "" && fc.ScheduleEnd == "" {
|
||||
return 0, 24 * 60, nil
|
||||
@@ -342,9 +338,8 @@ func startOfDay(t time.Time) time.Time {
|
||||
return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
|
||||
// DefaultFeatureControl 返回某功能的默认配置(尚未落库时的行为)。
|
||||
// 提问与投票默认开启(与历史行为一致);私聊默认关闭,沿用 users.allow_chat 的默认值,
|
||||
// 避免历史账号在功能上线后被动开启私聊。
|
||||
// DefaultFeatureControl 返回某功能的默认配置(尚未落库时的行为)。提问与投票默认开启
|
||||
// (与历史行为一致);私聊默认关闭,沿用 users.allow_chat 默认值,避免历史账号被动开启。
|
||||
func DefaultFeatureControl(userID uint, feature string) *FeatureControl {
|
||||
return &FeatureControl{
|
||||
UserID: userID,
|
||||
@@ -353,8 +348,8 @@ func DefaultFeatureControl(userID uint, feature string) *FeatureControl {
|
||||
}
|
||||
}
|
||||
|
||||
// FeatureStateOf 便捷入口:读取某用户某功能在当前的综合状态。
|
||||
// 查询失败或无存储时回退到默认配置,保证单个依赖表异常不会让整站功能不可用。
|
||||
// FeatureStateOf 读取某用户某功能当前的综合状态。查询失败或无存储时回退默认配置,
|
||||
// 保证单个依赖表异常不会让整站功能不可用。
|
||||
func FeatureStateOf(ctx context.Context, userID uint, feature string) FeatureState {
|
||||
if FeatureControls == nil {
|
||||
return DefaultFeatureControl(userID, feature).Evaluate(time.Now())
|
||||
@@ -367,6 +362,26 @@ func FeatureStateOf(ctx context.Context, userID uint, feature string) FeatureSta
|
||||
return fc.Evaluate(time.Now())
|
||||
}
|
||||
|
||||
// FeatureStateOfStrict 与 FeatureStateOf 相同,但查询失败时返回错误而不回退默认配置。
|
||||
//
|
||||
// 给**写入路径**用。FeatureStateOf 的 fail-open 对读展示是对的(配置表抖动不该让
|
||||
// 页面白屏),但对写入是危险的:它回退到 DefaultFeatureControl —— 提问与投票的
|
||||
// 默认值是「开启」,于是箱主明明设了「已关闭」或「已达上限」,只要这次读配置失败
|
||||
// 就被放行,等于定量收集与功能开关都能被一次数据库抖动绕过去。
|
||||
// 与审核链路「不可用即拒绝」同一口径。
|
||||
func FeatureStateOfStrict(ctx context.Context, userID uint, feature string) (FeatureState, error) {
|
||||
if FeatureControls == nil {
|
||||
// 未装配(测试 / 部分初始化):沿用默认配置,否则所有写路径都会失败。
|
||||
return DefaultFeatureControl(userID, feature).Evaluate(time.Now()), nil
|
||||
}
|
||||
fc, err := FeatureControls.Get(ctx, userID, feature)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx).WithError(err).Errorf("Failed to get feature control %q of user %d", feature, userID)
|
||||
return FeatureState{}, errors.Wrap(err, "get feature control")
|
||||
}
|
||||
return fc.Evaluate(time.Now()), nil
|
||||
}
|
||||
|
||||
// FeatureOpen 便捷入口:某用户某功能当前是否可用。
|
||||
func FeatureOpen(ctx context.Context, userID uint, feature string) bool {
|
||||
return FeatureStateOf(ctx, userID, feature).Open
|
||||
@@ -433,7 +448,7 @@ type UpdateFeatureControlOptions struct {
|
||||
QuotaLimit int64
|
||||
}
|
||||
|
||||
// Save 写入功能配置。使用 ON CONFLICT 幂等写入,并显式列出更新列,
|
||||
// Save 写入功能配置。用 ON CONFLICT 幂等写入并显式列出更新列,
|
||||
// 避免把 quota_count(已收集数量)误重置为 0。
|
||||
func (db *featureControls) Save(ctx context.Context, userID uint, feature string, opts UpdateFeatureControlOptions) error {
|
||||
now := time.Now()
|
||||
@@ -494,11 +509,37 @@ func (db *featureControls) ResetQuota(ctx context.Context, userID uint, feature
|
||||
// IncrementQuota 累加收集计数(delta 可为负,用于删除内容时回退计数)。
|
||||
// 负 delta 不会把计数减到 0 以下(WHERE quota_count > 0),避免误删历史内容导致负计数。
|
||||
func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feature string, delta int64) error {
|
||||
return incrementQuotaTx(db.WithContext(ctx), userID, feature, delta)
|
||||
}
|
||||
|
||||
// incrementQuotaTx 是配额递增的唯一实现,接受任意 *gorm.DB(可以是事务)。
|
||||
//
|
||||
// 为什么要暴露一个「能用事务」的版本:配额与内容必须是同生共死的关系。
|
||||
// 原来「创建提问」与「quota_count+1」是两次独立写入,后者失败只打一条 Warn ——
|
||||
// 于是计数比真实行数少,箱主的「已收集 7/10」永远偏低,达到上限后仍能继续收,
|
||||
// 而重置计数也校准不回来(重置是把计数清零,与实际行数更脱节)。
|
||||
// 现在内容 store 把这一步放进自己的事务里,任一步失败整体回滚,计数天然对齐。
|
||||
func reserveFeatureQuotaTx(tx *gorm.DB, userID uint, feature string) error {
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(DefaultFeatureControl(userID, feature)).Error; err != nil {
|
||||
return errors.Wrap(err, "initialize feature quota")
|
||||
}
|
||||
var fc FeatureControl
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("user_id = ? AND feature = ?", userID, feature).First(&fc).Error; err != nil {
|
||||
return errors.Wrap(err, "lock feature quota")
|
||||
}
|
||||
if fc.QuotaLimit > 0 && fc.QuotaCount >= fc.QuotaLimit {
|
||||
return ErrFeatureQuotaReached
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func incrementQuotaTx(tx *gorm.DB, userID uint, feature string, delta int64) error {
|
||||
if delta == 0 {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
q := db.WithContext(ctx).Model(&FeatureControl{}).
|
||||
q := tx.Model(&FeatureControl{}).
|
||||
Where("user_id = ? AND feature = ?", userID, feature)
|
||||
if delta < 0 {
|
||||
q = q.Where("quota_count > 0")
|
||||
@@ -520,7 +561,7 @@ func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feat
|
||||
fc.QuotaCount = delta
|
||||
}
|
||||
fc.QuotaResetAt = &now
|
||||
created := db.WithContext(ctx).
|
||||
created := tx.
|
||||
Clauses(clause.OnConflict{DoNothing: true}).
|
||||
Create(fc)
|
||||
if created.Error != nil {
|
||||
@@ -530,7 +571,7 @@ func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feat
|
||||
return nil
|
||||
}
|
||||
// 并发下配置行可能刚被其它请求创建:补一次累加,保证计数不丢。
|
||||
retry := db.WithContext(ctx).Model(&FeatureControl{}).
|
||||
retry := tx.Model(&FeatureControl{}).
|
||||
Where("user_id = ? AND feature = ?", userID, feature).
|
||||
Updates(map[string]interface{}{
|
||||
"quota_count": gorm.Expr("quota_count + ?", delta),
|
||||
@@ -544,13 +585,12 @@ func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feat
|
||||
|
||||
// backfillFeatureControls 为尚未初始化的历史账号补齐功能配置行。
|
||||
//
|
||||
// 背景:私聊开关此前落在 users.allow_chat,提问/投票则一直默认开启。功能上线后若没有
|
||||
// 对应的配置行,Get 会回退到默认值(私聊默认关闭),导致已开启私聊的历史账号被静默关闭。
|
||||
// 因此在 AutoMigrate 之后按 users.allow_chat 回填私聊开关,其余功能按"开启"回填。
|
||||
// 私聊开关此前落在 users.allow_chat,提问/投票一直默认开启;功能上线后没有配置行时
|
||||
// Get 会回退默认值(私聊默认关闭),导致已开启私聊的历史账号被静默关闭,
|
||||
// 故在 AutoMigrate 之后按 users.allow_chat 回填私聊,其余按「开启」回填。
|
||||
//
|
||||
// 幂等:每轮只挑出仍缺配置行的账号,写完后进入下一轮,直到没有遗漏。
|
||||
// 分批 + ON CONFLICT DO NOTHING,避免"边插入边查同一张表"在不同数据库上的差异行为,
|
||||
// 并保证并发启动(多实例同时启动)时不会重复插入。
|
||||
// 幂等:每轮只挑仍缺行的账号,分批 + ON CONFLICT DO NOTHING,避免不同数据库上
|
||||
// 「边插入边查同一张表」的差异行为,也保证多实例并发启动时不重复插入。
|
||||
func backfillFeatureControls(db *gorm.DB) error {
|
||||
// backfillBatchSize 每轮处理的账号数,控制单次事务体积与内存占用。
|
||||
const backfillBatchSize = 500
|
||||
@@ -616,28 +656,20 @@ func backfillFeatureControls(db *gorm.DB) error {
|
||||
|
||||
// reconcileLegacyChatEnabled 修复旧版 backfill 写入的私聊错误开关。
|
||||
//
|
||||
// 背景:早期版本 FeatureControl.Enabled 带 gorm:"default:true" 标签,backfill 为
|
||||
// allow_chat=false 的用户补私聊行时,false 被 GORM 替换为默认值 true,导致私聊被被动开启;
|
||||
// 且 UpdateChatSettings 会把 users.allow_chat 反向同步为 true,形成持久化。
|
||||
// 背景:早期 Enabled 带 gorm:"default:true",backfill 为 allow_chat=false 的用户补行时
|
||||
// false 被替换为 true,私聊被被动开启;UpdateChatSettings 还会把 allow_chat 同步为 true。
|
||||
//
|
||||
// 纠偏条件(必须同时满足,只命中"纯 backfill 默认行",绝不碰箱主配置过的行):
|
||||
// - feature = 'chat' 且 enabled = true;
|
||||
// - 对应 users.allow_chat = false(箱主从未在面板开启私聊——面板开启会同步 allow_chat=true);
|
||||
// - 该行无任何自定义配置(open_at / close_at 为空、quota_limit = 0、循环定时为空);
|
||||
// - updated_at <= created_at(从未被面板保存过,updated_at 未变化)。
|
||||
//
|
||||
// 满足条件即复位 feature_controls.enabled = false,并同步 users.allow_chat = false。
|
||||
// 幂等:每轮只处理仍满足条件的行,直到没有遗漏;已复位/已配置的行不会重复命中。
|
||||
// 纠偏条件(必须同时满足,只命中「纯 backfill 默认行」,绝不碰箱主配置过的行):
|
||||
// feature='chat' 且 enabled=true;对应 users.allow_chat=false;该行无任何自定义配置
|
||||
// (open_at/close_at 为空、quota_limit=0、循环定时为空);updated_at <= created_at。
|
||||
// 满足即复位 enabled=false 并同步 users.allow_chat=false。幂等,已配置行不会重复命中。
|
||||
func reconcileLegacyChatEnabled(db *gorm.DB) error {
|
||||
// reconcileBatchSize 每轮处理的账号数,控制单次事务体积。
|
||||
const reconcileBatchSize = 500
|
||||
|
||||
// pureDefaultFilterFC / pureDefaultFilterPlain 只匹配纯默认配置的私聊行
|
||||
// (无一次性窗口/循环定时/定量上限)。两者仅列前缀不同:
|
||||
// - FC 版带 "fc." 前缀,用于 SELECT(feature_controls fc 别名场景);
|
||||
// - 纯版不带前缀,用于 UPDATE(GORM 不会为 UPDATE 自动加别名,带前缀会报
|
||||
// missing FROM-clause entry for table "fc")。
|
||||
// 同时兼容旧行 schedule 列为 NULL 与新行空串两种情况。
|
||||
// 两者只匹配纯默认配置的私聊行(无一次性窗口/循环定时/定量上限),仅列前缀不同:
|
||||
// FC 版带 "fc." 用于 SELECT;纯版不带前缀用于 UPDATE(GORM 不会为 UPDATE 自动加别名,
|
||||
// 带前缀会报 missing FROM-clause entry for table "fc")。同时兼容旧行 NULL 与新行空串。
|
||||
pureDefaultFilterFC := "fc.open_at IS NULL AND fc.close_at IS NULL AND fc.quota_limit = 0" +
|
||||
" AND (fc.schedule_days IS NULL OR fc.schedule_days = '')" +
|
||||
" AND (fc.schedule_start IS NULL OR fc.schedule_start = '')" +
|
||||
@@ -703,11 +735,9 @@ func reconcileLegacyChatEnabled(db *gorm.DB) error {
|
||||
}
|
||||
|
||||
// normalizeChatQuota 清零私聊行上历史残留的收集上限与计数。
|
||||
//
|
||||
// 背景:私聊不提供收集上限能力(会话是双向对话,限制「新建会话数」意义不大),
|
||||
// UI 已隐藏该字段、保存时强制清零;此处在启动期做一次性兜底,把旧版本
|
||||
// 设置过的私聊上限/计数统一清零,避免达到残留上限后私聊「莫名关闭」。
|
||||
// 幂等:重复执行不产生任何变更,失败由调用方告警(不阻断启动)。
|
||||
// 私聊不提供收集上限(会话是双向对话,限制「新建会话数」意义不大),UI 已隐藏该字段、
|
||||
// 保存时强制清零;此处在启动期兜底清零,避免达到残留上限后私聊「莫名关闭」。
|
||||
// 幂等,失败由调用方告警(不阻断启动)。
|
||||
func normalizeChatQuota(db *gorm.DB) error {
|
||||
res := db.Model(&FeatureControl{}).
|
||||
Where("feature = ? AND (quota_limit > 0 OR quota_count > 0)", FeatureChat).
|
||||
|
||||
+65
-37
@@ -11,42 +11,42 @@ import (
|
||||
)
|
||||
|
||||
// MaxFollowUpCount 每条提问允许的最大追问次数(防滥用)。
|
||||
// 提问者被回答后,用 token(或登录账号)继续追问,达到上限后不可再追问。
|
||||
const MaxFollowUpCount = 3
|
||||
|
||||
// 追问相关错误(供 handler 区分处理,避免把业务错误当 500)
|
||||
// 追问相关错误,供 handler 区分处理,避免把业务错误当 500。
|
||||
var (
|
||||
// ErrFollowUpLimitReached 追问次数已达上限
|
||||
ErrFollowUpLimitReached = errors.New("follow up limit reached")
|
||||
// ErrFollowUpNotFound 追问不存在或不属于该问题
|
||||
ErrFollowUpNotFound = errors.New("follow up not found")
|
||||
// ErrFollowUpAlreadyAnswered 追问已被回答(禁止覆盖)
|
||||
ErrFollowUpLimitReached = errors.New("follow up limit reached")
|
||||
ErrFollowUpNotFound = errors.New("follow up not found")
|
||||
ErrFollowUpAlreadyAnswered = errors.New("follow up already answered")
|
||||
// ErrFollowUpAwaitingReply 上一条追问尚未被箱主回复,不允许继续追问。
|
||||
// 由 CreateIfBelowLimit 在事务内判定(见该方法的注释)。
|
||||
ErrFollowUpAwaitingReply = errors.New("previous follow up awaiting reply")
|
||||
)
|
||||
|
||||
var FollowUps FollowUpsStore
|
||||
|
||||
type FollowUpsStore interface {
|
||||
// CreateIfBelowLimit 原子创建追问:仅当该问题追问次数未达上限时创建。
|
||||
// 返回 created=false 且 err==ErrFollowUpLimitReached 表示已达上限。
|
||||
// 通过事务内锁定问题行,串行化同一问题的并发追问,消除 count-then-create 竞态。
|
||||
// CreateIfBelowLimit 原子创建追问:仅当该问题追问次数未达上限时创建;
|
||||
// 事务内锁定问题行串行化并发追问,消除 count-then-create 竞态。已达上限返回 (false, ErrFollowUpLimitReached)。
|
||||
CreateIfBelowLimit(ctx context.Context, opts CreateFollowUpOptions) (created bool, err error)
|
||||
GetByQuestionID(ctx context.Context, questionID uint) ([]*FollowUp, error)
|
||||
// GetLatestByQuestionID 返回某条提问最新的一条追问;无追问时返回 (nil, nil)。
|
||||
GetLatestByQuestionID(ctx context.Context, questionID uint) (*FollowUp, error)
|
||||
// GetByQuestionIDs 批量获取多个提问的追问,按 question_id 分组返回。
|
||||
// 用于账号导出等场景,将 N 次单题查询合并为 1 次 IN 查询,避免 N+1。
|
||||
// GetByQuestionIDs 批量获取多个提问的追问,按 question_id 分组返回(导出场景合并 N+1 为 1 次 IN 查询)。
|
||||
GetByQuestionIDs(ctx context.Context, questionIDs []uint) (map[uint][]*FollowUp, error)
|
||||
CountByQuestionID(ctx context.Context, questionID uint) (int64, error)
|
||||
// AnswerByID 回答某条追问;questionID 用于防止越权(必须属于该问题)。
|
||||
// 仅允许回答尚未回答的追问(answer 为空),已回答的返回 ErrFollowUpAlreadyAnswered,
|
||||
// 不存在/不属于该问题返回 ErrFollowUpNotFound。
|
||||
// AnswerByID 回答某条追问;questionID 防越权(必须属于该问题),仅允许回答尚未回答的(answer 为空)。
|
||||
// 已回答返回 ErrFollowUpAlreadyAnswered,不存在/不属于该问题返回 ErrFollowUpNotFound。
|
||||
AnswerByID(ctx context.Context, questionID uint, id uint, answer string) error
|
||||
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防止越权),
|
||||
// 与 AnswerByID 不同,此处允许覆盖已回答的追问(供主人修改回复使用)。
|
||||
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防越权),
|
||||
// 允许覆盖已回答的(供主人修改回复);返回 wasFirst 表示这是首次回答(此前为空)。
|
||||
// 不存在/不属于该问题返回 ErrFollowUpNotFound。
|
||||
UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) error
|
||||
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
|
||||
UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) (wasFirst bool, err error)
|
||||
// DeleteByQuestionID 删除某条提问时级联清理其全部追问。
|
||||
//
|
||||
// 用 Unscoped 真删:追问正文是提问者的个人内容,随提问一并真删(与 questions.DeleteByID
|
||||
// 对主表的真删口径一致)。软删会留下带 deleted_at 的追问文本,且这些孤儿行的
|
||||
// question_id 指向一个已不存在的提问 —— 与「删除即真删、个人数据不残留」的意图相悖。
|
||||
DeleteByQuestionID(ctx context.Context, questionID uint) error
|
||||
}
|
||||
|
||||
@@ -92,6 +92,23 @@ func (db *followUps) CreateIfBelowLimit(ctx context.Context, opts CreateFollowUp
|
||||
return ErrFollowUpLimitReached
|
||||
}
|
||||
|
||||
// 「上一条追问必须先被回复」也必须在事务内判断。
|
||||
//
|
||||
// 这段校验原来只在 handler 里做(事务外):双击提交时两个请求都读到
|
||||
// 「最新一条还没回复」,随后被这行 FOR UPDATE 串行化,各自 count 都 < 3,
|
||||
// 于是两条内容相同的追问都创建成功 —— 吃掉 2/3 名额,箱主还会收两封邮件。
|
||||
// 行锁只保护「条数」,不保护「业务前置条件」,所以判断必须挪进来。
|
||||
var latest FollowUp
|
||||
if err := tx.Where("question_id = ?", opts.QuestionID).
|
||||
Order("id DESC"). // 用 id 而非 created_at:同毫秒写入时时间排序不稳定
|
||||
Limit(1).
|
||||
Find(&latest).Error; err != nil {
|
||||
return errors.Wrap(err, "get latest follow up for reply check")
|
||||
}
|
||||
if latest.ID != 0 && latest.Answer == "" {
|
||||
return ErrFollowUpAwaitingReply
|
||||
}
|
||||
|
||||
f := &FollowUp{
|
||||
QuestionID: opts.QuestionID,
|
||||
Content: opts.Content,
|
||||
@@ -120,8 +137,7 @@ func (db *followUps) GetByQuestionID(ctx context.Context, questionID uint) ([]*F
|
||||
return followUps, nil
|
||||
}
|
||||
|
||||
// GetLatestByQuestionID 返回某条提问最新的一条追问(created_at 倒序取首条);
|
||||
// 无追问时返回 (nil, nil)。供发布追问前校验"上一条是否已回复"。
|
||||
// GetLatestByQuestionID 返回某条提问最新的一条追问(created_at 倒序);无追问时返回 (nil, nil),供发布前校验上一条是否已回复。
|
||||
func (db *followUps) GetLatestByQuestionID(ctx context.Context, questionID uint) (*FollowUp, error) {
|
||||
var fu FollowUp
|
||||
err := db.WithContext(ctx).
|
||||
@@ -169,9 +185,8 @@ func (db *followUps) CountByQuestionID(ctx context.Context, questionID uint) (in
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// AnswerByID 更新某条追问的回答内容(带 question_id 条件防止越权)。
|
||||
// 原子更新条件含 answer=”:已回答的追问不会被覆盖(避免重复提交时邮件/回答被二次写入)。
|
||||
// RowsAffected==0 时再查一次以区分"不存在"与"已回答",返回对应错误。
|
||||
// AnswerByID 更新回答(带 question_id 防越权)。
|
||||
// 原子条件含 answer=”,已回答的不被覆盖;RowsAffected==0 再查一次以区分"不存在"与"已回答"。
|
||||
func (db *followUps) AnswerByID(ctx context.Context, questionID uint, id uint, answer string) error {
|
||||
res := db.WithContext(ctx).
|
||||
Model(&FollowUp{}).
|
||||
@@ -198,23 +213,36 @@ func (db *followUps) AnswerByID(ctx context.Context, questionID uint, id uint, a
|
||||
|
||||
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防止越权),
|
||||
// 允许覆盖已回答的追问(用于主人修改回复)。不存在/不属于该问题返回 ErrFollowUpNotFound。
|
||||
func (db *followUps) UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) error {
|
||||
res := db.WithContext(ctx).
|
||||
Model(&FollowUp{}).
|
||||
Where("id = ? AND question_id = ?", id, questionID).
|
||||
Update("answer", answer)
|
||||
if res.Error != nil {
|
||||
return errors.Wrap(res.Error, "update follow up answer")
|
||||
func (db *followUps) UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) (bool, error) {
|
||||
var wasFirst bool
|
||||
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 先读旧值判断「是否首次回答」。handler 里拿不到可靠旧值:
|
||||
// 它手上的 question 是请求开始时读的,并发下可能已经过期。
|
||||
var fu FollowUp
|
||||
if err := tx.Where("id = ? AND question_id = ?", id, questionID).First(&fu).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrFollowUpNotFound
|
||||
}
|
||||
return errors.Wrap(err, "get follow up")
|
||||
}
|
||||
wasFirst = fu.Answer == ""
|
||||
|
||||
if err := tx.Model(&FollowUp{}).
|
||||
Where("id = ? AND question_id = ?", id, questionID).
|
||||
Update("answer", answer).Error; err != nil {
|
||||
return errors.Wrap(err, "update follow up answer")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrFollowUpNotFound
|
||||
}
|
||||
return nil
|
||||
return wasFirst, nil
|
||||
}
|
||||
|
||||
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
|
||||
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(真删,与提问保持一致)。
|
||||
func (db *followUps) DeleteByQuestionID(ctx context.Context, questionID uint) error {
|
||||
if err := db.WithContext(ctx).Where("question_id = ?", questionID).Delete(&FollowUp{}).Error; err != nil {
|
||||
if err := db.WithContext(ctx).Unscoped().Where("question_id = ?", questionID).Delete(&FollowUp{}).Error; err != nil {
|
||||
return errors.Wrap(err, "delete follow ups by question id")
|
||||
}
|
||||
return nil
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user