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:
+50
-35
@@ -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": "不正なリクエストパス",
|
||||
|
||||
Reference in New Issue
Block a user