| @@ -35,35 +35,47 @@ | ||
| 35 | 35 | |
| 36 | 36 | // enterprise.js can't load twice, and execute() needs a rendered site key — |
| 37 | 37 | // one widget per key. |
| 38 | 38 | const recaptchaWidgets = new Map(); |
| 39 | +const renderedKeys = new Set(); | |
| 40 | + | |
| 41 | +const renderWidget = async (siteKey) => { | |
| 42 | + await loadRecaptcha(); | |
| 43 | + | |
| 44 | + const container = document.createElement('div'); | |
| 45 | + document.body.appendChild(container); | |
| 46 | + const widget = window.grecaptcha.enterprise.render(container, { | |
| 47 | + sitekey: siteKey, | |
| 48 | + size: 'invisible', | |
| 49 | + }); | |
| 50 | + renderedKeys.add(siteKey); | |
| 51 | + | |
| 52 | + return widget; | |
| 53 | +}; | |
| 54 | + | |
| 55 | +// Keeps the script load and the widget render off the click's deadline. | |
| 56 | +export const prewarmRecaptcha = (siteKey) => { | |
| 57 | + if (!recaptchaWidgets.has(siteKey)) { | |
| 58 | + const widget = renderWidget(siteKey); | |
| 59 | + widget.catch(() => recaptchaWidgets.delete(siteKey)); | |
| 60 | + recaptchaWidgets.set(siteKey, widget); | |
| 61 | + } | |
| 62 | + return recaptchaWidgets.get(siteKey); | |
| 63 | +}; | |
| 64 | + | |
| 39 | 65 | const getRecaptchaToken = async (action, siteKey, timings = {}) => { |
| 40 | 66 | if (!siteKey) { |
| 41 | 67 | throw new Error(`No reCAPTCHA site key for the ${action} action`); |
| 42 | 68 | } |
| 43 | 69 | |
| 70 | + timings.captchaWasWarm = renderedKeys.has(siteKey); | |
| 44 | 71 | const start = Date.now(); |
| 45 | 72 | |
| 46 | 73 | try { |
| 47 | - await loadRecaptcha(); | |
| 74 | + const widget = await prewarmRecaptcha(siteKey); | |
| 48 | 75 | |
| 49 | - if (!recaptchaWidgets.has(siteKey)) { | |
| 50 | - const container = document.createElement('div'); | |
| 51 | - document.body.appendChild(container); | |
| 52 | - recaptchaWidgets.set( | |
| 53 | - siteKey, | |
| 54 | - window.grecaptcha.enterprise.render(container, { | |
| 55 | - sitekey: siteKey, | |
| 56 | - size: 'invisible', | |
| 57 | - }), | |
| 58 | - ); | |
| 59 | - } | |
| 60 | - | |
| 61 | 76 | // Without await, finally runs before execute settles and records ~0ms. |
| 62 | - return await window.grecaptcha.enterprise.execute( | |
| 63 | - recaptchaWidgets.get(siteKey), | |
| 64 | - { action }, | |
| 65 | - ); | |
| 77 | + return await window.grecaptcha.enterprise.execute(widget, { action }); | |
| 66 | 78 | } finally { |
| 67 | 79 | timings.captchaTimeInMs = Date.now() - start; |
| 68 | 80 | } |
| 69 | 81 | }; |
| @@ -70,9 +82,14 @@ | ||
| 70 | 82 | |
| 71 | 83 | // api-fetch throws the parsed body and drops the Response, so parse:false is the only way to keep the status. |
| 72 | 84 | const post = async (options) => { |
| 73 | 85 | try { |
| 74 | - await apiFetch({ ...options, method: 'POST', parse: false }); | |
| 86 | + const response = await apiFetch({ | |
| 87 | + ...options, | |
| 88 | + method: 'POST', | |
| 89 | + parse: false, | |
| 90 | + }); | |
| 91 | + return await response.json().catch(() => undefined); | |
| 75 | 92 | } catch (error) { |
| 76 | 93 | if (typeof error?.json !== 'function') throw error; |
| 77 | 94 | |
| 78 | 95 | const body = await error.json().catch(() => ({ code: 'invalid_json' })); |
| @@ -80,9 +97,9 @@ | ||
| 80 | 97 | } |
| 81 | 98 | }; |
| 82 | 99 | |
| 83 | 100 | const createAccount = ({ |
| 84 | - slug, | |
| 101 | + endpoint, | |
| 85 | 102 | email, |
| 86 | 103 | marketingConsent, |
| 87 | 104 | termsAgreed, |
| 88 | 105 | signal, |
| @@ -88,9 +105,9 @@ | ||
| 88 | 105 | signal, |
| 89 | 106 | scriptData, |
| 90 | 107 | }) => |
| 91 | 108 | post({ |
| 92 | - path: `extendify/v1/${slug}/create-account`, | |
| 109 | + path: endpoint, | |
| 93 | 110 | data: { |
| 94 | 111 | email, |
| 95 | 112 | marketingConsent, |
| 96 | 113 | termsAgreed, |
| @@ -100,17 +117,17 @@ | ||
| 100 | 117 | }); |
| 101 | 118 | |
| 102 | 119 | /* |
| 103 | 120 | * Plugin entries shape: |
| 104 | - * createAccountCallback: (data) => Promise<void> — performs the account creation request | |
| 105 | - * idempotent: boolean (default true) — false skips retries; an aborted fetch does not stop the PHP call, so a retry creates a second account | |
| 106 | - * data.timings: out-param — write captchaTimeInMs here; it survives a throw | |
| 121 | + * createAccountCallback: (data) => Promise<body> — performs the account creation request | |
| 122 | + * data.endpoint: the route PHP registered — requesting and recording must not drift | |
| 123 | + * data.timings: out-param — write the captcha timings here; they survive a throw | |
| 107 | 124 | */ |
| 108 | 125 | export const pluginsActivation = { |
| 109 | 126 | simplybook: { |
| 110 | - idempotent: false, | |
| 111 | 127 | createAccountCallback: async ({ |
| 112 | 128 | scriptData, |
| 129 | + endpoint, | |
| 113 | 130 | email, |
| 114 | 131 | marketingConsent, |
| 115 | 132 | termsAgreed, |
| 116 | 133 | signal, |
| @@ -124,12 +141,12 @@ | ||
| 124 | 141 | |
| 125 | 142 | // Hit the endpoint via ?rest_route= so the request URL contains "simplybook" — |
| 126 | 143 | // SimplyBook only registers its onboarding routes when it does, else they 404. |
| 127 | 144 | const url = addQueryArgs(`${window.extSharedData.homeUrl}/`, { |
| 128 | - rest_route: '/extendify/v1/simplybook/create-account', | |
| 145 | + rest_route: `/${endpoint}`, | |
| 129 | 146 | }); |
| 130 | 147 | |
| 131 | - await post({ | |
| 148 | + return post({ | |
| 132 | 149 | url, |
| 133 | 150 | data: { |
| 134 | 151 | email, |
| 135 | 152 | marketingConsent, |
| @@ -140,21 +157,17 @@ | ||
| 140 | 157 | }); |
| 141 | 158 | }, |
| 142 | 159 | }, |
| 143 | 160 | 'translatepress-multilingual': { |
| 144 | - idempotent: false, | |
| 145 | - createAccountCallback: (data) => | |
| 146 | - createAccount({ slug: 'translatepress-multilingual', ...data }), | |
| 161 | + createAccountCallback: createAccount, | |
| 147 | 162 | }, |
| 148 | 163 | imagify: { |
| 149 | - idempotent: false, | |
| 150 | - createAccountCallback: (data) => | |
| 151 | - createAccount({ slug: 'imagify', ...data }), | |
| 164 | + createAccountCallback: createAccount, | |
| 152 | 165 | }, |
| 153 | 166 | metricool: { |
| 154 | - idempotent: false, | |
| 155 | 167 | createAccountCallback: async ({ |
| 156 | 168 | scriptData, |
| 169 | + endpoint, | |
| 157 | 170 | email, |
| 158 | 171 | marketingConsent, |
| 159 | 172 | termsAgreed, |
| 160 | 173 | signal, |
| @@ -165,11 +178,10 @@ | ||
| 165 | 178 | scriptData?.recaptchaSiteKey, |
| 166 | 179 | timings, |
| 167 | 180 | ); |
| 168 | 181 | |
| 169 | - // The "/v1" segment is what makes Metricool register its logout route. | |
| 170 | - await post({ | |
| 171 | - path: 'extendify/v1/metricool/v1/create-account', | |
| 182 | + return post({ | |
| 183 | + path: endpoint, | |
| 172 | 184 | data: { |
| 173 | 185 | email, |
| 174 | 186 | marketingConsent, |
| 175 | 187 | termsAgreed, |