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