Completed
Branch #338-Save_posting_before_movin... (60770e)
by Schlaefer
02:30
created

EntriesController::add()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
nc 1
nop 0
dl 0
loc 5
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * Saito - The Threaded Web Forum
7
 *
8
 * @copyright Copyright (c) the Saito Project Developers
9
 * @link https://github.com/Schlaefer/Saito
10
 * @license http://opensource.org/licenses/MIT
11
 */
12
13
namespace App\Controller;
14
15
use App\Controller\Component\AutoReloadComponent;
16
use App\Controller\Component\MarkAsReadComponent;
17
use App\Controller\Component\RefererComponent;
18
use App\Controller\Component\ThreadsComponent;
19
use App\Model\Entity\Entry;
20
use App\Model\Table\EntriesTable;
21
use Cake\Core\Configure;
22
use Cake\Event\Event;
23
use Cake\Http\Exception\BadRequestException;
24
use Cake\Http\Exception\ForbiddenException;
25
use Cake\Http\Exception\MethodNotAllowedException;
26
use Cake\Http\Exception\NotFoundException;
27
use Cake\Http\Response;
28
use Cake\Routing\RequestActionTrait;
29
use Saito\Exception\SaitoForbiddenException;
30
use Saito\Posting\Posting;
31
use Saito\Posting\PostingInterface;
32
use Saito\User\CurrentUser\CurrentUserInterface;
33
use Stopwatch\Lib\Stopwatch;
34
35
/**
36
 * Class EntriesController
37
 *
38
 * @property CurrentUserInterface $CurrentUser
39
 * @property EntriesTable $Entries
40
 * @property MarkAsReadComponent $MarkAsRead
41
 * @property RefererComponent $Referer
42
 * @property ThreadsComponent $Threads
43
 */
