Passed
Push — master ( b5dddf...91d417 )
by Richard
09:12
created

SystemFineUploadHandler::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 1
dl 0
loc 3
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 1
1
<?php
2
3
/**
4
 * Base SystemFineUploadHandler class to work with ajaxfineupload.php endpoint
5
 *
6
 * Upload files as specified
7
 *
8
 * Do not use or reference this directly from your client-side code.
9
 * Instead, this should be required via the endpoint.php or endpoint-cors.php
10
 * file(s).
11
 *
12
 * @license   MIT License (MIT)
13
 * @copyright Copyright (c) 2015-present, Widen Enterprises, Inc.
14
 * @link      https://github.com/FineUploader/php-traditional-server
15
 *
16
 * The MIT License (MIT)
17
 *
18
 * Copyright (c) 2015-present, Widen Enterprises, Inc.
19
 *
20
 * Permission is hereby granted, free of charge, to any person obtaining a copy
21
 * of this software and associated documentation files (the "Software"), to deal
22
 * in the Software without restriction, including without limitation the rights
23
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
24
 * copies of the Software, and to permit persons to whom the Software is
25
 * furnished to do so, subject to the following conditions:
26
 *
27
 * The above copyright notice and this permission notice shall be included in all
28
 * copies or substantial portions of the Software.
29
 *
30
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
31
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
32
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
33
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
34
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
35
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
36
 * SOFTWARE.
37
 */
