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
+50 -35
View File
@@ -88,41 +88,49 @@ var enDict = map[string]string{
"explore.noVotes": "There are no public polls available right now.",
// Ask page
"ask.scan": "Scan to ask @%s",
"ask.anonymous": "Anyone can ask anonymously",
"ask.registerOnly": "Only registered users can ask",
"ask.nextOpenAt": "Next opening: %s",
"ask.lastCloseAt": "Last closed: %s",
"weekday.0": "Sun",
"weekday.1": "Mon",
"weekday.2": "Tue",
"weekday.3": "Wed",
"weekday.4": "Thu",
"weekday.5": "Fri",
"weekday.6": "Sat",
"ask.pinned": "Pinned",
"ask.share": "Share this box",
"ask.placeholder": "Write your question here...",
"ask.chatPlaceholder": "Write your chat message here...",
"ask.addImage": "Add image",
"ask.private": "Don't publish after reply",
"ask.notify": "Notify me",
"ask.emailPlaceholder": "Enter your email address",
"ask.submit": "Send question",
"ask.chat": "Start a chat",
"ask.chatDisabled": "Chat is disabled on this box",
"ask.chatLimit": "Message limit: %d",
"ask.history": "Previously answered by @%s",
"ask.loginRequired": "Log in required",
"ask.loginRequiredChat": "You need to log in to start a chat",
"ask.goLogin": "Go to login",
"ask.needPassword": "This box requires a password",
"ask.needLogin": "The owner only allows registered users to ask questions. Please log in first.",
"ask.pleaseEmail": "Please enter your email to receive notifications",
"ask.generating": "Generating share card…",
"ask.genFailed": "Failed to generate share card. Please try again later",
"ask.networkError": "Network error",
"ask.shareFilename": "share.png",
"ask.scan": "Scan to ask @%s",
"ask.anonymous": "Anyone can ask anonymously",
"ask.registerOnly": "Only registered users can ask",
"ask.nextOpenAt": "Next opening: %s",
"ask.lastCloseAt": "Last closed: %s",
"weekday.0": "Sun",
"weekday.1": "Mon",
"weekday.2": "Tue",
"weekday.3": "Wed",
"weekday.4": "Thu",
"weekday.5": "Fri",
"weekday.6": "Sat",
"ask.pinned": "Pinned",
"ask.share": "Share this box",
"ask.placeholder": "Write your question here...",
"ask.chatPlaceholder": "Write your chat message here...",
"ask.addImage": "Add image",
"ask.private": "Don't publish after reply",
"ask.notify": "Notify me",
"ask.emailPlaceholder": "Enter your email address",
"ask.submit": "Send question",
"ask.chat": "Start a chat",
"ask.chatDisabled": "Chat is disabled on this box",
"ask.chatLimit": "Message limit: %d",
"ask.history": "Previously answered by @%s",
"askHistory.banner": "You have {n} question(s) saved on this device",
"askHistory.answeredSuffix": ", {m} of them answered",
"askHistory.expand": "View",
"askHistory.collapse": "Hide",
"askHistory.answered": "Answered",
"askHistory.pending": "Awaiting reply",
"askHistory.open": "Open",
"askHistory.note": "Saved only in this browser. Switching devices or clearing browser data will lose them.",
"ask.loginRequired": "Log in required",
"ask.loginRequiredChat": "You need to log in to start a chat",
"ask.goLogin": "Go to login",
"ask.needPassword": "This box requires a password",
"ask.needLogin": "The owner only allows registered users to ask questions. Please log in first.",
"ask.pleaseEmail": "Please enter your email to receive notifications",
"ask.generating": "Generating share card…",
"ask.genFailed": "Failed to generate share card. Please try again later",
"ask.networkError": "Network error",
"ask.shareFilename": "share.png",
// Question item
"qitem.answer": "Answer",
@@ -846,6 +854,7 @@ var enDict = map[string]string{
"vote.err.editClosed": "Votes that are ended or archived cannot be edited",
"vote.err.publishDraftOnly": "Only draft polls can be published",
"vote.err.restrictedPublish": "Publishing is suspended while this account is under moderation",
"feature.stateUnavailable": "Unable to verify the status of this feature, please try again later",
"vote.err.closePublishedOnly": "Only active polls can be closed",
"vote.err.publishTitleRequired": "Please fill in the poll title before publishing",
"vote.err.publishFormFieldsRequired": "Form polls need at least 1 field",
@@ -911,6 +920,12 @@ var enDict = map[string]string{
"chat.flash.operated": "Operation successful",
"image.errTooLarge": "Image is too large, maximum supported size is %d×%d pixels, please compress and re-upload",
"internal.errFlash": "Internal server error, please try again later.",
"internal.errBadRequest": "Invalid request",
"error.notFoundTitle": "Page not found",
"error.notFoundDesc": "There is nothing at this address. The link may be mistyped, or the content has been removed.",
"error.internalTitle": "Something went wrong",
"error.internalDesc": "This request could not be completed. It is usually temporary — try again in a moment.",
"error.traceHint": "Please include this ID when reporting:",
"internal.errServer": "Internal server error",
"home.errStats": "Failed to retrieve statistics",
"pixel.errPath": "Invalid request path",
+50 -35
View File
@@ -88,41 +88,49 @@ var jaDict = map[string]string{
"explore.noVotes": "現在、参加できる公開投票はありません。",
// 質問ページ
"ask.scan": "QRコードで@%sに質問",
"ask.anonymous": "誰でも匿名で質問できます",
"ask.registerOnly": "登録ユーザーのみ質問できます",
"ask.nextOpenAt": "次回開放:%s",
"ask.lastCloseAt": "前回終了:%s",
"weekday.0": "日",
"weekday.1": "月",
"weekday.2": "火",
"weekday.3": "水",
"weekday.4": "木",
"weekday.5": "金",
"weekday.6": "土",
"ask.pinned": "ピン留め済み",
"ask.share": "質問箱を共有",
"ask.placeholder": "ここに質問を書いてください...",
"ask.chatPlaceholder": "ここにチャットメッセージを書いてください...",
"ask.addImage": "画像を追加",
"ask.private": "返信後に公開しない",
"ask.notify": "通知を受け取る",
"ask.emailPlaceholder": "メールアドレスを入力してください",
"ask.submit": "質問を送信",
"ask.chat": "チャットを開始",
"ask.chatDisabled": "この質問箱ではチャットが無効です",
"ask.chatLimit": "メッセージ上限:%d件",
"ask.history": "@%s が以前回答した質問",
"ask.loginRequired": "ログインが必要",
"ask.loginRequiredChat": "チャットを開始するにはログインが必要です",
"ask.goLogin": "ログインへ",
"ask.needPassword": "この質問箱にはパスワードが必要です",
"ask.needLogin": "質問箱の所有者は登録ユーザーのみ質問できるように設定しています。先にログインしてください。",
"ask.pleaseEmail": "通知を受け取るメールアドレスを入力してください",
"ask.generating": "共有カードを生成中…",
"ask.genFailed": "共有カードの生成に失敗しました。しばらくしてからお試しください",
"ask.networkError": "ネットワークエラー",
"ask.shareFilename": "share.png",
"ask.scan": "QRコードで@%sに質問",
"ask.anonymous": "誰でも匿名で質問できます",
"ask.registerOnly": "登録ユーザーのみ質問できます",
"ask.nextOpenAt": "次回開放:%s",
"ask.lastCloseAt": "前回終了:%s",
"weekday.0": "日",
"weekday.1": "月",
"weekday.2": "火",
"weekday.3": "水",
"weekday.4": "木",
"weekday.5": "金",
"weekday.6": "土",
"ask.pinned": "ピン留め済み",
"ask.share": "質問箱を共有",
"ask.placeholder": "ここに質問を書いてください...",
"ask.chatPlaceholder": "ここにチャットメッセージを書いてください...",
"ask.addImage": "画像を追加",
"ask.private": "返信後に公開しない",
"ask.notify": "通知を受け取る",
"ask.emailPlaceholder": "メールアドレスを入力してください",
"ask.submit": "質問を送信",
"ask.chat": "チャットを開始",
"ask.chatDisabled": "この質問箱ではチャットが無効です",
"ask.chatLimit": "メッセージ上限:%d件",
"ask.history": "@%s が以前回答した質問",
"askHistory.banner": "この端末に {n} 件の質問履歴があります",
"askHistory.answeredSuffix": "(うち {m} 件は回答済み)",
"askHistory.expand": "表示",
"askHistory.collapse": "閉じる",
"askHistory.answered": "回答済み",
"askHistory.pending": "回答待ち",
"askHistory.open": "開く",
"askHistory.note": "履歴はこのブラウザにのみ保存されます。端末の変更やブラウザデータの削除で失われます。",
"ask.loginRequired": "ログインが必要",
"ask.loginRequiredChat": "チャットを開始するにはログインが必要です",
"ask.goLogin": "ログインへ",
"ask.needPassword": "この質問箱にはパスワードが必要です",
"ask.needLogin": "質問箱の所有者は登録ユーザーのみ質問できるように設定しています。先にログインしてください。",
"ask.pleaseEmail": "通知を受け取るメールアドレスを入力してください",
"ask.generating": "共有カードを生成中…",
"ask.genFailed": "共有カードの生成に失敗しました。しばらくしてからお試しください",
"ask.networkError": "ネットワークエラー",
"ask.shareFilename": "share.png",
// 質問アイテム
"qitem.answer": "回答",
@@ -845,6 +853,7 @@ var jaDict = map[string]string{
"vote.err.editClosed": "終了またはアーカイブされた投票は編集できません",
"vote.err.publishDraftOnly": "草稿状態の投票のみ公開できます",
"vote.err.restrictedPublish": "現在はプラットフォームによる措置期間中のため、投票の公開を停止しています",
"feature.stateUnavailable": "機能の状態を確認できませんでした。しばらくしてから再度お試しください",
"vote.err.closePublishedOnly": "進行中の投票のみ終了できます",
"vote.err.publishTitleRequired": "公開前に投票のタイトルを入力してください",
"vote.err.publishFormFieldsRequired": "フォーム型投票には最低 1 つの項目が必要です",
@@ -910,6 +919,12 @@ var jaDict = map[string]string{
"chat.flash.operated": "操作が完了しました",
"image.errTooLarge": "画像が大きすぎます。最大%d×%dピクセルまで対応しています。圧縮して再アップロードしてください",
"internal.errFlash": "サーバー内部エラーが発生しました。後でもう一度お試しください。",
"internal.errBadRequest": "リクエストの内容が正しくありません",
"error.notFoundTitle": "ページが見つかりません",
"error.notFoundDesc": "このアドレスにコンテンツはありません。リンクが間違っているか、削除された可能性があります。",
"error.internalTitle": "問題が発生しました",
"error.internalDesc": "このリクエストを完了できませんでした。多くは一時的なものです。しばらくしてから再度お試しください。",
"error.traceHint": "ご報告の際はこの番号を添えてください:",
"internal.errServer": "サーバー内部エラー",
"home.errStats": "統計情報の取得に失敗しました",
"pixel.errPath": "不正なリクエストパス",
+50 -35
View File
@@ -88,41 +88,49 @@ var zhCNDict = map[string]string{
"explore.noVotes": "暂时没有可参与的公开投票。",
// 提问页
"ask.scan": "扫码向@%s提问",
"ask.anonymous": "谁都可以以匿名的形式提问",
"ask.registerOnly": "仅允许注册用户向TA提问",
"ask.nextOpenAt": "下次开放时间:%s",
"ask.lastCloseAt": "上次关闭时间:%s",
"weekday.0": "周日",
"weekday.1": "周一",
"weekday.2": "周二",
"weekday.3": "周三",
"weekday.4": "周四",
"weekday.5": "周五",
"weekday.6": "周六",
"ask.pinned": "已置顶",
"ask.share": "分享提问箱",
"ask.placeholder": "在此处撰写你的问题...",
"ask.chatPlaceholder": "在此处撰写你的私聊消息...",
"ask.addImage": "添加图片",
"ask.private": "回复后不公开",
"ask.notify": "接收通知",
"ask.emailPlaceholder": "请输入你的邮箱地址",
"ask.submit": "发送提问",
"ask.chat": "发起私聊",
"ask.chatDisabled": "该提问箱未开启私聊",
"ask.chatLimit": "消息上限:%d条",
"ask.history": "@%s 以前回答过的问题",
"ask.loginRequired": "需要登录",
"ask.loginRequiredChat": "发起私聊需要登录账号",
"ask.goLogin": "去登录",
"ask.needPassword": "此提问箱需要密码",
"ask.needLogin": "提问箱的主人设置了仅注册用户才能提问,你需要先登录才能向他提问。",
"ask.pleaseEmail": "请填写接收通知的邮箱地址",
"ask.generating": "正在生成分享图…",
"ask.genFailed": "生成分享图失败,请稍后重试",
"ask.networkError": "网络错误",
"ask.shareFilename": "share.png",
"ask.scan": "扫码向@%s提问",
"ask.anonymous": "谁都可以以匿名的形式提问",
"ask.registerOnly": "仅允许注册用户向TA提问",
"ask.nextOpenAt": "下次开放时间:%s",
"ask.lastCloseAt": "上次关闭时间:%s",
"weekday.0": "周日",
"weekday.1": "周一",
"weekday.2": "周二",
"weekday.3": "周三",
"weekday.4": "周四",
"weekday.5": "周五",
"weekday.6": "周六",
"ask.pinned": "已置顶",
"ask.share": "分享提问箱",
"ask.placeholder": "在此处撰写你的问题...",
"ask.chatPlaceholder": "在此处撰写你的私聊消息...",
"ask.addImage": "添加图片",
"ask.private": "回复后不公开",
"ask.notify": "接收通知",
"ask.emailPlaceholder": "请输入你的邮箱地址",
"ask.submit": "发送提问",
"ask.chat": "发起私聊",
"ask.chatDisabled": "该提问箱未开启私聊",
"ask.chatLimit": "消息上限:%d条",
"ask.history": "@%s 以前回答过的问题",
"askHistory.banner": "这台设备上有 {n} 条提问记录",
"askHistory.answeredSuffix": ",其中 {m} 条已有回答",
"askHistory.expand": "查看",
"askHistory.collapse": "收起",
"askHistory.answered": "已有回答",
"askHistory.pending": "等待回答",
"askHistory.open": "打开",
"askHistory.note": "记录只保存在这台设备的浏览器里,换设备或清除浏览器数据后会丢失。",
"ask.loginRequired": "需要登录",
"ask.loginRequiredChat": "发起私聊需要登录账号",
"ask.goLogin": "去登录",
"ask.needPassword": "此提问箱需要密码",
"ask.needLogin": "提问箱的主人设置了仅注册用户才能提问,你需要先登录才能向他提问。",
"ask.pleaseEmail": "请填写接收通知的邮箱地址",
"ask.generating": "正在生成分享图…",
"ask.genFailed": "生成分享图失败,请稍后重试",
"ask.networkError": "网络错误",
"ask.shareFilename": "share.png",
// 问题条目
"qitem.answer": "回答",
@@ -846,6 +854,7 @@ var zhCNDict = map[string]string{
"vote.err.editClosed": "已结束或归档的投票不允许编辑",
"vote.err.publishDraftOnly": "只有草稿状态的投票可以发布",
"vote.err.restrictedPublish": "当前处于平台处置期,已暂停发布投票",
"feature.stateUnavailable": "暂时无法确认该功能的状态,请稍后重试",
"vote.err.closePublishedOnly": "只有进行中的投票可以结束",
"vote.err.publishTitleRequired": "请先填写投票标题再发布",
"vote.err.publishFormFieldsRequired": "表单类型投票至少需要 1 个字段",
@@ -923,6 +932,12 @@ var zhCNDict = map[string]string{
"chat.flash.operated": "操作成功",
"image.errTooLarge": "图片尺寸过大,最大支持 %d×%d 像素,请压缩后重新上传",
"internal.errFlash": "服务内部错误,请稍后重试。",
"internal.errBadRequest": "请求参数不正确",
"error.notFoundTitle": "页面不存在",
"error.notFoundDesc": "这个地址没有内容,可能是链接输错了,或者内容已经被删除。",
"error.internalTitle": "服务出了点问题",
"error.internalDesc": "这次请求没能完成,通常是临时的。稍后重试一般就好了。",
"error.traceHint": "反馈时请带上这个编号:",
"internal.errServer": "服务器内部错误",
"home.errStats": "获取统计信息失败",
"pixel.errPath": "非法的请求路径",
+50 -35
View File
@@ -88,41 +88,49 @@ var zhTWDict = map[string]string{
"explore.noVotes": "暫時沒有可參與的公開投票。",
// 提問頁
"ask.scan": "掃碼向@%s提問",
"ask.anonymous": "誰都可以以匿名的方式提問",
"ask.registerOnly": "僅允許註冊使用者向TA提問",
"ask.nextOpenAt": "下次開放時間:%s",
"ask.lastCloseAt": "上次關閉時間:%s",
"weekday.0": "週日",
"weekday.1": "週一",
"weekday.2": "週二",
"weekday.3": "週三",
"weekday.4": "週四",
"weekday.5": "週五",
"weekday.6": "週六",
"ask.pinned": "已置頂",
"ask.share": "分享提問箱",
"ask.placeholder": "在此處撰寫你的問題...",
"ask.chatPlaceholder": "在此處撰寫你的私聊訊息...",
"ask.addImage": "新增圖片",
"ask.private": "回覆後不公開",
"ask.notify": "接收通知",
"ask.emailPlaceholder": "請輸入你的信箱地址",
"ask.submit": "傳送提問",
"ask.chat": "發起私聊",
"ask.chatDisabled": "該提問箱未開啟私聊",
"ask.chatLimit": "訊息上限:%d則",
"ask.history": "@%s 以前回答過的問題",
"ask.loginRequired": "需要登入",
"ask.loginRequiredChat": "發起私聊需要登入帳號",
"ask.goLogin": "去登入",
"ask.needPassword": "此提問箱需要密碼",
"ask.needLogin": "提問箱的主人設定了僅註冊使用者才能提問,你需要先登入才能向他提問。",
"ask.pleaseEmail": "請填寫接收通知的信箱地址",
"ask.generating": "正在產生分享圖…",
"ask.genFailed": "產生分享圖失敗,請稍後重試",
"ask.networkError": "網路錯誤",
"ask.shareFilename": "share.png",
"ask.scan": "掃碼向@%s提問",
"ask.anonymous": "誰都可以以匿名的方式提問",
"ask.registerOnly": "僅允許註冊使用者向TA提問",
"ask.nextOpenAt": "下次開放時間:%s",
"ask.lastCloseAt": "上次關閉時間:%s",
"weekday.0": "週日",
"weekday.1": "週一",
"weekday.2": "週二",
"weekday.3": "週三",
"weekday.4": "週四",
"weekday.5": "週五",
"weekday.6": "週六",
"ask.pinned": "已置頂",
"ask.share": "分享提問箱",
"ask.placeholder": "在此處撰寫你的問題...",
"ask.chatPlaceholder": "在此處撰寫你的私聊訊息...",
"ask.addImage": "新增圖片",
"ask.private": "回覆後不公開",
"ask.notify": "接收通知",
"ask.emailPlaceholder": "請輸入你的信箱地址",
"ask.submit": "傳送提問",
"ask.chat": "發起私聊",
"ask.chatDisabled": "該提問箱未開啟私聊",
"ask.chatLimit": "訊息上限:%d則",
"ask.history": "@%s 以前回答過的問題",
"askHistory.banner": "這台裝置上有 {n} 筆提問紀錄",
"askHistory.answeredSuffix": ",其中 {m} 筆已有回答",
"askHistory.expand": "查看",
"askHistory.collapse": "收合",
"askHistory.answered": "已有回答",
"askHistory.pending": "等待回答",
"askHistory.open": "開啟",
"askHistory.note": "紀錄只保存在這台裝置的瀏覽器裡,換裝置或清除瀏覽器資料後會遺失。",
"ask.loginRequired": "需要登入",
"ask.loginRequiredChat": "發起私聊需要登入帳號",
"ask.goLogin": "去登入",
"ask.needPassword": "此提問箱需要密碼",
"ask.needLogin": "提問箱的主人設定了僅註冊使用者才能提問,你需要先登入才能向他提問。",
"ask.pleaseEmail": "請填寫接收通知的信箱地址",
"ask.generating": "正在產生分享圖…",
"ask.genFailed": "產生分享圖失敗,請稍後重試",
"ask.networkError": "網路錯誤",
"ask.shareFilename": "share.png",
// 問題條目
"qitem.answer": "回答",
@@ -845,6 +853,7 @@ var zhTWDict = map[string]string{
"vote.err.editClosed": "已結束或歸檔的投票不允許編輯",
"vote.err.publishDraftOnly": "只有草稿狀態的投票可以發布",
"vote.err.restrictedPublish": "當前處於平台處置期,已暫停發布投票",
"feature.stateUnavailable": "暫時無法確認該功能的狀態,請稍後重試",
"vote.err.closePublishedOnly": "只有進行中的投票可以結束",
"vote.err.publishTitleRequired": "請先填寫投票標題再發布",
"vote.err.publishFormFieldsRequired": "表單類型投票至少需要 1 個欄位",
@@ -910,6 +919,12 @@ var zhTWDict = map[string]string{
"chat.flash.operated": "操作成功",
"image.errTooLarge": "圖片尺寸過大,最大支援 %d×%d 像素,請壓縮後重新上傳",
"internal.errFlash": "服務內部錯誤,請稍後重試。",
"internal.errBadRequest": "請求參數不正確",
"error.notFoundTitle": "頁面不存在",
"error.notFoundDesc": "這個位址沒有內容,可能是連結輸入錯誤,或者內容已經被刪除。",
"error.internalTitle": "服務出了點問題",
"error.internalDesc": "這次請求沒能完成,通常是暫時的。稍後重試一般就好了。",
"error.traceHint": "回報時請帶上這個編號:",
"internal.errServer": "伺服器內部錯誤",
"home.errStats": "獲取統計資訊失敗",
"pixel.errPath": "非法的請求路徑",
+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)
}
+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'"))
}
+13 -2
View File
@@ -193,7 +193,7 @@ func New() *flamego.Flame {
f := flamego.New()
// 访问日志放在 Recovery 外层:panic 被兜成 500 后仍能记到状态码。
f.Use(accessLogMiddleware())
f.Use(flamego.Recovery())
f.Use(recoveryMiddleware())
if conf.App.Production {
flamego.SetEnv(flamego.EnvTypeProd)
@@ -563,6 +563,10 @@ func New() *flamego.Flame {
f.Get("/explore/random", rateLimitMiddleware(60, 1*time.Minute), user.RandomRecommend)
// 首页"提问箱信息统计"公开接口(服务端 30 分钟缓存),供前端定时刷新
f.Get("/site-stats", rateLimitMiddleware(60, 1*time.Minute), route.SiteStatsAPI)
// 提问者「找回自己的提问」:浏览器里存着 {id, token},回访时批量问一次
// 哪些已被回答。必须同时给出正确 token 才返回有效信息,无法用于枚举。
// 用 POST 而不是 GET:token 不能进 URL(会留在浏览器历史、代理日志、Referer 里)。
f.Post("/ask-status", rateLimitMiddleware(30, 1*time.Minute), question.AskStatusAPI)
}, context.APIEndpoint)
},
cacheHandler,
@@ -624,7 +628,14 @@ func New() *flamego.Flame {
if ip := shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request)); ip != "" {
ab.Record(ip, ctx.Request().URL.Path, autoban.KindNotFound)
}
ctx.Redirect("/")
// 真 404:渲染错误页并写 404 状态码。
//
// 原来这里是 ctx.Redirect("/")。对用户来说那是「点了链接,地址栏跳到首页,
// 却什么都没提示」—— 分不清是链接坏了还是自己点错了;对搜索引擎是软 404,
// 失效链接会被当成有效页面收进索引,一直占着抓取预算。
// 蜜罐分流仍在最前面,这里只处理「确实不存在的普通路径」。
writeErrorPage(ctx, http.StatusNotFound, "error.notFoundTitle", "error.notFoundDesc")
})
return f