Files
tamabox/route/question/ask_history.go
T
tamakyi 5def41d0f8 feat(ux): 真 404 / HTML 500 页,以及「提问者找回自己的提问」
一、错误页

原来未知路径一律 302 到首页:用户点了个失效链接,地址栏莫名跳到首页却什么
提示都没有,分不清是链接坏了还是自己点错了;对搜索引擎是软 404,失效链接会
被当成有效页面一直占抓取预算。500 则是 flamego.Recovery() 的纯文本
"Internal Server Error",白底黑字两行英文,没有返回入口,也没法报编号。

现在是 internal/route/errorpage.go 里的 writeErrorPage:
- 404 保留状态码 + 一页能看懂的说明 + 返回首页按钮 + noindex
- panic 由自写的 recoveryMiddleware 兜住,渲染同样的 HTML 500 页,
  并带上 trace id(用户反馈时报这个号就能定位日志),日志里也带完整栈
- 响应已写出时不再写头(否则 net/http 会忽略状态码并打 superfluous 警告)

为什么不套模板:f.NotFound 不经过组级中间件 —— template.Templater 与
Contexter(连同 session、CSRF)都挂在 f.Group 上,在 NotFound 里注入
template.Data 会直接 panic(实测:"value not found for type template.Data")。
而 recovery 是最外层,那时模板还没装配。错误页恰恰最不该依赖可能出错的链路,
所以用内联 HTML + i18n.T,语言从 Accept-Language 猜(拿不到 session)。

蜜罐分流仍在 NotFound 最前面:临时打开 enable_honeypot 实测 /wp-admin
仍返回假 WordPress 登录页(200),普通未知路径返回 404。

二、提问者找回自己的提问

不填邮箱时,唯一能追踪这条提问的东西是提问成功页上那条链接 —— 关掉页面、
链接冲掉了就永久失联。箱主认真答了,提问者永远不知道。这是这类产品最核心的
体验断点,而它不需要账号体系就能解决:

- 成功页把 {domain, id, token} 写进本机 localStorage(最多 30 条,按 domain+id 去重)
- 再访该箱时显示一条不打扰的横条:「这台设备上有 N 条提问记录,其中 M 条已有回答」,
  点「查看」展开列表,每条可直接打开
- 新增 POST /api/v1/ask-status 批量回答「哪些已被回答」:一次 IN 查询,不逐条 N+1

它不能用来枚举:必须同时给出正确 token 才返回 ok=true,而「token 错」「id 不存在」
「不属于该箱主」三种情况的响应完全相同(ok=false / answered=false / excerpt 为空),
无法区分。token 比较走常数时间,单次最多 20 条,限流 30/分钟。用 POST 不用 GET,
因为 token 不能进 URL(会留在浏览器历史、代理日志、Referer 里)。

