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}