the browser-facing portion of osu!
at master 13 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\Exceptions\ModelNotSavedException; 9use App\Traits\Validatable; 10use Carbon\Carbon; 11use DB; 12use Ds\Set; 13 14/** 15 * @property BeatmapDiscussion $beatmapDiscussion 16 * @property int $beatmap_discussion_id 17 * @property \Carbon\Carbon|null $created_at 18 * @property \Carbon\Carbon|null $deleted_at 19 * @property int|null $deleted_by_id 20 * @property int $id 21 * @property int|null $last_editor_id 22 * @property string $message 23 * @property bool $system 24 * @property \Carbon\Carbon|null $updated_at 25 * @property User $user 26 * @property int|null $user_id 27 */ 28class BeatmapDiscussionPost extends Model implements Traits\ReportableInterface 29{ 30 use Traits\Reportable, Validatable; 31 32 const MESSAGE_LIMIT = 16_000; // column limit for 4 bytes utf8 33 const MESSAGE_LIMIT_TIMELINE = 750; 34 35 protected $touches = ['beatmapDiscussion']; 36 37 protected $casts = [ 38 'deleted_at' => 'datetime', 39 'system' => 'boolean', 40 ]; 41 42 public static function search($rawParams = []) 43 { 44 [$query, $params] = static::searchQueryAndParams(cursor_from_params($rawParams) ?? $rawParams); 45 46 $isModerator = $rawParams['is_moderator'] ?? false; 47 48 if (isset($rawParams['user'])) { 49 $params['user'] = $rawParams['user']; 50 $findAll = $isModerator || (($rawParams['current_user_id'] ?? null) === $rawParams['user']); 51 $user = User::lookup($params['user'], null, $findAll); 52 53 if ($user === null) { 54 $query->none(); 55 } else { 56 $query->where('user_id', $user->getKey()); 57 } 58 } 59 60 $types = (new Set(get_arr($rawParams['types'] ?? null, 'get_string') ?? [])) 61 ->intersect(new Set(['first', 'reply', 'system'])); 62 63 if ($types->isEmpty()) { 64 $types->add('reply'); 65 } 66 67 $query->byTypes($types); 68 $params['types'] = $types->toArray(); 69 70 if (isset($rawParams['sort'])) { 71 $sort = explode('_', strtolower($rawParams['sort'])); 72 73 if (in_array($sort[0] ?? null, ['id'], true)) { 74 $sortField = $sort[0]; 75 } 76 77 if (in_array($sort[1] ?? null, ['asc', 'desc'], true)) { 78 $sortOrder = $sort[1]; 79 } 80 } 81 82 $sortField ??= 'id'; 83 $sortOrder ??= 'desc'; 84 85 $params['sort'] = "{$sortField}_{$sortOrder}"; 86 $query->orderBy($sortField, $sortOrder); 87 88 $params['beatmapset_discussion_id'] = get_int($rawParams['beatmapset_discussion_id'] ?? null); 89 if ($params['beatmapset_discussion_id'] !== null) { 90 // column name is beatmap_ =) 91 $query->where('beatmap_discussion_id', $params['beatmapset_discussion_id']); 92 } 93 94 $params['with_deleted'] = get_bool($rawParams['with_deleted'] ?? null) ?? false; 95 96 if (!$params['with_deleted']) { 97 // $query->visible() may be slow for listing; calls visibleBeatmapDiscussion which calls more scopes... 98 $query->withoutTrashed(); 99 } 100 101 // TODO: normalize with main beatmapset discussion behaviour (needs React-side fixing) 102 if (!isset($params['user']) && !$isModerator) { 103 $query->whereHas('user', function ($userQuery) { 104 $userQuery->default(); 105 }); 106 } 107 108 return ['query' => $query, 'params' => $params]; 109 } 110 111 public static function generateLogResolveChange($user, $resolved) 112 { 113 return new static([ 114 'user_id' => $user->user_id, 115 'system' => true, 116 'message' => [ 117 'type' => 'resolved', 118 'value' => $resolved, 119 ], 120 ]); 121 } 122 123 public static function parseTimestamp($message) 124 { 125 preg_match('/\b(\d{2,}):([0-5]\d)[:.](\d{3})\b/', $message, $matches); 126 127 if (count($matches) === 4) { 128 $m = (int) $matches[1]; 129 $s = (int) $matches[2]; 130 $ms = (int) $matches[3]; 131 132 return ($m * 60 + $s) * 1000 + $ms; 133 } 134 } 135 136 public function beatmapset() 137 { 138 return $this->hasOneThrough( 139 Beatmapset::class, 140 BeatmapDiscussion::class, 141 'id', 142 'beatmapset_id', 143 'beatmap_discussion_id', 144 'beatmapset_id' 145 )->withTrashed(); 146 } 147 148 public function beatmapDiscussion() 149 { 150 return $this->belongsTo(BeatmapDiscussion::class); 151 } 152 153 public function visibleBeatmapDiscussion() 154 { 155 return $this->beatmapDiscussion()->visible(); 156 } 157 158 public function user() 159 { 160 return $this->belongsTo(User::class, 'user_id'); 161 } 162 163 /** 164 * Whether a post can be edited/deleted. 165 * 166 * When a discussion is resolved, the posts preceeding the resolution are locked. 167 * Posts after the resolution are not locked, unless the issue is re-opened and resolved again. 168 * 169 * @return bool 170 */ 171 public function canEdit() 172 { 173 if ($this->system) { 174 return false; 175 } 176 177 // The only system post type currently implemented is 'resolved', so we're making the assumption 178 // the next system post is always going to be either a resolve or unresolve. 179 // This will have to be changed if more types are added. 180 $systemPost = static::where('system', true) 181 ->where('id', '>', $this->id) 182 ->where('beatmap_discussion_id', $this->beatmap_discussion_id) 183 ->last(); 184 185 return $this->getKey() > optional($systemPost)->getKey(); 186 } 187 188 public function validateBeatmapsetDiscussion() 189 { 190 if ($this->beatmapDiscussion === null) { 191 $this->validationErrors()->add('beatmap_discussion_id', 'required'); 192 193 return; 194 } 195 196 // only applies on saved posts 197 static $modifiableWhenLocked = [ 198 'deleted_at', 199 'deleted_by_id', 200 ]; 201 202 if (!$this->exists || count(array_diff(array_keys($this->getDirty()), $modifiableWhenLocked)) > 0) { 203 if ($this->beatmapDiscussion->isLocked()) { 204 $this->validationErrors()->add('beatmap_discussion_id', '.discussion_locked'); 205 } 206 } 207 } 208 209 public function isValid() 210 { 211 $this->validationErrors()->reset(); 212 213 if ($this->deleted_at !== null && $this->isFirstPost()) { 214 $this->validationErrors()->add('base', '.first_post'); 215 } 216 217 $this->validateBeatmapsetDiscussion(); 218 219 if (!$this->system) { 220 if (!present($this->message)) { 221 $this->validationErrors()->add('message', 'required'); 222 } 223 224 $limit = $this->beatmapDiscussion?->timestamp === null 225 ? static::MESSAGE_LIMIT 226 : static::MESSAGE_LIMIT_TIMELINE; 227 $this->validateDbFieldLength($limit, 'message'); 228 } 229 230 return $this->validationErrors()->isEmpty(); 231 } 232 233 public function validationErrorsTranslationPrefix(): string 234 { 235 return 'beatmapset_discussion_post'; 236 } 237 238 public function save(array $options = []) 239 { 240 if (!$this->isValid()) { 241 return false; 242 } 243 244 $origExists = $this->exists; 245 246 try { 247 return $this->getConnection()->transaction(function () use ($options) { 248 if (!$this->exists) { 249 $this->beatmapDiscussion->update(['last_post_at' => Carbon::now()]); 250 } 251 252 if (!parent::save($options)) { 253 throw new ModelNotSavedException(); 254 } 255 256 $this->beatmapDiscussion->refreshTimestampOrExplode(); 257 258 return true; 259 }); 260 } catch (ModelNotSavedException $_e) { 261 $this->exists = $origExists; 262 $this->validationErrors()->merge($this->beatmapDiscussion->validationErrors()); 263 264 return false; 265 } 266 } 267 268 public function getMessageAttribute($value) 269 { 270 if ($this->system) { 271 return json_decode($value, true); 272 } else { 273 return $value; 274 } 275 } 276 277 public function setMessageAttribute($value) 278 { 279 // don't shoot me ;_; 280 if ($this->system || is_array($value)) { 281 $value = json_encode($value); 282 } 283 284 $this->attributes['message'] = trim($value); 285 } 286 287 public function isFirstPost() 288 { 289 return !static 290 ::where('beatmap_discussion_id', $this->beatmap_discussion_id) 291 ->where('id', '<', $this->id)->exists(); 292 } 293 294 public function relatedSystemPost() 295 { 296 if ($this->system) { 297 return; 298 } 299 300 $nextPost = static 301 ::where('id', '>', $this->getKey()) 302 ->orderBy('id', 'ASC') 303 ->first(); 304 305 if ($nextPost !== null && $nextPost->system && $nextPost->user_id === $this->user_id) { 306 return $nextPost; 307 } 308 } 309 310 public function restore($restoredBy) 311 { 312 return DB::transaction(function () use ($restoredBy) { 313 if ($restoredBy->getKey() !== $this->user_id) { 314 BeatmapsetEvent::log(BeatmapsetEvent::DISCUSSION_POST_RESTORE, $restoredBy, $this)->saveOrExplode(); 315 } 316 317 // restore related system post 318 $systemPost = $this->relatedSystemPost(); 319 320 if ($systemPost !== null) { 321 $systemPost->restore($restoredBy); 322 } 323 324 $this->update(['deleted_at' => null]); 325 326 $this->beatmapDiscussion->refreshResolved(); 327 328 return true; 329 }); 330 } 331 332 public function softDeleteOrExplode($deletedBy) 333 { 334 DB::transaction(function () use ($deletedBy) { 335 if ($deletedBy->getKey() !== $this->user_id) { 336 BeatmapsetEvent::log(BeatmapsetEvent::DISCUSSION_POST_DELETE, $deletedBy, $this)->saveOrExplode(); 337 } 338 339 // delete related system post 340 $systemPost = $this->relatedSystemPost(); 341 342 if ($systemPost !== null) { 343 $systemPost->softDeleteOrExplode($deletedBy); 344 } 345 346 $this->fill([ 347 'deleted_by_id' => $deletedBy->user_id, 348 'deleted_at' => Carbon::now(), 349 ])->saveOrExplode(); 350 351 $this->beatmapDiscussion->refreshResolved(); 352 }); 353 } 354 355 public function trashed() 356 { 357 return $this->deleted_at !== null; 358 } 359 360 public function timestamp() 361 { 362 return static::parseTimestamp($this->message); 363 } 364 365 public function scopeByTypes($query, Set $types) 366 { 367 $query->where(function ($q) use ($types) { 368 if ($types->contains('system')) { 369 $q->where('system', true); 370 } 371 372 $firstOrReplyCount = $types->intersect(new Set(['first', 'reply']))->count(); 373 if ($firstOrReplyCount > 0) { 374 $q->orWhere(function ($replyQuery) use ($firstOrReplyCount, $types) { 375 $replyQuery->where('system', false); 376 377 if ($firstOrReplyCount === 1) { 378 $replyQuery->where(fn ($q) => $q->firstFilter($types->contains('first'))); 379 } 380 381 return $replyQuery; 382 }); 383 } 384 385 return $q; 386 }); 387 } 388 389 public function scopeFirstFilter($query, $isFirst = true) 390 { 391 $table = $this->getTable(); 392 393 $condition = $isFirst ? 'whereNotExists' : 'whereExists'; 394 395 return $query->$condition(fn ($q) => $q 396 ->selectRaw(1) 397 ->from(DB::raw("{$table} d")) 398 ->whereRaw("d.beatmap_discussion_id = {$table}.beatmap_discussion_id") 399 ->whereRaw("d.id < {$table}.id")); 400 } 401 402 public function scopeWithoutTrashed($query) 403 { 404 $query->whereNull('deleted_at'); 405 } 406 407 public function scopeWithoutSystem($query) 408 { 409 $query->where('system', '=', false); 410 } 411 412 public function scopeVisible($query) 413 { 414 $query->withoutTrashed() 415 ->whereHas('visibleBeatmapDiscussion'); 416 } 417 418 public function url() 419 { 420 return route('beatmapsets.discussions.posts.show', $this->getKey()); 421 } 422 423 protected function newReportableExtraParams(): array 424 { 425 return [ 426 'reason' => 'Spam', 427 'user_id' => $this->user_id, 428 ]; 429 } 430}