the browser-facing portion of osu!
at master 12 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\Forum; 7 8use App\Casts\TimestampOrZero; 9use App\Exceptions\ModelNotSavedException; 10use App\Jobs\EsDocument; 11use App\Jobs\MarkNotificationsRead; 12use App\Libraries\BBCodeForDB; 13use App\Libraries\BBCodeFromDB; 14use App\Libraries\Elasticsearch\Indexable; 15use App\Libraries\Transactions\AfterCommit; 16use App\Models\Beatmapset; 17use App\Models\DeletedUser; 18use App\Models\Traits; 19use App\Models\User; 20use App\Traits\Validatable; 21use Carbon\Carbon; 22use DB; 23use Illuminate\Database\Eloquent\SoftDeletes; 24 25/** 26 * @property string $bbcode_bitfield 27 * @property string $bbcode_uid 28 * @property mixed $body_raw 29 * @property \Carbon\Carbon|null $deleted_at 30 * @property int $enable_bbcode 31 * @property int $enable_magic_url 32 * @property int $enable_sig 33 * @property int $enable_smilies 34 * @property Forum $forum 35 * @property int $forum_id 36 * @property int $icon_id 37 * @property User $lastEditor 38 * @property int $osu_kudosobtained 39 * @property bool $post_approved 40 * @property int $post_attachment 41 * @property int $post_edit_count 42 * @property bool $post_edit_locked 43 * @property string $post_edit_reason 44 * @property int $post_edit_time 45 * @property int $post_edit_user 46 * @property int $post_id 47 * @property mixed $post_position 48 * @property int $post_postcount 49 * @property int $post_reported 50 * @property string $post_subject 51 * @property mixed $post_text 52 * @property \Carbon\Carbon|null $post_time 53 * @property string $post_username 54 * @property int $poster_id 55 * @property string $poster_ip 56 * @property mixed $search_content 57 * @property Topic $topic 58 * @property int $topic_id 59 * @property User $user 60 */ 61class Post extends Model implements AfterCommit, Indexable, Traits\ReportableInterface 62{ 63 use Traits\Es\ForumPostSearch, Traits\Reportable, Traits\WithDbCursorHelper, Validatable; 64 use SoftDeletes { 65 restore as private origRestore; 66 } 67 68 const SORTS = [ 69 'id_asc' => [ 70 ['column' => 'post_id', 'columnInput' => 'id', 'order' => 'ASC'], 71 ], 72 'id_desc' => [ 73 ['column' => 'post_id', 'columnInput' => 'id', 'order' => 'DESC'], 74 ], 75 ]; 76 77 const DEFAULT_SORT = 'id_asc'; 78 79 protected $table = 'phpbb_posts'; 80 protected $primaryKey = 'post_id'; 81 82 public $timestamps = false; 83 84 protected $casts = [ 85 'post_approved' => 'boolean', 86 'post_edit_locked' => 'boolean', 87 'post_edit_time' => TimestampOrZero::class, 88 'post_time' => TimestampOrZero::class, 89 ]; 90 91 private $normalizedUsers = []; 92 93 private $skipBeatmapPostRestrictions = false; 94 private $skipBodyPresenceCheck = false; 95 96 public static function createNew($topic, $poster, $body, $isReply = true) 97 { 98 $post = (new static([ 99 'post_text' => $body, 100 'post_username' => $poster->username, 101 'poster_id' => $poster->user_id, 102 'forum_id' => $topic->forum_id, 103 'topic_id' => $topic->getKey(), 104 'post_time' => now(), 105 ]))->setRelation('topic', $topic) 106 ->setRelation('forum', $topic->forum); 107 108 $post->getConnection()->transaction(function () use ($topic, $post, $isReply) { 109 $post->saveOrExplode(); 110 111 $post->topic->postsAdded($isReply ? 1 : 0); 112 $post->forum->postsAdded(1); 113 114 if ($post->user !== null) { 115 $post->user->refreshForumCache($post->forum, 1); 116 $post->user->refresh(); 117 } 118 }); 119 120 return $post; 121 } 122 123 public function forum() 124 { 125 return $this->belongsTo(Forum::class, 'forum_id', 'forum_id'); 126 } 127 128 public function topic() 129 { 130 return $this->belongsTo(Topic::class, 'topic_id', 'topic_id')->withTrashed(); 131 } 132 133 public function user() 134 { 135 return $this->belongsTo(User::class, 'poster_id', 'user_id'); 136 } 137 138 public function lastEditor() 139 { 140 return $this->belongsTo(User::class, 'post_edit_user', 'user_id'); 141 } 142 143 public function setPostTextAttribute($value) 144 { 145 if ($value === $this->bodyRaw) { 146 return; 147 } 148 149 $bbcode = new BBCodeForDB($value); 150 $this->attributes['post_text'] = $bbcode->generate(); 151 $this->attributes['bbcode_uid'] = $bbcode->uid; 152 $this->attributes['bbcode_bitfield'] = $bbcode->bitfield; 153 } 154 155 public function getPostEditUserAttribute($value) 156 { 157 if ($value !== 0) { 158 return $value; 159 } 160 } 161 162 /** 163 * Gets a preview of the post_text by stripping anything that 164 * looks like bbcode or html. 165 * 166 * @return string 167 */ 168 public function getSearchContentAttribute() 169 { 170 // remove metadata 171 // remove blockquotes 172 // unescape html entities 173 // strip remaining bbcode 174 // strip any html tags left 175 $text = Beatmapset::removeMetadataText($this->post_text); 176 $text = BBCodeFromDB::removeBlockQuotes($text); 177 $text = html_entity_decode_better($text); 178 $text = BBCodeFromDB::removeBBCodeTags($text); 179 180 return strip_tags($text); 181 } 182 183 public static function lastUnreadByUser($topic, $user) 184 { 185 if ($user === null) { 186 return; 187 } 188 189 $startTime = TopicTrack::where('topic_id', $topic->topic_id) 190 ->where('user_id', $user->user_id) 191 ->value('mark_time'); 192 193 if ($startTime === null) { 194 return; 195 } 196 197 $unreadPostId = $topic 198 ->posts() 199 ->where('post_time', '>=', $startTime->getTimestamp()) 200 ->value('post_id'); 201 202 if ($unreadPostId === null) { 203 return $topic->posts()->orderBy('post_id', 'desc')->value('post_id'); 204 } 205 206 return $unreadPostId; 207 } 208 209 public function normalizeUser($user) 210 { 211 $key = $user === null ? 'user-null' : "user-{$user->user_id}"; 212 213 if (!isset($this->normalizedUsers[$key])) { 214 if ($user === null) { 215 $normalizedUser = new DeletedUser(); 216 } elseif ($user->isRestricted()) { 217 $normalizedUser = new DeletedUser(); 218 $normalizedUser->username = $user->username; 219 $normalizedUser->user_colour = '#ccc'; 220 } else { 221 $normalizedUser = $user; 222 } 223 224 $this->normalizedUsers[$key] = $normalizedUser; 225 } 226 227 return $this->normalizedUsers[$key]; 228 } 229 230 public function userNormalized() 231 { 232 return $this->normalizeUser($this->user); 233 } 234 235 public function lastEditorNormalized() 236 { 237 return $this->normalizeUser($this->lastEditor); 238 } 239 240 public function getPostPositionAttribute() 241 { 242 return $this->topic->postPosition($this->post_id); 243 } 244 245 public function skipBeatmapPostRestrictions() 246 { 247 $this->skipBeatmapPostRestrictions = true; 248 249 return $this; 250 } 251 252 public function skipBodyPresenceCheck() 253 { 254 $this->skipBodyPresenceCheck = true; 255 256 return $this; 257 } 258 259 public function delete() 260 { 261 if ($this->trashed()) { 262 return true; 263 } 264 265 $this->validationErrors()->reset(); 266 267 // don't forget to sync with views.forum.topics._posts 268 if ($this->isBeatmapsetPost()) { 269 $this->validationErrors()->add('base', '.beatmapset_post_no_delete'); 270 271 return false; 272 } 273 274 if ($this->getKey() === $this->topic->topic_first_post_id) { 275 $this->validationErrors()->add('post_id', '.no_delete_first_post'); 276 277 return false; 278 } 279 280 return $this->getConnection()->transaction(function () { 281 if (!parent::delete()) { 282 return false; 283 } 284 285 $this->topic->postsAdded(-1); 286 $this->forum->postsAdded(-1); 287 288 if ($this->user !== null) { 289 $this->user->refreshForumCache($this->forum, -1); 290 $this->user->refresh(); 291 } 292 293 return true; 294 }); 295 } 296 297 public function deleteOrExplode() 298 { 299 if (!$this->delete()) { 300 throw new ModelNotSavedException($this->validationErrors()->toSentence()); 301 } 302 303 return true; 304 } 305 306 public function restore() 307 { 308 if (!$this->trashed()) { 309 return true; 310 } 311 312 return $this->getConnection()->transaction(function () { 313 if (!$this->origRestore()) { 314 return false; 315 } 316 317 $this->topic->postsAdded(1); 318 $this->forum->postsAdded(1); 319 320 if ($this->user !== null) { 321 $this->user->refreshForumCache($this->forum, 1); 322 $this->user->refresh(); 323 } 324 325 return true; 326 }); 327 } 328 329 public function isValid() 330 { 331 $this->validationErrors()->reset(); 332 333 if (!$this->skipBodyPresenceCheck) { 334 if (trim_unicode($this->post_text) === '') { 335 $this->validationErrors()->add('post_text', 'required'); 336 } elseif (trim_unicode(BBCodeFromDB::removeBlockQuotes($this->post_text)) === '') { 337 $this->validationErrors()->add('base', '.only_quote'); 338 } 339 } 340 341 $this->validateDbFieldLength($GLOBALS['cfg']['osu']['forum']['max_post_length'], 'post_text', 'body_raw'); 342 343 if (!$this->skipBeatmapPostRestrictions) { 344 // don't forget to sync with views.forum.topics._posts 345 if ($this->isBeatmapsetPost()) { 346 $this->validationErrors()->add('base', '.beatmapset_post_no_edit'); 347 348 return false; 349 } 350 } 351 352 return $this->validationErrors()->isEmpty(); 353 } 354 355 public function save(array $options = []) 356 { 357 if (!$this->isValid()) { 358 return false; 359 } 360 361 // record edit history 362 if ($this->exists && $this->isDirty('post_text')) { 363 $this->post_edit_time = Carbon::now(); 364 if ($this->post_edit_count < 64000) { 365 $this->post_edit_count = DB::raw('post_edit_count + 1'); 366 } 367 } 368 369 return parent::save($options); 370 } 371 372 // don't forget to sync with views.forum.topics._posts 373 public function isBeatmapsetPost() 374 { 375 if ($this->topic !== null) { 376 return $this->getKey() === $this->topic->topic_first_post_id && 377 $this->topic->beatmapset()->exists(); 378 } 379 } 380 381 public function validationErrorsTranslationPrefix(): string 382 { 383 return 'forum.post'; 384 } 385 386 public function getBodyRawAttribute() 387 { 388 return bbcode_for_editor($this->post_text, $this->bbcode_uid); 389 } 390 391 public function scopeShowDeleted($query, $showDeleted) 392 { 393 if ($showDeleted) { 394 $query->withTrashed(); 395 } 396 } 397 398 public function afterCommit() 399 { 400 if ($this->exists) { 401 dispatch(new EsDocument($this)); 402 } 403 } 404 405 public function bodyHTML($options = []) 406 { 407 return bbcode($this->post_text, $this->bbcode_uid, array_merge(['withGallery' => true], $options)); 408 } 409 410 public function markRead($user) 411 { 412 if ($user === null) { 413 return; 414 } 415 416 $topic = $this->topic ?? $this->topic()->withTrashed()->first(); 417 418 if ($topic === null) { 419 return; 420 } 421 422 $topic->markRead($user, $this->post_time); 423 424 // reset notification status when viewing latest post 425 if ($topic->topic_last_post_id === $this->getKey()) { 426 TopicWatch::lookupQuery($topic, $user)->update(['notify_status' => false]); 427 } 428 429 (new MarkNotificationsRead($this, $user))->dispatch(); 430 } 431 432 public function url() 433 { 434 return route('forum.posts.show', $this); 435 } 436 437 protected function newReportableExtraParams(): array 438 { 439 return [ 440 'reason' => 'Spam', 441 'user_id' => $this->poster_id, 442 ]; 443 } 444}