Passed
Pull Request — master (#1332)
by Dante
01:45
created

AppController::setupOutputTimezone()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 14
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

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