Issues (281)

Branch: master

Modules/MediaLibrary/Component/UploadHandler.php (1 issue)

1
<?php
2
3
namespace Backend\Modules\MediaLibrary\Component;
4
5
use Backend\Modules\MediaLibrary\Manager\FileManager;
6
use Exception;
7
use Symfony\Component\HttpFoundation\File\MimeType\MimeTypeExtensionGuesser;
8
use Symfony\Component\HttpFoundation\File\UploadedFile;
9
use Symfony\Component\HttpFoundation\Request;
10
11
class UploadHandler
12
{
13
    public $allowedExtensions = [];
14
    public $allowedMimeTypes = [];
15
    public $sizeLimit = null;
16
    public $inputName = 'qqfile';
17
    public $chunksFolder = 'chunks';
18
19
    public $chunksCleanupProbability = 0.001; // Once in 1000 requests on avg
20
    public $chunksExpireIn = 604800; // One week
21
22
    /** @var string */
23
    protected $uploadName;
24
25
    /** @var Request */
26
    protected $request;
27
28
    /** @var FileManager */
29
    protected $fileManager;
30
31
    public function __construct(Request $request, FileManager $fileManager)
32
    {
33
        $this->request = $request;
34
        $this->fileManager = $fileManager;
35
    }
36
37
    public function getName(): string
38
    {
39
        $fileName = basename($this->request->request->get('qqfilename'));
40
        if ($fileName !== null) {
41
            return $fileName;
42
        }
43
44
        /** @var UploadedFile|null $file */
45
        $file = $this->request->files->get($this->inputName);
46
        if ($file instanceof UploadedFile) {
47
            return basename($file->getClientOriginalName());
48
        }
49
    }
50
51
    public function getUploadName(): string
52
    {
53
        return $this->uploadName;
54
    }
55
56
    public function combineChunks(string $uploadDirectory, string $name = null): array
57
    {
58
        $uuid = $this->request->request->get('qquuid');
59
        if ($name === null) {
60
            $name = $this->getName();
61
        }
62
        $targetFolder = $this->chunksFolder . DIRECTORY_SEPARATOR . $uuid;
63
        $totalParts = $this->request->request->getInt('qqtotalparts', 1);
64
65
        $targetPath = implode(DIRECTORY_SEPARATOR, [$uploadDirectory, $uuid, $name]);
66
        $this->uploadName = $name;
67
68
        if (!$this->fileManager->exists($targetPath)) {
69
            mkdir(dirname($targetPath), 0777, true);
70
        }
71
        $target = fopen($targetPath, 'wb');
72
73
        for ($i = 0; $i < $totalParts; ++$i) {
74
            $chunk = fopen($targetFolder . DIRECTORY_SEPARATOR . $i, 'rb');
75
            stream_copy_to_stream($chunk, $target);
76
            fclose($chunk);
77
        }
78
79
        // Success
80
        fclose($target);
81
82
        for ($i = 0; $i < $totalParts; ++$i) {
83
            unlink($targetFolder . DIRECTORY_SEPARATOR . $i);
84
        }
85
86
        rmdir($targetFolder);
87
88
        if ($this->sizeLimit !== null && filesize($targetPath) > $this->sizeLimit) {
89
            unlink($targetPath);
90
            http_response_code(413);
91
92
            return ['success' => false, 'uuid' => $uuid, 'preventRetry' => true];
93
        }
94
95
        return ['success' => true, 'uuid' => $uuid];
96
    }
97
98
    public function handleUpload(string $uploadDirectory, string $name = null): array
99
    {
100
        $this->cleanupChunksIfNecessary();
101
102
        try {
103
            $this->checkMaximumSize();
104
            $this->checkUploadDirectory($uploadDirectory);
105
            $this->checkType();
106
            $file = $this->getFile($uploadDirectory);
107
            $size = $this->getSize($file);
108
109
            if ($this->sizeLimit !== null && $size > $this->sizeLimit) {
110
                return ['error' => 'File is too large.', 'preventRetry' => true];
111
            }
112
113
            $name = $this->getRedefinedName($name);
114
            $this->checkFileExtension($name);
115
            if ($this->request->request->getInt('qqtotalparts', 1) === 1) {
116
                $this->checkFileMimeType($file);
117
            }
118
        } catch (Exception $e) {
119
            return ['error' => $e->getMessage()];
120
        }
121
122
        $uuid = $this->request->request->get('qquuid');
123
124
        // Chunked upload
125
        if ($this->request->request->getInt('qqtotalparts', 1) > 1) {
126
            if ($this->request->query->has('done')) {
127
                return ['success' => true, 'uuid' => $uuid];
128
            }
129
            $chunksFolder = $this->chunksFolder;
130
            $partIndex = $this->request->request->getInt('qqpartindex');
131
132
            if (!is_writable($chunksFolder) && !is_executable($uploadDirectory)) {
133
                return ['error' => "Server error. Chunks directory isn't writable or executable."];
134
            }
135
136
            $targetFolder = $this->chunksFolder . DIRECTORY_SEPARATOR . $uuid;
137
138
            if (!file_exists($targetFolder)) {
139
                mkdir($targetFolder, 0777, true);
140
            }
141
142
            $target = $targetFolder . '/' . $partIndex;
143
            $from = $file->getRealPath();
144
            $success = move_uploaded_file($from, $target);
145
146
            return ['success' => $success, 'uuid' => $uuid];
147
        }
148
149
        // Non-chunked upload
150
        $target = implode(DIRECTORY_SEPARATOR, [$uploadDirectory, $uuid, $name]);
151
152
        if ($target) {
153
            $this->uploadName = basename($target);
154
155
            if (!is_dir(dirname($target))) {
156
                mkdir(dirname($target), 0777, true);
157
            }
158
            if (move_uploaded_file($file->getRealPath(), $target)) {
159
                return ['success' => true, 'uuid' => $uuid];
160
            }
161
        }
162
163
        return ['error' => 'Could not save uploaded file.' . 'The upload was cancelled, or server error encountered'];
164
    }
165
166
    private function checkMaximumSize(): void
167
    {
168
        // Check that the max upload size specified in class configuration does not exceed size allowed by server config
169
        if ($this->toBytes(ini_get('post_max_size')) < $this->sizeLimit ||
170
            $this->toBytes(ini_get('upload_max_filesize')) < $this->sizeLimit
171
        ) {
172
            $neededRequestSize = max(1, $this->sizeLimit / 1024 / 1024) . 'M';
173
174
            throw new Exception(
175
                'Server error. Increase post_max_size and upload_max_filesize to ' . $neededRequestSize
176
            );
177
        }
178
    }
179
180
    /**
181
     * Determines whether a directory can be accessed.
182
     *
183
     * is_executable() is not reliable on Windows prior PHP 5.0.0
184
     *  (http://www.php.net/manual/en/function.is-executable.php)
185
     * The following tests if the current OS is Windows and if so, merely
186
     * checks if the folder is writable;
187
     * otherwise, it checks additionally for executable status (like before).
188
     *
189
     * @param string $uploadDirectory
190
     *
191
     * @throws Exception
192
     */
193
    private function checkUploadDirectory(string $uploadDirectory): void
194
    {
195
        if (($this->isWindows() && !is_writable($uploadDirectory))
196
            || (!is_writable($uploadDirectory) && !is_executable($uploadDirectory))) {
197
            throw new Exception('Server error. Uploads directory isn\'t writable');
198
        }
199
    }
200
201
    private function checkType(): void
202
    {
203
        $type = $this->request->server->get('HTTP_CONTENT_TYPE', $this->request->server->get('CONTENT_TYPE'));
204
        if ($type === null) {
205
            throw new Exception('No files were uploaded.');
206
        }
207
208
        if (strpos(strtolower($type), 'multipart/') !== 0 && !$this->request->query->has('done')) {
209
            throw new Exception(
210
                'Server error. Not a multipart request. Please set forceMultipart to default value (true).'
211
            );
212
        }
213
    }
214
215
    private function checkFileExtension(string $name): void
216
    {
217
        // Validate file extension
218
        $pathinfo = pathinfo($name);
219
        $ext = $pathinfo['extension'] ?? '';
220
221
        // Check file extension
222
        if (!in_array(strtolower($ext), array_map('strtolower', $this->allowedExtensions), true)) {
223
            $these = implode(', ', $this->allowedExtensions);
224
            throw new Exception('File has an invalid extension, it should be one of ' . $these . '.');
225
        }
226
    }
227
228
    private function checkFileMimeType(UploadedFile $file): void
229
    {
230
        // Check file mime type
231
        if (!in_array(strtolower($file->getMimeType()), array_map('strtolower', $this->allowedMimeTypes), true)) {
232
            $these = implode(', ', $this->allowedMimeTypes);
233
            throw new Exception('File has an invalid mime type, it should be one of ' . $these . '.');
234
        }
235
    }
236
237
    /**
238
     * Deletes all file parts in the chunks folder for files uploaded
239
     * more than chunksExpireIn seconds ago
240
     */
241
    private function cleanupChunksIfNecessary(): void
242
    {
243
        if (!is_writable($this->chunksFolder) || 1 !== random_int(1, 1 / $this->chunksCleanupProbability)) {
244
            return;
245
        }
246
247
        foreach (scandir($this->chunksFolder) as $item) {
248
            if ($item === '.' || $item === '..') {
249
                continue;
250
            }
251
252
            $path = $this->chunksFolder . DIRECTORY_SEPARATOR . $item;
253
254
            if (!is_dir($path)) {
255
                continue;
256
            }
257
258
            if (time() - filemtime($path) > $this->chunksExpireIn) {
259
                $this->fileManager->deleteFolder($path);
260
            }
261
        }
262
    }
263
264
    private function getFile(string $uploadDirectory): UploadedFile
265
    {
266
        if ($this->request->query->has('done')) {
267
            $name = basename($this->getName());
268
            $uploadedPath = implode(
269
                DIRECTORY_SEPARATOR,
270
                [$uploadDirectory, basename($this->request->request->get('qquuid')), $name]
271
            );
272
            $mimeType = (new MimeTypeExtensionGuesser())->guess($uploadedPath);
0 ignored issues
show
Deprecated Code introduced by
The class Symfony\Component\HttpFo...imeTypeExtensionGuesser has been deprecated: since Symfony 4.3, use {@link MimeTypes} instead ( Ignorable by Annotation )

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

272
            $mimeType = (/** @scrutinizer ignore-deprecated */ new MimeTypeExtensionGuesser())->guess($uploadedPath);
Loading history...
273
274
            return new UploadedFile($uploadedPath, $name, $mimeType, filesize($uploadedPath));
275
        }
276
277
        /** @var UploadedFile|null $file */
278
        $file = $this->request->files->get($this->inputName);
279
280
        // check file error
281
        if (!$file instanceof UploadedFile) {
282
            throw new Exception('Upload Error #UNKNOWN');
283
        }
284
285
        return $file;
286
    }
287
288
    private function getRedefinedName(string $name = null): string
289
    {
290
        if ($name === null) {
291
            $name = $this->getName();
292
        }
293
294
        // Validate name
295
        if ($name === null || $name === '') {
296
            throw new Exception('File name empty.');
297
        }
298
299
        return $name;
300
    }
301
302
    private function getSize(UploadedFile $file): int
303
    {
304
        $size = (int) $this->request->request->get('qqtotalfilesize', $file->getClientSize());
305
306
        // Validate file size
307
        if ($size === 0) {
308
            throw new Exception('File is empty.');
309
        }
310
311
        return $size;
312
    }
313
314
    /**
315
     * Determines is the OS is Windows or not
316
     *
317
     * @return bool
318
     */
319
    protected function isWindows(): bool
320
    {
321
        return 0 === stripos(PHP_OS, 'WIN');
322
    }
323
324
    /**
325
     * Converts a given size with units to bytes.
326
     *
327
     * @param string $str
328
     *
329
     * @return int
330
     */
331
    protected function toBytes(string $str): int
332
    {
333
        $str = trim($str);
334
        $unit = strtolower($str[strlen($str) - 1]);
335
        if (is_numeric($unit)) {
336
            return (int) $str;
337
        }
338
339
        $val = (int) substr($str, 0, -1);
340
        switch (strtoupper($unit)) {
341
            case 'G':
342
                return $val * 1073741824;
343
            case 'M':
344
                return $val * 1048576;
345
            case 'K':
346
                return $val * 1024;
347
            default:
348
                return $val;
349
        }
350
    }
351
}
352