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:
@@ -92,6 +92,57 @@
|
||||
border-color: var(--tb-border);
|
||||
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.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; }
|
||||
@@ -248,6 +299,16 @@ document.getElementById("introcolor").style.color = "{{.PageUser.Introcolor}}";
|
||||
{{ end }}
|
||||
{{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 覆盖 -->
|
||||
<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>
|
||||
@@ -321,4 +382,160 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
</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" .}}
|
||||
Reference in New Issue
Block a user