Compare commits
113
Commits
bfdc5089c5
...
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 |
+33
@@ -32,3 +32,36 @@ go.work.sum
|
|||||||
# outputs 里全是 TestGenPreview / 主题生成器 / 截图这类一次性产物,
|
# outputs 里全是 TestGenPreview / 主题生成器 / 截图这类一次性产物,
|
||||||
# 每次跑都会变,跟版本库无关。
|
# 每次跑都会变,跟版本库无关。
|
||||||
/outputs/
|
/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
|
||||||
|
|||||||
@@ -1,421 +0,0 @@
|
|||||||
# 2026-09-15 工作日志
|
|
||||||
|
|
||||||
## 落地 P2 推荐批次 #1–3(审核链路一致性 / 文档)
|
|
||||||
|
|
||||||
承接 09-11 的全库审阅(无 P0/P1,8 项 P2)。上次征询修复批次未获选择,本次按「继续 +
|
|
||||||
我标注的建议先做」自主推进**最低风险的一致性/文档类 #1–3**,未动 #4–8(需另行确认)。
|
|
||||||
|
|
||||||
### 已改(`internal/security/censor/`)
|
|
||||||
|
|
||||||
1. **#1 缓存/送审归一化统一**(censor.go:66-68,76,122,131)
|
|
||||||
- 原先:缓存按**原文** `text` 查 `GetByText`,实际送审 `RemoveTrustedURL(text)`,落库 `Input:text`。
|
|
||||||
→ 审计记录与实际送审内容不符;归一化后相同的原文会各占一条缓存、重复调 API。
|
|
||||||
- 现在:循环外算一次 `normalized := RemoveTrustedURL(text)`,缓存查找 / 送审 / `Input` 三者统一。
|
|
||||||
- 安全性论证:归一化是纯函数确定性映射;`TrustedHosts` 增减只会让旧条目 miss(重新送审),
|
|
||||||
不会让「已通过」的旧缓存被错配到新文本上 → 无漏放风险。
|
|
||||||
2. **#2 阿里云缓存回放分支与七牛对齐**(censor.go:99-110)
|
|
||||||
- 原先:aliyun 命中缓存**不看 Pass 直接 return**,依赖「aliyun 恒为链尾」这一隐式前提。
|
|
||||||
- 现在:与 qiniu 一致 —— 仅 `Pass` 才 return,否则 append + continue 交给下一通道复核。
|
|
||||||
- 等价性:aliyun 现为链尾,continue 后循环结束仍由 `responses[len-1]` 返回**同一份响应**,行为不变;
|
|
||||||
但消除了顺序耦合,将来若前置 aliyun 也不会短路后续通道。
|
|
||||||
3. **#3 修正失真注释**(text.go:66)
|
|
||||||
- 原注释称 `Details`「回放缓存结果时为 nil」**是错的**:
|
|
||||||
`AliyunTextCensorParser`(aliyun.go:433-461) 与 `QiniuTextCensorParser`(qiniu.go:133-170)
|
|
||||||
都会从 `RawResponse` 重建 `Details` → 缓存回放同样受严格度策略约束,改档位对历史缓存也生效。
|
|
||||||
- 已改为准确描述。**结论:审核缓存回放无 fail-open**(09-11 的怀疑已排除)。
|
|
||||||
|
|
||||||
### 验证
|
|
||||||
|
|
||||||
- `gofmt_keep_crlf.py` 两个文件 exit=0,CRLF 保留(censor 147 / text 141,loneLF=0)。
|
|
||||||
- `go build ./internal/security/censor/...` **EXIT=0**(定向构建,~秒级;用 goproxy.cn + overlay)。
|
|
||||||
- `go test ./internal/security/censor/` **ok 2.467s** —— 确认严格度策略与回放行为未被破坏。
|
|
||||||
|
|
||||||
### 顺带已完成 #6
|
|
||||||
|
|
||||||
- **#6 `User.Email` 改 `json:"-"`**(users.go:89)。
|
|
||||||
查证:`route/` 与 `internal/` 中**无任何非测试代码读取序列化后的 `.Email`**(模板里的 email 均为表单字段名 /
|
|
||||||
邮件模板,属 Go template 服务端渲染,不受 json tag 影响;输入走 `CreateUserOptions`/`UpdateUserOptions`,
|
|
||||||
不经 `User` 模型反序列化)→ 无消费方受损。
|
|
||||||
验证:gofmt exit=0,`go build ./internal/db/` **EXIT=0**。
|
|
||||||
|
|
||||||
### 待确认(P2 #4/#5/#7/#8)
|
|
||||||
|
|
||||||
| # | 项 | 我的建议 |
|
|
||||||
|---|---|---|
|
|
||||||
| 4 | 投票配额「插入后计数」非原子(route/vote/vote.go:814-822) | 暂缓:需 DB 层条件插入,超额有界且自动结束自愈 |
|
|
||||||
| 5 | ~~profile Name/Intro/link 无长度校验~~ | **误报,已关闭**:表单层已有校验,见下方更正 |
|
|
||||||
| 7 | `conf.Database.Schema` 拼 CREATE SEQUENCE(db.go:215-222) | 可选:配置来源非远端输入,纵深防御 |
|
|
||||||
| 8 | `RemoveTrustedURL` 24 次整串重建(text.go:114-141) | 可跳过:短文本开销可忽略(#1 已顺带把调用从循环内降到 1 次) |
|
|
||||||
|
|
||||||
#### ✅ #5 更正:是**误报**,已按「报错式校验」实现后**全部回滚**
|
|
||||||
|
|
||||||
- 先按用户选择实现了一版(Name 50 / Intro 500 / link 500 rune,4 语 i18n 词条),随后核对
|
|
||||||
`internal/form/user.go` 时发现**表单层早已有校验**:
|
|
||||||
- `Name` → `valid:"required;maxlen:20"`
|
|
||||||
- `Intro` → `valid:"required;maxlen:100"`
|
|
||||||
- 各 link → `valid:"maxlen:30"`
|
|
||||||
- 且确实生效:`internal/form/form.go:16` 引入 `govalid`,`:131` 在 `Bind()` 中 `govalid.Check()`;
|
|
||||||
handler 顶部 `ctx.HasError()` 承接;报错文案走标签 `msg:` 对应的 i18n 键
|
|
||||||
(`form.profile.name`="不超过 20 个字符"、`form.profile.intro`=100、`form.profile.linkMax`=30)。
|
|
||||||
- 结论:我那版是**永不触发的死代码**(超限在 Bind 阶段就被拒),且上限与真实值冲突 → 已完整回滚
|
|
||||||
(profile.go 的 utf8 导入 / 常量 / 校验块 + 4 份字典词条),`git status` 确认 5 个文件零 diff。
|
|
||||||
- 教训已写入 MEMORY.md「表单校验(勿重复造轮子)」:查某字段有无校验,**先看 `internal/form/*.go` 的 struct 标签**。
|
|
||||||
- 附带修正:Postgres 下 `string` 映射为 `text`(无 DB 层长度上限)这一点本身**没错**,
|
|
||||||
但不构成漏洞 —— 因为表单层已经挡住了。
|
|
||||||
|
|
||||||
### 环境
|
|
||||||
|
|
||||||
- ~~重包 `./route/user/ ./internal/route/ ./internal/db/` 仍会 30 分钟超时 → 判定为 1C1G 沙箱编译整图慢~~
|
|
||||||
**❌ 该结论已被推翻,见文末「编译卡死」误判更正。全库 `go build ./...` 实测 EXIT=0。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 「编译卡死」误判更正(用户指出:1C1G 是正式环境,不是沙箱)
|
|
||||||
|
|
||||||
用户明确纠正:**1C1G 是正式生产环境**,我不该拿「沙箱慢」当挡箭牌解释构建超时。重新取证后确认:
|
|
||||||
**根本没有编译问题,是我把代理挂起误判成了编译慢。**
|
|
||||||
|
|
||||||
### 取证过程
|
|
||||||
|
|
||||||
| 步骤 | 命令 | 结果 | 结论 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 隔离模块解析 | `go list -deps ./route/user/` | **2.3s EXIT=0** | 模块全在本地缓存,网络不是瓶颈 |
|
|
||||||
| 编译目标包 | `go build -overlay=... ./route/user/` | **7s EXIT=0** | 之前 240s 超时是伪命题 |
|
|
||||||
| 关键包逐个 | route/ internal/route/ question/ auth/ security/... db/ mail/ | **全 OK** | |
|
|
||||||
| 全量 | `go build -overlay=... ./...` | **EXIT=0** | |
|
|
||||||
| 静态检查 | `go vet -overlay=...` 7 个包 | **EXIT=0** | |
|
|
||||||
| 测试 | censor/autoban/ipinfo/route/conf/form | **全部 ok**,EXIT=0 | |
|
|
||||||
|
|
||||||
**真因**:前几次超时是 `GOPROXY` 未导出 → 卡在 `proxy.golang.org` 连接超时(HTTP=000)。
|
|
||||||
与机器性能、与 1C1G 都无关。**只要每条 go 命令前 `export GOPROXY=https://goproxy.cn,direct
|
|
||||||
GOSUMDB=off GOFLAGS=-mod=mod` 就秒级完成。**
|
|
||||||
|
|
||||||
⚠️ 方法论教训:**超时 ≠ 慢**。要用 `go list -deps`(纯元数据、不编译)先把「模块解析」和
|
|
||||||
「编译」两层拆开,否则会把网络挂起误判成性能问题,进而拿「环境」当借口。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 复核:追问邮件 token + 其他严重错误
|
|
||||||
|
|
||||||
### 1. 追问邮件确实没带 token(用户怀疑成立)
|
|
||||||
|
|
||||||
- `internal/mail/mail.go:58-65` `SendNewFollowUpMail` 链接 `%s/_/%s/%d`,**无 `?t=`**。
|
|
||||||
- 收件人是**箱主** `pageUser.Email`(follow_up.go:131),箱主靠登录态查看 → **不是权限漏洞**。
|
|
||||||
- 但有两处真实问题:
|
|
||||||
1. **不一致**:同样发给箱主的 `SendNewQuestionMail`(mail.go:25)**带** `?t=`。
|
|
||||||
2. **具体失效场景**:若该提问被箱主设为私密(`IsPrivate=true`),箱主在**未登录设备**点邮件链接
|
|
||||||
→ `Questioner` 中间件(question.go:61-66)判定 `IsPrivate && !IsLogged && token 不匹配`
|
|
||||||
→ **302 回首页,看不到追问**。新提问邮件因带 token 则不会。
|
|
||||||
- 建议:给 `SendNewFollowUpMail` 补 token 参数,与 `SendNewQuestionMail` 对齐。
|
|
||||||
- 关键的那封(**发给匿名提问者的回答通知**)`question.ReceiveReplyEmail` 是**带 token 的**:
|
|
||||||
follow_up.go:185、question.go:214 → `SendNewAnswerMail(..., question.Token)` ✓ 无问题。
|
|
||||||
|
|
||||||
### 2. 新发现 P2:提问 token 仅 6 位且爆破无限流
|
|
||||||
|
|
||||||
- `internal/db/questions.go:95` `Token: randstr.String(6)`;`randstr` 用 `crypto/rand` +
|
|
||||||
base62 → 62⁶ ≈ 5.68×10¹⁰,**仅 35.7 bit**(随机源没问题,是长度短)。
|
|
||||||
- `GET /_/{domain}/{questionID}?t=` **无 rateLimitMiddleware**;猜错由 `Questioner` 返回
|
|
||||||
**302 → /**,而 `autoban` 只统计「敏感路径」与「404」(404 默认关闭)→ **完全不计入封禁**。
|
|
||||||
- token 仅在「未回答 或 私密」且非箱主时才需要,攻破即=读私密/未公开提问。
|
|
||||||
- 可行性:单条定向 ~2.8×10¹⁰ 次不可行;但 questionID 可枚举、只需命中**任意一条**,
|
|
||||||
受保护提问 N 条时期望 5.68×10¹⁰/N 次 → 量大时理论可行。
|
|
||||||
- 低成本修复:`randstr.String(6)` → `randstr.String(12)`(≈71 bit)。存量 6 位 token 不受影响
|
|
||||||
(列是 string,无长度校验)。或对该 GET 路径加限流。
|
|
||||||
|
|
||||||
### 3. 已复核无问题(勿再重复查)
|
|
||||||
|
|
||||||
- **密码**(`internal/db/password.go`):bcrypt cost 10;旧 HMAC-SHA1 用 `subtle.ConstantTimeCompare`
|
|
||||||
常量时间比较并透明升级;哈希失败**不降级**为弱哈希(返回空串=锁定);bcrypt 72 字节截断 ✓
|
|
||||||
- **越权**:`Delete/SetPrivate/SetPublic/TogglePin/SetRead/SetUnread` 全部由 `canDelete`
|
|
||||||
(question.go:72 `ctx.IsLogged && ctx.User.ID == pageUser.ID`)把关;
|
|
||||||
`AnswerFollowUp`(follow_up.go:143) 与 `PublishAnswer`(question.go:154,161) 双重校验 ✓
|
|
||||||
(`ctx.Redirect` 会写响应从而终止后续 handler,早退有效)
|
|
||||||
- **CSRF**:全局挂载,SessionKey=uid 正确,有回归测试 ✓
|
|
||||||
- **会话**:登录 RegenerateID、pwd_ver 失效、HTTPOnly + SameSite=Lax ✓
|
|
||||||
- **密码找回**:验证码 24h TTL + 用后即删 ✓
|
|
||||||
- **SQL 注入**:全参数化 ✓
|
|
||||||
- **token/邮箱泄露**:`Question.Token` 与 `User.Email` 均为 `json:"-"` ✓
|
|
||||||
- **审核**:提问/回答/追问/投票/私聊统一走 `moderation.Check` ✓
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 落地 A+B(追问邮件补 token + 提问 token 6→12)
|
|
||||||
|
|
||||||
按「继续未完成的任务」指示,执行上一条报告的 A+B 两项变更。已用 gofmt_keep_crlf 保 CRLF。
|
|
||||||
|
|
||||||
### A. 追问邮件(发给箱主)补 token,与 `SendNewQuestionMail` 对齐
|
|
||||||
|
|
||||||
- `internal/mail/mail.go:58` `SendNewFollowUpMail` 签名加 `token string`,链接改为 `%s/_/%s/%d?t=%s`。
|
|
||||||
- `route/question/follow_up.go:131` 调用处补传 `question.Token`。
|
|
||||||
- 目的:箱主在**未登录设备**点开**私密**提问的追问邮件时也能凭 token 查看(否则被 Questioner 重定向首页)。
|
|
||||||
- 仅一个调用方,构建已验证签名一致。
|
|
||||||
|
|
||||||
### B. 提问 token 长度 6 → 12
|
|
||||||
|
|
||||||
- `internal/db/questions.go:95` `randstr.String(6)` → `randstr.String(12)`(≈35.7→71 bit)。
|
|
||||||
- 存量 6 位 token 不受影响(列是 string,无长度校验,旧 token 仍有效)。
|
|
||||||
|
|
||||||
### 验证
|
|
||||||
|
|
||||||
- `go build ./route/question/ ./internal/mail/ ./internal/db/` → EXIT=0
|
|
||||||
- `go build ./...`(overlay)→ EXIT=0
|
|
||||||
- `go vet` 三包 → EXIT=0
|
|
||||||
- `internal/db` 单元测试 FAIL 为已知环境限制(需真实 PostgreSQL/MySQL,报 `Unknown database type: ""`),
|
|
||||||
与本次改动无关,可跳过;security/autoban/censor/ipinfo、route 测试均 ok。
|
|
||||||
|
|
||||||
### 待定(未做,按需)
|
|
||||||
|
|
||||||
- CHANGELOG.json 是否补一条 entry(未做:需确认版本号与条目格式)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 用本地 Postgres 跑通 db 测试并修真实缺陷(用户提供 127.0.0.1:5432)
|
|
||||||
|
|
||||||
### 测试库环境变量(`internal/dbutil/testing.go:NewTestDB`)
|
|
||||||
- `DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=<pwd> DB_HOST=127.0.0.1 DB_PORT=5432`
|
|
||||||
DSN = `postgres://$DB_USER:$DB_PASSWORD@$DB_HOST:$DB_PORT?sslmode=disable`(不带库名,连上后
|
|
||||||
自动 `CREATE DATABASE tamabox-test-<rand>`,测试结束(未失败时)DROP)。
|
|
||||||
- 仍需 `GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod` + webp overlay。
|
|
||||||
|
|
||||||
### 修的真实缺陷(生产代码,2 处)
|
|
||||||
|
|
||||||
- `internal/db/questions.go:DeleteByID`:级联删追问/上传图 + 回退提问配额时**直接调包级全局单例**
|
|
||||||
`FollowUps`/`UploadImages`/`FeatureControls`。这些单例只在 `db.Init` 装配 → 测试/部分初始化场景 nil → **panic**。
|
|
||||||
改为 `NewFollowUpsStore(db.DB)` / `NewUploadImagesStore(db.DB)` / `NewFeatureControlsStore(db.DB)`,
|
|
||||||
用自身 store 的 `*gorm.DB`。生产行为不变(`Init` 里所有 store 共用同一 `*gorm.DB`),但去掉了隐藏全局依赖。
|
|
||||||
- `internal/db/conversation.go:MarkDeletedByOwner`:同类问题(`FeatureControls.IncrementQuota`),同样修。
|
|
||||||
- 同包 `feature_control.go:355` 的 `FeatureControls.Get` **已有** `if FeatureControls == nil` 守卫,故未动。
|
|
||||||
|
|
||||||
### 修的测试缺陷(全在 internal/db,非生产 bug)
|
|
||||||
|
|
||||||
1. `users_test.go`:5 处 `want.EncodePassword(); require.Equal(t, want, got)` —— bcrypt 随机盐,
|
|
||||||
哈希**永不相等**。新建 helper `requireUserEqual`:先 `got.Authenticate(明文)` 校验密码,再对齐
|
|
||||||
`Password` 与 `ChatMaxMessages`(= `conf.Limits.ChatMaxMessages`,默认 200)后比较其余字段。
|
|
||||||
2. `testUsersDeactivate`:复用已被 `testUsersCreate` 占用的 `[email protected]`/`e99` → 改唯一值。
|
|
||||||
3. `users_test.go`/`upload_images_test.go` 去掉顶层 `t.Parallel()`:子测试共用一张表、cleanup 会
|
|
||||||
`TRUNCATE ... RESTART IDENTITY`,并行时多个子测试拿到相同 id/邮箱 → 撞唯一键。
|
|
||||||
4. `upload_images_test.go`:cleanup 只 truncate `upload_images`,而链接表 `upload_image_questions`
|
|
||||||
**无 FK 级联**(复合主键 `upload_image_id+question_id`),残留行让下一子测试撞 `_pkey`
|
|
||||||
→ 补 `cleanup("upload_images", "upload_image_questions")`。
|
|
||||||
5. `UpdateBlockWords/normal`:期望 `HarassmentSettingNone`("none")但新用户实际 `""`
|
|
||||||
(模型字段无 `gorm:"default:"` 标签)。`""` 与 `"none"` 生产行为等价(只有 `==register_only` 才限制;
|
|
||||||
模板 profile.html:851 也只在 `register_only` 时勾选)→ **非生产 bug**。改成先显式
|
|
||||||
`UpdateHarassmentType(RegisterOnly)` 再断言未被误改(更强的测试,且不把 `""` 奉为期望)。
|
|
||||||
|
|
||||||
### 验证
|
|
||||||
|
|
||||||
- `go build ./...` **EXIT=0**、`go vet ./...` **EXIT=0**
|
|
||||||
- `go test ./...`(含 DB)**全绿 EXIT=0**:internal/db 57s、route/vote、internal/route、templates、
|
|
||||||
i18n、conf、form、cache、imagecheck、mail、shared、template、security/* 均 ok。
|
|
||||||
- 行尾:`internal/db/*` 与 `internal/mail/mail.go` 原本就是 LF,`route/question/*`、`route/vote/vote.go`、
|
|
||||||
`internal/route/route.go` 是 CRLF —— **仓库本来就是混合行尾**;本次编辑未改变任何文件原行尾(无 diff 污染)。
|
|
||||||
|
|
||||||
### 结论
|
|
||||||
|
|
||||||
- 除上述「全局单例 nil 依赖」(健壮性隐患:测试与部分初始化场景必崩)外,**未发现新的生产逻辑 bug**。
|
|
||||||
- db 测试套件此前的一堆失败**全部是测试自身缺陷**:bcrypt 直接比哈希、测试数据撞车、并行共享表、漏清链接表。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 修复 HarassmentSetting 潜在隐患(schema 默认值 + 启动期归一)
|
|
||||||
|
|
||||||
用户确认「影响不大就做」。改动面很小(3 处源码 + 1 处测试期望 + 1 个新回归测试)。
|
|
||||||
|
|
||||||
### 线索
|
|
||||||
- 模型 `HarassmentSetting HarassmentSettingType` **无 `gorm:"default:"` 标签** → 新用户该列为空串;
|
|
||||||
而规范取值是 none / register_only。全仓库**没有任何代码比较 `== HarassmentSettingNone`**
|
|
||||||
(production 只有 `== register_only`,模板 profile.html:851 也只看 register_only)→ **运行期无 bug**,
|
|
||||||
但将来若有人写 `== None` 判断就会漏判。
|
|
||||||
|
|
||||||
### 改动
|
|
||||||
1. `internal/db/users.go` 模型:`HarassmentSetting` 补 `gorm:"default:'none'"`。
|
|
||||||
⚠️ **字符串默认值必须带单引号**(`default:'none'`),参考既有 `ModerationStrictness` 的 `default:''`;
|
|
||||||
裸写 `default:none` 会生成非法 SQL。
|
|
||||||
2. `internal/db/users.go:Create`:显式 `HarassmentSetting: HarassmentSettingNone`
|
|
||||||
(确定写库,不依赖 DB 默认值回读)。
|
|
||||||
3. `internal/db/users.go`:新增 `normalizeHarassmentSetting(db)`,把历史空值(''/NULL)归一为 none,幂等。
|
|
||||||
4. `internal/db/db.go:Init`:在 `normalizeChatQuota` 之后调用,失败仅告警不阻断启动
|
|
||||||
(与 `backfillFeatureControls`/`reconcileLegacyChatEnabled` 等既有惯例一致)。
|
|
||||||
|
|
||||||
### 测试
|
|
||||||
- `users_test.go` helper `requireUserEqual` 补 `want.HarassmentSetting = HarassmentSettingNone`。
|
|
||||||
- 新增 `TestHarassmentSettingNormalize`:新建用户即 none + 历史空值经归一后为 none。
|
|
||||||
|
|
||||||
### 实测证据
|
|
||||||
- 临时探针查 `information_schema`:`harassment_setting` 的 `column_default = "'none'::text"` ✅
|
|
||||||
(AutoMigrate 确实把列默认值改成了 none;探针文件跑完即删)。
|
|
||||||
- `go build ./...` / `go vet ./...` EXIT=0;`go test ./...`(含 DB)**全绿**;gofmt 无 diff;
|
|
||||||
行尾未变(users.go CRLF、db.go/users_test.go LF)。
|
|
||||||
|
|
||||||
### ⚠️ 新踩到的 gofmt 陷阱
|
|
||||||
在**文档注释**里写 `''` 会被 gofmt 重排成 `”`(Go 1.19+ 的 doc comment 规范化:`` `` ``→`“`、`''`→`”`),
|
|
||||||
导致 `gofmt -l` 反复报未格式化。**注释里避免使用 `''`**(改写为「空字符串」等文字)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 账号停用体验修复:独立成功页(/deactivated)
|
|
||||||
|
|
||||||
### 问题
|
|
||||||
停用账号后 `DeactivateProfileAction` 把用户重定向到 `/login`,只在登录页顶部弹一条 flash。
|
|
||||||
用户会觉得「被丢回登录页」,下意识以为操作没生效。
|
|
||||||
|
|
||||||
诊断结论:flash **确实渲染**(Flush 清 session 数据但保留 session,flash 能带到下一页),
|
|
||||||
真正的体验错配是「停用后落点仍是登录页」——而账号已删/停,用户根本不可能再登录。
|
|
||||||
|
|
||||||
### 方案(用户选「独立成功页(推荐)」)
|
|
||||||
1. `route/user/profile.go`
|
|
||||||
- 新增公开 handler `DeactivatedProfile`:`ctx.SetTitle(i18n.T(ctx.Lang(), "deactivated.title"))` + `ctx.Success("user/deactivated")`。
|
|
||||||
- `DeactivateProfileAction` 末尾 `ctx.Redirect("/login")` → `ctx.Redirect("/deactivated")`,**并移除原来的 `SetSuccessFlash`**(页面自身即提示)。
|
|
||||||
- 顺手修正 `UpdateProfile` 里一处陈旧注释(原写「顺序与 DeactivateProfileAction 保持一致…随后跳登录页」,停用路径已不再如此)。
|
|
||||||
2. `internal/route/route.go`:在公开区(`/explore` 旁)加 `f.Get("/deactivated", user.DeactivatedProfile)`。
|
|
||||||
**必须是公开路由**:停用后会话已 Flush、`reqUserSignIn` 会把人弹回登录页,正是要避免的现象。
|
|
||||||
3. 新建 `templates/user/deactivated.html`:复用 `question/success.html` 的范式(大 ✓ 图标 + 卡片 + 单按钮返回首页)。
|
|
||||||
`templates/embed.go` 是整目录 `//go:embed ... user ...`,新模板**自动被嵌入**,无需改 embed 指令。
|
|
||||||
4. i18n:四份字典都加 `deactivated.title` / `deactivated.desc` / `deactivated.backHome`
|
|
||||||
(`TestDictionaryComplete` 要求 zh_cn/zh_tw/en/ja **键集合完全一致**,漏一份必挂)。
|
|
||||||
同时**删除已无引用的 `admin.flash.deactivated`**(4 份字典同步删;删它不影响对齐组,因为该组最长键是 `admin.flash.blockWordsTooMany`)。
|
|
||||||
|
|
||||||
### 验证
|
|
||||||
- `go build ./...` / `go vet ./...` EXIT=0;`go test ./...`(含本地 PG)**全绿**,
|
|
||||||
其中 `internal/i18n`(键一致性)、`internal/route`、`templates`、`internal/template`(模板解析)均 ok → 新模板可被解析、键齐备。
|
|
||||||
- gofmt 无 diff;行尾未变(4 份 dict + route.go 保持 CRLF,`route/user/profile.go` 实为 **LF**)。
|
|
||||||
|
|
||||||
### 备注
|
|
||||||
- `route/user/profile.go` 的行尾是 **LF**(与「route/** 皆 CRLF」的旧印象不符,按文件单独确认即可)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔴 P0 修复:账号停用后同一邮箱/域名永远无法重新注册
|
|
||||||
|
|
||||||
用户在测试环境 `https://box-dev-api.337845818.best` 注册 `1223383917@qq.com` / `tamakyi` 报 500:
|
|
||||||
```
|
|
||||||
SELECT ... WHERE email = '...' AND deleted_at IS NULL -> rows:0
|
|
||||||
INSERT INTO users ... -> ERROR: duplicate key value violates unique constraint "idx_users_email_unique" (SQLSTATE 23505)
|
|
||||||
ERRO Failed to create new user error="create user: ERROR: duplicate key ..."
|
|
||||||
```
|
|
||||||
|
|
||||||
### 根因(本地已实测复现,日志形态与线上一致)
|
|
||||||
`internal/db/users.go` 的 `Deactivate` 用的是 **GORM 默认的软删除**:
|
|
||||||
`db.WithContext(ctx).Model(&User{}).Delete("id = ?", u.ID)` → 只写 `deleted_at`,**行仍在库里**。
|
|
||||||
而 `User` 的 `email` / `domain` 是 `uniqueIndex`(**索引不含 deleted_at**),于是:
|
|
||||||
- 查重 `validate()` 走 gorm 软删除作用域 → 看不见这行 → 放行;
|
|
||||||
- INSERT 被数据库唯一索引拦下 → `Create` 的 TOCTOU 兜底再查一次仍看不见 → 抛底层 23505 → handler `default` 分支 → 500。
|
|
||||||
|
|
||||||
复现证据(临时测试,已删):停用后 `Unscoped` 查该邮箱 → 行仍在且 `deleted_at.Valid=true`;同邮箱重新注册 → 与线上同样的 23505。
|
|
||||||
**旁证**:`internal/cmd/ban.go:64` 早有注释「用 Unscoped 查找,支持封禁已软删除(自助停用)的账号」——
|
|
||||||
说明「自助停用会留软删除行」是既有事实,但没人把「唯一索引占位」这层后果接上。
|
|
||||||
同类先例:`internal/db/vote_submissions.go:234` 已因同一原因改用 Unscoped 物理删除。
|
|
||||||
|
|
||||||
顺带发现:`BanUserAndCleanup` 的「连带清理」也只是软删除子表(Question/FollowUp/Conversation/Message… 都嵌 `dbutil.Model`),
|
|
||||||
数据实际仍在库;也与停用页「所有数据将被删除」的承诺不符。
|
|
||||||
|
|
||||||
### 改动(`internal/db/users.go`)
|
|
||||||
1. `Deactivate` → 调 `db.purgeUser(ctx, u.ID)`;`BanUserAndCleanup` 同样委托给它。
|
|
||||||
2. 抽私有 `purgeUser`:级联删除**全部改为 `Unscoped()` 物理删除**(追问/消息/消息图片/会话/提问-图片关联/提问/拉黑/功能开关/用户行)。
|
|
||||||
3. `Create` 兜底加固:唯一键冲突且 `validate` 仍查不到时,用新增的 `duplicateFieldErr()`(Unscoped)判定是 email 还是 domain 被占,
|
|
||||||
返回 `ErrDuplicateEmail`/`ErrDuplicateDomain` 友好提示,而不是把 23505 抛成 500。(`isDuplicateKeyError` 复用 vote_submissions.go 里的)
|
|
||||||
4. 回归测试 `TestDeactivatePurgesUser`:停用后 Unscoped 也查不到用户行、提问被清理、同一邮箱可重新注册。
|
|
||||||
|
|
||||||
### ⚠️ 过程中自己踩的 GORM 大坑
|
|
||||||
把级联删除改成「先 `tx = tx.WithContext(ctx).Unscoped()` 再逐条 `tx.Where(...)`」——
|
|
||||||
**事务句柄 clone==0,复用同一个句柄会让上一条语句的 `Where` 条件串到下一条上**,
|
|
||||||
SQL 变成 `DELETE FROM follow_ups WHERE <follow_ups条件> AND <messages条件>` → `字段 conversation_id 不存在 (42703)`。
|
|
||||||
✅ 正确写法:**每条语句都重新取句柄** `tx.WithContext(ctx).Unscoped().Where(...)`(原代码本来就是每条 `tx.WithContext(ctx)`,是我改坏的)。
|
|
||||||
这条已写进 MEMORY.md。
|
|
||||||
|
|
||||||
### 验证
|
|
||||||
- `go build ./...` / `go vet ./...` EXIT=0;`go test ./...`(含本地 PG)**全绿**;gofmt clean;行尾未变(users.go CRLF、users_test.go LF)。
|
|
||||||
- 测试环境实测(脚本 `dev/devcheck-e2e.py`):
|
|
||||||
- ✅ 注册 / 登录 / `/user/profile` / `/user/questions` / `/user/votes`(302→`/user/questions#votes`) / 提问(返回提问成功页)/ 新建投票页 / 停用页 / **停用后跳 `/deactivated` 成功页** 全部正常;
|
|
||||||
- ✅ `/deactivated` 四语言标题渲染正确(账号已停用 / 帳號已停用 / Account deactivated / アカウントを停止しました);
|
|
||||||
- ❌ **停用后同邮箱重新注册仍 500** —— 测试环境跑的是**旧构建**(`/deactivated` 已在,但没有 purgeUser 修复),需重新部署。
|
|
||||||
- ⚠️ 测试环境 DB 里已留下本次测试产生的孤儿行,需清理:
|
|
||||||
`tbe2e0915122816@example.com`(仍在线)、`tbe2e0915122934@example.com`、`tbcheck0915122657@example.com`(均已停用=软删除占位);
|
|
||||||
用户自己的 `tamakyi` / `1223383917@qq.com` 同样是软删除占位(这就是他报错的直接原因)。
|
|
||||||
清理方式:`tamabox ban --email <邮箱> --yes`(CLI 支持查软删除账号),或直接 SQL 删 `users.deleted_at IS NOT NULL` 的行。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ 方案变更(覆盖上一节):停用改为「保留身份占位 + 清空内容」
|
|
||||||
|
|
||||||
用户表示**尚未部署**,并提出:「曾经注册过的账户停用后,再次注册时应提示『该邮箱已被占用但账户已停用,如需重新使用请联系站长』」。
|
|
||||||
经询问确认两点:
|
|
||||||
1. 停用形态 = **保留身份占位**(用户行软删除)+ **物理删除内容数据**(停用页「所有数据将被删除」的承诺仍然成立);
|
|
||||||
2. 「联系站长」**复用页脚已有的「找站长」入口**(`site.footer.admin` → `https://box.shiroko.one/_/tamakyi`),不新增站长邮箱配置项。
|
|
||||||
|
|
||||||
→ **上面「Deactivate 改成物理删除」的做法已被取代**,但其中「级联内容必须 Unscoped 物理删除」的结论仍然有效。
|
|
||||||
|
|
||||||
### 最终实现
|
|
||||||
- `internal/db/users.go`
|
|
||||||
- `purgeUserContent(ctx, tx, userID)`(包级函数):把内容级联删除抽出来,全部 `Unscoped()` 物理删除。
|
|
||||||
- `Deactivate` = `purgeUserContent` + **软删除用户行**(保留 email/domain 占位)。
|
|
||||||
- `BanUserAndCleanup` = `purgeUserContent` + **物理删除用户行**(唯一差别;彻底释放邮箱/域名)。
|
|
||||||
- `validate` 重写为两轮:先查活跃账号(ErrDuplicateEmail/Domain),再 **Unscoped** 查已停用占位行
|
|
||||||
→ 新增 `ErrDeactivatedEmail` / `ErrDeactivatedDomain`;辅助方法 `fieldTaken(ctx, column, value, includeDeleted)`。
|
|
||||||
- 新增 `Reactivate(ctx, id)`(清 `deleted_at`,未停用时返回 ErrUserNotExists),已加进 `UsersStore` 接口。
|
|
||||||
- 上一轮加的 `duplicateFieldErr` 已删除:`validate` 现在本身就覆盖了软删除行,无需在 Create 兜底里重复判定。
|
|
||||||
- `route/auth/register.go`:把两个新错误映射到 `auth.errDeactivatedEmail` / `auth.errDeactivatedDomain`。
|
|
||||||
- i18n ×4:新增 `auth.errDeactivatedEmail`、`auth.errDeactivatedDomain`、`deactivate.note`(键集合保持一致)。
|
|
||||||
- `templates/user/deactivate.html`:desc 下方加一行 `deactivate.note`(uk-text-small uk-text-muted),
|
|
||||||
提前告知「邮箱/域名会作为身份占位保留,无法用同一邮箱重新注册,如需恢复请找站长」。
|
|
||||||
- **新增 CLI `internal/cmd/restore.go`(`TamaBox restore`)**:`--email|--domain [--password] [--yes]`,
|
|
||||||
清掉 deleted_at 完成恢复;`--password` 会重置密码(必须先恢复再改密码——`UpdatePassword` 走普通作用域)。
|
|
||||||
|
|
||||||
### 🐛 顺带修掉的隐藏问题
|
|
||||||
**`cmd/tamabox.go` 里根本没注册 `cmd.Ban`** —— `internal/cmd/ban.go` 写了但命令不可达(`TamaBox ban` 不存在)。
|
|
||||||
现已把 `cmd.Ban` 与 `cmd.Restore` 一起注册进 `app.Commands`。
|
|
||||||
|
|
||||||
### 验证
|
|
||||||
- `go build ./...` / `go vet ./...` EXIT=0;`go test ./...`(含本地 PG)**全绿**;gofmt 全部 clean;行尾未变。
|
|
||||||
- 新增/改写测试:`TestDeactivatePurgesUser`(占位行保留 + 内容清空 + 同邮箱得 ErrDeactivatedEmail + 同域名得
|
|
||||||
ErrDeactivatedDomain + Reactivate 后可正常注册 + 重复 Reactivate 报 ErrUserNotExists)、
|
|
||||||
`TestBanUserAndCleanupRemovesRow`(封禁物理删除且可重新注册)。
|
|
||||||
- **CLI 端到端实测**(临时库 `tamabox-clitest`,用 `NEKOBOX_CONFIG_PATH` 指向临时 ini,跑完已 DROP + 删除产物):
|
|
||||||
- `restore --email clitest@example.com --yes` → deleted_at 变 NULL ✅;重复恢复 → 正确拒绝「未被停用,无需恢复」✅
|
|
||||||
- `restore --password newpass12345` → 密码重哈希 + password_version=1 ✅;密码过短/邮箱不存在 → 明确报错 ✅
|
|
||||||
- `ban --domain banme --yes` → users 与 questions 计数归零(物理删除)✅
|
|
||||||
- ⚠️ `conf.Init` 还需要 `server.salt` / `server.xsrf_key` / `app.external_url` 非空,配置路径可用
|
|
||||||
`NEKOBOX_CONFIG_PATH` 覆盖 —— 这是本地跑 CLI 冒烟的关键(仓库里只有 `conf/app.sample.ini`)。
|
|
||||||
- 测试环境 `devcheck-e2e.py` 的核心回归项已改为断言「重注册被明确拒绝并出现『已停用』提示、且不是 500 兜底」;
|
|
||||||
在**未重新部署的旧构建**上会 FAIL 并打印「サーバー内部エラー」,正是需要重新部署的信号。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 部署后巡检扩容:投票全链路 + 登录态资产(28/28 全绿)
|
|
||||||
|
|
||||||
**背景**:用户重新部署后要求「所有功能都试一遍」。此前 `devcheck-e2e.py` 只验证了「新建投票页 GET」,
|
|
||||||
**投票创建(POST)这条主链路从未端到端跑过**,是覆盖盲区。
|
|
||||||
|
|
||||||
**改动**:`dev/devcheck-e2e.py` 扩到 **28 项**,新增两组:
|
|
||||||
|
|
||||||
1. `check_vote_flow(owner, base)` —— 投票全链路
|
|
||||||
- POST `/user/votes` 建单选投票并 `action=publish` → 断言 302 → `/user/questions#votes`
|
|
||||||
- GET `/user/questions` 断言标题可见,并从 `copyShareURL('...')` 取 **LinkToken**、从 `/user/votes/(\d+)/edit` 取 **ID**
|
|
||||||
- 匿名 GET `/vote/<token>` → 200 且含标题;从 `name="option_id" value="..."` 取选项 ID
|
|
||||||
- 匿名 POST `/vote/<token>/submit`(`option_id` + 数学验证码 + `_csrf`)→ 302
|
|
||||||
- GET `/user/votes/<id>/export` 断言 **`总参与,N` 这行的 N == 1**
|
|
||||||
- 草稿:`action=save` → 302 到 `/user/votes/<id>/edit?saved=1`
|
|
||||||
- 表单型:`type=form` + `form_fields` JSON(field_type/label/required/max_length)→ 发布 302
|
|
||||||
2. `check_user_assets(owner)` —— 登录态接口/导出
|
|
||||||
- `/api/v1/user` 返回 JSON、`/explore` 200、`POST /user/profile/export-json`(JSON)、
|
|
||||||
`POST /user/profile/export`(xlsx,校验 content-type)、停用页 `deactivate.note` 结构断言
|
|
||||||
|
|
||||||
**踩到的坑(都已在脚本里修掉)**:
|
|
||||||
- 单选投票导出 CSV 的**末行是 `总参与,N`**,与各选项票数行同为 `,数字` 结尾 →
|
|
||||||
用 `,\d+$` 一把捞会把总参与也累加进去(实测 `[1,0,0,1]` 求和成了 2)。
|
|
||||||
**必须单独匹配 `总参与,(\d+)`**。
|
|
||||||
- `urllib` 读 **HTTPError 的响应体本身也会超时**(`c.get("/i18n/xx?...")` 这类 302 最易触发),
|
|
||||||
异常从 except 块里抛出会把整轮巡检打断。已把 body 读取包在 try/except 里,并在 `__main__` 外层加兜底
|
|
||||||
——中断时仍打印已完成项,而不是只留一段 traceback。
|
|
||||||
- ⚠️ 复盘我自己的一个假警报:用 `T=/c/Users/Tamakyi\ Gekka/...` 做 gofmt 比对循环时,
|
|
||||||
变量未加引号导致路径断裂 → 全部文件假报 DIFF。**脚本里带空格的路径变量一定要整体加引号**,
|
|
||||||
单文件手跑同一个文件即为 OK,说明是脚本问题而非代码问题。
|
|
||||||
|
|
||||||
**结果**:测试环境 `https://box-dev-api.337845818.best` 跑 `python dev/devcheck-e2e.py` → **28 项,失败 0 项**。
|
|
||||||
10 个改动过的 Go 文件 gofmt 比对全部 OK(无重排),行尾未变。
|
|
||||||
|
|
||||||
**遗留**:巡检每轮会在测试环境留下一个**已停用的身份占位**(邮箱/域名被占用)。
|
|
||||||
本轮新增 `tbe2e*` 若干;用户自己的 `1223383917@qq.com` / `tamakyi` 也仍是占位状态。
|
|
||||||
清理:`TamaBox ban --email <邮箱> --yes`(连行物理删,彻底释放)或 `TamaBox restore --email <邮箱> --yes`(恢复)。
|
|
||||||
@@ -1,614 +0,0 @@
|
|||||||
# 2026-10-19
|
|
||||||
|
|
||||||
## 部署后复测(强制严格档那批)
|
|
||||||
|
|
||||||
- 规则页返回首页已居中:线上 `<div class="uk-margin-large-top uk-text-center">` ✔
|
|
||||||
- 验证码弹窗按钮已缩短:线上 `>送信<` ✔(旧的「認証して送信」已消失)
|
|
||||||
- 箱主面板 `/user/questions`:限制期条幅 + 「查看社区规则」链接都在 ✔
|
|
||||||
- 严格度锁定(admin6 当前 L2):
|
|
||||||
- `/user/profile` 出现「处置期间审核严格度已锁定为最高档,解除后自动恢复你的原设置」✔
|
|
||||||
- `select[name=strictness]` 与「保存审核严格度」按钮均带 `disabled` ✔
|
|
||||||
- 直接 POST `/user/moderation-strictness/update?tab=security` + `strictness=lax` → 302 回 profile,
|
|
||||||
flash 仍是「已锁定」,且选中档仍为 `strict`(说明未写入)✔
|
|
||||||
- 踩坑:表单 action 是 `/user/moderation-strictness/update`(**没有** `/profile` 段);用错路径返回 302 到 `/`,
|
|
||||||
容易误判成「被中间件拦」。另外每次 POST 都要重新取 `_csrf`,用上一页(已跳首页)的 token 会 400。
|
|
||||||
|
|
||||||
## 记忆维护
|
|
||||||
|
|
||||||
MEMORY.md 一度 19KB 被注入截断,已重写压缩到约 4.9k 字符(8.1KB),保留构建/行尾/embed/审核/处置/CLI 等硬约束,
|
|
||||||
删去已在日志里的过程细节。
|
|
||||||
|
|
||||||
## CLI 文档 + 全量代码审阅(同日)
|
|
||||||
|
|
||||||
新增 `docs/cli.md`:命令总览、reports 三种用法与口径、restrict 级别语义/副作用、unrestrict、
|
|
||||||
hide-public、ban/restore、典型处置流程、排错表、明确不做的事。
|
|
||||||
|
|
||||||
审阅中发现并修复(全部本地临时库实测):
|
|
||||||
|
|
||||||
1. **`hide-public` 举报闭环静默失效**(真 bug):`build()` 带 `is_private=false`,在 Update 之后拿它当子查询恒为空集
|
|
||||||
→ 隐藏成功但 `content_reports` 仍 pending。改为 Update 前 `Pluck("id")` 快照,再按 ID 更新/标记。
|
|
||||||
实测:3 条 pending → accepted,未波及其他箱主。
|
|
||||||
2. **整箱分享卡未拦截**:`ShareCard` 直接走缓存,L1–L3 仍能生成整箱图。已在缓存查询前加拦截(fail-closed)。
|
|
||||||
3. **L2 通知邮件误导**:强制私密后仍给提问者发「你的提问有回答」,链接点开 302。改为 `!forcedPrivate` 才发。
|
|
||||||
4. **L2 写入顺序**:`SetPrivate` + `AnswerByID` 两条语句有「公开+已作答」窗口,第二条失败还会留下「私密+无回答」。
|
|
||||||
新增 `Questions.AnswerByIDPrivate`(单条 UPDATE 同时写 answer/is_read/is_private),并补测试 `TestQuestions/AnswerByIDPrivate`。
|
|
||||||
5. **`ListAPI` 绕过 Pager**:L4 停用与页面访问密码只挡 HTML,`/api/v1/user/{domain}/questions` 仍能整页拉。
|
|
||||||
已补两道校验(均返回「用户不存在」,不暴露原因)。**页面密码可被 API 绕过是升级前就存在的问题。**
|
|
||||||
6. **L1 曝光残留**:`GetShowInHome` 的 Count 未过滤(分页总数虚高、末页空白)、热门标签查询未过滤。已补同一子查询。
|
|
||||||
7. **性能**:`ActiveLevel` 由两次查询改一次(取最高 rank);新增 `moderation.EffectiveStrictnessFor(level, raw)`,
|
|
||||||
发布路径上不再重复查库(原先一次发布最多 4 次处置查询)。
|
|
||||||
8. **证据包口径**:分母是「累计公开回答」不是窗口产出 → 文案加「累计」;新增 `待处理 N` / `已处理` 标记,
|
|
||||||
避免把已闭合的举报误读成还挂着;补 `q.deleted_at IS NULL`;`--stats` 说明最长回溯 30 天;CLI 对 `active` 做 nil 守卫。
|
|
||||||
|
|
||||||
验证:`go build ./...` EXIT=0;db/i18n/templates 测试全绿;CLI 全流程(restrict L3 → 证据包 → unrestrict)实测通过;
|
|
||||||
临时库已 DROP。
|
|
||||||
|
|
||||||
遗留(未改,已向用户说明):L4 不覆盖 `/vote/{token}`;`restrict` 没有 `--days` 上限;处置不发邮件给箱主(只有登录后条幅);
|
|
||||||
`abuseAlertCooldowns` 进程内 map 无清理(重启即失效)。
|
|
||||||
|
|
||||||
## 第二轮:处置通知邮件 + 超长确认 + 冷却表清理
|
|
||||||
|
|
||||||
- 新增 `templates/mail/restriction-notice.html` + `mail.SendRestrictionNoticeMail(RestrictionNotice)`:
|
|
||||||
文案由调用方按**箱主自己的语言**(`users.language`,空则 `conf.App.DefaultLang`)预翻译后传入,
|
|
||||||
模板本身单语言。**不含 `--reason` 原文**,只有级别名/到期/规则链接/申诉渠道。
|
|
||||||
i18n 新增 10 个键 ×4 语言:`mail.restriction.*`(6)+ `restriction.level.L1..L4`。
|
|
||||||
- `restrict`:新增 `--force`(`--days > 90` 必填,否则直接报错)与 `--no-notify`;交互模式下超长处置
|
|
||||||
还要再输一次级别名(`--force --yes` 视为两次确认)。发信失败只 warn + 终端提示,**不回滚处置**;
|
|
||||||
发信加了 20s 超时(gomail 无 Timeout 字段,裸 Dial 会等系统 TCP 超时)。
|
|
||||||
- `abuseAlertCooldowns` 加惰性清理(>1024 条时删除已过 24h 的项)。
|
|
||||||
- 顺带:`/user/questions` 限制期条幅改用 `restriction.level.*` 的名称(「收窄公开」)而不是裸 `L2`。
|
|
||||||
|
|
||||||
验证:真实 SMTP sink 实收两封(ja 与 zh-CN),标题/级别名/到期/规则链接均正确、`--reason` 未出现、
|
|
||||||
链接未被 `ZgotmplZ` 转义;`--days 120` 无 `--force` 被拒;黑洞 SMTP 下 23s 内返回(未阻塞两分钟)。
|
|
||||||
`go build ./...`、`go vet`、db/i18n/templates 测试、checktpl 全绿。
|
|
||||||
|
|
||||||
遗留:`unrestrict` 不发通知(只解禁);`restrict` 对 `--days` 无上限(设计如此)。
|
|
||||||
|
|
||||||
## 第三轮:解除处置也发通知
|
|
||||||
|
|
||||||
- 复用同一封模板 `mail/restriction-notice.html`:`appeal` 段改为 `{{if .appeal}}` 可选,
|
|
||||||
解除通知不传该字段就整段省略。
|
|
||||||
- 新增 i18n 键 ×4:`mail.unrestrict.heading/body/note`(body 带域与级别名;
|
|
||||||
note 明确写「处置记录保留用于审计、私密内容不会自动恢复」)。
|
|
||||||
- `unrestrict` 新增 `--no-notify`;撤销前先 `ActiveLevel` 取级别(撤销后读不到),
|
|
||||||
撤销后按箱主语言发解除通知。`affected==0` 直接返回、不发信。
|
|
||||||
- 抽出公共件:`sendNotice`(20s 超时)、`reportNoticeResult`(统一成功/失败输出)。
|
|
||||||
- 文档补充:`unrestrict` 的 `--no-notify`、解除通知内容、以及**到期自动解除不会发信**(无调度器)。
|
|
||||||
|
|
||||||
实测(本地 SMTP sink,2 封):处置信含级别名/到期/申诉段;解除信含「已解除」+ 私密内容提示、
|
|
||||||
**无申诉段**;两封都不含 `--reason`。`--no-notify` 两处都不发信;无生效处置时不发信。
|
|
||||||
`go build ./...`、db/i18n/templates 测试、checktpl 全绿。
|
|
||||||
|
|
||||||
## 上线前最终验收(部署后实测)
|
|
||||||
|
|
||||||
- 公开页:`/` `/rules` `/restricted` `/deactivated` 全部 200;`/rules` 四语言标题正确。
|
|
||||||
- 箱主面板(admin6,L2):条幅显示「收窄公开」+ 规则链接;严格度面板锁定提示 1 处、下拉 disabled。
|
|
||||||
- **L2 写入路径实测**(关键,因为改成了 `AnswerByIDPrivate` 单条 UPDATE):
|
|
||||||
对未回答题 q95 作答 → 302 回题目页 → flash「已保存为私密:当前处于平台处置期,回答不会公开」→ 匿名访问 302。
|
|
||||||
- 公开列表 API `/api/v1/user/{domain}/questions` 正常返回 `{code,data:{data,next_cursor,has_more}}`(新增的 L4/密码校验没有打断正常路径)。
|
|
||||||
- 整箱分享卡:受处置箱主 302(拦截生效);未处置箱主 500,原因是**测试站未配置字体**(`分享图生成失败:字体文件未配置或无法加载`),与本次改动无关。
|
|
||||||
这一点反而证明拦截位置在生成之前。
|
|
||||||
- 未能自测:CLI 发信(restrict/unrestrict 通知)需要在服务器上跑命令,只能由用户执行。
|
|
||||||
|
|
||||||
注意:admin6 现有测试题 73/88/89/90/94/95;其中 95 已被我回答并存为私密(内容带 `restrict self-check`)。
|
|
||||||
admin6 的 L2 于 2026-10-19 21:29 自动到期。
|
|
||||||
|
|
||||||
## 邮件模板统一(用户反馈:页脚与其它邮件不一致)
|
|
||||||
|
|
||||||
- 原因:`restriction-notice.html` 是我新写的,页脚用了自造的 `© 2026 TamaBox`,与项目其它 11 封邮件的
|
|
||||||
「此邮件由系统自动发送,请勿直接回复。<br/>{{ .year }} 狼的提问箱」不一致。
|
|
||||||
- 处理:整封模板按 `question-reported.html` 的结构重写(24px 标题 + `{{.time}}` 副行、同样的卡片/页脚/外间距),
|
|
||||||
`time` 由 `mail.SendRestrictionNoticeMail` 统一注入(调用方不用各自拼时间)。
|
|
||||||
- 实测(本地 SMTP sink):页脚文本与 question-reported 完全一致,`© ... TamaBox` 已消失,头部时间行存在。
|
|
||||||
- 注意:站点名在所有邮件模板里都是硬编码「狼的提问箱」,不是配置项;改站名要一起改 12 个模板。
|
|
||||||
|
|
||||||
## 页脚精简(移动端一行放不下)
|
|
||||||
|
|
||||||
- 去掉「盘」链接(`https://share.shiroko.one`,zh 文案就叫「盘」),并删除已无引用的
|
|
||||||
`site.footer.drive` 键(四语言同步删,保证 `TestDictionaryComplete` 词条数一致)。
|
|
||||||
- `site.footer.rules`:zh-CN「社区规则」→「规则」,zh-TW「社群規則」→「規則」(en/ja 本就短)。
|
|
||||||
- 现在页脚一行:规则 | 找站长 | 公告 | 聊天 | 状态 | 主页。
|
|
||||||
- 注意:`templates/base/footer.html` 用 `|` 分隔,行首行尾各带一个,删整行时不会留下悬空分隔符(已核对)。
|
|
||||||
|
|
||||||
## 页脚改两行(用户改主意:盘恢复)
|
|
||||||
|
|
||||||
- 恢复 `https://share.shiroko.one`「盘」链接与 `site.footer.drive` 键(zh-CN 盘 / zh-TW 盤 / en Drive / ja ドライブ)。
|
|
||||||
- `site.footer.rules` 文案恢复:社区规则 / 社群規則(en/ja 不变)。
|
|
||||||
- 布局两行(靠 `<br>` 断行,不用额外 CSS):
|
|
||||||
第一行 `社区规则 | 找站长`,第二行 `盘 | 公告板 | 聊天室 | 监控板 | 导航页`。
|
|
||||||
- 键插在 `site.footer.rules` 之后(组末),空格数按同长度键复制,gofmt 对齐不变;dict 仍 CRLF。
|
|
||||||
- 验证:build / checktpl / i18n / templates 测试全绿。
|
|
||||||
|
|
||||||
## 页脚改为「从少到多」四行
|
|
||||||
|
|
||||||
版权行与存活计时移到最后两行(原来在最上面),整块按文字量递增:
|
|
||||||
`社区规则 | 找站长` → `盘 | 公告板 | 聊天室 | 监控板 | 导航页` → `Powered by NekoBox | ICP` → `提问箱已成功存活:…`。
|
|
||||||
实现只调整了 `<br>` 的位置,仍共 4 个 `<br>`,所以块高与顶部空行跟原来一致(未新增占位)。
|
|
||||||
注意:`box_runtime` 的脚本按 id 取元素,移动位置不影响;但 `<br>` 数量别随意增删,否则页脚高度会变。
|
|
||||||
|
|
||||||
## 前端样式体检(只出结论,未改代码)
|
|
||||||
|
|
||||||
量化事实(脚本统计 templates/**/*.html):
|
|
||||||
|
|
||||||
- 全站 **0 个 .css 文件**,样式全部内联:`base/header.html` 内含 **17.5KB CSS + 10.6KB JS**(模板总 42.5KB),
|
|
||||||
footer 3.0KB JS;全模板共 **24 个 `<style>` 块**,`user/profile.html` 自身还有 11.2KB CSS + 22.8KB JS(总 62KB)。
|
|
||||||
- `--tb-*` 设计变量 **只在 `html[data-theme="dark"]` 里定义**,light 主题没有 `:root` 一套;
|
|
||||||
调用处全靠 fallback(`#e9e9e9` / `#e5e5e5` / `#fafafa` / `#333` / `#666` 混用),explore 等页面还写死色值。
|
|
||||||
- **深色模式 FOUC**:`applyTheme(getTheme())` 在 body 内、`<nav>` 之后执行(header.html:812),
|
|
||||||
深色用户刷新时顶栏会先白闪一下。修法:把 theme 初始化内联脚本提到 `<head>`。
|
|
||||||
- 结构:`uk-container-xsmall`(750px)由 header 统一输出,仅 explore/vote/chat 页面自套 small;
|
|
||||||
`question/list.html` 仍用 `<center>`、内联 `style="min-height:260px"`,用户名/简介颜色由用户自选压在背景图上,无 scrim → 可读性无保障。
|
|
||||||
- 首页 `home.html` 是纯 UIkit 卡片 + `<hr>` + `style="color: red"` + `<pre>` 统计 + 首页挂 Waline(250 行 locale)。
|
|
||||||
|
|
||||||
建议分档(未实施):P0 = light/dark 双套 token + 圆角/阴影/中文字体栈 + 首页重排 + 背景图 scrim;
|
|
||||||
P1 = header CSS 抽成 `static/css/*.css`(注意 `embed.go`/`static/fs.go` 都是显式清单)+ FOUC 修复;
|
|
||||||
P2 = 图标统一(UIkit icons 取代 emoji/位图 png)、标签减量、viewpoint/meta 去重。
|
|
||||||
|
|
||||||
## P0 前端美化(已实施)
|
|
||||||
|
|
||||||
用户拍板:卡片**结构不变,只加圆角和柔阴影**;先做 P0,不抽 CSS 文件。
|
|
||||||
|
|
||||||
改动文件:
|
|
||||||
- `templates/base/header.html`
|
|
||||||
- head 顶部新增**主题初始化内联脚本**(深色用户不再白闪;原 `applyTheme` 在 body 里、nav 之后执行)。
|
|
||||||
顺手删掉 body 脚本里的 `applyTheme(getTheme())`,只保留切换逻辑。
|
|
||||||
- 新增**设计令牌块**:`:root` 亮色 + `html[data-theme=dark]` 暗色,同名 14 个(bg/surface/surface-2/surface-hover/
|
|
||||||
border/text/text-secondary/text-muted/primary/primary-weak/radius/radius-sm/radius-xs/shadow/shadow-hover/font)。
|
|
||||||
原暗色块里的 7 个变量定义已删除(现在统一在令牌块)。
|
|
||||||
- 新增**基础质感层**:html+body 底色与字体栈(system-ui + 苹方/冬青黑/雅黑)、卡片 12px 圆角 + 双层柔投影、
|
|
||||||
按钮/输入 8px、标签 6px、alert 10px、modal 14px、导航栏白底、`:focus-visible` 描边、`tabular-nums`、
|
|
||||||
`prefers-reduced-motion`。
|
|
||||||
- `.tb-cover`:封面遮罩;默认浅色(配 `.uk-light` 的黑字兜底),`.tb-cover-dark` 为深色。
|
|
||||||
- `.tb-img-uploader` 上收到 header(原来 item / new-question 各写一份**内联 style**,写死 #f8f8f8 → 深色下露白)。
|
|
||||||
- `templates/home.html`:整页重排为 hero(标题+一句话+探索/规则按钮)+ 3 张功能卡 + 域名迁移公告
|
|
||||||
+ 提醒列表 + `<details>` 折叠更新日志 + 3 个数字统计卡(`.SiteStats` 数值字段,30 分钟 JS 刷新改为按字段更新)。
|
|
||||||
全部原有信息保留;页内 `style=` 归零。
|
|
||||||
- `templates/question/list.html`:封面加 `.tb-cover`(仅当有背景图),新增 12 行 JS 按用户名颜色亮度
|
|
||||||
决定遮罩方向;`min-height:260px` 内联改为 `.tb-profile-cover`。
|
|
||||||
- 6 个页面样式块里的硬编码色值收敛到令牌(explore/question-list/profile/list/item/vote-page,共 ~84 行),
|
|
||||||
CRLF 文件用字节级替换(未改动行尾)。
|
|
||||||
|
|
||||||
验证:`go test ./templates/... ./internal/i18n/...` 全绿;`go build`(overlay)通过。
|
|
||||||
新增 `templates/zz_preview_test.go`:`TEST_PREVIEW=1 go test ./templates -run TestGenPreview` 产出
|
|
||||||
`outputs/preview-{home,box,explore}-{light,dark}.html`,用无头 Chrome 截图核对过亮暗两套。
|
|
||||||
⚠️ 本机 **Chrome 新版 headless 会挂住**,必须 `--headless=old`,且页面外部资源要本地化
|
|
||||||
(`outputs/preview-assets/`,从 cdnjs.snrat.com 取 UIkit 3.3.3;cloudflare/jsdelivr 在本机不通)。
|
|
||||||
|
|
||||||
## 封面遮罩 P0 缺陷修复(重要)
|
|
||||||
|
|
||||||
**症状**:`.tb-cover::after` 遮罩不渲染,且**完全不报错**——截图对比像素只差 ±1(只看着像"没效果")。
|
|
||||||
|
|
||||||
**根因**:UIkit 3.3.3 自带
|
|
||||||
`.uk-flex::after,.uk-flex::before{display:none}`(优先级 0,2,0),
|
|
||||||
而封面元素是 `uk-flex`,我写的 `.tb-cover::after`(0,1,1)优先级不够 → 被压成 display:none。
|
|
||||||
定位方法:`getComputedStyle(el,'::after').display` 得到 `none`;再逐个 `classList.remove` 试 →
|
|
||||||
去掉 `uk-flex` 立刻变 `block`。最后在 uikit.min.css 里 grep 到那条规则。
|
|
||||||
**修法**:选择器写成 `.tb-cover::after, .tb-cover.uk-flex::after` 并显式 `display:block`。
|
|
||||||
|
|
||||||
**实测效果**(无头 Chrome 截图 + 像素测量,非目测):
|
|
||||||
- 深色渐变背景 + 白字:封面底部亮度 0.229→0.102,白字对比度 **3.77→6.89**(过 WCAG AA 4.5)
|
|
||||||
- 线上默认背景(`box_background.png` 2048×1152,接近全白)+ 黑字:0.912→0.971,对比度 19.25→20.42,
|
|
||||||
即**对默认背景几乎没有可见影响**
|
|
||||||
- 亮度判定 JS 已验证:`#fff` 会加 `tb-cover-dark`,`#000` 不加。
|
|
||||||
|
|
||||||
**教训**:CSS 改动不能只看"规则写上了/类加上了",必须量像素。伪元素 + UIkit 工具类组合要在计算样式层验证。
|
|
||||||
|
|
||||||
## 账号背景图的真实链路(回答用户提问时查证)
|
|
||||||
|
|
||||||
- 注册时把 `[upload] default_background` **一次性复制**进 `users.background`(`route/auth/register.go:51`),
|
|
||||||
不是每次渲染兜底 → 之后改配置**不影响存量用户**。
|
|
||||||
- 页面只用 `.PageUser.Background` 渲染 `data-src`;为空则无背景图、无遮罩。
|
|
||||||
- 分享卡是另一条路:`route/question/share.go:345` 用 `user.Background`,为空时回退 `conf.App.ShareBackground`。
|
|
||||||
- 线上 `box.shiroko.one/_/tamakyi` 实际值 = `https://r2.337845818.best/tamabox/box_background.png`。
|
|
||||||
- 预览里那张"渐变图"是 `zz_preview_test.go` 造的测试数据(data: URI 渐变),**不是产品行为**;
|
|
||||||
顺带发现 Go 模板会把 `data:` URI 在属性上下文里替换成 `#ZgotmplZ`(真实 http URL 不受影响)。
|
|
||||||
|
|
||||||
## 设置页「防护设置」缺白底(用户报)
|
|
||||||
|
|
||||||
根因:其它面板都包在 `<section class="tb-pcard">`,只有内容防护面板(profile.html:842)是裸 `div.profile-panel`,
|
|
||||||
直接露出页面灰底 `--tb-bg`。修法:包一层 `section.tb-pcard`,并把 `.tb-pcard` 的 `background:#fff` 改成
|
|
||||||
`var(--tb-surface)`(删掉随之冗余的 dark 覆盖)。
|
|
||||||
实测(计算样式):account 与 security 面板 bg 亮色均 `rgb(255,255,255)`、暗色均 `rgb(36,40,48)`,圆角 10px,完全一致。
|
|
||||||
顺手把 `security` 加入 `render_test.go` 的面板渲染测试列表(原来只测 account/question/chat/vote)。
|
|
||||||
|
|
||||||
⚠️ 教训:用脚本批量缩进/hash 插入时先 dry-run。这次第一版脚本把「空行」也当成需要写 `<section>` 的行,
|
|
||||||
一次性插了 5 个多余标签;靠立刻 `sed` 复查发现并回滚。
|
|
||||||
|
|
||||||
## P1 第一步:CSS 抽离(已完成)
|
|
||||||
|
|
||||||
- 新增 `static/css/base.css`(23KB,含注释;gzip 后约 6KB),内容 = 原 header 里的
|
|
||||||
设计令牌 / UIkit 补丁 / 深色覆盖 / 质感层 / 顶栏响应式 / 标签 / Markdown / 进度条 / 移动端底栏。
|
|
||||||
- `header.html`:内联 `<style>` 从 17.5KB 降到只剩 1 个(算数验证码,跟着 `{{ if CaptchaType }}` 条件渲染);
|
|
||||||
改为 `<link href="/static/css/base.css?v=20261019a">`,位置在 UIkit 样式之后(保证覆盖顺序)。
|
|
||||||
单页 HTML 42.5KB → 30.9KB。
|
|
||||||
- `static/fs.go` 的 `//go:embed` 显式清单加 `css/base.css`(漏了就是运行期 404 + 全站无样式,编译期不报错)。
|
|
||||||
- 静态缓存沿用既有 `CacheControl: public, max-age=86400`;靠 `?v=` 版本号破缓存。
|
|
||||||
|
|
||||||
**验证方式**:改动前后各截一次同样尺寸的截图做像素比对。
|
|
||||||
home-light / home-dark / explore-light 三页差异均 **0.024%**,bbox 只有 (11,56)-(29,74) 一个 18×18 方块
|
|
||||||
= 导航栏随机动物 emoji(每次加载都变),不是样式回归。go build + 模板/i18n 测试全绿。
|
|
||||||
|
|
||||||
## 测试环境复测(P1 CSS 抽离 + 防护面板白底,已上线)
|
|
||||||
|
|
||||||
- `GET /static/css/base.css?v=20261019a` → 200 / `text/css` / **23088 字节,与本地 `static/css/base.css` 逐字节一致** /
|
|
||||||
`Cache-Control: public, max-age=86400` / gzip 后 7109 字节。
|
|
||||||
- 首页 32747 字节(改动前 52747),gzip 10534(改动前 13642);页面内联 `<style>` 只剩验证码 + 首页自己的样式块。
|
|
||||||
- 设置页(登录 admin6):`data-profile-panel="security"` 下已有 `<section class="tb-pcard">`;
|
|
||||||
用线上 HTML + 本地 CSS 复算样式 → account / security 两块 `bg=rgb(255,255,255)`、`border-radius:10px`,页面底 `rgb(247,248,250)`。
|
|
||||||
- `/_/admin6` 公开页:`class="... uk-light tb-cover"`,`:after` 计算值 **display=block, width=689px**
|
|
||||||
→ 遮罩在真实部署上确实渲染了(UIkit `.uk-flex::after{display:none}` 的坑已解决)。
|
|
||||||
该用户用户名为 `rgb(0,0,0)`,走浅色遮罩(符合预期)。
|
|
||||||
- 各页 200:`/`、`/explore`、`/rules`、`/user/questions`;`/user/vote-form` 无参 302 属正常。
|
|
||||||
- 首页首次访问总字节略增(10.5+7.1 vs 13.6KB gzip),但 CSS 跨页缓存后每页少 3.1KB,且服务端少渲染 17.5KB 模板。
|
|
||||||
|
|
||||||
## 专属测试账号(用户指定)
|
|
||||||
|
|
||||||
- `https://box-dev-api.337845818.best/_/admin6`,邮箱 `[email protected]`,密码 `adminadmin`。
|
|
||||||
- 登录需算数验证码,curl 流程:GET /login 取 `_csrf`(同时存 cookie)→ `GET /api/captcha/generate` 拿 `{id, question}`
|
|
||||||
→ 算答案 → `POST /api/captcha/verify`(头 `X-CSRF-Token`,体 `{id, answer}`)→
|
|
||||||
`POST /login`(email/password/_csrf/`g-recaptcha-response=id:answer`)→ 302 到 `/`,cookie 已带登录态。
|
|
||||||
|
|
||||||
## P2 一致性收尾(已实施,未部署)
|
|
||||||
|
|
||||||
1. **导航图标**:🌐🌙☀️ emoji → 内联单色 SVG(`stroke: currentColor`,跟随主题/悬停色)。
|
|
||||||
⚠️ 原计划用 UIkit icons,实测 **UIkit 3.3.3 的图标集里没有 moon / sun**(只有 world),
|
|
||||||
照写会渲染成空白按钮——所以三个图标全部改成内联 SVG,也不依赖 JS。
|
|
||||||
2. **标签**:投票类型 20 处 `uk-label uk-label-primary`(实心蓝)→ `.tb-chip` 轻量胶囊;
|
|
||||||
探索页热门标签原来的 5 色轮换(success/warning/danger/primary/secondary)也统一成 `.tb-chip`。
|
|
||||||
箱主面板 `question-list.html` 另 6 处字节级替换(CRLF 保留)。
|
|
||||||
保留:置顶/私密/被举报/已读 这些**语义状态**仍是彩色 uk-label(有含义,不该降级)。
|
|
||||||
3. **遗留写法**:`<center>` ×2 → `div.uk-text-center`;占比条从
|
|
||||||
`div.uk-progress > div.uk-progressbar(style:width)` 改成 UIkit 3 原生
|
|
||||||
`<progress class="uk-progress" value=".." max="100">`,**删掉 25 行 UIkit2 兼容补丁**,
|
|
||||||
只保留 10px 高/6px 圆角的外观收窄。测试 `percent_check_test.go` 的断言同步改成 `value="66" max="100"`。
|
|
||||||
4. **meta**:删掉重复的 `msvalidate.01`(保留 head 顶部一份)、viewport 补 `initial-scale=1`。
|
|
||||||
|
|
||||||
**踩坑**:暗色主题图标原来写 `display:inline`,span 里放块级 `<svg>` 会让导航栏高度变化 → 整页下移 36px。
|
|
||||||
用行亮度曲线做互相关定位到 dy=36,改成 `inline-flex` 后复测差异回到 0.059%(只剩随机 emoji)。
|
|
||||||
教训:**改图标尺寸/显示模式一定要量整页位移**,不能只看局部截图。
|
|
||||||
|
|
||||||
## P2 线上复测(2026-10-19 12:2x)
|
|
||||||
|
|
||||||
- `GET /static/css/base.css?v=20261019c` → 200 / text/css / **23918 字节,与本地逐字节一致**,页面已引用该版本。
|
|
||||||
- meta:viewport 已带 `initial-scale=1`;`msvalidate.01` 只剩 1 份。
|
|
||||||
- 图标:首页 emoji 🌐🌙☀️ 归零,`<svg ... stroke="currentColor">` ×5(语言 1 + 桌面主题 2 + 移动主题 2);
|
|
||||||
计算样式暗色下 `#theme-toggle .tb-icon-dark` = `display:flex`、亮色图标 `none`;
|
|
||||||
**导航高度亮/暗均 80px** → 上次 36px 位移问题确认修好。
|
|
||||||
- 探索页:`.tb-chip` ×25(全部有底色 `rgb(244,246,248)`、文字 `rgb(92,99,112)`、圆角 6px、字号 11.52px);
|
|
||||||
残留彩色 `uk-label` 4 个,均为置顶/私密等语义状态。
|
|
||||||
- 结果页:`/user/votes/{id}/results` 原生 `<progress class="uk-progress" value max>`,计算高度 **10px**、圆角 6px;
|
|
||||||
遍历 10 个结果页 `uk-progressbar` 全部为 0。
|
|
||||||
- 巡检 `/ /explore /rules /login /register /_/admin6 /_/admin3` 均 200。
|
|
||||||
|
|
||||||
## P2 补漏(已改,待部署)
|
|
||||||
|
|
||||||
复测时发现 **结果页与参与页的题型标签仍是实心彩色** —— 那两处的 class 由 Go 模板函数
|
|
||||||
`fieldTypeLabelClass` 生成,我上一轮只按字面 `class="uk-label uk-label-primary"` 搜索,漏了它们。
|
|
||||||
|
|
||||||
- `templates/user/vote-results.html`(2 处)、`templates/vote/page.html`(2 处):序号 + 题型 → `.tb-chip`;
|
|
||||||
状态标签(未开始/已结束/进行中/已参与)保留彩色,属语义。
|
|
||||||
- 删除 `internal/template/template.go` 的 `fieldTypeLabelClass`(19 行,已无引用)。
|
|
||||||
顺带查明:该函数 `text/textarea` 走 `return ""`,注释说"默认灰",**实际渲染成 UIkit 默认蓝**,
|
|
||||||
所以文本框标签一直是蓝的——注释与实现不符。
|
|
||||||
- `.uk-progress::-webkit-progress-value` 圆角改成两端都圆(`-moz-progress-bar` 同步)。
|
|
||||||
⚠️ 起因是误判:我先前测"右端方角"时像素扫描循环上界写成 `range(110,830)`,恰好等于条宽,
|
|
||||||
于是右端永远"恒定不变"→ 假象。放宽到整幅宽度后两版端点曲线完全一致(830/832/833/834/833/832/830),
|
|
||||||
即**没有视觉 bug**。保留改动只为 Firefox 的 `-moz-progress-bar` 保险。
|
|
||||||
- `base.css?v=` → `20261019d`。`go build` + `./templates ./internal/i18n ./internal/template` 全绿。
|
|
||||||
|
|
||||||
**教训(重要)**:像素测量脚本的扫描范围必须覆盖整幅图,循环上界不能与被测对象边界重合,
|
|
||||||
否则会把"边界截断"读成"图形特征"。之前 3.77→6.89 那组对比不知是否也受此影响,需复查时再确认。
|
|
||||||
|
|
||||||
## P2 补漏部署后复测(12:48)
|
|
||||||
|
|
||||||
- `base.css?v=20261019d` 上线,24023 字节与本地逐字节一致。
|
|
||||||
- 删掉 `fieldTypeLabelClass` 后**无运行期报错**:公开投票页 3 个(200,tb-chip 8/16/4,uk-label 各 1 = 状态)、
|
|
||||||
结果页 4 个(200,tb-chip 8/2/0/16,彩色 label 全 0,progress 7/0/10/19)、
|
|
||||||
草稿预览页 2 个(200,tb-chip 2/6)。
|
|
||||||
- 巡检 `/ /explore /rules /user/questions /user/profile?tab=security /_/admin6 /_/admin3` 全 200 无模板错误。
|
|
||||||
- `/user/votes/{id}/preview` 对**已发布**投票 302 到 `/vote/{token}` 是设计如此(vote.go:1093),不是回归。
|
|
||||||
- 截图核对:结果页「1 単選 / 2 多選」已为浅色胶囊,进度条 10px 圆角 6px;参与页字段头同样统一。
|
|
||||||
|
|
||||||
## "移动端语言/主题按钮不见了"排查(结论:非缺陷)
|
|
||||||
|
|
||||||
实测(线上测试环境,`--force-device-scale-factor=1`):
|
|
||||||
- 宽 360/390/414/640 → 顶栏 `.tb-nav-utils` = `display:none`,底栏 `#tb-mobile-utils` = `flex`,
|
|
||||||
rect `0,639,W,46`,语言 `block`、主题按钮 `block`(48×30,SVG 16×16),命中测试落在 `mobile-lang-select`。
|
|
||||||
- 宽 700/768/980 → 底栏 `none`,顶栏 `flex`,右上角地球 + 月亮都可见(截图 `outputs/t-nav-right.png`)。
|
|
||||||
- 页面级差异:`/_/admin6` 底栏**只有月亮没有语言下拉** —— `IsUserPage=true` 让语言块整体不渲染(桌面同样没有)。
|
|
||||||
- 生产 `box.shiroko.one` 仍是旧构建(页面无 `base.css` 引用,底栏是 emoji 月亮),移动端同样正常。
|
|
||||||
|
|
||||||
用户确认是忘了「移动端移到固定底栏」这个设计,不是 bug。
|
|
||||||
留下的真实缺口:**箱主页(最常见的外部入口)完全没有语言切换**,见 MEMORY.md 前端样式条目。
|
|
||||||
|
|
||||||
## 修正:箱主页无语言切换是设计,不是缺口
|
|
||||||
|
|
||||||
上一节把它记成"真实缺口"是错的。用户明确:**箱主页属箱主个性化,以箱主配置为主**,
|
|
||||||
语言入口不出现是有意为之(`IsUserPage` 门控)。
|
|
||||||
MEMORY.md 对应条目已改为"有意设计、别当 bug 修、要动先问"。
|
|
||||||
|
|
||||||
## 备份(用户要求)
|
|
||||||
|
|
||||||
`../TamaBox-v4-mainline-20261019-1445.tar.gz`(940K,302 条目,排除 .git/.workbuddy/outputs)。
|
|
||||||
P1 剩余两项(首屏跟随系统深色、首页 Waline 懒加载)用户明确**先不做**。
|
|
||||||
|
|
||||||
## 主题系统方案(只出设计,未实现)
|
|
||||||
|
|
||||||
用户诉求:箱主在后台选主题、仅对箱主相关页面生效;站点级默认主题在配置里;要一份可复用的主题底稿。
|
|
||||||
|
|
||||||
已确认的现状事实(设计依据):
|
|
||||||
- 设计令牌只有 16 个,集中在 `static/css/base.css` 的 `:root`(亮) + `html[data-theme="dark"]`(暗)。
|
|
||||||
- `<html lang>` 在 `templates/base/header.html:2`,服务端渲染;主题属性可放在这里(无 FOUC)。
|
|
||||||
- `question.Pager`(`route/question/page.go:45`)是 `/_/{domain}` 与 `/_/{domain}/{id}` 的共同中间件
|
|
||||||
→ 打主题标记的唯一合适位置。
|
|
||||||
- `/user/*` 与 `/_/*` 共用 `IsUserPage`(context.go:241 按 URL 前缀判定),所以「箱主相关页面」
|
|
||||||
≠ IsUserPage,必须单独区分,否则设置页会被主题化。
|
|
||||||
- `static/fs.go` 与 `templates/embed.go` 都是显式 embed 清单(新增主题 css 必须手加)。
|
|
||||||
- 用户模型已有大量个性化字段(usernamecolor/introcolor/background…),主题作为 `users.theme` 同族最自然。
|
|
||||||
- 分享卡是服务端绘制图片(`route/question/share.go`),不经过 CSS → 主题不覆盖它。
|
|
||||||
|
|
||||||
关键结论(写进方案的核心):令牌覆盖必须用
|
|
||||||
`html[data-theme-style="x"]`(0,1,1,压过 `:root` 的 0,1,0)+
|
|
||||||
`html[data-theme="dark"][data-theme-style="x"]`(0,2,1,压过 base 的 dark)。
|
|
||||||
只写浅色块会让深色模式被浅色令牌污染(同特异性 + 主题文件后加载)。
|
|
||||||
|
|
||||||
## 主题系统初版(已实现,未部署)
|
|
||||||
|
|
||||||
用户拍板:①作用范围跟随箱主配置(即 /_/ 箱主相关页面)②主题只做配色/图案/整页背景,**不涉及布局**
|
|
||||||
③主题名可本地化 ④分享卡不参与。
|
|
||||||
|
|
||||||
实现:
|
|
||||||
- `internal/theme`:注册表 + 三级回退。`[theme] default/available` → 白名单(slug 正则 + css 是否存在),
|
|
||||||
`Resolve(用户值)`:`default`→空串(不加载)/命中→该 slug/其余→站点默认。"" 与 "default" 语义不同:
|
|
||||||
""=跟随站点默认,"default"=箱主明确选基础外观。
|
|
||||||
设置页色块从主题 css 浅色块里抓 `--tb-bg`/`--tb-primary` 拼渐变(值必须过色值正则,防注入)。
|
|
||||||
- `users.theme varchar(32) default ''`(AutoMigrate 加列)+ `UpdateTheme` 单列更新。
|
|
||||||
- `question.Pager` 注入 `Data["ThemeSlug"]`(`/_/{domain}`、单题、密码页、分享卡路由共用这一个中间件);
|
|
||||||
header.html 在 `<html>` 输出 `data-theme-style` 并条件加载 `themes/<slug>.css`。
|
|
||||||
- 设置页账号面板末尾新增「主题外观」卡片(独立表单 `POST /user/profile/theme`,不塞进 UpdateProfile,
|
|
||||||
避免与该面板其它字段互相覆盖);`{{ if .Themes }}` 包裹,未配置主题时不显示。
|
|
||||||
- 静态版本号统一 `conf.StaticVersion = "20261019e"`(base.css 与主题 css 共用,改样式只改一处)。
|
|
||||||
- `static/fs.go` 改嵌目录 `css/themes`(以后加主题文件不用再改 embed 清单)。
|
|
||||||
- 主题文件:`_scaffold.css`(底稿,含 19 令牌说明 + 素材规格 + 特异性规范)、`default.css`(恒等,仅色块预览)、
|
|
||||||
`sakura.css`(示例,纯 CSS 网点图案 + 粉调)。
|
|
||||||
- base.css:body 接 `--tb-page-image` / `--tb-page-pattern` / `--tb-page-pattern-size` 两层背景;
|
|
||||||
**UIkit 主色是编译期常量 #1e87f0**,新增映射块把 a/uk-link、uk-button-primary、checkbox/radio、input:focus、
|
|
||||||
uk-tab active 接到 `--tb-primary`(基础主题值相同,默认外观零变化)。
|
|
||||||
|
|
||||||
实测(无头 Chrome,box 页 data-theme-style=sakura):
|
|
||||||
- 浅色 primary `#e5678f`/bg `#fff6f8`/pattern 16px 网点命中;深色 `#ff8ab0`/`#241a1f`,卡片表面各自跟随。
|
|
||||||
- 未选主题的 box 页:无 data 属性、无 themes link、主按钮仍 `rgb(30,135,240)` → 零回归。
|
|
||||||
|
|
||||||
踩坑:
|
|
||||||
1. 第一版 sakura 页「发送提问」还是蓝色 —— UIkit 组件色写死,令牌覆盖不到,必须按组件映射。
|
|
||||||
2. 预览里 `{{ .StaticVersion }}` 为空(预览夹具没这个键),导致按固定串替换主题 css 失败;改用正则。
|
|
||||||
3. `writePreview` 用全文搜 `data-theme="dark"` 判断是否已加属性 → 命中内联主题 css 里的选择器,深色预览没生效;
|
|
||||||
改为只看 `<html ...>` 开标签。
|
|
||||||
4. 脚本改 form/user.go 时把插入块写在结构体中间,拼出 `} Language ...`;靠 Read 复查发现并重写。
|
|
||||||
|
|
||||||
## 主题 follow-up(部署后反馈两处)
|
|
||||||
|
|
||||||
1. **/user/* 不生效主题**:根因是 `ctx.Data["ThemeSlug"]` 只在 `question.Pager`(`/_/{domain}`)里注入,
|
|
||||||
`/user/*` 完全没走这条链路。修法:`internal/context/context.go` 的 Contexter 里按
|
|
||||||
`isThemeScope(path) && c.IsLogged` 注入 `theme.Resolve(c.User.Theme)`;
|
|
||||||
`isThemeScope` 只认 `/user` 与 `/user/`(不能用裸 `HasPrefix("/user")`,会误吞 `/username`)。
|
|
||||||
新增 `internal/context/context_test.go` 守住路径白名单;page.go 的注释同步改。
|
|
||||||
公开页(`/`、`/explore`、`/vote/{token}`)**仍不跟随**登录用户主题,保持站点默认。
|
|
||||||
|
|
||||||
2. **sakura 显示奇怪**(用户选择:顶栏白与粉底割裂 / 卡片纯白太突 / 底色分布不均):
|
|
||||||
- base.css 新增令牌 `--tb-navbar`(`:root` 里 `var(--tb-surface)`,亮暗两条 navbar 规则都改成
|
|
||||||
`var(--tb-navbar, var(--tb-surface))`),主题可单独给顶栏上色。
|
|
||||||
- sakura v2:底色改**纯色** `#fceef3`(不用渐变——渐变会被读成"不均匀"),
|
|
||||||
顶栏 `#fff6f9`、卡片 `#fffafc`(暖白,不再是纯白硬切),网点从 16px/0.14 放宽到 24px/0.05。
|
|
||||||
- 静态版本 `20261019f`;`_scaffold.css` 令牌说明 19→20。
|
|
||||||
- `profile.theme.hint` 四语言文案改成「提问箱页面 + 本人后台生效,公开页保持默认」。
|
|
||||||
- `zz_preview_test.go` 抽出 `inlineThemeCSS()`,并新增 `preview-profile-sakura-*`(后台页面也要在主题下检查)。
|
|
||||||
|
|
||||||
验证:`go build ./...`(须带 webp overlay)、i18n/theme/context/templates 四个包测试全绿。
|
|
||||||
渲染法:抓线上 `/_/admin6` HTML → 把 base.css 指到本地新文件、主题 css 内联 → 无头 Chrome `--headless=old` 截图。
|
|
||||||
|
|
||||||
## 投票页 + 标签主色(部署复核后第二轮)
|
|
||||||
|
|
||||||
- `/vote/{token}` 主题:`route/vote/vote.go` 的 `Page` 里本来就会 `db.Users.GetByID(vote.UserID)` 取 owner,
|
|
||||||
直接在同一分支补 `ctx.Data["ThemeSlug"] = theme.Resolve(owner.Theme)`;box 页 / /user 后台 / 投票页三处
|
|
||||||
全部由发布者配置决定。GetByID 失败(箱主已删)保持站点默认外观。
|
|
||||||
- **`uk-label` 默认色就是 UIkit 主色 #1e87f0**:主题下提问管理的「未回答 / 未读」两枚胶囊还是蓝的。
|
|
||||||
base.css 追加
|
|
||||||
`.uk-label:not(.uk-label-danger):not(.uk-label-warning):not(.uk-label-success):not(.uk-label-secondary)`
|
|
||||||
→ `--tb-primary`。**必须用 :not() 排除语义色**:同特异性且 base.css 在 UIkit 之后,不排除会把
|
|
||||||
danger/warning/success 一起刷成主色。默认主题实测仍是 (30,135,240),零回归。
|
|
||||||
- StaticVersion → `20261019g`。
|
|
||||||
- 线上复核(admin6 专属账号,curl 走完整登录:GET /login 取 _csrf → /api/captcha/generate →
|
|
||||||
/api/captcha/verify(**answer 必须是数字,传字符串会 400 请求格式错误**)→ POST /login):
|
|
||||||
`/user/profile`、`/user/questions` 均已输出 `data-theme-style="sakura"` 与 themes link(v=20261019f)。
|
|
||||||
投票页在 f 版本仍无主题 → 需再部署一次。
|
|
||||||
|
|
||||||
## 投票页语言跟随发布者(第三轮)
|
|
||||||
|
|
||||||
- 抽出 `applyOwnerLocale(data map[string]interface{}, owner *db.User)`(route/vote/vote.go 顶部):
|
|
||||||
同时写 `Owner` / `Lang` / `LangCode` / `ThemeSlug`。
|
|
||||||
签名故意收 Data 而不是 `context.Context`:`Context` 内部字段不可在包外构造,收 map 才能单测
|
|
||||||
(`route/vote/locale_test.go`,纯内存,不碰 DB)。
|
|
||||||
- `Page`:owner 查询提到 `FeatureStateOf` **之前**——否则「投票功能已关闭」这类提示先按访客语言生成。
|
|
||||||
- `Submit`:同样先取 owner 再取 `lang`,保证提交后回跳页的 flash 与页面语言一致;
|
|
||||||
顺带删掉尾部重复的 `GetByID(owner)`(表单类型通知邮件复用同一个 owner),少一次查询。
|
|
||||||
- 四语言 `profile.theme.hint` 与 changelog 范围说明补上「你发布的投票页」。
|
|
||||||
- 验证:vote/i18n/context/theme/templates 全绿;线上 `lang="ja"` + 中文内容的不一致待部署后复测。
|
|
||||||
|
|
||||||
### 部署后复测(v=20261019g)
|
|
||||||
|
|
||||||
- `/vote/7kcP5kR8YnJC`(admin6):`lang="zh-CN" data-theme-style="sakura"`,base/theme css 均 v=g,
|
|
||||||
日文串(投票する / 匿名での参加 / ログイン)全部消失 → 语言与主题同步跟随发布者。
|
|
||||||
- `/ _/admin6`、`/user/profile`:仍为 `zh-CN` + sakura,未回归。
|
|
||||||
- 隔离性:另取三个 token 抽查 —— 4G3i79OCYa2E / 7l0MwF7cLz4T 为 `lang="ja"`(无语言偏好、无主题),
|
|
||||||
9J1zgXwQ05Ai 为 `lang="zh-CN"` 且**无** data-theme-style → 主题不会跨箱主泄漏。
|
|
||||||
- 遗留观感问题(未改,等用户决定):投票页的「允许匿名参与」提示条是 `uk-alert-primary`,
|
|
||||||
仍是 UIkit 浅蓝,在粉底上偏冷。改它需要「浅底 + 深主色文字」的对比度,现有 `--tb-primary-weak`
|
|
||||||
叠白后对比度只有 ~2.6:1,得先加一个 primary-strong 令牌,故未动。
|
|
||||||
|
|
||||||
### 顶栏配色再修(v=20261019h)
|
|
||||||
|
|
||||||
用户反馈「探索/注册链接底色粉色,但整条顶栏是灰色,很突兀」。实测部署版:顶栏 `#fff6f9`、
|
|
||||||
页面 `#fceef3` —— 近白横带压在饱和粉底上,肉眼就读成「白色/灰色的一条」。无头 Chrome 截图 +
|
|
||||||
像素采样确认不是 UIkit 默认 #f8f8f8 回退(那说明变量没生效,现象会连带整页失效)。
|
|
||||||
- sakura 浅色 `--tb-navbar` 直接取页面底色 `#fceef3`(深色同样 `#241a1f`),横带消失;
|
|
||||||
- 顶栏链接 `#999` 在粉底上偏淡 → 主题内微调为 `--tb-text-secondary`,hover 用 `--tb-primary`
|
|
||||||
(深色被 base.css 的 `!important` 规则接管,不受影响);
|
|
||||||
- 顺带 `uk-alert-primary`(匿名参与提示/空列表)的 UIkit 浅蓝是页面上唯一的冷色块,
|
|
||||||
改为 `--tb-primary-weak` 底 + `#a63a5e` 文字(≈5.3:1)。
|
|
||||||
- 以上三条都写在 sakura.css 的「组件微调」段,只对 sakura 生效,默认主题零影响。
|
|
||||||
|
|
||||||
## 主题「砂狼白子 / shiroko」(v=20261019i)
|
|
||||||
|
|
||||||
- `static/css/themes/shiroko.css`:灰蓝银白令牌(浅 #edf2f8 / 主色 #4a7fc1),
|
|
||||||
组件微调只有两条 —— `.uk-card-default` 右下角水印立绘(`background-size: auto 176px`,纯背景层不改布局)、
|
|
||||||
顶栏链接色提到 `--tb-text-secondary`;深色不铺立绘(浅色构图在深色下会糊)。
|
|
||||||
- 素材 `static/images/themes/shiroko/`(68KB):`bg-desktop.webp`(2400×1350) / `bg-mobile.webp`(900×1600) /
|
|
||||||
`hero-fade.webp`(275×620,16% alpha) / `pattern.svg` / `pattern-dark.svg`。
|
|
||||||
- **`static/fs.go` 的 embed 清单加了 `images/themes`**(目录项,子目录自动覆盖)——
|
|
||||||
这是「新增顶层静态目录必须手工登记」的又一次踩点。
|
|
||||||
- 背景是 PIL 合成的:1280 行的竖向渐变 + 白色径向光晕 + 立绘 30% 贴右下 + 狼 10% / 光环 20% / 纸飞机 18%。
|
|
||||||
实测 mean≈244、std≈9;右侧留白区 std 13(立绘可见),中间 std 1.9(正文区干净)。
|
|
||||||
- ⚠️ **`cover` 会按视口裁剪**:竖屏/超宽屏会把贴在图片最右的立绘裁掉,所以立绘不能太靠边 ——
|
|
||||||
位置要留在图片横向 24%~76% 之外就得配 `@media` 换图(本次桌面 16:9 + 移动 9:16 两张)。
|
|
||||||
- 生成素材(ImageGen)8 张里只有 3 张可用:线稿图案(狼/光环/纸飞机)抠白底干净;
|
|
||||||
2 张人像抠底失败(背景不是纯白,`background: transparent` 实际没生效),2 张场景背景几乎没有内容(std<5)。
|
|
||||||
教训:**没有视觉能力时,生成图要用 `mean/std/edge` 先验内容量**,别直接拿去合成。
|
|
||||||
- 素材来源与版权写在 `docs/theme-shiroko.md`(角色版权 NEXON / Yostar,非商用同人)。
|
|
||||||
- 启用需要在 `conf/app.ini` 的 `[theme] available` 里加 `shiroko`(示例配置已更新)。
|
|
||||||
|
|
||||||
## shiroko 插画重做:动物线稿 → Q 版人物(v=20261019k)
|
|
||||||
|
|
||||||
用户反馈「简笔画不是画动物,是人物形象」,插画层整体重做。
|
|
||||||
|
|
||||||
- 新增 `dev/gen_shiroko_chibi.py`:手写 SVG 生成 6 个 Q 版白子(wave/cheer/think/run/doze/peek),
|
|
||||||
3.8KB/张,造型逐项对齐用户立绘(灰发蘑菇头 + 圆钝狼耳 + 倾斜光环 + 青围巾 + 深蓝外套 + 灰裙 + 青黑球鞋)。
|
|
||||||
**生成模型这条路走不通**:`background: transparent` 不生效、无视觉反馈时无法判断好坏;
|
|
||||||
代码画路径反而可控、体积小、任意尺寸不糊。
|
|
||||||
- ⚠️ **cover 必然裁掉贴边插画**:1280×1400 视口下,2400×1350 的背景图只露出中部 1234px,
|
|
||||||
右下立绘被裁成 38px 一条。→ base.css 新增装饰层令牌 `--tb-deco / --tb-deco-size / --tb-deco-position`
|
|
||||||
(固定像素 + 四角定位 + fixed,不随视口比例变化)。
|
|
||||||
- ⚠️ **多背景层的值个数必须恒定**:deco 先写成 4 个值,background-image 变 6 层而 size/position 只有 3 个,
|
|
||||||
CSS 循环取值 → 整页重复小人。改成固定 6 个槽位(1 图案 + 6 装饰 + 1 背景 = 8 层),
|
|
||||||
主题必须写满 6 个(缺的填 none),这条写进了 `_scaffold.css`。
|
|
||||||
- 删掉 `bg-desktop.webp` / `bg-mobile.webp` / `hero.webp` / `dev/gen_shiroko_bg.py`;
|
|
||||||
整页底色改成纯 CSS 渐变(不用图片,也就没有 cover 缩放差异)。
|
|
||||||
- 深色关掉 deco(浅色线稿在深色底上糊成灰块);pattern 里的爪印换成 Q 版头像,
|
|
||||||
opacity 0.42→0.2、tile 240→320(原来铺得太密,像壁纸)。
|
|
||||||
- 验证:默认主题背景仍 `#f7f8fa`、顶栏纯白、卡片纯白(零回归);箱主页与 `/user` 后台四角装饰都不压内容;
|
|
||||||
移动端只留立绘 + peek。
|
|
||||||
|
|
||||||
## shiroko 插画二次返工:手绘 SVG → 参考立绘图生图(v=20261019l)
|
|
||||||
|
|
||||||
用户反馈上次的简笔画"太抽象"。根因判断:用户的立绘是**平涂无描边色块风**,
|
|
||||||
我用手绘线稿去配它,风格本身就冲突,而且没有真人手感,形准也做不到。
|
|
||||||
|
|
||||||
- 新方案:ImageGen **图生图**(`image1` = `static/images/shiroko-cutout.png`,`input_fidelity: high`),
|
|
||||||
提示词统一为「same character, same art style... flat vector illustration, no outlines」+ 换动作句,
|
|
||||||
生成 4 个姿态(wave / cheer / think / doze),1024×1024、纯白背景。
|
|
||||||
**四张角色特征与画风一致**,这一步是手绘做不到的。
|
|
||||||
- 抠图:四角 + 四边中点 8 点 floodfill(容差 16)+ alpha 1px MinFilter 收边,裁 bbox 后统一高 620、
|
|
||||||
WebP q90。实测无白边,光环中间的孔也正确透出。
|
|
||||||
- 删除 `dev/gen_shiroko_chibi.py` 与 6 个手绘 SVG;`chibi/*.webp` 4 张共 106KB。
|
|
||||||
图案里的手绘小头像也删了(和位图人物并排显得最抽象),改成雪花 + 光环 + 圆环 + 点簇。
|
|
||||||
- ⚠️ **deco 的 `center` 定位在矮视口会撞车**:右下立绘 520px 高,1366×768 下从 y=248 一直到底,
|
|
||||||
托腮放 `right center` 会直接站在立绘头顶。百分比是「(视口-图高)×pct」,改用 `top 46%` 并挪到左侧中部。
|
|
||||||
验证视口:1366×768 / 1440×900 / 1280×1400 三档,人物互不重叠。
|
|
||||||
- docs 补了完整提示词骨架、动作句与抠图参数,说明"必须平涂无描边"这条经验。
|
|
||||||
|
|
||||||
## shiroko 布局重排 + 新坐姿(v=20261019m)
|
|
||||||
|
|
||||||
用户反馈:左右不协调、最上边的头被顶栏挡住。
|
|
||||||
- 布局改为**左右各两个**(左上 wave / 左下 sit,右上 cheer / 右下立绘),
|
|
||||||
`≥920px` 真实视口才补中间两个(think / doze),矮屏不放(否则压到立绘)。
|
|
||||||
- 顶部定位从百分比改成**固定 `top 96px`**:顶栏实际高 60px(不是 80),
|
|
||||||
百分比是「(视口-图高)×pct」,小视口下算出来的值会落进顶栏里。
|
|
||||||
`bottom 0` 也改成 `bottom 6px`,避免脚贴页底。
|
|
||||||
- 新增坐姿(盘腿),ImageGen 图生图第 2 次才可用:第 1 张脚被画到画布底边(底部 78 像素贴边),
|
|
||||||
提示词补 `centered composition, whole body fully visible with generous empty margin` 后正常。
|
|
||||||
- ⚠️ **Chrome headless 的 `--window-size` ≠ CSS 视口**:实测 1080 → innerHeight 985、768 → 673
|
|
||||||
(差约 95px 的窗口边框)。所以 `@media (min-height: 1050px)` 在 1080p 下永远不触发。
|
|
||||||
判断媒体查询门槛要按「真实视口」写,截图验证必须加 `--force-device-scale-factor=1`,
|
|
||||||
否则系统 DPI 缩放会让截图尺寸和 window-size 也对不上(曾出现 1920 请求出 1088 宽)。
|
|
||||||
- git:仓库可用(origin = https://git.shiroko.one/tamakyi/tamabox,main),
|
|
||||||
已本地提交 `0c39aff`,但 `credential.helper=store` 里的凭据过期 → `Failed to authenticate user`。
|
|
||||||
打包 `../TamaBox-v4-mainline-20261019-2037.tar.gz`(1.5M,含图片素材)供直接部署。
|
|
||||||
|
|
||||||
### git push 补推
|
|
||||||
|
|
||||||
上次的 `Failed to authenticate user` 是临时态 —— 用户本机 Git for Windows 认证过之后 store 凭据恢复,
|
|
||||||
本轮 `git push origin main` 直接成功(`38cd41a..0c39aff`),`git rev-list --left-right --count origin/main...HEAD` = 0 0。
|
|
||||||
远端 8 个 shiroko 素材文件齐全。教训:**不要用一次 push 失败就断言无凭据**,先看一眼提交是否已存在、再试一次。
|
|
||||||
|
|
||||||
### 部署后「设置页找不到 shiroko」
|
|
||||||
|
|
||||||
线上实测:`/static/css/themes/shiroko.css?v=20261019m` 200 / 5038B 且与本地逐字节一致,
|
|
||||||
说明二进制确实带了新主题;但 `/user/profile` 渲染出来的主题单选只有 `""`、`default`、`sakura`
|
|
||||||
→ **根因是 `[theme] available` 白名单没登记 shiroko**(列表在 `theme.load()` 里按配置构建,启动时读一次)。
|
|
||||||
- `internal/theme/theme.go` 新增 `reportRegistry()` / `discover()`:启动打 info「主题注册表已加载 enabled=...」,
|
|
||||||
并把「css 在、但没登记」的主题以 warning 打出建议的 available 值。
|
|
||||||
没有这段时,这个故障在日志里完全没有痕迹,只能靠猜。
|
|
||||||
`discover()` 用 `fs.Glob(static.FS, "css/themes/*.css")`,用 slugRe 过滤掉 `_scaffold.css`(下划线开头)。
|
|
||||||
- `conf/app.sample.ini`:`available = default,sakura,shiroko` + 补充「没登记就不显示」的注释。
|
|
||||||
- `docs/theme-shiroko.md` 补「部署后看不到主题」两段排查(先 curl css 看 200/404,再看 available)。
|
|
||||||
- 排查口径:**先分「文件没发布」与「没登记」两类**,别一上来怀疑缓存;`StaticVersion` 只影响缓存,不影响列表。
|
|
||||||
|
|
||||||
### shiroko 装饰返工:散射 → 左下角一组(v=20261019n)
|
|
||||||
|
|
||||||
用户反馈「主题正常了,但展示方式怪怪的」。实测线上 1512×982 截图确认三处:
|
|
||||||
1. 四角各一个人 = 贴纸墙纸(人物之间、人物与内容都没有关系);
|
|
||||||
2. 半透明立绘(16%)叠在实心人物旁边 → 虚实混搭,像重影;
|
|
||||||
3. 右下角人物和右对齐的页脚文案(社区规则 / 备案号 / 存活时长)挤在一起。
|
|
||||||
改法:只留左下角一组(sit 330 + wave 190,`left 0/bottom 0`),删掉 hero-fade,
|
|
||||||
纹样减到雪花 + 光环 + 两点(opacity .16、tile 340),卡片补 1px 冷调描边(box-shadow 不占布局)。
|
|
||||||
990px 以下缩小左移,900px 以下(卡片占满宽)不出人物。
|
|
||||||
|
|
||||||
⚠️ **`background-position` 的四值语法必须以边关键字开头**:写成 `max(8px, calc(50% - 555px)) bottom 0`
|
|
||||||
整条声明作废并回落到左上角(表现为人物跑到顶上、背景出现错位色带)。
|
|
||||||
要贴卡片边只能 `left max(8px, calc(50% - 555px)) bottom 0`。
|
|
||||||
|
|
||||||
### 纹样横向条纹(v=20261019o)
|
|
||||||
|
|
||||||
图案元素原先全在 tile 上半部(雪花 y=52 / 光环 62 / 点 150、176),铺开后在整页形成
|
|
||||||
**横向条纹带**(左栏 60px 带宽均值 245→237→249,跳变 12),这是"看着怪"的第二个来源。
|
|
||||||
改成 3 行均布(52 / 132 / 152-212)后收敛为单调 245→236。
|
|
||||||
⚠️ 图片不走 `StaticVersion` 缓存键(`Cache-Control: max-age=86400`),换图案要自己给 url 加 `?v=`。
|
|
||||||
|
|
||||||
### 定稿:半隐在卡片后的单人(v=20261019p)
|
|
||||||
|
|
||||||
用户从 A/B/C/D 四版里选了 D(半隐在卡片后),并追加两条:人物离开底边一点、纹样再淡一点。
|
|
||||||
- `sit` 单人,`left max(6px, calc(50% - 515px)) bottom 26px`;宽度 177px,右缘压进卡片 40px。
|
|
||||||
- **离底边后必须配接触阴影**:一层 `radial-gradient(ellipse, rgba(63,96,138,.22), transparent 72%)`
|
|
||||||
128×15px,垫在脚下一格(deco 第 2 个槽位,绘制顺序在人物之下)。没有它,抬高 26px 会读成悬空。
|
|
||||||
- 纹样 opacity 0.13→0.085(深色 0.09→0.055);`?v=20261019p` 同步改。
|
|
||||||
- 剩余素材(wave/cheer/think/doze)保留但不上页面,换姿态只改 `--tb-deco` 的 url。
|
|
||||||
|
|
||||||
### shiroko 按二次元官网重做:整页场景 + 悬浮磨砂 UI(v=20261019q)
|
|
||||||
|
|
||||||
用户反馈「还是很怪,去网上学一下布局」。实际去看站:
|
|
||||||
- 蔚蓝档案 JP 首页(bluearchive.jp,截图存 outputs/ref/bluearchive_jp_.png):**整屏一张场景插画,
|
|
||||||
logo/按钮/横幅浮在画面上**,没有"白卡片"这层东西;
|
|
||||||
- marshmallow-qa / peing 这类真正的提问箱服务被 Cloudflare 拦,拿不到参照。
|
|
||||||
结论:前几版都卡在"白底 + 留白摆人物"这个结构上,人物没有环境,怎么调位置都像贴纸。
|
|
||||||
|
|
||||||
改法:生成教室场景(ImageGen,1536×1024)→ 裁掉底部 160px 水印 → 混白 38% → 模糊 1.4px →
|
|
||||||
WebP 1920×1080(22KB)/ 720×1003 竖版(6KB);卡片与顶栏改 `rgba(255,255,255,.86)` +
|
|
||||||
`backdrop-filter: blur(16px)`;人物 340px 站在左侧地面、离底 36px + 椭圆接触阴影;浅色不再叠纹样。
|
|
||||||
深色仍用渐变 + pattern-dark(白天教室压在深底上会脏)。
|
|
||||||
|
|
||||||
⚠️ **`ruleBody` 必须剥 CSS 注释**:令牌前写一行注释时,`tokenValue` 按 ";" 切完再按 ":" 取键,
|
|
||||||
会拿到 `/* ... */ --tb-surface`,色块预览静默变空、`TestThemeCSSComplete` 报"缺少令牌"。
|
|
||||||
已加 `commentRe` + `TestRuleBodyIgnoresComments`。
|
|
||||||
⚠️ ImageGen 出的图右下角有 `AI生成 WORKBUDDY` 水印,用作整页背景必须裁掉。
|
|
||||||
|
|
||||||
## 23:40 人像换成「窗边背影」
|
|
||||||
|
|
||||||
- 用户否掉了盘腿坐的正面人像("和背景没那么突兀的")。根因:正面直视 + 无环境 + 饱和度高,
|
|
||||||
在压白 38% 的空教室里就是一块贴纸。
|
|
||||||
- 新素材 `chibi/window.webp`(135×460, 13KB):文生图出侧后方 3/4 视角(双手背在身后)→ 再
|
|
||||||
image-to-image 转"纯色块无描边 + 去窗框"→ 抠白底 → **RGB 压白 16%** → 高 460 / q86。
|
|
||||||
第一步文生图会把窗框画进背景,抠不干净,必须靠第二步重绘。
|
|
||||||
- CSS:`--tb-deco` 首槽换 window.webp,高 380→400px,接触阴影 96×13 复位到脚底;`StaticVersion` → `20261019r`
|
|
||||||
(图片另带 `?v=20261019r`)。删除 `sit.webp`;`docs/theme-shiroko.md` 补两步做法与摆位说明。
|
|
||||||
- 提交 `e95671f` 已推送。教训:**人像与场景的"雾度"要对齐**——场景压白多少,人像也压相当比例。
|
|
||||||
|
|
||||||
@@ -195,3 +195,423 @@
|
|||||||
- ⚠️ `gofmt -l` 对 CRLF 文件一律报警(gofmt 要求 LF)—— 那是既有状态,不是我的改动。
|
- ⚠️ `gofmt -l` 对 CRLF 文件一律报警(gofmt 要求 LF)—— 那是既有状态,不是我的改动。
|
||||||
- ⚠️ 我自己的测试预期写错过两次(别名生效后的期望值、static_version 不是 ini 键)。
|
- ⚠️ 我自己的测试预期写错过两次(别名生效后的期望值、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),必须用绝对路径。
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# TamaBox-v4 长期笔记(索引版)
|
|
||||||
|
|
||||||
明细已拆分到 `topics/*.md`(按需 Read 全文):
|
|
||||||
`coop` 协作 / `build` 构建测试 / `embed` 行尾·模板·embed / `review` 审核处置举报 /
|
|
||||||
`gorm` GORM / `style` 前端样式 / `theme` 主题 / `builder` 主题生成器 / `misc` 其它约定。
|
|
||||||
|
|
||||||
## 协作
|
|
||||||
|
|
||||||
- 结论分级 P0/P1/P2;先根因取证 + 实测复现,别只静态推断。
|
|
||||||
- 改代码前先出变更清单;重大问题显式指出。用户常授权自主推进。
|
|
||||||
- 用户是运营者也是圈内人:处置必须可解释、可留痕、可申诉,不做自动级联封号。
|
|
||||||
|
|
||||||
## 构建与测试(详见 topics/build.md)
|
|
||||||
|
|
||||||
- 无 libwebp/GCC:`internal/storage/s3.go` 挡编译 → overlay 换桩 `dev/s3stub/s3_stub.go`。
|
|
||||||
- 「编译卡死」= `proxy.golang.org` 不可达。每条 go 命令前
|
|
||||||
`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod`。
|
|
||||||
- DB 测试:`DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=15207786347hXPm DB_HOST=127.0.0.1 DB_PORT=5432`(DSN 不带库名)。
|
|
||||||
- store 方法内用 `NewXStore(db.DB)`,别引用包级单例。
|
|
||||||
- 本沙箱起不了 `TamaBox web`(session 固定 redis,6379 未开)→ HTTP 端到端只能部署后做。
|
|
||||||
- python urllib 不稳定(用 curl);python 别用 `/tmp`;PIL 在 `~/.workbuddy/binaries/python/envs/default`。
|
|
||||||
|
|
||||||
## 行尾 / 模板 / embed(topics/embed.md)
|
|
||||||
|
|
||||||
- 行尾混合不可推断,改前用 Python 数 `b'\r\n'`;gofmt 走 `dev/gofmt_keep_crlf.py`。
|
|
||||||
- `templates/embed.go` 是显式清单:新增**顶层**模板必须加 `//go:embed`。
|
|
||||||
- `//go:embed` 的 `*` 不匹配 `_`/`.` 开头文件。
|
|
||||||
|
|
||||||
## 审核 / 处置 / 举报(topics/review.md)
|
|
||||||
|
|
||||||
- 线上通道 = 七牛;裁决在 `censor/policy.go` + `moderation.Check`,新增调用点必须走 `moderation.Check`,不可用时 fail-closed。
|
|
||||||
- 严格度存 `users.moderation_strictness`;受处置用户强制 strict(`moderation.EffectiveStrictness`),查询失败回退用户设置。
|
|
||||||
- 同请求渲染只能用 `ctx.SetError`/`SetInternalError`,不能 `*Flash`。
|
|
||||||
- 处置四级 L1–L4;到期靠 `expires_at` 无 cron;`restrict` 是替换语义;比较用 `RestrictionAtLeast`。
|
|
||||||
- L2 强制私密须在审核通过后、与写回答同一条 UPDATE(`AnswerByIDPrivate`)。
|
|
||||||
- `ListAPI` 不经 Pager 中间件,L4 与页面密码须自校验;分享卡拦截须在缓存查询之前。
|
|
||||||
- 举报:免登录+验证码+限流;同 IP 同题唯一索引 → 幂等;箱主只看次数不给 IP。
|
|
||||||
- 告警 24h≥8 或 7d≥20 → 邮件;`[security] security_alert_email` 为空则静默跳过。
|
|
||||||
|
|
||||||
## GORM(topics/gorm.md)
|
|
||||||
|
|
||||||
- 默认值必须带引号;事务里每句 `tx.WithContext(ctx)`;链式对象带状态,统计/更新各自重建 query。
|
|
||||||
- 软删除+唯一索引 = 槽位永久占用;级联删除一律 `Unscoped()`。
|
|
||||||
|
|
||||||
## 前端样式(topics/style.md)
|
|
||||||
|
|
||||||
- `static/css/base.css`(`?v=` 取 `conf.StaticVersion`,改完 +1)。令牌在 `:root` + `html[data-theme="dark"]`,新样式一律 `var(--tb-*)`。
|
|
||||||
- 移动端(≤640px)语言/主题按钮在固定底栏 `#tb-mobile-utils`;箱主页**有意**无语言切换。
|
|
||||||
- UIkit 3.3.3:`.uk-flex::before/::after{display:none}`;主色是编译期常量,需在 base.css 末尾映射。
|
|
||||||
- 视觉自检:`TEST_PREVIEW=1 go test ./templates -run TestGenPreview`;截图用 `chrome --headless=old`。
|
|
||||||
|
|
||||||
## 主题(topics/theme.md、topics/builder.md)
|
|
||||||
|
|
||||||
- 三个注入点按箱主/发布者的 `Theme`;投票页语言也跟发布者(`applyOwnerLocale`)。
|
|
||||||
- 一套主题 = `static/css/themes/<slug>.css`,只覆盖令牌;显示名用 `{{ themeName ... }}`,别直接 `printf "theme.name.%s"`。
|
|
||||||
- `[theme] available` 是硬白名单(启动时读一次);css 必须成对写亮/暗两个选择器。
|
|
||||||
- 装饰层固定 10 槽,三组 `--tb-deco/-size/-position` 必须等长;位置用两值 `calc(0%|50%|100% ± N)`。
|
|
||||||
- 主题生成器 `dev/theme-builder/`:非破坏性图片处理、`layerSize`/`unitGeom` 必须共用同一函数、
|
|
||||||
`hideDeco` 会把 @media 10 槽全写 none(窄屏素材消失的头号来源)。
|
|
||||||
|
|
||||||
## 其它约定(topics/misc.md)
|
|
||||||
|
|
||||||
- 校验用 `valid:"required;maxlen:N"` 标签。
|
|
||||||
- 只有箱主 `/user` 面板,**无管理员角色、无站方后台**(用户要求不要加):站方动作只走 CLI / 邮件 / 改配置重启。
|
|
||||||
- CLI 有两个构建入口必须同步:根 `go build .` 与 Dockerfile 的 `./cmd/`。
|
|
||||||
- 现有 CLI:`web censor uid ban restore reports restrict unrestrict hide-public`。
|
|
||||||
- git `https://git.shiroko.one/tamakyi/tamabox`(main),`git push` 可直推;`outputs/` 已 gitignore。
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# 构建与测试(长版明细)
|
|
||||||
|
|
||||||
## 构建与测试
|
|
||||||
|
|
||||||
- **无 libwebp/GCC**:cgo 的 `internal/storage/s3.go` 挡编译 → overlay 换桩
|
|
||||||
`dev/s3stub/s3_stub.go`(`-overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json"`)。
|
|
||||||
要检查 s3.go 本身得 tar 复制项目 + `go mod edit -replace`(overlay 换不了 GOMODCACHE)。
|
|
||||||
- **「编译卡死」= `proxy.golang.org` 不可达**。每条 go 命令前
|
|
||||||
`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod`(`go env -w` 沙箱失败)。
|
|
||||||
1C1G 是正式生产环境,别用「沙箱慢」解释超时。
|
|
||||||
- DB 测试:`DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=15207786347hXPm DB_HOST=127.0.0.1 DB_PORT=5432`
|
|
||||||
(DSN 不带库名,`NewTestDB` 自建临时库后 DROP)。带 DB 的 `go test ./...` 全绿。
|
|
||||||
- store 方法内用 `NewXStore(db.DB)`,别引用包级单例(测试下 nil panic;`FeatureStateOf` 有守卫)。
|
|
||||||
- ⚠️ **本沙箱起不了 `TamaBox web`**(session 固定 redis,6379 未开,启动 panic)→ HTTP 端到端只能部署后做。
|
|
||||||
探测端口 200 可能是别的进程,必须看日志确认 `Starting web server` 且无 panic。
|
|
||||||
- ⚠️ **本机 python urllib 不稳定**(代理下偶发读超时,curl 正常);python 别用 `/tmp`
|
|
||||||
(Windows 解析成 `C:\tmp`,与 MSYS 不通)。PIL 在 `~/.workbuddy/binaries/python/envs/default`。
|
|
||||||
@@ -20,7 +20,17 @@
|
|||||||
⚠️ `@media` 里**每个独立配置的 scope 都要写一条** `[data-page-scope="x"]`:页面覆盖块特异性 (0,2,1),
|
⚠️ `@media` 里**每个独立配置的 scope 都要写一条** `[data-page-scope="x"]`:页面覆盖块特异性 (0,2,1),
|
||||||
只写 `html[data-theme-style]`(0,1,1) 的话独立配置页在手机上完全不生效。
|
只写 `html[data-theme-style]`(0,1,1) 的话独立配置页在手机上完全不生效。
|
||||||
⚠️ 竖版图只替换**本来有背景图**的页面(`cfg.bg` 非空),否则等于给纯色页塞了张图。
|
⚠️ 竖版图只替换**本来有背景图**的页面(`cfg.bg` 非空),否则等于给纯色页塞了张图。
|
||||||
- 自检钩子:`?dump=css|zip|validate|install|fx|fxpipe|fxkey|layers|fit|quality`,`?demo=1&fresh=1&rows=N&scope=&loadurl=`
|
- ⚠️ **派生文字色必须走对比度下限,不能按固定比例混**:`paletteVars` 里
|
||||||
|
`text-secondary = mixToContrast(text, backdrops, 5.5)`、`text-muted = 4.5`(WCAG AA)。
|
||||||
|
旧的 `mix(text,bg,0.55)` 在浅底只有 2.9:1 —— 每导一次主题就把 base.css 的 AA 修复覆盖回去。
|
||||||
|
- 半透明卡片(`--tb-surface: rgba(255,255,255,0.86)`)必须 `flatten()` 压到底色再算,否则按纯白算偏低;
|
||||||
|
- 参考背景取「页面底色 + 压平后的卡片」两者更不利的那个;
|
||||||
|
- 手填正文本身不达标时**原样返回**(宁可不够淡,不能产出看不见的字)。
|
||||||
|
- 配色面板有读数(正文/次要/淡文字/主色),<4.5 变橙;默认 primary 已压到 `#3d6fac`(4.63)。
|
||||||
|
- 还要导出 **`--tb-on-primary`**(白字 `#ffffff` / 深字 `#0b1016` 里挑对比度更高的):
|
||||||
|
深色模式主色偏亮,白字只有 2.2~3.1:1。主色卡中间调时两种字都不够 → 面板橙色提示 +
|
||||||
|
`?dump=palette` 的 `warnings`,**不判失败**(是主色要调,不是工具挑错)。
|
||||||
|
- 自检钩子:`?dump=css|zip|validate|install|fx|fxpipe|fxkey|layers|fit|quality|palette`,`?demo=1&fresh=1&rows=N&scope=&loadurl=`
|
||||||
(`?demo=1` 已含 fit 层 + 竖版背景,`@media` 才会生成)。用法:
|
(`?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"`
|
`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"[^>]*>`)。
|
抓 `<pre id="dump">`(正则要带属性:`<pre id="dump"[^>]*>`)。
|
||||||
@@ -31,3 +41,34 @@
|
|||||||
且数值放进 label 里(第三个小 flex 项会被顶出可视区)。
|
且数值放进 label 里(第三个小 flex 项会被顶出可视区)。
|
||||||
- `?dump=css` 的产物要过 Go 侧校验:临时存成 `static/css/themes/<非下划线名>.css` 再 `go test ./internal/theme`,
|
- `?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,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` 连坐。
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# 其它约定(长版明细)
|
|
||||||
|
|
||||||
## 其它约定
|
|
||||||
|
|
||||||
- 校验靠 `valid:"required;maxlen:N"` 标签(`internal/form/*.go`),别在 handler 手写。
|
|
||||||
- 终止性操作(停用)落独立公开页 `/deactivated`(注册在 `reqUserSignIn` 外);改密码后重登才用 flash + `/login`。
|
|
||||||
- 只有箱主 `/user` 面板,**无管理员角色、无站方后台,用户要求不要加**:站方动作只走 CLI / 邮件 / 改配置重启。
|
|
||||||
- git `https://git.shiroko.one/tamakyi/tamabox`(`main`)。**`git push` 能直接推**(本机 Git for Windows 已认证);
|
|
||||||
「Failed to authenticate user」是临时态。推前 `git log --oneline -3`。`outputs/` 已 gitignore,别整目录 add。
|
|
||||||
- 打包交付用 tar.gz(排除 `.git`/`.workbuddy`/`outputs`),放项目同级。
|
|
||||||
|
|
||||||
## 测试环境 box-dev-api.337845818.best
|
|
||||||
|
|
||||||
- 账号 `[email protected]` / `adminadmin`,公开页 `/_/admin6`。
|
|
||||||
- 登录要过算数验证码:`GET /login` 取 `_csrf`+cookie → `GET /api/captcha/generate` → 算 `a op b` →
|
|
||||||
`POST /login` 带 `g-recaptcha-response="<id>:<答案>"`。默认语言 **ja**,四语言文案要一起加。
|
|
||||||
- **部署有延迟**;模板是编译期 embed,**只重启不重编不会变**。用户说「已部署但没变化」→ 先抓线上 HTML 比对标记。
|
|
||||||
- 表单字段:提问 `content`,回答 `answer`。`GET /user/votes` 故意 302;匿名无邮箱提问成功直接 200 渲染。
|
|
||||||
- 自检:`dev/devcheck-e2e.py`(28 项)、`dev/devcheck-restrict.sh`(基线非 200 就别往下走;`RUN=1` 才真跑)。
|
|
||||||
- 图片编码三档(头像 512/q78、背景 1920/q80、通用 1600/q80),**EXIF 方向必须先归一**(转 WebP 会丢)。
|
|
||||||
|
|
||||||
## CLI(`TamaBox <cmd>`)
|
|
||||||
|
|
||||||
- ⚠️ **两个构建入口必须同步**:根 `go build .`(`tamabox.go`)与 Dockerfile 的 `./cmd/`(`cmd/tamabox.go`),
|
|
||||||
否则 `No help topic for '<cmd>'`。
|
|
||||||
- 现有:`web` `censor` `uid` `ban` `restore` `reports` `restrict` `unrestrict` `hide-public`。
|
|
||||||
- `restrict --domain|--email --level L1..L4 --days N --reason` / `unrestrict`;
|
|
||||||
`reports --domain|--email [--days]`(证据包)、`--pending`、`--stats --days N`;`hide-public [--only-reported] [--since N]`。
|
|
||||||
- 本地冒烟:`NEKOBOX_CONFIG_PATH=<ini>`;`conf.Init` 强制 `app.external_url`、`server.salt`、`server.xsrf_key` 非空。
|
|
||||||
|
|
||||||
- 蜜罐 CLI:`TamaBox honeypot hits|stats|prune`(`--since 24h` / `--show-password` / `--yes`),
|
|
||||||
注册须在 `cmd/tamabox.go` + 根 `tamabox.go` 两处同步。
|
|
||||||
- 蜜罐配置:`[security] enable_honeypot` / `honeypot_paths` / `honeypot_retain_days`(默认全关)。
|
|
||||||
|
|
||||||
## 行尾与测试坑(2026-10-21 蜜罐轮)
|
|
||||||
- ⚠️ `internal/route/route.go`、`internal/conf/static.go`、`conf/app.sample.ini` **实际是全 CRLF**;
|
|
||||||
`grep | cat -A` 不显示 `^M` 极易误判 → Python 替换用 `\r\n` 锚点,写后统计 `b'\r\n'` 复核。
|
|
||||||
- ⚠️ Go 里 `time.Duration(60)` = 60 **纳秒**,测试写裸数字会让封禁/冷却瞬间过期 → 一律 `60 * time.Minute`。
|
|
||||||
- ⚠️ 测 `IsBanned` → 403 必须注册中间件(`f.Use(autobanMiddleware(ab, hp))`),只注册路由+NotFound 会被绕过。
|
|
||||||
|
|
||||||
## 配置(ini)相关约定
|
|
||||||
|
|
||||||
- ⚠️ **ini 对未知键静默忽略** → 键名写错 = 配置不生效且无任何现场线索。
|
|
||||||
已加两道防线(`internal/conf/keynorm.go` + `unknown.go`):
|
|
||||||
键名归一化(自动纠正常见误拼)+ 未知键启动告警(附最接近的合法键名)。
|
|
||||||
- ⚠️ **键名必须与代码逐字一致**,包括警告文案里提到的键名 —— 写错等于误导运维。
|
|
||||||
- ⚠️ `MapTo` 在**键缺失/值非法时不覆盖**目标字段 → 可安全预置缺省值。
|
|
||||||
- ⚠️ **bool 的 ini 解析是「有键才赋值」** → 「未配置」与「显式 false」无法用零值区分,
|
|
||||||
必须用 `HasKey` 判定。缺省为 true 的开关尤其要小心(否则未配置 = 关闭)。
|
|
||||||
- 归一化必须在 `MapTo` **之前**执行,否则错拼的键已被丢弃。
|
|
||||||
- 合法键名白名单由**反射从结构体 ini tag 得出**,是单一事实来源;新增配置项自动纳入。
|
|
||||||
- `TestSampleIniHasNoUnknownKeys` 回归检测:官方样例配置零误报。
|
|
||||||
改结构体字段名时必须同步 `conf/app.sample.ini`,否则这个测试会失败。
|
|
||||||
- app.sample.ini 里不要留「代码中从未读取」的死配置(`title` / `xsrf_expire` 已清理)。
|
|
||||||
@@ -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,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。
|
||||||
@@ -13,7 +13,29 @@
|
|||||||
- ⚠️ **UIkit 3.3.3 的 `.uk-flex::before/::after{display:none}`**:给带 `uk-flex` 的元素加伪元素必须写
|
- ⚠️ **UIkit 3.3.3 的 `.uk-flex::before/::after{display:none}`**:给带 `uk-flex` 的元素加伪元素必须写
|
||||||
复合选择器(`.tb-cover.uk-flex::after`),否则静默失效。
|
复合选择器(`.tb-cover.uk-flex::after`),否则静默失效。
|
||||||
- 分类/题型标签一律 `.tb-chip`;彩色 `uk-label` 只留给语义状态(置顶/私密/被举报/已读、投票四态)。
|
- 分类/题型标签一律 `.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`;
|
- 视觉自检:`TEST_PREVIEW=1 go test ./templates -run TestGenPreview` → `outputs/preview-*.html`;
|
||||||
截图必须 `chrome --headless=old --force-device-scale-factor=1`(新版 headless 本机挂住)。
|
截图必须 `chrome --headless=old --force-device-scale-factor=1`(新版 headless 本机挂住)。
|
||||||
⚠️ `--window-size` ≠ CSS 视口;要精确视口就用 iframe 指定 width/height。验证 CSS 靠**像素比对**
|
⚠️ `--window-size` ≠ CSS 视口;要精确视口就用 iframe 指定 width/height。验证 CSS 靠**像素比对**
|
||||||
(PIL 在 venv)而非目测;扫描循环上界不能与物体边界重合。
|
(PIL 在 venv)而非目测;扫描循环上界不能与物体边界重合。
|
||||||
|
- ⚠️ **后台页不是都有卡片**:`/user/questions` 的列表是 `<hr>` 裸行,模板里唯一一处 `.uk-card`
|
||||||
|
是投票 tab 的标题条。默认主题 #fff 看不出来,换带整页插画的主题就"整页透上来"。
|
||||||
|
已在 `.qlist-panel` 上补卡片底;新增后台页时别假设外层已有卡片。
|
||||||
|
|||||||
@@ -27,6 +27,13 @@
|
|||||||
- ⚠️ **UIkit 3.3.3 主色是编译期常量 `#1e87f0`**,令牌注入不进去。base.css 末尾按组件映射(a/uk-link、
|
- ⚠️ **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(...)`)。
|
uk-button-primary、checkbox/radio、input:focus、uk-tab active、`.uk-progress`、`.uk-label:not(...)`)。
|
||||||
- 令牌 `--tb-navbar`(顶栏底色,默认 `var(--tb-surface)`):页面底色偏彩时主题必须覆盖。
|
- 令牌 `--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=`。
|
- 静态资源版本号统一 `conf.StaticVersion`;**图片不走这个缓存键**(`max-age=86400`),换图要给 URL 带 `?v=`。
|
||||||
- 整页背景 `--tb-page-image`(cover)/ `--tb-page-pattern`(平铺,`-size` 控尺寸)/ `--tb-page-position`
|
- 整页背景 `--tb-page-image`(cover)/ `--tb-page-pattern`(平铺,`-size` 控尺寸)/ `--tb-page-position`
|
||||||
(焦点,默认 `var(--tb-page-position, center center)`,旧主题不写也不坏)。底色用**纯色**别用渐变;
|
(焦点,默认 `var(--tb-page-position, center center)`,旧主题不写也不坏)。底色用**纯色**别用渐变;
|
||||||
@@ -42,3 +49,19 @@
|
|||||||
- ⚠️ **抠白底容差 16**:背景 253、白袜子 237 只差 16,容差 22 以上会把袜子吃穿。手内侧/两腿之间是
|
- ⚠️ **抠白底容差 16**:背景 253、白袜子 237 只差 16,容差 22 以上会把袜子吃穿。手内侧/两腿之间是
|
||||||
**闭合**白区,四边 floodfill 进不去,得按连通域单独掏(面积 ≥24),再 1px 收边。
|
**闭合**白区,四边 floodfill 进不去,得按连通域单独掏(面积 ≥24),再 1px 收边。
|
||||||
- ⚠️ `ruleBody` 取令牌前要剥 `/* */`:注释会和后面的令牌粘成一个键,色块预览静默失效。
|
- ⚠️ `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)` 会漂;四周透明边留着不影响观感。
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ func main() {
|
|||||||
cmd.Unrestrict,
|
cmd.Unrestrict,
|
||||||
cmd.HidePublic,
|
cmd.HidePublic,
|
||||||
cmd.Honeypot,
|
cmd.Honeypot,
|
||||||
|
cmd.Backup,
|
||||||
|
cmd.RestoreImages,
|
||||||
|
cmd.R2Outbox,
|
||||||
}
|
}
|
||||||
if err := app.Run(os.Args); err != nil {
|
if err := app.Run(os.Args); err != nil {
|
||||||
logrus.WithError(err).Fatal("Failed to start application")
|
logrus.WithError(err).Fatal("Failed to start application")
|
||||||
|
|||||||
+241
-56
@@ -6,22 +6,29 @@
|
|||||||
|
|
||||||
[app]
|
[app]
|
||||||
production = true
|
production = true
|
||||||
external_url = "https://box.shiroko.one" ; 【必填】站点对外访问地址。用于生成 og:url 与邮件中的全部链接
|
; 【必填】站点对外访问地址。用于生成 og:url 与邮件中的全部链接
|
||||||
|
external_url = "https://box.shiroko.one"
|
||||||
; (找回密码、私信通知等),必须以 http(s):// 开头、结尾不带斜杠
|
; (找回密码、私信通知等),必须以 http(s):// 开头、结尾不带斜杠
|
||||||
; (程序会自动 TrimRight "/")。域名迁移时只改这一处即可全局生效。
|
; (程序会自动 TrimRight "/")。域名迁移时只改这一处即可全局生效。
|
||||||
icp = ""
|
icp = ""
|
||||||
default_lang = "" ; 站点默认语言:zh-CN / zh-TW / en / ja,留空默认简体中文(访客未设置语言时的兜底)
|
; 站点默认语言:zh-CN / zh-TW / en / ja,留空默认简体中文(访客未设置语言时的兜底)
|
||||||
|
default_lang = ""
|
||||||
uptrace_dsn = ""
|
uptrace_dsn = ""
|
||||||
qiniu_access_key = ""
|
qiniu_access_key = ""
|
||||||
qiniu_access_secret = ""
|
qiniu_access_secret = ""
|
||||||
aliyun_access_key = ""
|
aliyun_access_key = ""
|
||||||
aliyun_access_key_secret = ""
|
aliyun_access_key_secret = ""
|
||||||
share_background = "https://your-default-background.jpg" ; 默认分享图背景图链接(1280x720)
|
; 默认分享图背景图链接(1280x720)
|
||||||
font_path = "static/fonts/NotoSansSC-Regular.otf" ; 中文字体文件路径(相对项目根目录)
|
share_background = "https://your-default-background.jpg"
|
||||||
|
; 分享卡绘图用的中文字体(相对项目根目录)。字体文件不在仓库里,必须自备一份,
|
||||||
|
; 否则分享卡接口会返回 500「字体文件未配置或无法加载」。可用 Noto Sans SC / 思源黑体,
|
||||||
|
; 支持 .otf/.ttf/.ttc;放好后无需重启生效。
|
||||||
|
font_path = "static/fonts/NotoSansSC-Regular.otf"
|
||||||
|
|
||||||
[security]
|
[security]
|
||||||
enable_text_censor = true
|
enable_text_censor = true
|
||||||
text_censor_source = auto ; 文本审核服务通道:qiniu(七牛)/ aliyun(阿里云)/ auto(七牛优先、阿里云兜底)。
|
; 文本审核服务通道:qiniu(七牛)/ aliyun(阿里云)/ auto(七牛优先、阿里云兜底)。
|
||||||
|
text_censor_source = auto
|
||||||
; 留空等同于 auto,即引入该配置前的历史行为。
|
; 留空等同于 auto,即引入该配置前的历史行为。
|
||||||
; 显式选 qiniu / aliyun 时必须在 [app] 配好对应凭据,否则启动即报错
|
; 显式选 qiniu / aliyun 时必须在 [app] 配好对应凭据,否则启动即报错
|
||||||
; —— 避免运行期审核必然失败(审核失败是 fail-closed,拒绝提交,
|
; —— 避免运行期审核必然失败(审核失败是 fail-closed,拒绝提交,
|
||||||
@@ -40,72 +47,208 @@ text_censor_source = auto ; 文本审核服务通道:qiniu(七牛
|
|||||||
; 调用会永久返回 code=596 "You have not opened Yundun Content Moderation Service"。
|
; 调用会永久返回 code=596 "You have not opened Yundun Content Moderation Service"。
|
||||||
; 审核失败时日志会打印阿里云返回的 code / msg / requestId 便于定位;
|
; 审核失败时日志会打印阿里云返回的 code / msg / requestId 便于定位;
|
||||||
; 首次出现账号级失败(未开通 / 未授权 / 密钥错误)还会额外打印一条一次性告警。
|
; 首次出现账号级失败(未开通 / 未授权 / 密钥错误)还会额外打印一条一次性告警。
|
||||||
ip_blacklist = ; 逗号分隔的IP黑名单,支持精确IP(如1.2.3.4)和CIDR(如1.2.3.0/24),留空不启用
|
; 逗号分隔的IP黑名单,支持精确IP(如1.2.3.4)和CIDR(如1.2.3.0/24),留空不启用
|
||||||
ip_blacklist_redirect_url = ; 黑名单IP访问时强制跳转到该URL
|
ip_blacklist =
|
||||||
trust_proxy = false ; 是否信任反向代理设置的 CF-Connecting-IP / X-Real-IP / X-Forwarded-For 头。
|
; 黑名单IP访问时强制跳转到该URL
|
||||||
|
ip_blacklist_redirect_url =
|
||||||
|
; 是否信任反向代理设置的 CF-Connecting-IP / X-Real-IP / X-Forwarded-For 头。
|
||||||
|
trust_proxy = false
|
||||||
; 仅当站点部署在可信反向代理(如 nginx/Cloudflare)之后且代理已覆盖这些头时设为 true;
|
; 仅当站点部署在可信反向代理(如 nginx/Cloudflare)之后且代理已覆盖这些头时设为 true;
|
||||||
; 否则保持 false,防止客户端伪造 header 绕过速率限制与 IP 黑名单。
|
; 否则保持 false,防止客户端伪造 header 绕过速率限制与 IP 黑名单。
|
||||||
; ⚠️ 若站点在 Cloudflare 后且需使用下面的自动封禁,必须设为 true,
|
; ⚠️ 若站点在 Cloudflare 后且需使用下面的自动封禁,必须设为 true,
|
||||||
; 否则解析到的将是 Cloudflare 边缘 IP,自动封禁会误伤所有访客。
|
; 否则解析到的将是 Cloudflare 边缘 IP,自动封禁会误伤所有访客。
|
||||||
|
; ⚠️ 蜜罐取证同理:trust_proxy = false 时所有请求的来源都被记成反代地址
|
||||||
|
; (常见 127.0.0.1),取证数据失去意义;而且一次蜜罐封禁会连带封掉整站。
|
||||||
|
; 蜜罐命中回环地址时启动日志会打一次告警提醒检查这里。
|
||||||
; ⚠️ 开启 trust_proxy 时,必须配合「Cloudflare Tunnel / Authenticated Origin Pull /
|
; ⚠️ 开启 trust_proxy 时,必须配合「Cloudflare Tunnel / Authenticated Origin Pull /
|
||||||
; 防火墙仅放行 Cloudflare / EdgeOne 回源网段」锁定源站,否则攻击者可直连源站并伪造
|
; 防火墙仅放行 Cloudflare / EdgeOne 回源网段」锁定源站,否则攻击者可直连源站并伪造
|
||||||
; CF-Connecting-IP / EO-Client-IP 头。无需维护 CDN 庞大 CIDR 清单。
|
; CF-Connecting-IP / EO-Client-IP 头。无需维护 CDN 庞大 CIDR 清单。
|
||||||
;
|
;
|
||||||
; —— 双 CDN(境内腾讯 EdgeOne + 境外 Cloudflare)场景 ——
|
; —— 双 CDN(境内腾讯 EdgeOne + 境外 Cloudflare)场景 ——
|
||||||
; EdgeOne 回源会用 EO-Client-IP 携带客户端真实 IP(等效 CF-Connecting-IP),
|
; EdgeOne 回源会用 EO-Client-IP 携带客户端真实 IP(等效 CF-Connecting-IP),
|
||||||
; 本程序在 trust_proxy=true 时已同时信任这两个权威头。
|
; 本程序在 trust_proxy = true 时同时支持这两个权威头。
|
||||||
; 两家 CDN 并存时必须:① 防火墙仅放行两家回源网段;② 在各家 CDN 的规则引擎中
|
; ⚠️ 权威头只在「TCP 对端可信」时才被采信(回环地址,或命中 trusted_proxies)。
|
||||||
; 删除回源请求里「对方 CDN」的权威头(经 EdgeOne 回源删 CF-Connecting-IP、
|
; 这条是代码里的硬约束,不再依赖运维纪律 —— EdgeOne 不剥离客户端自带的 CF-Connecting-IP,
|
||||||
; 经 Cloudflare 回源删 EO-Client-IP),否则攻击者可借道另一家伪造客户端身份,
|
; 旧实现无条件优先读它,导致经 EdgeOne 进来的请求客户端 IP 完全可控:
|
||||||
; 绕过限流 / 自动封禁 / 单 IP 投票限制。详细步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md。
|
; 限流 / IP 黑名单失效,且 autoban 按假 IP 记账可被用来栽赃任意正常用户。
|
||||||
|
; 双 CDN 并存时:① 两家回源网段都登记到 trusted_proxies;② 防火墙仅放行两家回源网段;
|
||||||
|
; ③ 仍建议在各家规则引擎里删掉「对方 CDN」的权威头(纵深防御)。
|
||||||
|
; 仅用 cloudflared Tunnel 回本机时对端是 127.0.0.1,无需任何额外配置。
|
||||||
|
; 重新启用 EdgeOne 前请先按本节核对,步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md。
|
||||||
|
|
||||||
trusted_proxies =
|
trusted_proxies =
|
||||||
; 逗号分隔的「你自己的」反向代理/负载均衡 IP 或 CIDR(如 127.0.0.1、
|
; 逗号分隔的「你自己的」反向代理/负载均衡 IP 或 CIDR(如 10.0.0.0/8),
|
||||||
; 10.0.0.0/8)。仅当 TCP 对端落在该清单内时才解析 X-Forwarded-For / X-Real-IP。
|
; 或「CDN 直连回源」形态下该 CDN 的回源网段。
|
||||||
; 留空(默认)则这两个头永不被信任,仅采用 CDN 权威头 CF-Connecting-IP /
|
; 回环地址(127.0.0.1 / ::1)无需登记即视为可信对端:cloudflared / 本机 nginx
|
||||||
; EO-Client-IP(需源站已锁定)。
|
; 回源的连接必然由本机进程发起,外部无法伪造。
|
||||||
; 注意:此清单用于你自己的基础设施,不是 CDN 的全量 CIDR 清单。
|
; 除回环以外,CDN 权威头也必须对端命中本清单才会被采信 —— 公网对端带来的
|
||||||
enable_auto_ban = true ; 自动封禁(fail2ban 风格):检测到批量扫描/探测后临时封禁来源 IP
|
; CF-Connecting-IP / EO-Client-IP 一律忽略,改用 TCP 对端地址。
|
||||||
ban_track_notfound = false ; 是否把「未注册路径的 404」计入封禁统计(默认 false)。正常业务
|
; 仅当 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 计数)一律放行,
|
; (域名/题目不存在等均走 302 业务跳转,不产生 404 计数)一律放行,
|
||||||
; 仅命中后台扫描特征路径(/admin、/.env 等)才参与统计封禁,避免
|
; 仅命中后台扫描特征路径(/admin、/.env 等)才参与统计封禁,避免
|
||||||
; 共享出口(CDN 边缘 / CGNAT)下正常用户被 404 探测连坐误伤。
|
; 共享出口(CDN 边缘 / CGNAT)下正常用户被 404 探测连坐误伤。
|
||||||
; 如需恢复对纯 404 字典扫描的封禁再置 true。
|
; 如需恢复对纯 404 字典扫描的封禁再置 true。
|
||||||
ban_threshold_sensitive = 10 ; 统计窗口内命中敏感路径(/admin、/.env、/wp-admin 等)的次数阈值
|
; 统计窗口内命中敏感路径(/admin、/.env、/wp-admin 等)的次数阈值
|
||||||
ban_threshold_404 = 20 ; 统计窗口内返回 404 的次数阈值(仅 ban_track_notfound = true 时生效)
|
ban_threshold_sensitive = 10
|
||||||
ban_window_seconds = 60 ; 统计窗口时长(秒)
|
; 统计窗口内返回 404 的次数阈值(仅 ban_track_notfound = true 时生效)
|
||||||
ban_duration_minutes = 15 ; 临时封禁时长(分钟),到期自动解除,重启即清零
|
ban_threshold_404 = 20
|
||||||
security_alert_email = ; 触发封禁时发送告警邮件的收件人(复用下方 SMTP 发信配置),留空不发送
|
; 统计窗口时长(秒)
|
||||||
|
ban_window_seconds = 60
|
||||||
|
; 临时封禁时长(分钟),到期自动解除,重启即清零
|
||||||
|
ban_duration_minutes = 15
|
||||||
|
; 触发封禁时发送告警邮件的收件人(复用下方 SMTP 发信配置),留空不发送。
|
||||||
|
; ⚠️ 也是蜜罐告警邮件收件人,而蜜罐邮件正文含**明文账号密码**:
|
||||||
|
; 必须是个人邮箱,禁止填群组 / 分发列表 / 团队共用邮箱。
|
||||||
|
security_alert_email =
|
||||||
|
|
||||||
; ── 蜜罐(虚假后台登录界面)────────────────────────────────────────────
|
; ── 蜜罐(虚假后台登录界面)────────────────────────────────────────────
|
||||||
enable_honeypot = false ; 开启后,扫描器探测后台路径(/wp-admin、/.ssh、/.env 等)
|
; ⚠️ 本段一律「注释独占一行、值单独一行」。
|
||||||
; 时不再返回 302/404,而是返回一个仿 WordPress 的假登录页,
|
; 配置加载器开启了 IgnoreInlineComment,行尾 `; 注释` 会**并入值**:
|
||||||
; 并把其提交的用户名/密码记录入库供取证。
|
; honeypot_max_rows = 200000 ; 说明 → 值变成 "200000 ; 说明" → int 解析失败
|
||||||
; 只在「没有任何路由匹配」的路径上生效,真实业务路由零交集;
|
; honeypot_paths = ; 说明 → 值变成 " ; 说明" → 被切成一堆假路径
|
||||||
; 假页面只含 canary 标记与假凭据,绝不含任何真实信息。
|
; 后果是该项静默回退零值,而日志里只会说「配置值不对」,很难联想到是注释造成的。
|
||||||
honeypot_paths = ; 追加自定义诱饵路径(逗号分隔),与内置清单取并集。
|
; 本文件其余段落已全部改成这个写法,新增配置项时请保持一致。
|
||||||
honeypot_retain_days = 30 ; 蜜罐命中记录的保留天数(取证用途),到期自动清理。
|
|
||||||
; ⚠️ 键名是 honeypot_retain_days(没有 tion)。写错会被静默忽略,
|
; 开启后,扫描器探测后台路径(/wp-admin、/.ssh、/.env 等)时不再返回 302,
|
||||||
; 程序会尝试自动纠正并在日志里提示,但别指望它每次都猜对。
|
; 而是返回一个仿 WordPress 的假登录页,并把其提交的用户名/密码写入取证文件。
|
||||||
; 缺省 30 天;写 0 或负数会回退 30(不表示「永不清理」)。
|
; 只在「没有任何路由匹配」的路径上生效,真实业务路由零交集。
|
||||||
; 要关闭自动清理请用 honeypot_auto_clean = false。
|
; 假页面只含 canary 标记与假表单,绝不含任何真实信息。
|
||||||
; 可用 TamaBox honeypot size 查看行数与磁盘占用。
|
enable_honeypot = false
|
||||||
honeypot_auto_clean = true ; 是否启用蜜罐记录的后台自动保留期清理(每小时一轮)。
|
|
||||||
; honeypot_hits 是只追加的审计表,写入完全由外部扫描流量
|
; 追加自定义诱饵路径(逗号分隔),与内置清单取并集。留空 = 只用内置清单。
|
||||||
; 驱动,不清理的话表体积只增不减。关闭后仍可手动 prune。
|
; 用 `TamaBox honeypot paths` 可查看最终生效的完整清单。
|
||||||
; 缺省为 true(开启)。
|
honeypot_paths =
|
||||||
honeypot_max_rows = 200000 ; 蜜罐命中记录的行数上限(第二道闸)。缺省 200000。
|
|
||||||
; 显式写 0 = 不限(会告警提醒表只增不减)。
|
; 蜜罐命中记录的保留天数(取证用途),到期自动清理。
|
||||||
; 保留期是时间维度上界,但攻击者可用海量源 IP 在保留期内
|
; ⚠️ 键名是 honeypot_retain_days(没有 tion)。写错会被静默忽略,
|
||||||
; 把表撑爆。实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
|
; 程序会尝试自动纠正并在日志里提示,但别指望它每次都猜对。
|
||||||
; 实测数据:连续删 12000 插 12000,行数恒定 5 万,文件稳定在 15 MB 不再增长
|
; 缺省 30 天;写 0 或负数会回退 30(不表示「永不清理」)。
|
||||||
; (VACUUM 让空间可复用,不保证文件缩小)。
|
; 要关闭自动清理请用 honeypot_auto_clean = false。
|
||||||
honeypot_threshold = 1 ; 窗口内蜜罐命中多少次即封禁。默认 1(一次即封):蜜罐路径
|
honeypot_retain_days = 30
|
||||||
; 与真实业务路由零交集,正常访客不可能命中,一次即足以判定扫描器。
|
|
||||||
honeypot_ban_minutes = ; 蜜罐封禁时长(分钟)。留空则复用 ban_duration_minutes(15)。
|
; 是否启用蜜罐记录的后台自动保留期清理(每小时一轮)。
|
||||||
; ⚠️ 蜜罐封禁与 enable_auto_ban 相互独立:即使关掉自动封禁,
|
; honeypot_hits 是只追加的审计表,写入完全由外部扫描流量驱动,
|
||||||
; 只要 enable_honeypot = true,蜜罐命中仍会封禁并记录。
|
; 不清理的话表体积只增不减。关闭后仍可手动 `TamaBox honeypot prune`。
|
||||||
default_moderation_strictness = ; 站点默认「内容审核严格度」:lax(宽松)/ standard(标准)/ strict(严格)。
|
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(最严档):保证升级不会放宽任何拦截。
|
; 留空回退 strict(最严档):保证升级不会放宽任何拦截。
|
||||||
; 严格档是三个档位中最严的(拦截面不小于升级前),因此部署后默认拦截面可能略有扩大;
|
; 严格档是三个档位中最严的(拦截面不小于升级前),因此部署后默认拦截面可能略有扩大;
|
||||||
@@ -139,7 +282,8 @@ addr = "127.0.0.1:6379"
|
|||||||
password = ""
|
password = ""
|
||||||
|
|
||||||
[recaptcha]
|
[recaptcha]
|
||||||
captcha_type = recaptcha ; recaptcha(默认), turnstile, math(本地算数验证码,国内访问友好)
|
; recaptcha(默认), turnstile, math(本地算数验证码,国内访问友好)
|
||||||
|
captcha_type = recaptcha
|
||||||
domain = "https://www.recaptcha.net"
|
domain = "https://www.recaptcha.net"
|
||||||
site_key = ""
|
site_key = ""
|
||||||
server_key = ""
|
server_key = ""
|
||||||
@@ -174,6 +318,19 @@ password = ""
|
|||||||
port = 465
|
port = 465
|
||||||
smtp = ""
|
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]
|
[limits]
|
||||||
; ── 全站内容 / 数量上限(运行时配置,修改后重启生效)──────────────────────
|
; ── 全站内容 / 数量上限(运行时配置,修改后重启生效)──────────────────────
|
||||||
; 缺失本段或个别键时按内置默认值运行。长度统一按字符(rune)计数。
|
; 缺失本段或个别键时按内置默认值运行。长度统一按字符(rune)计数。
|
||||||
@@ -214,6 +371,34 @@ chat_max_messages = 200
|
|||||||
; 单用户每日私信条数上限(用户可设范围 0..该值;0 表示不限制每日条数)
|
; 单用户每日私信条数上限(用户可设范围 0..该值;0 表示不限制每日条数)
|
||||||
daily_message_limit_max = 999
|
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]
|
[theme]
|
||||||
; 主题系统:只改配色 / 图案 / 整页背景,不涉及布局。
|
; 主题系统:只改配色 / 图案 / 整页背景,不涉及布局。
|
||||||
; 作用范围:提问箱页面(主页 / 单题 / 密码 / 分享卡)、箱主本人的投票页与 /user 后台;
|
; 作用范围:提问箱页面(主页 / 单题 / 密码 / 分享卡)、箱主本人的投票页与 /user 后台;
|
||||||
@@ -225,6 +410,6 @@ daily_message_limit_max = 999
|
|||||||
; 改选择器与令牌值,再把 <slug> 加进 available —— 不需要改模板。
|
; 改选择器与令牌值,再把 <slug> 加进 available —— 不需要改模板。
|
||||||
; 需要图片素材的,放 static/images/themes/<slug>/(static/fs.go 已嵌 images/themes 目录)。
|
; 需要图片素材的,放 static/images/themes/<slug>/(static/fs.go 已嵌 images/themes 目录)。
|
||||||
default = default
|
default = default
|
||||||
available = default,sakura,shiroko
|
available = default,sakura,shiroko,new-sakura
|
||||||
; ⚠️ 没登记在 available 里的主题,即使 css 已随二进制发布也不会出现在设置页;
|
; ⚠️ 没登记在 available 里的主题,即使 css 已随二进制发布也不会出现在设置页;
|
||||||
; 启动日志会打出「[theme] available 未登记以下主题」提醒。,shiroko
|
; 启动日志会打出「[theme] available 未登记以下主题」提醒。
|
||||||
|
|||||||
Binary file not shown.
@@ -1,346 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""头像上传链路端到端验证(批次 A 分档 + B1 EXIF 归一)。
|
|
||||||
|
|
||||||
为什么走头像而不是提问图:
|
|
||||||
- 提问图那条路要经过内容审核/提问入库才能拿到 key,链路长且易受其它因素干扰;
|
|
||||||
- 头像强制 1:1 且 POST /user/profile/update 直接返回页面,便于立即取回 CDN URL。
|
|
||||||
|
|
||||||
验证两件事:
|
|
||||||
A 分档生效:上传 1600x1600 的大图,头像档 maxSide=512 -> 存盘应为 512x512
|
|
||||||
(改前是 1920,实测体积差 ~7 倍)
|
|
||||||
B1 EXIF Orientation=6 归一:上传「像素 512x256 + EXIF 顺时针 90」的方形图,
|
|
||||||
归一后仍应是 512x512(宽高互换但比例不变,故改用体积+尺寸双断言,
|
|
||||||
并额外验证竖长条图 256x512 + Orientation=6 -> 应变成 512x256)
|
|
||||||
|
|
||||||
用法:
|
|
||||||
python dev/devcheck-avatar.py [--base https://box-dev-api.337845818.best]
|
|
||||||
依赖:Pillow(仅用于本地生成测试图片)
|
|
||||||
清理:TamaBox ban --email <输出中的测试邮箱> --yes
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import http.cookiejar
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import struct
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
||||||
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
|
|
||||||
RESULTS = []
|
|
||||||
|
|
||||||
|
|
||||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
||||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class Resp:
|
|
||||||
def __init__(self, status, headers, body):
|
|
||||||
self.status = status
|
|
||||||
self.h = {k.lower(): v for k, v in headers.items()}
|
|
||||||
self.body = body
|
|
||||||
|
|
||||||
@property
|
|
||||||
def loc(self):
|
|
||||||
return self.h.get("location", "")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def text(self):
|
|
||||||
return self.body.decode("utf-8", "replace")
|
|
||||||
|
|
||||||
|
|
||||||
class Client:
|
|
||||||
def __init__(self, base):
|
|
||||||
self.base = base
|
|
||||||
self.cj = http.cookiejar.CookieJar()
|
|
||||||
self.op = urllib.request.build_opener(
|
|
||||||
urllib.request.HTTPCookieProcessor(self.cj), NoRedirect())
|
|
||||||
|
|
||||||
def _send(self, req, timeout=120):
|
|
||||||
try:
|
|
||||||
with self.op.open(req, timeout=timeout) as r:
|
|
||||||
return Resp(r.status, dict(r.headers), r.read())
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
try:
|
|
||||||
body = e.read()
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
body = b""
|
|
||||||
return Resp(e.code, dict(e.headers), body)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
return Resp(-1, {}, ("TRANSPORT_ERROR: %s" % e).encode())
|
|
||||||
|
|
||||||
def req(self, method, path, data=None, content_type=None, timeout=120):
|
|
||||||
url = path if path.startswith("http") else self.base + path
|
|
||||||
headers = {"User-Agent": UA, "Referer": self.base + path.split("?")[0]}
|
|
||||||
body = None
|
|
||||||
if data is not None:
|
|
||||||
body = data if isinstance(data, bytes) else urllib.parse.urlencode(data).encode()
|
|
||||||
headers["Content-Type"] = content_type or "application/x-www-form-urlencoded"
|
|
||||||
return self._send(urllib.request.Request(url, data=body, headers=headers, method=method),
|
|
||||||
timeout)
|
|
||||||
|
|
||||||
def get(self, p, **kw):
|
|
||||||
r = self.req("GET", p, **kw)
|
|
||||||
return r if r.status != -1 else self.req("GET", p, **kw)
|
|
||||||
|
|
||||||
def post(self, p, data=None, **kw):
|
|
||||||
return self.req("POST", p, data=data, **kw)
|
|
||||||
|
|
||||||
def get_binary(self, url):
|
|
||||||
return self._send(urllib.request.Request(
|
|
||||||
url, headers={"User-Agent": UA}, method="GET"), timeout=60)
|
|
||||||
|
|
||||||
|
|
||||||
def step(desc, ok, extra=""):
|
|
||||||
RESULTS.append((desc, ok, extra))
|
|
||||||
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", desc, (" (%s)" % extra) if extra else ""))
|
|
||||||
return ok
|
|
||||||
|
|
||||||
|
|
||||||
def csrf_of(html):
|
|
||||||
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
|
|
||||||
return m.group(1) if m else None
|
|
||||||
|
|
||||||
|
|
||||||
def captcha(c):
|
|
||||||
j = json.loads(c.get("/api/captcha/generate").text)
|
|
||||||
pid, q = j["data"]["id"], j["data"]["question"]
|
|
||||||
m = re.match(r"\s*(\d+)\s*([+\-×])\s*(\d+)\s*=", q)
|
|
||||||
a, op, b = int(m.group(1)), m.group(2), int(m.group(3))
|
|
||||||
return "%s:%d" % (pid, {"+": a + b, "-": a - b, "×": a * b}[op])
|
|
||||||
|
|
||||||
|
|
||||||
def multipart(fields, files):
|
|
||||||
boundary = "----tbav%s" % uuid.uuid4().hex
|
|
||||||
buf = io.BytesIO()
|
|
||||||
for k, v in fields.items():
|
|
||||||
buf.write(("--%s\r\n" % boundary).encode())
|
|
||||||
buf.write(('Content-Disposition: form-data; name="%s"\r\n\r\n' % k).encode())
|
|
||||||
buf.write(("%s\r\n" % v).encode())
|
|
||||||
for name, fn, ct, data in files:
|
|
||||||
buf.write(("--%s\r\n" % boundary).encode())
|
|
||||||
buf.write(('Content-Disposition: form-data; name="%s"; filename="%s"\r\n' % (name, fn)).encode())
|
|
||||||
buf.write(("Content-Type: %s\r\n\r\n" % ct).encode())
|
|
||||||
buf.write(data)
|
|
||||||
buf.write(b"\r\n")
|
|
||||||
buf.write(("--%s--\r\n" % boundary).encode())
|
|
||||||
return buf.getvalue(), "multipart/form-data; boundary=%s" % boundary
|
|
||||||
|
|
||||||
|
|
||||||
def sniff(d):
|
|
||||||
if len(d) >= 12 and d[:4] == b"RIFF" and d[8:12] == b"WEBP":
|
|
||||||
return "webp", webp_dims(d)
|
|
||||||
if d[:3] == b"\xff\xd8\xff":
|
|
||||||
return "jpeg", jpeg_dims(d)
|
|
||||||
if d[:8] == b"\x89PNG\r\n\x1a\n":
|
|
||||||
return "png", (struct.unpack(">II", d[16:24]) if len(d) >= 24 else None)
|
|
||||||
return "unknown", None
|
|
||||||
|
|
||||||
|
|
||||||
def webp_dims(d):
|
|
||||||
c = d[12:16]
|
|
||||||
if c == b"VP8 " and len(d) >= 30:
|
|
||||||
return (struct.unpack("<H", d[26:28])[0] & 0x3FFF,
|
|
||||||
struct.unpack("<H", d[28:30])[0] & 0x3FFF)
|
|
||||||
if c == b"VP8L" and len(d) >= 25:
|
|
||||||
b = struct.unpack("<I", d[21:25])[0]
|
|
||||||
return (b & 0x3FFF) + 1, ((b >> 14) & 0x3FFF) + 1
|
|
||||||
if c == b"VP8X" and len(d) >= 30:
|
|
||||||
return ((d[24] | d[25] << 8 | d[26] << 16) + 1,
|
|
||||||
(d[27] | d[28] << 8 | d[29] << 16) + 1)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def jpeg_dims(d):
|
|
||||||
i = 2
|
|
||||||
while i + 9 < len(d):
|
|
||||||
if d[i] != 0xFF:
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
m = d[i + 1]
|
|
||||||
if m in (0xC0, 0xC1, 0xC2, 0xC3):
|
|
||||||
h, w = struct.unpack(">HH", d[i + 5:i + 9])
|
|
||||||
return (w, h)
|
|
||||||
if m in (0xD8, 0xD9) or 0xD0 <= m <= 0xD7:
|
|
||||||
i += 2
|
|
||||||
continue
|
|
||||||
i += 2 + struct.unpack(">H", d[i + 2:i + 4])[0]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def make_avatar(w, h, orientation=None):
|
|
||||||
"""生成一张带明显方位特征的方形头像。"""
|
|
||||||
from PIL import Image, ImageDraw
|
|
||||||
im = Image.new("RGB", (w, h), (30, 120, 200))
|
|
||||||
d = ImageDraw.Draw(im)
|
|
||||||
# 顶部一条粗红条:旋转 90 度后它会跑到侧面,方向一眼可辨
|
|
||||||
d.rectangle([0, 0, w, max(2, h // 12)], fill=(230, 30, 30))
|
|
||||||
for i in range(0, w, max(8, w // 20)):
|
|
||||||
d.line([(i, h // 4), (i, h)], fill=(255, 255, 255), width=2)
|
|
||||||
buf = io.BytesIO()
|
|
||||||
if orientation:
|
|
||||||
exif = im.getexif()
|
|
||||||
exif[0x0112] = orientation
|
|
||||||
im.save(buf, "JPEG", quality=90, exif=exif)
|
|
||||||
else:
|
|
||||||
im.save(buf, "JPEG", quality=90)
|
|
||||||
return buf.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
def scrape_profile_fields(html):
|
|
||||||
"""从 profile 页抓取所有 input 的 name/value,保证回填必填项。
|
|
||||||
|
|
||||||
UpdateProfile 的必填字段有十来个(昵称/介绍/5 个二维码颜色/点大小/透明度…),
|
|
||||||
漏一个就会因 `valid:"required"` 被 Bind 挡下、头像根本不会走到上传分支。
|
|
||||||
"""
|
|
||||||
fields = {}
|
|
||||||
for m in re.finditer(r'<input\b[^>]*>', html, re.I):
|
|
||||||
tag = m.group(0)
|
|
||||||
nm = re.search(r'name="([^"]+)"', tag, re.I)
|
|
||||||
if not nm:
|
|
||||||
continue
|
|
||||||
name = nm.group(1)
|
|
||||||
if name in ("avatar", "background", "backgroundimage", "_csrf", "submit"):
|
|
||||||
continue
|
|
||||||
typ = re.search(r'type="([^"]+)"', tag, re.I)
|
|
||||||
if typ and typ.group(1).lower() in ("checkbox", "radio"):
|
|
||||||
# 只保留勾选态的,避免把「本想取消」的项又勾上
|
|
||||||
if not re.search(r'\bchecked\b', tag, re.I):
|
|
||||||
continue
|
|
||||||
val = re.search(r'value="([^"]*)"', tag, re.I)
|
|
||||||
fields[name] = val.group(1) if val else ""
|
|
||||||
# select 走第一个非空 option
|
|
||||||
for m in re.finditer(r'<select\b[^>]*name="([^"]+)"[^>]*>(.*?)</select>', html, re.I | re.S):
|
|
||||||
name, body = m.group(1), m.group(2)
|
|
||||||
opts = re.findall(r'<option\b([^>]*)>', body, re.I)
|
|
||||||
chosen = ""
|
|
||||||
for o in opts:
|
|
||||||
vm = re.search(r'value="([^"]*)"', o)
|
|
||||||
v = vm.group(1) if vm else ""
|
|
||||||
if re.search(r'\bselected\b', o, re.I):
|
|
||||||
chosen = v
|
|
||||||
break
|
|
||||||
if not chosen:
|
|
||||||
chosen = v
|
|
||||||
fields[name] = chosen
|
|
||||||
# textarea
|
|
||||||
for m in re.finditer(r'<textarea\b[^>]*name="([^"]+)"[^>]*>(.*?)</textarea>', html, re.I | re.S):
|
|
||||||
fields[m.group(1)] = re.sub(r"<[^>]+>", "", m.group(2)).strip()
|
|
||||||
return fields
|
|
||||||
|
|
||||||
|
|
||||||
def upload_avatar(c, data, fname="avatar.jpg"):
|
|
||||||
page = c.get("/user/profile")
|
|
||||||
if page.status != 200:
|
|
||||||
return None, "profile HTTP %s" % page.status
|
|
||||||
fields = scrape_profile_fields(page.text)
|
|
||||||
fields["_csrf"] = csrf_of(page.text)
|
|
||||||
body, ct = multipart(fields, [("avatar", fname, "image/jpeg", data)])
|
|
||||||
res = c.post("/user/profile/update", data=body, content_type=ct)
|
|
||||||
# 成功时是 302 跳回 /user/profile,头像 URL 在跳转后的页面里
|
|
||||||
if res.status == 302 and res.loc:
|
|
||||||
res = c.get(res.loc if res.loc.startswith("/") else "/user/profile")
|
|
||||||
if res.status != 200:
|
|
||||||
return None, "update 后页面 HTTP %s" % res.status
|
|
||||||
# 从页面里取头像 URL:<img src="https://r2.../picture/..."> 或 og:image
|
|
||||||
urls = re.findall(r'https://r2\.[^"\'\s<>]+/picture/[0-9]+/\d{2}/\d{2}/[0-9a-f]+', res.text)
|
|
||||||
if not urls:
|
|
||||||
m = re.search(r'(https://r2\.[^"\'\s<>]+/picture/[^"\'\s<>]+)', res.text)
|
|
||||||
urls = [m.group(1)] if m else []
|
|
||||||
if urls:
|
|
||||||
return urls[-1], None
|
|
||||||
# 没匹配到:落盘返回页并打印可疑片段,便于定位是「上传被拒」还是「正则没覆盖」
|
|
||||||
dump = "C:/Users/Tamakyi Gekka/AppData/Local/Temp/tbav_profile.html"
|
|
||||||
with open(dump, "wb") as f:
|
|
||||||
f.write(res.body)
|
|
||||||
alerts = re.findall(r'class="uk-alert[^"]*"[^>]*>(.*?)</div>', res.text, re.S)
|
|
||||||
hint = " | ".join(re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", a)).strip()[:80]
|
|
||||||
for a in alerts[:3])
|
|
||||||
return None, "未匹配到 picture URL(已存 %s)%s%s" % (dump, (" 提示:" + hint) if hint else "",
|
|
||||||
(" HTTP %s" % res.status))
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument("--base", default="https://box-dev-api.337845818.best")
|
|
||||||
args = ap.parse_args()
|
|
||||||
base = args.base.rstrip("/")
|
|
||||||
pwd = "TbAv!2026"
|
|
||||||
ts = time.strftime("%m%d%H%M%S")
|
|
||||||
email, domain = "tbav%s@example.com" % ts, "tbav%s" % ts
|
|
||||||
print("== 账号:%s / %s ==" % (email, domain))
|
|
||||||
|
|
||||||
# 方形 1600x1600:验证 A 分档(应存成 512x512)
|
|
||||||
square = make_avatar(1600, 1600)
|
|
||||||
# EXIF Orientation=6(顺时针 90):像素存成横的 1536x512,归一后显示为竖的 512x1536,
|
|
||||||
# 再经头像档限边 512 -> 512x512。
|
|
||||||
# 注意必须用「像素横躺」的样本:头像有 1:1 比例校验,若像素本身就是竖的会被先拒掉,
|
|
||||||
# 那样就测不到 EXIF 分支了。
|
|
||||||
exif_shot = make_avatar(1536, 512, orientation=6)
|
|
||||||
print(" 本地样本 square 1600x1600%7d bytes" % len(square))
|
|
||||||
print(" 本地样本 exifshot 1536x512 (EXIF=6)%d bytes" % len(exif_shot))
|
|
||||||
|
|
||||||
c = Client(base)
|
|
||||||
r = c.get("/register")
|
|
||||||
reg = c.post("/register", {
|
|
||||||
"email": email, "domain": domain, "name": "TBAv%s" % ts,
|
|
||||||
"password": pwd, "repeat_password": pwd,
|
|
||||||
"g-recaptcha-response": captcha(c), "language": "zh-CN",
|
|
||||||
"_csrf": csrf_of(r.text),
|
|
||||||
})
|
|
||||||
if not step("注册", reg.status == 302, "HTTP %s -> %s" % (reg.status, reg.loc)):
|
|
||||||
return 1
|
|
||||||
|
|
||||||
c = Client(base)
|
|
||||||
r = c.get("/login")
|
|
||||||
lg = c.post("/login", {"email": email, "password": pwd,
|
|
||||||
"g-recaptcha-response": captcha(c), "_csrf": csrf_of(r.text)})
|
|
||||||
if not step("登录", lg.status == 302, "HTTP %s -> %s" % (lg.status, lg.loc)):
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# ---- A 分档 ----
|
|
||||||
url, err = upload_avatar(c, square)
|
|
||||||
if not step("上传方形大头像(1600x1600)", url is not None, err or (url or "")):
|
|
||||||
return 1
|
|
||||||
img = c.get_binary(url)
|
|
||||||
st, data = img.status, img.body
|
|
||||||
if not step("拉回头像", st == 200 and bool(data), "HTTP %s" % st):
|
|
||||||
return 1
|
|
||||||
kind, dims = sniff(data)
|
|
||||||
print(" 存储结果:%s %s %d bytes" % (kind, dims, len(data)))
|
|
||||||
step("A 分档生效:大图被缩到头像档 512(改前是 1920)",
|
|
||||||
dims == (512, 512), "实际 %s" % (dims,))
|
|
||||||
|
|
||||||
# ---- B1 EXIF ----
|
|
||||||
# 关键:两次上传的 URL 必须不同,否则说明第二次没生效(回退到了旧头像)。
|
|
||||||
url2, err2 = upload_avatar(c, exif_shot)
|
|
||||||
if not step("上传 EXIF 横躺头像(1536x512, Orientation=6)", bool(url2), err2 or (url2 or "")):
|
|
||||||
return 0
|
|
||||||
if url2 == url:
|
|
||||||
step("B1 EXIF 归一(第二次上传未生效,URL 未变)", False,
|
|
||||||
"两次上传返回同一 URL,无法区分;请检查是否被 1:1 校验拒绝")
|
|
||||||
return 0
|
|
||||||
img2 = c.get_binary(url2)
|
|
||||||
kind2, dims2 = sniff(img2.body)
|
|
||||||
print(" 存储结果:%s %s %d bytes" % (kind2, dims2, len(img2.body)))
|
|
||||||
# 归一后:像素 1536x512 -> 旋转成 512x1536 -> 再限边 512 -> 512x512
|
|
||||||
step("B1 EXIF Orientation=6 归一(1536x512 -> 512x1536 -> 512x512)",
|
|
||||||
dims2 == (512, 512), "实际 %s" % (dims2,))
|
|
||||||
# 体积对照:未归一时 1536x512 的 WebP 明显更大
|
|
||||||
step("B1 归一后体积已按 512 档压过(应与方形样本同量级)",
|
|
||||||
len(img2.body) < 6000, "%d bytes" % len(img2.body))
|
|
||||||
|
|
||||||
print("\n== 结果:%d/%d 通过 ==" % (sum(1 for _, ok, _ in RESULTS if ok), len(RESULTS)))
|
|
||||||
print("清理:TamaBox ban --email %s --yes" % email)
|
|
||||||
return 0 if all(ok for _, ok, _ in RESULTS) else 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,360 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""TamaBox 部署后冒烟巡检(端到端)。
|
|
||||||
|
|
||||||
用途:对已部署环境跑一遍核心链路,用于发版/重新部署后的回归。
|
|
||||||
覆盖:注册 -> 登录 -> 个人页/提问列表/投票面板 -> 投票全链路(建票发布/公开页/匿名提交/
|
|
||||||
结果导出计数/草稿/表单型)-> 匿名提问 -> 停用
|
|
||||||
-> /deactivated 成功页 -> **同邮箱重注册必须被明确拒绝并提示联系站长**
|
|
||||||
-> 公开页只读巡检 -> /deactivated 四语言渲染。
|
|
||||||
|
|
||||||
语义约定(2026-09-15 起):停用 = 清空内容 + 保留用户行作「身份占位」,
|
|
||||||
故邮箱/域名继续被占用,重注册应得到「账号已停用,请找站长」的提示而不是 302 或 500。
|
|
||||||
|
|
||||||
用法:
|
|
||||||
python dev/devcheck-e2e.py [--base https://box-dev-api.337845818.best]
|
|
||||||
|
|
||||||
注意:
|
|
||||||
- 会在目标环境**真实创建账号并发送提问**(使用 tbe2e<时间戳>@example.com 之类的可识别测试邮箱),
|
|
||||||
仅在测试/开发环境运行。
|
|
||||||
- 每次完整运行都会**留下一个已停用的身份占位**(邮箱/域名被占用)。清理方式:
|
|
||||||
`TamaBox ban --email <该邮箱> --yes`(连行物理删除)或 `TamaBox restore --email <该邮箱> --yes`(恢复账号)。
|
|
||||||
- 依赖数学验证码(conf.Recaptcha.CaptchaType == "math");若站点改用 recaptcha/turnstile 则无法自动通过。
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import http.cookiejar
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
||||||
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
|
|
||||||
|
|
||||||
RESULTS = []
|
|
||||||
|
|
||||||
|
|
||||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
||||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class Resp:
|
|
||||||
def __init__(self, status, headers, body):
|
|
||||||
self.status = status
|
|
||||||
self.h = {k.lower(): v for k, v in headers.items()}
|
|
||||||
self.body = body
|
|
||||||
|
|
||||||
@property
|
|
||||||
def loc(self):
|
|
||||||
return self.h.get("location", "")
|
|
||||||
|
|
||||||
def alert(self):
|
|
||||||
m = re.search(r'class="uk-alert[^"]*"[^>]*>(.*?)</div>', self.body, re.S)
|
|
||||||
return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", m.group(0))).strip()[:200] if m else ""
|
|
||||||
|
|
||||||
|
|
||||||
class Client:
|
|
||||||
def __init__(self, base):
|
|
||||||
self.base = base
|
|
||||||
self.cj = http.cookiejar.CookieJar()
|
|
||||||
self.op = urllib.request.build_opener(
|
|
||||||
urllib.request.HTTPCookieProcessor(self.cj), NoRedirect())
|
|
||||||
|
|
||||||
def req(self, method, path, data=None, timeout=90):
|
|
||||||
url = path if path.startswith("http") else self.base + path
|
|
||||||
headers = {"User-Agent": UA, "Referer": self.base + path.split("?")[0]}
|
|
||||||
body = urllib.parse.urlencode(data).encode() if data is not None else None
|
|
||||||
if body:
|
|
||||||
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
||||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
||||||
try:
|
|
||||||
with self.op.open(req, timeout=timeout) as r:
|
|
||||||
return Resp(r.status, dict(r.headers), r.read().decode("utf-8", "replace"))
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
# 读取错误响应体本身也可能超时(尤其 302 无体/远端慢),不能让它把巡检整轮打断
|
|
||||||
try:
|
|
||||||
body = e.read().decode("utf-8", "replace")
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
body = ""
|
|
||||||
return Resp(e.code, dict(e.headers), body)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
return Resp(-1, {}, "TRANSPORT_ERROR: %s: %s" % (type(e).__name__, e))
|
|
||||||
|
|
||||||
def get(self, p, **kw):
|
|
||||||
# 只读 GET 重试一次:远端偶发慢请求会产生 TRANSPORT_ERROR,避免巡检出现假失败。
|
|
||||||
r = self.req("GET", p, **kw)
|
|
||||||
if r.status == -1:
|
|
||||||
r = self.req("GET", p, **kw)
|
|
||||||
return r
|
|
||||||
|
|
||||||
def post(self, p, data=None, **kw):
|
|
||||||
return self.req("POST", p, data=data, **kw)
|
|
||||||
|
|
||||||
|
|
||||||
def csrf_of(html):
|
|
||||||
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
|
|
||||||
return m.group(1) if m else None
|
|
||||||
|
|
||||||
|
|
||||||
def captcha(c):
|
|
||||||
"""取一道数学验证码并算出答案,返回 <id>:<answer>。"""
|
|
||||||
j = json.loads(c.get("/api/captcha/generate").body)
|
|
||||||
pid, q = j["data"]["id"], j["data"]["question"]
|
|
||||||
m = re.match(r"\s*(\d+)\s*([+\-×])\s*(\d+)\s*=", q)
|
|
||||||
a, op, b = int(m.group(1)), m.group(2), int(m.group(3))
|
|
||||||
ans = {"+": a + b, "-": a - b, "×": a * b}[op]
|
|
||||||
return "%s:%d" % (pid, ans)
|
|
||||||
|
|
||||||
|
|
||||||
def step(desc, ok, extra=""):
|
|
||||||
RESULTS.append((desc, ok, extra))
|
|
||||||
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", desc, (" (%s)" % extra) if extra else ""))
|
|
||||||
|
|
||||||
|
|
||||||
# 停用提示的跨语言特征串(站点默认语言可能是 ja,断言不能只看中文)
|
|
||||||
DEACTIVATED_HINTS = ("已停用", "has been deactivated", "は停止されています")
|
|
||||||
INTERNAL_ERROR_HINTS = ("サーバー内部エラー", "Internal Server Error", "服务器内部错误")
|
|
||||||
|
|
||||||
|
|
||||||
def is_deactivated_rejected(r):
|
|
||||||
"""停用后的重注册应被明确拒绝:HTTP 200 + 提示「账号已停用」,且不得是 500 兜底。"""
|
|
||||||
if r.status != 200:
|
|
||||||
return False
|
|
||||||
if any(h in r.body for h in INTERNAL_ERROR_HINTS):
|
|
||||||
return False
|
|
||||||
return any(h in r.body for h in DEACTIVATED_HINTS)
|
|
||||||
|
|
||||||
|
|
||||||
def check_public_pages(base):
|
|
||||||
"""公开页只读巡检:期望全部 200(/user/votes 之类的登录页除外)。"""
|
|
||||||
paths = ["/", "/explore", "/register", "/login", "/forgot-password",
|
|
||||||
"/sponsor", "/change-logs", "/about-me", "/robots.txt",
|
|
||||||
"/sitemap.xml", "/llms.txt", "/llms-full.txt", "/favicon.ico",
|
|
||||||
"/deactivated", "/api/captcha/generate", "/api/v1/site-stats"]
|
|
||||||
c = Client(base)
|
|
||||||
bad = []
|
|
||||||
for p in paths:
|
|
||||||
r = c.get(p, timeout=40)
|
|
||||||
if r.status != 200:
|
|
||||||
bad.append("%s=%s" % (p, r.status))
|
|
||||||
step("公开页只读巡检(%d 个端点)" % len(paths), not bad, ", ".join(bad))
|
|
||||||
|
|
||||||
|
|
||||||
def create_vote(c, title, vtype="single", options=None, fields=None, action="publish"):
|
|
||||||
"""提交一次投票创建(POST /user/votes)。
|
|
||||||
|
|
||||||
后端对创建动作一律按 draft=true 解析(草稿可随时暂存、跳过审核),
|
|
||||||
action=publish 时再走 validateForPublish + 内容审核;因此这里两种动作都可直接测。
|
|
||||||
"""
|
|
||||||
page = c.get("/user/votes/new")
|
|
||||||
data = {
|
|
||||||
"title": title, "description": "", "visibility": "public",
|
|
||||||
"voter_scope": "anyone", "show_result": "after_vote",
|
|
||||||
"type": vtype, "max_choices": "1", "allow_custom": "",
|
|
||||||
"start_at": "", "end_at": "", "quota_limit": "0",
|
|
||||||
"options": json.dumps(options or [], ensure_ascii=False),
|
|
||||||
"form_fields": json.dumps(fields or [], ensure_ascii=False),
|
|
||||||
"action": action, "_csrf": csrf_of(page.body),
|
|
||||||
}
|
|
||||||
return c.post("/user/votes", data)
|
|
||||||
|
|
||||||
|
|
||||||
def check_user_assets(owner):
|
|
||||||
"""登录态的接口/导出,以及停用页的「身份占位」说明。
|
|
||||||
|
|
||||||
停用页说明用**结构断言**(.uk-text-muted 段落非空),不写死文案 —— 站点语言随会话变化。
|
|
||||||
"""
|
|
||||||
r = owner.get("/api/v1/user")
|
|
||||||
step("登录态 /api/v1/user 返回 JSON", r.status == 200 and r.body.lstrip().startswith("{"),
|
|
||||||
"HTTP %s" % r.status)
|
|
||||||
step("登录态 /explore", owner.get("/explore").status == 200)
|
|
||||||
|
|
||||||
csrf = csrf_of(owner.get("/user/profile").body)
|
|
||||||
r = owner.post("/user/profile/export-json", {"_csrf": csrf})
|
|
||||||
step("导出 JSON", r.status == 200 and r.body.lstrip().startswith(("{", "[")),
|
|
||||||
"HTTP %s" % r.status)
|
|
||||||
r = owner.post("/user/profile/export", {"_csrf": csrf})
|
|
||||||
step("导出 Excel", r.status == 200, "HTTP %s %s" % (r.status, r.h.get("content-type", "")))
|
|
||||||
|
|
||||||
r = owner.get("/user/profile/deactivate")
|
|
||||||
note = re.search(r'class="uk-text-small uk-text-muted">(.*?)</span>', r.body, re.S)
|
|
||||||
step("停用页含身份占位说明", r.status == 200 and bool(note) and note.group(1).strip() != "")
|
|
||||||
|
|
||||||
|
|
||||||
def check_vote_flow(owner, base):
|
|
||||||
"""投票全链路:建票发布 -> 管理列表 -> 公开页 -> 匿名提交 -> 导出计数 -> 草稿 -> 表单型。"""
|
|
||||||
ts = time.strftime("%m%d%H%M%S")
|
|
||||||
|
|
||||||
title = "巡检单选投票%s" % ts
|
|
||||||
r = create_vote(owner, title, "single", options=["选项甲", "选项乙", "选项丙"])
|
|
||||||
step("创建单选投票并发布", r.status == 302 and "questions" in r.loc,
|
|
||||||
"HTTP %s -> %s %s" % (r.status, r.loc, r.alert()))
|
|
||||||
|
|
||||||
q = owner.get("/user/questions")
|
|
||||||
step("投票出现在管理列表", title in q.body)
|
|
||||||
mt = re.search(r"copyShareURL\('([^']+)'", q.body)
|
|
||||||
mv = re.search(r"/user/votes/(\d+)/edit", q.body)
|
|
||||||
token, vid = (mt.group(1) if mt else None), (mv.group(1) if mv else None)
|
|
||||||
step("取得分享 token / 投票 ID", bool(token and vid), "%s / %s" % (token, vid))
|
|
||||||
if not (token and vid):
|
|
||||||
return
|
|
||||||
|
|
||||||
anon = Client(base)
|
|
||||||
pv = anon.get("/vote/%s" % token)
|
|
||||||
step("公开投票页渲染", pv.status == 200 and title in pv.body, "HTTP %s" % pv.status)
|
|
||||||
mo = re.search(r'name="option_id" value="(\d+)"', pv.body)
|
|
||||||
step("公开页含可选项", bool(mo))
|
|
||||||
if not mo:
|
|
||||||
return
|
|
||||||
|
|
||||||
sub = anon.post("/vote/%s/submit" % token, {
|
|
||||||
"option_id": mo.group(1), "g-recaptcha-response": captcha(anon),
|
|
||||||
"custom_option": "", "_csrf": csrf_of(pv.body),
|
|
||||||
})
|
|
||||||
step("匿名提交投票", sub.status == 302, "HTTP %s -> %s %s" % (sub.status, sub.loc, sub.alert()))
|
|
||||||
|
|
||||||
# 导出仅作只读校验:单选导出的 CSV 每行是 <选项,票数>,末行为 <总参与,N>。
|
|
||||||
# 只认「总参与」这一行,避免把各选项票数一起求和导致 2 倍计数。
|
|
||||||
exp = owner.get("/user/votes/%s/export" % vid)
|
|
||||||
mtot = re.search(r"总参与,(\d+)", exp.body)
|
|
||||||
total = int(mtot.group(1)) if mtot else -1
|
|
||||||
step("导出显示总参与 = 1", exp.status == 200 and total == 1,
|
|
||||||
"HTTP %s 总参与=%s" % (exp.status, total))
|
|
||||||
|
|
||||||
dr = create_vote(owner, "巡检草稿%s" % ts, "single", options=["甲", "乙"], action="save")
|
|
||||||
step("保存草稿跳编辑页(?saved=1)",
|
|
||||||
dr.status == 302 and "/user/votes/" in dr.loc and "saved=1" in dr.loc,
|
|
||||||
"HTTP %s -> %s" % (dr.status, dr.loc))
|
|
||||||
|
|
||||||
fr = create_vote(owner, "巡检表单投票%s" % ts, "form", fields=[
|
|
||||||
{"field_type": "text", "label": "昵称", "required": True, "max_length": 50},
|
|
||||||
{"field_type": "textarea", "label": "建议", "required": False, "max_length": 200},
|
|
||||||
])
|
|
||||||
step("创建表单型投票并发布", fr.status == 302 and "questions" in fr.loc,
|
|
||||||
"HTTP %s -> %s %s" % (fr.status, fr.loc, fr.alert()))
|
|
||||||
|
|
||||||
|
|
||||||
def check_deactivated_i18n(base):
|
|
||||||
"""停用成功页四语言渲染(用语言无关的 ratio:3 标记 + 标题非空)。"""
|
|
||||||
bad = []
|
|
||||||
for lang in ["zh-CN", "zh-TW", "en", "ja"]:
|
|
||||||
c = Client(base)
|
|
||||||
c.get("/i18n/%s?to=/deactivated" % lang, timeout=40) # 302,语言写入 session
|
|
||||||
r = c.get("/deactivated", timeout=40) # 再请求一次才渲染新语言
|
|
||||||
title = re.search(r'uk-card-body uk-text-center".*?<h3[^>]*>([^<]*)</h3>',
|
|
||||||
r.body, re.S)
|
|
||||||
if not title or not title.group(1).strip():
|
|
||||||
bad.append(lang)
|
|
||||||
step("停用成功页四语言渲染", not bad, "缺失: " + ",".join(bad) if bad else "")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument("--base", default="https://box-dev-api.337845818.best")
|
|
||||||
ap.add_argument("--reuse-email", default="",
|
|
||||||
help="只跑一项:用该既有邮箱重新注册(用于复测停用后能否重注册)")
|
|
||||||
args = ap.parse_args()
|
|
||||||
base = args.base.rstrip("/")
|
|
||||||
pwd = "TbCheck!2026"
|
|
||||||
|
|
||||||
if args.reuse_email:
|
|
||||||
domain = args.reuse_email.split("@")[0]
|
|
||||||
c = Client(base)
|
|
||||||
r = c.get("/register")
|
|
||||||
r = c.post("/register", {
|
|
||||||
"email": args.reuse_email, "domain": domain, "name": "TBCheck",
|
|
||||||
"password": pwd, "repeat_password": pwd,
|
|
||||||
"g-recaptcha-response": captcha(c), "language": "zh-CN", "agree_rules": "1",
|
|
||||||
"_csrf": csrf_of(r.body),
|
|
||||||
})
|
|
||||||
step("以既有邮箱重新注册(已停用则应被明确拒绝并提示找站长)", is_deactivated_rejected(r),
|
|
||||||
"HTTP %s %s" % (r.status, r.alert()))
|
|
||||||
return 0 if all(x[1] for x in RESULTS) else 1
|
|
||||||
|
|
||||||
ts = time.strftime("%m%d%H%M%S")
|
|
||||||
email, domain = "tbe2e%s@example.com" % ts, "tbe2e%s" % ts
|
|
||||||
print("== 新账号:%s / %s ==" % (email, domain))
|
|
||||||
|
|
||||||
pub = Client(base)
|
|
||||||
r = pub.get("/register")
|
|
||||||
reg = pub.post("/register", {
|
|
||||||
"email": email, "domain": domain, "name": "TB%s" % ts,
|
|
||||||
"password": pwd, "repeat_password": pwd,
|
|
||||||
"g-recaptcha-response": captcha(pub), "language": "zh-CN", "agree_rules": "1",
|
|
||||||
"_csrf": csrf_of(r.body),
|
|
||||||
})
|
|
||||||
step("注册", reg.status == 302, "HTTP %s -> %s %s" % (reg.status, reg.loc, reg.alert()))
|
|
||||||
|
|
||||||
owner = Client(base)
|
|
||||||
r = owner.get("/login")
|
|
||||||
r = owner.post("/login", {
|
|
||||||
"email": email, "password": pwd,
|
|
||||||
"g-recaptcha-response": captcha(owner), "_csrf": csrf_of(r.body),
|
|
||||||
})
|
|
||||||
step("登录", r.status == 302, "HTTP %s -> %s" % (r.status, r.loc))
|
|
||||||
|
|
||||||
step("登录态 /user/profile", owner.get("/user/profile").status == 200)
|
|
||||||
step("登录态 /user/questions", owner.get("/user/questions").status == 200)
|
|
||||||
r = owner.get("/user/votes")
|
|
||||||
step("/user/votes 302 -> /user/questions#votes",
|
|
||||||
r.status == 302 and "questions" in r.loc, "HTTP %s -> %s" % (r.status, r.loc))
|
|
||||||
step("新建投票页", owner.get("/user/votes/new").status == 200)
|
|
||||||
check_user_assets(owner)
|
|
||||||
check_vote_flow(owner, base)
|
|
||||||
|
|
||||||
# 匿名提问:无邮箱时成功后直接 200 渲染提问成功页(不重定向),故只断言提问详情可达
|
|
||||||
r = pub.get("/_/%s" % domain)
|
|
||||||
step("打开提问箱", r.status == 200)
|
|
||||||
r = pub.post("/_/%s" % domain, {
|
|
||||||
"content": "自动化巡检测试提问(可忽略)",
|
|
||||||
"g-recaptcha-response": captcha(pub),
|
|
||||||
"receive_reply_email": "", "private": "", "ask_password": "",
|
|
||||||
"start_chat_val": "", "_csrf": csrf_of(r.body),
|
|
||||||
})
|
|
||||||
step("提交提问", r.status in (200, 302), "HTTP %s -> %s" % (r.status, r.loc))
|
|
||||||
|
|
||||||
r = owner.get("/user/profile/deactivate")
|
|
||||||
step("打开停用页", r.status == 200)
|
|
||||||
r = owner.post("/user/profile/deactivate", {"_csrf": csrf_of(r.body)})
|
|
||||||
step("停用后跳 /deactivated", r.status == 302 and "deactivated" in r.loc,
|
|
||||||
"HTTP %s -> %s" % (r.status, r.loc))
|
|
||||||
r = owner.get("/deactivated")
|
|
||||||
step("停用成功页渲染", r.status == 200 and "ratio: 3" in r.body, "HTTP %s" % r.status)
|
|
||||||
|
|
||||||
pub2 = Client(base)
|
|
||||||
r = pub2.get("/register")
|
|
||||||
r = pub2.post("/register", {
|
|
||||||
"email": email, "domain": domain, "name": "TB%sb" % ts,
|
|
||||||
"password": pwd, "repeat_password": pwd,
|
|
||||||
"g-recaptcha-response": captcha(pub2), "language": "zh-CN", "agree_rules": "1",
|
|
||||||
"_csrf": csrf_of(r.body),
|
|
||||||
})
|
|
||||||
step("停用后同邮箱重注册被明确拒绝并提示找站长(核心回归)", is_deactivated_rejected(r),
|
|
||||||
"HTTP %s %s" % (r.status, r.alert()))
|
|
||||||
|
|
||||||
check_public_pages(base)
|
|
||||||
check_deactivated_i18n(base)
|
|
||||||
|
|
||||||
print("\n===== 汇总 =====")
|
|
||||||
for d, ok, ex in RESULTS:
|
|
||||||
print("%s %s%s" % ("PASS" if ok else "FAIL", d, (" (%s)" % ex) if ex else ""))
|
|
||||||
n = sum(1 for x in RESULTS if not x[1])
|
|
||||||
print("\n共 %d 项,失败 %d 项" % (len(RESULTS), n))
|
|
||||||
return 1 if n else 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
|
||||||
sys.exit(main())
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
raise
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
# 远端抖动等意外中断时,仍把已完成项的结论打出来,避免只剩一段 traceback
|
|
||||||
print("\n巡检意外中断:%s: %s" % (type(e).__name__, e))
|
|
||||||
for d, ok, ex in RESULTS:
|
|
||||||
print("%s %s%s" % ("PASS" if ok else "FAIL", d, (" (%s)" % ex) if ex else ""))
|
|
||||||
sys.exit(2)
|
|
||||||
@@ -1,370 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""图片上传链路端到端验证(批次 A / B1 / B2)。
|
|
||||||
|
|
||||||
针对已部署环境验证三件事:
|
|
||||||
B1 EXIF Orientation=6 的竖拍 JPEG 转换后必须变成竖图(核心验收点)
|
|
||||||
B2 动画 GIF 必须被转成 .webp(cwebp 可用性 + 提问路径与头像路径一致性)
|
|
||||||
A 转换后不比原图小时必须回退原图(用一张极小线框图验证)
|
|
||||||
|
|
||||||
做法:注册账号 -> 匿名提问(multipart 同时带三张图)-> 解析成功页里的 CDN URL
|
|
||||||
-> 逐张拉回字节,解析真实容器格式与尺寸。
|
|
||||||
|
|
||||||
用法:
|
|
||||||
python dev/devcheck-image.py [--base https://box-dev-api.337845818.best]
|
|
||||||
|
|
||||||
依赖:Pillow(仅用于在本地生成测试图片,不参与被测逻辑)。
|
|
||||||
注意:会在目标环境真实创建账号并发送带图提问(测试邮箱前缀 tbimg<ts>@)。
|
|
||||||
清理:TamaBox ban --email <该邮箱> --yes
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import http.cookiejar
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
import re
|
|
||||||
import struct
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
||||||
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
|
|
||||||
RESULTS = []
|
|
||||||
CREATED_EMAIL = ""
|
|
||||||
|
|
||||||
|
|
||||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
||||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class Resp:
|
|
||||||
def __init__(self, status, headers, body):
|
|
||||||
self.status = status
|
|
||||||
self.h = {k.lower(): v for k, v in headers.items()}
|
|
||||||
self.body = body
|
|
||||||
|
|
||||||
@property
|
|
||||||
def loc(self):
|
|
||||||
return self.h.get("location", "")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def text(self):
|
|
||||||
return self.body.decode("utf-8", "replace")
|
|
||||||
|
|
||||||
|
|
||||||
class Client:
|
|
||||||
def __init__(self, base):
|
|
||||||
self.base = base
|
|
||||||
self.cj = http.cookiejar.CookieJar()
|
|
||||||
self.op = urllib.request.build_opener(
|
|
||||||
urllib.request.HTTPCookieProcessor(self.cj), NoRedirect())
|
|
||||||
|
|
||||||
def _send(self, req, timeout=120):
|
|
||||||
try:
|
|
||||||
with self.op.open(req, timeout=timeout) as r:
|
|
||||||
return Resp(r.status, dict(r.headers), r.read())
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
try:
|
|
||||||
body = e.read()
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
body = b""
|
|
||||||
return Resp(e.code, dict(e.headers), body)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
return Resp(-1, {}, ("TRANSPORT_ERROR: %s" % e).encode())
|
|
||||||
|
|
||||||
def req(self, method, path, data=None, content_type=None, timeout=120):
|
|
||||||
url = path if path.startswith("http") else self.base + path
|
|
||||||
headers = {"User-Agent": UA, "Referer": self.base + path.split("?")[0]}
|
|
||||||
body = None
|
|
||||||
if data is not None:
|
|
||||||
body = data if isinstance(data, bytes) else urllib.parse.urlencode(data).encode()
|
|
||||||
headers["Content-Type"] = content_type or "application/x-www-form-urlencoded"
|
|
||||||
return self._send(urllib.request.Request(url, data=body, headers=headers, method=method),
|
|
||||||
timeout)
|
|
||||||
|
|
||||||
def get(self, p, **kw):
|
|
||||||
r = self.req("GET", p, **kw)
|
|
||||||
return r if r.status != -1 else self.req("GET", p, **kw)
|
|
||||||
|
|
||||||
def post(self, p, data=None, **kw):
|
|
||||||
return self.req("POST", p, data=data, **kw)
|
|
||||||
|
|
||||||
def get_binary(self, url):
|
|
||||||
return self._send(urllib.request.Request(
|
|
||||||
url, headers={"User-Agent": UA}, method="GET"), timeout=60)
|
|
||||||
|
|
||||||
|
|
||||||
def step(desc, ok, extra=""):
|
|
||||||
RESULTS.append((desc, ok, extra))
|
|
||||||
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", desc, (" (%s)" % extra) if extra else ""))
|
|
||||||
return ok
|
|
||||||
|
|
||||||
|
|
||||||
def csrf_of(html):
|
|
||||||
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
|
|
||||||
return m.group(1) if m else None
|
|
||||||
|
|
||||||
|
|
||||||
def captcha(c):
|
|
||||||
j = json.loads(c.get("/api/captcha/generate").text)
|
|
||||||
pid, q = j["data"]["id"], j["data"]["question"]
|
|
||||||
m = re.match(r"\s*(\d+)\s*([+\-×])\s*(\d+)\s*=", q)
|
|
||||||
a, op, b = int(m.group(1)), m.group(2), int(m.group(3))
|
|
||||||
return "%s:%d" % (pid, {"+": a + b, "-": a - b, "×": a * b}[op])
|
|
||||||
|
|
||||||
|
|
||||||
def multipart(fields, files):
|
|
||||||
boundary = "----tbimg%s" % uuid.uuid4().hex
|
|
||||||
buf = io.BytesIO()
|
|
||||||
for k, v in fields.items():
|
|
||||||
buf.write(("--%s\r\n" % boundary).encode())
|
|
||||||
buf.write(('Content-Disposition: form-data; name="%s"\r\n\r\n' % k).encode())
|
|
||||||
buf.write(("%s\r\n" % v).encode())
|
|
||||||
for name, fn, ct, data in files:
|
|
||||||
buf.write(("--%s\r\n" % boundary).encode())
|
|
||||||
buf.write(('Content-Disposition: form-data; name="%s"; filename="%s"\r\n' % (name, fn)).encode())
|
|
||||||
buf.write(("Content-Type: %s\r\n\r\n" % ct).encode())
|
|
||||||
buf.write(data)
|
|
||||||
buf.write(b"\r\n")
|
|
||||||
buf.write(("--%s--\r\n" % boundary).encode())
|
|
||||||
return buf.getvalue(), "multipart/form-data; boundary=%s" % boundary
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- 容器格式解析(纯 stdlib) ----------
|
|
||||||
|
|
||||||
def sniff(data):
|
|
||||||
"""返回 (kind, (w,h))。kind in {webp, jpeg, gif, png, unknown}"""
|
|
||||||
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
||||||
return "webp", webp_dims(data)
|
|
||||||
if data[:3] == b"\xff\xd8\xff":
|
|
||||||
return "jpeg", jpeg_dims(data)
|
|
||||||
if data[:3] == b"GIF":
|
|
||||||
return "gif", gif_dims(data)
|
|
||||||
if data[:8] == b"\x89PNG\r\n\x1a\n":
|
|
||||||
return "png", png_dims(data)
|
|
||||||
return "unknown", None
|
|
||||||
|
|
||||||
|
|
||||||
def webp_dims(d):
|
|
||||||
c = d[12:16]
|
|
||||||
if c == b"VP8 " and len(d) >= 30:
|
|
||||||
return (struct.unpack("<H", d[26:28])[0] & 0x3FFF,
|
|
||||||
struct.unpack("<H", d[28:30])[0] & 0x3FFF)
|
|
||||||
if c == b"VP8L" and len(d) >= 25:
|
|
||||||
b = struct.unpack("<I", d[21:25])[0]
|
|
||||||
return (b & 0x3FFF) + 1, ((b >> 14) & 0x3FFF) + 1
|
|
||||||
if c == b"VP8X" and len(d) >= 30:
|
|
||||||
return ((d[24] | d[25] << 8 | d[26] << 16) + 1,
|
|
||||||
(d[27] | d[28] << 8 | d[29] << 16) + 1)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def jpeg_dims(d):
|
|
||||||
i = 2
|
|
||||||
while i + 9 < len(d):
|
|
||||||
if d[i] != 0xFF:
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
m = d[i + 1]
|
|
||||||
if m in (0xC0, 0xC1, 0xC2, 0xC3):
|
|
||||||
h, w = struct.unpack(">HH", d[i + 5:i + 9])
|
|
||||||
return (w, h)
|
|
||||||
if m in (0xD8, 0xD9) or 0xD0 <= m <= 0xD7:
|
|
||||||
i += 2
|
|
||||||
continue
|
|
||||||
i += 2 + struct.unpack(">H", d[i + 2:i + 4])[0]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def gif_dims(d):
|
|
||||||
return struct.unpack("<HH", d[6:10]) if len(d) >= 10 else None
|
|
||||||
|
|
||||||
|
|
||||||
def png_dims(d):
|
|
||||||
return struct.unpack(">II", d[16:24]) if len(d) >= 24 else None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- 测试图片生成(Pillow 仅此一处使用) ----------
|
|
||||||
|
|
||||||
def make_images():
|
|
||||||
from PIL import Image, ImageDraw
|
|
||||||
import io as _io
|
|
||||||
|
|
||||||
# B1:EXIF Orientation=6。像素存 240x120,正确归一后应显示 120x240。
|
|
||||||
base = Image.new("RGB", (240, 120))
|
|
||||||
dr = ImageDraw.Draw(base)
|
|
||||||
for i in range(0, 240, 20):
|
|
||||||
dr.line([(i, 0), (i, 120)], fill=(200, 30, 30), width=3)
|
|
||||||
dr.rectangle([0, 0, 239, 119], outline=(0, 0, 255), width=4)
|
|
||||||
exif = base.getexif()
|
|
||||||
exif[0x0112] = 6
|
|
||||||
buf = _io.BytesIO()
|
|
||||||
base.save(buf, "JPEG", quality=88, exif=exif)
|
|
||||||
portrait = buf.getvalue()
|
|
||||||
|
|
||||||
# B2:6 帧动画 GIF。加大到 640x480,确保转换后仍小于原图(否则会走回退)。
|
|
||||||
frames = []
|
|
||||||
pal_frames = []
|
|
||||||
for f in range(6):
|
|
||||||
im = Image.new("RGB", (640, 480), (240, 240, 240))
|
|
||||||
d = ImageDraw.Draw(im)
|
|
||||||
d.ellipse([40 + f * 60, 120, 200 + f * 60, 280], fill=(220, 40, 40))
|
|
||||||
d.rectangle([300, 60 + f * 20, 600, 200], fill=(30, 90, 220))
|
|
||||||
pal_frames.append(im.convert("P", palette=Image.ADAPTIVE, colors=64))
|
|
||||||
gbuf = _io.BytesIO()
|
|
||||||
pal_frames[0].save(gbuf, "GIF", save_all=True, append_images=pal_frames[1:],
|
|
||||||
duration=120, loop=0)
|
|
||||||
animated = gbuf.getvalue()
|
|
||||||
|
|
||||||
# A:极小线框图(200x200),WebP 有损编码后反而比 PNG 大 -> 应回退原图。
|
|
||||||
wire = Image.new("RGB", (200, 200), (255, 255, 255))
|
|
||||||
d = ImageDraw.Draw(wire)
|
|
||||||
for i in range(0, 200, 20):
|
|
||||||
d.line([(i, 0), (i, 199)], fill=(0, 0, 0))
|
|
||||||
d.line([(0, i), (199, i)], fill=(0, 0, 0))
|
|
||||||
wbuf = _io.BytesIO()
|
|
||||||
wire.save(wbuf, "PNG")
|
|
||||||
wireframe = wbuf.getvalue()
|
|
||||||
|
|
||||||
return {"portrait": portrait, "anim": animated, "wire": wireframe}
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
global CREATED_EMAIL
|
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument("--base", default="https://box-dev-api.337845818.best")
|
|
||||||
args = ap.parse_args()
|
|
||||||
base = args.base.rstrip("/")
|
|
||||||
pwd = "TbImg!2026"
|
|
||||||
ts = time.strftime("%m%d%H%M%S")
|
|
||||||
email, domain = "tbimg%s@example.com" % ts, "tbimg%s" % ts
|
|
||||||
CREATED_EMAIL = email
|
|
||||||
print("== 账号:%s / %s ==" % (email, domain))
|
|
||||||
|
|
||||||
imgs = make_images()
|
|
||||||
for k, v in imgs.items():
|
|
||||||
print(" 本地样本 %-9s %7d bytes" % (k, len(v)))
|
|
||||||
|
|
||||||
c = Client(base)
|
|
||||||
r = c.get("/register")
|
|
||||||
reg = c.post("/register", {
|
|
||||||
"email": email, "domain": domain, "name": "TBImg%s" % ts,
|
|
||||||
"password": pwd, "repeat_password": pwd,
|
|
||||||
"g-recaptcha-response": captcha(c), "language": "zh-CN",
|
|
||||||
"_csrf": csrf_of(r.text),
|
|
||||||
})
|
|
||||||
if not step("注册", reg.status == 302, "HTTP %s -> %s" % (reg.status, reg.loc)):
|
|
||||||
return 1
|
|
||||||
|
|
||||||
c = Client(base)
|
|
||||||
r = c.get("/login")
|
|
||||||
lg = c.post("/login", {"email": email, "password": pwd,
|
|
||||||
"g-recaptcha-response": captcha(c), "_csrf": csrf_of(r.text)})
|
|
||||||
if not step("登录", lg.status == 302, "HTTP %s -> %s" % (lg.status, lg.loc)):
|
|
||||||
return 1
|
|
||||||
|
|
||||||
page = c.get("/_/%s" % domain)
|
|
||||||
if not step("打开提问箱", page.status == 200, "HTTP %s" % page.status):
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# route/question/page.go 只取 f.Images[0](单张上限),
|
|
||||||
# 故每张样本各发一条提问,逐张验证。
|
|
||||||
fetched = []
|
|
||||||
for key, (fname, ctype) in (("portrait", ("portrait.jpg", "image/jpeg")),
|
|
||||||
("anim", ("anim.gif", "image/gif")),
|
|
||||||
("wire", ("wire.png", "image/png"))):
|
|
||||||
box = c.get("/_/%s" % domain)
|
|
||||||
body, ct = multipart({
|
|
||||||
"content": "图片上传链路验证 %s(可忽略)" % key,
|
|
||||||
"g-recaptcha-response": captcha(c),
|
|
||||||
"receive_reply_email": "", "private": "", "ask_password": "",
|
|
||||||
"start_chat_val": "", "_csrf": csrf_of(box.text),
|
|
||||||
}, [("images", fname, ctype, imgs[key])])
|
|
||||||
|
|
||||||
res = c.post("/_/%s" % domain, data=body, content_type=ct)
|
|
||||||
if not step("提交带图提问 [%s]" % key, res.status in (200, 302),
|
|
||||||
"HTTP %s -> %s" % (res.status, res.loc)):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 成功页不渲染图片(templates/question/success.html 无图片区),
|
|
||||||
# 故从成功页取出 question ID,再访问详情页取图片 key。
|
|
||||||
qid = re.search(r'/_/%s/(\d+)' % re.escape(domain), res.text)
|
|
||||||
if not qid:
|
|
||||||
step(" └ 取提问 ID [%s]" % key, False, "成功页未给出 /_/<domain>/<id>")
|
|
||||||
continue
|
|
||||||
detail = c.get("/_/%s/%s" % (domain, qid.group(1)))
|
|
||||||
# 提问图的 key 不带 picture/ 前缀(是 "<年>/<月>/<纳秒>.<ext>"),
|
|
||||||
# 模板拼成 https://<CDN>/<key>,故按 CDN host + 数字开头路径来匹配。
|
|
||||||
urls = sorted(set(re.findall(
|
|
||||||
r'https://r2\.[^"\'\s<>]+/\d{4}/\d{1,2}/\d+\.\w+', detail.text)))
|
|
||||||
if not urls:
|
|
||||||
step(" └ 详情页回显图片 [%s]" % key, False,
|
|
||||||
"HTTP %s,未找到 picture URL" % detail.status)
|
|
||||||
continue
|
|
||||||
step(" └ 详情页回显图片 [%s]" % key, True, "%d 个" % len(urls))
|
|
||||||
|
|
||||||
for u in urls:
|
|
||||||
st, data = c.get_binary(u)
|
|
||||||
if st != 200 or not data:
|
|
||||||
step(" └ 拉回图片 [%s]" % key, False, "HTTP %s" % st)
|
|
||||||
continue
|
|
||||||
kind, dims = sniff(data)
|
|
||||||
fetched.append((key, kind, dims, len(data)))
|
|
||||||
print(" %-14s %-7s %-12s %7d bytes" % (u.rsplit("/", 1)[-1], kind, dims, len(data)))
|
|
||||||
|
|
||||||
|
|
||||||
print("\n== 断言 ==")
|
|
||||||
|
|
||||||
# B1
|
|
||||||
portrait = [f for f in fetched if f[0] == "portrait" and f[1] in ("webp", "jpeg")
|
|
||||||
and f[2] == (120, 240)]
|
|
||||||
land = [f for f in fetched if f[0] == "portrait" and f[1] in ("webp", "jpeg")
|
|
||||||
and f[2] == (240, 120)]
|
|
||||||
if portrait:
|
|
||||||
step("B1 EXIF Orientation=6 已归一(240x120 -> 120x240)", True,
|
|
||||||
"%s, %d bytes" % (portrait[0][1], portrait[0][3]))
|
|
||||||
elif land:
|
|
||||||
step("B1 EXIF Orientation=6 已归一", False, "仍是 240x120,说明未应用 EXIF 旋转")
|
|
||||||
else:
|
|
||||||
step("B1 EXIF 方向归一", False, "未匹配到 240x120 / 120x240 的图片,实际: %s" %
|
|
||||||
[(f[1], f[2]) for f in fetched])
|
|
||||||
|
|
||||||
# B2:GIF 样本必须变成 webp;关键是按 key 归属判定,而不是看整体有没有 webp
|
|
||||||
gif_res = [f for f in fetched if f[0] == "anim"]
|
|
||||||
if not gif_res:
|
|
||||||
step("B2 动画 GIF 转 WebP", False, "未取到 anim 样本结果")
|
|
||||||
elif gif_res[0][1] == "webp":
|
|
||||||
step("B2 动画 GIF 已转为 WebP", True,
|
|
||||||
"%dx%d, %d bytes(原 GIF %d bytes,-%.0f%%)" % (
|
|
||||||
gif_res[0][2][0], gif_res[0][2][1], gif_res[0][3], len(imgs["anim"]),
|
|
||||||
(1 - gif_res[0][3] / len(imgs["anim"])) * 100))
|
|
||||||
elif gif_res[0][1] == "gif":
|
|
||||||
step("B2 动画 GIF 已转为 WebP", False,
|
|
||||||
"仍是原始 GIF %d bytes -> cwebp 不可用或转换失败" % gif_res[0][3])
|
|
||||||
else:
|
|
||||||
step("B2 动画 GIF 转 WebP", False, "得到 %s(预期 webp)" % gif_res[0][1])
|
|
||||||
|
|
||||||
# A:线框图应回退原图(PNG),而非转成更大的 WebP
|
|
||||||
wire = [f for f in fetched if f[0] == "wire"]
|
|
||||||
if not wire:
|
|
||||||
step("A 线框图兜底回退", False, "未取到 wire 样本结果")
|
|
||||||
elif wire[0][1] == "png":
|
|
||||||
step("A 转换无收益时回退原图(线框图保持 PNG)", True,
|
|
||||||
"%d bytes(原 PNG %d bytes)" % (wire[0][3], len(imgs["wire"])))
|
|
||||||
else:
|
|
||||||
step("A 线框图兜底回退", False,
|
|
||||||
"被转成了 %s(%d bytes)——本应回退原图" % (wire[0][1], wire[0][3]))
|
|
||||||
|
|
||||||
print("\n== 结果:%d/%d 通过 ==" % (sum(1 for _, ok, _ in RESULTS if ok), len(RESULTS)))
|
|
||||||
print("清理:TamaBox ban --email %s --yes" % email)
|
|
||||||
return 0 if all(ok for _, ok, _ in RESULTS) else 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,419 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""上传链路端到端验证(A 分档 / B1 EXIF 归一 + EXIF 比例校验 / B3 单次解码)。
|
|
||||||
|
|
||||||
为什么主用例走「背景」而不是头像
|
|
||||||
--------------------------------
|
|
||||||
头像要求 1:1,是**对称**比例:EXIF 归一(5/6/7/8 交换宽高)后比例不变。
|
|
||||||
所以无论修没修比例校验的 EXIF 缺口,头像路径的表现完全一样 —— **测不出缺口**。
|
|
||||||
背景要求 16:9,非对称,且用户真实场景就是「手机竖拍一张照当背景」:
|
|
||||||
像素 1080x1920(竖,比例 0.5625)+ EXIF Orientation=6(顺时针 90)
|
|
||||||
-> 用户肉眼看到的是 1920x1080 横图,比例 1.7778,符合 16:9
|
|
||||||
-> 修复前:只看原始像素 0.5625 vs 1.7778,偏差 1.215 > 0.01 → **被误拒**
|
|
||||||
-> 修复后:先交换宽高得 1920x1080,偏差 0 → 放行
|
|
||||||
|
|
||||||
头像用例保留,但只用来回归 A 分档(不再承担 EXIF 比例校验的验证职责)。
|
|
||||||
|
|
||||||
用法:
|
|
||||||
python dev/devcheck-upload.py [--base https://box-dev-api.337845818.best]
|
|
||||||
依赖:Pillow(仅用于本地生成测试图片)
|
|
||||||
清理:TamaBox ban --email <输出中的测试邮箱> --yes
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import http.cookiejar
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import struct
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from PIL import Image, ImageOps
|
|
||||||
|
|
||||||
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
||||||
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
|
|
||||||
RESULTS = []
|
|
||||||
|
|
||||||
|
|
||||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
||||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class Resp:
|
|
||||||
def __init__(self, status, headers, body):
|
|
||||||
self.status = status
|
|
||||||
self.h = {k.lower(): v for k, v in headers.items()}
|
|
||||||
self.body = body
|
|
||||||
|
|
||||||
@property
|
|
||||||
def loc(self):
|
|
||||||
return self.h.get("location", "")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def text(self):
|
|
||||||
return self.body.decode("utf-8", "replace")
|
|
||||||
|
|
||||||
|
|
||||||
class Client:
|
|
||||||
def __init__(self, base):
|
|
||||||
self.base = base
|
|
||||||
self.cj = http.cookiejar.CookieJar()
|
|
||||||
self.op = urllib.request.build_opener(
|
|
||||||
urllib.request.HTTPCookieProcessor(self.cj), NoRedirect())
|
|
||||||
|
|
||||||
def _send(self, req, timeout=180):
|
|
||||||
try:
|
|
||||||
with self.op.open(req, timeout=timeout) as r:
|
|
||||||
return Resp(r.status, dict(r.headers), r.read())
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
try:
|
|
||||||
body = e.read()
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
body = b""
|
|
||||||
return Resp(e.code, dict(e.headers), body)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
return Resp(-1, {}, ("TRANSPORT_ERROR: %s" % e).encode())
|
|
||||||
|
|
||||||
def req(self, method, path, data=None, content_type=None, timeout=180):
|
|
||||||
url = path if path.startswith("http") else self.base + path
|
|
||||||
headers = {"User-Agent": UA, "Referer": self.base + path.split("?")[0]}
|
|
||||||
body = None
|
|
||||||
if data is not None:
|
|
||||||
body = data if isinstance(data, bytes) else urllib.parse.urlencode(data).encode()
|
|
||||||
headers["Content-Type"] = content_type or "application/x-www-form-urlencoded"
|
|
||||||
return self._send(urllib.request.Request(url, data=body, headers=headers, method=method),
|
|
||||||
timeout)
|
|
||||||
|
|
||||||
def get(self, p, **kw):
|
|
||||||
r = self.req("GET", p, **kw)
|
|
||||||
return r if r.status != -1 else self.req("GET", p, **kw)
|
|
||||||
|
|
||||||
def post(self, p, data=None, **kw):
|
|
||||||
return self.req("POST", p, data=data, **kw)
|
|
||||||
|
|
||||||
def get_binary(self, url):
|
|
||||||
return self._send(urllib.request.Request(
|
|
||||||
url, headers={"User-Agent": UA}, method="GET"), timeout=60)
|
|
||||||
|
|
||||||
|
|
||||||
def step(desc, ok, extra=""):
|
|
||||||
RESULTS.append((desc, ok, extra))
|
|
||||||
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", desc, (" (%s)" % extra) if extra else ""))
|
|
||||||
return ok
|
|
||||||
|
|
||||||
|
|
||||||
def redness_bbox(im):
|
|
||||||
"""返回红色标记条的包围盒 (x0, y0, x1, y1)。
|
|
||||||
|
|
||||||
用来做「方向是否真的被旋转」的判据:尺寸对不代表方向对,必须看特征位置。
|
|
||||||
有损 WebP 会让红色略有偏差,故阈值放宽并按 4px 步长采样。
|
|
||||||
"""
|
|
||||||
W, H = im.size
|
|
||||||
px = im.load()
|
|
||||||
xs, ys = [], []
|
|
||||||
for y in range(0, H, 4):
|
|
||||||
for x in range(0, W, 4):
|
|
||||||
r, g, b = px[x, y]
|
|
||||||
if r > 150 and g < 90 and b < 90:
|
|
||||||
xs.append(x)
|
|
||||||
ys.append(y)
|
|
||||||
return (min(xs), min(ys), max(xs), max(ys)) if xs else None
|
|
||||||
|
|
||||||
|
|
||||||
def csrf_of(html):
|
|
||||||
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
|
|
||||||
return m.group(1) if m else None
|
|
||||||
|
|
||||||
|
|
||||||
def captcha(c):
|
|
||||||
j = json.loads(c.get("/api/captcha/generate").text)
|
|
||||||
pid, q = j["data"]["id"], j["data"]["question"]
|
|
||||||
m = re.match(r"\s*(\d+)\s*([+\-×])\s*(\d+)\s*=", q)
|
|
||||||
a, op, b = int(m.group(1)), m.group(2), int(m.group(3))
|
|
||||||
return "%s:%d" % (pid, {"+": a + b, "-": a - b, "×": a * b}[op])
|
|
||||||
|
|
||||||
|
|
||||||
def multipart(fields, files):
|
|
||||||
boundary = "----tbuv%s" % uuid.uuid4().hex
|
|
||||||
buf = io.BytesIO()
|
|
||||||
for k, v in fields.items():
|
|
||||||
buf.write(("--%s\r\n" % boundary).encode())
|
|
||||||
buf.write(('Content-Disposition: form-data; name="%s"\r\n\r\n' % k).encode())
|
|
||||||
buf.write(("%s\r\n" % v).encode())
|
|
||||||
for name, fn, ct, data in files:
|
|
||||||
buf.write(("--%s\r\n" % boundary).encode())
|
|
||||||
buf.write(('Content-Disposition: form-data; name="%s"; filename="%s"\r\n' % (name, fn)).encode())
|
|
||||||
buf.write(("Content-Type: %s\r\n\r\n" % ct).encode())
|
|
||||||
buf.write(data)
|
|
||||||
buf.write(b"\r\n")
|
|
||||||
buf.write(("--%s--\r\n" % boundary).encode())
|
|
||||||
return buf.getvalue(), "multipart/form-data; boundary=%s" % boundary
|
|
||||||
|
|
||||||
|
|
||||||
def sniff(d):
|
|
||||||
if len(d) >= 12 and d[:4] == b"RIFF" and d[8:12] == b"WEBP":
|
|
||||||
return "webp", webp_dims(d)
|
|
||||||
if d[:3] == b"\xff\xd8\xff":
|
|
||||||
return "jpeg", jpeg_dims(d)
|
|
||||||
if d[:8] == b"\x89PNG\r\n\x1a\n":
|
|
||||||
return "png", (struct.unpack(">II", d[16:24]) if len(d) >= 24 else None)
|
|
||||||
return "unknown", None
|
|
||||||
|
|
||||||
|
|
||||||
def webp_dims(d):
|
|
||||||
c = d[12:16]
|
|
||||||
if c == b"VP8 " and len(d) >= 30:
|
|
||||||
return (struct.unpack("<H", d[26:28])[0] & 0x3FFF,
|
|
||||||
struct.unpack("<H", d[28:30])[0] & 0x3FFF)
|
|
||||||
if c == b"VP8L" and len(d) >= 25:
|
|
||||||
b = struct.unpack("<I", d[21:25])[0]
|
|
||||||
return (b & 0x3FFF) + 1, ((b >> 14) & 0x3FFF) + 1
|
|
||||||
if c == b"VP8X" and len(d) >= 30:
|
|
||||||
return ((d[24] | d[25] << 8 | d[26] << 16) + 1,
|
|
||||||
(d[27] | d[28] << 8 | d[29] << 16) + 1)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def jpeg_dims(d):
|
|
||||||
i = 2
|
|
||||||
while i + 9 < len(d):
|
|
||||||
if d[i] != 0xFF:
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
m = d[i + 1]
|
|
||||||
if m in (0xC0, 0xC1, 0xC2, 0xC3):
|
|
||||||
h, w = struct.unpack(">HH", d[i + 5:i + 9])
|
|
||||||
return (w, h)
|
|
||||||
if m in (0xD8, 0xD9) or 0xD0 <= m <= 0xD7:
|
|
||||||
i += 2
|
|
||||||
continue
|
|
||||||
i += 2 + struct.unpack(">H", d[i + 2:i + 4])[0]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def make_img(w, h, orientation=None, portrait=False):
|
|
||||||
"""生成带方位特征的图片。
|
|
||||||
|
|
||||||
portrait=True 时在**左侧**画一条粗红色竖条:EXIF 顺时针 90 后它会跑到顶部,
|
|
||||||
用于像素级确认方向确实被归一了(而非仅尺寸数字对)。
|
|
||||||
"""
|
|
||||||
from PIL import ImageDraw
|
|
||||||
im = Image.new("RGB", (w, h), (30, 120, 200))
|
|
||||||
d = ImageDraw.Draw(im)
|
|
||||||
if portrait:
|
|
||||||
d.rectangle([0, 0, max(2, w // 12), h], fill=(230, 30, 30))
|
|
||||||
d.text((max(4, w // 12 + 6), h // 2), "TOP-IS-LEFT", fill=(255, 255, 255))
|
|
||||||
else:
|
|
||||||
d.rectangle([0, 0, w, max(2, h // 12)], fill=(230, 30, 30))
|
|
||||||
d.text((w // 4, max(4, h // 12 + 4)), "TOP-IS-UP", fill=(255, 255, 255))
|
|
||||||
for i in range(0, w, max(8, w // 20)):
|
|
||||||
d.line([(i, h // 4), (i, h)], fill=(255, 255, 255), width=2)
|
|
||||||
buf = io.BytesIO()
|
|
||||||
if orientation:
|
|
||||||
exif = im.getexif()
|
|
||||||
exif[0x0112] = orientation
|
|
||||||
im.save(buf, "JPEG", quality=90, exif=exif)
|
|
||||||
else:
|
|
||||||
im.save(buf, "JPEG", quality=90)
|
|
||||||
return buf.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
def scrape_profile_fields(html):
|
|
||||||
"""从 profile 页抓取所有 input 的 name/value,保证回填必填项。
|
|
||||||
|
|
||||||
UpdateProfile 的必填字段有十来个(昵称/介绍/5 个二维码颜色/点大小/透明度…),
|
|
||||||
漏一个就会因 `valid:"required"` 被Bind 挡下、图片根本不会走到上传分支。
|
|
||||||
"""
|
|
||||||
fields = {}
|
|
||||||
for m in re.finditer(r'<input\b[^>]*>', html, re.I):
|
|
||||||
tag = m.group(0)
|
|
||||||
nm = re.search(r'name="([^"]+)"', tag, re.I)
|
|
||||||
if not nm:
|
|
||||||
continue
|
|
||||||
name = nm.group(1)
|
|
||||||
if name in ("avatar", "background", "backgroundimage", "_csrf", "submit"):
|
|
||||||
continue
|
|
||||||
typ = re.search(r'type="([^"]+)"', tag, re.I)
|
|
||||||
if typ and typ.group(1).lower() in ("checkbox", "radio"):
|
|
||||||
if not re.search(r'\bchecked\b', tag, re.I):
|
|
||||||
continue
|
|
||||||
val = re.search(r'value="([^"]*)"', tag, re.I)
|
|
||||||
fields[name] = val.group(1) if val else ""
|
|
||||||
for m in re.finditer(r'<select\b[^>]*name="([^"]+)"[^>]*>(.*?)</select>', html, re.I | re.S):
|
|
||||||
name, body = m.group(1), m.group(2)
|
|
||||||
opts = re.findall(r'<option\b([^>]*)>', body, re.I)
|
|
||||||
chosen = ""
|
|
||||||
for o in opts:
|
|
||||||
vm = re.search(r'value="([^"]*)"', o, re.I)
|
|
||||||
v = vm.group(1) if vm else ""
|
|
||||||
if re.search(r'\bselected\b', o, re.I):
|
|
||||||
chosen = v
|
|
||||||
break
|
|
||||||
if not chosen:
|
|
||||||
chosen = v
|
|
||||||
fields[name] = chosen
|
|
||||||
for m in re.finditer(r'<textarea\b[^>]*name="([^"]+)"[^>]*>(.*?)</textarea>', html, re.I | re.S):
|
|
||||||
fields[m.group(1)] = re.sub(r"<[^>]+>", "", m.group(2)).strip()
|
|
||||||
return fields
|
|
||||||
|
|
||||||
|
|
||||||
def upload(c, field, data, fname="img.jpg"):
|
|
||||||
"""上传图片到 profile 表单的指定字段(avatar / background)。
|
|
||||||
|
|
||||||
返回 (图片CDN URL 或 None, 错误描述或 None)。
|
|
||||||
"""
|
|
||||||
page = c.get("/user/profile")
|
|
||||||
if page.status != 200:
|
|
||||||
return None, "profile HTTP %s" % page.status
|
|
||||||
fields = scrape_profile_fields(page.text)
|
|
||||||
fields["_csrf"] = csrf_of(page.text)
|
|
||||||
body, ct = multipart(fields, [(field, fname, "image/jpeg", data)])
|
|
||||||
res = c.post("/user/profile/update", data=body, content_type=ct)
|
|
||||||
if res.status == 302 and res.loc:
|
|
||||||
res = c.get(res.loc if res.loc.startswith("/") else "/user/profile")
|
|
||||||
if res.status != 200:
|
|
||||||
return None, "update 后页面 HTTP %s" % res.status
|
|
||||||
|
|
||||||
if field == "background":
|
|
||||||
m = re.search(r'id="bg-prev"[^>]*src="([^"]+)"', res.text) or \
|
|
||||||
re.search(r'src="([^"]+)"[^>]*id="bg-prev"', res.text)
|
|
||||||
url = m.group(1) if m else None
|
|
||||||
else:
|
|
||||||
urls = re.findall(r'https://r2\.[^"\'\s<>]+/picture/[0-9]+/\d{2}/\d{2}/[0-9a-f]+', res.text)
|
|
||||||
if not urls:
|
|
||||||
m = re.search(r'(https://r2\.[^"\'\s<>]+/picture/[^"\'\s<>]+)', res.text)
|
|
||||||
urls = [m.group(1)] if m else []
|
|
||||||
url = urls[-1] if urls else None
|
|
||||||
|
|
||||||
if url:
|
|
||||||
return url, None
|
|
||||||
dump = "C:/Users/Tamakyi Gekka/AppData/Local/Temp/tbuv_profile.html"
|
|
||||||
with open(dump, "wb") as f:
|
|
||||||
f.write(res.body)
|
|
||||||
alerts = re.findall(r'class="uk-alert[^"]*"[^>]*>(.*?)</div>', res.text, re.S)
|
|
||||||
hint = " | ".join(re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", a)).strip()[:80]
|
|
||||||
for a in alerts[:3])
|
|
||||||
return None, "未取到图片 URL(已存 %s)%s%s" % (dump, (" 提示:" + hint) if hint else "",
|
|
||||||
(" HTTP %s" % res.status))
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument("--base", default="https://box-dev-api.337845818.best")
|
|
||||||
args = ap.parse_args()
|
|
||||||
base = args.base.rstrip("/")
|
|
||||||
pwd = "TbUv!2026"
|
|
||||||
ts = time.strftime("%m%d%H%M%S")
|
|
||||||
email, domain = "tbuv%s@example.com" % ts, "tbuv%s" % ts
|
|
||||||
print("== 账号:%s / %s ==" % (email, domain))
|
|
||||||
|
|
||||||
# ---- 本地样本 ----
|
|
||||||
square = make_img(1600, 1600) # A 分档回归:头像
|
|
||||||
bg_ok = make_img(1920, 1080) # 背景 16:9,无 EXIF(正向对照)
|
|
||||||
bg_ori6 = make_img(1080, 1920, orientation=6, portrait=True) # 核心用例
|
|
||||||
bg_bad = make_img(1080, 1920) # 无 EXIF 的竖图:应被拒(16:9 校验仍生效)
|
|
||||||
print(" 样本 avatar 1600x1600 %7d B" % len(square))
|
|
||||||
print(" 样本 bg-ok 1920x1080 %7d B" % len(bg_ok))
|
|
||||||
print(" 样本 bg-ori6 1080x1920 (EXIF=6) %7d B" % len(bg_ori6))
|
|
||||||
print(" 样本 bg-bad 1080x1920 (无 EXIF) %7d B" % len(bg_bad))
|
|
||||||
|
|
||||||
c = Client(base)
|
|
||||||
r = c.get("/register")
|
|
||||||
reg = c.post("/register", {
|
|
||||||
"email": email, "domain": domain, "name": "TBUv%s" % ts,
|
|
||||||
"password": pwd, "repeat_password": pwd,
|
|
||||||
"g-recaptcha-response": captcha(c), "language": "zh-CN",
|
|
||||||
"_csrf": csrf_of(r.text),
|
|
||||||
})
|
|
||||||
if not step("注册", reg.status == 302, "HTTP %s -> %s" % (reg.status, reg.loc)):
|
|
||||||
return 1
|
|
||||||
|
|
||||||
c = Client(base)
|
|
||||||
r = c.get("/login")
|
|
||||||
lg = c.post("/login", {"email": email, "password": pwd,
|
|
||||||
"g-recaptcha-response": captcha(c), "_csrf": csrf_of(r.text)})
|
|
||||||
if not step("登录", lg.status == 302, "HTTP %s -> %s" % (lg.status, lg.loc)):
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# ================= A 分档(头像回归) =================
|
|
||||||
print("\n-- A 分档(头像 512/q78)--")
|
|
||||||
url, err = upload(c, "avatar", square)
|
|
||||||
if not step("上传方形大头像(1600x1600)", url is not None, err or (url or "")):
|
|
||||||
return 1
|
|
||||||
img = c.get_binary(url)
|
|
||||||
if not step("拉回头像", img.status == 200 and bool(img.body), "HTTP %s" % img.status):
|
|
||||||
return 1
|
|
||||||
kind, dims = sniff(img.body)
|
|
||||||
print(" 存储结果:%s %s %d bytes" % (kind, dims, len(img.body)))
|
|
||||||
step("A 分档生效:大图被缩到头像档 512", dims == (512, 512), "实际 %s" % (dims,))
|
|
||||||
|
|
||||||
# ================= B1 背景正向对照(无 EXIF) =================
|
|
||||||
print("\n-- 背景 16:9 正向对照(无 EXIF)--")
|
|
||||||
url_bg, err = upload(c, "background", bg_ok)
|
|
||||||
if not step("上传 16:9 背景(1920x1080,无 EXIF)", url_bg is not None, err or (url_bg or "")):
|
|
||||||
return 1
|
|
||||||
r1 = c.get_binary(url_bg)
|
|
||||||
k1, d1 = sniff(r1.body)
|
|
||||||
print(" 存储结果:%s %s %d bytes" % (k1, d1, len(r1.body)))
|
|
||||||
step("背景档 1920 且比例正确", k1 == "webp" and d1 == (1920, 1080), "实际 %s %s" % (k1, d1))
|
|
||||||
|
|
||||||
# ================= B1 核心:EXIF Orientation=6 的竖拍背景 =================
|
|
||||||
print("\n-- B1 核心:EXIF 比例校验缺口(手机竖拍当背景)--")
|
|
||||||
print(" 像素 1080x1920 + EXIF=6 ->肉眼看到 1920x1080 (16:9)")
|
|
||||||
print(" 修复前:按像素 0.5625 判比例 -> 偏差 1.215 >> 0.01 -> 被误拒")
|
|
||||||
url_o, err = upload(c, "background", bg_ori6)
|
|
||||||
if not step("上传 EXIF 竖拍背景(1080x1920, Orientation=6)", url_o is not None,
|
|
||||||
err or (url_o or "")):
|
|
||||||
print(" >>> 若此处 FAIL,说明 EXIF 比例校验缺口仍未修复")
|
|
||||||
return 1
|
|
||||||
if url_o == url_bg:
|
|
||||||
step("EXIF 背景确实换了新图", False, "两次上传返回同一 URL(第二次被拒、沿用旧值)")
|
|
||||||
return 1
|
|
||||||
r2 = c.get_binary(url_o)
|
|
||||||
k2, d2 = sniff(r2.body)
|
|
||||||
print(" 存储结果:%s %s %d bytes" % (k2, d2, len(r2.body)))
|
|
||||||
step("B1 EXIF 归一后按16:9 存盘(1920x1080)", k2 == "webp" and d2 == (1920, 1080),
|
|
||||||
"实际 %s %s" % (k2, d2))
|
|
||||||
step("B1 归一后限边生效(长边 1920)",
|
|
||||||
d2 is not None and max(d2) == 1920, "实际 %s" % (d2,))
|
|
||||||
|
|
||||||
# 尺寸对了不代表方向对了 —— 与 Pillow exif_transpose 作像素级黄金标准比对:
|
|
||||||
# 定位红色条的包围盒,两边必须一致(原图红条在左侧,顺时针 90 后应跑到顶部)。
|
|
||||||
ref = ImageOps.exif_transpose(Image.open(io.BytesIO(bg_ori6))).convert("RGB")
|
|
||||||
got = Image.open(io.BytesIO(r2.body)).convert("RGB")
|
|
||||||
if ref.size == got.size:
|
|
||||||
bb_ref, bb_got = redness_bbox(ref), redness_bbox(got)
|
|
||||||
step("B1 像素级方向与 Pillow exif_transpose 一致(红条包围盒)",
|
|
||||||
bb_ref is not None and bb_ref == bb_got,
|
|
||||||
"黄金标准 %s vs 存盘 %s" % (bb_ref, bb_got))
|
|
||||||
else:
|
|
||||||
step("B1 像素级方向与 Pillow exif_transpose 一致(红条包围盒)", False,
|
|
||||||
"尺寸不同:黄金标准 %s vs 存盘 %s" % (ref.size, got.size))
|
|
||||||
|
|
||||||
# ================= 反向用例:EXIF 修复不能放松比例校验 =================
|
|
||||||
print("\n-- 反向用例:无 EXIF 的竖图仍应被拒(确认没把校验放得过宽)--")
|
|
||||||
url_bad, err_bad = upload(c, "background", bg_bad)
|
|
||||||
# 期望:上传被拒 -> bg-prev 仍是上一步的 ori6 图(url_o)
|
|
||||||
if url_bad == url_o:
|
|
||||||
step("无 EXIF 的 9:16 竖图被 16:9 校验拒绝", True, "bg-prev 保持 ori6 那张")
|
|
||||||
elif url_bad is None:
|
|
||||||
step("无 EXIF 的 9:16 竖图被 16:9 校验拒绝", True, "上传被拒(%s)" % (err_bad or "")[:60])
|
|
||||||
else:
|
|
||||||
step("无 EXIF 的 9:16 竖图被 16:9 校验拒绝", False,
|
|
||||||
"竟被接受并存成 %s" % (url_bad,))
|
|
||||||
|
|
||||||
print("\n== 结果:%d/%d 通过 ==" % (sum(1 for _, ok, _ in RESULTS if ok), len(RESULTS)))
|
|
||||||
for d, ok, e in RESULTS:
|
|
||||||
if not ok:
|
|
||||||
print(" FAIL: %s %s" % (d, e))
|
|
||||||
print("清理:TamaBox ban --email %s --yes" % email)
|
|
||||||
return 0 if all(ok for _, ok, _ in RESULTS) else 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
+1
-1
@@ -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"))
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
'
|
||||||
+132
-4
@@ -16,13 +16,26 @@
|
|||||||
TEST_PREVIEW=1 go test ./templates -run TestGenPreview
|
TEST_PREVIEW=1 go test ./templates -run TestGenPreview
|
||||||
|
|
||||||
# 2) 起服务(只读,不改项目任何文件)
|
# 2) 起服务(只读,不改项目任何文件)
|
||||||
python dev/theme-builder/serve.py # 默认 8777
|
python dev/theme-builder/serve.py # 默认监听 0.0.0.0:8777,同局域网可访问
|
||||||
|
python dev/theme-builder/serve.py --host 127.0.0.1 # 只给本机用(公共网络请加这个)
|
||||||
|
|
||||||
# 3) 打开
|
# 3) 打开终端里打印的地址
|
||||||
http://127.0.0.1:8777/dev/theme-builder/
|
# 本机 http://127.0.0.1:8777/dev/theme-builder/
|
||||||
|
# 局域网 http://192.168.x.x:8777/dev/theme-builder/
|
||||||
```
|
```
|
||||||
|
|
||||||
不想用脚本也行:在项目根跑 `python -m http.server 8777`,效果一样。
|
默认绑 `0.0.0.0`,是为了能躺在床上用手机 / 平板调主题:启动时会把本机各网卡的地址都列出来,
|
||||||
|
照着点就行(手机与电脑要在同一网段)。第一次启动 Windows 会弹防火墙询问,要允许私有网络。
|
||||||
|
|
||||||
|
⚠️ 两个必须知道的点:
|
||||||
|
|
||||||
|
- **上传接口没有账号体系**:能打开这个端口的人,都能用你填的密钥往对象存储写 / 删文件。
|
||||||
|
公共网络(咖啡馆、宿舍、公司网)一律 `--host 127.0.0.1`。写接口还会校验 `Origin` 与
|
||||||
|
`Host` 同源,挡掉「别的网页借你浏览器发请求」,但挡不住同网段主动连过来的人。
|
||||||
|
- **换地址 = 换 origin**:`127.0.0.1` 与 `192.168.x.x` 在浏览器眼里是两个站点,
|
||||||
|
草稿(localStorage)与 S3 密钥(sessionStorage)不共享 —— 第一次用新地址要重填一次密钥。
|
||||||
|
|
||||||
|
不想用脚本也行:在项目根跑 `python -m http.server 8777`,但那就只有静态预览、没有上传能力。
|
||||||
|
|
||||||
## 它和线上是同一条链路
|
## 它和线上是同一条链路
|
||||||
|
|
||||||
@@ -133,6 +146,46 @@ http://127.0.0.1:8777/dev/theme-builder/
|
|||||||
生成 `--tb-page-position`(`base.css` 里以 `var(--tb-page-position, center center)` 兜底,
|
生成 `--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。导出时只打包实际用到的图。
|
**图片池** —— 上传任意张,浏览器本地转 WebP。导出时只打包实际用到的图。
|
||||||
每张图左上角有 `fx` 按钮,进处理面板(处理过的会高亮)。
|
每张图左上角有 `fx` 按钮,进处理面板(处理过的会高亮)。
|
||||||
|
|
||||||
@@ -167,6 +220,79 @@ WebP 体积 ≈ 像素数 × 质量。**真正的大头是像素**:CSS 里只
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 质量 | 0.88 | 0.85~0.9 肉眼几乎看不出差别,低于 0.8 才明显糊 |
|
| 质量 | 0.88 | 0.85~0.9 肉眼几乎看不出差别,低于 0.8 才明显糊 |
|
||||||
| 长边上限 | 1600 | 只缩不放。整页背景建议 1920/不限制;装饰人物 1200 够用 |
|
| 长边上限 | 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)`。
|
面板底部实时显示 `3 张,共 19KB;最大一张 bg.webp 7KB(1920×1080)`。
|
||||||
改完立刻按「原图 + fx 参数」重编码全部素材,并报出省了多少 —— 与图片处理同一条非破坏原则,
|
改完立刻按「原图 + fx 参数」重编码全部素材,并报出省了多少 —— 与图片处理同一条非破坏原则,
|
||||||
@@ -252,6 +378,8 @@ INSTALL-<slug>.txt
|
|||||||
?dump=layers 图层/作用域回归(加层默认不绑图、行内换图、自动切独立、新建清空)
|
?dump=layers 图层/作用域回归(加层默认不绑图、行内换图、自动切独立、新建清空)
|
||||||
?dump=fit 尺寸自适应回归(五档视口占用、溢出检测、fitw、旧工程兼容、@media 覆盖各作用域)
|
?dump=fit 尺寸自适应回归(五档视口占用、溢出检测、fitw、旧工程兼容、@media 覆盖各作用域)
|
||||||
?dump=quality 画质回归(q 单调性、q=1 被夹到 0.95、长边上限真的压像素)
|
?dump=quality 画质回归(q 单调性、q=1 被夹到 0.95、长边上限真的压像素)
|
||||||
|
?dump=palette 派生文字色的对比度回归(四种配色下 ≥AA、层级不倒挂、手填低对比不继续变淡)
|
||||||
|
?dump=navbar 顶栏三档回归(实色无 blur、半透明带 alpha+blur、全透明被 validate 拦、alpha 边界)
|
||||||
?dump=fxkey 端到端去白底:载入白底图,统计透明像素比例
|
?dump=fxkey 端到端去白底:载入白底图,统计透明像素比例
|
||||||
?loadurl=/static/images/x.webp&fx=x
|
?loadurl=/static/images/x.webp&fx=x
|
||||||
直接把项目里的图当素材载入(不必先下载再上传),fx= 打开处理面板
|
直接把项目里的图当素材载入(不必先下载再上传),fx= 打开处理面板
|
||||||
|
|||||||
@@ -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())
|
||||||
+1034
-20
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())
|
||||||
+531
-10
@@ -1,33 +1,441 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""主题生成器的本地预览服务。
|
"""主题生成器的本地预览服务 + 素材上传代理。
|
||||||
|
|
||||||
为什么必须起服务:生成器不再自己画一个"仿制页面",而是把项目真实渲染出来的
|
为什么必须起服务:生成器不再自己画一个"仿制页面",而是把项目真实渲染出来的
|
||||||
页面(outputs/preview-*.html)放进 iframe 当画板,再往里注入正在编辑的主题 CSS。
|
页面(outputs/preview-*.html)放进 iframe 当画板,再往里注入正在编辑的主题 CSS。
|
||||||
file:// 下浏览器不允许父页面访问 iframe 的 DOM(即使同目录),预览就只能退回示意图。
|
file:// 下浏览器不允许父页面访问 iframe 的 DOM(即使同目录),预览就只能退回示意图。
|
||||||
起一个本地 HTTP 服务后父子同源,拖拽、注入、实时改令牌都能正常工作。
|
起一个本地 HTTP 服务后父子同源,拖拽、注入、实时改令牌都能正常工作。
|
||||||
|
|
||||||
用法(在项目根目录执行任意一种):
|
为什么上传也走这个服务:浏览器直连对象存储要配 CORS、还要把密钥交给页面里的
|
||||||
|
fetch,各家 S3 兼容实现的 CORS 配置界面又都不一样。放到本机 Python 里代发,
|
||||||
|
生成器只跟 127.0.0.1 说话:不碰 CORS,密钥不落盘、不进工程文件、不进导出 zip。
|
||||||
|
|
||||||
python dev/theme-builder/serve.py
|
用法(在项目根目录执行):
|
||||||
python -m http.server 8777
|
|
||||||
|
|
||||||
然后打开 http://localhost:8777/dev/theme-builder/
|
python dev/theme-builder/serve.py # 默认 0.0.0.0:8777,同局域网可访问
|
||||||
|
python dev/theme-builder/serve.py --host 127.0.0.1 # 只给本机(公共网络用这个)
|
||||||
|
python -m http.server 8777 # 只有静态预览,没有上传能力
|
||||||
|
|
||||||
|
启动时终端会列出本机与各网卡的地址,照着打开即可。
|
||||||
|
|
||||||
这个脚本只读文件,不写任何东西,也不改项目配置。
|
这个脚本只读文件,不写任何东西,也不改项目配置。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import base64
|
||||||
|
import datetime
|
||||||
import functools
|
import functools
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
import http.server
|
import http.server
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
import socketserver
|
import socketserver
|
||||||
import sys
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
# dev/theme-builder/serve.py → 上两级就是项目根
|
# dev/theme-builder/serve.py → 上两级就是项目根
|
||||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
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):
|
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||||
|
server_version = "TamaBoxThemeBuilder/1"
|
||||||
|
|
||||||
def end_headers(self):
|
def end_headers(self):
|
||||||
# 预览页是刚由 go test 生成的,禁止缓存,否则改了 CSS 看不到变化
|
# 预览页是刚由 go test 生成的,禁止缓存,否则改了 CSS 看不到变化
|
||||||
self.send_header("Cache-Control", "no-store, must-revalidate")
|
self.send_header("Cache-Control", "no-store, must-revalidate")
|
||||||
@@ -39,11 +447,108 @@ class Handler(http.server.SimpleHTTPRequestHandler):
|
|||||||
if str(status).startswith(("4", "5")):
|
if str(status).startswith(("4", "5")):
|
||||||
sys.stderr.write(" %s\n" % (fmt % args))
|
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():
|
def main():
|
||||||
ap = argparse.ArgumentParser(description="TamaBox 主题生成器的本地预览服务")
|
ap = argparse.ArgumentParser(description="TamaBox 主题生成器的本地预览服务")
|
||||||
ap.add_argument("--port", type=int, default=8777, help="端口,默认 8777")
|
ap.add_argument("--port", type=int, default=8777, help="端口,默认 8777")
|
||||||
ap.add_argument("--host", default="127.0.0.1", help="监听地址,默认 127.0.0.1")
|
ap.add_argument("--host", default="0.0.0.0",
|
||||||
|
help="监听地址,默认 0.0.0.0(同一局域网都能打开);只给本机用就写 127.0.0.1")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
if not os.path.isdir(os.path.join(PROJECT_ROOT, "outputs")):
|
if not os.path.isdir(os.path.join(PROJECT_ROOT, "outputs")):
|
||||||
@@ -52,12 +557,28 @@ def main():
|
|||||||
" TEST_PREVIEW=1 go test ./templates -run TestGenPreview\n"
|
" 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)
|
handler = functools.partial(Handler, directory=PROJECT_ROOT)
|
||||||
socketserver.TCPServer.allow_reuse_address = True
|
socketserver.ThreadingTCPServer.allow_reuse_address = True
|
||||||
with socketserver.TCPServer((args.host, args.port), handler) as httpd:
|
# Threading:上传一张几 MB 的图会占住连接,单线程时 iframe 预览和其它请求都得排队。
|
||||||
url = "http://%s:%d/dev/theme-builder/" % (args.host, args.port)
|
with socketserver.ThreadingTCPServer((args.host, args.port), handler) as httpd:
|
||||||
|
httpd.daemon_threads = True
|
||||||
print("项目根:%s" % PROJECT_ROOT)
|
print("项目根:%s" % PROJECT_ROOT)
|
||||||
print("生成器:%s" % url)
|
if wildcard:
|
||||||
|
for ip in local_addresses():
|
||||||
|
print("生成器:http://%s:%d/dev/theme-builder/" % (ip, args.port))
|
||||||
|
print("生成器:http://127.0.0.1:%d/dev/theme-builder/ (本机)" % args.port)
|
||||||
|
print("提示:局域网里的其它设备用上面的 IP 地址打开;手机与电脑要在同一网段。")
|
||||||
|
sys.stderr.write(
|
||||||
|
"⚠️ 正在对局域网开放(%s:%d)。上传接口没有账号体系 —— 能打开这个端口的人,\n"
|
||||||
|
" 都能用你填的密钥往对象存储写 / 删文件。公共网络(咖啡馆、宿舍、公司网)\n"
|
||||||
|
" 请改用 --host 127.0.0.1。\n" % (args.host, args.port))
|
||||||
|
else:
|
||||||
|
print("生成器:http://%s:%d/dev/theme-builder/" % (args.host, args.port))
|
||||||
|
print("上传接口:/api/s3/test /api/s3/upload /api/s3/list /api/s3/delete")
|
||||||
print("Ctrl+C 停止")
|
print("Ctrl+C 停止")
|
||||||
try:
|
try:
|
||||||
httpd.serve_forever()
|
httpd.serve_forever()
|
||||||
|
|||||||
@@ -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]"))
|
||||||
+113
-5
@@ -20,13 +20,25 @@ go build -o NekoBox ./cmd/
|
|||||||
两处都注册在同一份 `app.Commands` 列表中。**新增命令时必须同时改 `tamabox.go` 和 `cmd/tamabox.go`**,
|
两处都注册在同一份 `app.Commands` 列表中。**新增命令时必须同时改 `tamabox.go` 和 `cmd/tamabox.go`**,
|
||||||
否则会出现「本机二进制有、线上没有」(或反之),典型报错是 `No help topic for '<cmd>'`。
|
否则会出现「本机二进制有、线上没有」(或反之),典型报错是 `No help topic for '<cmd>'`。
|
||||||
|
|
||||||
|
⚠️ **替换二进制不等于生效**:`go build -o tamabox .` 只改磁盘文件,正在运行的进程仍是旧映像。
|
||||||
|
换完之后必须重启(systemd / supervisor / 手动拉起),否则改了代码也看不到变化。
|
||||||
|
另外 `./tamabox web` 在前台立即返回,通常是端口被旧进程占着:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ps -eo pid,lstart,cmd | grep '[t]amabox' # 看进程启动时间,与二进制 mtime 比对
|
||||||
|
```
|
||||||
|
|
||||||
确认手头二进制包含全部命令:
|
确认手头二进制包含全部命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./tamabox --help | sed -n '/COMMANDS/,/GLOBAL/p'
|
./tamabox --help | sed -n '/COMMANDS/,/GLOBAL/p'
|
||||||
# web / censor / uid / ban / restore / reports / restrict / unrestrict / hide-public
|
# web / censor / uid / ban / restore / reports / restrict / unrestrict / hide-public / honeypot / backup / restore-images
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 想确认某个字符串有没有编进二进制,**不要用 `strings`** —— 它默认只输出可打印 ASCII,
|
||||||
|
> UTF-8 中文整段被丢弃,`strings x | grep 中文` 恒为 0。
|
||||||
|
> 用 `grep -ac "中文字符串" x`。
|
||||||
|
|
||||||
### 配置文件
|
### 配置文件
|
||||||
|
|
||||||
CLI 与 `web` 读同一份配置,路径由环境变量指定:
|
CLI 与 `web` 读同一份配置,路径由环境变量指定:
|
||||||
@@ -64,9 +76,14 @@ pg_dump -Fc <dbname> > before-$(date +%F-%H%M).dump
|
|||||||
| `restore` | 恢复已停用账号 | 是 | 用户申诉后恢复 |
|
| `restore` | 恢复已停用账号 | 是 | 用户申诉后恢复 |
|
||||||
| `censor` | 重新审核历史内容 | 是 | 审核规则变更后复检 |
|
| `censor` | 重新审核历史内容 | 是 | 审核规则变更后复检 |
|
||||||
| `uid` | 为用户生成/补齐 uid | 是 | 数据修复 |
|
| `uid` | 为用户生成/补齐 uid | 是 | 数据修复 |
|
||||||
|
| `honeypot` | 扫描器诱捕取证(假登录页) | 部分是(`prune` / `purge-creds`) | 看谁在扫、拿到了什么字典 |
|
||||||
|
| `backup` | 备份 PostgreSQL 与配置 bucket 的全部 R2 对象 | 写本地备份目录 | 灾难恢复前留档 |
|
||||||
|
| `restore-images` | 校验并恢复 R2 图片及元数据 | 是(除 `--dry-run`) | 图片灾难恢复、隔离恢复演练 |
|
||||||
| `web` | 启动 Web 服务 | — | 生产入口 |
|
| `web` | 启动 Web 服务 | — | 生产入口 |
|
||||||
|
|
||||||
除 `reports` 外,所有写操作都要求交互式输入大写 `YES` 确认;脚本化时加 `--yes`。
|
除 `reports`、`honeypot hits/stats/paths/size` 外,既有运营写操作要求交互式输入大写 `YES` 确认;
|
||||||
|
脚本化时加 `--yes`。注意 `honeypot prune` / `purge-creds` 的确认词是 **`yes`**(小写)。
|
||||||
|
`restore-images` 不做交互确认:实际写入必须显式提供 `--yes`,`--dry-run` 除外。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -277,7 +294,93 @@ pg_dump -Fc <dbname> > before-$(date +%F-%H%M).dump
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 典型处置流程
|
## 7. `honeypot` —— 扫描器取证
|
||||||
|
|
||||||
|
> 设计取舍、凭据存储格式、部署要点见 [honeypot.md](honeypot.md)。这里只列命令。
|
||||||
|
|
||||||
|
| 子命令 | 作用 | 改数据 |
|
||||||
|
|---|---|---|
|
||||||
|
| `hits` | 列出捕获到的凭据(时间倒序,密码默认脱敏) | 否 |
|
||||||
|
| `stats` | 来源排行(IPv6 按 /64 聚合)+ 路径排行(库)+ 凭据排行(文件) | 否 |
|
||||||
|
| `paths` | 列出当前生效的诱饵路径(按伪装家族汇总) | 否 |
|
||||||
|
| `size` | 表行数、磁盘占用、凭据文件体积 | 否 |
|
||||||
|
| `prune` | 按保留期清元数据 + 凭据文件 | 是 |
|
||||||
|
| `purge-creds` | 删除旧版遗留的明文凭据列(**不可逆**) | 是 |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./tamabox honeypot hits --since 7d --limit 50
|
||||||
|
./tamabox honeypot hits --ip 203.0.113.9 --show-password
|
||||||
|
./tamabox honeypot stats --days 7
|
||||||
|
./tamabox honeypot paths
|
||||||
|
./tamabox honeypot size
|
||||||
|
./tamabox honeypot prune --days 30
|
||||||
|
./tamabox honeypot purge-creds
|
||||||
|
```
|
||||||
|
|
||||||
|
要点:
|
||||||
|
|
||||||
|
- **凭据不入库**。用户名/密码只写在 `data/honeypot-creds/creds-YYYY-MM-DD.jsonl`
|
||||||
|
(目录 0700、文件 0600),数据库里只有 IP / 路径 / UA / 是否封禁这些元数据。
|
||||||
|
所以 `hits` / `paths` **不连数据库**,DB 挂了也能查。
|
||||||
|
- 密码默认脱敏(保留首尾各 1 位)。终端输出常被贴进工单或截图,`--show-password` 只在确有取证需要时开。
|
||||||
|
- `hits` / `stats` 里的 `ip` 是**精确**客户端地址;封禁用的是 `shared.LimitKey` 折叠后的键
|
||||||
|
(IPv6 为 `/64` 前缀)。两者在 IPv6 下不一致是正常的,别拿邮件里的精确地址去对封禁键。
|
||||||
|
- `purge-creds` 用于清掉早期版本存在 `honeypot_hits` 上的明文列(AutoMigrate 不删既有列)。
|
||||||
|
执行前先 `pg_dump`;它只处理当前库,**历史备份与从库里的明文需要自行清理**。
|
||||||
|
- 封禁语义:GET 探测走常规阈值(默认 10 次 / 60 秒),**提交凭据才一次即封**。
|
||||||
|
所以「扫了几十次怎么没封」不是 bug —— 见 [honeypot.md](honeypot.md) 的「两条封禁路径」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. `backup` / `restore-images` —— 数据库与 R2 图片
|
||||||
|
|
||||||
|
### 8.1 备份
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 数据库 + 配置 bucket 的全部对象
|
||||||
|
./tamabox backup --output /var/backups/tamabox
|
||||||
|
|
||||||
|
# 只备份对象,或只备份数据库
|
||||||
|
./tamabox backup --images-only --output /var/backups/tamabox
|
||||||
|
./tamabox backup --db-only --output /var/backups/tamabox
|
||||||
|
```
|
||||||
|
|
||||||
|
图片备份使用**空前缀**分页列举配置 bucket 的全部对象,不再只查 `tamabox/`。根日期 key、
|
||||||
|
`picture/`、`tamabox/` 和静态资源都会按原 key 保存到 `<output>/images/`。
|
||||||
|
备份会先完成全部分页 List、key 与本地路径预检,再开始下载;非法路径、目录 key、文件/目录碰撞会中止备份。
|
||||||
|
|
||||||
|
`images-manifest.json` 记录实际下载字节的 `size`、`sha256`,以及 GET 返回的 `etag`、
|
||||||
|
`content_type`、`cache_control`。对象与 manifest 都采用临时文件原子落盘;下载流中断会整对象重试,
|
||||||
|
最终失败不会留下 `.part`。空 bucket 的 manifest 是 `[]`。
|
||||||
|
|
||||||
|
### 8.2 恢复与隔离演练
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 校验全部本地文件并检查默认防覆盖,不写远端
|
||||||
|
./tamabox restore-images --input /var/backups/tamabox --dry-run
|
||||||
|
|
||||||
|
# 实际恢复到原 key;必须显式 --yes
|
||||||
|
./tamabox restore-images --input /var/backups/tamabox --yes
|
||||||
|
|
||||||
|
# 明确允许替换已存在对象
|
||||||
|
./tamabox restore-images --input /var/backups/tamabox --yes --overwrite
|
||||||
|
|
||||||
|
# 在原完整 key 前加隔离前缀,不覆盖生产 key
|
||||||
|
./tamabox restore-images --input /var/backups/tamabox --yes \
|
||||||
|
--target-prefix tamabox-backup-test/manual-restore/
|
||||||
|
```
|
||||||
|
|
||||||
|
`--input` 可指向备份目录,也可直接指向 `images-manifest.json`。恢复会在任何远端操作前校验全部
|
||||||
|
manifest 条目和本地文件(普通文件、非 symlink、大小、实际 SHA-256)。默认先 Head 目标;任一目标已存在
|
||||||
|
即失败且零 Put,只有 `--overwrite` 才允许替换。`--dry-run` 不写对象,实际写入必须 `--yes`。
|
||||||
|
|
||||||
|
`--target-prefix` 会加在 manifest 的完整 key 前;非空前缀末尾没有 `/` 时会自动补一个。
|
||||||
|
Put 保留内容、原 key 路径(额外添加指定前缀)、`ContentType` 与 `CacheControl`,并使用单对象超时和有限重试。
|
||||||
|
**恢复不是事务**:中途失败会明确报告已成功数量,已上传对象不会自动回滚。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 典型处置流程
|
||||||
|
|
||||||
1. **发现**:收到告警邮件(同一箱主 24h ≥ 8 条 或 7d ≥ 20 条)→ 或箱主后台出现「被举报 N 次」。
|
1. **发现**:收到告警邮件(同一箱主 24h ≥ 8 条 或 7d ≥ 20 条)→ 或箱主后台出现「被举报 N 次」。
|
||||||
2. **取证**:`reports --domain <domain>` 看条目、占比、历史处置。
|
2. **取证**:`reports --domain <domain>` 看条目、占比、历史处置。
|
||||||
@@ -299,7 +402,7 @@ pg_dump -Fc <dbname> > before-$(date +%F-%H%M).dump
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. 排错
|
## 10. 排错
|
||||||
|
|
||||||
| 现象 | 原因 | 处理 |
|
| 现象 | 原因 | 处理 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -312,12 +415,17 @@ pg_dump -Fc <dbname> > before-$(date +%F-%H%M).dump
|
|||||||
| 箱主说「我改了但没生效」 | 处置期内严格度被锁定 / L2 禁止设为公开 | 看后台条幅;`reports` 查当前处置 |
|
| 箱主说「我改了但没生效」 | 处置期内严格度被锁定 / L2 禁止设为公开 | 看后台条幅;`reports` 查当前处置 |
|
||||||
| `--days 超过 90 天` 被拒绝 | 防手误设计,需要 `--force` | 确认时长后重试,或改用常规天数 |
|
| `--days 超过 90 天` 被拒绝 | 防手误设计,需要 `--force` | 确认时长后重试,或改用常规天数 |
|
||||||
| 处置成功但箱主说没收到邮件 | SMTP 未配 / 发送失败 / 用了 `--no-notify` | 看终端 warn;`[mail]` 配好后可 `restrict` 重发(同级别重设会替换记录) |
|
| 处置成功但箱主说没收到邮件 | SMTP 未配 / 发送失败 / 用了 `--no-notify` | 看终端 warn;`[mail]` 配好后可 `restrict` 重发(同级别重设会替换记录) |
|
||||||
|
| 蜜罐记录的 `ip` 是 `127.0.0.1` | `trust_proxy` 未配,源站把所有人看成同一个代理 IP | 配 `trust_proxy`;**此时一次封禁会封掉整站**(见 [honeypot.md](honeypot.md) 的「部署」一节) |
|
||||||
|
| 整站都返回 403 | 蜜罐把反代 IP 封了(上一条的后果) | 重启进程(封禁是内存态)或等 `honeypot_ban_minutes` 到期 |
|
||||||
|
| 换了二进制但行为没变 | 进程没重启,跑的还是旧映像 | `ps -eo lstart,cmd \| grep tamabox` 比对启动时间与文件 mtime |
|
||||||
|
| `strings 二进制 \| grep 中文` 恒为 0 | `strings` 只输出可打印 ASCII,中文 UTF-8 被丢弃 | 用 `grep -ac "中文字符串" 二进制` |
|
||||||
|
| `honeypot` 命令不存在 | 二进制旧 / 两个构建入口未同步 | 重新编译后**重启进程** |
|
||||||
|
|
||||||
改完配置**必须重启**才生效(配置只在启动时读取一次)。
|
改完配置**必须重启**才生效(配置只在启动时读取一次)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. 明确不做的事
|
## 11. 明确不做的事
|
||||||
|
|
||||||
- **不做自动处置**:举报数达标只发邮件,不自动封禁。这个场景里举报本身就是攻击武器。
|
- **不做自动处置**:举报数达标只发邮件,不自动封禁。这个场景里举报本身就是攻击武器。
|
||||||
- **不做申诉后台**:申诉走邮件,处置结果由 CLI 写入。
|
- **不做申诉后台**:申诉走邮件,处置结果由 CLI 写入。
|
||||||
|
|||||||
@@ -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. 箱主设置页 → 账号 → 外观,选择「新·樱花」。
|
||||||
@@ -58,12 +58,20 @@
|
|||||||
| 地面 | 轮子底线贴画布底部,下方留 12~16px 给接触阴影 |
|
| 地面 | 轮子底线贴画布底部,下方留 12~16px 给接触阴影 |
|
||||||
| 阴影 | 可以在图内自带一层椭圆软影(不透明度 15~20%,模糊 8~12px) |
|
| 阴影 | 可以在图内自带一层椭圆软影(不透明度 15~20%,模糊 8~12px) |
|
||||||
| 雾度 | RGB 压白 **26%**(比人物淡一档,它在人身后) |
|
| 雾度 | RGB 压白 **26%**(比人物淡一档,它在人身后) |
|
||||||
|
| 背景 | **必须是纯色绿幕或透明**,不要白底(白色车架 + 白底 = 无法分离,见下方“白上白”) |
|
||||||
|
|
||||||
要点:
|
要点:
|
||||||
|
|
||||||
- 侧视图即可,但不要画成“图标”——轮辐、脚踏、链条这些细节保留,否则缩到 259px 会变成一个圈。
|
- 侧视图即可,但不要画成“图标”——轮辐、脚踏、链条这些细节保留,否则缩到 259px 会变成一个圈。
|
||||||
- 枪要细长、冷色(灰蓝),**不要木色**(会像双管猎枪)。
|
- 枪要细长、冷色(灰蓝),**不要木色**(会像双管猎枪)。
|
||||||
|
|
||||||
|
⚠️ **白上白(本项目踩过)**:白底 + 白车架时,车身涂装和背景是同一个白(实测都是 `#f9f9f9`,
|
||||||
|
差 0~3 级),颜色完全分不开;四角 floodfill 只能吃掉外圈,轮圈内、车架三角、车筐里的白会整块留下,
|
||||||
|
看上去就是「没抠干净」。此时的取舍只有两条:
|
||||||
|
1. 把这批白**全部**设为透明 —— 车身填色一起走,成品是**线稿风**(描边 + 粉色轮圈 + 蓝枪托仍在,
|
||||||
|
轮辐、车架、车筐网格都清楚,现有的 `bike.webp` 就是这么做出来的,不透明率 19.4%、剩余近白 0%);
|
||||||
|
2. 重新出一张**绿幕/透明背景**的素材,车身用浅灰蓝等中间调(白车在浅色场景里本来就发飘)。
|
||||||
|
|
||||||
## 四、场景 `scene-desktop.webp`
|
## 四、场景 `scene-desktop.webp`
|
||||||
|
|
||||||
| 项 | 值 |
|
| 项 | 值 |
|
||||||
@@ -113,5 +121,7 @@ left: calc(50% - 612px) bottom 20px;
|
|||||||
1. **白边**:放大 300% 看轮廓,出现 1px 白色描边就是没抠干净。
|
1. **白边**:放大 300% 看轮廓,出现 1px 白色描边就是没抠干净。
|
||||||
2. **闭合区域**:手内侧、腿间、环心应能透出背景色。
|
2. **闭合区域**:手内侧、腿间、环心应能透出背景色。
|
||||||
3. **接地**:脚底/轮底离画布底边 ≤ 4px;有阴影时阴影在轮子内侧。
|
3. **接地**:脚底/轮底离画布底边 ≤ 4px;有阴影时阴影在轮子内侧。
|
||||||
4. **体积**:人 ≤40KB、车 ≤40KB、场景 ≤40KB(现在分别是 16 / 23 / 19 KB)。
|
4. **体积**:人 ≤40KB、车 ≤40KB、场景 ≤40KB(现在分别是 15 / 29 / 19 KB)。
|
||||||
5. **深色模式**:所有图片在深色下都不显示,不用管。
|
5. **深色模式**:所有图片在深色下都不显示,不用管。
|
||||||
|
6. **白上白**:车这类「白底 + 白主体」的素材,验收要额外看「不透明像素里近白(>240)的占比」——
|
||||||
|
正常应接近 0;本项目修复前是 **50%**,说明一半的不透明区域其实是背景白。
|
||||||
|
|||||||
+44
-5
@@ -75,7 +75,7 @@ available = default,sakura,shiroko
|
|||||||
| 文件 | 用途 | 尺寸 | 体积 |
|
| 文件 | 用途 | 尺寸 | 体积 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `chibi/window.webp` | **当前使用**:侧后方视角、双手背在身后望向箱内,压白 16% | 135×460 | 13KB |
|
| `chibi/window.webp` | **当前使用**:侧后方视角、双手背在身后望向箱内,压白 16% | 135×460 | 13KB |
|
||||||
| `props/bike.webp` | 自行车 + 步枪(镜像后车头朝右),压白 26%,自带接触阴影 | 397×259 | 22KB |
|
| `props/bike.webp` | 自行车 + 步枪(车头朝右),压白 26% 后**只剩描边**(车身白填色已去掉,见下),自带接触阴影 | 397×259 | 29KB |
|
||||||
| `chibi/wave.webp` | 备用:正面挥手 | 324×620 | 27KB |
|
| `chibi/wave.webp` | 备用:正面挥手 | 324×620 | 27KB |
|
||||||
| `chibi/cheer.webp` | 双手欢呼 | 354×620 | 27KB |
|
| `chibi/cheer.webp` | 双手欢呼 | 354×620 | 27KB |
|
||||||
| `chibi/think.webp` | 托腮 | 283×620 | 26KB |
|
| `chibi/think.webp` | 托腮 | 283×620 | 26KB |
|
||||||
@@ -88,7 +88,7 @@ available = default,sakura,shiroko
|
|||||||
|
|
||||||
| 素材 | 来源 |
|
| 素材 | 来源 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `static/images/shiroko-cutout.png` | **用户自行用 AI 生成**的立绘,仅作生成参考图,不再上页面 |
|
| 立绘参考图 | **用户自行用 AI 生成**,仅作 image-to-image 的 `image1`,不上页面、**不入库**(2026-10-21 移除,仓库里不存大图)|
|
||||||
| `chibi/window.webp` | WorkBuddy ImageGen 生成(文生图 → image-to-image 转平涂),再抠白底 |
|
| `chibi/window.webp` | WorkBuddy ImageGen 生成(文生图 → image-to-image 转平涂),再抠白底 |
|
||||||
| `chibi/wave.webp` 等 | WorkBuddy ImageGen **以用户立绘为参考图**(image-to-image)生成,再抠白底 |
|
| `chibi/wave.webp` 等 | WorkBuddy ImageGen **以用户立绘为参考图**(image-to-image)生成,再抠白底 |
|
||||||
| `scene-desktop.webp` / `scene-mobile.webp` | WorkBuddy ImageGen 生成(提示词见下),未使用任何外部素材 |
|
| `scene-desktop.webp` / `scene-mobile.webp` | WorkBuddy ImageGen 生成(提示词见下),未使用任何外部素材 |
|
||||||
@@ -96,7 +96,8 @@ available = default,sakura,shiroko
|
|||||||
|
|
||||||
### 生成与抠图参数
|
### 生成与抠图参数
|
||||||
|
|
||||||
- 参考图:`static/images/shiroko-cutout.png`,`input_fidelity: high`,保证角色造型与平涂风格一致;
|
- 参考图:用户提供的立绘(**不入库**,随便放个工作目录,传给 `image1` 即可),
|
||||||
|
`input_fidelity: high`,保证角色造型与平涂风格一致;
|
||||||
- 提示词骨架(四个姿态共用,只改动作句):
|
- 提示词骨架(四个姿态共用,只改动作句):
|
||||||
|
|
||||||
> Same character, same art style as the reference image: flat vector illustration, no outlines,
|
> Same character, same art style as the reference image: flat vector illustration, no outlines,
|
||||||
@@ -156,9 +157,10 @@ available = default,sakura,shiroko
|
|||||||
## 替换 / 扩素材
|
## 替换 / 扩素材
|
||||||
|
|
||||||
1. 加姿态:按上面的提示词骨架换动作句生成,白底 → 抠图 → 存 `chibi/<name>.webp`(高 620),
|
1. 加姿态:按上面的提示词骨架换动作句生成,白底 → 抠图 → 存 `chibi/<name>.webp`(高 620),
|
||||||
再塞进 `shiroko.css` 的 `--tb-deco`(记得保持 6 个值,并按上面的摆位原则定位)。
|
再塞进 `shiroko.css` 的 `--tb-deco`(**必须保持 10 个值**,`--tb-deco-size` / `-position` 同步补齐,
|
||||||
|
少写几个不会报错、只会让图层与尺寸错位;`TestDecoSlotCount` 会拦),并按上面的摆位原则定位。
|
||||||
换 `<img>` 时别忘了给 URL 带 `?v=`——图片不走 `StaticVersion` 缓存键,`max-age` 是 1 天。
|
换 `<img>` 时别忘了给 URL 带 `?v=`——图片不走 `StaticVersion` 缓存键,`max-age` 是 1 天。
|
||||||
2. 换立绘:透明 PNG 覆盖 `static/images/shiroko-cutout.png`,再按同一提示词骨架重新生成姿态。
|
2. 换立绘:准备一张透明 PNG 立绘当参考图(不入库),再按同一提示词骨架重新生成姿态。
|
||||||
3. 换配色:改 `shiroko.css` 的令牌即可,插画是位图,换色需重新生成。
|
3. 换配色:改 `shiroko.css` 的令牌即可,插画是位图,换色需重新生成。
|
||||||
4. 新增素材不需要改 `static/fs.go`(已嵌整个 `images/themes` 目录);但改了 CSS 必须把
|
4. 新增素材不需要改 `static/fs.go`(已嵌整个 `images/themes` 目录);但改了 CSS 必须把
|
||||||
`conf.StaticVersion` +1,否则老访客吃缓存看不到变化。
|
`conf.StaticVersion` +1,否则老访客吃缓存看不到变化。
|
||||||
@@ -213,3 +215,40 @@ wide scene suitable as a website background
|
|||||||
2. 按 16:9 取顶部 864px,整体混白 38%(`v*0.62 + 255*0.38`)—— 保证白卡片放上去仍有足够对比度;
|
2. 按 16:9 取顶部 864px,整体混白 38%(`v*0.62 + 255*0.38`)—— 保证白卡片放上去仍有足够对比度;
|
||||||
3. 高斯模糊 1.4px,让桌椅不抢正文;
|
3. 高斯模糊 1.4px,让桌椅不抢正文;
|
||||||
4. 导出 WebP q80(1920×1080,22KB);移动端从同一张按 9:16 中裁 620px 宽,导出 720×1003。
|
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,6 +1,6 @@
|
|||||||
module github.com/tamakyi/TamaBox
|
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
|
replace golang.org/x/image => golang.org/x/image v0.13.0
|
||||||
|
|
||||||
@@ -48,6 +48,7 @@ require (
|
|||||||
go.opentelemetry.io/otel/sdk v1.9.0
|
go.opentelemetry.io/otel/sdk v1.9.0
|
||||||
go.opentelemetry.io/otel/trace v1.10.0
|
go.opentelemetry.io/otel/trace v1.10.0
|
||||||
golang.org/x/crypto v0.54.0
|
golang.org/x/crypto v0.54.0
|
||||||
|
golang.org/x/image v0.13.0
|
||||||
golang.org/x/net v0.56.0
|
golang.org/x/net v0.56.0
|
||||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||||
gopkg.in/ini.v1 v1.67.0
|
gopkg.in/ini.v1 v1.67.0
|
||||||
@@ -124,7 +125,7 @@ require (
|
|||||||
github.com/projectdiscovery/blackrock v0.0.2 // indirect
|
github.com/projectdiscovery/blackrock v0.0.2 // indirect
|
||||||
github.com/projectdiscovery/retryabledns v1.0.115 // indirect
|
github.com/projectdiscovery/retryabledns v1.0.115 // indirect
|
||||||
github.com/projectdiscovery/utils v0.11.1 // 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/mscfb v1.0.4 // indirect
|
||||||
github.com/richardlehane/msoleps v1.0.1 // indirect
|
github.com/richardlehane/msoleps v1.0.1 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
@@ -151,7 +152,6 @@ require (
|
|||||||
go.opentelemetry.io/proto/otlp v0.19.0 // indirect
|
go.opentelemetry.io/proto/otlp v0.19.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // 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/mod v0.37.0 // indirect
|
||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
|
|||||||
Vendored
+33
@@ -58,6 +58,39 @@ func (c *TTLCache) Get(key string) (interface{}, bool) {
|
|||||||
return e.value, true
|
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 写入缓存并设定过期时间。
|
// Set 写入缓存并设定过期时间。
|
||||||
func (c *TTLCache) Set(key string, value interface{}, ttl time.Duration) {
|
func (c *TTLCache) Set(key string, value interface{}, ttl time.Duration) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
|
|||||||
Vendored
+66
@@ -59,3 +59,69 @@ func TestTTLCacheConcurrent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
wg.Wait()
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+214
-22
@@ -7,6 +7,7 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -15,6 +16,8 @@ import (
|
|||||||
|
|
||||||
"github.com/tamakyi/TamaBox/internal/conf"
|
"github.com/tamakyi/TamaBox/internal/conf"
|
||||||
"github.com/tamakyi/TamaBox/internal/db"
|
"github.com/tamakyi/TamaBox/internal/db"
|
||||||
|
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
||||||
|
"github.com/tamakyi/TamaBox/internal/shared"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Honeypot 提供蜜罐取证命令(只读 + 一条显式清理)。
|
// Honeypot 提供蜜罐取证命令(只读 + 一条显式清理)。
|
||||||
@@ -22,6 +25,7 @@ import (
|
|||||||
// TamaBox honeypot hits --since 24h 列出捕获到的破解凭据
|
// TamaBox honeypot hits --since 24h 列出捕获到的破解凭据
|
||||||
// TamaBox honeypot stats --days 7 统计来源/路径/凭据排行
|
// TamaBox honeypot stats --days 7 统计来源/路径/凭据排行
|
||||||
// TamaBox honeypot size 报告表行数与磁盘占用
|
// TamaBox honeypot size 报告表行数与磁盘占用
|
||||||
|
// TamaBox honeypot report --period week 生成安全汇总周报(含地图)并发邮件
|
||||||
// TamaBox honeypot prune --days 30 清理过期记录(保留期由配置决定)
|
// TamaBox honeypot prune --days 30 清理过期记录(保留期由配置决定)
|
||||||
//
|
//
|
||||||
// 密码默认脱敏:CLI 输出会被贴到工单、聊天窗口甚至截图里,
|
// 密码默认脱敏:CLI 输出会被贴到工单、聊天窗口甚至截图里,
|
||||||
@@ -53,7 +57,7 @@ var Honeypot = &cli.Command{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "prune",
|
Name: "prune",
|
||||||
Usage: "Delete honeypot records older than the retention period",
|
Usage: "Delete honeypot records (DB metadata + credential files) older than the retention period",
|
||||||
Flags: []cli.Flag{
|
Flags: []cli.Flag{
|
||||||
&cli.IntFlag{Name: "days", Value: 0, Usage: "Days to keep (0 = use honeypot_retain_days from config)"},
|
&cli.IntFlag{Name: "days", Value: 0, Usage: "Days to keep (0 = use honeypot_retain_days from config)"},
|
||||||
&cli.BoolFlag{Name: "yes", Usage: "Skip confirmation"},
|
&cli.BoolFlag{Name: "yes", Usage: "Skip confirmation"},
|
||||||
@@ -65,6 +69,21 @@ var Honeypot = &cli.Command{
|
|||||||
Usage: "Report honeypot table row count and disk usage",
|
Usage: "Report honeypot table row count and disk usage",
|
||||||
Action: runHoneypotSize,
|
Action: runHoneypotSize,
|
||||||
},
|
},
|
||||||
|
// 周期安全汇总:把命中按 IP 定位到城市,出地图报告并邮件送达。
|
||||||
|
reportCommand,
|
||||||
|
{
|
||||||
|
Name: "paths",
|
||||||
|
Usage: "List active decoy paths (built-in + honeypot_paths)",
|
||||||
|
Action: runHoneypotPaths,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "purge-creds",
|
||||||
|
Usage: "Drop legacy credential columns (username/password/fields/raw_body) from honeypot_hits — irreversible",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.BoolFlag{Name: "yes", Usage: "Skip confirmation"},
|
||||||
|
},
|
||||||
|
Action: runHoneypotPurgeCreds,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,28 +115,40 @@ func parseSince(s string) (time.Duration, error) {
|
|||||||
return time.Duration(n) * mult, nil
|
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 {
|
func runHoneypotHits(ctx *cli.Context) error {
|
||||||
dur, err := parseSince(ctx.String("since"))
|
dur, err := parseSince(ctx.String("since"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
database, err := openDatabase()
|
// 凭据只在取证文件里,本命令不再连数据库 —— DB 挂了也能查。
|
||||||
if err != nil {
|
// (元数据在库里不含凭据内容,见 db.HoneypotHit.HadFields 的注释。)
|
||||||
return err
|
if err := conf.Init(); err != nil {
|
||||||
|
return errors.Wrap(err, "load configuration")
|
||||||
|
}
|
||||||
|
store, err := credStoreFromConfig()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "open honeypot credential store")
|
||||||
}
|
}
|
||||||
store := db.NewHoneypotStore(database)
|
|
||||||
since := time.Now().Add(-dur)
|
since := time.Now().Add(-dur)
|
||||||
filterIP := strings.TrimSpace(ctx.String("ip"))
|
filterIP := strings.TrimSpace(ctx.String("ip"))
|
||||||
showPwd := ctx.Bool("show-password")
|
showPwd := ctx.Bool("show-password")
|
||||||
|
|
||||||
// 按 IP 过滤在 SQL 层做:取回最近 N 条再在内存里筛,会导致「指定 IP 的
|
// IP 过滤在读取阶段做(列表本来就是流式读,先取最近 N 条再筛会让
|
||||||
// 历史命中基本查不出来」(它的记录被其他 IP 的更近记录挤出了窗口)。
|
// 指定 IP 的历史记录被其他 IP 挤出去)。
|
||||||
rows, err := store.ListSinceByIP(context.Background(), since, filterIP, ctx.Int("limit"))
|
rows, err := store.ListSince(since, filterIP, ctx.Int("limit"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "query honeypot hits")
|
return errors.Wrap(err, "read honeypot credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("蜜罐命中记录(近 %s,共 %d 条)\n", dur, len(rows))
|
fmt.Printf("蜜罐凭据记录(近 %s,共 %d 条,来源 %s)\n", dur, len(rows), store.Dir())
|
||||||
fmt.Println(strings.Repeat("-", 72))
|
fmt.Println(strings.Repeat("-", 72))
|
||||||
if len(rows) == 0 {
|
if len(rows) == 0 {
|
||||||
fmt.Println(" 无")
|
fmt.Println(" 无")
|
||||||
@@ -134,11 +165,8 @@ func runHoneypotHits(ctx *cli.Context) error {
|
|||||||
if user == "" {
|
if user == "" {
|
||||||
user = "(空)"
|
user = "(空)"
|
||||||
}
|
}
|
||||||
fmt.Printf("%s %-8s %-6s %s\n", r.CreatedAt.Format("2006-01-02 15:04:05"), r.IP, r.Method, truncateForCLI(r.Path, 48))
|
fmt.Printf("%s %-8s %-6s %s\n", r.Time.Format("2006-01-02 15:04:05"), r.IP, r.Method, truncateForCLI(r.Path, 48))
|
||||||
fmt.Printf(" 用户名:%s 密码:%s\n", truncateForCLI(user, 64), truncateForCLI(pwd, 64))
|
fmt.Printf(" 用户名:%s 密码:%s\n", truncateForCLI(user, 64), truncateForCLI(pwd, 64))
|
||||||
if r.Provider != "" {
|
|
||||||
fmt.Printf(" 归属:%s%s\n", r.Provider, proxiedSuffix(r.Proxied))
|
|
||||||
}
|
|
||||||
if r.UserAgent != "" {
|
if r.UserAgent != "" {
|
||||||
fmt.Printf(" UA:%s\n", truncateForCLI(r.UserAgent, 96))
|
fmt.Printf(" UA:%s\n", truncateForCLI(r.UserAgent, 96))
|
||||||
}
|
}
|
||||||
@@ -170,15 +198,27 @@ func runHoneypotStats(ctx *cli.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "count honeypot hits")
|
return errors.Wrap(err, "count honeypot hits")
|
||||||
}
|
}
|
||||||
sources, err := store.TopSources(cctx, since, ctx.Int("limit"))
|
// 元数据表存的是精确地址(取证需要),但 IPv6 攻击者常在一个 /64 内轮换地址:
|
||||||
|
// 直接按精确 IP 排行会碎成几十条各 1 次的记录,看不出「其实是同一个人」。
|
||||||
|
// 多取一些再按封禁同口径(/64)合并,见 mergeSourcesByKey。
|
||||||
|
limit := ctx.Int("limit")
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
sources, err := store.TopSources(cctx, since, limit*4)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "query top sources")
|
return errors.Wrap(err, "query top sources")
|
||||||
}
|
}
|
||||||
|
sources = mergeSourcesByKey(sources, limit)
|
||||||
paths, err := store.TopPaths(cctx, since, ctx.Int("limit"))
|
paths, err := store.TopPaths(cctx, since, ctx.Int("limit"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "query top paths")
|
return errors.Wrap(err, "query top paths")
|
||||||
}
|
}
|
||||||
creds, err := store.TopCredentials(cctx, since, ctx.Int("limit"))
|
credStore, err := credStoreFromConfig()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "open honeypot credential store")
|
||||||
|
}
|
||||||
|
creds, err := credStore.TopCredentials(since, ctx.Int("limit"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "query top credentials")
|
return errors.Wrap(err, "query top credentials")
|
||||||
}
|
}
|
||||||
@@ -186,7 +226,7 @@ func runHoneypotStats(ctx *cli.Context) error {
|
|||||||
fmt.Printf("蜜罐统计:近 %d 天,共 %d 次命中\n", days, total)
|
fmt.Printf("蜜罐统计:近 %d 天,共 %d 次命中\n", days, total)
|
||||||
fmt.Println(strings.Repeat("-", 72))
|
fmt.Println(strings.Repeat("-", 72))
|
||||||
|
|
||||||
fmt.Println("来源 IP 排行:")
|
fmt.Println("来源排行(IPv6 按 /64 聚合,与封禁口径一致):")
|
||||||
if len(sources) == 0 {
|
if len(sources) == 0 {
|
||||||
fmt.Println(" 无")
|
fmt.Println(" 无")
|
||||||
}
|
}
|
||||||
@@ -204,7 +244,7 @@ func runHoneypotStats(ctx *cli.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println("最常被尝试的凭据组合:")
|
fmt.Println("最常被尝试的凭据组合(来自取证文件,非数据库):")
|
||||||
if len(creds) == 0 {
|
if len(creds) == 0 {
|
||||||
fmt.Println(" 无")
|
fmt.Println(" 无")
|
||||||
}
|
}
|
||||||
@@ -264,6 +304,16 @@ func runHoneypotPrune(ctx *cli.Context) error {
|
|||||||
fmt.Printf("提示:空间回收失败(%v),数据本身已正确删除;PostgreSQL 下可手动执行 VACUUM honeypot_hits。\n", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,6 +364,111 @@ func runHoneypotSize(ctx *cli.Context) error {
|
|||||||
} else {
|
} else {
|
||||||
fmt.Printf("保留期:未设置 —— 记录只增不减,需要手动执行 TamaBox honeypot prune\n")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,9 +523,46 @@ func maskSecret(s string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func proxiedSuffix(proxied bool) string {
|
// mergeSourcesByKey 按「封禁 / 限流」的同一口径合并来源排行。
|
||||||
if proxied {
|
//
|
||||||
return "(CDN/代理出口)"
|
// 元数据表存的是精确地址(取证需要,见 db.HoneypotHit.IP),但 IPv6 攻击者
|
||||||
|
// 通常在一个 /64 内轮换地址:直接按精确 IP 排行会碎成几十条各 1 次的记录,
|
||||||
|
// 看不出「其实是同一台机器」。这里用 shared.LimitKey 折成 /64 ——
|
||||||
|
// 与 autoban 判定、限流的键完全一致,看到的排行就是「谁在被封」。
|
||||||
|
//
|
||||||
|
// 代价:SQL 只取了前 limit*4 条精确地址,极端长尾可能漏算,排行本身是近似展示。
|
||||||
|
func mergeSourcesByKey(rows []*db.HoneypotSourceStat, limit int) []*db.HoneypotSourceStat {
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return rows
|
||||||
}
|
}
|
||||||
return ""
|
type agg struct {
|
||||||
|
count int64
|
||||||
|
provider string
|
||||||
|
}
|
||||||
|
merged := make(map[string]*agg, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
key := shared.LimitKey(r.IP)
|
||||||
|
if a, ok := merged[key]; ok {
|
||||||
|
a.count += r.Count
|
||||||
|
if a.provider == "" {
|
||||||
|
a.provider = r.Provider
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
merged[key] = &agg{count: r.Count, provider: r.Provider}
|
||||||
|
}
|
||||||
|
out := make([]*db.HoneypotSourceStat, 0, len(merged))
|
||||||
|
for key, a := range merged {
|
||||||
|
out = append(out, &db.HoneypotSourceStat{IP: key, Count: a.count, Provider: a.provider})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Count != out[j].Count {
|
||||||
|
return out[i].Count > out[j].Count
|
||||||
|
}
|
||||||
|
return out[i].IP < out[j].IP // 同次数时稳定排序,避免每次输出顺序抖动
|
||||||
|
})
|
||||||
|
if limit > 0 && len(out) > limit {
|
||||||
|
out = out[:limit]
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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), "窗口未结束时不能发不完整的报告")
|
||||||
|
}
|
||||||
+142
-6
@@ -5,8 +5,13 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
@@ -17,9 +22,13 @@ import (
|
|||||||
|
|
||||||
"github.com/tamakyi/TamaBox/internal/conf"
|
"github.com/tamakyi/TamaBox/internal/conf"
|
||||||
"github.com/tamakyi/TamaBox/internal/db"
|
"github.com/tamakyi/TamaBox/internal/db"
|
||||||
|
"github.com/tamakyi/TamaBox/internal/insights"
|
||||||
"github.com/tamakyi/TamaBox/internal/route"
|
"github.com/tamakyi/TamaBox/internal/route"
|
||||||
|
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
||||||
"github.com/tamakyi/TamaBox/internal/shared"
|
"github.com/tamakyi/TamaBox/internal/shared"
|
||||||
|
"github.com/tamakyi/TamaBox/internal/stats"
|
||||||
"github.com/tamakyi/TamaBox/internal/tracing"
|
"github.com/tamakyi/TamaBox/internal/tracing"
|
||||||
|
"github.com/tamakyi/TamaBox/route/question"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Web = &cli.Command{
|
var Web = &cli.Command{
|
||||||
@@ -67,34 +76,118 @@ func runWeb(ctx *cli.Context) error {
|
|||||||
}
|
}
|
||||||
conf.Database.DSN = dsn
|
conf.Database.DSN = dsn
|
||||||
|
|
||||||
_, err := db.Init(dbType, dsn)
|
gdb, err := db.Init(dbType, dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "connect to database")
|
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_hits 是只追加的审计表,写入完全由
|
||||||
// 外部扫描流量驱动(不可预期、不可节流)。没有自动清理的话,表体积只增不减。
|
// 外部扫描流量驱动(不可预期、不可节流)。没有自动清理的话,表体积只增不减。
|
||||||
// 保留期复用 honeypot_retain_days(与 CLI prune 同一个口径),开关为 honeypot_auto_clean。
|
// 保留期复用 honeypot_retain_days(与 CLI prune 同一个口径),开关为 honeypot_auto_clean。
|
||||||
|
//
|
||||||
|
// 凭据文件(明文账号密码)也按同一个保留期清理 —— 它们不该比元数据活得更久。
|
||||||
|
// 这里独立打开一次目录即可:PurgeBefore 只删「整天都早于保留期」的文件,
|
||||||
|
// 与 route 层那个 Append 用的句柄不会争抢同一天的文件。
|
||||||
|
// 两个后台任务(保留期清理 / 周期报告)共用一个停止信号,
|
||||||
|
// defer 必须放在 if 外面 —— 否则「自动清理关掉、报告开着」时这个
|
||||||
|
// channel 永远不会被关闭,那个 goroutine 就一直挂着。
|
||||||
stopRetention := make(chan struct{})
|
stopRetention := make(chan struct{})
|
||||||
if conf.Security.HoneypotAutoClean {
|
|
||||||
db.NewHoneypotRetention(db.Honeypots, conf.Security.HoneypotRetainDays, conf.Security.HoneypotMaxRows, 0).Start(stopRetention)
|
|
||||||
defer close(stopRetention)
|
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")
|
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)
|
// 站点默认语言初始化(default_lang 配置,未配置时回退 zh-CN)
|
||||||
shared.App.SetLang(conf.App.DefaultLang)
|
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 := route.New()
|
||||||
r.Use(tracing.Middleware("TamaBox"))
|
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 上配置超时:
|
// 最外层挂载 gzip 压缩,并在 http.Server 上配置超时:
|
||||||
// - ReadHeaderTimeout 防止慢速客户端占用连接
|
// - ReadHeaderTimeout 防止慢速客户端占用连接(slowloris 的防线在这里)
|
||||||
// - ReadTimeout / WriteTimeout 防止读写挂死
|
// - ReadTimeout / WriteTimeout 防止读写挂死
|
||||||
// - IdleTimeout 复用 keep-alive 连接
|
// - IdleTimeout 复用 keep-alive 连接
|
||||||
// listen 留空时回退到 ":port"(监听所有网卡);Cloudflare Tunnel 部署建议设为
|
// listen 留空时回退到 ":port"(监听所有网卡);Cloudflare Tunnel 部署建议设为
|
||||||
// "127.0.0.1:<port>",使源站端口仅对本地 cloudflared 可见,即使防火墙误配也无法被外部直连。
|
// "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
|
addr := conf.Server.Listen
|
||||||
if addr == "" {
|
if addr == "" {
|
||||||
addr = fmt.Sprintf(":%d", conf.Server.Port)
|
addr = fmt.Sprintf(":%d", conf.Server.Port)
|
||||||
@@ -103,14 +196,57 @@ func runWeb(ctx *cli.Context) error {
|
|||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: route.GzipHandler(r),
|
Handler: route.GzipHandler(r),
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
ReadTimeout: 30 * time.Second,
|
ReadTimeout: 120 * time.Second,
|
||||||
WriteTimeout: 60 * time.Second,
|
WriteTimeout: 60 * time.Second,
|
||||||
IdleTimeout: 120 * time.Second,
|
IdleTimeout: 120 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
logrus.WithContext(ctx.Context).WithField("port", conf.Server.Port).Info("Listening on")
|
logrus.WithContext(ctx.Context).WithField("port", conf.Server.Port).Info("Listening on")
|
||||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
||||||
|
// 优雅关闭:收到 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 errors.Wrap(err, "start http server")
|
||||||
}
|
}
|
||||||
return nil
|
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("配置无效时应清空已解析的时间点")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,10 +102,19 @@ func Init() error {
|
|||||||
return errors.Wrap(err, "map 'theme'")
|
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 {
|
if err := File.Section("mail").MapTo(&Mail); err != nil {
|
||||||
return errors.Wrap(err, "map 'mail'")
|
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()
|
serviceSections := File.Section("service").ChildSections()
|
||||||
for _, serviceSection := range serviceSections {
|
for _, serviceSection := range serviceSections {
|
||||||
serviceSection := serviceSection
|
serviceSection := serviceSection
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
package conf
|
package conf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,9 +28,28 @@ const (
|
|||||||
// DefaultHoneypotMaxRows 蜜罐记录默认行数上限。
|
// DefaultHoneypotMaxRows 蜜罐记录默认行数上限。
|
||||||
// 实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
|
// 实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
|
||||||
DefaultHoneypotMaxRows = 200000
|
DefaultHoneypotMaxRows = 200000
|
||||||
// DefaultHoneypotThreshold 蜜罐命中多少次即封禁。默认 1(一次即封):
|
// DefaultHoneypotThreshold 「提交凭据」多少次即封禁。默认 1:
|
||||||
// 蜜罐路径与真实业务路由零交集,正常访客不可能命中。
|
// 往假后台里填账号密码是证据最强的行为,抓到一次就足够。
|
||||||
|
// 纯路径探测不走这个阈值(见 route.honeypotHandler.Serve)。
|
||||||
DefaultHoneypotThreshold = 1
|
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 归一化蜜罐相关配置,修正「未配置」与「显式关闭」的歧义。
|
// sanitizeHoneypot 归一化蜜罐相关配置,修正「未配置」与「显式关闭」的歧义。
|
||||||
@@ -78,4 +99,42 @@ func sanitizeHoneypot() {
|
|||||||
if !sec.HasKey("honeypot_threshold") {
|
if !sec.HasKey("honeypot_threshold") {
|
||||||
Security.HoneypotThreshold = DefaultHoneypotThreshold
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,18 @@ var keyAliases = map[string]string{
|
|||||||
"honeypot_retentionday": "honeypot_retain_days",
|
"honeypot_retentionday": "honeypot_retain_days",
|
||||||
"honeypot_keep_days": "honeypot_retain_days",
|
"honeypot_keep_days": "honeypot_retain_days",
|
||||||
"honeypot_retain_max_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 归一化配置键名。
|
// normalizeKeyName 归一化配置键名。
|
||||||
@@ -145,6 +157,7 @@ func sectionKeySets() map[string]map[string]bool {
|
|||||||
return map[string]map[string]bool{
|
return map[string]map[string]bool{
|
||||||
"app": legalKeysOf(&App),
|
"app": legalKeysOf(&App),
|
||||||
"theme": legalKeysOf(&Theme),
|
"theme": legalKeysOf(&Theme),
|
||||||
|
"announcement": legalKeysOf(&Announcement),
|
||||||
"security": legalKeysOf(&Security),
|
"security": legalKeysOf(&Security),
|
||||||
"server": legalKeysOf(&Server),
|
"server": legalKeysOf(&Server),
|
||||||
"database": legalKeysOf(&Database),
|
"database": legalKeysOf(&Database),
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
|
|||||||
wantMaxRows int
|
wantMaxRows int
|
||||||
wantAutoClean bool
|
wantAutoClean bool
|
||||||
wantThreshold int
|
wantThreshold int
|
||||||
|
wantBan int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "全未配置时回退安全默认值",
|
name: "全未配置时回退安全默认值",
|
||||||
@@ -244,6 +245,7 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
|
|||||||
wantMaxRows: DefaultHoneypotMaxRows,
|
wantMaxRows: DefaultHoneypotMaxRows,
|
||||||
wantAutoClean: true,
|
wantAutoClean: true,
|
||||||
wantThreshold: DefaultHoneypotThreshold,
|
wantThreshold: DefaultHoneypotThreshold,
|
||||||
|
wantBan: DefaultHoneypotBanMinutes,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "显式关闭自动清理被尊重",
|
name: "显式关闭自动清理被尊重",
|
||||||
@@ -252,6 +254,7 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
|
|||||||
wantMaxRows: DefaultHoneypotMaxRows,
|
wantMaxRows: DefaultHoneypotMaxRows,
|
||||||
wantAutoClean: false,
|
wantAutoClean: false,
|
||||||
wantThreshold: DefaultHoneypotThreshold,
|
wantThreshold: DefaultHoneypotThreshold,
|
||||||
|
wantBan: DefaultHoneypotBanMinutes,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "显式保留期被采用",
|
name: "显式保留期被采用",
|
||||||
@@ -260,6 +263,7 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
|
|||||||
wantMaxRows: DefaultHoneypotMaxRows,
|
wantMaxRows: DefaultHoneypotMaxRows,
|
||||||
wantAutoClean: true,
|
wantAutoClean: true,
|
||||||
wantThreshold: DefaultHoneypotThreshold,
|
wantThreshold: DefaultHoneypotThreshold,
|
||||||
|
wantBan: DefaultHoneypotBanMinutes,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "保留期写成 0 回退默认而非永不清理",
|
name: "保留期写成 0 回退默认而非永不清理",
|
||||||
@@ -268,6 +272,7 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
|
|||||||
wantMaxRows: DefaultHoneypotMaxRows,
|
wantMaxRows: DefaultHoneypotMaxRows,
|
||||||
wantAutoClean: true,
|
wantAutoClean: true,
|
||||||
wantThreshold: DefaultHoneypotThreshold,
|
wantThreshold: DefaultHoneypotThreshold,
|
||||||
|
wantBan: DefaultHoneypotBanMinutes,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "行数上限显式 0 表示不限",
|
name: "行数上限显式 0 表示不限",
|
||||||
@@ -276,6 +281,27 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
|
|||||||
wantMaxRows: 0,
|
wantMaxRows: 0,
|
||||||
wantAutoClean: true,
|
wantAutoClean: true,
|
||||||
wantThreshold: DefaultHoneypotThreshold,
|
wantThreshold: DefaultHoneypotThreshold,
|
||||||
|
wantBan: DefaultHoneypotBanMinutes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "显式封禁时长被采用",
|
||||||
|
src: "[security]\nhoneypot_ban_minutes = 90\n",
|
||||||
|
wantRetain: DefaultHoneypotRetainDays,
|
||||||
|
wantMaxRows: DefaultHoneypotMaxRows,
|
||||||
|
wantAutoClean: true,
|
||||||
|
wantThreshold: DefaultHoneypotThreshold,
|
||||||
|
wantBan: 90,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 0 是逃生口:留着让 autoban 回退 ban_duration_minutes,
|
||||||
|
// sanitize 不做覆盖(只告警),故期望值仍是 0。
|
||||||
|
name: "封禁时长写 0 交给 autoban 回退 ban_duration_minutes",
|
||||||
|
src: "[security]\nhoneypot_ban_minutes = 0\n",
|
||||||
|
wantRetain: DefaultHoneypotRetainDays,
|
||||||
|
wantMaxRows: DefaultHoneypotMaxRows,
|
||||||
|
wantAutoClean: true,
|
||||||
|
wantThreshold: DefaultHoneypotThreshold,
|
||||||
|
wantBan: 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +316,7 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
|
|||||||
Security.HoneypotMaxRows = 0
|
Security.HoneypotMaxRows = 0
|
||||||
Security.HoneypotAutoClean = false
|
Security.HoneypotAutoClean = false
|
||||||
Security.HoneypotThreshold = 0
|
Security.HoneypotThreshold = 0
|
||||||
|
Security.HoneypotBanMinutes = 0
|
||||||
File = f
|
File = f
|
||||||
if err := f.Section("security").MapTo(&Security); err != nil {
|
if err := f.Section("security").MapTo(&Security); err != nil {
|
||||||
t.Fatalf("map security: %v", err)
|
t.Fatalf("map security: %v", err)
|
||||||
@@ -308,6 +335,9 @@ func TestSanitizeHoneypotDefaults(t *testing.T) {
|
|||||||
if Security.HoneypotThreshold != c.wantThreshold {
|
if Security.HoneypotThreshold != c.wantThreshold {
|
||||||
t.Errorf("HoneypotThreshold = %d, want %d", Security.HoneypotThreshold, c.wantThreshold)
|
t.Errorf("HoneypotThreshold = %d, want %d", Security.HoneypotThreshold, c.wantThreshold)
|
||||||
}
|
}
|
||||||
|
if Security.HoneypotBanMinutes != c.wantBan {
|
||||||
|
t.Errorf("HoneypotBanMinutes = %d, want %d", Security.HoneypotBanMinutes, c.wantBan)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -358,6 +388,34 @@ func TestSampleIniHasNoUnknownKeys(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSampleIniValuesHaveNoInlineComment 守住「行尾注释会被并入值」的陷阱。
|
||||||
|
//
|
||||||
|
// conf.Init 用 ini.LoadOptions{IgnoreInlineComment: true},该选项的语义是
|
||||||
|
// **不剥离**行尾注释(实测 `port = 1440 ; 注释` → 值 "1440 ; 注释",
|
||||||
|
// int 解析失败,而 MapTo 仍然返回 nil error)。后果是该配置项静默回退零值,
|
||||||
|
// 日志里只会说「配置值不对」,根本联想不到是注释造成的。
|
||||||
|
//
|
||||||
|
// 样例配置曾经全线是 `key = value ; 注释` 的写法:text_censor_source 因此
|
||||||
|
// 让 conf.Init 直接拒绝启动,ip_blacklist / security_alert_email 变成带注释的
|
||||||
|
// 垃圾字符串,honeypot_max_rows / honeypot_auto_clean 静默失效。
|
||||||
|
//
|
||||||
|
// 直接检查每个键的值里没有 ';',比逐个字段断言更彻底,也不会漏掉新加的配置项。
|
||||||
|
func TestSampleIniValuesHaveNoInlineComment(t *testing.T) {
|
||||||
|
path := filepath.Join("..", "..", "conf", "app.sample.ini")
|
||||||
|
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("跳过:无法读取样例配置 %s(%v)", path, err)
|
||||||
|
}
|
||||||
|
for _, sec := range f.Sections() {
|
||||||
|
for _, k := range sec.Keys() {
|
||||||
|
if strings.Contains(k.Value(), ";") {
|
||||||
|
t.Errorf("样例配置 [%s] %s 的值含 ';':%q —— 行尾注释会被并入值,该项将静默失效",
|
||||||
|
sec.Name(), k.Name(), k.Value())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestNormalizeAllSectionKeysEndToEnd 走完整链路:
|
// TestNormalizeAllSectionKeysEndToEnd 走完整链路:
|
||||||
// 加载带错拼键的配置 → 归一化 → MapTo → 拿到正确的保留期。
|
// 加载带错拼键的配置 → 归一化 → MapTo → 拿到正确的保留期。
|
||||||
//
|
//
|
||||||
|
|||||||
+103
-7
@@ -10,7 +10,7 @@ var (
|
|||||||
BuildCommit = "dev"
|
BuildCommit = "dev"
|
||||||
|
|
||||||
// StaticVersion 静态资源缓存版本号:base.css 与主题 css 共用,改样式只改这一处。
|
// StaticVersion 静态资源缓存版本号:base.css 与主题 css 共用,改样式只改这一处。
|
||||||
StaticVersion = "20261020g"
|
StaticVersion = "20261022c"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 文本审核通道取值([security] text_censor_source)。定义在 conf 侧,
|
// 文本审核通道取值([security] text_censor_source)。定义在 conf 侧,
|
||||||
@@ -47,6 +47,9 @@ var (
|
|||||||
Available string `ini:"available"`
|
Available string `ini:"available"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Announcement 站内公告([announcement] 段)。解析与展示判定见 announcement.go。
|
||||||
|
Announcement announcementConfig
|
||||||
|
|
||||||
Security struct {
|
Security struct {
|
||||||
EnableTextCensor bool `ini:"enable_text_censor"`
|
EnableTextCensor bool `ini:"enable_text_censor"`
|
||||||
IPBlacklist string `ini:"ip_blacklist"`
|
IPBlacklist string `ini:"ip_blacklist"`
|
||||||
@@ -54,11 +57,15 @@ var (
|
|||||||
// TrustProxy 为 true 时信任 CDN 权威客户端头 CF-Connecting-IP / EO-Client-IP
|
// TrustProxy 为 true 时信任 CDN 权威客户端头 CF-Connecting-IP / EO-Client-IP
|
||||||
// (以及受信代理设置的 X-Real-IP / X-Forwarded-For);否则一律用 RemoteAddr,
|
// (以及受信代理设置的 X-Real-IP / X-Forwarded-For);否则一律用 RemoteAddr,
|
||||||
// 防止伪造 header 绕过速率限制与 IP 黑名单。
|
// 防止伪造 header 绕过速率限制与 IP 黑名单。
|
||||||
|
// ⚠️ 权威头只在 TCP 对端可信(回环,或命中 TrustedProxies)时才会被采信 ——
|
||||||
|
// 见 shared.ResolveClientIP:公网对端带来的权威头一律忽略(P1-3 修复)。
|
||||||
TrustProxy bool `ini:"trust_proxy"`
|
TrustProxy bool `ini:"trust_proxy"`
|
||||||
// TrustedProxies 逗号分隔的 IP / CIDR,仅限自己的反向代理 / LB。仅当 TCP 对端
|
// TrustedProxies 逗号分隔的 IP / CIDR,仅限自己的反向代理 / LB。仅当 TCP 对端
|
||||||
// 落在该清单内才解析 X-Forwarded-For / X-Real-IP;留空则这两个头永不被信任
|
// 落在该清单内才解析 X-Forwarded-For / X-Real-IP;留空则这两个头永不被信任。
|
||||||
// (CDN 权威头仍会采用,前提是源站已由 Cloudflare Tunnel / 回源网段锁定,
|
// 回环地址(127.0.0.1 / ::1)无需登记即视为可信对端:cloudflared / 本机 nginx
|
||||||
// 从而免于维护上游 CDN 的庞大 CIDR 清单)。
|
// 回源的连接必然由本机进程发起,外部无法伪造。
|
||||||
|
// CDN 直连回源(非 Tunnel)的部署形态下,必须把该 CDN 的回源网段登记到这里,
|
||||||
|
// 否则它的权威头会被忽略、客户端 IP 退化为 CDN 边缘 IP。
|
||||||
TrustedProxies string `ini:"trusted_proxies"`
|
TrustedProxies string `ini:"trusted_proxies"`
|
||||||
EnableAutoBan bool `ini:"enable_auto_ban"` // fail2ban 风格:检测到扫描特征后临时封禁来源 IP
|
EnableAutoBan bool `ini:"enable_auto_ban"` // fail2ban 风格:检测到扫描特征后临时封禁来源 IP
|
||||||
// BanTrackNotFound 是否把未注册路径的 404 计入封禁统计。默认 false:仅命中后台
|
// BanTrackNotFound 是否把未注册路径的 404 计入封禁统计。默认 false:仅命中后台
|
||||||
@@ -103,13 +110,90 @@ var (
|
|||||||
// 保留期是时间维度的上界,但攻击者可以用海量源 IP 在保留期内把表撑爆,
|
// 保留期是时间维度的上界,但攻击者可以用海量源 IP 在保留期内把表撑爆,
|
||||||
// 这是第二道闸。实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
|
// 这是第二道闸。实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
|
||||||
HoneypotMaxRows int `ini:"honeypot_max_rows"`
|
HoneypotMaxRows int `ini:"honeypot_max_rows"`
|
||||||
// HoneypotThreshold 窗口内蜜罐命中多少次即封禁。默认 1(一次即封):
|
// HoneypotThreshold 蜜罐「提交凭据」多少次即封禁。默认 1(一次即封):
|
||||||
// 蜜罐路径与真实业务路由零交集,正常访客不可能命中,一次即足以判定扫描器。
|
// 往假后台里填账号密码是证据最强的行为,抓到一次就足够。
|
||||||
|
// ⚠️ 它不作用于纯路径探测(GET 看一眼假登录页)—— 那类请求走常规
|
||||||
|
// ban_threshold_sensitive,否则扫描器 GET 一次就被封,永远走不到
|
||||||
|
// POST 提交凭据那一步,蜜罐也就拿不到字典。
|
||||||
// 缺省回退 1(见 sanitizeHoneypot)。
|
// 缺省回退 1(见 sanitizeHoneypot)。
|
||||||
HoneypotThreshold int `ini:"honeypot_threshold"`
|
HoneypotThreshold int `ini:"honeypot_threshold"`
|
||||||
// HoneypotBanMinutes 蜜罐封禁时长(分钟)。留空则复用 ban_duration_minutes。
|
// HoneypotBanMinutes 蜜罐封禁时长(分钟)。缺省 1440(24 小时),
|
||||||
|
// 刻意远长于 ban_duration_minutes:命中蜜罐意味着对方已在提交账号密码,
|
||||||
|
// 证据强度高于单纯路径探测(见 DefaultHoneypotBanMinutes)。
|
||||||
|
// 显式 <= 0 表示回退 ban_duration_minutes。
|
||||||
HoneypotBanMinutes int `ini:"honeypot_ban_minutes"`
|
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"` // 触发封禁时告警收件人(留空不发送)
|
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 {
|
Server struct {
|
||||||
@@ -173,6 +257,18 @@ var (
|
|||||||
SkipTLSVerify bool `ini:"skip_tls_verify"` // 仅自签名 / 内网 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 {
|
Service struct {
|
||||||
Backends []struct {
|
Backends []struct {
|
||||||
Prefix string `ini:"prefix"`
|
Prefix string `ini:"prefix"`
|
||||||
|
|||||||
@@ -66,7 +66,10 @@ func (c *Context) HasError() bool {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return false
|
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{}) {
|
func (c *Context) SetError(err error, f ...interface{}) {
|
||||||
@@ -135,6 +138,11 @@ func (c *Context) Refresh() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Context) JSON(data interface{}) error {
|
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{}{
|
resp := map[string]interface{}{
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"data": data,
|
"data": data,
|
||||||
@@ -149,6 +157,9 @@ func (c *Context) ServerError() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Context) JSONError(errorCode int, message string) 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())
|
span := trace.SpanFromContext(c.Request().Context())
|
||||||
|
|
||||||
resp := map[string]interface{}{
|
resp := map[string]interface{}{
|
||||||
@@ -367,9 +378,10 @@ func pageScope(path string) string {
|
|||||||
path == "/register" || strings.HasPrefix(path, "/register/"):
|
path == "/register" || strings.HasPrefix(path, "/register/"):
|
||||||
return "auth"
|
return "auth"
|
||||||
case path == "/user" || strings.HasPrefix(path, "/user/"):
|
case path == "/user" || strings.HasPrefix(path, "/user/"):
|
||||||
// 题目/私聊管理列表是「翻找内容」的页面,和「设置表单」的信息密度差很多,
|
// 题目/私聊管理列表、数据面板都是「读数据」的页面,和「设置表单」的信息密度差很多,
|
||||||
// 装饰要分开配:admin 更该退到背景,profile 可以保留一点氛围。
|
// 装饰要分开配:admin 更该退到背景,profile 可以保留一点氛围。
|
||||||
if strings.HasPrefix(path, "/user/questions") || strings.HasPrefix(path, "/user/votes") {
|
if strings.HasPrefix(path, "/user/questions") || strings.HasPrefix(path, "/user/votes") ||
|
||||||
|
strings.HasPrefix(path, "/user/insights") {
|
||||||
return "admin"
|
return "admin"
|
||||||
}
|
}
|
||||||
return "profile"
|
return "profile"
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ func TestPageScope(t *testing.T) {
|
|||||||
"/user/profile": "profile",
|
"/user/profile": "profile",
|
||||||
"/user/questions": "admin", // 题目列表:信息密,装饰要退
|
"/user/questions": "admin", // 题目列表:信息密,装饰要退
|
||||||
"/user/votes/1/results": "admin",
|
"/user/votes/1/results": "admin",
|
||||||
|
"/user/insights": "admin", // 数据面板同属「读数据」
|
||||||
"/_/admin6": "box",
|
"/_/admin6": "box",
|
||||||
"/_/admin6/": "box",
|
"/_/admin6/": "box",
|
||||||
"/_/admin6/tag/杂谈": "box",
|
"/_/admin6/tag/杂谈": "box",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
+170
-2
@@ -6,19 +6,30 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/thanhpk/randstr"
|
"github.com/thanhpk/randstr"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
"github.com/tamakyi/TamaBox/internal/dbutil"
|
"github.com/tamakyi/TamaBox/internal/dbutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Conversations ConversationStore
|
var Conversations ConversationStore
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrDailyMessageLimit = errors.New("已达到每日私聊上限")
|
||||||
|
ErrConversationLimit = errors.New("会话已达到消息上限或已关闭")
|
||||||
|
)
|
||||||
|
|
||||||
type ConversationStore interface {
|
type ConversationStore interface {
|
||||||
Create(ctx context.Context, opts CreateConversationOptions) (*Conversation, error)
|
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)
|
GetByID(ctx context.Context, id uint) (*Conversation, error)
|
||||||
GetByToken(ctx context.Context, token string) (*Conversation, error)
|
GetByToken(ctx context.Context, token string) (*Conversation, error)
|
||||||
GetByIDAndOwner(ctx context.Context, id, ownerID uint) (*Conversation, error)
|
GetByIDAndOwner(ctx context.Context, id, ownerID uint) (*Conversation, error)
|
||||||
@@ -71,6 +82,39 @@ type CreateConversationOptions struct {
|
|||||||
MaxMessages int
|
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) {
|
func (db *conversations) Create(ctx context.Context, opts CreateConversationOptions) (*Conversation, error) {
|
||||||
conversation := &Conversation{
|
conversation := &Conversation{
|
||||||
OwnerID: opts.OwnerID,
|
OwnerID: opts.OwnerID,
|
||||||
@@ -83,6 +127,117 @@ func (db *conversations) Create(ctx context.Context, opts CreateConversationOpti
|
|||||||
return conversation, nil
|
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) {
|
func (db *conversations) GetByID(ctx context.Context, id uint) (*Conversation, error) {
|
||||||
var conv Conversation
|
var conv Conversation
|
||||||
if err := db.WithContext(ctx).First(&conv, id).Error; err != nil {
|
if err := db.WithContext(ctx).First(&conv, id).Error; err != nil {
|
||||||
@@ -216,8 +371,21 @@ func (db *conversations) MarkDeletedByOwner(ctx context.Context, id uint) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.WithContext(ctx).Model(&Conversation{}).Where("id = ?", id).Update("is_deleted_by_owner", true).Error; err != nil {
|
// 用「条件更新 + RowsAffected」代替纯 Update:
|
||||||
return errors.Wrap(err, "mark conversation deleted by owner")
|
// 原来的写法是 check-then-act,双击「删除会话」或前端重复提交时,两个请求
|
||||||
|
// 都能读到 is_deleted_by_owner=false,于是都执行 UPDATE、都执行一次
|
||||||
|
// IncrementQuota(-1)。IncrementQuota 有 quota_count>0 兜底不会变负,
|
||||||
|
// 但会白吃掉本用户其他会话的计数 —— 表现为定量收集提前关闭,
|
||||||
|
// 而箱主重置计数也校准不回来。
|
||||||
|
res := db.WithContext(ctx).Model(&Conversation{}).
|
||||||
|
Where("id = ? AND is_deleted_by_owner = ?", id, false).
|
||||||
|
Update("is_deleted_by_owner", true)
|
||||||
|
if res.Error != nil {
|
||||||
|
return errors.Wrap(res.Error, "mark conversation deleted by owner")
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
// 并发里被另一个请求抢先删掉了:它已经做过配额回退,这里不能再来一次。
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 回退定量收集计数:删除私聊会话等价于少一条已收集内容,
|
// 回退定量收集计数:删除私聊会话等价于少一条已收集内容,
|
||||||
|
|||||||
+58
-23
@@ -1,6 +1,7 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,9 +15,28 @@ import (
|
|||||||
"github.com/tamakyi/TamaBox/internal/conf"
|
"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{}{
|
var AllTables = []interface{}{
|
||||||
&User{}, &Question{}, &FollowUp{}, &CensorLog{}, &UploadImage{}, &UploadImageQuestion{},
|
&User{}, &Question{}, &FollowUp{}, &CensorLog{}, &UploadImage{}, &UploadImageQuestion{},
|
||||||
&Conversation{}, &Message{}, &MessageImage{}, &UserBlock{},
|
&Conversation{}, &Message{}, &MessageImage{}, &UserBlock{}, &R2DeleteTask{},
|
||||||
// 匿名投票
|
// 匿名投票
|
||||||
&Vote{}, &VoteOption{}, &VoteFormField{}, &VoteSubmission{},
|
&Vote{}, &VoteOption{}, &VoteFormField{}, &VoteSubmission{},
|
||||||
// 功能开关(提问 / 私聊 / 投票 的开关、定时、定量收集)
|
// 功能开关(提问 / 私聊 / 投票 的开关、定时、定量收集)
|
||||||
@@ -25,6 +45,14 @@ var AllTables = []interface{}{
|
|||||||
&UserRestriction{}, &ContentReport{},
|
&UserRestriction{}, &ContentReport{},
|
||||||
// 蜜罐命中记录(审计证据,只追加不软删)
|
// 蜜罐命中记录(审计证据,只追加不软删)
|
||||||
&HoneypotHit{},
|
&HoneypotHit{},
|
||||||
|
// 来源 IP 的地理定位缓存(安全汇总报告用,纯 KV 缓存)
|
||||||
|
&IPGeoRecord{},
|
||||||
|
// 周期安全汇总的报告台账(period_key 唯一,保证重启不会重复发信)
|
||||||
|
&SecurityReportLog{},
|
||||||
|
// 自动封禁记录(内存态封禁的落库账本,用于事后解释与取证)
|
||||||
|
&BanRecord{},
|
||||||
|
// 箱主数据面板的访问流水(每 访客×天×箱 一行,Unscoped 语义,见 BoxView 注释)
|
||||||
|
&BoxView{},
|
||||||
}
|
}
|
||||||
|
|
||||||
func Init(typ, dsn string) (*gorm.DB, error) {
|
func Init(typ, dsn string) (*gorm.DB, error) {
|
||||||
@@ -40,11 +68,13 @@ func Init(typ, dsn string) (*gorm.DB, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
db, err := gorm.Open(dialector, &gorm.Config{
|
db, err := gorm.Open(dialector, &gorm.Config{
|
||||||
|
Logger: newSafeLogger(),
|
||||||
SkipDefaultTransaction: true,
|
SkipDefaultTransaction: true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "connect to database")
|
return nil, errors.Wrap(err, "connect to database")
|
||||||
}
|
}
|
||||||
|
handle = db
|
||||||
|
|
||||||
sqlDB, err := db.DB()
|
sqlDB, err := db.DB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -65,32 +95,16 @@ func Init(typ, dsn string) (*gorm.DB, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AutoMigrate 只负责声明式的「加表 / 加列 / 加索引」——这些变更幂等、无数据丢失风险。
|
||||||
if err := db.AutoMigrate(AllTables...); err != nil {
|
if err := db.AutoMigrate(AllTables...); err != nil {
|
||||||
return nil, errors.Wrap(err, "auto migrate")
|
return nil, errors.Wrap(err, "auto migrate")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 功能开关初始化:为历史账号补齐 feature_controls 行(私聊沿用 users.allow_chat)。
|
// 版本化迁移:AutoMigrate 做不了的破坏性变更(改列名/删列/改约束)与一次性数据回填
|
||||||
// 幂等,已初始化的账号不会重复写入;失败不阻断启动,仅告警,避免依赖表抖动导致服务起不来。
|
// 都收编进这里,靠 schema_migrations 账本保证「只跑一次、顺序固定、可审计」。
|
||||||
if err := backfillFeatureControls(db); err != nil {
|
// 迁移失败会阻断启动 —— 半途而废的迁移比静默跳过更危险(见 runMigrations 注释)。
|
||||||
logrus.WithError(err).Warn("初始化功能开关配置时异常(可在功能管理页手动保存一次以生成配置)")
|
if err := runMigrations(db); err != nil {
|
||||||
}
|
return nil, errors.Wrap(err, "run versioned migrations")
|
||||||
|
|
||||||
// 私聊开关纠偏:旧版 backfill 曾把未开启私聊的账号写为开启(GORM 零值陷阱),
|
|
||||||
// 启动期幂等复位;同样不阻断启动,失败仅告警。
|
|
||||||
if err := reconcileLegacyChatEnabled(db); err != nil {
|
|
||||||
logrus.WithError(err).Warn("私聊开关历史数据纠偏时异常(可在功能管理页手动调整私聊开关)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 私聊收集上限下线:私聊不再支持收集上限(UI 已隐藏,保存时强制清零),
|
|
||||||
// 启动期把历史残留的上限/计数一并清零,避免私聊「莫名关闭」。幂等,失败仅告警。
|
|
||||||
if err := normalizeChatQuota(db); err != nil {
|
|
||||||
logrus.WithError(err).Warn("私聊收集上限历史数据清理时异常(可在功能管理页保存一次私聊设置)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 防骚扰设置归一:历史账号 harassment_setting 为空串(建表时无默认值),与 none 语义等价
|
|
||||||
// 但取值不同,会导致按 == none 判断时漏判。幂等,失败仅告警。
|
|
||||||
if err := normalizeHarassmentSetting(db); err != nil {
|
|
||||||
logrus.WithError(err).Warn("防骚扰设置历史数据归一时异常(可在防骚扰设置页保存一次)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动期补充高频查询复合索引(幂等,适合既有库)。
|
// 启动期补充高频查询复合索引(幂等,适合既有库)。
|
||||||
@@ -118,6 +132,7 @@ func Init(typ, dsn string) (*gorm.DB, error) {
|
|||||||
Conversations = NewConversationStore(db)
|
Conversations = NewConversationStore(db)
|
||||||
Messages = NewMessageStore(db)
|
Messages = NewMessageStore(db)
|
||||||
MessageImages = NewMessageImageStore(db)
|
MessageImages = NewMessageImageStore(db)
|
||||||
|
R2DeleteTasks = NewR2DeleteTaskStore(db)
|
||||||
Votes = NewVotesStore(db)
|
Votes = NewVotesStore(db)
|
||||||
VoteOptions = NewVoteOptionsStore(db)
|
VoteOptions = NewVoteOptionsStore(db)
|
||||||
VoteFormFields = NewVoteFormFieldsStore(db)
|
VoteFormFields = NewVoteFormFieldsStore(db)
|
||||||
@@ -126,9 +141,23 @@ func Init(typ, dsn string) (*gorm.DB, error) {
|
|||||||
Restrictions = NewRestrictionsStore(db)
|
Restrictions = NewRestrictionsStore(db)
|
||||||
Reports = NewReportsStore(db)
|
Reports = NewReportsStore(db)
|
||||||
Honeypots = NewHoneypotStore(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(
|
if err := db.Use(otelgorm.NewPlugin(
|
||||||
otelgorm.WithDBName(conf.Database.Name),
|
otelgorm.WithDBName(conf.Database.Name),
|
||||||
|
otelgorm.WithoutQueryVariables(),
|
||||||
)); err != nil {
|
)); err != nil {
|
||||||
return nil, errors.Wrap(err, "register otelgorm plugin")
|
return nil, errors.Wrap(err, "register otelgorm plugin")
|
||||||
}
|
}
|
||||||
@@ -207,7 +236,13 @@ func dedupeUniqueFields(db *gorm.DB) error {
|
|||||||
func ensureFrequentlyUsedIndexes(db *gorm.DB) error {
|
func ensureFrequentlyUsedIndexes(db *gorm.DB) error {
|
||||||
indexes := []string{
|
indexes := []string{
|
||||||
`CREATE INDEX IF NOT EXISTS idx_questions_asker_user_id ON questions(asker_user_id)`,
|
`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)`,
|
`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_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_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)`,
|
`CREATE INDEX IF NOT EXISTS idx_messages_sender_created ON messages(sender_id, created_at)`,
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import (
|
|||||||
|
|
||||||
var FeatureControls FeatureControlsStore
|
var FeatureControls FeatureControlsStore
|
||||||
|
|
||||||
|
var ErrFeatureQuotaReached = errors.New("功能收集配额已满")
|
||||||
|
|
||||||
var _ FeatureControlsStore = (*featureControls)(nil)
|
var _ FeatureControlsStore = (*featureControls)(nil)
|
||||||
|
|
||||||
// 受控功能标识(feature_controls.feature 取值)。
|
// 受控功能标识(feature_controls.feature 取值)。
|
||||||
@@ -360,6 +362,26 @@ func FeatureStateOf(ctx context.Context, userID uint, feature string) FeatureSta
|
|||||||
return fc.Evaluate(time.Now())
|
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 便捷入口:某用户某功能当前是否可用。
|
// FeatureOpen 便捷入口:某用户某功能当前是否可用。
|
||||||
func FeatureOpen(ctx context.Context, userID uint, feature string) bool {
|
func FeatureOpen(ctx context.Context, userID uint, feature string) bool {
|
||||||
return FeatureStateOf(ctx, userID, feature).Open
|
return FeatureStateOf(ctx, userID, feature).Open
|
||||||
@@ -487,11 +509,37 @@ func (db *featureControls) ResetQuota(ctx context.Context, userID uint, feature
|
|||||||
// IncrementQuota 累加收集计数(delta 可为负,用于删除内容时回退计数)。
|
// IncrementQuota 累加收集计数(delta 可为负,用于删除内容时回退计数)。
|
||||||
// 负 delta 不会把计数减到 0 以下(WHERE quota_count > 0),避免误删历史内容导致负计数。
|
// 负 delta 不会把计数减到 0 以下(WHERE quota_count > 0),避免误删历史内容导致负计数。
|
||||||
func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feature string, delta int64) error {
|
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 {
|
if delta == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
q := db.WithContext(ctx).Model(&FeatureControl{}).
|
q := tx.Model(&FeatureControl{}).
|
||||||
Where("user_id = ? AND feature = ?", userID, feature)
|
Where("user_id = ? AND feature = ?", userID, feature)
|
||||||
if delta < 0 {
|
if delta < 0 {
|
||||||
q = q.Where("quota_count > 0")
|
q = q.Where("quota_count > 0")
|
||||||
@@ -513,7 +561,7 @@ func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feat
|
|||||||
fc.QuotaCount = delta
|
fc.QuotaCount = delta
|
||||||
}
|
}
|
||||||
fc.QuotaResetAt = &now
|
fc.QuotaResetAt = &now
|
||||||
created := db.WithContext(ctx).
|
created := tx.
|
||||||
Clauses(clause.OnConflict{DoNothing: true}).
|
Clauses(clause.OnConflict{DoNothing: true}).
|
||||||
Create(fc)
|
Create(fc)
|
||||||
if created.Error != nil {
|
if created.Error != nil {
|
||||||
@@ -523,7 +571,7 @@ func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feat
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// 并发下配置行可能刚被其它请求创建:补一次累加,保证计数不丢。
|
// 并发下配置行可能刚被其它请求创建:补一次累加,保证计数不丢。
|
||||||
retry := db.WithContext(ctx).Model(&FeatureControl{}).
|
retry := tx.Model(&FeatureControl{}).
|
||||||
Where("user_id = ? AND feature = ?", userID, feature).
|
Where("user_id = ? AND feature = ?", userID, feature).
|
||||||
Updates(map[string]interface{}{
|
Updates(map[string]interface{}{
|
||||||
"quota_count": gorm.Expr("quota_count + ?", delta),
|
"quota_count": gorm.Expr("quota_count + ?", delta),
|
||||||
|
|||||||
+52
-14
@@ -18,6 +18,9 @@ var (
|
|||||||
ErrFollowUpLimitReached = errors.New("follow up limit reached")
|
ErrFollowUpLimitReached = errors.New("follow up limit reached")
|
||||||
ErrFollowUpNotFound = errors.New("follow up not found")
|
ErrFollowUpNotFound = errors.New("follow up not found")
|
||||||
ErrFollowUpAlreadyAnswered = errors.New("follow up already answered")
|
ErrFollowUpAlreadyAnswered = errors.New("follow up already answered")
|
||||||
|
// ErrFollowUpAwaitingReply 上一条追问尚未被箱主回复,不允许继续追问。
|
||||||
|
// 由 CreateIfBelowLimit 在事务内判定(见该方法的注释)。
|
||||||
|
ErrFollowUpAwaitingReply = errors.New("previous follow up awaiting reply")
|
||||||
)
|
)
|
||||||
|
|
||||||
var FollowUps FollowUpsStore
|
var FollowUps FollowUpsStore
|
||||||
@@ -35,10 +38,15 @@ type FollowUpsStore interface {
|
|||||||
// AnswerByID 回答某条追问;questionID 防越权(必须属于该问题),仅允许回答尚未回答的(answer 为空)。
|
// AnswerByID 回答某条追问;questionID 防越权(必须属于该问题),仅允许回答尚未回答的(answer 为空)。
|
||||||
// 已回答返回 ErrFollowUpAlreadyAnswered,不存在/不属于该问题返回 ErrFollowUpNotFound。
|
// 已回答返回 ErrFollowUpAlreadyAnswered,不存在/不属于该问题返回 ErrFollowUpNotFound。
|
||||||
AnswerByID(ctx context.Context, questionID uint, id uint, answer string) error
|
AnswerByID(ctx context.Context, questionID uint, id uint, answer string) error
|
||||||
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防越权),允许覆盖已回答的(供主人修改回复)。
|
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防越权),
|
||||||
|
// 允许覆盖已回答的(供主人修改回复);返回 wasFirst 表示这是首次回答(此前为空)。
|
||||||
// 不存在/不属于该问题返回 ErrFollowUpNotFound。
|
// 不存在/不属于该问题返回 ErrFollowUpNotFound。
|
||||||
UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) error
|
UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) (wasFirst bool, err error)
|
||||||
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
|
// DeleteByQuestionID 删除某条提问时级联清理其全部追问。
|
||||||
|
//
|
||||||
|
// 用 Unscoped 真删:追问正文是提问者的个人内容,随提问一并真删(与 questions.DeleteByID
|
||||||
|
// 对主表的真删口径一致)。软删会留下带 deleted_at 的追问文本,且这些孤儿行的
|
||||||
|
// question_id 指向一个已不存在的提问 —— 与「删除即真删、个人数据不残留」的意图相悖。
|
||||||
DeleteByQuestionID(ctx context.Context, questionID uint) error
|
DeleteByQuestionID(ctx context.Context, questionID uint) error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +92,23 @@ func (db *followUps) CreateIfBelowLimit(ctx context.Context, opts CreateFollowUp
|
|||||||
return ErrFollowUpLimitReached
|
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{
|
f := &FollowUp{
|
||||||
QuestionID: opts.QuestionID,
|
QuestionID: opts.QuestionID,
|
||||||
Content: opts.Content,
|
Content: opts.Content,
|
||||||
@@ -188,23 +213,36 @@ func (db *followUps) AnswerByID(ctx context.Context, questionID uint, id uint, a
|
|||||||
|
|
||||||
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防止越权),
|
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防止越权),
|
||||||
// 允许覆盖已回答的追问(用于主人修改回复)。不存在/不属于该问题返回 ErrFollowUpNotFound。
|
// 允许覆盖已回答的追问(用于主人修改回复)。不存在/不属于该问题返回 ErrFollowUpNotFound。
|
||||||
func (db *followUps) UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) error {
|
func (db *followUps) UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) (bool, error) {
|
||||||
res := db.WithContext(ctx).
|
var wasFirst bool
|
||||||
Model(&FollowUp{}).
|
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
Where("id = ? AND question_id = ?", id, questionID).
|
// 先读旧值判断「是否首次回答」。handler 里拿不到可靠旧值:
|
||||||
Update("answer", answer)
|
// 它手上的 question 是请求开始时读的,并发下可能已经过期。
|
||||||
if res.Error != nil {
|
var fu FollowUp
|
||||||
return errors.Wrap(res.Error, "update follow up answer")
|
if err := tx.Where("id = ? AND question_id = ?", id, questionID).First(&fu).Error; err != nil {
|
||||||
}
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
if res.RowsAffected == 0 {
|
|
||||||
return ErrFollowUpNotFound
|
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
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return wasFirst, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
|
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(真删,与提问保持一致)。
|
||||||
func (db *followUps) DeleteByQuestionID(ctx context.Context, questionID uint) error {
|
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 errors.Wrap(err, "delete follow ups by question id")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+118
-44
@@ -20,7 +20,11 @@ type HoneypotHit struct {
|
|||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||||
|
|
||||||
// IP 为归一化后的客户端 IP(shared.LimitKey 之后,IPv6 折叠到 /64)。
|
// IP 是发起请求的**精确**客户端地址(IPv6 保留完整地址,最长 45 字符)。
|
||||||
|
//
|
||||||
|
// 刻意不存 shared.LimitKey 折叠后的 /64 聚合键:那张表是取证物,
|
||||||
|
// 「哪台主机」必须留下来才能查其他日志、做 abuse 投诉。聚合键只适合
|
||||||
|
// 当计数器的 key(封禁 / 限流),它进不来这里。
|
||||||
IP string `gorm:"size:64;index" json:"ip"`
|
IP string `gorm:"size:64;index" json:"ip"`
|
||||||
// Method 与 Path 记录原始请求方法与路径(未归一化的原始值)。
|
// Method 与 Path 记录原始请求方法与路径(未归一化的原始值)。
|
||||||
Method string `gorm:"size:16" json:"method"`
|
Method string `gorm:"size:16" json:"method"`
|
||||||
@@ -28,15 +32,14 @@ type HoneypotHit struct {
|
|||||||
// Query 保存原始查询串(截断)。
|
// Query 保存原始查询串(截断)。
|
||||||
Query string `gorm:"size:1024" json:"query"`
|
Query string `gorm:"size:1024" json:"query"`
|
||||||
|
|
||||||
// Username / Password 为提取出的破解凭据。Password 落库前做可逆加密还是脱敏,
|
|
||||||
// 由调用方决定:这里只存明文,CLI 展示时默认脱敏。
|
|
||||||
Username string `gorm:"size:256" json:"username"`
|
|
||||||
Password string `gorm:"size:256" json:"password"`
|
|
||||||
// Fields 保存其余表单/JSON 字段(JSON 字符串),便于还原破解工具特征。
|
|
||||||
Fields string `gorm:"type:text" json:"fields"`
|
|
||||||
// RawBody 保存截断后的原始 body。
|
|
||||||
RawBody string `gorm:"type:text" json:"raw_body"`
|
|
||||||
// HadFields 标记本次提交是否真的带了凭据字段(区分「探测 GET」与「尝试破解 POST」)。
|
// HadFields 标记本次提交是否真的带了凭据字段(区分「探测 GET」与「尝试破解 POST」)。
|
||||||
|
//
|
||||||
|
// ⚠️ 这里只存「有没有」这个事实,**不存凭据本身**。
|
||||||
|
// 用户名/密码/原始 body 全部落在 honeypot.CredStore(默认
|
||||||
|
// data/honeypot-creds/creds-YYYY-MM-DD.jsonl,权限 0600),原因见
|
||||||
|
// internal/security/honeypot/credstore.go 的 CredRecord 注释:
|
||||||
|
// 明文账号密码不能进数据库 —— 一旦入库就会跟着进备份、主从复制、
|
||||||
|
// 以及任何有只读权限的报表工具,事后无法彻底清除。
|
||||||
HadFields bool `json:"had_fields"`
|
HadFields bool `json:"had_fields"`
|
||||||
|
|
||||||
// UserAgent / Referer / ContentType 用于扫描器画像。
|
// UserAgent / Referer / ContentType 用于扫描器画像。
|
||||||
@@ -69,19 +72,12 @@ type HoneypotStore interface {
|
|||||||
Create(ctx context.Context, hit *HoneypotHit) error
|
Create(ctx context.Context, hit *HoneypotHit) error
|
||||||
// ListSince 返回 since 之后的命中记录,按时间倒序,最多 limit 条。
|
// ListSince 返回 since 之后的命中记录,按时间倒序,最多 limit 条。
|
||||||
ListSince(ctx context.Context, since time.Time, limit int) ([]*HoneypotHit, error)
|
ListSince(ctx context.Context, since time.Time, limit int) ([]*HoneypotHit, error)
|
||||||
// ListSinceByIP 返回 since 之后、来自指定 IP 的命中记录,按时间倒序。
|
|
||||||
//
|
|
||||||
// 刻意在 SQL 层过滤而不是取回后在 Go 侧过滤:后者只能看到「最近 limit 条」
|
|
||||||
// 里恰好落在窗口内的该 IP 记录,指定 IP 的历史命中基本查不出来。
|
|
||||||
ListSinceByIP(ctx context.Context, since time.Time, ip string, limit int) ([]*HoneypotHit, error)
|
|
||||||
// CountSince 统计 since 之后的命中总数。
|
// CountSince 统计 since 之后的命中总数。
|
||||||
CountSince(ctx context.Context, since time.Time) (int64, error)
|
CountSince(ctx context.Context, since time.Time) (int64, error)
|
||||||
// TopSources 返回 since 之后命中次数最多的来源 IP。
|
// TopSources 返回 since 之后命中次数最多的来源 IP。
|
||||||
TopSources(ctx context.Context, since time.Time, limit int) ([]*HoneypotSourceStat, error)
|
TopSources(ctx context.Context, since time.Time, limit int) ([]*HoneypotSourceStat, error)
|
||||||
// TopPaths 返回 since 之后被命中次数最多的路径。
|
// TopPaths 返回 since 之后被命中次数最多的路径。
|
||||||
TopPaths(ctx context.Context, since time.Time, limit int) ([]*HoneypotPathStat, error)
|
TopPaths(ctx context.Context, since time.Time, limit int) ([]*HoneypotPathStat, error)
|
||||||
// TopCredentials 返回 since 之后最常被尝试的用户名/密码组合。
|
|
||||||
TopCredentials(ctx context.Context, since time.Time, limit int) ([]*HoneypotCredentialStat, error)
|
|
||||||
// DeleteBefore 清理 before 之前的记录(保留期到期),返回删除行数。
|
// DeleteBefore 清理 before 之前的记录(保留期到期),返回删除行数。
|
||||||
DeleteBefore(ctx context.Context, before time.Time) (int64, error)
|
DeleteBefore(ctx context.Context, before time.Time) (int64, error)
|
||||||
// DeleteOldestBeyond 保留最近 keepRows 行,删除更早的记录,返回删除行数。
|
// DeleteOldestBeyond 保留最近 keepRows 行,删除更早的记录,返回删除行数。
|
||||||
@@ -101,6 +97,24 @@ type HoneypotStore interface {
|
|||||||
// 而不是只能靠开发一次性汇报。Bytes 取整个关系的总大小(含索引),
|
// 而不是只能靠开发一次性汇报。Bytes 取整个关系的总大小(含索引),
|
||||||
// 因为磁盘压力来自索引和 TOAST,不只是堆表。
|
// 因为磁盘压力来自索引和 TOAST,不只是堆表。
|
||||||
Size(ctx context.Context) (HoneypotSize, error)
|
Size(ctx context.Context) (HoneypotSize, error)
|
||||||
|
// AggregateSources 统计 [since, until) 内按来源 IP 聚合的命中数。
|
||||||
|
//
|
||||||
|
// 安全汇总报告专用:报告要先按 IP 聚合再拿去地理定位,而不是把几十万条
|
||||||
|
// 命中明细全捞进内存再 group —— 明细只在「命中数」这一个维度上有用,
|
||||||
|
// 聚合能把它压到「独立 IP 数」的量级。
|
||||||
|
AggregateSources(ctx context.Context, since, until time.Time) ([]*HoneypotSourceAgg, error)
|
||||||
|
// AggregatePaths 统计 [since, until) 内被探测最多的路径。
|
||||||
|
AggregatePaths(ctx context.Context, since, until time.Time, limit int) ([]*HoneypotPathStat, error)
|
||||||
|
// TimestampsBetween 返回 [since, until) 内所有命中的时间戳(仅这一列)。
|
||||||
|
//
|
||||||
|
// 用于画每日趋势。刻意不在 SQL 里按天分组:DATE() 的返回类型在
|
||||||
|
// PostgreSQL(date)与 MySQL([]byte)之间不一致,跨方言解析很脆;
|
||||||
|
// 而这一列的体积极小(时间戳 8 字节),取回来在 Go 里按天归并更稳。
|
||||||
|
TimestampsBetween(ctx context.Context, since, until time.Time) ([]time.Time, error)
|
||||||
|
// LegacyCredentialColumns 返回仍残留的旧版凭据列(空表示已清理)。
|
||||||
|
LegacyCredentialColumns(ctx context.Context) ([]string, error)
|
||||||
|
// PurgeLegacyCredentialColumns 删除旧版遗留的凭据列(不可逆)。
|
||||||
|
PurgeLegacyCredentialColumns(ctx context.Context) ([]string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HoneypotSize 蜜罐表的体积报告。
|
// HoneypotSize 蜜罐表的体积报告。
|
||||||
@@ -121,17 +135,62 @@ type HoneypotSourceStat struct {
|
|||||||
Provider string
|
Provider string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HoneypotSourceAgg 是带「处置结果」的来源聚合行(安全汇总报告用)。
|
||||||
|
//
|
||||||
|
// 与 HoneypotSourceStat 的区别:那个是给 CLI 排行看的(只要次数),
|
||||||
|
// 这个要给报告回答「打了多少次 / 有多少次被拦下 / 有多少次真的在试密码」。
|
||||||
|
type HoneypotSourceAgg struct {
|
||||||
|
IP string
|
||||||
|
Hits int64
|
||||||
|
Banned int64
|
||||||
|
// Creds 带有凭据字段的命中数(真实破解尝试,区别于纯路径探测)。
|
||||||
|
Creds int64
|
||||||
|
// Provider 是该 IP 最近一次命中时的 cdncheck 标签(云厂商/CDN/WAF)。
|
||||||
|
Provider string
|
||||||
|
}
|
||||||
|
|
||||||
// HoneypotPathStat 路径聚合行。
|
// HoneypotPathStat 路径聚合行。
|
||||||
type HoneypotPathStat struct {
|
type HoneypotPathStat struct {
|
||||||
Path string
|
Path string
|
||||||
Count int64
|
Count int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// HoneypotCredentialStat 凭据组合聚合行。
|
// LegacyHoneypotCredentialColumns 是早期版本在 honeypot_hits 上留下的凭据列。
|
||||||
type HoneypotCredentialStat struct {
|
//
|
||||||
Username string
|
// 结构体已经删掉这些字段,但 GORM 的 AutoMigrate **不会删除既有列** ——
|
||||||
Password string
|
// 老库升级上来后列还在、历史明文也还在。启动时检测到就告警,由运维执行
|
||||||
Count int64
|
// `TamaBox honeypot purge-creds` 显式清除(DROP COLUMN 不可逆,不自动做)。
|
||||||
|
var LegacyHoneypotCredentialColumns = []string{"username", "password", "fields", "raw_body"}
|
||||||
|
|
||||||
|
// LegacyCredentialColumns 返回当前库里仍然存在的旧凭据列(空表示已清理干净)。
|
||||||
|
func (h *honeypots) LegacyCredentialColumns(ctx context.Context) ([]string, error) {
|
||||||
|
var present []string
|
||||||
|
for _, col := range LegacyHoneypotCredentialColumns {
|
||||||
|
if h.db.WithContext(ctx).Migrator().HasColumn(&HoneypotHit{}, col) {
|
||||||
|
present = append(present, col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return present, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeLegacyCredentialColumns 删除旧版遗留的凭据列(不可逆)。
|
||||||
|
//
|
||||||
|
// 语义是「把历史上已经入库的明文凭据彻底清掉」,所以是 DROP COLUMN 而不是
|
||||||
|
// 置空:置空后明文仍可能残留在表的死元组/备份里,DROP 至少让新产生的
|
||||||
|
// 备份与后续查询不再包含它们。真正的历史备份需要运维自行处理。
|
||||||
|
func (h *honeypots) PurgeLegacyCredentialColumns(ctx context.Context) ([]string, error) {
|
||||||
|
present, err := h.LegacyCredentialColumns(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dropped := make([]string, 0, len(present))
|
||||||
|
for _, col := range present {
|
||||||
|
if err := h.db.WithContext(ctx).Migrator().DropColumn(&HoneypotHit{}, col); err != nil {
|
||||||
|
return dropped, err
|
||||||
|
}
|
||||||
|
dropped = append(dropped, col)
|
||||||
|
}
|
||||||
|
return dropped, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type honeypots struct {
|
type honeypots struct {
|
||||||
@@ -163,23 +222,6 @@ func (h *honeypots) ListSince(ctx context.Context, since time.Time, limit int) (
|
|||||||
return rows, err
|
return rows, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *honeypots) ListSinceByIP(ctx context.Context, since time.Time, ip string, limit int) ([]*HoneypotHit, error) {
|
|
||||||
if limit <= 0 || limit > 1000 {
|
|
||||||
limit = 200
|
|
||||||
}
|
|
||||||
if ip == "" {
|
|
||||||
return h.ListSince(ctx, since, limit)
|
|
||||||
}
|
|
||||||
var rows []*HoneypotHit
|
|
||||||
err := h.db.WithContext(ctx).
|
|
||||||
Where("created_at >= ?", since).
|
|
||||||
Where("ip = ?", ip).
|
|
||||||
Order("created_at DESC").
|
|
||||||
Limit(limit).
|
|
||||||
Find(&rows).Error
|
|
||||||
return rows, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *honeypots) CountSince(ctx context.Context, since time.Time) (int64, error) {
|
func (h *honeypots) CountSince(ctx context.Context, since time.Time) (int64, error) {
|
||||||
var n int64
|
var n int64
|
||||||
err := h.db.WithContext(ctx).Model(&HoneypotHit{}).
|
err := h.db.WithContext(ctx).Model(&HoneypotHit{}).
|
||||||
@@ -220,23 +262,55 @@ func (h *honeypots) TopPaths(ctx context.Context, since time.Time, limit int) ([
|
|||||||
return rows, err
|
return rows, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *honeypots) TopCredentials(ctx context.Context, since time.Time, limit int) ([]*HoneypotCredentialStat, error) {
|
// AggregateSources 统计 [since, until) 内按来源 IP 聚合的命中数。
|
||||||
|
//
|
||||||
|
// [since, until) 左闭右开:周期报告的相邻两期不能有重叠,也不能漏掉
|
||||||
|
// 恰好落在整点上的记录 —— 用闭区间会让周一的 00:00:00 同时算进上周和本周。
|
||||||
|
func (h *honeypots) AggregateSources(ctx context.Context, since, until time.Time) ([]*HoneypotSourceAgg, error) {
|
||||||
|
var rows []*HoneypotSourceAgg
|
||||||
|
err := h.db.WithContext(ctx).
|
||||||
|
Table("honeypot_hits").
|
||||||
|
// SUM(CASE WHEN ...) 是 PG 与 MySQL 都支持的写法(boolean 在 PG 里
|
||||||
|
// 不能直接 sum,转 1/0 才跨方言)。
|
||||||
|
Select("ip AS ip, count(*) AS hits, "+
|
||||||
|
"sum(case when banned then 1 else 0 end) AS banned, "+
|
||||||
|
"sum(case when had_fields then 1 else 0 end) AS creds, "+
|
||||||
|
"max(provider) AS provider").
|
||||||
|
Where("created_at >= ? AND created_at < ?", since, until).
|
||||||
|
Group("ip").
|
||||||
|
Order("hits DESC").
|
||||||
|
Scan(&rows).Error
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AggregatePaths 统计 [since, until) 内被探测最多的路径。
|
||||||
|
func (h *honeypots) AggregatePaths(ctx context.Context, since, until time.Time, limit int) ([]*HoneypotPathStat, error) {
|
||||||
if limit <= 0 || limit > 500 {
|
if limit <= 0 || limit > 500 {
|
||||||
limit = 20
|
limit = 20
|
||||||
}
|
}
|
||||||
var rows []*HoneypotCredentialStat
|
var rows []*HoneypotPathStat
|
||||||
err := h.db.WithContext(ctx).
|
err := h.db.WithContext(ctx).
|
||||||
Table("honeypot_hits").
|
Table("honeypot_hits").
|
||||||
Select("username AS username, password AS password, count(*) AS count").
|
Select("path AS path, count(*) AS count").
|
||||||
Where("created_at >= ?", since).
|
Where("created_at >= ? AND created_at < ?", since, until).
|
||||||
Where("username <> '' OR password <> ''").
|
Group("path").
|
||||||
Group("username, password").
|
|
||||||
Order("count DESC").
|
Order("count DESC").
|
||||||
Limit(limit).
|
Limit(limit).
|
||||||
Scan(&rows).Error
|
Scan(&rows).Error
|
||||||
return rows, err
|
return rows, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TimestampsBetween 返回 [since, until) 内所有命中的时间戳。
|
||||||
|
func (h *honeypots) TimestampsBetween(ctx context.Context, since, until time.Time) ([]time.Time, error) {
|
||||||
|
var ts []time.Time
|
||||||
|
err := h.db.WithContext(ctx).
|
||||||
|
Model(&HoneypotHit{}).
|
||||||
|
Where("created_at >= ? AND created_at < ?", since, until).
|
||||||
|
Order("created_at ASC").
|
||||||
|
Pluck("created_at", &ts).Error
|
||||||
|
return ts, err
|
||||||
|
}
|
||||||
|
|
||||||
func (h *honeypots) DeleteBefore(ctx context.Context, before time.Time) (int64, error) {
|
func (h *honeypots) DeleteBefore(ctx context.Context, before time.Time) (int64, error) {
|
||||||
res := h.db.WithContext(ctx).Where("created_at < ?", before).Delete(&HoneypotHit{})
|
res := h.db.WithContext(ctx).Where("created_at < ?", before).Delete(&HoneypotHit{})
|
||||||
return res.RowsAffected, res.Error
|
return res.RowsAffected, res.Error
|
||||||
|
|||||||
@@ -34,8 +34,19 @@ import (
|
|||||||
// 此时只记一条告警日志,不删任何数据。
|
// 此时只记一条告警日志,不删任何数据。
|
||||||
// - 清理失败只记日志,绝不 panic、绝不阻断主流程:蜜罐是安全增强功能,
|
// - 清理失败只记日志,绝不 panic、绝不阻断主流程:蜜罐是安全增强功能,
|
||||||
// 不能因为它把站点搞挂。
|
// 不能因为它把站点搞挂。
|
||||||
|
//
|
||||||
|
// CredPurgeable 是「凭据文件按月清理」的能力(由 honeypot.CredStore 实现)。
|
||||||
|
//
|
||||||
|
// 用本地小接口而不是直接 import honeypot 包:存储层不该反向依赖安全层,
|
||||||
|
// 也能让清理器在测试里用假实现。
|
||||||
|
type CredPurgeable interface {
|
||||||
|
// PurgeBefore 删除早于 before 的整天文件,返回删除的文件数。
|
||||||
|
PurgeBefore(before time.Time) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
type HoneypotRetention struct {
|
type HoneypotRetention struct {
|
||||||
store HoneypotStore
|
store HoneypotStore
|
||||||
|
creds CredPurgeable
|
||||||
retainDays int
|
retainDays int
|
||||||
maxRows int
|
maxRows int
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
@@ -44,13 +55,14 @@ type HoneypotRetention struct {
|
|||||||
// NewHoneypotRetention 构造保留期清理器。
|
// NewHoneypotRetention 构造保留期清理器。
|
||||||
//
|
//
|
||||||
// retainDays <= 0 表示不自动清理;maxRows <= 0 表示不设数量上限;
|
// retainDays <= 0 表示不自动清理;maxRows <= 0 表示不设数量上限;
|
||||||
// interval <= 0 时使用默认 1 小时。
|
// interval <= 0 时使用默认 1 小时;creds 为 nil 表示不清理凭据文件。
|
||||||
func NewHoneypotRetention(store HoneypotStore, retainDays int, maxRows int, interval time.Duration) *HoneypotRetention {
|
func NewHoneypotRetention(store HoneypotStore, creds CredPurgeable, retainDays int, maxRows int, interval time.Duration) *HoneypotRetention {
|
||||||
if interval <= 0 {
|
if interval <= 0 {
|
||||||
interval = time.Hour
|
interval = time.Hour
|
||||||
}
|
}
|
||||||
return &HoneypotRetention{
|
return &HoneypotRetention{
|
||||||
store: store,
|
store: store,
|
||||||
|
creds: creds,
|
||||||
retainDays: retainDays,
|
retainDays: retainDays,
|
||||||
maxRows: maxRows,
|
maxRows: maxRows,
|
||||||
interval: interval,
|
interval: interval,
|
||||||
@@ -126,6 +138,17 @@ func (r *HoneypotRetention) runOnce() {
|
|||||||
total += m
|
total += m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 凭据文件按同一个保留期清理:明文账号密码不该比元数据活得更久。
|
||||||
|
// 独立于 DB 的删除量 —— 即使这批数据库记录本来就已达标,文件仍可能过期。
|
||||||
|
if r.creds != nil {
|
||||||
|
if n, err := r.creds.PurgeBefore(before); err != nil {
|
||||||
|
logrus.WithError(err).Warn("蜜罐凭据文件清理失败(不影响数据库记录)")
|
||||||
|
} else if n > 0 {
|
||||||
|
logrus.WithFields(logrus.Fields{"files": n, "days": r.retainDays}).
|
||||||
|
Info("已清理过期的蜜罐凭据文件")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func TestHoneypotRetentionRunOnce(t *testing.T) {
|
|||||||
// 保留期 30 天、行数上限 7:
|
// 保留期 30 天、行数上限 7:
|
||||||
// 时间闸删掉 5 条 90 天前的旧记录 → 剩 5 条新的;
|
// 时间闸删掉 5 条 90 天前的旧记录 → 剩 5 条新的;
|
||||||
// 数量闸:5 < 7,未超上限,不删 → 最终剩 5 条。
|
// 数量闸:5 < 7,未超上限,不删 → 最终剩 5 条。
|
||||||
ret := NewHoneypotRetention(store, 30, 7, 0)
|
ret := NewHoneypotRetention(store, nil, 30, 7, 0)
|
||||||
ret.runOnce()
|
ret.runOnce()
|
||||||
|
|
||||||
remaining, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
|
remaining, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
|
||||||
@@ -82,7 +82,7 @@ func TestHoneypotRetentionDisabled(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
ret := NewHoneypotRetention(store, 0, 100, 0)
|
ret := NewHoneypotRetention(store, nil, 0, 100, 0)
|
||||||
ret.runOnce()
|
ret.runOnce()
|
||||||
|
|
||||||
total, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
|
total, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
|
||||||
@@ -109,7 +109,7 @@ func TestHoneypotRetentionMaxRowsOnly(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
ret := NewHoneypotRetention(store, 30, 5, 0)
|
ret := NewHoneypotRetention(store, nil, 30, 5, 0)
|
||||||
ret.runOnce()
|
ret.runOnce()
|
||||||
|
|
||||||
total, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
|
total, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
|
||||||
@@ -147,7 +147,7 @@ func TestHoneypotRetentionBothGates(t *testing.T) {
|
|||||||
|
|
||||||
// 保留期 30 天、数量上限 4:
|
// 保留期 30 天、数量上限 4:
|
||||||
// 时间闸删 5 条 ancient → 剩 10 条 recent;数量闸删到 4 条(删最旧的 6 条)
|
// 时间闸删 5 条 ancient → 剩 10 条 recent;数量闸删到 4 条(删最旧的 6 条)
|
||||||
ret := NewHoneypotRetention(store, 30, 4, 0)
|
ret := NewHoneypotRetention(store, nil, 30, 4, 0)
|
||||||
ret.runOnce()
|
ret.runOnce()
|
||||||
|
|
||||||
total, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
|
total, err := store.CountSince(ctx, now.Add(-365*24*time.Hour))
|
||||||
@@ -208,44 +208,6 @@ func TestHoneypotDeleteOldestBeyond(t *testing.T) {
|
|||||||
require.Equal(t, int64(0), n3)
|
require.Equal(t, int64(0), n3)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHoneypotListSinceByIP 验证按 IP 过滤在 SQL 层生效。
|
|
||||||
//
|
|
||||||
// 背景:原先 CLI 的 --ip 过滤是在取回最近 N 条后在内存里筛,
|
|
||||||
// 导致「指定 IP 的历史命中」基本查不出来(被其他 IP 的更近记录挤出窗口)。
|
|
||||||
func TestHoneypotListSinceByIP(t *testing.T) {
|
|
||||||
database, cleanup := newTestDB(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
if err := cleanup("honeypot_hits"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
store := NewHoneypotStore(database)
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
// 目标 IP 只有 1 条很老的记录;其余 20 条都是别的 IP 且更新
|
|
||||||
require.NoError(t, store.Create(ctx, &HoneypotHit{
|
|
||||||
IP: "9.9.9.9", Method: "GET", Path: "/target", CreatedAt: now.Add(-10 * time.Hour),
|
|
||||||
}))
|
|
||||||
for i := 0; i < 20; i++ {
|
|
||||||
require.NoError(t, store.Create(ctx, &HoneypotHit{
|
|
||||||
IP: "8.8.8.8", Method: "GET", Path: "/other", CreatedAt: now.Add(-time.Duration(i) * time.Minute),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := store.ListSinceByIP(ctx, now.Add(-24*time.Hour), "9.9.9.9", 200)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, rows, 1, "指定 IP 的老记录应能被查出(不能被其他 IP 的新记录挤出窗口)")
|
|
||||||
require.Equal(t, "/target", rows[0].Path)
|
|
||||||
|
|
||||||
// 空 IP 退化为不过滤
|
|
||||||
all, err := store.ListSinceByIP(ctx, now.Add(-24*time.Hour), "", 200)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, all, 21)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHoneypotCompact 验证空间回收在 PostgreSQL 下可执行且不报错。
|
// TestHoneypotCompact 验证空间回收在 PostgreSQL 下可执行且不报错。
|
||||||
func TestHoneypotCompact(t *testing.T) {
|
func TestHoneypotCompact(t *testing.T) {
|
||||||
database, cleanup := newTestDB(t)
|
database, cleanup := newTestDB(t)
|
||||||
|
|||||||
+113
-11
@@ -31,9 +31,9 @@ func TestHoneypotStore(t *testing.T) {
|
|||||||
|
|
||||||
rows := []*HoneypotHit{
|
rows := []*HoneypotHit{
|
||||||
{IP: "1.2.3.4", Method: "GET", Path: "/wp-admin", Provider: "Azure", CreatedAt: now.Add(-3 * time.Hour)},
|
{IP: "1.2.3.4", Method: "GET", Path: "/wp-admin", Provider: "Azure", CreatedAt: now.Add(-3 * time.Hour)},
|
||||||
{IP: "1.2.3.4", Method: "POST", Path: "/wp-login.php", Username: "admin", Password: "admin123", HadFields: true, CreatedAt: now.Add(-2 * time.Hour)},
|
{IP: "1.2.3.4", Method: "POST", Path: "/wp-login.php", HadFields: true, CreatedAt: now.Add(-2 * time.Hour)},
|
||||||
{IP: "1.2.3.4", Method: "POST", Path: "/wp-login.php", Username: "admin", Password: "admin123", HadFields: true, CreatedAt: now.Add(-time.Hour)},
|
{IP: "1.2.3.4", Method: "POST", Path: "/wp-login.php", HadFields: true, CreatedAt: now.Add(-time.Hour)},
|
||||||
{IP: "5.6.7.8", Method: "POST", Path: "/.ssh/id_rsa", Username: "root", Password: "toor", HadFields: true, CreatedAt: now.Add(-30 * time.Minute)},
|
{IP: "5.6.7.8", Method: "POST", Path: "/.ssh/id_rsa", HadFields: true, CreatedAt: now.Add(-30 * time.Minute)},
|
||||||
}
|
}
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
require.NoError(t, store.Create(ctx, r))
|
require.NoError(t, store.Create(ctx, r))
|
||||||
@@ -64,13 +64,6 @@ func TestHoneypotStore(t *testing.T) {
|
|||||||
require.Equal(t, "/wp-login.php", paths[0].Path)
|
require.Equal(t, "/wp-login.php", paths[0].Path)
|
||||||
require.Equal(t, int64(2), paths[0].Count)
|
require.Equal(t, int64(2), paths[0].Count)
|
||||||
|
|
||||||
creds, err := store.TopCredentials(ctx, since, 10)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, creds, 2)
|
|
||||||
require.Equal(t, "admin", creds[0].Username)
|
|
||||||
require.Equal(t, "admin123", creds[0].Password)
|
|
||||||
require.Equal(t, int64(2), creds[0].Count)
|
|
||||||
|
|
||||||
// 保留期清理
|
// 保留期清理
|
||||||
deleted, err := store.DeleteBefore(ctx, now.Add(-90*time.Minute))
|
deleted, err := store.DeleteBefore(ctx, now.Add(-90*time.Minute))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -81,6 +74,45 @@ func TestHoneypotStore(t *testing.T) {
|
|||||||
require.Equal(t, int64(2), total)
|
require.Equal(t, int64(2), total)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHoneypotLegacyCredentialColumns 覆盖「旧版凭据列」的检测与清除。
|
||||||
|
//
|
||||||
|
// 背景:凭据已改为只落文件(见 honeypot.CredRecord),结构体也删掉了
|
||||||
|
// username/password/fields/raw_body 四个字段。但 GORM 的 AutoMigrate
|
||||||
|
// 不会删除既有列,老库升级上来后历史明文仍然留在表里。
|
||||||
|
// 这个测试用 ADD COLUMN 模拟老库,验证检测与 DROP COLUMN 都真的生效。
|
||||||
|
func TestHoneypotLegacyCredentialColumns(t *testing.T) {
|
||||||
|
database, cleanup := newTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := cleanup("honeypot_hits"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
store := NewHoneypotStore(database)
|
||||||
|
|
||||||
|
cols, err := store.LegacyCredentialColumns(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, cols, "新建的表不应有旧版凭据列")
|
||||||
|
|
||||||
|
// 模拟旧版本留下的列。
|
||||||
|
require.NoError(t, database.Exec("ALTER TABLE honeypot_hits ADD COLUMN username varchar(256)").Error)
|
||||||
|
require.NoError(t, database.Exec("ALTER TABLE honeypot_hits ADD COLUMN password varchar(256)").Error)
|
||||||
|
|
||||||
|
cols, err = store.LegacyCredentialColumns(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.ElementsMatch(t, []string{"username", "password"}, cols)
|
||||||
|
|
||||||
|
dropped, err := store.PurgeLegacyCredentialColumns(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.ElementsMatch(t, []string{"username", "password"}, dropped)
|
||||||
|
|
||||||
|
cols, err = store.LegacyCredentialColumns(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, cols, "清除后不应再检测到旧版凭据列")
|
||||||
|
}
|
||||||
|
|
||||||
// TestHoneypotSize 覆盖体积报告:行数、时间跨度、磁盘占用。
|
// TestHoneypotSize 覆盖体积报告:行数、时间跨度、磁盘占用。
|
||||||
//
|
//
|
||||||
// 目的:让「蜜罐表有没有把数据库撑臃肿」成为运维随时能自己查的事。
|
// 目的:让「蜜罐表有没有把数据库撑臃肿」成为运维随时能自己查的事。
|
||||||
@@ -102,7 +134,7 @@ func TestHoneypotSize(t *testing.T) {
|
|||||||
IP: "9.9.9.9",
|
IP: "9.9.9.9",
|
||||||
Method: "POST",
|
Method: "POST",
|
||||||
Path: "/wp-login.php",
|
Path: "/wp-login.php",
|
||||||
Username: "admin",
|
HadFields: true,
|
||||||
CreatedAt: now.Add(-time.Duration(i) * time.Hour),
|
CreatedAt: now.Add(-time.Duration(i) * time.Hour),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -127,3 +159,73 @@ func TestHoneypotSize(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, int64(2), info2.Rows)
|
require.Equal(t, int64(2), info2.Rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHoneypotAggregateRange 覆盖安全汇总报告用的区间聚合。
|
||||||
|
//
|
||||||
|
// 三个方法共同回答「这一周谁在打、打了哪儿」。重点验证:
|
||||||
|
// - [since, until) 左闭右开,相邻两期不能重叠;
|
||||||
|
// - SUM(CASE WHEN ...) 在 PostgreSQL / MySQL 上都能算出处置数;
|
||||||
|
// - 只取时间戳那一列时不夹带整行(报告不需要 UA / 路径以外的字段)。
|
||||||
|
func TestHoneypotAggregateRange(t *testing.T) {
|
||||||
|
database, cleanup := newTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := cleanup("honeypot_hits"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
store := NewHoneypotStore(database)
|
||||||
|
loc := time.Local
|
||||||
|
start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc)
|
||||||
|
end := start.AddDate(0, 0, 7)
|
||||||
|
|
||||||
|
rows := []*HoneypotHit{
|
||||||
|
// 窗口内:IP A 三次(两次被拦、一次带凭据)、IP B 一次
|
||||||
|
{IP: "203.0.113.1", Path: "/wp-login.php", Banned: true, HadFields: false, Provider: "Azure", CreatedAt: start.Add(time.Hour)},
|
||||||
|
{IP: "203.0.113.1", Path: "/wp-login.php", Banned: true, HadFields: true, Provider: "Azure", CreatedAt: start.Add(2 * time.Hour)},
|
||||||
|
{IP: "203.0.113.1", Path: "/.env", Banned: true, HadFields: false, Provider: "Azure", CreatedAt: start.Add(3 * time.Hour)},
|
||||||
|
{IP: "198.51.100.2", Path: "/wp-admin", Banned: false, HadFields: false, CreatedAt: start.Add(4 * time.Hour)},
|
||||||
|
// 窗口外(右侧开区间边界):不能算进本期
|
||||||
|
{IP: "203.0.113.1", Path: "/wp-login.php", Banned: true, CreatedAt: end},
|
||||||
|
// 窗口外(早于起点)
|
||||||
|
{IP: "192.0.2.9", Path: "/phpmyadmin", Banned: true, CreatedAt: start.Add(-time.Minute)},
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
require.NoError(t, store.Create(ctx, r))
|
||||||
|
}
|
||||||
|
|
||||||
|
sources, err := store.AggregateSources(ctx, start, end)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, sources, 2)
|
||||||
|
|
||||||
|
// 按命中数降序:203.0.113.1 在前
|
||||||
|
require.Equal(t, "203.0.113.1", sources[0].IP)
|
||||||
|
require.Equal(t, int64(3), sources[0].Hits)
|
||||||
|
require.Equal(t, int64(3), sources[0].Banned)
|
||||||
|
require.Equal(t, int64(1), sources[0].Creds)
|
||||||
|
require.Equal(t, "Azure", sources[0].Provider)
|
||||||
|
|
||||||
|
require.Equal(t, "198.51.100.2", sources[1].IP)
|
||||||
|
require.Equal(t, int64(1), sources[1].Hits)
|
||||||
|
require.Equal(t, int64(0), sources[1].Banned)
|
||||||
|
|
||||||
|
paths, err := store.AggregatePaths(ctx, start, end, 10)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, paths, 3)
|
||||||
|
require.Equal(t, "/wp-login.php", paths[0].Path)
|
||||||
|
require.Equal(t, int64(2), paths[0].Count)
|
||||||
|
|
||||||
|
stamps, err := store.TimestampsBetween(ctx, start, end)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, stamps, 4, "右边界那条不能算进来")
|
||||||
|
for _, ts := range stamps {
|
||||||
|
require.True(t, ts.Before(end), "所有时间戳都应严格早于 end")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空窗口必须返回空而不是报错(报告要能生成「本周无人扫描」)
|
||||||
|
empty, err := store.AggregateSources(ctx, start.AddDate(0, 0, 30), start.AddDate(0, 0, 37))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, empty)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
// Copyright 2026 tamakyi. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSetPinnedIfCurrentOnlyAppliesOnce 覆盖「双击置顶」这个场景。
|
||||||
|
//
|
||||||
|
// 原来的 handler 是 read-modify-write:SetPinned(id, !question.IsPinned)。
|
||||||
|
// 双击时两个请求都读到 is_pinned=false,都写 true,于是用户点了两次、期望
|
||||||
|
// 「置顶后取消」,结果仍是「置顶」。现在加 current 条件,第二次会因为状态
|
||||||
|
// 已变而影响 0 行 —— 语义变成「只切换一次」,符合用户本意(手抖点两下)。
|
||||||
|
func TestSetPinnedIfCurrentOnlyAppliesOnce(t *testing.T) {
|
||||||
|
database, cleanup := newTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := cleanup("questions"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
store := NewQuestionsStore(database)
|
||||||
|
q, err := store.Create(ctx, CreateQuestionOptions{
|
||||||
|
FromIP: "114.5.1.4",
|
||||||
|
UserID: 1,
|
||||||
|
Content: "试试置顶",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, q.IsPinned)
|
||||||
|
|
||||||
|
// 第一次切换:false -> true,生效
|
||||||
|
applied, err := store.SetPinnedIfCurrent(ctx, q.ID, false, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, applied, "第一次切换应生效")
|
||||||
|
|
||||||
|
// 第二次切换:请求方仍然以为当前是 false(它读到的旧值),写入 true
|
||||||
|
// 此时数据库已经是 true,条件不成立 -> 0 行
|
||||||
|
applied, err = store.SetPinnedIfCurrent(ctx, q.ID, false, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, applied, "状态已变,第二次切换不应再生效")
|
||||||
|
|
||||||
|
got, err := store.GetByID(ctx, q.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, got.IsPinned, "最终状态应仍是置顶,而不是被双击反转")
|
||||||
|
|
||||||
|
// 正常取消置顶(条件匹配)
|
||||||
|
applied, err = store.SetPinnedIfCurrent(ctx, q.ID, true, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, applied)
|
||||||
|
got, err = store.GetByID(ctx, q.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, got.IsPinned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFollowUpRejectsSecondWhilePreviousUnanswered 覆盖「追问必须等主人回复」。
|
||||||
|
//
|
||||||
|
// 这条规则原来只在 handler 里判断(事务外)。双击提交时两个请求都能通过那次检查,
|
||||||
|
// 随后被 CreateIfBelowLimit 的问题行锁串行化,各自 count 都 < 3 —— 两条内容相同的
|
||||||
|
// 追问都创建成功,吃掉 2/3 名额,箱主还会收到两封邮件。
|
||||||
|
// 现在判定挪进事务,第二个请求会拿到 ErrFollowUpAwaitingReply。
|
||||||
|
func TestFollowUpRejectsSecondWhilePreviousUnanswered(t *testing.T) {
|
||||||
|
database, cleanup := newTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := cleanup("follow_ups", "questions"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
qStore := NewQuestionsStore(database)
|
||||||
|
q, err := qStore.Create(ctx, CreateQuestionOptions{
|
||||||
|
FromIP: "114.5.1.4",
|
||||||
|
UserID: 1,
|
||||||
|
Content: "允许我追问吗",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
fStore := NewFollowUpsStore(database)
|
||||||
|
|
||||||
|
created, err := fStore.CreateIfBelowLimit(ctx, CreateFollowUpOptions{
|
||||||
|
QuestionID: q.ID,
|
||||||
|
Content: "第一次追问",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, created)
|
||||||
|
|
||||||
|
// 第一条还没被回复:第二次追问必须被拒
|
||||||
|
_, err = fStore.CreateIfBelowLimit(ctx, CreateFollowUpOptions{
|
||||||
|
QuestionID: q.ID,
|
||||||
|
Content: "第一次追问", // 内容相同,模拟双击
|
||||||
|
})
|
||||||
|
require.ErrorIs(t, err, ErrFollowUpAwaitingReply)
|
||||||
|
|
||||||
|
// 确认库里只有一条(没有因为双击写入两条)
|
||||||
|
list, err := fStore.GetByQuestionID(ctx, q.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, list, 1, "双击不应产生两条追问")
|
||||||
|
|
||||||
|
// 主人回复后,允许继续追问
|
||||||
|
require.NoError(t, fStore.AnswerByID(ctx, q.ID, list[0].ID, "答:可以"))
|
||||||
|
created, err = fStore.CreateIfBelowLimit(ctx, CreateFollowUpOptions{
|
||||||
|
QuestionID: q.ID,
|
||||||
|
Content: "第二次追问",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, created, "上一条已回复后应允许继续追问")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMarkDeletedByOwnerConcurrentDecrementsQuotaOnce 覆盖并发删除会话时的配额回退。
|
||||||
|
//
|
||||||
|
// 原来是 check-then-act:两次并发请求都读到 is_deleted_by_owner=false,
|
||||||
|
// 都执行 UPDATE、都执行一次 IncrementQuota(-1)。计数不会变负,但会白吃掉
|
||||||
|
// 其他会话的额度,表现为定量收集提前关闭。
|
||||||
|
func TestMarkDeletedByOwnerConcurrentDecrementsQuotaOnce(t *testing.T) {
|
||||||
|
database, cleanup := newTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := cleanup("conversations", "feature_controls"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const ownerID = 7
|
||||||
|
|
||||||
|
// 先把私聊配额计数做到 5
|
||||||
|
fcStore := NewFeatureControlsStore(database)
|
||||||
|
require.NoError(t, fcStore.IncrementQuota(ctx, ownerID, FeatureChat, 5))
|
||||||
|
before, err := fcStore.Get(ctx, ownerID, FeatureChat)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, 5, before.QuotaCount)
|
||||||
|
|
||||||
|
convStore := NewConversationStore(database)
|
||||||
|
conv, err := convStore.Create(ctx, CreateConversationOptions{OwnerID: ownerID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// 并发两次删除(模拟双击 / 前端重复提交)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errs := make([]error, 2)
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
errs[idx] = convStore.MarkDeletedByOwner(ctx, conv.ID)
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
require.NoError(t, errs[0])
|
||||||
|
require.NoError(t, errs[1])
|
||||||
|
|
||||||
|
after, err := fcStore.Get(ctx, ownerID, FeatureChat)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, 4, after.QuotaCount,
|
||||||
|
"两个并发删除请求只应回退一次配额(5 -> 4),而不是两次(5 -> 3)")
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// Copyright 2026 tamakyi. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IPGeoRecord 是一个来源 IP 的地理定位缓存。
|
||||||
|
//
|
||||||
|
// 为什么必须落库而不是每次现查:
|
||||||
|
// - 免费 IP 库都有配额(ip-api 免费版 45 次/分钟)。一次周报可能有几百上千个
|
||||||
|
// 不同来源 IP,现查必然撞限流,撞了就是「报告里一半 IP 没有位置」;
|
||||||
|
// - 同一个攻击者会在多份报告里反复出现,缓存能让第二次之后零外部请求;
|
||||||
|
// - 定位结果本身变化很慢(国家级几乎不变,城市级极少变),没必要重复付费。
|
||||||
|
//
|
||||||
|
// 缓存键用 shared.LimitKey(IPv4 精确、IPv6 折叠 /64)而不是原始 IP:
|
||||||
|
// 攻击者常在一个 /64 里轮换地址,按精确地址缓存会把同一个 C 段查上几十次,
|
||||||
|
// 既浪费配额又得到一模一样的结果。
|
||||||
|
type IPGeoRecord struct {
|
||||||
|
// IPKey 是缓存键(shared.LimitKey 的产物)。主键,不用自增 id:
|
||||||
|
// 这张表是纯 KV 缓存,没有任何按 id 遍历或引用的需求。
|
||||||
|
IPKey string `gorm:"size:64;primarykey"`
|
||||||
|
|
||||||
|
Country string `gorm:"size:64"`
|
||||||
|
CountryCode string `gorm:"size:8"`
|
||||||
|
Region string `gorm:"size:128"`
|
||||||
|
City string `gorm:"size:128"`
|
||||||
|
// ISP 运营商 / 网络名("China Telecom"、"Amazon.com, Inc.")。肉鸡报告里
|
||||||
|
// 这个字段比国家更有信息量:能看出是家宽还是云主机。
|
||||||
|
ISP string `gorm:"size:160"`
|
||||||
|
// ASN 自治域号("AS4134")。同 ASN 大量命中通常意味着同一片被控网络。
|
||||||
|
ASN string `gorm:"size:64"`
|
||||||
|
|
||||||
|
// Lat / Lon 为定位坐标。坐标系随来源不同:
|
||||||
|
// tencent → GCJ-02(火星坐标,与腾讯地图对齐)
|
||||||
|
// ipapi → WGS-84
|
||||||
|
// 两者相差几百米,在世界地图气泡图尺度上无影响;放大到街道级才有意义。
|
||||||
|
Lat float64
|
||||||
|
Lon float64
|
||||||
|
// HasCoords 标记坐标是否可用。IP 库对国家级、部分省级结果不给坐标,
|
||||||
|
// 用 0,0 表示「未知」会被画到几内亚湾去 —— 必须显式区分。
|
||||||
|
HasCoords bool
|
||||||
|
|
||||||
|
// Source 记录这条数据来自哪个库(tencent / ipapi),便于事后判断精度。
|
||||||
|
Source string `gorm:"size:16"`
|
||||||
|
// Failed 标记查询失败。失败也要缓存:某个 IP 查不出来通常是确定性的
|
||||||
|
// (保留地址、库未收录),反复重试只会白白消耗配额。
|
||||||
|
Failed bool
|
||||||
|
|
||||||
|
ResolvedAt time.Time `gorm:"index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 固定表名。
|
||||||
|
func (IPGeoRecord) TableName() string { return "ip_geo_cache" }
|
||||||
|
|
||||||
|
// IPGeos 地理定位缓存全局 store。
|
||||||
|
var IPGeos IPGeoStore
|
||||||
|
|
||||||
|
// IPGeoStore 地理定位缓存的读写。
|
||||||
|
type IPGeoStore interface {
|
||||||
|
// GetMany 批量取缓存,返回 map[IPKey]*IPGeoRecord。未命中的键不出现在结果里。
|
||||||
|
GetMany(ctx context.Context, keys []string) (map[string]*IPGeoRecord, error)
|
||||||
|
// UpsertMany 批量写入或覆盖。
|
||||||
|
UpsertMany(ctx context.Context, records []*IPGeoRecord) error
|
||||||
|
// PurgeResolvedBefore 删除 resolved_at 早于 before 的记录(缓存过期)。
|
||||||
|
//
|
||||||
|
// 注意「过期」与「失败」的 TTL 不同,由调用方分两次调:
|
||||||
|
// 成功结果留得久(90 天),失败结果留得短(3 天)—— 失败可能是临时故障
|
||||||
|
// (对方 API 超时),留太久等于永久放弃这个 IP。
|
||||||
|
PurgeResolvedBefore(ctx context.Context, before time.Time, onlyFailed bool) (int64, error)
|
||||||
|
// Count 返回缓存条目数。
|
||||||
|
Count(ctx context.Context) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ipGeos struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIPGeoStore 构造缓存 store。务必传 db.DB 而不是复用什么包级单例。
|
||||||
|
func NewIPGeoStore(db *gorm.DB) IPGeoStore {
|
||||||
|
return &ipGeos{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ipGeos) GetMany(ctx context.Context, keys []string) (map[string]*IPGeoRecord, error) {
|
||||||
|
out := make(map[string]*IPGeoRecord, len(keys))
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分批 IN 查询:PostgreSQL 的占位符上限是 65535,MySQL 受 max_allowed_packet
|
||||||
|
// 约束,一次塞上千个键虽然多数情况能过,但没必要赌。
|
||||||
|
const batch = 500
|
||||||
|
for start := 0; start < len(keys); start += batch {
|
||||||
|
end := start + batch
|
||||||
|
if end > len(keys) {
|
||||||
|
end = len(keys)
|
||||||
|
}
|
||||||
|
var rows []*IPGeoRecord
|
||||||
|
if err := s.db.WithContext(ctx).
|
||||||
|
Where("ip_key IN ?", keys[start:end]).
|
||||||
|
Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
out[r.IPKey] = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ipGeos) UpsertMany(ctx context.Context, records []*IPGeoRecord) error {
|
||||||
|
if len(records) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// 只更新定位字段,不更新 ip_key 本身(它是冲突判定列)。
|
||||||
|
cols := []string{
|
||||||
|
"country", "country_code", "region", "city", "isp", "asn",
|
||||||
|
"lat", "lon", "has_coords", "source", "failed", "resolved_at",
|
||||||
|
}
|
||||||
|
return s.db.WithContext(ctx).
|
||||||
|
Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "ip_key"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns(cols),
|
||||||
|
}).
|
||||||
|
CreateInBatches(records, 100).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ipGeos) PurgeResolvedBefore(ctx context.Context, before time.Time, onlyFailed bool) (int64, error) {
|
||||||
|
q := s.db.WithContext(ctx).Where("resolved_at < ?", before)
|
||||||
|
if onlyFailed {
|
||||||
|
q = q.Where("failed = ?", true)
|
||||||
|
} else {
|
||||||
|
// 成功记录那一轮不要连失败的一起删:两者的 TTL 不同。
|
||||||
|
q = q.Where("failed = ?", false)
|
||||||
|
}
|
||||||
|
res := q.Delete(&IPGeoRecord{})
|
||||||
|
return res.RowsAffected, res.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ipGeos) Count(ctx context.Context) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
err := s.db.WithContext(ctx).Model(&IPGeoRecord{}).Count(&n).Error
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// Copyright 2026 tamakyi. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestIPGeoStore 覆盖地理定位缓存的读写、批量取、覆盖与过期清理。
|
||||||
|
//
|
||||||
|
// 这张表是配额的保护层:同一批攻击者会在多份报告里反复出现,
|
||||||
|
// 「查一次、之后全靠缓存」能不能成立全看这里的语义对不对。
|
||||||
|
func TestIPGeoStore(t *testing.T) {
|
||||||
|
database, cleanup := newTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := cleanup("ip_geo_cache"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
store := NewIPGeoStore(database)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
records := []*IPGeoRecord{
|
||||||
|
{
|
||||||
|
IPKey: "203.0.113.1", Country: "美国", CountryCode: "US",
|
||||||
|
Region: "Virginia", City: "Ashburn", ISP: "Amazon", ASN: "AS14618",
|
||||||
|
Lat: 39.04, Lon: -77.49, HasCoords: true, Source: "ipapi", ResolvedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IPKey: "2001:db8::/64", Country: "中国", CountryCode: "CN",
|
||||||
|
Region: "北京市", City: "北京", Lat: 39.9, Lon: 116.4,
|
||||||
|
HasCoords: true, Source: "tencent", ResolvedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 查不到:Failed 标记要能落库,否则每次报告都会重试同一批地址
|
||||||
|
IPKey: "198.51.100.9", Failed: true, ResolvedAt: now,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.NoError(t, store.UpsertMany(ctx, records))
|
||||||
|
|
||||||
|
got, err := store.GetMany(ctx, []string{"203.0.113.1", "2001:db8::/64", "198.51.100.9", "10.0.0.1"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 3, "不存在的键不应出现在结果里")
|
||||||
|
require.Equal(t, "Ashburn", got["203.0.113.1"].City)
|
||||||
|
require.Equal(t, "AS14618", got["203.0.113.1"].ASN)
|
||||||
|
require.True(t, got["203.0.113.1"].HasCoords)
|
||||||
|
require.Equal(t, "tencent", got["2001:db8::/64"].Source)
|
||||||
|
require.True(t, got["198.51.100.9"].Failed)
|
||||||
|
|
||||||
|
// 覆盖写:同一个键再写一次应当更新而不是报主键冲突
|
||||||
|
require.NoError(t, store.UpsertMany(ctx, []*IPGeoRecord{{
|
||||||
|
IPKey: "203.0.113.1", Country: "美国", CountryCode: "US",
|
||||||
|
City: "Reston", Lat: 38.96, Lon: -77.35, HasCoords: true,
|
||||||
|
Source: "tencent", ResolvedAt: now.Add(time.Hour),
|
||||||
|
}}))
|
||||||
|
got2, err := store.GetMany(ctx, []string{"203.0.113.1"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "Reston", got2["203.0.113.1"].City)
|
||||||
|
require.Equal(t, "tencent", got2["203.0.113.1"].Source)
|
||||||
|
|
||||||
|
n, err := store.Count(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(3), n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIPGeoStorePurge 覆盖两套 TTL 分开清理:成功留 90 天、失败留 3 天。
|
||||||
|
func TestIPGeoStorePurge(t *testing.T) {
|
||||||
|
database, cleanup := newTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := cleanup("ip_geo_cache"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
store := NewIPGeoStore(database)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
require.NoError(t, store.UpsertMany(ctx, []*IPGeoRecord{
|
||||||
|
{IPKey: "1.0.0.1", Country: "澳大利亚", ResolvedAt: now.AddDate(0, 0, -100)}, // 成功且过期
|
||||||
|
{IPKey: "1.0.0.2", Country: "日本", ResolvedAt: now.AddDate(0, 0, -10)}, // 成功且新鲜
|
||||||
|
{IPKey: "1.0.0.3", Failed: true, ResolvedAt: now.AddDate(0, 0, -4)}, // 失败且过期
|
||||||
|
{IPKey: "1.0.0.4", Failed: true, ResolvedAt: now}, // 失败且新鲜
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 清失败的旧记录:只删 1.0.0.3
|
||||||
|
n, err := store.PurgeResolvedBefore(ctx, now.AddDate(0, 0, -3), true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(1), n)
|
||||||
|
|
||||||
|
// 清成功的旧记录:只删 1.0.0.1(不能连失败的一起删,两者 TTL 不同)
|
||||||
|
n, err = store.PurgeResolvedBefore(ctx, now.AddDate(0, 0, -90), false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(1), n)
|
||||||
|
|
||||||
|
left, err := store.GetMany(ctx, []string{"1.0.0.1", "1.0.0.2", "1.0.0.3", "1.0.0.4"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, left, 2)
|
||||||
|
require.Contains(t, left, "1.0.0.2")
|
||||||
|
require.Contains(t, left, "1.0.0.4")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSecurityReportStore 覆盖报告台账的幂等语义。
|
||||||
|
//
|
||||||
|
// 这是「重启不会重复发同一期周报」的唯一保证:period_key 唯一索引 + upsert。
|
||||||
|
func TestSecurityReportStore(t *testing.T) {
|
||||||
|
database, cleanup := newTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := cleanup("security_report_logs"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
store := NewSecurityReportStore(database)
|
||||||
|
start := time.Date(2026, 10, 19, 0, 0, 0, 0, time.Local)
|
||||||
|
end := start.AddDate(0, 0, 7)
|
||||||
|
|
||||||
|
// 首次:不存在
|
||||||
|
rec, err := store.Get(ctx, "week:2026-W43")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, rec)
|
||||||
|
|
||||||
|
require.NoError(t, store.Save(ctx, &SecurityReportLog{
|
||||||
|
PeriodKey: "week:2026-W43", Kind: "week",
|
||||||
|
WindowStart: start, WindowEnd: end,
|
||||||
|
Hits: 42, UniqueSources: 7, Countries: 3,
|
||||||
|
Recipient: "[email protected]", Attempts: 1, Error: "smtp timeout",
|
||||||
|
}))
|
||||||
|
|
||||||
|
rec, err = store.Get(ctx, "week:2026-W43")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, rec)
|
||||||
|
require.False(t, rec.Sent)
|
||||||
|
require.Equal(t, 1, rec.Attempts)
|
||||||
|
|
||||||
|
// 第二次:同一 period_key 再写应当覆盖,而不是插入第二行 ——
|
||||||
|
// 否则「这一期发过没」会被多行记录搞乱。
|
||||||
|
require.NoError(t, store.Save(ctx, &SecurityReportLog{
|
||||||
|
PeriodKey: "week:2026-W43", Kind: "week",
|
||||||
|
WindowStart: start, WindowEnd: end,
|
||||||
|
Hits: 42, UniqueSources: 7, Countries: 3,
|
||||||
|
Recipient: "[email protected]", Attempts: 2, Sent: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
rec, err = store.Get(ctx, "week:2026-W43")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, rec.Sent)
|
||||||
|
require.Equal(t, 2, rec.Attempts)
|
||||||
|
|
||||||
|
recent, err := store.Recent(ctx, 10)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, recent, 1, "同一期只能有一行台账")
|
||||||
|
|
||||||
|
// 不同周期应各自一行
|
||||||
|
require.NoError(t, store.Save(ctx, &SecurityReportLog{
|
||||||
|
PeriodKey: "month:2026-10", Kind: "month",
|
||||||
|
WindowStart: start, WindowEnd: end, Sent: true,
|
||||||
|
}))
|
||||||
|
recent, err = store.Recent(ctx, 10)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, recent, 2)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type safeLogger struct {
|
||||||
|
logger.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSafeLogger() logger.Interface {
|
||||||
|
return &safeLogger{Config: logger.Config{
|
||||||
|
SlowThreshold: 200 * time.Millisecond,
|
||||||
|
LogLevel: logger.Warn,
|
||||||
|
IgnoreRecordNotFoundError: false,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *safeLogger) LogMode(level logger.LogLevel) logger.Interface {
|
||||||
|
newLogger := *l
|
||||||
|
newLogger.LogLevel = level
|
||||||
|
return &newLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *safeLogger) Info(ctx context.Context, _ string, _ ...interface{}) {
|
||||||
|
if l.LogLevel >= logger.Info {
|
||||||
|
logrus.WithContext(ctx).WithField("component", "gorm").Info("数据库信息事件")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *safeLogger) Warn(ctx context.Context, _ string, _ ...interface{}) {
|
||||||
|
if l.LogLevel >= logger.Warn {
|
||||||
|
logrus.WithContext(ctx).WithField("component", "gorm").Warn("数据库警告事件")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *safeLogger) Error(ctx context.Context, _ string, _ ...interface{}) {
|
||||||
|
if l.LogLevel >= logger.Error {
|
||||||
|
logrus.WithContext(ctx).WithField("component", "gorm").Error("数据库错误事件")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *safeLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
|
||||||
|
if l.LogLevel <= logger.Silent {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := time.Since(begin)
|
||||||
|
switch {
|
||||||
|
case err != nil && l.LogLevel >= logger.Error && (!errors.Is(err, logger.ErrRecordNotFound) || !l.IgnoreRecordNotFoundError):
|
||||||
|
_, rows := fc()
|
||||||
|
l.traceEntry(ctx, elapsed, rows).WithFields(logrus.Fields{
|
||||||
|
"error_type": fmt.Sprintf("%T", err),
|
||||||
|
"record_not_found": errors.Is(err, logger.ErrRecordNotFound),
|
||||||
|
}).Error("数据库操作失败")
|
||||||
|
case elapsed > l.SlowThreshold && l.SlowThreshold != 0 && l.LogLevel >= logger.Warn:
|
||||||
|
_, rows := fc()
|
||||||
|
l.traceEntry(ctx, elapsed, rows).
|
||||||
|
WithField("slow_threshold", l.SlowThreshold.String()).
|
||||||
|
Warn("数据库慢查询")
|
||||||
|
case l.LogLevel >= logger.Info:
|
||||||
|
_, rows := fc()
|
||||||
|
l.traceEntry(ctx, elapsed, rows).Info("数据库操作完成")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *safeLogger) traceEntry(ctx context.Context, elapsed time.Duration, rows int64) *logrus.Entry {
|
||||||
|
return logrus.WithContext(ctx).WithFields(logrus.Fields{
|
||||||
|
"component": "gorm",
|
||||||
|
"elapsed_ms": float64(elapsed.Nanoseconds()) / 1e6,
|
||||||
|
"rows_affected": rows,
|
||||||
|
})
|
||||||
|
}
|
||||||
+32
-2
@@ -7,9 +7,17 @@ import (
|
|||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/tamakyi/TamaBox/internal/conf"
|
||||||
"github.com/tamakyi/TamaBox/internal/dbutil"
|
"github.com/tamakyi/TamaBox/internal/dbutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// minChatMessagesPerPage 是会话消息单次加载的兜底条数。
|
||||||
|
//
|
||||||
|
// 正常情况下取 conf.Limits.ChatMaxMessages(默认 200,即一次把整个会话读满)。
|
||||||
|
// 这个常量只在「配置未初始化」(零值,测试里直接构造 store 时会遇到)时生效,
|
||||||
|
// 不会覆盖箱主显式配置的更大或更小的值。
|
||||||
|
const minChatMessagesPerPage = 50
|
||||||
|
|
||||||
var Messages MessageStore
|
var Messages MessageStore
|
||||||
|
|
||||||
type MessageStore interface {
|
type MessageStore interface {
|
||||||
@@ -60,20 +68,42 @@ func (db *messages) GetByConversationID(ctx context.Context, conversationID uint
|
|||||||
var messages []*Message
|
var messages []*Message
|
||||||
q := db.WithContext(ctx).Where("conversation_id = ?", conversationID)
|
q := db.WithContext(ctx).Where("conversation_id = ?", conversationID)
|
||||||
|
|
||||||
limit := 50
|
// 默认一次取满整个会话,而不是固定 50 条。
|
||||||
|
//
|
||||||
|
// 原先写死 limit=50 且按 created_at ASC 取,取到的是「最早的 50 条」;
|
||||||
|
// 而会话上限是 conf.Limits.ChatMaxMessages(默认 200)。结果是第 51 条起
|
||||||
|
// 写库成功、message_count 在涨、页面显示 51/200,但列表里永远没有它 ——
|
||||||
|
// 用户无论等多久、换设备、重新登录都看不到(实测 55 条时只能渲染 #1–#50)。
|
||||||
|
limit := conf.Limits.ChatMaxMessages
|
||||||
|
if limit <= 0 {
|
||||||
|
// 仅兜底「配置未初始化」(零值)这一种情况。
|
||||||
|
// 不能写成 limit < 50 → 50:那样会把箱主显式配的小上限抬大,
|
||||||
|
// 属于替用户改配置,而不是补默认值。
|
||||||
|
limit = minChatMessagesPerPage
|
||||||
|
}
|
||||||
if cursor != nil {
|
if cursor != nil {
|
||||||
if cursor.Value != nil && fmt.Sprintf("%v", cursor.Value) != "" {
|
if cursor.Value != nil && fmt.Sprintf("%v", cursor.Value) != "" {
|
||||||
|
// 游标语义是「加载更早的消息」,与下面的 ORDER BY id DESC 配套;
|
||||||
|
// 若沿用旧的 ASC 排序,id < cursor 每次都会命中同一批最老的消息。
|
||||||
q = q.Where("id < ?", cursor.Value)
|
q = q.Where("id < ?", cursor.Value)
|
||||||
}
|
}
|
||||||
limit = cursor.Limit()
|
limit = cursor.Limit()
|
||||||
}
|
}
|
||||||
|
|
||||||
q = q.Order("created_at ASC")
|
// 按 id 倒序取最新 limit 条,再反转成时间正序交给视图层
|
||||||
|
// (气泡从上到下 = 旧 → 新)。
|
||||||
|
//
|
||||||
|
// 排序键用 id 而非 created_at:同一毫秒内写入的多条消息 created_at 完全相同,
|
||||||
|
// 排序不稳定会导致同一页里顺序来回变;id 自增与写入顺序严格一致。
|
||||||
|
q = q.Order("id DESC")
|
||||||
q = q.Limit(limit)
|
q = q.Limit(limit)
|
||||||
|
|
||||||
if err := q.Find(&messages).Error; err != nil {
|
if err := q.Find(&messages).Error; err != nil {
|
||||||
return nil, errors.Wrap(err, "get messages")
|
return nil, errors.Wrap(err, "get messages")
|
||||||
}
|
}
|
||||||
|
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
messages[i], messages[j] = messages[j], messages[i]
|
||||||
|
}
|
||||||
return messages, nil
|
return messages, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type MessageImage struct {
|
|||||||
gorm.Model
|
gorm.Model
|
||||||
MessageID uint `gorm:"index" json:"message_id"`
|
MessageID uint `gorm:"index" json:"message_id"`
|
||||||
URL string `gorm:"size:500" json:"url"`
|
URL string `gorm:"size:500" json:"url"`
|
||||||
|
ObjectKey string `gorm:"type:text" json:"-"`
|
||||||
FileName string `gorm:"size:200" json:"file_name"`
|
FileName string `gorm:"size:200" json:"file_name"`
|
||||||
FileSize int64 `json:"file_size"`
|
FileSize int64 `json:"file_size"`
|
||||||
}
|
}
|
||||||
@@ -33,6 +34,7 @@ type MessageImage struct {
|
|||||||
type CreateMessageImageOptions struct {
|
type CreateMessageImageOptions struct {
|
||||||
MessageID uint
|
MessageID uint
|
||||||
URL string
|
URL string
|
||||||
|
ObjectKey string
|
||||||
FileName string
|
FileName string
|
||||||
FileSize int64
|
FileSize int64
|
||||||
}
|
}
|
||||||
@@ -41,6 +43,7 @@ func (db *messageImages) Create(ctx context.Context, opts CreateMessageImageOpti
|
|||||||
img := &MessageImage{
|
img := &MessageImage{
|
||||||
MessageID: opts.MessageID,
|
MessageID: opts.MessageID,
|
||||||
URL: opts.URL,
|
URL: opts.URL,
|
||||||
|
ObjectKey: opts.ObjectKey,
|
||||||
FileName: opts.FileName,
|
FileName: opts.FileName,
|
||||||
FileSize: opts.FileSize,
|
FileSize: opts.FileSize,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMessageImageCreatePersistsObjectKey(t *testing.T) {
|
||||||
|
conn, _ := newTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
store := NewMessageImageStore(conn)
|
||||||
|
|
||||||
|
created, err := store.Create(ctx, CreateMessageImageOptions{
|
||||||
|
MessageID: 42,
|
||||||
|
URL: "https://cdn.example.test/assets/2026/11/private.webp",
|
||||||
|
ObjectKey: "2026/11/private.webp",
|
||||||
|
FileName: "private.webp",
|
||||||
|
FileSize: 1234,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "2026/11/private.webp", created.ObjectKey)
|
||||||
|
|
||||||
|
var persisted MessageImage
|
||||||
|
require.NoError(t, conn.WithContext(ctx).First(&persisted, created.ID).Error)
|
||||||
|
require.Equal(t, created.ObjectKey, persisted.ObjectKey)
|
||||||
|
byMessage, err := store.GetByMessageIDs(ctx, []uint{42})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, byMessage[42], 1)
|
||||||
|
require.Equal(t, created.ObjectKey, byMessage[42][0].ObjectKey)
|
||||||
|
|
||||||
|
encoded, err := json.Marshal(&persisted)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotContains(t, string(encoded), "ObjectKey")
|
||||||
|
require.NotContains(t, string(encoded), "object_key")
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// Copyright 2026 tamakyi. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/tamakyi/TamaBox/internal/conf"
|
||||||
|
"github.com/tamakyi/TamaBox/internal/dbutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGetByConversationIDReturnsLatest 是本轮修复的回归测试。
|
||||||
|
//
|
||||||
|
// 修复前的行为:Order("created_at ASC") + Limit(50),取的是「最早的 50 条」。
|
||||||
|
// 会话上限是 200,所以第 51 条起写库成功、计数在涨,但列表里永远看不到 ——
|
||||||
|
// 实测 55 条会话只能渲染 #1–#50,双方都以为对方没回。
|
||||||
|
//
|
||||||
|
// 现在要求:默认一次取满会话上限,且返回的是「最新」的那一批。
|
||||||
|
func TestGetByConversationIDReturnsLatest(t *testing.T) {
|
||||||
|
database, cleanup := newTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := cleanup("messages", "conversations"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
conv := &Conversation{OwnerID: 1}
|
||||||
|
require.NoError(t, database.Create(conv).Error)
|
||||||
|
|
||||||
|
const total = 55
|
||||||
|
for i := 1; i <= total; i++ {
|
||||||
|
require.NoError(t, database.Create(&Message{
|
||||||
|
ConversationID: conv.ID,
|
||||||
|
SenderID: 0,
|
||||||
|
Content: fmt.Sprintf("msg #%d", i),
|
||||||
|
}).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
store := NewMessageStore(database)
|
||||||
|
got, err := store.GetByConversationID(ctx, conv.ID, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// ① 不再被截断在 50 条
|
||||||
|
require.Len(t, got, total, "整个会话都应可见,而不是只取最早的 50 条")
|
||||||
|
|
||||||
|
// ② 顺序是时间正序(视图层气泡从上到下=旧→新)
|
||||||
|
require.Equal(t, "msg #1", got[0].Content)
|
||||||
|
require.Equal(t, fmt.Sprintf("msg #%d", total), got[len(got)-1].Content)
|
||||||
|
for i := 1; i < len(got); i++ {
|
||||||
|
require.Less(t, got[i-1].ID, got[i].ID, "返回结果必须按 id 升序,否则气泡顺序会乱")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetByConversationIDKeepsNewestWhenOverLimit 覆盖「消息数超过单次加载上限」的场景。
|
||||||
|
//
|
||||||
|
// 此时必须丢最老的、保最新的 —— 这正是修复前反过来的地方。
|
||||||
|
func TestGetByConversationIDKeepsNewestWhenOverLimit(t *testing.T) {
|
||||||
|
database, cleanup := newTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := cleanup("messages", "conversations"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 临时把上限压到 10,避免造 200+ 条拖慢测试
|
||||||
|
original := conf.Limits.ChatMaxMessages
|
||||||
|
conf.Limits.ChatMaxMessages = 10
|
||||||
|
t.Cleanup(func() { conf.Limits.ChatMaxMessages = original })
|
||||||
|
|
||||||
|
conv := &Conversation{OwnerID: 1}
|
||||||
|
require.NoError(t, database.Create(conv).Error)
|
||||||
|
|
||||||
|
const total = 15
|
||||||
|
for i := 1; i <= total; i++ {
|
||||||
|
require.NoError(t, database.Create(&Message{
|
||||||
|
ConversationID: conv.ID,
|
||||||
|
SenderID: 0,
|
||||||
|
Content: fmt.Sprintf("msg #%d", i),
|
||||||
|
}).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
store := NewMessageStore(database)
|
||||||
|
got, err := store.GetByConversationID(ctx, conv.ID, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Len(t, got, 10, "超过上限时应只取上限条数")
|
||||||
|
// 保最新:应拿到 #6–#15,#1–#5 被丢弃
|
||||||
|
require.Equal(t, "msg #6", got[0].Content)
|
||||||
|
require.Equal(t, "msg #15", got[len(got)-1].Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetByConversationIDCursorLoadsOlder 覆盖「加载更早消息」的游标语义。
|
||||||
|
//
|
||||||
|
// 游标必须与 ORDER BY id DESC 配套;修复前 cursor 走 id < 但排序是 ASC,
|
||||||
|
// 接上「加载更多」后会反复返回同一批最老的消息。
|
||||||
|
func TestGetByConversationIDCursorLoadsOlder(t *testing.T) {
|
||||||
|
database, cleanup := newTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := cleanup("messages", "conversations"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
conv := &Conversation{OwnerID: 1}
|
||||||
|
require.NoError(t, database.Create(conv).Error)
|
||||||
|
|
||||||
|
const total = 12
|
||||||
|
ids := make([]uint, 0, total)
|
||||||
|
for i := 1; i <= total; i++ {
|
||||||
|
m := &Message{ConversationID: conv.ID, SenderID: 0, Content: fmt.Sprintf("msg #%d", i)}
|
||||||
|
require.NoError(t, database.Create(m).Error)
|
||||||
|
ids = append(ids, m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
store := NewMessageStore(database)
|
||||||
|
|
||||||
|
// 以第 7 条的 id 为界,往前取 3 条 → 应得到 #4–#6
|
||||||
|
got, err := store.GetByConversationID(ctx, conv.ID, &dbutil.Cursor{Value: ids[6], PageSize: 3})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 3)
|
||||||
|
require.Equal(t, "msg #4", got[0].Content)
|
||||||
|
require.Equal(t, "msg #6", got[2].Content)
|
||||||
|
|
||||||
|
// 再往前翻一页:以第 4 条的 id 为界取 3 条 → #1–#3
|
||||||
|
got, err = store.GetByConversationID(ctx, conv.ID, &dbutil.Cursor{Value: ids[3], PageSize: 3})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 3)
|
||||||
|
require.Equal(t, "msg #1", got[0].Content)
|
||||||
|
require.Equal(t, "msg #3", got[2].Content)
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SchemaMigration 记录一次已应用的数据库迁移。一行 = 一个已跑过的版本。
|
||||||
|
// 这是迁移机制的账本:启动时按版本号升序执行「账本里还没有」的迁移,已应用的跳过。
|
||||||
|
//
|
||||||
|
// 为什么需要它:GORM 的 AutoMigrate 只会「加表 / 加列 / 加索引」,从不「改列名 /
|
||||||
|
// 删列 / 改约束」。任何破坏性 schema 变更若靠 AutoMigrate 的 HasColumn 判断或幂等补丁
|
||||||
|
// 硬撑,都会在「改列名 → 新旧两列并存、新列恒空」这种形态下静默丢数据,而且没有任何
|
||||||
|
// 地方记录「这个库已经跑过哪些变更」。版本表把「跑过什么、顺序如何」固化下来,
|
||||||
|
// 让迁移可解释、可回看、可审计。
|
||||||
|
type SchemaMigration struct {
|
||||||
|
// Version 单调递增的版本号,迁移按它升序执行。
|
||||||
|
Version int `gorm:"primaryKey"`
|
||||||
|
// Name 迁移的简短描述,用于日志与人工排查。
|
||||||
|
Name string `gorm:"type:varchar(128)"`
|
||||||
|
// AppliedAt 应用时间,仅供审计。
|
||||||
|
AppliedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration 一个数据库迁移步骤。
|
||||||
|
type Migration struct {
|
||||||
|
Version int
|
||||||
|
Name string
|
||||||
|
// Up 执行迁移。应当尽量幂等,因为早期版本在收编进版本表之前可能已在旧流程里
|
||||||
|
// 跑过一次;靠版本账本保证「收编后只跑一次」。
|
||||||
|
Up func(db *gorm.DB) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrations 按版本号升序排列的迁移清单。新增迁移时追加到末尾并分配一个更大的版本号。
|
||||||
|
//
|
||||||
|
// 收编说明:下面这些迁移对应的函数原本散落在 db.Init 里,靠 HasColumn 判断或幂等硬撑,
|
||||||
|
// 每次启动都会重新执行一遍。收编进版本表后,它们只在「该版本号尚未记录」时执行一次,
|
||||||
|
// 之后启动直接跳过 —— 既省掉无谓的全表扫描,也消除了「幂等补丁某天不小心变得不幂等」
|
||||||
|
// 时对历史数据的重复改写风险。
|
||||||
|
var migrations = []Migration{
|
||||||
|
{
|
||||||
|
Version: 1,
|
||||||
|
Name: "backfill questions.answered_at",
|
||||||
|
Up: backfillAnsweredAt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: 2,
|
||||||
|
Name: "backfill feature_controls",
|
||||||
|
Up: backfillFeatureControls,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: 3,
|
||||||
|
Name: "reconcile legacy chat enabled",
|
||||||
|
Up: reconcileLegacyChatEnabled,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: 4,
|
||||||
|
Name: "normalize chat quota",
|
||||||
|
Up: normalizeChatQuota,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: 5,
|
||||||
|
Name: "normalize harassment setting",
|
||||||
|
Up: normalizeHarassmentSetting,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 新增列 AutoMigrate 只补 0,而 0 与「无授权」的零值撞车 —— 必须回填,
|
||||||
|
// 否则升级后存量用户的页面密码会被静默绕过。
|
||||||
|
Version: 6,
|
||||||
|
Name: "backfill page password version",
|
||||||
|
Up: backfillPagePasswordVer,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// runMigrations 按版本号升序执行尚未应用的迁移。
|
||||||
|
//
|
||||||
|
// 执行模型:先建账本表(若无),读取已应用版本集合;对每个未应用的迁移,
|
||||||
|
// 在事务里跑 Up 并写入账本行。任一步失败即返回错误并终止后续迁移 —— 迁移是有序依赖的,
|
||||||
|
// 半途而废比硬着头皮跳过更安全(调用方会阻断启动,让运维看到明确的失败版本)。
|
||||||
|
//
|
||||||
|
// 与 AutoMigrate 的分工:AutoMigrate 仍负责「新增表 / 加列 / 加索引」这类声明式、
|
||||||
|
// 幂等、无数据丢失风险的变更(它本来就擅长且安全);版本迁移负责 AutoMigrate
|
||||||
|
// 做不了的破坏性变更与一次性数据回填。两者在 db.Init 里顺序执行,互不替代。
|
||||||
|
func runMigrations(db *gorm.DB) error {
|
||||||
|
if err := db.AutoMigrate(&SchemaMigration{}); err != nil {
|
||||||
|
return errors.Wrap(err, "create schema_migrations table")
|
||||||
|
}
|
||||||
|
|
||||||
|
var applied []SchemaMigration
|
||||||
|
if err := db.Order("version ASC").Find(&applied).Error; err != nil {
|
||||||
|
return errors.Wrap(err, "load applied migrations")
|
||||||
|
}
|
||||||
|
appliedSet := make(map[int]bool, len(applied))
|
||||||
|
for _, m := range applied {
|
||||||
|
appliedSet[m.Version] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防御:清单必须按版本号严格升序且无重复,否则顺序执行会出错、账本会混乱。
|
||||||
|
ordered := make([]Migration, len(migrations))
|
||||||
|
copy(ordered, migrations)
|
||||||
|
sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].Version < ordered[j].Version })
|
||||||
|
for i := 1; i < len(ordered); i++ {
|
||||||
|
if ordered[i].Version == ordered[i-1].Version {
|
||||||
|
return errors.Errorf("duplicate migration version %d", ordered[i].Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range ordered {
|
||||||
|
if appliedSet[m.Version] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logrus.WithField("version", m.Version).WithField("name", m.Name).
|
||||||
|
Info("应用数据库迁移")
|
||||||
|
|
||||||
|
// 迁移必须在一个事务里完成:Up 成功 + 记版本要么同时生效,要么都不生效。
|
||||||
|
// 否则进程在「Up 成功、账本未记」之间崩溃,重启后会重复执行一次迁移。
|
||||||
|
err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := m.Up(tx); err != nil {
|
||||||
|
return errors.Wrapf(err, "run migration %d (%s)", m.Version, m.Name)
|
||||||
|
}
|
||||||
|
rec := SchemaMigration{Version: m.Version, Name: m.Name, AppliedAt: time.Now()}
|
||||||
|
if err := tx.Create(&rec).Error; err != nil {
|
||||||
|
return errors.Wrapf(err, "record migration %d", m.Version)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppliedMigrations 返回已应用的迁移版本号集合,供测试与运维查询。
|
||||||
|
func AppliedMigrations(db *gorm.DB) (map[int]bool, error) {
|
||||||
|
var rows []SchemaMigration
|
||||||
|
if err := db.Order("version ASC").Find(&rows).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(err, "load applied migrations")
|
||||||
|
}
|
||||||
|
set := make(map[int]bool, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
set[r.Version] = true
|
||||||
|
}
|
||||||
|
return set, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRunMigrationsIdempotent 迁移账本幂等:同一组迁移跑两遍,第二遍不重复执行。
|
||||||
|
func TestRunMigrationsIdempotent(t *testing.T) {
|
||||||
|
conn, clean := newTestDB(t)
|
||||||
|
defer clean("schema_migrations", "users", "feature_controls")
|
||||||
|
|
||||||
|
// 第一遍:全部迁移都未应用,应执行并落账本。
|
||||||
|
require.NoError(t, runMigrations(conn))
|
||||||
|
|
||||||
|
applied, err := AppliedMigrations(conn)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, applied, len(migrations), "第一遍后所有版本都应已记录")
|
||||||
|
|
||||||
|
// 第二遍:账本齐全,runMigrations 应立即返回且不报错。
|
||||||
|
// 若已应用版本被重复执行,迁移事务会因主键冲突报错 —— 无错跑完即证明跳过了。
|
||||||
|
require.NoError(t, runMigrations(conn))
|
||||||
|
|
||||||
|
applied2, err := AppliedMigrations(conn)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, applied, applied2, "重复执行不应改变账本")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunMigrationsStopsOnFailure 迁移失败必须阻断:失败版本不落账本、后续版本不执行。
|
||||||
|
func TestRunMigrationsStopsOnFailure(t *testing.T) {
|
||||||
|
conn, clean := newTestDB(t)
|
||||||
|
defer clean("schema_migrations", "users", "feature_controls")
|
||||||
|
|
||||||
|
// 先建账本表(runMigrations 内部会建,但这里手动跑一次无迁移场景需要显式建表)。
|
||||||
|
require.NoError(t, conn.AutoMigrate(&SchemaMigration{}))
|
||||||
|
|
||||||
|
// 用一套独立的迁移清单模拟「第二个迁移失败」:
|
||||||
|
// v100 成功,v101 失败。预期:v100 落账本,v101 不落,且整体返回错误。
|
||||||
|
saved := migrations
|
||||||
|
defer func() { migrations = saved }()
|
||||||
|
|
||||||
|
migrations = []Migration{
|
||||||
|
{Version: 100, Name: "ok", Up: func(*gorm.DB) error { return nil }},
|
||||||
|
{Version: 101, Name: "boom", Up: func(*gorm.DB) error { return errors.New("intentional") }},
|
||||||
|
{Version: 102, Name: "never-runs", Up: func(*gorm.DB) error {
|
||||||
|
t.Error("v102 不应执行,因为 v101 已失败")
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := runMigrations(conn)
|
||||||
|
require.Error(t, err, "迁移失败必须返回错误")
|
||||||
|
|
||||||
|
applied, aErr := AppliedMigrations(conn)
|
||||||
|
require.NoError(t, aErr)
|
||||||
|
require.True(t, applied[100], "成功的 v100 应已落账本")
|
||||||
|
require.False(t, applied[101], "失败的 v101 不应落账本")
|
||||||
|
require.False(t, applied[102], "被阻断的 v102 不应落账本")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMigrationsOrdered 迁移清单必须按版本号严格升序且无重复。
|
||||||
|
func TestMigrationsOrdered(t *testing.T) {
|
||||||
|
for i := 1; i < len(migrations); i++ {
|
||||||
|
require.Less(t, migrations[i-1].Version, migrations[i].Version,
|
||||||
|
"迁移清单必须按版本号严格升序,第 %d 个版本 %d 不小于第 %d 个版本 %d",
|
||||||
|
i, migrations[i].Version, i-1, migrations[i-1].Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBackfillPagePasswordVer 覆盖 2026-11-05 那个 P0 的数据面:
|
||||||
|
// 新增列 AutoMigrate 只补 0,而 0 恰好等于「session 里没有授权记录」的零值,
|
||||||
|
// 不回填的话存量页面密码在升级瞬间全部失效。
|
||||||
|
func TestBackfillPagePasswordVer(t *testing.T) {
|
||||||
|
conn, clean := newTestDB(t)
|
||||||
|
defer clean("users")
|
||||||
|
|
||||||
|
mustCreate := func(domain string, pagePassword string, pageVer uint, askPassword string, askVer uint) *User {
|
||||||
|
u := &User{
|
||||||
|
Name: domain,
|
||||||
|
Email: domain + "@example.com",
|
||||||
|
Password: "x",
|
||||||
|
Domain: domain,
|
||||||
|
PagePassword: pagePassword,
|
||||||
|
PagePasswordVer: pageVer,
|
||||||
|
AskPassword: askPassword,
|
||||||
|
AskPasswordVer: askVer,
|
||||||
|
}
|
||||||
|
require.NoError(t, conn.Create(u).Error)
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存量:有密码但版本号仍为默认 0 —— 必须被回填。
|
||||||
|
legacy := mustCreate("legacy", "hashed-page", 0, "hashed-ask", 0)
|
||||||
|
// 已轮换过密码:版本号非 0,不能被重置。
|
||||||
|
rotated := mustCreate("rotated", "hashed-page", 3, "hashed-ask", 5)
|
||||||
|
// 没有密码:版本号保持 0,回填不应凭空造出授权。
|
||||||
|
noPassword := mustCreate("nopassword", "", 0, "", 0)
|
||||||
|
|
||||||
|
require.NoError(t, backfillPagePasswordVer(conn))
|
||||||
|
|
||||||
|
var gotLegacy, gotRotated, gotNoPassword User
|
||||||
|
require.NoError(t, conn.First(&gotLegacy, legacy.ID).Error)
|
||||||
|
require.NoError(t, conn.First(&gotRotated, rotated.ID).Error)
|
||||||
|
require.NoError(t, conn.First(&gotNoPassword, noPassword.ID).Error)
|
||||||
|
|
||||||
|
require.EqualValues(t, 1, gotLegacy.PagePasswordVer, "存量页面密码应回填为 1")
|
||||||
|
require.EqualValues(t, 1, gotLegacy.AskPasswordVer, "存量提问密码应回填为 1")
|
||||||
|
|
||||||
|
require.EqualValues(t, 3, gotRotated.PagePasswordVer, "已轮换的版本号不能被覆盖")
|
||||||
|
require.EqualValues(t, 5, gotRotated.AskPasswordVer, "已轮换的版本号不能被覆盖")
|
||||||
|
|
||||||
|
require.EqualValues(t, 0, gotNoPassword.PagePasswordVer, "没设密码的账号不应被写版本号")
|
||||||
|
require.EqualValues(t, 0, gotNoPassword.AskPasswordVer, "没设密码的账号不应被写版本号")
|
||||||
|
|
||||||
|
// 幂等:再跑一次不应把已回填的行推到 2。
|
||||||
|
require.NoError(t, backfillPagePasswordVer(conn))
|
||||||
|
require.NoError(t, conn.First(&gotLegacy, legacy.ID).Error)
|
||||||
|
require.EqualValues(t, 1, gotLegacy.PagePasswordVer, "迁移必须幂等")
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Copyright 2026 tamakyi. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestQuestionDeleteCascadesHardDelete 验证删除提问时,追问与上传图片也被**真删**,
|
||||||
|
// 而不是软删残留。
|
||||||
|
//
|
||||||
|
// 背景:questions.DeleteByID 对主表用 Unscoped 真删,但历史上一度只有主表真删、
|
||||||
|
// 追问/图片仍软删,导致提问正文删干净了、追问文本与图片元数据却带着 deleted_at
|
||||||
|
// 永久留在库里(这正是「真删以免个人数据删不干净」想消灭的问题,却在子表漏改)。
|
||||||
|
// 本测试用 Unscoped 计数兜底:软删的行在普通查询里不可见,只有 Unscoped 才能看到,
|
||||||
|
// 因此断言「删除后 Unscoped 计数 = 0」才能证明是真删。
|
||||||
|
func TestQuestionDeleteCascadesHardDelete(t *testing.T) {
|
||||||
|
db, cleanup := newTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
questions := NewQuestionsStore(db)
|
||||||
|
followUps := NewFollowUpsStore(db)
|
||||||
|
uploadImages := NewUploadImagesStore(db)
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
// 三张表都要清:follow_ups / upload_images(含链接表)在删除后应该已经空了,
|
||||||
|
// 但其他子测试的 data 可能残留,用 cleanup 统一兜底。
|
||||||
|
_ = cleanup("questions", "follow_ups", "upload_images", "upload_image_questions")
|
||||||
|
})
|
||||||
|
|
||||||
|
// 建一条提问
|
||||||
|
q, err := questions.Create(ctx, CreateQuestionOptions{
|
||||||
|
FromIP: "114.5.1.4",
|
||||||
|
UserID: 1,
|
||||||
|
Content: "需要被彻底删除的提问",
|
||||||
|
AskerUserID: 1,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// 给它加一条追问
|
||||||
|
created, err := followUps.CreateIfBelowLimit(ctx, CreateFollowUpOptions{
|
||||||
|
QuestionID: q.ID,
|
||||||
|
Content: "追问内容,也应被真删",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, created)
|
||||||
|
|
||||||
|
// 给它加一张上传图片
|
||||||
|
img, err := uploadImages.Create(ctx, CreateUploadImageOptions{
|
||||||
|
Type: UploadImageQuestionTypeAsk,
|
||||||
|
QuestionID: q.ID,
|
||||||
|
UploaderUserID: 1,
|
||||||
|
Name: "secret.png",
|
||||||
|
FileSize: 1024,
|
||||||
|
Md5: "deadbeef",
|
||||||
|
Key: "2026/10/29/secret.png",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotZero(t, img.ID)
|
||||||
|
|
||||||
|
// 删除提问
|
||||||
|
require.NoError(t, questions.DeleteByID(ctx, q.ID))
|
||||||
|
|
||||||
|
// 主表:真删
|
||||||
|
require.Equal(t, int64(0), hardCount(t, db, &Question{}, "id = ?", q.ID))
|
||||||
|
|
||||||
|
// 追问:真删(软删的话这里会是 1)
|
||||||
|
require.Equal(t, int64(0), hardCount(t, db, &FollowUp{}, "question_id = ?", q.ID))
|
||||||
|
|
||||||
|
// 上传图片主体:真删
|
||||||
|
require.Equal(t, int64(0), hardCount(t, db, &UploadImage{}, "id = ?", img.ID))
|
||||||
|
|
||||||
|
// 关联行:真删
|
||||||
|
require.Equal(t, int64(0), hardCount(t, db, &UploadImageQuestion{}, "question_id = ?", q.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// hardCount 用 Unscoped 统计满足条件的行数,能穿透软删除过滤,
|
||||||
|
// 用于断言「物理上也不存在」。
|
||||||
|
func hardCount(t *testing.T, db *gorm.DB, model interface{}, where string, args ...interface{}) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var n int64
|
||||||
|
err := db.WithContext(context.Background()).Unscoped().
|
||||||
|
Model(model).
|
||||||
|
Where(where, args...).
|
||||||
|
Count(&n).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
return n
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user