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\Http\Controllers\Forum; 7 8use App\Exceptions\ModelNotSavedException; 9use App\Jobs\Notifications\ForumTopicReply; 10use App\Libraries\NewForumTopic; 11use App\Models\Forum\FeatureVote; 12use App\Models\Forum\Forum; 13use App\Models\Forum\PollOption; 14use App\Models\Forum\Post; 15use App\Models\Forum\Topic; 16use App\Models\Forum\TopicCover; 17use App\Models\Forum\TopicPoll; 18use App\Models\Forum\TopicWatch; 19use App\Models\UserProfileCustomization; 20use App\Transformers\Forum\TopicCoverTransformer; 21use Auth; 22use DB; 23use Request; 24 25/** 26 * @group Forum 27 */ 28class TopicsController extends Controller 29{ 30 private static function nextUrl($topic, $sort, $cursor, $withDeleted) 31 { 32 return route('forum.topics.show', [ 33 'cursor_string' => cursor_encode($cursor), 34 'skip_layout' => 1, 35 'sort' => $sort, 36 'topic' => $topic, 37 'with_deleted' => $withDeleted, 38 ]); 39 } 40 41 public function __construct() 42 { 43 parent::__construct(); 44 45 $this->middleware('auth', ['only' => [ 46 'create', 47 'lock', 48 'preview', 49 'reply', 50 'store', 51 ]]); 52 53 $this->middleware('require-scopes:public', ['only' => ['show']]); 54 $this->middleware('require-scopes:forum.write', ['only' => ['reply', 'store', 'update']]); 55 } 56 57 public function create() 58 { 59 $forum = Forum::findOrFail(request('forum_id')); 60 61 priv_check('ForumTopicStore', $forum)->ensureCan(); 62 63 return ext_view( 64 'forum.topics.create', 65 (new NewForumTopic($forum, Auth::user()))->toArray() 66 ); 67 } 68 69 public function destroy($id) 70 { 71 $topic = Topic::withTrashed()->findOrFail($id); 72 73 priv_check('ForumTopicDelete', $topic)->ensureCan(); 74 75 DB::transaction(function () use ($topic) { 76 if ((auth()->user()->user_id ?? null) !== $topic->topic_poster) { 77 $this->logModerate( 78 'LOG_DELETE_TOPIC', 79 [$topic->topic_title], 80 $topic 81 ); 82 } 83 84 if (!$topic->delete()) { 85 throw new ModelNotSavedException($topic->validationErrors()->toSentence()); 86 } 87 }); 88 89 if (priv_check('ForumModerate', $topic->forum)->can()) { 90 return ext_view('forum.topics.delete', ['post' => $topic->firstPost], 'js'); 91 } else { 92 return ujs_redirect(route('forum.forums.show', $topic->forum)); 93 } 94 } 95 96 public function restore($id) 97 { 98 $topic = Topic::withTrashed()->findOrFail($id); 99 100 priv_check('ForumModerate', $topic->forum)->ensureCan(); 101 102 DB::transaction(function () use ($topic) { 103 $this->logModerate( 104 'LOG_RESTORE_TOPIC', 105 [$topic->topic_title], 106 $topic 107 ); 108 109 if (!$topic->restore()) { 110 throw new ModelNotSavedException($topic->validationErrors()->toSentence()); 111 } 112 }); 113 114 return ext_view('forum.topics.restore', ['post' => $topic->firstPost], 'js'); 115 } 116 117 public function editPollGet($topicId) 118 { 119 $topic = Topic::findOrFail($topicId); 120 121 priv_check('ForumTopicPollEdit', $topic)->ensureCan(); 122 123 return ext_view('forum.topics._edit_poll', compact('topic')); 124 } 125 126 public function editPollPost($topicId) 127 { 128 $topic = Topic::findOrFail($topicId); 129 130 priv_check('ForumTopicPollEdit', $topic)->ensureCan(); 131 132 $poll = (new TopicPoll())->fill($this->getPollParams()); 133 $poll->setTopic($topic); 134 135 $topic->getConnection()->transaction(function () use ($poll, $topic) { 136 if (!$poll->save()) { 137 return; 138 } 139 140 if (Auth::user()->getKey() !== $topic->topic_poster) { 141 $this->logModerate( 142 'LOG_EDIT_POLL', 143 [$topic->poll_title], 144 $topic 145 ); 146 } 147 }); 148 149 if ($poll->validationErrors()->isAny()) { 150 return error_popup($poll->validationErrors()->toSentence()); 151 } 152 153 $pollSummary = PollOption::summary($topic, Auth::user()); 154 $canEditPoll = $poll->canEdit(); 155 156 return ext_view('forum.topics._poll', compact('canEditPoll', 'pollSummary', 'topic')); 157 } 158 159 public function issueTag($id) 160 { 161 $topic = Topic::findOrFail($id); 162 163 priv_check('ForumModerate', $topic->forum)->ensureCan(); 164 165 $issueTag = presence(Request::input('issue_tag')); 166 $state = get_bool(Request::input('state')); 167 $type = 'issue_tag_'.$issueTag; 168 169 if ($issueTag === null || !$topic->isIssue() || !in_array($issueTag, $topic::ISSUE_TAGS, true)) { 170 abort(422); 171 } 172 173 $this->logModerate('LOG_ISSUE_TAG', compact('issueTag', 'state'), $topic); 174 175 $method = $state ? 'setIssueTag' : 'unsetIssueTag'; 176 177 $topic->$method($issueTag); 178 179 return ext_view('forum.topics.replace_button', compact('topic', 'type', 'state'), 'js'); 180 } 181 182 public function lock($id) 183 { 184 $topic = Topic::withTrashed()->findOrFail($id); 185 186 priv_check('ForumModerate', $topic->forum)->ensureCan(); 187 188 $type = 'lock'; 189 $state = get_bool(Request::input('lock')); 190 $this->logModerate($state ? 'LOG_LOCK' : 'LOG_UNLOCK', [$topic->topic_title], $topic); 191 $topic->lock($state); 192 193 return ext_view('forum.topics.replace_button', compact('topic', 'type', 'state'), 'js'); 194 } 195 196 public function move($id) 197 { 198 $topic = Topic::withTrashed()->findOrFail($id); 199 $originForum = $topic->forum; 200 $destinationForum = Forum::findOrFail(Request::input('destination_forum_id')); 201 202 priv_check('ForumModerate', $originForum)->ensureCan(); 203 priv_check('ForumModerate', $destinationForum)->ensureCan(); 204 205 $this->logModerate('LOG_MOVE', [$originForum->forum_name], $topic); 206 if ($topic->moveTo($destinationForum)) { 207 return ext_view('layout.ujs-reload', [], 'js'); 208 } else { 209 abort(422); 210 } 211 } 212 213 public function pin($id) 214 { 215 $topic = Topic::withTrashed()->findOrFail($id); 216 217 priv_check('ForumModerate', $topic->forum)->ensureCan(); 218 219 $type = 'moderate_pin'; 220 $state = get_int(Request::input('pin')); 221 DB::transaction(function () use ($topic, $state) { 222 $topic->pin($state); 223 224 $this->logModerate( 225 'LOG_TOPIC_TYPE', 226 ['title' => $topic->topic_title, 'type' => $topic->topic_type], 227 $topic 228 ); 229 }); 230 231 return ext_view('forum.topics.replace_button', compact('topic', 'type', 'state'), 'js'); 232 } 233 234 /** 235 * Reply Topic 236 * 237 * Create a post replying to the specified topic. 238 * 239 * --- 240 * 241 * ### Response Format 242 * 243 * [ForumPost](#forum-post) with `body` included. 244 * 245 * @urlParam topic integer required Id of the topic to be replied to. Example: 1 246 * 247 * @bodyParam body string required Content of the reply post. Example: hello 248 */ 249 public function reply($id) 250 { 251 $topic = Topic::findOrFail($id); 252 253 priv_check('ForumTopicReply', $topic)->ensureCan(); 254 255 $user = \Auth::user(); 256 $post = Post::createNew($topic, $user, get_string(request('body'))); 257 258 $post->markRead($user); 259 (new ForumTopicReply($post, $user))->dispatch(); 260 261 $watch = $user->user_notify 262 ? TopicWatch::setState($topic, $user, 'watching_mail') 263 : TopicWatch::lookup($topic, $user); 264 265 if (is_api_request()) { 266 return json_item($post, 'Forum\Post', ['body']); 267 } else { 268 return [ 269 'posts' => view('forum.topics._posts', [ 270 'firstPostPosition' => $topic->postPosition($post->post_id), 271 'posts' => collect([$post]), 272 'topic' => $topic, 273 ])->render(), 274 'watch' => view('forum.topics._watch', [ 275 'state' => $watch, 276 'topic' => $topic, 277 ])->render(), 278 ]; 279 } 280 } 281 282 /** 283 * Get Topic and Posts 284 * 285 * Get topic and its posts. 286 * 287 * --- 288 * 289 * ### Response Format 290 * 291 * Field | Type | Notes 292 * ------------- | ----------------------------- | ----- 293 * cursor_string | [CursorString](#cursorstring) | | 294 * posts | [ForumPost](#forum-post)[] | Includes `body`. 295 * search | | Parameters used for current request excluding cursor. 296 * topic | [ForumTopic](#forum-topic) | | 297 * 298 * @urlParam topic integer required Id of the topic. Example: 1 299 * 300 * @usesCursor 301 * @queryParam sort Post sorting option. Valid values are `id_asc` (default) and `id_desc`. No-example 302 * @queryParam limit Maximum number of posts to be returned (20 default, 50 at most). No-example 303 * @queryParam start First post id to be returned with `sort` set to `id_asc`. This parameter is ignored if `cursor_string` is specified. No-example 304 * @queryParam end First post id to be returned with `sort` set to `id_desc`. This parameter is ignored if `cursor_string` is specified. No-example 305 * 306 * @response { 307 * "topic": { "id": 1, "...": "..." }, 308 * "posts": [ 309 * { "id": 1, "...": "..." }, 310 * { "id": 2, "...": "..." } 311 * ], 312 * "cursor_string": "eyJoZWxsbyI6IndvcmxkIn0", 313 * "sort": "id_asc" 314 * } 315 */ 316 public function show($id) 317 { 318 $topic = Topic::with(['forum'])->withTrashed()->findOrFail($id); 319 320 $userCanModerate = priv_check('ForumModerate', $topic->forum)->can(); 321 322 if ($topic->trashed() && !$userCanModerate) { 323 abort(404); 324 } 325 326 // TODO: firstPost is sometimes null when created by legacy process. 327 // Create an endpoint so the post is properly created in a transaction. 328 if ($topic->forum === null || $topic->firstPost === null) { 329 abort(404); 330 } 331 332 priv_check('ForumView', $topic->forum)->ensureCan(); 333 334 $currentUser = auth()->user(); 335 $params = $this->getIndexParams($topic, $currentUser, $userCanModerate); 336 337 $skipLayout = $params['skip_layout']; 338 $showDeleted = $params['with_deleted']; 339 340 $cursorHelper = Post::makeDbCursorHelper($params['sort']); 341 342 $postsQueryBase = $topic->posts()->showDeleted($showDeleted)->limit($params['limit']); 343 $posts = (clone $postsQueryBase)->cursorSort($cursorHelper, $params['cursor'])->get(); 344 345 $isJsonRequest = is_api_request(); 346 347 if (!$isJsonRequest && $posts->count() === 0) { 348 if ($skipLayout) { 349 return response(null, 204); 350 } else { 351 // make sure topic has posts at all otherwise this will be a redirect loop 352 if ($topic->posts()->showDeleted($showDeleted)->exists()) { 353 return ujs_redirect(route('forum.topics.show', $topic)); 354 } else { 355 abort(404); 356 } 357 } 358 } 359 360 if ($isJsonRequest || $skipLayout) { 361 $jumpTo = null; 362 } else { 363 $firstPost = $posts->first(); 364 $jumpTo = $firstPost->getKey(); 365 366 if ($cursorHelper->getSortName() === 'id_asc') { 367 if ($jumpTo !== $topic->topic_first_post_id) { 368 $extraSort = 'id_desc'; 369 } 370 } else { 371 $extraSort = 'id_asc'; 372 } 373 if (isset($extraSort)) { 374 $extraPosts = (clone $postsQueryBase)->cursorSort($extraSort, ['id' => $jumpTo])->get()->reverse(); 375 376 $posts = $extraPosts->concat($posts); 377 } 378 } 379 380 $posts->each(fn ($item) => $item 381 ->setRelation('forum', $topic->forum) 382 ->setRelation('topic', $topic)); 383 384 $nextCursor = $cursorHelper->next($posts); 385 386 if ($isJsonRequest) { 387 return array_merge([ 388 'posts' => json_collection($posts, 'Forum\Post', ['body']), 389 'search' => ['limit' => $params['limit'], 'sort' => $cursorHelper->getSortName()], 390 'topic' => json_item($topic, 'Forum\Topic'), 391 ], cursor_for_response($nextCursor)); 392 } 393 394 $posts->load([ 395 'lastEditor', 396 'user.country', 397 'user.rank', 398 'user.supporterTagPurchases', 399 'user.team', 400 'user.userGroups', 401 ]); 402 403 $navigatingBackwards = $cursorHelper->getSortName() === 'id_desc'; 404 if ($navigatingBackwards) { 405 $posts = $posts->reverse(); 406 } 407 408 $navUrls = [ 409 'next' => static::nextUrl($topic, 'id_asc', $nextCursor, $showDeleted), 410 'previous' => static::nextUrl( 411 $topic, 412 'id_desc', 413 $cursorHelper->next([$posts->first()]), 414 $showDeleted 415 ), 416 ]; 417 418 $firstShownPostId = $posts->first()->getKey(); 419 // position of the first post, incremented in the view 420 // to generate positions of further posts 421 $firstPostPosition = $topic->postPosition($firstShownPostId); 422 423 if ($skipLayout) { 424 return [ 425 'content' => view('forum.topics._posts', compact('posts', 'firstPostPosition', 'topic'))->render(), 426 'next_url' => $navigatingBackwards ? $navUrls['previous'] : $navUrls['next'], 427 ]; 428 } 429 430 $poll = $topic->poll(); 431 if ($poll->exists()) { 432 $topic->load([ 433 'pollOptions.votes', 434 'pollOptions.post', 435 ]); 436 $canEditPoll = $poll->canEdit() && priv_check('ForumTopicPollEdit', $topic)->can(); 437 } else { 438 $canEditPoll = false; 439 } 440 441 $pollSummary = PollOption::summary($topic, $currentUser); 442 443 $topic->incrementViewCount($currentUser, \Request::ip()); 444 $posts->last()->markRead($currentUser); 445 446 // Instantiate new cover model separately to prevent it from getting used by opengraph 447 $coverModel = $topic->cover ?? new TopicCover(['topic_id' => $topic->getKey()]); 448 $coverModel->setRelation('topic', $topic); 449 $cover = json_item($coverModel, new TopicCoverTransformer()); 450 451 $watch = TopicWatch::lookup($topic, $currentUser); 452 453 $featureVotes = $this->groupFeatureVotes($topic); 454 $noindex = !$topic->forum->enable_indexing; 455 456 set_opengraph($topic); 457 458 return ext_view('forum.topics.show', compact( 459 'canEditPoll', 460 'cover', 461 'watch', 462 'jumpTo', 463 'pollSummary', 464 'posts', 465 'featureVotes', 466 'firstPostPosition', 467 'navUrls', 468 'noindex', 469 'topic', 470 'userCanModerate', 471 'showDeleted' 472 )); 473 } 474 475 476 /** 477 * Create Topic 478 * 479 * Create a new topic. 480 * 481 * --- 482 * 483 * ### Response Format 484 * 485 * Field | Type | Includes 486 * ------ | -------------------------- | -------- 487 * topic | [ForumTopic](#forum-topic) | | 488 * post | [ForumPost](#forum-post) | body 489 * 490 * @bodyParam body string required Content of the topic. Example: hello 491 * @bodyParam forum_id integer required Forum to create the topic in. Example: 1 492 * @bodyParam title string required Title of the topic. Example: untitled 493 * @bodyParam with_poll boolean Enable this to also create poll in the topic (default: false). Example: 1 494 * @bodyParam forum_topic_poll[hide_results] boolean Enable this to hide result until voting period ends (default: false). No-example 495 * @bodyParam forum_topic_poll[length_days] integer Number of days for voting period. 0 means the voting will never ends (default: 0). This parameter is required if `hide_results` option is enabled. No-example 496 * @bodyParam forum_topic_poll[max_options] integer Maximum number of votes each user can cast (default: 1). No-example 497 * @bodyParam forum_topic_poll[options] string required Newline-separated list of voting options. BBCode is supported. Example: item A... 498 * @bodyParam forum_topic_poll[title] string required Title of the poll. Example: my poll 499 * @bodyParam forum_topic_poll[vote_change] boolean Enable this to allow user to change their votes (default: false). No-example 500 */ 501 public function store() 502 { 503 $params = get_params(request()->all(), null, [ 504 'body:string', 505 'cover_id:int', 506 'forum_id:int', 507 'title:string', 508 'with_poll:bool', 509 ], ['null_missing' => true]); 510 511 $forum = Forum::findOrFail($params['forum_id']); 512 $user = auth()->user(); 513 514 priv_check('ForumTopicStore', $forum)->ensureCan(); 515 516 if ($params['with_poll']) { 517 $poll = (new TopicPoll())->fill($this->getPollParams()); 518 519 if (!$poll->isValid()) { 520 return error_popup($poll->validationErrors()->toSentence()); 521 } 522 } 523 524 $topicParams = [ 525 'title' => $params['title'], 526 'user' => $user, 527 'body' => $params['body'], 528 'cover' => TopicCover::findForUse($params['cover_id'], $user), 529 ]; 530 531 $topic = Topic::createNew($forum, $topicParams, $poll ?? null); 532 533 if ($user->user_notify || $forum->isHelpForum()) { 534 TopicWatch::setState($topic, $user, 'watching_mail'); 535 } 536 537 $post = $topic->posts->last(); 538 $post->markRead($user); 539 540 if (is_api_request()) { 541 return [ 542 'topic' => json_item($topic, 'Forum\Topic'), 543 'post' => json_item($post, 'Forum\Post', ['body']), 544 ]; 545 } else { 546 return ujs_redirect(route('forum.topics.show', $topic)); 547 } 548 } 549 550 /** 551 * Edit Topic 552 * 553 * Edit topic. Only title can be edited through this endpoint. 554 * 555 * --- 556 * 557 * ### Response Format 558 * 559 * The edited [ForumTopic](#forum-topic). 560 * 561 * @urlParam topic integer required Id of the topic. Example: 1 562 * @bodyParam forum_topic[topic_title] string New topic title. Example: titled 563 */ 564 public function update($id) 565 { 566 $topic = Topic::withTrashed()->findOrFail($id); 567 568 if (!priv_check('ForumTopicEdit', $topic)->can()) { 569 abort(403); 570 } 571 572 $params = get_params(request()->all(), 'forum_topic', ['topic_title']); 573 574 if ($topic->update($params)) { 575 if ((Auth::user()->user_id ?? null) !== $topic->topic_poster) { 576 $this->logModerate( 577 'LOG_EDIT_TOPIC', 578 [$topic->topic_title], 579 $topic 580 ); 581 } 582 583 if (is_api_request()) { 584 return json_item($topic, 'Forum\Topic'); 585 } else { 586 return response(null, 204); 587 } 588 } else { 589 return error_popup($topic->validationErrors()->toSentence()); 590 } 591 } 592 593 public function vote($topicId) 594 { 595 $topic = Topic::findOrFail($topicId); 596 597 priv_check('ForumTopicVote', $topic)->ensureCan(); 598 599 $params = get_params(Request::input(), 'forum_topic_vote', ['option_ids:int[]']); 600 $params['user_id'] = Auth::user()->user_id; 601 $params['ip'] = Request::ip(); 602 603 if ($topic->vote()->fill($params)->save()) { 604 return ujs_redirect(route('forum.topics.show', $topic->topic_id)); 605 } else { 606 return error_popup($topic->vote()->validationErrors()->toSentence()); 607 } 608 } 609 610 public function voteFeature($topicId) 611 { 612 $star = FeatureVote::createNew([ 613 'user_id' => Auth::user()->user_id, 614 'topic_id' => $topicId, 615 ]); 616 617 if ($star->getKey() !== null) { 618 return ujs_redirect(route('forum.topics.show', $topicId)); 619 } else { 620 return error_popup($star->validationErrors()->toSentence()); 621 } 622 } 623 624 private function getIndexParams($topic, $currentUser, $userCanModerate) 625 { 626 $rawParams = request()->all(); 627 $params = get_params($rawParams, null, [ 628 'start', // either number or "unread" or "latest" 629 'end:int', 630 'n:int', 631 632 'skip_layout:bool', 633 'with_deleted:bool', 634 635 'sort:string', 636 'limit:int', 637 ], ['null_missing' => true]); 638 639 $params['skip_layout'] = $params['skip_layout'] ?? false; 640 $params['limit'] = \Number::clamp($params['limit'] ?? Post::PER_PAGE, 1, 50); 641 642 if ($userCanModerate) { 643 $params['with_deleted'] ??= UserProfileCustomization::forUser($currentUser)['forum_posts_show_deleted']; 644 } else { 645 $params['with_deleted'] = false; 646 } 647 648 $params['cursor'] = cursor_from_params($rawParams); 649 650 if (!is_array($params['cursor'])) { 651 $params['start'] = match ($params['start']) { 652 'latest' => $topic->topic_last_post_id, 653 'unread' => Post::lastUnreadByUser($topic, $currentUser), 654 default => get_int($params['start']), 655 }; 656 657 if ($params['n'] !== null && $params['n'] > 0) { 658 $post = $topic->nthPost($params['n']) ?? $topic->posts()->last(); 659 if ($post !== null) { 660 $params['cursor'] = ['id' => $post->getKey() - 1]; 661 $params['sort'] = 'id_asc'; 662 } 663 } elseif ($params['start'] !== null) { 664 $params['cursor'] = ['id' => $params['start'] - 1]; 665 $params['sort'] = 'id_asc'; 666 } elseif ($params['end'] !== null) { 667 $params['cursor'] = ['id' => $params['end'] + 1]; 668 $params['sort'] = 'id_desc'; 669 } 670 } 671 672 return $params; 673 } 674 675 private function getPollParams() 676 { 677 return get_params(request()->all(), 'forum_topic_poll', [ 678 'hide_results:bool', 679 'length_days:int', 680 'max_options:int', 681 'options:string_split', 682 'title', 683 'vote_change:bool', 684 ]); 685 } 686 687 private function groupFeatureVotes($topic) 688 { 689 if (!$topic->isFeatureTopic()) { 690 return []; 691 } 692 693 $ret = []; 694 695 foreach ($topic->featureVotes()->with('user')->get() as $vote) { 696 $username = optional($vote->user)->username; 697 $ret[$username] ?? ($ret[$username] = 0); 698 $ret[$username] += $vote->voteIncrement(); 699 } 700 701 arsort($ret); 702 703 return $ret; 704 } 705}