1<?php
2namespace GuzzleHttp\Ring\Client;
3
4use GuzzleHttp\Ring\Core;
5use GuzzleHttp\Ring\Exception\ConnectException;
6use GuzzleHttp\Ring\Exception\RingException;
7use GuzzleHttp\Stream\LazyOpenStream;
8use GuzzleHttp\Stream\StreamInterface;
9
10/**
11 * Creates curl resources from a request
12 */
13class CurlFactory
14{
15    /**
16     * Creates a cURL handle, header resource, and body resource based on a
17     * transaction.
18     *
19     * @param array         $request Request hash
20     * @param null|resource $handle  Optionally provide a curl handle to modify
21     *
22     * @return array Returns an array of the curl handle, headers array, and
23     *               response body handle.
24     * @throws \RuntimeException when an option cannot be applied
25     */
26    public function __invoke(array $request, $handle = null)
27    {
28        $headers = [];
29        $options = $this->getDefaultOptions($request, $headers);
30        $this->applyMethod($request, $options);
31
32        if (isset($request['client'])) {
33            $this->applyHandlerOptions($request, $options);
34        }
35
36        $this->applyHeaders($request, $options);
37        unset($options['_headers']);
38
39        // Add handler options from the request's configuration options
40        if (isset($request['client']['curl'])) {
41            $options = $this->applyCustomCurlOptions(
42                $request['client']['curl'],
43                $options
44            );
45        }
46
47        if (!$handle) {
48            $handle = curl_init();
49        }
50
51        $body = $this->getOutputBody($request, $options);
52        curl_setopt_array($handle, $options);
53
54        return [$handle, &$headers, $body];
55    }
56
57    /**
58     * Creates a response hash from a cURL result.
59     *
60     * @param callable $handler  Handler that was used.
61     * @param array    $request  Request that sent.
62     * @param array    $response Response hash to update.
63     * @param array    $headers  Headers received during transfer.
64     * @param resource $body     Body fopen response.
65     *
66     * @return array
67     */
68    public static function createResponse(
69        callable $handler,
70        array $request,
71        array $response,
72        array $headers,
73        $body
74    ) {
75        if (isset($response['transfer_stats']['url'])) {
76            $response['effective_url'] = $response['transfer_stats']['url'];
77        }
78
79        if (!empty($headers)) {
80            $startLine = explode(' ', array_shift($headers), 3);
81            $headerList = Core::headersFromLines($headers);
82            $response['headers'] = $headerList;
83            $response['version'] = isset($startLine[0]) ? substr($startLine[0], 5) : null;
84            $response['status'] = isset($startLine[1]) ? (int) $startLine[1] : null;
85            $response['reason'] = isset($startLine[2]) ? $startLine[2] : null;
86            $response['body'] = $body;
87            Core::rewindBody($response);
88        }
89
90        return !empty($response['curl']['errno']) || !isset($response['status'])
91            ? self::createErrorResponse($handler, $request, $response)
92            : $response;
93    }
94
95    private static function createErrorResponse(
96        callable $handler,
97        array $request,
98        array $response
99    ) {
100        static $connectionErrors = [
101            CURLE_OPERATION_TIMEOUTED  => true,
102            CURLE_COULDNT_RESOLVE_HOST => true,
103            CURLE_COULDNT_CONNECT      => true,
104            CURLE_SSL_CONNECT_ERROR    => true,
105            CURLE_GOT_NOTHING          => true,
106        ];
107
108        // Retry when nothing is present or when curl failed to rewind.
109        if (!isset($response['err_message'])
110            && (empty($response['curl']['errno'])
111                || $response['curl']['errno'] == 65)
112        ) {
113            return self::retryFailedRewind($handler, $request, $response);
114        }
115
116        $message = isset($response['err_message'])
117            ? $response['err_message']
118            : sprintf('cURL error %s: %s',
119                $response['curl']['errno'],
120                isset($response['curl']['error'])
121                    ? $response['curl']['error']
122                    : 'See http://curl.haxx.se/libcurl/c/libcurl-errors.html');
123
124        $error = isset($response['curl']['errno'])
125            && isset($connectionErrors[$response['curl']['errno']])
126            ? new ConnectException($message)
127            : new RingException($message);
128
129        return $response + [
130            'status'  => null,
131            'reason'  => null,
132            'body'    => null,
133            'headers' => [],
134            'error'   => $error,
135        ];
136    }
137
138    private function getOutputBody(array $request, array &$options)
139    {
140        // Determine where the body of the response (if any) will be streamed.
141        if (isset($options[CURLOPT_WRITEFUNCTION])) {
142            return $request['client']['save_to'];
143        }
144
145        if (isset($options[CURLOPT_FILE])) {
146            return $options[CURLOPT_FILE];
147        }
148
149        if ($request['http_method'] != 'HEAD') {
150            // Create a default body if one was not provided
151            return $options[CURLOPT_FILE] = fopen('php://temp', 'w+');
152        }
153
154        return null;
155    }
156
157    private function getDefaultOptions(array $request, array &$headers)
158    {
159        $url = Core::url($request);
160        $startingResponse = false;
161
162        $options = [
163            '_headers'             => $request['headers'],
164            CURLOPT_CUSTOMREQUEST  => $request['http_method'],
165            CURLOPT_URL            => $url,
166            CURLOPT_RETURNTRANSFER => false,
167            CURLOPT_HEADER         => false,
168            CURLOPT_CONNECTTIMEOUT => 150,
169            CURLOPT_HEADERFUNCTION => function ($ch, $h) use (&$headers, &$startingResponse) {
170                $value = trim($h);
171                if ($value === '') {
172                    $startingResponse = true;
173                } elseif ($startingResponse) {
174                    $startingResponse = false;
175                    $headers = [$value];
176                } else {
177                    $headers[] = $value;
178                }
179                return strlen($h);
180            },
181        ];
182
183        if (isset($request['version'])) {
184            if ($request['version'] == 2.0) {
185                $options[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_2_0;
186            } else if ($request['version'] == 1.1) {
187                $options[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_1;
188            } else {
189                $options[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_0;
190            }
191        }
192
193        if (defined('CURLOPT_PROTOCOLS')) {
194            $options[CURLOPT_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS;
195        }
196
197        return $options;
198    }
199
200    private function applyMethod(array $request, array &$options)
201    {
202        if (isset($request['body'])) {
203            $this->applyBody($request, $options);
204            return;
205        }
206
207        switch ($request['http_method']) {
208            case 'PUT':
209            case 'POST':
210                // See http://tools.ietf.org/html/rfc7230#section-3.3.2
211                if (!Core::hasHeader($request, 'Content-Length')) {
212                    $options[CURLOPT_HTTPHEADER][] = 'Content-Length: 0';
213                }
214                break;
215            case 'HEAD':
216                $options[CURLOPT_NOBODY] = true;
217                unset(
218                    $options[CURLOPT_WRITEFUNCTION],
219                    $options[CURLOPT_READFUNCTION],
220                    $options[CURLOPT_FILE],
221                    $options[CURLOPT_INFILE]
222                );
223        }
224    }
225
226    private function applyBody(array $request, array &$options)
227    {
228        $contentLength = Core::firstHeader($request, 'Content-Length');
229        $size = $contentLength !== null ? (int) $contentLength : null;
230
231        // Send the body as a string if the size is less than 1MB OR if the
232        // [client][curl][body_as_string] request value is set.
233        if (($size !== null && $size < 1000000) ||
234            isset($request['client']['curl']['body_as_string']) ||
235            is_string($request['body'])
236        ) {
237            $options[CURLOPT_POSTFIELDS] = Core::body($request);
238            // Don't duplicate the Content-Length header
239            $this->removeHeader('Content-Length', $options);
240            $this->removeHeader('Transfer-Encoding', $options);
241        } else {
242            $options[CURLOPT_UPLOAD] = true;
243            if ($size !== null) {
244                // Let cURL handle setting the Content-Length header
245                $options[CURLOPT_INFILESIZE] = $size;
246                $this->removeHeader('Content-Length', $options);
247            }
248            $this->addStreamingBody($request, $options);
249        }
250
251        // If the Expect header is not present, prevent curl from adding it
252        if (!Core::hasHeader($request, 'Expect')) {
253            $options[CURLOPT_HTTPHEADER][] = 'Expect:';
254        }
255
256        // cURL sometimes adds a content-type by default. Prevent this.
257        if (!Core::hasHeader($request, 'Content-Type')) {
258            $options[CURLOPT_HTTPHEADER][] = 'Content-Type:';
259        }
260    }
261
262    private function addStreamingBody(array $request, array &$options)
263    {
264        $body = $request['body'];
265
266        if ($body instanceof StreamInterface) {
267            $options[CURLOPT_READFUNCTION] = function ($ch, $fd, $length) use ($body) {
268                return (string) $body->read($length);
269            };
270            if (!isset($options[CURLOPT_INFILESIZE])) {
271                if ($size = $body->getSize()) {
272                    $options[CURLOPT_INFILESIZE] = $size;
273                }
274            }
275        } elseif (is_resource($body)) {
276            $options[CURLOPT_INFILE] = $body;
277        } elseif ($body instanceof \Iterator) {
278            $buf = '';
279            $options[CURLOPT_READFUNCTION] = function ($ch, $fd, $length) use ($body, &$buf) {
280                if ($body->valid()) {
281                    $buf .= $body->current();
282                    $body->next();
283                }
284                $result = (string) substr($buf, 0, $length);
285                $buf = substr($buf, $length);
286                return $result;
287            };
288        } else {
289            throw new \InvalidArgumentException('Invalid request body provided');
290        }
291    }
292
293    private function applyHeaders(array $request, array &$options)
294    {
295        foreach ($options['_headers'] as $name => $values) {
296            foreach ($values as $value) {
297                $options[CURLOPT_HTTPHEADER][] = "$name: $value";
298            }
299        }
300
301        // Remove the Accept header if one was not set
302        if (!Core::hasHeader($request, 'Accept')) {
303            $options[CURLOPT_HTTPHEADER][] = 'Accept:';
304        }
305    }
306
307    /**
308     * Takes an array of curl options specified in the 'curl' option of a
309     * request's configuration array and maps them to CURLOPT_* options.
310     *
311     * This method is only called when a  request has a 'curl' config setting.
312     *
313     * @param array $config  Configuration array of custom curl option
314     * @param array $options Array of existing curl options
315     *
316     * @return array Returns a new array of curl options
317     */
318    private function applyCustomCurlOptions(array $config, array $options)
319    {
320        $curlOptions = [];
321        foreach ($config as $key => $value) {
322            if (is_int($key)) {
323                $curlOptions[$key] = $value;
324            }
325        }
326
327        return $curlOptions + $options;
328    }
329
330    /**
331     * Remove a header from the options array.
332     *
333     * @param string $name    Case-insensitive header to remove
334     * @param array  $options Array of options to modify
335     */
336    private function removeHeader($name, array &$options)
337    {
338        foreach (array_keys($options['_headers']) as $key) {
339            if (!strcasecmp($key, $name)) {
340                unset($options['_headers'][$key]);
341                return;
342            }
343        }
344    }
345
346    /**
347     * Applies an array of request client options to a the options array.
348     *
349     * This method uses a large switch rather than double-dispatch to save on
350     * high overhead of calling functions in PHP.
351     */
352    private function applyHandlerOptions(array $request, array &$options)
353    {
354        foreach ($request['client'] as $key => $value) {
355            switch ($key) {
356            // Violating PSR-4 to provide more room.
357            case 'verify':
358
359                if ($value === false) {
360                    unset($options[CURLOPT_CAINFO]);
361                    $options[CURLOPT_SSL_VERIFYHOST] = 0;
362                    $options[CURLOPT_SSL_VERIFYPEER] = false;
363                    continue 2;
364                }
365
366                $options[CURLOPT_SSL_VERIFYHOST] = 2;
367                $options[CURLOPT_SSL_VERIFYPEER] = true;
368
369                if (is_string($value)) {
370                    $options[CURLOPT_CAINFO] = $value;
371                    if (!file_exists($value)) {
372                        throw new \InvalidArgumentException(
373                            "SSL CA bundle not found: $value"
374                        );
375                    }
376                }
377                break;
378
379            case 'decode_content':
380
381                if ($value === false) {
382                    continue 2;
383                }
384
385                $accept = Core::firstHeader($request, 'Accept-Encoding');
386                if ($accept) {
387                    $options[CURLOPT_ENCODING] = $accept;
388                } else {
389                    $options[CURLOPT_ENCODING] = '';
390                    // Don't let curl send the header over the wire
391                    $options[CURLOPT_HTTPHEADER][] = 'Accept-Encoding:';
392                }
393                break;
394
395            case 'save_to':
396
397                if (is_string($value)) {
398                    if (!is_dir(dirname($value))) {
399                        throw new \RuntimeException(sprintf(
400                            'Directory %s does not exist for save_to value of %s',
401                            dirname($value),
402                            $value
403                        ));
404                    }
405                    $value = new LazyOpenStream($value, 'w+');
406                }
407
408                if ($value instanceof StreamInterface) {
409                    $options[CURLOPT_WRITEFUNCTION] =
410                        function ($ch, $write) use ($value) {
411                            return $value->write($write);
412                        };
413                } elseif (is_resource($value)) {
414                    $options[CURLOPT_FILE] = $value;
415                } else {
416                    throw new \InvalidArgumentException('save_to must be a '
417                        . 'GuzzleHttp\Stream\StreamInterface or resource');
418                }
419                break;
420
421            case 'timeout':
422
423                if (defined('CURLOPT_TIMEOUT_MS')) {
424                    $options[CURLOPT_TIMEOUT_MS] = $value * 1000;
425                } else {
426                    $options[CURLOPT_TIMEOUT] = $value;
427                }
428                break;
429
430            case 'connect_timeout':
431
432                if (defined('CURLOPT_CONNECTTIMEOUT_MS')) {
433                    $options[CURLOPT_CONNECTTIMEOUT_MS] = $value * 1000;
434                } else {
435                    $options[CURLOPT_CONNECTTIMEOUT] = $value;
436                }
437                break;
438
439            case 'proxy':
440
441                if (!is_array($value)) {
442                    $options[CURLOPT_PROXY] = $value;
443                } elseif (isset($request['scheme'])) {
444                    $scheme = $request['scheme'];
445                    if (isset($value[$scheme])) {
446                        $options[CURLOPT_PROXY] = $value[$scheme];
447                    }
448                }
449                break;
450
451            case 'cert':
452
453                if (is_array($value)) {
454                    $options[CURLOPT_SSLCERTPASSWD] = $value[1];
455                    $value = $value[0];
456                }
457
458                if (!file_exists($value)) {
459                    throw new \InvalidArgumentException(
460                        "SSL certificate not found: {$value}"
461                    );
462                }
463
464                $options[CURLOPT_SSLCERT] = $value;
465                break;
466
467            case 'ssl_key':
468
469                if (is_array($value)) {
470                    $options[CURLOPT_SSLKEYPASSWD] = $value[1];
471                    $value = $value[0];
472                }
473
474                if (!file_exists($value)) {
475                    throw new \InvalidArgumentException(
476                        "SSL private key not found: {$value}"
477                    );
478                }
479
480                $options[CURLOPT_SSLKEY] = $value;
481                break;
482
483            case 'progress':
484
485                if (!is_callable($value)) {
486                    throw new \InvalidArgumentException(
487                        'progress client option must be callable'
488                    );
489                }
490
491                $options[CURLOPT_NOPROGRESS] = false;
492                $options[CURLOPT_PROGRESSFUNCTION] =
493                    function () use ($value) {
494                        $args = func_get_args();
495                        // PHP 5.5 pushed the handle onto the start of the args
496                        if (is_resource($args[0])) {
497                            array_shift($args);
498                        }
499                        call_user_func_array($value, $args);
500                    };
501                break;
502
503            case 'debug':
504
505                if ($value) {
506                    $options[CURLOPT_STDERR] = Core::getDebugResource($value);
507                    $options[CURLOPT_VERBOSE] = true;
508                }
509                break;
510            }
511        }
512    }
513
514    /**
515     * This function ensures that a response was set on a transaction. If one
516     * was not set, then the request is retried if possible. This error
517     * typically means you are sending a payload, curl encountered a
518     * "Connection died, retrying a fresh connect" error, tried to rewind the
519     * stream, and then encountered a "necessary data rewind wasn't possible"
520     * error, causing the request to be sent through curl_multi_info_read()
521     * without an error status.
522     */
523    private static function retryFailedRewind(
524        callable $handler,
525        array $request,
526        array $response
527    ) {
528        // If there is no body, then there is some other kind of issue. This
529        // is weird and should probably never happen.
530        if (!isset($request['body'])) {
531            $response['err_message'] = 'No response was received for a request '
532                . 'with no body. This could mean that you are saturating your '
533                . 'network.';
534            return self::createErrorResponse($handler, $request, $response);
535        }
536
537        if (!Core::rewindBody($request)) {
538            $response['err_message'] = 'The connection unexpectedly failed '
539                . 'without providing an error. The request would have been '
540                . 'retried, but attempting to rewind the request body failed.';
541            return self::createErrorResponse($handler, $request, $response);
542        }
543
544        // Retry no more than 3 times before giving up.
545        if (!isset($request['curl']['retries'])) {
546            $request['curl']['retries'] = 1;
547        } elseif ($request['curl']['retries'] == 2) {
548            $response['err_message'] = 'The cURL request was retried 3 times '
549                . 'and did no succeed. cURL was unable to rewind the body of '
550                . 'the request and subsequent retries resulted in the same '
551                . 'error. Turn on the debug option to see what went wrong. '
552                . 'See https://bugs.php.net/bug.php?id=47204 for more information.';
553            return self::createErrorResponse($handler, $request, $response);
554        } else {
555            $request['curl']['retries']++;
556        }
557
558        return $handler($request);
559    }
560}
561