PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 28.2
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v28.2
28.5 28.4 28.3 28.2 28.1 28.0 27.9 27.8 27.7 27.6 27.5 trunk 18.0 18.1 18.2 18.3 18.4 18.4.1 18.5 18.5.1 18.6 18.7 18.8 18.9 19.0 All 129 releases
wordpress-seo / src / myyoast-client / infrastructure / http / http-client.php

http-client.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI 28.2, at src/myyoast-client/infrastructure/http/http-client.php

355 lines 11.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
3
4 namespace Yoast\WP\SEO\MyYoast_Client\Infrastructure\Http;
5
6 use SensitiveParameter;
7 use WP_Error;
8 use WpOrg\Requests\Utility\CaseInsensitiveDictionary;
9 use Yoast\WP\SEO\Expiring_Store\Application\Expiring_Store;
10 use Yoast\WP\SEO\Expiring_Store\Domain\Corrupted_Value_Exception;
11 use Yoast\WP\SEO\Expiring_Store\Domain\Key_Not_Found_Exception;
12 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\OAuth_Server_Client_Interface;
13 use Yoast\WP\SEO\MyYoast_Client\Domain\Auth_Token_Type;
14 use Yoast\WP\SEO\MyYoast_Client\Domain\HTTP_Response;
15 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\DPoP\DPoP_Handler;
16 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\DPoP\DPoP_Proof_Exception;
17 use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface;
18 use YoastSEO_Vendor\Psr\Log\LoggerAwareTrait;
19 use YoastSEO_Vendor\Psr\Log\NullLogger;
20
21 /**
22 * HTTP client wrapping WordPress HTTP API with DPoP header injection.
23 *
24 * Provides automatic DPoP proof generation for all requests and handles
25 * `use_dpop_nonce` errors by retrying once with the new nonce.
26 */
27 class HTTP_Client implements OAuth_Server_Client_Interface, LoggerAwareInterface {
28 use LoggerAwareTrait;
29
30 private const RATE_LIMIT_KEY_PREFIX = 'myyoast_rate_limit:';
31 private const DEFAULT_BACKOFF_SECONDS = \MINUTE_IN_SECONDS;
32
33 /**
34 * The DPoP handler.
35 *
36 * @var DPoP_Handler
37 */
38 private $dpop_handler;
39
40 /**
41 * The expiring store for rate limit backoff.
42 *
43 * @var Expiring_Store
44 */
45 private $expiring_store;
46
47 /**
48 * HTTP_Client constructor.
49 *
50 * @param DPoP_Handler $dpop_handler The DPoP handler.
51 * @param Expiring_Store $expiring_store The expiring store for rate limit tracking.
52 */
53 public function __construct( DPoP_Handler $dpop_handler, Expiring_Store $expiring_store ) {
54 $this->dpop_handler = $dpop_handler;
55 $this->expiring_store = $expiring_store;
56 $this->logger = new NullLogger();
57 }
58
59 /**
60 * Sends an HTTP request with optional DPoP proof.
61 *
62 * @param string $method The HTTP method.
63 * @param string $url The request URL.
64 * @param array<string, string|int|bool|string[]|null> $options Request options: 'headers', 'body', 'timeout', 'dpop' (bool), 'access_token'.
65 *
66 * @return HTTP_Response The parsed response.
67 */
68 public function request( string $method, string $url, array $options = [] ): HTTP_Response {
69 $cached_response = $this->get_cached_rate_limit_response( $url );
70 if ( $cached_response !== null ) {
71 return $cached_response;
72 }
73
74 $result = $this->do_request( $method, $url, $options );
75
76 // Handle DPoP nonce lifecycle when DPoP is active.
77 if ( ! empty( $options['dpop'] ) ) {
78 $this->dpop_handler->handle_nonce_response( $result->get_headers() );
79
80 // Retry once on use_dpop_nonce error with the fresh nonce.
81 if ( $this->is_dpop_nonce_error( $result ) ) {
82 $this->logger->debug(
83 'Retrying request with fresh DPoP nonce for {method} {url}.',
84 [
85 'method' => $method,
86 'url' => $url,
87 ],
88 );
89 $result = $this->do_request( $method, $url, $options );
90 $this->dpop_handler->handle_nonce_response( $result->get_headers() );
91 }
92 }
93
94 return $result;
95 }
96
97 /**
98 * Sends an authenticated resource request with DPoP proof.
99 * 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.
100 *
101 * @param string $method The HTTP method.
102 * @param string $url The resource URL.
103 * @param string $access_token The access token.
104 * @param string $token_type An Auth_Token_Type constant.
105 * @param array<string, string|int|bool|string[]|null> $options Additional request options.
106 *
107 * @return HTTP_Response The parsed response.
108 */
109 public function authenticated_request(
110 string $method,
111 string $url,
112 // phpcs:ignore PHPCompatibility.Attributes.NewAttributes.PHPNativeAttributeFound -- No-op on PHP < 8.2; redacts parameter from stack traces on PHP 8.2+.
113 #[SensitiveParameter]
114 string $access_token,
115 string $token_type = Auth_Token_Type::DPOP,
116 array $options = []
117 ): HTTP_Response {
118 $headers = ( $options['headers'] ?? [] );
119
120 $headers['Authorization'] = $token_type . ' ' . $access_token;
121
122 return $this->request(
123 $method,
124 $url,
125 \array_merge(
126 $options,
127 [
128 'headers' => $headers,
129 'dpop' => ( $token_type === Auth_Token_Type::DPOP ),
130 'access_token' => $access_token,
131 ],
132 ),
133 );
134 }
135
136 /**
137 * Checks if the response indicates a use_dpop_nonce challenge.
138 *
139 * Covers both signalling shapes per RFC 9449 §§5.2 and 8:
140 * - Authorization-server style: response body carries `{"error":"use_dpop_nonce"}`.
141 * - Resource-server style: HTTP 401 with `WWW-Authenticate: DPoP error="use_dpop_nonce"` and a fresh `DPoP-Nonce` header.
142 *
143 * @param HTTP_Response $result The parsed response.
144 *
145 * @return bool Whether this is a DPoP nonce challenge.
146 */
147 private function is_dpop_nonce_error( HTTP_Response $result ): bool {
148 if ( $result->get_body_value( 'error' ) === 'use_dpop_nonce' ) {
149 return true;
150 }
151
152 if ( $result->get_status() !== 401 ) {
153 return false;
154 }
155
156 $headers = $result->get_headers();
157 if ( ! isset( $headers['www-authenticate'], $headers['dpop-nonce'] ) ) {
158 return false;
159 }
160
161 $www_authenticate = $headers['www-authenticate'];
162 if ( \is_array( $www_authenticate ) ) {
163 $www_authenticate = (string) \reset( $www_authenticate );
164 }
165
166 return ( \stripos( (string) $www_authenticate, 'use_dpop_nonce' ) !== false );
167 }
168
169 /**
170 * Executes a single HTTP request with optional DPoP proof injection.
171 *
172 * @param string $method The HTTP method.
173 * @param string $url The request URL.
174 * @param array<string, string|int|bool|string[]|null> $options Request options.
175 *
176 * @return HTTP_Response The parsed response.
177 */
178 private function do_request( string $method, string $url, array $options ): HTTP_Response {
179 $headers = ( $options['headers'] ?? [] );
180 $timeout = ( $options['timeout'] ?? 10 );
181
182 // Add DPoP proof header if requested.
183 if ( ! empty( $options['dpop'] ) ) {
184 $access_token = ( $options['access_token'] ?? null );
185 try {
186 $headers['DPoP'] = $this->dpop_handler->create_proof( $method, $url, $access_token );
187 } catch ( DPoP_Proof_Exception $e ) {
188 $this->logger->error(
189 'DPoP proof generation failed for {method} {url}: {error}',
190 [
191 'method' => $method,
192 'url' => $url,
193 'error' => $e->getMessage(),
194 ],
195 );
196 return new HTTP_Response(
197 0,
198 [],
199 [
200 'error' => 'dpop_proof_failed',
201 'error_description' => $e->getMessage(),
202 ],
203 );
204 }
205 }
206
207 $wp_args = [
208 'method' => \strtoupper( $method ),
209 'headers' => $headers,
210 'timeout' => $timeout,
211 ];
212
213 if ( isset( $options['body'] ) ) {
214 $wp_args['body'] = $options['body'];
215 }
216
217 $response = \wp_remote_request( $url, $wp_args );
218
219 return $this->parse_response( $response, $url );
220 }
221
222 /**
223 * Parses a WordPress HTTP API response into a standardized format.
224 *
225 * @param array<string, string|int|array<string, string>>|WP_Error $response The raw WordPress response.
226 * @param string $url The request URL (for error messages).
227 *
228 * @return HTTP_Response The parsed response.
229 */
230 private function parse_response( $response, string $url ): HTTP_Response {
231 if ( \is_wp_error( $response ) ) {
232 $this->logger->warning(
233 'Network error for {url}: {error}',
234 [
235 'url' => $url,
236 'error' => $response->get_error_message(),
237 ],
238 );
239 return new HTTP_Response(
240 0,
241 [],
242 [
243 'error' => 'network_error',
244 'error_description' => $response->get_error_message(),
245 ],
246 );
247 }
248
249 $status = \wp_remote_retrieve_response_code( $response );
250 $headers = \wp_remote_retrieve_headers( $response );
251 $body = \wp_remote_retrieve_body( $response );
252
253 $headers_array = [];
254 if ( $headers instanceof CaseInsensitiveDictionary ) {
255 $headers_array = (array) $headers->getAll();
256 }
257 elseif ( \is_array( $headers ) ) {
258 $headers_array = $headers;
259 }
260
261 $decoded = \json_decode( $body, true );
262
263 $parsed = new HTTP_Response(
264 (int) $status,
265 $headers_array,
266 ( \is_array( $decoded ) ? $decoded : $body ),
267 );
268
269 if ( (int) $status === 429 ) {
270 $this->logger->warning( 'Rate limited (429) by {url}.', [ 'url' => $url ] );
271 $this->store_rate_limit_response( $url, $parsed );
272 }
273
274 return $parsed;
275 }
276
277 /**
278 * Returns a cached 429 response with an updated Retry-After header, or null if not rate limited.
279 *
280 * @param string $url The request URL.
281 *
282 * @return HTTP_Response|null The cached response or null.
283 */
284 private function get_cached_rate_limit_response( string $url ): ?HTTP_Response {
285 $key = $this->get_rate_limit_key( $url );
286
287 try {
288 $cached = $this->expiring_store->get( $key );
289 } catch ( Key_Not_Found_Exception |Corrupted_Value_Exception $e ) {
290 return null;
291 }
292
293 if ( ! \is_array( $cached ) || ! isset( $cached['stored_at'], $cached['backoff_seconds'], $cached['status'], $cached['headers'], $cached['body'] ) ) {
294 return null;
295 }
296
297 $remaining = ( ( $cached['stored_at'] + $cached['backoff_seconds'] ) - \time() );
298 if ( $remaining <= 0 ) {
299 return null;
300 }
301
302 $headers = $cached['headers'];
303 $headers['retry-after'] = (string) $remaining;
304
305 return new HTTP_Response( (int) $cached['status'], $headers, $cached['body'] );
306 }
307
308 /**
309 * Stores a 429 response in the expiring store for later replay.
310 *
311 * @param string $url The request URL.
312 * @param HTTP_Response $response The parsed 429 response.
313 *
314 * @return void
315 */
316 private function store_rate_limit_response( string $url, HTTP_Response $response ): void {
317 $headers = $response->get_headers();
318 $retry_after = ( $headers['retry-after'] ?? null );
319
320 if ( \is_numeric( $retry_after ) ) {
321 $backoff_seconds = (int) $retry_after;
322 }
323 else {
324 $backoff_seconds = self::DEFAULT_BACKOFF_SECONDS;
325 }
326
327 $this->expiring_store->persist(
328 $this->get_rate_limit_key( $url ),
329 [
330 'stored_at' => \time(),
331 'backoff_seconds' => $backoff_seconds,
332 'status' => $response->get_status(),
333 'headers' => $headers,
334 'body' => $response->get_body(),
335 ],
336 $backoff_seconds,
337 );
338 }
339
340 /**
341 * Builds a rate limit key from a URL using its host and path.
342 *
343 * @param string $url The request URL.
344 *
345 * @return string The rate limit key.
346 */
347 private function get_rate_limit_key( string $url ): string {
348 $parsed = \wp_parse_url( $url );
349 $host = ( $parsed['host'] ?? 'unknown' );
350 $path = ( $parsed['path'] ?? '/' );
351
352 return self::RATE_LIMIT_KEY_PREFIX . $host . $path;
353 }
354 }
355