Completed
Push — master ( f58f72...e50618 )
by Arthur
01:35
created

WscMain::getPayloadData()   B

Complexity

Conditions 5
Paths 6

Size

Total Lines 25
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 25
rs 8.439
c 0
b 0
f 0
cc 5
eloc 14
nc 6
nop 2
1
<?php
2
3
namespace WSSC\Components;
4
5
use WSSC\Contracts\CommonsContract;
6
use WSSC\Contracts\WscCommonsContract;
7
use WSSC\Exceptions\BadOpcodeException;
8
use WSSC\Exceptions\BadUriException;
9
use WSSC\Exceptions\ConnectionException;
10
11
class WscMain implements WscCommonsContract
12
{
13
14
    private $socket;
15
    private $isConnected = false;
16
    private $isClosing = false;
17
    private $lastOpcode;
18
    private $closeStatus;
19
    private $hugePayload;
20
21
    private static $opcodes = [
22
        CommonsContract::EVENT_TYPE_CONTINUATION => 0,
23
        CommonsContract::EVENT_TYPE_TEXT         => 1,
24
        CommonsContract::EVENT_TYPE_BINARY       => 2,
25
        CommonsContract::EVENT_TYPE_CLOSE        => 8,
26
        CommonsContract::EVENT_TYPE_PING         => 9,
27
        CommonsContract::EVENT_TYPE_PONG         => 10,
28
    ];
29
30
    protected $socketUrl = '';
31
    protected $options = [];
32
33
    /**
34
     * @throws \InvalidArgumentException
35
     * @throws BadUriException
36
     * @throws ConnectionException
37
     * @throws \Exception
38
     */
39
    protected function connect() : void
40
    {
41
        $urlParts = parse_url($this->socketUrl);
42
        $scheme = $urlParts['scheme'];
43
        $host = $urlParts['host'];
44
        $user = isset($urlParts['user']) ? $urlParts['user'] : '';
45
        $pass = isset($urlParts['pass']) ? $urlParts['pass'] : '';
46
        $port = isset($urlParts['port']) ? $urlParts['port'] : ($scheme === 'wss' ? 443 : 80);
47
48
        $pathWithQuery = $this->getPathWithQuery($urlParts);
49
        if (in_array($scheme, ['ws', 'wss'], true) === false) {
50
            throw new BadUriException(
51
                "Url should have scheme ws or wss, not '$scheme' from URI '$this->socketUrl' ."
52
            );
53
        }
54
55
        $hostUri = ($scheme === 'wss' ? 'ssl' : 'tcp') . '://' . $host;
56
        // Set the stream context options if they're already set in the config
57
        $context = $this->getStreamContext();
58
        $this->socket = @stream_socket_client(
59
            $hostUri . ':' . $port, $errno, $errstr, $this->options['timeout'], STREAM_CLIENT_CONNECT, $context
60
        );
61
        if ($this->socket === false) {
62
            throw new ConnectionException(
63
                "Could not open socket to \"$host:$port\": $errstr ($errno)."
64
            );
65
        }
66
67
        // Set timeout on the stream as well.
68
        stream_set_timeout($this->socket, $this->options['timeout']);
69
70
        // Generate the WebSocket key.
71
        $key = $this->generateKey();
72
        $headers = [
73
            'Host'                  => $host . ':' . $port,
74
            'User-Agent'            => 'websocket-client-php',
75
            'Connection'            => 'Upgrade',
76
            'Upgrade'               => 'WebSocket',
77
            'Sec-WebSocket-Key'     => $key,
78
            'Sec-Websocket-Version' => '13',
79
        ];
80
81
        // Handle basic authentication.
82
        if ($user || $pass) {
83
            $headers['authorization'] = 'Basic ' . base64_encode($user . ':' . $pass) . "\r\n";
84
        }
85
        // Add and override with headers from options.
86
        if (isset($this->options['headers'])) {
87
            $headers = array_merge($headers, $this->options['headers']);
88
        }
89
90
        $header = $this->getHeaders($pathWithQuery, $headers);
91
        // Send headers.
92
        $this->write($header);
93
        // Get server response header 
94
        $response = stream_get_line($this->socket, self::DEFAULT_RESPONSE_HEADER, "\r\n\r\n");
95
        /// @todo Handle version switching
96
        // Validate response.
97
        if (!preg_match(self::SEC_WEBSOCKET_ACCEPT_PTTRN, $response, $matches)) {
98
            $address = $scheme . '://' . $host . $pathWithQuery;
99
            throw new ConnectionException(
100
                "Connection to '{$address}' failed: Server sent invalid upgrade response:\n"
101
                . $response
102
            );
103
        }
104
105
        $keyAccept = trim($matches[1]);
106
        $expectedResonse = base64_encode(pack('H*', sha1($key . self::SERVER_KEY_ACCEPT)));
107
        if ($keyAccept !== $expectedResonse) {
108
            throw new ConnectionException('Server sent bad upgrade response.');
109
        }
110
        $this->isConnected = true;
111
    }
112
113
    /**
114
     * @return mixed|resource
115
     * @throws \InvalidArgumentException
116
     */
117
    private function getStreamContext()
118
    {
119
        if (isset($this->options['context'])) {
120
            // Suppress the error since we'll catch it below
121
            if (@get_resource_type($this->options['context']) === 'stream-context') {
122
                return $this->options['context'];
123
            }
124
125
            throw new \InvalidArgumentException(
126
                "Stream context in \$options['context'] isn't a valid context"
127
            );
128
        }
129
130
        return stream_context_create();
131
    }
132
133
    /**
134
     * @param mixed $urlParts
135
     * @return string
136
     */
137
    private function getPathWithQuery($urlParts) : string
138
    {
139
        $path = isset($urlParts['path']) ? $urlParts['path'] : '/';
140
        $query = isset($urlParts['query']) ? $urlParts['query'] : '';
141
        $fragment = isset($urlParts['fragment']) ? $urlParts['fragment'] : '';
142
        $pathWithQuery = $path;
143
        if (!empty($query)) {
144
            $pathWithQuery .= '?' . $query;
145
        }
146
        if (!empty($fragment)) {
147
            $pathWithQuery .= '#' . $fragment;
148
        }
149
        return $pathWithQuery;
150
    }
151
152
    /**
153
     * @param string $pathWithQuery
154
     * @param array $headers
155
     * @return string
156
     */
157
    private function getHeaders(string $pathWithQuery, array $headers) : string
158
    {
159
        return 'GET ' . $pathWithQuery . " HTTP/1.1\r\n"
160
            . implode(
161
                "\r\n", array_map(
162
                    function ($key, $value) {
163
                        return "$key: $value";
164
                    }, array_keys($headers), $headers
165
                )
166
            )
167
            . "\r\n\r\n";
168
    }
169
170
    /**
171
     * @return string
172
     */
173
    public function getLastOpcode() : string
174
    {
175
        return $this->lastOpcode;
176
    }
177
178
    /**
179
     * @return int
180
     */
181
    public function getCloseStatus() : int
182
    {
183
        return $this->closeStatus;
184
    }
185
186
    /**
187
     * @return bool
188
     */
189
    public function isConnected() : bool
190
    {
191
        return $this->isConnected;
192
    }
193
194
    /**
195
     * @param int $timeout
196
     * @param null $microSecs
197
     */
198
    public function setTimeout(int $timeout, $microSecs = null)
199
    {
200
        $this->options['timeout'] = $timeout;
201
202
        if ($this->socket && get_resource_type($this->socket) === 'stream') {
203
            stream_set_timeout($this->socket, $timeout, $microSecs);
204
        }
205
    }
206
207
    public function setFragmentSize($fragmentSize)
208
    {
209
        $this->options['fragment_size'] = $fragmentSize;
210
        return $this;
211
    }
212
213
    public function getFragmentSize()
214
    {
215
        return $this->options['fragment_size'];
216
    }
217
218
    public function send($payload, $opcode = 'text', $masked = true)
219
    {
220
        if (!$this->isConnected) {
221
            $this->connect();
222
        }
223
        if (array_key_exists($opcode, self::$opcodes) === false) {
224
            throw new BadOpcodeException("Bad opcode '$opcode'.  Try 'text' or 'binary'.");
225
        }
226
        echo $payload;
227
        // record the length of the payload
228
        $payload_length = strlen($payload);
229
230
        $fragment_cursor = 0;
231
        // while we have data to send
232
        while ($payload_length > $fragment_cursor) {
233
            // get a fragment of the payload
234
            $sub_payload = substr($payload, $fragment_cursor, $this->options['fragment_size']);
235
236
            // advance the cursor
237
            $fragment_cursor += $this->options['fragment_size'];
238
239
            // is this the final fragment to send?
240
            $final = $payload_length <= $fragment_cursor;
241
242
            // send the fragment
243
            $this->sendFragment($final, $sub_payload, $opcode, $masked);
244
245
            // all fragments after the first will be marked a continuation
246
            $opcode = 'continuation';
247
        }
248
    }
249
250
    /**
251
     * @param $final
252
     * @param $payload
253
     * @param $opcode
254
     * @param $masked
255
     * @throws ConnectionException
256
     * @throws \Exception
257
     */
258
    protected function sendFragment($final, $payload, $opcode, $masked)
259
    {
260
        // Binary string for header.
261
        $frameHeadBin = '';
262
        // Write FIN, final fragment bit.
263
        $frameHeadBin .= (bool)$final ? '1' : '0';
264
        // RSV 1, 2, & 3 false and unused.
0 ignored issues
show
Unused Code Comprehensibility introduced by
43% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
265
        $frameHeadBin .= '000';
266
        // Opcode rest of the byte.
267
        $frameHeadBin .= sprintf('%04b', self::$opcodes[$opcode]);
268
        // Use masking?
269
        $frameHeadBin .= $masked ? '1' : '0';
270
271
        // 7 bits of payload length...
272
        $payloadLen = strlen($payload);
273
        if ($payloadLen > self::MAX_BYTES_READ) {
274
            $frameHeadBin .= decbin(self::MASK_127);
275
            $frameHeadBin .= sprintf('%064b', $payloadLen);
276
        } else if ($payloadLen > self::MASK_125) {
277
            $frameHeadBin .= decbin(self::MASK_126);
278
            $frameHeadBin .= sprintf('%016b', $payloadLen);
279
        } else {
280
            $frameHeadBin .= sprintf('%07b', $payloadLen);
281
        }
282
283
        $frame = '';
284
285
        // Write frame head to frame.
286
        foreach (str_split($frameHeadBin, 8) as $binstr) {
287
            $frame .= chr(bindec($binstr));
288
        }
289
        // Handle masking
290
        if ($masked) {
291
            // generate a random mask:
292
            $mask = '';
293 View Code Duplication
            for ($i = 0; $i < 4; $i++) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
294
                $mask .= chr(random_int(0, 255));
295
            }
296
            $frame .= $mask;
297
        }
298
299
        // Append payload to frame:
300 View Code Duplication
        for ($i = 0; $i < $payloadLen; $i++) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
301
            $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...
302
        }
