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