1<?php
2
3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
4// See the LICENCE file in the repository root for full licence text.
5
6namespace App\Models\Forum;
7
8use App\Casts\TimestampOrZero;
9use App\Jobs\EsDocument;
10use App\Jobs\UpdateUserForumCache;
11use App\Jobs\UpdateUserForumTopicFollows;
12use App\Libraries\BBCodeForDB;
13use App\Libraries\Transactions\AfterCommit;
14use App\Models\Beatmapset;
15use App\Models\Log;
16use App\Models\Notification;
17use App\Models\User;
18use App\Traits\Memoizes;
19use App\Traits\Validatable;
20use Carbon\Carbon;
21use DB;
22use Illuminate\Database\Eloquent\SoftDeletes;
23use Illuminate\Database\QueryException;
24
25/**
26 * @property Beatmapset $beatmapset
27 * @property TopicCover $cover
28 * @property \Carbon\Carbon|null $deleted_at
29 * @property \Illuminate\Database\Eloquent\Collection $featureVotes FeatureVote
30 * @property Forum $forum
31 * @property int $forum_id
32 * @property int $icon_id
33 * @property \Illuminate\Database\Eloquent\Collection $logs Log
34 * @property mixed $osu_lastreplytype
35 * @property int $osu_starpriority
36 * @property \Illuminate\Database\Eloquent\Collection $pollOptions PollOption
37 * @property \Illuminate\Database\Eloquent\Collection $pollVotes PollVote
38 * @property bool $poll_hide_results
39 * @property \Carbon\Carbon|null $poll_last_vote
40 * @property int $poll_length
41 * @property mixed $poll_length_days
42 * @property int $poll_max_options
43 * @property \Carbon\Carbon|null $poll_start
44 * @property string $poll_title
45 * @property bool $poll_vote_change
46 * @property \Illuminate\Database\Eloquent\Collection $posts Post
47 * @property bool $topic_approved
48 * @property int $topic_attachment
49 * @property int $topic_bumped
50 * @property int $topic_bumper
51 * @property int $topic_first_post_id
52 * @property string|null $topic_first_poster_colour
53 * @property string $topic_first_poster_name
54 * @property int $topic_id
55 * @property int $topic_last_post_id
56 * @property string $topic_last_post_subject
57 * @property \Carbon\Carbon|null $topic_last_post_time
58 * @property string|null $topic_last_poster_colour
59 * @property int $topic_last_poster_id
60 * @property string $topic_last_poster_name
61 * @property \Carbon\Carbon|null $topic_last_view_time
62 * @property int $topic_moved_id
63 * @property int $topic_poster
64 * @property int $topic_replies
65 * @property int $topic_replies_real
66 * @property int $topic_reported
67 * @property int $topic_status
68 * @property \Carbon\Carbon|null $topic_time
69 * @property int $topic_time_limit
70 * @property string $topic_title
71 * @property int $topic_type
72 * @property int $topic_views
73 * @property \Illuminate\Database\Eloquent\Collection $userTracks TopicTrack
74 * @property \Illuminate\Database\Eloquent\Collection $watches TopicWatch
75 */
76class Topic extends Model implements AfterCommit
77{
78 use Memoizes, Validatable;
79 use SoftDeletes {
80 restore as private origRestore;
81 }
82
83 const DEFAULT_SORT = 'new';
84
85 const STATUS_LOCKED = 1;
86 const STATUS_UNLOCKED = 0;
87
88 const TYPES = [
89 'normal' => 0,
90 'sticky' => 1,
91 'announcement' => 2,
92 ];
93
94 const ISSUE_TAGS = [
95 'added',
96 'assigned',
97 'confirmed',
98 'duplicate',
99 'invalid',
100 'resolved',
101 ];
102
103 const MAX_FIELD_LENGTHS = [
104 'topic_title' => 100,
105 ];
106
107 const VIEW_COUNT_INTERVAL = 86400; // 1 day
108
109 protected $table = 'phpbb_topics';
110 protected $primaryKey = 'topic_id';
111
112 public $timestamps = false;
113
114 protected $casts = [
115 'poll_hide_results' => 'boolean',
116 'poll_last_vote' => TimestampOrZero::class,
117 'poll_start' => TimestampOrZero::class,
118 'poll_vote_change' => 'boolean',
119 'topic_approved' => 'boolean',
120 'topic_last_post_time' => TimestampOrZero::class,
121 'topic_last_view_time' => TimestampOrZero::class,
122 'topic_time' => TimestampOrZero::class,
123 ];
124
125 public static function createNew($forum, $params, $poll = null)
126 {
127 $topic = new static([
128 'topic_time' => Carbon::now(),
129 'topic_title' => $params['title'] ?? null,
130 'topic_poster' => $params['user']->user_id,
131 'topic_first_poster_name' => $params['user']->username,
132 'topic_first_poster_colour' => $params['user']->user_colour,
133 ]);
134 $topic->forum()->associate($forum);
135
136 $topic->getConnection()->transaction(function () use ($topic, $params, $poll) {
137 $topic->saveOrExplode();
138 Post::createNew($topic, $params['user'], $params['body'], false);
139
140 if ($poll !== null) {
141 $topic->poll($poll)->save();
142 }
143
144 if (($params['cover'] ?? null) !== null) {
145 $params['cover']->topic()->associate($topic);
146 $params['cover']->save();
147 }
148 });
149
150 return $topic->fresh();
151 }
152
153 public static function typeStr($typeInt)
154 {
155 return array_search_null($typeInt, static::TYPES) ?? null;
156 }
157
158 public static function typeInt($typeIntOrStr)
159 {
160 if (is_int($typeIntOrStr)) {
161 if (in_array($typeIntOrStr, static::TYPES, true)) {
162 return $typeIntOrStr;
163 }
164 } else {
165 return static::TYPES[$typeIntOrStr] ?? null;
166 }
167 }
168
169 public function validationErrorsTranslationPrefix(): string
170 {
171 return 'forum.topic';
172 }
173
174 public function beatmapset()
175 {
176 return $this->belongsTo(Beatmapset::class, 'topic_id', 'thread_id');
177 }
178
179 public function firstPost()
180 {
181 return $this->hasOne(Post::class, 'post_id', 'topic_first_post_id');
182 }
183
184 public function posts()
185 {
186 return $this->hasMany(Post::class);
187 }
188
189 public function forum()
190 {
191 return $this->belongsTo(Forum::class, 'forum_id');
192 }
193
194 public function cover()
195 {
196 return $this->hasOne(TopicCover::class);
197 }
198
199 public function userTracks()
200 {
201 return $this->hasMany(TopicTrack::class);
202 }
203
204 public function logs()
205 {
206 return $this->hasMany(Log::class);
207 }
208
209 public function notifications()
210 {
211 return $this->morphMany(Notification::class, 'notifiable');
212 }
213
214 public function featureVotes()
215 {
216 return $this->hasMany(FeatureVote::class);
217 }
218
219 public function pollOptions()
220 {
221 return $this->hasMany(PollOption::class);
222 }
223
224 public function pollVotes()
225 {
226 return $this->hasMany(PollVote::class);
227 }
228
229 public function watches()
230 {
231 return $this->hasMany(TopicWatch::class);
232 }
233
234 public function getPollLengthDaysAttribute()
235 {
236 return $this->attributes['poll_length'] / 86400;
237 }
238
239 public function getTopicFirstPosterColourAttribute($value)
240 {
241 if (present($value)) {
242 return "#{$value}";
243 }
244 }
245
246 public function setTopicFirstPosterColourAttribute($value)
247 {
248 // also functions for casting null to string
249 $this->attributes['topic_first_poster_colour'] = ltrim($value, '#');
250 }
251
252 public function getTopicLastPosterColourAttribute($value)
253 {
254 if (present($value)) {
255 return "#{$value}";
256 }
257 }
258
259 public function setTopicLastPosterColourAttribute($value)
260 {
261 // also functions for casting null to string
262 $this->attributes['topic_last_poster_colour'] = ltrim($value, '#');
263 }
264
265 public function setTopicTitleAttribute($value)
266 {
267 $this->attributes['topic_title'] = trim_unicode($value);
268 }
269
270 public function save(array $options = [])
271 {
272 if (!$this->isValid()) {
273 return false;
274 }
275
276 return $this->getConnection()->transaction(function () use ($options) {
277 // creating new topic
278 if (!$this->exists && $this->forum !== null) {
279 $this->forum->topicsAdded(1);
280 }
281
282 return parent::save($options);
283 });
284 }
285
286 public function isValid()
287 {
288 $this->validationErrors()->reset();
289
290 if ($this->isDirty('topic_title') && !present($this->topic_title)) {
291 $this->validationErrors()->add('topic_title', 'required');
292 }
293
294 $this->validateDbFieldLengths();
295
296 return $this->validationErrors()->isEmpty();
297 }
298
299 public function titleNormalized()
300 {
301 if (!$this->isIssue()) {
302 return $this->topic_title;
303 }
304
305 $title = $this->topic_title;
306
307 foreach (static::ISSUE_TAGS as $tag) {
308 $title = str_replace("[{$tag}]", '', $title);
309 }
310
311 return trim($title);
312 }
313
314 public function issueTags()
315 {
316 return $this->memoize(__FUNCTION__, function () {
317 if (!$this->isIssue()) {
318 return [];
319 }
320
321 $tags = [];
322 foreach (static::ISSUE_TAGS as $tag) {
323 if ($this->hasIssueTag($tag)) {
324 $tags[] = $tag;
325 }
326 }
327
328 return $tags;
329 });
330 }
331
332 public function scopePinned($query)
333 {
334 return $query->where('topic_type', '<>', static::typeInt('normal'));
335 }
336
337 public function scopeNormal($query)
338 {
339 return $query->where('topic_type', '=', static::typeInt('normal'));
340 }
341
342 public function scopeShowDeleted($query, $showDeleted)
343 {
344 if ($showDeleted) {
345 $query->withTrashed();
346 }
347 }
348
349 public function scopeWatchedByUser($query, $user)
350 {
351 return $query
352 ->with('forum')
353 ->whereIn(
354 'topic_id',
355 TopicWatch::where('user_id', $user->user_id)->select('topic_id')
356 )
357 ->orderBy('topic_last_post_time', 'DESC');
358 }
359
360 public function scopeWithReplies($query, $withReplies)
361 {
362 switch ($withReplies) {
363 case 'only':
364 $query->where('topic_replies_real', '<>', 0);
365 break;
366 case 'none':
367 $query->where('topic_replies_real', 0);
368 break;
369 }
370 }
371
372 public function scopePresetSort($query, $sort)
373 {
374 $tieBreakerOrder = 'desc';
375
376 switch ($sort) {
377 case 'created':
378 $query->orderBy('topic_time', 'desc');
379 break;
380 case 'feature-votes':
381 $query->orderBy('osu_starpriority', 'desc');
382 break;
383 }
384
385 return $query->orderBy('topic_last_post_time', $tieBreakerOrder);
386 }
387
388 public function scopeRecent($query, $params = null)
389 {
390 $sort = $params['sort'] ?? null;
391 $withReplies = $params['withReplies'] ?? null;
392
393 $query->withReplies($withReplies);
394 $query->presetSort($sort);
395 }
396
397 public function nthPost($n)
398 {
399 return $this->posts()->skip(intval($n) - 1)->first();
400 }
401
402 public function postPosition($postId)
403 {
404 return $this->posts()->where('post_id', '<=', $postId)->count();
405 }
406
407 public function setPollTitleAttribute($value)
408 {
409 $this->attributes['poll_title'] = (new BBCodeForDB($value))->generate();
410 }
411
412 public function pollTitleRaw()
413 {
414 return bbcode_for_editor($this->poll_title);
415 }
416
417 public function pollTitleHTML()
418 {
419 return bbcode($this->poll_title, $this->firstPost->bbcode_uid);
420 }
421
422 public function pollEnd()
423 {
424 if ($this->poll_start !== null && $this->poll_length !== 0) {
425 return $this->poll_start->copy()->addSeconds($this->poll_length);
426 }
427 }
428
429 public function postCount()
430 {
431 return $this->memoize(__FUNCTION__, function () {
432 return $this->topic_replies + 1;
433 });
434 }
435
436 public function deletedPostsCount()
437 {
438 return $this->memoize(__FUNCTION__, function () {
439 return $this->posts()->onlyTrashed()->count();
440 });
441 }
442
443 public function isOld()
444 {
445 // pinned and announce posts should never be considered old
446 if ($this->topic_type !== static::TYPES['normal']) {
447 return false;
448 }
449
450 return $this->topic_last_post_time < Carbon::now()->subMonths($GLOBALS['cfg']['osu']['forum']['old_months']);
451 }
452
453 public function isLocked()
454 {
455 // not checking STATUS_LOCK because there's another
456 // state (STATUS_MOVED) which isn't handled yet.
457 return $this->topic_status !== static::STATUS_UNLOCKED;
458 }
459
460 public function isActive()
461 {
462 return $this->topic_last_post_time > Carbon::now()->subMonths($GLOBALS['cfg']['osu']['forum']['necropost_months']);
463 }
464
465 public function markRead($user, $markTime)
466 {
467 if ($user === null) {
468 return;
469 }
470
471 DB::beginTransaction();
472
473 $status = TopicTrack
474 ::where([
475 'user_id' => $user->user_id,
476 'topic_id' => $this->topic_id,
477 ])
478 ->first();
479
480 if ($status === null) {
481 // first time seeing the topic, create tracking entry
482 // and increment views count
483 try {
484 TopicTrack::create([
485 'user_id' => $user->user_id,
486 'topic_id' => $this->topic_id,
487 'forum_id' => $this->forum_id,
488 'mark_time' => $markTime,
489 ]);
490 } catch (QueryException $ex) {
491 DB::rollback();
492
493 // Duplicate entry.
494 // Retry, hoping $status now contains something.
495 if (is_sql_unique_exception($ex)) {
496 $this->markRead($user, $markTime);
497 return;
498 }
499
500 throw $ex;
501 }
502 } elseif ($status->mark_time < $markTime) {
503 $status->update(['mark_time' => $markTime]);
504 }
505
506 if ($this->topic_last_view_time < $markTime) {
507 $this->topic_last_view_time = $markTime;
508 $this->save();
509 }
510
511 DB::commit();
512 }
513
514 public function incrementViewCount(?User $user, string $ipAddr): void
515 {
516 $lockKey = "view:forum_topic:{$this->getKey()}:";
517 $lockKey .= $user === null
518 ? "guest:{$ipAddr}"
519 : "user:{$user->getKey()}";
520
521 if (\Cache::lock($lockKey, static::VIEW_COUNT_INTERVAL)->get()) {
522 $this->incrementInstance('topic_views');
523 }
524 }
525
526 public function isIssue()
527 {
528 return in_array($this->forum_id, $GLOBALS['cfg']['osu']['forum']['issue_forum_ids'], true);
529 }
530
531 public function delete()
532 {
533 if ($this->trashed()) {
534 return true;
535 }
536
537 $deleted = $this->getConnection()->transaction(function () {
538 if (!parent::delete()) {
539 return false;
540 }
541
542 $deletedPosts = $this->postCount();
543 $this->forum->topicsAdded(-1);
544 $this->forum->postsAdded(-$deletedPosts);
545
546 return true;
547 });
548
549 if ($deleted) {
550 $this->queueSyncPosts();
551 }
552
553 return $deleted;
554 }
555
556 public function restore()
557 {
558 if (!$this->trashed()) {
559 return true;
560 }
561
562 $restored = $this->getConnection()->transaction(function () {
563 if (!$this->origRestore()) {
564 return false;
565 }
566
567 $restoredPosts = $this->postCount();
568 $this->forum->topicsAdded(1);
569 $this->forum->postsAdded($restoredPosts);
570
571 return true;
572 });
573
574 if ($restored) {
575 $this->queueSyncPosts();
576 }
577
578 return $restored;
579 }
580
581 public function moveTo($destinationForum)
582 {
583 if ($this->forum_id === $destinationForum->forum_id) {
584 return true;
585 }
586
587 if (!$this->forum->isOpen()) {
588 return false;
589 }
590
591 $this->getConnection()->transaction(function () use ($destinationForum) {
592 $originForum = $this->forum;
593 $this->forum()->associate($destinationForum);
594 $this->save();
595
596 $this->posts()->withTrashed()->update(['forum_id' => $this->forum_id]);
597
598 $this->logs()->update(['forum_id' => $destinationForum->forum_id]);
599 $this->userTracks()->update(['forum_id' => $destinationForum->forum_id]);
600
601 $visiblePostsCount = $this->posts()->count();
602 optional($originForum)->topicsAdded(-1);
603 optional($originForum)->postsAdded($visiblePostsCount * -1);
604 optional($this->forum)->topicsAdded(1);
605 optional($this->forum)->postsAdded($visiblePostsCount);
606 });
607
608 $this->queueSyncPosts();
609
610 return true;
611 }
612
613 public function postsAdded($count)
614 {
615 $this->getConnection()->transaction(function () use ($count) {
616 $this->fill([
617 'topic_replies' => db_unsigned_increment('topic_replies', $count),
618 'topic_replies_real' => db_unsigned_increment('topic_replies_real', $count),
619 ]);
620 $this->setFirstPostCache();
621 $this->setLastPostCache();
622
623 $this->save();
624 });
625 }
626
627 public function refreshCache()
628 {
629 $this->getConnection()->transaction(function () {
630 $this->setPostCountCache();
631 $this->setFirstPostCache();
632 $this->setLastPostCache();
633
634 $this->save();
635 });
636 }
637
638 public function setPostCountCache()
639 {
640 $this->topic_replies = -1 + $this->posts()->where('post_approved', true)->count();
641 $this->topic_replies_real = -1 + $this->posts()->count();
642 }
643
644 public function setFirstPostCache()
645 {
646 $firstPost = $this->posts()->first();
647
648 if ($firstPost === null) {
649 $this->topic_first_post_id = 0;
650 $this->topic_poster = 0;
651 $this->topic_first_poster_name = '';
652 $this->topic_first_poster_colour = '';
653 } else {
654 $this->topic_first_post_id = $firstPost->post_id;
655
656 if ($firstPost->user === null) {
657 $this->topic_poster = 0;
658 $this->topic_first_poster_name = '';
659 $this->topic_first_poster_colour = '';
660 } else {
661 $this->topic_poster = $firstPost->user->user_id;
662 $this->topic_first_poster_name = $firstPost->user->username;
663 $this->topic_first_poster_colour = $firstPost->user->user_colour;
664 }
665 }
666 }
667
668 public function setLastPostCache()
669 {
670 $lastPost = $this->posts()->last();
671
672 if ($lastPost === null) {
673 $this->topic_last_post_id = 0;
674 $this->topic_last_post_time = null;
675
676 $this->topic_last_poster_id = 0;
677 $this->topic_last_poster_name = '';
678 $this->topic_last_poster_colour = '';
679 } else {
680 $this->topic_last_post_id = $lastPost->post_id;
681 $this->topic_last_post_time = $lastPost->post_time;
682
683 if ($lastPost->user === null) {
684 $this->topic_last_poster_id = 0;
685 $this->topic_last_poster_name = '';
686 $this->topic_last_poster_colour = '';
687 } else {
688 $this->topic_last_poster_id = $lastPost->user->user_id;
689 $this->topic_last_poster_name = $lastPost->user->username;
690 $this->topic_last_poster_colour = $lastPost->user->user_colour;
691 }
692 }
693 }
694
695 public function lock($lock = true)
696 {
697 $this->update([
698 'topic_status' => $lock ? static::STATUS_LOCKED : static::STATUS_UNLOCKED,
699 ]);
700 }
701
702 public function pin($pin)
703 {
704 $this->update([
705 'topic_type' => static::typeInt($pin) ?? static::typeInt('normal'),
706 ]);
707 }
708
709 public function deleteWithDependencies()
710 {
711 if ($this->cover !== null) {
712 $this->cover->deleteWithFile();
713 }
714
715 $this->pollOptions()->delete();
716 $this->pollVotes()->delete();
717 $this->userTracks()->delete();
718
719 // FIXME: returning used stars?
720 $this->featureVotes()->delete();
721
722 $this->delete();
723 }
724
725 public function allowsDoublePosting(): bool
726 {
727 return in_array($this->forum_id, $GLOBALS['cfg']['osu']['forum']['double_post_allowed_forum_ids'], true);
728 }
729
730 public function isDoublePostBy(User $user)
731 {
732 if ($user === null) {
733 return false;
734 }
735 if ($user->user_id !== $this->topic_last_poster_id) {
736 return false;
737 }
738 if ($user->user_id === $this->topic_poster) {
739 $minHours = $GLOBALS['cfg']['osu']['forum']['double_post_time']['author'];
740 } else {
741 $minHours = $GLOBALS['cfg']['osu']['forum']['double_post_time']['normal'];
742 }
743
744 return $this->topic_last_post_time > Carbon::now()->subHours($minHours);
745 }
746
747 public function isFeatureTopic()
748 {
749 return $this->topic_type === static::TYPES['normal'] && $this->forum->isFeatureForum();
750 }
751
752 public function poll($poll = null): TopicPoll
753 {
754 return $this->memoize(__FUNCTION__, function () use ($poll) {
755 return ($poll ?? new TopicPoll())->setTopic($this);
756 });
757 }
758
759 public function vote()
760 {
761 return $this->memoize(__FUNCTION__, function () {
762 return new TopicVote($this);
763 });
764 }
765
766 public function setIssueTag($tag)
767 {
768 $this->topic_type = static::typeInt($tag === 'confirmed' ? 'sticky' : 'normal');
769
770 if (!$this->hasIssueTag($tag)) {
771 $this->topic_title = "[{$tag}] {$this->topic_title}";
772 }
773
774 $this->saveOrExplode();
775 }
776
777 public function unsetIssueTag($tag)
778 {
779 $this->topic_type = static::typeInt($tag === 'resolved' ? 'sticky' : 'normal');
780
781 $this->topic_title = preg_replace(
782 '/ +/',
783 ' ',
784 trim(str_replace("[{$tag}]", '', $this->topic_title))
785 );
786
787 $this->saveOrExplode();
788 }
789
790 public function hasIssueTag($tag)
791 {
792 return strpos($this->topic_title, "[{$tag}]") !== false;
793 }
794
795 public function afterCommit()
796 {
797 if ($this->exists && $this->firstPost !== null) {
798 dispatch(new EsDocument($this->firstPost));
799 }
800 }
801
802 private function queueSyncPosts()
803 {
804 $this
805 ->posts()
806 ->withTrashed()
807 // this relies on dispatcher always reloading the model
808 ->select(['poster_id', 'post_id'])
809 ->each(function ($post) {
810 dispatch(new UpdateUserForumCache($post->poster_id));
811 dispatch(new EsDocument($post));
812 });
813
814 dispatch(new UpdateUserForumTopicFollows($this));
815 }
816}