303
304
        $this->write($frame);
305
    }
306
307
    public function receive()
308
    {
309
        if (!$this->isConnected) {
310
            $this->connect();
311
        }
312
        $this->hugePayload = '';
313
314
        $response = NULL;
315
        while (NULL === $response) {
316
            $response = $this->receiveFragment();
317
        }
318
        return $response;
319
    }
320
321
    /**
322
     * @return string
323
     * @throws BadOpcodeException
324
     * @throws ConnectionException
325
     */
326
    protected function receiveFragment() : string
327
    {
328
        // Just read the main fragment information first.
329
        $data = $this->read(2);
330
331
        // Is this the final fragment?  // Bit 0 in byte 0
332
        /// @todo Handle huge payloads with multiple fragments.
333
        $final = (bool)(ord($data[0]) & 1 << 7);
334
335
        // Parse opcode
336
        $opcode_int = ord($data[0]) & 31; // Bits 4-7
337
        $opcode_ints = array_flip(self::$opcodes);
338
        if (!array_key_exists($opcode_int, $opcode_ints)) {
339
            throw new ConnectionException("Bad opcode in websocket frame: $opcode_int");
340
        }
341
        $opcode = $opcode_ints[$opcode_int];
342
343
        // record the opcode if we are not receiving a continutation fragment
344
        if ($opcode !== 'continuation') {
345
            $this->lastOpcode = $opcode;
346
        }
347
348
        $payloadLength = $this->getPayloadLength($data);
349
        $payload = $this->getPayloadData($data, $payloadLength);
350
        if ($opcode === CommonsContract::EVENT_TYPE_CLOSE) {
351
            // Get the close status.
352
            if ($payloadLength >= 2) {
353
                $statusBin = $payload[0] . $payload[1];
354
                $status = bindec(sprintf('%08b%08b', ord($payload[0]), ord($payload[1])));
355
                $this->closeStatus = $status;
356
                $payload = substr($payload, 2);
357
358
                if (!$this->isClosing) {
359
                    $this->send($statusBin . 'Close acknowledged: ' . $status, 'close'); // Respond.
360
                }
361
            }
362
363
            if ($this->isClosing) {
364
                $this->isClosing = false; // A close response, all done.
365
            }
366
367
            fclose($this->socket);
368
            $this->isConnected = false;
369
        }
370
371
        if (!$final) {
372
            $this->hugePayload .= $payload;
373
            return NULL;
374
        } // this is the last fragment, and we are processing a huge_payload
375
376
        if ($this->hugePayload) {
377
            $payload = $this->hugePayload .= $payload;
378
            $this->hugePayload = NULL;
379
        }
380
381
        return $payload;
382
    }