38
39
class SystemFineUploadHandler
40
{
41
42
    public $allowedExtensions = array();
43
    public $allowedMimeTypes = array();
44
    public $sizeLimit = null;
45
    public $inputName = 'qqfile';
46
    public $chunksFolder = 'chunks';
47
48
    public $chunksCleanupProbability = 0.001; // Once in 1000 requests on avg
49
    public $chunksExpireIn = 604800; // One week
50
51
    protected $uploadName;
52
    public $claims;
53
54
    /**
55
     * XoopsFineUploadHandler constructor.
56
     * @param stdClass $claims claims passed in JWT header
57
     */
58
    public function __construct(\stdClass $claims)
59
    {
60
        $this->claims = $claims;
61
    }
62
63
    /**
64
     * Get the original filename
65
     */
66
    public function getName()
67
    {
68
        if (isset($_REQUEST['qqfilename'])) {
69
            return $_REQUEST['qqfilename'];
70
        }
71
72
        if (isset($_FILES[$this->inputName])) {
73
            return $_FILES[$this->inputName]['name'];
74
        }
75
    }
76
77
    /**
78
     * Get the name of the uploaded file
79
     * @return string
80
     */
81
    public function getUploadName()
82
    {
83
        return $this->uploadName;
84
    }
85
86
    /**
87
     * Combine chunks into a single file
88
     *
89
     * @param string      $uploadDirectory upload directory
90
     * @param string|null $name            name
91
     * @return array response to be json encoded and returned to client
92
     */
93
    public function combineChunks($uploadDirectory, $name = null)
94
    {
95
        $uuid = $_POST['qquuid'];
96
        if ($name === null) {
97
            $name = $this->getName();
98
        }
99
        $targetFolder = $this->chunksFolder.DIRECTORY_SEPARATOR.$uuid;
100
        $totalParts = isset($_REQUEST['qqtotalparts']) ? (int)$_REQUEST['qqtotalparts'] : 1;
101
102
        $targetPath = join(DIRECTORY_SEPARATOR, array($uploadDirectory, $uuid, $name));
103
        $this->uploadName = $name;
104
105
        if (!file_exists($targetPath)) {
106
            mkdir(dirname($targetPath), 0777, true);
107
        }
108
        $target = fopen($targetPath, 'wb');
109
110
        for ($i=0; $i<$totalParts; $i++) {
111
            $chunk = fopen($targetFolder.DIRECTORY_SEPARATOR.$i, "rb");
112
            stream_copy_to_stream($chunk, $target);
0 ignored issues
show
Bug introduced by
It seems like $target can also be of type false; however, parameter $dest of stream_copy_to_stream() 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

112
            stream_copy_to_stream($chunk, /** @scrutinizer ignore-type */ $target);
Loading history...
Bug introduced by
It seems like $chunk can also be of type false; however, parameter $source of stream_copy_to_stream() 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

112
            stream_copy_to_stream(/** @scrutinizer ignore-type */ $chunk, $target);
Loading history...
113
            fclose($chunk);
0 ignored issues
show
Bug introduced by
It seems like $chunk can also be of type false; however, parameter $handle of fclose() 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

113
            fclose(/** @scrutinizer ignore-type */ $chunk);
Loading history...
114
        }
115
116
        // Success
117
        fclose($target);
118
119
        for ($i=0; $i<$totalParts; $i++) {
120
            unlink($targetFolder.DIRECTORY_SEPARATOR.$i);
121
        }
122
123
        rmdir($targetFolder);
124
125
        if (!is_null($this->sizeLimit) && filesize($targetPath) > $this->sizeLimit) {
126
            unlink($targetPath);
127
            //http_response_code(413);
128
            header("HTTP/1.0 413 Request Entity Too Large");
129
            return array("success" => false, "uuid" => $uuid, "preventRetry" => true);
130
        }
131
132
        return array("success" => true, "uuid" => $uuid);
133
    }
134
135
    /**
136
     * Process the upload.
137
     * @param string $uploadDirectory Target directory.
138
     * @param string $name Overwrites the name of the file.
139
     * @return array response to be json encoded and returned to client
140
     */
141
    public function handleUpload($uploadDirectory, $name = null)
142
    {
143
        if (is_writable($this->chunksFolder) &&
144
            1 == mt_rand(1, 1/$this->chunksCleanupProbability)) {
145
            // Run garbage collection
146
            $this->cleanupChunks();
147
        }
148
149
        // Check that the max upload size specified in class configuration does not
150
        // exceed size allowed by server config
151
        if ($this->toBytes(ini_get('post_max_size')) < $this->sizeLimit ||
152
            $this->toBytes(ini_get('upload_max_filesize')) < $this->sizeLimit) {
153
            $neededRequestSize = max(1, $this->sizeLimit / 1024 / 1024) . 'M';
154
            return array(
155
                'error'=>"Server error. Increase post_max_size and upload_max_filesize to ".$neededRequestSize
156
            );
157
        }
158
159
        if ($this->isInaccessible($uploadDirectory)) {
160
            return array('error' => "Server error. Uploads directory isn't writable");
161
        }
162
163
        $type = $_SERVER['CONTENT_TYPE'];
164
        if (isset($_SERVER['HTTP_CONTENT_TYPE'])) {
165
            $type = $_SERVER['HTTP_CONTENT_TYPE'];
166
        }
167
168
        if (!isset($type)) {
169
            return array('error' => "No files were uploaded.");
170
        } elseif (strpos(strtolower($type), 'multipart/') !== 0) {
171
            return array(
172
                'error' => "Server error. Not a multipart request. Please set forceMultipart to default value (true)."
173
            );
174
        }
175
176
        // Get size and name
177
        $file = $_FILES[$this->inputName];
178
        $size = $file['size'];
179
        if (isset($_REQUEST['qqtotalfilesize'])) {
180
            $size = $_REQUEST['qqtotalfilesize'];
181
        }
182
183
        if ($name === null) {
184
            $name = $this->getName();
185
        }
186
187
        // check file error
188
        if ($file['error']) {
189
            return array('error' => 'Upload Error #'.$file['error']);
190
        }
191
192
        // Validate name
193
        if ($name === null || $name === '') {
194
            return array('error' => 'File name empty.');
195
        }
196
197
        // Validate file size
198
        if ($size == 0) {
199
            return array('error' => 'File is empty.');
200
        }
201
202
        if (!is_null($this->sizeLimit) && $size > $this->sizeLimit) {
203
            return array('error' => 'File is too large.', 'preventRetry' => true);
204
        }
205
206
        // Validate file extension
207
        $pathinfo = pathinfo($name);
208
        $ext = isset($pathinfo['extension']) ? strtolower($pathinfo['extension']) : '';
209
210
        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...
211
            && !in_array(strtolower($ext), array_map("strtolower", $this->allowedExtensions))) {
212
            $these = implode(', ', $this->allowedExtensions);
213
            return array(
214
                'error' => 'File has an invalid extension, it should be one of '. $these . '.',
215
                'preventRetry' => true
216
            );
217
        }
218
219
        $mimeType = '';
220
        if (!empty($this->allowedMimeTypes)) {
221
            $mimeType = mime_content_type($_FILES[$this->inputName]['tmp_name']);
222
            if (!in_array($mimeType, $this->allowedMimeTypes)) {
223
                return array('error' => 'File is of an invalid type.', 'preventRetry' => true);
224
            }
225
        }
226
227
        // Save a chunk
228
        $totalParts = isset($_REQUEST['qqtotalparts']) ? (int)$_REQUEST['qqtotalparts'] : 1;
229
230
        $uuid = $_REQUEST['qquuid'];
231
        if ($totalParts > 1) {
232
            # chunked upload
233
234
            $chunksFolder = $this->chunksFolder;
235
            $partIndex = (int)$_REQUEST['qqpartindex'];
236
237
            if (!is_writable($chunksFolder) && !is_executable($uploadDirectory)) {
238
                return array('error' => "Server error. Chunks directory isn't writable or executable.");
239
            }
240
241
            $targetFolder = $this->chunksFolder.DIRECTORY_SEPARATOR.$uuid;
242
243
            if (!file_exists($targetFolder)) {
244
                mkdir($targetFolder, 0775, true);
245
            }
246
247
            $target = $targetFolder.'/'.$partIndex;
248
249
            $storeResult = $this->storeUploadedFile($target, $mimeType, $uuid);
250
            if (false !== $storeResult) {
251
                return $storeResult;
252
            }
253
        } else {
254
            # non-chunked upload
255
256
            $target = join(DIRECTORY_SEPARATOR, array($uploadDirectory, $uuid, $name));
257
258
            if ($target) {
259
                $this->uploadName = basename($target);
260
261
                $storeResult = $this->storeUploadedFile($target, $mimeType, $uuid);
262
                if (false !== $storeResult) {
263
                    return $storeResult;
264
                }
265
            }
266
267
            return array('error'=> 'Could not save uploaded file.' .
268
                'The upload was cancelled, or server error encountered');
269
        }
270
    }
