Completed
Push — master ( bbe44d...bf8056 )
by Arthur
01:42 queued 10s
created

WscMain::getCloseStatus()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 4
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 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
    private $socket;
23
    private $isConnected = false;
24
    private $isClosing = false;
25
    private $lastOpcode;
26
    private $closeStatus;
27
    private $hugePayload;
28
29
    private static $opcodes = [
30
        CommonsContract::EVENT_TYPE_CONTINUATION => 0,
31
        CommonsContract::EVENT_TYPE_TEXT         => 1,
32
        CommonsContract::EVENT_TYPE_BINARY       => 2,
33
        CommonsContract::EVENT_TYPE_CLOSE        => 8,
34
        CommonsContract::EVENT_TYPE_PING         => 9,
35
        CommonsContract::EVENT_TYPE_PONG         => 10,
36
    ];
37
38
    protected $socketUrl = '';
39
    protected $config;
40
41
    /**
42
     * @throws \InvalidArgumentException
43
     * @throws BadUriException
44
     * @throws ConnectionException
45
     * @throws \Exception
46
     */
47
    protected function connect()
48
    {
49
        $urlParts = parse_url($this->socketUrl);
50
51
        $this->config->setScheme($urlParts['scheme']);
52
        $this->config->setHost($urlParts['host']);
53
        $this->config->setUser($urlParts);
0 ignored issues
show
Security Bug introduced by
It seems like $urlParts defined by parse_url($this->socketUrl) on line 49 can also be of type false; however, WSSC\Components\ClientConfig::setUser() does only seem to accept array, did you maybe forget to handle an error condition?

This check looks for type mismatches where the missing type is false. This is usually indicative of an error condtion.

Consider the follow example

<?php

function getDate($date)
{
    if ($date !== null) {
        return new DateTime($date);
    }

    return false;
}

This function either returns a new DateTime object or false, if there was an error. This is a typical pattern in PHP programming to show that an error has occurred without raising an exception. The calling code should check for this returned false before passing on the value to another function or method that may not be able to handle a false.

Loading history...
54
        $this->config->setPassword($urlParts);
0 ignored issues
show
Security Bug introduced by
It seems like $urlParts defined by parse_url($this->socketUrl) on line 49 can also be of type false; however, WSSC\Components\ClientConfig::setPassword() does only seem to accept array, did you maybe forget to handle an error condition?

This check looks for type mismatches where the missing type is false. This is usually indicative of an error condtion.

Consider the follow example

<?php

function getDate($date)
{
    if ($date !== null) {
        return new DateTime($date);
    }

    return false;
}

This function either returns a new DateTime object or false, if there was an error. This is a typical pattern in PHP programming to show that an error has occurred without raising an exception. The calling code should check for this returned false before passing on the value to another function or method that may not be able to handle a false.

Loading history...
55
        $this->config->setPort($urlParts);
0 ignored issues
show
Security Bug introduced by
It seems like $urlParts defined by parse_url($this->socketUrl) on line 49 can also be of type false; however, WSSC\Components\ClientConfig::setPort() does only seem to accept array, did you maybe forget to handle an error condition?

This check looks for type mismatches where the missing type is false. This is usually indicative of an error condtion.

Consider the follow example

<?php

function getDate($date)
{
    if ($date !== null) {
        return new DateTime($date);
    }

    return false;
}

This function either returns a new DateTime object or false, if there was an error. This is a typical pattern in PHP programming to show that an error has occurred without raising an exception. The calling code should check for this returned false before passing on the value to another function or method that may not be able to handle a false.

Loading history...
56
57
        $pathWithQuery = $this->getPathWithQuery($urlParts);
58
        $hostUri = $this->getHostUri($this->config);
59
60
        // Set the stream context options if they're already set in the config
61
        $context = $this->getStreamContext();
62
63
64
        if ($this->config->hasProxy()) {
65
            $this->socket = $this->proxy($this->config);
66
        } else {
67
            $this->socket = @stream_socket_client(
68
                $hostUri . ':' . $this->config->getPort(),
69
                $errno,
70
                $errstr,
71
                $this->config->getTimeout(),
72
                STREAM_CLIENT_CONNECT,
73
                $context
74
            );
75
        }
76
77
        if ($this->socket === false) {
78
            throw new ConnectionException(
79
                "Could not open socket to \"{$this->config->getHost()}:{$this->config->getPort()}\": $errstr ($errno).",
80
                CommonsContract::CLIENT_COULD_NOT_OPEN_SOCKET
81
            );
82
        }
83
84
        // Set timeout on the stream as well.
85
        stream_set_timeout($this->socket, $this->config->getTimeout());
86
87
        // Generate the WebSocket key.
88
        $key = $this->generateKey();
89
        $headers = [
90
            'Host'                  => $this->config->getHost() . ':' . $this->config->getPort(),
91
            'User-Agent'            => 'websocket-client-php',
92
            'Connection'            => 'Upgrade',
93
            'Upgrade'               => 'WebSocket',
94
            'Sec-WebSocket-Key'     => $key,
95
            'Sec-Websocket-Version' => '13',
96
        ];
97
98
        // Handle basic authentication.
99
        if ($this->config->getUser() || $this->config->getPassword()) {
100
            $headers['authorization'] = 'Basic ' . base64_encode($this->config->getUser() . ':' . $this->config->getPassword()) . "\r\n";
101
        }
102
103
        // Add and override with headers from options.
104
        if (!empty($this->config->getHeaders())) {
105
            $headers = array_merge($headers, $this->config->getHeaders());
106
        }
107
108
        $header = $this->getHeaders($pathWithQuery, $headers);
109
110
        // Send headers.
111
        $this->write($header);
112
113
        // Get server response header
114
        // @todo Handle version switching
115
        $this->validateResponse($this->config, $pathWithQuery, $key);
116
        $this->isConnected = true;
117
    }
