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