| 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 |
|