118
119
120
    /**
121
     * Init a proxy connection
122
     *
123
     * @param ClientConfig $config
124
     * @return bool|resource
125
     * @throws \InvalidArgumentException
126
     * @throws \WSSC\Exceptions\ConnectionException
127
     */
128
    private function proxy(ClientConfig $config)
129
    {
130
        $sock = @stream_socket_client(
131
            WscCommonsContract::TCP_SCHEME . $config->getProxyIp() . ':' . $config->getProxyPort(),
132
            $errno,
133
            $errstr,
134
            $this->config->getTimeout(),
135
            STREAM_CLIENT_CONNECT,
136
            $this->getStreamContext()
137
        );
138
139
        $write = "CONNECT {$config->getHost()} HTTP/1.1\r\n";
140
        $auth = $config->getProxyAuth();
141
        if ($auth !== NULL) {
142
            $write .= "Proxy-Authorization: Basic {$auth}\r\n";
143
        }
144
        $write .= "\r\n";
145
        fwrite($sock, $write);
146
        $resp = fread($sock, 1024);
147
148
        if (preg_match('/^HTTP\/\d\.\d 200/', $resp) === 1) {
149
            return $sock;
150
        }
151
152
        throw new ConnectionException('Failed to connect to the host via proxy');
153
    }
154
155
156
    /**
157
     * @return mixed|resource
158
     * @throws \InvalidArgumentException
159
     */
160
    private function getStreamContext()
161
    {
162
        if ($this->config->getContext() !== null) {
163
            // Suppress the error since we'll catch it below
164
            if (@get_resource_type($this->config->getContext()) === 'stream-context') {
165
                return $this->config->getContext();
166
            }
167
168
            throw new \InvalidArgumentException(
169
                'Stream context is invalid',
170
                CommonsContract::CLIENT_INVALID_STREAM_CONTEXT
171
            );
172
        }
173
174
        return stream_context_create($this->config->getContextOptions());
175
    }
