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