| 1 |
/** |
| 2 |
* Google reCAPTCHA v3 loader for Contact Forms. |
| 3 |
* |
| 4 |
* Each captcha_v3 field registers itself through the global queue |
| 5 |
* `accuaformRecaptcha3Queue` (so registration works whether the field markup |
| 6 |
* is parsed before or after this file loads). The Google api.js script is |
| 7 |
* injected only after the visitor starts interacting with a form — or on |
| 8 |
* demand at submit time — so no third-party request is made on page load. |
| 9 |
* |
| 10 |
* v3 tokens are single-use and expire after ~2 minutes, so the token is |
| 11 |
* fetched when the form is submitted: |
| 12 |
* - AJAX forms: the submit handler generated by AccuaForm.php calls |
| 13 |
* accuaformRecaptcha3.getToken() (async), fills the hidden input and |
| 14 |
* re-triggers the submit. After every AJAX response the handler calls |
| 15 |
* accuaformRecaptcha3.reset() so a retry gets a fresh token. |
| 16 |
* - Non-AJAX forms emit no submit JS at all (the whole handler block in |
| 17 |
* AccuaForm.php is AJAX-only), so the delegated document-level submit |
| 18 |
* handler at the bottom of this file does the same dance for them and |
| 19 |
* resubmits natively (form.submit() skips handlers - no loop). |
| 20 |
* |
| 21 |
* This file is independent from recaptcha2.js (v2 checkbox): if a v2 form on |
| 22 |
* the same page already loaded api.js in explicit-render mode, that same |
| 23 |
* script is reused — grecaptcha.execute() works there too. |
| 24 |
*/ |
| 25 |
|
| 26 |
(function ($) { |
| 27 |
var registered = {}; // inputId -> {sitekey: ..., action: ...} |
| 28 |
var tokenTimestamps = {}; // inputId -> ms timestamp of last token |
| 29 |
var listenerAttached = false; |
| 30 |
var apiPromise = null; |
| 31 |
var lang = ''; |
| 32 |
var TOKEN_MAX_AGE_MS = 110000; // Google tokens expire after 2 minutes |
| 33 |
|
| 34 |
function firstSitekey() { |
| 35 |
for (var id in registered) { |
| 36 |
if (Object.prototype.hasOwnProperty.call(registered, id)) { |
| 37 |
return registered[id].sitekey; |
| 38 |
} |
| 39 |
} |
| 40 |
return ''; |
| 41 |
} |
| 42 |
|
| 43 |
// Resolves when window.grecaptcha is available (never rejects). |
| 44 |
function loadApi() { |
| 45 |
if (apiPromise) { |
| 46 |
return apiPromise; |
| 47 |
} |
| 48 |
apiPromise = new Promise(function (resolve) { |
| 49 |
if (window.grecaptcha) { |
| 50 |
resolve(); |
| 51 |
return; |
| 52 |
} |
| 53 |
// api.js may already be present (injected by recaptcha2.js for a v2 |
| 54 |
// form on the same page) but not yet executed: wait for it instead of |
| 55 |
// injecting a second copy. |
| 56 |
if (!document.querySelector('script[src*="recaptcha/api.js"]')) { |
| 57 |
var script = document.createElement('script'); |
| 58 |
script.src = 'https://www.recaptcha.net/recaptcha/api.js?render=' + |
| 59 |
encodeURIComponent(firstSitekey()) + '&hl=' + encodeURIComponent(lang); |
| 60 |
script.async = true; |
| 61 |
document.head.appendChild(script); |
| 62 |
} |
| 63 |
var waited = 0; |
| 64 |
var poll = setInterval(function () { |
| 65 |
waited += 200; |
| 66 |
if (window.grecaptcha || waited >= 20000) { |
| 67 |
clearInterval(poll); |
| 68 |
resolve(); |
| 69 |
} |
| 70 |
}, 200); |
| 71 |
}); |
| 72 |
return apiPromise; |
| 73 |
} |
| 74 |
|
| 75 |
function formInputs(formEl) { |
| 76 |
return $(formEl).find('input.accua_forms_recaptcha3_input').filter(function () { |
| 77 |
return !!registered[this.id]; |
| 78 |
}); |
| 79 |
} |
| 80 |
|
| 81 |
window.accuaformRecaptcha3 = { |
| 82 |
// True when the form has a registered v3 input with no recent getToken() |
| 83 |
// attempt. The timestamp is stamped on failed attempts too, so a broken |
| 84 |
// grecaptcha cannot cause an endless submit -> fetch -> resubmit loop: |
| 85 |
// the submit proceeds with an empty token and the server rejects it. |
| 86 |
needsToken: function (formEl) { |
| 87 |
var needed = false; |
| 88 |
formInputs(formEl).each(function () { |
| 89 |
var ts = tokenTimestamps[this.id]; |
| 90 |
if (!ts || (Date.now() - ts) > TOKEN_MAX_AGE_MS) { |
| 91 |
needed = true; |
| 92 |
} |
| 93 |
}); |
| 94 |
return needed; |
| 95 |
}, |
| 96 |
|
| 97 |
// Fetch fresh tokens for the form's v3 inputs. Always resolves: on any |
| 98 |
// failure the input stays empty and the server rejects the submission |
| 99 |
// (server-side validation is fail-closed) — the client must not hang. |
| 100 |
getToken: function (formEl) { |
| 101 |
var inputs = formInputs(formEl); |
| 102 |
if (!inputs.length) { |
| 103 |
return Promise.resolve(); |
| 104 |
} |
| 105 |
var stampAll = function () { |
| 106 |
inputs.each(function () { |
| 107 |
tokenTimestamps[this.id] = Date.now(); |
| 108 |
}); |
| 109 |
}; |
| 110 |
return loadApi().then(function () { |
| 111 |
if (!window.grecaptcha || !window.grecaptcha.execute) { |
| 112 |
stampAll(); |
| 113 |
return; |
| 114 |
} |
| 115 |
return new Promise(function (resolve) { |
| 116 |
window.grecaptcha.ready(function () { |
| 117 |
var pending = inputs.length; |
| 118 |
inputs.each(function () { |
| 119 |
var input = this; |
| 120 |
var reg = registered[input.id]; |
| 121 |
var done = function () { |
| 122 |
tokenTimestamps[input.id] = Date.now(); |
| 123 |
pending--; |
| 124 |
if (pending <= 0) { |
| 125 |
resolve(); |
| 126 |
} |
| 127 |
}; |
| 128 |
try { |
| 129 |
window.grecaptcha.execute(reg.sitekey, {action: reg.action}).then(function (token) { |
| 130 |
input.value = token; |
| 131 |
done(); |
| 132 |
}, done); |
| 133 |
} catch (e) { |
| 134 |
done(); |
| 135 |
} |
| 136 |
}); |
| 137 |
}); |
| 138 |
}); |
| 139 |
}); |
| 140 |
}, |
| 141 |
|
| 142 |
// Clear used tokens so the next submit attempt fetches fresh ones. |
| 143 |
reset: function (formEl) { |
| 144 |
formInputs(formEl).each(function () { |
| 145 |
this.value = ''; |
| 146 |
delete tokenTimestamps[this.id]; |
| 147 |
}); |
| 148 |
} |
| 149 |
}; |
| 150 |
|
| 151 |
// item = [inputId, {sitekey, action}, language] |
| 152 |
function register(item) { |
| 153 |
var id = item[0]; |
| 154 |
if (registered[id]) { |
| 155 |
return; |
| 156 |
} |
| 157 |
registered[id] = item[1]; |
| 158 |
if (item[2] && !lang) { |
| 159 |
lang = item[2]; |
| 160 |
} |
| 161 |
if (!listenerAttached) { |
| 162 |
listenerAttached = true; |
| 163 |
// Same privacy-preserving lazy trigger as recaptcha2.js: no Google |
| 164 |
// request until the visitor interacts with a form. getToken() also |
| 165 |
// loads the api on demand as a fallback (form submitted without any |
| 166 |
// change event). |
| 167 |
$(document).one('change', '.accuaforms-field-required, .pfbc-fieldwrap > *', function () { |
| 168 |
loadApi(); |
| 169 |
}); |
| 170 |
} |
| 171 |
} |
| 172 |
|
| 173 |
var queue = window.accuaformRecaptcha3Queue = window.accuaformRecaptcha3Queue || []; |
| 174 |
for (var i = 0; i < queue.length; i++) { |
| 175 |
register(queue[i]); |
| 176 |
} |
| 177 |
queue.push = function (item) { |
| 178 |
register(item); |
| 179 |
return queue.length; |
| 180 |
}; |
| 181 |
|
| 182 |
// Token gate for non-AJAX forms. AJAX forms are gated by the inline |
| 183 |
// onsubmit handler generated in AccuaForm.php, which cancels the event |
| 184 |
// while it fetches the token — in that case the event arrives here already |
| 185 |
// default-prevented (or with the token in place) and this handler is a |
| 186 |
// no-op. Non-AJAX forms have no submit JS at all, so this handler cancels |
| 187 |
// the native submit, fetches the token and resubmits with form.submit(), |
| 188 |
// which does not re-fire submit handlers. |
| 189 |
$(document).on('submit', 'form', function (e) { |
| 190 |
var form = this; |
| 191 |
if (e.isDefaultPrevented()) { |
| 192 |
return; |
| 193 |
} |
| 194 |
if (!window.accuaformRecaptcha3.needsToken(form)) { |
| 195 |
return; |
| 196 |
} |
| 197 |
e.preventDefault(); |
| 198 |
var resubmit = function () { |
| 199 |
form.submit(); |
| 200 |
}; |
| 201 |
window.accuaformRecaptcha3.getToken(form).then(resubmit, resubmit); |
| 202 |
}); |
| 203 |
})(jQuery); |
| 204 |
|