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

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

253 lines 9.2 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__ . '/FeedbackTransportLog.php';
8
9 /**
10 * Server-to-server client for self-service diagnostic data requests.
11 *
12 * The browser talks only to local admin AJAX handlers. This client sends the
13 * site's stored bearer token to the reports server, classifies failures into
14 * curated safe summaries, and strips any server response fields that are not
15 * part of the documented browser contract.
16 */
17 class ABJ_404_Solution_FeedbackDsrClient {
18
19 const HTTP_TIMEOUT = 10;
20
21 /**
22 * Request the diagnostic export rows for the authenticated site token.
23 *
24 * @param string $token Locally stored 64-hex site token.
25 * @return array{ok: true, status: int, data: array<string, mixed>}|array{ok: false, status: int, message: string}
26 */
27 public static function exportRows(string $token): array {
28 $result = self::postJson('/api/v1/dsr/export', new stdClass(), $token);
29 if (empty($result['ok'])) {
30 return $result;
31 }
32
33 $rows = self::extractExportRows($result['body'] ?? '');
34 if ($rows === null) {
35 return self::failure('invalid_response_shape', 502);
36 }
37
38 return array('ok' => true, 'status' => (int)$result['status'], 'data' => array('rows' => $rows));
39 }
40
41 /**
42 * Delete diagnostic rows for the authenticated site token.
43 *
44 * @param string $token Locally stored 64-hex site token.
45 * @param bool $confirm Server-side destructive confirmation.
46 * @return array{ok: true, status: int, data: array<string, mixed>}|array{ok: false, status: int, message: string}
47 */
48 public static function deleteRows(string $token, bool $confirm): array {
49 $result = self::postJson('/api/v1/dsr/delete', array('confirm' => $confirm), $token);
50 if (empty($result['ok'])) {
51 return $result;
52 }
53
54 $payload = self::extractDeletePayload($result['body'] ?? '');
55 if ($payload === null) {
56 return self::failure('invalid_response_shape', 502);
57 }
58
59 return array('ok' => true, 'status' => (int)$result['status'], 'data' => $payload);
60 }
61
62 /**
63 * @param array<string, mixed>|stdClass $payload
64 * @return array{ok: true, status: int, body: string}|array{ok: false, status: int, message: string}
65 */
66 private static function postJson(string $path, $payload, string $token): array {
67 $json = function_exists('wp_json_encode') ? wp_json_encode($payload) : json_encode($payload);
68 if (!is_string($json)) {
69 return self::failure('json_encode_failed', 500);
70 }
71
72 $response = wp_remote_post(self::resolveEndpoint($path), array(
73 'timeout' => self::HTTP_TIMEOUT,
74 'redirection' => 0,
75 'blocking' => true,
76 'headers' => array(
77 'Content-Type' => 'application/json',
78 'Authorization' => 'Bearer ' . $token,
79 ),
80 'body' => $json,
81 ));
82
83 if (function_exists('is_wp_error') && is_wp_error($response)) {
84 return self::failure(self::wpErrorReason($response), 502);
85 }
86
87 $status = function_exists('wp_remote_retrieve_response_code')
88 ? (int)wp_remote_retrieve_response_code($response)
89 : 0;
90 $body = function_exists('wp_remote_retrieve_body') ? wp_remote_retrieve_body($response) : '';
91 $bodyString = is_string($body) ? $body : '';
92
93 if ($status >= 200 && $status < 300) {
94 return array('ok' => true, 'status' => $status, 'body' => $bodyString);
95 }
96
97 return self::failure('http_' . (string)$status, $status > 0 ? $status : 502);
98 }
99
100 /**
101 * @return array<int, mixed>|null
102 */
103 private static function extractExportRows(string $body) {
104 $decoded = self::decodeJson($body);
105 if ($decoded === null) {
106 return null;
107 }
108 if (is_array($decoded) && self::isListArray($decoded)) {
109 return array_values($decoded);
110 }
111 if (is_array($decoded) && isset($decoded['rows'])) {
112 $rows = $decoded['rows'];
113 if (is_array($rows)) {
114 return array_values($rows);
115 }
116 }
117 return null;
118 }
119
120 /**
121 * @return array{deleted: int, matched: int, ranDelete: bool}|null
122 */
123 private static function extractDeletePayload(string $body) {
124 $decoded = self::decodeJson($body);
125 if (!is_array($decoded)) {
126 return null;
127 }
128 if (!array_key_exists('deleted', $decoded)
129 || !array_key_exists('matched', $decoded)
130 || !array_key_exists('ranDelete', $decoded)) {
131 return null;
132 }
133
134 return array(
135 'deleted' => (int)$decoded['deleted'],
136 'matched' => (int)$decoded['matched'],
137 'ranDelete' => (bool)$decoded['ranDelete'],
138 );
139 }
140
141 /**
142 * @return mixed|null
143 */
144 private static function decodeJson(string $body) {
145 if ($body === '') {
146 return null;
147 }
148 $decoded = json_decode($body, true);
149 return json_last_error() === JSON_ERROR_NONE ? $decoded : null;
150 }
151
152 /**
153 * @param mixed $value
154 */
155 private static function isListArray($value): bool {
156 if (!is_array($value)) {
157 return false;
158 }
159 if (count($value) === 0) {
160 return true;
161 }
162 return array_keys($value) === range(0, count($value) - 1);
163 }
164
165 /**
166 * @return array{ok: false, status: int, message: string}
167 */
168 private static function failure(string $reason, int $status): array {
169 return array('ok' => false, 'status' => $status, 'message' => self::safeFailureMessage($reason, $status));
170 }
171
172 private static function safeFailureMessage(string $reason, int $status): string {
173 if ($status === 401) {
174 return __('Unauthorized (401)', '404-solution');
175 }
176 if ($status === 429) {
177 return __('Rate limited (429). Try again later.', '404-solution');
178 }
179 if ($reason === 'timed_out') {
180 return sprintf(__('Timed out after %ds', '404-solution'), self::HTTP_TIMEOUT);
181 }
182 if (strpos($reason, 'wp_error:') === 0) {
183 return sprintf(
184 __('Network request failed (%s)', '404-solution'),
185 substr($reason, strlen('wp_error:'))
186 );
187 }
188 if ($reason === 'json_encode_failed') {
189 return __('Could not prepare the diagnostic data request.', '404-solution');
190 }
191 if ($reason === 'invalid_response_shape') {
192 return __('The reports server returned an unexpected response.', '404-solution');
193 }
194 if ($status >= 500) {
195 return sprintf(__('Reports server error (%d)', '404-solution'), $status);
196 }
197 return sprintf(__('Reports server returned HTTP %d', '404-solution'), $status);
198 }
199
200 /**
201 * @param mixed $error
202 */
203 private static function wpErrorReason($error): string {
204 if (!is_object($error)) {
205 return 'wp_error:unknown';
206 }
207 $code = method_exists($error, 'get_error_code') ? (string)$error->get_error_code() : 'unknown';
208 $message = method_exists($error, 'get_error_message') ? (string)$error->get_error_message() : '';
209 if (stripos($message, 'timed out') !== false || stripos($message, 'timeout') !== false) {
210 return 'timed_out';
211 }
212 $safeCode = strtolower((string)preg_replace('/[^a-zA-Z0-9_\-]/', '', $code));
213 return 'wp_error:' . ($safeCode !== '' ? $safeCode : 'unknown');
214 }
215
216 private static function resolveEndpoint(string $path): string {
217 $reportEndpoint = self::resolveReportEndpoint();
218 if (substr($reportEndpoint, -15) === '/api/v1/reports') {
219 return substr($reportEndpoint, 0, -15) . $path;
220 }
221 return 'https://404solution.ajexperience.com' . $path;
222 }
223
224 private static function resolveReportEndpoint(): string {
225 $default = defined('ABJ404_REPORT_ENDPOINT')
226 ? ABJ404_REPORT_ENDPOINT
227 : 'https://404solution.ajexperience.com/api/v1/reports';
228 if (function_exists('apply_filters')) {
229 $filtered = apply_filters('abj404_report_endpoint', $default);
230 if (is_string($filtered) && $filtered !== '' && self::isHttpUrl($filtered)) {
231 return $filtered;
232 }
233 if ($filtered !== $default) {
234 ABJ_404_Solution_FeedbackTransportLog::log('warn', sprintf(
235 'abj404_dsr: abj404_report_endpoint filter returned an invalid value; using default. got=%s',
236 is_scalar($filtered) ? (string)$filtered : gettype($filtered)
237 ));
238 }
239 }
240 return $default;
241 }
242
243 private static function isHttpUrl(string $url): bool {
244 $scheme = function_exists('wp_parse_url')
245 ? wp_parse_url($url, PHP_URL_SCHEME)
246 : parse_url($url, PHP_URL_SCHEME);
247 $host = function_exists('wp_parse_url')
248 ? wp_parse_url($url, PHP_URL_HOST)
249 : parse_url($url, PHP_URL_HOST);
250 return ($scheme === 'http' || $scheme === 'https') && is_string($host) && $host !== '';
251 }
252 }
253