Completed
Push — master ( 743575...aa2450 )
by Stefano
20s queued 11s
created

ModulesComponent::prepareQuery()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

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