安全: 修复凭据、遥测与账号数据清理风险
This commit is contained in:
@@ -1,421 +0,0 @@
|
||||
# 2026-09-15 工作日志
|
||||
|
||||
## 落地 P2 推荐批次 #1–3(审核链路一致性 / 文档)
|
||||
|
||||
承接 09-11 的全库审阅(无 P0/P1,8 项 P2)。上次征询修复批次未获选择,本次按「继续 +
|
||||
我标注的建议先做」自主推进**最低风险的一致性/文档类 #1–3**,未动 #4–8(需另行确认)。
|
||||
|
||||
### 已改(`internal/security/censor/`)
|
||||
|
||||
1. **#1 缓存/送审归一化统一**(censor.go:66-68,76,122,131)
|
||||
- 原先:缓存按**原文** `text` 查 `GetByText`,实际送审 `RemoveTrustedURL(text)`,落库 `Input:text`。
|
||||
→ 审计记录与实际送审内容不符;归一化后相同的原文会各占一条缓存、重复调 API。
|
||||
- 现在:循环外算一次 `normalized := RemoveTrustedURL(text)`,缓存查找 / 送审 / `Input` 三者统一。
|
||||
- 安全性论证:归一化是纯函数确定性映射;`TrustedHosts` 增减只会让旧条目 miss(重新送审),
|
||||
不会让「已通过」的旧缓存被错配到新文本上 → 无漏放风险。
|
||||
2. **#2 阿里云缓存回放分支与七牛对齐**(censor.go:99-110)
|
||||
- 原先:aliyun 命中缓存**不看 Pass 直接 return**,依赖「aliyun 恒为链尾」这一隐式前提。
|
||||
- 现在:与 qiniu 一致 —— 仅 `Pass` 才 return,否则 append + continue 交给下一通道复核。
|
||||
- 等价性:aliyun 现为链尾,continue 后循环结束仍由 `responses[len-1]` 返回**同一份响应**,行为不变;
|
||||
但消除了顺序耦合,将来若前置 aliyun 也不会短路后续通道。
|
||||
3. **#3 修正失真注释**(text.go:66)
|
||||
- 原注释称 `Details`「回放缓存结果时为 nil」**是错的**:
|
||||
`AliyunTextCensorParser`(aliyun.go:433-461) 与 `QiniuTextCensorParser`(qiniu.go:133-170)
|
||||
都会从 `RawResponse` 重建 `Details` → 缓存回放同样受严格度策略约束,改档位对历史缓存也生效。
|
||||
- 已改为准确描述。**结论:审核缓存回放无 fail-open**(09-11 的怀疑已排除)。
|
||||
|
||||
### 验证
|
||||
|
||||
- `gofmt_keep_crlf.py` 两个文件 exit=0,CRLF 保留(censor 147 / text 141,loneLF=0)。
|
||||
- `go build ./internal/security/censor/...` **EXIT=0**(定向构建,~秒级;用 goproxy.cn + overlay)。
|
||||
- `go test ./internal/security/censor/` **ok 2.467s** —— 确认严格度策略与回放行为未被破坏。
|
||||
|
||||
### 顺带已完成 #6
|
||||
|
||||
- **#6 `User.Email` 改 `json:"-"`**(users.go:89)。
|
||||
查证:`route/` 与 `internal/` 中**无任何非测试代码读取序列化后的 `.Email`**(模板里的 email 均为表单字段名 /
|
||||
邮件模板,属 Go template 服务端渲染,不受 json tag 影响;输入走 `CreateUserOptions`/`UpdateUserOptions`,
|
||||
不经 `User` 模型反序列化)→ 无消费方受损。
|
||||
验证:gofmt exit=0,`go build ./internal/db/` **EXIT=0**。
|
||||
|
||||
### 待确认(P2 #4/#5/#7/#8)
|
||||
|
||||
| # | 项 | 我的建议 |
|
||||
|---|---|---|
|
||||
| 4 | 投票配额「插入后计数」非原子(route/vote/vote.go:814-822) | 暂缓:需 DB 层条件插入,超额有界且自动结束自愈 |
|
||||
| 5 | ~~profile Name/Intro/link 无长度校验~~ | **误报,已关闭**:表单层已有校验,见下方更正 |
|
||||
| 7 | `conf.Database.Schema` 拼 CREATE SEQUENCE(db.go:215-222) | 可选:配置来源非远端输入,纵深防御 |
|
||||
| 8 | `RemoveTrustedURL` 24 次整串重建(text.go:114-141) | 可跳过:短文本开销可忽略(#1 已顺带把调用从循环内降到 1 次) |
|
||||
|
||||
#### ✅ #5 更正:是**误报**,已按「报错式校验」实现后**全部回滚**
|
||||
|
||||
- 先按用户选择实现了一版(Name 50 / Intro 500 / link 500 rune,4 语 i18n 词条),随后核对
|
||||
`internal/form/user.go` 时发现**表单层早已有校验**:
|
||||
- `Name` → `valid:"required;maxlen:20"`
|
||||
- `Intro` → `valid:"required;maxlen:100"`
|
||||
- 各 link → `valid:"maxlen:30"`
|
||||
- 且确实生效:`internal/form/form.go:16` 引入 `govalid`,`:131` 在 `Bind()` 中 `govalid.Check()`;
|
||||
handler 顶部 `ctx.HasError()` 承接;报错文案走标签 `msg:` 对应的 i18n 键
|
||||
(`form.profile.name`="不超过 20 个字符"、`form.profile.intro`=100、`form.profile.linkMax`=30)。
|
||||
- 结论:我那版是**永不触发的死代码**(超限在 Bind 阶段就被拒),且上限与真实值冲突 → 已完整回滚
|
||||
(profile.go 的 utf8 导入 / 常量 / 校验块 + 4 份字典词条),`git status` 确认 5 个文件零 diff。
|
||||
- 教训已写入 MEMORY.md「表单校验(勿重复造轮子)」:查某字段有无校验,**先看 `internal/form/*.go` 的 struct 标签**。
|
||||
- 附带修正:Postgres 下 `string` 映射为 `text`(无 DB 层长度上限)这一点本身**没错**,
|
||||
但不构成漏洞 —— 因为表单层已经挡住了。
|
||||
|
||||
### 环境
|
||||
|
||||
- ~~重包 `./route/user/ ./internal/route/ ./internal/db/` 仍会 30 分钟超时 → 判定为 1C1G 沙箱编译整图慢~~
|
||||
**❌ 该结论已被推翻,见文末「编译卡死」误判更正。全库 `go build ./...` 实测 EXIT=0。**
|
||||
|
||||
---
|
||||
|
||||
## 「编译卡死」误判更正(用户指出:1C1G 是正式环境,不是沙箱)
|
||||
|
||||
用户明确纠正:**1C1G 是正式生产环境**,我不该拿「沙箱慢」当挡箭牌解释构建超时。重新取证后确认:
|
||||
**根本没有编译问题,是我把代理挂起误判成了编译慢。**
|
||||
|
||||
### 取证过程
|
||||
|
||||
| 步骤 | 命令 | 结果 | 结论 |
|
||||
|---|---|---|---|
|
||||
| 隔离模块解析 | `go list -deps ./route/user/` | **2.3s EXIT=0** | 模块全在本地缓存,网络不是瓶颈 |
|
||||
| 编译目标包 | `go build -overlay=... ./route/user/` | **7s EXIT=0** | 之前 240s 超时是伪命题 |
|
||||
| 关键包逐个 | route/ internal/route/ question/ auth/ security/... db/ mail/ | **全 OK** | |
|
||||
| 全量 | `go build -overlay=... ./...` | **EXIT=0** | |
|
||||
| 静态检查 | `go vet -overlay=...` 7 个包 | **EXIT=0** | |
|
||||
| 测试 | censor/autoban/ipinfo/route/conf/form | **全部 ok**,EXIT=0 | |
|
||||
|
||||
**真因**:前几次超时是 `GOPROXY` 未导出 → 卡在 `proxy.golang.org` 连接超时(HTTP=000)。
|
||||
与机器性能、与 1C1G 都无关。**只要每条 go 命令前 `export GOPROXY=https://goproxy.cn,direct
|
||||
GOSUMDB=off GOFLAGS=-mod=mod` 就秒级完成。**
|
||||
|
||||
⚠️ 方法论教训:**超时 ≠ 慢**。要用 `go list -deps`(纯元数据、不编译)先把「模块解析」和
|
||||
「编译」两层拆开,否则会把网络挂起误判成性能问题,进而拿「环境」当借口。
|
||||
|
||||
---
|
||||
|
||||
## 复核:追问邮件 token + 其他严重错误
|
||||
|
||||
### 1. 追问邮件确实没带 token(用户怀疑成立)
|
||||
|
||||
- `internal/mail/mail.go:58-65` `SendNewFollowUpMail` 链接 `%s/_/%s/%d`,**无 `?t=`**。
|
||||
- 收件人是**箱主** `pageUser.Email`(follow_up.go:131),箱主靠登录态查看 → **不是权限漏洞**。
|
||||
- 但有两处真实问题:
|
||||
1. **不一致**:同样发给箱主的 `SendNewQuestionMail`(mail.go:25)**带** `?t=`。
|
||||
2. **具体失效场景**:若该提问被箱主设为私密(`IsPrivate=true`),箱主在**未登录设备**点邮件链接
|
||||
→ `Questioner` 中间件(question.go:61-66)判定 `IsPrivate && !IsLogged && token 不匹配`
|
||||
→ **302 回首页,看不到追问**。新提问邮件因带 token 则不会。
|
||||
- 建议:给 `SendNewFollowUpMail` 补 token 参数,与 `SendNewQuestionMail` 对齐。
|
||||
- 关键的那封(**发给匿名提问者的回答通知**)`question.ReceiveReplyEmail` 是**带 token 的**:
|
||||
follow_up.go:185、question.go:214 → `SendNewAnswerMail(..., question.Token)` ✓ 无问题。
|
||||
|
||||
### 2. 新发现 P2:提问 token 仅 6 位且爆破无限流
|
||||
|
||||
- `internal/db/questions.go:95` `Token: randstr.String(6)`;`randstr` 用 `crypto/rand` +
|
||||
base62 → 62⁶ ≈ 5.68×10¹⁰,**仅 35.7 bit**(随机源没问题,是长度短)。
|
||||
- `GET /_/{domain}/{questionID}?t=` **无 rateLimitMiddleware**;猜错由 `Questioner` 返回
|
||||
**302 → /**,而 `autoban` 只统计「敏感路径」与「404」(404 默认关闭)→ **完全不计入封禁**。
|
||||
- token 仅在「未回答 或 私密」且非箱主时才需要,攻破即=读私密/未公开提问。
|
||||
- 可行性:单条定向 ~2.8×10¹⁰ 次不可行;但 questionID 可枚举、只需命中**任意一条**,
|
||||
受保护提问 N 条时期望 5.68×10¹⁰/N 次 → 量大时理论可行。
|
||||
- 低成本修复:`randstr.String(6)` → `randstr.String(12)`(≈71 bit)。存量 6 位 token 不受影响
|
||||
(列是 string,无长度校验)。或对该 GET 路径加限流。
|
||||
|
||||
### 3. 已复核无问题(勿再重复查)
|
||||
|
||||
- **密码**(`internal/db/password.go`):bcrypt cost 10;旧 HMAC-SHA1 用 `subtle.ConstantTimeCompare`
|
||||
常量时间比较并透明升级;哈希失败**不降级**为弱哈希(返回空串=锁定);bcrypt 72 字节截断 ✓
|
||||
- **越权**:`Delete/SetPrivate/SetPublic/TogglePin/SetRead/SetUnread` 全部由 `canDelete`
|
||||
(question.go:72 `ctx.IsLogged && ctx.User.ID == pageUser.ID`)把关;
|
||||
`AnswerFollowUp`(follow_up.go:143) 与 `PublishAnswer`(question.go:154,161) 双重校验 ✓
|
||||
(`ctx.Redirect` 会写响应从而终止后续 handler,早退有效)
|
||||
- **CSRF**:全局挂载,SessionKey=uid 正确,有回归测试 ✓
|
||||
- **会话**:登录 RegenerateID、pwd_ver 失效、HTTPOnly + SameSite=Lax ✓
|
||||
- **密码找回**:验证码 24h TTL + 用后即删 ✓
|
||||
- **SQL 注入**:全参数化 ✓
|
||||
- **token/邮箱泄露**:`Question.Token` 与 `User.Email` 均为 `json:"-"` ✓
|
||||
- **审核**:提问/回答/追问/投票/私聊统一走 `moderation.Check` ✓
|
||||
|
||||
---
|
||||
|
||||
## 落地 A+B(追问邮件补 token + 提问 token 6→12)
|
||||
|
||||
按「继续未完成的任务」指示,执行上一条报告的 A+B 两项变更。已用 gofmt_keep_crlf 保 CRLF。
|
||||
|
||||
### A. 追问邮件(发给箱主)补 token,与 `SendNewQuestionMail` 对齐
|
||||
|
||||
- `internal/mail/mail.go:58` `SendNewFollowUpMail` 签名加 `token string`,链接改为 `%s/_/%s/%d?t=%s`。
|
||||
- `route/question/follow_up.go:131` 调用处补传 `question.Token`。
|
||||
- 目的:箱主在**未登录设备**点开**私密**提问的追问邮件时也能凭 token 查看(否则被 Questioner 重定向首页)。
|
||||
- 仅一个调用方,构建已验证签名一致。
|
||||
|
||||
### B. 提问 token 长度 6 → 12
|
||||
|
||||
- `internal/db/questions.go:95` `randstr.String(6)` → `randstr.String(12)`(≈35.7→71 bit)。
|
||||
- 存量 6 位 token 不受影响(列是 string,无长度校验,旧 token 仍有效)。
|
||||
|
||||
### 验证
|
||||
|
||||
- `go build ./route/question/ ./internal/mail/ ./internal/db/` → EXIT=0
|
||||
- `go build ./...`(overlay)→ EXIT=0
|
||||
- `go vet` 三包 → EXIT=0
|
||||
- `internal/db` 单元测试 FAIL 为已知环境限制(需真实 PostgreSQL/MySQL,报 `Unknown database type: ""`),
|
||||
与本次改动无关,可跳过;security/autoban/censor/ipinfo、route 测试均 ok。
|
||||
|
||||
### 待定(未做,按需)
|
||||
|
||||
- CHANGELOG.json 是否补一条 entry(未做:需确认版本号与条目格式)。
|
||||
|
||||
---
|
||||
|
||||
## 用本地 Postgres 跑通 db 测试并修真实缺陷(用户提供 127.0.0.1:5432)
|
||||
|
||||
### 测试库环境变量(`internal/dbutil/testing.go:NewTestDB`)
|
||||
- `DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=<pwd> DB_HOST=127.0.0.1 DB_PORT=5432`
|
||||
DSN = `postgres://$DB_USER:$DB_PASSWORD@$DB_HOST:$DB_PORT?sslmode=disable`(不带库名,连上后
|
||||
自动 `CREATE DATABASE tamabox-test-<rand>`,测试结束(未失败时)DROP)。
|
||||
- 仍需 `GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod` + webp overlay。
|
||||
|
||||
### 修的真实缺陷(生产代码,2 处)
|
||||
|
||||
- `internal/db/questions.go:DeleteByID`:级联删追问/上传图 + 回退提问配额时**直接调包级全局单例**
|
||||
`FollowUps`/`UploadImages`/`FeatureControls`。这些单例只在 `db.Init` 装配 → 测试/部分初始化场景 nil → **panic**。
|
||||
改为 `NewFollowUpsStore(db.DB)` / `NewUploadImagesStore(db.DB)` / `NewFeatureControlsStore(db.DB)`,
|
||||
用自身 store 的 `*gorm.DB`。生产行为不变(`Init` 里所有 store 共用同一 `*gorm.DB`),但去掉了隐藏全局依赖。
|
||||
- `internal/db/conversation.go:MarkDeletedByOwner`:同类问题(`FeatureControls.IncrementQuota`),同样修。
|
||||
- 同包 `feature_control.go:355` 的 `FeatureControls.Get` **已有** `if FeatureControls == nil` 守卫,故未动。
|
||||
|
||||
### 修的测试缺陷(全在 internal/db,非生产 bug)
|
||||
|
||||
1. `users_test.go`:5 处 `want.EncodePassword(); require.Equal(t, want, got)` —— bcrypt 随机盐,
|
||||
哈希**永不相等**。新建 helper `requireUserEqual`:先 `got.Authenticate(明文)` 校验密码,再对齐
|
||||
`Password` 与 `ChatMaxMessages`(= `conf.Limits.ChatMaxMessages`,默认 200)后比较其余字段。
|
||||
2. `testUsersDeactivate`:复用已被 `testUsersCreate` 占用的 `[email protected]`/`e99` → 改唯一值。
|
||||
3. `users_test.go`/`upload_images_test.go` 去掉顶层 `t.Parallel()`:子测试共用一张表、cleanup 会
|
||||
`TRUNCATE ... RESTART IDENTITY`,并行时多个子测试拿到相同 id/邮箱 → 撞唯一键。
|
||||
4. `upload_images_test.go`:cleanup 只 truncate `upload_images`,而链接表 `upload_image_questions`
|
||||
**无 FK 级联**(复合主键 `upload_image_id+question_id`),残留行让下一子测试撞 `_pkey`
|
||||
→ 补 `cleanup("upload_images", "upload_image_questions")`。
|
||||
5. `UpdateBlockWords/normal`:期望 `HarassmentSettingNone`("none")但新用户实际 `""`
|
||||
(模型字段无 `gorm:"default:"` 标签)。`""` 与 `"none"` 生产行为等价(只有 `==register_only` 才限制;
|
||||
模板 profile.html:851 也只在 `register_only` 时勾选)→ **非生产 bug**。改成先显式
|
||||
`UpdateHarassmentType(RegisterOnly)` 再断言未被误改(更强的测试,且不把 `""` 奉为期望)。
|
||||
|
||||
### 验证
|
||||
|
||||
- `go build ./...` **EXIT=0**、`go vet ./...` **EXIT=0**
|
||||
- `go test ./...`(含 DB)**全绿 EXIT=0**:internal/db 57s、route/vote、internal/route、templates、
|
||||
i18n、conf、form、cache、imagecheck、mail、shared、template、security/* 均 ok。
|
||||
- 行尾:`internal/db/*` 与 `internal/mail/mail.go` 原本就是 LF,`route/question/*`、`route/vote/vote.go`、
|
||||
`internal/route/route.go` 是 CRLF —— **仓库本来就是混合行尾**;本次编辑未改变任何文件原行尾(无 diff 污染)。
|
||||
|
||||
### 结论
|
||||
|
||||
- 除上述「全局单例 nil 依赖」(健壮性隐患:测试与部分初始化场景必崩)外,**未发现新的生产逻辑 bug**。
|
||||
- db 测试套件此前的一堆失败**全部是测试自身缺陷**:bcrypt 直接比哈希、测试数据撞车、并行共享表、漏清链接表。
|
||||
|
||||
---
|
||||
|
||||
## 修复 HarassmentSetting 潜在隐患(schema 默认值 + 启动期归一)
|
||||
|
||||
用户确认「影响不大就做」。改动面很小(3 处源码 + 1 处测试期望 + 1 个新回归测试)。
|
||||
|
||||
### 线索
|
||||
- 模型 `HarassmentSetting HarassmentSettingType` **无 `gorm:"default:"` 标签** → 新用户该列为空串;
|
||||
而规范取值是 none / register_only。全仓库**没有任何代码比较 `== HarassmentSettingNone`**
|
||||
(production 只有 `== register_only`,模板 profile.html:851 也只看 register_only)→ **运行期无 bug**,
|
||||
但将来若有人写 `== None` 判断就会漏判。
|
||||
|
||||
### 改动
|
||||
1. `internal/db/users.go` 模型:`HarassmentSetting` 补 `gorm:"default:'none'"`。
|
||||
⚠️ **字符串默认值必须带单引号**(`default:'none'`),参考既有 `ModerationStrictness` 的 `default:''`;
|
||||
裸写 `default:none` 会生成非法 SQL。
|
||||
2. `internal/db/users.go:Create`:显式 `HarassmentSetting: HarassmentSettingNone`
|
||||
(确定写库,不依赖 DB 默认值回读)。
|
||||
3. `internal/db/users.go`:新增 `normalizeHarassmentSetting(db)`,把历史空值(''/NULL)归一为 none,幂等。
|
||||
4. `internal/db/db.go:Init`:在 `normalizeChatQuota` 之后调用,失败仅告警不阻断启动
|
||||
(与 `backfillFeatureControls`/`reconcileLegacyChatEnabled` 等既有惯例一致)。
|
||||
|
||||
### 测试
|
||||
- `users_test.go` helper `requireUserEqual` 补 `want.HarassmentSetting = HarassmentSettingNone`。
|
||||
- 新增 `TestHarassmentSettingNormalize`:新建用户即 none + 历史空值经归一后为 none。
|
||||
|
||||
### 实测证据
|
||||
- 临时探针查 `information_schema`:`harassment_setting` 的 `column_default = "'none'::text"` ✅
|
||||
(AutoMigrate 确实把列默认值改成了 none;探针文件跑完即删)。
|
||||
- `go build ./...` / `go vet ./...` EXIT=0;`go test ./...`(含 DB)**全绿**;gofmt 无 diff;
|
||||
行尾未变(users.go CRLF、db.go/users_test.go LF)。
|
||||
|
||||
### ⚠️ 新踩到的 gofmt 陷阱
|
||||
在**文档注释**里写 `''` 会被 gofmt 重排成 `”`(Go 1.19+ 的 doc comment 规范化:`` `` ``→`“`、`''`→`”`),
|
||||
导致 `gofmt -l` 反复报未格式化。**注释里避免使用 `''`**(改写为「空字符串」等文字)。
|
||||
|
||||
---
|
||||
|
||||
## 账号停用体验修复:独立成功页(/deactivated)
|
||||
|
||||
### 问题
|
||||
停用账号后 `DeactivateProfileAction` 把用户重定向到 `/login`,只在登录页顶部弹一条 flash。
|
||||
用户会觉得「被丢回登录页」,下意识以为操作没生效。
|
||||
|
||||
诊断结论:flash **确实渲染**(Flush 清 session 数据但保留 session,flash 能带到下一页),
|
||||
真正的体验错配是「停用后落点仍是登录页」——而账号已删/停,用户根本不可能再登录。
|
||||
|
||||
### 方案(用户选「独立成功页(推荐)」)
|
||||
1. `route/user/profile.go`
|
||||
- 新增公开 handler `DeactivatedProfile`:`ctx.SetTitle(i18n.T(ctx.Lang(), "deactivated.title"))` + `ctx.Success("user/deactivated")`。
|
||||
- `DeactivateProfileAction` 末尾 `ctx.Redirect("/login")` → `ctx.Redirect("/deactivated")`,**并移除原来的 `SetSuccessFlash`**(页面自身即提示)。
|
||||
- 顺手修正 `UpdateProfile` 里一处陈旧注释(原写「顺序与 DeactivateProfileAction 保持一致…随后跳登录页」,停用路径已不再如此)。
|
||||
2. `internal/route/route.go`:在公开区(`/explore` 旁)加 `f.Get("/deactivated", user.DeactivatedProfile)`。
|
||||
**必须是公开路由**:停用后会话已 Flush、`reqUserSignIn` 会把人弹回登录页,正是要避免的现象。
|
||||
3. 新建 `templates/user/deactivated.html`:复用 `question/success.html` 的范式(大 ✓ 图标 + 卡片 + 单按钮返回首页)。
|
||||
`templates/embed.go` 是整目录 `//go:embed ... user ...`,新模板**自动被嵌入**,无需改 embed 指令。
|
||||
4. i18n:四份字典都加 `deactivated.title` / `deactivated.desc` / `deactivated.backHome`
|
||||
(`TestDictionaryComplete` 要求 zh_cn/zh_tw/en/ja **键集合完全一致**,漏一份必挂)。
|
||||
同时**删除已无引用的 `admin.flash.deactivated`**(4 份字典同步删;删它不影响对齐组,因为该组最长键是 `admin.flash.blockWordsTooMany`)。
|
||||
|
||||
### 验证
|
||||
- `go build ./...` / `go vet ./...` EXIT=0;`go test ./...`(含本地 PG)**全绿**,
|
||||
其中 `internal/i18n`(键一致性)、`internal/route`、`templates`、`internal/template`(模板解析)均 ok → 新模板可被解析、键齐备。
|
||||
- gofmt 无 diff;行尾未变(4 份 dict + route.go 保持 CRLF,`route/user/profile.go` 实为 **LF**)。
|
||||
|
||||
### 备注
|
||||
- `route/user/profile.go` 的行尾是 **LF**(与「route/** 皆 CRLF」的旧印象不符,按文件单独确认即可)。
|
||||
|
||||
---
|
||||
|
||||
## 🔴 P0 修复:账号停用后同一邮箱/域名永远无法重新注册
|
||||
|
||||
用户在测试环境 `https://box-dev-api.337845818.best` 注册 `1223383917@qq.com` / `tamakyi` 报 500:
|
||||
```
|
||||
SELECT ... WHERE email = '...' AND deleted_at IS NULL -> rows:0
|
||||
INSERT INTO users ... -> ERROR: duplicate key value violates unique constraint "idx_users_email_unique" (SQLSTATE 23505)
|
||||
ERRO Failed to create new user error="create user: ERROR: duplicate key ..."
|
||||
```
|
||||
|
||||
### 根因(本地已实测复现,日志形态与线上一致)
|
||||
`internal/db/users.go` 的 `Deactivate` 用的是 **GORM 默认的软删除**:
|
||||
`db.WithContext(ctx).Model(&User{}).Delete("id = ?", u.ID)` → 只写 `deleted_at`,**行仍在库里**。
|
||||
而 `User` 的 `email` / `domain` 是 `uniqueIndex`(**索引不含 deleted_at**),于是:
|
||||
- 查重 `validate()` 走 gorm 软删除作用域 → 看不见这行 → 放行;
|
||||
- INSERT 被数据库唯一索引拦下 → `Create` 的 TOCTOU 兜底再查一次仍看不见 → 抛底层 23505 → handler `default` 分支 → 500。
|
||||
|
||||
复现证据(临时测试,已删):停用后 `Unscoped` 查该邮箱 → 行仍在且 `deleted_at.Valid=true`;同邮箱重新注册 → 与线上同样的 23505。
|
||||
**旁证**:`internal/cmd/ban.go:64` 早有注释「用 Unscoped 查找,支持封禁已软删除(自助停用)的账号」——
|
||||
说明「自助停用会留软删除行」是既有事实,但没人把「唯一索引占位」这层后果接上。
|
||||
同类先例:`internal/db/vote_submissions.go:234` 已因同一原因改用 Unscoped 物理删除。
|
||||
|
||||
顺带发现:`BanUserAndCleanup` 的「连带清理」也只是软删除子表(Question/FollowUp/Conversation/Message… 都嵌 `dbutil.Model`),
|
||||
数据实际仍在库;也与停用页「所有数据将被删除」的承诺不符。
|
||||
|
||||
### 改动(`internal/db/users.go`)
|
||||
1. `Deactivate` → 调 `db.purgeUser(ctx, u.ID)`;`BanUserAndCleanup` 同样委托给它。
|
||||
2. 抽私有 `purgeUser`:级联删除**全部改为 `Unscoped()` 物理删除**(追问/消息/消息图片/会话/提问-图片关联/提问/拉黑/功能开关/用户行)。
|
||||
3. `Create` 兜底加固:唯一键冲突且 `validate` 仍查不到时,用新增的 `duplicateFieldErr()`(Unscoped)判定是 email 还是 domain 被占,
|
||||
返回 `ErrDuplicateEmail`/`ErrDuplicateDomain` 友好提示,而不是把 23505 抛成 500。(`isDuplicateKeyError` 复用 vote_submissions.go 里的)
|
||||
4. 回归测试 `TestDeactivatePurgesUser`:停用后 Unscoped 也查不到用户行、提问被清理、同一邮箱可重新注册。
|
||||
|
||||
### ⚠️ 过程中自己踩的 GORM 大坑
|
||||
把级联删除改成「先 `tx = tx.WithContext(ctx).Unscoped()` 再逐条 `tx.Where(...)`」——
|
||||
**事务句柄 clone==0,复用同一个句柄会让上一条语句的 `Where` 条件串到下一条上**,
|
||||
SQL 变成 `DELETE FROM follow_ups WHERE <follow_ups条件> AND <messages条件>` → `字段 conversation_id 不存在 (42703)`。
|
||||
✅ 正确写法:**每条语句都重新取句柄** `tx.WithContext(ctx).Unscoped().Where(...)`(原代码本来就是每条 `tx.WithContext(ctx)`,是我改坏的)。
|
||||
这条已写进 MEMORY.md。
|
||||
|
||||
### 验证
|
||||
- `go build ./...` / `go vet ./...` EXIT=0;`go test ./...`(含本地 PG)**全绿**;gofmt clean;行尾未变(users.go CRLF、users_test.go LF)。
|
||||
- 测试环境实测(脚本 `dev/devcheck-e2e.py`):
|
||||
- ✅ 注册 / 登录 / `/user/profile` / `/user/questions` / `/user/votes`(302→`/user/questions#votes`) / 提问(返回提问成功页)/ 新建投票页 / 停用页 / **停用后跳 `/deactivated` 成功页** 全部正常;
|
||||
- ✅ `/deactivated` 四语言标题渲染正确(账号已停用 / 帳號已停用 / Account deactivated / アカウントを停止しました);
|
||||
- ❌ **停用后同邮箱重新注册仍 500** —— 测试环境跑的是**旧构建**(`/deactivated` 已在,但没有 purgeUser 修复),需重新部署。
|
||||
- ⚠️ 测试环境 DB 里已留下本次测试产生的孤儿行,需清理:
|
||||
`tbe2e0915122816@example.com`(仍在线)、`tbe2e0915122934@example.com`、`tbcheck0915122657@example.com`(均已停用=软删除占位);
|
||||
用户自己的 `tamakyi` / `1223383917@qq.com` 同样是软删除占位(这就是他报错的直接原因)。
|
||||
清理方式:`tamabox ban --email <邮箱> --yes`(CLI 支持查软删除账号),或直接 SQL 删 `users.deleted_at IS NOT NULL` 的行。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 方案变更(覆盖上一节):停用改为「保留身份占位 + 清空内容」
|
||||
|
||||
用户表示**尚未部署**,并提出:「曾经注册过的账户停用后,再次注册时应提示『该邮箱已被占用但账户已停用,如需重新使用请联系站长』」。
|
||||
经询问确认两点:
|
||||
1. 停用形态 = **保留身份占位**(用户行软删除)+ **物理删除内容数据**(停用页「所有数据将被删除」的承诺仍然成立);
|
||||
2. 「联系站长」**复用页脚已有的「找站长」入口**(`site.footer.admin` → `https://box.shiroko.one/_/tamakyi`),不新增站长邮箱配置项。
|
||||
|
||||
→ **上面「Deactivate 改成物理删除」的做法已被取代**,但其中「级联内容必须 Unscoped 物理删除」的结论仍然有效。
|
||||
|
||||
### 最终实现
|
||||
- `internal/db/users.go`
|
||||
- `purgeUserContent(ctx, tx, userID)`(包级函数):把内容级联删除抽出来,全部 `Unscoped()` 物理删除。
|
||||
- `Deactivate` = `purgeUserContent` + **软删除用户行**(保留 email/domain 占位)。
|
||||
- `BanUserAndCleanup` = `purgeUserContent` + **物理删除用户行**(唯一差别;彻底释放邮箱/域名)。
|
||||
- `validate` 重写为两轮:先查活跃账号(ErrDuplicateEmail/Domain),再 **Unscoped** 查已停用占位行
|
||||
→ 新增 `ErrDeactivatedEmail` / `ErrDeactivatedDomain`;辅助方法 `fieldTaken(ctx, column, value, includeDeleted)`。
|
||||
- 新增 `Reactivate(ctx, id)`(清 `deleted_at`,未停用时返回 ErrUserNotExists),已加进 `UsersStore` 接口。
|
||||
- 上一轮加的 `duplicateFieldErr` 已删除:`validate` 现在本身就覆盖了软删除行,无需在 Create 兜底里重复判定。
|
||||
- `route/auth/register.go`:把两个新错误映射到 `auth.errDeactivatedEmail` / `auth.errDeactivatedDomain`。
|
||||
- i18n ×4:新增 `auth.errDeactivatedEmail`、`auth.errDeactivatedDomain`、`deactivate.note`(键集合保持一致)。
|
||||
- `templates/user/deactivate.html`:desc 下方加一行 `deactivate.note`(uk-text-small uk-text-muted),
|
||||
提前告知「邮箱/域名会作为身份占位保留,无法用同一邮箱重新注册,如需恢复请找站长」。
|
||||
- **新增 CLI `internal/cmd/restore.go`(`TamaBox restore`)**:`--email|--domain [--password] [--yes]`,
|
||||
清掉 deleted_at 完成恢复;`--password` 会重置密码(必须先恢复再改密码——`UpdatePassword` 走普通作用域)。
|
||||
|
||||
### 🐛 顺带修掉的隐藏问题
|
||||
**`cmd/tamabox.go` 里根本没注册 `cmd.Ban`** —— `internal/cmd/ban.go` 写了但命令不可达(`TamaBox ban` 不存在)。
|
||||
现已把 `cmd.Ban` 与 `cmd.Restore` 一起注册进 `app.Commands`。
|
||||
|
||||
### 验证
|
||||
- `go build ./...` / `go vet ./...` EXIT=0;`go test ./...`(含本地 PG)**全绿**;gofmt 全部 clean;行尾未变。
|
||||
- 新增/改写测试:`TestDeactivatePurgesUser`(占位行保留 + 内容清空 + 同邮箱得 ErrDeactivatedEmail + 同域名得
|
||||
ErrDeactivatedDomain + Reactivate 后可正常注册 + 重复 Reactivate 报 ErrUserNotExists)、
|
||||
`TestBanUserAndCleanupRemovesRow`(封禁物理删除且可重新注册)。
|
||||
- **CLI 端到端实测**(临时库 `tamabox-clitest`,用 `NEKOBOX_CONFIG_PATH` 指向临时 ini,跑完已 DROP + 删除产物):
|
||||
- `restore --email clitest@example.com --yes` → deleted_at 变 NULL ✅;重复恢复 → 正确拒绝「未被停用,无需恢复」✅
|
||||
- `restore --password newpass12345` → 密码重哈希 + password_version=1 ✅;密码过短/邮箱不存在 → 明确报错 ✅
|
||||
- `ban --domain banme --yes` → users 与 questions 计数归零(物理删除)✅
|
||||
- ⚠️ `conf.Init` 还需要 `server.salt` / `server.xsrf_key` / `app.external_url` 非空,配置路径可用
|
||||
`NEKOBOX_CONFIG_PATH` 覆盖 —— 这是本地跑 CLI 冒烟的关键(仓库里只有 `conf/app.sample.ini`)。
|
||||
- 测试环境 `devcheck-e2e.py` 的核心回归项已改为断言「重注册被明确拒绝并出现『已停用』提示、且不是 500 兜底」;
|
||||
在**未重新部署的旧构建**上会 FAIL 并打印「サーバー内部エラー」,正是需要重新部署的信号。
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 部署后巡检扩容:投票全链路 + 登录态资产(28/28 全绿)
|
||||
|
||||
**背景**:用户重新部署后要求「所有功能都试一遍」。此前 `devcheck-e2e.py` 只验证了「新建投票页 GET」,
|
||||
**投票创建(POST)这条主链路从未端到端跑过**,是覆盖盲区。
|
||||
|
||||
**改动**:`dev/devcheck-e2e.py` 扩到 **28 项**,新增两组:
|
||||
|
||||
1. `check_vote_flow(owner, base)` —— 投票全链路
|
||||
- POST `/user/votes` 建单选投票并 `action=publish` → 断言 302 → `/user/questions#votes`
|
||||
- GET `/user/questions` 断言标题可见,并从 `copyShareURL('...')` 取 **LinkToken**、从 `/user/votes/(\d+)/edit` 取 **ID**
|
||||
- 匿名 GET `/vote/<token>` → 200 且含标题;从 `name="option_id" value="..."` 取选项 ID
|
||||
- 匿名 POST `/vote/<token>/submit`(`option_id` + 数学验证码 + `_csrf`)→ 302
|
||||
- GET `/user/votes/<id>/export` 断言 **`总参与,N` 这行的 N == 1**
|
||||
- 草稿:`action=save` → 302 到 `/user/votes/<id>/edit?saved=1`
|
||||
- 表单型:`type=form` + `form_fields` JSON(field_type/label/required/max_length)→ 发布 302
|
||||
2. `check_user_assets(owner)` —— 登录态接口/导出
|
||||
- `/api/v1/user` 返回 JSON、`/explore` 200、`POST /user/profile/export-json`(JSON)、
|
||||
`POST /user/profile/export`(xlsx,校验 content-type)、停用页 `deactivate.note` 结构断言
|
||||
|
||||
**踩到的坑(都已在脚本里修掉)**:
|
||||
- 单选投票导出 CSV 的**末行是 `总参与,N`**,与各选项票数行同为 `,数字` 结尾 →
|
||||
用 `,\d+$` 一把捞会把总参与也累加进去(实测 `[1,0,0,1]` 求和成了 2)。
|
||||
**必须单独匹配 `总参与,(\d+)`**。
|
||||
- `urllib` 读 **HTTPError 的响应体本身也会超时**(`c.get("/i18n/xx?...")` 这类 302 最易触发),
|
||||
异常从 except 块里抛出会把整轮巡检打断。已把 body 读取包在 try/except 里,并在 `__main__` 外层加兜底
|
||||
——中断时仍打印已完成项,而不是只留一段 traceback。
|
||||
- ⚠️ 复盘我自己的一个假警报:用 `T=/c/Users/Tamakyi\ Gekka/...` 做 gofmt 比对循环时,
|
||||
变量未加引号导致路径断裂 → 全部文件假报 DIFF。**脚本里带空格的路径变量一定要整体加引号**,
|
||||
单文件手跑同一个文件即为 OK,说明是脚本问题而非代码问题。
|
||||
|
||||
**结果**:测试环境 `https://box-dev-api.337845818.best` 跑 `python dev/devcheck-e2e.py` → **28 项,失败 0 项**。
|
||||
10 个改动过的 Go 文件 gofmt 比对全部 OK(无重排),行尾未变。
|
||||
|
||||
**遗留**:巡检每轮会在测试环境留下一个**已停用的身份占位**(邮箱/域名被占用)。
|
||||
本轮新增 `tbe2e*` 若干;用户自己的 `1223383917@qq.com` / `tamakyi` 也仍是占位状态。
|
||||
清理:`TamaBox ban --email <邮箱> --yes`(连行物理删,彻底释放)或 `TamaBox restore --email <邮箱> --yes`(恢复)。
|
||||
@@ -1,614 +0,0 @@
|
||||
# 2026-10-19
|
||||
|
||||
## 部署后复测(强制严格档那批)
|
||||
|
||||
- 规则页返回首页已居中:线上 `<div class="uk-margin-large-top uk-text-center">` ✔
|
||||
- 验证码弹窗按钮已缩短:线上 `>送信<` ✔(旧的「認証して送信」已消失)
|
||||
- 箱主面板 `/user/questions`:限制期条幅 + 「查看社区规则」链接都在 ✔
|
||||
- 严格度锁定(admin6 当前 L2):
|
||||
- `/user/profile` 出现「处置期间审核严格度已锁定为最高档,解除后自动恢复你的原设置」✔
|
||||
- `select[name=strictness]` 与「保存审核严格度」按钮均带 `disabled` ✔
|
||||
- 直接 POST `/user/moderation-strictness/update?tab=security` + `strictness=lax` → 302 回 profile,
|
||||
flash 仍是「已锁定」,且选中档仍为 `strict`(说明未写入)✔
|
||||
- 踩坑:表单 action 是 `/user/moderation-strictness/update`(**没有** `/profile` 段);用错路径返回 302 到 `/`,
|
||||
容易误判成「被中间件拦」。另外每次 POST 都要重新取 `_csrf`,用上一页(已跳首页)的 token 会 400。
|
||||
|
||||
## 记忆维护
|
||||
|
||||
MEMORY.md 一度 19KB 被注入截断,已重写压缩到约 4.9k 字符(8.1KB),保留构建/行尾/embed/审核/处置/CLI 等硬约束,
|
||||
删去已在日志里的过程细节。
|
||||
|
||||
## CLI 文档 + 全量代码审阅(同日)
|
||||
|
||||
新增 `docs/cli.md`:命令总览、reports 三种用法与口径、restrict 级别语义/副作用、unrestrict、
|
||||
hide-public、ban/restore、典型处置流程、排错表、明确不做的事。
|
||||
|
||||
审阅中发现并修复(全部本地临时库实测):
|
||||
|
||||
1. **`hide-public` 举报闭环静默失效**(真 bug):`build()` 带 `is_private=false`,在 Update 之后拿它当子查询恒为空集
|
||||
→ 隐藏成功但 `content_reports` 仍 pending。改为 Update 前 `Pluck("id")` 快照,再按 ID 更新/标记。
|
||||
实测:3 条 pending → accepted,未波及其他箱主。
|
||||
2. **整箱分享卡未拦截**:`ShareCard` 直接走缓存,L1–L3 仍能生成整箱图。已在缓存查询前加拦截(fail-closed)。
|
||||
3. **L2 通知邮件误导**:强制私密后仍给提问者发「你的提问有回答」,链接点开 302。改为 `!forcedPrivate` 才发。
|
||||
4. **L2 写入顺序**:`SetPrivate` + `AnswerByID` 两条语句有「公开+已作答」窗口,第二条失败还会留下「私密+无回答」。
|
||||
新增 `Questions.AnswerByIDPrivate`(单条 UPDATE 同时写 answer/is_read/is_private),并补测试 `TestQuestions/AnswerByIDPrivate`。
|
||||
5. **`ListAPI` 绕过 Pager**:L4 停用与页面访问密码只挡 HTML,`/api/v1/user/{domain}/questions` 仍能整页拉。
|
||||
已补两道校验(均返回「用户不存在」,不暴露原因)。**页面密码可被 API 绕过是升级前就存在的问题。**
|
||||
6. **L1 曝光残留**:`GetShowInHome` 的 Count 未过滤(分页总数虚高、末页空白)、热门标签查询未过滤。已补同一子查询。
|
||||
7. **性能**:`ActiveLevel` 由两次查询改一次(取最高 rank);新增 `moderation.EffectiveStrictnessFor(level, raw)`,
|
||||
发布路径上不再重复查库(原先一次发布最多 4 次处置查询)。
|
||||
8. **证据包口径**:分母是「累计公开回答」不是窗口产出 → 文案加「累计」;新增 `待处理 N` / `已处理` 标记,
|
||||
避免把已闭合的举报误读成还挂着;补 `q.deleted_at IS NULL`;`--stats` 说明最长回溯 30 天;CLI 对 `active` 做 nil 守卫。
|
||||
|
||||
验证:`go build ./...` EXIT=0;db/i18n/templates 测试全绿;CLI 全流程(restrict L3 → 证据包 → unrestrict)实测通过;
|
||||
临时库已 DROP。
|
||||
|
||||
遗留(未改,已向用户说明):L4 不覆盖 `/vote/{token}`;`restrict` 没有 `--days` 上限;处置不发邮件给箱主(只有登录后条幅);
|
||||
`abuseAlertCooldowns` 进程内 map 无清理(重启即失效)。
|
||||
|
||||
## 第二轮:处置通知邮件 + 超长确认 + 冷却表清理
|
||||
|
||||
- 新增 `templates/mail/restriction-notice.html` + `mail.SendRestrictionNoticeMail(RestrictionNotice)`:
|
||||
文案由调用方按**箱主自己的语言**(`users.language`,空则 `conf.App.DefaultLang`)预翻译后传入,
|
||||
模板本身单语言。**不含 `--reason` 原文**,只有级别名/到期/规则链接/申诉渠道。
|
||||
i18n 新增 10 个键 ×4 语言:`mail.restriction.*`(6)+ `restriction.level.L1..L4`。
|
||||
- `restrict`:新增 `--force`(`--days > 90` 必填,否则直接报错)与 `--no-notify`;交互模式下超长处置
|
||||
还要再输一次级别名(`--force --yes` 视为两次确认)。发信失败只 warn + 终端提示,**不回滚处置**;
|
||||
发信加了 20s 超时(gomail 无 Timeout 字段,裸 Dial 会等系统 TCP 超时)。
|
||||
- `abuseAlertCooldowns` 加惰性清理(>1024 条时删除已过 24h 的项)。
|
||||
- 顺带:`/user/questions` 限制期条幅改用 `restriction.level.*` 的名称(「收窄公开」)而不是裸 `L2`。
|
||||
|
||||
验证:真实 SMTP sink 实收两封(ja 与 zh-CN),标题/级别名/到期/规则链接均正确、`--reason` 未出现、
|
||||
链接未被 `ZgotmplZ` 转义;`--days 120` 无 `--force` 被拒;黑洞 SMTP 下 23s 内返回(未阻塞两分钟)。
|
||||
`go build ./...`、`go vet`、db/i18n/templates 测试、checktpl 全绿。
|
||||
|
||||
遗留:`unrestrict` 不发通知(只解禁);`restrict` 对 `--days` 无上限(设计如此)。
|
||||
|
||||
## 第三轮:解除处置也发通知
|
||||
|
||||
- 复用同一封模板 `mail/restriction-notice.html`:`appeal` 段改为 `{{if .appeal}}` 可选,
|
||||
解除通知不传该字段就整段省略。
|
||||
- 新增 i18n 键 ×4:`mail.unrestrict.heading/body/note`(body 带域与级别名;
|
||||
note 明确写「处置记录保留用于审计、私密内容不会自动恢复」)。
|
||||
- `unrestrict` 新增 `--no-notify`;撤销前先 `ActiveLevel` 取级别(撤销后读不到),
|
||||
撤销后按箱主语言发解除通知。`affected==0` 直接返回、不发信。
|
||||
- 抽出公共件:`sendNotice`(20s 超时)、`reportNoticeResult`(统一成功/失败输出)。
|
||||
- 文档补充:`unrestrict` 的 `--no-notify`、解除通知内容、以及**到期自动解除不会发信**(无调度器)。
|
||||
|
||||
实测(本地 SMTP sink,2 封):处置信含级别名/到期/申诉段;解除信含「已解除」+ 私密内容提示、
|
||||
**无申诉段**;两封都不含 `--reason`。`--no-notify` 两处都不发信;无生效处置时不发信。
|
||||
`go build ./...`、db/i18n/templates 测试、checktpl 全绿。
|
||||
|
||||
## 上线前最终验收(部署后实测)
|
||||
|
||||
- 公开页:`/` `/rules` `/restricted` `/deactivated` 全部 200;`/rules` 四语言标题正确。
|
||||
- 箱主面板(admin6,L2):条幅显示「收窄公开」+ 规则链接;严格度面板锁定提示 1 处、下拉 disabled。
|
||||
- **L2 写入路径实测**(关键,因为改成了 `AnswerByIDPrivate` 单条 UPDATE):
|
||||
对未回答题 q95 作答 → 302 回题目页 → flash「已保存为私密:当前处于平台处置期,回答不会公开」→ 匿名访问 302。
|
||||
- 公开列表 API `/api/v1/user/{domain}/questions` 正常返回 `{code,data:{data,next_cursor,has_more}}`(新增的 L4/密码校验没有打断正常路径)。
|
||||
- 整箱分享卡:受处置箱主 302(拦截生效);未处置箱主 500,原因是**测试站未配置字体**(`分享图生成失败:字体文件未配置或无法加载`),与本次改动无关。
|
||||
这一点反而证明拦截位置在生成之前。
|
||||
- 未能自测:CLI 发信(restrict/unrestrict 通知)需要在服务器上跑命令,只能由用户执行。
|
||||
|
||||
注意:admin6 现有测试题 73/88/89/90/94/95;其中 95 已被我回答并存为私密(内容带 `restrict self-check`)。
|
||||
admin6 的 L2 于 2026-10-19 21:29 自动到期。
|
||||
|
||||
## 邮件模板统一(用户反馈:页脚与其它邮件不一致)
|
||||
|
||||
- 原因:`restriction-notice.html` 是我新写的,页脚用了自造的 `© 2026 TamaBox`,与项目其它 11 封邮件的
|
||||
「此邮件由系统自动发送,请勿直接回复。<br/>{{ .year }} 狼的提问箱」不一致。
|
||||
- 处理:整封模板按 `question-reported.html` 的结构重写(24px 标题 + `{{.time}}` 副行、同样的卡片/页脚/外间距),
|
||||
`time` 由 `mail.SendRestrictionNoticeMail` 统一注入(调用方不用各自拼时间)。
|
||||
- 实测(本地 SMTP sink):页脚文本与 question-reported 完全一致,`© ... TamaBox` 已消失,头部时间行存在。
|
||||
- 注意:站点名在所有邮件模板里都是硬编码「狼的提问箱」,不是配置项;改站名要一起改 12 个模板。
|
||||
|
||||
## 页脚精简(移动端一行放不下)
|
||||
|
||||
- 去掉「盘」链接(`https://share.shiroko.one`,zh 文案就叫「盘」),并删除已无引用的
|
||||
`site.footer.drive` 键(四语言同步删,保证 `TestDictionaryComplete` 词条数一致)。
|
||||
- `site.footer.rules`:zh-CN「社区规则」→「规则」,zh-TW「社群規則」→「規則」(en/ja 本就短)。
|
||||
- 现在页脚一行:规则 | 找站长 | 公告 | 聊天 | 状态 | 主页。
|
||||
- 注意:`templates/base/footer.html` 用 `|` 分隔,行首行尾各带一个,删整行时不会留下悬空分隔符(已核对)。
|
||||
|
||||
## 页脚改两行(用户改主意:盘恢复)
|
||||
|
||||
- 恢复 `https://share.shiroko.one`「盘」链接与 `site.footer.drive` 键(zh-CN 盘 / zh-TW 盤 / en Drive / ja ドライブ)。
|
||||
- `site.footer.rules` 文案恢复:社区规则 / 社群規則(en/ja 不变)。
|
||||
- 布局两行(靠 `<br>` 断行,不用额外 CSS):
|
||||
第一行 `社区规则 | 找站长`,第二行 `盘 | 公告板 | 聊天室 | 监控板 | 导航页`。
|
||||
- 键插在 `site.footer.rules` 之后(组末),空格数按同长度键复制,gofmt 对齐不变;dict 仍 CRLF。
|
||||
- 验证:build / checktpl / i18n / templates 测试全绿。
|
||||
|
||||
## 页脚改为「从少到多」四行
|
||||
|
||||
版权行与存活计时移到最后两行(原来在最上面),整块按文字量递增:
|
||||
`社区规则 | 找站长` → `盘 | 公告板 | 聊天室 | 监控板 | 导航页` → `Powered by NekoBox | ICP` → `提问箱已成功存活:…`。
|
||||
实现只调整了 `<br>` 的位置,仍共 4 个 `<br>`,所以块高与顶部空行跟原来一致(未新增占位)。
|
||||
注意:`box_runtime` 的脚本按 id 取元素,移动位置不影响;但 `<br>` 数量别随意增删,否则页脚高度会变。
|
||||
|
||||
## 前端样式体检(只出结论,未改代码)
|
||||
|
||||
量化事实(脚本统计 templates/**/*.html):
|
||||
|
||||
- 全站 **0 个 .css 文件**,样式全部内联:`base/header.html` 内含 **17.5KB CSS + 10.6KB JS**(模板总 42.5KB),
|
||||
footer 3.0KB JS;全模板共 **24 个 `<style>` 块**,`user/profile.html` 自身还有 11.2KB CSS + 22.8KB JS(总 62KB)。
|
||||
- `--tb-*` 设计变量 **只在 `html[data-theme="dark"]` 里定义**,light 主题没有 `:root` 一套;
|
||||
调用处全靠 fallback(`#e9e9e9` / `#e5e5e5` / `#fafafa` / `#333` / `#666` 混用),explore 等页面还写死色值。
|
||||
- **深色模式 FOUC**:`applyTheme(getTheme())` 在 body 内、`<nav>` 之后执行(header.html:812),
|
||||
深色用户刷新时顶栏会先白闪一下。修法:把 theme 初始化内联脚本提到 `<head>`。
|
||||
- 结构:`uk-container-xsmall`(750px)由 header 统一输出,仅 explore/vote/chat 页面自套 small;
|
||||
`question/list.html` 仍用 `<center>`、内联 `style="min-height:260px"`,用户名/简介颜色由用户自选压在背景图上,无 scrim → 可读性无保障。
|
||||
- 首页 `home.html` 是纯 UIkit 卡片 + `<hr>` + `style="color: red"` + `<pre>` 统计 + 首页挂 Waline(250 行 locale)。
|
||||
|
||||
建议分档(未实施):P0 = light/dark 双套 token + 圆角/阴影/中文字体栈 + 首页重排 + 背景图 scrim;
|
||||
P1 = header CSS 抽成 `static/css/*.css`(注意 `embed.go`/`static/fs.go` 都是显式清单)+ FOUC 修复;
|
||||
P2 = 图标统一(UIkit icons 取代 emoji/位图 png)、标签减量、viewpoint/meta 去重。
|
||||
|
||||
## P0 前端美化(已实施)
|
||||
|
||||
用户拍板:卡片**结构不变,只加圆角和柔阴影**;先做 P0,不抽 CSS 文件。
|
||||
|
||||
改动文件:
|
||||
- `templates/base/header.html`
|
||||
- head 顶部新增**主题初始化内联脚本**(深色用户不再白闪;原 `applyTheme` 在 body 里、nav 之后执行)。
|
||||
顺手删掉 body 脚本里的 `applyTheme(getTheme())`,只保留切换逻辑。
|
||||
- 新增**设计令牌块**:`:root` 亮色 + `html[data-theme=dark]` 暗色,同名 14 个(bg/surface/surface-2/surface-hover/
|
||||
border/text/text-secondary/text-muted/primary/primary-weak/radius/radius-sm/radius-xs/shadow/shadow-hover/font)。
|
||||
原暗色块里的 7 个变量定义已删除(现在统一在令牌块)。
|
||||
- 新增**基础质感层**:html+body 底色与字体栈(system-ui + 苹方/冬青黑/雅黑)、卡片 12px 圆角 + 双层柔投影、
|
||||
按钮/输入 8px、标签 6px、alert 10px、modal 14px、导航栏白底、`:focus-visible` 描边、`tabular-nums`、
|
||||
`prefers-reduced-motion`。
|
||||
- `.tb-cover`:封面遮罩;默认浅色(配 `.uk-light` 的黑字兜底),`.tb-cover-dark` 为深色。
|
||||
- `.tb-img-uploader` 上收到 header(原来 item / new-question 各写一份**内联 style**,写死 #f8f8f8 → 深色下露白)。
|
||||
- `templates/home.html`:整页重排为 hero(标题+一句话+探索/规则按钮)+ 3 张功能卡 + 域名迁移公告
|
||||
+ 提醒列表 + `<details>` 折叠更新日志 + 3 个数字统计卡(`.SiteStats` 数值字段,30 分钟 JS 刷新改为按字段更新)。
|
||||
全部原有信息保留;页内 `style=` 归零。
|
||||
- `templates/question/list.html`:封面加 `.tb-cover`(仅当有背景图),新增 12 行 JS 按用户名颜色亮度
|
||||
决定遮罩方向;`min-height:260px` 内联改为 `.tb-profile-cover`。
|
||||
- 6 个页面样式块里的硬编码色值收敛到令牌(explore/question-list/profile/list/item/vote-page,共 ~84 行),
|
||||
CRLF 文件用字节级替换(未改动行尾)。
|
||||
|
||||
验证:`go test ./templates/... ./internal/i18n/...` 全绿;`go build`(overlay)通过。
|
||||
新增 `templates/zz_preview_test.go`:`TEST_PREVIEW=1 go test ./templates -run TestGenPreview` 产出
|
||||
`outputs/preview-{home,box,explore}-{light,dark}.html`,用无头 Chrome 截图核对过亮暗两套。
|
||||
⚠️ 本机 **Chrome 新版 headless 会挂住**,必须 `--headless=old`,且页面外部资源要本地化
|
||||
(`outputs/preview-assets/`,从 cdnjs.snrat.com 取 UIkit 3.3.3;cloudflare/jsdelivr 在本机不通)。
|
||||
|
||||
## 封面遮罩 P0 缺陷修复(重要)
|
||||
|
||||
**症状**:`.tb-cover::after` 遮罩不渲染,且**完全不报错**——截图对比像素只差 ±1(只看着像"没效果")。
|
||||
|
||||
**根因**:UIkit 3.3.3 自带
|
||||
`.uk-flex::after,.uk-flex::before{display:none}`(优先级 0,2,0),
|
||||
而封面元素是 `uk-flex`,我写的 `.tb-cover::after`(0,1,1)优先级不够 → 被压成 display:none。
|
||||
定位方法:`getComputedStyle(el,'::after').display` 得到 `none`;再逐个 `classList.remove` 试 →
|
||||
去掉 `uk-flex` 立刻变 `block`。最后在 uikit.min.css 里 grep 到那条规则。
|
||||
**修法**:选择器写成 `.tb-cover::after, .tb-cover.uk-flex::after` 并显式 `display:block`。
|
||||
|
||||
**实测效果**(无头 Chrome 截图 + 像素测量,非目测):
|
||||
- 深色渐变背景 + 白字:封面底部亮度 0.229→0.102,白字对比度 **3.77→6.89**(过 WCAG AA 4.5)
|
||||
- 线上默认背景(`box_background.png` 2048×1152,接近全白)+ 黑字:0.912→0.971,对比度 19.25→20.42,
|
||||
即**对默认背景几乎没有可见影响**
|
||||
- 亮度判定 JS 已验证:`#fff` 会加 `tb-cover-dark`,`#000` 不加。
|
||||
|
||||
**教训**:CSS 改动不能只看"规则写上了/类加上了",必须量像素。伪元素 + UIkit 工具类组合要在计算样式层验证。
|
||||
|
||||
## 账号背景图的真实链路(回答用户提问时查证)
|
||||
|
||||
- 注册时把 `[upload] default_background` **一次性复制**进 `users.background`(`route/auth/register.go:51`),
|
||||
不是每次渲染兜底 → 之后改配置**不影响存量用户**。
|
||||
- 页面只用 `.PageUser.Background` 渲染 `data-src`;为空则无背景图、无遮罩。
|
||||
- 分享卡是另一条路:`route/question/share.go:345` 用 `user.Background`,为空时回退 `conf.App.ShareBackground`。
|
||||
- 线上 `box.shiroko.one/_/tamakyi` 实际值 = `https://r2.337845818.best/tamabox/box_background.png`。
|
||||
- 预览里那张"渐变图"是 `zz_preview_test.go` 造的测试数据(data: URI 渐变),**不是产品行为**;
|
||||
顺带发现 Go 模板会把 `data:` URI 在属性上下文里替换成 `#ZgotmplZ`(真实 http URL 不受影响)。
|
||||
|
||||
## 设置页「防护设置」缺白底(用户报)
|
||||
|
||||
根因:其它面板都包在 `<section class="tb-pcard">`,只有内容防护面板(profile.html:842)是裸 `div.profile-panel`,
|
||||
直接露出页面灰底 `--tb-bg`。修法:包一层 `section.tb-pcard`,并把 `.tb-pcard` 的 `background:#fff` 改成
|
||||
`var(--tb-surface)`(删掉随之冗余的 dark 覆盖)。
|
||||
实测(计算样式):account 与 security 面板 bg 亮色均 `rgb(255,255,255)`、暗色均 `rgb(36,40,48)`,圆角 10px,完全一致。
|
||||
顺手把 `security` 加入 `render_test.go` 的面板渲染测试列表(原来只测 account/question/chat/vote)。
|
||||
|
||||
⚠️ 教训:用脚本批量缩进/hash 插入时先 dry-run。这次第一版脚本把「空行」也当成需要写 `<section>` 的行,
|
||||
一次性插了 5 个多余标签;靠立刻 `sed` 复查发现并回滚。
|
||||
|
||||
## P1 第一步:CSS 抽离(已完成)
|
||||
|
||||
- 新增 `static/css/base.css`(23KB,含注释;gzip 后约 6KB),内容 = 原 header 里的
|
||||
设计令牌 / UIkit 补丁 / 深色覆盖 / 质感层 / 顶栏响应式 / 标签 / Markdown / 进度条 / 移动端底栏。
|
||||
- `header.html`:内联 `<style>` 从 17.5KB 降到只剩 1 个(算数验证码,跟着 `{{ if CaptchaType }}` 条件渲染);
|
||||
改为 `<link href="/static/css/base.css?v=20261019a">`,位置在 UIkit 样式之后(保证覆盖顺序)。
|
||||
单页 HTML 42.5KB → 30.9KB。
|
||||
- `static/fs.go` 的 `//go:embed` 显式清单加 `css/base.css`(漏了就是运行期 404 + 全站无样式,编译期不报错)。
|
||||
- 静态缓存沿用既有 `CacheControl: public, max-age=86400`;靠 `?v=` 版本号破缓存。
|
||||
|
||||
**验证方式**:改动前后各截一次同样尺寸的截图做像素比对。
|
||||
home-light / home-dark / explore-light 三页差异均 **0.024%**,bbox 只有 (11,56)-(29,74) 一个 18×18 方块
|
||||
= 导航栏随机动物 emoji(每次加载都变),不是样式回归。go build + 模板/i18n 测试全绿。
|
||||
|
||||
## 测试环境复测(P1 CSS 抽离 + 防护面板白底,已上线)
|
||||
|
||||
- `GET /static/css/base.css?v=20261019a` → 200 / `text/css` / **23088 字节,与本地 `static/css/base.css` 逐字节一致** /
|
||||
`Cache-Control: public, max-age=86400` / gzip 后 7109 字节。
|
||||
- 首页 32747 字节(改动前 52747),gzip 10534(改动前 13642);页面内联 `<style>` 只剩验证码 + 首页自己的样式块。
|
||||
- 设置页(登录 admin6):`data-profile-panel="security"` 下已有 `<section class="tb-pcard">`;
|
||||
用线上 HTML + 本地 CSS 复算样式 → account / security 两块 `bg=rgb(255,255,255)`、`border-radius:10px`,页面底 `rgb(247,248,250)`。
|
||||
- `/_/admin6` 公开页:`class="... uk-light tb-cover"`,`:after` 计算值 **display=block, width=689px**
|
||||
→ 遮罩在真实部署上确实渲染了(UIkit `.uk-flex::after{display:none}` 的坑已解决)。
|
||||
该用户用户名为 `rgb(0,0,0)`,走浅色遮罩(符合预期)。
|
||||
- 各页 200:`/`、`/explore`、`/rules`、`/user/questions`;`/user/vote-form` 无参 302 属正常。
|
||||
- 首页首次访问总字节略增(10.5+7.1 vs 13.6KB gzip),但 CSS 跨页缓存后每页少 3.1KB,且服务端少渲染 17.5KB 模板。
|
||||
|
||||
## 专属测试账号(用户指定)
|
||||
|
||||
- `https://box-dev-api.337845818.best/_/admin6`,邮箱 `[email protected]`,密码 `adminadmin`。
|
||||
- 登录需算数验证码,curl 流程:GET /login 取 `_csrf`(同时存 cookie)→ `GET /api/captcha/generate` 拿 `{id, question}`
|
||||
→ 算答案 → `POST /api/captcha/verify`(头 `X-CSRF-Token`,体 `{id, answer}`)→
|
||||
`POST /login`(email/password/_csrf/`g-recaptcha-response=id:answer`)→ 302 到 `/`,cookie 已带登录态。
|
||||
|
||||
## P2 一致性收尾(已实施,未部署)
|
||||
|
||||
1. **导航图标**:🌐🌙☀️ emoji → 内联单色 SVG(`stroke: currentColor`,跟随主题/悬停色)。
|
||||
⚠️ 原计划用 UIkit icons,实测 **UIkit 3.3.3 的图标集里没有 moon / sun**(只有 world),
|
||||
照写会渲染成空白按钮——所以三个图标全部改成内联 SVG,也不依赖 JS。
|
||||
2. **标签**:投票类型 20 处 `uk-label uk-label-primary`(实心蓝)→ `.tb-chip` 轻量胶囊;
|
||||
探索页热门标签原来的 5 色轮换(success/warning/danger/primary/secondary)也统一成 `.tb-chip`。
|
||||
箱主面板 `question-list.html` 另 6 处字节级替换(CRLF 保留)。
|
||||
保留:置顶/私密/被举报/已读 这些**语义状态**仍是彩色 uk-label(有含义,不该降级)。
|
||||
3. **遗留写法**:`<center>` ×2 → `div.uk-text-center`;占比条从
|
||||
`div.uk-progress > div.uk-progressbar(style:width)` 改成 UIkit 3 原生
|
||||
`<progress class="uk-progress" value=".." max="100">`,**删掉 25 行 UIkit2 兼容补丁**,
|
||||
只保留 10px 高/6px 圆角的外观收窄。测试 `percent_check_test.go` 的断言同步改成 `value="66" max="100"`。
|
||||
4. **meta**:删掉重复的 `msvalidate.01`(保留 head 顶部一份)、viewport 补 `initial-scale=1`。
|
||||
|
||||
**踩坑**:暗色主题图标原来写 `display:inline`,span 里放块级 `<svg>` 会让导航栏高度变化 → 整页下移 36px。
|
||||
用行亮度曲线做互相关定位到 dy=36,改成 `inline-flex` 后复测差异回到 0.059%(只剩随机 emoji)。
|
||||
教训:**改图标尺寸/显示模式一定要量整页位移**,不能只看局部截图。
|
||||
|
||||
## P2 线上复测(2026-10-19 12:2x)
|
||||
|
||||
- `GET /static/css/base.css?v=20261019c` → 200 / text/css / **23918 字节,与本地逐字节一致**,页面已引用该版本。
|
||||
- meta:viewport 已带 `initial-scale=1`;`msvalidate.01` 只剩 1 份。
|
||||
- 图标:首页 emoji 🌐🌙☀️ 归零,`<svg ... stroke="currentColor">` ×5(语言 1 + 桌面主题 2 + 移动主题 2);
|
||||
计算样式暗色下 `#theme-toggle .tb-icon-dark` = `display:flex`、亮色图标 `none`;
|
||||
**导航高度亮/暗均 80px** → 上次 36px 位移问题确认修好。
|
||||
- 探索页:`.tb-chip` ×25(全部有底色 `rgb(244,246,248)`、文字 `rgb(92,99,112)`、圆角 6px、字号 11.52px);
|
||||
残留彩色 `uk-label` 4 个,均为置顶/私密等语义状态。
|
||||
- 结果页:`/user/votes/{id}/results` 原生 `<progress class="uk-progress" value max>`,计算高度 **10px**、圆角 6px;
|
||||
遍历 10 个结果页 `uk-progressbar` 全部为 0。
|
||||
- 巡检 `/ /explore /rules /login /register /_/admin6 /_/admin3` 均 200。
|
||||
|
||||
## P2 补漏(已改,待部署)
|
||||
|
||||
复测时发现 **结果页与参与页的题型标签仍是实心彩色** —— 那两处的 class 由 Go 模板函数
|
||||
`fieldTypeLabelClass` 生成,我上一轮只按字面 `class="uk-label uk-label-primary"` 搜索,漏了它们。
|
||||
|
||||
- `templates/user/vote-results.html`(2 处)、`templates/vote/page.html`(2 处):序号 + 题型 → `.tb-chip`;
|
||||
状态标签(未开始/已结束/进行中/已参与)保留彩色,属语义。
|
||||
- 删除 `internal/template/template.go` 的 `fieldTypeLabelClass`(19 行,已无引用)。
|
||||
顺带查明:该函数 `text/textarea` 走 `return ""`,注释说"默认灰",**实际渲染成 UIkit 默认蓝**,
|
||||
所以文本框标签一直是蓝的——注释与实现不符。
|
||||
- `.uk-progress::-webkit-progress-value` 圆角改成两端都圆(`-moz-progress-bar` 同步)。
|
||||
⚠️ 起因是误判:我先前测"右端方角"时像素扫描循环上界写成 `range(110,830)`,恰好等于条宽,
|
||||
于是右端永远"恒定不变"→ 假象。放宽到整幅宽度后两版端点曲线完全一致(830/832/833/834/833/832/830),
|
||||
即**没有视觉 bug**。保留改动只为 Firefox 的 `-moz-progress-bar` 保险。
|
||||
- `base.css?v=` → `20261019d`。`go build` + `./templates ./internal/i18n ./internal/template` 全绿。
|
||||
|
||||
**教训(重要)**:像素测量脚本的扫描范围必须覆盖整幅图,循环上界不能与被测对象边界重合,
|
||||
否则会把"边界截断"读成"图形特征"。之前 3.77→6.89 那组对比不知是否也受此影响,需复查时再确认。
|
||||
|
||||
## P2 补漏部署后复测(12:48)
|
||||
|
||||
- `base.css?v=20261019d` 上线,24023 字节与本地逐字节一致。
|
||||
- 删掉 `fieldTypeLabelClass` 后**无运行期报错**:公开投票页 3 个(200,tb-chip 8/16/4,uk-label 各 1 = 状态)、
|
||||
结果页 4 个(200,tb-chip 8/2/0/16,彩色 label 全 0,progress 7/0/10/19)、
|
||||
草稿预览页 2 个(200,tb-chip 2/6)。
|
||||
- 巡检 `/ /explore /rules /user/questions /user/profile?tab=security /_/admin6 /_/admin3` 全 200 无模板错误。
|
||||
- `/user/votes/{id}/preview` 对**已发布**投票 302 到 `/vote/{token}` 是设计如此(vote.go:1093),不是回归。
|
||||
- 截图核对:结果页「1 単選 / 2 多選」已为浅色胶囊,进度条 10px 圆角 6px;参与页字段头同样统一。
|
||||
|
||||
## "移动端语言/主题按钮不见了"排查(结论:非缺陷)
|
||||
|
||||
实测(线上测试环境,`--force-device-scale-factor=1`):
|
||||
- 宽 360/390/414/640 → 顶栏 `.tb-nav-utils` = `display:none`,底栏 `#tb-mobile-utils` = `flex`,
|
||||
rect `0,639,W,46`,语言 `block`、主题按钮 `block`(48×30,SVG 16×16),命中测试落在 `mobile-lang-select`。
|
||||
- 宽 700/768/980 → 底栏 `none`,顶栏 `flex`,右上角地球 + 月亮都可见(截图 `outputs/t-nav-right.png`)。
|
||||
- 页面级差异:`/_/admin6` 底栏**只有月亮没有语言下拉** —— `IsUserPage=true` 让语言块整体不渲染(桌面同样没有)。
|
||||
- 生产 `box.shiroko.one` 仍是旧构建(页面无 `base.css` 引用,底栏是 emoji 月亮),移动端同样正常。
|
||||
|
||||
用户确认是忘了「移动端移到固定底栏」这个设计,不是 bug。
|
||||
留下的真实缺口:**箱主页(最常见的外部入口)完全没有语言切换**,见 MEMORY.md 前端样式条目。
|
||||
|
||||
## 修正:箱主页无语言切换是设计,不是缺口
|
||||
|
||||
上一节把它记成"真实缺口"是错的。用户明确:**箱主页属箱主个性化,以箱主配置为主**,
|
||||
语言入口不出现是有意为之(`IsUserPage` 门控)。
|
||||
MEMORY.md 对应条目已改为"有意设计、别当 bug 修、要动先问"。
|
||||
|
||||
## 备份(用户要求)
|
||||
|
||||
`../TamaBox-v4-mainline-20261019-1445.tar.gz`(940K,302 条目,排除 .git/.workbuddy/outputs)。
|
||||
P1 剩余两项(首屏跟随系统深色、首页 Waline 懒加载)用户明确**先不做**。
|
||||
|
||||
## 主题系统方案(只出设计,未实现)
|
||||
|
||||
用户诉求:箱主在后台选主题、仅对箱主相关页面生效;站点级默认主题在配置里;要一份可复用的主题底稿。
|
||||
|
||||
已确认的现状事实(设计依据):
|
||||
- 设计令牌只有 16 个,集中在 `static/css/base.css` 的 `:root`(亮) + `html[data-theme="dark"]`(暗)。
|
||||
- `<html lang>` 在 `templates/base/header.html:2`,服务端渲染;主题属性可放在这里(无 FOUC)。
|
||||
- `question.Pager`(`route/question/page.go:45`)是 `/_/{domain}` 与 `/_/{domain}/{id}` 的共同中间件
|
||||
→ 打主题标记的唯一合适位置。
|
||||
- `/user/*` 与 `/_/*` 共用 `IsUserPage`(context.go:241 按 URL 前缀判定),所以「箱主相关页面」
|
||||
≠ IsUserPage,必须单独区分,否则设置页会被主题化。
|
||||
- `static/fs.go` 与 `templates/embed.go` 都是显式 embed 清单(新增主题 css 必须手加)。
|
||||
- 用户模型已有大量个性化字段(usernamecolor/introcolor/background…),主题作为 `users.theme` 同族最自然。
|
||||
- 分享卡是服务端绘制图片(`route/question/share.go`),不经过 CSS → 主题不覆盖它。
|
||||
|
||||
关键结论(写进方案的核心):令牌覆盖必须用
|
||||
`html[data-theme-style="x"]`(0,1,1,压过 `:root` 的 0,1,0)+
|
||||
`html[data-theme="dark"][data-theme-style="x"]`(0,2,1,压过 base 的 dark)。
|
||||
只写浅色块会让深色模式被浅色令牌污染(同特异性 + 主题文件后加载)。
|
||||
|
||||
## 主题系统初版(已实现,未部署)
|
||||
|
||||
用户拍板:①作用范围跟随箱主配置(即 /_/ 箱主相关页面)②主题只做配色/图案/整页背景,**不涉及布局**
|
||||
③主题名可本地化 ④分享卡不参与。
|
||||
|
||||
实现:
|
||||
- `internal/theme`:注册表 + 三级回退。`[theme] default/available` → 白名单(slug 正则 + css 是否存在),
|
||||
`Resolve(用户值)`:`default`→空串(不加载)/命中→该 slug/其余→站点默认。"" 与 "default" 语义不同:
|
||||
""=跟随站点默认,"default"=箱主明确选基础外观。
|
||||
设置页色块从主题 css 浅色块里抓 `--tb-bg`/`--tb-primary` 拼渐变(值必须过色值正则,防注入)。
|
||||
- `users.theme varchar(32) default ''`(AutoMigrate 加列)+ `UpdateTheme` 单列更新。
|
||||
- `question.Pager` 注入 `Data["ThemeSlug"]`(`/_/{domain}`、单题、密码页、分享卡路由共用这一个中间件);
|
||||
header.html 在 `<html>` 输出 `data-theme-style` 并条件加载 `themes/<slug>.css`。
|
||||
- 设置页账号面板末尾新增「主题外观」卡片(独立表单 `POST /user/profile/theme`,不塞进 UpdateProfile,
|
||||
避免与该面板其它字段互相覆盖);`{{ if .Themes }}` 包裹,未配置主题时不显示。
|
||||
- 静态版本号统一 `conf.StaticVersion = "20261019e"`(base.css 与主题 css 共用,改样式只改一处)。
|
||||
- `static/fs.go` 改嵌目录 `css/themes`(以后加主题文件不用再改 embed 清单)。
|
||||
- 主题文件:`_scaffold.css`(底稿,含 19 令牌说明 + 素材规格 + 特异性规范)、`default.css`(恒等,仅色块预览)、
|
||||
`sakura.css`(示例,纯 CSS 网点图案 + 粉调)。
|
||||
- base.css:body 接 `--tb-page-image` / `--tb-page-pattern` / `--tb-page-pattern-size` 两层背景;
|
||||
**UIkit 主色是编译期常量 #1e87f0**,新增映射块把 a/uk-link、uk-button-primary、checkbox/radio、input:focus、
|
||||
uk-tab active 接到 `--tb-primary`(基础主题值相同,默认外观零变化)。
|
||||
|
||||
实测(无头 Chrome,box 页 data-theme-style=sakura):
|
||||
- 浅色 primary `#e5678f`/bg `#fff6f8`/pattern 16px 网点命中;深色 `#ff8ab0`/`#241a1f`,卡片表面各自跟随。
|
||||
- 未选主题的 box 页:无 data 属性、无 themes link、主按钮仍 `rgb(30,135,240)` → 零回归。
|
||||
|
||||
踩坑:
|
||||
1. 第一版 sakura 页「发送提问」还是蓝色 —— UIkit 组件色写死,令牌覆盖不到,必须按组件映射。
|
||||
2. 预览里 `{{ .StaticVersion }}` 为空(预览夹具没这个键),导致按固定串替换主题 css 失败;改用正则。
|
||||
3. `writePreview` 用全文搜 `data-theme="dark"` 判断是否已加属性 → 命中内联主题 css 里的选择器,深色预览没生效;
|
||||
改为只看 `<html ...>` 开标签。
|
||||
4. 脚本改 form/user.go 时把插入块写在结构体中间,拼出 `} Language ...`;靠 Read 复查发现并重写。
|
||||
|
||||
## 主题 follow-up(部署后反馈两处)
|
||||
|
||||
1. **/user/* 不生效主题**:根因是 `ctx.Data["ThemeSlug"]` 只在 `question.Pager`(`/_/{domain}`)里注入,
|
||||
`/user/*` 完全没走这条链路。修法:`internal/context/context.go` 的 Contexter 里按
|
||||
`isThemeScope(path) && c.IsLogged` 注入 `theme.Resolve(c.User.Theme)`;
|
||||
`isThemeScope` 只认 `/user` 与 `/user/`(不能用裸 `HasPrefix("/user")`,会误吞 `/username`)。
|
||||
新增 `internal/context/context_test.go` 守住路径白名单;page.go 的注释同步改。
|
||||
公开页(`/`、`/explore`、`/vote/{token}`)**仍不跟随**登录用户主题,保持站点默认。
|
||||
|
||||
2. **sakura 显示奇怪**(用户选择:顶栏白与粉底割裂 / 卡片纯白太突 / 底色分布不均):
|
||||
- base.css 新增令牌 `--tb-navbar`(`:root` 里 `var(--tb-surface)`,亮暗两条 navbar 规则都改成
|
||||
`var(--tb-navbar, var(--tb-surface))`),主题可单独给顶栏上色。
|
||||
- sakura v2:底色改**纯色** `#fceef3`(不用渐变——渐变会被读成"不均匀"),
|
||||
顶栏 `#fff6f9`、卡片 `#fffafc`(暖白,不再是纯白硬切),网点从 16px/0.14 放宽到 24px/0.05。
|
||||
- 静态版本 `20261019f`;`_scaffold.css` 令牌说明 19→20。
|
||||
- `profile.theme.hint` 四语言文案改成「提问箱页面 + 本人后台生效,公开页保持默认」。
|
||||
- `zz_preview_test.go` 抽出 `inlineThemeCSS()`,并新增 `preview-profile-sakura-*`(后台页面也要在主题下检查)。
|
||||
|
||||
验证:`go build ./...`(须带 webp overlay)、i18n/theme/context/templates 四个包测试全绿。
|
||||
渲染法:抓线上 `/_/admin6` HTML → 把 base.css 指到本地新文件、主题 css 内联 → 无头 Chrome `--headless=old` 截图。
|
||||
|
||||
## 投票页 + 标签主色(部署复核后第二轮)
|
||||
|
||||
- `/vote/{token}` 主题:`route/vote/vote.go` 的 `Page` 里本来就会 `db.Users.GetByID(vote.UserID)` 取 owner,
|
||||
直接在同一分支补 `ctx.Data["ThemeSlug"] = theme.Resolve(owner.Theme)`;box 页 / /user 后台 / 投票页三处
|
||||
全部由发布者配置决定。GetByID 失败(箱主已删)保持站点默认外观。
|
||||
- **`uk-label` 默认色就是 UIkit 主色 #1e87f0**:主题下提问管理的「未回答 / 未读」两枚胶囊还是蓝的。
|
||||
base.css 追加
|
||||
`.uk-label:not(.uk-label-danger):not(.uk-label-warning):not(.uk-label-success):not(.uk-label-secondary)`
|
||||
→ `--tb-primary`。**必须用 :not() 排除语义色**:同特异性且 base.css 在 UIkit 之后,不排除会把
|
||||
danger/warning/success 一起刷成主色。默认主题实测仍是 (30,135,240),零回归。
|
||||
- StaticVersion → `20261019g`。
|
||||
- 线上复核(admin6 专属账号,curl 走完整登录:GET /login 取 _csrf → /api/captcha/generate →
|
||||
/api/captcha/verify(**answer 必须是数字,传字符串会 400 请求格式错误**)→ POST /login):
|
||||
`/user/profile`、`/user/questions` 均已输出 `data-theme-style="sakura"` 与 themes link(v=20261019f)。
|
||||
投票页在 f 版本仍无主题 → 需再部署一次。
|
||||
|
||||
## 投票页语言跟随发布者(第三轮)
|
||||
|
||||
- 抽出 `applyOwnerLocale(data map[string]interface{}, owner *db.User)`(route/vote/vote.go 顶部):
|
||||
同时写 `Owner` / `Lang` / `LangCode` / `ThemeSlug`。
|
||||
签名故意收 Data 而不是 `context.Context`:`Context` 内部字段不可在包外构造,收 map 才能单测
|
||||
(`route/vote/locale_test.go`,纯内存,不碰 DB)。
|
||||
- `Page`:owner 查询提到 `FeatureStateOf` **之前**——否则「投票功能已关闭」这类提示先按访客语言生成。
|
||||
- `Submit`:同样先取 owner 再取 `lang`,保证提交后回跳页的 flash 与页面语言一致;
|
||||
顺带删掉尾部重复的 `GetByID(owner)`(表单类型通知邮件复用同一个 owner),少一次查询。
|
||||
- 四语言 `profile.theme.hint` 与 changelog 范围说明补上「你发布的投票页」。
|
||||
- 验证:vote/i18n/context/theme/templates 全绿;线上 `lang="ja"` + 中文内容的不一致待部署后复测。
|
||||
|
||||
### 部署后复测(v=20261019g)
|
||||
|
||||
- `/vote/7kcP5kR8YnJC`(admin6):`lang="zh-CN" data-theme-style="sakura"`,base/theme css 均 v=g,
|
||||
日文串(投票する / 匿名での参加 / ログイン)全部消失 → 语言与主题同步跟随发布者。
|
||||
- `/ _/admin6`、`/user/profile`:仍为 `zh-CN` + sakura,未回归。
|
||||
- 隔离性:另取三个 token 抽查 —— 4G3i79OCYa2E / 7l0MwF7cLz4T 为 `lang="ja"`(无语言偏好、无主题),
|
||||
9J1zgXwQ05Ai 为 `lang="zh-CN"` 且**无** data-theme-style → 主题不会跨箱主泄漏。
|
||||
- 遗留观感问题(未改,等用户决定):投票页的「允许匿名参与」提示条是 `uk-alert-primary`,
|
||||
仍是 UIkit 浅蓝,在粉底上偏冷。改它需要「浅底 + 深主色文字」的对比度,现有 `--tb-primary-weak`
|
||||
叠白后对比度只有 ~2.6:1,得先加一个 primary-strong 令牌,故未动。
|
||||
|
||||
### 顶栏配色再修(v=20261019h)
|
||||
|
||||
用户反馈「探索/注册链接底色粉色,但整条顶栏是灰色,很突兀」。实测部署版:顶栏 `#fff6f9`、
|
||||
页面 `#fceef3` —— 近白横带压在饱和粉底上,肉眼就读成「白色/灰色的一条」。无头 Chrome 截图 +
|
||||
像素采样确认不是 UIkit 默认 #f8f8f8 回退(那说明变量没生效,现象会连带整页失效)。
|
||||
- sakura 浅色 `--tb-navbar` 直接取页面底色 `#fceef3`(深色同样 `#241a1f`),横带消失;
|
||||
- 顶栏链接 `#999` 在粉底上偏淡 → 主题内微调为 `--tb-text-secondary`,hover 用 `--tb-primary`
|
||||
(深色被 base.css 的 `!important` 规则接管,不受影响);
|
||||
- 顺带 `uk-alert-primary`(匿名参与提示/空列表)的 UIkit 浅蓝是页面上唯一的冷色块,
|
||||
改为 `--tb-primary-weak` 底 + `#a63a5e` 文字(≈5.3:1)。
|
||||
- 以上三条都写在 sakura.css 的「组件微调」段,只对 sakura 生效,默认主题零影响。
|
||||
|
||||
## 主题「砂狼白子 / shiroko」(v=20261019i)
|
||||
|
||||
- `static/css/themes/shiroko.css`:灰蓝银白令牌(浅 #edf2f8 / 主色 #4a7fc1),
|
||||
组件微调只有两条 —— `.uk-card-default` 右下角水印立绘(`background-size: auto 176px`,纯背景层不改布局)、
|
||||
顶栏链接色提到 `--tb-text-secondary`;深色不铺立绘(浅色构图在深色下会糊)。
|
||||
- 素材 `static/images/themes/shiroko/`(68KB):`bg-desktop.webp`(2400×1350) / `bg-mobile.webp`(900×1600) /
|
||||
`hero-fade.webp`(275×620,16% alpha) / `pattern.svg` / `pattern-dark.svg`。
|
||||
- **`static/fs.go` 的 embed 清单加了 `images/themes`**(目录项,子目录自动覆盖)——
|
||||
这是「新增顶层静态目录必须手工登记」的又一次踩点。
|
||||
- 背景是 PIL 合成的:1280 行的竖向渐变 + 白色径向光晕 + 立绘 30% 贴右下 + 狼 10% / 光环 20% / 纸飞机 18%。
|
||||
实测 mean≈244、std≈9;右侧留白区 std 13(立绘可见),中间 std 1.9(正文区干净)。
|
||||
- ⚠️ **`cover` 会按视口裁剪**:竖屏/超宽屏会把贴在图片最右的立绘裁掉,所以立绘不能太靠边 ——
|
||||
位置要留在图片横向 24%~76% 之外就得配 `@media` 换图(本次桌面 16:9 + 移动 9:16 两张)。
|
||||
- 生成素材(ImageGen)8 张里只有 3 张可用:线稿图案(狼/光环/纸飞机)抠白底干净;
|
||||
2 张人像抠底失败(背景不是纯白,`background: transparent` 实际没生效),2 张场景背景几乎没有内容(std<5)。
|
||||
教训:**没有视觉能力时,生成图要用 `mean/std/edge` 先验内容量**,别直接拿去合成。
|
||||
- 素材来源与版权写在 `docs/theme-shiroko.md`(角色版权 NEXON / Yostar,非商用同人)。
|
||||
- 启用需要在 `conf/app.ini` 的 `[theme] available` 里加 `shiroko`(示例配置已更新)。
|
||||
|
||||
## shiroko 插画重做:动物线稿 → Q 版人物(v=20261019k)
|
||||
|
||||
用户反馈「简笔画不是画动物,是人物形象」,插画层整体重做。
|
||||
|
||||
- 新增 `dev/gen_shiroko_chibi.py`:手写 SVG 生成 6 个 Q 版白子(wave/cheer/think/run/doze/peek),
|
||||
3.8KB/张,造型逐项对齐用户立绘(灰发蘑菇头 + 圆钝狼耳 + 倾斜光环 + 青围巾 + 深蓝外套 + 灰裙 + 青黑球鞋)。
|
||||
**生成模型这条路走不通**:`background: transparent` 不生效、无视觉反馈时无法判断好坏;
|
||||
代码画路径反而可控、体积小、任意尺寸不糊。
|
||||
- ⚠️ **cover 必然裁掉贴边插画**:1280×1400 视口下,2400×1350 的背景图只露出中部 1234px,
|
||||
右下立绘被裁成 38px 一条。→ base.css 新增装饰层令牌 `--tb-deco / --tb-deco-size / --tb-deco-position`
|
||||
(固定像素 + 四角定位 + fixed,不随视口比例变化)。
|
||||
- ⚠️ **多背景层的值个数必须恒定**:deco 先写成 4 个值,background-image 变 6 层而 size/position 只有 3 个,
|
||||
CSS 循环取值 → 整页重复小人。改成固定 6 个槽位(1 图案 + 6 装饰 + 1 背景 = 8 层),
|
||||
主题必须写满 6 个(缺的填 none),这条写进了 `_scaffold.css`。
|
||||
- 删掉 `bg-desktop.webp` / `bg-mobile.webp` / `hero.webp` / `dev/gen_shiroko_bg.py`;
|
||||
整页底色改成纯 CSS 渐变(不用图片,也就没有 cover 缩放差异)。
|
||||
- 深色关掉 deco(浅色线稿在深色底上糊成灰块);pattern 里的爪印换成 Q 版头像,
|
||||
opacity 0.42→0.2、tile 240→320(原来铺得太密,像壁纸)。
|
||||
- 验证:默认主题背景仍 `#f7f8fa`、顶栏纯白、卡片纯白(零回归);箱主页与 `/user` 后台四角装饰都不压内容;
|
||||
移动端只留立绘 + peek。
|
||||
|
||||
## shiroko 插画二次返工:手绘 SVG → 参考立绘图生图(v=20261019l)
|
||||
|
||||
用户反馈上次的简笔画"太抽象"。根因判断:用户的立绘是**平涂无描边色块风**,
|
||||
我用手绘线稿去配它,风格本身就冲突,而且没有真人手感,形准也做不到。
|
||||
|
||||
- 新方案:ImageGen **图生图**(`image1` = `static/images/shiroko-cutout.png`,`input_fidelity: high`),
|
||||
提示词统一为「same character, same art style... flat vector illustration, no outlines」+ 换动作句,
|
||||
生成 4 个姿态(wave / cheer / think / doze),1024×1024、纯白背景。
|
||||
**四张角色特征与画风一致**,这一步是手绘做不到的。
|
||||
- 抠图:四角 + 四边中点 8 点 floodfill(容差 16)+ alpha 1px MinFilter 收边,裁 bbox 后统一高 620、
|
||||
WebP q90。实测无白边,光环中间的孔也正确透出。
|
||||
- 删除 `dev/gen_shiroko_chibi.py` 与 6 个手绘 SVG;`chibi/*.webp` 4 张共 106KB。
|
||||
图案里的手绘小头像也删了(和位图人物并排显得最抽象),改成雪花 + 光环 + 圆环 + 点簇。
|
||||
- ⚠️ **deco 的 `center` 定位在矮视口会撞车**:右下立绘 520px 高,1366×768 下从 y=248 一直到底,
|
||||
托腮放 `right center` 会直接站在立绘头顶。百分比是「(视口-图高)×pct」,改用 `top 46%` 并挪到左侧中部。
|
||||
验证视口:1366×768 / 1440×900 / 1280×1400 三档,人物互不重叠。
|
||||
- docs 补了完整提示词骨架、动作句与抠图参数,说明"必须平涂无描边"这条经验。
|
||||
|
||||
## shiroko 布局重排 + 新坐姿(v=20261019m)
|
||||
|
||||
用户反馈:左右不协调、最上边的头被顶栏挡住。
|
||||
- 布局改为**左右各两个**(左上 wave / 左下 sit,右上 cheer / 右下立绘),
|
||||
`≥920px` 真实视口才补中间两个(think / doze),矮屏不放(否则压到立绘)。
|
||||
- 顶部定位从百分比改成**固定 `top 96px`**:顶栏实际高 60px(不是 80),
|
||||
百分比是「(视口-图高)×pct」,小视口下算出来的值会落进顶栏里。
|
||||
`bottom 0` 也改成 `bottom 6px`,避免脚贴页底。
|
||||
- 新增坐姿(盘腿),ImageGen 图生图第 2 次才可用:第 1 张脚被画到画布底边(底部 78 像素贴边),
|
||||
提示词补 `centered composition, whole body fully visible with generous empty margin` 后正常。
|
||||
- ⚠️ **Chrome headless 的 `--window-size` ≠ CSS 视口**:实测 1080 → innerHeight 985、768 → 673
|
||||
(差约 95px 的窗口边框)。所以 `@media (min-height: 1050px)` 在 1080p 下永远不触发。
|
||||
判断媒体查询门槛要按「真实视口」写,截图验证必须加 `--force-device-scale-factor=1`,
|
||||
否则系统 DPI 缩放会让截图尺寸和 window-size 也对不上(曾出现 1920 请求出 1088 宽)。
|
||||
- git:仓库可用(origin = https://git.shiroko.one/tamakyi/tamabox,main),
|
||||
已本地提交 `0c39aff`,但 `credential.helper=store` 里的凭据过期 → `Failed to authenticate user`。
|
||||
打包 `../TamaBox-v4-mainline-20261019-2037.tar.gz`(1.5M,含图片素材)供直接部署。
|
||||
|
||||
### git push 补推
|
||||
|
||||
上次的 `Failed to authenticate user` 是临时态 —— 用户本机 Git for Windows 认证过之后 store 凭据恢复,
|
||||
本轮 `git push origin main` 直接成功(`38cd41a..0c39aff`),`git rev-list --left-right --count origin/main...HEAD` = 0 0。
|
||||
远端 8 个 shiroko 素材文件齐全。教训:**不要用一次 push 失败就断言无凭据**,先看一眼提交是否已存在、再试一次。
|
||||
|
||||
### 部署后「设置页找不到 shiroko」
|
||||
|
||||
线上实测:`/static/css/themes/shiroko.css?v=20261019m` 200 / 5038B 且与本地逐字节一致,
|
||||
说明二进制确实带了新主题;但 `/user/profile` 渲染出来的主题单选只有 `""`、`default`、`sakura`
|
||||
→ **根因是 `[theme] available` 白名单没登记 shiroko**(列表在 `theme.load()` 里按配置构建,启动时读一次)。
|
||||
- `internal/theme/theme.go` 新增 `reportRegistry()` / `discover()`:启动打 info「主题注册表已加载 enabled=...」,
|
||||
并把「css 在、但没登记」的主题以 warning 打出建议的 available 值。
|
||||
没有这段时,这个故障在日志里完全没有痕迹,只能靠猜。
|
||||
`discover()` 用 `fs.Glob(static.FS, "css/themes/*.css")`,用 slugRe 过滤掉 `_scaffold.css`(下划线开头)。
|
||||
- `conf/app.sample.ini`:`available = default,sakura,shiroko` + 补充「没登记就不显示」的注释。
|
||||
- `docs/theme-shiroko.md` 补「部署后看不到主题」两段排查(先 curl css 看 200/404,再看 available)。
|
||||
- 排查口径:**先分「文件没发布」与「没登记」两类**,别一上来怀疑缓存;`StaticVersion` 只影响缓存,不影响列表。
|
||||
|
||||
### shiroko 装饰返工:散射 → 左下角一组(v=20261019n)
|
||||
|
||||
用户反馈「主题正常了,但展示方式怪怪的」。实测线上 1512×982 截图确认三处:
|
||||
1. 四角各一个人 = 贴纸墙纸(人物之间、人物与内容都没有关系);
|
||||
2. 半透明立绘(16%)叠在实心人物旁边 → 虚实混搭,像重影;
|
||||
3. 右下角人物和右对齐的页脚文案(社区规则 / 备案号 / 存活时长)挤在一起。
|
||||
改法:只留左下角一组(sit 330 + wave 190,`left 0/bottom 0`),删掉 hero-fade,
|
||||
纹样减到雪花 + 光环 + 两点(opacity .16、tile 340),卡片补 1px 冷调描边(box-shadow 不占布局)。
|
||||
990px 以下缩小左移,900px 以下(卡片占满宽)不出人物。
|
||||
|
||||
⚠️ **`background-position` 的四值语法必须以边关键字开头**:写成 `max(8px, calc(50% - 555px)) bottom 0`
|
||||
整条声明作废并回落到左上角(表现为人物跑到顶上、背景出现错位色带)。
|
||||
要贴卡片边只能 `left max(8px, calc(50% - 555px)) bottom 0`。
|
||||
|
||||
### 纹样横向条纹(v=20261019o)
|
||||
|
||||
图案元素原先全在 tile 上半部(雪花 y=52 / 光环 62 / 点 150、176),铺开后在整页形成
|
||||
**横向条纹带**(左栏 60px 带宽均值 245→237→249,跳变 12),这是"看着怪"的第二个来源。
|
||||
改成 3 行均布(52 / 132 / 152-212)后收敛为单调 245→236。
|
||||
⚠️ 图片不走 `StaticVersion` 缓存键(`Cache-Control: max-age=86400`),换图案要自己给 url 加 `?v=`。
|
||||
|
||||
### 定稿:半隐在卡片后的单人(v=20261019p)
|
||||
|
||||
用户从 A/B/C/D 四版里选了 D(半隐在卡片后),并追加两条:人物离开底边一点、纹样再淡一点。
|
||||
- `sit` 单人,`left max(6px, calc(50% - 515px)) bottom 26px`;宽度 177px,右缘压进卡片 40px。
|
||||
- **离底边后必须配接触阴影**:一层 `radial-gradient(ellipse, rgba(63,96,138,.22), transparent 72%)`
|
||||
128×15px,垫在脚下一格(deco 第 2 个槽位,绘制顺序在人物之下)。没有它,抬高 26px 会读成悬空。
|
||||
- 纹样 opacity 0.13→0.085(深色 0.09→0.055);`?v=20261019p` 同步改。
|
||||
- 剩余素材(wave/cheer/think/doze)保留但不上页面,换姿态只改 `--tb-deco` 的 url。
|
||||
|
||||
### shiroko 按二次元官网重做:整页场景 + 悬浮磨砂 UI(v=20261019q)
|
||||
|
||||
用户反馈「还是很怪,去网上学一下布局」。实际去看站:
|
||||
- 蔚蓝档案 JP 首页(bluearchive.jp,截图存 outputs/ref/bluearchive_jp_.png):**整屏一张场景插画,
|
||||
logo/按钮/横幅浮在画面上**,没有"白卡片"这层东西;
|
||||
- marshmallow-qa / peing 这类真正的提问箱服务被 Cloudflare 拦,拿不到参照。
|
||||
结论:前几版都卡在"白底 + 留白摆人物"这个结构上,人物没有环境,怎么调位置都像贴纸。
|
||||
|
||||
改法:生成教室场景(ImageGen,1536×1024)→ 裁掉底部 160px 水印 → 混白 38% → 模糊 1.4px →
|
||||
WebP 1920×1080(22KB)/ 720×1003 竖版(6KB);卡片与顶栏改 `rgba(255,255,255,.86)` +
|
||||
`backdrop-filter: blur(16px)`;人物 340px 站在左侧地面、离底 36px + 椭圆接触阴影;浅色不再叠纹样。
|
||||
深色仍用渐变 + pattern-dark(白天教室压在深底上会脏)。
|
||||
|
||||
⚠️ **`ruleBody` 必须剥 CSS 注释**:令牌前写一行注释时,`tokenValue` 按 ";" 切完再按 ":" 取键,
|
||||
会拿到 `/* ... */ --tb-surface`,色块预览静默变空、`TestThemeCSSComplete` 报"缺少令牌"。
|
||||
已加 `commentRe` + `TestRuleBodyIgnoresComments`。
|
||||
⚠️ ImageGen 出的图右下角有 `AI生成 WORKBUDDY` 水印,用作整页背景必须裁掉。
|
||||
|
||||
## 23:40 人像换成「窗边背影」
|
||||
|
||||
- 用户否掉了盘腿坐的正面人像("和背景没那么突兀的")。根因:正面直视 + 无环境 + 饱和度高,
|
||||
在压白 38% 的空教室里就是一块贴纸。
|
||||
- 新素材 `chibi/window.webp`(135×460, 13KB):文生图出侧后方 3/4 视角(双手背在身后)→ 再
|
||||
image-to-image 转"纯色块无描边 + 去窗框"→ 抠白底 → **RGB 压白 16%** → 高 460 / q86。
|
||||
第一步文生图会把窗框画进背景,抠不干净,必须靠第二步重绘。
|
||||
- CSS:`--tb-deco` 首槽换 window.webp,高 380→400px,接触阴影 96×13 复位到脚底;`StaticVersion` → `20261019r`
|
||||
(图片另带 `?v=20261019r`)。删除 `sit.webp`;`docs/theme-shiroko.md` 补两步做法与摆位说明。
|
||||
- 提交 `e95671f` 已推送。教训:**人像与场景的"雾度"要对齐**——场景压白多少,人像也压相当比例。
|
||||
|
||||
@@ -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 包链接,不能「先转义再正则」否则 " 被吞进 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)→ 只把 `&` 转 `&`;
|
||||
`action="...?{{ .B }}"`(值是查询片段)→ `%3d`/`%26`。**以后 query 一律在 handler 拼完整 URL。**
|
||||
- 修法:Questioner 里生成 `AnswerAction` / `NextUnreadAction`(完整 URL),模板直接引用;
|
||||
守门 `templates/question_action_test.go`(断言无 `%3d`/`%26` 且有 `&sort=new`)。
|
||||
- 顺带确认(线上):单题页「回答并进入下一条未读」跳转、页脚「下一条未读」跳转、
|
||||
全部读完的「没有未读提问了」提示 —— 三条行为都正确,`Location` 带 `from=admin&read=unread&sort=new`。
|
||||
- 断言坑:页面上有 `/user/questions`(导航)和 `/user/questions?from=admin&…`(页脚返回)两个链接,
|
||||
用 `re.search` 取第一个会误判「返回丢参数」。要按 href 全量匹配。
|
||||
|
||||
## 部署后复验:14/14 通过(含「日志重复」排障)
|
||||
|
||||
- 复验结果:`answer?from=admin&read=unread&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。
|
||||
@@ -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%(切口平直,正好贴页面下沿)。
|
||||
@@ -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`),不影响用例。
|
||||
@@ -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` 已清理)。
|
||||
@@ -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>`),缺口只在跳转参数。
|
||||
Reference in New Issue
Block a user