PluginProbe
Parse.ly / 3.24.2
Parse.ly v3.24.2
3.24.2 3.24.1 3.24.0 3.23.7 3.23.6 3.23.5 3.23.4 3.23.3 3.16.0 3.16.1 3.16.2 3.16.3 3.16.4 3.17.0 3.18.0 3.18.1 3.19.0 3.19.1 3.19.2 3.19.3 3.2.0 3.2.1 3.20.0 3.20.1 3.20.2 All 106 releases
wp-parsely / src / services / class-base-service-endpoint.php

class-base-service-endpoint.php in Parse.ly 3.24.2, at src/services/class-base-service-endpoint.php

428 lines 11.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * External Service API endpoint base class.
4 *
5 * @package Parsely
6 * @since 3.17.0
7 */
8
9 declare(strict_types=1);
10
11 namespace Parsely\Services;
12
13 use Parsely\Parsely;
14 use WP_Error;
15
16 /**
17 * Base class for API service endpoints.
18 *
19 * @since 3.17.0
20 *
21 * @phpstan-type WP_HTTP_Response array{
22 * headers: array<string, string>,
23 * body: string,
24 * response: array{
25 * code: int|false,
26 * message: string|false,
27 * },
28 * cookies: array<string, string>,
29 * http_response: \WP_HTTP_Requests_Response|null,
30 * }
31 *
32 * @phpstan-import-type WP_HTTP_Request_Args from Parsely
33 */
34 abstract class Base_Service_Endpoint {
35 /**
36 * The API service that this endpoint belongs to.
37 *
38 * @since 3.17.0
39 *
40 * @var Base_API_Service
41 */
42 protected $api_service;
43
44 /**
45 * Flag to truncate the content of the request body.
46 *
47 * If set to true, the content of the request body will be truncated to a maximum length.
48 *
49 * @since 3.14.1
50 * @since 3.17.0 Moved to the Base_Service_Endpoint class.
51 *
52 * @var bool
53 */
54 protected const TRUNCATE_CONTENT = false;
55
56 /**
57 * The maximum length of the content of the request body.
58 *
59 * @since 3.14.1
60 * @since 3.17.0 Moved to the Base_Service_Endpoint class.
61 *
62 * @var int
63 */
64 protected const TRUNCATE_CONTENT_LENGTH = 25000;
65
66 /**
67 * The query arguments removed from relayed messages.
68 *
69 * The Site ID isn't listed, as it's public and aids diagnosis.
70 *
71 * @since 3.24.2
72 *
73 * @var array<string>
74 */
75 private const CREDENTIAL_QUERY_ARGS = array( 'secret' );
76
77 /**
78 * Initializes the class.
79 *
80 * @since 3.17.0
81 *
82 * @param Base_API_Service $api_service The API service that this endpoint belongs to.
83 */
84 public function __construct( Base_API_Service $api_service ) {
85 $this->api_service = $api_service;
86 }
87
88 /**
89 * Returns the headers to send with the request.
90 *
91 * @since 3.17.0
92 *
93 * @return array<string, string> The headers to send with the request.
94 */
95 protected function get_headers(): array {
96 return array(
97 'Content-Type' => 'application/json',
98 );
99 }
100
101 /**
102 * Returns the request options for the remote API request.
103 *
104 * @since 3.17.0
105 *
106 * @param string $method The HTTP method to use for the request.
107 * @return WP_HTTP_Request_Args The request options for the remote API request.
108 */
109 protected function get_request_options( string $method ): array {
110 $options = array(
111 'headers' => $this->get_headers(),
112 'method' => $method,
113 'timeout' => 60, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout
114 );
115
116 return $options;
117 }
118
119 /**
120 * Returns the common query arguments to send to the remote API.
121 *
122 * This can be used for setting common query arguments that are shared
123 * across multiple endpoints, such as the API key.
124 *
125 * @since 3.17.0
126 *
127 * @param array<mixed> $args Additional query arguments to send to the remote API.
128 * @return array<mixed> The query arguments to send to the remote API.
129 */
130 protected function get_query_args( array $args = array() ): array {
131 return $args;
132 }
133
134 /**
135 * Executes the API request.
136 *
137 * @since 3.17.0
138 *
139 * @param array<mixed> $args The arguments to pass to the API request.
140 * @return WP_Error|array<mixed> The response from the API.
141 */
142 abstract public function call( array $args = array() );
143
144 /**
145 * Returns the endpoint for the API request.
146 *
147 * This should be the path to the endpoint, not the full URL.
148 * Override this method in the child class to return the endpoint.
149 *
150 * @since 3.17.0
151 *
152 * @return string The endpoint for the API request.
153 */
154 abstract public function get_endpoint(): string;
155
156 /**
157 * Returns the full URL for the API request, including the endpoint and query arguments.
158 *
159 * @since 3.17.0
160 * @since 3.24.2 Encodes the query argument values.
161 *
162 * @param array<mixed> $query_args The query arguments to send to the remote API.
163 * @return string The full URL for the API request.
164 */
165 public function get_endpoint_url( array $query_args = array() ): string {
166 // Get the base URL from the API service.
167 $base_url = $this->api_service->get_api_url();
168
169 // Append the endpoint to the base URL.
170 $base_url .= $this->get_endpoint();
171
172 $query_args = $this->get_query_args( $query_args );
173
174 // add_query_arg() doesn't encode values, so `&` or `#` in one would alter the query.
175 // Decoding first keeps already-encoded values, like non-Latin permalinks, from being encoded twice.
176 foreach ( $query_args as $key => $value ) {
177 if ( is_string( $value ) ) {
178 $query_args[ $key ] = rawurlencode( rawurldecode( $value ) );
179 }
180 }
181
182 // Append any necessary query arguments.
183 $endpoint = add_query_arg( $query_args, $base_url );
184
185 return $endpoint;
186 }
187
188 /**
189 * Sends a request to the remote API.
190 *
191 * Any error returned has its credentials stripped.
192 *
193 * @since 3.17.0
194 *
195 * @param string $method The HTTP method to use for the request.
196 * @param array<mixed> $query_args The query arguments to send to the remote API.
197 * @param array<mixed> $data The data to send in the request body.
198 * @return WP_Error|array<mixed> The response from the remote API.
199 */
200 protected function request( string $method, array $query_args = array(), array $data = array() ) {
201 // Get the URL to send the request to.
202 $request_url = $this->get_endpoint_url( $query_args );
203
204 // Build the request options.
205 $request_options = $this->get_request_options( $method );
206
207 if ( count( $data ) > 0 ) {
208 if ( true === static::TRUNCATE_CONTENT ) {
209 $data = $this->truncate_array_content( $data );
210 }
211
212 $request_options['body'] = wp_json_encode( $data );
213 if ( false === $request_options['body'] ) {
214 return new WP_Error( 400, __( 'Unable to encode request body', 'wp-parsely' ) );
215 }
216 }
217
218 /** @var WP_HTTP_Response|WP_Error $response */
219 $response = wp_safe_remote_request( $request_url, $request_options );
220
221 if ( is_wp_error( $response ) ) {
222 return $this->get_relayable_transport_error( $response );
223 }
224
225 $result = $this->process_response( $response );
226
227 return is_wp_error( $result ) ? $this->strip_credentials_from_error( $result ) : $result;
228 }
229
230 /**
231 * Returns a transport error reduced to its code and first message, without
232 * the credentials.
233 *
234 * Its other messages and data are dropped, as `pre_http_request` and other
235 * filters can attach anything to it, including the request headers.
236 *
237 * @since 3.24.2
238 *
239 * @param WP_Error $error The transport error.
240 * @return WP_Error The error to relay to the caller.
241 */
242 protected function get_relayable_transport_error( WP_Error $error ): WP_Error {
243 return $this->strip_credentials_from_error(
244 new WP_Error(
245 $error->get_error_code(),
246 $error->get_error_message(),
247 array( 'status' => 500 )
248 )
249 );
250 }
251
252 /**
253 * Processes the response from the remote API.
254 *
255 * @since 3.17.0
256 * @since 3.24.2 Added the upstream status code check.
257 *
258 * @param WP_HTTP_Response|WP_Error $response The response from the remote API.
259 * @return array<mixed>|WP_Error The processed response.
260 */
261 protected function process_response( $response ) {
262 if ( is_wp_error( $response ) ) {
263 /** @var WP_Error $response */
264 return $response;
265 }
266
267 $body = wp_remote_retrieve_body( $response );
268 $decoded = json_decode( $body, true );
269
270 // A parseable body does not mean the request succeeded.
271 $status_code = (int) wp_remote_retrieve_response_code( $response );
272 if ( 0 !== $status_code && ( $status_code < 200 || $status_code >= 300 ) ) {
273 // The upstream message is kept, as request() strips the credentials.
274 $message = is_array( $decoded ) && isset( $decoded['message'] ) && is_string( $decoded['message'] ) && '' !== $decoded['message']
275 ? $decoded['message']
276 : __( 'The upstream API returned an unsuccessful response', 'wp-parsely' );
277
278 return new WP_Error( $status_code, $message, array( 'status' => $status_code ) );
279 }
280
281 if ( ! is_array( $decoded ) ) {
282 return new WP_Error( 400, __( 'Unable to decode upstream API response', 'wp-parsely' ) );
283 }
284
285 return $decoded;
286 }
287
288 /**
289 * Removes the credentials from a message relayed to the caller.
290 *
291 * Arguments are dropped by name rather than by value, so values altered by
292 * the URL builder are covered too.
293 *
294 * @since 3.24.2
295 *
296 * @param string $message The message to strip.
297 * @return string The message, without the credentials.
298 */
299 protected function strip_credentials( string $message ): string {
300 if ( '' === $message ) {
301 return $message;
302 }
303
304 foreach ( self::CREDENTIAL_QUERY_ARGS as $arg ) {
305 $arg = preg_quote( $arg, '/' );
306
307 // Keeps the separator when arguments follow, drops it when trailing.
308 $message = (string) preg_replace(
309 array( '/([?&])' . $arg . '=[^&\s]*&/i', '/[?&]' . $arg . '=[^&\s]*/i' ),
310 array( '$1', '' ),
311 $message
312 );
313 }
314
315 // Also catches the secret outside a query string.
316 $secret = $this->get_parsely()->get_api_secret();
317 if ( '' !== $secret ) {
318 $message = str_replace( $secret, '', $message );
319 }
320
321 // An encoded copy, such as a URL encoded by a filter, can't be stripped in place.
322 if ( $this->has_encoded_credentials( $message, $secret ) ) {
323 return __( 'The upstream API request failed.', 'wp-parsely' );
324 }
325
326 return $message;
327 }
328
329 /**
330 * Returns whether the URL-decoded or JSON-unescaped forms of a message
331 * still contain a credential.
332 *
333 * @since 3.24.2
334 *
335 * @param string $message The message, already stripped of its plain credentials.
336 * @param string $secret The API Secret.
337 * @return bool Whether the message holds an encoded credential.
338 */
339 private function has_encoded_credentials( string $message, string $secret ): bool {
340 $decoded = $message;
341
342 // Up to triple encoding, as each layer of code relaying a URL can encode it again.
343 for ( $i = 0; $i < 3; $i++ ) {
344 $decoded = str_replace( '\\/', '/', rawurldecode( $decoded ) );
345
346 if ( '' !== $secret && false !== strpos( $decoded, $secret ) ) {
347 return true;
348 }
349
350 foreach ( self::CREDENTIAL_QUERY_ARGS as $arg ) {
351 if ( 1 === preg_match( '/[?&]' . preg_quote( $arg, '/' ) . '=/i', $decoded ) ) {
352 return true;
353 }
354 }
355 }
356
357 return false;
358 }
359
360 /**
361 * Removes the credentials from every message held by an error.
362 *
363 * @since 3.24.2
364 *
365 * @param WP_Error $error The error to strip.
366 * @return WP_Error The error, without the credentials.
367 */
368 protected function strip_credentials_from_error( WP_Error $error ): WP_Error {
369 $codes = $error->get_error_codes();
370
371 if ( 0 === count( $codes ) ) {
372 return $error;
373 }
374
375 $stripped = new WP_Error();
376
377 foreach ( $codes as $code ) {
378 foreach ( $error->get_error_messages( $code ) as $message ) {
379 $stripped->add(
380 $code,
381 $this->strip_credentials( $message ),
382 $error->get_error_data( $code )
383 );
384 }
385 }
386
387 return $stripped;
388 }
389
390 /**
391 * Returns the Parsely instance.
392 *
393 * @since 3.17.0
394 *
395 * @return Parsely The Parsely instance.
396 */
397 public function get_parsely(): Parsely {
398 return $this->api_service->get_parsely();
399 }
400
401 /**
402 * Truncates the content of an array to a maximum length.
403 *
404 * @since 3.14.1
405 * @since 3.17.0 Moved to the Base_Service_Endpoint class.
406 *
407 * @param string|array|mixed $content The content to truncate.
408 * @return string|array|mixed The truncated content.
409 */
410 private function truncate_array_content( $content ) {
411 if ( is_array( $content ) ) {
412 // If the content is an array, iterate over its elements.
413 foreach ( $content as $key => $value ) {
414 // Recursively process/truncate each element of the array.
415 $content[ $key ] = $this->truncate_array_content( $value );
416 }
417 return $content;
418 } elseif ( is_string( $content ) ) {
419 // Check if the string length exceeds the maximum and truncate if necessary.
420 if ( mb_strlen( $content ) > self::TRUNCATE_CONTENT_LENGTH ) {
421 return mb_substr( $content, 0, self::TRUNCATE_CONTENT_LENGTH );
422 }
423 return $content;
424 }
425 return $content;
426 }
427 }
428