Completed
Push — master ( a5c053...b57f56 )
by Raffael
15:30 queued 11:35
created

File::cleanHistory()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

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