44
class EntriesController extends AppController
45
{
46
    use RequestActionTrait;
0 ignored issues
show
Deprecated Code introduced by
The trait Cake\Routing\RequestActionTrait has been deprecated with message: 3.3.0 Use view cells instead.

This class, trait or interface has been deprecated. The supplier of the file has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the type will be removed from the class and what other constant to use instead.

Loading history...
47
48
    public $helpers = ['Posting', 'Text'];
49
50
    public $actionAuthConfig = [
51
        'ajaxToggle' => 'mod',
52
        'merge' => 'mod',
53
        'delete' => 'mod'
54
    ];
55
56
    /**
57
     * {@inheritDoc}
58
     */
59
    public function initialize()
60
    {
61
        parent::initialize();
62
63
        $this->loadComponent('MarkAsRead');
64
        $this->loadComponent('Referer');
65
        $this->loadComponent('Threads');
66
    }
67
68
    /**
69
     * posting index
70
     *
71
     * @return void|\Cake\Network\Response
72
     */
73
    public function index()
74
    {
75
        Stopwatch::start('Entries->index()');
76
77
        //= determine user sort order
78
        $sortKey = 'last_answer';
79
        if (!$this->CurrentUser->get('user_sort_last_answer')) {
80
            $sortKey = 'time';
81
        }
82
        $order = ['fixed' => 'DESC', $sortKey => 'DESC'];
83
84
        //= get threads
85
        $threads = $this->Threads->paginate($order);
86
        $this->set('entries', $threads);
87
88
        $currentPage = (int)$this->request->getQuery('page') ?: 1;
89
        if ($currentPage > 1) {
90
            $this->set('titleForLayout', __('page') . ' ' . $currentPage);
91
        }
92
        if ($currentPage === 1) {
93
            if ($this->MarkAsRead->refresh()) {
94
                return $this->redirect(['action' => 'index']);
95
            }
96
            $this->MarkAsRead->next();
97
        }
98
99
        // @bogus
100
        $this->request->getSession()->write('paginator.lastPage', $currentPage);
101
        $this->set('showDisclaimer', true);
102
        $this->set('showBottomNavigation', true);
103
        $this->set('allowThreadCollapse', true);
104
        $this->Slidetabs->show();
105
106
        $this->_setupCategoryChooser($this->CurrentUser);
107
108
        /** @var AutoReloadComponent */
109
        $autoReload = $this->loadComponent('AutoReload');
110
        $autoReload->after($this->CurrentUser);
111
112
        Stopwatch::stop('Entries->index()');
113
    }
114
115
    /**
116
     * Mix view
117
     *
118
     * @param string $tid thread-ID
119
     * @return void|Response
120
     * @throws NotFoundException
121
     */
122
    public function mix($tid)
123
    {
124
        $tid = (int)$tid;
125
        if ($tid <= 0) {
126
            throw new BadRequestException();
127
        }
128
129
        $postings = $this->Entries->treeForNode(
130
            $tid,
131
            ['root' => true, 'complete' => true]
132
        );
133
134
        /// redirect sub-posting to mix view of thread
135
        if (!$postings) {
136
            $post = $this->Entries->find()
137
                ->select(['tid'])
138
                ->where(['id' => $tid])
139
                ->first();
140
            if (!empty($post)) {
141
                return $this->redirect([$post->get('tid'), '#' => $tid], 301);
142
            }
143
            throw new NotFoundException;
144
        }
145
146
        // check if anonymous tries to access internal categories
147
        $root = $postings;
148
        if (!$this->CurrentUser->getCategories()->permission('read', $root->get('category'))) {
149
            return $this->_requireAuth();
150
        }
151
152
        $this->_setRootEntry($root);
153
        $this->Title->setFromPosting($root, __('view.type.mix'));
154
155
        $this->set('showBottomNavigation', true);
156
        $this->set('entries', $postings);
157
158
        $this->_showAnsweringPanel();
159
160
        $this->Threads->incrementViews($root, 'thread');
161
        $this->MarkAsRead->thread($postings);
162
    }
163
164
    /**
165
     * load front page force all entries mark-as-read
166
     *
167
     * @return void
168
     */
169
    public function update()
170
    {
171
        $this->autoRender = false;
172
        $this->CurrentUser->getLastRefresh()->set();
173
        $this->redirect('/entries/index');
174
    }
175
176
    /**
177
     * Outputs raw markup of an posting $id
178
     *
179
     * @param string $id posting-ID
180
     * @return void
181
     */
182
    public function source($id = null)
183
    {
184
        $this->viewBuilder()->enableAutoLayout(false);
185
        $this->view($id);
186
    }
187
188
    /**
189
     * View posting.
190
     *
191
     * @param string $id posting-ID
192
     * @return \Cake\Network\Response|void
193
     */
194
    public function view($id = null)
195
    {
196
        Stopwatch::start('Entries->view()');
197
198
        // redirect if no id is given
199
        if (!$id) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $id of type string|null is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
200
            $this->Flash->set(__('Invalid post'), ['element' => 'error']);
201
202
            return $this->redirect(['action' => 'index']);
203
        }
204
205
        $entry = $this->Entries->get($id);
206
207
        // redirect if posting doesn't exists
208
        if ($entry == false) {
209
            $this->Flash->set(__('Invalid post'));
210
211
            return $this->redirect('/');
212
        }
213
214
        if (!$this->CurrentUser->getCategories()->permission('read', $entry->get('category'))) {
215
            return $this->_requireAuth();
216
        }
217
218
        $this->set('entry', $entry);
219
        $this->Threads->incrementViews($entry);
220
        $this->_setRootEntry($entry);
221
        $this->_showAnsweringPanel();
222
223
        $this->MarkAsRead->posting($entry);
224
225
        // inline open
226
        if ($this->request->is('ajax')) {
227
            return $this->render('/Element/entry/view_posting');
228
        }
229
230
        // full page request
231
        $this->set(
232
            'tree',
233
            $this->Entries->treeForNode($entry->get('tid'), ['root' => true])
234
        );
235
        $this->Title->setFromPosting($entry);
236
237
        Stopwatch::stop('Entries->view()');
238
    }
239
240
    /**
241
     * Add new posting.
242
     *
243
     * @return void|\Cake\Network\Response
244
     */
245
    public function add()
246
    {
247
        $titleForPage = __('Write a New Posting');
248
        $this->set(compact('titleForPage'));
249
    }
