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 截断:直接截原文会露出 
这种噪音,一条带图的提问几乎只剩链接。
验证:
- verify_ask_history.py 12/12(含「错误 token / 不存在的 id / 别人的提问」三者
响应完全一致、缺 CSRF 被拒、超 20 条截断)
- verify_ask_history_ui.mjs 用 CDP 在真实 Chrome 里跑:无记录时提示条隐藏;
注入记录后显示「1 条,其中 1 条已有回答」;列表渲染、链接带 token、点击展开正常
- 错误页单测 6 项(HTML/不泄露 panic 内容/已写出时不重复写头/语言选择/状态码/转义)
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
// 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,直接截原文会在列表里露出
|
||||
// `` 这种噪音,一条带图的提问几乎只剩链接。
|
||||
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})
|
||||
}
|
||||
Reference in New Issue
Block a user