Completed
Push — master ( 1747a0...0cf576 )
by Arthur
04:10
created

WscMain::receiveFragment()   B

Complexity

Conditions 9
Paths 43

Size

Total Lines 62

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 62
rs 7.2735
c 0
b 0
f 0
cc 9
nc 43
nop 0

How to fix   Long Method   

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\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
21
    use WSClientTrait;
22
23
    private $socket;
24
    private $isConnected = false;
25
    private $isClosing = false;
26
    private $lastOpcode;
27
    private $closeStatus;
28
    private $hugePayload;
29
30
    private static $opcodes = [
31
        CommonsContract::EVENT_TYPE_CONTINUATION => 0,
32
        CommonsContract::EVENT_TYPE_TEXT         => 1,
33
        CommonsContract::EVENT_TYPE_BINARY       => 2,
34
        CommonsContract::EVENT_TYPE_CLOSE        => 8,
35
        CommonsContract::EVENT_TYPE_PING         => 9,
36
        CommonsContract::EVENT_TYPE_PONG         => 10,
37
    ];
38
39
    protected $socketUrl = '';
40
    protected $config;
41
42
    /**
43
     * @throws \InvalidArgumentException
44
     * @throws BadUriException
45
     * @throws ConnectionException
46
     * @throws \Exception
47
     */
48
    protected function connect()
49
    {
50
        $urlParts = parse_url($this->socketUrl);
51
52
        $this->config->setScheme($urlParts['scheme']);
53
        $this->config->setHost($urlParts['host']);
54
        $this->config->setUser($urlParts);
0 ignored issues
show
Security Bug introduced by
It seems like $urlParts defined by parse_url($this->socketUrl) on line 50 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...
55
        $this->config->setPassword($urlParts);
0 ignored issues
show
Security Bug introduced by
It seems like $urlParts defined by parse_url($this->socketUrl) on line 50 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...
56
        $this->config->setPort($urlParts);
0 ignored issues
show
Security Bug introduced by
It seems like $urlParts defined by parse_url($this->socketUrl) on line 50 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...
57
58
        $pathWithQuery = $this->getPathWithQuery($urlParts);
59
        $hostUri = $this->getHostUri($this->config);
60
61
        // Set the stream context options if they're already set in the config
62
        $context = $this->getStreamContext();
63
        $this->socket = @stream_socket_client(
64
            $hostUri . ':' . $this->config->getPort(), $errno, $errstr, $this->config->getTimeout(),
65
            STREAM_CLIENT_CONNECT, $context
66
        );
67
68
        if ($this->socket === false) {
69
            throw new ConnectionException(
70
                "Could not open socket to \"{$this->config->getHost()}:{$this->config->getPort()}\": $errstr ($errno).",
71
                CommonsContract::CLIENT_COULD_NOT_OPEN_SOCKET
72
            );
73
        }
74
75
        // Set timeout on the stream as well.
76
        stream_set_timeout($this->socket, $this->config->getTimeout());
77
78
        // Generate the WebSocket key.
79
        $key = $this->generateKey();
80
        $headers = [
81
            'Host'                  => $this->config->getHost() . ':' . $this->config->getPort(),
82
            'User-Agent'            => 'websocket-client-php',
83
            'Connection'            => 'Upgrade',
84
            'Upgrade'               => 'WebSocket',
85
            'Sec-WebSocket-Key'     => $key,
86
            'Sec-Websocket-Version' => '13',
87
        ];
88
89
        // Handle basic authentication.
90
        if ($this->config->getUser() || $this->config->getPassword()) {
91
            $headers['authorization'] = 'Basic ' . base64_encode($this->config->getUser() . ':' . $this->config->getPassword()) . "\r\n";
92
        }
93
94
        // Add and override with headers from options.
95
        if (!empty($this->config->getHeaders())) {
96
            $headers = array_merge($headers, $this->config->getHeaders());
97
        }
98
99
        $header = $this->getHeaders($pathWithQuery, $headers);
100
101
        // Send headers.
102
        $this->write($header);
103
104
        // Get server response header
105
        // @todo Handle version switching
106
        $this->validateResponse($this->config, $pathWithQuery, $key);
107
        $this->isConnected = true;
108
    }