列表摘要先过 MarkdownPlainText 再按 rune 截断:直接截原文会露出 ![图片](https://...)
这种噪音,一条带图的提问几乎只剩链接。

验证:
- verify_ask_history.py 12/12(含「错误 token / 不存在的 id / 别人的提问」三者
  响应完全一致、缺 CSRF 被拒、超 20 条截断)
- verify_ask_history_ui.mjs 用 CDP 在真实 Chrome 里跑:无记录时提示条隐藏;
  注入记录后显示「1 条,其中 1 条已有回答」;列表渲染、链接带 token、点击展开正常
- 错误页单测 6 项(HTML/不泄露 panic 内容/已写出时不重复写头/语言选择/状态码/转义)
2026-10-29 17:18:39 +08:00

131 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package question
import (
"crypto/subtle"
"encoding/json"
"net/http"
"strings"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/i18n"
templatepkg "github.com/tamakyi/TamaBox/internal/template"
)
// 一次最多查询多少条。浏览器里存的是「这台设备上提过的全部问题」,
// 正常用户不会超过几条;设上限是为了让这个公开接口不能被当成批量探测器用。
const askStatusMaxItems = 20
type askStatusItem struct {
ID uint `json:"id"`
Token string `json:"token"`
}
type askStatusRequest struct {
Domain string `json:"domain"`
Items []askStatusItem `json:"items"`
}
type askStatusResult struct {
ID uint `json:"id"`
OK bool `json:"ok"` // token 校验通过(false 表示记录已失效,前端应删除)
Answered bool `json:"answered"` // 已有回答(追问不计)
Excerpt string `json:"excerpt"` // 正文摘要,让用户一眼认出是哪一条
Created int64 `json:"created"` // 提问时间(unix 秒),前端按本地时区显示
}
// askExcerptRuneLimit 摘要长度(按 rune 计,不按字节 —— 按字节会在多字节字符中间切断)。
const askExcerptRuneLimit = 60
// askExcerpt 取正文前若干字符作为摘要。
//
// 先转纯文本再截断:正文是 markdown,直接截原文会在列表里露出
// `![图片](https://...)` 这种噪音,一条带图的提问几乎只剩链接。
func askExcerpt(content string) string {
r := []rune(strings.TrimSpace(templatepkg.PlainText(content)))
if len(r) <= askExcerptRuneLimit {
return string(r)
}
return string(r[:askExcerptRuneLimit]) + "…"
}
// AskStatusAPI 批量查询「这些提问里哪些已经被回答」。
//
// 存在的理由:不填邮箱的提问者,唯一的追踪手段是提问成功页上那条链接。
// 关掉页面就等于永久失联 —— 箱主认真答了,提问者永远不知道。
// 前端把 {domain, id, token} 存在 localStorage,再回到箱主页时问一次这个接口,
// 就能提示「你有 2 条提问已被回答」。不引入账号,也不在服务端建立访客追踪。
//
// 安全性:必须同时给出正确的 token 才返回 ok=true,token 是 12 位随机串,
// 且比较走常数时间(与 Pager 中间件同口径),所以这个接口不能用来枚举
// 「某箱主有多少提问」「某个 id 是否存在」—— 错误的组合一律 ok=false,
// 与「提问不存在」「不属于该箱主」「token 不对」三种情况无法区分。
func AskStatusAPI(ctx context.Context) error {
if ctx.Request().Method != http.MethodPost {
return ctx.JSONError(40500, http.StatusText(http.StatusMethodNotAllowed))
}
var req askStatusRequest
if err := json.NewDecoder(ctx.Request().Request.Body).Decode(&req); err != nil {
return ctx.JSONError(40000, i18n.T(ctx.Lang(), "internal.errBadRequest"))
}
if req.Domain == "" || len(req.Items) == 0 {
return ctx.JSONError(40000, i18n.T(ctx.Lang(), "internal.errBadRequest"))
}
if len(req.Items) > askStatusMaxItems {
req.Items = req.Items[:askStatusMaxItems]
}
owner, err := db.Users.GetByDomain(ctx.Request().Context(), req.Domain)
if err != nil {
if errors.Is(err, db.ErrUserNotExists) {
// 不区分「箱主不存在」与「提问不属于他」:一律返回空结果。
return ctx.JSON(map[string]interface{}{"items": []askStatusResult{}})
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get box owner for ask status")
return ctx.ServerError()
}
// 收集合法 ID 并一次查回(IN 查询,避免逐条 N+1)。
ids := make([]uint, 0, len(req.Items))
for _, it := range req.Items {
if it.ID != 0 {
ids = append(ids, it.ID)
}
}
questions, err := db.Questions.ListByIDsAndUser(ctx.Request().Context(), ids, owner.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to list questions for ask status")
return ctx.ServerError()
}
byID := make(map[uint]*db.Question, len(questions))
for _, q := range questions {
byID[q.ID] = q
}
out := make([]askStatusResult, 0, len(req.Items))
for _, it := range req.Items {
res := askStatusResult{ID: it.ID}
if q, found := byID[it.ID]; found && q.Token != "" && it.Token != "" &&
subtle.ConstantTimeCompare([]byte(q.Token), []byte(it.Token)) == 1 {
res.OK = true
res.Answered = q.Answer != ""
// token 已校验通过,返回摘要不额外泄露任何东西 —— 提问者本来就能用
// 同一个 token 打开全文。
res.Excerpt = askExcerpt(q.Content)
res.Created = q.CreatedAt.Unix()
}
out = append(out, res)
}
return ctx.JSON(map[string]interface{}{"items": out})
}