优化了一些问题fix(captcha)

This commit is contained in:
2026-08-29 17:03:05 +08:00
parent 41da33bb13
commit 88eb29c645
10 changed files with 1074 additions and 407 deletions
@@ -0,0 +1,95 @@
# 第三方人机验证优化改造计划
约束:不写双路/兼容迁移代码;顺手清理死代码;不做真实视觉效果验收(用语法检查、契约测试、构建产物校验代替)。
已确认决策:范围全做(P0+P1+P2);下载场景 pass 放行 10 分钟;凭据不齐时仅加后台警告、前台保持放行。
## 改动文件总览
| 文件 | 动作 |
|---|---|
| `inc/modules/captcha.php` | 重写校验层 + 瘦身渲染 + 后台警告 + 静态缓存 |
| `assets/js/captcha-front.js` | 新建(从内联脚本迁移,~340 行) |
| `assets/css/shared/captcha.css` | 保留不动(改为进 bundle) |
| `tools/build-public-assets.mjs` | publicScripts 加 captcha-front.js;public.css 加 captcha.css |
| `src/Admin/Schema/SchemaPermissions.php` | 删除 'mode' 死字段(L32-34) |
| `assets/admin/src/schema/sections/permissions/index.js` | 删除 'mode' 死字段(L18 defaults、L50-56) |
| `src/Admin/SchemaProvider.php` | L403 白名单删 'mode';保存后 flush 配置静态缓存 |
| `tests/widget-system/captcha-cloud-contract.test.cjs` | 新建契约测试 |
不动的文件及原因:`aksrt-header-userbox.php`(toast-contract 测试锁定 signin 接线)、`data-aksrt-download-captcha` 挂载点(`templates/parts/resource-download-box.php:467`、`inc/helpers/aksrt-pay.php:1586`)、所有业务 JS(auth/signin/comments/cms-download/user-center/front-submit 只消费 `AKSRTCaptchaState.scenes[scene]` 与 mount 内 token input,接口不变即零改动)。
## 1. inc/modules/captcha.php — 校验层
**P0-1 腾讯云改走 WP HTTP API**:`aksrt_verify_tencent()` 删除 curl 块(L374-390,含 `CURLOPT_SSL_VERIFYPEER => false`),改用 `wp_remote_post('https://captcha.tencentcloudapi.com/', ['timeout'=>5, 'headers'=>aksrt_tencent_tc3_sign(...)生成的一维数组, 'body'=>$payload])`,与另外三家一致。签名函数不动。
**P1-5 错误信息收敛**:四家服务商的传输层错误(wp_remote_post 返回 WP_Error、响应不可解析)统一 `error_log('[AKSRT Captcha] {provider}: ' . 详情)` 后返回通用文案 `__('人机验证服务暂不可用,请稍后重试', 'aksrt')`;保留对用户有行动意义的映射文案(腾讯 CaptchaCode 表、阿里 F0xx 表)。腾讯 API Error 分支(签名错误等)也走 log+通用文案。
**P1-4**:阿里云 `SignatureNonce` 由 `uniqid('', true)` 改 `wp_generate_uuid4()`。
**P1-8**:四家 `'timeout'` 统一 5 秒(现 10 秒)。
**P1-6 Turnstile 绑定场景**:`aksrt_verify_captcha($post_data, $scene = '')` 增加第二参数(唯一调用方是 `aksrt_require_captcha`,无散落调用点);Cloudflare 成功响应校验:`action !== 'aksrt-verify'` 或 `cdata !== $scene`(存在才比对,lenient)→ 失败。客户端配套见 §2。
**P0-2 下载 pass 放行(10 分钟)**:`aksrt_require_captcha()` 内,scene 命中放行名单时先查 transient、验证通过后写 transient:
- `aksrt_captcha_pass_ttl($scene)`:仅 `download` 返回 `10 * MINUTE_IN_SECONDS`,其余返回 0(单一代码路径,无散落分支)
- key = `'aksrt_captcha_pass_' . md5($scene . '|' . get_current_user_id() . '|' . aksrt_get_client_ip() . '|' . md5(UA))`,仿 `aksrt_auth_rate_` 既有模式
## 2. assets/js/captcha-front.js — 新建(P2-9 + P1-7 客户端)
把 `aksrt_render_captcha()` 内联 `<script>`(L632-969)整体迁移为静态文件,逻辑不改名不重写:`loadScriptOnce`、四家 `load*`、`AKSRTCaptchaState.scenes[scene].verify/reset` 注册、`setField/resetVerificationState/syncDownloadSiblings` 等原样搬移。仅两处行为修正:
- 极验 `onClose` 空回调改为 `cancelPending('已取消验证')`(P1-7,与腾讯/CF 取消体验对齐)
- Turnstile render 配置加 `cdata: C.scene`(P1-6 客户端配套)
激活模型:扫描 `document.querySelectorAll('[data-aksrt-captcha-config]')`,`JSON.parse(el.getAttribute(...))` 逐个 init;DOMContentLoaded 后再补扫一次并暴露 `window.AKSRTCaptchaState.rescan()` 供未来 ajax 注入使用。保持 vanilla JS、无新依赖。
对应 PHP 渲染端瘦身:`aksrt_render_captcha()` 只输出
1. 原 wrapper 结构不变(`.aksrt-captcha-wrap` + `#uid` box + `#vid` captcha_verified 隐藏域),
2. wrapper 上挂 `data-aksrt-captcha-config`(`esc_attr(wp_json_encode($js_config))`),
3. 删除内联 `<script>` 与逐次 `<link rel="stylesheet">`。
TOKEN_FIELDS 常量、i18n 字符串原样进 JS 文件(与仓库其它 bundle 内硬编码中文一致)。
## 3. 构建与后台(P2-10 + bundle 接线)
- `tools/build-public-assets.mjs`:`publicScripts` 追加 `'assets/js/captcha-front.js'`;`cssBundles['public.css']` 追加 `'assets/css/shared/captcha.css'`。走既有 bundle 版本机制,无需新增 enqueue 代码。
- `SchemaPermissions.php`:删除 'mode' 字段定义(L32-34,单选项 always 的死字段)。
- `assets/admin/src/schema/sections/permissions/index.js`:删除 `mode` 的 defaults(L18)与字段定义(L50-56)。
- `SchemaProvider.php` L403:归一化白名单去掉 `'mode'`;`write_section_settings()` 更新 `aksrt_captcha_config` 后调用新助手 `aksrt_captcha_config_reset()` 清静态缓存(function_exists 守卫,与现有 captcha 专用块并排)。
- DB 里残留的 `mode` 键不做迁移清洗(读路径白名单已忽略它,符合"不做迁移"约束)。
## 4. inc/modules/captcha.php — 后台警告与缓存
- **P0-3**:新增顶层 `add_action('admin_notices', ...)`:`manage_options` 权限 + `enabled` 为真 + `active_provider` 非空 + `aksrt_captcha_provider_is_configured()` 为假时,输出 `notice-error` 横幅,文案指向「功能&权限 → 人机验证」补全配置(复用现有 misconfig 提示文案)。前台行为不变(保持放行)。
- **P2-11**:`aksrt_get_captcha_config()` 加 `static $cache`;新增 `aksrt_captcha_config_reset()`;写入路径(SchemaProvider)调用 reset。`aksrt_get_captcha_status_summary()` 的 12 场景循环随之受益。
## 5. 新契约测试 tests/widget-system/captcha-cloud-contract.test.cjs
仿 toast-contract 的正则断言风格:
- `captcha.php`:不匹配 `curl_init|CURLOPT_SSL_VERIFYPEER|machine-verification|slidercaptcha|imagecaptcha`;匹配 `wp_remote_post`(≥4 处)、`wp_generate_uuid4`、`'timeout'\s*=>\s*5`、`aksrt_captcha_pass_`;渲染函数不匹配 `/<script|<link/`(内联抽取完成)
- `captcha-front.js` 存在且含 `AKSRTCaptchaState`、`data-aksrt-captcha-config`
- `build-public-assets.mjs` 的 publicScripts 含 captcha-front.js、public.css 含 shared/captcha.css
- `SchemaPermissions.php` 与 admin `index.js` 的 captcha 段不匹配 `mode`
- `SchemaProvider.php` 不匹配 `'mode'`
## 6. 验证(不做视觉验收)
1. `php -l` 全部改动的 PHP 文件
2. `node --check` captcha-front.js
3. `node tools/build-public-assets.mjs` 重建 bundle
4. `grep` 全仓清扫:无 `curl_init`、无 `'mode'`(captcha 相关文件)、无 legacy 标记
5. `node --test tests/widget-system/*.test.cjs` 全量跑,预期仅存量 page-display-contract 一项失败(与本次无关,已验证在 HEAD 上同样失败)
## 明确不做
- 不做视觉验收/浏览器实测(按要求)
- 不写新旧配置迁移、不清洗 DB 残留 `mode` 键
- 不动腾讯 trerror 容灾票据策略(维持拒绝)
- 不做阿里云签名 V3 升级、Turnstile appearance/retry 配置项(列为后续可选项)
- 不动 `ExtensionController` 的 extensions 状态端点(SPA 未消费属另一话题)
## 建议提交拆分(如需提交)
1. `fix(captcha): use WP HTTP API with TLS verification for tencent`
2. `feat(captcha): short-lived download pass + turnstile binding + error hardening`
3. `refactor(captcha): extract inline runtime to captcha-front.js bundle`
4. `chore(captcha): drop dead mode field, config cache, contract tests`
@@ -15,7 +15,6 @@ export const permissionsSchemas = {
defaults: {
enabled: false,
active_provider: '',
mode: 'always',
enabled_scenarios: [
'comment', 'login', 'register', 'bind', 'bind_phone',
'lostpassword', 'change_password', 'change_email', 'download', 'signin',
@@ -46,16 +45,6 @@ export const permissionsSchemas = {
],
dependsOn: { key: 'enabled', value: true },
},
{
key: 'mode',
label: '触发模式',
type: 'radio',
buttonStyle: false,
options: [
{ label: '始终显示', value: 'always' },
],
dependsOn: { key: 'enabled', value: true },
},
{
key: 'enabled_scenarios',
label: '启用场景',
+24
View File
@@ -7748,3 +7748,27 @@ body.aksrt-modal-runtime-open {
transform: rotate(360deg);
}
}
/* source: assets/css/shared/captcha.css */
/**
* AKSRT Captcha Styles
* 人机验证组件样式(从 functions.php 内联 CSS 抽取)
*
* @package AKSRT
*/
.aksrt-captcha-box{display:none}.aksrt-captcha-wrap{text-align:center}
/* Cloudflare Turnstile Modal */
.aksrt-captcha-modal-overlay{position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center}
.aksrt-captcha-modal-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.55);animation:aksrtFadeIn .2s ease}
.aksrt-captcha-modal-box{position:relative;background:var(--surface-page,#fff);border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.3);width:90%;max-width:380px;animation:aksrtSlideUp .25s ease;overflow:hidden}
.aksrt-captcha-modal-header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--color-line,#e5e7eb)}
.aksrt-captcha-modal-title{font-size:15px;font-weight:600;color:var(--color-ink,#1f2937)}
.aksrt-captcha-modal-close{background:none;border:none;font-size:22px;color:var(--color-ink-4,#9ca3af);cursor:pointer;padding:0;line-height:1;width:28px;height:28px;display:flex;align-items:center;justify-content:center;border-radius:6px}.aksrt-captcha-modal-close:hover{background:var(--color-bg-hover,#f3f4f6);color:var(--color-ink,#1f2937)}
.aksrt-captcha-modal-body{padding:20px}
.aksrt-captcha-modal-hint{font-size:13px;color:var(--color-ink-4,#9ca3af);margin:0 0 16px;text-align:center}
.aksrt-captcha-modal-widget{display:flex;justify-content:center}
@keyframes aksrtFadeIn{from{opacity:0}to{opacity:1}}
@keyframes aksrtSlideUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+406
View File
@@ -0,0 +1,406 @@
/**
* AKSRT Captcha front-end runtime — cloud providers only (tencent / aliyun / cloudflare / geetest).
*
* PHP renders passive mounts via [data-aksrt-captcha-config] (JSON config attribute);
* this script scans them, registers AKSRTCaptchaState.scenes[scene], loads the provider
* SDK once per page, and writes verified tokens back into the surrounding form.
* Bundled into assets/js/build/public.js by tools/build-public-assets.mjs.
*/
(function () {
'use strict';
var TOKEN_FIELDS = [
'cf_turnstile_response',
'tencent_ticket', 'tencent_randstr', 'tencent_sid',
'geetest_lot_number', 'geetest_captcha_output', 'geetest_pass_token', 'geetest_gen_time',
'aliyun_captcha_verify_param'
];
var root = (window.AKSRTCaptchaState = window.AKSRTCaptchaState || { loaders: {}, scenes: {} });
root.rescan = scan;
function scan() {
var mounts = document.querySelectorAll('[data-aksrt-captcha-config]');
Array.prototype.forEach.call(mounts, function (el) {
if (el.dataset.aksrtCaptchaBound) {
return;
}
el.dataset.aksrtCaptchaBound = '1';
var config;
try {
config = JSON.parse(el.getAttribute('data-aksrt-captcha-config'));
} catch (e) {
return;
}
if (config && config.scene && config.uid && config.vid) {
initMount(config);
}
});
}
/**
* One mount = one scene instance. Mirrors the scene semantics of the previous
* per-render inline script: the last initialized mount wins scenes[scene].
*/
function initMount(C) {
var root = window.AKSRTCaptchaState;
var box, hField, form = null;
var verified = false;
var pendingAction = null;
var triggerCaptcha = function () { showTip('验证码加载中,请稍候', '#e67e22'); };
function loadScriptOnce(key, src, readyCheck) {
if (typeof readyCheck === 'function' && readyCheck()) { return Promise.resolve(); }
if (root.loaders[key]) { return root.loaders[key]; }
root.loaders[key] = new Promise(function (resolve, reject) {
var existing = document.querySelector('script[data-aksrt-sdk="' + key + '"]');
if (existing) {
if (typeof readyCheck === 'function' && readyCheck()) { resolve(); return; }
existing.addEventListener('load', function () { resolve(); }, { once: true });
existing.addEventListener('error', function () { reject(new Error(key + ' load failed')); }, { once: true });
return;
}
var script = document.createElement('script');
script.src = src;
script.async = true;
script.setAttribute('data-aksrt-sdk', key);
script.onload = function () { resolve(); };
script.onerror = function () { reject(new Error(key + ' load failed')); };
document.head.appendChild(script);
});
return root.loaders[key];
}
function findForm() {
form = box.closest('form');
if (!form && C.scene === 'comment') form = document.getElementById('commentform');
if (!form && (C.scene === 'login' || C.scene === 'register')) {
form = box.closest('[data-auth-form]') || document.querySelector('[data-auth-form]');
}
if (!form && C.scene === 'bind_phone') {
form = document.getElementById('aksrt-bind-phone-form')
|| document.querySelector('[data-aksrt-bind-phone-form]')
|| document.querySelector('.auth-form');
}
return form;
}
function bindSubmit() {
if (!form) return;
form.addEventListener('submit', function (e) {
if (verified || hField.value === '1') return;
e.preventDefault(); e.stopImmediatePropagation();
pendingAction = null; triggerCaptcha();
}, true);
}
function setField(name, value) {
var container = form || box;
var el = container.querySelector('input[name="' + name + '"]');
if (!el) {
el = document.createElement('input');
el.type = 'hidden';
el.name = name;
container.appendChild(el);
}
el.value = value;
}
function resetVerificationState() {
verified = false;
hField.value = '0';
var container = form || box;
TOKEN_FIELDS.forEach(function (n) {
var f = container.querySelector('input[name="' + n + '"]');
if (f) f.remove();
});
if (C.scene === 'download') {
syncDownloadSiblings(false);
}
}
function syncDownloadSiblings(passed) {
var myContainer = box.closest('[data-aksrt-download-captcha]');
if (!myContainer) return;
document.querySelectorAll('[data-aksrt-download-captcha]').forEach(function (target) {
if (target === myContainer) return;
var tgtH = target.querySelector('input[name="captcha_verified"]');
if (tgtH) tgtH.value = passed ? '1' : '0';
TOKEN_FIELDS.forEach(function (name) {
var f = target.querySelector('input[name="' + name + '"]');
if (f) f.remove();
if (passed) {
var srcEl = myContainer.querySelector('input[name="' + name + '"]');
if (srcEl && srcEl.value !== '') {
var tgtEl = document.createElement('input');
tgtEl.type = 'hidden';
tgtEl.name = name;
target.appendChild(tgtEl);
tgtEl.value = srcEl.value;
}
}
});
});
}
function submitNativeForm() {
if (!form) return;
if (form.requestSubmit) { form.requestSubmit(); }
else { HTMLFormElement.prototype.submit.call(form); }
}
function submitForm() {
verified = true; hField.value = '1';
if (C.scene === 'download') syncDownloadSiblings(true);
if (typeof pendingAction === 'function') {
finishPending(true);
return;
}
setTimeout(resetVerificationState, 3000);
submitNativeForm();
}
function showTip(msg, color) {
var t = box.querySelector('.captcha-tip');
if (!t) {
t = document.createElement('div');
t.className = 'captcha-tip';
t.style.cssText = 'font-size:12px;margin-top:4px;text-align:left;';
box.appendChild(t);
}
t.style.color = color || '#e74c3c';
t.textContent = msg;
setTimeout(function () { if (t.parentNode) t.remove(); }, 4000);
}
function showLoading() { box.innerHTML = '<div style="text-align:center;padding:20px;color:#9ca3af;">验证码加载中...</div>'; }
function clearLoading() { box.innerHTML = ''; }
function finishPending(ok) {
var action = pendingAction;
pendingAction = null;
if (typeof action === 'function') { action(!!ok); }
}
function cancelPending(msg) {
if (msg) showTip(msg, '#e67e22');
finishPending(false);
}
root.scenes[C.scene] = {
verify: function (callback) {
if (verified || hField.value === '1') {
if (typeof callback === 'function') callback(true);
return;
}
pendingAction = (typeof callback === 'function') ? function (ok) { callback(ok !== false); } : null;
triggerCaptcha();
},
reset: resetVerificationState
};
// ============ 腾讯云 Captcha 2.0 ============
function loadTencent() {
showLoading();
loadScriptOnce('tencent', 'https://turing.captcha.qcloud.com/TJCaptcha.js', function () {
return typeof TencentCaptcha !== 'undefined';
}).then(function () {
clearLoading();
var capIns = null;
triggerCaptcha = function () {
if (!capIns) {
capIns = new TencentCaptcha(C.tc.app_id, function (res) {
if (res.ret === 2) { cancelPending('已取消验证'); return; }
if (res.ret === 0 && res.ticket) {
if (res.ticket.indexOf('trerror_') === 0) {
showTip('验证码服务异常,请刷新页面重试', '#e67e22');
return;
}
setField('tencent_ticket', res.ticket);
setField('tencent_randstr', res.randstr);
setField('tencent_sid', res.sid || '');
submitForm();
return;
}
if (res.errorCode) {
var errorMap = { 1001: '验证码组件加载失败', 1002: '验证码超时', 1006: '验证码配置拉取失败', 1008: '验证码页面加载异常', 1012: '验证码刷新次数过多', 1013: '验证码网络异常' };
showTip(errorMap[res.errorCode] || ('验证码异常(errorCode:' + res.errorCode + ')'), '#e74c3c');
}
}, { userLanguage: 'zh-cn' });
}
capIns.show();
};
}).catch(function () { clearLoading(); showTip('验证码组件加载失败,请刷新重试', '#e74c3c'); });
}
// ============ Cloudflare Turnstile(弹窗) ============
function loadCloudflare() {
var cfWidgetId = null, cfVerifying = false, cfModal = null;
if (typeof turnstile === 'undefined') {
window['onTurnstileLoad_' + C.uid] = function () {};
loadScriptOnce('cloudflare', 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad_' + C.uid + '&render=explicit', function () {
return typeof turnstile !== 'undefined';
});
}
function renderWidget() {
if (cfWidgetId) { try { turnstile.remove(cfWidgetId); } catch (e) {} cfWidgetId = null; }
if (typeof turnstile === 'undefined') { setTimeout(renderWidget, 200); return; }
cfWidgetId = turnstile.render('#modalWidget_' + C.uid, {
sitekey: C.cf.site_key, theme: C.cf.theme, size: C.cf.size, action: 'aksrt-verify', cdata: C.scene,
callback: function (token) {
if (!cfVerifying) return;
cfVerifying = false;
setField('cf_turnstile_response', token);
closeModal();
submitForm();
},
'error-callback': function () { showTip('验证码失败,请重试', '#e74c3c'); cfWidgetId = null; setTimeout(renderWidget, 500); },
'expired-callback': function () { showTip('验证码已过期,请重新验证', '#e67e22'); cfWidgetId = null; setTimeout(renderWidget, 500); }
});
}
function createModal() {
cfModal = document.createElement('div');
cfModal.className = 'aksrt-captcha-modal-overlay';
cfModal.innerHTML = '<div class="aksrt-captcha-modal-backdrop"></div>' +
'<div class="aksrt-captcha-modal-box"><div class="aksrt-captcha-modal-header">' +
'<span class="aksrt-captcha-modal-title">人机验证</span>' +
'<button type="button" class="aksrt-captcha-modal-close">&times;</button></div>' +
'<div class="aksrt-captcha-modal-body"><p class="aksrt-captcha-modal-hint">请完成验证后继续操作</p>' +
'<div id="modalWidget_' + C.uid + '" class="aksrt-captcha-modal-widget"></div></div></div></div>';
document.body.appendChild(cfModal);
var cancelFn = function () { cfVerifying = false; closeModal(); cancelPending('已取消验证'); };
cfModal.querySelector('.aksrt-captcha-modal-backdrop').addEventListener('click', cancelFn);
cfModal.querySelector('.aksrt-captcha-modal-close').addEventListener('click', cancelFn);
renderWidget();
}
function closeModal() {
if (cfWidgetId) { try { turnstile.remove(cfWidgetId); } catch (e) {} cfWidgetId = null; }
if (cfModal && cfModal.parentNode) { cfModal.parentNode.removeChild(cfModal); }
cfModal = null;
}
triggerCaptcha = function () {
if (cfVerifying) { showTip('验证码进行中,请稍候', '#e67e22'); return; }
cfVerifying = true;
createModal();
};
}
// ============ 极验 GeeTest v4 ============
function loadGeetest() {
showLoading();
var captchaId = (C.gt && C.gt.captcha_id) ? String(C.gt.captcha_id).trim() : '';
var product = (C.gt && C.gt.product) ? String(C.gt.product) : 'bind';
if (!captchaId) {
clearLoading();
showTip('极验 Captcha ID 未配置,请到后台「功能&权限 → 人机验证」填写并保存', '#e74c3c');
return;
}
function render() {
clearLoading();
initGeetest4({ captchaId: captchaId, product: product, hideSuccess: true }, function (capIns) {
window['__gt_' + C.uid] = capIns;
function onSuccess() {
var result = capIns.getValidate();
if (!result) return;
setField('geetest_lot_number', result.lot_number);
setField('geetest_captcha_output', result.captcha_output);
setField('geetest_pass_token', result.pass_token);
setField('geetest_gen_time', result.gen_time);
submitForm();
}
if (product === 'bind' || product === 'popup') {
capIns.onReady(function () {}).onSuccess(onSuccess).onClose(function () { cancelPending('已取消验证'); });
} else {
capIns.appendTo('#' + C.uid).onSuccess(onSuccess).onClose(function () { cancelPending('已取消验证'); });
}
});
triggerCaptcha = function () {
var instance = window['__gt_' + C.uid];
if (!instance) { showTip('验证码加载中,请稍候再试', '#e67e22'); return; }
if (product === 'bind' || product === 'popup') { instance.showCaptcha(); return; }
showTip('请先完成人机验证', '#e67e22');
};
}
if (typeof initGeetest4 === 'undefined') {
loadScriptOnce('geetest', 'https://static.geetest.com/v4/gt4.js', function () {
return typeof initGeetest4 !== 'undefined';
}).then(render).catch(function () { clearLoading(); showTip('极验组件加载失败', '#e74c3c'); });
return;
}
render();
}
// ============ 阿里云 CAPTCHA 2.0 V3 ============
function loadAliyun() {
showLoading();
window.AliyunCaptchaConfig = { region: C.al.region || 'cn', prefix: C.al.prefix };
function render() {
clearLoading();
var captchaIns = null;
var btn = box.closest('form') ? box.closest('form').querySelector('button[type="submit"], input[type="submit"]') : null;
window.initAliyunCaptcha({
SceneId: C.al.scene_id, mode: 'popup', element: '#' + C.uid,
button: btn ? '#' + (btn.id || (function () { btn.id = 'aksrt-captcha-btn-' + C.scene; return btn.id; })()) : null,
success: function (captchaVerifyParam) {
setField('aliyun_captcha_verify_param', captchaVerifyParam);
submitForm();
},
fail: function (result) { showTip('验证码失败: ' + (result || '请重试'), '#e74c3c'); captchaIns = null; },
getInstance: function (instance) { captchaIns = instance; },
slideStyle: { width: 360, height: 40 }, language: 'cn'
});
triggerCaptcha = function () {
if (captchaIns && captchaIns.show) { captchaIns.show(); return; }
showTip('验证码组件加载中,请稍候再试', '#e67e22');
};
}
if (typeof window.initAliyunCaptcha === 'undefined') {
loadScriptOnce('aliyun', 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js', function () {
return typeof window.initAliyunCaptcha !== 'undefined';
}).then(render).catch(function () { clearLoading(); showTip('验证码组件加载失败,请刷新重试', '#e74c3c'); });
return;
}
render();
}
function loadProvider() {
switch (C.provider) {
case 'tencent': loadTencent(); break;
case 'cloudflare': loadCloudflare(); break;
case 'geetest': loadGeetest(); break;
case 'aliyun': loadAliyun(); break;
}
}
function init() {
box = document.getElementById(C.uid);
hField = document.getElementById(C.vid);
if (!box || !hField) return;
var tries = 0;
(function retryFind() {
findForm();
if (!form && ++tries < 10) { setTimeout(retryFind, 150); }
else {
if (form) bindSubmit();
setTimeout(loadProvider, 0);
}
})();
}
init();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', scan);
} else {
setTimeout(scan, 50);
}
})();
+133 -391
View File
@@ -5,7 +5,8 @@
* 唯一配置源:option `aksrt_captcha_config`。
* 闭环契约:
* aksrt_get_captcha_config() -> aksrt_is_captcha_enabled_for(scene)
* -> aksrt_render_captcha(scene) -> 客户端 AKSRTCaptchaState + token
* -> aksrt_render_captcha(scene) -> [data-aksrt-captcha-config] 挂载点
* -> captcha-front.js(public bundle)-> 客户端 AKSRTCaptchaState + token
* -> 业务入口 aksrt_require_captcha(scene, $payload) -> proceed/fail。
*
* 仅支持云厂商:tencent / aliyun / cloudflare / geetest。
@@ -81,9 +82,16 @@ function aksrt_captcha_sanitize_scene_list($raw): array {
/**
* 获取人机验证完整配置(规范化,不合并历史脏键)。
*
* 结果在同一请求内缓存;后台保存后调用 aksrt_captcha_config_reset() 失效。
*
* @return array
*/
function aksrt_get_captcha_config(): array {
$cache =& $GLOBALS['aksrt_captcha_config_cache'];
if (is_array($cache)) {
return $cache;
}
$stored = get_option('aksrt_captcha_config', []);
$cfg = array_merge(aksrt_captcha_default_config(), is_array($stored) ? $stored : []);
@@ -103,9 +111,17 @@ function aksrt_get_captcha_config(): array {
$result[$p] = isset($cfg[$p]) && is_array($cfg[$p]) ? $cfg[$p] : [];
}
$cache = $result;
return $result;
}
/**
* 使请求内的配置缓存失效(后台保存路径调用)。
*/
function aksrt_captcha_config_reset(): void {
$GLOBALS['aksrt_captcha_config_cache'] = null;
}
/**
* 各服务商必填字段。
*/
@@ -164,6 +180,25 @@ function aksrt_is_captcha_enabled_for($scene): bool {
return in_array($scene, $cfg['enabled_scenarios'], true);
}
/**
* 场景验证通过后的服务端放行时长(秒),0 表示不启用。
*
* 下载页多个卡片共享一次验证结果(前端会把 token 同步到兄弟挂载点),
* 而厂商 token 是一次性的,因此验证通过后由服务端记录短时放行,
* 后续下载请求直接命中放行,避免票据被判定重复使用。
*/
function aksrt_captcha_pass_ttl(string $scene): int {
return $scene === 'download' ? 10 * MINUTE_IN_SECONDS : 0;
}
/**
* 服务端放行记录的 transient key:场景 + 用户 + IP + UA。
*/
function aksrt_captcha_pass_key(string $scene): string {
$ua = isset($_SERVER['HTTP_USER_AGENT']) ? (string) $_SERVER['HTTP_USER_AGENT'] : '';
return 'aksrt_captcha_pass_' . md5($scene . '|' . get_current_user_id() . '|' . aksrt_get_client_ip() . '|' . md5($ua));
}
/**
* 业务入口统一调用:未启用放行;启用则校验。
*
@@ -175,7 +210,17 @@ function aksrt_require_captcha(string $scene, array $payload = []) {
if (!aksrt_is_captcha_enabled_for($scene)) {
return true;
}
return aksrt_verify_captcha($payload);
$ttl = aksrt_captcha_pass_ttl($scene);
if ($ttl > 0 && get_transient(aksrt_captcha_pass_key($scene))) {
return true;
}
$result = aksrt_verify_captcha($payload, $scene);
if ($result === true && $ttl > 0) {
set_transient(aksrt_captcha_pass_key($scene), time(), $ttl);
}
return $result;
}
/**
@@ -196,6 +241,28 @@ function aksrt_get_captcha_status_summary(): array {
];
}
/**
* 凭据未配齐时的后台警告(前台保持放行,验证码不是准入门槛)。
*/
add_action('admin_notices', static function () {
if (!current_user_can('manage_options')) {
return;
}
$cfg = aksrt_get_captcha_config();
if (empty($cfg['enabled']) || $cfg['active_provider'] === '') {
return;
}
$provider = $cfg['active_provider'];
if (aksrt_captcha_provider_is_configured($provider, $cfg[$provider] ?? [])) {
return;
}
echo '<div class="notice notice-error"><p>' . esc_html(sprintf(
/* translators: %s: captcha provider key */
__('人机验证服务商「%s」已启用但凭据未配齐,前台人机验证未生效。请到「功能&权限 → 人机验证」补全配置。', 'aksrt'),
$provider
)) . '</p></div>';
});
// ============================================================
// 客户端真实 IP(穿透 CDN / 代理)
// ============================================================
@@ -255,7 +322,7 @@ function aksrt_tencent_tc3_sign($secret_id, $secret_key, $service, $timestamp, $
$secret_date = hash_hmac('sha256', $date, 'TC3' . $secret_key, true);
$secret_service = hash_hmac('sha256', $service, $secret_date, true);
$secret_signing = hash_hmac('sha256', 'tc3_request', $secret_service, true);
$secret_signing = hash_hmac('sha256', 'tc3_request', $secret_signing, true);
$signature = hash_hmac('sha256', $string_to_sign, $secret_signing);
$authorization = 'TC3-HMAC-SHA256 '
@@ -291,13 +358,21 @@ function aksrt_aliyun_afs_sign(array $params, string $secret): string {
// 服务端二次校验
// ============================================================
/**
* 记录传输层 / 厂商原始错误细节,供站点排查;对用户只暴露通用文案。
*/
function aksrt_captcha_log(string $provider, string $detail): void {
error_log('[AKSRT Captcha] ' . $provider . ': ' . $detail);
}
/**
* 校验客户端提交的人机验证字段。
*
* @param array $post_data 客户端字段(captcha_verified + 厂商 token)
* @param array $post_data 客户端字段(captcha_verified + 厂商 token)
* @param string $scene 触发场景(用于 Cloudflare cdata 绑定校验)
* @return true|string 成功返回 true,失败返回错误信息
*/
function aksrt_verify_captcha($post_data) {
function aksrt_verify_captcha($post_data, string $scene = '') {
$cfg = aksrt_get_captcha_config();
if (empty($cfg['enabled']) || $cfg['active_provider'] === '') {
return true;
@@ -317,7 +392,7 @@ function aksrt_verify_captcha($post_data) {
$result = aksrt_verify_tencent($pcfg, $post_data, $client_ip);
break;
case 'cloudflare':
$result = aksrt_verify_cloudflare($pcfg, $post_data, $client_ip);
$result = aksrt_verify_cloudflare($pcfg, $post_data, $client_ip, $scene);
break;
case 'geetest':
$result = aksrt_verify_geetest($pcfg, $post_data);
@@ -366,37 +441,29 @@ function aksrt_verify_tencent(array $pcfg, array $post_data, string $client_ip)
$headers = aksrt_tencent_tc3_sign($secret_id, $secret_key, 'captcha', $timestamp, $date, 'DescribeCaptchaResult', '2019-07-22', $payload);
$curl_headers = [];
foreach ($headers as $k => $v) {
$curl_headers[] = "{$k}: {$v}";
}
$ch = curl_init('https://captcha.tencentcloudapi.com/');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => $curl_headers,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_FOLLOWLOCATION => false,
$resp = wp_remote_post('https://captcha.tencentcloudapi.com/', [
'timeout' => 5,
'headers' => $headers,
'body' => $payload,
]);
$response_body = curl_exec($ch);
$curl_error = curl_error($ch);
curl_close($ch);
if ($curl_error) {
return '验证码服务请求失败: ' . $curl_error;
if (is_wp_error($resp)) {
aksrt_captcha_log('tencent', $resp->get_error_message());
return __('人机验证服务暂不可用,请稍后重试', 'aksrt');
}
$body = json_decode((string) $response_body, true);
$body = json_decode(wp_remote_retrieve_body($resp), true);
if (!is_array($body)) {
aksrt_captcha_log('tencent', 'unparsable response');
return __('人机验证服务暂不可用,请稍后重试', 'aksrt');
}
if (isset($body['Response']['Error'])) {
$err = $body['Response']['Error'];
return '腾讯 API 调用失败: ' . ($err['Message'] ?? $err['Code'] ?? '未知错误');
aksrt_captcha_log('tencent', ($err['Code'] ?? 'unknown') . ': ' . ($err['Message'] ?? ''));
return __('人机验证服务暂不可用,请稍后重试', 'aksrt');
}
$resp = $body['Response'] ?? [];
$captcha_code = (int) ($resp['CaptchaCode'] ?? 0);
$resp_data = $body['Response'] ?? [];
$captcha_code = (int) ($resp_data['CaptchaCode'] ?? 0);
if ($captcha_code === 1) {
return true;
}
@@ -411,16 +478,16 @@ function aksrt_verify_tencent(array $pcfg, array $post_data, string $client_ip)
21 => '票据校验异常(风控拦截)',
100 => '参数校验失败(AppId/SecretKey/Ticket 不匹配)',
];
$msg = $resp['CaptchaMsg'] ?? ($messages[$captcha_code] ?? '未知错误');
$msg = $resp_data['CaptchaMsg'] ?? ($messages[$captcha_code] ?? '未知错误');
return '腾讯验证失败: ' . $msg . " (CaptchaCode: {$captcha_code})";
}
/**
* Cloudflare Turnstile siteverify。
* Cloudflare Turnstile siteverify。校验 action 与 cdata(场景)绑定,防 token 转用。
*
* @return true|string
*/
function aksrt_verify_cloudflare(array $pcfg, array $post_data, string $client_ip) {
function aksrt_verify_cloudflare(array $pcfg, array $post_data, string $client_ip, string $scene = '') {
$token = (string) ($post_data['cf_turnstile_response'] ?? '');
if ($token === '' || strlen($token) < 10) {
return __('缺少验证参数', 'aksrt');
@@ -435,7 +502,7 @@ function aksrt_verify_cloudflare(array $pcfg, array $post_data, string $client_i
$body = [];
foreach ($bodies as $attempt) {
$resp = wp_remote_post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
'timeout' => 10,
'timeout' => 5,
'body' => $attempt,
]);
if (is_wp_error($resp)) {
@@ -443,12 +510,22 @@ function aksrt_verify_cloudflare(array $pcfg, array $post_data, string $client_i
}
$body = json_decode(wp_remote_retrieve_body($resp), true);
if (!empty($body['success'])) {
if (isset($body['action']) && (string) $body['action'] !== 'aksrt-verify') {
aksrt_captcha_log('cloudflare', 'action mismatch: ' . (string) $body['action']);
return __('人机验证未通过,请重试', 'aksrt');
}
if ($scene !== '' && isset($body['cdata']) && (string) $body['cdata'] !== $scene) {
aksrt_captcha_log('cloudflare', 'cdata mismatch: ' . (string) $body['cdata'] . ' != ' . $scene);
return __('人机验证未通过,请重试', 'aksrt');
}
return true;
}
}
$codes = isset($body['error-codes']) && is_array($body['error-codes']) ? implode(', ', $body['error-codes']) : '未知错误';
return 'Cloudflare 验证失败: ' . $codes;
if (is_array($body) && isset($body['error-codes'])) {
aksrt_captcha_log('cloudflare', 'siteverify failed: ' . implode(', ', (array) $body['error-codes']));
}
return __('人机验证未通过,请重试', 'aksrt');
}
/**
@@ -469,7 +546,7 @@ function aksrt_verify_geetest(array $pcfg, array $post_data) {
$sign_token = hash_hmac('sha256', $lot_number, $captcha_key);
$resp = wp_remote_post('https://gcaptcha4.geetest.com/validate', [
'timeout' => 10,
'timeout' => 5,
'body' => [
'captcha_id' => (string) ($pcfg['captcha_id'] ?? ''),
'lot_number' => $lot_number,
@@ -481,15 +558,16 @@ function aksrt_verify_geetest(array $pcfg, array $post_data) {
]);
if (is_wp_error($resp)) {
return '验证请求失败: ' . $resp->get_error_message();
aksrt_captcha_log('geetest', $resp->get_error_message());
return __('人机验证服务暂不可用,请稍后重试', 'aksrt');
}
$body = json_decode(wp_remote_retrieve_body($resp), true);
if (!empty($body['result']) && $body['result'] === 'success') {
if (is_array($body) && !empty($body['result']) && $body['result'] === 'success') {
return true;
}
$reason = $body['reason'] ?? ($body['msg'] ?? '未知错误');
return '极验验证失败: ' . $reason;
aksrt_captcha_log('geetest', 'validate failed: ' . (string) ($body['reason'] ?? ($body['msg'] ?? 'unknown')));
return __('人机验证未通过,请重试', 'aksrt');
}
/**
@@ -514,7 +592,7 @@ function aksrt_verify_aliyun(array $pcfg, array $post_data) {
'Format' => 'JSON',
'SignatureMethod' => 'HMAC-SHA1',
'SignatureVersion' => '1.0',
'SignatureNonce' => uniqid('', true),
'SignatureNonce' => wp_generate_uuid4(),
'Timestamp' => gmdate('Y-m-d\TH:i:s\Z'),
'Version' => '2023-03-05',
'CaptchaVerifyParam' => $captcha_verify_param,
@@ -525,15 +603,20 @@ function aksrt_verify_aliyun(array $pcfg, array $post_data) {
$params['Signature'] = aksrt_aliyun_afs_sign($params, (string) ($pcfg['access_key_secret'] ?? ''));
$resp = wp_remote_post('https://' . $endpoint . '/', [
'timeout' => 10,
'timeout' => 5,
'body' => $params,
]);
if (is_wp_error($resp)) {
return '验证请求失败: ' . $resp->get_error_message();
aksrt_captcha_log('aliyun', $resp->get_error_message());
return __('人机验证服务暂不可用,请稍后重试', 'aksrt');
}
$body = json_decode(wp_remote_retrieve_body($resp), true);
if (!is_array($body)) {
aksrt_captcha_log('aliyun', 'unparsable response');
return __('人机验证服务暂不可用,请稍后重试', 'aksrt');
}
if (!empty($body['Success'])) {
$verify_result = $body['Result']['VerifyResult'] ?? false;
if ($verify_result === true) {
@@ -561,15 +644,18 @@ function aksrt_verify_aliyun(array $pcfg, array $post_data) {
return '阿里云 V3 验证失败: ' . ($code_messages[$verify_code] ?? '未知错误 (' . ($verify_code ?: 'N/A') . ')');
}
return '阿里云 V3 验证失败: ' . ($body['Message'] ?? '未知错误');
aksrt_captcha_log('aliyun', 'api failed: ' . (string) ($body['Message'] ?? 'unknown'));
return __('人机验证服务暂不可用,请稍后重试', 'aksrt');
}
// ============================================================
// 前端渲染(挂载点 + 内联 SDK + AKSRTCaptchaState)
// 前端渲染(挂载点 + data 配置,运行时在 public bundle 的 captcha-front.js)
// ============================================================
/**
* 渲染人机验证挂载点。未启用场景直接返回。
*
* 挂载点自带 JSON 配置,由 captcha-front.js(public bundle)扫描激活。
*/
function aksrt_render_captcha($scene = 'comment') {
$scene = sanitize_key((string) $scene);
@@ -618,354 +704,10 @@ function aksrt_render_captcha($scene = 'comment') {
'scene_id' => (string) ($pcfg['scene_id'] ?? ''),
],
];
$token_fields = ['cf_turnstile_response', 'tencent_ticket', 'tencent_randstr', 'tencent_sid',
'geetest_lot_number', 'geetest_captcha_output', 'geetest_pass_token', 'geetest_gen_time',
'aliyun_captcha_verify_param'];
$token_fields_json = wp_json_encode($token_fields, JSON_UNESCAPED_UNICODE);
?>
<div class="aksrt-captcha-wrap" data-provider="<?php echo esc_attr($provider); ?>" data-scene="<?php echo esc_attr($scene); ?>">
<div class="aksrt-captcha-wrap" data-provider="<?php echo esc_attr($provider); ?>" data-scene="<?php echo esc_attr($scene); ?>" data-aksrt-captcha-config="<?php echo esc_attr(wp_json_encode($js_config, JSON_UNESCAPED_UNICODE)); ?>">
<div id="<?php echo esc_attr($uid); ?>" class="aksrt-captcha-box" style="display:none"></div>
<input type="hidden" name="captcha_verified" id="<?php echo esc_attr($vid); ?>" value="0">
</div>
<link rel="stylesheet" href="<?php echo esc_url(AKSRT_URI . '/assets/css/shared/captcha.css'); ?>">
<script>(function(){
var C=<?php echo wp_json_encode($js_config, JSON_UNESCAPED_UNICODE); ?>;
var TOKEN_FIELDS=<?php echo $token_fields_json; ?>;
var root=window.AKSRTCaptchaState=window.AKSRTCaptchaState||{loaders:{}, scenes:{}};
function loadScriptOnce(key, src, readyCheck){
if(typeof readyCheck==='function' && readyCheck()){ return Promise.resolve(); }
if(root.loaders[key]){ return root.loaders[key]; }
root.loaders[key]=new Promise(function(resolve, reject){
var existing=document.querySelector('script[data-aksrt-sdk="'+key+'"]');
if(existing){
if(typeof readyCheck==='function' && readyCheck()){ resolve(); return; }
existing.addEventListener('load', function(){ resolve(); }, {once:true});
existing.addEventListener('error', function(){ reject(new Error(key+' load failed')); }, {once:true});
return;
}
var script=document.createElement('script');
script.src=src; script.async=true; script.setAttribute('data-aksrt-sdk', key);
script.onload=function(){ resolve(); };
script.onerror=function(){ reject(new Error(key+' load failed')); };
document.head.appendChild(script);
});
return root.loaders[key];
}
var box, hField, form=null;
var verified=false;
var pendingAction=null;
var triggerCaptcha=function(){ showTip('验证码加载中,请稍候', '#e67e22'); };
function findForm(){
form=box.closest('form');
if(!form&&C.scene==='comment') form=document.getElementById('commentform');
if(!form&&(C.scene==='login'||C.scene==='register')){
form=box.closest('[data-auth-form]')||document.querySelector('[data-auth-form]');
}
if(!form&&C.scene==='bind_phone'){
form=document.getElementById('aksrt-bind-phone-form')
||document.querySelector('[data-aksrt-bind-phone-form]')
||document.querySelector('.auth-form');
}
return form;
}
function bindSubmit(){
if(!form) return;
form.addEventListener('submit', function(e){
if(verified || hField.value==='1') return;
e.preventDefault(); e.stopImmediatePropagation();
pendingAction=null; triggerCaptcha();
}, true);
}
function setField(name, value){
var container = form || box;
var el=container.querySelector('input[name="'+name+'"]');
if(!el){ el=document.createElement('input'); el.type='hidden'; el.name=name; container.appendChild(el); }
el.value=value;
}
function resetVerificationState(){
verified=false;
hField.value='0';
var container = form || box;
TOKEN_FIELDS.forEach(function(n){
var f=container.querySelector('input[name="'+n+'"]');
if(f) f.remove();
});
if(C.scene==='download'){
syncDownloadSiblings(false);
}
}
function syncDownloadSiblings(passed){
var myContainer=box.closest('[data-aksrt-download-captcha]');
if(!myContainer) return;
document.querySelectorAll('[data-aksrt-download-captcha]').forEach(function(target){
if(target===myContainer) return;
var tgtH=target.querySelector('input[name="captcha_verified"]');
if(tgtH) tgtH.value=passed ? '1' : '0';
TOKEN_FIELDS.forEach(function(name){
var f=target.querySelector('input[name="'+name+'"]');
if(f) f.remove();
if(passed){
var srcEl=myContainer.querySelector('input[name="'+name+'"]');
if(srcEl&&srcEl.value!==''){
var tgtEl=document.createElement('input'); tgtEl.type='hidden'; tgtEl.name=name; target.appendChild(tgtEl);
tgtEl.value=srcEl.value;
}
}
});
});
}
function submitNativeForm(){
if(!form) return;
if(form.requestSubmit){ form.requestSubmit(); }
else { HTMLFormElement.prototype.submit.call(form); }
}
function submitForm(){
verified=true; hField.value='1';
if(C.scene==='download') syncDownloadSiblings(true);
if(typeof pendingAction==='function'){
finishPending(true); return;
}
setTimeout(resetVerificationState, 3000);
submitNativeForm();
}
function showTip(msg, color){
var t=box.querySelector('.captcha-tip');
if(!t){ t=document.createElement('div'); t.className='captcha-tip'; t.style.cssText='font-size:12px;margin-top:4px;text-align:left;'; box.appendChild(t); }
t.style.color=color||'#e74c3c'; t.textContent=msg;
setTimeout(function(){ if(t.parentNode) t.remove(); }, 4000);
}
function showLoading(){ box.innerHTML='<div style="text-align:center;padding:20px;color:#9ca3af;">验证码加载中...</div>'; }
function clearLoading(){ box.innerHTML=''; }
function finishPending(ok){
var action=pendingAction; pendingAction=null;
if(typeof action==='function'){ action(!!ok); }
}
function cancelPending(msg){
if(msg) showTip(msg, '#e67e22');
finishPending(false);
}
root.scenes[C.scene]={
verify:function(callback){
if(verified || hField.value==='1'){
if(typeof callback==='function') callback(true);
return;
}
pendingAction=(typeof callback==='function')?function(ok){ callback(ok!==false); }:null;
triggerCaptcha();
},
reset:resetVerificationState
};
// ============ 腾讯云 Captcha 2.0 ============
function loadTencent(){
showLoading();
loadScriptOnce('tencent', 'https://turing.captcha.qcloud.com/TJCaptcha.js', function(){
return typeof TencentCaptcha!=='undefined';
}).then(function(){
clearLoading();
var capIns=null;
triggerCaptcha=function(){
if(!capIns){
capIns=new TencentCaptcha(C.tc.app_id, function(res){
if(res.ret===2){ cancelPending('已取消验证'); return; }
if(res.ret===0 && res.ticket){
if(res.ticket.indexOf('trerror_')===0){
showTip('验证码服务异常,请刷新页面重试', '#e67e22'); return;
}
setField('tencent_ticket', res.ticket);
setField('tencent_randstr', res.randstr);
setField('tencent_sid', res.sid||'');
submitForm();
return;
}
if(res.errorCode){
var errorMap={1001:'验证码组件加载失败',1002:'验证码超时',1006:'验证码配置拉取失败',1008:'验证码页面加载异常',1012:'验证码刷新次数过多',1013:'验证码网络异常'};
showTip(errorMap[res.errorCode]||('验证码异常(errorCode:'+res.errorCode+')'), '#e74c3c');
}
}, {userLanguage:'zh-cn'});
}
capIns.show();
};
}).catch(function(){ clearLoading(); showTip('验证码组件加载失败,请刷新重试', '#e74c3c'); });
}
// ============ Cloudflare Turnstile(弹窗) ============
function loadCloudflare(){
var cfWidgetId=null, cfVerifying=false, cfModal=null;
if(typeof turnstile==='undefined'){
window['onTurnstileLoad_'+C.uid]=function(){};
loadScriptOnce('cloudflare', 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad_'+C.uid+'&render=explicit', function(){
return typeof turnstile!=='undefined';
});
}
function renderWidget(){
if(cfWidgetId){ try { turnstile.remove(cfWidgetId); } catch(e) {} cfWidgetId=null; }
if(typeof turnstile==='undefined'){ setTimeout(renderWidget, 200); return; }
cfWidgetId=turnstile.render('#modalWidget_'+C.uid, {
sitekey:C.cf.site_key, theme:C.cf.theme, size:C.cf.size, action:'aksrt-verify',
callback:function(token){
if(!cfVerifying) return;
cfVerifying=false;
setField('cf_turnstile_response', token);
closeModal(); submitForm();
},
'error-callback':function(){ showTip('验证码失败,请重试', '#e74c3c'); cfWidgetId=null; setTimeout(renderWidget, 500); },
'expired-callback':function(){ showTip('验证码已过期,请重新验证', '#e67e22'); cfWidgetId=null; setTimeout(renderWidget, 500); }
});
}
function createModal(){
cfModal=document.createElement('div');
cfModal.className='aksrt-captcha-modal-overlay';
cfModal.innerHTML='<div class="aksrt-captcha-modal-backdrop"></div>'+
'<div class="aksrt-captcha-modal-box"><div class="aksrt-captcha-modal-header">'+
'<span class="aksrt-captcha-modal-title">人机验证</span>'+
'<button type="button" class="aksrt-captcha-modal-close">&times;</button></div>'+
'<div class="aksrt-captcha-modal-body"><p class="aksrt-captcha-modal-hint">请完成验证后继续操作</p>'+
'<div id="modalWidget_'+C.uid+'" class="aksrt-captcha-modal-widget"></div></div></div>';
document.body.appendChild(cfModal);
var cancelFn=function(){ cfVerifying=false; closeModal(); cancelPending('已取消验证'); };
cfModal.querySelector('.aksrt-captcha-modal-backdrop').addEventListener('click', cancelFn);
cfModal.querySelector('.aksrt-captcha-modal-close').addEventListener('click', cancelFn);
renderWidget();
}
function closeModal(){
if(cfWidgetId){ try { turnstile.remove(cfWidgetId); } catch(e) {} cfWidgetId=null; }
if(cfModal && cfModal.parentNode){ cfModal.parentNode.removeChild(cfModal); }
cfModal=null;
}
triggerCaptcha=function(){
if(cfVerifying){ showTip('验证码进行中,请稍候', '#e67e22'); return; }
cfVerifying=true; createModal();
};
}
// ============ 极验 GeeTest v4 ============
function loadGeetest(){
showLoading();
var captchaId=(C.gt && C.gt.captcha_id) ? String(C.gt.captcha_id).trim() : '';
var product=(C.gt && C.gt.product) ? String(C.gt.product) : 'bind';
if(!captchaId){
clearLoading();
showTip('极验 Captcha ID 未配置,请到后台「功能&权限 → 人机验证」填写并保存', '#e74c3c');
return;
}
function render(){
clearLoading();
initGeetest4({captchaId:captchaId, product:product, hideSuccess:true}, function(capIns){
window['__gt_'+C.uid]=capIns;
function onSuccess(){
var result=capIns.getValidate();
if(!result) return;
setField('geetest_lot_number', result.lot_number);
setField('geetest_captcha_output', result.captcha_output);
setField('geetest_pass_token', result.pass_token);
setField('geetest_gen_time', result.gen_time);
submitForm();
}
if(product==='bind' || product==='popup'){
capIns.onReady(function(){}).onSuccess(onSuccess).onClose(function(){});
}else{
capIns.appendTo('#'+C.uid).onSuccess(onSuccess).onClose(function(){});
}
});
triggerCaptcha=function(){
var instance=window['__gt_'+C.uid];
if(!instance){ showTip('验证码加载中,请稍候再试', '#e67e22'); return; }
if(product==='bind' || product==='popup'){ instance.showCaptcha(); return; }
showTip('请先完成人机验证', '#e67e22');
};
}
if(typeof initGeetest4==='undefined'){
loadScriptOnce('geetest', 'https://static.geetest.com/v4/gt4.js', function(){
return typeof initGeetest4!=='undefined';
}).then(render).catch(function(){ clearLoading(); showTip('极验组件加载失败', '#e74c3c'); });
return;
}
render();
}
// ============ 阿里云 CAPTCHA 2.0 V3 ============
function loadAliyun(){
showLoading();
window.AliyunCaptchaConfig={ region:C.al.region||'cn', prefix:C.al.prefix };
function render(){
clearLoading();
var captchaIns=null;
var btn=box.closest('form') ? box.closest('form').querySelector('button[type="submit"], input[type="submit"]') : null;
window.initAliyunCaptcha({
SceneId:C.al.scene_id, mode:'popup', element:'#'+C.uid,
button:btn ? '#' + (btn.id || (function(){ btn.id='aksrt-captcha-btn-'+C.scene; return btn.id; })()) : null,
success:function(captchaVerifyParam){
setField('aliyun_captcha_verify_param', captchaVerifyParam);
submitForm();
},
fail:function(result){ showTip('验证码失败: ' + (result || '请重试'), '#e74c3c'); captchaIns=null; },
getInstance:function(instance){ captchaIns=instance; },
slideStyle:{width:360, height:40}, language:'cn'
});
triggerCaptcha=function(){
if(captchaIns && captchaIns.show){ captchaIns.show(); return; }
showTip('验证码组件加载中,请稍候再试', '#e67e22');
};
}
if(typeof window.initAliyunCaptcha==='undefined'){
loadScriptOnce('aliyun', 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js', function(){
return typeof window.initAliyunCaptcha!=='undefined';
}).then(render).catch(function(){ clearLoading(); showTip('验证码组件加载失败,请刷新重试', '#e74c3c'); });
return;
}
render();
}
function loadProvider(){
switch(C.provider){
case 'tencent': loadTencent(); break;
case 'cloudflare': loadCloudflare(); break;
case 'geetest': loadGeetest(); break;
case 'aliyun': loadAliyun(); break;
}
}
function init(){
box=document.getElementById(C.uid);
hField=document.getElementById(C.vid);
if(!box||!hField) return;
var tries=0;
(function retryFind(){
findForm();
if(!form && ++tries < 10){ setTimeout(retryFind, 150); }
else {
if(form) bindSubmit();
setTimeout(loadProvider, 0);
}
})();
}
if(document.readyState==='loading'){ document.addEventListener('DOMContentLoaded', init); }
else { setTimeout(init, 50); }
})();</script>
<?php
}
-3
View File
@@ -29,9 +29,6 @@ class SchemaPermissions {
['label' => 'Cloudflare Turnstile', 'value' => 'cloudflare'],
['label' => '极验 GeeTest', 'value' => 'geetest'],
]],
['key' => 'mode', 'label' => '触发模式', 'type' => 'radio', 'default' => 'always', 'dependsOn' => ['key' => 'enabled', 'value' => true], 'options' => [
['label' => '始终显示', 'value' => 'always'],
]],
['key' => 'enabled_scenarios', 'label' => '启用场景', 'type' => 'checkbox', 'default' => ['comment', 'login', 'register', 'bind', 'bind_phone', 'lostpassword', 'change_password', 'change_email', 'download', 'signin'], 'dependsOn' => ['key' => 'enabled', 'value' => true], 'options' => [
['label' => '发表评论', 'value' => 'comment'],
['label' => '用户登录', 'value' => 'login'],
+4 -1
View File
@@ -400,7 +400,7 @@ class SchemaProvider {
// Captcha: always surface the normalized positive scene list.
if ($option_key === 'aksrt_captcha_config' && function_exists('aksrt_get_captcha_config')) {
$normalized = aksrt_get_captcha_config();
foreach (['enabled', 'active_provider', 'enabled_scenarios', 'tencent', 'aliyun', 'cloudflare', 'geetest', 'mode'] as $key) {
foreach (['enabled', 'active_provider', 'enabled_scenarios', 'tencent', 'aliyun', 'cloudflare', 'geetest'] as $key) {
if (array_key_exists($key, $normalized)) {
$data[$key] = $normalized[$key];
}
@@ -437,6 +437,9 @@ class SchemaProvider {
}
$updated = update_option($option_key, $stored);
if ($option_key === 'aksrt_captcha_config' && function_exists('aksrt_captcha_config_reset')) {
aksrt_captcha_config_reset();
}
\AKSRT\Settings\Settings::flush_settings_cache();
if (isset($stored['site_favicon'])) {
+2
View File
@@ -38,6 +38,7 @@ const cssBundles = {
'assets/css/frontend/ajax-spinner.css',
'assets/css/frontend/modal-runtime.css',
'assets/css/frontend/notyf.css',
'assets/css/shared/captcha.css',
],
'homepage.css': [
'assets/css/frontend/post-card.css',
@@ -88,6 +89,7 @@ const publicScripts = [
'assets/js/color-extract.js',
'assets/js/effects.js',
'assets/js/runtime/main.min.js',
'assets/js/captcha-front.js',
'assets/js/instant-page.js',
];