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 enDict = map[string]string{
|
|||||||
"explore.noVotes": "There are no public polls available right now.",
|
"explore.noVotes": "There are no public polls available right now.",
|
||||||
|
|
||||||
// Ask page
|
// Ask page
|
||||||
"ask.scan": "Scan to ask @%s",
|
"ask.scan": "Scan to ask @%s",
|
||||||
"ask.anonymous": "Anyone can ask anonymously",
|
"ask.anonymous": "Anyone can ask anonymously",
|
||||||
"ask.registerOnly": "Only registered users can ask",
|
"ask.registerOnly": "Only registered users can ask",
|
||||||
"ask.nextOpenAt": "Next opening: %s",
|
"ask.nextOpenAt": "Next opening: %s",
|
||||||
"ask.lastCloseAt": "Last closed: %s",
|
"ask.lastCloseAt": "Last closed: %s",
|
||||||
"weekday.0": "Sun",
|
"weekday.0": "Sun",
|
||||||
"weekday.1": "Mon",
|
"weekday.1": "Mon",
|
||||||
"weekday.2": "Tue",
|
"weekday.2": "Tue",
|
||||||
"weekday.3": "Wed",
|
"weekday.3": "Wed",
|
||||||
"weekday.4": "Thu",
|
"weekday.4": "Thu",
|
||||||
"weekday.5": "Fri",
|
"weekday.5": "Fri",
|
||||||
"weekday.6": "Sat",
|
"weekday.6": "Sat",
|
||||||
"ask.pinned": "Pinned",
|
"ask.pinned": "Pinned",
|
||||||
"ask.share": "Share this box",
|
"ask.share": "Share this box",
|
||||||
"ask.placeholder": "Write your question here...",
|
"ask.placeholder": "Write your question here...",
|
||||||
"ask.chatPlaceholder": "Write your chat message here...",
|
"ask.chatPlaceholder": "Write your chat message here...",
|
||||||
"ask.addImage": "Add image",
|
"ask.addImage": "Add image",
|
||||||
"ask.private": "Don't publish after reply",
|
"ask.private": "Don't publish after reply",
|
||||||
"ask.notify": "Notify me",
|
"ask.notify": "Notify me",
|
||||||
"ask.emailPlaceholder": "Enter your email address",
|
"ask.emailPlaceholder": "Enter your email address",
|
||||||
"ask.submit": "Send question",
|
"ask.submit": "Send question",
|
||||||
"ask.chat": "Start a chat",
|
"ask.chat": "Start a chat",
|
||||||
"ask.chatDisabled": "Chat is disabled on this box",
|
"ask.chatDisabled": "Chat is disabled on this box",
|
||||||
"ask.chatLimit": "Message limit: %d",
|
"ask.chatLimit": "Message limit: %d",
|
||||||
"ask.history": "Previously answered by @%s",
|
"ask.history": "Previously answered by @%s",
|
||||||
"ask.loginRequired": "Log in required",
|
"askHistory.banner": "You have {n} question(s) saved on this device",
|
||||||
"ask.loginRequiredChat": "You need to log in to start a chat",
|
"askHistory.answeredSuffix": ", {m} of them answered",
|
||||||
"ask.goLogin": "Go to login",
|
"askHistory.expand": "View",
|
||||||
"ask.needPassword": "This box requires a password",
|
"askHistory.collapse": "Hide",
|
||||||
"ask.needLogin": "The owner only allows registered users to ask questions. Please log in first.",
|
"askHistory.answered": "Answered",
|
||||||
"ask.pleaseEmail": "Please enter your email to receive notifications",
|
"askHistory.pending": "Awaiting reply",
|
||||||
"ask.generating": "Generating share card…",
|
"askHistory.open": "Open",
|
||||||
"ask.genFailed": "Failed to generate share card. Please try again later",
|
"askHistory.note": "Saved only in this browser. Switching devices or clearing browser data will lose them.",
|
||||||
"ask.networkError": "Network error",
|
"ask.loginRequired": "Log in required",
|
||||||
"ask.shareFilename": "share.png",
|
"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
|
// Question item
|
||||||
"qitem.answer": "Answer",
|
"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.editClosed": "Votes that are ended or archived cannot be edited",
|
||||||
"vote.err.publishDraftOnly": "Only draft polls can be published",
|
"vote.err.publishDraftOnly": "Only draft polls can be published",
|
||||||
"vote.err.restrictedPublish": "Publishing is suspended while this account is under moderation",
|
"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.closePublishedOnly": "Only active polls can be closed",
|
||||||
"vote.err.publishTitleRequired": "Please fill in the poll title before publishing",
|
"vote.err.publishTitleRequired": "Please fill in the poll title before publishing",
|
||||||
"vote.err.publishFormFieldsRequired": "Form polls need at least 1 field",
|
"vote.err.publishFormFieldsRequired": "Form polls need at least 1 field",
|
||||||
@@ -911,6 +920,12 @@ var enDict = map[string]string{
|
|||||||
"chat.flash.operated": "Operation successful",
|
"chat.flash.operated": "Operation successful",
|
||||||
"image.errTooLarge": "Image is too large, maximum supported size is %d×%d pixels, please compress and re-upload",
|
"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.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",
|
"internal.errServer": "Internal server error",
|
||||||
"home.errStats": "Failed to retrieve statistics",
|
"home.errStats": "Failed to retrieve statistics",
|
||||||
"pixel.errPath": "Invalid request path",
|
"pixel.errPath": "Invalid request path",
|
||||||
|
|||||||
+50
-35
@@ -88,41 +88,49 @@ var jaDict = map[string]string{
|
|||||||
"explore.noVotes": "現在、参加できる公開投票はありません。",
|
"explore.noVotes": "現在、参加できる公開投票はありません。",
|
||||||
|
|
||||||
// 質問ページ
|
// 質問ページ
|
||||||
"ask.scan": "QRコードで@%sに質問",
|
"ask.scan": "QRコードで@%sに質問",
|
||||||
"ask.anonymous": "誰でも匿名で質問できます",
|
"ask.anonymous": "誰でも匿名で質問できます",
|
||||||
"ask.registerOnly": "登録ユーザーのみ質問できます",
|
"ask.registerOnly": "登録ユーザーのみ質問できます",
|
||||||
"ask.nextOpenAt": "次回開放:%s",
|
"ask.nextOpenAt": "次回開放:%s",
|
||||||
"ask.lastCloseAt": "前回終了:%s",
|
"ask.lastCloseAt": "前回終了:%s",
|
||||||
"weekday.0": "日",
|
"weekday.0": "日",
|
||||||
"weekday.1": "月",
|
"weekday.1": "月",
|
||||||
"weekday.2": "火",
|
"weekday.2": "火",
|
||||||
"weekday.3": "水",
|
"weekday.3": "水",
|
||||||
"weekday.4": "木",
|
"weekday.4": "木",
|
||||||
"weekday.5": "金",
|
"weekday.5": "金",
|
||||||
"weekday.6": "土",
|
"weekday.6": "土",
|
||||||
"ask.pinned": "ピン留め済み",
|
"ask.pinned": "ピン留め済み",
|
||||||
"ask.share": "質問箱を共有",
|
"ask.share": "質問箱を共有",
|
||||||
"ask.placeholder": "ここに質問を書いてください...",
|
"ask.placeholder": "ここに質問を書いてください...",
|
||||||
"ask.chatPlaceholder": "ここにチャットメッセージを書いてください...",
|
"ask.chatPlaceholder": "ここにチャットメッセージを書いてください...",
|
||||||
"ask.addImage": "画像を追加",
|
"ask.addImage": "画像を追加",
|
||||||
"ask.private": "返信後に公開しない",
|
"ask.private": "返信後に公開しない",
|
||||||
"ask.notify": "通知を受け取る",
|
"ask.notify": "通知を受け取る",
|
||||||
"ask.emailPlaceholder": "メールアドレスを入力してください",
|
"ask.emailPlaceholder": "メールアドレスを入力してください",
|
||||||
"ask.submit": "質問を送信",
|
"ask.submit": "質問を送信",
|
||||||
"ask.chat": "チャットを開始",
|
"ask.chat": "チャットを開始",
|
||||||
"ask.chatDisabled": "この質問箱ではチャットが無効です",
|
"ask.chatDisabled": "この質問箱ではチャットが無効です",
|
||||||
"ask.chatLimit": "メッセージ上限:%d件",
|
"ask.chatLimit": "メッセージ上限:%d件",
|
||||||
"ask.history": "@%s が以前回答した質問",
|
"ask.history": "@%s が以前回答した質問",
|
||||||
"ask.loginRequired": "ログインが必要",
|
"askHistory.banner": "この端末に {n} 件の質問履歴があります",
|
||||||
"ask.loginRequiredChat": "チャットを開始するにはログインが必要です",
|
"askHistory.answeredSuffix": "(うち {m} 件は回答済み)",
|
||||||
"ask.goLogin": "ログインへ",
|
"askHistory.expand": "表示",
|
||||||
"ask.needPassword": "この質問箱にはパスワードが必要です",
|
"askHistory.collapse": "閉じる",
|
||||||
"ask.needLogin": "質問箱の所有者は登録ユーザーのみ質問できるように設定しています。先にログインしてください。",
|
"askHistory.answered": "回答済み",
|
||||||
"ask.pleaseEmail": "通知を受け取るメールアドレスを入力してください",
|
"askHistory.pending": "回答待ち",
|
||||||
"ask.generating": "共有カードを生成中…",
|
"askHistory.open": "開く",
|
||||||
"ask.genFailed": "共有カードの生成に失敗しました。しばらくしてからお試しください",
|
"askHistory.note": "履歴はこのブラウザにのみ保存されます。端末の変更やブラウザデータの削除で失われます。",
|
||||||
"ask.networkError": "ネットワークエラー",
|
"ask.loginRequired": "ログインが必要",
|
||||||
"ask.shareFilename": "share.png",
|
"ask.loginRequiredChat": "チャットを開始するにはログインが必要です",
|
||||||
|
"ask.goLogin": "ログインへ",
|
||||||
|
"ask.needPassword": "この質問箱にはパスワードが必要です",
|
||||||
|
"ask.needLogin": "質問箱の所有者は登録ユーザーのみ質問できるように設定しています。先にログインしてください。",
|
||||||
|
"ask.pleaseEmail": "通知を受け取るメールアドレスを入力してください",
|
||||||
|
"ask.generating": "共有カードを生成中…",
|
||||||
|
"ask.genFailed": "共有カードの生成に失敗しました。しばらくしてからお試しください",
|
||||||
|
"ask.networkError": "ネットワークエラー",
|
||||||
|
"ask.shareFilename": "share.png",
|
||||||
|
|
||||||
// 質問アイテム
|
// 質問アイテム
|
||||||
"qitem.answer": "回答",
|
"qitem.answer": "回答",
|
||||||
@@ -845,6 +853,7 @@ var jaDict = map[string]string{
|
|||||||
"vote.err.editClosed": "終了またはアーカイブされた投票は編集できません",
|
"vote.err.editClosed": "終了またはアーカイブされた投票は編集できません",
|
||||||
"vote.err.publishDraftOnly": "草稿状態の投票のみ公開できます",
|
"vote.err.publishDraftOnly": "草稿状態の投票のみ公開できます",
|
||||||
"vote.err.restrictedPublish": "現在はプラットフォームによる措置期間中のため、投票の公開を停止しています",
|
"vote.err.restrictedPublish": "現在はプラットフォームによる措置期間中のため、投票の公開を停止しています",
|
||||||
|
"feature.stateUnavailable": "機能の状態を確認できませんでした。しばらくしてから再度お試しください",
|
||||||
"vote.err.closePublishedOnly": "進行中の投票のみ終了できます",
|
"vote.err.closePublishedOnly": "進行中の投票のみ終了できます",
|
||||||
"vote.err.publishTitleRequired": "公開前に投票のタイトルを入力してください",
|
"vote.err.publishTitleRequired": "公開前に投票のタイトルを入力してください",
|
||||||
"vote.err.publishFormFieldsRequired": "フォーム型投票には最低 1 つの項目が必要です",
|
"vote.err.publishFormFieldsRequired": "フォーム型投票には最低 1 つの項目が必要です",
|
||||||
@@ -910,6 +919,12 @@ var jaDict = map[string]string{
|
|||||||
"chat.flash.operated": "操作が完了しました",
|
"chat.flash.operated": "操作が完了しました",
|
||||||
"image.errTooLarge": "画像が大きすぎます。最大%d×%dピクセルまで対応しています。圧縮して再アップロードしてください",
|
"image.errTooLarge": "画像が大きすぎます。最大%d×%dピクセルまで対応しています。圧縮して再アップロードしてください",
|
||||||
"internal.errFlash": "サーバー内部エラーが発生しました。後でもう一度お試しください。",
|
"internal.errFlash": "サーバー内部エラーが発生しました。後でもう一度お試しください。",
|
||||||
|
"internal.errBadRequest": "リクエストの内容が正しくありません",
|
||||||
|
"error.notFoundTitle": "ページが見つかりません",
|
||||||
|
"error.notFoundDesc": "このアドレスにコンテンツはありません。リンクが間違っているか、削除された可能性があります。",
|
||||||
|
"error.internalTitle": "問題が発生しました",
|
||||||
|
"error.internalDesc": "このリクエストを完了できませんでした。多くは一時的なものです。しばらくしてから再度お試しください。",
|
||||||
|
"error.traceHint": "ご報告の際はこの番号を添えてください:",
|
||||||
"internal.errServer": "サーバー内部エラー",
|
"internal.errServer": "サーバー内部エラー",
|
||||||
"home.errStats": "統計情報の取得に失敗しました",
|
"home.errStats": "統計情報の取得に失敗しました",
|
||||||
"pixel.errPath": "不正なリクエストパス",
|
"pixel.errPath": "不正なリクエストパス",
|
||||||
|
|||||||
+50
-35
@@ -88,41 +88,49 @@ var zhCNDict = map[string]string{
|
|||||||
"explore.noVotes": "暂时没有可参与的公开投票。",
|
"explore.noVotes": "暂时没有可参与的公开投票。",
|
||||||
|
|
||||||
// 提问页
|
// 提问页
|
||||||
"ask.scan": "扫码向@%s提问",
|
"ask.scan": "扫码向@%s提问",
|
||||||
"ask.anonymous": "谁都可以以匿名的形式提问",
|
"ask.anonymous": "谁都可以以匿名的形式提问",
|
||||||
"ask.registerOnly": "仅允许注册用户向TA提问",
|
"ask.registerOnly": "仅允许注册用户向TA提问",
|
||||||
"ask.nextOpenAt": "下次开放时间:%s",
|
"ask.nextOpenAt": "下次开放时间:%s",
|
||||||
"ask.lastCloseAt": "上次关闭时间:%s",
|
"ask.lastCloseAt": "上次关闭时间:%s",
|
||||||
"weekday.0": "周日",
|
"weekday.0": "周日",
|
||||||
"weekday.1": "周一",
|
"weekday.1": "周一",
|
||||||
"weekday.2": "周二",
|
"weekday.2": "周二",
|
||||||
"weekday.3": "周三",
|
"weekday.3": "周三",
|
||||||
"weekday.4": "周四",
|
"weekday.4": "周四",
|
||||||
"weekday.5": "周五",
|
"weekday.5": "周五",
|
||||||
"weekday.6": "周六",
|
"weekday.6": "周六",
|
||||||
"ask.pinned": "已置顶",
|
"ask.pinned": "已置顶",
|
||||||
"ask.share": "分享提问箱",
|
"ask.share": "分享提问箱",
|
||||||
"ask.placeholder": "在此处撰写你的问题...",
|
"ask.placeholder": "在此处撰写你的问题...",
|
||||||
"ask.chatPlaceholder": "在此处撰写你的私聊消息...",
|
"ask.chatPlaceholder": "在此处撰写你的私聊消息...",
|
||||||
"ask.addImage": "添加图片",
|
"ask.addImage": "添加图片",
|
||||||
"ask.private": "回复后不公开",
|
"ask.private": "回复后不公开",
|
||||||
"ask.notify": "接收通知",
|
"ask.notify": "接收通知",
|
||||||
"ask.emailPlaceholder": "请输入你的邮箱地址",
|
"ask.emailPlaceholder": "请输入你的邮箱地址",
|
||||||
"ask.submit": "发送提问",
|
"ask.submit": "发送提问",
|
||||||
"ask.chat": "发起私聊",
|
"ask.chat": "发起私聊",
|
||||||
"ask.chatDisabled": "该提问箱未开启私聊",
|
"ask.chatDisabled": "该提问箱未开启私聊",
|
||||||
"ask.chatLimit": "消息上限:%d条",
|
"ask.chatLimit": "消息上限:%d条",
|
||||||
"ask.history": "@%s 以前回答过的问题",
|
"ask.history": "@%s 以前回答过的问题",
|
||||||
"ask.loginRequired": "需要登录",
|
"askHistory.banner": "这台设备上有 {n} 条提问记录",
|
||||||
"ask.loginRequiredChat": "发起私聊需要登录账号",
|
"askHistory.answeredSuffix": ",其中 {m} 条已有回答",
|
||||||
"ask.goLogin": "去登录",
|
"askHistory.expand": "查看",
|
||||||
"ask.needPassword": "此提问箱需要密码",
|
"askHistory.collapse": "收起",
|
||||||
"ask.needLogin": "提问箱的主人设置了仅注册用户才能提问,你需要先登录才能向他提问。",
|
"askHistory.answered": "已有回答",
|
||||||
"ask.pleaseEmail": "请填写接收通知的邮箱地址",
|
"askHistory.pending": "等待回答",
|
||||||
"ask.generating": "正在生成分享图…",
|
"askHistory.open": "打开",
|
||||||
"ask.genFailed": "生成分享图失败,请稍后重试",
|
"askHistory.note": "记录只保存在这台设备的浏览器里,换设备或清除浏览器数据后会丢失。",
|
||||||
"ask.networkError": "网络错误",
|
"ask.loginRequired": "需要登录",
|
||||||
"ask.shareFilename": "share.png",
|
"ask.loginRequiredChat": "发起私聊需要登录账号",
|
||||||
|
"ask.goLogin": "去登录",
|
||||||
|
"ask.needPassword": "此提问箱需要密码",
|
||||||
|
"ask.needLogin": "提问箱的主人设置了仅注册用户才能提问,你需要先登录才能向他提问。",
|
||||||
|
"ask.pleaseEmail": "请填写接收通知的邮箱地址",
|
||||||
|
"ask.generating": "正在生成分享图…",
|
||||||
|
"ask.genFailed": "生成分享图失败,请稍后重试",
|
||||||
|
"ask.networkError": "网络错误",
|
||||||
|
"ask.shareFilename": "share.png",
|
||||||
|
|
||||||
// 问题条目
|
// 问题条目
|
||||||
"qitem.answer": "回答",
|
"qitem.answer": "回答",
|
||||||
@@ -846,6 +854,7 @@ var zhCNDict = map[string]string{
|
|||||||
"vote.err.editClosed": "已结束或归档的投票不允许编辑",
|
"vote.err.editClosed": "已结束或归档的投票不允许编辑",
|
||||||
"vote.err.publishDraftOnly": "只有草稿状态的投票可以发布",
|
"vote.err.publishDraftOnly": "只有草稿状态的投票可以发布",
|
||||||
"vote.err.restrictedPublish": "当前处于平台处置期,已暂停发布投票",
|
"vote.err.restrictedPublish": "当前处于平台处置期,已暂停发布投票",
|
||||||
|
"feature.stateUnavailable": "暂时无法确认该功能的状态,请稍后重试",
|
||||||
"vote.err.closePublishedOnly": "只有进行中的投票可以结束",
|
"vote.err.closePublishedOnly": "只有进行中的投票可以结束",
|
||||||
"vote.err.publishTitleRequired": "请先填写投票标题再发布",
|
"vote.err.publishTitleRequired": "请先填写投票标题再发布",
|
||||||
"vote.err.publishFormFieldsRequired": "表单类型投票至少需要 1 个字段",
|
"vote.err.publishFormFieldsRequired": "表单类型投票至少需要 1 个字段",
|
||||||
@@ -923,6 +932,12 @@ var zhCNDict = map[string]string{
|
|||||||
"chat.flash.operated": "操作成功",
|
"chat.flash.operated": "操作成功",
|
||||||
"image.errTooLarge": "图片尺寸过大,最大支持 %d×%d 像素,请压缩后重新上传",
|
"image.errTooLarge": "图片尺寸过大,最大支持 %d×%d 像素,请压缩后重新上传",
|
||||||
"internal.errFlash": "服务内部错误,请稍后重试。",
|
"internal.errFlash": "服务内部错误,请稍后重试。",
|
||||||
|
"internal.errBadRequest": "请求参数不正确",
|
||||||
|
"error.notFoundTitle": "页面不存在",
|
||||||
|
"error.notFoundDesc": "这个地址没有内容,可能是链接输错了,或者内容已经被删除。",
|
||||||
|
"error.internalTitle": "服务出了点问题",
|
||||||
|
"error.internalDesc": "这次请求没能完成,通常是临时的。稍后重试一般就好了。",
|
||||||
|
"error.traceHint": "反馈时请带上这个编号:",
|
||||||
"internal.errServer": "服务器内部错误",
|
"internal.errServer": "服务器内部错误",
|
||||||
"home.errStats": "获取统计信息失败",
|
"home.errStats": "获取统计信息失败",
|
||||||
"pixel.errPath": "非法的请求路径",
|
"pixel.errPath": "非法的请求路径",
|
||||||
|
|||||||
+50
-35
@@ -88,41 +88,49 @@ var zhTWDict = map[string]string{
|
|||||||
"explore.noVotes": "暫時沒有可參與的公開投票。",
|
"explore.noVotes": "暫時沒有可參與的公開投票。",
|
||||||
|
|
||||||
// 提問頁
|
// 提問頁
|
||||||
"ask.scan": "掃碼向@%s提問",
|
"ask.scan": "掃碼向@%s提問",
|
||||||
"ask.anonymous": "誰都可以以匿名的方式提問",
|
"ask.anonymous": "誰都可以以匿名的方式提問",
|
||||||
"ask.registerOnly": "僅允許註冊使用者向TA提問",
|
"ask.registerOnly": "僅允許註冊使用者向TA提問",
|
||||||
"ask.nextOpenAt": "下次開放時間:%s",
|
"ask.nextOpenAt": "下次開放時間:%s",
|
||||||
"ask.lastCloseAt": "上次關閉時間:%s",
|
"ask.lastCloseAt": "上次關閉時間:%s",
|
||||||
"weekday.0": "週日",
|
"weekday.0": "週日",
|
||||||
"weekday.1": "週一",
|
"weekday.1": "週一",
|
||||||
"weekday.2": "週二",
|
"weekday.2": "週二",
|
||||||
"weekday.3": "週三",
|
"weekday.3": "週三",
|
||||||
"weekday.4": "週四",
|
"weekday.4": "週四",
|
||||||
"weekday.5": "週五",
|
"weekday.5": "週五",
|
||||||
"weekday.6": "週六",
|
"weekday.6": "週六",
|
||||||
"ask.pinned": "已置頂",
|
"ask.pinned": "已置頂",
|
||||||
"ask.share": "分享提問箱",
|
"ask.share": "分享提問箱",
|
||||||
"ask.placeholder": "在此處撰寫你的問題...",
|
"ask.placeholder": "在此處撰寫你的問題...",
|
||||||
"ask.chatPlaceholder": "在此處撰寫你的私聊訊息...",
|
"ask.chatPlaceholder": "在此處撰寫你的私聊訊息...",
|
||||||
"ask.addImage": "新增圖片",
|
"ask.addImage": "新增圖片",
|
||||||
"ask.private": "回覆後不公開",
|
"ask.private": "回覆後不公開",
|
||||||
"ask.notify": "接收通知",
|
"ask.notify": "接收通知",
|
||||||
"ask.emailPlaceholder": "請輸入你的信箱地址",
|
"ask.emailPlaceholder": "請輸入你的信箱地址",
|
||||||
"ask.submit": "傳送提問",
|
"ask.submit": "傳送提問",
|
||||||
"ask.chat": "發起私聊",
|
"ask.chat": "發起私聊",
|
||||||
"ask.chatDisabled": "該提問箱未開啟私聊",
|
"ask.chatDisabled": "該提問箱未開啟私聊",
|
||||||
"ask.chatLimit": "訊息上限:%d則",
|
"ask.chatLimit": "訊息上限:%d則",
|
||||||
"ask.history": "@%s 以前回答過的問題",
|
"ask.history": "@%s 以前回答過的問題",
|
||||||
"ask.loginRequired": "需要登入",
|
"askHistory.banner": "這台裝置上有 {n} 筆提問紀錄",
|
||||||
"ask.loginRequiredChat": "發起私聊需要登入帳號",
|
"askHistory.answeredSuffix": ",其中 {m} 筆已有回答",
|
||||||
"ask.goLogin": "去登入",
|
"askHistory.expand": "查看",
|
||||||
"ask.needPassword": "此提問箱需要密碼",
|
"askHistory.collapse": "收合",
|
||||||
"ask.needLogin": "提問箱的主人設定了僅註冊使用者才能提問,你需要先登入才能向他提問。",
|
"askHistory.answered": "已有回答",
|
||||||
"ask.pleaseEmail": "請填寫接收通知的信箱地址",
|
"askHistory.pending": "等待回答",
|
||||||
"ask.generating": "正在產生分享圖…",
|
"askHistory.open": "開啟",
|
||||||
"ask.genFailed": "產生分享圖失敗,請稍後重試",
|
"askHistory.note": "紀錄只保存在這台裝置的瀏覽器裡,換裝置或清除瀏覽器資料後會遺失。",
|
||||||
"ask.networkError": "網路錯誤",
|
"ask.loginRequired": "需要登入",
|
||||||
"ask.shareFilename": "share.png",
|
"ask.loginRequiredChat": "發起私聊需要登入帳號",
|
||||||
|
"ask.goLogin": "去登入",
|
||||||
|
"ask.needPassword": "此提問箱需要密碼",
|
||||||
|
"ask.needLogin": "提問箱的主人設定了僅註冊使用者才能提問,你需要先登入才能向他提問。",
|
||||||
|
"ask.pleaseEmail": "請填寫接收通知的信箱地址",
|
||||||
|
"ask.generating": "正在產生分享圖…",
|
||||||
|
"ask.genFailed": "產生分享圖失敗,請稍後重試",
|
||||||
|
"ask.networkError": "網路錯誤",
|
||||||
|
"ask.shareFilename": "share.png",
|
||||||
|
|
||||||
// 問題條目
|
// 問題條目
|
||||||
"qitem.answer": "回答",
|
"qitem.answer": "回答",
|
||||||
@@ -845,6 +853,7 @@ var zhTWDict = map[string]string{
|
|||||||
"vote.err.editClosed": "已結束或歸檔的投票不允許編輯",
|
"vote.err.editClosed": "已結束或歸檔的投票不允許編輯",
|
||||||
"vote.err.publishDraftOnly": "只有草稿狀態的投票可以發布",
|
"vote.err.publishDraftOnly": "只有草稿狀態的投票可以發布",
|
||||||
"vote.err.restrictedPublish": "當前處於平台處置期,已暫停發布投票",
|
"vote.err.restrictedPublish": "當前處於平台處置期,已暫停發布投票",
|
||||||
|
"feature.stateUnavailable": "暫時無法確認該功能的狀態,請稍後重試",
|
||||||
"vote.err.closePublishedOnly": "只有進行中的投票可以結束",
|
"vote.err.closePublishedOnly": "只有進行中的投票可以結束",
|
||||||
"vote.err.publishTitleRequired": "請先填寫投票標題再發布",
|
"vote.err.publishTitleRequired": "請先填寫投票標題再發布",
|
||||||
"vote.err.publishFormFieldsRequired": "表單類型投票至少需要 1 個欄位",
|
"vote.err.publishFormFieldsRequired": "表單類型投票至少需要 1 個欄位",
|
||||||
@@ -910,6 +919,12 @@ var zhTWDict = map[string]string{
|
|||||||
"chat.flash.operated": "操作成功",
|
"chat.flash.operated": "操作成功",
|
||||||
"image.errTooLarge": "圖片尺寸過大,最大支援 %d×%d 像素,請壓縮後重新上傳",
|
"image.errTooLarge": "圖片尺寸過大,最大支援 %d×%d 像素,請壓縮後重新上傳",
|
||||||
"internal.errFlash": "服務內部錯誤,請稍後重試。",
|
"internal.errFlash": "服務內部錯誤,請稍後重試。",
|
||||||
|
"internal.errBadRequest": "請求參數不正確",
|
||||||
|
"error.notFoundTitle": "頁面不存在",
|
||||||
|
"error.notFoundDesc": "這個位址沒有內容,可能是連結輸入錯誤,或者內容已經被刪除。",
|
||||||
|
"error.internalTitle": "服務出了點問題",
|
||||||
|
"error.internalDesc": "這次請求沒能完成,通常是暫時的。稍後重試一般就好了。",
|
||||||
|
"error.traceHint": "回報時請帶上這個編號:",
|
||||||
"internal.errServer": "伺服器內部錯誤",
|
"internal.errServer": "伺服器內部錯誤",
|
||||||
"home.errStats": "獲取統計資訊失敗",
|
"home.errStats": "獲取統計資訊失敗",
|
||||||
"pixel.errPath": "非法的請求路徑",
|
"pixel.errPath": "非法的請求路徑",
|
||||||
|
|||||||
@@ -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("&", "&", "<", "<", ">", ">", `"`, """, "'", "'")
|
||||||
|
return r.Replace(s)
|
||||||
|
}
|
||||||
@@ -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"b", esc(`a"b`))
|
||||||
|
require.Equal(t, "<script>", esc("<script>"))
|
||||||
|
require.Equal(t, "a&b", esc("a&b"))
|
||||||
|
require.Equal(t, "'x'", esc("'x'"))
|
||||||
|
}
|
||||||
+13
-2
@@ -193,7 +193,7 @@ func New() *flamego.Flame {
|
|||||||
f := flamego.New()
|
f := flamego.New()
|
||||||
// 访问日志放在 Recovery 外层:panic 被兜成 500 后仍能记到状态码。
|
// 访问日志放在 Recovery 外层:panic 被兜成 500 后仍能记到状态码。
|
||||||
f.Use(accessLogMiddleware())
|
f.Use(accessLogMiddleware())
|
||||||
f.Use(flamego.Recovery())
|
f.Use(recoveryMiddleware())
|
||||||
|
|
||||||
if conf.App.Production {
|
if conf.App.Production {
|
||||||
flamego.SetEnv(flamego.EnvTypeProd)
|
flamego.SetEnv(flamego.EnvTypeProd)
|
||||||
@@ -563,6 +563,10 @@ func New() *flamego.Flame {
|
|||||||
f.Get("/explore/random", rateLimitMiddleware(60, 1*time.Minute), user.RandomRecommend)
|
f.Get("/explore/random", rateLimitMiddleware(60, 1*time.Minute), user.RandomRecommend)
|
||||||
// 首页"提问箱信息统计"公开接口(服务端 30 分钟缓存),供前端定时刷新
|
// 首页"提问箱信息统计"公开接口(服务端 30 分钟缓存),供前端定时刷新
|
||||||
f.Get("/site-stats", rateLimitMiddleware(60, 1*time.Minute), route.SiteStatsAPI)
|
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)
|
}, context.APIEndpoint)
|
||||||
},
|
},
|
||||||
cacheHandler,
|
cacheHandler,
|
||||||
@@ -624,7 +628,14 @@ func New() *flamego.Flame {
|
|||||||
if ip := shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request)); ip != "" {
|
if ip := shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request)); ip != "" {
|
||||||
ab.Record(ip, ctx.Request().URL.Path, autoban.KindNotFound)
|
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
|
return f
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// 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 question
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/tamakyi/TamaBox/internal/context"
|
||||||
|
"github.com/tamakyi/TamaBox/internal/db"
|
||||||
|
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||||
|
templatepkg "github.com/tamakyi/TamaBox/internal/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 一次最多查询多少条。浏览器里存的是「这台设备上提过的全部问题」,
|
||||||
|
// 正常用户不会超过几条;设上限是为了让这个公开接口不能被当成批量探测器用。
|
||||||
|
const askStatusMaxItems = 20
|
||||||
|
|
||||||
|
type askStatusItem struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type askStatusRequest struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Items []askStatusItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type askStatusResult struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
OK bool `json:"ok"` // token 校验通过(false 表示记录已失效,前端应删除)
|
||||||
|
Answered bool `json:"answered"` // 已有回答(追问不计)
|
||||||
|
Excerpt string `json:"excerpt"` // 正文摘要,让用户一眼认出是哪一条
|
||||||
|
Created int64 `json:"created"` // 提问时间(unix 秒),前端按本地时区显示
|
||||||
|
}
|
||||||
|
|
||||||
|
// askExcerptRuneLimit 摘要长度(按 rune 计,不按字节 —— 按字节会在多字节字符中间切断)。
|
||||||
|
const askExcerptRuneLimit = 60
|
||||||
|
|
||||||
|
// askExcerpt 取正文前若干字符作为摘要。
|
||||||
|
//
|
||||||
|
// 先转纯文本再截断:正文是 markdown,直接截原文会在列表里露出
|
||||||
|
// `` 这种噪音,一条带图的提问几乎只剩链接。
|
||||||
|
func askExcerpt(content string) string {
|
||||||
|
r := []rune(strings.TrimSpace(templatepkg.PlainText(content)))
|
||||||
|
if len(r) <= askExcerptRuneLimit {
|
||||||
|
return string(r)
|
||||||
|
}
|
||||||
|
return string(r[:askExcerptRuneLimit]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// AskStatusAPI 批量查询「这些提问里哪些已经被回答」。
|
||||||
|
//
|
||||||
|
// 存在的理由:不填邮箱的提问者,唯一的追踪手段是提问成功页上那条链接。
|
||||||
|
// 关掉页面就等于永久失联 —— 箱主认真答了,提问者永远不知道。
|
||||||
|
// 前端把 {domain, id, token} 存在 localStorage,再回到箱主页时问一次这个接口,
|
||||||
|
// 就能提示「你有 2 条提问已被回答」。不引入账号,也不在服务端建立访客追踪。
|
||||||
|
//
|
||||||
|
// 安全性:必须同时给出正确的 token 才返回 ok=true,token 是 12 位随机串,
|
||||||
|
// 且比较走常数时间(与 Pager 中间件同口径),所以这个接口不能用来枚举
|
||||||
|
// 「某箱主有多少提问」「某个 id 是否存在」—— 错误的组合一律 ok=false,
|
||||||
|
// 与「提问不存在」「不属于该箱主」「token 不对」三种情况无法区分。
|
||||||
|
func AskStatusAPI(ctx context.Context) error {
|
||||||
|
if ctx.Request().Method != http.MethodPost {
|
||||||
|
return ctx.JSONError(40500, http.StatusText(http.StatusMethodNotAllowed))
|
||||||
|
}
|
||||||
|
|
||||||
|
var req askStatusRequest
|
||||||
|
if err := json.NewDecoder(ctx.Request().Request.Body).Decode(&req); err != nil {
|
||||||
|
return ctx.JSONError(40000, i18n.T(ctx.Lang(), "internal.errBadRequest"))
|
||||||
|
}
|
||||||
|
if req.Domain == "" || len(req.Items) == 0 {
|
||||||
|
return ctx.JSONError(40000, i18n.T(ctx.Lang(), "internal.errBadRequest"))
|
||||||
|
}
|
||||||
|
if len(req.Items) > askStatusMaxItems {
|
||||||
|
req.Items = req.Items[:askStatusMaxItems]
|
||||||
|
}
|
||||||
|
|
||||||
|
owner, err := db.Users.GetByDomain(ctx.Request().Context(), req.Domain)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, db.ErrUserNotExists) {
|
||||||
|
// 不区分「箱主不存在」与「提问不属于他」:一律返回空结果。
|
||||||
|
return ctx.JSON(map[string]interface{}{"items": []askStatusResult{}})
|
||||||
|
}
|
||||||
|
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get box owner for ask status")
|
||||||
|
return ctx.ServerError()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集合法 ID 并一次查回(IN 查询,避免逐条 N+1)。
|
||||||
|
ids := make([]uint, 0, len(req.Items))
|
||||||
|
for _, it := range req.Items {
|
||||||
|
if it.ID != 0 {
|
||||||
|
ids = append(ids, it.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
questions, err := db.Questions.ListByIDsAndUser(ctx.Request().Context(), ids, owner.ID)
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to list questions for ask status")
|
||||||
|
return ctx.ServerError()
|
||||||
|
}
|
||||||
|
|
||||||
|
byID := make(map[uint]*db.Question, len(questions))
|
||||||
|
for _, q := range questions {
|
||||||
|
byID[q.ID] = q
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]askStatusResult, 0, len(req.Items))
|
||||||
|
for _, it := range req.Items {
|
||||||
|
res := askStatusResult{ID: it.ID}
|
||||||
|
if q, found := byID[it.ID]; found && q.Token != "" && it.Token != "" &&
|
||||||
|
subtle.ConstantTimeCompare([]byte(q.Token), []byte(it.Token)) == 1 {
|
||||||
|
res.OK = true
|
||||||
|
res.Answered = q.Answer != ""
|
||||||
|
// token 已校验通过,返回摘要不额外泄露任何东西 —— 提问者本来就能用
|
||||||
|
// 同一个 token 打开全文。
|
||||||
|
res.Excerpt = askExcerpt(q.Content)
|
||||||
|
res.Created = q.CreatedAt.Unix()
|
||||||
|
}
|
||||||
|
out = append(out, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.JSON(map[string]interface{}{"items": out})
|
||||||
|
}
|
||||||
@@ -92,6 +92,57 @@
|
|||||||
border-color: var(--tb-border);
|
border-color: var(--tb-border);
|
||||||
background: var(--tb-surface-2);
|
background: var(--tb-surface-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 提问历史提示条:仅在本机存有该箱提问记录时出现,不占固定位置、不遮挡内容 */
|
||||||
|
.tb-ask-history {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--tb-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--tb-surface);
|
||||||
|
}
|
||||||
|
.tb-ask-history-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.tb-ask-history-text { font-size: var(--tb-fs-md); color: var(--tb-text); }
|
||||||
|
.tb-ask-history-bar .uk-button-text { flex: 0 0 auto; color: var(--tb-primary); }
|
||||||
|
.tb-ask-history-list {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.tb-ask-history-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-top: 1px solid var(--tb-border);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.tb-ask-history-item:hover .tb-ask-history-excerpt { color: var(--tb-primary); }
|
||||||
|
.tb-ask-history-excerpt {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: var(--tb-fs-md);
|
||||||
|
color: var(--tb-text);
|
||||||
|
}
|
||||||
|
.tb-ask-history-meta { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
|
||||||
|
.tb-ask-history-state { font-size: var(--tb-fs-xs); white-space: nowrap; }
|
||||||
|
.tb-ask-history-state.is-answered { color: #19a974; }
|
||||||
|
.tb-ask-history-state.is-pending { color: var(--tb-text-muted); }
|
||||||
|
.tb-ask-history-note {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: var(--tb-fs-xs);
|
||||||
|
color: var(--tb-text-muted);
|
||||||
|
}
|
||||||
html[data-theme="dark"] .profile-tab { color: var(--tb-text-muted); }
|
html[data-theme="dark"] .profile-tab { color: var(--tb-text-muted); }
|
||||||
html[data-theme="dark"] .profile-tab.is-active { background: var(--tb-surface); color: var(--tb-primary); }
|
html[data-theme="dark"] .profile-tab.is-active { background: var(--tb-surface); color: var(--tb-primary); }
|
||||||
html[data-theme="dark"] .profile-tab.is-disabled { color: var(--tb-text-muted); opacity: 0.55; }
|
html[data-theme="dark"] .profile-tab.is-disabled { color: var(--tb-text-muted); opacity: 0.55; }
|
||||||
@@ -248,6 +299,16 @@ document.getElementById("introcolor").style.color = "{{.PageUser.Introcolor}}";
|
|||||||
{{ end }}
|
{{ end }}
|
||||||
{{template "base/alert" .}}
|
{{template "base/alert" .}}
|
||||||
|
|
||||||
|
<!-- 提问者找回自己的提问:只有本机 localStorage 里存着本箱记录时才由脚本显示 -->
|
||||||
|
<div id="tb-ask-history" class="tb-ask-history" hidden>
|
||||||
|
<div class="tb-ask-history-bar">
|
||||||
|
<span class="tb-ask-history-text" id="tb-ask-history-text"></span>
|
||||||
|
<button type="button" id="tb-ask-history-toggle" class="uk-button uk-button-text uk-button-small uk-padding-remove" aria-expanded="false"></button>
|
||||||
|
</div>
|
||||||
|
<ul class="tb-ask-history-list" id="tb-ask-history-list" hidden></ul>
|
||||||
|
<p class="tb-ask-history-note">{{ t .LangCode "askHistory.note" }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 提问 / 投票 切换:默认显示提问,支持 ?tab=votes 直达(服务端初始态)与 #votes hash 覆盖 -->
|
<!-- 提问 / 投票 切换:默认显示提问,支持 ?tab=votes 直达(服务端初始态)与 #votes hash 覆盖 -->
|
||||||
<div class="profile-tabs" role="tablist">
|
<div class="profile-tabs" role="tablist">
|
||||||
<button type="button" class="profile-tab{{ if eq .ActiveProfileTab "questions" }} is-active{{ end }}" data-profile-tab="questions" role="tab" aria-selected="{{ if eq .ActiveProfileTab "questions" }}true{{ else }}false{{ end }}">{{t .LangCode "explore.questionsTab"}}</button>
|
<button type="button" class="profile-tab{{ if eq .ActiveProfileTab "questions" }} is-active{{ end }}" data-profile-tab="questions" role="tab" aria-selected="{{ if eq .ActiveProfileTab "questions" }}true{{ else }}false{{ end }}">{{t .LangCode "explore.questionsTab"}}</button>
|
||||||
@@ -321,4 +382,160 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 提问者找回自己的提问。
|
||||||
|
//
|
||||||
|
// 记录由「提问成功页」写进本机 localStorage(见 question/success.html),
|
||||||
|
// 这里只负责:有记录就显示一条提示,并问服务端哪些已经被回答。
|
||||||
|
// 服务端只做「你给的这个 token 对不对」的常数时间比对,不建任何访客档案。
|
||||||
|
(function () {
|
||||||
|
var KEY = 'tb_ask_history_v1';
|
||||||
|
var DOMAIN = '{{ .PageUser.Domain }}';
|
||||||
|
var CSRF = '{{ .CSRFToken }}';
|
||||||
|
|
||||||
|
var box = document.getElementById('tb-ask-history');
|
||||||
|
if (!box) return;
|
||||||
|
|
||||||
|
var I18N = {
|
||||||
|
banner: '{{ t .LangCode "askHistory.banner" | js }}',
|
||||||
|
answeredSuffix: '{{ t .LangCode "askHistory.answeredSuffix" | js }}',
|
||||||
|
expand: '{{ t .LangCode "askHistory.expand" | js }}',
|
||||||
|
collapse: '{{ t .LangCode "askHistory.collapse" | js }}',
|
||||||
|
answered: '{{ t .LangCode "askHistory.answered" | js }}',
|
||||||
|
pending: '{{ t .LangCode "askHistory.pending" | js }}',
|
||||||
|
open: '{{ t .LangCode "askHistory.open" | js }}'
|
||||||
|
};
|
||||||
|
|
||||||
|
function readAll() {
|
||||||
|
try {
|
||||||
|
var v = JSON.parse(localStorage.getItem(KEY) || '[]');
|
||||||
|
return Array.isArray(v) ? v : [];
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function writeAll(list) {
|
||||||
|
try { localStorage.setItem(KEY, JSON.stringify(list)); } catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
var mine = readAll().filter(function (x) {
|
||||||
|
return x && x.d === DOMAIN && x.id && x.t;
|
||||||
|
});
|
||||||
|
if (!mine.length) return;
|
||||||
|
|
||||||
|
var textEl = document.getElementById('tb-ask-history-text');
|
||||||
|
var toggleEl = document.getElementById('tb-ask-history-toggle');
|
||||||
|
var listEl = document.getElementById('tb-ask-history-list');
|
||||||
|
|
||||||
|
function renderTitle(answered) {
|
||||||
|
var t = I18N.banner.replace('{n}', mine.length);
|
||||||
|
if (answered > 0) t += I18N.answeredSuffix.replace('{m}', answered);
|
||||||
|
textEl.textContent = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTitle(0);
|
||||||
|
box.hidden = false;
|
||||||
|
toggleEl.textContent = I18N.expand;
|
||||||
|
|
||||||
|
toggleEl.addEventListener('click', function () {
|
||||||
|
var expanded = toggleEl.getAttribute('aria-expanded') === 'true';
|
||||||
|
toggleEl.setAttribute('aria-expanded', expanded ? 'false' : 'true');
|
||||||
|
listEl.hidden = expanded;
|
||||||
|
toggleEl.textContent = expanded ? I18N.expand : I18N.collapse;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 问服务端哪些已被回答。失败就只显示本地记录(不带状态),不影响主流程。
|
||||||
|
fetch('/api/v1/ask-status', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({
|
||||||
|
domain: DOMAIN,
|
||||||
|
items: mine.map(function (x) { return { id: x.id, token: x.t }; })
|
||||||
|
})
|
||||||
|
}).then(function (r) {
|
||||||
|
return r.ok ? r.json() : null;
|
||||||
|
}).then(function (payload) {
|
||||||
|
var items = (payload && payload.data && payload.data.items) || [];
|
||||||
|
var byId = {};
|
||||||
|
items.forEach(function (it) { byId[it.id] = it; });
|
||||||
|
|
||||||
|
// 服务端说 token 无效(提问被删、或记录过期):从本地清掉,避免一直挂着死链
|
||||||
|
var stale = items.filter(function (it) { return !it.ok; }).map(function (it) { return it.id; });
|
||||||
|
if (stale.length) {
|
||||||
|
mine = mine.filter(function (x) { return stale.indexOf(x.id) === -1; });
|
||||||
|
var all = readAll().filter(function (x) {
|
||||||
|
return !(x && x.d === DOMAIN && stale.indexOf(x.id) !== -1);
|
||||||
|
});
|
||||||
|
writeAll(all);
|
||||||
|
if (!mine.length) { box.hidden = true; return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
var answered = 0;
|
||||||
|
mine.forEach(function (x) {
|
||||||
|
var st = byId[x.id];
|
||||||
|
if (st && st.answered) answered++;
|
||||||
|
});
|
||||||
|
renderTitle(answered);
|
||||||
|
|
||||||
|
mine.sort(function (a, b) { return (b.ts || 0) - (a.ts || 0); });
|
||||||
|
listEl.textContent = '';
|
||||||
|
mine.forEach(function (x) {
|
||||||
|
var st = byId[x.id] || {};
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.className = 'tb-ask-history-item';
|
||||||
|
a.href = '/_/' + encodeURIComponent(DOMAIN) + '/' + x.id +
|
||||||
|
'?t=' + encodeURIComponent(x.t);
|
||||||
|
|
||||||
|
var left = document.createElement('span');
|
||||||
|
left.className = 'tb-ask-history-excerpt';
|
||||||
|
// textContent:摘要来自用户自己的提问,但仍然不走 innerHTML
|
||||||
|
left.textContent = (st.excerpt || ('#' + x.id)) +
|
||||||
|
(st.created ? ' · ' + new Date(st.created * 1000).toLocaleDateString() : '');
|
||||||
|
|
||||||
|
var right = document.createElement('span');
|
||||||
|
right.className = 'tb-ask-history-meta';
|
||||||
|
var state = document.createElement('span');
|
||||||
|
state.className = 'tb-ask-history-state ' + (st.answered ? 'is-answered' : 'is-pending');
|
||||||
|
state.textContent = st.answered ? I18N.answered : I18N.pending;
|
||||||
|
var go = document.createElement('span');
|
||||||
|
go.className = 'tb-ask-history-open';
|
||||||
|
go.textContent = I18N.open;
|
||||||
|
right.appendChild(state);
|
||||||
|
right.appendChild(go);
|
||||||
|
|
||||||
|
a.appendChild(left);
|
||||||
|
a.appendChild(right);
|
||||||
|
var li = document.createElement('li');
|
||||||
|
li.appendChild(a);
|
||||||
|
listEl.appendChild(li);
|
||||||
|
});
|
||||||
|
}).catch(function () {
|
||||||
|
// 网络失败:仍然把本地记录列出来(没有状态标签),链接照样能打开
|
||||||
|
mine.sort(function (a, b) { return (b.ts || 0) - (a.ts || 0); });
|
||||||
|
listEl.textContent = '';
|
||||||
|
mine.forEach(function (x) {
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.className = 'tb-ask-history-item';
|
||||||
|
a.href = '/_/' + encodeURIComponent(DOMAIN) + '/' + x.id +
|
||||||
|
'?t=' + encodeURIComponent(x.t);
|
||||||
|
var left = document.createElement('span');
|
||||||
|
left.className = 'tb-ask-history-excerpt';
|
||||||
|
left.textContent = '#' + x.id;
|
||||||
|
var right = document.createElement('span');
|
||||||
|
right.className = 'tb-ask-history-meta';
|
||||||
|
var go = document.createElement('span');
|
||||||
|
go.className = 'tb-ask-history-state is-pending';
|
||||||
|
go.textContent = I18N.open;
|
||||||
|
right.appendChild(go);
|
||||||
|
a.appendChild(left);
|
||||||
|
a.appendChild(right);
|
||||||
|
var li = document.createElement('li');
|
||||||
|
li.appendChild(a);
|
||||||
|
listEl.appendChild(li);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{{template "base/footer" .}}
|
{{template "base/footer" .}}
|
||||||
@@ -36,6 +36,33 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
// 把这提问记到本机,方便以后回来查回答。
|
||||||
|
//
|
||||||
|
// 动机:不填邮箱时,唯一能追踪这条提问的东西就是上面那条链接。
|
||||||
|
// 关掉页面、链接冲掉了,就永久失联 —— 箱主认真答了,提问者永远不知道。
|
||||||
|
// 记录只放在这台设备的 localStorage,不上传、不建访客档案;
|
||||||
|
// 换设备或清浏览器数据会丢,页面上有说明。
|
||||||
|
try {
|
||||||
|
var KEY = 'tb_ask_history_v1';
|
||||||
|
var list = JSON.parse(localStorage.getItem(KEY) || '[]');
|
||||||
|
if (!Array.isArray(list)) list = [];
|
||||||
|
var entry = {
|
||||||
|
d: '{{ .PageUser.Domain }}',
|
||||||
|
id: {{ .Question.ID }},
|
||||||
|
t: '{{ .Question.Token }}',
|
||||||
|
ts: Date.now()
|
||||||
|
};
|
||||||
|
// 同一条提问可能被重复打开成功页:按 domain+id 去重后置顶,最多留 30 条。
|
||||||
|
list = list.filter(function (x) {
|
||||||
|
return !(x && x.d === entry.d && x.id === entry.id);
|
||||||
|
});
|
||||||
|
list.unshift(entry);
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(list.slice(0, 30)));
|
||||||
|
} catch (e) {
|
||||||
|
// localStorage 被禁用(隐私模式 / 策略)时静默跳过:这条路本来就只是锦上添花,
|
||||||
|
// 不能因为它让「复制链接」也跟着失败。
|
||||||
|
}
|
||||||
|
|
||||||
var input = document.getElementById('private-url-input');
|
var input = document.getElementById('private-url-input');
|
||||||
var btn = document.getElementById('copy-btn');
|
var btn = document.getElementById('copy-btn');
|
||||||
if (!input || !btn) return;
|
if (!input || !btn) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user