PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.0
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 4.3.0, at includes/feedback/FeedbackHttpClient.php

161 lines 6.5 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
10 /**
11 * HTTP transport for feedback reports. Serializes a payload as gzipped JSON,
12 * POSTs it to the developer reports endpoint, and parses the response into
13 * a structured result. Has no opinions about queueing, retries, or
14 * fallbacks; FeedbackTransport orchestrates those concerns.
15 *
16 * Result shape (see send()):
17 * ['ok' => bool, 'status' => int|null, 'reason' => string|null, 'detail' => string|null]
18 */
19 class ABJ_404_Solution_FeedbackHttpClient {
20
21 const HTTP_TIMEOUT = 10;
22
23 /**
24 * POST a payload to the configured endpoint.
25 *
26 * @param array<string, mixed> $payload Already-validated, already-redacted payload.
27 * @return array<string, mixed> ok/status/reason/detail
28 */
29 public static function send(array $payload): array {
30 $endpoint = self::resolveEndpoint();
31 $wirePayload = ABJ_404_Solution_ReportPayloadJsonSchemaValidator::toWirePayload($payload);
32 $json = function_exists('wp_json_encode') ? wp_json_encode($wirePayload) : json_encode($wirePayload);
33 if (!is_string($json) || $json === '') {
34 return array('ok' => false, 'reason' => 'json_encode_failed');
35 }
36
37 $body = function_exists('gzencode') ? gzencode($json, 6) : false;
38 if ($body === false) {
39 return array('ok' => false, 'reason' => 'gzencode_failed');
40 }
41
42 $response = wp_remote_post($endpoint, array(
43 'timeout' => self::HTTP_TIMEOUT,
44 'redirection' => 0,
45 'blocking' => true,
46 'headers' => array(
47 'Content-Type' => 'application/json',
48 'Content-Encoding' => 'gzip',
49 ),
50 'body' => $body,
51 ));
52
53 if (function_exists('is_wp_error') && is_wp_error($response)) {
54 $raw = $response->get_error_message();
55 $msg = is_scalar($raw) ? (string)$raw : '';
56 return array('ok' => false, 'reason' => 'wp_error', 'detail' => $msg);
57 }
58
59 $code = function_exists('wp_remote_retrieve_response_code') ? (int)wp_remote_retrieve_response_code($response) : 0;
60 if ($code >= 200 && $code < 300) {
61 return array('ok' => true, 'status' => $code);
62 }
63 // Surface the server's structured error message in `detail`. The dev
64 // endpoint's setErrorHandler returns
65 // {statusCode, error: 'validation_failed', message: '<human>', field?}
66 // on schema rejections; without this extraction the admin only sees
67 // "HTTP 400" and has no way to tell which field was wrong.
68 $rawBody = function_exists('wp_remote_retrieve_body') ? wp_remote_retrieve_body($response) : '';
69 $detail = self::extractServerErrorDetail(is_string($rawBody) ? $rawBody : '');
70 return array('ok' => false, 'reason' => 'http_' . $code, 'status' => $code, 'detail' => $detail);
71 }
72
73 /**
74 * Pull the response body off a wp_remote_post() result and, when it's a
75 * JSON error envelope, extract a one-line "<message> [field=<path>]"
76 * detail. Falls back to a short truncated body when the response isn't
77 * structured JSON, so opaque HTML error pages from a misrouted endpoint
78 * still leave a fingerprint in the admin notice.
79 *
80 * @param string $body Raw response body from wp_remote_retrieve_body().
81 * @return string Empty string if no useful detail could be extracted.
82 */
83 private static function extractServerErrorDetail(string $body): string {
84 if ($body === '') {
85 return '';
86 }
87 $decoded = json_decode($body, true);
88 if (is_array($decoded)) {
89 $message = '';
90 if (isset($decoded['message']) && is_scalar($decoded['message'])) {
91 $message = trim((string)$decoded['message']);
92 }
93 $field = '';
94 if (isset($decoded['field']) && is_scalar($decoded['field'])) {
95 $field = trim((string)$decoded['field']);
96 }
97 if ($message !== '' && $field !== '') {
98 return $message . ' [field=' . $field . ']';
99 }
100 if ($message !== '') {
101 return $message;
102 }
103 }
104 $trimmed = trim($body);
105 if ($trimmed === '') {
106 return '';
107 }
108 return strlen($trimmed) > 240 ? substr($trimmed, 0, 240) . '...' : $trimmed;
109 }
110
111 /**
112 * Resolve the endpoint URL, allowing override via the
113 * 'abj404_report_endpoint' filter. Falls back to a string default if
114 * the constant is not defined yet (e.g. before Loader.php boots).
115 *
116 * @return string
117 */
118 private static function resolveEndpoint(): string {
119 $default = defined('ABJ404_REPORT_ENDPOINT')
120 ? ABJ404_REPORT_ENDPOINT
121 : 'https://404solution.ajexperience.com/api/v1/reports';
122 if (function_exists('apply_filters')) {
123 $filtered = apply_filters('abj404_report_endpoint', $default);
124 // Reject anything that isn't a non-empty http(s) URL so a buggy
125 // filter callback can never coerce wp_remote_post into a SSRF /
126 // file:// / data: request, or block on a malformed host.
127 if (is_string($filtered) && $filtered !== '' && self::isHttpUrl($filtered)) {
128 return $filtered;
129 }
130 if ($filtered !== $default) {
131 ABJ_404_Solution_FeedbackTransportLog::log('warn', sprintf(
132 'abj404_transport: abj404_report_endpoint filter returned an invalid value; using default. got=%s',
133 is_scalar($filtered) ? (string)$filtered : gettype($filtered)
134 ));
135 }
136 }
137 return $default;
138 }
139
140 /**
141 * Strict-shape URL check: only accept http:// or https:// with a host
142 * component. parse_url('http://') yields a parseable structure with no
143 * host, so we test for that explicitly.
144 *
145 * @param string $url
146 * @return bool
147 */
148 private static function isHttpUrl(string $url): bool {
149 $scheme = function_exists('wp_parse_url')
150 ? wp_parse_url($url, PHP_URL_SCHEME)
151 : parse_url($url, PHP_URL_SCHEME);
152 if (!is_string($scheme) || ($scheme !== 'http' && $scheme !== 'https')) {
153 return false;
154 }
155 $host = function_exists('wp_parse_url')
156 ? wp_parse_url($url, PHP_URL_HOST)
157 : parse_url($url, PHP_URL_HOST);
158 return is_string($host) && $host !== '';
159 }
160 }
161