1(function () { 2 'use strict'; 3 4 const FIELD_NAME = 'captcha_turnstile_response'; 5 const SKIP_ATTR = 'data-turnstile-skip'; 6 const ACTION_ATTR = 'data-turnstile-action'; 7 const DEBUG_LS_KEY = 'turnstileGateDebug'; 8 const DEBUG_STORE_CAP = 80; 9 const PANEL_ID = 'turnstile-gate-debug-panel'; 10 const LOADING_OVERLAY_ID = 'momentum-turnstile-gate-loading'; 11 const LOADING_OVERLAY_MESSAGE = 'Verifying your request. Please wait.'; 12 const API_SCRIPT_MAX_ATTEMPTS = 3; 13 const API_SCRIPT_RETRY_DELAY_MS = 400; 14 const API_SCRIPT_EXHAUSTED_MSG = 'Cloudflare Turnstile could not be loaded after several attempts. Please try submitting the form again.'; 15 const API_SCRIPT_ERROR_NAME = 'TurnstileApiScriptError'; 16 const AJAX_VALIDATION_ERROR_NAME = 'TurnstileAjaxValidationError'; 17 const TOKEN_MAX_ATTEMPTS = 3; 18 const TOKEN_RETRY_DELAY_MS = 400; 19 const TURNSTILE_ACTION_MAX_LEN = 32; 20 const ACTION_PATH_PREFIX_DEFAULT = 'TA-'; 21 const EVENT_GATE_ERROR = 'momentum:turnstile-gate-error'; 22 const PREFLIGHT_REFERER_HEADER = 'Referer'; 23 24 /** 25 * Normalizes a string for Turnstile `action`: only `A-Z`, `a-z`, `0-9`, `_`, `-` (casing preserved). 26 * 27 * @param {string} value 28 * 29 * @returns {string} 30 */ 31 function normalizeTurnstileActionPart(value) { 32 let part = String(value).replace(/[^a-zA-Z0-9_-]+/g, '_'); 33 part = part.replace(/_+/g, '_').replace(/^_+|_+$/g, ''); 34 return part; 35 } 36 37 /** 38 * Truncates to Cloudflare's 32-character `action` limit; trims a trailing `_` if cut mid-segment. 39 * 40 * @param {string} action 41 * 42 * @returns {string} 43 */ 44 function truncateTurnstileAction(action) { 45 if (action.length <= TURNSTILE_ACTION_MAX_LEN) { 46 return action; 47 } 48 49 let truncated = action.slice(0, TURNSTILE_ACTION_MAX_LEN); 50 truncated = truncated.replace(/_+$/, ''); 51 52 if (truncated === '') { 53 return 'unknown'; 54 } 55 56 return truncated; 57 } 58 59 /** 60 * Sanitizes and truncates an explicit or derived Turnstile `action` value. 61 * 62 * @param {string} value 63 * 64 * @returns {string} 65 */ 66 function formatTurnstileAction(value) { 67 const normalized = normalizeTurnstileActionPart(value); 68 if (normalized === '') { 69 return truncateTurnstileAction('unknown'); 70 } 71 72 return truncateTurnstileAction(normalized); 73 } 74 75 /** 76 * True when `exception` is an Error-like object with the given `name`. 77 * 78 * @param {unknown} exception 79 * @param {string} name 80 * 81 * @returns {boolean} 82 */ 83 function isNamedTurnstileError(exception, name) { 84 return 1 === 1 85 && exception !== null 86 && typeof exception === 'object' 87 && exception.name === name; 88 } 89 90 /** 91 * Builds a thrown Error with a stable `name` for gate catch / retry branching. 92 * 93 * @param {string} name 94 * @param {string} message 95 * 96 * @returns {Error} 97 */ 98 function createNamedTurnstileError(name, message) { 99 const error = new Error(message); 100 error.name = name; 101 return error; 102 } 103 104 /** 105 * Builds a slug from `location.pathname` (leading slash removed; `/` and other separators â `_`). 106 * 107 * @param {string} pathname 108 * 109 * @returns {string} 110 */ 111 function pathnameToTurnstileActionSlug(pathname) { 112 let path = (typeof pathname === 'string') ? pathname : ''; 113 if (path.charAt(0) === '/') { 114 path = path.slice(1); 115 } 116 117 const slug = normalizeTurnstileActionPart(path); 118 if (slug === '') { 119 return 'home'; 120 } 121 122 return slug; 123 } 124 125 /** 126 * Prefix for pathname-derived actions (overridable via `momentum.turnstile.actionPrefix`). 127 * 128 * @param {MomentumTurnstileOptions | null} config 129 * 130 * @returns {string} 131 */ 132 function getTurnstileActionPrefix(config) { 133 if (config !== null && typeof config === 'object' && typeof config.actionPrefix === 'string' && config.actionPrefix !== '') { 134 return normalizeTurnstileActionPart(config.actionPrefix); 135 } 136 137 return normalizeTurnstileActionPart(ACTION_PATH_PREFIX_DEFAULT); 138 } 139 140 /** 141 * Resolves Turnstile `action`: explicit **`data-turnstile-action`**, else **`actionPrefix` + pathname slug** (truncated to 32 chars). 142 * 143 * @param {HTMLFormElement} form 144 * @param {MomentumTurnstileOptions | null} config 145 * 146 * @returns {string} 147 */ 148 function resolveTurnstileAction(form, config) { 149 const actionRaw = form.getAttribute(ACTION_ATTR); 150 if (typeof actionRaw === 'string' && actionRaw.trim() !== '') { 151 return formatTurnstileAction(actionRaw.trim()); 152 } 153 154 const prefix = getTurnstileActionPrefix(config); 155 const pathSlug = pathnameToTurnstileActionSlug(window.location.pathname); 156 157 return formatTurnstileAction(prefix + pathSlug); 158 } 159 160 /** 161 * Options injected by PHP via `Joomla.getOptions('momentum.turnstile')`. 162 * 163 * @typedef {Object} MomentumTurnstileOptions 164 * 165 * @property {string} siteKey Cloudflare Turnstile site key (public). 166 * @property {boolean} [debug] When true, debug logging and UI are enabled (also see localStorage). 167 * @property {string} [size] Widget size passed to `turnstile.render` (e.g. `normal`, `flexible`). 168 * @property {string} [theme] Widget theme passed to `turnstile.render` (e.g. `auto`, `light`, `dark`). 169 * @property {string} [actionPrefix] Prepended to pathname-derived `action` when the form has no **`data-turnstile-action`** (default **`TA-`**). 170 * 171 * Optional on a **`<form>`**: **`data-turnstile-action`** â non-empty value is passed to Turnstile **`action`** (sanitized, max 32 chars). When absent or blank, the gate uses **`actionPrefix` + pathname slug** from **`window.location.pathname`**. 172 */ 173 174 /** 175 * Intercepts eligible form submits in the capture phase, obtains a Turnstile token with explicit rendering, 176 * POSTs it to **`com_ajax`** (**`ajaxturnstile`**) for the same server check as the legacy captcha helper, injects 177 * `captcha_turnstile_response`, then re-dispatches submit so other listeners and native validation run. The server 178 * stores a one-time session flag so the real form POST does not call Cloudflare **siteverify** twice on the same token. 179 * Each token uses a one-off widget (see {@link TurnstileFormGate#obtainToken}), not a persistent `cf-turnstile` / `turnstile.reset` flow. 180 * 181 * @see ../README.md 182 */ 183 class TurnstileFormGate { 184 /** 185 * Constructs a new TurnstileFormGate instance. 186 * 187 * @returns {void} 188 */ 189 constructor() { 190 /** 191 * Forms that already have a Turnstile token for this navigation cycle: the capture handler returns early so 192 * the synthetic `requestSubmit` pass is not stopped. Cleared on a microtask after `requestSubmit` (see {@link TurnstileFormGate#scheduleClearBy
192pass}). 193 * 194 * @type {WeakSet<HTMLFormElement>} 195 */ 196 this.validatedForms = new WeakSet(); 197 198 /** 199 * `event.submitter` from the first (intercepted) submit, kept until the token is written or the run errors; 200 * passed to `form.requestSubmit(â¦)` so button semantics match the user action. 201 * 202 * @type {WeakMap<HTMLFormElement, HTMLElement | null>} 203 */ 204 this.pendingSubmitters = new WeakMap(); 205 206 /** 207 * When `true`, an async Turnstile run is in progress for that form; further submits are blocked to avoid 208 * duplicate widgets or races. Reset in the `finally` of {@link TurnstileFormGate#runInterceptedSubmit}. 209 * 210 * @type {WeakMap<HTMLFormElement, boolean>} 211 */ 212 this.inFlight = new WeakMap(); 213 214 /** 215 * Saved `disabled` / `readOnly` for each control while the gate runs; cleared in {@link TurnstileFormGate#unlockFormControlsAfterGate}. 216 * 217 * @type {WeakMap<HTMLFormElement, Array<{disabled: boolean, el: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLButtonElement, readOnly: boolean}>>} 218 */ 219 this.formControlSnapshots = new WeakMap(); 220 221 /** 222 * Single in-flight promise for injecting or awaiting Cloudflare `api.js?render=explicit` (including retries); 223 * `null` when idle. Concurrent callers await the same instance (see {@link TurnstileFormGate#loadTurnstileApiExplicit}). 224 * 225 * @type {Promise<void> | null} 226 */ 227 this.apiLoadPromise = null; 228 229 /** 230 * Debug-only ring buffer of structured rows (phase, detail, â¦), capped at `DEBUG_STORE_CAP` (see file top). Written 231 * only when {@link TurnstileFormGate#isDebug} is true; read by the floating panel and `turnstileGateDebug.log`. 232 * 233 * @type {Array<Object>} 234 */ 235 this.debugStore = []; 236 237 /** 238 * Nesting counter for {@link TurnstileFormGate#showLoadingOverlay} / {@link TurnstileFormGate#hideLoadingOverlay}. 239 * 240 * @type {number} 241 */ 242 this.loadingOverlayCount = 0; 243 244 /** 245 * Bound {@link TurnstileFormGate#onSubmitCapture} registered on `document` in capture phase in {@link TurnstileFormGate#initialise}. 246 * 247 * @type {(event: SubmitEvent) => void} 248 */ 249 this.onSubmitCapture = this.onSubmitCapture.bind(this); 250 } 251 252 /** 253 * Exposes `window.turnstileGateDebug` (toggle panel) and `window.turnstileGateDebug.log` (`console.table` of the debug store). 254 * When debug is off, the toggle prints a short hint to the console instead of opening the panel. 255 * 256 * @returns {void} 257 */ 258 attachDebugApi() { 259 const self = this; 260 261 window.turnstileGateDebug = function () { 262 if (self.isDebug() === false) { 263 if (typeof console !== 'undefined' && typeof console.info === 'function') { 264 console.info('Turnstile gate debug: set JDEBUG / momentum.turnstile.debug or localStorage.' + DEBUG_LS_KEY + '=1'); 265 } 266 return; 267 } 268 269 const existing = document.getElementById(PANEL_ID); 270 if (existing !== null) { 271 self.destroyDebugPanel(); 272 } else { 273 self.renderDebugPanel(); 274 } 275 }; 276 277 window.turnstileGateDebug.log = function () { 278 if (self.debugStore.length === 0) { 279 if (typeof console !== 'undefined' && typeof console.info === 'function') { 280 console.info('Turnstile gate debug store empty.'); 281 } 282 return; 283 } 284 285 if (typeof console !== 'undefined' && typeof console.table === 'function') { 286 console.table(self.debugStore); 287 } 288 }; 289 } 290 291 /** 292 * Whether verbose debug is enabled: `localStorage.turnstileGateDebug === '1'` or `momentum.turnstile.debug === true`. 293 * 294 * @returns {boolean} 295 */ 296 isDebug() { 297 if (typeof window.localStorage !== 'undefined' && window.localStorage.getItem(DEBUG_LS_KEY) === '1') { 298 return true; 299 } 300 301 const options = this.getTurnstileOptions(); 302 if (options !== null && options.debug === true) { 303 return true; 304 } 305 306 return false;
307 } 308 309 /** 310 * Appends one structured row to the in-memory debug store (capped), when {@link TurnstileFormGate#isDebug} is true. 311 * 312 * @param {Object} entry Expected keys: `phase` (string), optional `time` (number), optional `detail` (any, stringified if not a string). 313 * 314 * @returns {void} 315 */ 316 debugPush(entry) { 317 if (this.isDebug() === false) { 318 return; 319 } 320 321 const row = { detail: '', phase: '', time: Date.now() }; 322 if (typeof entry.time === 'number') { 323 row.time = entry.time; 324 } 325 326 if (typeof entry.phase === 'string') { 327 row.phase = entry.phase; 328 } 329 330 if (entry.detail !== undefined && entry.detail !== null) { 331 row.detail = (typeof entry.detail === 'string') ? entry.detail : JSON.stringify(entry.detail); 332 } 333 334 this.debugStore.push(row); 335 if (this.debugStore.length > DEBUG_STORE_CAP) { 336 this.debugStore.shift(); 337 } 338 } 339 340 /** 341 * Removes the floating debug panel from the DOM if present. 342 * 343 * @returns {void} 344 */ 345 destroyDebugPanel() { 346 const existing = document.getElementById(PANEL_ID); 347 if (existing !== null && existing.parentNode !== null) { 348 existing.parentNode.removeChild(existing); 349 } 350 } 351 352 /** 353 * Builds and appends a simple fixed-position panel listing recent {@link TurnstileFormGate#debugStore} rows (newest first). 354 * 355 * @returns {void} 356 */ 357 renderDebugPanel() { 358 this.destroyDebugPanel(); 359 360 const panel = document.createElement('div'); 361 panel.id = PANEL_ID; 362 panel.setAttribute('role', 'log'); 363 panel.style.cssText = [ 364 'background:#1e1e2e', 365 'border:1px solid #45475a', 366 'border-radius:8px', 367 'bottom:12px', 368 'box-shadow:0 8px 32px rgba(0,0,0,0.5)', 369 'color:#cdd6f4', 370 'font-family:monospace', 371 'font-size:12px', 372 'line-height:1.5', 373 'max-height:70vh', 374 'max-width:420px', 375 'overflow:auto', 376 'padding:10px 12px', 377 'position:fixed', 378 'right:12px', 379 'z-index:999999' 380 ].join(';'); 381 382 const title = document.createElement('div'); 383 title.style.cssText = 'font-weight:bold;margin-bottom:8px;color:#89b4fa'; 384 title.textContent = 'Turnstile gate debug'; 385 panel.appendChild(title); 386 387 if (this.debugStore.length === 0) { 388 const empty = document.createElement('div'); 389 empty.style.color = '#6c7086'; 390 empty.textContent = 'No entries yet. Submit a form or enable JDEBUG / localStorage turnstileGateDebug=1'; 391 panel.appendChild(empty); 392 } else { 393 this.debugStore.slice().reverse().forEach(function (row) { 394 const line = document.createElement('div'); 395 line.style.cssText = 'border-bottom:1px solid #313244;padding:4px 0'; 396 line.textContent = String(row.time) + ' | ' + row.phase + ' | ' + row.detail; 397 panel.appendChild(line); 398 }); 399 } 400 401 const close = document.createElement('button'); 402 close.type = 'button'; 403 close.textContent = 'Close'; 404 close.style.cssText = 'margin-top:10px;cursor:pointer'; 405 close.addEventListener('click', this.destroyDebugPanel.bind(this)); 406 panel.appendChild(close); 407 408 document.body.appendChild(panel); 409 } 410 411 /** 412 * Returns Turnstile options from `Joomla.getOptions('momentum.turnstile')` or the legacy `window.__cfTurnstileSiteKey` fallback. 413 * When no site key is configured, returns `null` and the gate does not intercept submits. 414 * 415 * @returns {MomentumTurnstileOptions | null} 416 */ 417 getTurnstileOptions() { 418 if (typeof Joomla !== 'undefined' && typeof Joomla.getOptions === 'function') { 419 const options = Joomla.getOptions('momentum.turnstile'); 420 if (options !== null && typeof options === 'object' && typeof options.siteKey === 'string' && options.siteKey !== '') { 421 return options; 422 } 423 } 424 425 if (typeof window.__cfTurnstileSiteKey === 'string' && window.__cfTurnstileSiteKey !== '') { 426 return { siteKey: window.__cfTurnstileSiteKey }; 427 } 428 429 return null; 430 } 431 432 /** 433 * Disables every **`input`** (except **`type="hidden"`**), **`textarea`**, **`select`**, and **`button`** in the form 434 * **`disabled`** / **`readOnly`** so {@link TurnstileFormGate#unlockFormControlsAfterGate} can restore. No-op if a snapshot already exists. 435 * 436 * @param {HTMLFormElement} form 437 * 438 * @returns {void} 439 */ 440 lockFormControlsForGate(form) { 441 if (this.formControlSnapshots.has(form) === true) { 442 return; 443 } 444 445 const snapshots = []; 446 const elements = form.elements; 447 let i = 0; 448 449 for (i = 0; i < elements.length; i++) { 450 const el = elements[i]; 451 452 if (el instanceof HTMLInputElement === false && el instanceof HTMLTextAreaElement === false && el instanceof HTMLSelectElement === false && el instanceof HTMLButtonElement === false) { 453 continue; 454 } 455 456 if (el instanceof HTMLInputElement === true && el.type === 'hidden') { 457 continue; 458 } 459 460 snapshots.push({ 461 disabled : el.disabled, 462 el : el, 463 readOnly : (el instanceof HTMLInputElement === true || el instanceof HTMLTextAreaElement === true) ? el.readOnly : false 464 }); 465 466 el.disabled = true; 467 if (el instanceof HTMLInputElement === true || el instanceof HTMLTextAreaElement === true) { 468 el.readOnly = true; 469 } 470 } 471 472 this.formControlSnapshots.set(form, snapshots); 473 this.debugPush({ detail: { count: snapshots.length, form: form.getAttribute('id') || '(no id)' }, phase: 'form_controls_lock' }); 474 } 475 476 /** 477 * Restores **`disabled`** / **`readOnly`** from {@link TurnstileFormGate#formControlSnapshots}; removes the snapshot. 478 * 479 * @param {HTMLFormElement} form 480 * 481 * @returns {void} 482 */ 483 unlockFormControlsAfterGate(form) { 484 const snapshots = this.formControlSnapshots.get(form); 485 if (snapshots === undefined) { 486 return; 487 } 488 489 let i = 0; 490 for (i = 0; i < snapshots.length; i++) {
491 const row = snapshots[i]; 492 row.el.disabled = row.disabled; 493 494 if (row.el instanceof HTMLInputElement === true || row.el instanceof HTMLTextAreaElement === true) { 495 row.el.readOnly = row.readOnly; 496 } 497 } 498 499 this.formControlSnapshots.delete(form); 500 this.debugPush({ detail: { form: form.getAttribute('id') || '(no id)' }, phase: 'form_controls_unlock' }); 501 } 502 503 /** 504 * Creates the full-page loading overlay (backdrop + spinner + message) once per document. 505 * 506 * @returns {HTMLElement} 507 */ 508 ensureLoadingOverlayElement() { 509 let overlay = document.getElementById(LOADING_OVERLAY_ID); 510 if (overlay !== null) { 511 return overlay; 512 } 513 514 overlay = document.createElement('div'); 515 overlay.id = LOADING_OVERLAY_ID; 516 overlay.hidden = true; 517 overlay.setAttribute('aria-hidden', 'true'); 518 overlay.setAttribute('aria-live', 'polite'); 519 overlay.setAttribute('role', 'status'); 520 overlay.style.cssText = [ 521 'align-items:center', 522 'background:rgba(15, 23, 42, 0.72)', 523 'display:flex', 524 'inset:0', 525 'justify-content:center', 526 'position:fixed', 527 'z-index:999998' 528 ].join(';'); 529 530 const card = document.createElement('div'); 531 card.style.cssText = [ 532 'align-items:center', 533 'background:#ffffff', 534 'border-radius:8px', 535 'box-shadow:0 12px 40px rgba(0, 0, 0, 0.25)', 536 'color:#0f172a', 537 'display:flex', 538 'flex-direction:column', 539 'font-family:system-ui, -apple-system, Segoe UI, Roboto, sans-serif', 540 'font-size:16px', 541 'gap:16px', 542 'line-height:1.4', 543 'max-width:min(90vw, 360px)', 544 'padding:24px 28px', 545 'text-align:center' 546 ].join(';'); 547 548 const spinner = document.createElement('div'); 549 spinner.setAttribute('aria-hidden', 'true'); 550 spinner.style.cssText = [ 551 'animation:momentum-turnstile-gate-spin 0.75s linear infinite', 552 'border:3px solid #e2e8f0', 553 'border-radius:50%', 554 'border-top-color:#2563eb', 555 'box-sizing:border-box', 556 'height:40px', 557 'width:40px' 558 ].join(';'); 559 560 if (document.getElementById('momentum-turnstile-gate-loading-style') === null) { 561 const style = document.createElement('style'); 562 style.id = 'momentum-turnstile-gate-loading-style'; 563 style.textContent = '@keyframes momentum-turnstile-gate-spin { to { transform: rotate(360deg); } }'; 564 document.head.appendChild(style); 565 } 566 567 const message = document.createElement('p'); 568 message.style.cssText = 'margin:0;'; 569 message.textContent = LOADING_OVERLAY_MESSAGE; 570 571 card.appendChild(spinner); 572 card.appendChild(message); 573 overlay.appendChild(card); 574 document.body.appendChild(overlay); 575 576 return overlay; 577 } 578 579 /** 580 * Shows the verifying overlay (ref-counted for overlapping gated submits). 581 * 582 * @returns {void} 583 */ 584 showLoadingOverlay() { 585 this.loadingOverlayCount = this.loadingOverlayCount + 1; 586 const overlay = this.ensureLoadingOverlayElement(); 587 overlay.hidden = false; 588 overlay.setAttribute('aria-hidden', 'false'); 589 overlay.style.setProperty('display', 'flex', 'important'); 590 this.debugPush({ phase: 'loading_overlay_show' }); 591 } 592 593 /** 594 * Hides the verifying overlay when the ref count returns to zero. 595 * 596 * @returns {void} 597 */ 598 hideLoadingOverlay() { 599 if (this.loadingOverlayCount > 0) { 600 this.loadingOverlayCount = this.loadingOverlayCount - 1; 601 } 602 603 if (this.loadingOverlayCount !== 0) { 604 return; 605 } 606
607 const overlay = document.getElementById(LOADING_OVERLAY_ID); 608 if (overlay !== null) { 609 overlay.hidden = true; 610 overlay.setAttribute('aria-hidden', 'true'); 611 overlay.style.setProperty('display', 'none', 'important'); 612 } 613 614 this.debugPush({ phase: 'loading_overlay_hide' }); 615 } 616 617 /** 618 * Registers the capture-phase `submit` listener on `document` so the gate runs before bubble listeners on forms. 619 * 620 * @returns {void} 621 */ 622 initialise() { 623 document.addEventListener('submit', this.onSubmitCapture, true); 624 } 625 626 /** 627 * Capture-phase `submit` handler: filters forms, stops duplicate in-flight submits, then `preventDefault` + 628 * `stopImmediatePropagation` before delegating async work to {@link TurnstileFormGate#runInterceptedSubmit}. 629 * On the second pass (after token), `validatedForms` contains the form and this method returns without stopping propagation. 630 * 631 * @param {SubmitEvent} event 632 * 633 * @returns {void} 634 */ 635 onSubmitCapture(event) { 636 const form = event.target; 637 if (form instanceof HTMLFormElement === false) { 638 return; 639 } 640 641 if (form.method.toLowerCase() === 'get') { 642 return; 643 } 644 645 if (form.hasAttribute(SKIP_ATTR) === true) { 646 return; 647 } 648 649 if (this.validatedForms.has(form) === true) { 650 return; 651 } 652 653 const options = this.getTurnstileOptions(); 654 if (options === null) { 655 return; 656 } 657 658 if (this.inFlight.get(form) === true) { 659 event.preventDefault(); 660 event.stopImmediatePropagation(); 661 662 this.debugPush({ detail: 'duplicate_submit_blocked', phase: 'in_flight' }); 663 return; 664 } 665 666 event.preventDefault(); 667 event.stopImmediatePropagation(); 668 669 this.inFlight.set(form, true); 670 const submitter = (event.submitter instanceof HTMLElement) ? event.submitter : null; 671 this.pendingSubmitters.set(form, submitter); 672 673 this.lockFormControlsForGate(form); 674 this.showLoadingOverlay(); 675 676 this.debugPush({ detail: { form: form.getAttribute('id') || '(no id)' }, phase: 'intercept' }); 677 678 const self = this; 679 void this.runInterceptedSubmit(form, submitter, options).catch(function () { 680 /* Error surfaced via alert, CustomEvent, and rejected promise; avoid unhandled rejection. */ 681 self.debugPush({ detail: { form: form.getAttribute('id') || '(no id)' }, phase: 'gate_error_handled' }); 682 }); 683 } 684 685 /** 686 * Notifies integrators that the gate failed (after unlock). Listen on the form, or await {@link TurnstileFormGate#runInterceptedSubmit}. 687 * 688 * @param {HTMLFormElement} form 689 * @param {unknown} exception 690 * 691 * @returns {void} 692 */ 693 dispatchGateError(form, exception) { 694 const message = (exception !== null && typeof exception === 'object' && typeof exception.message === 'string') 695 ? exception.message 696 : String(exception); 697 const name = (exception !== null && typeof exception === 'object' && typeof exception.name === 'string') 698 ? exception.name 699 : 'Error'; 700 701 form.dispatchEvent(new CustomEvent(EVENT_GATE_ERROR, { 702 bubbles : true, 703 detail : { 704 error : exception, 705 message : message, 706 name : name 707 } 708 })); 709 } 710 711 /** 712 * Awaits a Turnstile token (with retries), validates it via **`com_ajax`** (**`ajaxturnstile`**), writes it to the hidden 713 * `captcha_turnstile_response` field, adds the form to `validatedForms`, unlocks form controls, then calls 714 * `requestSubmit` with the original submitter so other handlers run, then schedules clearing the bypass flag. 715 * On error, restores controls and logs. Always clears `inFlight` in `finally`. 716 * 717 * @param {HTMLFormElement} form Form being submitted. 718 * @param {HTMLElement | null} submitter `event.submitter` from the first pass, or `null`. 719 * @param {MomentumTurnstileOptions} config Site key and widget options. 720 * 721 * @returns {Promise<void>} 722 */ 723 async runInterceptedSubmit(form, submitter, config) { 724 try { 725 this.debugPush({ detail: { form: form.getAttribute('id') || '(no id)' }, phase: 'turnstile_start' }); 726 727 const token = await this.obtainTokenWithRetries(form, config); 728 729 this.debugPush({ detail: { form: form.getAttribute('id') || '(no id)' }, phase: 'ajax_preflight_start' }); 730 await this.validateTurnstileTokenAjax(token); 731 this.debugPush({ detail: { form: form.getAttribute('id') || '(no id)' }, phase: 'ajax_preflight_ok' }); 732 733 this.ensureResponseInput(form).value = token; 734 this.validatedForms.add(form); 735 this.pendingSubmitters.delete(form); 736 this.debugPush({ detail: { tokenLen: token.length }, phase: 'token_ready' }); 737 738 this.unlockFormControlsAfterGate(form); 739 740 if (typeof form.requestSubmit === 'function') { 741 form.requestSubmit((submitter !== null) ? submitter : undefined); 742 } else { 743 form.submit(); 744 } 745
746 this.scheduleClearBypass(form); 747 } catch (exception) { 748 this.pendingSubmitters.delete(form); 749 this.unlockFormControlsAfterGate(form); 750 this.debugPush({ detail: exception.message || String(exception), phase: 'error' }); 751 752 const isAjaxValidationError = isNamedTurnstileError(exception, AJAX_VALIDATION_ERROR_NAME); 753 const isApiScriptError = isNamedTurnstileError(exception, API_SCRIPT_ERROR_NAME); 754 755 if (typeof console !== 'undefined') { 756 if (isAjaxValidationError === true && typeof console.warn === 'function') { 757 console.warn('Turnstile gate:', exception); 758 } else if (isAjaxValidationError === false && typeof console.error === 'function') { 759 console.error('Turnstile gate:', exception); 760 } 761 } 762 763 /* AJAX preflight already alerted. Script load / token failures alert once here. */ 764 if (isAjaxValidationError === false && typeof window.alert === 'function') { 765 if (isApiScriptError === true) { 766 window.alert(API_SCRIPT_EXHAUSTED_MSG); 767 } else { 768 window.alert('An error occurred while obtaining a Turnstile token. Please try again.'); 769 } 770 } 771 772 this.dispatchGateError(form, exception); 773 throw exception; 774 } finally { 775 this.inFlight.set(form, false); 776 this.hideLoadingOverlay(); 777 } 778 } 779 780 /** 781 * Waits for a specified number of milliseconds before resolving. 782 * 783 * @param {number} ms Milliseconds to wait. 784 * 785 * @returns {Promise<void>} 786 */ 787 static sleep(ms) { 788 return new Promise(function (resolve) { 789 window.setTimeout(resolve, ms); 790 }); 791 } 792 793 /** 794 * Request headers for the Turnstile preflight POST. Uses **`X-Referer`** (browsers forbid setting **`Referer`** from `fetch`). 795 * 796 * @returns {Record<string, string>} 797 */ 798 static buildTurnstilePreflightRequestHeaders() { 799 const headers = {}; 800 801 if (typeof window.location !== 'undefined' && typeof window.location.origin === 'string' && window.location.origin !== '') { 802 headers[PREFLIGHT_REFERER_HEADER] = window.location.origin; 803 } 804 805 return headers; 806 } 807 808 /** 809 * Builds the **`com_ajax`** URL for **`ajaxturnstile`** (JSON), matching **`media/system/js/keepalive.js`** root handling and appending the Joomla CSRF query param when available. 810 * 811 * @returns {string} 812 */ 813 static buildComAjaxTurnstileUrl() { 814 const params = new URLSearchParams({ 815 format : 'json', 816 option : 'com_ajax', 817 plugin : 'ajaxturnstile' 818 }); 819 820 if (typeof Joomla !== 'undefined' && typeof Joomla.getOptions === 'function') { 821 const tokenName = Joomla.getOptions('csrf.token', ''); 822 823 if (typeof tokenName === 'string' && tokenName !== '') { 824 params.append(tokenName, '1'); 825 } 826 827 const paths = Joomla.getOptions('system.paths'); 828 829 if (paths !== null && typeof paths === 'object' && typeof paths.root === 'string' && paths.root !== '') { 830 const root = paths.root.replace(/\/$/, ''); 831 832 return root + '/index.php?' + params.toString(); 833 } 834 } 835 836 return '/index.php?' + params.toString(); 837 } 838 839 /** 840 * POSTs the token to **`PlgAjaxAjaxturnstile`** (same contract as `plugins/captcha/turnstile/turnstile.js`). On failure, shows the legacy alert, calls **`window.turnstile.reset`** when defined, then throws an **`Error`** whose **`name`** is **`TurnstileAjaxValidationError`**. 841 * 842 * @param {string} token Turnstile response token. 843 * 844 * @returns {Promise<Object>} 845 */ 846 async validateTurnstileTokenAjax(token) { 847 const failPreflight = function (technicalMessage) { 848 if (typeof window.alert === 'function') { 849 window.alert('An error has occurred while submitting your request. Please try submitting the form again after a few seconds.'); 850 } 851 852 if (typeof window.turnstile !== 'undefined' && window.turnstile !== null && typeof window.turnstile.reset === 'function') { 853 try { 854 window.turnstile.reset(); 855 } catch (resetException) { 856 /* ignore */ 857 } 858 } 859 860 const error = createNamedTurnstileError(AJAX_VALIDATION_ERROR_NAME, technicalMessage); 861 throw error; 862 }; 863 864 const formData = new FormData(); 865 formData.append(FIELD_NAME, token); 866 867 const preflightHeaders = TurnstileFormGate.buildTurnstilePreflightRequestHeaders(); 868 const fetchOptions = { 869 body : formData, 870 credentials : 'same-origin', 871 method : 'POST', 872 referrerPolicy : 'origin' 873 }; 874 875 if (Object.keys(preflightHeaders).length > 0) { 876 fetchOptions.headers = preflightHeaders; 877 } 878 879 const response = await fetch(TurnstileFormGate.buildComAjaxTurnstileUrl(), fetchOptions); 880 881 if (response.ok === false) { 882 failPreflight('Turnstile AJAX Validation HTTP ' + String(response.status)); 883 } 884 885 let payload = null; 886 887 try { 888 payload = await response.json(); 889 } catch (parseException) { 890 failPreflight('Turnstile AJAX Validation: Invalid JSON.'); 891 } 892 893 if (payload === null || typeof payload !== 'object' || payload.result !== true) { 894 const message = (payload !== null && typeof payload === 'object' && typeof payload.message === 'string') 895 ? payload.message 896 : 'Turnstile validation failed.'; 897 898 failPreflight(message); 899 } 900 901 return payload; 902 } 903 904 /** 905 * Removes our injected Turnstile `api.js` tag so a retry can a
905ppend a fresh copy. 906 * 907 * @returns {void} 908 */ 909 removeMomentumTurnstileScript() { 910 const el = document.querySelector('script[data-momentum-turnstile-api]'); 911 if (el !== null) { 912 el.remove(); 913 } 914 } 915 916 /** 917 * Single attempt: wait for an existing `[data-momentum-turnstile-api]` script or inject one; resolves when `window.turnstile` is available. 918 * 919 * @returns {Promise<void>} 920 */ 921 loadTurnstileApiOnce() { 922 return new Promise(function (resolve, reject) { 923 if (typeof window.turnstile !== 'undefined' && window.turnstile !== null) { 924 resolve(); 925 return; 926 } 927 928 const existing = document.querySelector('script[data-momentum-turnstile-api]'); 929 if (existing !== null) { 930 if (typeof window.turnstile !== 'undefined' && window.turnstile !== null) { 931 resolve(); 932 return; 933 } 934 935 const onLoad = function () { 936 if (typeof window.turnstile !== 'undefined' && window.turnstile !== null) { 937 resolve(); 938 return; 939 } 940 reject(new Error('Turnstile script load error')); 941 }; 942 const onError = function () { 943 reject(new Error('Turnstile script load error')); 944 }; 945 existing.addEventListener('load', onLoad, { once: true }); 946 existing.addEventListener('error', onError, { once: true }); 947 return; 948 } 949 950 const script = document.createElement('script'); 951 script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; 952 script.defer = true; 953 script.referrerPolicy = 'no-referrer'; 954 script.dataset.momentumTurnstileApi = '1'; 955 script.addEventListener('load', function () { 956 if (typeof window.turnstile !== 'undefined' && window.turnstile !== null) { 957 resolve(); 958 return; 959 } 960 reject(new Error('Turnstile script load error')); 961 }, { once: true }); 962 script.addEventListener('error', function () { 963 reject(new Error('Turnstile script load error')); 964 }, { once: true }); 965 966 document.head.appendChild(script); 967 }); 968 } 969 970 /** 971 * Tries up to `API_SCRIPT_MAX_ATTEMPTS` times with backoff, then throws {@link API_SCRIPT_ERROR_NAME}. 972 * Alerting is left to {@link TurnstileFormGate#runInterceptedSubmit} so nested token retries do not 973 * show the same message multiple times. A later form submit can call this again and re-inject `api.js`. 974 * 975 * @returns {Promise<void>} 976 */ 977 async runApiLoadWithRetries() { 978 let lastError = new Error('Turnstile script load error'); 979 980 for (let attempt = 1; attempt <= API_SCRIPT_MAX_ATTEMPTS; attempt++) { 981 try { 982 await this.loadTurnstileApiOnce(); 983 984 if (typeof window.turnstile !== 'undefined' && window.turnstile !== null) { 985 return; 986 } 987 988 lastError = new Error('Turnstile global missing after script load'); 989 throw lastError; 990 } catch (exception) { 991 lastError = (exception instanceof Error === true) ? exception : new Error(String(exception)); 992 993 this.debugPush({ 994 detail : { 995 attempt : attempt, 996 message : lastError.message 997 }, 998 phase : 'api_script_error' 999 }); 1000 1001 this.removeMomentumTurnstileScript(); 1002 1003 if (attempt < API_SCRIPT_MAX_ATTEMPTS) { 1004 await TurnstileFormGate.sleep(API_SCRIPT_RETRY_DELAY_MS * attempt); 1005 } 1006 } 1007 } 1008 1009 throw createNamedTurnstileError(API_SCRIPT_ERROR_NAME, lastError.message || API_SCRIPT_EXHAUSTED_MSG); 1010 } 1011 1012 /** 1013 * Loads Cloudflare `api.js?render=explicit` with retries, or waits for an in-flight load. Resolves immediately if `window.turnstile` already exists. 1014 * 1015 * @returns {Promise<void>} 1016 */ 1017 async loadTurnstileApiExplicit() { 1018 if (typeof window.turnstile !== 'undefined' && window.turnstile !== null) { 1019 return; 1020 } 1021 1022 if (this.apiLoadPromise === null) { 1023 const self = this; 1024 this.apiLoadPromise = this.runApiLoadWithRetries().finally(function () { 1025 self.apiLoadPromise = null; 1026 }); 1027 } 1028 1029 await this.apiLoadPromise; 1030 } 1031 1032 /** 1033 * Tries {@link TurnstileFormGate#obtainToken} up to `TOKEN_MAX_ATTEMPTS` with linear backoff 1034 * (`TOKEN_RETRY_DELAY_MS * attempt`). Does **not** retry when `api.js` load already exhausted 1035 * (`TurnstileApiScriptError`) â further widget attempts cannot succeed without the API. 1036 * 1037 * @param {HTMLFormElement} form 1038 * @param {MomentumTurnstileOptions} config 1039 * 1040 * @returns {Promise<string>} 1041 */ 1042 async obtainTokenWithRetries(form, config) { 1043 let lastError = new Error('Turnstile error.'); 1044 1045 for (let attempt = 1; attempt <= TOKEN_MAX_ATTEMPTS; attempt++) { 1046 try { 1047 return await this.obtainToken(form, config); 1048 } catch (exception) { 1049 lastError = (exception instanceof Error === true) ? exception : new Error(String(exception)); 1050 1051 this.debugPush({ 1052 detail : { 1053 attempt : attempt, 1054 message : lastError.message 1055 }, 1056 phase : 'token_obtain_error' 1057 }); 1058 1059 if (isNamedTurnstileError(lastError, API_SCRIPT_ERROR_NAME) === true) { 1060 throw lastError; 1061 } 1062 1063 if (attempt < TOKEN_MAX_ATTEMPTS) { 1064 await TurnstileFormGate.sleep(TOKEN_RETRY_DELAY_MS * attempt); 1065 } 1066 } 1067 } 1068 1069 throw lastError; 1070 } 1071 1072 /** 1073 * For this submit only: appends a mount `div`, calls `turnstile.render` in explicit mode, then removes the widget 1074 * (`turnstile.remove`) and mount on success, error, or expiry. Each later submit that runs the gate repeats this 1075 * cycle (no persistent widget, no `turnstile.reset`). Tur
1075nstile **`action`** comes from {@link resolveTurnstileAction}. 1076 * 1077 * @param {HTMLFormElement} form 1078 * @param {MomentumTurnstileOptions} config 1079 * 1080 * @returns {Promise<string>} 1081 */ 1082 async obtainToken(form, config) { 1083 await this.loadTurnstileApiExplicit(); 1084 1085 const action = resolveTurnstileAction(form, config); 1086 1087 if (this.isDebug() === true) { 1088 this.debugPush({ detail: { action: action }, phase: 'turnstile_action' }); 1089 } 1090 1091 const token = await new Promise(function (resolve, reject) { 1092 const holder = document.createElement('div'); 1093 holder.className = 'momentum-turnstile-mount'; 1094 form.appendChild(holder); 1095 1096 let widgetId = null; 1097 1098 const renderOptions = { 1099 'callback' : function (token) { 1100 try { 1101 if (widgetId !== null) { 1102 window.turnstile.remove(widgetId); 1103 } 1104 } catch (exception) { 1105 /* ignore */ 1106 } 1107 holder.remove(); 1108 resolve(token); 1109 }, 1110 'error-callback' : function () { 1111 try { 1112 if (widgetId !== null) { 1113 window.turnstile.remove(widgetId); 1114 } 1115 } catch (exception) { 1116 /* ignore */ 1117 } 1118 holder.remove(); 1119 reject(new Error('Turnstile error.')); 1120 }, 1121 'expired-callback' : function () { 1122 try { 1123 if (widgetId !== null) { 1124 window.turnstile.remove(widgetId); 1125 } 1126 } catch (exception) { 1127 /* ignore */ 1128 } 1129 holder.remove(); 1130 reject(new Error('Turnstile expired.')); 1131 }, 1132 sitekey : config.siteKey, 1133 size : (config.size !== undefined) ? config.size : 'normal', 1134 theme : (config.theme !== undefined) ? config.theme : 'auto' 1135 }; 1136 1137 renderOptions.action = action; 1138 1139 try { 1140 widgetId = window.turnstile.render(holder, renderOptions); 1141 } catch (exception) { 1142 holder.remove(); 1143 reject((exception instanceof Error === true) ? exception : new Error(String(exception))); 1144 } 1145 }); 1146 1147 return token; 1148 } 1149 1150 /** 1151 * Ensures a hidden input named `captcha_turnstile_response` exists on the form (Joomla Turnstile plugin field name). 1152 * 1153 * @param {HTMLFormElement} form 1154 * 1155 * @returns {HTMLInputElement} 1156 */ 1157 ensureResponseInput(form) { 1158 let input = form.querySelector('input[name="' + FIELD_NAME + '"]'); 1159 1160 if (input === null) { 1161 input = document.createElement('input'); 1162 input.type = 'hidden'; 1163 input.name = FIELD_NAME; 1164 form.appendChild(input); 1165 } 1166 1167 return input; 1168 } 1169 1170 /** 1171 * Normalizes a form reference from an `HTMLFormElement` or jQuery wrapper. 1172 * 1173 * @param {HTMLFormElement | {0: HTMLFormElement, jquery: string} | null | undefined} form 1174 * 1175 * @returns {HTMLFormElement | null} 1176 */ 1177 resolveFormElement(form) { 1178 if (form instanceof HTMLFormElement === true) { 1179 return form; 1180 } 1181 1182 if (form !== null && typeof form === 'object' && typeof form.jquery === 'string' && form.length > 0 && form[0] instanceof HTMLFormElement === true) { 1183 return form[0]; 1184 } 1185 1186 return null; 1187 } 1188 1189 /** 1190 * Whether the global gate is configured on this page (`momentum.turnstile` site key present). 1191 * 1192 * @returns {boolean} 1193 */ 1194 isGateActive() { 1195 return this.getTurnstileOptions() !== null; 1196 } 1197 1198 /** 1199 * Reads `captcha_turnstile_response` from a form (empty string when missing or blank). 1200 * 1201 * @param {HTMLFormElement | {0: HTMLFormElement, jquery: string} | null | undefined} form 1202 * 1203 * @returns {string} 1204 */ 1205 getTokenFromForm(form) { 1206 const resolvedForm = this.resolveFormElement(form); 1207 if (resolvedForm === null) { 1208 return ''; 1209 } 1210 1211 const input = resolvedForm.querySelector('input[name="' + FIELD_NAME + '"]'); 1212 if (input === null || input.value === '') { 1213 return ''; 1214 } 1215 1216 return input.value; 1217 } 1218 1219 /** 1220 * AJAX / custom submit handlers should return early when this is `true` (gate active, token not yet written). 1221 * When the gate script is not loaded or Turnstile is disabled, always returns `false`. 1222 * 1223 * @param {HTMLFormElement | {0: HTMLFormElement, jquery: string} | null | undefined} form 1224 * 1225 * @returns {boolean} 1226 */ 1227 shouldWaitForGate(form) { 1228 if (this.isGateActive() === false) { 1229 return false;
1230 } 1231 1232 return this.getTokenFromForm(form) === ''; 1233 } 1234 1235 /** 1236 * After `requestSubmit` returns, removes the form from `validatedForms` on a microtask so a later real submit is gated again. 1237 * 1238 * @param {HTMLFormElement} form 1239 * 1240 * @returns {void} 1241 */ 1242 scheduleClearBypass(form) { 1243 queueMicrotask(function () { 1244 this.validatedForms.delete(form); 1245 }.bind(this)); 1246 } 1247 } 1248 1249 const gate = new TurnstileFormGate(); 1250 gate.attachDebugApi(); 1251 gate.initialise(); 1252 1253 window.MomentumTurnstileGate = { 1254 EVENT_ERROR : EVENT_GATE_ERROR, 1255 PREFLIGHT_REFERER_HEADER : PREFLIGHT_REFERER_HEADER, 1256 getTokenFromForm : gate.getTokenFromForm.bind(gate), 1257 isGateActive : gate.isGateActive.bind(gate), 1258 shouldWaitForGate : gate.shouldWaitForGate.bind(gate) 1259 }; 1260 1261 /** 1262 * Backward-compatible name used by pages that still expect `plugins/captcha/turnstile/turnstile.js`. 1263 * 1264 * @param {string} captchaTurnstileResponse Token from Turnstile. 1265 * 1266 * @returns {Promise<Object>} 1267 */ 1268 window.turnstileCaptchaValidation = function (captchaTurnstileResponse) { 1269 return gate.validateTurnstileTokenAjax(captchaTurnstileResponse); 1270 }; 1271})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.