271
272
    protected function storeUploadedFile($target, $mimeType, $uuid)
273
    {
274
        if (!is_dir(dirname($target))) {
275
            mkdir(dirname($target), 0775, true);
276
        }
277
        if (move_uploaded_file($_FILES[$this->inputName]['tmp_name'], $target)) {
278
            return array('success'=> true, "uuid" => $uuid);
279
        }
280
        return false;
281
    }
282
283
    /**
284
     * Process a delete.
285
     * @param string      $uploadDirectory Target directory.
286
     * @param string|null $name            Overwrites the name of the file.
287
     * @return array response to be json encoded and returned to client
288
     */
289
    public function handleDelete($uploadDirectory, $name = null)
290
    {
291
        if ($this->isInaccessible($uploadDirectory)) {
292
            return array(
293
                'error' => "Server error. Uploads directory isn't writable"
294
                            . ((!$this->isWindows()) ? " or executable." : ".")
295
            );
296
        }
297
298
        $targetFolder = $uploadDirectory;
299
        $uuid = false;
300
        $method = $_SERVER["REQUEST_METHOD"];
301
        if ($method == "DELETE") {
302
            $url = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
303
            $tokens = explode('/', $url);
304
            $uuid = $tokens[sizeof($tokens)-1];
305
        } elseif ($method == "POST") {
306
            $uuid = $_REQUEST['qquuid'];
307
        } else {
308
            return array("success" => false,
309
                "error" => "Invalid request method! ".$method
310
            );
311
        }
312
313
        $target = join(DIRECTORY_SEPARATOR, array($targetFolder, $uuid));
314
315
        if (is_dir($target)) {
316
            $this->removeDir($target);
317
            return array("success" => true, "uuid" => $uuid);
318
        } else {
319
            return array("success" => false,
320
                "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...
321
                "path" => $uuid
322
            );
323
        }
324
    }
325
326
    /**
327
     * Returns a path to use with this upload. Check that the name does not exist,
328
     * and appends a suffix otherwise.
329
     * @param string $uploadDirectory Target directory
330
     * @param string $filename The name of the file to use.
331
     *
332
     * @return string|false path or false if path could not be determined
333
     */
334
    protected function getUniqueTargetPath($uploadDirectory, $filename)
