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