归档 artalk-cf 评论后端 + rss-robot 到 blog-admin(含技术选型/模块分布 README)

This commit is contained in:
zqlit committed 2026-10-04 08:45:40 +08:00
1 parent e1323bd260
commit 299ab57e4d
98 files changed
+15657

No files matched your search

+170
View File
@@ -0,0 +1,170 @@
// 人机验证(自研「一键验证」)
//
// 设计目标:正常读者**无感**(浏览器静默算一个 20~80ms 的 PoW,什么也不用手动做),
// 命中可疑信号时才让访客「点一下」,再可疑才回退到图形验证码。
//
// 为什么不用 Turnstile/reCAPTCHA:
// 1) 国内加载不稳定(Google/Cloudflare 的脚本经常拉不下来),一旦失败评论就发不出去;
// 2) 会把访客 IP/浏览器指纹送给第三方。
// 自研版靠 PoW + 行为信号,挡得住脚本刷评论和低成本机器人,对个人博客足够。
//
// 关键点:**挑战是无状态的**(HMAC 签名,不写库);通行证放 KV(不占 D1 额度)。
import type { Env } from '../types';
/** PoW 难度:sha256(challenge + nonce) 的十六进制前缀要有这么多个 0 */
export const POW_DIFFICULTY = 4;
const CHALLENGE_TTL_MS = 10 * 60 * 1000;
/** 通行证有效期(秒):过了一次就不用再验 */
const PASS_TTL_SEC = 1800;
/** 开关:存 KV,读一次比读 D1 settings 便宜 */
const ENABLED_KEY = 'human_check';
export interface HumanChallenge {
challenge: string;
difficulty: number;
exp: number;
sig: string;
}
function bytesToHex(buf: ArrayBuffer): string {
return [...new Uint8Array(buf)]
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
export async function sha256Hex(input: string): Promise<string> {
const data = new TextEncoder().encode(input);
return bytesToHex(await crypto.subtle.digest('SHA-256', data));
}
async function hmacHex(secret: string, msg: string): Promise<string> {
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(msg));
return bytesToHex(sig);
}
// ------------------------------------------------------------------ 开关
export async function isHumanCheckEnabled(env: Env): Promise<boolean> {
try {
return (await env.RSS_KV.get(ENABLED_KEY)) === '1';
} catch {
return false;
}
}
export async function setHumanCheck(env: Env, on: boolean): Promise<void> {
try {
await env.RSS_KV.put(ENABLED_KEY, on ? '1' : '0');
} catch {
/* 开关写失败不影响主流程 */
}
}
// ---------------------------------------------------------------- 挑战签发
export async function issueChallenge(env: Env): Promise<HumanChallenge> {
const buf = crypto.getRandomValues(new Uint8Array(12));
const challenge = bytesToHex(buf.buffer);
const exp = Date.now() + CHALLENGE_TTL_MS;
const sig = await hmacHex(env.TOKEN_SECRET, `${challenge}|${POW_DIFFICULTY}|${exp}`);
return { challenge, difficulty: POW_DIFFICULTY, exp, sig };
}
/** 校验 PoW 证明:签名有效 + 未过期 + 哈希前缀达标 */
export async function verifyProof(
env: Env,
p: { challenge?: string; nonce?: number | string; exp?: number; sig?: string },
): Promise<boolean> {
const challenge = String(p.challenge || '');
const exp = Number(p.exp || 0);
const sig = String(p.sig || '');
if (!challenge || !exp || !sig || p.nonce === undefined || p.nonce === null) return false;
if (Date.now() > exp) return false;
const expect = await hmacHex(env.TOKEN_SECRET, `${challenge}|${POW_DIFFICULTY}|${exp}`);
if (expect !== sig) return false;
const hash = await sha256Hex(`${challenge}${p.nonce}`);
return hash.startsWith('0'.repeat(POW_DIFFICULTY));
}
// ---------------------------------------------------------------- 通行证
export function humanPassKey(ip: string): string {
return `human:pass:${ip}`;
}
export async function hasHumanPass(env: Env, ip: string): Promise<boolean> {
try {
return (await env.RSS_KV.get(humanPassKey(ip))) === '1';
} catch {
return false;
}
}
export async function grantHumanPass(env: Env, ip: string, ttl = PASS_TTL_SEC): Promise<void> {
try {
await env.RSS_KV.put(humanPassKey(ip), '1', { expirationTtl: ttl });
} catch {
/* 通行证写失败 → 下次再验,不影响本次放行 */
}
}
// ------------------------------------------------------------ 行为信号评分
export interface HumanSignals {
/** 蜜罐字段:人类看不见也不会填,填了就一定是脚本 */
honeypot?: string;
/** 从拿到挑战到提交验证的耗时(毫秒) */
elapsedMs?: number;
/** 页面上的鼠标/键盘/滚动/触摸事件次数 */
events?: number;
/** navigator.webdriver(自动化浏览器通常为 true) */
webdriver?: boolean;
/** 是否是「点一下」触发的验证(点击本身就是人类信号) */
clicked?: boolean;
}
export type RiskLevel = 'low' | 'medium' | 'high';
export interface RiskResult {
level: RiskLevel;
reasons: string[];
}
/**
* 信号评分。
* low → 直接发通行证(读者无感)
* medium → 要求「点一下」(点击会补齐 elapsedMs / events 信号,重试即通过)
* high → 回退图形验证码
*/
export function assessSignals(s: HumanSignals): RiskResult {
const reasons: string[] = [];
if (s.honeypot) {
return { level: 'high', reasons: ['honeypot filled'] };
}
if (s.webdriver === true) {
reasons.push('webdriver');
}
const elapsed = Number(s.elapsedMs || 0);
const events = Number(s.events || 0);
if (elapsed > 0 && elapsed < 1200) reasons.push('too fast');
if (elapsed > 2 * 3600 * 1000) reasons.push('too slow');
if (events <= 0 && !s.clicked) reasons.push('no interaction');
if (reasons.includes('webdriver')) return { level: 'high', reasons };
if (s.clicked) {
// 点击过就直接放行(点击 + PoW 已经足够;elapsed 太短仍视为可疑)
return reasons.includes('too fast') && elapsed < 400
? { level: 'medium', reasons }
: { level: 'low', reasons };
}
if (reasons.length) return { level: 'medium', reasons };
return { level: 'low', reasons: [] };
}