From 6bd90779f8efe5488c5e1332ec47693de3111917 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Sun, 18 Oct 2026 21:14:59 +0800 Subject: [PATCH] restrict v1 --- .workbuddy/memory/2026-10-15.md | 37 +++ .workbuddy/memory/2026-10-18.md | 159 ++++++++++++ .workbuddy/memory/MEMORY.md | 32 ++- cmd/tamabox.go | 3 + dev/devcheck-restrict.sh | 129 ++++++++++ dev/devcheck-upload.py | 419 ++++++++++++++++++++++++++++++++ internal/cmd/restrict.go | 382 +++++++++++++++++++++++++++++ internal/db/db.go | 3 + internal/db/restriction.go | 265 ++++++++++++++++++++ internal/db/restriction_test.go | 154 ++++++++++++ internal/db/users.go | 15 ++ internal/i18n/dict_en.go | 8 + internal/i18n/dict_ja.go | 8 + internal/i18n/dict_zh_cn.go | 8 + internal/i18n/dict_zh_tw.go | 8 + internal/route/route.go | 2 + route/question/follow_up.go | 14 ++ route/question/page.go | 10 + route/question/question.go | 38 +++ route/question/share.go | 20 ++ route/user/restriction.go | 23 ++ tamabox.go | 4 + templates/user/restricted.html | 14 ++ 23 files changed, 1754 insertions(+), 1 deletion(-) create mode 100644 .workbuddy/memory/2026-10-18.md create mode 100644 dev/devcheck-restrict.sh create mode 100644 dev/devcheck-upload.py create mode 100644 internal/cmd/restrict.go create mode 100644 internal/db/restriction.go create mode 100644 internal/db/restriction_test.go create mode 100644 route/user/restriction.go create mode 100644 templates/user/restricted.html diff --git a/.workbuddy/memory/2026-10-15.md b/.workbuddy/memory/2026-10-15.md index 690c7b5..2bec9d7 100644 --- a/.workbuddy/memory/2026-10-15.md +++ b/.workbuddy/memory/2026-10-15.md @@ -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 定位像素包围盒**。 +- 排查时**必须先建立黄金标准**再判定「我实现错了」,否则会去改本来正确的代码。 diff --git a/.workbuddy/memory/2026-10-18.md b/.workbuddy/memory/2026-10-18.md new file mode 100644 index 0000000..8409d79 --- /dev/null +++ b/.workbuddy/memory/2026-10-18.md @@ -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(回答应被拒绝)。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index 628d11d..883401e 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -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/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 ''`。 +- 现有:`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=` 指定;`conf.Init` 强制 `app.external_url`、`server.salt`、`server.xsrf_key` 非空。跑 CLI 会自动 AutoMigrate,可指向临时库验证后 DROP。 + +## 架构约定:刻意不做站方管理后台 + +- 项目只有**箱主自己的** `/user` 面板(`reqUserSignIn`),**没有管理员角色、没有站方后台**,用户明确 + 要求后续也不要加。因此站方的一切运营动作只能落在:**CLI 子命令 / 邮件告警 / 改配置后重启**。 +- 判断"要不要新增管理界面"一律先问:能不能用 CLI 表达?能就不用 UI。 +- 站方→用户的告知走**模板渲染**(读库里的状态字段渲条幅/独立页),不给运营者开写入口。 +- `/user/questions` 在代码注释里被称作"管理后台",指的是箱主自己的收件箱,不是站方后台。 diff --git a/cmd/tamabox.go b/cmd/tamabox.go index 6734d90..b3e2b56 100644 --- a/cmd/tamabox.go +++ b/cmd/tamabox.go @@ -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") diff --git a/dev/devcheck-restrict.sh b/dev/devcheck-restrict.sh new file mode 100644 index 0000000..da91ce6 --- /dev/null +++ b/dev/devcheck-restrict.sh @@ -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(回答应被拒绝)。" diff --git a/dev/devcheck-upload.py b/dev/devcheck-upload.py new file mode 100644 index 0000000..b19792f --- /dev/null +++ b/dev/devcheck-upload.py @@ -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("= 25: + b = struct.unpack("> 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']*>', 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']*name="([^"]+)"[^>]*>(.*?)', html, re.I | re.S): + name, body = m.group(1), m.group(2) + opts = re.findall(r']*)>', 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']*name="([^"]+)"[^>]*>(.*?)', 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[^"]*"[^>]*>(.*?)', 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()) \ No newline at end of file diff --git a/internal/cmd/restrict.go b/internal/cmd/restrict.go new file mode 100644 index 0000000..108c63c --- /dev/null +++ b/internal/cmd/restrict.go @@ -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]) + "..." +} diff --git a/internal/db/db.go b/internal/db/db.go index 03a4f02..eecdfca 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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), diff --git a/internal/db/restriction.go b/internal/db/restriction.go new file mode 100644 index 0000000..f531f03 --- /dev/null +++ b/internal/db/restriction.go @@ -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 +} diff --git a/internal/db/restriction_test.go b/internal/db/restriction_test.go new file mode 100644 index 0000000..355196f --- /dev/null +++ b/internal/db/restriction_test.go @@ -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: "r@example.com", 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: "o@example.com", 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)) +} diff --git a/internal/db/users.go b/internal/db/users.go index a8460fa..5c9c2a6 100644 --- a/internal/db/users.go +++ b/internal/db/users.go @@ -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()) +} diff --git a/internal/i18n/dict_en.go b/internal/i18n/dict_en.go index 2505ea6..0a041a2 100644 --- a/internal/i18n/dict_en.go +++ b/internal/i18n/dict_en.go @@ -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", diff --git a/internal/i18n/dict_ja.go b/internal/i18n/dict_ja.go index 9385765..990d34c 100644 --- a/internal/i18n/dict_ja.go +++ b/internal/i18n/dict_ja.go @@ -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": "権限がありません", diff --git a/internal/i18n/dict_zh_cn.go b/internal/i18n/dict_zh_cn.go index 442d6fe..df7271c 100644 --- a/internal/i18n/dict_zh_cn.go +++ b/internal/i18n/dict_zh_cn.go @@ -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": "无权操作", diff --git a/internal/i18n/dict_zh_tw.go b/internal/i18n/dict_zh_tw.go index 543e29d..94eb07c 100644 --- a/internal/i18n/dict_zh_tw.go +++ b/internal/i18n/dict_zh_tw.go @@ -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": "無權操作", diff --git a/internal/route/route.go b/internal/route/route.go index a39aa82..4e82eb2 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -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() { diff --git a/route/question/follow_up.go b/route/question/follow_up.go index 075ea58..873d3c0 100644 --- a/route/question/follow_up.go +++ b/route/question/follow_up.go @@ -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")) // 内容安全检测:统一守卫,箱主本人回复同样按自己的审核严格度裁决。 diff --git a/route/question/page.go b/route/question/page.go index 8f63299..6e133b6 100644 --- a/route/question/page.go +++ b/route/question/page.go @@ -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) diff --git a/route/question/question.go b/route/question/question.go index 74fb401..88382e8 100644 --- a/route/question/question.go +++ b/route/question/question.go @@ -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() diff --git a/route/question/share.go b/route/question/share.go index 40fcfd0..d8b8a3b 100644 --- a/route/question/share.go +++ b/route/question/share.go @@ -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)) diff --git a/route/user/restriction.go b/route/user/restriction.go new file mode 100644 index 0000000..726600c --- /dev/null +++ b/route/user/restriction.go @@ -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") +} diff --git a/tamabox.go b/tamabox.go index 2dede39..b3e2b56 100644 --- a/tamabox.go +++ b/tamabox.go @@ -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") diff --git a/templates/user/restricted.html b/templates/user/restricted.html new file mode 100644 index 0000000..370dff5 --- /dev/null +++ b/templates/user/restricted.html @@ -0,0 +1,14 @@ +{{template "base/header" .}} +
+
+ +

{{t .LangCode "restricted.title"}}

+
+ +

{{t .LangCode "restricted.desc"}}

+ + +
+{{template "base/footer" .}}