109
110
    /**
111
     * @return mixed|resource
112
     * @throws \InvalidArgumentException
113
     */
114
    private function getStreamContext()
115
    {
116
        if ($this->config->getContext() !== NULL) {
117
            // Suppress the error since we'll catch it below
118
            if (@get_resource_type($this->config->getContext()) === 'stream-context') {
119
                return $this->config->getContext();
120
            }
121
122
            throw new \InvalidArgumentException(
123
                'Stream context is invalid', CommonsContract::CLIENT_INVALID_STREAM_CONTEXT
124
            );
125
        }
126
127
        return stream_context_create();
128
    }
129
130
    /**
131
     * @param mixed $urlParts
132
     * @return string
133
     */
134
    private function getPathWithQuery($urlParts): string
135
    {
136
        $path = isset($urlParts['path']) ? $urlParts['path'] : '/';
137
        $query = isset($urlParts['query']) ? $urlParts['query'] : '';
138
        $fragment = isset($urlParts['fragment']) ? $urlParts['fragment'] : '';
139
        $pathWithQuery = $path;
140
        if (!empty($query)) {
141
            $pathWithQuery .= '?' . $query;
142
        }
143
        if (!empty($fragment)) {
144
            $pathWithQuery .= '#' . $fragment;
145
        }
146
147
        return $pathWithQuery;
148
    }
149
150
    /**
151
     * @param string $pathWithQuery
152
     * @param array $headers
153
     * @return string
154
     */
155
    private function getHeaders(string $pathWithQuery, array $headers): string
156
    {
157
        return 'GET ' . $pathWithQuery . " HTTP/1.1\r\n"
158
            . implode(
159
                "\r\n", array_map(
160
                    function ($key, $value) {
161
                        return "$key: $value";
162
                    }, array_keys($headers), $headers
163
                )
164
            )
165
            . "\r\n\r\n";
166
    }
167
168
    /**
169
     * @return string
170
     */
171
    public function getLastOpcode(): string
172
    {
173
        return $this->lastOpcode;
174
    }
175
176
    /**
177
     * @return int
178
     */
179
    public function getCloseStatus(): int
180
    {
181
        return $this->closeStatus;
182
    }
183
184
    /**
185
     * @return bool
186
     */
187
    public function isConnected(): bool
188
    {
189
        return $this->isConnected;
190
    }
191
192
    /**
193
     * @param int $timeout
194
     * @param null $microSecs
195
     * @return WscMain
196
     */
197
    public function setTimeout(int $timeout, $microSecs = NULL): WscMain
198
    {
199
        $this->config->setTimeout($timeout);
200
        if ($this->socket && get_resource_type($this->socket) === 'stream') {
201
            stream_set_timeout($this->socket, $timeout, $microSecs);
202
        }
203
204
        return $this;
205
    }
206
207
    /**
208
     * Sends message to opened socket connection client->server
209
     *
210
     * @param $payload
211
     * @param string $opcode
212
     * @throws \InvalidArgumentException
213
     * @throws BadOpcodeException
214
     * @throws BadUriException
215
     * @throws ConnectionException
216
     * @throws \Exception
217
     */
218
    public function send($payload, $opcode = CommonsContract::EVENT_TYPE_TEXT)
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
                CommonsContract::CLIENT_BAD_OPCODE);
226
        }
227
        // record the length of the payload
228
        $payloadLength = strlen($payload);
229
230
        $fragmentCursor = 0;
231
        // while we have data to send
232
        while ($payloadLength > $fragmentCursor) {
233
            // get a fragment of the payload
234
            $subPayload = substr($payload, $fragmentCursor, $this->config->getFragmentSize());
235
236
            // advance the cursor
237
            $fragmentCursor += $this->config->getFragmentSize();
238
239
            // is this the final fragment to send?
240
            $final = $payloadLength <= $fragmentCursor;
241
242
            // send the fragment
243
            $this->sendFragment($final, $subPayload, $opcode, true);
244
245
            // all fragments after the first will be marked a continuation
246
            $opcode = 'continuation';
247
        }
