the browser-facing portion of osu!
at master 9.1 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; 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}