250
251
    /**
252
     * Edit posting
253
     *
254
     * @param string $id posting-ID
255
     * @return void|\Cake\Network\Response
256
     * @throws NotFoundException
257
     * @throws BadRequestException
258
     */
259
    public function edit($id = null)
260
    {
261
        if (empty($id)) {
262
            throw new BadRequestException;
263
        }
264
265
        /** @var PostingInterface */
266
        $posting = $this->Entries->get($id);
267
        if (!$posting) {
268
            throw new NotFoundException;
269
        }
270
271
        if (!$posting->isEditingAllowed()) {
272
            throw new SaitoForbiddenException(
273
                'Access to posting in EntriesController:edit() forbidden.',
274
                ['CurrentUser' => $this->CurrentUser]
275
            );
276
        }
277
278
        // show editing form
279
        if (!$posting->isEditingAsUserAllowed()) {
280
            $this->Flash->set(
281
                __('notice_you_are_editing_as_mod'),
282
                ['element' => 'warning']
283
            );
284
        }
285
286
        $this->set(compact('posting'));
287
288
        // set headers
289
        $this->set(
290
            'headerSubnavLeftTitle',
291
            __('back_to_posting_from_linkname', $posting->get('name'))
292
        );
293
        $this->set('headerSubnavLeftUrl', ['action' => 'view', $id]);
294
        $this->set('form_title', __('edit_linkname'));
295
        $this->render('/Entries/add');
296
    }
297
298
    /**
299
     * Get thread-line to insert after an inline-answer
300
     *
301
     * @param string $id posting-ID
302
     * @return void|\Cake\Network\Response
303
     */
304
    public function threadLine($id = null)
305
    {
306
        $posting = $this->Entries->get($id);
307
        if (!$this->CurrentUser->getCategories()->permission('read', $posting->get('category'))) {
308
            return $this->_requireAuth();
309
        }
310
311
        $this->set('entrySub', $posting);
312
        // ajax requests so far are always answers
313
        $this->response = $this->response->withType('json');
314
        $this->set('level', '1');
315
    }
316
317
    /**
318
     * Delete posting
319
     *
320
     * @param string $id posting-ID
321
     * @return void
322
     * @throws NotFoundException
323
     * @throws MethodNotAllowedException
324
     */
325
    public function delete($id = null)
326
    {
327
        //$this->request->allowMethod(['post', 'delete']);
328
        if (!$id) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $id of type string|null is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
329
            throw new NotFoundException;
330
        }
331
        /* @var Entry $posting */
332
        $posting = $this->Entries->get($id);
333
        if (!$posting) {
334
            throw new NotFoundException;
335
        }
336
337
        $success = $this->Entries->treeDeleteNode($id);
338
339
        if ($success) {
340
            $flashType = 'success';
341
            if ($posting->isRoot()) {
342
                $message = __('delete_tree_success');
343
                $redirect = '/';
344
            } else {
345
                $message = __('delete_subtree_success');
346
                $redirect = '/entries/view/' . $posting->get('pid');
347
            }
348
        } else {
349
            $flashType = 'error';
350
            $message = __('delete_tree_error');
351
            $redirect = $this->referer();
352
        }
353
        $this->Flash->set($message, ['element' => $flashType]);
354
        $this->redirect($redirect);
355
    }
356
357
    /**
358
     * Empty function for benchmarking
359
     *
360
     * @return void
361
     */
362
    public function e()
363
    {
364
        Stopwatch::start('Entries->e()');
365
        Stopwatch::stop('Entries->e()');
366
    }
367
368
    /**
369
     * Marks sub-entry $id as solution to its current root-entry
370
     *
371
     * @param string $id posting-ID
372
     * @return void
373
     * @throws BadRequestException
374
     */
375
    public function solve($id)
