Issues (326)

src/Model/Table/EntriesTable.php (11 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\Model\Table;
14
15
use App\Lib\Model\Table\AppTable;
16
use App\Model\Entity\Entry;
17
use App\Model\Table\CategoriesTable;
18
use App\Model\Table\DraftsTable;
19
use Bookmarks\Model\Table\BookmarksTable;
20
use Cake\Datasource\Exception\RecordNotFoundException;
21
use Cake\Event\Event;
22
use Cake\ORM\Entity;
23
use Cake\ORM\Query;
24
use Cake\ORM\RulesChecker;
25
use Cake\Validation\Validator;
26
use Saito\Posting\PostingInterface;
27
use Saito\User\CurrentUser\CurrentUserInterface;
28
use Saito\Validation\SaitoValidationProvider;
29
use Search\Manager;
30
31
/**
32
 * Stores postings
33
 *
34
 * Field notes:
35
 * - `edited_by` - Came from mylittleforum. @td Should by migrated to User.id.
36
 * - `name` - Came from mylittleforum. Is still used in fulltext index.
37
 *
38
 * @property BookmarksTable $Bookmarks
39
 * @property CategoriesTable $Categories
40
 * @property DraftsTable $Drafts
41
 * @method array treeBuild(array $postings)
42
 * @method createPosting(array $data, CurrentUserInterface $CurrentUser)
43
 * @method updatePosting(Entry $posting, array $data, CurrentUserInterface $CurrentUser)
44
 * @method array prepareChildPosting(BasicPostingInterface $parent, array $data)
45
 * @method array getRecentPostings(CurrentUserInterface $CU, ?array $options = [])
46
 * @method bool deletePosting(int $id)
47
 * @method array postingsForThreads(array $tids, ?array $order = null, ?CurrentUserInterface $CU)
48
 * @method PostingInterface postingsForThread(int $tid, ?bool $complete = false, ?CurrentUserInterface $CU)
49
 * @method threadMerge(int $sourceId, int $targetId)
50
 */
51
class EntriesTable extends AppTable
52
{
53
    /**
54
     * Max subject length.
55
     *
56
     * Constrained to 191 due to InnoDB index max-length on MySQL 5.6.
57
     */
58
    public const SUBJECT_MAXLENGTH = 191;
59
60
    /**
61
     * Fields for search plugin
62
     *
63
     * @var array
64
     */
65
    public $filterArgs = [
66
        'subject' => ['type' => 'like'],
67
        'text' => ['type' => 'like'],
68
        'name' => ['type' => 'like'],
69
        'category' => ['type' => 'value'],
70
    ];
71
72
    protected $_defaultConfig = [
73
        'subject_maxlength' => 100,
74
    ];
75
76
    /**
77
     * {@inheritDoc}
78
     */
79
    public function initialize(array $config)
80
    {
81
        $this->setPrimaryKey('id');
82
83
        $this->addBehavior('Posting');
84
        $this->addBehavior('IpLogging');
85
        $this->addBehavior('Timestamp');
86
87
        $this->addBehavior(
88
            'CounterCache',
89
            [
90
                // cache how many postings a user has
91
                'Users' => ['entry_count'],
92
                // cache how many threads a category has
93
                'Categories' => [
94
                    'thread_count' => function ($event, Entry $entity, $table, $original) {
95
                        if (!$entity->isRoot()) {
96
                            return false;
97
                        }
98
                        // posting is moved to new category…
99
                        if ($original) {
100
                            // update old category (should decrement counter)
101
                            $categoryId = $entity->getOriginal('category_id');
102
                        } else {
103
                            // update new category (increment counter)
104
                            $categoryId = $entity->get('category_id');
105
                        }
106
107
                        $query = $table->find('all', ['conditions' => [
108
                            'pid' => 0, 'category_id' => $categoryId,
109
                        ]]);
110
                        $count = $query->count();
111
112
                        return $count;
113
                    },
114
                ],
115
            ]
116
        );
117
118
        $this->belongsTo('Categories', ['foreignKey' => 'category_id']);
119
        $this->belongsTo('Users', ['foreignKey' => 'user_id']);
120
121
        $this->hasMany(
122
            'Bookmarks',
123
            ['foreignKey' => 'entry_id', 'dependent' => true]
124
        );
125
126
        // Releation never queried. Just for quick access to the table.
127
        $this->hasOne('Drafts');
128
    }
129
130
    /**
131
     * {@inheritDoc}
132
     */
133
    public function validationDefault(Validator $validator)
134
    {
135
        $validator->setProvider('saito', SaitoValidationProvider::class);
136
137
        /// category_id
138
        $categoryRequiredL10N = __('vld.entries.categories.notEmpty');
139
        $validator
0 ignored issues
show
Deprecated Code introduced by
The function Cake\Validation\Validator::notEmpty() has been deprecated: 3.7.0 Use notEmptyString(), notEmptyArray(), notEmptyFile(), notEmptyDate(), notEmptyTime() or notEmptyDateTime() instead. ( Ignorable by Annotation )

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

139
        /** @scrutinizer ignore-deprecated */ $validator

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

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

Loading history...
140
            ->notEmpty('category_id', $categoryRequiredL10N)
141
            ->requirePresence('category_id', 'create', $categoryRequiredL10N);
142
143
        /// last_answer
144
        $validator
145
            ->requirePresence('last_answer', 'create')
146
            ->notEmptyDateTime('last_answer', null, 'create');
147
148
        /// name
149
        $validator
150
            ->requirePresence('name', 'create')
151
            ->notEmptyString('name', null, 'create');
152
153
        /// pid
154
        $validator->requirePresence('pid', 'create');
155
156
        /// subject
157
        $subjectRequiredL10N = __('vld.entries.subject.notEmpty');
158
        $validator
159
            ->notEmptyString('subject', $subjectRequiredL10N)
160
            ->requirePresence('subject', 'create', $subjectRequiredL10N)
161
            ->add(
162
                'subject',
163
                [
164
                    'maxLength' => [
165
                        'rule' => ['maxLength', $this->getConfig('subject_maxlength')],
166
                        'message' => __('vld.entries.subject.maxlength'),
167
                    ],
168
                ]
169
            );
170
171
        /// time
172
        $validator
173
            ->requirePresence('time', 'create')
174
            ->notEmptyDateTime('time', null, 'create');
175
176
        /// user_id
177
        $validator
178
            ->requirePresence('user_id', 'create')
179
            ->add('user_id', ['numeric' => ['rule' => 'numeric']]);
180
181
        /// views
182
        $validator->add(
183
            'views',
184
            ['comparison' => ['rule' => ['comparison', '>=', 0]]]
185
        );
186
187
        return $validator;
188
    }
189
190
    /**
191
     * {@inheritDoc}
192
     */
193
    public function buildRules(RulesChecker $rules)
194
    {
195
        $rules = parent::buildRules($rules);
196
197
        $rules->add(
198
            function ($entity) {
199
                if (!$entity->isDirty('solves') || empty($entity->get('solves') > 0)) {
200
                    return true;
201
                }
202
203
                return !$entity->isRoot();
204
            },
205
            'checkSolvesOnlyOnAnswers',
206
            [
207
                'errorField' => 'solves',
208
                'message' => 'Root postings cannot mark themself solved.',
209
            ]
210
        );
211
212
        $rules->add(
213
            function ($entity) {
214
                if (!$entity->isDirty('solves') || empty($entity->get('solves') > 0)) {
215
                    return true;
216
                }
217
218
                return !$entity->isRoot();
219
            },
220
            'checkSolvesOnlyOnAnswers',
221
            [
222
                'errorField' => 'solves',
223
                'message' => 'Root postings cannot mark themself solved.',
224
            ]
225
        );
226
227
        return $rules;
228
    }
229
230
    /**
231
     * {@inheritDoc}
232
     */
233
    public function afterSave(Event $event, Entity $entity, \ArrayObject $options)
0 ignored issues
show
The parameter $options 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

233
    public function afterSave(Event $event, Entity $entity, /** @scrutinizer ignore-unused */ \ArrayObject $options)

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...
234
    {
235
        if ($entity->isNew()) {
236
            $this->Drafts->deleteDraftForPosting($entity);
237
238
            /** @var Entry */
239
            $posting = $this->get($entity->get('id'));
240
            if ($posting->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

240
            if ($posting->/** @scrutinizer ignore-call */ isRoot()) {
Loading history...
241
                /// New thread: set thread-ID to posting's own ID.
242
                $patched = $this->patchEntity($posting, ['tid' => $entity->get('id')]);
0 ignored issues
show
It seems like $posting can also be of type array; however, parameter $entity of Cake\ORM\Table::patchEntity() does only seem to accept Cake\Datasource\EntityInterface, 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

242
                $patched = $this->patchEntity(/** @scrutinizer ignore-type */ $posting, ['tid' => $entity->get('id')]);
Loading history...
243
                if (!$this->save($patched)) {
244
                    $event->stopPropagation();
245
                }
246
                // Set it in the entity returned by the the save
247
                $entity->set('tid', $entity->get('id'));
248
            } else {
249
                /// New answer: update last answer time of root entry
250
                // @td Is this really necessary?
251
                $this->updateAll(
252
                    ['last_answer' => $posting->get('last_answer')],
253
                    ['id' => $posting->get('tid')]
254
                );
255
            }
256
        }
257
    }
258
259
    /**
260
     * Advanced search configuration from SaitoSearch plugin
261
     *
262
     * @see https://github.com/FriendsOfCake/search
263
     *
264
     * @return Manager
265
     */
266
    public function searchManager(): Manager
267
    {
268
        /** @var Manager $searchManager */
269
        $searchManager = $this->getBehavior('Search')->searchManager();
0 ignored issues
show
The method searchManager() does not exist on Cake\ORM\Behavior. It seems like you code against a sub-type of Cake\ORM\Behavior such as Search\Model\Behavior\SearchBehavior. ( Ignorable by Annotation )

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

269
        $searchManager = $this->getBehavior('Search')->/** @scrutinizer ignore-call */ searchManager();
Loading history...
270
        $searchManager
271
        ->like('subject', [
272
            'before' => true,
273
            'after' => true,
274
            'fieldMode' => 'OR',
275
            'comparison' => 'LIKE',
276
            'wildcardAny' => '*',
277
            'wildcardOne' => '?',
278
            'field' => ['subject'],
279
            'filterEmpty' => true,
280
        ])
281
        ->like('text', [
282
            'before' => true,
283
            'after' => true,
284
            'fieldMode' => 'OR',
285
            'comparison' => 'LIKE',
286
            'wildcardAny' => '*',
287
            'wildcardOne' => '?',
288
            'field' => ['text'],
289
            'filterEmpty' => true,
290
        ])
291
        ->value('name', ['filterEmpty' => true]);
292
293
        return $searchManager;
294
    }
295
296
    /**
297
     * Shorthand for reading an entry with full da516ta
298
     *
299
     * @param int $primaryKey key
300
     * @param array $options options
301
     * @throws RecordNotFoundException if record isn't found
302
     * @return mixed Posting
303
     */
304
    public function get($primaryKey, $options = [])
305
    {
306
        /** @var Entry */
307
        $result = $this->find('entry', ['complete' => true])
308
            ->where([$this->getAlias() . '.id' => $primaryKey])
309
            ->first();
310
311
        if (empty($result)) {
312
            $msg = sprintf('Posting with ID "%s" not found.', $primaryKey);
313
            throw new RecordNotFoundException($msg);
314
        }
315
316
        return $result;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $result also could return the type array which is incompatible with the return type mandated by Cake\Datasource\RepositoryInterface::get() of Cake\Datasource\EntityInterface.
Loading history...
317
    }
318
319
    /**
320
     * Implements the custom find type 'entry'
321
     *
322
     * @param Query $query query
323
     * @param array $options options
324
     * - 'complete' bool controls fieldset selected as in getFieldset($complete)
325
     * @return Query
326
     */
327
    public function findEntry(Query $query, array $options = [])
328
    {
329
        $options += ['complete' => false];
330
        $query
331
            ->select($this->getFieldset($options['complete']))
332
            ->contain(['Users', 'Categories']);
333
334
        return $query;
335
    }
336
337
    /**
338
     * Get list of fields required to display posting.:w
339
     *
340
     * You don't want to fetch every field for performance reasons.
341
     *
342
     * @param bool $complete Threadline if false; Full posting if true
343
     * @return array The fieldset
344
     */
345
    public function getFieldset(bool $complete = false): array
346
    {
347
        // field list necessary for displaying a thread_line
348
        $threadLineFieldList = [
349
            'Categories.accession',
350
            'Categories.category',
351
            'Categories.description',
352
            'Categories.id',
353
            'Entries.fixed',
354
            'Entries.id',
355
            'Entries.last_answer',
356
            'Entries.locked',
357
            'Entries.name',
358
            'Entries.pid',
359
            'Entries.solves',
360
            'Entries.subject',
361
            // Entry.text determines if Entry is n/t
362
            'Entries.text',
363
            'Entries.tid',
364
            'Entries.time',
365
            'Entries.user_id',
366
            'Entries.views',
367
            'Users.username',
368
        ];
369
370
        // fields additional to $threadLineFieldList to show complete entry
371
        $showEntryFieldListAdditional = [
372
            'Entries.category_id',
373
            'Entries.edited',
374
            'Entries.edited_by',
375
            'Entries.ip',
376
            'Users.avatar',
377
            'Users.id',
378
            'Users.signature',
379
            'Users.user_type',
380
            'Users.user_place',
381
        ];
382
383
        $fields = $threadLineFieldList;
384
        if ($complete) {
385
            $fields = array_merge($fields, $showEntryFieldListAdditional);
386
        }
387
388
        return $fields;
389
    }
390
391
    /**
392
     * Finds the thread-IT for a posting.
393
     *
394
     * @param int $id Posting-Id
395
     * @return int Thread-Id
396
     * @throws RecordNotFoundException If posting isn't found
397
     */
398
    public function getThreadId($id)
399
    {
400
        $entry = $this->find(
401
            'all',
402
            ['conditions' => ['id' => $id], 'fields' => 'tid']
403
        )->first();
404
        if (empty($entry)) {
405
            throw new RecordNotFoundException(
406
                'Posting not found. Posting-Id: ' . $id
407
            );
408
        }
409
410
        return $entry->get('tid');
411
    }
412
413
    /**
414
     * creates a new root or child entry for a node
415
     *
416
     * fields in $data are filtered
417
     *
418
     * @param array $data data
419
     * @return Entry|null on success, null otherwise
420
     */
421
    public function createEntry(array $data): ?Entry
422
    {
423
        $data['time'] = bDate();
424
        $data['last_answer'] = bDate();
425
426
        /** @var Entry */
427
        $posting = $this->newEntity($data);
428
        $errors = $posting->getErrors();
429
        if (!empty($errors)) {
430
            return $posting;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $posting returns the type Cake\Datasource\EntityInterface which includes types incompatible with the type-hinted return App\Model\Entity\Entry|null.
Loading history...
431
        }
432
433
        /** @var Entry */
434
        $posting = $this->save($posting);
435
        if (empty($posting)) {
436
            return null;
437
        }
438
439
        $eventData = ['subject' => $posting->get('pid'), 'data' => $posting];
440
        $this->dispatchDbEvent('Model.Entry.replyToEntry', $eventData);
441
442
        return $posting;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $posting returns the type Cake\Datasource\EntityInterface which includes types incompatible with the type-hinted return App\Model\Entity\Entry|null.
Loading history...
443
    }
444
445
    /**
446
     * Updates a posting with new data
447
     *
448
     * @param Entry $posting Entity
449
     * @param array $data data
450
     * @return Entry|null
451
     */
452
    public function updateEntry(Entry $posting, array $data): ?Entry
453
    {
454
        $data['id'] = $posting->get('id');
455
456
        /** @var Entry */
457
        $patched = $this->patchEntity($posting, $data);
458
        $errors = $patched->getErrors();
459
        if (!empty($errors)) {
460
            return $patched;
461
        }
462
463
        /** @var Entry */
464
        $new = $this->save($posting);
465
        if (empty($new)) {
466
            return null;
467
        }
468
469
        $this->dispatchDbEvent(
470
            'Model.Entry.update',
471
            ['subject' => $posting->get('id'), 'data' => $posting]
472
        );
473
474
        return $new;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $new returns the type Cake\Datasource\EntityInterface which includes types incompatible with the type-hinted return App\Model\Entity\Entry|null.
Loading history...
475
    }
476
477
    /**
478
     * {@inheritDoc}
479
     */
480
    public function beforeMarshal(Event $event, \ArrayObject $data, \ArrayObject $options)
0 ignored issues
show
The parameter $options 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

480
    public function beforeMarshal(Event $event, \ArrayObject $data, /** @scrutinizer ignore-unused */ \ArrayObject $options)

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...
The parameter $event 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

480
    public function beforeMarshal(/** @scrutinizer ignore-unused */ Event $event, \ArrayObject $data, \ArrayObject $options)

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...
481
    {
482
        /// Trim whitespace on subject and text
483
        $toTrim = ['subject', 'text'];
484
        foreach ($toTrim as $field) {
485
            if (!empty($data[$field])) {
486
                $data[$field] = trim($data[$field]);
487
            }
488
        }
489
    }
490
491
    /**
492
     * Deletes posting incl. all its subposting and associated data
493
     *
494
     * @param array $idsToDelete Entry ids which should be deleted
495
     * @return bool
496
     */
497
    public function deleteWithIds(array $idsToDelete): bool
498
    {
499
        $success = $this->deleteAll(['id IN' => $idsToDelete]);
500
501
        if (!$success) {
502
            return false;
503
        }
504
505
        // @td Should be covered by dependent assoc. Add tests.
506
        $this->Bookmarks->deleteAll(['entry_id IN' => $idsToDelete]);
507
508
        $this->dispatchSaitoEvent(
509
            'saito.core.posting.delete.after',
510
            ['subject' => $idsToDelete, 'table' => $this]
511
        );
512
513
        return true;
514
    }
515
516
    /**
517
     * Anonymizes the entries for a user
518
     *
519
     * @param int $userId user-ID
520
     * @return void
521
     */
522
    public function anonymizeEntriesFromUser(int $userId): void
523
    {
524
        // remove username from all entries and reassign to anonyme user
525
        $success = (bool)$this->updateAll(
526
            [
527
                'edited_by' => null,
528
                'ip' => null,
529
                'name' => null,
530
                'user_id' => 0,
531
            ],
532
            ['user_id' => $userId]
533
        );
534
535
        if ($success) {
536
            $this->dispatchDbEvent('Cmd.Cache.clear', ['cache' => 'Thread']);
537
        }
538
    }
539
540
    /**
541
     * Implements the custom find type 'index paginator'
542
     *
543
     * @param Query $query query
544
     * @param array $options finder options
545
     * @return Query
546
     */
547
    public function findIndexPaginator(Query $query, array $options)
548
    {
549
        $query
550
            ->select(['id', 'pid', 'tid', 'time', 'last_answer', 'fixed'])
551
            ->where(['Entries.pid' => 0]);
552
553
        if (!empty($options['counter'])) {
554
            $query->counter($options['counter']);
555
        }
556
557
        return $query;
558
    }
559
}
560