Issues (326)

src/Controller/EntriesController.php (13 issues)

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\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\Basic\BasicPostingInterface;
31
use Saito\User\CurrentUser\CurrentUserInterface;
32
use Saito\User\Permission\ResourceAI;
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 PostingComponent $Posting
42
 * @property RefererComponent $Referer
43
 * @property ThreadsComponent $Threads
44
 */
45
class EntriesController extends AppController
46
{
47
    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

47
    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...
48
49
    public $helpers = ['Posting', 'Text'];
50
51
    /**
52
     * {@inheritDoc}
53
     */
54
    public function initialize()
55
    {
56
        parent::initialize();
57
58
        $this->loadComponent('Posting');
59
        $this->loadComponent('MarkAsRead');
60
        $this->loadComponent('Referer');
61
        $this->loadComponent('Threads', ['table' => $this->Entries]);
62
    }
63
64
    /**
65
     * posting index
66
     *
67
     * @return void|\Cake\Http\Response
68
     */
69
    public function index()
70
    {
71
        Stopwatch::start('Entries->index()');
72
73
        //= determine user sort order
74
        $sortKey = 'last_answer';
75
        if (!$this->CurrentUser->get('user_sort_last_answer')) {
76
            $sortKey = 'time';
77
        }
78
        $order = ['fixed' => 'DESC', $sortKey => 'DESC'];
79
80
        //= get threads
81
        $threads = $this->Threads->paginate($order, $this->CurrentUser);
82
        $this->set('entries', $threads);
83
84
        $currentPage = (int)$this->request->getQuery('page') ?: 1;
85
        if ($currentPage > 1) {
86
            $this->set('titleForLayout', __('page') . ' ' . $currentPage);
87
        }
88
        if ($currentPage === 1) {
89
            if ($this->MarkAsRead->refresh()) {
90
                return $this->redirect(['action' => 'index']);
91
            }
92
            $this->MarkAsRead->next();
93
        }
94
95
        // @bogus
96
        $this->request->getSession()->write('paginator.lastPage', $currentPage);
97
        $this->set('showDisclaimer', true);
98
        $this->set('showBottomNavigation', true);
99
        $this->Slidetabs->show();
100
101
        $this->_setupCategoryChooser($this->CurrentUser);
102
103
        /** @var AutoReloadComponent */
104
        $autoReload = $this->loadComponent('AutoReload');
105
        $autoReload->after($this->CurrentUser);
0 ignored issues
show
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

105
        $autoReload->/** @scrutinizer ignore-call */ 
106
                     after($this->CurrentUser);
Loading history...
106
107
        Stopwatch::stop('Entries->index()');
108
    }
109
110
    /**
111
     * Mix view
112
     *
113
     * @param string $tid thread-ID
114
     * @return void|Response
115
     * @throws NotFoundException
116
     */
117
    public function mix($tid)
118
    {
119
        $tid = (int)$tid;
120
        if ($tid <= 0) {
121
            throw new BadRequestException();
122
        }
123
124
        try {
125
            $postings = $this->Entries->postingsForThread($tid, true, $this->CurrentUser);
126
        } catch (RecordNotFoundException $e) {
127
            /// redirect sub-posting to mix view of thread
128
            $actualTid = $this->Entries->getThreadId($tid);
129
130
            return $this->redirect([$actualTid, '#' => $tid], 301);
131
        }
132
133
        // check if anonymous tries to access internal categories
134
        $root = $postings;
135
        if (!$this->CurrentUser->getCategories()->permission('read', $root->get('category'))) {
136
            return $this->_requireAuth();
137
        }
138
139
        $this->_setRootEntry($root);
140
        $this->Title->setFromPosting($root, __('view.type.mix'));
141
142
        $this->set('showBottomNavigation', true);
143
        $this->set('entries', $postings);
144
145
        $this->_showAnsweringPanel();
146
147
        $this->Threads->incrementViewsForThread($root, $this->CurrentUser);
148
        $this->MarkAsRead->thread($postings);
149
    }
150
151
    /**
152
     * load front page force all entries mark-as-read
153
     *
154
     * @return void
155
     */
156
    public function update()
157
    {
158
        $this->autoRender = false;
159
        $this->CurrentUser->getLastRefresh()->set();
160
        $this->redirect('/entries/index');
161
    }
162
163
    /**
164
     * Outputs raw markup of an posting $id
165
     *
166
     * @param string $id posting-ID
167
     * @return void
168
     */
169
    public function source($id = null)
170
    {
171
        $this->viewBuilder()->enableAutoLayout(false);
172
        $this->view($id);
0 ignored issues
show
It seems like $id can also be of type null; however, parameter $id of App\Controller\EntriesController::view() does only seem to accept string, 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

172
        $this->view(/** @scrutinizer ignore-type */ $id);
Loading history...
173
    }
174
175
    /**
176
     * View posting.
177
     *
178
     * @param string $id posting-ID
179
     * @return \Cake\Http\Response|void
180
     */
181
    public function view(string $id)
182
    {
183
        $id = (int)$id;
184
        Stopwatch::start('Entries->view()');
185
186
        $entry = $this->Entries->get($id);
187
        $posting = $entry->toPosting()->withCurrentUser($this->CurrentUser);
0 ignored issues
show
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

187
        $posting = $entry->/** @scrutinizer ignore-call */ toPosting()->withCurrentUser($this->CurrentUser);
Loading history...
188
189
        if (!$this->CurrentUser->getCategories()->permission('read', $posting->get('category'))) {
190
            return $this->_requireAuth();
191
        }
192
193
        $this->set('entry', $posting);
194
        $this->Threads->incrementViewsForPosting($posting, $this->CurrentUser);
195
        $this->_setRootEntry($posting);
196
        $this->_showAnsweringPanel();
197
198
        $this->MarkAsRead->posting($posting);
199
200
        // inline open
201
        if ($this->request->is('ajax')) {
202
            return $this->render('/Element/entry/view_posting');
203
        }
204
205
        // full page request
206
        $this->set(
207
            'tree',
208
            $this->Entries->postingsForThread($posting->get('tid'), false, $this->CurrentUser)
209
        );
210
        $this->Title->setFromPosting($posting);
211
212
        Stopwatch::stop('Entries->view()');
213
    }
214
215
    /**
216
     * Add new posting.
217
     *
218
     * @return void|\Cake\Http\Response
219
     */
220
    public function add()
221
    {
222
        $titleForPage = __('Write a New Posting');
223
        $this->set(compact('titleForPage'));
224
    }
225
226
    /**
227
     * Edit posting
228
     *
229
     * @param string $id posting-ID
230
     * @return void|\Cake\Http\Response
231
     * @throws NotFoundException
232
     * @throws BadRequestException
233
     */
234
    public function edit(string $id)
235
    {
236
        $id = (int)$id;
237
        $entry = $this->Entries->get($id);
238
        $posting = $entry->toPosting()->withCurrentUser($this->CurrentUser);
239
240
        if (!$posting->isEditingAllowed()) {
241
            throw new SaitoForbiddenException(
242
                'Access to posting in EntriesController:edit() forbidden.',
243
                ['CurrentUser' => $this->CurrentUser]
244
            );
245
        }
246
247
        // show editing form
248
        if (!$posting->isEditingAsUserAllowed()) {
249
            $this->Flash->set(
250
                __('notice_you_are_editing_as_mod'),
251
                ['element' => 'warning']
252
            );
253
        }
254
255
        $this->set(compact('posting'));
256
257
        // set headers
258
        $this->set(
259
            'headerSubnavLeftTitle',
260
            __('back_to_posting_from_linkname', $posting->get('name'))
261
        );
262
        $this->set('headerSubnavLeftUrl', ['action' => 'view', $id]);
263
        $this->set('form_title', __('edit_linkname'));
264
        $this->render('/Entries/add');
265
    }
266
267
    /**
268
     * Get thread-line to insert after an inline-answer
269
     *
270
     * @param string $id posting-ID
271
     * @return void|\Cake\Http\Response
272
     */
273
    public function threadLine($id = null)
274
    {
275
        $posting = $this->Entries->get($id)->toPosting()->withCurrentUser($this->CurrentUser);
0 ignored issues
show
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

275
        $posting = $this->Entries->get(/** @scrutinizer ignore-type */ $id)->toPosting()->withCurrentUser($this->CurrentUser);
Loading history...
276
        if (!$this->CurrentUser->getCategories()->permission('read', $posting->get('category'))) {
277
            return $this->_requireAuth();
278
        }
279
280
        $this->set('entrySub', $posting);
281
        // ajax requests so far are always answers
282
        $this->response = $this->response->withType('json');
283
        $this->set('level', '1');
284
    }
285
286
    /**
287
     * Delete posting
288
     *
289
     * @param string $id posting-ID
290
     * @return void
291
     * @throws NotFoundException
292
     * @throws MethodNotAllowedException
293
     */
294
    public function delete(string $id)
295
    {
296
        //$this->request->allowMethod(['post', 'delete']);
297
        $id = (int)$id;
298
        if (!$id) {
299
            throw new NotFoundException();
300
        }
301
        /* @var Entry $posting */
302
        $posting = $this->Entries->get($id);
303
304
        $action = $posting->isRoot() ? 'thread' : 'answer';
305
        $allowed = $this->CurrentUser->getCategories()
306
            ->permission($action, $posting->get('category_id'));
307
        if (!$allowed) {
308
            throw new SaitoForbiddenException();
309
        }
310
311
        $success = $this->Entries->deletePosting($id);
312
313
        if ($success) {
314
            $flashType = 'success';
315
            if ($posting->isRoot()) {
316
                $message = __('delete_tree_success');
317
                $redirect = '/';
318
            } else {
319
                $message = __('delete_subtree_success');
320
                $redirect = '/entries/view/' . $posting->get('pid');
321
            }
322
        } else {
323
            $flashType = 'error';
324
            $message = __('delete_tree_error');
325
            $redirect = $this->referer();
326
        }
327
        $this->Flash->set($message, ['element' => $flashType]);
328
        $this->redirect($redirect);
329
    }
330
331
    /**
332
     * Empty function for benchmarking
333
     *
334
     * @return void
335
     */
336
    public function e()
337
    {
338
        Stopwatch::start('Entries->e()');
339
        Stopwatch::stop('Entries->e()');
340
    }
341
342
    /**
343
     * Marks sub-entry $id as solution to its current root-entry
344
     *
345
     * @param string $id posting-ID
346
     * @return void
347
     * @throws BadRequestException
348
     */
349
    public function solve($id)
350
    {
351
        $this->autoRender = false;
352
        try {
353
            $posting = $this->Entries->get($id);
0 ignored issues
show
$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

353
            $posting = $this->Entries->get(/** @scrutinizer ignore-type */ $id);
Loading history...
354
355
            if (empty($posting)) {
356
                throw new \InvalidArgumentException('Posting to mark solved not found.');
357
            }
358
359
            $rootId = $posting->get('tid');
360
            $rootPosting = $this->Entries->get($rootId);
361
362
            $allowed = $this->CurrentUser->permission(
363
                'saito.core.posting.solves.set',
364
                (new ResourceAI())->onRole($rootPosting->get('user')->getRole())->onOwner($rootPosting->get('user_id'))
365
            );
366
            if (!$allowed) {
367
                throw new SaitoForbiddenException(
368
                    sprintf('Attempt to mark posting %s as solution.', $posting->get('id')),
369
                    ['CurrentUser' => $this->CurrentUser]
370
                );
371
            }
372
373
            $value = $posting->get('solves') ? 0 : $rootPosting->get('tid');
374
            $success = $this->Entries->updateEntry($posting, ['solves' => $value]);
0 ignored issues
show
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

374
            $success = $this->Entries->updateEntry(/** @scrutinizer ignore-type */ $posting, ['solves' => $value]);
Loading history...
375
376
            if (!$success) {
0 ignored issues
show
$success is of type App\Model\Entity\Entry, thus it always evaluated to true.
Loading history...
377
                throw new BadRequestException();
378
            }
379
        } catch (\Exception $e) {
380
            throw new BadRequestException();
381
        }
382
    }
383
384
    /**
385
     * Merge threads.
386
     *
387
     * @param string $sourceId posting-ID of thread to be merged
388
     * @return void
389
     * @throws NotFoundException
390
     * @td put into admin entries controller
391
     */
392
    public function merge(string $sourceId = null)
393
    {
394
        $sourceId = (int)$sourceId;
395
        if (empty($sourceId)) {
396
            throw new NotFoundException();
397
        }
398
399
        /* @var Entry */
400
        $entry = $this->Entries->findById($sourceId)->first();
0 ignored issues
show
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

400
        $entry = $this->Entries->/** @scrutinizer ignore-call */ findById($sourceId)->first();
Loading history...
401
402
        if (!$entry || !$entry->isRoot()) {
0 ignored issues
show
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

402
        if (!$entry || !$entry->/** @scrutinizer ignore-call */ isRoot()) {
Loading history...
403
            throw new NotFoundException();
404
        }
405
406
        // perform move operation
407
        $targetId = $this->request->getData('targetId');
408
        if (!empty($targetId)) {
409
            if ($this->Entries->threadMerge($sourceId, $targetId)) {
0 ignored issues
show
$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

409
            if ($this->Entries->threadMerge($sourceId, /** @scrutinizer ignore-type */ $targetId)) {
Loading history...
410
                $this->redirect('/entries/view/' . $sourceId);
411
412
                return;
413
            } else {
414
                $this->Flash->set(__('Error'), ['element' => 'error']);
415
            }
416
        }
417
418
        $this->viewBuilder()->setLayout('Admin.admin');
419
        $this->set('posting', $entry);
420
    }
421
422
    /**
423
     * Toggle posting property via ajax request.
424
     *
425
     * @param string $id posting-ID
426
     * @param string $toggle property
427
     *
428
     * @return \Cake\Http\Response
429
     */
430
    public function ajaxToggle($id = null, $toggle = null)
431
    {
432
        $allowed = ['fixed', 'locked'];
433
        if (
434
            !$id
435
            || !$toggle
436
            || !$this->request->is('ajax')
437
            || !in_array($toggle, $allowed)
438
        ) {
439
            throw new BadRequestException();
440
        }
441
442
        $posting = $this->Entries->get($id);
0 ignored issues
show
$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

442
        $posting = $this->Entries->get(/** @scrutinizer ignore-type */ $id);
Loading history...
443
        $data = ['id' => (int)$id, $toggle => !$posting->get($toggle)];
444
        $this->Posting->update($posting, $data, $this->CurrentUser);
0 ignored issues
show
It seems like $posting can also be of type array; however, parameter $entry of App\Controller\Component...tingComponent::update() 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

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