the browser-facing portion of osu!
at master 35 kB view raw
1<?php 2 3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4// See the LICENCE file in the repository root for full licence text. 5 6namespace App\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}