1113 lines
37 KiB
JavaScript
1113 lines
37 KiB
JavaScript
/* global Audio, CSS, CustomEvent, Element, HTMLElement, MutationObserver, URL, clearInterval, clearTimeout, document, fetch, window */
|
|
(function () {
|
|
"use strict";
|
|
|
|
var RUNTIME_KEY = "__wrnexusCaptchaRuntime";
|
|
var existingRuntime = window[RUNTIME_KEY];
|
|
|
|
if (existingRuntime && typeof existingRuntime.scan === "function") {
|
|
window.__wrnexusRuntimes = window.__wrnexusRuntimes || {};
|
|
window.__wrnexusRuntimes.captcha = {
|
|
mount: existingRuntime.scan,
|
|
unmount: existingRuntime.unmount || function () {},
|
|
};
|
|
existingRuntime.scan(document);
|
|
return;
|
|
}
|
|
|
|
var states = new WeakMap();
|
|
var scriptPromises = new Map();
|
|
var instanceCounter = 0;
|
|
|
|
function bool(value, fallback) {
|
|
if (value === undefined || value === null || value === "") return fallback;
|
|
return value === true || value === "true" || value === "1";
|
|
}
|
|
|
|
function numberInRange(value, fallback, minimum, maximum) {
|
|
var parsed = Number(value);
|
|
if (!Number.isFinite(parsed)) return fallback;
|
|
return Math.max(minimum, Math.min(maximum, Math.round(parsed)));
|
|
}
|
|
|
|
function normalizeSize(value, compact) {
|
|
if (compact) return "compact";
|
|
var normalized = String(value || "normal")
|
|
.trim()
|
|
.toLowerCase();
|
|
if (normalized === "compact" || normalized === "small" || normalized === "sm") return "compact";
|
|
if (normalized === "big" || normalized === "large" || normalized === "lg") return "big";
|
|
return "normal";
|
|
}
|
|
|
|
function commaList(value) {
|
|
return String(value || "")
|
|
.split(",")
|
|
.map(function (item) {
|
|
return item.trim().toLowerCase();
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function text(root, selector, value) {
|
|
var element = root.querySelector(selector);
|
|
if (element) element.textContent = value == null ? "" : String(value);
|
|
}
|
|
|
|
function show(element, visible) {
|
|
if (!element) return;
|
|
element.hidden = !visible;
|
|
}
|
|
|
|
function setBusy(root, busy) {
|
|
root.setAttribute("aria-busy", busy ? "true" : "false");
|
|
}
|
|
|
|
function config(root) {
|
|
var data = root.dataset;
|
|
var responseField = data.captchaResponseField || data.captchaName || "wrn-captcha-response";
|
|
var compact = bool(data.captchaCompact, false);
|
|
var size = normalizeSize(data.captchaSize, compact);
|
|
|
|
return {
|
|
provider: data.captchaProvider || "self-hosted",
|
|
siteKey: data.captchaSiteKey || "",
|
|
type: data.captchaType || "alphanumeric",
|
|
action: data.captchaAction || "form-submit",
|
|
presentation: data.captchaPresentation || "visual",
|
|
difficulty: data.captchaDifficulty || "normal",
|
|
disturbance: numberInRange(data.captchaDisturbance, 50, 25, 75),
|
|
imageStyle: data.captchaImageStyle || "random",
|
|
allowedStyles: commaList(data.captchaAllowedStyles),
|
|
excludedStyles: commaList(data.captchaExcludedStyles),
|
|
randomizeStyle: bool(data.captchaRandomizeStyle, false),
|
|
locale: data.captchaLocale || "en",
|
|
size: size,
|
|
endpoint: data.captchaEndpoint || "/__wrnexus/captcha/challenge",
|
|
verifyEndpoint: data.captchaVerifyEndpoint || "/__wrnexus/captcha/verify",
|
|
responseField: responseField,
|
|
autoLoad: bool(data.captchaAutoLoad, true),
|
|
autoVerify: bool(data.captchaAutoVerify, false),
|
|
showVerify: bool(data.captchaShowVerify, true),
|
|
showRefresh: bool(data.captchaShowRefresh, true),
|
|
showAudio: bool(data.captchaShowAudio, true),
|
|
showListen: bool(data.captchaShowListen, true),
|
|
showStatus: bool(data.captchaShowStatus, true),
|
|
disabled: bool(data.captchaDisabled, false),
|
|
required: bool(data.captchaRequired, true),
|
|
resetOnError: bool(data.captchaResetOnError, true),
|
|
compact: compact,
|
|
requiredMessage: data.captchaRequiredMessage || "Please complete the security check.",
|
|
incorrectMessage: data.captchaIncorrectMessage || "That answer was not correct. Try again.",
|
|
expiredMessage: data.captchaExpiredMessage || "This challenge expired. Load a new one.",
|
|
networkMessage:
|
|
data.captchaNetworkMessage || "The verification service is unavailable. Try again.",
|
|
};
|
|
}
|
|
|
|
function eventDetail(state, extra) {
|
|
var challenge = state.challenge;
|
|
return {
|
|
component: "Captcha",
|
|
provider: state.config.provider,
|
|
type: challenge && challenge.type ? challenge.type : state.config.type,
|
|
action: state.config.action,
|
|
disturbance: state.config.disturbance,
|
|
imageStyle:
|
|
challenge && challenge.metadata && challenge.metadata.imageStyle
|
|
? challenge.metadata.imageStyle
|
|
: state.config.imageStyle,
|
|
requestedImageStyle:
|
|
challenge && challenge.metadata && challenge.metadata.requestedImageStyle
|
|
? challenge.metadata.requestedImageStyle
|
|
: state.config.imageStyle,
|
|
imageStylePool:
|
|
challenge && challenge.metadata && challenge.metadata.imageStylePool
|
|
? challenge.metadata.imageStylePool
|
|
: state.config.allowedStyles,
|
|
size: state.config.size,
|
|
status: state.status,
|
|
challengeId: challenge && challenge.id ? challenge.id : "",
|
|
responseToken: state.responseToken,
|
|
expiresAt: challenge && challenge.expiresAt ? challenge.expiresAt : null,
|
|
extra: extra || null,
|
|
};
|
|
}
|
|
|
|
function emit(state, name, extra) {
|
|
state.root.dispatchEvent(
|
|
new CustomEvent(name, {
|
|
bubbles: true,
|
|
detail: eventDetail(state, extra),
|
|
}),
|
|
);
|
|
}
|
|
|
|
function setResponseToken(state, token) {
|
|
state.responseToken = token || "";
|
|
var input = state.root.querySelector("[data-captcha-response]");
|
|
if (input) {
|
|
input.name = state.config.responseField;
|
|
input.value = state.responseToken;
|
|
}
|
|
}
|
|
|
|
function isNotRobot(state) {
|
|
return Boolean(
|
|
(state.challenge && state.challenge.type === "not-robot") ||
|
|
(!state.challenge && state.config.type === "not-robot"),
|
|
);
|
|
}
|
|
|
|
function updateNotRobotState(state) {
|
|
var panel = state.root.querySelector("[data-captcha-not-robot]");
|
|
var button = state.root.querySelector("[data-captcha-not-robot-button]");
|
|
var empty = state.root.querySelector("[data-captcha-not-robot-empty]");
|
|
var spinner = state.root.querySelector("[data-captcha-not-robot-spinner]");
|
|
var check = state.root.querySelector("[data-captcha-not-robot-check]");
|
|
var control = state.root.querySelector("[data-captcha-not-robot-control]");
|
|
var label = state.root.querySelector("[data-captcha-not-robot-label]");
|
|
var active = isNotRobot(state);
|
|
var pending = state.notRobotPending || state.status === "verifying";
|
|
var verified = state.status === "verified";
|
|
|
|
show(panel, active && state.status !== "loading" && state.status !== "idle");
|
|
if (!active) return;
|
|
|
|
if (button) {
|
|
button.disabled = state.config.disabled || pending || verified || state.status === "expired";
|
|
button.setAttribute("aria-pressed", verified ? "true" : "false");
|
|
}
|
|
show(empty, !pending && !verified);
|
|
show(spinner, pending && !verified);
|
|
show(check, verified);
|
|
|
|
if (control) control.dataset.verified = verified ? "true" : "false";
|
|
if (label) {
|
|
label.textContent = verified ? "Verified" : pending ? "Checking…" : "I'm not a robot";
|
|
}
|
|
}
|
|
|
|
function setStatus(state, status, message) {
|
|
state.status = status;
|
|
state.root.dataset.captchaStatus = status;
|
|
setBusy(state.root, status === "loading" || status === "verifying");
|
|
|
|
var loading = state.root.querySelector("[data-captcha-loading]");
|
|
var challenge = state.root.querySelector("[data-captcha-challenge]");
|
|
var success = state.root.querySelector("[data-captcha-success]");
|
|
var error = state.root.querySelector("[data-captcha-error]");
|
|
var badge = state.root.querySelector("[data-captcha-verified-badge]");
|
|
var verifyButton = state.root.querySelector("[data-captcha-verify]");
|
|
var verifyIcon = state.root.querySelector("[data-captcha-verify-icon]");
|
|
var verifySpinner = state.root.querySelector("[data-captcha-verify-spinner]");
|
|
var verifyLabel = state.root.querySelector("[data-captcha-verify-label]");
|
|
var answer = state.root.querySelector("[data-captcha-answer]");
|
|
|
|
show(loading, status === "loading");
|
|
show(
|
|
challenge,
|
|
status !== "loading" &&
|
|
status !== "idle" &&
|
|
state.config.provider !== "turnstile" &&
|
|
state.config.provider !== "recaptcha" &&
|
|
state.config.provider !== "hcaptcha",
|
|
);
|
|
show(badge, status === "verified");
|
|
|
|
if (state.config.showStatus) {
|
|
show(success, status === "verified");
|
|
show(error, Boolean(message) && status !== "verified");
|
|
} else {
|
|
show(success, false);
|
|
show(error, false);
|
|
}
|
|
|
|
if (message && status === "verified")
|
|
text(state.root, "[data-captcha-success-message]", message);
|
|
if (message && status !== "verified") text(state.root, "[data-captcha-error-message]", message);
|
|
|
|
if (answer) {
|
|
answer.disabled =
|
|
state.config.disabled ||
|
|
status === "verified" ||
|
|
status === "expired" ||
|
|
status === "loading" ||
|
|
status === "verifying";
|
|
answer.setAttribute("aria-invalid", status === "incorrect" ? "true" : "false");
|
|
}
|
|
|
|
if (verifyButton) {
|
|
verifyButton.disabled =
|
|
state.config.disabled ||
|
|
status === "loading" ||
|
|
status === "verifying" ||
|
|
status === "verified" ||
|
|
status === "expired";
|
|
}
|
|
|
|
show(verifyIcon, status !== "verifying");
|
|
show(verifySpinner, status === "verifying");
|
|
if (verifyLabel) verifyLabel.textContent = status === "verifying" ? "Verifying…" : "Verify";
|
|
|
|
updateNotRobotState(state);
|
|
updateControls(state);
|
|
}
|
|
|
|
function clearTimer(state) {
|
|
if (state.timer) {
|
|
window.clearInterval(state.timer);
|
|
state.timer = null;
|
|
}
|
|
}
|
|
|
|
function updateCountdown(state) {
|
|
var countdown = state.root.querySelector("[data-captcha-countdown]");
|
|
if (!countdown || !state.challenge || !state.challenge.expiresAt) {
|
|
show(countdown, false);
|
|
return;
|
|
}
|
|
|
|
var remaining = Math.max(0, Math.ceil((Number(state.challenge.expiresAt) - Date.now()) / 1000));
|
|
countdown.textContent = remaining + "s";
|
|
show(countdown, remaining > 0 && state.status !== "verified");
|
|
|
|
if (remaining <= 0 && state.status !== "verified" && state.status !== "expired") {
|
|
clearTimer(state);
|
|
setResponseToken(state, "");
|
|
setStatus(state, "expired", state.config.expiredMessage);
|
|
emit(state, "expired");
|
|
}
|
|
}
|
|
|
|
function startTimer(state) {
|
|
clearTimer(state);
|
|
if (!state.challenge || !state.challenge.expiresAt) return;
|
|
updateCountdown(state);
|
|
state.timer = window.setInterval(function () {
|
|
updateCountdown(state);
|
|
}, 1000);
|
|
}
|
|
|
|
function resetUi(state) {
|
|
if (state.audioPlayer) {
|
|
state.audioPlayer.pause();
|
|
state.audioPlayer.removeAttribute("src");
|
|
state.audioPlayer.load();
|
|
state.audioPlayer = null;
|
|
}
|
|
if (state.notRobotTimer) {
|
|
window.clearTimeout(state.notRobotTimer);
|
|
state.notRobotTimer = null;
|
|
}
|
|
|
|
state.notRobotPending = false;
|
|
state.challengeLoadedAt = 0;
|
|
state.answer = "";
|
|
state.selections = [];
|
|
state.challenge = null;
|
|
setResponseToken(state, "");
|
|
|
|
var answer = state.root.querySelector("[data-captcha-answer]");
|
|
var items = state.root.querySelector("[data-captcha-items]");
|
|
var imageWrap = state.root.querySelector("[data-captcha-image-wrap]");
|
|
var providerMount = state.root.querySelector("[data-captcha-provider-mount]");
|
|
var honeypot = state.root.querySelector("[data-captcha-honeypot]");
|
|
var notRobot = state.root.querySelector("[data-captcha-not-robot]");
|
|
|
|
if (answer) answer.value = "";
|
|
if (items) items.replaceChildren();
|
|
show(items, false);
|
|
show(imageWrap, false);
|
|
show(notRobot, false);
|
|
if (honeypot) {
|
|
honeypot.value = "";
|
|
honeypot.name = "";
|
|
}
|
|
if (
|
|
providerMount &&
|
|
state.config.provider !== "turnstile" &&
|
|
state.config.provider !== "recaptcha" &&
|
|
state.config.provider !== "hcaptcha"
|
|
) {
|
|
providerMount.replaceChildren();
|
|
show(providerMount, false);
|
|
}
|
|
}
|
|
|
|
function updateControls(state) {
|
|
var challenge = state.challenge;
|
|
var audio = state.root.querySelector("[data-captcha-audio]");
|
|
var audioAlternative = state.root.querySelector("[data-captcha-audio-alternative]");
|
|
var refresh = state.root.querySelector("[data-captcha-refresh]");
|
|
var verify = state.root.querySelector("[data-captcha-verify]");
|
|
var footer = state.root.querySelector("[data-captcha-footer]");
|
|
var isExternal =
|
|
state.config.provider === "turnstile" ||
|
|
state.config.provider === "recaptcha" ||
|
|
state.config.provider === "hcaptcha";
|
|
var notRobot = isNotRobot(state);
|
|
var locked =
|
|
state.config.disabled ||
|
|
state.status === "loading" ||
|
|
state.status === "verifying" ||
|
|
state.notRobotPending;
|
|
|
|
show(footer, !notRobot);
|
|
show(
|
|
audio,
|
|
!notRobot &&
|
|
state.config.showAudio &&
|
|
state.config.showListen &&
|
|
Boolean(challenge && challenge.audioUrl) &&
|
|
state.status !== "verified",
|
|
);
|
|
show(
|
|
audioAlternative,
|
|
!notRobot &&
|
|
state.config.showAudio &&
|
|
Boolean(challenge && challenge.type === "image") &&
|
|
state.status !== "verified",
|
|
);
|
|
show(refresh, !notRobot && state.config.showRefresh && state.status !== "verified");
|
|
show(verify, !notRobot && state.config.showVerify && !isExternal);
|
|
|
|
if (audio) audio.disabled = locked;
|
|
if (audioAlternative) audioAlternative.disabled = locked;
|
|
if (refresh) refresh.disabled = locked;
|
|
if (verify)
|
|
verify.disabled = locked || state.status === "verified" || state.status === "expired";
|
|
}
|
|
|
|
function renderItems(state, challenge) {
|
|
var container = state.root.querySelector("[data-captcha-items]");
|
|
var template = state.root.querySelector("[data-captcha-item-template]");
|
|
if (
|
|
!container ||
|
|
!template ||
|
|
!Array.isArray(challenge.items) ||
|
|
challenge.items.length === 0
|
|
) {
|
|
show(container, false);
|
|
return;
|
|
}
|
|
|
|
container.replaceChildren();
|
|
container.setAttribute("aria-label", challenge.prompt || "Select matching images");
|
|
|
|
challenge.items.forEach(function (item, index) {
|
|
var fragment = template.content.cloneNode(true);
|
|
var button = fragment.querySelector("button");
|
|
var image = fragment.querySelector("[data-captcha-item-image]");
|
|
if (!button || !image) return;
|
|
|
|
button.dataset.captchaItemId = item.id;
|
|
button.setAttribute("aria-label", item.alt || "Challenge tile " + (index + 1));
|
|
image.src = item.image;
|
|
image.alt = "";
|
|
button.addEventListener("click", function () {
|
|
toggleItem(state, item.id, button);
|
|
});
|
|
container.appendChild(fragment);
|
|
});
|
|
|
|
show(container, true);
|
|
}
|
|
|
|
function toggleItem(state, itemId, button) {
|
|
if (state.config.disabled || (state.status !== "ready" && state.status !== "incorrect")) return;
|
|
|
|
// An incorrect answer is still the same usable challenge until the server
|
|
// expires it or exhausts its attempts. Let the user amend the selection
|
|
// immediately and clear the stale error/locked control state.
|
|
if (state.status === "incorrect") setStatus(state, "ready", "");
|
|
|
|
var index = state.selections.indexOf(itemId);
|
|
var selected = index >= 0;
|
|
|
|
if (selected) {
|
|
state.selections.splice(index, 1);
|
|
} else {
|
|
var maximum = Number(
|
|
state.challenge && state.challenge.maxSelections ? state.challenge.maxSelections : 0,
|
|
);
|
|
if (maximum > 0 && state.selections.length >= maximum) {
|
|
var removed = state.selections.shift();
|
|
var previousButton = state.root.querySelector(
|
|
'[data-captcha-item-id="' + CSS.escape(removed) + '"]',
|
|
);
|
|
if (previousButton) {
|
|
previousButton.setAttribute("aria-pressed", "false");
|
|
show(previousButton.querySelector("[data-captcha-item-check]"), false);
|
|
}
|
|
}
|
|
state.selections.push(itemId);
|
|
}
|
|
|
|
var nowSelected = state.selections.indexOf(itemId) >= 0;
|
|
button.setAttribute("aria-pressed", nowSelected ? "true" : "false");
|
|
show(button.querySelector("[data-captcha-item-check]"), nowSelected);
|
|
emit(state, "input", { selections: state.selections.slice() });
|
|
|
|
var minimum = Number(
|
|
state.challenge && state.challenge.minSelections ? state.challenge.minSelections : 0,
|
|
);
|
|
if (state.config.autoVerify && minimum > 0 && state.selections.length >= minimum) verify(state);
|
|
}
|
|
|
|
function renderChallenge(state, challenge) {
|
|
state.challenge = challenge;
|
|
state.challengeLoadedAt = Date.now();
|
|
state.notRobotPending = false;
|
|
state.answer = "";
|
|
state.selections = [];
|
|
|
|
var notRobot = challenge.type === "not-robot";
|
|
state.root.dataset.captchaType = challenge.type || state.config.type;
|
|
state.root.dataset.captchaResolvedImageStyle =
|
|
challenge.metadata && challenge.metadata.imageStyle
|
|
? String(challenge.metadata.imageStyle)
|
|
: state.config.imageStyle;
|
|
text(
|
|
state.root,
|
|
"[data-captcha-prompt]",
|
|
challenge.prompt || "Complete the security challenge.",
|
|
);
|
|
|
|
var promptRow = state.root.querySelector("[data-captcha-prompt-row]");
|
|
var notRobotPanel = state.root.querySelector("[data-captcha-not-robot]");
|
|
show(promptRow, !notRobot);
|
|
show(notRobotPanel, notRobot);
|
|
|
|
var imageWrap = state.root.querySelector("[data-captcha-image-wrap]");
|
|
var image = state.root.querySelector("[data-captcha-image]");
|
|
if (!notRobot && challenge.image && image) {
|
|
image.src = challenge.image;
|
|
show(imageWrap, true);
|
|
} else {
|
|
if (image) image.removeAttribute("src");
|
|
show(imageWrap, false);
|
|
}
|
|
|
|
if (notRobot) {
|
|
var items = state.root.querySelector("[data-captcha-items]");
|
|
if (items) items.replaceChildren();
|
|
show(items, false);
|
|
} else {
|
|
renderItems(state, challenge);
|
|
}
|
|
|
|
var answerWrap = state.root.querySelector("[data-captcha-answer-wrap]");
|
|
var answer = state.root.querySelector("[data-captcha-answer]");
|
|
var needsAnswer = !notRobot && !challenge.items && challenge.inputMode !== "none";
|
|
show(answerWrap, needsAnswer);
|
|
if (answer) {
|
|
answer.value = "";
|
|
answer.inputMode = challenge.inputMode === "numeric" ? "numeric" : "text";
|
|
answer.setAttribute("aria-invalid", "false");
|
|
}
|
|
|
|
var honeypot = state.root.querySelector("[data-captcha-honeypot]");
|
|
if (honeypot) honeypot.name = challenge.honeypotField || "";
|
|
|
|
setStatus(state, "ready", "");
|
|
startTimer(state);
|
|
emit(state, "challenge", challenge);
|
|
emit(state, "ready");
|
|
|
|
if (
|
|
state.config.autoVerify &&
|
|
!notRobot &&
|
|
challenge.inputMode === "none" &&
|
|
!challenge.items
|
|
) {
|
|
window.setTimeout(function () {
|
|
verify(state);
|
|
}, 0);
|
|
}
|
|
}
|
|
|
|
async function createChallenge(state, requestedPresentation) {
|
|
if (state.config.disabled) return;
|
|
|
|
if (
|
|
state.config.provider === "turnstile" ||
|
|
state.config.provider === "recaptcha" ||
|
|
state.config.provider === "hcaptcha"
|
|
) {
|
|
await mountExternal(state);
|
|
return;
|
|
}
|
|
|
|
clearTimer(state);
|
|
resetUi(state);
|
|
setStatus(state, "loading", "");
|
|
|
|
try {
|
|
var response = await fetch(state.config.endpoint, {
|
|
method: "POST",
|
|
credentials: "same-origin",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
accept: "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
siteKey: state.config.siteKey,
|
|
action: state.config.action,
|
|
type: state.config.type,
|
|
presentation: requestedPresentation || state.config.presentation,
|
|
difficulty: state.config.difficulty,
|
|
disturbance: state.config.disturbance,
|
|
imageStyle: state.config.imageStyle,
|
|
allowedStyles: state.config.allowedStyles,
|
|
excludedStyles: state.config.excludedStyles,
|
|
randomizeStyle: state.config.randomizeStyle,
|
|
locale: state.config.locale,
|
|
responseField: state.config.responseField,
|
|
}),
|
|
});
|
|
|
|
var result = await response.json().catch(function () {
|
|
return null;
|
|
});
|
|
|
|
if (!response.ok || !result || !result.id) {
|
|
throw new Error(result && result.message ? result.message : "Challenge request failed");
|
|
}
|
|
|
|
renderChallenge(state, result);
|
|
} catch (error) {
|
|
setStatus(state, "network-error", state.config.networkMessage);
|
|
emit(state, "error", { error: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
}
|
|
|
|
async function verify(state) {
|
|
state.notRobotPending = false;
|
|
if (
|
|
state.config.disabled ||
|
|
state.status === "loading" ||
|
|
state.status === "verifying" ||
|
|
state.status === "expired" ||
|
|
state.status === "verified"
|
|
) {
|
|
updateNotRobotState(state);
|
|
updateControls(state);
|
|
return;
|
|
}
|
|
|
|
if (!state.challenge) {
|
|
await createChallenge(state);
|
|
return;
|
|
}
|
|
|
|
var answerInput = state.root.querySelector("[data-captcha-answer]");
|
|
var honeypotInput = state.root.querySelector("[data-captcha-honeypot]");
|
|
state.answer = answerInput ? answerInput.value : "";
|
|
|
|
setStatus(state, "verifying", "");
|
|
emit(state, "verify");
|
|
|
|
try {
|
|
var response = await fetch(state.challenge.verifyUrl || state.config.verifyEndpoint, {
|
|
method: "POST",
|
|
credentials: "same-origin",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
accept: "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
challengeId: state.challenge.id,
|
|
action: state.config.action,
|
|
answer: state.answer,
|
|
selections: state.selections,
|
|
honeypot: honeypotInput ? honeypotInput.value : "",
|
|
timingToken: state.challenge.timingToken || "",
|
|
}),
|
|
});
|
|
|
|
var result = await response.json().catch(function () {
|
|
return null;
|
|
});
|
|
|
|
if (!response.ok || !result || !result.success) {
|
|
setResponseToken(state, "");
|
|
var expired = Boolean(result && result.code === "expired");
|
|
setStatus(
|
|
state,
|
|
expired ? "expired" : "incorrect",
|
|
expired
|
|
? state.config.expiredMessage
|
|
: result && result.message
|
|
? result.message
|
|
: state.config.incorrectMessage,
|
|
);
|
|
emit(state, "failure", result || null);
|
|
return;
|
|
}
|
|
|
|
clearTimer(state);
|
|
setResponseToken(state, result.responseToken || "");
|
|
setStatus(state, "verified", "Verification completed.");
|
|
emit(state, "success", result);
|
|
} catch (error) {
|
|
setStatus(state, "network-error", state.config.networkMessage);
|
|
emit(state, "error", { error: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
}
|
|
|
|
function verifyNotRobot(state) {
|
|
if (state.config.disabled || state.notRobotPending || state.status !== "ready") return;
|
|
if (!state.challenge || state.challenge.type !== "not-robot") {
|
|
createChallenge(state);
|
|
return;
|
|
}
|
|
|
|
var configuredMinimum = Number(
|
|
state.challenge.metadata && state.challenge.metadata.minCompletionMs !== undefined
|
|
? state.challenge.metadata.minCompletionMs
|
|
: 800,
|
|
);
|
|
var minimum = Number.isFinite(configuredMinimum) ? Math.max(0, configuredMinimum) : 800;
|
|
var elapsed = Math.max(0, Date.now() - state.challengeLoadedAt);
|
|
var remaining = Math.max(0, minimum - elapsed);
|
|
|
|
state.notRobotPending = true;
|
|
updateNotRobotState(state);
|
|
updateControls(state);
|
|
emit(state, "input", { checked: true });
|
|
|
|
var complete = function () {
|
|
state.notRobotTimer = null;
|
|
state.notRobotPending = false;
|
|
verify(state);
|
|
};
|
|
|
|
if (remaining > 0) state.notRobotTimer = window.setTimeout(complete, remaining);
|
|
else complete();
|
|
}
|
|
|
|
function providerDefinition(provider) {
|
|
if (provider === "turnstile") {
|
|
return {
|
|
url: "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit",
|
|
globalName: "turnstile",
|
|
};
|
|
}
|
|
if (provider === "recaptcha") {
|
|
return {
|
|
url: "https://www.google.com/recaptcha/api.js?render=explicit",
|
|
globalName: "grecaptcha",
|
|
};
|
|
}
|
|
if (provider === "hcaptcha") {
|
|
return {
|
|
url: "https://js.hcaptcha.com/1/api.js?render=explicit",
|
|
globalName: "hcaptcha",
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function loadProviderScript(definition) {
|
|
if (window[definition.globalName]) return Promise.resolve(window[definition.globalName]);
|
|
if (scriptPromises.has(definition.url)) return scriptPromises.get(definition.url);
|
|
|
|
var promise = new Promise(function (resolve, reject) {
|
|
var script = document.querySelector('script[src="' + definition.url + '"]');
|
|
if (!script) {
|
|
script = document.createElement("script");
|
|
script.src = definition.url;
|
|
script.async = true;
|
|
script.defer = true;
|
|
document.head.appendChild(script);
|
|
}
|
|
|
|
script.addEventListener(
|
|
"load",
|
|
function () {
|
|
if (window[definition.globalName]) resolve(window[definition.globalName]);
|
|
else reject(new Error("CAPTCHA provider did not initialize"));
|
|
},
|
|
{ once: true },
|
|
);
|
|
script.addEventListener(
|
|
"error",
|
|
function () {
|
|
reject(new Error("CAPTCHA provider script failed"));
|
|
},
|
|
{ once: true },
|
|
);
|
|
});
|
|
|
|
scriptPromises.set(definition.url, promise);
|
|
return promise;
|
|
}
|
|
|
|
async function mountExternal(state) {
|
|
var definition = providerDefinition(state.config.provider);
|
|
if (!definition) return;
|
|
|
|
if (!state.config.siteKey) {
|
|
setStatus(state, "provider-error", "A public site key is required.");
|
|
return;
|
|
}
|
|
|
|
var mount = state.root.querySelector("[data-captcha-provider-mount]");
|
|
if (!mount) return;
|
|
|
|
show(mount, true);
|
|
setStatus(state, "loading", "");
|
|
|
|
try {
|
|
var api = await loadProviderScript(definition);
|
|
|
|
if (state.externalWidgetId !== null && typeof api.reset === "function") {
|
|
setResponseToken(state, "");
|
|
api.reset(state.externalWidgetId);
|
|
setStatus(state, "ready", "");
|
|
emit(state, "ready");
|
|
return;
|
|
}
|
|
|
|
mount.replaceChildren();
|
|
state.externalApi = api;
|
|
state.externalWidgetId = api.render(mount, {
|
|
sitekey: state.config.siteKey,
|
|
theme: "auto",
|
|
size:
|
|
state.config.size === "compact"
|
|
? "compact"
|
|
: state.config.provider === "turnstile"
|
|
? "flexible"
|
|
: "normal",
|
|
action: state.config.action,
|
|
callback: function (token) {
|
|
setResponseToken(state, token);
|
|
setStatus(state, "verified", "Verification completed.");
|
|
emit(state, "success");
|
|
},
|
|
"expired-callback": function () {
|
|
setResponseToken(state, "");
|
|
setStatus(state, "expired", state.config.expiredMessage);
|
|
emit(state, "expired");
|
|
},
|
|
"error-callback": function (providerError) {
|
|
setResponseToken(state, "");
|
|
setStatus(state, "provider-error", state.config.networkMessage);
|
|
emit(state, "error", { error: providerError || "provider-error" });
|
|
},
|
|
});
|
|
|
|
setStatus(state, "ready", "");
|
|
emit(state, "ready");
|
|
} catch (error) {
|
|
setStatus(state, "provider-error", state.config.networkMessage);
|
|
emit(state, "error", { error: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
}
|
|
|
|
function playAudio(state) {
|
|
if (!state.challenge || !state.challenge.audioUrl || state.config.disabled) return;
|
|
|
|
if (state.audioPlayer) {
|
|
state.audioPlayer.pause();
|
|
state.audioPlayer.currentTime = 0;
|
|
}
|
|
|
|
var audio = new Audio();
|
|
state.audioPlayer = audio;
|
|
audio.preload = "auto";
|
|
audio.volume = 1;
|
|
audio.src = new URL(state.challenge.audioUrl, window.location.href).href;
|
|
|
|
audio.addEventListener(
|
|
"ended",
|
|
function () {
|
|
if (state.audioPlayer === audio) state.audioPlayer = null;
|
|
emit(state, "audioEnd");
|
|
emit(state, "audio-end");
|
|
},
|
|
{ once: true },
|
|
);
|
|
|
|
audio.addEventListener(
|
|
"error",
|
|
function () {
|
|
if (state.audioPlayer === audio) state.audioPlayer = null;
|
|
var mediaError = audio.error;
|
|
setStatus(
|
|
state,
|
|
"network-error",
|
|
"The audio challenge could not be played. Load a new challenge and try again.",
|
|
);
|
|
emit(state, "error", {
|
|
code: "audio-playback-failed",
|
|
audioUrl: audio.src,
|
|
mediaErrorCode: mediaError ? mediaError.code : null,
|
|
});
|
|
},
|
|
{ once: true },
|
|
);
|
|
|
|
audio.load();
|
|
audio
|
|
.play()
|
|
.then(function () {
|
|
emit(state, "audioStart");
|
|
emit(state, "audio-start");
|
|
})
|
|
.catch(function (error) {
|
|
if (state.audioPlayer === audio) state.audioPlayer = null;
|
|
setStatus(
|
|
state,
|
|
"network-error",
|
|
"The audio challenge could not be played. Check the audio endpoint and try again.",
|
|
);
|
|
emit(state, "error", {
|
|
code: "audio-playback-rejected",
|
|
audioUrl: audio.src,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
});
|
|
}
|
|
|
|
function passesSubmitGuard(state) {
|
|
if (!state.config.required || state.responseToken) return true;
|
|
setStatus(state, "incorrect", state.config.requiredMessage);
|
|
emit(state, "failure", { code: "missing-input" });
|
|
var target =
|
|
state.root.querySelector("[data-captcha-answer]") ||
|
|
state.root.querySelector("[data-captcha-not-robot-button]") ||
|
|
state.root.querySelector("[data-captcha-verify]");
|
|
if (target && typeof target.focus === "function") target.focus();
|
|
return false;
|
|
}
|
|
|
|
function onFormSubmit(state, event) {
|
|
// Schema-backed forms run this guard after their ordinary fields validate.
|
|
if (state.form && state.form.__wireValidateBound) return;
|
|
if (passesSubmitGuard(state)) return;
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
}
|
|
|
|
function initialize(root) {
|
|
if (!(root instanceof HTMLElement) || states.has(root)) return;
|
|
|
|
if (!root.id) {
|
|
instanceCounter += 1;
|
|
root.id = "wrn-captcha-" + instanceCounter;
|
|
}
|
|
|
|
var state = {
|
|
root: root,
|
|
config: config(root),
|
|
challenge: null,
|
|
answer: "",
|
|
selections: [],
|
|
responseToken: "",
|
|
status: "idle",
|
|
timer: null,
|
|
notRobotTimer: null,
|
|
notRobotPending: false,
|
|
challengeLoadedAt: 0,
|
|
externalApi: null,
|
|
externalWidgetId: null,
|
|
audioPlayer: null,
|
|
form: root.closest("form"),
|
|
formSubmitListener: null,
|
|
formSuccessListener: null,
|
|
formErrorListener: null,
|
|
};
|
|
states.set(root, state);
|
|
root.dataset.captchaSize = state.config.size;
|
|
|
|
var response = root.querySelector("[data-captcha-response]");
|
|
if (response) response.name = state.config.responseField;
|
|
|
|
var answer = root.querySelector("[data-captcha-answer]");
|
|
var verifyButton = root.querySelector("[data-captcha-verify]");
|
|
var refreshButton = root.querySelector("[data-captcha-refresh]");
|
|
var audioButton = root.querySelector("[data-captcha-audio]");
|
|
var audioAlternative = root.querySelector("[data-captcha-audio-alternative]");
|
|
var notRobotButton = root.querySelector("[data-captcha-not-robot-button]");
|
|
|
|
if (answer) {
|
|
answer.addEventListener("input", function () {
|
|
state.answer = answer.value;
|
|
emit(state, "input", { answerLength: state.answer.length });
|
|
});
|
|
answer.addEventListener("keydown", function (event) {
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
verify(state);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (verifyButton)
|
|
verifyButton.addEventListener("click", function () {
|
|
verify(state);
|
|
});
|
|
if (notRobotButton)
|
|
notRobotButton.addEventListener("click", function () {
|
|
verifyNotRobot(state);
|
|
});
|
|
if (refreshButton)
|
|
refreshButton.addEventListener("click", function () {
|
|
emit(state, "refresh");
|
|
createChallenge(state, state.config.presentation);
|
|
});
|
|
if (audioButton)
|
|
audioButton.addEventListener("click", function () {
|
|
playAudio(state);
|
|
});
|
|
if (audioAlternative)
|
|
audioAlternative.addEventListener("click", function () {
|
|
emit(state, "refresh", { presentation: "audio" });
|
|
createChallenge(state, "audio");
|
|
});
|
|
|
|
root.addEventListener("captcha-reset", function () {
|
|
emit(state, "refresh");
|
|
createChallenge(state, state.config.presentation);
|
|
});
|
|
|
|
if (state.form) {
|
|
state.submitGuard = function () {
|
|
return passesSubmitGuard(state);
|
|
};
|
|
state.form.__wireSubmitGuards = state.form.__wireSubmitGuards || [];
|
|
state.form.__wireSubmitGuards.push(state.submitGuard);
|
|
state.formSubmitListener = function (event) {
|
|
onFormSubmit(state, event);
|
|
};
|
|
state.formSuccessListener = function () {
|
|
createChallenge(state, state.config.presentation);
|
|
};
|
|
state.formErrorListener = function (event) {
|
|
// Verified response tokens are single-use. The receiving API may
|
|
// consume one before another form field fails, so retry with a fresh
|
|
// challenge instead of reusing an already-consumed token.
|
|
var detail = event && event.detail ? event.detail : {};
|
|
var captchaFailed =
|
|
detail.code === "missing-input" ||
|
|
detail.code === "invalid-input" ||
|
|
detail.code === "expired" ||
|
|
detail.code === "already-used" ||
|
|
detail.code === "action-mismatch" ||
|
|
detail.code === "hostname-mismatch" ||
|
|
detail.code === "session-mismatch";
|
|
if (state.config.resetOnError || captchaFailed)
|
|
createChallenge(state, state.config.presentation);
|
|
};
|
|
state.form.addEventListener("submit", state.formSubmitListener, true);
|
|
state.form.addEventListener("wire:success", state.formSuccessListener);
|
|
state.form.addEventListener("wire:error", state.formErrorListener);
|
|
}
|
|
|
|
updateNotRobotState(state);
|
|
updateControls(state);
|
|
if (state.config.disabled) setStatus(state, "disabled", "");
|
|
else if (state.config.autoLoad) createChallenge(state, state.config.presentation);
|
|
}
|
|
|
|
function scan(scope) {
|
|
var host = scope || document;
|
|
if (host instanceof Element && host.matches("[data-wrn-captcha]")) initialize(host);
|
|
host.querySelectorAll("[data-wrn-captcha]").forEach(initialize);
|
|
}
|
|
|
|
function cleanupRoot(root) {
|
|
var state = states.get(root);
|
|
if (!state) return;
|
|
if (state.timer) clearInterval(state.timer);
|
|
if (state.notRobotTimer) clearTimeout(state.notRobotTimer);
|
|
if (state.audioPlayer) {
|
|
try {
|
|
state.audioPlayer.pause();
|
|
state.audioPlayer.src = "";
|
|
} catch {
|
|
// Best-effort cleanup for browser audio implementations.
|
|
}
|
|
}
|
|
if (state.form && state.formSubmitListener) {
|
|
state.form.removeEventListener("submit", state.formSubmitListener, true);
|
|
}
|
|
if (state.form && state.form.__wireSubmitGuards && state.submitGuard) {
|
|
state.form.__wireSubmitGuards = state.form.__wireSubmitGuards.filter(function (guard) {
|
|
return guard !== state.submitGuard;
|
|
});
|
|
}
|
|
if (state.form && state.formSuccessListener) {
|
|
state.form.removeEventListener("wire:success", state.formSuccessListener);
|
|
}
|
|
if (state.form && state.formErrorListener) {
|
|
state.form.removeEventListener("wire:error", state.formErrorListener);
|
|
}
|
|
try {
|
|
if (state.externalApi && state.externalWidgetId != null) {
|
|
if (typeof state.externalApi.remove === "function")
|
|
state.externalApi.remove(state.externalWidgetId);
|
|
else if (typeof state.externalApi.reset === "function")
|
|
state.externalApi.reset(state.externalWidgetId);
|
|
}
|
|
} catch {
|
|
// Third-party providers may reject cleanup after navigation.
|
|
}
|
|
states.delete(root);
|
|
}
|
|
|
|
function unmount(scope) {
|
|
var host = scope || document;
|
|
var roots = [];
|
|
if (host instanceof Element && host.matches("[data-wrn-captcha]")) roots.push(host);
|
|
if (host.querySelectorAll)
|
|
host.querySelectorAll("[data-wrn-captcha]").forEach(function (root) {
|
|
roots.push(root);
|
|
});
|
|
roots.forEach(cleanupRoot);
|
|
}
|
|
|
|
var runtime = {
|
|
scan: scan,
|
|
mount: scan,
|
|
unmount: unmount,
|
|
reset: function (element) {
|
|
var root = typeof element === "string" ? document.querySelector(element) : element;
|
|
var state = root ? states.get(root) : null;
|
|
if (state) createChallenge(state, state.config.presentation);
|
|
},
|
|
verify: function (element) {
|
|
var root = typeof element === "string" ? document.querySelector(element) : element;
|
|
var state = root ? states.get(root) : null;
|
|
return state ? verify(state) : Promise.resolve();
|
|
},
|
|
};
|
|
|
|
window[RUNTIME_KEY] = runtime;
|
|
window.__wrnexusRuntimes = window.__wrnexusRuntimes || {};
|
|
window.__wrnexusRuntimes.captcha = { mount: scan, unmount: unmount };
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener(
|
|
"DOMContentLoaded",
|
|
function () {
|
|
scan(document);
|
|
},
|
|
{ once: true },
|
|
);
|
|
} else {
|
|
scan(document);
|
|
}
|
|
|
|
new MutationObserver(function (records) {
|
|
records.forEach(function (record) {
|
|
record.addedNodes.forEach(function (node) {
|
|
if (node.nodeType === 1) scan(node);
|
|
});
|
|
});
|
|
}).observe(document.documentElement, { childList: true, subtree: true });
|
|
})();
|