PageSourceSearch

https://www.theadviser.com.au/media/plg_system_turnstile/js/turnstile.js?a9f118

js theadviser.com.au collected 2026-09-24 17:35:05 UTC 50,366 bytes, 1,271 lines download raw bytes

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.