| 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. The bottom-left |
| 24 |
* badge asks for that same explicit-render mode itself, see below. |
| 25 |
*/ |
| 26 |
|
| 27 |
(function ($) { |
| 28 |
var registered = {}; // inputId -> {sitekey: ..., action: ..., badge: ...} |
| 29 |
var tokenTimestamps = {}; // inputId -> ms timestamp of last token |
| 30 |
var widgets = {}; // sitekey -> widget id from grecaptcha.render() |
| 31 |
var listenerAttached = false; |
| 32 |
var apiPromise = null; |
| 33 |
var lang = ''; |
| 34 |
var badge = 'bottomright'; |
| 35 |
var TOKEN_MAX_AGE_MS = 110000; // Google tokens expire after 2 minutes |
| 36 |
|
| 37 |
// A badge on the left has to be asked of Google, not arranged afterwards: |
| 38 |
// the badge is one 256px strip with the logo at one end, collapsed by |
| 39 |
// hiding the other 186px, so CSS that moves the whole strip to the left |
| 40 |
// edge leaves the wrong half of it on screen. Passing badge:'bottomleft' |
| 41 |
// to grecaptcha.render() makes api.js build the mirrored strip itself, and |
| 42 |
// that call needs the explicit-render mode of the API script. |
| 43 |
function useExplicitRender() { |
| 44 |
return badge === 'bottomleft'; |
| 45 |
} |
| 46 |
|
| 47 |
// The execute() target for a sitekey: a widget id when the badge is |
| 48 |
// rendered explicitly, the sitekey itself otherwise. One widget per |
| 49 |
// sitekey, so several v3 fields on a page still share a single badge, and |
| 50 |
// a refused render() falls back to the sitekey - a misplaced badge must |
| 51 |
// never cost the token. Called from renderBadges() as soon as the API is |
| 52 |
// ready, so by submit time the widget is already there. |
| 53 |
function executeTarget(sitekey) { |
| 54 |
if (!useExplicitRender()) { |
| 55 |
return sitekey; |
| 56 |
} |
| 57 |
if (typeof widgets[sitekey] === 'undefined') { |
| 58 |
widgets[sitekey] = null; |
| 59 |
try { |
| 60 |
var container = document.createElement('div'); |
| 61 |
container.className = 'accua-forms-recaptcha3-badge'; |
| 62 |
document.body.appendChild(container); |
| 63 |
widgets[sitekey] = window.grecaptcha.render(container, { |
| 64 |
sitekey: sitekey, |
| 65 |
badge: badge, |
| 66 |
size: 'invisible' |
| 67 |
}); |
| 68 |
} catch (e) { |
| 69 |
widgets[sitekey] = null; |
| 70 |
} |
| 71 |
} |
| 72 |
return widgets[sitekey] === null ? sitekey : widgets[sitekey]; |
| 73 |
} |
| 74 |
|
| 75 |
function firstSitekey() { |
| 76 |
for (var id in registered) { |
| 77 |
if (Object.prototype.hasOwnProperty.call(registered, id)) { |
| 78 |
return registered[id].sitekey; |
| 79 |
} |
| 80 |
} |
| 81 |
return ''; |
| 82 |
} |
| 83 |
|
| 84 |
// Draw the badges as soon as the API is ready. In explicit-render mode |
| 85 |
// api.js paints nothing by itself: it is grecaptcha.render() that creates |
| 86 |
// the badge, so leaving that call to getToken() would show the badge only |
| 87 |
// once the form had been submitted. Every registered sitekey is rendered |
| 88 |
// here, and executeTarget() reuses what this created. |
| 89 |
function renderBadges() { |
| 90 |
if (!useExplicitRender() || !window.grecaptcha || !window.grecaptcha.ready) { |
| 91 |
return; |
| 92 |
} |
| 93 |
window.grecaptcha.ready(function () { |
| 94 |
for (var id in registered) { |
| 95 |
if (Object.prototype.hasOwnProperty.call(registered, id)) { |
| 96 |
executeTarget(registered[id].sitekey); |
| 97 |
} |
| 98 |
} |
| 99 |
}); |
| 100 |
} |
| 101 |
|
| 102 |
// Resolves when window.grecaptcha is available (never rejects). |
| 103 |
function loadApi() { |
| 104 |
if (apiPromise) { |
| 105 |
return apiPromise; |
| 106 |
} |
| 107 |
apiPromise = new Promise(function (resolve) { |
| 108 |
if (window.grecaptcha) { |
| 109 |
resolve(); |
| 110 |
return; |
| 111 |
} |
| 112 |
// api.js may already be present (injected by recaptcha2.js for a v2 |
| 113 |
// form on the same page) but not yet executed: wait for it instead of |
| 114 |
// injecting a second copy. |
| 115 |
if (!document.querySelector('script[src*="recaptcha/api.js"]')) { |
| 116 |
var script = document.createElement('script'); |
| 117 |
script.src = 'https://www.recaptcha.net/recaptcha/api.js?render=' + |
| 118 |
(useExplicitRender() ? 'explicit' : encodeURIComponent(firstSitekey())) + |
| 119 |
'&hl=' + encodeURIComponent(lang); |
| 120 |
script.async = true; |
| 121 |
document.head.appendChild(script); |
| 122 |
} |
| 123 |
var waited = 0; |
| 124 |
var poll = setInterval(function () { |
| 125 |
waited += 200; |
| 126 |
if (window.grecaptcha || waited >= 20000) { |
| 127 |
clearInterval(poll); |
| 128 |
resolve(); |
| 129 |
} |
| 130 |
}, 200); |
| 131 |
}).then(renderBadges); |
| 132 |
return apiPromise; |
| 133 |
} |
| 134 |
|
| 135 |
function formInputs(formEl) { |
| 136 |
return $(formEl).find('input.accua_forms_recaptcha3_input').filter(function () { |
| 137 |
return !!registered[this.id]; |
| 138 |
}); |
| 139 |
} |
| 140 |
|
| 141 |
window.accuaformRecaptcha3 = { |
| 142 |
// True when the form has a registered v3 input with no recent getToken() |
| 143 |
// attempt. The timestamp is stamped on failed attempts too, so a broken |
| 144 |
// grecaptcha cannot cause an endless submit -> fetch -> resubmit loop: |
| 145 |
// the submit proceeds with an empty token and the server rejects it. |
| 146 |
needsToken: function (formEl) { |
| 147 |
var needed = false; |
| 148 |
formInputs(formEl).each(function () { |
| 149 |
var ts = tokenTimestamps[this.id]; |
| 150 |
if (!ts || (Date.now() - ts) > TOKEN_MAX_AGE_MS) { |
| 151 |
needed = true; |
| 152 |
} |
| 153 |
}); |
| 154 |
return needed; |
| 155 |
}, |
| 156 |
|
| 157 |
// Fetch fresh tokens for the form's v3 inputs. Always resolves: on any |
| 158 |
// failure the input stays empty and the server rejects the submission |
| 159 |
// (server-side validation is fail-closed) - the client must not hang. |
| 160 |
getToken: function (formEl) { |
| 161 |
var inputs = formInputs(formEl); |
| 162 |
if (!inputs.length) { |
| 163 |
return Promise.resolve(); |
| 164 |
} |
| 165 |
var stampAll = function () { |
| 166 |
inputs.each(function () { |
| 167 |
tokenTimestamps[this.id] = Date.now(); |
| 168 |
}); |
| 169 |
}; |
| 170 |
return loadApi().then(function () { |
| 171 |
if (!window.grecaptcha || !window.grecaptcha.execute) { |
| 172 |
stampAll(); |
| 173 |
return; |
| 174 |
} |
| 175 |
return new Promise(function (resolve) { |
| 176 |
window.grecaptcha.ready(function () { |
| 177 |
var pending = inputs.length; |
| 178 |
inputs.each(function () { |
| 179 |
var input = this; |
| 180 |
var reg = registered[input.id]; |
| 181 |
var done = function () { |
| 182 |
tokenTimestamps[input.id] = Date.now(); |
| 183 |
pending--; |
| 184 |
if (pending <= 0) { |
| 185 |
resolve(); |
| 186 |
} |
| 187 |
}; |
| 188 |
var run = function (target, onFail) { |
| 189 |
try { |
| 190 |
var pending = window.grecaptcha.execute(target, {action: reg.action}); |
| 191 |
if (!pending || !pending.then) { |
| 192 |
onFail(); |
| 193 |
return; |
| 194 |
} |
| 195 |
pending.then(function (token) { |
| 196 |
input.value = token; |
| 197 |
done(); |
| 198 |
}, onFail); |
| 199 |
} catch (e) { |
| 200 |
onFail(); |
| 201 |
} |
| 202 |
}; |
| 203 |
var target = executeTarget(reg.sitekey); |
| 204 |
if (target === reg.sitekey) { |
| 205 |
run(target, done); |
| 206 |
} else { |
| 207 |
// The explicitly rendered widget exists only to place the |
| 208 |
// badge. If Google will not score through it, throw it away |
| 209 |
// and ask again with the site key: a badge in the wrong |
| 210 |
// corner is a nuisance, a form nobody can submit is not. |
| 211 |
run(target, function () { |
| 212 |
widgets[reg.sitekey] = null; |
| 213 |
run(reg.sitekey, done); |
| 214 |
}); |
| 215 |
} |
| 216 |
}); |
| 217 |
}); |
| 218 |
}); |
| 219 |
}); |
| 220 |
}, |
| 221 |
|
| 222 |
// Clear used tokens so the next submit attempt fetches fresh ones. |
| 223 |
reset: function (formEl) { |
| 224 |
formInputs(formEl).each(function () { |
| 225 |
this.value = ''; |
| 226 |
delete tokenTimestamps[this.id]; |
| 227 |
}); |
| 228 |
} |
| 229 |
}; |
| 230 |
|
| 231 |
// item = [inputId, {sitekey, action}, language] |
| 232 |
function register(item) { |
| 233 |
var id = item[0]; |
| 234 |
if (registered[id]) { |
| 235 |
return; |
| 236 |
} |
| 237 |
registered[id] = item[1]; |
| 238 |
if (item[1] && item[1].badge) { |
| 239 |
badge = item[1].badge; |
| 240 |
} |
| 241 |
if (item[2] && !lang) { |
| 242 |
lang = item[2]; |
| 243 |
} |
| 244 |
if (!listenerAttached) { |
| 245 |
listenerAttached = true; |
| 246 |
// Same privacy-preserving lazy trigger as recaptcha2.js: no Google |
| 247 |
// request until the visitor interacts with a form. getToken() also |
| 248 |
// loads the api on demand as a fallback (form submitted without any |
| 249 |
// change event). |
| 250 |
$(document).one('change', '.accuaforms-field-required, .pfbc-fieldwrap > *', function () { |
| 251 |
loadApi(); |
| 252 |
}); |
| 253 |
} |
| 254 |
} |
| 255 |
|
| 256 |
var queue = window.accuaformRecaptcha3Queue = window.accuaformRecaptcha3Queue || []; |
| 257 |
for (var i = 0; i < queue.length; i++) { |
| 258 |
register(queue[i]); |
| 259 |
} |
| 260 |
queue.push = function (item) { |
| 261 |
register(item); |
| 262 |
return queue.length; |
| 263 |
}; |
| 264 |
|
| 265 |
// Token gate for non-AJAX forms. AJAX forms are gated by the inline |
| 266 |
// onsubmit handler generated in AccuaForm.php, which cancels the event |
| 267 |
// while it fetches the token - in that case the event arrives here already |
| 268 |
// default-prevented (or with the token in place) and this handler is a |
| 269 |
// no-op. Non-AJAX forms have no submit JS at all, so this handler cancels |
| 270 |
// the native submit, fetches the token and resubmits with form.submit(), |
| 271 |
// which does not re-fire submit handlers. |
| 272 |
$(document).on('submit', 'form', function (e) { |
| 273 |
var form = this; |
| 274 |
if (e.isDefaultPrevented()) { |
| 275 |
return; |
| 276 |
} |
| 277 |
if (!window.accuaformRecaptcha3.needsToken(form)) { |
| 278 |
return; |
| 279 |
} |
| 280 |
e.preventDefault(); |
| 281 |
var resubmit = function () { |
| 282 |
form.submit(); |
| 283 |
}; |
| 284 |
window.accuaformRecaptcha3.getToken(form).then(resubmit, resubmit); |
| 285 |
}); |
| 286 |
})(jQuery); |
| 287 |
|