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;
7
8use App\Libraries\MorphMap;
9use App\Traits\Memoizes;
10use App\Traits\Validatable;
11use Carbon\Carbon;
12use Illuminate\Database\Eloquent\Builder;
13
14/**
15 * @property mixed $commentable
16 * @property int|null $commentable_id
17 * @property mixed|null $commentable_type
18 * @property \Carbon\Carbon|null $created_at
19 * @property \Carbon\Carbon|null $deleted_at
20 * @property int|null $deleted_by_id
21 * @property int|null $disqus_id
22 * @property int|null $disqus_parent_id
23 * @property string|null $disqus_thread_id
24 * @property array|null $disqus_user_data
25 * @property \Carbon\Carbon|null $edited_at
26 * @property int|null $edited_by_id
27 * @property User $editor
28 * @property int $id
29 * @property string $message
30 * @property static $parent
31 * @property int|null $parent_id
32 * @property bool $pinned
33 * @property \Illuminate\Database\Eloquent\Collection $replies static
34 * @property int $replies_count_cache
35 * @property \Carbon\Carbon|null $updated_at
36 * @property User $user
37 * @property int|null $user_id
38 * @property \Illuminate\Database\Eloquent\Collection $votes CommentVote
39 * @property int $votes_count_cache
40 */
41class Comment extends Model implements Traits\ReportableInterface
42{
43 use Memoizes, Traits\Reportable, Traits\WithDbCursorHelper, Validatable;
44
45 const COMMENTABLES = [
46 MorphMap::MAP[Beatmapset::class],
47 MorphMap::MAP[Build::class],
48 MorphMap::MAP[NewsPost::class],
49 ];
50
51 const MAX_FIELD_LENGTHS = [
52 // FIXME: decide on good number.
53 // some people seem to put song lyrics in comment which inflated the size.
54 'message' => 10000,
55 ];
56
57 const SORTS = [
58 'new' => [
59 ['column' => 'created_at', 'order' => 'DESC', 'type' => 'time'],
60 ['column' => 'id', 'order' => 'DESC'],
61 ],
62 'old' => [
63 ['column' => 'created_at', 'order' => 'ASC', 'type' => 'time'],
64 ['column' => 'id', 'order' => 'ASC'],
65 ],
66 'top' => [
67 ['column' => 'votes_count_cache', 'columnInput' => 'votes_count', 'order' => 'DESC'],
68 ['column' => 'created_at', 'order' => 'DESC', 'type' => 'time'],
69 ['column' => 'id', 'order' => 'DESC'],
70 ],
71 ];
72
73 const DEFAULT_SORT = 'new';
74
75 public $allowEmptyCommentable = false;
76
77 protected $casts = [
78 'deleted_at' => 'datetime',
79 'disqus_user_data' => 'array',
80 'edited_at' => 'datetime',
81 'pinned' => 'boolean',
82 ];
83
84 public static function isValidType($type)
85 {
86 return in_array($type, static::COMMENTABLES, true);
87 }
88
89 public function scopePinned(Builder $query): Builder
90 {
91 return $query->where('pinned', true);
92 }
93
94 public function scopeWithoutTrashed($query)
95 {
96 return $query->whereNull('deleted_at');
97 }
98
99 public function commentable()
100 {
101 return $this->morphTo();
102 }
103
104 public function editor()
105 {
106 return $this->belongsTo(User::class, 'edited_by_id');
107 }
108
109 public function user()
110 {
111 return $this->belongsTo(User::class, 'user_id');
112 }
113
114 public function parent()
115 {
116 return $this->belongsTo(static::class, 'parent_id');
117 }
118
119 public function replies()
120 {
121 return $this->hasMany(static::class, 'parent_id');
122 }
123
124 public function setMessageAttribute($value)
125 {
126 $this->resetMemoized();
127
128 return $this->attributes['message'] = trim(unzalgo($value));
129 }
130
131 public function votes()
132 {
133 return $this->hasMany(CommentVote::class);
134 }
135
136 public function setCommentableTypeAttribute($value)
137 {
138 if (!static::isValidType($value)) {
139 $value = null;
140 }
141
142 $this->attributes['commentable_type'] = $value;
143 }
144
145 public function getAttribute($key)
146 {
147 return match ($key) {
148 'commentable_id',
149 'commentable_type',
150 'deleted_by_id',
151 'disqus_id',
152 'disqus_parent_id',
153 'disqus_thread_id',
154 'edited_by_id',
155 'id',
156 'message',
157 'parent_id',
158 'replies_count_cache',
159 'user_id',
160 'votes_count_cache' => $this->getRawAttribute($key),
161
162 'created_at',
163 'deleted_at',
164 'edited_at',
165 'updated_at' => $this->getTimeFast($key),
166
167 'created_at_json',
168 'deleted_at_json',
169 'edited_at_json',
170 'updated_at_json' => $this->getJsonTimeFast($key),
171
172 'pinned' => (bool) $this->getRawAttribute($key),
173
174 'disqus_user_data' => $this->getArray($key),
175 'message_html' => $this->getMessageHtml(),
176
177 'commentable',
178 'editor',
179 'parent',
180 'replies',
181 'reportedIn',
182 'user',
183 'votes' => $this->getRelationValue($key),
184 };
185 }
186
187 public function setCommentable()
188 {
189 if ($this->parent_id === null || $this->parent === null) {
190 if ($this->commentable_type !== null) {
191 return;
192 }
193 // Reset the id if type is null otherwise Laravel will try to
194 // "eager load" the commentable (whatever tha means in this context).
195 // Note that setting type to random string doesn't work because
196 // Laravel will happily try to create the random string class.
197 //
198 // Reference: https://github.com/laravel/framework/blob/53b02b3c1d926c095cccca06883a35a5c6729773/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php#L279-L281
199 $this->commentable_id = null;
200 } else {
201 $this->commentable_id = $this->parent->commentable_id;
202 $this->commentable_type = $this->parent->commentable_type;
203 }
204
205 $this->unsetRelation('commentable');
206 }
207
208 public function isValid()
209 {
210 $this->validationErrors()->reset();
211
212 if ($this->isDirty('pinned') && $this->pinned && $this->parent_id !== null) {
213 $this->validationErrors()->add('pinned', '.top_only');
214 }
215
216 if (!present($this->message)) {
217 $this->validationErrors()->add('message', 'required');
218 }
219
220 $this->validateDbFieldLengths();
221
222 if ($this->isDirty('parent_id') && $this->parent_id !== null) {
223 if ($this->parent === null) {
224 $this->validationErrors()->add('parent_id', 'invalid');
225 } elseif ($this->parent->trashed()) {
226 $this->validationErrors()->add('parent_id', '.deleted_parent');
227 }
228 }
229
230 if (
231 !$this->allowEmptyCommentable && (
232 $this->commentable_type === null ||
233 $this->commentable_id === null ||
234 !$this->commentable()->exists()
235 ) && !$this->isDirty('deleted_at')
236 ) {
237 $this->validationErrors()->add('commentable', 'required');
238 }
239
240 return $this->validationErrors()->isEmpty();
241 }
242
243 public function url()
244 {
245 return route('comments.show', ['comment' => $this->getKey()]);
246 }
247
248 public function validationErrorsTranslationPrefix(): string
249 {
250 return 'comment';
251 }
252
253 public function save(array $options = [])
254 {
255 if (!$this->isValid()) {
256 return false;
257 }
258
259 return $this->getConnection()->transaction(function () use ($options) {
260 if (!$this->exists && $this->parent_id !== null && $this->parent !== null) {
261 // skips validation and everything
262 $this->parent->incrementInstance('replies_count_cache', 1, ['updated_at' => Carbon::now()]);
263 }
264
265 if ($this->isDirty('deleted_at')) {
266 if (isset($this->deleted_at)) {
267 $this->votes_count_cache = 0;
268 } else {
269 $this->votes_count_cache = $this->votes()->count();
270 }
271 }
272
273 return parent::save($options);
274 });
275 }
276
277 public function legacyName()
278 {
279 return presence($this->disqus_user_data['name'] ?? null);
280 }
281
282 public function trashed()
283 {
284 return $this->deleted_at !== null;
285 }
286
287 public function softDelete($deletedBy)
288 {
289 return $this->update([
290 'deleted_at' => now(),
291 'deleted_by_id' => $deletedBy->getKey(),
292 'pinned' => false,
293 ]);
294 }
295
296 public function restore()
297 {
298 return $this->update(['deleted_at' => null]);
299 }
300
301 protected function newReportableExtraParams(): array
302 {
303 return [
304 'reason' => 'Spam',
305 'user_id' => $this->user_id ?? 0,
306 ];
307 }
308
309 private function getMessageHtml(): ?string
310 {
311 return $this->memoize(__FUNCTION__, fn () => markdown($this->message, 'comment'));
312 }
313}