Files
blog/themes/Ying/assets/js/modules/human.js
T
zqlit e0c5ac5739 feat(comments): 人机验证改点按式 + 分割线可配置 + 投票契约修复
- 人机验证抽成 human.js 模块,改成 Cloudflare Turnstile 风格的「点一下验证」,
  控件移到「评论一下」发送按钮左侧(不再用图形验证码那一套)
- 留言页 / 文章页两段内联脚本(友链弹窗、人机验证)抽成模块随 page-only.js 加载。
  原先内联 <script> 写在 #pjax-container 外,PJAX 只换容器内容不重新执行它,
  从首页 PJAX 进留言页会出现「申请友链卡片点了没反应」「没有人机验证控件」
- 友链申请弹窗 #flApplyBox 隐藏滚动条(内容仍可滚,避开细滚动条 + 毛玻璃的割裂感)
- 内容分割线抽成 partial divider.html,hugo.toml [params.divider] 可切
  icon(原生 uk-divider-icon)/ image(本地图片),当前用 icon
- 投票契约修复(后端 blog-admin):Artalk 客户端把方向编在 target_name 后缀
  (comment_up / comment_down / page_up / page_down)、路径只有两段,
  而原先只注册了三段 :choice 路由 → 点赞/踩一路 404、界面点了没反应。
  补 POST /votes/:target_name/:target_id,新增 parseVoteTarget() 同时兼容两套契约
  (名字里与路径里方向矛盾时判非法,不静默取其一),错误文案改用实体名
- 无身份点赞不再静默失败:artalk.js 加 __atkVoteGuard,
  未填昵称/邮箱时拦截并提示「填写昵称和邮箱后才能点赞」,
  同时把光标送到昵称框(多个同名输入框逐个试,以 focus 真生效为判据)
2026-10-04 16:26:28 +08:00

