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}