1 <?php 2 3 namespace GuzzleHttp\Handler; 4 5 use GuzzleHttp\Exception\ConnectException; 6 use GuzzleHttp\Exception\RequestException; 7 use GuzzleHttp\Promise as P; 8 use GuzzleHttp\Promise\FulfilledPromise; 9 use GuzzleHttp\Promise\PromiseInterface; 10 use GuzzleHttp\Psr7; 11 use GuzzleHttp\TransferStats; 12 use GuzzleHttp\Utils; 13 use Psr\Http\Message\RequestInterface; 14 use Psr\Http\Message\ResponseInterface; 15 use Psr\Http\Message\StreamInterface; 16 use Psr\Http\Message\UriInterface; 17 18 /** 19 * HTTP handler that uses PHP's HTTP stream wrapper. 20 * 21 * @final 22 */ 23 class 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 return true; 238 }); 239 240 try { 241 $resource = $callback(); 242 } finally { 243 \restore_error_handler(); 244 } 245 246 if (!$resource) { 247 $message = 'Error creating resource: '; 248 foreach ($errors as $err) { 249 foreach ($err as $key => $value) { 250 $message .= "[$key] $value" . \PHP_EOL; 251 } 252 } 253 throw new \RuntimeException(\trim($message)); 254 } 255 256 return $resource; 257 } 258 259 /** 260 * @return resource 261 */ 262 private function createStream(RequestInterface $request, array $options) 263 { 264 static $methods; 265 if (!$methods) { 266 $methods = \array_flip(\get_class_methods(__CLASS__)); 267 } 268 269 if (!\in_array($request->getUri()->getScheme(), ['http', 'https'])) { 270 throw new RequestException(\sprintf("The scheme '%s' is not supported.", $request->getUri()->getScheme()), $request); 271 } 272 273 // HTTP/1.1 streams using the PHP stream wrapper require a 274 // Connection: close header 275 if ($request->getProtocolVersion() == '1.1' 276 && !$request->hasHeader('Connection') 277 ) { 278 $request = $request->withHeader('Connection', 'close'); 279 } 280 281 // Ensure SSL is verified by default 282 if (!isset($options['verify'])) { 283 $options['verify'] = true; 284 } 285 286 $params = []; 287 $context = $this->getDefaultContext($request); 288 289 if (isset($options['on_headers']) && !\is_callable($options['on_headers'])) { 290 throw new \InvalidArgumentException('on_headers must be callable'); 291 } 292 293 if (!empty($options)) { 294 foreach ($options as $key => $value) { 295 $method = "add_{$key}"; 296 if (isset($methods[$method])) { 297 $this->{$method}($request, $context, $value, $params); 298 } 299 } 300 } 301 302 if (isset($options['stream_context'])) { 303 if (!\is_array($options['stream_context'])) { 304 throw new \InvalidArgumentException('stream_context must be an array'); 305 } 306 $context = \array_replace_recursive($context, $options['stream_context']); 307 } 308 309 // Microsoft NTLM authentication only supported with curl handler 310 if (isset($options['auth'][2]) && 'ntlm' === $options['auth'][2]) { 311 throw new \InvalidArgumentException('Microsoft NTLM authentication only supported with curl handler'); 312 } 313 314 $uri = $this->resolveHost($request, $options); 315 316 $contextResource = $this->createResource( 317 static function () use ($context, $params) { 318 return \stream_context_create($context, $params); 319 } 320 ); 321 322 return $this->createResource( 323 function () use ($uri, &$http_response_header, $contextResource, $context, $options, $request) { 324 $resource = @\fopen((string) $uri, 'r', false, $contextResource); 325 $this->lastHeaders = $http_response_header ?? []; 326 327 if (false === $resource) { 328 throw new ConnectException(sprintf('Connection refused for URI %s', $uri), $request, null, $context); 329 } 330 331 if (isset($options['read_timeout'])) { 332 $readTimeout = $options['read_timeout']; 333 $sec = (int) $readTimeout; 334 $usec = ($readTimeout - $sec) * 100000; 335 \stream_set_timeout($resource, $sec, $usec); 336 } 337 338 return $resource; 339 } 340 ); 341 } 342 343 private function resolveHost(RequestInterface $request, array $options): UriInterface 344 { 345 $uri = $request->getUri(); 346 347 if (isset($options['force_ip_resolve']) && !\filter_var($uri->getHost(), \FILTER_VALIDATE_IP)) { 348 if ('v4' === $options['force_ip_resolve']) { 349 $records = \dns_get_record($uri->getHost(), \DNS_A); 350 if (false === $records || !isset($records[0]['ip'])) { 351 throw new ConnectException(\sprintf("Could not resolve IPv4 address for host '%s'", $uri->getHost()), $request); 352 } 353 return $uri->withHost($records[0]['ip']); 354 } 355 if ('v6' === $options['force_ip_resolve']) { 356 $records = \dns_get_record($uri->getHost(), \DNS_AAAA); 357 if (false === $records || !isset($records[0]['ipv6'])) { 358 throw new ConnectException(\sprintf("Could not resolve IPv6 address for host '%s'", $uri->getHost()), $request); 359 } 360 return $uri->withHost('[' . $records[0]['ipv6'] . ']'); 361 } 362 } 363 364 return $uri; 365 } 366 367 private function getDefaultContext(RequestInterface $request): array 368 { 369 $headers = ''; 370 foreach ($request->getHeaders() as $name => $value) { 371 foreach ($value as $val) { 372 $headers .= "$name: $val\r\n"; 373 } 374 } 375 376 $context = [ 377 'http' => [ 378 'method' => $request->getMethod(), 379 'header' => $headers, 380 'protocol_version' => $request->getProtocolVersion(), 381 'ignore_errors' => true, 382 'follow_location' => 0, 383 ], 384 'ssl' => [ 385 'peer_name' => $request->getUri()->getHost(), 386 ], 387 ]; 388 389 $body = (string) $request->getBody(); 390 391 if (!empty($body)) { 392 $context['http']['content'] = $body; 393 // Prevent the HTTP handler from adding a Content-Type header. 394 if (!$request->hasHeader('Content-Type')) { 395 $context['http']['header'] .= "Content-Type:\r\n"; 396 } 397 } 398 399 $context['http']['header'] = \rtrim($context['http']['header']); 400 401 return $context; 402 } 403 404 /** 405 * @param mixed $value as passed via Request transfer options. 406 */ 407 private function add_proxy(RequestInterface $request, array &$options, $value, array &$params): void 408 { 409 $uri = null; 410 411 if (!\is_array($value)) { 412 $uri = $value; 413 } else { 414 $scheme = $request->getUri()->getScheme(); 415 if (isset($value[$scheme])) { 416 if (!isset($value['no']) || !Utils::isHostInNoProxy($request->getUri()->getHost(), $value['no'])) { 417 $uri = $value[$scheme]; 418 } 419 } 420 } 421 422 if (!$uri) { 423 return; 424 } 425 426 $parsed = $this->parse_proxy($uri); 427 $options['http']['proxy'] = $parsed['proxy']; 428 429 if ($parsed['auth']) { 430 if (!isset($options['http']['header'])) { 431 $options['http']['header'] = []; 432 } 433 $options['http']['header'] .= "\r\nProxy-Authorization: {$parsed['auth']}"; 434 } 435 } 436 437 /** 438 * Parses the given proxy URL to make it compatible with the format PHP's stream context expects. 439 */ 440 private function parse_proxy(string $url): array 441 { 442 $parsed = \parse_url($url); 443 444 if ($parsed !== false && isset($parsed['scheme']) && $parsed['scheme'] === 'http') { 445 if (isset($parsed['host']) && isset($parsed['port'])) { 446 $auth = null; 447 if (isset($parsed['user']) && isset($parsed['pass'])) { 448 $auth = \base64_encode("{$parsed['user']}:{$parsed['pass']}"); 449 } 450 451 return [ 452 'proxy' => "tcp://{$parsed['host']}:{$parsed['port']}", 453 'auth' => $auth ? "Basic {$auth}" : null, 454 ]; 455 } 456 } 457 458 // Return proxy as-is. 459 return [ 460 'proxy' => $url, 461 'auth' => null, 462 ]; 463 } 464 465 /** 466 * @param mixed $value as passed via Request transfer options. 467 */ 468 private function add_timeout(RequestInterface $request, array &$options, $value, array &$params): void 469 { 470 if ($value > 0) { 471 $options['http']['timeout'] = $value; 472 } 473 } 474 475 /** 476 * @param mixed $value as passed via Request transfer options. 477 */ 478 private function add_verify(RequestInterface $request, array &$options, $value, array &$params): void 479 { 480 if ($value === false) { 481 $options['ssl']['verify_peer'] = false; 482 $options['ssl']['verify_peer_name'] = false; 483 484 return; 485 } 486 487 if (\is_string($value)) { 488 $options['ssl']['cafile'] = $value; 489 if (!\file_exists($value)) { 490 throw new \RuntimeException("SSL CA bundle not found: $value"); 491 } 492 } elseif ($value !== true) { 493 throw new \InvalidArgumentException('Invalid verify request option'); 494 } 495 496 $options['ssl']['verify_peer'] = true; 497 $options['ssl']['verify_peer_name'] = true; 498 $options['ssl']['allow_self_signed'] = false; 499 } 500 501 /** 502 * @param mixed $value as passed via Request transfer options. 503 */ 504 private function add_cert(RequestInterface $request, array &$options, $value, array &$params): void 505 { 506 if (\is_array($value)) { 507 $options['ssl']['passphrase'] = $value[1]; 508 $value = $value[0]; 509 } 510 511 if (!\file_exists($value)) { 512 throw new \RuntimeException("SSL certificate not found: {$value}"); 513 } 514 515 $options['ssl']['local_cert'] = $value; 516 } 517 518 /** 519 * @param mixed $value as passed via Request transfer options. 520 */ 521 private function add_progress(RequestInterface $request, array &$options, $value, array &$params): void 522 { 523 self::addNotification( 524 $params, 525 static function ($code, $a, $b, $c, $transferred, $total) use ($value) { 526 if ($code == \STREAM_NOTIFY_PROGRESS) { 527 // The upload progress cannot be determined. Use 0 for cURL compatibility: 528 // https://curl.se/libcurl/c/CURLOPT_PROGRESSFUNCTION.html 529 $value($total, $transferred, 0, 0); 530 } 531 } 532 ); 533 } 534 535 /** 536 * @param mixed $value as passed via Request transfer options. 537 */ 538 private function add_debug(RequestInterface $request, array &$options, $value, array &$params): void 539 { 540 if ($value === false) { 541 return; 542 } 543 544 static $map = [ 545 \STREAM_NOTIFY_CONNECT => 'CONNECT', 546 \STREAM_NOTIFY_AUTH_REQUIRED => 'AUTH_REQUIRED', 547 \STREAM_NOTIFY_AUTH_RESULT => 'AUTH_RESULT', 548 \STREAM_NOTIFY_MIME_TYPE_IS => 'MIME_TYPE_IS', 549 \STREAM_NOTIFY_FILE_SIZE_IS => 'FILE_SIZE_IS', 550 \STREAM_NOTIFY_REDIRECTED => 'REDIRECTED', 551 \STREAM_NOTIFY_PROGRESS => 'PROGRESS', 552 \STREAM_NOTIFY_FAILURE => 'FAILURE', 553 \STREAM_NOTIFY_COMPLETED => 'COMPLETED', 554 \STREAM_NOTIFY_RESOLVE => 'RESOLVE', 555 ]; 556 static $args = ['severity', 'message', 'message_code', 'bytes_transferred', 'bytes_max']; 557 558 $value = Utils::debugResource($value); 559 $ident = $request->getMethod() . ' ' . $request->getUri()->withFragment(''); 560 self::addNotification( 561 $params, 562 static function (int $code, ...$passed) use ($ident, $value, $map, $args): void { 563 \fprintf($value, '<%s> [%s] ', $ident, $map[$code]); 564 foreach (\array_filter($passed) as $i => $v) { 565 \fwrite($value, $args[$i] . ': "' . $v . '" '); 566 } 567 \fwrite($value, "\n"); 568 } 569 ); 570 } 571 572 private static function addNotification(array &$params, callable $notify): void 573 { 574 // Wrap the existing function if needed. 575 if (!isset($params['notification'])) { 576 $params['notification'] = $notify; 577 } else { 578 $params['notification'] = self::callArray([ 579 $params['notification'], 580 $notify 581 ]); 582 } 583 } 584 585 private static function callArray(array $functions): callable 586 { 587 return static function (...$args) use ($functions) { 588 foreach ($functions as $fn) { 589 $fn(...$args); 590 } 591 }; 592 } 593 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body