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
+126
View File
@@ -0,0 +1,126 @@
// 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 (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/flamego/flamego"
"github.com/stretchr/testify/require"
)
// TestRecoveryMiddlewareRendersHTML 锁住 panic 的对外表现。
//
// 改动前用的是 flamego.Recovery():它直接写纯文本 "Internal Server Error",
// 用户看到白底黑字两行英文,没有返回入口,也拿不到可以报给我们的编号。
func TestRecoveryMiddlewareRendersHTML(t *testing.T) {
f := flamego.New()
f.Use(recoveryMiddleware())
f.Get("/boom", func() {
panic("boom")
})
req := httptest.NewRequest(http.MethodGet, "/boom", nil)
resp := httptest.NewRecorder()
f.ServeHTTP(resp, req)
require.Equal(t, http.StatusInternalServerError, resp.Code)
require.True(t, strings.HasPrefix(resp.Header().Get("Content-Type"), "text/html"),
"必须是 HTML,不能退回纯文本")
require.Equal(t, "no-store", resp.Header().Get("Cache-Control"))
body := resp.Body.String()
require.Contains(t, body, "<!DOCTYPE html>")
// 中文环境(default lang)下应给出可读文案与返回入口
require.Contains(t, body, "服务出了点问题")
require.Contains(t, body, `href="/"`, "错误页必须有返回首页的入口")
require.NotContains(t, body, "boom", "不能把 panic 内容暴露给用户")
}
// TestRecoveryMiddlewareDoesNotDoubleWrite 响应已写出时不能再写头。
//
// 场景:handler 先写了一部分内容再 panic。这时 HTTP 头已经发出去了,
// 再 WriteHeader 会被 net/http 忽略(并打 superfluous 警告),
// 强行写还会把已经发出去的响应体盖掉。
func TestRecoveryMiddlewareDoesNotDoubleWrite(t *testing.T) {
f := flamego.New()
f.Use(recoveryMiddleware())
f.Get("/half", func(ctx flamego.Context) {
_, _ = ctx.ResponseWriter().Write([]byte("partial"))
panic("boom after write")
})
req := httptest.NewRequest(http.MethodGet, "/half", nil)
resp := httptest.NewRecorder()
f.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code, "头已发出,状态码不能变")
require.Equal(t, "partial", resp.Body.String(), "不能把已写出的内容覆盖掉")
}
// TestPickErrorPageLang 错误页语言只能靠 Accept-Language 猜
// (sessioner 是组级中间件,NotFound 不经过它)。
func TestPickErrorPageLang(t *testing.T) {
cases := []struct {
name string
header string
want string
}{
{"空 header 用站点默认", "", ""},
{"英文", "en-US,en;q=0.9", "en"},
{"日文", "ja-JP,ja;q=0.9", "ja"},
{"繁体", "zh-TW,zh;q=0.9", "zh-TW"},
{"zh-Hant 也算繁体", "zh-Hant", "zh-TW"},
{"简体", "zh-CN,zh;q=0.9", "zh-CN"},
// 浏览器常把首选语言排在前面,这里按出现顺序取第一个能识别的
{"首选英文后跟日文", "en-GB,en;q=0.9,ja;q=0.5", "en"},
{"无法识别时回退默认", "de-DE,fr;q=0.8", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/nope", nil)
if c.header != "" {
req.Header.Set("Accept-Language", c.header)
}
got := pickErrorPageLang(req)
if c.want == "" {
// 回退到站点默认语言,不是空串
require.NotEmpty(t, got)
return
}
require.Equal(t, c.want, got)
})
}
}
// TestWriteErrorPageStatusCodes 404 与 500 各自的状态码与 noindex。
func TestWriteErrorPageStatusCodes(t *testing.T) {
for _, status := range []int{http.StatusNotFound, http.StatusInternalServerError} {
f := flamego.New()
f.Get("/x", func(ctx flamego.Context) {
writeErrorPage(ctx, status, "error.notFoundTitle", "error.notFoundDesc")
})
req := httptest.NewRequest(http.MethodGet, "/x", nil)
resp := httptest.NewRecorder()
f.ServeHTTP(resp, req)
require.Equal(t, status, resp.Code)
require.Contains(t, resp.Body.String(), `name="robots" content="noindex"`,
"错误页不该被搜索引擎收录")
}
}
// TestEscErrorPageText 字典文本里出现引号/尖括号时不能把页面结构弄坏。
func TestEscErrorPageText(t *testing.T) {
require.Equal(t, "a&quot;b", esc(`a"b`))
require.Equal(t, "&lt;script&gt;", esc("<script>"))
require.Equal(t, "a&amp;b", esc("a&b"))
require.Equal(t, "&#39;x&#39;", esc("'x'"))
}