Passed
Push — master ( beaa4b...da3f2a )
by Shahrad
02:02
created

WscMain::read()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 27
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 17
nc 4
nop 1
dl 0
loc 27
c 1
b 0
f 0
cc 4
rs 9.7
1
<?php
2
3
namespace EasyHttp\Utils;
4
5
use EasyHttp\Contracts\CommonsContract;
6
use EasyHttp\Contracts\WscCommonsContract;
7
use EasyHttp\Exceptions\BadOpcodeException;
8
use EasyHttp\Exceptions\ConnectionException;
9
use EasyHttp\Traits\WSClientTrait;
10
use InvalidArgumentException;
11
12
/**
13
 * WscMain class
14
 *
15
 * @link    https://github.com/shahradelahi/easy-http
16
 * @author  Arthur Kushman (https://github.com/arthurkushman)
17
 * @license https://github.com/shahradelahi/easy-http/blob/master/LICENSE (MIT License)
18
 */
19
class WscMain implements WscCommonsContract
20
{
21
22
	use WSClientTrait;
23
24
	/**
25
	 * @var resource|bool
26
	 */
27
	private $socket;
28
29
	/**
30
	 * @var bool
31
	 */
32
	private bool $isConnected = false;
33
34
	/**
35
	 * @var bool
36
	 */
37
	private bool $isClosing = false;
38
39
	/**
40
	 * @var string
41
	 */
42
	private string $lastOpcode;
43
44
	/**
45
	 * @var float|int
46
	 */
47
	private float|int $closeStatus;
48
49
	/**
50
	 * @var string|null
51
	 */
52
	private ?string $hugePayload;
53
54
	/**
55
	 * @var array|int[]
56
	 */
57
	private static array $opcodes = [
58
		CommonsContract::EVENT_TYPE_CONTINUATION => 0,
59
		CommonsContract::EVENT_TYPE_TEXT => 1,
60
		CommonsContract::EVENT_TYPE_BINARY => 2,
61
		CommonsContract::EVENT_TYPE_CLOSE => 8,
62
		CommonsContract::EVENT_TYPE_PING => 9,
63
		CommonsContract::EVENT_TYPE_PONG => 10,
64
	];
65
66
	/**
67
	 * @var string
68
	 */
69
	protected string $socketUrl = '';
70
71
	/**
72
	 * @var WSConfig
73
	 */
74
	protected WSConfig $config;
75
76
	/**
77
	 * @param WSConfig $config
78
	 * @throws \Exception
79
	 */
80
	protected function connect(WSConfig $config): void
81
	{
82
		$this->config = $config;
83
		$urlParts = parse_url($this->socketUrl);
84
85
		$this->config->setScheme($urlParts['scheme']);
86
		$this->config->setHost($urlParts['host']);
87
		$this->config->setUser($urlParts);
88
		$this->config->setPassword($urlParts);
89
		$this->config->setPort($urlParts);
90
91
		$pathWithQuery = $this->getPathWithQuery($urlParts);
92
		$hostUri = $this->getHostUri($this->config);
93
94
		$context = $this->getStreamContext();
95
		if ($this->config->hasProxy()) {
96
			$this->socket = $this->proxy();
97
		} else {
98
			$this->socket = @stream_socket_client(
99
				$hostUri . ':' . $this->config->getPort(),
100
				$errno,
101
				$errstr,
102
				$this->config->getTimeout(),
103
				STREAM_CLIENT_CONNECT,
104
				$context
105
			);
106
		}
107
108
		if ($this->socket === false) {
109
			throw new ConnectionException(
110
				"Could not open socket to \"{$this->config->getHost()}:{$this->config->getPort()}\": $errstr ($errno).",
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $errstr does not seem to be defined for all execution paths leading up to this point.
Loading history...
Comprehensibility Best Practice introduced by
The variable $errno does not seem to be defined for all execution paths leading up to this point.
Loading history...
111
				CommonsContract::CLIENT_COULD_NOT_OPEN_SOCKET
112
			);
113
		}
114
115
		stream_set_timeout($this->socket, $this->config->getTimeout());
116
117
		$key = $this->generateKey();
118
		$headers = [
119
			'Host' => $this->config->getHost() . ':' . $this->config->getPort(),
120
			'User-Agent' => 'websocket-client-php',
121
			'Connection' => 'Upgrade',
122
			'Upgrade' => 'WebSocket',
123
			'Sec-WebSocket-Key' => $key,
124
			'Sec-Websocket-Version' => '13',
125
		];
126
127
		if ($this->config->getUser() || $this->config->getPassword()) {
128
			$headers['authorization'] = 'Basic ' . base64_encode($this->config->getUser() . ':' . $this->config->getPassword()) . "\r\n";
129
		}
130
131
		if (!empty($this->config->getHeaders())) {
132
			$headers = array_merge($headers, $this->config->getHeaders());
133
		}
134
135
		$header = $this->getHeaders($pathWithQuery, $headers);
136
137
		$this->write($header);
138
139
		$this->validateResponse($this->config, $pathWithQuery, $key);
140
		$this->isConnected = true;
141
	}
142
143
	/**
144
	 * Init a proxy connection
145
	 *
146
	 * @return resource|false
147
	 * @throws InvalidArgumentException
148
	 * @throws ConnectionException
149
	 */
150
	private function proxy()
151
	{
152
		$sock = @stream_socket_client(
153
			WscCommonsContract::TCP_SCHEME . $this->config->getProxyIp() . ':' . $this->config->getProxyPort(),
154
			$errno,
155
			$errstr,
156
			$this->config->getTimeout(),
157
			STREAM_CLIENT_CONNECT,
158
			$this->getStreamContext()
159
		);
160
		$write = "CONNECT {$this->config->getProxyIp()}:{$this->config->getProxyPort()} HTTP/1.1\r\n";
161
		$auth = $this->config->getProxyAuth();
162
		if ($auth !== NULL) {
163
			$write .= "Proxy-Authorization: Basic {$auth}\r\n";
164
		}
165
		$write .= "\r\n";
166
		fwrite($sock, $write);
0 ignored issues
show
Bug introduced by
It seems like $sock can also be of type false; however, parameter $stream of fwrite() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

166
		fwrite(/** @scrutinizer ignore-type */ $sock, $write);
Loading history...
167
		$resp = fread($sock, 1024);
0 ignored issues
show
Bug introduced by
It seems like $sock can also be of type false; however, parameter $stream of fread() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

167
		$resp = fread(/** @scrutinizer ignore-type */ $sock, 1024);
Loading history...
168
169
		if (preg_match(self::PROXY_MATCH_RESP, $resp) === 1) {
170
			return $sock;
171
		}
172
173
		throw new ConnectionException('Failed to connect to the host via proxy');
174
	}
175
176
	/**
177
	 * @return mixed
178
	 * @throws \InvalidArgumentException
179
	 */
180
	private function getStreamContext(): mixed
181
	{
182
		if ($this->config->getContext() !== null) {
183
			// Suppress the error since we'll catch it below
184
			if (@get_resource_type($this->config->getContext()) === 'stream-context') {
185
				return $this->config->getContext();
186
			}
187
188
			throw new \InvalidArgumentException(
189
				'Stream context is invalid',
190
				CommonsContract::CLIENT_INVALID_STREAM_CONTEXT
191
			);
192
		}
193
194
		return stream_context_create($this->config->getContextOptions());
195
	}
196
197
	/**
198
	 * @param mixed $urlParts
199
	 * @return string
200
	 */
201
	private function getPathWithQuery(mixed $urlParts): string
202
	{
203
		$path = isset($urlParts['path']) ? $urlParts['path'] : '/';
204
		$query = isset($urlParts['query']) ? $urlParts['query'] : '';
205
		$fragment = isset($urlParts['fragment']) ? $urlParts['fragment'] : '';
206
		$pathWithQuery = $path;
207
		if (!empty($query)) {
208
			$pathWithQuery .= '?' . $query;
209
		}
210
		if (!empty($fragment)) {
211
			$pathWithQuery .= '#' . $fragment;
212
		}
213
214
		return $pathWithQuery;
215
	}
216
217
	/**
218
	 * @param string $pathWithQuery
219
	 * @param array $headers
220
	 * @return string
221
	 */
222
	private function getHeaders(string $pathWithQuery, array $headers): string
223
	{
224
		return 'GET ' . $pathWithQuery . " HTTP/1.1\r\n"
225
			. implode(
226
				"\r\n",
227
				array_map(
228
					function ($key, $value) {
229
						return "$key: $value";
230
					},
231
					array_keys($headers),
232
					$headers
233
				)
234
			)
235
			. "\r\n\r\n";
236
	}
237
238
	/**
239
	 * @return string
240
	 */
241
	public function getLastOpcode(): string
242
	{
243
		return $this->lastOpcode;
244
	}
245
246
	/**
247
	 * @return int
248
	 */
249
	public function getCloseStatus(): int
250
	{
251
		return $this->closeStatus;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->closeStatus could return the type double which is incompatible with the type-hinted return integer. Consider adding an additional type-check to rule them out.
Loading history...
252
	}
253
254
	/**
255
	 * @return bool
256
	 */
257
	public function isConnected(): bool
258
	{
259
		return $this->isConnected;
260
	}
261
262
	/**
263
	 * @param int $timeout
264
	 * @param null $microSecs
0 ignored issues
show
Documentation Bug introduced by
Are you sure the doc-type for parameter $microSecs is correct as it would always require null to be passed?
Loading history...
265
	 * @return WscMain
266
	 */
267
	public function setTimeout(int $timeout, $microSecs = null): WscMain
268
	{
269
		$this->config->setTimeout($timeout);
270
		if ($this->socket && get_resource_type($this->socket) === 'stream') {
0 ignored issues
show
Bug introduced by
It seems like $this->socket can also be of type true; however, parameter $resource of get_resource_type() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

270
		if ($this->socket && get_resource_type(/** @scrutinizer ignore-type */ $this->socket) === 'stream') {
Loading history...
271
			stream_set_timeout($this->socket, $timeout, $microSecs);
0 ignored issues
show
Bug introduced by
It seems like $this->socket can also be of type true; however, parameter $stream of stream_set_timeout() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

271
			stream_set_timeout(/** @scrutinizer ignore-type */ $this->socket, $timeout, $microSecs);
Loading history...
Bug introduced by
$microSecs of type null is incompatible with the type integer expected by parameter $microseconds of stream_set_timeout(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

271
			stream_set_timeout($this->socket, $timeout, /** @scrutinizer ignore-type */ $microSecs);
Loading history...
272
		}
273
274
		return $this;
275
	}
276
277
	/**
278
	 * Sends message to opened socket connection client->server
279
	 *
280
	 * @param $payload
281
	 * @param string $opcode
282
	 * @throws \Exception
283
	 */
284
	public function send($payload, string $opcode = CommonsContract::EVENT_TYPE_TEXT): void
285
	{
286
		if (!$this->isConnected) {
287
			$this->connect(new WSConfig());
288
		}
289
290
		if (array_key_exists($opcode, self::$opcodes) === false) {
291
			throw new BadOpcodeException(
292
				"Bad opcode '$opcode'.  Try 'text' or 'binary'.",
293
				CommonsContract::CLIENT_BAD_OPCODE
294
			);
295
		}
296
297
		$payloadLength = strlen($payload);
298
		$fragmentCursor = 0;
299
300
		while ($payloadLength > $fragmentCursor) {
301
			$subPayload = substr($payload, $fragmentCursor, $this->config->getFragmentSize());
302
			$fragmentCursor += $this->config->getFragmentSize();
303
			$final = $payloadLength <= $fragmentCursor;
304
			$this->sendFragment($final, $subPayload, $opcode, true);
305
			$opcode = 'continuation';
306
		}
307
	}
308
309
	/**
310
	 * Receives message client<-server
311
	 *
312
	 * @return null|string
313
	 * @throws \Exception
314
	 */
315
	public function receive(): ?string
316
	{
317
		if (!$this->isConnected) {
318
			$this->connect(new WSConfig());
319
		}
320
321
		$this->hugePayload = '';
322
323
		$response = null;
324
		while ($response === null) {
325
			$response = $this->receiveFragment();
326
		}
327
328
		return $response;
329
	}
330
331
	/**
332
	 * Tell the socket to close.
333
	 *
334
	 * @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4
335
	 * @param string $message A closing message, max 125 bytes.
336
	 * @return bool|null|string
337
	 * @throws \Exception
338
	 */
339
	public function close(int $status = 1000, string $message = 'ttfn'): bool|null|string
340
	{
341
		$statusBin = sprintf('%016b', $status);
342
		$statusStr = '';
343
344
		foreach (str_split($statusBin, 8) as $binstr) {
345
			$statusStr .= chr(bindec($binstr));
0 ignored issues
show
Bug introduced by
It seems like bindec($binstr) can also be of type double; however, parameter $codepoint of chr() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

345
			$statusStr .= chr(/** @scrutinizer ignore-type */ bindec($binstr));
Loading history...
346
		}
347
348
		$this->send($statusStr . $message, CommonsContract::EVENT_TYPE_CLOSE);
349
		$this->isClosing = true;
350
351
		return $this->receive(); // Receiving a close frame will close the socket now.
352
	}
353
354
	/**
355
	 * @param string $data
356
	 * @throws ConnectionException
357
	 */
358
	protected function write(string $data): void
359
	{
360
		$written = fwrite($this->socket, $data);
0 ignored issues
show
Bug introduced by
It seems like $this->socket can also be of type boolean; however, parameter $stream of fwrite() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

360
		$written = fwrite(/** @scrutinizer ignore-type */ $this->socket, $data);
Loading history...
361
362
		if ($written < strlen($data)) {
363
			throw new ConnectionException(
364
				"Could only write $written out of " . strlen($data) . ' bytes.',
365
				CommonsContract::CLIENT_COULD_ONLY_WRITE_LESS
366
			);
367
		}
368
	}
369
370
	/**
371
	 * @param int $len
372
	 * @return string
373
	 * @throws ConnectionException
374
	 */
375
	protected function read(int $len): string
376
	{
377
		$data = '';
378
		while (($dataLen = strlen($data)) < $len) {
379
			$buff = fread($this->socket, $len - $dataLen);
0 ignored issues
show
Bug introduced by
It seems like $this->socket can also be of type boolean; however, parameter $stream of fread() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

379
			$buff = fread(/** @scrutinizer ignore-type */ $this->socket, $len - $dataLen);
Loading history...
380
381
			if ($buff === false) {
382
				$metadata = stream_get_meta_data($this->socket);
0 ignored issues
show
Bug introduced by
It seems like $this->socket can also be of type boolean; however, parameter $stream of stream_get_meta_data() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

382
				$metadata = stream_get_meta_data(/** @scrutinizer ignore-type */ $this->socket);
Loading history...
383
				throw new ConnectionException(
384
					'Broken frame, read ' . strlen($data) . ' of stated '
385
					. $len . ' bytes.  Stream state: '
386
					. json_encode($metadata),
387
					CommonsContract::CLIENT_BROKEN_FRAME
388
				);
389
			}
390
391
			if ($buff === '') {
392
				$metadata = stream_get_meta_data($this->socket);
393
				throw new ConnectionException(
394
					'Empty read; connection dead?  Stream state: ' . json_encode($metadata),
395
					CommonsContract::CLIENT_EMPTY_READ
396
				);
397
			}
398
			$data .= $buff;
399
		}
400
401
		return $data;
402
	}
403
404
	/**
405
	 * Helper to convert a binary to a string of '0' and '1'.
406
	 *
407
	 * @param string $string
408
	 * @return string
409
	 */
410
	protected static function sprintB(string $string): string
411
	{
412
		$return = '';
413
		$strLen = strlen($string);
414
		for ($i = 0; $i < $strLen; $i++) {
415
			$return .= sprintf('%08b', ord($string[$i]));
416
		}
417
418
		return $return;
419
	}
420
421
	/**
422
	 * Sec-WebSocket-Key generator
423
	 *
424
	 * @return string   the 16 character length key
425
	 * @throws \Exception
426
	 */
427
	private function generateKey(): string
428
	{
429
		$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"$&/()=[]{}0123456789';
430
		$key = '';
431
		$chLen = strlen($chars);
432
		for ($i = 0; $i < self::KEY_GEN_LENGTH; $i++) {
433
			$key .= $chars[random_int(0, $chLen - 1)];
434
		}
435
436
		return base64_encode($key);
437
	}
438
439
}
440