the browser-facing portion of osu!
at master 23 kB view raw
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}