Passed
Pull Request — master (#1304)
by Dante
01:32
created

AppController::prepareRoles()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 9
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 7
nc 4
nop 1
dl 0
loc 9
rs 10
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;
14
15
use App\Form\Form;
16
use App\Utility\DateRangesTools;
17
use App\Utility\PermissionsTrait;
18
use Authentication\Identity;
19
use BEdita\WebTools\ApiClientProvider;
20
use Cake\Controller\Controller;
21
use Cake\Controller\Exception\SecurityException;
22
use Cake\Core\Configure;
23
use Cake\Event\EventInterface;
24
use Cake\Http\Exception\BadRequestException;
25
use Cake\Http\Response;
26
use Cake\Utility\Hash;
27
28
/**
29
 * Base Application Controller.
30
 *
31
 * @property \Authentication\Controller\Component\AuthenticationComponent $Authentication
32
 * @property \App\Controller\Component\CategoriesComponent $Categories
33
 * @property \App\Controller\Component\ConfigComponent $Config
34
 * @property \App\Controller\Component\FlashComponent $Flash
35
 * @property \App\Controller\Component\ModulesComponent $Modules
36
 * @property \App\Controller\Component\SchemaComponent $Schema
37
 */
38
class AppController extends Controller
39
{
40
    use PermissionsTrait;
41
42
    /**
43
     * BEdita4 API client
44
     *
45
     * @var \BEdita\SDK\BEditaClient
46
     */
47
    protected $apiClient = null;
48
49
    /**
50
     * @inheritDoc
51
     */
52
    public function initialize(): void
53
    {
54
        parent::initialize();
55
56
        $this->loadComponent('RequestHandler', ['enableBeforeRedirect' => false]);
57
        $this->loadComponent('App.Flash', ['clear' => true]);
58
        $this->loadComponent('Security');
59
60
        // API config may not be set in `login` for a multi-project setup
61
        if (Configure::check('API.apiBaseUrl')) {
62
            $this->apiClient = ApiClientProvider::getApiClient();
63
        }
64
65
        $this->loadComponent('Authentication.Authentication', [
66
            'logoutRedirect' => '/login',
67
        ]);
68
69
        $this->loadComponent('Modules', [
70
            'currentModuleName' => $this->name,
71
        ]);
72
        $this->loadComponent('Schema');
73
        $this->loadComponent('Categories');
74
75
        /** @var \Authentication\Identity|null $identity */
76
        $identity = $this->Authentication->getIdentity();
77
        if ($identity && $identity->get('tokens')) {
78
            $this->apiClient->setupTokens($identity->get('tokens'));
79
        }
80
    }
81
82
    /**
83
     * @inheritDoc
84
     */
85
    public function beforeFilter(EventInterface $event): ?Response
86
    {
87
        /** @var \Authentication\Identity|null $identity */
88
        $identity = $this->Authentication->getIdentity();
89
        if (!($identity && $identity->get('tokens')) && !in_array(rtrim($this->getRequest()->getPath(), '/'), ['/login'])) {
90
            $route = $this->loginRedirectRoute();
91
            $this->Flash->error(__('Login required'));
92
93
            return $this->redirect($route);
94
        }
95
        $this->setupOutputTimezone();
96
        $this->Security->setConfig('blackHoleCallback', 'blackhole');
97
98
        return null;
99
    }
100
101
    /**
102
     * Handle security blackhole with logs for now
103
     *
104
     * @param string $type Exception type
105
     * @param \Cake\Controller\Exception\SecurityException $exception Raised exception
106
     * @return void
107
     * @throws \Cake\Http\Exception\BadRequestException
108
     * @codeCoverageIgnore
109
     */
110
    public function blackhole(string $type, SecurityException $exception): void
111
    {
112
        // Log original exception
113
        $this->log($exception->getMessage(), 'error');
114
115
        // Log form data & session id
116
        $token = (array)$this->getRequest()->getData('_Token');
117
        unset($token['debug']);
118
        $this->log('[Blackhole] type: ' . $type, 'debug');
119
        $this->log('[Blackhole] form token: ' . json_encode($token), 'debug');
120
        $this->log('[Blackhole] form fields: ' . json_encode(array_keys((array)$this->getRequest()->getData())), 'debug');
121
        $this->log('[Blackhole] form session id: ' . (string)$this->getRequest()->getData('_session_id'), 'debug');
122
        $sessionId = $this->getRequest()->getSession()->id();
123
        $this->log('[Blackhole] current session id: ' . $sessionId, 'debug');
124
125
        // Throw a generic bad request exception.
126
        throw new BadRequestException();
127
    }
128
129
    /**
130
     * Return route array for login redirect.
131
     * When request is not a get, return route without redirect.
132
     * When request uri path equals request attribute webroot (the app 'webroot'), return route without redirect.
133
     * Return route with redirect, otherwise.
134
     *
135
     * @return array
136
     */
137
    protected function loginRedirectRoute(): array
138
    {
139
        $route = ['_name' => 'login'];
140
141
        // if request is not a get, return route without redirect.
142
        if (!$this->getRequest()->is('get')) {
143
            return $route;
144
        }
145
146
        // if redirect is app webroot, return route without redirect.
147
        $redirect = $this->getRequest()->getUri()->getPath();
148
        if ($redirect === $this->getRequest()->getAttribute('webroot')) {
149
            return $route;
150
        }
151
152
        return $route + ['?' => compact('redirect')];
153
    }
154
155
    /**
156
     * Setup output timezone from user session
157
     *
158
     * @return void
159
     */
160
    protected function setupOutputTimezone(): void
161
    {
162
        /** @var \Authentication\Identity|null $identity */
163
        $identity = $this->Authentication->getIdentity();
164
        if (!$identity) {
165
            return;
166
        }
167
168
        $timezone = $identity->get('timezone');
169
        if (!$timezone) {
170
            return;
171
        }
172
173
        Configure::write('I18n.timezone', $timezone);
174
    }
175
176
    /**
177
     * {@inheritDoc}
178
     *
179
     * Update session tokens if updated/refreshed by client
180
     */
181
    public function beforeRender(EventInterface $event): ?Response
182
    {
183
        /** @var \Authentication\Identity|null $user */
184
        $user = $this->Authentication->getIdentity();
185
        if ($user) {
186
            $tokens = $this->apiClient->getTokens();
187
            if ($tokens && $user->get('tokens') !== $tokens) {
188
                $data = compact('tokens') + (array)$user->getOriginalData();
189
                $user = new Identity($data);
190
                $this->Authentication->setIdentity($user);
191
            }
192
193
            $this->set(compact('user'));
194
        }
195
196
        $path = $this->viewBuilder()->getTemplatePath();
197
        $this->viewBuilder()->setTemplatePath('Pages/' . $path);
198
199
        return null;
200
    }
201
202
    /**
203
     * Prepare request, set properly json data.
204
     *
205
     * @param string $type Object type
206
     * @return array request data
207
     */
208
    protected function prepareRequest($type): array
209
    {
210
        $data = (array)$this->getRequest()->getData();
211
212
        $this->specialAttributes($data);
213
        $this->setupParentsRelation($type, $data);
214
        $this->prepareRelations($data);
215
        $this->changedAttributes($data);
216
        $this->filterEmpty($data);
217
218
        return $data;
219
    }
220
221
    /**
222
     * Setup special attributes to be saved.
223
     *
224
     * @param array $data Request data
225
     * @return void
226
     */
227
    protected function specialAttributes(array &$data): void
228
    {
229
        // remove temporary session id
230
        unset($data['_session_id']);
231
        unset($data['selectedCategories']);
232
233
        // if password is empty, unset it
234
        if (array_key_exists('password', $data) && empty($data['password'])) {
235
            unset($data['password']);
236
            unset($data['confirm-password']);
237
        }
238
239
        $this->decodeJsonAttributes($data);
240
        $this->prepareRoles($data);
241
        $this->prepareDateRanges($data);
242
243
        // prepare categories
244
        if (!empty($data['categories'])) {
245
            $data['categories'] = json_decode($data['categories'], true);
246
            $data['categories'] = array_map(function ($category) {
247
                return ['name' => $category];
248
            }, $data['categories']);
249
        }
250
251
        // decode json fields
252
        $types = (array)Hash::get($data, '_types');
253
        if (!empty($types)) {
254
            foreach ($types as $field => $type) {
255
                if ($type === 'json' && is_string($data[$field])) {
256
                    $data[$field] = json_decode($data[$field], true);
257
                }
258
            }
259
            unset($data['_types']);
260
        }
261
    }
262
263
    /**
264
     * Decodes JSON attributes.
265
     *
266
     * @param array $data Request data
267
     * @return void
268
     */
269
    protected function decodeJsonAttributes(array &$data): void
270
    {
271
        if (empty($data['_jsonKeys'])) {
272
            return;
273
        }
274
275
        $keys = array_unique(explode(',', (string)$data['_jsonKeys']));
276
        foreach ($keys as $key) {
277
            $value = Hash::get($data, $key);
278
            $decoded = json_decode((string)$value, true);
279
            if ($decoded === []) {
280
                // decode as empty object in case of empty array
281
                $decoded = json_decode((string)$value);
282
            }
283
            $data = Hash::insert($data, $key, $decoded);
284
        }
285
        unset($data['_jsonKeys']);
286
    }
287
288
    /**
289
     * Prepare date ranges.
290
     * Remove empty date ranges.
291
     * Fix end date time to 23:59:59.000 if end_date contains '23:59:00.000'.
292
     *
293
     * @param array $data The data to prepare
294
     * @return void
295
     */
296
    protected function prepareDateRanges(array &$data): void
297
    {
298
        if (empty($data['date_ranges'])) {
299
            return;
300
        }
301
        $data['date_ranges'] = is_array($data['date_ranges']) ? $data['date_ranges'] : json_decode($data['date_ranges'], true);
302
        $data['date_ranges'] = DateRangesTools::prepare(Hash::get($data, 'date_ranges'));
303
    }
304
305
    /**
306
     * Transform roles data into relations format.
307
     *
308
     * @param array $data The data to prepare
309
     * @return void
310
     */
311
    protected function prepareRoles(array &$data): void
312
    {
313
        $roles = (string)Hash::get($data, 'roles');
314
        $roles = empty($roles) ? [] : (array)json_decode($roles, true);
315
        $data = array_filter($data, fn($key) => $key !== 'roles', ARRAY_FILTER_USE_KEY);
316
        if (!empty($roles)) {
317
            $data['relations']['roles']['replaceRelated'] = array_map(
318
                fn($id) => ['id' => $id, 'type' => 'roles'],
319
                array_keys($this->rolesByNames($roles))
320
            );
321
        }
322
    }
323
324
    /**
325
     * Prepare request relation data.
326
     *
327
     * @param array $data Request data
328
     * @return void
329
     */
330
    protected function prepareRelations(array &$data): void
331
    {
332
        // relations data for view/save - prepare api calls
333
        if (!empty($data['relations'])) {
334
            $api = [];
335
            foreach ($data['relations'] as $relation => $relationData) {
336
                $id = Hash::get($data, 'id', null);
337
                foreach ($relationData as $method => $ids) {
338
                    $relatedIds = $this->relatedIds($ids);
339
                    if ($method === 'replaceRelated' || !empty($relatedIds)) {
340
                        $api[] = compact('method', 'id', 'relation', 'relatedIds');
341
                    }
342
                }
343
            }
344
            $data['_api'] = $api;
345
        }
346
        $data = array_filter($data, fn($key) => $key !== 'relations', ARRAY_FILTER_USE_KEY);
347
    }
348
349
    /**
350
     * Get related ids from items array.
351
     * If items is string, it is json encoded array.
352
     * If items is array, it can be json encoded array or array of id/type data.
353
     */
354
    protected function relatedIds($items): array
355
    {
356
        if (empty($items)) {
357
            return [];
358
        }
359
        if (is_string($items)) {
360
            return json_decode($items, true);
361
        }
362
        if (is_string(Hash::get($items, 0))) {
363
            return array_map(
364
                function ($json) {
365
                    return json_decode($json, true);
366
                },
367
                $items
368
            );
369
        }
370
371
        return $items;
372
    }
373
374
    /**
375
     * Handle `parents` or `parent` relationship looking at `_changedParents` input
376
     *
377
     * @param string $type Object type
378
     * @param array $data Form data
379
     * @return void
380
     */
381
    protected function setupParentsRelation(string $type, array &$data): void
382
    {
383
        $changedParents = (string)Hash::get($data, '_changedParents');
384
        $relation = $type === 'folders' ? 'parent' : 'parents';
385
        if (empty($changedParents)) {
386
            unset($data['relations'][$relation], $data['_changedParents'], $data['_originalParents']);
387
388
            return;
389
        }
390
        $changedParents = array_unique(explode(',', $changedParents));
391
        $originalParents = array_filter(explode(',', (string)Hash::get($data, '_originalParents')));
392
        unset($data['_changedParents'], $data['_originalParents']);
393
        $replaceRelated = array_reduce(
394
            (array)Hash::get($data, sprintf('relations.%s.replaceRelated', $relation)),
395
            function ($acc, $obj) {
396
                $jsonObj = (array)json_decode($obj, true);
397
                $acc[(string)Hash::get($jsonObj, 'id')] = $jsonObj;
398
399
                return $acc;
400
            },
401
            []
402
        );
403
        $addRelated = array_map(
404
            function ($id) use ($replaceRelated) {
405
                return Hash::get($replaceRelated, $id);
406
            },
407
            $changedParents
408
        );
409
        $addRelated = array_filter(
410
            $addRelated,
411
            function ($elem) {
412
                return !empty($elem);
413
            }
414
        );
415
        $data['relations'][$relation]['addRelated'] = $addRelated;
416
417
        // no need to remove when relation is "parent"
418
        // ParentsComponent::addRelated already performs a replaceRelated
419
        if ($relation !== 'parent') {
420
            $rem = array_diff($originalParents, array_keys($replaceRelated));
421
            $data['relations'][$relation]['removeRelated'] = array_map(
422
                function ($id) {
423
                    return ['id' => $id, 'type' => 'folders'];
424
                },
425
                $rem
426
            );
427
        }
428
        unset($data['relations'][$relation]['replaceRelated']);
429
    }
430
431
    /**
432
     * Setup changed attributes to be saved.
433
     * Remove unchanged attributes from $data array.
434
     *
435
     * @param array $data Request data
436
     * @return void
437
     */
438
    protected function changedAttributes(array &$data): void
439
    {
440
        if (empty($data['_actualAttributes'])) {
441
            return;
442
        }
443
        $attributes = json_decode($data['_actualAttributes'], true);
444
        if ($attributes === null) {
445
            $this->log(sprintf('Wrong _actualAttributes, not a json string: %s', $data['_actualAttributes']), 'error');
446
            unset($data['_actualAttributes']);
447
448
            return;
449
        }
450
        foreach ($attributes as $key => $value) {
451
            if (!array_key_exists($key, $data)) {
452
                continue;
453
            }
454
            if ($data[$key] === Form::NULL_VALUE) {
455
                $data[$key] = null;
456
            }
457
            // remove unchanged attributes from $data
458
            if (!$this->hasFieldChanged($value, $data[$key], $key)) {
459
                unset($data[$key]);
460
            }
461
        }
462
        unset($data['_actualAttributes']);
463
    }
464
465
    /**
466
     * Return true if $oldValue equals $newValue or both are empty (null|'')
467
     *
468
     * @param mixed $oldValue The first value | field value in model data (db)
469
     * @param mixed $newValue The second value | field value from form
470
     * @param string $key The field key
471
     * @return bool
472
     */
473
    protected function hasFieldChanged($oldValue, $newValue, string $key): bool
474
    {
475
        if ($oldValue === $newValue) {
476
            return false; // not changed
477
        }
478
        if (($oldValue === null || $oldValue === '') && ($newValue === null || $newValue === '')) {
479
            return false; // not changed
480
        }
481
        if ($key === 'categories' || $key === 'tags') {
482
            return $this->Categories->hasChanged($oldValue, $newValue);
483
        }
484
        $booleanItems = ['0', '1', 'true', 'false', 0, 1];
485
        if (is_bool($oldValue) && !is_bool($newValue) && in_array($newValue, $booleanItems, true)) { // i.e. true / "1"
486
            return $oldValue !== boolval($newValue);
487
        }
488
        if (is_numeric($oldValue) && is_string($newValue)) {
489
            return (string)$oldValue !== $newValue;
490
        }
491
        if (is_string($oldValue) && is_numeric($newValue)) {
492
            return $oldValue !== (string)$newValue;
493
        }
494
495
        return $oldValue !== $newValue;
496
    }
497
498
    /**
499
     * Check request data by options.
500
     *
501
     *  - $options['allowedMethods']: check allowed method(s)
502
     *  - $options['requiredParameters']: check required parameter(s)
503
     *
504
     * @param array $options The options for request check(s)
505
     * @return array The request data for required parameters, if any
506
     * @throws \Cake\Http\Exception\BadRequestException on empty request or empty data by parameter
507
     */
508
    protected function checkRequest(array $options = []): array
509
    {
510
        // check allowed methods
511
        if (!empty($options['allowedMethods'])) {
512
            $this->getRequest()->allowMethod($options['allowedMethods']);
513
        }
514
515
        // check request required parameters, if any
516
        $data = [];
517
        if (!empty($options['requiredParameters'])) {
518
            foreach ($options['requiredParameters'] as $param) {
519
                $val = $this->getRequest()->getData($param);
520
                if (empty($val)) {
521
                    throw new BadRequestException(sprintf('Empty %s', $param));
522
                }
523
                $data[$param] = $val;
524
            }
525
        }
526
527
        return $data;
528
    }
529
530
    /**
531
     * Apply session filter (if any): if found, redirect properly.
532
     * Session key: '{$currentModuleName}.filter'
533
     * Scenarios:
534
     *
535
     * Query parameter 'reset=1': remove session key and redirect
536
     * Query parameters found: write them on session with proper key ({currentModuleName}.filter)
537
     * Session data for session key: build uri from session data and redirect to new uri.
538
     *
539
     * @return \Cake\Http\Response|null
540
     */
541
    protected function applySessionFilter(): ?Response
542
    {
543
        $session = $this->getRequest()->getSession();
544
        $sessionKey = sprintf('%s.filter', $this->Modules->getConfig('currentModuleName'));
545
546
        // if reset request, delete session data by key and redirect to proper uri
547
        if ($this->getRequest()->getQuery('reset') === '1') {
548
            $session->delete($sessionKey);
549
550
            return $this->redirect((string)$this->getRequest()->getUri()->withQuery(''));
551
        }
552
553
        // write request query parameters (if any) in session
554
        $params = $this->getRequest()->getQueryParams();
555
        if (!empty($params)) {
556
            unset($params['_search']);
557
            $session->write($sessionKey, $params);
558
559
            return null;
560
        }
561
562
        // read request query parameters from session and redirect to proper page
563
        $params = (array)$session->read($sessionKey);
564
        if (!empty($params)) {
565
            $query = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
566
567
            return $this->redirect((string)$this->getRequest()->getUri()->withQuery($query));
568
        }
569
570
        return null;
571
    }
572
573
    /**
574
     * Set objectNav array and objectNavModule.
575
     * Objects can be in different modules:
576
     *
577
     *  - a document is in "documents" and "objects" index
578
     *  - an image is in "images" and "media" index
579
     *  - etc.
580
     *
581
     * The session variable objectNavModule stores the last module index visited;
582
     * this is used then in controller view, to obtain the proper object nav (@see \App\Controller\AppController::getObjectNav)
583
     *
584
     * @param array $objects The objects to parse to set prev and next data
585
     * @return void
586
     */
587
    protected function setObjectNav($objects): void
588
    {
589
        $moduleName = $this->Modules->getConfig('currentModuleName');
590
        $total = count(array_keys($objects));
591
        $objectNav = [];
592
        /** @var int $i */
593
        foreach ($objects as $i => $object) {
594
            $objectNav[$moduleName][$object['id']] = [
595
                'prev' => $i > 0 ? Hash::get($objects, sprintf('%d.id', $i - 1)) : null,
596
                'next' => $i + 1 < $total ? Hash::get($objects, sprintf('%d.id', $i + 1)) : null,
597
                'index' => $i + 1,
598
                'total' => $total,
599
                'object_type' => Hash::get($objects, sprintf('%d.object_type', $i)),
600
            ];
601
        }
602
        $session = $this->getRequest()->getSession();
603
        $session->write('objectNav', $objectNav);
604
        $session->write('objectNavModule', $moduleName);
605
    }
606
607
    /**
608
     * Get objectNav for ID and current module name
609
     *
610
     * @param string $id The object ID
611
     * @return array
612
     */
613
    protected function getObjectNav($id): array
614
    {
615
        // get objectNav from session
616
        $session = $this->getRequest()->getSession();
617
        $objectNav = (array)$session->read('objectNav');
618
        if (empty($objectNav)) {
619
            return [];
620
        }
621
622
        // get objectNav by session objectNavModule
623
        $objectNavModule = (string)$session->read('objectNavModule');
624
625
        return (array)Hash::get($objectNav, sprintf('%s.%s', $objectNavModule, $id), []);
626
    }
627
628
    /**
629
     * Cake 4 compatibility wrapper method: set items to serialize for the view
630
     *
631
     * In Cake 3 => $this->set('_serialize', ['data']);
632
     * In Cake 4 => $this->viewBuilder()->setOption('serialize', ['data'])
633
     *
634
     * @param array $items Items to serialize
635
     * @return void
636
     * @codeCoverageIgnore
637
     */
638
    protected function setSerialize(array $items): void
639
    {
640
        $this->viewBuilder()->setOption('serialize', $items);
641
    }
642
643
    /**
644
     * Remove empty fields when saving new resource.
645
     *
646
     * @param array $data The form data
647
     * @return void
648
     */
649
    protected function filterEmpty(array &$data): void
650
    {
651
        if (!empty($data['id'])) {
652
            return;
653
        }
654
        $data = array_filter($data, function ($item) {
655
            return $item === '0' ? true : !empty($item);
656
        });
657
    }
658
}
659