Passed
Pull Request — master (#491)
by Stefano
04:01
created

AppController::hasFieldChanged()   B

Complexity

Conditions 8
Paths 4

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 8
eloc 7
nc 4
nop 2
dl 0
loc 13
rs 8.4444
c 0
b 0
f 0
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
 */
30
class AppController extends Controller
31
{
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->request->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($type, SecurityException $exception): void
0 ignored issues
show
Unused Code introduced by
The parameter $type is not used and could be removed. ( Ignorable by Annotation )

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

101
    public function blackhole(/** @scrutinizer ignore-unused */ $type, SecurityException $exception): void

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
102
    {
103
        // Log original exception
104
        $this->log($exception, 'error');
105
106
        // Log form data & session id
107
        $token = (array)$this->request->getData('_Token');
108
        unset($token['debug']);
109
        $this->log('[Blackhole] form token: ' . json_encode($token), 'debug');
110
        $this->log('[Blackhole] form fields: ' . json_encode(array_keys($this->request->getData())), 'debug');
0 ignored issues
show
Bug introduced by
It seems like $this->request->getData() can also be of type null; however, parameter $array of array_keys() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

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

110
        $this->log('[Blackhole] form fields: ' . json_encode(array_keys(/** @scrutinizer ignore-type */ $this->request->getData())), 'debug');
Loading history...
111
        $this->log('[Blackhole] form session id: ' . $this->request->getData('_session_id'), 'debug');
0 ignored issues
show
Bug introduced by
Are you sure $this->request->getData('_session_id') of type array|null|string can be used in concatenation? ( Ignorable by Annotation )

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

111
        $this->log('[Blackhole] form session id: ' . /** @scrutinizer ignore-type */ $this->request->getData('_session_id'), 'debug');
Loading history...
112
        $this->log('[Blackhole] current session id: ' . $this->request->getSession()->id(), 'debug');
113
114
        // Throw a generic bad request exception.
115
        throw new BadRequestException();
116
    }
117
118
    /**
119
     * Return route array for login redirect.
120
     * When request is not a get, return route without redirect.
121
     * When request uri path equals request attribute webroot (the app 'webroot'), return route without redirect.
122
     * Return route with redirect, otherwise.
123
     *
124
     * @return array
125
     */
126
    protected function loginRedirectRoute(): array
127
    {
128
        $route = ['_name' => 'login'];
129
130
        // if request is not a get, return route without redirect.
131
        if (!$this->request->is('get')) {
132
            return $route;
133
        }
134
135
        // if redirect is app webroot, return route without redirect.
136
        $redirect = $this->request->getUri()->getPath();
137
        if ($redirect === $this->request->getAttribute('webroot')) {
138
            return $route;
139
        }
140
141
        return $route + compact('redirect');
142
    }
143
144
    /**
145
     * Setup output timezone from user session
146
     *
147
     * @return void
148
     */
149
    protected function setupOutputTimezone(): void
150
    {
151
        $timezone = $this->Auth->user('timezone');
152
        if ($timezone) {
153
            Configure::write('I18n.timezone', $timezone);
154
        }
155
    }
156
157
    /**
158
     * {@inheritDoc}
159
     *
160
     * Update session tokens if updated/refreshed by client
161
     */
162
    public function beforeRender(Event $event): ?Response
163
    {
164
        if ($this->Auth && $this->Auth->user()) {
165
            $user = $this->Auth->user();
166
            $tokens = $this->apiClient->getTokens();
167
            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...
168
                // Update tokens in session.
169
                $user['tokens'] = $tokens;
170
                $this->Auth->setUser($user);
171
            }
172
173
            $this->set(compact('user'));
174
        }
175
176
        $this->viewBuilder()->setTemplatePath('Pages/' . $this->_viewPath());
177
178
        return null;
179
    }
180
181
    /**
182
     * Prepare request, set properly json data.
183
     *
184
     * @param string $type Object type
185
     * @return array request data
186
     */
187
    protected function prepareRequest($type): array
188
    {
189
        $data = (array)$this->request->getData();
190
191
        $this->specialAttributes($data);
192
        $this->setupParentsRelation($type, $data);
193
        $this->prepareRelations($data);
194
        $this->changedAttributes($data);
195
196
        // cleanup attributes on new objects/resources
197
        if (empty($data['id'])) {
198
            $data = array_filter($data);
199
        }
200
201
        return $data;
202
    }
203
204
    /**
205
     * Setup special attributes to be saved.
206
     *
207
     * @param array $data Request data
208
     * @return void
209
     */
210
    protected function specialAttributes(array &$data): void
211
    {
212
        // remove temporary session id
213
        unset($data['_session_id']);
214
215
        // if password is empty, unset it
216
        if (array_key_exists('password', $data) && empty($data['password'])) {
217
            unset($data['password']);
218
            unset($data['confirm-password']);
219
        }
220
221
        if (!empty($data['_jsonKeys'])) {
222
            $keys = explode(',', $data['_jsonKeys']);
223
            foreach ($keys as $key) {
224
                $data[$key] = json_decode($data[$key], true);
225
            }
226
            unset($data['_jsonKeys']);
227
        }
228
229
        // remove date_ranges items having empty both start & end dates
230
        if (!empty($data['date_ranges'])) {
231
            $data['date_ranges'] = array_filter(
232
                (array)$data['date_ranges'],
233
                function ($item) {
234
                    return !empty($item['start_date']) || !empty($item['end_date']);
235
                }
236
            );
237
        }
238
239
        // prepare categories
240
        if (!empty($data['categories'])) {
241
            $data['categories'] = array_map(function ($category) {
242
                return ['name' => $category];
243
            }, $data['categories']);
244
        }
245
    }
246
247
    /**
248
     * Prepare request relation data.
249
     *
250
     * @param array $data Request data
251
     * @return void
252
     */
253
    protected function prepareRelations(array &$data): void
254
    {
255
        // relations data for view/save - prepare api calls
256
        if (!empty($data['relations'])) {
257
            $api = [];
258
            foreach ($data['relations'] as $relation => $relationData) {
259
                $id = $data['id'];
260
                foreach ($relationData as $method => $ids) {
261
                    if (is_string($ids)) {
262
                        $relatedIds = json_decode($ids, true);
263
                    } else {
264
                        $relatedIds = array_map(
265
                            function ($id) {
266
                                return json_decode($id, true);
267
                            },
268
                            $ids
269
                        );
270
                    }
271
                    if ($method === 'replaceRelated' || !empty($relatedIds)) {
272
                        $api[] = compact('method', 'id', 'relation', 'relatedIds');
273
                    }
274
                }
275
            }
276
            $data['_api'] = $api;
277
        }
278
        unset($data['relations']);
279
    }
280
281
    /**
282
     * Handle `parents` or `parent` relationship looking at `_changedParents` input flag
283
     *
284
     * @param string $type Object type
285
     * @param array $data Form data
286
     * @return void
287
     */
288
    protected function setupParentsRelation(string $type, array &$data): void
289
    {
290
        $changedParents = (bool)Hash::get($data, '_changedParents');
291
        unset($data['_changedParents']);
292
        $relation = 'parents';
293
        if ($type === 'folders') {
294
            $relation = 'parent';
295
        }
296
        if (empty($changedParents)) {
297
            unset($data['relations'][$relation]);
298
299
            return;
300
        }
301
        if (empty($data['relations'][$relation])) {
302
            // all parents deselected => replace with empty set
303
            $data['relations'][$relation] = ['replaceRelated' => []];
304
        }
305
    }
306
307
    /**
308
     * Setup changed attributes to be saved.
309
     * Remove unchanged attributes from $data array.
310
     *
311
     * @param array $data Request data
312
     * @return void
313
     */
314
    protected function changedAttributes(array &$data): void
315
    {
316
        if (!empty($data['_actualAttributes'])) {
317
            $attributes = json_decode($data['_actualAttributes'], true);
318
            foreach ($attributes as $key => $value) {
319
                // remove unchanged attributes from $data
320
                if (array_key_exists($key, $data) && !$this->hasFieldChanged($value, $data[$key])) {
321
                    unset($data[$key]);
322
                }
323
            }
324
            unset($data['_actualAttributes']);
325
        }
326
    }
327
328
    /**
329
     * Return true if $value1 equals $value2 or both are empty (null|'')
330
     *
331
     * @param mixed $value1 The first value | field value in model data (db)
332
     * @param mixed $value2 The second value | field value from form
333
     * @return bool
334
     */
335
    protected function hasFieldChanged($value1, $value2)
336
    {
337
        if ($value1 === $value2) {
338
            return false; // not changed
339
        }
340
        if (($value1 === null || $value1 === '') && ($value2 === null || $value2 === '')) {
341
            return false; // not changed
342
        }
343
        if (is_bool($value1) && !is_bool($value2)) { // i.e. true / "1"
344
            return $value1 !== boolval($value2);
345
        }
346
347
        return $value1 !== $value2;
348
    }
349
350
    /**
351
     * Check request data by options.
352
     *
353
     *  - $options['allowedMethods']: check allowed method(s)
354
     *  - $options['requiredParameters']: check required parameter(s)
355
     *
356
     * @param array $options The options for request check(s)
357
     * @return array The request data for required parameters, if any
358
     * @throws Cake\Http\Exception\BadRequestException on empty request or empty data by parameter
359
     */
360
    protected function checkRequest(array $options = []): array
361
    {
362
        // check request
363
        if (empty($this->request)) {
364
            throw new BadRequestException('Empty request');
365
        }
366
367
        // check allowed methods
368
        if (!empty($options['allowedMethods'])) {
369
            $this->request->allowMethod($options['allowedMethods']);
370
        }
371
372
        // check request required parameters, if any
373
        $data = [];
374
        if (!empty($options['requiredParameters'])) {
375
            foreach ($options['requiredParameters'] as $param) {
376
                $val = $this->request->getData($param);
377
                if (empty($val)) {
378
                    throw new BadRequestException(sprintf('Empty %s', $param));
379
                }
380
                $data[$param] = $val;
381
            }
382
        }
383
384
        return $data;
385
    }
386
387
    /**
388
     * Apply session filter (if any): if found, redirect properly.
389
     * Session key: '{$currentModuleName}.filter'
390
     * Scenarios:
391
     *
392
     * Query parameter 'reset=1': remove session key and redirect
393
     * Query parameters found: write them on session with proper key ({currentModuleName}.filter)
394
     * Session data for session key: build uri from session data and redirect to new uri.
395
     *
396
     * @return \Cake\Http\Response|null
397
     */
398
    protected function applySessionFilter(): ?Response
399
    {
400
        $session = $this->request->getSession();
401
        $sessionKey = sprintf('%s.filter', $this->Modules->getConfig('currentModuleName'));
402
403
        // if reset request, delete session data by key and redirect to proper uri
404
        if ($this->request->getQuery('reset') === '1') {
405
            $session->delete($sessionKey);
406
407
            return $this->redirect((string)$this->request->getUri()->withQuery(''));
408
        }
409
410
        // write request query parameters (if any) in session
411
        $params = $this->request->getQueryParams();
412
        if (!empty($params)) {
413
            unset($params['_search']);
414
            $session->write($sessionKey, $params);
415
416
            return null;
417
        }
418
419
        // read request query parameters from session and redirect to proper page
420
        $params = (array)$session->read($sessionKey);
421
        if (!empty($params)) {
422
            $query = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
423
424
            return $this->redirect((string)$this->request->getUri()->withQuery($query));
425
        }
426
427
        return null;
428
    }
429
430
    /**
431
     * Set objectNav array and objectNavModule.
432
     * Objects can be in different modules:
433
     *
434
     *  - a document is in "documents" and "objects" index
435
     *  - an image is in "images" and "media" index
436
     *  - etc.
437
     *
438
     * The session variable objectNavModule stores the last module index visited;
439
     * this is used then in controller view, to obtain the proper object nav (@see \App\Controller\AppController::getObjectNav)
440
     *
441
     * @param array $objects The objects to parse to set prev and next data
442
     * @return void
443
     */
444
    protected function setObjectNav($objects): void
445
    {
446
        $moduleName = $this->Modules->getConfig('currentModuleName');
447
        $total = count(array_keys($objects));
448
        $objectNav = [];
449
        foreach ($objects as $i => $object) {
450
            $objectNav[$moduleName][$object['id']] = [
451
                'prev' => ($i > 0) ? Hash::get($objects, sprintf('%d.id', $i - 1)) : null,
452
                'next' => ($i + 1 < $total) ? Hash::get($objects, sprintf('%d.id', $i + 1)) : null,
453
                'index' => $i + 1,
454
                'total' => $total,
455
                'object_type' => Hash::get($objects, sprintf('%d.object_type', $i)),
456
            ];
457
        }
458
        $session = $this->request->getSession();
459
        $session->write('objectNav', $objectNav);
460
        $session->write('objectNavModule', $moduleName);
461
    }
462
463
    /**
464
     * Get objectNav for ID and current module name
465
     *
466
     * @param string $id The object ID
467
     * @return array
468
     */
469
    protected function getObjectNav($id): array
470
    {
471
        // get objectNav from session
472
        $session = $this->request->getSession();
473
        $objectNav = (array)$session->read('objectNav');
474
        if (empty($objectNav)) {
475
            return [];
476
        }
477
478
        // get objectNav by session objectNavModule
479
        $objectNavModule = (string)$session->read('objectNavModule');
480
481
        return (array)Hash::get($objectNav, sprintf('%s.%s', $objectNavModule, $id), []);
482
    }
483
}
484