376
    {
377
        $this->autoRender = false;
378
        try {
379
            $posting = $this->Entries->get($id, ['return' => 'Entity']);
380
381
            if (empty($posting)) {
382
                throw new \InvalidArgumentException('Posting to mark solved not found.');
383
            }
384
385
            if ($posting->isRoot()) {
386
                throw new \InvalidArgumentException('Root postings cannot mark themself solved.');
387
            }
388
389
            $rootId = $posting->get('tid');
390
            $rootPosting = $this->Entries->get($rootId);
391
            if ($rootPosting->get('user_id') !== $this->CurrentUser->getId()) {
392
                throw new SaitoForbiddenException(
393
                    sprintf('Attempt to mark posting %s as solution.', $posting->get('id')),
394
                    ['CurrentUser' => $this->CurrentUser]
395
                );
396
            }
397
398
            $success = $this->Entries->toggleSolve($posting);
399
400
            if (!$success) {
401
                throw new BadRequestException;
402
            }
403
        } catch (\Exception $e) {
404
            throw new BadRequestException();
405
        }
406
    }
407
408
    /**
409
     * Merge threads.
410
     *
411
     * @param string $sourceId posting-ID of thread to be merged
412
     * @return void
413
     * @throws NotFoundException
414
     * @td put into admin entries controller
415
     */
416
    public function merge($sourceId = null)
417
    {
418
        if (!$sourceId) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $sourceId of type string|null is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
419
            throw new NotFoundException();
420
        }
421
422
        /* @var Entry */
423
        $posting = $this->Entries->findById($sourceId)->first();
0 ignored issues
show
Documentation Bug introduced by
The method findById does not exist on object<App\Model\Table\EntriesTable>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
424
425
        if (!$posting || !$posting->isRoot()) {
426
            throw new NotFoundException();
427
        }
428
429
        // perform move operation
430
        $targetId = $this->request->getData('targetId');
431
        if (!empty($targetId)) {
432 View Code Duplication
            if ($this->Entries->threadMerge($sourceId, $targetId)) {
0 ignored issues
show
Documentation introduced by
$targetId is of type string|array, but the function expects a integer.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
433
                $this->redirect('/entries/view/' . $sourceId);
434
435
                return;
436
            } else {
437
                $this->Flash->set(__('Error'), ['element' => 'error']);
438
            }
439
        }
440
441
        $this->viewBuilder()->setLayout('Admin.admin');
442
        $this->set(compact('posting'));
443
    }
444
445
    /**
446
     * Toggle posting property via ajax request.
447
     *
448
     * @param string $id posting-ID
449
     * @param string $toggle property
450
     *
451
     * @return \Cake\Network\Response
452
     */
453
    public function ajaxToggle($id = null, $toggle = null)
454
    {
455
        $allowed = ['fixed', 'locked'];
456
        if (!$id
0 ignored issues
show
Bug Best Practice introduced by
The expression $id of type string|null is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
457
            || !$toggle
0 ignored issues
show
Bug Best Practice introduced by
The expression $toggle of type string|null is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
458
            || !$this->request->is('ajax')
459
            || !in_array($toggle, $allowed)
460
        ) {
461
            throw new BadRequestException;
462
        }
463
464
        $current = $this->Entries->toggle((int)$id, $toggle);
465
        if ($current) {
466
            $out['html'] = __d('nondynamic', $toggle . '_unset_entry_link');
0 ignored issues
show
Coding Style Comprehensibility introduced by
$out was never initialized. Although not strictly required by PHP, it is generally a good practice to add $out = array(); before regardless.

Adding an explicit array definition is generally preferable to implicit array definition as it guarantees a stable state of the code.

Let’s take a look at an example:

foreach ($collection as $item) {
    $myArray['foo'] = $item->getFoo();

    if ($item->hasBar()) {
        $myArray['bar'] = $item->getBar();
    }

    // do something with $myArray
}

As you can see in this example, the array $myArray is initialized the first time when the foreach loop is entered. You can also see that the value of the bar key is only written conditionally; thus, its value might result from a previous iteration.

This might or might not be intended. To make your intention clear, your code more readible and to avoid accidental bugs, we recommend to add an explicit initialization $myArray = array() either outside or inside the foreach loop.

Loading history...
467
        } else {
468
            $out['html'] = __d('nondynamic', $toggle . '_set_entry_link');
0 ignored issues
show
Coding Style Comprehensibility introduced by
$out was never initialized. Although not strictly required by PHP, it is generally a good practice to add $out = array(); before regardless.

Adding an explicit array definition is generally preferable to implicit array definition as it guarantees a stable state of the code.

Let’s take a look at an example:

foreach ($collection as $item) {
    $myArray['foo'] = $item->getFoo();

    if ($item->hasBar()) {
        $myArray['bar'] = $item->getBar();
    }

    // do something with $myArray
}

As you can see in this example, the array $myArray is initialized the first time when the foreach loop is entered. You can also see that the value of the bar key is only written conditionally; thus, its value might result from a previous iteration.

This might or might not be intended. To make your intention clear, your code more readible and to avoid accidental bugs, we recommend to add an explicit initialization $myArray = array() either outside or inside the foreach loop.

Loading history...
469
        }
470
471
        $this->response = $this->response->withType('json');
472
        $this->response = $this->response->withStringBody(json_encode($out));
473
474
        return $this->response;
475
    }
