- rehydrate.js 新增 boot()/aksrt:ready 事件,支持 boot 后晚注册立即以 init 触发 - aksrt-runtime.js 初始加载改走 AksrtFrontend.boot,tooltip/倒计时注册为组件 - 约 30 个前端脚本(首页、评论、支付、路由、页面叶子、widgets) 从直接 DOMContentLoaded 迁移为 AksrtFrontend.register,并加幂等标记 - section-navs 依赖改为 aksrt-runtime,保证注册时序 - 清理死代码:删除孤儿 loader.js/main.js 及 runtime/page-navs 遗留副本 - loader.js 移除已迁移模块的 tbquire 路径映射,main.js 相应瘦身 - build-public-assets.mjs 增加 runtime/main.min.js 自动压缩步骤 - 新增 4 个契约测试(homepage-dom/rehydrate-runtime/global-dom-init/ public-bundle-parity),3 个既有 harness 适配注册表初始化方式
553 lines
20 KiB
JavaScript
553 lines
20 KiB
JavaScript
/**
|
|
* AKSRT Captcha front-end runtime — cloud providers only.
|
|
*
|
|
* Mounts are registered internally by uid and resolved by scene plus DOM scope.
|
|
* The only public surface is window.AKSRTCaptcha.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
var TOKEN_FIELDS = Object.freeze([
|
|
'captcha_verified',
|
|
'aksrt_captcha_challenge',
|
|
'aksrt_captcha_proof',
|
|
'cf_turnstile_response',
|
|
'tencent_ticket', 'tencent_randstr',
|
|
'geetest_lot_number', 'geetest_captcha_output', 'geetest_pass_token', 'geetest_gen_time',
|
|
'aliyun_captcha_verify_param'
|
|
]);
|
|
var loaders = {};
|
|
var instances = {};
|
|
var scenes = {};
|
|
|
|
function asElement(scope) {
|
|
if (!scope) return null;
|
|
if (scope.nodeType === 1 || scope.nodeType === 9) return scope;
|
|
if (typeof scope !== 'string') return null;
|
|
if (instances[scope]) return instances[scope].box;
|
|
try { return document.querySelector(scope); } catch (_) { return null; }
|
|
}
|
|
|
|
function scopeBoundary(box, config) {
|
|
if (config.scope) {
|
|
try {
|
|
var configured = document.querySelector(config.scope);
|
|
if (configured) return configured;
|
|
} catch (_) {}
|
|
}
|
|
return box.closest('[data-aksrt-captcha-scope], form, [data-aksrt-download-box], [data-aksrt-auth], .aksrt-modal, [data-signin-captcha], #comments') || box.parentElement;
|
|
}
|
|
|
|
function register(instance) {
|
|
instances[instance.uid] = instance;
|
|
scenes[instance.scene] = scenes[instance.scene] || [];
|
|
if (scenes[instance.scene].indexOf(instance) === -1) scenes[instance.scene].push(instance);
|
|
}
|
|
|
|
function resolve(scene, scope) {
|
|
scan();
|
|
if (typeof scope === 'string' && instances[scope] && instances[scope].scene === scene) {
|
|
return instances[scope];
|
|
}
|
|
var candidates = scenes[scene] || [];
|
|
var element = asElement(scope);
|
|
if (element && element.getAttribute) {
|
|
var uid = element.getAttribute('data-aksrt-captcha-uid') || element.getAttribute('data-captcha-uid');
|
|
if (uid && instances[uid] && instances[uid].scene === scene) return instances[uid];
|
|
}
|
|
if (!element) return scope ? null : (candidates.length === 1 ? candidates[0] : null);
|
|
var matches = candidates.filter(function (instance) {
|
|
var boundary = instance.scope;
|
|
return element === instance.box || element === instance.mount || element === boundary ||
|
|
instance.box.contains(element) || instance.mount.contains(element) ||
|
|
(boundary && boundary.contains(element)) ||
|
|
(element.contains && (element.contains(instance.box) || (boundary && element.contains(boundary))));
|
|
});
|
|
if (matches.length === 1) return matches[0];
|
|
if (!matches.length) return null;
|
|
matches.sort(function (a, b) {
|
|
if (a.scope === b.scope) return 0;
|
|
if (a.scope && b.scope && a.scope.contains(b.scope)) return 1;
|
|
if (a.scope && b.scope && b.scope.contains(a.scope)) return -1;
|
|
return 0;
|
|
});
|
|
return matches[0];
|
|
}
|
|
|
|
function verify(scene, scope) {
|
|
var instance = resolve(scene, scope);
|
|
if (!instance) return Promise.resolve(true);
|
|
return instance.verify();
|
|
}
|
|
|
|
function collect(scene, scope) {
|
|
var instance = resolve(scene, scope);
|
|
return instance ? instance.collect() : {};
|
|
}
|
|
|
|
function reset(scene, scope) {
|
|
var instance = resolve(scene, scope);
|
|
if (instance) instance.reset();
|
|
}
|
|
|
|
function releaseRunLock(instance) {
|
|
instance.runLocked = false;
|
|
var waiters = instance.lockWaiters.slice();
|
|
instance.lockWaiters = [];
|
|
waiters.forEach(function (resolveWaiter) { resolveWaiter(null); });
|
|
}
|
|
|
|
function run(scene, scope) {
|
|
var instance = resolve(scene, scope);
|
|
if (!instance) return Promise.resolve({});
|
|
if (instance.runLocked) {
|
|
return new Promise(function (resolveWaiter) { instance.lockWaiters.push(resolveWaiter); });
|
|
}
|
|
instance.runLocked = true;
|
|
return instance.verify().then(function (passed) {
|
|
if (!passed) releaseRunLock(instance);
|
|
return passed ? instance.collect() : null;
|
|
}).catch(function (error) {
|
|
releaseRunLock(instance);
|
|
throw error;
|
|
});
|
|
}
|
|
|
|
window.AKSRTCaptcha = Object.freeze({
|
|
verify: verify,
|
|
collect: collect,
|
|
reset: reset,
|
|
run: run,
|
|
TOKEN_FIELDS: TOKEN_FIELDS
|
|
});
|
|
|
|
function scan(root, reason) {
|
|
var scope = root && root.querySelectorAll ? root : document;
|
|
var mounts = [];
|
|
if (scope.nodeType === 1 && scope.matches('[data-aksrt-captcha-config]')) mounts.push(scope);
|
|
Array.prototype.push.apply(mounts, scope.querySelectorAll('[data-aksrt-captcha-config]'));
|
|
Array.prototype.forEach.call(mounts, function (mount) {
|
|
if (mount.dataset.aksrtCaptchaBound) return;
|
|
var config;
|
|
try {
|
|
config = JSON.parse(mount.getAttribute('data-aksrt-captcha-config'));
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
if (!config || !config.scene || !config.uid || !config.vid) return;
|
|
mount.dataset.aksrtCaptchaBound = '1';
|
|
initMount(config, mount);
|
|
});
|
|
}
|
|
|
|
function initMount(C, mount) {
|
|
var box = document.getElementById(C.uid);
|
|
var hField = document.getElementById(C.vid);
|
|
if (!box || !hField) return;
|
|
|
|
var container = box.closest('form') || mount;
|
|
mount.setAttribute('data-aksrt-captcha-uid', C.uid);
|
|
var verified = false;
|
|
var pending = [];
|
|
var refreshingChallenge = null;
|
|
var providerReset = function () {};
|
|
var triggerCaptcha = function () {
|
|
showTip('验证码加载中,请稍候', '#e67e22');
|
|
finishPending(false);
|
|
};
|
|
|
|
function loadScriptOnce(key, src, readyCheck) {
|
|
if (typeof readyCheck === 'function' && readyCheck()) return Promise.resolve();
|
|
if (loaders[key]) return loaders[key];
|
|
loaders[key] = new Promise(function (resolveLoad, rejectLoad) {
|
|
var existing = document.querySelector('script[data-aksrt-sdk="' + key + '"]');
|
|
if (existing) {
|
|
if (typeof readyCheck === 'function' && readyCheck()) { resolveLoad(); return; }
|
|
existing.addEventListener('load', resolveLoad, { once: true });
|
|
existing.addEventListener('error', function () { rejectLoad(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 = resolveLoad;
|
|
script.onerror = function () { rejectLoad(new Error(key + ' load failed')); };
|
|
document.head.appendChild(script);
|
|
});
|
|
return loaders[key];
|
|
}
|
|
|
|
function setField(name, value) {
|
|
var field = container.querySelector('input[name="' + name + '"]');
|
|
if (!field) {
|
|
field = document.createElement('input');
|
|
field.type = 'hidden';
|
|
field.name = name;
|
|
container.appendChild(field);
|
|
}
|
|
field.value = value == null ? '' : value;
|
|
}
|
|
|
|
function collectFields() {
|
|
var payload = {};
|
|
TOKEN_FIELDS.forEach(function (name) {
|
|
var field = container.querySelector('input[name="' + name + '"]');
|
|
if (field && field.value !== '') payload[name] = field.value;
|
|
});
|
|
return payload;
|
|
}
|
|
|
|
function refreshChallenge() {
|
|
if (!C.challenge_url || typeof window.fetch !== 'function') return Promise.resolve();
|
|
if (refreshingChallenge) return refreshingChallenge;
|
|
refreshingChallenge = window.fetch(C.challenge_url, {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ scene: C.scene })
|
|
}).then(function (response) {
|
|
return response.json().catch(function () { return {}; }).then(function (body) {
|
|
if (!response.ok || !body || body.success === false) throw new Error(body.message || 'challenge refresh failed');
|
|
var data = body.data || body;
|
|
if (!data.challenge) throw new Error('challenge missing');
|
|
setField('aksrt_captcha_challenge', data.challenge);
|
|
});
|
|
}).catch(function () {
|
|
showTip('无法刷新人机验证会话,请刷新页面重试', '#e74c3c');
|
|
}).finally(function () {
|
|
refreshingChallenge = null;
|
|
});
|
|
return refreshingChallenge;
|
|
}
|
|
|
|
function clearProviderTokens() {
|
|
TOKEN_FIELDS.forEach(function (name) {
|
|
if (name === 'captcha_verified' || name === 'aksrt_captcha_challenge' || name === 'aksrt_captcha_proof') return;
|
|
Array.prototype.forEach.call(container.querySelectorAll('input[name="' + name + '"]'), function (field) {
|
|
field.remove();
|
|
});
|
|
});
|
|
}
|
|
|
|
function resetVerificationState() {
|
|
verified = false;
|
|
releaseRunLock(instance);
|
|
hField.value = '0';
|
|
setField('aksrt_captcha_proof', '');
|
|
clearProviderTokens();
|
|
try { providerReset(); } catch (_) {}
|
|
refreshChallenge();
|
|
}
|
|
|
|
function finishPending(ok) {
|
|
var callbacks = pending.slice();
|
|
pending = [];
|
|
callbacks.forEach(function (callback) { callback(!!ok); });
|
|
}
|
|
|
|
function cancelPending(message) {
|
|
if (message) showTip(message, '#e67e22');
|
|
finishPending(false);
|
|
}
|
|
|
|
function providerPayload() {
|
|
var payload = collectFields();
|
|
payload.scene = C.scene;
|
|
return payload;
|
|
}
|
|
|
|
function exchangeVerification() {
|
|
if (!C.exchange_url || typeof window.fetch !== 'function') {
|
|
cancelPending('人机验证服务不可用,请刷新页面重试');
|
|
return;
|
|
}
|
|
window.fetch(C.exchange_url, {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(providerPayload())
|
|
}).then(function (response) {
|
|
return response.json().catch(function () { return {}; }).then(function (body) {
|
|
if (!response.ok || !body || body.success === false) throw new Error(body.message || '人机验证失败');
|
|
var data = body.data || body;
|
|
if (!data.proof) throw new Error('人机验证凭证缺失');
|
|
setField('aksrt_captcha_proof', data.proof);
|
|
clearProviderTokens();
|
|
completeVerification();
|
|
});
|
|
}).catch(function (error) {
|
|
cancelPending(error.message || '人机验证失败,请重试');
|
|
resetVerificationState();
|
|
});
|
|
}
|
|
|
|
function completeProviderVerification() {
|
|
hField.value = '1';
|
|
exchangeVerification();
|
|
}
|
|
|
|
function completeVerification() {
|
|
verified = true;
|
|
hField.value = '1';
|
|
finishPending(true);
|
|
}
|
|
|
|
function showTip(message, color) {
|
|
var tip = box.querySelector('.captcha-tip');
|
|
if (!tip) {
|
|
tip = document.createElement('div');
|
|
tip.className = 'captcha-tip';
|
|
tip.style.cssText = 'font-size:12px;margin-top:4px;text-align:left;';
|
|
box.appendChild(tip);
|
|
}
|
|
tip.style.color = color || '#e74c3c';
|
|
tip.textContent = message;
|
|
setTimeout(function () { if (tip.parentNode) tip.remove(); }, 4000);
|
|
}
|
|
|
|
function showLoading() {
|
|
box.innerHTML = '<div style="text-align:center;padding:20px;color:#9ca3af;">验证码加载中...</div>';
|
|
}
|
|
|
|
function clearLoading() {
|
|
box.innerHTML = '';
|
|
}
|
|
|
|
var instance = {
|
|
uid: C.uid,
|
|
scene: C.scene,
|
|
box: box,
|
|
mount: mount,
|
|
scope: scopeBoundary(box, C),
|
|
runLocked: false,
|
|
lockWaiters: [],
|
|
collect: collectFields,
|
|
reset: resetVerificationState,
|
|
verify: function () {
|
|
var challengeField = container.querySelector('input[name="aksrt_captcha_challenge"]');
|
|
var preparation = challengeField && challengeField.value ? Promise.resolve() : refreshChallenge();
|
|
return preparation.then(function () {
|
|
challengeField = container.querySelector('input[name="aksrt_captcha_challenge"]');
|
|
if (!challengeField || !challengeField.value) {
|
|
showTip('人机验证会话未就绪,请稍后重试', '#e74c3c');
|
|
return false;
|
|
}
|
|
if (verified || hField.value === '1') return true;
|
|
return new Promise(function (resolveVerify) {
|
|
pending.push(resolveVerify);
|
|
if (pending.length === 1) triggerCaptcha();
|
|
});
|
|
});
|
|
}
|
|
};
|
|
register(instance);
|
|
refreshingChallenge = refreshChallenge();
|
|
|
|
function loadTencent() {
|
|
showLoading();
|
|
loadScriptOnce('tencent', 'https://turing.captcha.qcloud.com/TJCaptcha.js', function () {
|
|
return typeof window.TencentCaptcha !== 'undefined';
|
|
}).then(function () {
|
|
clearLoading();
|
|
var captchaInstance = null;
|
|
triggerCaptcha = function () {
|
|
if (!captchaInstance) {
|
|
captchaInstance = new window.TencentCaptcha(C.tc.app_id, function (result) {
|
|
if (result.ret === 2) { cancelPending('已取消验证'); return; }
|
|
if (result.ret === 0 && result.ticket) {
|
|
if (result.ticket.indexOf('trerror_') === 0) {
|
|
cancelPending('验证码服务异常,请刷新页面重试');
|
|
return;
|
|
}
|
|
setField('tencent_ticket', result.ticket);
|
|
setField('tencent_randstr', result.randstr);
|
|
completeProviderVerification();
|
|
return;
|
|
}
|
|
var errorMap = { 1001: '验证码组件加载失败', 1002: '验证码超时', 1006: '验证码配置拉取失败', 1008: '验证码页面加载异常', 1012: '验证码刷新次数过多', 1013: '验证码网络异常' };
|
|
cancelPending(errorMap[result.errorCode] || '验证码异常,请重试');
|
|
}, { userLanguage: 'zh-cn' });
|
|
}
|
|
captchaInstance.show();
|
|
};
|
|
}).catch(function () {
|
|
clearLoading();
|
|
cancelPending('验证码组件加载失败,请刷新重试');
|
|
});
|
|
}
|
|
|
|
function loadCloudflare() {
|
|
var widgetId = null;
|
|
var verifying = false;
|
|
var modal = null;
|
|
|
|
function closeModal() {
|
|
if (widgetId !== null && typeof window.turnstile !== 'undefined') {
|
|
try { window.turnstile.remove(widgetId); } catch (_) {}
|
|
}
|
|
widgetId = null;
|
|
if (modal && modal.parentNode) modal.parentNode.removeChild(modal);
|
|
modal = null;
|
|
}
|
|
|
|
function renderWidget() {
|
|
if (typeof window.turnstile === 'undefined') { setTimeout(renderWidget, 200); return; }
|
|
widgetId = window.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 (!verifying) return;
|
|
verifying = false;
|
|
setField('cf_turnstile_response', token);
|
|
closeModal();
|
|
completeProviderVerification();
|
|
},
|
|
'error-callback': function () {
|
|
verifying = false;
|
|
closeModal();
|
|
cancelPending('验证码失败,请重试');
|
|
},
|
|
'expired-callback': function () {
|
|
verifying = false;
|
|
closeModal();
|
|
cancelPending('验证码已过期,请重新验证');
|
|
}
|
|
});
|
|
}
|
|
|
|
function createModal() {
|
|
modal = document.createElement('div');
|
|
modal.className = 'aksrt-captcha-modal-overlay';
|
|
modal.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">×</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(modal);
|
|
var cancel = function () {
|
|
verifying = false;
|
|
closeModal();
|
|
cancelPending('已取消验证');
|
|
};
|
|
modal.querySelector('.aksrt-captcha-modal-backdrop').addEventListener('click', cancel);
|
|
modal.querySelector('.aksrt-captcha-modal-close').addEventListener('click', cancel);
|
|
renderWidget();
|
|
}
|
|
|
|
window['onTurnstileLoad_' + C.uid] = function () {};
|
|
if (typeof window.turnstile === 'undefined') {
|
|
loadScriptOnce('cloudflare', 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad_' + C.uid + '&render=explicit', function () {
|
|
return typeof window.turnstile !== 'undefined';
|
|
}).catch(function () { cancelPending('验证码组件加载失败,请刷新重试'); });
|
|
}
|
|
triggerCaptcha = function () {
|
|
if (verifying) return;
|
|
verifying = true;
|
|
createModal();
|
|
};
|
|
providerReset = closeModal;
|
|
}
|
|
|
|
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();
|
|
cancelPending('极验 Captcha ID 未配置');
|
|
return;
|
|
}
|
|
|
|
function render() {
|
|
clearLoading();
|
|
window.initGeetest4({ captchaId: captchaId, product: product, hideSuccess: true }, function (captchaInstance) {
|
|
function onSuccess() {
|
|
var result = captchaInstance.getValidate();
|
|
if (!result) { cancelPending('验证码失败,请重试'); 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);
|
|
completeProviderVerification();
|
|
}
|
|
captchaInstance.onReady(function () {}).onSuccess(onSuccess).onClose(function () { cancelPending('已取消验证'); });
|
|
if (product !== 'bind' && product !== 'popup') captchaInstance.appendTo('#' + C.uid);
|
|
triggerCaptcha = function () {
|
|
if (product === 'bind' || product === 'popup') captchaInstance.showCaptcha();
|
|
else showTip('请先完成人机验证', '#e67e22');
|
|
};
|
|
providerReset = function () {
|
|
if (typeof captchaInstance.reset === 'function') captchaInstance.reset();
|
|
};
|
|
});
|
|
}
|
|
|
|
if (typeof window.initGeetest4 === 'undefined') {
|
|
loadScriptOnce('geetest', 'https://static.geetest.com/v4/gt4.js', function () {
|
|
return typeof window.initGeetest4 !== 'undefined';
|
|
}).then(render).catch(function () {
|
|
clearLoading();
|
|
cancelPending('极验组件加载失败');
|
|
});
|
|
} else {
|
|
render();
|
|
}
|
|
}
|
|
|
|
function loadAliyun() {
|
|
showLoading();
|
|
window.AliyunCaptchaConfig = { region: C.al.region || 'cn', prefix: C.al.prefix };
|
|
|
|
function render() {
|
|
clearLoading();
|
|
var captchaInstance = null;
|
|
window.initAliyunCaptcha({
|
|
SceneId: C.al.scene_id,
|
|
mode: 'popup',
|
|
element: '#' + C.uid,
|
|
success: function (verifyParam) {
|
|
setField('aliyun_captcha_verify_param', verifyParam);
|
|
completeProviderVerification();
|
|
},
|
|
fail: function () { cancelPending('验证码失败,请重试'); },
|
|
getInstance: function (value) { captchaInstance = value; },
|
|
slideStyle: { width: 360, height: 40 },
|
|
language: 'cn'
|
|
});
|
|
triggerCaptcha = function () {
|
|
if (captchaInstance && captchaInstance.show) captchaInstance.show();
|
|
else cancelPending('验证码组件加载中,请稍候再试');
|
|
};
|
|
providerReset = function () {
|
|
if (captchaInstance && typeof captchaInstance.reset === 'function') captchaInstance.reset();
|
|
};
|
|
}
|
|
|
|
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();
|
|
cancelPending('验证码组件加载失败,请刷新重试');
|
|
});
|
|
} else {
|
|
render();
|
|
}
|
|
}
|
|
|
|
switch (C.provider) {
|
|
case 'tencent': loadTencent(); break;
|
|
case 'cloudflare': loadCloudflare(); break;
|
|
case 'geetest': loadGeetest(); break;
|
|
case 'aliyun': loadAliyun(); break;
|
|
}
|
|
}
|
|
|
|
window.AksrtFrontend.register('aksrt-captcha-front', scan);
|
|
})();
|