From adcc42cc8cb2f7ad25a6355403707feae1947e16 Mon Sep 17 00:00:00 2001
From: tamakyi
Date: Tue, 15 Sep 2026 11:06:09 +0800
Subject: [PATCH] fix anwser token and database issus
---
.workbuddy/memory/2026-09-10.md | 69 ++++++++
.workbuddy/memory/2026-09-11.md | 42 +++++
.workbuddy/memory/2026-09-15.md | 252 ++++++++++++++++++++++++++++
.workbuddy/memory/MEMORY.md | 66 +++++++-
dev/gofmt_keep_crlf.py | 63 +++++++
internal/captcha/math.go | 20 +--
internal/conf/conf.go | 21 +--
internal/conf/limits.go | 66 +++-----
internal/conf/static.go | 79 ++++-----
internal/db/conversation.go | 3 +-
internal/db/db.go | 16 +-
internal/db/feature_control.go | 86 ++++------
internal/db/follow_ups.go | 34 ++--
internal/db/questions.go | 10 +-
internal/db/upload_images_test.go | 10 +-
internal/db/users.go | 26 ++-
internal/db/users_test.go | 74 ++++++--
internal/form/form.go | 44 ++---
internal/i18n/i18n.go | 10 +-
internal/mail/mail.go | 7 +-
internal/route/csrf_binding_test.go | 26 +--
internal/route/gzip.go | 40 ++---
internal/route/route.go | 82 ++++-----
internal/security/censor/aliyun.go | 166 ++++++------------
internal/security/censor/censor.go | 63 ++++---
internal/security/censor/policy.go | 99 +++--------
internal/security/censor/text.go | 46 ++---
internal/shared/ip.go | 66 +++-----
internal/storage/s3.go | 10 +-
internal/template/markdown.go | 37 ++--
route/question/follow_up.go | 2 +-
route/user/harassment.go | 47 ++----
route/user/vote.go | 131 ++++++---------
33 files changed, 1011 insertions(+), 802 deletions(-)
create mode 100644 .workbuddy/memory/2026-09-11.md
create mode 100644 .workbuddy/memory/2026-09-15.md
create mode 100644 dev/gofmt_keep_crlf.py
diff --git a/.workbuddy/memory/2026-09-10.md b/.workbuddy/memory/2026-09-10.md
index 86852c0..beb5104 100644
--- a/.workbuddy/memory/2026-09-10.md
+++ b/.workbuddy/memory/2026-09-10.md
@@ -381,3 +381,72 @@ RAM 子账号授予 `AliyunYundunGreenWebFullAccess`;②CHANGELOG 条目仍未
`route/user/moderation.go`、`vote.go`),因为 gofmt 把 CRLF 规范成 LF。
剥离 CR 后比对,本次改动**零格式差异** → 格式是对的。
**切勿跑 `gofmt -w`**,那会把文件转成 LF、造成全量 diff 污染。
+
+---
+
+## 第七轮:精简注释(删复述、留 WHY)
+
+用户反馈「注释太多」,要求仅保留重要部分。原则(AskUserQuestion 已确认):
+**保守尺度** —— 删除复述型/装饰型,保留全部安全约束、踩坑、外部服务怪癖,长注释压成 1-3 行。
+
+### 摸底数据(脚本 `comment_survey.py`,排除 outputs/dev/.workbuddy)
+
+- `.go` 139 文件 29,185 行,注释 3,102 行(10.6%);`.html` 41 文件 7,784 行,注释 152 行(2.0%)。
+- 最重:`policy.go` 38.9%、`conf/limits.go` 37.8%、`shared/ip.go` 33.0%、`conf/static.go` 27.8%、
+ `harassment.go` 25.2%、`aliyun.go` 24.7%(152 行,绝对量最大)、`text.go` 22.2%、`censor.go` 21.3%、
+ `form.go` 19.8%、`gzip.go` 19.3%、`markdown.go` 18.1%、`route.go` 17.8%(80 行)。
+
+### 第一批已完成(-106 行,-31%)
+
+| 文件 | 原 | 现 | 减 |
+|---|---|---|---|
+| `policy.go` | 84 | 52 | -32(-38%)|
+| `conf/limits.go` | 45 | 25 | -20(-44%)|
+| `shared/ip.go` | 58 | 32 | -26(-45%)|
+| `aliyun.go` | 152 | 124 | -28(-18%,**仅处理到前 180 行,余下待续**)|
+
+保留的关键信息(勿再删):fail-closed 语义、严格档 ⊇ 改造前、底线维度不可关、
+阿里云 596/600 字上限与分段重叠、格式标记防历史缓存误判「通过」、SDK 标签表不可信、
+IPv6 /64 聚合仅限流不可用于黑名单、源站未锁可被伪造头、双 CDN 必须删对方权威头。
+
+### ⚠️ 复用方法(重要)
+
+1. **删注释会触发 gofmt 重排**:删掉字段/映射项之间的注释后,相邻行合并成一个对齐组,
+ 列宽会变。手算列宽易错 → 用脚本 `gofmt_keep_crlf.py `:
+ **跑 gofmt 后按原文件行尾还原 CRLF**(直接 `gofmt -w` 会把 CRLF 转成 LF,造成全量 diff 污染)。
+2. **仓库行尾本就不统一**(`policy.go`/`aliyun.go`/`text.go`/`dict_*`/`profile.html` 是 CRLF;
+ `limits.go`/`features.go`/`password.go` 是 LF)。改动前先确认,改后保持一致。
+3. `grep -c $'\r' file` 在 Git Bash 下**不可靠**(`$'\r'` 可能被当空模式,导致「全部行命中」)。
+ 查行尾要用 Python 数 `b'\r\n'`。
+4. `templates/user/moderation-strictness.html` 仍**只能用 shell/脚本改**(Write/Edit 会注入
+ `data-page-node-id`)。
+
+### 验证
+
+`go build ./internal/security/... ./internal/shared/... ./internal/conf/...` 通过;
+`go test ./internal/security/censor/` ok(严格度与 2.0 用例全过,确认删注释未影响行为)。
+
+---
+
+## 续:注释清理第二批 + 编译卡死根因(晚间)
+
+### 第二批已清理(本轮继续)
+
+`conf/static.go`、`conf/conf.go`、`security/censor/{text,censor,aliyun}.go`、`route/{gzip,route}.go`、
+`form/form.go`、`template/markdown.go`、`db/{db,feature_control}.go`、`captcha/math.go`、
+`route/user/harassment.go`、`route/user/vote.go`(最大,~149→少,N+1→2SQL、sanitizeCSVCell、BOM 等 WHY 保留)、
+`db/follow_ups.go`(部分,待构建释放后收尾)、`security/censor/policy.go`(最高比 28%→压缩,保 WHY)、
+`i18n/i18n.go`(删内联复述、修 typo)。
+
+### 编译卡死根因(已定位并绕过)
+
+- 真因:`proxy.golang.org` 在本沙箱 `HTTP=000`(连接超时),`go build` 触网即无限等待 → 表象「卡死」。
+ `goproxy.cn` → 200。解法:`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod` + **定向包构建**(勿 `./...`)。
+- `go env -w` 失败(`%AppData% is not defined`),无法持久化,只能每条命令 export。
+- 重活儿构建(`./route/user/ ./internal/route/ ./internal/db/`,overlay 换 s3 stub 避 cgo/webp)已用上述代理跑通、进行中(1C1G 首编 20+ 分钟)。
+
+### 行尾结论
+
+`core.autocrlf=true` → 工作区 CRLF、blob LF,Git 归一,**无 diff 污染**,不必手动转行尾。
+`gofmt_keep_crlf.py` 重建到 `dev/gofmt_keep_crlf.py`(Temp 版被清),保留工作区 CRLF 以免 gofmt -l 误报。
+
diff --git a/.workbuddy/memory/2026-09-11.md b/.workbuddy/memory/2026-09-11.md
new file mode 100644
index 0000000..c07c996
--- /dev/null
+++ b/.workbuddy/memory/2026-09-11.md
@@ -0,0 +1,42 @@
+# 2026-09-11 工作日志
+
+## 代码审阅(全库,安全/正确性/可优化)
+
+结论:**无 P0、无 P1 可利用漏洞**;仅 P2(一致性 / 健壮性 / 性能)。
+
+### 已验证「没问题」的项(勿重复排查)
+
+1. **越权**:所有写接口均有归属校验 —— `getOwnedVote` 显式比对 `vote.UserID != ctx.User.ID`(vote.go:388-401);
+ question 路由挂在 `question.Questioner` 中间件组(route.go:262-274),`PublishAnswer` 二次校验 `question.UserID`;
+ follow-up 写操作以 `question.ID` 为作用域(`AnswerByID`/`UpdateAnswerByID`)。profile/password/harassment/tags/features 一律用 `ctx.User.ID`。
+2. **SQL 注入**:`internal/db` 全部用 `?` 绑定参数;拼接仅出现在常量/配置(dedupeUniqueFields、tagCond、randomFunc)。
+3. **XSS**:`internal/template/markdown.go` 白名单消毒扎实 —— strip `on*`、禁 `style`、`safeURL` 协议白名单
+ (http/https/mailto/相对)、拒 `//` 协议相对、强制 `rel="noopener noreferrer"`。
+4. **竞态**:follow-up 用事务 + `FOR UPDATE` 行锁;投票「一人一票」用复合唯一索引。
+5. **GORM bool 默认值陷阱**:已规避(`Enabled` 不加 `gorm:"default:true"`,见 feature_control.go:79-81),
+ 且 `reconcileLegacyChatEnabled`(:609-618)修复旧 backfill 脏行。
+6. **审核缓存回放无 fail-open**:`AliyunTextCensorParser`(aliyun.go:433-461)与 `QiniuTextCensorParser`
+ (qiniu.go:133-170)都会从 raw 重建 `Details`,故 `Decide` 仍能按当前档位裁决(改档位对历史缓存同样生效)。
+ ⚠️ text.go:66 注释称「回放缓存结果时为 nil」**是错的**,需修正。
+
+### P2 清单(待用户选批后落地)
+
+| # | 文件:行 | 问题 | 建议 |
+|---|---|---|---|
+| 1 | censor.go:72,111,118-125 | 缓存按**原文** `text` 查,实际送审 `RemoveTrustedURL(text)`,落库 `Input` 存原文 → 审计与实际送审不一致 + 归一化相同文本重复调 API | 归一化一次,查找/送审/落库统一用归一化文本,原文另存字段 |
+| 2 | censor.go:85-102 | qiniu 缓存命中仅 `Pass` 才返回,aliyun 命中即返回(不看 Pass)→ 依赖「aliyun 恒为链尾」隐式前提 | 两分支语义对齐,或显式固化链尾不变量 |
+| 3 | text.go:66 | 注释失真(称回放时 Details 为 nil,实际会重建) | 修正注释 |
+| 4 | route/vote/vote.go:814-822 | 配额用「插入后计数」判定,非原子条件插入 → 并发可短暂超额(有界、自动结束自愈) | 改 `WHERE count 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-`,测试结束(未失败时)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` 占用的 `i@github.red`/`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` 反复报未格式化。**注释里避免使用 `''`**(改写为「空字符串」等文字)。
diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md
index 2c0bbce..5dfda01 100644
--- a/.workbuddy/memory/MEMORY.md
+++ b/.workbuddy/memory/MEMORY.md
@@ -16,10 +16,39 @@
go test -overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json" ./...
```
(`dev/overlay.json` 里的路径指向 TAMAKY~1 短名,可能失效;用上面 Temp 里的那份。)
-- `internal/db` 的测试需要真实 PostgreSQL/MySQL(否则报 `Unknown database type: ""`),
- 与业务改动无关,可跳过;`go vet` 已能覆盖其编译。
-- **`proxy.golang.org` 在本沙箱返回 Bad Gateway**:拉新依赖 / `go mod tidy` 前先
- `export GOPROXY=https://goproxy.cn,direct`。
+- `internal/db` 测试需要真实 PostgreSQL/MySQL(否则报 `Unknown database type: ""`)。
+ **用户本地有测试 PG**:`DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=15207786347hXPm DB_HOST=127.0.0.1 DB_PORT=5432`
+ (DSN 形如 `postgres://user:pwd@host:port?sslmode=disable`,不带库名;`NewTestDB` 会自建
+ `tamabox-test-` 并在成功时 DROP)。跑法:
+ ```
+ DB_TYPE=postgres DB_USER=postgres DB_PASSWORD= DB_HOST=127.0.0.1 DB_PORT=5432 \
+ go test -overlay= -count=1 ./internal/db/
+ ```
+ ✅ 2026-09-15 起 `go test ./...`(带 DB)**全绿**。此前该套件的一大堆失败**全是测试自身缺陷**
+ (bcrypt 直接比哈希、测试数据撞车、顶层 `t.Parallel()` + 共享表 + `TRUNCATE ... RESTART IDENTITY`、
+ cleanup 漏清 `upload_image_questions`),已修。
+- ⚠️ **`internal/db` 包级 store 单例(`Users`/`Questions`/`FollowUps`/`UploadImages`/`FeatureControls`…)
+ 只在 `db.Init` 装配**。若某 store 方法内部直接调这些全局单例,测试不装配 → **nil panic**。
+ 2026-09-15 已把 `DeleteByID`、`MarkDeletedByOwner` 改为 `NewXStore(db.DB)`(用自身 store 的 `*gorm.DB`)。
+ **新增此类跨 store 调用时务必用 `NewXStore(db.DB)`,不要引用包级全局。** `feature_control.go` 的
+ `FeatureStateOf` 是唯一例外(有 nil 守卫,返回默认配置)。
+- **「编译卡死」真因 = `proxy.golang.org` 不可达**:本沙箱 `curl -m10 proxy.golang.org` → `HTTP=000`(连接超时,rc=28),
+ 而 `goproxy.cn` → `HTTP=200`。任何触及网络的 `go build/test/mod` 会**无限等待代理超时**,表象就是「卡死」。
+ `go env` 默认 `GOPROXY=https://proxy.golang.org,direct` → 必卡。
+ ✅ 解法(每条 go 命令前都要):`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod`。
+ ⚠️ **「整库编译慢 / 卡死」是伪命题**:只要 GOPROXY 正确,`go build ./...` 实测 **EXIT=0**,
+ `./route/user/` 只要 **7 秒**(缓存热时全库也是秒级)。此前 20+ 分钟/240s 超时**全是代理挂起**。
+ ⚠️ **1C1G 是用户的正式生产环境,不是沙箱** —— 禁止用「沙箱慢/环境限制」解释任何超时(2026-09-15 用户纠正)。
+ ⚠️ 方法论:**超时 ≠ 慢**。先用 `go list -deps `(纯元数据、不编译)拆开「模块解析」与「编译」
+ 两层;前者 2.3s 说明模块在缓存,问题就必在别处。
+ ⚠️ `go env -w` 在本沙箱**会失败**(`cannot find go env config: %AppData% is not defined`,Git Bash 无该变量),
+ 无法持久化,只能每条命令 export。GROUP1(security/censor、conf、form、template、shared、captcha)已验证 EXIT=0(~10s)。
+- **行尾:仓库是「混合行尾」,不是统一 CRLF**(`core.autocrlf=true`,提交 blob 为 LF,Git 自动归一,**不污染 diff**)。
+ 实测:`internal/db/*`、`internal/mail/mail.go` 是 **LF**;`route/**`、`internal/route/route.go`、
+ `internal/db/users.go` 是 **CRLF**。→ 编辑时**保持各文件原行尾**,不要整体转换。
+ `gofmt -l` 会把 CRLF 文件**误报**为未格式化(false positive)。
+ ✅ 改注释/字段后统一用 `dev/gofmt_keep_crlf.py `(原 Temp 版被清,已重建到 `dev/`):跑 gofmt 后按原文件行尾还原,
+ 既应用格式化又保留工作区 CRLF、避免 gofmt -l 噪声。(`grep -c $'\r'` 在 Git Bash 不可靠,查行尾用 Python 数 `b'\r\n'`。)
- `internal/security/censor/censor.go`、`internal/security/censor/text_test.go` 存在**升级前就有**的
空格缩进(`gofmt -l` 会列出)。除非专门整理,否则不要顺手 gofmt,避免污染 diff。
@@ -56,6 +85,28 @@
否则「审核失败」与「真的违规」在日志里无法区分。
- ⚠️ `SetContentType(requests.Json)` 已加,但**已证伪与上述故障无关**,保留原因仅是接口契约对齐。
+## 表单校验(勿重复造轮子)
+
+- **长度/必填校验已在表单层完成**:字段的 `valid:"required;maxlen:N"` 标签由
+ `internal/form/form.go:131` 的 `govalid.Check()` 在 `Bind()` 时执行,handler 顶部用
+ `ctx.HasError()` 承接(见 `route/user/profile.go:76`、`route/user/harassment.go:30`)。
+ 报错文案通过标签的 `msg:` 指向 i18n 键(如 `form.profile.name` / `form.profile.linkMax`)。
+- ⚠️ **因此不要再在 handler 里手写一套长度校验**:既会成为永不触发的死代码(超限早在 Bind 阶段就被拒),
+ 又容易与真实上限冲突(profile 实际为:Name≤20、Intro≤100、各 link≤30,而非想当然的 50/500/500)。
+ 2026-09-15 曾据此误判 P2「profile 缺长度校验」并写了一版,已回滚。
+- 判断某字段是否已有校验:**先看 `internal/form/*.go` 的 struct 标签**,不要只看 handler 代码。
+
+## GORM 约定
+
+- **字符串列默认值必须带引号**:`gorm:"default:'none'"`(裸写 `default:none` 会生成非法 SQL)。
+- `default:` 标签会让 GORM 在 **Create** 时把该字段的**零值替换为默认值**
+ (`feature_control.go:79` 有 ⚠️ 记录,曾导致「取消勾选保存后库里仍是 true」)。
+ 因此「空串有语义」的字段不要加 `default:`(如 `ModerationStrictness` 用空串表示「跟随站点默认」)。
+ `Updates(map[string]interface{}{...})` 不受此影响(只写指定键)。
+- **启动期「历史数据归一」的既有惯例**:在 `db.Init` 的 `AutoMigrate` 之后追加幂等函数
+ (`backfillFeatureControls` / `reconcileLegacyChatEnabled` / `normalizeChatQuota` / `normalizeHarassmentSetting`),
+ 失败仅告警、不阻断启动。新增此类归一照此模式即可。
+
## 文件工具陷阱(重要)
- `templates/user/moderation-strictness.html`:**用 Write/Edit 写它会被自动注入 `data-page-node-id` 属性**
@@ -63,6 +114,13 @@
→ **该文件今后只用 shell 修改**。相关断言(`templates/render_test.go`)已改成不依赖属性顺序的正则。
- 往 gofmt 对齐组**中间**插 map 键,会把「拆分后剩余组」整组重新对齐 → 污染 diff。
新键应插在组末尾或独立成组(前后留空行)。i18n 字典已因此返工过一次。
+ 同理:**在结构体/字面量对齐组中间插入 `//` 注释行会打断对齐组**,使相邻行被重新对齐;
+ 要加说明就放在该组**末尾**(下一个注释区之前),或改用行尾注释。
+ 验证方法:`tr -d '\r' < f.go > a.go; gofmt a.go > b.go; diff a.go b.go`(无输出=不会重排)。
+- ⚠️ **gofmt 会把「文档注释」里的 `''` 规范化成 `”`**(Go 1.19+ doc comment 重排:`` `` ``→`“`、`''`→`”`),
+ 于是 `gofmt -l` 反复报未格式化。**注释里不要用 `''`**,改写为「空字符串」等文字。
+- ⚠️ `gofmt` 是 **Windows 原生程序,看不到 MSYS 的 `/tmp` 路径**(报 `GetFileAttributesEx ... cannot find`)。
+ 临时比对文件要放到 Windows 可见路径(如 `C:/Users//AppData/Local/Temp/...`)。
## 仓库状态
- **git 仓库已损坏**(`.git/refs` 缺失、index 截断、对象库近乎空),用户明确要求「不要动 git」。
diff --git a/dev/gofmt_keep_crlf.py b/dev/gofmt_keep_crlf.py
new file mode 100644
index 0000000..f0745b0
--- /dev/null
+++ b/dev/gofmt_keep_crlf.py
@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+# dev/gofmt_keep_crlf.py
+#
+# 运行 gofmt -w,但保留文件原始行尾(CRLF / LF),避免 gofmt 把 CRLF 文件整文件
+# 重写成 LF 造成的万行级 diff 污染。
+#
+# 用法:
+# python dev/gofmt_keep_crlf.py [file2 ...]
+# git diff --name-only | grep '\.go$' | xargs python dev/gofmt_keep_crlf.py
+#
+# 实现要点:先把源文件按 LF 写入临时副本交给 gofmt,gofmt 输出也是 LF;
+# 若原文件是 CRLF,再把输出整体换回 CRLF 写回,从而只保留真实的格式化改动。
+import os
+import sys
+import subprocess
+
+GOFMT = r"C:/Program Files/Go/bin/gofmt"
+
+
+def fmt_one(path):
+ with open(path, "rb") as f:
+ data = f.read()
+ crlf = b"\r\n" in data
+
+ tmp = path + ".gofmt.tmp"
+ with open(tmp, "wb") as f:
+ f.write(data.replace(b"\r\n", b"\n"))
+ try:
+ r = subprocess.run([GOFMT, "-w", tmp], capture_output=True)
+ if r.returncode != 0:
+ sys.stderr.write(
+ "gofmt FAILED on %s:\n%s\n" % (path, r.stderr.decode("utf-8", "replace"))
+ )
+ return False
+ with open(tmp, "rb") as f:
+ out = f.read()
+ finally:
+ try:
+ os.remove(tmp)
+ except OSError:
+ pass
+
+ if crlf:
+ out = out.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n")
+ with open(path, "wb") as f:
+ f.write(out)
+ return True
+
+
+def main():
+ files = [a for a in sys.argv[1:] if a.strip()]
+ if not files:
+ sys.stderr.write("usage: gofmt_keep_crlf.py [file ...]\n")
+ return 2
+ ok = True
+ for p in files:
+ if not fmt_one(p):
+ ok = False
+ return 0 if ok else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/internal/captcha/math.go b/internal/captcha/math.go
index d6c396c..fb63bd0 100644
--- a/internal/captcha/math.go
+++ b/internal/captcha/math.go
@@ -27,10 +27,8 @@ import (
)
const (
- // Session key prefix for storing math captcha answers
sessionKeyPrefix = "math_captcha_"
- // TTL for each captcha in session
- captchaTTL = 5 * time.Minute
+ captchaTTL = 5 * time.Minute
// maxCaptchaAttempts 同一条验证码被尝试(含模态框预校验)的最大次数,
// 超过即销毁该验证码,迫使刷新,防止对答案做无限轮询爆破。
maxCaptchaAttempts = 5
@@ -52,7 +50,6 @@ type StoredAnswer struct {
// GenerateMath creates a new arithmetic captcha problem and stores the answer in session.
func GenerateMath(s session.Session) (*MathProblem, error) {
- // Generate random operands
op1, err := rand.Int(rand.Reader, big.NewInt(90))
if err != nil {
return nil, errors.Wrap(err, "generate operand 1")
@@ -64,7 +61,6 @@ func GenerateMath(s session.Session) (*MathProblem, error) {
a := int(op1.Int64()) + 1 // 1-90
b := int(op2.Int64()) + 1 // 1-90
- // Pick a random operator (+, -, *)
opIdx, err := rand.Int(rand.Reader, big.NewInt(3))
if err != nil {
return nil, errors.Wrap(err, "generate operator")
@@ -90,18 +86,16 @@ func GenerateMath(s session.Session) (*MathProblem, error) {
answer = a * b
}
- // 生成唯一 ID
idBytes := make([]byte, 8)
if _, err := rand.Read(idBytes); err != nil {
return nil, errors.Wrap(err, "generate captcha id")
}
id := hex.EncodeToString(idBytes)
- // 清理策略说明:flamego session 接口不支持遍历,无法主动清理过期条目。
- // 泄漏可控:验证码一次性消费(VerifyMath 校验后立即删除),
- // 且每个用户同一时刻只生成 1 个新 key;session 本身过期后存储层整体清理。
+ // 清理策略:flamego session 不支持遍历,无法主动清理过期条目。但验证码一次性消费
+ // (校验后立即删除)、每用户同时刻只生成 1 个新 key,session 过期后存储层整体清理,
+ // 泄漏可控。
- // Store answer in session
key := sessionKeyPrefix + id
s.Set(key, StoredAnswer{
Answer: answer,
@@ -136,14 +130,12 @@ func VerifyMath(s session.Session, token string, lang string) (bool, error) {
return false, errors.New(i18n.T(lang, "captcha.errAnswerFormat"))
}
- // Retrieve stored answer
key := sessionKeyPrefix + id
stored, ok := s.Get(key).(StoredAnswer)
if !ok {
return false, errors.New(i18n.T(lang, "captcha.errExpired"))
}
- // Check expiry
if time.Since(stored.CreatedAt) > captchaTTL {
s.Delete(key)
return false, errors.New(i18n.T(lang, "captcha.errExpired"))
@@ -186,8 +178,8 @@ func VerifyOnly(s session.Session, id string, answer int) bool {
return subtle.ConstantTimeCompare([]byte(strconv.Itoa(answer)), []byte(strconv.Itoa(stored.Answer))) == 1
}
-// sessionLang 从会话读取匿名用户显式选择的语言(与 Contexter 一致,语言偏好现托管于
-// session 而非独立 lang cookie,避免被 session 中间件的 Set-Cookie 覆盖);未设置时回退默认。
+// sessionLang 从会话读取匿名用户显式选择的语言(语言偏好托管于 session 而非独立 lang
+// cookie,避免被 session 中间件的 Set-Cookie 覆盖);未设置时回退默认。
func sessionLang(s session.Session) string {
if v := s.Get("lang"); v != nil {
if str, ok := v.(string); ok && str != "" {
diff --git a/internal/conf/conf.go b/internal/conf/conf.go
index 060b5cc..5670ccc 100644
--- a/internal/conf/conf.go
+++ b/internal/conf/conf.go
@@ -12,7 +12,7 @@ import (
"gopkg.in/ini.v1"
)
-// File is the configuration object.
+// File 是解析后的配置对象。
var File *ini.File
func Init() error {
@@ -110,19 +110,14 @@ func Init() error {
}
// validateTextCensorSource 校验文本审核通道([security] text_censor_source)与凭据的搭配。
+// 抽成纯函数(只依赖入参、不读全局)以便单测覆盖,也让 Init 保持轻薄。
//
-// 单独抽成纯函数(只依赖入参、不读全局)是为了能直接被单测覆盖,也让 Init 保持轻薄。
-//
-// 规则:
-// - 审核整体关闭(enabled=false)→ 不校验,此时不需要任何凭据。
-// - 留空 / auto → 通过。auto 是历史默认(七牛优先、阿里云兜底),两者都没配凭据时
-// 维持既有行为(运行期 fail-closed),不阻断启动 —— 否则 app.sample.ini 原样拷贝
-// (enable_text_censor = true 且凭据留空)将无法启动,破坏开箱流程。
-// - 显式选 qiniu / aliyun → 必须配齐该通道的凭据,否则启动即失败。
-// 理由:缺凭据时运行期审核必然失败,而审核失败是 fail-closed(拒绝提交),
-// 会让发布/私信整站不可用,且现象隐蔽(只在日志可见)——不如启动就报错。
-// - 其它取值 → 直接报错,不做静默回退:拼写错误若被悄悄当成 auto,
-// 运维会以为「已经只用七牛了」,实际仍在走双通道。
+// - 审核整体关闭(enabled=false)→ 不校验,不需要任何凭据。
+// - 留空 / auto → 通过。auto 是历史默认(七牛优先、阿里云兜底),两者都无凭据时
+// 维持既有运行期 fail-closed 行为,不阻断启动(否则 app.sample.ini 原样拷贝无法启动)。
+// - 显式选 qiniu / aliyun → 必须配齐该通道凭据,否则启动即失败:缺凭据时运行期审核必失败,
+// 而审核失败是 fail-closed(拒绝提交),会让发布/私信整站不可用且现象隐蔽。
+// - 其它取值 → 直接报错,不做静默回退:拼写错误若被当成 auto,运维会误以为已切通道。
func validateTextCensorSource(enabled bool, source, qiniuKey, qiniuSecret, aliyunKey, aliyunSecret string) error {
if !enabled {
return nil
diff --git a/internal/conf/limits.go b/internal/conf/limits.go
index 8c089a2..5cd81ae 100644
--- a/internal/conf/limits.go
+++ b/internal/conf/limits.go
@@ -23,62 +23,39 @@ const (
// LimitsConfig 全站内容 / 数量上限([limits] 配置段)的字段集合。
//
-// 过去这些上限以常量形式散落在 internal/db(MaxTags / MaxCustomTextLen 等)与
-// 路由层硬编码(私聊单条 2000 字、会话条数 clamp 5~200 等)中,调整需改代码重编译,
-// 且前后端、i18n 文案各自携带一份数值容易漂移。统一收口到 [limits] 段后:
-// - 后端校验、模板注入、前端提示全部引用 conf.Limits 的同一份值;
-// - 调整仅需修改 conf/app.ini 并重启进程,无需改代码;
-// - 长度类上限统一按字符(rune / Unicode 码点)计数。
+// 收口到本段前,这些上限散落在 internal/db 与路由层硬编码,改一次要重编译且前后端数值易漂移。
+// 现后端校验 / 模板注入 / 前端提示统一引用同一份值,调整只需改 app.ini 重启;
+// 长度类上限一律按字符(rune)计数。
type LimitsConfig struct {
- // ── 标签与屏蔽词(账号设置页)──
-
- // 标签个数上限
- MaxTags int `ini:"max_tags"`
- // 单个标签最大字符数
- MaxTagLen int `ini:"max_tag_len"`
- // 屏蔽词个数上限
- MaxBlockWords int `ini:"max_block_words"`
- // 单个屏蔽词最大字符数
+ MaxTags int `ini:"max_tags"`
+ MaxTagLen int `ini:"max_tag_len"`
+ MaxBlockWords int `ini:"max_block_words"`
MaxBlockWordLen int `ini:"max_block_word_len"`
- // ── 投票 ──
-
- // 参与者填写的「自定义补充选项」文本最大字符数。
- // 覆盖两类入口:单选/多选/排序的全局补充选项,以及表单收集字段的
- // 「其他/自定义选项」(field_{id}_custom)。
+ // 参与者填写的「自定义补充选项」文本最大字符数(含表单字段的「其他」)。
CustomAnswerLen int `ini:"custom_answer_len"`
- // 站长创建投票时单个「选项」文本的最大字符数。
- // 覆盖单选/多选/排序的预设选项,以及表单收集字段(select/radio/checkbox/rank)
- // 的预置选项。与 CustomAnswerLen 是两条独立规则(站长侧预设刻意放宽),可各自调参。
+ // 站长创建投票时单个「选项」文本最大字符数。与 CustomAnswerLen 是两条独立规则
+ // (站长侧预设刻意放宽),可各自调参。
VoteOptionLen int `ini:"vote_option_len"`
- // ── 提问 / 回答 / 追问 ──
-
- // 提问正文最大字符数(访客向提问箱提交的问题内容)。
- // 旧实现依赖 govalid 的 maxlen 标签(编译期写死 2000),无法随配置调整,
- // 且与前端 maxlength=1000 不一致,现已移到路由层按本值做 rune 校验。
+ // 提问正文最大字符数。旧实现靠 govalid maxlen 写死 2000 且与前端 1000 不一致,
+ // 现改为路由层按本值做 rune 校验。
QuestionContentLen int `ini:"question_content_len"`
- // 回答正文最大字符数:箱主对提问的正式回答,以及箱主对追问的回复(共用)。
- AnswerContentLen int `ini:"answer_content_len"`
- // 追问正文最大字符数(提问者在问题被回答后继续追问的内容)。
+ // 回答正文最大字符数:箱主对提问的正式回答,以及对追问的回复(共用)。
+ AnswerContentLen int `ini:"answer_content_len"`
FollowUpContentLen int `ini:"followup_content_len"`
- // ── 私聊 ──
-
// 单条消息(发送 / 回复共用)最大字符数
ChatMessageLen int `ini:"chat_message_len"`
- // 每个私聊会话的消息条数上限(系统硬顶)。用户只能在 1..该值 之间设置自己的
- // chat_max_messages;新注册用户的默认值即该硬顶(而非固定 20)。
+ // 每个会话的消息条数硬顶。用户只能在 1..该值间设置 chat_max_messages,
+ // 新用户默认值即该硬顶(而非固定 20)。
ChatMaxMessages int `ini:"chat_max_messages"`
- // 单用户每日私信条数上限(用户可设范围 0..该值;0 表示不限制每日条数)。
- // 该值只是「用户可设置的上限」,不是站点强制的每日配额。
+ // 每日私信条数的「用户可设上限」(非站点强制配额):用户可设 0..该值,0 表示不限制。
DailyMessageLimitMax int `ini:"daily_message_limit_max"`
}
-// Limits 保存运行时的全部上限配置。conf.Init 中会先 MapTo [limits] 段覆盖显式键,
-// 再调用 sanitizeLimits 把 <1 的值回退到内置默认,因此:
-// - 缺失 [limits] 段或个别键 → 使用内置默认;
-// - ini 键留空(int 解析为 0)或误填 0/负数 → 自动回退默认,不会把上限静默置零。
+// Limits 保存运行时的全部上限配置。conf.Init 先 MapTo [limits] 段,再由 sanitizeLimits
+// 把 <1 的值回退内置默认,因此缺段、缺键、留空(解析为 0)或填负数都不会让上限静默置零。
var Limits = LimitsConfig{
MaxTags: defaultMaxTags,
MaxTagLen: defaultMaxTagLen,
@@ -94,10 +71,9 @@ var Limits = LimitsConfig{
DailyMessageLimitMax: defaultDailyMessageLimitMax,
}
-// sanitizeLimits 归一化 [limits] 配置:所有上限最小为 1(0 / 负数说明未配置或手滑,
-// 一律回退内置默认,避免「留空=0」把标签全拒、消息全超长等隐性灾难)。
-// 注意 DailyMessageLimitMax 同样要求 >=1:0 会让用户侧永远只能设为 0(=不限制),
-// 语义上等于关闭每日配额功能,需显式使用该字段其余语义时请另行设计。
+// sanitizeLimits 归一化 [limits]:所有上限最小为 1,0 / 负数一律回退内置默认,
+// 避免「留空=0」把标签全拒、消息全超长等隐性灾难。
+// DailyMessageLimitMax 同样要求 >=1:为 0 会让用户只能设为 0(=不限制),等于关闭每日配额功能。
func sanitizeLimits(l *LimitsConfig) {
clamp1 := func(p *int, def int) {
if *p < 1 {
diff --git a/internal/conf/static.go b/internal/conf/static.go
index d26b94e..4845383 100644
--- a/internal/conf/static.go
+++ b/internal/conf/static.go
@@ -4,24 +4,17 @@
package conf
-// Build time and commit information.
-//
-// ⚠️ WARNING: should only be set by "-ldflags".
+// ⚠️ 仅应由 "-ldflags" 注入。
var (
BuildTime string
BuildCommit = "dev"
)
-// 文本审核服务通道取值([security] text_censor_source)。
-//
-// 定义在 conf 侧是为了让「配置校验」(conf.Init)与「通道装配」
-// (internal/security/censor)引用同一份字面量,避免两边各写一份字符串而漂移。
+// 文本审核通道取值([security] text_censor_source)。定义在 conf 侧,
+// 让配置校验(conf.Init)与通道装配(internal/security/censor)共用同一份字面量。
const (
- // CensorSourceAuto 七牛优先、阿里云兜底(历史行为,留空同义)。
- CensorSourceAuto = "auto"
- // CensorSourceQiniu 只用七牛。
- CensorSourceQiniu = "qiniu"
- // CensorSourceAliyun 只用阿里云。
+ CensorSourceAuto = "auto" // 七牛优先、阿里云兜底(历史行为,留空同义)
+ CensorSourceQiniu = "qiniu"
CensorSourceAliyun = "aliyun"
)
@@ -47,43 +40,32 @@ var (
IPBlacklist string `ini:"ip_blacklist"`
IPBlacklistRedirectURL string `ini:"ip_blacklist_redirect_url"`
// TrustProxy 为 true 时信任 CDN 权威客户端头 CF-Connecting-IP / EO-Client-IP
- // (以及受信代理设置的 X-Real-IP / X-Forwarded-For);否则一律使用 RemoteAddr,
- // 防止客户端伪造 header 绕过速率限制与 IP 黑名单。
+ // (以及受信代理设置的 X-Real-IP / X-Forwarded-For);否则一律用 RemoteAddr,
+ // 防止伪造 header 绕过速率限制与 IP 黑名单。
TrustProxy bool `ini:"trust_proxy"`
- // TrustedProxies 逗号分隔的 IP / CIDR 列表,仅限“你自己的”反向代理/负载均衡基础设施
- // (例如本机 nginx 的 127.0.0.1、私有网络 LB)。仅当 TCP 对端落在该清单内时才会解析
- // X-Forwarded-For / X-Real-IP。留空则这两个头永不被信任(CDN 权威头 CF-Connecting-IP /
- // EO-Client-IP 仍会被采用,前提是源站已被对应 CDN 锁定)。
- // 目的:避免维护 Cloudflare / EdgeOne 等上游 CDN 的庞大 CIDR 清单——源站应配合
- // Cloudflare Tunnel / Authenticated Origin Pull / 防火墙仅放行 CDN 回源网段锁定,
- // 使 CF-Connecting-IP / EO-Client-IP 成为权威客户端 IP。
+ // TrustedProxies 逗号分隔的 IP / CIDR,仅限自己的反向代理 / LB。仅当 TCP 对端
+ // 落在该清单内才解析 X-Forwarded-For / X-Real-IP;留空则这两个头永不被信任
+ // (CDN 权威头仍会采用,前提是源站已由 Cloudflare Tunnel / 回源网段锁定,
+ // 从而免于维护上游 CDN 的庞大 CIDR 清单)。
TrustedProxies string `ini:"trusted_proxies"`
- // 自动封禁(fail2ban 风格):检测批量扫描/探测特征后临时封禁来源 IP。
- EnableAutoBan bool `ini:"enable_auto_ban"`
- // BanTrackNotFound 是否把「未注册路径的 404」计入封禁统计。默认 false:正常业务
- // (域名/题目不存在等均走 302 业务跳转,不产生计数)一律放行,仅命中后台扫描特征
- // 路径(KindSensitive)才参与统计封禁。避免共享出口(CDN 边缘 / CGNAT NAT444)下
- // 正常用户被 404 探测流量连坐误伤。如需恢复对纯 404 字典扫描的封禁再置 true。
+ EnableAutoBan bool `ini:"enable_auto_ban"` // fail2ban 风格:检测到扫描特征后临时封禁来源 IP
+ // BanTrackNotFound 是否把未注册路径的 404 计入封禁统计。默认 false:仅命中后台
+ // 扫描特征路径(KindSensitive)才统计,避免共享出口(CDN 边缘 / CGNAT)下
+ // 正常用户被 404 探测流量连坐误伤。
BanTrackNotFound bool `ini:"ban_track_notfound"`
BanThresholdSensitive int `ini:"ban_threshold_sensitive"` // 窗口内敏感路径命中次数
BanThreshold404 int `ini:"ban_threshold_404"` // 窗口内 404 次数
BanWindowSeconds int `ini:"ban_window_seconds"` // 统计窗口(秒)
BanDurationMinutes int `ini:"ban_duration_minutes"` // 封禁时长(分钟)
- // DefaultModerationStrictness 站点默认的「内容审核严格度」档位
- // (lax / standard / strict)。仅对未单独设置档位的账号生效:
- // 账号一旦显式选择了档位就以账号设置为准(见 moderation.StrictnessFor)。
- // 留空或非法值回退 strict(最严档)—— 保证升级不会放宽任何拦截;
- // 严格档拦截面不小于升级前,部署后默认拦截面可能略有扩大;
- // 若希望默认更少误伤,在本段显式写 standard 即可,无需改代码。
+ // DefaultModerationStrictness 站点默认审核严格度(lax / standard / strict),
+ // 仅对未单独设置档位的账号生效(见 moderation.StrictnessFor)。留空或非法值回退
+ // strict(最严档),保证升级不会放宽拦截;默认拦截面可能较升级前略有扩大,
+ // 希望更少误伤则显式写 standard。
DefaultModerationStrictness string `ini:"default_moderation_strictness"`
- // TextCensorSource 文本审核服务通道,取值:
- // qiniu —— 只用七牛(凭据见 [app] qiniu_access_key / qiniu_access_secret)
- // aliyun —— 只用阿里云(走内容安全 2.0 / 文本审核增强版 TextModeration;
- // 开通只需实名认证,个人账号可用。凭据见 [app] aliyun_access_key / aliyun_access_key_secret)
- // auto(默认,留空同义)—— 保持历史行为:七牛优先,未通过时用阿里云二次复核
- // 仅显式选定的通道会被调用;选定通道缺少凭据时启动即报错(见 conf.Init),
- // 避免运行时审核必然失败 —— 审核失败是 fail-closed(拒绝提交),
- // 会让发布/私信整站不可用,且现象隐蔽(只在日志里)。
+ // TextCensorSource 文本审核通道:qiniu / aliyun(内容安全 2.0 TextModeration,
+ // 个人实名账号即可开通)/ auto(默认,留空同义:七牛优先、阿里云复核)。
+ // 仅显式选定的通道会被调用,缺凭据时启动即报错(见 conf.Init)——
+ // 审核失败是 fail-closed(拒绝提交),会让发布与私信整站不可用且现象隐蔽。
TextCensorSource string `ini:"text_censor_source"`
SecurityAlertEmail string `ini:"security_alert_email"` // 触发封禁时告警收件人(留空不发送)
}
@@ -142,12 +124,11 @@ var (
}
Mail struct {
- Account string `ini:"account"`
- Password string `ini:"password"`
- Port int `ini:"port"`
- SMTP string `ini:"smtp"`
- // SkipTLSVerify 跳过 SMTP TLS 证书校验(仅自签名/内网 SMTP 需要,默认 false 校验证书)
- SkipTLSVerify bool `ini:"skip_tls_verify"`
+ Account string `ini:"account"`
+ Password string `ini:"password"`
+ Port int `ini:"port"`
+ SMTP string `ini:"smtp"`
+ SkipTLSVerify bool `ini:"skip_tls_verify"` // 仅自签名 / 内网 SMTP 需要
}
Service struct {
@@ -155,8 +136,6 @@ var (
Prefix string `ini:"prefix"`
ForwardURL string `ini:"forward_url"`
}
- // TLSInsecureSkipVerify 跳过反向代理到后端服务的 TLS 证书校验
- // (仅内网后端使用自签名证书时需要,默认 false 校验证书)
- TLSInsecureSkipVerify bool `ini:"tls_insecure_skip_verify"`
+ TLSInsecureSkipVerify bool `ini:"tls_insecure_skip_verify"` // 仅内网自签名后端需要
}
)
diff --git a/internal/db/conversation.go b/internal/db/conversation.go
index a2b4fe2..3541609 100644
--- a/internal/db/conversation.go
+++ b/internal/db/conversation.go
@@ -223,7 +223,8 @@ func (db *conversations) MarkDeletedByOwner(ctx context.Context, id uint) error
// 回退定量收集计数:删除私聊会话等价于少一条已收集内容,
// 否则达上限后即便删掉旧会话,功能仍会显示 quota_reached 关闭,须手动重置计数。
if conv.OwnerID != 0 {
- if err := FeatureControls.IncrementQuota(ctx, conv.OwnerID, FeatureChat, -1); err != nil {
+ // 用本 store 的 *gorm.DB 构造,避免依赖包级全局单例(详见 DeleteByID 注释)。
+ if err := NewFeatureControlsStore(db.DB).IncrementQuota(ctx, conv.OwnerID, FeatureChat, -1); err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to decrement chat quota for user %d", conv.OwnerID)
}
}
diff --git a/internal/db/db.go b/internal/db/db.go
index 4b2b955..03a4f02 100644
--- a/internal/db/db.go
+++ b/internal/db/db.go
@@ -42,21 +42,15 @@ func Init(typ, dsn string) (*gorm.DB, error) {
return nil, errors.Wrap(err, "connect to database")
}
- // 配置连接池
sqlDB, err := db.DB()
if err != nil {
return nil, errors.Wrap(err, "get sql.DB from gorm")
}
- // 1C1G 低配环境:调低连接池,避免每个 DB 连接占用过多内存。
- // 提问箱是低并发场景,10 个打开连接绰绰有余。
- // 最大打开连接数
+ // 1C1G 低配环境:调低连接池避免单连接占用过多内存。提问箱低并发,10 个打开连接绰绰有余。
sqlDB.SetMaxOpenConns(10)
- // 最大空闲连接数
sqlDB.SetMaxIdleConns(4)
- // 连接最大存活时间(避免长连接被数据库服务端断开)
- sqlDB.SetConnMaxLifetime(5 * time.Minute)
- // 空闲连接最大存活时间
+ sqlDB.SetConnMaxLifetime(5 * time.Minute) // 避免长连接被数据库服务端断开
sqlDB.SetConnMaxIdleTime(3 * time.Minute)
// 仅在明确开启迁移清理开关时处理 users 表历史重复值,避免服务启动时静默改写账号数据。
@@ -89,6 +83,12 @@ func Init(typ, dsn string) (*gorm.DB, error) {
logrus.WithError(err).Warn("私聊收集上限历史数据清理时异常(可在功能管理页保存一次私聊设置)")
}
+ // 防骚扰设置归一:历史账号 harassment_setting 为空串(建表时无默认值),与 none 语义等价
+ // 但取值不同,会导致按 == none 判断时漏判。幂等,失败仅告警。
+ if err := normalizeHarassmentSetting(db); err != nil {
+ logrus.WithError(err).Warn("防骚扰设置历史数据归一时异常(可在防骚扰设置页保存一次)")
+ }
+
// 启动期补充高频查询复合索引(幂等,适合既有库)。
// 仅 PostgreSQL 需要:这些索引针对服务端真实查询模式(按归属用户+私密+时间、
// 按 asker、按展示/投票状态+可见性+时间、按会话/发送者+时间)。
diff --git a/internal/db/feature_control.go b/internal/db/feature_control.go
index cde98a1..3085f58 100644
--- a/internal/db/feature_control.go
+++ b/internal/db/feature_control.go
@@ -66,29 +66,23 @@ type featureControls struct {
*gorm.DB
}
-// FeatureControl 提问箱单个功能(提问 / 私聊 / 投票)的开关、定时与定量收集配置。
+// FeatureControl 单个功能(提问 / 私聊 / 投票)的开关、定时与定量收集配置。
+// Enabled 为手动总开关;OpenAt / CloseAt 为一次性定时(nil 表示不限该侧);
+// QuotaLimit / QuotaCount 为定量收集(0 表示不限制)。最终状态由 Evaluate 综合计算。
//
-// 三类控制互不覆盖,最终可用状态由 Evaluate 综合计算:
-// - Enabled:手动总开关;
-// - OpenAt / CloseAt:定时开放与定时关闭(nil 表示不限制该侧);
-// - QuotaLimit / QuotaCount:定量收集,累计收集数达到上限后自动关闭(0 表示不限制)。
-//
-// 计数不做定时重置:一轮收集结束后由箱主在功能管理页手动「重置计数」开启下一轮,
-// 避免与定时开关叠加产生难以预期的自动恢复行为。
+// 计数不做定时重置:一轮结束后由箱主手动「重置计数」,避免与定时开关叠加产生
+// 难以预期的自动恢复行为。
type FeatureControl struct {
dbutil.Model
UserID uint `gorm:"uniqueIndex:idx_feature_control_user_feature;not null" json:"-"`
Feature string `gorm:"size:20;uniqueIndex:idx_feature_control_user_feature;not null" json:"feature"`
- // ⚠️ 不要给 Enabled 加 gorm:"default:true" 标签:
- // GORM 在 Create 时会把零值 false 替换为字段默认值(true),导致「取消勾选保存」后
- // 数据库里仍是 true、复选框自动恢复为勾选。去掉标签后 GORM 会原样写入显式设置的值。
- Enabled bool `json:"enabled"`
- // OpenAt / CloseAt:一次性定时开放 / 关闭(nil 表示不限制该侧)。
+ // ⚠️ 不要给 Enabled 加 gorm:"default:true":GORM 在 Create 时会把零值 false 替换为
+ // 默认值 true,导致「取消勾选保存」后库里仍是 true、复选框自动恢复勾选。
+ Enabled bool `json:"enabled"`
OpenAt *time.Time `json:"open_at"`
CloseAt *time.Time `json:"close_at"`
- // 循环定时(每周循环):ScheduleDays 为逗号分隔的周几(0=周日…6=周六,与 time.Weekday 一致),
- // 空表示每天都开放;ScheduleStart/ScheduleEnd 为每日时间段 "HH:MM"(留空表示该日全天)。
- // 两者都为空时表示未启用循环定时。
+ // 循环定时(每周):ScheduleDays 为逗号分隔的周几(0=周日…6=周六),空表示每天都开放;
+ // ScheduleStart/ScheduleEnd 为每日时间段 "HH:MM"(留空表示全天)。两者皆空即未启用。
ScheduleDays string `gorm:"size:31" json:"schedule_days"`
ScheduleStart string `gorm:"size:5" json:"schedule_start"`
ScheduleEnd string `gorm:"size:5" json:"schedule_end"`
@@ -178,8 +172,8 @@ func (fc *FeatureControl) scheduleDayOpen(day time.Time) bool {
return fc.ScheduleDays == "" || containsWeekday(fc.ScheduleDays, int(day.Weekday()))
}
-// scheduleWindow 返回单日开放窗口的分钟数 [startM, endM)。
-// 未设置时间段时视为全天 [0, 1440)。配置非法时返回错误(调用方按"不限制"处理)。
+// scheduleWindow 返回单日开放窗口的分钟数 [startM, endM)。未设置时间段时视为全天;
+// 配置非法时返回错误(调用方按「不限制」处理)。
func (fc *FeatureControl) scheduleWindow() (startM, endM int, err error) {
if fc.ScheduleStart == "" && fc.ScheduleEnd == "" {
return 0, 24 * 60, nil
@@ -342,9 +336,8 @@ func startOfDay(t time.Time) time.Time {
return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
}
-// DefaultFeatureControl 返回某功能的默认配置(尚未落库时的行为)。
-// 提问与投票默认开启(与历史行为一致);私聊默认关闭,沿用 users.allow_chat 的默认值,
-// 避免历史账号在功能上线后被动开启私聊。
+// DefaultFeatureControl 返回某功能的默认配置(尚未落库时的行为)。提问与投票默认开启
+// (与历史行为一致);私聊默认关闭,沿用 users.allow_chat 默认值,避免历史账号被动开启。
func DefaultFeatureControl(userID uint, feature string) *FeatureControl {
return &FeatureControl{
UserID: userID,
@@ -353,8 +346,8 @@ func DefaultFeatureControl(userID uint, feature string) *FeatureControl {
}
}
-// FeatureStateOf 便捷入口:读取某用户某功能在当前的综合状态。
-// 查询失败或无存储时回退到默认配置,保证单个依赖表异常不会让整站功能不可用。
+// FeatureStateOf 读取某用户某功能当前的综合状态。查询失败或无存储时回退默认配置,
+// 保证单个依赖表异常不会让整站功能不可用。
func FeatureStateOf(ctx context.Context, userID uint, feature string) FeatureState {
if FeatureControls == nil {
return DefaultFeatureControl(userID, feature).Evaluate(time.Now())
@@ -433,7 +426,7 @@ type UpdateFeatureControlOptions struct {
QuotaLimit int64
}
-// Save 写入功能配置。使用 ON CONFLICT 幂等写入,并显式列出更新列,
+// Save 写入功能配置。用 ON CONFLICT 幂等写入并显式列出更新列,
// 避免把 quota_count(已收集数量)误重置为 0。
func (db *featureControls) Save(ctx context.Context, userID uint, feature string, opts UpdateFeatureControlOptions) error {
now := time.Now()
@@ -544,13 +537,12 @@ func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feat
// backfillFeatureControls 为尚未初始化的历史账号补齐功能配置行。
//
-// 背景:私聊开关此前落在 users.allow_chat,提问/投票则一直默认开启。功能上线后若没有
-// 对应的配置行,Get 会回退到默认值(私聊默认关闭),导致已开启私聊的历史账号被静默关闭。
-// 因此在 AutoMigrate 之后按 users.allow_chat 回填私聊开关,其余功能按"开启"回填。
+// 私聊开关此前落在 users.allow_chat,提问/投票一直默认开启;功能上线后没有配置行时
+// Get 会回退默认值(私聊默认关闭),导致已开启私聊的历史账号被静默关闭,
+// 故在 AutoMigrate 之后按 users.allow_chat 回填私聊,其余按「开启」回填。
//
-// 幂等:每轮只挑出仍缺配置行的账号,写完后进入下一轮,直到没有遗漏。
-// 分批 + ON CONFLICT DO NOTHING,避免"边插入边查同一张表"在不同数据库上的差异行为,
-// 并保证并发启动(多实例同时启动)时不会重复插入。
+// 幂等:每轮只挑仍缺行的账号,分批 + ON CONFLICT DO NOTHING,避免不同数据库上
+// 「边插入边查同一张表」的差异行为,也保证多实例并发启动时不重复插入。
func backfillFeatureControls(db *gorm.DB) error {
// backfillBatchSize 每轮处理的账号数,控制单次事务体积与内存占用。
const backfillBatchSize = 500
@@ -616,28 +608,20 @@ func backfillFeatureControls(db *gorm.DB) error {
// reconcileLegacyChatEnabled 修复旧版 backfill 写入的私聊错误开关。
//
-// 背景:早期版本 FeatureControl.Enabled 带 gorm:"default:true" 标签,backfill 为
-// allow_chat=false 的用户补私聊行时,false 被 GORM 替换为默认值 true,导致私聊被被动开启;
-// 且 UpdateChatSettings 会把 users.allow_chat 反向同步为 true,形成持久化。
+// 背景:早期 Enabled 带 gorm:"default:true",backfill 为 allow_chat=false 的用户补行时
+// false 被替换为 true,私聊被被动开启;UpdateChatSettings 还会把 allow_chat 同步为 true。
//
-// 纠偏条件(必须同时满足,只命中"纯 backfill 默认行",绝不碰箱主配置过的行):
-// - feature = 'chat' 且 enabled = true;
-// - 对应 users.allow_chat = false(箱主从未在面板开启私聊——面板开启会同步 allow_chat=true);
-// - 该行无任何自定义配置(open_at / close_at 为空、quota_limit = 0、循环定时为空);
-// - updated_at <= created_at(从未被面板保存过,updated_at 未变化)。
-//
-// 满足条件即复位 feature_controls.enabled = false,并同步 users.allow_chat = false。
-// 幂等:每轮只处理仍满足条件的行,直到没有遗漏;已复位/已配置的行不会重复命中。
+// 纠偏条件(必须同时满足,只命中「纯 backfill 默认行」,绝不碰箱主配置过的行):
+// feature='chat' 且 enabled=true;对应 users.allow_chat=false;该行无任何自定义配置
+// (open_at/close_at 为空、quota_limit=0、循环定时为空);updated_at <= created_at。
+// 满足即复位 enabled=false 并同步 users.allow_chat=false。幂等,已配置行不会重复命中。
func reconcileLegacyChatEnabled(db *gorm.DB) error {
// reconcileBatchSize 每轮处理的账号数,控制单次事务体积。
const reconcileBatchSize = 500
- // pureDefaultFilterFC / pureDefaultFilterPlain 只匹配纯默认配置的私聊行
- // (无一次性窗口/循环定时/定量上限)。两者仅列前缀不同:
- // - FC 版带 "fc." 前缀,用于 SELECT(feature_controls fc 别名场景);
- // - 纯版不带前缀,用于 UPDATE(GORM 不会为 UPDATE 自动加别名,带前缀会报
- // missing FROM-clause entry for table "fc")。
- // 同时兼容旧行 schedule 列为 NULL 与新行空串两种情况。
+ // 两者只匹配纯默认配置的私聊行(无一次性窗口/循环定时/定量上限),仅列前缀不同:
+ // FC 版带 "fc." 用于 SELECT;纯版不带前缀用于 UPDATE(GORM 不会为 UPDATE 自动加别名,
+ // 带前缀会报 missing FROM-clause entry for table "fc")。同时兼容旧行 NULL 与新行空串。
pureDefaultFilterFC := "fc.open_at IS NULL AND fc.close_at IS NULL AND fc.quota_limit = 0" +
" AND (fc.schedule_days IS NULL OR fc.schedule_days = '')" +
" AND (fc.schedule_start IS NULL OR fc.schedule_start = '')" +
@@ -703,11 +687,9 @@ func reconcileLegacyChatEnabled(db *gorm.DB) error {
}
// normalizeChatQuota 清零私聊行上历史残留的收集上限与计数。
-//
-// 背景:私聊不提供收集上限能力(会话是双向对话,限制「新建会话数」意义不大),
-// UI 已隐藏该字段、保存时强制清零;此处在启动期做一次性兜底,把旧版本
-// 设置过的私聊上限/计数统一清零,避免达到残留上限后私聊「莫名关闭」。
-// 幂等:重复执行不产生任何变更,失败由调用方告警(不阻断启动)。
+// 私聊不提供收集上限(会话是双向对话,限制「新建会话数」意义不大),UI 已隐藏该字段、
+// 保存时强制清零;此处在启动期兜底清零,避免达到残留上限后私聊「莫名关闭」。
+// 幂等,失败由调用方告警(不阻断启动)。
func normalizeChatQuota(db *gorm.DB) error {
res := db.Model(&FeatureControl{}).
Where("feature = ? AND (quota_limit > 0 OR quota_count > 0)", FeatureChat).
diff --git a/internal/db/follow_ups.go b/internal/db/follow_ups.go
index 6e88455..0c2dbec 100644
--- a/internal/db/follow_ups.go
+++ b/internal/db/follow_ups.go
@@ -11,39 +11,31 @@ import (
)
// MaxFollowUpCount 每条提问允许的最大追问次数(防滥用)。
-// 提问者被回答后,用 token(或登录账号)继续追问,达到上限后不可再追问。
const MaxFollowUpCount = 3
-// 追问相关错误(供 handler 区分处理,避免把业务错误当 500)
+// 追问相关错误,供 handler 区分处理,避免把业务错误当 500。
var (
- // ErrFollowUpLimitReached 追问次数已达上限
- ErrFollowUpLimitReached = errors.New("follow up limit reached")
- // ErrFollowUpNotFound 追问不存在或不属于该问题
- ErrFollowUpNotFound = errors.New("follow up not found")
- // ErrFollowUpAlreadyAnswered 追问已被回答(禁止覆盖)
+ ErrFollowUpLimitReached = errors.New("follow up limit reached")
+ ErrFollowUpNotFound = errors.New("follow up not found")
ErrFollowUpAlreadyAnswered = errors.New("follow up already answered")
)
var FollowUps FollowUpsStore
type FollowUpsStore interface {
- // CreateIfBelowLimit 原子创建追问:仅当该问题追问次数未达上限时创建。
- // 返回 created=false 且 err==ErrFollowUpLimitReached 表示已达上限。
- // 通过事务内锁定问题行,串行化同一问题的并发追问,消除 count-then-create 竞态。
+ // CreateIfBelowLimit 原子创建追问:仅当该问题追问次数未达上限时创建;
+ // 事务内锁定问题行串行化并发追问,消除 count-then-create 竞态。已达上限返回 (false, ErrFollowUpLimitReached)。
CreateIfBelowLimit(ctx context.Context, opts CreateFollowUpOptions) (created bool, err error)
GetByQuestionID(ctx context.Context, questionID uint) ([]*FollowUp, error)
// GetLatestByQuestionID 返回某条提问最新的一条追问;无追问时返回 (nil, nil)。
GetLatestByQuestionID(ctx context.Context, questionID uint) (*FollowUp, error)
- // GetByQuestionIDs 批量获取多个提问的追问,按 question_id 分组返回。
- // 用于账号导出等场景,将 N 次单题查询合并为 1 次 IN 查询,避免 N+1。
+ // GetByQuestionIDs 批量获取多个提问的追问,按 question_id 分组返回(导出场景合并 N+1 为 1 次 IN 查询)。
GetByQuestionIDs(ctx context.Context, questionIDs []uint) (map[uint][]*FollowUp, error)
CountByQuestionID(ctx context.Context, questionID uint) (int64, error)
- // AnswerByID 回答某条追问;questionID 用于防止越权(必须属于该问题)。
- // 仅允许回答尚未回答的追问(answer 为空),已回答的返回 ErrFollowUpAlreadyAnswered,
- // 不存在/不属于该问题返回 ErrFollowUpNotFound。
+ // AnswerByID 回答某条追问;questionID 防越权(必须属于该问题),仅允许回答尚未回答的(answer 为空)。
+ // 已回答返回 ErrFollowUpAlreadyAnswered,不存在/不属于该问题返回 ErrFollowUpNotFound。
AnswerByID(ctx context.Context, questionID uint, id uint, answer string) error
- // UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防止越权),
- // 与 AnswerByID 不同,此处允许覆盖已回答的追问(供主人修改回复使用)。
+ // UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防越权),允许覆盖已回答的(供主人修改回复)。
// 不存在/不属于该问题返回 ErrFollowUpNotFound。
UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) error
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
@@ -120,8 +112,7 @@ func (db *followUps) GetByQuestionID(ctx context.Context, questionID uint) ([]*F
return followUps, nil
}
-// GetLatestByQuestionID 返回某条提问最新的一条追问(created_at 倒序取首条);
-// 无追问时返回 (nil, nil)。供发布追问前校验"上一条是否已回复"。
+// GetLatestByQuestionID 返回某条提问最新的一条追问(created_at 倒序);无追问时返回 (nil, nil),供发布前校验上一条是否已回复。
func (db *followUps) GetLatestByQuestionID(ctx context.Context, questionID uint) (*FollowUp, error) {
var fu FollowUp
err := db.WithContext(ctx).
@@ -169,9 +160,8 @@ func (db *followUps) CountByQuestionID(ctx context.Context, questionID uint) (in
return count, nil
}
-// AnswerByID 更新某条追问的回答内容(带 question_id 条件防止越权)。
-// 原子更新条件含 answer=”:已回答的追问不会被覆盖(避免重复提交时邮件/回答被二次写入)。
-// RowsAffected==0 时再查一次以区分"不存在"与"已回答",返回对应错误。
+// AnswerByID 更新回答(带 question_id 防越权)。
+// 原子条件含 answer=”,已回答的不被覆盖;RowsAffected==0 再查一次以区分"不存在"与"已回答"。
func (db *followUps) AnswerByID(ctx context.Context, questionID uint, id uint, answer string) error {
res := db.WithContext(ctx).
Model(&FollowUp{}).
diff --git a/internal/db/questions.go b/internal/db/questions.go
index f02a255..8a42b22 100644
--- a/internal/db/questions.go
+++ b/internal/db/questions.go
@@ -92,7 +92,7 @@ func (db *questions) Create(ctx context.Context, opts CreateQuestionOptions) (*Q
question := Question{
FromIP: opts.FromIP,
UserID: opts.UserID,
- Token: randstr.String(6),
+ Token: randstr.String(12),
Content: opts.Content,
ReceiveReplyEmail: opts.ReceiveReplyEmail,
AskerUserID: opts.AskerUserID,
@@ -401,17 +401,19 @@ func (db *questions) DeleteByID(ctx context.Context, id uint) error {
}
// 级联清理子表:追问与上传图片,避免删除提问后残留孤儿记录。
- if err := FollowUps.DeleteByQuestionID(ctx, id); err != nil {
+ // 直接用本 store 的 *gorm.DB 构造子 store,避免依赖包级全局单例
+ // (全局单例仅在 db.Init 装配,测试/部分初始化场景下为 nil 会 panic)。
+ if err := NewFollowUpsStore(db.DB).DeleteByQuestionID(ctx, id); err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to cascade delete follow ups for question %d", id)
}
- if err := UploadImages.DeleteByQuestionID(ctx, id); err != nil {
+ if err := NewUploadImagesStore(db.DB).DeleteByQuestionID(ctx, id); err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to cascade delete upload images for question %d", id)
}
// 回退定量收集计数:删除提问等价于少一条已收集内容,
// 否则达上限后即便删掉旧提问,功能仍会显示 quota_reached 关闭,须手动重置计数。
if question.UserID != 0 {
- if err := FeatureControls.IncrementQuota(ctx, question.UserID, FeatureQuestion, -1); err != nil {
+ if err := NewFeatureControlsStore(db.DB).IncrementQuota(ctx, question.UserID, FeatureQuestion, -1); err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to decrement question quota for user %d", question.UserID)
}
}
diff --git a/internal/db/upload_images_test.go b/internal/db/upload_images_test.go
index 9e783fe..b36c7d3 100644
--- a/internal/db/upload_images_test.go
+++ b/internal/db/upload_images_test.go
@@ -12,8 +12,9 @@ import (
)
func TestUploadImages(t *testing.T) {
- t.Parallel()
-
+ // 注意:各子测试都创建 question_id=1 的上传图,cleanup 会 truncate 并 RESTART IDENTITY,
+ // 并行子测试会拿到相同自增 id,撞 upload_image_questions 的复合主键
+ // (upload_image_id, question_id);故顺序执行,使每个子测试独占全表。
db, cleanup := newTestDB(t)
ctx := context.Background()
@@ -29,7 +30,10 @@ func TestUploadImages(t *testing.T) {
} {
t.Run(tc.name, func(t *testing.T) {
t.Cleanup(func() {
- if err := cleanup("upload_images"); err != nil {
+ // 必须一并清链接表:upload_image_questions 无 FK 级联依赖 upload_images,
+ // 仅 truncate upload_images 会残留 (upload_image_id, question_id) 链接行,
+ // 下一个子测试因身份重启拿到相同 id 而撞复合主键。
+ if err := cleanup("upload_images", "upload_image_questions"); err != nil {
t.Fatal(err)
}
})
diff --git a/internal/db/users.go b/internal/db/users.go
index 56631ef..94a655d 100644
--- a/internal/db/users.go
+++ b/internal/db/users.go
@@ -86,7 +86,7 @@ type User struct {
UID string `json:"-"`
Name string `json:"name"`
Password string `json:"-"`
- Email string `json:"email" gorm:"uniqueIndex:idx_users_email_unique"`
+ Email string `json:"-" gorm:"uniqueIndex:idx_users_email_unique"`
Avatar string `json:"avatar"`
Domain string `json:"domain" gorm:"uniqueIndex:idx_users_domain_unique"`
Background string `json:"background"`
@@ -105,8 +105,12 @@ type User struct {
Qrcodepdpcolor string `json:"qrcodepdpcolor"`
Backgroundimagealpha string `json:"Backgroundimagealpha"`
Notify NotifyType `json:"notify"`
- HarassmentSetting HarassmentSettingType `json:"harassment_setting"`
+ HarassmentSetting HarassmentSettingType `json:"harassment_setting" gorm:"default:'none'"`
BlockWords string `json:"-"`
+ // 防骚扰设置取值 none / register_only(见下方常量)。历史账号此列为空串(建表时无默认值),
+ // 与 none 语义等价但取值不同,会导致「按 == none 判断」的隐性漏判 → 已补 schema 默认值、
+ // Create 显式写入、启动期归一历史空值(见 db.Init 的 normalizeHarassmentSetting)。
+ //
// ModerationStrictness 内容审核严格度:lax / standard / strict。
// 空值表示「跟随站点默认」([security] default_moderation_strictness),
// 存量账号迁移后即为空值形态 —— 缺省回退 strict(最严档),
@@ -244,6 +248,7 @@ func (db *users) Create(ctx context.Context, opts CreateUserOptions) error {
Backgroundimagealpha: opts.Backgroundimagealpha,
Qrcodepdpcolor: opts.Qrcodepdpcolor,
Notify: NotifyTypeEmail,
+ HarassmentSetting: HarassmentSettingNone,
Usernamecolor: opts.Usernamecolor,
Introcolor: opts.Introcolor,
Acfunlink: opts.Acfunlink,
@@ -388,6 +393,23 @@ func (db *users) UpdateHarassmentType(ctx context.Context, id uint, typ Harassme
return nil
}
+// normalizeHarassmentSetting 把 users.harassment_setting 的历史空值(空字符串)归一为 none。
+// 空串与 none 语义等价(均表示不做限制),归一后取值唯一,避免将来按 == none 判断时漏判。
+// 幂等:无空值时不做写入;失败由调用方告警(不阻断启动)。
+func normalizeHarassmentSetting(db *gorm.DB) error {
+ res := db.Model(&User{}).
+ Where("harassment_setting IS NULL OR harassment_setting = ''").
+ Update("harassment_setting", HarassmentSettingNone)
+ if res.Error != nil {
+ return errors.Wrap(res.Error, "normalize harassment setting")
+ }
+ if res.RowsAffected > 0 {
+ logrus.WithField("count", res.RowsAffected).
+ Info("防骚扰设置已归一:历史空值已置为 none")
+ }
+ return nil
+}
+
// UpdateBlockWords 仅更新全局屏蔽词表(逗号分隔,保存前已去重/去空白/限额)。
// 只写 block_words 列,不动 harassment_setting。
func (db *users) UpdateBlockWords(ctx context.Context, id uint, words string) error {
diff --git a/internal/db/users_test.go b/internal/db/users_test.go
index dabed95..4083d55 100644
--- a/internal/db/users_test.go
+++ b/internal/db/users_test.go
@@ -11,11 +11,26 @@ import (
"github.com/stretchr/testify/require"
"gorm.io/gorm"
+
+ "github.com/tamakyi/TamaBox/internal/conf"
)
-func TestUsers(t *testing.T) {
- t.Parallel()
+// requireUserEqual 比较期望与实际的 User。
+// bcrypt 哈希每次加盐不同,无法逐字节比较:先按明文校验密码,再把哈希字段对齐后比较其余字段。
+// Create 会按 conf.Limits.ChatMaxMessages 显式写入该字段,故期望值同步取该配置默认。
+func requireUserEqual(t *testing.T, want, got *User) {
+ t.Helper()
+ require.True(t, got.Authenticate(want.Password))
+ want.Password = got.Password
+ want.ChatMaxMessages = conf.Limits.ChatMaxMessages
+ want.HarassmentSetting = HarassmentSettingNone // Create 显式写入 none
+ require.Equal(t, want, got)
+}
+func TestUsers(t *testing.T) {
+ // 注意:本测试各子测试创建用户均使用固定邮箱/域名(i@github.red / e99),
+ // 且每个子测试结束会 truncate 整张 users 表。若开启 t.Parallel(),并发子测试会在
+ // 同一张表上同时插入相同唯一键导致"邮箱已注册"误报;故顺序执行,使每个子测试独占全表。
db, cleanup := newTestDB(t)
ctx := context.Background()
@@ -122,8 +137,7 @@ func testUsersGetByID(t *testing.T, ctx context.Context, db *users) {
Intro: "Be cool, but also be warm.",
Notify: NotifyTypeEmail,
}
- want.EncodePassword()
- require.Equal(t, want, got)
+ requireUserEqual(t, want, got)
})
t.Run("not found", func(t *testing.T) {
@@ -165,8 +179,7 @@ func testUsersGetByEmail(t *testing.T, ctx context.Context, db *users) {
Intro: "Be cool, but also be warm.",
Notify: NotifyTypeEmail,
}
- want.EncodePassword()
- require.Equal(t, want, got)
+ requireUserEqual(t, want, got)
})
t.Run("not found", func(t *testing.T) {
@@ -208,8 +221,7 @@ func testUsersGetByDomain(t *testing.T, ctx context.Context, db *users) {
Intro: "Be cool, but also be warm.",
Notify: NotifyTypeEmail,
}
- want.EncodePassword()
- require.Equal(t, want, got)
+ requireUserEqual(t, want, got)
})
t.Run("not found", func(t *testing.T) {
@@ -260,8 +272,7 @@ func testUsersUpdate(t *testing.T, ctx context.Context, db *users) {
Intro: "Be cool, but also be warm!!",
Notify: NotifyTypeNone,
}
- want.EncodePassword()
- require.Equal(t, want, got)
+ requireUserEqual(t, want, got)
})
}
@@ -307,6 +318,10 @@ func testUsersUpdateBlockWords(t *testing.T, ctx context.Context, db *users) {
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
+ // 先显式设定防骚扰类型,验证 UpdateBlockWords 不会误改该列(类型列默认为空串,
+ // 与 HarassmentSettingNone 等价但值不同,故显式设定后再断言更可靠)。
+ require.Nil(t, db.UpdateHarassmentType(ctx, 1, HarassmentSettingTypeRegisterOnly))
+
err := db.UpdateBlockWords(ctx, 1, "广告,抽奖")
require.Nil(t, err)
@@ -314,7 +329,7 @@ func testUsersUpdateBlockWords(t *testing.T, ctx context.Context, db *users) {
require.Nil(t, err)
require.Equal(t, "广告,抽奖", got.BlockWords)
// 只应更新屏蔽词,类型列不受影响
- require.Equal(t, HarassmentSettingNone, got.HarassmentSetting)
+ require.Equal(t, HarassmentSettingTypeRegisterOnly, got.HarassmentSetting)
})
t.Run("empty clears words", func(t *testing.T) {
@@ -359,8 +374,7 @@ func testUsersAuthenticate(t *testing.T, ctx context.Context, db *users) {
Intro: "Be cool, but also be warm.",
Notify: NotifyTypeEmail,
}
- want.EncodePassword()
- require.Equal(t, want, got)
+ requireUserEqual(t, want, got)
}
func testUsersChangePassword(t *testing.T, ctx context.Context, db *users) {
@@ -408,9 +422,9 @@ func testUsersDeactivate(t *testing.T, ctx context.Context, db *users) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
- Email: "i@github.red",
+ Email: "deactivate@github.red",
Avatar: "avater.png",
- Domain: "e99",
+ Domain: "deactivate",
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
@@ -426,3 +440,33 @@ func testUsersDeactivate(t *testing.T, ctx context.Context, db *users) {
require.NotNil(t, err)
})
}
+
+// TestHarassmentSettingNormalize 覆盖防骚扰设置取值归一:
+// 新建用户即写入规范值 none;历史空值经启动期归一(normalizeHarassmentSetting)后也为 none。
+func TestHarassmentSettingNormalize(t *testing.T) {
+ db, _ := newTestDB(t)
+ ctx := context.Background()
+ usersStore := NewUsersStore(db)
+
+ require.Nil(t, usersStore.Create(ctx, CreateUserOptions{
+ Name: "E99p1ant",
+ Password: "super_secret",
+ Email: "normalize@github.red",
+ Avatar: "avater.png",
+ Domain: "normalize",
+ Background: "background.png",
+ Intro: "Be cool, but also be warm.",
+ }))
+
+ got, err := usersStore.GetByID(ctx, 1)
+ require.Nil(t, err)
+ require.Equal(t, HarassmentSettingNone, got.HarassmentSetting, "新建用户应为规范值 none")
+
+ // 模拟历史行:直接把列置为空串,再跑一次启动期归一。
+ require.Nil(t, db.Exec("UPDATE users SET harassment_setting = '' WHERE id = 1").Error)
+ require.Nil(t, normalizeHarassmentSetting(db))
+
+ got, err = usersStore.GetByID(ctx, 1)
+ require.Nil(t, err)
+ require.Equal(t, HarassmentSettingNone, got.HarassmentSetting, "历史空值应被归一为 none")
+}
diff --git a/internal/form/form.go b/internal/form/form.go
index df6c37f..55e4934 100644
--- a/internal/form/form.go
+++ b/internal/form/form.go
@@ -31,25 +31,18 @@ type Error struct {
}
const (
- // multipartMemoryLimit 是 multipart 解析时驻留内存的上限,超出部分由
- // mime/multipart 落盘到临时文件。
- multipartMemoryLimit = 10 << 20 // 10 MiB
+ multipartMemoryLimit = 10 << 20 // 10 MiB:multipart 内存驻留上限,超出由 mime/multipart 落盘
- // MaxRequestBodySize 是单次请求体的**总**大小上限(含落盘部分)。
+ // MaxRequestBodySize 请求体总大小上限(含落盘部分),略高于内存上限以留出开销。
//
- // ⚠️ 必须显式限制:ParseMultipartForm 的参数只约束内存缓冲,超出部分会无上限地
- // 写入临时文件。匿名可达的提问接口即接受 multipart,若不加总量限制,攻击者可持续
- // POST 超大 body 撑满磁盘(http.Server 的 ReadTimeout 在高带宽下不足以形成约束)。
- // 略高于内存上限,为 multipart 边界与其他表单字段留出开销。
- // 导出供 internal/route 的全局 requestBodyLimitMiddleware 复用同一上限:
- // 仅靠本 Bind 内的 MaxBytesReader 不够——Contexter 组级 CSRF 校验先于 Bind 执行,
- // 无 X-CSRF-Token 头时它会整包惰性解析 body,超大 multipart 会在 Bind 限制生效前
- // 落盘;全局中间件把上限提前到一切解析之前(见 internal/route/bodylimit.go)。
+ // ⚠️ 必须显式限制:ParseMultipartForm 的参数只约束内存缓冲,超出部分会无上限写入
+ // 临时文件;匿名可达的提问接口即接受 multipart,无总量限制则可持续撑满磁盘。
+ // 导出供 internal/route/bodylimit.go 的全局中间件复用——仅靠本 Bind 内限制不够,
+ // 组级 CSRF 校验先于 Bind 执行且会整包惰性解析 body,须把上限提前到一切解析之前。
MaxRequestBodySize = 12 << 20 // 12 MiB
)
func Bind(model interface{}) flamego.Handler {
- // Ensure not pointer.
if reflect.TypeOf(model).Kind() == reflect.Ptr {
panic("form: pointer can not be accepted as binding model")
}
@@ -60,11 +53,9 @@ func Bind(model interface{}) flamego.Handler {
r := c.Request()
- // 在任何解析之前封顶请求体总大小。
- // 注意:c.Request() 返回 flamego 的包装类型,其 Body 是方法而非字段,
- // 必须取内嵌的 *http.Request 才能替换 Body。
- // MaxBytesReader 在超限时让后续 Read 返回错误,从而使 ParseForm /
- // ParseMultipartForm 失败并走下方的反序列化错误分支,避免超大 body 落盘或读入内存。
+ // 在任何解析之前封顶请求体总大小。c.Request() 是 flamego 包装类型(Body 是方法
+ // 而非字段),须取内嵌 *http.Request 才能替换。超限后 Read 报错,使 ParseForm /
+ // ParseMultipartForm 失败并走反序列化错误分支,避免超大 body 落盘或读入内存。
httpReq := r.Request
httpReq.Body = http.MaxBytesReader(c.ResponseWriter(), httpReq.Body, MaxRequestBodySize)
@@ -72,11 +63,9 @@ func Bind(model interface{}) flamego.Handler {
switch contentType {
case "application/x-www-form-urlencoded", "":
if err := r.ParseForm(); err != nil {
- // ⚠️ 必须同时 SetError(写响应则在此提前中止):flamego 的 handler 链
- // 只要响应未写入就会自动执行下一 handler,仅 Map(Error) 不中断链,
- // 零值结构体会照常进入业务动作(如把用户资料/屏蔽词静默清空、解除页面密码)。
- // SetError 置位 HasError,业务 action 开头若检查 ctx.HasError() 即会早退;
- // 不检查的 action 也已在 route/user 各 handler 补了 abortOnFormError 守卫。
+ // ⚠️ 必须 SetError:flamego 的 handler 链只要响应未写入就会自动执行下一 handler,
+ // 仅 Map(Error) 不中断链,零值结构体会照常进入业务动作(静默清空屏蔽词、解除
+ // 页面密码等)。SetError 置位 HasError,action 开头的 abortOnFormError 守卫会早退。
c.SetError(err)
c.Map(Error{Category: ErrorCategoryDeserialization, Error: err})
return
@@ -89,7 +78,6 @@ func Bind(model interface{}) flamego.Handler {
}
}
- // Bind the form data to the given struct.
typ := reflect.TypeOf(obj.Interface())
val := reflect.ValueOf(obj.Interface())
if typ.Kind() == reflect.Ptr {
@@ -131,11 +119,9 @@ func Bind(model interface{}) flamego.Handler {
}
}
} else {
- // 非 multipart 请求(application/x-www-form-urlencoded):仅绑定 string 字段。
- // 其余类型(如 []*multipart.FileHeader 图片字段)保持零值,
- // 否则 reflect.Set 会因类型不匹配 panic(string → []*multipart.FileHeader)。
- // 此前提问表单因带 enctype="multipart/form-data" 走上面分支而从未触发;
- // 独立私聊表单等纯文本提交会命中此路径,必须先做类型判断。
+ // 非 multipart 请求仅绑定 string 字段,其余类型(如 []*multipart.FileHeader
+ // 图片字段)保持零值,否则 reflect.Set 会因类型不匹配 panic。
+ // 带 enctype 的提问表单走上面分支从未触发,纯文本提交(如私聊)会命中此路径。
if val.Field(i).Kind() == reflect.String {
val.Field(i).Set(reflect.ValueOf(r.Form.Get(fieldName)))
}
diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go
index 3f3cf8d..5c2af1d 100644
--- a/internal/i18n/i18n.go
+++ b/internal/i18n/i18n.go
@@ -12,10 +12,10 @@ import (
// Language 语言代码常量。
const (
- LangZhCN = "zh-CN" // 简体中文
- LangZhTW = "zh-TW" // 繁體中文
- LangEn = "en" // English
- LangJa = "ja" // 日本語
+ LangZhCN = "zh-CN"
+ LangZhTW = "zh-TW"
+ LangEn = "en"
+ LangJa = "ja"
Default = LangZhCN
)
@@ -26,7 +26,7 @@ type LangInfo struct {
Name string // 语言自身名称,如 "简体中文" / "繁體中文" / "English" / "日本語"
}
-// supported 支持的语语言列表,顺序即切换按钮展示顺序。
+// supported 支持的语言列表,顺序即切换按钮展示顺序。
var supported = []LangInfo{
{Code: LangZhCN, Name: "简体中文"},
{Code: LangZhTW, Name: "繁體中文"},
diff --git a/internal/mail/mail.go b/internal/mail/mail.go
index ab59511..c130b30 100644
--- a/internal/mail/mail.go
+++ b/internal/mail/mail.go
@@ -54,10 +54,11 @@ func SendPasswordRecoveryMail(email, code string) error {
}
// SendNewFollowUpMail 通知提问箱主人:有人对某条已回答的提问进行了追问。
-// 链接无需 token,主人登录后即可查看。
-func SendNewFollowUpMail(email, domain string, questionID uint, questionContent string, followUpContent string) error {
+// 链接带 token,与 SendNewQuestionMail 对齐——主人于未登录设备(手机邮件客户端)点开私密提问时
+// 也能凭 token 查看,否则会被 Questioner 中间件重定向到首页。
+func SendNewFollowUpMail(email, domain string, questionID uint, questionContent string, followUpContent string, token string) error {
params := map[string]string{
- "link": fmt.Sprintf("%s/_/%s/%d", conf.App.ExternalURL, domain, questionID),
+ "link": fmt.Sprintf("%s/_/%s/%d?t=%s", conf.App.ExternalURL, domain, questionID, token),
"question": questionContent,
"followup": followUpContent,
}
diff --git a/internal/route/csrf_binding_test.go b/internal/route/csrf_binding_test.go
index 4a0c504..dd134fa 100644
--- a/internal/route/csrf_binding_test.go
+++ b/internal/route/csrf_binding_test.go
@@ -12,18 +12,10 @@ import (
)
// TestCSRFTokenBoundToUserSession 是 P1-1 的回归测试:CSRF token 必须绑定到登录用户的
-// 会话键(CSRFUserSessionKey = "uid"),而非 flamego/csrf 默认的 "userID"。
-//
-// 背景:flamego/csrf 的校验是无状态 HMAC 重算(token = HMAC(secret, id:action:ts),不与
-// session 内存储的 token 比对)。一旦 SessionKey 与登录时写入的会话键(login.go 中的
-// Session.Set("uid", ...))不一致,token 内绑定的 id 恒为 "0",任意匿名访客从公开 /login
-// 页面取到的 token 对所有已登录用户均有效 —— CSRF 退化为全站公开常量,跨站请求伪造可自由发起。
-//
-// 本测试用内存会话(无需数据库)重放「会话 B 携带会话 A 的 token 提交」这一跨会话复用攻击:
-// - 会话 A(已登录 uid=42)携带自己的 token 提交 → 必须通过(200);
-// - 会话 B(匿名)携带会话 A 的 token 跨会话提交 → 必须被拒绝(400)。
-//
-// 若 CSRFUserSessionKey 被误改回 "userID",第二项断言会失败,CI 即报警。
+// 会话键(CSRFUserSessionKey="uid"),否则 flamego/csrf 的无状态 HMAC 重算会让匿名访客
+// 取到的 token 对所有已登录用户均有效 —— CSRF 退化为全站公开常量,跨站伪造可自由发起。
+// 测试用内存会话重放跨会话复用攻击:会话 A(uid=42)自带的 token 提交须 200,会话 B 跨会话
+// 复用会话 A 的 token 须 400。若 CSRFUserSessionKey 被误改回 "userID",第二项断言即失败报警。
func TestCSRFTokenBoundToUserSession(t *testing.T) {
const secret = "test-secret-for-csrf-regression"
@@ -49,14 +41,12 @@ func TestCSRFTokenBoundToUserSession(t *testing.T) {
SetHeader: true, // 便于测试从响应头取出 token
}))
- // 受保护路由:仅当 CSRF 校验通过才返回 200;失败由 csrf.Error 写 400。
+ // 受保护路由:校验通过才 200;失败由 csrf.Error 写 400。
f.Post("/protected", func(ctx flamego.Context, x csrf.CSRF) {
x.Validate(ctx)
- // 校验失败时 x.Validate 已写入 400;此处 Write 在已写 400 时仅追加 body,
- // 状态保持 400;校验通过时此处隐式返回 200。
+ // 校验失败时 x.Validate 已写 400,此处 Write 仅追加 body,状态保持 400。
ctx.ResponseWriter().Write([]byte("ok"))
})
- // 生成 token 的 GET 路由(token 在 GET 时生成,与真实应用一致)。
f.Get("/token", func(ctx flamego.Context) {
ctx.ResponseWriter().Write([]byte("ok"))
})
@@ -106,13 +96,11 @@ func TestCSRFTokenBoundToUserSession(t *testing.T) {
clientA := newClient()
tokenA := getToken(clientA, true)
- // 同一会话携带自己的 token 提交 —— 必须通过(200)。
if code := postProtected(clientA, tokenA); code != http.StatusOK {
t.Fatalf("会话 A 携带自身 token 提交,期望 200,实际 %d(修复后不应被拒绝)", code)
}
- // 会话 B:匿名访客,跨会话复用会话 A 的 token 提交 —— 必须被拒绝(400)。
- // 这是 P1-1 的核心回归点:token 绑定了 uid=42,会话 B 的 id 为 "0",HMAC 重算不匹配。
+ // 核心回归点:token 绑定了 uid=42,会话 B 的 id 为 "0",HMAC 重算不匹配,须被拒绝(400)。
clientB := newClient()
if code := postProtected(clientB, tokenA); code != http.StatusBadRequest {
t.Fatalf("会话 B 跨会话复用会话 A 的 token,期望 400,实际 %d(CSRF 绑定失效!)", code)
diff --git a/internal/route/gzip.go b/internal/route/gzip.go
index 78917d9..508c284 100644
--- a/internal/route/gzip.go
+++ b/internal/route/gzip.go
@@ -13,15 +13,12 @@ import (
"strings"
)
-// GzipHandler 返回一个压缩中间件,用于包装 http.Handler。
-// 它对可压缩的响应(HTML / CSS / JS / JSON 等文本类型)做 gzip 压缩,
-// 自动跳过图片、视频、字体等已经压缩过的格式。
-//
-// 由于 flamego 的 Context 无法替换底层 ResponseWriter,这里采用在
-// http.Server 最外层包装 Handler 的方式,保证对所有响应(含静态文件)生效。
+// GzipHandler 对可压缩的文本响应(HTML / CSS / JS / JSON 等)做 gzip 压缩,
+// 自动跳过图片、视频、字体等已压缩格式。
+// flamego 的 Context 无法替换底层 ResponseWriter,故在 http.Server 最外层包装
+// Handler,保证对所有响应(含静态文件)生效。
func GzipHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // 客户端不支持 gzip 时直接透传
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
next.ServeHTTP(w, r)
return
@@ -32,8 +29,7 @@ func GzipHandler(next http.Handler) http.Handler {
gz: gzip.NewWriter(w),
}
defer func() {
- // 只有真正写入过 body 才关闭 gzip 流,避免 302/204 等
- // 无 body 响应被追加空的 gzip 尾块
+ // 只有真正写入过 body 才关闭,避免 302/204 等无 body 响应被追加空的 gzip 尾块
if gw.compress && gw.wrote {
_ = gw.gz.Close()
}
@@ -42,8 +38,8 @@ func GzipHandler(next http.Handler) http.Handler {
})
}
-// gzipResponseWriter 包装 http.ResponseWriter,在 WriteHeader 时根据
-// Content-Type 决定是否启用 gzip,保持接口兼容(Flusher / Hijacker / Pusher)。
+// gzipResponseWriter 在 WriteHeader 时按 Content-Type 决定是否启用 gzip,
+// 并保持 Flusher / Hijacker / Pusher 接口兼容。
type gzipResponseWriter struct {
http.ResponseWriter
gz *gzip.Writer
@@ -52,8 +48,8 @@ type gzipResponseWriter struct {
wroteHeader bool
}
-// WriteHeader 记录状态码,并根据 Content-Type 决定是否压缩。
-// 由于 Content-Encoding 改变后 Content-Length 不再准确,需要删除该头。
+// WriteHeader 记录状态码并决定是否压缩;Content-Encoding 改变后 Content-Length
+// 不再准确,需删除该头。
func (w *gzipResponseWriter) WriteHeader(status int) {
if w.wroteHeader {
return
@@ -75,15 +71,14 @@ func (w *gzipResponseWriter) WriteHeader(status int) {
w.ResponseWriter.WriteHeader(status)
}
-// Write 写入响应体。若尚未 WriteHeader(handler 依赖 net/http 自动嗅探
-// Content-Type 的情况),先用首个字节块探测类型再决策。
+// Write 写入响应体。若尚未 WriteHeader,先用首个字节块探测类型再决策。
func (w *gzipResponseWriter) Write(b []byte) (int, error) {
if !w.wroteHeader {
ct := w.Header().Get("Content-Type")
if ct == "" {
ct = http.DetectContentType(b)
- // 将嗅探到的类型写回响应头,使 WriteHeader 的压缩判定能正确生效
- // (否则空 Content-Type 会被判定为"不压缩",导致本应压缩的纯文本响应未压缩)。
+ // 嗅探结果须写回头,否则空 Content-Type 会被判为「不压缩」,
+ // 导致本应压缩的纯文本响应漏压。
w.Header().Set("Content-Type", ct)
}
w.WriteHeader(http.StatusOK)
@@ -95,7 +90,7 @@ func (w *gzipResponseWriter) Write(b []byte) (int, error) {
return w.ResponseWriter.Write(b)
}
-// Flush 转发底层 Flusher,并在压缩模式下先刷新 gzip 缓冲。
+// Flush 转发底层 Flusher,压缩模式下先刷新 gzip 缓冲。
func (w *gzipResponseWriter) Flush() {
if w.compress && w.gz != nil {
_ = w.gz.Flush()
@@ -121,24 +116,21 @@ func (w *gzipResponseWriter) Push(target string, opts *http.PushOptions) error {
return http.ErrNotSupported
}
-// shouldCompressContentType 判断响应 Content-Type 是否值得压缩。
-// 空类型、二进制图片/视频/音频/字体等已压缩格式直接跳过。
+// shouldCompressContentType 判断 Content-Type 是否值得压缩:空类型与已压缩的
+// 二进制格式(图片 / 视频 / 音频 / 字体 / 归档等)直接跳过。
func shouldCompressContentType(ct string) bool {
ct = strings.ToLower(strings.TrimSpace(ct))
if ct == "" {
return false
}
- // SVG 是文本格式,值得压缩;其余图片格式已是压缩格式
if strings.HasPrefix(ct, "image/") {
- return strings.Contains(ct, "svg")
+ return strings.Contains(ct, "svg") // SVG 是文本格式
}
- // 视频 / 音频 / 字体已是压缩格式
if strings.HasPrefix(ct, "video/") || strings.HasPrefix(ct, "audio/") ||
strings.HasPrefix(ct, "font/") {
return false
}
- // 已知压缩或二进制格式
switch ct {
case "application/zip", "application/gzip", "application/x-7z-compressed",
"application/x-rar-compressed", "application/pdf",
diff --git a/internal/route/route.go b/internal/route/route.go
index 8af4f41..cc3dc46 100644
--- a/internal/route/route.go
+++ b/internal/route/route.go
@@ -49,13 +49,11 @@ import (
// 用于防止把路由参数直接拼进 embed FS 路径时发生路径穿越(如 "../robots.txt")。
var indexNowKeyPattern = regexp.MustCompile(`^[0-9a-f]{32}\.txt$`)
-// CSRFUserSessionKey 是 CSRF token 绑定的会话键,必须与登录时写入的会话键一致
-// (见 route/auth/login.go 中 ctx.Session.Set("uid", ...))。flamego/csrf 默认使用
-// "userID",若不与之对齐,token 的 id 恒为 "0",任意匿名访客从公开页面取到的 token
-// 对全部已登录用户均有效,CSRF 退化为全站公开常量。
-//
-// 本常量同时被 csrf_binding_test.go 引用,作为单一事实来源:一旦被误改,回归测试会在
-// CI 失败,避免修复悄悄倒退。
+// CSRFUserSessionKey 是 CSRF token 绑定的会话键,必须与登录时写入的键一致
+// (见 route/auth/login.go 的 ctx.Session.Set("uid", ...))。flamego/csrf 默认用
+// "userID",不对齐则 token id 恒为 "0",匿名访客从公开页取到的 token 对所有已登录
+// 用户均有效,CSRF 退化为全站公开常量。
+// 本常量被 csrf_binding_test.go 引用作为单一事实来源,误改会让回归测试失败。
const CSRFUserSessionKey = "uid"
// securityHeaders adds security-related HTTP headers to every response.
@@ -80,9 +78,8 @@ func New() *flamego.Flame {
}
// 自动封禁实例:整个请求链路共享同一个,供中间件与 f.NotFound 复用,
- // 避免对业务性 302→/(语言切换、登出、投票不存在等)误判为路径扫描。
- // 404 是否参与统计由 ban_track_notfound 控制(默认 false:正常业务一律放行,
- // 仅敏感扫描路径计数封禁,见 conf.Security.BanTrackNotFound)。
+ // 避免对业务性 302→/(语言切换、登出等)误判为路径扫描。
+ // 404 是否计入由 ban_track_notfound 控制(默认 false,仅敏感扫描路径计数)。
ab := autoban.New(autoban.Options{
Enabled: conf.Security.EnableAutoBan,
TrackNotFound: conf.Security.BanTrackNotFound,
@@ -98,10 +95,6 @@ func New() *flamego.Flame {
return mail.SendSecurityAlertMail(ip, provider, proxied, paths, durationMinutes)
},
})
- // 需要输出详细日志时配置如下
- // if conf.App.Production {
- // flamego.SetEnv(flamego.EnvTypeProd)
- // }
templateFS, err := template.EmbedFS(templates.FS, ".", []string{".html"})
if err != nil {
@@ -134,11 +127,9 @@ func New() *flamego.Flame {
sessioner := session.Sessioner(session.Options{
Initer: initer,
Config: sessionStorage,
- // 显式声明 cookie 安全属性,不依赖库默认值:
- // - HTTPOnly:禁止 JS 读取,降低 XSS 窃取会话的风险
- // - SameSite=Lax:阻断经典跨站表单 POST(CSRF 的主要防线之一)
- // - Secure:仅在站点以 https 对外服务时下发,避免 http 本地开发下 cookie 不生效。
- // 缺失 Secure 时,HSTS 尚未落地的首次访问可被主动 MITM 诱导明文请求窃取会话。
+ // 显式声明 cookie 安全属性:HTTPOnly 防 XSS 窃取会话;SameSite=Lax 阻断跨站表单
+ // POST;Secure 仅在 https 对外服务时下发,避免 http 本地开发下 cookie 不生效
+ // (缺失 Secure 时,HSTS 尚未落地的首次访问可被 MITM 诱导明文请求窃取会话)。
Cookie: session.CookieOptions{
HTTPOnly: true,
SameSite: http.SameSiteLaxMode,
@@ -227,11 +218,11 @@ func New() *flamego.Flame {
_, _ = io.Copy(c.ResponseWriter(), fs)
})
- // IndexNow 密钥文件:static/index-now/<32 位十六进制>.txt,供 Bing / Yandex / Seznam
- // 等支持 IndexNow 的搜索引擎校验站点所有权(提交 URL 时的 keyLocation 指向此处)。
- // ⚠️ flamego 的 Static 中间件 Prefix 为 "/static",不会接管本路径,必须在此显式注册,
- // 否则请求会落到 f.NotFound 被 302 重定向到首页,搜索引擎校验必然失败。
- // 文件名用 indexNowKeyPattern 严格白名单校验,避免路径穿越读取 embed FS 内其它文件。
+ // IndexNow 密钥文件:static/index-now/<32 位十六进制>.txt,供 Bing / Yandex 等
+ // 搜索引擎校验站点所有权。
+ // ⚠️ flamego Static 的 Prefix 是 "/static",不会接管本路径,必须显式注册,
+ // 否则会落到 f.NotFound 被 302 到首页,校验必然失败。
+ // 文件名经 indexNowKeyPattern 白名单校验,避免路径穿越读 embed FS 内其它文件。
f.Get("/index-now/{key}", func(c context.Context) {
key := c.Param("key")
if !indexNowKeyPattern.MatchString(key) {
@@ -282,7 +273,7 @@ func New() *flamego.Flame {
f.Post("/follow-up/{followUpID}/answer", reqUserSignIn, form.Bind(form.AnswerFollowUpQuestion{}), question.AnswerFollowUp)
}, question.Questioner)
- // 新增:私聊路由(需要登录,使用 Token 标识对话)
+ // 私聊路由(需登录,Token 标识对话)
f.Group("/chat/{token}", func() {
f.Get("", chat.ConversationPage)
f.Post("/send", rateLimitMiddleware(20, 1*time.Minute), chat.SendMessage)
@@ -300,16 +291,14 @@ func New() *flamego.Flame {
f.Post("/submit", voteSubmitRateLimitMiddleware(20, 1*time.Minute), vote.Submit)
})
- // 公开页语言切换:把语言偏好写入 session(由 session 中间件随其 cookie 一并持久化)
- // 后跳回原页面。此前写独立 lang cookie 会被 session 中间件的 Set-Cookie 整体覆盖而失效,
- // 故改为托管在 session 中。仅允许站内相对路径,防开放重定向。
+ // 公开页语言切换:语言偏好写入 session 后跳回原页面。此前写独立 lang cookie
+ // 会被 session 中间件的 Set-Cookie 整体覆盖而失效,故托管在 session 中。
f.Get("/i18n/{lang}", func(c context.Context) {
lang := i18n.Resolve(c.Param("lang"))
- // 写入 session,避免被 session 中间件的 Set-Cookie 覆盖(匿名用户也走 session 中间件)。
c.Session.Set("lang", lang)
to := c.Query("to")
- // 仅允许站内相对路径,防开放重定向:
- // 拒绝空、非 "/" 开头、以 "//" 开头的协议相对路径,以及含反斜杠/控制字符的变体。
+ // 仅允许站内相对路径,防开放重定向:拒绝空、非 "/" 开头、协议相对路径 "//",
+ // 以及含反斜杠 / 控制字符的变体。
if to == "" || !strings.HasPrefix(to, "/") ||
strings.HasPrefix(to, "//") || strings.ContainsAny(to, "\\\x00\r\n\t") {
to = "/"
@@ -360,13 +349,10 @@ func New() *flamego.Flame {
f.Post("/reset-quota", rateLimitMiddleware(20, 1*time.Minute), user.FeatureResetQuota)
})
})
- // 防骚扰设置:注册限制(harassment/update)与全局屏蔽词(blockwords/update)
- // 拆为两个独立入口。屏蔽词在提问/私聊/投票三个设置面板共用一份,保存后经
- // ?tab= 参数回跳到来源面板。
+ // 防骚扰与全局屏蔽词拆为两个独立入口,避免同表单一次写两列时相互清空。
+ // 保存后经 ?tab= 参数回跳到来源面板。
f.Post("/harassment/update", form.Bind(form.UpdateHarassment{}), user.UpdateHarassment)
f.Post("/blockwords/update", form.Bind(form.UpdateBlockWords{}), user.UpdateBlockWords)
- // 内容审核严格度:与屏蔽词一样在提问/私聊/投票三个面板共用一份,
- // 保存后经 ?tab= 参数回跳到来源面板。
f.Post("/moderation-strictness/update", form.Bind(form.UpdateModerationStrictness{}), user.UpdateModerationStrictness)
f.Post("/tags/update", form.Bind(form.UpdateTags{}), user.UpdateTags)
// Logout 必须用 POST:GET 会留下 CSRF 风险(第三方图片/链接可触发退出登录)
@@ -418,12 +404,11 @@ func New() *flamego.Flame {
csrf.Csrfer(csrf.Options{
Secret: conf.Server.XSRFKey,
Header: "X-CSRF-Token",
- // ⚠️ 必须与 route/auth/login.go 中 ctx.Session.Set("uid", ...) 的 key 一致。
- // flamego/csrf 默认 SessionKey 为 "userID",若不显式指定,s.Get("userID")
- // 恒为 nil,token 内绑定的 id 恒为 "0" —— 由于校验是无状态 HMAC 重算
- // (不与 session 中存储的 token 比对),这会导致任意匿名访客从公开的
- // /login 页面取到的 token 对所有已登录用户均有效,CSRF 退化为全站公开常量。
- // 回归测试见 route/csrf_binding_test.go(引用 CSRFUserSessionKey)。
+ // ⚠️ 必须与 login.go 的 ctx.Session.Set("uid", ...) 一致。flamego/csrf 默认
+ // SessionKey 为 "userID",不显式指定则 s.Get("userID") 恒为 nil、token 内 id
+ // 恒为 "0";校验是无状态 HMAC 重算(不与 session 内 token 比对),匿名访客从
+ // 公开页取到的 token 会对所有已登录用户有效,CSRF 退化为全站公开常量。
+ // 回归测试见 route/csrf_binding_test.go。
SessionKey: CSRFUserSessionKey,
}),
template.Templater(template.Options{
@@ -433,13 +418,12 @@ func New() *flamego.Flame {
context.Contexter(),
)
f.NotFound(func(ctx flamego.Context) {
- // 真正的 404 探测在此记录(仅此处,不把业务 302→/ 误判为扫描)。
- // 注意:flamego 的 NotFound 会先写入 404 状态再 Redirect 到 "/",
- // 因此这里在 Redirect 之前记录,确保状态为 404。
- // 键必须与 autobanMiddleware 中的 IsBanned/Record 保持一致(同样经 LimitKey 聚合),
- // 否则 404 计数会记在另一个键上,导致封禁永不生效。
- // ⚠️ 该 404 计数受 ban_track_notfound 配置控制,默认关闭(正常业务一律放行,
- // 仅敏感扫描路径计数封禁);Record 内部对未开启的 KindNotFound 直接忽略。
+ // 真正的 404 探测在此记录(不把业务 302→/ 误判为扫描)。flamego 的 NotFound 会先
+ // 写 404 状态再 Redirect,故须在 Redirect 前记录。
+ // 键必须与 autobanMiddleware 的 IsBanned/Record 一致(同样经 LimitKey 聚合),
+ // 否则计数落在另一个键上,封禁永不生效。
+ // ⚠️ 计数受 ban_track_notfound 控制,默认关闭;Record 内部会忽略未开启的
+ // KindNotFound。
if ip := shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request)); ip != "" {
ab.Record(ip, ctx.Request().URL.Path, autoban.KindNotFound)
}
diff --git a/internal/security/censor/aliyun.go b/internal/security/censor/aliyun.go
index b2c289f..f0d6a7c 100644
--- a/internal/security/censor/aliyun.go
+++ b/internal/security/censor/aliyun.go
@@ -4,20 +4,14 @@
package censor
-// 阿里云文本审核通道 —— 内容安全 2.0「文本审核增强版」。
+// 阿里云文本审核通道 —— 内容安全 2.0「文本审核增强版」(TextModeration)。
//
-// 迁移记录(2026-09):本通道原走「内容安全 1.0」(/green/text/scan + alibaba-cloud-sdk-go),
-// 但 1.0 的开通前提是**企业实名认证**,个人认证账号无法开通,接口会永久返回
-// `code=596 You have not opened Yundun Content Moderation Service`。
-// 现改为 2.0(TextModeration + alibabacloud-go/green-20220302/v3):2.0 只要求实名认证
-// (个人可开通),且标签体系对底线维度(涉政/暴恐/色情/自定义词库)覆盖完整。
+// 1.0(/green/text/scan)需企业实名认证,个人账号永久返回 code=596,故迁 2.0(仅需实名认证);
+// 2.0 标签对底线维度(涉政/暴恐/色情/自定义词库)覆盖完整。官方参考:
+// - API:https://help.aliyun.com/practice_detail/434034
+// - 标签:https://help.aliyun.com/practice_detail/465315
//
-// 官方文档:
-// - 文本审核增强版通用服务 API:https://help.aliyun.com/practice_detail/434034
-// - 标签处理建议参考: https://help.aliyun.com/practice_detail/465315
-//
-// ⚠️ 不要把 SDK 里 `TextModerationResponseBodyData.Labels` 的内联注释当作权威标签表:
-// 那份列表是旧的且不完整(没有 political 相关标签),以官方《标签处理建议参考》为准。
+// ⚠️ SDK 里 TextModerationResponseBodyData.Labels 的内联注释是旧表且不完整(缺涉政标签),以官方为准。
import (
"context"
@@ -35,38 +29,28 @@ import (
)
const (
- // aliyunRegionID 内容安全 2.0 的接入地域,SDK 据此解析出
- // green-cip.cn-shanghai.aliyuncs.com(见绿网 SDK 的 EndpointMap)。
+ // aliyunRegionID 接入地域,SDK 据此解析出 green-cip.cn-shanghai.aliyuncs.com。
aliyunRegionID = "cn-shanghai"
- // aliyunTextService 文本审核增强版的服务场景。
- //
- // 选 comment_detection(公聊评论内容检测):官方定位「评论/弹幕/公聊/转发」,
- // 明确覆盖涉黄/涉政/涉暴等底线违规与广告导流,是本站(公开问答为主、私聊为辅)
- // 最通用的单一场景。本通道被提问/回答、投票、私聊三条链路共用,接口层不带场景信息;
- // 若要按场景细分(例如私聊改用 chat_detection),需给审核入口补 scene 参数。
+ // aliyunTextService 服务场景。选 comment_detection:官方定位「评论/弹幕/公聊/转发」,
+ // 覆盖涉黄/涉政/涉暴底线与广告导流。三条链路共用且接口层不带场景,
+ // 若要按场景细分(如私聊改 chat_detection)需给审核入口补 scene 参数。
aliyunTextService = "comment_detection"
- // aliyunContentRuneLimit 2.0 通用服务单次 content 的硬上限。
- // 官方文档:content 限定在 600 字符以内。这里按 550 切分以留出安全余量。
+ // aliyunContentRuneLimit 单次 content 硬上限(官方 600),按 550 切分留安全余量。
aliyunContentRuneLimit = 550
- // aliyunChunkOverlapRunes 相邻分片的重叠字符数。
- // 切分点可能正好落在一个风险词中间,两段各自都看不出问题;重叠可消除该盲区。
- // 代价是少量重复计费(文本审核标准 7.5 元/万次,可忽略)。
+ // aliyunChunkOverlapRunes 相邻分片重叠字符数:防止风险词正好落在切分点而被漏判。
aliyunChunkOverlapRunes = 50
aliyunReadTimeoutMs = 10000
aliyunConnectTimeoutMs = 5000
- // aliyunMaxRawLogBytes 错误信息里回显的原始响应长度上限(按字符计),
- // 避免异常响应体过大时把日志刷屏。
+ // aliyunMaxRawLogBytes 错误信息里回显的原始响应长度上限(按字符计),避免日志刷屏。
aliyunMaxRawLogBytes = 512
- // aliyunTextModerationAPI 归一化响应的格式标记。
- //
- // 解析器据此拒绝「非本格式」的载荷(例如迁移前内容安全 1.0 的响应),
- // 让 censor.go 的缓存回放能安全地退回一次实时调用,
+ // aliyunTextModerationAPI 归一化响应的格式标记:解析器据此拒绝非本格式载荷
+ // (如迁移前 1.0 的响应),使 censor.go 的缓存回放能安全退回实时调用,
// 而不是把无法识别的历史缓存误判成「通过」。
aliyunTextModerationAPI = "TextModeration"
)
@@ -82,8 +66,7 @@ func NewAliyunTextCensor(accessKey, accessKeySecret string) *AliyunTextCensor {
}
}
-// 包级复用的 client:审核请求高频且密钥在启动后不变,
-// 用 sync.Once 懒加载一次并设置超时,避免每次审核都重建连接与对象。
+// 包级复用 client:密钥启动后不变,用 sync.Once 懒加载一次,避免每次审核都重建连接。
var (
aliyunGreenOnce sync.Once
aliyunGreenClient *green20220302.Client
@@ -93,11 +76,8 @@ var (
warnAliyunAccountIssueDoOnce sync.Once
)
-// newAliyunClient 构造内容安全 2.0 客户端。
-//
-// 不设置 Endpoint 而设置 RegionId:SDK 内置 EndpointMap 会把 cn-shanghai
-// 解析成 green-cip.cn-shanghai.aliyuncs.com。超时单位是毫秒
-// (见 darabonba-openapi 的 client.ReadTimeout → 请求参数 readTimeout)。
+// newAliyunClient 构造 2.0 客户端。设 RegionId 而非 Endpoint:SDK 的 EndpointMap 会解析出
+// green-cip.cn-shanghai.aliyuncs.com。超时单位为毫秒。
func newAliyunClient(accessKey, accessKeySecret string) (*green20220302.Client, error) {
regionID := aliyunRegionID
readTimeout := aliyunReadTimeoutMs
@@ -123,10 +103,8 @@ func (c *AliyunTextCensor) getClient() (*green20220302.Client, error) {
return aliyunGreenClient, aliyunGreenErr
}
-// newAliyunTextModerationRequest 构造 TextModeration 请求。
-//
-// 单独抽出便于测试断言请求形态:Service 必须是选定场景,
-// ServiceParameters 是 JSON 字符串 {"content": ...}。
+// newAliyunTextModerationRequest 构造 TextModeration 请求(Service 为场景,
+// ServiceParameters 为 JSON 串 {"content": ...})。单独抽出便于测试断言请求形态。
func newAliyunTextModerationRequest(text string) (*green20220302.TextModerationRequest, error) {
serviceParameters, err := json.Marshal(map[string]string{"content": text})
if err != nil {
@@ -137,13 +115,10 @@ func newAliyunTextModerationRequest(text string) (*green20220302.TextModerationR
SetServiceParameters(string(serviceParameters)), nil
}
-// splitAliyunText 把待审文本切成不超过 2.0 单次上限的分片(按 rune 切,避免切坏多字节字符)。
-//
-// 为什么必须切分:2.0 通用服务单次 content 硬上限 600 字符,而本站内容上限为
-// 提问 1000 / 回答·追问·私聊 2000 / 投票描述 5000 字,长文本必然超限。
-// 把超长文本直接发给接口会被拒,进而在 fail-closed 下变成「长内容一律发不出去」。
-//
-// 分片带 aliyunChunkOverlapRunes 的重叠,消除「风险词正好跨切分点」的漏判盲区。
+// splitAliyunText 按 rune 切成不超过单次上限的分片。
+// 必须切分:2.0 单次硬上限 600 字符,而本站上限为 提问 1000 / 回答·追问·私聊 2000 /
+// 投票描述 5000 字;超长直发会被拒,在 fail-closed 下等于「长内容一律发不出去」。
+// 分片带重叠以消除「风险词正好跨切分点」的漏判盲区。
func splitAliyunText(text string) []string {
runes := []rune(text)
if len(runes) <= aliyunContentRuneLimit {
@@ -165,19 +140,16 @@ func splitAliyunText(text string) []string {
return chunks
}
-// aliyunTextModerationReason 是 2.0 响应中 Data.Reason 的结构(该字段本身是 JSON 字符串)。
-//
-// 只声明用到的字段:riskLevel 风险级别、riskTips 子标签、riskWords 命中词。
+// aliyunTextModerationReason 2.0 响应中 Data.Reason 的结构(该字段本身是 JSON 字符串)。
type aliyunTextModerationReason struct {
RiskLevel string `json:"riskLevel"`
RiskTips string `json:"riskTips"`
RiskWords string `json:"riskWords"`
}
-// parseAliyunReason 解析 Data.Reason。
-//
-// 字段缺失或非法 JSON 时返回零值 —— 忽略解析错误是刻意的:Reason 是辅助字段,
-// 不应因为它的格式变化导致整条审核失败。风险级别拿不到时的保守处理见 aliyunRiskLevelScore。
+// parseAliyunReason 解析 Data.Reason。字段缺失或非法 JSON 时返回零值 ——
+// 忽略解析错误是刻意的:Reason 是辅助字段,不应因其格式变化导致整条审核失败
+// (风险级别拿不到时的保守处理见 aliyunRiskLevelScore)。
func parseAliyunReason(raw string) aliyunTextModerationReason {
var reason aliyunTextModerationReason
if strings.TrimSpace(raw) == "" {
@@ -254,11 +226,8 @@ func aliyunHighestRiskLevel(levels []string) string {
return best
}
-// aliyunTextModerationRaw 归一化后的可落库响应。
-//
-// 它是「在线调用」与「缓存回放」两条路径的唯一输入格式:
-// Censor 把(可能多分片的)2.0 响应合并成它并序列化,随后交给 AliyunTextCensorParser;
-// censor.go 命中落库缓存时,也把这段 JSON 交给同一个 parser。
+// aliyunTextModerationRaw 归一化后的可落库响应:在线调用与缓存回放两条路径的唯一输入格式,
+// 最终都交给 AliyunTextCensorParser 解析。
type aliyunTextModerationRaw struct {
// API 格式标记,取值恒为 aliyunTextModerationAPI,供解析器甄别旧格式载荷。
API string `json:"api"`
@@ -266,7 +235,7 @@ type aliyunTextModerationRaw struct {
Code int32 `json:"code"`
Message string `json:"message"`
RequestIDs []string `json:"requestIds,omitempty"`
- // Labels 已过滤掉 nonLabel / normal,且已去重。
+ // Labels 已过滤 nonLabel / normal 并去重。
Labels []string `json:"labels,omitempty"`
RiskLevel string `json:"riskLevel,omitempty"`
Hint string `json:"hint,omitempty"`
@@ -275,8 +244,7 @@ type aliyunTextModerationRaw struct {
// aliyunEmptyCleanRaw 空内容对应的归一化响应(无标签 → 通过)。见 Censor 的空内容分支。
var aliyunEmptyCleanRaw = []byte(`{"api":"TextModeration","statusCode":200,"code":200,"message":"OK"}`)
-// aliyunChunkResult 单个分片审核结果里我们关心的最小信息集。
-// 抽出这一层是为了让「多分片合并」这段逻辑可以脱离 SDK 与网络单独测试。
+// aliyunChunkResult 单个分片结果的最小信息集,使「多分片合并」可脱离 SDK 单独测试。
type aliyunChunkResult struct {
StatusCode int32
Code int32
@@ -288,9 +256,8 @@ type aliyunChunkResult struct {
}
// extractAliyunChunkResult 从 SDK 响应里提取分片结果。
-//
-// code 非 200/0 表示请求被阿里云拒绝(服务未开通、限流、参数错误等),
-// 必须报错而不是当成「未命中」—— 否则 fail-closed 会被悄悄变成 fail-open。
+// code 非 200/0 表示请求被拒(未开通、限流、参数错误等),必须报错而非当成「未命中」
+// —— 否则 fail-closed 会被悄悄变成 fail-open。
func extractAliyunChunkResult(resp *green20220302.TextModerationResponse) (aliyunChunkResult, error) {
if resp == nil || resp.Body == nil {
return aliyunChunkResult{}, errors.New("text moderation returned an empty response body")
@@ -338,12 +305,8 @@ func extractAliyunChunkResult(resp *green20220302.TextModerationResponse) (aliyu
return result, nil
}
-// mergeAliyunChunkResults 把多个分片的结果合并成一份归一化响应。
-//
-// 合并口径:
-// - 标签取并集(任一分片命中即视为整条内容命中);
-// - 风险级别取最高(high > medium > low > none),未知级别按最高处理;
-// - Hint 取首个非空,与「首个命中维度决定提示文案」的既有语义保持一致。
+// mergeAliyunChunkResults 把多个分片结果合并成一份归一化响应:标签取并集、
+// 风险级别取最高(未知级别按最高处理)、Hint 取首个非空。
func mergeAliyunChunkResults(results []aliyunChunkResult) aliyunTextModerationRaw {
merged := aliyunTextModerationRaw{
API: aliyunTextModerationAPI,
@@ -440,17 +403,14 @@ func aliyunRawDigest(raw []byte) string {
return s
}
-// AliyunTextCensorParser 把归一化响应转成通用的 TextCensorResponse。
-//
-// 在线调用与缓存回放共用本函数(见 aliyunTextModerationRaw 的说明)。
+// AliyunTextCensorParser 把归一化响应转成通用的 TextCensorResponse(在线调用与缓存回放共用)。
func AliyunTextCensorParser(raw []byte) (*TextCensorResponse, error) {
var parsed aliyunTextModerationRaw
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil, errors.Wrapf(err, "unmarshal response (raw=%s)", aliyunRawDigest(raw))
}
- // 格式标记不符:典型是迁移前内容安全 1.0 落库的缓存(形如 {"code":200,"data":[...]})。
- // 这种载荷无法按 2.0 语义解释,必须报错,交由 censor.go 退回一次实时调用;
+ // 格式标记不符(典型是迁移前 1.0 落库的缓存)必须报错,交由 censor.go 退回实时调用;
// 若当成「无标签 → 通过」,历史缓存就会变成绕过审核的后门。
if parsed.API != aliyunTextModerationAPI {
return nil, errors.Errorf("unsupported aliyun response format (api=%q raw=%s)",
@@ -460,14 +420,12 @@ func AliyunTextCensorParser(raw []byte) (*TextCensorResponse, error) {
// 2.0 不返回逐标签置信度,只有整条内容的 riskLevel,所有标签共用折算后的分值。
score := aliyunRiskLevelScore(parsed.RiskLevel)
- // Pass 取「审核服务未给出任何风险标签」。注意它不是最终裁决 ——
- // 是否拦截由 policy.go 按箱主严格度决定。
+ // Pass 只表示「未给出风险标签」,不是最终裁决(是否拦截由 policy.go 按严格度决定)。
pass := len(parsed.Labels) == 0
suggestion := aliyunRiskLevelSuggestion(parsed.RiskLevel)
- // 与七牛保持一致:审核服务判为「非通过」却没给出建议时补成 block。
- // 典型场景是命中了尚未映射的新标签(既没有明细维度、也没有 riskLevel),
- // 不补的话策略层会把它当成未命中放行,与改造前的 fail-closed 行为不一致。
+ // 与七牛一致:判为「非通过」却没给建议时补成 block(如命中尚未映射的新标签),
+ // 不补会被策略层当成未命中放行,与改造前的 fail-closed 行为不一致。
if suggestion == "" && !pass {
suggestion = SuggestionBlock
}
@@ -480,8 +438,7 @@ func AliyunTextCensorParser(raw []byte) (*TextCensorResponse, error) {
for _, label := range parsed.Labels {
mapped := formatAliyunForbiddenType(label)
if mapped == "" {
- // 未映射的标签不进入明细:它无法参与按维度裁决。
- // 原始标签仍完整保留在 RawResponse 中供审计。
+ // 未映射的标签无法参与按维度裁决,不进明细(原始标签仍保留在 RawResponse 供审计)。
continue
}
@@ -506,25 +463,10 @@ func AliyunTextCensorParser(raw []byte) (*TextCensorResponse, error) {
}, nil
}
-// formatAliyunForbiddenType 把内容安全 2.0 的标签映射为本项目的 ForbiddenType。
-//
-// 标签取值以官方《文本审核增强版标签处理建议参考》为准;括号内为官方释义与风险级别:
-//
-// political_content / regional 涉政 (高)
-// violence 暴恐 (高)
-// sexual_content / sexuality 色情 (高)
-// contraband 违禁 (高)
-// ad / PullinTraffic / ad_compliance 广告引流/广告法合规(中高)
-// profanity 辱骂 (中高)
-// religion 宗教 (中高)
-// negative_content / cyberbullying 不良内容/网络暴力 (中)
-// nonsense 无意义 (中)
-// spam 垃圾信息
-// C_customized 自定义词库命中
-//
-// 映射结果决定 policy.go 的裁决:政治/暴恐/色情/自定义属底线维度,任何档位都拦。
-// 注意 2.0 没有「灌水(flood)」维度(同类内容归入 spam),因此 ForbiddenTypeFlood
-// 不会由本通道产生,七牛仍可能返回它。
+// formatAliyunForbiddenType 把 2.0 标签映射为 ForbiddenType(取值以官方
+// 《文本审核增强版标签处理建议参考》为准;SDK 内联的标签表是旧版且不完整,勿采信)。
+// 政治 / 暴恐 / 色情 / 自定义属底线维度,任何档位都拦。
+// 2.0 没有「灌水(flood)」维度(同类归入 spam),故 ForbiddenTypeFlood 不由本通道产生。
func formatAliyunForbiddenType(typ string) ForbiddenType {
switch strings.ToLower(strings.TrimSpace(typ)) {
case "political_content", "regional":
@@ -553,10 +495,9 @@ func formatAliyunForbiddenType(typ string) ForbiddenType {
}
}
-// aliyunAccountIssueSignals 「账号侧未就绪」的特征信号(小写匹配),覆盖结构化 code 与消息文本两条路径:
-// - 596 / "not opened":服务未开通
-// - Forbidden:RAM 未授权
-// - InvalidAccessKeyId / SignatureDoesNotMatch:密钥错误
+// aliyunAccountIssueSignals 「账号侧未就绪」的特征信号(小写匹配),覆盖结构化 code 与消息文本:
+// 596 / "not opened" 服务未开通;Forbidden / no permission RAM 未授权;
+// InvalidAccessKeyId / SignatureDoesNotMatch 密钥错误。
var aliyunAccountIssueSignals = []string{
"596",
"not opened",
@@ -592,12 +533,9 @@ func aliyunIsAccountIssue(err error) bool {
return false
}
-// warnAliyunAccountIssueOnce 在首次出现「账号侧未就绪」类失败时告警一次。
-//
-// 这类问题(服务未开通 / RAM 未授权 / 密钥无效)不会自愈,会把 fail-closed 的审核链路
-// 永久卡死:外部表现是「全站内容都发不出去」,或(auto 模式下)内容静默落到下一个通道,
-// 但日志里原本只是一条普通 ERRO,极易被当成偶发网络抖动而忽略。
-// 因此第一次遇到就打出可操作的指引,并用 sync.Once 约束为只打一次,避免按请求刷屏。
+// warnAliyunAccountIssueOnce 首次出现「账号侧未就绪」失败时告警一次(sync.Once 避免刷屏)。
+// 这类问题(未开通 / RAM 未授权 / 密钥无效)不会自愈,会永久卡死 fail-closed 的审核链路,
+// 表现为「全站内容发不出去」,而日志里原本只是一条普通 ERRO、极易被当成偶发网络抖动。
func warnAliyunAccountIssueOnce(ctx context.Context, err error) {
if !aliyunIsAccountIssue(err) {
return
diff --git a/internal/security/censor/censor.go b/internal/security/censor/censor.go
index 9d82b25..bb59225 100644
--- a/internal/security/censor/censor.go
+++ b/internal/security/censor/censor.go
@@ -24,13 +24,10 @@ var (
warnNoCensorCredentialsOnce sync.Once
)
-// censorChain 返回按配置选定的审核通道列表,顺序即优先级:
-// 靠前的通道判定「通过」即返回;未通过则继续交给后面的通道复核。
-//
-// 取值来源 [security] text_censor_source(见 conf.CensorSource*):
-// - qiniu → 只用七牛(凭据齐备性已由 conf.Init 启动校验)
-// - aliyun → 只用阿里云(同上)
-// - 其它(含留空 / auto)→ 七牛优先、阿里云兜底,与引入该配置前完全一致
+// censorChain 返回按 [security] text_censor_source 选定的通道列表,顺序即优先级:
+// 靠前通道判定通过即返回,未通过则交给后面的通道复核。
+// qiniu / aliyun 只用对应通道(凭据齐备性已由 conf.Init 启动校验);
+// 其它取值(含留空 / auto)为七牛优先、阿里云兜底,与引入该配置前一致。
func censorChain() []TextCensor {
qiniu := NewQiniuTextCensor(conf.App.QiniuAccessKey, conf.App.QiniuAccessSecret)
aliyun := NewAliyunTextCensor(conf.App.AliyunAccessKey, conf.App.AliyunAccessKeySecret)
@@ -45,11 +42,9 @@ func censorChain() []TextCensor {
}
}
-// warnIfNoCensorCredentials 在「配置里一条可用凭据都没有」时告警一次。
-//
-// 这种情况下审核调用必然失败,而调用方是 fail-closed(拒绝提交),表现为
-// 全站发不出内容/私信——现象隐蔽、只在日志可见,因此第一次触发就留下明确线索。
-// 显式选定通道但缺凭据的情况已由 conf.Init 直接挡住,这里兜的是 auto 的漏网情况。
+// warnIfNoCensorCredentials 在一条可用凭据都没有时告警一次:此时审核必然失败,
+// 而调用方是 fail-closed(拒绝提交),表现为全站发不出内容/私信且只在日志可见。
+// 显式选定通道但缺凭据已由 conf.Init 挡住,这里兜的是 auto 的漏网情况。
func warnIfNoCensorCredentials(ctx context.Context) {
qiniuReady := conf.App.QiniuAccessKey != "" && conf.App.QiniuAccessSecret != ""
aliyunReady := conf.App.AliyunAccessKey != "" && conf.App.AliyunAccessKeySecret != ""
@@ -62,35 +57,35 @@ func warnIfNoCensorCredentials(ctx context.Context) {
})
}
-// Text checks the text for sensitive content.
-// It will save the censor log to the database and invoke the callback function.
+// Text 检查文本是否含敏感内容,并把审核结果落库(31 天内可回放复用)。
func Text(ctx context.Context, text string) (*TextCensorResponse, error) {
if !conf.Security.EnableTextCensor {
return &TextCensorResponse{Pass: true}, nil
}
+ // 缓存键与送审内容统一用归一化文本(已剔除可信域名链接):否则缓存按原文命中、
+ // 实际送审的却是归一化结果 —— 审计对不上,且归一化后相同的原文会各占一条缓存、重复调 API。
+ normalized := RemoveTrustedURL(text)
+
var responses []*TextCensorResponse
warnIfNoCensorCredentials(ctx)
for _, censor := range censorChain() {
sourceName := censor.String()
- // Try to get the censor from the database log.
- censorLog, err := db.CensorLogs.GetByText(ctx, sourceName, text, time.Now().Add(-censorCacheNoMoreThan))
+ censorLog, err := db.CensorLogs.GetByText(ctx, sourceName, normalized, time.Now().Add(-censorCacheNoMoreThan))
if err != nil {
if !errors.Is(err, db.ErrCensorLogsNotFound) {
logrus.WithContext(ctx).WithError(err).Error("Failed to get censor log")
}
} else {
raw := censorLog.RawResponse
- // We got the previous censor log cache, seems like we saved money for the API call.
switch censorLog.SourceName {
case "qiniu":
- // 七牛缓存命中:只有「通过」才可直接复用(省一次 API 调用)。
- // 未通过时不在此下最终结论,而是交给链上的下一个通道复核(auto 模式下即阿里云,
- // 这是引入通道配置前就有的行为)。
- // text_censor_source = qiniu 时链上只有七牛:continue 后循环结束,
- // 仍会由函数末尾返回这份七牛结果,判定不会丢失。
+ // 只有「通过」才直接复用(省一次 API 调用);未通过则交给链上下一个通道复核
+ // (auto 模式下即阿里云,引入通道配置前就是如此)。
+ // text_censor_source=qiniu 时链上只有七牛,continue 后循环结束,
+ // 仍由函数末尾返回这份结果,判定不会丢失。
response, err := QiniuTextCensorParser(raw)
if err == nil {
if response.Pass {
@@ -102,14 +97,19 @@ func Text(ctx context.Context, text string) (*TextCensorResponse, error) {
}
case "aliyun":
- // 阿里云缓存命中:能解析就直接复用(省一次 API 调用)。
- // 解析失败必须退回一次实时调用,而不是把错误往上抛:
- // 最典型的失败源是「迁移到内容安全 2.0 之前落库的 1.0 缓存」,
- // 它无法按 2.0 语义解释;若在此返回错误,fail-closed 会让这些
- // 历史文本在缓存过期(31 天)前一直发不出去。
+ // 能解析就复用;解析失败必须退回实时调用而不是把错误往上抛:
+ // 最典型的失败源是迁到内容安全 2.0 前落库的 1.0 缓存,无法按 2.0 语义解释,
+ // 若在此报错,fail-closed 会让这些历史文本在缓存过期前一直发不出去。
+ // 与七牛一致:仅「通过」才直接返回,未通过则交给链上下一个通道复核
+ // (阿里云现为链尾,continue 后循环结束,仍由函数末尾返回同一份结果)。
response, err := AliyunTextCensorParser(raw)
if err == nil {
- return response, nil
+ if response.Pass {
+ return response, nil
+ }
+
+ responses = append(responses, response)
+ continue
}
logrus.WithContext(ctx).WithError(err).WithField("censor_source", sourceName).
Warn("Failed to replay cached aliyun censor result; falling back to a live call")
@@ -119,19 +119,16 @@ func Text(ctx context.Context, text string) (*TextCensorResponse, error) {
}
}
- // response, err := censor.Censor(ctx, text)
- censorText := RemoveTrustedURL(text)
- response, err := censor.Censor(ctx, censorText)
+ response, err := censor.Censor(ctx, normalized)
if err != nil {
logrus.WithContext(ctx).WithError(err).WithField("censor_source", sourceName).Error("Failed to censor text")
continue
}
responses = append(responses, response)
- // Save the censor log to the database.
if err := db.CensorLogs.Create(ctx, db.CreateCensorLogOptions{
SourceName: sourceName,
- Input: text,
+ Input: normalized,
Pass: response.Pass,
RawResponse: response.RawResponse,
}); err != nil {
diff --git a/internal/security/censor/policy.go b/internal/security/censor/policy.go
index 78a3488..c60e0fe 100644
--- a/internal/security/censor/policy.go
+++ b/internal/security/censor/policy.go
@@ -5,40 +5,21 @@
package censor
// Strictness 内容审核严格度档位。
-//
-// 背景:审核服务(本项目为七牛云文本审核)对「辱骂」等维度的召回偏激进,
-// 正常提问/对话经常被判为不通过而被整条拒绝。但我们既不能让审核服务判得更准,
-// 也不能让它判得更松(七牛文本审核不支持白名单,自定义词库需提交工单),
-// 唯一能控制的是「审核命中之后到底拦不拦」。
-//
-// 因此把「是否拦截」从审核服务自身的 Pass 判定中剥离出来,改为由本档位策略裁决,
-// 让箱主在「少误伤」与「少漏放」之间自行取舍。
+// 审核服务调不松、不可配白名单,可控的只有「命中后拦不拦」,故把拦截从 Pass 判定剥离、改由本档位裁决。
type Strictness string
const (
- // StrictnessLax 宽松:仅拦截底线维度,其余一律放行。误伤最小,漏放最多。
+ // StrictnessLax 宽松:仅拦底线维度,误伤最小、漏放最多。
StrictnessLax Strictness = "lax"
- // StrictnessStandard 标准:底线维度 + 高置信度的辱骂/违禁。
+ // StrictnessStandard 标准:底线 + 高置信度的辱骂/违禁。
StrictnessStandard Strictness = "standard"
- // StrictnessStrict 严格:底线维度 + 全部非正常维度(不限置信度)+ 采信审核服务的 block 建议。
- //
- // 这是三个档位中最严的一档,且是唯一保留「采信审核服务整体 block 建议」的档位。
- // 改造前的拦截条件是 `!Pass`(即 涉政/暴恐/色情 命中,或审核服务建议为 block),
- // 该条件可被本档完全覆盖,但本档会额外拦截全部带维度的非正常内容(如 review 的辱骂)。
- // 换言之:严格档 ⊇ 改造前的行为,是「至少与改造前一样严」,并非完全等价。
+ // StrictnessStrict 严格:底线 + 全部非正常维度(不限置信度)+ 采信服务 block 建议。
+ // 严格档 ⊇ 改造前行为(改造前为 !Pass),「至少与改造前一样严」,并非完全等价。
StrictnessStrict Strictness = "strict"
)
-// DefaultStrictness 缺省档位。
-//
-// 取 strict 的取舍:本能力只用于「让箱主把审核调松」,从不用于「在用户未选择时把审核调松」。
-// 存量账号从未设置过该值,缺省落到最严档,保证部署本功能这一动作本身不会让任何账号
-// 的拦截面变小(合规上更保守);想减少误伤的箱主再显式切到 标准 / 宽松。
-//
-// ⚠️ 注意方向:因为严格档比改造前更严(见 StrictnessStrict 注释),缺省为 strict 意味着
-// 部署后默认拦截面会略有扩大 —— 这是刻意的取舍(已于 2026-09-10 确认维持 strict):
-// 合规上宁紧勿松,且「调松」应当是箱主的显式动作,部署本身不应悄悄放宽任何账号。
-// 若哪天希望默认更少误伤,把下面这行改成 StrictnessStandard 即可(一行常量)。
+// DefaultStrictness 缺省取 strict:部署不应悄悄放宽任何账号,「调松」必须是箱主显式动作;
+// 想默认更少误伤可改 Standard。
const DefaultStrictness = StrictnessStrict
// Suggestion 审核服务自身给出的管控建议取值。
@@ -48,14 +29,9 @@ const (
SuggestionPass = "pass"
)
-// baselineBlockLabels 底线拦截维度:涉政 / 暴恐 / 色情 / 自定义词库。
-//
-// 这是合规底线:任何档位下都强制生效、不受置信度门槛约束、不可关闭,
-// 也不能通过任何用户配置绕过 —— 见 Decide 中它始终排在最先、无条件返回拦截。
-//
-// ForbiddenTypeCustomized 归入底线的原因:它是运营方在审核服务控制台(阿里云)
-// 显式配置的词库命中,属「用户明写的规则」,与箱主自定义屏蔽词同一性质,
-// 不应被档位调低后放行。运营方若要放开,正确的做法是调整控制台词库,而不是调档位。
+// baselineBlockLabels 底线拦截维度:涉政/暴恐/色情/自定义词库。
+// 合规底线:任何档位强制生效、不受置信度约束、不可关闭、不可绕过(见 Decide 最先判定)。
+// Customized 归入底线:它是控制台显式配置的词库,要放开应改控制台词库而非调档位。
var baselineBlockLabels = map[ForbiddenType]struct{}{
ForbiddenTypePolitics: {},
ForbiddenTypeTerrorism: {},
@@ -63,35 +39,23 @@ var baselineBlockLabels = map[ForbiddenType]struct{}{
ForbiddenTypeCustomized: {},
}
-// abuseScoreFloor 标准档位下「辱骂」维度生效所需的最低置信度。
-//
-// 辱骂是审核服务误伤最集中的维度(普通吐槽、语气较冲的表达常被判为辱骂),
-// 因此标准档要求高置信度才拦截;严格档则为任意置信度。
-//
-// 注:置信度的来源因服务商而异。七牛直接给 0~1 的 score;阿里云内容安全 2.0 只给
-// 整条内容的 riskLevel,折算为 1 / 0.6 / 0.3(high / medium / low),因此本阈值
-// 在阿里云下的实际语义是「标准档只拦 riskLevel=high 的辱骂」。
+// abuseScoreFloor 标准档下「辱骂」生效所需最低置信度。
+// 辱骂误伤最集中(吐槽常被判辱骂),故标准档要求高置信度;严格档不限。阿里云 2.0 只给 riskLevel,下语义为「只拦 high」。
const abuseScoreFloor = 0.9
// levelPolicy 单一档位的拦截规则。
type levelPolicy struct {
- // blockLabels 该档位额外拦截的维度 -> 生效所需最低置信度(0 表示不限置信度)。
- // 注意:底线维度不在此表中,由 Decide 无条件前置判定。
+ // blockLabels 该档位额外拦截的维度 -> 生效最低置信度(0 表示不限)。底线维度不在此表,由 Decide 前置判定。
blockLabels map[ForbiddenType]float64
- // blockOnSuggestion 是否采信审核服务自身的 block 建议。
- //
- // 该建议不携带具体维度(可能是任何原因),无法做置信度收敛,误伤面最大,
- // 也是此前正常对话被整条拒绝的主要来源,因此仅严格档启用。
+ // blockOnSuggestion 是否采信服务自身的 block 建议:不携带维度、无法收敛置信度,误伤面最大,仅严格档启用。
blockOnSuggestion bool
}
var strictnessPolicies = map[Strictness]levelPolicy{
- // 宽松:只留底线,其余全部放行。
StrictnessLax: {
blockLabels: map[ForbiddenType]float64{},
blockOnSuggestion: false,
},
- // 标准:底线 + 高置信度辱骂 + 违禁。
StrictnessStandard: {
blockLabels: map[ForbiddenType]float64{
ForbiddenTypeAbuse: abuseScoreFloor,
@@ -99,7 +63,6 @@ var strictnessPolicies = map[Strictness]levelPolicy{
},
blockOnSuggestion: false,
},
- // 严格:底线 + 全部非正常维度(不限置信度)+ 采信审核服务 block 建议。
StrictnessStrict: {
blockLabels: map[ForbiddenType]float64{
ForbiddenTypeSpam: 0,
@@ -127,8 +90,8 @@ const (
// Blocked 是否应当拒绝提交。
func (d Decision) Blocked() bool { return d == DecisionBlock }
-// IsValidStrictness 判断取值是否为合法档位,供后台保存前校验。
-// 非法取值一律拒绝写入,避免脏数据把账号静默切到非预期档位。
+// IsValidStrictness 校验档位取值合法性,供后台保存前校验;
+// 非法值一律拒绝写入,避免脏数据把账号静默切到非预期档位。
func IsValidStrictness(s string) bool {
switch Strictness(s) {
case StrictnessLax, StrictnessStandard, StrictnessStrict:
@@ -138,8 +101,8 @@ func IsValidStrictness(s string) bool {
}
}
-// ParseStrictness 归一化严格度取值,未知/空值回退到 DefaultStrictness(保守方向,即最严)。
-// 方向刻意选保守:宁可沿用严格判定,也不要因为识别失败把账号静默放到最松档。
+// ParseStrictness 归一化严格度,未知/空值回退 DefaultStrictness(最严方向);
+// 宁可沿用严格判定,也不要因识别失败静默放到最松档。
func ParseStrictness(s string) Strictness {
if !IsValidStrictness(s) {
return DefaultStrictness
@@ -147,15 +110,9 @@ func ParseStrictness(s string) Strictness {
return Strictness(s)
}
-// Decide 按档位裁决一次内容审核结果,返回裁决结果与触发拦截的维度(未命中具体维度时为 nil)。
-//
-// 判定顺序固定为「底线维度 → 档位维度 → 审核服务建议」,且不可调换:
-// 1. 底线维度排在最前且无条件返回拦截,保证任何档位都无法把它关掉;
-// 2. 档位维度带各自的置信度门槛,用于收敛误伤最集中的维度;
-// 3. 审核服务自身的 block 建议排在最后,且仅严格档启用。
-//
-// Details 为空(例如审核服务返回了 suggestion=block 但没给出任何非 normal 维度)时,
-// 只有开启 blockOnSuggestion 的档位会拦截。
+// Decide 按档位裁决一次审核结果,返回裁决与触发拦截的维度(无具体维度时 nil)。
+// 判定顺序固定不可调换:底线(无条件)→ 档位维度(带置信度门槛)→ 服务 block 建议(仅严格档)。
+// Details 为空时仅开启 blockOnSuggestion 的档位会拦截。
func Decide(strictness string, r *TextCensorResponse) (Decision, *LabelScore) {
if r == nil {
return DecisionAllow, nil
@@ -163,7 +120,7 @@ func Decide(strictness string, r *TextCensorResponse) (Decision, *LabelScore) {
policy := strictnessPolicies[ParseStrictness(strictness)]
- // 1. 底线:合规底线,任何档位强制生效、不可关闭。
+ // 1. 底线:任何档位强制生效、不可关闭。
for i := range r.Details {
detail := &r.Details[i]
if _, ok := baselineBlockLabels[detail.Label]; ok {
@@ -171,7 +128,7 @@ func Decide(strictness string, r *TextCensorResponse) (Decision, *LabelScore) {
}
}
- // 2. 档位维度:命中所配维度且达到该维度的置信度门槛。
+ // 2. 档位维度:命中维度且达到置信度门槛。
for i := range r.Details {
detail := &r.Details[i]
scoreFloor, ok := policy.blockLabels[detail.Label]
@@ -184,7 +141,7 @@ func Decide(strictness string, r *TextCensorResponse) (Decision, *LabelScore) {
return DecisionBlock, detail
}
- // 3. 审核服务自身的 block 建议:仅严格档采信。
+ // 3. 服务 block 建议:仅严格档采信。
if policy.blockOnSuggestion && r.ProviderSuggestion == SuggestionBlock {
return DecisionBlock, nil
}
@@ -192,12 +149,8 @@ func Decide(strictness string, r *TextCensorResponse) (Decision, *LabelScore) {
return DecisionAllow, nil
}
-// BlockedLabels 返回指定档位最终会拦截的全部维度(含底线维度)。
-//
-// 用途:策略层的自描述 API —— 目前由 policy_test.go 用来守住「底线维度在每个档位都必须出现
-// 且不重复」这条不变量。设置页展示的拦截范围是 i18n 静态文案
-// (templates/user/moderation-strictness.html),尚未消费本函数;若将来要让 UI 直接由策略层
-// 生成文案,这里是唯一事实来源。
+// BlockedLabels 返回指定档位最终拦截的全部维度(含底线)。
+// 不变量由 policy_test.go 守住(底线维度在每个档位都出现且不重复);设置页文案仍是 i18n 静态文本,未消费本函数。
func BlockedLabels(strictness string) []ForbiddenType {
policy := strictnessPolicies[ParseStrictness(strictness)]
diff --git a/internal/security/censor/text.go b/internal/security/censor/text.go
index f43ec11..5212014 100644
--- a/internal/security/censor/text.go
+++ b/internal/security/censor/text.go
@@ -24,9 +24,8 @@ const (
ForbiddenTypeContraband ForbiddenType = "contraband"
ForbiddenTypeMeaningless ForbiddenType = "meaningless"
ForbiddenTypeHarmful ForbiddenType = "harmful"
- // ForbiddenTypeCustomized 命中审核服务侧的自定义词库(阿里云控制台配置,
- // 七牛不支持服务端词库因此不会返回该维度)。属运营方显式配置的规则,
- // 在 policy.go 中被归入底线集合,任何档位都不放行。
+ // ForbiddenTypeCustomized 命中服务侧自定义词库(阿里云控制台配置,七牛无服务端词库)。
+ // 属运营方显式规则,在 policy.go 中归入底线集合,任何档位都不放行。
ForbiddenTypeCustomized ForbiddenType = "customized"
)
@@ -46,16 +45,12 @@ func (f ForbiddenType) String() string {
}[f]
}
-// LabelScore 审核服务针对单个维度(label)给出的判定与置信度。
+// LabelScore 单个维度(label)的判定与置信度。过去只取首个非 normal 维度,
+// 丢掉了其余维度信息,无法按维度差异化处置(例如只拦高置信度的辱骂)。
//
-// 各服务商都会返回「多维度 + 每个维度一个分值」的结果,过去只取首个非 normal 维度,
-// 丢掉了其余维度的信息,导致无法按维度做差异化处置(例如只拦高置信度的辱骂)。
-//
-// ⚠️ Score 已统一归一化到 0~1,与提供方无关:七牛本身即 0~1。
-// 阿里云内容安全 2.0 不返回逐标签置信度,只返回整条内容的 riskLevel,
-// 由 AliyunTextCensorParser 折算为 0~1(high=1 / medium=0.6 / low=0.3)后由该响应的
-// 所有标签共用 —— 这是有损的近似,详见 AliyunTextCensorParser 与 aliyunRiskLevelScore 的注释。
-// policy.go 的阈值因此对两家服务商都成立(对阿里云的语义为「按风险级别分级」)。
+// ⚠️ Score 统一归一化到 0~1:七牛本身即 0~1;阿里云 2.0 无逐标签置信度,只给整条内容的
+// riskLevel,由 AliyunTextCensorParser 折算(high=1 / medium=0.6 / low=0.3)后该响应内所有
+// 标签共用——有损近似。policy.go 的阈值因此对两家都成立(对阿里云语义为「按风险级别分级」)。
type LabelScore struct {
Label ForbiddenType `json:"label"`
Score float64 `json:"score"`
@@ -68,11 +63,9 @@ type TextCensorResponse struct {
Hint string `json:"hint"`
Confidence float64 `json:"confidence"`
RawResponse json.RawMessage `json:"raw_response"`
- // Details 全部非 normal 维度的判定明细,供严格度策略按维度裁决。
- // 仅在回放缓存结果时为 nil(见 censor.go),此时 Pass 已足以表达判定。
- Details []LabelScore `json:"details,omitempty"`
- // ProviderSuggestion 审核服务自身给出的管控建议(block / review / pass)。
- // 它不携带具体维度,无法做置信度收敛,因此只在严格档被采信(见 policy.go)。
+ Details []LabelScore `json:"details,omitempty"` // 全部非 normal 维度,供严格度策略裁决;由各 parser 从 RawResponse 重建,缓存回放同样有值(故改档位对历史缓存也生效)
+ // ProviderSuggestion 服务方管控建议(block / review / pass)。不携带维度、无法做
+ // 置信度收敛,因此只在严格档被采信(见 policy.go)。
ProviderSuggestion string `json:"provider_suggestion,omitempty"`
}
@@ -81,12 +74,9 @@ func (r *TextCensorResponse) ToJSON() []byte {
return jsonBytes
}
-// ErrorMessageFor 生成针对指定命中维度的拒绝提示。
-//
-// 与已移除的 ErrorMessage 的区别:后者固定使用首个非 normal 维度,而实际触发拦截的
-// 可能是另一个维度(策略层按档位裁决),两者不一致时用户会看到对不上的原因。
-// 因此拦截时统一用本方法,按真正命中的维度出文案。
-// detail 为 nil(即由审核服务的整体 block 建议触发)时退化为不带维度的基础文案。
+// ErrorMessageFor 按真正命中的维度生成拒绝提示——已移除的 ErrorMessage 固定用首个非
+// normal 维度,而触发拦截的可能是另一维度(策略层按档位裁决),会导致文案对不上。
+// detail 为 nil(由服务方整体 block 建议触发)时退化为不带维度的基础文案。
func (r *TextCensorResponse) ErrorMessageFor(detail *LabelScore) string {
errorMessage := "内容安全检查不通过"
if detail != nil && detail.Label != "" {
@@ -110,8 +100,7 @@ var TrustedHosts = []string{
"acfun.cn",
"aixifan.com",
"github.com",
- // 自有域名:旧域 tama.guru 保留至 2026-12 到期前的过渡期结束,
- // 期间用户分享的旧链接仍需视为可信(不被判为广告);新域 shiroko.one 为主域。
+ // 旧域 tama.guru 保留至 2026-12 到期,过渡期内旧分享链接仍需视为可信。
"tama.guru",
"shiroko.one",
"337845818.best",
@@ -119,10 +108,9 @@ var TrustedHosts = []string{
"bilibili.com",
}
-// RemoveTrustedURL removes the trusted URL from the text,
-// in order to bypass the URL censoring.
-// 仅当可信域名后紧跟路径分隔符(/ ? #)或位于字符串结尾时才视为可信,
-// 避免 github.com.evil.tld 被朴素替换误判为 github.com 之子域而漏审。
+// RemoveTrustedURL 移除可信域名以免链接被判为广告。
+// 仅当可信域名后紧跟路径分隔符(/ ? #)或位于串尾时才视为可信,
+// 避免 github.com.evil.tld 被朴素替换误判为 github.com 子域而漏审。
func RemoveTrustedURL(str string) string {
for _, host := range TrustedHosts {
for _, protocol := range []string{"http://", "https://"} {
diff --git a/internal/shared/ip.go b/internal/shared/ip.go
index b5c7f2c..1ae1f1d 100644
--- a/internal/shared/ip.go
+++ b/internal/shared/ip.go
@@ -9,11 +9,8 @@ import (
"github.com/tamakyi/TamaBox/internal/conf"
)
-// NormalizeIP 规范化 IP 字符串:
-// - 去除两端空白
-// - 剥离端口("1.2.3.4:8080" / "[::1]:8080")
-// - 校验合法性,非法返回空串
-// - IPv4-mapped IPv6(::ffff:1.2.3.4)归一为 IPv4,便于黑名单/限流精确匹配
+// NormalizeIP 规范化 IP:去空白、剥端口、校验合法性(非法返回空串),
+// 并把 IPv4-mapped IPv6(::ffff:1.2.3.4)归一为 IPv4,便于黑名单 / 限流精确匹配。
func NormalizeIP(s string) string {
s = strings.TrimSpace(s)
if s == "" {
@@ -33,21 +30,15 @@ func NormalizeIP(s string) string {
}
// LimitKey 把客户端 IP 归一化为「限流 / 自动封禁」用的聚合键。
+// IPv4 原样;IPv6 掩码到 /64 —— 住宅 / 隧道分配通常就是一个 /64(2^64 个地址),
+// 若按完整地址计数,攻击者每请求换源地址即可让阈值永不触发,且键会无限膨胀内存。
//
-// - IPv4:原样返回(每个地址独立计数)。
-// - IPv6:掩码到 /64 前缀后返回,形如 "2001:db8:1:2::/64"。
-//
-// 为什么 IPv6 必须聚合:单个住宅或隧道 IPv6 分配通常就是一个 /64(2^64 个地址)。
-// 若以完整地址作键,攻击者每次请求换一个源地址即可让每个键只命中一次,
-// 使限流与 autoban 的阈值永远无法触发,同时海量键还会持续膨胀内存。
-// /64 是 IPv6 的最小实际分配单位,按此聚合可在不误伤正常用户的前提下堵住绕过。
-//
-// ⚠️ 仅用于限流 / 封禁计数。IP 黑名单必须继续使用精确匹配(见 ipblacklist.go),
-// 不要套用本函数,否则会把整个 /64 段一并拉黑。
+// ⚠️ 仅用于限流 / 封禁计数。IP 黑名单必须继续精确匹配(见 ipblacklist.go),
+// 否则会把整个 /64 段一并拉黑。
func LimitKey(ip string) string {
parsed := net.ParseIP(ip)
if parsed == nil {
- // 无法解析时原样返回,保持调用方行为可预期(空串由调用方自行判断)。
+ // 无法解析时原样返回,保持调用方可预期(空串由调用方自行判断)。
return ip
}
if v4 := parsed.To4(); v4 != nil {
@@ -59,14 +50,11 @@ func LimitKey(ip string) string {
// trustedProxyNets 缓存解析后的可信代理网段(conf.Init 之后由 InitTrustedProxies 填充)。
var trustedProxyNets []*net.IPNet
-// InitTrustedProxies 解析 conf.Security.TrustedProxies(逗号分隔的 IP / CIDR 列表),
-// 仅限“你自己的”反向代理/负载均衡基础设施(例如本机 nginx 的 127.0.0.1、私有网络 LB),
-// 在 conf.Init 之后调用一次。列表为空时,X-Forwarded-For / X-Real-IP 永不被信任。
+// InitTrustedProxies 解析 conf.Security.TrustedProxies(逗号分隔 IP / CIDR),在 conf.Init 之后调用一次。
+// 仅限"你自己的"反向代理 / LB(如本机 nginx 127.0.0.1、私有网络 LB);列表为空时 XFF / X-Real-IP 永不被信任。
//
-// 设计目标:避免维护 Cloudflare / EdgeOne 等上游 CDN 的庞大 CIDR 清单。
-// 站点若架在 CDN 后,应配合 Cloudflare Tunnel / Authenticated Origin Pull(Cloudflare)或
-// 防火墙仅放行回源网段(Cloudflare / EdgeOne)锁定源站只接受 CDN 连接,此时
-// CF-Connecting-IP / EO-Client-IP 即为权威客户端 IP,无需任何 CIDR 白名单。
+// 不为 CDN 维护庞大 CIDR 清单:站点架在 CDN 后时,应配合 Tunnel / Authenticated Origin Pull
+// 或防火墙仅放行回源网段锁定源站,此时 CF-Connecting-IP / EO-Client-IP 即权威客户端 IP。
func InitTrustedProxies() error {
trustedProxyNets = nil
for _, raw := range strings.Split(conf.Security.TrustedProxies, ",") {
@@ -114,36 +102,22 @@ func isTrustedProxy(ipStr string) bool {
// ResolveClientIP 统一解析客户端真实 IP,供速率限制、IP 黑名单与审计记录共用。
//
-// 解析策略(不依赖上游 CDN 的庞大 CIDR 清单):
-// 1. 未开启 TrustProxy:一切以 TCP 对端 RemoteAddr 为准(不可伪造)。
-// 2. 开启 TrustProxy:
-// a. 优先信任 CDN 权威客户端头:CF-Connecting-IP(Cloudflare)→ EO-Client-IP
-// (腾讯 EdgeOne)。这类头只能由对应 CDN 边缘按真实客户端写入,前提是源站已锁定
-// 为仅接受这些 CDN 的回源连接(攻击者无法直连源站伪造)。
-// b. 仅当 TCP 对端落在 trusted_proxies(你自己的基础设施)内时,才解析
-// X-Forwarded-For / X-Real-IP,并从右向左剥离已知可信跳数取原始客户端。
-// c. 否则回退到 RemoteAddr,绝不信任客户端自行提供的头。
+// 策略:未开 TrustProxy 时一律用不可伪造的 TCP RemoteAddr;开启后 (a) 先信 CDN 权威头
+// CF-Connecting-IP → EO-Client-IP,(b) 仅当 TCP 对端落在 trusted_proxies 内才从右向左
+// 解析 XFF / X-Real-IP,(c) 否则回退 RemoteAddr,绝不信任客户端自供的头。
//
-// ⚠️ 若 trust_proxy = true 但源站仍可被人直连(未用 Tunnel / 未锁源站),攻击者可自行
-// 发送 CF-Connecting-IP / EO-Client-IP 头,此时本函数会信任之。因此生产开启 trust_proxy
-// 时,务必配合「Cloudflare Tunnel / Authenticated Origin Pull / 防火墙仅放行 CDN 回源
-// 网段」等手段锁定源站,使伪造头无法抵达应用。
+// ⚠️ trust_proxy=true 但源站可被直连(未用 Tunnel / 未锁源站)时,攻击者可自造
+// CF-Connecting-IP / EO-Client-IP 并被本函数信任 —— 生产开启必须锁定源站只接受 CDN 回源。
//
-// 双 CDN 部署(境内 EdgeOne + 境外 Cloudflare)的额外约束:
-// 若回源请求携带对方 CDN 的权威头(经 EdgeOne 回源的请求带 CF-Connecting-IP、经
-// Cloudflare 回源的请求带 EO-Client-IP),攻击者即可借道另一家伪造身份,绕过限流 /
-// 自动封禁 / 单 IP 投票限制,或嫁祸他人。因此必须:① 防火墙仅放行两家 CDN 的回源网段;
-// ② 在各家 CDN 的规则引擎中删除入站请求里「对方 CDN」的权威头,使每个请求最终只携带
-// 自己所在 CDN 的头(操作步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md)。
-// Cloudflare 侧因 CF-Connecting-IP 恒被覆盖,且用 Transform Rule 删掉 EO-Client-IP 即可;
-// EdgeOne 侧用规则引擎删掉 CF-Connecting-IP 即可。
+// ⚠️ 双 CDN(境内 EdgeOne + 境外 Cloudflare)时必须:① 防火墙仅放行两家回源网段;
+// ② 在各家规则引擎中删除入站请求里「对方 CDN」的权威头,否则攻击者可借道另一家伪造身份,
+// 绕过限流 / 自动封禁 / 单 IP 投票限制。步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md。
func ResolveClientIP(r *http.Request) string {
if !conf.Security.TrustProxy {
return NormalizeIP(r.RemoteAddr)
}
- // (a) CDN 权威客户端头:CF-Connecting-IP(Cloudflare)优先,其次 EO-Client-IP(腾讯
- // EdgeOne,等效于 CF-Connecting-IP 的权威头,回源时由 EdgeOne 边缘写入客户端真实 IP)。
+ // (a) CDN 权威客户端头(只能由 CDN 边缘写入):Cloudflare 优先,其次腾讯 EdgeOne。
if cfIP := NormalizeIP(r.Header.Get("CF-Connecting-IP")); cfIP != "" {
return cfIP
}
diff --git a/internal/storage/s3.go b/internal/storage/s3.go
index 7de1aaf..b9de464 100644
--- a/internal/storage/s3.go
+++ b/internal/storage/s3.go
@@ -109,24 +109,22 @@ func uploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader, as
return "", errors.Wrap(err, "reset reader")
}
- // 4. 根据格式进行转换或直接使用原图
+ // 4. 按格式转换或直接使用原图
switch format {
case "gif":
webpData, err := convertGifToAnimatedWebP(fileData)
if err != nil {
- // 转换失败则回退到原始 GIF
- uploadData = fileData
+ uploadData = fileData // 转换失败回退原始 GIF
contentType = "image/gif"
} else {
uploadData = webpData
contentType = "image/webp"
}
default:
- // 静态图片(JPEG / PNG / WebP 等)转换为静态 WebP
+ // 静态图片(JPEG / PNG / WebP 等)转静态 WebP
webpData, err := ConvertStaticImageToWebP(fileReader)
if err != nil {
- // 转换失败时回退到原始图片
- uploadData = fileData
+ uploadData = fileData // 转换失败回退原图
contentType = mimeTypeFromFormat(format)
} else {
uploadData = webpData
diff --git a/internal/template/markdown.go b/internal/template/markdown.go
index deeee2f..1ebc8c6 100644
--- a/internal/template/markdown.go
+++ b/internal/template/markdown.go
@@ -15,10 +15,9 @@ import (
"golang.org/x/net/html"
)
-// blockEndTags 与 reBlockEndBrList:删除块级元素闭合标签紧前的
。
-// HardLineBreak 会把块级元素末尾的换行也渲染成
,产生多余空行;
-// 这里仅清除末尾的
(可含中间空白),行内真正的换行保留。
-// 注意:RE2 不支持 lookahead,故对每个闭合标签单独编译。
+// blockEndTags / reBlockEndBrList 删除块级元素闭合标签紧前的
:HardLineBreak 会把
+// 块级元素末尾的换行也渲染成
,产生多余空行(行内换行保留)。
+// RE2 不支持 lookahead,故对每个闭合标签单独编译。
var blockEndTags = []string{
"", "
", "", "", "", "", "", "",
"", "", "",
@@ -27,7 +26,6 @@ var blockEndTags = []string{
var reBlockEndBrList = func() []*regexp.Regexp {
res := make([]*regexp.Regexp, 0, len(blockEndTags))
for _, tag := range blockEndTags {
- // 捕获闭合标签本身,替换时用 $1 保留
res = append(res, regexp.MustCompile(`
[ \t\r\n]*(`+regexp.QuoteMeta(tag)+`)`))
}
return res
@@ -36,8 +34,7 @@ var reBlockEndBrList = func() []*regexp.Regexp {
// reBlankLines 压缩连续换行为单个换行(MarkdownToPlainText 提取时块级/br 会产生多个 \n)。
var reBlankLines = regexp.MustCompile(`\n{2,}`)
-// PlainText 渲染 Markdown 为纯文本并压缩连续空行为单个换行,
-// 供列表/摘要等紧凑展示场景使用(保留文字与换行,不显示 Markdown 符号与排版)。
+// PlainText 渲染为纯文本并压缩连续空行,供列表/摘要等紧凑场景使用。
func PlainText(markdown string) string {
if strings.TrimSpace(markdown) == "" {
return ""
@@ -45,15 +42,9 @@ func PlainText(markdown string) string {
return reBlankLines.ReplaceAllString(MarkdownToPlainText(markdown), "\n")
}
-// MarkdownToHTML 将 Markdown 渲染为安全的 HTML 片段(供模板直接输出)。
-//
-// 安全性:先用 blackfriday 渲染,再经白名单过滤器清洗:
-// - 只保留常用排版标签与属性(a[href]/img[src,alt,title] 等)
-// - 丢弃 script / style / iframe / object / embed / form 等危险标签
-// - href/src 仅允许 http/https/mailto,其余一律去除
-// - 剥离所有 on* 事件属性
-//
-// 这样既支持 Markdown 排版,又不会引入存储型 XSS。
+// MarkdownToHTML 渲染为安全的 HTML 片段。先由 blackfriday 渲染,再经白名单过滤:
+// 只保留常用排版标签与属性;丢弃 script / style / iframe / form 等危险标签;
+// href/src 仅允许 http/https/mailto;剥离所有 on* 事件属性。避免存储型 XSS。
func MarkdownToHTML(markdown string) template.HTML {
if strings.TrimSpace(markdown) == "" {
return ""
@@ -117,7 +108,6 @@ func extractPlainText(raw []byte) string {
}
}
-// 允许的标签集合。
var allowedTags = map[string]bool{
"p": true, "br": true, "hr": true, "div": true, "span": true,
"strong": true, "em": true, "b": true, "i": true, "u": true, "s": true,
@@ -141,13 +131,12 @@ func sanitizeHTML(raw []byte) string {
case html.ErrorToken:
return out.String()
case html.TextToken:
- // Tokenizer.Text returns unescaped text. Escape it again before
- // rebuilding HTML, otherwise input such as <script> can become
- // executable markup in the template.HTML result.
+ // ⚠️ Tokenizer.Text 返回已反转义文本,重建 HTML 前必须重新转义,
+ // 否则 <script> 这类输入会在 template.HTML 结果中变成可执行标记。
out.WriteString(stdhtml.EscapeString(string(tokenizer.Text())))
case html.StartTagToken, html.SelfClosingTagToken:
- // SelfClosingTagToken 覆盖 `
` 这类自闭合写法(blackfriday 的硬换行即此形式),
- // 漏掉会导致
被丢弃、换行失效。
+ // SelfClosingTagToken 覆盖 `
` 自闭合写法(blackfriday 硬换行即此形式),
+ // 漏掉会丢弃
、换行失效。
writeSanitizedTag(&out, tokenizer.Token(), false)
case html.EndTagToken:
writeSanitizedTag(&out, tokenizer.Token(), true)
@@ -182,7 +171,6 @@ func writeSanitizedTag(out *bytes.Buffer, tok html.Token, isEnd bool) {
key := strings.ToLower(attr.Key)
val := strings.TrimSpace(attr.Val)
- // 过滤事件属性与危险属性
if strings.HasPrefix(key, "on") {
continue
}
@@ -247,8 +235,7 @@ func safeURL(u string) bool {
lower := strings.ToLower(strings.TrimSpace(u))
// 必须先于下面的 "/" 前缀判断:协议相对 URL(//evil.com)同样以 "/" 开头,
- // 若不单独拦截会被误判为站内相对路径,从而绕过"仅 http/https/mailto"的白名单本意,
- // 使 markdown 中的 [x](//evil.com) 渲染成指向外站的链接(钓鱼 / 外链埋点)。
+ // 不单独拦截会被误判为站内相对路径,[x](//evil.com) 即可指向外站(钓鱼 / 外链埋点)。
// 反斜杠变体(\\evil.com、/\evil.com)在部分浏览器中等价于 //,一并拒绝。
if strings.HasPrefix(lower, "//") || strings.HasPrefix(lower, `\\`) ||
strings.HasPrefix(lower, `/\`) {
diff --git a/route/question/follow_up.go b/route/question/follow_up.go
index 2838785..075ea58 100644
--- a/route/question/follow_up.go
+++ b/route/question/follow_up.go
@@ -128,7 +128,7 @@ func PublishFollowUp(ctx context.Context, pageUser *db.User, question *db.Questi
// 邮件通知提问箱主人(开启邮件提醒时)
shared.SafeGo(func() {
if pageUser.Notify == db.NotifyTypeEmail {
- if err := mail.SendNewFollowUpMail(pageUser.Email, pageUser.Domain, question.ID, question.Content, f.Content); err != nil {
+ if err := mail.SendNewFollowUpMail(pageUser.Email, pageUser.Domain, question.ID, question.Content, f.Content, question.Token); err != nil {
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new follow up mail to user")
}
}
diff --git a/route/user/harassment.go b/route/user/harassment.go
index 08c6e13..006be5c 100644
--- a/route/user/harassment.go
+++ b/route/user/harassment.go
@@ -15,24 +15,17 @@ import (
"github.com/tamakyi/TamaBox/internal/i18n"
)
-// returnTab 表单提交后回跳的账号设置面板:屏蔽词设置已分散展示在
-// 提问/私聊/投票三个面板,action 携带 ?tab= 来源参数,保存后停留原面板。
+// returnTab 表单提交后回跳的面板:action 携带 ?tab= 来源参数,保存后停留原面板。
func returnTab(ctx context.Context) string {
return profileURLWithTab(ProfileTab(ctx.Query("tab")))
}
-// abortOnFormError 处理表单校验/反序列化失败:返回 true 表示已中止本次写入。
+// abortOnFormError 处理表单校验/反序列化失败:返回 true 表示已中止本次写入,不触碰既有配置。
//
-// 背景:form.Bind 在反序列化失败(如请求体超限、body 畸形)时只置位错误并继续执行
-// handler 链(flamego 语义:响应未写即自动续链),零值结构体会照常进入业务动作——
-// 对不检查 ctx.HasError() 的 handler,会把用户既有配置静默清空/关闭,例如:
-// - UpdateHarassment:RegisterOnly="" → 防骚扰被改回 none(关闭"仅允许注册用户");
-// - UpdateBlockWords:BlockWords="" → 全部屏蔽词被清空;
-// - UpdateTags:Tags="" → 标签被清空、ShowInHome 被关;
-// - UpdatePagePassword / UpdateAskPassword:Password="" → 访问/提问密码被解除。
-//
-// 错误信息已由 Bind 经 context.SetError 写入 Data["Error"](govalid 校验错误已按界面
-// 语言翻译,解析错误为原文),此处转入 flash,保证 PRG 重定向后仍能向用户展示。
+// ⚠️ form.Bind 反序列化失败(请求体超限、body 畸形)时只置位错误并继续走 handler 链
+// (flamego 语义:响应未写即自动续链),零值结构体会进入业务动作,把用户既有配置静默清空:
+// 防骚扰被改回 none、屏蔽词/标签被清空、访问与提问密码被解除。
+// 错误信息已由 Bind 写入 Data["Error"],此处转入 flash 以便 PRG 重定向后仍可展示。
func abortOnFormError(ctx context.Context, redirectTo string) bool {
if !ctx.HasError() {
return false
@@ -44,12 +37,11 @@ func abortOnFormError(ctx context.Context, redirectTo string) bool {
return true
}
-// UpdateHarassment 仅更新「防骚扰类型」(none / register_only,即"仅允许注册用户向我提问")。
-// 只写 harassment_setting 列;全局屏蔽词已拆分为独立入口 UpdateBlockWords,
-// 二者互不覆盖(此前同一个表单一次写两列,面板拆分后单侧保存会误清空另一侧)。
+// UpdateHarassment 仅更新防骚扰类型(none / register_only)。只写 harassment_setting 列:
+// 全局屏蔽词已拆分为 UpdateBlockWords,二者互不覆盖(同表单一次写两列时,
+// 面板拆分后单侧保存会误清空另一侧)。
func UpdateHarassment(ctx context.Context, f form.UpdateHarassment) {
- // 反序列化失败(如请求体超限)时零值 RegisterOnly="" 会把防骚扰静默改回 none
- // (若此前为 register_only)——先中止,不触碰现有设置。
+ // 零值 RegisterOnly="" 会把防骚扰静默改回 none(见 abortOnFormError)。
if abortOnFormError(ctx, returnTab(ctx)) {
return
}
@@ -70,11 +62,10 @@ func UpdateHarassment(ctx context.Context, f form.UpdateHarassment) {
ctx.Redirect(returnTab(ctx))
}
-// UpdateBlockWords 更新全局屏蔽词表:提问、私聊(对方发给我的消息)与投票
-// 提交的开放文本共用同一份词表(users.block_words)。
+// UpdateBlockWords 更新全局屏蔽词表:提问、私聊(对方发给我的消息)与投票提交的
+// 开放文本共用同一份词表(users.block_words)。
func UpdateBlockWords(ctx context.Context, f form.UpdateBlockWords) {
- // 反序列化失败(如请求体超限)时零值 BlockWords="" 会经 sanitizeBlockWords 得到
- // 空词表,静默清空用户全部屏蔽词——先中止,不触碰现有词表。
+ // 零值 BlockWords="" 会经 sanitizeBlockWords 得到空词表,静默清空全部屏蔽词。
if abortOnFormError(ctx, returnTab(ctx)) {
return
}
@@ -101,12 +92,9 @@ func UpdateBlockWords(ctx context.Context, f form.UpdateBlockWords) {
ctx.Redirect(returnTab(ctx))
}
-// sanitizeBlockWords 把输入的屏蔽词串规整为去重后的词表:
-// - 全角逗号转半角;逐词 TrimSpace;跳过空词与重复词。
-// - 单词长度按字符(rune)计数,超限(conf.Limits.MaxBlockWordLen)返回超长提示;
-// - 词总数超限(conf.Limits.MaxBlockWords)返回数量提示。
-//
-// 成功时 errKey 为空,errWord 为触发超长的具体词(供 flash 展示)。
+// sanitizeBlockWords 把输入词串规整为去重词表:全角逗号转半角、逐词 TrimSpace、
+// 跳过空词与重复词;单词长度超限(conf.Limits.MaxBlockWordLen)或总数超限
+// (conf.Limits.MaxBlockWords)返回对应提示。成功时 errKey 为空,errWord 为超长的具体词。
func sanitizeBlockWords(raw string) (words []string, errKey string, errWord string) {
blockWords := strings.ReplaceAll(raw, ",", ",")
blockWords = strings.TrimSpace(blockWords)
@@ -123,9 +111,8 @@ func sanitizeBlockWords(raw string) (words []string, errKey string, errWord stri
}
wordSet[word] = struct{}{}
- // 按字符(rune)计数:早期用 len(word) 按字节计数,中文一词 3 字节,
+ // 按 rune 计数:早期用 len(word) 按字节算,中文一词 3 字节,
// 导致中文屏蔽词实际只能输 3 个字,与界面文案「最大长度为 10」不符。
- // 上限取自 conf.Limits.MaxBlockWordLen([limits] 配置段)。
if len([]rune(word)) > conf.Limits.MaxBlockWordLen {
return nil, "admin.flash.blockWordTooLong", word
}
diff --git a/route/user/vote.go b/route/user/vote.go
index 781506e..e92561f 100644
--- a/route/user/vote.go
+++ b/route/user/vote.go
@@ -30,12 +30,10 @@ import (
votepkg "github.com/tamakyi/TamaBox/route/vote"
)
-// voteTextRepliesCache 箱主表单投票"回复明细"(逐条文本)的短 TTL 缓存。
-// 该视图仅在箱主结果页展示,且需在 Go 层逐条 json.Unmarshal 全部提交记录(O(N)),
-// 缓存可显著降低重算开销;结果页非强一致,30s 陈旧可接受。
+// voteTextRepliesCache 表单投票「回复明细」的短 TTL 缓存:该视图需逐条 json.Unmarshal
+// 全部提交记录(O(N)),结果页非强一致,30s 陈旧可接受。
var voteTextRepliesCache = cache.NewTTLCache(64)
-// voteTextRepliesCacheTTL 回复明细缓存有效期。
const voteTextRepliesCacheTTL = 30 * time.Second
// ---------------- 表单解析 ----------------
@@ -58,19 +56,15 @@ type voteForm struct {
censorMetadata datatypes.JSON // 标题审核结果
}
-// censorCheck 对单段文本做内容审核。
-//
-// 与提问 / 私聊共用同一个守卫(internal/security/moderation.Check):是否拦截由
-// 投票发起人本人的「审核严格度」档位决定。
-// fail-closed:审核服务异常时拒绝发布;未开启 EnableTextCensor 时底层直接放行。
+// censorCheck 对单段文本做内容审核。与提问 / 私聊共用 moderation.Check 守卫,
+// 是否拦截由发起人的「审核严格度」档位决定;fail-closed,服务异常时拒绝发布。
func censorCheck(ctx context.Context, text string) error {
_, err := moderation.Check(ctx.Request().Context(), moderation.StrictnessFor(ctx.User.ModerationStrictness), ctx.Lang(), text)
return err
}
-// parseTimeInput 解析 "YYYY-MM-DDTHH:MM"(HTML datetime-local 格式),空字符串返回 nil。
-// 使用 time.Local 解析:datetime-local 提交的是无时区信息的时间串,按服务端本地时区解释,
-// 与 Evaluate 中 time.Now() 的比较基准保持一致(旧的 time.Parse 会解析成 UTC,产生时区偏移)。
+// parseTimeInput 解析 "YYYY-MM-DDTHH:MM"(HTML datetime-local 格式),空串返回 nil。
+// 用 time.Local 而非 time.Parse:后者会解析成 UTC,与 time.Now() 比较时产生时区偏移。
func parseTimeInput(ctx context.Context, s string) (*time.Time, error) {
s = strings.TrimSpace(s)
if s == "" {
@@ -83,11 +77,9 @@ func parseTimeInput(ctx context.Context, s string) (*time.Time, error) {
return &t, nil
}
-// parseVoteForm 手动解析投票表单(完整:元信息 + 类型 + 选项/字段)。
-// 说明:不使用 form.Bind —— Bind 的反射绑定会把 string 直接赋给 int 字段导致 panic,
-// 且选项/表单字段数组需要 JSON 解析,手动解析更安全。
-// draft=true 时放宽“选项/字段数量”与内容审核要求(详见 parseVoteMeta/parseOptions/parseFormFields),
-// 使草稿可随时暂存、不丢失已填内容。
+// parseVoteForm 手动解析投票表单(元信息 + 类型 + 选项/字段)。
+// 不用 form.Bind:其反射绑定会把 string 直接赋给 int 字段导致 panic,
+// 且选项/字段数组需 JSON 解析。draft=true 时放宽数量与审核要求,便于随时暂存。
func parseVoteForm(ctx context.Context, draft bool) (*voteForm, error) {
form, err := parseVoteMeta(ctx, draft)
if err != nil {
@@ -114,7 +106,6 @@ func parseVoteForm(ctx context.Context, draft bool) (*voteForm, error) {
}
form.opts.MaxChoices = maxChoices
- // 选项 / 表单字段解析与校验
if voteType == db.VoteTypeForm {
if err := parseFormFields(ctx, form, draft); err != nil {
return nil, err
@@ -134,9 +125,8 @@ func parseVoteMetaForm(ctx context.Context) (*voteForm, error) {
return parseVoteMeta(ctx, false)
}
-// parseVoteMeta 解析投票元信息(不含类型与选项/字段)。
-// draft=true 时放宽校验并跳过内容审核:草稿为私有内容,保存时不应因审核服务抖动而丢失;
-// 审核统一在发布(VotePublish)时执行,确保对外公开内容仍受管控。
+// parseVoteMeta 解析投票元信息(不含类型与选项/字段)。draft=true 时放宽校验并跳过
+// 内容审核:草稿为私有内容,不应因审核服务抖动而丢失;审核统一在发布时补齐。
func parseVoteMeta(ctx context.Context, draft bool) (*voteForm, error) {
get := func(name string) string { return strings.TrimSpace(ctx.Request().FormValue(name)) }
@@ -193,8 +183,7 @@ func parseVoteMeta(ctx context.Context, draft bool) (*voteForm, error) {
// 匿名投票始终要求验证码(决策 #3 / P2-2):忽略客户端开关,强制开启,关闭复选框无法解除。
requireCaptcha := true
- // 该策略由业务规则固定为一人一票;不从客户端读取已废弃的配置字段,
- // 避免旧页面或手工请求写入与数据库约束冲突的多票语义。
+ // 一人一票由业务规则固定;不从客户端读取已废弃字段,避免与数据库约束冲突。
maxVotes := 1
startAt, err := parseTimeInput(ctx, get("start_at"))
@@ -358,8 +347,7 @@ func parseFormFields(ctx context.Context, form *voteForm, draft bool) error {
if o == db.CustomFieldMarker {
return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldReservedOption", i+1, label))
}
- // 单个选项长度与单选/多选选项同规则(vote-form.html 前端 maxlength 同源,
- // 此前仅有前端软限制,这里补齐后端兜底校验)
+ // 长度与单选/多选选项同规则(前端 maxlength 同源,这里补后端兜底校验)
if len([]rune(o)) > conf.Limits.VoteOptionLen {
return errors.New(i18n.T(ctx.Lang(), "vote.err.optionTooLong", conf.Limits.VoteOptionLen))
}
@@ -414,10 +402,9 @@ func getOwnedVote(ctx context.Context, voteIDStr string) (*db.Vote, error) {
// ---------------- 处理器 ----------------
-// VoteList 投票管理列表(含每票参与数统计)。
// effectiveVoteStatus 计算投票的有效展示状态(status + 时间窗联合判断):
-// published 且未到开始时间 → 未开始;published 且已过结束时间 → 已结束;
-// 其余与 status 字段一致。管理列表据此展示,避免"已过结束时间仍显示进行中"。
+// published 且未到开始时间 → 未开始;已过结束时间 → 已结束。
+// 避免「已过结束时间仍显示进行中」。
func effectiveVoteStatus(v *db.Vote, now time.Time) string {
if v.Status != db.VoteStatusPublished {
return v.Status
@@ -466,10 +453,8 @@ type voteItem struct {
}
// buildVoteItems 将投票列表转换为管理面板所需的富信息条目。
-// QuestionList 的「我的提问箱 · 投票」面板与此前独立的投票管理列表共用,避免重复统计逻辑。
-//
-// 性能:参与数与选项数通过 CountByVoteIDs 批量聚合(2 条 WHERE vote_id IN (...)),
-// 替代原先对每票各发 2 次 COUNT 的 N+1 查询,N 张投票从 2N 条 SQL 降为 2 条。
+// 性能:参与数与选项数经 CountByVoteIDs 批量聚合(2 条 WHERE vote_id IN),
+// 替代原先每票各发 2 次 COUNT 的 N+1 查询(2N 条 SQL 降为 2 条)。
func buildVoteItems(ctx context.Context, votes []*db.Vote) []*voteItem {
lang := i18n.Default
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
@@ -479,7 +464,6 @@ func buildVoteItems(ctx context.Context, votes []*db.Vote) []*voteItem {
now := time.Now()
items := make([]*voteItem, 0, len(votes))
- // 批量预取参与数与选项数(避免 N+1)
ids := make([]uint, 0, len(votes))
for _, v := range votes {
ids = append(ids, v.ID)
@@ -544,13 +528,11 @@ func VoteNew(ctx context.Context) {
ctx.Success("user/vote-form")
}
-// renderVoteFormWithInput 在保存失败时重新渲染投票表单并回填用户已提交的内容,
-// 避免校验/审核/数据库错误导致整页输入被清空(原先 Redirect("/user/votes/new") 会丢失全部数据)。
+// renderVoteFormWithInput 在保存失败时重新渲染表单并回填已提交内容,避免整页输入被清空。
// isEdit=true 时回填 vote.ID,保留编辑动作的提交地址。
//
-// flashMsg/internal 用于在同请求内直接写入 c.Data 的错误提示(而非 session flash):
-// 本项目的 flash 在请求开始时读取进 c.Data,随后写入 session 的 flash 不会被本次渲染读到,
-// 且会残留到下一次请求造成"陈旧错误"串页;故错误回填采用直接写 c.Data 的方式。
+// 错误提示直接写 c.Data 而非 session flash:本项目的 flash 在请求开始时就已读进 c.Data,
+// 之后写入 session 的 flash 本次渲染读不到,还会残留到下次请求造成「陈旧错误」串页。
func renderVoteFormWithInput(ctx context.Context, isEdit bool, voteID uint, flashMsg string, internal bool) {
ctx.Data["MaxVoteOptionLen"] = conf.Limits.VoteOptionLen // 选项输入框 maxlength 同源(vote-form.html JS)
get := func(n string) string { return strings.TrimSpace(ctx.Request().FormValue(n)) }
@@ -631,9 +613,9 @@ func renderVoteFormWithInput(ctx context.Context, isEdit bool, voteID uint, flas
ctx.Success("user/vote-form")
}
-// VoteCreate 创建投票(默认草稿状态)。
-// 草稿保存放宽校验并跳过内容审核(见 parseVoteForm/parseVoteMeta);任何错误均回填表单而非清空输入。
-// action=publish 时:先按草稿保存,再执行发布校验(结构 + 审核),通过则直接发布;失败则保留草稿并停留在编辑页,避免重复创建。
+// VoteCreate 创建投票(默认草稿状态)。草稿保存放宽校验并跳过内容审核;
+// 任何错误均回填表单而非清空输入。action=publish 时先存草稿再做发布校验,
+// 失败则保留草稿并停留在编辑页,避免重复创建。
func VoteCreate(ctx context.Context) {
action := strings.TrimSpace(ctx.Request().FormValue("action"))
@@ -673,7 +655,6 @@ func VoteCreate(ctx context.Context) {
editURL := "/user/votes/" + fmt.Sprint(vote.ID) + "/edit"
- // action=publish:已保存为草稿,再做发布校验;失败则保留草稿并停留在编辑页,避免重复创建
if action == "publish" {
if err := validateForPublish(ctx, vote); err != nil {
ctx.SetErrorFlash(err.Error())
@@ -717,9 +698,8 @@ func VoteEdit(ctx context.Context) {
// 发布后的投票锁定类型与选项/字段(避免破坏历史投票数据归因),仅可编辑元信息
ctx.Data["LockedContent"] = vote.Status == db.VoteStatusPublished
- // 选项 / 表单字段回填:传结构化数据,模板经 toJSON(template.JS)注入