PluginProbe
Contact Forms by Cimatti / trunk
Contact Forms by Cimatti vtrunk
2.3.6 2.3.5 2.3.0 2.2.32 2.2.4 2.2.0 2.1.2 2.1.1 trunk 1.0 1.1 1.2 1.2.1 1.3 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.3.6 1.3.7 1.3.8 1.3.9 1.4.0 1.4.1 All 62 releases
contact-forms / assets / js / frontend / recaptcha3.js

recaptcha3.js in Contact Forms by Cimatti trunk, at assets/js/frontend/recaptcha3.js

287 lines 10.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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