diff --git a/internal/i18n/dict_en.go b/internal/i18n/dict_en.go index 8732e3e..c11ac5e 100644 --- a/internal/i18n/dict_en.go +++ b/internal/i18n/dict_en.go @@ -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", diff --git a/internal/i18n/dict_ja.go b/internal/i18n/dict_ja.go index 86a08e0..641e808 100644 --- a/internal/i18n/dict_ja.go +++ b/internal/i18n/dict_ja.go @@ -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": "不正なリクエストパス", diff --git a/internal/i18n/dict_zh_cn.go b/internal/i18n/dict_zh_cn.go index 4539a23..01ca1f7 100644 --- a/internal/i18n/dict_zh_cn.go +++ b/internal/i18n/dict_zh_cn.go @@ -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": "非法的请求路径", diff --git a/internal/i18n/dict_zh_tw.go b/internal/i18n/dict_zh_tw.go index 7dd4449..73692de 100644 --- a/internal/i18n/dict_zh_tw.go +++ b/internal/i18n/dict_zh_tw.go @@ -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": "非法的請求路徑", diff --git a/internal/route/errorpage.go b/internal/route/errorpage.go new file mode 100644 index 0000000..b835fff --- /dev/null +++ b/internal/route/errorpage.go @@ -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(` + + + +%s + +
+

%s

+

%s

+ %s +
%s
+
`, + htmlLang(lang), esc(title), esc(title), esc(desc), + func() string { + if traceID == "" { + return "" + } + return `

` + esc(hint) + ` ` + esc(traceID) + `

` + }(), + 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 把内部语言代码转成 该用的 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("&", "&", "<", "<", ">", ">", `"`, """, "'", "'") + return r.Replace(s) +} diff --git a/internal/route/errorpage_test.go b/internal/route/errorpage_test.go new file mode 100644 index 0000000..73e8d27 --- /dev/null +++ b/internal/route/errorpage_test.go @@ -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, "") + // 中文环境(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"b", esc(`a"b`)) + require.Equal(t, "<script>", esc(" + + {{template "base/footer" .}} \ No newline at end of file diff --git a/templates/question/success.html b/templates/question/success.html index 87df201..44d14e0 100644 --- a/templates/question/success.html +++ b/templates/question/success.html @@ -36,6 +36,33 @@