302 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 人机验证(自研「一键验证」)—— 服务端见 blog-admin/src/lib/human.ts
*
* 交互仿 Cloudflare Turnstile:编辑器底部、`.atk-send-btn`(评论一下)左侧一个
* 方形勾选框。读者大多数时候无感 —— 浏览器在后台静默算一个 PoW,够了就直接打勾;
* 信号不足时方框保持可点,用户点一下就通过。
*
* 为什么不再用「图形验证码」:用户明确不要「输字符」那种交互。前端已移除图形
* 验证码入口;服务端 humanGate 的 captcha 兜底仍然保留(不影响),遇到
* need_captcha 时前端展示可重试的失败态。
*
* 通行证按 IP 记在服务端 KV(30 分钟),前端不传任何 token,
* 所以不需要改 Artalk 客户端。文章页 / 留言页共用本模块。
*
* 历史:本逻辑原先内联在 layouts/page/comment.html 里 —— 只有留言页有,
* 而文章页评论同样走服务端 humanGate → 新访客在文章页会 403。抽成模块
* 进 page-only bundle 后两处一致。
*/
(function () {
var BASE = window.rssApiBase ||
(window.artalkConfig && window.artalkConfig.server) ||
'https://api.200181.xyz';
BASE = String(BASE).replace(/\/+$/, '');
var API = BASE + '/api/v2';
var DIFFICULTY = 4;
var PREFIX = new Array(DIFFICULTY + 1).join('0');
var CHECK_SVG = '<svg viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>';
var started = Date.now();
var events = 0, honey = '', busy = false;
var state = 'idle'; // idle | busy | pass | fail
var failMsg = '';
var proof = null, autoTried = false;
// ── 行为信号:人类随手动一下就会有,脚本环境常常一个都没有
['mousemove', 'pointerdown', 'keydown', 'keyup', 'input', 'focusin', 'scroll', 'wheel',
'touchstart', 'click'].forEach(function (ev) {
document.addEventListener(ev, function () { events++; }, { passive: true });
});
function injectCss() {
if (document.getElementById('atk-human-css')) return;
var st = document.createElement('style');
st.id = 'atk-human-css';
st.textContent =
/* 容器:与 .atk-send-btn 同高 30px,紧贴其左侧 */
'.atk-human-tf{display:inline-flex;align-items:center;gap:7px;height:30px;box-sizing:border-box;' +
'margin-right:8px;padding:0 10px;border:1px solid var(--border-main,#e5e7eb);border-radius:4px;' +
'background:var(--bg-card,#fafafa);font-size:12px;line-height:1;color:var(--text-main,#4b5563);' +
'cursor:pointer;user-select:none;white-space:nowrap;flex:none;' +
'transition:border-color .2s ease,background .2s ease,box-shadow .2s ease}' +
'.atk-human-tf.is-idle:hover{border-color:var(--theme-main,#07c160);' +
'box-shadow:0 0 0 3px var(--theme-main-20,rgba(7,193,96,.15))}' +
'.atk-human-tf.is-busy{cursor:default;opacity:.85}' +
'.atk-human-tf.is-pass{cursor:default;border-color:var(--theme-main,#07c160);' +
'background:var(--theme-main-10,rgba(7,193,96,.08))}' +
'.atk-human-tf.is-fail{cursor:pointer;border-color:#e05a5a;background:rgba(224,90,90,.08);color:#c0392b}' +
/* 左侧方形勾选框 */
'.atk-human-tf-box{flex:none;width:18px;height:18px;border-radius:3px;box-sizing:border-box;' +
'border:1.5px solid #c8ccd4;background:#fff;display:flex;align-items:center;justify-content:center;' +
'transition:background .2s ease,border-color .2s ease}' +
'.atk-human-tf.is-pass .atk-human-tf-box{background:var(--theme-main,#07c160);' +
'border-color:var(--theme-main,#07c160)}' +
'.atk-human-tf.is-fail .atk-human-tf-box{background:#e05a5a;border-color:#e05a5a;color:#fff;' +
'font-weight:700;font-size:12px;line-height:1}' +
'.atk-human-tf-box svg{width:12px;height:12px;display:block}' +
'.atk-human-tf-box svg path{stroke:#fff;stroke-width:2.6;fill:none;stroke-linecap:round;stroke-linejoin:round}' +
'.atk-human-tf-spin{width:12px;height:12px;border-radius:50%;box-sizing:border-box;' +
'border:2px solid var(--theme-main-30,rgba(7,193,96,.3));border-top-color:var(--theme-main,#07c160);' +
'animation:atkHumanSpin .7s linear infinite}' +
'@keyframes atkHumanSpin{to{transform:rotate(360deg)}}' +
'.atk-human-tf-tx{white-space:nowrap}' +
'[data-theme="dark"] .atk-human-tf{border-color:rgba(255,255,255,.14);background:rgba(255,255,255,.04)}' +
'[data-theme="dark"] .atk-human-tf-box{border-color:rgba(255,255,255,.28);background:rgba(255,255,255,.08)}' +
'@media (max-width:640px){.atk-human-tf{padding:0 8px;margin-right:6px}.atk-human-tf-tx{font-size:11px}}' +
'@media (prefers-reduced-motion:reduce){.atk-human-tf-spin{animation-duration:2s}}';
document.head.appendChild(st);
}
function hex(buf) {
var b = new Uint8Array(buf), out = '';
for (var i = 0; i < b.length; i++) out += ('0' + b[i].toString(16)).slice(-2);
return out;
}
function sha256(str) {
return crypto.subtle.digest('SHA-256', new TextEncoder().encode(str)).then(hex);
}
/* 找 nonce 让 sha256(challenge+nonce) 以 N 个 0 开头。
crypto.subtle 是异步的,逐次 await 的 promise 开销远大于哈希本身:
所以按 CHUNK 串一条链、每 CHUNK*YIELD_EVERY 次让出一次主线程,
页面保持流畅,总耗时压到 1 秒上下(全程后台,读者无感)。 */
var CHUNK = 512, YIELD_EVERY = 8;
function solve(challenge) {
var i = 0;
function step() {
var chain = Promise.resolve(null);
var found0 = null;
var limit = i + CHUNK * YIELD_EVERY;
while (i < limit && i < 3000000) {
(function (n) {
chain = chain.then(function () {
if (found0 !== null) return found0; // 已找到 → 后面的直接短路
return sha256(challenge + n).then(function (h) {
if (h.slice(0, DIFFICULTY) === PREFIX) { found0 = n; return n; }
return null;
});
});
})(i++);
}
return chain.then(function (found) {
if (found !== null) return found;
if (i >= 4000000) return null;
return new Promise(function (r) { setTimeout(r, 0); }).then(step);
});
}
return step();
}
function api(path, body) {
return fetch(API + path, {
method: body ? 'POST' : 'GET',
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
}).then(function (r) { return r.json().catch(function () { return {}; }); });
}
// ── 控件
function sendBtn() {
return document.querySelector('.atk-bottom .atk-send-btn') ||
document.querySelector('.atk-send-btn');
}
function editorEl() {
return document.querySelector('.atk-main-editor');
}
/** 把控件插到「评论一下」按钮左侧(同一个 .atk-item 内,天然左邻) */
function ensureWidget() {
var btn = sendBtn();
if (!btn || !btn.parentElement) return null;
var host = btn.parentElement;
var w = null;
for (var i = 0; i < host.children.length; i++) {
if (host.children[i].classList && host.children[i].classList.contains('atk-human-tf')) {
w = host.children[i]; break;
}
}
if (!w) {
w = document.createElement('div');
w.className = 'atk-human-tf is-idle';
w.setAttribute('role', 'button');
w.setAttribute('tabindex', '0');
w.setAttribute('title', '人机验证');
w.addEventListener('click', onWidgetClick);
w.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onWidgetClick(); }
});
host.insertBefore(w, btn);
injectCss();
}
render(w);
if (!autoTried) { autoTried = true; setTimeout(function () { run(false); }, 60); }
return w;
}
function onWidgetClick() {
if (busy || state === 'busy' || state === 'pass') return;
run(true); // 点击本身就是强人类信号(服务端 clicked 直接放行)
}
function render(w) {
w = w || ensureWidget();
if (!w) return;
var key = state + '|' + failMsg;
if (w.dataset.hk === key) return; // 状态没变就别重建 DOM(避免转圈动画被重置)
w.dataset.hk = key;
w.className = 'atk-human-tf is-' + state;
if (state === 'pass') {
w.innerHTML = '<span class="atk-human-tf-box">' + CHECK_SVG + '</span>' +
'<span class="atk-human-tf-tx">已通过验证</span>';
} else if (state === 'busy') {
w.innerHTML = '<span class="atk-human-tf-box"><span class="atk-human-tf-spin"></span></span>' +
'<span class="atk-human-tf-tx">验证中…</span>';
} else if (state === 'fail') {
w.innerHTML = '<span class="atk-human-tf-box">!</span>' +
'<span class="atk-human-tf-tx">' + (failMsg || '验证失败,点击重试') + '</span>';
} else {
w.innerHTML = '<span class="atk-human-tf-box"></span>' +
'<span class="atk-human-tf-tx">点击验证</span>';
}
}
function setState(s, msg) {
state = s;
if (s === 'fail') failMsg = msg || '';
render();
}
function ensureHoneypot() {
if (document.getElementById('atk-human-hp')) return;
var ed = editorEl();
if (!ed) return;
var i = document.createElement('input');
i.id = 'atk-human-hp'; i.type = 'text'; i.tabIndex = -1; i.autocomplete = 'off';
i.setAttribute('aria-hidden', 'true');
i.style.cssText = 'position:absolute;left:-9999px;top:0;width:1px;height:1px;opacity:0';
i.addEventListener('input', function () { honey = i.value; });
ed.appendChild(i);
}
// ── 主流程
// 证明只算一次并缓存:点「点击验证」复用同一个 nonce 重发(补上 clicked 信号),
// 所以点击是**瞬间完成**的,不会让访客再等一次算力。
function ensureProof() {
if (proof && proof.exp > Date.now() + 5000) return Promise.resolve(proof);
return api('/human/challenge').then(function (ch) {
if (!ch || ch.enabled === false || ch.pass) return null;
return solve(ch.challenge).then(function (nonce) {
if (nonce === null) return null;
proof = { challenge: ch.challenge, nonce: nonce, exp: ch.exp, sig: ch.sig };
return proof;
});
});
}
function run(clicked) {
if (busy || state === 'pass') return Promise.resolve();
busy = true;
setState('busy');
return api('/human/status').then(function (st) {
if (!st || st.enabled === false || st.pass) { setState('pass'); return null; }
if (!window.crypto || !crypto.subtle) {
setState('fail', '浏览器不支持验证');
return null;
}
return ensureProof().then(function (pr) {
if (!pr) { setState('fail', '验证服务不可用'); return null; }
return api('/human/verify', {
challenge: pr.challenge, nonce: pr.nonce, exp: pr.exp, sig: pr.sig,
elapsedMs: Date.now() - started, events: events,
webdriver: navigator.webdriver === true, honeypot: honey, clicked: !!clicked,
}).then(function (res) {
if (res && res.pass) { setState('pass'); return; }
// need_captcha(高风险:蜜罐被填 / webdriver)不再弹图形验证码,给可重试的失败态
if (res && res.need_captcha) { setState('fail', '未通过验证,点击重试'); return; }
// need_click:只是还缺人类信号 —— 这不是错误,回到可点的未验证态即可
setState('idle');
});
});
}).catch(function () {
/* 网络异常不打扰读者:回到可点状态,服务端提交时仍有门禁兜底 */
setState('idle');
}).then(function () { busy = false; });
}
// ── 编辑器会被 Artalk 反复重建(pjax / 回复模式 / 提交后),用 observer 补挂
var mo = null, moTimer = null;
function watch() {
if (mo || typeof MutationObserver === 'undefined' || !document.body) return;
mo = new MutationObserver(function () {
if (moTimer) return;
moTimer = setTimeout(function () {
moTimer = null;
var btn = sendBtn();
if (!btn || !btn.parentElement) return;
var w = null;
for (var i = 0; i < btn.parentElement.children.length; i++) {
if (btn.parentElement.children[i].classList &&
btn.parentElement.children[i].classList.contains('atk-human-tf')) { w = btn.parentElement.children[i]; break; }
}
// 控件丢了 / 挂错位置 / 不在按钮左侧 → 重新挂
if (!w || w.nextElementSibling !== btn) ensureWidget();
}, 200);
});
mo.observe(document.body, { childList: true, subtree: true });
}
function init() {
// 评论页才有意义;文章页/留言页都有 .comments-area
if (!document.querySelector('.comments-area')) return;
started = Date.now(); events = 0; busy = false; proof = null; failMsg = '';
autoTried = false;
if (state !== 'pass') state = 'idle';
ensureHoneypot();
ensureWidget(); // 挂上即触发一次静默验证(见 ensureWidget 里的 autoTried)
watch();
}
/* 调试/自测钩子(线上留着也无害) */
window.__humanCheck = {
state: function () {
return { state: state, busy: busy, events: events, honey: honey, hasPass: state === 'pass' };
},
run: run,
widget: function () { return document.querySelector('.atk-human-tf'); },
};
document.addEventListener('pjax:complete', function () { setTimeout(init, 300); });
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();