Completed
Pull Request — master (#19)
by Şəhriyar
33:48
created

FileStream::combineChunks()   B

Complexity

Conditions 4
Paths 6

Size

Total Lines 24
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 24
rs 8.6846
cc 4
eloc 15
nc 6
nop 1
1
<?php namespace App\Services;
2
3
use App\Exceptions\Common\ValidationException;
4
use App\Exceptions\FileStream as FileStreamExceptions;
5
use App\Models\File;
6
use Illuminate\Http\Request;
7
use Illuminate\Http\Response as IlluminateResponse;
8
use Symfony\Component\HttpFoundation\File\File as SymfonyFile;
9
use Symfony\Component\HttpFoundation\File\UploadedFile;
10
11
class FileStream
12
{
13
    /**
14
     * Folder to hold uploaded chunks.
15
     *
16
     * @var string
17
     */
18
    public $temporaryChunksFolder;
19
20
    /**
21
     * Chunks will be cleaned once in 1000 requests on average.
22
     *
23
     * @var float
24
     */
25
    public $chunksCleanupProbability = 0.001;
26
27
    /**
28
     * By default, chunks are considered loose and deletable, in 1 week.
29
     *
30
     * @var int
31
     */
32
    public $chunksExpireIn = 604800;
33
34
    /**
35
     * Upload size limit.
36
     *
37
     * @var int
38
     */
39
    public $sizeLimit;
40
41
    public function __construct()
42
    {
43
        $this->temporaryChunksFolder = DIRECTORY_SEPARATOR . '_chunks';
44
        if (app('config')->has('filesystems.chunks_ttl') && is_int(config('filesystems.chunks_ttl'))) {
45
            $this->chunksExpireIn = config('filesystems.chunks_ttl');
46
        }
47
        if (app('config')->has('filesystems.size_limit') && is_int(config('filesystems.size_limit'))) {
48
            $this->sizeLimit = config('filesystems.size_limit');
49
        }
50
    }
51
52
    /**
53
     * Write the uploaded file to the local filesystem.
54
     *
55
     * @param \Illuminate\Http\Request $request
56
     *
57
     * @return \Illuminate\Http\JsonResponse
58
     */
59
    public function handleUpload(Request $request)
60
    {
61
        $fineUploaderUuid = null;
62
        if ($request->has('qquuid')) {
63
            $fineUploaderUuid = $request->get('qquuid');
64
        }
65
66
        //------------------------------
67
        // Is it Post-processing?
68
        //------------------------------
69
70
        if ($request->has('post-process') && $request->get('post-process') == 1) {
71
            $validator = app('validator')->make($request->all(), [
72
                'qquuid' => 'required|string|size:36',
73
                'qqfilename' => 'required|string',
74
                'qqtotalfilesize' => 'required|numeric',
75
                'qqtotalparts' => 'required|numeric'
76
            ]);
77
            if ($validator->fails()) {
78
                throw new ValidationException($validator);
79
            }
80
81
            # Combine chunks.
82
            $this->combineChunks($request);
83
84
            # Real MIME validation of the uploaded file.
85
            $this->validateUploadRealMimeAgainstAllowedTypes($this->getAbsolutePath($fineUploaderUuid));
86
87
            # Move file to its final permanent destination.
88
            $hash = hash_file('sha256', $this->getAbsolutePath($fineUploaderUuid));
89
            $destination = $this->renameAndMoveUploadedFileByItsHash($fineUploaderUuid, $hash);
90
91
            # Persist file record in database.
92
            $this->persistDatabaseRecord(new SymfonyFile($this->getAbsolutePath($destination)));
93
94
            return response()->json(['message' => 'Created', 'success' => true, 'uuid' => $fineUploaderUuid])->setStatusCode(IlluminateResponse::HTTP_CREATED);
95
        }
96
97
        //----------------
98
        // Prelim work.
99
        //----------------
100
101
        $filesystem = app('filesystem')->disk();
102
103
        if (!file_exists($this->temporaryChunksFolder) || !is_dir($this->temporaryChunksFolder)) {
104
            $filesystem->makeDirectory($this->temporaryChunksFolder);
105
        }
106
107
        # Temp folder writable?
108
        if (!is_writable($absolutePathToTemporaryChunksFolder = config('filesystems.disks.local.root') . $this->temporaryChunksFolder) || !is_executable($absolutePathToTemporaryChunksFolder)) {
109
            throw new FileStreamExceptions\TemporaryUploadFolderNotWritableException;
110
        }
111
112
        # Cleanup chunks.
113
        if (1 === mt_rand(1, 1 / $this->chunksCleanupProbability)) {
114
            $this->cleanupChunks();
115
        }
116
117
        # Check upload size against the size-limit, if any.
118
        if (!empty($this->sizeLimit)) {
119
            $uploadIsTooLarge = false;
120
            $request->has('qqtotalfilesize') && intval($request->get('qqtotalfilesize')) > $this->sizeLimit && $uploadIsTooLarge = true;
121
            $this->filesizeFromHumanReadableToBytes(ini_get('post_max_size')) < $this->sizeLimit && $uploadIsTooLarge = true;
122
            $this->filesizeFromHumanReadableToBytes(ini_get('upload_max_filesize')) < $this->sizeLimit && $uploadIsTooLarge = true;
123
            if ($uploadIsTooLarge) {
124
                throw new FileStreamExceptions\UploadTooLargeException;
125
            }
126
        }
127
128
        # Is there attempt for multiple file uploads?
129
        $collectionOfUploadedFiles = collect($request->file());
130
        if ($collectionOfUploadedFiles->count() > 1) {
131
            throw new FileStreamExceptions\MultipleSimultaneousUploadsNotAllowedException;
132
        }
133
134
        /** @var UploadedFile $file */
135
        $file = $collectionOfUploadedFiles->first();
136
137
        //--------------------
138
        // Upload handling.
139
        //--------------------
140
141
        if ($file->getSize() == 0) {
142
            throw new FileStreamExceptions\UploadIsEmptyException;
143
        }
144
145
        $name = $file->getClientOriginalName();
146
        if ($request->has('qqfilename')) {
147
            $name = $request->get('qqfilename');
148
        }
149
        if (empty($name)) {
150
            throw new FileStreamExceptions\UploadFilenameIsEmptyException;
151
        }
152
153
        $totalNumberOfChunks = $request->has('qqtotalparts') ? $request->get('qqtotalparts') : 1;
154
155
        if ($totalNumberOfChunks > 1) {
156
            $chunkIndex = intval($request->get('qqpartindex'));
157
            $targetFolder = $this->temporaryChunksFolder . DIRECTORY_SEPARATOR . $fineUploaderUuid;
158
            if (!$filesystem->exists($targetFolder)) {
159
                $filesystem->makeDirectory($targetFolder);
160
            }
161
162
            if (!$file->isValid()) {
163
                throw new FileStreamExceptions\UploadAttemptFailedException;
164
            }
165
            $file->move(storage_path('app' . $targetFolder), $chunkIndex);
166
167
            return response()->json(['success' => true, 'uuid' => $fineUploaderUuid]);
168
        } else {
169
            if (!$file->isValid()) {
170
                throw new FileStreamExceptions\UploadAttemptFailedException;
171
            }
172
            $file->move(storage_path('app'), $name);
173
174
            return response()->json(['success' => true, 'uuid' => $fineUploaderUuid])->setStatusCode(IlluminateResponse::HTTP_CREATED);
175
        }
176
    }
177
178
    /**
179
     * @param \Illuminate\Http\Request $request
180
     *
181
     * @return bool
182
     */
183
    public function isUploadResumable(Request $request)
184
    {
185
        $filesystem = app('filesystem')->disk();
186
        $fineUploaderUuid = $request->get('qquuid');
187
        $chunkIndex = intval($request->get('qqpartindex'));
188
        $numberOfExistingChunks = count($filesystem->files($this->temporaryChunksFolder . DIRECTORY_SEPARATOR . $fineUploaderUuid));
189
        if ($numberOfExistingChunks < $chunkIndex) {
190
            throw new FileStreamExceptions\UploadIncompleteException;
191
        }
192
193
        return true;
194
    }
195
196
    /**
197
     * @param string $size
198
     *
199
     * @return false|string
200
     */
201
    public function filesizeFromHumanReadableToBytes($size)
202
    {
203
        if (preg_match('/^([\d,.]+)\s?([kmgtpezy]?i?b)$/i', $size, $matches) !== 1) {
204
            return false;
205
        }
206
        $coefficient = $matches[1];
207
        $prefix = strtolower($matches[2]);
208
209
        $binaryPrefices = ['b', 'kib', 'mib', 'gib', 'tib', 'pib', 'eib', 'zib', 'yib'];
210
        $decimalPrefices = ['b', 'kb', 'mb', 'gb', 'tb', 'pb', 'eb', 'zb', 'yb'];
211
212
        $base = in_array($prefix, $binaryPrefices) ? 1024 : 1000;
213
        $flippedPrefixMap = $base == 1024 ? array_flip($binaryPrefices) : array_flip($decimalPrefices);
214
        $factor = array_pull($flippedPrefixMap, $prefix);
215
216
        return sprintf("%d", bcmul(str_replace(',', '', $coefficient), bcpow($base, $factor)));
217
    }
218
219
    /**
220
     * @param int  $bytes
221
     * @param int  $decimals
222
     * @param bool $binary
223
     *
224
     * @return string
225
     */
226
    public function filesizeFromBytesToHumanReadable($bytes, $decimals = 2, $binary = true)
227
    {
228
        $binaryPrefices = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
229
        $decimalPrefices = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
230
        $factor = intval(floor((strlen($bytes) - 1) / 3));
231
232
        return sprintf("%.{$decimals}f", $bytes / pow($binary ? 1024 : 1000, $factor)) . ' ' . $binary ? $binaryPrefices[$factor] : $decimalPrefices[$factor];
233
    }
234
235
    /**
236
     * @param string $path
237
     *
238
     * @return string
239
     * @throws \App\Exceptions\FileStream\NotFoundException
240
     */
241
    public function getAbsolutePath($path)
242
    {
243
        return config('filesystems.disks.local.root') . DIRECTORY_SEPARATOR . trim($path, DIRECTORY_SEPARATOR);
244
    }
245
246
    private function cleanupChunks()
247
    {
248
        $filesystem = app('filesystem')->disk('local');
249
        foreach ($filesystem->directories($this->temporaryChunksFolder) as $file) {
250
            if (time() - $filesystem->lastModified($file) > $this->chunksExpireIn) {
251
                $filesystem->deleteDirectory($file);
252
            }
253
        }
254
    }
255
256
    /**
257
     * @param \Illuminate\Http\Request $request
258
     *
259
     * @return void
260
     */
261
    private function combineChunks(Request $request)
262
    {
263
        # Prelim
264
        $filesystem = app('filesystem')->disk();
265
        $fineUploaderUuid = $request->get('qquuid');
266
        $chunksFolder = $this->temporaryChunksFolder . DIRECTORY_SEPARATOR . $fineUploaderUuid;
267
        $totalNumberOfChunks = $request->has('qqtotalparts') ? intval($request->get('qqtotalparts')) : 1;
268
269
        # Do we have all chunks?
270
        $numberOfExistingChunks = count($filesystem->files($chunksFolder));
271
        if ($numberOfExistingChunks != $totalNumberOfChunks) {
272
            throw new FileStreamExceptions\UploadIncompleteException;
273
        }
274
275
        # We have all chunks, proceed with combine.
276
        $targetStream = fopen($this->getAbsolutePath($fineUploaderUuid), 'wb');
277
        for ($i = 0; $i < $totalNumberOfChunks; $i++) {
278
            $chunkStream = fopen($this->getAbsolutePath($chunksFolder . DIRECTORY_SEPARATOR . $i), 'rb');
279
            stream_copy_to_stream($chunkStream, $targetStream);
280
            fclose($chunkStream);
281
        }
282
        fclose($targetStream);
283
        $filesystem->deleteDirectory($chunksFolder);
284
    }
285
286
    /**
287
     * @param string $path
288
     *
289
     * @return void
290
     */
291
    private function validateUploadRealMimeAgainstAllowedTypes($path)
292
    {
293
        $file = new SymfonyFile($path);
294
        if ($allowedMimeTypes = config('filesystems.allowed_mimetypes')) {
295
            if (is_array($allowedMimeTypes) && !is_null($fileMimeType = $file->getMimeType())) {
296
                if (!in_array($fileMimeType, $allowedMimeTypes)) {
297
                    throw new FileStreamExceptions\MimeTypeNotAllowedException;
298
                }
299
            }
300
        }
301
    }
302
303
    /**
304
     * @param string $originalPath
305
     * @param string $hash
306
     * @param bool   $loadBalance
307
     *
308
     * @return string
309
     */
310
    private function renameAndMoveUploadedFileByItsHash($originalPath, $hash, $loadBalance = true)
311
    {
312
        $destination = $hash;
313
        if ($loadBalance) {
314
            $config = config('filesystems.load_balancing');
315
            $folders = [];
316
            for ($i = 0; $i < $config['depth']; $i++) {
317
                $folders[] = substr($hash, -1 * ($i + 1) * $config['length'], $config['length']);
318
            }
319
            $destination = implode(DIRECTORY_SEPARATOR, array_merge($folders, [$hash]));
320
        }
321
        $filesystem = app('filesystem')->disk();
322
        (!$filesystem->exists($destination)) ? $filesystem->move($originalPath, $destination) : $filesystem->delete($originalPath);
323
324
        return $destination;
325
    }
326
327
    /**
328
     * @param \Symfony\Component\HttpFoundation\File\File $uploadedFile
329
     */
330
    private function persistDatabaseRecord(SymfonyFile $uploadedFile)
331
    {
332
        if (!$file = File::find($hash = $uploadedFile->getFilename())) {
333
            $file = new File();
334
            $file->hash = $hash;
335
            $file->uri_resource_host = 'localhost';
336
            $file->uri_resource_path = $uploadedFile->getPathname();
337
            $file->mime = $uploadedFile->getMimeType();
338
            $file->size = $uploadedFile->getSize();
339
            $file->save();
340
        }
341
    }
342
}
343