restrict v1
This commit is contained in:
@@ -187,3 +187,40 @@
|
|||||||
| JPEG 4000×3000 (2.3MB) | 18.1 MB | 36.1 MB |
|
| JPEG 4000×3000 (2.3MB) | 18.1 MB | 36.1 MB |
|
||||||
| PNG 3000×3000 (33KB) | 36.1 MB | 72.1 MB |
|
| PNG 3000×3000 (33KB) | 36.1 MB | 72.1 MB |
|
||||||
→ 正好 2 倍。1C1G 上并发上传大图时这是决定性的。
|
→ 正好 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 定位像素包围盒**。
|
||||||
|
- 排查时**必须先建立黄金标准**再判定「我实现错了」,否则会去改本来正确的代码。
|
||||||
|
|||||||
@@ -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(回答应被拒绝)。
|
||||||
@@ -33,6 +33,12 @@
|
|||||||
- 行尾确认:`internal/storage/s3.go` = LF,`route/question/page.go` = CRLF。
|
- 行尾确认:`internal/storage/s3.go` = LF,`route/question/page.go` = CRLF。
|
||||||
- ⚠️ **依赖真实编码器的端到端断言在本机不成立**(webp 被 replace 成桩,Encode 不产出字节)。
|
- ⚠️ **依赖真实编码器的端到端断言在本机不成立**(webp 被 replace 成桩,Encode 不产出字节)。
|
||||||
这类测试要写成「检测到桩就 `t.Skip`」,否则 CI/本机表现不一致。
|
这类测试要写成「检测到桩就 `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`)
|
## 图片上传与编码(`internal/storage/s3.go`)
|
||||||
|
|
||||||
@@ -47,6 +53,11 @@
|
|||||||
⚠️ **比例校验必须用同一套方向**(`aspect.check(cfg, orientation)` + `orientationSwapsDimensions`),
|
⚠️ **比例校验必须用同一套方向**(`aspect.check(cfg, orientation)` + `orientationSwapsDimensions`),
|
||||||
否则手机竖拍照片(像素横躺 + Orientation=6)直传头像/背景会被误判「比例不符」而拒。
|
否则手机竖拍照片(像素横躺 + Orientation=6)直传头像/背景会被误判「比例不符」而拒。
|
||||||
比例校验只交换宽高、**不解码像素**(大图内存)。前端 cropper 路径不受影响(canvas 已归一)。
|
比例校验只交换宽高、**不解码像素**(大图内存)。前端 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 + 宽高,
|
- **上传管线只完整解码一次**:`image.DecodeConfig` 一次同时拿 format + 宽高,
|
||||||
供像素上限 / 比例 / WebP 透传三处共用;完整解码只在真正转码时发生。
|
供像素上限 / 比例 / WebP 透传三处共用;完整解码只在真正转码时发生。
|
||||||
`ConvertStaticImageToWebP(data []byte, spec)` 收字节而非 `io.Reader`。
|
`ConvertStaticImageToWebP(data []byte, spec)` 收字节而非 `io.Reader`。
|
||||||
@@ -145,9 +156,28 @@
|
|||||||
|
|
||||||
## 管理 CLI(`TamaBox <cmd>`)
|
## 管理 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]` → 物理删除用户+内容(不可恢复,邮箱释放)。
|
- `ban --email|--domain [--yes]` → 物理删除用户+内容(不可恢复,邮箱释放)。
|
||||||
- `restore --email|--domain [--password <新密码>] [--yes]` → 恢复已停用账号(清 deleted_at)。
|
- `restore --email|--domain [--password <新密码>] [--yes]` → 恢复已停用账号(清 deleted_at)。
|
||||||
`--password` 必须在恢复之后执行(`UpdatePassword` 走普通作用域)。
|
`--password` 必须在恢复之后执行(`UpdatePassword` 走普通作用域)。
|
||||||
- 本地冒烟:仓库只有 `conf/app.sample.ini`,用 `NEKOBOX_CONFIG_PATH=<ini>` 指定;`conf.Init` 强制
|
- 本地冒烟:仓库只有 `conf/app.sample.ini`,用 `NEKOBOX_CONFIG_PATH=<ini>` 指定;`conf.Init` 强制
|
||||||
`app.external_url`、`server.salt`、`server.xsrf_key` 非空。跑 CLI 会自动 AutoMigrate,可指向临时库验证后 DROP。
|
`app.external_url`、`server.salt`、`server.xsrf_key` 非空。跑 CLI 会自动 AutoMigrate,可指向临时库验证后 DROP。
|
||||||
|
|
||||||
|
## 架构约定:刻意不做站方管理后台
|
||||||
|
|
||||||
|
- 项目只有**箱主自己的** `/user` 面板(`reqUserSignIn`),**没有管理员角色、没有站方后台**,用户明确
|
||||||
|
要求后续也不要加。因此站方的一切运营动作只能落在:**CLI 子命令 / 邮件告警 / 改配置后重启**。
|
||||||
|
- 判断"要不要新增管理界面"一律先问:能不能用 CLI 表达?能就不用 UI。
|
||||||
|
- 站方→用户的告知走**模板渲染**(读库里的状态字段渲条幅/独立页),不给运营者开写入口。
|
||||||
|
- `/user/questions` 在代码注释里被称作"管理后台",指的是箱主自己的收件箱,不是站方后台。
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ func main() {
|
|||||||
cmd.Uid,
|
cmd.Uid,
|
||||||
cmd.Ban,
|
cmd.Ban,
|
||||||
cmd.Restore,
|
cmd.Restore,
|
||||||
|
cmd.Reports,
|
||||||
|
cmd.Restrict,
|
||||||
|
cmd.Unrestrict,
|
||||||
}
|
}
|
||||||
if err := app.Run(os.Args); err != nil {
|
if err := app.Run(os.Args); err != nil {
|
||||||
logrus.WithError(err).Fatal("Failed to start application")
|
logrus.WithError(err).Fatal("Failed to start application")
|
||||||
|
|||||||
@@ -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(回答应被拒绝)。"
|
||||||
@@ -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())
|
||||||
@@ -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]) + "..."
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ var AllTables = []interface{}{
|
|||||||
&Vote{}, &VoteOption{}, &VoteFormField{}, &VoteSubmission{},
|
&Vote{}, &VoteOption{}, &VoteFormField{}, &VoteSubmission{},
|
||||||
// 功能开关(提问 / 私聊 / 投票 的开关、定时、定量收集)
|
// 功能开关(提问 / 私聊 / 投票 的开关、定时、定量收集)
|
||||||
&FeatureControl{},
|
&FeatureControl{},
|
||||||
|
// 站方处置与举报(无管理后台,处置经 CLI 写入、运行时读表生效)
|
||||||
|
&UserRestriction{}, &ContentReport{},
|
||||||
}
|
}
|
||||||
|
|
||||||
func Init(typ, dsn string) (*gorm.DB, error) {
|
func Init(typ, dsn string) (*gorm.DB, error) {
|
||||||
@@ -113,6 +115,7 @@ func Init(typ, dsn string) (*gorm.DB, error) {
|
|||||||
VoteFormFields = NewVoteFormFieldsStore(db)
|
VoteFormFields = NewVoteFormFieldsStore(db)
|
||||||
VoteSubmissions = NewVoteSubmissionsStore(db)
|
VoteSubmissions = NewVoteSubmissionsStore(db)
|
||||||
FeatureControls = NewFeatureControlsStore(db)
|
FeatureControls = NewFeatureControlsStore(db)
|
||||||
|
Restrictions = NewRestrictionsStore(db)
|
||||||
|
|
||||||
if err := db.Use(otelgorm.NewPlugin(
|
if err := db.Use(otelgorm.NewPlugin(
|
||||||
otelgorm.WithDBName(conf.Database.Name),
|
otelgorm.WithDBName(conf.Database.Name),
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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 := db.WithContext(ctx).Where("show_in_home = ?", true)
|
||||||
|
query = query.Where("id NOT IN (?)", activeRestrictionsSubQuery(db.WithContext(ctx)))
|
||||||
switch sort {
|
switch sort {
|
||||||
case "oldest":
|
case "oldest":
|
||||||
query = query.Order("created_at asc")
|
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)"
|
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 := 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 {
|
if err := baseQuery.Count(&count).Error; err != nil {
|
||||||
return nil, 0, errors.Wrap(err, "count users by tag")
|
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).
|
if err := db.WithContext(ctx).
|
||||||
Where("show_in_home = ?", true).
|
Where("show_in_home = ?", true).
|
||||||
|
Where("id NOT IN (?)", activeRestrictionsSubQuery(db.WithContext(ctx))).
|
||||||
Order(randomFunc).
|
Order(randomFunc).
|
||||||
Limit(limit).
|
Limit(limit).
|
||||||
Find(&users).Error; err != nil {
|
Find(&users).Error; err != nil {
|
||||||
@@ -1047,3 +1050,15 @@ func purgeUserContent(ctx context.Context, tx *gorm.DB, userID uint) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.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",
|
"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
|
// Anonymous voting
|
||||||
"nav.votes": "Polls",
|
"nav.votes": "Polls",
|
||||||
|
|
||||||
@@ -707,6 +711,10 @@ var enDict = map[string]string{
|
|||||||
"question.flash.operated": "Operation successful",
|
"question.flash.operated": "Operation successful",
|
||||||
"question.flash.markedRead": "Marked as read",
|
"question.flash.markedRead": "Marked as read",
|
||||||
"question.flash.markedUnread": "Marked as unread",
|
"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)
|
// Follow-ups: visitor/owner visible (P0)
|
||||||
"followup.errNoPermission": "Permission denied",
|
"followup.errNoPermission": "Permission denied",
|
||||||
|
|||||||
@@ -425,6 +425,10 @@ var jaDict = map[string]string{
|
|||||||
"deactivated.desc": "質問箱とすべてのデータを削除しました。ご利用いただきありがとうございました。またのお越しをお待ちしています。",
|
"deactivated.desc": "質問箱とすべてのデータを削除しました。ご利用いただきありがとうございました。またのお越しをお待ちしています。",
|
||||||
"deactivated.backHome": "ホームに戻る",
|
"deactivated.backHome": "ホームに戻る",
|
||||||
|
|
||||||
|
// 处置停用页(L4)
|
||||||
|
"restricted.title": "この質問箱は一時的に利用できません",
|
||||||
|
"restricted.desc": "この質問箱はコミュニティガイドライン違反のため、公開を停止しています。",
|
||||||
|
|
||||||
// 匿名投票
|
// 匿名投票
|
||||||
"nav.votes": "投票",
|
"nav.votes": "投票",
|
||||||
|
|
||||||
@@ -707,6 +711,10 @@ var jaDict = map[string]string{
|
|||||||
"question.flash.operated": "操作が完了しました",
|
"question.flash.operated": "操作が完了しました",
|
||||||
"question.flash.markedRead": "既読にしました",
|
"question.flash.markedRead": "既読にしました",
|
||||||
"question.flash.markedUnread": "未読にしました",
|
"question.flash.markedUnread": "未読にしました",
|
||||||
|
// 平台处置(L1–L4):被处置箱主在处置期内不能发布公开内容
|
||||||
|
"question.errRestrictedPublish": "現在はプラットフォームによる措置期間中のため、回答の公開を停止しています",
|
||||||
|
"question.errRestrictedPublic": "措置期間中はコンテンツを公開に設定できません",
|
||||||
|
"question.flash.answeredPrivate": "非公開で保存しました:措置期間中のため回答は公開されません",
|
||||||
|
|
||||||
// 追加質問:訪問者/所有者に表示(P0)
|
// 追加質問:訪問者/所有者に表示(P0)
|
||||||
"followup.errNoPermission": "権限がありません",
|
"followup.errNoPermission": "権限がありません",
|
||||||
|
|||||||
@@ -425,6 +425,10 @@ var zhCNDict = map[string]string{
|
|||||||
"deactivated.desc": "你的提问箱及所有数据已删除,感谢你的使用,期待未来再见。",
|
"deactivated.desc": "你的提问箱及所有数据已删除,感谢你的使用,期待未来再见。",
|
||||||
"deactivated.backHome": "返回首页",
|
"deactivated.backHome": "返回首页",
|
||||||
|
|
||||||
|
// 处置停用页(L4)
|
||||||
|
"restricted.title": "提问箱暂时不可访问",
|
||||||
|
"restricted.desc": "该提问箱因违反社区规范,已暂停对外访问。",
|
||||||
|
|
||||||
// 匿名投票
|
// 匿名投票
|
||||||
"nav.votes": "投票",
|
"nav.votes": "投票",
|
||||||
|
|
||||||
@@ -707,6 +711,10 @@ var zhCNDict = map[string]string{
|
|||||||
"question.flash.operated": "操作成功",
|
"question.flash.operated": "操作成功",
|
||||||
"question.flash.markedRead": "已标记为已读",
|
"question.flash.markedRead": "已标记为已读",
|
||||||
"question.flash.markedUnread": "已标记为未读",
|
"question.flash.markedUnread": "已标记为未读",
|
||||||
|
// 平台处置(L1–L4):被处置箱主在处置期内不能发布公开内容
|
||||||
|
"question.errRestrictedPublish": "当前处于平台处置期,已暂停发布回答",
|
||||||
|
"question.errRestrictedPublic": "当前处于平台处置期,不可将内容设为公开",
|
||||||
|
"question.flash.answeredPrivate": "已保存为私密:当前处于平台处置期,回答不会公开",
|
||||||
|
|
||||||
// 追问:访客/箱主可见(P0)
|
// 追问:访客/箱主可见(P0)
|
||||||
"followup.errNoPermission": "无权操作",
|
"followup.errNoPermission": "无权操作",
|
||||||
|
|||||||
@@ -425,6 +425,10 @@ var zhTWDict = map[string]string{
|
|||||||
"deactivated.desc": "你的提問箱及所有資料已刪除,感謝你的使用,期待未來再見。",
|
"deactivated.desc": "你的提問箱及所有資料已刪除,感謝你的使用,期待未來再見。",
|
||||||
"deactivated.backHome": "返回首頁",
|
"deactivated.backHome": "返回首頁",
|
||||||
|
|
||||||
|
// 处置停用页(L4)
|
||||||
|
"restricted.title": "提問箱暫時無法存取",
|
||||||
|
"restricted.desc": "該提問箱因違反社群規範,已暫停對外存取。",
|
||||||
|
|
||||||
// 匿名投票
|
// 匿名投票
|
||||||
"nav.votes": "投票",
|
"nav.votes": "投票",
|
||||||
|
|
||||||
@@ -707,6 +711,10 @@ var zhTWDict = map[string]string{
|
|||||||
"question.flash.operated": "操作成功",
|
"question.flash.operated": "操作成功",
|
||||||
"question.flash.markedRead": "已標記為已讀",
|
"question.flash.markedRead": "已標記為已讀",
|
||||||
"question.flash.markedUnread": "已標記為未讀",
|
"question.flash.markedUnread": "已標記為未讀",
|
||||||
|
// 平台处置(L1–L4):被处置箱主在处置期内不能发布公开内容
|
||||||
|
"question.errRestrictedPublish": "當前處於平台處置期,已暫停發布回答",
|
||||||
|
"question.errRestrictedPublic": "當前處於平台處置期,不可將內容設為公開",
|
||||||
|
"question.flash.answeredPrivate": "已儲存為私密:當前處於平台處置期,回答不會公開",
|
||||||
|
|
||||||
// 追問:訪客/箱主可見(P0)
|
// 追問:訪客/箱主可見(P0)
|
||||||
"followup.errNoPermission": "無權操作",
|
"followup.errNoPermission": "無權操作",
|
||||||
|
|||||||
@@ -287,6 +287,8 @@ func New() *flamego.Flame {
|
|||||||
// 账号停用成功页(公开):停用后会话已清空,不能再要求登录,
|
// 账号停用成功页(公开):停用后会话已清空,不能再要求登录,
|
||||||
// 故为独立公开页,让用户明确看到「已停用」而非被丢回登录页。
|
// 故为独立公开页,让用户明确看到「已停用」而非被丢回登录页。
|
||||||
f.Get("/deactivated", user.DeactivatedProfile)
|
f.Get("/deactivated", user.DeactivatedProfile)
|
||||||
|
// 处置停用页(L4):必须注册在公开区,被处置箱主自己也只能看到这一页。
|
||||||
|
f.Get("/restricted", user.RestrictedProfile)
|
||||||
|
|
||||||
// 匿名投票参与端(公开):token 链接入口
|
// 匿名投票参与端(公开):token 链接入口
|
||||||
f.Group("/vote/{token}", func() {
|
f.Group("/vote/{token}", func() {
|
||||||
|
|||||||
@@ -158,6 +158,20 @@ func AnswerFollowUp(ctx context.Context, pageUser *db.User, question *db.Questio
|
|||||||
return
|
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"))
|
followUpID := uint(ctx.ParamInt("followUpID"))
|
||||||
|
|
||||||
// 内容安全检测:统一守卫,箱主本人回复同样按自己的审核严格度裁决。
|
// 内容安全检测:统一守卫,箱主本人回复同样按自己的审核严格度裁决。
|
||||||
|
|||||||
@@ -59,6 +59,16 @@ func Pager(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
ctx.Map(pageUser)
|
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;未配置时回退站点默认语言
|
// 个人页语言:永远跟随提问箱主人(pageUser)配置的语言,忽略访客 cookie;未配置时回退站点默认语言
|
||||||
if pageUser.Language != "" {
|
if pageUser.Language != "" {
|
||||||
lang := i18n.Resolve(pageUser.Language)
|
lang := i18n.Resolve(pageUser.Language)
|
||||||
|
|||||||
@@ -164,6 +164,30 @@ func PublishAnswer(ctx context.Context, pageUser *db.User, question *db.Question
|
|||||||
|
|
||||||
answer := f.Answer
|
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.
|
// 🚨 Content security check.
|
||||||
// 统一守卫:按箱主「审核严格度」裁决是否放行(审核源不可用仍为 fail-closed,
|
// 统一守卫:按箱主「审核严格度」裁决是否放行(审核源不可用仍为 fail-closed,
|
||||||
// 拒绝发布而非免审放行)。返回的 censorResponse 用于下方落审计字段。
|
// 拒绝发布而非免审放行)。返回的 censorResponse 用于下方落审计字段。
|
||||||
@@ -259,6 +283,20 @@ func SetPublic(ctx context.Context, pageUser *db.User, question *db.Question, ca
|
|||||||
return
|
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 {
|
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")
|
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set question public")
|
||||||
ctx.SetInternalError()
|
ctx.SetInternalError()
|
||||||
|
|||||||
@@ -810,6 +810,26 @@ func QuestionShareCard(ctx context.Context) {
|
|||||||
return
|
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)
|
cacheKey := questionShareCardCacheKey(user, question)
|
||||||
if cached, ok := questionShareCache.Get(cacheKey); ok {
|
if cached, ok := questionShareCache.Get(cacheKey); ok {
|
||||||
writeShareCardResponse(ctx, cached.([]byte))
|
writeShareCardResponse(ctx, cached.([]byte))
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -23,6 +23,10 @@ func main() {
|
|||||||
cmd.Censor,
|
cmd.Censor,
|
||||||
cmd.Uid,
|
cmd.Uid,
|
||||||
cmd.Ban,
|
cmd.Ban,
|
||||||
|
cmd.Restore,
|
||||||
|
cmd.Reports,
|
||||||
|
cmd.Restrict,
|
||||||
|
cmd.Unrestrict,
|
||||||
}
|
}
|
||||||
if err := app.Run(os.Args); err != nil {
|
if err := app.Run(os.Args); err != nil {
|
||||||
logrus.WithError(err).Fatal("Failed to start application")
|
logrus.WithError(err).Fatal("Failed to start application")
|
||||||
|
|||||||
@@ -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" .}}
|
||||||
Reference in New Issue
Block a user