| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* The browser's half of the request ledger (Bruno timeout cause matrix, |
| 9 |
* coverage req. 6). |
| 10 |
* |
| 11 |
* The server flight recorder can prove what PHP did with a request. It cannot |
| 12 |
* prove that the request ever left the browser, how long it sat in the browser |
| 13 |
* connection queue, whether response headers arrived and the body then stalled, |
| 14 |
* or whether the completion callback lost a race to jQuery's timeout timer. |
| 15 |
* Only the browser can answer those, and only if what it observed gets back |
| 16 |
* here. This class is where it lands. |
| 17 |
* |
| 18 |
* Two arrival routes, both authenticated exactly like a normal table request: |
| 19 |
* |
| 20 |
* 1. Riding the next table request's params (the primary route: the client |
| 21 |
* attaches its previous attempt's record to the following request, so the |
| 22 |
* evidence arrives even if the admin never sends a support request). |
| 23 |
* 2. A clientReportOnly beacon fired after the last attempt of a failed |
| 24 |
* request, which does no table work and returns immediately. |
| 25 |
* |
| 26 |
* Reports are journaled through ABJ_404_Solution_AjaxCheckpointLogger rather |
| 27 |
* than the trace class, for the same reason the server checkpoints are: a |
| 28 |
* defect in the component under investigation must not be able to erase the |
| 29 |
* evidence about it. The payload is treated as untrusted text throughout: it |
| 30 |
* is length-bounded, parsed defensively, and never echoed back to any client. |
| 31 |
*/ |
| 32 |
final class ABJ_404_Solution_ClientTransportReport { |
| 33 |
|
| 34 |
/** |
| 35 |
* Hard bound on a single client report. The client trims its own record to |
| 36 |
* 4000 characters before sending; this is the server refusing to journal |
| 37 |
* more than that regardless of what actually arrives. |
| 38 |
*/ |
| 39 |
const MAX_REPORT_BYTES = 4096; |
| 40 |
|
| 41 |
/** |
| 42 |
* Hard bound on the raw drained buffer BEFORE it is parsed. Only an input |
| 43 |
* guard against an absurd POST; the shipping bound is the caller's budget. |
| 44 |
*/ |
| 45 |
const MAX_DRAINED_BUFFER_INPUT_BYTES = 131072; |
| 46 |
|
| 47 |
/** |
| 48 |
* The only attempt outcome that means "did not fail". An allowlist, not a |
| 49 |
* deny-list: 'pending' is an attempt that never finished (the hung request |
| 50 |
* itself) and an unrecognised or absent outcome is an unknown, which is |
| 51 |
* worth more than a known success when something has to be dropped. |
| 52 |
*/ |
| 53 |
const HEALTHY_OUTCOMES = array('success'); |
| 54 |
|
| 55 |
/** |
| 56 |
* Attempt ids carried into the support-collection manifest. The browser's |
| 57 |
* own ring buffer holds 16 records, so this is that ceiling plus headroom |
| 58 |
* for a buffer that arrives from an older or a modified client. |
| 59 |
*/ |
| 60 |
const MAX_ATTEMPT_IDS_REPORTED = 32; |
| 61 |
|
| 62 |
/** |
| 63 |
* Read, bound, and journal whatever the browser said about a previous |
| 64 |
* attempt, plus the build identity of the JavaScript that said it. Never |
| 65 |
* throws: a malformed or absent report must not affect the request that |
| 66 |
* carried it. |
| 67 |
*/ |
| 68 |
public static function journal(string $requestId): void { |
| 69 |
try { |
| 70 |
$reader = self::requestReader(); |
| 71 |
$build = (string)$reader->getPostOrGetSanitize('clientBuild', ''); |
| 72 |
$buildModules = (string)$reader->getPostOrGetSanitize('clientBuildModules', ''); |
| 73 |
$inflight = (string)$reader->getPostOrGetSanitize('clientInflight', ''); |
| 74 |
$tabs = (string)$reader->getPostOrGetSanitize('clientTabs', ''); |
| 75 |
$foreignInflight = (string)$reader->getPostOrGetSanitize('clientForeignInflight', ''); |
| 76 |
$storageHealth = (string)$reader->getPostOrGetSanitize('clientStorageHealth', ''); |
| 77 |
if ($build !== '' || $inflight !== '' || $tabs !== '' || $foreignInflight !== '' || |
| 78 |
$storageHealth !== '') { |
| 79 |
// What the client said about ITSELF at send time: which |
| 80 |
// JavaScript is executing, how many other plugin requests that |
| 81 |
// tab already had open, how many admin tabs of the page are |
| 82 |
// open at all, and how much non-plugin AJAX the tab had |
| 83 |
// outstanding. The last two are the browser's half of the |
| 84 |
// same-site contention the server counts in |
| 85 |
// ABJ_404_Solution_SameSiteRequestCensus; neither used to be |
| 86 |
// recorded anywhere, so a cross-tab cause could not even be |
| 87 |
// suspected from the evidence that survived. The previous |
| 88 |
// attempt's story is a separate record below. |
| 89 |
ABJ_404_Solution_AjaxCheckpointLogger::record( |
| 90 |
$requestId, |
| 91 |
'client_send_state', |
| 92 |
array_merge( |
| 93 |
ABJ_404_Solution_ClientBuildFingerprint::compare($build, $buildModules), |
| 94 |
array( |
| 95 |
'inflight' => ctype_digit($inflight) ? (int)$inflight : null, |
| 96 |
'inflight_ids' => substr( |
| 97 |
(string)$reader->getPostOrGetSanitize('clientInflightIds', ''), 0, 256), |
| 98 |
// -1 is the client's own "could not observe this", |
| 99 |
// and it is preserved rather than folded into null: |
| 100 |
// an unobservable channel and an absent parameter |
| 101 |
// are different findings about the client. |
| 102 |
'open_tabs' => self::signedCountOrNull($tabs), |
| 103 |
'foreign_inflight' => self::signedCountOrNull($foreignInflight), |
| 104 |
'storage_health' => self::parseStorageHealth($storageHealth), |
| 105 |
) |
| 106 |
) |
| 107 |
); |
| 108 |
} |
| 109 |
$report = self::readReport($reader); |
| 110 |
if ($report === null) { |
| 111 |
return; |
| 112 |
} |
| 113 |
// Nested under one key, never spread across the record: the |
| 114 |
// envelope's own fields (request_id, event, ts, pid) are the join |
| 115 |
// keys the whole journal is read by, and a client that sent a |
| 116 |
// field with one of those names would otherwise overwrite them and |
| 117 |
// forge the identity of its own evidence. |
| 118 |
if (ABJ_404_Solution_ConcurrentControlReceipt::isBrowserReceipt($report)) { |
| 119 |
ABJ_404_Solution_ConcurrentControlReceipt::journal(array( |
| 120 |
'carrierRequestId' => $requestId, |
| 121 |
'sessionId' => (string)ABJ_404_Solution_AjaxRequestLedger::readFields($reader)['session_id'], |
| 122 |
'report' => $report, |
| 123 |
)); |
| 124 |
return; |
| 125 |
} |
| 126 |
ABJ_404_Solution_AjaxCheckpointLogger::record( |
| 127 |
$requestId, 'client_prior_attempt', array('report' => $report)); |
| 128 |
} catch (Throwable $e) { |
| 129 |
ABJ_404_Solution_AjaxCheckpointLogger::record($requestId, 'client_report_error', array( |
| 130 |
'message' => substr($e->getMessage(), 0, 200), |
| 131 |
)); |
| 132 |
} |
| 133 |
} |
| 134 |
|
| 135 |
|
| 136 |
/** |
| 137 |
* A client-sent count that is allowed to be -1 ("this browser could not |
| 138 |
* observe it"), or null when the parameter was absent or not a count at |
| 139 |
* all. Kept separate from ctype_digit() because -1 is a real reading here |
| 140 |
* and silently discarding it would turn a declared blind spot into a |
| 141 |
* missing field. |
| 142 |
*/ |
| 143 |
private static function signedCountOrNull(string $raw): ?int { |
| 144 |
return preg_match('/^-?\d{1,9}$/', $raw) === 1 ? (int)$raw : null; |
| 145 |
} |
| 146 |
|
| 147 |
/** |
| 148 |
* The browser storage adapter's bounded health result. Rebuild the shape |
| 149 |
* field by field because this is untrusted request data; malformed input |
| 150 |
* remains a positive "unparseable" finding rather than blocking the table. |
| 151 |
* |
| 152 |
* @return array<string, mixed> |
| 153 |
*/ |
| 154 |
private static function parseStorageHealth(string $raw): array { |
| 155 |
if ($raw === '') { |
| 156 |
return array('status' => 'absent', 'raw_length' => 0); |
| 157 |
} |
| 158 |
$decoded = json_decode(substr($raw, 0, 512), true); |
| 159 |
if (!is_array($decoded)) { |
| 160 |
return array('status' => 'unparseable', 'raw_length' => strlen($raw)); |
| 161 |
} |
| 162 |
$status = isset($decoded['status']) && is_scalar($decoded['status']) |
| 163 |
? (string)$decoded['status'] : 'unknown'; |
| 164 |
$quota = isset($decoded['quota']) && is_scalar($decoded['quota']) |
| 165 |
? (string)$decoded['quota'] : 'unknown'; |
| 166 |
$fallback = isset($decoded['fallback']) && is_scalar($decoded['fallback']) |
| 167 |
? (string)$decoded['fallback'] : 'memory'; |
| 168 |
return array( |
| 169 |
'status' => in_array($status, array('available', 'unavailable'), true) ? $status : 'unknown', |
| 170 |
'accessible' => is_bool($decoded['accessible'] ?? null) ? $decoded['accessible'] : null, |
| 171 |
'writable' => is_bool($decoded['writable'] ?? null) ? $decoded['writable'] : null, |
| 172 |
'quota' => in_array($quota, array('ok', 'exceeded', 'unknown'), true) ? $quota : 'unknown', |
| 173 |
'last_write_ok' => is_bool($decoded['last_write_ok'] ?? null) |
| 174 |
? $decoded['last_write_ok'] : null, |
| 175 |
'fallback' => in_array($fallback, array('none', 'memory'), true) ? $fallback : 'memory', |
| 176 |
); |
| 177 |
} |
| 178 |
|
| 179 |
/** |
| 180 |
* The request reader, straight from the container. |
| 181 |
* |
| 182 |
* Resolved here rather than through |
| 183 |
* ABJ_404_Solution_AjaxAdminEndpointSupport::getRequestReader(), which |
| 184 |
* returns this same service and belongs to the endpoint layer. Reading |
| 185 |
* request parameters is not an endpoint-only need, and routing through |
| 186 |
* that class made a recorder depend on the presentation surface it exists |
| 187 |
* to observe. |
| 188 |
* |
| 189 |
* @return ABJ_404_Solution_RequestInputNormalizer |
| 190 |
*/ |
| 191 |
private static function requestReader() { |
| 192 |
/** @var ABJ_404_Solution_RequestInputNormalizer $requestReader */ |
| 193 |
$requestReader = abj_service('request_input_normalizer'); |
| 194 |
return $requestReader; |
| 195 |
} |
| 196 |
|
| 197 |
/** |
| 198 |
* The decoded client report, or null when none was sent. Returns a |
| 199 |
* diagnostic stand-in (never null) when a report was sent but could not be |
| 200 |
* decoded: "the client sent something unparseable" is itself a finding |
| 201 |
* about the transport and must not be silently dropped. |
| 202 |
* |
| 203 |
* @param ABJ_404_Solution_RequestInputNormalizer $reader Docblock-typed only: |
| 204 |
* tests substitute request-reader doubles that are not literally that class. |
| 205 |
* @return array<string, mixed>|null |
| 206 |
*/ |
| 207 |
private static function readReport($reader): ?array { |
| 208 |
$raw = $reader->getPostOrGetSanitize('clientReport', ''); |
| 209 |
if (!is_scalar($raw) || (string)$raw === '') { |
| 210 |
return null; |
| 211 |
} |
| 212 |
$raw = (string)$raw; |
| 213 |
$truncated = strlen($raw) > self::MAX_REPORT_BYTES; |
| 214 |
$decoded = json_decode(substr($raw, 0, self::MAX_REPORT_BYTES), true); |
| 215 |
if (!is_array($decoded)) { |
| 216 |
return array( |
| 217 |
'decoded' => false, |
| 218 |
'json_error' => json_last_error_msg(), |
| 219 |
'raw_length' => strlen($raw), |
| 220 |
'raw_head' => substr($raw, 0, 200), |
| 221 |
); |
| 222 |
} |
| 223 |
// Rebuilt key by key rather than passed through: the decoded value is |
| 224 |
// whatever the browser sent, so its keys are only assumed to be |
| 225 |
// strings until they are made so here. |
| 226 |
$report = array(); |
| 227 |
foreach ($decoded as $key => $value) { |
| 228 |
$report[(string)$key] = $value; |
| 229 |
} |
| 230 |
$report['decoded'] = true; |
| 231 |
$report['truncated_on_arrival'] = $truncated; |
| 232 |
return $report; |
| 233 |
} |
| 234 |
|
| 235 |
/** |
| 236 |
* The attempt outcomes the browser says its drained buffer describes. |
| 237 |
* |
| 238 |
* The support payload is the one place both halves of the request ledger |
| 239 |
* meet, so "the browser is reporting attempt X and the collected journals |
| 240 |
* never mention X" is a decisive fact about the COLLECTION rather than |
| 241 |
* about the request -- and it is only available if the ids the browser |
| 242 |
* named are read before the buffer is bounded down to fit the payload. |
| 243 |
* Parsing lives here, next to boundDrainedBuffer(), because this class |
| 244 |
* already owns every rule about what that buffer is; the manifest that |
| 245 |
* consumes this owns none of them. |
| 246 |
* |
| 247 |
* The three statuses are kept distinct on purpose: "the browser sent |
| 248 |
* nothing" and "the browser sent something we could not read" are |
| 249 |
* different findings, and collapsing the second into an empty id list is |
| 250 |
* the same silent-empty defect this whole manifest exists to end. |
| 251 |
* |
| 252 |
* @param string $raw The raw POSTed buffer, already unslashed. |
| 253 |
* A failure is sticky across duplicate records. Browser storage is a |
| 254 |
* ring buffer and a retry can leave more than one account of an attempt; |
| 255 |
* a later success must not erase an earlier timeout, and a later timeout |
| 256 |
* must still override an earlier success. Only the explicit `success` |
| 257 |
* outcome is healthy, matching the ranking rules used after journaling. |
| 258 |
* |
| 259 |
* @return array{status: string, ids: array<int, string>, records: int, outcomes: array<string, bool>} |
| 260 |
* status: `absent`, `unparseable`, or `parsed`. |
| 261 |
*/ |
| 262 |
public static function attemptOutcomesInDrainedBuffer(string $raw): array { |
| 263 |
if ($raw === '') { |
| 264 |
return array( |
| 265 |
'status' => 'absent', 'ids' => array(), 'records' => 0, 'outcomes' => array(), |
| 266 |
); |
| 267 |
} |
| 268 |
$boundedRaw = substr($raw, 0, self::MAX_DRAINED_BUFFER_INPUT_BYTES); |
| 269 |
$decoded = json_decode($boundedRaw, true); |
| 270 |
if (!is_array($decoded) || !self::isJsonArrayDocument($boundedRaw)) { |
| 271 |
return array( |
| 272 |
'status' => 'unparseable', 'ids' => array(), 'records' => 0, 'outcomes' => array(), |
| 273 |
); |
| 274 |
} |
| 275 |
$ids = array(); |
| 276 |
$outcomes = array(); |
| 277 |
foreach ($decoded as $record) { |
| 278 |
if (!is_array($record) || !isset($record['id']) || !is_scalar($record['id'])) { |
| 279 |
continue; |
| 280 |
} |
| 281 |
$id = (string)$record['id']; |
| 282 |
// The wire contract's own request-id shape. An id that cannot be a |
| 283 |
// server request id cannot be reconciled against one, and letting |
| 284 |
// arbitrary browser text into the manifest would put an unbounded |
| 285 |
// string in a bounded record. |
| 286 |
if (preg_match('/^[a-zA-Z0-9]{1,64}$/', $id) !== 1) { |
| 287 |
continue; |
| 288 |
} |
| 289 |
$ids[$id] = true; |
| 290 |
$outcome = isset($record['outcome']) && is_scalar($record['outcome']) |
| 291 |
? (string)$record['outcome'] : ''; |
| 292 |
$healthy = in_array($outcome, self::HEALTHY_OUTCOMES, true); |
| 293 |
if (!array_key_exists($id, $outcomes) || !$healthy) { |
| 294 |
$outcomes[$id] = $healthy; |
| 295 |
} |
| 296 |
} |
| 297 |
return array( |
| 298 |
'status' => 'parsed', |
| 299 |
'ids' => array_slice(array_keys($ids), 0, self::MAX_ATTEMPT_IDS_REPORTED), |
| 300 |
'records' => count($decoded), |
| 301 |
'outcomes' => $outcomes, |
| 302 |
); |
| 303 |
} |
| 304 |
|
| 305 |
/** |
| 306 |
* Fit the browser's drained attempt buffer inside a byte budget WITHOUT |
| 307 |
* destroying it. |
| 308 |
* |
| 309 |
* The buffer is a JSON array of per-attempt records, and it can exceed |
| 310 |
* what the support payload will carry: the browser store holds up to 16 |
| 311 |
* records / 48 KB. Cutting the serialized array at a byte offset -- which |
| 312 |
* is what both ends used to do -- leaves invalid JSON, so an overflowing |
| 313 |
* buffer arrived as "unparseable" and EVERY attempt was lost rather than |
| 314 |
* the least interesting one. That is the same defect the journal excerpt |
| 315 |
* had, on the one channel that can describe attempts the server never saw |
| 316 |
* at all. |
| 317 |
* |
| 318 |
* So whole records are dropped, not bytes, and the ones kept are chosen: |
| 319 |
* attempts that did not succeed first (oldest first, because the first |
| 320 |
* failure is the one without retry effects), then the rest newest first. |
| 321 |
* |
| 322 |
* @param string $raw The raw POSTed buffer. |
| 323 |
* @param int $budgetBytes Ceiling for the returned JSON. |
| 324 |
* @return array{json: string, parsed: bool, kept: int, dropped: int, raw_length: int, error: string} |
| 325 |
*/ |
| 326 |
public static function boundDrainedBuffer(string $raw, int $budgetBytes): array { |
| 327 |
$rawLength = strlen($raw); |
| 328 |
$unparseable = array( |
| 329 |
'json' => '', 'parsed' => false, 'kept' => 0, 'dropped' => 0, |
| 330 |
'raw_length' => $rawLength, 'error' => '', |
| 331 |
); |
| 332 |
if ($raw === '') { |
| 333 |
return $unparseable; |
| 334 |
} |
| 335 |
$boundedRaw = substr($raw, 0, self::MAX_DRAINED_BUFFER_INPUT_BYTES); |
| 336 |
$decoded = json_decode($boundedRaw, true); |
| 337 |
if (!is_array($decoded) || !self::isJsonArrayDocument($boundedRaw)) { |
| 338 |
$unparseable['error'] = is_array($decoded) |
| 339 |
? 'expected a JSON array of attempt records' |
| 340 |
: json_last_error_msg(); |
| 341 |
return $unparseable; |
| 342 |
} |
| 343 |
$records = array(); |
| 344 |
foreach ($decoded as $record) { |
| 345 |
$records[] = $record; |
| 346 |
} |
| 347 |
if ($rawLength <= self::MAX_DRAINED_BUFFER_INPUT_BYTES && $rawLength <= $budgetBytes) { |
| 348 |
return array( |
| 349 |
'json' => $raw, 'parsed' => true, 'kept' => count($records), 'dropped' => 0, |
| 350 |
'raw_length' => $rawLength, 'error' => '', |
| 351 |
); |
| 352 |
} |
| 353 |
|
| 354 |
$kept = self::keepWithinBudget($records, $budgetBytes); |
| 355 |
$json = json_encode($kept, JSON_UNESCAPED_SLASHES); |
| 356 |
if (!is_string($json) || strlen($json) > $budgetBytes) { |
| 357 |
$unparseable['error'] = 'buffer could not be reduced to the support budget'; |
| 358 |
return $unparseable; |
| 359 |
} |
| 360 |
return array( |
| 361 |
'json' => $json, 'parsed' => true, 'kept' => count($kept), |
| 362 |
'dropped' => count($records) - count($kept), 'raw_length' => $rawLength, 'error' => '', |
| 363 |
); |
| 364 |
} |
| 365 |
|
| 366 |
/** |
| 367 |
* Whether decoded JSON came from the buffer's required top-level array. |
| 368 |
* |
| 369 |
* Associative decoding turns both JSON objects and arrays into PHP arrays, |
| 370 |
* so the decoded type alone cannot enforce the wire contract. Inspecting |
| 371 |
* the first non-whitespace byte keeps a valid object from being reported as |
| 372 |
* a successfully parsed empty attempt list. |
| 373 |
*/ |
| 374 |
private static function isJsonArrayDocument(string $raw): bool { |
| 375 |
return substr(ltrim($raw), 0, 1) === '['; |
| 376 |
} |
| 377 |
|
| 378 |
/** |
| 379 |
* The records that fit, in their original order, failures first. |
| 380 |
* |
| 381 |
* @param array<int, mixed> $records |
| 382 |
* @return array<int, mixed> |
| 383 |
*/ |
| 384 |
private static function keepWithinBudget(array $records, int $budgetBytes): array { |
| 385 |
$failed = array(); |
| 386 |
$healthy = array(); |
| 387 |
foreach ($records as $position => $record) { |
| 388 |
$outcome = is_array($record) && isset($record['outcome']) && is_scalar($record['outcome']) |
| 389 |
? (string)$record['outcome'] : ''; |
| 390 |
if (in_array($outcome, self::HEALTHY_OUTCOMES, true)) { |
| 391 |
$healthy[] = $position; |
| 392 |
} else { |
| 393 |
$failed[] = $position; |
| 394 |
} |
| 395 |
} |
| 396 |
$order = array_merge($failed, array_reverse($healthy)); |
| 397 |
|
| 398 |
// Two brackets and the commas between the records; charged up front so |
| 399 |
// the encoded result cannot creep past the budget on the last record. |
| 400 |
$used = 2; |
| 401 |
$keepPositions = array(); |
| 402 |
foreach ($order as $position) { |
| 403 |
$encoded = json_encode($records[$position], JSON_UNESCAPED_SLASHES); |
| 404 |
if (!is_string($encoded)) { |
| 405 |
continue; |
| 406 |
} |
| 407 |
$cost = strlen($encoded) + ($keepPositions === array() ? 0 : 1); |
| 408 |
if ($used + $cost > $budgetBytes) { |
| 409 |
continue; |
| 410 |
} |
| 411 |
$used += $cost; |
| 412 |
$keepPositions[] = $position; |
| 413 |
} |
| 414 |
sort($keepPositions); |
| 415 |
|
| 416 |
$kept = array(); |
| 417 |
foreach ($keepPositions as $position) { |
| 418 |
$kept[] = $records[$position]; |
| 419 |
} |
| 420 |
return $kept; |
| 421 |
} |
| 422 |
} |
| 423 |
|