PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / ajax / SupportRequest.js

SupportRequest.js in 404 Solution trunk, at includes/ajax/SupportRequest.js

270 lines 11.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Client for the abj404_support_request AJAX endpoint.
3 *
4 * Exposes window.abj404SupportRequest.send({triggered_from, user_message,
5 * reply_email}) for the reusable button UI added in task B. Returns a
6 * Promise that resolves to {ok, reference_id, fallback_used} on success
7 * or rejects with {status, message, retry_after_seconds?} on error. A
8 * genuine network failure rejects with the native fetch TypeError (never
9 * caught here); a stalled response past SEND_TIMEOUT_MS is aborted and
10 * surfaces as that same TypeError shape so callers' existing
11 * `err instanceof TypeError` network-failure branch (see
12 * support-request-button.js's doSend) handles both identically.
13 *
14 * The nonce is read from a global ABJ404 namespace populated server-side:
15 * ABJ404.nonces.support_request = '<wp_create_nonce(...)>';
16 * Callers do NOT have to pass the nonce; it is injected automatically.
17 */
18
19 (function (window, abj404Module) {
20 if (window.abj404ClientBuildRegistry) {
21 window.abj404ClientBuildRegistry.register('support_request', abj404Module);
22 }
23 abj404Module(window);
24 }(window, /* abj404-client-module:start */ function (window) {
25 'use strict';
26
27 function resolveNonce() {
28 if (window.ABJ404 && window.ABJ404.nonces && window.ABJ404.nonces.support_request) {
29 return String(window.ABJ404.nonces.support_request);
30 }
31 return '';
32 }
33
34 function resolveAjaxUrl() {
35 if (typeof window.ajaxurl === 'string' && window.ajaxurl) {
36 return window.ajaxurl;
37 }
38 if (window.ABJ404 && window.ABJ404.ajaxurl) {
39 return String(window.ABJ404.ajaxurl);
40 }
41 return '/wp-admin/admin-ajax.php';
42 }
43
44 // Submission is a one-shot admin action; a generous bound avoids a
45 // false-positive abort on a slow-but-working shared host while still
46 // guaranteeing the caller's Promise cannot stay pending forever if
47 // admin-ajax never responds (M501).
48 var SEND_TIMEOUT_MS = 30000;
49
50 /**
51 * Hard bound on the drained client transport telemetry. The server bounds
52 * the same value again; this keeps a pathological buffer from inflating
53 * the request before it is even sent.
54 */
55 var MAX_TELEMETRY_CHARS = 32000;
56
57 /**
58 * The only attempt outcome that means "did not fail". An allowlist, not a
59 * deny-list of failure words: 'pending' is an attempt that never finished
60 * (the hung request itself) and an unrecognised or absent outcome is an
61 * unknown, which is worth more than a known success when something has to
62 * be dropped.
63 */
64 var HEALTHY_OUTCOMES = ['success'];
65
66 /**
67 * The attempt records that fit in MAX_TELEMETRY_CHARS, in their original
68 * order, failures first.
69 *
70 * Whole records are dropped, never bytes. The buffer store holds up to
71 * 48 KB while this bound is 32 KB, so slicing the serialized array at a
72 * character offset -- which is what this did -- produced invalid JSON
73 * whenever the buffer was full. The server then reported the entire
74 * client-side story as unparseable, which is total loss on the ONE channel
75 * that can describe attempts the server never saw. Failed attempts are
76 * kept oldest first (the first failure has no retry effects in it) and
77 * successes newest first.
78 *
79 * @param {Array<object>} records
80 * @returns {Array<object>}
81 */
82 function recordsWithinBound(records) {
83 var failed = [];
84 var healthy = [];
85 records.forEach(function (record, position) {
86 var outcome = record && typeof record.outcome === 'string' ? record.outcome : '';
87 if (HEALTHY_OUTCOMES.indexOf(outcome) === -1) {
88 failed.push(position);
89 } else {
90 healthy.push(position);
91 }
92 });
93
94 // Two brackets, plus one comma per record after the first.
95 var used = 2;
96 var keep = [];
97 failed.concat(healthy.reverse()).forEach(function (position) {
98 var encoded = JSON.stringify(records[position]);
99 if (typeof encoded !== 'string') {
100 return;
101 }
102 var cost = encoded.length + (keep.length === 0 ? 0 : 1);
103 if (used + cost > MAX_TELEMETRY_CHARS) {
104 return;
105 }
106 used += cost;
107 keep.push(position);
108 });
109
110 return keep.sort(function (left, right) { return left - right; })
111 .map(function (position) { return records[position]; });
112 }
113
114 /**
115 * Drain the browser's transport-attempt buffer into the support payload.
116 *
117 * This is the only channel that carries records the server never saw:
118 * attempts whose retry never happened because the admin gave up, and
119 * attempts from a page that was closed before the next request went out.
120 * Returns '' when the telemetry module is absent or the buffer is empty.
121 *
122 * @return {string}
123 */
124 function drainClientTelemetry() {
125 try {
126 if (!window.abj404ClientTelemetryStore) {
127 return '';
128 }
129 var records = window.abj404ClientTelemetryStore.readAll();
130 if (!records || records.length === 0) {
131 return '';
132 }
133 var serialized = JSON.stringify(records);
134 if (serialized.length <= MAX_TELEMETRY_CHARS) {
135 return serialized;
136 }
137 return JSON.stringify(recordsWithinBound(records));
138 } catch (drainError) {
139 if (window.console && window.console.warn) {
140 window.console.warn('404 Solution: could not drain client transport telemetry', drainError);
141 }
142 return '';
143 }
144 }
145
146 /**
147 * The browser session (tab) this report is being sent from.
148 *
149 * Every table request already carries this id, and the server journals the
150 * detach A/B mode it chose under it. Sending it here is what lets the
151 * server decide the experiment for THIS tab at harvest time: the checkpoint
152 * journal is site-wide, so without the id a second admin tab's attempts
153 * would be indistinguishable from this one's. Returns '' when the telemetry
154 * modules are not on the page (the plugins-list row action, a corrupt
155 * install's degraded screen), which the server reports as "no session"
156 * rather than as a measured result.
157 *
158 * @return {string}
159 */
160 function browserSessionId() {
161 try {
162 var env = window.abj404ClientTelemetryEnv;
163 return env && typeof env.sessionId === 'function' ? String(env.sessionId()) : '';
164 } catch (sessionError) {
165 if (window.console && window.console.warn) {
166 window.console.warn('404 Solution: could not read the client session id', sessionError);
167 }
168 return '';
169 }
170 }
171
172 /**
173 * Send a support request. Returns a Promise.
174 *
175 * @param {Object} args
176 * @param {string} args.triggered_from Required. Must match the server
177 * allowlist (redirects_page, captured_404s_page, plugins_row_action,
178 * settings_debug, system_corrupt_install).
179 * @param {string} [args.user_message] Optional, max 2000 chars.
180 * @param {string} [args.reply_email] Optional.
181 * @return {Promise<Object>}
182 */
183 function send(args) {
184 args = args || {};
185 var formData = new FormData();
186 formData.append('action', 'abj404_support_request');
187 formData.append('nonce', resolveNonce());
188 formData.append('triggered_from', String(args.triggered_from || ''));
189 if (typeof args.user_message === 'string') {
190 formData.append('user_message', args.user_message);
191 }
192 if (typeof args.reply_email === 'string') {
193 formData.append('reply_email', args.reply_email);
194 }
195 var clientTelemetry = drainClientTelemetry();
196 if (clientTelemetry !== '') {
197 formData.append('client_telemetry', clientTelemetry);
198 }
199 var sessionId = browserSessionId();
200 if (sessionId !== '') {
201 formData.append('sessionId', sessionId);
202 }
203
204 var controller = new AbortController();
205 var timeoutId = setTimeout(function () { controller.abort(); }, SEND_TIMEOUT_MS);
206
207 // ajax-direct-approved: this is the dedicated fetch-based API client for the support-request endpoint
208 return fetch(resolveAjaxUrl(), { // allow-direct-network: this IS the dedicated fetch-based API client for the support-request endpoint
209 method: 'POST',
210 credentials: 'same-origin',
211 body: formData,
212 signal: controller.signal
213 }).then(function (response) {
214 clearTimeout(timeoutId);
215 // Clone before reading: a Response body can only be consumed
216 // once, and a JSON-parse failure needs the raw text (WAF block
217 // page, gateway timeout HTML, PHP fatal output) so the failure
218 // is diagnosable instead of collapsing into a generic message.
219 return response.clone().text().then(function (rawText) {
220 var json;
221 try {
222 json = JSON.parse(rawText);
223 } catch (parseError) {
224 if (window.console && window.console.error) {
225 window.console.error('404 Solution: support request response was not valid JSON', {
226 status: response.status,
227 bodySnippet: rawText.slice(0, 500),
228 parseError: parseError.message
229 });
230 }
231 return { status: response.status, body: null };
232 }
233 return { status: response.status, body: json };
234 });
235 }).then(function (wrapped) {
236 var body = wrapped.body || {};
237 var data = body.data || {};
238 if (body.success === true) {
239 return data;
240 }
241 var err = {
242 status: wrapped.status,
243 message: data.message || 'Support request failed.'
244 };
245 if (typeof data.retry_after_seconds === 'number') {
246 err.retry_after_seconds = data.retry_after_seconds;
247 }
248 if (typeof data.fallback_used === 'boolean') {
249 err.fallback_used = data.fallback_used;
250 }
251 throw err;
252 }).catch(function (err) {
253 clearTimeout(timeoutId);
254 if (err && err.name === 'AbortError') {
255 // A stalled response never resolves the request; treat the
256 // timeout as the same network-class failure the native
257 // TypeError already represents, so it flows through the
258 // caller's existing `err instanceof TypeError` branch
259 // (support-request-button.js's doSend) instead of falling
260 // into the generic markFailure() message path.
261 throw new TypeError('Request timed out'); // allow-raw-error: reuses the native TypeError shape callers already branch on; not a new error contract
262 }
263 throw err;
264 });
265 }
266
267 window.abj404SupportRequest = { send: send };
268
269 } /* abj404-client-module:end */));
270