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\LazyOpenStream; 11use GuzzleHttp\TransferStats; 12use GuzzleHttp\Utils; 13use Psr\Http\Message\RequestInterface; 14 15/** 16 * Creates curl resources from a request 17 * 18 * @final 19 */ 20class CurlFactory implements CurlFactoryInterface 21{ 22 public const CURL_VERSION_STR = 'curl_version'; 23 24 /** 25 * @deprecated 26 */ 27 public const LOW_CURL_VERSION_NUMBER = '7.21.2'; 28 29 /** 30 * @var resource[]|\CurlHandle[] 31 */ 32 private $handles = []; 33 34 /** 35 * @var int Total number of idle handles to keep in cache 36 */ 37 private $maxHandles; 38 39 /** 40 * @param int $maxHandles Maximum number of idle handles. 41 */ 42 public function __construct(int $maxHandles) 43 { 44 $this->maxHandles = $maxHandles; 45 } 46 47 public function create(RequestInterface $request, array $options): EasyHandle 48 { 49 if (isset($options['curl']['body_as_string'])) { 50 $options['_body_as_string'] = $options['curl']['body_as_string']; 51 unset($options['curl']['body_as_string']); 52 } 53 54 $easy = new EasyHandle(); 55 $easy->request = $request; 56 $easy->options = $options; 57 $conf = $this->getDefaultConf($easy); 58 $this->applyMethod($easy, $conf); 59 $this->applyHandlerOptions($easy, $conf); 60 $this->applyHeaders($easy, $conf); 61 unset($conf['_headers']); 62 63 // Add handler options from the request configuration options 64 if (isset($options['curl'])) { 65 $conf = \array_replace($conf, $options['curl']); 66 } 67 68 $conf[\CURLOPT_HEADERFUNCTION] = $this->createHeaderFn($easy); 69 $easy->handle = $this->handles ? \array_pop($this->handles) : \curl_init(); 70 curl_setopt_array($easy->handle, $conf); 71 72 return $easy; 73 } 74 75 public function release(EasyHandle $easy): void 76 { 77 $resource = $easy->handle; 78 unset($easy->handle); 79 80 if (\count($this->handles) >= $this->maxHandles) { 81 \curl_close($resource); 82 } else { 83 // Remove all callback functions as they can hold onto references 84 // and are not cleaned up by curl_reset. Using curl_setopt_array 85 // does not work for some reason, so removing each one 86 // individually. 87 \curl_setopt($resource, \CURLOPT_HEADERFUNCTION, null); 88 \curl_setopt($resource, \CURLOPT_READFUNCTION, null); 89 \curl_setopt($resource, \CURLOPT_WRITEFUNCTION, null); 90 \curl_setopt($resource, \CURLOPT_PROGRESSFUNCTION, null); 91 \curl_reset($resource); 92 $this->handles[] = $resource; 93 } 94 } 95 96 /** 97 * Completes a cURL transaction, either returning a response promise or a 98 * rejected promise. 99 * 100 * @param callable(RequestInterface, array): PromiseInterface $handler 101 * @param CurlFactoryInterface $factory Dictates how the handle is released 102 */ 103 public static function finish(callable $handler, EasyHandle $easy, CurlFactoryInterface $factory): PromiseInterface 104 { 105 if (isset($easy->options['on_stats'])) { 106 self::invokeStats($easy); 107 } 108 109 if (!$easy->response || $easy->errno) { 110 return self::finishError($handler, $easy, $factory); 111 } 112 113 // Return the response if it is present and there is no error. 114 $factory->release($easy); 115 116 // Rewind the body of the response if possible. 117 $body = $easy->response->getBody(); 118 if ($body->isSeekable()) { 119 $body->rewind(); 120 } 121 122 return new FulfilledPromise($easy->response); 123 } 124 125 private static function invokeStats(EasyHandle $easy): void 126 { 127 $curlStats = \curl_getinfo($easy->handle); 128 $curlStats['appconnect_time'] = \curl_getinfo($easy->handle, \CURLINFO_APPCONNECT_TIME); 129 $stats = new TransferStats( 130 $easy->request, 131 $easy->response, 132 $curlStats['total_time'], 133 $easy->errno, 134 $curlStats 135 ); 136 ($easy->options['on_stats'])($stats); 137 } 138 139 /** 140 * @param callable(RequestInterface, array): PromiseInterface $handler 141 */ 142 private static function finishError(callable $handler, EasyHandle $easy, CurlFactoryInterface $factory): PromiseInterface 143 { 144 // Get error information and release the handle to the factory. 145 $ctx = [ 146 'errno' => $easy->errno, 147 'error' => \curl_error($easy->handle), 148 'appconnect_time' => \curl_getinfo($easy->handle, \CURLINFO_APPCONNECT_TIME), 149 ] + \curl_getinfo($easy->handle); 150 $ctx[self::CURL_VERSION_STR] = \curl_version()['version']; 151 $factory->release($easy); 152 153 // Retry when nothing is present or when curl failed to rewind. 154 if (empty($easy->options['_err_message']) && (!$easy->errno || $easy->errno == 65)) { 155 return self::retryFailedRewind($handler, $easy, $ctx); 156 } 157 158 return self::createRejection($easy, $ctx); 159 } 160 161 private static function createRejection(EasyHandle $easy, array $ctx): PromiseInterface 162 { 163 static $connectionErrors = [ 164 \CURLE_OPERATION_TIMEOUTED => true, 165 \CURLE_COULDNT_RESOLVE_HOST => true, 166 \CURLE_COULDNT_CONNECT => true, 167 \CURLE_SSL_CONNECT_ERROR => true, 168 \CURLE_GOT_NOTHING => true, 169 ]; 170 171 if ($easy->createResponseException) { 172 return P\Create::rejectionFor( 173 new RequestException( 174 'An error was encountered while creating the response', 175 $easy->request, 176 $easy->response, 177 $easy->createResponseException, 178 $ctx 179 ) 180 ); 181 } 182 183 // If an exception was encountered during the onHeaders event, then 184 // return a rejected promise that wraps that exception. 185 if ($easy->onHeadersException) { 186 return P\Create::rejectionFor( 187 new RequestException( 188 'An error was encountered during the on_headers event', 189 $easy->request, 190 $easy->response, 191 $easy->onHeadersException, 192 $ctx 193 ) 194 ); 195 } 196 197 $message = \sprintf( 198 'cURL error %s: %s (%s)', 199 $ctx['errno'], 200 $ctx['error'], 201 'see https://curl.haxx.se/libcurl/c/libcurl-errors.html' 202 ); 203 $uriString = (string) $easy->request->getUri(); 204 if ($uriString !== '' && false === \strpos($ctx['error'], $uriString)) { 205 $message .= \sprintf(' for %s', $uriString); 206 } 207 208 // Create a connection exception if it was a specific error code. 209 $error = isset($connectionErrors[$easy->errno]) 210 ? new ConnectException($message, $easy->request, null, $ctx) 211 : new RequestException($message, $easy->request, $easy->response, null, $ctx); 212 213 return P\Create::rejectionFor($error); 214 } 215 216 /** 217 * @return array<int|string, mixed> 218 */ 219 private function getDefaultConf(EasyHandle $easy): array 220 { 221 $conf = [ 222 '_headers' => $easy->request->getHeaders(), 223 \CURLOPT_CUSTOMREQUEST => $easy->request->getMethod(), 224 \CURLOPT_URL => (string) $easy->request->getUri()->withFragment(''), 225 \CURLOPT_RETURNTRANSFER => false, 226 \CURLOPT_HEADER => false, 227 \CURLOPT_CONNECTTIMEOUT => 300, 228 ]; 229 230 if (\defined('CURLOPT_PROTOCOLS')) { 231 $conf[\CURLOPT_PROTOCOLS] = \CURLPROTO_HTTP | \CURLPROTO_HTTPS; 232 } 233 234 $version = $easy->request->getProtocolVersion(); 235 if ($version == 1.1) { 236 $conf[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_1_1; 237 } elseif ($version == 2.0) { 238 $conf[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_2_0; 239 } else { 240 $conf[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_1_0; 241 } 242 243 return $conf; 244 } 245 246 private function applyMethod(EasyHandle $easy, array &$conf): void 247 { 248 $body = $easy->request->getBody(); 249 $size = $body->getSize(); 250 251 if ($size === null || $size > 0) { 252 $this->applyBody($easy->request, $easy->options, $conf); 253 254 return; 255 } 256 257 $method = $easy->request->getMethod(); 258 if ($method === 'PUT' || $method === 'POST') { 259 // See https://datatracker.ietf.org/doc/html/rfc7230#section-3.3.2 260 if (!$easy->request->hasHeader('Content-Length')) { 261 $conf[\CURLOPT_HTTPHEADER][] = 'Content-Length: 0'; 262 } 263 } elseif ($method === 'HEAD') { 264 $conf[\CURLOPT_NOBODY] = true; 265 unset( 266 $conf[\CURLOPT_WRITEFUNCTION], 267 $conf[\CURLOPT_READFUNCTION], 268 $conf[\CURLOPT_FILE], 269 $conf[\CURLOPT_INFILE] 270 ); 271 } 272 } 273 274 private function applyBody(RequestInterface $request, array $options, array &$conf): void 275 { 276 $size = $request->hasHeader('Content-Length') 277 ? (int) $request->getHeaderLine('Content-Length') 278 : null; 279 280 // Send the body as a string if the size is less than 1MB OR if the 281 // [curl][body_as_string] request value is set. 282 if (($size !== null && $size < 1000000) || !empty($options['_body_as_string'])) { 283 $conf[\CURLOPT_POSTFIELDS] = (string) $request->getBody(); 284 // Don't duplicate the Content-Length header 285 $this->removeHeader('Content-Length', $conf); 286 $this->removeHeader('Transfer-Encoding', $conf); 287 } else { 288 $conf[\CURLOPT_UPLOAD] = true; 289 if ($size !== null) { 290 $conf[\CURLOPT_INFILESIZE] = $size; 291 $this->removeHeader('Content-Length', $conf); 292 } 293 $body = $request->getBody(); 294 if ($body->isSeekable()) { 295 $body->rewind(); 296 } 297 $conf[\CURLOPT_READFUNCTION] = static function ($ch, $fd, $length) use ($body) { 298 return $body->read($length); 299 }; 300 } 301 302 // If the Expect header is not present, prevent curl from adding it 303 if (!$request->hasHeader('Expect')) { 304 $conf[\CURLOPT_HTTPHEADER][] = 'Expect:'; 305 } 306 307 // cURL sometimes adds a content-type by default. Prevent this. 308 if (!$request->hasHeader('Content-Type')) { 309 $conf[\CURLOPT_HTTPHEADER][] = 'Content-Type:'; 310 } 311 } 312 313 private function applyHeaders(EasyHandle $easy, array &$conf): void 314 { 315 foreach ($conf['_headers'] as $name => $values) { 316 foreach ($values as $value) { 317 $value = (string) $value; 318 if ($value === '') { 319 // cURL requires a special format for empty headers. 320 // See https://github.com/guzzle/guzzle/issues/1882 for more details. 321 $conf[\CURLOPT_HTTPHEADER][] = "$name;"; 322 } else { 323 $conf[\CURLOPT_HTTPHEADER][] = "$name: $value"; 324 } 325 } 326 } 327 328 // Remove the Accept header if one was not set 329 if (!$easy->request->hasHeader('Accept')) { 330 $conf[\CURLOPT_HTTPHEADER][] = 'Accept:'; 331 } 332 } 333 334 /** 335 * Remove a header from the options array. 336 * 337 * @param string $name Case-insensitive header to remove 338 * @param array $options Array of options to modify 339 */ 340 private function removeHeader(string $name, array &$options): void 341 { 342 foreach (\array_keys($options['_headers']) as $key) { 343 if (!\strcasecmp($key, $name)) { 344 unset($options['_headers'][$key]); 345 346 return; 347 } 348 } 349 } 350 351 private function applyHandlerOptions(EasyHandle $easy, array &$conf): void 352 { 353 $options = $easy->options; 354 if (isset($options['verify'])) { 355 if ($options['verify'] === false) { 356 unset($conf[\CURLOPT_CAINFO]); 357 $conf[\CURLOPT_SSL_VERIFYHOST] = 0; 358 $conf[\CURLOPT_SSL_VERIFYPEER] = false; 359 } else { 360 $conf[\CURLOPT_SSL_VERIFYHOST] = 2; 361 $conf[\CURLOPT_SSL_VERIFYPEER] = true; 362 if (\is_string($options['verify'])) { 363 // Throw an error if the file/folder/link path is not valid or doesn't exist. 364 if (!\file_exists($options['verify'])) { 365 throw new \InvalidArgumentException("SSL CA bundle not found: {$options['verify']}"); 366 } 367 // If it's a directory or a link to a directory use CURLOPT_CAPATH. 368 // If not, it's probably a file, or a link to a file, so use CURLOPT_CAINFO. 369 if ( 370 \is_dir($options['verify']) 371 || ( 372 \is_link($options['verify']) === true 373 && ($verifyLink = \readlink($options['verify'])) !== false 374 && \is_dir($verifyLink) 375 ) 376 ) { 377 $conf[\CURLOPT_CAPATH] = $options['verify']; 378 } else { 379 $conf[\CURLOPT_CAINFO] = $options['verify']; 380 } 381 } 382 } 383 } 384 385 if (!isset($options['curl'][\CURLOPT_ENCODING]) && !empty($options['decode_content'])) { 386 $accept = $easy->request->getHeaderLine('Accept-Encoding'); 387 if ($accept) { 388 $conf[\CURLOPT_ENCODING] = $accept; 389 } else { 390 // The empty string enables all available decoders and implicitly 391 // sets a matching 'Accept-Encoding' header. 392 $conf[\CURLOPT_ENCODING] = ''; 393 // But as the user did not specify any acceptable encodings we need 394 // to overwrite this implicit header with an empty one. 395 $conf[\CURLOPT_HTTPHEADER][] = 'Accept-Encoding:'; 396 } 397 } 398 399 if (!isset($options['sink'])) { 400 // Use a default temp stream if no sink was set. 401 $options['sink'] = \GuzzleHttp\Psr7\Utils::tryFopen('php://temp', 'w+'); 402 } 403 $sink = $options['sink']; 404 if (!\is_string($sink)) { 405 $sink = \GuzzleHttp\Psr7\Utils::streamFor($sink); 406 } elseif (!\is_dir(\dirname($sink))) { 407 // Ensure that the directory exists before failing in curl. 408 throw new \RuntimeException(\sprintf('Directory %s does not exist for sink value of %s', \dirname($sink), $sink)); 409 } else { 410 $sink = new LazyOpenStream($sink, 'w+'); 411 } 412 $easy->sink = $sink; 413 $conf[\CURLOPT_WRITEFUNCTION] = static function ($ch, $write) use ($sink): int { 414 return $sink->write($write); 415 }; 416 417 $timeoutRequiresNoSignal = false; 418 if (isset($options['timeout'])) { 419 $timeoutRequiresNoSignal |= $options['timeout'] < 1; 420 $conf[\CURLOPT_TIMEOUT_MS] = $options['timeout'] * 1000; 421 } 422 423 // CURL default value is CURL_IPRESOLVE_WHATEVER 424 if (isset($options['force_ip_resolve'])) { 425 if ('v4' === $options['force_ip_resolve']) { 426 $conf[\CURLOPT_IPRESOLVE] = \CURL_IPRESOLVE_V4; 427 } elseif ('v6' === $options['force_ip_resolve']) { 428 $conf[\CURLOPT_IPRESOLVE] = \CURL_IPRESOLVE_V6; 429 } 430 } 431 432 if (isset($options['connect_timeout'])) { 433 $timeoutRequiresNoSignal |= $options['connect_timeout'] < 1; 434 $conf[\CURLOPT_CONNECTTIMEOUT_MS] = $options['connect_timeout'] * 1000; 435 } 436 437 if ($timeoutRequiresNoSignal && \strtoupper(\substr(\PHP_OS, 0, 3)) !== 'WIN') { 438 $conf[\CURLOPT_NOSIGNAL] = true; 439 } 440 441 if (isset($options['proxy'])) { 442 if (!\is_array($options['proxy'])) { 443 $conf[\CURLOPT_PROXY] = $options['proxy']; 444 } else { 445 $scheme = $easy->request->getUri()->getScheme(); 446 if (isset($options['proxy'][$scheme])) { 447 $host = $easy->request->getUri()->getHost(); 448 if (isset($options['proxy']['no']) && Utils::isHostInNoProxy($host, $options['proxy']['no'])) { 449 unset($conf[\CURLOPT_PROXY]); 450 } else { 451 $conf[\CURLOPT_PROXY] = $options['proxy'][$scheme]; 452 } 453 } 454 } 455 } 456 457 if (isset($options['crypto_method'])) { 458 if (\STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT === $options['crypto_method']) { 459 if (!defined('CURL_SSLVERSION_TLSv1_0')) { 460 throw new \InvalidArgumentException('Invalid crypto_method request option: TLS 1.0 not supported by your version of cURL'); 461 } 462 $conf[\CURLOPT_SSLVERSION] = \CURL_SSLVERSION_TLSv1_0; 463 } elseif (\STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT === $options['crypto_method']) { 464 if (!defined('CURL_SSLVERSION_TLSv1_1')) { 465 throw new \InvalidArgumentException('Invalid crypto_method request option: TLS 1.1 not supported by your version of cURL'); 466 } 467 $conf[\CURLOPT_SSLVERSION] = \CURL_SSLVERSION_TLSv1_1; 468 } elseif (\STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT === $options['crypto_method']) { 469 if (!defined('CURL_SSLVERSION_TLSv1_2')) { 470 throw new \InvalidArgumentException('Invalid crypto_method request option: TLS 1.2 not supported by your version of cURL'); 471 } 472 $conf[\CURLOPT_SSLVERSION] = \CURL_SSLVERSION_TLSv1_2; 473 } elseif (defined('STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT') && \STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT === $options['crypto_method']) { 474 if (!defined('CURL_SSLVERSION_TLSv1_3')) { 475 throw new \InvalidArgumentException('Invalid crypto_method request option: TLS 1.3 not supported by your version of cURL'); 476 } 477 $conf[\CURLOPT_SSLVERSION] = \CURL_SSLVERSION_TLSv1_3; 478 } else { 479 throw new \InvalidArgumentException('Invalid crypto_method request option: unknown version provided'); 480 } 481 } 482 483 if (isset($options['cert'])) { 484 $cert = $options['cert']; 485 if (\is_array($cert)) { 486 $conf[\CURLOPT_SSLCERTPASSWD] = $cert[1]; 487 $cert = $cert[0]; 488 } 489 if (!\file_exists($cert)) { 490 throw new \InvalidArgumentException("SSL certificate not found: {$cert}"); 491 } 492 // OpenSSL (versions 0.9.3 and later) also support "P12" for PKCS#12-encoded files. 493 // see https://curl.se/libcurl/c/CURLOPT_SSLCERTTYPE.html 494 $ext = pathinfo($cert, \PATHINFO_EXTENSION); 495 if (preg_match('#^(der|p12)$#i', $ext)) { 496 $conf[\CURLOPT_SSLCERTTYPE] = strtoupper($ext); 497 } 498 $conf[\CURLOPT_SSLCERT] = $cert; 499 } 500 501 if (isset($options['ssl_key'])) { 502 if (\is_array($options['ssl_key'])) { 503 if (\count($options['ssl_key']) === 2) { 504 [$sslKey, $conf[\CURLOPT_SSLKEYPASSWD]] = $options['ssl_key']; 505 } else { 506 [$sslKey] = $options['ssl_key']; 507 } 508 } 509 510 $sslKey = $sslKey ?? $options['ssl_key']; 511 512 if (!\file_exists($sslKey)) { 513 throw new \InvalidArgumentException("SSL private key not found: {$sslKey}"); 514 } 515 $conf[\CURLOPT_SSLKEY] = $sslKey; 516 } 517 518 if (isset($options['progress'])) { 519 $progress = $options['progress']; 520 if (!\is_callable($progress)) { 521 throw new \InvalidArgumentException('progress client option must be callable'); 522 } 523 $conf[\CURLOPT_NOPROGRESS] = false; 524 $conf[\CURLOPT_PROGRESSFUNCTION] = static function ($resource, int $downloadSize, int $downloaded, int $uploadSize, int $uploaded) use ($progress) { 525 $progress($downloadSize, $downloaded, $uploadSize, $uploaded); 526 }; 527 } 528 529 if (!empty($options['debug'])) { 530 $conf[\CURLOPT_STDERR] = Utils::debugResource($options['debug']); 531 $conf[\CURLOPT_VERBOSE] = true; 532 } 533 } 534 535 /** 536 * This function ensures that a response was set on a transaction. If one 537 * was not set, then the request is retried if possible. This error 538 * typically means you are sending a payload, curl encountered a 539 * "Connection died, retrying a fresh connect" error, tried to rewind the 540 * stream, and then encountered a "necessary data rewind wasn't possible" 541 * error, causing the request to be sent through curl_multi_info_read() 542 * without an error status. 543 * 544 * @param callable(RequestInterface, array): PromiseInterface $handler 545 */ 546 private static function retryFailedRewind(callable $handler, EasyHandle $easy, array $ctx): PromiseInterface 547 { 548 try { 549 // Only rewind if the body has been read from. 550 $body = $easy->request->getBody(); 551 if ($body->tell() > 0) { 552 $body->rewind(); 553 } 554 } catch (\RuntimeException $e) { 555 $ctx['error'] = 'The connection unexpectedly failed without ' 556 .'providing an error. The request would have been retried, ' 557 .'but attempting to rewind the request body failed. ' 558 .'Exception: '.$e; 559 560 return self::createRejection($easy, $ctx); 561 } 562 563 // Retry no more than 3 times before giving up. 564 if (!isset($easy->options['_curl_retries'])) { 565 $easy->options['_curl_retries'] = 1; 566 } elseif ($easy->options['_curl_retries'] == 2) { 567 $ctx['error'] = 'The cURL request was retried 3 times ' 568 .'and did not succeed. The most likely reason for the failure ' 569 .'is that cURL was unable to rewind the body of the request ' 570 .'and subsequent retries resulted in the same error. Turn on ' 571 .'the debug option to see what went wrong. See ' 572 .'https://bugs.php.net/bug.php?id=47204 for more information.'; 573 574 return self::createRejection($easy, $ctx); 575 } else { 576 ++$easy->options['_curl_retries']; 577 } 578 579 return $handler($easy->request, $easy->options); 580 } 581 582 private function createHeaderFn(EasyHandle $easy): callable 583 { 584 if (isset($easy->options['on_headers'])) { 585 $onHeaders = $easy->options['on_headers']; 586 587 if (!\is_callable($onHeaders)) { 588 throw new \InvalidArgumentException('on_headers must be callable'); 589 } 590 } else { 591 $onHeaders = null; 592 } 593 594 return static function ($ch, $h) use ( 595 $onHeaders, 596 $easy, 597 &$startingResponse 598 ) { 599 $value = \trim($h); 600 if ($value === '') { 601 $startingResponse = true; 602 try { 603 $easy->createResponse(); 604 } catch (\Exception $e) { 605 $easy->createResponseException = $e; 606 607 return -1; 608 } 609 if ($onHeaders !== null) { 610 try { 611 $onHeaders($easy->response); 612 } catch (\Exception $e) { 613 // Associate the exception with the handle and trigger 614 // a curl header write error by returning 0. 615 $easy->onHeadersException = $e; 616 617 return -1; 618 } 619 } 620 } elseif ($startingResponse) { 621 $startingResponse = false; 622 $easy->headers = [$value]; 623 } else { 624 $easy->headers[] = $value; 625 } 626 627 return \strlen($h); 628 }; 629 } 630 631 public function __destruct() 632 { 633 foreach ($this->handles as $id => $handle) { 634 \curl_close($handle); 635 unset($this->handles[$id]); 636 } 637 } 638} 639