Files
rainblogweb/frontend/src/components/CaptchaModal.jsx
T

285 lines
10 KiB
React
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.
import React, { useEffect, useRef, useState } from 'react';
import * as captchaApi from '../api/captcha.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));
});
}
// 加载第三方验证码脚本(recaptcha / turnstile
function ensureThirdPartyScript(type) {
if (type === 'recaptcha') {
window.recaptchaCallbacks = window.recaptchaCallbacks || [];
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 (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);
}
}
}
// SHA-256Proof 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 验证码弹窗
function BuiltinCaptcha({ onFinish }) {
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 (
<div className="dialog" style={{ maxWidth: 380, textAlign: 'center' }}>
<h3 style={{ marginBottom: 12 }}>验证码</h3>
<div ref={imgRef} style={{ margin: '0 auto 12px', maxWidth: 280, minHeight: 72 }}>
{svg
? <div dangerouslySetInnerHTML={{ __html: svg }} />
: <div className="spinner" style={{ width: 24, height: 24, margin: '16px auto' }} />}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'center' }}>
<input
type="text"
value={answer}
onChange={(e) => setAnswer(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
placeholder="输入验证码"
maxLength={6}
autoComplete="off"
style={{ flex: 1, textAlign: 'center', fontSize: 20, letterSpacing: 6, textTransform: 'uppercase' }}
/>
<button type="button" className="btn btn-icon" title="刷新" onClick={loadImage} style={{ flexShrink: 0 }}>
<span className="material-icons">refresh</span>
</button>
</div>
{error && <div style={{ color: 'var(--md-ref-error)', fontSize: 13, marginTop: 8 }}>{error}</div>}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: 8, fontSize: 13, color: 'var(--md-ref-on-surface-variant)' }}>
{powDone ? (
<>
<span className="material-icons" style={{ fontSize: 16, color: '#4caf50' }}>check_circle</span>
<span style={{ color: '#4caf50' }}>智能验证通过</span>
</>
) : (
<>
<span className="material-icons" style={{ fontSize: 16 }}>smart_toy</span>
<span>智能验证中...</span>
</>
)}
</div>
<div className="actions" style={{ justifyContent: 'center', marginTop: 16 }}>
<button type="button" className="btn btn-text" onClick={() => onFinish(null)}>取消</button>
<button type="button" className="btn btn-filled" onClick={submit} disabled={verifying}>确认</button>
</div>
</div>
);
}
// 第三方验证码(reCAPTCHA / Turnstile),验证通过后把 siteverify token 传回调用方
function ThirdPartyCaptcha({ type, onFinish }) {
const siteKey = type === 'recaptcha'
? (window._recaptchaSiteKey || '')
: (window._turnstileSiteKey || '');
const [status, setStatus] = useState('正在加载...');
const widgetRef = useRef(null);
const doneRef = useRef(false);
useEffect(() => {
if (!siteKey) { onFinish(null); return; }
const container = widgetRef.current;
const render = () => {
try {
if (type === 'recaptcha') {
const wid = window.grecaptcha.render(container, {
sitekey: siteKey,
callback: () => {
setStatus('验证通过');
let token = '';
try { token = window.grecaptcha.getResponse(wid); } catch { /* 忽略 */ }
setTimeout(() => onFinish({ type: 'recaptcha', value: token || '' }), 300);
},
'expired-callback': () => setStatus('验证已过期'),
});
} else {
const wid = window.turnstile.render(container, {
sitekey: siteKey,
callback: () => {
setStatus('验证通过');
let token = '';
try { token = window.turnstile.getResponse(wid); } catch { /* 忽略 */ }
setTimeout(() => onFinish({ type: 'turnstile', value: token || '' }), 300);
},
'expired-callback': () => setStatus('验证已过期'),
});
}
setStatus('请完成验证');
} catch {
setStatus('加载失败');
setTimeout(() => onFinish(null), 2000);
}
};
if (type === 'recaptcha') {
if (typeof window.grecaptcha !== 'undefined') render();
else {
window.recaptchaCallbacks = window.recaptchaCallbacks || [];
window.recaptchaCallbacks.push(render);
ensureThirdPartyScript(type);
}
} else {
if (typeof window.turnstile !== 'undefined') render();
else {
window.turnstileCallbacks = window.turnstileCallbacks || [];
window.turnstileCallbacks.push(render);
ensureThirdPartyScript(type);
}
}
// 卸载时不触发重复回调
return () => { doneRef.current = true; };
}, []);
return (
<div className="dialog" style={{ maxWidth: 400, textAlign: 'center' }}>
<h3 style={{ marginBottom: 16 }}>{type === 'recaptcha' ? 'Google reCAPTCHA' : 'Cloudflare Turnstile'}</h3>
<div ref={widgetRef} style={{ display: 'flex', justifyContent: 'center', margin: '16px 0' }} />
<p className="text-muted" style={{ fontSize: 13 }}>{status}</p>
<div className="actions" style={{ justifyContent: 'center' }}>
<button type="button" className="btn btn-text" onClick={() => onFinish(null)}>取消</button>
</div>
</div>
);
}
/**
* 全局挂载的验证码弹窗宿主:放入 Layout 内,配合 showCaptcha(action) 使用。
*/
export default function CaptchaModalHost() {
const [request, setRequest] = useState(null);
useEffect(() => {
listener = () => setRequest(pending);
return () => { listener = null; };
}, []);
const finish = (proof) => {
if (request) {
const resolve = request.resolve;
pending = null;
setRequest(null);
resolve(proof);
}
};
if (!request) return null;
return (
<div
className="dialog-overlay active"
style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) finish(null); }}
>
{request.type === 'recaptcha' ? (
<ThirdPartyCaptcha type="recaptcha" onFinish={finish} />
) : request.type === 'turnstile' ? (
<ThirdPartyCaptcha type="turnstile" onFinish={finish} />
) : (
<BuiltinCaptcha onFinish={finish} />
)}
</div>
);
}