SystemFineUploadHandler   F
last analyzed

Complexity

Total Complexity 86

Size/Duplication

Total Lines 457
Duplicated Lines 0 %

Importance

Changes 4
Bugs 1 Features 0
Metric Value
eloc 201
c 4
b 1
f 0
dl 0
loc 457
rs 2
wmc 86

13 Methods

Rating   Name   Duplication   Size   Complexity  
A getUploadName() 0 3 1
A getName() 0 10 3
B combineChunks() 0 42 9
A __construct() 0 3 1
A cleanupChunks() 0 15 6
A storeUploadedFile() 0 12 6
B handleDelete() 0 37 7
A isWindows() 0 4 1
A removeDir() 0 14 5
A isInaccessible() 0 6 3
A toBytes() 0 18 5
F handleUpload() 0 137 32
B getUniqueTargetPath() 0 39 7

How to fix   Complexity   

Complex Class

Complex classes like SystemFineUploadHandler 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 SystemFineUploadHandler, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
use Xmf\Request;
4
5
/**
6
 * Base SystemFineUploadHandler class to work with ajaxfineupload.php endpoint
7
 *
8
 * Upload files as specified
9
 *
10
 * Do not use or reference this directly from your client-side code.
11
 * Instead, this should be required via the endpoint.php or endpoint-cors.php
12
 * file(s).
13
 *
14
 * @license   MIT License (MIT)
15
 * @copyright Copyright (c) 2015-present, Widen Enterprises, Inc.
16
 * @link      https://github.com/FineUploader/php-traditional-server
17
 *
18
 * The MIT License (MIT)
19
 *
20
 * Copyright (c) 2015-present, Widen Enterprises, Inc.
21
 *
22
 * Permission is hereby granted, free of charge, to any person obtaining a copy
23
 * of this software and associated documentation files (the "Software"), to deal
24
 * in the Software without restriction, including without limitation the rights
25
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
26
 * copies of the Software, and to permit persons to whom the Software is
27
 * furnished to do so, subject to the following conditions:
28
 *
29
 * The above copyright notice and this permission notice shall be included in all
30
 * copies or substantial portions of the Software.
31
 *
32
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
33
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
34
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
35
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
36
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
37
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
38
 * SOFTWARE.
39
 */
