Passed
Pull Request — master (#701)
by Stefano
03:22
created

AppController::setSerialize()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 1
c 0
b 0
f 0
dl 0
loc 3
rs 10
cc 1
nc 1
nop 1
1
<?php
2
/**
3
 * BEdita, API-first content management framework
4
 * Copyright 2019 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 BEdita\WebTools\ApiClientProvider;
16
use Cake\Controller\Controller;
17
use Cake\Controller\Exception\SecurityException;
18
use Cake\Core\Configure;
19
use Cake\Event\Event;
20
use Cake\Http\Exception\BadRequestException;
21
use Cake\Http\Response;
22
use Cake\Utility\Hash;
23
24
/**
25
 * Base Application Controller.
26
 *
27
 * @property \App\Controller\Component\ModulesComponent $Modules
28
 * @property \App\Controller\Component\SchemaComponent $Schema
29
 * @property \App\Controller\Component\FlashComponent $Flash
30
 */
31
class AppController extends Controller
32
{
33
    /**
34
     * BEdita4 API client
35
     *
36
     * @var \BEdita\SDK\BEditaClient
37
     */
38
    protected $apiClient = null;
39
40
    /**
41
     * {@inheritDoc}
42
     */
43
    public function initialize(): void
44
    {
45
        parent::initialize();
46
47
        $this->loadComponent('RequestHandler', ['enableBeforeRedirect' => false]);
48
        $this->loadComponent('App.Flash', ['clear' => true]);
49
        $this->loadComponent('Security');
50
51
        // API config may not be set in `login` for a multi-project setup
52
        if (Configure::check('API.apiBaseUrl')) {
53
            $this->apiClient = ApiClientProvider::getApiClient();
54
        }
55
56
        $this->loadComponent('Auth', [
57
            'authenticate' => [
58
                'BEdita/WebTools.Api' => [],
59
            ],
60
            'loginAction' => ['_name' => 'login'],
61
            'loginRedirect' => ['_name' => 'dashboard'],
62
        ]);
63
64
        $this->Auth->deny();
65
66
        $this->loadComponent('Modules', [
67
            'currentModuleName' => $this->name,
68
        ]);
69
        $this->loadComponent('Schema');
70
    }
71
72
    /**
73
     * {@inheritDoc}
74
     */
75
    public function beforeFilter(Event $event): ?Response
76
    {
77
        $tokens = $this->Auth->user('tokens');
78
        if (!empty($tokens)) {
79
            $this->apiClient->setupTokens($tokens);
80
        } elseif (!in_array($this->getRequest()->getPath(), ['/login'])) {
81
            $route = $this->loginRedirectRoute();
82
            $this->Flash->error(__('Login required'));
83
84
            return $this->redirect($route);
85
        }
86
        $this->setupOutputTimezone();
87
        $this->Security->setConfig('blackHoleCallback', 'blackhole');
88
89
        return null;
90
    }
91
92
    /**
93
     * Handle security blackhole with logs for now
94
     *
95
     * @param string $type Excepion type
96
     * @param SecurityException $exception Raised exception
97
     * @return void
98
     * @throws \Cake\Http\Exception\BadRequestException
99
     * @codeCoverageIgnore
100
     */
101
    public function blackhole(string $type, SecurityException $exception): void
102
    {
103
        // Log original exception
104
        $this->log($exception->getMessage(), 'error');
105
106
        // Log form data & session id
107
        $token = (array)$this->getRequest()->getData('_Token');
108
        unset($token['debug']);
109
        $this->log('[Blackhole] type: ' . $type, 'debug');
110
        $this->log('[Blackhole] form token: ' . json_encode($token), 'debug');
111
        $this->log('[Blackhole] form fields: ' . json_encode(array_keys((array)$this->getRequest()->getData())), 'debug');
112
        $this->log('[Blackhole] form session id: ' . (string)$this->getRequest()->getData('_session_id'), 'debug');
113
        $sessionId = $this->getRequest()->getSession() ? $this->getRequest()->getSession()->id() : null;
114
        $this->log('[Blackhole] current session id: ' . $sessionId, 'debug');
115
116
        // Throw a generic bad request exception.
117
        throw new BadRequestException();
118
    }
119
120
    /**
121
     * Return route array for login redirect.
122
     * When request is not a get, return route without redirect.
123
     * When request uri path equals request attribute webroot (the app 'webroot'), return route without redirect.
124
     * Return route with redirect, otherwise.
125
     *
126
     * @return array
127
     */
128
    protected function loginRedirectRoute(): array
129
    {
130
        $route = ['_name' => 'login'];
131
132
        // if request is not a get, return route without redirect.
133
        if (!$this->getRequest()->is('get')) {
134
            return $route;
135
        }
136
137
        // if redirect is app webroot, return route without redirect.
138
        $redirect = $this->getRequest()->getUri()->getPath();
139
        if ($redirect === $this->getRequest()->getAttribute('webroot')) {
140
            return $route;
141
        }
142
143
        return $route + compact('redirect');
144
    }
145
146
    /**
147
     * Setup output timezone from user session
148
     *
149
     * @return void
150
     */
151
    protected function setupOutputTimezone(): void
152
    {
153
        $timezone = $this->Auth->user('timezone');
154
        if ($timezone) {
155
            Configure::write('I18n.timezone', $timezone);
156
        }
157
    }
158
159
    /**
160
     * {@inheritDoc}
161
     *
162
     * Update session tokens if updated/refreshed by client
163
     */
164
    public function beforeRender(Event $event): ?Response
165
    {
166
        if ($this->Auth && $this->Auth->user()) {
167
            $user = $this->Auth->user();
168
            $tokens = $this->apiClient->getTokens();
169
            if ($tokens && $user['tokens'] !== $tokens) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $tokens of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
170
                // Update tokens in session.
171
                $user['tokens'] = $tokens;
172
                $this->Auth->setUser($user);
173
            }
174
175
            $this->set(compact('user'));
176
        }
177
178
        $path = $this->viewBuilder()->getTemplatePath();
179
        $this->viewBuilder()->setTemplatePath('Pages/' . $path);
180
181
        return null;
182
    }
183
184
    /**
185
     * Prepare request, set properly json data.
186
     *
187
     * @param string $type Object type
188
     * @return array request data
189
     */
190
    protected function prepareRequest($type): array
191
    {
192
        $data = (array)$this->getRequest()->getData();
193
194
        $this->specialAttributes($data);
195
        $this->setupParentsRelation($type, $data);
196
        $this->prepareRelations($data);
197
        $this->changedAttributes($data);
198
199
        // cleanup attributes on new objects/resources
200
        if (empty($data['id'])) {
201
            $data = array_filter($data);
202
        }
203
204
        return $data;
205
    }
206
207
    /**
208
     * Setup special attributes to be saved.
209
     *
210
     * @param array $data Request data
211
     * @return void
212
     */
213
    protected function specialAttributes(array &$data): void
214
    {
215
        // remove temporary session id
216
        unset($data['_session_id']);
217
218
        // if password is empty, unset it
219
        if (array_key_exists('password', $data) && empty($data['password'])) {
220
            unset($data['password']);
221
            unset($data['confirm-password']);
222
        }
223
224
        if (!empty($data['_jsonKeys'])) {
225
            $keys = explode(',', $data['_jsonKeys']);
226
            foreach ($keys as $key) {
227
                $data[$key] = json_decode($data[$key], true);
228
            }
229
            unset($data['_jsonKeys']);
230
        }
231
232
        // remove date_ranges items having empty both start & end dates
233
        if (!empty($data['date_ranges'])) {
234
            $data['date_ranges'] = array_filter(
235
                (array)$data['date_ranges'],
236
                function ($item) {
237
                    return !empty($item['start_date']) || !empty($item['end_date']);
238
                }
239
            );
240
        }
241
242
        // prepare categories
243
        if (!empty($data['categories'])) {
244
            $data['categories'] = array_map(function ($category) {
245
                return ['name' => $category];
246
            }, $data['categories']);
247
        }
248
249
        // decode json fields
250
        $types = (array)Hash::get($data, '_types');
251
        if (!empty($types)) {
252
            foreach ($types as $field => $type) {
253
                if ($type === 'json') {
254
                    $data[$field] = json_decode($data[$field], true);
255
                }
256
            }
257
            unset($data['_types']);
258
        }
259
    }
260
261
    /**
262
     * Prepare request relation data.
263
     *
264
     * @param array $data Request data
265
     * @return void
266
     */
267
    protected function prepareRelations(array &$data): void
268
    {
269
        // relations data for view/save - prepare api calls
270
        if (!empty($data['relations'])) {
271
            $api = [];
272
            foreach ($data['relations'] as $relation => $relationData) {
273
                $id = $data['id'];
274
                foreach ($relationData as $method => $ids) {
275
                    if (is_string($ids)) {
276
                        $relatedIds = json_decode($ids, true);
277
                    } else {
278
                        $relatedIds = array_map(
279
                            function ($id) {
280
                                return json_decode($id, true);
281
                            },
282
                            $ids
283
                        );
284
                    }
285
                    if ($method === 'replaceRelated' || !empty($relatedIds)) {
286
                        $api[] = compact('method', 'id', 'relation', 'relatedIds');
287
                    }
288
                }
289
            }
290
            $data['_api'] = $api;
291
        }
292
        unset($data['relations']);
293
    }
294
295
    /**
296
     * Handle `parents` or `parent` relationship looking at `_changedParents` input flag
297
     *
298
     * @param string $type Object type
299
     * @param array $data Form data
300
     * @return void
301
     */
302
    protected function setupParentsRelation(string $type, array &$data): void
303
    {
304
        $changedParents = (bool)Hash::get($data, '_changedParents');
305
        unset($data['_changedParents']);
306
        $relation = 'parents';
307
        if ($type === 'folders') {
308
            $relation = 'parent';
309
        }
310
        if (empty($changedParents)) {
311
            unset($data['relations'][$relation]);
312
313
            return;
314
        }
315
        if (empty($data['relations'][$relation])) {
316
            // all parents deselected => replace with empty set
317
            $data['relations'][$relation] = ['replaceRelated' => []];
318
        }
319
    }
320
321
    /**
322
     * Setup changed attributes to be saved.
323
     * Remove unchanged attributes from $data array.
324
     *
325
     * @param array $data Request data
326
     * @return void
327
     */
328
    protected function changedAttributes(array &$data): void
329
    {
330
        if (!empty($data['_actualAttributes'])) {
331
            $attributes = json_decode($data['_actualAttributes'], true);
332
            if ($attributes === null) {
333
                $this->log(sprintf('Wrong _actualAttributes, not a json string: %s', $data['_actualAttributes']), 'error');
334
                $attributes = [];
335
            }
336
            foreach ($attributes as $key => $value) {
337
                // remove unchanged attributes from $data
338
                if (array_key_exists($key, $data) && !$this->hasFieldChanged($value, $data[$key])) {
339
                    unset($data[$key]);
340
                }
341
            }
342
            unset($data['_actualAttributes']);
343
        }
344
    }
345
346
    /**
347
     * Return true if $value1 equals $value2 or both are empty (null|'')
348
     *
349
     * @param mixed $value1 The first value | field value in model data (db)
350
     * @param mixed $value2 The second value | field value from form
351
     * @return bool
352
     */
353
    protected function hasFieldChanged($value1, $value2): bool
354
    {
355
        if ($value1 === $value2) {
356
            return false; // not changed
357
        }
358
        if (($value1 === null || $value1 === '') && ($value2 === null || $value2 === '')) {
359
            return false; // not changed
360
        }
361
        if (is_bool($value1) && !is_bool($value2)) { // i.e. true / "1"
362
            return $value1 !== boolval($value2);
363
        }
364
        if (is_numeric($value1) && is_string($value2)) {
365
            return (string)$value1 !== $value2;
366
        }
367
        if (is_string($value1) && is_numeric($value2)) {
368
            return $value1 !== (string)$value2;
369
        }
370
371
        return $value1 !== $value2;
372
    }
373
374
    /**
375
     * Check request data by options.
376
     *
377
     *  - $options['allowedMethods']: check allowed method(s)
378
     *  - $options['requiredParameters']: check required parameter(s)
379
     *
380
     * @param array $options The options for request check(s)
381
     * @return array The request data for required parameters, if any
382
     * @throws Cake\Http\Exception\BadRequestException on empty request or empty data by parameter
383
     */
384
    protected function checkRequest(array $options = []): array
385
    {
386
        // check allowed methods
387
        if (!empty($options['allowedMethods'])) {
388
            $this->getRequest()->allowMethod($options['allowedMethods']);
389
        }
390
391
        // check request required parameters, if any
392
        $data = [];
393
        if (!empty($options['requiredParameters'])) {
394
            foreach ($options['requiredParameters'] as $param) {
395
                $val = $this->getRequest()->getData($param);
396
                if (empty($val)) {
397
                    throw new BadRequestException(sprintf('Empty %s', $param));
398
                }
399
                $data[$param] = $val;
400
            }
401
        }
402
403
        return $data;
404
    }
405
406
    /**
407
     * Apply session filter (if any): if found, redirect properly.
408
     * Session key: '{$currentModuleName}.filter'
409
     * Scenarios:
410
     *
411
     * Query parameter 'reset=1': remove session key and redirect
412
     * Query parameters found: write them on session with proper key ({currentModuleName}.filter)
413
     * Session data for session key: build uri from session data and redirect to new uri.
414
     *
415
     * @return \Cake\Http\Response|null
416
     */
417
    protected function applySessionFilter(): ?Response
418
    {
419
        $session = $this->getRequest()->getSession();
420
        $sessionKey = sprintf('%s.filter', $this->Modules->getConfig('currentModuleName'));
421
422
        // if reset request, delete session data by key and redirect to proper uri
423
        if ($this->getRequest()->getQuery('reset') === '1') {
424
            $session->delete($sessionKey);
425
426
            return $this->redirect((string)$this->getRequest()->getUri()->withQuery(''));
427
        }
428
429
        // write request query parameters (if any) in session
430
        $params = $this->getRequest()->getQueryParams();
431
        if (!empty($params)) {
432
            unset($params['_search']);
433
            $session->write($sessionKey, $params);
434
435
            return null;
436
        }
437
438
        // read request query parameters from session and redirect to proper page
439
        $params = (array)$session->read($sessionKey);
440
        if (!empty($params)) {
441
            $query = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
442
443
            return $this->redirect((string)$this->getRequest()->getUri()->withQuery($query));
444
        }
445
446
        return null;
447
    }
448
449
    /**
450
     * Set objectNav array and objectNavModule.
451
     * Objects can be in different modules:
452
     *
453
     *  - a document is in "documents" and "objects" index
454
     *  - an image is in "images" and "media" index
455
     *  - etc.
456
     *
457
     * The session variable objectNavModule stores the last module index visited;
458
     * this is used then in controller view, to obtain the proper object nav (@see \App\Controller\AppController::getObjectNav)
459
     *
460
     * @param array $objects The objects to parse to set prev and next data
461
     * @return void
462
     */
463
    protected function setObjectNav($objects): void
464
    {
465
        $moduleName = $this->Modules->getConfig('currentModuleName');
466
        $total = count(array_keys($objects));
467
        $objectNav = [];
468
        foreach ($objects as $i => $object) {
469
            $objectNav[$moduleName][$object['id']] = [
470
                'prev' => ($i > 0) ? Hash::get($objects, sprintf('%d.id', $i - 1)) : null,
471
                'next' => ($i + 1 < $total) ? Hash::get($objects, sprintf('%d.id', $i + 1)) : null,
472
                'index' => $i + 1,
473
                'total' => $total,
474
                'object_type' => Hash::get($objects, sprintf('%d.object_type', $i)),
475
            ];
476
        }
477
        $session = $this->getRequest()->getSession();
478
        $session->write('objectNav', $objectNav);
479
        $session->write('objectNavModule', $moduleName);
480
    }
481
482
    /**
483
     * Get objectNav for ID and current module name
484
     *
485
     * @param string $id The object ID
486
     * @return array
487
     */
488
    protected function getObjectNav($id): array
489
    {
490
        // get objectNav from session
491
        $session = $this->getRequest()->getSession();
492
        $objectNav = (array)$session->read('objectNav');
493
        if (empty($objectNav)) {
494
            return [];
495
        }
496
497
        // get objectNav by session objectNavModule
498
        $objectNavModule = (string)$session->read('objectNavModule');
499
500
        return (array)Hash::get($objectNav, sprintf('%s.%s', $objectNavModule, $id), []);
501
    }
502
503
    /**
504
     * Cake 4 compatibility wrapper method: set items to serialize for the view
505
     *
506
     * In Cake 3 => $this->set('_serialize', ['data']);
507
     * In Cake 4 => $this->viewBuilder()->setOption('serialize', ['data'])
508
     *
509
     * @param array $items Items to serialize
510
     * @return void
511
     * @codeCoverageIgnore
512
     */
513
    protected function setSerialize(array $items): void
514
    {
515
        $this->set('_serialize', $items);
516
    }
517
}
518