176
177
    /**
178
     * @param mixed $urlParts
179
     * @return string
180
     */
181
    private function getPathWithQuery($urlParts): string
182
    {
183
        $path = isset($urlParts['path']) ? $urlParts['path'] : '/';
184
        $query = isset($urlParts['query']) ? $urlParts['query'] : '';
185
        $fragment = isset($urlParts['fragment']) ? $urlParts['fragment'] : '';
186
        $pathWithQuery = $path;
187
        if (!empty($query)) {
188
            $pathWithQuery .= '?' . $query;
189
        }
190
        if (!empty($fragment)) {
191
            $pathWithQuery .= '#' . $fragment;
192
        }
193
194
        return $pathWithQuery;
195
    }
196
197
    /**
198
     * @param string $pathWithQuery
199
     * @param array $headers
200
     * @return string
201
     */
202
    private function getHeaders(string $pathWithQuery, array $headers): string
203
    {
204
        return 'GET ' . $pathWithQuery . " HTTP/1.1\r\n"
205
            . implode(
206
                "\r\n",
207
                array_map(
208
                    function ($key, $value) {
209
                        return "$key: $value";
210
                    },
211
                    array_keys($headers),
212
                    $headers
213
                )
214
            )
215
            . "\r\n\r\n";
216
    }
217
218
    /**
219
     * @return string
220
     */
221
    public function getLastOpcode(): string
222
    {
223
        return $this->lastOpcode;
224
    }
225
226
    /**
227
     * @return int
228
     */
229
    public function getCloseStatus(): int
230
    {
231
        return $this->closeStatus;
232
    }
233
234
    /**
235
     * @return bool
236
     */
237
    public function isConnected(): bool
238
    {
239
        return $this->isConnected;
240
    }
241
242
    /**
243
     * @param int $timeout
244
     * @param null $microSecs
245
     * @return WscMain
246
     */
247
    public function setTimeout(int $timeout, $microSecs = null): WscMain
248
    {
249
        $this->config->setTimeout($timeout);
250
        if ($this->socket && get_resource_type($this->socket) === 'stream') {
251
            stream_set_timeout($this->socket, $timeout, $microSecs);
252
        }
253
254
        return $this;
255
    }
256
257
    /**
258
     * Sends message to opened socket connection client->server
259
     *
260
     * @param $payload
261
     * @param string $opcode
262
     * @throws \InvalidArgumentException
263
     * @throws BadOpcodeException
264
     * @throws BadUriException
265
     * @throws ConnectionException
266
     * @throws \Exception
267
     */
268
    public function send($payload, $opcode = CommonsContract::EVENT_TYPE_TEXT)
269
    {
270
        if (!$this->isConnected) {
271
            $this->connect();
272
        }
273
        if (array_key_exists($opcode, self::$opcodes) === false) {
274
            throw new BadOpcodeException(
275
                "Bad opcode '$opcode'.  Try 'text' or 'binary'.",
276
                CommonsContract::CLIENT_BAD_OPCODE
277
            );
278
        }
279
        // record the length of the payload
280
        $payloadLength = strlen($payload);
281
282
        $fragmentCursor = 0;
283
        // while we have data to send
284
        while ($payloadLength > $fragmentCursor) {
285
            // get a fragment of the payload
286
            $subPayload = substr($payload, $fragmentCursor, $this->config->getFragmentSize());
287
288
            // advance the cursor
289
            $fragmentCursor += $this->config->getFragmentSize();
290
291
            // is this the final fragment to send?
292
            $final = $payloadLength <= $fragmentCursor;
293
294
            // send the fragment
295
            $this->sendFragment($final, $subPayload, $opcode, true);
296
297
            // all fragments after the first will be marked a continuation
298
            $opcode = 'continuation';
299
        }
300
    }