335
    {
336
        // Allow only one process at the time to get a unique file name, otherwise
337
        // if multiple people would upload a file with the same name at the same time
338
        // only the latest would be saved.
339
340
        if (function_exists('sem_acquire')) {
341
            $lock = sem_get(ftok(__FILE__, 'u'));
342
            sem_acquire($lock);
343
        }
344
345
        $pathinfo = pathinfo($filename);
346
        $base = $pathinfo['filename'];
347
        $ext = isset($pathinfo['extension']) ? $pathinfo['extension'] : '';
348
        $ext = $ext == '' ? $ext : '.' . $ext;
349
350
        $unique = $base;
351
        $suffix = 0;
352
353
        // Get unique file name for the file, by appending random suffix.
354
355
        while (file_exists($uploadDirectory . DIRECTORY_SEPARATOR . $unique . $ext)) {
356
            $suffix += rand(1, 999);
357
            $unique = $base.'-'.$suffix;
358
        }
359
360
        $result =  $uploadDirectory . DIRECTORY_SEPARATOR . $unique . $ext;
361
362
        // Create an empty target file
363
        if (!touch($result)) {
364
            // Failed
365
            $result = false;
366
        }
367
368
        if (function_exists('sem_acquire')) {
369
            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...
370
        }
371
372
        return $result;
373
    }
374
375
    /**
376
     * Deletes all file parts in the chunks folder for files uploaded
377
     * more than chunksExpireIn seconds ago
378
     *
379
     * @return void
380
     */
381
    protected function cleanupChunks()
382
    {
383
        foreach (scandir($this->chunksFolder) as $item) {
384
            if ($item == "." || $item == "..") {
385
                continue;
386
            }
387
388
            $path = $this->chunksFolder.DIRECTORY_SEPARATOR.$item;
389
390
            if (!is_dir($path)) {
391
                continue;
392
            }
393
394
            if (time() - filemtime($path) > $this->chunksExpireIn) {
395
                $this->removeDir($path);
396
            }
397
        }
398
    }
399
400
    /**
401
     * Removes a directory and all files contained inside
402
     * @param string $dir
403
     * @return void
404
     */
405
    protected function removeDir($dir)
406
    {
407
        foreach (scandir($dir) as $item) {
408
            if ($item == "." || $item == "..") {
409
                continue;
410
            }
411
412
            if (is_dir($item)) {
413
                $this->removeDir($item);
414
            } else {
415
                unlink(join(DIRECTORY_SEPARATOR, array($dir, $item)));
416
            }
417
        }
418
        rmdir($dir);
419
    }
420
421
    /**
422
     * Converts a given size with units to bytes.
423
     * @param string $str
424
     * @return int
425
     */
426
    protected function toBytes($str)
427
    {
428
        $str = trim($str);
429
        $last = strtolower($str[strlen($str)-1]);
430
        if(is_numeric($last)) {
431
            $val = (int) $str;
432
        } else {
433
            $val = (int) substr($str, 0, -1);
434
        }
435
        switch ($last) {
436
            case 'g':
437
                $val *= 1024; // fall thru
438
            case 'm':
439
                $val *= 1024; // fall thru
440
            case 'k':
441
                $val *= 1024; // fall thru
442
        }
443
        return $val;
444
    }
445
446
    /**
447
     * Determines whether a directory can be accessed.
448
     *
449
     * is_executable() is not reliable on Windows prior PHP 5.0.0
450
     *  (http://www.php.net/manual/en/function.is-executable.php)
451
     * The following tests if the current OS is Windows and if so, merely
452
     * checks if the folder is writable;
453
     * otherwise, it checks additionally for executable status (like before).
454
     *
455
     * @param string $directory The target directory to test access
456
     * @return bool true if directory is NOT accessible
457
     */
458
    protected function isInaccessible($directory)
459
    {
460
        $isWin = $this->isWindows();
461
        $folderInaccessible =
462
            ($isWin) ? !is_writable($directory) : (!is_writable($directory) && !is_executable($directory));
463
        return $folderInaccessible;
464
    }
465
466
    /**
467
     * Determines is the OS is Windows or not
468
     *
469
     * @return boolean
470
     */
471
472
    protected function isWindows()
473
    {
474
        $isWin = (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN');
475
        return $isWin;
476
    }
477
}
478