PageSourceSearch

https://aacrdev.org/wp-content/plugins/cookie-notice/includes/modules/gravity-forms/recaptcha.js?ver=3.1.9

js aacrdev.org collected 2026-10-07 20:46:57 UTC 17,544 bytes, 437 lines download raw bytes

1( function( window, document, undefined ) {
2
3	'use strict';
4
5	/**
6	 * Gravity Forms reCAPTCHA release controller.
7	 *
8	 * gravity-forms.php has held the add-on's frontend bundle inert, because that bundle
9	 * captures window.grecaptcha by value the moment it runs. Our job is to run it at the
10	 * one point where that capture is useful: after Google's api.js has actually defined
11	 * grecaptcha, which only happens once the visitor's consent covers it.
12	 *
13	 * Until then the form cannot be submitted — a Google captcha cannot be satisfied
14	 * without contacting Google — so we say so plainly instead of letting the button
15	 * spin forever, which is what it did before this module existed.
16	 */
17
18	var HELD_SELECTOR = 'script[type="text/plain"][data-cn-gf-recaptcha-src]';
19	var NOTICE_CLASS  = 'cn-gf-recaptcha-notice';
20	var POLL_INTERVAL = 200;
21	var POLL_TIMEOUT  = 20000;
22
23	/** Gravity Forms' own data-submission-type value for a real submit. See isSubmitControl(). */
24	var SUBMISSION_TYPE_SUBMIT = 'submit';
25
26	/** Gravity Forms controls that move around a form rather than submitting it. */
27	var NAV_SELECTOR = '.gform_next_button, .gform_previous_button, .gform_save_link, .gform_send_resume_link_button';
28
29	var strings  = window.cn_gf_recaptcha || {};
30	var timedOut = false;
31
32	/**
33	 * Does this visitor's recorded consent COVER Google reCAPTCHA?
34	 *
35	 * This is what tells the two silences apart. grecaptcha being absent means "we are
36	 * holding it until your consent covers it", and "Google genuinely did not load" once
37	 * it does — same observable, opposite advice to the visitor. timedOut alone cannot
38	 * distinguish them, because the poll below is armed on every pageview that has
39	 * something held, whatever the visitor has or has not chosen.
40	 *
41	 * ⚠️ NOT "did they answer the banner". A visitor who answered by REJECTING
42	 * non-essential cookies has a consent record, yet reCAPTCHA stays blocked for them
43	 * exactly as it does before any answer — so the honest advice is still "accept
44	 * cookies", because accepting is what fixes it. Testing merely that a record EXISTS
45	 * sent them to "reCAPTCHA did not load, reload the page": false, and unactionable,
46	 * since reloading changes nothing and the hold is ours.
47	 *
48	 * ⚠️ NOT the `cookies-unblocked.hu` event either. That fires only when the widget
49	 * actually unblocked something (its emit is gated on `unblocked > 0`), so for a
50	 * returning visitor whose recorded consent already disabled blocking outright it
51	 * never fires at all — reporting "has not consented" for someone who had, and pinning
52	 * them there once the poll expired.
53	 *
54	 * So ask the question the widget itself asks per script — `categories[category] ===
55	 * true` — against the category THIS SITE has reCAPTCHA in, which the customer can
56	 * change and which is localized in alongside the strings.
57	 *
58	 * Read synchronously at call time rather than latched, so it is correct on a pageview
59	 * where no widget event ever fires. `readData` and `prefix` are both on the widget's
60	 * documented global (`window.__hu` / `window.hu`). `prefix` is a build-time constant
61	 * there and is always 'hu' today; the fallback is not for per-site renaming (there is
62	 * none) but so a change to that constant cannot silently strand this lookup.
63	 *
64	 * Returns false whenever we cannot tell — widget absent because an optimizer dropped
65	 * it or it bailed on bot detection, unreadable cookie, no categories. That is the safe
66	 * direction: fall back to the pre-consent wording rather than blaming Google for a
67	 * hold that is ours.
68	 *
69	 * @return {boolean}
70	 */
71	function consentCoversRecaptcha() {
72		var hu = window.__hu || window.hu;
73
74		if ( ! hu || typeof hu.readData !== 'function' ) {
75			return false;
76		}
77
78		try {
79			var data = hu.readData( 'cookie', ( hu.prefix || 'hu' ) + '-consent' );
80			var categories = data && data.categories;
81
82			return !! categories && categories[ recaptchaCategory() ] === true;
83		} catch ( e ) {
84			return false;
85		}
86	}
87
88	/**
89	 * The consent category Google reCAPTCHA sits in ON THIS SITE.
90	 *
91	 * Localized from the same cached portal config the PHP side reads, so a customer who
92	 * moved reCAPTCHA to Analytics or Marketing gets the right question asked rather than
93	 * a hardcoded 2. Falls back to the shipped default when the value is absent — an older
94	 * cached page — which is DEFAULT_CATEGORY in gravity-forms.php.
95	 *
96	 * @return {number}
97	 */
98	function recaptchaCategory() {
99		return parseInt( strings.category, 10 ) || 2;
100	}
101
102	/**
103	 * Anything still held on the page right now.
104	 *
105	 * State is read from the DOM rather than latched in a variable, so a form injected
106	 * after an earlier release — an Elementor popup, an AJAX embed — is still handled
107	 * instead of being left held while interception is switched off.
108	 *
109	 * @return {NodeList}
110	 */
111	function heldScripts() {
112		return document.querySelectorAll( HELD_SELECTOR );
113	}
114
115	/**
116	 * Is a usable grecaptcha present? The add-on supports both the standard and
117	 * enterprise entry points, so accept either.
118	 *
119	 * @return {boolean}
120	 */
121	function recaptchaReady() {
122		var g = window.grecaptcha;
123
124		if ( ! g ) {
125			return false;
126		}
127
128		return typeof g.execute === 'function' ||
129			typeof g.render === 'function' ||
130			!! ( g.enterprise && ( typeof g.enterprise.execute === 'function' || typeof g.enterprise.render === 'function' ) );
131	}
132
133	/**
134	 * Re-insert the add-on bundle so it executes and captures the real grecaptcha.
135	 *
136	 * @return {void}
137	 */
138	function release() {
139		var held = heldScripts();
140
141		if ( ! held.length ) {
142			return;
143		}
144
145		Array.prototype.forEach.call( held, function( placeholder ) {
146			var script = document.createElement( 'script' );
147			var src    = placeholder.getAttribute( 'data-cn-gf-recaptcha-src' );
148
149			script.src = src;
150
151			// The v2 checkbox flow renders on GF's post_render event, which has already
152			// fired by now, so ask the add-on to sweep the page once it is in charge.
153			// v3 needs nothing here — it fetches its token at submit time.
154			script.onload = function() {
155				if ( typeof window.gravityformsrecaptchaRenderCheckboxes === 'function' ) {
156					window.gravityformsrecaptchaRenderCheckboxes();
157				}
158
159				clearNotices();
160			};
161
162			// If the add-on bundle itself cannot be fetched, put the placeholder back so
163			// the submit stays intercepted with an explanation, rather than letting the
164			// form post a token it has no way of producing.
165			script.onerror = function() {
166				timedOut = true;
167				placeholder.setAttribute( 'data-cn-gf-recaptcha-src', src );
168				placeholder.removeAttribute( 'data-cn-gf-recaptcha-released' );
169				clearNotices();
170			};
171
172			// Mark it done rather than removing the placeholder, so a later pass can tell
173			// the difference between "not yet released" and "already released".
174			placeholder.removeAttribute( 'data-cn-gf-recaptcha-src' );
175			placeholder.setAttribute( 'data-cn-gf-recaptcha-released', 'true' );
176
177			( document.head || document.documentElement ).appendChild( script );
178		} );
179	}
180
181	/**
182	 * Wait for grecaptcha, then release. api.js loads asynchronously once unblocked, so
183	 * the unblock event alone is too early — that race is the whole reason this exists.
184	 *
185	 * @return {void}
186	 */
187	function releaseWhenReady() {
188		if ( ! heldScripts().length ) {
189			return;
190		}
191
192		if ( recaptchaReady() ) {
193			timedOut = false;
194			release();
195
196			return;
197		}
198
199		var waited = 0;
200		var timer  = window.setInterval( function() {
201			waited += POLL_INTERVAL;
202
203			if ( recaptchaReady() ) {
204				window.clearInterval( timer );
205				timedOut = false;
206				release();
207			} else if ( waited >= POLL_TIMEOUT ) {
208				// Give up waiting, but stay held. Releasing anyway would run the bundle
209				// against a still-absent grecaptcha and reproduce the original hang.
210				// Remember it, so the visitor is told reCAPTCHA failed to load rather
211				// than being asked to accept cookies they may already have accepted.
212				window.clearInterval( timer );
213				timedOut = true;
214				clearNotices();
215			}
216		}, POLL_INTERVAL );
217	}
218
219	/**
220	 * Forms on this page that Gravity Forms has protected with reCAPTCHA.
221	 *
222	 * @param {Element} form
223	 * @return {boolean}
224	 */
225	function isProtected( form ) {
226		return !! form.querySelector( '.ginput_recaptchav3, .ginput_container_recaptcha_checkbox, .gfield_recaptcha_response' );
227	}
228
229	/**
230	 * Is this control the one that actually submits the form?
231	 *
232	 * Only the submit needs a reCAPTCHA token, so only the submit may be held back. A
233	 * multi-page form's Next and Previous, and its save-and-continue link, ask nothing of
234	 * Google — stopping those leaves the visitor stuck in a form they cannot page through,
235	 * and protects nothing (#47928).
236	 *
237	 * Telling them apart cannot be done on the button's `type`. Gravity Forms sets the
238	 * Previous control's type itself — `o.type = t < r.length ? "submit" : "button"` in
239	 * assets/js/dist/scripts-theme.min.js (3.0.3, read 2026-08-25) — so Previous genuinely
240	 * IS an `input[type=submit]`, and narrowing the click selector alone would not exclude
241	 * it. Three signals, in order of how much we can trust them:
242	 *
243	 *   1. `data-submission-type`, which the same bundle stamps and reads, with the values
244	 *      `S="submit",k="next",x="previous"`. When present it is Gravity Forms' own
245	 *      statement of intent, so it decides outright.
246	 *   2. Failing that — older markup carries no such attribute — the class. That bundle
247	 *      matches navigation as `.gform_next_button` / `.gform_previous_button` and the
248	 *      submit as `.gform_button`, so rule the navigation classes out first, because a
249	 *      control can carry both.
250	 *   3. Failing that too, position — but only as far as the evidence reaches. A theme can
251	 *      replace the submit's markup entirely through Gravity Forms' own
252	 *      `gform_submit_button` filter, and a replacement keeping neither the attribute nor
253	 *      the class would otherwise stop being held, which is the promise 3.1.5 made to
254	 *      those sites. On a SINGLE-PAGE form the submit sits in
255	 *      `<div class="gform-footer gform_footer ...">` — read off a live customer page on
256	 *      2026-08-25. That is the only shape verified, and the only one this catches.
257	 *
258	 * Beyond that, biased toward letting an unrecognised control through. Over-blocking is
259	 * the failure being fixed here, and under-blocking is not a hole: Gravity Forms
260	 * validates the token server-side, so a submit that slips through fails its captcha
261	 * check rather than bypassing it. Note it cannot hang either — a held bundle never ran,
262	 * so it never registered a submission step to stall on.
263	 *
264	 * THREE THINGS NOT ESTABLISHED, because Gravity Forms is not vendored here and only its
265	 * theme bundle and one live page could be read:
266	 *
267	 *   a. What it stamps on a save-and-continue control. The bundle exports a
268	 *      SUBMISSION_TYPE_SAVE_AND_CONTINUE constant but does not carry its value, so if
269	 *      that value is "submit" this treats save as a submit and holds it. NAV_SELECTOR
270	 *      lists the class, which covers only the attribute-absent case.
271	 *   b. Where a MULTI-PAGE form puts its submit. If the last page's footer is not
272	 *      `.gform_footer`, signal 3 does nothing there and a themed submit on a multi-page
273	 *      form is let through — no worse than before this function existed, but not fixed
274	 *      either. Deliberately not guessed at: an earlier attempt to exclude a
275	 *      `.gform_page_footer` container was removed once it turned out to veto the very
276	 *      shape signal 3 is meant to catch.
277	 *   c. Whether a themed save-and-continue link, or a themed Next/Previous, keeps any GF
278	 *      class — GF exposes `gform_savecontinue_link` and `gform_next_button` filters just
279	 *      as it does `gform_submit_button`. If such a control keeps none and sits in the
280	 *      footer, signal 3 holds it — a known over-block, and the accepted cost of closing
281	 *      the themed-submit gap, since position cannot tell those two apart. A nav control
282	 *      that KEEPS its class is safe: the NAV_SELECTOR veto runs first.
283	 *
284	 * @param {Element} button
285	 * @return {boolean}
286	 */
287	function isSubmitControl( button ) {
288		var declared = button.getAttribute( 'data-submission-type' );
289
290		if ( declared !== null ) {
291			return declared === SUBMISSION_TYPE_SUBMIT;
292		}
293
294		if ( button.matches( NAV_SELECTOR ) ) {
295			return false;
296		}
297
298		return button.classList.contains( 'gform_button' ) || !! button.closest( '.gform_footer' );
299	}
300
301	/**
302	 * Remove any notice we added.
303	 *
304	 * @return {void}
305	 */
306	function clearNotices() {
307		var notices = document.getElementsByClassName( NOTICE_CLASS );
308
309		while ( notices.length ) {
310			notices[0].parentNode.removeChild( notices[0] );
311		}
312	}
313
314	/**
315	 * Show, once per form, why the submission cannot proceed yet.
316	 *
317	 * THREE states, not two. Whether consent COVERS reCAPTCHA and whether we gave up
318	 * waiting for Google are independent, and the pair decides the advice:
319	 *
320	 *   covered? timed out?  what the visitor is told
321	 *   -------- ----------  -----------------------------------------------------------
322	 *   no       no          accept cookies — true, and accepting is what fixes it
323	 *   no       yes         accept cookies — still true; that expiry was OUR hold, not
324	 *                        Google failing, so "did not load" would be a lie
325	 *   yes      no          still loading — transient, resolves when api.js defines
326	 *                        grecaptcha. Telling them to accept is wrong (their consent
327	 *                        already covers it) and blaming Google is premature
328	 *   yes      yes         reCAPTCHA did not load — the only honest failure report
329	 *
330	 * The `no` rows deliberately collapse two different visitors: one who has not answered
331	 * the banner, and one who answered by declining. Both are told to accept, and for both
332	 * that is true — reCAPTCHA is held either way, and accepting is the only thing that
333	 * releases it. Splitting on "has a consent record" instead of "is reCAPTCHA covered"
334	 * put the decliner in row 4 and told them Google had failed.
335	 *
336	 * Row 3 is what a covered visitor hits while api.js loads, and it had no message of
337	 * its own: it fell through to "please accept cookies", to someone who already had.
338	 *
339	 * @param {Element} form
340	 * @return {void}
341	 */
342	function showNotice( form ) {
343		if ( form.getElementsByClassName( NOTICE_CLASS ).length ) {
344			return;
345		}
346
347		var notice = document.createElement( 'p' );
348		var text;
349
350		notice.className = NOTICE_CLASS;
351		notice.setAttribute( 'role', 'alert' );
352
353		if ( ! consentCoversRecaptcha() ) {
354			text = strings.blockedMessage || 'Please accept cookies to submit this form.';
355		} else if ( timedOut ) {
356			text = strings.unavailableMessage || 'This form could not be submitted because Google reCAPTCHA did not load.';
357		} else {
358			text = strings.loadingMessage || 'This form is still loading. Please try again in a moment.';
359		}
360
361		notice.textContent = text;
362
363		form.appendChild( notice );
364	}
365
366	/**
367	 * While the bundle is still held, stop the submit and explain. Capture phase, so we
368	 * run before Gravity Forms' own button handler.
369	 */
370	document.addEventListener( 'click', function( e ) {
371		if ( ! e.target || typeof e.target.closest !== 'function' ) {
372			return;
373		}
374
375		// Match broadly, then let isSubmitControl() decide — a multi-page form's Next and
376		// Previous are themselves input[type=submit], so the selector cannot tell them apart.
377		var button = e.target.closest( '[data-submission-type], input[type="submit"], button[type="submit"]' );
378
379		if ( ! button || ! isSubmitControl( button ) ) {
380			return;
381		}
382
383		var form = button.closest( 'form' );
384
385		if ( ! form || ! isProtected( form ) ) {
386			return;
387		}
388
389		// Nothing held any more (released, or never blocked) — leave the form alone.
390		if ( ! heldScripts().length ) {
391			return;
392		}
393
394		e.preventDefault();
395		e.stopPropagation();
396
397		showNotice( form );
398	}, true );
399
400	/**
401	 * Handle the widget's unblock event.
402	 *
403	 * This is a RELEASE TRIGGER and nothing more. It used to also latch "consent was
404	 * given", which it cannot witness — see consentCoversRecaptcha() for why the event is
405	 * silent on the covered visitor, and blind to the decliner.
406	 *
407	 * timedOut is still cleared here: if scripts were just unblocked, any earlier expiry
408	 * was the pre-consent wait, and the one starting now is the only one that could
409	 * honestly report a load failure. Clearing it on a signal we DO observe is sound;
410	 * inferring consent from it was not.
411	 */
412	document.addEventListener( 'cookies-unblocked.hu', function() {
413		timedOut = false;
414
415		releaseWhenReady();
416	}, false );
417
418	/**
419	 * Gravity Forms fires this after it renders a form, including forms brought in later
420	 * by AJAX or a popup. Such a form arrives with its own held placeholder, so re-check
421	 * rather than assuming the page was fully handled on first load.
422	 */
423	document.addEventListener( 'gform/post_render', releaseWhenReady, false );
424
425	/**
426	 * Safety net: if reCAPTCHA turns out not to be blocked on this pageview after all —
427	 * an excluded handle, a returning visitor whose consent was already recorded, the
428	 * widget failing to load — release on our own so we never leave a form worse off
429	 * than it was before this module existed.
430	 */
431	if ( document.readyState === 'loading' ) {
432		document.addEventListener( 'DOMContentLoaded', releaseWhenReady, false );
433	} else {
434		releaseWhenReady();
435	}
436
437} )( window, document );

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.