Passed
Pull Request — master (#1198)
by Stefano
01:32
created

ModulesComponent::objectTypes()   A

Complexity

Conditions 5
Paths 4

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

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