-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathWpHttpClient.php
More file actions
486 lines (433 loc) · 19.3 KB
/
Copy pathWpHttpClient.php
File metadata and controls
486 lines (433 loc) · 19.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
<?php
/**
* WordPress-native transport for the Assinafy PHP SDK.
*
* @package Assinafy\WP
*/
declare( strict_types=1 );
namespace Assinafy\WP\Http;
use Assinafy\SDK\Configuration;
use Assinafy\SDK\Exceptions\ApiException;
use Assinafy\SDK\Exceptions\NetworkException;
use Assinafy\SDK\Http\HttpClientInterface;
use Assinafy\SDK\Http\LogRedactor;
use Assinafy\SDK\Http\Response;
use Assinafy\WP\Vendor\Psr\Log\LoggerInterface;
use Assinafy\WP\Vendor\Psr\Log\NullLogger;
defined( 'ABSPATH' ) || exit;
/**
* Talks to the Assinafy API through the WordPress HTTP API.
*
* Injected into `AssinafyClient` so the SDK's Guzzle transport is never constructed and
* `GuzzleHttp\*` is never autoloaded. Requests therefore inherit the site's proxy settings,
* `WP_HTTP_BLOCK_EXTERNAL` policy and TLS configuration, and are interceptable through the
* `pre_http_request` / `http_request_args` filters.
*
* Four rules in here are security controls rather than tidiness:
*
* - a request URI must be relative, so a caller cannot point a credentialled request at
* another origin;
* - redirects are refused, so `X-Api-Key` is never replayed to a redirect target;
* - without cURL, a request through a proxy is refused, because streams cannot tunnel TLS;
* - signer-facing and public routes are sent with no credential at all.
*/
// phpcs:disable WordPress.NamingConventions.ValidFunctionName -- Method names are fixed by HttpClientInterface.
// phpcs:disable WordPress.NamingConventions.ValidVariableName -- Parameter names are fixed by HttpClientInterface.
final class WpHttpClient implements HttpClientInterface {
/**
* Base URL with a mandatory trailing slash.
*
* The slash is load-bearing. RFC 3986 resolution replaces the last path segment of a base
* without one, so `https://api.assinafy.com.br/v1` + `documents/statuses` would silently
* lose the `/v1`. Request URLs are built by plain concatenation onto this value.
*/
private string $base_url;
/**
* Default header names, for `__debugInfo()` only. Values stay on the request factory.
*
* @var list<string>
*/
private array $default_header_names;
/**
* Assembles the arguments for one outbound request, credential policy included.
*/
private RequestFactory $requests;
/**
* Turns a transport result into a `Response` or the failure it reports.
*/
private ResponseReader $responses;
/**
* Structure-only diagnostics. Request and response bodies are never logged.
*/
private LoggerInterface $logger;
/**
* OAuth only: refreshes a rejected access token, returning the new one or why there is none.
*
* @var (\Closure(): (string|\WP_Error))|null
*/
private ?\Closure $renew_token;
/**
* @param Configuration $config Base URL, timeout and default headers.
* @param LoggerInterface|null $logger Optional PSR-3 logger for structural diagnostics.
* @param \Closure|null $renew_token OAuth: called when the API rejects the Bearer token.
*/
public function __construct( #[\SensitiveParameter] Configuration $config, ?LoggerInterface $logger = null, ?\Closure $renew_token = null ) {
$headers = $config->getHeaders();
$this->base_url = rtrim( $config->getBaseUrl(), '/' ) . '/';
$this->logger = $logger ?? new NullLogger();
$this->default_header_names = array_keys( $headers );
$this->requests = new RequestFactory(
$config->getTimeout(),
$headers,
$headers['User-Agent'] ?? 'Assinafy-PHP-SDK/v' . Configuration::SDK_VERSION
);
$this->responses = new ResponseReader( $this->logger );
$this->renew_token = $renew_token;
}
/**
* Keep credentials out of diagnostic object dumps.
*
* @return array{transport: string, logger: string, base_url: string, default_headers: list<string>}
*/
public function __debugInfo(): array {
return array(
'transport' => 'wp_remote_request',
'logger' => $this->logger::class,
'base_url' => $this->base_url,
'default_headers' => $this->default_header_names,
);
}
/**
* {@inheritDoc}
*
* @param string $uri URI relative to the configured base URL.
* @param array<string, scalar> $params Query-string parameters.
* @param array<string, string> $headers Per-request headers.
*/
public function get( string $uri, array $params = array(), array $headers = array() ): Response {
return $this->request(
'GET',
$uri,
array(
'query' => $params,
'headers' => $headers,
)
);
}
/**
* {@inheritDoc}
*
* @param string $uri URI relative to the configured base URL.
* @param array<array-key, mixed>|null $data JSON body; null sends no body at all.
* @param array<string, string> $headers Per-request headers.
* @param array<string, scalar> $query Query-string parameters.
*/
public function post( string $uri, ?array $data = null, array $headers = array(), array $query = array() ): Response {
return $this->request( 'POST', $uri, $this->json_options( $data, $headers, $query ) );
}
/**
* {@inheritDoc}
*
* @param string $uri URI relative to the configured base URL.
* @param array<array-key, mixed>|null $data JSON body; null sends no body at all.
* @param array<string, string> $headers Per-request headers.
* @param array<string, scalar> $query Query-string parameters.
*/
public function put( string $uri, ?array $data = null, array $headers = array(), array $query = array() ): Response {
return $this->request( 'PUT', $uri, $this->json_options( $data, $headers, $query ) );
}
/**
* {@inheritDoc}
*
* @param string $uri URI relative to the configured base URL.
* @param array<array-key, mixed>|null $data JSON body; null sends no body at all.
* @param array<string, string> $headers Per-request headers.
* @param array<string, scalar> $query Query-string parameters.
*/
public function patch( string $uri, ?array $data = null, array $headers = array(), array $query = array() ): Response {
return $this->request( 'PATCH', $uri, $this->json_options( $data, $headers, $query ) );
}
/**
* {@inheritDoc}
*
* @param string $uri URI relative to the configured base URL.
* @param array<string, string> $headers Per-request headers.
* @param array<string, scalar> $query Query-string parameters.
* @param array<array-key, mixed> $data Optional JSON body; an empty array sends none.
*/
public function delete( string $uri, array $headers = array(), array $query = array(), array $data = array() ): Response {
$options = array( 'headers' => array() === $data ? $headers : $this->with_json_headers( $headers ) );
if ( array() !== $data ) {
$options['json'] = $data;
}
return $this->request( 'DELETE', $uri, $this->with_optional_query( $options, $query ) );
}
/**
* {@inheritDoc}
*
* WordPress has no multipart helper, so the body is assembled by hand. The file part must be
* named exactly `file`; any other part name answers 400 "O parâmetro \"file\" não está
* presente." and an extra `name=` field is accepted and silently discarded, so the document
* is renamed afterwards with PATCH rather than at upload time.
*
* The whole file is read into memory: the WordPress HTTP API cannot stream a request body.
*
* @param string $uri URI relative to the configured base URL.
* @param string $filePath Absolute path of the file to upload.
* @param array<string, mixed> $data Extra form fields; arrays are JSON-encoded.
* @param array<string, string> $headers Per-request headers.
*
* @throws \InvalidArgumentException When the file is missing or unreadable.
*/
public function uploadFile( string $uri, string $filePath, array $data = array(), array $headers = array() ): Response {
$file_path = $filePath;
if ( ! is_file( $file_path ) || ! is_readable( $file_path ) ) {
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- A filesystem diagnostic, escaped by the screen that displays it.
throw new \InvalidArgumentException( "File not found: {$file_path}" );
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading a local file for a multipart body; WP_Filesystem cannot return binary safely here.
$contents = file_get_contents( $file_path );
if ( false === $contents ) {
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- A filesystem diagnostic, escaped by the screen that displays it.
throw new \InvalidArgumentException( "File is not readable: {$file_path}" );
}
$file_type = wp_check_filetype( $file_path );
$multipart = array(
array(
'name' => 'file',
'contents' => $contents,
'filename' => basename( $file_path ),
'content_type' => is_string( $file_type['type'] ?? null ) ? $file_type['type'] : 'application/octet-stream',
),
);
foreach ( $data as $key => $value ) {
$multipart[] = array(
'name' => (string) $key,
'contents' => is_array( $value ) ? (string) wp_json_encode( $value ) : (string) $value,
);
}
return $this->request(
'POST',
$uri,
array(
'multipart' => $multipart,
'headers' => $headers,
)
);
}
/**
* {@inheritDoc}
*
* @param string $uri URI relative to the configured base URL.
* @param string $body Raw request body, possibly binary.
* @param string $contentType Content type of the raw body.
* @param array<string, scalar> $query Query-string parameters.
* @param array<string, string> $headers Per-request headers.
*/
public function postRaw(
string $uri,
string $body,
string $contentType,
array $query = array(),
array $headers = array()
): Response {
return $this->request(
'POST',
$uri,
array(
'query' => $query,
'body' => $body,
'headers' => array_merge( array( 'Content-Type' => $contentType ), $headers ),
)
);
}
/**
* Add the JSON content type without discarding caller headers.
*
* @param array<string, string> $headers Per-request headers.
* @return array<string, string>
*/
private function with_json_headers( array $headers ): array {
return array_merge( array( 'Content-Type' => 'application/json' ), $headers );
}
/**
* @param array<string, mixed> $options Transport options.
* @param array<string, scalar> $query Query-string parameters.
* @return array<string, mixed>
*/
private function with_optional_query( array $options, array $query ): array {
if ( array() !== $query ) {
$options['query'] = $query;
}
return $options;
}
/**
* Body semantics for the methods that carry JSON.
*
* `null` means no body and no `Content-Type`; an empty array means a literal JSON `[]`,
* which `POST /accounts/{id}/fields/validate-multiple` and the signer sign route require.
*
* @param array<array-key, mixed>|null $data JSON body, or null for none.
* @param array<string, string> $headers Per-request headers.
* @param array<string, scalar> $query Query-string parameters.
* @return array<string, mixed>
*/
private function json_options( ?array $data, array $headers, array $query ): array {
$options = array( 'headers' => null === $data ? $headers : $this->with_json_headers( $headers ) );
if ( null !== $data ) {
$options['json'] = $data;
}
return $this->with_optional_query( $options, $query );
}
/**
* Send one request and turn the result into a `Response` or an exception.
*
* @param string $method HTTP method.
* @param string $uri URI relative to the configured base URL.
* @param array<string, mixed> $options Transport options: query, headers, json, body, multipart.
*
* @throws \InvalidArgumentException When the URI is not relative or the payload cannot be encoded.
* @throws NetworkException When the transport fails or the response shape is invalid.
* @throws ApiException When the HTTP status or the envelope status reports a failure.
*/
private function request( string $method, string $uri, #[\SensitiveParameter] array $options = array() ): Response {
RequestFactory::assert_relative_uri( $uri );
$options = $this->requests->with_default_headers( $method, $uri, $options );
$args = $this->requests->args( $method, $options );
$safe_path = explode( '?', explode( '#', $uri, 2 )[0], 2 )[0];
$safe_request = LogRedactor::redactText( "{$method} {$safe_path}" );
$this->logger->debug(
"Assinafy API Request: {$safe_request}",
array( 'request' => LogRedactor::summarizeRequestOptions( $options ) )
);
// Concatenation, not RFC 3986 reference resolution: the base URL keeps its trailing
// slash, so a validated relative URI can only ever land on the configured origin.
$url = $this->base_url . $uri;
// Not `add_query_arg()`: it emits array arguments verbatim, so a `+` or `&` in a signer
// email would reach the wire unencoded. RFC 3986 is what the SDK's Guzzle transport sends.
if ( isset( $options['query'] ) && is_array( $options['query'] ) && array() !== $options['query'] ) {
$url .= ( str_contains( $url, '?' ) ? '&' : '?' )
. http_build_query( $options['query'], '', '&', PHP_QUERY_RFC3986 );
}
// The URL WordPress itself decides the proxy for.
RequestFactory::assert_tunnelled( $url );
return $this->responses->read( $this->renew_on_401( $this->dispatch( $url, $args, $safe_request ), $url, $args, $safe_request ), $safe_request );
}
/**
* OAuth: when the API rejects the access token, refresh once and resend once. A failed
* refresh has already dropped the connection, so its reason (reconnect) replaces the 401.
* A 401 means the API processed nothing, so resending cannot duplicate an action.
*
* @param array<string, mixed> $result Raw result of the first attempt.
* @param string $url Absolute request URL.
* @param array<string, mixed> $args Transport arguments.
* @param string $safe_request Redacted method and path, for diagnostics.
* @return array<string, mixed>
*
* @throws ApiException When the token could not be refreshed.
*/
private function renew_on_401( array $result, string $url, #[\SensitiveParameter] array $args, string $safe_request ): array {
$renew = $this->renew_token;
$headers = $args['headers'] ?? null;
if ( null === $renew || 401 !== (int) wp_remote_retrieve_response_code( $result ) || ! is_array( $headers ) || ! Headers::has( $headers, 'Authorization' ) ) {
return $result;
}
$token = $renew();
if ( $token instanceof \WP_Error ) {
throw new ApiException( $token->get_error_message(), 401 ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- A translated plain-text message; display consumers escape it.
}
if ( '' === $token ) {
return $result;
}
$args['headers'] = array( 'Authorization' => 'Bearer ' . $token ) + Headers::without( $headers, 'Authorization' );
return $this->dispatch( $url, $args, $safe_request );
}
/**
* Send the request and map a transport failure onto `NetworkException`.
*
* Guarantees the failing URL never reaches the exception chain: the `WP_Error` is replaced
* by a cause carrying only its sanitised error code, because a request URI can hold a signer
* access code.
*
* @param string $url Absolute request URL.
* @param array<string, mixed> $args Transport arguments.
* @param string $safe_request Redacted method and path, for diagnostics.
* @return array<string, mixed> Raw `wp_remote_request()` result.
*
* @throws NetworkException When the transport fails.
*/
private function dispatch( string $url, #[\SensitiveParameter] array $args, string $safe_request ): array {
// The HTTP API has no minimum-TLS argument, so require TLS 1.2+ on this request's cURL
// handle only, after every other hook.
$require_tls12 = static function ( $handle, $parsed_args, $request_url ) use ( $url ): void {
if ( $request_url === $url ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- `http_api_curl` hands over the raw cURL handle; there is no WordPress wrapper.
curl_setopt( $handle, CURLOPT_SSLVERSION, CURL_SSLVERSION_TLSv1_2 );
}
};
// Without cURL, the streams transport opens `ssl://host:port`, which PHP lets fall back
// to TLS 1.0. Its only hook is the socket URI, and `tlsv1.2://` is the strictest one.
// ponytail: pins exactly TLS 1.2 there (no 1.3); the context is unreachable for a range.
$socket_prefix = 'ssl://' . wp_parse_url( $url, PHP_URL_HOST ) . ':';
$require_tls12_stream = static function ( &$remote_socket ) use ( $socket_prefix ): void {
if ( is_string( $remote_socket ) && str_starts_with( $remote_socket, $socket_prefix ) ) {
$remote_socket = 'tlsv1.2://' . substr( $remote_socket, strlen( 'ssl://' ) );
}
};
add_action( 'http_api_curl', $require_tls12, PHP_INT_MAX, 3 );
add_action( 'requests-fsockopen.remote_socket', $require_tls12_stream, PHP_INT_MAX );
try {
$result = wp_remote_request( $url, $args );
} finally {
remove_action( 'http_api_curl', $require_tls12, PHP_INT_MAX );
remove_action( 'requests-fsockopen.remote_socket', $require_tls12_stream, PHP_INT_MAX );
}
if ( is_wp_error( $result ) ) {
$this->logger->error(
"Assinafy Network Error: {$safe_request}",
array( 'error_code' => (string) $result->get_error_code() )
);
throw new NetworkException(
self::network_message( $result ), // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- This sanitized exception is not HTML; display consumers escape it.
0,
self::sanitized_previous( $result ), // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Preserve the sanitized Throwable cause without converting it into HTML.
self::request_was_not_sent( $result ) ? array( 'request_sent' => false ) : array()
);
}
return $result;
}
/** Only transport failures before an HTTP request may retry a rotating token. */
private static function request_was_not_sent( \WP_Error $error ): bool {
if ( 'http_request_not_executed' === $error->get_error_code() ) {
return true;
}
return 'http_request_failed' === $error->get_error_code()
&& 1 === preg_match( '/^cURL error (?:6|7|35|60):/', $error->get_error_message() );
}
/**
* An actionable message for the failure modes a site owner can fix.
*
* @param \WP_Error $error Transport error.
*/
private static function network_message( \WP_Error $error ): string {
if ( 'http_request_not_executed' === $error->get_error_code() ) {
return 'Network error while calling the Assinafy API: this site blocks external HTTP requests. '
. 'Add the Assinafy API host to WP_ACCESSIBLE_HOSTS, or unset WP_HTTP_BLOCK_EXTERNAL.';
}
return 'Network error while calling the Assinafy API';
}
/**
* Keep a safe diagnostic cause without retaining the request URI.
*
* A request URI can carry a signer access code, and `WP_Error` messages from the transport
* quote the URL they failed on, so the original is discarded rather than chained.
*
* @param \WP_Error $error Transport error.
*/
private static function sanitized_previous( \WP_Error $error ): \RuntimeException {
$code = preg_replace( '/[^A-Za-z0-9_.-]/', '', (string) $error->get_error_code() );
return new \RuntimeException(
'Underlying HTTP transport error (WP_Error' . ( '' === (string) $code ? '' : ': ' . $code ) . ')'
);
}
}