Completed
Push — master ( 2cecc7...7c9e4e )
by Arthur
04:46
created

WscMain::setHeaders()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 6
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 1
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()
40
    {
41
        $urlParts = parse_url($this->socketUrl);
42
43
        $scheme = $urlParts['scheme'];
44
        $host = $urlParts['host'];
45
        $user = isset($urlParts['user']) ? $urlParts['user'] : '';
46
        $pass = isset($urlParts['pass']) ? $urlParts['pass'] : '';
47
        $port = isset($urlParts['port']) ? $urlParts['port'] : ($scheme === 'wss' ? 443 : 80);
48
49
        $pathWithQuery = $this->getPathWithQuery($urlParts);
50
        $hostUri = $this->getHostUri($scheme, $host);
51
        // Set the stream context options if they're already set in the config
52
        $context = $this->getStreamContext();
53
        $this->socket = @stream_socket_client(
54
            $hostUri . ':' . $port, $errno, $errstr, $this->options['timeout'], STREAM_CLIENT_CONNECT, $context
55
        );
56
        if ($this->socket === false) {
57
            throw new ConnectionException(
58
                "Could not open socket to \"$host:$port\": $errstr ($errno)."
59
            );
60
        }
61
62
        // Set timeout on the stream as well.
63
        stream_set_timeout($this->socket, $this->options['timeout']);
64
65
        // Generate the WebSocket key.
66
        $key = $this->generateKey();
67
        $headers = [
68
            'Host'                  => $host . ':' . $port,
69
            'User-Agent'            => 'websocket-client-php',
70
            'Connection'            => 'Upgrade',
71
            'Upgrade'               => 'WebSocket',
72
            'Sec-WebSocket-Key'     => $key,
73
            'Sec-Websocket-Version' => '13',
74
        ];
75
76
        // Handle basic authentication.
77
        if ($user || $pass) {
78
            $headers['authorization'] = 'Basic ' . base64_encode($user . ':' . $pass) . "\r\n";
79
        }
80
81
        // Add and override with headers from options.
82
        if (isset($this->options['headers'])) {
83
            $headers = array_merge($headers, $this->options['headers']);
84
        }
85
86
        $header = $this->getHeaders($pathWithQuery, $headers);
87
88
        // Send headers.
89
        $this->write($header);
90
91
        // Get server response header
92
        // @todo Handle version switching
93
        $this->validateResponse($scheme, $host, $pathWithQuery, $key);
94
        $this->isConnected = true;
95
    }
96
97
    /**
98
     * @param string $scheme
99
     * @param string $host
100
     * @return string
101
     * @throws BadUriException
102
     */
103
    private function getHostUri(string $scheme, string $host): string
104
    {
105
        if (in_array($scheme, ['ws', 'wss'], true) === false) {
106
            throw new BadUriException(
107
                "Url should have scheme ws or wss, not '$scheme' from URI '$this->socketUrl' ."
108
            );
109
        }
110
111
        return ($scheme === 'wss' ? 'ssl' : 'tcp') . '://' . $host;
112
    }
113
114
    /**
115
     * @param string $scheme
116
     * @param string $host
117
     * @param string $pathWithQuery
118
     * @param string $key
119
     * @throws ConnectionException
120
     */
121
    private function validateResponse(string $scheme, string $host, string $pathWithQuery, string $key)
122
    {
123
        $response = stream_get_line($this->socket, self::DEFAULT_RESPONSE_HEADER, "\r\n\r\n");
124
        if (!preg_match(self::SEC_WEBSOCKET_ACCEPT_PTTRN, $response, $matches)) {
125
            $address = $scheme . '://' . $host . $pathWithQuery;
126
            throw new ConnectionException(
127
                "Connection to '{$address}' failed: Server sent invalid upgrade response:\n"
128
                . $response
129
            );
130
        }
131
132
        $keyAccept = trim($matches[1]);
133
        $expectedResonse = base64_encode(pack('H*', sha1($key . self::SERVER_KEY_ACCEPT)));
134
        if ($keyAccept !== $expectedResonse) {
135
            throw new ConnectionException('Server sent bad upgrade response.');
136
        }
137
    }
138
139
    /**
140
     * @return mixed|resource
141
     * @throws \InvalidArgumentException
142
     */
143
    private function getStreamContext()
144
    {
145
        if (isset($this->options['context'])) {
146
            // Suppress the error since we'll catch it below
147
            if (@get_resource_type($this->options['context']) === 'stream-context') {
148
                return $this->options['context'];
149
            }
150
151
            throw new \InvalidArgumentException(
152
                "Stream context in \$options['context'] isn't a valid context"
153
            );
154
        }
155
156
        return stream_context_create();
157
    }
