restrict v1

This commit is contained in:
tamakyi
2026-10-18 21:14:59 +08:00
parent da7e76a7db
commit 6bd90779f8
23 changed files with 1754 additions and 1 deletions
+37
View File
@@ -187,3 +187,40 @@
| JPEG 4000×3000 (2.3MB) | 18.1 MB | 36.1 MB |
| PNG 3000×3000 (33KB) | 36.1 MB | 72.1 MB |
→ 正好 2 倍。1C1G 上并发上传大图时这是决定性的。
## 线上验证通过(2026-10-16 上午)
脚本:`dev/devcheck-upload.py`(**取代 `dev/devcheck-avatar.py`**),12/12 全通过。
### ⚠️ 重要纠正:头像路径测不出 EXIF 比例校验缺口
头像要求 **1:1 是对称比例** —— EXIF 归一(5/6/7/8 交换宽高)后比例**不变**。
所以修没修缺口,头像路径表现完全一样。之前 `devcheck-avatar.py` 那个
「1536x512 + ori6」用例**根本测不到点上**(3:1 和 1:3 都不是 1:1,两边都会被拒)。
**能触发缺口的是背景 16:9(非对称)**。
### 核心用例(背景 16:9 + EXIF Orientation=6)
像素 1080x1920(竖,比例 0.5625)+ ori6 → 肉眼看到 1920x1080(16:9)
- 修复前:按像素判比例,偏差 |0.5625 - 1.7778| = 1.215 >> 0.01 → **被误拒**
- 修复后:先交换宽高,偏差 0 → 放行
### 线上实测数据
| 用例 | 输入 | 存盘结果 |
|---|---|---|
| A 分档头像 | 1600×1600 JPEG 125,865 B | webp (512,512) **1,542 B** |
| 背景正向对照 | 1920×1080 JPEG 93,484 B | webp (1920,1080) 7,582 B |
| **背景 EXIF ori6** | 1080×1920 JPEG 138,842 B | webp (1920,1080) 9,900 B |
| 反向用例 | 1080×1920 无 EXIF | **被 16:9 校验拒绝** ✅ 未把校验放宽 |
### 像素级方向验证(尺寸对≠ 方向对)
与 **Pillow `ImageOps.exif_transpose` 作黄金标准**比「红色标记条包围盒」:
```
黄金标准(Pillow exif_transpose) 红条bbox=(0, 0, 1916, 88) -> 顶部
线上存盘(本项目 WebP) 红条bbox=(0, 0, 1916, 88) -> 顶部
```
**完全一致** → 方向归一正确。这条已固化进脚本(`redness_bbox()` + 一条断言)。
### 踩坑
- 用「上下左右取平均亮度」判断红条位置**不可靠**:采样带(H//10=108px)比红条本身
(w//12=90px)还宽,红色被稀释成背景色 → 误判「方向未归一」。
正确做法是**按颜色阈值 r>150&&g<90&&b<90 定位像素包围盒**。
- 排查时**必须先建立黄金标准**再判定「我实现错了」,否则会去改本来正确的代码。
+159
View File
@@ -0,0 +1,159 @@
# 2026-10-18
## 运营侧内容治理:饭圈黑话拉踩/挂人问题(诊断 + 方向)
用户观察到部分箱主页面出现拉踩、黑话辱骂、挑动群体对立(样例 `/_/52mxtx1314`,85 条问答,
内容为「避雷/壁垒/挂人 + 圈内代称」,如咸菜坤/县城坤/猪肝/澄毒/忘羡姐/端水洁癖/蟹脚coser)。
### 已核实的事实(代码侧)
- `moderation.Check` 已覆盖提问入口(`route/question/page.go:441`)、回答、私聊、投票、追问;七牛只硬拦
politics/terrorism/porn,`review` 一律放行 → **通用审核对黑话零命中**,样例文本无一个传统脏字。
- 箱主自建屏蔽词上限:`conf.Limits.MaxBlockWords=10`、`MaxBlockWordLen=10`(`internal/conf/limits.go`),
对「一人一个代称」的场景容量不匹配。
- **全站无举报入口**:`grep Report` 无结果;处置能力只有箱主侧 Delete / SetPrivate / SetPublic。
- 现有成本控制:30 次/分钟限流、数学验证码、同 IP+同内容重复拦截(`shared.DuplicateGuard`)、
`harassment_setting` 的 register_only 档。`autoban` 只针对后台路径扫描,不涉及内容行为。
- `Question.FromIP` 已落库但**未被任何运营/聚合逻辑消费**。
- 单题页/提问箱页已全局 noindex(`internal/context/context.go:243` 的非公开页分支)→ SEO 不是主要传播面,
扩散主要靠截图外传。
### 关键判断
根因是**激励结构**而非审核强度:匿名零身份 + 提问进后台默认不公开 + 箱主逐条回答后公开(样例中连「?」这类
单字回答也算)+ 页面可分享。攻击者收益是「被朗读、被围观」,不是「发出去」。因此加词库/调最严档
投入产出极低且放大误伤。方向按三层:结构层降低收益(已回答≠自动公开、含第三方指涉的投稿默认私密)、
成本层用行为信号(来源配额、同 token 聚合)替代词库、处置层补举报-下架-按来源封禁并留痕。
本次仅给方向,未改代码。下一步待用户选定:出具体变更清单,或先落「举报 + 批量处置」最小闭环。
## 追加:箱主主动引导投稿(处罚口径讨论)
用户澄清:本例是**箱主引导用户投稿挂人**,不是被动受害;提议"禁用提问 N 天,多次累计直接封号"。
### 新核实的事实
- **单题分享卡是公开的一键外传通道**:`QuestionShareCard`(`route/question/share.go:792`)只要
`answer != "" && !IsPrivate && question.UserID == user.ID` 就渲染整张卡片(含箱主头像/背景/水印/二维码),
路由 `GET /_/{domain}/{questionID}/share-card` 在公开的 `Questioner` 分组内。挂人内容因此可以变成无痕图片流出站外。
- **首页/探索分发无任何信誉门槛**:`users.show_in_home` 由箱主自选,`GetShowInHome` / `GetByTag` /
`getRandomUsers` 只按 created_at 或随机排序,不查举报/处置记录。高互动挂人箱可直接获得平台曝光。
- **per-user 功能开关已存在**:`feature_controls`(user_id + feature + enabled + 时间窗 + quota),
`FeatureOpen` / `FeatureStateOf` 是唯一判断点 → 但它是**箱主自设**,`Save` 由本人调用,
若复用为处罚,箱主可自行 `enabled=true` 解除,语义也混淆。处罚需独立 "平台限制" 层,优先级高于用户设置。
- `User` 模型无任何限制/处罚字段;现有只有 Deactivate(自停用)、BanUserAndCleanup(物理删)、restore。
- 「公开可见 = 已回答」(`Questioner` 已挡私密/未回答),所以**「回答」这个动作同时是生产与公开**,处罚打这里最准。
### 立场(待用户确认)
- 赞同罚箱主:主动引导投稿属滥用产品组织围攻,不是内容治理能覆盖的。
- 反对罚「提问(收信)」:伤害来自「发布」,禁收信对已公开的 85 条无效,且给箱主"被平台打压"叙事。
正确梯子是从传播面往下收:停止分发 → 收窄公开 → 冻结发布(回答/公开)→ 停用页面 → 封禁。
- 反对「多次被禁自动封号」:本场景举报本身是攻击武器,自动级联等于给组织化举报装自动扳机。
必须"举报成立(带证据快照)+ 人工判定 + 可申诉 + 留痕"。
- 判据必须用行为指纹而非文本关键词:挂人内容占比、独立指涉对象数、处置后复发率。
- ⚠️ 本例箱主自述被"澄毒长期辱骂",可能是互殴一方 → 流程必须双向取证,否则单边处罚会激化对立。
## 追加:改为运营者人工裁定(用户倾向)
用户提出:被举报不自动封号,交由运营者本人判断。此方向认可,但补三条前提,否则会退化成"看心情处置":
1. 判定标准必须**先书面化**(什么算挂人:可识别第三方 + 贬损/号召规避/曝光主页 + 无公共利益),
否则同一人不同时间判出不同结果,比误封更伤公信力。
2. **证据包自动生成**(内容快照 + 来源 IP 聚合 + 该箱主历史处置 + 同 token 的关联条目),
人工只做裁决,不做取证的体力活。
3. 处置必须**留痕 + 可申诉 + 有冷静期**(举报后 24h 再判),这是平台履行"通知-删除"义务的自证材料;
自动封号反而更难自证。
分级矩阵(裁定 × 内容性质)作为处置口径:不成立→不处理(C 类转人工);成立首次→A 提示整改、
B L1 停止分发、C L1+L2;成立复发→A L1、B L2、C L3;恶意持续→A L2、B L3、C L4→L5。
互殴/选边风险:无法判定过错方时**对等处置**(双方同 L1),不选边;运营者本人有圈内立场,
封号类必须二次复核 + 统一模板公告,不写个人评价。L1 类建议静默生效(不单独通知),
避免暴露"平台在盯内容"后转为图片/缩写等更隐蔽表达。
运营侧还缺一个"处置台":举报列表、证据聚合、一键执行 L1–L5、留痕查询 —— 目前完全没有。
## 追加:用户明确要求「不要后台」,改为无 UI 实现
审阅后确认项目结构:`/user` 是箱主自助面板(`reqUserSignIn`),**无 admin 角色、无站方后台**;
CLI 骨架在 `cmd/tamabox.go`(`internal/cmd/*.go` 每个命令一个文件,范式见 `ban.go`:`conf.Init` → 拼 DSN →
`db.Init` → 操作 → 确认 → 执行)。告警可用 `conf.Security.SecurityAlertEmail`(`internal/conf/static.go:70`,
autoban 已在用)+ `mail.SendSecurityAlertMail`。
因此"处置台"改为三条非 UI 腿:
1. **举报采集**:单题页举报按钮 → `content_reports` 表;对箱主可见走 `/user/questions?filter=reported`
(这是他自己的收件箱,不是站方后台),举报的处置第一责任人仍是箱主。
2. **告警**:达到阈值(同一箱主 24h 内 N 条举报)发邮件到 `security_alert_email`,这是运营者唯一需要的"入口"。
3. **处置**:`TamaBox reports --domain` 出证据包、`TamaBox restrict --domain --level --days --reason`、
`TamaBox unrestrict`。状态写 `user_restrictions`(带 `expires_at`,判断时比较时间 → **过期自动失效,不需要 cron**)。
生效点(已定位):`QuestionShareCard`(share.go:808)、`GetShowInHome`/`GetByTag`/`getRandomUsers`
(users.go:620/643/749,注意 GetRandom 有 30s 缓存)、`FeatureOpen`(feature_control.go:364,可一处覆盖箱主自设)、
`PublishAnswer`/`SetPublic`/`AnswerFollowUp`、`question.Pager`(L4 整页停用)。
关键约束(已向用户说明):不能复用 `feature_controls`(箱主可自行 `enabled=true` 解除,语义混淆);
不用配置文件方案(要重启、无法表达到期时间);无后台 ⇒ 只能**事后处置**,不能事前审核,口径需写成
"发布后 24–48h 内可被处置";举报必须防刷(同 IP + 同 question 唯一),否则邮箱会被刷爆。
## 已落地(第 1–2 步 + L1 两个出口),已实测
备份:`C:/OtherFiles/Github/TamaBox-v4-mainline-before-abuse-restrict-20261018.tar.gz`(3.2M,排除 .git/.workbuddy)。
新增/改动:
- `internal/db/restriction.go`(新):`RestrictionLevel` L1–L4、`UserRestriction`、`ContentReport`、
`RestrictionsStore`(Create 替换语义 / RevokeActive / ActiveLevel / ActiveLevels / ListByUserID)。
两个模型都**刻意不嵌 dbutil.Model**:处置记录与举报都不软删(撤销走 `revoked_at`),
且带 `DeletedAt` 的唯一索引会留下"删了仍占槽位"的陷阱。
- `internal/cmd/restrict.go`(新):`reports` / `restrict` / `unrestrict`,复用 `fmtScan` 做 YES 确认。
- `internal/db/db.go`:`AllTables` 加两表,`db.Init` 装配 `Restrictions`。
- `tamabox.go` + `cmd/tamabox.go`:注册三个命令(并给根入口补上一直缺失的 `restore`)。
- `route/question/share.go`:`QuestionShareCard` 在任何级别处置下都拒绝生成分享图(fail-closed)。**L1 核心出口**。
- `internal/db/users.go`(CRLF,用 python 脚本改):`GetShowInHome` / `GetByTag` / `getRandomUsers`
加 `id NOT IN (activeRestrictionsSubQuery(...))`,把被处置箱主从首页/探索/标签/随机位摘除。
`GetRandom` 有 30s 缓存 → 随机位最多滞后 30s,其余即时。
- `internal/db/restriction_test.go`(新):覆盖替换 / 升级 / 到期失效 / 撤销幂等 / 曝光排除。**测试全绿**(3.87s)。
实测踩到的两个坑:
1. **只改 `cmd/tamabox.go` 命令不生效** —— 真正入口是根 `tamabox.go`,报 `No help topic for 'reports'`。
2. 证据包比例首版写成「举报次数 / 公开回答数」,实测出现 **166.7%**(5 次举报 / 3 条回答)。
已改为「被举报**条目数** / 公开回答数」,举报次数单独展示。
CLI 冒烟(临时 PG 库 + 临时 ini):reports 证据包 → restrict L1 → 证据包显示生效 → pending 列表 →
unrestrict → 显示"已撤销";升级 L1→L3 后旧记录 `revoked_at` 置位、新记录 L3 生效。清理已 DROP 临时库。
未做(下一步):L2/L3 发布侧拦截(`PublishAnswer`/`SetPublic`/`AnswerFollowUp` + `FeatureOpen` 覆盖箱主自设)、
L4 整页停用、举报按钮与箱主面板筛选、邮件告警。分享卡拦截未做 web 端到端验证(仅编译 + 单测覆盖 store 层)。
## 已落地(第二批:L2 / L3 / L4 发布侧),编译与单测通过,web e2e 受沙箱限制未做
- `internal/db/restriction.go`:加 `restrictionLevelRank` + `RestrictionAtLeast(level, target)`。
显式权重而非字符串比较(将来加 L10 不会静默排错)。空/未知级别恒 false,防止脏数据锁死箱主。
- `route/question/question.go`(CRLF,python 改):
- `PublishAnswer`:L3+ 拒绝(`question.errRestrictedPublish`);L2 **先 SetPrivate 再 AnswerByID**,
保证任何时刻都不出现「公开 + 已作答」的中间态(不是先答后藏)。
- `SetPublic`:L2+ 拒绝(`question.errRestrictedPublic`),否则等于箱主自行解除处置。
- `route/question/follow_up.go`:`AnswerFollowUp` L2+ 拒绝 —— 追问回答同样是公开内容且没有私密档可降。
- `route/question/page.go`:`Pager` 里 L4 → `Redirect("/restricted")`。此处 **fail-open**
(DB 出错放行):它不承担外传通道职责,fail-closed 会让所有箱子一起白屏。
- `route/user/restriction.go`(新)+ `templates/user/restricted.html`(新)+ `internal/route/route.go`
注册公开路由 `/restricted`(必须在 reqUserSignIn 之外)。
- i18n 4 语言各加 5 键(`question.errRestricted*` / `question.flash.answeredPrivate` / `restricted.*`),
`TestDictionaryComplete` 通过。i18n 与 route 文件均为 CRLF:用 python 插行 → `gofmt | 转 CRLF` 写回。
修正上一轮的判断:**`FeatureOpen` 不需要改**。L3 的语义是「冻结发布、仍可收信」,而 feature_controls 控制的是
收集(收信);关掉它反而违背 L3 定义。L4 已经由 Pager 整页挡住。
验证:`go build ./...` EXIT=0;`TestRestrictions` / `TestRestrictionLevelValidation` / `TestRestrictionAtLeast`
/ `TestDictionaryComplete` 全绿。**L4 与分享卡的 HTTP 行为未验证** —— 本沙箱无 redis,
`TamaBox web` 启动即 panic,详见 MEMORY.md 的构建与测试一节。
## 追加:部署自检脚本(用户要部署到测试服务器)
新增 `dev/devcheck-restrict.sh`:串起「基线 → 施加 L4(断言 302 + /restricted 200 + 文案)→ 降级 L1
(断言页面 200 + 分享卡 302)→ 撤销」,默认 dry-run,`RUN=1` 才执行,`trap EXIT` 兜底撤销。
只依赖 CLI 与 curl,不需要 psql。`bash -n` 通过,LF 行尾。
同时核实:`Dockerfile` 的构建命令是 `go build ... ./cmd/`(不是根目录),所以两个入口必须同步 ——
已写进 MEMORY.md。部署前必须先确认 `./NekoBox --help` 里有 `restrict`,否则是旧产物。
手册里另注明两项只能人工手测:L2(回答应保存为私密)、L3(回答应被拒绝)。
+31 -1
View File
@@ -33,6 +33,12 @@
- 行尾确认:`internal/storage/s3.go` = LF,`route/question/page.go` = CRLF。
- ⚠️ **依赖真实编码器的端到端断言在本机不成立**(webp 被 replace 成桩,Encode 不产出字节)。
这类测试要写成「检测到桩就 `t.Skip`」,否则 CI/本机表现不一致。
- ⚠️ **本沙箱起不了 `TamaBox web`**:flamego/session 固定走 redis store,本机 **6379 未开**,
启动即 `panic: session: open database: default addr for network ...`。所以任何需要 HTTP 的
端到端验证(L4 停用页、分享卡拦截、发布拦截)只能部署到 box-dev 后做,别在本地硬试。
- ⚠️ **探测端口 200 可能是别的进程**:8899 被本机无关进程占用,服务 panic 后 curl 仍返回 200,
差点误判成「改动没生效」。起服务后必须看任务日志确认 `Starting web server` 且无 panic,
再信任 curl 结果;端口探测本身不能证明是自己的服务。
## 图片上传与编码(`internal/storage/s3.go`)
@@ -47,6 +53,11 @@
⚠️ **比例校验必须用同一套方向**(`aspect.check(cfg, orientation)` + `orientationSwapsDimensions`),
否则手机竖拍照片(像素横躺 + Orientation=6)直传头像/背景会被误判「比例不符」而拒。
比例校验只交换宽高、**不解码像素**(大图内存)。前端 cropper 路径不受影响(canvas 已归一)。
⚠️ **头像 1:1 测不出这个缺口**(对称比例,交换宽高比例不变);**只有背景 16:9 能触发**。
线上验证脚本是 `dev/devcheck-upload.py`(不是 devcheck-avatar.py),12/12 覆盖
A 分档 / 背景对照 / EXIF ori6 放行 / **像素级方向比对(vs Pillow exif_transpose 红条包围盒)** / 反向用例(无 EXIF 竖图仍被拒)。
⚠️ 判定「方向是否归一」别用区域平均亮度:采样带比标记条还宽会稀释颜色,误判成「没归一」。
按颜色阈值定位像素包围盒才靠谱。**排查先立黄金标准(Pillow exif_transpose),否则会去改本来正确的代码。**
- **上传管线只完整解码一次**:`image.DecodeConfig` 一次同时拿 format + 宽高,
供像素上限 / 比例 / WebP 透传三处共用;完整解码只在真正转码时发生。
`ConvertStaticImageToWebP(data []byte, spec)` 收字节而非 `io.Reader`。
@@ -145,9 +156,28 @@
## 管理 CLI(`TamaBox <cmd>`)
- 注册在 `cmd/tamabox.go` 的 `app.Commands`。现有:`web` / `censor` / `uid` / `ban` / `restore`。
- ⚠️ **构建入口有两个,必须同步维护**:
- 根目录 `go build .` → 用根 `tamabox.go` 的 `app.Commands`;
- `Dockerfile` 里是 `go build ... -o NekoBox ./cmd/` → 用 `cmd/tamabox.go` 的 `app.Commands`。
两处命令列表必须一致,否则「本机构建能用、Docker 部署没有该命令」(或反之)。`cmd/tamabox.go`
曾是只注册到 `ban` 的过期副本,连 `restore` 都漏了;2026-10-18 已把两处对齐到 8 个命令。
新增命令只改其中一处时,实测报 `No help topic for '<cmd>'`。
- 现有:`web` / `censor` / `uid` / `ban` / `restore` / `reports` / `restrict` / `unrestrict`。
- `restrict --domain|--email --level L1|L2|L3|L4 --days N --reason "..." [--yes]` → 写 `user_restrictions`,
**替换语义**(先撤销该用户所有生效中记录再写新的),到期靠 `expires_at` 比较时间自动失效,无 cron。
- `reports --domain|--email [--days N]` 出证据包(被举报条目 / 公开回答条数 / 占比 / 历史处置);
`reports --pending` 列全站待处理。两者只读。
- `unrestrict --domain|--email` 撤销全部生效中处置。
- `ban --email|--domain [--yes]` → 物理删除用户+内容(不可恢复,邮箱释放)。
- `restore --email|--domain [--password <新密码>] [--yes]` → 恢复已停用账号(清 deleted_at)。
`--password` 必须在恢复之后执行(`UpdatePassword` 走普通作用域)。
- 本地冒烟:仓库只有 `conf/app.sample.ini`,用 `NEKOBOX_CONFIG_PATH=<ini>` 指定;`conf.Init` 强制
`app.external_url`、`server.salt`、`server.xsrf_key` 非空。跑 CLI 会自动 AutoMigrate,可指向临时库验证后 DROP。
## 架构约定:刻意不做站方管理后台
- 项目只有**箱主自己的** `/user` 面板(`reqUserSignIn`),**没有管理员角色、没有站方后台**,用户明确
要求后续也不要加。因此站方的一切运营动作只能落在:**CLI 子命令 / 邮件告警 / 改配置后重启**。
- 判断"要不要新增管理界面"一律先问:能不能用 CLI 表达?能就不用 UI。
- 站方→用户的告知走**模板渲染**(读库里的状态字段渲条幅/独立页),不给运营者开写入口。
- `/user/questions` 在代码注释里被称作"管理后台",指的是箱主自己的收件箱,不是站方后台。