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;
7
8use App\Exceptions\ModelNotSavedException;
9use App\Jobs\Notifications\CommentNew;
10use App\Libraries\CommentBundle;
11use App\Libraries\MorphMap;
12use App\Models\Comment;
13use App\Models\Log;
14use App\Models\User;
15use Carbon\Carbon;
16use Exception;
17use Illuminate\Pagination\LengthAwarePaginator;
18
19/**
20 * @group Comments
21 */
22class CommentsController extends Controller
23{
24 public function __construct()
25 {
26 parent::__construct();
27
28 $this->middleware('auth', ['except' => ['index', 'show']]);
29 }
30
31 /**
32 * Delete Comment
33 *
34 * Deletes the specified comment.
35 *
36 * ---
37 *
38 * ### Response Format
39 *
40 * Returns [CommentBundle](#commentbundle)
41 */
42 public function destroy($id)
43 {
44 $comment = Comment::findOrFail($id);
45
46 priv_check('CommentDestroy', $comment)->ensureCan();
47
48 $comment->softDelete(auth()->user());
49
50 if ($comment->user_id !== auth()->user()->getKey()) {
51 $this->logModerate('LOG_COMMENT_DELETE', $comment);
52 }
53
54 return CommentBundle::forComment($comment)->toArray();
55 }
56
57 /**
58 * Get Comments
59 *
60 * Returns a list comments and their replies up to 2 levels deep.
61 *
62 * ---
63 *
64 * ### Response Format
65 *
66 * Returns [CommentBundle](#commentbundle).
67 *
68 * `pinned_comments` is only included when `commentable_type` and `commentable_id` are specified.
69 *
70 * @queryParam after Return comments which come after the specified comment id as per sort option. No-example
71 * @queryParam commentable_type The type of resource to get comments for. Example: beatmapset
72 * @queryParam commentable_id The id of the resource to get comments for. Example: 1
73 * @queryParam cursor Pagination option. See [CommentSort](#commentsort) for detail. The format follows [Cursor](#cursor) except it's not currently included in the response. No-example
74 * @queryParam parent_id Limit to comments which are reply to the specified id. Specify 0 to get top level comments. Example: 1
75 * @queryParam sort Sort option as defined in [CommentSort](#commentsort). Defaults to `new` for guests and user-specified default when authenticated. Example: new
76 */
77 public function index()
78 {
79 $params = request()->all();
80
81 $userId = $params['user_id'] ?? null;
82
83 if ($userId !== null) {
84 $user = User::lookup($userId, 'id', true);
85
86 if ($user === null || !priv_check('UserShow', $user)->can()) {
87 abort(404);
88 }
89 }
90
91 $id = $params['commentable_id'] ?? null;
92 $type = $params['commentable_type'] ?? null;
93
94 if (isset($type) && isset($id)) {
95 if (!Comment::isValidType($type)) {
96 abort(422);
97 }
98
99 $class = MorphMap::getClass($type);
100 $commentable = $class::findOrFail($id);
101 }
102
103 $params['sort'] = $params['sort'] ?? Comment::DEFAULT_SORT;
104 $commentBundle = new CommentBundle(
105 $commentable ?? null,
106 ['params' => $params]
107 );
108
109 if (is_json_request()) {
110 return $commentBundle->toArray();
111 } else {
112 $commentBundle->depth = 0;
113 $commentBundle->includePinned = false;
114
115 $commentPagination = new LengthAwarePaginator(
116 [],
117 $commentBundle->countForPaginator(),
118 $commentBundle->params->limit,
119 $commentBundle->params->page,
120 [
121 'path' => LengthAwarePaginator::resolveCurrentPath(),
122 'query' => $commentBundle->params->forUrl(),
123 ]
124 );
125
126 return ext_view('comments.index', compact('commentBundle', 'commentPagination'));
127 }
128 }
129
130 public function restore($id)
131 {
132 $comment = Comment::findOrFail($id);
133
134 priv_check('CommentRestore', $comment)->ensureCan();
135
136 $comment->restore();
137
138 $this->logModerate('LOG_COMMENT_RESTORE', $comment);
139
140 return CommentBundle::forComment($comment)->toArray();
141 }
142
143 /**
144 * Get a Comment
145 *
146 * Gets a comment and its replies up to 2 levels deep.
147 *
148 * ---
149 *
150 * ### Response Format
151 *
152 * Returns [CommentBundle](#commentbundle)
153 */
154 public function show($id)
155 {
156 $comment = Comment::findOrFail($id);
157
158 $commentBundle = CommentBundle::forComment($comment, true);
159
160 if (is_json_request()) {
161 return $commentBundle->toArray();
162 }
163
164 set_opengraph($comment);
165
166 return ext_view('comments.show', compact('commentBundle'));
167 }
168
169 /**
170 * Post a new comment
171 *
172 * Posts a new comment to a comment thread.
173 *
174 * ---
175 *
176 * ### Response Format
177 *
178 * Returns [CommentBundle](#commentbundle)
179 *
180 * @queryParam comment.commentable_id Resource ID the comment thread is attached to
181 * @queryParam comment.commentable_type Resource type the comment thread is attached to
182 * @queryParam comment.message Text of the comment
183 * @queryParam comment.parent_id The id of the comment to reply to, null if not a reply
184 */
185 public function store()
186 {
187 $user = auth()->user();
188
189 $params = get_params(request()->all(), 'comment', [
190 'commentable_id:int',
191 'commentable_type',
192 'message',
193 'parent_id:int',
194 ]);
195 $params['user_id'] = optional($user)->getKey();
196
197 $comment = new Comment($params);
198 $comment->setCommentable();
199
200 if ($comment->commentable === null) {
201 abort(422, 'invalid commentable specified');
202 }
203
204 priv_check('CommentStore', $comment->commentable)->ensureCan();
205
206 try {
207 $comment->saveOrExplode();
208 } catch (ModelNotSavedException $e) {
209 return error_popup($e->getMessage());
210 }
211
212 (new CommentNew($comment, $user))->dispatch();
213
214 return CommentBundle::forComment($comment)->toArray();
215 }
216
217 /**
218 * Edit Comment
219 *
220 * Edit an existing comment.
221 *
222 * ---
223 *
224 * ### Response Format
225 *
226 * Returns [CommentBundle](#commentbundle)
227 *
228 * @queryParam comment.message New text of the comment
229 */
230 public function update($id)
231 {
232 $comment = Comment::findOrFail($id);
233
234 priv_check('CommentUpdate', $comment)->ensureCan();
235
236 $params = get_params(request()->all(), 'comment', ['message']);
237 $params['edited_by_id'] = auth()->user()->getKey();
238 $params['edited_at'] = Carbon::now();
239 $comment->update($params);
240
241 if ($comment->user_id !== auth()->user()->getKey()) {
242 $this->logModerate('LOG_COMMENT_UPDATE', $comment);
243 }
244
245 return CommentBundle::forComment($comment)->toArray();
246 }
247
248 public function pinDestroy($id)
249 {
250 $comment = Comment::findOrFail($id);
251
252 priv_check('CommentPin', $comment)->ensureCan();
253
254 $comment->fill(['pinned' => false])->saveOrExplode();
255
256 return CommentBundle::forComment($comment)->toArray();
257 }
258
259 public function pinStore($id)
260 {
261 $comment = Comment::findOrFail($id);
262
263 priv_check('CommentPin', $comment)->ensureCan();
264
265 $comment->fill(['pinned' => true])->saveOrExplode();
266
267 return CommentBundle::forComment($comment)->toArray();
268 }
269
270 /**
271 * Remove Comment vote
272 *
273 * Un-upvotes a comment.
274 *
275 * ---
276 *
277 * ### Response Format
278 *
279 * Returns [CommentBundle](#commentbundle)
280 */
281 public function voteDestroy($id)
282 {
283 $comment = Comment::findOrFail($id);
284
285 priv_check('CommentVote', $comment)->ensureCan();
286
287 $vote = $comment->votes()->where([
288 'user_id' => auth()->user()->getKey(),
289 ])->first();
290
291 optional($vote)->delete();
292
293 return CommentBundle::forComment($comment->fresh(), false)->toArray();
294 }
295
296 /**
297 * Add Comment vote
298 *
299 * Upvotes a comment.
300 *
301 * ---
302 *
303 * ### Response Format
304 *
305 * Returns [CommentBundle](#commentbundle)
306 */
307 public function voteStore($id)
308 {
309 $comment = Comment::findOrFail($id);
310
311 priv_check('CommentVote', $comment)->ensureCan();
312
313 try {
314 $comment->votes()->create([
315 'user_id' => auth()->user()->getKey(),
316 ]);
317 } catch (Exception $e) {
318 if (!is_sql_unique_exception($e)) {
319 throw $e;
320 }
321 }
322
323 return CommentBundle::forComment($comment->fresh(), false)->toArray();
324 }
325
326 private function logModerate($operation, $comment)
327 {
328 $this->log([
329 'log_type' => Log::LOG_COMMENT_MOD,
330 'log_operation' => $operation,
331 'log_data' => [
332 'commentable_type' => $comment->commentable_type,
333 'commentable_id' => $comment->commentable_id,
334 'id' => $comment->getKey(),
335 ],
336 ]);
337 }
338}