🏠 Root
/
home
/
artorgp
/
www
/
wp-content
/
plugins
/
wordpress-seo
/
src
/
myyoast-client
/
infrastructure
/
http
/
Editing: http-client.php
<?php // phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure. namespace Yoast\WP\SEO\MyYoast_Client\Infrastructure\Http; use SensitiveParameter; use WP_Error; use WpOrg\Requests\Utility\CaseInsensitiveDictionary; use Yoast\WP\SEO\Expiring_Store\Application\Expiring_Store; use Yoast\WP\SEO\Expiring_Store\Domain\Corrupted_Value_Exception; use Yoast\WP\SEO\Expiring_Store\Domain\Key_Not_Found_Exception; use Yoast\WP\SEO\MyYoast_Client\Application\Ports\OAuth_Server_Client_Interface; use Yoast\WP\SEO\MyYoast_Client\Domain\Auth_Token_Type; use Yoast\WP\SEO\MyYoast_Client\Domain\HTTP_Response; use Yoast\WP\SEO\MyYoast_Client\Infrastructure\DPoP\DPoP_Handler; use Yoast\WP\SEO\MyYoast_Client\Infrastructure\DPoP\DPoP_Proof_Exception; use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface; use YoastSEO_Vendor\Psr\Log\LoggerAwareTrait; use YoastSEO_Vendor\Psr\Log\NullLogger; /** * HTTP client wrapping WordPress HTTP API with DPoP header injection. * * Provides automatic DPoP proof generation for all requests and handles * `use_dpop_nonce` errors by retrying once with the new nonce. */ class HTTP_Client implements OAuth_Server_Client_Interface, LoggerAwareInterface { use LoggerAwareTrait; private const RATE_LIMIT_KEY_PREFIX = 'myyoast_rate_limit:'; private const DEFAULT_BACKOFF_SECONDS = \MINUTE_IN_SECONDS; /** * The DPoP handler. * * @var DPoP_Handler */ private $dpop_handler; /** * The expiring store for rate limit backoff. * * @var Expiring_Store */ private $expiring_store; /** * HTTP_Client constructor. * * @param DPoP_Handler $dpop_handler The DPoP handler. * @param Expiring_Store $expiring_store The expiring store for rate limit tracking. */ public function __construct( DPoP_Handler $dpop_handler, Expiring_Store $expiring_store ) { $this->dpop_handler = $dpop_handler; $this->expiring_store = $expiring_store; $this->logger = new NullLogger(); } /** * Sends an HTTP request with optional DPoP proof. * * @param string $method The HTTP method. * @param string $url The request URL. * @param array<string, string|int|bool|string[]|null> $options Request options: 'headers', 'body', 'timeout', 'dpop' (bool), 'access_token'. * * @return HTTP_Response The parsed response. */ public function request( string $method, string $url, array $options = [] ): HTTP_Response { $cached_response = $this->get_cached_rate_limit_response( $url ); if ( $cached_response !== null ) { return $cached_response; } $result = $this->do_request( $method, $url, $options ); // Handle DPoP nonce lifecycle when DPoP is active. if ( ! empty( $options['dpop'] ) ) { $this->dpop_handler->handle_nonce_response( $result->get_headers() ); // Retry once on use_dpop_nonce error with the fresh nonce. if ( $this->is_dpop_nonce_error( $result ) ) { $this->logger->debug( 'Retrying request with fresh DPoP nonce for {method} {url}.', [ 'method' => $method, 'url' => $url, ], ); $result = $this->do_request( $method, $url, $options ); $this->dpop_handler->handle_nonce_response( $result->get_headers() ); } } return $result; } /** * Sends an authenticated resource request with DPoP proof. * Authenticate with a (DPoP bound) access token or refresh token, an Initial Access Token, or a Client Registration Access Token, depending on the token type and endpoint requirements. * * @param string $method The HTTP method. * @param string $url The resource URL. * @param string $access_token The access token. * @param string $token_type An Auth_Token_Type constant. * @param array<string, string|int|bool|string[]|null> $options Additional request options. * * @return HTTP_Response The parsed response. */ public function authenticated_request( string $method, string $url, // phpcs:ignore PHPCompatibility.Attributes.NewAttributes.PHPNativeAttributeFound -- No-op on PHP < 8.2; redacts parameter from stack traces on PHP 8.2+. #[SensitiveParameter] string $access_token, string $token_type = Auth_Token_Type::DPOP, array $options = [] ): HTTP_Response { $headers = ( $options['headers'] ?? [] ); $headers['Authorization'] = $token_type . ' ' . $access_token; return $this->request( $method, $url, \array_merge( $options, [ 'headers' => $headers, 'dpop' => ( $token_type === Auth_Token_Type::DPOP ), 'access_token' => $access_token, ], ), ); } /** * Checks if the response indicates a use_dpop_nonce error. * * @param HTTP_Response $result The parsed response. * * @return bool Whether this is a DPoP nonce error. */ private function is_dpop_nonce_error( HTTP_Response $result ): bool { return ( $result->get_body_value( 'error' ) === 'use_dpop_nonce' ); } /** * Executes a single HTTP request with optional DPoP proof injection. * * @param string $method The HTTP method. * @param string $url The request URL. * @param array<string, string|int|bool|string[]|null> $options Request options. * * @return HTTP_Response The parsed response. */ private function do_request( string $method, string $url, array $options ): HTTP_Response { $headers = ( $options['headers'] ?? [] ); $timeout = ( $options['timeout'] ?? 10 ); // Add DPoP proof header if requested. if ( ! empty( $options['dpop'] ) ) { $access_token = ( $options['access_token'] ?? null ); try { $headers['DPoP'] = $this->dpop_handler->create_proof( $method, $url, $access_token ); } catch ( DPoP_Proof_Exception $e ) { $this->logger->error( 'DPoP proof generation failed for {method} {url}: {error}', [ 'method' => $method, 'url' => $url, 'error' => $e->getMessage(), ], ); return new HTTP_Response( 0, [], [ 'error' => 'dpop_proof_failed', 'error_description' => $e->getMessage(), ], ); } } $wp_args = [ 'method' => \strtoupper( $method ), 'headers' => $headers, 'timeout' => $timeout, ]; if ( isset( $options['body'] ) ) { $wp_args['body'] = $options['body']; } $response = \wp_remote_request( $url, $wp_args ); return $this->parse_response( $response, $url ); } /** * Parses a WordPress HTTP API response into a standardized format. * * @param array<string, string|int|array<string, string>>|WP_Error $response The raw WordPress response. * @param string $url The request URL (for error messages). * * @return HTTP_Response The parsed response. */ private function parse_response( $response, string $url ): HTTP_Response { if ( \is_wp_error( $response ) ) { $this->logger->warning( 'Network error for {url}: {error}', [ 'url' => $url, 'error' => $response->get_error_message(), ], ); return new HTTP_Response( 0, [], [ 'error' => 'network_error', 'error_description' => $response->get_error_message(), ], ); } $status = \wp_remote_retrieve_response_code( $response ); $headers = \wp_remote_retrieve_headers( $response ); $body = \wp_remote_retrieve_body( $response ); $headers_array = []; if ( $headers instanceof CaseInsensitiveDictionary ) { $headers_array = (array) $headers->getAll(); } elseif ( \is_array( $headers ) ) { $headers_array = $headers; } $decoded = \json_decode( $body, true ); $parsed = new HTTP_Response( (int) $status, $headers_array, ( \is_array( $decoded ) ? $decoded : $body ), ); if ( (int) $status === 429 ) { $this->logger->warning( 'Rate limited (429) by {url}.', [ 'url' => $url ] ); $this->store_rate_limit_response( $url, $parsed ); } return $parsed; } /** * Returns a cached 429 response with an updated Retry-After header, or null if not rate limited. * * @param string $url The request URL. * * @return HTTP_Response|null The cached response or null. */ private function get_cached_rate_limit_response( string $url ): ?HTTP_Response { $key = $this->get_rate_limit_key( $url ); try { $cached = $this->expiring_store->get( $key ); } catch ( Key_Not_Found_Exception | Corrupted_Value_Exception $e ) { return null; } if ( ! \is_array( $cached ) || ! isset( $cached['stored_at'], $cached['backoff_seconds'], $cached['status'], $cached['headers'], $cached['body'] ) ) { return null; } $remaining = ( ( $cached['stored_at'] + $cached['backoff_seconds'] ) - \time() ); if ( $remaining <= 0 ) { return null; } $headers = $cached['headers']; $headers['retry-after'] = (string) $remaining; return new HTTP_Response( (int) $cached['status'], $headers, $cached['body'] ); } /** * Stores a 429 response in the expiring store for later replay. * * @param string $url The request URL. * @param HTTP_Response $response The parsed 429 response. * * @return void */ private function store_rate_limit_response( string $url, HTTP_Response $response ): void { $headers = $response->get_headers(); $retry_after = ( $headers['retry-after'] ?? null ); if ( \is_numeric( $retry_after ) ) { $backoff_seconds = (int) $retry_after; } else { $backoff_seconds = self::DEFAULT_BACKOFF_SECONDS; } $this->expiring_store->persist( $this->get_rate_limit_key( $url ), [ 'stored_at' => \time(), 'backoff_seconds' => $backoff_seconds, 'status' => $response->get_status(), 'headers' => $headers, 'body' => $response->get_body(), ], $backoff_seconds, ); } /** * Builds a rate limit key from a URL using its host and path. * * @param string $url The request URL. * * @return string The rate limit key. */ private function get_rate_limit_key( string $url ): string { $parsed = \wp_parse_url( $url ); $host = ( $parsed['host'] ?? 'unknown' ); $path = ( $parsed['path'] ?? '/' ); return self::RATE_LIMIT_KEY_PREFIX . $host . $path; } }
Save
Cancel