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\ValidationException;
9use App\Libraries\MorphMap;
10use App\Models\Chat\Message;
11use App\Models\Score\Best;
12use App\Models\Score\Best\Model as BestModel;
13use App\Traits\Validatable;
14use Illuminate\Notifications\Notification;
15use Illuminate\Notifications\RoutesNotifications;
16
17/**
18 * @property string $comments
19 * @property int $mode
20 * @property mixed $reason
21 * @property int $report_id
22 * @property mixed $reportable
23 * @property int|null $reportable_id
24 * @property mixed|null $reportable_type
25 * @property User $reporter
26 * @property int $reporter_id
27 * @property int $score_id
28 * @property \Carbon\Carbon $timestamp
29 * @property User $user
30 * @property int $user_id
31 */
32class UserReport extends Model
33{
34 use RoutesNotifications, Validatable;
35
36 const BEATMAPSET_TYPE_REASONS = ['UnwantedContent', 'Other'];
37 const MAX_FIELD_LENGTHS = [
38 'comments' => 2000,
39 ];
40 const POST_TYPE_REASONS = ['Insults', 'Spam', 'UnwantedContent', 'Nonsense', 'Other'];
41 const SCORE_TYPE_REASONS = ['Cheating', 'MultipleAccounts', 'Other'];
42
43 const ALLOWED_REASONS = [
44 MorphMap::MAP[BeatmapDiscussionPost::class] => self::POST_TYPE_REASONS,
45 MorphMap::MAP[Beatmapset::class] => self::BEATMAPSET_TYPE_REASONS,
46 MorphMap::MAP[Best\Fruits::class] => self::SCORE_TYPE_REASONS,
47 MorphMap::MAP[Best\Mania::class] => self::SCORE_TYPE_REASONS,
48 MorphMap::MAP[Best\Osu::class] => self::SCORE_TYPE_REASONS,
49 MorphMap::MAP[Best\Taiko::class] => self::SCORE_TYPE_REASONS,
50 MorphMap::MAP[Chat\Message::class] => self::POST_TYPE_REASONS,
51 MorphMap::MAP[Comment::class] => self::POST_TYPE_REASONS,
52 MorphMap::MAP[Forum\Post::class] => self::POST_TYPE_REASONS,
53 MorphMap::MAP[Solo\Score::class] => self::SCORE_TYPE_REASONS,
54 ];
55
56 const CREATED_AT = 'timestamp';
57
58 public $timestamps = false;
59
60 protected $casts = ['timestamp' => 'datetime'];
61 protected $primaryKey = 'report_id';
62 protected $table = 'osu_user_reports';
63
64 public function reportable()
65 {
66 return $this->morphTo();
67 }
68
69 public function reporter()
70 {
71 return $this->belongsTo(User::class, 'reporter_id');
72 }
73
74 public function routeNotificationForSlack(?Notification $_notification): ?string
75 {
76 $reason = $this->reason;
77 $reportableModel = $this->reportable()->getModel();
78
79 if (
80 $reason === 'Cheating'
81 || $reason === 'MultipleAccounts'
82 || $reportableModel instanceof BestModel
83 || $reportableModel instanceof Solo\Score
84 ) {
85 return $GLOBALS['cfg']['osu']['user_report_notification']['endpoint_cheating'];
86 } else {
87 $type = match ($reportableModel::class) {
88 BeatmapDiscussionPost::class => 'beatmapset_discussion',
89 Beatmapset::class => 'beatmapset',
90 Chat\Message::class => 'chat',
91 Comment::class => 'comment',
92 Forum\Post::class => 'forum',
93 User::class => 'user',
94 };
95
96 return $GLOBALS['cfg']['osu']['user_report_notification']['endpoint'][$type]
97 ?? $GLOBALS['cfg']['osu']['user_report_notification']['endpoint_moderation'];
98 }
99 }
100
101 public function user()
102 {
103 return $this->belongsTo(User::class, 'user_id');
104 }
105
106 public function isRecent(): bool
107 {
108 return $this->timestamp->addDays(1)->isFuture();
109 }
110
111 public function isValid()
112 {
113 $this->validationErrors()->reset();
114
115 if (!present(trim($this->comments)) && (!($this->reportable instanceof Chat\Message) || $this->reason === 'Other')) {
116 $this->validationErrors()->add('comments', 'required');
117 }
118
119 if ($this->user_id === $this->reporter_id) {
120 $this->validationErrors()->add(
121 'user_id',
122 '.self'
123 );
124 }
125
126 $allowedReasons = static::ALLOWED_REASONS[$this->reportable_type] ?? [
127 ...static::BEATMAPSET_TYPE_REASONS,
128 ...static::POST_TYPE_REASONS,
129 ...static::SCORE_TYPE_REASONS,
130 ];
131
132 if (!in_array($this->reason, $allowedReasons, true)) {
133 $this->validationErrors()->add(
134 'reason',
135 '.reason_not_valid',
136 ['reason' => $this->reason]
137 );
138 }
139
140 if ($this->reportable instanceof Beatmapset && $this->reportable->isScoreable()) {
141 $this->validationErrors()->add(
142 'reason',
143 '.no_ranked_beatmapset'
144 );
145 }
146
147 if (
148 $this->reportable instanceof Message
149 && $this->reportable->channel->isHideable()
150 && !$this->reportable->channel->hasUser($this->reporter)
151 ) {
152 $this->validationErrors()->add(
153 'reportable',
154 '.not_in_channel'
155 );
156 }
157
158 $this->validateDbFieldLengths();
159
160 return $this->validationErrors()->isEmpty();
161 }
162
163 public function save(array $options = [])
164 {
165 if (!$this->isValid()) {
166 throw new ValidationException($this->validationErrors());
167 }
168
169 return parent::save();
170 }
171
172 public function validationErrorsTranslationPrefix(): string
173 {
174 return 'user_report';
175 }
176}