158
159
    /**
160
     * @param mixed $urlParts
161
     * @return string
162
     */
163
    private function getPathWithQuery($urlParts): string
164
    {
165
        $path = isset($urlParts['path']) ? $urlParts['path'] : '/';
166
        $query = isset($urlParts['query']) ? $urlParts['query'] : '';
167
        $fragment = isset($urlParts['fragment']) ? $urlParts['fragment'] : '';
168
        $pathWithQuery = $path;
169
        if (!empty($query)) {
170
            $pathWithQuery .= '?' . $query;
171
        }
172
        if (!empty($fragment)) {
173
            $pathWithQuery .= '#' . $fragment;
174
        }
175
176
        return $pathWithQuery;
177
    }
178
179
    /**
180
     * @param string $pathWithQuery
181
     * @param array $headers
182
     * @return string
183
     */
184
    private function getHeaders(string $pathWithQuery, array $headers): string
185
    {
186
        return 'GET ' . $pathWithQuery . " HTTP/1.1\r\n"
187
            . implode(
188
                "\r\n", array_map(
189
                    function ($key, $value) {
190
                        return "$key: $value";
191
                    }, array_keys($headers), $headers
192
                )
193
            )
194
            . "\r\n\r\n";
195
    }
196
197
    /**
198
     * @return string
199
     */
200
    public function getLastOpcode(): string
201
    {
202
        return $this->lastOpcode;
203
    }
204
205
    /**
206
     * @return int
207
     */
208
    public function getCloseStatus(): int
209
    {
210
        return $this->closeStatus;
211
    }
212
213
    /**
214
     * @return bool
215
     */
216
    public function isConnected(): bool
217
    {
218
        return $this->isConnected;
219
    }
220
221
    /**
222
     * @param int $timeout
223
     * @param null $microSecs
224
     * @return WscMain
225
     */
226
    public function setTimeout(int $timeout, $microSecs = NULL): WscMain
227
    {
228
        $this->options['timeout'] = $timeout;
229
230
        if ($this->socket && get_resource_type($this->socket) === 'stream') {
231
            stream_set_timeout($this->socket, $timeout, $microSecs);
232
        }
233
234
        return $this;
235
    }
236
237
    /**
238
     * @param $fragmentSize
239
     * @return WscMain
240
     */
241
    public function setFragmentSize(int $fragmentSize): WscMain
242
    {
243
        $this->options['fragment_size'] = $fragmentSize;
244
245
        return $this;
246
    }
247
248
    public function getFragmentSize()
249
    {
250
        return $this->options['fragment_size'];
251
    }
252
253
    public function setHeaders(array $headers)
254
    {
255
        $this->options['headers'] = $headers;
256
257
        return $this;
258
    }
259
260
    public function send($payload, $opcode = CommonsContract::EVENT_TYPE_TEXT)
261
    {
262
        if (!$this->isConnected) {
263
            $this->connect();
264
        }
265
        if (array_key_exists($opcode, self::$opcodes) === false) {
266
            throw new BadOpcodeException("Bad opcode '$opcode'.  Try 'text' or 'binary'.");
267
        }
268
        // record the length of the payload
269
        $payload_length = strlen($payload);
270
271
        $fragment_cursor = 0;
272
        // while we have data to send
273
        while ($payload_length > $fragment_cursor) {
274
            // get a fragment of the payload
275
            $sub_payload = substr($payload, $fragment_cursor, $this->options['fragment_size']);
276
277
            // advance the cursor
278
            $fragment_cursor += $this->options['fragment_size'];
279
280
            // is this the final fragment to send?
281
            $final = $payload_length <= $fragment_cursor;
282
283
            // send the fragment
284
            $this->sendFragment($final, $sub_payload, $opcode, true);
285
286
            // all fragments after the first will be marked a continuation
287
            $opcode = 'continuation';
288
        }
289
    }
290
291
    /**
292
     * @param $final
293
     * @param $payload
294
     * @param $opcode
295
     * @param $masked
296
     * @throws ConnectionException
297
     * @throws \Exception
298
     */
299
    protected function sendFragment($final, $payload, $opcode, $masked)
