Passed
Pull Request — master (#352)
by Dante
02:49
created

ModulesComponent   B

Complexity

Total Complexity 46

Size/Duplication

Total Lines 355
Duplicated Lines 0 %

Importance

Changes 3
Bugs 3 Features 0
Metric Value
eloc 128
dl 0
loc 355
rs 8.72
c 3
b 3
f 0
wmc 46

12 Methods

Rating   Name   Duplication   Size   Complexity  
A removeStream() 0 13 3
A upload() 0 31 5
B checkRequestForUpload() 0 24 8
A isAbstract() 0 3 1
A assocStreamToMedia() 0 26 3
A getMeta() 0 18 3
A getModules() 0 37 5
A oEmbedMeta() 0 3 1
A getProject() 0 10 1
A startup() 0 23 5
A objectTypes() 0 13 5
A updateFromFailedSave() 0 27 6

How to fix   Complexity   

Complex Class

Complex classes like ModulesComponent often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use ModulesComponent, and based on these observations, apply Extract Interface, too.

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\BEditaClient;
19
use BEdita\SDK\BEditaClientException;
20
use BEdita\WebTools\ApiClientProvider;
21
use Cake\Cache\Cache;
22
use Cake\Controller\Component;
23
use Cake\Core\Configure;
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
 */
33
class ModulesComponent extends Component
34
{
35
36
    /**
37
     * {@inheritDoc}
38
     */
39
    public $components = ['Auth'];
40
41
    /**
42
     * {@inheritDoc}
43
     */
44
    protected $_defaultConfig = [
45
        'currentModuleName' => null,
46
        'clearHomeCache' => false,
47
    ];
48
49
    /**
50
     * Project modules for a user from `/home` endpoint
51
     *
52
     * @var array
53
     */
54
    protected $modules = [];
55
56
    /**
57
     * Read modules and project info from `/home' endpoint.
58
     *
59
     * @return void
60
     */
61
    public function startup(): void
62
    {
63
        if (empty($this->Auth->user('id'))) {
64
            $this->getController()->set(['modules' => [], 'project' => []]);
65
66
            return;
67
        }
68
69
        if ($this->getConfig('clearHomeCache')) {
70
            Cache::delete(sprintf('home_%d', $this->Auth->user('id')));
71
        }
72
73
        $modules = $this->getModules();
74
        $project = $this->getProject();
75
        $this->getController()->set(compact('modules', 'project'));
76
77
        $currentModuleName = $this->getConfig('currentModuleName');
78
        if (!empty($currentModuleName)) {
79
            $currentModule = Hash::get($modules, $currentModuleName);
80
        }
81
82
        if (!empty($currentModule)) {
83
            $this->getController()->set(compact('currentModule'));
84
        }
85
    }
86
87
    /**
88
     * Getter for home endpoint metadata.
89
     *
90
     * @return array
91
     */
92
    protected function getMeta(): array
93
    {
94
        try {
95
            $home = Cache::remember(
96
                sprintf('home_%d', $this->Auth->user('id')),
97
                function () {
98
                    return ApiClientProvider::getApiClient()->get('/home');
99
                }
100
            );
101
        } catch (BEditaClientException $e) {
102
            // Something bad happened. Returning an empty array instead.
103
            // The exception is being caught _outside_ of `Cache::remember()` to avoid caching the fallback.
104
            $this->log($e, LogLevel::ERROR);
105
106
            return [];
107
        }
108
109
        return !empty($home['meta']) ? $home['meta'] : [];
110
    }
111
112
    /**
113
     * Create internal list of available modules in `$this->modules` as an array with `name` as key
114
     * and return it.
115
     * Modules are read from `/home` endpoint
116
     *
117
     * @return array
118
     */
119
    public function getModules(): array
120
    {
121
        $modulesOrder = Configure::read('Modules.order');
122
123
        $meta = $this->getMeta();
124
        $modules = collection(Hash::get($meta, 'resources', []))
125
            ->map(function (array $data, $endpoint) {
126
                $name = substr($endpoint, 1);
127
128
                return $data + compact('name');
129
            })
130
            ->reject(function (array $data) {
131
                return Hash::get($data, 'hints.object_type') !== true && Hash::get($data, 'name') !== 'trash';
132
            })
133
            ->sortBy(function (array $data) use ($modulesOrder) {
134
                $name = Hash::get($data, 'name');
135
                $idx = array_search($name, $modulesOrder);
136
                if ($idx === false) {
137
                    // No configured order for this module. Use hash to preserve order, and ensure it is after other modules.
138
                    $idx = count($modulesOrder) + hexdec(hash('crc32', $name));
139
140
                    if ($name === 'trash') {
141
                        // Trash eventually.
142
                        $idx = PHP_INT_MAX;
143
                    }
144
                }
145
146
                return -$idx;
147
            })
148
            ->toList();
149
        $plugins = Configure::read('Modules.plugins');
150
        if ($plugins) {
151
            $modules = array_merge($modules, $plugins);
152
        }
153
        $this->modules = Hash::combine($modules, '{n}.name', '{n}');
154
155
        return $this->modules;
156
    }
157
158
    /**
159
     * Get information about current project.
160
     *
161
     * @return array
162
     */
163
    public function getProject(): array
164
    {
165
        $meta = $this->getMeta();
166
        $project = [
167
            'name' => Hash::get($meta, 'project.name', ''),
168
            'version' => Hash::get($meta, 'version', ''),
169
            'colophon' => '', // TODO: populate this value.
170
        ];
171
172
        return $project;
173
    }
174
175
    /**
176
     * Check if an object type is abstract or concrete.
177
     * This method MUST NOT be called from `beforeRender` since `$this->modules` array is still not initialized.
178
     *
179
     * @param string $name Name of object type.
180
     * @return bool True if abstract, false if concrete
181
     */
182
    public function isAbstract(string $name): bool
183
    {
184
        return (bool)Hash::get($this->modules, sprintf('%s.hints.multiple_types', $name), false);
185
    }
186
187
    /**
188
     * Get list of object types
189
     * This method MUST NOT be called from `beforeRender` since `$this->modules` array is still not initialized.
190
     *
191
     * @param bool|null $abstract Only abstract or concrete types.
192
     * @return array Type names list
193
     */
194
    public function objectTypes(?bool $abstract = null): array
195
    {
196
        $types = [];
197
        foreach ($this->modules as $name => $data) {
198
            if (!$data['hints']['object_type']) {
199
                continue;
200
            }
201
            if ($abstract === null || $data['hints']['multiple_types'] === $abstract) {
202
                $types[] = $name;
203
            }
204
        }
205
206
        return $types;
207
    }
208
209
    /**
210
     * Read oEmbed metadata
211
     *
212
     * @param string $url Remote URL
213
     * @return array|null
214
     * @codeCoverageIgnore
215
     */
216
    protected function oEmbedMeta(string $url): ?array
217
    {
218
        return (new OEmbed())->readMetadata($url);
219
    }
220
221
    /**
222
     * Upload a file and store it in a media stream
223
     * Or create a remote media trying to get some metadata via oEmbed
224
     *
225
     * @param array $requestData The request data from form
226
     * @return void
227
     */
228
    public function upload(array &$requestData): void
229
    {
230
        $uploadBehavior = Hash::get($requestData, 'upload_behavior', 'file');
231
232
        if ($uploadBehavior === 'embed' && !empty($requestData['remote_url'])) {
233
            $data = $this->oEmbedMeta($requestData['remote_url']);
234
            $requestData = array_filter($requestData) + $data;
235
236
            return;
237
        }
238
        if (empty($requestData['file'])) {
239
            return;
240
        }
241
242
        // verify upload form data
243
        if ($this->checkRequestForUpload($requestData)) {
244
            // has another stream? drop it
245
            $this->removeStream($requestData);
246
247
            // upload file
248
            $filename = $requestData['file']['name'];
249
            $filepath = $requestData['file']['tmp_name'];
250
            $headers = ['Content-Type' => $requestData['file']['type']];
251
            $apiClient = ApiClientProvider::getApiClient();
252
            $response = $apiClient->upload($filename, $filepath, $headers);
253
254
            // assoc stream to media
255
            $streamId = $response['data']['id'];
256
            $requestData['id'] = $this->assocStreamToMedia($streamId, $requestData, $filename);
257
        }
258
        unset($requestData['file'], $requestData['remote_url']);
259
    }
260
261
    /**
262
     * Remove a stream from a media, if any
263
     *
264
     * @param array $requestData The request data from form
265
     * @return void
266
     */
267
    public function removeStream(array $requestData): void
268
    {
269
        if (empty($requestData['id'])) {
270
            return;
271
        }
272
273
        $apiClient = ApiClientProvider::getApiClient();
274
        $response = $apiClient->get(sprintf('/%s/%s/streams', $requestData['model-type'], $requestData['id']));
275
        if (empty($response['data'])) { // no streams for media
276
            return;
277
        }
278
        $streamId = Hash::get($response, 'data.0.id');
279
        $apiClient->deleteObject($streamId, 'streams');
280
    }
281
282
    /**
283
     * Associate a stream to a media using API
284
     * If $requestData['id'] is null, create media from stream.
285
     * If $requestData['id'] is not null, replace properly related stream.
286
     *
287
     * @param string $streamId The stream ID
288
     * @param array $requestData The request data
289
     * @param string $defaultTitle The default title for media
290
     * @return string The media ID
291
     */
292
    public function assocStreamToMedia(string $streamId, array &$requestData, string $defaultTitle): string
293
    {
294
        $apiClient = ApiClientProvider::getApiClient();
295
        $type = $requestData['model-type'];
296
        if (empty($requestData['id'])) {
297
            // create media from stream
298
            // save only `title` (filename if not set) and `status` in new media object
299
            $attributes = array_filter([
300
                'title' => !empty($requestData['title']) ? $requestData['title'] : $defaultTitle,
301
                'status' => Hash::get($requestData, 'status'),
302
            ]);
303
            $data = compact('type', 'attributes');
304
            $body = compact('data');
305
            $response = $apiClient->createMediaFromStream($streamId, $type, $body);
306
            // `title` and `status` saved here, remove from next save
307
            unset($requestData['title'], $requestData['status']);
308
309
            return $response['data']['id'];
310
        }
311
312
        // assoc existing media to stream
313
        $id = $requestData['id'];
314
        $data = compact('id', 'type');
315
        $apiClient->replaceRelated($streamId, 'streams', 'object', $data);
316
317
        return $id;
318
    }
319
320
    /**
321
     * Check request data for upload and return true if upload is boht possible and needed
322
     *
323
     *
324
     * @param array $requestData The request data
325
     * @return bool true if upload is possible and needed
326
     */
327
    public function checkRequestForUpload(array $requestData): bool
328
    {
329
        // check if change file is empty
330
        if ($requestData['file']['error'] === UPLOAD_ERR_NO_FILE) {
331
            return false;
332
        }
333
334
        // if upload error, throw exception
335
        if ($requestData['file']['error'] !== UPLOAD_ERR_OK) {
336
            throw new UploadException(null, $requestData['file']['error']);
337
        }
338
339
        // verify presence and value of 'name', 'tmp_name', 'type'
340
        foreach (['name', 'tmp_name', 'type'] as $field) {
341
            if (empty($requestData['file'][$field]) || !is_string($requestData['file'][$field])) {
342
                throw new InternalErrorException(sprintf('Invalid form data: file.%s', $field));
343
            }
344
        }
345
        // verify 'model-type'
346
        if (empty($requestData['model-type']) || !is_string($requestData['model-type'])) {
347
            throw new InternalErrorException('Invalid form data: model-type');
348
        }
349
350
        return true;
351
    }
352
353
    /**
354
     * Update object, when failed save occurred.
355
     * Check session data by `failedSave.{type}.{id}` key and `failedSave.{type}.{id}__timestamp`.
356
     * If data is set and timestamp is not older than 5 minutes.
357
     *
358
     * @param array $object The object.
359
     * @return void
360
     */
361
    public function updateFromFailedSave(array &$object): void
362
    {
363
        if (empty($object) || empty($object['id']) || empty($object['type'])) {
364
            return;
365
        }
366
367
        // check session data for object id => use `failedSave.{type}.{id}` as key
368
        $session = $this->request->getSession();
0 ignored issues
show
Deprecated Code introduced by
The property Cake\Controller\Component::$request has been deprecated: 3.4.0 Storing references to the request is deprecated. Use Component::getController() or callback $event->getSubject() to access the controller & request instead. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

368
        $session = /** @scrutinizer ignore-deprecated */ $this->request->getSession();

This property has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the property will be removed from the class and what other property to use instead.

Loading history...
369
        $key = sprintf('failedSave.%s.%s', $object['type'], $object['id']);
370
        $data = $session->read($key);
371
        if (empty($data)) {
372
            return;
373
        }
374
375
        // read timestamp session key
376
        $timestampKey = sprintf('%s__timestamp', $key);
377
        $timestamp = $session->read($timestampKey);
378
379
        // if data exist for {type} and {id} and `__timestamp` not too old (<= 5 minutes)
380
        if (strtotime($timestamp) < strtotime("-5 minutes")) {
381
            //  => merge with $object['attributes']
382
            $object['attributes'] = array_merge($object['attributes'], $data);
0 ignored issues
show
Bug introduced by
It seems like $data can also be of type string; however, parameter $array2 of array_merge() does only seem to accept array|null, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

382
            $object['attributes'] = array_merge($object['attributes'], /** @scrutinizer ignore-type */ $data);
Loading history...
383
        }
384
385
        // remove session data
386
        $session->delete($key);
387
        $session->delete($timestampKey);
388
    }
389
}
390