PluginProbe
Extendify / 3.2.1
Extendify v3.2.1
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
← All changes | src/Shared/api/pluginsActivation.js +102 -41 3.1.43.2.1 View file →
@@ -1,51 +1,113 @@
1 1 import apiFetch from '@wordpress/api-fetch';
2 2 import { addQueryArgs } from '@wordpress/url';
3 3
4 -const getRecaptchaToken = (action, siteKey) =>
5 - new Promise((resolve, reject) => {
6 - if (!siteKey) {
7 - reject(new Error(`No reCAPTCHA site key for the ${action} action`));
4 +let recaptchaReady;
5 +const loadRecaptcha = () => {
6 + recaptchaReady ??= new Promise((resolve, reject) => {
7 + const ready = () => window.grecaptcha.enterprise.ready(resolve);
8 + if (window.grecaptcha?.enterprise) {
9 + ready();
8 10 return;
9 11 }
10 12
11 13 const existing = document.querySelector(
12 - `script[src*="recaptcha/enterprise"]`,
14 + 'script[src*="recaptcha/enterprise"]',
13 15 );
14 - const load = () =>
15 - window.grecaptcha.enterprise.ready(async () => {
16 - try {
17 - resolve(
18 - await window.grecaptcha.enterprise.execute(siteKey, { action }),
19 - );
20 - } catch (error) {
21 - reject(error);
22 - }
23 - });
24 -
25 16 if (existing) {
26 - load();
17 + existing.addEventListener('load', ready);
27 18 return;
28 19 }
29 20
30 21 const script = document.createElement('script');
31 - script.src = `https://www.google.com/recaptcha/enterprise.js?render=${siteKey}`;
22 + script.src =
23 + 'https://www.google.com/recaptcha/enterprise.js?render=explicit';
32 24 script.async = true;
33 - script.onload = load;
25 + script.onload = ready;
26 + script.onerror = () => {
27 + // A cached rejection would block every retry.
28 + recaptchaReady = undefined;
29 + reject(new Error('Failed to load the reCAPTCHA script'));
30 + };
34 31 document.head.appendChild(script);
35 32 });
33 + return recaptchaReady;
34 +};
36 35
37 -const createAccount = async ({
38 - slug,
36 +// enterprise.js can't load twice, and execute() needs a rendered site key —
37 +// one widget per key.
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 +
65 +const getRecaptchaToken = async (action, siteKey, timings = {}) => {
66 + if (!siteKey) {
67 + throw new Error(`No reCAPTCHA site key for the ${action} action`);
68 + }
69 +
70 + timings.captchaWasWarm = renderedKeys.has(siteKey);
71 + const start = Date.now();
72 +
73 + try {
74 + const widget = await prewarmRecaptcha(siteKey);
75 +
76 + // Without await, finally runs before execute settles and records ~0ms.
77 + return await window.grecaptcha.enterprise.execute(widget, { action });
78 + } finally {
79 + timings.captchaTimeInMs = Date.now() - start;
80 + }
81 +};
82 +
83 +// api-fetch throws the parsed body and drops the Response, so parse:false is the only way to keep the status.
84 +const post = async (options) => {
85 + try {
86 + const response = await apiFetch({
87 + ...options,
88 + method: 'POST',
89 + parse: false,
90 + });
91 + return await response.json().catch(() => undefined);
92 + } catch (error) {
93 + if (typeof error?.json !== 'function') throw error;
94 +
95 + const body = await error.json().catch(() => ({ code: 'invalid_json' }));
96 + throw { ...body, httpStatus: error.status };
97 + }
98 +};
99 +
100 +const createAccount = ({
101 + endpoint,
39 102 email,
40 103 marketingConsent,
41 104 termsAgreed,
42 105 signal,
43 106 scriptData,
44 -}) => {
45 - await apiFetch({
46 - path: `extendify/v1/${slug}/create-account`,
47 - method: 'POST',
107 +}) =>
108 + post({
109 + path: endpoint,
48 110 data: {
49 111 email,
50 112 marketingConsent,
51 113 termsAgreed,
@@ -52,39 +114,40 @@
52 114 ...scriptData,
53 115 },
54 116 signal,
55 117 });
56 -};
57 118
58 119 /*
59 120 * Plugin entries shape:
60 - * createAccountCallback: (data) => Promise<void> — performs the account creation request
61 - * idempotent: boolean (default true) — false skips retries; use when re-sending the same request could cause errors
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
62 124 */
63 125 export const pluginsActivation = {
64 126 simplybook: {
65 - idempotent: false,
66 127 createAccountCallback: async ({
67 128 scriptData,
129 + endpoint,
68 130 email,
69 131 marketingConsent,
70 132 termsAgreed,
71 133 signal,
134 + timings,
72 135 }) => {
73 136 const captchaToken = await getRecaptchaToken(
74 137 scriptData?.recaptchaAction,
75 138 scriptData?.recaptchaSiteKey,
139 + timings,
76 140 );
77 141
78 142 // Hit the endpoint via ?rest_route= so the request URL contains "simplybook" —
79 143 // SimplyBook only registers its onboarding routes when it does, else they 404.
80 144 const url = addQueryArgs(`${window.extSharedData.homeUrl}/`, {
81 - rest_route: '/extendify/v1/simplybook/create-account',
145 + rest_route: `/${endpoint}`,
82 146 });
83 147
84 - await apiFetch({
148 + return post({
85 149 url,
86 - method: 'POST',
87 150 data: {
88 151 email,
89 152 marketingConsent,
90 153 termsAgreed,
@@ -94,33 +157,31 @@
94 157 });
95 158 },
96 159 },
97 160 'translatepress-multilingual': {
98 - createAccountCallback: (data) =>
99 - createAccount({ slug: 'translatepress-multilingual', ...data }),
161 + createAccountCallback: createAccount,
100 162 },
101 163 imagify: {
102 - createAccountCallback: (data) =>
103 - createAccount({ slug: 'imagify', ...data }),
164 + createAccountCallback: createAccount,
104 165 },
105 166 metricool: {
106 - idempotent: false,
107 167 createAccountCallback: async ({
108 168 scriptData,
169 + endpoint,
109 170 email,
110 171 marketingConsent,
111 172 termsAgreed,
112 173 signal,
174 + timings,
113 175 }) => {
114 176 const captchaToken = await getRecaptchaToken(
115 177 scriptData?.recaptchaAction,
116 178 scriptData?.recaptchaSiteKey,
179 + timings,
117 180 );
118 181
119 - // The "/v1" segment is what makes Metricool register its logout route.
120 - await apiFetch({
121 - path: 'extendify/v1/metricool/v1/create-account',
122 - method: 'POST',
182 + return post({
183 + path: endpoint,
123 184 data: {
124 185 email,
125 186 marketingConsent,
126 187 termsAgreed,