StreamHandler.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. <?php
  2. namespace GuzzleHttp\Handler;
  3. use GuzzleHttp\Exception\ConnectException;
  4. use GuzzleHttp\Exception\RequestException;
  5. use GuzzleHttp\Promise\FulfilledPromise;
  6. use GuzzleHttp\Promise\PromiseInterface;
  7. use GuzzleHttp\Psr7;
  8. use GuzzleHttp\TransferStats;
  9. use GuzzleHttp\Utils;
  10. use Psr\Http\Message\RequestInterface;
  11. use Psr\Http\Message\ResponseInterface;
  12. use Psr\Http\Message\StreamInterface;
  13. use Psr\Http\Message\UriInterface;
  14. /**
  15. * HTTP handler that uses PHP's HTTP stream wrapper.
  16. *
  17. * @final
  18. */
  19. class StreamHandler
  20. {
  21. /**
  22. * @var array
  23. */
  24. private $lastHeaders = [];
  25. /**
  26. * Sends an HTTP request.
  27. *
  28. * @param RequestInterface $request Request to send.
  29. * @param array $options Request transfer options.
  30. */
  31. public function __invoke(RequestInterface $request, array $options): PromiseInterface
  32. {
  33. // Sleep if there is a delay specified.
  34. if (isset($options['delay'])) {
  35. \usleep($options['delay'] * 1000);
  36. }
  37. $startTime = isset($options['on_stats']) ? Utils::currentTime() : null;
  38. try {
  39. // Does not support the expect header.
  40. $request = $request->withoutHeader('Expect');
  41. // Append a content-length header if body size is zero to match
  42. // cURL's behavior.
  43. if (0 === $request->getBody()->getSize()) {
  44. $request = $request->withHeader('Content-Length', '0');
  45. }
  46. return $this->createResponse(
  47. $request,
  48. $options,
  49. $this->createStream($request, $options),
  50. $startTime
  51. );
  52. } catch (\InvalidArgumentException $e) {
  53. throw $e;
  54. } catch (\Exception $e) {
  55. // Determine if the error was a networking error.
  56. $message = $e->getMessage();
  57. // This list can probably get more comprehensive.
  58. if (false !== \strpos($message, 'getaddrinfo') // DNS lookup failed
  59. || false !== \strpos($message, 'Connection refused')
  60. || false !== \strpos($message, "couldn't connect to host") // error on HHVM
  61. || false !== \strpos($message, "connection attempt failed")
  62. ) {
  63. $e = new ConnectException($e->getMessage(), $request, $e);
  64. } else {
  65. $e = RequestException::wrapException($request, $e);
  66. }
  67. $this->invokeStats($options, $request, $startTime, null, $e);
  68. return \GuzzleHttp\Promise\rejection_for($e);
  69. }
  70. }
  71. private function invokeStats(
  72. array $options,
  73. RequestInterface $request,
  74. ?float $startTime,
  75. ResponseInterface $response = null,
  76. \Throwable $error = null
  77. ): void {
  78. if (isset($options['on_stats'])) {
  79. $stats = new TransferStats(
  80. $request,
  81. $response,
  82. Utils::currentTime() - $startTime,
  83. $error,
  84. []
  85. );
  86. ($options['on_stats'])($stats);
  87. }
  88. }
  89. /**
  90. * @param resource $stream
  91. */
  92. private function createResponse(
  93. RequestInterface $request,
  94. array $options,
  95. $stream,
  96. ?float $startTime
  97. ): PromiseInterface {
  98. $hdrs = $this->lastHeaders;
  99. $this->lastHeaders = [];
  100. $parts = \explode(' ', \array_shift($hdrs), 3);
  101. $ver = \explode('/', $parts[0])[1];
  102. $status = (int) $parts[1];
  103. $reason = $parts[2] ?? null;
  104. $headers = Utils::headersFromLines($hdrs);
  105. [$stream, $headers] = $this->checkDecode($options, $headers, $stream);
  106. $stream = Psr7\stream_for($stream);
  107. $sink = $stream;
  108. if (\strcasecmp('HEAD', $request->getMethod())) {
  109. $sink = $this->createSink($stream, $options);
  110. }
  111. $response = new Psr7\Response($status, $headers, $sink, $ver, $reason);
  112. if (isset($options['on_headers'])) {
  113. try {
  114. $options['on_headers']($response);
  115. } catch (\Exception $e) {
  116. $msg = 'An error was encountered during the on_headers event';
  117. $ex = new RequestException($msg, $request, $response, $e);
  118. return \GuzzleHttp\Promise\rejection_for($ex);
  119. }
  120. }
  121. // Do not drain when the request is a HEAD request because they have
  122. // no body.
  123. if ($sink !== $stream) {
  124. $this->drain(
  125. $stream,
  126. $sink,
  127. $response->getHeaderLine('Content-Length')
  128. );
  129. }
  130. $this->invokeStats($options, $request, $startTime, $response, null);
  131. return new FulfilledPromise($response);
  132. }
  133. private function createSink(StreamInterface $stream, array $options): StreamInterface
  134. {
  135. if (!empty($options['stream'])) {
  136. return $stream;
  137. }
  138. $sink = $options['sink']
  139. ?? \fopen('php://temp', 'r+');
  140. return \is_string($sink)
  141. ? new Psr7\LazyOpenStream($sink, 'w+')
  142. : Psr7\stream_for($sink);
  143. }
  144. /**
  145. * @param resource $stream
  146. */
  147. private function checkDecode(array $options, array $headers, $stream): array
  148. {
  149. // Automatically decode responses when instructed.
  150. if (!empty($options['decode_content'])) {
  151. $normalizedKeys = Utils::normalizeHeaderKeys($headers);
  152. if (isset($normalizedKeys['content-encoding'])) {
  153. $encoding = $headers[$normalizedKeys['content-encoding']];
  154. if ($encoding[0] === 'gzip' || $encoding[0] === 'deflate') {
  155. $stream = new Psr7\InflateStream(
  156. Psr7\stream_for($stream)
  157. );
  158. $headers['x-encoded-content-encoding']
  159. = $headers[$normalizedKeys['content-encoding']];
  160. // Remove content-encoding header
  161. unset($headers[$normalizedKeys['content-encoding']]);
  162. // Fix content-length header
  163. if (isset($normalizedKeys['content-length'])) {
  164. $headers['x-encoded-content-length']
  165. = $headers[$normalizedKeys['content-length']];
  166. $length = (int) $stream->getSize();
  167. if ($length === 0) {
  168. unset($headers[$normalizedKeys['content-length']]);
  169. } else {
  170. $headers[$normalizedKeys['content-length']] = [$length];
  171. }
  172. }
  173. }
  174. }
  175. }
  176. return [$stream, $headers];
  177. }
  178. /**
  179. * Drains the source stream into the "sink" client option.
  180. *
  181. * @param string $contentLength Header specifying the amount of
  182. * data to read.
  183. *
  184. * @throws \RuntimeException when the sink option is invalid.
  185. */
  186. private function drain(
  187. StreamInterface $source,
  188. StreamInterface $sink,
  189. string $contentLength
  190. ): StreamInterface {
  191. // If a content-length header is provided, then stop reading once
  192. // that number of bytes has been read. This can prevent infinitely
  193. // reading from a stream when dealing with servers that do not honor
  194. // Connection: Close headers.
  195. Psr7\copy_to_stream(
  196. $source,
  197. $sink,
  198. (\strlen($contentLength) > 0 && (int) $contentLength > 0) ? (int) $contentLength : -1
  199. );
  200. $sink->seek(0);
  201. $source->close();
  202. return $sink;
  203. }
  204. /**
  205. * Create a resource and check to ensure it was created successfully
  206. *
  207. * @param callable $callback Callable that returns stream resource
  208. *
  209. * @return resource
  210. *
  211. * @throws \RuntimeException on error
  212. */
  213. private function createResource(callable $callback)
  214. {
  215. $errors = [];
  216. \set_error_handler(static function ($_, $msg, $file, $line) use (&$errors): bool {
  217. $errors[] = [
  218. 'message' => $msg,
  219. 'file' => $file,
  220. 'line' => $line
  221. ];
  222. return true;
  223. });
  224. $resource = $callback();
  225. \restore_error_handler();
  226. if (!$resource) {
  227. $message = 'Error creating resource: ';
  228. foreach ($errors as $err) {
  229. foreach ($err as $key => $value) {
  230. $message .= "[$key] $value" . \PHP_EOL;
  231. }
  232. }
  233. throw new \RuntimeException(\trim($message));
  234. }
  235. return $resource;
  236. }
  237. /**
  238. * @return resource
  239. */
  240. private function createStream(RequestInterface $request, array $options)
  241. {
  242. static $methods;
  243. if (!$methods) {
  244. $methods = \array_flip(\get_class_methods(__CLASS__));
  245. }
  246. // HTTP/1.1 streams using the PHP stream wrapper require a
  247. // Connection: close header
  248. if ($request->getProtocolVersion() == '1.1'
  249. && !$request->hasHeader('Connection')
  250. ) {
  251. $request = $request->withHeader('Connection', 'close');
  252. }
  253. // Ensure SSL is verified by default
  254. if (!isset($options['verify'])) {
  255. $options['verify'] = true;
  256. }
  257. $params = [];
  258. $context = $this->getDefaultContext($request);
  259. if (isset($options['on_headers']) && !\is_callable($options['on_headers'])) {
  260. throw new \InvalidArgumentException('on_headers must be callable');
  261. }
  262. if (!empty($options)) {
  263. foreach ($options as $key => $value) {
  264. $method = "add_{$key}";
  265. if (isset($methods[$method])) {
  266. $this->{$method}($request, $context, $value, $params);
  267. }
  268. }
  269. }
  270. if (isset($options['stream_context'])) {
  271. if (!\is_array($options['stream_context'])) {
  272. throw new \InvalidArgumentException('stream_context must be an array');
  273. }
  274. $context = \array_replace_recursive(
  275. $context,
  276. $options['stream_context']
  277. );
  278. }
  279. // Microsoft NTLM authentication only supported with curl handler
  280. if (isset($options['auth'][2]) && 'ntlm' === $options['auth'][2]) {
  281. throw new \InvalidArgumentException('Microsoft NTLM authentication only supported with curl handler');
  282. }
  283. $uri = $this->resolveHost($request, $options);
  284. $contextResource = $this->createResource(
  285. static function () use ($context, $params) {
  286. return \stream_context_create($context, $params);
  287. }
  288. );
  289. return $this->createResource(
  290. function () use ($uri, &$http_response_header, $contextResource, $context, $options, $request) {
  291. $resource = \fopen((string) $uri, 'r', false, $contextResource);
  292. $this->lastHeaders = $http_response_header;
  293. if (false === $resource) {
  294. throw new ConnectException(
  295. sprintf('Connection refused for URI %s', $uri),
  296. $request,
  297. null,
  298. $context
  299. );
  300. }
  301. if (isset($options['read_timeout'])) {
  302. $readTimeout = $options['read_timeout'];
  303. $sec = (int) $readTimeout;
  304. $usec = ($readTimeout - $sec) * 100000;
  305. \stream_set_timeout($resource, $sec, $usec);
  306. }
  307. return $resource;
  308. }
  309. );
  310. }
  311. private function resolveHost(RequestInterface $request, array $options): UriInterface
  312. {
  313. $uri = $request->getUri();
  314. if (isset($options['force_ip_resolve']) && !\filter_var($uri->getHost(), \FILTER_VALIDATE_IP)) {
  315. if ('v4' === $options['force_ip_resolve']) {
  316. $records = \dns_get_record($uri->getHost(), \DNS_A);
  317. if (false === $records || !isset($records[0]['ip'])) {
  318. throw new ConnectException(
  319. \sprintf(
  320. "Could not resolve IPv4 address for host '%s'",
  321. $uri->getHost()
  322. ),
  323. $request
  324. );
  325. }
  326. return $uri->withHost($records[0]['ip']);
  327. }
  328. if ('v6' === $options['force_ip_resolve']) {
  329. $records = \dns_get_record($uri->getHost(), \DNS_AAAA);
  330. if (false === $records || !isset($records[0]['ipv6'])) {
  331. throw new ConnectException(
  332. \sprintf(
  333. "Could not resolve IPv6 address for host '%s'",
  334. $uri->getHost()
  335. ),
  336. $request
  337. );
  338. }
  339. return $uri->withHost('[' . $records[0]['ipv6'] . ']');
  340. }
  341. }
  342. return $uri;
  343. }
  344. private function getDefaultContext(RequestInterface $request): array
  345. {
  346. $headers = '';
  347. foreach ($request->getHeaders() as $name => $value) {
  348. foreach ($value as $val) {
  349. $headers .= "$name: $val\r\n";
  350. }
  351. }
  352. $context = [
  353. 'http' => [
  354. 'method' => $request->getMethod(),
  355. 'header' => $headers,
  356. 'protocol_version' => $request->getProtocolVersion(),
  357. 'ignore_errors' => true,
  358. 'follow_location' => 0,
  359. ],
  360. ];
  361. $body = (string) $request->getBody();
  362. if (!empty($body)) {
  363. $context['http']['content'] = $body;
  364. // Prevent the HTTP handler from adding a Content-Type header.
  365. if (!$request->hasHeader('Content-Type')) {
  366. $context['http']['header'] .= "Content-Type:\r\n";
  367. }
  368. }
  369. $context['http']['header'] = \rtrim($context['http']['header']);
  370. return $context;
  371. }
  372. /**
  373. * @param mixed $value as passed via Request transfer options.
  374. */
  375. private function add_proxy(RequestInterface $request, array &$options, $value, array &$params): void
  376. {
  377. if (!\is_array($value)) {
  378. $options['http']['proxy'] = $value;
  379. } else {
  380. $scheme = $request->getUri()->getScheme();
  381. if (isset($value[$scheme])) {
  382. if (!isset($value['no'])
  383. || !Utils::isHostInNoProxy(
  384. $request->getUri()->getHost(),
  385. $value['no']
  386. )
  387. ) {
  388. $options['http']['proxy'] = $value[$scheme];
  389. }
  390. }
  391. }
  392. }
  393. /**
  394. * @param mixed $value as passed via Request transfer options.
  395. */
  396. private function add_timeout(RequestInterface $request, array &$options, $value, array &$params): void
  397. {
  398. if ($value > 0) {
  399. $options['http']['timeout'] = $value;
  400. }
  401. }
  402. /**
  403. * @param mixed $value as passed via Request transfer options.
  404. */
  405. private function add_verify(RequestInterface $request, array &$options, $value, array &$params): void
  406. {
  407. if ($value === false) {
  408. $options['ssl']['verify_peer'] = false;
  409. $options['ssl']['verify_peer_name'] = false;
  410. return;
  411. }
  412. if (\is_string($value)) {
  413. $options['ssl']['cafile'] = $value;
  414. if (!\file_exists($value)) {
  415. throw new \RuntimeException("SSL CA bundle not found: $value");
  416. }
  417. } elseif ($value !== true) {
  418. throw new \InvalidArgumentException('Invalid verify request option');
  419. }
  420. $options['ssl']['verify_peer'] = true;
  421. $options['ssl']['verify_peer_name'] = true;
  422. $options['ssl']['allow_self_signed'] = false;
  423. }
  424. /**
  425. * @param mixed $value as passed via Request transfer options.
  426. */
  427. private function add_cert(RequestInterface $request, array &$options, $value, array &$params): void
  428. {
  429. if (\is_array($value)) {
  430. $options['ssl']['passphrase'] = $value[1];
  431. $value = $value[0];
  432. }
  433. if (!\file_exists($value)) {
  434. throw new \RuntimeException("SSL certificate not found: {$value}");
  435. }
  436. $options['ssl']['local_cert'] = $value;
  437. }
  438. /**
  439. * @param mixed $value as passed via Request transfer options.
  440. */
  441. private function add_progress(RequestInterface $request, array &$options, $value, array &$params): void
  442. {
  443. self::addNotification(
  444. $params,
  445. static function ($code, $a, $b, $c, $transferred, $total) use ($value) {
  446. if ($code == \STREAM_NOTIFY_PROGRESS) {
  447. $value($total, $transferred, null, null);
  448. }
  449. }
  450. );
  451. }
  452. /**
  453. * @param mixed $value as passed via Request transfer options.
  454. */
  455. private function add_debug(RequestInterface $request, array &$options, $value, array &$params): void
  456. {
  457. if ($value === false) {
  458. return;
  459. }
  460. static $map = [
  461. \STREAM_NOTIFY_CONNECT => 'CONNECT',
  462. \STREAM_NOTIFY_AUTH_REQUIRED => 'AUTH_REQUIRED',
  463. \STREAM_NOTIFY_AUTH_RESULT => 'AUTH_RESULT',
  464. \STREAM_NOTIFY_MIME_TYPE_IS => 'MIME_TYPE_IS',
  465. \STREAM_NOTIFY_FILE_SIZE_IS => 'FILE_SIZE_IS',
  466. \STREAM_NOTIFY_REDIRECTED => 'REDIRECTED',
  467. \STREAM_NOTIFY_PROGRESS => 'PROGRESS',
  468. \STREAM_NOTIFY_FAILURE => 'FAILURE',
  469. \STREAM_NOTIFY_COMPLETED => 'COMPLETED',
  470. \STREAM_NOTIFY_RESOLVE => 'RESOLVE',
  471. ];
  472. static $args = ['severity', 'message', 'message_code',
  473. 'bytes_transferred', 'bytes_max'];
  474. $value = Utils::debugResource($value);
  475. $ident = $request->getMethod() . ' ' . $request->getUri()->withFragment('');
  476. self::addNotification(
  477. $params,
  478. static function (int $code, ...$passed) use ($ident, $value, $map, $args): void {
  479. \fprintf($value, '<%s> [%s] ', $ident, $map[$code]);
  480. foreach (\array_filter($passed) as $i => $v) {
  481. \fwrite($value, $args[$i] . ': "' . $v . '" ');
  482. }
  483. \fwrite($value, "\n");
  484. }
  485. );
  486. }
  487. private static function addNotification(array &$params, callable $notify): void
  488. {
  489. // Wrap the existing function if needed.
  490. if (!isset($params['notification'])) {
  491. $params['notification'] = $notify;
  492. } else {
  493. $params['notification'] = self::callArray([
  494. $params['notification'],
  495. $notify
  496. ]);
  497. }
  498. }
  499. private static function callArray(array $functions): callable
  500. {
  501. return static function (...$args) use ($functions) {
  502. foreach ($functions as $fn) {
  503. $fn(...$args);
  504. }
  505. };
  506. }
  507. }