Passed
Push — master ( 34beb8...79bf97 )
by Alberto
14:24 queued 13s
created

ModulesComponent::saveRelated()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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