WscMain::setTimeout()   A
last analyzed

Complexity

Conditions 3
Paths 2

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 4
nc 2
nop 2
dl 0
loc 8
rs 10
c 0
b 0
f 0
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
/**
12
 * Class WscMain
13
 *
14
 * @package WSSC\Components
15
 *
16
 * @property ClientConfig config
17
 */
18
class WscMain implements WscCommonsContract
19
{
20
    use WSClientTrait;
21
22
    /**
23
     * @var resource|bool
24
     */
25
    private $socket;
26
27
    /**
28
     * @var bool
29
     */
30
    private bool $isConnected = false;
31
32
    /**
33
     * @var bool
34
     */
35
    private bool $isClosing = false;
36
37
    /**
38
     * @var string
39
     */
40
    private string $lastOpcode;
41
42
    /**
43
     * @var float|int
44
     */
45
    private $closeStatus;
46
47
    /**
48
     * @var string|null
49
     */
50
    private ?string $hugePayload;
51
52
    /**
53
     * @var array|int[]
54
     */
55
    private static array $opcodes = [
56
        CommonsContract::EVENT_TYPE_CONTINUATION => 0,
57
        CommonsContract::EVENT_TYPE_TEXT => 1,
58
        CommonsContract::EVENT_TYPE_BINARY => 2,
59
        CommonsContract::EVENT_TYPE_CLOSE => 8,
60
        CommonsContract::EVENT_TYPE_PING => 9,
61
        CommonsContract::EVENT_TYPE_PONG => 10,
62
    ];
63
64
    /**
65
     * @var string
66
     */
67
    protected string $socketUrl = '';
68
69
    /**
70
     * @var ClientConfig
71
     */
72
    protected ClientConfig $config;
73
74
    /**
75
     * @param ClientConfig $config
76
     * @throws BadUriException
77
     * @throws ConnectionException
78
     */
79
    protected function connect(ClientConfig $config): void
80
    {
81
        $this->config = $config;
82
        $urlParts = parse_url($this->socketUrl);
83
84
        $this->config->setScheme($urlParts['scheme']);
85
        $this->config->setHost($urlParts['host']);
86
        $this->config->setUser($urlParts);
87
        $this->config->setPassword($urlParts);
88
        $this->config->setPort($urlParts);
89
90
        $pathWithQuery = $this->getPathWithQuery($urlParts);
91
        $hostUri = $this->getHostUri($this->config);
92
93
        // Set the stream context options if they're already set in the config
94
        $context = $this->getStreamContext();
95
        if ($this->config->hasProxy()) {
96
            $this->socket = $this->proxy();
97
        } else {
98
            $this->socket = @stream_socket_client(
99
                $hostUri . ':' . $this->config->getPort(),
100
                $errno,
101
                $errstr,
102
                $this->config->getTimeout(),
103
                STREAM_CLIENT_CONNECT,
104
                $context
105
            );
106
        }
107
108
        if ($this->socket === false) {
109
            throw new ConnectionException(
110
                "Could not open socket to \"{$this->config->getHost()}:{$this->config->getPort()}\": $errstr ($errno).",
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $errno does not seem to be defined for all execution paths leading up to this point.
Loading history...
Comprehensibility Best Practice introduced by
The variable $errstr does not seem to be defined for all execution paths leading up to this point.
Loading history...
111
                CommonsContract::CLIENT_COULD_NOT_OPEN_SOCKET
112
            );
113
        }
114
115
        // Set timeout on the stream as well.
116
        stream_set_timeout($this->socket, $this->config->getTimeout());
117
118
        // Generate the WebSocket key.
119
        $key = $this->generateKey();
120
        $headers = [
121
            'Host'                  => $this->config->getHost() . ':' . $this->config->getPort(),
122
            'User-Agent'            => 'websocket-client-php',
123
            'Connection'            => 'Upgrade',
124
            'Upgrade'               => 'WebSocket',
125
            'Sec-WebSocket-Key'     => $key,
126
            'Sec-Websocket-Version' => '13',
127
        ];
128
129
        // Handle basic authentication.
130
        if ($this->config->getUser() || $this->config->getPassword()) {
131
            $headers['authorization'] = 'Basic ' . base64_encode($this->config->getUser() . ':' . $this->config->getPassword()) . "\r\n";
132
        }
133
134
        // Add and override with headers from options.
135
        if (!empty($this->config->getHeaders())) {
136
            $headers = array_merge($headers, $this->config->getHeaders());
137
        }
138
139
        $header = $this->getHeaders($pathWithQuery, $headers);
140
141
        // Send headers.
142
        $this->write($header);
143
144
        // Get server response header
145
        // @todo Handle version switching
146
        $this->validateResponse($this->config, $pathWithQuery, $key);
147
        $this->isConnected = true;
148
    }
149
150
151
    /**
152
     * Init a proxy connection
153
     *
154
     * @return bool|resource
155
     * @throws \InvalidArgumentException
156
     * @throws \WSSC\Exceptions\ConnectionException
157
     */
158
    private function proxy()
159
    {
160
        $sock = @stream_socket_client(
161
            WscCommonsContract::TCP_SCHEME . $this->config->getProxyIp() . ':' . $this->config->getProxyPort(),
162
            $errno,
163
            $errstr,
164
            $this->config->getTimeout(),
165
            STREAM_CLIENT_CONNECT,
166
            $this->getStreamContext()
167
        );
168
        $write = "CONNECT {$this->config->getProxyIp()}:{$this->config->getProxyPort()} HTTP/1.1\r\n";
169
        $auth = $this->config->getProxyAuth();
170
        if ($auth !== NULL) {
171
            $write .= "Proxy-Authorization: Basic {$auth}\r\n";
172
        }
173
        $write .= "\r\n";
174
        fwrite($sock, $write);
0 ignored issues
show
Bug introduced by
It seems like $sock can also be of type false; however, parameter $stream of fwrite() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

174
        fwrite(/** @scrutinizer ignore-type */ $sock, $write);
Loading history...
175
        $resp = fread($sock, 1024);
0 ignored issues
show
Bug introduced by
It seems like $sock can also be of type false; however, parameter $stream of fread() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

175
        $resp = fread(/** @scrutinizer ignore-type */ $sock, 1024);
Loading history...
176
177
        if (preg_match(self::PROXY_MATCH_RESP, $resp) === 1) {
178
            return $sock;
179
        }
180
181
        throw new ConnectionException('Failed to connect to the host via proxy');
182
    }
183
184
185
    /**
186
     * @return mixed|resource
187
     * @throws \InvalidArgumentException
188
     */
189
    private function getStreamContext()
190
    {
191
        if ($this->config->getContext() !== null) {
192
            // Suppress the error since we'll catch it below
193
            if (@get_resource_type($this->config->getContext()) === 'stream-context') {
194
                return $this->config->getContext();
195
            }
196
197
            throw new \InvalidArgumentException(
198
                'Stream context is invalid',
199
                CommonsContract::CLIENT_INVALID_STREAM_CONTEXT
200
            );
201
        }
202
203
        return stream_context_create($this->config->getContextOptions());
204
    }
205
206
    /**
207
     * @param mixed $urlParts
208
     * @return string
209
     */
210
    private function getPathWithQuery($urlParts): string
211
    {
212
        $path = isset($urlParts['path']) ? $urlParts['path'] : '/';
213
        $query = isset($urlParts['query']) ? $urlParts['query'] : '';
214
        $fragment = isset($urlParts['fragment']) ? $urlParts['fragment'] : '';
215
        $pathWithQuery = $path;
216
        if (!empty($query)) {
217
            $pathWithQuery .= '?' . $query;
218
        }
219
        if (!empty($fragment)) {
220
            $pathWithQuery .= '#' . $fragment;
221
        }
222
223
        return $pathWithQuery;
224
    }
225
226
    /**
227
     * @param string $pathWithQuery
228
     * @param array $headers
229
     * @return string
230
     */
231
    private function getHeaders(string $pathWithQuery, array $headers): string
232
    {
233
        return 'GET ' . $pathWithQuery . " HTTP/1.1\r\n"
234
            . implode(
235
                "\r\n",
236
                array_map(
237
                    function ($key, $value) {
238
                        return "$key: $value";
239
                    },
240
                    array_keys($headers),
241
                    $headers
242
                )
243
            )
244
            . "\r\n\r\n";
245
    }
246
247
    /**
248
     * @return string
249
     */
250
    public function getLastOpcode(): string
251
    {
252
        return $this->lastOpcode;
253
    }
254
255
    /**
256
     * @return int
257
     */
258
    public function getCloseStatus(): int
259
    {
260
        return $this->closeStatus;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->closeStatus could return the type double which is incompatible with the type-hinted return integer. Consider adding an additional type-check to rule them out.
Loading history...
261
    }
262
263
    /**
264
     * @return bool
265
     */
266
    public function isConnected(): bool
267
    {
268
        return $this->isConnected;
269
    }
270
271
    /**
272
     * @param int $timeout
273
     * @param null $microSecs
0 ignored issues
show
Documentation Bug introduced by
Are you sure the doc-type for parameter $microSecs is correct as it would always require null to be passed?
Loading history...
274
     * @return WscMain
275
     */
276
    public function setTimeout(int $timeout, $microSecs = null): WscMain
277
    {
278
        $this->config->setTimeout($timeout);
279
        if ($this->socket && get_resource_type($this->socket) === 'stream') {
0 ignored issues
show
Bug introduced by
It seems like $this->socket can also be of type true; however, parameter $resource of get_resource_type() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

279
        if ($this->socket && get_resource_type(/** @scrutinizer ignore-type */ $this->socket) === 'stream') {
Loading history...
280
            stream_set_timeout($this->socket, $timeout, $microSecs);
0 ignored issues
show
Bug introduced by
$microSecs of type null is incompatible with the type integer expected by parameter $microseconds of stream_set_timeout(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

280
            stream_set_timeout($this->socket, $timeout, /** @scrutinizer ignore-type */ $microSecs);
Loading history...
Bug introduced by
It seems like $this->socket can also be of type true; however, parameter $stream of stream_set_timeout() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

280
            stream_set_timeout(/** @scrutinizer ignore-type */ $this->socket, $timeout, $microSecs);
Loading history...
281
        }
282
283
        return $this;
284
    }
285
286
    /**
287
     * Sends message to opened socket connection client->server
288
     *
289
     * @param $payload
290
     * @param string $opcode
291
     * @throws \InvalidArgumentException
292
     * @throws BadOpcodeException
293
     * @throws BadUriException
294
     * @throws ConnectionException
295
     * @throws \Exception
296
     */
297
    public function send($payload, $opcode = CommonsContract::EVENT_TYPE_TEXT): void
298
    {
299
        if (!$this->isConnected) {
300
            $this->connect(new ClientConfig());
301
        }
302
        if (array_key_exists($opcode, self::$opcodes) === false) {
303
            throw new BadOpcodeException(
304
                "Bad opcode '$opcode'.  Try 'text' or 'binary'.",
305
                CommonsContract::CLIENT_BAD_OPCODE
306
            );
307
        }
308
        // record the length of the payload
309
        $payloadLength = strlen($payload);
310
311
        $fragmentCursor = 0;
312
        // while we have data to send
313
        while ($payloadLength > $fragmentCursor) {
314
            // get a fragment of the payload
315
            $subPayload = substr($payload, $fragmentCursor, $this->config->getFragmentSize());
316
317
            // advance the cursor
318
            $fragmentCursor += $this->config->getFragmentSize();
319
320
            // is this the final fragment to send?
321
            $final = $payloadLength <= $fragmentCursor;
322
323
            // send the fragment
324
            $this->sendFragment($final, $subPayload, $opcode, true);
325
326
            // all fragments after the first will be marked a continuation
327
            $opcode = 'continuation';
328
        }
329
    }
330
331
    /**
332
     * Receives message client<-server
333
     *
334
     * @return null|string
335
     * @throws \InvalidArgumentException
336
     * @throws BadOpcodeException
337
     * @throws BadUriException
338
     * @throws ConnectionException
339
     * @throws \Exception
340
     */
341
    public function receive(): ?string
342
    {
343
        if (!$this->isConnected) {
344
            $this->connect(new ClientConfig());
345
        }
346
        $this->hugePayload = '';
347
348
        $response = null;
349
        while ($response === null) {
350
            $response = $this->receiveFragment();
351
        }
352
353
        return $response;
354
    }
355
356
    /**
357
     * Tell the socket to close.
358
     *
359
     * @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4
360
     * @param string $message A closing message, max 125 bytes.
361
     * @return bool|null|string
362
     * @throws \InvalidArgumentException
363
     * @throws BadOpcodeException
364
     * @throws BadUriException
365
     * @throws ConnectionException
366
     * @throws \Exception
367
     */
368
    public function close(int $status = 1000, string $message = 'ttfn')
369
    {
370
        $statusBin = sprintf('%016b', $status);
371
        $statusStr = '';
372
373
        foreach (str_split($statusBin, 8) as $binstr) {
374
            $statusStr .= chr(bindec($binstr));
0 ignored issues
show
Bug introduced by
It seems like bindec($binstr) can also be of type double; however, parameter $codepoint of chr() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

374
            $statusStr .= chr(/** @scrutinizer ignore-type */ bindec($binstr));
Loading history...
375
        }
376
377
        $this->send($statusStr . $message, CommonsContract::EVENT_TYPE_CLOSE);
378
        $this->isClosing = true;
379
380
        return $this->receive(); // Receiving a close frame will close the socket now.
381
    }
382
383
    /**
384
     * @param $data
385
     * @throws ConnectionException
386
     */
387
    protected function write(string $data): void
388
    {
389
        $written = fwrite($this->socket, $data);
0 ignored issues
show
Bug introduced by
It seems like $this->socket can also be of type boolean; however, parameter $stream of fwrite() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

389
        $written = fwrite(/** @scrutinizer ignore-type */ $this->socket, $data);
Loading history...
390
391
        if ($written < strlen($data)) {
392
            throw new ConnectionException(
393
                "Could only write $written out of " . strlen($data) . ' bytes.',
394
                CommonsContract::CLIENT_COULD_ONLY_WRITE_LESS
395
            );
396
        }
397
    }
398
399
    /**
400
     * @param int $len
401
     * @return string
402
     * @throws ConnectionException
403
     */
404
    protected function read(int $len): string
405
    {
406
        $data = '';
407
        while (($dataLen = strlen($data)) < $len) {
408
            $buff = fread($this->socket, $len - $dataLen);
0 ignored issues
show
Bug introduced by
It seems like $this->socket can also be of type boolean; however, parameter $stream of fread() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

408
            $buff = fread(/** @scrutinizer ignore-type */ $this->socket, $len - $dataLen);
Loading history...
409
410
            if ($buff === false) {
411
                $metadata = stream_get_meta_data($this->socket);
0 ignored issues
show
Bug introduced by
It seems like $this->socket can also be of type boolean; however, parameter $stream of stream_get_meta_data() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

411
                $metadata = stream_get_meta_data(/** @scrutinizer ignore-type */ $this->socket);
Loading history...
412
                throw new ConnectionException(
413
                    'Broken frame, read ' . strlen($data) . ' of stated '
414
                    . $len . ' bytes.  Stream state: '
415
                    . json_encode($metadata),
416
                    CommonsContract::CLIENT_BROKEN_FRAME
417
                );
418
            }
419
420
            if ($buff === '') {
421
                $metadata = stream_get_meta_data($this->socket);
422
                throw new ConnectionException(
423
                    'Empty read; connection dead?  Stream state: ' . json_encode($metadata),
424
                    CommonsContract::CLIENT_EMPTY_READ
425
                );
426
            }
427
            $data .= $buff;
428
        }
429
430
        return $data;
431
    }
432
433
    /**
434
     * Helper to convert a binary to a string of '0' and '1'.
435
     *
436
     * @param $string
437
     * @return string
438
     */
439
    protected static function sprintB(string $string): string
440
    {
441
        $return = '';
442
        $strLen = strlen($string);
443
        for ($i = 0; $i < $strLen; $i++) {
444
            $return .= sprintf('%08b', ord($string[$i]));
445
        }
446
447
        return $return;
448
    }
449
450
    /**
451
     * Sec-WebSocket-Key generator
452
     *
453
     * @return string   the 16 character length key
454
     * @throws \Exception
455
     */
456
    private function generateKey(): string
457
    {
458
        $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"$&/()=[]{}0123456789';
459
        $key = '';
460
        $chLen = strlen($chars);
461
        for ($i = 0; $i < self::KEY_GEN_LENGTH; $i++) {
462
            $key .= $chars[random_int(0, $chLen - 1)];
463
        }
464
465
        return base64_encode($key);
466
    }
467
}
468