Completed
Pull Request — master (#190)
by Ryan
05:32 queued 02:48
created

Requests_Transport_fsockopen::accept_encoding()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 14
Code Lines 8

Duplication

Lines 0
Ratio 0 %
Metric Value
dl 0
loc 14
rs 9.4285
cc 3
eloc 8
nc 4
nop 0
1
<?php
2
/**
3
 * fsockopen HTTP transport
4
 *
5
 * @package Requests
6
 * @subpackage Transport
7
 */
8
9
/**
10
 * fsockopen HTTP transport
11
 *
12
 * @package Requests
13
 * @subpackage Transport
14
 */
15
class Requests_Transport_fsockopen implements Requests_Transport {
16
	/**
17
	 * Second to microsecond conversion
18
	 *
19
	 * @var integer
20
	 */
21
	const SECOND_IN_MICROSECONDS = 1000000;
22
23
	/**
24
	 * Raw HTTP data
25
	 *
26
	 * @var string
27
	 */
28
	public $headers = '';
29
30
	/**
31
	 * Stream metadata
32
	 *
33
	 * @var array Associative array of properties, see {@see http://php.net/stream_get_meta_data}
34
	 */
35
	public $info;
36
37
	/**
38
	 * Request body to send.
39
	 *
40
	 * @var stream|string|null
41
	 */
42
	protected $request_body = null;
43
44
	/**
45
	 * What's the maximum number of bytes we should keep?
46
	 *
47
	 * @var int|bool Byte count, or false if no limit.
48
	 */
49
	protected $max_bytes = false;
50
51
	protected $connect_error = '';
52
53
	/**
54
	 * Perform a request
55
	 *
56
	 * @throws Requests_Exception On failure to connect to socket (`fsockopenerror`)
57
	 * @throws Requests_Exception On socket timeout (`timeout`)
58
	 *
59
	 * @param string $url URL to request
60
	 * @param array $headers Associative array of request headers
61
	 * @param string|array $data Data to send either as the POST body, or as parameters in the URL for a GET/HEAD
62
	 * @param array $options Request options, see {@see Requests::response()} for documentation
63
	 * @return string Raw HTTP result
64
	 */
65
	public function request($url, $headers = array(), $data = array(), $options = array()) {
66
		$options['hooks']->dispatch('fsockopen.before_request');
67
68
		$url_parts = parse_url($url);
69
		if (empty($url_parts)) {
70
			throw new Requests_Exception('Invalid URL.', 'invalidurl', $url);
71
		}
72
		$host = $url_parts['host'];
73
		$context = stream_context_create();
74
		$verifyname = false;
75
		$case_insensitive_headers = new Requests_Utility_CaseInsensitiveDictionary($headers);
76
77
		// HTTPS support
78
		if (isset($url_parts['scheme']) && strtolower($url_parts['scheme']) === 'https') {
79
			$remote_socket = 'ssl://' . $host;
80
			$url_parts['port'] = 443;
81
82
			$context_options = array(
83
				'verify_peer' => true,
84
				// 'CN_match' => $host,
85
				'capture_peer_cert' => true
86
			);
87
			$verifyname = true;
88
89
			// SNI, if enabled (OpenSSL >=0.9.8j)
90
			if (defined('OPENSSL_TLSEXT_SERVER_NAME') && OPENSSL_TLSEXT_SERVER_NAME) {
91
				$context_options['SNI_enabled'] = true;
92
				if (isset($options['verifyname']) && $options['verifyname'] === false) {
93
					$context_options['SNI_enabled'] = false;
94
				}
95
			}
96
97
			if (isset($options['verify'])) {
98
				if ($options['verify'] === false) {
99
					$context_options['verify_peer'] = false;
100
				}
101
				elseif (is_string($options['verify'])) {
102
					$context_options['cafile'] = $options['verify'];
103
				}
104
			}
105
106
			if (isset($options['verifyname']) && $options['verifyname'] === false) {
107
				$verifyname = false;
108
			}
109
110
			stream_context_set_option($context, array('ssl' => $context_options));
111
		}
112
		else {
113
			$remote_socket = 'tcp://' . $host;
114
		}
115
116
		$this->max_bytes = $options['max_bytes'];
117
118
		if (!isset($url_parts['port'])) {
119
			$url_parts['port'] = 80;
120
		}
121
		$remote_socket .= ':' . $url_parts['port'];
122
123
		set_error_handler(array($this, 'connect_error_handler'), E_WARNING | E_NOTICE);
124
125
		$options['hooks']->dispatch('fsockopen.remote_socket', array(&$remote_socket));
126
127
		$socket = stream_socket_client($remote_socket, $errno, $errstr, ceil($options['connect_timeout']), STREAM_CLIENT_CONNECT, $context);
128
129
		restore_error_handler();
130
131
		if ($verifyname && !$this->verify_certificate_from_context($host, $context)) {
132
			throw new Requests_Exception('SSL certificate did not match the requested domain name', 'ssl.no_match');
133
		}
134
135
		if (!$socket) {
136
			if ($errno === 0) {
137
				// Connection issue
138
				throw new Requests_Exception(rtrim($this->connect_error), 'fsockopen.connect_error');
139
			}
140
141
			throw new Requests_Exception($errstr, 'fsockopenerror', null, $errno);
142
		}
143
144
		$data_format = $options['data_format'];
145
146
		if ($data_format === 'query') {
147
			$path = self::format_get($url_parts, $data);
0 ignored issues
show
Bug introduced by
It seems like $data defined by parameter $data on line 65 can also be of type string; however, Requests_Transport_fsockopen::format_get() does only seem to accept array|object, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
148
			$data = null;
149
		}
150
		else {
151
			$path = self::format_get($url_parts, array());
152
		}
153
154
		$options['hooks']->dispatch('fsockopen.remote_host_path', array(&$path, $url));
155
156
		$this->request_body = '';
157
		$out = sprintf("%s %s HTTP/%.1f\r\n", $options['type'], $path, $options['protocol_version']);
158
159
		$body_headers = $this->prepare_body($data, $case_insensitive_headers, $options);
0 ignored issues
show
Bug introduced by
It seems like $data defined by parameter $data on line 65 can also be of type array; however, Requests_Transport_fsockopen::prepare_body() does only seem to accept string|resource|null, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
160
		$headers = array_merge($headers, $body_headers);
161
162
		if (!isset($case_insensitive_headers['Host'])) {
163
			$out .= sprintf('Host: %s', $url_parts['host']);
164
165
			if ($url_parts['port'] !== 80) {
166
				$out .= ':' . $url_parts['port'];
167
			}
168
			$out .= "\r\n";
169
		}
170
171
		if (!isset($case_insensitive_headers['User-Agent'])) {
172
			$out .= sprintf("User-Agent: %s\r\n", $options['useragent']);
173
		}
174
175
		$accept_encoding = $this->accept_encoding();
176
		if (!isset($case_insensitive_headers['Accept-Encoding']) && !empty($accept_encoding)) {
177
			$out .= sprintf("Accept-Encoding: %s\r\n", $accept_encoding);
178
		}
179
180
		$headers = Requests::flatten($headers);
181
182
		if (!empty($headers)) {
183
			$out .= implode($headers, "\r\n") . "\r\n";
184
		}
185
186
		$options['hooks']->dispatch('fsockopen.after_headers', array(&$out));
187
188
		if (substr($out, -2) !== "\r\n") {
189
			$out .= "\r\n";
190
		}
191
192
		if (!isset($case_insensitive_headers['Connection'])) {
193
			$out .= "Connection: Close\r\n";
194
		}
195
196
		$out .= "\r\n";
197
		if (is_string($this->request_body)) {
198
			$out .= $this->request_body;
199
		}
200
201
		$options['hooks']->dispatch('fsockopen.before_send', array(&$out));
202
203
		fwrite($socket, $out);
204
		$this->send_body($socket);
205
206
		$options['hooks']->dispatch('fsockopen.after_send', array($out));
207
208
		if (!$options['blocking']) {
209
			fclose($socket);
210
			$fake_headers = '';
211
			$options['hooks']->dispatch('fsockopen.after_request', array(&$fake_headers));
212
			return '';
213
		}
214
215
		$timeout_sec = (int) floor($options['timeout']);
216
		if ($timeout_sec == $options['timeout']) {
217
			$timeout_msec = 0;
218
		}
219
		else {
220
			$timeout_msec = self::SECOND_IN_MICROSECONDS * $options['timeout'] % self::SECOND_IN_MICROSECONDS;
221
		}
222
		stream_set_timeout($socket, $timeout_sec, $timeout_msec);
223
224
		$response = $body = $headers = '';
225
		$this->info = stream_get_meta_data($socket);
226
		$size = 0;
227
		$doingbody = false;
228
		$download = false;
229
		if ($options['filename']) {
230
			$download = fopen($options['filename'], 'wb');
231
		}
232
233
		while (!feof($socket)) {
234
			$this->info = stream_get_meta_data($socket);
235
			if ($this->info['timed_out']) {
236
				throw new Requests_Exception('fsocket timed out', 'timeout');
237
			}
238
239
			$block = fread($socket, Requests::BUFFER_SIZE);
240
			if (!$doingbody) {
241
				$response .= $block;
242
				if (strpos($response, "\r\n\r\n")) {
243
					list($headers, $block) = explode("\r\n\r\n", $response, 2);
244
					$doingbody = true;
245
				}
246
			}
247
248
			// Are we in body mode now?
249
			if ($doingbody) {
250
				$options['hooks']->dispatch('request.progress', array($block, $size, $this->max_bytes));
251
				$data_length = strlen($block);
252
				if ($this->max_bytes) {
253
					// Have we already hit a limit?
254
					if ($size === $this->max_bytes) {
255
						continue;
256
					}
257
					if (($size + $data_length) > $this->max_bytes) {
258
						// Limit the length
259
						$limited_length = ($this->max_bytes - $size);
260
						$block = substr($block, 0, $limited_length);
261
					}
262
				}
263
264
				$size += strlen($block);
265
				if ($download) {
266
					fwrite($download, $block);
267
				}
268
				else {
269
					$body .= $block;
270
				}
271
			}
272
		}
273
		$this->headers = $headers;
274
275
		if ($download) {
276
			fclose($download);
277
		}
278
		else {
279
			$this->headers .= "\r\n\r\n" . $body;
280
		}
281
		fclose($socket);
282
283
		$options['hooks']->dispatch('fsockopen.after_request', array(&$this->headers));
284
		return $this->headers;
285
	}
286
287
	/**
288
	 * Send multiple requests simultaneously
289
	 *
290
	 * @param array $requests Request data (array of 'url', 'headers', 'data', 'options') as per {@see Requests_Transport::request}
291
	 * @param array $options Global options, see {@see Requests::response()} for documentation
292
	 * @return array Array of Requests_Response objects (may contain Requests_Exception or string responses as well)
293
	 */
294
	public function request_multiple($requests, $options) {
295
		$responses = array();
296
		$class = get_class($this);
297
		foreach ($requests as $id => $request) {
298
			try {
299
				$handler = new $class();
300
				$responses[$id] = $handler->request($request['url'], $request['headers'], $request['data'], $request['options']);
301
302
				$request['options']['hooks']->dispatch('transport.internal.parse_response', array(&$responses[$id], $request));
303
			}
304
			catch (Requests_Exception $e) {
305
				$responses[$id] = $e;
306
			}
307
308 View Code Duplication
			if (!is_string($responses[$id])) {
309
				$request['options']['hooks']->dispatch('multiple.request.complete', array(&$responses[$id], $id));
310
			}
311
		}
312
313
		return $responses;
314
	}
315
316
	/**
317
	 * Prepare the body data to send.
318
	 *
319
	 * @param string|resource|null $data Data as a string, stream resource, or null.
320
	 * @param array|Requests_Utility_CaseInsensitiveDictionary $headers Headers set on the request.
321
	 * @param array $options Options set on the request.
322
	 * @return array Extra headers to add to the request.
323
	 */
324
	protected function prepare_body($data, $headers, $options) {
325
		if (empty($data)) {
326
			return array();
327
		}
328
329
		$body_headers = array();
330
		if ($options['type'] !== Requests::TRACE) {
331
			if (is_array($data)) {
332
				$this->request_body = http_build_query($data, null, '&');
333
				$length = strlen($this->request_body);
334
			}
335
			elseif (is_resource($data)) {
336
				$this->request_body = $data;
0 ignored issues
show
Documentation Bug introduced by
It seems like $data of type resource is incompatible with the declared type object<stream>|string|null of property $request_body.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
337
				$stat = fstat($data);
338
				if (!$stat) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $stat of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
339
					throw new Requests_Exception('Body stream resource does not support stat.', 'requests.stream_no_stat', $stat);
340
				}
341
				$length = $stat['size'];
342
			}
343
			else {
344
				$this->request_body = $data;
0 ignored issues
show
Documentation Bug introduced by
It seems like $data can also be of type resource. However, the property $request_body is declared as type object<stream>|string|null. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
345
				$length = strlen($this->request_body);
346
			}
347
348
			if (!empty($data)) {
349
				if (!isset($headers['Content-Length'])) {
350
					$body_headers['Content-Length'] = $length;
351
				}
352
353
				if (!isset($headers['Content-Type'])) {
354
					$body_headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
355
				}
356
			}
357
		}
358
359
		return $body_headers;
360
	}
361
362
	/**
363
	 * Send body data with the request.
364
	 *
365
	 * @param resource $stream Remote socket for the server.
366
	 */
367
	protected function send_body($stream) {
368
		if (!is_resource($this->request_body)) {
369
			// Already sent
370
			return;
371
		}
372
373
		while (!feof($this->request_body)) {
374
			$bytes = fread($this->request_body, Requests::BUFFER_SIZE);
375
			fwrite($stream, $bytes);
376
		}
377
	}
378
379
	/**
380
	 * Retrieve the encodings we can accept
381
	 *
382
	 * @return string Accept-Encoding header value
383
	 */
384
	protected static function accept_encoding() {
385
		$type = array();
386
		if (function_exists('gzinflate')) {
387
			$type[] = 'deflate;q=1.0';
388
		}
389
390
		if (function_exists('gzuncompress')) {
391
			$type[] = 'compress;q=0.5';
392
		}
393
394
		$type[] = 'gzip;q=0.5';
395
396
		return implode(', ', $type);
397
	}
398
399
	/**
400
	 * Format a URL given GET data
401
	 *
402
	 * @param array $url_parts
403
	 * @param array|object $data Data to build query using, see {@see http://php.net/http_build_query}
404
	 * @return string URL with data
405
	 */
406
	protected static function format_get($url_parts, $data) {
407
		if (!empty($data)) {
408
			if (empty($url_parts['query'])) {
409
				$url_parts['query'] = '';
410
			}
411
412
			$url_parts['query'] .= '&' . http_build_query($data, null, '&');
413
			$url_parts['query'] = trim($url_parts['query'], '&');
414
		}
415
		if (isset($url_parts['path'])) {
416
			if (isset($url_parts['query'])) {
417
				$get = $url_parts['path'] . '?' . $url_parts['query'];
418
			}
419
			else {
420
				$get = $url_parts['path'];
421
			}
422
		}
423
		else {
424
			$get = '/';
425
		}
426
		return $get;
427
	}
428
429
	/**
430
	 * Error handler for stream_socket_client()
431
	 *
432
	 * @param int $errno Error number (e.g. E_WARNING)
433
	 * @param string $errstr Error message
434
	 */
435
	public function connect_error_handler($errno, $errstr) {
436
		// Double-check we can handle it
437
		if (($errno & E_WARNING) === 0 && ($errno & E_NOTICE) === 0) {
438
			// Return false to indicate the default error handler should engage
439
			return false;
440
		}
441
442
		$this->connect_error .= $errstr . "\n";
443
		return true;
444
	}
445
446
	/**
447
	 * Verify the certificate against common name and subject alternative names
448
	 *
449
	 * Unfortunately, PHP doesn't check the certificate against the alternative
450
	 * names, leading things like 'https://www.github.com/' to be invalid.
451
	 * Instead
452
	 *
453
	 * @see http://tools.ietf.org/html/rfc2818#section-3.1 RFC2818, Section 3.1
454
	 *
455
	 * @throws Requests_Exception On failure to connect via TLS (`fsockopen.ssl.connect_error`)
456
	 * @throws Requests_Exception On not obtaining a match for the host (`fsockopen.ssl.no_match`)
457
	 * @param string $host Host name to verify against
458
	 * @param resource $context Stream context
459
	 * @return bool
460
	 */
461
	public function verify_certificate_from_context($host, $context) {
462
		$meta = stream_context_get_options($context);
463
464
		// If we don't have SSL options, then we couldn't make the connection at
465
		// all
466
		if (empty($meta) || empty($meta['ssl']) || empty($meta['ssl']['peer_certificate'])) {
467
			throw new Requests_Exception(rtrim($this->connect_error), 'ssl.connect_error');
468
		}
469
470
		$cert = openssl_x509_parse($meta['ssl']['peer_certificate']);
471
472
		return Requests_SSL::verify_certificate($host, $cert);
473
	}
474
475
	/**
476
	 * Whether this transport is valid
477
	 *
478
	 * @codeCoverageIgnore
479
	 * @return boolean True if the transport is valid, false otherwise.
480
	 */
481
	public static function test($capabilities = array()) {
482
		if (!function_exists('fsockopen')) {
483
			return false;
484
		}
485
486
		// If needed, check that streams support SSL
487
		if (isset($capabilities['ssl']) && $capabilities['ssl']) {
488
			if (!extension_loaded('openssl') || !function_exists('openssl_x509_parse')) {
489
				return false;
490
			}
491
492
			// Currently broken, thanks to https://github.com/facebook/hhvm/issues/2156
493
			if (defined('HHVM_VERSION')) {
494
				return false;
495
			}
496
		}
497
498
		return true;
499
	}
500
}
501