import React, { useEffect, useRef, useState } from 'react'; import * as captchaApi from '../api/captcha.js'; import { useDialog } from '../lib/utils.js'; // ── 模块级单例:showCaptcha 挂起请求,由 CaptchaModalHost 消费 ── let pending = null; // { action, type, resolve } let listener = null; function notifyHost() { if (listener) listener(); } /** * 验证码流程入口(迁移自 public/js/captcha.js): * - null 无需验证 / 用户取消 * - object { type: 'proof' | 'recaptcha' | 'turnstile', value } * proof → 内置验证码通过,value 为后端签发的 captcha_proof * recaptcha → reCAPTCHA 验证通过,value 为 siteverify token * turnstile → Turnstile 验证通过,value 为 siteverify token * 调用方提交时按 type 放入 captcha_proof / recaptcha_token / turnstile_token 字段。 */ export function showCaptcha(action) { return new Promise((resolve) => { captchaApi.required(action) .then((r) => { if (!r || !r.required || r.type === 'none') { resolve(null); return; } pending = { action, type: r.type || 'builtin', resolve }; notifyHost(); }) .catch(() => resolve(null)); }); } // 执行并清空排队中的渲染回调(供 onload 全局与竞态兜底共用) function flushCallbacks(type) { const key = type === 'recaptcha' ? 'recaptchaCallbacks' : 'turnstileCallbacks'; const q = window[key] || []; window[key] = []; q.forEach((cb) => { try { cb(); } catch { /* 单条失败不影响其余 */ } }); } // 加载第三方验证码脚本(recaptcha / turnstile) // 注意:脚本通过 ?onload=onTurnstileLoad / onRecaptchaLoad 回调通知就绪, // 必须在注入脚本之前定义好全局 onload 函数(事件丢失 → 排队回调永不触发 → 永久卡「正在加载」)。 function ensureThirdPartyScript(type) { if (type === 'recaptcha') { window.recaptchaCallbacks = window.recaptchaCallbacks || []; if (!window.onRecaptchaLoad) { window.onRecaptchaLoad = () => flushCallbacks('recaptcha'); } if (typeof window.grecaptcha === 'undefined' && !document.querySelector('script[src*="recaptcha/api"]')) { const s = document.createElement('script'); s.src = 'https://www.recaptcha.net/recaptcha/api.js?onload=onRecaptchaLoad&render=explicit'; s.async = true; s.defer = true; document.head.appendChild(s); } } else { window.turnstileCallbacks = window.turnstileCallbacks || []; if (!window.onTurnstileLoad) { window.onTurnstileLoad = () => flushCallbacks('turnstile'); } if (typeof window.turnstile === 'undefined' && !document.querySelector('script[src*="turnstile"]')) { const s = document.createElement('script'); s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad&render=explicit'; s.async = true; s.defer = true; document.head.appendChild(s); } } } // 第三方验证码加载超时(毫秒):脚本注入后若库仍未就绪,自动降级到内置验证码 const THIRD_PARTY_LOAD_TIMEOUT = 8000; // 注册渲染回调:库就绪立即执行,未就绪排队等 onload 冲刷;加 300ms 竞态兜底 // (脚本恰好在「检查就绪」与「入队」之间加载完成、onload 已错过时重放队列)。 // 脚本注入后启动 8s 加载超时:届时库仍未就绪 → 调用 onTimeout 触发降级; // 库已就绪但回调错过 → 冲刷队列兜底(wrappedRender 执行并自行清理计时器)。 // 返回清理函数:移除排队回调并清除 300ms 竞态与 8s 超时两个计时器, // 供弹窗关闭/重开时防泄漏、防残留降级状态。 function queueRender(type, render, onTimeout) { const lib = type === 'recaptcha' ? 'grecaptcha' : 'turnstile'; if (typeof window[lib] !== 'undefined') { render(); return null; } const key = type === 'recaptcha' ? 'recaptchaCallbacks' : 'turnstileCallbacks'; window[key] = window[key] || []; let done = false; // 已渲染 / 已超时降级 / 已清理:任一后不再执行 render let raceTimer = null; let timeoutTimer = null; const wrappedRender = () => { if (done) return; done = true; clearTimeout(raceTimer); clearTimeout(timeoutTimer); render(); }; window[key].push(wrappedRender); ensureThirdPartyScript(type); // 300ms 竞态兜底:脚本恰好在「检查就绪」与「入队」之间完成加载、onload 已错过时重放队列 raceTimer = setTimeout(() => { if (typeof window[lib] !== 'undefined') flushCallbacks(type); }, 300); // 8s 加载超时:库仍未就绪 → 降级;就绪但回调错过 → 冲刷队列兜底 timeoutTimer = setTimeout(() => { if (done) return; if (typeof window[lib] !== 'undefined') { flushCallbacks(type); } else { done = true; clearTimeout(raceTimer); if (onTimeout) onTimeout(); } }, THIRD_PARTY_LOAD_TIMEOUT); return () => { if (done) return; done = true; clearTimeout(raceTimer); clearTimeout(timeoutTimer); const q = window[key]; if (q) { const i = q.indexOf(wrappedRender); if (i >= 0) q.splice(i, 1); } }; } // SHA-256(Proof of Work 使用) async function sha256(str) { const buf = new TextEncoder().encode(str); const hash = await crypto.subtle.digest('SHA-256', buf); return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, '0')).join(''); } // 内置 SVG 验证码弹窗(notice 为可选提示,用于第三方降级时告知用户) function BuiltinCaptcha({ onFinish, notice }) { const [svg, setSvg] = useState(''); const [error, setError] = useState(''); const [answer, setAnswer] = useState(''); const [powDone, setPowDone] = useState(false); const [verifying, setVerifying] = useState(false); const tokenRef = useRef(null); const imgRef = useRef(null); const loadImage = async () => { setError(''); setAnswer(''); const data = await captchaApi.getImage(); if (data && data.token) { tokenRef.current = data.token; setSvg(data.svg); } }; useEffect(() => { loadImage(); // 智能验证(Proof of Work)后台运行,20s 内找到满足难度的 nonce (async () => { try { const chal = await captchaApi.powChallenge(); if (!chal || !chal.token) return; const target = '0'.repeat(chal.difficulty); const start = Date.now(); let nonce = 0; while (Date.now() - start < 20000) { const hash = await sha256(chal.prefix + nonce); if (hash.startsWith(target)) { await captchaApi.powVerify(chal.token, String(nonce)); setPowDone(true); return; } nonce++; } } catch { /* 失败不影响主流程 */ } })(); }, []); // 让后端返回的 SVG 自适应弹窗宽度(照搬 captcha.js 的样式处理) useEffect(() => { if (svg && imgRef.current) { const s = imgRef.current.querySelector('svg'); if (s) s.setAttribute('style', 'width:100%;max-width:240px;height:auto;border-radius:8px;display:block'); } }, [svg]); const submit = async () => { if (!powDone) { setError('智能验证尚未完成,请稍候...'); return; } if (!answer.trim() || !tokenRef.current) { setError('请输入验证码'); return; } setVerifying(true); const v = await captchaApi.verify(tokenRef.current, answer.trim()); setVerifying(false); if (v.success) { onFinish({ type: 'proof', value: v.proof || '' }); } else { setError(v.error || '验证码错误'); tokenRef.current = null; setAnswer(''); loadImage(); } }; return (
{notice}
)}{status}