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