Completed
Pull Request — master (#141)
by Raffael
15:37 queued 10:36
created

File   D

Complexity

Total Complexity 59

Size/Duplication

Total Lines 640
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 13

Test Coverage

Coverage 11.23%

Importance

Changes 0
Metric Value
wmc 59
lcom 1
cbo 13
dl 0
loc 640
ccs 31
cts 276
cp 0.1123
rs 4.04
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 32 5
A getHistory() 0 4 1
C restore() 0 84 9
B delete() 0 43 5
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
A setContent() 0 26 2
A _forceDelete() 0 22 2
A increaseVersion() 0 16 2
A prePutFile() 0 20 3
B addVersion() 0 50 5
A postPutFile() 0 30 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;
19
use Balloon\Filesystem\Storage\Exception as StorageException;
20
use Balloon\Hook;
21
use MimeType\MimeType;
22
use MongoDB\BSON\ObjectId;
23
use MongoDB\BSON\UTCDateTime;
24
use MongoDB\GridFS\Exception as GridFSException;
25
use Psr\Log\LoggerInterface;
26
use Sabre\DAV\IFile;
27
28
class File extends AbstractNode implements IFile
29
{
30
    /**
31
     * History types.
32
     */
33
    const HISTORY_CREATE = 0;
34
    const HISTORY_EDIT = 1;
35
    const HISTORY_RESTORE = 2;
36
    const HISTORY_DELETE = 3;
37
    const HISTORY_UNDELETE = 4;
38
39
    /**
40
     * Empty content hash (NULL).
41
     */
42
    const EMPTY_CONTENT = 'd41d8cd98f00b204e9800998ecf8427e';
43
44
    /**
45
     * Temporary file patterns.
46
     *
47
     * @param array
48
     **/
49
    protected $temp_files = [
50
        '/^\._(.*)$/',     // OS/X resource forks
51
        '/^.DS_Store$/',   // OS/X custom folder settings
52
        '/^desktop.ini$/', // Windows custom folder settings
53
        '/^Thumbs.db$/',   // Windows thumbnail cache
54
        '/^.(.*).swpx$/',  // ViM temporary files
55
        '/^.(.*).swx$/',   // ViM temporary files
56
        '/^.(.*).swp$/',   // ViM temporary files
57
        '/^\.dat(.*)$/',   // Smultron seems to create these
58
        '/^~lock.(.*)#$/', // Windows 7 lockfiles
59
    ];
60
61
    /**
62
     * MD5 Hash of the content.
63
     *
64
     * @var string
65
     */
66
    protected $hash;
67
68
    /**
69
     * File version.
70
     *
71
     * @var int
72
     */
73
    protected $version = 0;
74
75
    /**
76
     * File size.
77
     *
78
     * @var int
79
     */
80
    protected $size = 0;
81
82
    /**
83
     * History.
84
     *
85
     * @var array
86
     */
87
    protected $history = [];
88
89
    /**
90
     * Storage attributes.
91
     *
92
     * @var mixed
93
     */
94
    protected $storage;
95
96
    /**
97
     * Initialize file node.
98
     */
99 10
    public function __construct(array $attributes, Filesystem $fs, LoggerInterface $logger, Hook $hook, Acl $acl, Storage $storage)
100
    {
101 10
        $this->_fs = $fs;
102 10
        $this->_server = $fs->getServer();
103 10
        $this->_db = $fs->getDatabase();
104 10
        $this->_user = $fs->getUser();
105 10
        $this->_logger = $logger;
106 10
        $this->_hook = $hook;
107 10
        $this->_storage = $storage;
108 10
        $this->_acl = $acl;
109
110 10
        foreach ($attributes as $attr => $value) {
111 10
            $this->{$attr} = $value;
112
        }
113
114 10
        $this->raw_attributes = $attributes;
115 10
    }
116
117
    /**
118
     * Read content and return ressource.
119
     *
120
     * @return resource
121
     */
122
    public function get()
123
    {
124
        if (null === $this->storage) {
125
            return null;
126
        }
127
128
        try {
129
            return $this->_storage->getFile($this, $this->storage);
130
        } catch (GridFSException\FileNotFoundException $e) {
0 ignored issues
show
Bug introduced by
The class MongoDB\GridFS\Exception\FileNotFoundException does not exist. Did you forget a USE statement, or did you not list all dependencies?

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

Loading history...
131
            throw new Exception\NotFound(
132
                'storage blob is gone',
133
                Exception\NotFound::CONTENTS_NOT_FOUND,
134
                $e
135
            );
136
        }
137
    }
138
139
    /**
140
     * Copy node.
141
     *
142
     * @param string $recursion
143
     */
144
    public function copyTo(Collection $parent, int $conflict = NodeInterface::CONFLICT_NOACTION, ?string $recursion = null, bool $recursion_first = true): NodeInterface
145
    {
146
        $this->_hook->run(
147
            'preCopyFile',
148
            [$this, $parent, &$conflict, &$recursion, &$recursion_first]
149
        );
150
151
        if (NodeInterface::CONFLICT_RENAME === $conflict && $parent->childExists($this->name)) {
152
            $name = $this->getDuplicateName();
153
        } else {
154
            $name = $this->name;
155
        }
156
157
        if (NodeInterface::CONFLICT_MERGE === $conflict && $parent->childExists($this->name)) {
158
            $result = $parent->getChild($this->name);
159
            $result->put($this->get());
160
        } else {
161
            $result = $parent->addFile($name, $this->get(), [
162
                'created' => $this->created,
163
                'changed' => $this->changed,
164
                'deleted' => $this->deleted,
165
                'meta' => $this->meta,
166
            ], NodeInterface::CONFLICT_NOACTION, true);
167
        }
168
169
        $this->_hook->run(
170
            'postCopyFile',
171
            [$this, $parent, $result, $conflict, $recursion, $recursion_first]
172
        );
173
174
        return $result;
175
    }
176
177
    /**
178
     * Get history.
179
     */
180
    public function getHistory(): array
181
    {
182
        return $this->history;
183
    }
184
185
    /**
186
     * Restore content to some older version.
187
     */
188
    public function restore(int $version): bool
189
    {
190
        if (!$this->_acl->isAllowed($this, 'w')) {
191
            throw new AclException\Forbidden(
192
                'not allowed to restore node '.$this->name,
193
                AclException\Forbidden::NOT_ALLOWED_TO_RESTORE
194
            );
195
        }
196
197
        $this->_hook->run('preRestoreFile', [$this, &$version]);
198
199
        if ($this->readonly) {
200
            throw new Exception\Conflict(
201
                'node is marked as readonly, it is not possible to change any content',
202
                Exception\Conflict::READONLY
203
            );
204
        }
205
206
        if ($this->version === $version) {
207
            throw new Exception('file is already version '.$version);
208
        }
209
210
        $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...
211
        $new = $this->increaseVersion();
212
213
        $v = array_search($version, array_column($this->history, 'version'), true);
214
        if (false === $v) {
215
            throw new Exception('failed restore file to version '.$version.', version was not found');
216
        }
217
218
        $file = $this->history[$v]['storage'];
219
220
        $this->history[] = [
221
            'version' => $new,
222
            'changed' => $this->changed,
223
            'user' => $this->owner,
224
            'type' => self::HISTORY_RESTORE,
225
            'hash' => $this->history[$v]['hash'],
226
            'origin' => $this->history[$v]['version'],
227
            'storage' => $this->history[$v]['storage'],
228
            'storage_adapter' => $this->history[$v]['storage_adapter'],
229
            'size' => $this->history[$v]['size'],
230
            'mime' => isset($this->history[$v]['mime']) ? $this->history[$v]['mime'] : null,
231
        ];
232
233
        try {
234
            $this->deleted = false;
235
            $this->version = $new;
236
            $this->storage = $this->history[$v]['storage'];
237
            $this->storage_adapter = $this->history[$v]['storage_adapter'];
238
239
            $this->hash = null === $file ? self::EMPTY_CONTENT : $this->history[$v]['hash'];
240
            $this->mime = isset($this->history[$v]['mime']) ? $this->history[$v]['mime'] : null;
241
            $this->size = $this->history[$v]['size'];
242
            $this->changed = $this->history[$v]['changed'];
243
244
            $this->save([
245
                'deleted',
246
                'version',
247
                'storage',
248
                'storage_adapter',
249
                'hash',
250
                'mime',
251
                'size',
252
                'history',
253
                'changed',
254
            ]);
255
256
            $this->_hook->run('postRestoreFile', [$this, &$version]);
257
258
            $this->_logger->info('restored file ['.$this->_id.'] to version ['.$version.']', [
259
                'category' => get_class($this),
260
            ]);
261
        } catch (\Exception $e) {
262
            $this->_logger->error('failed restore file ['.$this->_id.'] to version ['.$version.']', [
263
                'category' => get_class($this),
264
                'exception' => $e,
265
            ]);
266
267
            throw $e;
268
        }
269
270
        return true;
271
    }
272
273
    /**
274
     * Delete node.
275
     *
276
     * Actually the node will not be deleted (Just set a delete flag), set $force=true to
277
     * delete finally
278
     *
279
     * @param string $recursion
280
     */
281
    public function delete(bool $force = false, ?string $recursion = null, bool $recursion_first = true): bool
282
    {
283
        if (!$this->_acl->isAllowed($this, 'w')) {
284
            throw new AclException\Forbidden(
285
                'not allowed to delete node '.$this->name,
286
                AclException\Forbidden::NOT_ALLOWED_TO_DELETE
287
            );
288
        }
289
290
        $this->_hook->run('preDeleteFile', [$this, &$force, &$recursion, &$recursion_first]);
291
292
        if (true === $force || $this->isTemporaryFile()) {
293
            $result = $this->_forceDelete();
294
            $this->_hook->run('postDeleteFile', [$this, $force, $recursion, $recursion_first]);
295
296
            return $result;
297
        }
298
299
        $ts = new UTCDateTime();
300
        $this->deleted = $ts;
301
        $this->increaseVersion();
302
303
        $this->history[] = [
304
            'version' => $this->version,
305
            'changed' => $ts,
306
            'user' => ($this->_user === null) ? null : $this->_user->getId(),
307
            'type' => self::HISTORY_DELETE,
308
            'storage' => $this->storage,
309
            'storage_adapter' => $this->storage_adapter,
310
            'size' => $this->size,
311
            'hash' => $this->hash,
312
        ];
313
314
        $result = $this->save([
315
            'version',
316
            'deleted',
317
            'history',
318
        ], [], $recursion, $recursion_first);
319
320
        $this->_hook->run('postDeleteFile', [$this, $force, $recursion, $recursion_first]);
321
322
        return $result;
323
    }
324
325
    /**
326
     * Check if file is temporary.
327
     *
328
     **/
329 2
    public function isTemporaryFile(): bool
330
    {
331 2
        foreach ($this->temp_files as $pattern) {
332 2
            if (preg_match($pattern, $this->name)) {
333 2
                return true;
334
            }
335
        }
336
337 1
        return false;
338
    }
339
340
    /**
341
     * Delete version.
342
     */
343
    public function deleteVersion(int $version): bool
344
    {
345
        $key = array_search($version, array_column($this->history, 'version'), true);
346
347
        if (false === $key) {
348
            throw new Exception('version '.$version.' does not exists');
349
        }
350
351
        $blobs = array_column($this->history, 'storage');
352
353
        try {
354
            //do not remove blob if there are other versions linked against it
355
            if ($this->history[$key]['storage'] !== null && count(array_keys($blobs, $this->history[$key]['storage'])) === 1) {
356
                $this->_storage->deleteFile($this, $this->history[$key]['storage']);
357
            }
358
359
            array_splice($this->history, $key, 1);
360
361
            $this->_logger->debug('removed version ['.$version.'] from file ['.$this->_id.']', [
362
                'category' => get_class($this),
363
            ]);
364
365
            return $this->save('history');
366
        } catch (StorageException\BlobNotFound $e) {
367
            $this->_logger->error('failed remove version ['.$version.'] from file ['.$this->_id.']', [
368
                'category' => get_class($this),
369
                'exception' => $e,
370
            ]);
371
372
            return false;
373
        }
374
    }
375
376
    /**
377
     * Cleanup history.
378
     */
379
    public function cleanHistory(): bool
380
    {
381
        foreach ($this->history as $node) {
382
            $this->deleteVersion($node['version']);
383
        }
384
385
        return true;
386
    }
387
388
    /**
389
     * Get Attributes.
390
     */
391
    public function getAttributes(): array
392
    {
393
        return [
394
            '_id' => $this->_id,
395
            'name' => $this->name,
396
            'hash' => $this->hash,
397
            'directory' => false,
398
            'size' => $this->size,
399
            'version' => $this->version,
400
            'parent' => $this->parent,
401
            'acl' => $this->acl,
402
            'app' => $this->app,
403
            'meta' => $this->meta,
404
            'mime' => $this->mime,
405
            'owner' => $this->owner,
406
            'history' => $this->history,
407
            'shared' => $this->shared,
408
            'deleted' => $this->deleted,
409
            'changed' => $this->changed,
410
            'created' => $this->created,
411
            'destroy' => $this->destroy,
412
            'readonly' => $this->readonly,
413
            'storage_adapter' => $this->storage_adapter,
414
            'storage' => $this->storage,
415
        ];
416
    }
417
418
    /**
419
     * Get filename extension.
420
     */
421 2
    public function getExtension(): string
422
    {
423 2
        $ext = strrchr($this->name, '.');
424 2
        if (false === $ext) {
425 1
            throw new Exception('file does not have an extension');
426
        }
427
428 1
        return substr($ext, 1);
429
    }
430
431
    /**
432
     * Get file size.
433
     */
434 1
    public function getSize(): int
435
    {
436 1
        return $this->size;
437
    }
438
439
    /**
440
     * Get md5 sum of the file content,
441
     * actually the hash value comes from the database.
442
     */
443 1
    public function getETag(): string
444
    {
445 1
        return "'".$this->hash."'";
446
    }
447
448
    /**
449
     * Get hash.
450
     *
451
     * @return string
452
     */
453 1
    public function getHash(): ?string
454
    {
455 1
        return $this->hash;
456
    }
457
458
    /**
459
     * Get version.
460
     */
461 1
    public function getVersion(): int
462
    {
463 1
        return $this->version;
464
    }
465
466
    /**
467
     * Change content (Sabe dav compatible method).
468
     */
469
    public function put($content): int
470
    {
471
        $this->_logger->debug('write new file content into temporary storage for file ['.$this->_id.']', [
472
            'category' => get_class($this),
473
        ]);
474
475
        $session = $this->_storage->storeTemporaryFile($content, $this->_user);
476
477
        return $this->setContent($session);
478
    }
479
480
    /**
481
     * Set content (temporary file).
482
     */
483
    public function setContent(ObjectId $session, array $attributes = []): int
484
    {
485
        $this->_logger->debug('set temporary file ['.$session.'] as file content for ['.$this->_id.']', [
486
            'category' => get_class($this),
487
        ]);
488
489
        $this->prePutFile($session);
490
        $result = $this->_storage->storeFile($this, $session, $this->storage_adapter);
491
        $this->storage = $result['reference'];
492
        $this->hash = $result['hash'];
493
        $this->size = $result['size'];
494
495
        if ($this->size === 0) {
496
            $this->storage = null;
497
        } else {
498
            $this->storage = $result['reference'];
499
        }
500
501
        $this->mime = MimeType::getType($this->name);
502
        $this->increaseVersion();
503
504
        $this->addVersion($attributes)
505
             ->postPutFile();
506
507
        return $this->version;
508
    }
509
510
    /**
511
     * Completly remove file.
512
     */
513
    protected function _forceDelete(): bool
514
    {
515
        try {
516
            $this->cleanHistory();
517
            $this->_db->storage->deleteOne([
518
                '_id' => $this->_id,
519
            ]);
520
521
            $this->_logger->info('removed file node ['.$this->_id.']', [
522
                'category' => get_class($this),
523
            ]);
524
        } catch (\Exception $e) {
525
            $this->_logger->error('failed delete file node ['.$this->_id.']', [
526
                'category' => get_class($this),
527
                'exception' => $e,
528
            ]);
529
530
            throw $e;
531
        }
532
533
        return true;
534
    }
535
536
    /**
537
     * Increase version.
538
     */
539
    protected function increaseVersion(): int
540
    {
541
        $max = $this->_fs->getServer()->getMaxFileVersion();
542
        if (count($this->history) >= $max) {
543
            $del = key($this->history);
544
            $this->_logger->debug('history limit ['.$max.'] reached, remove oldest version ['.$del.'] from file ['.$this->_id.']', [
545
                'category' => get_class($this),
546
            ]);
547
548
            $this->deleteVersion($this->history[$del]['version']);
549
        }
550
551
        ++$this->version;
552
553
        return $this->version;
554
    }
555
556
    /**
557
     * Pre content change checks.
558
     */
559
    protected function prePutFile(ObjectId $session): bool
560
    {
561
        if (!$this->_acl->isAllowed($this, 'w')) {
562
            throw new AclException\Forbidden(
563
                'not allowed to modify node',
564
                AclException\Forbidden::NOT_ALLOWED_TO_MODIFY
565
            );
566
        }
567
568
        $this->_hook->run('prePutFile', [$this, &$session]);
569
570
        if ($this->readonly) {
571
            throw new Exception\Conflict(
572
                'node is marked as readonly, it is not possible to change any content',
573
                Exception\Conflict::READONLY
574
            );
575
        }
576
577
        return true;
578
    }
579
580
    /**
581
     * Add new version.
582
     */
583
    protected function addVersion(array $attributes = []): self
584
    {
585
        if (1 !== $this->version) {
586
            if (isset($attributes['changed'])) {
587
                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...
588
                    throw new Exception\InvalidArgument('attribute changed must be an instance of UTCDateTime');
589
                }
590
591
                $this->changed = $attributes['changed'];
592
            } else {
593
                $this->changed = new UTCDateTime();
594
            }
595
596
            $this->_logger->debug('added new history version ['.$this->version.'] for file ['.$this->_id.']', [
597
                'category' => get_class($this),
598
            ]);
599
600
            $this->history[] = [
601
                'version' => $this->version,
602
                'changed' => $this->changed,
603
                'user' => $this->_user->getId(),
604
                'type' => self::HISTORY_EDIT,
605
                'storage' => $this->storage,
606
                'storage_adapter' => $this->storage_adapter,
607
                'size' => $this->size,
608
                'mime' => $this->mime,
609
                'hash' => $this->hash,
610
            ];
611
612
            return $this;
613
        }
614
615
        $this->_logger->debug('added first file version [1] for file ['.$this->_id.']', [
616
            'category' => get_class($this),
617
        ]);
618
619
        $this->history[0] = [
620
            'version' => 1,
621
            'changed' => isset($attributes['changed']) ? $attributes['changed'] : new UTCDateTime(),
622
            'user' => $this->owner,
623
            'type' => self::HISTORY_CREATE,
624
            'storage' => $this->storage,
625
            'storage_adapter' => $this->storage_adapter,
626
            'size' => $this->size,
627
            'mime' => $this->mime,
628
            'hash' => $this->hash,
629
        ];
630
631
        return $this;
632
    }
633
634
    /**
635
     * Finalize put request.
636
     */
637
    protected function postPutFile(): self
638
    {
639
        try {
640
            $this->save([
641
                'size',
642
                'changed',
643
                'mime',
644
                'hash',
645
                'version',
646
                'history',
647
                'storage',
648
                'storage_adapter',
649
            ]);
650
651
            $this->_logger->debug('modifed file metadata ['.$this->_id.']', [
652
                'category' => get_class($this),
653
            ]);
654
655
            $this->_hook->run('postPutFile', [$this]);
656
657
            return $this;
658
        } catch (\Exception $e) {
659
            $this->_logger->error('failed modify file metadata ['.$this->_id.']', [
660
                'category' => get_class($this),
661
                'exception' => $e,
662
            ]);
663
664
            throw $e;
665
        }
666
    }
667
}
668