Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.
   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\LazyOpenStream;
  11  use GuzzleHttp\TransferStats;
  12  use GuzzleHttp\Utils;
  13  use Psr\Http\Message\RequestInterface;
  14  
  15  /**
  16   * Creates curl resources from a request
  17   *
  18   * @final
  19   */
  20  class 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 => 150,
 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              return;
 254          }
 255  
 256          $method = $easy->request->getMethod();
 257          if ($method === 'PUT' || $method === 'POST') {
 258              // See https://tools.ietf.org/html/rfc7230#section-3.3.2
 259              if (!$easy->request->hasHeader('Content-Length')) {
 260                  $conf[\CURLOPT_HTTPHEADER][] = 'Content-Length: 0';
 261              }
 262          } elseif ($method === 'HEAD') {
 263              $conf[\CURLOPT_NOBODY] = true;
 264              unset(
 265                  $conf[\CURLOPT_WRITEFUNCTION],
 266                  $conf[\CURLOPT_READFUNCTION],
 267                  $conf[\CURLOPT_FILE],
 268                  $conf[\CURLOPT_INFILE]
 269              );
 270          }
 271      }
 272  
 273      private function applyBody(RequestInterface $request, array $options, array &$conf): void
 274      {
 275          $size = $request->hasHeader('Content-Length')
 276              ? (int) $request->getHeaderLine('Content-Length')
 277              : null;
 278  
 279          // Send the body as a string if the size is less than 1MB OR if the
 280          // [curl][body_as_string] request value is set.
 281          if (($size !== null && $size < 1000000) || !empty($options['_body_as_string'])) {
 282              $conf[\CURLOPT_POSTFIELDS] = (string) $request->getBody();
 283              // Don't duplicate the Content-Length header
 284              $this->removeHeader('Content-Length', $conf);
 285              $this->removeHeader('Transfer-Encoding', $conf);
 286          } else {
 287              $conf[\CURLOPT_UPLOAD] = true;
 288              if ($size !== null) {
 289                  $conf[\CURLOPT_INFILESIZE] = $size;
 290                  $this->removeHeader('Content-Length', $conf);
 291              }
 292              $body = $request->getBody();
 293              if ($body->isSeekable()) {
 294                  $body->rewind();
 295              }
 296              $conf[\CURLOPT_READFUNCTION] = static function ($ch, $fd, $length) use ($body) {
 297                  return $body->read($length);
 298              };
 299          }
 300  
 301          // If the Expect header is not present, prevent curl from adding it
 302          if (!$request->hasHeader('Expect')) {
 303              $conf[\CURLOPT_HTTPHEADER][] = 'Expect:';
 304          }
 305  
 306          // cURL sometimes adds a content-type by default. Prevent this.
 307          if (!$request->hasHeader('Content-Type')) {
 308              $conf[\CURLOPT_HTTPHEADER][] = 'Content-Type:';
 309          }
 310      }
 311  
 312      private function applyHeaders(EasyHandle $easy, array &$conf): void
 313      {
 314          foreach ($conf['_headers'] as $name => $values) {
 315              foreach ($values as $value) {
 316                  $value = (string) $value;
 317                  if ($value === '') {
 318                      // cURL requires a special format for empty headers.
 319                      // See https://github.com/guzzle/guzzle/issues/1882 for more details.
 320                      $conf[\CURLOPT_HTTPHEADER][] = "$name;";
 321                  } else {
 322                      $conf[\CURLOPT_HTTPHEADER][] = "$name: $value";
 323                  }
 324              }
 325          }
 326  
 327          // Remove the Accept header if one was not set
 328          if (!$easy->request->hasHeader('Accept')) {
 329              $conf[\CURLOPT_HTTPHEADER][] = 'Accept:';
 330          }
 331      }
 332  
 333      /**
 334       * Remove a header from the options array.
 335       *
 336       * @param string $name    Case-insensitive header to remove
 337       * @param array  $options Array of options to modify
 338       */
 339      private function removeHeader(string $name, array &$options): void
 340      {
 341          foreach (\array_keys($options['_headers']) as $key) {
 342              if (!\strcasecmp($key, $name)) {
 343                  unset($options['_headers'][$key]);
 344                  return;
 345              }
 346          }
 347      }
 348  
 349      private function applyHandlerOptions(EasyHandle $easy, array &$conf): void
 350      {
 351          $options = $easy->options;
 352          if (isset($options['verify'])) {
 353              if ($options['verify'] === false) {
 354                  unset($conf[\CURLOPT_CAINFO]);
 355                  $conf[\CURLOPT_SSL_VERIFYHOST] = 0;
 356                  $conf[\CURLOPT_SSL_VERIFYPEER] = false;
 357              } else {
 358                  $conf[\CURLOPT_SSL_VERIFYHOST] = 2;
 359                  $conf[\CURLOPT_SSL_VERIFYPEER] = true;
 360                  if (\is_string($options['verify'])) {
 361                      // Throw an error if the file/folder/link path is not valid or doesn't exist.
 362                      if (!\file_exists($options['verify'])) {
 363                          throw new \InvalidArgumentException("SSL CA bundle not found: {$options['verify']}");
 364                      }
 365                      // If it's a directory or a link to a directory use CURLOPT_CAPATH.
 366                      // If not, it's probably a file, or a link to a file, so use CURLOPT_CAINFO.
 367                      if (
 368                          \is_dir($options['verify']) ||
 369                          (
 370                              \is_link($options['verify']) === true &&
 371                              ($verifyLink = \readlink($options['verify'])) !== false &&
 372                              \is_dir($verifyLink)
 373                          )
 374                      ) {
 375                          $conf[\CURLOPT_CAPATH] = $options['verify'];
 376                      } else {
 377                          $conf[\CURLOPT_CAINFO] = $options['verify'];
 378                      }
 379                  }
 380              }
 381          }
 382  
 383          if (!isset($options['curl'][\CURLOPT_ENCODING]) && !empty($options['decode_content'])) {
 384              $accept = $easy->request->getHeaderLine('Accept-Encoding');
 385              if ($accept) {
 386                  $conf[\CURLOPT_ENCODING] = $accept;
 387              } else {
 388                  // The empty string enables all available decoders and implicitly
 389                  // sets a matching 'Accept-Encoding' header.
 390                  $conf[\CURLOPT_ENCODING] = '';
 391                  // But as the user did not specify any acceptable encodings we need
 392                  // to overwrite this implicit header with an empty one.
 393                  $conf[\CURLOPT_HTTPHEADER][] = 'Accept-Encoding:';
 394              }
 395          }
 396  
 397          if (!isset($options['sink'])) {
 398              // Use a default temp stream if no sink was set.
 399              $options['sink'] = \GuzzleHttp\Psr7\Utils::tryFopen('php://temp', 'w+');
 400          }
 401          $sink = $options['sink'];
 402          if (!\is_string($sink)) {
 403              $sink = \GuzzleHttp\Psr7\Utils::streamFor($sink);
 404          } elseif (!\is_dir(\dirname($sink))) {
 405              // Ensure that the directory exists before failing in curl.
 406              throw new \RuntimeException(\sprintf('Directory %s does not exist for sink value of %s', \dirname($sink), $sink));
 407          } else {
 408              $sink = new LazyOpenStream($sink, 'w+');
 409          }
 410          $easy->sink = $sink;
 411          $conf[\CURLOPT_WRITEFUNCTION] = static function ($ch, $write) use ($sink): int {
 412              return $sink->write($write);
 413          };
 414  
 415          $timeoutRequiresNoSignal = false;
 416          if (isset($options['timeout'])) {
 417              $timeoutRequiresNoSignal |= $options['timeout'] < 1;
 418              $conf[\CURLOPT_TIMEOUT_MS] = $options['timeout'] * 1000;
 419          }
 420  
 421          // CURL default value is CURL_IPRESOLVE_WHATEVER
 422          if (isset($options['force_ip_resolve'])) {
 423              if ('v4' === $options['force_ip_resolve']) {
 424                  $conf[\CURLOPT_IPRESOLVE] = \CURL_IPRESOLVE_V4;
 425              } elseif ('v6' === $options['force_ip_resolve']) {
 426                  $conf[\CURLOPT_IPRESOLVE] = \CURL_IPRESOLVE_V6;
 427              }
 428          }
 429  
 430          if (isset($options['connect_timeout'])) {
 431              $timeoutRequiresNoSignal |= $options['connect_timeout'] < 1;
 432              $conf[\CURLOPT_CONNECTTIMEOUT_MS] = $options['connect_timeout'] * 1000;
 433          }
 434  
 435          if ($timeoutRequiresNoSignal && \strtoupper(\substr(\PHP_OS, 0, 3)) !== 'WIN') {
 436              $conf[\CURLOPT_NOSIGNAL] = true;
 437          }
 438  
 439          if (isset($options['proxy'])) {
 440              if (!\is_array($options['proxy'])) {
 441                  $conf[\CURLOPT_PROXY] = $options['proxy'];
 442              } else {
 443                  $scheme = $easy->request->getUri()->getScheme();
 444                  if (isset($options['proxy'][$scheme])) {
 445                      $host = $easy->request->getUri()->getHost();
 446                      if (!isset($options['proxy']['no']) || !Utils::isHostInNoProxy($host, $options['proxy']['no'])) {
 447                          $conf[\CURLOPT_PROXY] = $options['proxy'][$scheme];
 448                      }
 449                  }
 450              }
 451          }
 452  
 453          if (isset($options['cert'])) {
 454              $cert = $options['cert'];
 455              if (\is_array($cert)) {
 456                  $conf[\CURLOPT_SSLCERTPASSWD] = $cert[1];
 457                  $cert = $cert[0];
 458              }
 459              if (!\file_exists($cert)) {
 460                  throw new \InvalidArgumentException("SSL certificate not found: {$cert}");
 461              }
 462              # OpenSSL (versions 0.9.3 and later) also support "P12" for PKCS#12-encoded files.
 463              # see https://curl.se/libcurl/c/CURLOPT_SSLCERTTYPE.html
 464              $ext = pathinfo($cert, \PATHINFO_EXTENSION);
 465              if (preg_match('#^(der|p12)$#i', $ext)) {
 466                  $conf[\CURLOPT_SSLCERTTYPE] = strtoupper($ext);
 467              }
 468              $conf[\CURLOPT_SSLCERT] = $cert;
 469          }
 470  
 471          if (isset($options['ssl_key'])) {
 472              if (\is_array($options['ssl_key'])) {
 473                  if (\count($options['ssl_key']) === 2) {
 474                      [$sslKey, $conf[\CURLOPT_SSLKEYPASSWD]] = $options['ssl_key'];
 475                  } else {
 476                      [$sslKey] = $options['ssl_key'];
 477                  }
 478              }
 479  
 480              $sslKey = $sslKey ?? $options['ssl_key'];
 481  
 482              if (!\file_exists($sslKey)) {
 483                  throw new \InvalidArgumentException("SSL private key not found: {$sslKey}");
 484              }
 485              $conf[\CURLOPT_SSLKEY] = $sslKey;
 486          }
 487  
 488          if (isset($options['progress'])) {
 489              $progress = $options['progress'];
 490              if (!\is_callable($progress)) {
 491                  throw new \InvalidArgumentException('progress client option must be callable');
 492              }
 493              $conf[\CURLOPT_NOPROGRESS] = false;
 494              $conf[\CURLOPT_PROGRESSFUNCTION] = static function ($resource, int $downloadSize, int $downloaded, int $uploadSize, int $uploaded) use ($progress) {
 495                  $progress($downloadSize, $downloaded, $uploadSize, $uploaded);
 496              };
 497          }
 498  
 499          if (!empty($options['debug'])) {
 500              $conf[\CURLOPT_STDERR] = Utils::debugResource($options['debug']);
 501              $conf[\CURLOPT_VERBOSE] = true;
 502          }
 503      }
 504  
 505      /**
 506       * This function ensures that a response was set on a transaction. If one
 507       * was not set, then the request is retried if possible. This error
 508       * typically means you are sending a payload, curl encountered a
 509       * "Connection died, retrying a fresh connect" error, tried to rewind the
 510       * stream, and then encountered a "necessary data rewind wasn't possible"
 511       * error, causing the request to be sent through curl_multi_info_read()
 512       * without an error status.
 513       *
 514       * @param callable(RequestInterface, array): PromiseInterface $handler
 515       */
 516      private static function retryFailedRewind(callable $handler, EasyHandle $easy, array $ctx): PromiseInterface
 517      {
 518          try {
 519              // Only rewind if the body has been read from.
 520              $body = $easy->request->getBody();
 521              if ($body->tell() > 0) {
 522                  $body->rewind();
 523              }
 524          } catch (\RuntimeException $e) {
 525              $ctx['error'] = 'The connection unexpectedly failed without '
 526                  . 'providing an error. The request would have been retried, '
 527                  . 'but attempting to rewind the request body failed. '
 528                  . 'Exception: ' . $e;
 529              return self::createRejection($easy, $ctx);
 530          }
 531  
 532          // Retry no more than 3 times before giving up.
 533          if (!isset($easy->options['_curl_retries'])) {
 534              $easy->options['_curl_retries'] = 1;
 535          } elseif ($easy->options['_curl_retries'] == 2) {
 536              $ctx['error'] = 'The cURL request was retried 3 times '
 537                  . 'and did not succeed. The most likely reason for the failure '
 538                  . 'is that cURL was unable to rewind the body of the request '
 539                  . 'and subsequent retries resulted in the same error. Turn on '
 540                  . 'the debug option to see what went wrong. See '
 541                  . 'https://bugs.php.net/bug.php?id=47204 for more information.';
 542              return self::createRejection($easy, $ctx);
 543          } else {
 544              $easy->options['_curl_retries']++;
 545          }
 546  
 547          return $handler($easy->request, $easy->options);
 548      }
 549  
 550      private function createHeaderFn(EasyHandle $easy): callable
 551      {
 552          if (isset($easy->options['on_headers'])) {
 553              $onHeaders = $easy->options['on_headers'];
 554  
 555              if (!\is_callable($onHeaders)) {
 556                  throw new \InvalidArgumentException('on_headers must be callable');
 557              }
 558          } else {
 559              $onHeaders = null;
 560          }
 561  
 562          return static function ($ch, $h) use (
 563              $onHeaders,
 564              $easy,
 565              &$startingResponse
 566          ) {
 567              $value = \trim($h);
 568              if ($value === '') {
 569                  $startingResponse = true;
 570                  try {
 571                      $easy->createResponse();
 572                  } catch (\Exception $e) {
 573                      $easy->createResponseException = $e;
 574                      return -1;
 575                  }
 576                  if ($onHeaders !== null) {
 577                      try {
 578                          $onHeaders($easy->response);
 579                      } catch (\Exception $e) {
 580                          // Associate the exception with the handle and trigger
 581                          // a curl header write error by returning 0.
 582                          $easy->onHeadersException = $e;
 583                          return -1;
 584                      }
 585                  }
 586              } elseif ($startingResponse) {
 587                  $startingResponse = false;
 588                  $easy->headers = [$value];
 589              } else {
 590                  $easy->headers[] = $value;
 591              }
 592              return \strlen($h);
 593          };
 594      }
 595  }