40
41
abstract class SystemFineUploadHandler
42
{
43
44
    public $allowedExtensions = array();
45
    public $allowedMimeTypes = array('(none)'); // must specify!
46
    public $sizeLimit = null;
47
    public $inputName = 'qqfile';
48
    public $chunksFolder = 'chunks';
49
50
    public $chunksCleanupProbability = 0.001; // Once in 1000 requests on avg
51
    public $chunksExpireIn = 604800; // One week
52
53
    protected $uploadName;
54
    public $claims;
55
56
    /**
57
     * XoopsFineUploadHandler constructor.
58
     * @param stdClass $claims claims passed in JWT header
59
     */
60
    public function __construct(\stdClass $claims)
61
    {
62
        $this->claims = $claims;
63
    }
64
65
    /**
66
     * Get the original filename
67
     */
68
    public function getName()
69
    {
70
        if (Request::hasVar('qqfilename', 'REQUEST')) {
71
            $qqfilename = Request::getString('qqfilename', '', 'REQUEST');
72
            return $qqfilename;
73
        }
74
75
        if (Request::hasVar($this->inputName, 'FILES')) {
76
            $file = Request::getArray($this->inputName, null, 'FILES');
77
            return $file ;
78
        }
79
    }
80
81
    /**
82
     * Get the name of the uploaded file
83
     * @return string
84
     */
85
    public function getUploadName()
86
    {
87
        return $this->uploadName;
88
    }
89
90
    /**
91
     * Combine chunks into a single file
92
     *
93
     * @param string      $uploadDirectory upload directory
94
     * @param string|null $name            name
95
     * @return array response to be json encoded and returned to client
96
     */
97
    public function combineChunks($uploadDirectory, $name = null)
98
    {
99
        $uuid = Request::getString('qquuid', '', 'POST');
100
        if ('' === $name) {
101
            $name = $this->getName();
102
        }
103
        $targetFolder = $this->chunksFolder . DIRECTORY_SEPARATOR . $uuid;
104
        $totalParts = Request::getInt('qqtotalparts', 1, 'REQUEST');
105
106
        $targetPath = implode(DIRECTORY_SEPARATOR, array($uploadDirectory, $uuid, $name));
107
        $this->uploadName = $name;
108
109
        if (!file_exists($targetPath)) {
110
            if (!mkdir($concurrentDirectory = dirname($targetPath), 0777, true) && !is_dir($concurrentDirectory)) {
111
                throw new \RuntimeException(sprintf('Directory "%s" was not created', $concurrentDirectory));
112
            }
113
        }
114
        $target = fopen($targetPath, 'wb');
115
116
        for ($i = 0; $i < $totalParts; $i++) {
117
            $chunk = fopen($targetFolder . DIRECTORY_SEPARATOR . $i, 'rb');
118
            stream_copy_to_stream($chunk, $target);
119
            fclose($chunk);
120
        }
121
122
        // Success
123
        fclose($target);
124
125
        for ($i = 0; $i < $totalParts; $i++) {
126
            unlink($targetFolder . DIRECTORY_SEPARATOR . $i);
127
        }
128
129
        rmdir($targetFolder);
130
131
        if (null !== $this->sizeLimit && filesize($targetPath) > $this->sizeLimit) {
132
            unlink($targetPath);
133
            //http_response_code(413);
134
            header('HTTP/1.0 413 Request Entity Too Large');
135
            return array('success' => false, 'uuid' => $uuid, 'preventRetry' => true);
136
        }
137
138
        return array('success' => true, 'uuid' => $uuid);
139
    }
140
141
    /**
142
     * Process the upload.
143
     * @param string $uploadDirectory Target directory.
144
     * @param string $name Overwrites the name of the file.
145
     * @return array response to be json encoded and returned to client
146
     */
147
    public function handleUpload($uploadDirectory, $name = null)
148
    {
149
        if (is_writable($this->chunksFolder) &&
150
            1 == mt_rand(1, 1/$this->chunksCleanupProbability)) {
0 ignored issues
show
Bug introduced by
1 / $this->chunksCleanupProbability of type double is incompatible with the type integer expected by parameter $max of mt_rand(). ( Ignorable by Annotation )

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

150
            1 == mt_rand(1, /** @scrutinizer ignore-type */ 1/$this->chunksCleanupProbability)) {
Loading history...
151
            // Run garbage collection
152
            $this->cleanupChunks();
153
        }
154
155
        // Check that the max upload size specified in class configuration does not
156
        // exceed size allowed by server config
157
        if ($this->toBytes(ini_get('post_max_size')) < $this->sizeLimit ||
158
            $this->toBytes(ini_get('upload_max_filesize')) < $this->sizeLimit) {
159
            $neededRequestSize = max(1, $this->sizeLimit / 1024 / 1024) . 'M';
160
            return array(
161
                'error'=> 'Server error. Increase post_max_size and upload_max_filesize to ' . $neededRequestSize
162
            );
163
        }
164
165
        if ($this->isInaccessible($uploadDirectory)) {
166
            return array('error' => "Server error. Uploads directory isn't writable");
167
        }
168
169
        $type = $_SERVER['CONTENT_TYPE'];
170
        if (isset($_SERVER['HTTP_CONTENT_TYPE'])) {
171
            $type = $_SERVER['HTTP_CONTENT_TYPE'];
172
        }
173
174
        if (!isset($type)) {
175
            return array('error' => "No files were uploaded.");
176
        }
177
178
        if (strpos(strtolower($type), 'multipart/') !== 0) {
179
            return array(
180
                'error' => "Server error. Not a multipart request. Please set forceMultipart to default value (true)."
181
            );
182
        }
183
184
        // Get size and name
185
        $file = Request::getArray($this->inputName, [], 'FILES');
186
        $size = $file['size'];
187
        if (Request::hasVar('qqtotalfilesize')) {
188
            $size = Request::getInt('qqtotalfilesize');
189
        }
190
191
        if (null === $name) {
192
            $name = $this->getName();
193
        }
194
195
        // check file error
196
        if ($file['error']) {
197
            return array('error' => 'Upload Error #'.$file['error']);
198
        }
199
200
        // Validate name
201
        if (null === $name || '' === $name) {
202
            return array('error' => 'File name empty.');
203
        }
204
205
        // Validate file size
206
        if (0 == $size) {
207
            return array('error' => 'File is empty.');
208
        }
209
210
        if (null !== $this->sizeLimit && $size > $this->sizeLimit) {
211
            return array('error' => 'File is too large.', 'preventRetry' => true);
212
        }
213
214
        // Validate file extension
215
        $pathinfo = pathinfo($name);
0 ignored issues
show
Bug introduced by
It seems like $name can also be of type array; however, parameter $path of pathinfo() does only seem to accept string, 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

215
        $pathinfo = pathinfo(/** @scrutinizer ignore-type */ $name);
Loading history...
216
        $ext = isset($pathinfo['extension']) ? strtolower($pathinfo['extension']) : '';
217
218
        if ($this->allowedExtensions
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->allowedExtensions of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
219
            && !in_array(strtolower($ext), array_map('strtolower', $this->allowedExtensions))) {
220
            $these = implode(', ', $this->allowedExtensions);
221
            return array(
222
                'error' => 'File has an invalid extension, it should be one of '. $these . '.',
223
                'preventRetry' => true
224
            );
225
        }
226
227
        $mimeType = '';
228
        if (!empty($this->allowedMimeTypes)) {
229
230
            $temp = $this->inputName;
231
            $mimeType = mime_content_type(Request::getArray($temp, [], 'FILES')['tmp_name']);
232
            if (!in_array($mimeType, $this->allowedMimeTypes)) {
233
                return array('error' => 'File is of an invalid type.', 'preventRetry' => true);
234
            }
235
        }
236
237
        // Save a chunk
238
        $totalParts = 1;
239
        if (Request::hasVar('qqtotalparts')) {
240
            $totalParts = (int)Request::getString('qqtotalparts');
241
        }
242
243
        $uuid = Request::getString('qquuid');
244
        if ($totalParts > 1) {
245
            # chunked upload
246
247
            $chunksFolder = $this->chunksFolder;
248
            $partIndex = (int)Request::getString('qqpartindex');
249
250
            if (!is_writable($chunksFolder) && !is_executable($uploadDirectory)) {
251
                return array('error' => "Server error. Chunks directory isn't writable or executable.");
252
            }
253
254
            $targetFolder = $this->chunksFolder.DIRECTORY_SEPARATOR.$uuid;
255
256
            if (!file_exists($targetFolder)) {
257
                if (!mkdir($targetFolder, 0775, true) && !is_dir($targetFolder)) {
258
                    throw new \RuntimeException(sprintf('Directory "%s" was not created', $targetFolder));
259
                }
260
            }
261
262
            $target = $targetFolder.'/'.$partIndex;
263
264
            $storeResult = $this->storeUploadedFile($target, $mimeType, $uuid);
265
            if (false !== $storeResult) {
266
                return $storeResult;
267
            }
268
        } else {
269
            # non-chunked upload
270
271
            $target = implode(DIRECTORY_SEPARATOR, array($uploadDirectory, $uuid, $name));
272
273
            if ($target) {
274
                $this->uploadName = basename($target);
275
276
                $storeResult = $this->storeUploadedFile($target, $mimeType, $uuid);
277
                if (false !== $storeResult) {
278
                    return $storeResult;
279
                }
280
            }
281
282
            return array('error'=> 'Could not save uploaded file.' .
283
                'The upload was cancelled, or server error encountered');
284
        }
285
    }
286
287
    protected function storeUploadedFile($target, $mimeType, $uuid)
288
    {
289
        if (!is_dir(dirname($target))) {
290
            if (!mkdir($concurrentDirectory = dirname($target), 0775, true) && !is_dir($concurrentDirectory)) {
291
                throw new \RuntimeException(sprintf('Directory "%s" was not created', $concurrentDirectory));
292
            }
293
        }
294
        $file = Request::getArray($this->inputName, null, 'FILES');
295
        if (null !== $file && move_uploaded_file($file['tmp_name'], $target)) {
296
            return array('success' => true, 'uuid' => $uuid);
297
        }
298
        return false;
299
    }
300
301
    /**
302
     * Process a delete.
303
     * @param string      $uploadDirectory Target directory.
304
     * @param string|null $name            Overwrites the name of the file.
305
     * @return array response to be json encoded and returned to client
306
     */
307
    public function handleDelete($uploadDirectory, $name = null)
308
    {
309
        if ($this->isInaccessible($uploadDirectory)) {
310
            return array(
311
                'error' => "Server error. Uploads directory isn't writable"
312
                           . ((!$this->isWindows()) ? ' or executable.' : '.')
313
            );
314
        }
315
316
        $uuid = false;
317
        $method = Request::getString('REQUEST_METHOD', 'GET', 'SERVER');
318
319
        if ('DELETE' === $method) {
320
            $url = Request::getString('REQUEST_URI', '', 'SERVER');
321
            if ('' !== $url) {
322
                $url    = parse_url($url, PHP_URL_PATH);
323
                $tokens = explode('/', $url);
324
                $uuid = $tokens[count($tokens) - 1];
325
            }
326
        } elseif ('POST' === $method) {
327
            $uuid = Request::getString('qquuid', '', 'REQUEST');
328
        } else {
329
            return array('success' => false,
330
                'error' => 'Invalid request method! ' . $method
331
             );
332
        }
333
334
        $target = implode(DIRECTORY_SEPARATOR, array($uploadDirectory, $uuid));
335
336
        if (is_dir($target)) {
337
            $this->removeDir($target);
338
            return array('success' => true, 'uuid' => $uuid);
339
        } else {
340
            return array(
341
                'success' => false,
342
                'error'   => 'File not found! Unable to delete.' . $url,
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $url does not seem to be defined for all execution paths leading up to this point.
Loading history...
343
                'path'    => $uuid
344
            );
345
        }
346
    }
347
348
    /**
349
     * Returns a path to use with this upload. Check that the name does not exist,
350
     * and appends a suffix otherwise.
351
     * @param string $uploadDirectory Target directory
352
     * @param string $filename The name of the file to use.
353
     *
354
     * @return string|false path or false if path could not be determined
355
     */
356
    protected function getUniqueTargetPath($uploadDirectory, $filename)
357
    {
358
        // Allow only one process at the time to get a unique file name, otherwise
359
        // if multiple people would upload a file with the same name at the same time
360
        // only the latest would be saved.
361
362
        if (function_exists('sem_acquire')) {
363
            $lock = sem_get(ftok(__FILE__, 'u'));
364
            sem_acquire($lock);
365
        }
366
367
        $pathinfo = pathinfo($filename);
368
        $base = $pathinfo['filename'];
369
        $ext = isset($pathinfo['extension']) ? $pathinfo['extension'] : '';
370
        $ext = '' == $ext ? $ext : '.' . $ext;
371
372
        $unique = $base;
373
        $suffix = 0;
374
375
        // Get unique file name for the file, by appending random suffix.
376
377
        while (file_exists($uploadDirectory . DIRECTORY_SEPARATOR . $unique . $ext)) {
378
            $suffix += rand(1, 999);
379
            $unique = $base.'-'.$suffix;
380
        }
381
382
        $result =  $uploadDirectory . DIRECTORY_SEPARATOR . $unique . $ext;
383
384
        // Create an empty target file
385
        if (!touch($result)) {
386
            // Failed
387
            $result = false;
388
        }
389
390
        if (function_exists('sem_acquire')) {
391
            sem_release($lock);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $lock does not seem to be defined for all execution paths leading up to this point.
Loading history...
392
        }
393
394
        return $result;
395
    }
396
397
    /**
398
     * Deletes all file parts in the chunks folder for files uploaded
399
     * more than chunksExpireIn seconds ago
400
     *
401
     * @return void
402
     */
403
    protected function cleanupChunks()
404
    {
405
        foreach (scandir($this->chunksFolder) as $item) {
406
            if ('.' == $item || '..' == $item) {
407
                continue;
408
            }
409
410
            $path = $this->chunksFolder.DIRECTORY_SEPARATOR.$item;
411
412
            if (!is_dir($path)) {
413
                continue;
414
            }
415
416
            if (time() - filemtime($path) > $this->chunksExpireIn) {
417
                $this->removeDir($path);
418
            }
419
        }
420
    }
421
422
    /**
423
     * Removes a directory and all files contained inside
424
     * @param string $dir
425
     * @return void
426
     */
427
    protected function removeDir($dir)
428
    {
429
        foreach (scandir($dir) as $item) {
430
            if ('.' == $item || '..' == $item) {
431
                continue;
432
            }
433
434
            if (is_dir($item)) {
435
                $this->removeDir($item);
436
            } else {
437
                unlink(implode(DIRECTORY_SEPARATOR, array($dir, $item)));
438
            }
439
        }
440
        rmdir($dir);
441
    }
442
443
    /**
444
     * Converts a given size with units to bytes.
445
     * @param string $str
446
     * @return int
447
     */
448
    protected function toBytes($str)
449
    {
450
        $str = trim($str);
451
        $last = strtolower($str[strlen($str)-1]);
452
        if(is_numeric($last)) {
453
            $val = (int) $str;
454
        } else {
455
            $val = (int) substr($str, 0, -1);
456
        }
457
        switch ($last) {
458
            case 'g':
459
                $val *= 1024; // fall thru
460
            case 'm':
461
                $val *= 1024; // fall thru
462
            case 'k':
463
                $val *= 1024; // fall thru
464
        }
465
        return $val;
466
    }
467
468
    /**
469
     * Determines whether a directory can be accessed.
470
     *
471
     * is_executable() is not reliable on Windows prior PHP 5.0.0
472
     *  (https://www.php.net/manual/en/function.is-executable.php)
473
     * The following tests if the current OS is Windows and if so, merely
474
     * checks if the folder is writable;
475
     * otherwise, it checks additionally for executable status (like before).
476
     *
477
     * @param string $directory The target directory to test access
478
     * @return bool true if directory is NOT accessible
479
     */
480
    protected function isInaccessible($directory)
481
    {
482
        $isWin = $this->isWindows();
483
        $folderInaccessible =
484
            ($isWin) ? !is_writable($directory) : (!is_writable($directory) && !is_executable($directory));
485
        return $folderInaccessible;
486
    }
487
488
    /**
489
     * Determines is the OS is Windows or not
490
     *
491
     * @return bool
492
     */
493
494
    protected function isWindows()
495
    {
496
        $isWin = (stripos(PHP_OS, 'WIN') === 0);
497
        return $isWin;
498
    }
499
}
500