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\ChangeUsernameException;
9use App\Exceptions\InvariantException;
10use App\Exceptions\ModelNotSavedException;
11use App\Jobs\EsDocument;
12use App\Libraries\BBCodeForDB;
13use App\Libraries\ChangeUsername;
14use App\Libraries\Elasticsearch\Indexable;
15use App\Libraries\Session\Store as SessionStore;
16use App\Libraries\Transactions\AfterCommit;
17use App\Libraries\Uploader;
18use App\Libraries\User\AvatarHelper;
19use App\Libraries\User\Cover;
20use App\Libraries\User\DatadogLoginAttempt;
21use App\Libraries\User\ProfileBeatmapset;
22use App\Libraries\User\UsernamesForDbLookup;
23use App\Libraries\UsernameValidation;
24use App\Models\Forum\TopicWatch;
25use App\Models\OAuth\Client;
26use App\Traits\Memoizes;
27use App\Traits\Validatable;
28use Cache;
29use Carbon\Carbon;
30use DB;
31use Ds\Set;
32use Hash;
33use Illuminate\Auth\Authenticatable;
34use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
35use Illuminate\Contracts\Translation\HasLocalePreference;
36use Illuminate\Database\Eloquent\Builder;
37use Illuminate\Database\Eloquent\Collection;
38use Illuminate\Database\Eloquent\Relations\HasMany;
39use Illuminate\Database\Eloquent\Relations\HasOne;
40use Illuminate\Database\Eloquent\Relations\HasOneThrough;
41use Illuminate\Database\QueryException;
42use Laravel\Passport\HasApiTokens;
43use League\OAuth2\Server\Exception\OAuthServerException;
44use Request;
45
46/**
47 * @property-read Collection<UserAccountHistory> $accountHistories
48 * @property-read Collection<ApiKey> $apiKeys
49 * @property-read Collection<UserBadge> $badges
50 * @property-read Collection<BeatmapDiscussionVote> $beatmapDiscussionVotes
51 * @property-read Collection<BeatmapDiscussion> $beatmapDiscussions
52 * @property-read Collection<BeatmapPlaycount> $beatmapPlaycounts
53 * @property-read Collection<Beatmap> $beatmaps
54 * @property-read Collection<BeatmapsetNomination> $beatmapsetNominations
55 * @property-read Collection<BeatmapsetUserRating> $beatmapsetRatings
56 * @property-read Collection<BeatmapsetWatch> $beatmapsetWatches
57 * @property-read Collection<Beatmapset> $beatmapsets
58 * @property-read Collection<static> $blocks
59 * @property-read Collection<Changelog> $changelogs
60 * @property-read Collection<Chat\Channel> $channels
61 * @property-read Collection<UserClient> $clients
62 * @property-read Collection<Comment> $comments
63 * @property-read Country|null $country
64 * @property string|null $country_acronym
65 * @property-write string|null $current_password
66 * @property-read Carbon|null $displayed_last_visit
67 * @property-read string|null $email
68 * @property-read Collection<Event> $events
69 * @property-read Collection<FavouriteBeatmapset> $favourites
70 * @property-read Collection<Follow> $follows
71 * @property-read Collection<Forum\Post> $forumPosts
72 * @property-read Collection<static> $friends
73 * @property-read GithubUser|null $githubUser
74 * @property-read Collection<KudosuHistory> $givenKudosu
75 * @property int $group_id
76 * @property bool $hide_presence
77 * @property bool $lock_email_changes
78 * @property-read Collection<UserMonthlyPlaycount> $monthlyPlaycounts
79 * @property-read Collection<UserNotificationOption> $notificationOptions
80 * @property-read Collection<Client> $oauthClients
81 * @property-read Collection<Store\Order> $orders
82 * @property int $osu_featurevotes
83 * @property int $osu_kudosavailable
84 * @property int $osu_kudosdenied
85 * @property int $osu_kudostotal
86 * @property float $osu_mapperrank
87 * @property string|null $osu_playmode
88 * @property string[]|null $osu_playstyle
89 * @property bool $osu_subscriber
90 * @property Carbon|null $osu_subscriptionexpiry
91 * @property int $osu_testversion
92 * @property-write string|null $password
93 * @property-write string|null $password_confirmation
94 * @property-read string|null $playmode
95 * @property bool $pm_friends_only
96 * @property-read Collection<ProfileBanner> $profileBanners
97 * @property-read Collection<Beatmapset> $profileBeatmapsetsGraveyard
98 * @property-read Collection<Beatmapset> $profileBeatmapsetsLoved
99 * @property-read Collection<Beatmapset> $profileBeatmapsetsPending
100 * @property-read Collection<Beatmapset> $profileBeatmapsetsRanked
101 * @property-read Rank $rank
102 * @property-read Collection<RankHighest> $rankHighests
103 * @property-read Collection<RankHistory> $rankHistories
104 * @property-read Collection<KudosuHistory> $receivedKudosu
105 * @property-read Collection<UserRelation> $relations
106 * @property string|null $remember_token
107 * @property-read Collection<UserReplaysWatchedCount> $replaysWatchedCounts
108 * @property-read Collection<UserReport> $reportsMade
109 * @property-read Collection<ScorePin> $scorePins
110 * @property-read Collection<Score\Best\Fruits> $scoresBestFruits
111 * @property-read Collection<Score\Best\Mania> $scoresBestMania
112 * @property-read Collection<Score\Best\Osu> $scoresBestOsu
113 * @property-read Collection<Score\Best\Taiko> $scoresBestTaiko
114 * @property-read Collection<Score\Best\Fruits> $scoresFirstFruits
115 * @property-read Collection<Score\Best\Mania> $scoresFirstMania
116 * @property-read Collection<Score\Best\Osu> $scoresFirstOsu
117 * @property-read Collection<Score\Best\Taiko> $scoresFirstTaiko
118 * @property-read Collection<Score\Fruits> $scoresFruits
119 * @property-read Collection<Score\Mania> $scoresMania
120 * @property-read Collection<Score\Osu> $scoresOsu
121 * @property-read Collection<Score\Taiko> $scoresTaiko
122 * @property-read Collection<UserSeasonScoreAggregate> $seasonScores
123 * @property-read UserStatistics\Fruits|null $statisticsFruits
124 * @property-read UserStatistics\Mania|null $statisticsMania
125 * @property-read UserStatistics\Mania4k|null $statisticsMania4k
126 * @property-read UserStatistics\Mania7k|null $statisticsMania7k
127 * @property-read UserStatistics\Osu|null $statisticsOsu
128 * @property-read UserStatistics\Taiko|null $statisticsTaiko
129 * @property-read Collection<Store\Address> $storeAddresses
130 * @property-read Collection<UserDonation> $supporterTagPurchases
131 * @property-read Collection<UserDonation> $supporterTags
132 * @property-read Team|null $team
133 * @property-read Collection<OAuth\Token> $tokens
134 * @property-read Collection<Forum\TopicWatch> $topicWatches
135 * @property-read Collection<UserAchievement> $userAchievements
136 * @property-read Collection<UserGroup> $userGroups
137 * @property-read Collection<UserNotification> $userNotifications
138 * @property-read Forum\Post|null $userPage
139 * @property-read UserProfileCustomization|null $userProfileCustomization
140 * @property string $user_actkey
141 * @property int $user_allow_massemail
142 * @property bool $user_allow_pm
143 * @property int $user_allow_viewemail
144 * @property bool $user_allow_viewonline
145 * @property string $user_avatar
146 * @property int $user_avatar_height
147 * @property int $user_avatar_type
148 * @property int $user_avatar_width
149 * @property string $user_birthday
150 * @property string|null $user_colour
151 * @property-read Collection<UserCountryHistory> $userCountryHistory
152 * @property string $user_dateformat
153 * @property string|null $user_discord
154 * @property int $user_dst
155 * @property string|null $user_email
156 * @property-write string|null $user_email_confirmation
157 * @property int $user_emailtime
158 * @property string|null $user_from
159 * @property int $user_full_folder
160 * @property int $user_id
161 * @property int $user_inactive_reason
162 * @property int $user_inactive_time
163 * @property string|null $user_interests
164 * @property string $user_ip
165 * @property string|null $user_jabber
166 * @property string $user_lang
167 * @property string $user_last_confirm_key
168 * @property int $user_last_privmsg
169 * @property int $user_last_search
170 * @property int $user_last_warning
171 * @property Carbon|null $user_lastmark
172 * @property string $user_lastpage
173 * @property Carbon|null $user_lastpost_time
174 * @property Carbon|null $user_lastvisit
175 * @property int $user_login_attempts
176 * @property int $user_message_rules
177 * @property string $user_msnm
178 * @property int $user_new_privmsg
179 * @property string $user_newpasswd
180 * @property bool $user_notify
181 * @property int $user_notify_pm
182 * @property int $user_notify_type
183 * @property string|null $user_occ
184 * @property int $user_options
185 * @property int $user_passchg
186 * @property string $user_password
187 * @property int|null $user_perm_from
188 * @property string $user_permissions
189 * @property int $user_post_show_days
190 * @property string $user_post_sortby_dir
191 * @property string $user_post_sortby_type
192 * @property int $user_posts
193 * @property int|null $user_rank
194 * @property Carbon $user_regdate
195 * @property string $user_sig
196 * @property string $user_sig_bbcode_bitfield
197 * @property string $user_sig_bbcode_uid
198 * @property int $user_style
199 * @property float $user_timezone
200 * @property int $user_topic_show_days
201 * @property string $user_topic_sortby_dir
202 * @property string $user_topic_sortby_type
203 * @property string|null $user_twitter
204 * @property int $user_type
205 * @property int $user_unread_privmsg
206 * @property int $user_warnings
207 * @property string|null $user_website
208 * @property string $username
209 * @property-read Collection<UsernameChangeHistory> $usernameChangeHistory
210 * @property-read Collection<UsernameChangeHistory> $usernameChangeHistoryPublic publically visible UsernameChangeHistory containing only user_id and username_last
211 * @property string $username_clean
212 * @property string|null $username_previous
213 * @property int|null $userpage_post_id
214 * @method static Builder default()
215 * @method static Builder eagerloadForListing()
216 * @method static Builder online()
217 */
218class User extends Model implements AfterCommit, AuthenticatableContract, HasLocalePreference, Indexable, Traits\ReportableInterface
219{
220 use Authenticatable, HasApiTokens, Memoizes, Traits\Es\UserSearch, Traits\Reportable, Traits\UserScoreable, Traits\UserStore, Validatable;
221
222 const PLAYSTYLES = [
223 'mouse' => 1,
224 'keyboard' => 2,
225 'tablet' => 4,
226 'touch' => 8,
227 ];
228
229 const CACHING = [
230 'follower_count' => [
231 'key' => 'followerCount',
232 'duration' => 43200, // 12 hours
233 ],
234 'mapping_follower_count' => [
235 'key' => 'moddingFollowerCount',
236 'duration' => 43200, // 12 hours
237 ],
238 ];
239
240 const INACTIVE_DAYS = 180;
241
242 const MAX_FIELD_LENGTHS = [
243 'user_discord' => 37, // max 32char username + # + 4-digit discriminator
244 'user_from' => 25,
245 'user_interests' => 30,
246 'user_occ' => 30,
247 'user_sig' => 3000,
248 'user_twitter' => 255,
249 'user_website' => 200,
250 ];
251
252 public $password = null;
253 public $timestamps = false;
254
255 protected $attributes = [
256 'user_allow_pm' => true,
257 ];
258 protected $casts = [
259 'lock_email_changes' => 'boolean',
260 'osu_subscriber' => 'boolean',
261 'user_allow_pm' => 'boolean',
262 'user_allow_viewonline' => 'boolean',
263 'user_lastmark' => 'datetime',
264 'user_lastpost_time' => 'datetime',
265 'user_lastvisit' => 'datetime',
266 'user_notify' => 'boolean',
267 'user_regdate' => 'datetime',
268 'user_timezone' => 'float',
269 ];
270 protected $dateFormat = 'U';
271 protected $primaryKey = 'user_id';
272 protected $table = 'phpbb_users';
273
274 private Cover $cover;
275 private Uploader $customCover;
276 private $validateCurrentPassword = false;
277 private $validatePasswordConfirmation = false;
278 private $passwordConfirmation = null;
279 private $currentPassword = null;
280
281 private $emailConfirmation = null;
282 private $validateEmailConfirmation = false;
283
284 private $isSessionVerified;
285
286 public static function statisticsRelationName(string $ruleset, ?string $variant = null): ?string
287 {
288 if (!Beatmap::isModeValid($ruleset) || !Beatmap::isVariantValid($ruleset, $variant)) {
289 return null;
290 }
291
292 $variantSuffix = $variant === null ? '' : "_{$variant}";
293
294 return 'statistics'.studly_case("{$ruleset}{$variantSuffix}");
295 }
296
297 public function userCountryHistory(): HasMany
298 {
299 return $this->hasMany(UserCountryHistory::class);
300 }
301
302 public function team(): HasOneThrough
303 {
304 return $this->hasOneThrough(
305 Team::class,
306 TeamMember::class,
307 'user_id',
308 'id',
309 'user_id',
310 'team_id',
311 );
312 }
313
314 public function getAuthPassword()
315 {
316 return $this->user_password;
317 }
318
319 public function usernameChangeCost()
320 {
321 $minTier = $this->usernameChangeHistory()->paid()->exists() ? 1 : 0;
322
323 $tier = max(
324 $this->usernameChangeHistory()
325 ->paid()
326 ->where('timestamp', '>', Carbon::now()->subYears(3))
327 ->count(),
328 $minTier,
329 );
330
331 return match ($tier) {
332 0 => 0,
333 1 => 8,
334 2 => 16,
335 3 => 32,
336 4 => 64,
337 default => 100,
338 };
339 }
340
341 public function revertUsername($type = 'revert'): UsernameChangeHistory
342 {
343 // TODO: normalize validation with changeUsername.
344 if ($this->user_id <= 1) {
345 throw new ChangeUsernameException('user_id is not valid');
346 }
347
348 if (!presence($this->username_previous)) {
349 throw new ChangeUsernameException('username_previous is blank.');
350 }
351
352 return $this->updateUsername($this->username_previous, $type);
353 }
354
355 public function changeUsername(string $newUsername, string $type): UsernameChangeHistory
356 {
357 $errors = $this->validateChangeUsername($newUsername, $type);
358 if ($errors->isAny()) {
359 throw new ChangeUsernameException($errors);
360 }
361
362 return $this->getConnection()->transaction(function () use ($newUsername, $type) {
363 static::findAndRenameUserForInactive($newUsername);
364
365 return $this->updateUsername($newUsername, $type);
366 });
367 }
368
369 public function renameIfInactive(): ?UsernameChangeHistory
370 {
371 if ($this->getUsernameAvailableAt() <= Carbon::now()) {
372 $newUsername = "{$this->username}_old";
373
374 return $this->tryUpdateUsername(0, $newUsername, 'inactive');
375 }
376
377 return null;
378 }
379
380 private function tryUpdateUsername(int $try, string $newUsername, string $type): UsernameChangeHistory
381 {
382 $name = $try > 0 ? "{$newUsername}_{$try}" : $newUsername;
383
384 try {
385 return $this->updateUsername($name, $type);
386 } catch (QueryException $ex) {
387 // Maybe use different suffix altogether if people manage
388 // to try using same name more than 21 times.
389 if (!is_sql_unique_exception($ex) || $try > 20) {
390 throw $ex;
391 }
392
393 return $this->tryUpdateUsername($try + 1, $newUsername, $type);
394 }
395 }
396
397 private function updateUsername(string $newUsername, string $type): UsernameChangeHistory
398 {
399 $oldUsername = $type === 'revert' ? null : $this->getOriginal('username');
400 $this->username_previous = $oldUsername;
401 $this->username = $newUsername;
402
403 return DB::transaction(function () use ($newUsername, $oldUsername, $type) {
404 Forum\Forum::where('forum_last_poster_id', $this->user_id)->update(['forum_last_poster_name' => $newUsername]);
405 // DB::table('phpbb_moderator_cache')->where('user_id', $this->user_id)->update(['username' => $newUsername]);
406 Forum\Post::where('poster_id', $this->user_id)->update(['post_username' => $newUsername]);
407 Forum\Topic::where('topic_poster', $this->user_id)
408 ->update(['topic_first_poster_name' => $newUsername]);
409 Forum\Topic::where('topic_last_poster_id', $this->user_id)
410 ->update(['topic_last_poster_name' => $newUsername]);
411
412 $history = $this->usernameChangeHistory()->create([
413 'username' => $newUsername,
414 'username_last' => $oldUsername,
415 'timestamp' => Carbon::now(),
416 'type' => $type,
417 ]);
418
419 if (!$history->exists) {
420 throw new ModelNotSavedException('failed saving model');
421 }
422
423 $skipValidations = in_array($type, ['inactive', 'revert'], true);
424 $this->saveOrExplode(['skipValidations' => $skipValidations]);
425
426 return $history;
427 });
428 }
429
430 public static function cleanUsername($username)
431 {
432 return strtolower($username);
433 }
434
435 public static function findAndRenameUserForInactive($username): ?self
436 {
437 $existing = static::findByUsernameForInactive($username);
438 if ($existing !== null) {
439 $existing->renameIfInactive();
440 // TODO: throw if expected rename doesn't happen?
441 }
442
443 return $existing;
444 }
445
446 // TODO: be able to change which connection this runs on?
447 public static function findByUsernameForInactive($username): ?self
448 {
449 return static::whereIn(
450 'username',
451 UsernamesForDbLookup::make($username, trimPrefix: false),
452 )->first();
453 }
454
455 public static function checkWhenUsernameAvailable($username): Carbon
456 {
457 $user = static::findByUsernameForInactive($username);
458 if ($user !== null) {
459 return $user->getUsernameAvailableAt();
460 }
461
462 $lastUsage = UsernameChangeHistory::where('username_last', $username)
463 ->where('type', '<>', 'inactive') // don't include changes caused by inactives; this validation needs to be removed on normal save.
464 ->orderBy('change_id', 'desc')
465 ->first();
466
467 if ($lastUsage === null) {
468 return Carbon::now();
469 }
470
471 return Carbon::parse($lastUsage->timestamp)->addDays(static::INACTIVE_DAYS);
472 }
473
474 public function getUsernameAvailableAt(): Carbon
475 {
476 $playCount = $this->playCount();
477
478 $allGroupIds = array_merge([$this->group_id], $this->groupIds()['active']);
479 $allowedGroupIds = array_map(function ($groupIdentifier) {
480 return app('groups')->byIdentifier($groupIdentifier)->getKey();
481 }, $GLOBALS['cfg']['osu']['user']['allowed_rename_groups']);
482
483 // only users which groups are all in the whitelist can be renamed
484 if (count(array_diff($allGroupIds, $allowedGroupIds)) > 0) {
485 return Carbon::now()->addYears(10);
486 }
487
488 if ($this->isRestricted()) {
489 $minDays = 0;
490 $expMod = 0.35;
491 $linMod = 0.75;
492 } else {
493 $minDays = static::INACTIVE_DAYS;
494 $expMod = 1;
495 $linMod = 1;
496 }
497
498 // This is a exponential decay function with the identity 1-e^{-$playCount}.
499 // The constant multiplier of 1580 causes the formula to flatten out at around 1580 days (~4.3 years).
500 // $playCount is then divided by the constant value 5900 causing it to flatten out at about 40,000 plays.
501 // A linear bonus of $playCount * 8 / 5900 is added to reward long-term players.
502 // Furthermore, when the user is restricted, the exponential decay function and the linear bonus are lowered.
503 // An interactive graph of the formula can be found at https://www.desmos.com/calculator/s7bxytxbbt
504
505 return $this->user_lastvisit
506 ->addDays(
507 intval(
508 $minDays +
509 1580 * (1 - pow(M_E, $playCount * $expMod * -1 / 5900)) +
510 ($playCount * $linMod * 8 / 5900)
511 )
512 );
513 }
514
515 public function validateChangeUsername(string $username, string $type = 'paid')
516 {
517 return (new ChangeUsername($this, $username, $type))->validate();
518 }
519
520 public static function lookup($usernameOrId, $type = null, $findAll = false): ?self
521 {
522 if (!present($usernameOrId)) {
523 return null;
524 }
525
526 switch ($type) {
527 case 'username':
528 $searchUsernames = UsernamesForDbLookup::make($usernameOrId);
529
530 $user = static::where(fn ($query) => $query
531 ->whereIn('username', $searchUsernames)
532 ->orWhereIn('username_clean', $searchUsernames));
533 break;
534
535 case 'id':
536 $user = static::where('user_id', $usernameOrId);
537 break;
538
539 default:
540 return ctype_digit((string) $usernameOrId)
541 ? static::lookup($usernameOrId, 'id', $findAll)
542 : static::lookup($usernameOrId, 'username', $findAll);
543 }
544
545 if (!$findAll) {
546 $user->default();
547 }
548
549 $user = $user->first();
550
551 if ($user !== null && $user->hasProfile()) {
552 return $user;
553 }
554
555 return null;
556 }
557
558 public static function lookupWithHistory($usernameOrId, $type = null, $findAll = false)
559 {
560 $user = static::lookup($usernameOrId, $type, $findAll);
561
562 if ($user !== null) {
563 return $user;
564 }
565
566 // don't perform username change history lookup if we're searching by ID
567 // TODO: remove this parameter and always rely on `@` prefix or digit check.
568 if ($type === 'id') {
569 return null;
570 }
571
572 // also reject looking up history when it's all digit and not explicit username search
573 if ($type !== 'username' && ctype_digit($usernameOrId)) {
574 return null;
575 }
576
577 $change = UsernameChangeHistory::visible()
578 ->whereIn('username_last', UsernamesForDbLookup::make($usernameOrId))
579 ->orderBy('change_id', 'desc')
580 ->first();
581
582 if ($change !== null) {
583 return static::lookup($change->user_id, 'id');
584 }
585 }
586
587 public function addToGroup(Group $group, ?array $playmodes = null, ?self $actor = null): void
588 {
589 $playmodes = array_unique($playmodes ?? []);
590
591 if (!$group->has_playmodes && $playmodes !== []) {
592 throw new InvariantException('Group does not allow playmodes');
593 }
594
595 $invalidPlaymodes = array_diff($playmodes, array_keys(Beatmap::MODES));
596
597 if ($invalidPlaymodes !== []) {
598 throw new InvariantException('Invalid playmodes: '.implode(', ', $invalidPlaymodes));
599 }
600
601 $activeUserGroup = $this->findUserGroup($group, true);
602
603 if ($activeUserGroup === null) {
604 $userGroup = $this
605 ->userGroups()
606 ->firstOrNew(['group_id' => $group->getKey()])
607 ->setRelation('group', $group)
608 ->fill([
609 'playmodes' => $playmodes,
610 'user_pending' => false,
611 ]);
612
613 $this->getConnection()->transaction(function () use ($actor, $group, $userGroup) {
614 UserGroupEvent::logUserAdd($actor, $this, $group, $userGroup->playmodes);
615
616 $userGroup->save();
617 });
618 } else {
619 $previousPlaymodes = $activeUserGroup->playmodes ?? [];
620 $playmodesAdded = array_values(array_diff($playmodes, $previousPlaymodes));
621 $playmodesRemoved = array_values(array_diff($previousPlaymodes, $playmodes));
622
623 if ($playmodesAdded === [] && $playmodesRemoved === []) {
624 return;
625 }
626
627 $this->getConnection()->transaction(function () use ($activeUserGroup, $actor, $group, $playmodes, $playmodesAdded, $playmodesRemoved) {
628 if ($playmodesAdded !== []) {
629 UserGroupEvent::logUserAddPlaymodes($actor, $this, $group, $playmodesAdded);
630 }
631
632 if ($playmodesRemoved !== []) {
633 UserGroupEvent::logUserRemovePlaymodes($actor, $this, $group, $playmodesRemoved);
634 }
635
636 $activeUserGroup->update(['playmodes' => $playmodes]);
637 });
638 }
639
640 $this->unsetRelation('userGroups');
641 $this->resetMemoized();
642 }
643
644 public function removeFromGroup(Group $group, ?self $actor = null): void
645 {
646 $userGroup = $this->findUserGroup($group, false);
647
648 if ($userGroup === null) {
649 return;
650 }
651
652 $this->getConnection()->transaction(function () use ($actor, $group, $userGroup) {
653 if (!$userGroup->user_pending) {
654 UserGroupEvent::logUserRemove($actor, $this, $group);
655 }
656
657 $userGroup->delete();
658
659 if ($this->group_id === $group->getKey()) {
660 $this->setDefaultGroup(app('groups')->byIdentifier('default'));
661 }
662 });
663
664 $this->unsetRelation('userGroups');
665 $this->resetMemoized();
666 }
667
668 public function setDefaultGroup(Group $group, ?self $actor = null): void
669 {
670 $this->getConnection()->transaction(function () use ($actor, $group) {
671 if ($this->findUserGroup($group, true) === null) {
672 $this->addToGroup($group, null, $actor);
673 }
674
675 if ($this->group_id !== $group->getKey()) {
676 UserGroupEvent::logUserSetDefault($actor, $this, $group);
677 }
678
679 $this->update([
680 'group_id' => $group->getKey(),
681 'user_colour' => $group->group_colour,
682 'user_rank' => $group->group_rank,
683 ]);
684 });
685 }
686
687 public function setUserFromAttribute($value)
688 {
689 $this->attributes['user_from'] = e(unzalgo($value));
690 }
691
692 public function setUserInterestsAttribute($value)
693 {
694 $this->attributes['user_interests'] = e(unzalgo($value));
695 }
696
697 public function setUserOccAttribute($value)
698 {
699 $this->attributes['user_occ'] = e(unzalgo($value));
700 }
701
702 public function setUserSigAttribute($value)
703 {
704 $bbcode = new BBCodeForDB($value);
705 $this->attributes['user_sig'] = $bbcode->generate();
706 $this->attributes['user_sig_bbcode_uid'] = $bbcode->uid;
707 }
708
709 public function setUserStyleAttribute(?int $value): void
710 {
711 if ($value === null || $value < 1 || $value > 360) {
712 $value = 0;
713 }
714 $this->attributes['user_style'] = $value;
715 }
716
717 public function setUserWebsiteAttribute($value)
718 {
719 // doubles as casting to empty string for not null constraint
720 // allowing zalgo in urls sounds like a terrible idea.
721 $value = unzalgo(trim($value), 0);
722
723 // FIXME: this can probably be removed after old site is deactivated
724 // as there's same check in getter function.
725 if (present($value) && !is_http($value)) {
726 $value = "https://{$value}";
727 }
728
729 $this->attributes['user_website'] = $value;
730 }
731
732 public function setOsuPlaystyleAttribute($value)
733 {
734 $styles = 0;
735
736 if ($value !== null) {
737 foreach (self::PLAYSTYLES as $type => $bit) {
738 if (in_array($type, $value, true)) {
739 $styles += $bit;
740 }
741 }
742 }
743
744 $this->attributes['osu_playstyle'] = $styles;
745 }
746
747 public function setPmFriendsOnlyAttribute($value)
748 {
749 $this->user_allow_pm = !$value;
750 }
751
752 public function setHidePresenceAttribute($value)
753 {
754 $this->user_allow_viewonline = !$value;
755 }
756
757 public function setUsernameAttribute($value)
758 {
759 $this->attributes['username'] = $value;
760 $this->username_clean = static::cleanUsername($value);
761 }
762
763 public function isLoginBlocked()
764 {
765 return $this->user_email === null;
766 }
767
768 public function isSpecial()
769 {
770 return $this->user_id !== null && present($this->user_colour);
771 }
772
773 public function setUserTwitterAttribute($value)
774 {
775 $this->attributes['user_twitter'] = trim(ltrim($value, '@'));
776 }
777
778 public function setUserDiscordAttribute($value)
779 {
780 $this->attributes['user_jabber'] = trim($value);
781 }
782
783 public function setUserColourAttribute($value)
784 {
785 // also functions for casting null to string
786 $this->attributes['user_colour'] = ltrim($value, '#');
787 }
788
789 public function getAttribute($key)
790 {
791 return match ($key) {
792 'cover_preset_id',
793 'custom_cover_filename',
794 'group_id',
795 'laravel_through_key', // added by hasManyThrough relation in Beatmap
796 'osu_featurevotes',
797 'osu_kudosavailable',
798 'osu_kudosdenied',
799 'osu_kudostotal',
800 'osu_mapperrank',
801 'osu_playmode',
802 'osu_testversion',
803 'remember_token',
804 'user_actkey',
805 'user_allow_massemail',
806 'user_allow_viewemail',
807 'user_avatar_height',
808 'user_avatar_type',
809 'user_avatar_width',
810 'user_birthday',
811 'user_dateformat',
812 'user_dst',
813 'user_email',
814 'user_emailtime',
815 'user_full_folder',
816 'user_id',
817 'user_inactive_reason',
818 'user_inactive_time',
819 'user_ip',
820 'user_last_confirm_key',
821 'user_last_privmsg',
822 'user_last_search',
823 'user_last_warning',
824 'user_lastpage',
825 'user_login_attempts',
826 'user_message_rules',
827 'user_msnm',
828 'user_new_privmsg',
829 'user_newpasswd',
830 'user_notify_pm',
831 'user_notify_type',
832 'user_options',
833 'user_passchg',
834 'user_password',
835 'user_perm_from',
836 'user_permissions',
837 'user_post_show_days',
838 'user_post_sortby_dir',
839 'user_post_sortby_type',
840 'user_posts',
841 'user_sig',
842 'user_sig_bbcode_bitfield',
843 'user_sig_bbcode_uid',
844 'user_topic_show_days',
845 'user_topic_sortby_dir',
846 'user_topic_sortby_type',
847 'user_type',
848 'user_unread_privmsg',
849 'user_warnings',
850 'username',
851 'username_clean',
852 'username_previous',
853 'userpage_post_id' => $this->getRawAttribute($key),
854
855 // boolean, default to false for null value
856 'lock_email_changes',
857 'osu_subscriber',
858 'user_allow_pm',
859 'user_allow_viewonline',
860 'user_notify' => (bool) $this->getRawAttribute($key),
861
862 // float
863 'user_timezone' => (float) $this->getRawAttribute($key),
864
865 // datetime but unix timestamp
866 'user_lastmark',
867 'user_lastpost_time',
868 'user_lastvisit',
869 'user_regdate' => Carbon::createFromTimestamp($this->getRawAttribute($key)),
870
871 // datetime
872 'osu_subscriptionexpiry' => $this->getTimeFast($key),
873
874 // custom cast
875 'displayed_last_visit' => $this->getDisplayedLastVisit(),
876 'osu_playstyle' => $this->getOsuPlaystyle(),
877 'playmode' => $this->getPlaymode(),
878 'user_avatar' => AvatarHelper::url($this),
879 'user_colour' => $this->getUserColour(),
880 'user_rank' => $this->getUserRank(),
881 'user_style' => $this->getUserStyle(),
882 'user_website' => $this->getUserWebsite(),
883
884 // one-liner cast
885 'country_acronym' => presence($this->getRawAttribute($key)),
886 'email' => $this->user_email,
887 'hide_presence' => !$this->user_allow_viewonline,
888 'id' => $this->getKey(), // used by clockwork
889 'name' => null, // used by mailer
890 'nonexistent' => null,
891 'pm_friends_only' => !$this->user_allow_pm,
892 'user_discord' => $this->user_jabber,
893 'user_from' => presence(html_entity_decode_better($this->getRawAttribute($key))),
894 'user_interests' => presence(html_entity_decode_better($this->getRawAttribute($key))),
895 'user_jabber' => presence($this->getRawAttribute($key)),
896 'user_lang' => get_valid_locale($this->getRawAttribute($key)),
897 'user_occ' => presence(html_entity_decode_better($this->getRawAttribute($key))),
898 'user_twitter' => presence(ltrim($this->getRawAttribute($key), '@')),
899
900 // relations
901 'accountHistories',
902 'apiKeys',
903 'badges',
904 'beatmapDiscussionVotes',
905 'beatmapDiscussions',
906 'beatmapPlaycounts',
907 'beatmaps',
908 'beatmapsetNominations',
909 'beatmapsetRatings',
910 'beatmapsetWatches',
911 'beatmapsets',
912 'blocks',
913 'changelogs',
914 'channels',
915 'clients',
916 'comments',
917 'country',
918 'dailyChallengeUserStats',
919 'events',
920 'favourites',
921 'follows',
922 'forumPosts',
923 'friends',
924 'githubUser',
925 'givenKudosu',
926 'legacyIrcKey',
927 'monthlyPlaycounts',
928 'notificationOptions',
929 'oauthClients',
930 'orders',
931 'pivot', // laravel built-in relation when using belongsToMany
932 'profileBanners',
933 'profileBannersActive',
934 'profileBeatmapsetsGraveyard',
935 'profileBeatmapsetsLoved',
936 'profileBeatmapsetsPending',
937 'profileBeatmapsetsRanked',
938 'rank',
939 'rankHighests',
940 'rankHistories',
941 'receivedKudosu',
942 'relationFriends',
943 'relations',
944 'replaysWatchedCounts',
945 'reportedIn',
946 'reportsMade',
947 'scorePins',
948 'scoresBestFruits',
949 'scoresBestMania',
950 'scoresBestOsu',
951 'scoresBestTaiko',
952 'scoresFirstFruits',
953 'scoresFirstMania',
954 'scoresFirstOsu',
955 'scoresFirstTaiko',
956 'scoresFruits',
957 'scoresMania',
958 'scoresOsu',
959 'scoresTaiko',
960 'soloScores',
961 'statisticsFruits',
962 'statisticsMania',
963 'statisticsMania4k',
964 'statisticsMania7k',
965 'statisticsOsu',
966 'statisticsTaiko',
967 'storeAddresses',
968 'supporterTagPurchases',
969 'supporterTags',
970 'team',
971 'tokens',
972 'topicWatches',
973 'userAchievements',
974 'userCountryHistory',
975 'userGroups',
976 'userNotifications',
977 'userPage',
978 'userProfileCustomization',
979 'usernameChangeHistory',
980 'usernameChangeHistoryPublic' => $this->getRelationValue($key),
981 };
982 }
983
984 /*
985 |--------------------------------------------------------------------------
986 | Permission Checker Functions
987 |--------------------------------------------------------------------------
988 |
989 | This checks to see if a user is in a specified group.
990 | You should try to be specific.
991 |
992 */
993
994 public function inGroupWithPlaymode($groupIdentifier, $playmode = null)
995 {
996 $group = app('groups')->byIdentifier($groupIdentifier);
997 $isGroup = $this->isGroup($group);
998
999 if ($isGroup === false || $playmode === null) {
1000 return $isGroup;
1001 }
1002
1003 $groupModes = $this->findUserGroup($group, true)->actualRulesets();
1004
1005 return in_array($playmode, $groupModes ?? [], true);
1006 }
1007
1008 public function isNAT(?string $mode = null)
1009 {
1010 return $this->inGroupWithPlaymode('nat', $mode);
1011 }
1012
1013 public function isAdmin()
1014 {
1015 return $this->isGroup(app('groups')->byIdentifier('admin'));
1016 }
1017
1018 public function isChatAnnouncer()
1019 {
1020 return $this->findUserGroup(app('groups')->byIdentifier('announce'), true) !== null;
1021 }
1022
1023 public function isGMT()
1024 {
1025 return $this->isGroup(app('groups')->byIdentifier('gmt'));
1026 }
1027
1028 public function isBNG(?string $mode = null)
1029 {
1030 return $this->isFullBN($mode) || $this->isLimitedBN($mode);
1031 }
1032
1033 public function isFullBN(?string $mode = null)
1034 {
1035 return $this->inGroupWithPlaymode('bng', $mode);
1036 }
1037
1038 public function isLimitedBN(?string $mode = null)
1039 {
1040 return $this->inGroupWithPlaymode('bng_limited', $mode);
1041 }
1042
1043 public function isDev()
1044 {
1045 return $this->isGroup(app('groups')->byIdentifier('dev'));
1046 }
1047
1048 public function isModerator()
1049 {
1050 return $this->isGMT() || $this->isNAT();
1051 }
1052
1053 public function isAlumni()
1054 {
1055 return $this->isGroup(app('groups')->byIdentifier('alumni'));
1056 }
1057
1058 public function isRegistered()
1059 {
1060 return $this->isGroup(app('groups')->byIdentifier('default'));
1061 }
1062
1063 public function isProjectLoved()
1064 {
1065 return $this->isGroup(app('groups')->byIdentifier('loved'));
1066 }
1067
1068 public function isBot()
1069 {
1070 return $this->group_id === app('groups')->byIdentifier('bot')->getKey();
1071 }
1072
1073 public function hasSupported()
1074 {
1075 return $this->getRawAttribute('osu_subscriptionexpiry') !== null;
1076 }
1077
1078 public function isSupporter()
1079 {
1080 return $this->osu_subscriber === true;
1081 }
1082
1083 public function isActive()
1084 {
1085 static $monthInSecond = 30 * 86400;
1086
1087 return time() - $this->getRawAttribute('user_lastvisit') < $monthInSecond;
1088 }
1089
1090 /*
1091 * almost like !isActive but different duration
1092 *
1093 * @return bool
1094 */
1095 public function isInactive(): bool
1096 {
1097 return time() - $this->getRawAttribute('user_lastvisit') > $GLOBALS['cfg']['osu']['user']['inactive_seconds_verification'];
1098 }
1099
1100 public function isOnline()
1101 {
1102 return !$this->hide_presence
1103 && time() - $this->getRawAttribute('user_lastvisit') < $GLOBALS['cfg']['osu']['user']['online_window'];
1104 }
1105
1106 public function isPrivileged()
1107 {
1108 return $this->isAdmin()
1109 || $this->isDev()
1110 || $this->isGMT()
1111 || $this->isBNG()
1112 || $this->isNAT()
1113 || $this->isProjectLoved();
1114 }
1115
1116 public function isBanned()
1117 {
1118 return $this->user_type === 1;
1119 }
1120
1121 public function isOld()
1122 {
1123 return preg_match('/_old(_\d+)?$/', $this->username) === 1;
1124 }
1125
1126 public function isRestricted()
1127 {
1128 return $this->isBanned() || $this->user_warnings > 0;
1129 }
1130
1131 public function isSilenced()
1132 {
1133 return $this->memoize(__FUNCTION__, function () {
1134 if ($this->isRestricted()) {
1135 return true;
1136 }
1137
1138 $lastBan = $this->accountHistories()->bans()->first();
1139
1140 return $lastBan !== null &&
1141 $lastBan->period !== 0 &&
1142 $lastBan->endTime()->isFuture();
1143 });
1144 }
1145
1146 public function trashed()
1147 {
1148 return starts_with($this->username, 'DeletedUser_');
1149 }
1150
1151 /**
1152 * User group to be displayed in preference over other groups.
1153 */
1154 public function defaultGroup(): Group
1155 {
1156 $groups = app('groups');
1157
1158 if ($this->group_id === $groups->byIdentifier('admin')->getKey()) {
1159 return $groups->byIdentifier('default');
1160 }
1161
1162 return $groups->byId($this->group_id) ?? $groups->byIdentifier('default');
1163 }
1164
1165 public function groupIds()
1166 {
1167 return $this->memoize(__FUNCTION__, function () {
1168 $ret = [
1169 'active' => [],
1170 'pending' => [],
1171 ];
1172
1173 foreach ($this->userGroups as $userGroup) {
1174 $key = $userGroup->user_pending ? 'pending' : 'active';
1175 $ret[$key][] = $userGroup->group_id;
1176 }
1177
1178 return $ret;
1179 });
1180 }
1181
1182
1183 public function findUserGroup($group, bool $activeOnly): ?UserGroup
1184 {
1185 $byGroupId = $this->memoize(__FUNCTION__.':byGroupId', fn () => $this->userGroups->keyBy('group_id'));
1186
1187 $userGroup = $byGroupId->get($group->getKey());
1188
1189 if ($userGroup === null || ($activeOnly && $userGroup->user_pending)) {
1190 return null;
1191 }
1192
1193 return $userGroup;
1194 }
1195
1196 /**
1197 * Check if a user is in a specific group.
1198 *
1199 * This will always return false when called on user authenticated using OAuth.
1200 *
1201 * @param Group $group
1202 *
1203 * @return bool
1204 */
1205 public function isGroup($group)
1206 {
1207 return $this->findUserGroup($group, true) !== null && $this->token() === null;
1208 }
1209
1210 public function badges()
1211 {
1212 return $this->hasMany(UserBadge::class);
1213 }
1214
1215 public function githubUser(): HasOne
1216 {
1217 return $this->hasOne(GithubUser::class);
1218 }
1219
1220 public function legacyIrcKey(): HasOne
1221 {
1222 return $this->hasOne(LegacyIrcKey::class);
1223 }
1224
1225 public function monthlyPlaycounts()
1226 {
1227 return $this->hasMany(UserMonthlyPlaycount::class);
1228 }
1229
1230 public function notificationOptions()
1231 {
1232 return $this->hasMany(UserNotificationOption::class);
1233 }
1234
1235 public function replaysWatchedCounts()
1236 {
1237 return $this->hasMany(UserReplaysWatchedCount::class);
1238 }
1239
1240 public function reportsMade()
1241 {
1242 return $this->hasMany(UserReport::class, 'reporter_id');
1243 }
1244
1245 public function scorePins()
1246 {
1247 return $this->hasMany(ScorePin::class);
1248 }
1249
1250 public function userGroups()
1251 {
1252 return $this->hasMany(UserGroup::class);
1253 }
1254
1255 public function beatmapDiscussionVotes()
1256 {
1257 return $this->hasMany(BeatmapDiscussionVote::class);
1258 }
1259
1260 public function beatmapDiscussions()
1261 {
1262 return $this->hasMany(BeatmapDiscussion::class);
1263 }
1264
1265 public function beatmapsets()
1266 {
1267 return $this->hasMany(Beatmapset::class);
1268 }
1269
1270 public function beatmapsetWatches()
1271 {
1272 return $this->hasMany(BeatmapsetWatch::class);
1273 }
1274
1275 public function beatmaps()
1276 {
1277 return $this->hasMany(Beatmap::class);
1278 }
1279
1280 public function clients()
1281 {
1282 return $this->hasMany(UserClient::class);
1283 }
1284
1285 public function dailyChallengeUserStats(): HasOne
1286 {
1287 return $this->hasOne(DailyChallengeUserStats::class);
1288 }
1289
1290 public function favourites()
1291 {
1292 return $this->hasMany(FavouriteBeatmapset::class);
1293 }
1294
1295 public function favouriteBeatmapsets()
1296 {
1297 $favouritesTable = (new FavouriteBeatmapset())->getTable();
1298 $beatmapsetsTable = (new Beatmapset())->getTable();
1299
1300 return Beatmapset::select("{$beatmapsetsTable}.*")
1301 ->join($favouritesTable, "{$favouritesTable}.beatmapset_id", '=', "{$beatmapsetsTable}.beatmapset_id")
1302 ->where("{$favouritesTable}.user_id", '=', $this->user_id)
1303 ->orderby("{$favouritesTable}.dateadded", 'desc');
1304 }
1305
1306 public function beatmapsetNominations()
1307 {
1308 return $this->hasMany(BeatmapsetNomination::class);
1309 }
1310
1311 public function beatmapsetNominationsToday()
1312 {
1313 return $this->beatmapsetNominations()->where('created_at', '>', Carbon::now()->subDays())->count();
1314 }
1315
1316 public function beatmapPlaycounts()
1317 {
1318 return $this->hasMany(BeatmapPlaycount::class);
1319 }
1320
1321 public function apiKeys()
1322 {
1323 return $this->hasMany(ApiKey::class);
1324 }
1325
1326 public function profileBanners()
1327 {
1328 return $this->hasMany(ProfileBanner::class);
1329 }
1330
1331 public function profileBannersActive(): HasMany
1332 {
1333 return $this->profileBanners()->active()->with('tournamentBanner')->orderBy('banner_id');
1334 }
1335
1336 public function storeAddresses()
1337 {
1338 return $this->hasMany(Store\Address::class);
1339 }
1340
1341 public function rank()
1342 {
1343 return $this->belongsTo(Rank::class, 'user_rank');
1344 }
1345
1346 public function rankHighests(): HasMany
1347 {
1348 return $GLOBALS['cfg']['osu']['scores']['experimental_rank_as_default']
1349 ? $this->hasMany(RankHighest::class, null, 'nonexistent')
1350 : $this->hasMany(RankHighest::class);
1351 }
1352
1353 public function rankHistories()
1354 {
1355 return $this->hasMany(RankHistory::class);
1356 }
1357
1358 public function country()
1359 {
1360 return $this->belongsTo(Country::class, 'country_acronym');
1361 }
1362
1363 public function seasonScores(): HasMany
1364 {
1365 return $this->hasMany(UserSeasonScoreAggregate::class);
1366 }
1367
1368 public function statisticsOsu()
1369 {
1370 return $this->hasOne(UserStatistics\Osu::class);
1371 }
1372
1373 public function statisticsFruits()
1374 {
1375 return $this->hasOne(UserStatistics\Fruits::class);
1376 }
1377
1378 public function statisticsMania()
1379 {
1380 return $this->hasOne(UserStatistics\Mania::class);
1381 }
1382
1383 public function statisticsMania4k()
1384 {
1385 return $this->hasOne(UserStatistics\Mania4k::class);
1386 }
1387
1388 public function statisticsMania7k()
1389 {
1390 return $this->hasOne(UserStatistics\Mania7k::class);
1391 }
1392
1393 public function statisticsTaiko()
1394 {
1395 return $this->hasOne(UserStatistics\Taiko::class);
1396 }
1397
1398 public function statistics(string $ruleset, bool $returnQuery = false, ?string $variant = null)
1399 {
1400 $relationName = static::statisticsRelationName($ruleset, $variant);
1401
1402 if ($relationName !== null) {
1403 return $returnQuery ? $this->$relationName() : $this->$relationName;
1404 }
1405 }
1406
1407 public function scoresOsu()
1408 {
1409 return $this->hasMany(Score\Osu::class)->default();
1410 }
1411
1412 public function scoresFruits()
1413 {
1414 return $this->hasMany(Score\Fruits::class)->default();
1415 }
1416
1417 public function scoresMania()
1418 {
1419 return $this->hasMany(Score\Mania::class)->default();
1420 }
1421
1422 public function scoresTaiko()
1423 {
1424 return $this->hasMany(Score\Taiko::class)->default();
1425 }
1426
1427 public function scores(string $mode, bool $returnQuery = false)
1428 {
1429 if (!Beatmap::isModeValid($mode)) {
1430 return;
1431 }
1432
1433 $relation = 'scores'.studly_case($mode);
1434
1435 return $returnQuery ? $this->$relation() : $this->$relation;
1436 }
1437
1438 public function scoresFirstOsu()
1439 {
1440 return $this->hasMany(LegacyScoreFirst\Osu::class)->default();
1441 }
1442
1443 public function scoresFirstFruits()
1444 {
1445 return $this->hasMany(LegacyScoreFirst\Fruits::class)->default();
1446 }
1447
1448 public function scoresFirstMania()
1449 {
1450 return $this->hasMany(LegacyScoreFirst\Mania::class)->default();
1451 }
1452
1453 public function scoresFirstTaiko()
1454 {
1455 return $this->hasMany(LegacyScoreFirst\Taiko::class)->default();
1456 }
1457
1458 public function scoresFirst(string $mode, bool $returnQuery = false)
1459 {
1460 if (!Beatmap::isModeValid($mode)) {
1461 return;
1462 }
1463
1464 $relation = 'scoresFirst'.studly_case($mode);
1465
1466 return $returnQuery ? $this->$relation() : $this->$relation;
1467 }
1468
1469 public function scoresBestOsu()
1470 {
1471 return $this->hasMany(Score\Best\Osu::class)->default();
1472 }
1473
1474 public function scoresBestFruits()
1475 {
1476 return $this->hasMany(Score\Best\Fruits::class)->default();
1477 }
1478
1479 public function scoresBestMania()
1480 {
1481 return $this->hasMany(Score\Best\Mania::class)->default();
1482 }
1483
1484 public function scoresBestTaiko()
1485 {
1486 return $this->hasMany(Score\Best\Taiko::class)->default();
1487 }
1488
1489 public function scoresBest(string $mode, bool $returnQuery = false)
1490 {
1491 if (!Beatmap::isModeValid($mode)) {
1492 return;
1493 }
1494
1495 $relation = 'scoresBest'.studly_case($mode);
1496
1497 return $returnQuery ? $this->$relation() : $this->$relation;
1498 }
1499
1500 public function soloScores(): HasMany
1501 {
1502 return $this->hasMany(Solo\Score::class);
1503 }
1504
1505 public function topicWatches()
1506 {
1507 return $this->hasMany(TopicWatch::class);
1508 }
1509
1510 public function userProfileCustomization()
1511 {
1512 return $this->hasOne(UserProfileCustomization::class);
1513 }
1514
1515 public function accountHistories()
1516 {
1517 return $this->hasMany(UserAccountHistory::class);
1518 }
1519
1520 public function userPage()
1521 {
1522 return $this->belongsTo(Forum\Post::class, 'userpage_post_id');
1523 }
1524
1525 public function userAchievements()
1526 {
1527 return $this->hasMany(UserAchievement::class);
1528 }
1529
1530 public function userNotifications()
1531 {
1532 return $this->hasMany(UserNotification::class);
1533 }
1534
1535 public function usernameChangeHistory()
1536 {
1537 return $this->hasMany(UsernameChangeHistory::class);
1538 }
1539
1540 public function usernameChangeHistoryPublic()
1541 {
1542 return $this->usernameChangeHistory()
1543 ->visible()
1544 ->select(['user_id', 'username_last'])
1545 ->withPresent('username_last')
1546 ->orderBy('timestamp', 'ASC');
1547 }
1548
1549 public function relationFriends(): HasMany
1550 {
1551 return $this->relations()->friends()->withMutual();
1552 }
1553
1554 public function relations()
1555 {
1556 return $this->hasMany(UserRelation::class);
1557 }
1558
1559 public function blocks()
1560 {
1561 return $this
1562 ->belongsToMany(static::class, 'phpbb_zebra', 'user_id', 'zebra_id')
1563 ->wherePivot('foe', true)
1564 ->default();
1565 }
1566
1567 public function friends()
1568 {
1569 return $this
1570 ->belongsToMany(static::class, 'phpbb_zebra', 'user_id', 'zebra_id')
1571 ->wherePivot('friend', true)
1572 ->default();
1573 }
1574
1575 public function channels()
1576 {
1577 return $this->hasManyThrough(
1578 Chat\Channel::class,
1579 Chat\UserChannel::class,
1580 'user_id',
1581 'channel_id',
1582 'user_id',
1583 'channel_id'
1584 );
1585 }
1586
1587 public function comments()
1588 {
1589 return $this->hasMany(Comment::class);
1590 }
1591
1592 public function follows()
1593 {
1594 return $this->hasMany(Follow::class);
1595 }
1596
1597 public function maxBlocks()
1598 {
1599 return (int)ceil($this->maxFriends() / 5);
1600 }
1601
1602 public function maxFriends()
1603 {
1604 return $this->isSupporter() ? $GLOBALS['cfg']['osu']['user']['max_friends_supporter'] : $GLOBALS['cfg']['osu']['user']['max_friends'];
1605 }
1606
1607 public function maxMultiplayerDuration()
1608 {
1609 return $this->isSupporter() ? $GLOBALS['cfg']['osu']['user']['max_multiplayer_duration_supporter'] : $GLOBALS['cfg']['osu']['user']['max_multiplayer_duration'];
1610 }
1611
1612 public function maxMultiplayerRooms()
1613 {
1614 return $this->isSupporter() ? $GLOBALS['cfg']['osu']['user']['max_multiplayer_rooms_supporter'] : $GLOBALS['cfg']['osu']['user']['max_multiplayer_rooms'];
1615 }
1616
1617 public function maxScorePins()
1618 {
1619 return $this->isSupporter() ? $GLOBALS['cfg']['osu']['user']['max_score_pins_supporter'] : $GLOBALS['cfg']['osu']['user']['max_score_pins'];
1620 }
1621
1622 public function beatmapsetDownloadAllowance()
1623 {
1624 return $this->isSupporter() ? $GLOBALS['cfg']['osu']['beatmapset']['download_limit_supporter'] : $GLOBALS['cfg']['osu']['beatmapset']['download_limit'];
1625 }
1626
1627 public function beatmapsetFavouriteAllowance()
1628 {
1629 return $this->isSupporter() ? $GLOBALS['cfg']['osu']['beatmapset']['favourite_limit_supporter'] : $GLOBALS['cfg']['osu']['beatmapset']['favourite_limit'];
1630 }
1631
1632 public function uncachedFollowerCount()
1633 {
1634 return UserRelation::where('zebra_id', $this->user_id)->where('friend', 1)->count();
1635 }
1636
1637 public function uncachedMappingFollowerCount()
1638 {
1639 return Follow::whereMorphedTo('notifiable', $this)
1640 ->where('subtype', 'mapping')
1641 ->count();
1642 }
1643
1644 public function cacheFollowerCount()
1645 {
1646 $count = $this->uncachedFollowerCount();
1647
1648 Cache::put(
1649 self::CACHING['follower_count']['key'].':'.$this->user_id,
1650 $count,
1651 self::CACHING['follower_count']['duration']
1652 );
1653
1654 return $count;
1655 }
1656
1657 public function cacheMappingFollowerCount()
1658 {
1659 $count = $this->uncachedMappingFollowerCount();
1660
1661 Cache::put(
1662 self::CACHING['mapping_follower_count']['key'].':'.$this->user_id,
1663 $count,
1664 self::CACHING['mapping_follower_count']['duration']
1665 );
1666
1667 return $count;
1668 }
1669
1670 public function followerCount()
1671 {
1672 return get_int(Cache::get(self::CACHING['follower_count']['key'].':'.$this->user_id)) ?? $this->cacheFollowerCount();
1673 }
1674
1675 public function mappingFollowerCount()
1676 {
1677 return get_int(Cache::get(self::CACHING['mapping_follower_count']['key'].':'.$this->user_id)) ?? $this->cacheMappingFollowerCount();
1678 }
1679
1680 public function events()
1681 {
1682 return $this->hasMany(Event::class);
1683 }
1684
1685 public function beatmapsetRatings()
1686 {
1687 return $this->hasMany(BeatmapsetUserRating::class);
1688 }
1689
1690 public function givenKudosu()
1691 {
1692 return $this->hasMany(KudosuHistory::class, 'giver_id');
1693 }
1694
1695 public function receivedKudosu()
1696 {
1697 return $this->hasMany(KudosuHistory::class, 'receiver_id');
1698 }
1699
1700 public function supporterTags()
1701 {
1702 return $this->hasMany(UserDonation::class, 'target_user_id');
1703 }
1704
1705 public function supporterTagPurchases()
1706 {
1707 return $this->hasMany(UserDonation::class);
1708 }
1709
1710 public function forumPosts()
1711 {
1712 return $this->hasMany(Forum\Post::class, 'poster_id');
1713 }
1714
1715 public function changelogs()
1716 {
1717 return $this->hasMany(Changelog::class);
1718 }
1719
1720 public function oauthClients()
1721 {
1722 return $this->hasMany(Client::class);
1723 }
1724
1725 public function setPlaymodeAttribute($value)
1726 {
1727 $this->osu_playmode = Beatmap::modeInt($value);
1728 }
1729
1730 public function blockedUserIds()
1731 {
1732 return $this->blocks->pluck('user_id');
1733 }
1734
1735 public function userGroupsForBadges()
1736 {
1737 return $this->memoize(__FUNCTION__, function () {
1738 if ($this->isBot()) {
1739 // Not a query because it's both unnecessary and not guaranteed
1740 // that the usergroup for "bot" will exist
1741 return collect([
1742 UserGroup::make([
1743 'group_id' => app('groups')->byIdentifier('bot')->getKey(),
1744 'user_id' => $this->getKey(),
1745 'user_pending' => false,
1746 ]),
1747 ]);
1748 }
1749
1750 return $this->userGroups
1751 ->filter(function ($userGroup) {
1752 return optional($userGroup->group)->hasBadge();
1753 })
1754 ->sort(function ($a, $b) {
1755 // If the user has a default group, always show it first
1756 if ($a->group_id === $this->group_id) {
1757 return -1;
1758 }
1759 if ($b->group_id === $this->group_id) {
1760 return 1;
1761 }
1762
1763 // Otherwise, sort by display order
1764 return $a->group->display_order - $b->group->display_order;
1765 })
1766 ->values();
1767 });
1768 }
1769
1770 public function nominationModes()
1771 {
1772 return $this->memoize(__FUNCTION__, function () {
1773 if (!$this->isNAT() && !$this->isBNG()) {
1774 return;
1775 }
1776
1777 $modes = [];
1778
1779 if ($this->isLimitedBN()) {
1780 $playmodes = $this->findUserGroup(app('groups')->byIdentifier('bng_limited'), true)->actualRulesets();
1781 foreach ($playmodes as $playmode) {
1782 $modes[$playmode] = 'limited';
1783 }
1784 }
1785
1786 if ($this->isFullBN()) {
1787 $playmodes = $this->findUserGroup(app('groups')->byIdentifier('bng'), true)->actualRulesets();
1788 foreach ($playmodes as $playmode) {
1789 $modes[$playmode] = 'full';
1790 }
1791 }
1792
1793 if ($this->isNAT()) {
1794 $playmodes = $this->findUserGroup(app('groups')->byIdentifier('nat'), true)->actualRulesets();
1795 foreach ($playmodes as $playmode) {
1796 $modes[$playmode] = 'full';
1797 }
1798 }
1799
1800 return $modes;
1801 });
1802 }
1803
1804 public function hasBlocked(self $user)
1805 {
1806 return $this->memoize(__FUNCTION__, function () {
1807 return new Set($this->blocks->pluck('user_id'));
1808 })->contains($user->getKey());
1809 }
1810
1811 public function hasFriended(self $user)
1812 {
1813 return $this->memoize(__FUNCTION__, function () {
1814 return new Set($this->friends->pluck('user_id'));
1815 })->contains($user->getKey());
1816 }
1817
1818 public function hasFavourited($beatmapset)
1819 {
1820 return $this->memoize(__FUNCTION__, function () {
1821 return new Set($this->favourites->pluck('beatmapset_id'));
1822 })->contains($beatmapset->getKey());
1823 }
1824
1825 public function remainingHype()
1826 {
1827 return $this->memoize(__FUNCTION__, function () {
1828 $hyped = $this
1829 ->beatmapDiscussions()
1830 ->withoutTrashed()
1831 ->ofType('hype')
1832 ->where('created_at', '>', Carbon::now()->subWeeks())
1833 ->count();
1834
1835 return $GLOBALS['cfg']['osu']['beatmapset']['user_weekly_hype'] - $hyped;
1836 });
1837 }
1838
1839 public function newHypeTime()
1840 {
1841 return $this->memoize(__FUNCTION__, function () {
1842 $earliestWeeklyHype = $this
1843 ->beatmapDiscussions()
1844 ->withoutTrashed()
1845 ->ofType('hype')
1846 ->where('created_at', '>', Carbon::now()->subWeeks())
1847 ->orderBy('created_at')
1848 ->first();
1849
1850 return $earliestWeeklyHype === null ? null : $earliestWeeklyHype->created_at->addWeeks();
1851 });
1852 }
1853
1854 public function authHash(): string
1855 {
1856 return hash('sha256', $this->user_email).':'.hash('sha256', $this->user_password);
1857 }
1858
1859 public function resetSessions(?string $excludedSessionId = null): void
1860 {
1861 $userId = $this->getKey();
1862 $sessionIds = SessionStore::ids($userId);
1863 if ($excludedSessionId !== null) {
1864 $sessionIds = array_filter(
1865 $sessionIds,
1866 fn ($sessionId) => $sessionId !== $excludedSessionId,
1867 );
1868 }
1869 SessionStore::batchDelete($userId, $sessionIds);
1870
1871 $this
1872 ->tokens()
1873 ->with('refreshToken')
1874 ->chunkById(1000, fn ($tokens) => $tokens->each->revokeRecursive());
1875 }
1876
1877 public function title(): ?string
1878 {
1879 return $this->rank?->rank_title;
1880 }
1881
1882 public function titleUrl(): ?string
1883 {
1884 return $this->rank?->url;
1885 }
1886
1887 public function hasProfile()
1888 {
1889 return $this->getKey() !== null
1890 && $this->group_id !== app('groups')->byIdentifier('no_profile')->getKey();
1891 }
1892
1893 public function hasProfileVisible()
1894 {
1895 return $this->hasProfile() && !$this->isRestricted();
1896 }
1897
1898 public function updatePage($text)
1899 {
1900 if ($this->userPage === null) {
1901 DB::transaction(function () use ($text) {
1902 $topic = Forum\Topic::createNew(
1903 Forum\Forum::find($GLOBALS['cfg']['osu']['user']['user_page_forum_id']),
1904 [
1905 'title' => "{$this->username}'s user page",
1906 'user' => $this,
1907 'body' => $text,
1908 ]
1909 );
1910
1911 $this->update(['userpage_post_id' => $topic->topic_first_post_id]);
1912 });
1913 } else {
1914 $this
1915 ->userPage
1916 ->skipBodyPresenceCheck()
1917 ->update([
1918 'post_text' => $text,
1919 'post_edit_user' => $this->getKey(),
1920 ]);
1921
1922 if ($this->userPage->validationErrors()->isAny()) {
1923 throw new ModelNotSavedException($this->userPage->validationErrors()->toSentence());
1924 }
1925 }
1926
1927 return $this->fresh();
1928 }
1929
1930 public function supportLength()
1931 {
1932 return $this->memoize(__FUNCTION__, function () {
1933 $supportLength = 0;
1934
1935 foreach ($this->supporterTagPurchases as $support) {
1936 if ($support->cancel === true) {
1937 $supportLength -= $support->length;
1938 } else {
1939 $supportLength += $support->length;
1940 }
1941 }
1942
1943 return $supportLength;
1944 });
1945 }
1946
1947 public function supportLevel()
1948 {
1949 if ($this->osu_subscriber === false) {
1950 return 0;
1951 }
1952
1953 $length = $this->supportLength();
1954
1955 if ($length < 12) {
1956 return 1;
1957 }
1958
1959 if ($length < 5 * 12) {
1960 return 2;
1961 }
1962
1963 return 3;
1964 }
1965
1966 /**
1967 * Recommended star difficulty.
1968 *
1969 * @param string $mode one of Beatmap::MODES
1970 *
1971 * @return float
1972 */
1973 public function recommendedStarDifficulty(string $mode)
1974 {
1975 $stats = $this->statistics($mode);
1976
1977 return UserStatistics\Model::calculateRecommendedStarDifficulty($stats);
1978 }
1979
1980 /**
1981 * Recommended star difficulty for all modes.
1982 *
1983 * @return float
1984 */
1985 public function recommendedStarDifficultyAll()
1986 {
1987 return $this->memoize(__FUNCTION__, function () {
1988 $unionQuery = null;
1989
1990 foreach (Beatmap::MODES as $key => $_value) {
1991 $query = $this->statistics($key, true)->selectRaw("'{$key}' AS game_mode, rank_score");
1992
1993 if ($unionQuery === null) {
1994 $unionQuery = $query;
1995 } else {
1996 $unionQuery->unionAll($query);
1997 }
1998 }
1999
2000 $stats = $unionQuery->get()->keyBy('game_mode');
2001
2002 foreach (Beatmap::MODES as $key => $_value) {
2003 $recs[$key] = UserStatistics\Model::calculateRecommendedStarDifficulty($stats[$key] ?? null);
2004 }
2005
2006 return $recs;
2007 });
2008 }
2009
2010 public function refreshForumCache($forum = null, $postsChangeCount = 0)
2011 {
2012 if ($forum !== null) {
2013 if (Forum\Authorize::increasesPostsCount($this, $forum) !== true) {
2014 $postsChangeCount = 0;
2015 }
2016
2017 $newPostsCount = db_unsigned_increment('user_posts', $postsChangeCount);
2018 } else {
2019 $newPostsCount = $this->forumPosts()->whereIn('forum_id', Forum\Authorize::postsCountedForums($this))->count();
2020 }
2021
2022 $lastPost = $this->forumPosts()->select('post_time')->last();
2023
2024 // FIXME: not null column, hence default 0. Change column to allow null
2025 $lastPostTime = $lastPost !== null ? $lastPost->post_time : 0;
2026
2027 return $this->update([
2028 'user_posts' => $newPostsCount,
2029 'user_lastpost_time' => $lastPostTime,
2030 ]);
2031 }
2032
2033 public function scopeDefault($query)
2034 {
2035 return $query->where([
2036 'user_warnings' => 0,
2037 'user_type' => 0,
2038 ]);
2039 }
2040
2041 public function scopeOnline($query)
2042 {
2043 return $query
2044 ->where('user_allow_viewonline', true)
2045 ->where('user_lastvisit', '>', time() - $GLOBALS['cfg']['osu']['user']['online_window']);
2046 }
2047
2048 public function scopeWithoutBots(Builder $query): Builder
2049 {
2050 return $query->whereNot('group_id', app('groups')->byIdentifier('bot')->getKey());
2051 }
2052
2053 public function scopeWithoutNoProfile(Builder $query): Builder
2054 {
2055 return $query->whereNot('group_id', app('groups')->byIdentifier('no_profile')->getKey());
2056 }
2057
2058 public function checkPassword($password)
2059 {
2060 return Hash::check($password, $this->getAuthPassword());
2061 }
2062
2063 public function validatePasswordConfirmation()
2064 {
2065 $this->validatePasswordConfirmation = true;
2066
2067 return $this;
2068 }
2069
2070 public function setPasswordConfirmationAttribute($value)
2071 {
2072 $this->passwordConfirmation = $value;
2073 }
2074
2075 public function setPasswordAttribute($value)
2076 {
2077 // actual user_password assignment is after validation
2078 $this->password = $value;
2079 }
2080
2081 public function validateCurrentPassword()
2082 {
2083 $this->validateCurrentPassword = true;
2084
2085 return $this;
2086 }
2087
2088 public function setCurrentPasswordAttribute($value)
2089 {
2090 $this->currentPassword = $value;
2091 }
2092
2093 /**
2094 * Enables email presence and confirmation field equality check.
2095 */
2096 public function validateEmailConfirmation()
2097 {
2098 $this->validateEmailConfirmation = true;
2099
2100 return $this;
2101 }
2102
2103 public function setUserEmailConfirmationAttribute($value)
2104 {
2105 $this->emailConfirmation = $value;
2106 }
2107
2108 public static function attemptLogin($user, $password, $ip = null)
2109 {
2110 $ip = $ip ?? Request::getClientIp() ?? '0.0.0.0';
2111
2112 if (LoginAttempt::isLocked($ip)) {
2113 DatadogLoginAttempt::log('locked_ip');
2114
2115 return osu_trans('users.login.locked_ip');
2116 }
2117
2118 $authError = null;
2119
2120 if ($user === null) {
2121 $authError = 'nonexistent_user';
2122 } else {
2123 $isLoginBlocked = $user->isLoginBlocked();
2124
2125 if ($isLoginBlocked) {
2126 $authError = 'user_login_blocked';
2127 } else {
2128 if (!$user->checkPassword($password)) {
2129 $authError = 'invalid_password';
2130 }
2131 }
2132 }
2133
2134
2135 if ($authError !== null) {
2136 DatadogLoginAttempt::log($authError);
2137 LoginAttempt::logAttempt($ip, $user, 'fail', $password);
2138
2139 return osu_trans('users.login.failed');
2140 }
2141
2142 LoginAttempt::logLoggedIn($ip, $user);
2143 }
2144
2145 public static function findForLogin($username, $allowEmail = false)
2146 {
2147 $username = trim($username ?? '');
2148
2149 if ($username === null) {
2150 return null;
2151 }
2152
2153 $query = static::where('username', $username);
2154
2155 if ($GLOBALS['cfg']['osu']['user']['allow_email_login'] || $allowEmail) {
2156 $query->orWhere('user_email', strtolower($username));
2157 }
2158
2159 return $query->first();
2160 }
2161
2162 public static function findAndValidateForPassport($username, $password)
2163 {
2164 $user = static::findForLogin($username);
2165 $authError = static::attemptLogin($user, $password);
2166
2167 if ($authError === null) {
2168 return $user;
2169 }
2170
2171 throw OAuthServerException::invalidGrant($authError);
2172 }
2173
2174 public function cover(): Cover
2175 {
2176 return $this->cover ??= new Cover($this);
2177 }
2178
2179 public function customCover(): Uploader
2180 {
2181 return $this->customCover ??= new Uploader(
2182 'user-profile-covers',
2183 $this,
2184 'custom_cover_filename',
2185 ['image' => ['maxDimensions' => Cover::CUSTOM_COVER_MAX_DIMENSIONS]],
2186 );
2187 }
2188
2189 public function playCount()
2190 {
2191 return $this->memoize(__FUNCTION__, function () {
2192 $unionQuery = null;
2193
2194 foreach (Beatmap::MODES as $key => $_value) {
2195 $query = $this->statistics($key, true)->select('playcount');
2196
2197 if ($unionQuery === null) {
2198 $unionQuery = $query;
2199 } else {
2200 $unionQuery->unionAll($query);
2201 }
2202 }
2203
2204 return $unionQuery->get()->sum('playcount');
2205 });
2206 }
2207
2208 /**
2209 * User's previous usernames
2210 *
2211 * @param bool $includeCurrent true if previous usernames matching the the current one should be included.
2212 *
2213 * @return Collection string
2214 */
2215 public function previousUsernames(bool $includeCurrent = false)
2216 {
2217 $history = $this->usernameChangeHistoryPublic;
2218
2219 if (!$includeCurrent) {
2220 $history = $history->where('username_last', '<>', $this->username);
2221 }
2222
2223 return $history->pluck('username_last');
2224 }
2225
2226 public function profileBeatmapsetsRanked()
2227 {
2228 return $this->beatmapsets()
2229 ->withStates(['ranked', 'approved', 'qualified'])
2230 ->active()
2231 ->with('beatmaps');
2232 }
2233
2234 public function profileBeatmapsetsFavourite()
2235 {
2236 return $this->favouriteBeatmapsets()
2237 ->active()
2238 ->with('beatmaps');
2239 }
2240
2241 public function profileBeatmapsetsPending()
2242 {
2243 return $this->beatmapsets()->withStates(['pending', 'wip'])->active()->with('beatmaps');
2244 }
2245
2246 public function profileBeatmapsetsGraveyard()
2247 {
2248 return $this->beatmapsets()
2249 ->graveyard()
2250 ->active()
2251 ->with('beatmaps');
2252 }
2253
2254 public function profileBeatmapsetsLoved()
2255 {
2256 return $this->beatmapsets()
2257 ->loved()
2258 ->active()
2259 ->with('beatmaps');
2260 }
2261
2262 public function profileBeatmapsetsGuest()
2263 {
2264 return Beatmapset
2265 ::where('user_id', '<>', $this->getKey())
2266 ->whereHas(
2267 'beatmaps',
2268 fn (Builder $query) => $query->scoreable()->whereHas(
2269 'beatmapOwners',
2270 fn (Builder $ownerQuery) => $ownerQuery->where('user_id', $this->getKey())
2271 )
2272 )
2273 ->with('beatmaps');
2274 }
2275
2276 public function profileBeatmapsetsNominated()
2277 {
2278 return Beatmapset::withStates(['approved', 'ranked'])
2279 ->whereHas('beatmapsetNominations', fn ($q) => $q->current()->where('user_id', $this->getKey()))
2280 ->with('beatmaps');
2281 }
2282
2283 public function profileBeatmapsetCountByGroupedStatus(string $status)
2284 {
2285 return $this->memoize(__FUNCTION__, fn () =>
2286 ProfileBeatmapset::countByGroupedStatus($this))[$status] ?? 0;
2287 }
2288
2289 public function isSessionVerified()
2290 {
2291 return $this->isSessionVerified;
2292 }
2293
2294 public function markSessionVerified()
2295 {
2296 $this->isSessionVerified = true;
2297
2298 return $this;
2299 }
2300
2301 public function isValid()
2302 {
2303 $this->validationErrors()->reset();
2304
2305 if ($this->isDirty('username')) {
2306 $errors = UsernameValidation::validateUsername($this->username);
2307
2308 if ($errors->isAny()) {
2309 $this->validationErrors()->merge($errors);
2310 }
2311 }
2312
2313 if ($this->validateCurrentPassword) {
2314 if (!$this->checkPassword($this->currentPassword)) {
2315 $this->validationErrors()->add('current_password', '.wrong_current_password');
2316 }
2317 }
2318
2319 if ($this->validatePasswordConfirmation) {
2320 if ($this->password !== $this->passwordConfirmation) {
2321 $this->validationErrors()->add('password_confirmation', '.wrong_password_confirmation');
2322 }
2323 }
2324
2325 if (present($this->password)) {
2326 if (present($this->username)) {
2327 if (strpos(strtolower($this->password), strtolower($this->username)) !== false) {
2328 $this->validationErrors()->add('password', '.contains_username');
2329 }
2330 }
2331
2332 if (strlen($this->password) < 8) {
2333 $this->validationErrors()->add('password', '.too_short');
2334 }
2335
2336 if (WeakPassword::check($this->password)) {
2337 $this->validationErrors()->add('password', '.weak');
2338 }
2339
2340 if ($this->validationErrors()->isEmpty()) {
2341 $this->user_password = Hash::make($this->password);
2342 }
2343 }
2344
2345 if ($this->validateEmailConfirmation) {
2346 if ($this->user_email === null) {
2347 $this->validationErrors()->add('user_email', '.required');
2348 }
2349
2350 if ($this->user_email !== $this->emailConfirmation) {
2351 $this->validationErrors()->add('user_email_confirmation', '.wrong_email_confirmation');
2352 }
2353 }
2354
2355 if ($this->isDirty('user_email') && present($this->user_email)) {
2356 $this->isValidEmail();
2357 }
2358
2359 $countryAcronym = $this->country_acronym;
2360 if ($countryAcronym === null) {
2361 $this->country_acronym = Country::UNKNOWN;
2362 } elseif ($this->isDirty('country_acronym') && $countryAcronym !== Country::UNKNOWN) {
2363 if (app('countries')->byCode($countryAcronym) === null) {
2364 $this->validationErrors()->add('country', '.invalid_country');
2365 }
2366 }
2367
2368 // user_discord is an accessor for user_jabber
2369 if ($this->isDirty('user_jabber') && present($this->user_discord)) {
2370 // This is a basic check and not 100% compliant to Discord's spec, only validates that input:
2371 // - is a 2-32 char username (excluding chars @#:) and 4-digit discriminator for old-style usernames; or,
2372 // - 2-32 char alphanumeric + period username for new-style usernames; consecutive periods are not validated.
2373 if (!preg_match('/^([^@#:]{2,32}#\d{4}|[\w.]{2,32})$/i', $this->user_discord)) {
2374 $this->validationErrors()->add('user_discord', '.invalid_discord');
2375 }
2376 }
2377
2378 if ($this->isDirty('user_twitter') && present($this->user_twitter)) {
2379 // https://help.twitter.com/en/managing-your-account/twitter-username-rules
2380 if (!preg_match('/^[a-zA-Z0-9_]{1,15}$/', $this->user_twitter)) {
2381 $this->validationErrors()->add('user_twitter', '.invalid_twitter');
2382 }
2383 }
2384
2385 $this->validateDbFieldLengths();
2386
2387 if ($this->isDirty('group_id') && app('groups')->byId($this->group_id) === null) {
2388 $this->validationErrors()->add('group_id', 'invalid');
2389 }
2390
2391 return $this->validationErrors()->isEmpty();
2392 }
2393
2394 public function isValidEmail()
2395 {
2396 if (!is_valid_email_format($this->user_email)) {
2397 $this->validationErrors()->add('user_email', '.invalid_email');
2398
2399 // no point validating further if address isn't valid.
2400 return false;
2401 }
2402
2403 $banlist = DB::table('phpbb_banlist')->where('ban_end', '>=', now()->timestamp)->orWhere('ban_end', 0);
2404 foreach (model_pluck($banlist, 'ban_email') as $check) {
2405 if (preg_match('#^'.str_replace('\*', '.*?', preg_quote($check, '#')).'$#i', $this->user_email)) {
2406 $this->validationErrors()->add('user_email', '.email_not_allowed');
2407
2408 return false;
2409 }
2410 }
2411
2412 if (static::where('user_id', '<>', $this->getKey())->where('user_email', '=', $this->user_email)->exists()) {
2413 $this->validationErrors()->add('user_email', '.email_already_used');
2414
2415 return false;
2416 }
2417
2418 return true;
2419 }
2420
2421 public function preferredLocale()
2422 {
2423 return $this->user_lang;
2424 }
2425
2426 public function url(?string $ruleset = null)
2427 {
2428 return route('users.show', ['mode' => $ruleset, 'user' => $this->getKey()]);
2429 }
2430
2431 public function validationErrorsTranslationPrefix(): string
2432 {
2433 return 'user';
2434 }
2435
2436 public function save(array $options = [])
2437 {
2438 if ($options['skipValidations'] ?? false) {
2439 return parent::save($options);
2440 }
2441
2442 return $this->isValid() && parent::save($options);
2443 }
2444
2445 public function afterCommit()
2446 {
2447 dispatch(new EsDocument($this));
2448 }
2449
2450 protected function newReportableExtraParams(): array
2451 {
2452 return [
2453 'reason' => 'Cheating',
2454 'user_id' => $this->getKey(),
2455 ];
2456 }
2457
2458 private function getDisplayedLastVisit()
2459 {
2460 return $this->hide_presence ? null : $this->user_lastvisit;
2461 }
2462
2463 private function getOsuPlaystyle()
2464 {
2465 $value = $this->getRawAttribute('osu_playstyle');
2466
2467 $styles = [];
2468 foreach (self::PLAYSTYLES as $type => $bit) {
2469 if (($value & $bit) !== 0) {
2470 $styles[] = $type;
2471 }
2472 }
2473
2474 return empty($styles) ? null : $styles;
2475 }
2476
2477 private function getPlaymode()
2478 {
2479 return Beatmap::modeStr($this->osu_playmode);
2480 }
2481
2482 private function getUserColour()
2483 {
2484 $value = presence($this->getRawAttribute('user_colour'));
2485
2486 return $value === null
2487 ? null
2488 : "#{$value}";
2489 }
2490
2491 private function getUserRank()
2492 {
2493 $value = $this->getRawAttribute('user_rank');
2494
2495 return $value === 0 ? null : $value;
2496 }
2497
2498 private function getUserStyle()
2499 {
2500 $value = $this->getRawAttribute('user_style');
2501
2502 return $value === 0 ? null : $value;
2503 }
2504
2505 private function getUserWebsite()
2506 {
2507 $value = presence(trim($this->getRawAttribute('user_website')));
2508
2509 return $value === null
2510 ? null
2511 : (is_http($value)
2512 ? $value
2513 : "https://{$value}"
2514 );
2515 }
2516}