1<?php
2
3declare(strict_types=1);
4
5namespace Sabre\HTTP;
6
7use Sabre\Event\EventEmitter;
8use Sabre\Uri;
9
10/**
11 * A rudimentary HTTP client.
12 *
13 * This object wraps PHP's curl extension and provides an easy way to send it a
14 * Request object, and return a Response object.
15 *
16 * This is by no means intended as the next best HTTP client, but it does the
17 * job and provides a simple integration with the rest of sabre/http.
18 *
19 * This client emits the following events:
20 *   beforeRequest(RequestInterface $request)
21 *   afterRequest(RequestInterface $request, ResponseInterface $response)
22 *   error(RequestInterface $request, ResponseInterface $response, bool &$retry, int $retryCount)
23 *   exception(RequestInterface $request, ClientException $e, bool &$retry, int $retryCount)
24 *
25 * The beforeRequest event allows you to do some last minute changes to the
26 * request before it's done, such as adding authentication headers.
27 *
28 * The afterRequest event will be emitted after the request is completed
29 * succesfully.
30 *
31 * If a HTTP error is returned (status code higher than 399) the error event is
32 * triggered. It's possible using this event to retry the request, by setting
33 * retry to true.
34 *
35 * The amount of times a request has retried is passed as $retryCount, which
36 * can be used to avoid retrying indefinitely. The first time the event is
37 * called, this will be 0.
38 *
39 * It's also possible to intercept specific http errors, by subscribing to for
40 * example 'error:401'.
41 *
42 * @copyright Copyright (C) fruux GmbH (https://fruux.com/)
43 * @author Evert Pot (http://evertpot.com/)
44 * @license http://sabre.io/license/ Modified BSD License
45 */
46class Client extends EventEmitter
47{
48    /**
49     * List of curl settings.
50     *
51     * @var array
52     */
53    protected $curlSettings = [];
54
55    /**
56     * Wether or not exceptions should be thrown when a HTTP error is returned.
57     *
58     * @var bool
59     */
60    protected $throwExceptions = false;
61
62    /**
63     * The maximum number of times we'll follow a redirect.
64     *
65     * @var int
66     */
67    protected $maxRedirects = 5;
68
69    protected $headerLinesMap = [];
70
71    /**
72     * Initializes the client.
73     */
74    public function __construct()
75    {
76        // See https://github.com/sabre-io/http/pull/115#discussion_r241292068
77        // Preserve compatibility for sub-classes that implements their own method `parseCurlResult`
78        $separatedHeaders = __CLASS__ === get_class($this);
79
80        $this->curlSettings = [
81            CURLOPT_RETURNTRANSFER => true,
82            CURLOPT_NOBODY => false,
83            CURLOPT_USERAGENT => 'sabre-http/'.Version::VERSION.' (http://sabre.io/)',
84        ];
85        if ($separatedHeaders) {
86            $this->curlSettings[CURLOPT_HEADERFUNCTION] = [$this, 'receiveCurlHeader'];
87        } else {
88            $this->curlSettings[CURLOPT_HEADER] = true;
89        }
90    }
91
92    protected function receiveCurlHeader($curlHandle, $headerLine)
93    {
94        $this->headerLinesMap[(int) $curlHandle][] = $headerLine;
95
96        return strlen($headerLine);
97    }
98
99    /**
100     * Sends a request to a HTTP server, and returns a response.
101     */
102    public function send(RequestInterface $request): ResponseInterface
103    {
104        $this->emit('beforeRequest', [$request]);
105
106        $retryCount = 0;
107        $redirects = 0;
108
109        do {
110            $doRedirect = false;
111            $retry = false;
112
113            try {
114                $response = $this->doRequest($request);
115
116                $code = $response->getStatus();
117
118                // We are doing in-PHP redirects, because curl's
119                // FOLLOW_LOCATION throws errors when PHP is configured with
120                // open_basedir.
121                //
122                // https://github.com/fruux/sabre-http/issues/12
123                if ($redirects < $this->maxRedirects && in_array($code, [301, 302, 307, 308])) {
124                    $oldLocation = $request->getUrl();
125
126                    // Creating a new instance of the request object.
127                    $request = clone $request;
128
129                    // Setting the new location
130                    $request->setUrl(Uri\resolve(
131                        $oldLocation,
132                        $response->getHeader('Location')
133                    ));
134
135                    $doRedirect = true;
136                    ++$redirects;
137                }
138
139                // This was a HTTP error
140                if ($code >= 400) {
141                    $this->emit('error', [$request, $response, &$retry, $retryCount]);
142                    $this->emit('error:'.$code, [$request, $response, &$retry, $retryCount]);
143                }
144            } catch (ClientException $e) {
145                $this->emit('exception', [$request, $e, &$retry, $retryCount]);
146
147                // If retry was still set to false, it means no event handler
148                // dealt with the problem. In this case we just re-throw the
149                // exception.
150                if (!$retry) {
151                    throw $e;
152                }
153            }
154
155            if ($retry) {
156                ++$retryCount;
157            }
158        } while ($retry || $doRedirect);
159
160        $this->emit('afterRequest', [$request, $response]);
161
162        if ($this->throwExceptions && $code >= 400) {
163            throw new ClientHttpException($response);
164        }
165
166        return $response;
167    }
168
169    /**
170     * Sends a HTTP request asynchronously.
171     *
172     * Due to the nature of PHP, you must from time to time poll to see if any
173     * new responses came in.
174     *
175     * After calling sendAsync, you must therefore occasionally call the poll()
176     * method, or wait().
177     */
178    public function sendAsync(RequestInterface $request, callable $success = null, callable $error = null)
179    {
180        $this->emit('beforeRequest', [$request]);
181        $this->sendAsyncInternal($request, $success, $error);
182        $this->poll();
183    }
184
185    /**
186     * This method checks if any http requests have gotten results, and if so,
187     * call the appropriate success or error handlers.
188     *
189     * This method will return true if there are still requests waiting to
190     * return, and false if all the work is done.
191     */
192    public function poll(): bool
193    {
194        // nothing to do?
195        if (!$this->curlMultiMap) {
196            return false;
197        }
198
199        do {
200            $r = curl_multi_exec(
201                $this->curlMultiHandle,
202                $stillRunning
203            );
204        } while (CURLM_CALL_MULTI_PERFORM === $r);
205
206        $messagesInQueue = 0;
207        do {
208            messageQueue:
209
210            $status = curl_multi_info_read(
211                $this->curlMultiHandle,
212                $messagesInQueue
213            );
214
215            if ($status && CURLMSG_DONE === $status['msg']) {
216                $resourceId = (int) $status['handle'];
217                list(
218                    $request,
219                    $successCallback,
220                    $errorCallback,
221                    $retryCount) = $this->curlMultiMap[$resourceId];
222                unset($this->curlMultiMap[$resourceId]);
223
224                $curlHandle = $status['handle'];
225                $curlResult = $this->parseResponse(curl_multi_getcontent($curlHandle), $curlHandle);
226                $retry = false;
227
228                if (self::STATUS_CURLERROR === $curlResult['status']) {
229                    $e = new ClientException($curlResult['curl_errmsg'], $curlResult['curl_errno']);
230                    $this->emit('exception', [$request, $e, &$retry, $retryCount]);
231
232                    if ($retry) {
233                        ++$retryCount;
234                        $this->sendAsyncInternal($request, $successCallback, $errorCallback, $retryCount);
235                        goto messageQueue;
236                    }
237
238                    $curlResult['request'] = $request;
239
240                    if ($errorCallback) {
241                        $errorCallback($curlResult);
242                    }
243                } elseif (self::STATUS_HTTPERROR === $curlResult['status']) {
244                    $this->emit('error', [$request, $curlResult['response'], &$retry, $retryCount]);
245                    $this->emit('error:'.$curlResult['http_code'], [$request, $curlResult['response'], &$retry, $retryCount]);
246
247                    if ($retry) {
248                        ++$retryCount;
249                        $this->sendAsyncInternal($request, $successCallback, $errorCallback, $retryCount);
250                        goto messageQueue;
251                    }
252
253                    $curlResult['request'] = $request;
254
255                    if ($errorCallback) {
256                        $errorCallback($curlResult);
257                    }
258                } else {
259                    $this->emit('afterRequest', [$request, $curlResult['response']]);
260
261                    if ($successCallback) {
262                        $successCallback($curlResult['response']);
263                    }
264                }
265            }
266        } while ($messagesInQueue > 0);
267
268        return count($this->curlMultiMap) > 0;
269    }
270
271    /**
272     * Processes every HTTP request in the queue, and waits till they are all
273     * completed.
274     */
275    public function wait()
276    {
277        do {
278            curl_multi_select($this->curlMultiHandle);
279            $stillRunning = $this->poll();
280        } while ($stillRunning);
281    }
282
283    /**
284     * If this is set to true, the Client will automatically throw exceptions
285     * upon HTTP errors.
286     *
287     * This means that if a response came back with a status code greater than
288     * or equal to 400, we will throw a ClientHttpException.
289     *
290     * This only works for the send() method. Throwing exceptions for
291     * sendAsync() is not supported.
292     */
293    public function setThrowExceptions(bool $throwExceptions)
294    {
295        $this->throwExceptions = $throwExceptions;
296    }
297
298    /**
299     * Adds a CURL setting.
300     *
301     * These settings will be included in every HTTP request.
302     *
303     * @param mixed $value
304     */
305    public function addCurlSetting(int $name, $value)
306    {
307        $this->curlSettings[$name] = $value;
308    }
309
310    /**
311     * This method is responsible for performing a single request.
312     */
313    protected function doRequest(RequestInterface $request): ResponseInterface
314    {
315        $settings = $this->createCurlSettingsArray($request);
316
317        if (!$this->curlHandle) {
318            $this->curlHandle = curl_init();
319        } else {
320            curl_reset($this->curlHandle);
321        }
322
323        curl_setopt_array($this->curlHandle, $settings);
324        $response = $this->curlExec($this->curlHandle);
325        $response = $this->parseResponse($response, $this->curlHandle);
326        if (self::STATUS_CURLERROR === $response['status']) {
327            throw new ClientException($response['curl_errmsg'], $response['curl_errno']);
328        }
329
330        return $response['response'];
331    }
332
333    /**
334     * Cached curl handle.
335     *
336     * By keeping this resource around for the lifetime of this object, things
337     * like persistent connections are possible.
338     *
339     * @var resource
340     */
341    private $curlHandle;
342
343    /**
344     * Handler for curl_multi requests.
345     *
346     * The first time sendAsync is used, this will be created.
347     *
348     * @var resource
349     */
350    private $curlMultiHandle;
351
352    /**
353     * Has a list of curl handles, as well as their associated success and
354     * error callbacks.
355     *
356     * @var array
357     */
358    private $curlMultiMap = [];
359
360    /**
361     * Turns a RequestInterface object into an array with settings that can be
362     * fed to curl_setopt.
363     */
364    protected function createCurlSettingsArray(RequestInterface $request): array
365    {
366        $settings = $this->curlSettings;
367
368        switch ($request->getMethod()) {
369            case 'HEAD':
370                $settings[CURLOPT_NOBODY] = true;
371                $settings[CURLOPT_CUSTOMREQUEST] = 'HEAD';
372                break;
373            case 'GET':
374                $settings[CURLOPT_CUSTOMREQUEST] = 'GET';
375                break;
376            default:
377                $body = $request->getBody();
378                if (is_resource($body)) {
379                    // This needs to be set to PUT, regardless of the actual
380                    // method used. Without it, INFILE will be ignored for some
381                    // reason.
382                    $settings[CURLOPT_PUT] = true;
383                    $settings[CURLOPT_INFILE] = $request->getBody();
384                } else {
385                    // For security we cast this to a string. If somehow an array could
386                    // be passed here, it would be possible for an attacker to use @ to
387                    // post local files.
388                    $settings[CURLOPT_POSTFIELDS] = (string) $body;
389                }
390                $settings[CURLOPT_CUSTOMREQUEST] = $request->getMethod();
391                break;
392        }
393
394        $nHeaders = [];
395        foreach ($request->getHeaders() as $key => $values) {
396            foreach ($values as $value) {
397                $nHeaders[] = $key.': '.$value;
398            }
399        }
400        $settings[CURLOPT_HTTPHEADER] = $nHeaders;
401        $settings[CURLOPT_URL] = $request->getUrl();
402        // FIXME: CURLOPT_PROTOCOLS is currently unsupported by HHVM
403        if (defined('CURLOPT_PROTOCOLS')) {
404            $settings[CURLOPT_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS;
405        }
406        // FIXME: CURLOPT_REDIR_PROTOCOLS is currently unsupported by HHVM
407        if (defined('CURLOPT_REDIR_PROTOCOLS')) {
408            $settings[CURLOPT_REDIR_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS;
409        }
410
411        return $settings;
412    }
413
414    const STATUS_SUCCESS = 0;
415    const STATUS_CURLERROR = 1;
416    const STATUS_HTTPERROR = 2;
417
418    private function parseResponse(string $response, $curlHandle): array
419    {
420        $settings = $this->curlSettings;
421        $separatedHeaders = isset($settings[CURLOPT_HEADERFUNCTION]) && (bool) $settings[CURLOPT_HEADERFUNCTION];
422
423        if ($separatedHeaders) {
424            $resourceId = (int) $curlHandle;
425            if (isset($this->headerLinesMap[$resourceId])) {
426                $headers = $this->headerLinesMap[$resourceId];
427            } else {
428                $headers = [];
429            }
430            $response = $this->parseCurlResponse($headers, $response, $curlHandle);
431        } else {
432            $response = $this->parseCurlResult($response, $curlHandle);
433        }
434
435        return $response;
436    }
437
438    /**
439     * Parses the result of a curl call in a format that's a bit more
440     * convenient to work with.
441     *
442     * The method returns an array with the following elements:
443     *   * status - one of the 3 STATUS constants.
444     *   * curl_errno - A curl error number. Only set if status is
445     *                  STATUS_CURLERROR.
446     *   * curl_errmsg - A current error message. Only set if status is
447     *                   STATUS_CURLERROR.
448     *   * response - Response object. Only set if status is STATUS_SUCCESS, or
449     *                STATUS_HTTPERROR.
450     *   * http_code - HTTP status code, as an int. Only set if Only set if
451     *                 status is STATUS_SUCCESS, or STATUS_HTTPERROR
452     *
453     * @param resource $curlHandle
454     */
455    protected function parseCurlResponse(array $headerLines, string $body, $curlHandle): array
456    {
457        list(
458            $curlInfo,
459            $curlErrNo,
460            $curlErrMsg
461        ) = $this->curlStuff($curlHandle);
462
463        if ($curlErrNo) {
464            return [
465                'status' => self::STATUS_CURLERROR,
466                'curl_errno' => $curlErrNo,
467                'curl_errmsg' => $curlErrMsg,
468            ];
469        }
470
471        $response = new Response();
472        $response->setStatus($curlInfo['http_code']);
473        $response->setBody($body);
474
475        foreach ($headerLines as $header) {
476            $parts = explode(':', $header, 2);
477            if (2 === count($parts)) {
478                $response->addHeader(trim($parts[0]), trim($parts[1]));
479            }
480        }
481
482        $httpCode = $response->getStatus();
483
484        return [
485            'status' => $httpCode >= 400 ? self::STATUS_HTTPERROR : self::STATUS_SUCCESS,
486            'response' => $response,
487            'http_code' => $httpCode,
488        ];
489    }
490
491    /**
492     * Parses the result of a curl call in a format that's a bit more
493     * convenient to work with.
494     *
495     * The method returns an array with the following elements:
496     *   * status - one of the 3 STATUS constants.
497     *   * curl_errno - A curl error number. Only set if status is
498     *                  STATUS_CURLERROR.
499     *   * curl_errmsg - A current error message. Only set if status is
500     *                   STATUS_CURLERROR.
501     *   * response - Response object. Only set if status is STATUS_SUCCESS, or
502     *                STATUS_HTTPERROR.
503     *   * http_code - HTTP status code, as an int. Only set if Only set if
504     *                 status is STATUS_SUCCESS, or STATUS_HTTPERROR
505     *
506     * @deprecated Use parseCurlResponse instead
507     *
508     * @param resource $curlHandle
509     */
510    protected function parseCurlResult(string $response, $curlHandle): array
511    {
512        list(
513            $curlInfo,
514            $curlErrNo,
515            $curlErrMsg
516        ) = $this->curlStuff($curlHandle);
517
518        if ($curlErrNo) {
519            return [
520                'status' => self::STATUS_CURLERROR,
521                'curl_errno' => $curlErrNo,
522                'curl_errmsg' => $curlErrMsg,
523            ];
524        }
525
526        $headerBlob = substr($response, 0, $curlInfo['header_size']);
527        // In the case of 204 No Content, strlen($response) == $curlInfo['header_size].
528        // This will cause substr($response, $curlInfo['header_size']) return FALSE instead of NULL
529        // An exception will be thrown when calling getBodyAsString then
530        $responseBody = substr($response, $curlInfo['header_size']) ?: '';
531
532        unset($response);
533
534        // In the case of 100 Continue, or redirects we'll have multiple lists
535        // of headers for each separate HTTP response. We can easily split this
536        // because they are separated by \r\n\r\n
537        $headerBlob = explode("\r\n\r\n", trim($headerBlob, "\r\n"));
538
539        // We only care about the last set of headers
540        $headerBlob = $headerBlob[count($headerBlob) - 1];
541
542        // Splitting headers
543        $headerBlob = explode("\r\n", $headerBlob);
544
545        return $this->parseCurlResponse($headerBlob, $responseBody, $curlHandle);
546    }
547
548    /**
549     * Sends an asynchronous HTTP request.
550     *
551     * We keep this in a separate method, so we can call it without triggering
552     * the beforeRequest event and don't do the poll().
553     */
554    protected function sendAsyncInternal(RequestInterface $request, callable $success, callable $error, int $retryCount = 0)
555    {
556        if (!$this->curlMultiHandle) {
557            $this->curlMultiHandle = curl_multi_init();
558        }
559        $curl = curl_init();
560        curl_setopt_array(
561            $curl,
562            $this->createCurlSettingsArray($request)
563        );
564        curl_multi_add_handle($this->curlMultiHandle, $curl);
565
566        $resourceId = (int) $curl;
567        $this->headerLinesMap[$resourceId] = [];
568        $this->curlMultiMap[$resourceId] = [
569            $request,
570            $success,
571            $error,
572            $retryCount,
573        ];
574    }
575
576    // @codeCoverageIgnoreStart
577
578    /**
579     * Calls curl_exec.
580     *
581     * This method exists so it can easily be overridden and mocked.
582     *
583     * @param resource $curlHandle
584     */
585    protected function curlExec($curlHandle): string
586    {
587        $this->headerLinesMap[(int) $curlHandle] = [];
588
589        $result = curl_exec($curlHandle);
590        if (false === $result) {
591            $result = '';
592        }
593
594        return $result;
595    }
596
597    /**
598     * Returns a bunch of information about a curl request.
599     *
600     * This method exists so it can easily be overridden and mocked.
601     *
602     * @param resource $curlHandle
603     */
604    protected function curlStuff($curlHandle): array
605    {
606        return [
607            curl_getinfo($curlHandle),
608            curl_errno($curlHandle),
609            curl_error($curlHandle),
610        ];
611    }
612
613    // @codeCoverageIgnoreEnd
614}
615