安全: 修复凭据、遥测与账号数据清理风险

This commit is contained in:
tamakyi
2026-11-04 14:38:19 +08:00
parent c633789a92
commit 3e7719f200
19 changed files with 305 additions and 3378 deletions
+17 -2
View File
@@ -45,8 +45,23 @@ __pycache__/
# 本地凭据与一次性状态(测试账号密码等),只留在本机,不进版本库。
# 目录说明见 .workbuddy/local/test-accounts.md 的抬头注释。
/.workbuddy/local/
/.codeartsdoer/
# WSL 测试环境的 app.ini 模板:含数据库密码与对象存储密钥,绝不能进版本库。
# 用法见 dev/sync-wsl.sh(同步时必须排除 conf/app.ini,否则会被 rsync --delete 删掉)。
# 本地运行配置与 WSL 测试环境模板:含数据库密码与对象存储密钥,绝不能进版本库。
# dev/wsl-app.ini 的用法见 dev/sync-wsl.sh;同步时必须排除 conf/app.ini,避免被 rsync --delete 删掉。
/conf/app.ini
/dev/wsl-app.ini
/dev/_*.py
# 含真实测试凭据的历史记录与巡检脚本:保留本地文件,但不再纳入版本控制。
/.workbuddy/memory/MEMORY.md
/.workbuddy/memory/topics/build.md
/.workbuddy/memory/topics/sec.md
/.workbuddy/memory/topics/misc.md
/.workbuddy/memory/2026-09-15.md
/.workbuddy/memory/2026-10-19.md
/.workbuddy/memory/2026-10-22.md
/dev/devcheck-e2e.py
/dev/devcheck-avatar.py
/dev/devcheck-image.py
/dev/devcheck-upload.py
-421
View File
@@ -1,421 +0,0 @@
# 2026-09-15 工作日志
## 落地 P2 推荐批次 #1–3(审核链路一致性 / 文档)
承接 09-11 的全库审阅(无 P0/P1,8 项 P2)。上次征询修复批次未获选择,本次按「继续 +
我标注的建议先做」自主推进**最低风险的一致性/文档类 #1–3**,未动 #4–8(需另行确认)。
### 已改(`internal/security/censor/`)
1. **#1 缓存/送审归一化统一**(censor.go:66-68,76,122,131)
- 原先:缓存按**原文** `text` 查 `GetByText`,实际送审 `RemoveTrustedURL(text)`,落库 `Input:text`。
→ 审计记录与实际送审内容不符;归一化后相同的原文会各占一条缓存、重复调 API。
- 现在:循环外算一次 `normalized := RemoveTrustedURL(text)`,缓存查找 / 送审 / `Input` 三者统一。
- 安全性论证:归一化是纯函数确定性映射;`TrustedHosts` 增减只会让旧条目 miss(重新送审),
不会让「已通过」的旧缓存被错配到新文本上 → 无漏放风险。
2. **#2 阿里云缓存回放分支与七牛对齐**(censor.go:99-110)
- 原先:aliyun 命中缓存**不看 Pass 直接 return**,依赖「aliyun 恒为链尾」这一隐式前提。
- 现在:与 qiniu 一致 —— 仅 `Pass` 才 return,否则 append + continue 交给下一通道复核。
- 等价性:aliyun 现为链尾,continue 后循环结束仍由 `responses[len-1]` 返回**同一份响应**,行为不变;
但消除了顺序耦合,将来若前置 aliyun 也不会短路后续通道。
3. **#3 修正失真注释**(text.go:66)
- 原注释称 `Details`「回放缓存结果时为 nil」**是错的**:
`AliyunTextCensorParser`(aliyun.go:433-461) 与 `QiniuTextCensorParser`(qiniu.go:133-170)
都会从 `RawResponse` 重建 `Details` → 缓存回放同样受严格度策略约束,改档位对历史缓存也生效。
- 已改为准确描述。**结论:审核缓存回放无 fail-open**(09-11 的怀疑已排除)。
### 验证
- `gofmt_keep_crlf.py` 两个文件 exit=0,CRLF 保留(censor 147 / text 141,loneLF=0)。
- `go build ./internal/security/censor/...` **EXIT=0**(定向构建,~秒级;用 goproxy.cn + overlay)。
- `go test ./internal/security/censor/` **ok 2.467s** —— 确认严格度策略与回放行为未被破坏。
### 顺带已完成 #6
- **#6 `User.Email` 改 `json:"-"`**(users.go:89)。
查证:`route/` 与 `internal/` 中**无任何非测试代码读取序列化后的 `.Email`**(模板里的 email 均为表单字段名 /
邮件模板,属 Go template 服务端渲染,不受 json tag 影响;输入走 `CreateUserOptions`/`UpdateUserOptions`,
不经 `User` 模型反序列化)→ 无消费方受损。
验证:gofmt exit=0,`go build ./internal/db/` **EXIT=0**。
### 待确认(P2 #4/#5/#7/#8)
| # | 项 | 我的建议 |
|---|---|---|
| 4 | 投票配额「插入后计数」非原子(route/vote/vote.go:814-822) | 暂缓:需 DB 层条件插入,超额有界且自动结束自愈 |
| 5 | ~~profile Name/Intro/link 无长度校验~~ | **误报,已关闭**:表单层已有校验,见下方更正 |
| 7 | `conf.Database.Schema` 拼 CREATE SEQUENCE(db.go:215-222) | 可选:配置来源非远端输入,纵深防御 |
| 8 | `RemoveTrustedURL` 24 次整串重建(text.go:114-141) | 可跳过:短文本开销可忽略(#1 已顺带把调用从循环内降到 1 次) |
#### ✅ #5 更正:是**误报**,已按「报错式校验」实现后**全部回滚**
- 先按用户选择实现了一版(Name 50 / Intro 500 / link 500 rune,4 语 i18n 词条),随后核对
`internal/form/user.go` 时发现**表单层早已有校验**:
- `Name` → `valid:"required;maxlen:20"`
- `Intro` → `valid:"required;maxlen:100"`
- 各 link → `valid:"maxlen:30"`
- 且确实生效:`internal/form/form.go:16` 引入 `govalid`,`:131` 在 `Bind()` 中 `govalid.Check()`;
handler 顶部 `ctx.HasError()` 承接;报错文案走标签 `msg:` 对应的 i18n 键
(`form.profile.name`="不超过 20 个字符"、`form.profile.intro`=100、`form.profile.linkMax`=30)。
- 结论:我那版是**永不触发的死代码**(超限在 Bind 阶段就被拒),且上限与真实值冲突 → 已完整回滚
(profile.go 的 utf8 导入 / 常量 / 校验块 + 4 份字典词条),`git status` 确认 5 个文件零 diff。
- 教训已写入 MEMORY.md「表单校验(勿重复造轮子)」:查某字段有无校验,**先看 `internal/form/*.go` 的 struct 标签**。
- 附带修正:Postgres 下 `string` 映射为 `text`(无 DB 层长度上限)这一点本身**没错**,
但不构成漏洞 —— 因为表单层已经挡住了。
### 环境
- ~~重包 `./route/user/ ./internal/route/ ./internal/db/` 仍会 30 分钟超时 → 判定为 1C1G 沙箱编译整图慢~~
**❌ 该结论已被推翻,见文末「编译卡死」误判更正。全库 `go build ./...` 实测 EXIT=0。**
---
## 「编译卡死」误判更正(用户指出:1C1G 是正式环境,不是沙箱)
用户明确纠正:**1C1G 是正式生产环境**,我不该拿「沙箱慢」当挡箭牌解释构建超时。重新取证后确认:
**根本没有编译问题,是我把代理挂起误判成了编译慢。**
### 取证过程
| 步骤 | 命令 | 结果 | 结论 |
|---|---|---|---|
| 隔离模块解析 | `go list -deps ./route/user/` | **2.3s EXIT=0** | 模块全在本地缓存,网络不是瓶颈 |
| 编译目标包 | `go build -overlay=... ./route/user/` | **7s EXIT=0** | 之前 240s 超时是伪命题 |
| 关键包逐个 | route/ internal/route/ question/ auth/ security/... db/ mail/ | **全 OK** | |
| 全量 | `go build -overlay=... ./...` | **EXIT=0** | |
| 静态检查 | `go vet -overlay=...` 7 个包 | **EXIT=0** | |
| 测试 | censor/autoban/ipinfo/route/conf/form | **全部 ok**,EXIT=0 | |
**真因**:前几次超时是 `GOPROXY` 未导出 → 卡在 `proxy.golang.org` 连接超时(HTTP=000)。
与机器性能、与 1C1G 都无关。**只要每条 go 命令前 `export GOPROXY=https://goproxy.cn,direct
GOSUMDB=off GOFLAGS=-mod=mod` 就秒级完成。**
⚠️ 方法论教训:**超时 ≠ 慢**。要用 `go list -deps`(纯元数据、不编译)先把「模块解析」和
「编译」两层拆开,否则会把网络挂起误判成性能问题,进而拿「环境」当借口。
---
## 复核:追问邮件 token + 其他严重错误
### 1. 追问邮件确实没带 token(用户怀疑成立)
- `internal/mail/mail.go:58-65` `SendNewFollowUpMail` 链接 `%s/_/%s/%d`,**无 `?t=`**。
- 收件人是**箱主** `pageUser.Email`(follow_up.go:131),箱主靠登录态查看 → **不是权限漏洞**。
- 但有两处真实问题:
1. **不一致**:同样发给箱主的 `SendNewQuestionMail`(mail.go:25)**带** `?t=`。
2. **具体失效场景**:若该提问被箱主设为私密(`IsPrivate=true`),箱主在**未登录设备**点邮件链接
→ `Questioner` 中间件(question.go:61-66)判定 `IsPrivate && !IsLogged && token 不匹配`
→ **302 回首页,看不到追问**。新提问邮件因带 token 则不会。
- 建议:给 `SendNewFollowUpMail` 补 token 参数,与 `SendNewQuestionMail` 对齐。
- 关键的那封(**发给匿名提问者的回答通知**)`question.ReceiveReplyEmail` 是**带 token 的**:
follow_up.go:185、question.go:214 → `SendNewAnswerMail(..., question.Token)` ✓ 无问题。
### 2. 新发现 P2:提问 token 仅 6 位且爆破无限流
- `internal/db/questions.go:95` `Token: randstr.String(6)`;`randstr` 用 `crypto/rand` +
base62 → 62⁶ ≈ 5.68×10¹⁰,**仅 35.7 bit**(随机源没问题,是长度短)。
- `GET /_/{domain}/{questionID}?t=` **无 rateLimitMiddleware**;猜错由 `Questioner` 返回
**302 → /**,而 `autoban` 只统计「敏感路径」与「404」(404 默认关闭)→ **完全不计入封禁**。
- token 仅在「未回答 或 私密」且非箱主时才需要,攻破即=读私密/未公开提问。
- 可行性:单条定向 ~2.8×10¹⁰ 次不可行;但 questionID 可枚举、只需命中**任意一条**,
受保护提问 N 条时期望 5.68×10¹⁰/N 次 → 量大时理论可行。
- 低成本修复:`randstr.String(6)` → `randstr.String(12)`(≈71 bit)。存量 6 位 token 不受影响
(列是 string,无长度校验)。或对该 GET 路径加限流。
### 3. 已复核无问题(勿再重复查)
- **密码**(`internal/db/password.go`):bcrypt cost 10;旧 HMAC-SHA1 用 `subtle.ConstantTimeCompare`
常量时间比较并透明升级;哈希失败**不降级**为弱哈希(返回空串=锁定);bcrypt 72 字节截断 ✓
- **越权**:`Delete/SetPrivate/SetPublic/TogglePin/SetRead/SetUnread` 全部由 `canDelete`
(question.go:72 `ctx.IsLogged && ctx.User.ID == pageUser.ID`)把关;
`AnswerFollowUp`(follow_up.go:143) 与 `PublishAnswer`(question.go:154,161) 双重校验 ✓
(`ctx.Redirect` 会写响应从而终止后续 handler,早退有效)
- **CSRF**:全局挂载,SessionKey=uid 正确,有回归测试 ✓
- **会话**:登录 RegenerateID、pwd_ver 失效、HTTPOnly + SameSite=Lax ✓
- **密码找回**:验证码 24h TTL + 用后即删 ✓
- **SQL 注入**:全参数化 ✓
- **token/邮箱泄露**:`Question.Token` 与 `User.Email` 均为 `json:"-"` ✓
- **审核**:提问/回答/追问/投票/私聊统一走 `moderation.Check` ✓
---
## 落地 A+B(追问邮件补 token + 提问 token 6→12)
按「继续未完成的任务」指示,执行上一条报告的 A+B 两项变更。已用 gofmt_keep_crlf 保 CRLF。
### A. 追问邮件(发给箱主)补 token,与 `SendNewQuestionMail` 对齐
- `internal/mail/mail.go:58` `SendNewFollowUpMail` 签名加 `token string`,链接改为 `%s/_/%s/%d?t=%s`。
- `route/question/follow_up.go:131` 调用处补传 `question.Token`。
- 目的:箱主在**未登录设备**点开**私密**提问的追问邮件时也能凭 token 查看(否则被 Questioner 重定向首页)。
- 仅一个调用方,构建已验证签名一致。
### B. 提问 token 长度 6 → 12
- `internal/db/questions.go:95` `randstr.String(6)` → `randstr.String(12)`(≈35.7→71 bit)。
- 存量 6 位 token 不受影响(列是 string,无长度校验,旧 token 仍有效)。
### 验证
- `go build ./route/question/ ./internal/mail/ ./internal/db/` → EXIT=0
- `go build ./...`(overlay)→ EXIT=0
- `go vet` 三包 → EXIT=0
- `internal/db` 单元测试 FAIL 为已知环境限制(需真实 PostgreSQL/MySQL,报 `Unknown database type: ""`),
与本次改动无关,可跳过;security/autoban/censor/ipinfo、route 测试均 ok。
### 待定(未做,按需)
- CHANGELOG.json 是否补一条 entry(未做:需确认版本号与条目格式)。
---
## 用本地 Postgres 跑通 db 测试并修真实缺陷(用户提供 127.0.0.1:5432)
### 测试库环境变量(`internal/dbutil/testing.go:NewTestDB`)
- `DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=<pwd> DB_HOST=127.0.0.1 DB_PORT=5432`
DSN = `postgres://$DB_USER:$DB_PASSWORD@$DB_HOST:$DB_PORT?sslmode=disable`(不带库名,连上后
自动 `CREATE DATABASE tamabox-test-<rand>`,测试结束(未失败时)DROP)。
- 仍需 `GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod` + webp overlay。
### 修的真实缺陷(生产代码,2 处)
- `internal/db/questions.go:DeleteByID`:级联删追问/上传图 + 回退提问配额时**直接调包级全局单例**
`FollowUps`/`UploadImages`/`FeatureControls`。这些单例只在 `db.Init` 装配 → 测试/部分初始化场景 nil → **panic**。
改为 `NewFollowUpsStore(db.DB)` / `NewUploadImagesStore(db.DB)` / `NewFeatureControlsStore(db.DB)`,
用自身 store 的 `*gorm.DB`。生产行为不变(`Init` 里所有 store 共用同一 `*gorm.DB`),但去掉了隐藏全局依赖。
- `internal/db/conversation.go:MarkDeletedByOwner`:同类问题(`FeatureControls.IncrementQuota`),同样修。
- 同包 `feature_control.go:355` 的 `FeatureControls.Get` **已有** `if FeatureControls == nil` 守卫,故未动。
### 修的测试缺陷(全在 internal/db,非生产 bug)
1. `users_test.go`:5 处 `want.EncodePassword(); require.Equal(t, want, got)` —— bcrypt 随机盐,
哈希**永不相等**。新建 helper `requireUserEqual`:先 `got.Authenticate(明文)` 校验密码,再对齐
`Password` 与 `ChatMaxMessages`(= `conf.Limits.ChatMaxMessages`,默认 200)后比较其余字段。
2. `testUsersDeactivate`:复用已被 `testUsersCreate` 占用的 `[email protected]`/`e99` → 改唯一值。
3. `users_test.go`/`upload_images_test.go` 去掉顶层 `t.Parallel()`:子测试共用一张表、cleanup 会
`TRUNCATE ... RESTART IDENTITY`,并行时多个子测试拿到相同 id/邮箱 → 撞唯一键。
4. `upload_images_test.go`:cleanup 只 truncate `upload_images`,而链接表 `upload_image_questions`
**无 FK 级联**(复合主键 `upload_image_id+question_id`),残留行让下一子测试撞 `_pkey`
→ 补 `cleanup("upload_images", "upload_image_questions")`。
5. `UpdateBlockWords/normal`:期望 `HarassmentSettingNone`("none")但新用户实际 `""`
(模型字段无 `gorm:"default:"` 标签)。`""` 与 `"none"` 生产行为等价(只有 `==register_only` 才限制;
模板 profile.html:851 也只在 `register_only` 时勾选)→ **非生产 bug**。改成先显式
`UpdateHarassmentType(RegisterOnly)` 再断言未被误改(更强的测试,且不把 `""` 奉为期望)。
### 验证
- `go build ./...` **EXIT=0**、`go vet ./...` **EXIT=0**
- `go test ./...`(含 DB)**全绿 EXIT=0**:internal/db 57s、route/vote、internal/route、templates、
i18n、conf、form、cache、imagecheck、mail、shared、template、security/* 均 ok。
- 行尾:`internal/db/*` 与 `internal/mail/mail.go` 原本就是 LF,`route/question/*`、`route/vote/vote.go`、
`internal/route/route.go` 是 CRLF —— **仓库本来就是混合行尾**;本次编辑未改变任何文件原行尾(无 diff 污染)。
### 结论
- 除上述「全局单例 nil 依赖」(健壮性隐患:测试与部分初始化场景必崩)外,**未发现新的生产逻辑 bug**。
- db 测试套件此前的一堆失败**全部是测试自身缺陷**:bcrypt 直接比哈希、测试数据撞车、并行共享表、漏清链接表。
---
## 修复 HarassmentSetting 潜在隐患(schema 默认值 + 启动期归一)
用户确认「影响不大就做」。改动面很小(3 处源码 + 1 处测试期望 + 1 个新回归测试)。
### 线索
- 模型 `HarassmentSetting HarassmentSettingType` **无 `gorm:"default:"` 标签** → 新用户该列为空串;
而规范取值是 none / register_only。全仓库**没有任何代码比较 `== HarassmentSettingNone`**
(production 只有 `== register_only`,模板 profile.html:851 也只看 register_only)→ **运行期无 bug**,
但将来若有人写 `== None` 判断就会漏判。
### 改动
1. `internal/db/users.go` 模型:`HarassmentSetting` 补 `gorm:"default:'none'"`。
⚠️ **字符串默认值必须带单引号**(`default:'none'`),参考既有 `ModerationStrictness` 的 `default:''`;
裸写 `default:none` 会生成非法 SQL。
2. `internal/db/users.go:Create`:显式 `HarassmentSetting: HarassmentSettingNone`
(确定写库,不依赖 DB 默认值回读)。
3. `internal/db/users.go`:新增 `normalizeHarassmentSetting(db)`,把历史空值(''/NULL)归一为 none,幂等。
4. `internal/db/db.go:Init`:在 `normalizeChatQuota` 之后调用,失败仅告警不阻断启动
(与 `backfillFeatureControls`/`reconcileLegacyChatEnabled` 等既有惯例一致)。
### 测试
- `users_test.go` helper `requireUserEqual` 补 `want.HarassmentSetting = HarassmentSettingNone`。
- 新增 `TestHarassmentSettingNormalize`:新建用户即 none + 历史空值经归一后为 none。
### 实测证据
- 临时探针查 `information_schema`:`harassment_setting` 的 `column_default = "'none'::text"` ✅
(AutoMigrate 确实把列默认值改成了 none;探针文件跑完即删)。
- `go build ./...` / `go vet ./...` EXIT=0;`go test ./...`(含 DB)**全绿**;gofmt 无 diff;
行尾未变(users.go CRLF、db.go/users_test.go LF)。
### ⚠️ 新踩到的 gofmt 陷阱
在**文档注释**里写 `''` 会被 gofmt 重排成 `”`(Go 1.19+ 的 doc comment 规范化:`` `` ``→`“`、`''`→`”`),
导致 `gofmt -l` 反复报未格式化。**注释里避免使用 `''`**(改写为「空字符串」等文字)。
---
## 账号停用体验修复:独立成功页(/deactivated)
### 问题
停用账号后 `DeactivateProfileAction` 把用户重定向到 `/login`,只在登录页顶部弹一条 flash。
用户会觉得「被丢回登录页」,下意识以为操作没生效。
诊断结论:flash **确实渲染**(Flush 清 session 数据但保留 session,flash 能带到下一页),
真正的体验错配是「停用后落点仍是登录页」——而账号已删/停,用户根本不可能再登录。
### 方案(用户选「独立成功页(推荐)」)
1. `route/user/profile.go`
- 新增公开 handler `DeactivatedProfile`:`ctx.SetTitle(i18n.T(ctx.Lang(), "deactivated.title"))` + `ctx.Success("user/deactivated")`。
- `DeactivateProfileAction` 末尾 `ctx.Redirect("/login")` → `ctx.Redirect("/deactivated")`,**并移除原来的 `SetSuccessFlash`**(页面自身即提示)。
- 顺手修正 `UpdateProfile` 里一处陈旧注释(原写「顺序与 DeactivateProfileAction 保持一致…随后跳登录页」,停用路径已不再如此)。
2. `internal/route/route.go`:在公开区(`/explore` 旁)加 `f.Get("/deactivated", user.DeactivatedProfile)`。
**必须是公开路由**:停用后会话已 Flush、`reqUserSignIn` 会把人弹回登录页,正是要避免的现象。
3. 新建 `templates/user/deactivated.html`:复用 `question/success.html` 的范式(大 ✓ 图标 + 卡片 + 单按钮返回首页)。
`templates/embed.go` 是整目录 `//go:embed ... user ...`,新模板**自动被嵌入**,无需改 embed 指令。
4. i18n:四份字典都加 `deactivated.title` / `deactivated.desc` / `deactivated.backHome`
(`TestDictionaryComplete` 要求 zh_cn/zh_tw/en/ja **键集合完全一致**,漏一份必挂)。
同时**删除已无引用的 `admin.flash.deactivated`**(4 份字典同步删;删它不影响对齐组,因为该组最长键是 `admin.flash.blockWordsTooMany`)。
### 验证
- `go build ./...` / `go vet ./...` EXIT=0;`go test ./...`(含本地 PG)**全绿**,
其中 `internal/i18n`(键一致性)、`internal/route`、`templates`、`internal/template`(模板解析)均 ok → 新模板可被解析、键齐备。
- gofmt 无 diff;行尾未变(4 份 dict + route.go 保持 CRLF,`route/user/profile.go` 实为 **LF**)。
### 备注
- `route/user/profile.go` 的行尾是 **LF**(与「route/** 皆 CRLF」的旧印象不符,按文件单独确认即可)。
---
## 🔴 P0 修复:账号停用后同一邮箱/域名永远无法重新注册
用户在测试环境 `https://box-dev-api.337845818.best` 注册 `1223383917@qq.com` / `tamakyi` 报 500:
```
SELECT ... WHERE email = '...' AND deleted_at IS NULL -> rows:0
INSERT INTO users ... -> ERROR: duplicate key value violates unique constraint "idx_users_email_unique" (SQLSTATE 23505)
ERRO Failed to create new user error="create user: ERROR: duplicate key ..."
```
### 根因(本地已实测复现,日志形态与线上一致)
`internal/db/users.go` 的 `Deactivate` 用的是 **GORM 默认的软删除**:
`db.WithContext(ctx).Model(&User{}).Delete("id = ?", u.ID)` → 只写 `deleted_at`,**行仍在库里**。
而 `User` 的 `email` / `domain` 是 `uniqueIndex`(**索引不含 deleted_at**),于是:
- 查重 `validate()` 走 gorm 软删除作用域 → 看不见这行 → 放行;
- INSERT 被数据库唯一索引拦下 → `Create` 的 TOCTOU 兜底再查一次仍看不见 → 抛底层 23505 → handler `default` 分支 → 500。
复现证据(临时测试,已删):停用后 `Unscoped` 查该邮箱 → 行仍在且 `deleted_at.Valid=true`;同邮箱重新注册 → 与线上同样的 23505。
**旁证**:`internal/cmd/ban.go:64` 早有注释「用 Unscoped 查找,支持封禁已软删除(自助停用)的账号」——
说明「自助停用会留软删除行」是既有事实,但没人把「唯一索引占位」这层后果接上。
同类先例:`internal/db/vote_submissions.go:234` 已因同一原因改用 Unscoped 物理删除。
顺带发现:`BanUserAndCleanup` 的「连带清理」也只是软删除子表(Question/FollowUp/Conversation/Message… 都嵌 `dbutil.Model`),
数据实际仍在库;也与停用页「所有数据将被删除」的承诺不符。
### 改动(`internal/db/users.go`)
1. `Deactivate` → 调 `db.purgeUser(ctx, u.ID)`;`BanUserAndCleanup` 同样委托给它。
2. 抽私有 `purgeUser`:级联删除**全部改为 `Unscoped()` 物理删除**(追问/消息/消息图片/会话/提问-图片关联/提问/拉黑/功能开关/用户行)。
3. `Create` 兜底加固:唯一键冲突且 `validate` 仍查不到时,用新增的 `duplicateFieldErr()`(Unscoped)判定是 email 还是 domain 被占,
返回 `ErrDuplicateEmail`/`ErrDuplicateDomain` 友好提示,而不是把 23505 抛成 500。(`isDuplicateKeyError` 复用 vote_submissions.go 里的)
4. 回归测试 `TestDeactivatePurgesUser`:停用后 Unscoped 也查不到用户行、提问被清理、同一邮箱可重新注册。
### ⚠️ 过程中自己踩的 GORM 大坑
把级联删除改成「先 `tx = tx.WithContext(ctx).Unscoped()` 再逐条 `tx.Where(...)`」——
**事务句柄 clone==0,复用同一个句柄会让上一条语句的 `Where` 条件串到下一条上**,
SQL 变成 `DELETE FROM follow_ups WHERE <follow_ups条件> AND <messages条件>` → `字段 conversation_id 不存在 (42703)`。
✅ 正确写法:**每条语句都重新取句柄** `tx.WithContext(ctx).Unscoped().Where(...)`(原代码本来就是每条 `tx.WithContext(ctx)`,是我改坏的)。
这条已写进 MEMORY.md。
### 验证
- `go build ./...` / `go vet ./...` EXIT=0;`go test ./...`(含本地 PG)**全绿**;gofmt clean;行尾未变(users.go CRLF、users_test.go LF)。
- 测试环境实测(脚本 `dev/devcheck-e2e.py`):
- ✅ 注册 / 登录 / `/user/profile` / `/user/questions` / `/user/votes`(302→`/user/questions#votes`) / 提问(返回提问成功页)/ 新建投票页 / 停用页 / **停用后跳 `/deactivated` 成功页** 全部正常;
- ✅ `/deactivated` 四语言标题渲染正确(账号已停用 / 帳號已停用 / Account deactivated / アカウントを停止しました);
- ❌ **停用后同邮箱重新注册仍 500** —— 测试环境跑的是**旧构建**(`/deactivated` 已在,但没有 purgeUser 修复),需重新部署。
- ⚠️ 测试环境 DB 里已留下本次测试产生的孤儿行,需清理:
`tbe2e0915122816@example.com`(仍在线)、`tbe2e0915122934@example.com`、`tbcheck0915122657@example.com`(均已停用=软删除占位);
用户自己的 `tamakyi` / `1223383917@qq.com` 同样是软删除占位(这就是他报错的直接原因)。
清理方式:`tamabox ban --email <邮箱> --yes`(CLI 支持查软删除账号),或直接 SQL 删 `users.deleted_at IS NOT NULL` 的行。
---
## ⚠️ 方案变更(覆盖上一节):停用改为「保留身份占位 + 清空内容」
用户表示**尚未部署**,并提出:「曾经注册过的账户停用后,再次注册时应提示『该邮箱已被占用但账户已停用,如需重新使用请联系站长』」。
经询问确认两点:
1. 停用形态 = **保留身份占位**(用户行软删除)+ **物理删除内容数据**(停用页「所有数据将被删除」的承诺仍然成立);
2. 「联系站长」**复用页脚已有的「找站长」入口**(`site.footer.admin` → `https://box.shiroko.one/_/tamakyi`),不新增站长邮箱配置项。
→ **上面「Deactivate 改成物理删除」的做法已被取代**,但其中「级联内容必须 Unscoped 物理删除」的结论仍然有效。
### 最终实现
- `internal/db/users.go`
- `purgeUserContent(ctx, tx, userID)`(包级函数):把内容级联删除抽出来,全部 `Unscoped()` 物理删除。
- `Deactivate` = `purgeUserContent` + **软删除用户行**(保留 email/domain 占位)。
- `BanUserAndCleanup` = `purgeUserContent` + **物理删除用户行**(唯一差别;彻底释放邮箱/域名)。
- `validate` 重写为两轮:先查活跃账号(ErrDuplicateEmail/Domain),再 **Unscoped** 查已停用占位行
→ 新增 `ErrDeactivatedEmail` / `ErrDeactivatedDomain`;辅助方法 `fieldTaken(ctx, column, value, includeDeleted)`。
- 新增 `Reactivate(ctx, id)`(清 `deleted_at`,未停用时返回 ErrUserNotExists),已加进 `UsersStore` 接口。
- 上一轮加的 `duplicateFieldErr` 已删除:`validate` 现在本身就覆盖了软删除行,无需在 Create 兜底里重复判定。
- `route/auth/register.go`:把两个新错误映射到 `auth.errDeactivatedEmail` / `auth.errDeactivatedDomain`。
- i18n ×4:新增 `auth.errDeactivatedEmail`、`auth.errDeactivatedDomain`、`deactivate.note`(键集合保持一致)。
- `templates/user/deactivate.html`:desc 下方加一行 `deactivate.note`(uk-text-small uk-text-muted),
提前告知「邮箱/域名会作为身份占位保留,无法用同一邮箱重新注册,如需恢复请找站长」。
- **新增 CLI `internal/cmd/restore.go`(`TamaBox restore`)**:`--email|--domain [--password] [--yes]`,
清掉 deleted_at 完成恢复;`--password` 会重置密码(必须先恢复再改密码——`UpdatePassword` 走普通作用域)。
### 🐛 顺带修掉的隐藏问题
**`cmd/tamabox.go` 里根本没注册 `cmd.Ban`** —— `internal/cmd/ban.go` 写了但命令不可达(`TamaBox ban` 不存在)。
现已把 `cmd.Ban` 与 `cmd.Restore` 一起注册进 `app.Commands`。
### 验证
- `go build ./...` / `go vet ./...` EXIT=0;`go test ./...`(含本地 PG)**全绿**;gofmt 全部 clean;行尾未变。
- 新增/改写测试:`TestDeactivatePurgesUser`(占位行保留 + 内容清空 + 同邮箱得 ErrDeactivatedEmail + 同域名得
ErrDeactivatedDomain + Reactivate 后可正常注册 + 重复 Reactivate 报 ErrUserNotExists)、
`TestBanUserAndCleanupRemovesRow`(封禁物理删除且可重新注册)。
- **CLI 端到端实测**(临时库 `tamabox-clitest`,用 `NEKOBOX_CONFIG_PATH` 指向临时 ini,跑完已 DROP + 删除产物):
- `restore --email clitest@example.com --yes` → deleted_at 变 NULL ✅;重复恢复 → 正确拒绝「未被停用,无需恢复」✅
- `restore --password newpass12345` → 密码重哈希 + password_version=1 ✅;密码过短/邮箱不存在 → 明确报错 ✅
- `ban --domain banme --yes` → users 与 questions 计数归零(物理删除)✅
- ⚠️ `conf.Init` 还需要 `server.salt` / `server.xsrf_key` / `app.external_url` 非空,配置路径可用
`NEKOBOX_CONFIG_PATH` 覆盖 —— 这是本地跑 CLI 冒烟的关键(仓库里只有 `conf/app.sample.ini`)。
- 测试环境 `devcheck-e2e.py` 的核心回归项已改为断言「重注册被明确拒绝并出现『已停用』提示、且不是 500 兜底」;
在**未重新部署的旧构建**上会 FAIL 并打印「サーバー内部エラー」,正是需要重新部署的信号。
---
## 部署后巡检扩容:投票全链路 + 登录态资产(28/28 全绿)
**背景**:用户重新部署后要求「所有功能都试一遍」。此前 `devcheck-e2e.py` 只验证了「新建投票页 GET」,
**投票创建(POST)这条主链路从未端到端跑过**,是覆盖盲区。
**改动**:`dev/devcheck-e2e.py` 扩到 **28 项**,新增两组:
1. `check_vote_flow(owner, base)` —— 投票全链路
- POST `/user/votes` 建单选投票并 `action=publish` → 断言 302 → `/user/questions#votes`
- GET `/user/questions` 断言标题可见,并从 `copyShareURL('...')` 取 **LinkToken**、从 `/user/votes/(\d+)/edit` 取 **ID**
- 匿名 GET `/vote/<token>` → 200 且含标题;从 `name="option_id" value="..."` 取选项 ID
- 匿名 POST `/vote/<token>/submit`(`option_id` + 数学验证码 + `_csrf`)→ 302
- GET `/user/votes/<id>/export` 断言 **`总参与,N` 这行的 N == 1**
- 草稿:`action=save` → 302 到 `/user/votes/<id>/edit?saved=1`
- 表单型:`type=form` + `form_fields` JSON(field_type/label/required/max_length)→ 发布 302
2. `check_user_assets(owner)` —— 登录态接口/导出
- `/api/v1/user` 返回 JSON、`/explore` 200、`POST /user/profile/export-json`(JSON)、
`POST /user/profile/export`(xlsx,校验 content-type)、停用页 `deactivate.note` 结构断言
**踩到的坑(都已在脚本里修掉)**:
- 单选投票导出 CSV 的**末行是 `总参与,N`**,与各选项票数行同为 `,数字` 结尾 →
用 `,\d+$` 一把捞会把总参与也累加进去(实测 `[1,0,0,1]` 求和成了 2)。
**必须单独匹配 `总参与,(\d+)`**。
- `urllib` 读 **HTTPError 的响应体本身也会超时**(`c.get("/i18n/xx?...")` 这类 302 最易触发),
异常从 except 块里抛出会把整轮巡检打断。已把 body 读取包在 try/except 里,并在 `__main__` 外层加兜底
——中断时仍打印已完成项,而不是只留一段 traceback。
- ⚠️ 复盘我自己的一个假警报:用 `T=/c/Users/Tamakyi\ Gekka/...` 做 gofmt 比对循环时,
变量未加引号导致路径断裂 → 全部文件假报 DIFF。**脚本里带空格的路径变量一定要整体加引号**,
单文件手跑同一个文件即为 OK,说明是脚本问题而非代码问题。
**结果**:测试环境 `https://box-dev-api.337845818.best` 跑 `python dev/devcheck-e2e.py` → **28 项,失败 0 项**。
10 个改动过的 Go 文件 gofmt 比对全部 OK(无重排),行尾未变。
**遗留**:巡检每轮会在测试环境留下一个**已停用的身份占位**(邮箱/域名被占用)。
本轮新增 `tbe2e*` 若干;用户自己的 `1223383917@qq.com` / `tamakyi` 也仍是占位状态。
清理:`TamaBox ban --email <邮箱> --yes`(连行物理删,彻底释放)或 `TamaBox restore --email <邮箱> --yes`(恢复)。
-614
View File
@@ -1,614 +0,0 @@
# 2026-10-19
## 部署后复测(强制严格档那批)
- 规则页返回首页已居中:线上 `<div class="uk-margin-large-top uk-text-center">` ✔
- 验证码弹窗按钮已缩短:线上 `>送信<` ✔(旧的「認証して送信」已消失)
- 箱主面板 `/user/questions`:限制期条幅 + 「查看社区规则」链接都在 ✔
- 严格度锁定(admin6 当前 L2):
- `/user/profile` 出现「处置期间审核严格度已锁定为最高档,解除后自动恢复你的原设置」✔
- `select[name=strictness]` 与「保存审核严格度」按钮均带 `disabled` ✔
- 直接 POST `/user/moderation-strictness/update?tab=security` + `strictness=lax` → 302 回 profile,
flash 仍是「已锁定」,且选中档仍为 `strict`(说明未写入)✔
- 踩坑:表单 action 是 `/user/moderation-strictness/update`(**没有** `/profile` 段);用错路径返回 302 到 `/`,
容易误判成「被中间件拦」。另外每次 POST 都要重新取 `_csrf`,用上一页(已跳首页)的 token 会 400。
## 记忆维护
MEMORY.md 一度 19KB 被注入截断,已重写压缩到约 4.9k 字符(8.1KB),保留构建/行尾/embed/审核/处置/CLI 等硬约束,
删去已在日志里的过程细节。
## CLI 文档 + 全量代码审阅(同日)
新增 `docs/cli.md`:命令总览、reports 三种用法与口径、restrict 级别语义/副作用、unrestrict、
hide-public、ban/restore、典型处置流程、排错表、明确不做的事。
审阅中发现并修复(全部本地临时库实测):
1. **`hide-public` 举报闭环静默失效**(真 bug):`build()` 带 `is_private=false`,在 Update 之后拿它当子查询恒为空集
→ 隐藏成功但 `content_reports` 仍 pending。改为 Update 前 `Pluck("id")` 快照,再按 ID 更新/标记。
实测:3 条 pending → accepted,未波及其他箱主。
2. **整箱分享卡未拦截**:`ShareCard` 直接走缓存,L1–L3 仍能生成整箱图。已在缓存查询前加拦截(fail-closed)。
3. **L2 通知邮件误导**:强制私密后仍给提问者发「你的提问有回答」,链接点开 302。改为 `!forcedPrivate` 才发。
4. **L2 写入顺序**:`SetPrivate` + `AnswerByID` 两条语句有「公开+已作答」窗口,第二条失败还会留下「私密+无回答」。
新增 `Questions.AnswerByIDPrivate`(单条 UPDATE 同时写 answer/is_read/is_private),并补测试 `TestQuestions/AnswerByIDPrivate`。
5. **`ListAPI` 绕过 Pager**:L4 停用与页面访问密码只挡 HTML,`/api/v1/user/{domain}/questions` 仍能整页拉。
已补两道校验(均返回「用户不存在」,不暴露原因)。**页面密码可被 API 绕过是升级前就存在的问题。**
6. **L1 曝光残留**:`GetShowInHome` 的 Count 未过滤(分页总数虚高、末页空白)、热门标签查询未过滤。已补同一子查询。
7. **性能**:`ActiveLevel` 由两次查询改一次(取最高 rank);新增 `moderation.EffectiveStrictnessFor(level, raw)`,
发布路径上不再重复查库(原先一次发布最多 4 次处置查询)。
8. **证据包口径**:分母是「累计公开回答」不是窗口产出 → 文案加「累计」;新增 `待处理 N` / `已处理` 标记,
避免把已闭合的举报误读成还挂着;补 `q.deleted_at IS NULL`;`--stats` 说明最长回溯 30 天;CLI 对 `active` 做 nil 守卫。
验证:`go build ./...` EXIT=0;db/i18n/templates 测试全绿;CLI 全流程(restrict L3 → 证据包 → unrestrict)实测通过;
临时库已 DROP。
遗留(未改,已向用户说明):L4 不覆盖 `/vote/{token}`;`restrict` 没有 `--days` 上限;处置不发邮件给箱主(只有登录后条幅);
`abuseAlertCooldowns` 进程内 map 无清理(重启即失效)。
## 第二轮:处置通知邮件 + 超长确认 + 冷却表清理
- 新增 `templates/mail/restriction-notice.html` + `mail.SendRestrictionNoticeMail(RestrictionNotice)`:
文案由调用方按**箱主自己的语言**(`users.language`,空则 `conf.App.DefaultLang`)预翻译后传入,
模板本身单语言。**不含 `--reason` 原文**,只有级别名/到期/规则链接/申诉渠道。
i18n 新增 10 个键 ×4 语言:`mail.restriction.*`(6)+ `restriction.level.L1..L4`。
- `restrict`:新增 `--force`(`--days > 90` 必填,否则直接报错)与 `--no-notify`;交互模式下超长处置
还要再输一次级别名(`--force --yes` 视为两次确认)。发信失败只 warn + 终端提示,**不回滚处置**;
发信加了 20s 超时(gomail 无 Timeout 字段,裸 Dial 会等系统 TCP 超时)。
- `abuseAlertCooldowns` 加惰性清理(>1024 条时删除已过 24h 的项)。
- 顺带:`/user/questions` 限制期条幅改用 `restriction.level.*` 的名称(「收窄公开」)而不是裸 `L2`。
验证:真实 SMTP sink 实收两封(ja 与 zh-CN),标题/级别名/到期/规则链接均正确、`--reason` 未出现、
链接未被 `ZgotmplZ` 转义;`--days 120` 无 `--force` 被拒;黑洞 SMTP 下 23s 内返回(未阻塞两分钟)。
`go build ./...`、`go vet`、db/i18n/templates 测试、checktpl 全绿。
遗留:`unrestrict` 不发通知(只解禁);`restrict` 对 `--days` 无上限(设计如此)。
## 第三轮:解除处置也发通知
- 复用同一封模板 `mail/restriction-notice.html`:`appeal` 段改为 `{{if .appeal}}` 可选,
解除通知不传该字段就整段省略。
- 新增 i18n 键 ×4:`mail.unrestrict.heading/body/note`(body 带域与级别名;
note 明确写「处置记录保留用于审计、私密内容不会自动恢复」)。
- `unrestrict` 新增 `--no-notify`;撤销前先 `ActiveLevel` 取级别(撤销后读不到),
撤销后按箱主语言发解除通知。`affected==0` 直接返回、不发信。
- 抽出公共件:`sendNotice`(20s 超时)、`reportNoticeResult`(统一成功/失败输出)。
- 文档补充:`unrestrict` 的 `--no-notify`、解除通知内容、以及**到期自动解除不会发信**(无调度器)。
实测(本地 SMTP sink,2 封):处置信含级别名/到期/申诉段;解除信含「已解除」+ 私密内容提示、
**无申诉段**;两封都不含 `--reason`。`--no-notify` 两处都不发信;无生效处置时不发信。
`go build ./...`、db/i18n/templates 测试、checktpl 全绿。
## 上线前最终验收(部署后实测)
- 公开页:`/` `/rules` `/restricted` `/deactivated` 全部 200;`/rules` 四语言标题正确。
- 箱主面板(admin6,L2):条幅显示「收窄公开」+ 规则链接;严格度面板锁定提示 1 处、下拉 disabled。
- **L2 写入路径实测**(关键,因为改成了 `AnswerByIDPrivate` 单条 UPDATE):
对未回答题 q95 作答 → 302 回题目页 → flash「已保存为私密:当前处于平台处置期,回答不会公开」→ 匿名访问 302。
- 公开列表 API `/api/v1/user/{domain}/questions` 正常返回 `{code,data:{data,next_cursor,has_more}}`(新增的 L4/密码校验没有打断正常路径)。
- 整箱分享卡:受处置箱主 302(拦截生效);未处置箱主 500,原因是**测试站未配置字体**(`分享图生成失败:字体文件未配置或无法加载`),与本次改动无关。
这一点反而证明拦截位置在生成之前。
- 未能自测:CLI 发信(restrict/unrestrict 通知)需要在服务器上跑命令,只能由用户执行。
注意:admin6 现有测试题 73/88/89/90/94/95;其中 95 已被我回答并存为私密(内容带 `restrict self-check`)。
admin6 的 L2 于 2026-10-19 21:29 自动到期。
## 邮件模板统一(用户反馈:页脚与其它邮件不一致)
- 原因:`restriction-notice.html` 是我新写的,页脚用了自造的 `© 2026 TamaBox`,与项目其它 11 封邮件的
「此邮件由系统自动发送,请勿直接回复。<br/>{{ .year }} 狼的提问箱」不一致。
- 处理:整封模板按 `question-reported.html` 的结构重写(24px 标题 + `{{.time}}` 副行、同样的卡片/页脚/外间距),
`time` 由 `mail.SendRestrictionNoticeMail` 统一注入(调用方不用各自拼时间)。
- 实测(本地 SMTP sink):页脚文本与 question-reported 完全一致,`© ... TamaBox` 已消失,头部时间行存在。
- 注意:站点名在所有邮件模板里都是硬编码「狼的提问箱」,不是配置项;改站名要一起改 12 个模板。
## 页脚精简(移动端一行放不下)
- 去掉「盘」链接(`https://share.shiroko.one`,zh 文案就叫「盘」),并删除已无引用的
`site.footer.drive` 键(四语言同步删,保证 `TestDictionaryComplete` 词条数一致)。
- `site.footer.rules`:zh-CN「社区规则」→「规则」,zh-TW「社群規則」→「規則」(en/ja 本就短)。
- 现在页脚一行:规则 | 找站长 | 公告 | 聊天 | 状态 | 主页。
- 注意:`templates/base/footer.html` 用 `|` 分隔,行首行尾各带一个,删整行时不会留下悬空分隔符(已核对)。
## 页脚改两行(用户改主意:盘恢复)
- 恢复 `https://share.shiroko.one`「盘」链接与 `site.footer.drive` 键(zh-CN 盘 / zh-TW 盤 / en Drive / ja ドライブ)。
- `site.footer.rules` 文案恢复:社区规则 / 社群規則(en/ja 不变)。
- 布局两行(靠 `<br>` 断行,不用额外 CSS):
第一行 `社区规则 | 找站长`,第二行 `盘 | 公告板 | 聊天室 | 监控板 | 导航页`。
- 键插在 `site.footer.rules` 之后(组末),空格数按同长度键复制,gofmt 对齐不变;dict 仍 CRLF。
- 验证:build / checktpl / i18n / templates 测试全绿。
## 页脚改为「从少到多」四行
版权行与存活计时移到最后两行(原来在最上面),整块按文字量递增:
`社区规则 | 找站长` → `盘 | 公告板 | 聊天室 | 监控板 | 导航页` → `Powered by NekoBox | ICP` → `提问箱已成功存活:…`。
实现只调整了 `<br>` 的位置,仍共 4 个 `<br>`,所以块高与顶部空行跟原来一致(未新增占位)。
注意:`box_runtime` 的脚本按 id 取元素,移动位置不影响;但 `<br>` 数量别随意增删,否则页脚高度会变。
## 前端样式体检(只出结论,未改代码)
量化事实(脚本统计 templates/**/*.html):
- 全站 **0 个 .css 文件**,样式全部内联:`base/header.html` 内含 **17.5KB CSS + 10.6KB JS**(模板总 42.5KB),
footer 3.0KB JS;全模板共 **24 个 `<style>` 块**,`user/profile.html` 自身还有 11.2KB CSS + 22.8KB JS(总 62KB)。
- `--tb-*` 设计变量 **只在 `html[data-theme="dark"]` 里定义**,light 主题没有 `:root` 一套;
调用处全靠 fallback(`#e9e9e9` / `#e5e5e5` / `#fafafa` / `#333` / `#666` 混用),explore 等页面还写死色值。
- **深色模式 FOUC**:`applyTheme(getTheme())` 在 body 内、`<nav>` 之后执行(header.html:812),
深色用户刷新时顶栏会先白闪一下。修法:把 theme 初始化内联脚本提到 `<head>`。
- 结构:`uk-container-xsmall`(750px)由 header 统一输出,仅 explore/vote/chat 页面自套 small;
`question/list.html` 仍用 `<center>`、内联 `style="min-height:260px"`,用户名/简介颜色由用户自选压在背景图上,无 scrim → 可读性无保障。
- 首页 `home.html` 是纯 UIkit 卡片 + `<hr>` + `style="color: red"` + `<pre>` 统计 + 首页挂 Waline(250 行 locale)。
建议分档(未实施):P0 = light/dark 双套 token + 圆角/阴影/中文字体栈 + 首页重排 + 背景图 scrim;
P1 = header CSS 抽成 `static/css/*.css`(注意 `embed.go`/`static/fs.go` 都是显式清单)+ FOUC 修复;
P2 = 图标统一(UIkit icons 取代 emoji/位图 png)、标签减量、viewpoint/meta 去重。
## P0 前端美化(已实施)
用户拍板:卡片**结构不变,只加圆角和柔阴影**;先做 P0,不抽 CSS 文件。
改动文件:
- `templates/base/header.html`
- head 顶部新增**主题初始化内联脚本**(深色用户不再白闪;原 `applyTheme` 在 body 里、nav 之后执行)。
顺手删掉 body 脚本里的 `applyTheme(getTheme())`,只保留切换逻辑。
- 新增**设计令牌块**:`:root` 亮色 + `html[data-theme=dark]` 暗色,同名 14 个(bg/surface/surface-2/surface-hover/
border/text/text-secondary/text-muted/primary/primary-weak/radius/radius-sm/radius-xs/shadow/shadow-hover/font)。
原暗色块里的 7 个变量定义已删除(现在统一在令牌块)。
- 新增**基础质感层**:html+body 底色与字体栈(system-ui + 苹方/冬青黑/雅黑)、卡片 12px 圆角 + 双层柔投影、
按钮/输入 8px、标签 6px、alert 10px、modal 14px、导航栏白底、`:focus-visible` 描边、`tabular-nums`、
`prefers-reduced-motion`。
- `.tb-cover`:封面遮罩;默认浅色(配 `.uk-light` 的黑字兜底),`.tb-cover-dark` 为深色。
- `.tb-img-uploader` 上收到 header(原来 item / new-question 各写一份**内联 style**,写死 #f8f8f8 → 深色下露白)。
- `templates/home.html`:整页重排为 hero(标题+一句话+探索/规则按钮)+ 3 张功能卡 + 域名迁移公告
+ 提醒列表 + `<details>` 折叠更新日志 + 3 个数字统计卡(`.SiteStats` 数值字段,30 分钟 JS 刷新改为按字段更新)。
全部原有信息保留;页内 `style=` 归零。
- `templates/question/list.html`:封面加 `.tb-cover`(仅当有背景图),新增 12 行 JS 按用户名颜色亮度
决定遮罩方向;`min-height:260px` 内联改为 `.tb-profile-cover`。
- 6 个页面样式块里的硬编码色值收敛到令牌(explore/question-list/profile/list/item/vote-page,共 ~84 行),
CRLF 文件用字节级替换(未改动行尾)。
验证:`go test ./templates/... ./internal/i18n/...` 全绿;`go build`(overlay)通过。
新增 `templates/zz_preview_test.go`:`TEST_PREVIEW=1 go test ./templates -run TestGenPreview` 产出
`outputs/preview-{home,box,explore}-{light,dark}.html`,用无头 Chrome 截图核对过亮暗两套。
⚠️ 本机 **Chrome 新版 headless 会挂住**,必须 `--headless=old`,且页面外部资源要本地化
(`outputs/preview-assets/`,从 cdnjs.snrat.com 取 UIkit 3.3.3;cloudflare/jsdelivr 在本机不通)。
## 封面遮罩 P0 缺陷修复(重要)
**症状**:`.tb-cover::after` 遮罩不渲染,且**完全不报错**——截图对比像素只差 ±1(只看着像"没效果")。
**根因**:UIkit 3.3.3 自带
`.uk-flex::after,.uk-flex::before{display:none}`(优先级 0,2,0),
而封面元素是 `uk-flex`,我写的 `.tb-cover::after`(0,1,1)优先级不够 → 被压成 display:none。
定位方法:`getComputedStyle(el,'::after').display` 得到 `none`;再逐个 `classList.remove` 试 →
去掉 `uk-flex` 立刻变 `block`。最后在 uikit.min.css 里 grep 到那条规则。
**修法**:选择器写成 `.tb-cover::after, .tb-cover.uk-flex::after` 并显式 `display:block`。
**实测效果**(无头 Chrome 截图 + 像素测量,非目测):
- 深色渐变背景 + 白字:封面底部亮度 0.229→0.102,白字对比度 **3.77→6.89**(过 WCAG AA 4.5)
- 线上默认背景(`box_background.png` 2048×1152,接近全白)+ 黑字:0.912→0.971,对比度 19.25→20.42,
即**对默认背景几乎没有可见影响**
- 亮度判定 JS 已验证:`#fff` 会加 `tb-cover-dark`,`#000` 不加。
**教训**:CSS 改动不能只看"规则写上了/类加上了",必须量像素。伪元素 + UIkit 工具类组合要在计算样式层验证。
## 账号背景图的真实链路(回答用户提问时查证)
- 注册时把 `[upload] default_background` **一次性复制**进 `users.background`(`route/auth/register.go:51`),
不是每次渲染兜底 → 之后改配置**不影响存量用户**。
- 页面只用 `.PageUser.Background` 渲染 `data-src`;为空则无背景图、无遮罩。
- 分享卡是另一条路:`route/question/share.go:345` 用 `user.Background`,为空时回退 `conf.App.ShareBackground`。
- 线上 `box.shiroko.one/_/tamakyi` 实际值 = `https://r2.337845818.best/tamabox/box_background.png`。
- 预览里那张"渐变图"是 `zz_preview_test.go` 造的测试数据(data: URI 渐变),**不是产品行为**;
顺带发现 Go 模板会把 `data:` URI 在属性上下文里替换成 `#ZgotmplZ`(真实 http URL 不受影响)。
## 设置页「防护设置」缺白底(用户报)
根因:其它面板都包在 `<section class="tb-pcard">`,只有内容防护面板(profile.html:842)是裸 `div.profile-panel`,
直接露出页面灰底 `--tb-bg`。修法:包一层 `section.tb-pcard`,并把 `.tb-pcard` 的 `background:#fff` 改成
`var(--tb-surface)`(删掉随之冗余的 dark 覆盖)。
实测(计算样式):account 与 security 面板 bg 亮色均 `rgb(255,255,255)`、暗色均 `rgb(36,40,48)`,圆角 10px,完全一致。
顺手把 `security` 加入 `render_test.go` 的面板渲染测试列表(原来只测 account/question/chat/vote)。
⚠️ 教训:用脚本批量缩进/hash 插入时先 dry-run。这次第一版脚本把「空行」也当成需要写 `<section>` 的行,
一次性插了 5 个多余标签;靠立刻 `sed` 复查发现并回滚。
## P1 第一步:CSS 抽离(已完成)
- 新增 `static/css/base.css`(23KB,含注释;gzip 后约 6KB),内容 = 原 header 里的
设计令牌 / UIkit 补丁 / 深色覆盖 / 质感层 / 顶栏响应式 / 标签 / Markdown / 进度条 / 移动端底栏。
- `header.html`:内联 `<style>` 从 17.5KB 降到只剩 1 个(算数验证码,跟着 `{{ if CaptchaType }}` 条件渲染);
改为 `<link href="/static/css/base.css?v=20261019a">`,位置在 UIkit 样式之后(保证覆盖顺序)。
单页 HTML 42.5KB → 30.9KB。
- `static/fs.go` 的 `//go:embed` 显式清单加 `css/base.css`(漏了就是运行期 404 + 全站无样式,编译期不报错)。
- 静态缓存沿用既有 `CacheControl: public, max-age=86400`;靠 `?v=` 版本号破缓存。
**验证方式**:改动前后各截一次同样尺寸的截图做像素比对。
home-light / home-dark / explore-light 三页差异均 **0.024%**,bbox 只有 (11,56)-(29,74) 一个 18×18 方块
= 导航栏随机动物 emoji(每次加载都变),不是样式回归。go build + 模板/i18n 测试全绿。
## 测试环境复测(P1 CSS 抽离 + 防护面板白底,已上线)
- `GET /static/css/base.css?v=20261019a` → 200 / `text/css` / **23088 字节,与本地 `static/css/base.css` 逐字节一致** /
`Cache-Control: public, max-age=86400` / gzip 后 7109 字节。
- 首页 32747 字节(改动前 52747),gzip 10534(改动前 13642);页面内联 `<style>` 只剩验证码 + 首页自己的样式块。
- 设置页(登录 admin6):`data-profile-panel="security"` 下已有 `<section class="tb-pcard">`;
用线上 HTML + 本地 CSS 复算样式 → account / security 两块 `bg=rgb(255,255,255)`、`border-radius:10px`,页面底 `rgb(247,248,250)`。
- `/_/admin6` 公开页:`class="... uk-light tb-cover"`,`:after` 计算值 **display=block, width=689px**
→ 遮罩在真实部署上确实渲染了(UIkit `.uk-flex::after{display:none}` 的坑已解决)。
该用户用户名为 `rgb(0,0,0)`,走浅色遮罩(符合预期)。
- 各页 200:`/`、`/explore`、`/rules`、`/user/questions`;`/user/vote-form` 无参 302 属正常。
- 首页首次访问总字节略增(10.5+7.1 vs 13.6KB gzip),但 CSS 跨页缓存后每页少 3.1KB,且服务端少渲染 17.5KB 模板。
## 专属测试账号(用户指定)
- `https://box-dev-api.337845818.best/_/admin6`,邮箱 `[email protected]`,密码 `adminadmin`。
- 登录需算数验证码,curl 流程:GET /login 取 `_csrf`(同时存 cookie)→ `GET /api/captcha/generate` 拿 `{id, question}`
→ 算答案 → `POST /api/captcha/verify`(头 `X-CSRF-Token`,体 `{id, answer}`)→
`POST /login`(email/password/_csrf/`g-recaptcha-response=id:answer`)→ 302 到 `/`,cookie 已带登录态。
## P2 一致性收尾(已实施,未部署)
1. **导航图标**:🌐🌙☀️ emoji → 内联单色 SVG(`stroke: currentColor`,跟随主题/悬停色)。
⚠️ 原计划用 UIkit icons,实测 **UIkit 3.3.3 的图标集里没有 moon / sun**(只有 world),
照写会渲染成空白按钮——所以三个图标全部改成内联 SVG,也不依赖 JS。
2. **标签**:投票类型 20 处 `uk-label uk-label-primary`(实心蓝)→ `.tb-chip` 轻量胶囊;
探索页热门标签原来的 5 色轮换(success/warning/danger/primary/secondary)也统一成 `.tb-chip`。
箱主面板 `question-list.html` 另 6 处字节级替换(CRLF 保留)。
保留:置顶/私密/被举报/已读 这些**语义状态**仍是彩色 uk-label(有含义,不该降级)。
3. **遗留写法**:`<center>` ×2 → `div.uk-text-center`;占比条从
`div.uk-progress > div.uk-progressbar(style:width)` 改成 UIkit 3 原生
`<progress class="uk-progress" value=".." max="100">`,**删掉 25 行 UIkit2 兼容补丁**,
只保留 10px 高/6px 圆角的外观收窄。测试 `percent_check_test.go` 的断言同步改成 `value="66" max="100"`。
4. **meta**:删掉重复的 `msvalidate.01`(保留 head 顶部一份)、viewport 补 `initial-scale=1`。
**踩坑**:暗色主题图标原来写 `display:inline`,span 里放块级 `<svg>` 会让导航栏高度变化 → 整页下移 36px。
用行亮度曲线做互相关定位到 dy=36,改成 `inline-flex` 后复测差异回到 0.059%(只剩随机 emoji)。
教训:**改图标尺寸/显示模式一定要量整页位移**,不能只看局部截图。
## P2 线上复测(2026-10-19 12:2x)
- `GET /static/css/base.css?v=20261019c` → 200 / text/css / **23918 字节,与本地逐字节一致**,页面已引用该版本。
- meta:viewport 已带 `initial-scale=1`;`msvalidate.01` 只剩 1 份。
- 图标:首页 emoji 🌐🌙☀️ 归零,`<svg ... stroke="currentColor">` ×5(语言 1 + 桌面主题 2 + 移动主题 2);
计算样式暗色下 `#theme-toggle .tb-icon-dark` = `display:flex`、亮色图标 `none`;
**导航高度亮/暗均 80px** → 上次 36px 位移问题确认修好。
- 探索页:`.tb-chip` ×25(全部有底色 `rgb(244,246,248)`、文字 `rgb(92,99,112)`、圆角 6px、字号 11.52px);
残留彩色 `uk-label` 4 个,均为置顶/私密等语义状态。
- 结果页:`/user/votes/{id}/results` 原生 `<progress class="uk-progress" value max>`,计算高度 **10px**、圆角 6px;
遍历 10 个结果页 `uk-progressbar` 全部为 0。
- 巡检 `/ /explore /rules /login /register /_/admin6 /_/admin3` 均 200。
## P2 补漏(已改,待部署)
复测时发现 **结果页与参与页的题型标签仍是实心彩色** —— 那两处的 class 由 Go 模板函数
`fieldTypeLabelClass` 生成,我上一轮只按字面 `class="uk-label uk-label-primary"` 搜索,漏了它们。
- `templates/user/vote-results.html`(2 处)、`templates/vote/page.html`(2 处):序号 + 题型 → `.tb-chip`;
状态标签(未开始/已结束/进行中/已参与)保留彩色,属语义。
- 删除 `internal/template/template.go` 的 `fieldTypeLabelClass`(19 行,已无引用)。
顺带查明:该函数 `text/textarea` 走 `return ""`,注释说"默认灰",**实际渲染成 UIkit 默认蓝**,
所以文本框标签一直是蓝的——注释与实现不符。
- `.uk-progress::-webkit-progress-value` 圆角改成两端都圆(`-moz-progress-bar` 同步)。
⚠️ 起因是误判:我先前测"右端方角"时像素扫描循环上界写成 `range(110,830)`,恰好等于条宽,
于是右端永远"恒定不变"→ 假象。放宽到整幅宽度后两版端点曲线完全一致(830/832/833/834/833/832/830),
即**没有视觉 bug**。保留改动只为 Firefox 的 `-moz-progress-bar` 保险。
- `base.css?v=` → `20261019d`。`go build` + `./templates ./internal/i18n ./internal/template` 全绿。
**教训(重要)**:像素测量脚本的扫描范围必须覆盖整幅图,循环上界不能与被测对象边界重合,
否则会把"边界截断"读成"图形特征"。之前 3.77→6.89 那组对比不知是否也受此影响,需复查时再确认。
## P2 补漏部署后复测(12:48)
- `base.css?v=20261019d` 上线,24023 字节与本地逐字节一致。
- 删掉 `fieldTypeLabelClass` 后**无运行期报错**:公开投票页 3 个(200,tb-chip 8/16/4,uk-label 各 1 = 状态)、
结果页 4 个(200,tb-chip 8/2/0/16,彩色 label 全 0,progress 7/0/10/19)、
草稿预览页 2 个(200,tb-chip 2/6)。
- 巡检 `/ /explore /rules /user/questions /user/profile?tab=security /_/admin6 /_/admin3` 全 200 无模板错误。
- `/user/votes/{id}/preview` 对**已发布**投票 302 到 `/vote/{token}` 是设计如此(vote.go:1093),不是回归。
- 截图核对:结果页「1 単選 / 2 多選」已为浅色胶囊,进度条 10px 圆角 6px;参与页字段头同样统一。
## "移动端语言/主题按钮不见了"排查(结论:非缺陷)
实测(线上测试环境,`--force-device-scale-factor=1`):
- 宽 360/390/414/640 → 顶栏 `.tb-nav-utils` = `display:none`,底栏 `#tb-mobile-utils` = `flex`,
rect `0,639,W,46`,语言 `block`、主题按钮 `block`(48×30,SVG 16×16),命中测试落在 `mobile-lang-select`。
- 宽 700/768/980 → 底栏 `none`,顶栏 `flex`,右上角地球 + 月亮都可见(截图 `outputs/t-nav-right.png`)。
- 页面级差异:`/_/admin6` 底栏**只有月亮没有语言下拉** —— `IsUserPage=true` 让语言块整体不渲染(桌面同样没有)。
- 生产 `box.shiroko.one` 仍是旧构建(页面无 `base.css` 引用,底栏是 emoji 月亮),移动端同样正常。
用户确认是忘了「移动端移到固定底栏」这个设计,不是 bug。
留下的真实缺口:**箱主页(最常见的外部入口)完全没有语言切换**,见 MEMORY.md 前端样式条目。
## 修正:箱主页无语言切换是设计,不是缺口
上一节把它记成"真实缺口"是错的。用户明确:**箱主页属箱主个性化,以箱主配置为主**,
语言入口不出现是有意为之(`IsUserPage` 门控)。
MEMORY.md 对应条目已改为"有意设计、别当 bug 修、要动先问"。
## 备份(用户要求)
`../TamaBox-v4-mainline-20261019-1445.tar.gz`(940K,302 条目,排除 .git/.workbuddy/outputs)。
P1 剩余两项(首屏跟随系统深色、首页 Waline 懒加载)用户明确**先不做**。
## 主题系统方案(只出设计,未实现)
用户诉求:箱主在后台选主题、仅对箱主相关页面生效;站点级默认主题在配置里;要一份可复用的主题底稿。
已确认的现状事实(设计依据):
- 设计令牌只有 16 个,集中在 `static/css/base.css` 的 `:root`(亮) + `html[data-theme="dark"]`(暗)。
- `<html lang>` 在 `templates/base/header.html:2`,服务端渲染;主题属性可放在这里(无 FOUC)。
- `question.Pager`(`route/question/page.go:45`)是 `/_/{domain}` 与 `/_/{domain}/{id}` 的共同中间件
→ 打主题标记的唯一合适位置。
- `/user/*` 与 `/_/*` 共用 `IsUserPage`(context.go:241 按 URL 前缀判定),所以「箱主相关页面」
≠ IsUserPage,必须单独区分,否则设置页会被主题化。
- `static/fs.go` 与 `templates/embed.go` 都是显式 embed 清单(新增主题 css 必须手加)。
- 用户模型已有大量个性化字段(usernamecolor/introcolor/background…),主题作为 `users.theme` 同族最自然。
- 分享卡是服务端绘制图片(`route/question/share.go`),不经过 CSS → 主题不覆盖它。
关键结论(写进方案的核心):令牌覆盖必须用
`html[data-theme-style="x"]`(0,1,1,压过 `:root` 的 0,1,0)+
`html[data-theme="dark"][data-theme-style="x"]`(0,2,1,压过 base 的 dark)。
只写浅色块会让深色模式被浅色令牌污染(同特异性 + 主题文件后加载)。
## 主题系统初版(已实现,未部署)
用户拍板:①作用范围跟随箱主配置(即 /_/ 箱主相关页面)②主题只做配色/图案/整页背景,**不涉及布局**
③主题名可本地化 ④分享卡不参与。
实现:
- `internal/theme`:注册表 + 三级回退。`[theme] default/available` → 白名单(slug 正则 + css 是否存在),
`Resolve(用户值)`:`default`→空串(不加载)/命中→该 slug/其余→站点默认。"" 与 "default" 语义不同:
""=跟随站点默认,"default"=箱主明确选基础外观。
设置页色块从主题 css 浅色块里抓 `--tb-bg`/`--tb-primary` 拼渐变(值必须过色值正则,防注入)。
- `users.theme varchar(32) default ''`(AutoMigrate 加列)+ `UpdateTheme` 单列更新。
- `question.Pager` 注入 `Data["ThemeSlug"]`(`/_/{domain}`、单题、密码页、分享卡路由共用这一个中间件);
header.html 在 `<html>` 输出 `data-theme-style` 并条件加载 `themes/<slug>.css`。
- 设置页账号面板末尾新增「主题外观」卡片(独立表单 `POST /user/profile/theme`,不塞进 UpdateProfile,
避免与该面板其它字段互相覆盖);`{{ if .Themes }}` 包裹,未配置主题时不显示。
- 静态版本号统一 `conf.StaticVersion = "20261019e"`(base.css 与主题 css 共用,改样式只改一处)。
- `static/fs.go` 改嵌目录 `css/themes`(以后加主题文件不用再改 embed 清单)。
- 主题文件:`_scaffold.css`(底稿,含 19 令牌说明 + 素材规格 + 特异性规范)、`default.css`(恒等,仅色块预览)、
`sakura.css`(示例,纯 CSS 网点图案 + 粉调)。
- base.css:body 接 `--tb-page-image` / `--tb-page-pattern` / `--tb-page-pattern-size` 两层背景;
**UIkit 主色是编译期常量 #1e87f0**,新增映射块把 a/uk-link、uk-button-primary、checkbox/radio、input:focus、
uk-tab active 接到 `--tb-primary`(基础主题值相同,默认外观零变化)。
实测(无头 Chrome,box 页 data-theme-style=sakura):
- 浅色 primary `#e5678f`/bg `#fff6f8`/pattern 16px 网点命中;深色 `#ff8ab0`/`#241a1f`,卡片表面各自跟随。
- 未选主题的 box 页:无 data 属性、无 themes link、主按钮仍 `rgb(30,135,240)` → 零回归。
踩坑:
1. 第一版 sakura 页「发送提问」还是蓝色 —— UIkit 组件色写死,令牌覆盖不到,必须按组件映射。
2. 预览里 `{{ .StaticVersion }}` 为空(预览夹具没这个键),导致按固定串替换主题 css 失败;改用正则。
3. `writePreview` 用全文搜 `data-theme="dark"` 判断是否已加属性 → 命中内联主题 css 里的选择器,深色预览没生效;
改为只看 `<html ...>` 开标签。
4. 脚本改 form/user.go 时把插入块写在结构体中间,拼出 `} Language ...`;靠 Read 复查发现并重写。
## 主题 follow-up(部署后反馈两处)
1. **/user/* 不生效主题**:根因是 `ctx.Data["ThemeSlug"]` 只在 `question.Pager`(`/_/{domain}`)里注入,
`/user/*` 完全没走这条链路。修法:`internal/context/context.go` 的 Contexter 里按
`isThemeScope(path) && c.IsLogged` 注入 `theme.Resolve(c.User.Theme)`;
`isThemeScope` 只认 `/user` 与 `/user/`(不能用裸 `HasPrefix("/user")`,会误吞 `/username`)。
新增 `internal/context/context_test.go` 守住路径白名单;page.go 的注释同步改。
公开页(`/`、`/explore`、`/vote/{token}`)**仍不跟随**登录用户主题,保持站点默认。
2. **sakura 显示奇怪**(用户选择:顶栏白与粉底割裂 / 卡片纯白太突 / 底色分布不均):
- base.css 新增令牌 `--tb-navbar`(`:root` 里 `var(--tb-surface)`,亮暗两条 navbar 规则都改成
`var(--tb-navbar, var(--tb-surface))`),主题可单独给顶栏上色。
- sakura v2:底色改**纯色** `#fceef3`(不用渐变——渐变会被读成"不均匀"),
顶栏 `#fff6f9`、卡片 `#fffafc`(暖白,不再是纯白硬切),网点从 16px/0.14 放宽到 24px/0.05。
- 静态版本 `20261019f`;`_scaffold.css` 令牌说明 19→20。
- `profile.theme.hint` 四语言文案改成「提问箱页面 + 本人后台生效,公开页保持默认」。
- `zz_preview_test.go` 抽出 `inlineThemeCSS()`,并新增 `preview-profile-sakura-*`(后台页面也要在主题下检查)。
验证:`go build ./...`(须带 webp overlay)、i18n/theme/context/templates 四个包测试全绿。
渲染法:抓线上 `/_/admin6` HTML → 把 base.css 指到本地新文件、主题 css 内联 → 无头 Chrome `--headless=old` 截图。
## 投票页 + 标签主色(部署复核后第二轮)
- `/vote/{token}` 主题:`route/vote/vote.go` 的 `Page` 里本来就会 `db.Users.GetByID(vote.UserID)` 取 owner,
直接在同一分支补 `ctx.Data["ThemeSlug"] = theme.Resolve(owner.Theme)`;box 页 / /user 后台 / 投票页三处
全部由发布者配置决定。GetByID 失败(箱主已删)保持站点默认外观。
- **`uk-label` 默认色就是 UIkit 主色 #1e87f0**:主题下提问管理的「未回答 / 未读」两枚胶囊还是蓝的。
base.css 追加
`.uk-label:not(.uk-label-danger):not(.uk-label-warning):not(.uk-label-success):not(.uk-label-secondary)`
→ `--tb-primary`。**必须用 :not() 排除语义色**:同特异性且 base.css 在 UIkit 之后,不排除会把
danger/warning/success 一起刷成主色。默认主题实测仍是 (30,135,240),零回归。
- StaticVersion → `20261019g`。
- 线上复核(admin6 专属账号,curl 走完整登录:GET /login 取 _csrf → /api/captcha/generate →
/api/captcha/verify(**answer 必须是数字,传字符串会 400 请求格式错误**)→ POST /login):
`/user/profile`、`/user/questions` 均已输出 `data-theme-style="sakura"` 与 themes link(v=20261019f)。
投票页在 f 版本仍无主题 → 需再部署一次。
## 投票页语言跟随发布者(第三轮)
- 抽出 `applyOwnerLocale(data map[string]interface{}, owner *db.User)`(route/vote/vote.go 顶部):
同时写 `Owner` / `Lang` / `LangCode` / `ThemeSlug`。
签名故意收 Data 而不是 `context.Context`:`Context` 内部字段不可在包外构造,收 map 才能单测
(`route/vote/locale_test.go`,纯内存,不碰 DB)。
- `Page`:owner 查询提到 `FeatureStateOf` **之前**——否则「投票功能已关闭」这类提示先按访客语言生成。
- `Submit`:同样先取 owner 再取 `lang`,保证提交后回跳页的 flash 与页面语言一致;
顺带删掉尾部重复的 `GetByID(owner)`(表单类型通知邮件复用同一个 owner),少一次查询。
- 四语言 `profile.theme.hint` 与 changelog 范围说明补上「你发布的投票页」。
- 验证:vote/i18n/context/theme/templates 全绿;线上 `lang="ja"` + 中文内容的不一致待部署后复测。
### 部署后复测(v=20261019g)
- `/vote/7kcP5kR8YnJC`(admin6):`lang="zh-CN" data-theme-style="sakura"`,base/theme css 均 v=g,
日文串(投票する / 匿名での参加 / ログイン)全部消失 → 语言与主题同步跟随发布者。
- `/ _/admin6`、`/user/profile`:仍为 `zh-CN` + sakura,未回归。
- 隔离性:另取三个 token 抽查 —— 4G3i79OCYa2E / 7l0MwF7cLz4T 为 `lang="ja"`(无语言偏好、无主题),
9J1zgXwQ05Ai 为 `lang="zh-CN"` 且**无** data-theme-style → 主题不会跨箱主泄漏。
- 遗留观感问题(未改,等用户决定):投票页的「允许匿名参与」提示条是 `uk-alert-primary`,
仍是 UIkit 浅蓝,在粉底上偏冷。改它需要「浅底 + 深主色文字」的对比度,现有 `--tb-primary-weak`
叠白后对比度只有 ~2.6:1,得先加一个 primary-strong 令牌,故未动。
### 顶栏配色再修(v=20261019h)
用户反馈「探索/注册链接底色粉色,但整条顶栏是灰色,很突兀」。实测部署版:顶栏 `#fff6f9`、
页面 `#fceef3` —— 近白横带压在饱和粉底上,肉眼就读成「白色/灰色的一条」。无头 Chrome 截图 +
像素采样确认不是 UIkit 默认 #f8f8f8 回退(那说明变量没生效,现象会连带整页失效)。
- sakura 浅色 `--tb-navbar` 直接取页面底色 `#fceef3`(深色同样 `#241a1f`),横带消失;
- 顶栏链接 `#999` 在粉底上偏淡 → 主题内微调为 `--tb-text-secondary`,hover 用 `--tb-primary`
(深色被 base.css 的 `!important` 规则接管,不受影响);
- 顺带 `uk-alert-primary`(匿名参与提示/空列表)的 UIkit 浅蓝是页面上唯一的冷色块,
改为 `--tb-primary-weak` 底 + `#a63a5e` 文字(≈5.3:1)。
- 以上三条都写在 sakura.css 的「组件微调」段,只对 sakura 生效,默认主题零影响。
## 主题「砂狼白子 / shiroko」(v=20261019i)
- `static/css/themes/shiroko.css`:灰蓝银白令牌(浅 #edf2f8 / 主色 #4a7fc1),
组件微调只有两条 —— `.uk-card-default` 右下角水印立绘(`background-size: auto 176px`,纯背景层不改布局)、
顶栏链接色提到 `--tb-text-secondary`;深色不铺立绘(浅色构图在深色下会糊)。
- 素材 `static/images/themes/shiroko/`(68KB):`bg-desktop.webp`(2400×1350) / `bg-mobile.webp`(900×1600) /
`hero-fade.webp`(275×620,16% alpha) / `pattern.svg` / `pattern-dark.svg`。
- **`static/fs.go` 的 embed 清单加了 `images/themes`**(目录项,子目录自动覆盖)——
这是「新增顶层静态目录必须手工登记」的又一次踩点。
- 背景是 PIL 合成的:1280 行的竖向渐变 + 白色径向光晕 + 立绘 30% 贴右下 + 狼 10% / 光环 20% / 纸飞机 18%。
实测 mean≈244、std≈9;右侧留白区 std 13(立绘可见),中间 std 1.9(正文区干净)。
- ⚠️ **`cover` 会按视口裁剪**:竖屏/超宽屏会把贴在图片最右的立绘裁掉,所以立绘不能太靠边 ——
位置要留在图片横向 24%~76% 之外就得配 `@media` 换图(本次桌面 16:9 + 移动 9:16 两张)。
- 生成素材(ImageGen)8 张里只有 3 张可用:线稿图案(狼/光环/纸飞机)抠白底干净;
2 张人像抠底失败(背景不是纯白,`background: transparent` 实际没生效),2 张场景背景几乎没有内容(std<5)。
教训:**没有视觉能力时,生成图要用 `mean/std/edge` 先验内容量**,别直接拿去合成。
- 素材来源与版权写在 `docs/theme-shiroko.md`(角色版权 NEXON / Yostar,非商用同人)。
- 启用需要在 `conf/app.ini` 的 `[theme] available` 里加 `shiroko`(示例配置已更新)。
## shiroko 插画重做:动物线稿 → Q 版人物(v=20261019k)
用户反馈「简笔画不是画动物,是人物形象」,插画层整体重做。
- 新增 `dev/gen_shiroko_chibi.py`:手写 SVG 生成 6 个 Q 版白子(wave/cheer/think/run/doze/peek),
3.8KB/张,造型逐项对齐用户立绘(灰发蘑菇头 + 圆钝狼耳 + 倾斜光环 + 青围巾 + 深蓝外套 + 灰裙 + 青黑球鞋)。
**生成模型这条路走不通**:`background: transparent` 不生效、无视觉反馈时无法判断好坏;
代码画路径反而可控、体积小、任意尺寸不糊。
- ⚠️ **cover 必然裁掉贴边插画**:1280×1400 视口下,2400×1350 的背景图只露出中部 1234px,
右下立绘被裁成 38px 一条。→ base.css 新增装饰层令牌 `--tb-deco / --tb-deco-size / --tb-deco-position`
(固定像素 + 四角定位 + fixed,不随视口比例变化)。
- ⚠️ **多背景层的值个数必须恒定**:deco 先写成 4 个值,background-image 变 6 层而 size/position 只有 3 个,
CSS 循环取值 → 整页重复小人。改成固定 6 个槽位(1 图案 + 6 装饰 + 1 背景 = 8 层),
主题必须写满 6 个(缺的填 none),这条写进了 `_scaffold.css`。
- 删掉 `bg-desktop.webp` / `bg-mobile.webp` / `hero.webp` / `dev/gen_shiroko_bg.py`;
整页底色改成纯 CSS 渐变(不用图片,也就没有 cover 缩放差异)。
- 深色关掉 deco(浅色线稿在深色底上糊成灰块);pattern 里的爪印换成 Q 版头像,
opacity 0.42→0.2、tile 240→320(原来铺得太密,像壁纸)。
- 验证:默认主题背景仍 `#f7f8fa`、顶栏纯白、卡片纯白(零回归);箱主页与 `/user` 后台四角装饰都不压内容;
移动端只留立绘 + peek。
## shiroko 插画二次返工:手绘 SVG → 参考立绘图生图(v=20261019l)
用户反馈上次的简笔画"太抽象"。根因判断:用户的立绘是**平涂无描边色块风**,
我用手绘线稿去配它,风格本身就冲突,而且没有真人手感,形准也做不到。
- 新方案:ImageGen **图生图**(`image1` = `static/images/shiroko-cutout.png`,`input_fidelity: high`),
提示词统一为「same character, same art style... flat vector illustration, no outlines」+ 换动作句,
生成 4 个姿态(wave / cheer / think / doze),1024×1024、纯白背景。
**四张角色特征与画风一致**,这一步是手绘做不到的。
- 抠图:四角 + 四边中点 8 点 floodfill(容差 16)+ alpha 1px MinFilter 收边,裁 bbox 后统一高 620、
WebP q90。实测无白边,光环中间的孔也正确透出。
- 删除 `dev/gen_shiroko_chibi.py` 与 6 个手绘 SVG;`chibi/*.webp` 4 张共 106KB。
图案里的手绘小头像也删了(和位图人物并排显得最抽象),改成雪花 + 光环 + 圆环 + 点簇。
- ⚠️ **deco 的 `center` 定位在矮视口会撞车**:右下立绘 520px 高,1366×768 下从 y=248 一直到底,
托腮放 `right center` 会直接站在立绘头顶。百分比是「(视口-图高)×pct」,改用 `top 46%` 并挪到左侧中部。
验证视口:1366×768 / 1440×900 / 1280×1400 三档,人物互不重叠。
- docs 补了完整提示词骨架、动作句与抠图参数,说明"必须平涂无描边"这条经验。
## shiroko 布局重排 + 新坐姿(v=20261019m)
用户反馈:左右不协调、最上边的头被顶栏挡住。
- 布局改为**左右各两个**(左上 wave / 左下 sit,右上 cheer / 右下立绘),
`≥920px` 真实视口才补中间两个(think / doze),矮屏不放(否则压到立绘)。
- 顶部定位从百分比改成**固定 `top 96px`**:顶栏实际高 60px(不是 80),
百分比是「(视口-图高)×pct」,小视口下算出来的值会落进顶栏里。
`bottom 0` 也改成 `bottom 6px`,避免脚贴页底。
- 新增坐姿(盘腿),ImageGen 图生图第 2 次才可用:第 1 张脚被画到画布底边(底部 78 像素贴边),
提示词补 `centered composition, whole body fully visible with generous empty margin` 后正常。
- ⚠️ **Chrome headless 的 `--window-size` ≠ CSS 视口**:实测 1080 → innerHeight 985、768 → 673
(差约 95px 的窗口边框)。所以 `@media (min-height: 1050px)` 在 1080p 下永远不触发。
判断媒体查询门槛要按「真实视口」写,截图验证必须加 `--force-device-scale-factor=1`,
否则系统 DPI 缩放会让截图尺寸和 window-size 也对不上(曾出现 1920 请求出 1088 宽)。
- git:仓库可用(origin = https://git.shiroko.one/tamakyi/tamabox,main),
已本地提交 `0c39aff`,但 `credential.helper=store` 里的凭据过期 → `Failed to authenticate user`。
打包 `../TamaBox-v4-mainline-20261019-2037.tar.gz`(1.5M,含图片素材)供直接部署。
### git push 补推
上次的 `Failed to authenticate user` 是临时态 —— 用户本机 Git for Windows 认证过之后 store 凭据恢复,
本轮 `git push origin main` 直接成功(`38cd41a..0c39aff`),`git rev-list --left-right --count origin/main...HEAD` = 0 0。
远端 8 个 shiroko 素材文件齐全。教训:**不要用一次 push 失败就断言无凭据**,先看一眼提交是否已存在、再试一次。
### 部署后「设置页找不到 shiroko」
线上实测:`/static/css/themes/shiroko.css?v=20261019m` 200 / 5038B 且与本地逐字节一致,
说明二进制确实带了新主题;但 `/user/profile` 渲染出来的主题单选只有 `""`、`default`、`sakura`
→ **根因是 `[theme] available` 白名单没登记 shiroko**(列表在 `theme.load()` 里按配置构建,启动时读一次)。
- `internal/theme/theme.go` 新增 `reportRegistry()` / `discover()`:启动打 info「主题注册表已加载 enabled=...」,
并把「css 在、但没登记」的主题以 warning 打出建议的 available 值。
没有这段时,这个故障在日志里完全没有痕迹,只能靠猜。
`discover()` 用 `fs.Glob(static.FS, "css/themes/*.css")`,用 slugRe 过滤掉 `_scaffold.css`(下划线开头)。
- `conf/app.sample.ini`:`available = default,sakura,shiroko` + 补充「没登记就不显示」的注释。
- `docs/theme-shiroko.md` 补「部署后看不到主题」两段排查(先 curl css 看 200/404,再看 available)。
- 排查口径:**先分「文件没发布」与「没登记」两类**,别一上来怀疑缓存;`StaticVersion` 只影响缓存,不影响列表。
### shiroko 装饰返工:散射 → 左下角一组(v=20261019n)
用户反馈「主题正常了,但展示方式怪怪的」。实测线上 1512×982 截图确认三处:
1. 四角各一个人 = 贴纸墙纸(人物之间、人物与内容都没有关系);
2. 半透明立绘(16%)叠在实心人物旁边 → 虚实混搭,像重影;
3. 右下角人物和右对齐的页脚文案(社区规则 / 备案号 / 存活时长)挤在一起。
改法:只留左下角一组(sit 330 + wave 190,`left 0/bottom 0`),删掉 hero-fade,
纹样减到雪花 + 光环 + 两点(opacity .16、tile 340),卡片补 1px 冷调描边(box-shadow 不占布局)。
990px 以下缩小左移,900px 以下(卡片占满宽)不出人物。
⚠️ **`background-position` 的四值语法必须以边关键字开头**:写成 `max(8px, calc(50% - 555px)) bottom 0`
整条声明作废并回落到左上角(表现为人物跑到顶上、背景出现错位色带)。
要贴卡片边只能 `left max(8px, calc(50% - 555px)) bottom 0`。
### 纹样横向条纹(v=20261019o)
图案元素原先全在 tile 上半部(雪花 y=52 / 光环 62 / 点 150、176),铺开后在整页形成
**横向条纹带**(左栏 60px 带宽均值 245→237→249,跳变 12),这是"看着怪"的第二个来源。
改成 3 行均布(52 / 132 / 152-212)后收敛为单调 245→236。
⚠️ 图片不走 `StaticVersion` 缓存键(`Cache-Control: max-age=86400`),换图案要自己给 url 加 `?v=`。
### 定稿:半隐在卡片后的单人(v=20261019p)
用户从 A/B/C/D 四版里选了 D(半隐在卡片后),并追加两条:人物离开底边一点、纹样再淡一点。
- `sit` 单人,`left max(6px, calc(50% - 515px)) bottom 26px`;宽度 177px,右缘压进卡片 40px。
- **离底边后必须配接触阴影**:一层 `radial-gradient(ellipse, rgba(63,96,138,.22), transparent 72%)`
128×15px,垫在脚下一格(deco 第 2 个槽位,绘制顺序在人物之下)。没有它,抬高 26px 会读成悬空。
- 纹样 opacity 0.13→0.085(深色 0.09→0.055);`?v=20261019p` 同步改。
- 剩余素材(wave/cheer/think/doze)保留但不上页面,换姿态只改 `--tb-deco` 的 url。
### shiroko 按二次元官网重做:整页场景 + 悬浮磨砂 UI(v=20261019q)
用户反馈「还是很怪,去网上学一下布局」。实际去看站:
- 蔚蓝档案 JP 首页(bluearchive.jp,截图存 outputs/ref/bluearchive_jp_.png):**整屏一张场景插画,
logo/按钮/横幅浮在画面上**,没有"白卡片"这层东西;
- marshmallow-qa / peing 这类真正的提问箱服务被 Cloudflare 拦,拿不到参照。
结论:前几版都卡在"白底 + 留白摆人物"这个结构上,人物没有环境,怎么调位置都像贴纸。
改法:生成教室场景(ImageGen,1536×1024)→ 裁掉底部 160px 水印 → 混白 38% → 模糊 1.4px →
WebP 1920×1080(22KB)/ 720×1003 竖版(6KB);卡片与顶栏改 `rgba(255,255,255,.86)` +
`backdrop-filter: blur(16px)`;人物 340px 站在左侧地面、离底 36px + 椭圆接触阴影;浅色不再叠纹样。
深色仍用渐变 + pattern-dark(白天教室压在深底上会脏)。
⚠️ **`ruleBody` 必须剥 CSS 注释**:令牌前写一行注释时,`tokenValue` 按 ";" 切完再按 ":" 取键,
会拿到 `/* ... */ --tb-surface`,色块预览静默变空、`TestThemeCSSComplete` 报"缺少令牌"。
已加 `commentRe` + `TestRuleBodyIgnoresComments`。
⚠️ ImageGen 出的图右下角有 `AI生成 WORKBUDDY` 水印,用作整页背景必须裁掉。
## 23:40 人像换成「窗边背影」
- 用户否掉了盘腿坐的正面人像("和背景没那么突兀的")。根因:正面直视 + 无环境 + 饱和度高,
在压白 38% 的空教室里就是一块贴纸。
- 新素材 `chibi/window.webp`(135×460, 13KB):文生图出侧后方 3/4 视角(双手背在身后)→ 再
image-to-image 转"纯色块无描边 + 去窗框"→ 抠白底 → **RGB 压白 16%** → 高 460 / q86。
第一步文生图会把窗框画进背景,抠不干净,必须靠第二步重绘。
- CSS:`--tb-deco` 首槽换 window.webp,高 380→400px,接触阴影 96×13 复位到脚底;`StaticVersion` → `20261019r`
(图片另带 `?v=20261019r`)。删除 `sit.webp`;`docs/theme-shiroko.md` 补两步做法与摆位说明。
- 提交 `e95671f` 已推送。教训:**人像与场景的"雾度"要对齐**——场景压白多少,人像也压相当比例。
-427
View File
@@ -1,427 +0,0 @@
# 2026-10-22
## new-sakura 主题:纯 CSS 樱花
用户要求「用编辑器生成 new-sakura,CSS 做樱花、每页位置不同、上传到已配好的 S3」。
- **结论:生成器做不了这个** —— 它的装饰层只支持图片素材(导出打 zip / 传对象存储),
没有内联 SVG 图层。所以改成手写主题 CSS(结构完全遵循生成器输出:令牌 + 10 槽 deco +
scope 覆盖),生成器只用来做视觉验证。
- 实现:`dev/theme-tools/gen_new_sakura.py` 生成 `static/css/themes/new-sakura.css`。
花/瓣/枝/底纹都是内联 SVG 的 data URI(`quote(svq, safe="")` 全编码,避免 URI 里的
逗号被 `TestDecoSlotCount` 当成分层符)。CSS 50KB(gzip ≈12KB)。
- 六个 scope 各一套 `--tb-deco` 位置;窄屏(≤640px)装饰全关、底纹缩到 200px;
深色换亮粉 + 关底纹(浅粉压深底=噪点)。
- 调参踩到的:①花枝 `auto 390px` 放右上角时可见部分只剩一团花 → 缩到 300px 并内移;
②枝上 5 朵 scale≈1 的花是一串糖葫芦 → 改成 8 朵 0.4~0.62;
③底纹 260px 双花双瓣读成「包装纸」→ 改成 300px 一朵花 + 两片瓣、各自错位。
- 对比度:primary 候选 `#c14a72` 只有 4.39:1,压深到 `#b8436a`(4.87);muted `#77626a`(5.27);
深色 `#ff93b4`(8.17)/`#ab9099`(5.82)。
- i18n 四份词典加 `theme.name.new-sakura`(插在 shiroko 之后,gofmt_keep_crlf 后每份只 +1 行);
`conf/app.sample.ini` 的 available 登记。
- ⚠️ 本机 Chrome/Edge 的 localStorage 里**没有**生成器的 S3 配置记录,项目也没有
`conf/app.ini` → 「已预先配置好的 S3」在本机取不到,上传这一步需要用户给凭据。
纯 CSS 主题本身零素材,严格来说 S3 无事可做(已向用户确认)。
**R2 自定义域可用 + 素材包一键上传(接上一条)**
- `r2.337845818.best` 实测公开可读(7 个对象全部 200、Content-Type 正确);桶的 S3 endpoint 仍 403。
- 樱花位图从 `gen_new_sakura.py` 的同一份 SVG 导出(`export_assets.py` 直接 import 它,不另画),
`--upload` 一次传 CSS + 5 张 webp + 生成的 INSTALL.txt(域名/体积写实数)。
- `serve.upload_files` 支持逐文件 `cacheControl`:原来硬编码 immutable,而
`INSTALL.txt` / 镜像 CSS 的键不带版本 → 改了看不到。现在位图 1 天、CSS 5 分钟、README 60 秒。
- ⚠️ **CF 边缘缓存不会因为重传而失效**:第一版以 immutable 传过,`cf-cache-status: HIT`、Age>800,
重传后拿到的仍是旧副本(带 `?cb=` 才是新内容)。清缓存只能去控制台按 URL 清。
- ⚠️ **gzip 纠错**:服务端 `internal/route/gzip.go` 会压 CSS,内联版 51KB 实际传输 **3.9KB**,
位图版 2.7KB + 5 张图 48.9KB ≈ 51.6KB。所以"换位图省 CSS 体积"是错的 ——
位图唯一的理由是素材要给非 CSS 场景复用。文档与 INSTALL.txt 都已改。
- `image_bucket_cdn_host` 沿用 app.ini 同名键(只写主机名),`export_assets.py` 据此拼公开地址。
## 线上 521 排障(box.shiroko.one 打不开)
- 现象:`https://box.shiroko.one` 全路径 **521**,同机 IP+端口正常。
- 实测链路:`box.shiroko.one` → CNAME `cloudflare.182682.xyz`(第三方 CF SaaS 接入/优选,
上游 `source.cfnode.eu.org`),**不是** `<UUID>.cfargotunnel.com`。DNS/TLS 均正常(tls=0),
响应体 `error code: 521`,5 次请求全 LAX,0.6s 快速失败(RST 特征,非超时)。
- 对照实验有价值:`box.tama.guru` 走**同一个** CNAME 目标却正常 301 → 中转 zone 边缘健康;
它不回源,而 box.shiroko.one 要回源才 521 ⇒ 断点在「CF → 源站」这一段。
- `shiroko.one` / `www` / `tama.guru` apex 都 CNAME `navi-ew2.pages.dev`(CF Pages 导航站),与本站无关,
别拿它们的 200 当证据。
- 候选根因(待服务器侧确认):① cloudflared 断开(严格说 tunnel 无 connector 多为 502/1033);
② `scripts/edgeone-origin-whitelist.sh` 的 `REJECT tcp-reset` 拦掉 CF 直连回源——脚本注释里
「521」本就是白名单漏放的既有症状;③ 回源 IP 过期(服务器 IP 变动 / 第三方面板未更新)。
- 注意矛盾点:用户本地能连 IP+端口 ⇒ 白名单大概率没生效或不覆盖该端口 ⇒ 更偏向 ③/tunnel 侧。
**根因已确认(服务器实测)**
- `systemctl is-active cloudflared` = **inactive**,`pgrep` 无进程 → Tunnel 断。
journald 最后一条 2026-10-21T21:42:22Z(CST 10-22 05:42),此后无日志 ⇒ **停了约 7 小时**。
- 死前日志模式:`accept stream listener encountered a failure while serving` +
`failed to accept QUIC stream: timeout: no recent network activity` + `datagram handler
error="context canceled"` + `Retrying connection in up to 1s`,connIndex 2/3 反复 →
UDP 7844 QUIC 长时间不稳(美西 23.94.211.138)。**inactive 而非 failed ⇒ 是被正常 stop 的,
不是崩溃**,与用户「昨晚升级 nginx 到 1.30.x」的时间点吻合(升级流程连带停服)。
- 应用侧全好:`*:5877` 监听(tamabox pid),loopback 200。
- `iptables -S INPUT` 只有 `-i eth0 --dport 8118/5877 -j DROP`,**EO_IN / ipset EO_ORIGIN 都不存在**
⇒ EdgeOne 白名单脚本当前未生效。且 DROP 限定 `-i eth0`,网卡名若不叫 eth0 则规则空转。
- 关键推论:CF 一定走 tunnel 而非直连 5877(否则 DROP 空转应 200 而非 521)⇒ 521 完全由 tunnel 断解释。
**续:start 之后仍 521**
- 用户确认:**5877 只允许 Tailscale(100.84.113.84:5877)访问**,公网靠 iptables DROP 封掉。
⇒ 排除「CF 直连公网 5877」这条路径,CF 只能经 tunnel 回源。
- `systemctl start cloudflared` → `active`,`sleep 5` 后 curl 域名仍 **521**。
- 待分辨:① connector 尚未完成边缘注册(sleep 5 偏短);② 本机 tunnel 与 CF/SaaS 侧
`box.shiroko.one` 绑定的 origin(应为 `<uuid>.cfargotunnel.com`)不是同一个;
③ 该 hostname 的 Public Hostname / ingress 未指向 `http://127.0.0.1:5877`。
- 决定性隔离实验:临时 quick tunnel(`cloudflared tunnel --url http://127.0.0.1:5877`)
拿 trycloudflare 域名直测 —— 200 则应用+出站正常,问题在 CF 侧绑定。
**journald 取证(关键)**
- `protocol=quic`,`location=la01`。从 10-21 13:53:30 起就是死循环:
`INF Registered tunnel connection` → 约 3s 后 `ERR failed to accept QUIC stream:
timeout: no recent network activity` + `datagram handler error="context canceled"` +
`WRN failed to serve tunnel connection error="accept stream listener encountered a failure
while serving"` → `INF Retrying connection in up to 1s` → 再注册。connIndex 0/1/2 全部如此,
每 ~3 分钟一轮,一直持续到 10-21 21:42:40。
- ⇒ **UDP 7844 的 QUIC 在这台机器上基本不可用**(RackNerd 美西 → CF la01),这是历史顽疾,
不是本次新故障。根治手段 = `TUNNEL_TRANSPORT_PROTOCOL=http2`(走 TCP 443)。
- 🔴 但 12:56 `systemctl start` 之后 journal 里**一条新日志都没有**(最后仍是 10-21 21:42:40,
PID 626)⇒ 新进程疑似启动即退出/token 无效,`is-active=active` 可能是重启窗口里的瞬时值。
需 `systemctl status` 看 Restart 计数与 ExecMainStatus。
- 旁证:EdgeOne 那条备用通道同样不通(18080 无监听、EO_IN/ipset 均不存在)。
**结论定案**
- 重大纠正:cloudflared **从未死过**。`systemctl status` 显示 `active (running) since 2026-10-20 05:32:44`,
Main PID 626 存活 2 天+。前面「21:42 后无日志」不是进程退出,而是 QUIC 连接最后注册成功后没再报错
(只有 24h 一次的版本提醒)。所以「tunnel 断导致 521」这个假设是错的。
- 改 `TUNNEL_TRANSPORT_PROTOCOL=http2` 后:4 个 connector 全部 `Registered tunnel connection
... protocol=http2`,`SUMMARY: Environment is healthy. Cloudflared will use 'http2' as primary
protocol`,零错误 ⇒ **tunnel 侧完全健康**。QUIC 抖动问题也顺手解决了(该 drop-in 值得长期保留)。
- 但站点仍 521 ⇒ **问题 100% 在 CF 侧 origin/路由,与本机 tunnel 无关**。
- 公网实测 `23.94.211.138`:5877 / 18080 / 80 / 443 **全部 timeout**(不止 5877)。
⇒ 若第三方 SaaS 的 origin 填的是「公网 IP:5877」,CF 同样连不上 → 521。
这解释了「突然」:用户近期把 5877 收敛为仅 Tailscale(100.84.113.84)可达。
- 本机 tunnel 身份(从 token 解出,注意 token 已明文暴露需轮换):
tunnel id `ba34e35d-e517-498a-b2cd-7275c22e354e`,account tag `0c99c40b475a65b393abaca3ca91731b`。
- 修复方向:①(推荐)第三方面板把 origin 改成 `ba34e35d-...cfargotunnel.com`,并在
Zero Trust → Tunnels → 该 tunnel 的 Public Hostnames 补 `box.shiroko.one → http://127.0.0.1:5877`;
②(不推荐)放行 CF 回源网段到 5877,等于重新暴露源站。
- ⚠️ 安全 P0:tunnel token 在对话截图里明文出现,等同凭据泄露 → 必须 Refresh token 后重装服务。
**真凶:SaaS origin 指向被封的公网**
- SaaS 自定义主机名 `box.shiroko.one` 的「源服务器」= `box-direct.337845818.best`。
- 实测该 origin 域名 **解析到 Cloudflare 自己的 IP**(104.21.47.52 / 172.67.144.141,
`Server: cloudflare`,CF-RAY)⇒ 它是**橙云(proxied)记录**,不是灰云 A 记录。
`http://` 301(Always Use HTTPS)、`https://` **521** —— 它自己也回源失败。
- 源站 `23.94.211.138`(colocrossing,RackNerd 上游):22 / 80 / 443 / 5877 / 18080
**全部 timeout**。不只应用端口,连 SSH 都不通 ⇒ 该机对公网几乎无入站(仅白名单/Tailscale)。
- 结论链:CF 回源 `box-direct` → 该记录橙云 → 真实源站端口被封 → **521**。
所有共用 `-direct` origin 的站点同时 521 ⇒ 用户说的「cloudflared 构建的网站全部 521」
与 cloudflared 无关,是**公网入站收敛**这一个动作把整批站点打掉的。nginx 升级纯属时间巧合。
- 修复(保持「只允许组网」的安全目标):① Zero Trust → 该 tunnel → Public Hostnames 加
`box.shiroko.one → http://127.0.0.1:5877`;② SaaS 面板把每个自定义主机名的「源服务器」
从 `xxx-direct.337845818.best` 改为 `ba34e35d-e517-498a-b2cd-7275c22e354e.cfargotunnel.com`。
注意不能把 `-direct` 改成「灰云 CNAME 到 cfargotunnel」——tunnel 路由要求橙云,SaaS origin 又
要求非橙云,两者冲突,必须直接在 origin 字段填 cfargotunnel。
**⚠️ 纠正(官方文档)**
- 上一条的后半段判断错了。CF for SaaS 文档明确:custom origin **必须**是该账号下
**proxied(橙云)的 A / AAAA / CNAME** 记录,不能用 IP。
⇒ `box-direct.337845818.best` 是橙云,**格式上完全正确**,不是配置错误。
用 tunnel 的正确形态就是「橙云 CNAME → `<uuid>.cfargotunnel.com`」,所以那条路径依然成立,
只是理由不是「橙云错了」。
- 真正断点仍是:`23.94.211.138` 的 **443 从公网不可达**(实测 timeout)。
但用户贴的 `iptables -S INPUT` 里**只有 8118 / 5877 两条 DROP,没有 443**
⇒ 对 443 的封锁来自 iptables 之外(云厂商安全组 / 上游 / 其他链),或者 nginx 压根没监听 443。
- 我上一轮的探测命令 `grep ':(80|5877|18080)'` **漏了 443**,这是取证缺口,需要补
`ss -tlnp | grep -E ':(443|80|5877)'` + `systemctl is-active nginx`。
- 注意:SaaS 回源默认 Host = 原始自定义主机名(box.shiroko.one),而 tunnel 的 Public Hostname
只能从账号内 zone 选(shiroko.one 不在 CF)⇒ 走 tunnel 时必须用 Origin Rule / 源站主机头覆盖,
否则 tunnel 会 404。
**最终根因(用户自查确认):nginx 停了 11 小时**
- 时间线:`10-22 00:12:43 Started`(升级后)→ `02:31:10 Stopping...` →
`02:31:15 Stopping timed out. Terminating.` → `Failed with result 'timeout'` → `Stopped`
→ **中间 11 小时无任何记录** → `13:34:42` 用户手动 `systemctl start` 才起来。
- ⇒ 全站 521 的直接原因就是 nginx 不在 443 监听;CF 回源 `box-direct`(橙云 A → 23.94.211.138:443)
拿不到东西。cloudflared/tunnel 全程健康且**从未参与这条链路**。
- 重启后实测:`box.shiroko.one` 连续 3 次 200,`box-direct` 200,源站 443 OPEN。
- 教训(我的误判链,值得记住):排障时先确认「443 上跑的是什么、进程在不在」(`ss -tlnp` +
`systemctl is-active nginx`),再往 DNS/CDN/tunnel 方向挖。这次因为 CNAME 指向第三方 SaaS
+ cloudflared 恰好健康,被带了很远。
- 遗留技术债:
① `listen 443 ssl http2` 在 1.25.1+ 已废弃 → 改 `listen 443 ssl;` + `http2 on;`;
② `Failed to parse PID from file /run/nginx.pid: Invalid argument` —— 大概率是上次 SIGKILL
残留的 pid 文件,配合 `TimeoutStopSec` 过短(5s 就 Terminating)导致 stop 卡死;
③ 全站无可用性监控(11 小时空窗没人知道)→ 建议外部 uptime + cron watchdog
`*/5 * * * * systemctl is-active --quiet nginx || systemctl start nginx`。
**6) 窄屏覆盖被作用域块压死(真 bug,已修)**
- 现象:`@media (max-width:640px)` 里把 `--tb-deco` 全写 none,手机上却照样是桌面装饰层。
- 根因:媒体查询**不提升特异性**。作用域块 `html[data-theme-style][data-page-scope="box"]`
是 (0,2,1),媒体里写的 `html[data-theme-style]` 只有 (0,1,1) → 压不住;深色作用域块 (0,3,1) 更难。
- 修法:媒体查询里按「作用域通配 `[data-page-scope]`(0,2,1)」+「深色基础(0,2,1)」+
「深色+作用域(0,3,1)」各写一条,靠文件更晚取胜。shiroko 的 900px 块只对浅色有效,
但它的深色块本来就是 none,所以不算坏。
- 守门:新增 `internal/theme/narrow_test.go::TestNarrowMediaOverridesScopedDeco`
——外层每处非 none 的 `--tb-deco`,媒体查询里必须有「同属性名、属性数不少于它」的选择器;
变异验证过(删掉深色基础那条立刻 FAIL)。
- 手机端最终决定**不放装饰层**:试过顶栏与卡片之间那条 35px 带子,box 页 OK、question/admin
页卡片从 y=110 起就切半个 → 变成黏在卡片边的粉色碎块。改用加强版底纹(透明度 ×1.9、格子 180px)。
- 埋点:`gofmt -l internal/theme` 会报 `slots_test.go`(它是 CRLF),新增文件是 LF,别顺手格式化它。
## 站内公告功能(配置驱动)
- 新增 `[announcement]` 段(content/start/end),改完重启生效;判定全在服务端按 time.Now()。
- 改动:conf/announcement.go(解析+Active+单测)、conf.Init MapTo、keynorm sectionKeySets 登记(漏了会误报未知段)、
template.FuncMap 新增 `announcement`(返回安全 HTML,空串 falsy 配 `{{ with }}`)、
internal/template/announcement.go(切段转义→URL 包链接,不能「先转义再正则」否则 &#34; 被吞进 URL)、
header.html 导航下方插 `uk-container-xsmall` 容器包 `.tb-announce`、base.css 样式(StaticVersion → 20261022a)。
- 时间格式:`2006-01-02 15:04` / 带秒 / 纯日期 / RFC3339;裸时间按服务器本地时区(容器是 UTC),启动 INFO 打印解析结果。
- 时间写错只 WARN 关公告不阻断启动(与未知键口径一致);窗口左闭右开。
- 预览验证:TEST_PREVIEW=1 + 临时 test 设 conf.Announcement 渲染 question/list,chrome --headless=old 截图 OK(亮/暗都看过)。
- 公告间距修复:`.tb-announce` 自身的 margin-bottom 会被 UIkit 的
`.uk-container > :last-child { margin-bottom: 0 }` 清零 → 间距必须挂在容器类
`.tb-announce-wrap { margin-bottom: 20px }` 上。这是容器内末元素贴边的通用坑,
以后在 `.uk-container` 里加末位区块都绕这条。
- 环境坑:沙箱 go 是 1.25.5,go.mod 要 1.27.3,GOTOOLCHAIN=auto 需下载工具链模块,
而 GOSUMDB=off 会卡住校验 → 下载工具链那次要放开 GOSUMDB(goproxy.cn 会代理 sumdb)。
**7) 顶栏:实色 → 半透明磨砂(用户:不能全透明,以后别再来一次)**
- 线上 `https://box.shiroko.one/_/tamakyi` 实测:`--tb-navbar=#fdf6f8` 实色,顶栏带(y=20..80)
把右上花枝齐刷刷切一段;`--tb-navbar` 默认是 `--tb-surface`,彩色主题不覆盖就成一条近白横带。
- 第一版改成 `transparent` 被用户否掉:花枝完整了但顶栏"消失",导航没依托、文字压在花纹上。
- 最终:`rgba(253,246,248,0.72)` / `rgba(34,26,30,0.72)` + `backdrop-filter: blur(12px)`
(模糊只能写在规则里,令牌只是颜色)。0.72 有依据:文字 `#6d555d` 纯底 6.34:1,
最坏(深花瓣压顶栏后面)压平后 5.08:1。
- 花枝 `top` 从 -16px 下移到 26px:顶栏透明后 700~760px 宽度下枝头会压到「登录」(截图确认)。
- 守门:`internal/theme/navbar_test.go::TestNavbarNotFullyTransparent` 拦
`transparent` / `rgba(...,0)` / `#rrggbb00`,变异验证过。约定同步写进
`static/css/themes/_scaffold.css` 的令牌注释与 `topics/theme.md`。
- 手机底栏 `#tb-mobile-utils` 在 base.css 写死 `rgba(255,255,255,.92)`,彩色主题要自己调(本例改同色系 0.92)。
- ⚠️ `StaticVersion` → `20261022c`:主题 CSS 改了必须提版本号,否则浏览器拿着旧 css,
现象就是「改了但线上没变」。
- ⚠️ 跑 go 命令时**别加 `GOSUMDB=off`**:本机 go 1.25.5,go.mod 要 1.27.3,
`GOTOOLCHAIN=auto` 要校验 toolchain 模块,关掉 sumdb 会直接报 "verifying module"。
只设 `GOPROXY=https://goproxy.cn,direct GOFLAGS=-mod=mod` 即可(toolchain 已缓存)。
**8) 生成器新增「顶栏」面板:实色 / 半透明(磨砂)/ 全透明**
- 状态 `S.navbar = {mode, alpha, blur}`(默认 glass 0.72 / 12px),基色仍取「配色 → 顶栏」,
`navbarValue(p)` 统一算:solid → `rgb()`(先 flatten 压平旧工程里的 rgba)、glass → `rgba()`、
clear → `transparent`。模糊单独写 `.uk-navbar-container:not(.uk-navbar-transparent)` 规则,
一条同时管深浅(选择器不带 data-theme)。
- **全透明只给预览**:`validate()` 直接拦(导出报错),与 `TestNavbarNotFullyTransparent` 同一条规矩。
面板上写明原因 —— 不然下一轮又会有人问「为什么不能导出」。
- 对比度读数按「压平后的顶栏」算(半透明时是顶栏压在底色上的结果),低于 4.5 变橙。
- 旧工程迁移:配色里塞过 `rgba(...,0.52)` 的,按 alpha 反推档位(1→solid,0→clear,其余→glass)。
- 自检 `?dump=navbar` 10 项;Playwright 端到端 17 项(含滑杆跟手、刷新后保持、迁移三例)。
- ⚠️ `?fresh=1` 每次加载都会清草稿 —— 测「刷新后配置还在」必须导航到不带 fresh 的地址。
- ⚠️ Playwright 的 `fill()` 对 `input[type=range]` 报 Malformed value,要 evaluate 设 value + dispatch input。
- ⚠️ 滑杆步长别用 0.02:0.55 会被吸附成 0.54/0.56,用户以为坏了。改 0.01。
## 单题页「下一条未读」(箱主收件箱式导航)
- 新增 `db.Questions.NextUnread(ctx, userID, excludeID)`:未读 = `is_read=false AND answer=''`,
`created_at ASC` 取第一条,无则返回 `(nil, nil)`。正序=先来先处理,反复取即可清空积压。
- `POST /_/{domain}/{qid}/next-unread`(`question.NextUnread`):**先 SetRead 再跳**。
只跳不标记会「打转」—— 当前这条仍是未读,下一次取最早未读又把它选回来(2 条未读时最明显)。
- `PublishAnswer` 支持同表单第二个提交按钮 `name="next" value="1"`(「回答并下一条未读」)。
- 按钮位置:回答行 `[回答][回答并下一条未读]`(箱主+有下一条时);页脚与「返回提问箱」同一行
放 `下一条未读 ›`(POST 表单 + `uk-button-link`,因为要改状态)。
- `adminBackQuery(ctx)` 抽出(原 BackTo 逻辑),`NextQuery` 进模板 → 答案表单/next-unread 表单的
action 都带上 `?from=admin&sort=...`,跳转后续「返回」仍回带筛选的后台列表。
flamego `ctx.Query` 只读 URL query,所以 POST 必须把参数写在 action 里(不是隐藏域)。
- 预览:`templates/zz_preview_test.go` 新增 `writeQuestionOwnerPreviews`(未回答/已回答 × 深浅)。
⚠️ 预览 HTML 的 `base.css` 是 `/static/...` 绝对路径,file:// 打开**不含主题变量**(暗色看着像亮色);
要验暗色必须 `python -m http.server` 起本地服务走 http。移动端预览本身有横向溢出(waline 表情行所致),非本次改动。
- 移动端排版实测(关键数据):390px 视口下 `.tb-ans-row` 只有 **300px**(360px 视口 270px),
而「回答 90 + 回答并下一条未读 174 + 标记为已读 132 + 间距 20」= 416px,**收窄内边距也不够**(320px)。
→ 手机上一行放不下三个按钮,改成:第 1 行保留原「回答 … 标记为已读」,第 2 行「回答并下一条未读」整行宽。
实现:`@media(max-width:640px){ .tb-ans-actions{display:contents} .tb-ans-state{order:2;margin-left:auto}
.tb-ans-actions .uk-button{order:1} .tb-ans-actions .uk-button.tb-ans-next{order:3;flex:1 1 100%} }`
⚠️ `.tb-ans-actions .uk-button`(0,2,0)特异性高于 `.tb-ans-next`(0,1,0),必须再写一条同级选择器盖 order。
- 页脚两个次级操作之间加 `.tb-foot-sep`(`·`,浅色 #b0b6bd / 暗色 #5c6674),原来两个蓝色链接几乎连成一片。
- Chrome headless 在 Windows 上 `--window-size` < 500 无效(innerWidth 恒 500)——要测真·移动端布局,
用「父页面 + 宽 390 的 iframe」再取 `contentDocument` 量尺寸/截图,媒体查询才会按 iframe 宽度生效。
- 文案定为「回答并进入下一条未读」(en: Answer & open next unread / ja: 回答して次の未読を開く)。
实测宽度:该按钮 202px;661px 视口(桌面态最窄)行宽 526 ≥ 90+202+132+20=444,仍单行;
640px 断点两侧切换干净,无需再调。
## 近期新增功能审阅(10-18~10-22)
- 编译(s3 overlay 桩)+ conf/template/theme/route/security/templates 全测试通过,无 P0。
- 🔴 **P1 实测复现**:`NextQuery` 不含 `from=admin`,而 `adminBackQuery` 首行要求它
→ 「回答并下一条未读」/「下一条未读」POST 后筛选上下文全丢,「返回」掉回箱主页。
实测:`POST /answer?sort=new&status=unread` → `from="" backQuery=""`;
带 `?from=admin` 才返回 `sort=..`。修法:`adminBackQuery` 里 `backQuery.Set("from","admin")`,
并把 `PublishAnswer` 普通分支(question.go:279)也改用 `questionPath`。
- P2:公告单语言(四语站点);裸时间按 time.Local(容器 UTC)易偏 8h;
NextUnread 无 `(user_id,is_read,created_at)` 复合索引且忽略后台筛选;公告出现在 pixel 画板页
(xsmall 容器 vs 全宽画布);new-sakura 磨砂顶栏 backdrop-filter 低端机掉帧风险;
`conf/app.sample.ini:296` 注释行尾残留 `,shiroko`。
- 蜜罐仅做代码审阅(用户要求不进用户说明):无正常用户误封路径,前提是 `trust_proxy` 已开。
- 交付:`outputs/2026-10-新增功能审阅报告.md`、`outputs/2026-10-功能说明.md`(面向一般用户)。
## 修 P1:后台上下文丢失(已落地)
- `adminBackQuery` 改为 `url.Values{"from": {"admin"}}` 起手:返回串同时用于模板 action
与 `questionPath`,POST 回来才能重新认出来源(Flamego `ctx.Query` 只读 URL query)。
- `PublishAnswer` 末尾裸重定向 → `questionPath`,让「回答」这条也保留筛选。
- 新增 `route/question/admin_backquery_test.go` 两条用例;build + route/templates/conf/template 测试全绿。
- 副作用(可接受):`BackTo` 拼成 `/user/questions?from=admin&sort=..`,列表页不读 from。
- `question.go` 是 CRLF:改完跑 `python dev/gofmt_keep_crlf.py`,diff 只有 6 增 2 删。
## 修 P2:公告分语言 / 东八区 / 未读索引(已落地)
- **公告分语言**:新增 `content_zh_cn/_zh_tw/_en/_ja`,`content` 降级为默认回退;
`hasContent()` 改成「任一语言非空即开启」(只看 content 会把「只写英文」误判成未配置)。
模板函数签名变为 `announcement .LangCode`(header.html 同步),判定仍在服务端。
- **时区**:新增 `[announcement] timezone`,默认 `Asia/Shanghai`;`import _ "time/tzdata"`
保证容器无 zoneinfo 也能 LoadLocation。RFC3339 仍以自带偏移为准。
启动日志扩成 `zone/languages/start/end/active_now`,解析结果直接带 `+08:00`。
- **索引**:`idx_questions_user_unread_created (user_id, is_read, created_at)` 加进
`ensureFrequentlyUsedIndexes`;本机测试库 psql 实跑两次验证语法 + 幂等。
- 测试:conf 侧 7 组(语言回退/归一化/东八区/时区覆盖/无效时区)、
templates 侧 2 条渲染断言(zh-CN 与 en 只出现各自文案、窗口外不渲染 `tb-announce`)。
- 未改:`NextUnread` 仍不看后台筛选(产品取舍);画板页公告问题按用户确认忽略(画板已下线)。
## 测试站部署验证(box-dev-api)
- 结论:部署的确实是最新代码。判据:`base.css?v=20261022c`、CSS 含 `.tb-announce-wrap`、
`--tb-primary:#0e6fd0`、`themes/new-sakura.css` 含 `backdrop-filter`。
- **公告分语言在线上生效**:同一页面切 `/i18n/{zh-CN,zh-TW,en,ja}` 拿到四套不同文案,
链接渲染成 `<a target="_blank" rel="noopener noreferrer nofollow">`。
- 移动端(CDP `Emulation.setDeviceMetricsOverride` 390×844, mobile:true):
`scrollWidth == innerWidth == 390`,无横向溢出,公告块 right=390 贴容器边 —— 正常。
⚠️ 纯 `chrome --headless --screenshot --window-size=390` 的截图会让人误判「被切」,
真要量布局用 CDP + mobile:true,别只看图。
- 外部验证不到的三项:时区解析(看启动日志 `zone=Asia/Shanghai` + `+08:00`)、
复合索引(`SELECT indexname FROM pg_indexes WHERE tablename='questions'`)、
「下一条未读」(需箱主登录,且不能靠 POST 探测 —— 路由缺失会落到蜜罐返回 200 假登录页)。
- 偶发一次 CF 520(同 URL 复测 3 次均 200),未复现,非部署问题。
- 本机 curl 走 `http_proxy=127.0.0.1:58589`,node 直连本地调试端口要 `NO_PROXY=127.0.0.1`。
## 登录态实测(自建测试账号)→ 又抓到一个 P1 残留
- 账号:`tbunread<ts>@example.com` / TbUnread!2026(脚本 `Temp/tb_unread_check.py`,注册→发提问→登录→走流程→删题)。
- 🔴 **模板 URL 上下文会把 `?{{ .NextQuery }}` 整段 percent-encode**:
实测渲染出 `action=".../answer?from%3dadmin%26read%3dunread%26sort%3dnew"`,
`ctx.Query("from")` 永远为空 ⇒ 浏览器提交时 P1 修复失效。之前只测了 handler 层,没测渲染结果。
本地 html/template 实验确认:`action="{{ .A }}"`(值是完整 URL)→ 只把 `&` 转 `&amp;`;
`action="...?{{ .B }}"`(值是查询片段)→ `%3d`/`%26`。**以后 query 一律在 handler 拼完整 URL。**
- 修法:Questioner 里生成 `AnswerAction` / `NextUnreadAction`(完整 URL),模板直接引用;
守门 `templates/question_action_test.go`(断言无 `%3d`/`%26` 且有 `&amp;sort=new`)。
- 顺带确认(线上):单题页「回答并进入下一条未读」跳转、页脚「下一条未读」跳转、
全部读完的「没有未读提问了」提示 —— 三条行为都正确,`Location` 带 `from=admin&read=unread&sort=new`。
- 断言坑:页面上有 `/user/questions`(导航)和 `/user/questions?from=admin&amp;…`(页脚返回)两个链接,
用 `re.search` 取第一个会误判「返回丢参数」。要按 href 全量匹配。
## 部署后复验:14/14 通过(含「日志重复」排障)
- 复验结果:`answer?from=admin&amp;read=unread&amp;sort=new`(未被编码)、
回答并下一条 → 次早、页脚下一条 → 再下一条、读完后「没有未读提问了」、
下一条页「返回」= `/user/questions?from=admin&read=unread&sort=new`。数据已删净(0 条)。
- 🔴 **日志「一直重复同一个操作」的真相:是自检脚本,不是站点。**
两个原因叠加:① 补数据循环 `while len(ids) < 3` 没有上限、不校验 POST 结果;
② 每轮发的**内容完全相同**,被 `shared.DuplicateGuard`(同 IP + 相同内容防刷屏,
`route/question/page.go:587`)拦下 —— 返回 200 + 错误提示但不落库,脚本没读提示于是无限重发。
→ 泛化教训:跑端到端脚本时,注入的文本必须逐条不同;循环必须有 attempts 上限 + 「未增长即停」。
- `urllib` 偶发 `TimeoutError` 的根因是代理复用 stale keep-alive 连接:请求头加
`Connection: close` 后不再出现,比加重试更有效。
- 测试账号 `tbunread1022181904`(自建,密码不入库)。提问已全部删除,账号可留作后续自检。
## 测试账号存档(凭据不入库)
- 用户要求「记住别再忘」→ 凭据写在 `.workbuddy/local/test-accounts.md`,
`.gitignore` 已加 `/.workbuddy/local/`(明文密码不随仓库走)。
- 内容:`tbunread1022181904@example.com` / 域名 `tbunread1022181904` / 密码 + 登录四步
(`/login` 取 csrf → `/api/captcha/generate` 算数学题 → POST `g-recaptcha-response="<id>:<答案>"`)。
- 以后要跑登录态自检**先读这个文件复用账号**,不要再注册新的;`admin6` 仍只有域名、无密码。
# 2026-10-22
## shiroko 平涂极简肖像(参考用户给的图)
用户给了一张参考图:平涂零描边、纯黑圆角矩形眼、无嘴、腮红、头占满画面被裁边,
要求做同风格的白子,位置他自己放。
- 产出 3 张透明 WebP:`static/images/themes/shiroko/portraits/portrait-{front,tilt,close}.webp`
(907–1024 × 921,共 197KB),未接进 `--tb-deco`,留给用户自己摆。
- ⚠️ **ImageGen 的 `background: "transparent"` 依然不生效**:返回的是 RGB 无 alpha 通道(实测 mode=RGB)。
- ⚠️ **不能按白底抠**:白衬衫会一起被吃掉,闭合光环内部的白色 floodfill 也进不去(留下白斑)。
解法:提示词里把背景写成一个**具体纯色** `#E6EEF8`,抠图时按该色值算 alpha:
`alpha = clip((maxChannelDiff - 6) / 12)`,再 `MinFilter(3)` 收 1px。实测头发 alpha 全 255,
中间过渡只占 2.5–6%(全是边缘),不会被误抠。
- 水印在右下角("AI生成 WORKBUDDY"),统一裁掉底部 10%,切口平直,正好可以贴页面下沿。
- 提示词关键:no outlines / no gradients、眼睛写死 `solid black vertically elongated rounded rectangles`
(否则出白眼球+高光+睫毛)、无嘴要重复强调。3/4 侧脸那版还是画出了嘴唇,弃用。
## 分享卡「二维码下链接不对齐 + 长链接乱掉」(已定位,待批准落地)
- 根因在 `route/question/share.go` 的**字宽估算**:`charPixelWidth` 把 `unicode.Punct/Symbol`
一律按 1.0em 计,而 URL 里的 `: / . _ -` 在 Noto Sans SC 里只有 0.25~0.35em。
- 实测(NotoSansSC + 本机原型 `dev/tmpcard`):`https://box.shiroko.one/_/tamakyi` 15px 真实
227.9px、估算 352.5px(+55%);卡片可用宽仅 225px → 触发缩字号到 **9.57px**,实际只画 145px 宽,
且起点按估算算 → 整段左偏 **40px**。dev 域名 7.24px / 偏 39px;长域名 5.26px / 偏 34px。
- 纯中文不受影响(CJK 就是 1em,偏差 0);底部水印 `Powered by 狼的提问箱` 偏 9.8px。
- 正确测法:`Font.HMetric(scale).AdvanceWidth` + `Font.Kern` 累加,scale=size*64(DPI 72)。
实测与 `freetype.Context.DrawString` 返回笔位完全一致(227.88 vs 227.88)—— 以后居中文字都用它,
别再靠字符宽度系数猜。
- 只修测量还不够:等比缩字号没有下限也没有断行/省略兜底,长链接必然掉到 5px 以下。
- 另记:QR 用 `qr.Image(256)` 再 NearestNeighbor 到 165,41 模块 → 每模块 4.02px,模块宽窄不一;
`qr.Image(-k)` 按模块整数倍直接出图更锐(41*4=164)。
- 交付:`outputs/sharecard-link-compare-2026-10-22.png`(现状/修复后对照)。
**已落地(用户选方案 A:单行优先,放不下才拆 host/path)**
- `measureTextWidth` 改为 `HMetric.AdvanceWidth + Kern` 累加,字体不可用时才回退估算;
所有 `drawTextCentered` 自动吃到修正(链接、水印、提示语)。
- 新增 `displayURL`(去 `https://`)/ `layoutLink`(单行 15→13 → host+path 两行 13→11 →
host 中间省略 @11)/ `ellipsizeMiddle`;`LinkMaxWidth = QRCardW-40 = 230`。
- 卡片内容高度改为按行数算,两行时 startY 自动下移(281 ≤ 315,顶距 17)。
- 守门测试 `route/question/share_layout_test.go`:测量=笔位(0.01px 级)、限宽、
居中(按着墨边界 ±3px)、`TEST_PREVIEW=1` 出图。**变异验证过**:把 measure 换回估算,
三条用例全 FAIL(偏移 39/59px,且文字真的溢出卡片 615 < 660)。
- 删掉了临时原型 `dev/tmpcard`(结论已进测试与 outputs 图)。
- P1 未做(二维码模块整数倍、间距 4px 栅格、水印相对左侧内容区居中)—— 等用户看完效果再定。
- 已推送 main:`0054e50`(修复 + 测试 + go.mod 依赖转正)、`80e042d`(记忆/忽略规则),等测试环境部署复验。
**dev 部署后复验(box-dev-api)**
- `/_/tbunread1022181904/share-card` → **500「分享图生成失败:字体文件未配置或无法加载」**。
- 定位:不是代码回退。`static/fonts/` **从未入库**(`git log -- static/fonts` 空、`git ls-files` 无、
也没被 ignore),`static/fs.go` 的 embed 清单里也没有字体;`/static/fonts/*.otf` 走 HTTP 是 302 回首页。
生产能出图(200 jpeg)纯粹因为老环境手工放过字体。
- 代码侧补强(`d51629e`):`getFont` 去掉 `sync.Once`(失败不再永久缓存,补文件即生效)、
错误信息带「已尝试路径」、候选路径 = font_path → `static/fonts/*.{otf,ttf,ttc}` → 系统 CJK 字体;
`app.sample.ini` 注明必须自备。
- 顺带做了 P1 第一项:`generateQR` 按模块整数倍出图(41 模块 @165 → 每模块 4px / 符号 164px 居中),
头像内嵌改为按目标尺寸等比 + 2px 白边。新增 `TestGenerateQRIsModuleAligned`(扫描线游程必须是
模块宽度整数倍,变异验证过)与 `TestGenerateQRWithLogo`(中心头像、四角静默区纯白)。
- 待用户:在 dev 部署目录放字体(生产上有同一份)后我复验;Dockerfile 仍是旧模板
(module path 写成 NekoWheel/NekoBox、没 COPY static、golang:1.20 低于 go.mod 要求的 1.27.3),
需确认 dev 是否走容器部署。
**用户放好字体后复验(200 OK)**
- 实测着墨居中偏差:链接两行 −0.5 / 0.0px、提示语 −1.0px、底部水印 480.0(原偏 9.8px)——字宽测量修复确认生效。
- 单题卡要「已回答 + 非私密」才出图:提交的提问必须先回答,否则 403/302。
探针脚本 `.workbuddy/local/qcard_probe.py`(提问 → 登录 → 回答 → 拉图 → 删除,不入库)。
- **抓到上一版自己引入的回归**:`generateQR` 把 4 模块静默区画进了图里,49 模块挤进 165px
→ 符号只有 111px(3px/模块),比改造前的 135px 还小。960 卡缩到 480 宽时模块 1.5px,
gozxing 直接解不出。
修法(`b35c2a8`):`qr.DisableBorder = true`,图像只出符号,静默区交给白卡;
调用方按实际符号尺寸居中排版;单题卡 `QQRSize` 128 → 150(37 模块拿到 4px);卡内间距收到
24/20/20/16。profile 与单题卡二维码均 111 → 148px,gozxing 在 960 原图与 75% 缩放图上都能解出正确 URL。
- 结论性数字:**960×540 下 4px/模块是当前上限(profile 卡 37 模块 @148px,用满 90%)**;
缩到 480 宽仍解不出 → 真要抗微信二次压缩只能上 2x 渲染(1920×1080),1C1G 需实测 CPU。
- `dev/overlay.json` 原来指向 /tmp 的桩文件(早已失效),改成指向仓库内 `dev/s3stub/s3_stub.go`。
**第二轮复验(用户重新部署后)+ 换 2x 的替代方案**
- 部署确认:箱子卡 200,二维码 148px、左右留白 61/61、三处居中偏差 −0.5/0.0/−0.5px。
- 延迟别拿 HTTP 测:这台机器 **静态 CSS 的 TTFB 就 2.7s**(TLS+代理),分享卡 TTFB 2.4s
比静态资源还快,测不出服务端渲染耗时。要测就在本地跑基准。
- 本地基准(1C1G 上大约 ×3~5):1x 像素工作 ≈ 40ms(JPEG 12 + 背景缩放 25 + 画布 3),
2x ≈ 103ms;**二维码生成只有 0.8ms**,与尺寸无关 → 放大二维码不花 CPU,2x 贵在编码与缩放。
- 实验(37 模块 / 真实 55 字符链接 / 含头像,gozxing 逐宽度解码):4px/模块 480 宽就挂,
5px 能撑到 320。降纠错 Q→M 只省 11% 模块且容错 25%→15%,不划算;PNG 救不了缩放。
- 落地 `72f08cd`:`QRCodeSize` 165→190(→185px / 5px 模块)、`QRCardH` 315→335,
新增 `TestGenerateQRModuleSizeAtLeast5`(改回 165 立刻 FAIL)。单题卡仍是 150(4px),未动。
- 比 2x 更值得做的:进程内缓存只有 16 条 + TTL 1h,箱子多时冷渲染频繁 → 结果落盘/R2。
-145
View File
@@ -1,145 +0,0 @@
# TamaBox-v4 长期笔记(索引版)
明细已拆分到 `topics/*.md`(按需 Read 全文):
`coop` 协作 / `build` 构建测试 / `embed` 行尾·模板·embed / `review` 审核处置举报 /
`gorm` GORM / `style` 前端样式 / `theme` 主题 / `builder` 主题生成器 / `share` 分享卡 /
`honeypot` 蜜罐多家族伪装 / `stats` 全站数据页 / `sec` 安全评估·客户端 IP 信任 /
`quality` 业务正确性·运维·前端(2026-10-29) / `misc` 其它约定。
新增(2026-10-29 数据面板批次):`insights` 箱主数据面板(box_views 表 + 异步记录器 +
`/user/insights` 页面 + 图片前端压缩)—— 详见 topics 可暂并入 quality,或另起 topic。
新增(2026-10-30 安全汇总批次):`honeypot report` 周报/月报(`geoip` 定位层 + `report`
聚合绘图 + `ip_geo_cache`/`security_report_logs` + 邮件)—— 详见 `2026-10-30.md`。
## 安全汇总(要点,明细见 2026-10-30.md)
- **ipinfo ≠ 地理位置**:ipinfo 是 cdncheck 离线指纹,答「是不是云厂商/CDN/WAF」,
肉鸡必然显示「未识别(普通网络)」;「在哪儿」走 `internal/security/geoip`。
- `geoip.Result{Geo, Unresolved, Requests, Failures}` 三态:只有「服务明确说没数据」
才可写失败缓存(3 天),网络失败**绝不写** —— 否则一次抖动毒化整批 IP 三天。
- ip-api 免费版慢(22 个 IP 实测 4–10s),超时给 45s + 重试一次;`addrs` 必须按
调用方顺序(命中数降序)构建,否则腾讯精修配额花在长尾上。
- 自绘地图**零国界**(合规),真实底图走腾讯;代码不预置 key。报告里港澳台统一
写「中国香港/中国澳门/中国台湾」。地图标签必须防重叠(叠出过假的「AU 305」)。
- 周期窗口一律 `[start, end)` 左闭右开;报告幂等靠 `security_report_logs.period_key`
唯一索引,失败重试 5 次后停。
## 协作
- 结论分级 P0/P1/P2;先根因取证 + 实测复现,别只静态推断。
- 改代码前先出变更清单;重大问题显式指出。用户常授权自主推进。
- 用户是运营者也是圈内人:处置必须可解释、可留痕、可申诉,不做自动级联封号。
## 构建与测试(详见 topics/build.md)
- 无 libwebp/GCC:`internal/storage/s3.go` 挡编译 → overlay 换桩 `dev/s3stub/s3_stub.go`。
- 「编译卡死」= `proxy.golang.org` 不可达。每条 go 命令前
`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod`。
- DB 测试:`DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=15207786347hXPm DB_HOST=127.0.0.1 DB_PORT=5432`(DSN 不带库名)。
- store 方法内用 `NewXStore(db.DB)`,别引用包级单例。
- ~~本沙箱起不了 `TamaBox web`~~ **2026-10-27 起可在 WSL2 Debian 上真跑**(PG+Redis+systemd,端口 5877):
详见 topics/build.md「本地部署」小节。要点:`env -u GOROOT`(bashrc 指着 1.26.3)、
`[recaptcha] captcha_type = math`、Windows 侧 127.0.0.1:5877 被占要用 WSL eth0 IP。
HTTP 端到端与 P1-3 的双路径 A/B 都能在这里做(回环=可信、跨主机=不可信)。
- python urllib 不稳定(用 curl);python 别用 `/tmp`;PIL 在 `~/.workbuddy/binaries/python/envs/default`。
- **Windows 上 `Path.write_text()` 默认把 `\n` 写成 `\r\n`** —— 改 LF 文件必须传 `newline="\n"`
或走 bytes,否则整个文件行尾翻车。症状:`gofmt -l` 突然把整文件标为未格式化。
**排查看 `git diff --stat`**:真实改动应是几十行,整文件重写就是行尾变了;
恢复用 `git show HEAD:<file>` 取其行尾再统一替换。
另:本项目 `core.autocrlf=true`,仓库存 LF、工作区 checkout 成 CRLF —— 判断基准要看 `git show`。
## 行尾 / 模板 / embed(topics/embed.md)
- 行尾混合不可推断,改前用 Python 数 `b'\r\n'`;gofmt 走 `dev/gofmt_keep_crlf.py`。
- `templates/embed.go` 是显式清单:新增**顶层**模板必须加 `//go:embed`。
- `//go:embed` 的 `*` 不匹配 `_`/`.` 开头文件。
## 审核 / 处置 / 举报(topics/review.md)
- **图片存储 = Cloudflare R2**(S3 兼容协议,非七牛):`conf.Upload.ImageEndpoint`/`ImageBucket`/
`ImageBucketCDNHost`,客户端见 `internal/storage/client.go`(R2 的 endpoint resolver 写法,
`HostnameImmutable` + `region=auto`);key = `picture/YYYY/MM/DD/<15hex>`。代码里还留了一套
阿里 OSS 客户端(`GetOSSBucket`),是历史遗留,实际在用的是 S3/R2 这条。
- **文本审核通道 = 七牛/阿里云**(`[security] text_censor_source = qiniu|aliyun|auto`,七牛优先)——
这是内容审核 API,与图片存储是两码事,别搞混。线上通道 = 七牛;裁决在 `censor/policy.go` +
`moderation.Check`,新增调用点必须走 `moderation.Check`,不可用时 fail-closed。
- 严格度存 `users.moderation_strictness`;受处置用户强制 strict(`moderation.EffectiveStrictness`),查询失败回退用户设置。
- 同请求渲染只能用 `ctx.SetError`/`SetInternalError`,不能 `*Flash`。
- 写路径查功能开关用 `db.FeatureStateOfStrict`(失败即拒);`FeatureStateOf` 的
fail-open 只给读展示用 —— 它的默认值对提问/投票是「开启」,等于让一次配置读失败
绕过箱主的关闭与定量上限。
- 处置四级 L1–L4;到期靠 `expires_at` 无 cron;`restrict` 是替换语义;比较用 `RestrictionAtLeast`。
- L2 强制私密须在审核通过后、与写回答同一条 UPDATE(`AnswerByIDPrivate`)。
- `ListAPI` 不经 Pager 中间件,L4 与页面密码须自校验;分享卡拦截须在缓存查询之前。
- 举报:免登录+验证码+限流;同 IP 同题唯一索引 → 幂等;箱主只看次数不给 IP。
- 告警 24h≥8 或 7d≥20 → 邮件;`[security] security_alert_email` 为空则静默跳过。
- **处置覆盖面:新增任何「用户发起」的写入口都要问一句「这里要不要查 `ActiveLevel`」**。
提问链路 7 处齐全;投票链路曾 0 处(L4 后 `/vote/{token}` 照常可投、可发新投票、探索页照常曝光),
2026-10-29 已补齐:`vote.Page`/`vote.Submit` 拦 L4、`VotePublish` 拦 L2、探索页按 `ActiveLevels` 批量过滤。
## GORM(topics/gorm.md)
- 默认值必须带引号;事务里每句 `tx.WithContext(ctx)`;链式对象带状态,统计/更新各自重建 query。
- 软删除+唯一索引 = 槽位永久占用;级联删除一律 `Unscoped()`。
- **「用户主动删除」= 真删**:模型全带 `dbutil.Model` 的 `DeletedAt`,默认 Delete 是软删 ——
提交记录里的 `voter_ip`/文本、提问里的 `FromIP` 会永久留在库里。删投票/删提问已改
`Unscoped` 并收进一个事务(`Votes.DeleteWithCascade`、`Questions.DeleteByID`)。
- **配额必须与内容同事务**:`incrementQuotaTx(tx,...)` 是唯一实现;别在 handler 里
再补一次 `IncrementQuota`(那是独立写入,失败只 Warn → `quota_count` 永久偏低)。
注意口径按功能不同:提问/私聊是「条数」,投票是「累计收到的票数」。
## 前端样式(topics/style.md)
- `static/css/base.css`(`?v=` 取 `conf.StaticVersion`,改完 +1)。令牌在 `:root` + `html[data-theme="dark"]`,新样式一律 `var(--tb-*)`。
- 移动端(≤640px)语言/主题按钮在固定底栏 `#tb-mobile-utils`;箱主页**有意**无语言切换。
- **错误页不套模板**:`f.NotFound` 不经过组级中间件(`template.Templater`/`Contexter`
连同 session、CSRF 都在 `f.Group` 里),在里面注入 `template.Data` 会 panic。
404/500 统一走 `internal/route/errorpage.go` 的内联 HTML + `i18n.T`,
语言从 `Accept-Language` 猜(`pickErrorPageLang`)。Recovery 也是自写的。
- **提问者找回自己的提问**:`templates/question/{success,list}.html` + `POST /api/v1/ask-status`。
记录只在本机 localStorage;接口必须同时给对 token 才返回信息,三种失败情形响应完全一致。
- UIkit 3.3.3:`.uk-flex::before/::after{display:none}`;主色是编译期常量,需在 base.css 末尾映射。
- 视觉自检:`TEST_PREVIEW=1 go test ./templates -run TestGenPreview`;截图用 `chrome --headless=old`。
## 主题(topics/theme.md、topics/builder.md)
- 三个注入点按箱主/发布者的 `Theme`;投票页语言也跟发布者(`applyOwnerLocale`)。
- 一套主题 = `static/css/themes/<slug>.css`,只覆盖令牌;显示名用 `{{ themeName ... }}`,别直接 `printf "theme.name.%s"`。
- `[theme] available` 是硬白名单(启动时读一次);css 必须成对写亮/暗两个选择器。
- 装饰层固定 10 槽,三组 `--tb-deco/-size/-position` 必须等长;位置用两值 `calc(0%|50%|100% ± N)`。
- 主题生成器 `dev/theme-builder/`:非破坏性图片处理、`layerSize`/`unitGeom` 必须共用同一函数、
`hideDeco` 会把 @media 10 槽全写 none(窄屏素材消失的头号来源)。
## 安全(topics/sec.md)
- 权威头 `CF-Connecting-IP` / `EO-Client-IP` 只在 TCP 对端可信(回环 或 `trusted_proxies` 命中)
才采信,公网对端一律忽略;唯一读取点 `internal/shared/ip.go`,别在别处另读 header。
- 生产回源 = cloudflared Tunnel 回本机(对端 127.0.0.1)→ 零配置;`trust_proxy` 两套环境都 true。
- **EdgeOne 已取消**(境内速度差)。重新启用前必须把 EO 回源网段写进 `trusted_proxies`,
否则 EO 线退化成按边缘 IP 聚合(限流互挤、自动封禁误伤整片)。`app.sample.ini` 已注明。
- 跳转参数只走 `shared.SafeRedirectTarget`;`url.Parse` 会解 `%0d%0a`,必须逐 rune 查 `u.Path`。
- **同一 handler 里 GET 有所有权校验 ≠ POST 有**(私聊 `SendMessage` 曾漏,可跨箱骚扰)。逐分支核对。
- CSP 默认 **enforce**(逃生口 `csp = report|off` 后重启);`script-src` 必须含 `mami.shiroko.one`
(umami 动态注入,grep 抓不到)与按 `sentry_dsn` 条件放行的 `browser.sentry-cdn.com`。
改 CSP 白名单后必须跑 `csp_sweep.py`(真浏览器巡检),静态检查不算数。
- 注册防枚举:邮箱重复/停用与正常注册共用同一个成功出口;代价是忘密只能走找回。
- **`flamego.Classic()` 自带 `Logger()` 会记 `RequestURI`(含 query)** —— `?t=` 私密题钥匙、
`?code=` 改密凭证都会明文进日志(已实测)。要自写访问日志,只记 `URL.Path`。
- 报告 `outputs/security-audit-2026-10-25.md` + 复现脚本 `outputs/sec-audit-2026-10-25/`;
非安全向的复盘见 `outputs/review-2026-10-29.md`。
## 其它约定(topics/misc.md)
- 健康检查:`/healthz` 永远 200(不碰任何依赖,只看进程活着)+ `/readyz` 查 DB(失败 503)。
别把 DB 检查并进 healthz —— 数据库抖动会让进程被反复重启,对恢复毫无帮助。
- 校验用 `valid:"required;maxlen:N"` 标签。
- 只有箱主 `/user` 面板,**无管理员角色、无站方后台**(用户要求不要加):站方动作只走 CLI / 邮件 / 改配置重启。
- CLI 有两个构建入口必须同步:根 `go build .` 与 Dockerfile 的 `./cmd/`。
- 现有 CLI:`web censor uid ban restore reports restrict unrestrict hide-public`。
- git `https://git.shiroko.one/tamakyi/tamabox`(main),`git push` 可直推;`outputs/` 已 gitignore。
- **ImageGen 的 `background: "transparent"` 至今不生效**(返回 RGB、无 alpha 通道)。要透明素材:
提示词把背景写成一个**具体纯色**(不要写 white,白衬衫/光环会被一起吃),
再按该色值算 alpha:`alpha = clip((maxChannelDiff - 6) / 12)`,最后 `MinFilter(3)` 收 1px。
闭合图形(如光环圆环)内部的白/底色用 floodfill 填不到,按色值抠才干净。
- 生成图的右下角有 "AI生成 WORKBUDDY" 水印,做素材时统一裁掉底部约 10%(切口平直,正好贴页面下沿)。
-62
View File
@@ -1,62 +0,0 @@
# 构建与测试(长版明细)
## 构建与测试
- **无 libwebp/GCC**:cgo 的 `internal/storage/s3.go` 挡编译 → overlay 换桩
`dev/s3stub/s3_stub.go`(`-overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json"`)。
要检查 s3.go 本身得 tar 复制项目 + `go mod edit -replace`(overlay 换不了 GOMODCACHE)。
- **「编译卡死」= `proxy.golang.org` 不可达**。每条 go 命令前
`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod`(`go env -w` 沙箱失败)。
1C1G 是正式生产环境,别用「沙箱慢」解释超时。
- DB 测试:`DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=15207786347hXPm DB_HOST=127.0.0.1 DB_PORT=5432`
(DSN 不带库名,`NewTestDB` 自建临时库后 DROP)。带 DB 的 `go test ./...` 全绿。
- store 方法内用 `NewXStore(db.DB)`,别引用包级单例(测试下 nil panic;`FeatureStateOf` 有守卫)。
- ⚠️ **本沙箱起不了 `TamaBox web`**(session 固定 redis,6379 未开,启动 panic)→ HTTP 端到端只能部署后做。
探测端口 200 可能是别的进程,必须看日志确认 `Starting web server` 且无 panic。
- ⚠️ **本机 python urllib 不稳定**(代理下偶发读超时,curl 正常);python 别用 `/tmp`
(Windows 解析成 `C:\tmp`,与 MSYS 不通)。PIL 在 `~/.workbuddy/binaries/python/envs/default`。
- ⚠️ **验证 Go 二进制里有没有某个中文字符串,不能用 `strings`**:GNU strings 默认只输出
可打印 ASCII(0x20–0x7E),UTF-8 中文(0xE4+ 开头)整段被丢弃,`strings x | grep 中文`
恒为 0 —— 曾据此误判「用户没重新编译」,实际二进制是新的。
正确做法:`grep -ac "中文字符串" 二进制文件`(实测同一个二进制:strings=0、grep -a=1)。
- ⚠️ 替换二进制后**必须重启进程**:`go build -o X` 只改磁盘文件,正在跑的进程仍是旧映像。
若服务由 systemd/supervisor 管理,再手动 `./X web` 会因端口被占直接退出。
- ⚠️ **分享卡字体不在仓库里**:`static/fonts/` 从未入库也没 embed(`static/fs.go` 的清单里没有),
`/static/fonts/...` 走 HTTP 是 302 回首页。新环境不手工放字体 → `share-card` 恒 500
「字体文件未配置或无法加载」。老环境(如生产)能出图只是因为当年手工补过文件。
排查口诀:响应体是这句中文 → 一定是部署侧缺文件,不是代码回退。
`getFont` 现已支持 candidates(`font_path` → `static/fonts/*` → 系统 CJK 字体)且不缓存失败,
补文件后无需重启。
- ⚠️ **`GOSUMDB=off` 会让 toolchain 自动下载失败**:go.mod 要 1.27.3 而 PATH 上的 `go` 是旧版时,
会报 `golang.org/toolchain@...: verifying module: checksum database disabled by GOSUMDB=off`
(`go version` 显示的还是 shell 包装器的版本,容易误判成「版本没变」)。
解法:直接用缓存里的 toolchain 并禁用自动切换——
`PATH="$HOME/go/pkg/mod/golang.org/[email protected]/bin:$PATH" GOTOOLCHAIN=local go test ./...`
## 本地部署(WSL2 Debian,2026-10-27 打通)
用户本机有 WSL2 `Debian`(可 `wsl.exe -d Debian -u root` 免密进 root)。在这上面跑通了完整服务,
从此 HTTP 端到端不必「部署到测试站才能做」。要点:
- **GOROOT 是个坑**:`~/.bashrc` 里 `export GOROOT=/usr/local/go`(1.26.3)。用 `/opt/go/bin/go`
(1.27.3,从 dl.google.com 下的 tar 解到 /opt)时必须 `env -u GOROOT`,否则
`compile: version "go1.26.3" does not match go tool version "go1.27.3"`。
另:GOCACHE 被旧版本编译过也会报同样的错 → 换独立 `GOCACHE=~/.cache/go-build-tb`。
- **依赖**:`apt-get install redis-server python3 python3-requests gcc libwebp-dev pkg-config`
(有 libwebp 后 `internal/storage/s3.go` 正常编译,不再需要 s3stub overlay)。
session 与 cache 都硬依赖 redis,6379 不通就起不来;`systemctl start redis-server` 即可(WSL 有 systemd)。
- **库**:DSN 不带库名 → 连的是与用户名同名的库。测试还要 `CREATEDB` 权限(`NewTestDB` 自建临时库)。
`CREATE ROLE tamabox LOGIN PASSWORD '...' CREATEDB; CREATE DATABASE tamabox OWNER tamabox;
CREATE DATABASE nekobox OWNER tamabox; CREATE SCHEMA nekobox AUTHORIZATION tamabox;`
- **配置坑**:`data/data.txt` 里的 `[app] title`、`[server] xsrf_expire`、`[captcha] provider`、
`[rate_limit] *` 全是当前版本不认识的键(启动 WARN 后忽略)。验证码要关外网校验得写
`[recaptcha] captcha_type = math`。`external_url` 用 http → session cookie 不带 Secure,本地能登录。
- **进程托管**:`/etc/systemd/system/tamabox-test.service`(User=tamakyi、WorkingDirectory=源码目录、
ExecStart=<二进制> web、日志 append 到文件)。`systemctl restart` 必须 root,普通用户报 Access denied。
- **访问路径**:Windows 侧 `http://127.0.0.1:5877` 被本机别的进程占用(返回 502),要用
`http://<wsl eth0 ip>:5877`。这个差异正好白送一套 P1-3 的 A/B:
WSL 内 `curl 127.0.0.1` = 回环对端(模拟 cloudflared 回源),Windows 侧访问 = 非回环对端(模拟公网直连)。
- **抓 IP 伪造**:临时配 `ip_blacklist = 1.2.3.4` + `ip_blacklist_redirect_url`,带头 curl 看有没有 302,
比读日志直观;配合日志里的 `remote_addr` / `header_ip` 双重取证。
- **限流会绊脚本**:`/api/captcha/generate` 按 IP 限流,批量跑用例会 429,
`audit.get_captcha` 已加退避重试。SMTP 密码不对只会打日志(`526 Authentication failure`),不影响用例。
-54
View File
@@ -1,54 +0,0 @@
# 其它约定(长版明细)
## 其它约定
- 校验靠 `valid:"required;maxlen:N"` 标签(`internal/form/*.go`),别在 handler 手写。
- 终止性操作(停用)落独立公开页 `/deactivated`(注册在 `reqUserSignIn` 外);改密码后重登才用 flash + `/login`。
- 只有箱主 `/user` 面板,**无管理员角色、无站方后台,用户要求不要加**:站方动作只走 CLI / 邮件 / 改配置重启。
- git `https://git.shiroko.one/tamakyi/tamabox`(`main`)。**`git push` 能直接推**(本机 Git for Windows 已认证);
「Failed to authenticate user」是临时态。推前 `git log --oneline -3`。`outputs/` 已 gitignore,别整目录 add。
- 打包交付用 tar.gz(排除 `.git`/`.workbuddy`/`outputs`),放项目同级。
## 测试环境 box-dev-api.337845818.best
- 账号 `[email protected]` / `adminadmin`,公开页 `/_/admin6`。
- 登录要过算数验证码:`GET /login` 取 `_csrf`+cookie → `GET /api/captcha/generate` → 算 `a op b` →
`POST /login` 带 `g-recaptcha-response="<id>:<答案>"`。默认语言 **ja**,四语言文案要一起加。
- **部署有延迟**;模板是编译期 embed,**只重启不重编不会变**。用户说「已部署但没变化」→ 先抓线上 HTML 比对标记。
- 表单字段:提问 `content`,回答 `answer`。`GET /user/votes` 故意 302;匿名无邮箱提问成功直接 200 渲染。
- 自检:`dev/devcheck-e2e.py`(28 项)、`dev/devcheck-restrict.sh`(基线非 200 就别往下走;`RUN=1` 才真跑)。
- 图片编码三档(头像 512/q78、背景 1920/q80、通用 1600/q80),**EXIF 方向必须先归一**(转 WebP 会丢)。
## CLI(`TamaBox <cmd>`)
- ⚠️ **两个构建入口必须同步**:根 `go build .`(`tamabox.go`)与 Dockerfile 的 `./cmd/`(`cmd/tamabox.go`),
否则 `No help topic for '<cmd>'`。
- 现有:`web` `censor` `uid` `ban` `restore` `reports` `restrict` `unrestrict` `hide-public`。
- `restrict --domain|--email --level L1..L4 --days N --reason` / `unrestrict`;
`reports --domain|--email [--days]`(证据包)、`--pending`、`--stats --days N`;`hide-public [--only-reported] [--since N]`。
- 本地冒烟:`NEKOBOX_CONFIG_PATH=<ini>`;`conf.Init` 强制 `app.external_url`、`server.salt`、`server.xsrf_key` 非空。
- 蜜罐 CLI:`TamaBox honeypot hits|stats|prune`(`--since 24h` / `--show-password` / `--yes`),
注册须在 `cmd/tamabox.go` + 根 `tamabox.go` 两处同步。
- 蜜罐配置:`[security] enable_honeypot` / `honeypot_paths` / `honeypot_retain_days`(默认全关)。
## 行尾与测试坑(2026-10-21 蜜罐轮)
- ⚠️ `internal/route/route.go`、`internal/conf/static.go`、`conf/app.sample.ini` **实际是全 CRLF**;
`grep | cat -A` 不显示 `^M` 极易误判 → Python 替换用 `\r\n` 锚点,写后统计 `b'\r\n'` 复核。
- ⚠️ Go 里 `time.Duration(60)` = 60 **纳秒**,测试写裸数字会让封禁/冷却瞬间过期 → 一律 `60 * time.Minute`。
- ⚠️ 测 `IsBanned` → 403 必须注册中间件(`f.Use(autobanMiddleware(ab, hp))`),只注册路由+NotFound 会被绕过。
## 配置(ini)相关约定
- ⚠️ **ini 对未知键静默忽略** → 键名写错 = 配置不生效且无任何现场线索。
已加两道防线(`internal/conf/keynorm.go` + `unknown.go`):
键名归一化(自动纠正常见误拼)+ 未知键启动告警(附最接近的合法键名)。
- ⚠️ **键名必须与代码逐字一致**,包括警告文案里提到的键名 —— 写错等于误导运维。
- ⚠️ `MapTo` 在**键缺失/值非法时不覆盖**目标字段 → 可安全预置缺省值。
- ⚠️ **bool 的 ini 解析是「有键才赋值」** → 「未配置」与「显式 false」无法用零值区分,
必须用 `HasKey` 判定。缺省为 true 的开关尤其要小心(否则未配置 = 关闭)。
- 归一化必须在 `MapTo` **之前**执行,否则错拼的键已被丢弃。
- 合法键名白名单由**反射从结构体 ini tag 得出**,是单一事实来源;新增配置项自动纳入。
- `TestSampleIniHasNoUnknownKeys` 回归检测:官方样例配置零误报。
改结构体字段名时必须同步 `conf/app.sample.ini`,否则这个测试会失败。
- app.sample.ini 里不要留「代码中从未读取」的死配置(`title` / `xsrf_expire` 已清理)。
-135
View File
@@ -1,135 +0,0 @@
# 安全评估与客户端 IP 信任
报告:`outputs/security-audit-2026-10-25.md`;复现脚本 `outputs/sec-audit-2026-10-25/`(可重跑)。
## 客户端 IP 采信(P1-3,2026-10-26 已修)
- 唯一读取点:`internal/shared/ip.go` 的 `ResolveClientIP`,23 处调用共用。
**不要在别处另读 `CF-Connecting-IP` / `EO-Client-IP`**,否则绕过校验。
- 规则:权威头只在 `headerSourceTrusted(remote)` 为真时采信 ——
对端是回环地址,或命中 `trusted_proxies`。公网对端带来的权威头一律忽略,回退 remote。
- XFF / X-Real-IP 仍只认 `trusted_proxies`;回环**不**隐含信任这两个头。
- 公网对端却带权威头 → 进程内一条 WARN(`remote_addr` + `header_ip`)。
出现这条日志 = 要么有人在伪造,要么 CDN 直连回源但没登记网段。
- 不能靠「哪个头存在」推断请求走了哪家 CDN:客户端自带任意头都能反向利用。
## 部署形态
- 生产/测试都是 `trust_proxy = true`,回源 = **cloudflared Tunnel 回本机**(对端 127.0.0.1),
因此修复后零配置变更。
- **EdgeOne 已取消**(境内访问速度不理想),当前只有 Cloudflare 一条线。
重新启用前必须把 EO 回源网段写进 `[security] trusted_proxies`,否则 EO 线退化成按边缘 IP 聚合
(限流互挤、自动封禁误伤整片)。注释已写进 `conf/app.sample.ini`。
- `box.shiroko.one` CNAME 到第三方 `cloudflare.182682.xyz`(CF SaaS 优选),该 zone 的
Transform/WAF 不归我们管 —— 别把安全前提建立在那边的规则上。
- 源站隐藏:外网尽力排查(A 记录 / certspotter 51 个子域 / MX / 候选 IP)未找到直连入口;
仍须服务器侧 `dev/security-selfcheck.sh local` 确认 `listen = 127.0.0.1:5877`。
## 实测确认「防护到位」(别重复怀疑)
CSRF 绑 uid(无 token POST → 400);18 条 XSS 载荷全灭(含实体编码、`java\tscript:`、
协议相对 `//`、反斜杠);跨箱题目/投票越权全 302;页面密码对主页/整箱分享图/单题分享图/API
四个出口都生效;pixel/service/ShareCard 无 SSRF;`exec cwebp` 不接用户输入;SMTP 默认校验证书;
`RegenerateID` + `pwd_ver`;TLS1.0/1.1 拒绝;无 CORS 头;测试站蜜罐已开。
## 已修(2026-10-26)
- **P1-1** 外链 `<img>`:`markdown.go` 的 `safeImageSrc` 只放行站内相对路径与自有图片 CDN 主机
(配置来源 `image_bucket_cdn_host` / `aliyun_bucket_cdn_host` / `<bucket>.<endpoint>` / `external_url`);
`a[href]` 仍允许外站。无合法 src 的 img 整段丢弃。
- **P1-2** 密码:page/ask 密码 `minlen:6`(`form.PasswordMinLen`)+ handler 二次校验;
提问密码比对挪到验证码之后(`askPasswordDenied`,私聊与普通提问各调一次);
页面密码失败锁定 `shared.AttemptLimiter(5, 10min, 10min)`,键 = 箱主ID + LimitKey(IP)。
- **P1-3** 权威头:见上「客户端 IP 采信」。
- 部署后验收:`python outputs/sec-audit-2026-10-25/verify_p1_fixes.py`,四项应全 PASS
(旧版本上实测四项全 FAIL)。跑完会自动还原严格度/密码/测试提问。
## 第二轮修复(2026-10-27,P2/P3 全清)
- **跳转白名单收敛为唯一实现 `shared.SafeRedirectTarget`**(`internal/shared/redirect.go`),
登录 `to` 与 `/_/<domain>/password` 的 `next` 共用。规则:拒绝空/含 `\`、控制字符、空格;
必须单斜杠开头、非 `//`;`url.Parse` 后不得有 Scheme/Host/User/Opaque;遍历 `u.Path` 的 rune
拒 `\`、`<0x20`、`0x7f`(**关键**:`url.Parse` 会把 `%0d%0a` 解码成 CRLF,只看原始串抓不住);
最后 `path.Clean` 再验一次前缀。测试在 `redirect_test.go`(25 条定向 + 全量不变式)。
- **注册防枚举**:邮箱重复/已停用与全新注册走**同一个** `registerSucceeded(ctx)` 出口
(302 → `/login`、无 alert),日志里才留痕。域名占用仍明文提示(不然用户没法选)。
代价:忘密用户只能走「找回密码」。
- **CSP**:`internal/route/csp.go` + `[security] csp = off|report|enforce`(默认 report)、
`csp_report_uri`。`script-src` 白名单必须含 **`https://mami.shiroko.one`**——
`templates/base/header.html` 用 `document.createElement("script")` 动态注 umami,
静态 grep `<script src=` 抓不到,切 enforce 后会在无头 Chrome 里报违规。
存储 API endpoint(`r2.cloudflarestorage.com` 等)**不要**写进 CSP,只登记对外发资源的 CDN 域。
- **`no-store`**:`/user`、`/_*` 命中 `isNoStorePath`,`/share-card` 例外(要能被爬)。
- **`securityHeaders` 注册顺序**:原先排在静态资源中间件之后,静态文件拿不到 nosniff/HSTS,已前移。
- **封禁落库** `internal/db/ban_record.go`(`ban_records`:ban_key/reason/duration_minutes/expires_at),
`newBanner()` 的 `OnBan` 异步写(3s 独立 ctx,失败只告警,store 为 nil 直接跳过)。
- 其它:token 比较改 `subtle.ConstantTimeCompare`;`/explore` 页码上限 500;
429 统一 `tooManyRequests()`(带 `Retry-After`);`ctx.JSON/JSONError` 显式补 Content-Type。
## 全局复查新发现(2026-10-27)
- 🔴 **私聊 `SendMessage` 缺所有权校验**(高危):`internal/route/chat/chat_handler.go` 只有 GET 分支
校验 `conv.OwnerID != pageUser.ID`,POST 没有 → 被拉黑的人换个 domain 就能继续往他人会话发消息、
触发邮件、绕过屏蔽词与日限额。已补校验 + `verify_chat_owner.py` 端到端 3/3 + **变异验证**
(去掉校验后「换 domain 不能操作他人的会话」立刻变红)。**教训:同一 handler 里 GET 有校验
不代表 POST 有,逐分支核对。**
- 🟡 页面密码 `next` 与 P2-3 同源缺陷(`/\evil` 外跳),随白名单收敛一并修好。
- 🟡 **分享卡字体**:无可用字体时每个请求要烧 0.7–4s CPU,而 `/share-card` 是公开端点。
现在成功永久缓存、失败缓存 1 分钟(`fontFailRetry`);实测连发 3.44s → 0.53s。
### 分享卡字体的硬限制
`github.com/golang/freetype` **只支持 TrueType(glyf)轮廓**,CFF/OTTO 一律报 `bad TTF version`。
`NotoSansCJK-Regular.ttc` 是 OTTO → 就算 `ttcFirstFont()` 正确提取出子字体也仍不可用。
`.ttc` 是字体集合,需先按 `ttcf` 魔数 → `numFonts` → 子字体偏移 → 重排 sfnt header + 表目录
(4 字节对齐)才能交给 freetype(`route/question/ttc.go`,测试 `ttc_test.go` 自造最小 TTC)。
**2026-10-27 已收敛**:`fontCandidates()` 补 glyf 系统候选(wqy-zenhei / wqy-microhei /
DroidSansFallbackFull / uming)且排在 Noto(CFF) 之前;`static/fonts` 同时按 CWD 与可执行文件目录找;
`CheckShareCardFont()` 在 `runWeb` 启动时探一次(Info 打路径 / WARN 给 `apt install fonts-wqy-zenhei`)。
本地实测该 apt 包即可零配置出图(`/share-card` 500 → 200 image/jpeg)。生产同理,别用 Noto CJK。
## 仍待决策/未做
- 限流/失败计数迁 Redis(单进程内存态,多副本会各算各的)—— 明确不做,配置注释已写明单副本前提。
- 匿名投票清 cookie 即换身份:只加了结果页匿名说明,未做 IP 唯一索引(会被 NAT/移动网误伤)。
- 颜色字段只校验长度(有 Go 上下文转义兜底,非安全问题)。
- P1-1 残余:存量内容里的外链 img 仍在库里,仅渲染层不再输出;将来若加「原样导出」入口会复发。
- CSP 默认 **enforce**(2026-10-27 定):不给 `csp_report_uri` 的 report-only 等于没装,
留个不生效的开关更容易让人误以为已防护。白名单由 `csp_sweep.py` 真浏览器巡检背书
(18 页面 + 2 图片端点,零违规);误伤时的逃生口是 `csp = report|off` + 重启,不用改代码。
- **Sentry CDN 是条件白名单**:`header.html` 的 Sentry 在 `{{ if ne SentryDSN "" }}` 里,
静态 grep 和本地巡检都碰不到 → 按 `conf.App.SentryDSN` 条件放行,单测双向断言。
## 复现脚本怎么跑(踩过的两次假红,别再误判成回归)
- 四套脚本默认目标是**远端** `box-dev-api`(`audit.py` 的 fallback),在本地实例上验证
必须显式给环境变量,否则 4/4 全 FAIL —— 那是「远端没部署修复」,不是回归:
`PYTHONDONTWRITEBYTECODE=1 TB_BASE=http://172.27.49.117:5877 [email protected]
TB_PWD='TbLocal!2026' TB_DOMAIN=tblocal <脚本>`(Windows 侧 127.0.0.1:5877 被占,需用 WSL eth0 IP)。
- **`verify_chat_owner.py` 必须在 WSL 里跑**:它直接调 `psql` 查库,Windows 侧 `psql` 会连到
宿主自己的 PostgreSQL 18(`/c/OtherFiles/PostgreSQL/18/bin/psql`),账号/库都不对;且 psql 的中文
报错是 GBK,Python 按 UTF-8 解会先抛 `UnicodeDecodeError` 把真实错误吃掉。WSL 侧:
`cd /mnt/c/.../sec-audit-2026-10-25 && TB_BASE=http://127.0.0.1:5877 ... python3 verify_chat_owner.py`。
- 页面密码入口限流 **10 次/分钟/IP**,`verify_page_next.py` 一次要 POST 6 次 → **一分钟内连跑两次**
会从第 5 项开始 `status=429`;`verify_p1_fixes.py` 的 p1-2c 还会把「本机 IP + 测试箱」锁 10 分钟。
两者都靠 `systemctl restart tamabox-test` 清内存态。顺序:先 `verify_page_next.py`,再 `verify_p1_fixes.py`。
- 判据别写「Location 里不含 evil」这种弱断言:一旦被限流/锁定停在密码页也会假绿。
`verify_page_next.py` 已改成精确比对 `Location == /_/<domain>`、429 单独报,且**每条载荷开新会话**
(验证通过会给会话发通行标记,复用会话的话后面几条根本不走密码校验)。
- 残留:`verify_round2.py` / `verify_chat_owner.py` 会注册临时账号(`verify*@example.com`);
跑完 `DELETE FROM nekobox.users WHERE email LIKE 'verify%@example.com'`(无 FK,先确认
conversations/questions/votes 无关联行)。
## 本地部署实测(2026-10-27,WSL)
- 三项 P1 在真实实例上端到端复验通过:P1-1 正/负与变体、P1-2a/b/c、P1-3 的可信/不可信对端 A/B
(探针用临时 `ip_blacklist = 1.2.3.4`:回环对端 + `CF-Connecting-IP: 1.2.3.4` → 302 命中;
跨主机对端同样头 → 200 忽略,日志出 `remote_addr` / `header_ip` 的 WARN)。
- ⚠️ **P1-1 白名单的坑(已修)**:配置里 `image_bucket_cdn_host` 实际写成 `host/path`
(本项目就是 `r2.337845818.best/tamabox`),整串当主机名比 → 自家图被剥。
现在统一走 `normalizeHostLike()`(补协议 → url.Parse → Hostname),并把 S3 endpoint 主机自身也入白名单
(R2 path-style 链接是 `<endpoint>/<bucket>/…`)。**白名单类单测必须照抄生产配置写法。**
- **P2-3 实测确认可利用**:`/login?to=/\evil.example` → `Location: /\evil.example`,
WHATWG 解析为 `http://evil.example/`;`\\evil.example` 同样外跳;`//`、`///` 已被规整。
正文渲染侧同类载荷是安全的(`<a href="/\example.org">` → `<a>`),缺口只在跳转参数。
Binary file not shown.
-346
View File
@@ -1,346 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""头像上传链路端到端验证(批次 A 分档 + B1 EXIF 归一)。
为什么走头像而不是提问图:
- 提问图那条路要经过内容审核/提问入库才能拿到 key,链路长且易受其它因素干扰;
- 头像强制 1:1 且 POST /user/profile/update 直接返回页面,便于立即取回 CDN URL。
验证两件事:
A 分档生效:上传 1600x1600 的大图,头像档 maxSide=512 -> 存盘应为 512x512
(改前是 1920,实测体积差 ~7 倍)
B1 EXIF Orientation=6 归一:上传「像素 512x256 + EXIF 顺时针 90」的方形图,
归一后仍应是 512x512(宽高互换但比例不变,故改用体积+尺寸双断言,
并额外验证竖长条图 256x512 + Orientation=6 -> 应变成 512x256)
用法:
python dev/devcheck-avatar.py [--base https://box-dev-api.337845818.best]
依赖:Pillow(仅用于本地生成测试图片)
清理:TamaBox ban --email <输出中的测试邮箱> --yes
"""
import argparse
import http.cookiejar
import io
import json
import re
import struct
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
RESULTS = []
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
return None
class Resp:
def __init__(self, status, headers, body):
self.status = status
self.h = {k.lower(): v for k, v in headers.items()}
self.body = body
@property
def loc(self):
return self.h.get("location", "")
@property
def text(self):
return self.body.decode("utf-8", "replace")
class Client:
def __init__(self, base):
self.base = base
self.cj = http.cookiejar.CookieJar()
self.op = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(self.cj), NoRedirect())
def _send(self, req, timeout=120):
try:
with self.op.open(req, timeout=timeout) as r:
return Resp(r.status, dict(r.headers), r.read())
except urllib.error.HTTPError as e:
try:
body = e.read()
except Exception: # noqa: BLE001
body = b""
return Resp(e.code, dict(e.headers), body)
except Exception as e: # noqa: BLE001
return Resp(-1, {}, ("TRANSPORT_ERROR: %s" % e).encode())
def req(self, method, path, data=None, content_type=None, timeout=120):
url = path if path.startswith("http") else self.base + path
headers = {"User-Agent": UA, "Referer": self.base + path.split("?")[0]}
body = None
if data is not None:
body = data if isinstance(data, bytes) else urllib.parse.urlencode(data).encode()
headers["Content-Type"] = content_type or "application/x-www-form-urlencoded"
return self._send(urllib.request.Request(url, data=body, headers=headers, method=method),
timeout)
def get(self, p, **kw):
r = self.req("GET", p, **kw)
return r if r.status != -1 else self.req("GET", p, **kw)
def post(self, p, data=None, **kw):
return self.req("POST", p, data=data, **kw)
def get_binary(self, url):
return self._send(urllib.request.Request(
url, headers={"User-Agent": UA}, method="GET"), timeout=60)
def step(desc, ok, extra=""):
RESULTS.append((desc, ok, extra))
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", desc, (" (%s)" % extra) if extra else ""))
return ok
def csrf_of(html):
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
return m.group(1) if m else None
def captcha(c):
j = json.loads(c.get("/api/captcha/generate").text)
pid, q = j["data"]["id"], j["data"]["question"]
m = re.match(r"\s*(\d+)\s*([+\-×])\s*(\d+)\s*=", q)
a, op, b = int(m.group(1)), m.group(2), int(m.group(3))
return "%s:%d" % (pid, {"+": a + b, "-": a - b, "×": a * b}[op])
def multipart(fields, files):
boundary = "----tbav%s" % uuid.uuid4().hex
buf = io.BytesIO()
for k, v in fields.items():
buf.write(("--%s\r\n" % boundary).encode())
buf.write(('Content-Disposition: form-data; name="%s"\r\n\r\n' % k).encode())
buf.write(("%s\r\n" % v).encode())
for name, fn, ct, data in files:
buf.write(("--%s\r\n" % boundary).encode())
buf.write(('Content-Disposition: form-data; name="%s"; filename="%s"\r\n' % (name, fn)).encode())
buf.write(("Content-Type: %s\r\n\r\n" % ct).encode())
buf.write(data)
buf.write(b"\r\n")
buf.write(("--%s--\r\n" % boundary).encode())
return buf.getvalue(), "multipart/form-data; boundary=%s" % boundary
def sniff(d):
if len(d) >= 12 and d[:4] == b"RIFF" and d[8:12] == b"WEBP":
return "webp", webp_dims(d)
if d[:3] == b"\xff\xd8\xff":
return "jpeg", jpeg_dims(d)
if d[:8] == b"\x89PNG\r\n\x1a\n":
return "png", (struct.unpack(">II", d[16:24]) if len(d) >= 24 else None)
return "unknown", None
def webp_dims(d):
c = d[12:16]
if c == b"VP8 " and len(d) >= 30:
return (struct.unpack("<H", d[26:28])[0] & 0x3FFF,
struct.unpack("<H", d[28:30])[0] & 0x3FFF)
if c == b"VP8L" and len(d) >= 25:
b = struct.unpack("<I", d[21:25])[0]
return (b & 0x3FFF) + 1, ((b >> 14) & 0x3FFF) + 1
if c == b"VP8X" and len(d) >= 30:
return ((d[24] | d[25] << 8 | d[26] << 16) + 1,
(d[27] | d[28] << 8 | d[29] << 16) + 1)
return None
def jpeg_dims(d):
i = 2
while i + 9 < len(d):
if d[i] != 0xFF:
i += 1
continue
m = d[i + 1]
if m in (0xC0, 0xC1, 0xC2, 0xC3):
h, w = struct.unpack(">HH", d[i + 5:i + 9])
return (w, h)
if m in (0xD8, 0xD9) or 0xD0 <= m <= 0xD7:
i += 2
continue
i += 2 + struct.unpack(">H", d[i + 2:i + 4])[0]
return None
def make_avatar(w, h, orientation=None):
"""生成一张带明显方位特征的方形头像。"""
from PIL import Image, ImageDraw
im = Image.new("RGB", (w, h), (30, 120, 200))
d = ImageDraw.Draw(im)
# 顶部一条粗红条:旋转 90 度后它会跑到侧面,方向一眼可辨
d.rectangle([0, 0, w, max(2, h // 12)], fill=(230, 30, 30))
for i in range(0, w, max(8, w // 20)):
d.line([(i, h // 4), (i, h)], fill=(255, 255, 255), width=2)
buf = io.BytesIO()
if orientation:
exif = im.getexif()
exif[0x0112] = orientation
im.save(buf, "JPEG", quality=90, exif=exif)
else:
im.save(buf, "JPEG", quality=90)
return buf.getvalue()
def scrape_profile_fields(html):
"""从 profile 页抓取所有 input 的 name/value,保证回填必填项。
UpdateProfile 的必填字段有十来个(昵称/介绍/5 个二维码颜色/点大小/透明度…),
漏一个就会因 `valid:"required"` 被 Bind 挡下、头像根本不会走到上传分支。
"""
fields = {}
for m in re.finditer(r'<input\b[^>]*>', html, re.I):
tag = m.group(0)
nm = re.search(r'name="([^"]+)"', tag, re.I)
if not nm:
continue
name = nm.group(1)
if name in ("avatar", "background", "backgroundimage", "_csrf", "submit"):
continue
typ = re.search(r'type="([^"]+)"', tag, re.I)
if typ and typ.group(1).lower() in ("checkbox", "radio"):
# 只保留勾选态的,避免把「本想取消」的项又勾上
if not re.search(r'\bchecked\b', tag, re.I):
continue
val = re.search(r'value="([^"]*)"', tag, re.I)
fields[name] = val.group(1) if val else ""
# select 走第一个非空 option
for m in re.finditer(r'<select\b[^>]*name="([^"]+)"[^>]*>(.*?)</select>', html, re.I | re.S):
name, body = m.group(1), m.group(2)
opts = re.findall(r'<option\b([^>]*)>', body, re.I)
chosen = ""
for o in opts:
vm = re.search(r'value="([^"]*)"', o)
v = vm.group(1) if vm else ""
if re.search(r'\bselected\b', o, re.I):
chosen = v
break
if not chosen:
chosen = v
fields[name] = chosen
# textarea
for m in re.finditer(r'<textarea\b[^>]*name="([^"]+)"[^>]*>(.*?)</textarea>', html, re.I | re.S):
fields[m.group(1)] = re.sub(r"<[^>]+>", "", m.group(2)).strip()
return fields
def upload_avatar(c, data, fname="avatar.jpg"):
page = c.get("/user/profile")
if page.status != 200:
return None, "profile HTTP %s" % page.status
fields = scrape_profile_fields(page.text)
fields["_csrf"] = csrf_of(page.text)
body, ct = multipart(fields, [("avatar", fname, "image/jpeg", data)])
res = c.post("/user/profile/update", data=body, content_type=ct)
# 成功时是 302 跳回 /user/profile,头像 URL 在跳转后的页面里
if res.status == 302 and res.loc:
res = c.get(res.loc if res.loc.startswith("/") else "/user/profile")
if res.status != 200:
return None, "update 后页面 HTTP %s" % res.status
# 从页面里取头像 URL:<img src="https://r2.../picture/..."> 或 og:image
urls = re.findall(r'https://r2\.[^"\'\s<>]+/picture/[0-9]+/\d{2}/\d{2}/[0-9a-f]+', res.text)
if not urls:
m = re.search(r'(https://r2\.[^"\'\s<>]+/picture/[^"\'\s<>]+)', res.text)
urls = [m.group(1)] if m else []
if urls:
return urls[-1], None
# 没匹配到:落盘返回页并打印可疑片段,便于定位是「上传被拒」还是「正则没覆盖」
dump = "C:/Users/Tamakyi Gekka/AppData/Local/Temp/tbav_profile.html"
with open(dump, "wb") as f:
f.write(res.body)
alerts = re.findall(r'class="uk-alert[^"]*"[^>]*>(.*?)</div>', res.text, re.S)
hint = " | ".join(re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", a)).strip()[:80]
for a in alerts[:3])
return None, "未匹配到 picture URL(已存 %s)%s%s" % (dump, (" 提示:" + hint) if hint else "",
(" HTTP %s" % res.status))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--base", default="https://box-dev-api.337845818.best")
args = ap.parse_args()
base = args.base.rstrip("/")
pwd = "TbAv!2026"
ts = time.strftime("%m%d%H%M%S")
email, domain = "tbav%s@example.com" % ts, "tbav%s" % ts
print("== 账号:%s / %s ==" % (email, domain))
# 方形 1600x1600:验证 A 分档(应存成 512x512)
square = make_avatar(1600, 1600)
# EXIF Orientation=6(顺时针 90):像素存成横的 1536x512,归一后显示为竖的 512x1536,
# 再经头像档限边 512 -> 512x512。
# 注意必须用「像素横躺」的样本:头像有 1:1 比例校验,若像素本身就是竖的会被先拒掉,
# 那样就测不到 EXIF 分支了。
exif_shot = make_avatar(1536, 512, orientation=6)
print(" 本地样本 square 1600x1600%7d bytes" % len(square))
print(" 本地样本 exifshot 1536x512 (EXIF=6)%d bytes" % len(exif_shot))
c = Client(base)
r = c.get("/register")
reg = c.post("/register", {
"email": email, "domain": domain, "name": "TBAv%s" % ts,
"password": pwd, "repeat_password": pwd,
"g-recaptcha-response": captcha(c), "language": "zh-CN",
"_csrf": csrf_of(r.text),
})
if not step("注册", reg.status == 302, "HTTP %s -> %s" % (reg.status, reg.loc)):
return 1
c = Client(base)
r = c.get("/login")
lg = c.post("/login", {"email": email, "password": pwd,
"g-recaptcha-response": captcha(c), "_csrf": csrf_of(r.text)})
if not step("登录", lg.status == 302, "HTTP %s -> %s" % (lg.status, lg.loc)):
return 1
# ---- A 分档 ----
url, err = upload_avatar(c, square)
if not step("上传方形大头像(1600x1600)", url is not None, err or (url or "")):
return 1
img = c.get_binary(url)
st, data = img.status, img.body
if not step("拉回头像", st == 200 and bool(data), "HTTP %s" % st):
return 1
kind, dims = sniff(data)
print(" 存储结果:%s %s %d bytes" % (kind, dims, len(data)))
step("A 分档生效:大图被缩到头像档 512(改前是 1920)",
dims == (512, 512), "实际 %s" % (dims,))
# ---- B1 EXIF ----
# 关键:两次上传的 URL 必须不同,否则说明第二次没生效(回退到了旧头像)。
url2, err2 = upload_avatar(c, exif_shot)
if not step("上传 EXIF 横躺头像(1536x512, Orientation=6)", bool(url2), err2 or (url2 or "")):
return 0
if url2 == url:
step("B1 EXIF 归一(第二次上传未生效,URL 未变)", False,
"两次上传返回同一 URL,无法区分;请检查是否被 1:1 校验拒绝")
return 0
img2 = c.get_binary(url2)
kind2, dims2 = sniff(img2.body)
print(" 存储结果:%s %s %d bytes" % (kind2, dims2, len(img2.body)))
# 归一后:像素 1536x512 -> 旋转成 512x1536 -> 再限边 512 -> 512x512
step("B1 EXIF Orientation=6 归一(1536x512 -> 512x1536 -> 512x512)",
dims2 == (512, 512), "实际 %s" % (dims2,))
# 体积对照:未归一时 1536x512 的 WebP 明显更大
step("B1 归一后体积已按 512 档压过(应与方形样本同量级)",
len(img2.body) < 6000, "%d bytes" % len(img2.body))
print("\n== 结果:%d/%d 通过 ==" % (sum(1 for _, ok, _ in RESULTS if ok), len(RESULTS)))
print("清理:TamaBox ban --email %s --yes" % email)
return 0 if all(ok for _, ok, _ in RESULTS) else 1
if __name__ == "__main__":
sys.exit(main())
-360
View File
@@ -1,360 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""TamaBox 部署后冒烟巡检(端到端)。
用途:对已部署环境跑一遍核心链路,用于发版/重新部署后的回归。
覆盖:注册 -> 登录 -> 个人页/提问列表/投票面板 -> 投票全链路(建票发布/公开页/匿名提交/
结果导出计数/草稿/表单型)-> 匿名提问 -> 停用
-> /deactivated 成功页 -> **同邮箱重注册必须被明确拒绝并提示联系站长**
-> 公开页只读巡检 -> /deactivated 四语言渲染。
语义约定(2026-09-15 起):停用 = 清空内容 + 保留用户行作「身份占位」,
故邮箱/域名继续被占用,重注册应得到「账号已停用,请找站长」的提示而不是 302 或 500。
用法:
python dev/devcheck-e2e.py [--base https://box-dev-api.337845818.best]
注意:
- 会在目标环境**真实创建账号并发送提问**(使用 tbe2e<时间戳>@example.com 之类的可识别测试邮箱),
仅在测试/开发环境运行。
- 每次完整运行都会**留下一个已停用的身份占位**(邮箱/域名被占用)。清理方式:
`TamaBox ban --email <该邮箱> --yes`(连行物理删除)或 `TamaBox restore --email <该邮箱> --yes`(恢复账号)。
- 依赖数学验证码(conf.Recaptcha.CaptchaType == "math");若站点改用 recaptcha/turnstile 则无法自动通过。
"""
import argparse
import http.cookiejar
import json
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
RESULTS = []
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
return None
class Resp:
def __init__(self, status, headers, body):
self.status = status
self.h = {k.lower(): v for k, v in headers.items()}
self.body = body
@property
def loc(self):
return self.h.get("location", "")
def alert(self):
m = re.search(r'class="uk-alert[^"]*"[^>]*>(.*?)</div>', self.body, re.S)
return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", m.group(0))).strip()[:200] if m else ""
class Client:
def __init__(self, base):
self.base = base
self.cj = http.cookiejar.CookieJar()
self.op = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(self.cj), NoRedirect())
def req(self, method, path, data=None, timeout=90):
url = path if path.startswith("http") else self.base + path
headers = {"User-Agent": UA, "Referer": self.base + path.split("?")[0]}
body = urllib.parse.urlencode(data).encode() if data is not None else None
if body:
headers["Content-Type"] = "application/x-www-form-urlencoded"
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with self.op.open(req, timeout=timeout) as r:
return Resp(r.status, dict(r.headers), r.read().decode("utf-8", "replace"))
except urllib.error.HTTPError as e:
# 读取错误响应体本身也可能超时(尤其 302 无体/远端慢),不能让它把巡检整轮打断
try:
body = e.read().decode("utf-8", "replace")
except Exception: # noqa: BLE001
body = ""
return Resp(e.code, dict(e.headers), body)
except Exception as e: # noqa: BLE001
return Resp(-1, {}, "TRANSPORT_ERROR: %s: %s" % (type(e).__name__, e))
def get(self, p, **kw):
# 只读 GET 重试一次:远端偶发慢请求会产生 TRANSPORT_ERROR,避免巡检出现假失败。
r = self.req("GET", p, **kw)
if r.status == -1:
r = self.req("GET", p, **kw)
return r
def post(self, p, data=None, **kw):
return self.req("POST", p, data=data, **kw)
def csrf_of(html):
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
return m.group(1) if m else None
def captcha(c):
"""取一道数学验证码并算出答案,返回 <id>:<answer>。"""
j = json.loads(c.get("/api/captcha/generate").body)
pid, q = j["data"]["id"], j["data"]["question"]
m = re.match(r"\s*(\d+)\s*([+\-×])\s*(\d+)\s*=", q)
a, op, b = int(m.group(1)), m.group(2), int(m.group(3))
ans = {"+": a + b, "-": a - b, "×": a * b}[op]
return "%s:%d" % (pid, ans)
def step(desc, ok, extra=""):
RESULTS.append((desc, ok, extra))
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", desc, (" (%s)" % extra) if extra else ""))
# 停用提示的跨语言特征串(站点默认语言可能是 ja,断言不能只看中文)
DEACTIVATED_HINTS = ("已停用", "has been deactivated", "は停止されています")
INTERNAL_ERROR_HINTS = ("サーバー内部エラー", "Internal Server Error", "服务器内部错误")
def is_deactivated_rejected(r):
"""停用后的重注册应被明确拒绝:HTTP 200 + 提示「账号已停用」,且不得是 500 兜底。"""
if r.status != 200:
return False
if any(h in r.body for h in INTERNAL_ERROR_HINTS):
return False
return any(h in r.body for h in DEACTIVATED_HINTS)
def check_public_pages(base):
"""公开页只读巡检:期望全部 200(/user/votes 之类的登录页除外)。"""
paths = ["/", "/explore", "/register", "/login", "/forgot-password",
"/sponsor", "/change-logs", "/about-me", "/robots.txt",
"/sitemap.xml", "/llms.txt", "/llms-full.txt", "/favicon.ico",
"/deactivated", "/api/captcha/generate", "/api/v1/site-stats"]
c = Client(base)
bad = []
for p in paths:
r = c.get(p, timeout=40)
if r.status != 200:
bad.append("%s=%s" % (p, r.status))
step("公开页只读巡检(%d 个端点)" % len(paths), not bad, ", ".join(bad))
def create_vote(c, title, vtype="single", options=None, fields=None, action="publish"):
"""提交一次投票创建(POST /user/votes)。
后端对创建动作一律按 draft=true 解析(草稿可随时暂存、跳过审核),
action=publish 时再走 validateForPublish + 内容审核;因此这里两种动作都可直接测。
"""
page = c.get("/user/votes/new")
data = {
"title": title, "description": "", "visibility": "public",
"voter_scope": "anyone", "show_result": "after_vote",
"type": vtype, "max_choices": "1", "allow_custom": "",
"start_at": "", "end_at": "", "quota_limit": "0",
"options": json.dumps(options or [], ensure_ascii=False),
"form_fields": json.dumps(fields or [], ensure_ascii=False),
"action": action, "_csrf": csrf_of(page.body),
}
return c.post("/user/votes", data)
def check_user_assets(owner):
"""登录态的接口/导出,以及停用页的「身份占位」说明。
停用页说明用**结构断言**(.uk-text-muted 段落非空),不写死文案 —— 站点语言随会话变化。
"""
r = owner.get("/api/v1/user")
step("登录态 /api/v1/user 返回 JSON", r.status == 200 and r.body.lstrip().startswith("{"),
"HTTP %s" % r.status)
step("登录态 /explore", owner.get("/explore").status == 200)
csrf = csrf_of(owner.get("/user/profile").body)
r = owner.post("/user/profile/export-json", {"_csrf": csrf})
step("导出 JSON", r.status == 200 and r.body.lstrip().startswith(("{", "[")),
"HTTP %s" % r.status)
r = owner.post("/user/profile/export", {"_csrf": csrf})
step("导出 Excel", r.status == 200, "HTTP %s %s" % (r.status, r.h.get("content-type", "")))
r = owner.get("/user/profile/deactivate")
note = re.search(r'class="uk-text-small uk-text-muted">(.*?)</span>', r.body, re.S)
step("停用页含身份占位说明", r.status == 200 and bool(note) and note.group(1).strip() != "")
def check_vote_flow(owner, base):
"""投票全链路:建票发布 -> 管理列表 -> 公开页 -> 匿名提交 -> 导出计数 -> 草稿 -> 表单型。"""
ts = time.strftime("%m%d%H%M%S")
title = "巡检单选投票%s" % ts
r = create_vote(owner, title, "single", options=["选项甲", "选项乙", "选项丙"])
step("创建单选投票并发布", r.status == 302 and "questions" in r.loc,
"HTTP %s -> %s %s" % (r.status, r.loc, r.alert()))
q = owner.get("/user/questions")
step("投票出现在管理列表", title in q.body)
mt = re.search(r"copyShareURL\('([^']+)'", q.body)
mv = re.search(r"/user/votes/(\d+)/edit", q.body)
token, vid = (mt.group(1) if mt else None), (mv.group(1) if mv else None)
step("取得分享 token / 投票 ID", bool(token and vid), "%s / %s" % (token, vid))
if not (token and vid):
return
anon = Client(base)
pv = anon.get("/vote/%s" % token)
step("公开投票页渲染", pv.status == 200 and title in pv.body, "HTTP %s" % pv.status)
mo = re.search(r'name="option_id" value="(\d+)"', pv.body)
step("公开页含可选项", bool(mo))
if not mo:
return
sub = anon.post("/vote/%s/submit" % token, {
"option_id": mo.group(1), "g-recaptcha-response": captcha(anon),
"custom_option": "", "_csrf": csrf_of(pv.body),
})
step("匿名提交投票", sub.status == 302, "HTTP %s -> %s %s" % (sub.status, sub.loc, sub.alert()))
# 导出仅作只读校验:单选导出的 CSV 每行是 <选项,票数>,末行为 <总参与,N>。
# 只认「总参与」这一行,避免把各选项票数一起求和导致 2 倍计数。
exp = owner.get("/user/votes/%s/export" % vid)
mtot = re.search(r"总参与,(\d+)", exp.body)
total = int(mtot.group(1)) if mtot else -1
step("导出显示总参与 = 1", exp.status == 200 and total == 1,
"HTTP %s 总参与=%s" % (exp.status, total))
dr = create_vote(owner, "巡检草稿%s" % ts, "single", options=["甲", "乙"], action="save")
step("保存草稿跳编辑页(?saved=1)",
dr.status == 302 and "/user/votes/" in dr.loc and "saved=1" in dr.loc,
"HTTP %s -> %s" % (dr.status, dr.loc))
fr = create_vote(owner, "巡检表单投票%s" % ts, "form", fields=[
{"field_type": "text", "label": "昵称", "required": True, "max_length": 50},
{"field_type": "textarea", "label": "建议", "required": False, "max_length": 200},
])
step("创建表单型投票并发布", fr.status == 302 and "questions" in fr.loc,
"HTTP %s -> %s %s" % (fr.status, fr.loc, fr.alert()))
def check_deactivated_i18n(base):
"""停用成功页四语言渲染(用语言无关的 ratio:3 标记 + 标题非空)。"""
bad = []
for lang in ["zh-CN", "zh-TW", "en", "ja"]:
c = Client(base)
c.get("/i18n/%s?to=/deactivated" % lang, timeout=40) # 302,语言写入 session
r = c.get("/deactivated", timeout=40) # 再请求一次才渲染新语言
title = re.search(r'uk-card-body uk-text-center".*?<h3[^>]*>([^<]*)</h3>',
r.body, re.S)
if not title or not title.group(1).strip():
bad.append(lang)
step("停用成功页四语言渲染", not bad, "缺失: " + ",".join(bad) if bad else "")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--base", default="https://box-dev-api.337845818.best")
ap.add_argument("--reuse-email", default="",
help="只跑一项:用该既有邮箱重新注册(用于复测停用后能否重注册)")
args = ap.parse_args()
base = args.base.rstrip("/")
pwd = "TbCheck!2026"
if args.reuse_email:
domain = args.reuse_email.split("@")[0]
c = Client(base)
r = c.get("/register")
r = c.post("/register", {
"email": args.reuse_email, "domain": domain, "name": "TBCheck",
"password": pwd, "repeat_password": pwd,
"g-recaptcha-response": captcha(c), "language": "zh-CN", "agree_rules": "1",
"_csrf": csrf_of(r.body),
})
step("以既有邮箱重新注册(已停用则应被明确拒绝并提示找站长)", is_deactivated_rejected(r),
"HTTP %s %s" % (r.status, r.alert()))
return 0 if all(x[1] for x in RESULTS) else 1
ts = time.strftime("%m%d%H%M%S")
email, domain = "tbe2e%s@example.com" % ts, "tbe2e%s" % ts
print("== 新账号:%s / %s ==" % (email, domain))
pub = Client(base)
r = pub.get("/register")
reg = pub.post("/register", {
"email": email, "domain": domain, "name": "TB%s" % ts,
"password": pwd, "repeat_password": pwd,
"g-recaptcha-response": captcha(pub), "language": "zh-CN", "agree_rules": "1",
"_csrf": csrf_of(r.body),
})
step("注册", reg.status == 302, "HTTP %s -> %s %s" % (reg.status, reg.loc, reg.alert()))
owner = Client(base)
r = owner.get("/login")
r = owner.post("/login", {
"email": email, "password": pwd,
"g-recaptcha-response": captcha(owner), "_csrf": csrf_of(r.body),
})
step("登录", r.status == 302, "HTTP %s -> %s" % (r.status, r.loc))
step("登录态 /user/profile", owner.get("/user/profile").status == 200)
step("登录态 /user/questions", owner.get("/user/questions").status == 200)
r = owner.get("/user/votes")
step("/user/votes 302 -> /user/questions#votes",
r.status == 302 and "questions" in r.loc, "HTTP %s -> %s" % (r.status, r.loc))
step("新建投票页", owner.get("/user/votes/new").status == 200)
check_user_assets(owner)
check_vote_flow(owner, base)
# 匿名提问:无邮箱时成功后直接 200 渲染提问成功页(不重定向),故只断言提问详情可达
r = pub.get("/_/%s" % domain)
step("打开提问箱", r.status == 200)
r = pub.post("/_/%s" % domain, {
"content": "自动化巡检测试提问(可忽略)",
"g-recaptcha-response": captcha(pub),
"receive_reply_email": "", "private": "", "ask_password": "",
"start_chat_val": "", "_csrf": csrf_of(r.body),
})
step("提交提问", r.status in (200, 302), "HTTP %s -> %s" % (r.status, r.loc))
r = owner.get("/user/profile/deactivate")
step("打开停用页", r.status == 200)
r = owner.post("/user/profile/deactivate", {"_csrf": csrf_of(r.body)})
step("停用后跳 /deactivated", r.status == 302 and "deactivated" in r.loc,
"HTTP %s -> %s" % (r.status, r.loc))
r = owner.get("/deactivated")
step("停用成功页渲染", r.status == 200 and "ratio: 3" in r.body, "HTTP %s" % r.status)
pub2 = Client(base)
r = pub2.get("/register")
r = pub2.post("/register", {
"email": email, "domain": domain, "name": "TB%sb" % ts,
"password": pwd, "repeat_password": pwd,
"g-recaptcha-response": captcha(pub2), "language": "zh-CN", "agree_rules": "1",
"_csrf": csrf_of(r.body),
})
step("停用后同邮箱重注册被明确拒绝并提示找站长(核心回归)", is_deactivated_rejected(r),
"HTTP %s %s" % (r.status, r.alert()))
check_public_pages(base)
check_deactivated_i18n(base)
print("\n===== 汇总 =====")
for d, ok, ex in RESULTS:
print("%s %s%s" % ("PASS" if ok else "FAIL", d, (" (%s)" % ex) if ex else ""))
n = sum(1 for x in RESULTS if not x[1])
print("\n共 %d 项,失败 %d 项" % (len(RESULTS), n))
return 1 if n else 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
raise
except Exception as e: # noqa: BLE001
# 远端抖动等意外中断时,仍把已完成项的结论打出来,避免只剩一段 traceback
print("\n巡检意外中断:%s: %s" % (type(e).__name__, e))
for d, ok, ex in RESULTS:
print("%s %s%s" % ("PASS" if ok else "FAIL", d, (" (%s)" % ex) if ex else ""))
sys.exit(2)
-370
View File
@@ -1,370 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""图片上传链路端到端验证(批次 A / B1 / B2)。
针对已部署环境验证三件事:
B1 EXIF Orientation=6 的竖拍 JPEG 转换后必须变成竖图(核心验收点)
B2 动画 GIF 必须被转成 .webp(cwebp 可用性 + 提问路径与头像路径一致性)
A 转换后不比原图小时必须回退原图(用一张极小线框图验证)
做法:注册账号 -> 匿名提问(multipart 同时带三张图)-> 解析成功页里的 CDN URL
-> 逐张拉回字节,解析真实容器格式与尺寸。
用法:
python dev/devcheck-image.py [--base https://box-dev-api.337845818.best]
依赖:Pillow(仅用于在本地生成测试图片,不参与被测逻辑)。
注意:会在目标环境真实创建账号并发送带图提问(测试邮箱前缀 tbimg<ts>@)。
清理:TamaBox ban --email <该邮箱> --yes
"""
import argparse
import http.cookiejar
import io
import json
import os
import tempfile
import re
import struct
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
RESULTS = []
CREATED_EMAIL = ""
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
return None
class Resp:
def __init__(self, status, headers, body):
self.status = status
self.h = {k.lower(): v for k, v in headers.items()}
self.body = body
@property
def loc(self):
return self.h.get("location", "")
@property
def text(self):
return self.body.decode("utf-8", "replace")
class Client:
def __init__(self, base):
self.base = base
self.cj = http.cookiejar.CookieJar()
self.op = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(self.cj), NoRedirect())
def _send(self, req, timeout=120):
try:
with self.op.open(req, timeout=timeout) as r:
return Resp(r.status, dict(r.headers), r.read())
except urllib.error.HTTPError as e:
try:
body = e.read()
except Exception: # noqa: BLE001
body = b""
return Resp(e.code, dict(e.headers), body)
except Exception as e: # noqa: BLE001
return Resp(-1, {}, ("TRANSPORT_ERROR: %s" % e).encode())
def req(self, method, path, data=None, content_type=None, timeout=120):
url = path if path.startswith("http") else self.base + path
headers = {"User-Agent": UA, "Referer": self.base + path.split("?")[0]}
body = None
if data is not None:
body = data if isinstance(data, bytes) else urllib.parse.urlencode(data).encode()
headers["Content-Type"] = content_type or "application/x-www-form-urlencoded"
return self._send(urllib.request.Request(url, data=body, headers=headers, method=method),
timeout)
def get(self, p, **kw):
r = self.req("GET", p, **kw)
return r if r.status != -1 else self.req("GET", p, **kw)
def post(self, p, data=None, **kw):
return self.req("POST", p, data=data, **kw)
def get_binary(self, url):
return self._send(urllib.request.Request(
url, headers={"User-Agent": UA}, method="GET"), timeout=60)
def step(desc, ok, extra=""):
RESULTS.append((desc, ok, extra))
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", desc, (" (%s)" % extra) if extra else ""))
return ok
def csrf_of(html):
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
return m.group(1) if m else None
def captcha(c):
j = json.loads(c.get("/api/captcha/generate").text)
pid, q = j["data"]["id"], j["data"]["question"]
m = re.match(r"\s*(\d+)\s*([+\-×])\s*(\d+)\s*=", q)
a, op, b = int(m.group(1)), m.group(2), int(m.group(3))
return "%s:%d" % (pid, {"+": a + b, "-": a - b, "×": a * b}[op])
def multipart(fields, files):
boundary = "----tbimg%s" % uuid.uuid4().hex
buf = io.BytesIO()
for k, v in fields.items():
buf.write(("--%s\r\n" % boundary).encode())
buf.write(('Content-Disposition: form-data; name="%s"\r\n\r\n' % k).encode())
buf.write(("%s\r\n" % v).encode())
for name, fn, ct, data in files:
buf.write(("--%s\r\n" % boundary).encode())
buf.write(('Content-Disposition: form-data; name="%s"; filename="%s"\r\n' % (name, fn)).encode())
buf.write(("Content-Type: %s\r\n\r\n" % ct).encode())
buf.write(data)
buf.write(b"\r\n")
buf.write(("--%s--\r\n" % boundary).encode())
return buf.getvalue(), "multipart/form-data; boundary=%s" % boundary
# ---------- 容器格式解析(纯 stdlib) ----------
def sniff(data):
"""返回 (kind, (w,h))。kind in {webp, jpeg, gif, png, unknown}"""
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "webp", webp_dims(data)
if data[:3] == b"\xff\xd8\xff":
return "jpeg", jpeg_dims(data)
if data[:3] == b"GIF":
return "gif", gif_dims(data)
if data[:8] == b"\x89PNG\r\n\x1a\n":
return "png", png_dims(data)
return "unknown", None
def webp_dims(d):
c = d[12:16]
if c == b"VP8 " and len(d) >= 30:
return (struct.unpack("<H", d[26:28])[0] & 0x3FFF,
struct.unpack("<H", d[28:30])[0] & 0x3FFF)
if c == b"VP8L" and len(d) >= 25:
b = struct.unpack("<I", d[21:25])[0]
return (b & 0x3FFF) + 1, ((b >> 14) & 0x3FFF) + 1
if c == b"VP8X" and len(d) >= 30:
return ((d[24] | d[25] << 8 | d[26] << 16) + 1,
(d[27] | d[28] << 8 | d[29] << 16) + 1)
return None
def jpeg_dims(d):
i = 2
while i + 9 < len(d):
if d[i] != 0xFF:
i += 1
continue
m = d[i + 1]
if m in (0xC0, 0xC1, 0xC2, 0xC3):
h, w = struct.unpack(">HH", d[i + 5:i + 9])
return (w, h)
if m in (0xD8, 0xD9) or 0xD0 <= m <= 0xD7:
i += 2
continue
i += 2 + struct.unpack(">H", d[i + 2:i + 4])[0]
return None
def gif_dims(d):
return struct.unpack("<HH", d[6:10]) if len(d) >= 10 else None
def png_dims(d):
return struct.unpack(">II", d[16:24]) if len(d) >= 24 else None
# ---------- 测试图片生成(Pillow 仅此一处使用) ----------
def make_images():
from PIL import Image, ImageDraw
import io as _io
# B1:EXIF Orientation=6。像素存 240x120,正确归一后应显示 120x240。
base = Image.new("RGB", (240, 120))
dr = ImageDraw.Draw(base)
for i in range(0, 240, 20):
dr.line([(i, 0), (i, 120)], fill=(200, 30, 30), width=3)
dr.rectangle([0, 0, 239, 119], outline=(0, 0, 255), width=4)
exif = base.getexif()
exif[0x0112] = 6
buf = _io.BytesIO()
base.save(buf, "JPEG", quality=88, exif=exif)
portrait = buf.getvalue()
# B2:6 帧动画 GIF。加大到 640x480,确保转换后仍小于原图(否则会走回退)。
frames = []
pal_frames = []
for f in range(6):
im = Image.new("RGB", (640, 480), (240, 240, 240))
d = ImageDraw.Draw(im)
d.ellipse([40 + f * 60, 120, 200 + f * 60, 280], fill=(220, 40, 40))
d.rectangle([300, 60 + f * 20, 600, 200], fill=(30, 90, 220))
pal_frames.append(im.convert("P", palette=Image.ADAPTIVE, colors=64))
gbuf = _io.BytesIO()
pal_frames[0].save(gbuf, "GIF", save_all=True, append_images=pal_frames[1:],
duration=120, loop=0)
animated = gbuf.getvalue()
# A:极小线框图(200x200),WebP 有损编码后反而比 PNG 大 -> 应回退原图。
wire = Image.new("RGB", (200, 200), (255, 255, 255))
d = ImageDraw.Draw(wire)
for i in range(0, 200, 20):
d.line([(i, 0), (i, 199)], fill=(0, 0, 0))
d.line([(0, i), (199, i)], fill=(0, 0, 0))
wbuf = _io.BytesIO()
wire.save(wbuf, "PNG")
wireframe = wbuf.getvalue()
return {"portrait": portrait, "anim": animated, "wire": wireframe}
def main():
global CREATED_EMAIL
ap = argparse.ArgumentParser()
ap.add_argument("--base", default="https://box-dev-api.337845818.best")
args = ap.parse_args()
base = args.base.rstrip("/")
pwd = "TbImg!2026"
ts = time.strftime("%m%d%H%M%S")
email, domain = "tbimg%s@example.com" % ts, "tbimg%s" % ts
CREATED_EMAIL = email
print("== 账号:%s / %s ==" % (email, domain))
imgs = make_images()
for k, v in imgs.items():
print(" 本地样本 %-9s %7d bytes" % (k, len(v)))
c = Client(base)
r = c.get("/register")
reg = c.post("/register", {
"email": email, "domain": domain, "name": "TBImg%s" % ts,
"password": pwd, "repeat_password": pwd,
"g-recaptcha-response": captcha(c), "language": "zh-CN",
"_csrf": csrf_of(r.text),
})
if not step("注册", reg.status == 302, "HTTP %s -> %s" % (reg.status, reg.loc)):
return 1
c = Client(base)
r = c.get("/login")
lg = c.post("/login", {"email": email, "password": pwd,
"g-recaptcha-response": captcha(c), "_csrf": csrf_of(r.text)})
if not step("登录", lg.status == 302, "HTTP %s -> %s" % (lg.status, lg.loc)):
return 1
page = c.get("/_/%s" % domain)
if not step("打开提问箱", page.status == 200, "HTTP %s" % page.status):
return 1
# route/question/page.go 只取 f.Images[0](单张上限),
# 故每张样本各发一条提问,逐张验证。
fetched = []
for key, (fname, ctype) in (("portrait", ("portrait.jpg", "image/jpeg")),
("anim", ("anim.gif", "image/gif")),
("wire", ("wire.png", "image/png"))):
box = c.get("/_/%s" % domain)
body, ct = multipart({
"content": "图片上传链路验证 %s(可忽略)" % key,
"g-recaptcha-response": captcha(c),
"receive_reply_email": "", "private": "", "ask_password": "",
"start_chat_val": "", "_csrf": csrf_of(box.text),
}, [("images", fname, ctype, imgs[key])])
res = c.post("/_/%s" % domain, data=body, content_type=ct)
if not step("提交带图提问 [%s]" % key, res.status in (200, 302),
"HTTP %s -> %s" % (res.status, res.loc)):
continue
# 成功页不渲染图片(templates/question/success.html 无图片区),
# 故从成功页取出 question ID,再访问详情页取图片 key。
qid = re.search(r'/_/%s/(\d+)' % re.escape(domain), res.text)
if not qid:
step(" └ 取提问 ID [%s]" % key, False, "成功页未给出 /_/<domain>/<id>")
continue
detail = c.get("/_/%s/%s" % (domain, qid.group(1)))
# 提问图的 key 不带 picture/ 前缀(是 "<年>/<月>/<纳秒>.<ext>"),
# 模板拼成 https://<CDN>/<key>,故按 CDN host + 数字开头路径来匹配。
urls = sorted(set(re.findall(
r'https://r2\.[^"\'\s<>]+/\d{4}/\d{1,2}/\d+\.\w+', detail.text)))
if not urls:
step(" └ 详情页回显图片 [%s]" % key, False,
"HTTP %s,未找到 picture URL" % detail.status)
continue
step(" └ 详情页回显图片 [%s]" % key, True, "%d 个" % len(urls))
for u in urls:
st, data = c.get_binary(u)
if st != 200 or not data:
step(" └ 拉回图片 [%s]" % key, False, "HTTP %s" % st)
continue
kind, dims = sniff(data)
fetched.append((key, kind, dims, len(data)))
print(" %-14s %-7s %-12s %7d bytes" % (u.rsplit("/", 1)[-1], kind, dims, len(data)))
print("\n== 断言 ==")
# B1
portrait = [f for f in fetched if f[0] == "portrait" and f[1] in ("webp", "jpeg")
and f[2] == (120, 240)]
land = [f for f in fetched if f[0] == "portrait" and f[1] in ("webp", "jpeg")
and f[2] == (240, 120)]
if portrait:
step("B1 EXIF Orientation=6 已归一(240x120 -> 120x240)", True,
"%s, %d bytes" % (portrait[0][1], portrait[0][3]))
elif land:
step("B1 EXIF Orientation=6 已归一", False, "仍是 240x120,说明未应用 EXIF 旋转")
else:
step("B1 EXIF 方向归一", False, "未匹配到 240x120 / 120x240 的图片,实际: %s" %
[(f[1], f[2]) for f in fetched])
# B2:GIF 样本必须变成 webp;关键是按 key 归属判定,而不是看整体有没有 webp
gif_res = [f for f in fetched if f[0] == "anim"]
if not gif_res:
step("B2 动画 GIF 转 WebP", False, "未取到 anim 样本结果")
elif gif_res[0][1] == "webp":
step("B2 动画 GIF 已转为 WebP", True,
"%dx%d, %d bytes(原 GIF %d bytes,-%.0f%%)" % (
gif_res[0][2][0], gif_res[0][2][1], gif_res[0][3], len(imgs["anim"]),
(1 - gif_res[0][3] / len(imgs["anim"])) * 100))
elif gif_res[0][1] == "gif":
step("B2 动画 GIF 已转为 WebP", False,
"仍是原始 GIF %d bytes -> cwebp 不可用或转换失败" % gif_res[0][3])
else:
step("B2 动画 GIF 转 WebP", False, "得到 %s(预期 webp)" % gif_res[0][1])
# A:线框图应回退原图(PNG),而非转成更大的 WebP
wire = [f for f in fetched if f[0] == "wire"]
if not wire:
step("A 线框图兜底回退", False, "未取到 wire 样本结果")
elif wire[0][1] == "png":
step("A 转换无收益时回退原图(线框图保持 PNG)", True,
"%d bytes(原 PNG %d bytes)" % (wire[0][3], len(imgs["wire"])))
else:
step("A 线框图兜底回退", False,
"被转成了 %s(%d bytes)——本应回退原图" % (wire[0][1], wire[0][3]))
print("\n== 结果:%d/%d 通过 ==" % (sum(1 for _, ok, _ in RESULTS if ok), len(RESULTS)))
print("清理:TamaBox ban --email %s --yes" % email)
return 0 if all(ok for _, ok, _ in RESULTS) else 1
if __name__ == "__main__":
sys.exit(main())
-419
View File
@@ -1,419 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""上传链路端到端验证(A 分档 / B1 EXIF 归一 + EXIF 比例校验 / B3 单次解码)。
为什么主用例走「背景」而不是头像
--------------------------------
头像要求 1:1,是**对称**比例:EXIF 归一(5/6/7/8 交换宽高)后比例不变。
所以无论修没修比例校验的 EXIF 缺口,头像路径的表现完全一样 —— **测不出缺口**。
背景要求 16:9,非对称,且用户真实场景就是「手机竖拍一张照当背景」:
像素 1080x1920(竖,比例 0.5625)+ EXIF Orientation=6(顺时针 90)
-> 用户肉眼看到的是 1920x1080 横图,比例 1.7778,符合 16:9
-> 修复前:只看原始像素 0.5625 vs 1.7778,偏差 1.215 > 0.01 → **被误拒**
-> 修复后:先交换宽高得 1920x1080,偏差 0 → 放行
头像用例保留,但只用来回归 A 分档(不再承担 EXIF 比例校验的验证职责)。
用法:
python dev/devcheck-upload.py [--base https://box-dev-api.337845818.best]
依赖:Pillow(仅用于本地生成测试图片)
清理:TamaBox ban --email <输出中的测试邮箱> --yes
"""
import argparse
import http.cookiejar
import io
import json
import re
import struct
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from PIL import Image, ImageOps
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
RESULTS = []
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
return None
class Resp:
def __init__(self, status, headers, body):
self.status = status
self.h = {k.lower(): v for k, v in headers.items()}
self.body = body
@property
def loc(self):
return self.h.get("location", "")
@property
def text(self):
return self.body.decode("utf-8", "replace")
class Client:
def __init__(self, base):
self.base = base
self.cj = http.cookiejar.CookieJar()
self.op = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(self.cj), NoRedirect())
def _send(self, req, timeout=180):
try:
with self.op.open(req, timeout=timeout) as r:
return Resp(r.status, dict(r.headers), r.read())
except urllib.error.HTTPError as e:
try:
body = e.read()
except Exception: # noqa: BLE001
body = b""
return Resp(e.code, dict(e.headers), body)
except Exception as e: # noqa: BLE001
return Resp(-1, {}, ("TRANSPORT_ERROR: %s" % e).encode())
def req(self, method, path, data=None, content_type=None, timeout=180):
url = path if path.startswith("http") else self.base + path
headers = {"User-Agent": UA, "Referer": self.base + path.split("?")[0]}
body = None
if data is not None:
body = data if isinstance(data, bytes) else urllib.parse.urlencode(data).encode()
headers["Content-Type"] = content_type or "application/x-www-form-urlencoded"
return self._send(urllib.request.Request(url, data=body, headers=headers, method=method),
timeout)
def get(self, p, **kw):
r = self.req("GET", p, **kw)
return r if r.status != -1 else self.req("GET", p, **kw)
def post(self, p, data=None, **kw):
return self.req("POST", p, data=data, **kw)
def get_binary(self, url):
return self._send(urllib.request.Request(
url, headers={"User-Agent": UA}, method="GET"), timeout=60)
def step(desc, ok, extra=""):
RESULTS.append((desc, ok, extra))
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", desc, (" (%s)" % extra) if extra else ""))
return ok
def redness_bbox(im):
"""返回红色标记条的包围盒 (x0, y0, x1, y1)。
用来做「方向是否真的被旋转」的判据:尺寸对不代表方向对,必须看特征位置。
有损 WebP 会让红色略有偏差,故阈值放宽并按 4px 步长采样。
"""
W, H = im.size
px = im.load()
xs, ys = [], []
for y in range(0, H, 4):
for x in range(0, W, 4):
r, g, b = px[x, y]
if r > 150 and g < 90 and b < 90:
xs.append(x)
ys.append(y)
return (min(xs), min(ys), max(xs), max(ys)) if xs else None
def csrf_of(html):
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
return m.group(1) if m else None
def captcha(c):
j = json.loads(c.get("/api/captcha/generate").text)
pid, q = j["data"]["id"], j["data"]["question"]
m = re.match(r"\s*(\d+)\s*([+\-×])\s*(\d+)\s*=", q)
a, op, b = int(m.group(1)), m.group(2), int(m.group(3))
return "%s:%d" % (pid, {"+": a + b, "-": a - b, "×": a * b}[op])
def multipart(fields, files):
boundary = "----tbuv%s" % uuid.uuid4().hex
buf = io.BytesIO()
for k, v in fields.items():
buf.write(("--%s\r\n" % boundary).encode())
buf.write(('Content-Disposition: form-data; name="%s"\r\n\r\n' % k).encode())
buf.write(("%s\r\n" % v).encode())
for name, fn, ct, data in files:
buf.write(("--%s\r\n" % boundary).encode())
buf.write(('Content-Disposition: form-data; name="%s"; filename="%s"\r\n' % (name, fn)).encode())
buf.write(("Content-Type: %s\r\n\r\n" % ct).encode())
buf.write(data)
buf.write(b"\r\n")
buf.write(("--%s--\r\n" % boundary).encode())
return buf.getvalue(), "multipart/form-data; boundary=%s" % boundary
def sniff(d):
if len(d) >= 12 and d[:4] == b"RIFF" and d[8:12] == b"WEBP":
return "webp", webp_dims(d)
if d[:3] == b"\xff\xd8\xff":
return "jpeg", jpeg_dims(d)
if d[:8] == b"\x89PNG\r\n\x1a\n":
return "png", (struct.unpack(">II", d[16:24]) if len(d) >= 24 else None)
return "unknown", None
def webp_dims(d):
c = d[12:16]
if c == b"VP8 " and len(d) >= 30:
return (struct.unpack("<H", d[26:28])[0] & 0x3FFF,
struct.unpack("<H", d[28:30])[0] & 0x3FFF)
if c == b"VP8L" and len(d) >= 25:
b = struct.unpack("<I", d[21:25])[0]
return (b & 0x3FFF) + 1, ((b >> 14) & 0x3FFF) + 1
if c == b"VP8X" and len(d) >= 30:
return ((d[24] | d[25] << 8 | d[26] << 16) + 1,
(d[27] | d[28] << 8 | d[29] << 16) + 1)
return None
def jpeg_dims(d):
i = 2
while i + 9 < len(d):
if d[i] != 0xFF:
i += 1
continue
m = d[i + 1]
if m in (0xC0, 0xC1, 0xC2, 0xC3):
h, w = struct.unpack(">HH", d[i + 5:i + 9])
return (w, h)
if m in (0xD8, 0xD9) or 0xD0 <= m <= 0xD7:
i += 2
continue
i += 2 + struct.unpack(">H", d[i + 2:i + 4])[0]
return None
def make_img(w, h, orientation=None, portrait=False):
"""生成带方位特征的图片。
portrait=True 时在**左侧**画一条粗红色竖条:EXIF 顺时针 90 后它会跑到顶部,
用于像素级确认方向确实被归一了(而非仅尺寸数字对)。
"""
from PIL import ImageDraw
im = Image.new("RGB", (w, h), (30, 120, 200))
d = ImageDraw.Draw(im)
if portrait:
d.rectangle([0, 0, max(2, w // 12), h], fill=(230, 30, 30))
d.text((max(4, w // 12 + 6), h // 2), "TOP-IS-LEFT", fill=(255, 255, 255))
else:
d.rectangle([0, 0, w, max(2, h // 12)], fill=(230, 30, 30))
d.text((w // 4, max(4, h // 12 + 4)), "TOP-IS-UP", fill=(255, 255, 255))
for i in range(0, w, max(8, w // 20)):
d.line([(i, h // 4), (i, h)], fill=(255, 255, 255), width=2)
buf = io.BytesIO()
if orientation:
exif = im.getexif()
exif[0x0112] = orientation
im.save(buf, "JPEG", quality=90, exif=exif)
else:
im.save(buf, "JPEG", quality=90)
return buf.getvalue()
def scrape_profile_fields(html):
"""从 profile 页抓取所有 input 的 name/value,保证回填必填项。
UpdateProfile 的必填字段有十来个(昵称/介绍/5 个二维码颜色/点大小/透明度…),
漏一个就会因 `valid:"required"` 被Bind 挡下、图片根本不会走到上传分支。
"""
fields = {}
for m in re.finditer(r'<input\b[^>]*>', html, re.I):
tag = m.group(0)
nm = re.search(r'name="([^"]+)"', tag, re.I)
if not nm:
continue
name = nm.group(1)
if name in ("avatar", "background", "backgroundimage", "_csrf", "submit"):
continue
typ = re.search(r'type="([^"]+)"', tag, re.I)
if typ and typ.group(1).lower() in ("checkbox", "radio"):
if not re.search(r'\bchecked\b', tag, re.I):
continue
val = re.search(r'value="([^"]*)"', tag, re.I)
fields[name] = val.group(1) if val else ""
for m in re.finditer(r'<select\b[^>]*name="([^"]+)"[^>]*>(.*?)</select>', html, re.I | re.S):
name, body = m.group(1), m.group(2)
opts = re.findall(r'<option\b([^>]*)>', body, re.I)
chosen = ""
for o in opts:
vm = re.search(r'value="([^"]*)"', o, re.I)
v = vm.group(1) if vm else ""
if re.search(r'\bselected\b', o, re.I):
chosen = v
break
if not chosen:
chosen = v
fields[name] = chosen
for m in re.finditer(r'<textarea\b[^>]*name="([^"]+)"[^>]*>(.*?)</textarea>', html, re.I | re.S):
fields[m.group(1)] = re.sub(r"<[^>]+>", "", m.group(2)).strip()
return fields
def upload(c, field, data, fname="img.jpg"):
"""上传图片到 profile 表单的指定字段(avatar / background)。
返回 (图片CDN URL 或 None, 错误描述或 None)。
"""
page = c.get("/user/profile")
if page.status != 200:
return None, "profile HTTP %s" % page.status
fields = scrape_profile_fields(page.text)
fields["_csrf"] = csrf_of(page.text)
body, ct = multipart(fields, [(field, fname, "image/jpeg", data)])
res = c.post("/user/profile/update", data=body, content_type=ct)
if res.status == 302 and res.loc:
res = c.get(res.loc if res.loc.startswith("/") else "/user/profile")
if res.status != 200:
return None, "update 后页面 HTTP %s" % res.status
if field == "background":
m = re.search(r'id="bg-prev"[^>]*src="([^"]+)"', res.text) or \
re.search(r'src="([^"]+)"[^>]*id="bg-prev"', res.text)
url = m.group(1) if m else None
else:
urls = re.findall(r'https://r2\.[^"\'\s<>]+/picture/[0-9]+/\d{2}/\d{2}/[0-9a-f]+', res.text)
if not urls:
m = re.search(r'(https://r2\.[^"\'\s<>]+/picture/[^"\'\s<>]+)', res.text)
urls = [m.group(1)] if m else []
url = urls[-1] if urls else None
if url:
return url, None
dump = "C:/Users/Tamakyi Gekka/AppData/Local/Temp/tbuv_profile.html"
with open(dump, "wb") as f:
f.write(res.body)
alerts = re.findall(r'class="uk-alert[^"]*"[^>]*>(.*?)</div>', res.text, re.S)
hint = " | ".join(re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", a)).strip()[:80]
for a in alerts[:3])
return None, "未取到图片 URL(已存 %s)%s%s" % (dump, (" 提示:" + hint) if hint else "",
(" HTTP %s" % res.status))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--base", default="https://box-dev-api.337845818.best")
args = ap.parse_args()
base = args.base.rstrip("/")
pwd = "TbUv!2026"
ts = time.strftime("%m%d%H%M%S")
email, domain = "tbuv%s@example.com" % ts, "tbuv%s" % ts
print("== 账号:%s / %s ==" % (email, domain))
# ---- 本地样本 ----
square = make_img(1600, 1600) # A 分档回归:头像
bg_ok = make_img(1920, 1080) # 背景 16:9,无 EXIF(正向对照)
bg_ori6 = make_img(1080, 1920, orientation=6, portrait=True) # 核心用例
bg_bad = make_img(1080, 1920) # 无 EXIF 的竖图:应被拒(16:9 校验仍生效)
print(" 样本 avatar 1600x1600 %7d B" % len(square))
print(" 样本 bg-ok 1920x1080 %7d B" % len(bg_ok))
print(" 样本 bg-ori6 1080x1920 (EXIF=6) %7d B" % len(bg_ori6))
print(" 样本 bg-bad 1080x1920 (无 EXIF) %7d B" % len(bg_bad))
c = Client(base)
r = c.get("/register")
reg = c.post("/register", {
"email": email, "domain": domain, "name": "TBUv%s" % ts,
"password": pwd, "repeat_password": pwd,
"g-recaptcha-response": captcha(c), "language": "zh-CN",
"_csrf": csrf_of(r.text),
})
if not step("注册", reg.status == 302, "HTTP %s -> %s" % (reg.status, reg.loc)):
return 1
c = Client(base)
r = c.get("/login")
lg = c.post("/login", {"email": email, "password": pwd,
"g-recaptcha-response": captcha(c), "_csrf": csrf_of(r.text)})
if not step("登录", lg.status == 302, "HTTP %s -> %s" % (lg.status, lg.loc)):
return 1
# ================= A 分档(头像回归) =================
print("\n-- A 分档(头像 512/q78)--")
url, err = upload(c, "avatar", square)
if not step("上传方形大头像(1600x1600)", url is not None, err or (url or "")):
return 1
img = c.get_binary(url)
if not step("拉回头像", img.status == 200 and bool(img.body), "HTTP %s" % img.status):
return 1
kind, dims = sniff(img.body)
print(" 存储结果:%s %s %d bytes" % (kind, dims, len(img.body)))
step("A 分档生效:大图被缩到头像档 512", dims == (512, 512), "实际 %s" % (dims,))
# ================= B1 背景正向对照(无 EXIF) =================
print("\n-- 背景 16:9 正向对照(无 EXIF)--")
url_bg, err = upload(c, "background", bg_ok)
if not step("上传 16:9 背景(1920x1080,无 EXIF)", url_bg is not None, err or (url_bg or "")):
return 1
r1 = c.get_binary(url_bg)
k1, d1 = sniff(r1.body)
print(" 存储结果:%s %s %d bytes" % (k1, d1, len(r1.body)))
step("背景档 1920 且比例正确", k1 == "webp" and d1 == (1920, 1080), "实际 %s %s" % (k1, d1))
# ================= B1 核心:EXIF Orientation=6 的竖拍背景 =================
print("\n-- B1 核心:EXIF 比例校验缺口(手机竖拍当背景)--")
print(" 像素 1080x1920 + EXIF=6 ->肉眼看到 1920x1080 (16:9)")
print(" 修复前:按像素 0.5625 判比例 -> 偏差 1.215 >> 0.01 -> 被误拒")
url_o, err = upload(c, "background", bg_ori6)
if not step("上传 EXIF 竖拍背景(1080x1920, Orientation=6)", url_o is not None,
err or (url_o or "")):
print(" >>> 若此处 FAIL,说明 EXIF 比例校验缺口仍未修复")
return 1
if url_o == url_bg:
step("EXIF 背景确实换了新图", False, "两次上传返回同一 URL(第二次被拒、沿用旧值)")
return 1
r2 = c.get_binary(url_o)
k2, d2 = sniff(r2.body)
print(" 存储结果:%s %s %d bytes" % (k2, d2, len(r2.body)))
step("B1 EXIF 归一后按16:9 存盘(1920x1080)", k2 == "webp" and d2 == (1920, 1080),
"实际 %s %s" % (k2, d2))
step("B1 归一后限边生效(长边 1920)",
d2 is not None and max(d2) == 1920, "实际 %s" % (d2,))
# 尺寸对了不代表方向对了 —— 与 Pillow exif_transpose 作像素级黄金标准比对:
# 定位红色条的包围盒,两边必须一致(原图红条在左侧,顺时针 90 后应跑到顶部)。
ref = ImageOps.exif_transpose(Image.open(io.BytesIO(bg_ori6))).convert("RGB")
got = Image.open(io.BytesIO(r2.body)).convert("RGB")
if ref.size == got.size:
bb_ref, bb_got = redness_bbox(ref), redness_bbox(got)
step("B1 像素级方向与 Pillow exif_transpose 一致(红条包围盒)",
bb_ref is not None and bb_ref == bb_got,
"黄金标准 %s vs 存盘 %s" % (bb_ref, bb_got))
else:
step("B1 像素级方向与 Pillow exif_transpose 一致(红条包围盒)", False,
"尺寸不同:黄金标准 %s vs 存盘 %s" % (ref.size, got.size))
# ================= 反向用例:EXIF 修复不能放松比例校验 =================
print("\n-- 反向用例:无 EXIF 的竖图仍应被拒(确认没把校验放得过宽)--")
url_bad, err_bad = upload(c, "background", bg_bad)
# 期望:上传被拒 -> bg-prev 仍是上一步的 ori6 图(url_o)
if url_bad == url_o:
step("无 EXIF 的 9:16 竖图被 16:9 校验拒绝", True, "bg-prev 保持 ori6 那张")
elif url_bad is None:
step("无 EXIF 的 9:16 竖图被 16:9 校验拒绝", True, "上传被拒(%s)" % (err_bad or "")[:60])
else:
step("无 EXIF 的 9:16 竖图被 16:9 校验拒绝", False,
"竟被接受并存成 %s" % (url_bad,))
print("\n== 结果:%d/%d 通过 ==" % (sum(1 for _, ok, _ in RESULTS if ok), len(RESULTS)))
for d, ok, e in RESULTS:
if not ok:
print(" FAIL: %s %s" % (d, e))
print("清理:TamaBox ban --email %s --yes" % email)
return 0 if all(ok for _, ok, _ in RESULTS) else 1
if __name__ == "__main__":
sys.exit(main())
+2
View File
@@ -68,6 +68,7 @@ func Init(typ, dsn string) (*gorm.DB, error) {
}
db, err := gorm.Open(dialector, &gorm.Config{
Logger: newSafeLogger(),
SkipDefaultTransaction: true,
})
if err != nil {
@@ -155,6 +156,7 @@ func Init(typ, dsn string) (*gorm.DB, error) {
if err := db.Use(otelgorm.NewPlugin(
otelgorm.WithDBName(conf.Database.Name),
otelgorm.WithoutQueryVariables(),
)); err != nil {
return nil, errors.Wrap(err, "register otelgorm plugin")
}
+79
View File
@@ -0,0 +1,79 @@
package db
import (
"context"
"errors"
"fmt"
"time"
"github.com/sirupsen/logrus"
"gorm.io/gorm/logger"
)
type safeLogger struct {
logger.Config
}
func newSafeLogger() logger.Interface {
return &safeLogger{Config: logger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: logger.Warn,
IgnoreRecordNotFoundError: false,
}}
}
func (l *safeLogger) LogMode(level logger.LogLevel) logger.Interface {
newLogger := *l
newLogger.LogLevel = level
return &newLogger
}
func (l *safeLogger) Info(ctx context.Context, _ string, _ ...interface{}) {
if l.LogLevel >= logger.Info {
logrus.WithContext(ctx).WithField("component", "gorm").Info("数据库信息事件")
}
}
func (l *safeLogger) Warn(ctx context.Context, _ string, _ ...interface{}) {
if l.LogLevel >= logger.Warn {
logrus.WithContext(ctx).WithField("component", "gorm").Warn("数据库警告事件")
}
}
func (l *safeLogger) Error(ctx context.Context, _ string, _ ...interface{}) {
if l.LogLevel >= logger.Error {
logrus.WithContext(ctx).WithField("component", "gorm").Error("数据库错误事件")
}
}
func (l *safeLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
if l.LogLevel <= logger.Silent {
return
}
elapsed := time.Since(begin)
switch {
case err != nil && l.LogLevel >= logger.Error && (!errors.Is(err, logger.ErrRecordNotFound) || !l.IgnoreRecordNotFoundError):
_, rows := fc()
l.traceEntry(ctx, elapsed, rows).WithFields(logrus.Fields{
"error_type": fmt.Sprintf("%T", err),
"record_not_found": errors.Is(err, logger.ErrRecordNotFound),
}).Error("数据库操作失败")
case elapsed > l.SlowThreshold && l.SlowThreshold != 0 && l.LogLevel >= logger.Warn:
_, rows := fc()
l.traceEntry(ctx, elapsed, rows).
WithField("slow_threshold", l.SlowThreshold.String()).
Warn("数据库慢查询")
case l.LogLevel >= logger.Info:
_, rows := fc()
l.traceEntry(ctx, elapsed, rows).Info("数据库操作完成")
}
}
func (l *safeLogger) traceEntry(ctx context.Context, elapsed time.Duration, rows int64) *logrus.Entry {
return logrus.WithContext(ctx).WithFields(logrus.Fields{
"component": "gorm",
"elapsed_ms": float64(elapsed.Nanoseconds()) / 1e6,
"rows_affected": rows,
})
}
+56 -8
View File
@@ -1011,7 +1011,8 @@ func (db *users) BanUserAndCleanup(ctx context.Context, userID uint) error {
}
// purgeUserContent 物理删除某用户的全部内容数据:追问、消息与消息图片、私聊会话、
// 提问-图片关联、提问、拉黑关系、功能开关。**不含用户行本身**(由调用方决定是软删除还是物理删除)。
// 提问图片元数据及关联、提问、投票及其子表、访问统计、拉黑关系、功能开关。
// **不含用户行本身**(由调用方决定是软删除还是物理删除),也不删除审计/处置记录。
//
// 全部使用 Unscoped 物理删除而非软删除:这些表多为软删除模型,而部分表的唯一索引
// 不含 deleted_at(如 conversations.token),软删除会让被删行永久占据唯一槽位。
@@ -1027,18 +1028,18 @@ func purgeUserContent(ctx context.Context, tx *gorm.DB, userID uint) error {
return errors.Wrap(err, "delete follow ups of user")
}
// 消息图片必须先于消息删除,否则消息子查询将无法再匹配待删图片。
if err := tx.WithContext(ctx).Unscoped().
Where("message_id IN (SELECT id FROM messages WHERE conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?))", userID, userID).
Delete(&MessageImage{}).Error; err != nil {
return errors.Wrap(err, "delete message images of user")
}
// 消息:属于该用户作为 owner 或 asker 的会话
if err := tx.WithContext(ctx).Unscoped().
Where("conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?)", userID, userID).
Delete(&Message{}).Error; err != nil {
return errors.Wrap(err, "delete messages of user")
}
// 消息图片
if err := tx.WithContext(ctx).Unscoped().
Where("message_id IN (SELECT id FROM messages WHERE conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?))", userID, userID).
Delete(&MessageImage{}).Error; err != nil {
return errors.Wrap(err, "delete message images of user")
}
// 私聊会话
if err := tx.WithContext(ctx).Unscoped().
@@ -1047,7 +1048,25 @@ func purgeUserContent(ctx context.Context, tx *gorm.DB, userID uint) error {
return errors.Wrap(err, "delete conversations of user")
}
// 提问-图片关联(仅删除关联记录;UploadImage 本体为通用资源,保留)
// 提问图片元数据:删除本用户上传的孤立图片,以及仅链接到本用户提问的图片。
// 仍被其他提问引用的图片必须保留;这里只清数据库元数据,不操作对象存储。
if err := tx.WithContext(ctx).Unscoped().
Where(`(
uploader_user_id = ?
OR upload_images.id IN (
SELECT upload_image_id FROM upload_image_questions
WHERE question_id IN (SELECT id FROM questions WHERE user_id = ?)
)
) AND NOT EXISTS (
SELECT 1 FROM upload_image_questions AS shared_links
WHERE shared_links.upload_image_id = upload_images.id
AND shared_links.question_id NOT IN (SELECT id FROM questions WHERE user_id = ?)
)`, userID, userID, userID).
Delete(&UploadImage{}).Error; err != nil {
return errors.Wrap(err, "delete upload images of user")
}
// 提问-图片关联
if err := tx.WithContext(ctx).Unscoped().
Where("question_id IN (SELECT id FROM questions WHERE user_id = ?)", userID).
Delete(&UploadImageQuestion{}).Error; err != nil {
@@ -1061,6 +1080,35 @@ func purgeUserContent(ctx context.Context, tx *gorm.DB, userID uint) error {
return errors.Wrap(err, "delete questions of user")
}
// 投票子表必须先于投票主表删除;只处理该用户拥有的投票,不按参与者身份扩大范围。
if err := tx.WithContext(ctx).Unscoped().
Where("vote_id IN (SELECT id FROM votes WHERE user_id = ?)", userID).
Delete(&VoteSubmission{}).Error; err != nil {
return errors.Wrap(err, "delete vote submissions of user")
}
if err := tx.WithContext(ctx).Unscoped().
Where("vote_id IN (SELECT id FROM votes WHERE user_id = ?)", userID).
Delete(&VoteFormField{}).Error; err != nil {
return errors.Wrap(err, "delete vote form fields of user")
}
if err := tx.WithContext(ctx).Unscoped().
Where("vote_id IN (SELECT id FROM votes WHERE user_id = ?)", userID).
Delete(&VoteOption{}).Error; err != nil {
return errors.Wrap(err, "delete vote options of user")
}
if err := tx.WithContext(ctx).Unscoped().
Where("user_id = ?", userID).
Delete(&Vote{}).Error; err != nil {
return errors.Wrap(err, "delete votes of user")
}
// 提问箱访问统计:user_id 明确指向箱主,不属于站方审计/处置记录。
if err := tx.WithContext(ctx).Unscoped().
Where("user_id = ?", userID).
Delete(&BoxView{}).Error; err != nil {
return errors.Wrap(err, "delete box views of user")
}
// 拉黑关系(该用户拉黑别人,或被别人拉黑)
if err := tx.WithContext(ctx).Unscoped().
Where("user_id = ? OR blocked_id = ?", userID, userID).
+98
View File
@@ -552,6 +552,104 @@ func TestDeactivatePurgesUser(t *testing.T) {
// TestBanUserAndCleanupRemovesRow 封禁与停用的唯一差别:封禁连用户行一起物理删除,
// 该邮箱/域名被彻底释放。
func TestDeactivatePurgesAllOwnedContent(t *testing.T) {
db, _ := newTestDB(t)
ctx := context.Background()
usersStore := NewUsersStore(db)
createUser := func(email, domain string) *User {
require.Nil(t, usersStore.Create(ctx, CreateUserOptions{
Name: domain,
Password: "test-only-password",
Email: email,
Domain: domain,
}))
user, err := usersStore.GetByEmail(ctx, email)
require.Nil(t, err)
return user
}
owner := createUser("[email protected]", "purge-all")
other := createUser("[email protected]", "purge-other")
question, err := NewQuestionsStore(db).Create(ctx, CreateQuestionOptions{UserID: owner.ID, Content: "owned"})
require.Nil(t, err)
otherQuestion, err := NewQuestionsStore(db).Create(ctx, CreateQuestionOptions{UserID: other.ID, Content: "other"})
require.Nil(t, err)
images := NewUploadImagesStore(db)
ownedImage, err := images.Create(ctx, CreateUploadImageOptions{
QuestionID: question.ID, UploaderUserID: owner.ID, Key: "owned.webp",
})
require.Nil(t, err)
sharedImage, err := images.Create(ctx, CreateUploadImageOptions{
QuestionID: question.ID, UploaderUserID: owner.ID, Key: "shared.webp",
})
require.Nil(t, err)
require.Nil(t, db.WithContext(ctx).Create(&UploadImageQuestion{
UploadImageID: sharedImage.ID, QuestionID: otherQuestion.ID,
}).Error)
orphanImage := &UploadImage{UploaderUserID: owner.ID, Key: "orphan.webp"}
require.Nil(t, db.WithContext(ctx).Create(orphanImage).Error)
otherImage, err := images.Create(ctx, CreateUploadImageOptions{
QuestionID: otherQuestion.ID, UploaderUserID: other.ID, Key: "other.webp",
})
require.Nil(t, err)
conversations := NewConversationStore(db)
conversation, message, err := conversations.CreateWithFirstMessage(ctx, CreateConversationOptions{
OwnerID: owner.ID, AskerID: other.ID, MaxMessages: 20,
}, other.ID, "private")
require.Nil(t, err)
_, err = NewMessageImageStore(db).Create(ctx, CreateMessageImageOptions{
MessageID: message.ID, URL: "message.webp", FileName: "message.webp",
})
require.Nil(t, err)
vote, err := NewVotesStore(db).Create(ctx, CreateVoteOptions{
UserID: owner.ID, Title: "owned vote", Type: VoteTypeForm,
})
require.Nil(t, err)
option, err := NewVoteOptionsStore(db).Create(ctx, CreateVoteOptionOptions{
VoteID: vote.ID, Content: "option",
})
require.Nil(t, err)
_, err = NewVoteFormFieldsStore(db).Create(ctx, CreateVoteFormFieldOptions{
VoteID: vote.ID, FieldType: VoteFormFieldTypeText, Label: "field",
})
require.Nil(t, err)
_, err = NewVoteSubmissionsStore(db).Create(ctx, CreateVoteSubmissionOptions{
VoteID: vote.ID, OptionID: option.ID,
})
require.Nil(t, err)
require.Nil(t, NewBoxViewsStore(db).Record(ctx, RecordBoxViewOptions{
UserID: owner.ID, ViewedOn: time.Now(), Visitor: "visitor",
}))
require.Nil(t, usersStore.Deactivate(ctx, owner.ID))
assertCount := func(model interface{}, query string, args []interface{}, want int64) {
t.Helper()
var count int64
require.Nil(t, db.WithContext(ctx).Unscoped().Model(model).Where(query, args...).Count(&count).Error)
require.Equal(t, want, count)
}
assertCount(&MessageImage{}, "message_id = ?", []interface{}{message.ID}, 0)
assertCount(&Message{}, "conversation_id = ?", []interface{}{conversation.ID}, 0)
assertCount(&Conversation{}, "id = ?", []interface{}{conversation.ID}, 0)
assertCount(&VoteSubmission{}, "vote_id = ?", []interface{}{vote.ID}, 0)
assertCount(&VoteFormField{}, "vote_id = ?", []interface{}{vote.ID}, 0)
assertCount(&VoteOption{}, "vote_id = ?", []interface{}{vote.ID}, 0)
assertCount(&Vote{}, "id = ?", []interface{}{vote.ID}, 0)
assertCount(&UploadImage{}, "id = ?", []interface{}{ownedImage.ID}, 0)
assertCount(&UploadImage{}, "id = ?", []interface{}{orphanImage.ID}, 0)
assertCount(&UploadImage{}, "id = ?", []interface{}{sharedImage.ID}, 1)
assertCount(&UploadImage{}, "id = ?", []interface{}{otherImage.ID}, 1)
assertCount(&UploadImageQuestion{}, "question_id = ?", []interface{}{question.ID}, 0)
assertCount(&UploadImageQuestion{}, "upload_image_id = ? AND question_id = ?", []interface{}{sharedImage.ID, otherQuestion.ID}, 1)
assertCount(&BoxView{}, "user_id = ?", []interface{}{owner.ID}, 0)
assertCount(&Question{}, "id = ?", []interface{}{otherQuestion.ID}, 1)
}
func TestBanUserAndCleanupRemovesRow(t *testing.T) {
db, cleanup := newTestDB(t)
defer func() { _ = cleanup("users", "questions", "feature_controls") }()
+28 -4
View File
@@ -10,6 +10,7 @@ import (
"github.com/flamego/flamego"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/propagation"
semconv "go.opentelemetry.io/otel/semconv/v1.12.0"
oteltrace "go.opentelemetry.io/otel/trace"
@@ -31,16 +32,39 @@ func Middleware(service string, opts ...Option) flamego.Handler {
c.Request().Request = c.Request().Request.WithContext(savedCtx)
}()
request := c.Request().Request
ctx := cfg.Propagators.Extract(savedCtx, propagation.HeaderCarrier(c.Request().Header))
method := request.Method
if method == "" {
method = http.MethodGet
}
httpAttrs := []attribute.KeyValue{
semconv.HTTPMethodKey.String(method),
semconv.HTTPTargetKey.String(request.URL.Path),
}
if service != "" {
httpAttrs = append(httpAttrs, semconv.HTTPServerNameKey.String(service))
}
if request.TLS != nil {
httpAttrs = append(httpAttrs, semconv.HTTPSchemeHTTPS)
} else {
httpAttrs = append(httpAttrs, semconv.HTTPSchemeHTTP)
}
switch request.ProtoMajor {
case 1:
httpAttrs = append(httpAttrs, semconv.HTTPFlavorKey.String(fmt.Sprintf("1.%d", request.ProtoMinor)))
case 2:
httpAttrs = append(httpAttrs, semconv.HTTPFlavorHTTP20)
}
opts := []oteltrace.SpanStartOption{
oteltrace.WithAttributes(semconv.NetAttributesFromHTTPRequest("tcp", c.Request().Request)...),
oteltrace.WithAttributes(semconv.EndUserAttributesFromHTTPRequest(c.Request().Request)...),
oteltrace.WithAttributes(semconv.HTTPServerAttributesFromHTTPRequest(service, "", c.Request().Request)...),
oteltrace.WithAttributes(semconv.NetAttributesFromHTTPRequest("tcp", request)...),
oteltrace.WithAttributes(semconv.EndUserAttributesFromHTTPRequest(request)...),
oteltrace.WithAttributes(httpAttrs...),
oteltrace.WithSpanKind(oteltrace.SpanKindServer),
}
// span 名只取 URL.Path,不含 query string:
// RequestURI 会把 ?t=<问题token>、找回密码 ?code= 等私密参数原样写入 trace。
spanName := c.Request().URL.Path
spanName := request.URL.Path
if spanName == "" {
spanName = fmt.Sprintf("HTTP %s route not found", c.Request().Method)
}
+25 -11
View File
@@ -656,6 +656,10 @@ func VoteCreate(ctx context.Context) {
editURL := "/user/votes/" + fmt.Sprint(vote.ID) + "/edit"
if action == "publish" {
if !canPublishVote(ctx) {
ctx.Redirect(editURL)
return
}
if err := validateForPublish(ctx, vote); err != nil {
ctx.SetErrorFlash(err.Error())
ctx.Redirect(editURL)
@@ -875,6 +879,10 @@ func VoteUpdate(ctx context.Context) {
// action=publish:已保存为草稿,再执行发布校验;失败则保留草稿并停留在编辑页
if action == "publish" {
if !canPublishVote(ctx) {
ctx.Redirect(editURL)
return
}
// 用刚保存的内容刷新 vote 结构,供审核与结构校验读取最新标题/类型
vote.Title = form.opts.Title
vote.Description = form.opts.Description
@@ -900,6 +908,22 @@ func VoteUpdate(ctx context.Context) {
ctx.Redirect(editURL + "?saved=1")
}
// canPublishVote 统一投票发布的站方处置检查:L2 起不得发布新的公开内容。
// 查询失败时 fail-closed;错误提示在此统一设置,重定向目标由各发布入口保持原语义。
func canPublishVote(ctx context.Context) bool {
level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), ctx.User.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query user restriction")
ctx.SetInternalErrorFlash()
return false
}
if db.RestrictionAtLeast(level, db.RestrictionL2) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "vote.err.restrictedPublish"))
return false
}
return true
}
// VotePublish 发布投票(draft → published)。草稿阶段跳过的结构校验与 fail-closed
// 内容审核在此统一补齐,确保对外公开内容仍受管控。
func VotePublish(ctx context.Context) {
@@ -915,17 +939,7 @@ func VotePublish(ctx context.Context) {
return
}
// 站方处置:L2 起不得再发布新的公开内容(与「设公开」同口径)。
// 此前投票链路完全没有处置校验:被处置的箱主仍能发新投票并进探索页曝露。
level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), ctx.User.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query user restriction")
ctx.SetInternalErrorFlash()
ctx.Redirect("/user/questions#votes")
return
}
if db.RestrictionAtLeast(level, db.RestrictionL2) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "vote.err.restrictedPublish"))
if !canPublishVote(ctx) {
ctx.Redirect("/user/questions#votes")
return
}