Completed
Push — master ( 37faaa...541bbf )
by Raffael
10:18 queued 06:30
created

Gridfs::storeFile()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 48

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 0
Metric Value
dl 0
loc 48
ccs 0
cts 39
cp 0
rs 9.1344
c 0
b 0
f 0
cc 2
crap 6
nc 2
nop 2
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * balloon
7
 *
8
 * @copyright   Copryright (c) 2012-2019 gyselroth GmbH (https://gyselroth.com)
9
 * @license     GPL-3.0 https://opensource.org/licenses/GPL-3.0
10
 */
11
12
namespace Balloon\Filesystem\Storage\Adapter;
13
14
use Balloon\Filesystem\Node\Collection;
15
use Balloon\Filesystem\Node\File;
16
use Balloon\Filesystem\Node\NodeInterface;
17
use Balloon\Filesystem\Storage\Exception;
18
use Balloon\Server\User;
19
use Balloon\Session\SessionInterface;
20
use MongoDB\BSON\Binary;
21
use MongoDB\BSON\ObjectId;
22
use MongoDB\BSON\UTCDateTime;
23
use MongoDB\Database;
24
use MongoDB\GridFS\Bucket;
25
use Psr\Log\LoggerInterface;
26
27
class Gridfs implements AdapterInterface
28
{
29
    /**
30
     * Grid chunks.
31
     */
32
    public const CHUNK_SIZE = 261120;
33
34
    /**
35
     * Database.
36
     *
37
     * @var Database
38
     */
39
    protected $db;
40
41
    /**
42
     * GridFS.
43
     *
44
     * @var Bucket
45
     */
46
    protected $gridfs;
47
48
    /**
49
     * Logger.
50
     *
51
     * @var LoggerInterface
52
     */
53
    protected $logger;
54
55
    /**
56
     * GridFS storage.
57
     */
58
    public function __construct(Database $db, Bucket $gridfs, LoggerInterface $logger)
59
    {
60
        $this->db = $db;
61
        $this->gridfs = $gridfs;
62
        $this->logger = $logger;
63
    }
64
65
    /**
66
     * {@inheritdoc}
67
     */
68
    public function deleteCollection(Collection $collection): ?array
69
    {
70
        return $collection->getAttributes()['storage'];
71
    }
72
73
    /**
74
     * {@inheritdoc}
75
     */
76
    public function forceDeleteCollection(Collection $collection): bool
77
    {
78
        return true;
79
    }
80
81
    /**
82
     * {@inheritdoc}
83
     */
84
    public function undelete(NodeInterface $node): ?array
85
    {
86
        return $node->getAttributes()['storage'];
87
    }
88
89
    /**
90
     * {@inheritdoc}
91
     */
92
    public function rename(NodeInterface $node, string $new_name): ?array
93
    {
94
        return $node->getAttributes()['storage'];
95
    }
96
97
    /**
98
     * {@inheritdoc}
99
     */
100
    public function readonly(NodeInterface $node, bool $readonly = true): ?array
101
    {
102
        return $node->getAttributes()['storage'];
103
    }
104
105
    /**
106
     * {@inheritdoc}
107
     */
108
    public function hasNode(NodeInterface $node): bool
109
    {
110
        return null !== $this->getFileById($node->getId());
111
    }
112
113
    /**
114
     * {@inheritdoc}
115
     */
116
    public function forceDeleteFile(File $file, ?int $version = null): bool
117
    {
118
        try {
119
            $exists = $this->getFileById($this->getId($file, $version));
120
        } catch (Exception\BlobNotFound $e) {
121
            return true;
122
        }
123
124
        if (null === $exists) {
125
            $this->logger->debug('gridfs blob ['.$exists['_id'].'] was not found for file reference ['.$file->getId().']', [
126
                'category' => get_class($this),
127
            ]);
128
129
            return false;
130
        }
131
132
        if (!isset($exists['metadata']['references'])) {
133
            $this->gridfs->delete($exists['_id']);
134
135
            return true;
136
        }
137
138
        $refs = $exists['metadata']['references'];
139
        if (($key = array_search($file->getId(), $refs)) !== false) {
140
            unset($refs[$key]);
141
            $refs = array_values($refs);
142
        }
143
144
        if (count($refs) >= 1) {
145
            $this->logger->debug('gridfs content node ['.$exists['_id'].'] still has references left, just remove the reference ['.$file->getId().']', [
146
                'category' => get_class($this),
147
            ]);
148
149
            $this->db->{'fs.files'}->updateOne(['_id' => $exists['_id']], [
150
                '$set' => ['metadata.references' => $refs],
151
            ]);
152
        } else {
153
            $this->logger->debug('gridfs content node ['.$exists['_id'].'] has no references left, delete node completely', [
154
                'category' => get_class($this),
155
            ]);
156
157
            $this->gridfs->delete($exists['_id']);
158
        }
159
160
        return true;
161
    }
162
163
    /**
164
     * {@inheritdoc}
165
     */
166
    public function deleteFile(File $file, ?int $version = null): ?array
167
    {
168
        return $file->getAttributes()['storage'];
169
    }
170
171
    /**
172
     * {@inheritdoc}
173
     */
174
    public function openReadStream(File $file)
175
    {
176
        return $this->gridfs->openDownloadStream($this->getId($file));
177
    }
178
179
    /**
180
     * {@inheritdoc}
181
     */
182
    public function storeFile(File $file, SessionInterface $session): array
183
    {
184
        $this->logger->debug('finalize temporary file ['.$session->getId().'] and add file ['.$file->getId().'] as reference', [
185
            'category' => get_class($this),
186
        ]);
187
188
        $md5 = $session->getHash();
189
        $blob = $this->getFileByHash($md5);
190
191
        if ($blob !== null) {
192
            $this->logger->debug('found existing file with hash ['.$md5.'], add file ['.$file->getId().'] as reference to ['.$blob['_id'].']', [
193
                'category' => get_class($this),
194
            ]);
195
196
            $this->db->selectCollection('fs.files')->updateOne([
197
                'md5' => $blob['md5'],
198
            ], [
199
                '$addToSet' => [
200
                    'metadata.references' => $file->getId(),
201
                ],
202
            ]);
203
204
            $this->gridfs->delete($session->getId());
205
206
            return [
207
                'reference' => ['_id' => $blob['_id']],
208
            ];
209
        }
210
211
        $this->db->selectCollection('fs.files')->updateOne([
212
            '_id' => $session->getId(),
213
        ], [
214
            '$set' => [
215
                'md5' => $md5,
216
                'metadata.references' => [$file->getId()],
217
            ],
218
            '$unset' => [
219
                'metadata.temporary' => true,
220
            ],
221
        ]);
222
223
        $blob = $this->getFileById($session->getId());
224
225
        return [
226
            'reference' => ['_id' => $session->getId()],
227
            'size' => $blob['length'],
228
        ];
229
    }
230
231
    /**
232
     * {@inheritdoc}
233
     */
234
    public function createCollection(Collection $parent, string $name): array
235
    {
236
        return [];
237
    }
238
239
    /**
240
     * {@inheritdoc}
241
     */
242
    public function move(NodeInterface $node, Collection $parent): ?array
243
    {
244
        return $node->getAttributes()['storage'];
245
    }
246
247
    /**
248
     * {@inheritdoc}
249
     */
250
    public function storeTemporaryFile($stream, User $user, ?ObjectId $session = null): ObjectId
251
    {
252
        $exists = $session;
253
254
        if ($session === null) {
255
            $session = new ObjectId();
256
257
            $this->logger->info('create new tempory storage file ['.$session.']', [
258
                'category' => get_class($this),
259
            ]);
260
261
            $this->db->selectCollection('fs.files')->insertOne([
262
                '_id' => $session,
263
                'chunkSize' => self::CHUNK_SIZE,
264
                'length' => 0,
265
                'uploadDate' => new UTCDateTime(),
266
                'metadata' => ['temporary' => true],
267
            ]);
268
        }
269
270
        $temp = $this->db->selectCollection('fs.files')->findOne([
271
            '_id' => $session,
272
        ]);
273
274
        if ($temp === null) {
275
            throw new Exception\SessionNotFound('temporary storage for this file is gone');
276
        }
277
278
        $this->storeStream($stream, $user, $exists, $temp);
0 ignored issues
show
Bug introduced by
It seems like $temp defined by $this->db->selectCollect...ray('_id' => $session)) on line 270 can also be of type object; however, Balloon\Filesystem\Stora...r\Gridfs::storeStream() does only seem to accept array, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
279
280
        return $session;
281
    }
282
283
    /**
284
     * Get file blob id.
285
     */
286
    protected function getId(NodeInterface $node, ?int $version = null): ObjectId
287
    {
288
        $attributes = $node->getAttributes();
289
290
        if ($version !== null) {
291
            $history = $node->getHistory();
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Balloon\Filesystem\Node\NodeInterface as the method getHistory() does only exist in the following implementations of said interface: Balloon\Filesystem\Node\File.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
292
293
            $key = array_search($version, array_column($history, 'version'), true);
294
            $blobs = array_column($history, 'storage');
295
296
            if ($key === false || !isset($blobs[$key]['_id'])) {
297
                throw new Exception\BlobNotFound('attributes do not contain a gridfs id storage._id');
298
            }
299
300
            return $blobs[$key]['_id'];
301
        }
302
303
        if (!isset($attributes['storage']['_id'])) {
304
            throw new Exception\BlobNotFound('attributes do not contain a gridfs id storage._id');
305
        }
306
307
        return $attributes['storage']['_id'];
308
    }
309
310
    /**
311
     * Get stored file.
312
     */
313
    protected function getFileById(ObjectId $id): ?array
314
    {
315
        return $this->gridfs->findOne(['_id' => $id]);
316
    }
317
318
    /**
319
     * Get stored file.
320
     */
321
    protected function getFileByHash(string $hash): ?array
322
    {
323
        return $this->gridfs->findOne(['md5' => $hash]);
324
    }
325
326
    /**
327
     * Store stream content.
328
     */
329
    protected function storeStream($stream, User $user, ?ObjectId $exists, array $temp): int
330
    {
331
        $data = null;
332
        $length = $temp['length'];
333
        $chunks = 0;
334
        $left = 0;
335
336
        if ($exists !== null) {
337
            $chunks = (int) ceil($temp['length'] / $temp['chunkSize']);
338
            $left = (int) ($chunks * $temp['chunkSize'] - $temp['length']);
339
340
            if ($left < 0) {
341
                $left = 0;
342
            } else {
343
                $this->logger->debug('found existing chunks ['.$chunks.'] for temporary file ['.$temp['_id'].'] while the last chunk has ['.$left.'] bytes free', [
344
                    'category' => get_class($this),
345
                ]);
346
347
                --$chunks;
348
349
                $last = $this->db->selectCollection('fs.chunks')->findOne([
350
                    'files_id' => $temp['_id'],
351
                    'n' => $chunks,
352
                ]);
353
354
                if ($last === null) {
355
                    throw new Exception\ChunkNotFound('Chunk not found, file is corrupt');
356
                }
357
358
                $data = $last['data']->getData();
359
            }
360
        }
361
362
        while (!feof($stream)) {
363
            if ($data !== null) {
364
                $append = $this->readStream($stream, $left);
365
                $data .= $append;
366
                $chunk = new Binary($data, Binary::TYPE_GENERIC);
367
                $size = mb_strlen($append, '8bit');
368
                $length += $size;
369
370
                $this->logger->debug('append data ['.$size.'] to last chunk ['.$chunks.'] in temporary file ['.$temp['_id'].']', [
371
                    'category' => get_class($this),
372
                ]);
373
374
                $last = $this->db->selectCollection('fs.chunks')->updateOne([
0 ignored issues
show
Unused Code introduced by
$last is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
375
                    'files_id' => $temp['_id'],
376
                    'n' => $chunks,
377
                ], [
378
                    '$set' => [
379
                        'data' => $chunk,
380
                    ],
381
                ]);
382
383
                ++$chunks;
384
                $data = null;
385
386
                continue;
387
            }
388
389
            $content = $this->readStream($stream, $temp['chunkSize']);
390
            $size = mb_strlen($content, '8bit');
391
392
            if ($size === 0) {
393
                continue;
394
            }
395
396
            $length += $size;
397
398
            $chunk = new Binary($content, Binary::TYPE_GENERIC);
399
            $last = $this->db->selectCollection('fs.chunks')->insertOne([
400
                'files_id' => $temp['_id'],
401
                'n' => $chunks,
402
                'data' => $chunk,
403
            ]);
404
405
            $this->logger->debug('inserted new chunk ['.$last->getInsertedId().'] ['.$size.'] in temporary file ['.$temp['_id'].']', [
406
                'category' => get_class($this),
407
            ]);
408
409
            ++$chunks;
410
411
            continue;
412
        }
413
414
        $this->verifyQuota($user, $length, $temp['_id']);
415
416
        $this->db->selectCollection('fs.files')->updateOne([
417
            '_id' => $temp['_id'],
418
        ], [
419
            '$set' => [
420
                'uploadDate' => new UTCDateTime(),
421
                'length' => $length,
422
            ],
423
        ]);
424
425
        return $length;
426
    }
427
428
    /**
429
     * Verify quota.
430
     */
431
    protected function verifyQuota(User $user, int $size, ObjectId $session): bool
432
    {
433
        if (!$user->checkQuota($size)) {
434
            $this->logger->warning('stop adding chunk, user ['.$user->getId().'] quota is full, remove upload session', [
435
                'category' => get_class($this),
436
            ]);
437
438
            $this->gridfs->delete($session);
439
440
            throw new Exception\InsufficientStorage('user quota is full', Exception\InsufficientStorage::USER_QUOTA_FULL);
441
        }
442
443
        return true;
444
    }
445
446
    /**
447
     * Read x bytes from stream.
448
     */
449
    protected function readStream($stream, int $bytes): string
450
    {
451
        $length = 0;
452
        $data = '';
453
        while (!feof($stream)) {
454
            if ($length + 8192 > $bytes) {
455
                $max = $bytes - $length;
456
457
                if ($max === 0) {
458
                    return $data;
459
                }
460
461
                $length += $max;
462
463
                return $data .= fread($stream, $max);
464
            }
465
466
            $length += 8192;
467
            $data .= fread($stream, 8192);
468
        }
469
470
        return $data;
471
    }
472
}
473