chore: outputs 不再纳入版本控制
.gitignore 里原本写的是 `./outputs` —— gitignore 不认 `./` 前缀,整条规则失效, 于是 247 个产物(预览快照、截图、历史 review 报告)一直被跟踪, 每次跑 TestGenPreview 都会脏一遍工作区。 改成 `/outputs/` 并 git rm -r --cached outputs:索引里删除,磁盘文件全部保留。 克隆者不再拿到这些一次性产物;需要预览快照时跑 TEST_PREVIEW=1 go test ./templates -run TestGenPreview 重新生成。
@@ -28,4 +28,7 @@ go.work.sum
|
||||
# otherfile
|
||||
./workbuddy
|
||||
./scripts
|
||||
./outputs
|
||||
# 注意:gitignore 不认 "./" 前缀,/outputs 才是「仓库根下的 outputs」。
|
||||
# outputs 里全是 TestGenPreview / 主题生成器 / 截图这类一次性产物,
|
||||
# 每次跑都会变,跟版本库无关。
|
||||
/outputs/
|
||||
|
||||
@@ -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),并补充 `<script>...`、双重编码、属性上下文等回归测试。修复后检查所有 `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`
|
||||
|
Before Width: | Height: | Size: 367 KiB |
|
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 × 敏感词表 = 零命中
|
||||
@@ -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 内出现可扫 → 用户体感"加载中"消失。
|
||||
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 108 KiB |
@@ -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` 注释已更新。
|
||||
|
Before Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 257 KiB |
@@ -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 仍可用。
|
||||
@@ -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 / 相关包测试。*
|
||||
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 297 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
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。
|
||||
@@ -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/` 资源。
|
||||
@@ -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) 哈希,不影响转发性能。
|
||||
@@ -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)
|
||||
@@ -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 真实编译)
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 524 B |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 308 KiB |
@@ -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&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 要不要上。要不要我先出这部分的变更清单?
|
||||
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 46 KiB |
@@ -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 上限等)尚未处理。
|
||||
@@ -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 ./...` 验证。
|
||||
|
Before Width: | Height: | Size: 61 KiB |
@@ -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 ./...` 验证全部包。
|
||||
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 49 KiB |
@@ -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 小时即时区偏移实锤
|
||||
-- ============================================================================
|
||||
@@ -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 层问题
|
||||
-- ============================================================================
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||