Completed
Push — master ( f7beba...93105c )
by Raffael
23:29
created

File   F

Complexity

Total Complexity 62

Size/Duplication

Total Lines 625
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 14

Test Coverage

Coverage 11.31%

Importance

Changes 0
Metric Value
wmc 62
lcom 1
cbo 14
dl 0
loc 625
ccs 31
cts 274
cp 0.1131
rs 3.415
c 0
b 0
f 0

22 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 17 2
A get() 0 16 3
A copyTo() 0 36 5
A getHistory() 0 4 1
C restore() 0 82 9
A delete() 0 33 4
A isTemporaryFile() 0 10 3
A deleteVersion() 0 32 5
A cleanHistory() 0 8 2
A getAttributes() 0 26 1
A getExtension() 0 9 2
A getSize() 0 4 1
A getETag() 0 4 1
A getHash() 0 4 1
A getVersion() 0 4 1
A put() 0 10 1
B setContent() 0 49 8
A _forceDelete() 0 23 2
A increaseVersion() 0 16 2
A prePutFile() 0 20 3
A addVersion() 0 38 3
A postPutFile() 0 29 2

How to fix   Complexity   

Complex Class

Complex classes like File 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. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

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 File, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * balloon
7
 *
8
 * @copyright   Copryright (c) 2012-2018 gyselroth GmbH (https://gyselroth.com)
9
 * @license     GPL-3.0 https://opensource.org/licenses/GPL-3.0
10
 */
11
12
namespace Balloon\Filesystem\Node;
13
14
use Balloon\Filesystem;
15
use Balloon\Filesystem\Acl;
16
use Balloon\Filesystem\Acl\Exception as AclException;
17
use Balloon\Filesystem\Exception;
18
use Balloon\Filesystem\Storage\Exception as StorageException;
19
use Balloon\Hook;
20
use MongoDB\BSON\ObjectId;
21
use MongoDB\BSON\UTCDateTime;
22
use Psr\Log\LoggerInterface;
23
use Sabre\DAV\IFile;
24
25
class File extends AbstractNode implements IFile
26
{
27
    /**
28
     * History types.
29
     */
30
    const HISTORY_CREATE = 0;
31
    const HISTORY_EDIT = 1;
32
    const HISTORY_RESTORE = 2;
33
34
    /**
35
     * Empty content hash (NULL).
36
     */
37
    const EMPTY_CONTENT = 'd41d8cd98f00b204e9800998ecf8427e';
38
39
    /**
40
     * Temporary file patterns.
41
     *
42
     * @param array
43
     **/
44
    protected $temp_files = [
45
        '/^\._(.*)$/',     // OS/X resource forks
46
        '/^.DS_Store$/',   // OS/X custom folder settings
47
        '/^desktop.ini$/', // Windows custom folder settings
48
        '/^Thumbs.db$/',   // Windows thumbnail cache
49
        '/^.(.*).swpx$/',  // ViM temporary files
50
        '/^.(.*).swx$/',   // ViM temporary files
51
        '/^.(.*).swp$/',   // ViM temporary files
52
        '/^\.dat(.*)$/',   // Smultron seems to create these
53
        '/^~lock.(.*)#$/', // Windows 7 lockfiles
54
    ];
55
56
    /**
57
     * MD5 Hash of the content.
58
     *
59
     * @var string
60
     */
61
    protected $hash;
62
63
    /**
64
     * File version.
65
     *
66
     * @var int
67
     */
68
    protected $version = 0;
69
70
    /**
71
     * File size.
72
     *
73
     * @var int
74
     */
75
    protected $size = 0;
76
77
    /**
78
     * History.
79
     *
80
     * @var array
81
     */
82
    protected $history = [];
83
84
    /**
85
     * Initialize file node.
86
     */
87 10
    public function __construct(array $attributes, Filesystem $fs, LoggerInterface $logger, Hook $hook, Acl $acl, Collection $parent)
88
    {
89 10
        $this->_fs = $fs;
90 10
        $this->_server = $fs->getServer();
91 10
        $this->_db = $fs->getDatabase();
92 10
        $this->_user = $fs->getUser();
93 10
        $this->_logger = $logger;
94 10
        $this->_hook = $hook;
95 10
        $this->_acl = $acl;
96 10
        $this->_parent = $parent;
97
98 10
        foreach ($attributes as $attr => $value) {
99 10
            $this->{$attr} = $value;
100
        }
101
102 10
        $this->raw_attributes = $attributes;
103 10
    }
104
105
    /**
106
     * Read content and return ressource.
107
     */
108
    public function get()
109
    {
110
        if (null === $this->storage) {
111
            return null;
112
        }
113
114
        try {
115
            return $this->_parent->getStorage()->openReadStream($this);
116
        } catch (\Exception $e) {
117
            throw new Exception\NotFound(
118
                'storage blob is gone',
119
                Exception\NotFound::CONTENTS_NOT_FOUND,
120
                $e
121
            );
122
        }
123
    }
124
125
    /**
126
     * Copy node.
127
     */
128
    public function copyTo(Collection $parent, int $conflict = NodeInterface::CONFLICT_NOACTION, ?string $recursion = null, bool $recursion_first = true): NodeInterface
129
    {
130
        $this->_hook->run(
131
            'preCopyFile',
132
            [$this, $parent, &$conflict, &$recursion, &$recursion_first]
133
        );
134
135
        if (NodeInterface::CONFLICT_RENAME === $conflict && $parent->childExists($this->name)) {
136
            $name = $this->getDuplicateName();
137
        } else {
138
            $name = $this->name;
139
        }
140
141
        if (NodeInterface::CONFLICT_MERGE === $conflict && $parent->childExists($this->name)) {
142
            $result = $parent->getChild($this->name);
143
            $result->put($this->get());
144
        } else {
145
            $stream = $this->get();
146
            $session = $parent->getStorage()->storeTemporaryFile($stream, $this->_server->getUserById($this->getOwner()));
147
            $result = $parent->addFile($name, $session, [
148
                'created' => $this->created,
149
                'changed' => $this->changed,
150
                'deleted' => $this->deleted,
151
                'meta' => $this->meta,
152
            ], NodeInterface::CONFLICT_NOACTION, true);
153
154
            fclose($stream);
155
        }
156
157
        $this->_hook->run(
158
            'postCopyFile',
159
            [$this, $parent, $result, $conflict, $recursion, $recursion_first]
160
        );
161
162
        return $result;
163
    }
164
165
    /**
166
     * Get history.
167
     */
168
    public function getHistory(): array
169
    {
170
        return $this->history;
171
    }
172
173
    /**
174
     * Restore content to some older version.
175
     */
176
    public function restore(int $version): bool
177
    {
178
        if (!$this->_acl->isAllowed($this, 'w')) {
179
            throw new AclException\Forbidden(
180
                'not allowed to restore node '.$this->name,
181
                AclException\Forbidden::NOT_ALLOWED_TO_RESTORE
182
            );
183
        }
184
185
        $this->_hook->run('preRestoreFile', [$this, &$version]);
186
187
        if ($this->readonly) {
188
            throw new Exception\Conflict(
189
                'node is marked as readonly, it is not possible to change any content',
190
                Exception\Conflict::READONLY
191
            );
192
        }
193
194
        if ($this->version === $version) {
195
            throw new Exception('file is already version '.$version);
196
        }
197
198
        $current = $this->version;
0 ignored issues
show
Unused Code introduced by
$current 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...
199
200
        $v = array_search($version, array_column($this->history, 'version'), true);
201
        if (false === $v) {
202
            throw new Exception('failed restore file to version '.$version.', version was not found');
203
        }
204
205
        $file = $this->history[$v]['storage'];
206
        $latest = $this->version + 1;
207
208
        $this->history[] = [
209
            'version' => $latest,
210
            'changed' => $this->changed,
211
            'user' => $this->owner,
212
            'type' => self::HISTORY_RESTORE,
213
            'hash' => $this->history[$v]['hash'],
214
            'origin' => $this->history[$v]['version'],
215
            'storage' => $this->history[$v]['storage'],
216
            'size' => $this->history[$v]['size'],
217
            'mime' => isset($this->history[$v]['mime']) ? $this->history[$v]['mime'] : null,
218
        ];
219
220
        try {
221
            $this->deleted = false;
222
            $this->storage = $this->history[$v]['storage'];
223
224
            $this->hash = null === $file ? self::EMPTY_CONTENT : $this->history[$v]['hash'];
225
            $this->mime = isset($this->history[$v]['mime']) ? $this->history[$v]['mime'] : null;
226
            $this->size = $this->history[$v]['size'];
227
            $this->changed = $this->history[$v]['changed'];
228
            $new = $this->increaseVersion();
229
            $this->version = $new;
230
231
            $this->save([
232
                'deleted',
233
                'version',
234
                'storage',
235
                'hash',
236
                'mime',
237
                'size',
238
                'history',
239
                'changed',
240
            ]);
241
242
            $this->_hook->run('postRestoreFile', [$this, &$version]);
243
244
            $this->_logger->info('restored file ['.$this->_id.'] to version ['.$version.']', [
245
                'category' => get_class($this),
246
            ]);
247
        } catch (\Exception $e) {
248
            $this->_logger->error('failed restore file ['.$this->_id.'] to version ['.$version.']', [
249
                'category' => get_class($this),
250
                'exception' => $e,
251
            ]);
252
253
            throw $e;
254
        }
255
256
        return true;
257
    }
258
259
    /**
260
     * Delete node.
261
     *
262
     * Actually the node will not be deleted (Just set a delete flag), set $force=true to
263
     * delete finally
264
     */
265
    public function delete(bool $force = false, ?string $recursion = null, bool $recursion_first = true): bool
266
    {
267
        if (!$this->_acl->isAllowed($this, 'w')) {
268
            throw new AclException\Forbidden(
269
                'not allowed to delete node '.$this->name,
270
                AclException\Forbidden::NOT_ALLOWED_TO_DELETE
271
            );
272
        }
273
274
        $this->_hook->run('preDeleteFile', [$this, &$force, &$recursion, &$recursion_first]);
275
276
        if (true === $force || $this->isTemporaryFile()) {
277
            $result = $this->_forceDelete();
278
            $this->_hook->run('postDeleteFile', [$this, $force, $recursion, $recursion_first]);
279
280
            return $result;
281
        }
282
283
        $ts = new UTCDateTime();
284
        $this->deleted = $ts;
285
        $this->storage = $this->_parent->getStorage()->deleteFile($this);
286
287
        $result = $this->save([
288
            'version',
289
            'storage',
290
            'deleted',
291
            'history',
292
        ], [], $recursion, $recursion_first);
293
294
        $this->_hook->run('postDeleteFile', [$this, $force, $recursion, $recursion_first]);
295
296
        return $result;
297
    }
298
299
    /**
300
     * Check if file is temporary.
301
     */
302 2
    public function isTemporaryFile(): bool
303
    {
304 2
        foreach ($this->temp_files as $pattern) {
305 2
            if (preg_match($pattern, $this->name)) {
306 2
                return true;
307
            }
308
        }
309
310 1
        return false;
311
    }
312
313
    /**
314
     * Delete version.
315
     */
316
    public function deleteVersion(int $version): bool
317
    {
318
        $key = array_search($version, array_column($this->history, 'version'), true);
319
320
        if (false === $key) {
321
            throw new Exception('version '.$version.' does not exists');
322
        }
323
324
        $blobs = array_column($this->history, 'storage');
325
326
        try {
327
            //do not remove blob if there are other versions linked against it
328
            if ($this->history[$key]['storage'] !== null && count(array_keys($blobs, $this->history[$key]['storage'])) === 1) {
329
                $this->_parent->getStorage()->forceDeleteFile($this, $version);
330
            }
331
332
            array_splice($this->history, $key, 1);
333
334
            $this->_logger->debug('removed version ['.$version.'] from file ['.$this->_id.']', [
335
                'category' => get_class($this),
336
            ]);
337
338
            return $this->save('history');
339
        } catch (StorageException\BlobNotFound $e) {
340
            $this->_logger->error('failed remove version ['.$version.'] from file ['.$this->_id.']', [
341
                'category' => get_class($this),
342
                'exception' => $e,
343
            ]);
344
345
            return false;
346
        }
347
    }
348
349
    /**
350
     * Cleanup history.
351
     */
352
    public function cleanHistory(): bool
353
    {
354
        foreach ($this->history as $node) {
355
            $this->deleteVersion($node['version']);
356
        }
357
358
        return true;
359
    }
360
361
    /**
362
     * Get Attributes.
363
     */
364
    public function getAttributes(): array
365
    {
366
        return [
367
            '_id' => $this->_id,
368
            'name' => $this->name,
369
            'hash' => $this->hash,
370
            'directory' => false,
371
            'size' => $this->size,
372
            'version' => $this->version,
373
            'parent' => $this->parent,
374
            'acl' => $this->acl,
375
            'app' => $this->app,
376
            'meta' => $this->meta,
377
            'mime' => $this->mime,
378
            'owner' => $this->owner,
379
            'history' => $this->history,
380
            'shared' => $this->shared,
381
            'deleted' => $this->deleted,
382
            'changed' => $this->changed,
383
            'created' => $this->created,
384
            'destroy' => $this->destroy,
385
            'readonly' => $this->readonly,
386
            'storage_reference' => $this->storage_reference,
387
            'storage' => $this->storage,
388
        ];
389
    }
390
391
    /**
392
     * Get filename extension.
393
     */
394 2
    public function getExtension(): string
395
    {
396 2
        $ext = strrchr($this->name, '.');
397 2
        if (false === $ext) {
398 1
            throw new Exception('file does not have an extension');
399
        }
400
401 1
        return substr($ext, 1);
402
    }
403
404
    /**
405
     * Get file size.
406
     */
407 1
    public function getSize(): int
408
    {
409 1
        return $this->size;
410
    }
411
412
    /**
413
     * Get md5 sum of the file content,
414
     * actually the hash value comes from the database.
415
     */
416 1
    public function getETag(): string
417
    {
418 1
        return "'".$this->hash."'";
419
    }
420
421
    /**
422
     * Get hash.
423
     */
424 1
    public function getHash(): ?string
425
    {
426 1
        return $this->hash;
427
    }
428
429
    /**
430
     * Get version.
431
     */
432 1
    public function getVersion(): int
433
    {
434 1
        return $this->version;
435
    }
436
437
    /**
438
     * Change content (Sabe dav compatible method).
439
     */
440
    public function put($content): int
441
    {
442
        $this->_logger->debug('write new file content into temporary storage for file ['.$this->_id.']', [
443
            'category' => get_class($this),
444
        ]);
445
446
        $session = $this->_parent->getStorage()->storeTemporaryFile($content, $this->_user);
447
448
        return $this->setContent($session);
449
    }
450
451
    /**
452
     * Set content (temporary file).
453
     */
454
    public function setContent(ObjectId $session, array $attributes = []): int
455
    {
456
        $this->_logger->debug('set temporary file ['.$session.'] as file content for ['.$this->_id.']', [
457
            'category' => get_class($this),
458
        ]);
459
460
        $previous = $this->version;
461
        $storage = $this->storage;
462
        $this->prePutFile($session);
463
        $result = $this->_parent->getStorage()->storeFile($this, $session);
464
        $this->storage = $result['reference'];
465
466
        if ($this->hash === $result['hash']) {
467
            $this->_logger->debug('do not update file version, hash identical to existing version ['.$this->hash.' == '.$result['hash'].']', [
468
                'category' => get_class($this),
469
            ]);
470
471
            return $this->version;
472
        }
473
474
        $this->hash = $result['hash'];
475
        $this->size = $result['size'];
476
477
        if ($this->size === 0 && $this->getMount() === null) {
478
            $this->storage = null;
0 ignored issues
show
Documentation Bug introduced by
It seems like null of type null is incompatible with the declared type array of property $storage.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
479
        } else {
480
            $this->storage = $result['reference'];
481
        }
482
483
        $this->increaseVersion();
484
485
        if (isset($attributes['changed'])) {
486
            if (!($attributes['changed'] instanceof UTCDateTime)) {
0 ignored issues
show
Bug introduced by
The class MongoDB\BSON\UTCDateTime does not exist. Did you forget a USE statement, or did you not list all dependencies?

This error could be the result of:

1. Missing dependencies

PHP Analyzer uses your composer.json file (if available) to determine the dependencies of your project and to determine all the available classes and functions. It expects the composer.json to be in the root folder of your repository.

Are you sure this class is defined by one of your dependencies, or did you maybe not list a dependency in either the require or require-dev section?

2. Missing use statement

PHP does not complain about undefined classes in ìnstanceof checks. For example, the following PHP code will work perfectly fine:

if ($x instanceof DoesNotExist) {
    // Do something.
}

If you have not tested against this specific condition, such errors might go unnoticed.

Loading history...
487
                throw new Exception\InvalidArgument('attribute changed must be an instance of UTCDateTime');
488
            }
489
490
            $this->changed = $attributes['changed'];
491
        } else {
492
            $this->changed = new UTCDateTime();
493
        }
494
495
        if ($result['reference'] != $storage || $previous === 0) {
496
            $this->addVersion($attributes);
497
        }
498
499
        $this->postPutFile();
500
501
        return $this->version;
502
    }
503
504
    /**
505
     * Completly remove file.
506
     */
507
    protected function _forceDelete(): bool
508
    {
509
        try {
510
            $this->_parent->getStorage()->forceDeleteFile($this);
511
            $this->cleanHistory();
512
            $this->_db->storage->deleteOne([
513
                '_id' => $this->_id,
514
            ]);
515
516
            $this->_logger->info('removed file node ['.$this->_id.']', [
517
                'category' => get_class($this),
518
            ]);
519
        } catch (\Exception $e) {
520
            $this->_logger->error('failed delete file node ['.$this->_id.']', [
521
                'category' => get_class($this),
522
                'exception' => $e,
523
            ]);
524
525
            throw $e;
526
        }
527
528
        return true;
529
    }
530
531
    /**
532
     * Increase version.
533
     */
534
    protected function increaseVersion(): int
535
    {
536
        $max = $this->_fs->getServer()->getMaxFileVersion();
537
        if (count($this->history) >= $max) {
538
            $del = key($this->history);
539
            $this->_logger->debug('history limit ['.$max.'] reached, remove oldest version ['.$this->history[$del]['version'].'] from file ['.$this->_id.']', [
540
                'category' => get_class($this),
541
            ]);
542
543
            $this->deleteVersion($this->history[$del]['version']);
544
        }
545
546
        ++$this->version;
547
548
        return $this->version;
549
    }
550
551
    /**
552
     * Pre content change checks.
553
     */
554
    protected function prePutFile(ObjectId $session): bool
555
    {
556
        if (!$this->_acl->isAllowed($this, 'w')) {
557
            throw new AclException\Forbidden(
558
                'not allowed to modify node',
559
                AclException\Forbidden::NOT_ALLOWED_TO_MODIFY
560
            );
561
        }
562
563
        $this->_hook->run('prePutFile', [$this, &$session]);
564
565
        if ($this->readonly) {
566
            throw new Exception\Conflict(
567
                'node is marked as readonly, it is not possible to change any content',
568
                Exception\Conflict::READONLY
569
            );
570
        }
571
572
        return true;
573
    }
574
575
    /**
576
     * Add new version.
577
     */
578
    protected function addVersion(array $attributes = []): self
579
    {
580
        if (1 !== $this->version) {
581
            $this->_logger->debug('added new history version ['.$this->version.'] for file ['.$this->_id.']', [
582
                'category' => get_class($this),
583
            ]);
584
585
            $this->history[] = [
586
                'version' => $this->version,
587
                'changed' => $this->changed,
588
                'user' => $this->_user->getId(),
589
                'type' => self::HISTORY_EDIT,
590
                'storage' => $this->storage,
591
                'size' => $this->size,
592
                'mime' => $this->mime,
593
                'hash' => $this->hash,
594
            ];
595
596
            return $this;
597
        }
598
599
        $this->_logger->debug('added first file version [1] for file ['.$this->_id.']', [
600
            'category' => get_class($this),
601
        ]);
602
603
        $this->history[0] = [
604
            'version' => 1,
605
            'changed' => isset($attributes['changed']) ? $attributes['changed'] : new UTCDateTime(),
606
            'user' => $this->owner,
607
            'type' => self::HISTORY_CREATE,
608
            'storage' => $this->storage,
609
            'size' => $this->size,
610
            'mime' => $this->mime,
611
            'hash' => $this->hash,
612
        ];
613
614
        return $this;
615
    }
616
617
    /**
618
     * Finalize put request.
619
     */
620
    protected function postPutFile(): self
621
    {
622
        try {
623
            $this->save([
624
                'size',
625
                'changed',
626
                'mime',
627
                'hash',
628
                'version',
629
                'history',
630
                'storage',
631
            ]);
632
633
            $this->_logger->debug('modifed file metadata ['.$this->_id.']', [
634
                'category' => get_class($this),
635
            ]);
636
637
            $this->_hook->run('postPutFile', [$this]);
638
639
            return $this;
640
        } catch (\Exception $e) {
641
            $this->_logger->error('failed modify file metadata ['.$this->_id.']', [
642
                'category' => get_class($this),
643
                'exception' => $e,
644
            ]);
645
646
            throw $e;
647
        }
648
    }
649
}
650