Completed
Push — master ( 6ac504...a958ab )
by Raffael
10:46 queued 06:24
created

File::copyTo()   A

Complexity

Conditions 5
Paths 4

Size

Total Lines 33

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 30

Importance

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