476
477
    /**
478
     * {@inheritDoc}
479
     */
480
    public function beforeFilter(Event $event)
481
    {
482
        parent::beforeFilter($event);
483
        Stopwatch::start('Entries->beforeFilter()');
484
485
        $this->Security->setConfig(
486
            'unlockedActions',
487
            ['solve', 'view']
488
        );
489
        $this->Auth->allow(['index', 'view', 'mix', 'update']);
490
491
        Stopwatch::stop('Entries->beforeFilter()');
492
    }
493
494
    /**
495
     * set view vars for category chooser
496
     *
497
     * @param CurrentUserInterface $User CurrentUser
498
     * @return void
499
     */
500
    protected function _setupCategoryChooser(CurrentUserInterface $User)
501
    {
502
        if (!$User->isLoggedIn()) {
503
            return;
504
        }
505
        $globalActivation = Configure::read(
506
            'Saito.Settings.category_chooser_global'
507
        );
508
        if (!$globalActivation) {
509
            if (!Configure::read(
510
                'Saito.Settings.category_chooser_user_override'
511
            )
512
            ) {
513
                return;
514
            }
515
            if (!$User->get('user_category_override')) {
516
                return;
517
            }
518
        }
519
520
        $this->set(
521
            'categoryChooserChecked',
522
            $User->getCategories()->getCustom('read')
523
        );
524
        switch ($User->getCategories()->getType()) {
525
            case 'single':
526
                $title = $User->get('user_category_active');
527
                break;
528
            case 'custom':
529
                $title = __('Custom');
530
                break;
531
            default:
532
                $title = __('All Categories');
533
        }
534
        $this->set('categoryChooserTitleId', $title);
535
        $this->set(
536
            'categoryChooser',
537
            $User->getCategories()->getAll('read', 'list')
538
        );
539
    }
540
541
    /**
542
     * Decide if an answering panel is show when rendering a posting
543
     *
544
     * @return void
545
     */
546
    protected function _showAnsweringPanel()
547
    {
548
        $showAnsweringPanel = false;
549
550
        if ($this->CurrentUser->isLoggedIn()) {
551
            // Only logged in users see the answering buttons if they …
552
            if (// … directly on entries/view but not inline
553
                ($this->request->getParam('action') === 'view' && !$this->request->is('ajax'))
554
                // … directly in entries/mix
555
                || $this->request->getParam('action') === 'mix'
556
                // … inline viewing … on entries/index.
557
                || ($this->Referer->wasController('entries')
558
                    && $this->Referer->wasAction('index'))
559
            ) {
560
                $showAnsweringPanel = true;
561
            }
562
        }
563
        $this->set('showAnsweringPanel', $showAnsweringPanel);
564
    }
565
566
    /**
567
     * makes root posting of $posting avaiable in view
568
     *
569
     * @param Posting $posting posting for root entry
570
     * @return void
571
     */
572
    protected function _setRootEntry(Posting $posting)
573
    {
574
        if (!$posting->isRoot()) {
575
            $root = $this->Entries->find()
576
                ->select(['user_id'])
577
                ->where(['id' => $posting->get('tid')])
578
                ->first();
579
        } else {
580
            $root = $posting;
581
        }
582
        $this->set('rootEntry', $root);
583
    }
584
}
585