1<?php
2/**
3 * Zend Framework (http://framework.zend.com/)
4 *
5 * @link      http://github.com/zendframework/zf2 for the canonical source repository
6 * @copyright Copyright (c) 2005-2015 Zend Technologies USA Inc. (http://www.zend.com)
7 * @license   http://framework.zend.com/license/new-bsd New BSD License
8 */
9
10namespace Zend\Http\Client\Adapter;
11
12use Traversable;
13use Zend\Http\Client\Adapter\AdapterInterface as HttpAdapter;
14use Zend\Http\Client\Adapter\Exception as AdapterException;
15use Zend\Stdlib\ArrayUtils;
16
17/**
18 * An adapter class for Zend\Http\Client based on the curl extension.
19 * Curl requires libcurl. See for full requirements the PHP manual: http://php.net/curl
20 */
21class Curl implements HttpAdapter, StreamInterface
22{
23    /**
24     * Parameters array
25     *
26     * @var array
27     */
28    protected $config = array();
29
30    /**
31     * What host/port are we connected to?
32     *
33     * @var array
34     */
35    protected $connectedTo = array(null, null);
36
37    /**
38     * The curl session handle
39     *
40     * @var resource|null
41     */
42    protected $curl = null;
43
44    /**
45     * List of cURL options that should never be overwritten
46     *
47     * @var array
48     */
49    protected $invalidOverwritableCurlOptions;
50
51    /**
52     * Response gotten from server
53     *
54     * @var string
55     */
56    protected $response = null;
57
58    /**
59     * Stream for storing output
60     *
61     * @var resource
62     */
63    protected $outputStream;
64
65    /**
66     * Adapter constructor
67     *
68     * Config is set using setOptions()
69     *
70     * @throws AdapterException\InitializationException
71     */
72    public function __construct()
73    {
74        if (!extension_loaded('curl')) {
75            throw new AdapterException\InitializationException(
76                'cURL extension has to be loaded to use this Zend\Http\Client adapter'
77            );
78        }
79        $this->invalidOverwritableCurlOptions = array(
80            CURLOPT_HTTPGET,
81            CURLOPT_POST,
82            CURLOPT_UPLOAD,
83            CURLOPT_CUSTOMREQUEST,
84            CURLOPT_HEADER,
85            CURLOPT_RETURNTRANSFER,
86            CURLOPT_HTTPHEADER,
87            CURLOPT_INFILE,
88            CURLOPT_INFILESIZE,
89            CURLOPT_PORT,
90            CURLOPT_MAXREDIRS,
91            CURLOPT_CONNECTTIMEOUT,
92        );
93    }
94
95    /**
96     * Set the configuration array for the adapter
97     *
98     * @param  array|Traversable $options
99     * @return Curl
100     * @throws AdapterException\InvalidArgumentException
101     */
102    public function setOptions($options = array())
103    {
104        if ($options instanceof Traversable) {
105            $options = ArrayUtils::iteratorToArray($options);
106        }
107        if (!is_array($options)) {
108            throw new AdapterException\InvalidArgumentException(
109                'Array or Traversable object expected, got ' . gettype($options)
110            );
111        }
112
113        /** Config Key Normalization */
114        foreach ($options as $k => $v) {
115            unset($options[$k]); // unset original value
116            $options[str_replace(array('-', '_', ' ', '.'), '', strtolower($k))] = $v; // replace w/ normalized
117        }
118
119        if (isset($options['proxyuser']) && isset($options['proxypass'])) {
120            $this->setCurlOption(CURLOPT_PROXYUSERPWD, $options['proxyuser'] . ":" . $options['proxypass']);
121            unset($options['proxyuser'], $options['proxypass']);
122        }
123
124        if (isset($options['sslverifypeer'])) {
125            $this->setCurlOption(CURLOPT_SSL_VERIFYPEER, $options['sslverifypeer']);
126            unset($options['sslverifypeer']);
127        }
128
129        foreach ($options as $k => $v) {
130            $option = strtolower($k);
131            switch ($option) {
132                case 'proxyhost':
133                    $this->setCurlOption(CURLOPT_PROXY, $v);
134                    break;
135                case 'proxyport':
136                    $this->setCurlOption(CURLOPT_PROXYPORT, $v);
137                    break;
138                default:
139                    if (is_array($v) && isset($this->config[$option]) && is_array($this->config[$option])) {
140                        $v = ArrayUtils::merge($this->config[$option], $v);
141                    }
142                    $this->config[$option] = $v;
143                    break;
144            }
145        }
146
147        return $this;
148    }
149
150    /**
151     * Retrieve the array of all configuration options
152     *
153     * @return array
154     */
155    public function getConfig()
156    {
157        return $this->config;
158    }
159
160    /**
161     * Direct setter for cURL adapter related options.
162     *
163     * @param  string|int $option
164     * @param  mixed $value
165     * @return Curl
166     */
167    public function setCurlOption($option, $value)
168    {
169        if (!isset($this->config['curloptions'])) {
170            $this->config['curloptions'] = array();
171        }
172        $this->config['curloptions'][$option] = $value;
173        return $this;
174    }
175
176    /**
177     * Initialize curl
178     *
179     * @param  string  $host
180     * @param  int     $port
181     * @param  bool $secure
182     * @return void
183     * @throws AdapterException\RuntimeException if unable to connect
184     */
185    public function connect($host, $port = 80, $secure = false)
186    {
187        // If we're already connected, disconnect first
188        if ($this->curl) {
189            $this->close();
190        }
191
192        // Do the actual connection
193        $this->curl = curl_init();
194        if ($port != 80) {
195            curl_setopt($this->curl, CURLOPT_PORT, intval($port));
196        }
197
198        if (isset($this->config['timeout'])) {
199            if (defined('CURLOPT_CONNECTTIMEOUT_MS')) {
200                curl_setopt($this->curl, CURLOPT_CONNECTTIMEOUT_MS, $this->config['timeout'] * 1000);
201            } else {
202                curl_setopt($this->curl, CURLOPT_CONNECTTIMEOUT, $this->config['timeout']);
203            }
204
205            if (defined('CURLOPT_TIMEOUT_MS')) {
206                curl_setopt($this->curl, CURLOPT_TIMEOUT_MS, $this->config['timeout'] * 1000);
207            } else {
208                curl_setopt($this->curl, CURLOPT_TIMEOUT, $this->config['timeout']);
209            }
210        }
211
212        if (isset($this->config['maxredirects'])) {
213            // Set Max redirects
214            curl_setopt($this->curl, CURLOPT_MAXREDIRS, $this->config['maxredirects']);
215        }
216
217        if (!$this->curl) {
218            $this->close();
219
220            throw new AdapterException\RuntimeException('Unable to Connect to ' . $host . ':' . $port);
221        }
222
223        if ($secure !== false) {
224            // Behave the same like Zend\Http\Adapter\Socket on SSL options.
225            if (isset($this->config['sslcert'])) {
226                curl_setopt($this->curl, CURLOPT_SSLCERT, $this->config['sslcert']);
227            }
228            if (isset($this->config['sslpassphrase'])) {
229                curl_setopt($this->curl, CURLOPT_SSLCERTPASSWD, $this->config['sslpassphrase']);
230            }
231        }
232
233        // Update connected_to
234        $this->connectedTo = array($host, $port);
235    }
236
237    /**
238     * Send request to the remote server
239     *
240     * @param  string        $method
241     * @param  \Zend\Uri\Uri $uri
242     * @param  float         $httpVersion
243     * @param  array         $headers
244     * @param  string        $body
245     * @return string        $request
246     * @throws AdapterException\RuntimeException If connection fails, connected
247     *     to wrong host, no PUT file defined, unsupported method, or unsupported
248     *     cURL option.
249     * @throws AdapterException\InvalidArgumentException if $method is currently not supported
250     */
251    public function write($method, $uri, $httpVersion = 1.1, $headers = array(), $body = '')
252    {
253        // Make sure we're properly connected
254        if (!$this->curl) {
255            throw new AdapterException\RuntimeException("Trying to write but we are not connected");
256        }
257
258        if ($this->connectedTo[0] != $uri->getHost() || $this->connectedTo[1] != $uri->getPort()) {
259            throw new AdapterException\RuntimeException("Trying to write but we are connected to the wrong host");
260        }
261
262        // set URL
263        curl_setopt($this->curl, CURLOPT_URL, $uri->__toString());
264
265        // ensure correct curl call
266        $curlValue = true;
267        switch ($method) {
268            case 'GET':
269                $curlMethod = CURLOPT_HTTPGET;
270                break;
271
272            case 'POST':
273                $curlMethod = CURLOPT_POST;
274                break;
275
276            case 'PUT':
277                // There are two different types of PUT request, either a Raw Data string has been set
278                // or CURLOPT_INFILE and CURLOPT_INFILESIZE are used.
279                if (is_resource($body)) {
280                    $this->config['curloptions'][CURLOPT_INFILE] = $body;
281                }
282                if (isset($this->config['curloptions'][CURLOPT_INFILE])) {
283                    // Now we will probably already have Content-Length set, so that we have to delete it
284                    // from $headers at this point:
285                    if (!isset($headers['Content-Length'])
286                        && !isset($this->config['curloptions'][CURLOPT_INFILESIZE])
287                    ) {
288                        throw new AdapterException\RuntimeException(
289                            'Cannot set a file-handle for cURL option CURLOPT_INFILE'
290                            . ' without also setting its size in CURLOPT_INFILESIZE.'
291                        );
292                    }
293
294                    if (isset($headers['Content-Length'])) {
295                        $this->config['curloptions'][CURLOPT_INFILESIZE] = (int) $headers['Content-Length'];
296                        unset($headers['Content-Length']);
297                    }
298
299                    if (is_resource($body)) {
300                        $body = '';
301                    }
302
303                    $curlMethod = CURLOPT_UPLOAD;
304                } else {
305                    $curlMethod = CURLOPT_CUSTOMREQUEST;
306                    $curlValue = "PUT";
307                }
308                break;
309
310            case 'PATCH':
311                $curlMethod = CURLOPT_CUSTOMREQUEST;
312                $curlValue = "PATCH";
313                break;
314
315            case 'DELETE':
316                $curlMethod = CURLOPT_CUSTOMREQUEST;
317                $curlValue = "DELETE";
318                break;
319
320            case 'OPTIONS':
321                $curlMethod = CURLOPT_CUSTOMREQUEST;
322                $curlValue = "OPTIONS";
323                break;
324
325            case 'TRACE':
326                $curlMethod = CURLOPT_CUSTOMREQUEST;
327                $curlValue = "TRACE";
328                break;
329
330            case 'HEAD':
331                $curlMethod = CURLOPT_CUSTOMREQUEST;
332                $curlValue = "HEAD";
333                break;
334
335            default:
336                // For now, through an exception for unsupported request methods
337                throw new AdapterException\InvalidArgumentException("Method '$method' currently not supported");
338        }
339
340        if (is_resource($body) && $curlMethod != CURLOPT_UPLOAD) {
341            throw new AdapterException\RuntimeException("Streaming requests are allowed only with PUT");
342        }
343
344        // get http version to use
345        $curlHttp = ($httpVersion == 1.1) ? CURL_HTTP_VERSION_1_1 : CURL_HTTP_VERSION_1_0;
346
347        // mark as HTTP request and set HTTP method
348        curl_setopt($this->curl, CURLOPT_HTTP_VERSION, $curlHttp);
349        curl_setopt($this->curl, $curlMethod, $curlValue);
350
351        if ($this->outputStream) {
352            // headers will be read into the response
353            curl_setopt($this->curl, CURLOPT_HEADER, false);
354            curl_setopt($this->curl, CURLOPT_HEADERFUNCTION, array($this, "readHeader"));
355            // and data will be written into the file
356            curl_setopt($this->curl, CURLOPT_FILE, $this->outputStream);
357        } else {
358            // ensure headers are also returned
359            curl_setopt($this->curl, CURLOPT_HEADER, true);
360
361            // ensure actual response is returned
362            curl_setopt($this->curl, CURLOPT_RETURNTRANSFER, true);
363        }
364
365        // Treating basic auth headers in a special way
366        if (array_key_exists('Authorization', $headers) && 'Basic' == substr($headers['Authorization'], 0, 5)) {
367            curl_setopt($this->curl, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
368            curl_setopt($this->curl, CURLOPT_USERPWD, base64_decode(substr($headers['Authorization'], 6)));
369            unset($headers['Authorization']);
370        }
371
372        // set additional headers
373        if (!isset($headers['Accept'])) {
374            $headers['Accept'] = '';
375        }
376        $curlHeaders = array();
377        foreach ($headers as $key => $value) {
378            $curlHeaders[] = $key . ': ' . $value;
379        }
380
381        curl_setopt($this->curl, CURLOPT_HTTPHEADER, $curlHeaders);
382
383        /**
384         * Make sure POSTFIELDS is set after $curlMethod is set:
385         * @link http://de2.php.net/manual/en/function.curl-setopt.php#81161
386         */
387        if (in_array($method, array('POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'), true)) {
388            curl_setopt($this->curl, CURLOPT_POSTFIELDS, $body);
389        } elseif ($curlMethod == CURLOPT_UPLOAD) {
390            // this covers a PUT by file-handle:
391            // Make the setting of this options explicit (rather than setting it through the loop following a bit lower)
392            // to group common functionality together.
393            curl_setopt($this->curl, CURLOPT_INFILE, $this->config['curloptions'][CURLOPT_INFILE]);
394            curl_setopt($this->curl, CURLOPT_INFILESIZE, $this->config['curloptions'][CURLOPT_INFILESIZE]);
395            unset($this->config['curloptions'][CURLOPT_INFILE]);
396            unset($this->config['curloptions'][CURLOPT_INFILESIZE]);
397        }
398
399        // set additional curl options
400        if (isset($this->config['curloptions'])) {
401            foreach ((array) $this->config['curloptions'] as $k => $v) {
402                if (!in_array($k, $this->invalidOverwritableCurlOptions)) {
403                    if (curl_setopt($this->curl, $k, $v) == false) {
404                        throw new AdapterException\RuntimeException(sprintf(
405                            'Unknown or erroreous cURL option "%s" set',
406                            $k
407                        ));
408                    }
409                }
410            }
411        }
412
413        // send the request
414
415        $response = curl_exec($this->curl);
416        // if we used streaming, headers are already there
417        if (!is_resource($this->outputStream)) {
418            $this->response = $response;
419        }
420
421        $request  = curl_getinfo($this->curl, CURLINFO_HEADER_OUT);
422        $request .= $body;
423
424        if (empty($this->response)) {
425            throw new AdapterException\RuntimeException("Error in cURL request: " . curl_error($this->curl));
426        }
427
428        // separating header from body because it is dangerous to accidentially replace strings in the body
429        $responseHeaderSize = curl_getinfo($this->curl, CURLINFO_HEADER_SIZE);
430        $responseHeaders = substr($this->response, 0, $responseHeaderSize);
431
432        // cURL automatically decodes chunked-messages, this means we have to
433        // disallow the Zend\Http\Response to do it again.
434        $responseHeaders = preg_replace("/Transfer-Encoding:\s*chunked\\r\\n/", "", $responseHeaders);
435
436        // cURL can automatically handle content encoding; prevent double-decoding from occurring
437        if (isset($this->config['curloptions'][CURLOPT_ENCODING])
438            && '' == $this->config['curloptions'][CURLOPT_ENCODING]
439        ) {
440            $responseHeaders = preg_replace("/Content-Encoding:\s*gzip\\r\\n/", '', $responseHeaders);
441        }
442
443        // cURL automatically handles Proxy rewrites, remove the "HTTP/1.0 200 Connection established" string:
444        $responseHeaders = preg_replace(
445            "/HTTP\/1.0\s*200\s*Connection\s*established\\r\\n\\r\\n/",
446            '',
447            $responseHeaders
448        );
449
450        // replace old header with new, cleaned up, header
451        $this->response = substr_replace($this->response, $responseHeaders, 0, $responseHeaderSize);
452
453        // Eliminate multiple HTTP responses.
454        do {
455            $parts = preg_split('|(?:\r?\n){2}|m', $this->response, 2);
456            $again = false;
457
458            if (isset($parts[1]) && preg_match("|^HTTP/1\.[01](.*?)\r\n|mi", $parts[1])) {
459                $this->response = $parts[1];
460                $again          = true;
461            }
462        } while ($again);
463
464        return $request;
465    }
466
467    /**
468     * Return read response from server
469     *
470     * @return string
471     */
472    public function read()
473    {
474        return $this->response;
475    }
476
477    /**
478     * Close the connection to the server
479     *
480     */
481    public function close()
482    {
483        if (is_resource($this->curl)) {
484            curl_close($this->curl);
485        }
486        $this->curl         = null;
487        $this->connectedTo = array(null, null);
488    }
489
490    /**
491     * Get cUrl Handle
492     *
493     * @return resource
494     */
495    public function getHandle()
496    {
497        return $this->curl;
498    }
499
500    /**
501     * Set output stream for the response
502     *
503     * @param resource $stream
504     * @return Curl
505     */
506    public function setOutputStream($stream)
507    {
508        $this->outputStream = $stream;
509        return $this;
510    }
511
512    /**
513     * Header reader function for CURL
514     *
515     * @param resource $curl
516     * @param string $header
517     * @return int
518     */
519    public function readHeader($curl, $header)
520    {
521        $this->response .= $header;
522        return strlen($header);
523    }
524}
525