Base62x   F
last analyzed

Complexity

Total Complexity 93

Size/Duplication

Total Lines 496
Duplicated Lines 0 %

Importance

Changes 2
Bugs 1 Features 0
Metric Value
wmc 93
eloc 197
c 2
b 1
f 0
dl 0
loc 496
rs 2

17 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 16 5
B compress() 0 31 8
A decode() 0 3 1
A encode() 0 3 1
A decompress() 0 3 1
A _decode() 0 20 5
D _isSerializedString() 0 60 24
A get() 0 19 5
A decrypt() 0 3 1
A _performEncryption() 0 11 2
A _performUncompress() 0 14 3
A encrypt() 0 19 5
C _getCompressionFootprintAndSanitizePayload() 0 55 17
A _performCompress() 0 20 4
A _createCompressionFootprint() 0 3 1
A _performDecryption() 0 23 5
A _encode() 0 15 5

How to fix   Complexity   

Complex Class

Complex classes like Base62x often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Base62x, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace Mfonte\Base62x;
4
5
use Exception;
6
use Mfonte\Base62x\Exception\InvalidParam;
7
use Mfonte\Base62x\Exception\CryptException;
8
use Mfonte\Base62x\Exception\DecodeException;
9
use Mfonte\Base62x\Exception\EncodeException;
10
use Mfonte\Base62x\Encoding\Base62x as Encoder;
11
use Mfonte\Base62x\Encryption\Crypt as Crypter;
12
use Mfonte\Base62x\Compression\Gzip\GzipCompression as GzipCompressor;
13
use Mfonte\Base62x\Compression\Huffman\HuffmanCoding as HuffmanCompressor;
14
15
class Base62x
16
{
17
    public const MODE_ENCODE = 1;
18
    public const MODE_DECODE = 2;
19
20
    protected $_validCompressionAlgorithms = ['gzip' => ['zlib', 'deflate', 'gzip'], 'huffman'];
21
22
    /**
23
     * The mode: encode or decode.
24
     *
25
     * @var int
26
     */
27
    protected $mode;
28
29
    /**
30
     * The payload to be encoded or decoded.
31
     *
32
     * @var mixed
33
     */
34
    protected $payload;
35
36
    /**
37
     * Wheter the payload needs to be compressed prior of encoding. Defaults to null.
38
     *
39
     * @var string
40
     */
41
    protected $compressAlgorithm = null;
42
43
    /**
44
     * The compression mode related to the compression algo. Defaults to null.
45
     *
46
     * @var string
47
     */
48
    protected $compressEncoding = null;
49
50
    /**
51
     * The encryption cypher (algorithm) to be used in case of password-protected encoding.
52
     * This variable *must* be a valid cypher method supported in openssl_get_cipher_methods().
53
     *
54
     * @var string
55
     */
56
    protected $cryptCypher = 'aes-256-cbc';
57
58
    /**
59
     * The encrypt/decrypt key (password) to be used to protect/unprotect the encoding.
60
     *
61
     * @var mixed
62
     */
63
    protected $cryptKey;
64
65
    /**
66
     * Wheter the payload needs to be decompressed after the decoding. Defaults to false.
67
     *
68
     * @var string
69
     */
70
    protected $decompressAlgorithm = null;
71
72
    /**
73
     * The compression mode related to the compression algo. Defaults to null.
74
     *
75
     * @var string
76
     */
77
    protected $decompressEncoding = null;
78
79
    /**
80
     * @param mixed $payload
81
     *
82
     * @return \Mfonte\Base62x\Base62x
83
     */
84
    public static function encode($payload): self
85
    {
86
        return new self(self::MODE_ENCODE, $payload);
87
    }
88
89
    /**
90
     * @param mixed $payload
91
     *
92
     * @return \Mfonte\Base62x\Base62x
93
     */
94
    public static function decode($payload): self
95
    {
96
        return new self(self::MODE_DECODE, $payload);
97
    }
98
99
    public function __construct($mode, $payload)
100
    {
101
        $this->mode = $mode;
102
103
        if (empty($payload)) {
104
            throw new InvalidParam('payload', __FUNCTION__, __CLASS__, 'The payload cannot be empty');
105
        } elseif (\is_resource($payload)) {
106
            throw new InvalidParam('payload', __FUNCTION__, __CLASS__, 'The payload cannot be a resource');
107
        } elseif (\is_object($payload)) {
108
            throw new InvalidParam('payload', __FUNCTION__, __CLASS__, 'The payload cannot be an object');
109
        } elseif (\is_array($payload)) {
110
            // if the payload is an array, perform here and now the translation to a serialized string
111
            $payload = serialize($payload);
112
        }
113
114
        $this->payload = $payload;
115
    }
116
117
    /**
118
     * Sets the compression type and encoding.
119
     *
120
     * @param string $algo     A valid compression algorithm as seen on $_validCompressionAlgorithms
121
     * @param string $encoding A valid compression encoding as seen on $_validCompressionAlgorithms
122
     *
123
     * @return \Mfonte\Base62x\Base62x
124
     */
125
    public function compress($algo = 'gzip', $encoding = 'gzip'): self
126
    {
127
        // sanity check for bad $algo
128
        if (
129
            !\array_key_exists($algo, $this->_validCompressionAlgorithms) &&
130
            !\in_array($algo, $this->_validCompressionAlgorithms, true)
131
        ) {
132
            throw new InvalidParam('algo', __FUNCTION__, __CLASS__);
133
        }
134
135
        // sanity check for bad $encoding
136
        if (
137
            \array_key_exists($algo, $this->_validCompressionAlgorithms) &&
138
            \is_array($this->_validCompressionAlgorithms[$algo]) &&
139
            !\in_array($encoding, $this->_validCompressionAlgorithms[$algo], true)
140
        ) {
141
            throw new InvalidParam('encoding', __FUNCTION__, __CLASS__);
142
        }
143
144
        // make sure we nullify the $encoding if we don't have one
145
        if (
146
            !\array_key_exists($algo, $this->_validCompressionAlgorithms) ||
147
            !\is_array($this->_validCompressionAlgorithms[$algo])
148
        ) {
149
            $encoding = null;
150
        }
151
152
        $this->compressAlgorithm = $algo;
153
        $this->compressEncoding = $encoding;
154
155
        return $this;
156
    }
157
158
    /**
159
     * As the decompression is done automagically via the "magic string" at the beginning of the
160
     * encoded payload, this method is pointless.
161
     * It is present only as a reference.
162
     *
163
     * @return \Mfonte\Base62x\Base62x
164
     */
165
    public function decompress(): self
166
    {
167
        return $this;
168
    }
169
170
    /**
171
     * Sets the encryption key (password) and cypher method.
172
     * This method is only available if the openssl extension is available in your PHP installation.
173
     * This method will throw exceptions if you try to use any ECB cypher method, or AEAD cypher methods.
174
     *
175
     * @param string $key    A password for your encoded base62x output string
176
     * @param string $cypher A valid openssl cypher method as supported in your environment (openssl_get_cipher_methods)
177
     *
178
     * @return \Mfonte\Base62x\Base62x
179
     */
180
    public function encrypt(string $key, string $cypher = 'aes-128-ctr'): self
181
    {
182
        if (!\function_exists('openssl_get_cipher_methods')) {
183
            throw new CryptException('openssl_get_cipher_methods unsupported in your PHP installation');
184
        }
185
        if (!\in_array(mb_strtolower($cypher), openssl_get_cipher_methods(), true)) {
186
            throw new CryptException('Encryption cypher method "'.$cypher.'" is either unsupported in your PHP installation or not a valid encryption algorithm.');
187
        }
188
        if (\in_array(mb_strtolower($cypher), ['aes-128-ecb', 'aes-192-ecb', 'aes-256-ecb'], true)) {
189
            throw new CryptException('Encryption cypher method "'.$cypher.'" is not supported. ECB mode is not secure.');
190
        }
191
        if (\in_array(mb_strtolower($cypher), ['aead'], true)) {
192
            throw new CryptException('Encryption cypher method "'.$cypher.'" is not supported. AEAD mode is not supported.');
193
        }
194
195
        $this->cryptCypher = mb_strtolower($cypher);
196
        $this->cryptKey = $key;
197
198
        return $this;
199
    }
200
201
    /**
202
     * Sets the encryption key (password) and method (algorithm).
203
     *
204
     * @see self::encrypt
205
     */
206
    public function decrypt(string $key, string $method = 'aes-128-ctr'): self
207
    {
208
        return $this->encrypt($key, $method);
209
    }
210
211
    /**
212
     * Gets the encoded or decoded mixed variable originally passed as $payload to instance.
213
     *
214
     * @return mixed
215
     */
216
    public function get()
217
    {
218
        $retval = null;
219
        switch ($this->mode) {
220
            case self::MODE_ENCODE:
221
                $retval = $this->_encode($this->payload);
222
                break;
223
224
            case self::MODE_DECODE:
225
                $retval = $this->_decode($this->payload);
226
227
                // decoded payload can be a serialized array: if so, we return the original representation
228
                if ($this->_isSerializedString($retval) && ($unserialized = @unserialize($retval)) !== false) {
229
                    $retval = $unserialized;
230
                }
231
                break;
232
        }
233
234
        return $retval;
235
    }
236
237
    /**
238
     * Performs the actual Base62x encoding.
239
     */
240
    private function _encode(string $payload): string
241
    {
242
        if ($this->cryptKey && $this->cryptCypher) {
243
            $payload = $this->_performEncryption($payload);
244
        }
245
        if ($this->compressAlgorithm) {
246
            $payload = $this->_performCompress($payload);
247
        }
248
249
        $encoded = Encoder::encode($payload);
250
        if (empty($encoded)) {
251
            throw new EncodeException();
252
        }
253
254
        return $encoded;
255
    }
256
257
    /**
258
     * Performs the actual Base62x decoding.
259
     */
260
    private function _decode(string $payload): string
261
    {
262
        $decoded = Encoder::decode($payload);
263
        if (empty($decoded)) {
264
            throw new DecodeException();
265
        }
266
267
        // remove the magic string for Compression
268
        $data = $this->_getCompressionFootprintAndSanitizePayload($decoded);
269
270
        if ($data['compression_algo']) {
271
            $decoded = $this->_performUncompress($data['payload'], $data['compression_algo'], $data['compression_encoding']);
272
        }
273
274
        // eventually perform decryption
275
        if ($this->cryptKey && $this->cryptCypher) {
276
            $decoded = $this->_performDecryption($decoded);
277
        }
278
279
        return $decoded;
280
    }
281
282
    /**
283
     * Performs the actual compress before chaining it into the Base62x encoder.
284
     */
285
    private function _performCompress(string $payload): string
286
    {
287
        $compressed = null;
288
        switch ($this->compressAlgorithm) {
289
            case 'gzip':
290
                $compressed = GzipCompressor::encode($payload, $this->compressEncoding);
291
                break;
292
            case 'huffman':
293
                $compressed = HuffmanCompressor::encode($payload, HuffmanCompressor::createCodeTree($payload));
294
                break;
295
        }
296
297
        if (empty($compressed)) {
298
            throw new EncodeException();
299
        }
300
301
        // create the compression footprint, to avoid the decompress() on Base62x::decode()
302
        $footprint = $this->_createCompressionFootprint();
303
304
        return $footprint.$compressed;
305
    }
306
307
    /**
308
     * Decompresses the payload, that was prior compressed using one of the available compression types.
309
     */
310
    private function _performUncompress(string $compressed_payload, string $compression_algo, ?string $compression_encoding): string
311
    {
312
        switch ($compression_algo) {
313
            case 'gzip':
314
                $payload = GzipCompressor::decode($compressed_payload, $compression_encoding);
315
                break;
316
            case 'huffman':
317
                $payload = HuffmanCompressor::decode($compressed_payload);
318
                break;
319
            default:
320
                $payload = '';
321
        }
322
323
        return (string) $payload;
324
    }
325
326
    /**
327
     * Performs the actual encryption before chaining it into the Base62x encoder.
328
     */
329
    private function _performEncryption(string $payload): ?string
330
    {
331
        try {
332
            $crypt = new Crypter([
333
                'key' => $this->cryptKey,
334
                'method' => $this->cryptCypher,
335
            ]);
336
337
            return $crypt->cipher($payload)->encrypt();
338
        } catch (Exception $ex) {
339
            throw new CryptException('Cannot encrypt the payload: '.$ex->getMessage());
340
        }
341
    }
342
343
    /**
344
     * Decrypts the payload, that was prior encrypted using the on-board encrypter.
345
     */
346
    private function _performDecryption(string $payload): string
347
    {
348
        if (empty($this->cryptKey)) {
349
            throw new CryptException('Cannot decrypt the payload without a valid cryptKey');
350
        }
351
        if (empty($this->cryptCypher)) {
352
            throw new CryptException('Cannot decrypt the payload without a valid cryptCypher');
353
        }
354
355
        try {
356
            $crypt = new Crypter([
357
                'key' => $this->cryptKey,
358
                'method' => $this->cryptCypher,
359
            ]);
360
361
            $decrypted = $crypt->cipher($payload)->decrypt();
362
            if ($decrypted === false) {
363
                throw new CryptException('Cannot decrypt the payload: result from cipher()->decrypt() is false');
364
            }
365
366
            return $decrypted;
367
        } catch (Exception $ex) {
368
            throw new CryptException('Cannot decrypt the payload: '.$ex->getMessage());
369
        }
370
    }
371
372
    /**
373
     * Prepares a "magic string" that will be appendend at beginning of the compressed payload,
374
     * prior of chaining it into the Base62x encoder.
375
     * Doing so, the decode method will automagically uncompress the encoded payload, so the subsequent "decode"
376
     * can understand which compression algo+encoding was originally used.
377
     */
378
    private function _createCompressionFootprint(): string
379
    {
380
        return '[MFB62X.COMPRESS.'.base64_encode(implode(',', [$this->compressAlgorithm, $this->compressEncoding])).']';
381
    }
382
383
    /**
384
     * Gets the decoded Base26x string, and checks if it needs decompression,
385
     * by analyzing its "compression footprint" placed at the very beginning of the payload.
386
     */
387
    private function _getCompressionFootprintAndSanitizePayload(string $payload): array
388
    {
389
        $compression_algo = $compression_encoding = null;
390
        $pos_start = mb_strpos($payload, '[MFB62X.COMPRESS.');
391
        $pos_end = mb_strpos($payload, ']');
392
393
        if ($pos_start === 0 && $pos_end > 0) {
394
            $footprint = mb_substr($payload, 0, $pos_end + 1);
395
            $compression_footprint = str_replace(['[', 'MFB62X.COMPRESS.', ']'], '', $footprint);
396
397
            $compression_params = @base64_decode($compression_footprint, true);
398
            if ($compression_params && \count(explode(',', $compression_params)) === 2) {
399
                $compression_params = explode(',', $compression_params);
400
                $compression_algo = $compression_params[0];
401
                $compression_encoding = $compression_params[1];
402
            }
403
404
            // clean the payload, removing the compression footprint
405
            $payload = mb_substr($payload, $pos_end + 1);
406
        }
407
408
        // some sanity checks to avoid tampering with the payload and cause bad behaviour or worse
409
        // sanity check for bad $algo
410
        if (
411
            $compression_algo &&
412
            !\array_key_exists($compression_algo, $this->_validCompressionAlgorithms) &&
413
            !\in_array($compression_algo, $this->_validCompressionAlgorithms, true)
414
        ) {
415
            throw new DecodeException();
416
        }
417
418
        // sanity check for bad $encoding
419
        if (
420
            $compression_algo &&
421
            $compression_encoding &&
422
            \array_key_exists($compression_algo, $this->_validCompressionAlgorithms) &&
423
            \is_array($this->_validCompressionAlgorithms[$compression_algo]) &&
424
            !\in_array($compression_encoding, $this->_validCompressionAlgorithms[$compression_algo], true)
425
        ) {
426
            throw new DecodeException();
427
        }
428
429
        // make sure we nullify the $encoding if we don't have one
430
        if (
431
            $compression_algo &&
432
            (!\array_key_exists($compression_algo, $this->_validCompressionAlgorithms) ||
433
            !\is_array($this->_validCompressionAlgorithms[$compression_algo]))
434
        ) {
435
            $compression_encoding = null;
436
        }
437
438
        return [
439
            'payload' => $payload,
440
            'compression_algo' => $compression_algo,
441
            'compression_encoding' => (!empty($compression_encoding)) ? $compression_encoding : null,
442
        ];
443
    }
444
445
    /**
446
     * Checks whether the $data argument is a serialized string, i.e. an array serialized with native PHP's serialize().
447
     *
448
     * @param mixed $data   (should always be a string)
449
     * @param bool  $strict Whether to perform a strict analysis or not
450
     */
451
    private function _isSerializedString($data, $strict = true): bool
452
    {
453
        // If it isn't a string, it isn't serialized.
454
        if (!\is_string($data)) {
455
            return false;
456
        }
457
        $data = trim($data);
458
        if ($data == 'N;') {
459
            return true;
460
        }
461
        if (mb_strlen($data) < 4) {
462
            return false;
463
        }
464
        if ($data[1] !== ':') {
465
            return false;
466
        }
467
        if ($strict) {
468
            $lastc = mb_substr($data, -1);
469
            if ($lastc !== ';' && $lastc !== '}') {
470
                return false;
471
            }
472
        } else {
473
            $semicolon = mb_strpos($data, ';');
474
            $brace = mb_strpos($data, '}');
475
            // Either ; or } must exist.
476
            if ($semicolon === false && $brace === false) {
477
                return false;
478
            }
479
            // But neither must be in the first X characters.
480
            if ($semicolon !== false && $semicolon < 3) {
481
                return false;
482
            }
483
            if ($brace !== false && $brace < 4) {
484
                return false;
485
            }
486
        }
487
        $token = $data[0];
488
        switch ($token) {
489
            case 's':
490
                if ($strict) {
491
                    if (mb_substr($data, -2, 1) !== '"') {
492
                        return false;
493
                    }
494
                } elseif (mb_strpos($data, '"') === false) {
495
                    return false;
496
                }
497
                // Or else fall through.
498
                // no break
499
            case 'a':
500
            case 'O':
501
                return (bool) preg_match("/^{$token}:[0-9]+:/s", $data);
502
            case 'b':
503
            case 'i':
504
            case 'd':
505
                $end = $strict ? '$' : '';
506
507
                return (bool) preg_match("/^{$token}:[0-9.E+-]+;$end/", $data);
508
        }
509
510
        return false;
511
    }
512
}
513