Passed
Push — develop ( 2ed109...a94368 )
by Schlaefer
05:00
created

EntriesController::merge()   A

Complexity

Conditions 6
Paths 5

Size

Total Lines 28
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
eloc 15
c 1
b 0
f 0
nc 5
nop 1
dl 0
loc 28
rs 9.2222
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\PostingComponent;
18
use App\Controller\Component\RefererComponent;
19
use App\Controller\Component\ThreadsComponent;
20
use App\Model\Table\EntriesTable;
21
use Cake\Core\Configure;
22
use Cake\Datasource\Exception\RecordNotFoundException;
23
use Cake\Event\Event;
24
use Cake\Http\Exception\BadRequestException;
25
use Cake\Http\Exception\ForbiddenException;
26
use Cake\Http\Exception\MethodNotAllowedException;
27
use Cake\Http\Exception\NotFoundException;
28
use Cake\Http\Response;
29
use Cake\Routing\RequestActionTrait;
30
use Saito\Exception\SaitoForbiddenException;
31
use Saito\Posting\Basic\BasicPostingInterface;
32
use Saito\User\CurrentUser\CurrentUserInterface;
33
use Saito\User\Permission\ResourceAI;
34
use Stopwatch\Lib\Stopwatch;
35
36
/**
37
 * Class EntriesController
38
 *
39
 * @property CurrentUserInterface $CurrentUser
40
 * @property EntriesTable $Entries
41
 * @property MarkAsReadComponent $MarkAsRead
42
 * @property PostingComponent $Posting
43
 * @property RefererComponent $Referer
44
 * @property ThreadsComponent $Threads
45
 */