300
    {
301
        // Binary string for header.
302
        $frameHeadBin = '';
303
        // Write FIN, final fragment bit.
304
        $frameHeadBin .= (bool)$final ? '1' : '0';
305
        // 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...
306
        $frameHeadBin .= '000';
307
        // Opcode rest of the byte.
308
        $frameHeadBin .= sprintf('%04b', self::$opcodes[$opcode]);
309
        // Use masking?
310
        $frameHeadBin .= $masked ? '1' : '0';
311
312
        // 7 bits of payload length...
313
        $payloadLen = strlen($payload);
314
        if ($payloadLen > self::MAX_BYTES_READ) {
315
            $frameHeadBin .= decbin(self::MASK_127);
316
            $frameHeadBin .= sprintf('%064b', $payloadLen);
317
        } else if ($payloadLen > self::MASK_125) {
318
            $frameHeadBin .= decbin(self::MASK_126);
319
            $frameHeadBin .= sprintf('%016b', $payloadLen);
320
        } else {
321
            $frameHeadBin .= sprintf('%07b', $payloadLen);
322
        }
323
324
        $frame = '';
325
326
        // Write frame head to frame.
327
        foreach (str_split($frameHeadBin, 8) as $binstr) {
328
            $frame .= chr(bindec($binstr));
329
        }
330
        // Handle masking
331
        if ($masked) {
332
            // generate a random mask:
333
            $mask = '';
334 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...
335
                $mask .= chr(random_int(0, 255));
336
            }
337
            $frame .= $mask;
338
        }
339
340
        // Append payload to frame:
341 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...
342
            $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...
343
        }
344
345
        $this->write($frame);
346
    }
347
348
    public function receive()
349
    {
350
        if (!$this->isConnected) {
351
            $this->connect();
352
        }
353
        $this->hugePayload = '';
354
355
        $response = NULL;
356
        while (NULL === $response) {
357
            $response = $this->receiveFragment();
358
        }
359
360
        return $response;
361
    }
362
363
    /**
364
     * @return null|string
365
     * @throws BadOpcodeException
366
     * @throws ConnectionException
367
     */
368
    protected function receiveFragment()
369
    {
370
        // Just read the main fragment information first.
371
        $data = $this->read(2);
372
373
        // Is this the final fragment?  // Bit 0 in byte 0
374
        /// @todo Handle huge payloads with multiple fragments.
375
        $final = (bool)(ord($data[0]) & 1 << 7);
376
377
        // Parse opcode
378
        $opcode_int = ord($data[0]) & 31; // Bits 4-7
379
        $opcode_ints = array_flip(self::$opcodes);
380
        if (!array_key_exists($opcode_int, $opcode_ints)) {
381
            throw new ConnectionException("Bad opcode in websocket frame: $opcode_int");
382
        }
383
        $opcode = $opcode_ints[$opcode_int];
384
385
        // record the opcode if we are not receiving a continutation fragment
386
        if ($opcode !== 'continuation') {
387
            $this->lastOpcode = $opcode;
388
        }
389
390
        $payloadLength = $this->getPayloadLength($data);
391
        $payload = $this->getPayloadData($data, $payloadLength);
392
        if ($opcode === CommonsContract::EVENT_TYPE_CLOSE) {
393
            // Get the close status.
394
            if ($payloadLength >= 2) {
395
                $statusBin = $payload[0] . $payload[1];
396
                $status = bindec(sprintf('%08b%08b', ord($payload[0]), ord($payload[1])));
397
                $this->closeStatus = $status;
398
                $payload = substr($payload, 2);
399
400
                if (!$this->isClosing) {
401
                    $this->send($statusBin . 'Close acknowledged: ' . $status,
402
                        CommonsContract::EVENT_TYPE_CLOSE); // Respond.
403
                }
404
            }
405
406
            if ($this->isClosing) {
407
                $this->isClosing = false; // A close response, all done.
408
            }
409
410
            fclose($this->socket);
411
            $this->isConnected = false;
412
        }
413
414
        if (!$final) {
415
            $this->hugePayload .= $payload;
416
417
            return NULL;
418
        } // this is the last fragment, and we are processing a huge_payload
419
420
        if ($this->hugePayload) {
421
            $payload = $this->hugePayload .= $payload;
422
            $this->hugePayload = NULL;
423
        }
424
425
        return $payload;
426
    }
427
428
    /**
429
     * @param string $data
430
     * @param int $payloadLength
431
     * @return string
432
     * @throws ConnectionException
433
     */
434
    private function getPayloadData(string $data, int $payloadLength)
