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` 在代码注释里被称作"管理后台",指的是箱主自己的收件箱,不是站方后台。
+3
View File
@@ -24,6 +24,9 @@ func main() {
cmd.Uid,
cmd.Ban,
cmd.Restore,
cmd.Reports,
cmd.Restrict,
cmd.Unrestrict,
}
if err := app.Run(os.Args); err != nil {
logrus.WithError(err).Fatal("Failed to start application")
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# 处置链路部署后自检(L1 停止分发 / L4 整页停用 / CLI 可用性)
#
# 为什么需要它:本机沙箱起不了 web(无 redis),L1/L4 的 HTTP 行为只能在服务器上验证。
# 这个脚本把「施加处置 → 观察 HTTP → 自动撤销」串起来,避免手工操作忘了解除。
#
# 用法:
# BASE_URL=https://box-dev.example.com DOMAIN=<测试箱主域名> TAMABOX=/path/to/NekoBox \
# bash dev/devcheck-restrict.sh # 只打印将要执行的命令(dry-run)
#
# BASE_URL=... DOMAIN=... TAMABOX=... RUN=1 \
# bash dev/devcheck-restrict.sh # 真正执行,结束时自动撤销
#
# 注意:
# - DOMAIN 必须是**测试箱主**,不要用真实用户;脚本会临时施加 L4/L1 再撤销。
# - 站点默认语言可能是 ja,页面文案断言按日文/英文各查一次,避免误判「没生效」。
# - 只依赖 CLI 与 curl,不需要 psql 凭据。
set -uo pipefail
BASE_URL=${BASE_URL:?need BASE_URL, e.g. https://box-dev.example.com}
DOMAIN=${DOMAIN:?need DOMAIN, a TEST box domain only}
TAMABOX=${TAMABOX:-./NekoBox}
RUN=${RUN:-0}
DAYS=${DAYS:-1}
pass=0
fail=0
restricted=0
say() { printf '%s\n' "$*"; }
check() {
local name=$1 want=$2 got=$3
if [ "$want" = "$got" ]; then
say " PASS $name (=$got)"
pass=$((pass + 1))
else
say " FAIL $name (want $want, got $got)"
fail=$((fail + 1))
fi
}
code() { curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$1"; }
do_restrict() {
restricted=1
if [ "$RUN" = 1 ]; then
"$TAMABOX" restrict --domain "$DOMAIN" --level "$1" --days "$DAYS" --reason "deploy self-check" --yes
else
say " [dry-run] $TAMABOX restrict --domain $DOMAIN --level $1 --days $DAYS --reason 'deploy self-check' --yes"
fi
}
cleanup() {
if [ "$RUN" = 1 ] && [ "$restricted" = 1 ]; then
say ""
say "== 兜底撤销(无论中途是否失败)=="
"$TAMABOX" unrestrict --domain "$DOMAIN" || true
fi
}
trap cleanup EXIT
say "== 0. 二进制是否包含新命令 =="
if "$TAMABOX" --help 2>&1 | grep -q 'restrict'; then
say " PASS restrict 命令存在"
pass=$((pass + 1))
else
say " FAIL 二进制里没有 restrict —— 构建的是旧代码(注意入口是根目录 tamabox.go,或用 ./cmd/)"
fail=$((fail + 1))
say ""
say "结果: 无法继续,先修构建入口。"
exit 1
fi
say ""
say "== 1. 基线:处置前箱主页应可访问 =="
base=$(code "$BASE_URL/_/$DOMAIN")
check "box page before restrict" 200 "$base"
say ""
say "== 2. L4 整页停用 =="
do_restrict L4
sleep 1
check "box page under L4" 302 "$(code "$BASE_URL/_/$DOMAIN")"
check "restricted page" 200 "$(code "$BASE_URL/restricted")"
if [ "$RUN" = 1 ]; then
body=$(curl -sL --max-time 10 "$BASE_URL/_/$DOMAIN")
if printf '%s' "$body" | grep -qE '一時的に利用できません|temporarily unavailable|暫時無法存取|暂时不可访问'; then
say " PASS restricted page copy rendered"
pass=$((pass + 1))
else
say " FAIL restricted page copy not found (检查 i18n 与模板是否部署)"
fail=$((fail + 1))
fi
else
say " [dry-run] 跳过文案断言"
fi
say ""
say "== 3. L1 停止分发:页面恢复、分享卡被拦 =="
do_restrict L1
sleep 1
check "box page under L1" 200 "$(code "$BASE_URL/_/$DOMAIN")"
qid=$(curl -s --max-time 10 "$BASE_URL/_/$DOMAIN" | grep -o "/_/$DOMAIN/[0-9]\+" | head -1 | grep -o '[0-9]\+$')
if [ -n "${qid:-}" ]; then
check "share card under L1 (qid=$qid)" 302 "$(code "$BASE_URL/_/$DOMAIN/$qid/share-card")"
else
say " SKIP 该箱主没有已公开回答,无法验证分享卡(换一个有已答问题的测试箱主)"
fi
say ""
say "== 4. 撤销 =="
if [ "$RUN" = 1 ]; then
"$TAMABOX" unrestrict --domain "$DOMAIN"
restricted=0
check "box page after unrestrict" 200 "$(code "$BASE_URL/_/$DOMAIN")"
else
say " [dry-run] $TAMABOX unrestrict --domain $DOMAIN"
fi
say ""
say "== 结果: PASS=$pass FAIL=$fail =="
if [ "$fail" -gt 0 ]; then
say "有失败项,把上面输出 + 服务日志(journalctl / docker logs 里搜 'Failed to query user restriction')一起发我。"
exit 1
fi
say "全部通过。仍需人工手测两项:L2(回答应保存为私密)、L3(回答应被拒绝)。"
+419
View File
@@ -0,0 +1,419 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""上传链路端到端验证(A 分档 / B1 EXIF 归一 + EXIF 比例校验 / B3 单次解码)。
为什么主用例走「背景」而不是头像
--------------------------------
头像要求 1:1,是**对称**比例:EXIF 归一(5/6/7/8 交换宽高)后比例不变。
所以无论修没修比例校验的 EXIF 缺口,头像路径的表现完全一样 —— **测不出缺口**。
背景要求 16:9,非对称,且用户真实场景就是「手机竖拍一张照当背景」:
像素 1080x1920(竖,比例 0.5625)+ EXIF Orientation=6(顺时针 90)
-> 用户肉眼看到的是 1920x1080 横图,比例 1.7778,符合 16:9
-> 修复前:只看原始像素 0.5625 vs 1.7778,偏差 1.215 > 0.01 → **被误拒**
-> 修复后:先交换宽高得 1920x1080,偏差 0 → 放行
头像用例保留,但只用来回归 A 分档(不再承担 EXIF 比例校验的验证职责)。
用法:
python dev/devcheck-upload.py [--base https://box-dev-api.337845818.best]
依赖:Pillow(仅用于本地生成测试图片)
清理:TamaBox ban --email <输出中的测试邮箱> --yes
"""
import argparse
import http.cookiejar
import io
import json
import re
import struct
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from PIL import Image, ImageOps
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
RESULTS = []
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
return None
class Resp:
def __init__(self, status, headers, body):
self.status = status
self.h = {k.lower(): v for k, v in headers.items()}
self.body = body
@property
def loc(self):
return self.h.get("location", "")
@property
def text(self):
return self.body.decode("utf-8", "replace")
class Client:
def __init__(self, base):
self.base = base
self.cj = http.cookiejar.CookieJar()
self.op = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(self.cj), NoRedirect())
def _send(self, req, timeout=180):
try:
with self.op.open(req, timeout=timeout) as r:
return Resp(r.status, dict(r.headers), r.read())
except urllib.error.HTTPError as e:
try:
body = e.read()
except Exception: # noqa: BLE001
body = b""
return Resp(e.code, dict(e.headers), body)
except Exception as e: # noqa: BLE001
return Resp(-1, {}, ("TRANSPORT_ERROR: %s" % e).encode())
def req(self, method, path, data=None, content_type=None, timeout=180):
url = path if path.startswith("http") else self.base + path
headers = {"User-Agent": UA, "Referer": self.base + path.split("?")[0]}
body = None
if data is not None:
body = data if isinstance(data, bytes) else urllib.parse.urlencode(data).encode()
headers["Content-Type"] = content_type or "application/x-www-form-urlencoded"
return self._send(urllib.request.Request(url, data=body, headers=headers, method=method),
timeout)
def get(self, p, **kw):
r = self.req("GET", p, **kw)
return r if r.status != -1 else self.req("GET", p, **kw)
def post(self, p, data=None, **kw):
return self.req("POST", p, data=data, **kw)
def get_binary(self, url):
return self._send(urllib.request.Request(
url, headers={"User-Agent": UA}, method="GET"), timeout=60)
def step(desc, ok, extra=""):
RESULTS.append((desc, ok, extra))
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", desc, (" (%s)" % extra) if extra else ""))
return ok
def redness_bbox(im):
"""返回红色标记条的包围盒 (x0, y0, x1, y1)。
用来做「方向是否真的被旋转」的判据:尺寸对不代表方向对,必须看特征位置。
有损 WebP 会让红色略有偏差,故阈值放宽并按 4px 步长采样。
"""
W, H = im.size
px = im.load()
xs, ys = [], []
for y in range(0, H, 4):
for x in range(0, W, 4):
r, g, b = px[x, y]
if r > 150 and g < 90 and b < 90:
xs.append(x)
ys.append(y)
return (min(xs), min(ys), max(xs), max(ys)) if xs else None
def csrf_of(html):
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
return m.group(1) if m else None
def captcha(c):
j = json.loads(c.get("/api/captcha/generate").text)
pid, q = j["data"]["id"], j["data"]["question"]
m = re.match(r"\s*(\d+)\s*([+\-×])\s*(\d+)\s*=", q)
a, op, b = int(m.group(1)), m.group(2), int(m.group(3))
return "%s:%d" % (pid, {"+": a + b, "-": a - b, "×": a * b}[op])
def multipart(fields, files):
boundary = "----tbuv%s" % uuid.uuid4().hex
buf = io.BytesIO()
for k, v in fields.items():
buf.write(("--%s\r\n" % boundary).encode())
buf.write(('Content-Disposition: form-data; name="%s"\r\n\r\n' % k).encode())
buf.write(("%s\r\n" % v).encode())
for name, fn, ct, data in files:
buf.write(("--%s\r\n" % boundary).encode())
buf.write(('Content-Disposition: form-data; name="%s"; filename="%s"\r\n' % (name, fn)).encode())
buf.write(("Content-Type: %s\r\n\r\n" % ct).encode())
buf.write(data)
buf.write(b"\r\n")
buf.write(("--%s--\r\n" % boundary).encode())
return buf.getvalue(), "multipart/form-data; boundary=%s" % boundary
def sniff(d):
if len(d) >= 12 and d[:4] == b"RIFF" and d[8:12] == b"WEBP":
return "webp", webp_dims(d)
if d[:3] == b"\xff\xd8\xff":
return "jpeg", jpeg_dims(d)
if d[:8] == b"\x89PNG\r\n\x1a\n":
return "png", (struct.unpack(">II", d[16:24]) if len(d) >= 24 else None)
return "unknown", None
def webp_dims(d):
c = d[12:16]
if c == b"VP8 " and len(d) >= 30:
return (struct.unpack("<H", d[26:28])[0] & 0x3FFF,
struct.unpack("<H", d[28:30])[0] & 0x3FFF)
if c == b"VP8L" and len(d) >= 25:
b = struct.unpack("<I", d[21:25])[0]
return (b & 0x3FFF) + 1, ((b >> 14) & 0x3FFF) + 1
if c == b"VP8X" and len(d) >= 30:
return ((d[24] | d[25] << 8 | d[26] << 16) + 1,
(d[27] | d[28] << 8 | d[29] << 16) + 1)
return None
def jpeg_dims(d):
i = 2
while i + 9 < len(d):
if d[i] != 0xFF:
i += 1
continue
m = d[i + 1]
if m in (0xC0, 0xC1, 0xC2, 0xC3):
h, w = struct.unpack(">HH", d[i + 5:i + 9])
return (w, h)
if m in (0xD8, 0xD9) or 0xD0 <= m <= 0xD7:
i += 2
continue
i += 2 + struct.unpack(">H", d[i + 2:i + 4])[0]
return None
def make_img(w, h, orientation=None, portrait=False):
"""生成带方位特征的图片。
portrait=True 时在**左侧**画一条粗红色竖条:EXIF 顺时针 90 后它会跑到顶部,
用于像素级确认方向确实被归一了(而非仅尺寸数字对)。
"""
from PIL import ImageDraw
im = Image.new("RGB", (w, h), (30, 120, 200))
d = ImageDraw.Draw(im)
if portrait:
d.rectangle([0, 0, max(2, w // 12), h], fill=(230, 30, 30))
d.text((max(4, w // 12 + 6), h // 2), "TOP-IS-LEFT", fill=(255, 255, 255))
else:
d.rectangle([0, 0, w, max(2, h // 12)], fill=(230, 30, 30))
d.text((w // 4, max(4, h // 12 + 4)), "TOP-IS-UP", fill=(255, 255, 255))
for i in range(0, w, max(8, w // 20)):
d.line([(i, h // 4), (i, h)], fill=(255, 255, 255), width=2)
buf = io.BytesIO()
if orientation:
exif = im.getexif()
exif[0x0112] = orientation
im.save(buf, "JPEG", quality=90, exif=exif)
else:
im.save(buf, "JPEG", quality=90)
return buf.getvalue()
def scrape_profile_fields(html):
"""从 profile 页抓取所有 input 的 name/value,保证回填必填项。
UpdateProfile 的必填字段有十来个(昵称/介绍/5 个二维码颜色/点大小/透明度…),
漏一个就会因 `valid:"required"` 被Bind 挡下、图片根本不会走到上传分支。
"""
fields = {}
for m in re.finditer(r'<input\b[^>]*>', html, re.I):
tag = m.group(0)
nm = re.search(r'name="([^"]+)"', tag, re.I)
if not nm:
continue
name = nm.group(1)
if name in ("avatar", "background", "backgroundimage", "_csrf", "submit"):
continue
typ = re.search(r'type="([^"]+)"', tag, re.I)
if typ and typ.group(1).lower() in ("checkbox", "radio"):
if not re.search(r'\bchecked\b', tag, re.I):
continue
val = re.search(r'value="([^"]*)"', tag, re.I)
fields[name] = val.group(1) if val else ""
for m in re.finditer(r'<select\b[^>]*name="([^"]+)"[^>]*>(.*?)</select>', html, re.I | re.S):
name, body = m.group(1), m.group(2)
opts = re.findall(r'<option\b([^>]*)>', body, re.I)
chosen = ""
for o in opts:
vm = re.search(r'value="([^"]*)"', o, re.I)
v = vm.group(1) if vm else ""
if re.search(r'\bselected\b', o, re.I):
chosen = v
break
if not chosen:
chosen = v
fields[name] = chosen
for m in re.finditer(r'<textarea\b[^>]*name="([^"]+)"[^>]*>(.*?)</textarea>', html, re.I | re.S):
fields[m.group(1)] = re.sub(r"<[^>]+>", "", m.group(2)).strip()
return fields
def upload(c, field, data, fname="img.jpg"):
"""上传图片到 profile 表单的指定字段(avatar / background)。
返回 (图片CDN URL 或 None, 错误描述或 None)。
"""
page = c.get("/user/profile")
if page.status != 200:
return None, "profile HTTP %s" % page.status
fields = scrape_profile_fields(page.text)
fields["_csrf"] = csrf_of(page.text)
body, ct = multipart(fields, [(field, fname, "image/jpeg", data)])
res = c.post("/user/profile/update", data=body, content_type=ct)
if res.status == 302 and res.loc:
res = c.get(res.loc if res.loc.startswith("/") else "/user/profile")
if res.status != 200:
return None, "update 后页面 HTTP %s" % res.status
if field == "background":
m = re.search(r'id="bg-prev"[^>]*src="([^"]+)"', res.text) or \
re.search(r'src="([^"]+)"[^>]*id="bg-prev"', res.text)
url = m.group(1) if m else None
else:
urls = re.findall(r'https://r2\.[^"\'\s<>]+/picture/[0-9]+/\d{2}/\d{2}/[0-9a-f]+', res.text)
if not urls:
m = re.search(r'(https://r2\.[^"\'\s<>]+/picture/[^"\'\s<>]+)', res.text)
urls = [m.group(1)] if m else []
url = urls[-1] if urls else None
if url:
return url, None
dump = "C:/Users/Tamakyi Gekka/AppData/Local/Temp/tbuv_profile.html"
with open(dump, "wb") as f:
f.write(res.body)
alerts = re.findall(r'class="uk-alert[^"]*"[^>]*>(.*?)</div>', res.text, re.S)
hint = " | ".join(re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", a)).strip()[:80]
for a in alerts[:3])
return None, "未取到图片 URL(已存 %s)%s%s" % (dump, (" 提示:" + hint) if hint else "",
(" HTTP %s" % res.status))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--base", default="https://box-dev-api.337845818.best")
args = ap.parse_args()
base = args.base.rstrip("/")
pwd = "TbUv!2026"
ts = time.strftime("%m%d%H%M%S")
email, domain = "tbuv%s@example.com" % ts, "tbuv%s" % ts
print("== 账号:%s / %s ==" % (email, domain))
# ---- 本地样本 ----
square = make_img(1600, 1600) # A 分档回归:头像
bg_ok = make_img(1920, 1080) # 背景 16:9,无 EXIF(正向对照)
bg_ori6 = make_img(1080, 1920, orientation=6, portrait=True) # 核心用例
bg_bad = make_img(1080, 1920) # 无 EXIF 的竖图:应被拒(16:9 校验仍生效)
print(" 样本 avatar 1600x1600 %7d B" % len(square))
print(" 样本 bg-ok 1920x1080 %7d B" % len(bg_ok))
print(" 样本 bg-ori6 1080x1920 (EXIF=6) %7d B" % len(bg_ori6))
print(" 样本 bg-bad 1080x1920 (无 EXIF) %7d B" % len(bg_bad))
c = Client(base)
r = c.get("/register")
reg = c.post("/register", {
"email": email, "domain": domain, "name": "TBUv%s" % ts,
"password": pwd, "repeat_password": pwd,
"g-recaptcha-response": captcha(c), "language": "zh-CN",
"_csrf": csrf_of(r.text),
})
if not step("注册", reg.status == 302, "HTTP %s -> %s" % (reg.status, reg.loc)):
return 1
c = Client(base)
r = c.get("/login")
lg = c.post("/login", {"email": email, "password": pwd,
"g-recaptcha-response": captcha(c), "_csrf": csrf_of(r.text)})
if not step("登录", lg.status == 302, "HTTP %s -> %s" % (lg.status, lg.loc)):
return 1
# ================= A 分档(头像回归) =================
print("\n-- A 分档(头像 512/q78)--")
url, err = upload(c, "avatar", square)
if not step("上传方形大头像(1600x1600)", url is not None, err or (url or "")):
return 1
img = c.get_binary(url)
if not step("拉回头像", img.status == 200 and bool(img.body), "HTTP %s" % img.status):
return 1
kind, dims = sniff(img.body)
print(" 存储结果:%s %s %d bytes" % (kind, dims, len(img.body)))
step("A 分档生效:大图被缩到头像档 512", dims == (512, 512), "实际 %s" % (dims,))
# ================= B1 背景正向对照(无 EXIF) =================
print("\n-- 背景 16:9 正向对照(无 EXIF)--")
url_bg, err = upload(c, "background", bg_ok)
if not step("上传 16:9 背景(1920x1080,无 EXIF)", url_bg is not None, err or (url_bg or "")):
return 1
r1 = c.get_binary(url_bg)
k1, d1 = sniff(r1.body)
print(" 存储结果:%s %s %d bytes" % (k1, d1, len(r1.body)))
step("背景档 1920 且比例正确", k1 == "webp" and d1 == (1920, 1080), "实际 %s %s" % (k1, d1))
# ================= B1 核心:EXIF Orientation=6 的竖拍背景 =================
print("\n-- B1 核心:EXIF 比例校验缺口(手机竖拍当背景)--")
print(" 像素 1080x1920 + EXIF=6 ->肉眼看到 1920x1080 (16:9)")
print(" 修复前:按像素 0.5625 判比例 -> 偏差 1.215 >> 0.01 -> 被误拒")
url_o, err = upload(c, "background", bg_ori6)
if not step("上传 EXIF 竖拍背景(1080x1920, Orientation=6)", url_o is not None,
err or (url_o or "")):
print(" >>> 若此处 FAIL,说明 EXIF 比例校验缺口仍未修复")
return 1
if url_o == url_bg:
step("EXIF 背景确实换了新图", False, "两次上传返回同一 URL(第二次被拒、沿用旧值)")
return 1
r2 = c.get_binary(url_o)
k2, d2 = sniff(r2.body)
print(" 存储结果:%s %s %d bytes" % (k2, d2, len(r2.body)))
step("B1 EXIF 归一后按16:9 存盘(1920x1080)", k2 == "webp" and d2 == (1920, 1080),
"实际 %s %s" % (k2, d2))
step("B1 归一后限边生效(长边 1920)",
d2 is not None and max(d2) == 1920, "实际 %s" % (d2,))
# 尺寸对了不代表方向对了 —— 与 Pillow exif_transpose 作像素级黄金标准比对:
# 定位红色条的包围盒,两边必须一致(原图红条在左侧,顺时针 90 后应跑到顶部)。
ref = ImageOps.exif_transpose(Image.open(io.BytesIO(bg_ori6))).convert("RGB")
got = Image.open(io.BytesIO(r2.body)).convert("RGB")
if ref.size == got.size:
bb_ref, bb_got = redness_bbox(ref), redness_bbox(got)
step("B1 像素级方向与 Pillow exif_transpose 一致(红条包围盒)",
bb_ref is not None and bb_ref == bb_got,
"黄金标准 %s vs 存盘 %s" % (bb_ref, bb_got))
else:
step("B1 像素级方向与 Pillow exif_transpose 一致(红条包围盒)", False,
"尺寸不同:黄金标准 %s vs 存盘 %s" % (ref.size, got.size))
# ================= 反向用例:EXIF 修复不能放松比例校验 =================
print("\n-- 反向用例:无 EXIF 的竖图仍应被拒(确认没把校验放得过宽)--")
url_bad, err_bad = upload(c, "background", bg_bad)
# 期望:上传被拒 -> bg-prev 仍是上一步的 ori6 图(url_o)
if url_bad == url_o:
step("无 EXIF 的 9:16 竖图被 16:9 校验拒绝", True, "bg-prev 保持 ori6 那张")
elif url_bad is None:
step("无 EXIF 的 9:16 竖图被 16:9 校验拒绝", True, "上传被拒(%s)" % (err_bad or "")[:60])
else:
step("无 EXIF 的 9:16 竖图被 16:9 校验拒绝", False,
"竟被接受并存成 %s" % (url_bad,))
print("\n== 结果:%d/%d 通过 ==" % (sum(1 for _, ok, _ in RESULTS if ok), len(RESULTS)))
for d, ok, e in RESULTS:
if not ok:
print(" FAIL: %s %s" % (d, e))
print("清理:TamaBox ban --email %s --yes" % email)
return 0 if all(ok for _, ok, _ in RESULTS) else 1
if __name__ == "__main__":
sys.exit(main())
+382
View File
@@ -0,0 +1,382 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package cmd
import (
"context"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
)
// 本文件提供站方处置能力。项目刻意不做管理后台,因此运营动作全部落在 CLI:
//
// TamaBox reports --domain xxx 出证据包(只读)
// TamaBox restrict --domain xxx --level L1 --days 7 --reason "..."
// TamaBox unrestrict --domain xxx
//
// 处置记录由运行时读表生效(见 db.Restrictions.ActiveLevel),到期自动失效,
// 不需要定时任务,也不需要任何后台页面。
// Restrict 对箱主施加一级处置。
var Restrict = &cli.Command{
Name: "restrict",
Usage: "Restrict a user (L1 stop distribution / L2 narrow publishing / L3 freeze publishing / L4 suspend page)",
Flags: []cli.Flag{
&cli.StringFlag{Name: "email", Usage: "Email of the user"},
&cli.StringFlag{Name: "domain", Usage: "Custom domain of the user"},
&cli.StringFlag{Name: "level", Value: string(db.RestrictionL1), Usage: "Restriction level: L1 / L2 / L3 / L4"},
&cli.IntFlag{Name: "days", Value: 7, Usage: "Effective days, counted from now"},
&cli.StringFlag{Name: "reason", Usage: "Reason recorded for audit (required)"},
&cli.BoolFlag{Name: "yes", Usage: "Skip confirmation"},
},
Action: runRestrict,
}
// Unrestrict 撤销箱主当前生效的全部处置。
var Unrestrict = &cli.Command{
Name: "unrestrict",
Usage: "Revoke all active restrictions of a user",
Flags: []cli.Flag{
&cli.StringFlag{Name: "email", Usage: "Email of the user"},
&cli.StringFlag{Name: "domain", Usage: "Custom domain of the user"},
},
Action: runUnrestrict,
}
// Reports 输出某箱主的举报证据包,或全站待处理举报列表(只读,不改数据)。
var Reports = &cli.Command{
Name: "reports",
Usage: "Show abuse reports of a user, or list all pending reports",
Flags: []cli.Flag{
&cli.StringFlag{Name: "email", Usage: "Email of the user"},
&cli.StringFlag{Name: "domain", Usage: "Custom domain of the user"},
&cli.IntFlag{Name: "days", Value: 30, Usage: "Only count reports created within N days"},
&cli.IntFlag{Name: "limit", Value: 30, Usage: "Max rows to print"},
&cli.BoolFlag{Name: "pending", Usage: "List all pending reports across the site (ignores --email/--domain)"},
},
Action: runReports,
}
// openDatabase 复用 ban / censor 的初始化流程:读配置 → 拼 DSN → 连库并 AutoMigrate。
func openDatabase() (*gorm.DB, error) {
if err := conf.Init(); err != nil {
return nil, errors.Wrap(err, "load configuration")
}
dbType := conf.Database.Type
if dbType == "postgres" {
conf.Database.DSN = conf.PostgresDsn()
} else {
conf.Database.DSN = conf.MySQLDsn()
}
database, err := db.Init(dbType, conf.Database.DSN)
if err != nil {
return nil, errors.Wrap(err, "connect to database")
}
return database, nil
}
// findTargetUser 按 email / domain 定位用户。
// 用 Unscoped 以支持已自助停用(软删除)的账号 —— 处置历史必须能追溯到停用账号。
func findTargetUser(database *gorm.DB, email, domain string) (*db.User, error) {
if email == "" && domain == "" {
return nil, errors.New("must provide --email or --domain")
}
var user db.User
query := database.Unscoped()
if email != "" {
query = query.Where("email = ?", email)
} else {
query = query.Where("domain = ?", domain)
}
if err := query.First(&user).Error; err != nil {
return nil, errors.Wrap(err, "find user")
}
return &user, nil
}
func runRestrict(ctx *cli.Context) error {
level := strings.TrimSpace(ctx.String("level"))
if !db.IsValidRestrictionLevel(level) {
return errors.Errorf("invalid --level %q (want L1 / L2 / L3 / L4)", level)
}
days := ctx.Int("days")
if days <= 0 {
return errors.New("--days must be positive")
}
reason := strings.TrimSpace(ctx.String("reason"))
if reason == "" {
return errors.New("--reason is required (recorded for audit)")
}
database, err := openDatabase()
if err != nil {
return err
}
user, err := findTargetUser(database, ctx.String("email"), ctx.String("domain"))
if err != nil {
return err
}
activeLevel, active, err := db.NewRestrictionsStore(database).ActiveLevel(ctx.Context, user.ID)
if err != nil {
return errors.Wrap(err, "query active restriction")
}
fmt.Printf("箱主: %s (@%s) id=%d\n", user.Name, user.Domain, user.ID)
if activeLevel != "" {
fmt.Printf("当前处置: %s(至 %s,原因:%s)→ 本次将替换该记录\n",
activeLevel, active.ExpiresAt.Format("2006-01-02 15:04"), active.Reason)
} else {
fmt.Println("当前处置: 无")
}
fmt.Printf("本次处置: %s,%d 天,原因:%s\n", level, days, reason)
if !ctx.Bool("yes") {
logrus.Warn("输入大写 YES 确认执行(该操作可经 unrestrict 撤销):")
var answer string
if _, err := fmtScan(&answer); err != nil {
return errors.Wrap(err, "read confirmation")
}
if answer != "YES" {
return errors.New("已取消")
}
}
restriction, err := db.NewRestrictionsStore(database).Create(ctx.Context, db.CreateRestrictionOptions{
UserID: user.ID,
Level: level,
Reason: reason,
CreatedBy: "cli",
Days: days,
})
if err != nil {
return errors.Wrap(err, "create restriction")
}
logrus.WithFields(logrus.Fields{
"user_id": user.ID,
"level": level,
"expires_at": restriction.ExpiresAt,
}).Info("处置已生效")
fmt.Printf("已生效,到期时间 %s(到期自动解除,无需手工操作)\n",
restriction.ExpiresAt.Format("2006-01-02 15:04"))
return nil
}
func runUnrestrict(ctx *cli.Context) error {
database, err := openDatabase()
if err != nil {
return err
}
user, err := findTargetUser(database, ctx.String("email"), ctx.String("domain"))
if err != nil {
return err
}
affected, err := db.NewRestrictionsStore(database).RevokeActive(ctx.Context, user.ID)
if err != nil {
return errors.Wrap(err, "revoke restrictions")
}
fmt.Printf("箱主: %s (@%s) id=%d\n", user.Name, user.Domain, user.ID)
if affected == 0 {
fmt.Println("没有生效中的处置,无需操作")
return nil
}
logrus.WithFields(logrus.Fields{"user_id": user.ID, "count": affected}).Info("已撤销处置")
fmt.Printf("已撤销 %d 条生效中的处置\n", affected)
return nil
}
// reportedQuestionRow 证据包里的一行:被举报的提问及其举报数。
type reportedQuestionRow struct {
ID uint
Content string
CreatedAt time.Time
ReportCount int64
}
// pendingReportRow 全站待处理举报的一行(按箱主聚合展示)。
type pendingReportRow struct {
Domain string
QuestionID uint
Content string
ReportCount int64
LastAt time.Time
}
func runReports(ctx *cli.Context) error {
database, err := openDatabase()
if err != nil {
return err
}
if ctx.Bool("pending") {
return printPendingReports(ctx.Context, database, ctx.Int("limit"))
}
return printUserReport(database, ctx)
}
func printUserReport(database *gorm.DB, ctx *cli.Context) error {
user, err := findTargetUser(database, ctx.String("email"), ctx.String("domain"))
if err != nil {
return err
}
days := ctx.Int("days")
if days <= 0 {
days = 30
}
since := time.Now().AddDate(0, 0, -days)
fmt.Printf("箱主: %s (@%s) id=%d\n", user.Name, user.Domain, user.ID)
fmt.Println(strings.Repeat("-", 60))
store := db.NewRestrictionsStore(database)
level, active, err := store.ActiveLevel(ctx.Context, user.ID)
if err != nil {
return errors.Wrap(err, "query active restriction")
}
if level == "" {
fmt.Println("当前处置: 无")
} else {
fmt.Printf("当前处置: %s(至 %s,原因:%s)\n",
level, active.ExpiresAt.Format("2006-01-02 15:04"), active.Reason)
}
// 公开回答总数:用于计算「被举报内容占其公开产出」的比例,
// 这是判断「组织化挂人」与「偶发吐槽」最直接的一个量。
var publicAnswered int64
if err := database.WithContext(ctx.Context).
Model(&db.Question{}).
Where("user_id = ? AND answer != '' AND is_private = false", user.ID).
Count(&publicAnswered).Error; err != nil {
return errors.Wrap(err, "count public answered questions")
}
var rows []*reportedQuestionRow
if err := database.WithContext(ctx.Context).
Raw(`SELECT q.id AS id, q.content AS content, q.created_at AS created_at,
COUNT(r.id) AS report_count
FROM questions q
JOIN content_reports r ON r.question_id = q.id
WHERE q.user_id = ? AND r.created_at >= ?
GROUP BY q.id, q.content, q.created_at
ORDER BY report_count DESC, q.created_at DESC
LIMIT ?`, user.ID, since, ctx.Int("limit")).Scan(&rows).Error; err != nil {
return errors.Wrap(err, "query reported questions")
}
var totalReports int64
for _, row := range rows {
totalReports += row.ReportCount
}
// 比例的分母是「公开回答总数」,分子必须是「被举报的条目数」——
// ⚠️ 不能拿举报次数当分子:同一条内容可被多人举报,次数可以超过条目数,
// 实测会出现 166.7% 这种无意义的数值(5 次举报 / 3 条回答)。
ratio := 0.0
if publicAnswered > 0 {
ratio = float64(len(rows)) / float64(publicAnswered) * 100
}
fmt.Printf("近 %d 天: 被举报 %d 条 / 公开回答 %d 条(%.1f%%),累计 %d 次举报\n",
days, len(rows), publicAnswered, ratio, totalReports)
if len(rows) == 0 {
fmt.Println("没有举报记录")
} else {
fmt.Println("被举报条目(按举报数降序):")
for _, row := range rows {
fmt.Printf(" 举报 %-3d #%-6d %s %s\n",
row.ReportCount, row.ID, row.CreatedAt.Format("2006-01-02 15:04"),
truncateForCLI(row.Content, 48))
}
}
history, err := store.ListByUserID(ctx.Context, user.ID)
if err != nil {
return errors.Wrap(err, "list restriction history")
}
fmt.Println(strings.Repeat("-", 60))
if len(history) == 0 {
fmt.Println("历史处置: 无")
return nil
}
fmt.Println("历史处置:")
for _, item := range history {
state := "生效中"
if item.RevokedAt != nil {
state = "已撤销 " + item.RevokedAt.Format("2006-01-02")
} else if item.ExpiresAt.Before(time.Now()) {
state = "已到期"
}
fmt.Printf(" %s %s 至 %s %s(%s)\n",
item.CreatedAt.Format("2006-01-02 15:04"), item.Level,
item.ExpiresAt.Format("2006-01-02"), item.Reason, state)
}
return nil
}
func printPendingReports(ctx context.Context, database *gorm.DB, limit int) error {
if limit <= 0 {
limit = 30
}
var rows []*pendingReportRow
if err := database.WithContext(ctx).
Raw(`SELECT u.domain AS domain, q.id AS question_id, q.content AS content,
COUNT(r.id) AS report_count, MAX(r.created_at) AS last_at
FROM content_reports r
JOIN questions q ON q.id = r.question_id
JOIN users u ON u.id = q.user_id
WHERE r.status = ?
GROUP BY u.domain, q.id, q.content
ORDER BY report_count DESC, last_at DESC
LIMIT ?`, db.ReportStatusPending, limit).Scan(&rows).Error; err != nil {
return errors.Wrap(err, "query pending reports")
}
if len(rows) == 0 {
fmt.Println("没有待处理的举报")
return nil
}
fmt.Printf("待处理举报 %d 条(按举报数降序):\n", len(rows))
lastDomain := ""
for _, row := range rows {
if row.Domain != lastDomain {
fmt.Printf("\n@%s\n", row.Domain)
lastDomain = row.Domain
}
fmt.Printf(" 举报 %-3d #%-6d %s %s\n",
row.ReportCount, row.QuestionID, row.LastAt.Format("2006-01-02 15:04"),
truncateForCLI(row.Content, 48))
}
return nil
}
// truncateForCLI 按 rune 截断并压平换行,避免终端输出被长内容冲散。
func truncateForCLI(s string, max int) string {
s = strings.ReplaceAll(strings.ReplaceAll(s, "\r", " "), "\n", " ")
if utf8.RuneCountInString(s) <= max {
return s
}
runes := []rune(s)
return string(runes[:max]) + "..."
}
+3
View File
@@ -21,6 +21,8 @@ var AllTables = []interface{}{
&Vote{}, &VoteOption{}, &VoteFormField{}, &VoteSubmission{},
// 功能开关(提问 / 私聊 / 投票 的开关、定时、定量收集)
&FeatureControl{},
// 站方处置与举报(无管理后台,处置经 CLI 写入、运行时读表生效)
&UserRestriction{}, &ContentReport{},
}
func Init(typ, dsn string) (*gorm.DB, error) {
@@ -113,6 +115,7 @@ func Init(typ, dsn string) (*gorm.DB, error) {
VoteFormFields = NewVoteFormFieldsStore(db)
VoteSubmissions = NewVoteSubmissionsStore(db)
FeatureControls = NewFeatureControlsStore(db)
Restrictions = NewRestrictionsStore(db)
if err := db.Use(otelgorm.NewPlugin(
otelgorm.WithDBName(conf.Database.Name),
+265
View File
@@ -0,0 +1,265 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"time"
"github.com/pkg/errors"
"gorm.io/gorm"
)
// RestrictionLevel 站方对箱主的处置级别。
//
// 本项目刻意不做管理后台:处置只能由运维经 CLI(TamaBox restrict)写入,运行时读表生效。
// 级别用稳定字符串并依赖字典序("L1" < "L2" < "L3" < "L4",均为 L + 单个数字),
// 取「当前最高生效级别」时可直接 ORDER BY level DESC —— 不要引入多字符级别("L10" < "L4")。
type RestrictionLevel string
const (
// RestrictionL1 停止分发:禁用单题分享卡、撤销首页与探索曝光。不涉及价值判断,可批量执行。
RestrictionL1 RestrictionLevel = "L1"
// RestrictionL2 收窄公开:新回答与「设为公开」动作强制转为私密。
RestrictionL2 RestrictionLevel = "L2"
// RestrictionL3 冻结发布:禁止回答 / 设为公开 / 回答追问,仍可收信(不剥夺收件能力)。
RestrictionL3 RestrictionLevel = "L3"
// RestrictionL4 页面停用:整页不可访问,数据保留。
RestrictionL4 RestrictionLevel = "L4"
)
// restrictionLevels 合法级别集合。CLI 与写入前校验共用,非法值一律拒绝,
// 避免脏数据让「ORDER BY level DESC」选出非预期级别。
var restrictionLevels = map[RestrictionLevel]struct{}{
RestrictionL1: {},
RestrictionL2: {},
RestrictionL3: {},
RestrictionL4: {},
}
// IsValidRestrictionLevel 校验级别取值合法性。
func IsValidRestrictionLevel(level string) bool {
_, ok := restrictionLevels[RestrictionLevel(level)]
return ok
}
// restrictionLevelRank 级别权重,用于「是否达到某一档」的比较。
// 显式权重而非字符串比较:字符串比较依赖「L + 单个数字」这个隐含约束,
// 将来加 L10 会静默排错("L10" < "L4")。
var restrictionLevelRank = map[RestrictionLevel]int{
RestrictionL1: 1,
RestrictionL2: 2,
RestrictionL3: 3,
RestrictionL4: 4,
}
// RestrictionAtLeast 判断当前级别是否达到 target(含相等)。
// 空级别(无处置)恒为 false;未知级别按「无处置」处理,避免脏数据把箱主锁死。
func RestrictionAtLeast(level, target RestrictionLevel) bool {
current, ok := restrictionLevelRank[RestrictionLevel(level)]
if !ok {
return false
}
threshold, ok := restrictionLevelRank[target]
if !ok {
return false
}
return current >= threshold
}
// UserRestriction 一条站方处置记录。
//
// 刻意不嵌 dbutil.Model:
// - 处置记录不允许软删除(撤销走 RevokedAt),必须留痕,否则无法自证处置过程;
// - 到期自动失效靠 expires_at 与当前时间比较实现,不需要定时任务或后台解封动作。
type UserRestriction struct {
ID uint `gorm:"primarykey" json:"id"`
UserID uint `gorm:"index:idx_user_restrictions_user;not null" json:"user_id"`
Level string `gorm:"type:varchar(8);not null" json:"level"`
Reason string `gorm:"type:text" json:"reason"`
CreatedBy string `gorm:"type:varchar(64)" json:"created_by"`
ExpiresAt time.Time `gorm:"index:idx_user_restrictions_expires;not null" json:"expires_at"`
RevokedAt *time.Time `json:"revoked_at"`
CreatedAt time.Time `json:"created_at"`
}
// ContentReport 访客对单条提问/回答的举报。
//
// 同 IP 对同一问题只能报一次(复合唯一索引)。这里同样不嵌 dbutil.Model:
// 举报是审计证据,不做软删除;带 DeletedAt 的唯一索引会留下「删了仍占槽位」的陷阱。
// 注意:唯一索引不含 deleted_at,一旦将来引入软删除,重复举报会在 INSERT 处被拦成 500。
type ContentReport struct {
ID uint `gorm:"primarykey" json:"id"`
QuestionID uint `gorm:"uniqueIndex:idx_content_reports_question_ip,priority:1;not null" json:"question_id"`
ReporterIP string `gorm:"type:varchar(64);uniqueIndex:idx_content_reports_question_ip,priority:2;not null" json:"reporter_ip"`
Reason string `gorm:"type:varchar(32);not null" json:"reason"`
Status string `gorm:"type:varchar(16);not null;default:'pending';index:idx_content_reports_status" json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// 举报处理状态。
const (
ReportStatusPending = "pending" // 待处理
ReportStatusAccepted = "accepted" // 举报成立
ReportStatusRejected = "rejected" // 举报驳回
)
// RestrictionsStore 处置记录读写。
//
// 只提供「写入 / 撤销 / 查询当前生效级别」三类动作:本项目没有管理后台,
// 历史查询由 CLI(TamaBox reports)直接读库完成,不需要在这里堆展示型方法。
type RestrictionsStore interface {
// Create 追加一条处置记录。写入前会撤销该用户现有生效中的记录,
// 保证同一用户同一时刻只有一条生效记录(升级/降级都是替换语义,避免叠加混乱)。
Create(ctx context.Context, opts CreateRestrictionOptions) (*UserRestriction, error)
// RevokeActive 撤销某用户当前所有生效中的记录,返回受影响行数(幂等)。
RevokeActive(ctx context.Context, userID uint) (int64, error)
// ActiveLevel 返回某用户当前生效的最高级别;无生效记录时返回空串与 nil。
ActiveLevel(ctx context.Context, userID uint) (RestrictionLevel, *UserRestriction, error)
// ActiveLevels 批量版本:探索页等列表场景避免 N+1。
ActiveLevels(ctx context.Context, userIDs []uint) (map[uint]RestrictionLevel, error)
// ListByUserID 按创建时间倒序列出某用户全部处置历史(含已撤销/已过期)。
ListByUserID(ctx context.Context, userID uint) ([]*UserRestriction, error)
}
// Restrictions 处置记录全局 store,由 db.Init 装配。
// ⚠️ 与其它 store 一致:方法内部只用自身 db 句柄,不反向引用该包级变量(测试下未装配会 nil panic)。
var Restrictions RestrictionsStore
var _ RestrictionsStore = (*restrictions)(nil)
// CreateRestrictionOptions 新建处置记录的入参。
type CreateRestrictionOptions struct {
UserID uint
Level string
Reason string
CreatedBy string
// Days 生效天数,从写入时刻起算;必须为正整数,避免写入即过期的空记录。
Days int
}
func NewRestrictionsStore(db *gorm.DB) RestrictionsStore {
return &restrictions{db: db}
}
type restrictions struct {
db *gorm.DB
}
func (s *restrictions) Create(ctx context.Context, opts CreateRestrictionOptions) (*UserRestriction, error) {
if !IsValidRestrictionLevel(opts.Level) {
return nil, errors.Errorf("invalid restriction level: %q", opts.Level)
}
if opts.Days <= 0 {
return nil, errors.New("restriction days must be positive")
}
restriction := &UserRestriction{
UserID: opts.UserID,
Level: opts.Level,
Reason: opts.Reason,
CreatedBy: opts.CreatedBy,
ExpiresAt: time.Now().AddDate(0, 0, opts.Days),
}
// 替换语义:先撤销旧的生效记录,再写新的。两条语句共用一个事务,
// ⚠️ 事务里每条语句都要重新取句柄(tx.WithContext),复用同一 tx 会串条件。
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
now := time.Now()
if err := tx.WithContext(ctx).
Model(&UserRestriction{}).
Where("user_id = ?", opts.UserID).
Where("revoked_at IS NULL").
Where("expires_at > ?", now).
Updates(map[string]interface{}{"revoked_at": now}).Error; err != nil {
return errors.Wrap(err, "revoke active restrictions")
}
if err := tx.WithContext(ctx).Create(restriction).Error; err != nil {
return errors.Wrap(err, "create restriction")
}
return nil
})
if err != nil {
return nil, err
}
return restriction, nil
}
func (s *restrictions) RevokeActive(ctx context.Context, userID uint) (int64, error) {
now := time.Now()
result := s.db.WithContext(ctx).
Model(&UserRestriction{}).
Where("user_id = ?", userID).
Where("revoked_at IS NULL").
Where("expires_at > ?", now).
Updates(map[string]interface{}{"revoked_at": now})
if result.Error != nil {
return 0, errors.Wrap(result.Error, "revoke active restrictions")
}
return result.RowsAffected, nil
}
func (s *restrictions) ActiveLevel(ctx context.Context, userID uint) (RestrictionLevel, *UserRestriction, error) {
levels, err := s.ActiveLevels(ctx, []uint{userID})
if err != nil {
return "", nil, err
}
level, ok := levels[userID]
if !ok {
return "", nil, nil
}
var restriction UserRestriction
if err := s.db.WithContext(ctx).
Where("user_id = ?", userID).
Where("level = ?", string(level)).
Where("revoked_at IS NULL").
Where("expires_at > ?", time.Now()).
Order("created_at DESC").
First(&restriction).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return level, nil, nil
}
return "", nil, errors.Wrap(err, "find active restriction")
}
return level, &restriction, nil
}
func (s *restrictions) ActiveLevels(ctx context.Context, userIDs []uint) (map[uint]RestrictionLevel, error) {
levels := make(map[uint]RestrictionLevel, len(userIDs))
if len(userIDs) == 0 {
return levels, nil
}
var rows []*UserRestriction
if err := s.db.WithContext(ctx).
Where("user_id IN ?", userIDs).
Where("revoked_at IS NULL").
Where("expires_at > ?", time.Now()).
Order("level DESC").
Find(&rows).Error; err != nil {
return nil, errors.Wrap(err, "list active restrictions")
}
// Order 已按级别降序,每个 user 首次出现即为最高级别。
for _, row := range rows {
if _, ok := levels[row.UserID]; ok {
continue
}
levels[row.UserID] = RestrictionLevel(row.Level)
}
return levels, nil
}
func (s *restrictions) ListByUserID(ctx context.Context, userID uint) ([]*UserRestriction, error) {
var rows []*UserRestriction
if err := s.db.WithContext(ctx).
Where("user_id = ?", userID).
Order("created_at DESC").
Find(&rows).Error; err != nil {
return nil, errors.Wrap(err, "list restrictions")
}
return rows, nil
}
+154
View File
@@ -0,0 +1,154 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestRestrictions 覆盖站方处置的读写语义与「L1 停止分发」的曝光摘除。
// 这些行为没有管理后台兜底(处置只能经 CLI 写入),一旦语义写错无法从界面发现,
// 因此用数据库测试把「替换 / 到期 / 撤销 / 曝光排除」四条钉死。
func TestRestrictions(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
usersStore := NewUsersStore(database)
store := NewRestrictionsStore(database)
t.Cleanup(func() {
if err := cleanup("users", "user_restrictions"); err != nil {
t.Fatal(err)
}
})
require.NoError(t, usersStore.Create(ctx, CreateUserOptions{
Name: "Box Owner", Password: "super_secret", Email: "[email protected]", Domain: "rbox",
}))
owner, err := usersStore.GetByDomain(ctx, "rbox")
require.NoError(t, err)
require.NoError(t, usersStore.Create(ctx, CreateUserOptions{
Name: "Other Owner", Password: "super_secret", Email: "[email protected]", Domain: "obox",
}))
other, err := usersStore.GetByDomain(ctx, "obox")
require.NoError(t, err)
// 两个箱主都上首页,用于验证处置后是否被摘除。
require.NoError(t, usersStore.UpdateTags(ctx, owner.ID, UpdateTagsOptions{Tags: "test", ShowInHome: true}))
require.NoError(t, usersStore.UpdateTags(ctx, other.ID, UpdateTagsOptions{Tags: "test", ShowInHome: true}))
// 无处置时:不排除任何人。
level, active, err := store.ActiveLevel(ctx, owner.ID)
require.NoError(t, err)
require.Equal(t, RestrictionLevel(""), level)
require.Nil(t, active)
list, _, err := usersStore.GetShowInHome(ctx, 1, 10, "latest")
require.NoError(t, err)
require.Len(t, list, 2)
// 施加 L1。
created, err := store.Create(ctx, CreateRestrictionOptions{
UserID: owner.ID, Level: string(RestrictionL1), Reason: "组织挂人", CreatedBy: "test", Days: 7,
})
require.NoError(t, err)
require.WithinDuration(t, time.Now().AddDate(0, 0, 7), created.ExpiresAt, time.Minute)
level, active, err = store.ActiveLevel(ctx, owner.ID)
require.NoError(t, err)
require.Equal(t, RestrictionL1, level)
require.NotNil(t, active)
require.Equal(t, "组织挂人", active.Reason)
// L1 的核心语义:被处置箱主从首页 / 探索的展示池里摘除。
list, _, err = usersStore.GetShowInHome(ctx, 1, 10, "latest")
require.NoError(t, err)
require.Len(t, list, 1)
require.Equal(t, other.ID, list[0].ID)
// 批量版本只返回被处置者。
levels, err := store.ActiveLevels(ctx, []uint{owner.ID, other.ID})
require.NoError(t, err)
require.Equal(t, map[uint]RestrictionLevel{owner.ID: RestrictionL1}, levels)
// 替换语义:升级到 L3 后,旧记录保留但被撤销,同一时刻只有一条生效。
_, err = store.Create(ctx, CreateRestrictionOptions{
UserID: owner.ID, Level: string(RestrictionL3), Reason: "升级", CreatedBy: "test", Days: 3,
})
require.NoError(t, err)
level, _, err = store.ActiveLevel(ctx, owner.ID)
require.NoError(t, err)
require.Equal(t, RestrictionL3, level)
history, err := store.ListByUserID(ctx, owner.ID)
require.NoError(t, err)
require.Len(t, history, 2)
// ListByUserID 按创建时间倒序,第一条是新的 L3(未撤销),第二条是已撤销的 L1。
require.Equal(t, string(RestrictionL3), history[0].Level)
require.Nil(t, history[0].RevokedAt)
require.Equal(t, string(RestrictionL1), history[1].Level)
require.NotNil(t, history[1].RevokedAt)
// 到期即失效:直接把到期时间改到过去,ActiveLevel 必须返回空(处置自动解除,不需要定时任务)。
require.NoError(t, database.Model(&UserRestriction{}).
Where("user_id = ? AND revoked_at IS NULL", owner.ID).
Update("expires_at", time.Now().Add(-time.Minute)).Error)
level, active, err = store.ActiveLevel(ctx, owner.ID)
require.NoError(t, err)
require.Equal(t, RestrictionLevel(""), level)
require.Nil(t, active)
// 过期后曝光自动恢复。
list, _, err = usersStore.GetShowInHome(ctx, 1, 10, "latest")
require.NoError(t, err)
require.Len(t, list, 2)
// 撤销语义:有效期内撤销后立即恢复可见。
_, err = store.Create(ctx, CreateRestrictionOptions{
UserID: owner.ID, Level: string(RestrictionL1), Reason: "再处置", CreatedBy: "test", Days: 7,
})
require.NoError(t, err)
affected, err := store.RevokeActive(ctx, owner.ID)
require.NoError(t, err)
require.EqualValues(t, 1, affected)
list, _, err = usersStore.GetShowInHome(ctx, 1, 10, "latest")
require.NoError(t, err)
require.Len(t, list, 2)
// 幂等:再次撤销不再有任何生效记录。
affected, err = store.RevokeActive(ctx, owner.ID)
require.NoError(t, err)
require.EqualValues(t, 0, affected)
}
// TestRestrictionLevelValidation 防止脏级别进入 ORDER BY level DESC 的排序("L9" 会排在 "L4" 之后)。
func TestRestrictionLevelValidation(t *testing.T) {
require.True(t, IsValidRestrictionLevel(string(RestrictionL1)))
require.True(t, IsValidRestrictionLevel(string(RestrictionL4)))
require.False(t, IsValidRestrictionLevel("L5"))
require.False(t, IsValidRestrictionLevel("l1")) // 大小写敏感,避免 CLI 误传
require.False(t, IsValidRestrictionLevel(""))
}
// TestRestrictionAtLeast 守住发布侧拦截的判定:L2 收窄公开、L3 冻结发布都依赖这个比较。
// 空级别(无处置)与未知级别必须恒为 false,否则脏数据会把正常箱主锁死。
func TestRestrictionAtLeast(t *testing.T) {
require.True(t, RestrictionAtLeast(RestrictionL2, RestrictionL1))
require.True(t, RestrictionAtLeast(RestrictionL4, RestrictionL3))
require.True(t, RestrictionAtLeast(RestrictionL2, RestrictionL2))
require.False(t, RestrictionAtLeast(RestrictionL1, RestrictionL2))
require.False(t, RestrictionAtLeast(RestrictionL3, RestrictionL4))
require.False(t, RestrictionAtLeast("", RestrictionL1))
require.False(t, RestrictionAtLeast("L9", RestrictionL1))
}
+15
View File
@@ -626,6 +626,7 @@ func (db *users) GetShowInHome(ctx context.Context, page, pageSize int, sort str
}
query := db.WithContext(ctx).Where("show_in_home = ?", true)
query = query.Where("id NOT IN (?)", activeRestrictionsSubQuery(db.WithContext(ctx)))
switch sort {
case "oldest":
query = query.Order("created_at asc")
@@ -653,6 +654,7 @@ func (db *users) GetByTag(ctx context.Context, tag string, page, pageSize int, s
tagCond = "FIND_IN_SET(?, tags)"
}
baseQuery := db.WithContext(ctx).Model(&User{}).Where("show_in_home = ? AND tags IS NOT NULL AND "+tagCond, true, tag)
baseQuery = baseQuery.Where("id NOT IN (?)", activeRestrictionsSubQuery(db.WithContext(ctx)))
if err := baseQuery.Count(&count).Error; err != nil {
return nil, 0, errors.Wrap(err, "count users by tag")
}
@@ -746,6 +748,7 @@ func (db *users) getRandomUsers(ctx context.Context, limit int) ([]*User, error)
}
if err := db.WithContext(ctx).
Where("show_in_home = ?", true).
Where("id NOT IN (?)", activeRestrictionsSubQuery(db.WithContext(ctx))).
Order(randomFunc).
Limit(limit).
Find(&users).Error; err != nil {
@@ -1047,3 +1050,15 @@ func purgeUserContent(ctx context.Context, tx *gorm.DB, userID uint) error {
}
return nil
}
// activeRestrictionsSubQuery 返回「当前处于生效中的处置」的用户 ID 子查询。
//
// 用于把被处置的箱主从首页 / 探索 / 标签检索 / 随机位摘除,即 L1「停止分发」的曝光部分。
// 用子查询而不是先查一遍 ID 列表:这三处都在列表热路径上,多一次往返没有收益;
// 没有处置记录时子查询返回空集,正常路径不产生额外代价。
// 注意 GetRandom 有 30 秒缓存,处置后随机位最多滞后 30 秒生效(其余入口即时生效)。
func activeRestrictionsSubQuery(tx *gorm.DB) *gorm.DB {
return tx.Table("user_restrictions").Select("user_id").
Where("revoked_at IS NULL").
Where("expires_at > ?", time.Now())
}
+8
View File
@@ -425,6 +425,10 @@ var enDict = map[string]string{
"deactivated.desc": "Your question box and all data have been deleted. Thank you for using Wolf's Question Box. We hope to see you again.",
"deactivated.backHome": "Back to home",
// 处置停用页(L4)
"restricted.title": "This question box is temporarily unavailable",
"restricted.desc": "This question box has been suspended for violating community guidelines.",
// Anonymous voting
"nav.votes": "Polls",
@@ -707,6 +711,10 @@ var enDict = map[string]string{
"question.flash.operated": "Operation successful",
"question.flash.markedRead": "Marked as read",
"question.flash.markedUnread": "Marked as unread",
// 平台处置(L1–L4):被处置箱主在处置期内不能发布公开内容
"question.errRestrictedPublish": "Publishing is suspended while this box is under moderation",
"question.errRestrictedPublic": "Content cannot be made public while this box is under moderation",
"question.flash.answeredPrivate": "Saved as private: this box is under moderation, so the answer stays hidden",
// Follow-ups: visitor/owner visible (P0)
"followup.errNoPermission": "Permission denied",
+8
View File
@@ -425,6 +425,10 @@ var jaDict = map[string]string{
"deactivated.desc": "質問箱とすべてのデータを削除しました。ご利用いただきありがとうございました。またのお越しをお待ちしています。",
"deactivated.backHome": "ホームに戻る",
// 处置停用页(L4)
"restricted.title": "この質問箱は一時的に利用できません",
"restricted.desc": "この質問箱はコミュニティガイドライン違反のため、公開を停止しています。",
// 匿名投票
"nav.votes": "投票",
@@ -707,6 +711,10 @@ var jaDict = map[string]string{
"question.flash.operated": "操作が完了しました",
"question.flash.markedRead": "既読にしました",
"question.flash.markedUnread": "未読にしました",
// 平台处置(L1–L4):被处置箱主在处置期内不能发布公开内容
"question.errRestrictedPublish": "現在はプラットフォームによる措置期間中のため、回答の公開を停止しています",
"question.errRestrictedPublic": "措置期間中はコンテンツを公開に設定できません",
"question.flash.answeredPrivate": "非公開で保存しました:措置期間中のため回答は公開されません",
// 追加質問:訪問者/所有者に表示(P0)
"followup.errNoPermission": "権限がありません",
+8
View File
@@ -425,6 +425,10 @@ var zhCNDict = map[string]string{
"deactivated.desc": "你的提问箱及所有数据已删除,感谢你的使用,期待未来再见。",
"deactivated.backHome": "返回首页",
// 处置停用页(L4)
"restricted.title": "提问箱暂时不可访问",
"restricted.desc": "该提问箱因违反社区规范,已暂停对外访问。",
// 匿名投票
"nav.votes": "投票",
@@ -707,6 +711,10 @@ var zhCNDict = map[string]string{
"question.flash.operated": "操作成功",
"question.flash.markedRead": "已标记为已读",
"question.flash.markedUnread": "已标记为未读",
// 平台处置(L1–L4):被处置箱主在处置期内不能发布公开内容
"question.errRestrictedPublish": "当前处于平台处置期,已暂停发布回答",
"question.errRestrictedPublic": "当前处于平台处置期,不可将内容设为公开",
"question.flash.answeredPrivate": "已保存为私密:当前处于平台处置期,回答不会公开",
// 追问:访客/箱主可见(P0)
"followup.errNoPermission": "无权操作",
+8
View File
@@ -425,6 +425,10 @@ var zhTWDict = map[string]string{
"deactivated.desc": "你的提問箱及所有資料已刪除,感謝你的使用,期待未來再見。",
"deactivated.backHome": "返回首頁",
// 处置停用页(L4)
"restricted.title": "提問箱暫時無法存取",
"restricted.desc": "該提問箱因違反社群規範,已暫停對外存取。",
// 匿名投票
"nav.votes": "投票",
@@ -707,6 +711,10 @@ var zhTWDict = map[string]string{
"question.flash.operated": "操作成功",
"question.flash.markedRead": "已標記為已讀",
"question.flash.markedUnread": "已標記為未讀",
// 平台处置(L1–L4):被处置箱主在处置期内不能发布公开内容
"question.errRestrictedPublish": "當前處於平台處置期,已暫停發布回答",
"question.errRestrictedPublic": "當前處於平台處置期,不可將內容設為公開",
"question.flash.answeredPrivate": "已儲存為私密:當前處於平台處置期,回答不會公開",
// 追問:訪客/箱主可見(P0)
"followup.errNoPermission": "無權操作",
+2
View File
@@ -287,6 +287,8 @@ func New() *flamego.Flame {
// 账号停用成功页(公开):停用后会话已清空,不能再要求登录,
// 故为独立公开页,让用户明确看到「已停用」而非被丢回登录页。
f.Get("/deactivated", user.DeactivatedProfile)
// 处置停用页(L4):必须注册在公开区,被处置箱主自己也只能看到这一页。
f.Get("/restricted", user.RestrictedProfile)
// 匿名投票参与端(公开):token 链接入口
f.Group("/vote/{token}", func() {
+14
View File
@@ -158,6 +158,20 @@ func AnswerFollowUp(ctx context.Context, pageUser *db.User, question *db.Questio
return
}
// 站方处置 L2 起拒绝回答追问:追问回答同样是公开内容,且没有「私密」这一档可以降级。
restrictionLevel, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), pageUser.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query user restriction")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
if db.RestrictionAtLeast(restrictionLevel, db.RestrictionL2) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errRestrictedPublish"))
ctx.Success("question/item")
return
}
followUpID := uint(ctx.ParamInt("followUpID"))
// 内容安全检测:统一守卫,箱主本人回复同样按自己的审核严格度裁决。
+10
View File
@@ -59,6 +59,16 @@ func Pager(ctx context.Context) {
}
ctx.Map(pageUser)
// 站方处置 L4:整页停止对外访问(数据保留)。
// 查询失败不拦(fail-open):DB 异常时整站都不可用,这里再 fail-closed 只会让所有
// 提问箱一起白屏;与分享卡不同,这一层不是内容外传通道,宁可放行也不要制造大面积故障。
if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), pageUser.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query user restriction")
} else if level == db.RestrictionL4 {
ctx.Redirect("/restricted")
return
}
// 个人页语言:永远跟随提问箱主人(pageUser)配置的语言,忽略访客 cookie;未配置时回退站点默认语言
if pageUser.Language != "" {
lang := i18n.Resolve(pageUser.Language)
+38
View File
@@ -164,6 +164,30 @@ func PublishAnswer(ctx context.Context, pageUser *db.User, question *db.Question
answer := f.Answer
// 站方处置:L3 起冻结发布,L2 收窄公开(回答强制转私密)。
// 处置记录只能由运维经 CLI 写入,箱主无法自行解除 —— 与 feature_controls 的自主开关严格区分。
restrictionLevel, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), pageUser.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query user restriction")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
if db.RestrictionAtLeast(restrictionLevel, db.RestrictionL3) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errRestrictedPublish"))
ctx.Success("question/item")
return
}
// L2:先把该题置为私密再写入回答,任何时刻都不会出现「公开 + 已作答」的中间态。
if db.RestrictionAtLeast(restrictionLevel, db.RestrictionL2) {
if err := db.Questions.SetPrivate(ctx.Request().Context(), question.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to force question private")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
}
// 🚨 Content security check.
// 统一守卫:按箱主「审核严格度」裁决是否放行(审核源不可用仍为 fail-closed,
// 拒绝发布而非免审放行)。返回的 censorResponse 用于下方落审计字段。
@@ -259,6 +283,20 @@ func SetPublic(ctx context.Context, pageUser *db.User, question *db.Question, ca
return
}
// 站方处置 L2 起禁止「设为公开」:L2 的语义就是收窄公开,放开等于自行解除处置。
restrictionLevel, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), pageUser.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query user restriction")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
if db.RestrictionAtLeast(restrictionLevel, db.RestrictionL2) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errRestrictedPublic"))
ctx.Success("question/item")
return
}
if err := db.Questions.SetPublic(ctx.Request().Context(), question.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set question public")
ctx.SetInternalError()
+20
View File
@@ -810,6 +810,26 @@ func QuestionShareCard(ctx context.Context) {
return
}
// 站方处置:任何级别都禁止生成分享图。
// 分享卡会把「提问 + 回答」渲染成一张带箱主水印与二维码的图片,是挂人内容外流的主要通道
// —— 图片离开原页面后没有上下文、没有原链接、无法回溯,比页面本身传播得更远。
// 因此 L1「停止分发」首先就落在这里。查询失败按已处置处理(fail-closed):
// 宁可少出一张卡,也不要让处置在 DB 抖动时静默失效。
level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), user.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query user restriction")
ctx.Redirect("/")
return
}
if level != "" {
logrus.WithContext(ctx.Request().Context()).
WithField("domain", domain).
WithField("level", level).
Info("Share card blocked by restriction")
ctx.Redirect("/")
return
}
cacheKey := questionShareCardCacheKey(user, question)
if cached, ok := questionShareCache.Get(cacheKey); ok {
writeShareCardResponse(ctx, cached.([]byte))
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package user
import (
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/i18n"
)
// RestrictedProfile 提问箱暂停访问页(站方处置 L4)。
//
// 为什么单独一个公开页,而不是跳首页或登录页:
// - 跳首页会让访客以为链接失效;
// - 跳登录页会让人以为需要登录,而被处置箱主自己登录后仍然进不去,反复尝试;
// - 该页不暴露「谁被处置、为什么被处置」的细节,只给一句通用说明,避免二次传播。
//
// 路由必须注册在公开区(reqUserSignIn 之外),否则被处置箱主会先被要求登录再看到 404。
func RestrictedProfile(ctx context.Context) {
ctx.SetTitle(i18n.T(ctx.Lang(), "restricted.title"))
ctx.Success("user/restricted")
}
+4
View File
@@ -23,6 +23,10 @@ func main() {
cmd.Censor,
cmd.Uid,
cmd.Ban,
cmd.Restore,
cmd.Reports,
cmd.Restrict,
cmd.Unrestrict,
}
if err := app.Run(os.Args); err != nil {
logrus.WithError(err).Fatal("Failed to start application")
+14
View File
@@ -0,0 +1,14 @@
{{template "base/header" .}}
<div class="uk-card uk-card-default uk-card-body uk-text-center">
<div class="uk-margin-bottom">
<span uk-icon="icon: lock; ratio: 3" class="uk-text-muted"></span>
<h3 class="uk-margin-small-top uk-margin-remove-bottom">{{t .LangCode "restricted.title"}}</h3>
</div>
<p class="uk-text-muted">{{t .LangCode "restricted.desc"}}</p>
<div class="uk-margin-top">
<a class="uk-button uk-button-primary" href="/">{{t .LangCode "deactivated.backHome"}}</a>
</div>
</div>
{{template "base/footer" .}}