Passed
Push — master ( 6407ea...00c9b0 )
by Stefano
01:20
created

AppController::changedAttributes()   B

Complexity

Conditions 7
Paths 11

Size

Total Lines 18
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

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