PluginProbe
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell / 2.8.16
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell v2.8.16
3.13.3 3.13.2 3.13.1 3.13.0 3.12.13 3.12.12 3.12.11 3.12.10 3.12.9 3.12.8 3.12.7 3.12.6 3.12.5 3.12.4 3.12.3 3.12.1 3.12.2 3.12.0 3.11.1 3.11.0 3.10.9 3.10.8 3.10.7 3.10.6 2.8.16 All 261 releases
wpfunnels / vendor / symfony / http-client / HttpClientTrait.php

HttpClientTrait.php in WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell 2.8.16, at vendor/symfony/http-client/HttpClientTrait.php

715 lines 28.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /*
4 * This file is part of the Symfony package.
5 *
6 * (c) Fabien Potencier <[email protected]>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12 namespace Symfony\Component\HttpClient;
13
14 use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
15 use Symfony\Component\HttpClient\Exception\TransportException;
16
17 /**
18 * Provides the common logic from writing HttpClientInterface implementations.
19 *
20 * All private methods are static to prevent implementers from creating memory leaks via circular references.
21 *
22 * @author Nicolas Grekas <[email protected]>
23 */
24 trait HttpClientTrait
25 {
26 private static $CHUNK_SIZE = 16372;
27
28 /**
29 * {@inheritdoc}
30 */
31 public function withOptions(array $options): self
32 {
33 $clone = clone $this;
34 $clone->defaultOptions = self::mergeDefaultOptions($options, $this->defaultOptions);
35
36 return $clone;
37 }
38
39 /**
40 * Validates and normalizes method, URL and options, and merges them with defaults.
41 *
42 * @throws InvalidArgumentException When a not-supported option is found
43 */
44 private static function prepareRequest(?string $method, ?string $url, array $options, array $defaultOptions = [], bool $allowExtraOptions = false): array
45 {
46 if (null !== $method) {
47 if (\strlen($method) !== strspn($method, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ')) {
48 throw new InvalidArgumentException(sprintf('Invalid HTTP method "%s", only uppercase letters are accepted.', $method));
49 }
50 if (!$method) {
51 throw new InvalidArgumentException('The HTTP method cannot be empty.');
52 }
53 }
54
55 $options = self::mergeDefaultOptions($options, $defaultOptions, $allowExtraOptions);
56
57 $buffer = $options['buffer'] ?? true;
58
59 if ($buffer instanceof \Closure) {
60 $options['buffer'] = static function (array $headers) use ($buffer) {
61 if (!\is_bool($buffer = $buffer($headers))) {
62 if (!\is_array($bufferInfo = @stream_get_meta_data($buffer))) {
63 throw new \LogicException(sprintf('The closure passed as option "buffer" must return bool or stream resource, got "%s".', get_debug_type($buffer)));
64 }
65
66 if (false === strpbrk($bufferInfo['mode'], 'acew+')) {
67 throw new \LogicException(sprintf('The stream returned by the closure passed as option "buffer" must be writeable, got mode "%s".', $bufferInfo['mode']));
68 }
69 }
70
71 return $buffer;
72 };
73 } elseif (!\is_bool($buffer)) {
74 if (!\is_array($bufferInfo = @stream_get_meta_data($buffer))) {
75 throw new InvalidArgumentException(sprintf('Option "buffer" must be bool, stream resource or Closure, "%s" given.', get_debug_type($buffer)));
76 }
77
78 if (false === strpbrk($bufferInfo['mode'], 'acew+')) {
79 throw new InvalidArgumentException(sprintf('The stream in option "buffer" must be writeable, mode "%s" given.', $bufferInfo['mode']));
80 }
81 }
82
83 if (isset($options['json'])) {
84 if (isset($options['body']) && '' !== $options['body']) {
85 throw new InvalidArgumentException('Define either the "json" or the "body" option, setting both is not supported.');
86 }
87 $options['body'] = self::jsonEncode($options['json']);
88 unset($options['json']);
89
90 if (!isset($options['normalized_headers']['content-type'])) {
91 $options['normalized_headers']['content-type'] = ['Content-Type: application/json'];
92 }
93 }
94
95 if (!isset($options['normalized_headers']['accept'])) {
96 $options['normalized_headers']['accept'] = ['Accept: */*'];
97 }
98
99 if (isset($options['body'])) {
100 $options['body'] = self::normalizeBody($options['body']);
101
102 if (\is_string($options['body'])
103 && (string) \strlen($options['body']) !== substr($h = $options['normalized_headers']['content-length'][0] ?? '', 16)
104 && ('' !== $h || '' !== $options['body'])
105 ) {
106 if ('chunked' === substr($options['normalized_headers']['transfer-encoding'][0] ?? '', \strlen('Transfer-Encoding: '))) {
107 unset($options['normalized_headers']['transfer-encoding']);
108 $options['body'] = self::dechunk($options['body']);
109 }
110
111 $options['normalized_headers']['content-length'] = [substr_replace($h ?: 'Content-Length: ', \strlen($options['body']), 16)];
112 }
113 }
114
115 if (isset($options['peer_fingerprint'])) {
116 $options['peer_fingerprint'] = self::normalizePeerFingerprint($options['peer_fingerprint']);
117 }
118
119 // Validate on_progress
120 if (isset($options['on_progress']) && !\is_callable($onProgress = $options['on_progress'])) {
121 throw new InvalidArgumentException(sprintf('Option "on_progress" must be callable, "%s" given.', get_debug_type($onProgress)));
122 }
123
124 if (\is_array($options['auth_basic'] ?? null)) {
125 $count = \count($options['auth_basic']);
126 if ($count <= 0 || $count > 2) {
127 throw new InvalidArgumentException(sprintf('Option "auth_basic" must contain 1 or 2 elements, "%s" given.', $count));
128 }
129
130 $options['auth_basic'] = implode(':', $options['auth_basic']);
131 }
132
133 if (!\is_string($options['auth_basic'] ?? '')) {
134 throw new InvalidArgumentException(sprintf('Option "auth_basic" must be string or an array, "%s" given.', get_debug_type($options['auth_basic'])));
135 }
136
137 if (isset($options['auth_bearer'])) {
138 if (!\is_string($options['auth_bearer'])) {
139 throw new InvalidArgumentException(sprintf('Option "auth_bearer" must be a string, "%s" given.', get_debug_type($options['auth_bearer'])));
140 }
141 if (preg_match('{[^\x21-\x7E]}', $options['auth_bearer'])) {
142 throw new InvalidArgumentException('Invalid character found in option "auth_bearer": '.json_encode($options['auth_bearer']).'.');
143 }
144 }
145
146 if (isset($options['auth_basic'], $options['auth_bearer'])) {
147 throw new InvalidArgumentException('Define either the "auth_basic" or the "auth_bearer" option, setting both is not supported.');
148 }
149
150 if (null !== $url) {
151 // Merge auth with headers
152 if (($options['auth_basic'] ?? false) && !($options['normalized_headers']['authorization'] ?? false)) {
153 $options['normalized_headers']['authorization'] = ['Authorization: Basic '.base64_encode($options['auth_basic'])];
154 }
155 // Merge bearer with headers
156 if (($options['auth_bearer'] ?? false) && !($options['normalized_headers']['authorization'] ?? false)) {
157 $options['normalized_headers']['authorization'] = ['Authorization: Bearer '.$options['auth_bearer']];
158 }
159
160 unset($options['auth_basic'], $options['auth_bearer']);
161
162 // Parse base URI
163 if (\is_string($options['base_uri'])) {
164 $options['base_uri'] = self::parseUrl($options['base_uri']);
165 }
166
167 // Validate and resolve URL
168 $url = self::parseUrl($url, $options['query']);
169 $url = self::resolveUrl($url, $options['base_uri'], $defaultOptions['query'] ?? []);
170 }
171
172 // Finalize normalization of options
173 $options['http_version'] = (string) ($options['http_version'] ?? '') ?: null;
174 if (0 > $options['timeout'] = (float) ($options['timeout'] ?? \ini_get('default_socket_timeout'))) {
175 $options['timeout'] = 172800.0; // 2 days
176 }
177
178 $options['max_duration'] = isset($options['max_duration']) ? (float) $options['max_duration'] : 0;
179 $options['headers'] = array_merge(...array_values($options['normalized_headers']));
180
181 return [$url, $options];
182 }
183
184 /**
185 * @throws InvalidArgumentException When an invalid option is found
186 */
187 private static function mergeDefaultOptions(array $options, array $defaultOptions, bool $allowExtraOptions = false): array
188 {
189 $options['normalized_headers'] = self::normalizeHeaders($options['headers'] ?? []);
190
191 if ($defaultOptions['headers'] ?? false) {
192 $options['normalized_headers'] += self::normalizeHeaders($defaultOptions['headers']);
193 }
194
195 $options['headers'] = array_merge(...array_values($options['normalized_headers']) ?: [[]]);
196
197 if ($resolve = $options['resolve'] ?? false) {
198 $options['resolve'] = [];
199 foreach ($resolve as $k => $v) {
200 $options['resolve'][substr(self::parseUrl('http://'.$k)['authority'], 2)] = (string) $v;
201 }
202 }
203
204 // Option "query" is never inherited from defaults
205 $options['query'] = $options['query'] ?? [];
206
207 $options += $defaultOptions;
208
209 if (isset(self::$emptyDefaults)) {
210 foreach (self::$emptyDefaults as $k => $v) {
211 if (!isset($options[$k])) {
212 $options[$k] = $v;
213 }
214 }
215 }
216
217 if (isset($defaultOptions['extra'])) {
218 $options['extra'] += $defaultOptions['extra'];
219 }
220
221 if ($resolve = $defaultOptions['resolve'] ?? false) {
222 foreach ($resolve as $k => $v) {
223 $options['resolve'] += [substr(self::parseUrl('http://'.$k)['authority'], 2) => (string) $v];
224 }
225 }
226
227 if ($allowExtraOptions || !$defaultOptions) {
228 return $options;
229 }
230
231 // Look for unsupported options
232 foreach ($options as $name => $v) {
233 if (\array_key_exists($name, $defaultOptions) || 'normalized_headers' === $name) {
234 continue;
235 }
236
237 if ('auth_ntlm' === $name) {
238 if (!\extension_loaded('curl')) {
239 $msg = 'try installing the "curl" extension to use "%s" instead.';
240 } else {
241 $msg = 'try using "%s" instead.';
242 }
243
244 throw new InvalidArgumentException(sprintf('Option "auth_ntlm" is not supported by "%s", '.$msg, __CLASS__, CurlHttpClient::class));
245 }
246
247 $alternatives = [];
248
249 foreach ($defaultOptions as $k => $v) {
250 if (levenshtein($name, $k) <= \strlen($name) / 3 || str_contains($k, $name)) {
251 $alternatives[] = $k;
252 }
253 }
254
255 throw new InvalidArgumentException(sprintf('Unsupported option "%s" passed to "%s", did you mean "%s"?', $name, __CLASS__, implode('", "', $alternatives ?: array_keys($defaultOptions))));
256 }
257
258 return $options;
259 }
260
261 /**
262 * @return string[][]
263 *
264 * @throws InvalidArgumentException When an invalid header is found
265 */
266 private static function normalizeHeaders(array $headers): array
267 {
268 $normalizedHeaders = [];
269
270 foreach ($headers as $name => $values) {
271 if (\is_object($values) && method_exists($values, '__toString')) {
272 $values = (string) $values;
273 }
274
275 if (\is_int($name)) {
276 if (!\is_string($values)) {
277 throw new InvalidArgumentException(sprintf('Invalid value for header "%s": expected string, "%s" given.', $name, get_debug_type($values)));
278 }
279 [$name, $values] = explode(':', $values, 2);
280 $values = [ltrim($values)];
281 } elseif (!is_iterable($values)) {
282 if (\is_object($values)) {
283 throw new InvalidArgumentException(sprintf('Invalid value for header "%s": expected string, "%s" given.', $name, get_debug_type($values)));
284 }
285
286 $values = (array) $values;
287 }
288
289 $lcName = strtolower($name);
290 $normalizedHeaders[$lcName] = [];
291
292 foreach ($values as $value) {
293 $normalizedHeaders[$lcName][] = $value = $name.': '.$value;
294
295 if (\strlen($value) !== strcspn($value, "\r\n\0")) {
296 throw new InvalidArgumentException(sprintf('Invalid header: CR/LF/NUL found in "%s".', $value));
297 }
298 }
299 }
300
301 return $normalizedHeaders;
302 }
303
304 /**
305 * @param array|string|resource|\Traversable|\Closure $body
306 *
307 * @return string|resource|\Closure
308 *
309 * @throws InvalidArgumentException When an invalid body is passed
310 */
311 private static function normalizeBody($body)
312 {
313 if (\is_array($body)) {
314 array_walk_recursive($body, $caster = static function (&$v) use (&$caster) {
315 if (\is_object($v)) {
316 if ($vars = get_object_vars($v)) {
317 array_walk_recursive($vars, $caster);
318 $v = $vars;
319 } elseif (method_exists($v, '__toString')) {
320 $v = (string) $v;
321 }
322 }
323 });
324
325 return http_build_query($body, '', '&');
326 }
327
328 if (\is_string($body)) {
329 return $body;
330 }
331
332 $generatorToCallable = static function (\Generator $body): \Closure {
333 return static function () use ($body) {
334 while ($body->valid()) {
335 $chunk = $body->current();
336 $body->next();
337
338 if ('' !== $chunk) {
339 return $chunk;
340 }
341 }
342
343 return '';
344 };
345 };
346
347 if ($body instanceof \Generator) {
348 return $generatorToCallable($body);
349 }
350
351 if ($body instanceof \Traversable) {
352 return $generatorToCallable((static function ($body) { yield from $body; })($body));
353 }
354
355 if ($body instanceof \Closure) {
356 $r = new \ReflectionFunction($body);
357 $body = $r->getClosure();
358
359 if ($r->isGenerator()) {
360 $body = $body(self::$CHUNK_SIZE);
361
362 return $generatorToCallable($body);
363 }
364
365 return $body;
366 }
367
368 if (!\is_array(@stream_get_meta_data($body))) {
369 throw new InvalidArgumentException(sprintf('Option "body" must be string, stream resource, iterable or callable, "%s" given.', get_debug_type($body)));
370 }
371
372 return $body;
373 }
374
375 private static function dechunk(string $body): string
376 {
377 $h = fopen('php://temp', 'w+');
378 stream_filter_append($h, 'dechunk', \STREAM_FILTER_WRITE);
379 fwrite($h, $body);
380 $body = stream_get_contents($h, -1, 0);
381 rewind($h);
382 ftruncate($h, 0);
383
384 if (fwrite($h, '-') && '' !== stream_get_contents($h, -1, 0)) {
385 throw new TransportException('Request body has broken chunked encoding.');
386 }
387
388 return $body;
389 }
390
391 /**
392 * @param string|string[] $fingerprint
393 *
394 * @throws InvalidArgumentException When an invalid fingerprint is passed
395 */
396 private static function normalizePeerFingerprint($fingerprint): array
397 {
398 if (\is_string($fingerprint)) {
399 switch (\strlen($fingerprint = str_replace(':', '', $fingerprint))) {
400 case 32: $fingerprint = ['md5' => $fingerprint]; break;
401 case 40: $fingerprint = ['sha1' => $fingerprint]; break;
402 case 44: $fingerprint = ['pin-sha256' => [$fingerprint]]; break;
403 case 64: $fingerprint = ['sha256' => $fingerprint]; break;
404 default: throw new InvalidArgumentException(sprintf('Cannot auto-detect fingerprint algorithm for "%s".', $fingerprint));
405 }
406 } elseif (\is_array($fingerprint)) {
407 foreach ($fingerprint as $algo => $hash) {
408 $fingerprint[$algo] = 'pin-sha256' === $algo ? (array) $hash : str_replace(':', '', $hash);
409 }
410 } else {
411 throw new InvalidArgumentException(sprintf('Option "peer_fingerprint" must be string or array, "%s" given.', get_debug_type($fingerprint)));
412 }
413
414 return $fingerprint;
415 }
416
417 /**
418 * @param mixed $value
419 *
420 * @throws InvalidArgumentException When the value cannot be json-encoded
421 */
422 private static function jsonEncode($value, int $flags = null, int $maxDepth = 512): string
423 {
424 $flags = $flags ?? (\JSON_HEX_TAG | \JSON_HEX_APOS | \JSON_HEX_AMP | \JSON_HEX_QUOT | \JSON_PRESERVE_ZERO_FRACTION);
425
426 try {
427 $value = json_encode($value, $flags | (\PHP_VERSION_ID >= 70300 ? \JSON_THROW_ON_ERROR : 0), $maxDepth);
428 } catch (\JsonException $e) {
429 throw new InvalidArgumentException('Invalid value for "json" option: '.$e->getMessage());
430 }
431
432 if (\PHP_VERSION_ID < 70300 && \JSON_ERROR_NONE !== json_last_error() && (false === $value || !($flags & \JSON_PARTIAL_OUTPUT_ON_ERROR))) {
433 throw new InvalidArgumentException('Invalid value for "json" option: '.json_last_error_msg());
434 }
435
436 return $value;
437 }
438
439 /**
440 * Resolves a URL against a base URI.
441 *
442 * @see https://tools.ietf.org/html/rfc3986#section-5.2.2
443 *
444 * @throws InvalidArgumentException When an invalid URL is passed
445 */
446 private static function resolveUrl(array $url, ?array $base, array $queryDefaults = []): array
447 {
448 if (null !== $base && '' === ($base['scheme'] ?? '').($base['authority'] ?? '')) {
449 throw new InvalidArgumentException(sprintf('Invalid "base_uri" option: host or scheme is missing in "%s".', implode('', $base)));
450 }
451
452 if (null === $url['scheme'] && (null === $base || null === $base['scheme'])) {
453 throw new InvalidArgumentException(sprintf('Invalid URL: scheme is missing in "%s". Did you forget to add "http(s)://"?', implode('', $base ?? $url)));
454 }
455
456 if (null === $base && '' === $url['scheme'].$url['authority']) {
457 throw new InvalidArgumentException(sprintf('Invalid URL: no "base_uri" option was provided and host or scheme is missing in "%s".', implode('', $url)));
458 }
459
460 if (null !== $url['scheme']) {
461 $url['path'] = self::removeDotSegments($url['path'] ?? '');
462 } else {
463 if (null !== $url['authority']) {
464 $url['path'] = self::removeDotSegments($url['path'] ?? '');
465 } else {
466 if (null === $url['path']) {
467 $url['path'] = $base['path'];
468 $url['query'] = $url['query'] ?? $base['query'];
469 } else {
470 if ('/' !== $url['path'][0]) {
471 if (null === $base['path']) {
472 $url['path'] = '/'.$url['path'];
473 } else {
474 $segments = explode('/', $base['path']);
475 array_splice($segments, -1, 1, [$url['path']]);
476 $url['path'] = implode('/', $segments);
477 }
478 }
479
480 $url['path'] = self::removeDotSegments($url['path']);
481 }
482
483 $url['authority'] = $base['authority'];
484
485 if ($queryDefaults) {
486 $url['query'] = '?'.self::mergeQueryString(substr($url['query'] ?? '', 1), $queryDefaults, false);
487 }
488 }
489
490 $url['scheme'] = $base['scheme'];
491 }
492
493 if ('' === ($url['path'] ?? '')) {
494 $url['path'] = '/';
495 }
496
497 if ('?' === ($url['query'] ?? '')) {
498 $url['query'] = null;
499 }
500
501 return $url;
502 }
503
504 /**
505 * Parses a URL and fixes its encoding if needed.
506 *
507 * @throws InvalidArgumentException When an invalid URL is passed
508 */
509 private static function parseUrl(string $url, array $query = [], array $allowedSchemes = ['http' => 80, 'https' => 443]): array
510 {
511 if (false === $parts = parse_url($url)) {
512 throw new InvalidArgumentException(sprintf('Malformed URL "%s".', $url));
513 }
514
515 if ($query) {
516 $parts['query'] = self::mergeQueryString($parts['query'] ?? null, $query, true);
517 }
518
519 $port = $parts['port'] ?? 0;
520
521 if (null !== $scheme = $parts['scheme'] ?? null) {
522 if (!isset($allowedSchemes[$scheme = strtolower($scheme)])) {
523 throw new InvalidArgumentException(sprintf('Unsupported scheme in "%s".', $url));
524 }
525
526 $port = $allowedSchemes[$scheme] === $port ? 0 : $port;
527 $scheme .= ':';
528 }
529
530 if (null !== $host = $parts['host'] ?? null) {
531 if (!\defined('INTL_IDNA_VARIANT_UTS46') && preg_match('/[\x80-\xFF]/', $host)) {
532 throw new InvalidArgumentException(sprintf('Unsupported IDN "%s", try enabling the "intl" PHP extension or running "composer require symfony/polyfill-intl-idn".', $host));
533 }
534
535 $host = \defined('INTL_IDNA_VARIANT_UTS46') ? idn_to_ascii($host, \IDNA_DEFAULT | \IDNA_USE_STD3_RULES | \IDNA_CHECK_BIDI | \IDNA_CHECK_CONTEXTJ | \IDNA_NONTRANSITIONAL_TO_ASCII, \INTL_IDNA_VARIANT_UTS46) ?: strtolower($host) : strtolower($host);
536 $host .= $port ? ':'.$port : '';
537 }
538
539 foreach (['user', 'pass', 'path', 'query', 'fragment'] as $part) {
540 if (!isset($parts[$part])) {
541 continue;
542 }
543
544 if (str_contains($parts[$part], '%')) {
545 // https://tools.ietf.org/html/rfc3986#section-2.3
546 $parts[$part] = preg_replace_callback('/%(?:2[DE]|3[0-9]|[46][1-9A-F]|5F|[57][0-9A]|7E)++/i', function ($m) { return rawurldecode($m[0]); }, $parts[$part]);
547 }
548
549 // https://tools.ietf.org/html/rfc3986#section-3.3
550 $parts[$part] = preg_replace_callback("#[^-A-Za-z0-9._~!$&/'()[\]*+,;=:@\\\\^`{|}%]++#", function ($m) { return rawurlencode($m[0]); }, $parts[$part]);
551 }
552
553 return [
554 'scheme' => $scheme,
555 'authority' => null !== $host ? '//'.(isset($parts['user']) ? $parts['user'].(isset($parts['pass']) ? ':'.$parts['pass'] : '').'@' : '').$host : null,
556 'path' => isset($parts['path'][0]) ? $parts['path'] : null,
557 'query' => isset($parts['query']) ? '?'.$parts['query'] : null,
558 'fragment' => isset($parts['fragment']) ? '#'.$parts['fragment'] : null,
559 ];
560 }
561
562 /**
563 * Removes dot-segments from a path.
564 *
565 * @see https://tools.ietf.org/html/rfc3986#section-5.2.4
566 */
567 private static function removeDotSegments(string $path)
568 {
569 $result = '';
570
571 while (!\in_array($path, ['', '.', '..'], true)) {
572 if ('.' === $path[0] && (str_starts_with($path, $p = '../') || str_starts_with($path, $p = './'))) {
573 $path = substr($path, \strlen($p));
574 } elseif ('/.' === $path || str_starts_with($path, '/./')) {
575 $path = substr_replace($path, '/', 0, 3);
576 } elseif ('/..' === $path || str_starts_with($path, '/../')) {
577 $i = strrpos($result, '/');
578 $result = $i ? substr($result, 0, $i) : '';
579 $path = substr_replace($path, '/', 0, 4);
580 } else {
581 $i = strpos($path, '/', 1) ?: \strlen($path);
582 $result .= substr($path, 0, $i);
583 $path = substr($path, $i);
584 }
585 }
586
587 return $result;
588 }
589
590 /**
591 * Merges and encodes a query array with a query string.
592 *
593 * @throws InvalidArgumentException When an invalid query-string value is passed
594 */
595 private static function mergeQueryString(?string $queryString, array $queryArray, bool $replace): ?string
596 {
597 if (!$queryArray) {
598 return $queryString;
599 }
600
601 $query = [];
602
603 if (null !== $queryString) {
604 foreach (explode('&', $queryString) as $v) {
605 if ('' !== $v) {
606 $k = urldecode(explode('=', $v, 2)[0]);
607 $query[$k] = (isset($query[$k]) ? $query[$k].'&' : '').$v;
608 }
609 }
610 }
611
612 if ($replace) {
613 foreach ($queryArray as $k => $v) {
614 if (null === $v) {
615 unset($query[$k]);
616 }
617 }
618 }
619
620 $queryString = http_build_query($queryArray, '', '&', \PHP_QUERY_RFC3986);
621 $queryArray = [];
622
623 if ($queryString) {
624 if (str_contains($queryString, '%')) {
625 // https://tools.ietf.org/html/rfc3986#section-2.3 + some chars not encoded by browsers
626 $queryString = strtr($queryString, [
627 '%21' => '!',
628 '%24' => '$',
629 '%28' => '(',
630 '%29' => ')',
631 '%2A' => '*',
632 '%2F' => '/',
633 '%3A' => ':',
634 '%3B' => ';',
635 '%40' => '@',
636 '%5B' => '[',
637 '%5C' => '\\',
638 '%5D' => ']',
639 '%5E' => '^',
640 '%60' => '`',
641 '%7C' => '|',
642 ]);
643 }
644
645 foreach (explode('&', $queryString) as $v) {
646 $queryArray[rawurldecode(explode('=', $v, 2)[0])] = $v;
647 }
648 }
649
650 return implode('&', $replace ? array_replace($query, $queryArray) : ($query + $queryArray));
651 }
652
653 /**
654 * Loads proxy configuration from the same environment variables as curl when no proxy is explicitly set.
655 */
656 private static function getProxy(?string $proxy, array $url, ?string $noProxy): ?array
657 {
658 if (null === $proxy = self::getProxyUrl($proxy, $url)) {
659 return null;
660 }
661
662 $proxy = (parse_url($proxy) ?: []) + ['scheme' => 'http'];
663
664 if (!isset($proxy['host'])) {
665 throw new TransportException('Invalid HTTP proxy: host is missing.');
666 }
667
668 if ('http' === $proxy['scheme']) {
669 $proxyUrl = 'tcp://'.$proxy['host'].':'.($proxy['port'] ?? '80');
670 } elseif ('https' === $proxy['scheme']) {
671 $proxyUrl = 'ssl://'.$proxy['host'].':'.($proxy['port'] ?? '443');
672 } else {
673 throw new TransportException(sprintf('Unsupported proxy scheme "%s": "http" or "https" expected.', $proxy['scheme']));
674 }
675
676 $noProxy = $noProxy ?? $_SERVER['no_proxy'] ?? $_SERVER['NO_PROXY'] ?? '';
677 $noProxy = $noProxy ? preg_split('/[\s,]+/', $noProxy) : [];
678
679 return [
680 'url' => $proxyUrl,
681 'auth' => isset($proxy['user']) ? 'Basic '.base64_encode(rawurldecode($proxy['user']).':'.rawurldecode($proxy['pass'] ?? '')) : null,
682 'no_proxy' => $noProxy,
683 ];
684 }
685
686 private static function getProxyUrl(?string $proxy, array $url): ?string
687 {
688 if (null !== $proxy) {
689 return $proxy;
690 }
691
692 // Ignore HTTP_PROXY except on the CLI to work around httpoxy set of vulnerabilities
693 $proxy = $_SERVER['http_proxy'] ?? (\in_array(\PHP_SAPI, ['cli', 'phpdbg'], true) ? $_SERVER['HTTP_PROXY'] ?? null : null) ?? $_SERVER['all_proxy'] ?? $_SERVER['ALL_PROXY'] ?? null;
694
695 if ('https:' === $url['scheme']) {
696 $proxy = $_SERVER['https_proxy'] ?? $_SERVER['HTTPS_PROXY'] ?? $proxy;
697 }
698
699 return $proxy;
700 }
701
702 private static function shouldBuffer(array $headers): bool
703 {
704 if (null === $contentType = $headers['content-type'][0] ?? null) {
705 return false;
706 }
707
708 if (false !== $i = strpos($contentType, ';')) {
709 $contentType = substr($contentType, 0, $i);
710 }
711
712 return $contentType && preg_match('#^(?:text/|application/(?:.+\+)?(?:json|xml)$)#i', $contentType);
713 }
714 }
715