HttpClientTrait.php 34 KB

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