Completed
Push — master ( 2d58e0...41ca8e )
by Arthur
01:35
created

WscMain::sendFragment()   C

Complexity

Conditions 10
Paths 144

Size

Total Lines 48
Code Lines 26

Duplication

Lines 6
Ratio 12.5 %

Importance

Changes 0
Metric Value
dl 6
loc 48
rs 5.0666
c 0
b 0
f 0
cc 10
eloc 26
nc 144
nop 4

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace WSSC\Components;
4
5
use WSSC\Contracts\WscCommonsContract;
6
use WSSC\Exceptions\BadOpcodeException;
7
use WSSC\Exceptions\BadUriException;
8
use WSSC\Exceptions\ConnectionException;
9
10
class WscMain implements WscCommonsContract
11
{
12
13
    private $socket;
14
    private $isConnected = false;
15
    private $isClosing = false;
16
    private $lastOpcode;
17
    private $closeStatus;
18
    private $hugePayload;
19
20
    private static $opcodes = [
21
        'continuation' => 0,
22
        'text'         => 1,
23
        'binary'       => 2,
24
        'close'        => 8,
25
        'ping'         => 9,
26
        'pong'         => 10,
27
    ];
28
29
    protected $socketUrl = '';
30
    protected $options = [];
31
32
    /**
33
     * @throws \InvalidArgumentException
34
     * @throws BadUriException
35
     * @throws ConnectionException
36
     * @throws \Exception
37
     */
38
    protected function connect() : void
39
    {
40
        $urlParts = parse_url($this->socketUrl);
41
        $scheme = $urlParts['scheme'];
42
        $host = $urlParts['host'];
43
        $user = isset($urlParts['user']) ? $urlParts['user'] : '';
44
        $pass = isset($urlParts['pass']) ? $urlParts['pass'] : '';
45
        $port = isset($urlParts['port']) ? $urlParts['port'] : ($scheme === 'wss' ? 443 : 80);
46
        $path = isset($urlParts['path']) ? $urlParts['path'] : '/';
47
        $query = isset($urlParts['query']) ? $urlParts['query'] : '';
48
        $fragment = isset($urlParts['fragment']) ? $urlParts['fragment'] : '';
49
50
        $path_with_query = $path;
51
        if (!empty($query)) {
52
            $path_with_query .= '?' . $query;
53
        }
54
        if (!empty($fragment)) {
55
            $path_with_query .= '#' . $fragment;
56
        }
57
58
        if (!in_array($scheme, ['ws', 'wss'])) {
59
            throw new BadUriException(
60
                "Url should have scheme ws or wss, not '$scheme' from URI '$this->socketUrl' ."
61
            );
62
        }
63
64
        $host_uri = ($scheme === 'wss' ? 'ssl' : 'tcp') . '://' . $host;
65
66
        // Set the stream context options if they're already set in the config
67
        if (isset($this->options['context'])) {
68
            // Suppress the error since we'll catch it below
69
            if (@get_resource_type($this->options['context']) === 'stream-context') {
70
                $context = $this->options['context'];
71
            } else {
72
                throw new \InvalidArgumentException(
73
                    "Stream context in \$options['context'] isn't a valid context"
74
                );
75
            }
76
        } else {
77
            $context = stream_context_create();
78
        }
79
80
        $this->socket = @stream_socket_client(
81
            $host_uri . ':' . $port, $errno, $errstr, $this->options['timeout'], STREAM_CLIENT_CONNECT, $context
82
        );
83
84
        if ($this->socket === false) {
85
            throw new ConnectionException(
86
                "Could not open socket to \"$host:$port\": $errstr ($errno)."
87
            );
88
        }
89
90
        // Set timeout on the stream as well.
91
        stream_set_timeout($this->socket, $this->options['timeout']);
92
93
        // Generate the WebSocket key.
94
        $key = $this->generateKey();
95
96
        $headers = [
97
            'Host'                  => $host . ':' . $port,
98
            'User-Agent'            => 'websocket-client-php',
99
            'Connection'            => 'Upgrade',
100
            'Upgrade'               => 'WebSocket',
101
            'Sec-WebSocket-Key'     => $key,
102
            'Sec-Websocket-Version' => '13',
103
        ];
104
105
        // Handle basic authentication.
106
        if ($user || $pass) {
107
            $headers['authorization'] = 'Basic ' . base64_encode($user . ':' . $pass) . "\r\n";
108
        }
109
110
        // Add and override with headers from options.
111
        if (isset($this->options['headers'])) {
112
            $headers = array_merge($headers, $this->options['headers']);
113
        }
114
115
        $header = 'GET ' . $path_with_query . " HTTP/1.1\r\n"
116
            . implode(
117
                "\r\n", array_map(
118
                    function ($key, $value) {
119
                        return "$key: $value";
120
                    }, array_keys($headers), $headers
121
                )
122
            )
123
            . "\r\n\r\n";
124
        // Send headers.
125
        $this->write($header);
126
        // Get server response header 
127
        $response = stream_get_line($this->socket, self::DEFAULT_RESPONSE_HEADER, "\r\n\r\n");
128
        /// @todo Handle version switching
129
        // Validate response.
130
        if (!preg_match(self::SEC_WEBSOCKET_ACCEPT_PTTRN, $response, $matches)) {
131
            $address = $scheme . '://' . $host . $path_with_query;
132
            throw new ConnectionException(
133
                "Connection to '{$address}' failed: Server sent invalid upgrade response:\n"
134
                . $response
135
            );
136
        }
137
138
        $keyAccept = trim($matches[1]);
139
        $expectedResonse = base64_encode(pack('H*', sha1($key . self::SERVER_KEY_ACCEPT)));
140
141
        if ($keyAccept !== $expectedResonse) {
142
            throw new ConnectionException('Server sent bad upgrade response.');
143
        }
144
145
        $this->isConnected = true;
146
    }
147
148
    public function getLastOpcode()
149
    {
150
        return $this->lastOpcode;
151
    }
152
153
    public function getCloseStatus()
154
    {
155
        return $this->closeStatus;
156
    }
157
158
    public function isConnected()
159
    {
160
        return $this->isConnected;
161
    }
162
163
    /**
164
     * @param int $timeout
165
     * @param null $microSecs
166
     */
167
    public function setTimeout(int $timeout, $microSecs = null)
168
    {
169
        $this->options['timeout'] = $timeout;
170
171
        if ($this->socket && get_resource_type($this->socket) === 'stream') {
172
            stream_set_timeout($this->socket, $timeout, $microSecs);
173
        }
174
    }
175
176
    public function setFragmentSize($fragment_size)
177
    {
178
        $this->options['fragment_size'] = $fragment_size;
179
        return $this;
180
    }
181
182
    public function getFragmentSize()
183
    {
184
        return $this->options['fragment_size'];
185
    }
186
187
    public function send($payload, $opcode = 'text', $masked = true)
188
    {
189
        if (!$this->isConnected) {
190
            $this->connect();
191
        }
192
        if (array_key_exists($opcode, self::$opcodes) === false) {
193
            throw new BadOpcodeException("Bad opcode '$opcode'.  Try 'text' or 'binary'.");
194
        }
195
        echo $payload;
196
        // record the length of the payload
197
        $payload_length = strlen($payload);
198
199
        $fragment_cursor = 0;
200
        // while we have data to send
201
        while ($payload_length > $fragment_cursor) {
202
            // get a fragment of the payload
203
            $sub_payload = substr($payload, $fragment_cursor, $this->options['fragment_size']);
204
205
            // advance the cursor
206
            $fragment_cursor += $this->options['fragment_size'];
207
208
            // is this the final fragment to send?
209
            $final = $payload_length <= $fragment_cursor;
210
211
            // send the fragment
212
            $this->sendFragment($final, $sub_payload, $opcode, $masked);
213
214
            // all fragments after the first will be marked a continuation
215
            $opcode = 'continuation';
216
        }
217
    }
218
219
    /**
220
     * @param $final
221
     * @param $payload
222
     * @param $opcode
223
     * @param $masked
224
     * @throws ConnectionException
225
     * @throws \Exception
226
     */
227
    protected function sendFragment($final, $payload, $opcode, $masked)
228
    {
229
        // Binary string for header.
230
        $frameHeadBin = '';
231
        // Write FIN, final fragment bit.
232
        $frameHeadBin .= (bool)$final ? '1' : '0';
233
        // 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...
234
        $frameHeadBin .= '000';
235
        // Opcode rest of the byte.
236
        $frameHeadBin .= sprintf('%04b', self::$opcodes[$opcode]);
237
        // Use masking?
238
        $frameHeadBin .= $masked ? '1' : '0';
239
240
        // 7 bits of payload length...
241
        $payloadLen = strlen($payload);
242
        if ($payloadLen > self::MAX_BYTES_READ) {
243
            $frameHeadBin .= decbin(self::MASK_127);
244
            $frameHeadBin .= sprintf('%064b', $payloadLen);
245
        } else if ($payloadLen > self::MASK_125) {
246
            $frameHeadBin .= decbin(self::MASK_126);
247
            $frameHeadBin .= sprintf('%016b', $payloadLen);
248
        } else {
249
            $frameHeadBin .= sprintf('%07b', $payloadLen);
250
        }
251
252
        $frame = '';
253
254
        // Write frame head to frame.
255
        foreach (str_split($frameHeadBin, 8) as $binstr) {
256
            $frame .= chr(bindec($binstr));
257
        }
258
        // Handle masking
259
        if ($masked) {
260
            // generate a random mask:
261
            $mask = '';
262 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...
263
                $mask .= chr(random_int(0, 255));
264
            }
265
            $frame .= $mask;
266
        }
267
268
        // Append payload to frame:
269 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...
270
            $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...
271
        }
