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 内容/已写出时不重复写头/语言选择/状态码/转义)
This commit is contained in:
tamakyi
2026-10-29 17:18:39 +08:00
parent d5d25c804b
commit 5def41d0f8
10 changed files with 885 additions and 142 deletions
+172
View File
@@ -0,0 +1,172 @@
// 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 route
import (
"fmt"
"net/http"
"runtime/debug"
"strings"
"github.com/flamego/flamego"
"github.com/sirupsen/logrus"
"go.opentelemetry.io/otel/trace"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/shared"
)
// recoveryMiddleware 替代 flamego.Recovery(),把 panic 渲染成 HTML 500 页。
//
// 为什么不用框架自带的:flamego.Recovery() 直接写纯文本 "Internal Server Error",
// 用户看到的是白底黑字的两行英文,地址栏还停在出错的 URL 上 ——
// 既不知道发生了什么,也没有回去的入口,更没法把这次错误的编号报给我们。
func recoveryMiddleware() flamego.Handler {
return func(ctx flamego.Context) {
defer func() {
if r := recover(); r != nil {
// 带栈:没有栈的 panic 日志几乎无法定位。
logrus.WithContext(ctx.Request().Context()).
Errorf("panic recovered: %v\n%s", r, debug.Stack())
if ctx.ResponseWriter().Written() {
// 头已经发出去了,没法再改状态码,只能让连接断在这里。
return
}
writeErrorPage(ctx, http.StatusInternalServerError, "error.internalTitle", "error.internalDesc")
}
}()
ctx.Next()
}
}
// writeErrorPage 输出不依赖模板系统的完整错误页。
//
// 为什么不用模板:这两个页面出现在两处「拿不到模板」的地方 ——
// NotFound 处理器不在组级中间件链里(template.Templater 与 Contexter 都挂在组级,
// 包括 session 与 CSRF),而 recovery 中间件挂在最外层,那时模板还没装配。
// 错误页恰恰是最不该依赖出错链路的页面,所以这里用内联 HTML + i18n.T。
func writeErrorPage(ctx flamego.Context, status int, titleKey, descKey string) {
lang := pickErrorPageLang(ctx.Request().Request)
title := i18n.T(lang, titleKey)
desc := i18n.T(lang, descKey)
home := i18n.T(lang, "deactivated.backHome")
hint := i18n.T(lang, "error.traceHint")
traceID := ""
if span := trace.SpanFromContext(ctx.Request().Context()); span.SpanContext().TraceID().IsValid() {
traceID = span.SpanContext().TraceID().String()
}
body := fmt.Sprintf(`<!DOCTYPE html>
<html lang="%s"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex">
<title>%s</title>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
padding: 24px; background: #f7f7f8; color: #1f2328;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB",
"Microsoft YaHei", sans-serif; line-height: 1.7;
}
.card {
width: 100%%; max-width: 30rem; padding: 2rem 1.75rem; text-align: center;
background: #fff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,.08), 0 8px 24px rgba(0,0,0,.04);
}
h1 { font-size: 1.35rem; margin: 0 0 .75rem; font-weight: 600; }
p { margin: 0; color: #59636e; }
.hint { margin-top: 1rem; font-size: .8125rem; color: #818b98; }
code { background: #eceef0; padding: .1rem .35rem; border-radius: 4px; font-size: .8125rem; }
.actions { margin-top: 1.75rem; }
a.btn {
display: inline-block; padding: .55rem 1.25rem; border-radius: 6px;
background: #1f6feb; color: #fff; text-decoration: none; font-size: .9375rem;
}
a.btn:hover { background: #1a5fd0; }
@media (prefers-color-scheme: dark) {
body { background: #16181a; color: #e6edf3; }
.card { background: #1f2225; box-shadow: none; border: 1px solid #2c3136; }
p { color: #9aa4af; }
.hint { color: #7d8792; }
code { background: #2c3136; }
}
</style></head>
<body><div class="card">
<h1>%s</h1>
<p>%s</p>
%s
<div class="actions"><a class="btn" href="/">%s</a></div>
</div></body></html>`,
htmlLang(lang), esc(title), esc(title), esc(desc),
func() string {
if traceID == "" {
return ""
}
return `<p class="hint">` + esc(hint) + ` <code>` + esc(traceID) + `</code></p>`
}(),
esc(home),
)
ctx.ResponseWriter().Header().Set("Content-Type", "text/html; charset=utf-8")
ctx.ResponseWriter().Header().Set("X-Content-Type-Options", "nosniff")
// 错误页不缓存:修好之后用户刷新应该立刻拿到正常页面。
ctx.ResponseWriter().Header().Set("Cache-Control", "no-store")
ctx.ResponseWriter().WriteHeader(status)
_, _ = ctx.ResponseWriter().Write([]byte(body))
}
// pickErrorPageLang 选错误页语言。
//
// 这里拿不到 session(sessioner 是组级中间件,NotFound 不经过它),
// 所以只能从 Accept-Language 猜一个,猜不到就用站点默认语言。
func pickErrorPageLang(r *http.Request) string {
def := i18n.Resolve(shared.App.Lang())
header := strings.ToLower(r.Header.Get("Accept-Language"))
if header == "" {
return def
}
// 按声明顺序取第一个能识别的语言偏好(q 值排序这里不精确处理:
// 错误页的语言不需要那么讲究,按出现顺序取第一个命中的即可)。
for _, part := range strings.Split(header, ",") {
tag := strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
switch {
case strings.HasPrefix(tag, "zh-tw"), strings.HasPrefix(tag, "zh-hk"),
strings.HasPrefix(tag, "zh-hant"):
return i18n.Resolve("zh-TW")
case strings.HasPrefix(tag, "zh"):
return i18n.Resolve("zh-CN")
case strings.HasPrefix(tag, "ja"):
return i18n.Resolve("ja")
case strings.HasPrefix(tag, "en"):
return i18n.Resolve("en")
}
}
return def
}
// htmlLang 把内部语言代码转成 <html lang> 该用的 BCP 47 值。
func htmlLang(lang string) string {
switch lang {
case "zh-CN":
return "zh-CN"
case "zh-TW":
return "zh-Hant"
case "ja":
return "ja"
default:
return "en"
}
}
// esc 对极少数字符做转义。这里的内容全部来自字典(不是用户输入),
// 但仍然转义一次 —— 字典里出现引号或 & 时不能把页面结构弄坏。
func esc(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&#39;")
return r.Replace(s)
}