Test Failed
Pull Request — master (#274)
by Raffael
14:22
created

File::increaseVersion()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

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