248
    }
249
250
    /**
251
     * Receives message client<-server
252
     *
253
     * @return null|string
254
     * @throws \InvalidArgumentException
255
     * @throws BadOpcodeException
256
     * @throws BadUriException
257
     * @throws ConnectionException
258
     * @throws \Exception
259
     */
260
    public function receive()
261
    {
262
        if (!$this->isConnected) {
263
            $this->connect();
264
        }
265
        $this->hugePayload = '';
266
267
        $response = NULL;
268
        while (NULL === $response) {
269
            $response = $this->receiveFragment();
270
        }
271
272
        return $response;
273
    }
274
275
    /**
276
     * Tell the socket to close.
277
     *
278
     * @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4
279
     * @param string $message A closing message, max 125 bytes.
280
     * @return bool|null|string
281
     * @throws \InvalidArgumentException
282
     * @throws BadOpcodeException
283
     * @throws BadUriException
284
     * @throws ConnectionException
285
     * @throws \Exception
286
     */
287
    public function close(int $status = 1000, string $message = 'ttfn')
288
    {
289
        $statusBin = sprintf('%016b', $status);
290
        $status_str = '';
291
292
        foreach (str_split($statusBin, 8) as $binstr) {
293
            $status_str .= chr(bindec($binstr));
294
        }
295
296
        $this->send($status_str . $message, CommonsContract::EVENT_TYPE_CLOSE);
297
        $this->isClosing = true;
298
299
        return $this->receive(); // Receiving a close frame will close the socket now.
300
    }
301
302
    /**
303
     * @param $data
304
     * @throws ConnectionException
305
     */
306
    protected function write(string $data)
307
    {
308
        $written = fwrite($this->socket, $data);
309
310
        if ($written < strlen($data)) {
311
            throw new ConnectionException(
312
                "Could only write $written out of " . strlen($data) . ' bytes.',
313
                CommonsContract::CLIENT_COULD_ONLY_WRITE_LESS
314
            );
315
        }
316
    }
317
318
    /**
319
     * @param int $len
320
     * @return string
321
     * @throws ConnectionException
322
     */
323
    protected function read(int $len): string
324
    {
325
        $data = '';
326
        while (($dataLen = strlen($data)) < $len) {
327
            $buff = fread($this->socket, $len - $dataLen);
328
329
            if ($buff === false) {
330
                $metadata = stream_get_meta_data($this->socket);
331
                throw new ConnectionException(
332
                    'Broken frame, read ' . strlen($data) . ' of stated '
333
                    . $len . ' bytes.  Stream state: '
334
                    . json_encode($metadata), CommonsContract::CLIENT_BROKEN_FRAME
335
                );
336
            }
337
338
            if ($buff === '') {
339
                $metadata = stream_get_meta_data($this->socket);
340
                throw new ConnectionException(
341
                    'Empty read; connection dead?  Stream state: ' . json_encode($metadata),
342
                    CommonsContract::CLIENT_EMPTY_READ
343
                );
344
            }
345
            $data .= $buff;
346
        }
347
348
        return $data;
349
    }
350
351
    /**
352
     * Helper to convert a binary to a string of '0' and '1'.
353
     *
354
     * @param $string
355
     * @return string
356
     */
357
    protected static function sprintB(string $string): string
358
    {
359
        $return = '';
360
        $strLen = strlen($string);
361
        for ($i = 0; $i < $strLen; $i++) {
362
            $return .= sprintf('%08b', ord($string[$i]));
363
        }
364
365
        return $return;
366
    }
367
368
    /**
369
     * Sec-WebSocket-Key generator
370
     *
371
     * @return string   the 16 character length key
372
     * @throws \Exception
373
     */
374
    private function generateKey(): string
375
    {
376
        $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"$&/()=[]{}0123456789';
377
        $key = '';
378
        $chLen = strlen($chars);
379
        for ($i = 0; $i < self::KEY_GEN_LENGTH; $i++) {
380
            $key .= $chars[random_int(0, $chLen - 1)];
381
        }
382
383
        return base64_encode($key);
384
    }
385
}
386