Completed
Push — master ( 287393...7ff50d )
by Raffael
18:27 queued 14:12
created

src/lib/Filesystem/Node/AbstractNode.php (3 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

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\Forbidden as ForbiddenException;
17
use Balloon\Filesystem\Exception;
18
use Balloon\Hook;
19
use Balloon\Server;
20
use Balloon\Server\User;
21
use MimeType\MimeType;
22
use MongoDB\BSON\ObjectId;
23
use MongoDB\BSON\UTCDateTime;
24
use MongoDB\Database;
25
use Normalizer;
26
use Psr\Log\LoggerInterface;
27
use ZipStream\ZipStream;
28
29
abstract class AbstractNode implements NodeInterface
30
{
31
    /**
32
     * name max lenght.
33
     */
34
    const MAX_NAME_LENGTH = 255;
35
36
    /**
37
     * Unique id.
38
     *
39
     * @var ObjectId
40
     */
41
    protected $_id;
42
43
    /**
44
     * Node name.
45
     *
46
     * @var string
47
     */
48
    protected $name = '';
49
50
    /**
51
     * Owner.
52
     *
53
     * @var ObjectId
54
     */
55
    protected $owner;
56
57
    /**
58
     * Mime.
59
     *
60
     * @var string
61
     */
62
    protected $mime;
63
64
    /**
65
     * Meta attributes.
66
     *
67
     * @var array
68
     */
69
    protected $meta = [];
70
71
    /**
72
     * Parent collection.
73
     *
74
     * @var ObjectId
75
     */
76
    protected $parent;
77
78
    /**
79
     * Is file deleted.
80
     *
81
     * @var bool|UTCDateTime
82
     */
83
    protected $deleted = false;
84
85
    /**
86
     * Is shared?
87
     *
88
     * @var bool
89
     */
90
    protected $shared = false;
91
92
    /**
93
     * Destory at a certain time.
94
     *
95
     * @var UTCDateTime
96
     */
97
    protected $destroy;
98
99
    /**
100
     * Changed timestamp.
101
     *
102
     * @var UTCDateTime
103
     */
104
    protected $changed;
105
106
    /**
107
     * Created timestamp.
108
     *
109
     * @var UTCDateTime
110
     */
111
    protected $created;
112
113
    /**
114
     * Point to antother node (Means this node is reference to $reference).
115
     *
116
     * @var ObjectId
117
     */
118
    protected $reference;
119
120
    /**
121
     * Raw attributes before any processing or modifications.
122
     *
123
     * @var array
124
     */
125
    protected $raw_attributes;
126
127
    /**
128
     * Readonly flag.
129
     *
130
     * @var bool
131
     */
132
    protected $readonly = false;
133
134
    /**
135
     * App attributes.
136
     *
137
     * @var array
138
     */
139
    protected $app = [];
140
141
    /**
142
     * Filesystem.
143
     *
144
     * @var Filesystem
145
     */
146
    protected $_fs;
147
148
    /**
149
     * Database.
150
     *
151
     * @var Database
152
     */
153
    protected $_db;
154
155
    /**
156
     * User.
157
     *
158
     * @var User
159
     */
160
    protected $_user;
161
162
    /**
163
     * Logger.
164
     *
165
     * @var LoggerInterface
166
     */
167
    protected $_logger;
168
169
    /**
170
     * Server.
171
     *
172
     * @var Server
173
     */
174
    protected $_server;
175
176
    /**
177
     * Hook.
178
     *
179
     * @var Hook
180
     */
181
    protected $_hook;
182
183
    /**
184
     * Acl.
185
     *
186
     * @var Acl
187
     */
188
    protected $_acl;
189
190
    /**
191
     * Mount.
192
     *
193
     * @var ObjectId
194
     */
195
    protected $storage_reference;
196
197
    /**
198
     * Storage attributes.
199
     *
200
     * @var array
201
     */
202
    protected $storage;
203
204
    /**
205
     * Acl.
206
     *
207
     * @var array
208
     */
209
    protected $acl = [];
210
211
    /**
212
     * Mount.
213
     *
214
     * @var array
215
     */
216
    protected $mount = [];
217
218
    /**
219
     * Parent collection.
220
     *
221
     * @var Collection
222
     */
223
    protected $_parent;
224
225
    /**
226
     * Convert to filename.
227
     *
228
     * @return string
229
     */
230
    public function __toString()
231
    {
232
        return $this->name;
233
    }
234
235
    /**
236
     * Get owner.
237
     */
238
    public function getOwner(): ObjectId
239
    {
240
        return $this->owner;
241
    }
242
243
    /**
244
     * Set filesystem.
245
     */
246
    public function setFilesystem(Filesystem $fs): NodeInterface
247
    {
248
        $this->_fs = $fs;
249
        $this->_user = $fs->getUser();
250
251
        return $this;
252
    }
253
254
    /**
255
     * Get filesystem.
256
     */
257
    public function getFilesystem(): Filesystem
258
    {
259
        return $this->_fs;
260
    }
261
262
    /**
263
     * Check if $node is a sub node of any parent nodes of this node.
264
     */
265
    public function isSubNode(NodeInterface $node): bool
266
    {
267
        if ($node->getId() == $this->_id) {
268
            return true;
269
        }
270
271
        foreach ($node->getParents() as $node) {
272
            if ($node->getId() == $this->_id) {
273
                return true;
274
            }
275
        }
276
277
        if ($this->isRoot()) {
278
            return true;
279
        }
280
281
        return false;
282
    }
283
284
    /**
285
     * Move node.
286
     */
287
    public function setParent(Collection $parent, int $conflict = NodeInterface::CONFLICT_NOACTION): NodeInterface
288
    {
289
        if ($this->parent === $parent->getId()) {
290
            throw new Exception\Conflict(
291
                'source node '.$this->name.' is already in the requested parent folder',
292
                Exception\Conflict::ALREADY_THERE
293
            );
294
        }
295
        if ($this->isSubNode($parent)) {
296
            throw new Exception\Conflict(
297
                'node called '.$this->name.' can not be moved into itself',
298
                Exception\Conflict::CANT_BE_CHILD_OF_ITSELF
299
            );
300
        }
301
        if (!$this->_acl->isAllowed($this, 'w') && !$this->isReference()) {
302
            throw new ForbiddenException(
303
                'not allowed to move node '.$this->name,
304
                ForbiddenException::NOT_ALLOWED_TO_MOVE
305
            );
306
        }
307
308
        $exists = $parent->childExists($this->name);
309
        if (true === $exists && NodeInterface::CONFLICT_NOACTION === $conflict) {
310
            throw new Exception\Conflict(
311
                'a node called '.$this->name.' does already exists in this collection',
312
                Exception\Conflict::NODE_WITH_SAME_NAME_ALREADY_EXISTS
313
            );
314
        }
315
        if ($this->isShared() && $this instanceof Collection && $parent->isShared()) {
316
            throw new Exception\Conflict(
317
                'a shared folder can not be a child of a shared folder too',
318
                Exception\Conflict::SHARED_NODE_CANT_BE_CHILD_OF_SHARE
319
            );
320
        }
321
        if ($parent->isDeleted()) {
322
            throw new Exception\Conflict(
323
                'cannot move node into a deleted collction',
324
                Exception\Conflict::DELETED_PARENT
325
            );
326
        }
327
328
        if (true === $exists && NodeInterface::CONFLICT_RENAME === $conflict) {
329
            $this->setName($this->getDuplicateName());
330
            $this->raw_attributes['name'] = $this->name;
331
        }
332
333
        if ($this instanceof Collection) {
334
            $this->getChildrenRecursive($this->getRealId(), $shares);
335
336
            if (!empty($shares) && $parent->isShared()) {
337
                throw new Exception\Conflict(
338
                    'folder contains a shared folder',
339
                    Exception\Conflict::NODE_CONTAINS_SHARED_NODE
340
                );
341
            }
342
        }
343
344
        if (($parent->isSpecial() && $this->shared != $parent->getShareId())
345
          || (!$parent->isSpecial() && $this->isShareMember())
346
          || ($parent->getMount() != $this->getParent()->getMount())) {
347
            $new = $this->copyTo($parent, $conflict);
348
            $this->delete();
349
350
            return $new;
351
        }
352
353
        if (true === $exists && NodeInterface::CONFLICT_MERGE === $conflict) {
354
            $new = $this->copyTo($parent, $conflict);
355
            $this->delete(true);
356
357
            return $new;
358
        }
359
360
        $this->storage = $this->_parent->getStorage()->move($this, $parent);
361
        $this->parent = $parent->getRealId();
362
        $this->owner = $this->_user->getId();
363
364
        $this->save(['parent', 'shared', 'owner', 'storage']);
365
366
        return $this;
367
    }
368
369
    /**
370
     * Set node acl.
371
     */
372
    public function setAcl(array $acl): NodeInterface
373
    {
374
        if (!$this->_acl->isAllowed($this, 'm')) {
375
            throw new ForbiddenException(
376
                'not allowed to update acl',
377
                 ForbiddenException::NOT_ALLOWED_TO_MANAGE
378
            );
379
        }
380
381
        if (!$this->isShareMember()) {
382
            throw new Exception\Conflict('node acl may only be set on share member nodes', Exception\Conflict::NOT_SHARED);
383
        }
384
385
        $this->_acl->validateAcl($this->_server, $acl);
386
        $this->acl = $acl;
387
        $this->save(['acl']);
388
389
        return $this;
390
    }
391
392
    /**
393
     * Get ACL.
394
     */
395
    public function getAcl(): array
396
    {
397
        if ($this->isReference()) {
398
            $acl = $this->_fs->findRawNode($this->getShareId())['acl'];
0 ignored issues
show
It seems like $this->getShareId() can be null; however, findRawNode() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
399
        } else {
400
            $acl = $this->acl;
401
        }
402
403
        return $this->_acl->resolveAclTable($this->_server, $acl);
404
    }
405
406
    /**
407
     * Get share id.
408
     */
409
    public function getShareId(bool $reference = false): ?ObjectId
410
    {
411
        if ($this->isReference() && true === $reference) {
412
            return $this->_id;
413
        }
414
        if ($this->isShareMember() && true === $reference) {
415
            return $this->shared;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return $this->shared; (boolean) is incompatible with the return type declared by the interface Balloon\Filesystem\Node\NodeInterface::getShareId of type MongoDB\BSON\ObjectId.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
416
        }
417
        if ($this->isShared() && $this->isReference()) {
418
            return $this->reference;
419
        }
420
        if ($this->isShared()) {
421
            return $this->_id;
422
        }
423
        if ($this->isShareMember()) {
424
            return $this->shared;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return $this->shared; (boolean) is incompatible with the return type declared by the interface Balloon\Filesystem\Node\NodeInterface::getShareId of type MongoDB\BSON\ObjectId.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
425
        }
426
427
        return null;
428
    }
429
430
    /**
431
     * Get share node.
432
     */
433
    public function getShareNode(): ?Collection
434
    {
435
        if ($this->isShare()) {
436
            return $this;
437
        }
438
439
        if ($this->isSpecial()) {
440
            return $this->_fs->findNodeById($this->getShareId(true));
441
        }
442
443
        return null;
444
    }
445
446
    /**
447
     * Is node marked as readonly?
448
     */
449
    public function isReadonly(): bool
450
    {
451
        return $this->readonly;
452
    }
453
454
    /**
455
     * Request is from node owner?
456
     */
457
    public function isOwnerRequest(): bool
458
    {
459
        return null !== $this->_user && $this->owner == $this->_user->getId();
460
    }
461
462
    /**
463
     * Check if node is kind of special.
464
     */
465
    public function isSpecial(): bool
466
    {
467
        if ($this->isShared()) {
468
            return true;
469
        }
470
        if ($this->isReference()) {
471
            return true;
472
        }
473
        if ($this->isShareMember()) {
474
            return true;
475
        }
476
477
        return false;
478
    }
479
480
    /**
481
     * Check if node is a sub node of a share.
482
     */
483
    public function isShareMember(): bool
484
    {
485
        return $this->shared instanceof ObjectId && !$this->isReference();
486
    }
487
488
    /**
489
     * Check if node is a sub node of an external storage mount.
490
     */
491
    public function isMountMember(): bool
492
    {
493
        return $this->storage_reference instanceof ObjectId;
494
    }
495
496
    /**
497
     * Is share.
498
     */
499
    public function isShare(): bool
500
    {
501
        return true === $this->shared && !$this->isReference();
502
    }
503
504
    /**
505
     * Is share (Reference or master share).
506
     */
507
    public function isShared(): bool
508
    {
509
        if (true === $this->shared) {
510
            return true;
511
        }
512
513
        return false;
514
    }
515
516
    /**
517
     * Set the name.
518
     */
519
    public function setName($name): bool
520
    {
521
        $name = $this->checkName($name);
522
523
        try {
524
            $child = $this->getParent()->getChild($name);
525
            if ($child->getId() != $this->_id) {
526
                throw new Exception\Conflict(
527
                    'a node called '.$name.' does already exists in this collection',
528
                    Exception\Conflict::NODE_WITH_SAME_NAME_ALREADY_EXISTS
529
                );
530
            }
531
        } catch (Exception\NotFound $e) {
532
            //child does not exists, we can safely rename
533
        }
534
535
        $this->storage = $this->_parent->getStorage()->rename($this, $name);
536
        $this->name = $name;
537
538
        if ($this instanceof File) {
539
            $this->mime = MimeType::getType($this->name);
540
        }
541
542
        return $this->save(['name', 'storage', 'mime']);
543
    }
544
545
    /**
546
     * Check name.
547
     */
548
    public function checkName(string $name): string
549
    {
550
        if (preg_match('/([\\\<\>\:\"\/\|\*\?])|(^$)|(^\.$)|(^\..$)/', $name)) {
551
            throw new Exception\InvalidArgument('name contains invalid characters');
552
        }
553
        if (strlen($name) > self::MAX_NAME_LENGTH) {
554
            throw new Exception\InvalidArgument('name is longer than '.self::MAX_NAME_LENGTH.' characters');
555
        }
556
557
        if (!Normalizer::isNormalized($name)) {
558
            $name = Normalizer::normalize($name);
559
        }
560
561
        return $name;
562
    }
563
564
    /**
565
     * Get the name.
566
     */
567
    public function getName(): string
568
    {
569
        return $this->name;
570
    }
571
572
    /**
573
     * Get mount node.
574
     */
575
    public function getMount(): ?ObjectId
576
    {
577
        return count($this->mount) > 0 ? $this->_id : $this->storage_reference;
578
    }
579
580
    /**
581
     * Undelete.
582
     */
583
    public function undelete(int $conflict = NodeInterface::CONFLICT_NOACTION, ?string $recursion = null, bool $recursion_first = true): bool
584
    {
585
        if (!$this->_acl->isAllowed($this, 'w') && !$this->isReference()) {
586
            throw new ForbiddenException(
587
                'not allowed to restore node '.$this->name,
588
                ForbiddenException::NOT_ALLOWED_TO_UNDELETE
589
            );
590
        }
591
        if (!$this->isDeleted()) {
592
            throw new Exception\Conflict(
593
                'node is not deleted, skip restore',
594
                Exception\Conflict::NOT_DELETED
595
            );
596
        }
597
598
        $parent = $this->getParent();
599
        if ($parent->isDeleted()) {
600
            throw new Exception\Conflict(
601
                'could not restore node '.$this->name.' into a deleted parent',
602
                Exception\Conflict::DELETED_PARENT
603
            );
604
        }
605
606
        if ($parent->childExists($this->name)) {
607
            if (NodeInterface::CONFLICT_MERGE === $conflict) {
608
                $this->copyTo($parent, $conflict);
609
                $this->delete(true);
610
            } elseif (NodeInterface::CONFLICT_RENAME === $conflict) {
611
                $this->setName($this->getDuplicateName());
612
                $this->raw_attributes['name'] = $this->name;
613
            } else {
614
                throw new Exception\Conflict(
615
                    'a node called '.$this->name.' does already exists in this collection',
616
                    Exception\Conflict::NODE_WITH_SAME_NAME_ALREADY_EXISTS
617
                );
618
            }
619
        }
620
621
        if (null === $recursion) {
622
            $recursion_first = true;
623
            $recursion = uniqid();
624
        } else {
625
            $recursion_first = false;
626
        }
627
628
        $this->storage = $this->_parent->getStorage()->undelete($this);
629
        $this->deleted = false;
630
631
        $this->save([
632
                'storage',
633
                'name',
634
                'deleted',
635
            ], [], $recursion, $recursion_first);
636
637
        if ($this instanceof File || $this->isReference() || $this->isMounted() || $this->isFiltered()) {
638
            return true;
639
        }
640
641
        return $this->doRecursiveAction(function ($node) use ($conflict, $recursion) {
642
            $node->undelete($conflict, $recursion, false);
643
        }, NodeInterface::DELETED_ONLY);
644
    }
645
646
    /**
647
     * Is node deleted?
648
     */
649
    public function isDeleted(): bool
650
    {
651
        return $this->deleted instanceof UTCDateTime;
652
    }
653
654
    /**
655
     * Get last modified timestamp.
656
     */
657
    public function getLastModified(): int
658
    {
659
        if ($this->changed instanceof UTCDateTime) {
660
            return (int) $this->changed->toDateTime()->format('U');
661
        }
662
663
        return 0;
664
    }
665
666
    /**
667
     * Get unique id.
668
     *
669
     * @return ObjectId
670
     */
671 1
    public function getId(): ?ObjectId
672
    {
673 1
        return $this->_id;
674
    }
675
676
    /**
677
     * Get parent.
678
     */
679
    public function getParent(): ?Collection
680
    {
681
        return $this->_parent;
682
    }
683
684
    /**
685
     * Get parents.
686
     */
687
    public function getParents(?NodeInterface $node = null, array $parents = []): array
688
    {
689
        if (null === $node) {
690
            $node = $this;
691
        }
692
693
        if ($node->isInRoot()) {
694
            return $parents;
695
        }
696
        $parent = $node->getParent();
697
        $parents[] = $parent;
698
699
        return $node->getParents($parent, $parents);
700
    }
701
702
    /**
703
     * Get as zip.
704
     */
705
    public function getZip(): void
706
    {
707
        $archive = new ZipStream($this->name.'.zip');
708
        $this->zip($archive, false);
709
        $archive->finish();
710
    }
711
712
    /**
713
     * Create zip.
714
     */
715
    public function zip(ZipStream $archive, bool $self = true, ?NodeInterface $parent = null, string $path = '', int $depth = 0): bool
716
    {
717
        if (null === $parent) {
718
            $parent = $this;
719
        }
720
721
        if ($parent instanceof Collection) {
722
            $children = $parent->getChildNodes();
723
724
            if (true === $self && 0 === $depth) {
725
                $path = $parent->getName().DIRECTORY_SEPARATOR;
726
            } elseif (0 === $depth) {
727
                $path = '';
728
            } elseif (0 !== $depth) {
729
                $path .= DIRECTORY_SEPARATOR.$parent->getName().DIRECTORY_SEPARATOR;
730
            }
731
732
            foreach ($children as $child) {
733
                $name = $path.$child->getName();
734
735
                if ($child instanceof Collection) {
736
                    $this->zip($archive, $self, $child, $name, ++$depth);
737
                } elseif ($child instanceof File) {
738
                    try {
739
                        $resource = $child->get();
740
                        if ($resource !== null) {
741
                            $archive->addFileFromStream($name, $resource);
742
                        }
743
                    } catch (\Exception $e) {
744
                        $this->_logger->error('failed add file ['.$child->getId().'] to zip stream', [
745
                            'category' => get_class($this),
746
                            'exception' => $e,
747
                        ]);
748
                    }
749
                }
750
            }
751
        } elseif ($parent instanceof File) {
752
            $resource = $parent->get();
753
            if ($resource !== null) {
754
                $archive->addFileFromStream($parent->getName(), $resource);
755
            }
756
        }
757
758
        return true;
759
    }
760
761
    /**
762
     * Get mime type.
763
     */
764 1
    public function getContentType(): string
765
    {
766 1
        return $this->mime;
767
    }
768
769
    /**
770
     * Is reference.
771
     */
772
    public function isReference(): bool
773
    {
774
        return $this->reference instanceof ObjectId;
775
    }
776
777
    /**
778
     * Set app attributes.
779
     */
780
    public function setAppAttributes(string $namespace, array $attributes): NodeInterface
781
    {
782
        $this->app[$namespace] = $attributes;
783
        $this->save('app.'.$namespace);
784
785
        return $this;
786
    }
787
788
    /**
789
     * Set app attribute.
790
     */
791
    public function setAppAttribute(string $namespace, string $attribute, $value): NodeInterface
792
    {
793
        if (!isset($this->app[$namespace])) {
794
            $this->app[$namespace] = [];
795
        }
796
797
        $this->app[$namespace][$attribute] = $value;
798
        $this->save('app.'.$namespace);
799
800
        return $this;
801
    }
802
803
    /**
804
     * Remove app attribute.
805
     */
806
    public function unsetAppAttributes(string $namespace): NodeInterface
807
    {
808
        if (isset($this->app[$namespace])) {
809
            unset($this->app[$namespace]);
810
            $this->save('app.'.$namespace);
811
        }
812
813
        return $this;
814
    }
815
816
    /**
817
     * Remove app attribute.
818
     */
819
    public function unsetAppAttribute(string $namespace, string $attribute): NodeInterface
820
    {
821
        if (isset($this->app[$namespace][$attribute])) {
822
            unset($this->app[$namespace][$attribute]);
823
            $this->save('app'.$namespace);
824
        }
825
826
        return $this;
827
    }
828
829
    /**
830
     * Get app attribute.
831
     */
832
    public function getAppAttribute(string $namespace, string $attribute)
833
    {
834
        if (isset($this->app[$namespace][$attribute])) {
835
            return $this->app[$namespace][$attribute];
836
        }
837
838
        return null;
839
    }
840
841
    /**
842
     * Get app attributes.
843
     */
844
    public function getAppAttributes(string $namespace): array
845
    {
846
        if (isset($this->app[$namespace])) {
847
            return $this->app[$namespace];
848
        }
849
850
        return [];
851
    }
852
853
    /**
854
     * Set meta attributes.
855
     */
856
    public function setMetaAttributes(array $attributes): NodeInterface
857
    {
858
        $attributes = $this->validateMetaAttributes($attributes);
859
        foreach ($attributes as $attribute => $value) {
860
            if (empty($value) && isset($this->meta[$attribute])) {
861
                unset($this->meta[$attribute]);
862
            } elseif (!empty($value)) {
863
                $this->meta[$attribute] = $value;
864
            }
865
        }
866
867
        $this->save('meta');
868
869
        return $this;
870
    }
871
872
    /**
873
     * Get meta attributes as array.
874
     */
875
    public function getMetaAttributes(array $attributes = []): array
876
    {
877
        if (empty($attributes)) {
878
            return $this->meta;
879
        }
880
        if (is_array($attributes)) {
881
            return array_intersect_key($this->meta, array_flip($attributes));
882
        }
883
    }
884
885
    /**
886
     * Mark node as readonly.
887
     */
888
    public function setReadonly(bool $readonly = true): bool
889
    {
890
        $this->readonly = $readonly;
891
        $this->storage = $this->_parent->getStorage()->readonly($this, $readonly);
892
893
        return $this->save(['readonly', 'storage']);
894
    }
895
896
    /**
897
     * Mark node as self-destroyable.
898
     */
899
    public function setDestroyable(?UTCDateTime $ts): bool
900
    {
901
        $this->destroy = $ts;
902
903
        if (null === $ts) {
904
            return $this->save([], 'destroy');
905
        }
906
907
        return $this->save('destroy');
908
    }
909
910
    /**
911
     * Get original raw attributes before any processing.
912
     */
913
    public function getRawAttributes(): array
914
    {
915
        return $this->raw_attributes;
916
    }
917
918
    /**
919
     * Check if node is in root.
920
     */
921
    public function isInRoot(): bool
922
    {
923
        return null === $this->parent;
924
    }
925
926
    /**
927
     * Check if node is an instance of the actual root collection.
928
     */
929
    public function isRoot(): bool
930
    {
931
        return null === $this->_id && ($this instanceof Collection);
932
    }
933
934
    /**
935
     * Resolve node path.
936
     */
937
    public function getPath(): string
938
    {
939
        $path = '';
940
        foreach (array_reverse($this->getParents()) as $parent) {
941
            $path .= DIRECTORY_SEPARATOR.$parent->getName();
942
        }
943
944
        $path .= DIRECTORY_SEPARATOR.$this->getName();
945
946
        return $path;
947
    }
948
949
    /**
950
     * Save node attributes.
951
     *
952
     * @param array|string $attributes
953
     * @param array|string $remove
954
     * @param string       $recursion
955
     */
956
    public function save($attributes = [], $remove = [], ?string $recursion = null, bool $recursion_first = true): bool
957
    {
958
        if (!$this->_acl->isAllowed($this, 'w') && !$this->isReference()) {
959
            throw new ForbiddenException(
960
                'not allowed to modify node '.$this->name,
961
                ForbiddenException::NOT_ALLOWED_TO_MODIFY
962
            );
963
        }
964
965
        if ($this instanceof Collection && $this->isRoot()) {
966
            return false;
967
        }
968
969
        $remove = (array) $remove;
970
        $attributes = (array) $attributes;
971
        $this->_hook->run(
972
            'preSaveNodeAttributes',
973
            [$this, &$attributes, &$remove, &$recursion, &$recursion_first]
974
        );
975
976
        try {
977
            $set = [];
978
            $values = $this->getAttributes();
979
            foreach ($attributes as $attr) {
980
                $set[$attr] = $this->getArrayValue($values, $attr);
981
            }
982
983
            $update = [];
984
            if (!empty($set)) {
985
                $update['$set'] = $set;
986
            }
987
988
            if (!empty($remove)) {
989
                $remove = array_fill_keys($remove, 1);
990
                $update['$unset'] = $remove;
991
            }
992
993
            if (empty($update)) {
994
                return false;
995
            }
996
            $result = $this->_db->storage->updateOne([
997
                    '_id' => $this->_id,
998
                ], $update);
999
1000
            $this->_hook->run(
1001
                'postSaveNodeAttributes',
1002
                [$this, $attributes, $remove, $recursion, $recursion_first]
1003
            );
1004
1005
            $this->_logger->info('modified node attributes of ['.$this->_id.']', [
1006
                'category' => get_class($this),
1007
                'params' => $update,
1008
            ]);
1009
1010
            return true;
1011
        } catch (\Exception $e) {
1012
            $this->_logger->error('failed modify node attributes of ['.$this->_id.']', [
1013
                'category' => get_class($this),
1014
                'exception' => $e,
1015
            ]);
1016
1017
            throw $e;
1018
        }
1019
    }
1020
1021
    /**
1022
     * Get array value via string path.
1023
     */
1024
    protected function getArrayValue(Iterable $array, string $path, string $separator = '.')
1025
    {
1026
        if (isset($array[$path])) {
1027
            return $array[$path];
1028
        }
1029
        $keys = explode($separator, $path);
1030
1031
        foreach ($keys as $key) {
1032
            if (!array_key_exists($key, $array)) {
1033
                return;
1034
            }
1035
1036
            $array = $array[$key];
1037
        }
1038
1039
        return $array;
1040
    }
1041
1042
    /**
1043
     * Validate meta attributes.
1044
     */
1045
    protected function validateMetaAttributes(array $attributes): array
1046
    {
1047
        foreach ($attributes as $attribute => $value) {
1048
            $const = __CLASS__.'::META_'.strtoupper($attribute);
1049
            if (!defined($const)) {
1050
                throw new Exception('meta attribute '.$attribute.' is not valid');
1051
            }
1052
1053
            if ($attribute === NodeInterface::META_TAGS && !empty($value) && (!is_array($value) || array_filter($value, 'is_string') != $value)) {
1054
                throw new Exception('tags meta attribute must be an array of strings');
1055
            }
1056
1057
            if ($attribute !== NodeInterface::META_TAGS && !is_string($value)) {
1058
                throw new Exception($attribute.' meta attribute must be a string');
1059
            }
1060
        }
1061
1062
        return $attributes;
1063
    }
1064
1065
    /**
1066
     * Duplicate name with a uniqid within name.
1067
     */
1068
    protected function getDuplicateName(?string $name = null, ?string $class = null): string
1069
    {
1070
        if (null === $name) {
1071
            $name = $this->name;
1072
        }
1073
1074
        if (null === $class) {
1075
            $class = get_class($this);
1076
        }
1077
1078
        if ($class === Collection::class) {
1079
            return $name.' ('.substr(uniqid('', true), -4).')';
1080
        }
1081
1082
        $ext = substr(strrchr($name, '.'), 1);
1083
        if (false === $ext) {
1084
            return $name.' ('.substr(uniqid('', true), -4).')';
1085
        }
1086
1087
        $name = substr($name, 0, -(strlen($ext) + 1));
1088
1089
        return $name.' ('.substr(uniqid('', true), -4).')'.'.'.$ext;
1090
    }
1091
1092
    /**
1093
     * Completly remove node.
1094
     */
1095
    abstract protected function _forceDelete(): bool;
1096
}
1097