383
384
    /**
385
     * @param string $data
386
     * @param int $payloadLength
387
     * @return string
388
     * @throws ConnectionException
389
     */
390
    private function getPayloadData(string $data, int $payloadLength)
391
    {
392
        // Masking?
393
        $mask = (bool)(ord($data[1]) >> 7);  // Bit 0 in byte 1
394
        $payload = '';
395
        $maskingKey = '';
396
        // Get masking key.
397
        if ($mask) {
398
            $maskingKey = $this->read(4);
399
        }
400
        // Get the actual payload, if any (might not be for e.g. close frames.
401
        if ($payloadLength > 0) {
402
            $data = $this->read($payloadLength);
403
404
            if ($mask) {
405
                // Unmask payload.
406
                for ($i = 0; $i < $payloadLength; $i++) {
407
                    $payload .= ($data[$i] ^ $maskingKey[$i % 4]);
408
                }
409
            } else {
410
                $payload = $data;
411
            }
412
        }
413
        return $payload;
414
    }
415
416
    /**
417
     * @param string $data
418
     * @return float|int
419
     * @throws ConnectionException
420
     */
421
    private function getPayloadLength(string $data)
422
    {
423
        $payloadLength = (int)ord($data[1]) & self::MASK_127; // Bits 1-7 in byte 1
424
        if ($payloadLength > self::MASK_125) {
425
            if ($payloadLength === self::MASK_126) {
426
                $data = $this->read(2); // 126: Payload is a 16-bit unsigned int
427
            } else {
428
                $data = $this->read(8); // 127: Payload is a 64-bit unsigned int
429
            }
430
            $payloadLength = bindec(self::sprintB($data));
431
        }
432
        return $payloadLength;
433
    }
