Passed
Pull Request — master (#1279)
by Dante
01:50
created

ModulesComponent::skipSave()   A

Complexity

Conditions 5
Paths 4

Size

Total Lines 26
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 18
nc 4
nop 3
dl 0
loc 26
rs 9.3554
c 0
b 0
f 0
1
<?php
2
/**
3
 * BEdita, API-first content management framework
4
 * Copyright 2022 ChannelWeb Srl, Chialab Srl
5
 *
6
 * This file is part of BEdita: you can redistribute it and/or modify
7
 * it under the terms of the GNU Lesser General Public License as published
8
 * by the Free Software Foundation, either version 3 of the License, or
9
 * (at your option) any later version.
10
 *
11
 * See LICENSE.LGPL or <http://gnu.org/licenses/lgpl-3.0.html> for more details.
12
 */
13
namespace App\Controller\Component;
14
15
use App\Core\Exception\UploadException;
16
use App\Utility\OEmbed;
17
use App\Utility\SchemaTrait;
18
use BEdita\WebTools\ApiClientProvider;
19
use Cake\Cache\Cache;
20
use Cake\Controller\Component;
21
use Cake\Core\Configure;
22
use Cake\Event\Event;
23
use Cake\Event\EventInterface;
24
use Cake\Http\Client\Response;
25
use Cake\Http\Exception\BadRequestException;
26
use Cake\Http\Exception\InternalErrorException;
27
use Cake\I18n\I18n;
28
use Cake\Utility\Hash;
29
30
/**
31
 * Component to load available modules.
32
 *
33
 * @property \Authentication\Controller\Component\AuthenticationComponent $Authentication
34
 * @property \App\Controller\Component\ChildrenComponent $Children
35
 * @property \App\Controller\Component\ConfigComponent $Config
36
 * @property \App\Controller\Component\ParentsComponent $Parents
37
 * @property \App\Controller\Component\SchemaComponent $Schema
38
 */
39
class ModulesComponent extends Component
40
{
41
    use SchemaTrait;
42
43
    /**
44
     * Fixed relationships to be loaded for each object
45
     *
46
     * @var array
47
     */
48
    public const FIXED_RELATIONSHIPS = [
49
        'parent',
50
        'children',
51
        'parents',
52
        'translations',
53
        'streams',
54
        'roles',
55
    ];
56
57
    /**
58
     * @inheritDoc
59
     */
60
    public $components = ['Authentication', 'Children', 'Config', 'Parents', 'Schema'];
61
62
    /**
63
     * @inheritDoc
64
     */
65
    protected $_defaultConfig = [
66
        'currentModuleName' => null,
67
        'clearHomeCache' => false,
68
    ];
69
70
    /**
71
     * Project modules for a user from `/home` endpoint
72
     *
73
     * @var array
74
     */
75
    protected $modules = [];
76
77
    /**
78
     * Other "logic" modules, non objects
79
     *
80
     * @var array
81
     */
82
    protected $otherModules = [
83
        'tags' => [
84
            'name' => 'tags',
85
            'hints' => ['allow' => ['GET', 'POST', 'PATCH', 'DELETE']],
86
        ],
87
    ];
88
89
    /**
90
     * @inheritDoc
91
     */
92
    public function beforeFilter(EventInterface $event): ?Response
93
    {
94
        /** @var \Authentication\Identity|null $user */
95
        $user = $this->Authentication->getIdentity();
96
        if (!empty($user)) {
97
            $this->getController()->set('modules', $this->getModules());
98
        }
99
100
        return null;
101
    }
102
103
    /**
104
     * Read modules and project info from `/home' endpoint.
105
     *
106
     * @return void
107
     */
108
    public function startup(): void
109
    {
110
        /** @var \Authentication\Identity|null $user */
111
        $user = $this->Authentication->getIdentity();
112
        if (empty($user) || !$user->get('id')) {
113
            $this->getController()->set(['modules' => [], 'project' => []]);
114
115
            return;
116
        }
117
118
        if ($this->getConfig('clearHomeCache')) {
119
            Cache::delete(sprintf('home_%d', $user->get('id')));
120
        }
121
122
        $project = $this->getProject();
123
        $uploadable = (array)Hash::get($this->Schema->objectTypesFeatures(), 'uploadable');
124
        $this->getController()->set(compact('project', 'uploadable'));
125
126
        $currentModuleName = $this->getConfig('currentModuleName');
127
        $modules = (array)$this->getController()->viewBuilder()->getVar('modules');
128
        if (!empty($currentModuleName)) {
129
            $currentModule = Hash::get($modules, $currentModuleName);
130
        }
131
132
        if (!empty($currentModule)) {
133
            $this->getController()->set(compact('currentModule'));
134
        }
135
    }
136
137
    /**
138
     * Create internal list of available modules in `$this->modules` as an array with `name` as key
139
     * and return it.
140
     * Modules are created from configuration and merged with information read from `/home` endpoint
141
     *
142
     * @return array
143
     */
144
    public function getModules(): array
145
    {
146
        $modules = (array)Configure::read('Modules');
147
        $pluginModules = array_filter($modules, function ($item) {
148
            return !empty($item['route']);
149
        });
150
        $metaModules = $this->modulesFromMeta() + $this->otherModules;
151
        $modules = array_intersect_key($modules, $metaModules);
152
        array_walk(
153
            $modules,
154
            function (&$data, $key) use ($metaModules) {
155
                $data = array_merge((array)Hash::get($metaModules, $key), $data);
156
            }
157
        );
158
        $this->modules = array_merge(
159
            $modules,
160
            array_diff_key($metaModules, $modules),
161
            $pluginModules
162
        );
163
        $this->modulesByAccessControl();
164
        if (!$this->Schema->tagsInUse()) {
165
            unset($this->modules['tags']);
166
        }
167
168
        return $this->modules;
169
    }
170
171
    /**
172
     * This filters modules and apply 'AccessControl' config by user role, if any.
173
     * Module can be "hidden": remove from modules.
174
     * Module can be "readonly": adjust "hints.allow" for module.
175
     *
176
     * @return void
177
     */
178
    protected function modulesByAccessControl(): void
179
    {
180
        $accessControl = (array)Configure::read('AccessControl');
181
        if (empty($accessControl)) {
182
            return;
183
        }
184
        /** @var \Authentication\Identity|null $user */
185
        $user = $this->Authentication->getIdentity();
186
        if (empty($user) || empty($user->getOriginalData())) {
187
            return;
188
        }
189
        $roles = array_intersect(array_keys($accessControl), (array)$user->get('roles'));
190
        $modules = (array)array_keys($this->modules);
191
        $hidden = [];
192
        $readonly = [];
193
        $write = [];
194
        foreach ($roles as $role) {
195
            $h = (array)Hash::get($accessControl, sprintf('%s.hidden', $role));
196
            $hidden = empty($hidden) ? $h : array_intersect($hidden, $h);
197
            $r = (array)Hash::get($accessControl, sprintf('%s.readonly', $role));
198
            $readonly = empty($readonly) ? $r : array_intersect($readonly, $r);
199
            $write = array_unique(array_merge($write, array_diff($modules, $hidden, $readonly)));
200
        }
201
        // Note: https://github.com/bedita/manager/issues/969 Accesses priority is "write" > "read" > "hidden"
202
        $readonly = array_diff($readonly, $write);
203
        $hidden = array_diff($hidden, $readonly, $write);
204
        if (empty($hidden) && empty($readonly)) {
205
            return;
206
        }
207
        // remove "hidden"
208
        $this->modules = array_diff_key($this->modules, array_flip($hidden));
209
        // make sure $readonly contains valid module names
210
        $readonly = array_intersect($readonly, array_keys($this->modules));
211
        foreach ($readonly as $key) {
212
            $path = sprintf('%s.hints.allow', $key);
213
            $allow = (array)Hash::get($this->modules, $path);
214
            $this->modules[$key]['hints']['allow'] = array_diff($allow, ['POST', 'PATCH', 'DELETE']);
215
        }
216
    }
217
218
    /**
219
     * Modules data from `/home` endpoint 'meta' response.
220
     * Modules are object endpoints from BE4 API
221
     *
222
     * @return array
223
     */
224
    protected function modulesFromMeta(): array
225
    {
226
        /** @var \Authentication\Identity $user */
227
        $user = $this->Authentication->getIdentity();
228
        $meta = $this->getMeta($user);
229
        $modules = collection(Hash::get($meta, 'resources', []))
230
            ->map(function (array $data, $endpoint) {
231
                $name = substr($endpoint, 1);
232
233
                return $data + compact('name');
234
            })
235
            ->reject(function (array $data) {
236
                return Hash::get($data, 'hints.object_type') !== true && !in_array(Hash::get($data, 'name'), ['trash', 'translations']);
237
            })
238
            ->toList();
239
240
        return Hash::combine($modules, '{n}.name', '{n}');
241
    }
242
243
    /**
244
     * Get information about current project.
245
     *
246
     * @return array
247
     */
248
    public function getProject(): array
249
    {
250
        /** @var \Authentication\Identity $user */
251
        $user = $this->Authentication->getIdentity();
252
        $meta = $this->getMeta($user);
253
        $project = (array)Configure::read('Project');
254
        $name = (string)Hash::get($project, 'name', Hash::get($meta, 'project.name'));
255
        $version = Hash::get($meta, 'version', '');
256
257
        return compact('name', 'version');
258
    }
259
260
    /**
261
     * Check if an object type is abstract or concrete.
262
     * This method MUST NOT be called from `beforeRender` since `$this->modules` array is still not initialized.
263
     *
264
     * @param string $name Name of object type.
265
     * @return bool True if abstract, false if concrete
266
     */
267
    public function isAbstract(string $name): bool
268
    {
269
        return (bool)Hash::get($this->modules, sprintf('%s.hints.multiple_types', $name), false);
270
    }
271
272
    /**
273
     * Get list of object types
274
     * This method MUST NOT be called from `beforeRender` since `$this->modules` array is still not initialized.
275
     *
276
     * @param bool|null $abstract Only abstract or concrete types.
277
     * @return array Type names list
278
     */
279
    public function objectTypes(?bool $abstract = null): array
280
    {
281
        $types = [];
282
        foreach ($this->modules as $name => $data) {
283
            if (empty($data['hints']['object_type'])) {
284
                continue;
285
            }
286
            if ($abstract === null || $data['hints']['multiple_types'] === $abstract) {
287
                $types[] = $name;
288
            }
289
        }
290
291
        return $types;
292
    }
293
294
    /**
295
     * Read oEmbed metadata
296
     *
297
     * @param string $url Remote URL
298
     * @return array|null
299
     * @codeCoverageIgnore
300
     */
301
    protected function oEmbedMeta(string $url): ?array
302
    {
303
        return (new OEmbed())->readMetadata($url);
304
    }
305
306
    /**
307
     * Upload a file and store it in a media stream
308
     * Or create a remote media trying to get some metadata via oEmbed
309
     *
310
     * @param array $requestData The request data from form
311
     * @return void
312
     */
313
    public function upload(array &$requestData): void
314
    {
315
        $uploadBehavior = Hash::get($requestData, 'upload_behavior', 'file');
316
317
        if ($uploadBehavior === 'embed' && !empty($requestData['remote_url'])) {
318
            $data = $this->oEmbedMeta($requestData['remote_url']);
319
            $requestData = array_filter($requestData) + $data;
320
321
            return;
322
        }
323
        if (empty($requestData['file'])) {
324
            return;
325
        }
326
327
        // verify upload form data
328
        if ($this->checkRequestForUpload($requestData)) {
329
            // has another stream? drop it
330
            $this->removeStream($requestData);
331
332
            /** @var \Laminas\Diactoros\UploadedFile $file */
333
            $file = $requestData['file'];
334
335
            // upload file
336
            $filename = basename($file->getClientFileName());
337
            $filepath = $file->getStream()->getMetadata('uri');
338
            $headers = ['Content-Type' => $file->getClientMediaType()];
339
            $apiClient = ApiClientProvider::getApiClient();
340
            $response = $apiClient->upload($filename, $filepath, $headers);
341
342
            // assoc stream to media
343
            $streamId = $response['data']['id'];
344
            $requestData['id'] = $this->assocStreamToMedia($streamId, $requestData, $filename);
345
        }
346
        unset($requestData['file'], $requestData['remote_url']);
347
    }
348
349
    /**
350
     * Remove a stream from a media, if any
351
     *
352
     * @param array $requestData The request data from form
353
     * @return bool
354
     */
355
    public function removeStream(array $requestData): bool
356
    {
357
        if (empty($requestData['id'])) {
358
            return false;
359
        }
360
361
        $apiClient = ApiClientProvider::getApiClient();
362
        $response = $apiClient->get(sprintf('/%s/%s/streams', $requestData['model-type'], $requestData['id']));
363
        if (empty($response['data'])) { // no streams for media
364
            return false;
365
        }
366
        $streamId = Hash::get($response, 'data.0.id');
367
        $apiClient->deleteObject($streamId, 'streams');
368
369
        return true;
370
    }
371
372
    /**
373
     * Associate a stream to a media using API
374
     * If $requestData['id'] is null, create media from stream.
375
     * If $requestData['id'] is not null, replace properly related stream.
376
     *
377
     * @param string $streamId The stream ID
378
     * @param array $requestData The request data
379
     * @param string $defaultTitle The default title for media
380
     * @return string The media ID
381
     */
382
    public function assocStreamToMedia(string $streamId, array &$requestData, string $defaultTitle): string
383
    {
384
        $apiClient = ApiClientProvider::getApiClient();
385
        $type = $requestData['model-type'];
386
        if (empty($requestData['id'])) {
387
            // create media from stream
388
            // save only `title` (filename if not set) and `status` in new media object
389
            $attributes = array_filter([
390
                'title' => !empty($requestData['title']) ? $requestData['title'] : $defaultTitle,
391
                'status' => Hash::get($requestData, 'status'),
392
            ]);
393
            $data = compact('type', 'attributes');
394
            $body = compact('data');
395
            $response = $apiClient->createMediaFromStream($streamId, $type, $body);
396
            // `title` and `status` saved here, remove from next save
397
            unset($requestData['title'], $requestData['status']);
398
399
            return (string)Hash::get($response, 'data.id');
400
        }
401
402
        // assoc existing media to stream
403
        $id = (string)Hash::get($requestData, 'id');
404
        $data = compact('id', 'type');
405
        $apiClient->replaceRelated($streamId, 'streams', 'object', $data);
406
407
        return $id;
408
    }
409
410
    /**
411
     * Check request data for upload and return true if upload is boht possible and needed
412
     *
413
     * @param array $requestData The request data
414
     * @return bool true if upload is possible and needed
415
     */
416
    public function checkRequestForUpload(array $requestData): bool
417
    {
418
        /** @var \Laminas\Diactoros\UploadedFile $file */
419
        $file = $requestData['file'];
420
        $error = $file->getError();
421
        // check if change file is empty
422
        if ($error === UPLOAD_ERR_NO_FILE) {
423
            return false;
424
        }
425
426
        // if upload error, throw exception
427
        if ($error !== UPLOAD_ERR_OK) {
428
            throw new UploadException(null, $error);
429
        }
430
431
        // verify presence and value of 'name', 'tmp_name', 'type'
432
        $name = $file->getClientFileName();
433
        if (empty($name)) {
434
            throw new InternalErrorException('Invalid form data: file.name');
435
        }
436
        $uri = $file->getStream()->getMetadata('uri');
437
        if (empty($uri)) {
438
            throw new InternalErrorException('Invalid form data: file.tmp_name');
439
        }
440
441
        // verify 'model-type'
442
        if (empty($requestData['model-type']) || !is_string($requestData['model-type'])) {
443
            throw new InternalErrorException('Invalid form data: model-type');
444
        }
445
446
        return true;
447
    }
448
449
    /**
450
     * Check if save can be skipped.
451
     * This is used to avoid saving object with no changes.
452
     *
453
     * @param string $id The object ID
454
     * @param array $requestData The request data
455
     * @param array $relatedData The related data
456
     * @return bool True if save can be skipped, false otherwise
457
     */
458
    public function skipSave(string $id, array $requestData, array $relatedData): bool
459
    {
460
        if (empty($id) || !empty($relatedData)) {
461
            return false;
462
        }
463
        $data = $requestData;
464
        unset($data['id']);
465
        $requestPermissions = (array)Hash::get($data, 'permissions', []);
466
        if (!empty($requestPermissions)) {
467
            $requestPermissions = array_map(
468
                function ($role) {
469
                    return (int)$role;
470
                },
471
                $requestPermissions
472
            );
473
            sort($requestPermissions);
474
            $query = ['filter' => ['object_id' => $id], 'page_size' => 100];
475
            $objectPermissions = (array)ApiClientProvider::getApiClient()->getObjects('object_permissions', $query);
476
            $actualPermissions = (array)Hash::extract($objectPermissions, 'data.{n}.attributes.role_id');
477
            sort($actualPermissions);
478
            if ($actualPermissions === $requestPermissions) {
479
                unset($data['permissions']);
480
            }
481
        }
482
483
        return empty($data);
484
    }
485
486
    /**
487
     * Set current attributes from loaded $object data in `currentAttributes`.
488
     *
489
     * @param array $object The object.
490
     * @return void
491
     */
492
    public function setupAttributes(array &$object): void
493
    {
494
        $currentAttributes = json_encode((array)Hash::get($object, 'attributes'));
495
        $this->getController()->set(compact('currentAttributes'));
496
    }
497
498
    /**
499
     * Setup relations information metadata.
500
     *
501
     * @param array $schema Relations schema.
502
     * @param array $relationships Object relationships.
503
     * @param array $order Ordered names inside 'main' and 'aside' keys.
504
     * @param array $hidden List of hidden relations.
505
     * @param array $readonly List of readonly relations.
506
     * @return void
507
     */
508
    public function setupRelationsMeta(array $schema, array $relationships, array $order = [], array $hidden = [], array $readonly = []): void
509
    {
510
        // relations between objects
511
        $relationsSchema = $this->relationsSchema($schema, $relationships, $hidden, $readonly);
512
        // relations between objects and resources
513
        $resourceRelations = array_diff(array_keys($relationships), array_keys($relationsSchema), $hidden, self::FIXED_RELATIONSHIPS);
514
        // set objectRelations array with name as key and label as value
515
        $relationNames = array_keys($relationsSchema);
516
517
        // define 'main' and 'aside' relation groups
518
        $aside = array_intersect((array)Hash::get($order, 'aside'), $relationNames);
519
        $relationNames = array_diff($relationNames, $aside);
520
        $main = array_intersect((array)Hash::get($order, 'main'), $relationNames);
521
        $main = array_unique(array_merge($main, $relationNames));
522
523
        $objectRelations = [
524
            'main' => $this->relationLabels($relationsSchema, $main),
525
            'aside' => $this->relationLabels($relationsSchema, $aside),
526
        ];
527
528
        $this->getController()->set(compact('relationsSchema', 'resourceRelations', 'objectRelations'));
529
    }
530
531
    /**
532
     * Relations schema by schema and relationships.
533
     *
534
     * @param array $schema The schema
535
     * @param array $relationships The relationships
536
     * @param array $hidden Hidden relationships
537
     * @param array $readonly Readonly relationships
538
     * @return array
539
     */
540
    protected function relationsSchema(array $schema, array $relationships, array $hidden = [], array $readonly = []): array
541
    {
542
        $types = $this->objectTypes(false);
543
        sort($types);
544
        $relationsSchema = array_diff_key(array_intersect_key($schema, $relationships), array_flip($hidden));
545
546
        foreach ($relationsSchema as $relName => &$relSchema) {
547
            if (in_array('objects', (array)Hash::get($relSchema, 'right'))) {
548
                $relSchema['right'] = $types;
549
            }
550
            if (!empty($relationships[$relName]['readonly']) || in_array($relName, $readonly)) {
551
                $relSchema['readonly'] = true;
552
            }
553
        }
554
555
        return $relationsSchema;
556
    }
557
558
    /**
559
     * Retrieve associative array with names as keys and labels as values.
560
     *
561
     * @param array $relationsSchema Relations schema.
562
     * @param array $names Relation names.
563
     * @return array
564
     */
565
    protected function relationLabels(array &$relationsSchema, array $names): array
566
    {
567
        return (array)array_combine(
568
            $names,
569
            array_map(
570
                function ($r) use ($relationsSchema) {
571
                    // return 'label' or 'inverse_label' looking at 'name'
572
                    $attributes = $relationsSchema[$r]['attributes'];
573
                    if ($r === $attributes['name']) {
574
                        return $attributes['label'];
575
                    }
576
577
                    return $attributes['inverse_label'];
578
                },
579
                $names
580
            )
581
        );
582
    }
583
584
    /**
585
     * Get related types from relation name.
586
     *
587
     * @param array $schema Relations schema.
588
     * @param string $relation Relation name.
589
     * @return array
590
     */
591
    public function relatedTypes(array $schema, string $relation): array
592
    {
593
        $relationsSchema = (array)Hash::get($schema, $relation);
594
595
        return (array)Hash::get($relationsSchema, 'right');
596
    }
597
598
    /**
599
     * Save related objects.
600
     *
601
     * @param string $id Object ID
602
     * @param string $type Object type
603
     * @param array $relatedData Related objects data
604
     * @return void
605
     */
606
    public function saveRelated(string $id, string $type, array $relatedData): void
607
    {
608
        foreach ($relatedData as $data) {
609
            $this->saveRelatedObjects($id, $type, $data);
610
            $event = new Event('Controller.afterSaveRelated', $this, compact('id', 'type', 'data'));
611
            $this->getController()->getEventManager()->dispatch($event);
612
        }
613
    }
614
615
    /**
616
     * Save related objects per object by ID.
617
     *
618
     * @param string $id Object ID
619
     * @param string $type Object type
620
     * @param array $data Related object data
621
     * @return array|null
622
     * @throws \Cake\Http\Exception\BadRequestException
623
     */
624
    public function saveRelatedObjects(string $id, string $type, array $data): ?array
625
    {
626
        $method = (string)Hash::get($data, 'method');
627
        if (!in_array($method, ['addRelated', 'removeRelated', 'replaceRelated'])) {
628
            throw new BadRequestException(__('Bad related data method'));
629
        }
630
        $relation = (string)Hash::get($data, 'relation');
631
        $related = $this->getRelated($data);
632
        if ($relation === 'parent' && $type === 'folders') {
633
            return $this->Parents->{$method}($id, $related);
634
        }
635
        if ($relation === 'children' && $type === 'folders') {
636
            return $this->Children->{$method}($id, $related);
637
        }
638
        $lang = I18n::getLocale();
639
        $headers = ['Accept-Language' => $lang];
640
641
        return ApiClientProvider::getApiClient()->{$method}($id, $type, $relation, $related, $headers);
642
    }
643
644
    /**
645
     * Get related objects.
646
     * If related object has no ID, it will be created.
647
     *
648
     * @param array $data Related object data
649
     * @return array
650
     */
651
    public function getRelated(array $data): array
652
    {
653
        $related = (array)Hash::get($data, 'relatedIds');
654
        if (empty($related)) {
655
            return [];
656
        }
657
        $relatedObjects = [];
658
        foreach ($related as $obj) {
659
            if (!empty($obj['id'])) {
660
                $relatedObjects[] = [
661
                    'id' => $obj['id'],
662
                    'type' => $obj['type'],
663
                    'meta' => (array)Hash::get($obj, 'meta'),
664
                ];
665
                continue;
666
            }
667
            $response = ApiClientProvider::getApiClient()->save(
668
                (string)Hash::get($obj, 'type'),
669
                (array)Hash::get($obj, 'attributes')
670
            );
671
            $relatedObjects[] = [
672
                'id' => Hash::get($response, 'data.id'),
673
                'type' => Hash::get($response, 'data.type'),
674
                'meta' => (array)Hash::get($response, 'data.meta'),
675
            ];
676
        }
677
678
        return $relatedObjects;
679
    }
680
}
681