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 097852301e7..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', '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 df41471a29b..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); @@ -203,6 +205,7 @@ public function update($roomId, $playlistItemId, $tokenId) $score = $scoreLink->score; if ($score->wasRecentlyCreated) { + ClientCheck::queueToken($clientTokenData, $score->getKey()); $score->queueForProcessing(); } diff --git a/app/Http/Controllers/ScoreTokensController.php b/app/Http/Controllers/ScoreTokensController.php index b746f9f113b..946996736d0 100644 --- a/app/Http/Controllers/ScoreTokensController.php +++ b/app/Http/Controllers/ScoreTokensController.php @@ -24,8 +24,8 @@ public function store($beatmapId) { $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 +43,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 f5509401a63..046c11ec08b 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,7 +32,7 @@ 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(); @@ -42,6 +45,7 @@ public function store($beatmapId, $tokenId) }); if ($score->wasRecentlyCreated) { + ClientCheck::queueToken($clientTokenData, $score->getKey()); $score->queueForProcessing(); } diff --git a/app/Http/Controllers/UsersController.php b/app/Http/Controllers/UsersController.php index b92a8bd65e1..229d0fbb501 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; @@ -176,7 +179,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->recentScoreCount($this->mode) ), 'replays_watched_counts' => json_collection($this->user->replaysWatchedCounts, new UserReplaysWatchedCountTransformer()), ]; @@ -191,7 +194,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', @@ -217,11 +220,15 @@ public function store() ], 403); } - 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()); + ClientCheck::parseToken($request); + + return $this->storeUser($request->all()); } public function storeWeb() @@ -787,15 +794,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 +831,12 @@ 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) + $query = $this->user->soloScores() + ->default() + ->forRuleset($this->mode) ->includeFails($options['includeFails'] ?? false) - ->with([...ScoreTransformer::USER_PROFILE_INCLUDES_PRELOAD, 'best']); + ->reorderBy('id', 'desc') + ->with(ScoreTransformer::USER_PROFILE_INCLUDES_PRELOAD); $userRelationColumn = 'user'; break; } diff --git a/app/Libraries/ClientCheck.php b/app/Libraries/ClientCheck.php index f195067fd4c..914fc87afe8 100644 --- a/app/Libraries/ClientCheck.php +++ b/app/Libraries/ClientCheck.php @@ -3,39 +3,101 @@ // 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; + } 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([ + 'id' => $scoreId, + 'token' => $tokenData['token'], + ])); + } + + 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/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 08f17c1e9e1..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() + $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/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/Solo/Score.php b/app/Models/Solo/Score.php index b4884978306..c3e775a9852 100644 --- a/app/Models/Solo/Score.php +++ b/app/Models/Solo/Score.php @@ -137,6 +137,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. */ 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..5b5865144e0 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); @@ -1796,6 +1802,18 @@ public function authHash(): string return hash('sha256', $this->user_email).':'.hash('sha256', $this->user_password); } + public function recentScoreCount(string $ruleset): int + { + return $this->soloScores() + ->default() + ->forRuleset($ruleset) + ->includeFails(false) + ->select('id') + ->limit(100) + ->get() + ->count(); + } + public function resetSessions(?string $excludedSessionId = null): void { $userId = $this->getKey(); diff --git a/app/Transformers/UserCompactTransformer.php b/app/Transformers/UserCompactTransformer.php index b68fdb447d1..86caa6f3bf8 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; @@ -387,7 +388,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 +406,7 @@ 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->recentScoreCount($this->mode)); } public function includeStatistics(User $user) diff --git a/config/osu.php b/config/osu.php index 33af7ae8d4a..e4b35c8a942 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' => [ 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/Solo/ScoreFactory.php b/database/factories/Solo/ScoreFactory.php index de75879064f..96be7f78004 100644 --- a/database/factories/Solo/ScoreFactory.php +++ b/database/factories/Solo/ScoreFactory.php @@ -33,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, ]; } 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/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/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/utils/score-helper.ts b/resources/js/utils/score-helper.ts index 9f3bf6d3fd4..e861fd541d1 100644 --- a/resources/js/utils/score-helper.ts +++ b/resources/js/utils/score-helper.ts @@ -123,7 +123,11 @@ export function scoreUrl(score: SoloScoreJson) { } export function totalScore(score: SoloScoreJson) { - return score.legacy_score_id == null - ? score.total_score - : score.legacy_total_score; + if (core.userPreferences.get('legacy_score_only')) { + return score.legacy_score_id == null + ? score.total_score + : score.legacy_total_score; + } + + return score.total_score; } 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/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 + $legacyScoreOnlyValue = App\Libraries\Search\ScoreSearchParams::showLegacyForUser(Auth::user()); + $icon = $legacyScoreOnlyValue + ? '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']) + ['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/Controllers/BeatmapsControllerSoloScoresTest.php b/tests/Controllers/BeatmapsControllerSoloScoresTest.php index c632fe41952..1e2d1a58b6c 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,104 +41,119 @@ 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()->state(['build_id' => 0]); - foreach (['solo' => null, 'legacy' => 1] as $type => $legacyScoreId) { + foreach (['solo' => false, 'legacy' => true] as $type => $isLegacy) { $scoreFactory = $scoreFactory->state([ - 'legacy_score_id' => $legacyScoreId, + 'legacy_score_id' => $isLegacy ? 1 : null, ]); + $makeMods = fn (array $modNames): array => array_map( + fn (string $modName): array => [ + 'acronym' => $modName, + 'settings' => [], + ], + [...$modNames, ...($isLegacy ? ['CL'] : [])], + ); + + $makeTotalScores = fn (int $totalScore): array => [ + 'legacy_total_score' => $totalScore * ($isLegacy ? 1 : 0), + 'total_score' => $totalScore + ($isLegacy ? -1 : 0), + ]; static::$scores = [ ...static::$scores, - "{$type}:user" => $scoreFactory->create([ + "{$type}:userModsLowerScore" => $scoreFactory->withData([ + 'mods' => $makeMods(['DT', 'HD']), + ])->create([ + ...$makeTotalScores(1000), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'total_score' => 1100, 'user_id' => static::$user, ]), - "{$type}:userMods" => $scoreFactory->withData([ - 'mods' => static::defaultMods(['DT', 'HD']), + "{$type}:otherUserModsNCPFHigherScore" => $scoreFactory->withData([ + 'mods' => $makeMods(['NC', 'PF']), ])->create([ + ...$makeTotalScores(1010), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'total_score' => 1050, - 'user_id' => static::$user, + 'user_id' => static::$otherUser, ]), - "{$type}:userModsNC" => $scoreFactory->withData([ - 'mods' => static::defaultMods(['NC']), + "{$type}:userMods" => $scoreFactory->withData([ + 'mods' => $makeMods(['DT', 'HD']), ])->create([ + ...$makeTotalScores(1050), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'total_score' => 1050, 'user_id' => static::$user, ]), - "{$type}:otherUserModsNCPFHigherScore" => $scoreFactory->withData([ - 'mods' => static::defaultMods(['NC', 'PF']), + "{$type}:userModsNC" => $scoreFactory->withData([ + 'mods' => $makeMods(['NC']), ])->create([ + ...$makeTotalScores(1050), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'total_score' => 1010, - 'user_id' => static::$otherUser, + 'user_id' => static::$user, ]), - "{$type}:userModsLowerScore" => $scoreFactory->withData([ - 'mods' => static::defaultMods(['DT', 'HD']), - ])->create([ + "{$type}:user" => $scoreFactory->create([ + ...$makeTotalScores(1100), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'total_score' => 1000, 'user_id' => static::$user, ]), "{$type}:friend" => $scoreFactory->create([ + ...$makeTotalScores(1000), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'total_score' => 1000, 'user_id' => $friend, ]), // With preference mods "{$type}:otherUser" => $scoreFactory->withData([ - 'mods' => static::defaultMods(['PF']), + 'mods' => $makeMods(['PF']), ])->create([ + ...$makeTotalScores(1000), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'total_score' => 1000, 'user_id' => static::$otherUser, ]), "{$type}:otherUserMods" => $scoreFactory->withData([ - 'mods' => static::defaultMods(['HD', 'PF', 'NC']), + 'mods' => $makeMods(['HD', 'PF', 'NC']), ])->create([ + ...$makeTotalScores(1000), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'total_score' => 1000, 'user_id' => static::$otherUser, ]), "{$type}:otherUserModsExtraNonPreferences" => $scoreFactory->withData([ - 'mods' => static::defaultMods(['DT', 'HD', 'HR']), + 'mods' => $makeMods(['DT', 'HD', 'HR']), ])->create([ + ...$makeTotalScores(1000), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'total_score' => 1000, 'user_id' => static::$otherUser, ]), "{$type}:otherUserModsUnrelated" => $scoreFactory->withData([ - 'mods' => static::defaultMods(['FL']), + 'mods' => $makeMods(['FL']), ])->create([ + ...$makeTotalScores(1000), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'total_score' => 1000, 'user_id' => static::$otherUser, ]), // Same total score but achieved later so it should come up after earlier score "{$type}:otherUser2Later" => $scoreFactory->create([ + ...$makeTotalScores(1000), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'total_score' => 1000, - 'user_id' => User::factory()->state(['country_acronym' => Country::factory()]), + 'user_id' => $otherUser2, ]), "{$type}:otherUser3SameCountry" => $scoreFactory->create([ + ...$makeTotalScores(1000), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'total_score' => 1000, - 'user_id' => User::factory()->state(['country_acronym' => $countryAcronym]), + 'user_id' => $otherUser3SameCountry, ]), // Non-preserved score should be filtered out "{$type}:nonPreserved" => $scoreFactory->create([ @@ -172,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(); @@ -181,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); @@ -209,46 +216,91 @@ public function testQuery(array $scoreKeys, array $params) } } + /** + * @group RequiresScoreIndexer + */ + public function testUserScore() + { + $url = route('api.beatmaps.user.score', [ + 'beatmap' => static::$beatmap->getKey(), + '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(), + '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 d42526b930c..62f8de1ed9a 100644 --- a/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php +++ b/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php @@ -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/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/Solo/ScoresControllerTest.php b/tests/Controllers/Solo/ScoresControllerTest.php index a5dc22bebd1..93e91976b13 100644 --- a/tests/Controllers/Solo/ScoresControllerTest.php +++ b/tests/Controllers/Solo/ScoresControllerTest.php @@ -5,6 +5,7 @@ namespace Tests\Controllers\Solo; +use App\Models\Build; use App\Models\Score as LegacyScore; use App\Models\ScoreToken; use App\Models\Solo\Score; @@ -16,13 +17,19 @@ class ScoresControllerTest extends TestCase { public function testStore() { - $scoreToken = ScoreToken::factory()->create(); + $build = Build::factory()->create(['allow_ranking' => true]); + $scoreToken = ScoreToken::factory()->create(['build_id' => $build]); $legacyScoreClass = LegacyScore\Model::getClassByRulesetId($scoreToken->beatmap->playmode); $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/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/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/TestCase.php b/tests/TestCase.php index 7fe0f8462e2..ce5e00cd300 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -11,6 +11,7 @@ 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; @@ -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(