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 / feedback / FeedbackHttpClient.php

FeedbackHttpClient.php in 404 Solution trunk, at includes/feedback/FeedbackHttpClient.php

323 lines 13.0 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 require_once __DIR__ . '/ReportPayloadJsonSchemaValidator.php';
8 require_once __DIR__ . '/FeedbackTransportLog.php';
9 require_once __DIR__ . '/FeedbackSiteTokenStore.php';
10
11 /**
12 * HTTP transport for feedback reports. Serializes a payload as gzipped JSON,
13 * POSTs it to the developer reports endpoint, and parses the response into
14 * a structured result. Has no opinions about queueing, retries, or
15 * fallbacks; FeedbackTransport orchestrates those concerns.
16 *
17 * Result shape (see send()):
18 * ['ok' => bool, 'status' => int|null, 'reason' => string|null, 'detail' => string|null]
19 */
20 class ABJ_404_Solution_FeedbackHttpClient {
21
22 const HTTP_TIMEOUT = 10;
23
24 /**
25 * POST a payload to the configured endpoint.
26 *
27 * @param array<string, mixed> $payload Already-validated, already-redacted payload.
28 * @return array<string, mixed> ok/status/reason/detail
29 */
30 public static function send(array $payload): array {
31 $endpoint = self::resolveEndpoint();
32 $token = ABJ_404_Solution_FeedbackSiteTokenStore::storedToken();
33 if ($token === '') {
34 $token = self::registerSiteToken($endpoint);
35 }
36 $result = self::sendReportRequest($endpoint, $payload, $token);
37 if (!empty($result['ok'])) {
38 self::persistRotationTokenFromResponse($result);
39 return $result;
40 }
41 if (($result['status'] ?? null) !== 401 || $token === '') {
42 return $result;
43 }
44
45 $freshToken = ABJ_404_Solution_FeedbackSiteTokenStore::freshStoredToken();
46 if ($freshToken !== '' && $freshToken !== $token) {
47 $freshResult = self::sendReportRequest($endpoint, $payload, $freshToken);
48 if (!empty($freshResult['ok'])) {
49 self::persistRotationTokenFromResponse($freshResult);
50 return $freshResult;
51 }
52 if (($freshResult['status'] ?? null) !== 401) {
53 return $freshResult;
54 }
55 }
56
57 $recoveredToken = self::registerSiteToken($endpoint);
58 if ($recoveredToken !== '') {
59 ABJ_404_Solution_FeedbackSiteTokenStore::recordIdentityRecoveryNotice();
60 $recoveredResult = self::sendReportRequest($endpoint, $payload, $recoveredToken);
61 if (!empty($recoveredResult['ok'])) {
62 self::persistRotationTokenFromResponse($recoveredResult);
63 }
64 return $recoveredResult;
65 }
66
67 return self::sendReportRequest($endpoint, $payload, '');
68 }
69
70 /**
71 * @param array<string, mixed> $payload
72 * @return array<string, mixed>
73 */
74 private static function sendReportRequest(string $endpoint, array $payload, string $token): array {
75 $wirePayload = ABJ_404_Solution_ReportPayloadJsonSchemaValidator::toWirePayload($payload);
76 $json = function_exists('wp_json_encode') ? wp_json_encode($wirePayload) : json_encode($wirePayload);
77 if (!is_string($json) || $json === '') {
78 return array('ok' => false, 'reason' => 'json_encode_failed');
79 }
80
81 $body = function_exists('gzencode') ? gzencode($json, 6) : false;
82 if ($body === false) {
83 return array('ok' => false, 'reason' => 'gzencode_failed');
84 }
85
86 $headers = array(
87 'Content-Type' => 'application/json',
88 'Content-Encoding' => 'gzip',
89 );
90 if ($token !== '') {
91 $headers['Authorization'] = 'Bearer ' . $token;
92 }
93
94 $response = wp_remote_post($endpoint, array(
95 'timeout' => self::HTTP_TIMEOUT,
96 'redirection' => 0,
97 'blocking' => true,
98 'headers' => $headers,
99 'body' => $body,
100 ));
101
102 if (function_exists('is_wp_error') && is_wp_error($response)) {
103 $raw = $response->get_error_message();
104 $msg = is_scalar($raw) ? (string)$raw : '';
105 return array('ok' => false, 'reason' => 'wp_error', 'detail' => $msg);
106 }
107
108 $code = function_exists('wp_remote_retrieve_response_code') ? (int)wp_remote_retrieve_response_code($response) : 0;
109 $rawBody = function_exists('wp_remote_retrieve_body') ? wp_remote_retrieve_body($response) : '';
110 $bodyString = is_string($rawBody) ? $rawBody : '';
111 if ($code >= 200 && $code < 300) {
112 return array('ok' => true, 'status' => $code, 'body' => $bodyString);
113 }
114 // Surface the server's structured error message in `detail`. The dev
115 // endpoint's setErrorHandler returns
116 // {statusCode, error: 'validation_failed', message: '<human>', field?}
117 // on schema rejections; without this extraction the admin only sees
118 // "HTTP 400" and has no way to tell which field was wrong.
119 $detail = self::extractServerErrorDetail($bodyString);
120 return array('ok' => false, 'reason' => 'http_' . $code, 'status' => $code, 'detail' => $detail);
121 }
122
123 private static function registerSiteToken(string $reportEndpoint): string {
124 $endpoint = self::resolveRegisterEndpoint($reportEndpoint);
125 $siteUrl = '';
126 if (function_exists('home_url')) {
127 $raw = home_url();
128 $siteUrl = is_scalar($raw) ? (string)$raw : '';
129 }
130 $body = array();
131 if ($siteUrl !== '' && strlen($siteUrl) <= 255) {
132 $body['site_url'] = $siteUrl;
133 }
134 $json = function_exists('wp_json_encode') ? wp_json_encode($body) : json_encode($body);
135 if (!is_string($json)) {
136 ABJ_404_Solution_FeedbackTransportLog::log(
137 'warn',
138 'abj404_transport: registration payload JSON encoding failed; sending report without token'
139 );
140 return '';
141 }
142
143 $response = wp_remote_post($endpoint, array(
144 'timeout' => self::HTTP_TIMEOUT,
145 'redirection' => 0,
146 'blocking' => true,
147 'headers' => array(
148 'Content-Type' => 'application/json',
149 ),
150 'body' => $json,
151 ));
152
153 if (function_exists('is_wp_error') && is_wp_error($response)) {
154 $raw = $response->get_error_message();
155 $msg = is_scalar($raw) ? (string)$raw : '';
156 ABJ_404_Solution_FeedbackTransportLog::log(
157 'warn',
158 'abj404_transport: registration failed with wp_error; sending report without token. detail=' . $msg
159 );
160 return '';
161 }
162
163 $code = function_exists('wp_remote_retrieve_response_code') ? (int)wp_remote_retrieve_response_code($response) : 0;
164 $rawBody = function_exists('wp_remote_retrieve_body') ? wp_remote_retrieve_body($response) : '';
165 $bodyString = is_string($rawBody) ? $rawBody : '';
166 if ($code < 200 || $code >= 300) {
167 ABJ_404_Solution_FeedbackTransportLog::log(
168 'warn',
169 'abj404_transport: registration failed with http_' . $code . '; sending report without token'
170 );
171 return '';
172 }
173
174 $decoded = json_decode($bodyString, true);
175 $token = is_array($decoded) && isset($decoded['token']) ? $decoded['token'] : null;
176 if (!is_string($token) || !ABJ_404_Solution_FeedbackSiteTokenStore::isValidToken($token)) {
177 ABJ_404_Solution_FeedbackTransportLog::log(
178 'warn',
179 'abj404_transport: registration returned malformed token; sending report without token'
180 );
181 return '';
182 }
183
184 ABJ_404_Solution_FeedbackSiteTokenStore::persistToken($token);
185 return $token;
186 }
187
188 /**
189 * @param array<string, mixed> $result
190 */
191 private static function persistRotationTokenFromResponse(array $result): void {
192 $body = isset($result['body']) && is_string($result['body']) ? $result['body'] : '';
193 if ($body === '') {
194 return;
195 }
196 $decoded = json_decode($body, true);
197 if (!is_array($decoded) || !array_key_exists('rotation', $decoded)) {
198 return;
199 }
200 $rotation = $decoded['rotation'];
201 if (!is_array($rotation) || !array_key_exists('token', $rotation)) {
202 return;
203 }
204 $token = $rotation['token'];
205 if (!ABJ_404_Solution_FeedbackSiteTokenStore::isValidToken($token)) {
206 ABJ_404_Solution_FeedbackTransportLog::log(
207 'warn',
208 'abj404_transport: rotation token was malformed; keeping the previous site token'
209 );
210 return;
211 }
212 ABJ_404_Solution_FeedbackSiteTokenStore::persistToken($token);
213 }
214
215 private static function resolveRegisterEndpoint(string $reportEndpoint): string {
216 $default = 'https://404solution.ajexperience.com/api/v1/register';
217 if (substr($reportEndpoint, -15) === '/api/v1/reports') {
218 $default = substr($reportEndpoint, 0, -7) . 'register';
219 }
220 if (function_exists('apply_filters')) {
221 $filtered = apply_filters('abj404_register_endpoint', $default);
222 if (is_string($filtered) && $filtered !== '' && self::isHttpUrl($filtered)) {
223 return $filtered;
224 }
225 if ($filtered !== $default) {
226 ABJ_404_Solution_FeedbackTransportLog::log('warn', sprintf(
227 'abj404_transport: abj404_register_endpoint filter returned an invalid value; using default. got=%s',
228 is_scalar($filtered) ? (string)$filtered : gettype($filtered)
229 ));
230 }
231 }
232 return $default;
233 }
234
235 /**
236 * Pull the response body off a wp_remote_post() result and, when it's a
237 * JSON error envelope, extract a one-line "<message> [field=<path>]"
238 * detail. Falls back to a short truncated body when the response isn't
239 * structured JSON, so opaque HTML error pages from a misrouted endpoint
240 * still leave a fingerprint in the admin notice.
241 *
242 * @param string $body Raw response body from wp_remote_retrieve_body().
243 * @return string Empty string if no useful detail could be extracted.
244 */
245 private static function extractServerErrorDetail(string $body): string {
246 if ($body === '') {
247 return '';
248 }
249 $decoded = json_decode($body, true);
250 if (is_array($decoded)) {
251 $message = '';
252 if (isset($decoded['message']) && is_scalar($decoded['message'])) {
253 $message = trim((string)$decoded['message']);
254 }
255 $field = '';
256 if (isset($decoded['field']) && is_scalar($decoded['field'])) {
257 $field = trim((string)$decoded['field']);
258 }
259 if ($message !== '' && $field !== '') {
260 return $message . ' [field=' . $field . ']';
261 }
262 if ($message !== '') {
263 return $message;
264 }
265 }
266 $trimmed = trim($body);
267 if ($trimmed === '') {
268 return '';
269 }
270 return strlen($trimmed) > 240 ? substr($trimmed, 0, 240) . '...' : $trimmed;
271 }
272
273 /**
274 * Resolve the endpoint URL, allowing override via the
275 * 'abj404_report_endpoint' filter. Falls back to a string default if
276 * the constant is not defined yet (e.g. before Loader.php boots).
277 *
278 * @return string
279 */
280 private static function resolveEndpoint(): string {
281 $default = defined('ABJ404_REPORT_ENDPOINT')
282 ? ABJ404_REPORT_ENDPOINT
283 : 'https://404solution.ajexperience.com/api/v1/reports';
284 if (function_exists('apply_filters')) {
285 $filtered = apply_filters('abj404_report_endpoint', $default);
286 // Reject anything that isn't a non-empty http(s) URL so a buggy
287 // filter callback can never coerce wp_remote_post into a SSRF /
288 // file:// / data: request, or block on a malformed host.
289 if (is_string($filtered) && $filtered !== '' && self::isHttpUrl($filtered)) {
290 return $filtered;
291 }
292 if ($filtered !== $default) {
293 ABJ_404_Solution_FeedbackTransportLog::log('warn', sprintf(
294 'abj404_transport: abj404_report_endpoint filter returned an invalid value; using default. got=%s',
295 is_scalar($filtered) ? (string)$filtered : gettype($filtered)
296 ));
297 }
298 }
299 return $default;
300 }
301
302 /**
303 * Strict-shape URL check: only accept http:// or https:// with a host
304 * component. parse_url('http://') yields a parseable structure with no
305 * host, so we test for that explicitly.
306 *
307 * @param string $url
308 * @return bool
309 */
310 private static function isHttpUrl(string $url): bool {
311 $scheme = function_exists('wp_parse_url')
312 ? wp_parse_url($url, PHP_URL_SCHEME)
313 : parse_url($url, PHP_URL_SCHEME);
314 if (!is_string($scheme) || ($scheme !== 'http' && $scheme !== 'https')) {
315 return false;
316 }
317 $host = function_exists('wp_parse_url')
318 ? wp_parse_url($url, PHP_URL_HOST)
319 : parse_url($url, PHP_URL_HOST);
320 return is_string($host) && $host !== '';
321 }
322 }
323