46
class EntriesController extends AppController
47
{
48
    use RequestActionTrait;
0 ignored issues
show
Deprecated Code introduced by
The trait Cake\Routing\RequestActionTrait has been deprecated: 3.3.0 Use view cells instead. ( Ignorable by Annotation )

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

48
    use /** @scrutinizer ignore-deprecated */ RequestActionTrait;

This trait has been deprecated. The supplier of the trait has supplied an explanatory message.

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

Loading history...
49
50
    public $helpers = ['Posting', 'Text'];
51
52
    /**
53
     * {@inheritDoc}
54
     */
55
    public function initialize()
56
    {
57
        parent::initialize();
58
59
        $this->loadComponent('Posting');
60
        $this->loadComponent('MarkAsRead');
61
        $this->loadComponent('Referer');
62
        $this->loadComponent('Threads', ['table' => $this->Entries]);
63
    }
64
65
    /**
66
     * posting index
67
     *
68
     * @return void|\Cake\Network\Response
69
     */
70
    public function index()
71
    {
72
        Stopwatch::start('Entries->index()');
73
74
        //= determine user sort order
75
        $sortKey = 'last_answer';
76
        if (!$this->CurrentUser->get('user_sort_last_answer')) {
77
            $sortKey = 'time';
78
        }
79
        $order = ['fixed' => 'DESC', $sortKey => 'DESC'];
80
81
        //= get threads
82
        $threads = $this->Threads->paginate($order, $this->CurrentUser);
83
        $this->set('entries', $threads);
84
85
        $currentPage = (int)$this->request->getQuery('page') ?: 1;
86
        if ($currentPage > 1) {
87
            $this->set('titleForLayout', __('page') . ' ' . $currentPage);
88
        }
89
        if ($currentPage === 1) {
90
            if ($this->MarkAsRead->refresh()) {
91
                return $this->redirect(['action' => 'index']);
92
            }
93
            $this->MarkAsRead->next();
94
        }
95
96
        // @bogus
97
        $this->request->getSession()->write('paginator.lastPage', $currentPage);
98
        $this->set('showDisclaimer', true);
99
        $this->set('showBottomNavigation', true);
100
        $this->set('allowThreadCollapse', true);
101
        $this->Slidetabs->show();
102
103
        $this->_setupCategoryChooser($this->CurrentUser);
104
105
        /** @var AutoReloadComponent */
106
        $autoReload = $this->loadComponent('AutoReload');
107
        $autoReload->after($this->CurrentUser);
0 ignored issues
show
Bug introduced by
The method after() does not exist on Cake\Controller\Component. It seems like you code against a sub-type of Cake\Controller\Component such as Cake\Controller\Component\FlashComponent or App\Controller\Component\AutoReloadComponent or Cake\Controller\Component\PaginatorComponent. ( Ignorable by Annotation )

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

107
        $autoReload->/** @scrutinizer ignore-call */ 
108
                     after($this->CurrentUser);
Loading history...
108
109
        Stopwatch::stop('Entries->index()');
110
    }
111
112
    /**
113
     * Mix view
114
     *
115
     * @param string $tid thread-ID
116
     * @return void|Response
117
     * @throws NotFoundException
118
     */
119
    public function mix($tid)
120
    {
121
        $tid = (int)$tid;
122
        if ($tid <= 0) {
123
            throw new BadRequestException();
124
        }
125
126
        try {
127
            $postings = $this->Entries->postingsForThread($tid, true, $this->CurrentUser);
128
        } catch (RecordNotFoundException $e) {
129
            /// redirect sub-posting to mix view of thread
130
            $actualTid = $this->Entries->getThreadId($tid);
131
132
            return $this->redirect([$actualTid, '#' => $tid], 301);
133
        }
134
135
        // check if anonymous tries to access internal categories
136
        $root = $postings;
137
        if (!$this->CurrentUser->getCategories()->permission('read', $root->get('category'))) {
138
            return $this->_requireAuth();
139
        }
140
141
        $this->_setRootEntry($root);
142
        $this->Title->setFromPosting($root, __('view.type.mix'));
143
144
        $this->set('showBottomNavigation', true);
145
        $this->set('entries', $postings);
146
147
        $this->_showAnsweringPanel();
148
149
        $this->Threads->incrementViewsForThread($root, $this->CurrentUser);
150
        $this->MarkAsRead->thread($postings);
151
    }
152
153
    /**
154
     * load front page force all entries mark-as-read
155
     *
156
     * @return void
157
     */
158
    public function update()
159
    {
160
        $this->autoRender = false;
161
        $this->CurrentUser->getLastRefresh()->set();
162
        $this->redirect('/entries/index');
163
    }
164
165
    /**
166
     * Outputs raw markup of an posting $id
167
     *
168
     * @param string $id posting-ID
169
     * @return void
170
     */
171
    public function source($id = null)
172
    {
173
        $this->viewBuilder()->enableAutoLayout(false);
174
        $this->view($id);
175
    }
176
177
    /**
178
     * View posting.
179
     *
180
     * @param string $id posting-ID
181
     * @return \Cake\Network\Response|void
182
     */
183
    public function view($id = null)
184
    {
185
        Stopwatch::start('Entries->view()');
186
187
        // redirect if no id is given
188
        if (!$id) {
189
            $this->Flash->set(__('Invalid post'), ['element' => 'error']);
190
191
            return $this->redirect(['action' => 'index']);
192
        }
193
194
        $entry = $this->Entries->get($id);
0 ignored issues
show
Bug introduced by
$id of type string is incompatible with the type integer expected by parameter $primaryKey of App\Model\Table\EntriesTable::get(). ( Ignorable by Annotation )

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

194
        $entry = $this->Entries->get(/** @scrutinizer ignore-type */ $id);
Loading history...
195
196
        // redirect if posting doesn't exists
197
        if ($entry == false) {
198
            $this->Flash->set(__('Invalid post'));
199
200
            return $this->redirect('/');
201
        }
202
203
        $posting = $entry->toPosting()->withCurrentUser($this->CurrentUser);
0 ignored issues
show
Bug introduced by
The method toPosting() does not exist on Cake\Datasource\EntityInterface. It seems like you code against a sub-type of Cake\Datasource\EntityInterface such as App\Model\Entity\Entry. ( Ignorable by Annotation )

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

203
        $posting = $entry->/** @scrutinizer ignore-call */ toPosting()->withCurrentUser($this->CurrentUser);
Loading history...
204
205
        if (!$this->CurrentUser->getCategories()->permission('read', $posting->get('category'))) {
206
            return $this->_requireAuth();
207
        }
208
209
        $this->set('entry', $posting);
210
        $this->Threads->incrementViewsForPosting($posting, $this->CurrentUser);
211
        $this->_setRootEntry($posting);
212
        $this->_showAnsweringPanel();
213
214
        $this->MarkAsRead->posting($posting);
215
216
        // inline open
217
        if ($this->request->is('ajax')) {
218
            return $this->render('/Element/entry/view_posting');
219
        }
220
221
        // full page request
222
        $this->set(
223
            'tree',
224
            $this->Entries->postingsForThread($posting->get('tid'), false, $this->CurrentUser)
225
        );
226
        $this->Title->setFromPosting($posting);
227
228
        Stopwatch::stop('Entries->view()');
229
    }
230
231
    /**
232
     * Add new posting.
233
     *
234
     * @return void|\Cake\Network\Response
235
     */
236
    public function add()
237
    {
238
        $titleForPage = __('Write a New Posting');
239
        $this->set(compact('titleForPage'));
240
    }
241
242
    /**
243
     * Edit posting
244
     *
245
     * @param string $id posting-ID
246
     * @return void|\Cake\Network\Response
247
     * @throws NotFoundException
248
     * @throws BadRequestException
249
     */
250
    public function edit($id = null)
251
    {
252
        if (empty($id)) {
253
            throw new BadRequestException;
254
        }
255
256
        $entry = $this->Entries->get($id);
0 ignored issues
show
Bug introduced by
$id of type string is incompatible with the type integer expected by parameter $primaryKey of App\Model\Table\EntriesTable::get(). ( Ignorable by Annotation )

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

256
        $entry = $this->Entries->get(/** @scrutinizer ignore-type */ $id);
Loading history...
257
        if (empty($entry)) {
258
            throw new NotFoundException;
259
        }
260
        $posting = $entry->toPosting()->withCurrentUser($this->CurrentUser);
261
262
        if (!$posting->isEditingAllowed()) {
263
            throw new SaitoForbiddenException(
264
                'Access to posting in EntriesController:edit() forbidden.',
265
                ['CurrentUser' => $this->CurrentUser]
266
            );
267
        }
268
269
        // show editing form
270
        if (!$posting->isEditingAsUserAllowed()) {
271
            $this->Flash->set(
272
                __('notice_you_are_editing_as_mod'),
273
                ['element' => 'warning']
274
            );
275
        }
276
277
        $this->set(compact('posting'));
278
279
        // set headers
280
        $this->set(
281
            'headerSubnavLeftTitle',
282
            __('back_to_posting_from_linkname', $posting->get('name'))
283
        );
284
        $this->set('headerSubnavLeftUrl', ['action' => 'view', $id]);
285
        $this->set('form_title', __('edit_linkname'));
286
        $this->render('/Entries/add');
287
    }
288
289
    /**
290
     * Get thread-line to insert after an inline-answer
291
     *
292
     * @param string $id posting-ID
293
     * @return void|\Cake\Network\Response
294
     */
295
    public function threadLine($id = null)
296
    {
297
        $posting = $this->Entries->get($id)->toPosting()->withCurrentUser($this->CurrentUser);
0 ignored issues
show
Bug introduced by
It seems like $id can also be of type string; however, parameter $primaryKey of App\Model\Table\EntriesTable::get() does only seem to accept integer, 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

297
        $posting = $this->Entries->get(/** @scrutinizer ignore-type */ $id)->toPosting()->withCurrentUser($this->CurrentUser);
Loading history...
298
        if (!$this->CurrentUser->getCategories()->permission('read', $posting->get('category'))) {
299
            return $this->_requireAuth();
300
        }
301
302
        $this->set('entrySub', $posting);
303
        // ajax requests so far are always answers
304
        $this->response = $this->response->withType('json');
305
        $this->set('level', '1');
306
    }
307
308
    /**
309
     * Delete posting
310
     *
311
     * @param string $id posting-ID
312
     * @return void
313
     * @throws NotFoundException
314
     * @throws MethodNotAllowedException
315
     */
316
    public function delete(string $id)
317
    {
318
        //$this->request->allowMethod(['post', 'delete']);
319
        $id = (int)$id;
320
        if (!$id) {
321
            throw new NotFoundException;
322
        }
323
        /* @var Entry $posting */
324
        $posting = $this->Entries->get($id);
325
        if (!$posting) {
0 ignored issues
show
introduced by
$posting is of type App\Controller\Entry, thus it always evaluated to true.
Loading history...
326
            throw new NotFoundException;
327
        }
328
329
        $action = $posting->isRoot() ? 'thread' : 'answer';
330
        $allowed = $this->CurrentUser->getCategories()
331
            ->permission($action, $posting->get('category_id'));
332
        if (!$allowed) {
333
            throw new ForbiddenException(null, 1571309481);
334
        }
335
336
        $success = $this->Entries->deletePosting($id);
337
338
        if ($success) {
339
            $flashType = 'success';
340
            if ($posting->isRoot()) {
341
                $message = __('delete_tree_success');
342
                $redirect = '/';
343
            } else {
344
                $message = __('delete_subtree_success');
345
                $redirect = '/entries/view/' . $posting->get('pid');
346
            }
347
        } else {
348
            $flashType = 'error';
349
            $message = __('delete_tree_error');
350
            $redirect = $this->referer();
351
        }
352
        $this->Flash->set($message, ['element' => $flashType]);
353
        $this->redirect($redirect);
354
    }
355
356
    /**
357
     * Empty function for benchmarking
358
     *
359
     * @return void
360
     */
361
    public function e()
362
    {
363
        Stopwatch::start('Entries->e()');
364
        Stopwatch::stop('Entries->e()');
365
    }
366
367
    /**
368
     * Marks sub-entry $id as solution to its current root-entry
369
     *
370
     * @param string $id posting-ID
371
     * @return void
372
     * @throws BadRequestException
373
     */
374
    public function solve($id)
375
    {
376
        $this->autoRender = false;
377
        try {
378
            $posting = $this->Entries->get($id);
0 ignored issues
show
Bug introduced by
$id of type string is incompatible with the type integer expected by parameter $primaryKey of App\Model\Table\EntriesTable::get(). ( Ignorable by Annotation )

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

378
            $posting = $this->Entries->get(/** @scrutinizer ignore-type */ $id);
Loading history...
379
380
            if (empty($posting)) {
381
                throw new \InvalidArgumentException('Posting to mark solved not found.');
382
            }
383
384
            $rootId = $posting->get('tid');
385
            $rootPosting = $this->Entries->get($rootId);
386
387
            $allowed = $this->CurrentUser->permission(
388
                'saito.core.posting.solves.set',
389
                (new ResourceAI())->onRole($rootPosting->get('user')->getRole())->onOwner($rootPosting->get('user_id'))
390
            );
391
            if (!$allowed) {
392
                throw new SaitoForbiddenException(
393
                    sprintf('Attempt to mark posting %s as solution.', $posting->get('id')),
394
                    ['CurrentUser' => $this->CurrentUser]
395
                );
396
            }
397
398
            $value = $posting->get('solves') ? 0 : $rootPosting->get('tid');
399
            $success = $this->Entries->updateEntry($posting, ['solves' => $value]);
0 ignored issues
show
Bug introduced by
It seems like $posting can also be of type array; however, parameter $posting of App\Model\Table\EntriesTable::updateEntry() does only seem to accept App\Model\Entity\Entry, 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

399
            $success = $this->Entries->updateEntry(/** @scrutinizer ignore-type */ $posting, ['solves' => $value]);
Loading history...
400
401
            if (!$success) {
0 ignored issues
show
introduced by
$success is of type App\Model\Entity\Entry, thus it always evaluated to true.
Loading history...
402
                throw new BadRequestException();
403
            }
404
        } catch (\Exception $e) {
405
            throw new BadRequestException();
406
        }
407
    }
408
409
    /**
410
     * Merge threads.
411
     *
412
     * @param string $sourceId posting-ID of thread to be merged
413
     * @return void
414
     * @throws NotFoundException
415
     * @td put into admin entries controller
416
     */
417
    public function merge(string $sourceId = null)
418
    {
419
        $sourceId = (int)$sourceId;
420
        if (empty($sourceId)) {
421
            throw new NotFoundException();
422
        }
423
424
        /* @var Entry */
425
        $entry = $this->Entries->findById($sourceId)->first();
0 ignored issues
show
Bug introduced by
The method findById() does not exist on App\Model\Table\EntriesTable. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

425
        $entry = $this->Entries->/** @scrutinizer ignore-call */ findById($sourceId)->first();
Loading history...
426
427
        if (!$entry || !$entry->isRoot()) {
0 ignored issues
show
Bug introduced by
The method isRoot() does not exist on Cake\Datasource\EntityInterface. It seems like you code against a sub-type of Cake\Datasource\EntityInterface such as App\Model\Entity\Entry. ( Ignorable by Annotation )

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

427
        if (!$entry || !$entry->/** @scrutinizer ignore-call */ isRoot()) {
Loading history...
428
            throw new NotFoundException();
429
        }
430
431
        // perform move operation
432
        $targetId = $this->request->getData('targetId');
433
        if (!empty($targetId)) {
434
            if ($this->Entries->threadMerge($sourceId, $targetId)) {
0 ignored issues
show
Bug introduced by
$targetId of type array|string is incompatible with the type integer expected by parameter $targetId of App\Model\Table\EntriesTable::threadMerge(). ( Ignorable by Annotation )

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

434
            if ($this->Entries->threadMerge($sourceId, /** @scrutinizer ignore-type */ $targetId)) {
Loading history...
435
                $this->redirect('/entries/view/' . $sourceId);
436
437
                return;
438
            } else {
439
                $this->Flash->set(__('Error'), ['element' => 'error']);
440
            }
441
        }
442
443
        $this->viewBuilder()->setLayout('Admin.admin');
444
        $this->set('posting', $entry);
445
    }
446
447
    /**
448
     * Toggle posting property via ajax request.
449
     *
450
     * @param string $id posting-ID
451
     * @param string $toggle property
452
     *
453
     * @return \Cake\Network\Response
454
     */
455
    public function ajaxToggle($id = null, $toggle = null)
456
    {
457
        $allowed = ['fixed', 'locked'];
458
        if (!$id
459
            || !$toggle
460
            || !$this->request->is('ajax')
461
            || !in_array($toggle, $allowed)
462
        ) {
463
            throw new BadRequestException;
464
        }
465
466
        $posting = $this->Entries->get($id);
0 ignored issues
show
Bug introduced by
$id of type string is incompatible with the type integer expected by parameter $primaryKey of App\Model\Table\EntriesTable::get(). ( Ignorable by Annotation )

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

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