272
273
        $this->write($frame);
274
    }
275
276
    public function receive()
277
    {
278
        if (!$this->isConnected) {
279
            $this->connect();
280
        }
281
        $this->hugePayload = '';
282
283
        $response = NULL;
284
        while (NULL === $response) {
285
            $response = $this->receiveFragment();
286
        }
287
        return $response;
288
    }
289
290
    protected function receiveFragment()
291
    {
292
        // Just read the main fragment information first.
293
        $data = $this->read(2);
294
295
        // Is this the final fragment?  // Bit 0 in byte 0
296
        /// @todo Handle huge payloads with multiple fragments.
297
        $final = (bool)(ord($data[0]) & 1 << 7);
298
299
        // Should be unused, and must be false…  // Bits 1, 2, & 3
300
        //      $rsv1  = (boolean) (ord($data[0]) & 1 << 6);
0 ignored issues
show
Unused Code Comprehensibility introduced by
60% 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...
301
        //      $rsv2  = (boolean) (ord($data[0]) & 1 << 5);
0 ignored issues
show
Unused Code Comprehensibility introduced by
60% 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...
302
        //      $rsv3  = (boolean) (ord($data[0]) & 1 << 4);
0 ignored issues
show
Unused Code Comprehensibility introduced by
60% 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...
303
        // Parse opcode
304
        $opcode_int = ord($data[0]) & 31; // Bits 4-7
305
        $opcode_ints = array_flip(self::$opcodes);
306
        if (!array_key_exists($opcode_int, $opcode_ints)) {
307
            throw new ConnectionException("Bad opcode in websocket frame: $opcode_int");
308
        }
309
        $opcode = $opcode_ints[$opcode_int];
310
311
        // record the opcode if we are not receiving a continutation fragment
312
        if ($opcode !== 'continuation') {
313
            $this->lastOpcode = $opcode;
314
        }
315
316
        // Masking?
317
        $mask = (bool)(ord($data[1]) >> 7);  // Bit 0 in byte 1
318
319
        $payload = '';
320
321
        // Payload length
322
        $payload_length = (int)ord($data[1]) & self::MASK_127; // Bits 1-7 in byte 1
323
        if ($payload_length > self::MASK_125) {
324
            if ($payload_length === self::MASK_126) {
325
                $data = $this->read(2); // 126: Payload is a 16-bit unsigned int
326
            } else {
327
                $data = $this->read(8); // 127: Payload is a 64-bit unsigned int
328
            }
329
            $payload_length = bindec(self::sprintB($data));
330
        }
331
332
        // Get masking key.
333
        if ($mask) {
334
            $masking_key = $this->read(4);
335
        }
336
        // Get the actual payload, if any (might not be for e.g. close frames.
337
        if ($payload_length > 0) {
338
            $data = $this->read($payload_length);
339
340
            if ($mask) {
341
                // Unmask payload.
342
                for ($i = 0; $i < $payload_length; $i++) {
343
                    $payload .= ($data[$i] ^ $masking_key[$i % 4]);
0 ignored issues
show
Bug introduced by
The variable $masking_key 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...
344
                }
345
            } else {
346
                $payload = $data;
347
            }
348
        }
349
350
        if ($opcode === 'close') {
351
            // Get the close status.
352
            if ($payload_length >= 2) {
353
                $status_bin = $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($status_bin . '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
     * Tell the socket to close.
386
     *
387
     * @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4
388
     * @param string $message A closing message, max 125 bytes.
389
     * @return bool|null|string
390
     * @throws BadOpcodeException
391
     */
392
    public function close(int $status = 1000, string $message = 'ttfn')
393
    {
394
        $statusBin = sprintf('%016b', $status);
395
        $status_str = '';
396
        foreach (str_split($statusBin, 8) as $binstr) {
397
            $status_str .= chr(bindec($binstr));
398
        }
399
        $this->send($status_str . $message, 'close', true);
400
        $this->isClosing = true;
401
        return $this->receive(); // Receiving a close frame will close the socket now.
402
    }
403
404
    /**
405
     * @param $data
406
     * @throws ConnectionException
407
     */
408
    protected function write(string $data) : void
409
    {
410
        $written = fwrite($this->socket, $data);
411
412
        if ($written < strlen($data)) {
413
            throw new ConnectionException(
414
                "Could only write $written out of " . strlen($data) . " bytes."
415
            );
416
        }
417
    }
418
419
    /**
420
     * @param int $len
421
     * @return string
422
     * @throws ConnectionException
423
     */
424
    protected function read(int $len) : string
425
    {
426
        $data = '';
427
        while (($dataLen = strlen($data)) < $len) {
428
            $buff = fread($this->socket, $len - $dataLen);
429
            if ($buff === false) {
430
                $metadata = stream_get_meta_data($this->socket);
431
                throw new ConnectionException(
432
                    'Broken frame, read ' . strlen($data) . ' of stated '
433
                    . $len . ' bytes.  Stream state: '
434
                    . json_encode($metadata)
435
                );
436
            }
437
            if ($buff === '') {
438
                $metadata = stream_get_meta_data($this->socket);
439
                throw new ConnectionException(
440
                    'Empty read; connection dead?  Stream state: ' . json_encode($metadata)
441
                );
442
            }
443
            $data .= $buff;
444
        }
445
        return $data;
446
    }
447
448
    /**
449
     * Helper to convert a binary to a string of '0' and '1'.
450
     *
451
     * @param $string
452
     * @return string
453
     */
454
    protected static function sprintB(string $string) : string
455
    {
456
        $return = '';
457
        $strLen = strlen($string);
458
        for ($i = 0; $i < $strLen; $i++) {
459
            $return .= sprintf('%08b', ord($string[$i]));
460
        }
461
        return $return;
462
    }
463
464
    /**
465
     * Sec-WebSocket-Key generator
466
     *
467
     * @return string   the 16 character length key
468
     * @throws \Exception
469
     */
470
    private function generateKey() : string
471
    {
472
        $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"$&/()=[]{}0123456789';
473
        $key = '';
474
        $chLen = strlen($chars);
475
        for ($i = 0; $i < self::KEY_GEN_LENGTH; $i++) {
476
            $key .= $chars[random_int(0, $chLen - 1)];
477
        }
478
        return base64_encode($key);
479
    }
480
481
}
482