Passed
Push — master ( 856d07...dc6ba4 )
by Adam
07:19
created

Client::connect()   B

Complexity

Conditions 2
Paths 2

Size

Total Lines 25
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 0
Metric Value
dl 0
loc 25
ccs 0
cts 11
cp 0
rs 8.8571
c 0
b 0
f 0
cc 2
eloc 12
nc 2
nop 0
crap 6
1
<?php
2
/**
3
 * Client.php
4
 *
5
 * @copyright      More in license.md
6
 * @license        https://www.ipublikuj.eu
7
 * @author         Adam Kadlec <[email protected]>
8
 * @package        iPublikuj:WebSocketsWAMPClient!
9
 * @subpackage     Client
10
 * @since          1.0.0
11
 *
12
 * @date           11.05.18
13
 */
14
15
declare(strict_types = 1);
16
17
namespace IPub\WebSocketsWAMPClient\Client;
18
19
use Nette;
20
use Nette\Utils;
21
22
use React\EventLoop;
23
use React\Stream;
24
25
use IPub\WebSocketsWAMPClient\Exceptions;
26
27
/**
28
 * @method void onConnect()
29
 * @method void onOpen(array $data)
30
 * @method void onEvent(string $topic, string $event)
31
 */
32 1
class Client implements IClient
33
{
34
	/**
35
	 * Implement nette smart magic
36
	 */
37 1
	use Nette\SmartObject;
38
39
	const VERSION = '0.1.0';
40
41
	const TYPE_ID_WELCOME = 0;
42
	const TYPE_ID_PREFIX = 1;
43
	const TYPE_ID_CALL = 2;
44
	const TYPE_ID_CALL_RESULT = 3;
45
	const TYPE_ID_ERROR = 4;
46
	const TYPE_ID_SUBSCRIBE = 5;
47
	const TYPE_ID_UNSUBSCRIBE = 6;
48
	const TYPE_ID_PUBLISH = 7;
49
	const TYPE_ID_EVENT = 8;
50
51
	/**
52
	 * @var \Closure
53
	 */
54
	public $onConnect = [];
55
56
	/**
57
	 * @var \Closure
58
	 */
59
	public $onOpen = [];
60
61
	/**
62
	 * @var \Closure
63
	 */
64
	public $onEvent = [];
65
66
	/**
67
	 * @var EventLoop\LoopInterface
68
	 */
69
	private $loop;
70
71
	/**
72
	 * @var Configuration
73
	 */
74
	private $configuration;
75
76
	/**
77
	 * @var Stream\DuplexResourceStream
78
	 */
79
	private $stream;
80
81
	/**
82
	 * @var bool
83
	 */
84
	private $connected = FALSE;
85
86
	/**
87
	 * @var array
88
	 */
89
	private $callbacks = [];
90
91
	/**
92
	 * @param EventLoop\LoopInterface $loop
93
	 * @param Configuration $configuration
94
	 */
95
	function __construct(EventLoop\LoopInterface $loop, Configuration $configuration)
0 ignored issues
show
Best Practice introduced by
It is generally recommended to explicitly declare the visibility for methods.

Adding explicit visibility (private, protected, or public) is generally recommend to communicate to other developers how, and from where this method is intended to be used.

Loading history...
96
	{
97 1
		$this->loop = $loop;
98 1
		$this->configuration = $configuration;
99 1
	}
100
101
	/**
102
	 * Disconnect on destruct
103
	 */
104
	function __destruct()
0 ignored issues
show
Best Practice introduced by
It is generally recommended to explicitly declare the visibility for methods.

Adding explicit visibility (private, protected, or public) is generally recommend to communicate to other developers how, and from where this method is intended to be used.

Loading history...
105
	{
106 1
		$this->disconnect();
107 1
	}
108
109
	/**
110
	 * {@inheritdoc}
111
	 */
112
	public function setLoop(EventLoop\LoopInterface $loop) : void
113
	{
114
		$this->loop = $loop;
115
	}
116
117
	/**
118
	 * {@inheritdoc}
119
	 */
120
	public function getLoop() : EventLoop\LoopInterface
121
	{
122
		return $this->loop;
123
	}
124
125
	/**
126
	 * {@inheritdoc}
127
	 */
128
	public function connect() : void
129
	{
130
		$resource = @stream_socket_client('tcp://' . $this->configuration->getHost() . ':' . $this->configuration->getPort());
131
132
		if (!$resource) {
133
			throw new Exceptions\ConnectionException('Opening socket failed.');
134
		}
135
136
		$this->stream = new Stream\DuplexResourceStream($resource, $this->getLoop());
137
138
		$this->stream->on('data', function ($chunk) : void {
139
			$data = $this->parseChunk($chunk);
140
141
			$this->parseData($data);
142
		});
143
144
		$this->stream->on('close', function () : void {
145
			// When connection is closed, stop loop & end running script
146
			$this->loop->stop();
147
		});
148
149
		$this->stream->write($this->createHeader());
150
151
		$this->onConnect();
152
	}
153
154
	/**
155
	 * {@inheritdoc}
156
	 */
157
	public function disconnect() : void
158
	{
159 1
		$this->connected = FALSE;
160
161 1
		if ($this->stream instanceof Stream\DuplexStreamInterface) {
0 ignored issues
show
Bug introduced by
The class React\Stream\DuplexStreamInterface does not exist. Did you forget a USE statement, or did you not list all dependencies?

This error could be the result of:

1. Missing dependencies

PHP Analyzer uses your composer.json file (if available) to determine the dependencies of your project and to determine all the available classes and functions. It expects the composer.json to be in the root folder of your repository.

Are you sure this class is defined by one of your dependencies, or did you maybe not list a dependency in either the require or require-dev section?

2. Missing use statement

PHP does not complain about undefined classes in ìnstanceof checks. For example, the following PHP code will work perfectly fine:

if ($x instanceof DoesNotExist) {
    // Do something.
}

If you have not tested against this specific condition, such errors might go unnoticed.

Loading history...
162
			$this->stream->close();
163
		}
164 1
	}
165
166
	/**
167
	 * {@inheritdoc}
168
	 */
169
	public function isConnected() : bool
170
	{
171
		return $this->connected;
172
	}
173
174
	/**
175
	 * {@inheritdoc}
176
	 */
177
	public function publish(string $topicUri, string $event, array $exclude = [], array $eligible = []) : void
178
	{
179
		$this->sendData([
180
			self::TYPE_ID_PUBLISH,
181
			$topicUri,
182
			$event,
183
			$exclude,
184
			$eligible
185
		]);
186
	}
187
188
	/**
189
	 * {@inheritdoc}
190
	 */
191
	public function subscribe(string $topicUri) : void
192
	{
193
		$this->sendData([
194
			self::TYPE_ID_SUBSCRIBE,
195
			$topicUri
196
		]);
197
	}
198
199
	/**
200
	 * {@inheritdoc}
201
	 */
202
	public function unsubscribe(string $topicUri) : void
203
	{
204
		$this->sendData([
205
			self::TYPE_ID_UNSUBSCRIBE,
206
			$topicUri
207
		]);
208
	}
209
210
	/**
211
	 * {@inheritdoc}
212
	 */
213
	public function call(string $processUri, array $args, callable $callback = NULL) : void
214
	{
215
		$callId = $this->generateAlphaNumToken(16);
216
217
		$this->callbacks[$callId] = $callback;
218
219
		$data = [
220
			self::TYPE_ID_CALL,
221
			$callId,
222
			$processUri,
223
			$args,
224
		];
225
226
		$this->sendData($data);
227
	}
228
229
	/**
230
	 * @param mixed $data
231
	 * @param array $header
232
	 *
233
	 * @return void
234
	 */
235
	private function receiveData($data, array $header) : void
236
	{
237
		if (!$this->isConnected()) {
238
			$this->disconnect();
239
240
			return;
241
		}
242
243
		if (isset($data[0])) {
244
			switch ($data[0]) {
245
				case self::TYPE_ID_WELCOME:
246
					$this->onOpen($data);
247
					break;
248
249
				case self::TYPE_ID_CALL_RESULT:
250
					if (isset($data[1])) {
251
						$id = $data[1];
252
253
						if (isset($this->callbacks[$id])) {
254
							$callback = $this->callbacks[$id];
255
							$callback((isset($data[2]) ? $data[2] : []));
256
						}
257
					}
258
					break;
259
260
				case self::TYPE_ID_EVENT:
261
					if (isset($data[1]) && isset($data[2])) {
262
						$this->onEvent($data[1], $data[2]);
263
					}
264
					break;
265
			}
266
		}
267
	}
268
269
	/**
270
	 * @param array $data
271
	 * @param string $type
272
	 * @param bool $masked
273
	 *
274
	 * @return void
275
	 *
276
	 * @throws Utils\JsonException
277
	 */
278
	private function sendData(array $data, string $type = 'text', bool $masked = TRUE) : void
279
	{
280
		if (!$this->isConnected()) {
281
			$this->disconnect();
282
283
			return;
284
		}
285
286
		$this->stream->write($this->hybi10Encode(Utils\Json::encode($data), $type, $masked));
287
	}
288
289
	/**
290
	 * Parse received data
291
	 *
292
	 * @param array $response
293
	 *
294
	 * @return void
295
	 *
296
	 * @throws Utils\JsonException
297
	 */
298
	private function parseData(array $response) : void
299
	{
300
		if (!$this->connected && isset($response['Sec-Websocket-Accept'])) {
301
			if (base64_encode(pack('H*', sha1($this->configuration->getKey() . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'))) === $response['Sec-Websocket-Accept']) {
302
				$this->connected = TRUE;
303
			}
304
		}
305
306
		if ($this->connected && !empty($response['content'])) {
307
			$content = trim($response['content']);
308
309
			if (preg_match('/(\[.*\])/', $content, $match)) {
310
				// Fixing weird state when content is sometimes duplicated
311
				$contentFields = explode(' ', preg_replace('/\x81\x1b+/', ' ', $match[1]));
312
313
				$content = Utils\Json::decode($contentFields[0], Utils\Json::FORCE_ARRAY);
314
315
				if (is_array($content)) {
316
					unset($response['status']);
317
					unset($response['content']);
318
319
					$this->receiveData($content, $response);
320
				}
321
			}
322
		}
323
	}
324
325
	/**
326
	 * Create header for web socket client
327
	 *
328
	 * @return string
329
	 */
330
	private function createHeader() : string
331
	{
332
		$host = $this->configuration->getHost();
333
334
		if ($host === '127.0.0.1' || $host === '0.0.0.0') {
335
			$host = 'localhost';
336
		}
337
338
		$origin = $this->configuration->getOrigin() ? $this->configuration->getOrigin() : 'null';
339
340
		return
341
			"GET {$this->configuration->getPath()} HTTP/1.1" . "\r\n" .
342
			"Origin: {$origin}" . "\r\n" .
343
			"Host: {$host}:{$this->configuration->getPort()}" . "\r\n" .
344
			"Sec-WebSocket-Key: {$this->configuration->getKey()}" . "\r\n" .
345
			"User-Agent: IPubWebSocketClient/" . self::VERSION . "\r\n" .
346
			"Upgrade: websocket" . "\r\n" .
347
			"Connection: Upgrade" . "\r\n" .
348
			"Sec-WebSocket-Protocol: wamp" . "\r\n" .
349
			"Sec-WebSocket-Version: 13" . "\r\n" . "\r\n";
350
	}
351
352
	/**
353
	 * Parse raw incoming data
354
	 *
355
	 * @param string $header
356
	 *
357
	 * @return mixed[]
358
	 */
359
	private function parseChunk(string $header) : array
360
	{
361
		$parsed = [];
362
363
		$content = '';
364
365
		$fields = explode("\r\n", preg_replace('/\x0D\x0A[\x09\x20]+/', ' ', $header));
366
367
		foreach ($fields as $field) {
368
			if (preg_match('/([^:]+): (.+)/m', $field, $match)) {
369
				$match[1] = preg_replace_callback('/(?<=^|[\x09\x20\x2D])./', function ($matches) {
370
					return strtoupper($matches[0]);
371
				}, strtolower(trim($match[1])));
372
373
				if (isset($parsed[$match[1]])) {
374
					$parsed[$match[1]] = [$parsed[$match[1]], $match[2]];
375
376
				} else {
377
					$parsed[$match[1]] = trim($match[2]);
378
				}
379
380
			} elseif (preg_match('!HTTP/1\.\d (\d)* .!', $field)) {
381
				$parsed['status'] = $field;
382
383
			} else {
384
				$content .= $field . "\r\n";
385
			}
386
		}
387
388
		$parsed['content'] = $content;
389
390
		return $parsed;
391
	}
392
393
	/**
394
	 * Generate token
395
	 *
396
	 * @param int $length
397
	 *
398
	 * @return string
399
	 */
400
	private function generateAlphaNumToken(int $length) : string
401
	{
402
		$characters = str_split('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789');
403
404
		srand((int) microtime() * 1000000);
405
406
		$token = '';
407
408
		do {
409
			shuffle($characters);
410
			$token .= $characters[mt_rand(0, (count($characters) - 1))];
411
		} while (strlen($token) < $length);
412
413
		return $token;
414
	}
415
416
	/**
417
	 * @param string $payload
418
	 * @param string $type
419
	 * @param bool $masked
420
	 *
421
	 * @return bool|string
422
	 */
423
	private function hybi10Encode(string $payload, string $type = 'text', bool $masked = TRUE)
424
	{
425
		$frameHead = [];
426
427
		$payloadLength = strlen($payload);
428
429
		switch ($type) {
430
			case 'text':
431
				// First byte indicates FIN, Text-Frame (10000001):
432
				$frameHead[0] = 129;
433
				break;
434
435
			case 'close':
436
				// First byte indicates FIN, Close Frame(10001000):
437
				$frameHead[0] = 136;
438
				break;
439
440
			case 'ping':
441
				// First byte indicates FIN, Ping frame (10001001):
442
				$frameHead[0] = 137;
443
				break;
444
445
			case 'pong':
446
				// First byte indicates FIN, Pong frame (10001010):
447
				$frameHead[0] = 138;
448
				break;
449
		}
450
451
		// Set mask and payload length (using 1, 3 or 9 bytes)
452
		if ($payloadLength > 65535) {
453
			$payloadLengthBin = str_split(sprintf('%064b', $payloadLength), 8);
454
			$frameHead[1] = ($masked === TRUE) ? 255 : 127;
455
456
			for ($i = 0; $i < 8; $i++) {
457
				$frameHead[$i + 2] = bindec($payloadLengthBin[$i]);
458
			}
459
460
			// Most significant bit MUST be 0 (close connection if frame too big)
461
			if ($frameHead[2] > 127) {
462
				$this->close(1004);
463
464
				return FALSE;
465
			}
466
467
		} elseif ($payloadLength > 125) {
468
			$payloadLengthBin = str_split(sprintf('%016b', $payloadLength), 8);
469
470
			$frameHead[1] = ($masked === TRUE) ? 254 : 126;
471
			$frameHead[2] = bindec($payloadLengthBin[0]);
472
			$frameHead[3] = bindec($payloadLengthBin[1]);
473
474
		} else {
475
			$frameHead[1] = ($masked === TRUE) ? $payloadLength + 128 : $payloadLength;
476
		}
477
478
		// Convert frame-head to string:
479
		foreach (array_keys($frameHead) as $i) {
480
			$frameHead[$i] = chr($frameHead[$i]);
481
		}
482
483
		if ($masked === TRUE) {
484
			// Generate a random mask:
485
			$mask = [];
486
487
			for ($i = 0; $i < 4; $i++) {
488
				$mask[$i] = chr(rand(0, 255));
489
			}
490
491
			$frameHead = array_merge($frameHead, $mask);
492
		}
493
494
		$frame = implode('', $frameHead);
495
496
		// Append payload to frame:
497
		for ($i = 0; $i < $payloadLength; $i++) {
498
			$frame .= ($masked === TRUE) ? $payload[$i] ^ $mask[$i % 4] : $payload[$i];
0 ignored issues
show
Bug introduced by
The variable $mask does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
499
		}
500
501
		return $frame;
502
	}
503
504
	/**
505
	 * @param $data
506
	 *
507
	 * @return string|NULL
508
	 */
509
	private function hybi10Decode($data) : ?string
0 ignored issues
show
Unused Code introduced by
This method is not used, and could be removed.
Loading history...
510
	{
511
		if (empty($data)) {
512
			return NULL;
513
		}
514
515
		$bytes = $data;
516
		$decodedData = '';
517
		$secondByte = sprintf('%08b', ord($bytes[1]));
518
		$masked = ($secondByte[0] == '1') ? TRUE : FALSE;
519
		$dataLength = ($masked === TRUE) ? ord($bytes[1]) & 127 : ord($bytes[1]);
520
521
		if ($masked === TRUE) {
522
			if ($dataLength === 126) {
523
				$mask = substr($bytes, 4, 4);
524
				$coded_data = substr($bytes, 8);
525
526
			} elseif ($dataLength === 127) {
527
				$mask = substr($bytes, 10, 4);
528
				$coded_data = substr($bytes, 14);
529
530
			} else {
531
				$mask = substr($bytes, 2, 4);
532
				$coded_data = substr($bytes, 6);
533
			}
534
535
			for ($i = 0; $i < strlen($coded_data); $i++) {
536
				$decodedData .= $coded_data[$i] ^ $mask[$i % 4];
537
			}
538
539
		} else {
540
			if ($dataLength === 126) {
541
				$decodedData = substr($bytes, 4);
542
543
			} elseif ($dataLength === 127) {
544
				$decodedData = substr($bytes, 10);
545
546
			} else {
547
				$decodedData = substr($bytes, 2);
548
			}
549
		}
550
551
		return $decodedData;
552
	}
553
}
554