Completed
Push — master ( dc6ba4...c43d55 )
by Adam
18:06
created

Client::disconnect()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 2.0625

Importance

Changes 0
Metric Value
dl 0
loc 8
ccs 3
cts 4
cp 0.75
rs 10
c 0
b 0
f 0
cc 2
nc 2
nop 0
crap 2.0625
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
use Tracy\Debugger;
27
28
/**
29
 * @method void onConnect()
30
 * @method void onOpen(array $data)
31
 * @method void onEvent(string $topic, string $event)
32
 */
33 1
class Client implements IClient
34
{
35
	/**
36
	 * Implement nette smart magic
37
	 */
38 1
	use Nette\SmartObject;
39
40
	const VERSION = '0.1.0';
41
42
	const TYPE_ID_WELCOME = 0;
43
	const TYPE_ID_PREFIX = 1;
44
	const TYPE_ID_CALL = 2;
45
	const TYPE_ID_CALL_RESULT = 3;
46
	const TYPE_ID_CALL_ERROR = 4;
47
	const TYPE_ID_SUBSCRIBE = 5;
48
	const TYPE_ID_UNSUBSCRIBE = 6;
49
	const TYPE_ID_PUBLISH = 7;
50
	const TYPE_ID_EVENT = 8;
51
52
	/**
53
	 * @var \Closure
54
	 */
55
	public $onConnect = [];
56
57
	/**
58
	 * @var \Closure
59
	 */
60
	public $onOpen = [];
61
62
	/**
63
	 * @var \Closure
64
	 */
65
	public $onEvent = [];
66
67
	/**
68
	 * @var EventLoop\LoopInterface
69
	 */
70
	private $loop;
71
72
	/**
73
	 * @var Configuration
74
	 */
75
	private $configuration;
76
77
	/**
78
	 * @var Stream\DuplexResourceStream
79
	 */
80
	private $stream;
81
82
	/**
83
	 * @var bool
84
	 */
85
	private $connected = FALSE;
86
87
	/**
88
	 * @var callable[]
89
	 */
90
	private $sucessCallbacks = [];
91
92
	/**
93
	 * @var callable[]
94
	 */
95
	private $errorCallbacks = [];
96
97
	/**
98
	 * @param EventLoop\LoopInterface $loop
99
	 * @param Configuration $configuration
100
	 */
101
	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...
102
	{
103 1
		$this->loop = $loop;
104 1
		$this->configuration = $configuration;
105 1
	}
106
107
	/**
108
	 * Disconnect on destruct
109
	 */
110
	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...
111
	{
112 1
		$this->disconnect();
113 1
	}
114
115
	/**
116
	 * {@inheritdoc}
117
	 */
118
	public function setLoop(EventLoop\LoopInterface $loop) : void
119
	{
120
		$this->loop = $loop;
121
	}
122
123
	/**
124
	 * {@inheritdoc}
125
	 */
126
	public function getLoop() : EventLoop\LoopInterface
127
	{
128
		return $this->loop;
129
	}
130
131
	/**
132
	 * {@inheritdoc}
133
	 */
134
	public function connect() : void
135
	{
136
		$resource = @stream_socket_client('tcp://' . $this->configuration->getHost() . ':' . $this->configuration->getPort());
137
138
		if (!$resource) {
139
			throw new Exceptions\ConnectionException('Opening socket failed.');
140
		}
141
142
		$this->stream = new Stream\DuplexResourceStream($resource, $this->getLoop());
143
144
		$this->stream->on('data', function ($chunk) : void {
145
			$data = $this->parseChunk($chunk);
146
147
			$this->parseData($data);
148
		});
149
150
		$this->stream->on('close', function () : void {
151
			// When connection is closed, stop loop & end running script
152
			$this->loop->stop();
153
		});
154
155
		$this->stream->write($this->createHeader());
156
157
		$this->onConnect();
158
	}
159
160
	/**
161
	 * {@inheritdoc}
162
	 */
163
	public function disconnect() : void
164
	{
165 1
		$this->connected = FALSE;
166
167 1
		if ($this->stream instanceof Stream\DuplexStreamInterface) {
168
			$this->stream->close();
169
		}
170 1
	}
171
172
	/**
173
	 * {@inheritdoc}
174
	 */
175
	public function isConnected() : bool
176
	{
177
		return $this->connected;
178
	}
179
180
	/**
181
	 * {@inheritdoc}
182
	 */
183
	public function publish(string $topicUri, string $event, array $exclude = [], array $eligible = []) : void
184
	{
185
		$this->sendData([
186
			self::TYPE_ID_PUBLISH,
187
			$topicUri,
188
			$event,
189
			$exclude,
190
			$eligible
191
		]);
192
	}
193
194
	/**
195
	 * {@inheritdoc}
196
	 */
197
	public function subscribe(string $topicUri) : void
198
	{
199
		$this->sendData([
200
			self::TYPE_ID_SUBSCRIBE,
201
			$topicUri
202
		]);
203
	}
204
205
	/**
206
	 * {@inheritdoc}
207
	 */
208
	public function unsubscribe(string $topicUri) : void
209
	{
210
		$this->sendData([
211
			self::TYPE_ID_UNSUBSCRIBE,
212
			$topicUri
213
		]);
214
	}
215
216
	/**
217
	 * {@inheritdoc}
218
	 */
219
	public function call(string $processUri, array $args, callable $successCallback = NULL, callable $errorCallback = NULL) : void
220
	{
221
		$callId = $this->generateAlphaNumToken(16);
222
223
		$this->sucessCallbacks[$callId] = $successCallback;
224
		$this->errorCallbacks[$callId] = $errorCallback;
225
226
		$data = [
227
			self::TYPE_ID_CALL,
228
			$callId,
229
			$processUri,
230
			$args,
231
		];
232
233
		$this->sendData($data);
234
	}
235
236
	/**
237
	 * @param mixed $data
238
	 * @param array $header
239
	 *
240
	 * @return void
241
	 */
242
	private function receiveData($data, array $header) : void
0 ignored issues
show
Unused Code introduced by
The parameter $header is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
243
	{
244
		if (!$this->isConnected()) {
245
			$this->disconnect();
246
247
			return;
248
		}
249
250
		if (isset($data[0])) {
251
			switch ($data[0]) {
252
				case self::TYPE_ID_WELCOME:
253
					$this->onOpen($data);
254
					break;
255
256
				case self::TYPE_ID_CALL_RESULT:
257
					if (isset($data[1])) {
258
						$id = $data[1];
259
260
						if (isset($this->sucessCallbacks[$id])) {
261
							$callback = $this->sucessCallbacks[$id];
262
							$callback(
263
								Utils\ArrayHash::from(isset($data[2]) ? (is_array($data[2]) ? $data[2] : [$data[2]]) : [])
264
							);
265
266
							unset($this->sucessCallbacks[$id]);
267
						}
268
					}
269
					break;
270
271
				case self::TYPE_ID_CALL_ERROR:
272
					if (isset($data[1])) {
273
						$id = $data[1];
274
275
						if (isset($this->errorCallbacks[$id])) {
276
							$callback = $this->errorCallbacks[$id];
277
							$callback(
278
								// Topic
279
								(isset($data[2]) ? (string) $data[2] : NULL),
280
281
								// Error exception message
282
								(isset($data[3]) ? (string) $data[3] : NULL),
283
284
								// Additional error data
285
								Utils\ArrayHash::from(isset($data[4]) ? (is_array($data[4]) ? $data[4] : [$data[4]]) : [])
286
							);
287
288
							unset($this->errorCallbacks[$id]);
289
						}
290
					}
291
					break;
292
293
				case self::TYPE_ID_EVENT:
294
					if (isset($data[1]) && isset($data[2])) {
295
						$this->onEvent($data[1], $data[2]);
296
					}
297
					break;
298
			}
299
		}
300
	}
301
302
	/**
303
	 * @param array $data
304
	 * @param string $type
305
	 * @param bool $masked
306
	 *
307
	 * @return void
308
	 *
309
	 * @throws Utils\JsonException
310
	 */
311
	private function sendData(array $data, string $type = 'text', bool $masked = TRUE) : void
312
	{
313
		if (!$this->isConnected()) {
314
			$this->disconnect();
315
316
			return;
317
		}
318
319
		$this->stream->write($this->hybi10Encode(Utils\Json::encode($data), $type, $masked));
320
	}
321
322
	/**
323
	 * Parse received data
324
	 *
325
	 * @param array $response
326
	 *
327
	 * @return void
328
	 *
329
	 * @throws Utils\JsonException
330
	 */
331
	private function parseData(array $response) : void
332
	{
333
		if (!$this->connected && isset($response['Sec-Websocket-Accept'])) {
334
			if (base64_encode(pack('H*', sha1($this->configuration->getKey() . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'))) === $response['Sec-Websocket-Accept']) {
335
				$this->connected = TRUE;
336
			}
337
		}
338
339
		if ($this->connected && !empty($response['content'])) {
340
			$content = str_replace("\r\n", '', trim($response['content']));
341
342
			if (preg_match('/(\[[^\]]+\])/', $content, $match)) {
343
				try {
344
					$parsedContent = Utils\Json::decode($match[1], Utils\Json::FORCE_ARRAY);
345
346
				} catch (Utils\JsonException $ex) {
347
					Debugger::log($ex);
348
					Debugger::log(trim($response['content']));
349
					Debugger::log($content);
350
351
					$parsedContent = NULL;
352
				}
353
354
				if (is_array($parsedContent)) {
355
					unset($response['status']);
356
					unset($response['content']);
357
358
					$this->receiveData($parsedContent, $response);
359
				}
360
			}
361
		}
362
	}
363
364
	/**
365
	 * Create header for web socket client
366
	 *
367
	 * @return string
368
	 */
369
	private function createHeader() : string
370
	{
371
		$host = $this->configuration->getHost();
372
373
		if ($host === '127.0.0.1' || $host === '0.0.0.0') {
374
			$host = 'localhost';
375
		}
376
377
		$origin = $this->configuration->getOrigin() ? $this->configuration->getOrigin() : 'null';
378
379
		return
380
			"GET {$this->configuration->getPath()} HTTP/1.1" . "\r\n" .
381
			"Origin: {$origin}" . "\r\n" .
382
			"Host: {$host}:{$this->configuration->getPort()}" . "\r\n" .
383
			"Sec-WebSocket-Key: {$this->configuration->getKey()}" . "\r\n" .
384
			"User-Agent: IPubWebSocketClient/" . self::VERSION . "\r\n" .
385
			"Upgrade: websocket" . "\r\n" .
386
			"Connection: Upgrade" . "\r\n" .
387
			"Sec-WebSocket-Protocol: wamp" . "\r\n" .
388
			"Sec-WebSocket-Version: 13" . "\r\n" . "\r\n";
389
	}
390
391
	/**
392
	 * Parse raw incoming data
393
	 *
394
	 * @param string $header
395
	 *
396
	 * @return mixed[]
397
	 */
398
	private function parseChunk(string $header) : array
399
	{
400
		$parsed = [];
401
402
		$content = '';
403
404
		$fields = explode("\r\n", preg_replace('/\x0D\x0A[\x09\x20]+/', ' ', $header));
405
		Debugger::log($header, 'response');
406
		Debugger::log($fields, 'response');
407
408
		foreach ($fields as $field) {
409
			if (preg_match('/([^:]+): (.+)/m', $field, $match)) {
410
				$match[1] = preg_replace_callback('/(?<=^|[\x09\x20\x2D])./', function ($matches) {
411
					return strtoupper($matches[0]);
412
				}, strtolower(trim($match[1])));
413
414
				if (isset($parsed[$match[1]])) {
415
					$parsed[$match[1]] = [$parsed[$match[1]], $match[2]];
416
417
				} else {
418
					$parsed[$match[1]] = trim($match[2]);
419
				}
420
421
			} elseif (preg_match('!HTTP/1\.\d (\d)* .!', $field)) {
422
				$parsed['status'] = $field;
423
424
			} else {
425
				$content .= $field . "\r\n";
426
			}
427
		}
428
429
		$parsed['content'] = $content;
430
431
		return $parsed;
432
	}
433
434
	/**
435
	 * Generate token
436
	 *
437
	 * @param int $length
438
	 *
439
	 * @return string
440
	 */
441
	private function generateAlphaNumToken(int $length) : string
442
	{
443
		$characters = str_split('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789');
444
445
		srand((int) microtime() * 1000000);
446
447
		$token = '';
448
449
		do {
450
			shuffle($characters);
451
			$token .= $characters[mt_rand(0, (count($characters) - 1))];
452
		} while (strlen($token) < $length);
453
454
		return $token;
455
	}
456
457
	/**
458
	 * @param string $payload
459
	 * @param string $type
460
	 * @param bool $masked
461
	 *
462
	 * @return bool|string
463
	 */
464
	private function hybi10Encode(string $payload, string $type = 'text', bool $masked = TRUE)
465
	{
466
		$frameHead = [];
467
468
		$payloadLength = strlen($payload);
469
470
		switch ($type) {
471
			case 'text':
472
				// First byte indicates FIN, Text-Frame (10000001):
473
				$frameHead[0] = 129;
474
				break;
475
476
			case 'close':
477
				// First byte indicates FIN, Close Frame(10001000):
478
				$frameHead[0] = 136;
479
				break;
480
481
			case 'ping':
482
				// First byte indicates FIN, Ping frame (10001001):
483
				$frameHead[0] = 137;
484
				break;
485
486
			case 'pong':
487
				// First byte indicates FIN, Pong frame (10001010):
488
				$frameHead[0] = 138;
489
				break;
490
		}
491
492
		// Set mask and payload length (using 1, 3 or 9 bytes)
493
		if ($payloadLength > 65535) {
494
			$payloadLengthBin = str_split(sprintf('%064b', $payloadLength), 8);
495
			$frameHead[1] = ($masked === TRUE) ? 255 : 127;
496
497
			for ($i = 0; $i < 8; $i++) {
498
				$frameHead[$i + 2] = bindec($payloadLengthBin[$i]);
499
			}
500
501
			// Most significant bit MUST be 0 (close connection if frame too big)
502
			if ($frameHead[2] > 127) {
503
				$this->close(1004);
0 ignored issues
show
Documentation Bug introduced by
The method close does not exist on object<IPub\WebSocketsWAMPClient\Client\Client>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
504
505
				return FALSE;
506
			}
507
508
		} elseif ($payloadLength > 125) {
509
			$payloadLengthBin = str_split(sprintf('%016b', $payloadLength), 8);
510
511
			$frameHead[1] = ($masked === TRUE) ? 254 : 126;
512
			$frameHead[2] = bindec($payloadLengthBin[0]);
513
			$frameHead[3] = bindec($payloadLengthBin[1]);
514
515
		} else {
516
			$frameHead[1] = ($masked === TRUE) ? $payloadLength + 128 : $payloadLength;
517
		}
518
519
		// Convert frame-head to string:
520
		foreach (array_keys($frameHead) as $i) {
521
			$frameHead[$i] = chr($frameHead[$i]);
522
		}
523
524
		if ($masked === TRUE) {
525
			// Generate a random mask:
526
			$mask = [];
527
528
			for ($i = 0; $i < 4; $i++) {
529
				$mask[$i] = chr(rand(0, 255));
530
			}
531
532
			$frameHead = array_merge($frameHead, $mask);
533
		}
534
535
		$frame = implode('', $frameHead);
536
537
		// Append payload to frame:
538
		for ($i = 0; $i < $payloadLength; $i++) {
539
			$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...
540
		}
541
542
		return $frame;
543
	}
544
545
	/**
546
	 * @param $data
547
	 *
548
	 * @return string|NULL
549
	 */
550
	private function hybi10Decode($data) : ?string
551
	{
552
		if (empty($data)) {
553
			return NULL;
554
		}
555
556
		$bytes = $data;
557
		$decodedData = '';
558
		$secondByte = sprintf('%08b', ord($bytes[1]));
559
		$masked = ($secondByte[0] == '1') ? TRUE : FALSE;
560
		$dataLength = ($masked === TRUE) ? ord($bytes[1]) & 127 : ord($bytes[1]);
561
562
		if ($masked === TRUE) {
563
			if ($dataLength === 126) {
564
				$mask = substr($bytes, 4, 4);
565
				$coded_data = substr($bytes, 8);
566
567
			} elseif ($dataLength === 127) {
568
				$mask = substr($bytes, 10, 4);
569
				$coded_data = substr($bytes, 14);
570
571
			} else {
572
				$mask = substr($bytes, 2, 4);
573
				$coded_data = substr($bytes, 6);
574
			}
575
576
			for ($i = 0; $i < strlen($coded_data); $i++) {
577
				$decodedData .= $coded_data[$i] ^ $mask[$i % 4];
578
			}
579
580
		} else {
581
			if ($dataLength === 126) {
582
				$decodedData = substr($bytes, 4);
583
584
			} elseif ($dataLength === 127) {
585
				$decodedData = substr($bytes, 10);
586
587
			} else {
588
				$decodedData = substr($bytes, 2);
589
			}
590
		}
591
592
		return $decodedData;
593
	}
594
}
595