一、错误页
原来未知路径一律 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 内容/已写出时不重复写头/语言选择/状态码/转义)
83 lines
3.8 KiB
HTML
83 lines
3.8 KiB
HTML
{{template "base/header" .}}
|
|
<div class="uk-card uk-card-default uk-card-body">
|
|
<div class="uk-text-center uk-margin-bottom">
|
|
<span uk-icon="icon: check; ratio: 2" class="uk-text-success"></span>
|
|
<h3 class="uk-margin-remove">{{t .LangCode "success.title"}}</h3>
|
|
</div>
|
|
|
|
<p class="uk-text-small uk-text-muted">{{t .LangCode "success.yourQuestion"}}</p>
|
|
<div class="md-body uk-text-break">{{MarkdownFormat .Question.Content}}</div>
|
|
|
|
{{ if .HasReceiveEmail }}
|
|
<div class="uk-alert-success" uk-alert>
|
|
<p>{{t .LangCode "success.emailSent"}}</p>
|
|
</div>
|
|
{{ else }}
|
|
<div class="uk-alert-danger" uk-alert>
|
|
<p>{{t .LangCode "success.saveLink" | safe}}</p>
|
|
</div>
|
|
{{ end }}
|
|
|
|
<div class="uk-margin">
|
|
<label class="uk-form-label uk-text-small">{{t .LangCode "success.privateLink"}}</label>
|
|
<div class="uk-flex uk-flex-middle">
|
|
<input id="private-url-input" class="uk-input uk-form-small uk-width-expand" type="text" value="{{.PrivateAbsURL}}" readonly>
|
|
<button id="copy-btn" class="uk-button uk-button-primary uk-button-small uk-margin-small-left uk-flex-none" type="button">{{t .LangCode "success.copy"}}</button>
|
|
</div>
|
|
<p class="uk-text-small uk-text-muted">{{t .LangCode "success.token" .Question.Token}}</p>
|
|
</div>
|
|
|
|
<!-- 两个按钮等宽:用 flex 均分而不是靠 margin,文字长短不一(各语言差异更大)时也能对齐 -->
|
|
<div class="uk-flex uk-flex-center uk-flex-wrap uk-margin-top" style="gap: 8px;">
|
|
<a class="uk-button uk-button-default uk-button-small" style="flex: 1 1 160px; max-width: 220px;" href="{{.PrivateURL}}">{{t .LangCode "success.viewQuestion"}}</a>
|
|
<a class="uk-button uk-button-default uk-button-small" style="flex: 1 1 160px; max-width: 220px;" href="/_/{{.PageUser.Domain}}">{{t .LangCode "success.backToBox" .PageUser.Name}}</a>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
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 btn = document.getElementById('copy-btn');
|
|
if (!input || !btn) return;
|
|
btn.addEventListener('click', function () {
|
|
input.select();
|
|
input.setSelectionRange(0, 99999);
|
|
var ok = false;
|
|
try { ok = document.execCommand('copy'); } catch (e) {}
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(input.value).then(function () { ok = true; }, function () {});
|
|
}
|
|
btn.textContent = ok ? '{{t .LangCode "success.copied"}}' : '{{t .LangCode "success.copyFailed"}}';
|
|
setTimeout(function () { btn.textContent = '{{t .LangCode "success.copy"}}'; }, 2000);
|
|
});
|
|
});
|
|
</script>
|
|
{{template "base/footer" .}}
|