434
435
    /**
436
     * Tell the socket to close.
437
     *
438
     * @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4
439
     * @param string $message A closing message, max 125 bytes.
440
     * @return bool|null|string
441
     * @throws BadOpcodeException
442
     */
443
    public function close(int $status = 1000, string $message = 'ttfn')
444
    {
445
        $statusBin = sprintf('%016b', $status);
446
        $status_str = '';
447
        foreach (str_split($statusBin, 8) as $binstr) {
448
            $status_str .= chr(bindec($binstr));
449
        }
450
        $this->send($status_str . $message, 'close', true);
451
        $this->isClosing = true;
452
        return $this->receive(); // Receiving a close frame will close the socket now.
453
    }
454
455
    /**
456
     * @param $data
457
     * @throws ConnectionException
458
     */
459
    protected function write(string $data) : void
460
    {
461
        $written = fwrite($this->socket, $data);
462
463
        if ($written < strlen($data)) {
464
            throw new ConnectionException(
465
                "Could only write $written out of " . strlen($data) . " bytes."
466
            );
467
        }
468
    }
469
470
    /**
471
     * @param int $len
472
     * @return string
473
     * @throws ConnectionException
474
     */
475
    protected function read(int $len) : string
476
    {
477
        $data = '';
478
        while (($dataLen = strlen($data)) < $len) {
479
            $buff = fread($this->socket, $len - $dataLen);
480
            if ($buff === false) {
481
                $metadata = stream_get_meta_data($this->socket);
482
                throw new ConnectionException(
483
                    'Broken frame, read ' . strlen($data) . ' of stated '
484
                    . $len . ' bytes.  Stream state: '
485
                    . json_encode($metadata)
486
                );
487
            }
488
            if ($buff === '') {
489
                $metadata = stream_get_meta_data($this->socket);
490
                throw new ConnectionException(
491
                    'Empty read; connection dead?  Stream state: ' . json_encode($metadata)
492
                );
493
            }
494
            $data .= $buff;
495
        }
496
        return $data;
497
    }
498
499
    /**
500
     * Helper to convert a binary to a string of '0' and '1'.
501
     *
502
     * @param $string
503
     * @return string
504
     */
505
    protected static function sprintB(string $string) : string
506
    {
507
        $return = '';
508
        $strLen = strlen($string);
509
        for ($i = 0; $i < $strLen; $i++) {
510
            $return .= sprintf('%08b', ord($string[$i]));
511
        }
512
        return $return;
513
    }
514
515
    /**
516
     * Sec-WebSocket-Key generator
517
     *
518
     * @return string   the 16 character length key
519
     * @throws \Exception
520
     */
521
    private function generateKey() : string
522
    {
523
        $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"$&/()=[]{}0123456789';
524
        $key = '';
525
        $chLen = strlen($chars);
526
        for ($i = 0; $i < self::KEY_GEN_LENGTH; $i++) {
527
            $key .= $chars[random_int(0, $chLen - 1)];
528
        }
529
        return base64_encode($key);
530
    }
531
532
}
533