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\Http\Controllers;
7
8use App\Exceptions\ModelNotSavedException;
9use App\Exceptions\ValidationException;
10use App\Http\Middleware\RequestCost;
11use App\Libraries\ClientCheck;
12use App\Libraries\RateLimiter;
13use App\Libraries\Search\ForumSearch;
14use App\Libraries\Search\ForumSearchRequestParams;
15use App\Libraries\Search\ScoreSearchParams;
16use App\Libraries\User\FindForProfilePage;
17use App\Libraries\UserRegistration;
18use App\Models\Beatmap;
19use App\Models\BeatmapDiscussion;
20use App\Models\IpBan;
21use App\Models\Log;
22use App\Models\User;
23use App\Models\UserAccountHistory;
24use App\Transformers\CurrentUserTransformer;
25use App\Transformers\ScoreTransformer;
26use App\Transformers\UserCompactTransformer;
27use App\Transformers\UserMonthlyPlaycountTransformer;
28use App\Transformers\UserReplaysWatchedCountTransformer;
29use App\Transformers\UserTransformer;
30use Auth;
31use Illuminate\Database\Eloquent\Relations\MorphTo;
32use Request;
33use romanzipp\Turnstile\Validator as TurnstileValidator;
34use Sentry\State\Scope;
35use Symfony\Component\HttpKernel\Exception\HttpException;
36
37/**
38 * @group Users
39 */
40class UsersController extends Controller
41{
42 // more limited list of UserProfileCustomization::SECTIONS for now.
43 const LAZY_EXTRA_PAGES = ['beatmaps', 'kudosu', 'recent_activity', 'top_ranks', 'historical'];
44
45 const PER_PAGE = [
46 'scoresBest' => 5,
47 'scoresFirsts' => 5,
48 'scoresPinned' => 5,
49 'scoresRecent' => 5,
50
51 'beatmapPlaycounts' => 5,
52 'favouriteBeatmapsets' => 6,
53 'graveyardBeatmapsets' => 2,
54 'guestBeatmapsets' => 6,
55 'lovedBeatmapsets' => 6,
56 'nominatedBeatmapsets' => 6,
57 'pendingBeatmapsets' => 6,
58 'rankedBeatmapsets' => 6,
59
60 'recentActivity' => 5,
61 'recentlyReceivedKudosu' => 5,
62 ];
63
64 protected $maxResults = 100;
65
66 private ?string $mode = null;
67 private ?int $offset = null;
68 private ?int $perPage = null;
69 private ?User $user = null;
70
71 public function __construct()
72 {
73 $this->middleware('guest', ['only' => ['create', 'store', 'storeWeb']]);
74 $this->middleware('auth', ['only' => [
75 'checkUsernameAvailability',
76 'report',
77 'me',
78 'posts',
79 'updatePage',
80 ]]);
81
82 $this->middleware('throttle:60,10', ['only' => ['store']]);
83
84 $this->middleware('require-scopes:identify', ['only' => ['me']]);
85 $this->middleware('require-scopes:public', ['only' => [
86 'beatmapsets',
87 'index',
88 'kudosu',
89 'recentActivity',
90 'scores',
91 'show',
92 ]]);
93
94 $this->middleware(function ($request, $next) {
95 $this->parsePaginationParams();
96
97 return $next($request);
98 }, [
99 'only' => ['extraPages', 'scores', 'beatmapsets', 'kudosu', 'recentActivity'],
100 ]);
101
102 parent::__construct();
103 }
104
105 private static function storeClientDisabledError()
106 {
107 return response([
108 'error' => osu_trans('users.store.from_web'),
109 'url' => route('users.create'),
110 ], 403);
111 }
112
113 public function create()
114 {
115 if (!$GLOBALS['cfg']['osu']['user']['registration_mode']['web']) {
116 return abort(403, osu_trans('users.store.from_client'));
117 }
118
119 return ext_view('users.create');
120 }
121
122 public function disabled()
123 {
124 return ext_view('users.disabled');
125 }
126
127 public function checkUsernameAvailability()
128 {
129 $username = Request::input('username') ?? '';
130
131 $errors = Auth::user()->validateChangeUsername($username);
132
133 $available = $errors->isEmpty();
134 $message = $available ? "Username '".e($username)."' is available!" : $errors->toSentence();
135 $cost = $available ? Auth::user()->usernameChangeCost() : 0;
136
137 return [
138 'username' => Request::input('username'),
139 'available' => $available,
140 'message' => $message,
141 'cost' => $cost,
142 'costString' => currency($cost),
143 ];
144 }
145
146 public function extraPages($_id, $page)
147 {
148 // TODO: counts basically duplicated from UserCompactTransformer
149 switch ($page) {
150 case 'beatmaps':
151 return [
152 'favourite' => $this->getExtraSection('favouriteBeatmapsets', $this->user->profileBeatmapsetsFavourite()->count()),
153 'graveyard' => $this->getExtraSection('graveyardBeatmapsets', $this->user->profileBeatmapsetCountByGroupedStatus('graveyard')),
154 'guest' => $this->getExtraSection('guestBeatmapsets', $this->user->profileBeatmapsetsGuest()->count()),
155 'loved' => $this->getExtraSection('lovedBeatmapsets', $this->user->profileBeatmapsetCountByGroupedStatus('loved')),
156 'nominated' => $this->getExtraSection('nominatedBeatmapsets', $this->user->profileBeatmapsetsNominated()->count()),
157 'ranked' => $this->getExtraSection('rankedBeatmapsets', $this->user->profileBeatmapsetCountByGroupedStatus('ranked')),
158 'pending' => $this->getExtraSection('pendingBeatmapsets', $this->user->profileBeatmapsetCountByGroupedStatus('pending')),
159 ];
160
161 case 'historical':
162 return [
163 'beatmap_playcounts' => $this->getExtraSection('beatmapPlaycounts', $this->user->beatmapPlaycounts()->count()),
164 'monthly_playcounts' => json_collection($this->user->monthlyPlaycounts, new UserMonthlyPlaycountTransformer()),
165 'recent' => $this->getExtraSection(
166 'scoresRecent',
167 $this->user->soloScores()->recent($this->mode, false)->count(),
168 ),
169 'replays_watched_counts' => json_collection($this->user->replaysWatchedCounts, new UserReplaysWatchedCountTransformer()),
170 ];
171
172 case 'kudosu':
173 return $this->getExtraSection('recentlyReceivedKudosu');
174
175 case 'recent_activity':
176 return $this->getExtraSection('recentActivity');
177
178 case 'top_ranks':
179 return [
180 'best' => $this->getExtraSection(
181 'scoresBest',
182 count($this->user->beatmapBestScoreIds($this->mode, ScoreSearchParams::showLegacyForUser(\Auth::user())))
183 ),
184 'firsts' => $this->getExtraSection(
185 'scoresFirsts',
186 $this->user->scoresFirst($this->mode, true)->count()
187 ),
188 'pinned' => $this->getExtraSection(
189 'scoresPinned',
190 $this->user->scorePins()->forRuleset($this->mode)->withVisibleScore()->count()
191 ),
192 ];
193
194 default:
195 abort(404);
196 }
197 }
198
199 public function store()
200 {
201 if (!$GLOBALS['cfg']['osu']['user']['registration_mode']['client']) {
202 return static::storeClientDisabledError();
203 }
204
205 $request = \Request::instance();
206
207 if (!starts_with($request->header('User-Agent'), $GLOBALS['cfg']['osu']['client']['user_agent'])) {
208 return error_popup(osu_trans('users.store.from_client'), 403);
209 }
210
211 try {
212 ClientCheck::parseToken($request);
213 } catch (HttpException $e) {
214 return static::storeClientDisabledError();
215 }
216
217 return $this->storeUser($request->all());
218 }
219
220 public function storeWeb()
221 {
222 if (!$GLOBALS['cfg']['osu']['user']['registration_mode']['web']) {
223 return error_popup(osu_trans('users.store.from_client'), 403);
224 }
225
226 $rawParams = request()->all();
227
228 if (captcha_enabled()) {
229 $token = get_string($rawParams['cf-turnstile-response'] ?? null) ?? '';
230
231 $validCaptcha = (new TurnstileValidator())->validate($token)->isValid();
232
233 if (!$validCaptcha) {
234 return abort(422, 'invalid captcha');
235 }
236 }
237
238 $params = get_params($rawParams, 'user', [
239 'password',
240 'password_confirmation',
241 'user_email',
242 'user_email_confirmation',
243 ], ['null_missing' => true]);
244
245 foreach (['user_email', 'password'] as $confirmableField) {
246 $confirmationField = "{$confirmableField}_confirmation";
247 if ($params[$confirmableField] !== $params[$confirmationField]) {
248 return response([
249 'form_error' => ['user' => [$confirmationField => osu_trans('model_validation.wrong_confirmation')]],
250 ], 422);
251 }
252 }
253
254 return $this->storeUser($rawParams);
255 }
256
257 /**
258 * Get User Beatmaps
259 *
260 * Returns the beatmaps of specified user.
261 *
262 * | Type | Notes
263 * |------------ | -----
264 * | favourite | |
265 * | graveyard | |
266 * | guest | |
267 * | loved | |
268 * | most_played | |
269 * | nominated | |
270 * | pending | Previously `unranked`
271 * | ranked | Previously `ranked_and_approved`
272 *
273 * ---
274 *
275 * ### Response format
276 *
277 * Array of [BeatmapPlaycount](#beatmapplaycount) when `type` is `most_played`;
278 * array of [BeatmapsetExtended](#beatmapsetextended), otherwise.
279 *
280 * @urlParam user integer required Id of the user. Example: 1
281 * @urlParam type string required Beatmap type. Example: favourite
282 *
283 * @queryParam limit Maximum number of results.
284 * @queryParam offset Result offset for pagination. Example: 1
285 *
286 * @response [
287 * {
288 * "id": 1,
289 * "other": "attributes..."
290 * },
291 * {
292 * "id": 2,
293 * "other": "attributes..."
294 * }
295 * ]
296 */
297 public function beatmapsets($_userId, $type)
298 {
299 static $mapping = [
300 'favourite' => 'favouriteBeatmapsets',
301 'graveyard' => 'graveyardBeatmapsets',
302 'guest' => 'guestBeatmapsets',
303 'loved' => 'lovedBeatmapsets',
304 'most_played' => 'beatmapPlaycounts',
305 'nominated' => 'nominatedBeatmapsets',
306 'ranked' => 'rankedBeatmapsets',
307 'pending' => 'pendingBeatmapsets',
308
309 // TODO: deprecated
310 'ranked_and_approved' => 'rankedBeatmapsets',
311 'unranked' => 'pendingBeatmapsets',
312 ];
313
314 $page = $mapping[$type] ?? abort(404);
315
316 // Override per page restriction in parsePaginationParams to allow infinite paging
317 $perPage = $this->sanitizedLimitParam();
318
319 return $this->getExtra($page, [], $perPage, $this->offset);
320 }
321
322 /**
323 * Get Users
324 *
325 * Returns list of users.
326 *
327 * ---
328 *
329 * ### Response format
330 *
331 * Field | Type | Description
332 * ----- | --------------- | -----------
333 * users | [User](#user)[] | Includes `country`, `cover`, `groups`, and `statistics_rulesets`.
334 *
335 * @queryParam ids[] User id to be returned. Specify once for each user id requested. Up to 50 users can be requested at once. Example: 1
336 * @queryParam include_variant_statistics boolean Whether to additionally include `statistics_rulesets.variants` (default: `false`). No-example
337 *
338 * @response {
339 * "users": [
340 * {
341 * "id": 1,
342 * "other": "attributes..."
343 * },
344 * {
345 * "id": 2,
346 * "other": "attributes..."
347 * }
348 * ]
349 * }
350 */
351 public function index()
352 {
353 $params = get_params(request()->all(), null, [
354 'ids:int[]',
355 'include_variant_statistics:bool',
356 ]);
357
358 $includes = UserCompactTransformer::CARD_INCLUDES;
359
360 if (isset($params['ids'])) {
361 $includeVariantStatistics = $params['include_variant_statistics'] ?? false;
362 $preload = UserCompactTransformer::CARD_INCLUDES_PRELOAD;
363
364 RequestCost::setCost(count($params['ids']) * ($includeVariantStatistics ? 3 : 1));
365
366 foreach (Beatmap::MODES as $ruleset => $_rulesetId) {
367 $includes[] = "statistics_rulesets.{$ruleset}";
368 $preload[] = User::statisticsRelationName($ruleset);
369
370 if ($includeVariantStatistics) {
371 $includes[] = "statistics_rulesets.{$ruleset}.variants";
372
373 foreach (Beatmap::VARIANTS[$ruleset] ?? [] as $variant) {
374 $preload[] = User::statisticsRelationName($ruleset, $variant);
375 }
376 }
377 }
378
379 $users = User
380 ::whereIn('user_id', array_slice($params['ids'], 0, 50))
381 ->default()
382 ->with($preload)
383 ->get();
384
385 if ($includeVariantStatistics) {
386 // Preload user on statistics relations that have variants.
387 // See `UserStatisticsTransformer::includeVariants()`
388 foreach ($users as $user) {
389 foreach (Beatmap::VARIANTS as $ruleset => $_variants) {
390 $user->statistics($ruleset)?->setRelation('user', $user);
391 }
392 }
393 }
394 }
395
396 return [
397 'users' => json_collection($users ?? [], 'UserCompact', $includes),
398 ];
399 }
400
401 public function posts($id)
402 {
403 $user = User::lookup($id, 'id', true);
404 if ($user === null || !priv_check('UserShow', $user)->can()) {
405 abort(404);
406 }
407
408 $params = request()->all();
409 $params['username'] = $id;
410 $search = (new ForumSearch(new ForumSearchRequestParams($params, Auth::user())))->size(50);
411
412 $fields = ['user' => null];
413 if (!(Auth::user()?->isModerator() ?? false)) {
414 $fields['includeDeleted'] = null;
415 }
416
417 return ext_view('users.posts', compact('fields', 'search', 'user'));
418 }
419
420 /**
421 * Get User Kudosu
422 *
423 * Returns kudosu history.
424 *
425 * ---
426 *
427 * ### Response format
428 *
429 * Array of [KudosuHistory](#kudosuhistory).
430 *
431 * @urlParam user integer required Id of the user. Example: 1
432 *
433 * @queryParam limit Maximum number of results.
434 * @queryParam offset Result offset for pagination. Example: 1
435 *
436 * @response [
437 * {
438 * "id": 1,
439 * "other": "attributes..."
440 * },
441 * {
442 * "id": 2,
443 * "other": "attributes..."
444 * }
445 * ]
446 */
447 public function kudosu($_userId)
448 {
449 return $this->getExtra('recentlyReceivedKudosu', [], $this->perPage, $this->offset);
450 }
451
452 /**
453 * Get User Recent Activity
454 *
455 * Returns recent activity.
456 *
457 * ---
458 *
459 * ### Response format
460 *
461 * Array of [Event](#event).
462 *
463 * @urlParam user integer required Id of the user. Example: 1
464 *
465 * @queryParam limit Maximum number of results.
466 * @queryParam offset Result offset for pagination. Example: 1
467 *
468 * @response [
469 * {
470 * "id": 1,
471 * "other": "attributes..."
472 * },
473 * {
474 * "id": 2,
475 * "other": "attributes..."
476 * }
477 * ]
478 */
479 public function recentActivity($_userId)
480 {
481 return $this->getExtra('recentActivity', [], $this->perPage, $this->offset);
482 }
483
484 /**
485 * Get User Scores
486 *
487 * This endpoint returns the scores of specified user.
488 *
489 * ---
490 *
491 * ### Response format
492 *
493 * Array of [Score](#score).
494 * Following attributes are included in the response object when applicable.
495 *
496 * Attribute | Notes
497 * -----------|----------------------
498 * beatmap | |
499 * beatmapset | |
500 * weight | Only for type `best`.
501 *
502 * @urlParam user integer required Id of the user. Example: 1
503 * @urlParam type string required Score type. Must be one of these: `best`, `firsts`, `recent`. Example: best
504 *
505 * @queryParam legacy_only integer Whether or not to exclude lazer scores. Defaults to 0. Example: 0
506 * @queryParam include_fails Only for recent scores, include scores of failed plays. Set to 1 to include them. Defaults to 0. Example: 0
507 * @queryParam mode [Ruleset](#ruleset) of the scores to be returned. Defaults to the specified `user`'s mode. Example: osu
508 * @queryParam limit Maximum number of results.
509 * @queryParam offset Result offset for pagination. Example: 1
510 *
511 * @response [
512 * {
513 * "id": 1,
514 * "other": "attributes..."
515 * },
516 * {
517 * "id": 2,
518 * "other": "attributes..."
519 * }
520 * ]
521 */
522 public function scores($_userId, $type)
523 {
524 static $mapping = [
525 'best' => 'scoresBest',
526 'firsts' => 'scoresFirsts',
527 'pinned' => 'scoresPinned',
528 'recent' => 'scoresRecent',
529 ];
530
531 $page = $mapping[$type] ?? abort(404);
532
533 $perPage = $this->perPage;
534
535 if ($type === 'firsts' || $type === 'pinned') {
536 // Override per page restriction in parsePaginationParams to allow infinite paging
537 $perPage = $this->sanitizedLimitParam();
538 }
539
540 $options = [
541 'includeFails' => get_bool(request('include_fails')) ?? false,
542 ];
543
544 $json = $this->getExtra($page, $options, $perPage, $this->offset);
545
546 return response($json, is_null($json['error'] ?? null) ? 200 : 504);
547 }
548
549 /**
550 * Get Own Data
551 *
552 * Similar to [Get User](#get-user) but with authenticated user (token owner) as user id.
553 *
554 * ---
555 *
556 * ### Response format
557 *
558 * See [Get User](#get-user).
559 *
560 * `session_verified` attribute is included.
561 * Additionally, `statistics_rulesets` is included, containing statistics for all rulesets.
562 *
563 * @urlParam mode string [Ruleset](#ruleset). User default mode will be used if not specified. Example: osu
564 *
565 * @response "See User object section"
566 */
567 public function me($mode = null)
568 {
569 $user = \Auth::user();
570 $currentMode = $mode ?? $user->playmode;
571
572 if (!Beatmap::isModeValid($currentMode)) {
573 abort(404);
574 }
575
576 $user->statistics($currentMode)?->setRelation('user', $user);
577
578 return $this->fillDeprecatedDuplicateFields(json_item(
579 $user,
580 (new UserTransformer())->setMode($currentMode),
581 [
582 'session_verified',
583 ...$this->showUserIncludes(),
584 ...array_map(
585 fn (string $ruleset) => "statistics_rulesets.{$ruleset}",
586 array_keys(Beatmap::MODES),
587 ),
588 ],
589 ));
590 }
591
592 /**
593 * Get User
594 *
595 * This endpoint returns the detail of specified user.
596 *
597 * <aside class="notice">
598 * It's highly recommended to pass <code>key</code> parameter to avoid getting unexpected result (mainly when looking up user with numeric username or nonexistent user id).
599 * </aside>
600 *
601 * ---
602 *
603 * ### Response format
604 *
605 * Returns [UserExtended](#userextended) object.
606 * The following [optional attributes on User](#user-optionalattributes) are included:
607 *
608 * - account_history
609 * - active_tournament_banner
610 * - badges
611 * - beatmap_playcounts_count
612 * - favourite_beatmapset_count
613 * - follower_count
614 * - graveyard_beatmapset_count
615 * - groups
616 * - loved_beatmapset_count
617 * - mapping_follower_count
618 * - monthly_playcounts
619 * - page
620 * - pending_beatmapset_count
621 * - previous_usernames
622 * - rank_highest
623 * - rank_history
624 * - ranked_beatmapset_count
625 * - replays_watched_counts
626 * - scores_best_count
627 * - scores_first_count
628 * - scores_recent_count
629 * - statistics
630 * - statistics.country_rank
631 * - statistics.rank
632 * - statistics.variants
633 * - support_level
634 * - user_achievements
635 *
636 * @urlParam user integer required Id or `@`-prefixed username of the user. Previous usernames are also checked in some cases. Example: 1
637 * @urlParam mode string [Ruleset](#ruleset). User default mode will be used if not specified. Example: osu
638 *
639 * @queryParam key Type of `user` passed in url parameter. Can be either `id` or `username` to limit lookup by their respective type. Passing empty or invalid value will result in id lookup followed by username lookup if not found. This parameter has been deprecated. Prefix `user` parameter with `@` instead to lookup by username.
640 *
641 * @response "See User object section"
642 */
643 public function show($id, $mode = null)
644 {
645 $user = FindForProfilePage::find($id, get_string(request('key')));
646
647 $currentMode = $mode ?? $user->playmode;
648
649 if (!Beatmap::isModeValid($currentMode)) {
650 abort(404);
651 }
652
653 // preload and set relation for opengraph header and transformer sharing data
654 $user->statistics($currentMode)?->setRelation('user', $user);
655
656 $userArray = $this->fillDeprecatedDuplicateFields(json_item(
657 $user,
658 (new UserTransformer())->setMode($currentMode),
659 $this->showUserIncludes(),
660 ));
661
662 if (is_api_request()) {
663 return $userArray;
664 } else {
665 $achievements = json_collection(app('medals')->all(), 'Achievement');
666 $currentUser = \Auth::user();
667 if ($currentUser !== null && $currentUser->getKey() === $user->getKey()) {
668 $userCoverPresets = app('user-cover-presets')->json();
669 }
670
671 $initialData = [
672 'achievements' => $achievements,
673 'current_mode' => $currentMode,
674 'scores_notice' => $GLOBALS['cfg']['osu']['user']['profile_scores_notice'],
675 'user' => $userArray,
676 'user_cover_presets' => $userCoverPresets ?? [],
677 ];
678
679 set_opengraph($user, 'show', $currentMode);
680
681 return ext_view('users.show', compact('initialData', 'mode', 'user'));
682 }
683 }
684
685 public function updatePage($id)
686 {
687 $user = User::findOrFail($id);
688
689 priv_check('UserPageEdit', $user)->ensureCan();
690
691 try {
692 $user = $user->updatePage(request('body'));
693
694 if (!$user->is(auth()->user())) {
695 UserAccountHistory::logUserPageModerated($user, auth()->user());
696
697 $this->log([
698 'log_type' => Log::LOG_USER_MOD,
699 'log_operation' => 'LOG_USER_PAGE_EDIT',
700 'log_data' => ['id' => $user->getKey()],
701 ]);
702 }
703
704 return ['html' => $user->userPage->bodyHTML(['modifiers' => ['profile-page']])];
705 } catch (ModelNotSavedException $e) {
706 return error_popup($e->getMessage());
707 }
708 }
709
710 private function parsePaginationParams()
711 {
712 $this->user = FindForProfilePage::find(request()->route('user'), 'id');
713
714 $this->mode = request()->route('mode') ?? request()->input('mode') ?? $this->user->playmode;
715 if (!Beatmap::isModeValid($this->mode)) {
716 abort(404);
717 }
718
719 $this->offset = max(0, get_int(Request::input('offset')) ?? 0);
720
721 if ($this->offset >= $this->maxResults) {
722 $this->perPage = 0;
723 } else {
724 $perPage = $this->sanitizedLimitParam();
725 $this->perPage = min($perPage, $this->maxResults - $this->offset);
726 }
727 }
728
729 private function sanitizedLimitParam()
730 {
731 return \Number::clamp(get_int(request('limit')) ?? 5, 1, 100);
732 }
733
734 private function getExtra($page, array $options, int $perPage = 10, int $offset = 0)
735 {
736 // Grouped by $transformer and sorted alphabetically ($transformer and then $page).
737 switch ($page) {
738 // BeatmapPlaycount
739 case 'beatmapPlaycounts':
740 $transformer = 'BeatmapPlaycount';
741 $query = $this->user->beatmapPlaycounts()
742 ->with('beatmap', 'beatmap.beatmapset')
743 ->whereHas('beatmap.beatmapset')
744 ->orderBy('playcount', 'desc')
745 ->orderBy('beatmap_id', 'desc'); // for consistent sorting
746 break;
747
748 // Beatmapset
749 case 'favouriteBeatmapsets':
750 $transformer = 'Beatmapset';
751 $includes = ['beatmaps'];
752 $query = $this->user->profileBeatmapsetsFavourite();
753 break;
754 case 'graveyardBeatmapsets':
755 $transformer = 'Beatmapset';
756 $includes = ['beatmaps'];
757 $query = $this->user->profileBeatmapsetsGraveyard()
758 ->orderBy('last_update', 'desc');
759 break;
760 case 'guestBeatmapsets':
761 $transformer = 'Beatmapset';
762 $includes = ['beatmaps'];
763 $query = $this->user->profileBeatmapsetsGuest()
764 ->orderBy('approved_date', 'desc');
765 break;
766 case 'lovedBeatmapsets':
767 $transformer = 'Beatmapset';
768 $includes = ['beatmaps'];
769 $query = $this->user->profileBeatmapsetsLoved()
770 ->orderBy('approved_date', 'desc');
771 break;
772 case 'nominatedBeatmapsets':
773 $transformer = 'Beatmapset';
774 $includes = ['beatmaps'];
775 $query = $this->user->profileBeatmapsetsNominated()
776 ->orderBy('approved_date', 'desc');
777 break;
778 case 'rankedBeatmapsets':
779 $transformer = 'Beatmapset';
780 $includes = ['beatmaps'];
781 $query = $this->user->profileBeatmapsetsRanked()
782 ->orderBy('approved_date', 'desc');
783 break;
784 case 'pendingBeatmapsets':
785 $transformer = 'Beatmapset';
786 $includes = ['beatmaps'];
787 $query = $this->user->profileBeatmapsetsPending()
788 ->orderBy('last_update', 'desc');
789 break;
790
791 // Event
792 case 'recentActivity':
793 $transformer = 'Event';
794 $query = $this->user->events()->recent();
795 break;
796
797 // KudosuHistory
798 case 'recentlyReceivedKudosu':
799 $transformer = 'KudosuHistory';
800 $query = $this->user->receivedKudosu()
801 ->with('post', 'post.topic', 'giver')
802 ->with(['kudosuable' => function (MorphTo $morphTo) {
803 $morphTo->morphWith([BeatmapDiscussion::class => ['beatmap', 'beatmapset']]);
804 }])
805 ->orderBy('exchange_id', 'desc');
806 break;
807
808 // Score
809 case 'scoresBest':
810 $transformer = new ScoreTransformer();
811 $includes = [...ScoreTransformer::USER_PROFILE_INCLUDES, 'weight'];
812 $collection = $this->user->beatmapBestScores(
813 $this->mode,
814 $perPage,
815 $offset,
816 ScoreTransformer::USER_PROFILE_INCLUDES_PRELOAD,
817 ScoreSearchParams::showLegacyForUser(\Auth::user()),
818 );
819 $userRelationColumn = 'user';
820 break;
821 case 'scoresFirsts':
822 $transformer = new ScoreTransformer();
823 $includes = ScoreTransformer::USER_PROFILE_INCLUDES;
824 $query = $this
825 ->user
826 ->scoresFirst($this->mode, true)
827 ->with(array_map(
828 fn ($include) => "score.{$include}",
829 ScoreTransformer::USER_PROFILE_INCLUDES_PRELOAD,
830 ))
831 ->orderByDesc('score_id');
832 $userRelationColumn = 'user';
833 $collectionFn = fn ($scoreFirst) => $scoreFirst->map->score;
834 break;
835 case 'scoresPinned':
836 $transformer = new ScoreTransformer();
837 $includes = ScoreTransformer::USER_PROFILE_INCLUDES;
838 $query = $this->user
839 ->scorePins()
840 ->forRuleset($this->mode)
841 ->withVisibleScore()
842 ->with(array_map(fn ($include) => "score.{$include}", ScoreTransformer::USER_PROFILE_INCLUDES_PRELOAD))
843 ->reorderBy('display_order', 'asc');
844 $collectionFn = fn ($pins) => $pins->map->score;
845 $userRelationColumn = 'user';
846 break;
847 case 'scoresRecent':
848 $transformer = new ScoreTransformer();
849 $includes = ScoreTransformer::USER_PROFILE_INCLUDES;
850 $query = $this->user->soloScores()
851 ->recent($this->mode, $options['includeFails'] ?? false)
852 ->reorderBy('ended_at', 'desc')
853 ->with(ScoreTransformer::USER_PROFILE_INCLUDES_PRELOAD);
854 $userRelationColumn = 'user';
855 break;
856 }
857
858 if (!isset($collection)) {
859 $collection = $query->limit($perPage)->offset($offset)->get();
860
861 if (isset($collectionFn)) {
862 $collection = $collectionFn($collection);
863 }
864 }
865
866 if (isset($userRelationColumn)) {
867 foreach ($collection as $item) {
868 $item->setRelation($userRelationColumn, $this->user);
869 }
870 }
871
872 return json_collection($collection, $transformer, $includes ?? []);
873 }
874
875 private function getExtraSection(string $section, ?int $count = null)
876 {
877 // TODO: replace with cursor.
878 $items = $this->getExtra($section, [], static::PER_PAGE[$section] + 1);
879 $hasMore = count($items) > static::PER_PAGE[$section];
880 if ($hasMore) {
881 array_pop($items);
882 }
883
884 $response = [
885 'items' => $items,
886 'pagination' => [
887 'hasMore' => $hasMore,
888 ],
889 ];
890
891 if ($count !== null) {
892 $response['count'] = $count;
893 }
894
895 return $response;
896 }
897
898 private function showUserIncludes()
899 {
900 static $apiIncludes = [
901 // historical
902 'beatmap_playcounts_count',
903 'monthly_playcounts',
904 'replays_watched_counts',
905 'scores_recent_count',
906
907 // beatmapsets
908 'favourite_beatmapset_count',
909 'graveyard_beatmapset_count',
910 'guest_beatmapset_count',
911 'loved_beatmapset_count',
912 'nominated_beatmapset_count',
913 'pending_beatmapset_count',
914 'ranked_beatmapset_count',
915
916 // top scores
917 'scores_best_count',
918 'scores_first_count',
919 'scores_pinned_count',
920 ];
921
922 $userIncludes = [
923 ...UserTransformer::PROFILE_HEADER_INCLUDES,
924 'account_history',
925 'daily_challenge_user_stats',
926 'page',
927 'pending_beatmapset_count',
928 'rank_highest',
929 'rank_history',
930 'statistics',
931 'statistics.country_rank',
932 'statistics.rank',
933 'statistics.variants',
934 'team',
935 'user_achievements',
936 ];
937
938 if (is_api_request()) {
939 // TODO: deprecate
940 $userIncludes = array_merge($userIncludes, $apiIncludes);
941 }
942
943 if (priv_check('UserSilenceShowExtendedInfo')->can() && !is_api_request()) {
944 $userIncludes[] = 'account_history.actor';
945 $userIncludes[] = 'account_history.supporting_url';
946 }
947
948 return $userIncludes;
949 }
950
951 private function fillDeprecatedDuplicateFields(array $userJson): array
952 {
953 static $map = [
954 'rankHistory' => 'rank_history',
955 'ranked_and_approved_beatmapset_count' => 'ranked_beatmapset_count',
956 'unranked_beatmapset_count' => 'pending_beatmapset_count',
957 ];
958
959 foreach ($map as $legacyKey => $key) {
960 if (array_key_exists($key, $userJson)) {
961 $userJson[$legacyKey] = $userJson[$key];
962 }
963 }
964
965 return $userJson;
966 }
967
968 private function storeUser(array $rawParams)
969 {
970 if (!$GLOBALS['cfg']['osu']['user']['allow_registration']) {
971 return abort(403, 'User registration is currently disabled');
972 }
973
974 $ip = Request::ip();
975
976 if (IpBan::where('ip', '=', $ip)->exists()) {
977 return error_popup('Banned IP', 403);
978 }
979
980 $params = get_params($rawParams, 'user', [
981 'password',
982 'user_email',
983 'username',
984 ], ['null_missing' => true]);
985 $countryCode = request_country();
986 $params['user_ip'] = $ip;
987 $params['country_acronym'] = $countryCode;
988 $params['user_lang'] = \App::getLocale();
989
990 $registration = new UserRegistration($params);
991
992 try {
993 $registration->assertValid();
994
995 if (get_bool($rawParams['check'] ?? null)) {
996 return response(null, 204);
997 }
998
999 $throttleKey = 'registration:asn:'.app('ip2asn')->lookup($ip);
1000
1001 if (app(RateLimiter::class)->tooManyAttempts($throttleKey, 10)) {
1002 abort(429);
1003 }
1004
1005 $registration->save();
1006 app(RateLimiter::class)->hit($throttleKey, 600);
1007
1008 $user = $registration->user();
1009
1010 // report unknown country code but ignore non-country from cloudflare
1011 if (
1012 $countryCode !== null
1013 && $countryCode !== 'T1'
1014 && app('countries')->byCode($countryCode) === null
1015 ) {
1016 app('sentry')->getClient()->captureMessage(
1017 'User registered from unknown country',
1018 null,
1019 (new Scope())
1020 ->setTag('country', $countryCode)
1021 ->setExtra('ip', $ip)
1022 ->setExtra('user_id', $user->getKey())
1023 );
1024 }
1025
1026 if (is_json_request()) {
1027 return json_item($user->fresh(), new CurrentUserTransformer());
1028 } else {
1029 $this->login($user);
1030 session()->flash('popup', osu_trans('users.store.saved'));
1031
1032 return ujs_redirect(route('home'));
1033 }
1034 } catch (ValidationException $e) {
1035 return ModelNotSavedException::makeResponse($e, [
1036 'user' => $registration->user(),
1037 ]);
1038 }
1039 }
1040}