diff --git a/.env.example b/.env.example index 388820fa363..af867a69fd4 100644 --- a/.env.example +++ b/.env.example @@ -225,6 +225,8 @@ CLIENT_CHECK_VERSION=false # CHAT_PUBLIC_BACKLOG_LIMIT_HOURS=24 # ALLOW_REGISTRATION=true +# REGISTRATION_MODE_CLIENT=true +# REGISTRATION_MODE_WEB=false # USER_ALLOW_EMAIL_LOGIN=true # USER_BYPASS_VERIFICATION=false @@ -308,6 +310,7 @@ CLIENT_CHECK_VERSION=false # SCORES_EXPERIMENTAL_RANK_AS_DEFAULT=false # SCORES_EXPERIMENTAL_RANK_AS_EXTRA=false # SCORES_PROCESSING_QUEUE=osu-queue:score-statistics +# SCORES_SUBMISSION_ENABLED=1 # SCORES_RANK_CACHE_LOCAL_SERVER=0 # SCORES_RANK_CACHE_MIN_USERS=35000 # SCORES_RANK_CACHE_SERVER_URL= diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 75a6cc79211..b6ef71e039b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -49,7 +49,7 @@ jobs: - name: Install js dependencies run: yarn --frozen-lockfile - - run: 'yarn lint --max-warnings 90 > /dev/null' + - run: 'yarn lint --max-warnings 89 > /dev/null' - run: ./bin/update_licence.sh -nf diff --git a/app/Exceptions/ClientCheckParseTokenException.php b/app/Exceptions/ClientCheckParseTokenException.php new file mode 100644 index 00000000000..f5e6f800572 --- /dev/null +++ b/app/Exceptions/ClientCheckParseTokenException.php @@ -0,0 +1,12 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Exceptions; + +class ClientCheckParseTokenException extends \Exception +{ +} diff --git a/app/Http/Controllers/BeatmapPacksController.php b/app/Http/Controllers/BeatmapPacksController.php index 9337dbd78c5..f8a368a3f3a 100644 --- a/app/Http/Controllers/BeatmapPacksController.php +++ b/app/Http/Controllers/BeatmapPacksController.php @@ -5,10 +5,10 @@ namespace App\Http\Controllers; +use App\Libraries\Search\ScoreSearchParams; use App\Models\Beatmap; use App\Models\BeatmapPack; use App\Transformers\BeatmapPackTransformer; -use Auth; /** * @group Beatmap Packs @@ -100,7 +100,11 @@ public function show($idOrTag) $pack = $query->where('tag', $idOrTag)->firstOrFail(); $mode = Beatmap::modeStr($pack->playmode ?? 0); $sets = $pack->beatmapsets; - $userCompletionData = $pack->userCompletionData(Auth::user()); + $currentUser = \Auth::user(); + $userCompletionData = $pack->userCompletionData( + $currentUser, + ScoreSearchParams::showLegacyForUser($currentUser), + ); if (is_api_request()) { return json_item( diff --git a/app/Http/Controllers/BeatmapsController.php b/app/Http/Controllers/BeatmapsController.php index 97158c45737..2648af007a3 100644 --- a/app/Http/Controllers/BeatmapsController.php +++ b/app/Http/Controllers/BeatmapsController.php @@ -10,6 +10,9 @@ use App\Jobs\Notifications\BeatmapOwnerChange; use App\Libraries\BeatmapDifficultyAttributes; use App\Libraries\Score\BeatmapScores; +use App\Libraries\Score\UserRank; +use App\Libraries\Search\ScoreSearch; +use App\Libraries\Search\ScoreSearchParams; use App\Models\Beatmap; use App\Models\BeatmapsetEvent; use App\Models\Score\Best\Model as BestModel; @@ -51,6 +54,66 @@ private static function baseScoreQuery(Beatmap $beatmap, $mode, $mods, $type = n return $query; } + private static function beatmapScores(string $id, ?string $scoreTransformerType, ?bool $isLegacy): array + { + $beatmap = Beatmap::findOrFail($id); + if ($beatmap->approved <= 0) { + return ['scores' => []]; + } + + $params = get_params(request()->all(), null, [ + 'limit:int', + 'mode', + 'mods:string[]', + 'type:string', + ], ['null_missing' => true]); + + if ($params['mode'] !== null) { + $rulesetId = Beatmap::MODES[$params['mode']] ?? null; + if ($rulesetId === null) { + throw new InvariantException('invalid mode specified'); + } + } + $rulesetId ??= $beatmap->playmode; + $mods = array_values(array_filter($params['mods'] ?? [])); + $type = presence($params['type'], 'global'); + $currentUser = \Auth::user(); + + static::assertSupporterOnlyOptions($currentUser, $type, $mods); + + $esFetch = new BeatmapScores([ + 'beatmap_ids' => [$beatmap->getKey()], + 'is_legacy' => $isLegacy, + 'limit' => $params['limit'], + 'mods' => $mods, + 'ruleset_id' => $rulesetId, + 'type' => $type, + 'user' => $currentUser, + ]); + $scores = $esFetch->all()->loadMissing(['beatmap', 'user.country', 'user.userProfileCustomization']); + $userScore = $esFetch->userBest(); + $scoreTransformer = new ScoreTransformer($scoreTransformerType); + + $results = [ + 'scores' => json_collection( + $scores, + $scoreTransformer, + static::DEFAULT_SCORE_INCLUDES + ), + ]; + + if (isset($userScore)) { + $results['user_score'] = [ + 'position' => $esFetch->rank($userScore), + 'score' => json_item($userScore, $scoreTransformer, static::DEFAULT_SCORE_INCLUDES), + ]; + // TODO: remove this old camelCased json field + $results['userScore'] = $results['user_score']; + } + + return $results; + } + public function __construct() { parent::__construct(); @@ -280,7 +343,7 @@ public function show($id) /** * Get Beatmap scores * - * Returns the top scores for a beatmap + * Returns the top scores for a beatmap. Depending on user preferences, this may only show legacy scores. * * --- * @@ -296,60 +359,18 @@ public function show($id) */ public function scores($id) { - $beatmap = Beatmap::findOrFail($id); - if ($beatmap->approved <= 0) { - return ['scores' => []]; - } - - $params = get_params(request()->all(), null, [ - 'limit:int', - 'mode:string', - 'mods:string[]', - 'type:string', - ], ['null_missing' => true]); - - $mode = presence($params['mode']) ?? $beatmap->mode; - $mods = array_values(array_filter($params['mods'] ?? [])); - $type = presence($params['type']) ?? 'global'; - $currentUser = auth()->user(); - - static::assertSupporterOnlyOptions($currentUser, $type, $mods); - - $query = static::baseScoreQuery($beatmap, $mode, $mods, $type); - - if ($currentUser !== null) { - // own score shouldn't be filtered by visibleUsers() - $userScore = (clone $query)->where('user_id', $currentUser->user_id)->first(); - } - - $scoreTransformer = new ScoreTransformer(); - - $results = [ - 'scores' => json_collection( - $query->visibleUsers()->forListing($params['limit']), - $scoreTransformer, - static::DEFAULT_SCORE_INCLUDES - ), - ]; - - if (isset($userScore)) { - $results['user_score'] = [ - 'position' => $userScore->userRank(compact('type', 'mods')), - 'score' => json_item($userScore, $scoreTransformer, static::DEFAULT_SCORE_INCLUDES), - ]; - // TODO: remove this old camelCased json field - $results['userScore'] = $results['user_score']; - } - - return $results; + return static::beatmapScores( + $id, + null, + // TODO: change to imported name after merge with other PRs + \App\Libraries\Search\ScoreSearchParams::showLegacyForUser(\Auth::user()), + ); } /** - * Get Beatmap scores (temp) + * Get Beatmap scores (non-legacy) * - * Returns the top scores for a beatmap from newer client. - * - * This is a temporary endpoint. + * Returns the top scores for a beatmap. * * --- * @@ -359,68 +380,14 @@ public function scores($id) * * @urlParam beatmap integer required Id of the [Beatmap](#beatmap). * + * @queryParam legacy_only Set to true to only return legacy scores. Example: 0 * @queryParam mode The [Ruleset](#ruleset) to get scores for. * @queryParam mods An array of matching Mods, or none // TODO. * @queryParam type Beatmap score ranking type // TODO. */ public function soloScores($id) { - $beatmap = Beatmap::findOrFail($id); - if ($beatmap->approved <= 0) { - return ['scores' => []]; - } - - $params = get_params(request()->all(), null, [ - 'limit:int', - 'mode', - 'mods:string[]', - 'type:string', - ], ['null_missing' => true]); - - if ($params['mode'] !== null) { - $rulesetId = Beatmap::MODES[$params['mode']] ?? null; - if ($rulesetId === null) { - throw new InvariantException('invalid mode specified'); - } - } - $rulesetId ??= $beatmap->playmode; - $mods = array_values(array_filter($params['mods'] ?? [])); - $type = presence($params['type'], 'global'); - $currentUser = auth()->user(); - - static::assertSupporterOnlyOptions($currentUser, $type, $mods); - - $esFetch = new BeatmapScores([ - 'beatmap_ids' => [$beatmap->getKey()], - 'is_legacy' => false, - 'limit' => $params['limit'], - 'mods' => $mods, - 'ruleset_id' => $rulesetId, - 'type' => $type, - 'user' => $currentUser, - ]); - $scores = $esFetch->all()->loadMissing(['beatmap', 'performance', 'user.country', 'user.userProfileCustomization']); - $userScore = $esFetch->userBest(); - $scoreTransformer = new ScoreTransformer(ScoreTransformer::TYPE_SOLO); - - $results = [ - 'scores' => json_collection( - $scores, - $scoreTransformer, - static::DEFAULT_SCORE_INCLUDES - ), - ]; - - if (isset($userScore)) { - $results['user_score'] = [ - 'position' => $esFetch->rank($userScore), - 'score' => json_item($userScore, $scoreTransformer, static::DEFAULT_SCORE_INCLUDES), - ]; - // TODO: remove this old camelCased json field - $results['userScore'] = $results['user_score']; - } - - return $results; + return static::beatmapScores($id, ScoreTransformer::TYPE_SOLO, null); } public function updateOwner($id) @@ -481,13 +448,25 @@ public function userScore($beatmapId, $userId) $mode = presence($params['mode'] ?? null, $beatmap->mode); $mods = array_values(array_filter($params['mods'] ?? [])); - $score = static::baseScoreQuery($beatmap, $mode, $mods) - ->visibleUsers() - ->where('user_id', $userId) - ->firstOrFail(); + $baseParams = ScoreSearchParams::fromArray([ + 'beatmap_ids' => [$beatmap->getKey()], + 'is_legacy' => ScoreSearchParams::showLegacyForUser(\Auth::user()), + 'limit' => 1, + 'mods' => $mods, + 'ruleset_id' => Beatmap::MODES[$mode], + 'sort' => 'score_desc', + 'user_id' => (int) $userId, + ]); + $score = (new ScoreSearch($baseParams))->records()->first(); + abort_if($score === null, 404); + + $rankParams = clone $baseParams; + $rankParams->beforeScore = $score; + $rankParams->userId = null; + $rank = UserRank::getRank($rankParams); return [ - 'position' => $score->userRank(compact('mods')), + 'position' => $rank, 'score' => json_item( $score, new ScoreTransformer(), @@ -518,12 +497,14 @@ public function userScoreAll($beatmapId, $userId) { $beatmap = Beatmap::scoreable()->findOrFail($beatmapId); $mode = presence(get_string(request('mode'))) ?? $beatmap->mode; - $scores = BestModel::getClass($mode) - ::default() - ->where([ - 'beatmap_id' => $beatmap->getKey(), - 'user_id' => $userId, - ])->get(); + $params = ScoreSearchParams::fromArray([ + 'beatmap_ids' => [$beatmap->getKey()], + 'is_legacy' => ScoreSearchParams::showLegacyForUser(\Auth::user()), + 'ruleset_id' => Beatmap::MODES[$mode], + 'sort' => 'score_desc', + 'user_id' => (int) $userId, + ]); + $scores = (new ScoreSearch($params))->records(); return [ 'scores' => json_collection($scores, new ScoreTransformer()), diff --git a/app/Http/Controllers/Multiplayer/Rooms/Playlist/ScoresController.php b/app/Http/Controllers/Multiplayer/Rooms/Playlist/ScoresController.php index a55dc88afac..cfb8c9d067f 100644 --- a/app/Http/Controllers/Multiplayer/Rooms/Playlist/ScoresController.php +++ b/app/Http/Controllers/Multiplayer/Rooms/Playlist/ScoresController.php @@ -166,10 +166,10 @@ public function store($roomId, $playlistId) $room = Room::findOrFail($roomId); $playlistItem = $room->playlist()->where('id', $playlistId)->firstOrFail(); $user = auth()->user(); - $params = request()->all(); + $request = \Request::instance(); + $params = $request->all(); - $buildId = ClientCheck::findBuild($user, $params)?->getKey() - ?? $GLOBALS['cfg']['osu']['client']['default_build_id']; + $buildId = ClientCheck::parseToken($request)['buildId']; $scoreToken = $room->startPlay($user, $playlistItem, $buildId); @@ -181,6 +181,8 @@ public function store($roomId, $playlistId) */ public function update($roomId, $playlistItemId, $tokenId) { + $request = \Request::instance(); + $clientTokenData = ClientCheck::parseToken($request); $scoreLink = \DB::transaction(function () use ($roomId, $playlistItemId, $tokenId) { $room = Room::findOrFail($roomId); @@ -202,15 +204,14 @@ public function update($roomId, $playlistItemId, $tokenId) }); $score = $scoreLink->score; - $transformer = ScoreTransformer::newSolo(); if ($score->wasRecentlyCreated) { - $scoreJson = json_item($score, $transformer); - $score::queueForProcessing($scoreJson); + ClientCheck::queueToken($clientTokenData, $score->getKey()); + $score->queueForProcessing(); } return json_item( $scoreLink, - $transformer, + ScoreTransformer::newSolo(), [ ...ScoreTransformer::MULTIPLAYER_BASE_INCLUDES, 'position', diff --git a/app/Http/Controllers/ScoreTokensController.php b/app/Http/Controllers/ScoreTokensController.php index b746f9f113b..44ca3eae66c 100644 --- a/app/Http/Controllers/ScoreTokensController.php +++ b/app/Http/Controllers/ScoreTokensController.php @@ -22,10 +22,14 @@ public function __construct() public function store($beatmapId) { + if (!$GLOBALS['cfg']['osu']['scores']['submission_enabled']) { + abort(422, 'score submission is disabled'); + } + $beatmap = Beatmap::increasesStatistics()->findOrFail($beatmapId); $user = auth()->user(); - $rawParams = request()->all(); - $params = get_params($rawParams, null, [ + $request = \Request::instance(); + $params = get_params($request->all(), null, [ 'beatmap_hash', 'ruleset_id:int', ]); @@ -43,12 +47,12 @@ public function store($beatmapId) } } - $build = ClientCheck::findBuild($user, $rawParams); + $buildId = ClientCheck::parseToken($request)['buildId']; try { $scoreToken = ScoreToken::create([ 'beatmap_id' => $beatmap->getKey(), - 'build_id' => $build?->getKey() ?? $GLOBALS['cfg']['osu']['client']['default_build_id'], + 'build_id' => $buildId, 'ruleset_id' => $params['ruleset_id'], 'user_id' => $user->getKey(), ]); diff --git a/app/Http/Controllers/Solo/ScoresController.php b/app/Http/Controllers/Solo/ScoresController.php index 6800c80051d..71fff6292e1 100644 --- a/app/Http/Controllers/Solo/ScoresController.php +++ b/app/Http/Controllers/Solo/ScoresController.php @@ -6,6 +6,7 @@ namespace App\Http\Controllers\Solo; use App\Http\Controllers\Controller as BaseController; +use App\Libraries\ClientCheck; use App\Models\ScoreToken; use App\Models\Solo\Score; use App\Transformers\ScoreTransformer; @@ -20,7 +21,9 @@ public function __construct() public function store($beatmapId, $tokenId) { - $score = DB::transaction(function () use ($beatmapId, $tokenId) { + $request = \Request::instance(); + $clientTokenData = ClientCheck::parseToken($request); + $score = DB::transaction(function () use ($beatmapId, $request, $tokenId) { $user = auth()->user(); $scoreToken = ScoreToken::where([ 'beatmap_id' => $beatmapId, @@ -29,9 +32,8 @@ public function store($beatmapId, $tokenId) // return existing score otherwise (assuming duplicated submission) if ($scoreToken->score_id === null) { - $params = Score::extractParams(\Request::all(), $scoreToken); + $params = Score::extractParams($request->all(), $scoreToken); $score = Score::createFromJsonOrExplode($params); - $score->createLegacyEntryOrExplode(); $scoreToken->fill(['score_id' => $score->getKey()])->saveOrExplode(); } else { // assume score exists and is valid @@ -41,11 +43,11 @@ public function store($beatmapId, $tokenId) return $score; }); - $scoreJson = json_item($score, new ScoreTransformer(ScoreTransformer::TYPE_SOLO)); if ($score->wasRecentlyCreated) { - $score::queueForProcessing($scoreJson); + ClientCheck::queueToken($clientTokenData, $score->getKey()); + $score->queueForProcessing(); } - return $scoreJson; + return json_item($score, new ScoreTransformer(ScoreTransformer::TYPE_SOLO)); } } diff --git a/app/Http/Controllers/UsersController.php b/app/Http/Controllers/UsersController.php index b92a8bd65e1..6583cda0b3a 100644 --- a/app/Http/Controllers/UsersController.php +++ b/app/Http/Controllers/UsersController.php @@ -9,9 +9,11 @@ use App\Exceptions\UserProfilePageLookupException; use App\Exceptions\ValidationException; use App\Http\Middleware\RequestCost; +use App\Libraries\ClientCheck; use App\Libraries\RateLimiter; use App\Libraries\Search\ForumSearch; use App\Libraries\Search\ForumSearchRequestParams; +use App\Libraries\Search\ScoreSearchParams; use App\Libraries\User\FindForProfilePage; use App\Libraries\UserRegistration; use App\Models\Beatmap; @@ -19,6 +21,7 @@ use App\Models\Country; use App\Models\IpBan; use App\Models\Log; +use App\Models\Solo\Score as SoloScore; use App\Models\User; use App\Models\UserAccountHistory; use App\Models\UserNotFound; @@ -33,6 +36,7 @@ use NoCaptcha; use Request; use Sentry\State\Scope; +use Symfony\Component\HttpKernel\Exception\HttpException; /** * @group Users @@ -103,6 +107,14 @@ public function __construct() parent::__construct(); } + private static function storeClientDisabledError() + { + return response([ + 'error' => osu_trans('users.store.from_web'), + 'url' => route('users.create'), + ], 403); + } + public function card($id) { try { @@ -116,7 +128,7 @@ public function card($id) public function create() { - if ($GLOBALS['cfg']['osu']['user']['registration_mode'] !== 'web') { + if (!$GLOBALS['cfg']['osu']['user']['registration_mode']['web']) { return abort(403, osu_trans('users.store.from_client')); } @@ -176,7 +188,7 @@ public function extraPages($_id, $page) 'monthly_playcounts' => json_collection($this->user->monthlyPlaycounts, new UserMonthlyPlaycountTransformer()), 'recent' => $this->getExtraSection( 'scoresRecent', - $this->user->scores($this->mode, true)->includeFails(false)->count() + $this->user->soloScores()->recent($this->mode, false)->count(), ), 'replays_watched_counts' => json_collection($this->user->replaysWatchedCounts, new UserReplaysWatchedCountTransformer()), ]; @@ -191,7 +203,7 @@ public function extraPages($_id, $page) return [ 'best' => $this->getExtraSection( 'scoresBest', - count($this->user->beatmapBestScoreIds($this->mode)) + count($this->user->beatmapBestScoreIds($this->mode, ScoreSearchParams::showLegacyForUser(\Auth::user()))) ), 'firsts' => $this->getExtraSection( 'scoresFirsts', @@ -210,23 +222,28 @@ public function extraPages($_id, $page) public function store() { - if ($GLOBALS['cfg']['osu']['user']['registration_mode'] !== 'client') { - return response([ - 'error' => osu_trans('users.store.from_web'), - 'url' => route('users.create'), - ], 403); + if (!$GLOBALS['cfg']['osu']['user']['registration_mode']['client']) { + return static::storeClientDisabledError(); } - if (!starts_with(Request::header('User-Agent'), $GLOBALS['cfg']['osu']['client']['user_agent'])) { + $request = \Request::instance(); + + if (!starts_with($request->header('User-Agent'), $GLOBALS['cfg']['osu']['client']['user_agent'])) { return error_popup(osu_trans('users.store.from_client'), 403); } - return $this->storeUser(request()->all()); + try { + ClientCheck::parseToken($request); + } catch (HttpException $e) { + return static::storeClientDisabledError(); + } + + return $this->storeUser($request->all()); } public function storeWeb() { - if ($GLOBALS['cfg']['osu']['user']['registration_mode'] !== 'web') { + if (!$GLOBALS['cfg']['osu']['user']['registration_mode']['web']) { return error_popup(osu_trans('users.store.from_client'), 403); } @@ -540,6 +557,7 @@ public function scores($_userId, $type) * * See [Get User](#get-user). * + * `session_verified` attribute is included. * Additionally, `statistics_rulesets` is included, containing statistics for all rulesets. * * @urlParam mode string [Ruleset](#ruleset). User default mode will be used if not specified. Example: osu @@ -548,7 +566,7 @@ public function scores($_userId, $type) */ public function me($mode = null) { - $user = auth()->user(); + $user = \Auth::user(); $currentMode = $mode ?? $user->playmode; if (!Beatmap::isModeValid($currentMode)) { @@ -561,6 +579,7 @@ public function me($mode = null) $user, (new UserTransformer())->setMode($currentMode), [ + 'session_verified', ...$this->showUserIncludes(), ...array_map( fn (string $ruleset) => "statistics_rulesets.{$ruleset}", @@ -787,15 +806,25 @@ private function getExtra($page, array $options, int $perPage = 10, int $offset case 'scoresBest': $transformer = new ScoreTransformer(); $includes = [...ScoreTransformer::USER_PROFILE_INCLUDES, 'weight']; - $collection = $this->user->beatmapBestScores($this->mode, $perPage, $offset, ScoreTransformer::USER_PROFILE_INCLUDES_PRELOAD); + $collection = $this->user->beatmapBestScores( + $this->mode, + $perPage, + $offset, + ScoreTransformer::USER_PROFILE_INCLUDES_PRELOAD, + ScoreSearchParams::showLegacyForUser(\Auth::user()), + ); $userRelationColumn = 'user'; break; case 'scoresFirsts': $transformer = new ScoreTransformer(); $includes = ScoreTransformer::USER_PROFILE_INCLUDES; - $query = $this->user->scoresFirst($this->mode, true) - ->visibleUsers() - ->reorderBy('score_id', 'desc') + $scoreQuery = $this->user->scoresFirst($this->mode, true)->unorder(); + $userFirstsQuery = $scoreQuery->select($scoreQuery->qualifyColumn('score_id')); + $query = SoloScore + ::whereIn('legacy_score_id', $userFirstsQuery) + ->where('ruleset_id', Beatmap::MODES[$this->mode]) + ->default() + ->reorderBy('id', 'desc') ->with(ScoreTransformer::USER_PROFILE_INCLUDES_PRELOAD); $userRelationColumn = 'user'; break; @@ -814,9 +843,10 @@ private function getExtra($page, array $options, int $perPage = 10, int $offset case 'scoresRecent': $transformer = new ScoreTransformer(); $includes = ScoreTransformer::USER_PROFILE_INCLUDES; - $query = $this->user->scores($this->mode, true) - ->includeFails($options['includeFails'] ?? false) - ->with([...ScoreTransformer::USER_PROFILE_INCLUDES_PRELOAD, 'best']); + $query = $this->user->soloScores() + ->recent($this->mode, $options['includeFails'] ?? false) + ->reorderBy('unix_updated_at', 'desc') + ->with(ScoreTransformer::USER_PROFILE_INCLUDES_PRELOAD); $userRelationColumn = 'user'; break; } @@ -982,13 +1012,13 @@ private function storeUser(array $rawParams) ); } - if ($GLOBALS['cfg']['osu']['user']['registration_mode'] === 'web') { + if (is_json_request()) { + return json_item($user->fresh(), new CurrentUserTransformer()); + } else { $this->login($user); session()->flash('popup', osu_trans('users.store.saved')); return ujs_redirect(route('home')); - } else { - return json_item($user->fresh(), new CurrentUserTransformer()); } } catch (ValidationException $e) { return ModelNotSavedException::makeResponse($e, [ diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index c6062330a92..12cb86edc9a 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -24,6 +24,7 @@ class Kernel extends HttpKernel Middleware\SetLocaleApi::class, Middleware\CheckUserBanStatus::class, Middleware\UpdateUserLastvisit::class, + Middleware\VerifyUserAlways::class, ], 'web' => [ Middleware\StripCookies::class, diff --git a/app/Http/Middleware/AuthApi.php b/app/Http/Middleware/AuthApi.php index 592c1ef5a17..d7f6a50ecf8 100644 --- a/app/Http/Middleware/AuthApi.php +++ b/app/Http/Middleware/AuthApi.php @@ -5,6 +5,7 @@ namespace App\Http\Middleware; +use App\Libraries\SessionVerification; use Closure; use Illuminate\Auth\AuthenticationException; use Laravel\Passport\ClientRepository; @@ -95,10 +96,14 @@ private function validTokenFromRequest($psr) } if ($user !== null) { - auth()->setUser($user); + \Auth::setUser($user); $user->withAccessToken($token); - // this should match osu-notification-server OAuthVerifier - $user->markSessionVerified(); + + if ($token->isVerified()) { + $user->markSessionVerified(); + } else { + SessionVerification\Helper::issue($token, $user, true); + } } return $token; diff --git a/app/Http/Middleware/UpdateUserLastvisit.php b/app/Http/Middleware/UpdateUserLastvisit.php index 2ba7144c29b..519f4fc33d5 100644 --- a/app/Http/Middleware/UpdateUserLastvisit.php +++ b/app/Http/Middleware/UpdateUserLastvisit.php @@ -5,6 +5,7 @@ namespace App\Http\Middleware; +use App\Libraries\SessionVerification; use App\Models\Country; use Carbon\Carbon; use Closure; @@ -30,7 +31,7 @@ public function handle($request, Closure $next) if ($shouldUpdate) { $isInactive = $user->isInactive(); if ($isInactive) { - $isVerified = $user->isSessionVerified(); + $isVerified = SessionVerification\Helper::currentSession()->isVerified(); } if (!$isInactive || $isVerified) { diff --git a/app/Http/Middleware/VerifyUser.php b/app/Http/Middleware/VerifyUser.php index f50855bf4da..88c01549b4e 100644 --- a/app/Http/Middleware/VerifyUser.php +++ b/app/Http/Middleware/VerifyUser.php @@ -20,6 +20,7 @@ class VerifyUser 'notifications_controller@endpoint' => true, 'sessions_controller@destroy' => true, 'sessions_controller@store' => true, + 'users_controller@me' => true, 'wiki_controller@image' => true, 'wiki_controller@show' => true, 'wiki_controller@sitemap' => true, diff --git a/app/Jobs/RemoveBeatmapsetSoloScores.php b/app/Jobs/RemoveBeatmapsetSoloScores.php index b70d45947e4..cfe16299e1a 100644 --- a/app/Jobs/RemoveBeatmapsetSoloScores.php +++ b/app/Jobs/RemoveBeatmapsetSoloScores.php @@ -11,7 +11,6 @@ use App\Models\Beatmap; use App\Models\Beatmapset; use App\Models\Solo\Score; -use App\Models\Solo\ScorePerformance; use DB; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -68,7 +67,6 @@ private function deleteScores(Collection $scores): void $scoresQuery->update(['preserve' => false]); $this->scoreSearch->queueForIndex($this->schemas, $ids); DB::transaction(function () use ($ids, $scoresQuery): void { - ScorePerformance::whereKey($ids)->delete(); $scoresQuery->delete(); }); } diff --git a/app/Libraries/ClientCheck.php b/app/Libraries/ClientCheck.php index f195067fd4c..371552ebed4 100644 --- a/app/Libraries/ClientCheck.php +++ b/app/Libraries/ClientCheck.php @@ -3,39 +3,106 @@ // Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. // See the LICENCE file in the repository root for full licence text. +declare(strict_types=1); + namespace App\Libraries; +use App\Exceptions\ClientCheckParseTokenException; use App\Models\Build; +use Illuminate\Http\Request; class ClientCheck { - public static function findBuild($user, $params): ?Build + public static function parseToken(Request $request): array { - $assertValid = $GLOBALS['cfg']['osu']['client']['check_version'] && $user->findUserGroup(app('groups')->byIdentifier('admin'), true) === null; - - $clientHash = presence(get_string($params['version_hash'] ?? null)); - if ($clientHash === null) { - if ($assertValid) { - abort(422, 'missing client version'); - } else { - return null; + $token = $request->header('x-token'); + $assertValid = $GLOBALS['cfg']['osu']['client']['check_version']; + $ret = [ + 'buildId' => $GLOBALS['cfg']['osu']['client']['default_build_id'], + 'token' => null, + ]; + + try { + if ($token === null) { + throw new ClientCheckParseTokenException('missing token header'); + } + + $input = static::splitToken($token); + + $build = Build::firstWhere([ + 'hash' => $input['clientHash'], + 'allow_ranking' => true, + ]); + + if ($build === null) { + throw new ClientCheckParseTokenException('invalid client hash'); + } + + $ret['buildId'] = $build->getKey(); + + $computed = hash_hmac( + 'sha1', + $input['clientData'], + static::getKey($build), + true, + ); + + if (!hash_equals($computed, $input['expected'])) { + throw new ClientCheckParseTokenException('invalid verification hash'); } - } - // temporary measure to allow android builds to submit without access to the underlying dll to hash - if (strlen($clientHash) !== 32) { - $clientHash = md5($clientHash); + $now = time(); + static $maxTime = 15 * 60; + if (abs($now - $input['clientTime']) > $maxTime) { + throw new ClientCheckParseTokenException('expired token'); + } + + $ret['token'] = $token; + // to be included in queue + $ret['body'] = base64_encode($request->getContent()); + $ret['url'] = $request->getRequestUri(); + } catch (ClientCheckParseTokenException $e) { + abort_if($assertValid, 422, $e->getMessage()); } - $build = Build::firstWhere([ - 'hash' => hex2bin($clientHash), - 'allow_ranking' => true, - ]); + return $ret; + } - if ($build === null && $assertValid) { - abort(422, 'invalid client hash'); + public static function queueToken(?array $tokenData, int $scoreId): void + { + if ($tokenData['token'] === null) { + return; } - return $build; + \LaravelRedis::lpush($GLOBALS['cfg']['osu']['client']['token_queue'], json_encode([ + 'body' => $tokenData['body'], + 'id' => $scoreId, + 'token' => $tokenData['token'], + 'url' => $tokenData['url'], + ])); + } + + private static function getKey(Build $build): string + { + return $GLOBALS['cfg']['osu']['client']['token_keys'][$build->platform()] + ?? $GLOBALS['cfg']['osu']['client']['token_keys']['default'] + ?? ''; + } + + private static function splitToken(string $token): array + { + $data = substr($token, -82); + $clientTimeHex = substr($data, 32, 8); + $clientTime = strlen($clientTimeHex) === 8 + ? unpack('V', hex2bin($clientTimeHex))[1] + : 0; + + return [ + 'clientData' => substr($data, 0, 40), + 'clientHash' => hex2bin(substr($data, 0, 32)), + 'clientTime' => $clientTime, + 'expected' => hex2bin(substr($data, 40, 40)), + 'version' => substr($data, 80, 2), + ]; } } diff --git a/app/Libraries/Elasticsearch/Search.php b/app/Libraries/Elasticsearch/Search.php index 2f507589456..3e36086e175 100644 --- a/app/Libraries/Elasticsearch/Search.php +++ b/app/Libraries/Elasticsearch/Search.php @@ -22,10 +22,8 @@ abstract class Search extends HasSearch implements Queryable /** * A tag to use when logging timing of fetches. * FIXME: context-based tagging would be nicer. - * - * @var string|null */ - public $loggingTag; + public ?string $loggingTag; protected $aggregations; protected $index; diff --git a/app/Libraries/OAuth/EncodeToken.php b/app/Libraries/OAuth/EncodeToken.php new file mode 100644 index 00000000000..700655b6b58 --- /dev/null +++ b/app/Libraries/OAuth/EncodeToken.php @@ -0,0 +1,45 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Libraries\OAuth; + +use App\Models\OAuth\Token; +use Defuse\Crypto\Crypto; +use Firebase\JWT\JWT; +use Laravel\Passport\Passport; +use Laravel\Passport\RefreshToken; + +class EncodeToken +{ + public static function encodeAccessToken(Token $token): string + { + $privateKey = $GLOBALS['cfg']['passport']['private_key'] + ?? file_get_contents(Passport::keyPath('oauth-private.key')); + + return JWT::encode([ + 'aud' => $token->client_id, + 'exp' => $token->expires_at->timestamp, + 'iat' => $token->created_at->timestamp, // issued at + 'jti' => $token->getKey(), + 'nbf' => $token->created_at->timestamp, // valid after + 'sub' => $token->user_id, + 'scopes' => $token->scopes, + ], $privateKey, 'RS256'); + } + + public static function encodeRefreshToken(RefreshToken $refreshToken, Token $accessToken): string + { + return Crypto::encryptWithPassword(json_encode([ + 'client_id' => (string) $accessToken->client_id, + 'refresh_token_id' => $refreshToken->getKey(), + 'access_token_id' => $accessToken->getKey(), + 'scopes' => $accessToken->scopes, + 'user_id' => $accessToken->user_id, + 'expire_time' => $refreshToken->expires_at->timestamp, + ]), \Crypt::getKey()); + } +} diff --git a/app/Libraries/OAuth/RefreshTokenGrant.php b/app/Libraries/OAuth/RefreshTokenGrant.php new file mode 100644 index 00000000000..690aca36490 --- /dev/null +++ b/app/Libraries/OAuth/RefreshTokenGrant.php @@ -0,0 +1,40 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Libraries\OAuth; + +use App\Models\OAuth\Token; +use League\OAuth2\Server\Grant\RefreshTokenGrant as BaseRefreshTokenGrant; +use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface; +use Psr\Http\Message\ServerRequestInterface; + +class RefreshTokenGrant extends BaseRefreshTokenGrant +{ + private ?array $oldRefreshToken = null; + + public function respondToAccessTokenRequest( + ServerRequestInterface $request, + ResponseTypeInterface $responseType, + \DateInterval $accessTokenTTL + ) { + $refreshTokenData = parent::respondToAccessTokenRequest($request, $responseType, $accessTokenTTL); + + // Copy previous verification state + $accessToken = (new \ReflectionProperty($refreshTokenData, 'accessToken'))->getValue($refreshTokenData); + Token::where('id', $accessToken->getIdentifier())->update([ + 'verified' => Token::select('verified')->find($this->oldRefreshToken['access_token_id'])->verified, + ]); + $this->oldRefreshToken = null; + + return $refreshTokenData; + } + + protected function validateOldRefreshToken(ServerRequestInterface $request, $clientId) + { + return $this->oldRefreshToken = parent::validateOldRefreshToken($request, $clientId); + } +} diff --git a/app/Libraries/Score/FetchDedupedScores.php b/app/Libraries/Score/FetchDedupedScores.php index 4e9f1ce2813..198d6d821af 100644 --- a/app/Libraries/Score/FetchDedupedScores.php +++ b/app/Libraries/Score/FetchDedupedScores.php @@ -15,8 +15,11 @@ class FetchDedupedScores private int $limit; private array $result; - public function __construct(private string $dedupeColumn, private ScoreSearchParams $params) - { + public function __construct( + private string $dedupeColumn, + private ScoreSearchParams $params, + private ?string $searchLoggingTag = null + ) { $this->limit = $this->params->size; } @@ -24,6 +27,7 @@ public function all(): array { $this->params->size = $this->limit + 50; $search = new ScoreSearch($this->params); + $search->loggingTag = $this->searchLoggingTag; $nextCursor = null; $hasNext = true; diff --git a/app/Libraries/Search/BeatmapsetSearch.php b/app/Libraries/Search/BeatmapsetSearch.php index ec69b4ec3c2..7cd61548832 100644 --- a/app/Libraries/Search/BeatmapsetSearch.php +++ b/app/Libraries/Search/BeatmapsetSearch.php @@ -13,8 +13,9 @@ use App\Models\Beatmap; use App\Models\Beatmapset; use App\Models\Follow; -use App\Models\Score; +use App\Models\Solo; use App\Models\User; +use Ds\Set; class BeatmapsetSearch extends RecordSearch { @@ -423,38 +424,36 @@ private function addTextFilter(BoolQuery $query, string $paramField, array $fiel private function getPlayedBeatmapIds(?array $rank = null) { - $unionQuery = null; + $query = Solo\Score + ::where('user_id', $this->params->user->getKey()) + ->whereIn('ruleset_id', $this->getSelectedModes()); - $select = $rank === null ? 'beatmap_id' : ['beatmap_id', 'score', 'rank']; + if ($rank === null) { + return $query->distinct('beatmap_id')->pluck('beatmap_id'); + } - foreach ($this->getSelectedModes() as $mode) { - $newQuery = Score\Best\Model::getClassByRulesetId($mode) - ::forUser($this->params->user) - ->select($select); + $topScores = []; + $scoreField = ScoreSearchParams::showLegacyForUser($this->params->user) + ? 'legacy_total_score' + : 'total_score'; + foreach ($query->get() as $score) { + $prevScore = $topScores[$score->beatmap_id] ?? null; - if ($unionQuery === null) { - $unionQuery = $newQuery; - } else { - $unionQuery->union($newQuery); + $scoreValue = $score->$scoreField; + if ($scoreValue !== null && ($prevScore === null || $prevScore->$scoreField < $scoreValue)) { + $topScores[$score->beatmap_id] = $score; } } - if ($rank === null) { - return model_pluck($unionQuery, 'beatmap_id'); - } else { - $allScores = $unionQuery->get(); - $beatmapRank = collect(); - - foreach ($allScores as $score) { - $prevScore = $beatmapRank[$score->beatmap_id] ?? null; - - if ($prevScore === null || $prevScore->score < $score->score) { - $beatmapRank[$score->beatmap_id] = $score; - } + $ret = []; + $rankSet = new Set($rank); + foreach ($topScores as $beatmapId => $score) { + if ($rankSet->contains($score->rank)) { + $ret[] = $beatmapId; } - - return $beatmapRank->whereInStrict('rank', $rank)->pluck('beatmap_id')->all(); } + + return $ret; } private function getSelectedModes() diff --git a/app/Libraries/Search/ScoreSearch.php b/app/Libraries/Search/ScoreSearch.php index c7125d1ce28..332f95d2263 100644 --- a/app/Libraries/Search/ScoreSearch.php +++ b/app/Libraries/Search/ScoreSearch.php @@ -48,6 +48,9 @@ public function getQuery(): BoolQuery if ($this->params->userId !== null) { $query->filter(['term' => ['user_id' => $this->params->userId]]); } + if ($this->params->excludeConverts) { + $query->filter(['term' => ['convert' => false]]); + } if ($this->params->excludeMods !== null && count($this->params->excludeMods) > 0) { foreach ($this->params->excludeMods as $excludedMod) { $query->mustNot(['term' => ['mods' => $excludedMod]]); @@ -67,19 +70,20 @@ public function getQuery(): BoolQuery $beforeTotalScore = $this->params->beforeTotalScore; if ($beforeTotalScore === null && $this->params->beforeScore !== null) { - $beforeTotalScore = $this->params->beforeScore->isLegacy() - ? $this->params->beforeScore->data->legacyTotalScore - : $this->params->beforeScore->data->totalScore; + $beforeTotalScore = $this->params->isLegacy + ? $this->params->beforeScore->legacy_total_score + : $this->params->beforeScore->total_score; } if ($beforeTotalScore !== null) { $scoreQuery = (new BoolQuery())->shouldMatch(1); + $scoreField = $this->params->isLegacy ? 'legacy_total_score' : 'total_score'; $scoreQuery->should((new BoolQuery())->filter(['range' => [ - 'total_score' => ['gt' => $beforeTotalScore], + $scoreField => ['gt' => $beforeTotalScore], ]])); if ($this->params->beforeScore !== null) { $scoreQuery->should((new BoolQuery()) ->filter(['range' => ['id' => ['lt' => $this->params->beforeScore->getKey()]]]) - ->filter(['term' => ['total_score' => $beforeTotalScore]])); + ->filter(['term' => [$scoreField => $beforeTotalScore]])); } $query->must($scoreQuery); @@ -142,7 +146,8 @@ private function addModsFilter(BoolQuery $query): void $allMods = $this->params->rulesetId === null ? $modsHelper->allIds : new Set(array_keys($modsHelper->mods[$this->params->rulesetId])); - $allMods->remove('PF', 'SD', 'MR'); + // CL is currently considered a "preference" mod + $allMods->remove('CL', 'PF', 'SD', 'MR'); $allSearchMods = []; foreach ($mods as $mod) { diff --git a/app/Libraries/Search/ScoreSearchParams.php b/app/Libraries/Search/ScoreSearchParams.php index 13496e13df6..cfd94a57ac5 100644 --- a/app/Libraries/Search/ScoreSearchParams.php +++ b/app/Libraries/Search/ScoreSearchParams.php @@ -21,6 +21,7 @@ class ScoreSearchParams extends SearchParams public ?array $beatmapIds = null; public ?Score $beforeScore = null; public ?int $beforeTotalScore = null; + public bool $excludeConverts = false; public ?array $excludeMods = null; public ?bool $isLegacy = null; public ?array $mods = null; @@ -36,6 +37,7 @@ public static function fromArray(array $rawParams): static { $params = new static(); $params->beatmapIds = $rawParams['beatmap_ids'] ?? null; + $params->excludeConverts = $rawParams['exclude_converts'] ?? $params->excludeConverts; $params->excludeMods = $rawParams['exclude_mods'] ?? null; $params->isLegacy = $rawParams['is_legacy'] ?? null; $params->mods = $rawParams['mods'] ?? null; @@ -55,10 +57,33 @@ public static function fromArray(array $rawParams): static } /** - * This returns value for isLegacy based on user preference + * This returns value for isLegacy based on user preference, request type, and `legacy_only` parameter */ - public static function showLegacyForUser(?User $user): null | true - { + public static function showLegacyForUser( + ?User $user = null, + ?bool $legacyOnly = null, + ?bool $isApiRequest = null + ): null | true { + $isApiRequest ??= is_api_request(); + // `null` is actual parameter value for the other two parameters so + // only try filling them up if not passed at all. + $argLen = func_num_args(); + if ($argLen < 2) { + $legacyOnly = get_bool(Request('legacy_only')); + + if ($argLen < 1) { + $user = \Auth::user(); + } + } + + if ($legacyOnly !== null) { + return $legacyOnly ? true : null; + } + + if ($isApiRequest) { + return null; + } + return $user?->userProfileCustomization?->legacy_score_only ?? UserProfileCustomization::DEFAULT_LEGACY_ONLY_ATTRIBUTE ? true : null; @@ -93,9 +118,15 @@ public function setSort(?string $sort): void { switch ($sort) { case 'score_desc': + $sortColumn = $this->isLegacy ? 'legacy_total_score' : 'total_score'; + $this->sorts = [ + new Sort($sortColumn, 'desc'), + new Sort('id', 'asc'), + ]; + break; + case 'pp_desc': $this->sorts = [ - new Sort('is_legacy', 'asc'), - new Sort('total_score', 'desc'), + new Sort('pp', 'desc'), new Sort('id', 'asc'), ]; break; diff --git a/app/Libraries/SessionVerification/Controller.php b/app/Libraries/SessionVerification/Controller.php index 0333f65b192..55160944991 100644 --- a/app/Libraries/SessionVerification/Controller.php +++ b/app/Libraries/SessionVerification/Controller.php @@ -21,11 +21,11 @@ public static function initiate() $user = Helper::currentUserOrFail(); $email = $user->user_email; - $session = \Session::instance(); - if (State::fromSession($session) === null) { - Helper::logAttempt('input', 'new'); + $session = Helper::currentSession(); + Helper::issue($session, $user, true); - Helper::issue($session, $user); + if (is_api_request()) { + return response(null, $statusCode); } if (\Request::ajax()) { @@ -43,9 +43,9 @@ public static function initiate() public static function reissue() { - $session = \Session::instance(); + $session = Helper::currentSession(); if ($session->isVerified()) { - return response(null, 204); + return response(null, 422); } Helper::issue($session, Helper::currentUserOrFail()); @@ -55,9 +55,13 @@ public static function reissue() public static function verify() { + $session = Helper::currentSession(); + if ($session->isVerified()) { + return response(null, 204); + } + $key = strtr(get_string(\Request::input('verification_key')) ?? '', [' ' => '']); $user = Helper::currentUserOrFail(); - $session = \Session::instance(); $state = State::fromSession($session); try { diff --git a/app/Libraries/SessionVerification/Helper.php b/app/Libraries/SessionVerification/Helper.php index aa44cf8c374..cb9a8c53e6b 100644 --- a/app/Libraries/SessionVerification/Helper.php +++ b/app/Libraries/SessionVerification/Helper.php @@ -15,6 +15,11 @@ class Helper { + public static function currentSession(): ?SessionVerificationInterface + { + return is_api_request() ? oauth_token() : \Session::instance(); + } + public static function currentUserOrFail(): User { $user = \Auth::user(); @@ -23,8 +28,16 @@ public static function currentUserOrFail(): User return $user; } - public static function issue(SessionVerificationInterface $session, User $user): void + public static function issue(SessionVerificationInterface $session, User $user, bool $initial = false): void { + if ($initial) { + if (State::fromSession($session) === null) { + static::logAttempt('input', 'new'); + } else { + return; + } + } + if (!is_valid_email_format($user->user_email)) { return; } diff --git a/app/Models/BeatmapPack.php b/app/Models/BeatmapPack.php index 6bcbfe13507..84de26931fa 100644 --- a/app/Models/BeatmapPack.php +++ b/app/Models/BeatmapPack.php @@ -5,8 +5,10 @@ namespace App\Models; +use App\Libraries\Search\ScoreSearch; +use App\Libraries\Search\ScoreSearchParams; use App\Models\Traits\WithDbCursorHelper; -use Exception; +use Ds\Set; /** * @property string $author @@ -92,69 +94,59 @@ public function getRouteKeyName(): string return 'tag'; } - public function userCompletionData($user) + public function userCompletionData($user, ?bool $isLegacy) { if ($user !== null) { $userId = $user->getKey(); - $beatmapsetIds = $this->items()->pluck('beatmapset_id')->all(); - $query = Beatmap::select('beatmapset_id')->distinct()->whereIn('beatmapset_id', $beatmapsetIds); - - if ($this->playmode === null) { - static $scoreRelations; - - // generate list of beatmap->score relation names for each modes - // store int mode as well as it'll be used for filtering the scores - if (!isset($scoreRelations)) { - $scoreRelations = []; - foreach (Beatmap::MODES as $modeStr => $modeInt) { - $scoreRelations[] = [ - 'playmode' => $modeInt, - 'relation' => camel_case("scores_best_{$modeStr}"), - ]; - } - } - - // outer where function - // The idea is SELECT ... WHERE ... AND ( OR OR ...). - $query->where(function ($q) use ($scoreRelations, $userId) { - foreach ($scoreRelations as $scoreRelation) { - // The scores> mentioned above is generated here. - // As it's "playmode = AND EXISTS (< score for user>)", - // wrap them so it's not flat "playmode = AND EXISTS ... OR playmode = AND EXISTS ...". - $q->orWhere(function ($qq) use ($scoreRelation, $userId) { - $qq - // this playmode filter ensures the scores are limited to non-convert maps - ->where('playmode', '=', $scoreRelation['playmode']) - ->whereHas($scoreRelation['relation'], function ($scoreQuery) use ($userId) { - $scoreQuery->where('user_id', '=', $userId); - - if ($this->no_diff_reduction) { - $scoreQuery->withoutMods(app('mods')->difficultyReductionIds->toArray()); - } - }); - }); - } - }); - } else { - $modeStr = Beatmap::modeStr($this->playmode); - - if ($modeStr === null) { - throw new Exception("beatmapset pack {$this->getKey()} has invalid playmode: {$this->playmode}"); - } - - $scoreRelation = camel_case("scores_best_{$modeStr}"); - - $query->whereHas($scoreRelation, function ($query) use ($userId) { - $query->where('user_id', '=', $userId); - - if ($this->no_diff_reduction) { - $query->withoutMods(app('mods')->difficultyReductionIds->toArray()); - } - }); + + $beatmaps = Beatmap + ::whereIn('beatmapset_id', $this->items()->select('beatmapset_id')) + ->select(['beatmap_id', 'beatmapset_id', 'playmode']) + ->get(); + $beatmapsetIdsByBeatmapId = []; + foreach ($beatmaps as $beatmap) { + $beatmapsetIdsByBeatmapId[$beatmap->beatmap_id] = $beatmap->beatmapset_id; + } + $params = [ + 'beatmap_ids' => array_keys($beatmapsetIdsByBeatmapId), + 'exclude_converts' => $this->playmode === null, + 'is_legacy' => $isLegacy, + 'limit' => 0, + 'ruleset_id' => $this->playmode, + 'user_id' => $userId, + ]; + if ($this->no_diff_reduction) { + $params['exclude_mods'] = app('mods')->difficultyReductionIds->toArray(); } - $completedBeatmapsetIds = $query->pluck('beatmapset_id')->all(); - $completed = count($completedBeatmapsetIds) === count($beatmapsetIds); + static $aggName = 'by_beatmap'; + + $search = new ScoreSearch(ScoreSearchParams::fromArray($params)); + $search->size(0); + $search->setAggregations([$aggName => [ + 'terms' => [ + 'field' => 'beatmap_id', + 'size' => max(1, count($params['beatmap_ids'])), + ], + 'aggs' => [ + 'scores' => [ + 'top_hits' => [ + 'size' => 1, + ], + ], + ], + ]]); + $response = $search->response(); + $search->assertNoError(); + $completedBeatmapIds = array_map( + fn (array $hit): int => (int) $hit['key'], + $response->aggregations($aggName)['buckets'], + ); + $completedBeatmapsetIds = (new Set(array_map( + fn (int $beatmapId): int => $beatmapsetIdsByBeatmapId[$beatmapId], + $completedBeatmapIds, + )))->toArray(); + $completed = count($completedBeatmapsetIds) === count(array_unique($beatmapsetIdsByBeatmapId)); } return [ diff --git a/app/Models/Build.php b/app/Models/Build.php index 6b02a3fdbb5..ee20be84fb5 100644 --- a/app/Models/Build.php +++ b/app/Models/Build.php @@ -198,6 +198,16 @@ public function notificationCover() // no image } + public function platform(): string + { + $version = $this->version; + $suffixPos = strpos($version, '-'); + + return $suffixPos === false + ? '' + : substr($version, $suffixPos + 1); + } + public function url() { return build_url($this); diff --git a/app/Models/Multiplayer/PlaylistItemUserHighScore.php b/app/Models/Multiplayer/PlaylistItemUserHighScore.php index 05617852f3d..8adf655d761 100644 --- a/app/Models/Multiplayer/PlaylistItemUserHighScore.php +++ b/app/Models/Multiplayer/PlaylistItemUserHighScore.php @@ -71,7 +71,7 @@ public static function scoresAround(ScoreLink $scoreLink): array { $placeholder = new static([ 'score_id' => $scoreLink->getKey(), - 'total_score' => $scoreLink->score->data->totalScore, + 'total_score' => $scoreLink->score->total_score, ]); static $typeOptions = [ @@ -117,10 +117,10 @@ public function updateWithScoreLink(ScoreLink $scoreLink): void $score = $scoreLink->score; $this->fill([ - 'accuracy' => $score->data->accuracy, + 'accuracy' => $score->accuracy, 'pp' => $score->pp, 'score_id' => $scoreLink->getKey(), - 'total_score' => $score->data->totalScore, + 'total_score' => $score->total_score, ])->save(); } } diff --git a/app/Models/Multiplayer/ScoreLink.php b/app/Models/Multiplayer/ScoreLink.php index 8bca690c1e2..f2977be647f 100644 --- a/app/Models/Multiplayer/ScoreLink.php +++ b/app/Models/Multiplayer/ScoreLink.php @@ -109,7 +109,7 @@ public function position(): ?int $query = PlaylistItemUserHighScore ::where('playlist_item_id', $this->playlist_item_id) ->cursorSort('score_asc', [ - 'total_score' => $score->data->totalScore, + 'total_score' => $score->total_score, 'score_id' => $this->getKey(), ]); diff --git a/app/Models/Multiplayer/UserScoreAggregate.php b/app/Models/Multiplayer/UserScoreAggregate.php index db0a9146378..9569542252f 100644 --- a/app/Models/Multiplayer/UserScoreAggregate.php +++ b/app/Models/Multiplayer/UserScoreAggregate.php @@ -83,7 +83,7 @@ public function addScoreLink(ScoreLink $scoreLink, ?PlaylistItemUserHighScore $h $scoreLink->playlist_item_id, ); - if ($score->data->passed && $score->data->totalScore > $highestScore->total_score) { + if ($score->passed && $score->total_score > $highestScore->total_score) { $this->updateUserTotal($scoreLink, $highestScore); $highestScore->updateWithScoreLink($scoreLink); } @@ -134,7 +134,7 @@ public function recalculate() $scoreLinks = ScoreLink ::whereHas('playlistItem', fn ($q) => $q->where('room_id', $this->room_id)) ->where('user_id', $this->user_id) - ->with('score.performance') + ->with('score') ->get(); foreach ($scoreLinks as $scoreLink) { $this->addScoreLink( @@ -221,8 +221,8 @@ private function updateUserTotal(ScoreLink $currentScoreLink, PlaylistItemUserHi $current = $currentScoreLink->score; - $this->total_score += $current->data->totalScore; - $this->accuracy += $current->data->accuracy; + $this->total_score += $current->total_score; + $this->accuracy += $current->accuracy; $this->pp += $current->pp; $this->completed++; $this->last_score_id = $currentScoreLink->getKey(); diff --git a/app/Models/OAuth/Token.php b/app/Models/OAuth/Token.php index 402388f50cf..5ca1e9ff3b2 100644 --- a/app/Models/OAuth/Token.php +++ b/app/Models/OAuth/Token.php @@ -7,6 +7,7 @@ use App\Events\UserSessionEvent; use App\Exceptions\InvalidScopeException; +use App\Interfaces\SessionVerificationInterface; use App\Models\Traits\FasterAttributes; use App\Models\User; use Ds\Set; @@ -14,13 +15,25 @@ use Laravel\Passport\RefreshToken; use Laravel\Passport\Token as PassportToken; -class Token extends PassportToken +class Token extends PassportToken implements SessionVerificationInterface { // PassportToken doesn't have factory use HasFactory, FasterAttributes; + protected $casts = [ + 'expires_at' => 'datetime', + 'revoked' => 'boolean', + 'scopes' => 'array', + 'verified' => 'boolean', + ]; + private ?Set $scopeSet; + public static function findForVerification(string $id): ?static + { + return static::find($id); + } + public function refreshToken() { return $this->hasOne(RefreshToken::class, 'access_token_id'); @@ -49,8 +62,10 @@ public function getAttribute($key) 'name', 'user_id' => $this->getRawAttribute($key), - 'revoked' => (bool) $this->getRawAttribute($key), - 'scopes' => json_decode($this->getRawAttribute($key), true), + 'revoked', + 'verified' => $this->getNullableBool($key), + + 'scopes' => json_decode($this->getRawAttribute($key) ?? 'null', true), 'created_at', 'expires_at', @@ -62,6 +77,11 @@ public function getAttribute($key) }; } + public function getKeyForEvent(): string + { + return "oauth:{$this->getKey()}"; + } + /** * Resource owner for the token. * @@ -90,6 +110,16 @@ public function isOwnToken(): bool return $clientUserId !== null && $clientUserId === $this->user_id; } + public function isVerified(): bool + { + return $this->verified; + } + + public function markVerified(): void + { + $this->update(['verified' => true]); + } + public function revokeRecursive() { $result = $this->revoke(); @@ -103,7 +133,7 @@ public function revoke() $saved = parent::revoke(); if ($saved && $this->user_id !== null) { - UserSessionEvent::newLogout($this->user_id, ["oauth:{$this->getKey()}"])->broadcast(); + UserSessionEvent::newLogout($this->user_id, [$this->getKeyForEvent()])->broadcast(); } return $saved; @@ -124,6 +154,11 @@ public function setScopesAttribute(?array $value) $this->attributes['scopes'] = $this->castAttributeAsJson('scopes', $value); } + public function userId(): ?int + { + return $this->user_id; + } + public function validate(): void { static $scopesRequireDelegation = new Set(['chat.write', 'chat.write_manage', 'delegate']); @@ -185,6 +220,9 @@ public function save(array $options = []) { // Forces error if passport tries to issue an invalid client_credentials token. $this->validate(); + if (!$this->exists) { + $this->setVerifiedState(); + } return parent::save($options); } @@ -193,4 +231,13 @@ private function scopeSet(): Set { return $this->scopeSet ??= new Set($this->scopes ?? []); } + + private function setVerifiedState(): void + { + // client credential doesn't have user attached and auth code is + // already verified during grant process + $this->verified ??= $GLOBALS['cfg']['osu']['user']['bypass_verification'] + || $this->user === null + || !$this->client->password_client; + } } diff --git a/app/Models/Score/Best/Model.php b/app/Models/Score/Best/Model.php index b656d04067b..a8bc4b743d0 100644 --- a/app/Models/Score/Best/Model.php +++ b/app/Models/Score/Best/Model.php @@ -83,14 +83,18 @@ public function getAttribute($key) 'date_json' => $this->getJsonTimeFast($key), 'best' => $this, - 'data' => $this->getData(), 'enabled_mods' => $this->getEnabledModsAttribute($this->getRawAttribute('enabled_mods')), 'pass' => true, + 'best_id' => $this->getKey(), + 'has_replay' => $this->replay, + 'beatmap', 'replayViewCount', 'reportedIn', 'user' => $this->getRelationValue($key), + + default => $this->getNewScoreAttribute($key), }; } diff --git a/app/Models/Score/Model.php b/app/Models/Score/Model.php index a9befb727b0..7f48e9991a8 100644 --- a/app/Models/Score/Model.php +++ b/app/Models/Score/Model.php @@ -5,6 +5,7 @@ namespace App\Models\Score; +use App\Enums\Ruleset; use App\Exceptions\ClassNotFoundException; use App\Libraries\Mods; use App\Models\Beatmap; @@ -146,13 +147,36 @@ public function getAttribute($key) 'date_json' => $this->getJsonTimeFast($key), - 'data' => $this->getData(), 'enabled_mods' => $this->getEnabledModsAttribute($this->getRawAttribute('enabled_mods')), + 'best_id' => $this->getRawAttribute('high_score_id'), + 'has_replay' => $this->best?->replay, + 'pp' => $this->best?->pp, + 'beatmap', 'best', 'replayViewCount', 'user' => $this->getRelationValue($key), + + default => $this->getNewScoreAttribute($key), + }; + } + + public function getNewScoreAttribute(string $key) + { + return match ($key) { + 'accuracy' => $this->accuracy(), + 'build_id' => null, + 'data' => $this->getData(), + 'ended_at_json' => $this->date_json, + 'legacy_perfect' => $this->perfect, + 'legacy_score_id' => $this->getKey(), + 'legacy_total_score' => $this->score, + 'max_combo' => $this->maxcombo, + 'passed' => $this->pass, + 'ruleset_id' => Ruleset::tryFromName($this->getMode())->value, + 'started_at_json' => null, + 'total_score' => $this->score, }; } @@ -161,28 +185,29 @@ public function getMode(): string return snake_case(get_class_basename(static::class)); } - protected function getData() + public function getData(): ScoreData { $mods = array_map(fn ($m) => ['acronym' => $m, 'settings' => []], $this->enabled_mods); + $statistics = [ 'miss' => $this->countmiss, 'great' => $this->count300, ]; - $ruleset = $this->getMode(); + $ruleset = Ruleset::tryFromName($this->getMode()); switch ($ruleset) { - case 'osu': + case Ruleset::osu: $statistics['ok'] = $this->count100; $statistics['meh'] = $this->count50; break; - case 'taiko': + case Ruleset::taiko: $statistics['ok'] = $this->count100; break; - case 'fruits': + case Ruleset::catch: $statistics['large_tick_hit'] = $this->count100; $statistics['small_tick_hit'] = $this->count50; $statistics['small_tick_miss'] = $this->countkatu; break; - case 'mania': + case Ruleset::mania: $statistics['perfect'] = $this->countgeki; $statistics['good'] = $this->countkatu; $statistics['ok'] = $this->count100; @@ -190,18 +215,6 @@ protected function getData() break; } - return new ScoreData([ - 'accuracy' => $this->accuracy(), - 'beatmap_id' => $this->beatmap_id, - 'ended_at' => $this->date_json, - 'max_combo' => $this->maxcombo, - 'mods' => $mods, - 'passed' => $this->pass, - 'rank' => $this->rank, - 'ruleset_id' => Beatmap::modeInt($ruleset), - 'statistics' => $statistics, - 'total_score' => $this->score, - 'user_id' => $this->user_id, - ]); + return new ScoreData(compact('mods', 'statistics')); } } diff --git a/app/Models/Solo/Score.php b/app/Models/Solo/Score.php index 38e9c536e76..04072ce2fa7 100644 --- a/app/Models/Solo/Score.php +++ b/app/Models/Solo/Score.php @@ -7,30 +7,43 @@ namespace App\Models\Solo; +use App\Enums\ScoreRank; +use App\Exceptions\InvariantException; use App\Libraries\Score\UserRank; use App\Libraries\Search\ScoreSearchParams; use App\Models\Beatmap; +use App\Models\Beatmapset; use App\Models\Model; use App\Models\Multiplayer\ScoreLink as MultiplayerScoreLink; use App\Models\Score as LegacyScore; use App\Models\ScoreToken; use App\Models\Traits; use App\Models\User; -use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; use LaravelRedis; use Storage; /** + * @property float $accuracy * @property int $beatmap_id - * @property \Carbon\Carbon|null $created_at - * @property string|null $created_at_json + * @property int $build_id * @property ScoreData $data + * @property \Carbon\Carbon|null $ended_at + * @property string|null $ended_at_json * @property bool $has_replay * @property int $id + * @property int $legacy_score_id + * @property int $legacy_total_score + * @property int $max_combo + * @property bool $passed + * @property float $pp * @property bool $preserve + * @property string $rank * @property bool $ranked * @property int $ruleset_id + * @property \Carbon\Carbon|null $started_at + * @property string|null $started_at_json + * @property int $total_score * @property int $unix_updated_at * @property User $user * @property int $user_id @@ -43,26 +56,36 @@ class Score extends Model implements Traits\ReportableInterface protected $casts = [ 'data' => ScoreData::class, + 'ended_at' => 'datetime', 'has_replay' => 'boolean', + 'passed' => 'boolean', 'preserve' => 'boolean', + 'ranked' => 'boolean', + 'started_at' => 'datetime', ]; - public static function createFromJsonOrExplode(array $params) + public static function createFromJsonOrExplode(array $params): static { - $score = new static([ - 'beatmap_id' => $params['beatmap_id'], - 'ruleset_id' => $params['ruleset_id'], - 'user_id' => $params['user_id'], - 'data' => $params, - ]); + $params['data'] = [ + 'maximum_statistics' => $params['maximum_statistics'] ?? [], + 'mods' => $params['mods'] ?? [], + 'statistics' => $params['statistics'] ?? [], + ]; + unset( + $params['maximum_statistics'], + $params['mods'], + $params['statistics'], + ); + + $score = new static($params); - $score->data->assertCompleted(); + $score->assertCompleted(); // this should potentially just be validation rather than applying this logic here, but // older lazer builds potentially submit incorrect details here (and we still want to // accept their scores. - if (!$score->data->passed) { - $score->data->rank = 'F'; + if (!$score->passed) { + $score->rank = 'F'; } $score->saveOrExplode(); @@ -70,46 +93,38 @@ public static function createFromJsonOrExplode(array $params) return $score; } - public static function extractParams(array $params, ScoreToken|MultiplayerScoreLink $scoreToken): array + public static function extractParams(array $rawParams, ScoreToken|MultiplayerScoreLink $scoreToken): array { - return [ - ...get_params($params, null, [ - 'accuracy:float', - 'max_combo:int', - 'maximum_statistics:array', - 'passed:bool', - 'rank:string', - 'statistics:array', - 'total_score:int', - ]), - 'beatmap_id' => $scoreToken->beatmap_id, - 'build_id' => $scoreToken->build_id, - 'ended_at' => json_time(Carbon::now()), - 'mods' => app('mods')->parseInputArray($scoreToken->ruleset_id, get_arr($params['mods'] ?? null) ?? []), - 'ruleset_id' => $scoreToken->ruleset_id, - 'started_at' => $scoreToken->created_at_json, - 'user_id' => $scoreToken->user_id, - ]; - } + $params = get_params($rawParams, null, [ + 'accuracy:float', + 'max_combo:int', + 'maximum_statistics:array', + 'mods:array', + 'passed:bool', + 'rank:string', + 'statistics:array', + 'total_score:int', + ]); - /** - * Queue the item for score processing - * - * @param array $scoreJson JSON of the score generated using ScoreTransformer of type Solo - */ - public static function queueForProcessing(array $scoreJson): void - { - LaravelRedis::lpush($GLOBALS['cfg']['osu']['scores']['processing_queue'], json_encode([ - 'Score' => [ - 'beatmap_id' => $scoreJson['beatmap_id'], - 'id' => $scoreJson['id'], - 'ruleset_id' => $scoreJson['ruleset_id'], - 'user_id' => $scoreJson['user_id'], - // TODO: processor is currently order dependent and requires - // this to be located at the end - 'data' => json_encode($scoreJson), - ], - ])); + $params['maximum_statistics'] ??= []; + $params['statistics'] ??= []; + + $params['mods'] = app('mods')->parseInputArray($scoreToken->ruleset_id, $params['mods'] ?? []); + + $params['beatmap_id'] = $scoreToken->beatmap_id; + $params['build_id'] = $scoreToken->build_id; + $params['ended_at'] = new \DateTime(); + $params['ruleset_id'] = $scoreToken->ruleset_id; + $params['started_at'] = $scoreToken->created_at; + $params['user_id'] = $scoreToken->user_id; + + $beatmap = $scoreToken->beatmap; + $params['ranked'] = $beatmap !== null && in_array($beatmap->approved, [ + Beatmapset::STATES['approved'], + Beatmapset::STATES['ranked'], + ], true); + + return $params; } public function beatmap() @@ -117,11 +132,6 @@ public function beatmap() return $this->belongsTo(Beatmap::class, 'beatmap_id'); } - public function performance() - { - return $this->hasOne(ScorePerformance::class, 'score_id'); - } - public function user() { return $this->belongsTo(User::class, 'user_id'); @@ -132,6 +142,18 @@ public function scopeDefault(Builder $query): Builder return $query->whereHas('beatmap.beatmapset'); } + public function scopeForRuleset(Builder $query, string $ruleset): Builder + { + return $query->where('ruleset_id', Beatmap::MODES[$ruleset]); + } + + public function scopeIncludeFails(Builder $query, bool $includeFails): Builder + { + return $includeFails + ? $query + : $query->where('passed', true); + } + /** * This should match the one used in osu-elastic-indexer. */ @@ -142,25 +164,50 @@ public function scopeIndexable(Builder $query): Builder ->whereHas('user', fn (Builder $q): Builder => $q->default()); } + public function scopeRecent(Builder $query, string $ruleset, bool $includeFails): Builder + { + return $query + ->default() + ->forRuleset($ruleset) + ->includeFails($includeFails) + // 2 days (2 * 24 * 3600) + ->where('unix_updated_at', '>', time() - 172_800); + } + public function getAttribute($key) { return match ($key) { + 'accuracy', 'beatmap_id', + 'build_id', 'id', + 'legacy_score_id', + 'legacy_total_score', + 'max_combo', + 'pp', 'ruleset_id', + 'total_score', 'unix_updated_at', 'user_id' => $this->getRawAttribute($key), + 'rank' => $this->getRawAttribute($key) ?? 'F', + 'data' => $this->getClassCastableAttributeValue($key, $this->getRawAttribute($key)), 'has_replay', - 'preserve', - 'ranked' => (bool) $this->getRawAttribute($key), + 'passed', + 'preserve' => (bool) $this->getRawAttribute($key), + + 'ranked' => (bool) ($this->getRawAttribute($key) ?? true), + + 'ended_at', + 'started_at' => $this->getTimeFast($key), - 'created_at' => $this->getTimeFast($key), - 'created_at_json' => $this->getJsonTimeFast($key), + 'ended_at_json', + 'started_at_json' => $this->getJsonTimeFast($key), - 'pp' => $this->performance?->pp, + 'best_id' => null, + 'legacy_perfect' => null, 'beatmap', 'performance', @@ -169,13 +216,21 @@ public function getAttribute($key) }; } - public function createLegacyEntryOrExplode() + public function assertCompleted(): void { - $score = $this->makeLegacyEntry(); + if (ScoreRank::tryFrom($this->rank ?? '') === null) { + throw new InvariantException("'{$this->rank}' is not a valid rank."); + } - $score->saveOrExplode(); + foreach (['total_score', 'accuracy', 'max_combo', 'passed'] as $field) { + if (!present($this->$field)) { + throw new InvariantException("field missing: '{$field}'"); + } + } - return $score; + if ($this->data->statistics->isEmpty()) { + throw new InvariantException("field cannot be empty: 'statistics'"); + } } public function getMode(): string @@ -191,12 +246,12 @@ public function getReplayFile(): ?string public function isLegacy(): bool { - return $this->data->buildId === null; + return $this->legacy_score_id !== null; } public function legacyScore(): ?LegacyScore\Best\Model { - $id = $this->data->legacyScoreId; + $id = $this->legacy_score_id; return $id === null ? null @@ -214,11 +269,11 @@ public function makeLegacyEntry(): LegacyScore\Model 'beatmapset_id' => $this->beatmap?->beatmapset_id ?? 0, 'countmiss' => $statistics->miss, 'enabled_mods' => app('mods')->idsToBitset(array_column($data->mods, 'acronym')), - 'maxcombo' => $data->maxCombo, - 'pass' => $data->passed, - 'perfect' => $data->passed && $statistics->miss + $statistics->large_tick_miss === 0, - 'rank' => $data->rank, - 'score' => $data->totalScore, + 'maxcombo' => $this->max_combo, + 'pass' => $this->passed, + 'perfect' => $this->passed && $statistics->miss + $statistics->large_tick_miss === 0, + 'rank' => $this->rank, + 'score' => $this->total_score, 'scorechecksum' => "\0", 'user_id' => $this->user_id, ]); @@ -251,6 +306,13 @@ public function makeLegacyEntry(): LegacyScore\Model return $score; } + public function queueForProcessing(): void + { + LaravelRedis::lpush($GLOBALS['cfg']['osu']['scores']['processing_queue'], json_encode([ + 'Score' => $this->getAttributes(), + ])); + } + public function trashed(): bool { return false; @@ -263,13 +325,18 @@ public function url(): string public function userRank(?array $params = null): int { - return UserRank::getRank(ScoreSearchParams::fromArray(array_merge($params ?? [], [ + // Non-legacy score always has its rank checked against all score types. + if (!$this->isLegacy()) { + $params['is_legacy'] = null; + } + + return UserRank::getRank(ScoreSearchParams::fromArray([ + ...($params ?? []), 'beatmap_ids' => [$this->beatmap_id], 'before_score' => $this, - 'is_legacy' => $this->isLegacy(), 'ruleset_id' => $this->ruleset_id, 'user' => $this->user, - ]))); + ])); } protected function newReportableExtraParams(): array diff --git a/app/Models/Solo/ScoreData.php b/app/Models/Solo/ScoreData.php index ebebeaf61fd..51ed8bc0b98 100644 --- a/app/Models/Solo/ScoreData.php +++ b/app/Models/Solo/ScoreData.php @@ -7,30 +7,15 @@ namespace App\Models\Solo; -use App\Enums\ScoreRank; -use App\Exceptions\InvariantException; use Illuminate\Contracts\Database\Eloquent\Castable; use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use JsonSerializable; class ScoreData implements Castable, JsonSerializable { - public float $accuracy; - public int $beatmapId; - public ?int $buildId; - public string $endedAt; - public ?int $legacyScoreId; - public ?int $legacyTotalScore; - public int $maxCombo; public ScoreDataStatistics $maximumStatistics; public array $mods; - public bool $passed; - public string $rank; - public int $rulesetId; - public ?string $startedAt; public ScoreDataStatistics $statistics; - public int $totalScore; - public int $userId; public function __construct(array $data) { @@ -51,22 +36,9 @@ public function __construct(array $data) } } - $this->accuracy = $data['accuracy'] ?? 0; - $this->beatmapId = $data['beatmap_id']; - $this->buildId = $data['build_id'] ?? null; - $this->endedAt = $data['ended_at']; - $this->legacyScoreId = $data['legacy_score_id'] ?? null; - $this->legacyTotalScore = $data['legacy_total_score'] ?? null; - $this->maxCombo = $data['max_combo'] ?? 0; $this->maximumStatistics = new ScoreDataStatistics($data['maximum_statistics'] ?? []); $this->mods = $mods; - $this->passed = $data['passed'] ?? false; - $this->rank = $data['rank'] ?? 'F'; - $this->rulesetId = $data['ruleset_id']; - $this->startedAt = $data['started_at'] ?? null; $this->statistics = new ScoreDataStatistics($data['statistics'] ?? []); - $this->totalScore = $data['total_score'] ?? 0; - $this->userId = $data['user_id']; } public static function castUsing(array $arguments) @@ -75,25 +47,13 @@ public static function castUsing(array $arguments) { public function get($model, $key, $value, $attributes) { - $dataJson = json_decode($value, true); - $dataJson['beatmap_id'] ??= $attributes['beatmap_id']; - $dataJson['ended_at'] ??= $model->created_at_json; - $dataJson['ruleset_id'] ??= $attributes['ruleset_id']; - $dataJson['user_id'] ??= $attributes['user_id']; - - return new ScoreData($dataJson); + return new ScoreData(json_decode($value, true)); } public function set($model, $key, $value, $attributes) { if (!($value instanceof ScoreData)) { - $value = new ScoreData([ - 'beatmap_id' => $attributes['beatmap_id'] ?? null, - 'ended_at' => $attributes['created_at'] ?? null, - 'ruleset_id' => $attributes['ruleset_id'] ?? null, - 'user_id' => $attributes['user_id'] ?? null, - ...$value, - ]); + $value = new ScoreData($value); } return ['data' => json_encode($value)]; @@ -101,50 +61,12 @@ public function set($model, $key, $value, $attributes) }; } - public function assertCompleted(): void - { - if (ScoreRank::tryFrom($this->rank) === null) { - throw new InvariantException("'{$this->rank}' is not a valid rank."); - } - - foreach (['totalScore', 'accuracy', 'maxCombo', 'passed'] as $field) { - if (!present($this->$field)) { - throw new InvariantException("field missing: '{$field}'"); - } - } - - if ($this->statistics->isEmpty()) { - throw new InvariantException("field cannot be empty: 'statistics'"); - } - } - public function jsonSerialize(): array { - $ret = [ - 'accuracy' => $this->accuracy, - 'beatmap_id' => $this->beatmapId, - 'build_id' => $this->buildId, - 'ended_at' => $this->endedAt, - 'legacy_score_id' => $this->legacyScoreId, - 'legacy_total_score' => $this->legacyTotalScore, - 'max_combo' => $this->maxCombo, + return [ 'maximum_statistics' => $this->maximumStatistics, 'mods' => $this->mods, - 'passed' => $this->passed, - 'rank' => $this->rank, - 'ruleset_id' => $this->rulesetId, - 'started_at' => $this->startedAt, 'statistics' => $this->statistics, - 'total_score' => $this->totalScore, - 'user_id' => $this->userId, ]; - - foreach ($ret as $field => $value) { - if ($value === null) { - unset($ret[$field]); - } - } - - return $ret; } } diff --git a/app/Models/Solo/ScorePerformance.php b/app/Models/Solo/ScorePerformance.php deleted file mode 100644 index b30a1f9a410..00000000000 --- a/app/Models/Solo/ScorePerformance.php +++ /dev/null @@ -1,21 +0,0 @@ -. Licensed under the GNU Affero General Public License v3.0. -// See the LICENCE file in the repository root for full licence text. - -namespace App\Models\Solo; - -use App\Models\Model; - -/** - * @property int $score_id - * @property float|null $pp - */ -class ScorePerformance extends Model -{ - public $incrementing = false; - public $timestamps = false; - - protected $primaryKey = 'score_id'; - protected $table = 'score_performance'; -} diff --git a/app/Models/Traits/FasterAttributes.php b/app/Models/Traits/FasterAttributes.php index f54fc880274..e0e4d7e1452 100644 --- a/app/Models/Traits/FasterAttributes.php +++ b/app/Models/Traits/FasterAttributes.php @@ -16,6 +16,13 @@ public function getRawAttribute(string $key) return $this->attributes[$key] ?? null; } + protected function getNullableBool(string $key) + { + $raw = $this->getRawAttribute($key); + + return $raw === null ? null : (bool) $raw; + } + /** * Fast Time Attribute to Json Transformer * diff --git a/app/Models/Traits/UserScoreable.php b/app/Models/Traits/UserScoreable.php index e04a9bacccf..1afe54df899 100644 --- a/app/Models/Traits/UserScoreable.php +++ b/app/Models/Traits/UserScoreable.php @@ -5,71 +5,43 @@ namespace App\Models\Traits; -use App\Libraries\Elasticsearch\BoolQuery; -use App\Libraries\Elasticsearch\SearchResponse; -use App\Libraries\Search\BasicSearch; -use App\Models\Score\Best; +use App\Libraries\Score\FetchDedupedScores; +use App\Libraries\Search\ScoreSearchParams; +use App\Models\Beatmap; +use App\Models\Solo\Score; +use Illuminate\Database\Eloquent\Collection; trait UserScoreable { - private $beatmapBestScoreIds = []; + private array $beatmapBestScoreIds = []; + private array $beatmapBestScores = []; - public function aggregatedScoresBest(string $mode, int $size): SearchResponse + public function aggregatedScoresBest(string $mode, null | true $legacyOnly, int $size): array { - $index = $GLOBALS['cfg']['osu']['elasticsearch']['prefix']."high_scores_{$mode}"; - - $search = new BasicSearch($index, "aggregatedScoresBest_{$mode}"); - $search->connectionName = 'scores'; - $search - ->size(0) // don't care about hits - ->query( - (new BoolQuery()) - ->filter(['term' => ['user_id' => $this->getKey()]]) - ) - ->setAggregations([ - 'by_beatmaps' => [ - 'terms' => [ - 'field' => 'beatmap_id', - // sort by sub-aggregation max_pp, with score_id as tie breaker - 'order' => [['max_pp' => 'desc'], ['min_score_id' => 'asc']], - 'size' => $size, - ], - 'aggs' => [ - 'top_scores' => [ - 'top_hits' => [ - 'size' => 1, - 'sort' => [['pp' => ['order' => 'desc']]], - ], - ], - // top_hits aggregation is not useable for sorting, so we need an extra aggregation to sort on. - 'max_pp' => ['max' => ['field' => 'pp']], - 'min_score_id' => ['min' => ['field' => 'score_id']], - ], - ], - ]); - - $response = $search->response(); - $search->assertNoError(); - - return $response; + return (new FetchDedupedScores('beatmap_id', ScoreSearchParams::fromArray([ + 'is_legacy' => $legacyOnly, + 'limit' => $size, + 'ruleset_id' => Beatmap::MODES[$mode], + 'sort' => 'pp_desc', + 'user_id' => $this->getKey(), + ]), "aggregatedScoresBest_{$mode}"))->all(); } - public function beatmapBestScoreIds(string $mode) + public function beatmapBestScoreIds(string $mode, null | true $legacyOnly) { - if (!isset($this->beatmapBestScoreIds[$mode])) { + $key = $mode.'-'.($legacyOnly ? '1' : '0'); + + if (!isset($this->beatmapBestScoreIds[$key])) { // aggregations do not support regular pagination. // always fetching 100 to cache; we're not supporting beyond 100, either. - $this->beatmapBestScoreIds[$mode] = cache_remember_mutexed( - "search-cache:beatmapBestScores:{$this->getKey()}:{$mode}", + $this->beatmapBestScoreIds[$key] = cache_remember_mutexed( + "search-cache:beatmapBestScoresSolo:{$this->getKey()}:{$key}", $GLOBALS['cfg']['osu']['scores']['es_cache_duration'], [], - function () use ($mode) { - // FIXME: should return some sort of error on error - $buckets = $this->aggregatedScoresBest($mode, 100)->aggregations('by_beatmaps')['buckets'] ?? []; + function () use ($key, $legacyOnly, $mode) { + $this->beatmapBestScores[$key] = $this->aggregatedScoresBest($mode, $legacyOnly, 100); - return array_map(function ($bucket) { - return array_get($bucket, 'top_scores.hits.hits.0._id'); - }, $buckets); + return array_column($this->beatmapBestScores[$key], 'id'); }, function () { // TODO: propagate a more useful message back to the client @@ -79,15 +51,22 @@ function () { ); } - return $this->beatmapBestScoreIds[$mode]; + return $this->beatmapBestScoreIds[$key]; } - public function beatmapBestScores(string $mode, int $limit, int $offset = 0, $with = []) + public function beatmapBestScores(string $mode, int $limit, int $offset, array $with, null | true $legacyOnly): Collection { - $ids = array_slice($this->beatmapBestScoreIds($mode), $offset, $limit); - $clazz = Best\Model::getClass($mode); + $ids = $this->beatmapBestScoreIds($mode, $legacyOnly); + $key = $mode.'-'.($legacyOnly ? '1' : '0'); + + if (isset($this->beatmapBestScores[$key])) { + $results = new Collection(array_slice($this->beatmapBestScores[$key], $offset, $limit)); + } else { + $ids = array_slice($ids, $offset, $limit); + $results = Score::whereKey($ids)->orderByField('id', $ids)->default()->get(); + } - $results = $clazz::whereIn('score_id', $ids)->orderByField('score_id', $ids)->with($with)->get(); + $results->load($with); // fill in positions for weighting // also preload the user relation diff --git a/app/Models/User.php b/app/Models/User.php index ce03568fcb7..5f05a42a110 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -916,6 +916,7 @@ public function getAttribute($key) 'scoresMania', 'scoresOsu', 'scoresTaiko', + 'soloScores', 'statisticsFruits', 'statisticsMania', 'statisticsMania4k', @@ -1447,6 +1448,11 @@ public function scoresBest(string $mode, bool $returnQuery = false) return $returnQuery ? $this->$relation() : $this->$relation; } + public function soloScores(): HasMany + { + return $this->hasMany(Solo\Score::class); + } + public function topicWatches() { return $this->hasMany(TopicWatch::class); diff --git a/app/Providers/PassportServiceProvider.php b/app/Providers/PassportServiceProvider.php new file mode 100644 index 00000000000..254863109e0 --- /dev/null +++ b/app/Providers/PassportServiceProvider.php @@ -0,0 +1,29 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Providers; + +use App\Libraries\OAuth\RefreshTokenGrant; +use Laravel\Passport\Bridge\RefreshTokenRepository; +use Laravel\Passport\Passport; +use Laravel\Passport\PassportServiceProvider as BasePassportServiceProvider; + +class PassportServiceProvider extends BasePassportServiceProvider +{ + /** + * Overrides RefreshTokenGrant to copy verified attribute of the token + */ + protected function makeRefreshTokenGrant() + { + $repository = $this->app->make(RefreshTokenRepository::class); + + $grant = new RefreshTokenGrant($repository); + $grant->setRefreshTokenTTL(Passport::refreshTokensExpireIn()); + + return $grant; + } +} diff --git a/app/Transformers/ScoreTransformer.php b/app/Transformers/ScoreTransformer.php index 8526ad7ea48..ca6f49c67d9 100644 --- a/app/Transformers/ScoreTransformer.php +++ b/app/Transformers/ScoreTransformer.php @@ -7,6 +7,7 @@ namespace App\Transformers; +use App\Libraries\Search\ScoreSearchParams; use App\Models\Beatmap; use App\Models\DeletedUser; use App\Models\LegacyMatch; @@ -22,7 +23,7 @@ class ScoreTransformer extends TransformerAbstract const MULTIPLAYER_BASE_INCLUDES = ['user.country', 'user.cover']; // warning: the preload is actually for PlaylistItemUserHighScore, not for Score const MULTIPLAYER_BASE_PRELOAD = [ - 'scoreLink.score.performance', + 'scoreLink.score', 'scoreLink.user.country', 'scoreLink.user.userProfileCustomization', ]; @@ -90,43 +91,48 @@ public function transform(LegacyMatch\Score|MultiplayerScoreLink|ScoreModel|Solo public function transformSolo(MultiplayerScoreLink|ScoreModel|SoloScore $score) { - if ($score instanceof ScoreModel) { - $legacyPerfect = $score->perfect; - $best = $score->best; + $extraAttributes = []; - if ($best !== null) { - $bestId = $best->getKey(); - $pp = $best->pp; - $hasReplay = $best->replay; - } - } else { - if ($score instanceof MultiplayerScoreLink) { - $multiplayerAttributes = [ - 'playlist_item_id' => $score->playlist_item_id, - 'room_id' => $score->playlistItem->room_id, - 'solo_score_id' => $score->score_id, - ]; - $score = $score->score; - } + if ($score instanceof MultiplayerScoreLink) { + $extraAttributes['playlist_item_id'] = $score->playlist_item_id; + $extraAttributes['room_id'] = $score->playlistItem->room_id; + $extraAttributes['solo_score_id'] = $score->score_id; + $score = $score->score; + } - $pp = $score->pp; - $hasReplay = $score->has_replay; + if ($score instanceof SoloScore) { + $extraAttributes['ranked'] = $score->ranked; } - $hasReplay ??= false; + $hasReplay = $score->has_replay; return [ + ...$extraAttributes, ...$score->data->jsonSerialize(), - ...($multiplayerAttributes ?? []), - 'best_id' => $bestId ?? null, - 'has_replay' => $hasReplay, + 'beatmap_id' => $score->beatmap_id, + 'best_id' => $score->best_id, 'id' => $score->getKey(), - 'legacy_perfect' => $legacyPerfect ?? null, - 'pp' => $pp ?? null, + 'rank' => $score->rank, + 'type' => $score->getMorphClass(), + 'user_id' => $score->user_id, + 'accuracy' => $score->accuracy, + 'build_id' => $score->build_id, + 'ended_at' => $score->ended_at_json, + 'has_replay' => $hasReplay, + 'legacy_perfect' => $score->legacy_perfect, + 'legacy_score_id' => $score->legacy_score_id, + 'legacy_total_score' => $score->legacy_total_score, + 'max_combo' => $score->max_combo, + 'passed' => $score->passed, + 'pp' => $score->pp, + 'ruleset_id' => $score->ruleset_id, + 'started_at' => $score->started_at_json, + 'total_score' => $score->total_score, // TODO: remove this redundant field sometime after 2024-02 'replay' => $hasReplay, - 'type' => $score->getMorphClass(), ]; + + return $ret; } public function transformLegacy(LegacyMatch\Score|ScoreModel|SoloScore $score) @@ -146,7 +152,7 @@ public function transformLegacy(LegacyMatch\Score|ScoreModel|SoloScore $score) $soloScore = $score; $score = $soloScore->makeLegacyEntry(); $score->score_id = $soloScore->getKey(); - $createdAt = $soloScore->created_at_json; + $createdAt = $soloScore->ended_at_json; $type = $soloScore->getMorphClass(); $pp = $soloScore->pp; } else { @@ -248,12 +254,17 @@ function ($item) use ($limit, $transformer) { public function includeRankCountry(ScoreBest|SoloScore $score) { - return $this->primitive($score->userRank(['type' => 'country'])); + return $this->primitive($score->userRank([ + 'type' => 'country', + 'is_legacy' => ScoreSearchParams::showLegacyForUser(\Auth::user()), + ])); } public function includeRankGlobal(ScoreBest|SoloScore $score) { - return $this->primitive($score->userRank([])); + return $this->primitive($score->userRank([ + 'is_legacy' => ScoreSearchParams::showLegacyForUser(\Auth::user()), + ])); } public function includeUser(LegacyMatch\Score|MultiplayerScoreLink|ScoreModel|SoloScore $score) diff --git a/app/Transformers/UserCompactTransformer.php b/app/Transformers/UserCompactTransformer.php index b68fdb447d1..a1878074572 100644 --- a/app/Transformers/UserCompactTransformer.php +++ b/app/Transformers/UserCompactTransformer.php @@ -6,6 +6,7 @@ namespace App\Transformers; use App\Libraries\MorphMap; +use App\Libraries\Search\ScoreSearchParams; use App\Models\Beatmap; use App\Models\User; use App\Models\UserProfileCustomization; @@ -86,6 +87,7 @@ class UserCompactTransformer extends TransformerAbstract 'scores_first_count', 'scores_pinned_count', 'scores_recent_count', + 'session_verified', 'statistics', 'statistics_rulesets', 'support_level', @@ -387,7 +389,10 @@ public function includeReplaysWatchedCounts(User $user) public function includeScoresBestCount(User $user) { - return $this->primitive(count($user->beatmapBestScoreIds($this->mode))); + return $this->primitive(count($user->beatmapBestScoreIds( + $this->mode, + ScoreSearchParams::showLegacyForUser(\Auth::user()), + ))); } public function includeScoresFirstCount(User $user) @@ -402,7 +407,12 @@ public function includeScoresPinnedCount(User $user) public function includeScoresRecentCount(User $user) { - return $this->primitive($user->scores($this->mode, true)->includeFails(false)->count()); + return $this->primitive($user->soloScores()->recent($this->mode, false)->count()); + } + + public function includeSessionVerified(User $user) + { + return $this->primitive($user->token()?->isVerified() ?? false); } public function includeStatistics(User $user) diff --git a/app/helpers.php b/app/helpers.php index eba32ab1083..e4aa16ae86d 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -539,7 +539,7 @@ function mysql_escape_like($string) function oauth_token(): ?App\Models\OAuth\Token { - return request()->attributes->get(App\Http\Middleware\AuthApi::REQUEST_OAUTH_TOKEN_KEY); + return Request::instance()->attributes->get(App\Http\Middleware\AuthApi::REQUEST_OAUTH_TOKEN_KEY); } function osu_trans($key = null, $replace = [], $locale = null) diff --git a/config/app.php b/config/app.php index d39297d1714..9217d6732b2 100644 --- a/config/app.php +++ b/config/app.php @@ -186,6 +186,7 @@ App\Providers\EventServiceProvider::class, // Override default migrate:fresh App\Providers\MigrationServiceProvider::class, + App\Providers\PassportServiceProvider::class, App\Providers\RouteServiceProvider::class, // Override the session id naming (for redis key namespacing) App\Providers\SessionServiceProvider::class, diff --git a/config/octane.php b/config/octane.php index d39e5a36f7d..0e5be335c34 100644 --- a/config/octane.php +++ b/config/octane.php @@ -188,9 +188,9 @@ 'composer.lock*', 'config', 'database', - 'public/**/*.php', 'public/assets/manifest.json*', - 'resources/**/*.php', + 'resources/lang', + 'resources/views', 'routes', ], diff --git a/config/osu.php b/config/osu.php index 43148a067d4..7ed6fa05236 100644 --- a/config/osu.php +++ b/config/osu.php @@ -5,6 +5,14 @@ $profileScoresNotice = markdown_plain($profileScoresNotice); } +$clientTokenKeys = []; +foreach (explode(',', env('CLIENT_TOKEN_KEYS') ?? '') as $entry) { + if ($entry !== '') { + [$platform, $encodedKey] = explode('=', $entry, 2); + $clientTokenKeys[$platform] = hex2bin($encodedKey); + } +} + // osu config~ return [ 'achievement' => [ @@ -93,6 +101,8 @@ 'client' => [ 'check_version' => get_bool(env('CLIENT_CHECK_VERSION')) ?? true, 'default_build_id' => get_int(env('DEFAULT_BUILD_ID')) ?? 0, + 'token_keys' => $clientTokenKeys, + 'token_queue' => env('CLIENT_TOKEN_QUEUE') ?? 'token-queue', 'user_agent' => env('CLIENT_USER_AGENT', 'osu!'), ], 'elasticsearch' => [ @@ -173,6 +183,8 @@ 'experimental_rank_as_default' => get_bool(env('SCORES_EXPERIMENTAL_RANK_AS_DEFAULT')) ?? false, 'experimental_rank_as_extra' => get_bool(env('SCORES_EXPERIMENTAL_RANK_AS_EXTRA')) ?? false, 'processing_queue' => presence(env('SCORES_PROCESSING_QUEUE')) ?? 'osu-queue:score-statistics', + 'submission_enabled' => get_bool(env('SCORES_SUBMISSION_ENABLED')) ?? true, + 'rank_cache' => [ 'local_server' => get_bool(env('SCORES_RANK_CACHE_LOCAL_SERVER')) ?? false, 'min_users' => get_int(env('SCORES_RANK_CACHE_MIN_USERS')) ?? 35000, @@ -258,7 +270,6 @@ 'key_length' => 8, 'tries' => 8, ], - 'registration_mode' => presence(env('REGISTRATION_MODE')) ?? 'client', 'super_friendly' => array_map('intval', explode(' ', env('SUPER_FRIENDLY', '3'))), 'ban_persist_days' => get_int(env('BAN_PERSIST_DAYS')) ?? 28, @@ -266,6 +277,11 @@ 'max_mixed_months' => get_int(env('USER_COUNTRY_CHANGE_MAX_MIXED_MONTHS')) ?? 2, 'min_months' => get_int(env('USER_COUNTRY_CHANGE_MIN_MONTHS')) ?? 6, ], + + 'registration_mode' => [ + 'client' => get_bool(env('REGISTRATION_MODE_CLIENT')) ?? true, + 'web' => get_bool(env('REGISTRATION_MODE_WEB')) ?? false, + ], ], 'user_report_notification' => [ 'endpoint_cheating' => presence(env('USER_REPORT_NOTIFICATION_ENDPOINT_CHEATING')), diff --git a/database/factories/BuildFactory.php b/database/factories/BuildFactory.php index 2bf74f338c1..fb5559fed59 100644 --- a/database/factories/BuildFactory.php +++ b/database/factories/BuildFactory.php @@ -16,7 +16,7 @@ public function definition(): array { return [ 'date' => fn () => $this->faker->dateTimeBetween('-5 years'), - 'hash' => fn () => md5($this->faker->word(), true), + 'hash' => fn () => md5(rand(), true), 'stream_id' => fn () => array_rand_val($GLOBALS['cfg']['osu']['changelog']['update_streams']), 'users' => rand(100, 10000), diff --git a/database/factories/OAuth/TokenFactory.php b/database/factories/OAuth/TokenFactory.php index a956346bcc1..c5857ab6864 100644 --- a/database/factories/OAuth/TokenFactory.php +++ b/database/factories/OAuth/TokenFactory.php @@ -23,8 +23,9 @@ public function definition(): array 'expires_at' => fn () => now()->addDays(), 'id' => str_random(40), 'revoked' => false, - 'scopes' => ['public'], + 'scopes' => ['identify', 'public'], 'user_id' => User::factory(), + 'verified' => true, ]; } } diff --git a/database/factories/Solo/ScoreFactory.php b/database/factories/Solo/ScoreFactory.php index f7c39b3233a..96be7f78004 100644 --- a/database/factories/Solo/ScoreFactory.php +++ b/database/factories/Solo/ScoreFactory.php @@ -7,6 +7,7 @@ namespace Database\Factories\Solo; +use App\Enums\ScoreRank; use App\Models\Beatmap; use App\Models\Solo\Score; use App\Models\User; @@ -19,7 +20,12 @@ class ScoreFactory extends Factory public function definition(): array { return [ + 'accuracy' => fn (): float => $this->faker->randomFloat(1, 0, 1), 'beatmap_id' => Beatmap::factory()->ranked(), + 'ended_at' => new \DateTime(), + 'pp' => fn (): float => $this->faker->randomFloat(4, 0, 1000), + 'rank' => fn () => array_rand_val(ScoreRank::cases())->value, + 'total_score' => fn (): int => $this->faker->randomNumber(7), 'user_id' => User::factory(), // depends on beatmap_id @@ -27,6 +33,8 @@ public function definition(): array // depends on all other attributes 'data' => fn (array $attr): array => $this->makeData()($attr), + + 'legacy_total_score' => fn (array $attr): int => isset($attr['legacy_score_id']) ? $attr['total_score'] : 0, ]; } @@ -41,19 +49,11 @@ private function makeData(?array $overrides = null): callable { return fn (array $attr): array => array_map( fn ($value) => is_callable($value) ? $value($attr) : $value, - array_merge([ - 'accuracy' => fn (): float => $this->faker->randomFloat(1, 0, 1), - 'beatmap_id' => $attr['beatmap_id'], - 'ended_at' => fn (): string => json_time(now()), - 'max_combo' => fn (): int => rand(1, Beatmap::find($attr['beatmap_id'])->countNormal), + [ + 'statistics' => ['great' => 1], 'mods' => [], - 'passed' => true, - 'rank' => fn (): string => array_rand_val(['A', 'S', 'B', 'SH', 'XH', 'X']), - 'ruleset_id' => $attr['ruleset_id'], - 'started_at' => fn (): string => json_time(now()->subSeconds(600)), - 'total_score' => fn (): int => $this->faker->randomNumber(7), - 'user_id' => $attr['user_id'], - ], $overrides ?? []), + ...($overrides ?? []), + ], ); } } diff --git a/database/migrations/2023_12_18_104437_add_verified_column_to_oauth_access_tokens.php b/database/migrations/2023_12_18_104437_add_verified_column_to_oauth_access_tokens.php new file mode 100644 index 00000000000..6510da683c6 --- /dev/null +++ b/database/migrations/2023_12_18_104437_add_verified_column_to_oauth_access_tokens.php @@ -0,0 +1,27 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::table('oauth_access_tokens', function (Blueprint $table) { + $table->boolean('verified')->default(true); + }); + } + + public function down(): void + { + Schema::table('oauth_access_tokens', function (Blueprint $table) { + $table->dropColumn('verified'); + }); + } +}; diff --git a/database/migrations/2024_01_12_115738_update_scores_table_final.php b/database/migrations/2024_01_12_115738_update_scores_table_final.php new file mode 100644 index 00000000000..8a93c4edf2c --- /dev/null +++ b/database/migrations/2024_01_12_115738_update_scores_table_final.php @@ -0,0 +1,94 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + private static function resetView(): void + { + DB::statement('DROP VIEW scores'); + DB::statement('CREATE VIEW scores AS SELECT * FROM solo_scores'); + } + + public function up(): void + { + Schema::drop('solo_scores'); + DB::statement("CREATE TABLE `solo_scores` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned NOT NULL, + `ruleset_id` smallint unsigned NOT NULL, + `beatmap_id` mediumint unsigned NOT NULL, + `has_replay` tinyint NOT NULL DEFAULT '0', + `preserve` tinyint NOT NULL DEFAULT '0', + `ranked` tinyint NOT NULL DEFAULT '1', + `rank` char(2) NOT NULL DEFAULT '', + `passed` tinyint NOT NULL DEFAULT '0', + `accuracy` float NOT NULL DEFAULT '0', + `max_combo` int unsigned NOT NULL DEFAULT '0', + `total_score` int unsigned NOT NULL DEFAULT '0', + `data` json NOT NULL, + `pp` float DEFAULT NULL, + `legacy_score_id` bigint unsigned DEFAULT NULL, + `legacy_total_score` int unsigned NOT NULL DEFAULT '0', + `started_at` timestamp NULL DEFAULT NULL, + `ended_at` timestamp NOT NULL, + `unix_updated_at` int unsigned NOT NULL DEFAULT (unix_timestamp()), + `build_id` smallint unsigned DEFAULT NULL, + PRIMARY KEY (`id`,`preserve`,`unix_updated_at`), + KEY `user_ruleset_index` (`user_id`,`ruleset_id`), + KEY `beatmap_user_index` (`beatmap_id`,`user_id`), + KEY `legacy_score_lookup` (`ruleset_id`,`legacy_score_id`) + )"); + + DB::statement('DROP VIEW score_legacy_id_map'); + Schema::drop('solo_scores_legacy_id_map'); + + DB::statement('DROP VIEW score_performance'); + Schema::drop('solo_scores_performance'); + + static::resetView(); + } + + public function down(): void + { + Schema::drop('solo_scores'); + DB::statement("CREATE TABLE `solo_scores` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned NOT NULL, + `beatmap_id` mediumint unsigned NOT NULL, + `ruleset_id` smallint unsigned NOT NULL, + `data` json NOT NULL, + `has_replay` tinyint DEFAULT '0', + `preserve` tinyint NOT NULL DEFAULT '0', + `ranked` tinyint NOT NULL DEFAULT '1', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `unix_updated_at` int unsigned NOT NULL DEFAULT (unix_timestamp()), + PRIMARY KEY (`id`,`preserve`,`unix_updated_at`), + KEY `user_ruleset_index` (`user_id`,`ruleset_id`), + KEY `beatmap_user_index` (`beatmap_id`,`user_id`) + )"); + + DB::statement('CREATE TABLE `solo_scores_legacy_id_map` ( + `ruleset_id` smallint unsigned NOT NULL, + `old_score_id` bigint unsigned NOT NULL, + `score_id` bigint unsigned NOT NULL, + PRIMARY KEY (`ruleset_id`,`old_score_id`) + )'); + DB::statement('CREATE VIEW score_legacy_id_map AS SELECT * FROM solo_scores_legacy_id_map'); + + DB::statement('CREATE TABLE `solo_scores_performance` ( + `score_id` bigint unsigned NOT NULL, + `pp` float DEFAULT NULL, + PRIMARY KEY (`score_id`) + )'); + DB::statement('CREATE VIEW score_performance AS SELECT * FROM solo_scores_performance'); + + static::resetView(); + } +}; diff --git a/docker-compose.yml b/docker-compose.yml index c2e1ddab56a..6e7fa320e9a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -154,7 +154,7 @@ services: - "${NGINX_PORT:-8080}:80" score-indexer: - image: pppy/osu-elastic-indexer:99cd549c5c5c959ff6b2728b76af603dda4c85cb + image: pppy/osu-elastic-indexer:master command: ["queue", "watch"] depends_on: redis: @@ -168,7 +168,7 @@ services: SCHEMA: "${SCHEMA:-1}" score-indexer-test: - image: pppy/osu-elastic-indexer:99cd549c5c5c959ff6b2728b76af603dda4c85cb + image: pppy/osu-elastic-indexer:master command: ["queue", "watch"] depends_on: redis: diff --git a/phpunit.xml b/phpunit.xml index 02c1458af45..2464da35285 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -21,6 +21,7 @@ + diff --git a/public/images/layout/osu-lazer-logo-triangles.svg b/public/images/layout/osu-lazer-logo-triangles.svg new file mode 100644 index 00000000000..2321f2c04c2 --- /dev/null +++ b/public/images/layout/osu-lazer-logo-triangles.svg @@ -0,0 +1,257 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/layout/osu-lazer-logo-white.svg b/public/images/layout/osu-lazer-logo-white.svg new file mode 100644 index 00000000000..8209a0c0ae4 --- /dev/null +++ b/public/images/layout/osu-lazer-logo-white.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/css/bem/nav2.less b/resources/css/bem/nav2.less index eade7ae8eff..2617b0c61f7 100644 --- a/resources/css/bem/nav2.less +++ b/resources/css/bem/nav2.less @@ -60,7 +60,7 @@ transition: all 100ms ease-in-out; will-change: opacity, transform; - background-image: url('~@images/layout/osu-logo-white.svg'); + background-image: var(--nav-logo); .@{_top}__logo-link:hover & { // be careful of weird snapping at the end of animation on Firefox (with 1.1, ~60px). @@ -68,7 +68,7 @@ } &--bg { - background-image: url('~@images/layout/osu-logo-triangles.svg'); + background-image: var(--nav-logo-bg); opacity: 0; .@{_top}__logo-link:hover & { diff --git a/resources/css/bem/navbar-mobile.less b/resources/css/bem/navbar-mobile.less index def8b0270fa..9ec8321e802 100644 --- a/resources/css/bem/navbar-mobile.less +++ b/resources/css/bem/navbar-mobile.less @@ -33,7 +33,7 @@ &__logo { flex: none; display: block; - background-image: url('~@images/layout/osu-logo-white.svg'); + background-image: var(--nav-logo); background-size: contain; background-repeat: no-repeat; background-position: center; diff --git a/resources/css/bem/osu-layout.less b/resources/css/bem/osu-layout.less index 226b8c79bb7..09915043850 100644 --- a/resources/css/bem/osu-layout.less +++ b/resources/css/bem/osu-layout.less @@ -13,6 +13,9 @@ transition: filter 200ms ease-in-out, opacity 200ms ease-in-out; // for fading in after &--masked is removed &--body { + --nav-logo: url('~@images/layout/osu-logo-white.svg'); + --nav-logo-bg: url('~@images/layout/osu-logo-triangles.svg'); + background-color: @osu-colour-b6; } @@ -35,6 +38,11 @@ } } + &--body-lazer { + --nav-logo: url('~@images/layout/osu-lazer-logo-white.svg'); + --nav-logo-bg: url('~@images/layout/osu-lazer-logo-triangles.svg'); + } + &--full { flex: 1 0 auto; width: 100%; diff --git a/resources/css/bem/simple-menu.less b/resources/css/bem/simple-menu.less index 34ec88f10b3..986ceb81437 100644 --- a/resources/css/bem/simple-menu.less +++ b/resources/css/bem/simple-menu.less @@ -135,6 +135,12 @@ } } + &__extra { + background-color: hsl(var(--hsl-b5)); + padding: @_padding-vertical @_gutter; + margin: -@_padding-vertical -@_gutter @_padding-vertical; + } + &__form { margin: -@_padding-vertical -@_gutter; } diff --git a/resources/js/beatmap-discussions-history/main.tsx b/resources/js/beatmap-discussions-history/main.tsx index ce2fc305ad9..2c747dabc65 100644 --- a/resources/js/beatmap-discussions-history/main.tsx +++ b/resources/js/beatmap-discussions-history/main.tsx @@ -1,98 +1,49 @@ // Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. // See the LICENCE file in the repository root for full licence text. -import { BeatmapsContext } from 'beatmap-discussions/beatmaps-context'; -import { BeatmapsetsContext } from 'beatmap-discussions/beatmapsets-context'; import { Discussion } from 'beatmap-discussions/discussion'; -import { DiscussionsContext } from 'beatmap-discussions/discussions-context'; import BeatmapsetCover from 'components/beatmapset-cover'; import BeatmapsetDiscussionsBundleJson from 'interfaces/beatmapset-discussions-bundle-json'; -import { keyBy } from 'lodash'; -import { computed, makeObservable } from 'mobx'; -import { observer } from 'mobx-react'; -import { deletedUserJson } from 'models/user'; import * as React from 'react'; +import BeatmapsetDiscussionsBundleStore from 'stores/beatmapset-discussions-bundle-store'; import { makeUrl } from 'utils/beatmapset-discussion-helper'; import { trans } from 'utils/lang'; -interface Props { - bundle: BeatmapsetDiscussionsBundleJson; -} - -@observer -export default class Main extends React.Component { - @computed - private get beatmaps() { - return keyBy(this.props.bundle.beatmaps, 'id'); - } - - @computed - private get beatmapsets() { - return keyBy(this.props.bundle.beatmapsets, 'id'); - } - - @computed - private get discussions() { - return keyBy(this.props.bundle.included_discussions, 'id'); - } - - @computed - private get users() { - const values = keyBy(this.props.bundle.users, 'id'); - // eslint-disable-next-line id-blacklist - values.null = values.undefined = deletedUserJson; - - return values; - } - - constructor(props: Props) { - super(props); - - makeObservable(this); - } +export default class Main extends React.Component { + private readonly store = new BeatmapsetDiscussionsBundleStore(this.props); render() { return ( - - - -
- {this.props.bundle.discussions.length === 0 ? ( -
- {trans('beatmap_discussions.index.none_found')} -
- ) : (this.props.bundle.discussions.map((discussion) => { - // TODO: handle in child component? Refactored state might not have beatmapset here (and uses Map) - const beatmapset = this.beatmapsets[discussion.beatmapset_id]; - - return beatmapset != null && ( -
- - - - -
- ); - }))} +
+ {this.props.discussions.length === 0 ? ( +
+ {trans('beatmap_discussions.index.none_found')} +
+ ) : (this.props.discussions.map((discussion) => { + // TODO: handle in child component? Refactored state might not have beatmapset here (and uses Map) + const beatmapset = this.store.beatmapsets.get(discussion.beatmapset_id); + + return beatmapset != null && ( +
+ + + +
- - - + ); + }))} +
); } } diff --git a/resources/js/beatmap-discussions/beatmap-list.tsx b/resources/js/beatmap-discussions/beatmap-list.tsx index d08d1c177b0..7c5a60db2fa 100644 --- a/resources/js/beatmap-discussions/beatmap-list.tsx +++ b/resources/js/beatmap-discussions/beatmap-list.tsx @@ -3,44 +3,43 @@ import BeatmapListItem from 'components/beatmap-list-item'; import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; -import BeatmapsetJson from 'interfaces/beatmapset-json'; import UserJson from 'interfaces/user-json'; -import { deletedUser } from 'models/user'; +import { action, computed, makeObservable, observable } from 'mobx'; +import { observer } from 'mobx-react'; +import { deletedUserJson } from 'models/user'; import * as React from 'react'; +import { makeUrl } from 'utils/beatmapset-discussion-helper'; import { blackoutToggle } from 'utils/blackout'; import { classWithModifiers } from 'utils/css'; import { formatNumber } from 'utils/html'; import { nextVal } from 'utils/seq'; +import DiscussionsState from './discussions-state'; interface Props { - beatmaps: BeatmapExtendedJson[]; - beatmapset: BeatmapsetJson; - createLink: (beatmap: BeatmapExtendedJson) => string; - currentBeatmap: BeatmapExtendedJson; - getCount: (beatmap: BeatmapExtendedJson) => number | undefined; - onSelectBeatmap: (beatmapId: number) => void; - users: Partial>; + discussionsState: DiscussionsState; + users: Map; } -interface State { - showingSelector: boolean; -} - -export default class BeatmapList extends React.PureComponent { +@observer +export default class BeatmapList extends React.Component { private readonly eventId = `beatmapset-discussions-show-beatmap-list-${nextVal()}`; + @observable private showingSelector = false; + + @computed + private get beatmaps() { + return this.props.discussionsState.groupedBeatmaps.get(this.props.discussionsState.currentBeatmap.mode) ?? []; + } constructor(props: Props) { super(props); - this.state = { - showingSelector: false, - }; + makeObservable(this); } componentDidMount() { $(document).on(`click.${this.eventId}`, this.onDocumentClick); - $(document).on(`turbolinks:before-cache.${this.eventId}`, this.hideSelector); - this.syncBlackout(); + $(document).on(`turbolinks:before-cache.${this.eventId}`, this.handleBeforeCache); + blackoutToggle(this.showingSelector, 0.5); } componentWillUnmount() { @@ -49,14 +48,14 @@ export default class BeatmapList extends React.PureComponent { render() { return ( -
+
@@ -73,20 +72,20 @@ export default class BeatmapList extends React.PureComponent { } private readonly beatmapListItem = (beatmap: BeatmapExtendedJson) => { - const count = this.props.getCount(beatmap); + const count = this.props.discussionsState.unresolvedDiscussionCounts.byBeatmap[beatmap.id]; return (
{count != null && @@ -98,12 +97,12 @@ export default class BeatmapList extends React.PureComponent { ); }; - private readonly hideSelector = () => { - if (this.state.showingSelector) { - this.setSelector(false); - } + @action + private readonly handleBeforeCache = () => { + this.setShowingSelector(false); }; + @action private readonly onDocumentClick = (e: JQuery.ClickEvent) => { if (e.button !== 0) return; @@ -111,31 +110,31 @@ export default class BeatmapList extends React.PureComponent { return; } - this.hideSelector(); + this.setShowingSelector(false); }; + @action private readonly selectBeatmap = (e: React.MouseEvent) => { if (e.button !== 0) return; e.preventDefault(); const beatmapId = parseInt(e.currentTarget.dataset.id ?? '', 10); - this.props.onSelectBeatmap(beatmapId); - }; - private readonly setSelector = (state: boolean) => { - if (this.state.showingSelector !== state) { - this.setState({ showingSelector: state }, this.syncBlackout); - } + this.props.discussionsState.currentBeatmapId = beatmapId; + this.props.discussionsState.changeDiscussionPage('timeline'); }; - private readonly syncBlackout = () => { - blackoutToggle(this.state.showingSelector, 0.5); - }; + @action + private setShowingSelector(state: boolean) { + this.showingSelector = state; + blackoutToggle(state, 0.5); + } + @action private readonly toggleSelector = (e: React.MouseEvent) => { if (e.button !== 0) return; e.preventDefault(); - this.setSelector(!this.state.showingSelector); + this.setShowingSelector(!this.showingSelector); }; } diff --git a/resources/js/beatmap-discussions/beatmap-owner-editor.tsx b/resources/js/beatmap-discussions/beatmap-owner-editor.tsx index 6d5c6f889ab..6c10a1060df 100644 --- a/resources/js/beatmap-discussions/beatmap-owner-editor.tsx +++ b/resources/js/beatmap-discussions/beatmap-owner-editor.tsx @@ -5,7 +5,7 @@ import { Spinner } from 'components/spinner'; import UserAvatar from 'components/user-avatar'; import UserLink from 'components/user-link'; import BeatmapJson from 'interfaces/beatmap-json'; -import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; +import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; import UserJson from 'interfaces/user-json'; import { route } from 'laroute'; import { action, computed, makeObservable, observable, runInAction } from 'mobx'; @@ -16,17 +16,17 @@ import { onErrorWithCallback } from 'utils/ajax'; import { classWithModifiers } from 'utils/css'; import { transparentGif } from 'utils/html'; import { trans } from 'utils/lang'; - -type BeatmapsetWithDiscussionJson = BeatmapsetExtendedJson; +import DiscussionsState from './discussions-state'; interface XhrCollection { - updateOwner: JQuery.jqXHR; + updateOwner: JQuery.jqXHR; userLookup: JQuery.jqXHR; } interface Props { beatmap: BeatmapJson; beatmapsetUser: UserJson; + discussionsState: DiscussionsState; user: UserJson; userByName: Map; } @@ -245,8 +245,8 @@ export default class BeatmapOwnerEditor extends React.Component { data: { beatmap: { user_id: userId } }, method: 'PUT', }); - this.xhr.updateOwner.done((data) => runInAction(() => { - $.publish('beatmapsetDiscussions:update', { beatmapset: data }); + this.xhr.updateOwner.done((beatmapset) => runInAction(() => { + this.props.discussionsState.update({ beatmapset }); this.editing = false; })).fail(onErrorWithCallback(() => { this.updateOwner(userId); diff --git a/resources/js/beatmap-discussions/beatmaps-context.ts b/resources/js/beatmap-discussions/beatmaps-context.ts deleted file mode 100644 index a8e51669055..00000000000 --- a/resources/js/beatmap-discussions/beatmaps-context.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. -// See the LICENCE file in the repository root for full licence text. - -import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; -import * as React from 'react'; - -const defaultValue: Partial> = {}; - -export const BeatmapsContext = React.createContext(defaultValue); diff --git a/resources/js/beatmap-discussions/beatmaps-owner-editor.tsx b/resources/js/beatmap-discussions/beatmaps-owner-editor.tsx index acdba3d1f37..ca079a63d8a 100644 --- a/resources/js/beatmap-discussions/beatmaps-owner-editor.tsx +++ b/resources/js/beatmap-discussions/beatmaps-owner-editor.tsx @@ -10,11 +10,13 @@ import * as React from 'react'; import { group as groupBeatmaps } from 'utils/beatmap-helper'; import { trans } from 'utils/lang'; import BeatmapOwnerEditor from './beatmap-owner-editor'; +import DiscussionsState from './discussions-state'; interface Props { beatmapset: BeatmapsetExtendedJson; + discussionsState: DiscussionsState; onClose: () => void; - users: Partial>; + users: Map; } @observer @@ -26,7 +28,7 @@ export default class BeatmapsOwnerEditor extends React.Component { // this will be outdated on new props but it's fine // as there's separate process handling unknown users - for (const user of Object.values(props.users)) { + for (const user of this.props.users.values()) { if (user != null) { this.userByName.set(normaliseUsername(user.username), user); } @@ -61,6 +63,7 @@ export default class BeatmapsOwnerEditor extends React.Component { key={beatmap.id} beatmap={beatmap} beatmapsetUser={beatmapsetUser} + discussionsState={this.props.discussionsState} user={this.getUser(beatmap.user_id)} userByName={this.userByName} /> @@ -82,6 +85,6 @@ export default class BeatmapsOwnerEditor extends React.Component { } private getUser(userId: number) { - return this.props.users[userId] ?? deletedUserJson; + return this.props.users.get(userId) ?? deletedUserJson; } } diff --git a/resources/js/beatmap-discussions/beatmapsets-context.ts b/resources/js/beatmap-discussions/beatmapsets-context.ts deleted file mode 100644 index 8a327a9c84f..00000000000 --- a/resources/js/beatmap-discussions/beatmapsets-context.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. -// See the LICENCE file in the repository root for full licence text. - -import BeatmapetExtendedJson from 'interfaces/beatmapset-extended-json'; -import * as React from 'react'; - -const defaultValue: Partial> = {}; - -export const BeatmapsetsContext = React.createContext(defaultValue); diff --git a/resources/js/beatmap-discussions/chart.tsx b/resources/js/beatmap-discussions/chart.tsx index 6dc72192d4b..c48caed3006 100644 --- a/resources/js/beatmap-discussions/chart.tsx +++ b/resources/js/beatmap-discussions/chart.tsx @@ -7,7 +7,7 @@ import { formatTimestamp, makeUrl } from 'utils/beatmapset-discussion-helper'; import { classWithModifiers } from 'utils/css'; interface Props { - discussions: Partial>; + discussions: BeatmapsetDiscussionJson[]; duration: number; } @@ -22,7 +22,7 @@ export default function Chart(props: Props) { const items: React.ReactNode[] = []; if (props.duration !== 0) { - Object.values(props.discussions).forEach((discussion) => { + props.discussions.forEach((discussion) => { if (discussion == null || discussion.timestamp == null) return; let className = classWithModifiers('beatmapset-discussions-chart__item', [ diff --git a/resources/js/beatmap-discussions/discussion-mode.ts b/resources/js/beatmap-discussions/discussion-mode.ts index 797ae2a12c7..bae742fe778 100644 --- a/resources/js/beatmap-discussions/discussion-mode.ts +++ b/resources/js/beatmap-discussions/discussion-mode.ts @@ -1,10 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. // See the LICENCE file in the repository root for full licence text. -// In display order on discussion page tabs -export const discussionPages = ['reviews', 'generalAll', 'general', 'timeline', 'events'] as const; -export type DiscussionPage = (typeof discussionPages)[number]; +import DiscussionPage from './discussion-page'; type DiscussionMode = Exclude; +export const discussionModes: Readonly = ['reviews', 'generalAll', 'general', 'timeline'] as const; export default DiscussionMode; diff --git a/resources/js/beatmap-discussions/discussion-page.ts b/resources/js/beatmap-discussions/discussion-page.ts new file mode 100644 index 00000000000..96956e42fda --- /dev/null +++ b/resources/js/beatmap-discussions/discussion-page.ts @@ -0,0 +1,14 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +// In display order on discussion page tabs +export const discussionPages = ['reviews', 'generalAll', 'general', 'timeline', 'events'] as const; +type DiscussionPage = (typeof discussionPages)[number]; + +const discussionPageSet = new Set(discussionPages); + +export function isDiscussionPage(value: unknown): value is DiscussionPage { + return discussionPageSet.has(value); +} + +export default DiscussionPage; diff --git a/resources/js/beatmap-discussions/discussion-vote-buttons.tsx b/resources/js/beatmap-discussions/discussion-vote-buttons.tsx index 8ac8e04ec86..5fea47f2fb4 100644 --- a/resources/js/beatmap-discussions/discussion-vote-buttons.tsx +++ b/resources/js/beatmap-discussions/discussion-vote-buttons.tsx @@ -3,6 +3,7 @@ import UserListPopup, { createTooltip } from 'components/user-list-popup'; import { BeatmapsetDiscussionJsonForShow } from 'interfaces/beatmapset-discussion-json'; +import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; import UserJson from 'interfaces/user-json'; import { route } from 'laroute'; import { action, computed, makeObservable, observable } from 'mobx'; @@ -14,6 +15,7 @@ import { onError } from 'utils/ajax'; import { classWithModifiers } from 'utils/css'; import { trans } from 'utils/lang'; import { hideLoadingOverlay, showLoadingOverlay } from 'utils/loading-overlay'; +import DiscussionsState from './discussions-state'; const voteTypes = ['up', 'down'] as const; type VoteType = typeof voteTypes[number]; @@ -21,13 +23,14 @@ type VoteType = typeof voteTypes[number]; interface Props { cannotVote: boolean; discussion: BeatmapsetDiscussionJsonForShow; - users: Partial>; + discussionsState: DiscussionsState; + users: Map; } @observer export default class DiscussionVoteButtons extends React.Component { private readonly tooltips: Partial> = {}; - @observable private voteXhr: JQuery.jqXHR | null = null; + @observable private voteXhr: JQuery.jqXHR | null = null; @computed private get canDownvote() { @@ -68,7 +71,7 @@ export default class DiscussionVoteButtons extends React.Component { ? trans(`beatmaps.discussions.votes.none.${type}`) : `${trans(`beatmaps.discussions.votes.latest.${type}`)}:`; - const users = this.props.discussion.votes.voters[type].map((id) => this.props.users[id] ?? { id }); + const users = this.props.discussion.votes.voters[type].map((id) => this.props.users.get(id) ?? { id }); return renderToStaticMarkup(); } @@ -89,7 +92,7 @@ export default class DiscussionVoteButtons extends React.Component { }); this.voteXhr - .done((beatmapset) => $.publish('beatmapsetDiscussions:update', { beatmapset })) + .done((beatmapset) => this.props.discussionsState.update({ beatmapset })) .fail(onError) .always(action(() => { hideLoadingOverlay(); diff --git a/resources/js/beatmap-discussions/discussion.tsx b/resources/js/beatmap-discussions/discussion.tsx index 2bc4ee99631..07502b9c8b2 100644 --- a/resources/js/beatmap-discussions/discussion.tsx +++ b/resources/js/beatmap-discussions/discussion.tsx @@ -1,11 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. // See the LICENCE file in the repository root for full licence text. -import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; import BeatmapsetDiscussionJson, { BeatmapsetDiscussionJsonForBundle, BeatmapsetDiscussionJsonForShow } from 'interfaces/beatmapset-discussion-json'; import BeatmapsetDiscussionPostJson from 'interfaces/beatmapset-discussion-post-json'; -import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; -import UserJson from 'interfaces/user-json'; +import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; import { findLast } from 'lodash'; import { action, computed, makeObservable } from 'mobx'; import { observer } from 'mobx-react'; @@ -18,7 +16,7 @@ import { classWithModifiers, groupColour } from 'utils/css'; import { trans } from 'utils/lang'; import { DiscussionType, discussionTypeIcons } from './discussion-type'; import DiscussionVoteButtons from './discussion-vote-buttons'; -import DiscussionsStateContext from './discussions-state-context'; +import DiscussionsState from './discussions-state'; import { NewReply } from './new-reply'; import Post from './post'; import SystemPost from './system-post'; @@ -26,26 +24,18 @@ import { UserCard } from './user-card'; const bn = 'beatmap-discussion'; -interface PropsBase { - beatmapset: BeatmapsetExtendedJson; - currentBeatmap: BeatmapExtendedJson | null; +interface BaseProps { isTimelineVisible: boolean; parentDiscussion?: BeatmapsetDiscussionJson | null; - readonly: boolean; - readPostIds?: Set; - showDeleted: boolean; - users: Partial>; + store: BeatmapsetDiscussionsStore; } -// preview version is used on pages other than the main discussions page. -type Props = PropsBase & ({ - // BeatmapsetDiscussionJsonForShow is because editing still returns - // BeatmapsetDiscussionJsonForShow which gets merged into the parent discussions blob. - discussion: BeatmapsetDiscussionJsonForBundle | BeatmapsetDiscussionJsonForShow; - preview: true; +type Props = BaseProps & ({ + discussion: BeatmapsetDiscussionJsonForBundle; + discussionsState: null; // TODO: make optional? } | { discussion: BeatmapsetDiscussionJsonForShow; - preview: false; + discussionsState: DiscussionsState; }); function DiscussionTypeIcon({ type }: { type: DiscussionType | 'resolved' }) { @@ -64,60 +54,79 @@ function DiscussionTypeIcon({ type }: { type: DiscussionType | 'resolved' }) { @observer export class Discussion extends React.Component { - static contextType = DiscussionsStateContext; - static defaultProps = { - preview: false, - readonly: false, - }; - - declare context: React.ContextType; private lastResolvedState = false; - constructor(props: Props) { - super(props); - makeObservable(this); + private get beatmapset() { + return this.props.discussionsState?.beatmapset; + } + + private get currentBeatmap() { + return this.props.discussionsState?.currentBeatmap; } @computed private get canBeRepliedTo() { - return !downloadLimited(this.props.beatmapset) - && (!this.props.beatmapset.discussion_locked || canModeratePosts()) - && (this.props.discussion.beatmap_id == null || this.props.currentBeatmap?.deleted_at == null); + return this.beatmapset != null + && !downloadLimited(this.beatmapset) + && (!this.beatmapset.discussion_locked || canModeratePosts()) + && (this.props.discussion.beatmap_id == null || this.currentBeatmap?.deleted_at == null); } @computed private get collapsed() { - return this.context.discussionCollapsed.get(this.props.discussion.id) ?? this.context.discussionDefaultCollapsed; + return this.props.discussionsState?.discussionCollapsed.get(this.props.discussion.id) ?? this.props.discussionsState?.discussionDefaultCollapsed ?? false; } @computed private get highlighted() { - return this.context.highlightedDiscussionId === this.props.discussion.id; + return this.props.discussionsState?.highlightedDiscussionId === this.props.discussion.id; + } + + private get readonly() { + return this.props.discussionsState == null; + } + + private get readPostIds() { + return this.props.discussionsState?.readPostIds; } @computed - private get resolvedSystemPostId() { + private get resolvedStateChangedPostId() { // TODO: handling resolved status in bundles....? - if (this.props.preview) return -1; + if (this.props.discussionsState == null) return -1; - const systemPost = findLast(this.props.discussion.posts, (post) => post != null && post.system && post.message.type === 'resolved'); + const systemPost = findLast(this.props.discussion.posts, (post) => post.system && post.message.type === 'resolved'); return systemPost?.id ?? -1; } + private get showDeleted() { + return this.props.discussionsState?.showDeleted ?? true; + } + + private get users() { + return this.props.store.users; + } + + constructor(props: Props) { + super(props); + makeObservable(this); + } + render() { if (!this.isVisible(this.props.discussion)) return null; const firstPost = startingPost(this.props.discussion); - // TODO: check if possible to have null post... + // firstPost shouldn't be null anymore; + // just simpler to allow startingPost to return undefined and adding a null check in render. if (firstPost == null) return null; const lineClasses = classWithModifiers(`${bn}__line`, { resolved: this.props.discussion.resolved }); this.lastResolvedState = false; - const user = this.props.users[this.props.discussion.user_id] ?? deletedUserJson; + const user = this.users.get(this.props.discussion.user_id) ?? deletedUserJson; const group = badgeGroup({ - beatmapset: this.props.beatmapset, - currentBeatmap: this.props.currentBeatmap, + beatmapset: this.beatmapset, + currentBeatmap: this.currentBeatmap, discussion: this.props.discussion, user, }); @@ -126,7 +135,7 @@ export class Discussion extends React.Component { deleted: this.props.discussion.deleted_at != null, highlighted: this.highlighted, 'horizontal-desktop': this.props.discussion.message_type !== 'review', - preview: this.props.preview, + preview: this.readonly, review: this.props.discussion.message_type === 'review', timeline: this.props.discussion.timestamp != null, unread: !this.isRead(firstPost), @@ -166,13 +175,13 @@ export class Discussion extends React.Component { @action private readonly handleCollapseClick = () => { - this.context.discussionCollapsed.set(this.props.discussion.id, !this.collapsed); + this.props.discussionsState?.discussionCollapsed.set(this.props.discussion.id, !this.collapsed); }; @action private readonly handleSetHighlight = (e: React.MouseEvent) => { - if (e.defaultPrevented) return; - this.context.highlightedDiscussionId = this.props.discussion.id; + if (e.defaultPrevented || this.props.discussionsState == null) return; + this.props.discussionsState.highlightedDiscussionId = this.props.discussion.id; }; private isOwner(object: { user_id: number }) { @@ -180,15 +189,15 @@ export class Discussion extends React.Component { } private isRead(post: BeatmapsetDiscussionPostJson) { - return this.props.readPostIds?.has(post.id) || this.isOwner(post) || this.props.preview; + return this.readPostIds?.has(post.id) || this.isOwner(post) || this.readonly; } private isVisible(object: BeatmapsetDiscussionJson | BeatmapsetDiscussionPostJson) { - return object != null && (this.props.showDeleted || object.deleted_at == null); + return object != null && (this.showDeleted || object.deleted_at == null); } private postFooter() { - if (this.props.preview) return null; + if (this.props.discussionsState == null) return null; let cssClasses = `${bn}__expanded`; if (this.collapsed) { @@ -200,11 +209,10 @@ export class Discussion extends React.Component {
{this.props.discussion.posts.slice(1).map(this.renderReply)}
- {this.canBeRepliedTo && ( + {this.props.discussionsState != null && this.canBeRepliedTo && ( )}
@@ -212,7 +220,7 @@ export class Discussion extends React.Component { } private renderPost(post: BeatmapsetDiscussionPostJson, type: 'discussion' | 'reply') { - const user = this.props.users[post.user_id] ?? deletedUserJson; + const user = this.users.get(post.user_id) ?? deletedUserJson; if (post.system) { return ( @@ -223,24 +231,25 @@ export class Discussion extends React.Component { return ( ); } private renderPostButtons() { - if (this.props.preview) return null; + if (this.props.discussionsState == null) { + return null; + } - const user = this.props.users[this.props.discussion.user_id]; + const user = this.props.store.users.get(this.props.discussion.user_id); return (
@@ -260,7 +269,8 @@ export class Discussion extends React.Component { - )} - - {this.deleteModel.deleted_at == null && this.canDelete && ( - - {trans('beatmaps.discussions.delete')} - - )} - - {this.deleteModel.deleted_at != null && this.canModerate && ( - - {trans('beatmaps.discussions.restore')} - - )} - - {this.props.type === 'discussion' && this.props.discussion.current_user_attributes?.can_moderate_kudosu && ( - this.props.discussion.can_grant_kudosu - ? this.renderKudosuAction('deny') - : this.props.discussion.kudosu_denied && this.renderKudosuAction('allow') - )} - - )} + {this.renderMessageViewerEditingActions()} {this.canReport && ( { ); } + private renderMessageViewerEditingActions() { + if (this.props.readonly || this.props.discussionsState == null) return; + + return ( + <> + {this.canEdit && ( + + )} + + {this.deleteModel.deleted_at == null && this.canDelete && ( + + {trans('beatmaps.discussions.delete')} + + )} + + {this.deleteModel.deleted_at != null && this.canModerate && ( + + {trans('beatmaps.discussions.restore')} + + )} + + {this.props.type === 'discussion' && this.props.discussion.current_user_attributes?.can_moderate_kudosu && ( + this.props.discussion.can_grant_kudosu + ? this.renderKudosuAction('deny') + : this.props.discussion.kudosu_denied && this.renderKudosuAction('allow') + )} + + ); + } @action private readonly updatePost = () => { @@ -481,7 +483,7 @@ export default class Post extends React.Component { this.xhr.done((beatmapset) => runInAction(() => { this.editing = false; - $.publish('beatmapsetDiscussions:update', { beatmapset }); + this.props.discussionsState?.update({ beatmapset }); })) .fail(onError) .always(action(() => this.xhr = null)); diff --git a/resources/js/beatmap-discussions/review-document.ts b/resources/js/beatmap-discussions/review-document.ts index dd1a9a2ecd8..c90e63f5c41 100644 --- a/resources/js/beatmap-discussions/review-document.ts +++ b/resources/js/beatmap-discussions/review-document.ts @@ -1,7 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. // See the LICENCE file in the repository root for full licence text. -import { BeatmapsetDiscussionJsonForBundle, BeatmapsetDiscussionJsonForShow } from 'interfaces/beatmapset-discussion-json'; +import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; import remarkParse from 'remark-parse'; import disableConstructs from 'remark-plugins/disable-constructs'; import { Element, Text } from 'slate'; @@ -30,7 +30,7 @@ function isText(node: UnistNode): node is TextNode { return node.type === 'text'; } -export function parseFromJson(json: string, discussions: Partial>) { +export function parseFromJson(json: string, discussions: Map) { let srcDoc: BeatmapDiscussionReview; try { @@ -87,7 +87,7 @@ export function parseFromJson(json: string, discussions: Partial { - const bn = 'beatmap-discussion-review-post-embed-preview'; - const discussions = React.useContext(DiscussionsContext); - const beatmaps = React.useContext(BeatmapsContext); - const discussion = discussions[data.discussion_id]; +const bn = 'beatmap-discussion-review-post-embed-preview'; - if (!discussion) { +export const ReviewPostEmbed = ({ data, store }: Props) => { + const beatmaps = store.beatmaps; + const discussion = store.discussions.get(data.discussion_id); + + if (discussion == null) { // if a discussion has been deleted or is otherwise missing return (
@@ -43,12 +43,12 @@ export const ReviewPostEmbed = ({ data }: Props) => { } const post = startingPost(discussion); - if (post.system) { - console.error('embed should not have system starting post', discussion.id); + if (post == null || post.system) { + console.error('embed starting post is missing or is system post', discussion.id); return null; } - const beatmap = discussion.beatmap_id == null ? undefined : beatmaps[discussion.beatmap_id]; + const beatmap = discussion.beatmap_id == null ? undefined : beatmaps.get(discussion.beatmap_id); const messageTypeIcon = () => { const type = discussion.message_type; diff --git a/resources/js/beatmap-discussions/review-post.tsx b/resources/js/beatmap-discussions/review-post.tsx index 5335fd94ffa..caf6e9cceba 100644 --- a/resources/js/beatmap-discussions/review-post.tsx +++ b/resources/js/beatmap-discussions/review-post.tsx @@ -3,12 +3,14 @@ import { PersistedBeatmapDiscussionReview } from 'interfaces/beatmap-discussion-review'; import { BeatmapsetDiscussionMessagePostJson } from 'interfaces/beatmapset-discussion-post-json'; +import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; import * as React from 'react'; import DiscussionMessage from './discussion-message'; import { ReviewPostEmbed } from './review-post-embed'; interface Props { post: BeatmapsetDiscussionMessagePostJson; + store: BeatmapsetDiscussionsStore; } export class ReviewPost extends React.Component { @@ -27,7 +29,7 @@ export class ReviewPost extends React.Component { } case 'embed': if (block.discussion_id) { - docBlocks.push(); + docBlocks.push(); } break; } diff --git a/resources/js/beatmap-discussions/subscribe.tsx b/resources/js/beatmap-discussions/subscribe.tsx index e0ebdc6050e..1e69e9cc939 100644 --- a/resources/js/beatmap-discussions/subscribe.tsx +++ b/resources/js/beatmap-discussions/subscribe.tsx @@ -9,9 +9,11 @@ import { observer } from 'mobx-react'; import * as React from 'react'; import { onError } from 'utils/ajax'; import { trans } from 'utils/lang'; +import DiscussionsState from './discussions-state'; interface Props { beatmapset: BeatmapsetJson; + discussionsState: DiscussionsState; } @observer @@ -60,7 +62,7 @@ export class Subscribe extends React.Component { }); this.xhr.done(() => { - $.publish('beatmapsetDiscussions:update', { watching: !this.isWatching }); + this.props.discussionsState.update({ watching: !this.isWatching }); }) .fail(onError) .always(action(() => this.xhr = null)); diff --git a/resources/js/beatmap-discussions/type-filters.tsx b/resources/js/beatmap-discussions/type-filters.tsx new file mode 100644 index 00000000000..c516f5de53f --- /dev/null +++ b/resources/js/beatmap-discussions/type-filters.tsx @@ -0,0 +1,86 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import { kebabCase, snakeCase } from 'lodash'; +import { computed } from 'mobx'; +import { observer } from 'mobx-react'; +import core from 'osu-core-singleton'; +import * as React from 'react'; +import { makeUrl } from 'utils/beatmapset-discussion-helper'; +import { classWithModifiers } from 'utils/css'; +import { trans } from 'utils/lang'; +import { Filter } from './current-discussions'; +import DiscussionsState from './discussions-state'; + +interface Props { + discussionsState: DiscussionsState; +} + +const bn = 'counter-box'; +const statTypes: Filter[] = ['mine', 'mapperNotes', 'resolved', 'pending', 'praises', 'deleted', 'total']; + +@observer +export default class TypeFilters extends React.Component { + @computed + private get discussionCounts() { + const counts: Partial> = {}; + const selectedUserId = this.props.discussionsState.selectedUserId; + + for (const type of statTypes) { + let discussions = this.props.discussionsState.discussionsByFilter[type]; + if (selectedUserId != null) { + discussions = discussions.filter((discussion) => discussion.user_id === selectedUserId); + } + + counts[type] = discussions.length; + } + + return counts; + } + + render() { + return statTypes.map(this.renderType); + } + + private readonly renderType = (type: Filter) => { + if ((type === 'deleted') && !core.currentUser?.is_admin) { + return null; + } + + let topClasses = classWithModifiers(bn, 'beatmap-discussions', kebabCase(type)); + if (this.props.discussionsState.currentPage !== 'events' && this.props.discussionsState.currentFilter === type) { + topClasses += ' js-active'; + } + + return ( + +
+
+ {trans(`beatmaps.discussions.stats.${snakeCase(type)}`)} +
+
+ {this.discussionCounts[type]} +
+
+
+ + ); + }; + + private readonly setFilter = (event: React.SyntheticEvent) => { + event.preventDefault(); + this.props.discussionsState.changeFilter(event.currentTarget.dataset.type); + }; +} + diff --git a/resources/js/beatmap-discussions/user-filter.tsx b/resources/js/beatmap-discussions/user-filter.tsx index 8922d35b5ca..840b529a6e3 100644 --- a/resources/js/beatmap-discussions/user-filter.tsx +++ b/resources/js/beatmap-discussions/user-filter.tsx @@ -3,11 +3,17 @@ import mapperGroup from 'beatmap-discussions/mapper-group'; import SelectOptions, { OptionRenderProps } from 'components/select-options'; +import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; import UserJson from 'interfaces/user-json'; +import { action, computed, makeObservable } from 'mobx'; +import { observer } from 'mobx-react'; +import { usernameSortAscending } from 'models/user'; import * as React from 'react'; +import { mobxArrayGet } from 'utils/array'; import { makeUrl, parseUrl } from 'utils/beatmapset-discussion-helper'; import { groupColour } from 'utils/css'; import { trans } from 'utils/lang'; +import DiscussionsState from './discussions-state'; const allUsers = Object.freeze({ id: null, @@ -21,14 +27,13 @@ const noSelection = Object.freeze({ interface Option { groups: UserJson['groups']; - id: UserJson['id']; + id: UserJson['id'] | null; text: UserJson['username']; } interface Props { - ownerId: number; - selectedUser?: UserJson | null; - users: UserJson[]; + discussionsState: DiscussionsState; + store: BeatmapsetDiscussionsStore; } function mapUserProperties(user: UserJson): Option { @@ -39,15 +44,42 @@ function mapUserProperties(user: UserJson): Option { }; } +@observer export class UserFilter extends React.Component { + private get ownerId() { + return this.props.discussionsState.beatmapset.user_id; + } + + @computed private get selected() { - return this.props.selectedUser != null - ? mapUserProperties(this.props.selectedUser) + return this.props.discussionsState.selectedUser != null + ? mapUserProperties(this.props.discussionsState.selectedUser) : noSelection; } + @computed private get options() { - return [allUsers, ...this.props.users.map(mapUserProperties)]; + const usersWithDicussions = new Map(); + for (const [, discussion] of this.props.store.discussions) { + if (discussion.message_type === 'hype') continue; + + const user = this.props.store.users.get(discussion.user_id); + if (user != null && !usersWithDicussions.has(user.id)) { + usersWithDicussions.set(user.id, user); + } + } + + return [ + allUsers, + ...[...usersWithDicussions.values()] + .sort(usernameSortAscending) + .map(mapUserProperties), + ]; + } + + constructor(props: Props) { + super(props); + makeObservable(this); } render() { @@ -62,27 +94,34 @@ export class UserFilter extends React.Component { ); } + private getGroup(option: Option) { + if (this.isOwner(option)) return mapperGroup; + + return mobxArrayGet(option.groups, 0); + } + + @action private readonly handleChange = (option: Option) => { - $.publish('beatmapsetDiscussions:update', { selectedUserId: option.id }); + this.props.discussionsState.selectedUserId = option.id; }; private isOwner(user?: Option) { - return user != null && user.id === this.props.ownerId; + return user != null && user.id === this.ownerId; } private readonly renderOption = ({ cssClasses, children, onClick, option }: OptionRenderProps
- {this.props.score.mods.map((mod) => )} + {filterMods(this.props.score).map((mod) => )}
diff --git a/resources/js/components/beatmapset-event.tsx b/resources/js/components/beatmapset-event.tsx index f91bee2efbc..9430b0a37ce 100644 --- a/resources/js/components/beatmapset-event.tsx +++ b/resources/js/components/beatmapset-event.tsx @@ -9,7 +9,7 @@ import BeatmapsetEventJson from 'interfaces/beatmapset-event-json'; import UserJson from 'interfaces/user-json'; import { route } from 'laroute'; import { kebabCase } from 'lodash'; -import { deletedUser } from 'models/user'; +import { deletedUser, deletedUserJson } from 'models/user'; import * as React from 'react'; import { makeUrl } from 'utils/beatmapset-discussion-helper'; import { classWithModifiers } from 'utils/css'; @@ -27,11 +27,11 @@ function simpleKebab(str: string | number | undefined) { export type EventViewMode = 'discussions' | 'profile' | 'list'; interface Props { - discussions?: Partial>; + discussions?: Map; event: BeatmapsetEventJson; mode: EventViewMode; time?: string; - users: Partial>; + users: Map; } export default class BeatmapsetEvent extends React.PureComponent { @@ -48,7 +48,7 @@ export default class BeatmapsetEvent extends React.PureComponent { // discussion page doesn't include the discussion as part of the event. private get discussion() { - return this.props.event.discussion ?? this.props.discussions?.[this.discussionId ?? '']; + return this.props.event.discussion ?? this.props.discussions?.get(this.discussionId); } private get firstPost() { @@ -129,11 +129,10 @@ export default class BeatmapsetEvent extends React.PureComponent { url = makeUrl({ discussion: this.discussion }); text = firstPostMessage != null ? : '[no preview]'; - const discussionUser = this.props.users[this.discussion.user_id]; + const discussionUser = this.props.users.get(this.discussion.user_id) ?? deletedUserJson; - if (discussionUser != null) { - discussionUserLink = ; - } + // TODO: remove link for deleted user? + discussionUserLink = ; } discussionLink = {`#${this.discussionId}`}; @@ -148,7 +147,7 @@ export default class BeatmapsetEvent extends React.PureComponent { } if (this.props.event.user_id != null) { - const userData = this.props.users[this.props.event.user_id]; + const userData = this.props.users.get(this.props.event.user_id); user = userData != null ? : deletedUser.username; } diff --git a/resources/js/components/beatmapset-events.tsx b/resources/js/components/beatmapset-events.tsx index 6ec865d6959..2acd68c32a6 100644 --- a/resources/js/components/beatmapset-events.tsx +++ b/resources/js/components/beatmapset-events.tsx @@ -9,7 +9,7 @@ import * as React from 'react'; export interface Props { events: BeatmapsetEventJson[]; mode: EventViewMode; - users: Partial>; + users: Map; } export default class BeatmapsetEvents extends React.PureComponent { diff --git a/resources/js/entrypoints/beatmap-discussions-history.tsx b/resources/js/entrypoints/beatmap-discussions-history.tsx index ca935aff027..1e500acc8ec 100644 --- a/resources/js/entrypoints/beatmap-discussions-history.tsx +++ b/resources/js/entrypoints/beatmap-discussions-history.tsx @@ -8,5 +8,5 @@ import React from 'react'; import { parseJson } from 'utils/json'; core.reactTurbolinks.register('beatmap-discussions-history', () => ( -
('json-index')} /> +
('json-index')} /> )); diff --git a/resources/js/entrypoints/beatmap-discussions.coffee b/resources/js/entrypoints/beatmap-discussions.coffee deleted file mode 100644 index fddd6fa3527..00000000000 --- a/resources/js/entrypoints/beatmap-discussions.coffee +++ /dev/null @@ -1,12 +0,0 @@ -# Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. -# See the LICENCE file in the repository root for full licence text. - -import core from 'osu-core-singleton' -import { createElement } from 'react' -import { parseJson } from 'utils/json' -import { Main } from 'beatmap-discussions/main' - -core.reactTurbolinks.register 'beatmap-discussions', (container) -> - createElement Main, - initial: parseJson 'json-beatmapset-discussion' - container: container diff --git a/resources/js/entrypoints/beatmap-discussions.tsx b/resources/js/entrypoints/beatmap-discussions.tsx new file mode 100644 index 00000000000..869a45c2974 --- /dev/null +++ b/resources/js/entrypoints/beatmap-discussions.tsx @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import Main from 'beatmap-discussions/main'; +import core from 'osu-core-singleton'; +import React from 'react'; +import { parseJson } from 'utils/json'; + + +core.reactTurbolinks.register('beatmap-discussions', () => ( +
+)); diff --git a/resources/js/entrypoints/modding-profile.tsx b/resources/js/entrypoints/modding-profile.tsx index 9574db1b5cc..4d43cf22411 100644 --- a/resources/js/entrypoints/modding-profile.tsx +++ b/resources/js/entrypoints/modding-profile.tsx @@ -1,22 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. // See the LICENCE file in the repository root for full licence text. +import { BeatmapsetDiscussionsBundleJsonForModdingProfile } from 'interfaces/beatmapset-discussions-bundle-json'; import Main from 'modding-profile/main'; import core from 'osu-core-singleton'; import React from 'react'; import { parseJson } from 'utils/json'; core.reactTurbolinks.register('modding-profile', () => ( -
+
('json-bundle')} /> )); diff --git a/resources/js/interfaces/beatmapset-discussions-bundle-json.ts b/resources/js/interfaces/beatmapset-discussions-bundle-json.ts index 3e526e243d5..168f907fe67 100644 --- a/resources/js/interfaces/beatmapset-discussions-bundle-json.ts +++ b/resources/js/interfaces/beatmapset-discussions-bundle-json.ts @@ -1,10 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. // See the LICENCE file in the repository root for full licence text. +import { Direction, VoteSummary } from 'modding-profile/votes'; import BeatmapExtendedJson from './beatmap-extended-json'; import { BeatmapsetDiscussionJsonForBundle } from './beatmapset-discussion-json'; +import { BeatmapsetDiscussionMessagePostJson } from './beatmapset-discussion-post-json'; +import BeatmapsetEventJson from './beatmapset-event-json'; import BeatmapsetExtendedJson from './beatmapset-extended-json'; +import KudosuHistoryJson from './kudosu-history-json'; import UserJson from './user-json'; +import UserModdingProfileJson from './user-modding-profile-json'; export default interface BeatmapsetDiscussionsBundleJson { beatmaps: BeatmapExtendedJson[]; @@ -13,3 +18,20 @@ export default interface BeatmapsetDiscussionsBundleJson { included_discussions: BeatmapsetDiscussionJsonForBundle[]; users: UserJson[]; } + +export interface BeatmapsetDiscussionsBundleJsonForModdingProfile { + beatmaps: BeatmapExtendedJson[]; + beatmapsets: BeatmapsetExtendedJson[]; + discussions: BeatmapsetDiscussionJsonForBundle[]; + events: BeatmapsetEventJson[]; + extras: { + recentlyReceivedKudosu: KudosuHistoryJson[]; + }; + perPage: { + recentlyReceivedKudosu: number; + }; + posts: BeatmapsetDiscussionMessagePostJson[]; + user: UserModdingProfileJson; + users: UserJson[]; + votes: Record; +} diff --git a/resources/js/interfaces/beatmapset-discussions-store.ts b/resources/js/interfaces/beatmapset-discussions-store.ts new file mode 100644 index 00000000000..45dd9594cae --- /dev/null +++ b/resources/js/interfaces/beatmapset-discussions-store.ts @@ -0,0 +1,14 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; +import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; +import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; +import UserJson from 'interfaces/user-json'; + +export default interface BeatmapsetDiscussionsStore { + beatmaps: Map; + beatmapsets: Map; + discussions: Map; + users: Map; +} diff --git a/resources/js/interfaces/beatmapset-with-discussions-json.ts b/resources/js/interfaces/beatmapset-with-discussions-json.ts index 96e76d3b886..f6ab563dc00 100644 --- a/resources/js/interfaces/beatmapset-with-discussions-json.ts +++ b/resources/js/interfaces/beatmapset-with-discussions-json.ts @@ -1,9 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. // See the LICENCE file in the repository root for full licence text. +import { BeatmapsetDiscussionJsonForShow } from './beatmapset-discussion-json'; import BeatmapsetExtendedJson from './beatmapset-extended-json'; -type DiscussionsRequiredAttributes = 'beatmaps' | 'current_user_attributes' | 'discussions' | 'events' | 'nominations' | 'related_users'; -type BeatmapsetWithDiscussionsJson = BeatmapsetExtendedJson & Required>; +type DiscussionsRequiredAttributes = 'beatmaps' | 'current_user_attributes' | 'events' | 'nominations' | 'related_users'; +type BeatmapsetWithDiscussionsJson = + Omit + & OverrideIncludes + & Required>; + +interface OverrideIncludes { + discussions: BeatmapsetDiscussionJsonForShow[]; +} export default BeatmapsetWithDiscussionsJson; diff --git a/resources/js/interfaces/solo-score-json.ts b/resources/js/interfaces/solo-score-json.ts index d33708bdaa1..b16d3055f87 100644 --- a/resources/js/interfaces/solo-score-json.ts +++ b/resources/js/interfaces/solo-score-json.ts @@ -34,12 +34,13 @@ type SoloScoreJsonDefaultAttributes = { has_replay: boolean; id: number; legacy_score_id: number | null; - legacy_total_score: number | null; + legacy_total_score: number; max_combo: number; mods: ScoreModJson[]; passed: boolean; pp: number | null; rank: Rank; + ranked?: boolean; ruleset_id: number; started_at: string | null; statistics: Partial>; diff --git a/resources/js/interfaces/user-preferences-json.ts b/resources/js/interfaces/user-preferences-json.ts index 06fcc5df47a..810154ec1d3 100644 --- a/resources/js/interfaces/user-preferences-json.ts +++ b/resources/js/interfaces/user-preferences-json.ts @@ -16,6 +16,7 @@ export const defaultUserPreferencesJson: UserPreferencesJson = { comments_show_deleted: false, comments_sort: 'new', forum_posts_show_deleted: true, + legacy_score_only: true, profile_cover_expanded: true, user_list_filter: 'all', user_list_sort: 'last_visit', @@ -33,6 +34,7 @@ export default interface UserPreferencesJson { comments_show_deleted: boolean; comments_sort: string; forum_posts_show_deleted: boolean; + legacy_score_only: boolean; profile_cover_expanded: boolean; user_list_filter: Filter; user_list_sort: SortMode; diff --git a/resources/js/modding-profile/discussions.tsx b/resources/js/modding-profile/discussions.tsx index 27d8c2e71fd..84179a5089a 100644 --- a/resources/js/modding-profile/discussions.tsx +++ b/resources/js/modding-profile/discussions.tsx @@ -1,25 +1,24 @@ // Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. // See the LICENCE file in the repository root for full licence text. -import { BeatmapsContext } from 'beatmap-discussions/beatmaps-context'; -import { BeatmapsetsContext } from 'beatmap-discussions/beatmapsets-context'; import { Discussion } from 'beatmap-discussions/discussion'; import BeatmapsetCover from 'components/beatmapset-cover'; -import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; import { BeatmapsetDiscussionJsonForBundle } from 'interfaces/beatmapset-discussion-json'; -import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; +import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; import UserJson from 'interfaces/user-json'; import { route } from 'laroute'; +import { observer } from 'mobx-react'; import React from 'react'; import { makeUrl } from 'utils/beatmapset-discussion-helper'; import { trans } from 'utils/lang'; interface Props { discussions: BeatmapsetDiscussionJsonForBundle[]; + store: BeatmapsetDiscussionsStore; user: UserJson; - users: Partial>; } +@observer export default class Discussions extends React.Component { render() { return ( @@ -29,46 +28,32 @@ export default class Discussions extends React.Component { {this.props.discussions.length === 0 ? (
{trans('users.show.extra.none')}
) : ( - - {(beatmapsets) => ( - - {(beatmaps) => ( - <> - {this.props.discussions.map((discussion) => this.renderDiscussion(discussion, beatmapsets, beatmaps))} - - {trans('users.show.extra.discussions.show_more')} - - - )} - - )} - + <> + {this.props.discussions.map((discussion) => this.renderDiscussion(discussion))} + + {trans('users.show.extra.discussions.show_more')} + + )}
); } - private renderDiscussion(discussion: BeatmapsetDiscussionJsonForBundle, beatmapsets: Partial>, beatmaps: Partial>) { - const beatmapset = beatmapsets[discussion.beatmapset_id]; - const currentBeatmap = discussion.beatmap_id != null ? beatmaps[discussion.beatmap_id] : null; - + private renderDiscussion(discussion: BeatmapsetDiscussionJsonForBundle) { + const beatmapset = this.props.store.beatmapsets.get(discussion.beatmapset_id); if (beatmapset == null) return null; return ( ); diff --git a/resources/js/modding-profile/events.tsx b/resources/js/modding-profile/events.tsx index 7e1a8c7d5e4..b25fb56a8b2 100644 --- a/resources/js/modding-profile/events.tsx +++ b/resources/js/modding-profile/events.tsx @@ -11,7 +11,7 @@ import { trans } from 'utils/lang'; interface Props { events: BeatmapsetEventJson[]; user: UserJson; - users: Partial>; + users: Map; } export default class Events extends React.Component { diff --git a/resources/js/modding-profile/main.tsx b/resources/js/modding-profile/main.tsx index 3c5f2adf89b..69da74a402d 100644 --- a/resources/js/modding-profile/main.tsx +++ b/resources/js/modding-profile/main.tsx @@ -1,63 +1,35 @@ // Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. // See the LICENCE file in the repository root for full licence text. -import { BeatmapsContext } from 'beatmap-discussions/beatmaps-context'; -import { BeatmapsetsContext } from 'beatmap-discussions/beatmapsets-context'; -import { DiscussionsContext } from 'beatmap-discussions/discussions-context'; import HeaderV4 from 'components/header-v4'; import ProfilePageExtraTab from 'components/profile-page-extra-tab'; import ProfileTournamentBanner from 'components/profile-tournament-banner'; import UserProfileContainer from 'components/user-profile-container'; -import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; -import { BeatmapsetDiscussionJsonForBundle } from 'interfaces/beatmapset-discussion-json'; -import { BeatmapsetDiscussionMessagePostJson } from 'interfaces/beatmapset-discussion-post-json'; -import BeatmapsetEventJson from 'interfaces/beatmapset-event-json'; -import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; -import KudosuHistoryJson from 'interfaces/kudosu-history-json'; -import UserJson from 'interfaces/user-json'; -import UserModdingProfileJson from 'interfaces/user-modding-profile-json'; -import { first, isEmpty, keyBy, last, throttle } from 'lodash'; +import { BeatmapsetDiscussionsBundleJsonForModdingProfile } from 'interfaces/beatmapset-discussions-bundle-json'; +import { first, last, throttle } from 'lodash'; import { action, computed, makeObservable, observable } from 'mobx'; import { observer } from 'mobx-react'; import Kudosu from 'modding-profile/kudosu'; -import { deletedUserJson } from 'models/user'; import core from 'osu-core-singleton'; import Badges from 'profile-page/badges'; import Cover from 'profile-page/cover'; import DetailBar from 'profile-page/detail-bar'; import headerLinks from 'profile-page/header-links'; import * as React from 'react'; +import BeatmapsetDiscussionsBundleForModdingProfileStore from 'stores/beatmapset-discussions-for-modding-profile-store'; import { bottomPage } from 'utils/html'; import { nextVal } from 'utils/seq'; import { switchNever } from 'utils/switch-never'; import { currentUrl } from 'utils/turbolinks'; import Discussions from './discussions'; import Events from './events'; -import { Posts } from './posts'; +import Posts from './posts'; import Stats from './stats'; -import Votes, { Direction, VoteSummary } from './votes'; +import Votes from './votes'; // in display order. const moddingExtraPages = ['events', 'discussions', 'posts', 'votes', 'kudosu'] as const; type ModdingExtraPage = (typeof moddingExtraPages)[number]; - -interface Props { - beatmaps: BeatmapExtendedJson[]; - beatmapsets: BeatmapsetExtendedJson[]; - discussions: BeatmapsetDiscussionJsonForBundle[]; - events: BeatmapsetEventJson[]; - extras: { - recentlyReceivedKudosu: KudosuHistoryJson[]; - }; - perPage: { - recentlyReceivedKudosu: number; - }; - posts: BeatmapsetDiscussionMessagePostJson[]; - user: UserModdingProfileJson; - users: UserJson[]; - votes: Record; -} - type Page = ModdingExtraPage | 'main'; function validPage(page: unknown) { @@ -69,7 +41,7 @@ function validPage(page: unknown) { } @observer -export default class Main extends React.Component { +export default class Main extends React.Component { @observable private currentPage: Page = 'main'; private readonly disposers = new Set<(() => void) | undefined>(); private readonly eventId = `users-modding-history-index-${nextVal()}`; @@ -84,26 +56,9 @@ export default class Main extends React.Component { }; private readonly pages = React.createRef(); private readonly pagesOffsetRef = React.createRef(); + @observable private readonly store = new BeatmapsetDiscussionsBundleForModdingProfileStore(this.props); private readonly tabs = React.createRef(); - @computed - private get beatmaps() { - return keyBy(this.props.beatmaps, 'id'); - } - - @computed - private get beatmapsets() { - return keyBy(this.props.beatmapsets, 'id'); - } - - @computed - private get discussions() { - // skipped discussions - // - not privileged (deleted discussion) - // - deleted beatmap - return keyBy(this.props.discussions.filter((d) => !isEmpty(d)), 'id'); - } - private get pagesOffset() { return this.pagesOffsetRef.current; } @@ -112,21 +67,16 @@ export default class Main extends React.Component { return core.stickyHeader.headerHeight + (this.pagesOffset?.getBoundingClientRect().height ?? 0); } - @computed - private get userDiscussions() { - return this.props.discussions.filter((d) => d.user_id === this.props.user.id); + private get user() { + return this.props.user; } @computed - private get users() { - const values = keyBy(this.props.users, 'id'); - // eslint-disable-next-line id-blacklist - values.null = values.undefined = deletedUserJson; - - return values; + private get userDiscussions() { + return [...this.store.discussions.values()].filter((d) => d.user_id === this.props.user.id); } - constructor(props: Props) { + constructor(props: BeatmapsetDiscussionsBundleJsonForModdingProfile) { super(props); makeObservable(this); @@ -155,98 +105,92 @@ export default class Main extends React.Component { render() { return ( - - - - - -
-
- - - {!this.props.user.is_bot && ( - <> - {this.props.user.active_tournament_banners.map((banner) => ( - - ))} -
- -
- - )} - + + +
+
+ + + {!this.user.is_bot && ( + <> + {this.user.active_tournament_banners.map((banner) => ( + + ))} +
+
-
+ )} + +
+
+
+ {moddingExtraPages.map((page) => ( + - -
-
- {moddingExtraPages.map((name) => ( -
- {this.extraPage(name)} -
- ))} -
+ + + ))} +
+
+
+ {moddingExtraPages.map((name) => ( +
+ {this.extraPage(name)}
- - - - + ))} +
+
+
); } private readonly extraPage = (name: ModdingExtraPage) => { switch (name) { case 'discussions': - return ; + return ; case 'events': - return ; + return ; case 'kudosu': return ( ); case 'posts': - return ; + return ; case 'votes': - return ; + return ; default: switchNever(name); throw new Error('unsupported extra page'); diff --git a/resources/js/modding-profile/posts.tsx b/resources/js/modding-profile/posts.tsx index 3fe4a3d0efa..6226fcfef45 100644 --- a/resources/js/modding-profile/posts.tsx +++ b/resources/js/modding-profile/posts.tsx @@ -1,9 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. // See the LICENCE file in the repository root for full licence text. -import { BeatmapsContext } from 'beatmap-discussions/beatmaps-context'; import BeatmapsetCover from 'components/beatmapset-cover'; import { BeatmapsetDiscussionMessagePostJson } from 'interfaces/beatmapset-discussion-post-json'; +import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; import UserJson from 'interfaces/user-json'; import { route } from 'laroute'; import { deletedUserJson } from 'models/user'; @@ -15,14 +15,11 @@ import Post from '../beatmap-discussions/post'; interface Props { posts: BeatmapsetDiscussionMessagePostJson[]; + store: BeatmapsetDiscussionsStore; user: UserJson; - users: Partial>; } -export class Posts extends React.Component { - static contextType = BeatmapsContext; - declare context: React.ContextType; - +export default class Posts extends React.Component { render() { return (
@@ -50,8 +47,6 @@ export class Posts extends React.Component { const discussion = post.beatmap_discussion; if (discussion == null || discussion.beatmapset == null) return; - const beatmap = (discussion.beatmap_id != null ? this.context[discussion.beatmap_id] : null) ?? null; - const discussionClasses = classWithModifiers( 'beatmap-discussion', ['preview', 'modding-profile'], @@ -76,16 +71,14 @@ export class Posts extends React.Component {
diff --git a/resources/js/mp-history/game-header.coffee b/resources/js/mp-history/game-header.coffee index 51ad2bc9633..1a226343adb 100644 --- a/resources/js/mp-history/game-header.coffee +++ b/resources/js/mp-history/game-header.coffee @@ -9,6 +9,7 @@ import * as React from 'react' import { div, a, span, h1, h2 } from 'react-dom-factories' import { getArtist, getTitle } from 'utils/beatmapset-helper' import { trans } from 'utils/lang' +import { filterMods } from 'utils/score-helper' el = React.createElement diff --git a/resources/js/profile-page/play-detail.tsx b/resources/js/profile-page/play-detail.tsx index da1bcb34b1e..0fbe9dc190a 100644 --- a/resources/js/profile-page/play-detail.tsx +++ b/resources/js/profile-page/play-detail.tsx @@ -13,7 +13,7 @@ import { getArtist, getTitle } from 'utils/beatmapset-helper'; import { classWithModifiers } from 'utils/css'; import { formatNumber } from 'utils/html'; import { trans } from 'utils/lang'; -import { hasMenu } from 'utils/score-helper'; +import { accuracy, filterMods, hasMenu, rank } from 'utils/score-helper'; import { beatmapUrl } from 'utils/url'; const bn = 'play-detail'; @@ -52,13 +52,14 @@ export default class PlayDetail extends React.PureComponent { } const scoreWeight = this.props.showPpWeight ? score.weight : null; + const scoreRank = rank(score); return (
{this.renderPinSortableHandle()}
-
+
@@ -86,12 +87,12 @@ export default class PlayDetail extends React.PureComponent {
-
+
- {formatNumber(score.accuracy * 100, 2)}% + {formatNumber(accuracy(score) * 100, 2)}% {scoreWeight != null && ( @@ -111,7 +112,7 @@ export default class PlayDetail extends React.PureComponent {
- {score.mods.map((mod) => )} + {filterMods(score).map((mod) => )}
diff --git a/resources/js/register-components.tsx b/resources/js/register-components.tsx index 0dd1613fd0f..95d60a9f6e9 100644 --- a/resources/js/register-components.tsx +++ b/resources/js/register-components.tsx @@ -19,15 +19,14 @@ import { UserCardStore } from 'components/user-card-store'; import { startListening, UserCardTooltip } from 'components/user-card-tooltip'; import { UserCards } from 'components/user-cards'; import { WikiSearch } from 'components/wiki-search'; -import { keyBy } from 'lodash'; import { observable } from 'mobx'; -import { deletedUserJson } from 'models/user'; import NotificationWidget from 'notification-widget/main'; import core from 'osu-core-singleton'; import QuickSearch from 'quick-search/main'; import QuickSearchWorker from 'quick-search/worker'; import * as React from 'react'; import { parseJson } from 'utils/json'; +import { mapBy } from 'utils/map'; function reqJson(input: string|undefined): T { // This will throw when input is missing and thus parsing empty string. @@ -54,13 +53,9 @@ core.reactTurbolinks.register('beatmap-discussion-events', () => { const props: BeatmapsetEventsProps = { events: parseJson('json-events'), mode: 'list', - users: keyBy(parseJson('json-users'), 'id'), + users: mapBy(parseJson('json-users'), 'id'), }; - // TODO: move to store? - // eslint-disable-next-line id-blacklist - props.users.null = props.users.undefined = deletedUserJson; - return ; }); diff --git a/resources/js/scores-show/info.tsx b/resources/js/scores-show/info.tsx index 2adf0d7129a..e598a775c44 100644 --- a/resources/js/scores-show/info.tsx +++ b/resources/js/scores-show/info.tsx @@ -5,6 +5,7 @@ import BeatmapsetCover from 'components/beatmapset-cover'; import { SoloScoreJsonForShow } from 'interfaces/solo-score-json'; import * as React from 'react'; import { rulesetName } from 'utils/beatmap-helper'; +import { accuracy, rank } from 'utils/score-helper'; import Buttons from './buttons'; import Dial from './dial'; import Player from './player'; @@ -22,11 +23,11 @@ export default function Info({ score }: Props) {
- +
- +
diff --git a/resources/js/scores-show/player.tsx b/resources/js/scores-show/player.tsx index 2a267c67cc4..499637bef9c 100644 --- a/resources/js/scores-show/player.tsx +++ b/resources/js/scores-show/player.tsx @@ -7,7 +7,7 @@ import * as moment from 'moment'; import * as React from 'react'; import { formatNumber } from 'utils/html'; import { trans } from 'utils/lang'; -import { totalScore } from 'utils/score-helper'; +import { filterMods, totalScore } from 'utils/score-helper'; interface Props { score: SoloScoreJsonForShow; @@ -22,7 +22,7 @@ export default function Player(props: Props) {
- {props.score.mods.map((mod) => ( + {filterMods(props.score).map((mod) => (
diff --git a/resources/js/scores/pp-value.tsx b/resources/js/scores/pp-value.tsx index c84c77b8a08..395c203eb72 100644 --- a/resources/js/scores/pp-value.tsx +++ b/resources/js/scores/pp-value.tsx @@ -21,13 +21,16 @@ export default function PpValue(props: Props) { if (!isBest && !isSolo) { title = trans('scores.status.non_best'); content = '-'; + } else if (props.score.ranked === false) { + title = trans('scores.status.no_pp'); + content = '-'; } else if (props.score.pp == null) { if (isSolo && !props.score.passed) { title = trans('scores.status.non_passing'); content = '-'; } else { title = trans('scores.status.processing'); - content = ; + content = ; } } else { title = formatNumber(props.score.pp); diff --git a/resources/js/stores/beatmapset-discussions-bundle-store.ts b/resources/js/stores/beatmapset-discussions-bundle-store.ts new file mode 100644 index 00000000000..0b408683fc7 --- /dev/null +++ b/resources/js/stores/beatmapset-discussions-bundle-store.ts @@ -0,0 +1,38 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import BeatmapsetDiscussionsBundleJson from 'interfaces/beatmapset-discussions-bundle-json'; +import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; +import { computed, makeObservable, observable } from 'mobx'; +import { mapBy, mapByWithNulls } from 'utils/map'; + +export default class BeatmapsetDiscussionsBundleStore implements BeatmapsetDiscussionsStore { + /** TODO: accessor; readonly */ + @observable bundle; + + @computed + get beatmaps() { + return mapBy(this.bundle.beatmaps, 'id'); + } + + @computed + get beatmapsets() { + return mapBy(this.bundle.beatmapsets, 'id'); + } + + @computed + get discussions() { + // TODO: add bundle.discussions? + return mapByWithNulls(this.bundle.included_discussions, 'id'); + } + + @computed + get users() { + return mapByWithNulls(this.bundle.users, 'id'); + } + + constructor(bundle: BeatmapsetDiscussionsBundleJson) { + this.bundle = bundle; + makeObservable(this); + } +} diff --git a/resources/js/stores/beatmapset-discussions-for-modding-profile-store.ts b/resources/js/stores/beatmapset-discussions-for-modding-profile-store.ts new file mode 100644 index 00000000000..d36fa48cc96 --- /dev/null +++ b/resources/js/stores/beatmapset-discussions-for-modding-profile-store.ts @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import { BeatmapsetDiscussionsBundleJsonForModdingProfile } from 'interfaces/beatmapset-discussions-bundle-json'; +import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; +import { computed, makeObservable, observable } from 'mobx'; +import { mapBy, mapByWithNulls } from 'utils/map'; + +export default class BeatmapsetDiscussionsBundleForModdingProfileStore implements BeatmapsetDiscussionsStore { + /** TODO: accessor; readonly */ + @observable bundle; + + @computed + get beatmaps() { + return mapBy(this.bundle.beatmaps, 'id'); + } + + @computed + get beatmapsets() { + return mapBy(this.bundle.beatmapsets, 'id'); + } + + @computed + get discussions() { + return mapByWithNulls(this.bundle.discussions, 'id'); + } + + @computed + get users() { + return mapByWithNulls(this.bundle.users, 'id'); + } + + constructor(bundle: BeatmapsetDiscussionsBundleJsonForModdingProfile) { + this.bundle = bundle; + makeObservable(this); + } +} diff --git a/resources/js/stores/beatmapset-discussions-show-store.ts b/resources/js/stores/beatmapset-discussions-show-store.ts new file mode 100644 index 00000000000..424c0bb3fad --- /dev/null +++ b/resources/js/stores/beatmapset-discussions-show-store.ts @@ -0,0 +1,52 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; +import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; +import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; +import { computed, makeObservable, observable } from 'mobx'; +import { mapBy, mapByWithNulls } from 'utils/map'; + +export default class BeatmapsetDiscussionsShowStore implements BeatmapsetDiscussionsStore { + @observable beatmapset: BeatmapsetWithDiscussionsJson; + + @computed + get beatmaps() { + const hasDiscussion = new Set(); + for (const discussion of this.beatmapset.discussions) { + if (discussion?.beatmap_id != null) { + hasDiscussion.add(discussion.beatmap_id); + } + } + + return mapBy( + this.beatmapset.beatmaps.filter((beatmap) => beatmap.deleted_at == null || hasDiscussion.has(beatmap.id)), + 'id', + ); + } + + @computed + get beatmapsets() { + return new Map([[this.beatmapset.id, this.beatmapset]]); + } + + @computed + get discussions() { + // skipped discussions + // - not privileged (deleted discussion) + // - deleted beatmap + + // allow null for the key so we can use .get(null) + return mapByWithNulls(this.beatmapset.discussions, 'id'); + } + + @computed + get users() { + return mapByWithNulls(this.beatmapset.related_users, 'id'); + } + + constructor(beatmapset: BeatmapsetWithDiscussionsJson) { + this.beatmapset = beatmapset; + makeObservable(this); + } +} diff --git a/resources/js/utils/array.ts b/resources/js/utils/array.ts new file mode 100644 index 00000000000..b1e4dcbc20f --- /dev/null +++ b/resources/js/utils/array.ts @@ -0,0 +1,6 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +export function mobxArrayGet(array: T[] | null | undefined, index: number): T | undefined { + return array != null && array.length > index ? array[index] : undefined; +} diff --git a/resources/js/utils/beatmapset-discussion-helper.ts b/resources/js/utils/beatmapset-discussion-helper.ts index 1fe0d425fbe..4c69b2f29ca 100644 --- a/resources/js/utils/beatmapset-discussion-helper.ts +++ b/resources/js/utils/beatmapset-discussion-helper.ts @@ -2,11 +2,12 @@ // See the LICENCE file in the repository root for full licence text. import { Filter, filters } from 'beatmap-discussions/current-discussions'; -import DiscussionMode, { DiscussionPage, discussionPages } from 'beatmap-discussions/discussion-mode'; +import DiscussionMode from 'beatmap-discussions/discussion-mode'; +import DiscussionPage, { isDiscussionPage } from 'beatmap-discussions/discussion-page'; import guestGroup from 'beatmap-discussions/guest-group'; import mapperGroup from 'beatmap-discussions/mapper-group'; import BeatmapJson from 'interfaces/beatmap-json'; -import BeatmapsetDiscussionJson, { BeatmapsetDiscussionJsonForBundle, BeatmapsetDiscussionJsonForShow } from 'interfaces/beatmapset-discussion-json'; +import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; import BeatmapsetDiscussionPostJson from 'interfaces/beatmapset-discussion-post-json'; import BeatmapsetJson from 'interfaces/beatmapset-json'; import GameMode, { gameModes } from 'interfaces/game-mode'; @@ -20,8 +21,8 @@ import { linkHtml, openBeatmapEditor } from 'utils/url'; import { getInt } from './math'; interface BadgeGroupParams { - beatmapset: BeatmapsetJson; - currentBeatmap: BeatmapJson | null; + beatmapset?: BeatmapsetJson; + currentBeatmap?: BeatmapJson | null; discussion: BeatmapsetDiscussionJson; user?: UserJson; } @@ -70,7 +71,6 @@ export const defaultFilter = 'total'; // parseUrl and makeUrl lookups const filterLookup = new Set(filters); const generalPages = new Set(['events', 'generalAll', 'reviews']); -const pageLookup = new Set(discussionPages); const defaultBeatmapId = '-'; @@ -89,7 +89,7 @@ export function badgeGroup({ beatmapset, currentBeatmap, discussion, user }: Bad return null; } - if (user.id === beatmapset.user_id) { + if (user.id === beatmapset?.user_id) { return mapperGroup; } @@ -97,7 +97,11 @@ export function badgeGroup({ beatmapset, currentBeatmap, discussion, user }: Bad return guestGroup; } - return user.groups?.[0]; + if (user.groups == null || user.groups.length === 0) { + return null; + } + + return user.groups[0]; } export function canModeratePosts() { @@ -130,11 +134,6 @@ export function formatTimestamp(value: number) { return `${padStart(m.toString(), 2, '0')}:${padStart(s.toString(), 2, '0')}:${padStart(ms.toString(), 3, '0')}`; } - -function isDiscussionPage(value: string): value is DiscussionPage { - return pageLookup.has(value); -} - function isFilter(value: string): value is Filter { return filterLookup.has(value); } @@ -375,12 +374,12 @@ export function propsFromHref(href = '') { } // Workaround for the discussion starting_post typing mess until the response gets refactored and normalized. -export function startingPost(discussion: BeatmapsetDiscussionJsonForBundle | BeatmapsetDiscussionJsonForShow): BeatmapsetDiscussionPostJson { - if (!('posts' in discussion)) { - return discussion.starting_post; +export function startingPost(discussion: BeatmapsetDiscussionJson) { + if ('posts' in discussion && discussion.posts != null) { + return discussion.posts[0]; } - return discussion.posts[0]; + return discussion.starting_post; } export function stateFromDiscussion(discussion: BeatmapsetDiscussionJson) { diff --git a/resources/js/utils/json.ts b/resources/js/utils/json.ts index a8ee15bbc72..9e871be5e1f 100644 --- a/resources/js/utils/json.ts +++ b/resources/js/utils/json.ts @@ -55,8 +55,8 @@ export function jsonClone(obj: T) { * * @param id id of the HTMLScriptElement. */ -export function parseJson(id: string): T { - const json = parseJsonNullable(id); +export function parseJson(id: string, remove = false): T { + const json = parseJsonNullable(id, remove); if (json == null) { throw new Error(`script element ${id} is missing or contains nullish value.`); } @@ -71,10 +71,10 @@ export function parseJson(id: string): T { * @param id id of the HTMLScriptElement. * @param remove true to remove the element after parsing; false, otherwise. */ -export function parseJsonNullable(id: string, remove = false): T | undefined { +export function parseJsonNullable(id: string, remove = false, reviver?: (key: string, value: any) => any): T | undefined { const element = (window.newBody ?? document.body).querySelector(`#${id}`); if (!(element instanceof HTMLScriptElement)) return undefined; - const json = JSON.parse(element.text) as T; + const json = JSON.parse(element.text, reviver) as T; if (remove) { element.remove(); diff --git a/resources/js/utils/legacy-score-helper.ts b/resources/js/utils/legacy-score-helper.ts new file mode 100644 index 00000000000..937c3bb83c7 --- /dev/null +++ b/resources/js/utils/legacy-score-helper.ts @@ -0,0 +1,158 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import Rank from 'interfaces/rank'; +import SoloScoreJson from 'interfaces/solo-score-json'; + +interface CacheEntry { + accuracy: number; + rank: Rank; +} +let cache: Partial> = {}; + +// reset cache on navigation +document.addEventListener('turbolinks:load', () => { + cache = {}; +}); + +function shouldHaveHiddenRank(score: SoloScoreJson) { + return score.mods.some((mod) => mod.acronym === 'FL' || mod.acronym === 'HD'); +} + +export function legacyAccuracyAndRank(score: SoloScoreJson) { + const key = `${score.type}:${score.id}`; + let cached = cache[key]; + + if (cached == null) { + const countMiss = score.statistics.miss ?? 0; + const countGreat = score.statistics.great ?? 0; + + let accuracy: number; + let rank: Rank; + + // Reference: https://github.com/ppy/osu/blob/e3ffea1b127cbd3171010972588a8b07cf049ba0/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs#L170-L274 + switch (score.ruleset_id) { + // osu + case 0: { + const countMeh = score.statistics.meh ?? 0; + const countOk = score.statistics.ok ?? 0; + + const totalHits = countMeh + countOk + countGreat + countMiss; + accuracy = totalHits > 0 + ? (countMeh * 50 + countOk * 100 + countGreat * 300) / (totalHits * 300) + : 1; + + const ratioGreat = totalHits > 0 ? countGreat / totalHits : 1; + const ratioMeh = totalHits > 0 ? countMeh / totalHits : 1; + + if (score.rank === 'F') { + rank = 'F'; + } else if (ratioGreat === 1) { + rank = shouldHaveHiddenRank(score) ? 'XH' : 'X'; + } else if (ratioGreat > 0.9 && ratioMeh <= 0.01 && countMiss === 0) { + rank = shouldHaveHiddenRank(score) ? 'SH' : 'S'; + } else if ((ratioGreat > 0.8 && countMiss === 0) || ratioGreat > 0.9) { + rank = 'A'; + } else if ((ratioGreat > 0.7 && countMiss === 0) || ratioGreat > 0.8) { + rank = 'B'; + } else if (ratioGreat > 0.6) { + rank = 'C'; + } else { + rank = 'D'; + } + break; + } + // taiko + case 1: { + const countOk = score.statistics.ok ?? 0; + + const totalHits = countOk + countGreat + countMiss; + accuracy = totalHits > 0 + ? (countOk * 150 + countGreat * 300) / (totalHits * 300) + : 1; + + const ratioGreat = totalHits > 0 ? countGreat / totalHits : 1; + + if (score.rank === 'F') { + rank = 'F'; + } else if (ratioGreat === 1) { + rank = shouldHaveHiddenRank(score) ? 'XH' : 'X'; + } else if (ratioGreat > 0.9 && countMiss === 0) { + rank = shouldHaveHiddenRank(score) ? 'SH' : 'S'; + } else if ((ratioGreat > 0.8 && countMiss === 0) || ratioGreat > 0.9) { + rank = 'A'; + } else if ((ratioGreat > 0.7 && countMiss === 0) || ratioGreat > 0.8) { + rank = 'B'; + } else if (ratioGreat > 0.6) { + rank = 'C'; + } else { + rank = 'D'; + } + break; + } + // catch + case 2: { + const countLargeTickHit = score.statistics.large_tick_hit ?? 0; + const countSmallTickHit = score.statistics.small_tick_hit ?? 0; + const countSmallTickMiss = score.statistics.small_tick_miss ?? 0; + + const totalHits = countSmallTickHit + countLargeTickHit + countGreat + countMiss + countSmallTickMiss; + accuracy = totalHits > 0 + ? (countSmallTickHit + countLargeTickHit + countGreat) / totalHits + : 1; + + if (score.rank === 'F') { + rank = 'F'; + } else if (accuracy === 1) { + rank = shouldHaveHiddenRank(score) ? 'XH' : 'X'; + } else if (accuracy > 0.98) { + rank = shouldHaveHiddenRank(score) ? 'SH' : 'S'; + } else if (accuracy > 0.94) { + rank = 'A'; + } else if (accuracy > 0.9) { + rank = 'B'; + } else if (accuracy > 0.85) { + rank = 'C'; + } else { + rank = 'D'; + } + break; + } + // mania + case 3: { + const countPerfect = score.statistics.perfect ?? 0; + const countGood = score.statistics.good ?? 0; + const countOk = score.statistics.ok ?? 0; + const countMeh = score.statistics.meh ?? 0; + + const totalHits = countPerfect + countGood + countOk + countMeh + countGreat + countMiss; + accuracy = totalHits > 0 + ? ((countGreat + countPerfect) * 300 + countGood * 200 + countOk * 100 + countMeh * 50) / (totalHits * 300) + : 1; + + if (score.rank === 'F') { + rank = 'F'; + } else if (accuracy === 1) { + rank = shouldHaveHiddenRank(score) ? 'XH' : 'X'; + } else if (accuracy > 0.95) { + rank = shouldHaveHiddenRank(score) ? 'SH' : 'S'; + } else if (accuracy > 0.9) { + rank = 'A'; + } else if (accuracy > 0.8) { + rank = 'B'; + } else if (accuracy > 0.7) { + rank = 'C'; + } else { + rank = 'D'; + } + break; + } + default: + throw new Error('unknown score ruleset'); + } + + cached = cache[key] = { accuracy, rank }; + } + + return cached; +} diff --git a/resources/js/utils/map.ts b/resources/js/utils/map.ts new file mode 100644 index 00000000000..f04c6106a45 --- /dev/null +++ b/resources/js/utils/map.ts @@ -0,0 +1,22 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +export function mapBy(array: T[], key: K) { + const map = new Map(); + + for (const value of array) { + map.set(value[key], value); + } + + return map; +} + +export function mapByWithNulls(array: T[], key: K) { + const map = new Map(); + + for (const value of array) { + map.set(value[key], value); + } + + return map; +} diff --git a/resources/js/utils/score-helper.ts b/resources/js/utils/score-helper.ts index d258d63722d..7e157c2d735 100644 --- a/resources/js/utils/score-helper.ts +++ b/resources/js/utils/score-helper.ts @@ -7,6 +7,15 @@ import { route } from 'laroute'; import core from 'osu-core-singleton'; import { rulesetName } from './beatmap-helper'; import { trans } from './lang'; +import { legacyAccuracyAndRank } from './legacy-score-helper'; + +export function accuracy(score: SoloScoreJson) { + if (score.legacy_score_id == null || !core.userPreferences.get('legacy_score_only')) { + return score.accuracy; + } + + return legacyAccuracyAndRank(score).accuracy; +} export function canBeReported(score: SoloScoreJson) { return (score.best_id != null || score.type === 'solo_score') @@ -14,6 +23,15 @@ export function canBeReported(score: SoloScoreJson) { && score.user_id !== core.currentUser.id; } +// Removes CL mod on legacy score if user has lazer mode disabled +export function filterMods(score: SoloScoreJson) { + if (score.legacy_score_id == null || !core.userPreferences.get('legacy_score_only')) { + return score.mods; + } + + return score.mods.filter((mod) => mod.acronym !== 'CL'); +} + // TODO: move to application state repository thingy later export function hasMenu(score: SoloScoreJson) { return canBeReported(score) || hasReplay(score) || hasShow(score) || core.scorePins.canBePinned(score); @@ -92,6 +110,14 @@ export const modeAttributesMap: Record = { ], }; +export function rank(score: SoloScoreJson) { + if (score.legacy_score_id == null || !core.userPreferences.get('legacy_score_only')) { + return score.rank; + } + + return legacyAccuracyAndRank(score).rank; +} + export function scoreDownloadUrl(score: SoloScoreJson) { if (score.type === 'solo_score') { return route('scores.download', { score: score.id }); @@ -123,5 +149,9 @@ export function scoreUrl(score: SoloScoreJson) { } export function totalScore(score: SoloScoreJson) { - return score.legacy_total_score ?? score.total_score; + if (score.legacy_score_id == null || !core.userPreferences.get('legacy_score_only')) { + return score.total_score; + } + + return score.legacy_total_score; } diff --git a/resources/lang/ar/password_reset.php b/resources/lang/ar/password_reset.php index 53eeb12b106..923e67f01da 100644 --- a/resources/lang/ar/password_reset.php +++ b/resources/lang/ar/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'أدخل اسم المستخدم أو عنوان البريد الإلكتروني', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'تحتاج دعم في المستقبل؟ تواصل معنا على :button.', 'button' => 'نظام الدعم', diff --git a/resources/lang/be/password_reset.php b/resources/lang/be/password_reset.php index 4356149825a..7a34748971a 100644 --- a/resources/lang/be/password_reset.php +++ b/resources/lang/be/password_reset.php @@ -37,6 +37,9 @@ 'starting' => [ 'username' => 'Увядзіце эл. пошту або імя карыстальніка', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Патрэбна дадатковая дапамога? Звяжыцеся з намі :button.', 'button' => 'сістэма падтрымкі', diff --git a/resources/lang/bg/password_reset.php b/resources/lang/bg/password_reset.php index 85e741061aa..029af01dc8a 100644 --- a/resources/lang/bg/password_reset.php +++ b/resources/lang/bg/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Въведете имейл или потребителско име', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Нуждаете се от допълнителна помощ? Свържете се с нашата :button.', 'button' => 'поддръжка', diff --git a/resources/lang/ca/password_reset.php b/resources/lang/ca/password_reset.php index 2dcb3c6da42..5a3fe4ca034 100644 --- a/resources/lang/ca/password_reset.php +++ b/resources/lang/ca/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Adreça electrònica o nom d\'usuari', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Necessites més assistència? Contacta\'ns a través del nostre :button.', 'button' => 'sistema de suport', diff --git a/resources/lang/cs/password_reset.php b/resources/lang/cs/password_reset.php index f7bcc132a84..a6b18ca1eb1 100644 --- a/resources/lang/cs/password_reset.php +++ b/resources/lang/cs/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Zadejte Vaši e-mailovou adresu nebo uživatelské jméno', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Potřebujete další pomoc? Kontaktujte nás prostřednictvím :button.', 'button' => 'systém podpory', diff --git a/resources/lang/da/password_reset.php b/resources/lang/da/password_reset.php index b7baef2d069..e8d33329c42 100644 --- a/resources/lang/da/password_reset.php +++ b/resources/lang/da/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Indtast email-adresse eller brugernavn', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Har du brug for yderligere assistance? Kontakt os via vores :button.', 'button' => 'support system', diff --git a/resources/lang/de/password_reset.php b/resources/lang/de/password_reset.php index e160e18b947..c900c05bd06 100644 --- a/resources/lang/de/password_reset.php +++ b/resources/lang/de/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Benutzername oder E-Mail eingeben', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Benötigst du weitere Hilfe? Kontaktiere uns über unser :button.', 'button' => 'Supportsystem', diff --git a/resources/lang/el/password_reset.php b/resources/lang/el/password_reset.php index f35077e7d7e..44a0c46afc4 100644 --- a/resources/lang/el/password_reset.php +++ b/resources/lang/el/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Εισάγετε τη διεύθυνση ηλεκτρονικού ταχυδρομείου ή το όνομα χρήστη', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Χρειάζεστε περαιτέρω βοήθεια? Επικοινωνήστε μαζί μας μέσω :button.', 'button' => 'σύστημα υποστήριξης', diff --git a/resources/lang/en/layout.php b/resources/lang/en/layout.php index 3f642aff3ac..5a59a7c25cc 100644 --- a/resources/lang/en/layout.php +++ b/resources/lang/en/layout.php @@ -195,6 +195,8 @@ 'account-edit' => 'Settings', 'follows' => 'Watchlists', 'friends' => 'Friends', + 'legacy_score_only_toggle' => 'Lazer mode', + 'legacy_score_only_toggle_tooltip' => 'Lazer mode shows scores set from lazer with a new scoring algorithm', 'logout' => 'Sign Out', 'profile' => 'My Profile', ], diff --git a/resources/lang/en/scores.php b/resources/lang/en/scores.php index e55b8aa5437..b6511efbae9 100644 --- a/resources/lang/en/scores.php +++ b/resources/lang/en/scores.php @@ -25,6 +25,7 @@ 'status' => [ 'non_best' => 'Only personal best scores award pp', 'non_passing' => 'Only passing scores award pp', + 'no_pp' => 'pp is not awarded for this score', 'processing' => 'This score is still being calculated and will be displayed soon', ], ]; diff --git a/resources/lang/es/password_reset.php b/resources/lang/es/password_reset.php index 1e261ad1362..7b1a4fbf5dd 100644 --- a/resources/lang/es/password_reset.php +++ b/resources/lang/es/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Ingrese correo o nombre de usuario', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => '¿Necesita asistencia? Contáctenos a través de nuestro :button.', 'button' => 'sistema de soporte', diff --git a/resources/lang/fa-IR/password_reset.php b/resources/lang/fa-IR/password_reset.php index ece9d05648a..5fd9ed7fcbb 100644 --- a/resources/lang/fa-IR/password_reset.php +++ b/resources/lang/fa-IR/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'ایمیل یا نام کاربری را وارد کتید', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'نیاز به کمک بیشتر دارید؟ با ما توسط :button تماس بگیرید.', 'button' => 'سیستم پشتیبانی', diff --git a/resources/lang/fi/beatmaps.php b/resources/lang/fi/beatmaps.php index 87c1ab69a9d..2211fd1edcc 100644 --- a/resources/lang/fi/beatmaps.php +++ b/resources/lang/fi/beatmaps.php @@ -293,7 +293,7 @@ 'pending' => 'Vireillä', 'wip' => 'Työn alla', 'qualified' => 'Kelpuutettu', - 'ranked' => 'Pisteytetty', + 'ranked' => 'Rankattu', ], 'genre' => [ 'any' => 'Kaikki', diff --git a/resources/lang/fi/beatmapsets.php b/resources/lang/fi/beatmapsets.php index e5c088fc4cd..d0f22c69046 100644 --- a/resources/lang/fi/beatmapsets.php +++ b/resources/lang/fi/beatmapsets.php @@ -185,7 +185,7 @@ 'friend' => 'Kukaan kavereistasi ei vielä ole saanut tulosta tässä mapissa!', 'global' => 'Tuloksia ei ole. Voisit hankkia niitä.', 'loading' => 'Ladataan tuloksia...', - 'unranked' => 'Pisteyttämätön rytmikartta.', + 'unranked' => 'Rankkaamaton rytmikartta.', ], 'score' => [ 'first' => 'Johdossa', diff --git a/resources/lang/fi/community.php b/resources/lang/fi/community.php index 8de5c09f624..9162986bd85 100644 --- a/resources/lang/fi/community.php +++ b/resources/lang/fi/community.php @@ -32,12 +32,12 @@ 'description' => 'Lahjoituksesi auttavat pitämään pelin itsenäisenä ja täysin vapaana mainoksista ja ulkopuolisista sponsoreista.', ], 'tournaments' => [ - 'title' => 'Virallisiin turnauksiin', - 'description' => 'Auta osu! maailmancup -turnausten ylläpidon (sekä palkintojen) rahoituksessa.', + 'title' => 'Viralliset turnaukset', + 'description' => 'Auta osu!-maailmancup -turnausten ylläpidon (sekä palkintojen) rahoituksessa.', 'link_text' => 'Selaa turnauksia »', ], 'bounty-program' => [ - 'title' => 'Avoimen lähdekoodin palkkio -ohjelmaan', + 'title' => 'Avoimen lähdekoodin palkkio -ohjelma', 'description' => 'Tue yhteisön osallistujia, jotka ovat käyttäneet aikaansa ja vaivaansa tekemään osu!sta paremman.', 'link_text' => 'Lue lisää »', ], diff --git a/resources/lang/fi/layout.php b/resources/lang/fi/layout.php index 9fc19b95199..e6369bc3541 100644 --- a/resources/lang/fi/layout.php +++ b/resources/lang/fi/layout.php @@ -75,7 +75,7 @@ ], 'help' => [ '_' => 'apua', - 'getAbuse' => 'ilmoita häirinnästä', + 'getAbuse' => 'ilmoita väärinkäytöstä', 'getFaq' => 'usein kysytyt', 'getRules' => 'säännöt', 'getSupport' => 'tarvitsen siis oikeasti apua!', diff --git a/resources/lang/fi/password_reset.php b/resources/lang/fi/password_reset.php index d6789d54c55..78ff6802e42 100644 --- a/resources/lang/fi/password_reset.php +++ b/resources/lang/fi/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Anna sähköposti tai käyttäjänimi', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Tarvitsetko lisäapua? Ota yhteyttä meihin: :button.', 'button' => 'tukijärjestelmä', diff --git a/resources/lang/fi/users.php b/resources/lang/fi/users.php index c8557653dbf..b4abe01f160 100644 --- a/resources/lang/fi/users.php +++ b/resources/lang/fi/users.php @@ -159,7 +159,7 @@ ], 'options' => [ - 'cheating' => 'Väärin pelaaminen / Huijaaminen', + 'cheating' => 'Huijaaminen', 'multiple_accounts' => 'Käyttää useita tilejä', 'insults' => 'Loukkaa minua / muita', 'spam' => 'Spämmii', diff --git a/resources/lang/fil/legacy_api_key.php b/resources/lang/fil/legacy_api_key.php index 562fed20b55..55398e7efb3 100644 --- a/resources/lang/fil/legacy_api_key.php +++ b/resources/lang/fil/legacy_api_key.php @@ -4,8 +4,8 @@ // See the LICENCE file in the repository root for full licence text. return [ - 'new' => '', - 'none' => '', + 'new' => 'Bagong Legacy API Key', + 'none' => 'Walang key.', 'docs' => [ '_' => '', diff --git a/resources/lang/fil/legacy_irc_key.php b/resources/lang/fil/legacy_irc_key.php index 412fea986d8..e37bb3bb568 100644 --- a/resources/lang/fil/legacy_irc_key.php +++ b/resources/lang/fil/legacy_irc_key.php @@ -4,20 +4,20 @@ // See the LICENCE file in the repository root for full licence text. return [ - 'confirm_new' => '', - 'new' => '', - 'none' => '', + 'confirm_new' => 'Gumawa ng bagong IRC password?', + 'new' => 'Bagong Legacy IRC Password', + 'none' => 'Hindi na-set ang IRC Password.', 'form' => [ - 'server_host' => '', - 'server_port' => '', - 'token' => '', - 'username' => '', + 'server_host' => 'server', + 'server_port' => 'port', + 'token' => 'server password', + 'username' => 'username', ], 'view' => [ - 'hide' => '', - 'show' => '', - 'delete' => '', + 'hide' => 'Itago ang password', + 'show' => 'Ipakita ang password', + 'delete' => 'Burahin', ], ]; diff --git a/resources/lang/fil/password_reset.php b/resources/lang/fil/password_reset.php index 397217e31d8..66101d3eeef 100644 --- a/resources/lang/fil/password_reset.php +++ b/resources/lang/fil/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Itala ang email address o username', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Kailangan pa ng tulong? Makipag-usap sa amin sa :button.', 'button' => 'support system', diff --git a/resources/lang/fil/store.php b/resources/lang/fil/store.php index 8784207c02c..97c88a996a4 100644 --- a/resources/lang/fil/store.php +++ b/resources/lang/fil/store.php @@ -6,7 +6,7 @@ return [ 'cart' => [ 'checkout' => 'Checkout', - 'empty_cart' => '', + 'empty_cart' => 'Tanggalin lahat ng items sa cart', 'info' => ':count_delimited pirasong item sa kariton ($:subtotal)|:count_delimited pirasong mga item sa kariton ($:subtotal)', 'more_goodies' => 'Gusto kong tingnan ang higit pang mga goodies bago makumpleto ang order', 'shipping_fees' => 'mga bayarin sa pagpapadala', @@ -49,7 +49,7 @@ ], 'discount' => 'makatipid ng :percent%', - 'free' => '', + 'free' => 'free!', 'invoice' => [ 'contact' => '', diff --git a/resources/lang/fr/chat.php b/resources/lang/fr/chat.php index a21442c8690..32562116111 100644 --- a/resources/lang/fr/chat.php +++ b/resources/lang/fr/chat.php @@ -23,7 +23,7 @@ 'title' => [ 'ANNOUNCE' => 'Annonces', 'GROUP' => 'Groupes', - 'PM' => 'Messages directs', + 'PM' => 'Messages privés', 'PUBLIC' => 'Canaux', ], ], @@ -49,7 +49,7 @@ 'input' => [ 'create' => 'Créer', - 'disabled' => 'impossible d\'envoyer un message...', + 'disabled' => 'impossible d’envoyer le message...', 'disconnected' => 'Déconnecté', 'placeholder' => 'saisissez votre message...', 'send' => 'Envoyer', diff --git a/resources/lang/fr/password_reset.php b/resources/lang/fr/password_reset.php index 722a851e7b2..7260190d31b 100644 --- a/resources/lang/fr/password_reset.php +++ b/resources/lang/fr/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Entrez une adresse e-mail ou un nom d\'utilisateur', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Vous avez besoin d\'aide supplémentaire ? Contactez-nous via notre :button.', 'button' => 'système de support', diff --git a/resources/lang/he/password_reset.php b/resources/lang/he/password_reset.php index 041f953221e..a609260b7eb 100644 --- a/resources/lang/he/password_reset.php +++ b/resources/lang/he/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'הכנס אימייל או שם משתמש', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'צריך עזרה נוספת? צור איתנו קשר דרך ה:button שלנו.', 'button' => 'מערכת תמיכה', diff --git a/resources/lang/hr-HR/password_reset.php b/resources/lang/hr-HR/password_reset.php index 3fa5e25c982..e49e2c9cf47 100644 --- a/resources/lang/hr-HR/password_reset.php +++ b/resources/lang/hr-HR/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Unesi svoju adresu e-pošte ili korisničko ime', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Trebaš dodatnu pomoć? Kontaktiraj nas putem naše :button.', 'button' => 'sistema za podršku', diff --git a/resources/lang/hu/password_reset.php b/resources/lang/hu/password_reset.php index af35a9391e4..3c925c3e705 100644 --- a/resources/lang/hu/password_reset.php +++ b/resources/lang/hu/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Add meg az e-mail címed vagy felhasználóneved', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Segítség kéne? Lépj kapcsolatba velünk itt :botton.', 'button' => 'támogatói rendszer', diff --git a/resources/lang/id/artist.php b/resources/lang/id/artist.php index 8b024e3d69d..f5643577d2f 100644 --- a/resources/lang/id/artist.php +++ b/resources/lang/id/artist.php @@ -4,7 +4,7 @@ // See the LICENCE file in the repository root for full licence text. return [ - 'page_description' => 'Featured artist di osu!', + 'page_description' => 'Featured Artist di osu!', 'title' => 'Featured Artist', 'admin' => [ diff --git a/resources/lang/id/authorization.php b/resources/lang/id/authorization.php index af0c4695da9..44d02290fa0 100644 --- a/resources/lang/id/authorization.php +++ b/resources/lang/id/authorization.php @@ -48,7 +48,7 @@ 'edit' => [ 'not_owner' => 'Hanya pemilik topik yang diperbolehkan untuk menyunting kiriman.', 'resolved' => 'Kamu tidak dapat menyunting postingan pada topik diskusi yang telah terjawab.', - 'system_generated' => 'Post yang dihasilkan secara otomatis tidak dapat disunting.', + 'system_generated' => 'Postingan yang dihasilkan secara otomatis tidak dapat disunting.', ], ], diff --git a/resources/lang/id/beatmap_discussions.php b/resources/lang/id/beatmap_discussions.php index b9d4b56d3d0..ab8b15fc175 100644 --- a/resources/lang/id/beatmap_discussions.php +++ b/resources/lang/id/beatmap_discussions.php @@ -13,7 +13,7 @@ ], 'events' => [ - 'empty' => 'Belum ada hal apapun yang terjadi... hingga saat ini.', + 'empty' => 'Belum ada hal apa pun yang terjadi... hingga saat ini.', ], 'index' => [ diff --git a/resources/lang/id/follows.php b/resources/lang/id/follows.php index b6884672293..26ae0593d0f 100644 --- a/resources/lang/id/follows.php +++ b/resources/lang/id/follows.php @@ -24,7 +24,7 @@ ], 'mapping' => [ - 'empty' => 'Kamu tidak sedang mengikuti siapapun.', + 'empty' => 'Kamu tidak sedang mengikuti siapa pun.', 'followers' => 'pengikut mapping', 'page_title' => 'mapper yang diikuti', 'title' => 'mapper', diff --git a/resources/lang/id/forum.php b/resources/lang/id/forum.php index 86d7ef75eca..f695440ae63 100644 --- a/resources/lang/id/forum.php +++ b/resources/lang/id/forum.php @@ -33,7 +33,7 @@ ], 'topics' => [ - 'empty' => 'Tidak ada topik!', + 'empty' => 'Tidak ada topik apa pun di sini!', ], ], diff --git a/resources/lang/id/home.php b/resources/lang/id/home.php index f40930047cd..f5418e317c2 100644 --- a/resources/lang/id/home.php +++ b/resources/lang/id/home.php @@ -7,7 +7,7 @@ 'landing' => [ 'download' => 'Unduh sekarang', 'online' => 'dengan :players pemain yang saat ini terhubung dalam :games ruang permainan', - 'peak' => 'Jumlah pengguna online terbanyak: :count', + 'peak' => 'Puncak aktivitas: :count pengguna online', 'players' => ':count pengguna terdaftar', 'title' => 'selamat datang', 'see_more_news' => 'lihat lebih banyak berita', diff --git a/resources/lang/id/legacy_api_key.php b/resources/lang/id/legacy_api_key.php index 24f7bc40a20..51f7a1d7256 100644 --- a/resources/lang/id/legacy_api_key.php +++ b/resources/lang/id/legacy_api_key.php @@ -23,7 +23,7 @@ ], 'warning' => [ - 'line1' => 'Jangan berikan informasi ini pada siapapun.', + 'line1' => 'Jangan berikan informasi ini kepada siapa pun.', 'line2' => "Ini sama halnya membagikan akunmu pada yang lain.", 'line3' => 'Harap untuk tidak membagikan informasi ini.', ], diff --git a/resources/lang/id/notifications.php b/resources/lang/id/notifications.php index c882bfa332a..6d15bd5a6b8 100644 --- a/resources/lang/id/notifications.php +++ b/resources/lang/id/notifications.php @@ -45,15 +45,15 @@ 'beatmapset_discussion' => [ '_' => 'Laman diskusi beatmap', - 'beatmapset_discussion_lock' => 'Diskusi untuk beatmap ":title" telah ditutup.', + 'beatmapset_discussion_lock' => 'Diskusi pada beatmap ":title" telah dikunci', 'beatmapset_discussion_lock_compact' => 'Diskusi beatmap telah dikunci', 'beatmapset_discussion_post_new' => 'Postingan baru pada ":title" oleh :username: ":content"', 'beatmapset_discussion_post_new_empty' => 'Postingan baru pada ":title" oleh :username', 'beatmapset_discussion_post_new_compact' => 'Postingan baru oleh :username: ":content"', 'beatmapset_discussion_post_new_compact_empty' => 'Postingan baru oleh :username', - 'beatmapset_discussion_review_new' => 'Terdapat ulasan baru pada ":title" oleh :username yang menyinggung seputar masalah: :problems, saran: :suggestions, dan pujian berupa: :praises', - 'beatmapset_discussion_review_new_compact' => 'Terdapat ulasan baru oleh :username yang menyinggung seputar masalah: :problems, saran: :suggestions, dan pujian berupa: :praises', - 'beatmapset_discussion_unlock' => 'Diskusi untuk beatmap ":title" telah dibuka kembali.', + 'beatmapset_discussion_review_new' => 'Kajian baru pada ":title" oleh :username yang mengandung :review_counts', + 'beatmapset_discussion_review_new_compact' => 'Kajian baru oleh :username yang mengandung :review_counts', + 'beatmapset_discussion_unlock' => 'Diskusi pada beatmap ":title" telah kembali dibuka', 'beatmapset_discussion_unlock_compact' => 'Diskusi beatmap telah dibuka', 'review_count' => [ @@ -80,12 +80,12 @@ 'beatmapset_nominate' => '":title" telah dinominasikan', 'beatmapset_nominate_compact' => 'Beatmap telah dinominasikan', 'beatmapset_qualify' => '":title" telah memperoleh jumlah nominasi yang dibutuhkan untuk dapat memasuki antrian ranking', - 'beatmapset_qualify_compact' => 'Beatmap telah memasuki antrian ranking', + 'beatmapset_qualify_compact' => 'Beatmap memasuki antrian ranking', 'beatmapset_rank' => '":title" telah berstatus Ranked', 'beatmapset_rank_compact' => 'Beatmap telah berstatus Ranked', 'beatmapset_remove_from_loved' => '":title" telah dilepas dari Loved', 'beatmapset_remove_from_loved_compact' => 'Beatmap telah dilepas dari Loved', - 'beatmapset_reset_nominations' => 'Masalah yang dikemukakan oleh :username menganulir nominasi sebelumnya pada beatmap ":title" ', + 'beatmapset_reset_nominations' => 'Nominasi pada beatmap ":title" telah dianulir', 'beatmapset_reset_nominations_compact' => 'Nominasi beatmap dianulir', ], @@ -207,7 +207,7 @@ 'beatmapset_qualify' => '":title" telah memperoleh jumlah nominasi yang dibutuhkan untuk dapat memasuki antrian ranking', 'beatmapset_rank' => '":title" telah berstatus Ranked', 'beatmapset_remove_from_loved' => ':title telah dilepas dari Loved', - 'beatmapset_reset_nominations' => 'Status nominasi pada ":title" telah dianulir', + 'beatmapset_reset_nominations' => 'Nominasi pada beatmap ":title" telah dianulir', ], 'comment' => [ diff --git a/resources/lang/id/password_reset.php b/resources/lang/id/password_reset.php index 951afcc11d6..7caa17126ea 100644 --- a/resources/lang/id/password_reset.php +++ b/resources/lang/id/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Masukkan alamat email atau nama pengguna', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Butuh bantuan lebih lanjut? Hubungi :button kami.', 'button' => 'layanan dukungan', diff --git a/resources/lang/id/store.php b/resources/lang/id/store.php index f6ebc45b10b..cedefdd7b4f 100644 --- a/resources/lang/id/store.php +++ b/resources/lang/id/store.php @@ -15,7 +15,7 @@ 'errors_no_checkout' => [ 'line_1' => 'Uh-oh, terdapat masalah dengan keranjangmu yang menghalangi proses checkout!', - 'line_2' => 'Hapus atau perbarui item-item di atas untuk melanjutkan.', + 'line_2' => 'Hapus atau perbarui rangkaian item di atas untuk melanjutkan.', ], 'empty' => [ @@ -43,7 +43,7 @@ ], 'pending_checkout' => [ - 'line_1' => 'Transaksi sebelumnya belum dituntaskan.', + 'line_1' => 'Terdapat transaksi terdahulu yang belum dituntaskan.', 'line_2' => 'Lanjutkan pembayaranmu dengan memilih metode pembayaran.', ], ], @@ -77,7 +77,7 @@ ], ], 'prepared' => [ - 'title' => 'Pesananmu sedang disiapkan!', + 'title' => 'Pesananmu sedang dipersiapkan!', 'line_1' => 'Harap tunggu sedikit lebih lama untuk pengiriman. Informasi pelacakan akan muncul di sini setelah pesanan telah diolah dan dikirim. Ini bisa perlu sampai 5 hari (tetapi biasanya lebih cepat!) tergantung kesibukan kami.', 'line_2' => 'Kami mengirim seluruh pesanan dari Jepang dengan berbagai macam layanan pengiriman tergantung berat dan nilai. Bagian ini akan diperbarui dengan perincian setelah kami mengirimkan pesanan.', ], diff --git a/resources/lang/id/wiki.php b/resources/lang/id/wiki.php index 27a44708550..05db0075a0e 100644 --- a/resources/lang/id/wiki.php +++ b/resources/lang/id/wiki.php @@ -21,7 +21,7 @@ ], 'translation' => [ - 'legal' => 'Terjemahan ini diberikan semata-mata hanya untuk memudahkan. :default dari artikel ini merupakan satu-satunya versi artikel yang mengikat secara hukum.', + 'legal' => 'Terjemahan ini diberikan semata-mata untuk memudahkan. :default dari artikel ini merupakan satu-satunya versi artikel yang mengikat secara hukum.', 'outdated' => 'Laman ini mengandung terjemahan yang telah kedaluwarsa dari artikel aslinya. Mohon periksa :default dari artikel ini untuk mendapatkan informasi yang paling akurat (dan apabila kamu berkenan, mohon bantu kami untuk memperbarui terjemahan ini)!', 'default' => 'Versi Bahasa Inggris', diff --git a/resources/lang/it/password_reset.php b/resources/lang/it/password_reset.php index cd343cc0fe0..ee2f35dd445 100644 --- a/resources/lang/it/password_reset.php +++ b/resources/lang/it/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Inserisci l\'indirizzo email o il nome utente', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Hai bisogno di ulteriore assistenza? Contattaci col nostro :button.', 'button' => 'sistema di supporto', diff --git a/resources/lang/it/store.php b/resources/lang/it/store.php index b8fccca3cff..281a0d9e8cd 100644 --- a/resources/lang/it/store.php +++ b/resources/lang/it/store.php @@ -93,7 +93,7 @@ 'title' => 'Il tuo ordine è stato spedito!', 'tracking_details' => '', 'no_tracking_details' => [ - '_' => "Non disponiamo dei dettagli di tracciabilità poiché abbiamo inviato il tuo pacco tramite posta aerea, ma puoi aspettarti di riceverlo entro 1-3 settimane. Per l'Europa, a volte la dogana può ritardare l'ordine senza il nostro controllo. Se hai qualche dubbio, rispondi all'e-mail di conferma dell'ordine che hai ricevuto :link.", + '_' => "Non disponiamo dei dettagli di tracciabilità poiché abbiamo inviato il tuo pacco tramite posta aerea, ma puoi aspettarti di riceverlo entro 1-3 settimane. Per l'Europa, a volte la dogana può ritardare l'ordine senza il nostro controllo. Se hai qualche dubbio, rispondi all'e-mail di conferma dell'ordine che hai ricevuto (o :link).", 'link_text' => 'inviaci un\'email', ], ], diff --git a/resources/lang/ja/password_reset.php b/resources/lang/ja/password_reset.php index 411520ebf30..f3df6f00418 100644 --- a/resources/lang/ja/password_reset.php +++ b/resources/lang/ja/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'メールアドレスまたはユーザー名を入力してください', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'さらにサポートが必要ですか? :buttonからお問い合わせください。', 'button' => 'サポートシステム', diff --git a/resources/lang/kk-KZ/password_reset.php b/resources/lang/kk-KZ/password_reset.php index 97f1d2f2703..c5e6f4be919 100644 --- a/resources/lang/kk-KZ/password_reset.php +++ b/resources/lang/kk-KZ/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => '', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => '', 'button' => '', diff --git a/resources/lang/ko/password_reset.php b/resources/lang/ko/password_reset.php index 29678acfbff..ff90611696d 100644 --- a/resources/lang/ko/password_reset.php +++ b/resources/lang/ko/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => '아이디나 이메일 주소를 입력하세요.', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => '도움이 필요하신가요? :button을 통해 문의해보세요.', 'button' => '지원 시스템', diff --git a/resources/lang/lt/password_reset.php b/resources/lang/lt/password_reset.php index 007a0aa6014..726419ba141 100644 --- a/resources/lang/lt/password_reset.php +++ b/resources/lang/lt/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Įrašykite el. pašto adresą arba naudotojo vardą', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Reikia tolimesnės pagalbos? Susisiekite su mumis per :button.', 'button' => 'pagalbos sistemą', diff --git a/resources/lang/lv-LV/password_reset.php b/resources/lang/lv-LV/password_reset.php index 96ae7eb4319..9d58873905c 100644 --- a/resources/lang/lv-LV/password_reset.php +++ b/resources/lang/lv-LV/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Ievadiet e-pasta adresi vai lietotājvārdu', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Nepieciešams tālāks atbalsts? Sazinieties ar mums caur :button.', 'button' => 'atbalsta sistēma', diff --git a/resources/lang/ms-MY/password_reset.php b/resources/lang/ms-MY/password_reset.php index ca44bd408e9..a1b58ecb95b 100644 --- a/resources/lang/ms-MY/password_reset.php +++ b/resources/lang/ms-MY/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Masukkan alamat e-mel atau nama pengguna', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Perlukan bantuan lebih lanjut? Hubungi :button kami.', 'button' => 'layanan dukungan', diff --git a/resources/lang/nl/password_reset.php b/resources/lang/nl/password_reset.php index f93d6eeb7a9..ad8bd037481 100644 --- a/resources/lang/nl/password_reset.php +++ b/resources/lang/nl/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Vul e-mail adres of gebruikersnaam in', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Meer hulp nodig? Neem contact met ons op via onze :button.', 'button' => 'ondersteuningssysteem', diff --git a/resources/lang/no/password_reset.php b/resources/lang/no/password_reset.php index c6b46bdcd20..9ebb27fa546 100644 --- a/resources/lang/no/password_reset.php +++ b/resources/lang/no/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Skriv inn e-postadresse eller brukernavn', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Trenger du mer hjelp? Kontakt oss via vårt :button.', 'button' => 'støttesystem', diff --git a/resources/lang/pl/password_reset.php b/resources/lang/pl/password_reset.php index 56956a40c15..8c7bc5b80e1 100644 --- a/resources/lang/pl/password_reset.php +++ b/resources/lang/pl/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Wprowadź e-mail lub nazwę użytkownika', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Potrzebujesz pomocy? Skontaktuj się z :button.', 'button' => 'pomocą techniczną', diff --git a/resources/lang/pt-br/password_reset.php b/resources/lang/pt-br/password_reset.php index 77dcc2bfa4d..10743010f2a 100644 --- a/resources/lang/pt-br/password_reset.php +++ b/resources/lang/pt-br/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Insira endereço de email ou nome de usuário', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Precisa de mais assistência? Entre em contato conosco através do nosso :button.', 'button' => 'sistema de suporte', diff --git a/resources/lang/pt/password_reset.php b/resources/lang/pt/password_reset.php index d4ac6386434..9d0909eb359 100644 --- a/resources/lang/pt/password_reset.php +++ b/resources/lang/pt/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Introduz um endereço de email ou um nome de utilizador', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Precisas de mais assistência? Contacta-nos a partir do nosso :button.', 'button' => 'sistema de suporte', diff --git a/resources/lang/ro/beatmapset_events.php b/resources/lang/ro/beatmapset_events.php index b5abe3b38e7..8653975c049 100644 --- a/resources/lang/ro/beatmapset_events.php +++ b/resources/lang/ro/beatmapset_events.php @@ -30,7 +30,7 @@ 'nomination_reset' => 'O problemă nouă :discussion (:text) a declanșat reluarea unei nominalizări.', 'nomination_reset_received' => 'Nominalizarea de :user a fost resetată de către :source_user (:text)', 'nomination_reset_received_profile' => 'Nominalizarea a fost resetată de :user (:text)', - 'offset_edit' => 'Offset-ul online schimbat din :old la :new.', + 'offset_edit' => 'Decalaj online schimbat din :old la :new.', 'qualify' => 'Acest beatmap a atins numărul limită de nominalizări și s-a calificat.', 'rank' => 'Clasat.', 'remove_from_loved' => 'Eliminat din Iubit de :user. (:text)', @@ -79,7 +79,7 @@ 'nomination_reset' => 'Resetarea nominalizărilor', 'nomination_reset_received' => 'Resetare a nominalizării primită', 'nsfw_toggle' => 'Marcaj obscen', - 'offset_edit' => 'Editare offset', + 'offset_edit' => 'Editare decalaj', 'qualify' => 'Calificare', 'rank' => 'Clasament', 'remove_from_loved' => 'Scoaterea din Iubit', diff --git a/resources/lang/ro/beatmapsets.php b/resources/lang/ro/beatmapsets.php index c28df21719b..cc0fd2b41dc 100644 --- a/resources/lang/ro/beatmapsets.php +++ b/resources/lang/ro/beatmapsets.php @@ -136,7 +136,7 @@ 'no_scores' => 'Încă se calculează datele...', 'nominators' => 'Nominalizatori', 'nsfw' => 'Conținut obscen', - 'offset' => 'Offset online', + 'offset' => 'Decalaj online', 'points-of-failure' => 'Puncte de eșec', 'source' => 'Sursă', 'storyboard' => 'Acest beatmap conține un storyboard', @@ -209,7 +209,7 @@ 'bpm' => 'BPM', 'count_circles' => 'Număr Cercuri', 'count_sliders' => 'Număr Slidere', - 'offset' => 'Offset online: :offset', + 'offset' => 'Decalaj online: :offset', 'user-rating' => 'Rating Utilizatori', 'rating-spread' => 'Grafic Rating-uri', 'nominations' => 'Nominalizări', diff --git a/resources/lang/ro/password_reset.php b/resources/lang/ro/password_reset.php index cc69b485810..9e0ee4aeff9 100644 --- a/resources/lang/ro/password_reset.php +++ b/resources/lang/ro/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Introduceți adresa de e-mail sau numele de utilizator', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Aveți nevoie de asistență suplimentară? Contactați-ne prin intermediul :button.', 'button' => 'sistem de ajutor', diff --git a/resources/lang/ru/password_reset.php b/resources/lang/ru/password_reset.php index 03424d90f94..a5a6d6d727a 100644 --- a/resources/lang/ru/password_reset.php +++ b/resources/lang/ru/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Введите почту или ник', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Нужна дополнительная помощь? Свяжитесь с нами через :button.', 'button' => 'систему поддержки', diff --git a/resources/lang/ru/store.php b/resources/lang/ru/store.php index fec6def83f6..f9621a0c0fe 100644 --- a/resources/lang/ru/store.php +++ b/resources/lang/ru/store.php @@ -93,7 +93,7 @@ 'title' => 'Ваш заказ отправлен!', 'tracking_details' => 'Подробности отслеживания:', 'no_tracking_details' => [ - '_' => "У нас нет данных отслеживания, поскольку мы отправили ваш заказ авиапочтой, однако вы можете рассчитывать на их получение в течение 1-3 недель. Иногда таможня в Европе может задержать заказ вне нашего контроля. Если у вас остались вопросы, ответьте на полученное вами письмо с подтверждением заказа :link.", + '_' => "У нас нет данных отслеживания, поскольку мы отправили ваш заказ авиапочтой, однако вы можете рассчитывать на их получение в течение 1-3 недель. Иногда таможня в Европе может задержать заказ вне нашего контроля. Если у вас остались вопросы, ответьте на полученное вами письмо с подтверждением заказа (или :link).", 'link_text' => 'отправьте нам письмо', ], ], diff --git a/resources/lang/si-LK/password_reset.php b/resources/lang/si-LK/password_reset.php index 7d1682ca277..d5c45b72e08 100644 --- a/resources/lang/si-LK/password_reset.php +++ b/resources/lang/si-LK/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => '', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => '', 'button' => '', diff --git a/resources/lang/sk/password_reset.php b/resources/lang/sk/password_reset.php index d74fff28b3e..dffd250a16a 100644 --- a/resources/lang/sk/password_reset.php +++ b/resources/lang/sk/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Zadajte e-mailovú adresu alebo užívateľské meno', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => '', 'button' => '', diff --git a/resources/lang/sl/password_reset.php b/resources/lang/sl/password_reset.php index 7b71cf5274e..90db330edc4 100644 --- a/resources/lang/sl/password_reset.php +++ b/resources/lang/sl/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Vnesi e-poštni naslov ali uporabniško ime', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Potrebuješ nadaljno pomoč? Kontaktiraj nas preko našega :button.', 'button' => 'sistema podpore', diff --git a/resources/lang/sr/password_reset.php b/resources/lang/sr/password_reset.php index 9d4b12e8e53..70def082186 100644 --- a/resources/lang/sr/password_reset.php +++ b/resources/lang/sr/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Унесите адресу е-поште или корисничко име', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Треба Вам додатна помоћ? Ступите у контакт преко нашег :button.', 'button' => 'систем за подршку', diff --git a/resources/lang/sv/password_reset.php b/resources/lang/sv/password_reset.php index bd05f598937..0da9a25e61b 100644 --- a/resources/lang/sv/password_reset.php +++ b/resources/lang/sv/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Fyll i din e-postadress eller ditt användarnamn', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Behöver du mer hjälp? Kontakta oss via vår :button.', 'button' => 'supportsystem', diff --git a/resources/lang/tg-TJ/password_reset.php b/resources/lang/tg-TJ/password_reset.php index 97f1d2f2703..c5e6f4be919 100644 --- a/resources/lang/tg-TJ/password_reset.php +++ b/resources/lang/tg-TJ/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => '', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => '', 'button' => '', diff --git a/resources/lang/tg-TJ/scores.php b/resources/lang/tg-TJ/scores.php index e8319f084b3..5e13cd1d042 100644 --- a/resources/lang/tg-TJ/scores.php +++ b/resources/lang/tg-TJ/scores.php @@ -8,7 +8,7 @@ 'title' => '', 'beatmap' => [ - 'by' => '', + 'by' => 'аз ҷониби :artist', ], 'player' => [ diff --git a/resources/lang/tg-TJ/store.php b/resources/lang/tg-TJ/store.php index e776a4912bc..54a21aff169 100644 --- a/resources/lang/tg-TJ/store.php +++ b/resources/lang/tg-TJ/store.php @@ -8,20 +8,20 @@ 'checkout' => '', 'empty_cart' => '', 'info' => '', - 'more_goodies' => '', - 'shipping_fees' => '', - 'title' => '', - 'total' => '', + 'more_goodies' => 'Ман мехоҳам пеш аз ба итмом расонидани фармоиш чизҳои бештарро тафтиш кунам', + 'shipping_fees' => 'ҳаққи интиқол', + 'title' => 'Сабади харид', + 'total' => 'умумии', 'errors_no_checkout' => [ 'line_1' => '', - 'line_2' => '', + 'line_2' => 'Барои идома додани ҷузъҳои боло хориҷ ё навсозӣ кунед.', ], 'empty' => [ - 'text' => '', + 'text' => 'Аробаи шумо холист.', 'return_link' => [ - '_' => '', + '_' => 'Ба :link баргардед, то чизҳои хубро пайдо кунед!', 'link_text' => '', ], ], diff --git a/resources/lang/tg-TJ/supporter_tag.php b/resources/lang/tg-TJ/supporter_tag.php index e0ea36b594e..632913da8d7 100644 --- a/resources/lang/tg-TJ/supporter_tag.php +++ b/resources/lang/tg-TJ/supporter_tag.php @@ -4,10 +4,10 @@ // See the LICENCE file in the repository root for full licence text. return [ - 'months' => '', + 'months' => 'моҳҳо', 'user_search' => [ - 'searching' => '', - 'not_found' => "", + 'searching' => 'чустучуй...', + 'not_found' => "Ин корбар вуҷуд надорад", ], ]; diff --git a/resources/lang/th/password_reset.php b/resources/lang/th/password_reset.php index c54ecbe0f3f..3ec39d0816b 100644 --- a/resources/lang/th/password_reset.php +++ b/resources/lang/th/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'กรอกอีเมล หรือชื่อผู้ใช้', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => ' ต้องการความช่วยเหลือเพิ่มเติมหรือไม่? ติดต่อเราผ่านทาง :button', diff --git a/resources/lang/tr/accounts.php b/resources/lang/tr/accounts.php index e0e2b59d090..2ef9f55ba94 100644 --- a/resources/lang/tr/accounts.php +++ b/resources/lang/tr/accounts.php @@ -63,15 +63,15 @@ ], 'github_user' => [ - 'info' => "", + 'info' => "Eğer osu!'nun açık kaynaklı repository'lerinde katkılıysanız, GitHub hesabınızı bağlamanız sizin değişim günlüğü girişleriniz, osu! profilinizle ilişkilendirilecektir. osu! repository'lerinde katkı geçmişi olmayan GitHub hesapları bağlanamaz.", 'link' => 'GitHub Hesabını Bağla', 'title' => 'GitHub', 'unlink' => 'GitHub Hesabının bağlantısını Kaldır', 'error' => [ - 'already_linked' => '', - 'no_contribution' => '', - 'unverified_email' => '', + 'already_linked' => 'Bu GitHub hesabı zaten başka bir kullanıcıya bağlı.', + 'no_contribution' => 'osu! repository\'lerinde katkı geçmişi olmayan GitHub hesabı bağlanamaz.', + 'unverified_email' => 'Lütfen GitHub\'daki ana e-postanızı doğrulayın, sonra hesabınızı tekrar bağlamayı deneyin.', ], ], diff --git a/resources/lang/tr/notifications.php b/resources/lang/tr/notifications.php index b2caf09edc4..fdf894192b9 100644 --- a/resources/lang/tr/notifications.php +++ b/resources/lang/tr/notifications.php @@ -57,9 +57,9 @@ 'beatmapset_discussion_unlock_compact' => 'Tartışmanın kilidi kaldırılmış', 'review_count' => [ - 'praises' => '', - 'problems' => '', - 'suggestions' => '', + 'praises' => ':count_delimited övgü|:count_delimited övgü', + 'problems' => ':count_delimited sorun|:count_delimited sorun', + 'suggestions' => ':count_delimited öneri|:count_delimited öneri', ], ], diff --git a/resources/lang/tr/password_reset.php b/resources/lang/tr/password_reset.php index b5c76eaccf7..3a5f712a0bc 100644 --- a/resources/lang/tr/password_reset.php +++ b/resources/lang/tr/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'E-posta adresi veya kullanıcı adı girin', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Yardıma mı ihtiyacınız var? :button üzerinden bizimle iletişime geçin.', 'button' => 'Destek sistemimiz', diff --git a/resources/lang/tr/store.php b/resources/lang/tr/store.php index ea644a74d86..5a5efabe4da 100644 --- a/resources/lang/tr/store.php +++ b/resources/lang/tr/store.php @@ -65,7 +65,7 @@ 'cancelled' => [ 'title' => 'Siparişiniz iptal edildi', 'line_1' => [ - '_' => "", + '_' => "Eğer iptal edilmesini talep etmediyseniz lütfen :link yoluyla sipariş numaranızı bahsederek ulaşınız. (#:order_number).", 'link_text' => 'osu!store destek', ], ], @@ -78,8 +78,8 @@ ], 'prepared' => [ 'title' => 'Siparişiniz hazılrlanıyor!', - 'line_1' => '', - 'line_2' => '', + 'line_1' => 'Lütfen kargolanması için az daha bekleyiniz. Takip bilgisi, siparişiniz işlenip gönderildiğinde burada belirecektir. Meşgullük durumumuza göre 5 güne kadar sürebilir (ama genellikle daha az!).', + 'line_2' => 'Siparişleri, ağırlığı ve değerine bağlı olarak çeşitli kargo şirketleri kullanarak gönderiyoruz. Bu alan, siparişi gönderdiğimizde detaylarla güncellenecektir.', ], 'processing' => [ 'title' => 'Ödemeniz henüz onaylanmadı!', @@ -93,7 +93,7 @@ 'title' => 'Siparişiniz kargoya verildi!', 'tracking_details' => 'Kargo takip detayları aşağıdadır:', 'no_tracking_details' => [ - '_' => "", + '_' => "Paketinizi uçak kargosu yoluyla gönderdiğimiz için takip ayrıntılarına sahip değiliz, ancak paketinizi 1-3 hafta içinde almayı bekleyebilirsiniz. Avrupa'da bazen gümrükler bizim kontrolümüz dışında siparişi geciktirebilir. Herhangi bir endişeniz varsa lütfen sana gelen sipariş onay e-postasını yanıtlayınız (ya da :link).", 'link_text' => 'bize bir e-mail yollayın', ], ], @@ -157,7 +157,7 @@ 'thanks' => [ 'title' => 'Siparişiniz için teşekkür ederiz!', 'line_1' => [ - '_' => '', + '_' => 'Yakında bir onay e-postası alacaksınız. Sorunuz varsa, lütfen :link!', 'link_text' => 'bizimle iletişime geçin', ], ], diff --git a/resources/lang/uk/password_reset.php b/resources/lang/uk/password_reset.php index 39d71896dea..25c09f64796 100644 --- a/resources/lang/uk/password_reset.php +++ b/resources/lang/uk/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Введіть пошту або нікнейм', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Потрібна додаткова допомога? Зв\'яжіться з нами через :button.', 'button' => 'система підтримки', diff --git a/resources/lang/vi/password_reset.php b/resources/lang/vi/password_reset.php index ba3fdd91254..7173c59e470 100644 --- a/resources/lang/vi/password_reset.php +++ b/resources/lang/vi/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Nhập địa chỉ email hoặc tên tài khoản', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Cần nhiều sự giúp đỡ hơn? Liên hệ với chúng tôi bằng :button.', 'button' => 'hệ thống hỗ trợ', diff --git a/resources/lang/zh-tw/password_reset.php b/resources/lang/zh-tw/password_reset.php index 1dc941288ac..be415f6fd13 100644 --- a/resources/lang/zh-tw/password_reset.php +++ b/resources/lang/zh-tw/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => '輸入郵件地址或使用者名稱', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => '需要進一步的幫助?通過我們的 :button 聯繫我們。', 'button' => '支持系統', diff --git a/resources/lang/zh/password_reset.php b/resources/lang/zh/password_reset.php index 53df1500968..b3f202696c7 100644 --- a/resources/lang/zh/password_reset.php +++ b/resources/lang/zh/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => '输入邮箱或用户名', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => '需要进一步的帮助?通过我们的 :button 联系我们。', 'button' => '支持系统', diff --git a/resources/views/beatmapsets/discussion.blade.php b/resources/views/beatmapsets/discussion.blade.php index a2b325e702d..f04801e4392 100644 --- a/resources/views/beatmapsets/discussion.blade.php +++ b/resources/views/beatmapsets/discussion.blade.php @@ -16,9 +16,12 @@ @section ("script") @parent - + @foreach ($initialData as $name => $data) + + @endforeach + @include('beatmapsets._recommended_star_difficulty_all') @include('layout._react_js', ['src' => 'js/beatmap-discussions.js']) diff --git a/resources/views/docs/_structures/user.md b/resources/views/docs/_structures/user.md index dca42e3ff46..b269bddedee 100644 --- a/resources/views/docs/_structures/user.md +++ b/resources/views/docs/_structures/user.md @@ -70,6 +70,7 @@ replays_watched_counts | | | scores_best_count | integer | | scores_first_count | integer | | scores_recent_count | integer | | +session_verified | boolean | | statistics | | | statistics_rulesets | UserStatisticsRulesets | | support_level | | | diff --git a/resources/views/layout/_popup_user.blade.php b/resources/views/layout/_popup_user.blade.php index 1ddb765c8de..cb6b364ab61 100644 --- a/resources/views/layout/_popup_user.blade.php +++ b/resources/views/layout/_popup_user.blade.php @@ -16,6 +16,10 @@ class="simple-menu__header simple-menu__header--link js-current-user-cover"
{{ Auth::user()->username }}
+
+ @include('layout._score_mode_toggle', ['class' => 'simple-menu__item']) +
+ . Licensed under the GNU Affero General Public License v3.0. + See the LICENCE file in the repository root for full licence text. +--}} +@php + $legacyScoreMode ??= App\Libraries\Search\ScoreSearchParams::showLegacyForUser(Auth::user()) === true; + $icon = $legacyScoreMode + ? 'far fa-square' + : 'fas fa-check-square'; +@endphp + diff --git a/resources/views/layout/header_mobile/user.blade.php b/resources/views/layout/header_mobile/user.blade.php index 7788db0fda6..d7875bb3c90 100644 --- a/resources/views/layout/header_mobile/user.blade.php +++ b/resources/views/layout/header_mobile/user.blade.php @@ -9,6 +9,8 @@ class="navbar-mobile-item__main js-react--user-card" data-is-current-user="1" >
+ @include('layout._score_mode_toggle', ['class' => 'navbar-mobile-item__main']) + diff --git a/resources/views/users/beatmapset_activities.blade.php b/resources/views/users/beatmapset_activities.blade.php index 3e11a8a6990..0a9e611a292 100644 --- a/resources/views/users/beatmapset_activities.blade.php +++ b/resources/views/users/beatmapset_activities.blade.php @@ -16,11 +16,9 @@ @section ("script") @parent - @foreach ($jsonChunks as $name => $data) - - @endforeach + @include('layout._react_js', ['src' => 'js/modding-profile.js']) @endsection diff --git a/routes/web.php b/routes/web.php index fad94074fa0..914afb7f989 100644 --- a/routes/web.php +++ b/routes/web.php @@ -407,14 +407,19 @@ // There's also a different group which skips throttle middleware. Route::group(['as' => 'api.', 'prefix' => 'api', 'middleware' => ['api', ThrottleRequests::getApiThrottle(), 'require-scopes']], function () { Route::group(['prefix' => 'v2'], function () { + Route::group(['middleware' => ['require-scopes:any']], function () { + Route::post('session/verify', 'AccountController@verify')->name('verify'); + Route::post('session/verify/reissue', 'AccountController@reissueCode')->name('verify.reissue'); + }); + Route::group(['as' => 'beatmaps.', 'prefix' => 'beatmaps'], function () { Route::get('lookup', 'BeatmapsController@lookup')->name('lookup'); Route::apiResource('packs', 'BeatmapPacksController', ['only' => ['index', 'show']]); Route::group(['prefix' => '{beatmap}'], function () { - Route::get('scores/users/{user}', 'BeatmapsController@userScore'); - Route::get('scores/users/{user}/all', 'BeatmapsController@userScoreAll'); + Route::get('scores/users/{user}', 'BeatmapsController@userScore')->name('user.score'); + Route::get('scores/users/{user}/all', 'BeatmapsController@userScoreAll')->name('user.scores'); Route::get('scores', 'BeatmapsController@scores')->name('scores'); Route::get('solo-scores', 'BeatmapsController@soloScores')->name('solo-scores'); diff --git a/tests/Browser/BeatmapDiscussionPostsTest.php b/tests/Browser/BeatmapDiscussionPostsTest.php index 8ef4c79259b..df0486d8836 100644 --- a/tests/Browser/BeatmapDiscussionPostsTest.php +++ b/tests/Browser/BeatmapDiscussionPostsTest.php @@ -15,7 +15,14 @@ class BeatmapDiscussionPostsTest extends DuskTestCase { - private $new_reply_widget_selector = '.beatmap-discussion-post--new-reply'; + private const NEW_REPLY_SELECTOR = '.beatmap-discussion-post--new-reply'; + private const RESOLVE_BUTTON_SELECTOR = '.btn-osu-big[data-action=reply_resolve]'; + + private Beatmap $beatmap; + private BeatmapDiscussion $beatmapDiscussion; + private Beatmapset $beatmapset; + private User $mapper; + private User $user; public function testConcurrentPostAfterResolve() { @@ -41,8 +48,8 @@ public function testConcurrentPostAfterResolve() protected function writeReply(Browser $browser, $reply) { - $browser->with($this->new_reply_widget_selector, function ($new_reply) use ($reply) { - $new_reply->press('Respond') + $browser->with(static::NEW_REPLY_SELECTOR, function (Browser $newReply) use ($reply) { + $newReply->press(trans('beatmap_discussions.reply.open.user')) ->waitFor('textarea') ->type('textarea', $reply); }); @@ -50,13 +57,16 @@ protected function writeReply(Browser $browser, $reply) protected function postReply(Browser $browser, $action) { - $browser->with($this->new_reply_widget_selector, function ($new_reply) use ($action) { + $browser->with(static::NEW_REPLY_SELECTOR, function (Browser $newReply) use ($action) { switch ($action) { case 'resolve': - $new_reply->press('Reply and Resolve'); + // button may be covered by dev banner; + // ->element->($selector)->getLocationOnScreenOnceScrolledIntoView() uses { block: 'end', inline: 'nearest' } which isn't enough. + $newReply->scrollIntoView(static::RESOLVE_BUTTON_SELECTOR); + $newReply->element(static::RESOLVE_BUTTON_SELECTOR)->click(); break; default: - $new_reply->keys('textarea', '{enter}'); + $newReply->keys('textarea', '{enter}'); break; } }); @@ -110,7 +120,7 @@ protected function setUp(): void $post = BeatmapDiscussionPost::factory()->timeline()->make([ 'user_id' => $this->user, ]); - $this->beatmapDiscussionPost = $this->beatmapDiscussion->beatmapDiscussionPosts()->save($post); + $this->beatmapDiscussion->beatmapDiscussionPosts()->save($post); $this->beforeApplicationDestroyed(function () { // Similar case to SanityTest, cleanup the models we created during the test. diff --git a/tests/Browser/SanityTest.php b/tests/Browser/SanityTest.php index d82db7403e3..069e28e3a42 100644 --- a/tests/Browser/SanityTest.php +++ b/tests/Browser/SanityTest.php @@ -294,6 +294,9 @@ private static function filterLog(array $log) } elseif ($line['message'] === "security - Error with Permissions-Policy header: Unrecognized feature: 'ch-ua-form-factor'.") { // we don't use ch-ua-* crap and this error is thrown by youtube.com as of 2023-05-16 continue; + } elseif (str_ends_with($line['message'], ' Third-party cookie will be blocked. Learn more in the Issues tab.')) { + // thanks, youtube + continue; } $return[] = $line; diff --git a/tests/Controllers/BeatmapsControllerSoloScoresTest.php b/tests/Controllers/BeatmapsControllerSoloScoresTest.php index eedbaf7c9f8..2dfab67f0bf 100644 --- a/tests/Controllers/BeatmapsControllerSoloScoresTest.php +++ b/tests/Controllers/BeatmapsControllerSoloScoresTest.php @@ -14,6 +14,7 @@ use App\Models\Genre; use App\Models\Group; use App\Models\Language; +use App\Models\OAuth; use App\Models\Solo\Score as SoloScore; use App\Models\User; use App\Models\UserGroup; @@ -40,109 +41,131 @@ public static function setUpBeforeClass(): void $countryAcronym = static::$user->country_acronym; + $otherUser2 = User::factory()->create(['country_acronym' => Country::factory()]); + $otherUser3SameCountry = User::factory()->create(['country_acronym' => $countryAcronym]); + static::$scores = []; - $scoreFactory = SoloScore::factory(); - foreach (['solo' => 0, 'legacy' => null] as $type => $buildId) { - $defaultData = ['build_id' => $buildId]; + $scoreFactory = SoloScore::factory()->state(['build_id' => 0]); + foreach (['solo' => false, 'legacy' => true] as $type => $isLegacy) { + $scoreFactory = $scoreFactory->state([ + 'legacy_score_id' => $isLegacy ? 1 : null, + ]); + $makeMods = fn (array $modNames): array => array_map( + fn (string $modName): array => [ + 'acronym' => $modName, + 'settings' => [], + ], + [...$modNames, ...($isLegacy ? ['CL'] : [])], + ); - static::$scores = array_merge(static::$scores, [ - "{$type}:user" => $scoreFactory->withData($defaultData, ['total_score' => 1100])->create([ + $makeTotalScores = fn (int $totalScore): array => [ + 'legacy_total_score' => $totalScore * ($isLegacy ? 1 : 0), + 'total_score' => $totalScore + ($isLegacy ? -1 : 0), + ]; + + static::$scores = [ + ...static::$scores, + "{$type}:userModsLowerScore" => $scoreFactory->withData([ + 'mods' => $makeMods(['DT', 'HD']), + ])->create([ + ...$makeTotalScores(1000), 'beatmap_id' => static::$beatmap, 'preserve' => true, 'user_id' => static::$user, ]), - "{$type}:userMods" => $scoreFactory->withData($defaultData, [ - 'total_score' => 1050, - 'mods' => static::defaultMods(['DT', 'HD']), + "{$type}:otherUserModsNCPFHigherScore" => $scoreFactory->withData([ + 'mods' => $makeMods(['NC', 'PF']), ])->create([ + ...$makeTotalScores(1010), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'user_id' => static::$user, + 'user_id' => static::$otherUser, ]), - "{$type}:userModsNC" => $scoreFactory->withData($defaultData, [ - 'total_score' => 1050, - 'mods' => static::defaultMods(['NC']), + "{$type}:userMods" => $scoreFactory->withData([ + 'mods' => $makeMods(['DT', 'HD']), ])->create([ + ...$makeTotalScores(1050), 'beatmap_id' => static::$beatmap, 'preserve' => true, 'user_id' => static::$user, ]), - "{$type}:otherUserModsNCPFHigherScore" => $scoreFactory->withData($defaultData, [ - 'total_score' => 1010, - 'mods' => static::defaultMods(['NC', 'PF']), + "{$type}:userModsNC" => $scoreFactory->withData([ + 'mods' => $makeMods(['NC']), ])->create([ + ...$makeTotalScores(1050), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'user_id' => static::$otherUser, + 'user_id' => static::$user, ]), - "{$type}:userModsLowerScore" => $scoreFactory->withData($defaultData, [ - 'total_score' => 1000, - 'mods' => static::defaultMods(['DT', 'HD']), - ])->create([ + "{$type}:user" => $scoreFactory->create([ + ...$makeTotalScores(1100), 'beatmap_id' => static::$beatmap, 'preserve' => true, 'user_id' => static::$user, ]), - "{$type}:friend" => $scoreFactory->withData($defaultData, ['total_score' => 1000])->create([ + "{$type}:friend" => $scoreFactory->create([ + ...$makeTotalScores(1000), 'beatmap_id' => static::$beatmap, 'preserve' => true, 'user_id' => $friend, ]), // With preference mods - "{$type}:otherUser" => $scoreFactory->withData($defaultData, [ - 'total_score' => 1000, - 'mods' => static::defaultMods(['PF']), + "{$type}:otherUser" => $scoreFactory->withData([ + 'mods' => $makeMods(['PF']), ])->create([ + ...$makeTotalScores(1000), 'beatmap_id' => static::$beatmap, 'preserve' => true, 'user_id' => static::$otherUser, ]), - "{$type}:otherUserMods" => $scoreFactory->withData($defaultData, [ - 'total_score' => 1000, - 'mods' => static::defaultMods(['HD', 'PF', 'NC']), + "{$type}:otherUserMods" => $scoreFactory->withData([ + 'mods' => $makeMods(['HD', 'PF', 'NC']), ])->create([ + ...$makeTotalScores(1000), 'beatmap_id' => static::$beatmap, 'preserve' => true, 'user_id' => static::$otherUser, ]), - "{$type}:otherUserModsExtraNonPreferences" => $scoreFactory->withData($defaultData, [ - 'total_score' => 1000, - 'mods' => static::defaultMods(['DT', 'HD', 'HR']), + "{$type}:otherUserModsExtraNonPreferences" => $scoreFactory->withData([ + 'mods' => $makeMods(['DT', 'HD', 'HR']), ])->create([ + ...$makeTotalScores(1000), 'beatmap_id' => static::$beatmap, 'preserve' => true, 'user_id' => static::$otherUser, ]), - "{$type}:otherUserModsUnrelated" => $scoreFactory->withData($defaultData, [ - 'total_score' => 1000, - 'mods' => static::defaultMods(['FL']), + "{$type}:otherUserModsUnrelated" => $scoreFactory->withData([ + 'mods' => $makeMods(['FL']), ])->create([ + ...$makeTotalScores(1000), 'beatmap_id' => static::$beatmap, 'preserve' => true, 'user_id' => static::$otherUser, ]), // Same total score but achieved later so it should come up after earlier score - "{$type}:otherUser2Later" => $scoreFactory->withData($defaultData, ['total_score' => 1000])->create([ + "{$type}:otherUser2Later" => $scoreFactory->create([ + ...$makeTotalScores(1000), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'user_id' => User::factory()->state(['country_acronym' => Country::factory()]), + 'user_id' => $otherUser2, ]), - "{$type}:otherUser3SameCountry" => $scoreFactory->withData($defaultData, ['total_score' => 1000])->create([ + "{$type}:otherUser3SameCountry" => $scoreFactory->create([ + ...$makeTotalScores(1000), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'user_id' => User::factory()->state(['country_acronym' => $countryAcronym]), + 'user_id' => $otherUser3SameCountry, ]), // Non-preserved score should be filtered out - "{$type}:nonPreserved" => $scoreFactory->withData($defaultData)->create([ + "{$type}:nonPreserved" => $scoreFactory->create([ 'beatmap_id' => static::$beatmap, 'preserve' => false, 'user_id' => User::factory()->state(['country_acronym' => Country::factory()]), ]), // Unrelated score - "{$type}:unrelated" => $scoreFactory->withData($defaultData)->create([ + "{$type}:unrelated" => $scoreFactory->create([ 'user_id' => User::factory()->state(['country_acronym' => Country::factory()]), ]), - ]); + ]; } UserRelation::create([ @@ -165,6 +188,8 @@ public static function tearDownAfterClass(): void Country::truncate(); Genre::truncate(); Language::truncate(); + OAuth\Client::truncate(); + OAuth\Token::truncate(); SoloScore::select()->delete(); // TODO: revert to truncate after the table is actually renamed User::truncate(); UserGroup::truncate(); @@ -174,25 +199,14 @@ public static function tearDownAfterClass(): void }); } - private static function defaultMods(array $modNames): array - { - return array_map( - fn ($modName) => [ - 'acronym' => $modName, - 'settings' => [], - ], - $modNames, - ); - } - /** * @dataProvider dataProviderForTestQuery * @group RequiresScoreIndexer */ - public function testQuery(array $scoreKeys, array $params) + public function testQuery(array $scoreKeys, array $params, string $route) { $resp = $this->actingAs(static::$user) - ->json('GET', route('beatmaps.solo-scores', static::$beatmap), $params) + ->json('GET', route("beatmaps.{$route}", static::$beatmap), $params) ->assertSuccessful(); $json = json_decode($resp->getContent(), true); @@ -202,46 +216,93 @@ public function testQuery(array $scoreKeys, array $params) } } + /** + * @group RequiresScoreIndexer + */ + public function testUserScore() + { + $url = route('api.beatmaps.user.score', [ + 'beatmap' => static::$beatmap->getKey(), + 'legacy_only' => 1, + 'mods' => ['DT', 'HD'], + 'user' => static::$user->getKey(), + ]); + $this->actAsScopedUser(static::$user); + $this + ->json('GET', $url) + ->assertJsonPath('score.id', static::$scores['legacy:userMods']->getKey()); + } + + /** + * @group RequiresScoreIndexer + */ + public function testUserScoreAll() + { + $url = route('api.beatmaps.user.scores', [ + 'beatmap' => static::$beatmap->getKey(), + 'legacy_only' => 1, + 'user' => static::$user->getKey(), + ]); + $this->actAsScopedUser(static::$user); + $this + ->json('GET', $url) + ->assertJsonCount(4, 'scores') + ->assertJsonPath( + 'scores.*.id', + array_map(fn (string $key): int => static::$scores[$key]->getKey(), [ + 'legacy:user', + 'legacy:userMods', + 'legacy:userModsNC', + 'legacy:userModsLowerScore', + ]) + ); + } + public static function dataProviderForTestQuery(): array { - return [ - 'no parameters' => [[ - 'solo:user', - 'solo:otherUserModsNCPFHigherScore', - 'solo:friend', - 'solo:otherUser2Later', - 'solo:otherUser3SameCountry', - ], []], - 'by country' => [[ - 'solo:user', - 'solo:otherUser3SameCountry', - ], ['type' => 'country']], - 'by friend' => [[ - 'solo:user', - 'solo:friend', - ], ['type' => 'friend']], - 'mods filter' => [[ - 'solo:userMods', - 'solo:otherUserMods', - ], ['mods' => ['DT', 'HD']]], - 'mods with implied filter' => [[ - 'solo:userModsNC', - 'solo:otherUserModsNCPFHigherScore', - ], ['mods' => ['NC']]], - 'mods with nomods' => [[ - 'solo:user', - 'solo:otherUserModsNCPFHigherScore', - 'solo:friend', - 'solo:otherUser2Later', - 'solo:otherUser3SameCountry', - ], ['mods' => ['NC', 'NM']]], - 'nomods filter' => [[ - 'solo:user', - 'solo:friend', - 'solo:otherUser', - 'solo:otherUser2Later', - 'solo:otherUser3SameCountry', - ], ['mods' => ['NM']]], - ]; + $ret = []; + foreach (['solo' => 'solo-scores', 'legacy' => 'scores'] as $type => $route) { + $ret = array_merge($ret, [ + "{$type}: no parameters" => [[ + "{$type}:user", + "{$type}:otherUserModsNCPFHigherScore", + "{$type}:friend", + "{$type}:otherUser2Later", + "{$type}:otherUser3SameCountry", + ], [], $route], + "{$type}: by country" => [[ + "{$type}:user", + "{$type}:otherUser3SameCountry", + ], ['type' => 'country'], $route], + "{$type}: by friend" => [[ + "{$type}:user", + "{$type}:friend", + ], ['type' => 'friend'], $route], + "{$type}: mods filter" => [[ + "{$type}:userMods", + "{$type}:otherUserMods", + ], ['mods' => ['DT', 'HD']], $route], + "{$type}: mods with implied filter" => [[ + "{$type}:userModsNC", + "{$type}:otherUserModsNCPFHigherScore", + ], ['mods' => ['NC']], $route], + "{$type}: mods with nomods" => [[ + "{$type}:user", + "{$type}:otherUserModsNCPFHigherScore", + "{$type}:friend", + "{$type}:otherUser2Later", + "{$type}:otherUser3SameCountry", + ], ['mods' => ['NC', 'NM']], $route], + "{$type}: nomods filter" => [[ + "{$type}:user", + "{$type}:friend", + "{$type}:otherUser", + "{$type}:otherUser2Later", + "{$type}:otherUser3SameCountry", + ], ['mods' => ['NM']], $route], + ]); + } + + return $ret; } } diff --git a/tests/Controllers/BeatmapsControllerTest.php b/tests/Controllers/BeatmapsControllerTest.php index 629272e79fd..17e97270780 100644 --- a/tests/Controllers/BeatmapsControllerTest.php +++ b/tests/Controllers/BeatmapsControllerTest.php @@ -10,12 +10,8 @@ use App\Models\Beatmap; use App\Models\Beatmapset; use App\Models\BeatmapsetEvent; -use App\Models\Country; -use App\Models\Score\Best\Model as ScoreBest; use App\Models\User; -use App\Models\UserRelation; use Illuminate\Testing\Fluent\AssertableJson; -use Illuminate\Testing\TestResponse; use Tests\TestCase; class BeatmapsControllerTest extends TestCase @@ -106,7 +102,7 @@ public function testInvalidMode() { $this->json('GET', route('beatmaps.scores', $this->beatmap), [ 'mode' => 'nope', - ])->assertStatus(404); + ])->assertStatus(422); } /** @@ -177,261 +173,6 @@ public function testScoresNonGeneralSupporter() ])->assertStatus(200); } - public function testScores() - { - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); - $scores = [ - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'score' => 1100, - 'user_id' => $this->user, - ]), - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'score' => 1000, - ]), - // Same total score but achieved later so it should come up after earlier score - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'score' => 1000, - ]), - ]; - // Hidden score should be filtered out - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'hidden' => true, - 'score' => 800, - ]); - // Another score from scores[0] user (should be filtered out) - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'score' => 800, - 'user_id' => $this->user, - ]); - // Unrelated score - ScoreBest::getClass(array_rand(Beatmap::MODES))::factory()->create(); - - $resp = $this->actingAs($this->user) - ->json('GET', route('beatmaps.scores', $this->beatmap)) - ->assertSuccessful(); - - $this->assertSameScoresFromResponse($scores, $resp); - } - - public function testScoresByCountry() - { - $countryAcronym = $this->user->country_acronym; - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); - $scores = [ - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'country_acronym' => $countryAcronym, - 'score' => 1100, - 'user_id' => $this->user, - ]), - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'score' => 1000, - 'country_acronym' => $countryAcronym, - 'user_id' => User::factory()->state(['country_acronym' => $countryAcronym]), - ]), - ]; - $otherCountry = Country::factory()->create(); - $otherCountryAcronym = $otherCountry->acronym; - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'country_acronym' => $otherCountryAcronym, - 'user_id' => User::factory()->state(['country_acronym' => $otherCountryAcronym]), - ]); - - $this->user->update(['osu_subscriber' => true]); - $resp = $this->actingAs($this->user) - ->json('GET', route('beatmaps.scores', ['beatmap' => $this->beatmap, 'type' => 'country'])) - ->assertSuccessful(); - - $this->assertSameScoresFromResponse($scores, $resp); - } - - public function testScoresByFriend() - { - $friend = User::factory()->create(); - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); - $scores = [ - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'score' => 1100, - 'user_id' => $friend, - ]), - // Own score is included - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'score' => 1000, - 'user_id' => $this->user, - ]), - ]; - UserRelation::create([ - 'friend' => true, - 'user_id' => $this->user->getKey(), - 'zebra_id' => $friend->getKey(), - ]); - // Non-friend score is filtered out - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - ]); - - $this->user->update(['osu_subscriber' => true]); - $resp = $this->actingAs($this->user) - ->json('GET', route('beatmaps.scores', ['beatmap' => $this->beatmap, 'type' => 'friend'])) - ->assertSuccessful(); - - $this->assertSameScoresFromResponse($scores, $resp); - } - - public function testScoresModsFilter() - { - $modsHelper = app('mods'); - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); - $scores = [ - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'HD']), - 'score' => 1500, - ]), - // Score with preference mods is included - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'HD', 'NC', 'PF']), - 'score' => 1100, - 'user_id' => $this->user, - ]), - ]; - // No mod is filtered out - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => 0, - ]); - // Unrelated mod is filtered out - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['FL']), - ]); - // Extra non-preference mod is filtered out - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'HD', 'HR']), - ]); - // From same user but lower score is filtered out - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'HD']), - 'score' => 1000, - 'user_id' => $this->user, - ]); - - $this->user->update(['osu_subscriber' => true]); - $resp = $this->actingAs($this->user) - ->json('GET', route('beatmaps.scores', ['beatmap' => $this->beatmap, 'mods' => ['DT', 'HD']])) - ->assertSuccessful(); - - $this->assertSameScoresFromResponse($scores, $resp); - } - - public function testScoresModsWithImpliedFilter() - { - $modsHelper = app('mods'); - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); - $scores = [ - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'NC']), - 'score' => 1500, - ]), - // Score with preference mods is included - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'NC', 'PF']), - 'score' => 1100, - 'user_id' => $this->user, - ]), - ]; - // No mod is filtered out - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => 0, - ]); - - $this->user->update(['osu_subscriber' => true]); - $resp = $this->actingAs($this->user) - ->json('GET', route('beatmaps.scores', ['beatmap' => $this->beatmap, 'mods' => ['NC']])) - ->assertSuccessful(); - - $this->assertSameScoresFromResponse($scores, $resp); - } - - public function testScoresModsWithNomodsFilter() - { - $modsHelper = app('mods'); - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); - $scores = [ - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'NC']), - 'score' => 1500, - ]), - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => 0, - 'score' => 1100, - 'user_id' => $this->user, - ]), - ]; - // With unrelated mod - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'NC', 'HD']), - 'score' => 1500, - ]); - - $this->user->update(['osu_subscriber' => true]); - $resp = $this->actingAs($this->user) - ->json('GET', route('beatmaps.scores', ['beatmap' => $this->beatmap, 'mods' => ['DT', 'NC', 'NM']])) - ->assertSuccessful(); - - $this->assertSameScoresFromResponse($scores, $resp); - } - - public function testScoresNomodsFilter() - { - $modsHelper = app('mods'); - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); - $scores = [ - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'score' => 1500, - 'enabled_mods' => 0, - ]), - // Preference mod is included - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'score' => 1100, - 'user_id' => $this->user, - 'enabled_mods' => $modsHelper->idsToBitset(['PF']), - ]), - ]; - // Non-preference mod is filtered out - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['DT']), - ]); - - $this->user->update(['osu_subscriber' => true]); - $resp = $this->actingAs($this->user) - ->json('GET', route('beatmaps.scores', ['beatmap' => $this->beatmap, 'mods' => ['NM']])) - ->assertSuccessful(); - - $this->assertSameScoresFromResponse($scores, $resp); - } - public function testShowForApi() { $beatmap = Beatmap::factory()->create(); @@ -621,15 +362,6 @@ protected function setUp(): void $this->beatmap = Beatmap::factory()->qualified()->create(); } - private function assertSameScoresFromResponse(array $scores, TestResponse $response): void - { - $json = json_decode($response->getContent(), true); - $this->assertSame(count($scores), count($json['scores'])); - foreach ($json['scores'] as $i => $jsonScore) { - $this->assertSame($scores[$i]->getKey(), $jsonScore['id']); - } - } - private function createExistingFruitsBeatmap() { return Beatmap::factory()->create([ diff --git a/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php b/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php index d9efb5b7fb9..62f8de1ed9a 100644 --- a/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php +++ b/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php @@ -23,19 +23,19 @@ public function testIndex() $scoreLinks[] = ScoreLink ::factory() ->state(['playlist_item_id' => $playlist]) - ->completed([], ['passed' => true, 'total_score' => 30]) + ->completed(['passed' => true, 'total_score' => 30]) ->create(); $scoreLinks[] = $userScoreLink = ScoreLink ::factory() ->state([ 'playlist_item_id' => $playlist, 'user_id' => $user, - ])->completed([], ['passed' => true, 'total_score' => 20]) + ])->completed(['passed' => true, 'total_score' => 20]) ->create(); $scoreLinks[] = ScoreLink ::factory() ->state(['playlist_item_id' => $playlist]) - ->completed([], ['passed' => true, 'total_score' => 10]) + ->completed(['passed' => true, 'total_score' => 10]) ->create(); foreach ($scoreLinks as $scoreLink) { @@ -65,19 +65,19 @@ public function testShow() $scoreLinks[] = ScoreLink ::factory() ->state(['playlist_item_id' => $playlist]) - ->completed([], ['passed' => true, 'total_score' => 30]) + ->completed(['passed' => true, 'total_score' => 30]) ->create(); $scoreLinks[] = $userScoreLink = ScoreLink ::factory() ->state([ 'playlist_item_id' => $playlist, 'user_id' => $user, - ])->completed([], ['passed' => true, 'total_score' => 20]) + ])->completed(['passed' => true, 'total_score' => 20]) ->create(); $scoreLinks[] = ScoreLink ::factory() ->state(['playlist_item_id' => $playlist]) - ->completed([], ['passed' => true, 'total_score' => 10]) + ->completed(['passed' => true, 'total_score' => 10]) ->create(); foreach ($scoreLinks as $scoreLink) { @@ -103,15 +103,18 @@ public function testShow() */ public function testStore($allowRanking, $hashParam, $status) { + $origClientCheckVersion = $GLOBALS['cfg']['osu']['client']['check_version']; + config_set('osu.client.check_version', true); $user = User::factory()->create(); $playlistItem = PlaylistItem::factory()->create(); $build = Build::factory()->create(['allow_ranking' => $allowRanking]); $this->actAsScopedUser($user, ['*']); - $params = []; if ($hashParam !== null) { - $params['version_hash'] = $hashParam ? bin2hex($build->hash) : md5('invalid_'); + $this->withHeaders([ + 'x-token' => $hashParam ? static::createClientToken($build) : strtoupper(md5('invalid_')), + ]); } $countDiff = ((string) $status)[0] === '2' ? 1 : 0; @@ -120,7 +123,9 @@ public function testStore($allowRanking, $hashParam, $status) $this->json('POST', route('api.rooms.playlist.scores.store', [ 'room' => $playlistItem->room_id, 'playlist' => $playlistItem->getKey(), - ]), $params)->assertStatus($status); + ]))->assertStatus($status); + + config_set('osu.client.check_version', $origClientCheckVersion); } /** @@ -134,6 +139,13 @@ public function testUpdate($bodyParams, $status) $build = Build::factory()->create(['allow_ranking' => true]); $scoreToken = $room->startPlay($user, $playlistItem, 0); + $this->withHeaders(['x-token' => static::createClientToken($build)]); + + $this->expectCountChange( + fn () => \LaravelRedis::llen($GLOBALS['cfg']['osu']['client']['token_queue']), + $status === 200 ? 1 : 0, + ); + $this->actAsScopedUser($user, ['*']); $url = route('api.rooms.playlist.scores.update', [ diff --git a/tests/Controllers/OAuth/TokensControllerTest.php b/tests/Controllers/OAuth/TokensControllerTest.php index 296fef5edd0..04df9487451 100644 --- a/tests/Controllers/OAuth/TokensControllerTest.php +++ b/tests/Controllers/OAuth/TokensControllerTest.php @@ -5,12 +5,26 @@ namespace Tests\Controllers\OAuth; +use App\Libraries\OAuth\EncodeToken; +use App\Mail\UserVerification as UserVerificationMail; +use App\Models\OAuth\Client; use App\Models\OAuth\Token; +use App\Models\User; +use Database\Factories\OAuth\ClientFactory; use Database\Factories\OAuth\RefreshTokenFactory; +use Database\Factories\UserFactory; use Tests\TestCase; class TokensControllerTest extends TestCase { + public static function dataProviderForTestIssueTokenWithRefreshTokenInheritsVerified(): array + { + return [ + [true], + [false], + ]; + } + public function testDestroyCurrent() { $refreshToken = (new RefreshTokenFactory())->create(); @@ -36,4 +50,71 @@ public function testDestroyCurrentClientGrant() $this->assertTrue($token->fresh()->revoked); } + + public function testIssueTokenWithPassword(): void + { + \Mail::fake(); + + $user = User::factory()->create(); + $client = (new ClientFactory())->create([ + 'password_client' => true, + ]); + + $this->expectCountChange(fn () => $user->tokens()->count(), 1); + + $tokenJson = $this->json('POST', route('oauth.passport.token'), [ + 'grant_type' => 'password', + 'client_id' => $client->getKey(), + 'client_secret' => $client->secret, + 'scope' => '*', + 'username' => $user->username, + 'password' => UserFactory::DEFAULT_PASSWORD, + ])->assertSuccessful() + ->decodeResponseJson(); + + $this->json('GET', route('api.me'), [], [ + 'Authorization' => "Bearer {$tokenJson['access_token']}", + ])->assertSuccessful() + ->assertJsonPath('session_verified', false); + + // unverified access to api should trigger this but not necessarily return 401 + \Mail::assertQueued(UserVerificationMail::class); + } + + /** + * @dataProvider dataProviderForTestIssueTokenWithRefreshTokenInheritsVerified + */ + public function testIssueTokenWithRefreshTokenInheritsVerified(bool $verified): void + { + \Mail::fake(); + + $client = Client::factory()->create(['password_client' => true]); + $accessToken = Token::factory()->create([ + 'client_id' => $client, + 'scopes' => ['*'], + 'verified' => $verified, + ]); + $refreshToken = (new RefreshTokenFactory()) + ->create(['access_token_id' => $accessToken]); + $refreshTokenString = EncodeToken::encodeRefreshToken($refreshToken, $accessToken); + $user = $accessToken->user; + + $this->expectCountChange(fn () => $user->tokens()->count(), 1); + + $tokenJson = $this->json('POST', route('oauth.passport.token'), [ + 'grant_type' => 'refresh_token', + 'client_id' => $client->getKey(), + 'client_secret' => $client->secret, + 'refresh_token' => $refreshTokenString, + 'scope' => implode(' ', $accessToken->scopes), + ])->assertSuccessful() + ->decodeResponseJson(); + + $this->json('GET', route('api.me'), [], [ + 'Authorization' => "Bearer {$tokenJson['access_token']}", + ])->assertSuccessful() + ->assertJsonPath('session_verified', $verified); + + \Mail::assertQueued(UserVerificationMail::class, $verified ? 0 : 1); + } } diff --git a/tests/Controllers/PasswordResetControllerTest.php b/tests/Controllers/PasswordResetControllerTest.php index 579bab20592..55fd6c6a30a 100644 --- a/tests/Controllers/PasswordResetControllerTest.php +++ b/tests/Controllers/PasswordResetControllerTest.php @@ -17,6 +17,8 @@ class PasswordResetControllerTest extends TestCase { + private string $origCacheDefault; + private static function randomPassword(): string { return str_random(10); @@ -283,6 +285,15 @@ protected function setUp(): void { parent::setUp(); $this->withoutMiddleware(ThrottleRequests::class); + // There's no easy way to clear data cache in redis otherwise + $this->origCacheDefault = $GLOBALS['cfg']['cache']['default']; + config_set('cache.default', 'array'); + } + + protected function tearDown(): void + { + parent::tearDown(); + config_set('cache.default', $this->origCacheDefault); } private function generateKey(User $user): string diff --git a/tests/Controllers/ScoreTokensControllerTest.php b/tests/Controllers/ScoreTokensControllerTest.php index 93910a7c6ca..f52a960e938 100644 --- a/tests/Controllers/ScoreTokensControllerTest.php +++ b/tests/Controllers/ScoreTokensControllerTest.php @@ -29,10 +29,8 @@ public function testStore(string $beatmapState, int $status): void 'beatmap' => $beatmap->getKey(), 'ruleset_id' => $beatmap->playmode, ]; - $bodyParams = [ - 'beatmap_hash' => $beatmap->checksum, - 'version_hash' => bin2hex($this->build->hash), - ]; + $bodyParams = ['beatmap_hash' => $beatmap->checksum]; + $this->withHeaders(['x-token' => static::createClientToken($this->build)]); $this->expectCountChange(fn () => ScoreToken::count(), $status >= 200 && $status < 300 ? 1 : 0); @@ -49,6 +47,8 @@ public function testStore(string $beatmapState, int $status): void */ public function testStoreInvalidParameter(string $paramKey, ?string $paramValue, int $status): void { + $origClientCheckVersion = $GLOBALS['cfg']['osu']['client']['check_version']; + config_set('osu.client.check_version', true); $beatmap = Beatmap::factory()->ranked()->create(); $this->actAsScopedUser($this->user, ['*']); @@ -56,10 +56,17 @@ public function testStoreInvalidParameter(string $paramKey, ?string $paramValue, $params = [ 'beatmap' => $beatmap->getKey(), 'ruleset_id' => $beatmap->playmode, - 'version_hash' => bin2hex($this->build->hash), 'beatmap_hash' => $beatmap->checksum, ]; - $params[$paramKey] = $paramValue; + $this->withHeaders([ + 'x-token' => $paramKey === 'client_token' + ? $paramValue + : static::createClientToken($this->build), + ]); + + if ($paramKey !== 'client_token') { + $params[$paramKey] = $paramValue; + } $routeParams = [ 'beatmap' => $params['beatmap'], @@ -67,16 +74,15 @@ public function testStoreInvalidParameter(string $paramKey, ?string $paramValue, ]; $bodyParams = [ 'beatmap_hash' => $params['beatmap_hash'], - 'version_hash' => $params['version_hash'], ]; $this->expectCountChange(fn () => ScoreToken::count(), 0); $errorMessage = $paramValue === null ? 'missing' : 'invalid'; $errorMessage .= ' '; - $errorMessage .= $paramKey === 'version_hash' + $errorMessage .= $paramKey === 'client_token' ? ($paramValue === null - ? 'client version' + ? 'token header' : 'client hash' ) : $paramKey; @@ -88,6 +94,8 @@ public function testStoreInvalidParameter(string $paramKey, ?string $paramValue, ->assertJson([ 'error' => $errorMessage, ]); + + config_set('osu.client.check_version', $origClientCheckVersion); } public static function dataProviderForTestStore(): array @@ -104,8 +112,8 @@ public static function dataProviderForTestStore(): array public static function dataProviderForTestStoreInvalidParameter(): array { return [ - 'invalid build hash' => ['version_hash', md5('invalid_'), 422], - 'missing build hash' => ['version_hash', null, 422], + 'invalid client token' => ['client_token', md5('invalid_'), 422], + 'missing client token' => ['client_token', null, 422], 'invalid ruleset id' => ['ruleset_id', '5', 422], 'missing ruleset id' => ['ruleset_id', null, 422], diff --git a/tests/Controllers/ScoresControllerTest.php b/tests/Controllers/ScoresControllerTest.php index ec4a585a24f..3620fa5b494 100644 --- a/tests/Controllers/ScoresControllerTest.php +++ b/tests/Controllers/ScoresControllerTest.php @@ -33,8 +33,8 @@ public function testDownload() public function testDownloadSoloScore() { $soloScore = SoloScore::factory() - ->withData(['legacy_score_id' => $this->score->getKey()]) ->create([ + 'legacy_score_id' => $this->score->getKey(), 'ruleset_id' => Beatmap::MODES[$this->score->getMode()], 'has_replay' => true, ]); diff --git a/tests/Controllers/Solo/ScoresControllerTest.php b/tests/Controllers/Solo/ScoresControllerTest.php index 084cbfcd481..3552e735f90 100644 --- a/tests/Controllers/Solo/ScoresControllerTest.php +++ b/tests/Controllers/Solo/ScoresControllerTest.php @@ -5,7 +5,7 @@ namespace Tests\Controllers\Solo; -use App\Models\Score as LegacyScore; +use App\Models\Build; use App\Models\ScoreToken; use App\Models\Solo\Score; use App\Models\User; @@ -16,13 +16,17 @@ class ScoresControllerTest extends TestCase { public function testStore() { - $scoreToken = ScoreToken::factory()->create(); - $legacyScoreClass = LegacyScore\Model::getClassByRulesetId($scoreToken->beatmap->playmode); + $build = Build::factory()->create(['allow_ranking' => true]); + $scoreToken = ScoreToken::factory()->create(['build_id' => $build]); $this->expectCountChange(fn () => Score::count(), 1); - $this->expectCountChange(fn () => $legacyScoreClass::count(), 1); $this->expectCountChange(fn () => $this->processingQueueCount(), 1); + $this->expectCountChange( + fn () => \LaravelRedis::llen($GLOBALS['cfg']['osu']['client']['token_queue']), + 1, + ); + $this->withHeaders(['x-token' => static::createClientToken($build)]); $this->actAsScopedUser($scoreToken->user, ['*']); $this->json( diff --git a/tests/Controllers/UsersControllerTest.php b/tests/Controllers/UsersControllerTest.php index 43670ca2d38..5c0a4df2783 100644 --- a/tests/Controllers/UsersControllerTest.php +++ b/tests/Controllers/UsersControllerTest.php @@ -50,9 +50,10 @@ public function testStore() $this->assertSame($previousCount + 1, User::count()); } - public function testStoreRegModeWeb() + public function testStoreRegModeWebOnly() { - config_set('osu.user.registration_mode', 'web'); + config_set('osu.user.registration_mode.client', false); + config_set('osu.user.registration_mode.web', true); $this->expectCountChange(fn () => User::count(), 0); $this @@ -131,8 +132,11 @@ public function testStoreInvalid() $this->assertSame($previousCount, User::count()); } - public function testStoreWebRegModeClient() + public function testStoreWebRegModeClientOnly() { + config_set('osu.user.registration_mode.client', true); + config_set('osu.user.registration_mode.web', false); + $this->expectCountChange(fn () => User::count(), 0); $this->post(route('users.store'), [ @@ -149,7 +153,7 @@ public function testStoreWebRegModeClient() public function testStoreWeb(): void { - config_set('osu.user.registration_mode', 'web'); + config_set('osu.user.registration_mode.web', true); $this->expectCountChange(fn () => User::count(), 1); $this->post(route('users.store-web'), [ @@ -168,7 +172,7 @@ public function testStoreWeb(): void */ public function testStoreWebInvalidParams($username, $email, $emailConfirmation, $password, $passwordConfirmation): void { - config_set('osu.user.registration_mode', 'web'); + config_set('osu.user.registration_mode.web', true); $this->expectCountChange(fn () => User::count(), 0); $this->post(route('users.store-web'), [ @@ -184,7 +188,7 @@ public function testStoreWebInvalidParams($username, $email, $emailConfirmation, public function testStoreWebLoggedIn(): void { - config_set('osu.user.registration_mode', 'web'); + config_set('osu.user.registration_mode.web', true); $user = User::factory()->create(); $this->expectCountChange(fn () => User::count(), 0); diff --git a/tests/Jobs/RemoveBeatmapsetSoloScoresTest.php b/tests/Jobs/RemoveBeatmapsetSoloScoresTest.php index e378f83e857..df4d3cb592e 100644 --- a/tests/Jobs/RemoveBeatmapsetSoloScoresTest.php +++ b/tests/Jobs/RemoveBeatmapsetSoloScoresTest.php @@ -16,7 +16,6 @@ use App\Models\Group; use App\Models\Language; use App\Models\Solo\Score; -use App\Models\Solo\ScorePerformance; use App\Models\User; use App\Models\UserGroup; use App\Models\UserGroupEvent; @@ -36,9 +35,6 @@ public function testHandle() fn (): Score => $this->createScore($beatmapset), array_fill(0, 10, null), ); - foreach ($scores as $i => $score) { - $score->performance()->create(['pp' => rand(0, 1000)]); - } $userAdditionalScores = array_map( fn (Score $score) => $this->createScore($beatmapset, $score->user_id, $score->ruleset_id), $scores, @@ -48,12 +44,10 @@ public function testHandle() // These scores shouldn't be deleted for ($i = 0; $i < 10; $i++) { - $score = $this->createScore($beatmapset); - $score->performance()->create(['pp' => rand(0, 1000)]); + $this->createScore($beatmapset); } $this->expectCountChange(fn () => Score::count(), count($scores) * -2, 'removes scores'); - $this->expectCountChange(fn () => ScorePerformance::count(), count($scores) * -1, 'removes score performances'); static::reindexScores(); @@ -71,7 +65,6 @@ public function testHandle() Genre::truncate(); Language::truncate(); Score::select()->delete(); // TODO: revert to truncate after the table is actually renamed - ScorePerformance::select()->delete(); // TODO: revert to truncate after the table is actually renamed User::truncate(); UserGroup::truncate(); UserGroupEvent::truncate(); diff --git a/tests/Libraries/ClientCheckTest.php b/tests/Libraries/ClientCheckTest.php index 3ffede7b3b1..40a23107f8c 100644 --- a/tests/Libraries/ClientCheckTest.php +++ b/tests/Libraries/ClientCheckTest.php @@ -7,84 +7,43 @@ use App\Libraries\ClientCheck; use App\Models\Build; -use App\Models\User; use Tests\TestCase; class ClientCheckTest extends TestCase { - public function testFindBuild() + public function testParseToken(): void { - $user = User::factory()->withGroup('default')->create(); $build = Build::factory()->create(['allow_ranking' => true]); + $request = \Request::instance(); + $request->headers->set('x-token', static::createClientToken($build)); - $foundBuild = ClientCheck::findBuild($user, ['version_hash' => bin2hex($build->hash)]); + $parsed = ClientCheck::parseToken($request); - $this->assertSame($build->getKey(), $foundBuild->getKey()); + $this->assertSame($build->getKey(), $parsed['buildId']); + $this->assertNotNull($parsed['token']); } - public function testFindBuildAsAdmin() + public function testParseTokenExpired() { - $user = User::factory()->withGroup('admin')->create(); $build = Build::factory()->create(['allow_ranking' => true]); + $request = \Request::instance(); + $request->headers->set('x-token', static::createClientToken($build, 0)); - $foundBuild = ClientCheck::findBuild($user, ['version_hash' => bin2hex($build->hash)]); + $parsed = ClientCheck::parseToken($request); - $this->assertSame($build->getKey(), $foundBuild->getKey()); + $this->assertSame($build->getKey(), $parsed['buildId']); + $this->assertNull($parsed['token']); } - public function testFindBuildDisallowedRanking() + public function testParseTokenNonRankedBuild(): void { - $user = User::factory()->withGroup('default')->create(); $build = Build::factory()->create(['allow_ranking' => false]); + $request = \Request::instance(); + $request->headers->set('x-token', static::createClientToken($build)); - $this->expectExceptionMessage('invalid client hash'); - ClientCheck::findBuild($user, ['version_hash' => bin2hex($build->hash)]); - } - - public function testFindBuildMissingParam() - { - $user = User::factory()->withGroup('default')->create(); - - $this->expectExceptionMessage('missing client version'); - ClientCheck::findBuild($user, []); - } - - public function testFindBuildNonexistent() - { - $user = User::factory()->withGroup('default')->create(); - - $this->expectExceptionMessage('invalid client hash'); - ClientCheck::findBuild($user, ['version_hash' => 'stuff']); - } - - public function testFindBuildNonexistentAsAdmin() - { - $user = User::factory()->withGroup('admin')->create(); - - $foundBuild = ClientCheck::findBuild($user, ['version_hash' => 'stuff']); - - $this->assertNull($foundBuild); - } - - public function testFindBuildNonexistentWithDisabledAssertion() - { - config_set('osu.client.check_version', false); - - $user = User::factory()->withGroup('default')->create(); - - $foundBuild = ClientCheck::findBuild($user, ['version_hash' => 'stuff']); - - $this->assertNull($foundBuild); - } - - public function testFindBuildStringHash() - { - $user = User::factory()->withGroup('default')->create(); - $hashString = 'hello'; - $build = Build::factory()->create(['allow_ranking' => true, 'hash' => md5($hashString, true)]); - - $foundBuild = ClientCheck::findBuild($user, ['version_hash' => $hashString]); + $parsed = ClientCheck::parseToken($request); - $this->assertSame($build->getKey(), $foundBuild->getKey()); + $this->assertSame($GLOBALS['cfg']['osu']['client']['default_build_id'], $parsed['buildId']); + $this->assertNull($parsed['token']); } } diff --git a/tests/Libraries/SessionVerification/ControllerTest.php b/tests/Libraries/SessionVerification/ControllerTest.php index 58f3ecc3da9..6589cb63480 100644 --- a/tests/Libraries/SessionVerification/ControllerTest.php +++ b/tests/Libraries/SessionVerification/ControllerTest.php @@ -11,6 +11,8 @@ use App\Libraries\SessionVerification; use App\Mail\UserVerification as UserVerificationMail; use App\Models\LoginAttempt; +use App\Models\OAuth\Client; +use App\Models\OAuth\Token; use App\Models\User; use Tests\TestCase; @@ -56,6 +58,37 @@ public function testReissue(): void $this->assertNotSame($state->key, SessionVerification\State::fromSession($session)->key); } + public function testReissueOAuthVerified(): void + { + \Mail::fake(); + $token = Token::factory()->create(['verified' => true]); + + $this + ->actingWithToken($token) + ->post(route('api.verify.reissue')) + ->assertStatus(422); + + \Mail::assertNotQueued(UserVerificationMail::class); + $this->assertNull(SessionVerification\State::fromSession($token)); + } + + public function testReissueVerified(): void + { + \Mail::fake(); + $user = User::factory()->create(); + $session = \Session::instance(); + $session->markVerified(); + + $this + ->be($user) + ->withPersistentSession($session) + ->post(route('account.reissue-code')) + ->assertStatus(422); + + \Mail::assertNotQueued(UserVerificationMail::class); + $this->assertNull(SessionVerification\State::fromSession($session)); + } + public function testVerify(): void { $user = User::factory()->create(); @@ -131,6 +164,32 @@ public function testVerifyLinkMismatch(): void $this->assertFalse(SessionStore::findOrNew($sessionId)->isVerified()); } + public function testVerifyLinkOAuth(): void + { + $token = Token::factory()->create([ + 'client_id' => Client::factory()->create(['password_client' => true]), + 'verified' => false, + ]); + + $this + ->actingWithToken($token) + ->get(route('api.me')) + ->assertSuccessful(); + + $linkKey = SessionVerification\State::fromSession($token)->linkKey; + + \Auth::logout(); + $this + ->withPersistentSession(SessionStore::findOrNew()) + ->get(route('account.verify', ['key' => $linkKey])) + ->assertSuccessful(); + + $record = LoginAttempt::find('127.0.0.1'); + + $this->assertFalse($record->containsUser($token->user, 'verify-mismatch:')); + $this->assertTrue($token->fresh()->isVerified()); + } + public function testVerifyMismatch(): void { $user = User::factory()->create(); @@ -156,4 +215,60 @@ public function testVerifyMismatch(): void $this->assertTrue($record->containsUser($user, 'verify-mismatch:')); $this->assertFalse($session->isVerified()); } + + public function testVerifyOAuth(): void + { + $token = Token::factory()->create([ + 'client_id' => Client::factory()->create(['password_client' => true]), + 'verified' => false, + ]); + + $this + ->actingWithToken($token) + ->get(route('api.me')) + ->assertSuccessful(); + + $key = SessionVerification\State::fromSession($token)->key; + + $this + ->actingWithToken($token) + ->post(route('api.verify', ['verification_key' => $key])) + ->assertSuccessful(); + + $record = LoginAttempt::find('127.0.0.1'); + + $this->assertFalse($record->containsUser($token->user, 'verify-mismatch:')); + $this->assertTrue($token->fresh()->isVerified()); + } + + public function testVerifyOAuthVerified(): void + { + \Mail::fake(); + $token = Token::factory()->create(['verified' => true]); + + $this + ->actingWithToken($token) + ->post(route('api.verify', ['verification_key' => 'invalid'])) + ->assertSuccessful(); + + $this->assertNull(SessionVerification\State::fromSession($token)); + \Mail::assertNotQueued(UserVerificationMail::class); + } + + public function testVerifyVerified(): void + { + \Mail::fake(); + $user = User::factory()->create(); + $session = \Session::instance(); + $session->markVerified(); + + $this + ->be($user) + ->withPersistentSession($session) + ->post(route('account.verify'), ['verification_key' => 'invalid']) + ->assertSuccessful(); + + $this->assertNull(SessionVerification\State::fromSession($session)); + \Mail::assertNotQueued(UserVerificationMail::class); + } } diff --git a/tests/Models/BeatmapPackUserCompletionTest.php b/tests/Models/BeatmapPackUserCompletionTest.php index 253a0290951..ca81f9075a2 100644 --- a/tests/Models/BeatmapPackUserCompletionTest.php +++ b/tests/Models/BeatmapPackUserCompletionTest.php @@ -7,53 +7,97 @@ namespace Tests\Models; +use App\Libraries\Search\ScoreSearch; use App\Models\Beatmap; use App\Models\BeatmapPack; -use App\Models\Score\Best as ScoreBest; +use App\Models\BeatmapPackItem; +use App\Models\Beatmapset; +use App\Models\Country; +use App\Models\Genre; +use App\Models\Group; +use App\Models\Language; +use App\Models\Solo\Score; use App\Models\User; +use App\Models\UserGroup; +use App\Models\UserGroupEvent; use Tests\TestCase; +/** + * @group RequiresScoreIndexer + */ class BeatmapPackUserCompletionTest extends TestCase { + private static array $users; + private static BeatmapPack $pack; + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + static::withDbAccess(function () { + $beatmap = Beatmap::factory()->ranked()->state([ + 'playmode' => Beatmap::MODES['taiko'], + ])->create(); + static::$pack = BeatmapPack::factory()->create(); + static::$pack->items()->create(['beatmapset_id' => $beatmap->beatmapset_id]); + + static::$users = [ + 'convertOsu' => User::factory()->create(), + 'default' => User::factory()->create(), + 'null' => null, + 'unrelated' => User::factory()->create(), + ]; + + Score::factory()->create([ + 'beatmap_id' => $beatmap, + 'ruleset_id' => Beatmap::MODES['osu'], + 'preserve' => true, + 'user_id' => static::$users['convertOsu'], + ]); + Score::factory()->create([ + 'beatmap_id' => $beatmap, + 'preserve' => true, + 'user_id' => static::$users['default'], + ]); + + static::reindexScores(); + }); + } + + public static function tearDownAfterClass(): void + { + static::withDbAccess(function () { + Beatmap::truncate(); + BeatmapPack::truncate(); + BeatmapPackItem::truncate(); + Beatmapset::truncate(); + Country::truncate(); + Genre::truncate(); + Language::truncate(); + Score::select()->delete(); // TODO: revert to truncate after the table is actually renamed + User::truncate(); + UserGroup::truncate(); + UserGroupEvent::truncate(); + (new ScoreSearch())->deleteAll(); + }); + + parent::tearDownAfterClass(); + } + + protected $connectionsToTransact = []; + /** * @dataProvider dataProviderForTestBasic */ public function testBasic(string $userType, ?string $packRuleset, bool $completed): void { - $beatmap = Beatmap::factory()->ranked()->state([ - 'playmode' => Beatmap::MODES['taiko'], - ])->create(); - $pack = BeatmapPack::factory()->create(); - $pack->items()->create(['beatmapset_id' => $beatmap->beatmapset_id]); - - $scoreUser = User::factory()->create(); - $scoreClass = ScoreBest\Taiko::class; - switch ($userType) { - case 'convertOsu': - $checkUser = $scoreUser; - $scoreClass = ScoreBest\Osu::class; - break; - case 'default': - $checkUser = $scoreUser; - break; - case 'null': - $checkUser = null; - break; - case 'unrelated': - $checkUser = User::factory()->create(); - break; - } - - $scoreClass::factory()->create([ - 'beatmap_id' => $beatmap, - 'user_id' => $scoreUser->getKey(), - ]); + $user = static::$users[$userType]; $rulesetId = $packRuleset === null ? null : Beatmap::MODES[$packRuleset]; - $pack->update(['playmode' => $rulesetId]); - $pack->refresh(); + static::$pack->update(['playmode' => $rulesetId]); + static::$pack->refresh(); - $data = $pack->userCompletionData($checkUser); + $data = static::$pack->userCompletionData($user, null); $this->assertSame($completed ? 1 : 0, count($data['beatmapset_ids'])); $this->assertSame($completed, $data['completed']); } diff --git a/tests/Models/ContestTest.php b/tests/Models/ContestTest.php index acad06b2096..6fb7ed5e8db 100644 --- a/tests/Models/ContestTest.php +++ b/tests/Models/ContestTest.php @@ -78,7 +78,7 @@ public function testAssertVoteRequirementPlaylistBeatmapsets( MultiplayerScoreLink::factory()->state([ 'playlist_item_id' => $playlistItem, 'user_id' => $userId, - ])->completed([], [ + ])->completed([ 'ended_at' => $endedAt, 'passed' => $passed, ])->create(); diff --git a/tests/Models/Multiplayer/ScoreLinkTest.php b/tests/Models/Multiplayer/ScoreLinkTest.php index cc1e09f7800..efc84c75220 100644 --- a/tests/Models/Multiplayer/ScoreLinkTest.php +++ b/tests/Models/Multiplayer/ScoreLinkTest.php @@ -12,11 +12,26 @@ use App\Models\Multiplayer\PlaylistItem; use App\Models\Multiplayer\ScoreLink; use App\Models\ScoreToken; -use Carbon\Carbon; use Tests\TestCase; class ScoreLinkTest extends TestCase { + private static array $commonScoreParams; + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + static::$commonScoreParams = [ + 'accuracy' => 0.5, + 'ended_at' => new \DateTime(), + 'max_combo' => 1, + 'statistics' => [ + 'great' => 1, + ], + 'total_score' => 1, + ]; + } + public function testRequiredModsMissing() { $playlistItem = PlaylistItem::factory()->create([ @@ -32,14 +47,10 @@ public function testRequiredModsMissing() $this->expectException(InvariantException::class); $this->expectExceptionMessage('This play does not include the mods required.'); ScoreLink::complete($scoreToken, [ + ...static::$commonScoreParams, 'beatmap_id' => $playlistItem->beatmap_id, 'ruleset_id' => $playlistItem->ruleset_id, 'user_id' => $scoreToken->user_id, - 'ended_at' => json_date(Carbon::now()), - 'mods' => [], - 'statistics' => [ - 'great' => 1, - ], ]); } @@ -57,14 +68,11 @@ public function testRequiredModsPresent() $this->expectNotToPerformAssertions(); ScoreLink::complete($scoreToken, [ + ...static::$commonScoreParams, 'beatmap_id' => $playlistItem->beatmap_id, + 'mods' => [['acronym' => 'HD']], 'ruleset_id' => $playlistItem->ruleset_id, 'user_id' => $scoreToken->user_id, - 'ended_at' => json_date(Carbon::now()), - 'mods' => [['acronym' => 'HD']], - 'statistics' => [ - 'great' => 1, - ], ]); } @@ -85,17 +93,14 @@ public function testExpectedAllowedMod() $this->expectNotToPerformAssertions(); ScoreLink::complete($scoreToken, [ + ...static::$commonScoreParams, 'beatmap_id' => $playlistItem->beatmap_id, - 'ruleset_id' => $playlistItem->ruleset_id, - 'user_id' => $scoreToken->user_id, - 'ended_at' => json_date(Carbon::now()), 'mods' => [ ['acronym' => 'DT'], ['acronym' => 'HD'], ], - 'statistics' => [ - 'great' => 1, - ], + 'ruleset_id' => $playlistItem->ruleset_id, + 'user_id' => $scoreToken->user_id, ]); } @@ -117,17 +122,14 @@ public function testUnexpectedAllowedMod() $this->expectException(InvariantException::class); $this->expectExceptionMessage('This play includes mods that are not allowed.'); ScoreLink::complete($scoreToken, [ + ...static::$commonScoreParams, 'beatmap_id' => $playlistItem->beatmap_id, - 'ruleset_id' => $playlistItem->ruleset_id, - 'user_id' => $scoreToken->user_id, - 'ended_at' => json_date(Carbon::now()), 'mods' => [ ['acronym' => 'DT'], ['acronym' => 'HD'], ], - 'statistics' => [ - 'great' => 1, - ], + 'ruleset_id' => $playlistItem->ruleset_id, + 'user_id' => $scoreToken->user_id, ]); } @@ -142,14 +144,11 @@ public function testUnexpectedModWhenNoModsAreAllowed() $this->expectException(InvariantException::class); $this->expectExceptionMessage('This play includes mods that are not allowed.'); ScoreLink::complete($scoreToken, [ + ...static::$commonScoreParams, 'beatmap_id' => $playlistItem->beatmap_id, + 'mods' => [['acronym' => 'HD']], 'ruleset_id' => $playlistItem->ruleset_id, 'user_id' => $scoreToken->user_id, - 'ended_at' => json_date(Carbon::now()), - 'mods' => [['acronym' => 'HD']], - 'statistics' => [ - 'great' => 1, - ], ]); } @@ -170,14 +169,11 @@ public function testUnexpectedModAcceptedIfAlwaysValidForSubmission() $this->expectNotToPerformAssertions(); ScoreLink::complete($scoreToken, [ + ...static::$commonScoreParams, 'beatmap_id' => $playlistItem->beatmap_id, + 'mods' => [['acronym' => 'TD']], 'ruleset_id' => $playlistItem->ruleset_id, 'user_id' => $scoreToken->user_id, - 'ended_at' => json_date(Carbon::now()), - 'mods' => [['acronym' => 'TD']], - 'statistics' => [ - 'great' => 1, - ], ]); } } diff --git a/tests/Models/Multiplayer/UserScoreAggregateTest.php b/tests/Models/Multiplayer/UserScoreAggregateTest.php index 37ded3882f9..6e4bc8f48cf 100644 --- a/tests/Models/Multiplayer/UserScoreAggregateTest.php +++ b/tests/Models/Multiplayer/UserScoreAggregateTest.php @@ -240,8 +240,9 @@ private function addPlay(User $user, PlaylistItem $playlistItem, array $params): [ 'beatmap_id' => $playlistItem->beatmap_id, 'ended_at' => json_time(new \DateTime()), - 'ruleset_id' => $playlistItem->ruleset_id, + 'max_combo' => 1, 'statistics' => ['good' => 1], + 'ruleset_id' => $playlistItem->ruleset_id, 'user_id' => $user->getKey(), ...$params, ], diff --git a/tests/Models/Solo/ScoreEsIndexTest.php b/tests/Models/Solo/ScoreEsIndexTest.php index 2b7fcf6d117..1b53c276887 100644 --- a/tests/Models/Solo/ScoreEsIndexTest.php +++ b/tests/Models/Solo/ScoreEsIndexTest.php @@ -40,7 +40,6 @@ public static function setUpBeforeClass(): void static::$beatmap = Beatmap::factory()->qualified()->create(); $scoreFactory = Score::factory()->state(['preserve' => true]); - $defaultData = ['build_id' => 1]; $mods = [ ['acronym' => 'DT', 'settings' => []], @@ -51,43 +50,44 @@ public static function setUpBeforeClass(): void ]; static::$scores = [ - 'otherUser' => $scoreFactory->withData($defaultData, [ - 'total_score' => 1150, + 'otherUser' => $scoreFactory->withData([ 'mods' => $unrelatedMods, ])->create([ 'beatmap_id' => static::$beatmap, + 'total_score' => 1150, 'user_id' => $otherUser, ]), - 'otherUserMods' => $scoreFactory->withData($defaultData, [ - 'total_score' => 1140, + 'otherUserMods' => $scoreFactory->withData([ 'mods' => $mods, ])->create([ 'beatmap_id' => static::$beatmap, + 'total_score' => 1140, 'user_id' => $otherUser, ]), - 'otherUser2' => $scoreFactory->withData($defaultData, [ - 'total_score' => 1150, + 'otherUser2' => $scoreFactory->withData([ 'mods' => $mods, ])->create([ 'beatmap_id' => static::$beatmap, + 'total_score' => 1150, 'user_id' => User::factory()->state(['country_acronym' => Country::factory()]), ]), - 'otherUser3SameCountry' => $scoreFactory->withData($defaultData, [ - 'total_score' => 1130, + 'otherUser3SameCountry' => $scoreFactory->withData([ 'mods' => $unrelatedMods, ])->create([ 'beatmap_id' => static::$beatmap, + 'total_score' => 1130, 'user_id' => User::factory()->state(['country_acronym' => static::$user->country_acronym]), ]), - 'user' => $scoreFactory->withData($defaultData, ['total_score' => 1100])->create([ + 'user' => $scoreFactory->create([ 'beatmap_id' => static::$beatmap, + 'total_score' => 1100, 'user_id' => static::$user, ]), - 'userMods' => $scoreFactory->withData($defaultData, [ - 'total_score' => 1050, + 'userMods' => $scoreFactory->withData([ 'mods' => $mods, ])->create([ 'beatmap_id' => static::$beatmap, + 'total_score' => 1050, 'user_id' => static::$user, ]), ]; diff --git a/tests/Models/Solo/ScoreTest.php b/tests/Models/Solo/ScoreTest.php index 5f300cc4dfb..663a8da2245 100644 --- a/tests/Models/Solo/ScoreTest.php +++ b/tests/Models/Solo/ScoreTest.php @@ -50,10 +50,10 @@ public function testLegacyPassScoreRetainsRank() 'user_id' => 1, ]); - $this->assertTrue($score->data->passed); - $this->assertSame($score->data->rank, 'S'); + $this->assertTrue($score->passed); + $this->assertSame($score->rank, 'S'); - $legacy = $score->createLegacyEntryOrExplode(); + $legacy = $score->makeLegacyEntry(); $this->assertTrue($legacy->perfect); $this->assertSame($legacy->rank, 'S'); @@ -75,10 +75,10 @@ public function testLegacyFailScoreIsRankF() 'user_id' => 1, ]); - $this->assertFalse($score->data->passed); - $this->assertSame($score->data->rank, 'F'); + $this->assertFalse($score->passed); + $this->assertSame($score->rank, 'F'); - $legacy = $score->createLegacyEntryOrExplode(); + $legacy = $score->makeLegacyEntry(); $this->assertFalse($legacy->perfect); $this->assertSame($legacy->rank, 'F'); @@ -98,7 +98,7 @@ public function testLegacyScoreHitCounts() 'statistics' => ['great' => 10, 'ok' => 20, 'meh' => 30, 'miss' => 40], 'total_score' => 1000, 'user_id' => 1, - ])->createLegacyEntryOrExplode(); + ])->makeLegacyEntry(); $this->assertFalse($legacy->perfect); $this->assertSame($legacy->count300, 10); @@ -121,7 +121,7 @@ public function testLegacyScoreHitCountsFromStudlyCaseStatistics() 'statistics' => ['Great' => 10, 'Ok' => 20, 'Meh' => 30, 'Miss' => 40], 'total_score' => 1000, 'user_id' => 1, - ])->createLegacyEntryOrExplode(); + ])->makeLegacyEntry(); $this->assertFalse($legacy->perfect); $this->assertSame($legacy->count300, 10); @@ -132,13 +132,15 @@ public function testLegacyScoreHitCountsFromStudlyCaseStatistics() public function testModsPropertyType() { - $score = new Score(['data' => [ + $score = new Score([ 'beatmap_id' => 0, + 'data' => [ + 'mods' => [['acronym' => 'DT']], + ], 'ended_at' => json_time(now()), - 'mods' => [['acronym' => 'DT']], 'ruleset_id' => 0, 'user_id' => 0, - ]]); + ]); $this->assertTrue($score->data->mods[0] instanceof stdClass, 'mods entry should be of type stdClass'); } @@ -147,8 +149,7 @@ public function testWeightedPp(): void { $pp = 10; $weight = 0.5; - $score = Score::factory()->create(); - $score->performance()->create(['pp' => $pp]); + $score = Score::factory()->create(['pp' => $pp]); $score->weight = $weight; $this->assertSame($score->weightedPp(), $pp * $weight); @@ -156,7 +157,7 @@ public function testWeightedPp(): void public function testWeightedPpWithoutPerformance(): void { - $score = Score::factory()->create(); + $score = Score::factory()->create(['pp' => null]); $score->weight = 0.5; $this->assertNull($score->weightedPp()); @@ -164,8 +165,7 @@ public function testWeightedPpWithoutPerformance(): void public function testWeightedPpWithoutWeight(): void { - $score = Score::factory()->create(); - $score->performance()->create(['pp' => 10]); + $score = Score::factory()->create(['pp' => 10]); $this->assertNull($score->weightedPp()); } diff --git a/tests/TestCase.php b/tests/TestCase.php index 7fe0f8462e2..7123d3d81a0 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -8,14 +8,15 @@ use App\Events\NewPrivateNotificationEvent; use App\Http\Middleware\AuthApi; use App\Jobs\Notifications\BroadcastNotificationBase; +use App\Libraries\OAuth\EncodeToken; use App\Libraries\Search\ScoreSearch; use App\Libraries\Session\Store as SessionStore; use App\Models\Beatmapset; +use App\Models\Build; use App\Models\OAuth\Client; use App\Models\User; use Artisan; use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts; -use Firebase\JWT\JWT; use Illuminate\Database\DatabaseManager; use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Testing\DatabaseTransactions; @@ -40,6 +41,14 @@ public static function withDbAccess(callable $callback): void static::resetAppDb($db); } + protected static function createClientToken(Build $build, ?int $clientTime = null): string + { + $data = strtoupper(bin2hex($build->hash).bin2hex(pack('V', $clientTime ?? time()))); + $expected = hash_hmac('sha1', $data, ''); + + return strtoupper(bin2hex(random_bytes(40)).$data.$expected.'00'); + } + protected static function fileList($path, $suffix) { return array_map( @@ -175,31 +184,14 @@ protected function actingAsVerified($user) return $this; } - // FIXME: figure out how to generate the encrypted token without doing it - // manually here. Or alternatively some other way to authenticate - // with token. protected function actingWithToken($token) { - static $privateKey; - - if ($privateKey === null) { - $privateKey = $GLOBALS['cfg']['passport']['private_key'] ?? file_get_contents(Passport::keyPath('oauth-private.key')); - } - - $encryptedToken = JWT::encode([ - 'aud' => $token->client_id, - 'exp' => $token->expires_at->timestamp, - 'iat' => $token->created_at->timestamp, // issued at - 'jti' => $token->getKey(), - 'nbf' => $token->created_at->timestamp, // valid after - 'sub' => $token->user_id, - 'scopes' => $token->scopes, - ], $privateKey, 'RS256'); - $this->actAsUserWithToken($token); + $encodedToken = EncodeToken::encodeAccessToken($token); + return $this->withHeaders([ - 'Authorization' => "Bearer {$encryptedToken}", + 'Authorization' => "Bearer {$encodedToken}", ]); } @@ -245,17 +237,14 @@ protected function clearMailFake() */ protected function createToken(?User $user, ?array $scopes = null, ?Client $client = null) { - $client ??= Client::factory()->create(); - - $token = $client->tokens()->create([ + return ($client ?? Client::factory()->create())->tokens()->create([ 'expires_at' => now()->addDays(1), 'id' => uniqid(), 'revoked' => false, 'scopes' => $scopes, - 'user_id' => optional($user)->getKey(), + 'user_id' => $user?->getKey(), + 'verified' => true, ]); - - return $token; } protected function expectCountChange(callable $callback, int $change, string $message = '') diff --git a/tests/api_routes.json b/tests/api_routes.json index b2d1ca6eb11..439a44ab358 100644 --- a/tests/api_routes.json +++ b/tests/api_routes.json @@ -1,4 +1,40 @@ [ + { + "uri": "api/v2/session/verify", + "methods": [ + "POST" + ], + "controller": "App\\Http\\Controllers\\AccountController@verify", + "middlewares": [ + "App\\Http\\Middleware\\ThrottleRequests:1200,1,api:", + "App\\Http\\Middleware\\RequireScopes", + "App\\Http\\Middleware\\RequireScopes:any", + "Illuminate\\Auth\\Middleware\\Authenticate", + "App\\Http\\Middleware\\VerifyUser", + "App\\Http\\Middleware\\ThrottleRequests:60,10" + ], + "scopes": [ + "any" + ] + }, + { + "uri": "api/v2/session/verify/reissue", + "methods": [ + "POST" + ], + "controller": "App\\Http\\Controllers\\AccountController@reissueCode", + "middlewares": [ + "App\\Http\\Middleware\\ThrottleRequests:1200,1,api:", + "App\\Http\\Middleware\\RequireScopes", + "App\\Http\\Middleware\\RequireScopes:any", + "Illuminate\\Auth\\Middleware\\Authenticate", + "App\\Http\\Middleware\\VerifyUser", + "App\\Http\\Middleware\\ThrottleRequests:60,10" + ], + "scopes": [ + "any" + ] + }, { "uri": "api/v2/beatmaps/lookup", "methods": [