bool, 'status' => int|null, 'reason' => string|null, 'detail' => string|null] */ class ABJ_404_Solution_FeedbackHttpClient { const HTTP_TIMEOUT = 10; /** * POST a payload to the configured endpoint. * * @param array $payload Already-validated, already-redacted payload. * @return array ok/status/reason/detail */ public static function send(array $payload): array { $endpoint = self::resolveEndpoint(); $token = ABJ_404_Solution_FeedbackSiteTokenStore::storedToken(); if ($token === '') { $token = self::registerSiteToken($endpoint); } $result = self::sendReportRequest($endpoint, $payload, $token); if (!empty($result['ok'])) { self::persistRotationTokenFromResponse($result); return $result; } if (($result['status'] ?? null) !== 401 || $token === '') { return $result; } $freshToken = ABJ_404_Solution_FeedbackSiteTokenStore::freshStoredToken(); if ($freshToken !== '' && $freshToken !== $token) { $freshResult = self::sendReportRequest($endpoint, $payload, $freshToken); if (!empty($freshResult['ok'])) { self::persistRotationTokenFromResponse($freshResult); return $freshResult; } if (($freshResult['status'] ?? null) !== 401) { return $freshResult; } } $recoveredToken = self::registerSiteToken($endpoint); if ($recoveredToken !== '') { ABJ_404_Solution_FeedbackSiteTokenStore::recordIdentityRecoveryNotice(); $recoveredResult = self::sendReportRequest($endpoint, $payload, $recoveredToken); if (!empty($recoveredResult['ok'])) { self::persistRotationTokenFromResponse($recoveredResult); } return $recoveredResult; } return self::sendReportRequest($endpoint, $payload, ''); } /** * @param array $payload * @return array */ private static function sendReportRequest(string $endpoint, array $payload, string $token): array { $wirePayload = ABJ_404_Solution_ReportPayloadJsonSchemaValidator::toWirePayload($payload); $json = function_exists('wp_json_encode') ? wp_json_encode($wirePayload) : json_encode($wirePayload); if (!is_string($json) || $json === '') { return array('ok' => false, 'reason' => 'json_encode_failed'); } $body = function_exists('gzencode') ? gzencode($json, 6) : false; if ($body === false) { return array('ok' => false, 'reason' => 'gzencode_failed'); } $headers = array( 'Content-Type' => 'application/json', 'Content-Encoding' => 'gzip', ); if ($token !== '') { $headers['Authorization'] = 'Bearer ' . $token; } $response = wp_remote_post($endpoint, array( 'timeout' => self::HTTP_TIMEOUT, 'redirection' => 0, 'blocking' => true, 'headers' => $headers, 'body' => $body, )); if (function_exists('is_wp_error') && is_wp_error($response)) { $raw = $response->get_error_message(); $msg = is_scalar($raw) ? (string)$raw : ''; return array('ok' => false, 'reason' => 'wp_error', 'detail' => $msg); } $code = function_exists('wp_remote_retrieve_response_code') ? (int)wp_remote_retrieve_response_code($response) : 0; $rawBody = function_exists('wp_remote_retrieve_body') ? wp_remote_retrieve_body($response) : ''; $bodyString = is_string($rawBody) ? $rawBody : ''; if ($code >= 200 && $code < 300) { return array('ok' => true, 'status' => $code, 'body' => $bodyString); } // Surface the server's structured error message in `detail`. The dev // endpoint's setErrorHandler returns // {statusCode, error: 'validation_failed', message: '', field?} // on schema rejections; without this extraction the admin only sees // "HTTP 400" and has no way to tell which field was wrong. $detail = self::extractServerErrorDetail($bodyString); return array('ok' => false, 'reason' => 'http_' . $code, 'status' => $code, 'detail' => $detail); } private static function registerSiteToken(string $reportEndpoint): string { $endpoint = self::resolveRegisterEndpoint($reportEndpoint); $siteUrl = ''; if (function_exists('home_url')) { $raw = home_url(); $siteUrl = is_scalar($raw) ? (string)$raw : ''; } $body = array(); if ($siteUrl !== '' && strlen($siteUrl) <= 255) { $body['site_url'] = $siteUrl; } $json = function_exists('wp_json_encode') ? wp_json_encode($body) : json_encode($body); if (!is_string($json)) { ABJ_404_Solution_FeedbackTransportLog::log( 'warn', 'abj404_transport: registration payload JSON encoding failed; sending report without token' ); return ''; } $response = wp_remote_post($endpoint, array( 'timeout' => self::HTTP_TIMEOUT, 'redirection' => 0, 'blocking' => true, 'headers' => array( 'Content-Type' => 'application/json', ), 'body' => $json, )); if (function_exists('is_wp_error') && is_wp_error($response)) { $raw = $response->get_error_message(); $msg = is_scalar($raw) ? (string)$raw : ''; ABJ_404_Solution_FeedbackTransportLog::log( 'warn', 'abj404_transport: registration failed with wp_error; sending report without token. detail=' . $msg ); return ''; } $code = function_exists('wp_remote_retrieve_response_code') ? (int)wp_remote_retrieve_response_code($response) : 0; $rawBody = function_exists('wp_remote_retrieve_body') ? wp_remote_retrieve_body($response) : ''; $bodyString = is_string($rawBody) ? $rawBody : ''; if ($code < 200 || $code >= 300) { ABJ_404_Solution_FeedbackTransportLog::log( 'warn', 'abj404_transport: registration failed with http_' . $code . '; sending report without token' ); return ''; } $decoded = json_decode($bodyString, true); $token = is_array($decoded) && isset($decoded['token']) ? $decoded['token'] : null; if (!is_string($token) || !ABJ_404_Solution_FeedbackSiteTokenStore::isValidToken($token)) { ABJ_404_Solution_FeedbackTransportLog::log( 'warn', 'abj404_transport: registration returned malformed token; sending report without token' ); return ''; } ABJ_404_Solution_FeedbackSiteTokenStore::persistToken($token); return $token; } /** * @param array $result */ private static function persistRotationTokenFromResponse(array $result): void { $body = isset($result['body']) && is_string($result['body']) ? $result['body'] : ''; if ($body === '') { return; } $decoded = json_decode($body, true); if (!is_array($decoded) || !array_key_exists('rotation', $decoded)) { return; } $rotation = $decoded['rotation']; if (!is_array($rotation) || !array_key_exists('token', $rotation)) { return; } $token = $rotation['token']; if (!ABJ_404_Solution_FeedbackSiteTokenStore::isValidToken($token)) { ABJ_404_Solution_FeedbackTransportLog::log( 'warn', 'abj404_transport: rotation token was malformed; keeping the previous site token' ); return; } ABJ_404_Solution_FeedbackSiteTokenStore::persistToken($token); } private static function resolveRegisterEndpoint(string $reportEndpoint): string { $default = 'https://404solution.ajexperience.com/api/v1/register'; if (substr($reportEndpoint, -15) === '/api/v1/reports') { $default = substr($reportEndpoint, 0, -7) . 'register'; } if (function_exists('apply_filters')) { $filtered = apply_filters('abj404_register_endpoint', $default); if (is_string($filtered) && $filtered !== '' && self::isHttpUrl($filtered)) { return $filtered; } if ($filtered !== $default) { ABJ_404_Solution_FeedbackTransportLog::log('warn', sprintf( 'abj404_transport: abj404_register_endpoint filter returned an invalid value; using default. got=%s', is_scalar($filtered) ? (string)$filtered : gettype($filtered) )); } } return $default; } /** * Pull the response body off a wp_remote_post() result and, when it's a * JSON error envelope, extract a one-line " [field=]" * detail. Falls back to a short truncated body when the response isn't * structured JSON, so opaque HTML error pages from a misrouted endpoint * still leave a fingerprint in the admin notice. * * @param string $body Raw response body from wp_remote_retrieve_body(). * @return string Empty string if no useful detail could be extracted. */ private static function extractServerErrorDetail(string $body): string { if ($body === '') { return ''; } $decoded = json_decode($body, true); if (is_array($decoded)) { $message = ''; if (isset($decoded['message']) && is_scalar($decoded['message'])) { $message = trim((string)$decoded['message']); } $field = ''; if (isset($decoded['field']) && is_scalar($decoded['field'])) { $field = trim((string)$decoded['field']); } if ($message !== '' && $field !== '') { return $message . ' [field=' . $field . ']'; } if ($message !== '') { return $message; } } $trimmed = trim($body); if ($trimmed === '') { return ''; } return strlen($trimmed) > 240 ? substr($trimmed, 0, 240) . '...' : $trimmed; } /** * Resolve the endpoint URL, allowing override via the * 'abj404_report_endpoint' filter. Falls back to a string default if * the constant is not defined yet (e.g. before Loader.php boots). * * @return string */ private static function resolveEndpoint(): string { $default = defined('ABJ404_REPORT_ENDPOINT') ? ABJ404_REPORT_ENDPOINT : 'https://404solution.ajexperience.com/api/v1/reports'; if (function_exists('apply_filters')) { $filtered = apply_filters('abj404_report_endpoint', $default); // Reject anything that isn't a non-empty http(s) URL so a buggy // filter callback can never coerce wp_remote_post into a SSRF / // file:// / data: request, or block on a malformed host. if (is_string($filtered) && $filtered !== '' && self::isHttpUrl($filtered)) { return $filtered; } if ($filtered !== $default) { ABJ_404_Solution_FeedbackTransportLog::log('warn', sprintf( 'abj404_transport: abj404_report_endpoint filter returned an invalid value; using default. got=%s', is_scalar($filtered) ? (string)$filtered : gettype($filtered) )); } } return $default; } /** * Strict-shape URL check: only accept http:// or https:// with a host * component. parse_url('http://') yields a parseable structure with no * host, so we test for that explicitly. * * @param string $url * @return bool */ private static function isHttpUrl(string $url): bool { $scheme = function_exists('wp_parse_url') ? wp_parse_url($url, PHP_URL_SCHEME) : parse_url($url, PHP_URL_SCHEME); if (!is_string($scheme) || ($scheme !== 'http' && $scheme !== 'https')) { return false; } $host = function_exists('wp_parse_url') ? wp_parse_url($url, PHP_URL_HOST) : parse_url($url, PHP_URL_HOST); return is_string($host) && $host !== ''; } }