435
    {
436
        // Masking?
437
        $mask = (bool)(ord($data[1]) >> 7);  // Bit 0 in byte 1
438
        $payload = '';
439
        $maskingKey = '';
440
        // Get masking key.
441
        if ($mask) {
442
            $maskingKey = $this->read(4);
443
        }
444
        // Get the actual payload, if any (might not be for e.g. close frames.
445
        if ($payloadLength > 0) {
446
            $data = $this->read($payloadLength);
447
448
            if ($mask) {
449
                // Unmask payload.
450
                for ($i = 0; $i < $payloadLength; $i++) {
451
                    $payload .= ($data[$i] ^ $maskingKey[$i % 4]);
452
                }
453
            } else {
454
                $payload = $data;
455
            }
456
        }
457
458
        return $payload;
459
    }
460
461
    /**
462
     * @param string $data
463
     * @return float|int
464
     * @throws ConnectionException
465
     */
466
    private function getPayloadLength(string $data)
467
    {
468
        $payloadLength = (int)ord($data[1]) & self::MASK_127; // Bits 1-7 in byte 1
469
        if ($payloadLength > self::MASK_125) {
470
            if ($payloadLength === self::MASK_126) {
471
                $data = $this->read(2); // 126: Payload is a 16-bit unsigned int
472
            } else {
473
                $data = $this->read(8); // 127: Payload is a 64-bit unsigned int
474
            }
475
            $payloadLength = bindec(self::sprintB($data));
476
        }
477
478
        return $payloadLength;
479
    }
480
481
    /**
482
     * Tell the socket to close.
483
     *
484
     * @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4
485
     * @param string $message A closing message, max 125 bytes.
486
     * @return bool|null|string
487
     * @throws BadOpcodeException
488
     */
489
    public function close(int $status = 1000, string $message = 'ttfn')
490
    {
491
        $statusBin = sprintf('%016b', $status);
492
        $status_str = '';
493
        foreach (str_split($statusBin, 8) as $binstr) {
494
            $status_str .= chr(bindec($binstr));
495
        }
496
        $this->send($status_str . $message, CommonsContract::EVENT_TYPE_CLOSE);
497
        $this->isClosing = true;
498
499
        return $this->receive(); // Receiving a close frame will close the socket now.
500
    }
501
502
    /**
503
     * @param $data
504
     * @throws ConnectionException
505
     */
506
    protected function write(string $data)
507
    {
508
        $written = fwrite($this->socket, $data);
509
510
        if ($written < strlen($data)) {
511
            throw new ConnectionException(
512
                "Could only write $written out of " . strlen($data) . ' bytes.'
513
            );
514
        }
515
    }
516
517
    /**
518
     * @param int $len
519
     * @return string
520
     * @throws ConnectionException
521
     */
522
    protected function read(int $len): string
523
    {
524
        $data = '';
525
        while (($dataLen = strlen($data)) < $len) {
526
            $buff = fread($this->socket, $len - $dataLen);
527
            if ($buff === false) {
528
                $metadata = stream_get_meta_data($this->socket);
529
                throw new ConnectionException(
530
                    'Broken frame, read ' . strlen($data) . ' of stated '
531
                    . $len . ' bytes.  Stream state: '
532
                    . json_encode($metadata)
533
                );
534
            }
535
            if ($buff === '') {
536
                $metadata = stream_get_meta_data($this->socket);
537
                throw new ConnectionException(
538
                    'Empty read; connection dead?  Stream state: ' . json_encode($metadata)
539
                );
540
            }
541
            $data .= $buff;
542
        }
543
544
        return $data;
545
    }
546
547
    /**
548
     * Helper to convert a binary to a string of '0' and '1'.
549
     *
550
     * @param $string
551
     * @return string
552
     */
553
    protected static function sprintB(string $string): string
554
    {
555
        $return = '';
556
        $strLen = strlen($string);
557
        for ($i = 0; $i < $strLen; $i++) {
558
            $return .= sprintf('%08b', ord($string[$i]));
559
        }
560
561
        return $return;
562
    }
563
564
    /**
565
     * Sec-WebSocket-Key generator
566
     *
567
     * @return string   the 16 character length key
568
     * @throws \Exception
569
     */
570
    private function generateKey(): string
571
    {
572
        $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"$&/()=[]{}0123456789';
573
        $key = '';
574
        $chLen = strlen($chars);
575
        for ($i = 0; $i < self::KEY_GEN_LENGTH; $i++) {
576
            $key .= $chars[random_int(0, $chLen - 1)];
577
        }
578
579
        return base64_encode($key);
580
    }
581
582
}
583