| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* Encodes an AJAX response body to JSON, and always produces bytes. |
| 9 |
* |
| 10 |
* WHY THIS EXISTS. `json_encode()` returns `false` for a payload it cannot |
| 11 |
* represent -- most reachably a string that is not valid UTF-8, which any |
| 12 |
* co-plugin hooked on `gettext`, `the_title` or any other WordPress filter our |
| 13 |
* admin table HTML passes through can introduce. `echo false` writes zero |
| 14 |
* bytes. The response then leaves the server as HTTP 200 with |
| 15 |
* `Content-type: application/json` and an empty body: the browser calls that |
| 16 |
* `parsererror`, the admin table never renders, and nothing anywhere says why. |
| 17 |
* That is a silent error path, and this class is its removal. |
| 18 |
* |
| 19 |
* THE LADDER, in the order the self-healing philosophy asks for -- try, |
| 20 |
* recover, retry, then notify: |
| 21 |
* |
| 22 |
* 1. Encode as-is. The overwhelming majority of responses stop here and pay |
| 23 |
* nothing for the rest of this class. |
| 24 |
* 2. Malformed UTF-8: re-encode with JSON_INVALID_UTF8_SUBSTITUTE. The user |
| 25 |
* gets their table; the offending bytes read as U+FFFD. Recovery the admin |
| 26 |
* never has to know about. |
| 27 |
* 3. Anything else (a resource, a recursive structure, INF/NAN, depth): |
| 28 |
* re-encode with JSON_PARTIAL_OUTPUT_ON_ERROR so the branches that CAN be |
| 29 |
* represented still arrive. |
| 30 |
* 4. Only if all three fail -- including when step 3 returns bytes that are |
| 31 |
* not parseable JSON, which it does for a payload nested past the depth |
| 32 |
* limit -- ABJ_404_Solution_AjaxErrorEnvelope carrying the real |
| 33 |
* json_last_error_msg(), so the admin screen shows an actionable message |
| 34 |
* rather than a dead table. That envelope reduces its reason to bounded |
| 35 |
* printable ASCII before encoding, so it cannot itself hit the failure it |
| 36 |
* is reporting. |
| 37 |
* |
| 38 |
* Every step after the first is checked by PARSING what it produced, not by |
| 39 |
* testing it against false. A body can be a non-empty string and still be |
| 40 |
* unreadable, and that is the same dead table by another route. |
| 41 |
* |
| 42 |
* Fixing the CONSUMER, not the input (defensive philosophy #10): the plugin |
| 43 |
* cannot stop a foreign filter handing it bad bytes, and constraining every |
| 44 |
* upstream producer would be an endless game. Making the one encoder tolerant |
| 45 |
* ends the class in a single place. |
| 46 |
* |
| 47 |
* @since 4.3.5 |
| 48 |
*/ |
| 49 |
final class ABJ_404_Solution_JsonResponseEncoder { |
| 50 |
|
| 51 |
/** |
| 52 |
* Encode a response payload, degrading rather than failing. |
| 53 |
* |
| 54 |
* @param mixed $payload |
| 55 |
* @return ABJ_404_Solution_EncodedJsonResponse Never carries a false json(). |
| 56 |
*/ |
| 57 |
public static function encode($payload): ABJ_404_Solution_EncodedJsonResponse { |
| 58 |
$json = json_encode($payload); |
| 59 |
if (is_string($json)) { |
| 60 |
return new ABJ_404_Solution_EncodedJsonResponse( |
| 61 |
$json, ABJ_404_Solution_EncodedJsonResponse::STRATEGY_DIRECT); |
| 62 |
} |
| 63 |
|
| 64 |
// Captured from the FIRST failure. Every later attempt overwrites |
| 65 |
// json_last_error(), and the first one is the diagnosis: UTF8 means a |
| 66 |
// producer handed us bad bytes, RECURSION or DEPTH means the payload |
| 67 |
// shape is wrong, INF_OR_NAN means an arithmetic bug upstream. |
| 68 |
$errorCode = json_last_error(); |
| 69 |
$errorMessage = json_last_error_msg(); |
| 70 |
|
| 71 |
if ($errorCode === JSON_ERROR_UTF8) { |
| 72 |
// JSON_INVALID_UTF8_SUBSTITUTE has existed since PHP 7.2 and the |
| 73 |
// plugin's floor is 7.4, so this needs no function_exists dance. |
| 74 |
$substituted = self::parseableOrNull(json_encode($payload, JSON_INVALID_UTF8_SUBSTITUTE)); |
| 75 |
if ($substituted !== null) { |
| 76 |
return new ABJ_404_Solution_EncodedJsonResponse( |
| 77 |
$substituted, |
| 78 |
ABJ_404_Solution_EncodedJsonResponse::STRATEGY_UTF8_SUBSTITUTED, |
| 79 |
$errorCode, |
| 80 |
$errorMessage |
| 81 |
); |
| 82 |
} |
| 83 |
} |
| 84 |
|
| 85 |
$partial = self::parseableOrNull( |
| 86 |
json_encode($payload, JSON_PARTIAL_OUTPUT_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE)); |
| 87 |
if ($partial !== null) { |
| 88 |
return new ABJ_404_Solution_EncodedJsonResponse( |
| 89 |
$partial, |
| 90 |
ABJ_404_Solution_EncodedJsonResponse::STRATEGY_PARTIAL_OUTPUT, |
| 91 |
$errorCode, |
| 92 |
$errorMessage |
| 93 |
); |
| 94 |
} |
| 95 |
|
| 96 |
return new ABJ_404_Solution_EncodedJsonResponse( |
| 97 |
ABJ_404_Solution_AjaxErrorEnvelope::encodeSafely( |
| 98 |
'The plugin could not encode this response (JSON error ' |
| 99 |
. $errorCode . ': ' . $errorMessage . ').'), |
| 100 |
ABJ_404_Solution_EncodedJsonResponse::STRATEGY_ERROR_ENVELOPE, |
| 101 |
$errorCode, |
| 102 |
$errorMessage |
| 103 |
); |
| 104 |
} |
| 105 |
|
| 106 |
/** |
| 107 |
* A candidate body, but only if a JSON parser can actually read it. |
| 108 |
* |
| 109 |
* `is_string()` is not enough, and assuming it was is how this class nearly |
| 110 |
* shipped the defect it was written to remove. JSON_PARTIAL_OUTPUT_ON_ERROR |
| 111 |
* does NOT always produce parseable output: hand json_encode() a structure |
| 112 |
* nested past the 512-level depth limit and it returns a string of 512 open |
| 113 |
* brackets and nothing else. That is a non-false, non-empty body a browser |
| 114 |
* still reports as `parsererror` -- the same dead admin table, reached |
| 115 |
* through the fallback instead of through the bug. |
| 116 |
* |
| 117 |
* Only ever called on the degraded path, so an ordinary response pays |
| 118 |
* nothing for the second parse. |
| 119 |
* |
| 120 |
* @param string|false $candidate |
| 121 |
*/ |
| 122 |
private static function parseableOrNull($candidate): ?string { |
| 123 |
if (!is_string($candidate) || $candidate === '') { |
| 124 |
return null; |
| 125 |
} |
| 126 |
json_decode($candidate); |
| 127 |
return json_last_error() === JSON_ERROR_NONE ? $candidate : null; |
| 128 |
} |
| 129 |
} |
| 130 |
|