chore: outputs 不再纳入版本控制

.gitignore 里原本写的是 `./outputs` —— gitignore 不认 `./` 前缀,整条规则失效,
于是 247 个产物(预览快照、截图、历史 review 报告)一直被跟踪,
每次跑 TestGenPreview 都会脏一遍工作区。

改成 `/outputs/` 并 git rm -r --cached outputs:索引里删除,磁盘文件全部保留。
克隆者不再拿到这些一次性产物;需要预览快照时跑
TEST_PREVIEW=1 go test ./templates -run TestGenPreview 重新生成。
This commit is contained in:
tamakyi
2026-10-20 12:17:13 +08:00
parent ef18d91920
commit 7b532574bb
249 changed files with 13 additions and 131095 deletions
+4 -1
View File
@@ -28,4 +28,7 @@ go.work.sum
# otherfile
./workbuddy
./scripts
./outputs
# 注意:gitignore 不认 "./" 前缀,/outputs 才是「仓库根下的 outputs」。
# outputs 里全是 TestGenPreview / 主题生成器 / 截图这类一次性产物,
# 每次跑都会变,跟版本库无关。
/outputs/
+9
View File
@@ -115,3 +115,12 @@
**顺手发现**:`.gitignore` 写的是 `./outputs`(无效写法),导致 247 个 outputs 文件被跟踪,
每次跑 `TestGenPreview` 都会产生一堆 M。没擅自改(会让人以为要清理历史产物),留待用户决定。
## 12:20 outputs 不再纳入版本控制
- 根因:`.gitignore` 里写的是 `./outputs`,**gitignore 不认 `./` 前缀**,整条规则等于失效,
247 个产物(预览快照、截图、历史 review 报告)一直被跟踪,每次跑 `TestGenPreview` 都产生一堆 M。
- 改成 `/outputs/` + `git rm -r --cached outputs`:索引删除 247 个,**磁盘文件一个没动**。
`git check-ignore -v outputs/x` 确认命中。
- 另两条同样无效的规则没动:`./workbuddy`(目录根本不存在,笔误)、`./scripts`(有 1 个文件被跟踪)。
用户只要求 outputs;要清理得单独确认。
@@ -1,113 +0,0 @@
# TamaBox-v4 完整代码审阅报告
> 审阅时间:2026-08-27
> 审阅对象:`TamaBox-v4-new-toupiao`(基于 NekoBox 的 Go 匿名问答 + 匿名投票 Web 应用,Flamego + GORM + MySQL/PostgreSQL + Redis)
> 审阅范围:全项目 Go 源码(约 20.8k 行,86 个 internal 文件 + 21 个 route 文件 + cmd)、关键模板、路由/中间件、配置
> 审阅方式:针对性精读(认证/会话、投票、私聊、提问、个人资料、Markdown 清洗、缓存、路由/中间件、限流)+ 全代码库自动化广度扫描 + 逐项复检历史报告(08-13 / 08-18 / 08-24 / 08-26)+ 核对 Flamego 框架源码确认中间件语义
> 结论口径:仅就当前代码可直接确认的问题定性,未做全量编译/运行(本环境缺 CGO/libwebp 与测试数据库,与 08-26 报告一致)
---
## 一、总体结论
**当前代码质量高、安全水位扎实,历史报告中的 P0/P1 缺陷已全部修复,未发现可阻断上线的 P0/P1 漏洞。** 剩余问题集中在 **健壮性 / 可扩展性(P2)** 与 **防御纵深缺口 / 易用性(P3)**,无直接可利用的高危漏洞。
本项目在安全意识上明显优于一般同类项目:CSRF 全覆盖、会话密码版本失效、图片像素/解压炸弹防护、代理 SSRF 三处防护、内容审核 fail-closed、匿名投票复合唯一索引 + HMAC 签名指纹、SafeGo 防崩溃、私聊原子计数 + 回滚、CSV 公式注入防护等均已落地。
---
## 二、历史问题复检(相对 08-13 / 08-18 / 08-24 / 08-26)
逐条核对后,**此前报告中列为 P0/P1/P2 的问题在当前代码中均已处理**:
| 历史问题 | 状态 | 当前证据 |
|---|---|---|
| 真实云服务/数据库密钥入库 | 已知未处理 | 按用户要求保留(测试环境凭据),非本次新增 |
| 图片解压炸弹(OOM) | ✅ 已修复 | `internal/imagecheck` 像素校验 + 三处解码入口接入 |
| 6 位 token 可爆破 | ✅ 已缓解 | 投票 `LinkToken` 用 `randstr.String(12)`;私聊 `Conversation.Token` 6 位(旧约定,低风险) |
| PostgreSQL schema 硬编码 | ✅ 已修复 | `fixPostgresSequences` 改用 `conf.Database.Schema` |
| 密码修改后旧会话仍有效 | ✅ 已修复 | `PasswordVersion` + `authenticatedUser` 版本校验 |
| 找回密码邮箱枚举 | ✅ 已修复 | 邮箱不存在也渲染统一「已发送」页 |
| 内容审核 fail-open(08-24 P1-1 两处) | ✅ 已修复 | `question.go:136-149`、`page.go:317-329`、`page.go:444-458`、`route/vote/censorCheck` 均在 `err != nil` 时拒绝发布 |
| 匿名 `vote_fp` Cookie 可伪造(08-26 P1-01) | ✅ 已修复 | `route/vote/vote.go:83-93` `validVoterToken` 做 HMAC 签名校验;`newVoterToken` 签名 |
| Markdown 实体编码 XSS(08-26 P1-02) | ✅ 已修复 | `internal/template/markdown.go:147` `sanitizeHTML` 对 `TextToken` 调用 `html.EscapeString`;`markdown_test.go:35` 含 XSS 回归测试 |
| 多选重复选项重复计票(08-26 P2-01) | ✅ 已修复 | `parseMultipleOptionIDs`(`vote.go:753-780`)用 `seen` 集合去重 |
| `max_votes_per_fingerprint` 配置矛盾(08-26 P2-02) | 🟡 已缓解 | 业务规则固定为一人一票(`parseVoteMeta` 硬编码 `maxVotes=1`,UI 无控制);但 DB 列与模板默认值仍残留(见 P3-5) |
---
## 三、新发现的问题(本次审阅)
### P2(中危 / 健壮性·可扩展性)
**P2-1 投票结果/CSV 全量加载所有提交记录(O(N) 内存+CPU)**
`LoadResults`(`route/vote/vote.go:209`)对 multiple/rank/rating 类型,以及 `aggregateFormFieldStats` / `aggregateCustomStats` / `VoteResults` / `VoteExportCSV` 均使用 `db.VoteSubmissions.ListByVoteID(ctx, voteID, nil)`(无游标、无上限)把**全部提交记录载入内存**再逐条 `json.Unmarshal` 聚合。结果虽有 30s 进程内 TTL 缓存,但每个缓存窗口的首次计算对热门投票(数千~数万条)是一次性内存/CPU 尖峰。配合 P2-2 的灌票风险,可能放大为资源耗尽。
**建议**:聚合逻辑下推 SQL(`COUNT ... GROUP BY option_id` 已有;rank/rating/form 应补 `SUM/AVG` 或分页流式聚合);或限定单次加载行数上限并降级展示。
**P2-2 匿名投票防刷阈值偏高 + 清 Cookie 可绕过 token 维度限流**
`voteSubmitRateLimitMiddleware`(`internal/route/ratelimit.go:151`)对匿名用户:
- IP 维度兜底 = `limit*10` = **200 次/分钟**;
- token 维度 = `limit` = 20 次/分钟,但**仅当请求携带有效签名 `vote_fp` 时才计数**。
若箱主关闭验证码(`RequireCaptcha=false`,DB 默认 `true` 但可由表单关闭),攻击者在每次提交时发送无效/清空 `vote_fp` Cookie,`ensureVoterToken` 会签发一个**全新的有效签名 token**,token 维度限流永不触发,仅 IP 维度 200/min 兜底生效 → 单 IP 每分钟可灌入最多 200 张真实票,污染结果统计。
**建议**:匿名投票始终要求验证码(或行为验证码);降低 IP 兜底阈值;或改为服务端持久化指纹(绑定 IP+UA 哈希)替代纯客户端 Cookie。
### P3(低危 / 防御纵深·易用性)
**P3-1 autoban 的 404 探测自动封禁失效**(经 Flamego 源码核实)
`internal/route/autoban.go:46-58` 在 `defer` 中读取 `rw.Status()` 判断 404 探测并 `b.Record(KindNotFound)`。但 Flamego 的调度(`[email protected]/context.go:190-229`)在**每个 handler 返回后、下游执行前**就运行 `defer`,且此时 `Written()==false`、`Status()==0`,因此 `KindNotFound` 分支**永远不触发**——404 探测型自动封禁形同虚设。敏感路径封禁(`KindSensitive`,在 defer 前同步设置)仍正常。
**建议**:在 allow 分支显式调用 `ctx.Next()`,使 `defer` 在下游 handler 执行后才运行;或在 `NotFound` 钩子里直接统计 404 信号。
**P3-2 进程内结果缓存跨实例不一致 + 新提交延迟最多 30s**
`resultsCache` / `voteTextRepliesCache` 为单进程 TTL 缓存(`internal/cache/cache.go` 已确认 `sync.RWMutex` 并发安全)。多实例横向扩容时各实例独立缓存,结果会发散;且提交成功后未主动失效,参与者「投票后查看结果」最多延迟 30s 才反映自己的票。
**建议**:多实例改用 Redis 共享缓存(项目已用 flamego/cache-redis);或在 `VoteSubmissions.Create` 成功后删除对应 `vote.ID` 的结果缓存 key。
**P3-3 死配置字段 `max_votes_per_fingerprint`**
业务逻辑已固定为一人一票(`parseVoteMeta` 硬编码 `maxVotes=1`,UI 无控制项),但 `db.Votes.MaxVotesPerFingerprint` 列仍存在并被写入,`VoteNew` 仍设默认值 1。无功能影响,但易误导维护者以为可配置多票。
**建议**:移除该列与模板默认值,或补 UI/文档明确「一人一票」。
**P3-4 草稿/归档投票 POST `/vote/{token}/submit` 提示误导**
`route/vote/vote.go:647-655`:`isVoteActive` 为 false 且非 ended 时统一提示「该投票尚未开始」。对 `draft`/`archived` 状态的投票(其链接本不该被提交),该提示不准确(实际应提示「不存在或已失效」)。
**建议**:按 `vote.Status` 区分提示文案。
**P3-5(信息项,非缺陷)已确认无误的点**
- 复合唯一索引 `(vote_id,user_id)` / `(vote_id,voter_token)` 利用 MySQL/PG 的 NULL 不冲突语义正确实现一人一票;`VoteSubmissions.Create` 用 `isDuplicateKeyError` 将唯一冲突转为 `ErrVoteAlreadyVoted` 兜底 TOCTOU。
- `db.User` 的 `Password`/`PasswordVersion`/`PagePassword`/`AskPassword` 均为 `json:"-"`,`ProfileAPI`(`ctx.JSON(ctx.User)`)与 `ExportProfileJSON` 均**不泄漏口令哈希**。
- `resultsCache` 返回共享 `*Results` 指针,构造后未被变异,当前安全;属脆弱点(未来若在某 handler 内修改该对象会影响所有读者)。
---
## 四、改进方向(非缺陷,建议项)
1. **聚合下推 SQL**:投票结果统计(rank/rating/form)改为数据库层聚合或分页流式处理,消除热门投票的内存/CPU 尖峰(对应 P2-1)。
2. **匿名指纹防刷增强**:持久化指纹 + 始终验证码 + 更严格的 IP 阈值,替代纯客户端 Cookie(对应 P2-2)。
3. **缓存失效与一致性**:提交成功后主动失效结果缓存;多实例用 Redis 共享(对应 P3-2)。
4. **自动化测试补齐**:增加集成/E2E 用例覆盖——内容审核 fail-closed、并发一人一票(唯一索引兜底)、`vote_fp` 签名防伪造、SSRF 逐跳校验、CSV 公式注入、autoban 敏感路径封禁。
5. **CI 质量门禁**:引入 `golangci-lint` + `govulncheck`;当前 `go test ./...` 因 CGO/libwebp 与未配置测试 DB 无法全绿,建议在带 C 工具链与测试库的预发布环境跑全量 `go test ./...`、`go vet ./...`、`govulncheck ./...`。
6. **清理与一致性**:移除死配置 `max_votes_per_fingerprint`、修正草稿/归档投票提交提示、统一错误日志与监控指标(若干 `_ =`/忽略错误可改为显式 metric)。
---
## 五、审阅覆盖清单
| 模块 | 审阅结论 |
|---|---|
| 认证/会话(`route/auth`、`internal/context/auth`、`internal/db/users`) | 密码版本失效、会话重生成到位;口令哈希 `json:"-"` 不泄漏 |
| 投票参与端(`route/vote/vote.go`) | 一人一票(唯一索引+签名 token)、fail-closed 审核、CSRF、去重、限流到位;P2-1/P2-2 待优化 |
| 投票管理端(`route/user/vote.go`) | `getOwnedVote` 全链路归属校验;草稿/发布分级正确;P3-4 提示待修 |
| 私聊(`internal/route/chat`) | 原子计数+回滚、归属校验、fail-closed 审核、邮件节流,均正确 |
| 提问(`route/question`) | 审核 fail-closed、token 访问控制、追问等待逻辑正确 |
| Markdown 清洗(`internal/template/markdown.go`) | 实体编码 XSS 已修复(EscapeString) |
| 缓存(`internal/cache`) | 并发安全(RWMutex);P3-2 跨实例/失效待优化 |
| 路由/中间件/限流(`internal/route`) | 限流/IP 封禁/自动封禁正确拦截(WriteHeader 即停链);P3-1 autoban 404 探测失效 |
| 个人资料/导出/停用(`route/user/profile.go`) | 仅操作 `ctx.User.ID`,无 IDOR;导出不泄漏口令 |
| 模板 XSS 输出 | `QuestionFormat`/`AnswerFormat` 已 EscapeString;仅 CSRF token 用 `Safe`(服务端生成)|
---
## 六、优先级建议
1. (可选,按需)P2-1 / P2-2:若投票功能面向公网且预计有热门投票,优先加固匿名防刷与结果聚合性能。
2. P3-1:修正 autoban 404 探测(一行 `ctx.Next()` 改动,恢复防御纵深)。
3. P3-2 ~ P3-4:随迭代清理,不阻塞上线。
4. 上线前在完整 CI 环境跑全量测试与漏洞扫描,确认无回归。
@@ -1,140 +0,0 @@
# TamaBox-v4 修复后代码复审报告
- **复审时间**:2026-08-26
- **审阅对象**:TamaBox-v4
- **复审基线**:`outputs/TamaBox-v4-code-review-report-2026-08-26.md`
- **复审范围**:上一轮 4 项确认问题的修复调用链、相关数据库模型、模板与回归测试;并对修复影响面做定向静态复查。
- **结论口径**:本报告仅将能由当前代码直接确认的问题列为确认问题;环境限制与建议性事项单独列出。
## 一、复审结论
上一轮报告中的 4 项确认问题均已完成代码级修复,未发现原问题仍可沿原路径复现:
| 原问题 | 复审结论 | 关键位置 |
|---|---|---|
| P1-01 匿名 `vote_fp` Cookie 可伪造 | **已修复** | `route/vote/vote.go:62-120, 691-700` |
| P1-02 Markdown 实体编码导致 XSS | **已修复** | `internal/template/markdown.go:143-147` |
| P2-01 多选重复选项可重复计票 | **已修复** | `route/vote/vote.go:232-247, 749-775, 802-809` |
| P2-02 投票次数配置与一人一票约束矛盾 | **已修复** | `route/user/vote.go:199-202, 563-566`、`templates/user/vote-form.html:85-88` |
本轮复审另发现并修复了一个由 P1-01 修复引入的兼容性问题:签名 token 长度为 108 字符,原数据库字段 `size:64` 不足,已调整为 `size:128`(`internal/db/vote_submissions.go:54-55`)。
**当前代码级结论:未发现 P0/P1/P2 确认问题。**
## 二、逐项复审证据
### 1. P1-01:匿名指纹 Cookie 伪造与防刷绕过
**状态:已修复。**
- `newVoterToken` 生成 32 字节随机 nonce,格式为 `64 位十六进制 nonce + "." + HMAC-SHA256 签名`。
- `signVoterToken` 使用 `conf.Server.XSRFKey` 作为服务端密钥;配置初始化已强制要求该密钥非空。
- `validVoterToken` 检查分段数量、nonce 长度、十六进制格式,并使用 `hmac.Equal` 比较签名。
- `ensureVoterToken` 对无效/伪造 Cookie 重新签发;`currentVoterToken` 对无效 Cookie 返回空值,不再将客户端任意字符串作为身份依据。
- 匿名提交无论是否带有效 Cookie,均按投票维度执行 24 小时 IP 计数;当前一人一票策略下,同一投票同一 IP 窗口内最多接受一次匿名提交。
- 数据库 `UNIQUE(vote_id, voter_token)` 仍作为有效 token 的最终并发防重兜底。
**残余设计取舍**:IP 兜底会对 NAT、校园网、公司网络等共享出口产生误伤;这是保守防刷策略,不是身份伪造漏洞。若业务必须支持共享出口多人投票,应改用验证码、设备/行为风控或更细粒度限流,而不能取消服务端限流。
### 2. P1-02:Markdown 实体编码 XSS
**状态:已修复。**
- `sanitizeHTML` 的 `html.TextToken` 分支现在对 tokenizer 已解码的文本调用 `html.EscapeString` 后再写入 `template.HTML`。
- 因此 `<script>`、双重编码输入不会在重建 HTML 时重新变成可执行标签。
- 标签、属性、URL 白名单逻辑保持有效;事件属性和危险协议仍被过滤。
- 回归测试已覆盖实体编码、双重编码、script、事件属性、危险 URL、iframe、SVG、style 以及 font color 属性注入。
### 3. P2-01:多选重复 option ID 重复计票
**状态:已修复。**
- `parseMultipleOptionIDs` 统一负责多选参数解析。
- 解析时使用 `seen` 集合;同一 `option_id` 出现两次直接返回“选项不能重复选择”。
- 同时校验空提交、最大选项数、选项归属和 `uint` 溢出,避免异常数值绕过校验。
- `LoadResults` 的多选聚合层也对历史数据中的重复 option ID 做防御性去重,因此旧异常数据不会在每次结果计算中再次放大统计。
- 新增测试覆盖正常多选、重复选项、超出上限、无效选项和整数溢出。
### 4. P2-02:MaxVotesPerFingerprint 与数据库约束矛盾
**状态:已修复。**
- 当前数据模型明确采用一人一票:登录用户由 `UNIQUE(vote_id, user_id)` 约束,匿名用户由 `UNIQUE(vote_id, voter_token)` 与 IP 兜底共同保护。
- 管理端解析不再信任旧页面或手工请求提交的 `max_votes_per_fingerprint`,统一按 `1` 保存。
- 新建/编辑失败回填也固定显示 `1`;表单输入设置为 `readonly`、`max="1"`,避免继续传达可配置多票语义。
- 四种语言的字段文案已改为明确的一人一票含义。
## 三、本轮新增兼容性问题及修复
签名 token 的组成长度为:64 字符 nonce + 1 个分隔符 + 43 字符 Raw Base64URL HMAC = **108 字符**。复审过程中确认原字段:
```go
VoterToken *string `gorm:"size:64;..."`
```
不足以存储完整 token,可能导致 MySQL 严格模式下匿名提交失败或 token 截断。现已改为:
```go
VoterToken *string `gorm:"size:128;uniqueIndex:idx_submission_token"`
```
并加入 token 长度回归断言。部署时依赖启动阶段的 GORM AutoMigrate 将字段扩容;上线前应在预生产数据库确认迁移结果和唯一索引状态。
## 四、验证结果
### 通过
```text
gofmt -d route/vote/vote.go route/vote/vote_stats_test.go route/user/vote.go \
internal/db/vote_submissions.go
# 无输出,格式通过
go test -count=1 ./route/vote ./internal/template ./templates ./internal/i18n \
./internal/security/autoban ./internal/security/censor ./internal/imagecheck \
./internal/shared ./internal/cache
# 全部 PASS
go vet ./route/vote ./internal/template ./templates ./internal/i18n
# 无输出,检查通过
```
### 全量验证限制
```text
go test ./...
go vet ./...
```
未能全绿,原因是当前环境已有的基础设施限制,不是本轮修复代码引入:
1. `github.com/chai2010/[email protected]` 在当前 Windows 环境缺少 CGO/WebP 编译符号(如 `webpGetInfo`、`webpDecodeRGB`),导致 `internal/storage`、`route/question`、`route/user`、主程序等相关包无法完整编译。
2. `internal/db` 测试未配置数据库类型,测试初始化报 `Unknown database type: ""`。
因此仍需在生产同构的 Linux/CGO 构建环境,并配置测试数据库后执行:
```text
go test ./...
go vet ./...
govulncheck ./...
```
## 五、上线前建议
1. 在预生产数据库执行/确认 AutoMigrate,确认 `vote_submissions.voter_token` 已扩容到至少 128 字符,且唯一索引仍存在。
2. 使用两个不同匿名客户端验证:同一 IP 同一投票第二次提交被拦截;伪造 `vote_fp` 会被覆盖且不能绕过 IP 限制;正常匿名首次投票成功。
3. 用真实浏览器验证实体编码文本仅以文字显示,不执行脚本;覆盖提问、回答、投票描述和结果页等 Markdown 输出点。
4. 若站点存在大量共享出口,结合验证码或行为风控评估 IP 一票策略的误伤率。
## 六、修改文件清单
- `internal/template/markdown.go`
- `internal/template/markdown_test.go`
- `route/vote/vote.go`
- `route/vote/vote_stats_test.go`
- `route/user/vote.go`
- `templates/user/vote-form.html`
- `internal/db/vote_submissions.go`
- `internal/i18n/dict_zh_cn.go`
- `internal/i18n/dict_zh_tw.go`
- `internal/i18n/dict_en.go`
- `internal/i18n/dict_ja.go`
@@ -1,105 +0,0 @@
# TamaBox-v4 代码逻辑与安全审阅报告
- **审阅时间**:2026-08-26
- **审阅对象**:`C:\Users\Tamakyi Gekka\Downloads\TamaBox-v4-backup-20260813-025725-release\TamaBox-v4-new-toupiao`
- **审阅范围**:Go 后端、路由/中间件、投票与私聊业务、模板渲染与 Markdown 清洗、配置与测试
- **审阅方式**:静态代码审阅、调用链回溯、关键路径输入校验检查、现有测试执行
- **结论口径**:本报告只把能由当前代码直接确认的问题列为“确认问题”;无法在当前环境完整运行的部分单列为验证限制。
## 一、总体结论
当前版本已有较多安全加固(CSRF、会话密码版本、图片像素限制、代理 SSRF 防护、内容审核、投票提交唯一索引等),但仍发现 **4 项需要处理的确认问题**:
- **P1:2 项**:匿名投票指纹 Cookie 可伪造绕过防刷;Markdown 清洗器存在实体编码导致的存储型 XSS。
- **P2:2 项**:多选重复选项可被重复计数;“同一指纹可投次数”配置与数据库唯一索引/应用逻辑不一致,配置大于 1 时实际仍只能投 1 次。
因此,当前不建议直接以“P0/P1 已全部清零”作为上线判断。至少应先修复两项 P1,并对投票防刷策略做明确取舍。
## 二、确认问题清单
### P1-01 🔴 匿名投票指纹 Cookie 未签名,可无限伪造绕过防刷
| 项目 | 详情 |
|---|---|
| 位置 | `route/vote/vote.go:52-90`、`route/vote/vote.go:619-669` |
| 触发条件 | 匿名投票,攻击者可自行发送任意 `vote_fp` Cookie 值 |
| 证据 | `currentVoterToken` 直接返回 Cookie 值;`ensureVoterToken` 也只检查“是否存在”,未校验服务端签名或格式。匿名提交只有在 `currentVoterToken(ctx) == ""` 时才执行 IP 计数(`route/vote/vote.go:656-665`)。数据库唯一索引只约束同一 `vote_id + voter_token`,攻击者每次换一个伪造值即可创建一条新记录。 |
| 影响 | 可绕过匿名投票的 IP 兜底与 Cookie 防重复机制,批量提交大量票;结果统计可被污染,验证码未开启时尤其明显。 |
| 修复建议 | 不要把客户端 Cookie 当作可信身份。使用带 HMAC 的服务端签名 token(签名内容包含随机值、版本与过期时间),服务端拒绝无效/过期/格式异常 token;对匿名提交始终保留 IP/设备/行为限流,不应因 Cookie 存在而跳过 IP 保护。若需要强防刷,使用服务端持久化指纹或按投票维度的限速。 |
### P1-02 🔴 Markdown 清洗器对实体编码文本未再次转义,存在存储型 XSS 路径
| 项目 | 详情 |
|---|---|
| 位置 | `internal/template/markdown.go:95-117`,重点为 `internal/template/markdown.go:103-106` |
| 触发条件 | 用户输入经过 Markdown 渲染后,文本中包含实体编码形式的 HTML,例如 `<img src=x onerror=alert(1)>`;该结果通过 `MarkdownToHTML` 以 `template.HTML` 输出。 |
| 证据 | `html.Tokenizer.Text()` 返回“已解码”的文本(`golang.org/x/net/html` 文档/实现明确为 unescaped text);当前 `extractPlainText` 在 `html.TextToken` 分支直接 `out.WriteString(string(tokenizer.Text()))`,未调用 `html.EscapeString`。因此实体编码可能重新变成原始 `<...>` 字符并被当作 HTML 输出。`MarkdownToHTML` 最终在第 73 行以 `template.HTML(cleaned)` 返回,形成危险输出路径。现有 `internal/template/markdown_test.go:35-54` 没有覆盖实体编码绕过。 |
| 影响 | 提问、回答、投票描述等使用 Markdown 渲染的内容可能形成存储型 XSS;受影响页面上的访问者/登录用户可能执行攻击者脚本。 |
| 修复建议 | 在 `sanitizeHTML` 的 `html.TextToken` 分支对 tokenizer 文本再次使用 `html.EscapeString` 后写入;或改用成熟且经过长期验证的 HTML sanitizer(如 bluemonday),并补充 `&lt;script&gt;...`、双重编码、属性上下文等回归测试。修复后检查所有 `template.HTML` 输出点。 |
### P2-01 🟠 多选提交未拒绝重复选项 ID,可造成单次提交重复计票
| 项目 | 详情 |
|---|---|
| 位置 | `route/vote/vote.go:741-758` |
| 触发条件 | 对多选投票手工构造 POST,使 `option_ids` 重复,例如 `option_ids=12&option_ids=12`。 |
| 证据 | 后端只检查 `len(ids) > v.MaxChoices`、选项 ID 是否存在,然后直接将每个 ID追加到 `chosen`;未使用 `seen` 集合去重。结果聚合 `route/vote/vote.go:203-223` 遍历 `data.Options`,每次出现都增加该选项计数。 |
| 影响 | 一次提交可让同一选项被重复计数;若 `MaxChoices` 大于 1,可用重复值放大某选项票数,破坏统计可信度。 |
| 修复建议 | 解析时维护 `seenOptionIDs`,重复 ID 直接拒绝或去重;同时在统计层对历史异常数据做防御性去重,并补充重复参数测试。 |
### P2-02 🟠 “同一指纹可投次数”配置与实际实现矛盾
| 项目 | 详情 |
|---|---|
| 位置 | `route/user/vote.go:199-205`、`route/vote/vote.go:619-669`、`internal/db/vote_submissions.go:49-55` |
| 触发条件 | 箱主将 `max_votes_per_fingerprint` 设置为 2-100。 |
| 证据 | 管理端允许并保存 1-100(`route/user/vote.go:199-205`);但 `VoteSubmission` 定义了唯一索引 `UNIQUE(vote_id, voter_token)`,且提交前 `HasTokenVoted` 一旦已有记录就直接拒绝(`route/vote/vote.go:627-635`)。因此同一匿名指纹对同一投票始终只能提交一次。该配置只在“无 Cookie 时”的 IP 计数分支使用,无法实现其名称暗示的多次投票。 |
| 影响 | 管理员配置与实际行为不一致;若业务预期允许同一指纹投多次,功能失效;若业务只允许一票,配置项会误导并使防刷策略难以理解。 |
| 修复建议 | 二选一:①业务规则是一人一票,则把字段固定为 1、移除 2-100 UI 和相关逻辑;②业务确实需要多投,则去掉 `UNIQUE(vote_id, voter_token)`,改为服务端计数/窗口约束,并使预检、数据库约束、IP 兜底和结果策略统一。不要只改 UI。 |
## 三、已检查且未发现确认问题的重点区域
- **认证/会话**:登录成功后调用会话 ID 重生成;密码版本变更会使旧会话失效(`route/auth/login.go:64-74`、`internal/context/auth.go:26-30`)。
- **CSRF**:Web POST 统一进入 CSRF 校验;注销已改为 POST;当前未发现可直接确认的高风险 CSRF 缺口。
- **投票归属**:管理端通过 `getOwnedVote` 校验 `vote.UserID == ctx.User.ID`,结果查看、编辑、发布、关闭、删除、导出均复用该校验。
- **SQL 注入**:业务查询主要使用参数绑定;本次检索未发现由用户输入直接拼接 SQL 的确认路径。
- **远程图片代理**:域名白名单、私网 IP 拦截、重定向逐跳校验、固定解析 IP 拨号和响应体上限均已存在;仍建议后续补充集成测试。
- **图片上传**:已在解码前检查像素上限,降低解压炸弹风险。
- **Flash/模板普通文本**:`templates/base/alert.html` 使用普通模板转义;当前未发现历史报告所述的 FlashTip 原样 HTML 输出问题。
## 四、验证结果与环境限制
### 已通过
```text
go test ./internal/template ./route/vote ./templates ./internal/security/autoban \
./internal/security/censor ./internal/imagecheck ./internal/shared ./internal/i18n
PASS
```
### 完整测试未能作为全绿结论
`go test ./...` 当前失败,主要原因是:
1. 本机 CGO/WebP 依赖导致 `github.com/chai2010/webp` 编译错误(`webpGetInfo` 等符号缺失),进而使 `internal/storage`、`route/question`、`route/user`、主程序等包无法完整编译。
2. `internal/db` 测试未配置数据库类型,多个测试报 `Unknown database type: ""`。
上述失败不等同于本次发现的 P1/P2 已被测试覆盖;建议在带 C 工具链与测试数据库的 CI/预发布环境重新执行:
```text
go test ./...
go vet ./...
govulncheck ./...
```
## 五、修复优先级建议
1. **P1-02**:先修 Markdown 清洗器实体编码绕过,并新增 XSS 回归测试。
2. **P1-01**:改为服务端签名匿名 token;同时恢复匿名提交的稳定限流,不因客户端 Cookie 存在而完全跳过 IP/行为防刷。
3. **P2-01**:多选参数去重 + 统计层防御性去重。
4. **P2-02**:明确“一人一票”还是“同指纹多票”,统一数据库约束、应用预检、配置和 UI 文案。
5. 在修复后使用真实测试数据库、启用 CGO 的构建环境重新跑全量测试与漏洞扫描。
## 六、与历史报告的差异说明
项目内已有的 `code-review-report-final.md` 将上一轮结论描述为“P0/P1 全部清零”。本次复审发现的 2 项 P1 与投票相关 P2 属于当前代码路径中仍存在的独立问题,因此不能直接沿用上一轮“全部清零”的结论;应以本报告的当前代码证据为准。
@@ -1,144 +0,0 @@
# TamaBox-v4 功能管理修复 + 代码审阅报告
- **日期**:2026-08-31(更新于同日 16:00,追加 §三 遗留项修复)
- **范围**:4 个已报告问题的修复 + 全链路回归审阅 + 审阅发现项二次修复
- **状态**:✅ 4 个问题已修复;✅ 审阅发现的 P1-2 / P2-1 已一并修复;仅剩已知边界 P2-2(时区约定,非 bug)
---
## 一、修复总览
| # | 用户报告的问题 | 根因 | 修复 | 验证 |
|---|---------------|------|------|------|
| 1 | 🔴「开启该功能」取消勾选保存后自动恢复勾选 | `Enabled bool` 带 `gorm:"default:true"`,GORM 在 Create 时把零值 `false` 替换为默认值 `true` 写入(INSERT 值与 ON CONFLICT 的 `excluded.enabled` 都是 true) | 移除该标签,GORM 原样写入显式值 | 生成的 SQL 已确认写入 `false`;单元测试通过 |
| 2 | 🟠 勾选「仅允许注册用户向我提问」后,二维码下仍显示「谁都可以以匿名的形式提问」 | `templates/question/list.html:165` 与 `question-not-found.html:86` 硬编码匿名文案,与 `HarassmentSetting` 无关 | 文案随设置联动:新增 `ask.registerOnly` 词条,`Pager` 注入 `RegisterOnlyAsk`,模板二选一 | 新增 3 个模板渲染测试 |
| 3 | 🟠 功能关闭时只显示「提问功能尚未开放」,缺少上次关闭/下次开放时间 | 关闭提示仅有 `FeatureClosedMessage` 一行 | `FeatureState` 新增 `NextOpenAt` / `LastCloseAt`,关闭提示下追加两行时间信息 | 新增 2 个渲染测试 + 逻辑单元测试 |
| 4 | 🟡 希望支持每周几 / 每天几点到几点循环开放 | 原模型只有一次性 `OpenAt/CloseAt` | 新增 `ScheduleDays`(周几列表)+ `ScheduleStart/End`(每日时段),`Evaluate` 综合判定 | 新增 20+ 个单元测试用例 |
**验证结果**:`internal/db`、`internal/i18n`、`internal/template`、`templates`、`internal/shared`、`internal/security/censor` 全部测试通过;`go vet ./...` 无新增告警(仅 Windows 沙箱 webp CGO 缺库的已知噪音);模板解析/跨引用/渲染测试全绿。
---
## 二、修复细节
### 1. 问题 1 根因(P0,已修复)
`internal/db/feature_control.go`:
```go
// 修复前(BUG)
Enabled bool `gorm:"default:true"`
// 修复后
Enabled bool // 不要加 default:true 标签(注释已标注原因)
```
**机制**:GORM 在 `Create` 时对带 `default` 标签的字段,若值为零值(`false`)会替换为 `DefaultValueInterface`(`true`)再写库。实测生成 SQL:
```sql
-- 修复前:Enabled=false 传入 → VALUES 里写入 true
INSERT INTO feature_controls (..., enabled, ...) VALUES (..., true, ...)
ON CONFLICT (...) DO UPDATE SET enabled = excluded.enabled -- 也是 true
-- 修复后:Enabled=false 传入 → VALUES 里写入 false ✓
INSERT INTO feature_controls (..., enabled, ...) VALUES (..., false, ...)
```
**连带修复**:`backfillFeatureControls` 为私聊(默认关闭)补行时同样会把 `false` 写成 `true`——即历史上所有私聊默认关闭的用户可能被静默开启。同一处修复同时解决。
### 2. 问题 2(P1,已修复)
- `route/question/page.go`:`Pager` 注入 `ctx.Data["RegisterOnlyAsk"] = pageUser.HarassmentSetting == register_only`。
- `templates/question/list.html` / `question-not-found.html`:二维码下文案改为
`{{ if .RegisterOnlyAsk }}仅允许注册用户向TA提问{{ else }}谁都可以以匿名的形式提问{{ end }}`。
- 新增 i18n 词条 `ask.registerOnly`(4 种语言)。
### 3. 问题 3(P1,已修复)
- `FeatureState` 新增 `NextOpenAt` / `LastCloseAt`(由 `Evaluate` 计算)。
- `Pager` 格式化为 `AskNextOpenAtStr` / `AskLastCloseAtStr`。
- `new-question-template.html` 在关闭提示下方追加:
- `下次开放时间:2026-09-02 10:00`(未开始 / 循环定时未来再开时)
- `上次关闭时间:2026-08-31 18:00`(曾经开放过时)
- 手动关闭 / 定量上限两类与时间无关的状态不展示时间行。
### 4. 问题 4(P1,新功能)
**模型**(`feature_controls` 新增 3 列,`AutoMigrate` 自动加列):
| 列 | 语义 |
|----|------|
| `schedule_days` | 逗号分隔周几,`0=周日…6=周六`(与 `time.Weekday` 一致);空 = 每天都开放 |
| `schedule_start` / `schedule_end` | 每日时段 `HH:MM`;都为空 = 开放日全天 |
**语义**:
- 只填开放日 → 该天全天开放(每周循环);只填时间 → 每天该时段开放;都填 → 按周循环的每日时段开放。
- 与一次性 `OpenAt/CloseAt` **叠加(AND)**:一次性窗口是硬性外边界,循环定时仅在其内生效。
- `Evaluate` 判定优先级:手动关闭 > 未到一次性开放 > 已过一次性关闭 > 不在循环窗口 > 定量上限。
- 「下次开放时间」按日向前扫描 8 天(覆盖全部周几组合),与一次性窗口取交集。
**UI**(`feature-control-form.html`):新增「循环定时(每周循环)」区块——7 个开放日胶囊复选框 + 每日开始/结束 `time` 输入;「只填开放日=全天 / 只填时间=每天」提示写入 hint。
**校验**(`route/user/features.go`):开始/结束必须成对填写;`HH:MM` 合法;结束须晚于开始;开放日仅接受 0-6 并去重排序存储。
---
## 三、审阅发现的其他潜在问题
### 🟠 P1-1 一次性时间解析的时区偏移(已修复)
- 位置:`route/user/vote.go` `parseTimeInput`。
- 问题:`time.Parse("2006-01-02T15:04", s)` 解析结果落在 **UTC**,而 `Evaluate` 用 `time.Now()`(服务器本地时区)比较——服务器非 UTC 时,「定时开放/关闭」与「投票起止」会整体偏移(如东八区偏移 8 小时)。
- 修复:改用 `time.ParseInLocation(..., time.Local)`,与 `time.Now()` 比较基准一致。
- 注意:修复前已保存的时间值按 UTC 解释,部署后若服务器本地时区非 UTC,历史设定会有一致性位移(UTC 服务器无感知)。
### 🟠 P1-2 历史数据:旧版 backfill 可能已把私聊错误开启(✅ 已修复)
- 位置:`internal/db/feature_control.go` 新增 `reconcileLegacyChatEnabled`(`db.Init` 中在 backfill 之后调用)。
- 问题:旧版本(`Enabled gorm:"default:true"`)下,为 `allow_chat=false` 的用户补私聊行时,`false` 被写成 `true`,**私聊被被动开启**;且 `UpdateChatSettings` 会反向把 `users.allow_chat` 同步为 `true`,形成持久化。
- 修复:启动期**幂等纠偏**,同时命中以下条件才复位(绝不误伤箱主配置过的行):
- `feature='chat'` 且 `enabled=true`;
- 对应 `users.allow_chat=false`(箱主从未在面板开启私聊——开启会同步 allow_chat=true);
- 该行无任何自定义配置(一次性时间为空、quota_limit=0、循环定时为空);
- `updated_at <= created_at`(从未被面板保存过)。
- 命中即复位 `feature_controls.enabled=false` 并同步 `users.allow_chat=false`;每次启动只处理未修复行,直到清零,失败仅告警不阻断启动。
- 说明:对于「allow_chat 已被旧同步逻辑改写为 true」的更深度历史污染,无法与箱主主动开启区分,不在自动纠偏范围内;如需处理请人工核对后按下方 SQL 定向调整:
```sql
-- PostgreSQL(仅当确认某账号私聊被错误开启时,替换 <user_id>)
UPDATE feature_controls SET enabled = false WHERE feature = 'chat' AND user_id = <user_id>;
UPDATE users SET allow_chat = false WHERE id = <user_id>;
```
### 🟡 P2-1 `Vote.RequireCaptcha` 同类 GORM 陷阱(✅ 已修复)
- 位置:`internal/db/votes.go` `RequireCaptcha bool`(原带 `gorm:"default:true"`)。
- 问题:与问题 1 同源——若底层 `Votes.Create` 未来允许 `RequireCaptcha=false`,GORM 会把 `false` 替换为默认 `true`。
- 修复:去掉 `default:true` 标签。业务层 `route/user/vote.go` 仍硬编码 `requireCaptcha := true`(决策 #3),**线上行为不变**;已同步更新 `votes_test.go` 的 `testVotesCreate` 断言(Create 未显式传参时按 `false` 落库,注释说明业务层强制 true)。实测生成的 SQL:显式 `true` 写入 `true`、未传写入 `false`。
### 🟡 P2-2 循环定时的时区与跨日限制(已知边界,建议知晓)
- 时段按**服务器本地时区**解释(与一次性时间一致)。若服务器与箱主时区不同,每日时段会整体偏移;如需支持,可后续增加箱主时区字段。
- 不支持跨午夜的时段(如 22:00-02:00,结束必须晚于开始),校验会拦截并给出中文提示。
- 「下次开放时间」扫描窗口为 8 天,覆盖任意周几组合;一次性窗口 >8 天的场景由一次性分支单独处理,不受影响。
---
## 四、回归检查(原问题是否复现 / 影响面)
| 检查项 | 结果 |
|--------|------|
| 问题 1(开关复位):`Save` 生成的 SQL 正确写入 `false`,ON CONFLICT 更新列含 `enabled` | ✅ |
| 问题 2(文案):匿名/注册专属两种状态的渲染测试 | ✅ 3 个用例通过 |
| 问题 3(关闭信息):未开始 / 已结束(永久)/ 循环定时三种关闭形态的模板渲染 | ✅ 2 个用例通过 |
| 问题 4(循环定时):14 组 Evaluate 判定 + 8 组 NextOpenAt/LastCloseAt 边界 | ✅ 全部通过 |
| P1-2 纠偏:SELECT(带 `fc.` 别名)/ UPDATE(无前缀)生成的 PostgreSQL SQL 均正确;软删除行已过滤 | ✅ SQL 生成验证 |
| P2-1 Vote:`Create(RequireCaptcha=true)` 写入 `true`、未传写入 `false`(不再被默认替换) | ✅ SQL 生成验证 + 断言更新 |
| 模板:新增 `hasDay` 函数与 `printf` 组合无解析错误,全部模板解析/跨引用测试通过 | ✅ |
| i18n:新增 12 个词条 × 4 语言齐全,无缺失(缺失会回退 zh-CN,但已全部补齐) | ✅ |
| 代码质量:`gofmt` 干净、`go vet ./...` 无新增告警 | ✅ |
| 存量行为:未改 `CanAsk` 计算、未改 `FeatureOpen` 语义、投票业务流(硬编码 requireCaptcha=true)不变 | ✅ |
**部署提醒**:
1. `feature_controls` 新增 3 列由 `AutoMigrate` 自动加列,无手工迁移;上线后在功能管理页重新保存一次各功能即可看到循环定时区块。
2. 私聊纠偏为**启动期自动执行**:首次启动时若命中旧数据会输出 `Warn "私聊开关纠偏:旧版数据把未开启私聊的账号置为开启,已复位为关闭"`(附数量),属预期行为;未命中则无日志。
3. `TestVotes` 等 DB 集成测试需在配置了 `DB_TYPE` 的 PostgreSQL/MySQL 环境运行(本地沙箱无凭据,未执行);逻辑层测试已全部通过。
@@ -1,146 +0,0 @@
# TamaBox 提问箱「加载更多」翻页重复事故复盘(时区偏移根因 + timestamptz 迁移修正)
- **日期**:2026-09-05(排查 01:57–04:35 左右,约 2.5 小时闭环)
- **影响范围**:线上 `box.tama.guru` 全部「提问箱历史」列表(单题页 `/_/{domain}/{id}` 与用户主页底部内嵌同一列表)
- **最终结论**:两层缺陷叠加——① 服务端游标解析的时区偏移导致 keyset 分页失效(直接根因);② 为根治而做的 timestamptz 迁移解释基准选错,导致存量时间整体 +8h(次生事故,已二次修正)
- **状态**:✅ 全部修复并线上验证通过
---
## 一、现象与影响
| 现象 | 影响 |
|---|---|
| 提问列表首屏 20 条加载完后「加载更多」按钮仍存在 | 点击后**追加与首页完全相同的内容** |
| 按钮永不消失,可无限点击重复 | 用户困惑;若服务端异常则无限膨胀 DOM |
线上 API 实测复现:携带首页返回的 `next_cursor` 再次请求,返回**完全相同的第 1 页 20 条**,`has_more=true`、游标不变。
---
## 二、关键环境事实(排查中确认)
| 项 | 值 | 备注 |
|---|---|---|
| 线上 schema | `nekobox`(非 public,默认 search_path 找不到) | 首版诊断脚本"关系不存在"根因 |
| `questions` 表 | `id numeric`(seq)、`user_id bigint`、`answer text`、`is_private/is_pinned/is_read boolean` | 与源码 GORM 模型基本一致 |
| **时间列** | `created_at/updated_at/deleted_at = timestamp WITHOUT time zone`(6) | **与源码 AutoMigrate 默认 timestamptz 不一致**——本次两大事故的共同土壤 |
| 实际含无时区时间列的表 | 仅 4 张:`questions / users / censor_logs / upload_images`(12 列) | 其余表(messages/conversations/votes 等)本为 timestamptz |
| 服务端进程/会话时区 | Asia/Shanghai(+08:00) | 与无时区列叠加构成偏移 |
| aoi 箱 | user_id=329,已答公开 38 条 | 与线上 API 总数一致 |
分页链路:前端 `templates/question/history-template.html`(Alpine fetch)→ `route/question/page.go ListAPI` → `internal/db/questions.go GetByUserID`,PinFirst 复合键集游标 `<isPinned>.<UnixNano>.<id>`,排序 `is_pinned DESC, created_at DESC, id DESC`,`page_size=20` 多取 1 条探测 `has_more`。
---
## 三、排查时间线
| 时间(约) | 事件 | 结果/判断 |
|---|---|---|
| 01:57–02:30 | 线上复现 + 全链路代码走查 | 前端模板与 0904 源码逐字符一致;排除 CDN 缓存(回源 MISS);**服务端游标被无视** |
| ~02:30 | 对照实验 | 任意格式游标均返回首页/个别怪异结果(空集、固定 6 条、38 条全量)——源码逻辑无法解释,疑似版本不一致 |
| 02:39 | 产出 v1 PG 诊断脚本 | 用户执行报"关系不存在"→ 定位 schema=nekobox |
| ~03:00 | 产出 v2 诊断脚本(search_path + 无时区列 + 时区诊断) | **DB 层洗清**:38 条 keyset 翻页不重不漏、无时区列读写文本对称、nano 指纹精确命中 |
| ~03:10 | 首次部署 0904(含防重兜底)后复测 | **仍复现**——推翻"部署版本旧"假设 |
| ~03:15 | 读 `parsePinCursor` 源码 + 本地 tz 实验 | 🔴 **真根因**:`time.Unix(0, nano)` 按服务器本地时区还原,+8h 墙钟发给无时区列比较恒真 → keyset 失效 |
| ~03:25 | 修复 `.UTC()` + 编译测试 | `go build`/`go vet` PASS |
| ~03:30 | 用户重新部署 | 线上翻页**38 条唯一收敛** ✓;非法/旧格式游标返回空+`has_more=false` ✓ |
| ~03:40 | 克隆库 timestamptz 迁移全套验证 | epoch 前后 0 差异、指纹一致、翻页同款、回滚路径无损 |
| 04:02 | 线上执行迁移 | 4 表 12 列转 timestamptz,验收 3.1–3.4 全绿(epoch 一致、残余 0) |
| ~04:20 | 端到端验收 | 38 条唯一收敛 ✓,但**发现 created_at 文本从 Z 变 +08:00** |
| 04:27 | 用户反馈「真实本地 00:01 的提问页面显示 08:01」 | 🟠 **次生事故确认**:迁移解释基准用错(见第五节) |
| 04:30–04:35 | 克隆库验证反向修正 → 线上执行 → API 验收 | created_at 回到本地墙钟、`next_cursor` nano 变化、38 条收敛 ✓ |
---
## 四、直接根因(第一层):游标解析时区偏移
```go
// 修改前(internal/db/questions.go parsePinCursor)
return isPinned, time.Unix(0, nano), uint(idVal), true
// ↑ 返回「服务器本地时区」的时间
```
**机制**:
1. 服务端把某行 `created_at`(无时区列读出后带 UTC 标记)转 `UnixNano` 下发(nano = UTC 绝对时刻)。
2. 解析时 `time.Unix(0, nano)` 返回 **Local(Asia/Shanghai)** 时间 → 墙钟数字 +8h(00:01 → 08:01)。
3. 驱动把该参数发往 **无时区列** 比较时按墙钟直发 → `created_at < '2026-09-05 08:01:16'` 对库内全部 `00:xx` 行**恒为真** → keyset 条件退化为"全部行" → **永远返回第一页 + has_more=true → 无限重复**。
4. 之前线上"空集 / 固定 6 条 / 38 条全量"等怪异现象,均为偏移后游标落在数据窗口不同位置的表现。
**修复**:`time.Unix(0, nano)` → `time.Unix(0, nano).UTC()`(绝对时刻语义,与列值墙钟对齐)。同时补充两层防重兜底:
| 文件 | 改动 |
|---|---|
| `internal/db/questions.go` `parsePinCursor` | `time.Unix(0, nano).UTC()` ← **根治一行**(附时区约定注释) |
| `internal/db/questions.go` `GetByUserID` | 游标解析失败**不再静默回退首页**,返回空列表 → `has_more=false` 收敛 |
| `internal/db/questions.go` `getBy`(旧路径) | 同步改为解析失败返回空,保持一致 |
| `templates/question/history-template.html` | SSR/JS 条目都带 `data-qid`;点击时按已渲染 id 集合**过滤重复**,无新增即 `more=false` 隐藏按钮 |
> 注:其余 `UnixNano` 用途(随机种子/文件名)无 keyset 风险,未受影响。
---
## 五、次生事故(第二层):timestamptz 迁移基准选错,存量 +8h
### 5.1 数据真实语义的判定
存量无时区列存的是**上海本地墙钟**(如 `00:01:16` = 用户凌晨 00:01 提问)。证据:用户以现实锚点反馈"真实本地 00:01 的提问页面显示 08:01"。
**此前 v2 诊断的认知盲区**:`DB 墙钟 == 线上 JSON Z 文本` 只能证明**读路径文本一致**——驱动把无时区列读出值标记为 UTC 输出,文本恰好等于库内墙钟数字,形成"读写对称"假象;它**不能证明**墙钟的世界语义是 UTC。而驱动写入无时区列时按进程本地墙钟落库 → 真实语义其实是**上海墙钟**。
### 5.2 错误迁移及其后果
首版迁移用 `USING created_at AT TIME ZONE 'UTC'`(把墙钟当 UTC 解释)→ 存量绝对时刻整体 **+8h** → 页面显示比真实晚 8 小时。迁移前页面显示"00:01"(无时区列按 UTC 直显,文本恰巧等于上海墙钟)反而是对的。
### 5.3 反向修正
```sql
ALTER TABLE nekobox.questions
ALTER COLUMN created_at TYPE timestamptz
USING (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Shanghai');
-- 净效果:绝对时刻 -8h;语义 = 原墙钟按 Asia/Shanghai 解释;与会话时区无关
```
克隆库整段演练通过后线上执行,API 验证:
| 验证点 | 修正前(错误) | 修正后(正确) |
|---|---|---|
| 3089 会话显示 | `2026-09-05 08:01:16.678885+08` | `2026-09-05 00:01:16.678885+08` |
| 3089 epoch | `1788566476678885000` | `1788537676678885000` |
| 3109 | `08:49:38+08` | `00:49:38.648502+08` |
| 2411 | `06-18 06:30:19+08` | `2026-06-17 22:30:19.615754+08` |
| API `next_cursor` nano 段 | `0.1788566476678885000` | `0.1788537676678885000` |
| 翻页收敛 | — | 38 条唯一 ✓ |
---
## 六、经验教训(预防清单)
| 级别 | 教训 | 落地动作 |
|---|---|---|
| 🔴 P0 | **无时区时间列与带时区应用代码混用是事故土壤** | 代码约定:所有时间列应统一 timestamptz;任何 `time.Unix`/`time.Parse` 进 keyset/SQL 比较路径必须显式 `.UTC()` 并在注释写明约定 |
| 🔴 P0 | **迁移解释基准必须先锚定数据真实语义**,不能只看"转换前后 epoch 一致" | 迁移前用 1–2 条**用户可确认真实时刻**的记录做锚点(如"这条我实际几点发的"),或写一条测试数据核对显示 |
| 🟠 P1 | "DB 墙钟 == JSON 文本"不能证明世界语义 | 读路径文本一致 ≠ 写路径语义正确;需结合写入方(驱动对无时区列的落库行为)判断 |
| 🟠 P1 | 线上行为无法用源码解释时,先怀疑**时区/列类型**再怀疑部署版本 | 本次"部署后仍复现"正是锁定 `.UTC()` 的关键转折 |
| 🟡 P2 | psql `\i` 长输出会进分页器截断 | 运维长脚本建议开头 `\pset pager off`,或分段执行 |
---
## 七、脚本/文档产物清单(`outputs/`)
| 文件 | 用途 | 状态 |
|---|---|---|
| `pg-cursor-pagination-diagnose.sql` | v1 诊断(默认 public schema,被 schema 问题阻住) | 已被 v2 取代 |
| `pg-cursor-diagnose-v2-nekobox.sql` | v2 诊断(search_path=nekobox,洗清 DB 层) | 完成使命 |
| `timestamptz-migration-online.sql` | 首版迁移(UTC 基准)——**含错误基准,勿复用** | ⚠️ 已被 fix-8h 修正 |
| `timestamptz-migration-fix-8h.sql` | 反向修正(Asia/Shanghai 基准) | ✅ 线上已执行;**不可重跑**(会再 -8h) |
| 本文档 | 事故复盘留档 | ✅ |
## 八、最终状态与遗留建议
- **线上已闭环**:翻页 38 条唯一收敛;存量时间回到真实本地墙钟;timestamptz 类型层根治,keyset 不再依赖进程时区。
- **遗留建议(按需排期)**:
1. 页面强刷后人工抽查时间显示(SSR 与「加载更多」追加项应一致)。
2. 可选:应用层 JSON 序列化统一 `.UTC()` 输出 Z(若存在外部消费者依赖 UTC 文本)。
3. 本地克隆测试实例(127.0.0.1:5433)与 dump 副本已无用途,可清理。
4. 生产库 `DROP SCHEMA diag_test CASCADE;` 清理诊断快照表。
@@ -1,65 +0,0 @@
# TamaBox-v4 评审修复落地报告(2026-08-27)
基于 `outputs/TamaBox-v4-code-review-2026-08-27.md` 的 P2/P3 findings,落实 4 项修复。
改动遵循「最小、可读、可回滚」原则;每项均给出根因、改法、验证状态。
---
## 已落地(4 项)
### P3-1 autoban 404 探测失效 ✅
**文件**:`internal/route/autoban.go`
**根因**:`autobanMiddleware` 内 `defer` 在该 handler **返回时**触发,而 Flamego `run()` 会在 handler 返回后自动推进链路,因此 `defer` 执行时下游路由尚未运行,`rw.Status()` 恒为 `0` → `KindNotFound` 分支永远进不去。敏感路径封禁因 `kind` 在 body 内同步置位,不受影响。
**修复**:在 `defer` 之后、函数返回前加 `ctx.Next()`。经核对 `[email protected]`:`Next()` 即 `c.index++; c.run()`,会把下游全部跑完并让 `c.index` 越过末尾,外层循环随后只「超调」一次即退出,**不会重复执行下游**。
**验证**:`internal/security/autoban` 单测通过(安全核心逻辑);`gofmt` 语法校验通过。
### P3-4 草稿/归档提交提示误导 ✅
**文件**:`route/vote/vote.go`(`Submit`,约 647 行)
**根因**:`!isVoteActive` 仅二分「已结束 / 尚未开始」。草稿(`draft`)与归档(`archived`)状态均 `Status != published` 且通常无 `EndAt`,双双落入「尚未开始」,而重定向目标 `/vote/{LinkToken}` 本身会拦截草稿/归档 → 死循环式跳转。
**修复**:按 `vote.Status` 精确分支:
- `draft` → 「该投票尚未发布」
- `archived` → 「该投票已归档,无法参与」
- 其余:`isVoteEnded` → 「该投票已结束」/ 否则「该投票尚未开始」
**验证**:`gofmt` 语法校验通过;状态常量 `db.VoteStatusDraft/Archived` 已核实存在。
### P2-2 匿名投票强制验证码 ✅
**方向**(用户拍板):匿名投票始终要求验证码(或行为验证码)。
**文件**:`route/vote/vote.go`(`Submit`)、`route/user/vote.go`(`parseVoteMeta` / `renderVoteFormWithInput`)、`templates/user/vote-form.html`
**根因**:原逻辑 `if vote.RequireCaptcha` 仅在创建者开启验证码时才校验匿名提交;关闭后匿名用户清除 `vote_fp` Cookie 即可换签名 token 重复投票,仅剩 `limit*10` 的 IP 兜底。
**修复**:
1. `Submit` 匿名分支门槛由 `if vote.RequireCaptcha` 改为 `if conf.Recaptcha.CaptchaType != ""`——**只要全局配置了验证码,匿名提交一律校验**;未配置(无密钥部署)时不阻断,避免整崩。
2. `parseVoteMeta`(草稿创建/编辑、发布中编辑三路共用的唯一 `require_captcha` 读取点)与 `renderVoteFormWithInput` 强制 `requireCaptcha := true`,使存储值与策略一致。
3. 模板复选框置为 `checked disabled`,对 owner 明确「不可关闭」。
**对齐既有决策**:`db.Votes.RequireCaptcha` gorm 默认 `true`,`votes_test.go:63` 断言「匿名验证码默认开启(决策 #3)」,i18n 即「匿名投票需通过验证码」。本修复使该决策不可被 UI 关闭。
**验证**:`gofmt` 语法校验通过;`conf.Recaptcha.CaptchaType` 字段在 `internal/context/context.go:269` 已使用,类型无误。
### P2-1 结果缓存写穿失效 ✅(务实版)
**文件**:`internal/cache/cache.go`、`route/vote/vote.go`(`Submit`)
**根因**:`LoadResults` 对 `multiple/rating/rank/form` 类型需 `ListByVoteID` 全量加载并在 Go 层逐条 `json.Unmarshal` 聚合(O(N));`resultsCache`(30s TTL)仅在过期时重算,新提交后结果页最多陈旧 30s,且每次窗口到期都要重算一次全量。
**修复**:
1. 给 `TTLCache` 新增 `Delete(key)` 方法。
2. `Submit` 成功 `Create` 提交后调用 `resultsCache.Delete(strconv.FormatUint(vote.ID,10))`——**写穿式失效**:新投票后立即清除该投票结果缓存,结果页随即反映,且把 O(N) 重算成本收敛到「每次新投票一次」。
**未做(需独立迁移)**:真正的 O(1) SQL 下推受限于选项选择结果存于 JSON `FormData` 大字段(跨 MySQL/PG 做 JSON 聚合既脆弱又依赖具体版本)。彻底消除 O(N) 需把选择项正态化到独立表(如 `vote_submission_selections(vote_id, submission_id, option_id)`),以 `GROUP BY option_id` 聚合。建议作为后续独立 schema 迁移评估,**不在本次改动内**。
**验证**:`internal/cache` 单测通过(含新 `Delete`);`gofmt` 语法校验通过。
---
## 本次未改(留作独立决策)
- **P3-3 死配置 `MaxVotesPerFingerprint`**:全链路写死为 1、提交侧从不读取,属历史遗留。鉴于设计本意即「一人一票」,建议整体移除该字段与表单接线;涉及 DB schema 迁移,需你确认后再做。
- **P3-2 进程内缓存跨实例陈旧**:`TTLCache` 为进程本地,多实例下清缓存不跨实例。可接受最终一致 / 缩短 TTL / 迁 Redis——架构决策,不急。
---
## 构建与验证状态
- ✅ 语法:`gofmt -e -l` 对 4 个改动的 .go 文件均无报错。
- ✅ 单元:`go test ./internal/security/autoban/... ./internal/cache/...` 全绿。
- ⚠️ 全量构建受阻(**既有环境问题,非本次改动**):`github.com/chai2010/webp` 依赖 libwebp 本机 C 库(CGO),当前沙箱缺该库,报 `undefined: webpGetInfo` 等。该依赖经传递链被 `route/vote`、`route/user`、`internal/route` 引入,故这几个包在本环境无法完整 `go build`。在带 libwebp 的 CI/Docker 环境中可正常构建。建议你在本地/CI 跑一次全量 `go build ./...` 与 `go test ./...` 做最终确认。
## 改动文件清单
- `internal/route/autoban.go`
- `internal/cache/cache.go`
- `route/vote/vote.go`
- `route/user/vote.go`
- `templates/user/vote-form.html`
Binary file not shown.

Before

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 367 KiB

@@ -1,235 +0,0 @@
# 阿里云审核通道「response data is empty」诊断报告
日期:2026-09-10
场景:`@scene#15:"日常开发"` — 内容审核 / `[security] text_censor_source = aliyun`
---
## 最终落地(2026-09-10 16:40)— 已迁移至内容安全 2.0
三条路径中**已选定并落地「方案 A:迁移到内容安全增强版 2.0」**,同时采纳第五节提出的折中防呆
(首次出现账号级拒绝时打一条一次性告警)。
### 为什么不是方案 B / C
- **B(升级企业实名认证)**:需要企业资质,当前账号不具备;且 1.0 的标签体系本身也已过时。
- **C(放弃阿里云只用七牛)**:会丢掉 auto 模式后半段「七牛误报时由阿里云二次复核」的能力。
### 关键事实(官方文档核实)
| 项目 | 内容安全 1.0(旧) | 文本审核增强版 2.0(新) |
| --- | --- | --- |
| 接口 | `green.cn-shanghai` + `/green/text/scan` | `green-cip.cn-shanghai.aliyuncs.com` + `TextModeration` |
| SDK | `alibaba-cloud-sdk-go`(已从依赖中移除) | `alibabacloud-go/green-20220302/v3 v3.5.4` |
| 开通前提 | **企业实名认证**(个人账号永远 596) | **实名认证即可**(个人可开通) |
| 计费 | — | 文本审核标准 7.5 元/万次;开通免费、按量出账 |
| 单次上限 | — | `content` ≤ **600 字符**(超长必须分段) |
⚠️ **一处必须记录的事实修正**:`green-20220302` SDK 中
`TextModerationResponseBodyData.Labels` 的内联注释列出的标签(10 个,且不含涉政)
**是旧的、不完整的**。以官方《文本审核增强版标签处理建议参考》为准,2.0 通用服务
**包含** `political_content`(涉政)、`violence`(暴恐)、`sexual_content`(色情)、
`C_customized`(自定义词库)—— 即 `policy.go` 的底线四件套可完整覆盖,
**不存在此前担心的「迁移后失去涉政拦截」缺口**。
### 落地改动
| 文件 | 改动 |
| --- | --- |
| `internal/security/censor/aliyun.go` | 整文件重写为 2.0:`TextModeration` + 归一化响应 + 标签映射 + 分段审核 + 一次性账号告警 |
| 同上 | 审核场景固定 `comment_detection`(公聊评论:官方定位覆盖涉政/涉暴/涉黄底线 + 广告导流) |
| 同上 | 超长文本按 550 字分段、段间重叠 50 字;合并口径为标签取并集、风险级别取最高 |
| 同上 | `riskLevel` → 置信度折算 high=1 / medium=0.6 / low=0.3,供 `policy.go` 的阈值复用 |
| 同上 | 归一化响应带 `api: "TextModeration"` 格式标记 |
| `internal/security/censor/censor.go` | 阿里云缓存回放解析失败时**退回实时调用**,不再直接抛错 |
| `internal/security/censor/{text,policy}.go` | 同步注释(置信度来源由 rate 改为 riskLevel 折算) |
| `internal/security/censor/aliyun_test.go` | 新增 2.0 测试套件(分段 / 标签映射 / 多片合并 / 兼容性 / 告警判定) |
| `internal/security/censor/policy_test.go` | 移除 1.0 格式的旧测试 |
| `conf/app.sample.ini`、`internal/conf/static.go` | 注释改为 2.0 开通指引 |
| `go.mod` / `go.sum` | 移除已无引用的 `alibaba-cloud-sdk-go v1.62.26` |
### 两个刻意的设计取舍
1. **格式标记 + 缓存回退**:解析器拒绝一切非 `api: "TextModeration"` 的载荷,`censor.go`
在解析失败时退回一次实时调用。作用:迁移前 1.0 落库的缓存(若有)既不会被误判成
「通过」(那会变成绕过审核的后门),也不会因 fail-closed 让这些历史文本在 31 天缓存
过期前一直发不出去。
2. **一次性账号级告警**:首次出现 596 / Forbidden / InvalidAccessKeyId 等「账号侧未就绪」
失败时,打印一条带开通入口与授权说明的 WARN,之后不再重复 —— 零成本、无外部依赖,
把「静默全站拒签」变成日志里一眼可见的可操作信号。
### 校验结果
- `go build -overlay=... ./...`:通过
- `go vet ./internal/security/...`:通过
- `go test ./...`:`internal/security/censor` 全绿;仅 `internal/db` 失败,
且失败信息均为 `Unknown database type: ""`(需真实 PostgreSQL/MySQL),与本改动无关
- `gofmt -l`:仅 `censor.go`(改动前既有的空格缩进,按约定未动)
### 仍需你处理
1. 在阿里云**内容安全 2.0 控制台**开通「内容审核增强版」按量付费;RAM 子账号授予
`AliyunYundunGreenWebFullAccess`。开通前:`auto` 模式只走七牛;`aliyun` 模式仍会 fail-closed。
2. CHANGELOG 尚未记入(见文末第五节)。
---
## 〇、结论速览(诊断阶段,2026-09-10 16:05)
> ✅ **根因(两层)**
> 1. 直接原因:**阿里云账号未开通「内容安全 1.0」** → `code=596`。
> 2. 深层原因:**「内容安全 1.0」的开通前提是账号已完成企业实名认证**(官方文档明文)。
> 若该账号是**个人实名认证**,则 1.0 **永远无法开通**,无论在产品页怎么点。
诊断改动落地后,下一条日志立即给出了答案:
```
error="text scan (request body={\"scenes\":[\"antispam\"],\"tasks\":[{\"content\":\"测试!\"}]}):
aliyun rejected the request: code=596
msg=\"You have not opened Yundun Content Moderation Service, please go to the page
(https://www.aliyun.com/product/lvwang) to open our service after login Ali cloud console\"
request_id=01A08A4B-CCE2-5AAF-9329-A0C51C0F5060
raw={\"code\":596,\"httpStatusCode\":200,\"msg\":\"...\",\"success\":false}"
```
三点关键信息:
1. **`httpStatusCode: 200` + `success: false` + 无 `data`** —— 精确印证了第二节的推断:
阿里云在**请求被拒时依然返回 HTTP 200**,所以旧代码只会报出「`data` 为空」这一句废话。
2. `code=596` 是**账号级「服务未开通」**拒绝,与请求体、签名、Content-Type、RAM 权限均无关
(RAM 权限不足会返回 403/Forbidden,不是 596)。
3. 用户按提示点开后仍报 596,原因见下:**开通入口与认证门槛**。
### 开通门槛对照(官方文档原文)
| 服务 | 开通前提条件 | 个人实名认证可否 |
| --- | --- | --- |
| **内容安全 1.0**(`green`, 2018-05-09 — 本项目在用) | **需要账号完成企业认证**(`document_detail/69806.html`,2026-07-23 更新) | ❌ 不可 |
| 内容安全增强版 2.0(`green-cip`, 2022-03-02) | 需要账号完成**实名认证**(`document_detail/477720.html`) | ✅ 可 |
另附两点排查结论:
- 用户点开的 `https://www.aliyun.com/product/lvwang` **只是产品介绍页**(现为「AI 安全护栏」聚合页),
点不出「一键开通 1.0」。1.0 的正确开通入口是:
`https://common-buy.aliyun.com/?commodityCode=cdi#/open`。
- 但即使换到正确入口,个人认证账号依然会被「企业认证」前提卡住 → 596 不会消失。
### 三条可选路径
| 方案 | 代价 | 说明 |
| --- | --- | --- |
| **A. 改用「内容安全增强版 2.0」** | 🟠 需改代码(中等) | 个人认证即可开通。需换成 `green-cip.<region>.aliyuncs.com` + `TextModeration` API + 新 SDK(`green-20220302`),并**重做风险标签映射**(2.0 标签体系与 1.0 完全不同:`pornographic_adult` / `inappropriate_profanity` / `pt_to_contact` …),策略层 `policy.go` 的维度清单需同步重写。 |
| **B. 把该账号升级为企业实名认证** | 需企业资质 | **代码零改动**,升级后到正确入口开通 1.0 即可。 |
| **C. 放弃阿里云通道,只用七牛** | 功能损失 | 置 `text_censor_source = auto` / `qiniu`。阿里云通道的原始用途是「七牛误报时的二次复核」,放弃后该能力消失。 |
📌 **副产品结论**:第三节第 2 项(`SetContentType(requests.Json)`)**已被证伪**——它不是本次根因。
改动保留,但代码注释已改成「按接口契约对齐」而非「修 bug」,避免后人误解。
📌 **副产品结论**:第三节第 2 项(`SetContentType(requests.Json)`)**已被证伪**——它不是本次根因。
改动保留,但代码注释已改成「按接口契约对齐」而非「修 bug」,避免后人误解。
## 一、报障现象
```
ERRO Failed to censor text censor_source=aliyun error="response data is empty"
ERRO Failed to censor text error="no censor source available" strictness=lax
```
两条同时出现 = 链上**所有**通道都没给出结论 → `censor.Text` 返回错误 → `moderation.Check` fail-closed 拒签。
> 🔴 **严重性说明**:fail-closed 是本项目既有设计,但推论是 —— **只要显式选定的审核通道坏掉(账号未开通 / 欠费停服 / RAM 权限不足),整站的「发布提问/回答 + 私信」会全部被拒**,且现象只在日志可见,前端只显示「审核服务不可用」。这次报障正是这种形态。
## 二、根因取证
### 2.1 请求构造:不是本项目引入的问题(已验证)
| 比对对象 | 结论 |
| --- | --- |
| 上游 `NekoWheel/NekoBox` 的 `internal/security/censor/aliyun.go` | 与本仓 **逐字节一致** |
| 阿里云官方 Go 示例(`help.aliyun.com/document_detail/330839.htm`) | `green.CreateTextScanRequest()` + `SetContent(json)`,body 形状 `{"scenes":["antispam"],"tasks":[{"content":...}]}` **一致** |
→ 请求的 URL、body 形状、签名流程都与官方参考实现相同,**不是本次改造引入的偏差**。
### 2.2 能产出该错误消息的唯一组合(已验证,走通 SDK 源码)
逐段读过 `[email protected]` 后,可以确定报出 `response data is empty` 必须同时满足:
| 条件 | 依据 |
| --- | --- |
| HTTP 状态是 2xx | `sdk/responses/response.go:44` 非 2xx 会被 `Unmarshal` 直接转成 `ServerError` → 走 `errors.Wrap(err,"text scan")`,不会是该消息 |
| 响应体是**合法 JSON** | `AliyunTextCensorParser` 的 `json.Unmarshal` 失败会报 `unmarshal response` |
| 顶层 `data` 为空数组 / 缺省 / null | `len(responseJSON.Data) == 0` 才命中该分支 |
→ 即:**阿里云回了「HTTP 200 + 合法 JSON + 无 data」**。
顺带排除一个可疑点:`green.EndpointMap` **不含 `cn-shanghai`**,但端点解析会一路回落到
`GetEndpointRules` → `green.cn-shanghai.aliyuncs.com`,**地址是正确的**。
### 2.3 顺带发现的确定缺陷(🟠 P1,非本次根因,但让根因不可见)
旧 parser 在 `len(Data)==0` 时只报 `response data is empty`,**把顶层 `code` / `msg` / `requestId` 全部丢掉**——
而这正是唯一能区分「签名错 / 权限不足 / 欠费 / 服务未开通 / 请求体不合契约」的线索。
这不是「阿里云用不了」的原因,但它是「为什么查不下去」的原因。
## 三、已落地改动
`internal/security/censor/aliyun.go`、`internal/security/censor/policy_test.go`
| # | 改动 | 性质 |
| --- | --- | --- |
| 1 | 抽出 `newTextScanRequest(text)`,便于测试断言请求形态 | 重构 |
| 2 | **显式 `req.SetContentType(requests.Json)`** | 🟡 **假设性修复**,见下方说明 |
| 3 | 顶层 `code != 0 && != 200` → 新分支 `aliyun rejected the request: code=.. msg=.. request_id=.. raw=..` | ✅ 确定改进(与旧「data 为空」同为错误返回,上层控制流不变) |
| 4 | `data` 为空 / JSON 解析失败时,错误里带上 `code/msg/requestId` + 原始响应摘要(≤512 字符、按字符截断) | ✅ 确定改进 |
| 5 | `Censor()` 中 SDK 返回 err 时补 `status` + `body`;`!IsSuccess()` 分支同样带 status/body | ✅ 确定改进 |
| 6 | `data[].code != 200`(任务级失败)**语义不变**(仍 fail-closed 置 block),但在真正决定 fail-closed 处补一条 Warn(code/msg/taskId/requestId) | ✅ 确定改进 |
| 7 | 修掉 `Censor()` 上错误的文档链接(原指向七牛 7260 → 阿里云 70439) | 文档 |
| 8 | 新增 5 个测试:请求形态+Content-Type、顶层 code 拒绝、空 data、非法 JSON、摘要截断 | 测试 |
**关于第 2 项(Content-Type)**:SDK 对 ROA 请求的**默认回退值是 `application/octet-stream`**
(`sdk/auth/roa_signature_composer.go:88`),而 `/green/text/scan` 的接口契约是 JSON 请求体。
该改动属**按接口契约对齐**(对已正常工作的环境无副作用)。
> ⚠️ 曾假设它是本例根因,**已于 2026-09-10 15:50 证伪**(真实根因是 code=596 未开通服务)。
> 保留理由仅为「显式声明正确类型优于依赖 SDK 兜底值」,代码注释已同步更正。
注意它必须在 `client.TextScan` 之前设置(`Content-Type` 参与 ROA 签名串)。
### 校验结果
- `gofmt -l internal/security/censor/`:仅列出 `censor.go`、`text_test.go`(升级前既有的空格缩进,按约定未动)
- `go build -overlay=... ./...`:通过
- `go vet ./internal/security/...`:通过
- `go test ./internal/security/... ./internal/template/...`:全绿
## 四、处置(已确认后)
1. ⛔ **(已作废,见文首「最终落地」)** 原计划:到阿里云开通「内容安全 1.0」按量付费。
实际已确认个人实名认证账号**无法**开通 1.0(企业认证前提),故改为迁移到 2.0。
2. ⚠️ **先恢复线上可用性**:开通动作需要时间。在开通完成前,把 `[security] text_censor_source`
改回 `auto`(七牛优先)或 `qiniu`,否则整站发布/私信会持续被 fail-closed 拒绝。
3. 开通后切回 `aliyun` 再验证一次;届时日志若仍报错,`code` / `msg` / `requestId` 会直接给出下一步。
### 为同类问题补的防呆(已落地)
`conf/app.sample.ini` 的 `text_censor_source` 注释里补上了这段经验:
```
; ⚠️ 配好凭据 ≠ 可用:阿里云通道还必须在阿里云控制台开通
; 「内容安全 1.0」按量付费(https://www.aliyun.com/product/lvwang,需勾选协议点「立即开通」),
; 否则接口返回 code=596 "You have not opened Yundun Content Moderation Service",
; 审核必然失败 → fail-closed 拒绝全部提交(整站发不出提问/回答/私信)。
; 用 RAM 子账号调用时还需授予内容安全(Green)权限;欠费停服同理。
; 开通完成前建议先用 auto / qiniu,避免线上不可用。
; 审核失败时日志会打印阿里云返回的 code / msg / requestId 便于定位。
```
## 五、待你决策
1. **是否加「启动预检」**(🟠 P2,现在证据更充分了):`text_censor_source` 显式选定某个通道时,
启动阶段发一次探活请求,失败则告警或直接拒启。
- 收益:把「静默全站拒签」提前到启动时暴露——本次故障正是这个形态(用户是先发现发不出内容,才去看日志);
- 成本:按条计费产生一次调用 + 启动强依赖外部网络;
- 折中方案:不做网络预检,改为在**首次**出现账号级拒绝(如 596 / 403)时打一条一次性、带处置指引的显式告警
(复用 `warnNoCensorCredentials` 的 `sync.Once` 模式)——零成本、无外部依赖。
2. **CHANGELOG 仍未记入**(此前遗留):按项目惯例应在 `dev/changelog-source.md` 顶部补条目并跑
`python dev/gen_changelog.py` 重新生成 `static/tamabox.changelogs.json`。档位口径已定(strict + auto),可以写了。
@@ -1,93 +0,0 @@
# autoban 自动封禁误伤诊断报告
日期:2026-09-05 · 分析对象:`internal/security/autoban` + `internal/route/autoban.go` + `internal/shared/ip.go`
## 一、当前配置与生效机制
| 配置 | 你的值 | sample 默认 | 语义 |
|---|---|---|---|
| trust_proxy | true | true | 信任 CDN 权威头解析客户端 IP |
| enable_auto_ban | true | true | 启用自动封禁 |
| ban_threshold_sensitive | **10** | 10 | 窗口内敏感路径命中 ≥10 触发封禁 |
| ban_threshold_404 | **20** | 20 | 窗口内 404 ≥20 触发封禁 |
| ban_window_seconds | **10** | **60** | 统计窗口(滚动) |
| ban_duration_minutes | 15 | 15 | 封禁时长 |
**判定逻辑**(autoban.go Record / IsBanned):
- 每个请求按「归一化客户端 IP」计数(IPv4 原样;IPv6 聚合到 /64 → `LimitKey`)
- 两类事件独立计数,`敏感≥10 OR 404≥20`(10s 滚动窗口内)任一达标 → 封禁该键 15 分钟
- 已封禁键上所有后续请求直接 403
**两个计数入口**(全站仅此两处):
1. 中间件 `autoban.go:39`:请求路径命中敏感词表(`/admin`、`/.env`、`/wp-admin`、`/config`、`/db`…共 39 条前缀)→ defer 计数
2. `f.NotFound`(route.go:403):一切未匹配路由 → 404 计数后 Redirect("/")
## 二、排除项(已静态验证,非误伤来源)
- ✅ **业务路由零碰撞**:全站注册路由 + 模板 77 个站内 URL 与 39 条敏感前缀**无一匹配**——正常页面打开不会误计敏感
- ✅ favicon.ico / robots.txt / sitemap / llms 均有 embed handler,不产生 404
- ✅ 404 处理器与业务性 302(语言切换/登出/投票不存在)已分离,不误计
## 三、误伤根因(按可能性排序)
### 🔴 P0-A:客户端 IP「键折叠」→ 多用户共享同一计数键(最可能)
正常用户自己 10s 内绝不可能凑出 10 次敏感或 20 次 404(每秒 2 个 404 的强度),所以能误伤正常用户,**唯一机制是"别人的探测被记到了你的键上"**。trust_proxy=true 的解析链(ip.go ResolveClientIP):
1. 先看 `CF-Connecting-IP` / `EO-Client-IP`(权威头)
2. 都没有 → 回退 `RemoteAddr`
**折叠场景**:
- 若 EO 回源**未开启/未注入** `EO-Client-IP`(需在 EO 控制台确认回源透传配置),国内请求全部回退到 **EO 边缘 IP(43.174.247.x 新加坡)** → 全体大陆用户共享一个键。扫描器也走同一边缘 → 任一扫描触发 → **全国用户 403 15 分钟**
- 海外 CF tunnel:cloudflared 必注 `CF-Connecting-IP`,正常不折叠;但若异常缺失则回退 `127.0.0.1` → 所有海外用户共享同一键
- sample 注释原话即为该坑:"否则解析到的将是 Cloudflare 边缘 IP,自动封禁会误伤所有访客"
**自检方法**:配置 `security_alert_email`,看封禁告警里的 IP —— 若出现 43.174.247.43 / 43.174.246.43 / 127.0.0.1 这类**非用户 IP**,即实锤折叠。
### 🔴 P0-B:运营商 CGNAT / 共享出口连坐(国内常态)
国内移动/联通/电信家宽普遍 NAT444,**成百上千真实用户共用一个公网 IPv4**。该池内任一被黑设备/扫描器探测你的站点(攻击者扫全网,你的 EO 域名是公网目标)→ 触发阈值 → 整个出口池 403 15 分钟。你本人被误伤大概率由此而来(你的出口 IP 里别人在扫站)。
### 🟠 P1-C:IPv6 /64 聚合连坐
`LimitKey` 将 IPv6 聚合到 /64。若运营商分配粒度粗(多户共 /64)或会话前缀抖动,同段邻居的探测会连坐你。相对少见,但配置了 IPv6 解析时存在。
### 🟠 P1-D:双 CDN 权威头互信被伪造(定向误伤)
若未按 `outputs/edgeone-cloudflare-dual-cdn-guide.md` 在两家 CDN 规则引擎删除"对方 CDN"的权威头,攻击者可伪造 `CF-Connecting-IP: <受害者IP>` 直连源站/借道 EO → 探测全部记到受害者头上 → **把任意人"定向打成 403"**。属攻击向量而非随机误伤,但同样表现为"我没干什么就 403"。
## 四、修复建议(分级)
### P0-① 先止血(纯配置,立即可做,无需改代码)
```ini
ban_window_seconds = 60 ; 10s 窗口对正常浏览太苛刻,回到默认
ban_threshold_sensitive = 30 ; 敏感命中门槛抬高(真扫描 1s 内远超此值)
ban_threshold_404 = 100 ; 404 门槛抬高(正常用户+偶发爬虫不会到)
security_alert_email = <你的邮箱> ; 关键:先打开告警看封禁 IP,定位是否"键折叠"
```
> 窗口越短反而越难误伤,你已比默认更保守仍误伤 → 证明误伤不是参数敏感,而是**键共享**,调参只能缓解不能根治。
### P0-② 确认并修复 IP 解析(治本,需查服务器/控制台)
1. **源站侧**:抓一条真实请求,确认带没带 `EO-Client-IP` / `CF-Connecting-IP` 头(nginx access log 或临时 echo 头调试)
- EO 域名确认开启「回源客户端 IP 透传」;无则开
2. **源站锁定**:防火墙仅放行两家 CDN 回源网段(sample 已警告;未锁定时任何人可直连伪造头)
3. 双 CDN 按指南删对端权威头
### P1-① 代码增强(需你批准后实施)
| 项 | 改动 | 效果 |
|---|---|---|
| 登录用户豁免 | 有有效 session 的请求不计数/不封禁 | 管理员/正常登录用户永不误伤(扫描器无 cookie) |
| 封禁日志 | 封禁时 logrus 打印 ip+路径样本+UA | 事后可归因,不再"黑盒" |
| UA 特征二次确认 | 敏感命中要求 UA 缺浏览器特征才加速计数 | 降低误伤 |
### P2 观察项
- 若确认 P0-A 折叠实锤:可考虑对"CDN 边缘类"键整体排除(仅当 RemoteAddr 属于 CDN 且权威头缺失时才回退 RemoteAddr——需小改 ip.go)
- 监控告警邮件中的封禁频率与 IP 类型分布
## 五、证据清单(本次分析依据)
- internal/security/autoban/autoban.go:阈值判定、窗口、清理
- internal/route/autoban.go:中间件挂载、403 直接返回
- internal/route/route.go:78-84、146、403:实例化、全局挂载、404 计数
- internal/shared/ip.go:ResolveClientIP 信任链 + LimitKey /64 聚合 + 双 CDN 警告注释
- conf/app.sample.ini:默认值与设计约束说明
- 静态碰撞分析:业务路由/模板 URL × 敏感词表 = 零命中
-149
View File
@@ -1,149 +0,0 @@
# 个人主页"头像 + 二维码加载慢 / 页面一直加载中"诊断报告
> 日期:2026-09-05 | 对象:`https://box.tama.guru/_/tamakyi`(个人主页 list.html)
> 测速视角:南宁电信(真实大陆出口,与本机直连一致)
---
## 一、结论速览
| 严重度 | 问题 | 一句话 |
|---|---|---|
| 🔴 P0 | **R2 图片全部无 `Cache-Control`** | 头像 Last-Modified 是当天 → 启发式缓存寿命 ≈ 0 → **每次**页面加载都重新走国际链路下载头像,这是"每次都很久"的直接原因 |
| 🔴 P0 | R2 是纯 CF 国际链路 | 南宁电信实测每个 R2 资源 **0.78–1.4s**,是 snrat 大陆镜像(0.3s)的 **3–5 倍**;304 条件请求也要 **1.05s**(省不掉往返 RTT) |
| 🟠 P1 | 二维码绘制依赖头像 `onload` | easy.qrcode 加载 logo 头像失败仅 `console.error`、**无任何降级** → 头像加载失败/超时 = 二维码永久空白 |
| 🟠 P1 | 头像缓存键分裂 | 同一头像被拆成 `?og-image=1`(header icon/meta)与 `?v=2`(页面头像 + QR logo)两个缓存条目 → 一页下载 2 份 |
| 🟠 P1 | 背景图 303KB 未压缩 PNG | `box_background.png` = 297KB,换设备/清缓存时首访要拉 297KB 的国际链路下载 |
| 🟡 P2 | defer 统计脚本拖慢 DOMContentLoaded | vercount 实测中位 1.24s(波动至 2.7s)、mami 波动至 3.7s → 二维码初始化挂在 DOMContentLoaded,被一起拖慢 |
---
## 二、加载链路拆解(页面发生了什么)
个人主页资源依赖(实测 61KB HTML):
```
EO 动态页 HTML ........................ ~1.2s (源站新加坡,已知架构上限)
head 渲染阻塞 CSS:仅 uikit.min.css (snrat) . ~0.5s
(waline CSS 已用 media=print 非阻塞,✅ 无问题)
defer 脚本 ×10(下载完才触发 DOMContentLoaded):
snrat: uikit/icons/dayjs/alpine/waline/jQuery 0.2–0.5s/个
R2: time.box.js(1.6KB) + easy.qrcode(52KB) ~0.9–1.0s/个
第三方: cn.vercount.one ~1.2s(波动 2.7s)、mami.tama.guru ~0.3s(波动 3.7s)
头像 <img src=头像?v=2>(body 解析即并发) ...... ~1s(R2)
背景 uk-img(UIkit 可见后加载) ............... ~1.4s(R2,297KB)
favicon = 头像 URL ............................ ~1s(R2)
二维码 boxqrimg:
DOMContentLoaded 后 easy.qrcode 初始化
→ 先加载 logo=头像?v=2(onload 后才绘制)→ +0~1s
```
**首访体感:头像 ~2.2s、二维码 ~2.5–3.5s(R2 链路抖动时 5s+)**
**二次访问:由于无 Cache-Control,头像/favicon 每次仍要 ~1s 重新下载 → 这就是"每次都慢、一直加载中"的体感来源。**
浏览器"加载中"迟迟不退的构成:`load` 事件要等非懒加载图片(头像)完成;`DOMContentLoaded` 要等全部 10 个 defer 完成(含 R2 与第三方统计的最慢者)。两者都被 R2 + 统计脚本拖着。
---
## 三、证据链(均为 2026-09-05 实测)
### 3.1 各资源耗时(南宁电信,3 次中位)
| 资源 | Host | 中位耗时 | 备注 |
|---|---|---|---|
| jQuery / UIkit | cdnjs.snrat.com | **0.29s / 0.52s** | 大陆镜像,基准线 |
| 头像 webp 27KB | r2.337845818.best | **0.91–1.05s** | |
| easy.qrcode.min.js 52KB | r2.337845818.best | 0.96s | |
| time.box.js 1.6KB | r2.337845818.best | 0.90s | 小文件也 0.9s = 纯 RTT 主导 |
| 背景 box_background.png 297KB | r2.337845818.best | 1.39s | |
| waline CSS | r2.337845818.best | 0.99s | |
| vercount 统计 | cn.vercount.one | 1.24s(波动至 2.69s) | |
| mami 统计 | mami.tama.guru | 0.31s(波动至 3.7s) | |
### 3.2 决定性验证:R2 缓存头
```
头像/背景/图标/CSS 响应头:
Content-Type / ETag / Last-Modified 有
Cache-Control ❌ 无
Expires ❌ 无
cf-cache-status: DYNAMIC(CF 边缘不缓存,每次都回 R2 源)
```
- 头像 `Last-Modified: Sat, 05 Sep 2026 06:49:29 GMT` = **当天上传**
- 按 RFC 7234 启发式缓存:freshness ≈ (当前时间 − Last-Modified) × 10% ≈ **0** → 浏览器每次**重新验证/下载**
- 实测:带 `If-Modified-Since` 的 304 请求仍需 **1.05s**(RTT 无法通过 304 消除)
- 对比:easy.qrcode.js / time.box.js 有 `max-age=2678400`(31 天强缓存)→ 它们二次访问是秒出的,唯独**图片类对象从未设置缓存头**
> 上传代码 `internal/storage/s3.go` `PutObjectInput` 仅设置 `ContentType`,**从未设置 `CacheControl`** —— 已确认。
### 3.3 easy.qrcode 头像 logo 逻辑(反编译 52KB 源)
```js
if (d.logo) {
var r = new Image;
r.onload = function(){ 绘制(带头像logo) }, // ✅ 头像到了才画
r.onerror = function(a){ console.error(a) }, // ❌ 仅打日志,什么都不画
r.crossOrigin = d.crossOrigin;
r.src = d.logo; // logo = 头像 URL
}
```
**结论:二维码 = 同步等待 R2 头像图下载;头像失败/超时 → 二维码区域永久空白,页面没有任何降级提示。**
### 3.4 头像 URL 在同一页面出现 3 个变体(缓存键分裂)
| 出处 | URL | 浏览器行为 |
|---|---|---|
| header.html 646–648(icon/favicon) | `头像?og-image=1` | Chrome 请求 favicon |
| header.html 642/644/649(og/twitter meta) | `头像?og-image=1` | 爬虫专用,浏览器不下载 |
| list.html 4–6 + 页面头像 + QR logo | `头像?v=2` | 页面必下载 |
`?og-image=1` 与 `?v=2` 是两个缓存键 → favicon 与页面头像各下载一次(同一天上传的头像,各自都 miss)。
---
## 四、优化建议
### P0(改小、见效最大:解决"每次都慢")
**① R2 上传图片设置强缓存头(根因修复)**
- 改 `internal/storage/s3.go` 上传处加 `CacheControl: aws.String("public, max-age=31536000, immutable")`
- 图片 key = 日期 + 15 位随机串,**天然不可变** → `immutable` 完全安全
- 存量对象(历史头像/背景)用 `wrangler r2 object` 或 S3 API 重打 metadata;或重传一次
- **预期收益**:二次访问头像/favicon/QR logo 从 ~1s → **0ms(本地缓存直读)**,这是本次优化中体感变化最大的一项
**② easy.qrcode 失败降级 + 两阶段绘制(防二维码空白)**
- 改 remake 库 logo 分支:`onerror` 时改为**先画无 logo 二维码**(立即可扫),并 `onload` 时自动重绘带 logo 版
- 或加超时(如 3s)兜底,避免头像悬挂时二维码永久空白
- 参考实现方向:库内 `r.onerror = function(){ 降级绘制() }`,其中降级绘制即"无 logo 路径 `b()` + makeImage"
### P1(进一步缩短首访)
**③ 统一头像缓存键**
- 确认 `?og-image=1` 无服务端语义(Go 代码中无处理,疑似历史遗留)后:header 的 icon/favicon 与 list.html 统一用同一 URL 形态(②落地强缓存后,`?v=2` 击穿历史污染缓存的目的已由 immutable+随机 key 取代,可逐步去掉 query)
- 收益:每页少 1 次头像下载(favicon 与页面头像共享缓存条目)
**④ 背景图压缩**
- `box_background.png`(297KB)→ WebP(预计 ≤60KB),首访下载量降 ~5 倍;该图 2024-08 上传未再变,压缩一次长期受益
**⑤ 统计脚本 async 化**
- `cn.vercount.one/js`、`mami.tama.guru/tamami.js` 均非页面功能依赖 → 改 `async`(或 DOMContentLoaded 后再注入),不再拖慢 DOMContentLoaded → 二维码初始化提前
### P2(结构性,可选)
**⑥ 二维码区域骨架占位**:`#boxqrimg` 给 min-height + 轻提示(如"二维码生成中…"),避免空白感
**⑦ waline 评论懒加载**:滚动到评论区再初始化(若评论非首屏核心)
**⑧ 图片域加速(治本路线)**:R2 域名无法直接挂 EO,但可在 R2 域名前接 EO/大陆 CDN 回源 `r2.337845818.best`,把头像等图片链路从"CF 国际 ~1s"降到"EO 近大陆 ~0.2s"——与主站 EO 方案同理,属另一工程
---
## 五、建议落地批次
| 批次 | 内容 | 风险 |
|---|---|---|
| 批 1(P0) | ① + ② | 低:加缓存头仅影响新上传;库改动影响 3 个模板的二维码渲染,需测试 profile/list/not-found |
| 批 2(P1) | ③④⑤ | 低–中:③需先确认 og-image 语义 |
| 批 3(P2) | ⑥⑦⑧ | 视需要 |
> 参考实测:①落地后二次访问头像 0ms;②落地后即使头像失败,二维码 0.2s 内出现可扫 → 用户体感"加载中"消失。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

-167
View File
@@ -1,167 +0,0 @@
# Cloudflare 锁定源站 实操指南(配合 P0 修复)
> 目标:让 `trust_proxy = true` 安全生效,同时**不维护 Cloudflare 庞大的 CIDR 清单**。
> 代码侧(`internal/shared/ip.go`)已改为:优先信任 `CF-Connecting-IP`,仅在 TCP 对端落在
> `trusted_proxies`(你自己的基础设施)时才解析 `X-Forwarded-For`/`X-Real-IP`。
> 关键前提:**源站必须只能从 Cloudflare 抵达**,这样攻击者无法直连并伪造 `CF-Connecting-IP`。
---
## 0. 为什么必须锁源站
上一轮的 P0 风险是:开启 `trust_proxy = true` 后,只要攻击者能**直连到源站 IP**,
他就可以在请求里塞一个 `CF-Connecting-IP: 他想封的任意 IP`,应用会信以为真。
后果:自动封禁、限流、IP 黑名单全部按**攻击者指定的 IP** 计数 → 可把正常访客(甚至你自己的 IP)打进黑名单。
两种解法都不需要 Cloudflare CIDR 白名单,只是「让攻击者根本连不到源站 / 连到也过不了 TLS」:
- **方案 A:Cloudflare Tunnel** —— 源站不开放任何公网入站端口,`cloudflared` 主动出站连 Cloudflare,
外网流量只走 CF→隧道→本地。攻击者不知道、也连不到源站 IP。**最省事,推荐。**
- **方案 B:Authenticated Origin Pull(mTLS)** —— 源站要求 Cloudflare 回源时出示客户端证书,
没证书的直连在 TLS 握手阶段就被拒,连 HTTP 头都发不出来。保留你现有公网 IP/端口时选它。
---
## 1. 方案 A:Cloudflare Tunnel(推荐)
### 1.1 原理
源站防火墙只放行运维端口(如 22),**不对外开 80/443**。Cloudflare 边缘与源站上的
`cloudflared` 之间建立一条持久出站隧道。访客的请求在 CF 边缘进入,经隧道到达本地
`cloudflared`,再转发给监听在 `127.0.0.1` 的 TamaBox。CF 在边缘做 TLS,所以回源用 HTTP 即可。
### 1.2 步骤
1. **前提**:域名已在 Cloudflare(橙色云代理),源站能主动出网(TCP 443 出站)。
2. **安装 cloudflared**(二选一):
- Debian/Ubuntu:`sudo apt install cloudflared`(或官方 deb:`curl -fsSL https://pkg.cloudflare.com/cloudflared.deb | sudo dpkg -i -`)
- Docker:`docker pull cloudflare/cloudflared:latest`
3. **登录授权**(浏览器选域):`cloudflared tunnel login`
4. **建隧道**:`cloudflared tunnel create tamabox`
记下输出的 tunnel ID 与 credentials 文件路径(默认 `~/.cloudflared/<id>.json`)。
5. **写配置** `~/.cloudflared/config.yml`:
```yaml
tunnel: <你的 tunnel id>
credentials-file: /root/.cloudflared/<id>.json
ingress:
- hostname: yourdomain.com # 改成你的域名
service: http://127.0.0.1:8080 # 改成 TamaBox 实际监听地址(见 1.4)
- service: http_status:404
```
6. **绑定 DNS**(自动加 CNAME 到 Cloudflare):`cloudflared tunnel route dns tamabox yourdomain.com`
7. **常驻运行**:
- systemd:`sudo cloudflared service install` 然后 `sudo systemctl enable --now cloudflared`
- Docker:`docker run -d --name cf-tunnel --restart unless-stopped -v ~/.cloudflared:/root/.cloudflared cloudflare/cloudflared tunnel run`
8. **收紧源站防火墙**:云安全组 / `iptables` 把 **80、443 的 0.0.0.0/0 入站全部删掉**,
只保留你的运维 IP 的 22。这样即便有人探测到源站 IP 也连不上。
### 1.3 app.ini 配置 glue
```ini
[security]
trust_proxy = true
trusted_proxies = 127.0.0.1
```
说明:`cloudflared` 连的是本地,`ResolveClientIP` 看到的 TCP 对端就是 `127.0.0.1`;
`CF-Connecting-IP` 由 Cloudflare 注入并经隧道透传,应用**优先采用它**,无需任何 CIDR。
### 1.4 TamaBox 监听地址建议
`conf/app.ini` 的 `[server] port = 80`。务必让程序**只绑本地**:
- 若用 `tamabox.go` 里 `flamego.Classic()` 默认 `0.0.0.0`,请改成 `127.0.0.1:8080`(或你 tunnel 里写的端口),
双保险——即使防火墙漏配也不会对外暴露。
- 改完重启 TamaBox。
---
## 2. 方案 B:Authenticated Origin Pull(mTLS)
### 2.1 原理
Cloudflare 回源时出示一张由 Cloudflare 私有 CA 签发的**客户端证书**;源站配置「要求并校验客户端证书」。
attackers 直接连源站 443 时,因为没有这张证书,TLS 握手直接失败,根本到不了 HTTP 层,
自然无法发送 `CF-Connecting-IP`。**这步本身已足以挡住伪造,不需要 CIDR。**
### 2.2 步骤
1. **开启 AOP**:Cloudflare 控制台 → **SSL/TLS → Origin Server → Authenticated Origin Pulls** → 开启。
2. **下载 Cloudflare Origin CA**(用于校验 CF 客户端证):同一页底部
**Origin CA certificates → Create Certificate**(或下载根 CA 证书 `cloudflare_origin_ca.pem`),
保存到源站 `/etc/ssl/cloudflare/cf-origin-ca.pem`,权限 `600`。
3. **落地方式二选一**:
- **B1(nginx 在前,最常见)** —— nginx 终止 TLS 并要求客户端证书:
```nginx
server {
listen 443 ssl;
server_name yourdomain.com;
ssl_certificate /etc/ssl/yourdom/fullchain.pem; # 源站证书(可用 CF Origin CA 签发,或自有)
ssl_certificate_key /etc/ssl/yourdom/privkey.pem;
ssl_client_certificate /etc/ssl/cloudflare/cf-origin-ca.pem; # Cloudflare 根 CA
ssl_verify_client on; # 必须校验 Cloudflare 客户端证书
ssl_verify_depth 2;
location / {
proxy_pass http://127.0.0.1:8080; # TamaBox 本地端口
proxy_set_header Host $host;
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip; # 透传给应用(优先采用)
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
`sudo nginx -t && sudo systemctl reload nginx`
- **B2(Go 直接对外 TLS,不用 nginx)** —— 让 TamaBox 监听 TLS 并强制客户端证书
(需改启动代码,示意):
```go
srv := &http.Server{
Addr: ":443",
TLSConfig: &tls.Config{
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: cfCACertPool, // 载入 cf-origin-ca.pem
},
}
srv.ServeTLS(ln, "/etc/ssl/yourdom/fullchain.pem", "/etc/ssl/yourdom/privkey.pem")
```
4. **防火墙(可选加固)**:源站 443 只放行 Cloudflare 回源 IP。**但 AOP 的 mTLS 已挡住伪造,
CIDR 只是减少无效握手,可按需省略**——正好契合你「不想维护大 CIDR」的诉求。
### 2.3 app.ini 配置 glue
- 用 nginx(B1):`trust_proxy = true`,`trusted_proxies = 127.0.0.1`
(nginx 在本地,TCP 对端 `127.0.0.1`)。`CF-Connecting-IP` 经 nginx 透传被应用优先采用;
即便没透传,XFF 由受信的 `127.0.0.1` 解析也能拿到真实 IP。
- 用 Go 直连 TLS(B2):`trust_proxy = true`,`trusted_proxies` 可留空
(TCP 对端就是 Cloudflare,`CF-Connecting-IP` 直接采用)。
---
## 3. 怎么选
| 场景 | 选 |
|------|----|
| 单实例、怕配错、不想管证书/防火墙 | **方案 A(Tunnel)** |
| 已有 nginx、必须保留公网 IP、希望源站也强制 TLS | **方案 B(AOP)** |
---
## 4. 验证(上线前务必做)
1. **直连源站公网 IP 应失败**:
- A:`curl -I https://<源站IP>` 应超时 / 连接拒绝。
- B:`curl -I https://<源站IP>` 应 TLS 握手失败(`sslv3 alert handshake failure` 之类)。
2. **经域名访问正常**:`curl -I https://yourdomain.com` 返回 200。
3. **确认记录的是真实客户端 IP**:用不同网络访问,查看限流/统计/日志,
不应再是 Cloudflare 边缘 IP。
4. **伪造头测试(仅限你自己的 IP)**:
`curl -H "CF-Connecting-IP: 1.2.3.4" https://yourdomain.com` —— 应用应忽略该头,记录你的真实 IP。
5. **自动封禁仍生效**:连续触发 404 阈值,确认被封的是真实客户端 IP,不是 CF 边缘。
---
## 5. 注意事项
- `trust_proxy = true` 一旦开启,**源站就必须锁定**,否则比 `false` 更危险
(应用会更信任客户端头)。代码注释已写明此约束。
- Tunnel 下建议 TamaBox 监听 `127.0.0.1`(不要 `0.0.0.0`),双保险。
- 隧道 credentials 与证书文件权限 `600`,**不要进 Git 仓库**。
- 多实例 / 容器:把 `cloudflared` 跑在与 TamaBox 能直连的网络(同主机或同 docker 网络)。
- 证书 / 隧道有有效期,纳入监控或自动续期(Cloudflare 证书默认较长,Tunnel 凭证基本免维护)。
---
## 6. 与代码的对应关系(便于回溯)
- `internal/shared/ip.go`:`ResolveClientIP` 优先 `CF-Connecting-IP`,仅 `isTrustedProxy(remote)` 才解析 XFF。
- `internal/conf/static.go`:`Security.TrustedProxies`(`trusted_proxies`)新字段。
- `internal/cmd/web.go`:`conf.Init()` 后调用 `shared.InitTrustedProxies()` 解析清单。
- `conf/app.sample.ini`:`trust_proxy` / `trusted_proxies` 注释已更新。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

-178
View File
@@ -1,178 +0,0 @@
# TamaBox-v4 整体代码审阅报告
- **审阅日期**:2026-08-28
- **审阅范围**:全仓(87 个 Go 文件 + 38 个 HTML 模板 + 配置/脚本)
- **技术栈**:Flamego + GORM + PostgreSQL + Redis(1C1G 部署)
- **方法**:4 个并行只读探索代理(架构 / 安全 / 性能 / 前端·代码质量)+ 关键高危项人工复核
- **说明**:本报告为纯审阅,**未改动任何代码**。按你一贯的分阶段工作流,请选定优先级后我分批落地。
---
## 一、执行摘要
整体健康度**良好**——安全工程成熟度高于同类项目:IDOR 越权、XSS 转义、SQL 注入三大高风险面均有系统化纵深防御;会话/CSRF/限流/SSRF/内容审核(fail-closed)均已落地。未发现会让线上立即崩溃或数据泄露的 P0 级事故。
主要短板集中在:**配置防呆不足**(Salt 未校验)、**部署样例与结构体错位**(PostgreSQL 直接连库失败)、**少量数据层查询未分页/缺索引**、**一个暴露面偏宽的内部反代**、以及**架构债**(上帝文件、缺 service 层)。这些均为可规划修复的中等问题,无紧急止血项。
| 维度 | 评分 | 一句话 |
|------|------|--------|
| 安全 | 🟢 良好 | 高风险面有纵深防御;残余项为中低危加固 |
| 架构 | 🟡 中等 | 分层清晰但存在上帝文件、缺 service 层、配置防呆不足 |
| 性能 | 🟢 良好 | 连接池/缓存/分页已调优;少量 N+1 与缺索引 |
| 代码质量 | 🟢 良好 | 日志统一、测试有部分覆盖;魔法字符串与构建依赖待理 |
| 前端/UX | 🟢 良好 | 模板写法规范、交互健全;少量体验增强空间 |
---
## 二、问题清单(按优先级)
### 🔴 P0 — 部署前必须修复(防呆 / 阻断)
**P0-1 密码 Salt 未做非空校验(高危防呆)**
- 位置:`internal/db/password.go:25`、`password.go:55` 使用 `HmacSha1(password, conf.Server.Salt)`;`conf/conf.go` 仅校验 `external_url` 与 `xsrf_key`,**未校验 Salt**。
- 影响:若部署沿用样例空盐(`app.sample.ini` 中 `salt = ""`),全站密码散列实际以空盐计算——相同密码产生相同哈希,且针对本站的离线字典/彩虹表攻击可行。
- 建议:在 `conf.Init` 仿照 `xsrf_key` 增加 `if Server.Salt == "" { return error }`;并提供强随机默认值或启动即失败。
**P0-2 配置样例与结构体字段不一致(部署即失败)**
- 位置:`conf/app.sample.ini:36-42` 的 `[database]` 用 `address = "tcp(127.0.0.1:3306)"`;而结构体为 `type/host/port/name`(`internal/conf/static.go:58-63`),**缺 host/port/type**。
- 影响:按样例部署时 `Database.Type` 为空 → 走 mysql 分支 → 生成非法 DSN `:@tcp(:0)/`,启动连库直接失败;PostgreSQL 用户也无法从样例获得正确字段。
- 建议:样例补全 `type = postgres`、`host`、`port`、`name`、`user`、`password`、`ssl_mode`,使其与当前结构体一一对应。
### 🟠 P1 — 应当修复(重要)
**P1-1 `/api/v1/service` 反代可转发至内部后端(中危)**
- 位置:`route/service/proxy.go:35-48`
- 影响:登录用户可控 `**` 路径转发到配置后端,`httputil.ReverseProxy` 透传原始 Cookie/Header;若后端前缀泄露或配置为内网地址,可成为横向探测入口;且 `TLSInsecureSkipVerify` 可关闭证书校验。
- 建议:白名单限定可转发子路径与方法;禁止转发敏感 Header;内网后端强制校验;补审计日志。
**P1-2 导出接口 N+1 查询**
- 位置:`route/user/profile.go:381-401`(循环内 `FollowUps.GetByQuestionID`/`UploadImages.GetByQuestionID`,每题 2 次)、`profile.go:426-449`(循环 `Messages.GetByConversationID`,每会话 1 次)。
- 影响:大账号导出慢、占用 DB 连接。
- 建议:改为 `IN (?)` 批量拉取后内存分组(与已有的 `GetFirstMessagesByConversationIDs` 窗口函数做法一致)。
**P1-3 私信列表无分页(全量返回)**
- 位置:`internal/db/conversation.go:145-161` `ListByOwner`/`ListByAsker` 无 LIMIT;调用处 `profile.go:419-422`、`chat` 列表。
- 影响:私信多的用户一次拉全部会话与消息,内存/DB 压力大。
- 建议:加 `LIMIT/OFFSET` 或按 `created_at DESC` 游标分页。
**P1-4 高频查询缺复合索引**
- `questions.asker_user_id`:`CREATE INDEX idx_questions_asker ON questions(asker_user_id);`(`questions.go:243`)
- 箱主公开页:`CREATE INDEX idx_q_user_private_created ON questions(user_id, is_private, created_at);`(`questions.go:171-228`)
- 探索页:`CREATE INDEX idx_users_showinhome_created ON users(show_in_home, created_at);`(`users.go:487-540`)
- 投票热门:`CREATE INDEX idx_votes_status_vis_created ON votes(status, visibility, created_at);`(`votes.go:206-270`)
- 消息每日计数:`CREATE INDEX idx_messages_sender_created ON messages(sender_id, created_at);`(`users.go:805`)
- 会话消息排序:将单列 `conversation_id` 索引改为复合 `(conversation_id, created_at)`(`message.go:56`)
**P1-5 changelog 仍走外部 fetch(引入外部依赖)**
- 位置:`templates/change-logs.html:10` 从外部域名取 `tamabox.changelogs.json`,而本地已生成并提交 `tamabox.changelogs.json` 未被使用。
- 影响:跨域依赖、外部不可达时 changelog 空白、可被篡改。
- 建议:将 json 纳入 `static` embed,新增同源路由 `/changelogs.json`(或放入 static 目录直接 fetch 同源路径),去掉外部依赖。
**P1-6 私密提问访问令牌熵值偏低(隐私/安全)**
- 位置:`internal/db/questions.go:93`(`Token: randstr.String(6)`)
- 影响:私密/未答提问与追问权仅靠 6 位随机串(约 62^6),结合 questionID 可枚举猜测,进而读取私密提问或冒用提问者身份发追问。
- 建议:Token 提升至 ≥16 位随机串或直接用 UUIDv4。
**P1-7 架构债:上帝文件 + 缺 service 层(可维护性,分阶段重构)**
- 位置:`route/user/vote.go`(1289 行)、`route/vote/vote.go`(1036)、`route/question/share.go`(964)、`internal/db/users.go`(881)。
- 影响:单文件同时承载 HTML 渲染、图片生成、DB 事务与业务判断,违反单一职责,难测试难维护;`route/*` 直接调 `db.*`,业务逻辑散布。
- 建议:引入 `internal/service` 承载用例编排,handler 仅做请求解析/渲染;图片/分享卡生成抽到 `internal/render`。分阶段进行,不一次性大改。
### 🟡 P2 — 可优化(锦上添花 / 技术债)
- **webp CGO 硬依赖**(已知约束):`internal/storage/s3.go:23` 与 `route/question/page.go:595` 直接 `import "github.com/chai2010/webp"`,链接阶段即需 libwebp,阻断 Windows/macOS 构建。你已在 Linux 解决,**不作为当前阻塞项**。建议用 `//go:build linux,cgo` 隔离为可选实现,未启用时走原图直传,消除跨平台构建门槛。
- **魔法字符串**:提问过滤参数 `"all"/"public"/"private_question"` 散落 `route/user/question.go:58,121,171,174`、`form/question.go:17`,无编译期保护。建议抽 `QuestionFilter*` 常量(投票可见性已有先例 `internal/db/votes.go:43`)。
- **会话 Cookie 未硬化**:`internal/route/route.go:92-95` 未设 `Secure/SameSite`。建议 `session.Options{Cookie:{Secure:true, SameSite:Lax}}`。
- **PostgreSQL 无 TLS**:`internal/conf/db.go:16` 硬编码 `sslmode=disable`。建议加 `ssl_mode` 配置项,生产默认 `require`。
- **限流为单进程内存**:`internal/route/ratelimit.go:17-100`,多实例可被分摊绕过;登录无失败锁定。建议改用 Redis 共享限流 + 失败阈值临时锁定。
- **匿名身份服务端留存**:`internal/db/questions.go:64-72` 记录 `FromIP`;若承诺强匿名,应定期脱敏并明示隐私策略。
- **邮件模板外链字体**:`templates/mail/*.html` 引用 `//fonts.loli.net`,离线/被墙时回退不可控。建议改用系统字体栈或自托管。
- **遗留 .bak**:`templates/question/new-question-template.html.bak` 含硬编码中文,易与 i18n 正式版混淆。建议删除,由 git 管历史。
- **无优雅退出**:`internal/cmd/web.go:82-94` 未注册 `signal.Notify`,重启会断连。建议加 SIGTERM + `server.Shutdown(ctx)`。
- **路由包命名混乱**:顶层 `route` 与 `internal/route` 同名(无 import 循环,但易误读)。建议收口到 `internal/route/*`。
- **首页统计缓存细节**:`internal/stats/stats.go:41-78` 进程内 30 分钟缓存合理,但过期瞬间并发重算、写后未 `Invalidate()`。建议 TTL 加抖动 + 写后失效。
- **GetHotTags 全表 Pluck**:`internal/db/users.go:661-667`(已 60s 缓存)。大数据量建议 SQL 内聚合或限样本。
- **搜索无分页**:`internal/db/questions.go:374` `SearchByUserID` 返回全部 LIKE 匹配。建议加 `LIMIT`。
- **CSRF 对 `/api/v1/pixel/` 关闭**:`internal/context/context.go:170`。影响有限,建议仅限 GET 或补回校验。
- **handler 级测试薄弱**:`route/question`、`route/user` 多依赖真实 DB/Redis,缺单测。建议补基于 `dbutil/testing.go` 的集成测试或 httptest mock。
---
## 三、已确认的良好实践(正面,保持)
- **IDOR 防护到位**:提问删除/私密/回答经 `canDelete = 登录且为箱主`(`route/question/question.go:69`)及 `question.UserID==pageUser.ID` 双重校验;投票经 `getOwnedVote`(`route/user/vote.go:392`);私聊经 `OwnerID/AskerID`(`chat_handler.go:52`)。
- **XSS 转义到位**:`QuestionFormat/AnswerFormat` 走 `html.EscapeString`(`internal/template/template.go:46-55`);Markdown 经白名单 sanitize;`toJSON` 依赖 `json.Marshal` 默认转义。
- **SQL 注入为零**:全量 GORM 参数化;Raw 均用 `?` 占位(`message.go:91` 的 `IN ?`)。
- **SSRF 防护**:`/proxy-image` 含域名白名单 + 私网 IP 拦截 + DNS rebinding 防护(`route/proxy.go:24-127`)。
- **其他**:注册/登录/提问强制验证码;内容审核 fail-closed;登录会话再生防固定(`route/auth/login.go:66`);开放跳转前缀校验防开放重定向。
- **性能基础好**:模板启动解析缓存(`templates/embed.go`)、Gzip 全量接入(`internal/cmd/web.go:84`)、连接池按 1C1G 调优(MaxOpen=10)、批量反 N+1 已落地。
- **Go 模板无运算符误用**:全仓 grep `!=`/`==`/`&&`/`||` 零命中,无启动 panic 风险;删除交互均有二次确认。
---
## 四、新功能与 UX 提升建议
> 均考虑 1C1G 约束(不引入常驻重服务);按"用户价值 / 实现成本"简评。
| # | 建议 | 用户价值 | 成本 | 说明 |
|---|------|---------|------|------|
| F1 | **通知中心 + 邮件摘要聚合** | 高 | 中 | 提问箱核心体验是"收到提问"。站内通知列表 + 邮件可设每日/每周摘要开关,避免逐条邮件轰炸;与现有邮件体系复用。 |
| F2 | **箱主治理面板** | 高 | 中 | 自定义提问箱简介/主题、开关"允许匿名/图片/投票"、屏蔽关键词、隐藏不当内容。提升箱主掌控感。 |
| F3 | **前台举报入口** | 高 | 低-中 | 后台已有 fail-closed 审核,但面向用户的"举报不当提问/回答"按钮缺失;补前台入口 + 箱主一键隐藏。 |
| F4 | **私密提问体验增强** | 中 | 中 | 追问流、已读状态、链接过期机制(配合 P1-6 令牌熵提升),让匿名双向对话更顺畅。 |
| F5 | **探索/发现页增强** | 中 | 中 | 标签筛选、热门排序、按话题聚合(`GetHotTags` 已有数据),提升内容分发与留存。 |
| F6 | **防刷与内容质量** | 中 | 中 | per-box 提问限流、垃圾链接/敏感词前端预检、重复内容检测(配合 P2 限流改造)。 |
| F7 | **数据导出优化** | 中 | 低 | 支持 Markdown/PDF 导出,导出走批量查询(顺带修 P1-2 N+1),导出包隐私脱敏。 |
| F8 | **移动端 PWA + 暗色模式** | 中 | 中 | 纯静态资源即可,1C1G 友好;显著提升移动端阅读与夜间体验。 |
| F9 | **草稿与定时发布** | 低-中 | 中 | 私密回答存草稿、公开回答定时发布,降低发布压力。 |
| F10 | **无障碍 a11y** | 低 | 低 | 键盘可达、aria 标签、对比度,提升专业度与合规。 |
**优先推荐**:F1(通知中心)+ F2(箱主治理)+ F3(前台举报)组合——直接提升"收提问→治理→互动"的主链路体验,且不引入重型依赖。
---
## 五、建议的分阶段实施路径
1. **阶段 0(本次部署前)**:P0-1、P0-2(防呆 + 样例对齐),半小时内可合。
2. **阶段 1(安全/数据层)**:P1-1 反代收敛、P1-6 令牌熵、P1-2/P1-3 分页与 N+1、P1-4 建索引、P1-5 changelog 同源。
3. **阶段 2(架构债)**:P1-7 引入 service 层,按模块分批抽离上帝文件(先 `route/question/share.go` 的图片/分享卡)。
4. **阶段 3(体验增强)**:P2 各项 + 从 F1/F2/F3 启动新功能。
---
## 附:探索索引
- 架构:`tamabox.go` → `cmd.Web` → `conf.Init` → `db.Init` → `route.New()`
- 安全:IDOR/ XSS / SQLi / SSRF / 限流 / 会话 已逐项核验
- 性能:连接池、缓存、分页、索引、changelog、统计
- 前端:模板转义、partial、交互确认、webp 构建依赖、魔法字符串
---
## 六、实施进展
### P0 已修复(2026-08-28)
- **P0-1 Salt 校验**:`internal/conf/conf.go` 的 `Init` 在 `server` 段 MapTo 后新增 `if Server.Salt == "" { return error }`,空盐启动即失败,防止密码以空盐哈希(离线碰撞风险)。
- **P0-2 配置样例对齐**:`conf/app.sample.ini` 的 `[database]` 段按 `Database` 结构体字段对齐(`type`/`host`/`port`/`name`/`schema`),去掉旧的不存在字段 `address`,默认 `type=postgres`、`port=5432`、`schema=public`。
- **验证**:`go build ./internal/conf/...` 通过;临时测试(已清理)确认 ① 样例 database 段 MapTo 出 `type=postgres`/`port=5432`/`schema=public`;② 空 salt 时 `conf.Init` 返回 salt 错误;③ 非空 salt 时正常加载。
- **重要陷阱**:`conf.Init` 使用 `IgnoreInlineComment: true`,行内 `;` 注释会被并入配置值——初次加的行内注释曾污染 `database.type` 与 `server.salt`(并使 Salt 校验失效),已全部改回整行注释。原 sample 大量行内注释存在同类系统性污染,建议后续统一改为整行注释(见 MEMORY.md 配置解析约定)。
### P1 已修复(2026-08-28)
- **P1-1 服务反代加固**:`route/service/proxy.go` 剥离转发的敏感 Header(Cookie / Authorization / X-* / Proxy-* / Host 等),并在转发前补审计日志(操作者、目标、路径、大小);`internal/route/route.go` 将 `/service/{**}` 路由由 `f.Any`(任意方法)收敛为 `f.Get`(仅 GET),消除写方法经反代的安全面。`go build ./route/service/...` 通过。
- **P1-2 导出接口 N+1 批量化**:`internal/db/follow_ups.go`、`upload_images.go`、`message.go` 新增 `GetByQuestionIDs` / `GetAllByConversationIDs` 批量方法(返回 `map[uint][]*T` 分组);`route/user/profile.go` 的导出逻辑由"循环单查"改为"IN 查询 + 内存分组",追问题/图片由每题 2 次、消息由每会话 1 次降为 3 次批量查询。`go build ./internal/db/...` 通过。
- **P1-3 私信列表分页**:`internal/db/conversation.go` 的 `ListByOwner`/`ListByAsker` 增加 `limit, offset` 参数(导出仍传 `0,0` 取全量,行为不变),并新增 `CountByOwner`/`CountByAsker`;`route/user/question.go` 的「我收到的 / 我发起的」私聊列表接入游标分页(每页 50,复用 `buildQListPageURL` 保留筛选参数),模板 `templates/user/question-list.html` 增加真实总数头与「加载更多」链接。模板解析/渲染测试全部通过。**注意**:`route/user` 在 Windows 因 `github.com/chai2010/webp` 的 CGO 硬依赖无法编译(已知约束,最终在 Linux 完整构建);逻辑已逐处类型核对无误。
- **P1-4 高频查询补复合索引**:`internal/db/db.go` 在 `AutoMigrate` 之后调用 `ensureFrequentlyUsedIndexes`(仅 PostgreSQL),以 6 条 `CREATE INDEX IF NOT EXISTS` 幂等建索引:`idx_questions_asker_user_id`、`idx_questions_user_private_created`、`idx_users_show_in_home_created`、`idx_votes_status_visibility_created`、`idx_messages_sender_created`、`idx_messages_conversation_created`。不影响共享 Model,适合既有库。`go build ./internal/db/...` 通过。
- **P1-5 changelog 同源化**:`dev/gen_changelog.py` 输出路径由根目录 `tamabox.changelogs.json` 改为 `static/tamabox.changelogs.json`;`static/fs.go` 的 `//go:embed` 纳入该文件;`templates/change-logs.html` 的 fetch 由外部域名 `api-direct.337845818.best/...` 改为同源 `/static/tamabox.changelogs.json`,并删除根目录旧副本。`go build ./static/...` + 模板解析测试通过。
- **验证总览**:可本地构建的包(`internal/conf`、`internal/db`、`static`、`templates`)全部 `go build`/`go test` 通过;`gofmt -l` 对全部改动 Go 文件零输出(已 `gofmt -w` 归一化);`route/service` 与 `route/user` 因 webp CGO 在 Windows 不可编,依项目约定留待 Linux/CI 完整构建。P1-6(令牌熵)、P1-7(service 层)未在本轮处理,列为后续阶段。
### 回归核查(2026-08-28 16:2x)
逐项复核 P0 + P1-1~P1-5 是否复现或引发新问题。**结论:7 处修复均完好、无复现、无回归。**
- 验证手段:逐文件 grep 关键标记 + 读 proxy.go/route.go/question.go/profile.go/db.go 改动段 + `gofmt -l`/`go build`/`go vet ./internal/db`/模板解析渲染测试 + 核对 i18n 三语言键(zh_cn/ja/en)。
- 构建结果:可本地编译包 `build` 退出码 0、`go vet` 0、模板测试 ok、`gofmt -l` 对 11 个改动文件零输出。
- 各点确认:
- P0-1 Salt 非空校验在 conf.go:57;P0-2 样例 `[database]` 字段对齐且**无行内注释污染**(IgnoreInlineComment 陷阱未复发)。
- P1-1 反代剥离敏感头(proxy.go:66-80)+ 审计日志(92-99)+ route.go:292 收敛 `f.Get`。
- P1-2 三个 store 批量方法签名与实现齐全,profile.go 导出改用批量/分组。
- P1-3 私信分页:`ChatStats.received/initiated` 来自 `CountByOwner/CountByAsker` **真实总数**(非分页切片长度);模板「加载更多」键 `qlist.loadMore` 三语言齐全;`strconv.FormatUint(uint64(...))` 类型错误已修。
- P1-4 6 条 `CREATE INDEX IF NOT EXISTS` 幂等、internal/db 编译通过。
- P1-5 静态前缀 `/static`(route.go:99)+ embed 含 json + 模板同源 fetch;根副本已删、无残留引用。
- 一处措辞纠正(非缺陷):P1-4 `ensureFrequentlyUsedIndexes` 实际**未做 dialect 门控**(无条件执行),但所用 DDL 为 PG 与 MySQL 8.0+ 均支持的 `CREATE INDEX IF NOT EXISTS` 可移植语法,对当前 PG 部署无影响;若将来接 MySQL 仍可用。
-132
View File
@@ -1,132 +0,0 @@
# TamaBox-v4 整体功能与逻辑审阅报告
- **审阅日期**:2026-08-31
- **范围**:全仓库(route / internal / templates / conf)
- **方法**:静态走查 + 编译验证(`go build` / `go vet` / 单元测试)+ 对每项高危结论做源码核对(trust but verify)
- **结论概览**:核心架构扎实(路由/中间件/会话固定/回收令牌/配额原子递增/功能开关 Evaluate 逻辑均正确)。发现 **1 个 P0 级安全缺陷(IP 伪造绕过)、若干 P1 逻辑/安全/可用性问题、一批 P2 健壮性项**。无导致编译崩溃的致命代码错误。
---
## 0. 测试与构建验证结果
| 项目 | 结果 |
|---|---|
| `go version` | go1.25.5 windows/amd64 |
| `go build ./...` | ❌ 仅因 `internal/storage/s3.go` 依赖 `github.com/chai2010/webp`(CGO 缺 libwebp 本机库,沙箱限制,与既有约定一致)。**其余包均可独立编译** |
| 核心包编译 | ✅ `internal/form internal/db internal/cache internal/i18n internal/captcha internal/security/* internal/context internal/conf internal/dbutil` 全部 OK |
| `go vet`(上述包) | ✅ 无告警 |
| 单元测试 | ✅ `internal/form` `internal/i18n` `internal/cache` `internal/security/autoban` 全部通过 |
| DB 集成测试 | ⚠️ 需外部 PostgreSQL/MySQL(`DB_TYPE` 环境变量,沙箱无),未执行;`feature_control_test` 等需落库测试建议在有 DB 环境补跑 |
> 说明:`gofmt -l` 发现 11 个文件未格式化(如 `internal/security/censor/text.go`、`internal/captcha/math.go`、`route/user/harassment.go` 等),建议 `gofmt -w` 统一。
---
## 🔴 P0 — 安全/可用阻断级
### P0-1 反向代理 IP 信任可被伪造,限流/黑名单/自动封禁全线失效
- **位置**:`internal/shared/ip.go:42-69`(`ResolveClientIP`)、被 `internal/route/ratelimit.go`、`autoban.go`、`ipblacklist.go` 共用
- **问题**:当 `conf.Security.TrustProxy=true` 时,直接信任客户端发来的 `CF-Connecting-IP` / `X-Real-IP` / `X-Forwarded-For`,**不校验 `RemoteAddr` 是否属于可信代理 CIDR**。任何人都可伪造这些头。
- **影响**:
1. 限速、IP 黑名单被完全绕过(每次请求换一个伪造 IP);
2. 自动封禁(fail2ban)被反射利用——攻击者用伪造头把**任意受害者 IP** 打进黑名单,造成误封(含 CDN 出口 IP,可能误伤全站访客)。
- **现实性**:`conf/app.sample.ini` 明确建议"站点在 Cloudflare 后且需自动封禁时必须设 `trust_proxy = true`"——这正是绝大多数生产部署,故该缺陷在生产可达。
- **修复**:配置 `trusted_proxies` CIDR 列表;仅当 `RemoteAddr` 命中可信网段才读取代理头;`X-Forwarded-For` 自右向左剥离已确认的可信跳数后再取最左原始 IP。
---
## 🟠 P1 — 逻辑错误 / 漏洞 / 可用性
### P1-1 会话 Cookie 缺 `Secure` / `SameSite`
- **位置**:`internal/route/route.go:92-95`(`session.Options` 仅设 `Initer/Config`,无 `Cookie` 字段)
- **影响**:生产经 TLS 反代时,会话 Cookie 不会标 `Secure`(明文链路可泄露);未显式 `SameSite`。`conf.App.Production` 只切 Flamego 环境,不联动 Cookie 标志。
- **修复**:按 `conf.App.ExternalURL` 是否 `https` 设 `Secure:true`;`SameSite:http.SameSiteLaxMode`(与既有 CSRF Token 协同);`HttpOnly:true`。
### P1-2 配置解析:行内 `;` 注释污染值,最坏导致启动崩溃
- **位置**:`internal/conf/conf.go:25-27`(`IgnoreInlineComment:true`)+ `conf/app.sample.ini:5,11,12,16-28,54`
- **问题**:该选项下 `key = val ; 注释` 的注释会被并入字段值。示例里 `trust_proxy = false ; 是否信任…`、`ban_threshold_sensitive = 10 ; …` 等带行内注释。若把 sample 直接当 `app.ini` 用,`File.Section("security").MapTo(&Security)` 解析 bool/int 会因尾部中文注释失败 → **进程启动即崩溃**;`share_background` 这类字符串字段则会得到带注释的非法 URL。
- **修复**:将 `IgnoreInlineComment` 改为 `false`(安全默认,`;` 重新开始注释),或全量清除 sample 中的行内注释(当前 sample 与其自身约定"禁用行内注释"矛盾)。
### P1-3 本地算数验证码可被脚本自动解出 + 端点无节流
- **位置**:`internal/captcha/math.go:195-212`(`GenerateHandler` 无限流)、`:148-159`(`VerifyOnly` 不消费、不计数)、`:49-55`(忽略 `rand.Int` 错误)
- **问题**:验证码题目由服务端返回给客户端(如 `"23 × 7 ="`),答案对客户端完全可见,**任何脚本都能读题即算**——对机器人无实质阻挡;`/api/captcha/verify` 无节流且 `VerifyOnly` 不消费,可被无限轮询;`GenerateHandler` 无限流会导致会话条目无界膨胀(DoS)。
- **影响**:匿名投票/提问的"唯一门槛"被自动化绕过;叠加 **P0-1** 的 IP 伪造,匿名刷票/刷提问几乎无成本。
- **修复**:匿名提交改用语服务端校验的 Turnstile/recaptcha;或补 `/api/captcha/*` 限流 + 同 id 尝试 3 次即销毁;对 math 答案做常量时间比较。
### P1-4 追问"等主人回复"仅客户端校验,服务端可绕过
- **位置**:`route/question/follow_up.go:31-63`(`PublishFollowUp`)
- **问题**:仅检查 `question.Answer != ""` 与 `count < 3`,**不校验上一条追问是否已被回答**(`waitingFollowUpReply` 只在模板隐藏表单,POST 不强制)。攻击者可连发满 3 条追问而不等主人回任一条。
- **修复**:取该问题最新一条 follow-up,若 `Answer == ""` 直接拒绝(并复用 `CreateIfBelowLimit` 原子兜底)。
### P1-5 内容审核"可信域名剥离"被绕过,钓鱼链接漏审
- **位置**:`internal/security/censor/text.go:89-96`(`RemoveTrustedURL`)
- **问题**:朴素 `strings.ReplaceAll("https://github.com", "")`。`https://github.com.evil.tld/phish` 会被剥成 `.evil.tld/phish`,恶意域名逃过 URL 审核。
- **修复**:解析 URL 后按"精确 host 或受信 host 的子域"精确匹配,再决定是否跳过审核。
### P1-6 密码校验:遗留 HMAC 比较非常量时间 + bcrypt 失败静默降级
- **位置**:`internal/db/password.go:56`(`legacyHash == storedHash`)、`:24`(`GenerateFromPassword` 出错回退弱 HMAC-SHA1)
- **问题**:遗留账户走 `==` 比较存在时序侧信道;bcrypt 出错时不返回错误,反而存一个弱 HMAC-SHA1 哈希(下次登录按 legacy 处理), silently 削弱了密码存储强度。
- **修复**:遗留分支改用 `subtle.ConstantTimeCompare`;bcrypt 失败直接 `return err`,不回退。建议 bcrypt cost 提到 12。
### P1-7 删除内容不回退功能配额计数
- **位置**:`internal/db/questions.go`(删除)、`internal/db/conversation.go:204`(`MarkDeletedByOwner`)
- **问题**:达定量上限后,即便删除该提问/私聊,对应 `feature_controls.quota_count` 不 `-1`,功能仍显示 `quota_reached` 关闭,须箱主手动"重置计数"。
- **修复**:删除时按功能类型调用 `FeatureControls.IncrementQuota(-1)`(注意软删除与并发)。
### P1-8 自动封禁把合法 302 误判为探测
- **位置**:`internal/route/autoban.go:51-53`
- **问题**:把"302 → /"一律当扫描信号;但 `/i18n/{lang}` 切换、logout、黑名单跳转、投票不存在等都是 302→/。60s 内 20 次即 403 封 15 分钟,易误伤正常用户。
- **修复**:仅在 `f.NotFound`(真正的 404)路径内计数,排除业务性 302。
### P1-9 服务反代路径穿越 + 丢失 query + 无超时
- **位置**:`route/service/proxy.go:37,59`
- **问题**:`forwardPath` 未经 `path.Clean`,`/service/<prefix>/../../x` 可越过白名单前缀(白名单本身有效,无任意 SSRF/元数据风险);丢失 `RawQuery`;每请求新建 `Transport` 且无超时。
- **修复**:`Clean` 后再次校验仍以 `forwardURL.Path` 为前缀;透传 query;复用带超时的 `Transport`。
### P1-10 `/api/v1/pixel/**` 豁免 CSRF + 放行写方法 + 无节流 + 响应头透传
- **位置**:`internal/context/context.go:170`(CSRF 排除)、`internal/route/route.go:296`(`f.Any` + `reqUserSignIn`)
- **问题**:该前缀跳过 CSRF 校验且 `f.Any` 放行 POST/PUT/DELETE;`path.Join("/api/", uri)` 可经 `..` 跳出 `/api`;上游 `Set-Cookie` 等响应头原样回传;无速率限制。
- **修复**:强制 CSRF 或仅放行 GET;拒绝含 `..` 的路径;白名单放行响应头;补限流。
---
## 🟡 P2 — 健壮性与改进(建议项)
| # | 位置 | 问题 | 建议 |
|---|---|---|---|
| P2-1 | `route/vote/vote.go:310` | 排序题统计 `json.Unmarshal(&data.Ranking,…)` 应为 `&data`,解析必失败且错误被忽略 | 改为 `&data` 并补单测验证 |
| P2-2 | `route/vote/vote.go:48,343,776` | 结果缓存共享指针,且仅新提交失效(删提交/改选项不失效),多实例不一致 | 失效键扩展;或缓存按 vote 维度加版本号 |
| P2-3 | `internal/db/vote_submissions.go:51-55` | 唯一索引不含 `deleted_at`:软删提交后统计归零却无法重投 | 唯一约束纳入 `deleted_at` 或改用业务去重 |
| P2-4 | `internal/db/questions.go` | `Question.Token` 仅 6 位且无唯一索引约束说明,碰撞可越权看他人私密/未答提问 | 确认是否 `uniqueIndex`;不足则加长 |
| P2-5 | `route/question/question.go` | 普通 `Delete` 不级联 `follow_ups`/`UploadImages`,留孤儿记录 | 软删时同步标记子表 |
| P2-6 | `internal/db/users.go` | `CountTodayMessagesFromUser` 用 `CURRENT_DATE`(DB 时区),与 App 本地时区不符,每日私信上限窗口偏移 | 用 App 本地时区日期边界 |
| P2-7 | `route/user/profile.go:337` | 导出 XLSX/CSV 时用户串直写单元格,含 `= + - @` 开头可在 Excel 执行公式(公式注入) | 单元格前加 `\t`/单引号或置文本格式 |
| P2-8 | `internal/db/users.go:454` | 注册重复 email/domain 校验存在 TOCTOU,并发同值双过 `validate` | 包事务或友好去重(唯一索引兜底) |
| P2-9 | `internal/route/gzip.go:75-80` | `ct` 求值后未用 → 未显式设 `Content-Type` 的响应永不压缩;204/304 仍加 `Content-Encoding` | 显式压缩判断 + 跳过无实体状态码 |
| P2-10 | `internal/route/route.go:233` | `/i18n/{lang}` 仅拦 `//`,未拦 `/\` 反斜杠等价变体 | 归一化 `to` 后再校验 |
| P2-11 | `internal/route/route.go` | 缺 HSTS;`lang` cookie 无 `Secure` | 加 `Strict-Transport-Security`;cookie `Secure` |
| P2-12 | `internal/route/chat/chat_handler.go:122` | `SendMessage` 未重新校验 `FeatureChat.Open`(创建时查过,发消息漏查) | 发送前 `FeatureStateOf(FeatureChat).Open` 校验,或明确设计为既有会话豁免 |
| P2-13 | `internal/db/questions.go` | 提问列表 `PinFirst` 与 `id<cursor` 游标冲突,置顶后"加载更多"会漏/重较新非置顶项 | 置顶与分页采用稳定排序键 |
| P2-14 | `internal/captcha/math.go:49` | 忽略 `rand.Int` 返回错误,极端下 `.Int64()` 可能 nil 解引用 | 校验错误并 return |
---
## ✅ 已核对正确的关键点(信心项)
- **私聊消息计数并发**:`IncrementMessageCountIfBelow`(`conversation.go:124`)使用原子条件更新 `WHERE message_count < max AND is_closed=false`,**无超额竞态**(经核实,非 agent 所报的竞态)。
- **提问归属鉴权**:`Questioner` 中间件 + `SetPrivate/Delete/TogglePin/SetRead` 均以 `ctx.User.ID==pageUser.ID` 判定,无 IDOR。
- **投票提交防绕过**:`Submit` 已校验 `FeatureVote.Open`、`isVoteActive`、已投(`HasUserVoted`/`HasTokenVoted`)+ 数据库唯一索引兜底;voter token 为 HMAC 签名。
- **回收密码令牌**:64 位随机、24h 有效期、单次使用、绑定 `user.ID`,设计安全。
- **会话固定**:登录后 `RegenerateID` 已做。
- **配额计数**:`IncrementQuota` 原子 `quota_count + ?` + 并发补加重试,设计合理。
- **启动健壮性**:`AutoMigrate` 前置去重、功能开关 backfill、旧版私聊零值陷阱纠偏,均为幂等且不阻断启动。
---
## 修复优先级建议
1. **立即(P0)**:P0-1 反向代理 IP 信任校验。
2. **本周(P1)**:P1-1 会话 Cookie 标志、P1-2 配置解析、P1-3 验证码策略(配合 P0-1)、P1-4 追问服务端校验、P1-5 审核域名绕过、P1-6 密码比较、P1-8 自动封禁误判、P1-10 pixel CSRF。
3. **排期(P2)**:导出公式注入、统计 bug 单测、缓存一致性、时区等。
> 注:outputs/ 下已有 2026-08-26/08-28 的历史审阅文档,本次为独立复检,新增/重申了 IP 伪造、审核 fail-closed、追问绕过、审核域名绕过、配置崩溃等项;其中 chat 消息计数竞态经核实**不存在**,已在历史结论中纠正。
@@ -1,138 +0,0 @@
# 代码审阅报告 —— 9-01 深夜加固批次(凌晨 02:11–02:23)
- 审阅范围:`internal/shared/ip.go`、`internal/route/ratelimit.go`、`internal/route/autoban.go`、`internal/template/markdown.go`、`internal/form/form.go`,及 3 个测试(`ip_test.go`、`markdown_test.go`、`csrf_binding_test.go`)
- 审阅方式:逐文件源码走查 + 交叉核对调用方契约(route.go 挂载点、login.go uid 键、ipblacklist 精确匹配、模板 FuncMap)+ **第三方库源码级语义核验**(flamego@v1.9.4 `run()`、flamego/csrf@v1.3.0 `Validate`、net/http 表单惰性解析)
- 审阅日期:2026-09-02
- 结论:**单个文件实现均正确、自洽,且有测试覆盖;但 form.go 的「请求体 12MiB 上限」因中间件执行顺序在真实链路上失效,属上一轮安全评审 P1-3「磁盘耗尽」未真正闭环;另有一处反序列化失败不中断业务链的通用缺陷。** 建议修复后再上线(本次仅审阅,未改码)。
---
## 一、🔴 P1-1 请求体 12MiB 上限实际失效:超大 multipart 仍可无节流落盘(磁盘耗尽 DoS 未闭环)
**位置**:`internal/form/form.go:59-79`(上限安装点)、`internal/context/context.go:180-182`(CSRF 校验点)、`internal/route/route.go:372-388`(中间件注册顺序)
### 问题
`maxRequestBodySize` 的 `http.MaxBytesReader` 被安装在 **form.Bind 内(路由级 handler)**,但真实链路上 CSRF 校验(`Contexter` → `x.Validate(ctx)`)是 **组级中间件、先于一切路由级 handler 执行**:
```
安全头 → IP黑名单 → autoban → cache/recaptcha/session → csrf.Csrfer → templater
→ Contexter(★ 此处对每个 POST 执行 csrf.Validate)
→ form.Bind(★ 12MiB 上限到这一步才装上)→ rateLimit → 业务动作
```
而 flamego/csrf 的 `Validate` 取值顺序是**先读 Header,无 Header 时 `FormValue("_csrf")`**——后者会触发 net/http 的**整包惰性解析**:
- multipart:`ParseMultipartForm(默认 32MiB 内存)`,超出部分**无上限落盘临时文件**;
- 解析**发生在 token 校验之前**:即使不带/带错 CSRF token(随后被 400 拒绝),超大 body 也已被完整读入并落盘;
- 该 400 由 `Contexter` 直接写出 → **响应已写、链路中断** → 路由级的 `rateLimitMiddleware` / autoban 计数**根本没机会执行**。
即:对任意 POST 路由(公开提问框 `/_/{domain}`、`/vote/{token}/submit`、`/login` 等),攻击者**无需有效 CSRF token、不受限流约束**,持续 POST >32MiB 的 multipart body 即可无限写临时目录,1C1G 机器可在数分钟内被打满磁盘。form.go 注释中想防御的场景(匿名可达接口的磁盘耗尽)依然存在。
> 补充:urlencoded 大 body 同理在 CSRF 阶段整体载入内存(无上限、无限流),为内存压力向量。
### 修复建议(单点修复,可同时覆盖下文 P2-2)
在**全局 `f.Use` 层**(`route.go:146` autobanMiddleware 之后即可)安装统一请求体上限,早于一切组级中间件与 body 解析:
```go
// internal/route/(建议独立文件 bodylimit.go)
const maxRequestBodySize = 12 << 20 // 与 form.go 语义一致;如需多图余量可统一上调
func requestBodyLimitMiddleware() flamego.Handler {
return func(ctx flamego.Context) {
req := ctx.Request().Request
req.Body = http.MaxBytesReader(ctx.ResponseWriter(), req.Body, maxRequestBodySize)
}
}
// route.go
f.Use(autobanMiddleware(ab))
f.Use(requestBodyLimitMiddleware()) // 必须在 Contexter 之前
```
- **取值论证**:合法单请求最大 body = 单张图片 ≤8MiB(`storage.MaxUploadBytes`,`internal/storage/s3.go:36`,且提问/回答只取 `f.Images[0]`)+ 表单字段/边界开销,远小于 12MiB;头像/背景 2MiB、私聊图 5MiB 均安全。12MiB 不会误伤合法提交。
- 该中间件同时封住:form.Bind 路由(含 HTML 表单路径)、chat `/send`、vote `/submit`,以及 CSRF 自身触发的解析。form.go 内层 wrap 可保留(幂等,误差同源)或删除。
- **回归测试建议**:仿 `route/csrf_binding_test.go` 增加路由级测试:向任一 POST 路由发送 Content-Length 超过上限的 multipart body,断言返回 4xx 且不落入业务(可借 `f.Post("/protected", ...)` 桩验证 body 未被读)。
---
## 二、🟠 P2-1 form.Bind 反序列化失败不阻断业务链:零值结构体进入业务动作
**位置**:`internal/form/form.go:70-78`(失败分支仅 `c.Map(Error{...})` 后 `return`)
### 问题
flamego@v1.9.4 `run()`(`context.go:190-228`)的链语义是:**handler 返回后只要 `ResponseWriter().Written() == false`,就继续执行下一 handler**(没有「不调用 Next 即停链」的语义)。因此 Bind 解析失败分支只 `Map(Error)`、**不 `SetError` 也不写响应** → 后续业务动作**以零值结构体照常执行**,`ctx.HasError()` 恒为 false,govalid 校验被整体跳过。
**触发面**:body 在 Bind 阶段解析失败(>12MiB、畸形编码)。HTML 表单提交的 body 在 CSRF 阶段已解析成功(Bind 幂等重解析不会失败),**实际只影响 Header 携带 CSRF token 的 JS/fetch 提交**(token 在头里时 CSRF 不解析 body,解析首次发生在 Bind)。匿名路由攻击者可自持「uid=0」的匿名 token 构造该请求。
**各动作影响(按危害排序)**:
| 动作 | 零值后果 | 二次防线 |
|---|---|---|
| `user.UpdateProfile`(`route/user/profile.go:66`) | `db.Users.Update` 用 **map 全字段无条件写**(`internal/db/users.go:336-357`)→ 昵称/简介/配色/社交链接被清空、`Notify` 置 `None`(**静默退订邮件通知**);无任何错误提示 | 无验证码;仅限账户本人/持其 CSRF 的请求(自伤型数据丢失) |
| `user.UpdatePagePassword` / `UpdateAskPassword`(`route/user/password.go:17,32`) | 这两个 handler **连 `HasError` 都不查**,直接 `f.Password`(空=清除密码)→ 反序列化失败等于「关闭页面/提问密码」,**防护被静默解除** | 仅本人(自伤型) |
| 匿名 `question.New` / `PublishFollowUp`(`route/question/page.go:315`) | 零值内容绕过 govalid 与屏蔽词/长度校验进入业务;`New` 有验证码闸门兜底,`PublishFollowUp` **无验证码且该路由无限流** | 验证码开启时安全;站点关闭/未配验证码时可产生空内容垃圾数据 |
| `auth.Register/Login/Forgot/Recover` | 零值走 DB 校验失败路径 | 验证码 + DB 唯一性 |
### 修复建议(与既有 govalid 分支同构,改动最小)
`internal/form/form.go:71,76` 两处失败分支,在 `c.Map(...)` 前补 `c.SetError(err)`(或本地化包装,如「请求内容不合法,请重试」)。`SetError` 会置 `HasError=true` 并写入 `Data["Error"]`(`internal/context/context.go:71-86`),所有动作的 `if ctx.HasError()` 分支随即生效(重渲染当前页并提示),与校验失败体验一致。
```go
if err := r.ParseForm(); err != nil {
c.SetError(err)
c.Map(Error{Category: ErrorCategoryDeserialization, Error: err})
return
}
```
> 注意:`UpdatePagePassword`/`UpdateAskPassword` 本身不查 `HasError`(空密码=关防护是合法操作),若采用 SetError 方案需给这两个 handler 补一个 `if ctx.HasError() { ctx.Redirect(...); return }` 守卫,否则 SetError 后仍会往下写空密码。
### 回归测试建议
给 `internal/form`(或路由级)补用例:模拟 >12MiB 的 multipart body 经 Bind 后,断言 `HasError()==true`;若约束链上动作,断言动作未执行。
---
## 三、🟠 P2-2 chat `/send` 与 vote `/submit` 无任何请求体上限
**位置**:`internal/route/chat/chat_handler.go:127-141`、`route/vote/vote.go:678+`;两路由均不走 `form.Bind`(`route.go:245,257` 直接挂 handler)。
**问题**:`SendMessage` 用 `FormValue("content")`/`FormFile("image")`、`Submit` 用多个 `FormValue(...)` 惰性触发解析——在 Header token 路径下(CSRF 不预解析),首次解析发生在 **handler 内部、无 MaxBytesReader**:multipart 超 32MiB 落盘无上限。私聊图 5MiB 业务上限在**解析完成后**才检查(`chat_handler.go:233`)。
**缓解因素**:chat `/send` 前置 `reqUserSignIn`(`route.go:248`)+ 20/min 限流;vote `/submit` 前置三层限流。故列为 P2。**采用 P1-1 的全局 body 上限方案后本项自动闭环**,无需单独改动。
---
## 四、✅ 确认无问题项(本批次核心实现 + 测试)
| 文件 | 判定 | 依据 |
|---|---|---|
| `internal/shared/ip.go` | ✅ | 信任模型正确:`TrustProxy=off` 时仅取 `RemoteAddr`;on 时 CF-Connecting-IP → 可信代理 XFF 剥取。`LimitKey` IPv6 聚合 /64 防轮换绕过;注释明确「不得用于黑名单」——与 `ipblacklist.go` 的 exact/CIDR 匹配一致 |
| `internal/route/ratelimit.go` | ✅ | 滑动窗口 + 全局注册表 `sync.Once` 单清理协程(修复旧版每实例泄漏);visitors>10000 快速清理守卫;匿名/登录 3 倍分级独立计数;vote 三层限流键无冲突。登录判定 `s.Get("uid").(uint)` 与 login.go 写入类型一致 |
| `internal/route/autoban.go` | ✅ | `LimitKey` 键与 `f.NotFound` 记录(`route.go:395`)一致,封禁可命中;403 直断(写响应即停链);敏感路径 `defer Record` 于响应后计数;404 独立计数,避免业务 302→/ 误判 |
| `internal/template/markdown.go` | ✅ | 白名单消毒 + `safeURL`(拒 `//`、`\`、`javascript:` 等)+ `safeColorValue`;`MarkdownToHTML`/`PlainText` 与模板 FuncMap 注册一致;测试覆盖 XSS/协议相对 URL/字体色注入 |
| `csrf_binding_test.go` | ✅ | 守卫 `CSRFUserSessionKey="uid"` 与 login 绑定一致(误改回 "userID" 即失败);会话 B 复用 A 的 token → 400 |
| `ip_test.go` / `markdown_test.go` | ✅ | 覆盖充分(IPv4 透传、v4-mapped 折叠、/64 聚合、端口、非法/空;markdown 六类用例) |
**次要观察(不改)**:`rateLimiter.allow()` 的 >10000 清理仅删「窗口内已无活动」键,活动大流量下 map 仍可增长——受 `LimitKey` 聚合 + 短窗口约束,实际可达量级(数万键 ≈ 数 MB)可接受,非回归。
---
## 五、✅ 修复实施记录(2026-09-02 夜,已落地并验证)
> 上节为原评审建议(针对 02:11–02:23 深夜加固批次)。用户批准后已全部实施,P2-2 随 P1-1 自动闭环。**复核中发现原 P2-1 影响面比初判更大**:`UpdateHarassment` / `UpdateBlockWords` / `UpdateTags` 与两个 password handler 一样**不检查 `HasError`**,零值结构体会静默关闭防骚扰 / 清空屏蔽词 / 清空标签——一并纳入守卫。
| 编号 | 改动文件 | 内容 | 验证 |
|---|---|---|---|
| P1-1 | `internal/route/bodylimit.go`(新增) | 全局 `requestBodyLimitMiddleware`:POST/PUT/PATCH 且 `ContentLength>12MiB` → 413 快速失败;其余用 `http.MaxBytesReader` 惰性封顶。挂载于 `f.Use` 链(autoban 之后、所有 Group 之前),先于 Contexter 内 CSRF 的整包惰性解析 | `go build -overlay` ✅ |
| P1-1 | `internal/route/route.go` | `f.Use(requestBodyLimitMiddleware())`(autoban 之后) | 全模块编译 ✅ |
| P1-1 | `internal/form/form.go` | `maxRequestBodySize` 导出为 `MaxRequestBodySize`(单一事实来源,供全局中间件引用) | `go vet` ✅ |
| P1-1 | `internal/route/bodylimit_test.go`(新增) | 回归测试:合法 8MiB → 200;Content-Length 超限 → 413;chunked 超限(绕过预检)→ MaxBytesReader 拒绝 | `go test ./internal/route/` ✅ |
| P2-1 | `internal/form/form.go` | 两个反序列化失败分支(ParseForm / ParseMultipartForm)补 `c.SetError(err)`——置位 HasError,使检查 HasError 的 action(含匿名提问/回答/追问)在零值结构体进入业务前早退 | 同上 |
| P2-1 | `route/user/harassment.go` | 新增 `abortOnFormError` helper(HasError → 错误经 flash 转交 → redirect 中止);`UpdateHarassment`、`UpdateBlockWords` 开头加守卫 | 同上 |
| P2-1 | `route/user/tags.go` | `UpdateTags` 开头加守卫(防清空标签 + 关闭首页展示) | 同上 |
| P2-1 | `route/user/password.go` | `UpdatePagePassword` / `UpdateAskPassword` 开头加守卫(防空密码静默解除页面/提问防护;govalid 超长密码此前也会绕过前端限制直接入库,一并拦截) | 同上 |
| P2-2 | — | chat `/send`、vote `/submit` 已被全局中间件覆盖(超限时 CSRF/解析失败即拒),无需单独改动 | 见 P1-1 |
**测试结果**(`go test -overlay=dev/overlay.json`):`templates`、`internal/template`、`internal/form`、`internal/shared`、`internal/security/autoban`、`internal/security/censor`、`internal/route`(含新增 bodylimit 回归 + csrf_binding)全部 PASS;`go build ./...` 与 `go vet ./internal/... ./route/...` exit 0。
---
*附:本报告评审结论基于源码走查与依赖库源码语义核验;修复部分已于 2026-09-02 夜落地并通过 overlay 全模块编译 / vet / 相关包测试。*
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

@@ -1,279 +0,0 @@
# box.tama.guru → box.shiroko.one 域名迁移方案(保 Bing 排名)
> 日期:2026-09-06 | 状态:方案待批准
> 目标:在 tama.guru **不续费**(2026-12-24 到期)的约束下,把 box.tama.guru 迁移到 box.shiroko.one,最小化 Bing(及 Google)排名损失。
---
## 0. 决策记录(用户已拍板)
| 决策点 | 选择 | 影响 |
|---|---|---|
| 过渡策略 | **不续费硬过渡** | 301 窗口 ≈ 现在→2026-12-24(约 3.5 个月) |
| 新域形态 | **box.shiroko.one**(子域) | 根域 shiroko.one 留给导航页/blog 等未来用途 |
| 引擎范围 | **Bing + Google 全套** | Site Move + GSC Change of Address + 双 sitemap + IndexNow |
| umami | **一并迁走** → mami.shiroko.one | 统计不中断 |
| 站群其余子域 | **一并迁移**(blog/chat/share/status → 同名 `.shiroko.one`) | 用户 2026-09-06 追加确认;footer 链接群与 home 文案内链全部换域 |
| `/_/tamakyi` | **不保** | 从 sitemap 移除;robots 维持 `Disallow: /_/`(与 sitemap 自相矛盾的问题一并消除)。页脚/首页指向站长箱的**功能性链接保留**(用户仍可点击),只是不再提交给搜索引擎 |
> ✅ **批 1(本地代码改动)已于 2026-09-06 完成**,详见 §12。
---
## 1. 现状与硬约束(侦查取证结果)
### 1.1 资产地图
- `tama.guru` 根域 = 「狼的导航页」(Hugo 0.121.2 / CF Pages)——用户声明不保
- `box.tama.guru` = **本次迁移对象**(提问箱,生产,DNSPod 三线路 DNS:境内→EdgeOne 新加坡、境外/默认→Cloudflare SaaS+tunnel)
- `mami.tama.guru` = umami 统计(Vercel)——随迁
- `blog/status/share/chat.tama.guru` = 站群其余子域——用户未声明保,列入开放项(§11)
- `r2.337845818.best`、`337845818.best` zone = 独立域,**不受影响**(仅需在 R2 CORS 白名单追加新域)
- 注册信息:tama.guru 注册商 **DNSPod, Inc.**,**expiration 2026-12-24**,当前 active
### 1.2 box.tama.guru 的 SEO 现状(好消息)
- Bing 收录管道 = `static/sitemap.xml` 仅 **8 条 URL**:
`/`、`/explore`、`/register`、`/login`、`/_/tamakyi`、`/change-logs`、`/explore`(重复)、裸域
- `/explore` 是唯一「内容页」,排名大概率集中于此 → **索引面小、301 映射表短、信号集中,迁移成本极低**
- ⚠️ robots.txt `Disallow: /_/` 与 sitemap 提交 `/_/tamakyi` **自相矛盾**——搜索引擎以 robots 为准,该条实为无效提交;若 `/_/tamakyi` 有真实流量价值需决策(§11 开放项)
- ⚠️ 页面**无 `<link rel="canonical">`**(仅 og:url)——迁移是新站补 canonical 的好时机,对 Bing 有实际帮助
### 1.3 关键机制(Bing/Google 官方语义)
1. **301 全量逐 URL 一一对应**是唯一可靠路径:不 302、不跳首页、无链式跳转
2. Bing Site Move / GSC Change of Address 只是「加速器」;工具不可用时 301+sitemap 兜底
3. 提交 Site Move 后 **新域 6 个月内不得再次迁移**
4. 通行要求:301 保持 6–12 个月;权重转移 60–90 天;迁移期掉 10–30% 属正常
5. **约束风险**:本方案窗口 ~3.5 个月 < 6 个月通行线。缓解:① 索引面仅 3–5 个有效 URL,Bing 对小型站处理快;② 尽早切换拉满窗口;③ IndexNow 秒级通知加速新站收录
---
## 2. 目标架构
```
┌─ 境内线路 ──> EdgeOne(box.shiroko.one 加速域) ─┐
用户 → DNSPod(shiroko.one) ┤ ├→ 源站 TamaBox
(分线路) └─ 境外/默认 ─> Cloudflare SaaS + tunnel ──────┘
│
旧域 box.tama.guru(DNS 在切换日改指静态 301 载体,见 §6)
任意线路 ─> CF Pages 跳转项目 ─ 301 ─> https://box.shiroko.one/<同路径><同query>
```
- 新域 **双 CDN 架构完全照搬** box.tama.guru 现状(境内 EO / 境外 CF)
- 旧域在 12-24 前**只做 301**,不再回源 → 源站与旧域解耦,安全退役
- umami:Vercel 加 `mami.shiroko.one`,同一实例,数据不丢
---
## 3. 时间线(倒排,越早切换 301 窗口越长)
| 阶段 | 窗口 | 内容 | 责任 |
|---|---|---|---|
| **P0 准备** | 现在 – 9/20 | 代码/配置改动(§5)、域名/控制台准备(§7)、Bing/GSC 双属性验证(§8.1) | 我 + 你 |
| **P1 新站上线** | 9/20 – 9/27 | box.shiroko.one 双线打通、全功能验收、旧站并行不动 | 我 + 你 |
| **P2 切换** | 9 月底某低峰日凌晨 | 改 external_url → 源站重启 → 旧域 DNS 切 301 载体 → Site Move/CoA/IndexNow(§8) | 我 + 你 |
| **P3 过渡监控** | 10 月 – 12/24 | 每周看两站索引与流量;反链外联更新;旧域保持 301 | 我 |
| **P4 到期善后** | 12/24 后 | 旧域自然失效属计划内;确认新站独立站稳、无旧缓存依赖 | 我 |
**切换建议 ≤ 2026-10-01**,301 窗口 ≈ 85–90 天,覆盖 Bing 权重转移期(60–90 天)。
---
## 4. 保排名逻辑(为什么这样做能最小化损失)
```
旧 URL(Bing 索引中) ──301──> 新 URL ──sitemap/IndexNow──> Bing 立即重爬新址
│ │
排名信号(点击/外链/信任) 由 301 传递 ≈90% 权重 60–90 天内转移
│ │
旧域 12/24 消亡时:Bing 已把信号合并到新域 ← 目标状态
```
不这样做的代价:旧 URL 到期后全部变死链 → Bing 逐个降权删除 → 新域从零积累,等于「重新开始」。
---
## 5. 代码与配置文件改动清单(本地副本,批准后分批实施)
### P0 必改(切域前)
| # | 文件 | 改动 | 说明 |
|---|---|---|---|
| C1 | 部署侧 `conf/app.ini` | `external_url = https://box.shiroko.one` | **必填配置**;og:url 与全部邮件链接(找回密码/私信通知)一次修复。app.ini 在部署环境持有,本地副本无此文件 |
| C2 | `static/robots.txt` | `Sitemap:` 行改 `https://box.shiroko.one/sitemap.xml` | go:embed 静态文件,当前硬编码旧域 |
| C3 | `static/sitemap.xml` | 8 条 URL 域替换为 box.shiroko.one | 同上;建议顺手去掉与 robots 冲突的 `/_/tamakyi`(§11 决策) |
| C4 | `route/auth/register.go:64` | `Bloglink: "box.shiroko.one"` | 注册引导链接硬编码 |
| C5 | `internal/security/censor/text.go` | TrustedHosts:`tama.guru` → 增加/替换为 `shiroko.one` | 否则用户在新站发 shiroko.one 链接被当广告拦。实施时确认 RemoveTrustedURL 对子域是否覆盖(needle=裸域 + 边界判断) |
| C6 | `templates/base/header.html` | og:url 保留(自动跟随 ExternalURL);**新增** `<link rel="canonical" href="{{.ExternalURL}}{{.CurrentURI}}">` | 新站补 canonical,防重复收录,Bing 友好 |
| C7 | `templates/base/header.html` | umami 注入块:`s.src = "https://mami.shiroko.one/tamami.js"` | 与 umami 迁移同批(§5 D 组) |
| C8 | `templates/base/footer.html` | 链接群(blog/chat/share/status/tama.guru)按 §11 决策处理 | 到期后这些全是死链,必须在切换前定 |
| C9 | `templates/home.html` | 正文内链 `box.tama.guru` → `box.shiroko.one`;指向 blog 的文章链接按 §11 决策 | 首页是 Bing 重点页面,内链必须指向新站而非继续依赖 301 |
### 伴随迁移(D 组,与 box 同批)
| # | 位置 | 改动 |
|---|---|---|
| D1 | Vercel 控制台 | umami 项目添加自定义域 `mami.shiroko.one`(CNAME → cname.vercel-dns.com) |
| D2 | DNS(新 zone) | `mami` 记录 CNAME → Vercel 目标 |
| D3 | C7 的 header 埋点 | 指向 mami.shiroko.one(同一 data-website-id,数据连续) |
> mami.tama.guru 在 12-24 前保持在线,与新域并行无冲突(浏览器缓存自然淘汰)。
---
## 6. 301 实现方案(旧域唯一使命)
**推荐:CF Pages 静态跳转项目**(免费、一行配置、境内境外通吃)
1. Cloudflare 新建一个 Pages 项目(如 `tama-redirect`),根目录放一个 `_redirects`:
```
/* https://box.shiroko.one/:splat 301!
```
2. 把 `box.tama.guru` 添加为该 Pages 项目的自定义域(Pages 面板 → Custom domains → 按提示在 DNSPod 加 CNAME)
3. **切换日**:DNSPod 上 box.tama.guru 的三条线路(境内 EO CNAME / 境外 CF / 默认 CF)统一改 CNAME → 该 Pages 域名(或直接 CNAME 到 pages.dev 域名)
4. 原 EO 加速站点 / CF SaaS 主机名(box.tama.guru)**可停可留**——DNS 切走后自然不再产生流量,12/24 随旧域注销即可
- ✅ 保留完整路径与 query(`:splat`),无链式跳转,天然 301
- ✅ 与源站/旧 CDN 完全解耦,源站只需服务新域
- ✅ 301 响应体积极小,境内经 CF Pages 慢 ~1s 无感
- 备选 A:EO 重定向规则(境内)+ CF Worker route(境外)——需在两边控制台分别配,操作更碎
- 备选 B:源站 nginx 按 Host 301——**不推荐**:CDN 回源 Host=box.tama.guru 时会产生回源环,需同时改回源配置,风险高
---
## 7. 控制台操作清单(需你在各平台操作,我提供每一步的精确值)
### 7.1 DNS 托管决策(先行)
**推荐**:`shiroko.one` 的 NS 从 Spaceship 改到 **DNSPod**(你已在 DNSPod 管理 tama.guru,分线路 DNS 是现有双 CDN 架构的前提;Spaceship DNS 无境内/境外分线路)。
步骤:DNSPod 添加 `shiroko.one` → 获得 DNSPod 分配的 NS → Spaceship 域名面板改 NS → 等待生效(1–24h,可用 `nslookup -type=NS shiroko.one 8.8.8.8` 验证)。
### 7.2 DNSPod(shiroko.one zone)
| 主机 | 类型 | 值 | 线路 |
|---|---|---|---|
| box | CNAME | EO 分配的 CNAME 目标(控制台给,形如 `xxx.eo.dnse4.com`) | 中国境内 |
| box | CNAME | CF SaaS CNAME 目标(337845818.best zone → Custom hostnames → 形如 `<hash>.cdn.cloudflare.net`)或既有优选 CNAME | 境外 / 默认 |
| mami | CNAME | `cname.vercel-dns.com` | 默认 |
> 以上记录结构完全照搬 box.tama.guru / mami.tama.guru 现有三条线;切换日另加 box.tama.guru 的 301 线路(§6)。
### 7.3 EdgeOne(腾讯)
- 新增加速域名 **box.shiroko.one**:归属验证(TXT/CNAME,验证记录保留勿删)→ HTTPS 证书 → 回源=源站 IP:高位端口、回源 HOST=box.shiroko.one
- 规则/缓存配置照抄 box.tama.guru 现有站点
### 7.4 Cloudflare
- `337845818.best` zone → SSL/TLS → Custom hostnames(for SaaS):添加 **box.shiroko.one**(回源走既有 tunnel;cloudflared ingress 增加 box.shiroko.one 路由)
- R2 bucket CORS 白名单:追加 `https://box.shiroko.one`(**必须在 301 前完成**,否则新站图片/二维码导出被 CORS 拦)
### 7.5 Vercel
- umami 项目添加 `mami.shiroko.one`(§5 D1)
---
## 8. SEO 迁移执行序列
### 8.1 预备(P0,先于新站上线,可立即做)
1. **Bing Webmaster Tools**:添加并验证 `box.shiroko.one`(用 DNS TXT 验证,DNSPod 加记录即可,无需等站点上线);确认旧站 `box.tama.guru` 属性已存在并验证
2. **Google Search Console**:同上双属性(URL-prefix:`https://box.tama.guru/`、`https://box.shiroko.one/`)
### 8.2 切换日顺序(P2,低峰凌晨,301 是前提)
1. 确认 box.shiroko.one 在 EO(境内视角)与 CF(境外视角)全功能验收通过(§9)
2. 改 `external_url` + 重启 → 新站所有 og/canonical/sitemap/邮件全指新域
3. 旧域 DNS 切 301 载体(§6)→ `curl -I` 验证 301 与 Location 正确
4. **Bing Site Move**(旧属性 → Diagnostics & Tools → Site Move → 目标 box.shiroko.one,Scope=Domain)——若工具不可用,301+sitemap 兜底
5. **GSC Change of Address**(旧属性 → 设置 → Change of Address → 新属性)
6. 双引擎分别提交**新站 sitemap**;旧站 sitemap 保留 60–90 天(引导爬虫吃 301)
7. **IndexNow**:用 API key 提交新站全部 URL(Bing 收录最快路径)
8. 逐条验证 §9.2 的 301 映射
---
## 9. 验证与验收
### 9.1 切换前(新站验收,全部通过才允许 301)
- [ ] `https://box.shiroko.one/` 境内 EO / 境外 CF 均 200(用不同线路 DNS 查询验证)
- [ ] 登录 / 提问 / 私信 / 上传 / 投票 / 二维码导出 主链路通过(R2 CORS 已含新域)
- [ ] `curl -s https://box.shiroko.one/robots.txt` → Sitemap 行 = 新域
- [ ] `curl -s https://box.shiroko.one/sitemap.xml` → 全部 URL = 新域
- [ ] 页面含 canonical 指向自身、og:url = 新域
- [ ] 邮件模板链接 = 新域(发一封找回密码实测)
### 9.2 切换后(301 映射抽查)
```
curl -sI https://box.tama.guru/explore → 301 Location: https://box.shiroko.one/explore
curl -sI https://box.tama.guru/_/tamakyi → 301 → https://box.shiroko.one/_/tamakyi
curl -sI "https://box.tama.guru/vote/xxx?lang=en" → 301 且保留 query
curl -sI https://box.tama.guru/no-such-page → 301(不允许 404/302)
```
- [ ] 抽样 20+ URL,确认无 302、无 404、无链式跳转(一条 301 直达)
### 9.3 过渡期每周监控(P3)
- Bing/GSC:旧域 indexed 数量应**逐周下降**;新域**逐周上升**,60–90 天追平旧域基线
- 综合 Search Performance:前 2–4 周掉 10–30% 属正常;**持续 >30% 且无回升 = 异常**,立即排查
- 新域 site:box.shiroko.one 查询确认收录增长
---
## 10. 风险清单
| # | 风险 | 等级 | 缓解 |
|---|---|---|---|
| R1 | 301 窗口 3.5 个月 < 6 个月通行线,权重转移可能不完整 | 🟠 中 | 9 月底前切换拉满窗口;索引面小 + IndexNow 加速;Bing 对小型站处理快 |
| R2 | 切换瞬间旧书签/API 用户 301 到新域 → cookie 一次性失效、跨域 | 🟡 低 | 一次性,切换公告;API 调用方(若有)需跟随新域 |
| R3 | umami 迁移延误 → 12/24 后统计断 | 🟡 低 | D 组随 P1 完成,两域并行过渡 |
| R4 | DNSPod 到期政策未知:到期日是否即停解析 | 🟡 低 | 按 12-24 停摆做计划;若到期后保留解析则 301 窗口自动延长,纯赚 |
| R5 | `/_/tamakyi` robots/sitemap 矛盾未决 → 若该页真有 Bing 流量,迁移映射需特殊处理 | 🟡 低 | §11 决策后补映射 |
| R6 | footer/home 指向 blog 等将死子域 → 新站页面出现死链 | 🟡 低 | §11 决策 |
---
## 11. 开放项(2026-09-06 更新:1/2 已由用户答复)
1. ~~站群其余子域(blog/status/share/chat)是否迁~~ → **已答:一并迁**。已按 `*.shiroko.one` 同名映射改完(footer 6 条链接 + home 文案内链 + llms 三处博客链)。
⚠️ 仍需你操作:这些子域的**实际服务**也要搬(blog 的 Hugo/CF Pages、chat/share/status 各自托管),否则新域链接会是死链。若某个子域打算放弃(如 share/status),告诉我,我把 footer 对应条目删掉。
2. ~~`/_/tamakyi` 是否保~~ → **已答:不保**。已从 sitemap 移除,robots 维持 Disallow。
3. **源站回源端口/隧道细节**:EO 与 CF tunnel 的 ingress 路由是否按 hostname 区分——决定 cloudflared 配置加一条路由即可(实施时我出精确 diff)
4. 迁移公告是否要做(首页横幅提示用户更新收藏/提问链接)
5. **搜索引擎验证 token 需替换**(本次代码审查新发现):`templates/base/header.html` 的 `google-site-verification`(636 行)与 `msvalidate.01`(7 行 + 637 行重复)是**绑定域名**的,新域验证后必须替换 content 值,否则 GSC/Bing 无法验证新站 → Site Move / Change of Address 无法提交。已加注释标记。
---
## 12. 批 1 实施记录(本地改动,2026-09-06 已完成)
**备份**:`.workbuddy/backup/domain-shiroko-20260906/`(9 个文件原样)
**替换规则**:`*.tama.guru` → `*.shiroko.one`;apex `tama.guru` → `shiroko.one`
| 文件 | 改动 |
|---|---|
| `static/robots.txt` | `Sitemap:` 行 → `box.shiroko.one` |
| `static/sitemap.xml` | 5 条 URL 全部换域;**移除 `/_/tamakyi` 条目**;**去重**(原 `https://box.tama.guru` 与 `.../` 重复);`lastmod` 全量刷新为 2026-09-06 以触发重抓 |
| `static/llms.txt` | 4 条站点链接 + 2 条资源链接换域 |
| `static/llms-full.txt` | 7 处换域(主域推荐、FAQ、站点地图、blog 更新日志链) |
| `templates/base/footer.html` | 6 条链接全换域(admin/notice/chat/drive/status/nav) |
| `templates/base/header.html` | umami 注释同步为 `mami.shiroko.one`(脚本 src 已先行改为新域);验证 token 处加迁移提醒注释 |
| `templates/home.html` | 3 处换域:站长箱链接、投票/Markdown 示例链、4 条 blog 文章链、探索页链 |
| `route/auth/register.go:64` | `Bloglink` 默认值 → `box.shiroko.one` |
| `internal/security/censor/text.go` | `TrustedHosts` **新增** `"shiroko.one"`;旧域 `tama.guru` **保留**(过渡期用户分享旧链接仍需判为可信,待旧域到期后再删) |
| `conf/app.sample.ini` | 补齐**缺失的必填项** `external_url`(conf.go:36 强制校验,原模板缺失会导致照抄即启动失败),示例指向新域 |
| `dev/eo-probe-ips.py`、`eo-probe-v2.py`、`eo-scan-all.py` | EO 回源探测脚本 `--host` 默认值 → `box.shiroko.one` |
| `internal/route/route.go` | **新增 `/index-now/{key}` 路由**(IndexNow 密钥文件,见 §12.1)。文件名用 `^[0-9a-f]{32}\.txt$` 白名单校验防路径穿越;配套回归测试 `internal/route/indexnow_route_test.go` |
**验证**:sitemap XML 解析通过(5 条、无重复);`go test ./templates/`、`./internal/security/censor/`、`./internal/route/` 通过;`route/auth`、`static`、`internal/conf` 带 overlay 编译通过。
### 12.1 IndexNow 密钥文件(迁移后加速新站收录,已实现)
用户在 `static/index-now/66e5e0911b0941f2ba7dbfbbd961e9be.txt` 放了 IndexNow key,但访问被 302 到首页。
**根因(重要,以后加任何根目录静态文件都会踩)**:`internal/route/route.go:138` 的 `flamego.Static` 中间件配置了 `Prefix: "/static"`,**只接管 `/static/...`**;而 `robots.txt`、`sitemap.xml`、`llms.txt`、`llms-full.txt`、`favicon.ico` 这些根目录文件**每一个都是单独注册的 `f.Get` handler**。新加的文件没有对应路由 → 落到 `f.NotFound` → `ctx.Redirect("/")` 返回 302。embed 本身没问题。
**修复**:新增 `f.Get("/index-now/{key}")`,文件名经 `^[0-9a-f]{32}\.txt$` 白名单校验(防路径穿越)后从 embed FS 读取,返回 `text/plain` + `max-age=86400`;校验失败或文件不存在返回 404(不再是 302)。
**提交 IndexNow 时的 keyLocation 填**:
```
https://box.shiroko.one/index-now/66e5e0911b0941f2ba7dbfbbd961e9be.txt
```
⚠️ 旧域 301 上线后,旧域的该路径会被 301 重定向,而搜索引擎校验密钥文件要求 **200 直出**,因此切换后请用新域的 keyLocation 重新提交。
---
## 附:本地代码改动的执行批次建议(批准后按批落地)
- ~~**批 1(P0 代码)**:C2/C3/C4/C5/C6/C8/C9 —— 纯本地仓库改动,可先行~~ → ✅ **已完成 2026-09-06**(见 §12)
- **批 2(配置)**:C1 + D 组 —— 需部署环境 app.ini + Vercel 控制台配合
- **批 3(切换)**:§6 301 载体 + §7 DNS/CDN 控制台操作 + §8 SEO 提交
@@ -1,155 +0,0 @@
# TamaBox 双 CDN 接入指南:境内腾讯 EdgeOne + 境外 Cloudflare
> 目标:中国大陆访客走腾讯 EdgeOne 节点加速,境外访客走 Cloudflare 边缘网络加速,共用同一域名与源站。
> 代码前置:`internal/shared/ip.go` 自 **2026-09-03** 起在 `trust_proxy = true` 时信任 EdgeOne 权威客户端头 `EO-Client-IP`(等效 `CF-Connecting-IP`)。单 EdgeOne 或双 CDN 部署均可直接用本文件配套。
---
## 0. 总体架构
```
┌──────────────── DNS(DNSPod 智能解析)─────────────────┐
│ 线路「境内」 → CNAME → EdgeOne 分配的 CNAME │
│ 线路「境外」 → CNAME → Cloudflare for SaaS CNAME │
│ 线路「默认」 → CNAME → Cloudflare(未匹配线路兜底) │
└───────────────┬───────────────────┬────────────────────┘
▼ ▼
EdgeOne(腾讯,大陆节点) Cloudflare(全球边缘)
│ │
└─────► 源站(仅放行两家回源网段)◄──────┘
TamaBox (Flamego) + PG + Redis
```
核心思路:**CDN 各自接入,DNS 按线路分发**。两家 CDN 都不需要托管你的主域名 NS,主域名 NS 全程留在 DNSPod(腾讯云解析),靠「境内 / 境外 / 默认」三条线路把用户分给不同的 CNAME 目标。
## 1. 前置条件(逐项确认)
| # | 条件 | 说明 |
|---|------|------|
| 1 | 域名已 **ICP 备案** | EdgeOne 加速区域含「中国大陆 / 全球可用区」时的硬性要求,不可绕开 |
| 2 | 腾讯云账号已实名 | 开通 EdgeOne 需要 |
| 3 | Cloudflare 账号 | Free 套餐即可(见步骤 3 的 SaaS 说明) |
| 4 | **一个闲置域名(或其子域)用于 Cloudflare 承载 zone** | Cloudflare for SaaS 需要一个已托管在 CF(NS 迁过去)的域名作为“承载 zone”,主域名不需要迁 NS。没有就考虑末尾的备选方案 C |
| 5 | 源站公网可达 | 回源协议 / 端口按你的部署调整 |
## 2. 步骤 1:DNS 迁到 DNSPod 并建立分线路骨架
1. 在 DNSPod(腾讯云解析)添加你的域名,按提示把注册商处的 NS 改成 DNSPod 提供的 `xxx.dnspod.net` 两条。
2. 等解析生效后,先**建立保底记录**,避免切换期中断:
- 线路「默认」:`www` → CNAME → EdgeOne(或先保持源站 A 记录,逐步切);
- 建议把各记录 **TTL 临时调小(如 60 秒)**,切换完成后再调回。
3. ⚠️ 若根域(`@`)承载邮箱(存在 MX 记录),**不能**给 `@` 配 CNAME(与 MX 冲突)。此时只对 `www` / 业务子域做 CDN 加速,`@` 保持 A 记录直连或做 301 跳转。
## 3. 步骤 2:EdgeOne 接入(CNAME 模式)
1. 控制台 → 添加站点 → 输入主域名 → 选购套餐 → **加速区域:中国大陆可用区**(境外交给 Cloudflare)。
2. **接入模式选 CNAME 接入**(主域名 NS 在 DNSPod,不能用 NS 接入)。
3. 归属权验证:按控制台提示在 DNSPod 添加 TXT 或做文件验证。
4. 添加加速域名:在「域名服务 → 域名管理 → 添加域名」里填业务主机名(`www` 或 `@`)+ 源站 IP/域名。
5. 平台分配一个 CNAME 地址(形如 `xxx.edgeone.net`),**先记下来**,步骤 5 统一填到 DNSPod。
6. 证书:开启「站点加速 → HTTPS」自动证书(免费)。
## 4. 步骤 3:Cloudflare for SaaS(主域名不迁 NS 的关键)
Cloudflare Free/Pro/Business 套餐的普通接入要求把整个 zone 迁到 CF 的 NS,与分线路解析冲突。**Cloudflare for SaaS**(Free 套餐含 100 个自定义主机名免费)允许主域名不迁 NS、仅用 CNAME 挂到 CF:
1. 用闲置域名(如 `cf-host.example`)在 Cloudflare 添加 zone,并把该域名的 NS 迁到 Cloudflare(承载 zone,仅此域名迁)。
2. 承载 zone 内:`SSL/TLS → Custom Hostnames(SSL for SaaS)→ Add Custom Hostname`,填入你的**主域名**(如 `www.example.com`)。
3. 按提示完成归属验证:CF 会给出一条验证 TXT(加到 DNSPod)+ 一个 **CNAME 目标值**(形如 `xxx.1234.cdn.cloudflare.net`),记下来用于步骤 5 的境外线路。
4. 等待证书签发至状态 `Active`(通常数分钟到数小时)。之后 CF 自动管理该域名的证书与续期。
> 备选:若不想多养一个域名做承载 zone,可退回**方案 C**——EdgeOne 加速区域直接选「全球可用区」,一家覆盖全球,不做 Cloudflare(代价:境外节点质量与覆盖面不如 CF)。或评估 Cloudflare Business(约 $200/月)的官方 CNAME 接入。
## 5. 步骤 4:DNSPod 分线路记录(最终形态)
同一主机名、不同线路分别建行(全部 CNAME,避免 A/CNAME 冲突提示):
| 主机记录 | 线路 | 类型 | 记录值 | 说明 |
|---|---|---|---|---|
| `www` | 境内 | CNAME | `xxx.edgeone.net`(EO 分配) | 大陆走 EdgeOne |
| `www` | 境外 | CNAME | `xxx.…cdn.cloudflare.net`(SaaS 分配) | 境外走 Cloudflare |
| `www` | 默认 | CNAME | `xxx.…cdn.cloudflare.net` | 未匹配线路(如部分境外 DNS)兜底 |
> 验证:境内 `dig www.example.com`(或 `nslookup`)应解析出 EdgeOne CNAME;用境外 DNS(如 `dig @8.8.8.8`)应解析出 Cloudflare CNAME。不同递归节点可能有缓存,等待 TTL 过期后复测。
## 6. 步骤 5:源站锁定(安全前提,不可跳过)
`trust_proxy = true` 意味着应用信任 CDN 写来的客户端头,**前提是请求只能来自这两家 CDN**:
1. 从官方渠道取回源网段并配置防火墙 / 安全组,仅放行:
- Cloudflare 官方 IP 段:https://www.cloudflare.com/ips/ (`cloudflare.com/ips-v4`、`ips-v6`)
- EdgeOne 回源 IP 段:腾讯云 EdgeOne 文档「回源 IP 网段」页(会动态更新,建议做成定期同步或至少配置时核对)
2. 放行你的运维入口(SSH 管理 IP)与回源端口(80/443),其余一律拒绝直连。
3. 回归验证:跑一次仓库自带的 `dev/security-selfcheck.sh`,确认带伪造 `CF-Connecting-IP` 的直连请求已被防火墙拦下(应无法到达应用)。
## 7. 步骤 6:真实 IP 传递(EdgeOne 侧关键配置!)
EdgeOne **默认不携带** `EO-Client-IP`,必须在控制台开启,否则经 EdgeOne 的请求到源站后拿不到客户端真实 IP(自动封禁会误伤所有大陆访客):
- 站点级:`站点加速(全局)→ 网络优化 → 客户端 IP 头部配置 → 开启`,**头部名称务必填 `EO-Client-IP`**(代码固定读这个名字,大小写敏感)。
- 或域名级:`规则引擎 → 创建规则(Host 匹配该域名)→ 操作:客户端 IP 头部 → 开启 → 名称填 `EO-Client-IP``。
Cloudflare 侧无需额外配置:`CF-Connecting-IP` 默认就有且恒被 CF 覆盖为真实客户端 IP。
## 8. 步骤 7:防“借道伪造”剥离规则(双 CDN 特有安全约束)
两家并存时出现新的伪造面:**攻击者借道 CDN-A 时携带 CDN-B 的权威头**。例如客户端经 Cloudflare 访问时自行塞一个 `EO-Client-IP: 任意IP`,若 CF 透传该头到源站,代码会信任它 → 可绕过限流 / 自动封禁 / 单 IP 投票限制,或嫁祸他人。因此必须在两家 CDN 的**回源请求**上剥离对方权威头:
| CDN | 剥离动作 | 位置 |
|---|---|---|
| Cloudflare | 回源前删除请求头 `EO-Client-IP` | `Rules → Transform Rules → Modify Request Header`(Free 套餐可用,规则数有限但只建一条即可) |
| EdgeOne | 回源前删除请求头 `CF-Connecting-IP` | `规则引擎 → 操作:修改回源请求头 → 删除 CF-Connecting-IP`(或站点全局回源请求头设置) |
> `X-Forwarded-For` / `X-Real-IP` 不需要在此剥离:代码只在 TCP 对端落入 `trusted_proxies` 时才解析它们,两家 CDN 边缘 IP 不在清单内,天然不可信。
>
> 验证方法:分别经两家 CDN 发起带伪造头的请求(如 `curl -H 'EO-Client-IP: 203.0.113.66' https://www.example.com/`),在源站访问日志里确认最终到达的请求**不包含**伪造的对方头,且应用记录的客户端 IP 为你的真实出口 IP。
## 9. 步骤 8:应用配置(conf/app.ini)
```ini
[security]
trust_proxy = true
; trusted_proxies 仅用于“你自己的”反代/LB(如本机 nginx 127.0.0.1),
; 双 CDN 场景不需要也不应把两家 CDN 的全量网段填进来(代码直接信任 CF-Connecting-IP / EO-Client-IP)。
trusted_proxies =
[server]
; 若用防火墙按网段放行回源,可继续监听 :443;若希望更严,可只监听本机 + 本机回源代理。
listen =
```
行为(2026-09-03 起):`trust_proxy = true` 时 `ResolveClientIP` 依次信任 `CF-Connecting-IP` → `EO-Client-IP` → (仅当 TCP 对端在 `trusted_proxies` 内)解析 XFF/X-Real-IP → 最后回退 RemoteAddr。
## 10. 步骤 9:缓存与动态内容
TamaBox 是动态应用:
- 两侧都**不要缓存 HTML / 页面 / API 响应**;只缓存静态资源。
- EdgeOne:缓存规则命中 `/static/*` 与带版本号资源即可;
- Cloudflare:`Caching → Cache Rules` 对 `/static/*` 开缓存,其余 `Bypass`。
- 若将来启用 WebSocket 长连接:Cloudflare Free 支持;EdgeOne 需在站点配置里显式开启 WebSocket。
- 注意两侧的 TLS 版本 / HTTP/3 行为差异属正常现象,与应用无关。
## 11. 上线切换与回滚
1. 先切「默认」线路 → 观察 15~30 分钟(错误率、日志中的真实 IP 是否正确)。
2. 再切「境内」线路(EdgeOne CNAME 生效)→ 用大陆网络(或大陆 DNS 递归查询)验证。
3. 最后切「境外」线路到 Cloudflare SaaS CNAME → 境外访问验证 + 证书状态 `Active`。
4. 回滚:把对应线路的 CNAME 改回原记录即可,秒级生效(DNS TTL 决定传播速度)。
## 12. 故障排查速查
| 现象 | 检查 |
|---|---|
| 大陆访问慢 / 未走 EO | DNSPod「境内」线路是否已配 EO CNAME;`nslookup` 从大陆递归查询结果 |
| 境外访问失败 / 证书错误 | SaaS Custom Hostname 状态是否 `Active`;境外线路 CNAME 是否拼错 |
| 自动封禁误封 / 限流全走同一个 IP | EO「客户端 IP 头部」是否开启且名称是否为 `EO-Client-IP`;`trust_proxy` 是否 true |
| 应用日志里出现伪造 IP | 两家剥离规则是否生效;防火墙是否真的挡住了直连 |
| 根域无法配 CNAME | 根域有 MX → 只加速 `www`/业务子域,`@` 保持 A 记录 |
## 13. 相关代码与文档
- 代码改动:`internal/shared/ip.go`(`EO-Client-IP` 信任分支)、`internal/shared/ip_test.go`(新增 4 条用例)、`internal/conf/static.go` 与 `conf/app.sample.ini`(注释同步)。
- 单 CDN Cloudflare 加固参考:`outputs/cloudflare-origin-lockdown-guide.md`。
- EdgeOne 客户端 IP 头部(腾讯官方文档,会更新):「携带客户端 IP 头部回源」。
- Cloudflare for SaaS 官方文档:developers.cloudflare.com → SSL/TLS → SSL for SaaS。
-252
View File
@@ -1,252 +0,0 @@
# TamaBox 免备案双线接入指南(EdgeOne 国际站 + Cloudflare Tunnel)
> **适用场景(2026-09-03 定稿)**:不备案。国内用户走腾讯 EdgeOne 国际站(免备案加速区域「全球可用区(不含中国大陆)」,大陆用户落到香港等最近海外节点,跨域骨干质量优于 CF);海外用户继续走现网 Cloudflare(`box.tama.guru` 已是 `337845818.best` zone 的 SaaS 自定义主机名 + Cloudflare Tunnel 回源,已正常生效)。
>
> **配套代码**:`internal/shared/ip.go` 已支持 EO-Client-IP(境内分支)与 CF-Connecting-IP(海外分支),`trust_proxy=true` 时双头同权信任,无需维护 CDN 大 CIDR 清单。
> ## 🔴 重要勘误(2026-09-03 三次更正定稿)——权威在 DNSPod;分线路已确认生效
>
> ⚠️ **历次结论演进**:
> 1. 初版「权威在 Cloudflare → 需子域委派」**作废**(用户 CF 没有 tama.guru zone,正常——它本来就不该有);
> 2. 二次更正(RDAP 直连 `.guru` 注册局 Identity Digital 铁证):`tama.guru` 注册局 NS 委托 = **`byron.dnspod.net` + `sunshine.dnspod.net`(DNSPod)**。此前误判因 apex `tama.guru` 自身 CNAME → `navi-ew2.pages.dev`(CF Pages),nslookup 跟随 CNAME 链误读;
> 3. **三次更正(12:10 最终确认)**:用户 DNSPod 面板已配好三线路(境内→EO / 境外→CF 优选 `cloudflare.182682.xyz` / 默认→CF 优选),NS=byron/sunshine——**分线路确认生效**:
> - **境外视角**(两家境外服务器 API 实测:hackertarget / networkcalc)→ `CNAME cloudflare.182682.xyz` → CF IP(104.x)✅
> - **境内视角**(223.5.5.5 / 119.29.29.29 / alidns DoH 稳定查询)→ `CNAME eo.dnse4.com` → EO IP(43.174.x)✅
> - 此前「全球四视角全部 EO」的实测是**假象**:大陆本机发往境外公共 DNS(8.8.8.8 / 9.9.9.9 / OpenDNS / 1.0.0.1,UDP 与 TCP 均)的查询被网络劫持/污染,应答按境内线路给出 EO。⚠️ **大陆本机 nslookup 境外 DNS 的结果不可信**,验证分线路必须用境外服务器 API 或境外网络(方法见 2.3 第 4 步)。
> 4. EO 归属验证若依赖 CNAME 自动校验,仍建议改 TXT 验证并把 TXT 放 DNSPod(防止分线路后续期失败)——此条仍适用。
---
## 0. 已核实事实(官方口径)
| 项 | 结论 |
|---|---|
| EdgeOne 加速区域与备案 | 「中国大陆可用区」「全球可用区」**必须备案**;「全球可用区(不含中国大陆)」**无需备案**(国际站同规则) |
| 不备案时大陆用户走哪 | 无大陆节点,就近落香港/日韩/新加坡等海外节点;香港节点走腾讯跨境骨干,大陆延迟通常显著优于 Cloudflare 免费版(仍建议实测) |
| Cloudflare for SaaS | 免费额度 100 个自定义主机名;`box.tama.guru` 已配置并正常打开,海外分支**零改动** |
| EdgeOne EO-Client-IP | **默认关闭**,需手动开启;自定义头部名必须填 `EO-Client-IP`(代码固定读此名) |
| 应用真实 IP 信任 | 已支持:境内→EO-Client-IP;海外→CF-Connecting-IP |
| **回源 IP 段官方入口** | 控制台「**安全防护 → 源站防护**」启用后展示回源 IP 列表(IPv4/IPv6 段);段变更前 14/7/3/1 天站内信+短信+邮件通知;可用 `DescribeOriginACL` API 每 3 天轮询自动同步(intl 文档 1145/48535) |
| **⚠️ 免费套餐限制** | **免费套餐不支持启用「源站防护」**(2026-03 官方答疑)→ 拿不到官方回源段。出路见 2.2「回源网段三选一」 |
| **🔴 DNS 分线路实测(2026-09-03 三次更正定稿)** | `tama.guru` 权威 NS = **DNSPod**(注册局 RDAP:`byron.dnspod.net` + `sunshine.dnspod.net`);CF 无此 zone。三线路(境内→EO / 境外→CF / 默认→CF)**已确认生效**:境外服务器 API → CF(104.x),境内通道 → EO(43.174.x)。⚠️ 大陆本机 nslookup 境外 DNS 会被劫持假返回 EO,不可用作境外视角 |
---
## 1. 目标拓扑
```
┌────────────────────────────────────────────┐
│ DNSPod 托管 tama.guru 整域 zone(权威) │
│ box 记录按线路分流: │
│ 中国境内 ──CNAME──▶ EdgeOne CNAME 目标 │
│ 默认 / 国外 ──CNAME──▶ CF SaaS CNAME 目标 │
└───────┬─────────────────────────────┬──────┘
大陆用户▶▶────────┘ │ 海外用户▶▶
(大陆 ISP 递归命中境内线路) │
▼ ▼
EdgeOne 国际站 Cloudflare 边缘
区域: 全球(不含中国大陆) SaaS 自定义主机名 box.tama.guru
HTTPS 免费证书自动签发 (挂在 337845818.best zone)
│ EO 回源(直连源站公网端口) │ Tunnel(源站主动出站)
▼ ▼
┌────────────────────────────┐ cloudflared(本机/内网)
│ 源站防火墙:仅放行 EO 回源网段│◀──────────────(无入站端口,不受影响)
└───────────────┬────────────┘
▼
TamaBox 应用(监听公网回源端口)
```
**安全前提(缺一不可,否则头信任模型可被绕过):**
1. 源站防火墙只对 **EdgeOne 官方回源网段** 开放回源端口(默认拒绝一切);
2. EO 侧开启「客户端 IP 头」且名称 = `EO-Client-IP`;
3. EO 规则引擎**删除**客户端可伪造的 `CF-Connecting-IP` / `X-Real-IP`(否则大陆攻击者经 EO 带假 CF 头会被应用优先信任);
4. `conf/app.ini` 的 `[security]` 设 `trust_proxy = true`。
> ⚠️ **前提 1 与套餐强相关**:官方回源段只有启用「源站防护」才能拿到,而**免费套餐不支持该功能**。免费版请先读 2.2 的「回源网段三选一」选定替代路径,再继续配置。
> 海外分支天然安全:攻击者只能经 Cloudflare 边缘进来,CF 会用自己的观测值重写 `CF-Connecting-IP`,客户端伪造无效;Tunnel 到源站为出站连接,源站无对应入站口。
---
## 2. 分步实施
### 2.1 EdgeOne 接入(国际站 edgeone.ai)
1. 登录 EdgeOne 国际站控制台 → **添加站点**:
- 站点域名:`box.tama.guru`(直接用子域做站点,避免影响 `tama.guru` 其他记录);
- 加速区域:**「全球可用区(不含中国大陆)」**(勿选含中国大陆的区域——未备案无法添加,选错会要求备案);
- 接入方式:**CNAME 接入**(保留 DNSPod 分线路能力)。
2. **归属验证选「DNS / TXT 验证」**(不要选要求整体 CNAME 的方式):按控制台提示在 DNSPod 为 `box.tama.guru` 添加指定 TXT(或指定子域名的 CNAME),验证通过后**该验证记录保留勿删**(续期/校验会用到)。
3. HTTPS:开启自动免费证书(默认开启)。
4. 状态变「已生效」后,记下控制台分配的 **CNAME 目标**(形如 `<hash>.<region>.edgeone…`,以控制台显示为准),供 2.3 使用。
5. **开启客户端 IP 头**:站点 → 网络优化 / 规则引擎(套餐不同路径略有差异)→「增加客户端 IP 请求头」类功能 → 开启,并把头部名**务必填 `EO-Client-IP`**。
6. **配置回源**:源站地址 = 服务器公网 IP;端口 = 2.2 选择的回源端口(优先高位非标端口,若控制台仅允许 80/443 则用 443 + 源站 TLS,见 2.2);回源 HOST = `box.tama.guru`;协议建议与现状一致(HTTP,由 EO 边缘终结 TLS)。
7. **(可选加固)回源鉴权**:若套餐支持「回源鉴权 / 自定义回源头」,加一个仅源站知道的头(如 `X-EO-Auth: <随机串>`),源站入口校验,作为防火墙白名单之外的纵深兜底。
### 2.2 源站监听公网口(不需要 nginx)与回源锁定(关键安全步骤)
**不需要 nginx/Caddy**:TamaBox 自带 HTTP 服务器,直接让应用监听公网端口即可——nginx 在整套方案里只是可选的「TLS/反代」环节,不是安全环节,低配服务器完全不必装。系统防火墙(ufw/iptables/firewalld)是内核 netfilter 规则,无常驻高内存进程,也不占资源。
1. **改监听地址**:`conf/app.ini` 的 `[server]` 段:
```ini
[server]
port = 18080 ; 换高位非标端口,别用 80/443
listen = 0.0.0.0:18080 ; 关键:从「仅本机」改为「全网卡」,EO 才能远程连入
```
> 说明:此前 Cloudflare Tunnel 单线部署建议 `listen = 127.0.0.1:port`(只对本机 cloudflared 可见);接 EO 直连后必须改为 `0.0.0.0:port`(或删掉 `listen` 留空 = 默认 `:port` 全网卡)。**Tunnel 分支不受影响**——cloudflared 连的是 `127.0.0.1:port`,`0.0.0.0` 监听兼容回环连入。改完重启应用。
> 端口限制:EO 回源端口是否允许高位非标以控制台为准;若只允许标准端口集合,再考虑在 `443` 前置 TLS(届时才需要 Caddy/nginx 或改用 HTTPS 回源)。
2. **EO 回源协议选 HTTP**(TLS 由 EO 边缘终结,与现在 Tunnel 到本机 HTTP 的形态一致)。注意 EO 边缘→源站这段是公网明文,可接受则用,介意再改 HTTPS 回源。
#### 回源网段三选一(决定安全模型)
**背景**:官方回源 IP 段只在「源站防护」功能里给,**免费套餐不支持启用**——这正是你在控制台找不到列表的原因。三条出路:
| 方案 | 成本 | 做法 | 风险 / 说明 |
|---|---|---|---|
| **A. 官方源站防护(推荐)** | EO 基础版以上(以控制台计费为准) | 控制台 → 站点 → **安全防护 → 源站防护** → 启用 → 复制回源 IP 列表(IPv4/IPv6)配防火墙;变更前 14/7/3/1 天会站内信/短信/邮件通知;也可用 `DescribeOriginACL` API 每 3 天轮询自动同步 | 最标准,段变更有官方通知兜底 |
| **B. 观察法自建 ACL(免费)** | 免费 | ① 先把回源端口临时放开(或安全组先放行);② 源站抓 3–7 天回源连接来源:`tcpdump -nn port 18080` 或看应用日志里的对端 IP;③ 把来源聚类成 CIDR;④ 防火墙收紧为只放行这些段;⑤ **每月自查一次**来源是否有新增 | ⚠️ EO 回源段平均 3–6 个月会变,免费档收不到变更通知 → 段变化会导致回源失败;出故障先临时全放再排查。低流量个人站可接受 |
| **C. 不设白名单(不推荐)** | 免费 | 仅靠随机高位端口 + 应用层 Host 校验 | 🔴 源站 IP 一旦泄露即被绕过 CDN 直连,且可伪造 `EO-Client-IP`/`CF-Connecting-IP` 绕过限流/封禁。若仍要走,需先做代码加固:让 ip.go 只信任来自 `trusted_proxies`/回环对端的 CDN 头(另行评审) |
**防火墙(ufw 示例,云安全组同理;按 A 或 B 得到的段列表执行):**
```bash
ufw default deny incoming
ufw allow from <EO_CIDR_1> to any port 18080 proto tcp
ufw allow from <EO_CIDR_2> to any port 18080 proto tcp
# …全部 EO 段放行完毕后,确认没有 0.0.0.0/0 放行 18080
# ssh 等管理端口勿对公网裸开(建议仅内网/跳板)
```
**验证「直连被拒」**:用一台与 EO 无关的外网 VPS 执行 `nc -zv <服务器IP> 18080`,应 `refused / timeout`;随后通过 EO 加速 URL 访问,确认回源返回 200。
**说明**:cloudflared(Tunnel)不受本次开端口影响——它始终是源站**主动出站**连 Cloudflare,不依赖任何入站规则。
### 2.3 DNS 分线路改造(权威在 DNSPod,直接配置;**无需任何委派**)
> **2026-09-03 三次更正后的最终认知(12:10 实测确认)**:注册局 RDAP 铁证 `tama.guru` 权威 NS = `byron.dnspod.net` + `sunshine.dnspod.net`(DNSPod),Cloudflare 从未托管此 zone。DNSPod 面板的**三线路配置(境内→EO / 境外→CF / 默认→CF)已确认生效**(境外服务器 API 实测 → CF;境内通道实测 → EO)。本节仅需**核对面板与下表一致**;不一致时按下表改。
#### 第 1 步:核对 DNSPod 域名列表(关键,避免改错地方)
登录腾讯云解析 DNSPod → 域名列表,确认:
- ✅ 存在 **`tama.guru`**(整域 zone,权威 NS = byron/sunshine.dnspod.net 所在);
- ⚠️ **若还存在一个独立的 `box.tama.guru` 子域 zone**(之前按旧方案误建的)——它不生效且会造成混淆:在 DNSPod 删除它(或忽略它,把配置改到 tama.guru 整域下);
- ⚠️ 确认不是登录错账号/域名在另一个 DNSPod 账号下。
> 判别方法:DNSPod 面板上 tama.guru 域名详情页底部会显示「NS 记录:byron.dnspod.net / sunshine.dnspod.net」;若显示的不是这组,说明域名不在当前账号。
#### 第 2 步:在 tama.guru 整域 zone 里配置 box 记录(主机记录填 `box`)
进入 `tama.guru` → 记录管理,找到/新建主机记录 `box`,**分线路三行**:
| 线路 | 类型 | 记录值 |
|---|---|---|
| **中国境内** | CNAME | `box.tama.guru.eo.dnse4.com`(EdgeOne CNAME 目标,2.1 控制台核对) |
| **境外** | CNAME | **CF SaaS CNAME 目标**(见下) |
| **默认** | CNAME | 同境外(兜底,覆盖未识别线路) |
- ✅ **用户面板实际状态(2026-09-03 12:07 核对)已符合上表,无需改动**:默认 → `cloudflare.182682.xyz`(2025-11-20 建)、境外 → `cloudflare.182682.xyz`(10:54 建)、境内 → `eo.dnse4.com`(11:29 建),NS = byron/sunshine.dnspod.net。若你的面板只有单条「默认 CNAME → EO」,则把它**编辑为线路=中国境内**,再新增境外/默认两条指向 CF。
- CF SaaS CNAME 目标获取:CF 控制台 → zone `337845818.best` → **SSL/TLS → Custom hostnames**(for SaaS)→ `box.tama.guru` 详情里的 **CNAME target**(形如 `<hash>.cdn.cloudflare.net`)。若用第三方「优选 IP」域名,填你的优选 CNAME 亦可(保证解析到 CF 边缘)。
- TTL:`120`~`600`(便于回滚)。
- ⚠️ DNSPod 免费版**支持**境内/境外/默认分线路(基础分线路免费;运营商级细分才收费)。
#### 第 3 步:⚠️ EO 归属验证方式的适配(重要,防止证书续期失败)
- EO 站点若靠「主记录 CNAME → EO 目标」自动验证归属,分线路后**境外/默认线路不再返回该 CNAME**,EO 周期性校验会失败。
- 对策:EO 控制台把验证方式改为 **TXT 验证**(EO 会给一条形如 `_<xxx>.box.tama.guru → TXT <值>` 的验证记录),把这条 TXT **加到 DNSPod 的 tama.guru zone**(主机记录 `_<xxx>`,线路=默认,全球可见);box 的境内线路 CNAME 保持指向 EO 不动。
#### 第 4 步:验证分线路(⚠️ 大陆本机 nslookup 境外 DNS 不可信)
大陆本机向境外公共 DNS(8.8.8.8 / 9.9.9.9 / OpenDNS 等)的查询会被网络劫持/污染,**必然假返回境内线路的 EO**——勿用它判断境外线路。正确方法:
```bash
# 境内视角(本机即可,通道可信)
nslookup box.tama.guru 223.5.5.5 # 期望 → eo.dnse4.com / 43.174.x(EO)
curl -s "https://dns.alidns.com/resolve?name=box.tama.guru&type=A" # 阿里 DoH(HTTPS),同上
```
```text
# 境外视角:用境外服务器 API(浏览器打开即可,2026-09-03 已实测)
# https://api.hackertarget.com/dnslookup/?q=box.tama.guru
# https://networkcalc.com/api/dns/lookup/box.tama.guru
# 期望:CNAME cloudflare.182682.xyz → 104.x / 172.x(CF 边缘 IP)
```
境内 EO + 境外 CF = 分线路成功(2026-09-03 12:10 已按此确认)。若在境外 VPS / 境外网络上,直接 `nslookup box.tama.guru 8.8.8.8` 亦可。
#### 回滚
把 box 的境外/默认线路 CNAME 改回(或删除,只留境内→EO)即回到全量 EO;或把三条都指向 CF 即全量 CF。分钟级生效,见 §5。
### 2.4 Cloudflare 侧(零改动——CF 没有 tama.guru zone,也无需有)
- **无需在 CF 做任何 DNS 操作**:`tama.guru` 的解析权在 DNSPod,CF 控制台不会、也不应出现该 zone。之前「父 zone 委派」方案基于错误前提,**作废**。
- **保持不动**:`337845818.best` zone 的 SaaS 自定义主机名 `box.tama.guru`(证书 + Tunnel 路由)、Tunnel 本身、承载域记录。它们只依赖「有流量带着 `Host: box.tama.guru` 到 CF 边缘」,与 DNS 记录归属无关——DNSPod 分线路把境外流量指到 CF 边缘 IP/CNAME 后,CF 按 Host 自动匹配到该自定义主机名并走 Tunnel 回源。
- **可选纵深**:在 `337845818.best` zone 加一条 Transform Rule「删除请求头 `EO-Client-IP`」——防海外用户经 CF 时把伪造 EO 头带入源站。当前代码因「CF-Connecting-IP 优先」已天然免疫,此规则属可选项。
- **无环路风险**:EO 回源目标是你的源站 IP,不会指回 CF SaaS 域名;CF 分支回源走 Tunnel(出站),互不干扰。
### 2.5 应用配置
`conf/app.ini`:
```ini
[server]
port = 18080
listen = 0.0.0.0:18080 ; EO 直连需要公网可达(纯 Tunnel 时期是 127.0.0.1:port,接 EO 后必须改)
[security]
trust_proxy = true ; 信任 EO-Client-IP / CF-Connecting-IP
```
无需维护 CDN 大 CIDR 清单(安全闭环依赖 2.1~2.2 的防火墙锁定 + 头剥离前提;免费版走观察法方案 B 时同理,前提是端口只对观测到的 EO 来源开放)。
---
## 3. 头信任矩阵(安全模型核对)
| 流量路径 | 到达源站的相关头 | 应用采用 | 伪造风险与对策 |
|---|---|---|---|
| 大陆:用户 → EO → 源站 | `EO-Client-IP`(EO 开启后写入真实客户端 IP);客户端自带的假 `CF-Connecting-IP` 被 EO 规则删除 | `EO-Client-IP` | 若 EO 未开启该功能或未删 CF 头 → 应用会拿到空/伪造 IP,**此为前提 2、3,必须配置** |
| 海外:用户 → CF 边缘 → Tunnel → 源站 | `CF-Connecting-IP`(CF 边缘以观测值重写);客户端伪造的 `EO-Client-IP` 也被透传但被忽略 | `CF-Connecting-IP` | CF 重写不可伪造;代码 CF 优先故天然免疫 |
| 攻击者假设绕过防火墙直连 | 可自带任意头 | — | 防火墙拒绝,到不了应用;纵深:回源 HOST 校验 + 可选 Token |
---
## 4. 上线验证清单
| # | 检查项 | 通过标准 |
|---|---|---|
| 0 | DNS 分线路 | 境内视角(本机):`nslookup box.tama.guru 223.5.5.5` → EO(eo.dnse4.com / 43.174.x);境外视角:境外服务器 API(hackertarget / networkcalc,方法见 2.3 第 4 步)→ CF(104.x)。⚠️ 大陆本机 `nslookup 8.8.8.8` 会被劫持假返回 EO,不可用 |
| 1 | 境内访问 | 大陆节点访问 `https://box.tama.guru` 正常,浏览器证书为 EO 签发;源站/EO 访问日志中客户端 IP = 你的大陆公网出口 IP(说明 `EO-Client-IP` 生效) |
| 2 | 海外访问 | 海外访问正常,日志客户端 IP = 海外出口 IP(CF-Connecting-IP 生效) |
| 3 | 直连封锁 | 外网 VPS `nc -zv <IP> <回源端口>` 拒绝/超时 |
| 4 | 伪造头 | 境内客户端手动带 `CF-Connecting-IP: 1.2.3.4` 请求,日志仍记录真实 IP(EO 删除规则生效) |
| 5 | 限流/封禁联动 | 用 EO 路径触发限流,确认按真实 IP 计数而非 EO 节点 IP |
| 6 | WebSocket/上传 | 若用到 WS:EO 站点需显式开启 WebSocket(CF 侧 Tunnel 原生支持);大体积上传实测通过 |
| 7 | 证书 | 大陆/海外浏览器均无证书告警 |
---
## 5. 回滚顺序
1. **快速回滚(恢复访问)**:DNSPod 里把 box 的「境外/默认」线路临时指向 EO 目标(或全线路都指向 EO)→ 全球走 EO,分钟级生效;
2. **彻底回滚(回到纯 CF 时代)**:DNSPod 里把 box 三条线路统一改为 `CNAME → <原 CF SaaS 目标 / 承载记录>`(全球走 CF);
3. 确认 CF 路径正常后,再逐步收尾:关 EO 客户端 IP 头、撤防火墙放行、删除 EO 站点。
4. ⚠️ 回滚后 EO 归属验证记录(若已按 2.3 第 3 步迁为 TXT)保持在 DNSPod 即可随线路变化继续有效;若 EO 站点还保留,按 EO 控制台提示重新验证。
---
## 6. 合规与风险提示
- 未备案域名面向大陆提供服务:无大陆节点(合规红线决定了这一点),且存在被运营商/监管按未备案域名处置(如阻断)的风险。本方案仅为技术可达路径,业务风险需自行评估。
- 若日后完成备案:EO 加速区域可改「全球可用区」(含大陆节点),大陆延迟进一步下降,DNSPod 分线路结构无需变动,只改 EO 区域并复验。
- `.guru` 等新顶级域在大陆解析/合规环境的稳定性低于 `.com`,如遇解析异常优先查 DNS 污染与线路命中。
---
## 7. 常见问题
- **`EO-Client-IP` 一直没出现?** 功能默认关闭且可自定义头名,需在 EO 侧开启并确认名称一字不差;另外只有走 EO 的流量才会带此头。
- **从大陆本机 `nslookup box.tama.guru 8.8.8.8` 返回 EO,是不是境外线路没生效?** ⚠️ **不是,别被误导**——大陆本机发往境外公共 DNS 的查询会被网络劫持/污染,应答按境内线路给出(EO),UDP 与 TCP 均如此。境外线路是否生效要用**境外服务器视角**验证:浏览器打开 `https://api.hackertarget.com/dnslookup/?q=box.tama.guru` 或 `https://networkcalc.com/api/dns/lookup/box.tama.guru`,返回 `cloudflare.182682.xyz` → 104.x 即境外→CF 生效(2026-09-03 已实测确认)。若境外 API 真返回 EO,才需要回 DNSPod 面板核对 box 记录三行是否齐全、线路字段是否正确(境内→EO / 境外→CF / 默认→CF)、是否误建了未委派的 `box.tama.guru` 子域 zone。
- **DNSPod 面板该核对哪些字段?** ① `box` 记录应有三行且线路分别是:境内(值 `box.tama.guru.eo.dnse4.com`)、境外(值 CF 优选 `cloudflare.182682.xyz` 或 CF SaaS CNAME target)、默认(同境外);② 域名列表只有 `tama.guru` 整域 zone(权威 = byron/sunshine.dnspod.net,域名详情页可见 NS),不应有独立的 `box.tama.guru` 子域 zone;③ 权威归属可用注册局 RDAP 自查(`https://rdap.org/domain/tama.guru` 应返回 byron/sunshine.dnspod.net)。
- **HSTS 报错「无法访问…使用了 HSTS」?** 应用在 ExternalURL 以 https 开头时对响应下发 `Strict-Transport-Security`(1 年,includeSubDomains),浏览器在 HTTPS 时代已记录。切 CDN 后若边缘证书与域名不匹配(如 EO 返回兜底证书 `*.cdn.myqcloud.com`)即触发此页。根因是**新 CDN 证书尚未签发/部署**,等证书正确即自动恢复(域名不在 preload 列表时无需清浏览器);排查命令:`echo | openssl s_client -connect box.tama.guru:443 -servername box.tama.guru \| openssl x509 -noout -subject`,subject 应含 `box.tama.guru`。
- **只放行 EO 段后业务断了?** 大概率回源 IP 段列表不全或已更新:先临时放行全段定位,再按最新清单收紧(付费档以官方源站防护通知为准;免费观察法每月自查来源 IP)。
- **找不到回源 IP 段列表?** 那是「源站防护」功能里的展示项,而**免费套餐不支持启用**——见 2.2「回源网段三选一」。
- **服务器太差不想跑 nginx,直接让服务监听公网端口行不行?** 行,而且推荐这么做:TamaBox 自带 HTTP 服务器,nginx 本就不是必需。只需把 `[server] listen` 改为 `0.0.0.0:port` 并让 EO 回源直连该端口;安全由防火墙网段白名单承担,与是否装 nginx 无关。
- **应用出现 http 跳转 / 非 Secure Cookie?** EO 回源为 HTTP 时应用看到的是 http scheme,与 Tunnel 现状一致。若应用按 scheme 生成链接或下发 Secure Cookie 且行为异常,改为 HTTPS 回源或让应用信任 `X-Forwarded-Proto`。
- **两家 CDN 缓存冲突?** TamaBox 为动态应用,两侧都保持默认不缓存 HTML/API,仅缓存 `static/` 资源。
-299
View File
@@ -1,299 +0,0 @@
# EdgeOne 回源端口 iptables 白名单自动维护指南
> **交付物**:`scripts/edgeone-origin-whitelist.sh`(Linux / iptables + ipset)
> **适用**:TamaBox 源站对公网开放了 EO 回源端口(免备案双线架构的 EO 分支),想把「开放 IP + 端口」收敛为「仅 EdgeOne 可访问」。
> **更新日期**:2026-09-03
---
## 0. 先泼一盆冷水:官方清单是付费功能
你要的「定期获取 EdgeOne IP 列表」——**免费套餐下官方不提供可程序化获取的渠道**:
| 事实 | 说明 |
|---|---|
| 官方唯一清单来源 = 「源站防护 / Origin ACL」 | EO 控制台 → 安全防护 → 源站防护,启用后展示回源 IPv4/IPv6 网段,变更前 14/7/3/1 天通知 |
| **仅付费套餐支持** | 官方文档明确「origin protection 仅支持绑定付费套餐的站点,free edition 不支持」(腾讯文档 1552/76086)——这就是你在控制台找不到入口的原因 |
| 自动化 API 存在但同样付费 | `DescribeOriginACL`(teo.intl.tencentcloudapi.com,国际站),每 3 天低频轮询,`NextOriginACL` 非空即同步并调确认接口(文档 71118) |
**所以本脚本的现实定位**:不依赖官方 API,以「本地/远程 CIDR 清单」为骨架,免费档立刻可用;日后升级付费套餐,只需把 API 拉取结果落到清单文件,脚本零改动继续工作(见 §7)。
> ### 🔴 0.1 2026-09-03 实测定稿:免费档请直接用「段级放行」,IP 级采种已证明不可行
>
> 曾设计 learn/auto 从连接表逐个收集 EO 出口 IP 作白名单,**服务器实测(tcpdump 取证)否决了该路线**:
>
> - EO 回源出口是**节点池,规模数百上千**,且**同一时刻在多段轮换**——一次 23 秒采样就看到 13 个不同出口(分布在 `43.146/43.152/43.168/43.174/43.175` 五段),其中 9 个因不在白名单被 REJECT → 表现为境内**间歇性 521**(EO 边缘回源失败)。
> - 结论:**IP 级清单 = 打地鼠**,永远追不上。免费档唯一现实可行方案是**按腾讯云大段放行**(脚本原生支持 CIDR,无需任何改动):
>
> ```bash
> # /etc/edgeone-origin/cidr.txt(2026-09-03 实测生效配置,521 消失)
> 43.146.0.0/16
> 43.152.0.0/16
> 43.168.0.0/16
> 43.174.0.0/16
> 43.175.0.0/16
> ```
>
> - learn/auto 在段级模式下**降级为「新段预警」辅助**:auto 采到清单外的 IP → `whois` 反查归属 → 若属腾讯云 EO 新段,把整段 `/16` 补进清单。首次采种不再需要 learn(§3.1 的逐 IP 采种流程整体退役)。
> - 代价说明:`/16` 段内任意腾讯云主机可直连该回源端口(比全开安全、比精确 ACL 松),对 TamaBox 这类自身带鉴权的应用风险中等、可接受(§8)。
---
## 1. 脚本做什么
把回源端口从「全网开放」收敛为白名单:
```
INPUT 链(默认仅 2 条规则,其余不动)
├─ -p tcp --dport 18080 -i lo -> ACCEPT ← cloudflared Tunnel 本机回连(回环放行)
└─ -p tcp --dport 18080 -i eth0 -> EO_IN ← 仅公网网卡 eth0 入站才判定
EO_IN 自建链(一次性建立,内容恒定)
├─ EO_ORIGIN 集合命中 -> ACCEPT ← EdgeOne 回源(白名单)
└─ 其余 -> REJECT tcp-reset ← 直连者秒断
(网卡名不是 eth0 请改脚本头 EO_IFACE;留空=不限网卡,回环由 EO_IN 内 127.0.0.0/8 兜底)
```
**两个关键设计,保证不会把自己锁死、不会误伤 CF 分支:**
1. **更新零窗口**:白名单存于 ipset 集合 `EO_ORIGIN`,更新走「临时集合填充 → `ipset swap` 原子替换 → 销毁临时集」;iptables 规则永远引用同名集合、从不重建。同步过程中不存在「规则被清空」的瞬间。
2. **cloudflared 不受影响**:Tunnel 是 cloudflared 从本机 `127.0.0.1` 连回源端口,INPUT 里有独立的 `-i lo` ACCEPT 放行回环(与 eth0 网卡限定解耦),所以**即使防火墙收紧,CF 分支依然正常工作**——这正是裸 iptables 白名单最容易踩的坑(有人把 18080 一刀切 REJECT,Tunnel 分支跟着全挂)。
其余保障:清单为空/拉取失败/全非法 → **拒绝更新、保留现网规则**(fail-safe);支持 `--dry-run` 演练;全程日志 `/var/log/eo-origin-whitelist.log`。
---
## 2. 部署前确认两件事
1. **确认你的实际回源端口**(脚本默认 `18080`,务必改成实际值):
```bash
ss -tlnp | grep -iE 'tamabox|LISTEN' # 找 TamaBox 监听的端口
```
或看 `conf/app.ini` 的 `[server] port`。改脚本文件头 `EO_PORT=18080` 那一行,或运行时 `sudo EO_PORT=xxxx ./edgeone-origin-whitelist.sh sync`。
2. **确认系统有 ipset**(白名单原子替换依赖它):
```bash
command -v ipset || sudo apt-get install -y ipset # Debian/Ubuntu
command -v ipset || sudo dnf install -y ipset # RHEL/CentOS/Fedora
```
3. **确认公网网卡名**(脚本默认只对 `eth0` 生效):
```bash
ip route get 1.1.1.1 | awk '{print $5; exit}' # 输出如 eth0 / ens3 / enp1s0
```
输出不是 `eth0` 时,改脚本头 `EO_IFACE=` 或运行时覆盖:`sudo EO_IFACE=ens3 ./edgeone-origin-whitelist.sh sync`。⚠️ 网卡名不对 → 跳转规则永不命中 → 端口等于裸奔。
---
## 3. 初始化:生成种子清单(免费档必经步骤)
> 🔴 **先读 §0.1(实测定稿)**:免费档**不要**再做逐 IP 采种(EO 出口是海量轮换节点池,IP 级白名单实测导致间歇 521)。直接按 §0.1 的五个 `/16` 段建清单即可;learn/auto 只留作「新段预警」,见 §3.1 末注。
### 3.1 首选:腾讯云大段放行(实测定稿)
按 §0.1 直接放行 EO 所属的腾讯云大段(免费档没有官方回源段 API,而逐 IP 采种已被实测否决)。清单内容(2026-09-03 服务器实测生效):
```bash
sudo mkdir -p /etc/edgeone-origin
# 每行一个 CIDR,# 开头为注释。以下为实测生效配置:
sudo tee /etc/edgeone-origin/cidr.txt >/dev/null <<'EOF'
# EdgeOne 回源段(腾讯云,2026-09-03 实测;境内 EO 分支访问验证正常)
43.146.0.0/16
43.152.0.0/16
43.168.0.0/16
43.174.0.0/16
43.175.0.0/16
EOF
```
> 段怎么来的:用 `auto`(或 tcpdump 抓 SYN)持续观察回源出口,把出现过的 IP 归并到所属腾讯云段(whois 查 ASN/归属确认),整段放行。上面五个 `/16` 来自多次实测采样(新加坡/腾讯云 EO 出口遍布这些段)。
>
> 🔴 段不是永恒不变的:EO 可能启用清单外的新段(罕见、以月计)。每月跑一次 `auto` 看采样里有没有清单外的 IP,`whois <IP>` 确认属腾讯云 EO 后把整段补进 cidr.txt 再 sync(详见 §4.4)。
### 3.2 备选:向 EO 索取回源段
免费档控制台无源站防护页面,但可尝试**提交工单索取当前回源 IP 段**(客服可能以"尽力提供"形式给出);拿到后把 CIDR 逐行放进清单文件即可,效果等同。日后升级付费套餐启用源站防护,官方段可直接用(§7)。
### 3.3 首次启用(务必先 dry-run)
```bash
# 1. 建清单文件
sudo mkdir -p /etc/edgeone-origin
sudo cp 你整理好的清单 /etc/edgeone-origin/cidr.txt # 每行一个 IP/CIDR,# 注释
# 2. 演练:只看将要执行的动作,不触碰系统
sudo ./edgeone-origin-whitelist.sh sync --dry-run
# 3. 正式启用
sudo ./edgeone-origin-whitelist.sh sync
```
启用后**立刻验证**(另开一个 SSH 或终端,别把自己关外面):
```bash
# 服务器本机验证(必须能通:Tunnel 回环放行)
curl -sI --max-time 5 http://127.0.0.1:18080/ | head -3
# 从一台"与 EO 无关"的外网机器验证(必须被拒)
nc -zv <你的服务器IP> 18080 # 期望 refused 或超时
# 经 EO 访问验证(必须能通)
# ⚠️ 必须用【境内网络】访问——境外访问会被 DNSPod 解析到 CF 分支(走 Tunnel 回环),
# 验证到的是 CF 而不是 EO!境内手机 4G 访问最方便
curl -sI --max-time 10 https://box.tama.guru/ | head -3
```
### 3.4 撤掉原来的全局放行
若你之前是用 ufw 放行的(`ufw allow 18080`),现在它已冗余(脚本的 REJECT 会先于它拦截非白名单),建议删掉避免困惑:
```bash
sudo ufw delete allow 18080 # 脚本 sync 时也会检测并提示
```
> ⚠️ 云厂商安全组若也是「全放行 18080」:本机 iptables 白名单已是最终防线,直连者到本机 INPUT 即被拒,**不强制改安全组**;但建议安全组层面至少把 18080 的源收敛为 EO 大段(升级付费拿到官方段后可做),纵深更稳。
---
## 4. 定时维护(核心诉求)
### 4.1 清单会变吗?
EO 官方口径:**回源网段平均 3–6 个月更新一次**,变更前 14/7/3/1 天通知。付费档启用源站防护后段变更有 API 兜底(§7);免费档请按 §7 的「工单/客服」或定期(建议每月一次)核对。
### 4.2 用 cron(最简单)
段级模式(§0.1)下清单是 5 个 `/16` 段、几乎不变 → 定时只需**周期 sync 兜底 + 月度人工归段核对**,auto 不再作日常主线(改为按需「新段预警」观测,见 4.4):
```bash
sudo crontab -e
# ★ 推荐:每 3 天一次 sync(全量重建:清单里没有的旧条目自动剔除)
17 4 */3 * * /usr/local/bin/edgeone-origin-whitelist.sh sync >> /var/log/eo-origin-whitelist.log 2>&1
# 想更保险可改每天:把 */3 改成 *(即 17 4 * * * …)——sync 开销秒级,零负担
# 新段预警观测(不设定时,需要时手动跑):
# sudo /usr/local/bin/edgeone-origin-whitelist.sh auto
# 输出「新增」→ whois 确认属腾讯云新段 → 整段写进 cidr.txt → 再 sync
```
把脚本放到固定路径:`sudo cp scripts/edgeone-origin-whitelist.sh /usr/local/bin/ && sudo chmod +x /usr/local/bin/edgeone-origin-whitelist.sh`
### 4.3 用 systemd timer(更规范,推荐)
`/etc/systemd/system/eo-origin-whitelist.service`:
```ini
[Unit]
Description=Sync EdgeOne origin whitelist
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/edgeone-origin-whitelist.sh sync
```
推荐**每 3 天一次**(段级清单近乎静态,sync 纯兜底):
```ini
# A. 日历式(推荐):每月 1/4/7/10/13/16/19/22/25/28/31 号 04:17
# /etc/systemd/system/eo-origin-whitelist.timer
[Unit]
Description=EdgeOne origin whitelist sync (3-day)
[Timer]
OnCalendar=*-*-1/3 04:17:00
Persistent=true
[Install]
WantedBy=timers.target
```
> 想每天跑:`OnCalendar=*-*-* 04:17:00`;想严格 72h 间隔:`OnBootSec=5min` + `OnUnitActiveSec=3d`。
### 4.4 失效的条目会自动剔除吗?(段级模式)
**会——且已内建,不需要任何额外参数。** 关键是理解 sync 的语义:
| 你可能以为 | 实际机制 |
|---|---|
| sync 是把清单「增量添加」,旧条目越积越多 | ❌ 错。sync 是**全量重建**:每次按当前清单文件从零填充临时集合 → `ipset swap` 原子替换 → 旧的 `EO_ORIGIN` 整个被新集合顶掉 |
所以**只要某段/某 IP 不在「当前清单文件」里,下一次 sync 它就自动消失**——剔除是 sync 的默认行为,零残留。反证见 §5:「临时 `ipset add` 放行的 IP,下次 sync 就没了」正是同一机制。
段级模式下清单近乎静态(5 个 `/16`),日常维护极简,只剩一件事:**防 EO 启用清单外新段**。
```bash
# ① 月度新段预警:跑一次 auto(或 tcpdump 抓 SYN),看采样里有没有清单外的 IP
sudo ./edgeone-origin-whitelist.sh auto
# auto 会把「清单外的新 IP」增量合并进 cidr.txt(只增不删),并提示新增了哪些
# ② 归段:whois <新IP>(/ ipinfo.io/<IP>),确认属腾讯云 EO(Tencent/EdgeOne 字样)
# ③ 把新 IP 归并成所属整段后写入清单(若 auto 已把裸 IP 加进 cidr.txt,可顺手整理成段):
sudo nano /etc/edgeone-origin/cidr.txt # 示例:43.175.x.x → 加一行 43.175.0.0/16
sudo ./edgeone-origin-whitelist.sh sync # 立即生效;不手动跑则下次定时 sync 也会生效
```
三个要点:
1. **别删到清单为空**:fail-safe 会让 sync 拒绝执行(宁可保留旧白名单,不可裸奔)。
2. **旧段留在清单里没有安全风险**:那些地址属于腾讯云,不会"变成攻击者",顶多白名单略宽(/16 级本就宽);真正要紧的只是**新段漏加 → 偶发 521**(见 §8 免费档清单质量)。
3. **想彻底全自动**(连月度核对都不想做):升级付费套餐启用「源站防护」,把 `DescribeOriginACL` 输出定时写入 cidr.txt(§7)——清单每次都是官方最新全集,sync 自动带上新段、自动剔除已退出的段,脚本零改动。
---
## 5. 常用操作速查
```bash
sudo ./edgeone-origin-whitelist.sh sync # 周期入口(定时任务用这个,段级清单全量重建)
sudo ./edgeone-origin-whitelist.sh auto # 新段预警:采集当前回源 IP → 合并进清单 → 有新增即 sync
sudo ./edgeone-origin-whitelist.sh learn # 看当前谁在回源(即时观测,不必用于日常维护)
sudo ./edgeone-origin-whitelist.sh show # 当前白名单条目 + iptables 规则
sudo ./edgeone-origin-whitelist.sh sync --dry-run # 演练
sudo ./edgeone-origin-whitelist.sh flush # 回滚:移除本脚本全部规则/集合
tail -20 /var/log/eo-origin-whitelist.log # 看更新历史
```
**临时放行某个 IP**(不用改清单,立即生效;下次 sync 会随清单重建而消失——想持久就写进清单文件):
```bash
sudo ipset add EO_ORIGIN x.x.x.x
```
**手动移除某个 IP**:
```bash
sudo ipset del EO_ORIGIN x.x.x.x
```
**修改回源端口**(低概率操作):改 `EO_PORT` 后先 `flush` 再 `sync`(避免 INPUT 残留旧端口跳转)。
**修改生效网卡**:改 `EO_IFACE`(或 `sudo EO_IFACE=ens3 ./edgeone-origin-whitelist.sh sync`)后直接 sync 即可——从「不限网卡」升级为「限定网卡」时会自动删除旧的无限定跳转;改完用 `show` 确认 INPUT 规则带 `-i <网卡>`。
---
## 6. 排错
| 现象 | 原因与处理 |
|---|---|
| sync 报「清单为空…保持现网白名单不变(fail-safe)」 | 正常保护:空清单绝不上线。成因=清单文件不存在 / 内容全非法 / 无有效条目。先用 `learn` 采种并人工审阅后写入 `/etc/edgeone-origin/cidr.txt`(裸 IP 每行即可,已支持直接入清单);脚本报错会自带下一步指引。若真配了 `EO_CIDR_URL` 且拉取失败 + 本地无条目,会单独报「来源全部失败」 |
| 收紧后 CF 分支(海外)访问挂 | 几乎肯定是 cloudflared 连的不是 `127.0.0.1`。确认 Tunnel 配置里 service 指向 `http://127.0.0.1:PORT`(脚本对 127.0.0.0/8 放行;若 cloudflared 在**另一台机器**则需把那台机器出口 IP 加进白名单——但那属于另一架构,需单独设计) |
| 经 EO 访问 502/回源失败(境内间歇性 521) | ① 端口填错(脚本拦的不是 EO 回源那个端口);② 🔴 **清单太细**——EO 回源出口是海量轮换节点池(实测 23 秒 13 个出口、分布多个 /16),逐 IP 白名单必然间歇漏放 → EO 报 521。解法=按 §0.1 放行腾讯云大段(43.146/43.152/43.168/43.174/43.175 的 /16,2026-09-03 实测 521 消失);③ 服务器重启后 ipset/iptables 规则丢失 → 见下 |
| sync 报 `Set EO_ORIGIN doesn't exist` | 🔴 iptables-nft 要求「被引用的 ipset 集合必须先于规则存在」。旧版脚本先建链(内含引用集合的规则)后建集合,**首次运行必失败**。已修复(集合创建挪到建链之前);旧版升级后重跑 `sync` 即可自愈 |
| sync 报 `RULE_APPEND failed (Invalid argument)` 且 EO_IN 链**缺 REJECT 兜底行**(只剩 127/集合两条 ACCEPT) | 🔴 iptables-nft 下 `--reject-with tcp-reset` **必须带 `-p tcp`**(nft 的 reject 语句需确认匹配 TCP 才能构造 RST,无 -p 直接 EINVAL)。旧版 REJECT 行无 `-p tcp` 必然失败 → 链尾无兜底 → **非白名单直连仍通,白名单形同虚设**(集合/127 两条 ACCEPT 无此限制,能正常加入)。已修复(REJECT 加 `-p tcp`);部署新版后重跑 `sync`,验证 `iptables -S EO_IN` 三条齐全:`-s 127.0.0.0/8 ACCEPT` / `-m set --match-set EO_ORIGIN src ACCEPT` / `-p tcp ... REJECT --reject-with tcp-reset` |
| 服务器重启后白名单没了 | ipset 与 iptables 规则是内存态。用 `iptables-persistent` + `ipset-persistent`(Debian/Ubuntu),或把 sync 加进开机自启(systemd timer `Persistent=true` 只补跑错过的任务,不保证开机即跑——建议加一条 `@reboot` cron 或 oneshot service 开机执行一次 sync) |
| 直连还是能通 | 先 `iptables -S INPUT` 看是否还有更靠前的全局 ACCEPT 匹配 18080;`ufw status` 若显示 allow 18080 先删;云安全组全放行但本机已 REJECT 的情况下直连应被拒——若仍通说明规则没插在 INPUT 最前或被其它链绕过(如 nftables 后端混用,`iptables -S` 与实际生效不一致时改用 `nft list ruleset` 排查) |
| auto 立即结束、没等 `EO_SAMPLE_SECONDS` 窗口(日志同秒出现「采样到 0 个」) | 🔴 旧版 tcpdump filter 误写为 `"... and 'tcp[tcpflags] & tcp-syn != 0'"`——双引号内的单引号是**字面字符**,会原样传给 tcpdump,pcap 编译器不认识引号 → syntax error **秒退**(手动执行时单引号由 shell 吃掉,脚本里没这一步);且旧版 `awk '{print $2}'` 取到的是协议名 `IP` 而非源地址(源在 `>` 前一字段)。两个缺陷叠加 = 抓了也白抓、还秒退。已修复(filter 去引号 + 按 `>` 前字段取源 IP,窗口会真实阻塞)。部署新版后:`sudo edgeone-origin-whitelist.sh auto` 应看到「tcpdump 采样 Ns … 请稍候」并等待 N 秒;`EO_SAMPLE_SECONDS` 非数字会打印提示并跳过 |
| learn 输出为空 | ① **没有活跃回源连接**——EO 回源是短连接、错过即无,且**必须境内网络访问才走 EO 路径**(境外访问走 CF Tunnel,对端 127.0.0.1 会被脚本过滤,永远为空);② 端口核对:`ss -tlnp \| grep LISTEN` 确认 TamaBox 监听端口 = 脚本 `EO_PORT`;③ 访问后 60 秒内 `ss -Htan \| grep :<端口>` 看 TIME-WAIT 痕迹;④ 最稳:`tcpdump -ni <网卡> tcp port <端口> and 'tcp[tcpflags] & tcp-syn != 0'` 抓 SYN——触发一次境内访问,出现的源 IP 即 EO 回源 IP。新版脚本空结果时会自带以上提示;`auto` 的采样含 conntrack(窗口比 learn 宽),仍采不到时排查思路同上 |
---
## 7. 升级付费套餐后:接官方 API 全自动(零脚本改动)
启用「源站防护」拿到 ZoneId 后,可用腾讯云 Python SDK / tccli 周期调用 `DescribeOriginACL`,把返回的 `CurrentOriginACL.EntireAddresses.IPv4[]` 写入 `/etc/edgeone-origin/cidr.txt`,本脚本照常 sync 即可——**脚本无需任何改动**(它只认"每行一个 CIDR 的文件")。
> 也可让脚本直接拉取:把 API 结果发布到一个私有 HTTP URL,配置 `EO_CIDR_URL=...`(文件 + URL 会取并集)。注意变更时先核对 `NextOriginACL` 计划生效时间,建议同步时机放在 `PlannedActiveTime` 前完成,避免 EO 强制切换后回源失败。
---
## 8. 边界与已知限制
- **默认仅公网网卡 eth0**:白名单只判定 `EO_IFACE` 指定网卡的入站流量(默认 `eth0`);回环由独立的 `-i lo` ACCEPT 放行(供 Tunnel 本机回连),与网卡限定解耦。网卡名不是 eth0 时务必配置,否则跳转永不命中、端口裸奔。
- **IPv6 天然无风险、无需任何 ip6tables 规则**:脚本只管理 IPv4;TamaBox 监听 `0.0.0.0`(仅 IPv4、无 AAAA),IPv6 上不存在该回源端口,v6 用户访问由 CDN 边缘走 IPv4 回源,一切正常。只有日后改为监听 `[::]:PORT` 并向 EO 开放 v6 回源,才需扩展 inet6 集合 + ip6tables 镜像(脚本头注释 5 有说明)。
- **免费档清单质量(段级视角)**:清单是 5 个腾讯云 `/16` 段(§0.1),EO 出口怎么轮换都在段内 → 正常无感。唯一风险是 EO 启用清单外的**新段**(罕见、以月计)→ 表现为个别请求偶发 521;对策=月度跑一次 `auto` 看有无清单外新 IP,`whois` 确认归段后补段(§4.4)。这是免费档没有官方清单的固有代价,可接受则用,不可接受就升级付费档走 §7。
- **脚本不触碰 ufw/云安全组**,只管理自己创建的链与集合;`flush` 也只删自己的东西,可放心回滚。
- 本机为 1C1G 低配:脚本每次运行仅秒级、无常驻进程,ipset 查表为 O(1) 哈希,不影响转发性能。
-114
View File
@@ -1,114 +0,0 @@
# EO 优选方案 B 实测报告(免费替代 wetest.vip)
日期:2026-09-05 | 结论状态:**候选已找到,待大陆多节点 itdog 验证后决定是否落地 DNS**
前提:方案 A(wetest.vip 会员活 IP,付费)用户已排除 → 本次全程免费实测
---
## 一、背景与目标
- 现状:box.tama.guru 国内走 EdgeOne(CNAME 恒定解析新加坡 43.174.246.43/247.43),大陆 itdog 3-11s
- 方案 B:不付费,自扫/公开 CNAME 找 **EO 网络内能服务 box.tama.guru 的亚洲近大陆边缘 IP**,改 dnspod 境内 A 记录直连
- 区域不限香港,周边(日/韩/东南亚)比新加坡好即可
## 二、免费公开数据源现状(全部实测,均死)
| 数据源 | 类型 | 实测结果 |
|---|---|---|
| wetest.vip/page/edgeone/address_v4 | 会员活 IP | 🔴 需登录/付费,公开页 0 IP(页面仅"登录可购买") |
| *.v1/v4.eo.fastlydns.cn(小周优选) | 免费 CNAME | 🔴 全 NXD(域名 NS 在但子域已删) |
| eo.072103.xyz | 免费 CNAME | 🔴 NXD |
| eo.088086.xyz | 免费 CNAME | 🔴 NXD |
| 蓝点网 43.174.150.199 等单 IP | 免费 IP | 🔴 证书已退回 CDN 通用池(*.cdn.myqcloud.com) |
| 雾创岛 43.174.150.150 等香港 IP | 免费 IP | 🔴 同上,全部失效 |
> 印证:EO 优选 IP 时效性极强(月度级),2025 年社区免费源到 2026-09 已全部死亡。
> 博客佐证(2026-02):"EdgeOne 现在不能使用优选节点,对套餐不适用的节点直接返回 418"。
## 三、自扫实测方法与结果
### 3.1 判定标准(关键)
社区 2025 教程用"TLS 握手成功"判优选是**错误判据**(腾讯 CDN 泛播池也握手成功但证书是 *.cdn.myqcloud.com)。
正确判据 = schannel 证书链校验通过(浏览器视角可访问)+ HTTP 200 + 响应头 Server: TencentEdgeOne。
### 3.2 扫描执行
- 段级抽样:43.174.0.0/16 + 43.175.0.0/16 全部 512 个 /24 × 6 IP = 3072 抽样
- 全段细扫:官方段 + 社区段 21 个 /24 ≈ 5300 IP
- HK/JP 段细扫:56 个 /24 ≈ 14000 IP
- 工具:dev/eo-probe-v2.py(schannel 判定)、dev/eo-probe-ips.py、dev/eo-scan-all.py
### 3.3 关键发现
**能服务 box.tama.guru 的 EO 边缘池(证书 CN=box.tama.guru)集中在 9 个 /24,全部新加坡:**
43.174.192-195、43.174.218、43.174.240-241、43.174.246-247(AS139341 ACE)
**但 EO 泛播确实同步到全球**——非官方分配段也有零星节点可服务:
- 香港:43.175.115.x / 119.x / 43.174.126.x(8 个 IP)
- 东京:43.175.16/17/18/113.x(6 个 IP)
- 大阪:43.175.107.217
- 阿姆斯特丹 43.175.185.x(5 个)、迪拜 43.174.152.63(远,无意义)
> 说明:域名配置经 EO 泛播池同步到亚洲边缘,只是官方 CNAME 只调度到新加坡。
> 这些 IP 均可作为境内 A 记录候选(证书/SNI/回源已验证 OK)。
## 四、大陆视角实测对比(南宁电信,真实大陆出口)
### 4.1 动态页 /_/tamakyi TTFB(用户核心痛点指标)
| 节点 | TCP connect | TTFB (3 次) | 评价 |
|---|---|---|---|
| **官方新加坡 43.174.247.43** | 180-186ms | 0.81 / 0.91 / 1.03s | 当前最优 |
| 香港 43.174.126.220 | 354-371ms | 1.29 / 1.47 / 1.80s | 更慢 |
| 东京 43.175.18.50 | 287-304ms | 1.07 / 1.19 / **4.02s** | 更慢+不稳 |
| 东京 43.175.113.139 | 292-294ms | 1.08 / 1.50 / 2.10s | 更慢 |
| 正常 DNS(现状) | 208-1060ms | 0.92 / 0.97 / 1.70s | 与官方一致 |
### 4.2 TCP RTT 排名(南宁电信 443 端口 3-5 次中位)
```
180ms 43.174.247.43 官方新加坡 ← 最优
276-299ms 东京 43.175.17/18/113/16.x
351-377ms 香港 43.175.115/119/43.174.126.x
```
### 4.3 结论:单点(南宁电信)实测官方新加坡反而最优
- ipinfo 标"香港/东京"的 EO 边缘,大陆电信实际路由质量**不如官方新加坡**
- 原因:官方 CNAME 的 43.174.246.43/247.43 是 EO 对大陆的优化调度;自扫边缘的跨境路由绕行
- ⚠️ 注意:南宁电信 ≠ 全国。itdog 曾显示多地区 3-11s 慢,说明不同地区/运营商差异大。
香港候选在广州/深圳/福建等华南地区可能优于新加坡,需 itdog 多节点验证才能定论。
## 五、候选 IP 清单(供 itdog 大陆多节点验证)
```bash
# 官方对照(当前 DNS)
43.174.246.43 43.174.247.43
# 香港候选(8)
43.174.126.152 43.174.126.220 43.175.115.41 43.175.115.123
43.175.115.149 43.175.115.202 43.175.119.174 43.175.119.214
# 东京候选(6)
43.175.16.233 43.175.17.109 43.175.17.185 43.175.18.50 43.175.113.139
# 大阪
43.175.107.217(返回 400,慎用)
```
itdog 建议:URL 填 `https://box.tama.guru/robots.txt`(静态,可缓存对比),
对上述每个 IP 用"自定义 IP"模式分别测;若某香港/东京 IP 全国均值明显 < 新加坡,再改 DNS。
## 六、结论与建议
1. **免费公开源已全部死亡**(wetest 会员除外)——免费优选的时代窗口(2025 年中-2026 初)已关闭
2. **自扫能力已具备**(脚本在 dev/),能稳定找出"当前仍服务 box.tama.guru 的 EO 边缘"
3. **南宁电信单点**:官方新加坡最优,候选不占优 → 但需 itdog 全国多点验证(华南可能反转)
4. 若 itdog 确认无候选优于新加坡 → 方案 B 免费路线到此为止,剩:
- 备案 + 大陆 CDN(治本,免费套餐获取难度降低但需实名+银行卡)
- 维持现状(静态已迁 snrat,动态页 ~1s TTFB 已是免备案架构上限附近)
5. 日常维护建议:若坚持 EO 优选,成本最低的是**订阅一个 ITDOG 会员自己做月度优选**(比 wetest 便宜且通用),而非固定买活 IP 列表
## 七、遗留
- dev/ 下新增 3 个扫描脚本可复用(改 --host 即可用于其他 EO 域名)
- 43.175.115.x 等香港候选在 check-host HK 节点延迟 2-60ms(真香港),仅大陆跨境路由绕
- 建议优先 itdog 验证 43.175.115.202 / 43.174.126.220(check-host 证实真香港 PoP)
-47
View File
@@ -1,47 +0,0 @@
# 功能管理面板改进:长期开放模式 + 私聊上限下线 + 按钮同行(2026-09-01)
## 需求与方案
| # | 需求 | 方案 |
|---|------|------|
| 1 | 缺少「长期开放」开关:设了时间/数量后服务到期即关,想要一直开放的服务没有直接入口 | 每个功能面板新增「收集模式」三选一(radio):**长期开放 / 定时收集 / 循环定时**;选「长期开放」保存后即不限时间,保持开放直到手动关闭(仍可叠加收集上限) |
| 2 | 私聊设置收集上限没有意义 | 私聊面板隐藏「收集上限」与「重置计数」;后端保存私聊时强制 `quota_limit=0`;启动期幂等清零历史残留的私聊上限/计数(`normalizeChatQuota`) |
| 3 | 「保存该功能设置」应与「重置计数」同一行 | 两按钮合并到同一 flex 行;重置按钮通过 HTML5 `form` 属性指向独立的 reset 表单(HTML 不允许表单嵌套),CSRF 各自保留 |
## 改动明细
### 1. 收集模式三选一(`templates/user/feature-control-form.html` + `route/user/features.go`)
- 模式取值:`always`(长期开放)/ `window`(定时收集,一次性开放/关闭窗口)/ `recurring`(循环定时,每周循环)。
- 渲染时从已保存配置推断模式(`inferFeatureMode`):有循环定时 → recurring;有一性次时间 → window;否则 → always。
- **模式互斥**:保存时只保留所选模式的字段,其余清空。这顺带修掉一个旧语义坑——
此前「一次性窗口 AND 循环定时」叠加,一次性关闭时间过后功能**永久结束**(硬边界),
用户想恢复一直开放必须手动清空时间字段;现在切换到「长期开放」保存一次即可。
- JS 按所选模式显隐「定时收集」/「循环定时」字段区(未选模式的字段仍提交,由后端按模式清理)。
- 未携带 `mode` 的旧表单提交按字段推断兜底,保持兼容;`Evaluate` 判定逻辑零改动。
### 2. 私聊收集上限下线
- UI:`{{ if ne .Key "chat" }}` 条件隐藏配额输入、已收集行、重置按钮。
- 后端:`FeaturesUpdate` 对 `feature=chat` 强制 `quota = 0`(防历史残留上限导致私聊"莫名关闭")。
- 数据:新增 `internal/db/feature_control.go::normalizeChatQuota`,启动时把
`feature='chat'` 且 `quota_limit>0 或 quota_count>0` 的行幂等清零(`db.go` 调用,失败仅告警)。
- 文案:四语言字典更新 `admin.featureHint.chat`(去掉"达到上限后停止新建会话")。
### 3. 按钮同行
- `.feat-actions` 行内先「重置计数」(default 样式,仅非私聊面板)后「保存」(primary)。
- 重置按钮 `form="feat-reset-{key}"` 指向表单外的独立 reset 表单,功能与原来完全一致。
## 其他改动
- i18n 四语言(zh-CN/zh-TW/en/ja)新增 `admin.featureMode`、`admin.featureModeAlways/Window/Recurring`、`admin.featureModeHint`。
- `templates/render_test.go`:mock 结构体补 `Mode` 字段(question=always、chat=always、vote=recurring)。
- `templates/user/profile.html`:新增 `.feat-modes` / `.feat-field-label` / `.is-hidden` 样式。
## 验证
- `gofmt -l -e` 全部改动文件 ✅
- 全模块 overlay `go build` / `go vet` ✅(webp 屏蔽包用 `dev/webpstub` 桩 + `-overlay` 类型检查)
- 全部可运行测试通过(含 `templates` 面板渲染回归、`internal/i18n`)✅
- 待办:Linux 环境最终 `go build ./...`(webp CGO 真实编译)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 524 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-886
View File
@@ -1,886 +0,0 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>测试</title>
<meta name="msvalidate.01" content="2F185E8E63C93635F50B3574F3B31E42" />
<meta name="description" content="狼の質問箱は登録不要で使える匿名質問箱。誰でも匿名で質問でき、安心してやり取りできる場所です。">
<meta name="keywords" content="匿名質問箱,質問箱,匿名質問,狼の質問箱,匿名で質問,匿名Q&amp;A">
<script>
(function () {
var m = document.cookie.match(/(?:^|;\s*)theme=([^;]*)/);
if (m && m[1] === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
})();
</script>
<link rel="preconnect" href="https://r2.337845818.best" crossorigin>
<link rel="preconnect" href="https://cdnjs.snrat.com" crossorigin>
<link rel="stylesheet" href="https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/css/uikit.min.css"/>
<link rel="stylesheet" href="https://box-dev-api.337845818.best/static/css/base.css?v=20261019f"/>
<link rel="stylesheet" href="https://r2.337845818.best/tamabox/cdn/waline/client/v2/waline-no-commits.css" media="print" onload="this.media='all'"/>
<script defer src="https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/js/uikit.min.js"></script>
<script defer src="https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/js/uikit-icons.min.js"></script>
<script defer src="https://cdnjs.snrat.com/ajax/libs/dayjs/1.10.8/dayjs.min.js"></script>
<script defer src="https://cdnjs.snrat.com/ajax/libs/alpinejs/3.9.0/cdn.min.js"></script>
<script defer src="https://cdnjs.snrat.com/ajax/libs/waline/2.15.8/waline.js"></script>
<style>
.captcha-equation-box {
display: inline-flex;
align-items: center;
background: #f0f2f5;
border: 1px solid #dcdcdc;
border-radius: 8px;
padding: 8px 8px 8px 16px;
gap: 6px;
transition: border-color 0.2s;
}
.captcha-equation-box:focus-within {
border-color: #1e87f0;
box-shadow: 0 0 0 3px rgba(30,135,240,0.12);
}
.math-captcha-modal-question {
font-size: 1.4rem;
font-family: "Courier New", monospace;
font-weight: 700;
color: #333;
letter-spacing: 1px;
white-space: nowrap;
}
#captcha-modal-answer {
width: 80px;
padding: 6px 10px;
text-align: center;
font-size: 1.2rem;
font-family: "Courier New", monospace;
font-weight: 600;
border: 1px solid #ccc;
border-radius: 5px;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
background: #fff;
}
#captcha-modal-answer:focus {
border-color: #1e87f0;
box-shadow: 0 0 0 2px rgba(30,135,240,0.2);
}
#captcha-modal-answer::-webkit-inner-spin-button,
#captcha-modal-answer::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
#captcha-modal-answer {
-moz-appearance: textfield;
}
</style>
<meta property="og:site_name" content="狼の質問箱 | 匿名質問箱" />
<meta property="og:type" content="website" />
<meta property="og:title" content="狼の質問箱 | 匿名質問箱" />
<meta property="og:url" content="https://box-dev-api.337845818.best/vote/7kcP5kR8YnJC" />
<meta property="og:description" content="狼の質問箱は登録不要で使える匿名質問箱。誰でも匿名で質問でき、安心してやり取りできる場所です。" />
<meta name="robots" content="noindex">
<meta itemprop="image" content="https://r2.337845818.best/tamabox/tamabox_low.png"/>
<meta name="msapplication-TileImage" content="https://r2.337845818.best/tamabox/tamabox_low.png"/>
<link rel="shortcut icon" href="https://r2.337845818.best/tamabox/tamabox_low.png" />
<link rel="apple-touch-icon" href="https://r2.337845818.best/tamabox/tamabox_low.png"/>
<link rel="icon" sizes="192x192" href="https://r2.337845818.best/tamabox/tamabox_low.png">
<meta name="twitter:image:src" content="https://r2.337845818.best/tamabox/tamabox_low.png" />
<meta name="google-site-verification" content="fEPwRRJfiGsgwc5SINBQwZhPK8F0cwRaOvRx0YRurI4" />
<script>
window.addEventListener("load", function () {
var s = document.createElement("script");
s.src = "https://mami.shiroko.one/tamami.js";
s.setAttribute("data-website-id", "c42ebe8b-9281-4da0-83a4-8f18b3e710fe");
document.body.appendChild(s);
});
</script>
</head>
<body>
<script>
window.tbRenderQR = function (el, getOptions) {
if (!el || typeof QRCode === 'undefined') return;
var opts = getOptions();
function remoteOf(o, key) {
var v = o[key];
return (typeof v === 'string' && v.indexOf('http') === 0) ? v : '';
}
var good = window._tbQrGood || (window._tbQrGood = {});
var bad = window._tbQrBad || (window._tbQrBad = {});
function layerState(url) { return !url ? '' : (good[url] ? 1 : (bad[url] ? 0 : null)); }
var layers = [
{ url: remoteOf(opts, 'logo'), st: null, present: function (o, on) {
if (on) { } else { o.logo = undefined; o.logoWidth = undefined; o.logoHeight = undefined; o.logoBackgroundTransparent = undefined; }
} },
{ url: remoteOf(opts, 'backgroundImage'), st: null, present: function (o, on) {
if (on) { } else { o.backgroundImage = undefined; o.backgroundImageAlpha = undefined; }
} }
];
var needProbe = false;
for (var i = 0; i < layers.length; i++) {
layers[i].st = layerState(layers[i].url);
if (layers[i].st === null) needProbe = true;
}
function paint(states) {
var o = {};
for (var k in opts) o[k] = opts[k];
for (var j = 0; j < layers.length; j++) layers[j].present(o, states[j] === 1);
el.innerHTML = '';
new QRCode(el, o);
}
var initStates = [];
for (var n = 0; n < layers.length; n++) initStates.push(layers[n].st === 1 ? 1 : 0);
paint(initStates);
if (!needProbe) return;
var pending = 0, cross = opts.crossOrigin || 'anonymous';
layers.forEach(function (L) {
if (L.st !== null) return;
pending++;
var im = new Image();
im.onload = function () { good[L.url] = true; L.st = 1; onDone(); };
im.onerror = function () { bad[L.url] = true; L.st = 0; onDone(); };
if (cross) im.crossOrigin = cross;
im.src = L.url;
});
function onDone() {
if (--pending > 0) return;
var finalStates = [];
for (var m = 0; m < layers.length; m++) finalStates.push(layers[m].st === 1 ? 1 : 0);
var changed = false;
for (var q = 0; q < finalStates.length; q++) if (finalStates[q] !== initStates[q]) changed = true;
if (changed) paint(finalStates);
}
setTimeout(function () { if (pending > 0) { pending = 0; onDone(); } }, 3000);
};
</script>
<nav class="uk-navbar-container uk-margin tb-navbar" uk-navbar>
<div class="uk-navbar-center">
<div class="uk-navbar-center-left">
<div>
<ul class="uk-navbar-nav">
<li><a href="/explore">探索</a></li>
<li class="tb-mobile-balancer" aria-hidden="true"><a tabindex="-1" style="visibility:hidden">マイ</a></li>
</ul>
</div>
</div>
<a class="uk-navbar-item uk-logo" href="/"><span class="emoji-placeholder" aria-label="random animal">🐺</span>の質問箱</a>
<div class="uk-navbar-center-right">
<div>
<ul class="uk-navbar-nav">
<li><a href="/register">登録</a></li>
</ul>
<ul class="uk-navbar-nav">
<li><a href="/login">ログイン</a></li>
</ul>
<ul class="uk-navbar-nav tb-nav-utils">
<li>
<a href="#" aria-label="言語" title="言語"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M3.2 9h17.6M3.2 15h17.6"/><ellipse cx="12" cy="12" rx="4" ry="9"/></svg></a>
<div class="uk-navbar-dropdown" uk-drop="mode: click; pos: bottom-right">
<ul class="uk-nav uk-navbar-dropdown-nav">
<li class="">
<a href="/i18n/zh-CN?to=%2Fvote%2F7kcP5kR8YnJC">简体中文</a>
</li>
<li class="">
<a href="/i18n/zh-TW?to=%2Fvote%2F7kcP5kR8YnJC">繁體中文</a>
</li>
<li class="">
<a href="/i18n/en?to=%2Fvote%2F7kcP5kR8YnJC">English</a>
</li>
<li class="uk-active">
<a href="/i18n/ja?to=%2Fvote%2F7kcP5kR8YnJC">日本語</a>
</li>
</ul>
</div>
</li>
</ul>
<ul class="uk-navbar-nav tb-nav-utils">
<li>
<a href="#" id="theme-toggle" title="ダーク/ライトモード切替" aria-label="ダーク/ライトモード切替">
<span class="tb-icon-light"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20.5 14.3A8.6 8.6 0 0 1 9.7 3.5a8.6 8.6 0 1 0 10.8 10.8z"/></svg></span><span class="tb-icon-dark"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4"/></svg></span>
</a>
</li>
</ul>
</div>
</div>
</div>
</nav>
<script>
document.addEventListener("DOMContentLoaded", function() {
const emojis = ["🐺","🐗","🐻","🐨","🐼","🐹","🐭","🐰","🦊","🐮","🐷","🐽","🐸","🐒","🦍","🐔","🐧","🐦","🐤","🐦‍⬛","🦅","🦉","🦇","🐴","🦄","🐝","🦎","🦖","🦕","🐙","🦑","🦐","🦞","🦀","🐡","🐠","🐟","🐬","🐳","🐋","🦈","🐊","🐅","🐆","🦓","🦍","🦧","🐘","🦛","🦏","🐪","🐫","🦒","🦘","🐃","🐂","🐄","🐎","🐖","🐏","🐑","🦙","🐐","🦌","🐕","🐩","🦮","🐕‍🦺","🐈","🐋","🐳","🐬","🐉","🐲"];
const placeholder = document.querySelector('.emoji-placeholder');
if (placeholder) {
placeholder.textContent = emojis[Math.floor(Math.random() * emojis.length)];
}
});
</script>
<div id="tb-mobile-utils">
<select id="mobile-lang-select" class="uk-select uk-form-small" aria-label="言語">
<option value="zh-CN" >简体中文</option>
<option value="zh-TW" >繁體中文</option>
<option value="en" >English</option>
<option value="ja" selected>日本語</option>
</select>
<button type="button" id="theme-toggle-mobile" class="uk-button uk-button-default uk-button-small"
title="ダーク/ライトモード切替" aria-label="ダーク/ライトモード切替">
<span class="tb-icon-light"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20.5 14.3A8.6 8.6 0 0 1 9.7 3.5a8.6 8.6 0 1 0 10.8 10.8z"/></svg></span><span class="tb-icon-dark"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4"/></svg></span>
</button>
</div>
<script>
(function() {
var THEME_COOKIE = 'theme';
function getTheme() {
var m = document.cookie.match(/(?:^|;\s*)theme=([^;]*)/);
return m ? m[1] : '';
}
function applyTheme(theme) {
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
}
function toggleTheme() {
var next = getTheme() === 'dark' ? 'light' : 'dark';
applyTheme(next);
var expires = new Date(Date.now() + 365 * 24 * 3600 * 1000).toUTCString();
document.cookie = THEME_COOKIE + '=' + next + '; path=/; max-age=' + (365 * 24 * 3600) + '; SameSite=Lax';
}
document.addEventListener('DOMContentLoaded', function() {
var buttons = document.querySelectorAll('#theme-toggle, #theme-toggle-mobile');
buttons.forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();
toggleTheme();
});
});
var langSel = document.getElementById('mobile-lang-select');
if (langSel) {
langSel.addEventListener('change', function() {
if (!this.value) return;
var to = window.location.pathname + window.location.search;
window.location.href = '/i18n/' + encodeURIComponent(this.value) + '?to=' + encodeURIComponent(to);
});
}
});
})();
</script>
<div class="uk-container uk-container-xsmall">
<div id="math-captcha-modal" uk-modal>
<div class="uk-modal-dialog uk-modal-body uk-text-center">
<h3 class="uk-modal-title">人機認証</h3>
<p class="uk-text-meta">以下の計算問題を解いて、ロボットでないことを証明してください</p>
<div class="captcha-equation-box">
<span id="captcha-modal-question" class="math-captcha-modal-question">読み込み中...</span>
<input type="number" id="captcha-modal-answer" placeholder="?" autocomplete="off" inputmode="numeric">
</div>
<div style="margin-top:6px;">
<button type="button" class="uk-button uk-button-text uk-text-small" id="captcha-modal-refresh">別の問題</button>
</div>
<div id="captcha-modal-error" class="uk-text-danger uk-text-small" style="display:none;min-height:20px;"></div>
<div class="uk-margin-small-top">
<button type="button" class="uk-button uk-button-primary" id="captcha-modal-submit">送信</button>
<button type="button" class="uk-button uk-button-default uk-modal-close">キャンセル</button>
</div>
</div>
</div>
<script>
var CAPTCHA_I18N = {
loading: "読み込み中...",
loadFailed: "読み込み失敗",
loadRetry: "読み込み失敗、再試行してください",
inputAnswer: "答えを入力してください",
wrongAnswer: "答えが違います。もう一度計算してください",
verifyFailed: "認証失敗、再試行してください",
needPassword: "この質問箱にはパスワードが必要です",
needEmail: "通知を受け取るメールアドレスを入力してください"
};
</script>
<script>
(function() {
var captchaId = '';
var pendingForm = null;
var pendingNeedPassword = false;
var verifying = false;
function fetchCaptcha() {
var qEl = document.getElementById('captcha-modal-question');
var errEl = document.getElementById('captcha-modal-error');
var ansEl = document.getElementById('captcha-modal-answer');
qEl.textContent = CAPTCHA_I18N.loading;
fetch('/api/captcha/generate')
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.code === 0 && data.data) {
captchaId = data.data.id;
qEl.textContent = data.data.question;
if (errEl) errEl.style.display = 'none';
if (ansEl) { ansEl.value = ''; ansEl.focus(); }
} else {
qEl.textContent = CAPTCHA_I18N.loadFailed;
}
})
.catch(function() {
qEl.textContent = CAPTCHA_I18N.loadRetry;
});
}
function getCSRFToken() {
var input = document.querySelector('input[name="_csrf"]');
return input ? input.value : '';
}
function doSubmit(form) {
var ans = document.getElementById('captcha-modal-answer').value.trim();
var hidden = form.querySelector('[name="g-recaptcha-response"]');
if (hidden) hidden.value = captchaId + ':' + (ans || '0');
UIkit.modal('#math-captcha-modal').hide();
if (pendingNeedPassword) {
UIkit.modal.prompt(CAPTCHA_I18N.needPassword, '', 'password')
.then(function(password) {
if (password) {
var pwInput = document.getElementById('ask_password_input');
if (pwInput) pwInput.value = password;
form.submit();
}
});
} else {
form.submit();
}
}
document.getElementById('captcha-modal-submit').addEventListener('click', function() {
if (verifying) return;
var ans = document.getElementById('captcha-modal-answer').value.trim();
if (!ans) {
var errEl = document.getElementById('captcha-modal-error');
errEl.textContent = CAPTCHA_I18N.inputAnswer;
errEl.style.display = 'block';
return;
}
verifying = true;
fetch('/api/captcha/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCSRFToken()
},
body: JSON.stringify({ id: captchaId, answer: parseInt(ans, 10) })
})
.then(function(r) { return r.json(); })
.then(function(data) {
verifying = false;
if (data.correct) {
if (pendingForm) doSubmit(pendingForm);
} else {
var errEl = document.getElementById('captcha-modal-error');
errEl.textContent = CAPTCHA_I18N.wrongAnswer;
errEl.style.display = 'block';
fetchCaptcha();
}
})
.catch(function() {
verifying = false;
var errEl = document.getElementById('captcha-modal-error');
errEl.textContent = CAPTCHA_I18N.verifyFailed;
errEl.style.display = 'block';
fetchCaptcha();
});
});
document.getElementById('captcha-modal-answer').addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
document.getElementById('captcha-modal-submit').click();
}
});
document.getElementById('captcha-modal-refresh').addEventListener('click', fetchCaptcha);
document.addEventListener('click', function(e) {
var btn = e.target.closest('.math-captcha-trigger');
if (!btn) return;
e.preventDefault();
var form = btn.closest('form');
if (!form) return;
pendingForm = form;
pendingNeedPassword = !!(form.querySelector('#ask_password_input'));
var notifyCb = document.getElementById('notify-checkbox');
var emailInput = document.getElementById('email-input');
if (notifyCb && notifyCb.checked && emailInput && emailInput.value.trim() === '') {
UIkit.modal.alert(CAPTCHA_I18N.needEmail);
return;
}
fetchCaptcha();
UIkit.modal('#math-captcha-modal').show();
});
})();
</script>
<style>
.tb-field-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 8px;
margin-bottom: 10px;
}
.tb-field-head .tb-field-label {
flex-basis: 100%;
font-weight: 600;
line-height: 1.5;
word-break: break-word;
overflow-wrap: anywhere;
}
.tb-choice {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
margin-bottom: 8px;
border: 1px solid var(--tb-border);
border-radius: 8px;
background: var(--tb-surface);
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease;
}
.tb-choice:last-child { margin-bottom: 0; }
.tb-choice input[type="radio"], .tb-choice input[type="checkbox"] {
margin-top: 3px;
}
.tb-choice span {
flex: 1;
min-width: 0;
line-height: 1.5;
word-break: break-word;
overflow-wrap: anywhere;
}
.tb-choice:hover {
border-color: var(--tb-primary);
background: var(--tb-surface-hover);
}
.tb-choice:has(input:checked) {
border-color: var(--tb-primary);
background: var(--tb-surface-active);
}
html[data-theme="dark"] .tb-choice {
border-color: var(--tb-border);
background: var(--tb-surface);
}
html[data-theme="dark"] .tb-choice:hover {
border-color: var(--tb-primary);
background: rgba(78, 168, 245, 0.08);
}
html[data-theme="dark"] .tb-choice:has(input:checked) {
border-color: var(--tb-primary);
background: rgba(78, 168, 245, 0.14);
}
.tb-custom-input {
margin-top: 2px;
}
.tb-text-counter {
margin-top: 2px;
text-align: right;
}
@media (max-width: 480px) {
.tb-choice { padding: 10px 12px; }
}
</style>
<div class="uk-card uk-card-default uk-card-body uk-margin-bottom">
<div class="uk-flex uk-flex-middle uk-margin-small-bottom" style="gap: 10px;">
<img class="uk-border-circle" src="https://r2.337845818.best/tamabox/picture/2026/08/19/9af3e38222d972ff9141e9cfdb0475" style="width: 40px; height: 40px;">
<div>
<a class="uk-text-bold" href="/_admin6">admin6</a>
<div class="uk-text-meta uk-text-small">の投票</div>
</div>
<div class="uk-margin-auto-left">
<span class="uk-label uk-label-success">進行中</span>
</div>
</div>
<h3 class="uk-margin-remove-top uk-margin-small-bottom">测试</h3>
<p class="uk-margin-remove-top">测试</p>
</div>
<div class="uk-alert uk-alert-primary uk-text-small">匿名での参加が可能で、あなたの身元は記録されません(同一端末で最大1回)。</div>
<form method="post" action="/vote/7kcP5kR8YnJC/submit" id="vote-form" onsubmit="return validateVoteForm()">
<input type="hidden" name="_csrf" value="7PjXuvNsGzsh5kA_4djVOR-5ZSY6MTc5MjM5ODMwMTkzNzA4Nzg0MA">
<input type="hidden" name="g-recaptcha-response" id="g-recaptcha-response" value="">
<div class="uk-card uk-card-default uk-card-body uk-margin-bottom">
<div class="uk-margin">
<div class="tb-field-head">
<span class="tb-chip">1</span>
<span class="tb-chip">一行テキスト</span>
<div class="tb-field-label">测试 <span class="uk-text-danger">*</span></div>
</div>
<input class="uk-input" type="text" name="field_21" data-vote-required data-vote-label="测试">
</div>
</div>
<div class="uk-margin">
<button type="button" class="uk-button uk-button-primary math-captcha-trigger" onclick="return voteValidate(event)">投票する</button>
</div>
</form>
<script>
var MAX_CUSTOM_LEN = 200 ;
function countChars(s) { return Array.from(String(s || '')).length; }
function setupTextCounters() {
var form = document.getElementById('vote-form');
if (!form) { return; }
form.querySelectorAll('[data-tb-text-counter]').forEach(function (input) {
var max = parseInt(input.getAttribute('data-tb-text-max'), 10);
if (!max || max < 1) { max = MAX_CUSTOM_LEN; }
var counter = document.createElement('div');
counter.className = 'uk-text-meta uk-text-small tb-text-counter';
if (input.parentNode) { input.parentNode.insertBefore(counter, input.nextSibling); }
var update = function () {
var n = countChars(input.value);
counter.textContent = n + ' / ' + max;
if (n > max) {
counter.classList.add('uk-text-danger');
input.setAttribute('data-tb-too-long', '1');
} else {
counter.classList.remove('uk-text-danger');
input.removeAttribute('data-tb-too-long');
}
};
input.addEventListener('input', update);
update();
});
}
function validateVoteForm() {
var form = document.getElementById('vote-form');
if (!form) { return true; }
var tooLong = form.querySelector('[data-tb-text-counter][data-tb-too-long]');
if (tooLong) {
alert('自由記入オプションは 200 字以内にしてください');
tooLong.focus();
return false;
}
var groups = {};
form.querySelectorAll('input[data-vote-required][type="radio"], input[data-vote-required][type="checkbox"]').forEach(function (el) {
(groups[el.name] = groups[el.name] || []).push(el);
});
for (var name in groups) {
var any = groups[name].some(function (el) { return el.checked; });
if (!any) {
alert('入力してください「' + groups[name][0].getAttribute('data-vote-label') + '」');
return false;
}
}
var els = form.querySelectorAll('[data-vote-required]:not(input[type="radio"]):not(input[type="checkbox"])');
for (var i = 0; i < els.length; i++) {
var el = els[i];
if (el.disabled) { continue; }
if ((el.value || '').trim() === '') {
alert('入力してください「' + el.getAttribute('data-vote-label') + '」');
return false;
}
}
return true;
}
function voteValidate(e) {
if (!validateVoteForm()) {
if (e) { e.stopPropagation(); e.preventDefault(); }
return false;
}
return true;
}
function onSubmit() {
if (validateVoteForm()) {
document.getElementById('vote-form').submit();
}
}
(function () {
var form = document.getElementById('vote-form');
if (!form) { return; }
setupTextCounters();
var maxChoices = 0;
if (maxChoices > 0) {
var boxes = form.querySelectorAll('input[type="checkbox"][name="option_ids"]');
boxes.forEach(function (box) {
box.addEventListener('change', function () {
var checked = form.querySelectorAll('input[type="checkbox"][name="option_ids"]:checked').length;
if (checked > maxChoices) { this.checked = false; }
});
});
}
var CUSTOM_MARKER = "__tb_custom_option__";
form.querySelectorAll('[data-vote-custom-input]').forEach(function (input) {
var fid = input.getAttribute('data-vote-custom-input');
var group = form.querySelectorAll('input[name="field_' + fid + '"]');
var select = form.querySelector('select[name="field_' + fid + '"]');
var sync = function () {
var on = false;
if (select) {
on = (select.value === CUSTOM_MARKER);
} else {
group.forEach(function (r) { if (r.value === CUSTOM_MARKER && r.checked) { on = true; } });
}
input.disabled = !on;
if (!on) { input.value = ''; }
};
if (select) { select.addEventListener('change', sync); } else { group.forEach(function (r) { r.addEventListener('change', sync); }); }
sync();
});
})();
</script>
<p class=" uk-text-right uk-text-muted uk-text-small">
<br>
<a href="/rules">ルール</a> |
<a href="https://box.shiroko.one/_/tamakyi" target="_blank">管理者へ</a>
<br>
<a href="https://share.shiroko.one" target="_blank">ドライブ</a>
| <a href="https://blog.shiroko.one" target="_blank">お知らせ</a>
| <a href="https://chat.shiroko.one" target="_blank">チャット</a>
| <a href="https://status.shiroko.one" target="_blank">ステータス</a>
| <a href="https://shiroko.one" target="_blank">リンク</a>
<br>Powered by NekoBox | 狼ICP备 20230101
<br>質問箱は稼働中:<span id="box_runtime"></span>
</p>
<script>
(function () {
var create = Math.round(Date.UTC(2023, 6, 1, 18, 15, 43) / 1000);
function tick() {
var el = document.getElementById("box_runtime");
if (!el) return;
var s = Math.round((Date.now() + 8 * 3600 * 1000) / 1000) - create;
var t = [0, 0, 0, 0, 0];
if (s >= 31536000) { t[0] = s / 31536000 | 0; s %= 31536000; }
if (s >= 86400) { t[1] = s / 86400 | 0; s %= 86400; }
if (s >= 3600) { t[2] = s / 3600 | 0; s %= 3600; }
if (s >= 60) { t[3] = s / 60 | 0; s %= 60; }
if (s > 0) { t[4] = s; }
el.innerHTML = t[0] + '年 ' + t[1] + '天 ' + t[2] + '时 ' + t[3] + '分 ' + t[4] + '秒 ';
}
tick();
setInterval(tick, 1000);
})();
</script>
<br>
</div>
<script>
var SUBMIT_I18N = {
submitting: '送信中...',
uploading: 'アップロード中...'
};
function tbResetSubmitButtons() {
document.querySelectorAll('button[data-tb-submitting]').forEach(function(b) {
b.disabled = false;
b.removeAttribute('data-tb-submitting');
var orig = b.getAttribute('data-tb-restore');
if (orig !== null) {
b.innerHTML = orig;
b.removeAttribute('data-tb-restore');
}
});
}
document.addEventListener('submit', function(e) {
var form = e.target;
if (!form || form.tagName !== 'FORM') return;
if (e.defaultPrevented || form.getAttribute('data-tb-skip-submit-feedback') === '1') {
form.removeAttribute('data-tb-skip-submit-feedback');
return;
}
if (form.getAttribute('target') === '_blank') return;
var trigger = e.submitter || form.querySelector('button[type="submit"]');
var hasFile = false;
form.querySelectorAll('input[type="file"]').forEach(function(f) {
if (f.files && f.files.length > 0) hasFile = true;
});
var label = hasFile ? SUBMIT_I18N.uploading : SUBMIT_I18N.submitting;
if (trigger && trigger.tagName === 'BUTTON') {
trigger.setAttribute('data-tb-restore', trigger.innerHTML);
trigger.innerHTML = '<span uk-spinner="ratio: 0.6"></span> ' + label;
}
form.querySelectorAll('button').forEach(function(b) {
b.disabled = true;
b.setAttribute('data-tb-submitting', '1');
});
});
window.addEventListener('pageshow', tbResetSubmitButtons);
</script>
</body>
</html>
@@ -1,310 +0,0 @@
# 提问箱 LLM 内容审核方案 · 可行性评审
- 日期:2026-09-10
- 范围:可行性评审,**未改动任何代码**
- 背景更正:项目文本/图片审核服务为**七牛云**(阿里云通道虽仍在代码中,但 `conf/app.sample.ini` 的 `aliyun_access_key` 为空,实际不生效)
---
## 0. 七牛云能力核对(评审基准)
已验证(官方文档 [文本审核 API](https://developer.qiniu.com/censor/api/7260/api-text-censor)):
| 项 | 事实 |
|---|---|
| 端点 | `POST https://ai.qiniuapi.com/v3/text/censor`(代码 `qiniu.go:85` 一致) |
| 审核场景 | 仅 `antispam` 一种 |
| 返回 label | `normal` / `spam` / `ad` / `politics` / `terrorism` / **`abuse`(辱骂)** / `porn` / `flood` / `contraband` / `meaningless` |
| 管控建议 | `result.suggestion`:`block` / `review` / `pass`,并带 `score` |
| 限制 | 文本长度 ≤ 2000 字;**不支持白名单**,自定义拉黑词库需提交工单 |
已验证([七牛 AI 大模型推理](https://developer.qiniu.com/aitokenapi/13379/real-time-ai-interface-api)):
- 提供 MaaS 推理端点 `https://api.qiniuapi.com/v1` → 实为 **`https://api.qnaigc.com/v1`**
- 兼容 OpenAI `/v1/chat/completions`、Anthropic `/v1/messages`、支持流式
- 50+ 模型(DeepSeek / Qwen / Kimi / GLM 等)
- 使用**独立的 API Key**,与七牛对象存储 AK/SK 不是同一套凭证
代码内已验证的现状(`/internal/security/censor/qiniu.go`):
```go
func (r *QiniuTextCensorResponse) IsPass() bool {
for _, detail := range r.Result.Scenes.Antispam.Details {
blockedLabels := []string{"politics", "terrorism", "porn"} // ← 只有 3 类
if lo.Contains(blockedLabels, detail.Label) { return false }
}
return r.Result.Scenes.Antispam.Suggestion != "block" // ← review 放行
}
```
**关键发现:`abuse`(辱骂)不在硬拦名单内,且 `review` 一律放行** —— 辱骂类内容现在基本是放行的。
> ⚠️ 推测待实测:`abuse` 在七牛返回的是 `review` 而非 `block`。建议直接抽样 `censor_logs.raw_response` 里 `label=abuse` 的记录验证(零成本,不需要额外调接口)。
**另一条已验证的好消息**:`censor.go:50-53` 命中缓存后会用 `QiniuTextCensorParser(raw)` **重新计算** `Pass`(缓存只省 API 调用费,不锁死判定结果)。所以后续若收紧 `IsPass()` 逻辑,**不需要清理 31 天缓存**。
---
## 1. 总体结论
**方案可行,但按现在的描述直接落地会有 4 个 P0 问题(SSRF、Key 明文、成本被刷、邮件轰炸)。**
更重要的一点判断:**方案里"系统内置的若干种屏蔽判定类型",大部分根本不需要 LLM。**
七牛已经按 `antispam` 场景返回 9 类 label + score,且代码里已经拿到这些数据只是没用上。合理分层是:
| 层级 | 手段 | 成本 | 覆盖 |
|---|---|---|---|
| L1(应先做) | 收紧七牛 `IsPass()`:`abuse` + score 阈值 | **0 元,0 延迟** | 辱骂、垃圾、广告、灌水、违禁 |
| L2(补充) | 本地负面情绪词库打分 | 0 元 | 七牛不覆盖的语气/情绪词 |
| L3(本方案) | LLM 语义判定 | 钱 + 延迟 | 阴阳怪气、暗示、语境依赖的恶意 |
**建议:L3 只承担 L1/L2 覆盖不到的部分**,否则你在用最贵、最慢、最不确定的手段去解决已经有确定性解的问题。
---
## 2. 数据流与调用时序
### 2.1 现状(已验证)
`route/question/page.go` 的 `New()` 是**全同步**链路,任一环节返回即终止:
```
长度校验 → 验证码 → 同IP重复拦截 → 屏蔽词 → 七牛审核(10s超时) → 入库 → 邮件通知(goroutine)
↓ 失败
fail-closed 拒绝发布
```
现状里唯一的异步是 `shared.SafeGo`(`internal/shared/goutil.go`)——**裸 goroutine,无重试、无持久化、进程重启即丢失**,项目里没有队列/Worker,Redis 只用于 session 与 cache。
### 2.2 拟议方案插在哪里
按方案 3「命中时在前端提示提问者」,LLM 判定**必须同步阻塞**在入库之前:
```
... → 屏蔽词 → 七牛审核 → 【LLM 审核】→ 入库
├─ 命中 → 前端提示 / 拦截
├─ 未命中 → 正常入库
└─ 超限/连不上 → 跳过 + 前端异常提示 + 邮件告警 + 正常入库
```
### 2.3 时序上的问题
| # | 问题 | 级别 | 说明 |
|---|---|---|---|
| T1 | **同步阻塞被放大** | 🟠 P1 | 七牛审核约 100–500ms;LLM 判定通常 1–10s(推理模型可达 30s+)。提问提交从"几乎无感"变成明显卡顿 |
| T2 | **超时预算未定义** | 🟠 P1 | 方案未给超时值。必须独立设 deadline(建议 3–5s),且不能复用请求 ctx(客户端断连会误判为调用失败 → 触发邮件告警) |
| T3 | **429 与连接失败未区分** | 🟠 P1 | 429(配额耗尽)应当立即熔断,不要再每次都等满超时;连接失败才走告警路径 |
| T4 | **无结果缓存** | 🟡 P2 | 相同内容重复提交会重复计费。可直接复用 `censor_logs` 已有的 31 天缓存范式 |
| T5 | **临时性错误无重试** | 🟡 P2 | 一次网络抖动就降级放行,命中率会明显低于预期 |
**改进建议(时序)**
1. 硬超时 3–5s,用独立 `context.WithTimeout`,**不复用请求 ctx**;
2. 内置熔断器:连续 N 次失败或收到 429 → 熔断 5–10 分钟,期间直接 fail-open 且**不发邮件**;
3. 结果按 `hash(userID + 内容)` 缓存,复用 `censor_logs` 的写法;
4. 若坚持不阻塞体验,可改为**先入库 → 异步判定 → 命中则转"待审/折叠"**,但这就不是方案 3 描述的"前端提示"了,需要你二选一(见 §7)。
---
## 3. 逐条评审
### 3.1 用户自填 API 地址与 Key
**可行,但这是整个方案风险最集中的一条。**
🔴 **P0-1 · SSRF(服务端请求伪造)**
用户填的地址由**你的服务器**发起请求。攻击者注册账号后填入 `http://127.0.0.1:6379`、`http://169.254.169.254/latest/meta-data/`、内网段地址,即可:
- 探测内网拓扑与端口存活
- 打云厂商 metadata 拿临时凭证
- 1C1G 机器上 Redis(6379) / Postgres(5432) 通常与 Web 同机
而且**方案 4 的"保存前连通性测试"本身就是一个开箱即用的内网探测入口**——不需要任何绕过技巧,填个地址点保存就能看到响应差异。
必须的防护(缺一不可):
- 仅允许 `https`(例外:显式允许 `http://localhost` 供自建模型调试,且需独立开关)
- 解析 DNS 后**逐个 IP 校验**,拒绝 loopback / link-local(169.254/16) / private(10/8, 172.16/12, 192.168/16) / IPv6 ULA
- **禁用 HTTP 重定向跟随**(否则 302 到内网即可绕过上面的校验)
- 建议在 `internal/shared/multidial.go` 已有自定义 Dialer 的基础上实现,统一在 dial 层拦截
🔴 **P0-2 · Key 存储安全**
- 已验证:全项目**没有任何 at-rest 加密**(grep `aes` / `Encrypt(` / `secretbox` 无结果)
- `users` 表单表存储,新增列即明文落库;DB/备份/导出 CSV 泄露 = Key 全泄露
- 建议:派生密钥加密存储(不要直接用 `conf.Server.Salt`,用 HKDF 派生独立子密钥);后台**只回显掩码**(`sk-...abcd`);**永不写日志**(现有代码有把完整响应写日志的习惯,需确认 LLM 请求体不会被 logrus 打出来)
🟠 **P1 · 协议兼容性未定义**
"API 地址"是任意 OpenAI 兼容地址,还是固定七牛 `https://api.qnaigc.com/v1`?若允许任意地址,则:
- 需约定必须 OpenAI 兼容(否则无法解析)
- 模型名由用户填还是系统固定?不同模型对 JSON 输出的遵循度差异极大
- 建议:**内置预设 + 允许自定义**,预设直接给七牛官方端点,用户只填 Key 和选模型(顺带消掉 SSRF 面)
### 3.2 内置判定类型,用户只能勾选
**方向正确。** 但强烈建议:
- **类型优先映射到七牛 label**(`abuse` / `spam` / `ad` / `flood` / `contraband` …),这部分走 L1,零成本、确定性、无延迟
- LLM 类型只保留七牛覆盖不到的少数几种(如「阴阳怪气 / 恶意暗示」「情绪宣泄 / 人身攻击」)
- 类型定义要写死在 Go 常量 + i18n key 里,与现有 `ForbiddenType`(`internal/security/censor/text.go:16`)保持同一种风格
🟡 注意:判定类型与界面语言是 4 种(zh-CN / zh-TW / en / ja),提示词里的类型描述要用提问箱主人设置的语言,但**被审核的提问文本可能是任意语言**,prompt 需显式要求"按原文语言判断"。
### 3.3 命中时在前端提示提问者
🟠 **P1 · 提示会泄露规则,可被枚举探测**
若提示写成"你的提问包含辱骂内容",攻击者就能用二分法逐词试探出完整判定规则,进而绕过。
现有屏蔽词的提示(`question.errBlockWords`)只说"包含屏蔽词"、不说是哪个 —— **LLM 提示应对齐这个粒度**,不暴露具体类型。
🟠 **P1 · 误判兜底(最重要的一条)**
LLM 判定是非确定性的:同一段文本两次调用可能给出不同结果。如果"命中 = 拒绝提交",那么**误判导致的提问丢失是不可逆的**——提问者写了一大段,被吞了,且不知道为什么。
建议三选一(按推荐度排序):
1. **命中 → 提示 + 仍允许提交**,但给箱主打 ⚠️ 标记并默认折叠(推荐:零丢失,箱主有最终处置权)
2. 命中 → 提示 + 要求二次确认后提交(保留"冷静期"效果,又不丢内容)
3. 命中 → 硬拦(不推荐,除非你接受误判成本)
另需注意:**提示文案走 i18n**,4 种语言各一份(现有 `admin.*` / `question.*` key 的写法)。
### 3.4 保存前连通性与鉴权测试
**可行,但是 SSRF 的头号入口(见 P0-1)。** 补充:
- 测试请求本身也要有超时(≤5s)与 SSRF 校验
- 建议用**最小 chat 请求**(`max_tokens=1`)而不是 `/v1/models`:部分 Key 只有推理权限没有列举模型权限,用 `/v1/models` 会误判为鉴权失败
- 测试通过 ≠ 长期可用:Key 可能随时被吊销/欠费。**保存时通过只是准入条件,运行期的熔断与告警才是主力**
- 保存动作必须防重放:测试是服务端发起的外网请求,需加频率限制(否则可被用来打第三方 / 放大攻击)
### 3.5 失败降级:前端提示 + 邮件告警 + 跳过审核正常提交
🟠 **P1 · fail-open 之后没有补审**
"跳过 LLM 审核并正常提交"意味着降级窗口内恶意内容**照常入库且无人复查**。而攻击者完全可以**主动制造失败**(刷到 Key 超限 / 打满你的连接),把系统稳定地压在降级态。
建议:降级期间入库的提问标记为 `llm_review = 'skipped'`,后台提供"补审"入口(复用 `cmd/censor.go` 已有的批量补审范式),否则降级态就是永久免审。
🔴 **P0-3 · 成本被刷(用户 Key 直接经济损失)**
Key 是**用户自己的钱**。提问入口匿名开放,攻击者可对某个启用了 LLM 的箱主持续提问,一夜烧光配额:
- 必须有**每箱主每日调用上限**(超上限即 fail-open,不再调用)
- 复用 `internal/route/ratelimit.go` 已有的内存滑动窗口限流器做**单 IP 限流**
- 超限后应**自动停用**该箱主的 LLM 审核并通知箱主,而不是静默继续烧钱
🔴 **P0-4 · 邮件轰炸 → SMTP 账号被封**
"调用失败就发邮件"在攻击场景下 = **每条提问一封邮件**。攻击者刷 1000 条提问,你的发信账号会被标记为垃圾邮件发送者甚至直接封号,**影响的是全站所有邮件(含找回密码)**。
必须的节流:
- 按 **箱主 + 时间窗聚合**(建议 10 分钟最多 1 封),窗口内汇总次数
- 收敛粒度建议按**错误类别**(连接失败 / 超时 / 429 / 鉴权失败)分别计数,而不是每次都发
- 触发熔断后**停止发信**,只在恢复时发一封汇总
- 告警收件人建议复用 `conf.Security.SecurityAlertEmail` 的模式,并允许箱主选择关闭告警
---
## 4. 失败降级策略 · 建议矩阵
| 失败类型 | 是否跳过审核 | 是否提示提问者 | 是否发邮件 | 是否熔断 |
|---|---|---|---|---|
| 超时 | 跳过 | 否(静默) | 窗口内聚合 | 连续 N 次 → 熔断 |
| 连接失败 | 跳过 | 否(静默) | 窗口内聚合 | 是 |
| 429 / 配额耗尽 | 跳过 | 否 | 是(1 次) | 是(较长) |
| 401 / 403 鉴权失败 | 跳过 | 否 | 是(1 次) | **自动停用该功能** |
| 响应解析失败 | 跳过(视为未命中) | 否 | 窗口内聚合 | 连续 N 次 → 熔断 |
| 达到日调用上限 | 跳过 | 否 | 是(1 次) | 是(到次日) |
> 原则:**提问者侧永远不要暴露"审核系统坏了"**——否则等于告诉对方"现在可以随便发"。
---
## 5. 风险清单
| ID | 级别 | 问题 | 处置 |
|---|---|---|---|
| P0-1 | 🔴 | 用户自填 API 地址导致 SSRF,连通性测试即探测入口 | 协议白名单 + DNS 解析后逐 IP 校验 + 禁用重定向 |
| P0-2 | 🔴 | Key 明文落库、无 at-rest 加密、可能回显/入日志 | 派生密钥加密 + 后台掩码 + 禁用请求体日志 |
| P0-3 | 🔴 | 用户 Key 被刷造成直接经济损失 | 每日调用上限 + 单 IP 限流 + 超限自动停用 |
| P0-4 | 🔴 | 失败即发信 → 邮件轰炸 → SMTP 封号 | 箱主+时间窗聚合 + 熔断后停发 + 允许关闭告警 |
| P1-1 | 🟠 | 同步阻塞放大(1–10s+),超时预算未定义 | 独立 3–5s deadline,不复用请求 ctx |
| P1-2 | 🟠 | 误判导致提问不可逆丢失 | 改"提示 + 标记折叠"或"二次确认",不要硬拦 |
| P1-3 | 🟠 | 提示文案泄露判定规则,可被枚举绕过 | 提示不暴露具体类型,对齐现有屏蔽词语气 |
| P1-4 | 🟠 | fail-open 后无补审,攻击者可主动维持降级态 | 标记 `skipped` + 后台补审入口 |
| P1-5 | 🟠 | LLM 输出解析不可靠(中文自由文本) | 强制 JSON 输出(`response_format`),严格 schema 校验,解析失败视为未命中 |
| P2-1 | 🟡 | 无结果缓存,重复内容重复计费 | 复用 `censor_logs` 31 天缓存范式 |
| P2-2 | 🟡 | 临时错误无重试 | 1 次快速重试(仅对连接类错误) |
| P2-3 | 🟡 | 提问内容出网给第三方 LLM(匿名提问的隐私) | 设置页明确告知 + 默认关闭 |
| P2-4 | 🟡 | 七牛文本上限 2000 字;`AnswerContentLen` 默认即 2000(边界) | 站长若调高超 2000,七牛会拒 → fail-closed 拒绝发布。需加前置截断或上限校验 |
| P2-5 | 🟡 | 无调用审计(谁、何时、花多少) | 建议记录调用量/耗时/结果,供箱主自查 |
---
## 6. 成本与限额
| 维度 | 建议 |
|---|---|
| 调用时机 | 只对**提问**调用,不要覆盖回答 / 追问 / 私聊(成本与延迟都会失控) |
| 单箱主日上限 | 建议默认 100–300 次/天,可在 `[limits]` 配置,超限 fail-open + 通知箱主 |
| 单 IP 限流 | 复用 `internal/route/ratelimit.go`,建议 5–10 次/分钟 |
| 输入长度 | 截断到提问上限(`QuestionContentLen`,默认 1000 字符),避免超长文本爆 token |
| 模型 | 审核是分类任务,用小模型即可(成本差一个数量级);不要上推理模型 |
| 输出 | `max_tokens` 压到最小(如 64),只回 JSON |
| 缓存 | 相同内容 31 天内不重复调用 |
| 免费额度 | 七牛 AI 推理对新用户有免费 token,可作为内置预设的冷启动优势 |
---
## 6.5 修订:撤回 L1 建议(用户实测反馈后追加)
> 2026-09-10 追加。用户反馈:**七牛对「辱骂」的判定过于严格,正常对话经常被误伤。**
这条反馈直接推翻了本文档 §1 的 L1 建议("收紧 `IsPass()`,把 `abuse` 纳入硬拦")——**该建议予以撤回**,照做只会放大误伤。
配合代码复核,误伤的真实成因有两个,且都是**硬拦语义**造成的:
| 成因 | 代码位置 | 现状 |
|---|---|---|
| 七牛把正常内容判为 `abuse` 且 `suggestion=block` | `qiniu.go:55-68` → `!Pass` → 拒绝 | 提问/私聊**直接发不出去**,用户只看到"内容安全检查不通过 [辱骂]" |
| 审核服务不可用/超时 | `page.go:574`、`chat_handler.go:258` → `err != nil` → **fail-closed 拒绝** | 七牛抖动期间所有提问与私聊一律失败 |
第二个成因尤其容易被忽略:**这不是"判得准不准"的问题,是审核服务一抖全站就发不出内容。**
### 关键约束(七牛官方已确认)
文本审核**不支持白名单**,自定义拉黑词库需**提交工单**处理 —— 也就是说:
**我们既不能让七牛判得更准,也不能让它判得更松,唯一能控制的是"命中之后怎么办"。**
### 修订后的设计原则
**显式规则硬拦,算法推断只标记。**
| 判定来源 | 处置 | 理由 |
|---|---|---|
| 箱主自定义屏蔽词 | **硬拦** | 用户显式指定,知道自己拦的是什么,误伤自负 |
| 算法判定 `politics` / `terrorism` / `porn` | **硬拦** | 合规底线,不能软 |
| 算法判定 `abuse` / `spam` / `ad` / `flood` / `contraband` / `meaningless` | **只打 ⚠️ 标记,照常送达** | 误报率高,误伤不可逆,把决定权交回箱主 |
| 审核服务不可用 | **fail-open + 标记待审** | 避免服务抖动导致全站发不出内容 |
### 落地形态
- `questions` / `messages` 各加一个标记列(GORM `AutoMigrate` 自动加列,无需手写迁移)
- 箱主后台:⚠️ 角标 + 默认折叠 + 可筛选 + 一键"忽略/删除"
- 设置页新增「审核严格度」三档,把权衡交还给用户:
- **严格**:维持现状(不过即拒)
- **宽松(建议默认)**:仅高危三类硬拦,其余标记
- **仅标记**:全部不拒,只标记
- 阈值自建:不采信七牛 `suggestion`,改用已有的 `detail.Score` + 可配阈值(默认偏高,如 0.9)
- 容错:短文本可跳过判定;箱主"这不是辱骂"反馈计数,用于后续调阈值
### 这条路线顺带消掉的风险
零延迟、零外部依赖 → **SSRF、Key 明文、Key 被刷、邮件轰炸四个 P0 全部不再存在**,也无需引入队列或加密主密钥。
---
## 7. 需要你确认的点
1. **命中后的语义**:是「①仅提示 + 仍提交(打标记折叠)」/「②提示 + 二次确认后提交」/「③硬拦拒绝」?
—— 我的建议是 ①,理由见 §3.3。
2. **是否接受同步阻塞**?若不接受,方案 3「前端提示提问者」就要改成异步 + 待审队列(需要新增队列能力,项目现在没有)。
3. **API 地址范围**:固定七牛 `https://api.qnaigc.com/v1`(消灭 SSRF 面)/ 还是允许任意 OpenAI 兼容地址?
4. **模型名**:系统固定一个小模型,还是用户自填?
5. **Key 加密**:是否接受引入 at-rest 加密(需要一个主密钥,放 `[server]` 段)?还是先明文 + `json:"-"` + 掩码回显,后续再补?
6. **告警收件人**:复用 `conf.Security.SecurityAlertEmail`,还是发给箱主本人(`users.email`),还是两者都发?
7. **调用范围**:只审提问,还是也要审回答 / 追问 / 私聊?
8. **是否先做 L1/L2**:我建议先收紧七牛 `IsPass()`(把 `abuse` 纳入 + score 阈值),这条零成本、零延迟,可能就解决了你 80% 的诉求,之后再决定 LLM 要不要上。要不要我先出这部分的变更清单?
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-77
View File
@@ -1,77 +0,0 @@
# TamaBox-v4 P0/P1 修复报告(2026-09-01)
第三轮审计的全部 P0×2 + P1×6 已修复,并在验证过程中额外发现并修复了一处
被 webp 屏障遮蔽的**隐藏编译回归**(见「额外发现」)。全模块构建/vet/测试全绿。
## 🔴 P0 修复
### P0-1 profile.go 导出函数编译错误
- `route/user/profile.go`:`personalData` 是 `[][]interface{}`,原代码把 `interface{}` 直接传给
`sanitizeCell(string)` 导致编译失败。
- 改为逐单元格类型断言:字符串净化防公式注入,非字符串(`time.Time` 等)原样写入。
### P0-2 history-template 复合游标未同步(翻页损坏)
- 采用更彻底的方案:`route/question/page.go` 的 `ListAPI` 响应改为
`{data: [...], next_cursor: "..."}`,由**服务端直接下发** `EncodePinCursor` 编码的复合游标。
- 前端 JS 不再用 `new Date().getTime()*1e6` 自行拼游标——JS 双精度浮点无法精确表示纳秒级
UnixNano(毫秒以下精度丢失),时间戳相同的提问会在翻页边界被跳过。
- JS 层已适配 `ctx.JSON` 的统一包装 `{code, data, message}`(内层才是分页负载)。
## 🟠 P1 修复
### P1-1 upload_images.go 级联删除查询不存在的列
- `DeleteByQuestionID` 重写:先查 `upload_image_questions` 关联行拿 imageID,
再事务内软删 `UploadImage` 主体并删除关联行(原代码直接在错误表上 `WHERE question_id`)。
### P1-2 conversation.go 重复删除二次回退配额
- `MarkDeletedByOwner` 增加幂等守卫:`conv.IsDeletedByOwner == true` 直接返回,
不再重复 `IncrementQuota(-1)`。
### P1-3 questions.go 非 PinFirst 游标 PG 报错 + ASC 方向反
- 游标改用 `strconv.ParseUint` 校验(原 `id < ?` 传 string,PG 下 `integer < text` 无操作符)。
- 排序方向规范化提前,ASC 用 `id > ?`、DESC 用 `id < ?`;解析失败忽略游标(与 PinFirst 一致)。
### P1-4 home.html 供应链 XSS
- 远程公告改 `x-text` 纯文本渲染,移除 `x-html`;meta 行与正文拆成两个元素分别渲染。
### P1-5 tracing span 泄露 query string
- span 名从 `RequestURI` 改为 `URL.Path`,`?t=<token>`、`?code=` 等不再落入 trace。
### P1-6 password.go(第一轮遗留项)
- legacy HMAC-SHA1 比较改用 `crypto/subtle.ConstantTimeCompare`(常量时间,防时序侧信道)。
- `encodePasswordBcrypt` 失败不再静默降级为 HMAC-SHA1 弱哈希:密码超 72 字节按 bcrypt
原生语义截断;bcrypt 生成失败记日志并返回空串(账号校验失败比弱哈希安全,且
`users.go:390` 的 `newHash != ""` 守卫保证空串不会被写库)。
## ⚠️ 额外发现:internal/route 隐藏编译回归(P0 级)
P1-8 批次(autoban 重构)引入、一直被 webp 屏障遮蔽的编译错误,本次通过
`go build -overlay` 全模块类型检查暴露:
- `internal/route/route.go`:使用 `autoban` / `mail` / `shared` 三个标识符但缺少 import;
- `internal/route/autoban.go`:`autoban.Autoban` 类型不存在,应为 `autoban.Banner`。
**若不修复,Linux 上的完整构建同样会失败。** 两处已修复。
## 验证方式(重要)
Windows 沙箱缺 libwebp,`internal/storage` 及其依赖包无法正常编译,导致其中的类型错误
(P0-1、internal/route 回归)在常规 `go build` 下被遮蔽。本次引入 **overlay 桩验证法**:
- `dev/webpstub/`:chai2010/webp 的签名兼容桩(仅 `Encode`/`Options`),保留在仓库供复用;
- `go build -overlay=<临时 overlay.json> ./route/... ./internal/... .` 将
`internal/storage/s3.go` 的 import 临时替换为桩包,实现全模块完整类型检查。
验证结果:
- 全模块 `go build`(overlay)✅
- 全模块 `go vet`(overlay)✅
- 全部可运行测试通过(templates/form/i18n/cache/autoban/censor 等,`go test` sweep)✅
- 含 `TestTemplatesParse` / `TestRenderPublicQuestionList` / `TestRenderPublicQuestionItem` /
`TestRenderUserQuestionList` / `TestTemplateCrossReferences`
- `internal/db` 测试需 `DB_TYPE` 环境变量 + 真实数据库,属既有环境限制,非回归。
## 待办提醒
1. **Linux/CI 环境跑一次 `go build ./...` + `go test ./...` 最终确认**(overlay 桩只覆盖类型检查,
webp CGO 真实编译仍需 libwebp 环境)。
2. P2 9 项(mail header、上传后缀白名单、孤儿文件、dbutil pageSize 上限等)尚未处理。
-51
View File
@@ -1,51 +0,0 @@
# P1 修复落地记录(P1-3 / 4 / 5 / 7 / 8 / 9 / 10)
- **日期**:2026-08-31
- **范围**:按用户要求顺序实施 7 个 P1 项(P1-6 密码比较不在本次范围;P1-3 按要求**不使用 Turnstile/recaptcha**)。
- **P0-1(IP 伪造)暂缓**,按用户指示留待后续。
## 1. P1-3 验证码限流 + 常量时间比较
**文件**:`internal/captcha/math.go`、`internal/route/route.go`
- 答案比对改为 `crypto/subtle.ConstantTimeCompare`,消除时序侧信道。
- `VerifyOnly`(模态框预校验)累加尝试次数,达到 `maxCaptchaAttempts=5` 即销毁该验证码,防止无限轮询爆破。
- `GenerateMath` 处理 `rand.Int` 错误(不再忽略)。
- `/api/captcha/generate`、`/api/captcha/verify` 加上 `rateLimitMiddleware(20, 1m)`。
- ⚠️ 这是**缓解**而非根治:数学答案仍返回给客户端,脚本理论上可读题。根治依赖 P0-1(IP 信任校验)落地后整体提权。
## 2. P1-4 追问"等主人回复"服务端校验
**文件**:`internal/db/follow_ups.go`、`route/question/follow_up.go`
- 新增 `FollowUps.GetLatestByQuestionID`(`Order(created_at DESC) Limit 1`)。
- `PublishFollowUp` 在计数校验后,取该问题最新一条追问;若其 `Answer == ""` 直接拒绝(不再仅依赖前端 `waitingFollowUpReply` 隐藏表单)。
## 3. P1-5 审核可信域名精确匹配
**文件**:`internal/security/censor/text.go`
- `RemoveTrustedURL` 改为边界感知:仅当可信域名后紧跟 `/`、`?`、`#` 或位于字符串结尾时才剥离,避免 `https://github.com.evil.tld` 被误判为 `github.com` 子域而漏审。
## 4. P1-7 删除回退功能配额计数
**文件**:`internal/db/feature_control.go`、`internal/db/questions.go`、`internal/db/conversation.go`
- `IncrementQuota` 负 delta 增加 `WHERE quota_count > 0` 守卫,防止计数减到负数。
- `questions.DeleteByID` 删除提问后回退 `FeatureQuestion` 配额 -1。
- `conversations.MarkDeletedByOwner` 删除私聊会话后回退 `FeatureChat` 配额 -1(并先取 OwnerID,已删除/不存在时幂等返回)。
## 5. P1-8 自动封禁仅计真正 404
**文件**:`internal/route/autoban.go`、`internal/route/route.go`
- `autobanMiddleware` 改为接收共享 `*autoban.Autoban` 实例(由 `New()` 创建一次)。
- 真正的 404 探测计数移入 `f.NotFound` 处理器单独记录;不再把业务性 `302 → /`(语言切换 / 登出 / 投票不存在 / 黑名单跳转)误判为路径扫描导致误封。
## 6. P1-9 服务反代路径穿越 / query / 超时
**文件**:`route/service/proxy.go`
- `forwardPath` 经 `path.Clean` 归一化,`singleJoiningSlash` 安全拼接,杜绝 `../` 越出白名单前缀。
- 透传原始 `RawQuery`(此前静默丢弃)。
- 复用带超时的共享 `Transport`(`ResponseHeaderTimeout=15s`、`IdleConnTimeout=30s`),不再每请求新建。
## 7. P1-10 像素追踪路由收紧
**文件**:`internal/route/route.go`、`internal/context/context.go`、`route/pixel/pixel.go`
- `/api/v1/pixel/{**}` 由 `f.Any` 改为 `f.Get` + `rateLimitMiddleware(30, 1m)`,仅放行只读 GET,并补限流。
- `context.go` 去掉对 `/api/v1/pixel/` 的 CSRF 豁免(GET 本不需 CSRF,写方法已被路由层禁止)。
- `pixel.go`:路径 `path.Clean` 后必须仍在 `/api/` 之下,否则拒绝;响应头仅透传白名单(Content-Type/Length/Encoding/Disposition/Cache-Control/ETag/Expires/Last-Modified/Pragma/Vary),**不透传 Set-Cookie 等**。
## 验证状态
- ✅ `go build` / `go vet` 全绿(不依赖 storage 的 5 个包):`internal/captcha`、`internal/security/censor`、`internal/db`、`route/pixel`、`route/service`。
- ✅ 单元测试通过:`internal/security/censor`、`internal/security/autoban`。
- ✅ 12 个改动文件 `gofmt -w` 已格式化。
- ⚠️ `internal/route/route.go`、`internal/route/autoban.go`、`internal/context/context.go`、`route/question/follow_up.go` 因传递依赖 `internal/storage`(`github.com/chai2010/webp` 缺 libwebp,Windows 沙箱 CGO 限制)**无法在此环境完整编译**;已用 `gofmt -e` 确认无语法错误,请在 Linux / 带 libwebp 环境做完整 `go build ./...` 验证。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

-76
View File
@@ -1,76 +0,0 @@
# TamaBox-v4 — P0 替代方案 + P2 整改(除 P2-4)与复审
- **日期**:2026-09-01
- **范围**:P0 IP 伪造的「免 CIDR」替代方案落地;P2-1~P2-3、P2-5~P2-14(除 P2-4)全部改进;改动后复审。
- **方法**:静态走查 + `go build` / `go vet` / `go test`(受 webp CGO 沙箱限制,含 `internal/storage` 的包仅做 `gofmt -e` 语法校验)。
---
## 一、P0 的「免 CIDR」替代方案(你最关心的点)
原报告建议「维护 Cloudflare 全量 CIDR 白名单 + 自右向左剥离跳数」。但正如你指出的:CIDR 列表太大、且 Cloudflare 边缘节点会变动,维护成本高且易遗漏。本次采用 **不依赖任何 CDN CIDR 清单** 的方案:
### 设计核心(`internal/shared/ip.go`)
1. **优先采用 `CF-Connecting-IP`**(Cloudflare 权威头)。只要源站被 **锁定只接受 Cloudflare 连接**,该头即等于真实客户端 IP,无需任何 IP 白名单。
2. **`trusted_proxies` 仅用于你自己的基础设施**(如本机 nginx `127.0.0.1`、私有 `10.0.0.0/8` LB),默认空。仅当 TCP 对端命中该小清单时才解析 `X-Forwarded-For` / `X-Real-IP`,且从右向左剥离已知可信跳数后取原始客户端。
3. **其余情况一律回退到 `RemoteAddr`**,绝不信任客户端自行提供的头。
### 为什么这能根治,且不用 CIDR
- **关键在部署侧锁定源站**,推荐二选一(都**不需要** CIDR 清单):
- **Cloudflare Tunnel(`cloudflared`)**:源站无任何公网 IP,只与 Cloudflare 建隧道 → 攻击者无法直连源站伪造 `CF-Connecting-IP`。
- **Cloudflare Authenticated Origin Pull(mTLS)**:源站 TLS 要求 Cloudflare 客户端证书 → 非 Cloudflare 连接被拒。
- 二者都让「伪造 `CF-Connecting-IP`」在物理/密码学层面不可行,从而免去了维护庞大 CIDR 的负担。
### 配置变更
- `conf/static.go`:`Security` 新增 `TrustedProxies string \`ini:"trusted_proxies"\``。
- `conf/app.sample.ini`:`[security]` 下新增 `trusted_proxies =`(默认空),并改写 `trust_proxy` 注释,明确「开启 trust_proxy 必须配合 Tunnel/mTLS 锁定源站」。
- `internal/cmd/web.go`:`conf.Init()` 后调用 `shared.InitTrustedProxies()` 解析清单(解析失败则启动报错)。
> 残留风险(已文档化):若 `trust_proxy=true` 但**源站仍可被直连**(未锁源站),攻击者可自行发 `CF-Connecting-IP` 被信任。生产必须配合上述锁定手段。这是「免 CIDR」方案的必要前提。
---
## 二、P2 整改清单(除 P2-4 外全部完成)
| # | 位置 | 改动 |
|---|---|---|
| P2-1 | `route/vote/vote.go:310` | 排序题统计 `json.Unmarshal(s.FormData, &data.Ranking)` → `&data`,修复解析必失败、排名恒为 0 的 bug |
| P2-2 | `route/vote/vote.go` + `route/user/vote.go` | 抽离 `InvalidateResultsCache(voteID)`;除新提交外,草稿「选项/字段重建」「投票删除」也主动失效结果缓存 |
| P2-3 | `internal/db/vote_submissions.go` | `DeleteByVoteID` 改为 `Unscoped()` 物理删除;避免软删行残留占据唯一索引导致「统计归零却无法重投」 |
| P2-5 | `internal/db/questions.go` + `follow_ups.go` + `upload_images.go` | 删除提问时级联软删其 `follow_ups` 与 `upload_images`,消除孤儿记录 |
| P2-6 | `internal/db/users.go:814` | `CountTodayMessagesFromUser` 改用应用本地时区当日 00:00 边界,替代 DB 的 `CURRENT_DATE`,修正每日私信上限窗口偏移 |
| P2-7 | `route/user/profile.go` | 导出 XLSX 单元格前置单引号防御公式注入(以 `= + - @ \t \r \n` 开头的内容按纯文本处理) |
| P2-8 | `internal/db/users.go:244` | 注册 `Create` 遇重复键错误时重新执行 `validate` 返回友好错误(`ErrDuplicateEmail`/`ErrDuplicateDomain`),消除 TOCTOU 暴露底层 DB 错误 |
| P2-9 | `internal/route/gzip.go` | `Write` 将嗅探到的 `Content-Type` 写回响应头使压缩判定生效;`204/304` 不再附加 `Content-Encoding` |
| P2-10 | `internal/route/route.go:244` | `/i18n/{lang}` 重定向拦截反斜杠 `\\` 与控制字符,防开放重定向变体 |
| P2-11 | `internal/route/route.go` | 加 `Strict-Transport-Security`(仅 https 时下发);`lang` cookie 在 https 下设 `Secure` |
| P2-12 | `internal/route/chat/chat_handler.go` | `SendMessage` 发送前重新校验 `FeatureStateOf(FeatureChat).Open`,箱主中途关闭私聊即阻止继续发消息 |
| P2-13 | `internal/db/questions.go` + `route/question/page.go` | 置顶优先分页改用复合键集游标 `(is_pinned, created_at, id)`,消除置顶项与非置顶项 id 交错导致的「加载更多」漏项 |
| P2-14 | `internal/captcha/math.go` | 已由前次 P1-3 覆盖:`rand.Int` 错误已校验(无新增改动) |
> P2-4(Question.Token 仅 6 位/唯一索引)按你要求**跳过**。
---
## 三、复审验证结果
| 项目 | 结果 |
|---|---|
| `go build` 不依赖 storage 的包(`shared/db/captcha/security/censor`) | ✅ 全绿 |
| `go vet` 同上包 | ✅ 无告警 |
| `go test ./route/vote/...` | ✅ 通过(覆盖 P2-1 排名聚合、缓存接口一致) |
| `gofmt -e` 受 webp 影响的编辑文件(`route/user/*`、`route/question/page.go`、`internal/route/*`、`chat_handler.go`) | ✅ 均无语法错误 |
| `route/vote` 整包 `go build` | ✅ 通过(P2-1/P2-2 改动类型校验通过) |
⚠️ 含 `internal/storage`(webp CGO)的包(`internal/route`、`route/user`、`route/question`、`internal/route/chat`、`cmd/web`)**本沙箱无法完整编译**(既定限制,与改动无关)。这些文件已用 `gofmt -e` 确认无语法错误,需在 **Linux / 带 libwebp** 环境跑一次 `go build ./...` 做最终确认。
### 复审新发现 / 残留项
- **P2-2 多实例一致性**:本方案让各实例在「新提交 / 改选项 / 删投票」时各自失效自身进程内缓存,但**跨实例**仍依赖 30s TTL 自然过期(进程内 TTL 缓存的固有限制)。若未来做多实例横向扩容,建议将 `resultsCache` 迁到 Redis 共享缓存,方能做到即时全局一致。当前单实例/低并发场景已足够。
- **P2-5 级联为软删除**:与提问本身的软删除保持一致;对象存储中的实际图片文件未删除(避免误删复用资源),仅清 DB 记录。若需彻底清理文件,应另起异步任务。
- 其余改动逻辑自洽,未发现引入新的越权 / 竞态 / 注入点。
---
## 四、待你确认/执行的部署动作
1. **P0 生效前提**:生产环境用 **Cloudflare Tunnel 或 Authenticated Origin Pull** 锁定源站;并在 `app.ini` 设 `trust_proxy = true`、`trusted_proxies` 填入你自己的反代 IP(如 `127.0.0.1`)。
2. **最终构建**:在带 libwebp 的环境 `go build ./...` 验证全部包。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

-234
View File
@@ -1,234 +0,0 @@
-- ============================================================================
-- TamaBox「加载更多重复加载」根因诊断 v2(按生产真实结构定制)
-- 适用库: schema = nekobox,表 = nekobox.questions / nekobox.users
-- questions: id numeric, created_at/updated_at/deleted_at timestamp(6) WITHOUT time zone,
-- user_id bigint, answer text, is_private/is_pinned/is_read boolean NOT NULL
-- users: id numeric, domain text, ...
-- 脚本用途: 用纯 SQL 复刻 ListAPI(FilterAnswered+PrivateStatus=1+PinFirst, 每页20)的
-- 复合键集翻页,判定「DB 层数据自洽性」+「时区解释是否与线上游标一致」。
-- 覆盖范围: 只读查询 + 会话级临时表,无任何持久写。可放心在生产执行。
-- 使用: psql/pgAdmin/DBeaver 整段执行;若当前库 schema 不是 nekobox,改开头 SET 一行。
-- 判读: 见文件末尾说明;跑完把「查询结果 + NOTICE」整体贴回。
-- ============================================================================
-- ============ 0) 切换 schema(关键:默认 search_path 只有 public,所以之前找不到表) ============
SET search_path TO nekobox, public;
SELECT '连接确认' AS step,
current_database() AS db,
current_schema() AS effective_schema, -- 应显示 nekobox
current_setting('TimeZone') AS session_tz;
-- ============================================================================
-- PART A 环境 / 结构 / 目标集合核对
-- ============================================================================
SELECT 'PART A: 环境与结构核对' AS step;
-- 1) questions 关键列定义(确认 id 为 numeric、created_at 无时区精度6)
SELECT column_name, data_type, datetime_precision, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'nekobox' AND table_name = 'questions'
AND column_name IN ('id','created_at','updated_at','deleted_at','user_id',
'answer','is_private','is_pinned','is_read')
ORDER BY ordinal_position;
-- 2) user_id 相关索引(确认是否命中 idx_questions_user_private_created 复合索引)
SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'nekobox' AND tablename = 'questions'
AND indexdef ILIKE '%user_id%';
-- 3) aoi 箱主定位 + 集合核对(answered_public_all 应=线上 API 总数,曾为 38)
SELECT u.id AS aoi_user_id,
u.domain,
count(*) FILTER (WHERE q.answer IS NOT NULL AND q.answer <> '' AND q.is_private = false)
AS answered_public_all,
count(*) FILTER (WHERE q.answer IS NOT NULL AND q.answer <> '' AND q.is_private = false AND q.is_pinned)
AS answered_public_pinned,
count(*) AS all_questions_rows
FROM users u
LEFT JOIN questions q ON q.user_id = u.id AND q.deleted_at IS NULL
WHERE u.domain = 'aoi'
GROUP BY u.id, u.domain;
-- 4) 该集合最新 6 条(含被删/私密剔除样本),关键看 created_at 的「墙钟」写法
-- -> 与线上 API JSON 的 created_at(如 2026-09-05T00:49:38.648502Z) 逐位比对:
-- 若 DB 墙钟==线上 Z 时间文本 => 读写按 UTC 对称(无时区偏移)
-- 若 DB 墙钟比线上 Z 大 8 小时 => 时区偏移实锤(上海墙钟被当 UTC / 反之)
SELECT q.id,
q.is_pinned,
q.created_at AS db_wall_clock, -- 无时区列原值
to_char(q.created_at, 'YYYY-MM-DD"T"HH24:MI:SS.US') AS db_wall_text,
(extract(epoch FROM q.created_at) * 1000000000)::numeric(30,0) AS nano_if_wall_is_utc,
q.answer <> '' AS has_answer,
q.is_private AS is_private,
q.deleted_at IS NOT NULL AS is_deleted
FROM questions q
WHERE q.user_id = (SELECT id FROM users WHERE domain = 'aoi')
ORDER BY q.is_pinned DESC, q.created_at DESC, q.id DESC
LIMIT 6;
-- 5) 是否存在 created_at 完全相同的行(同刻多行若缺 id tiebreaker 会导致翻页跳行/重复)
SELECT q.created_at, count(*) AS n
FROM questions q
WHERE q.user_id = (SELECT id FROM users WHERE domain = 'aoi')
AND q.deleted_at IS NULL AND q.answer <> '' AND q.is_private = false
GROUP BY q.created_at
HAVING count(*) > 1
ORDER BY n DESC
LIMIT 10;
-- ============================================================================
-- PART B 纯 SQL 复刻 ListAPI 复合键集翻页(核心复现,DB 层数据自洽性判定)
-- 游标锚点直接用 DB 行自身的 created_at(墙钟) 值推进——不经过任何 Go/时区转换,
-- 专门回答: 「库里的数据 + 墙钟 keyset 排序」本身能否正确翻页、不重不漏。
-- ============================================================================
SELECT 'PART B: keyset 翻页复现' AS step;
DROP TABLE IF EXISTS pg_temp.diag_pages;
DO $$
DECLARE
v_uid numeric; -- users.id 也是 numeric
v_page int := 0;
v_rows int;
v_ids text;
v_prev_ids text := '';
v_pin boolean; -- 上一页末条锚点
v_ts timestamp; -- 上一页末条锚点(用 timestamp 变量,避免任何时区换算)
v_id numeric;
v_last_pin boolean;
v_last_ts timestamp;
v_last_id numeric;
r record;
BEGIN
SELECT id INTO v_uid FROM users WHERE domain = 'aoi' LIMIT 1;
IF v_uid IS NULL THEN
RAISE EXCEPTION 'users 中不存在 domain=aoi,请把脚本中的 aoi 换成你的箱 domain';
END IF;
CREATE TEMP TABLE IF NOT EXISTS diag_pages (page_no int, n_rows int, ids text);
DELETE FROM diag_pages;
LOOP
v_page := v_page + 1;
v_ids := '';
v_rows := 0;
v_last_pin := NULL; v_last_ts := NULL; v_last_id := NULL;
FOR r IN
SELECT q.id, q.is_pinned, q.created_at
FROM questions q
WHERE q.user_id = v_uid
AND q.deleted_at IS NULL
AND q.answer IS NOT NULL AND q.answer <> ''
AND q.is_private = false
AND (
CASE
WHEN v_page = 1 THEN TRUE -- 首页无游标
WHEN v_pin THEN -- 锚点在置顶段: 置顶段内更小 + 全部非置顶
(q.is_pinned = true AND (q.created_at < v_ts OR (q.created_at = v_ts AND q.id < v_id)))
OR q.is_pinned = false
ELSE -- 锚点在非置顶段: 非置顶段内更小
q.is_pinned = false AND (q.created_at < v_ts OR (q.created_at = v_ts AND q.id < v_id))
END
)
ORDER BY q.is_pinned DESC, q.created_at DESC, q.id DESC
LIMIT 20
LOOP
v_rows := v_rows + 1;
v_ids := v_ids || r.id::text || ',';
v_last_pin := r.is_pinned;
v_last_ts := r.created_at;
v_last_id := r.id;
END LOOP;
IF v_rows = 0 THEN
RAISE NOTICE '第 % 页: 0 行 -> 翻页正常结束(空页,等价 has_more=false)', v_page;
EXIT;
END IF;
v_ids := left(v_ids, -1);
INSERT INTO diag_pages VALUES (v_page, v_rows, v_ids);
RAISE NOTICE '第 % 页: % 行 ids=[%]', v_page, v_rows, v_ids;
IF v_ids = v_prev_ids THEN
RAISE NOTICE '!!! 异常: 第 % 页与第 % 页完全重复,游标未前进(正是线上 bug 症状)', v_page, v_page - 1;
EXIT;
END IF;
v_prev_ids := v_ids;
IF v_rows < 20 THEN
RAISE NOTICE '本页不足 20 行 -> 已到末页(等价 has_more=false),翻页完成';
EXIT;
END IF;
IF v_page >= 30 THEN
RAISE NOTICE '!!! 异常: 已达 30 页上限仍未收敛,疑似死循环';
EXIT;
END IF;
v_pin := v_last_pin;
v_ts := v_last_ts;
v_id := v_last_id;
END LOOP;
RAISE NOTICE '--- 汇总: 共 % 页 ---', (SELECT count(*) FROM diag_pages);
END $$;
-- 表格形式再输出一次(NOTICE 显示不全时看这里)
SELECT page_no, n_rows, ids FROM diag_pages ORDER BY page_no;
-- 跨页重复检测: 无重复时只返回上面提示
SELECT '跨页重复检测: 若下方无第二张表结果则无重复' AS check_msg;
SELECT unnest(string_to_array(ids, ','))::numeric AS duplicated_question_id
FROM diag_pages
GROUP BY 1
HAVING count(*) > 1;
-- ============================================================================
-- PART C 时区/游标指纹核对 —— 区分「部署版本旧」vs「无时区列+时区解释错位」
-- 线上游标形如 0.1788566476678885000.3089(= pin=0, nano=1788566476678885000, id=3089)
-- 判读: 下面第 1 条若能精确命中 DB 行(3089) => 服务端按「DB墙钟==UTC」生成游标,
-- 与库值一致 => 无时区错位,问题指向部署版本/API 层;
-- 若命中不了 => nano 与实际库值对不上,时区解释有偏差。
-- 用法: 把 nano 数字换成你线上抓到的 next_cursor 中间段;id 换成对应行。
-- ============================================================================
SELECT 'PART C: 时区/游标指纹核对' AS step;
-- 1) nano 反向推导: 假定「墙钟==UTC」得到的 timestamp,是否正好等于 DB 中 3089 行的 created_at?
WITH c(nano) AS (VALUES (1788566476678885000::numeric)) -- <-- 替换为线上 next_cursor 中间段
SELECT c.nano,
(to_timestamp(c.nano / 1000000000.0) AT TIME ZONE 'UTC')::timestamp(6) AS derived_ts_if_wall_is_utc,
(SELECT q.created_at FROM questions q WHERE q.id = 3089) AS actual_db_ts_of_id_3089,
((to_timestamp(c.nano / 1000000000.0) AT TIME ZONE 'UTC')::timestamp(6)
= (SELECT q.created_at FROM questions q WHERE q.id = 3089)) AS match_3089
FROM c;
-- 2) 命中检测(通用版): nano 推导出的墙钟时刻在库中对应哪些行(正常应含线上游标那行)
WITH c(nano) AS (VALUES (1788566476678885000::numeric)) -- <-- 同上
SELECT q.id, q.created_at
FROM questions q, c
WHERE q.user_id = (SELECT id FROM users WHERE domain = 'aoi')
AND q.deleted_at IS NULL AND q.answer <> '' AND q.is_private = false
AND q.created_at = (to_timestamp(c.nano / 1000000000.0) AT TIME ZONE 'UTC')::timestamp(6)
ORDER BY q.id;
-- 3) 对照: 库中是否存在「把线上 JSON 的 Z 时刻原样解释为墙钟」对应的行
-- 即验证: 应用写库时是否把 UTC 时刻的墙钟数字直接存入无时区列(读写对称、无偏移)
SELECT q.id, q.created_at
FROM questions q
WHERE q.user_id = (SELECT id FROM users WHERE domain = 'aoi')
AND q.deleted_at IS NULL AND q.answer <> '' AND q.is_private = false
AND q.created_at IN ('2026-09-05 00:49:38.648502', '2026-09-05 00:01:16.678885') -- <-- 替换为线上首页首尾条的 created_at 文本
ORDER BY q.created_at DESC, q.id DESC;
-- ============================================================================
-- 判读指引
-- 1) PART B 第1页 id 应=线上首页20条;第2页应翻出剩余且无重复(DB 层自洽)
-- 2) PART B 若出现「第N页与第N-1页完全重复」=> 直接用 SQL 在 DB 层复现了 bug -> 数据/排序问题
-- 3) PART C.1 match_3089=true 且 C.3 能命中 => 读写按 UTC 对称,无时区错位,
-- DB keyset 正常时问题锁定在【部署版本/API层】;反之 => 无时区列+时区解释错位
-- 4) PART A.4 直接目视: DB 墙钟 vs 线上 JSON 的 Z 文本,差 8 小时即时区偏移实锤
-- ============================================================================
-224
View File
@@ -1,224 +0,0 @@
-- ============================================================================
-- TamaBox「加载更多重复加载」根因排查 SQL(PostgreSQL)
-- 目标箱: aoi(/api/v1/user/aoi/questions,/_/aoi/3105 底部历史列表同款数据)
-- 依据: internal/db/questions.go GetByUserID (PinFirst) + route/question/page.go ListAPI
-- ListAPI 等价条件: user_id=aoi箱 AND answer<>'' AND is_private=false AND deleted_at IS NULL
-- 排序: is_pinned DESC, created_at DESC, id DESC;每页 20 条
-- 游标: "<isPinned>.<createdAtUnixNano>.<id>"(末条编码,下一页取严格更小的行)
-- 兼容: psql / pgAdmin / DBeaver(避免 psql 专属元命令;NOTICE 各客户端均可看到)
-- 若提示语法错误 -> 说明当前连接不是 PostgreSQL,请改连 PG 实例
-- 使用: 按 PART 顺序整段复制执行,把「结果 + NOTICE 消息」原样粘贴回来
-- ============================================================================
-- ============================================================================
-- PART A 环境 / 表结构 / 集合核对
-- 目的: 核对列类型与精度、索引,确认下方 SQL 筛选后条数与线上 API 总数一致
-- (线上 page_size=99 曾返回 38 条 -> 这里 answered_public_all 应=38,
-- 若不一致说明还有隐藏过滤或连接的不是同一库)
-- ============================================================================
SELECT 'PART A: 环境与结构核对' AS step;
SELECT current_setting('server_version') AS server_version,
current_setting('TimeZone') AS session_tz,
version() AS full_version;
-- 1) questions 关键列定义(看 created_at 是 timestamptz 还是 timestamp、精度几位)
SELECT column_name, data_type, datetime_precision, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'questions'
AND column_name IN ('id','created_at','updated_at','deleted_at',
'user_id','answer','is_private','is_pinned','is_read')
ORDER BY ordinal_position;
-- 2) user_id 相关索引(若只有单列 user_id 索引即可;看是否缺复合索引影响性能,不影响正确性)
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'questions' AND indexdef ILIKE '%user_id%';
-- 3) 目标集合核对:应=线上 API 总数(曾为 38)
SELECT u.id AS aoi_user_id,
u.domain AS domain,
count(*) FILTER (WHERE q.answer <> '' AND q.is_private = false)
AS answered_public_all,
count(*) FILTER (WHERE q.answer <> '' AND q.is_private = false AND q.is_pinned)
AS answered_public_pinned,
count(*) AS all_rows_for_user
FROM users u
LEFT JOIN questions q ON q.user_id = u.id AND q.deleted_at IS NULL
WHERE u.domain = 'aoi'
GROUP BY u.id, u.domain;
-- 4) 该集合最新 5 条:把 created_at 按 UTC 与 UnixNano 打出来,
-- 便于和线上 API JSON / next_cursor(形如 0.1788566476678885000.3089)逐位目视比对
SELECT q.id,
q.is_pinned,
q.created_at,
to_char(q.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US') AS created_at_utc_text,
(extract(epoch FROM q.created_at) * 1000000000)::numeric(30,0) AS created_at_unix_nano
FROM questions q
JOIN users u ON u.id = q.user_id AND u.domain = 'aoi'
WHERE q.answer <> '' AND q.is_private = false AND q.deleted_at IS NULL
ORDER BY q.is_pinned DESC, q.created_at DESC, q.id DESC
LIMIT 5;
-- 5) 是否存在 created_at 完全相同的行(同一时间多行时若旧版漏 id tiebreaker 会翻页跳行/重复)
SELECT q.created_at, count(*) AS n
FROM questions q
JOIN users u ON u.id = q.user_id AND u.domain = 'aoi'
WHERE q.answer <> '' AND q.is_private = false AND q.deleted_at IS NULL
GROUP BY q.created_at
HAVING count(*) > 1
ORDER BY n DESC
LIMIT 10;
-- ============================================================================
-- PART B 用纯 SQL 完整复刻 ListAPI 的复合键集翻页(核心复现)
-- 目的: 逐页打印每页的 id 序列。若 DB 层正常: 第1页 20 条、第2页 18 条后结束,
-- 且无任何一行与上一页重复(总 38 条不重不漏)。
-- 若 DB 层有 bug: 会出现重复页 / 跳页 / 死循环(脚本会检测并提前退出)。
-- 判读:
-- [DB 层正常] 第1页id集合与线上首页一致,第2页正常翻出剩余 -> 问题在应用层
-- (部署版本旧 / GORM 参数转换),与 DB 无关,见 PART C 指纹进一步定位。
-- [DB 层异常] 某页与上一页完全重复 / 提前空页 / 顺序错乱 -> 问题在数据或库配置,
-- 把输出贴回即可看到错在哪一页。
-- ============================================================================
SELECT 'PART B: keyset 翻页复现' AS step;
DROP TABLE IF EXISTS pg_temp.diag_pages;
DO $$
DECLARE
v_uid bigint;
v_page int := 0;
v_rows int;
v_ids text;
v_prev_ids text := '';
v_pin boolean; -- 上一页末条 is_pinned(游标锚点)
v_ts timestamptz; -- 上一页末条 created_at(游标锚点)
v_id bigint; -- 上一页末条 id(游标锚点)
v_last_pin boolean;
v_last_ts timestamptz;
v_last_id bigint;
v_note text;
r record;
BEGIN
SELECT id INTO v_uid FROM users WHERE domain = 'aoi' LIMIT 1;
IF v_uid IS NULL THEN
RAISE EXCEPTION 'users 表中不存在 domain=aoi 的用户,请把脚本里的 aoi 换成你的箱 domain';
END IF;
CREATE TEMP TABLE diag_pages (page_no int, n_rows int, ids text);
LOOP
v_page := v_page + 1;
v_ids := '';
v_rows := 0;
v_last_pin := NULL; v_last_ts := NULL; v_last_id := NULL;
FOR r IN
SELECT q.id, q.is_pinned, q.created_at
FROM questions q
WHERE q.user_id = v_uid
AND q.answer <> ''
AND q.is_private = false
AND q.deleted_at IS NULL
AND (
CASE
WHEN v_page = 1 THEN TRUE -- 首页无游标
WHEN v_pin THEN -- 游标位于置顶段: 置顶段更小 + 全部非置顶
(q.is_pinned = true AND (q.created_at < v_ts OR (q.created_at = v_ts AND q.id < v_id)))
OR q.is_pinned = false
ELSE -- 游标位于非置顶段: 非置顶段内更小
q.is_pinned = false AND (q.created_at < v_ts OR (q.created_at = v_ts AND q.id < v_id))
END
)
ORDER BY q.is_pinned DESC, q.created_at DESC, q.id DESC
LIMIT 20
LOOP
v_rows := v_rows + 1;
v_ids := v_ids || r.id::text || ',';
v_last_pin := r.is_pinned;
v_last_ts := r.created_at;
v_last_id := r.id;
END LOOP;
IF v_rows = 0 THEN
RAISE NOTICE '第 % 页: 0 行 -> 翻页正常结束(空页,等价 has_more=false)', v_page;
EXIT;
END IF;
v_ids := left(v_ids, -1);
INSERT INTO diag_pages VALUES (v_page, v_rows, v_ids);
RAISE NOTICE '第 % 页: % 行 ids=[%]', v_page, v_rows, v_ids;
-- 死循环防护: 本页与上一页逐字相同(key set 未前进)则提前退出并标记异常
IF v_ids = v_prev_ids THEN
RAISE NOTICE '!!! 异常: 第 % 页与第 % 页内容完全重复,游标未前进(复现线上 bug 的症状)', v_page, v_page - 1;
EXIT;
END IF;
v_prev_ids := v_ids;
-- 末页判定: 本页不足 20 行 -> 已无下一页(等价 ListAPI 的 has_more=false)
IF v_rows < 20 THEN
RAISE NOTICE '本页不足 20 行 -> 已到末页(等价 has_more=false),翻页完成';
EXIT;
END IF;
IF v_page >= 30 THEN
RAISE NOTICE '!!! 异常: 已达 30 页上限仍未收敛,疑似死循环';
EXIT;
END IF;
-- 前进游标锚点 = 本页末条
v_pin := v_last_pin;
v_ts := v_last_ts;
v_id := v_last_id;
END LOOP;
RAISE NOTICE '--- 汇总: 共 % 页 ---', (SELECT count(*) FROM diag_pages);
END $$;
-- 同一会话内再以表格形式输出一次(若客户端 NOTICE 显示不全,看这个即可)
SELECT page_no, n_rows, ids
FROM pg_temp.diag_pages
ORDER BY page_no;
-- 重复检测: 跨页是否存在重复 id(正常应为 0 行,无重复时只显示上一条提示)
SELECT '跨页重复检测: 若下方无第二张表结果则无重复' AS check_msg;
SELECT unnest(string_to_array(ids, ','))::bigint AS duplicated_question_id
FROM pg_temp.diag_pages
GROUP BY 1
HAVING count(*) > 1;
-- ============================================================================
-- PART C created_at 精度指纹核对(可选,用于区分「部署版本旧」vs「DB 环境问题」)
-- 目的: 若线上 next_cursor 里的 UnixNano(如 0.1788566476678885000.3089 中的
-- 1788566476678885000)能在下方查询里精确命中对应 id 的 created_at_unix_nano,
-- 说明 API 下发的游标与 DB 行完全对应 -> 问题几乎锁定在部署版本/API 层。
-- 若命中不了 / 差着末几位 -> 说明 DB 存值与线上 JSON 时间对不上,是库环境问题。
-- 用法: 把下方 id 列表替换成你想核对的线上行(默认最新几条 + 3089/3105)。
-- ============================================================================
SELECT 'PART C: created_at 精度指纹' AS step;
SELECT q.id,
q.is_pinned,
to_char(q.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US') AS created_at_utc_text,
(extract(epoch FROM q.created_at) * 1000000000)::numeric(30,0) AS created_at_unix_nano,
(extract(epoch FROM q.created_at) * 1000000000)::numeric(30,0) % 1000 AS nano_tail3 -- 应为0: 微秒精度*1000
FROM questions q
WHERE q.id IN (3109, 3105, 3089, 3088) -- 替换成线上首页前几条 id
ORDER BY q.id DESC;
-- ============================================================================
-- 判读指引(复制上面的 PART A/B 即可,C 按需):
-- 1) PART A.3 的 answered_public_all 应与线上 API 总数一致(曾为 38)
-- 2) PART B 第1页应=线上首页20条;第2页应翻出剩余且与第1页无重复 ->
-- DB 层 keyset 自洽 = 问题在应用层(部署版本/参数转换)
-- 3) 若 PART B 出现「某页与上一页完全相同」= 直接在 DB 层复现了 bug
-- 4) PART C 的 unix_nano 与线上 next_cursor 中间段逐位一致 = 版本/API 层问题
-- ============================================================================
-779
View File
@@ -1,779 +0,0 @@
/* TamaBox 全站基础样式:设计令牌 / 主题覆盖 / 组件质感 / 响应式。
由 base/header.html 内联块抽出,集中一份便于缓存与维护。
⚠️ 改完务必把 header 里的 ?v= 版本号 +1,否则老访客仍用缓存。 */
/* 设计令牌:亮色为默认(:root),深色在 html[data-theme="dark"] 覆盖。
页面与组件样式一律引用 var(--tb-*),不再写死色值/圆角/阴影;
var() 的第二个参数仅作兜底(旧缓存页、邮件模板等不引入本文件的场景)。
改配色只动这一块,亮暗两套自动保持一致。 */
:root {
--tb-bg: #f7f8fa;
--tb-surface: #ffffff;
--tb-surface-2: #f4f6f8;
--tb-surface-hover: #f3f7ff;
/* 顶栏底色:默认跟随卡片面;主题想消除「纯白顶栏压在彩色底上」的割裂时单独覆盖 */
--tb-navbar: var(--tb-surface);
--tb-border: #e8eaee;
--tb-text: #1f2329;
--tb-text-secondary: #5c6370;
--tb-text-muted: #8a919c;
--tb-primary: #1e87f0;
--tb-primary-weak: rgba(30, 135, 240, 0.10);
--tb-radius: 12px;
--tb-radius-sm: 8px;
--tb-radius-xs: 6px;
--tb-shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 12px 28px -18px rgba(16, 24, 40, 0.20);
--tb-shadow-hover: 0 1px 2px rgba(16, 24, 40, 0.05), 0 18px 40px -20px rgba(16, 24, 40, 0.28);
--tb-font: system-ui, -apple-system, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
/* 整页背景:主题可覆盖。图案层叠在背景图之上,两者都不用时保持纯色底。 */
--tb-page-image: none;
--tb-page-pattern: none;
--tb-page-pattern-size: auto;
/* 装饰层:固定像素尺寸 + 四角定位,不受 cover 裁切影响(整页背景图做不到这一点,
视口比例一变,贴在图片边缘的插画就会被裁掉)。
⚠️ 固定 6 个槽位:background-* 各列表的层数必须恒定(1 图案 + 6 装饰 + 1 背景 = 8),
缺省用 none 占位;值的个数一变,多背景的分层就会整体错位。 */
--tb-deco: none, none, none, none, none, none;
--tb-deco-size: auto, auto, auto, auto, auto, auto;
--tb-deco-position: left top, left top, left top, left top, left top, left top;
}
html[data-theme="dark"] {
--tb-bg: #1a1d23;
--tb-surface: #242830;
--tb-surface-2: #2d323c;
--tb-surface-hover: rgba(92, 141, 255, 0.12);
--tb-border: #3a404c;
--tb-text: #d8dce4;
--tb-text-secondary: #a8b0bd;
--tb-text-muted: #8b93a1;
--tb-primary: #5c8dff;
--tb-primary-weak: rgba(92, 141, 255, 0.14);
--tb-shadow: 0 1px 2px rgba(0, 0, 0, 0.30), 0 12px 28px -18px rgba(0, 0, 0, 0.55);
--tb-shadow-hover: 0 1px 2px rgba(0, 0, 0, 0.35), 0 18px 40px -20px rgba(0, 0, 0, 0.65);
}
/* UIkit 补丁:复刻原自建版 "modify-black-font"(官方版与自建版仅 3 处颜色差异——把有色容器内默认白字/白标题改为黑字)。
模板当前只用到 .uk-light 容器(用户背景封面,question/list.html、question-not-found.html;
其中 h3/p 已由 #usernamecolor/#introcolor 内联 style 覆盖),故只需补该容器的 3 处投影即可保持渲染一致。
若日后新增 .uk-card-primary/.uk-card-secondary/.uk-section-primary/.uk-tile-*/.uk-overlay-primary 等容器且需黑字,须同步扩展。 */
.uk-light { color: rgba(0,0,0,.7); }
.uk-light .uk-h1, .uk-light .uk-h2, .uk-light .uk-h3, .uk-light .uk-h4, .uk-light .uk-h5, .uk-light .uk-h6,
.uk-light .uk-heading-2xlarge, .uk-light .uk-heading-large, .uk-light .uk-heading-medium, .uk-light .uk-heading-small, .uk-light .uk-heading-xlarge,
.uk-light h1, .uk-light h2, .uk-light h3, .uk-light h4, .uk-light h5, .uk-light h6 { color: #000; }
.uk-light .uk-table th { color: rgba(0,0,0,.7); }
.grecaptcha-badge {visibility: hidden;}
.icon-container {
display: flex;
justify-content: center;
width: 100%;
}
.icon {
margin-left: 10px;
width: 20px; /* 设置图标的宽度 */
height: auto; /* 保持图标的高度自动调整 */
}
.emoji-placeholder {
display: inline-block;
animation: jump 0.5s ease-in-out;
}
@keyframes jump {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
/* 防止 UIkit Dropbar 初始闪烁 */
.uk-dropbar {
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0.2s ease;
}
.uk-dropbar.uk-open {
opacity: 1;
visibility: visible;
}
a.uk-button.uk-button-link {
text-transform: none;
}
/* 移动端优化:缩小字体和间距,避免右侧空白 */
@media (max-width: 640px) {
.uk-logo {
font-size: 1rem;
}
.uk-navbar-nav a {
font-size: 0.78rem;
padding: 0 4px;
}
.emoji-placeholder {
font-size: 1rem;
}
/* 移动端用户名限宽省略,避免长英文用户名撑爆顶栏 */
.tb-nav-username {
max-width: 64px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
vertical-align: middle;
}
}
/* 深色模式:html[data-theme="dark"] 时覆盖 UIkit 3.3.3 编译主题的关键颜色 */
/* 变量(--tb-*)已上移到 head 顶部的令牌块,亮暗两套同名常量在那里定义。 */
/* 根元素与 body 一起覆盖,确保整个视口(含内容不足一屏时的空白区)都是深色背景 */
html[data-theme="dark"],
html[data-theme="dark"] body {
background-color: var(--tb-bg) !important;
color: var(--tb-text) !important;
}
/* 通用表面容器 */
html[data-theme="dark"] .uk-card,
html[data-theme="dark"] .uk-card-default,
html[data-theme="dark"] .uk-card-secondary,
html[data-theme="dark"] .uk-dropbar,
html[data-theme="dark"] .uk-navbar-dropdown,
html[data-theme="dark"] .uk-modal-dialog,
html[data-theme="dark"] .uk-dropdown,
html[data-theme="dark"] .uk-offcanvas-bar,
html[data-theme="dark"] .uk-section,
html[data-theme="dark"] .uk-tile,
html[data-theme="dark"] .uk-panel,
html[data-theme="dark"] .uk-background-default,
html[data-theme="dark"] .uk-search-default .uk-search-input {
background-color: var(--tb-surface) !important;
color: var(--tb-text) !important;
}
html[data-theme="dark"] .uk-navbar-container {
background-color: var(--tb-navbar, var(--tb-surface)) !important;
color: var(--tb-text) !important;
}
html[data-theme="dark"] .uk-navbar-nav > li > a,
html[data-theme="dark"] .uk-card a,
html[data-theme="dark"] .uk-link,
html[data-theme="dark"] a.uk-button-link,
html[data-theme="dark"] .uk-navbar-dropdown-nav > li > a,
html[data-theme="dark"] .uk-dropdown-nav > li > a,
html[data-theme="dark"] .uk-list a {
color: var(--tb-text) !important;
}
html[data-theme="dark"] .uk-navbar-nav > li > a:hover,
html[data-theme="dark"] .uk-card a:hover,
html[data-theme="dark"] .uk-navbar-dropdown-nav > li > a:hover,
html[data-theme="dark"] .uk-dropdown-nav > li > a:hover {
color: var(--tb-primary) !important;
}
html[data-theme="dark"] .uk-input,
html[data-theme="dark"] .uk-textarea,
html[data-theme="dark"] .uk-select {
background-color: var(--tb-surface-2) !important;
color: var(--tb-text) !important;
border-color: var(--tb-border) !important;
}
html[data-theme="dark"] .uk-input:focus,
html[data-theme="dark"] .uk-textarea:focus,
html[data-theme="dark"] .uk-select:focus {
border-color: var(--tb-primary) !important;
}
html[data-theme="dark"] .uk-text-muted,
html[data-theme="dark"] .uk-text-meta,
html[data-theme="dark"] .uk-text-secondary,
html[data-theme="dark"] .uk-text-muted a,
html[data-theme="dark"] .uk-text-small.uk-text-muted {
color: var(--tb-text-muted) !important;
}
html[data-theme="dark"] .uk-divider-icon::after,
html[data-theme="dark"] hr,
html[data-theme="dark"] .uk-table th,
html[data-theme="dark"] .uk-table td,
html[data-theme="dark"] .uk-list-divider > li:nth-child(n+2) {
border-color: var(--tb-border) !important;
}
html[data-theme="dark"] .uk-button-default {
background-color: var(--tb-surface-2) !important;
color: var(--tb-text) !important;
border-color: var(--tb-border) !important;
}
html[data-theme="dark"] .uk-modal-title,
html[data-theme="dark"] h1, html[data-theme="dark"] h2, html[data-theme="dark"] h3,
html[data-theme="dark"] h4, html[data-theme="dark"] h5, html[data-theme="dark"] h6,
html[data-theme="dark"] .uk-card-title {
color: var(--tb-text) !important;
}
html[data-theme="dark"] .uk-alert-warning,
html[data-theme="dark"] .uk-alert-danger,
html[data-theme="dark"] .uk-alert-success,
html[data-theme="dark"] .uk-alert-primary {
background-color: var(--tb-surface-2) !important;
color: var(--tb-text) !important;
}
html[data-theme="dark"] #math-captcha-modal .uk-modal-dialog {
background-color: var(--tb-surface) !important;
color: var(--tb-text) !important;
}
html[data-theme="dark"] #math-captcha-modal .captcha-equation-box {
background-color: var(--tb-surface-2) !important;
border-color: var(--tb-border) !important;
}
html[data-theme="dark"] #math-captcha-modal .math-captcha-modal-question {
color: var(--tb-text) !important;
}
html[data-theme="dark"] #math-captcha-modal #captcha-modal-answer {
background-color: var(--tb-surface) !important;
color: var(--tb-text) !important;
border-color: var(--tb-border) !important;
}
html[data-theme="dark"] #math-captcha-modal #captcha-modal-answer::placeholder {
color: var(--tb-text-muted) !important;
}
html[data-theme="dark"] #math-captcha-modal .uk-text-meta,
html[data-theme="dark"] #math-captcha-modal #captcha-modal-refresh {
color: var(--tb-text-muted) !important;
}
html[data-theme="dark"] #math-captcha-modal #captcha-modal-refresh:hover {
color: var(--tb-primary) !important;
}
html[data-theme="dark"] .uk-label {
color: #fff !important;
}
/* 自定义组件(探索页推荐卡片等)深色覆盖 */
html[data-theme="dark"] .recommend-card,
html[data-theme="dark"] .recommend-card:hover {
background-color: var(--tb-surface-2) !important;
}
html[data-theme="dark"] .explore-name a {
color: var(--tb-text) !important;
}
html[data-theme="dark"] .explore-name a:hover {
color: var(--tb-primary) !important;
}
html[data-theme="dark"] .explore-intro {
color: var(--tb-text-muted) !important;
}
/* 切换按钮图标切换(内联 SVG,不依赖 UIkit 图标库) */
#theme-toggle .tb-icon-light, #theme-toggle-mobile .tb-icon-light,
#theme-toggle .tb-icon-dark, #theme-toggle-mobile .tb-icon-dark {
display: inline-flex;
align-items: center;
}
#theme-toggle .tb-icon-light svg, #theme-toggle-mobile .tb-icon-light svg,
#theme-toggle .tb-icon-dark svg, #theme-toggle-mobile .tb-icon-dark svg { display: block; }
#theme-toggle .tb-icon-dark, #theme-toggle-mobile .tb-icon-dark { display: none; }
html[data-theme="dark"] #theme-toggle .tb-icon-light,
html[data-theme="dark"] #theme-toggle-mobile .tb-icon-light { display: none; }
html[data-theme="dark"] #theme-toggle .tb-icon-dark,
html[data-theme="dark"] #theme-toggle-mobile .tb-icon-dark { display: inline-flex; }
/* 基础质感层:只调整外观(圆角/投影/字体/数字对齐),不改任何结构与布局。
颜色一律取自头部令牌,亮暗两套自动生效,因此这里不需要再写 html[data-theme="dark"] 分支。 */
/* 根元素与 body 同时上色:内容不足一屏时,body 之外的视口区域也要是同一底色 */
html,
body {
font-family: var(--tb-font);
background-color: var(--tb-bg);
/* 主题的整页背景:四角装饰 + 平铺图案 + cover 背景图(共 6 层,顺序即绘制顺序) */
background-image: var(--tb-page-pattern), var(--tb-deco), var(--tb-page-image);
background-size: var(--tb-page-pattern-size), var(--tb-deco-size), cover;
background-repeat: repeat, no-repeat, no-repeat, no-repeat, no-repeat, no-repeat, no-repeat,
no-repeat;
background-position: left top, var(--tb-deco-position), center center;
background-attachment: scroll, fixed, fixed, fixed, fixed, fixed, fixed, fixed;
color: var(--tb-text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 卡片:12px 圆角 + 一层极淡接触影 + 一层大扩散影(UIkit 默认的 0 5px 15px 偏硬) */
.uk-card {
border-radius: var(--tb-radius);
}
.uk-card-default {
background-color: var(--tb-surface);
box-shadow: var(--tb-shadow);
}
.uk-card-default.uk-card-hover:hover {
box-shadow: var(--tb-shadow-hover);
}
.uk-card-header {
border-radius: var(--tb-radius) var(--tb-radius) 0 0;
}
.uk-card-footer {
border-radius: 0 0 var(--tb-radius) var(--tb-radius);
}
.uk-card,
.uk-button,
.uk-alert,
.uk-label {
transition: box-shadow 0.18s ease, background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease;
}
/* 交互元素:统一 8px 圆角,标签 6px */
.uk-button {
border-radius: var(--tb-radius-sm);
}
.uk-input,
.uk-select,
.uk-textarea,
.uk-form-small,
.uk-search-input {
border-radius: var(--tb-radius-sm);
}
.uk-label {
border-radius: var(--tb-radius-xs);
}
.uk-alert {
border-radius: 10px;
}
.uk-modal-dialog {
border-radius: 14px;
}
.uk-dropdown,
.uk-navbar-dropdown {
border-radius: 10px;
box-shadow: var(--tb-shadow);
}
.uk-navbar-container:not(.uk-navbar-transparent) {
background-color: var(--tb-navbar, var(--tb-surface));
}
/* 键盘可达性:仅在键盘聚焦时出现描边,鼠标点击不出现 */
.uk-button:focus-visible,
.uk-navbar-nav > li > a:focus-visible {
outline: 2px solid var(--tb-primary);
outline-offset: 2px;
}
/* 次要文字统一走令牌,避免 #999 / #8a919c 混用 */
.uk-text-muted,
.uk-text-meta {
color: var(--tb-text-muted);
}
/* 主题选择器(设置页):色块 + 名称。纯展示,不参与主题令牌,所以放在基础层。 */
.tb-theme-hint {
margin: 0 0 6px;
font-size: 0.8rem;
color: var(--tb-text-muted);
}
.tb-theme-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
margin: 8px 0 2px;
}
.tb-theme-opt {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 9px;
border: 1px solid var(--tb-border);
border-radius: var(--tb-radius-sm);
cursor: pointer;
}
.tb-theme-opt:hover {
background-color: var(--tb-surface-hover);
}
.tb-theme-opt input {
margin: 0;
}
.tb-theme-swatch {
flex: 0 0 auto;
width: 26px;
height: 26px;
border-radius: var(--tb-radius-xs);
border: 1px solid var(--tb-border);
background-color: var(--tb-surface-2);
}
/* 选中态用描边而不是 :has(),兼容老 WebView */
.tb-theme-opt input:checked + .tb-theme-swatch {
outline: 2px solid var(--tb-primary);
outline-offset: 1px;
}
.tb-theme-name {
font-size: 0.84rem;
line-height: 1.3;
}
.tb-theme-opt input:checked ~ .tb-theme-name {
color: var(--tb-primary);
}
/* UIkit 组件跟随主题色:UIkit 3.3.3 的主色是编译期常量(#1e87f0),
CSS 变量注入不进去,只能按组件覆盖。基础主题的 --tb-primary 恰好就是 #1e87f0,
所以这段对默认外观零改动,只是让箱主主题能贯穿到按钮/链接/勾选。 */
a, .uk-link {
color: var(--tb-primary);
}
a:hover, .uk-link:hover {
color: var(--tb-primary);
}
.uk-button-primary {
background-color: var(--tb-primary);
}
.uk-button-primary:hover,
.uk-button-primary:focus {
background-color: var(--tb-primary);
filter: brightness(1.07);
}
.uk-checkbox:checked,
.uk-checkbox:indeterminate,
.uk-radio:checked {
background-color: var(--tb-primary);
border-color: transparent;
}
/* 胶囊按钮/状态标签(提问管理里的「置顶」「未回答」,投票的「已参与」)也走主色。
danger / warning / success / secondary 是语义色,必须用 :not() 排除——
同特异性下 base.css 在 UIkit 之后加载,不排除会把它们一并刷成主色。 */
.uk-label:not(.uk-label-danger):not(.uk-label-warning):not(.uk-label-success):not(.uk-label-secondary) {
background-color: var(--tb-primary);
}
.uk-input:focus,
.uk-select:focus,
.uk-textarea:focus {
border-color: var(--tb-primary);
}
.uk-tab > .uk-active > a {
border-color: var(--tb-primary);
}
.uk-text-primary {
color: var(--tb-primary) !important;
}
/* 轻量胶囊:用于投票类型、热门标签等「只是分类信息」的位置。
实心 uk-label 一屏多个会变成一排色块,这里统一降级为浅底 + 次要色文字。 */
.tb-chip {
display: inline-block;
padding: 1px 8px;
border-radius: var(--tb-radius-xs);
background-color: var(--tb-surface-2);
color: var(--tb-text-secondary);
font-size: 0.72rem;
line-height: 1.7;
white-space: nowrap;
}
a.tb-chip:hover {
background-color: var(--tb-primary-weak);
color: var(--tb-primary);
text-decoration: none;
}
/* 统计/日期等数字列等宽对齐,避免刷新时左右跳动 */
.uk-table,
.tb-num {
font-variant-numeric: tabular-nums;
}
/* 图片上传条:原为 item/new-question 两处各写一份内联 style(写死 #f8f8f8,深色下露白),
收敛到一处并走令牌,亮暗自动适配 */
.tb-img-uploader {
width: 100%;
background-color: var(--tb-surface-2);
display: flex;
padding-top: 5px;
padding-bottom: 5px;
align-items: center;
justify-content: flex-start;
gap: 5px;
}
/* 背景图封面(提问箱主页):渐变遮罩,保证用户自选文字色叠加后仍可读。
默认浅色遮罩(页面 .uk-light 补丁把文字兜底成深色);若箱主选了浅色文字,
question/list.html 会按亮度加上 .tb-cover-dark 换成深色遮罩。 */
.tb-cover {
position: relative;
border-radius: var(--tb-radius) var(--tb-radius) 0 0;
overflow: hidden;
}
/* ⚠️ 必须带 .uk-flex 复合选择器:UIkit 3.3.3 里有
.uk-flex::after,.uk-flex::before{display:none}(优先级 0,2,0),
会把这个遮罩伪元素整个干掉——不报错、只是什么都不显示(踩过一次)。
单写 .tb-cover::after 优先级不够,会被它压回 display:none。 */
.tb-cover::after,
.tb-cover.uk-flex::after {
content: "";
display: block;
position: absolute;
inset: 0;
z-index: 0;
background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.58) 55%, rgba(255, 255, 255, 0.84) 100%);
pointer-events: none;
}
.tb-cover.tb-cover-dark::after {
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.16) 45%, rgba(0, 0, 0, 0.46) 100%);
}
.tb-cover > * {
position: relative;
z-index: 1;
}
.tb-cover h3,
.tb-cover p {
text-shadow: 0 1px 10px rgba(255, 255, 255, 0.65);
}
.tb-cover.tb-cover-dark h3,
.tb-cover.tb-cover-dark p {
text-shadow: 0 1px 12px rgba(0, 0, 0, 0.45);
}
/* 尊重系统的减少动效设置 */
@media (prefers-reduced-motion: reduce) {
.uk-card,
.uk-button,
.emoji-placeholder {
transition: none;
animation: none;
}
}
/* 顶栏自适应:导航全部直接显示,窄屏时分级缩小字号/间距,保证英文/日文等长文字不溢出 */
.uk-navbar-nav a {
font-size: 0.95rem;
padding: 0 10px;
}
/* 未登录移动端占位:桌面端隐藏,仅 ≤640px 显示(见下方 media 查询)。
桌面端布局与改动前完全一致,不受影响。 */
.tb-mobile-balancer { display: none; }
/* 桌面端保持 UIkit 原生居中三列布局(无需额外 CSS) */
.tb-navbar .uk-logo {
white-space: nowrap;
}
@media (max-width: 1200px) {
.uk-navbar-nav a {
font-size: 0.88rem;
padding: 0 8px;
}
}
@media (max-width: 1000px) {
.uk-navbar-nav a {
font-size: 0.82rem;
padding: 0 6px;
}
.uk-logo {
font-size: 1.1rem;
}
/* 登录用户名限宽,过长省略号截断 */
.tb-nav-username {
max-width: 110px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
vertical-align: middle;
}
}
@media (max-width: 800px) {
.uk-navbar-nav a {
font-size: 0.78rem;
padding: 0 4px;
}
.uk-logo {
font-size: 1rem;
}
.tb-nav-username {
max-width: 80px;
}
}
/* 手机端(≤640px):沿用 UIkit 桌面端 navbar 的原生三列定位。
不再重写 .uk-navbar-center / center-left / center-right 的定位,
避免 logo 被挤到下一行;底部语言与深色模式按钮仍由下方独立栏承载。 */
@media (max-width: 640px) {
.tb-navbar {
position: relative;
min-height: 56px;
overflow: visible;
}
.tb-navbar > .uk-navbar-center {
position: relative !important;
top: auto !important;
left: auto !important;
right: auto !important;
bottom: auto !important;
transform: none !important;
display: flex !important;
align-items: center;
justify-content: center;
width: 100%;
min-height: 56px;
white-space: nowrap;
flex-wrap: nowrap;
}
.tb-navbar .uk-navbar-center-left,
.tb-navbar .uk-navbar-center-right {
position: static !important;
top: auto !important;
left: auto !important;
right: auto !important;
bottom: auto !important;
transform: none !important;
display: flex !important;
align-items: center;
flex: 0 0 auto;
white-space: nowrap;
}
.tb-navbar .uk-navbar-center-left > div,
.tb-navbar .uk-navbar-center-right > div {
display: flex;
align-items: center;
white-space: nowrap;
}
.tb-navbar .uk-navbar-nav {
display: flex;
flex-wrap: nowrap;
flex: 0 0 auto;
}
.tb-navbar .uk-navbar-nav > li > a,
.tb-navbar .uk-logo {
white-space: nowrap;
}
/* 未登录:移动端显示与「我的」等宽的隐藏占位,使左栏「探索+占位」与
右栏「注册/登录」两项等宽,logo 真正居中,左右视觉平衡 */
.tb-mobile-balancer { display: block; }
}
/* 标签样式的按钮(管理页置顶等):
尺寸完全继承 .uk-label(font-size/padding/line-height/text-transform 等),
仅覆盖 button 元素特有的默认样式(边框/字体族/光标等),保证与相邻 span 标签完全一致。 */
.tb-label-btn {
border: none !important;
cursor: pointer;
font-family: inherit;
margin: 0;
vertical-align: middle;
outline: none;
background-image: none;
box-sizing: border-box;
-webkit-appearance: none;
appearance: none;
}
.tb-label-btn:hover {
opacity: 0.85;
}
/* 公开页置顶小图标:仅一个图钉,不占行高、不影响原布局 */
.tb-pin-flag {
font-size: 0.9em;
margin-right: 4px;
vertical-align: -1px;
cursor: default;
}
/* Markdown 渲染排版(问题/回答/追问正文,左对齐以保留排版格式) */
/* 纯文本列表:保留换行与连续空格(pre-wrap),不渲染 Markdown 大字号/彩色 */
.tb-preline {
white-space: pre-wrap;
}
/* 纯文本列表:超过 5 行截断并显示省略号(不足 5 行则完整显示) */
.tb-clamp-5 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 5;
overflow: hidden;
}
.md-body {
line-height: 1.7;
text-align: left;
word-break: break-word;
}
.md-body p { margin: 0.5em 0; }
.md-body h1, .md-body h2, .md-body h3,
.md-body h4, .md-body h5, .md-body h6 {
margin: 0.8em 0 0.4em;
font-weight: 600;
line-height: 1.4;
}
.md-body ul, .md-body ol { padding-left: 1.6em; margin: 0.5em 0; }
.md-body li { margin: 0.2em 0; }
.md-body blockquote {
border-left: 3px solid #d3d1c7;
margin: 0.6em 0;
padding: 0.2em 1em;
color: #5f5e5a;
}
.md-body pre {
background: #f5f5f5;
padding: 0.8em 1em;
border-radius: 6px;
overflow-x: auto;
margin: 0.6em 0;
}
.md-body code {
background: #f0f0f0;
padding: 0.1em 0.35em;
border-radius: 4px;
font-size: 0.9em;
}
.md-body pre code { background: none; padding: 0; }
.md-body img { max-width: 100%; height: auto; }
.md-body table { border-collapse: collapse; margin: 0.6em 0; }
.md-body th, .md-body td {
border: 1px solid #ddd;
padding: 0.4em 0.8em;
}
.md-body hr { margin: 1em 0; }
html[data-theme="dark"] .md-body blockquote {
border-color: #3a404c;
color: #8b93a1;
}
html[data-theme="dark"] .md-body pre,
html[data-theme="dark"] .md-body code { background: #2d323c; }
html[data-theme="dark"] .md-body th,
html[data-theme="dark"] .md-body td { border-color: #3a404c; }
/* 投票结果占比条:模板已改用 UIkit 3 原生 <progress class="uk-progress" value max>,
这里只把默认的 15px 高 / 500px 圆角 / 20px 外边距收紧到与页面一致的规格。
(原先为 UIkit 2 的 .uk-progressbar 结构打的补丁已删除) */
.uk-progress {
height: 10px;
margin: 0;
border-radius: var(--tb-radius-xs);
background-color: var(--tb-surface-2);
}
.uk-progress::-webkit-progress-bar {
background-color: var(--tb-surface-2);
border-radius: var(--tb-radius-xs);
}
.uk-progress::-webkit-progress-value {
/* 两端都圆:值 100% 时若只圆左边,右端方角会顶出轨道圆角 */
background-color: var(--tb-primary);
border-radius: var(--tb-radius-xs);
}
.uk-progress::-moz-progress-bar {
background-color: var(--tb-primary);
border-radius: var(--tb-radius-xs);
}
/* 移动端底部操作栏:固定底栏承载语言切换与深色模式,桌面端隐藏 */
#tb-mobile-utils {
display: none;
}
@media (max-width: 640px) {
/* 顶栏不再容纳语言/深色按钮,恢复原有导航空间 */
.tb-nav-utils {
display: none !important;
}
/* 手机端隐藏登录用户名(左边仅保留"探索"),保证 logo 居中、
两侧各不超过 2 个短按钮,避免用户名宽度挤压布局 */
.tb-nav-username {
display: none !important;
}
/* 固定底部操作栏 */
#tb-mobile-utils {
display: flex;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
justify-content: center;
align-items: center;
gap: 10px;
padding: 8px 12px calc(8px + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.92);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.08);
}
#tb-mobile-utils .uk-select {
width: 110px;
}
html[data-theme="dark"] #tb-mobile-utils {
background: rgba(36, 40, 48, 0.92);
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.4);
}
/* 给页面底部预留空间,避免页脚内容被固定底栏遮挡 */
body {
padding-bottom: 56px;
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-865
View File
@@ -1,865 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>预览 16:08 · </title>
<meta name="msvalidate.01" content="2F185E8E63C93635F50B3574F3B31E42" />
<meta name="description" content="狼的提问箱——一个无需注册、可匿名提问的匿名提问箱,安全地倾诉与回应。">
<meta name="keywords" content="匿名提问箱,提问箱,匿名提问,狼的提问箱,匿名问答,匿名提问网站">
<script>
(function () {
var m = document.cookie.match(/(?:^|;\s*)theme=([^;]*)/);
if (m && m[1] === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
})();
</script>
<link rel="preconnect" href="https://r2.337845818.best" crossorigin>
<link rel="preconnect" href="https://cdnjs.snrat.com" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/uikit/3.3.3/css/uikit.min.css"/>
<link rel="stylesheet" href="/static/css/base.css?v="/>
<link rel="stylesheet" href="https://r2.337845818.best/tamabox/cdn/waline/client/v2/waline-no-commits.css" media="print" onload="this.media='all'"/>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/uikit/3.3.3/js/uikit.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/uikit/3.3.3/js/uikit-icons.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.10.8/dayjs.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/alpinejs/3.9.0/cdn.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/waline/2.15.8/waline.js"></script>
<script src="/recaptcha/api.js" async defer></script>
<script>function onSubmit() {document.getElementById('form').submit();}</script>
<meta property="og:site_name" content="狼的提问箱 | 匿名提问箱" />
<meta property="og:type" content="website" />
<meta property="og:title" content="测试箱主 - 狼的提问箱 | 匿名提问箱" />
<meta property="og:url" content="" />
<meta property="og:description" content="测试箱主 - 狼的提问箱——一个无需注册、可匿名提问的匿名提问箱,安全地倾诉与回应。" />
<meta property="og:image" content="?og-image=1" />
<meta property="og:image:alt" content="测试箱主" />
<meta itemprop="image" content="?og-image=1"/>
<meta name="msapplication-TileImage" content=""/>
<link rel="shortcut icon" href="?og-image=1"/>
<link rel="apple-touch-icon" href="?og-image=1"/>
<link rel="icon" sizes="192x192" href="?og-image=1">
<meta name="twitter:image:src" content="?og-image=1" />
<meta name="google-site-verification" content="fEPwRRJfiGsgwc5SINBQwZhPK8F0cwRaOvRx0YRurI4" />
<script>
window.addEventListener("load", function () {
var s = document.createElement("script");
s.src = "https://mami.shiroko.one/tamami.js";
s.setAttribute("data-website-id", "c42ebe8b-9281-4da0-83a4-8f18b3e710fe");
document.body.appendChild(s);
});
</script>
<style>.tb-profile-cover{background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='900' height='500'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='%233b6ea5'/><stop offset='1' stop-color='%23d98b6a'/></linearGradient></defs><rect width='900' height='500' fill='url(%23g)'/></svg>");background-size:cover;background-position:center;}</style></head>
<body>
<script>
window.tbRenderQR = function (el, getOptions) {
if (!el || typeof QRCode === 'undefined') return;
var opts = getOptions();
function remoteOf(o, key) {
var v = o[key];
return (typeof v === 'string' && v.indexOf('http') === 0) ? v : '';
}
var good = window._tbQrGood || (window._tbQrGood = {});
var bad = window._tbQrBad || (window._tbQrBad = {});
function layerState(url) { return !url ? '' : (good[url] ? 1 : (bad[url] ? 0 : null)); }
var layers = [
{ url: remoteOf(opts, 'logo'), st: null, present: function (o, on) {
if (on) { } else { o.logo = undefined; o.logoWidth = undefined; o.logoHeight = undefined; o.logoBackgroundTransparent = undefined; }
} },
{ url: remoteOf(opts, 'backgroundImage'), st: null, present: function (o, on) {
if (on) { } else { o.backgroundImage = undefined; o.backgroundImageAlpha = undefined; }
} }
];
var needProbe = false;
for (var i = 0; i < layers.length; i++) {
layers[i].st = layerState(layers[i].url);
if (layers[i].st === null) needProbe = true;
}
function paint(states) {
var o = {};
for (var k in opts) o[k] = opts[k];
for (var j = 0; j < layers.length; j++) layers[j].present(o, states[j] === 1);
el.innerHTML = '';
new QRCode(el, o);
}
var initStates = [];
for (var n = 0; n < layers.length; n++) initStates.push(layers[n].st === 1 ? 1 : 0);
paint(initStates);
if (!needProbe) return;
var pending = 0, cross = opts.crossOrigin || 'anonymous';
layers.forEach(function (L) {
if (L.st !== null) return;
pending++;
var im = new Image();
im.onload = function () { good[L.url] = true; L.st = 1; onDone(); };
im.onerror = function () { bad[L.url] = true; L.st = 0; onDone(); };
if (cross) im.crossOrigin = cross;
im.src = L.url;
});
function onDone() {
if (--pending > 0) return;
var finalStates = [];
for (var m = 0; m < layers.length; m++) finalStates.push(layers[m].st === 1 ? 1 : 0);
var changed = false;
for (var q = 0; q < finalStates.length; q++) if (finalStates[q] !== initStates[q]) changed = true;
if (changed) paint(finalStates);
}
setTimeout(function () { if (pending > 0) { pending = 0; onDone(); } }, 3000);
};
</script>
<nav class="uk-navbar-container uk-margin tb-navbar" uk-navbar>
<div class="uk-navbar-center">
<div class="uk-navbar-center-left">
<div>
<ul class="uk-navbar-nav">
<li><a href="/explore">探索</a></li>
<li class="tb-mobile-balancer" aria-hidden="true"><a tabindex="-1" style="visibility:hidden">我的</a></li>
</ul>
</div>
</div>
<a class="uk-navbar-item uk-logo" href="/"><span class="emoji-placeholder" aria-label="random animal">🐺</span>的提问箱</a>
<div class="uk-navbar-center-right">
<div>
<ul class="uk-navbar-nav">
<li><a href="/register">注册</a></li>
</ul>
<ul class="uk-navbar-nav">
<li><a href="/login">登录</a></li>
</ul>
<ul class="uk-navbar-nav tb-nav-utils">
<li>
<a href="#" id="theme-toggle" title="切换深色/浅色模式" aria-label="切换深色/浅色模式">
<span class="tb-icon-light"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20.5 14.3A8.6 8.6 0 0 1 9.7 3.5a8.6 8.6 0 1 0 10.8 10.8z"/></svg></span><span class="tb-icon-dark"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4"/></svg></span>
</a>
</li>
</ul>
</div>
</div>
</div>
</nav>
<script>
document.addEventListener("DOMContentLoaded", function() {
const emojis = ["🐺","🐗","🐻","🐨","🐼","🐹","🐭","🐰","🦊","🐮","🐷","🐽","🐸","🐒","🦍","🐔","🐧","🐦","🐤","🐦‍⬛","🦅","🦉","🦇","🐴","🦄","🐝","🦎","🦖","🦕","🐙","🦑","🦐","🦞","🦀","🐡","🐠","🐟","🐬","🐳","🐋","🦈","🐊","🐅","🐆","🦓","🦍","🦧","🐘","🦛","🦏","🐪","🐫","🦒","🦘","🐃","🐂","🐄","🐎","🐖","🐏","🐑","🦙","🐐","🦌","🐕","🐩","🦮","🐕‍🦺","🐈","🐋","🐳","🐬","🐉","🐲"];
const placeholder = document.querySelector('.emoji-placeholder');
if (placeholder) {
placeholder.textContent = emojis[Math.floor(Math.random() * emojis.length)];
}
});
</script>
<div id="tb-mobile-utils">
<button type="button" id="theme-toggle-mobile" class="uk-button uk-button-default uk-button-small"
title="切换深色/浅色模式" aria-label="切换深色/浅色模式">
<span class="tb-icon-light"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20.5 14.3A8.6 8.6 0 0 1 9.7 3.5a8.6 8.6 0 1 0 10.8 10.8z"/></svg></span><span class="tb-icon-dark"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4"/></svg></span>
</button>
</div>
<script>
(function() {
var THEME_COOKIE = 'theme';
function getTheme() {
var m = document.cookie.match(/(?:^|;\s*)theme=([^;]*)/);
return m ? m[1] : '';
}
function applyTheme(theme) {
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
}
function toggleTheme() {
var next = getTheme() === 'dark' ? 'light' : 'dark';
applyTheme(next);
var expires = new Date(Date.now() + 365 * 24 * 3600 * 1000).toUTCString();
document.cookie = THEME_COOKIE + '=' + next + '; path=/; max-age=' + (365 * 24 * 3600) + '; SameSite=Lax';
}
document.addEventListener('DOMContentLoaded', function() {
var buttons = document.querySelectorAll('#theme-toggle, #theme-toggle-mobile');
buttons.forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();
toggleTheme();
});
});
var langSel = document.getElementById('mobile-lang-select');
if (langSel) {
langSel.addEventListener('change', function() {
if (!this.value) return;
var to = window.location.pathname + window.location.search;
window.location.href = '/i18n/' + encodeURIComponent(this.value) + '?to=' + encodeURIComponent(to);
});
}
});
})();
</script>
<div class="uk-container uk-container-xsmall">
<link rel="shortcut icon" href="?v=2" crossorigin="anonymous"/>
<link rel="apple-touch-icon" href="?v=2" crossorigin="anonymous"/>
<link rel="icon" sizes="192x192" href="?v=2" crossorigin="anonymous">
<style>
.tb-profile-cover { min-height: 260px; }
.profile-votes-list { display: grid; gap: 0; }
.profile-vote-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-width: 0;
padding: 14px 16px;
border-bottom: 1px solid var(--tb-border);
text-decoration: none;
}
.profile-vote-item:first-child { border-top: 1px solid var(--tb-border); }
.profile-vote-item:hover { background: var(--tb-surface-hover); text-decoration: none; }
.profile-vote-content { min-width: 0; }
.profile-vote-title {
display: block;
overflow: hidden;
color: var(--tb-text);
font-size: 0.98rem;
font-weight: 600;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-vote-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 7px;
margin-top: 8px;
}
.profile-vote-meta .uk-label { font-size: 0.72rem; line-height: 1.6; }
.profile-vote-action {
flex: 0 0 auto;
color: var(--tb-primary);
font-size: 0.82rem;
white-space: nowrap;
}
html[data-theme="dark"] .profile-vote-item { border-color: var(--tb-border); }
html[data-theme="dark"] .profile-vote-title { color: var(--tb-text); }
html[data-theme="dark"] .profile-vote-item:hover { background: rgba(78, 168, 245, 0.10); }
.profile-tabs {
display: flex;
gap: 4px;
padding: 4px;
margin-top: 20px;
margin-bottom: 18px;
border: 1px solid var(--tb-border);
border-radius: 9px;
background: var(--tb-surface-2);
}
.profile-tab {
flex: 1;
border: 0;
border-radius: 6px;
padding: 9px 15px;
background: transparent;
color: var(--tb-text-muted);
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s ease, color 0.2s ease;
}
.profile-tab:hover { color: var(--tb-primary); }
.profile-tab.is-active {
background: var(--tb-surface);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.10);
color: var(--tb-primary);
font-weight: 600;
}
.profile-tab.is-disabled,
.profile-tab:disabled {
cursor: not-allowed;
color: var(--tb-text-muted);
opacity: 0.7;
}
.profile-tab.is-disabled:hover { color: var(--tb-text-muted); }
.profile-panel[hidden] { display: none !important; }
html[data-theme="dark"] .profile-tabs {
border-color: var(--tb-border);
background: var(--tb-surface-2);
}
html[data-theme="dark"] .profile-tab { color: var(--tb-text-muted); }
html[data-theme="dark"] .profile-tab.is-active { background: var(--tb-surface); color: var(--tb-primary); }
html[data-theme="dark"] .profile-tab.is-disabled { color: var(--tb-text-muted); opacity: 0.55; }
</style>
<div class="uk-card uk-card-default uk-text-center">
<div class="tb-profile-cover uk-flex uk-flex-center uk-flex-middle uk-background-cover uk-light tb-cover" data-src="#ZgotmplZ" uk-img>
<div class="uk-card-body">
<img class="uk-border-circle uk-box-shadow-large" src="" crossorigin="anonymous" style="width: 100px; height: 100px; max-width: 100%; max-height: 100%;">
<h3 id="usernamecolor">测试箱主</h3>
<p id="introcolor">欢迎提问</p>
<div class="icon-container">
</div>
</div>
</div>
</div>
<script>
document.getElementById("usernamecolor").style.color = "#ffffff";
document.getElementById("introcolor").style.color = "#ffffff";
(function () {
var cover = document.querySelector('.tb-cover');
var el = document.getElementById('usernamecolor');
if (!cover || !el) return;
var m = window.getComputedStyle(el).color.match(/\d+/g);
if (!m || m.length < 3) return;
var lum = (0.299 * m[0] + 0.587 * m[1] + 0.114 * m[2]) / 255;
if (lum > 0.62) cover.classList.add('tb-cover-dark');
})();
</script>
<div>
<div class="uk-card uk-card-default uk-card-small uk-card-body">
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
<script defer src='https://r2.337845818.best/tamabox/cdn/js/remake/easy.qrcode.min.js' type="text/javascript" charset="utf-8"></script>
<div class="uk-text-center"><div id="boxqrimg"></div></div>
<script type="text/javascript">
document.addEventListener('DOMContentLoaded', function () {
var options = {
text: window.location.href,
width: 200,
height: 200,
colorDark: ( null ? "#fff" : "#000000") || "#000000",
colorLight: "#000" || "#FFFFFF",
dotScale: parseFloat("0.35") || 0.4,
dotScaleTiming: parseFloat("0.35") || 0.4,
dotScaleA: parseFloat("0.35") || 0.4,
correctLevel: QRCode.CorrectLevel.H,
logo: (function () {
var a = "" || "";
return a ? (a.indexOf("http") === 0 ? a + "?v=2" : a) : undefined;
})(),
logoWidth: 60,
logoHeight: 60,
logoBackgroundTransparent: true,
crossOrigin: 'anonymous',
backgroundImage: ("" ? "" : undefined),
backgroundImageAlpha: parseFloat("0.8") || 0.8,
autoColor: false,
PO: "#000" || "#000000",
PI: "#000" || "#000000",
AO: '',
AI: '',
timing: "#000" || "#000000"
};
window.tbRenderQR(document.getElementById("boxqrimg"), function () { return options; });
});
</script>
<p class="uk-text-center uk-text-small">扫码向@测试箱主提问<br>谁都可以以匿名的形式提问</p>
<div class="uk-text-center uk-margin-small-top">
<a id="share-btn" href="/_/tester/share-card" download class="uk-button uk-button-default uk-button-small">
📤 分享提问箱
</a>
</div>
<div id="share-loading-overlay" style="display:none; position: fixed; top:0; left:0; width:100%; height:100%; background: rgba(255,255,255,0.85); z-index:9999;">
<div style="display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%;">
<div uk-spinner="ratio: 2" class="uk-margin-bottom"></div>
<p class="uk-text-large uk-text-muted">正在生成分享图…</p>
</div>
</div>
<script>
(function() {
var btn = document.getElementById('share-btn');
var overlay = document.getElementById('share-loading-overlay');
if (!btn || !overlay) return;
btn.addEventListener('click', function(e) {
e.preventDefault();
overlay.style.display = 'block';
btn.style.pointerEvents = 'none';
fetch(btn.href)
.then(function(response) {
if (!response.ok) throw new Error('网络错误');
return response.blob();
})
.then(function(blob) {
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'share.png';
document.body.appendChild(a);
a.click();
setTimeout(function() {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
})
.catch(function(err) {
alert('生成分享图失败,请稍后重试');
console.error(err);
})
.finally(function() {
overlay.style.display = 'none';
btn.style.pointerEvents = 'auto';
});
});
})();
</script>
<div class="profile-tabs" role="tablist">
<button type="button" class="profile-tab" data-profile-tab="questions" role="tab" aria-selected="false">提问</button>
<button type="button" class="profile-tab is-disabled" disabled aria-disabled="true" uk-tooltip="">投票 <span uk-icon="icon: lock; ratio: 0.8"></span></button>
</div>
<div class="profile-panel" data-profile-panel="questions">
<form method="post" action="/_/tester" enctype="multipart/form-data" id="form">
<input type="hidden" name="_csrf" value="x">
<input type="hidden" name="start_chat_val" id="start_chat_val" value="false">
<input type="hidden" name="g-recaptcha-response" id="g-recaptcha-response" value="">
<div class="uk-margin uk-text-center">
<textarea name="content" class="uk-textarea" rows="5" placeholder="在此处撰写你的问题..."
maxlength="1000"></textarea>
<div x-data="{ files: null }" class="tb-img-uploader">
<div uk-form-custom>
<a href="#" class="uk-icon-link" uk-icon="image" style="margin-left: 10px"></a>
<span style="font-size: 12px" x-text="files ? files[0].name : '添加图片'"></span>
<input id="uploader" name="images" type="file" accept="image/*" x-on:change="files = Object.values($event.target.files)">
</div>
<button x-show="files" type="button" class="uk-icon-link" uk-icon="close" style="margin-left: 10px;"
@click="files = null"></button>
</div>
</div>
<div class="uk-margin uk-grid-small">
<label class="uk-text-small">
<input name="private" class="uk-checkbox" type="checkbox"> 回复后不公开
</label>
<label class="uk-text-small">
<input name="receive_reply_via_email" id="notify-checkbox" class="uk-checkbox" type="checkbox"> 接收通知
</label>
<div id="email-container" style="width: 100%; display: none;" class="uk-margin-small-top">
<input class="uk-input uk-form-small" type="email" name="receive_reply_email" id="email-input" placeholder="请输入你的邮箱地址">
</div>
</div>
<div class="uk-margin uk-text-center">
<div class="uk-grid-small uk-flex-center" uk-grid>
<div>
<button type="submit" class="uk-button uk-button-primary g-recaptcha"
data-sitekey="" data-callback="onSubmit"
id="normal-submit-btn">发送提问</button>
</div>
<div>
<button type="button" class="uk-button uk-button-secondary" disabled
uk-tooltip="该提问箱未开启私聊">发起私聊</button>
</div>
</div>
</div>
</form>
<script>
var notifyCheckbox = document.getElementById('notify-checkbox');
var emailContainer = document.getElementById('email-container');
var emailInput = document.getElementById('email-input');
if (notifyCheckbox && emailContainer && emailInput) {
notifyCheckbox.addEventListener('change', function() {
if (this.checked) {
emailContainer.style.display = 'block';
emailInput.setAttribute('required', 'required');
} else {
emailContainer.style.display = 'none';
emailInput.removeAttribute('required');
emailInput.value = '';
}
});
}
var chatBtn = document.getElementById('chat-submit-btn');
if (chatBtn) {
chatBtn.addEventListener('click', function() {
var isLogged = false;
if (!isLogged) {
var currentURI = '';
UIkit.modal.dialog(
'<div class="uk-text-center uk-padding" style="min-height: 150px; display: flex; flex-direction: column; justify-content: center;">' +
'<h3 class="uk-modal-title uk-margin-small-bottom">需要登录</h3>' +
'<p class="uk-text-muted uk-margin-small-bottom">发起私聊需要登录账号</p>' +
'<a href="/login?to=' + encodeURIComponent(currentURI) + '" class="uk-button uk-button-primary">去登录</a>' +
'</div>',
{width: '400px'}
);
return;
}
document.getElementById('start_chat_val').value = 'true';
document.getElementById('normal-submit-btn').click();
});
}
function onSubmit(token) {
var btn = document.querySelector('.g-recaptcha');
var originalText = btn.innerText;
if (notifyCheckbox && notifyCheckbox.checked && emailInput && emailInput.value.trim() === '') {
alert(CAPTCHA_I18N.needEmail);
grecaptcha.reset();
return;
}
btn.disabled = true;
btn.innerText = '验证中...';
var needPassword = false;
if (needPassword) {
UIkit.modal.prompt(CAPTCHA_I18N.needPassword, '', 'password')
.then(function(password) {
if (password) {
document.getElementById('ask_password_input').value = password;
document.getElementById('form').submit();
} else {
btn.disabled = false;
btn.innerText = originalText;
grecaptcha.reset();
}
});
} else {
document.getElementById('form').submit();
}
}
</script>
<p class="uk-text-left uk-text-muted uk-text-small">@测试箱主 以前回答过的问题 (2)</p>
<div id="question-list">
<div data-qid="1">
<hr>
<a class="uk-button uk-button-default uk-button-small uk-float-right"
href="/_/tester/1">查看回答</a>
<div class="uk-text-left uk-text-small uk-text-muted">
<span class="tb-pin-flag" title="已置顶">⭐</span>
2026-10-19 16:08:02
</div>
<p class="uk-text-small uk-text-break tb-preline tb-clamp-5">置顶的问题
</p>
</div>
<div data-qid="2">
<hr>
<a class="uk-button uk-button-default uk-button-small uk-float-right"
href="/_/tester/2">查看回答</a>
<div class="uk-text-left uk-text-small uk-text-muted">
2026-10-19 15:08:02
</div>
<p class="uk-text-small uk-text-break tb-preline tb-clamp-5">普通问题
</p>
</div>
</div>
<div x-data="{ more: false, loading: false, cursor: '2' }">
<button x-show="more" x-bind:disabled="loading" x-on:click.debounce="() => {
if (loading) return
loading = true
fetch(`/api/v1/user/tester/questions?cursor=${cursor}`)
.then(response => response.json())
.then(resp => {
loading = false
// 外层为 ctx.JSON 的统一包装 {code, data, message},内层才是分页负载;
// code !== 0(接口报错)时保持 more 不变,仅复位 loading,下次可重试。
if (!resp || resp.code !== 0) return
const payload = resp.data || {}
let items = Array.isArray(payload.data) ? payload.data : []
if (items.length === 0) {
more = false
return
}
// 防重兜底:先扫描 #question-list 下全部已渲染条目(含服务端首屏与既往
// 追加)的 data-qid 构造“已见”集合,仅追加未出现过的提问。若服务端因游标
// 未前进等原因返回了与已见内容相同的批次(重复加载历史 bug 的表现形态),
// 直接按“到底”处理并隐藏按钮,杜绝无限重复追加。
const seenQIDs = new Set()
document.querySelectorAll('#question-list [data-qid]').forEach(el => seenQIDs.add(Number(el.dataset.qid)))
items = items.filter(q => !seenQIDs.has(q.id))
if (items.length === 0) {
more = false
return
}
// 服务端下发复合键集游标(见上方注释),前端不自行拼接;
// has_more 由服务端多取一条探测得出,最后一页加载完立即隐藏按钮。
more = payload.has_more !== false
cursor = payload.next_cursor || cursor
items.forEach(question => {
const div = document.createElement('div')
div.dataset.qid = question.id
div.innerHTML = `
<div>
<hr>
<a class='uk-button uk-button-default uk-button-small uk-float-right'
href='/_/tester/${question.id}'>查看回答</a>
<div class='uk-text-left uk-text-small uk-text-muted'>
${question.is_pinned ? '<span class=\'tb-pin-flag\' title=\'已置顶\'>⭐</span>' : ''}
${dayjs(question.created_at).format('YYYY-MM-DD HH:mm:ss')}
</div>
<p class='uk-text-small uk-text-break tb-preline tb-clamp-5'></p>
</div>`
div.getElementsByTagName('p')[0].innerText = question.content
document.getElementById('question-list').appendChild(div)
})
})
// 网络失败/响应非法 JSON 时复位 loading,避免按钮永远停留在加载中状态
.catch(() => { loading = false })
}" class="uk-button uk-button-default uk-width-1-1 uk-margin-small-bottom">
<span x-show="!loading">加载更多</span>
<span x-show="loading"><div uk-spinner="ratio: 0.8"></div> 加载中...</span>
</button>
<div x-show="!more" class="uk-text-meta uk-text-center">
<hr>
无更多留言
<br><br>
</div>
</div>
</div>
<div class="profile-panel" data-profile-panel="votes" hidden>
<div class="uk-alert uk-alert-primary uk-margin-remove-top">暂时没有可参与的公开投票。</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var tabs = document.querySelectorAll('[data-profile-tab]');
var panels = document.querySelectorAll('[data-profile-panel]');
function showPanel(name) {
tabs.forEach(function(tab) {
var active = tab.dataset.profileTab === name;
tab.classList.toggle('is-active', active);
tab.setAttribute('aria-selected', active ? 'true' : 'false');
});
panels.forEach(function(panel) {
panel.hidden = panel.dataset.profilePanel !== name;
});
if (window.history && window.history.replaceState) {
window.history.replaceState({}, document.title, window.location.pathname + window.location.search + (name === 'votes' ? '#votes' : ''));
}
}
tabs.forEach(function(tab) {
tab.addEventListener('click', function() { showPanel(tab.dataset.profileTab); });
});
if (window.location.hash === '#votes' && document.querySelector('[data-profile-tab="votes"]')) {
showPanel('votes');
}
});
</script>
<p class=" uk-text-right uk-text-muted uk-text-small">
<br>
<a href="/rules">社区规则</a> |
<a href="https://box.shiroko.one/_/tamakyi" target="_blank">找站长</a>
<br>
<a href="https://share.shiroko.one" target="_blank">盘</a>
| <a href="https://blog.shiroko.one" target="_blank">公告板</a>
| <a href="https://chat.shiroko.one" target="_blank">聊天室</a>
| <a href="https://status.shiroko.one" target="_blank">监控板</a>
| <a href="https://shiroko.one" target="_blank">导航页</a>
<br>Powered by NekoBox |
<br>提问箱已成功存活:<span id="box_runtime"></span>
</p>
<script>
(function () {
var create = Math.round(Date.UTC(2023, 6, 1, 18, 15, 43) / 1000);
function tick() {
var el = document.getElementById("box_runtime");
if (!el) return;
var s = Math.round((Date.now() + 8 * 3600 * 1000) / 1000) - create;
var t = [0, 0, 0, 0, 0];
if (s >= 31536000) { t[0] = s / 31536000 | 0; s %= 31536000; }
if (s >= 86400) { t[1] = s / 86400 | 0; s %= 86400; }
if (s >= 3600) { t[2] = s / 3600 | 0; s %= 3600; }
if (s >= 60) { t[3] = s / 60 | 0; s %= 60; }
if (s > 0) { t[4] = s; }
el.innerHTML = t[0] + '年 ' + t[1] + '天 ' + t[2] + '时 ' + t[3] + '分 ' + t[4] + '秒 ';
}
tick();
setInterval(tick, 1000);
})();
</script>
<br>
</div>
<script>
var SUBMIT_I18N = {
submitting: '提交中...',
uploading: '上传中...'
};
function tbResetSubmitButtons() {
document.querySelectorAll('button[data-tb-submitting]').forEach(function(b) {
b.disabled = false;
b.removeAttribute('data-tb-submitting');
var orig = b.getAttribute('data-tb-restore');
if (orig !== null) {
b.innerHTML = orig;
b.removeAttribute('data-tb-restore');
}
});
}
document.addEventListener('submit', function(e) {
var form = e.target;
if (!form || form.tagName !== 'FORM') return;
if (e.defaultPrevented || form.getAttribute('data-tb-skip-submit-feedback') === '1') {
form.removeAttribute('data-tb-skip-submit-feedback');
return;
}
if (form.getAttribute('target') === '_blank') return;
var trigger = e.submitter || form.querySelector('button[type="submit"]');
var hasFile = false;
form.querySelectorAll('input[type="file"]').forEach(function(f) {
if (f.files && f.files.length > 0) hasFile = true;
});
var label = hasFile ? SUBMIT_I18N.uploading : SUBMIT_I18N.submitting;
if (trigger && trigger.tagName === 'BUTTON') {
trigger.setAttribute('data-tb-restore', trigger.innerHTML);
trigger.innerHTML = '<span uk-spinner="ratio: 0.6"></span> ' + label;
}
form.querySelectorAll('button').forEach(function(b) {
b.disabled = true;
b.setAttribute('data-tb-submitting', '1');
});
});
window.addEventListener('pageshow', tbResetSubmitButtons);
</script>
</body>
</html>
-865
View File
@@ -1,865 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>预览 16:08 · </title>
<meta name="msvalidate.01" content="2F185E8E63C93635F50B3574F3B31E42" />
<meta name="description" content="狼的提问箱——一个无需注册、可匿名提问的匿名提问箱,安全地倾诉与回应。">
<meta name="keywords" content="匿名提问箱,提问箱,匿名提问,狼的提问箱,匿名问答,匿名提问网站">
<script>
(function () {
var m = document.cookie.match(/(?:^|;\s*)theme=([^;]*)/);
if (m && m[1] === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
})();
</script>
<link rel="preconnect" href="https://r2.337845818.best" crossorigin>
<link rel="preconnect" href="https://cdnjs.snrat.com" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/uikit/3.3.3/css/uikit.min.css"/>
<link rel="stylesheet" href="/static/css/base.css?v="/>
<link rel="stylesheet" href="https://r2.337845818.best/tamabox/cdn/waline/client/v2/waline-no-commits.css" media="print" onload="this.media='all'"/>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/uikit/3.3.3/js/uikit.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/uikit/3.3.3/js/uikit-icons.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.10.8/dayjs.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/alpinejs/3.9.0/cdn.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/waline/2.15.8/waline.js"></script>
<script src="/recaptcha/api.js" async defer></script>
<script>function onSubmit() {document.getElementById('form').submit();}</script>
<meta property="og:site_name" content="狼的提问箱 | 匿名提问箱" />
<meta property="og:type" content="website" />
<meta property="og:title" content="测试箱主 - 狼的提问箱 | 匿名提问箱" />
<meta property="og:url" content="" />
<meta property="og:description" content="测试箱主 - 狼的提问箱——一个无需注册、可匿名提问的匿名提问箱,安全地倾诉与回应。" />
<meta property="og:image" content="?og-image=1" />
<meta property="og:image:alt" content="测试箱主" />
<meta itemprop="image" content="?og-image=1"/>
<meta name="msapplication-TileImage" content=""/>
<link rel="shortcut icon" href="?og-image=1"/>
<link rel="apple-touch-icon" href="?og-image=1"/>
<link rel="icon" sizes="192x192" href="?og-image=1">
<meta name="twitter:image:src" content="?og-image=1" />
<meta name="google-site-verification" content="fEPwRRJfiGsgwc5SINBQwZhPK8F0cwRaOvRx0YRurI4" />
<script>
window.addEventListener("load", function () {
var s = document.createElement("script");
s.src = "https://mami.shiroko.one/tamami.js";
s.setAttribute("data-website-id", "c42ebe8b-9281-4da0-83a4-8f18b3e710fe");
document.body.appendChild(s);
});
</script>
<style>.tb-profile-cover{background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='900' height='500'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='%233b6ea5'/><stop offset='1' stop-color='%23d98b6a'/></linearGradient></defs><rect width='900' height='500' fill='url(%23g)'/></svg>");background-size:cover;background-position:center;}</style></head>
<body>
<script>
window.tbRenderQR = function (el, getOptions) {
if (!el || typeof QRCode === 'undefined') return;
var opts = getOptions();
function remoteOf(o, key) {
var v = o[key];
return (typeof v === 'string' && v.indexOf('http') === 0) ? v : '';
}
var good = window._tbQrGood || (window._tbQrGood = {});
var bad = window._tbQrBad || (window._tbQrBad = {});
function layerState(url) { return !url ? '' : (good[url] ? 1 : (bad[url] ? 0 : null)); }
var layers = [
{ url: remoteOf(opts, 'logo'), st: null, present: function (o, on) {
if (on) { } else { o.logo = undefined; o.logoWidth = undefined; o.logoHeight = undefined; o.logoBackgroundTransparent = undefined; }
} },
{ url: remoteOf(opts, 'backgroundImage'), st: null, present: function (o, on) {
if (on) { } else { o.backgroundImage = undefined; o.backgroundImageAlpha = undefined; }
} }
];
var needProbe = false;
for (var i = 0; i < layers.length; i++) {
layers[i].st = layerState(layers[i].url);
if (layers[i].st === null) needProbe = true;
}
function paint(states) {
var o = {};
for (var k in opts) o[k] = opts[k];
for (var j = 0; j < layers.length; j++) layers[j].present(o, states[j] === 1);
el.innerHTML = '';
new QRCode(el, o);
}
var initStates = [];
for (var n = 0; n < layers.length; n++) initStates.push(layers[n].st === 1 ? 1 : 0);
paint(initStates);
if (!needProbe) return;
var pending = 0, cross = opts.crossOrigin || 'anonymous';
layers.forEach(function (L) {
if (L.st !== null) return;
pending++;
var im = new Image();
im.onload = function () { good[L.url] = true; L.st = 1; onDone(); };
im.onerror = function () { bad[L.url] = true; L.st = 0; onDone(); };
if (cross) im.crossOrigin = cross;
im.src = L.url;
});
function onDone() {
if (--pending > 0) return;
var finalStates = [];
for (var m = 0; m < layers.length; m++) finalStates.push(layers[m].st === 1 ? 1 : 0);
var changed = false;
for (var q = 0; q < finalStates.length; q++) if (finalStates[q] !== initStates[q]) changed = true;
if (changed) paint(finalStates);
}
setTimeout(function () { if (pending > 0) { pending = 0; onDone(); } }, 3000);
};
</script>
<nav class="uk-navbar-container uk-margin tb-navbar" uk-navbar>
<div class="uk-navbar-center">
<div class="uk-navbar-center-left">
<div>
<ul class="uk-navbar-nav">
<li><a href="/explore">探索</a></li>
<li class="tb-mobile-balancer" aria-hidden="true"><a tabindex="-1" style="visibility:hidden">我的</a></li>
</ul>
</div>
</div>
<a class="uk-navbar-item uk-logo" href="/"><span class="emoji-placeholder" aria-label="random animal">🐺</span>的提问箱</a>
<div class="uk-navbar-center-right">
<div>
<ul class="uk-navbar-nav">
<li><a href="/register">注册</a></li>
</ul>
<ul class="uk-navbar-nav">
<li><a href="/login">登录</a></li>
</ul>
<ul class="uk-navbar-nav tb-nav-utils">
<li>
<a href="#" id="theme-toggle" title="切换深色/浅色模式" aria-label="切换深色/浅色模式">
<span class="tb-icon-light"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20.5 14.3A8.6 8.6 0 0 1 9.7 3.5a8.6 8.6 0 1 0 10.8 10.8z"/></svg></span><span class="tb-icon-dark"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4"/></svg></span>
</a>
</li>
</ul>
</div>
</div>
</div>
</nav>
<script>
document.addEventListener("DOMContentLoaded", function() {
const emojis = ["🐺","🐗","🐻","🐨","🐼","🐹","🐭","🐰","🦊","🐮","🐷","🐽","🐸","🐒","🦍","🐔","🐧","🐦","🐤","🐦‍⬛","🦅","🦉","🦇","🐴","🦄","🐝","🦎","🦖","🦕","🐙","🦑","🦐","🦞","🦀","🐡","🐠","🐟","🐬","🐳","🐋","🦈","🐊","🐅","🐆","🦓","🦍","🦧","🐘","🦛","🦏","🐪","🐫","🦒","🦘","🐃","🐂","🐄","🐎","🐖","🐏","🐑","🦙","🐐","🦌","🐕","🐩","🦮","🐕‍🦺","🐈","🐋","🐳","🐬","🐉","🐲"];
const placeholder = document.querySelector('.emoji-placeholder');
if (placeholder) {
placeholder.textContent = emojis[Math.floor(Math.random() * emojis.length)];
}
});
</script>
<div id="tb-mobile-utils">
<button type="button" id="theme-toggle-mobile" class="uk-button uk-button-default uk-button-small"
title="切换深色/浅色模式" aria-label="切换深色/浅色模式">
<span class="tb-icon-light"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20.5 14.3A8.6 8.6 0 0 1 9.7 3.5a8.6 8.6 0 1 0 10.8 10.8z"/></svg></span><span class="tb-icon-dark"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4"/></svg></span>
</button>
</div>
<script>
(function() {
var THEME_COOKIE = 'theme';
function getTheme() {
var m = document.cookie.match(/(?:^|;\s*)theme=([^;]*)/);
return m ? m[1] : '';
}
function applyTheme(theme) {
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
}
function toggleTheme() {
var next = getTheme() === 'dark' ? 'light' : 'dark';
applyTheme(next);
var expires = new Date(Date.now() + 365 * 24 * 3600 * 1000).toUTCString();
document.cookie = THEME_COOKIE + '=' + next + '; path=/; max-age=' + (365 * 24 * 3600) + '; SameSite=Lax';
}
document.addEventListener('DOMContentLoaded', function() {
var buttons = document.querySelectorAll('#theme-toggle, #theme-toggle-mobile');
buttons.forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();
toggleTheme();
});
});
var langSel = document.getElementById('mobile-lang-select');
if (langSel) {
langSel.addEventListener('change', function() {
if (!this.value) return;
var to = window.location.pathname + window.location.search;
window.location.href = '/i18n/' + encodeURIComponent(this.value) + '?to=' + encodeURIComponent(to);
});
}
});
})();
</script>
<div class="uk-container uk-container-xsmall">
<link rel="shortcut icon" href="?v=2" crossorigin="anonymous"/>
<link rel="apple-touch-icon" href="?v=2" crossorigin="anonymous"/>
<link rel="icon" sizes="192x192" href="?v=2" crossorigin="anonymous">
<style>
.tb-profile-cover { min-height: 260px; }
.profile-votes-list { display: grid; gap: 0; }
.profile-vote-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-width: 0;
padding: 14px 16px;
border-bottom: 1px solid var(--tb-border);
text-decoration: none;
}
.profile-vote-item:first-child { border-top: 1px solid var(--tb-border); }
.profile-vote-item:hover { background: var(--tb-surface-hover); text-decoration: none; }
.profile-vote-content { min-width: 0; }
.profile-vote-title {
display: block;
overflow: hidden;
color: var(--tb-text);
font-size: 0.98rem;
font-weight: 600;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-vote-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 7px;
margin-top: 8px;
}
.profile-vote-meta .uk-label { font-size: 0.72rem; line-height: 1.6; }
.profile-vote-action {
flex: 0 0 auto;
color: var(--tb-primary);
font-size: 0.82rem;
white-space: nowrap;
}
html[data-theme="dark"] .profile-vote-item { border-color: var(--tb-border); }
html[data-theme="dark"] .profile-vote-title { color: var(--tb-text); }
html[data-theme="dark"] .profile-vote-item:hover { background: rgba(78, 168, 245, 0.10); }
.profile-tabs {
display: flex;
gap: 4px;
padding: 4px;
margin-top: 20px;
margin-bottom: 18px;
border: 1px solid var(--tb-border);
border-radius: 9px;
background: var(--tb-surface-2);
}
.profile-tab {
flex: 1;
border: 0;
border-radius: 6px;
padding: 9px 15px;
background: transparent;
color: var(--tb-text-muted);
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s ease, color 0.2s ease;
}
.profile-tab:hover { color: var(--tb-primary); }
.profile-tab.is-active {
background: var(--tb-surface);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.10);
color: var(--tb-primary);
font-weight: 600;
}
.profile-tab.is-disabled,
.profile-tab:disabled {
cursor: not-allowed;
color: var(--tb-text-muted);
opacity: 0.7;
}
.profile-tab.is-disabled:hover { color: var(--tb-text-muted); }
.profile-panel[hidden] { display: none !important; }
html[data-theme="dark"] .profile-tabs {
border-color: var(--tb-border);
background: var(--tb-surface-2);
}
html[data-theme="dark"] .profile-tab { color: var(--tb-text-muted); }
html[data-theme="dark"] .profile-tab.is-active { background: var(--tb-surface); color: var(--tb-primary); }
html[data-theme="dark"] .profile-tab.is-disabled { color: var(--tb-text-muted); opacity: 0.55; }
</style>
<div class="uk-card uk-card-default uk-text-center">
<div class="tb-profile-cover uk-flex uk-flex-center uk-flex-middle uk-background-cover uk-light tb-cover" data-src="#ZgotmplZ" uk-img>
<div class="uk-card-body">
<img class="uk-border-circle uk-box-shadow-large" src="" crossorigin="anonymous" style="width: 100px; height: 100px; max-width: 100%; max-height: 100%;">
<h3 id="usernamecolor">测试箱主</h3>
<p id="introcolor">欢迎提问</p>
<div class="icon-container">
</div>
</div>
</div>
</div>
<script>
document.getElementById("usernamecolor").style.color = "#ffffff";
document.getElementById("introcolor").style.color = "#ffffff";
(function () {
var cover = document.querySelector('.tb-cover');
var el = document.getElementById('usernamecolor');
if (!cover || !el) return;
var m = window.getComputedStyle(el).color.match(/\d+/g);
if (!m || m.length < 3) return;
var lum = (0.299 * m[0] + 0.587 * m[1] + 0.114 * m[2]) / 255;
if (lum > 0.62) cover.classList.add('tb-cover-dark');
})();
</script>
<div>
<div class="uk-card uk-card-default uk-card-small uk-card-body">
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
<script defer src='https://r2.337845818.best/tamabox/cdn/js/remake/easy.qrcode.min.js' type="text/javascript" charset="utf-8"></script>
<div class="uk-text-center"><div id="boxqrimg"></div></div>
<script type="text/javascript">
document.addEventListener('DOMContentLoaded', function () {
var options = {
text: window.location.href,
width: 200,
height: 200,
colorDark: ( null ? "#fff" : "#000000") || "#000000",
colorLight: "#000" || "#FFFFFF",
dotScale: parseFloat("0.35") || 0.4,
dotScaleTiming: parseFloat("0.35") || 0.4,
dotScaleA: parseFloat("0.35") || 0.4,
correctLevel: QRCode.CorrectLevel.H,
logo: (function () {
var a = "" || "";
return a ? (a.indexOf("http") === 0 ? a + "?v=2" : a) : undefined;
})(),
logoWidth: 60,
logoHeight: 60,
logoBackgroundTransparent: true,
crossOrigin: 'anonymous',
backgroundImage: ("" ? "" : undefined),
backgroundImageAlpha: parseFloat("0.8") || 0.8,
autoColor: false,
PO: "#000" || "#000000",
PI: "#000" || "#000000",
AO: '',
AI: '',
timing: "#000" || "#000000"
};
window.tbRenderQR(document.getElementById("boxqrimg"), function () { return options; });
});
</script>
<p class="uk-text-center uk-text-small">扫码向@测试箱主提问<br>谁都可以以匿名的形式提问</p>
<div class="uk-text-center uk-margin-small-top">
<a id="share-btn" href="/_/tester/share-card" download class="uk-button uk-button-default uk-button-small">
📤 分享提问箱
</a>
</div>
<div id="share-loading-overlay" style="display:none; position: fixed; top:0; left:0; width:100%; height:100%; background: rgba(255,255,255,0.85); z-index:9999;">
<div style="display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%;">
<div uk-spinner="ratio: 2" class="uk-margin-bottom"></div>
<p class="uk-text-large uk-text-muted">正在生成分享图…</p>
</div>
</div>
<script>
(function() {
var btn = document.getElementById('share-btn');
var overlay = document.getElementById('share-loading-overlay');
if (!btn || !overlay) return;
btn.addEventListener('click', function(e) {
e.preventDefault();
overlay.style.display = 'block';
btn.style.pointerEvents = 'none';
fetch(btn.href)
.then(function(response) {
if (!response.ok) throw new Error('网络错误');
return response.blob();
})
.then(function(blob) {
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'share.png';
document.body.appendChild(a);
a.click();
setTimeout(function() {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
})
.catch(function(err) {
alert('生成分享图失败,请稍后重试');
console.error(err);
})
.finally(function() {
overlay.style.display = 'none';
btn.style.pointerEvents = 'auto';
});
});
})();
</script>
<div class="profile-tabs" role="tablist">
<button type="button" class="profile-tab" data-profile-tab="questions" role="tab" aria-selected="false">提问</button>
<button type="button" class="profile-tab is-disabled" disabled aria-disabled="true" uk-tooltip="">投票 <span uk-icon="icon: lock; ratio: 0.8"></span></button>
</div>
<div class="profile-panel" data-profile-panel="questions">
<form method="post" action="/_/tester" enctype="multipart/form-data" id="form">
<input type="hidden" name="_csrf" value="x">
<input type="hidden" name="start_chat_val" id="start_chat_val" value="false">
<input type="hidden" name="g-recaptcha-response" id="g-recaptcha-response" value="">
<div class="uk-margin uk-text-center">
<textarea name="content" class="uk-textarea" rows="5" placeholder="在此处撰写你的问题..."
maxlength="1000"></textarea>
<div x-data="{ files: null }" class="tb-img-uploader">
<div uk-form-custom>
<a href="#" class="uk-icon-link" uk-icon="image" style="margin-left: 10px"></a>
<span style="font-size: 12px" x-text="files ? files[0].name : '添加图片'"></span>
<input id="uploader" name="images" type="file" accept="image/*" x-on:change="files = Object.values($event.target.files)">
</div>
<button x-show="files" type="button" class="uk-icon-link" uk-icon="close" style="margin-left: 10px;"
@click="files = null"></button>
</div>
</div>
<div class="uk-margin uk-grid-small">
<label class="uk-text-small">
<input name="private" class="uk-checkbox" type="checkbox"> 回复后不公开
</label>
<label class="uk-text-small">
<input name="receive_reply_via_email" id="notify-checkbox" class="uk-checkbox" type="checkbox"> 接收通知
</label>
<div id="email-container" style="width: 100%; display: none;" class="uk-margin-small-top">
<input class="uk-input uk-form-small" type="email" name="receive_reply_email" id="email-input" placeholder="请输入你的邮箱地址">
</div>
</div>
<div class="uk-margin uk-text-center">
<div class="uk-grid-small uk-flex-center" uk-grid>
<div>
<button type="submit" class="uk-button uk-button-primary g-recaptcha"
data-sitekey="" data-callback="onSubmit"
id="normal-submit-btn">发送提问</button>
</div>
<div>
<button type="button" class="uk-button uk-button-secondary" disabled
uk-tooltip="该提问箱未开启私聊">发起私聊</button>
</div>
</div>
</div>
</form>
<script>
var notifyCheckbox = document.getElementById('notify-checkbox');
var emailContainer = document.getElementById('email-container');
var emailInput = document.getElementById('email-input');
if (notifyCheckbox && emailContainer && emailInput) {
notifyCheckbox.addEventListener('change', function() {
if (this.checked) {
emailContainer.style.display = 'block';
emailInput.setAttribute('required', 'required');
} else {
emailContainer.style.display = 'none';
emailInput.removeAttribute('required');
emailInput.value = '';
}
});
}
var chatBtn = document.getElementById('chat-submit-btn');
if (chatBtn) {
chatBtn.addEventListener('click', function() {
var isLogged = false;
if (!isLogged) {
var currentURI = '';
UIkit.modal.dialog(
'<div class="uk-text-center uk-padding" style="min-height: 150px; display: flex; flex-direction: column; justify-content: center;">' +
'<h3 class="uk-modal-title uk-margin-small-bottom">需要登录</h3>' +
'<p class="uk-text-muted uk-margin-small-bottom">发起私聊需要登录账号</p>' +
'<a href="/login?to=' + encodeURIComponent(currentURI) + '" class="uk-button uk-button-primary">去登录</a>' +
'</div>',
{width: '400px'}
);
return;
}
document.getElementById('start_chat_val').value = 'true';
document.getElementById('normal-submit-btn').click();
});
}
function onSubmit(token) {
var btn = document.querySelector('.g-recaptcha');
var originalText = btn.innerText;
if (notifyCheckbox && notifyCheckbox.checked && emailInput && emailInput.value.trim() === '') {
alert(CAPTCHA_I18N.needEmail);
grecaptcha.reset();
return;
}
btn.disabled = true;
btn.innerText = '验证中...';
var needPassword = false;
if (needPassword) {
UIkit.modal.prompt(CAPTCHA_I18N.needPassword, '', 'password')
.then(function(password) {
if (password) {
document.getElementById('ask_password_input').value = password;
document.getElementById('form').submit();
} else {
btn.disabled = false;
btn.innerText = originalText;
grecaptcha.reset();
}
});
} else {
document.getElementById('form').submit();
}
}
</script>
<p class="uk-text-left uk-text-muted uk-text-small">@测试箱主 以前回答过的问题 (2)</p>
<div id="question-list">
<div data-qid="1">
<hr>
<a class="uk-button uk-button-default uk-button-small uk-float-right"
href="/_/tester/1">查看回答</a>
<div class="uk-text-left uk-text-small uk-text-muted">
<span class="tb-pin-flag" title="已置顶">⭐</span>
2026-10-19 16:08:02
</div>
<p class="uk-text-small uk-text-break tb-preline tb-clamp-5">置顶的问题
</p>
</div>
<div data-qid="2">
<hr>
<a class="uk-button uk-button-default uk-button-small uk-float-right"
href="/_/tester/2">查看回答</a>
<div class="uk-text-left uk-text-small uk-text-muted">
2026-10-19 15:08:02
</div>
<p class="uk-text-small uk-text-break tb-preline tb-clamp-5">普通问题
</p>
</div>
</div>
<div x-data="{ more: false, loading: false, cursor: '2' }">
<button x-show="more" x-bind:disabled="loading" x-on:click.debounce="() => {
if (loading) return
loading = true
fetch(`/api/v1/user/tester/questions?cursor=${cursor}`)
.then(response => response.json())
.then(resp => {
loading = false
// 外层为 ctx.JSON 的统一包装 {code, data, message},内层才是分页负载;
// code !== 0(接口报错)时保持 more 不变,仅复位 loading,下次可重试。
if (!resp || resp.code !== 0) return
const payload = resp.data || {}
let items = Array.isArray(payload.data) ? payload.data : []
if (items.length === 0) {
more = false
return
}
// 防重兜底:先扫描 #question-list 下全部已渲染条目(含服务端首屏与既往
// 追加)的 data-qid 构造“已见”集合,仅追加未出现过的提问。若服务端因游标
// 未前进等原因返回了与已见内容相同的批次(重复加载历史 bug 的表现形态),
// 直接按“到底”处理并隐藏按钮,杜绝无限重复追加。
const seenQIDs = new Set()
document.querySelectorAll('#question-list [data-qid]').forEach(el => seenQIDs.add(Number(el.dataset.qid)))
items = items.filter(q => !seenQIDs.has(q.id))
if (items.length === 0) {
more = false
return
}
// 服务端下发复合键集游标(见上方注释),前端不自行拼接;
// has_more 由服务端多取一条探测得出,最后一页加载完立即隐藏按钮。
more = payload.has_more !== false
cursor = payload.next_cursor || cursor
items.forEach(question => {
const div = document.createElement('div')
div.dataset.qid = question.id
div.innerHTML = `
<div>
<hr>
<a class='uk-button uk-button-default uk-button-small uk-float-right'
href='/_/tester/${question.id}'>查看回答</a>
<div class='uk-text-left uk-text-small uk-text-muted'>
${question.is_pinned ? '<span class=\'tb-pin-flag\' title=\'已置顶\'>⭐</span>' : ''}
${dayjs(question.created_at).format('YYYY-MM-DD HH:mm:ss')}
</div>
<p class='uk-text-small uk-text-break tb-preline tb-clamp-5'></p>
</div>`
div.getElementsByTagName('p')[0].innerText = question.content
document.getElementById('question-list').appendChild(div)
})
})
// 网络失败/响应非法 JSON 时复位 loading,避免按钮永远停留在加载中状态
.catch(() => { loading = false })
}" class="uk-button uk-button-default uk-width-1-1 uk-margin-small-bottom">
<span x-show="!loading">加载更多</span>
<span x-show="loading"><div uk-spinner="ratio: 0.8"></div> 加载中...</span>
</button>
<div x-show="!more" class="uk-text-meta uk-text-center">
<hr>
无更多留言
<br><br>
</div>
</div>
</div>
<div class="profile-panel" data-profile-panel="votes" hidden>
<div class="uk-alert uk-alert-primary uk-margin-remove-top">暂时没有可参与的公开投票。</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var tabs = document.querySelectorAll('[data-profile-tab]');
var panels = document.querySelectorAll('[data-profile-panel]');
function showPanel(name) {
tabs.forEach(function(tab) {
var active = tab.dataset.profileTab === name;
tab.classList.toggle('is-active', active);
tab.setAttribute('aria-selected', active ? 'true' : 'false');
});
panels.forEach(function(panel) {
panel.hidden = panel.dataset.profilePanel !== name;
});
if (window.history && window.history.replaceState) {
window.history.replaceState({}, document.title, window.location.pathname + window.location.search + (name === 'votes' ? '#votes' : ''));
}
}
tabs.forEach(function(tab) {
tab.addEventListener('click', function() { showPanel(tab.dataset.profileTab); });
});
if (window.location.hash === '#votes' && document.querySelector('[data-profile-tab="votes"]')) {
showPanel('votes');
}
});
</script>
<p class=" uk-text-right uk-text-muted uk-text-small">
<br>
<a href="/rules">社区规则</a> |
<a href="https://box.shiroko.one/_/tamakyi" target="_blank">找站长</a>
<br>
<a href="https://share.shiroko.one" target="_blank">盘</a>
| <a href="https://blog.shiroko.one" target="_blank">公告板</a>
| <a href="https://chat.shiroko.one" target="_blank">聊天室</a>
| <a href="https://status.shiroko.one" target="_blank">监控板</a>
| <a href="https://shiroko.one" target="_blank">导航页</a>
<br>Powered by NekoBox |
<br>提问箱已成功存活:<span id="box_runtime"></span>
</p>
<script>
(function () {
var create = Math.round(Date.UTC(2023, 6, 1, 18, 15, 43) / 1000);
function tick() {
var el = document.getElementById("box_runtime");
if (!el) return;
var s = Math.round((Date.now() + 8 * 3600 * 1000) / 1000) - create;
var t = [0, 0, 0, 0, 0];
if (s >= 31536000) { t[0] = s / 31536000 | 0; s %= 31536000; }
if (s >= 86400) { t[1] = s / 86400 | 0; s %= 86400; }
if (s >= 3600) { t[2] = s / 3600 | 0; s %= 3600; }
if (s >= 60) { t[3] = s / 60 | 0; s %= 60; }
if (s > 0) { t[4] = s; }
el.innerHTML = t[0] + '年 ' + t[1] + '天 ' + t[2] + '时 ' + t[3] + '分 ' + t[4] + '秒 ';
}
tick();
setInterval(tick, 1000);
})();
</script>
<br>
</div>
<script>
var SUBMIT_I18N = {
submitting: '提交中...',
uploading: '上传中...'
};
function tbResetSubmitButtons() {
document.querySelectorAll('button[data-tb-submitting]').forEach(function(b) {
b.disabled = false;
b.removeAttribute('data-tb-submitting');
var orig = b.getAttribute('data-tb-restore');
if (orig !== null) {
b.innerHTML = orig;
b.removeAttribute('data-tb-restore');
}
});
}
document.addEventListener('submit', function(e) {
var form = e.target;
if (!form || form.tagName !== 'FORM') return;
if (e.defaultPrevented || form.getAttribute('data-tb-skip-submit-feedback') === '1') {
form.removeAttribute('data-tb-skip-submit-feedback');
return;
}
if (form.getAttribute('target') === '_blank') return;
var trigger = e.submitter || form.querySelector('button[type="submit"]');
var hasFile = false;
form.querySelectorAll('input[type="file"]').forEach(function(f) {
if (f.files && f.files.length > 0) hasFile = true;
});
var label = hasFile ? SUBMIT_I18N.uploading : SUBMIT_I18N.submitting;
if (trigger && trigger.tagName === 'BUTTON') {
trigger.setAttribute('data-tb-restore', trigger.innerHTML);
trigger.innerHTML = '<span uk-spinner="ratio: 0.6"></span> ' + label;
}
form.querySelectorAll('button').forEach(function(b) {
b.disabled = true;
b.setAttribute('data-tb-submitting', '1');
});
});
window.addEventListener('pageshow', tbResetSubmitButtons);
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-932
View File
@@ -1,932 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>预览 16:08 · TamaBox</title>
<meta name="msvalidate.01" content="2F185E8E63C93635F50B3574F3B31E42" />
<meta name="description" content="狼的提问箱——一个无需注册、可匿名提问的匿名提问箱,安全地倾诉与回应。">
<meta name="keywords" content="匿名提问箱,提问箱,匿名提问,狼的提问箱,匿名问答,匿名提问网站">
<script>
(function () {
var m = document.cookie.match(/(?:^|;\s*)theme=([^;]*)/);
if (m && m[1] === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
})();
</script>
<link rel="preconnect" href="https://r2.337845818.best" crossorigin>
<link rel="preconnect" href="https://cdnjs.snrat.com" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/uikit/3.3.3/css/uikit.min.css"/>
<link rel="stylesheet" href="/static/css/base.css?v="/>
<link rel="stylesheet" href="https://r2.337845818.best/tamabox/cdn/waline/client/v2/waline-no-commits.css" media="print" onload="this.media='all'"/>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/uikit/3.3.3/js/uikit.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/uikit/3.3.3/js/uikit-icons.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.10.8/dayjs.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/alpinejs/3.9.0/cdn.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/waline/2.15.8/waline.js"></script>
<style>
.captcha-equation-box {
display: inline-flex;
align-items: center;
background: #f0f2f5;
border: 1px solid #dcdcdc;
border-radius: 8px;
padding: 8px 8px 8px 16px;
gap: 6px;
transition: border-color 0.2s;
}
.captcha-equation-box:focus-within {
border-color: #1e87f0;
box-shadow: 0 0 0 3px rgba(30,135,240,0.12);
}
.math-captcha-modal-question {
font-size: 1.4rem;
font-family: "Courier New", monospace;
font-weight: 700;
color: #333;
letter-spacing: 1px;
white-space: nowrap;
}
#captcha-modal-answer {
width: 80px;
padding: 6px 10px;
text-align: center;
font-size: 1.2rem;
font-family: "Courier New", monospace;
font-weight: 600;
border: 1px solid #ccc;
border-radius: 5px;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
background: #fff;
}
#captcha-modal-answer:focus {
border-color: #1e87f0;
box-shadow: 0 0 0 2px rgba(30,135,240,0.2);
}
#captcha-modal-answer::-webkit-inner-spin-button,
#captcha-modal-answer::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
#captcha-modal-answer {
-moz-appearance: textfield;
}
</style>
<meta property="og:site_name" content="狼的提问箱 | 匿名提问箱" />
<meta property="og:type" content="website" />
<meta property="og:title" content="狼的提问箱 | 匿名提问箱" />
<meta property="og:url" content="https://box.shiroko.one/" />
<meta property="og:description" content="狼的提问箱——一个无需注册、可匿名提问的匿名提问箱,安全地倾诉与回应。" />
<meta itemprop="image" content="https://r2.337845818.best/tamabox/tamabox_low.png"/>
<meta name="msapplication-TileImage" content="https://r2.337845818.best/tamabox/tamabox_low.png"/>
<link rel="shortcut icon" href="https://r2.337845818.best/tamabox/tamabox_low.png" />
<link rel="apple-touch-icon" href="https://r2.337845818.best/tamabox/tamabox_low.png"/>
<link rel="icon" sizes="192x192" href="https://r2.337845818.best/tamabox/tamabox_low.png">
<meta name="twitter:image:src" content="https://r2.337845818.best/tamabox/tamabox_low.png" />
<meta name="google-site-verification" content="fEPwRRJfiGsgwc5SINBQwZhPK8F0cwRaOvRx0YRurI4" />
<script>
window.addEventListener("load", function () {
var s = document.createElement("script");
s.src = "https://mami.shiroko.one/tamami.js";
s.setAttribute("data-website-id", "c42ebe8b-9281-4da0-83a4-8f18b3e710fe");
document.body.appendChild(s);
});
</script>
</head>
<body>
<script>
window.tbRenderQR = function (el, getOptions) {
if (!el || typeof QRCode === 'undefined') return;
var opts = getOptions();
function remoteOf(o, key) {
var v = o[key];
return (typeof v === 'string' && v.indexOf('http') === 0) ? v : '';
}
var good = window._tbQrGood || (window._tbQrGood = {});
var bad = window._tbQrBad || (window._tbQrBad = {});
function layerState(url) { return !url ? '' : (good[url] ? 1 : (bad[url] ? 0 : null)); }
var layers = [
{ url: remoteOf(opts, 'logo'), st: null, present: function (o, on) {
if (on) { } else { o.logo = undefined; o.logoWidth = undefined; o.logoHeight = undefined; o.logoBackgroundTransparent = undefined; }
} },
{ url: remoteOf(opts, 'backgroundImage'), st: null, present: function (o, on) {
if (on) { } else { o.backgroundImage = undefined; o.backgroundImageAlpha = undefined; }
} }
];
var needProbe = false;
for (var i = 0; i < layers.length; i++) {
layers[i].st = layerState(layers[i].url);
if (layers[i].st === null) needProbe = true;
}
function paint(states) {
var o = {};
for (var k in opts) o[k] = opts[k];
for (var j = 0; j < layers.length; j++) layers[j].present(o, states[j] === 1);
el.innerHTML = '';
new QRCode(el, o);
}
var initStates = [];
for (var n = 0; n < layers.length; n++) initStates.push(layers[n].st === 1 ? 1 : 0);
paint(initStates);
if (!needProbe) return;
var pending = 0, cross = opts.crossOrigin || 'anonymous';
layers.forEach(function (L) {
if (L.st !== null) return;
pending++;
var im = new Image();
im.onload = function () { good[L.url] = true; L.st = 1; onDone(); };
im.onerror = function () { bad[L.url] = true; L.st = 0; onDone(); };
if (cross) im.crossOrigin = cross;
im.src = L.url;
});
function onDone() {
if (--pending > 0) return;
var finalStates = [];
for (var m = 0; m < layers.length; m++) finalStates.push(layers[m].st === 1 ? 1 : 0);
var changed = false;
for (var q = 0; q < finalStates.length; q++) if (finalStates[q] !== initStates[q]) changed = true;
if (changed) paint(finalStates);
}
setTimeout(function () { if (pending > 0) { pending = 0; onDone(); } }, 3000);
};
</script>
<nav class="uk-navbar-container uk-margin tb-navbar" uk-navbar>
<div class="uk-navbar-center">
<div class="uk-navbar-center-left">
<div>
<ul class="uk-navbar-nav">
<li><a href="/explore">探索</a></li>
<li class="tb-mobile-balancer" aria-hidden="true"><a tabindex="-1" style="visibility:hidden">我的</a></li>
</ul>
</div>
</div>
<a class="uk-navbar-item uk-logo" href="/"><span class="emoji-placeholder" aria-label="random animal">🐺</span>的提问箱</a>
<div class="uk-navbar-center-right">
<div>
<ul class="uk-navbar-nav">
<li><a href="/register">注册</a></li>
</ul>
<ul class="uk-navbar-nav">
<li><a href="/login">登录</a></li>
</ul>
<ul class="uk-navbar-nav tb-nav-utils">
<li>
<a href="#" aria-label="语言" title="语言"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M3.2 9h17.6M3.2 15h17.6"/><ellipse cx="12" cy="12" rx="4" ry="9"/></svg></a>
<div class="uk-navbar-dropdown" uk-drop="mode: click; pos: bottom-right">
<ul class="uk-nav uk-navbar-dropdown-nav">
<li class="uk-active">
<a href="/i18n/zh-CN?to=%2F">简体中文</a>
</li>
<li class="">
<a href="/i18n/ja?to=%2F">日本語</a>
</li>
<li class="">
<a href="/i18n/en?to=%2F">English</a>
</li>
</ul>
</div>
</li>
</ul>
<ul class="uk-navbar-nav tb-nav-utils">
<li>
<a href="#" id="theme-toggle" title="切换深色/浅色模式" aria-label="切换深色/浅色模式">
<span class="tb-icon-light"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20.5 14.3A8.6 8.6 0 0 1 9.7 3.5a8.6 8.6 0 1 0 10.8 10.8z"/></svg></span><span class="tb-icon-dark"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4"/></svg></span>
</a>
</li>
</ul>
</div>
</div>
</div>
</nav>
<script>
document.addEventListener("DOMContentLoaded", function() {
const emojis = ["🐺","🐗","🐻","🐨","🐼","🐹","🐭","🐰","🦊","🐮","🐷","🐽","🐸","🐒","🦍","🐔","🐧","🐦","🐤","🐦‍⬛","🦅","🦉","🦇","🐴","🦄","🐝","🦎","🦖","🦕","🐙","🦑","🦐","🦞","🦀","🐡","🐠","🐟","🐬","🐳","🐋","🦈","🐊","🐅","🐆","🦓","🦍","🦧","🐘","🦛","🦏","🐪","🐫","🦒","🦘","🐃","🐂","🐄","🐎","🐖","🐏","🐑","🦙","🐐","🦌","🐕","🐩","🦮","🐕‍🦺","🐈","🐋","🐳","🐬","🐉","🐲"];
const placeholder = document.querySelector('.emoji-placeholder');
if (placeholder) {
placeholder.textContent = emojis[Math.floor(Math.random() * emojis.length)];
}
});
</script>
<div id="tb-mobile-utils">
<select id="mobile-lang-select" class="uk-select uk-form-small" aria-label="语言">
<option value="zh-CN" selected>简体中文</option>
<option value="ja" >日本語</option>
<option value="en" >English</option>
</select>
<button type="button" id="theme-toggle-mobile" class="uk-button uk-button-default uk-button-small"
title="切换深色/浅色模式" aria-label="切换深色/浅色模式">
<span class="tb-icon-light"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20.5 14.3A8.6 8.6 0 0 1 9.7 3.5a8.6 8.6 0 1 0 10.8 10.8z"/></svg></span><span class="tb-icon-dark"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4"/></svg></span>
</button>
</div>
<script>
(function() {
var THEME_COOKIE = 'theme';
function getTheme() {
var m = document.cookie.match(/(?:^|;\s*)theme=([^;]*)/);
return m ? m[1] : '';
}
function applyTheme(theme) {
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
}
function toggleTheme() {
var next = getTheme() === 'dark' ? 'light' : 'dark';
applyTheme(next);
var expires = new Date(Date.now() + 365 * 24 * 3600 * 1000).toUTCString();
document.cookie = THEME_COOKIE + '=' + next + '; path=/; max-age=' + (365 * 24 * 3600) + '; SameSite=Lax';
}
document.addEventListener('DOMContentLoaded', function() {
var buttons = document.querySelectorAll('#theme-toggle, #theme-toggle-mobile');
buttons.forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();
toggleTheme();
});
});
var langSel = document.getElementById('mobile-lang-select');
if (langSel) {
langSel.addEventListener('change', function() {
if (!this.value) return;
var to = window.location.pathname + window.location.search;
window.location.href = '/i18n/' + encodeURIComponent(this.value) + '?to=' + encodeURIComponent(to);
});
}
});
})();
</script>
<div class="uk-container uk-container-xsmall">
<div id="math-captcha-modal" uk-modal>
<div class="uk-modal-dialog uk-modal-body uk-text-center">
<h3 class="uk-modal-title">人机验证</h3>
<p class="uk-text-meta">请计算下方的算术题以验证你不是机器人</p>
<div class="captcha-equation-box">
<span id="captcha-modal-question" class="math-captcha-modal-question">加载中...</span>
<input type="number" id="captcha-modal-answer" placeholder="?" autocomplete="off" inputmode="numeric">
</div>
<div style="margin-top:6px;">
<button type="button" class="uk-button uk-button-text uk-text-small" id="captcha-modal-refresh">换一题</button>
</div>
<div id="captcha-modal-error" class="uk-text-danger uk-text-small" style="display:none;min-height:20px;"></div>
<div class="uk-margin-small-top">
<button type="button" class="uk-button uk-button-primary" id="captcha-modal-submit">提交</button>
<button type="button" class="uk-button uk-button-default uk-modal-close">取消</button>
</div>
</div>
</div>
<script>
var CAPTCHA_I18N = {
loading: "加载中...",
loadFailed: "加载失败",
loadRetry: "加载失败,请重试",
inputAnswer: "请输入答案",
wrongAnswer: "答案错误,请重新计算",
verifyFailed: "验证失败,请重试",
needPassword: "此提问箱需要密码",
needEmail: "请填写接收通知的邮箱地址"
};
</script>
<script>
(function() {
var captchaId = '';
var pendingForm = null;
var pendingNeedPassword = false;
var verifying = false;
function fetchCaptcha() {
var qEl = document.getElementById('captcha-modal-question');
var errEl = document.getElementById('captcha-modal-error');
var ansEl = document.getElementById('captcha-modal-answer');
qEl.textContent = CAPTCHA_I18N.loading;
fetch('/api/captcha/generate')
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.code === 0 && data.data) {
captchaId = data.data.id;
qEl.textContent = data.data.question;
if (errEl) errEl.style.display = 'none';
if (ansEl) { ansEl.value = ''; ansEl.focus(); }
} else {
qEl.textContent = CAPTCHA_I18N.loadFailed;
}
})
.catch(function() {
qEl.textContent = CAPTCHA_I18N.loadRetry;
});
}
function getCSRFToken() {
var input = document.querySelector('input[name="_csrf"]');
return input ? input.value : '';
}
function doSubmit(form) {
var ans = document.getElementById('captcha-modal-answer').value.trim();
var hidden = form.querySelector('[name="g-recaptcha-response"]');
if (hidden) hidden.value = captchaId + ':' + (ans || '0');
UIkit.modal('#math-captcha-modal').hide();
if (pendingNeedPassword) {
UIkit.modal.prompt(CAPTCHA_I18N.needPassword, '', 'password')
.then(function(password) {
if (password) {
var pwInput = document.getElementById('ask_password_input');
if (pwInput) pwInput.value = password;
form.submit();
}
});
} else {
form.submit();
}
}
document.getElementById('captcha-modal-submit').addEventListener('click', function() {
if (verifying) return;
var ans = document.getElementById('captcha-modal-answer').value.trim();
if (!ans) {
var errEl = document.getElementById('captcha-modal-error');
errEl.textContent = CAPTCHA_I18N.inputAnswer;
errEl.style.display = 'block';
return;
}
verifying = true;
fetch('/api/captcha/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCSRFToken()
},
body: JSON.stringify({ id: captchaId, answer: parseInt(ans, 10) })
})
.then(function(r) { return r.json(); })
.then(function(data) {
verifying = false;
if (data.correct) {
if (pendingForm) doSubmit(pendingForm);
} else {
var errEl = document.getElementById('captcha-modal-error');
errEl.textContent = CAPTCHA_I18N.wrongAnswer;
errEl.style.display = 'block';
fetchCaptcha();
}
})
.catch(function() {
verifying = false;
var errEl = document.getElementById('captcha-modal-error');
errEl.textContent = CAPTCHA_I18N.verifyFailed;
errEl.style.display = 'block';
fetchCaptcha();
});
});
document.getElementById('captcha-modal-answer').addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
document.getElementById('captcha-modal-submit').click();
}
});
document.getElementById('captcha-modal-refresh').addEventListener('click', fetchCaptcha);
document.addEventListener('click', function(e) {
var btn = e.target.closest('.math-captcha-trigger');
if (!btn) return;
e.preventDefault();
var form = btn.closest('form');
if (!form) return;
pendingForm = form;
pendingNeedPassword = !!(form.querySelector('#ask_password_input'));
var notifyCb = document.getElementById('notify-checkbox');
var emailInput = document.getElementById('email-input');
if (notifyCb && notifyCb.checked && emailInput && emailInput.value.trim() === '') {
UIkit.modal.alert(CAPTCHA_I18N.needEmail);
return;
}
fetchCaptcha();
UIkit.modal('#math-captcha-modal').show();
});
})();
</script>
<style>
.tb-hero {
padding: 36px 24px 32px;
text-align: center;
background-image: linear-gradient(180deg, var(--tb-primary-weak), rgba(255, 255, 255, 0));
}
.tb-hero-title {
margin: 0;
font-size: 1.75rem;
line-height: 1.35;
font-weight: 600;
color: var(--tb-text);
}
.tb-hero-lead {
margin: 12px auto 0;
max-width: 30rem;
font-size: 0.95rem;
line-height: 1.8;
color: var(--tb-text-muted);
}
.tb-hero-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
margin-top: 22px;
}
.tb-hero-actions .uk-button {
min-width: 112px;
}
.tb-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 12px;
margin-top: 12px;
}
.tb-grid .uk-card {
padding: 18px 18px 20px;
text-align: left;
}
.tb-card-title {
margin: 0 0 6px;
font-size: 0.98rem;
font-weight: 600;
color: var(--tb-text);
}
.tb-card-text {
margin: 0;
font-size: 0.86rem;
line-height: 1.7;
color: var(--tb-text-muted);
}
.tb-card-text + .tb-card-text {
margin-top: 8px;
}
.tb-card-text.tb-lead {
margin-top: 12px;
}
.tb-changelog summary + ul {
margin-top: 10px;
}
.tb-section-title {
margin: 0 0 10px;
font-size: 0.95rem;
font-weight: 600;
color: var(--tb-text);
}
.tb-notice-tag {
display: inline-block;
margin: 0 0 8px;
padding: 2px 8px;
border-radius: var(--tb-radius-xs);
background: var(--tb-primary-weak);
color: var(--tb-primary);
font-size: 0.75rem;
}
.tb-list {
margin: 0;
padding-left: 1.15em;
font-size: 0.9rem;
line-height: 1.9;
color: var(--tb-text);
}
.tb-changelog summary {
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
color: var(--tb-text);
list-style: none;
}
.tb-changelog summary::-webkit-details-marker { display: none; }
.tb-changelog summary::before {
content: "▸";
display: inline-block;
margin-right: 8px;
color: var(--tb-text-muted);
transition: transform 0.2s ease;
}
.tb-changelog[open] summary::before { transform: rotate(90deg); }
.tb-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
text-align: center;
}
.tb-stat-num {
display: block;
font-size: 1.5rem;
font-weight: 600;
line-height: 1.3;
color: var(--tb-text);
}
.tb-stat-label {
display: block;
margin-top: 2px;
font-size: 0.78rem;
color: var(--tb-text-muted);
}
.tb-stats-meta {
margin: 14px 0 0;
font-size: 0.78rem;
line-height: 1.8;
color: var(--tb-text-muted);
text-align: center;
}
@media (max-width: 480px) {
.tb-hero { padding: 28px 16px 24px; }
.tb-hero-title { font-size: 1.4rem; }
.tb-stat-num { font-size: 1.25rem; }
}
</style>
<div class="uk-card uk-card-default tb-hero">
<h1 class="tb-hero-title">狼的提问箱 | 匿名提问箱</h1>
<p class="tb-hero-lead">
一个无需注册即可提问的匿名提问箱。不用下载 App,不用绑定社交账号,连邮箱都不必是真的。
</p>
<div class="tb-hero-actions">
<a href="/explore" class="uk-button uk-button-primary">探索</a>
<a href="/rules" class="uk-button uk-button-default">社区规则</a>
</div>
</div>
<div class="tb-grid">
<div class="uk-card uk-card-default">
<p class="tb-card-title">匿名也能问</p>
<p class="tb-card-text">不需要账号、不需要真实邮箱,打开就能写。想收到回复再留邮箱即可。</p>
</div>
<div class="uk-card uk-card-default">
<p class="tb-card-title">不止一问一答</p>
<p class="tb-card-text">问题二维码、私聊、投票、追加提问、Markdown 排版,原版没有的这里都有。</p>
</div>
<div class="uk-card uk-card-default">
<p class="tb-card-title">箱子归你</p>
<p class="tb-card-text">头像、背景、二维码配色都能自己调,还能设置审核严格度和收集时段。</p>
</div>
</div>
<div class="uk-card uk-card-default uk-card-body">
<p class="tb-notice-tag">域名迁移</p>
<p class="tb-card-text">
本站已切换到 <a href="https://box.shiroko.one/">box.shiroko.one</a>,原域名将在 2026 年 12 月 24 日到期,
期间通过 301 转发指向新域名,到期后旧域名将彻底不再使用。
</p>
<p class="tb-card-text">
审核服务已切换到阿里云,审核速度提升且误伤更少;同时允许用户调整审核严格度,在不碰底线的前提下
可以设置不接受辱骂、广告等信息(即「不听坏话模式」)。
</p>
</div>
<div class="uk-card uk-card-default uk-card-body">
<h2 class="tb-section-title">一点提醒</h2>
<ul class="tb-list">
<li>本站唯一指定认证站长:<a href="https://box.shiroko.one/_/tamakyi">小狼</a>,注意不要被骗。</li>
<li>本站提倡友好、友善的发言,请遵守<a href="/rules">社区规则</a>。无论匿名与否,都要为自己的发言负责。</li>
<li>不允许垃圾信息、广告、辱骂、涉政、暴恐、色情、违禁、无意义、灌水提问,涉政或不良言论将直接删除。</li>
</ul>
<p class="tb-card-text tb-lead">
想先试试功能,可以到站长的提问箱
<a href="//box.shiroko.one/_/tamakyi">小狼的提问箱</a> 提问或提意见。
</p>
</div>
<details class="uk-card uk-card-default uk-card-body tb-changelog">
<summary>近期更新</summary>
<ul class="tb-list">
<li><strong>2026.9</strong> 新增审核严格度选项,增加提问、投票、私聊的总开关,可设置定时收集、定量收集或关闭功能,前后端显示也更易用了。</li>
<li><strong>2026.8</strong> 新增<a href="https://box.shiroko.one/vote/sANyLkiBNUGN">投票功能</a>、追加提问、多语言、<a href="https://box.shiroko.one/_/tamakyi/2894">Markdown 显示</a>、置顶显示等,详情见<a href="https://blog.shiroko.one/record/tamabox_changelog_20260813">这里</a>。</li>
<li><strong>2026.8</strong> 放弃使用 recaptcha 等对国内用户不友好的验证方式,更新密码加密方式,修复图片上传等一系列问题。</li>
<li><strong>2026.5</strong> 增加私聊功能,可连续对话,详见<a href="https://blog.shiroko.one/record/tamabox-update-20260513">介绍</a>;另有提问箱卡片分享。</li>
<li><strong>2026.2</strong> 新增用户展示和标签功能,可给自己贴 5 个标签并选择是否在<a href="https://box.shiroko.one/explore">探索页</a>展示,详情见<a href="https://blog.shiroko.one/record/tamabox-tags">狼的公告板</a>。</li>
</ul>
</details>
<div class="uk-card uk-card-default uk-card-body">
<h2 class="tb-section-title">提问箱信息统计</h2>
<div class="tb-stats">
<div>
<span class="tb-stat-num tb-num" data-stat="registered_users">1284</span>
<span class="tb-stat-label">注册用户</span>
</div>
<div>
<span class="tb-stat-num tb-num" data-stat="total_questions">56321</span>
<span class="tb-stat-label">累计提问</span>
</div>
<div>
<span class="tb-stat-num tb-num" data-stat="answered_questions">31208</span>
<span class="tb-stat-label">已获回答</span>
</div>
</div>
<p class="tb-stats-meta">
最后提问:<span data-stat="last_question_at">2026-10-19 10:12:44</span><br>
最后回答:<span data-stat="last_answer_at">2026-10-19 09:58:02</span><br>
数据更新于:<span data-stat="updated_at">2026-10-19 10:00:00</span>
</p>
</div>
<script type="text/javascript">
document.addEventListener('DOMContentLoaded', function () {
setInterval(function () {
fetch("/api/v1/site-stats")
.then(function (r) { return r.json(); })
.then(function (resp) {
var d = resp.data;
if (!d) return;
["registered_users", "total_questions", "answered_questions",
"last_question_at", "last_answer_at", "updated_at"].forEach(function (k) {
var v = d[k];
if (v === undefined || v === null || v === "") return;
var el = document.querySelector('[data-stat="' + k + '"]');
if (el) el.textContent = v;
});
})
.catch(function () {});
}, 30 * 60 * 1000);
});
</script>
<div id="waline"></div>
<script>
document.addEventListener('DOMContentLoaded', function () {
const waline = Waline.init({
el: '#waline',
serverURL: 'https://waline.337845818.best/',
path: window.location.href,
locale: {
nick: '昵称',
nickError: '昵称不能少于3个字符',
mail: '邮箱',
mailError: '请填写正确的邮件地址',
link: '网址',
optional: '可选',
placeholder: '欢迎评论(可以无需登录,但需要手工审核,不会立即展现)',
sofa: '来发评论吧~',
submit: '提交',
like: '喜欢',
cancelLike: '取消喜欢',
reply: '回复',
cancelReply: '取消回复',
comment: '评论',
refresh: '刷新',
more: '加载更多...',
preview: '预览',
emoji: '表情',
uploadImage: '上传图片',
seconds: '秒前',
minutes: '分钟前',
hours: '小时前',
days: '天前',
now: '刚刚',
uploading: '正在上传',
login: '登录',
logout: '退出',
admin: '博主',
sticky: '置顶',
word: '字',
wordHint: '评论字数应在 $0 到 $1 字之间!\n当前字数:$2',
anonymous: '无名小狼',
level0: '潜水',
level1: '冒泡',
level2: '吐槽',
level3: '活跃',
level4: '话痨',
level5: '传说',
gif: '表情包',
gifSearchPlaceholder: '搜索表情包',
profile: '个人资料',
approved: '通过',
waiting: '待审核',
spam: '垃圾',
unsticky: '取消置顶',
oldest: '按倒序',
latest: '按正序',
hottest: '按热度',
reactionTitle: '',
reaction0: '哇哦',
reaction1: 'prpr',
reaction2: '流汗',
reaction3: '生气',
reaction4: '流鼻血',
reaction5: 'あべ',
},
reaction: [
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChYwOGQ3OTRhYzRjMTBkZmZhZDVmZTA1EIjM1y8.gif',
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChQwOGEyOTlhYzRjMTBmOWFkZjAwYRCIzNcv.gif',
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChYwOGRhOTVhYzRjMTBkNmViYTVlNTA2EIjM1y8.gif',
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChYwOGU1OTJhYzRjMTBmMzk3ZmVmNTAyEIjM1y8.gif',
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChYwOGNkODdhYzRjMTBjYmEwOTFjNjAxEIjM1y8.gif',
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChQwODliOGVhYzRjMTBmOGM0ZWY1ZRCIzNcv.gif',
],
meta: ['nick','mail'],
requiredMeta: ['mail'],
pageview: true,
search: false,
login: 'enable',
wordLimit: 50,
pageSize: 5,
avatar: 'identicon',
emoji: [
'https://gcore.jsdelivr.net/gh/tamakyi/emoji-collection@main/acfun-color/60x60',
],
imageUploader: false,
copyright: false,
});
});
</script>
<p class=" uk-text-right uk-text-muted uk-text-small">
<br>
<a href="/rules">社区规则</a> |
<a href="https://box.shiroko.one/_/tamakyi" target="_blank">找站长</a>
<br>
<a href="https://share.shiroko.one" target="_blank">盘</a>
| <a href="https://blog.shiroko.one" target="_blank">公告板</a>
| <a href="https://chat.shiroko.one" target="_blank">聊天室</a>
| <a href="https://status.shiroko.one" target="_blank">监控板</a>
| <a href="https://shiroko.one" target="_blank">导航页</a>
<br>Powered by NekoBox |
<br>提问箱已成功存活:<span id="box_runtime"></span>
</p>
<script>
(function () {
var create = Math.round(Date.UTC(2023, 6, 1, 18, 15, 43) / 1000);
function tick() {
var el = document.getElementById("box_runtime");
if (!el) return;
var s = Math.round((Date.now() + 8 * 3600 * 1000) / 1000) - create;
var t = [0, 0, 0, 0, 0];
if (s >= 31536000) { t[0] = s / 31536000 | 0; s %= 31536000; }
if (s >= 86400) { t[1] = s / 86400 | 0; s %= 86400; }
if (s >= 3600) { t[2] = s / 3600 | 0; s %= 3600; }
if (s >= 60) { t[3] = s / 60 | 0; s %= 60; }
if (s > 0) { t[4] = s; }
el.innerHTML = t[0] + '年 ' + t[1] + '天 ' + t[2] + '时 ' + t[3] + '分 ' + t[4] + '秒 ';
}
tick();
setInterval(tick, 1000);
})();
</script>
<br>
</div>
<script>
var SUBMIT_I18N = {
submitting: '提交中...',
uploading: '上传中...'
};
function tbResetSubmitButtons() {
document.querySelectorAll('button[data-tb-submitting]').forEach(function(b) {
b.disabled = false;
b.removeAttribute('data-tb-submitting');
var orig = b.getAttribute('data-tb-restore');
if (orig !== null) {
b.innerHTML = orig;
b.removeAttribute('data-tb-restore');
}
});
}
document.addEventListener('submit', function(e) {
var form = e.target;
if (!form || form.tagName !== 'FORM') return;
if (e.defaultPrevented || form.getAttribute('data-tb-skip-submit-feedback') === '1') {
form.removeAttribute('data-tb-skip-submit-feedback');
return;
}
if (form.getAttribute('target') === '_blank') return;
var trigger = e.submitter || form.querySelector('button[type="submit"]');
var hasFile = false;
form.querySelectorAll('input[type="file"]').forEach(function(f) {
if (f.files && f.files.length > 0) hasFile = true;
});
var label = hasFile ? SUBMIT_I18N.uploading : SUBMIT_I18N.submitting;
if (trigger && trigger.tagName === 'BUTTON') {
trigger.setAttribute('data-tb-restore', trigger.innerHTML);
trigger.innerHTML = '<span uk-spinner="ratio: 0.6"></span> ' + label;
}
form.querySelectorAll('button').forEach(function(b) {
b.disabled = true;
b.setAttribute('data-tb-submitting', '1');
});
});
window.addEventListener('pageshow', tbResetSubmitButtons);
</script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More