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 / JsonResponseEncoder.php

JsonResponseEncoder.php in 404 Solution trunk, at includes/ajax/JsonResponseEncoder.php

130 lines 5.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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