301
302
    /**
303
     * Receives message client<-server
304
     *
305
     * @return null|string
306
     * @throws \InvalidArgumentException
307
     * @throws BadOpcodeException
308
     * @throws BadUriException
309
     * @throws ConnectionException
310
     * @throws \Exception
311
     */
312
    public function receive()
313
    {
314
        if (!$this->isConnected) {
315
            $this->connect();
316
        }
317
        $this->hugePayload = '';
318
319
        $response = null;
320
        while ($response === null) {
321
            $response = $this->receiveFragment();
322
        }
323
324
        return $response;
325
    }
326
327
    /**
328
     * Tell the socket to close.
329
     *
330
     * @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4
331
     * @param string $message A closing message, max 125 bytes.
332
     * @return bool|null|string
333
     * @throws \InvalidArgumentException
334
     * @throws BadOpcodeException
335
     * @throws BadUriException
336
     * @throws ConnectionException
337
     * @throws \Exception
338
     */
339
    public function close(int $status = 1000, string $message = 'ttfn')
340
    {
341
        $statusBin = sprintf('%016b', $status);
342
        $status_str = '';
343
344
        foreach (str_split($statusBin, 8) as $binstr) {
345
            $status_str .= chr(bindec($binstr));
346
        }
347
348
        $this->send($status_str . $message, CommonsContract::EVENT_TYPE_CLOSE);
349
        $this->isClosing = true;
350
351
        return $this->receive(); // Receiving a close frame will close the socket now.
352
    }
353
354
    /**
355
     * @param $data
356
     * @throws ConnectionException
357
     */
358
    protected function write(string $data)
359
    {
360
        $written = fwrite($this->socket, $data);
361
362
        if ($written < strlen($data)) {
363
            throw new ConnectionException(
364
                "Could only write $written out of " . strlen($data) . ' bytes.',
365
                CommonsContract::CLIENT_COULD_ONLY_WRITE_LESS
366
            );
367
        }
368
    }
369
370
    /**
371
     * @param int $len
372
     * @return string
373
     * @throws ConnectionException
374
     */
375
    protected function read(int $len): string
376
    {
377
        $data = '';
378
        while (($dataLen = strlen($data)) < $len) {
379
            $buff = fread($this->socket, $len - $dataLen);
380
381
            if ($buff === false) {
382
                $metadata = stream_get_meta_data($this->socket);
383
                throw new ConnectionException(
384
                    'Broken frame, read ' . strlen($data) . ' of stated '
385
                    . $len . ' bytes.  Stream state: '
386
                    . json_encode($metadata),
387
                    CommonsContract::CLIENT_BROKEN_FRAME
388
                );
389
            }
390
391
            if ($buff === '') {
392
                $metadata = stream_get_meta_data($this->socket);
393
                throw new ConnectionException(
394
                    'Empty read; connection dead?  Stream state: ' . json_encode($metadata),
395
                    CommonsContract::CLIENT_EMPTY_READ
396
                );
397
            }
398
            $data .= $buff;
399
        }
400
401
        return $data;
402
    }
403
404
    /**
405
     * Helper to convert a binary to a string of '0' and '1'.
406
     *
407
     * @param $string
408
     * @return string
409
     */
410
    protected static function sprintB(string $string): string
411
    {
412
        $return = '';
413
        $strLen = strlen($string);
414
        for ($i = 0; $i < $strLen; $i++) {
415
            $return .= sprintf('%08b', ord($string[$i]));
416
        }
417
418
        return $return;
419
    }
420
421
    /**
422
     * Sec-WebSocket-Key generator
423
     *
424
     * @return string   the 16 character length key
425
     * @throws \Exception
426
     */
427
    private function generateKey(): string
428
    {
429
        $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"$&/()=[]{}0123456789';
430
        $key = '';
431
        $chLen = strlen($chars);
432
        for ($i = 0; $i < self::KEY_GEN_LENGTH; $i++) {
433
            $key .= $chars[random_int(0, $chLen - 1)];
434
        }
435
436
        return base64_encode($key);
437
    }
438
}
439