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\Http\Response;
16use Zend\Stdlib\ArrayUtils;
17use Zend\Stdlib\ErrorHandler;
18
19/**
20 * A sockets based (stream\socket\client) adapter class for Zend\Http\Client. Can be used
21 * on almost every PHP environment, and does not require any special extensions.
22 */
23class Socket implements HttpAdapter, StreamInterface
24{
25    /**
26     * Map SSL transport wrappers to stream crypto method constants
27     *
28     * @var array
29     */
30    protected static $sslCryptoTypes = array(
31        'ssl'   => STREAM_CRYPTO_METHOD_SSLv23_CLIENT,
32        'sslv2' => STREAM_CRYPTO_METHOD_SSLv2_CLIENT,
33        'sslv3' => STREAM_CRYPTO_METHOD_SSLv3_CLIENT,
34        'tls'   => STREAM_CRYPTO_METHOD_TLS_CLIENT,
35    );
36
37    /**
38     * The socket for server connection
39     *
40     * @var resource|null
41     */
42    protected $socket = null;
43
44    /**
45     * What host/port are we connected to?
46     *
47     * @var array
48     */
49    protected $connectedTo = array(null, null);
50
51    /**
52     * Stream for storing output
53     *
54     * @var resource
55     */
56    protected $outStream = null;
57
58    /**
59     * Parameters array
60     *
61     * @var array
62     */
63    protected $config = array(
64        'persistent'            => false,
65        'ssltransport'          => 'ssl',
66        'sslcert'               => null,
67        'sslpassphrase'         => null,
68        'sslverifypeer'         => true,
69        'sslcafile'             => null,
70        'sslcapath'             => null,
71        'sslallowselfsigned'    => false,
72        'sslusecontext'         => false,
73    );
74
75    /**
76     * Request method - will be set by write() and might be used by read()
77     *
78     * @var string
79     */
80    protected $method = null;
81
82    /**
83     * Stream context
84     *
85     * @var resource
86     */
87    protected $context = null;
88
89    /**
90     * Adapter constructor, currently empty. Config is set using setOptions()
91     *
92     */
93    public function __construct()
94    {
95    }
96
97    /**
98     * Set the configuration array for the adapter
99     *
100     * @param  array|Traversable $options
101     * @throws AdapterException\InvalidArgumentException
102     */
103    public function setOptions($options = array())
104    {
105        if ($options instanceof Traversable) {
106            $options = ArrayUtils::iteratorToArray($options);
107        }
108        if (!is_array($options)) {
109            throw new AdapterException\InvalidArgumentException(
110                'Array or Zend\Config object expected, got ' . gettype($options)
111            );
112        }
113
114        foreach ($options as $k => $v) {
115            $this->config[strtolower($k)] = $v;
116        }
117    }
118
119    /**
120     * Retrieve the array of all configuration options
121     *
122     * @return array
123     */
124    public function getConfig()
125    {
126        return $this->config;
127    }
128
129    /**
130     * Set the stream context for the TCP connection to the server
131     *
132     * Can accept either a pre-existing stream context resource, or an array
133     * of stream options, similar to the options array passed to the
134     * stream_context_create() PHP function. In such case a new stream context
135     * will be created using the passed options.
136     *
137     * @since  Zend Framework 1.9
138     *
139     * @param  mixed $context Stream context or array of context options
140     * @throws Exception\InvalidArgumentException
141     * @return Socket
142     */
143    public function setStreamContext($context)
144    {
145        if (is_resource($context) && get_resource_type($context) == 'stream-context') {
146            $this->context = $context;
147        } elseif (is_array($context)) {
148            $this->context = stream_context_create($context);
149        } else {
150            // Invalid parameter
151            throw new AdapterException\InvalidArgumentException(
152                "Expecting either a stream context resource or array, got " . gettype($context)
153            );
154        }
155
156        return $this;
157    }
158
159    /**
160     * Get the stream context for the TCP connection to the server.
161     *
162     * If no stream context is set, will create a default one.
163     *
164     * @return resource
165     */
166    public function getStreamContext()
167    {
168        if (! $this->context) {
169            $this->context = stream_context_create();
170        }
171
172        return $this->context;
173    }
174
175    /**
176     * Connect to the remote server
177     *
178     * @param string  $host
179     * @param int     $port
180     * @param  bool $secure
181     * @throws AdapterException\RuntimeException
182     */
183    public function connect($host, $port = 80, $secure = false)
184    {
185        // If we are connected to the wrong host, disconnect first
186        $connectedHost = (strpos($this->connectedTo[0], '://'))
187            ? substr($this->connectedTo[0], (strpos($this->connectedTo[0], '://') + 3), strlen($this->connectedTo[0]))
188            : $this->connectedTo[0];
189
190        if ($connectedHost != $host || $this->connectedTo[1] != $port) {
191            if (is_resource($this->socket)) {
192                $this->close();
193            }
194        }
195
196        // Now, if we are not connected, connect
197        if (!is_resource($this->socket) || ! $this->config['keepalive']) {
198            $context = $this->getStreamContext();
199
200            if ($secure || $this->config['sslusecontext']) {
201                if ($this->config['sslverifypeer'] !== null) {
202                    if (!stream_context_set_option($context, 'ssl', 'verify_peer', $this->config['sslverifypeer'])) {
203                        throw new AdapterException\RuntimeException('Unable to set sslverifypeer option');
204                    }
205                }
206
207                if ($this->config['sslcafile']) {
208                    if (!stream_context_set_option($context, 'ssl', 'cafile', $this->config['sslcafile'])) {
209                        throw new AdapterException\RuntimeException('Unable to set sslcafile option');
210                    }
211                }
212
213                if ($this->config['sslcapath']) {
214                    if (!stream_context_set_option($context, 'ssl', 'capath', $this->config['sslcapath'])) {
215                        throw new AdapterException\RuntimeException('Unable to set sslcapath option');
216                    }
217                }
218
219                if ($this->config['sslallowselfsigned'] !== null) {
220                    if (!stream_context_set_option($context, 'ssl', 'allow_self_signed', $this->config['sslallowselfsigned'])) {
221                        throw new AdapterException\RuntimeException('Unable to set sslallowselfsigned option');
222                    }
223                }
224
225                if ($this->config['sslcert'] !== null) {
226                    if (!stream_context_set_option($context, 'ssl', 'local_cert', $this->config['sslcert'])) {
227                        throw new AdapterException\RuntimeException('Unable to set sslcert option');
228                    }
229                }
230
231                if ($this->config['sslpassphrase'] !== null) {
232                    if (!stream_context_set_option($context, 'ssl', 'passphrase', $this->config['sslpassphrase'])) {
233                        throw new AdapterException\RuntimeException('Unable to set sslpassphrase option');
234                    }
235                }
236            }
237
238            $flags = STREAM_CLIENT_CONNECT;
239            if ($this->config['persistent']) {
240                $flags |= STREAM_CLIENT_PERSISTENT;
241            }
242
243            ErrorHandler::start();
244            $this->socket = stream_socket_client(
245                $host . ':' . $port,
246                $errno,
247                $errstr,
248                (int) $this->config['timeout'],
249                $flags,
250                $context
251            );
252            $error = ErrorHandler::stop();
253
254            if (!$this->socket) {
255                $this->close();
256                throw new AdapterException\RuntimeException(
257                    sprintf(
258                        'Unable to connect to %s:%d%s',
259                        $host,
260                        $port,
261                        ($error ? ' . Error #' . $error->getCode() . ': ' . $error->getMessage() : '')
262                    ),
263                    0,
264                    $error
265                );
266            }
267
268            // Set the stream timeout
269            if (!stream_set_timeout($this->socket, (int) $this->config['timeout'])) {
270                throw new AdapterException\RuntimeException('Unable to set the connection timeout');
271            }
272
273            if ($secure || $this->config['sslusecontext']) {
274                if ($this->config['ssltransport'] && isset(static::$sslCryptoTypes[$this->config['ssltransport']])) {
275                    $sslCryptoMethod = static::$sslCryptoTypes[$this->config['ssltransport']];
276                } else {
277                    $sslCryptoMethod = STREAM_CRYPTO_METHOD_SSLv3_CLIENT;
278                }
279
280                ErrorHandler::start();
281                $test  = stream_socket_enable_crypto($this->socket, true, $sslCryptoMethod);
282                $error = ErrorHandler::stop();
283                if (!$test || $error) {
284                    // Error handling is kind of difficult when it comes to SSL
285                    $errorString = '';
286                    if (extension_loaded('openssl')) {
287                        while (($sslError = openssl_error_string()) != false) {
288                            $errorString .= "; SSL error: $sslError";
289                        }
290                    }
291                    $this->close();
292
293                    if ((! $errorString) && $this->config['sslverifypeer']) {
294                        // There's good chance our error is due to sslcapath not being properly set
295                        if (! ($this->config['sslcafile'] || $this->config['sslcapath'])) {
296                            $errorString = 'make sure the "sslcafile" or "sslcapath" option are properly set for the environment.';
297                        } elseif ($this->config['sslcafile'] && !is_file($this->config['sslcafile'])) {
298                            $errorString = 'make sure the "sslcafile" option points to a valid SSL certificate file';
299                        } elseif ($this->config['sslcapath'] && !is_dir($this->config['sslcapath'])) {
300                            $errorString = 'make sure the "sslcapath" option points to a valid SSL certificate directory';
301                        }
302                    }
303
304                    if ($errorString) {
305                        $errorString = ": $errorString";
306                    }
307
308                    throw new AdapterException\RuntimeException(sprintf(
309                        'Unable to enable crypto on TCP connection %s%s',
310                        $host,
311                        $errorString
312                    ), 0, $error);
313                }
314
315                $host = $this->config['ssltransport'] . "://" . $host;
316            } else {
317                $host = 'tcp://' . $host;
318            }
319
320            // Update connectedTo
321            $this->connectedTo = array($host, $port);
322        }
323    }
324
325
326    /**
327     * Send request to the remote server
328     *
329     * @param string        $method
330     * @param \Zend\Uri\Uri $uri
331     * @param string        $httpVer
332     * @param array         $headers
333     * @param string        $body
334     * @throws AdapterException\RuntimeException
335     * @return string Request as string
336     */
337    public function write($method, $uri, $httpVer = '1.1', $headers = array(), $body = '')
338    {
339        // Make sure we're properly connected
340        if (! $this->socket) {
341            throw new AdapterException\RuntimeException('Trying to write but we are not connected');
342        }
343
344        $host = $uri->getHost();
345        $host = (strtolower($uri->getScheme()) == 'https' ? $this->config['ssltransport'] : 'tcp') . '://' . $host;
346        if ($this->connectedTo[0] != $host || $this->connectedTo[1] != $uri->getPort()) {
347            throw new AdapterException\RuntimeException('Trying to write but we are connected to the wrong host');
348        }
349
350        // Save request method for later
351        $this->method = $method;
352
353        // Build request headers
354        $path = $uri->getPath();
355        if ($uri->getQuery()) {
356            $path .= '?' . $uri->getQuery();
357        }
358        $request = "{$method} {$path} HTTP/{$httpVer}\r\n";
359        foreach ($headers as $k => $v) {
360            if (is_string($k)) {
361                $v = ucfirst($k) . ": $v";
362            }
363            $request .= "$v\r\n";
364        }
365
366        if (is_resource($body)) {
367            $request .= "\r\n";
368        } else {
369            // Add the request body
370            $request .= "\r\n" . $body;
371        }
372
373        // Send the request
374        ErrorHandler::start();
375        $test  = fwrite($this->socket, $request);
376        $error = ErrorHandler::stop();
377        if (false === $test) {
378            throw new AdapterException\RuntimeException('Error writing request to server', 0, $error);
379        }
380
381        if (is_resource($body)) {
382            if (stream_copy_to_stream($body, $this->socket) == 0) {
383                throw new AdapterException\RuntimeException('Error writing request to server');
384            }
385        }
386
387        return $request;
388    }
389
390    /**
391     * Read response from server
392     *
393     * @throws AdapterException\RuntimeException
394     * @return string
395     */
396    public function read()
397    {
398        // First, read headers only
399        $response = '';
400        $gotStatus = false;
401
402        while (($line = fgets($this->socket)) !== false) {
403            $gotStatus = $gotStatus || (strpos($line, 'HTTP') !== false);
404            if ($gotStatus) {
405                $response .= $line;
406                if (rtrim($line) === '') {
407                    break;
408                }
409            }
410        }
411
412        $this->_checkSocketReadTimeout();
413
414        $responseObj= Response::fromString($response);
415
416        $statusCode = $responseObj->getStatusCode();
417
418        // Handle 100 and 101 responses internally by restarting the read again
419        if ($statusCode == 100 || $statusCode == 101) {
420            return $this->read();
421        }
422
423        // Check headers to see what kind of connection / transfer encoding we have
424        $headers = $responseObj->getHeaders();
425
426        /**
427         * Responses to HEAD requests and 204 or 304 responses are not expected
428         * to have a body - stop reading here
429         */
430        if ($statusCode == 304 || $statusCode == 204 ||
431            $this->method == \Zend\Http\Request::METHOD_HEAD) {
432            // Close the connection if requested to do so by the server
433            $connection = $headers->get('connection');
434            if ($connection && $connection->getFieldValue() == 'close') {
435                $this->close();
436            }
437            return $response;
438        }
439
440        // If we got a 'transfer-encoding: chunked' header
441        $transferEncoding = $headers->get('transfer-encoding');
442        $contentLength = $headers->get('content-length');
443        if ($transferEncoding !== false) {
444            if (strtolower($transferEncoding->getFieldValue()) == 'chunked') {
445                do {
446                    $line  = fgets($this->socket);
447                    $this->_checkSocketReadTimeout();
448
449                    $chunk = $line;
450
451                    // Figure out the next chunk size
452                    $chunksize = trim($line);
453                    if (! ctype_xdigit($chunksize)) {
454                        $this->close();
455                        throw new AdapterException\RuntimeException('Invalid chunk size "' .
456                            $chunksize . '" unable to read chunked body');
457                    }
458
459                    // Convert the hexadecimal value to plain integer
460                    $chunksize = hexdec($chunksize);
461
462                    // Read next chunk
463                    $readTo = ftell($this->socket) + $chunksize;
464
465                    do {
466                        $currentPos = ftell($this->socket);
467                        if ($currentPos >= $readTo) {
468                            break;
469                        }
470
471                        if ($this->outStream) {
472                            if (stream_copy_to_stream($this->socket, $this->outStream, $readTo - $currentPos) == 0) {
473                                $this->_checkSocketReadTimeout();
474                                break;
475                            }
476                        } else {
477                            $line = fread($this->socket, $readTo - $currentPos);
478                            if ($line === false || strlen($line) === 0) {
479                                $this->_checkSocketReadTimeout();
480                                break;
481                            }
482                            $chunk .= $line;
483                        }
484                    } while (! feof($this->socket));
485
486                    ErrorHandler::start();
487                    $chunk .= fgets($this->socket);
488                    ErrorHandler::stop();
489                    $this->_checkSocketReadTimeout();
490
491                    if (!$this->outStream) {
492                        $response .= $chunk;
493                    }
494                } while ($chunksize > 0);
495            } else {
496                $this->close();
497                throw new AdapterException\RuntimeException('Cannot handle "' .
498                    $transferEncoding->getFieldValue() . '" transfer encoding');
499            }
500
501            // We automatically decode chunked-messages when writing to a stream
502            // this means we have to disallow the Zend\Http\Response to do it again
503            if ($this->outStream) {
504                $response = str_ireplace("Transfer-Encoding: chunked\r\n", '', $response);
505            }
506        // Else, if we got the content-length header, read this number of bytes
507        } elseif ($contentLength !== false) {
508            // If we got more than one Content-Length header (see ZF-9404) use
509            // the last value sent
510            if (is_array($contentLength)) {
511                $contentLength = $contentLength[count($contentLength) - 1];
512            }
513            $contentLength = $contentLength->getFieldValue();
514
515            $currentPos = ftell($this->socket);
516
517            for ($readTo = $currentPos + $contentLength;
518                 $readTo > $currentPos;
519                 $currentPos = ftell($this->socket)) {
520                if ($this->outStream) {
521                    if (stream_copy_to_stream($this->socket, $this->outStream, $readTo - $currentPos) == 0) {
522                        $this->_checkSocketReadTimeout();
523                        break;
524                    }
525                } else {
526                    $chunk = fread($this->socket, $readTo - $currentPos);
527                    if ($chunk === false || strlen($chunk) === 0) {
528                        $this->_checkSocketReadTimeout();
529                        break;
530                    }
531
532                    $response .= $chunk;
533                }
534
535                // Break if the connection ended prematurely
536                if (feof($this->socket)) {
537                    break;
538                }
539            }
540
541        // Fallback: just read the response until EOF
542        } else {
543            do {
544                if ($this->outStream) {
545                    if (stream_copy_to_stream($this->socket, $this->outStream) == 0) {
546                        $this->_checkSocketReadTimeout();
547                        break;
548                    }
549                } else {
550                    $buff = fread($this->socket, 8192);
551                    if ($buff === false || strlen($buff) === 0) {
552                        $this->_checkSocketReadTimeout();
553                        break;
554                    } else {
555                        $response .= $buff;
556                    }
557                }
558            } while (feof($this->socket) === false);
559
560            $this->close();
561        }
562
563        // Close the connection if requested to do so by the server
564        $connection = $headers->get('connection');
565        if ($connection && $connection->getFieldValue() == 'close') {
566            $this->close();
567        }
568
569        return $response;
570    }
571
572    /**
573     * Close the connection to the server
574     *
575     */
576    public function close()
577    {
578        if (is_resource($this->socket)) {
579            ErrorHandler::start();
580            fclose($this->socket);
581            ErrorHandler::stop();
582        }
583        $this->socket = null;
584        $this->connectedTo = array(null, null);
585    }
586
587    /**
588     * Check if the socket has timed out - if so close connection and throw
589     * an exception
590     *
591     * @throws AdapterException\TimeoutException with READ_TIMEOUT code
592     */
593    protected function _checkSocketReadTimeout()
594    {
595        if ($this->socket) {
596            $info = stream_get_meta_data($this->socket);
597            $timedout = $info['timed_out'];
598            if ($timedout) {
599                $this->close();
600                throw new AdapterException\TimeoutException(
601                    "Read timed out after {$this->config['timeout']} seconds",
602                    AdapterException\TimeoutException::READ_TIMEOUT
603                );
604            }
605        }
606    }
607
608    /**
609     * Set output stream for the response
610     *
611     * @param resource $stream
612     * @return \Zend\Http\Client\Adapter\Socket
613     */
614    public function setOutputStream($stream)
615    {
616        $this->outStream = $stream;
617        return $this;
618    }
619
620    /**
621     * Destructor: make sure the socket is disconnected
622     *
623     * If we are in persistent TCP mode, will not close the connection
624     *
625     */
626    public function __destruct()
627    {
628        if (! $this->config['persistent']) {
629            if ($this->socket) {
630                $this->close();
631            }
632        }
633    }
634}
635