diff --git a/app/Http/Controllers/BeatmapTagsController.php b/app/Http/Controllers/BeatmapTagsController.php index 87c9d9b253b..f6cf56b035a 100644 --- a/app/Http/Controllers/BeatmapTagsController.php +++ b/app/Http/Controllers/BeatmapTagsController.php @@ -23,22 +23,6 @@ public function __construct() 'destroy', ], ]); - - $this->middleware('require-scopes:public', ['only' => 'index']); - } - - public function index($beatmapId) - { - $topBeatmapTags = cache_remember_mutexed( - "beatmap_tags:{$beatmapId}", - $GLOBALS['cfg']['osu']['tags']['beatmap_tags_cache_duration'], - [], - fn () => Tag::topTags($beatmapId), - ); - - return [ - 'beatmap_tags' => $topBeatmapTags, - ]; } public function destroy($beatmapId, $tagId) diff --git a/app/Http/Controllers/BeatmapsetsController.php b/app/Http/Controllers/BeatmapsetsController.php index d0958f9567a..a2da6fc6b37 100644 --- a/app/Http/Controllers/BeatmapsetsController.php +++ b/app/Http/Controllers/BeatmapsetsController.php @@ -404,6 +404,7 @@ private function showJson($beatmapset) 'beatmaps.failtimes', 'beatmaps.max_combo', 'beatmaps.owners', + 'beatmaps.top_tag_ids', 'converts', 'converts.failtimes', 'converts.owners', @@ -415,6 +416,7 @@ private function showJson($beatmapset) 'pack_tags', 'ratings', 'recent_favourites', + 'related_tags', 'related_users', 'user', ]); diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 0cd6ff043d9..3bd96032464 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -16,6 +16,7 @@ use App\Models\NewsPost; use App\Models\UserDonation; use App\Transformers\MenuImageTransformer; +use App\Transformers\UserCompactTransformer; use Auth; use Jenssegers\Agent\Agent; use Request; @@ -142,12 +143,11 @@ public function quickSearch() $result[$mode]['total'] = $search->count(); } - $result['user']['users'] = json_collection($searches['user']->data(), 'UserCompact', [ - 'country', - 'cover', - 'groups', - 'support_level', - ]); + $result['user']['users'] = json_collection( + $searches['user']->data(), + new UserCompactTransformer(), + [...UserCompactTransformer::CARD_INCLUDES, 'support_level'], + ); $result['beatmapset']['beatmapsets'] = json_collection($searches['beatmapset']->data(), 'Beatmapset', ['beatmaps']); } @@ -210,7 +210,7 @@ public function setLocale() ]); } - return ext_view('layout.ujs-reload', [], 'js') + return ext_view('layout.ujs_full_reload', [], 'js') ->withCookie(cookie()->forever('locale', $newLocale)); } diff --git a/app/Http/Controllers/InterOp/Multiplayer/RoomsController.php b/app/Http/Controllers/InterOp/Multiplayer/RoomsController.php new file mode 100644 index 00000000000..27e08dfbb9c --- /dev/null +++ b/app/Http/Controllers/InterOp/Multiplayer/RoomsController.php @@ -0,0 +1,35 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +namespace App\Http\Controllers\InterOp\Multiplayer; + +use App\Http\Controllers\Controller; +use App\Models\Multiplayer\Room; +use App\Models\User; +use App\Transformers\Multiplayer\RoomTransformer; + +class RoomsController extends Controller +{ + public function join(string $id, string $userId) + { + $user = User::findOrFail($userId); + $room = Room::findOrFail($id); + + $room->assertCorrectPassword(get_string(request('password'))); + $room->join($user); + + return RoomTransformer::createShowResponse($room); + } + + public function store() + { + $params = \Request::all(); + $user = User::findOrFail(get_int($params['user_id'] ?? null)); + + $room = (new Room())->startGame($user, $params); + + return RoomTransformer::createShowResponse($room); + } +} diff --git a/app/Http/Controllers/Multiplayer/Rooms/Playlist/ScoresController.php b/app/Http/Controllers/Multiplayer/Rooms/Playlist/ScoresController.php index ea1f7321d1f..19b79dde254 100644 --- a/app/Http/Controllers/Multiplayer/Rooms/Playlist/ScoresController.php +++ b/app/Http/Controllers/Multiplayer/Rooms/Playlist/ScoresController.php @@ -5,7 +5,6 @@ namespace App\Http\Controllers\Multiplayer\Rooms\Playlist; -use App\Exceptions\InvariantException; use App\Http\Controllers\Controller as BaseController; use App\Libraries\ClientCheck; use App\Models\Multiplayer\PlaylistItem; @@ -182,15 +181,11 @@ public function store($roomId, $playlistId) $playlistItem = $room->playlist()->findOrFail($playlistId); $user = \Auth::user(); $request = \Request::instance(); - $params = $request->all(); - if (get_string($params['beatmap_hash'] ?? null) !== $playlistItem->beatmap->checksum) { - throw new InvariantException(osu_trans('score_tokens.create.beatmap_hash_invalid')); - } - - $buildId = ClientCheck::parseToken($request)['buildId']; - - $scoreToken = $room->startPlay($user, $playlistItem, $buildId); + $scoreToken = $room->startPlay($user, $playlistItem, [ + ...$request->all(), + 'build_id' => ClientCheck::parseToken($request)['buildId'], + ]); return json_item($scoreToken, new ScoreTokenTransformer()); } diff --git a/app/Http/Controllers/Multiplayer/RoomsController.php b/app/Http/Controllers/Multiplayer/RoomsController.php index 97c6e7fd555..00d0bdd7c13 100644 --- a/app/Http/Controllers/Multiplayer/RoomsController.php +++ b/app/Http/Controllers/Multiplayer/RoomsController.php @@ -5,7 +5,6 @@ namespace App\Http\Controllers\Multiplayer; -use App\Exceptions\InvariantException; use App\Http\Controllers\Controller; use App\Http\Controllers\Ranking\DailyChallengeController; use App\Models\Model; @@ -101,24 +100,18 @@ public function index() public function join($roomId, $userId) { + $currentUser = \Auth::user(); // this allows admins/whatever to add users to games in the future - if (get_int($userId) !== auth()->user()->user_id) { + if (get_int($userId) !== $currentUser->getKey()) { abort(403); } $room = Room::findOrFail($roomId); + $room->assertCorrectPassword(get_string(request('password'))); - if ($room->password !== null) { - $password = get_param_value(request('password'), null); - - if ($password === null || !hash_equals(hash('sha256', $room->password), hash('sha256', $password))) { - abort(403, osu_trans('multiplayer.room.invalid_password')); - } - } - - $room->join(auth()->user()); + $room->join($currentUser); - return $this->createJoinedRoomResponse($room); + return RoomTransformer::createShowResponse($room); } public function leaderboard($roomId) @@ -168,7 +161,7 @@ public function show($id) } if (is_api_request()) { - return $this->createJoinedRoomResponse($room); + return RoomTransformer::createShowResponse($room); } if ($room->category === 'daily_challenge') { @@ -200,32 +193,8 @@ public function show($id) public function store() { - try { - $room = (new Room())->startGame(auth()->user(), request()->all()); - - return $this->createJoinedRoomResponse($room); - } catch (InvariantException $e) { - return error_popup($e->getMessage(), $e->getStatusCode()); - } - } + $room = (new Room())->startGame(\Auth::user(), \Request::all()); - private function createJoinedRoomResponse($room) - { - return json_item( - $room->loadMissing([ - 'host', - 'playlist.beatmap.beatmapset', - 'playlist.beatmap.baseMaxCombo', - ]), - 'Multiplayer\Room', - [ - 'current_user_score.playlist_item_attempts', - 'host.country', - 'playlist.beatmap.beatmapset', - 'playlist.beatmap.checksum', - 'playlist.beatmap.max_combo', - 'recent_participants', - ] - ); + return RoomTransformer::createShowResponse($room); } } diff --git a/app/Http/Controllers/ScoreTokensController.php b/app/Http/Controllers/ScoreTokensController.php index 06a392e7358..7f352f081aa 100644 --- a/app/Http/Controllers/ScoreTokensController.php +++ b/app/Http/Controllers/ScoreTokensController.php @@ -29,33 +29,20 @@ public function store($beatmapId) $beatmap = Beatmap::increasesStatistics()->findOrFail($beatmapId); $user = auth()->user(); $request = \Request::instance(); - $params = get_params($request->all(), null, [ - 'beatmap_hash', - 'ruleset_id:int', - ]); - - $checks = [ - 'beatmap_hash' => fn (string $value): bool => $value === $beatmap->checksum, - 'ruleset_id' => fn (int $value): bool => Beatmap::modeStr($value) !== null && $beatmap->canBeConvertedTo($value), - ]; - foreach ($checks as $key => $testFn) { - if (!isset($params[$key])) { - throw new InvariantException("missing {$key}"); - } - if (!$testFn($params[$key])) { - throw new InvariantException("invalid {$key}"); - } - } - $buildId = ClientCheck::parseToken($request)['buildId']; + $scoreToken = new ScoreToken([ + 'beatmap_id' => $beatmap->getKey(), + 'build_id' => ClientCheck::parseToken($request)['buildId'], + 'user_id' => $user->getKey(), + ...get_params($request->all(), null, [ + 'beatmap_hash', + 'ruleset_id:int', + ]), + ]); + $scoreToken->setRelation('beatmap', $beatmap); try { - $scoreToken = ScoreToken::create([ - 'beatmap_id' => $beatmap->getKey(), - 'build_id' => $buildId, - 'ruleset_id' => $params['ruleset_id'], - 'user_id' => $user->getKey(), - ]); + $scoreToken->saveOrExplode(); } catch (PDOException $e) { // TODO: move this to be a validation inside Score model throw new InvariantException('failed creating score token'); diff --git a/app/Http/Controllers/TagsController.php b/app/Http/Controllers/TagsController.php index 80b657d9182..b0ce5c493a9 100644 --- a/app/Http/Controllers/TagsController.php +++ b/app/Http/Controllers/TagsController.php @@ -7,9 +7,6 @@ namespace App\Http\Controllers; -use App\Models\Tag; -use App\Transformers\TagTransformer; - class TagsController extends Controller { public function __construct() @@ -21,15 +18,8 @@ public function __construct() public function index() { - $tags = cache_remember_mutexed( - 'tags', - $GLOBALS['cfg']['osu']['tags']['tags_cache_duration'], - [], - fn () => Tag::all(), - ); - return [ - 'tags' => json_collection($tags, new TagTransformer()), + 'tags' => app('tags')->json(), ]; } } diff --git a/app/Http/Controllers/TeamsController.php b/app/Http/Controllers/TeamsController.php index 0792c39db0b..92b6de5f8f8 100644 --- a/app/Http/Controllers/TeamsController.php +++ b/app/Http/Controllers/TeamsController.php @@ -19,6 +19,17 @@ public function __construct() $this->middleware('auth', ['only' => ['part']]); } + public function destroy(string $id): Response + { + $team = Team::findOrFail($id); + priv_check('TeamUpdate', $team)->ensureCan(); + + $team->delete(); + \Session::flash('popup', osu_trans('teams.destroy.ok')); + + return ujs_redirect(route('home')); + } + public function edit(string $id): Response { $team = Team::findOrFail($id); diff --git a/app/Http/Controllers/Users/LookupController.php b/app/Http/Controllers/Users/LookupController.php index afe871f58ef..229f906e24d 100644 --- a/app/Http/Controllers/Users/LookupController.php +++ b/app/Http/Controllers/Users/LookupController.php @@ -22,7 +22,12 @@ public function __construct() public function index() { // TODO: referer check? - $ids = array_slice(array_reject_null(get_arr(request('ids'), presence(...)) ?? []), 0, 50); + $params = get_params(\Request::all(), null, [ + 'exclude_bots:bool', + 'ids:string[]', + ]); + + $ids = array_slice(array_reject_null(get_arr($params['ids'] ?? [], presence(...))), 0, 50); $numericIds = []; $stringIds = []; @@ -35,13 +40,16 @@ public function index() } $users = User::where(fn ($q) => $q->whereIn('user_id', $numericIds)->orWhereIn('username', $stringIds)) - ->where('group_id', '<>', app('groups')->byIdentifier('no_profile')->getKey()) ->default() - ->with(UserCompactTransformer::CARD_INCLUDES_PRELOAD) - ->get(); + ->withoutNoProfile() + ->with(UserCompactTransformer::CARD_INCLUDES_PRELOAD); + + if ($params['exclude_bots'] ?? false) { + $users = $users->withoutBots(); + } return [ - 'users' => json_collection($users, new UserCompactTransformer(), UserCompactTransformer::CARD_INCLUDES), + 'users' => json_collection($users->get(), new UserCompactTransformer(), UserCompactTransformer::CARD_INCLUDES), ]; } } diff --git a/app/Libraries/Beatmapset/ChangeBeatmapOwners.php b/app/Libraries/Beatmapset/ChangeBeatmapOwners.php index 87a869871ba..62f6a702d74 100644 --- a/app/Libraries/Beatmapset/ChangeBeatmapOwners.php +++ b/app/Libraries/Beatmapset/ChangeBeatmapOwners.php @@ -43,7 +43,7 @@ public function handle(): void $newUserIds = $this->userIds->diff($currentOwners); - if (User::whereIn('user_id', $newUserIds->toArray())->default()->count() !== $newUserIds->count()) { + if (User::whereIn('user_id', $newUserIds->toArray())->default()->withoutBots()->withoutNoProfile()->count() !== $newUserIds->count()) { throw new InvariantException('invalid user_id'); } diff --git a/app/Models/Beatmap.php b/app/Models/Beatmap.php index b107d638d17..9bda60e088d 100644 --- a/app/Models/Beatmap.php +++ b/app/Models/Beatmap.php @@ -8,6 +8,7 @@ use App\Exceptions\InvariantException; use App\Jobs\EsDocument; use App\Libraries\Transactions\AfterCommit; +use App\Traits\Memoizes; use DB; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; @@ -53,7 +54,7 @@ */ class Beatmap extends Model implements AfterCommit { - use SoftDeletes; + use Memoizes, SoftDeletes; public $convert = false; @@ -348,6 +349,20 @@ public function status() return array_search($this->approved, Beatmapset::STATES, true); } + public function topTagIds() + { + // TODO: Add option to multi query when beatmapset requests all tags for beatmaps? + return $this->memoize( + __FUNCTION__, + fn () => cache_remember_mutexed( + "beatmap_top_tag_ids:{$this->getKey()}", + $GLOBALS['cfg']['osu']['tags']['beatmap_tags_cache_duration'], + [], + fn () => $this->beatmapTags()->topTagIds()->limit(50)->get()->toArray(), + ), + ); + } + private function getDifficultyrating() { if ($this->convert) { diff --git a/app/Models/BeatmapTag.php b/app/Models/BeatmapTag.php index a4fca351852..a61da0fc571 100644 --- a/app/Models/BeatmapTag.php +++ b/app/Models/BeatmapTag.php @@ -7,18 +7,34 @@ namespace App\Models; +use Illuminate\Contracts\Database\Eloquent\Builder; + /** * @property-read Beatmap $beatmap * @property int $beatmap_id + * @property \Carbon\Carbon $created_at * @property int $tag_id + * @property \Carbon\Carbon $updated_at * @property-read User $user * @property int $user_id */ class BeatmapTag extends Model { + public $incrementing = false; + protected $primaryKey = ':composite'; protected $primaryKeys = ['beatmap_id', 'tag_id', 'user_id']; + public function scopeTopTagIds(Builder $query) + { + return $query->whereHas('user', fn ($userQuery) => $userQuery->default()) + ->groupBy('tag_id') + ->select('tag_id') + ->selectRaw('COUNT(*) as count') + ->orderBy('count', 'desc') + ->orderBy('tag_id', 'asc'); + } + public function beatmap() { return $this->belongsTo(Beatmap::class, 'beatmap_id'); diff --git a/app/Models/ChangelogEntry.php b/app/Models/ChangelogEntry.php index 194a87238c7..4a377b2b8ef 100644 --- a/app/Models/ChangelogEntry.php +++ b/app/Models/ChangelogEntry.php @@ -39,9 +39,9 @@ class ChangelogEntry extends Model public static function convertLegacy($changelog) { - $message = $changelog->message; - $splitMessage = static::splitMessage($message); - $title = $splitMessage[0]; + $splitMessage = static::splitMessage($changelog->message); + $title = presence($splitMessage[0]); + $message = $splitMessage[1]; if ($title === null) { $title = $splitMessage[1]; @@ -65,6 +65,13 @@ public static function convertLegacy($changelog) ]); } + public static function getDisplayMessage(?string $origMessage): string + { + $split = static::splitMessage($origMessage); + + return $split[1] === null ? '' : $split[0]; + } + public static function guessCategory($data) { static $ignored = [ @@ -125,7 +132,7 @@ public static function importFromGithub($data) 'category' => static::guessCategory($data), 'created_at' => Carbon::parse($data['pull_request']['merged_at']), 'github_pull_request_id' => $data['pull_request']['number'], - 'message' => $data['pull_request']['body'], + 'message' => static::getDisplayMessage($data['pull_request']['body']), 'private' => static::isPrivate($data), 'title' => $data['pull_request']['title'], 'type' => static::guessType($data), @@ -180,21 +187,30 @@ public static function placeholder() ]); } - public static function splitMessage($message) + /** + * Returns array of message split by thematic break (`---`) + * + * The array length is always two. + * If the message is empty, both values will be null. + * If there's no thematic break, the second value will be null. + */ + public static function splitMessage($message): array { if (!present($message)) { return [null, null]; } static $separator = "\n\n---\n"; - // prepended with \n\n just in case the message starts with ---\n (blank first part). - $message = "\n\n".trim(str_replace("\r\n", "\n", $message)); - $splitPos = null_if_false(strpos($message, $separator)) ?? strlen($message); - - return [ - presence(trim(substr($message, 0, $splitPos))), - presence(trim(substr($message, $splitPos + strlen($separator)))), - ]; + // Surround with newlines to handle separator at the start/end. + $message = "\n\n".trim(strtr($message, ["\r\n" => "\n"]))."\n"; + $splitPos = strpos($message, $separator); + + return $splitPos === false + ? [trim($message), null] + : [ + trim(substr($message, 0, $splitPos)), + trim(substr($message, $splitPos + strlen($separator))), + ]; } public function builds() @@ -253,21 +269,12 @@ public function githubUrl() } } - public function publicMessageHtml() + public function messageHtml(): ?string { return $this->memoize(__FUNCTION__, function () { - $message = $this->publicMessage(); + $message = $this->message; - if ($message !== null) { - return markdown($message, 'changelog_entry'); - } - }); - } - - public function publicMessage() - { - return $this->memoize(__FUNCTION__, function () { - return static::splitMessage($this->message)[1]; + return present($message) ? markdown($message, 'changelog_entry') : null; }); } } diff --git a/app/Models/Multiplayer/PlaylistItem.php b/app/Models/Multiplayer/PlaylistItem.php index 4c8e6175505..4782c1714a5 100644 --- a/app/Models/Multiplayer/PlaylistItem.php +++ b/app/Models/Multiplayer/PlaylistItem.php @@ -21,6 +21,7 @@ * @property int $owner_id * @property int|null $playlist_order * @property json|null $required_mods + * @property bool $freestyle * @property Room $room * @property int $room_id * @property int|null $ruleset_id @@ -35,6 +36,7 @@ class PlaylistItem extends Model protected $casts = [ 'allowed_mods' => 'object', 'expired' => 'boolean', + 'freestyle' => 'boolean', 'required_mods' => 'object', ]; @@ -64,6 +66,7 @@ public static function fromJsonParams(User $owner, $json) $obj->$field = $value; } + $obj->freestyle = get_bool($json['freestyle'] ?? false); $obj->max_attempts = get_int($json['max_attempts'] ?? null); $modsHelper = app('mods'); @@ -169,6 +172,13 @@ private function assertValidRuleset() private function assertValidMods() { + if ($this->freestyle) { + if (count($this->allowed_mods) !== 0 || count($this->required_mods) !== 0) { + throw new InvariantException("mod isn't allowed in freestyle"); + } + return; + } + $allowedModIds = array_column($this->allowed_mods, 'acronym'); $requiredModIds = array_column($this->required_mods, 'acronym'); diff --git a/app/Models/Multiplayer/Room.php b/app/Models/Multiplayer/Room.php index 66c2bc38eb3..7619309d632 100644 --- a/app/Models/Multiplayer/Room.php +++ b/app/Models/Multiplayer/Room.php @@ -6,6 +6,7 @@ namespace App\Models\Multiplayer; use App\Casts\PresentString; +use App\Exceptions\AuthorizationException; use App\Exceptions\InvariantException; use App\Models\Beatmap; use App\Models\Chat\Channel; @@ -340,6 +341,17 @@ public function scopeWithRecentParticipantIds($query, ?int $limit = null) ", 'recent_participant_ids'); } + public function assertCorrectPassword(?string $password): void + { + if ($this->password === null) { + return; + } + + if ($password === null || !hash_equals(hash('sha256', $this->password), hash('sha256', $password))) { + throw new AuthorizationException(osu_trans('multiplayer.room.invalid_password')); + } + } + public function difficultyRange() { $extraQuery = true; @@ -676,13 +688,25 @@ public function endGame(User $requestingUser) $this->save(); } - public function startPlay(User $user, PlaylistItem $playlistItem, int $buildId) + public function startPlay(User $user, PlaylistItem $playlistItem, array $rawParams): ScoreToken { priv_check_user($user, 'MultiplayerScoreSubmit', $this)->ensureCan(); - $this->assertValidStartPlay($user, $playlistItem); + $params = get_params($rawParams, null, [ + 'beatmap_hash', + 'beatmap_id:int', + 'build_id', + 'ruleset_id:int', + ], ['null_missing' => true]); + + if (!$playlistItem->freestyle) { + $params['beatmap_id'] = $playlistItem->beatmap_id; + $params['ruleset_id'] = $playlistItem->ruleset_id; + } + + $this->assertValidStartPlay($user, $playlistItem, $params); - return $this->getConnection()->transaction(function () use ($buildId, $user, $playlistItem) { + return $this->getConnection()->transaction(function () use ($params, $playlistItem, $user) { $agg = UserScoreAggregate::new($user, $this); if ($agg->wasRecentlyCreated) { $this->incrementInstance('participant_count'); @@ -694,10 +718,11 @@ public function startPlay(User $user, PlaylistItem $playlistItem, int $buildId) $playlistItemAgg->updateUserAttempts(); return ScoreToken::create([ - 'beatmap_id' => $playlistItem->beatmap_id, - 'build_id' => $buildId, + 'beatmap_hash' => $params['beatmap_hash'], + 'beatmap_id' => $params['beatmap_id'], + 'build_id' => $params['build_id'], 'playlist_item_id' => $playlistItem->getKey(), - 'ruleset_id' => $playlistItem->ruleset_id, + 'ruleset_id' => $params['ruleset_id'], 'user_id' => $user->getKey(), ]); }); @@ -758,7 +783,7 @@ private function assertValidStartGame() } } - private function assertValidStartPlay(User $user, PlaylistItem $playlistItem) + private function assertValidStartPlay(User $user, PlaylistItem $playlistItem, array $params): void { // todo: check against room's end time (to see if player has enough time to play this beatmap) and is under the room's max attempts limit @@ -766,6 +791,13 @@ private function assertValidStartPlay(User $user, PlaylistItem $playlistItem) throw new InvariantException('Room has already ended.'); } + if ($playlistItem->freestyle) { + // assert the beatmap_id is part of playlist item's beatmapset + if ($playlistItem->beatmap->beatmapset_id !== Beatmap::find($params['beatmap_id'])?->beatmapset_id) { + throw new InvariantException('Specified beatmap_id is not allowed'); + } + } + $userId = $user->getKey(); if ($this->max_attempts !== null) { $roomStats = $this->userHighScores()->where('user_id', $userId)->first(); diff --git a/app/Models/ScoreToken.php b/app/Models/ScoreToken.php index f75c4bb07c5..5427c55b505 100644 --- a/app/Models/ScoreToken.php +++ b/app/Models/ScoreToken.php @@ -5,6 +5,7 @@ namespace App\Models; +use App\Exceptions\InvariantException; use App\Models\Multiplayer\PlaylistItem; use App\Models\Solo\Score; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -25,6 +26,8 @@ */ class ScoreToken extends Model { + public ?string $beatmapHash = null; + public function beatmap() { return $this->belongsTo(Beatmap::class, 'beatmap_id'); @@ -74,4 +77,34 @@ public function getAttribute($key) 'user' => $this->getRelationValue($key), }; } + + public function setBeatmapHashAttribute(?string $value): void + { + $this->beatmapHash = $value; + } + + public function assertValid(): void + { + $beatmap = $this->beatmap; + if ($this->beatmapHash !== $beatmap->checksum) { + throw new InvariantException(osu_trans('score_tokens.create.beatmap_hash_invalid')); + } + + $rulesetId = $this->ruleset_id; + if ($rulesetId === null) { + throw new InvariantException('missing ruleset_id'); + } + if (Beatmap::modeStr($rulesetId) === null || !$beatmap->canBeConvertedTo($rulesetId)) { + throw new InvariantException('invalid ruleset_id'); + } + } + + public function save(array $options = []): bool + { + if (!$this->exists) { + $this->assertValid(); + } + + return parent::save($options); + } } diff --git a/app/Models/Tag.php b/app/Models/Tag.php index 3513ab3d891..92134fa99ad 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -22,20 +22,4 @@ public function beatmapTags(): HasMany { return $this->hasMany(BeatmapTag::class); } - - public static function topTags($beatmapId) - { - return static - ::joinRelation( - 'beatmapTags', - fn ($q) => $q->where('beatmap_id', $beatmapId)->whereHas('user', fn ($userQuery) => $userQuery->default()) - ) - ->groupBy('id') - ->select('id', 'name') - ->selectRaw('COUNT(*) as count') - ->orderBy('count', 'desc') - ->orderBy('id', 'desc') - ->limit(50) - ->get(); - } } diff --git a/app/Models/Team.php b/app/Models/Team.php index a4a324052a3..33437a2774f 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -76,6 +76,22 @@ public function descriptionHtml(): string : bbcode((new BBCodeForDB($description))->generate()); } + public function delete() + { + $this->header()->delete(); + $this->logo()->delete(); + + return $this->getConnection()->transaction(function () { + $ret = parent::delete(); + + if ($ret) { + $this->members()->delete(); + } + + return $ret; + }); + } + public function header(): Uploader { return $this->header ??= new Uploader( diff --git a/app/Models/User.php b/app/Models/User.php index 0a40c4ef026..0131130bdf9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2045,6 +2045,16 @@ public function scopeOnline($query) ->where('user_lastvisit', '>', time() - $GLOBALS['cfg']['osu']['user']['online_window']); } + public function scopeWithoutBots(Builder $query): Builder + { + return $query->whereNot('group_id', app('groups')->byIdentifier('bot')->getKey()); + } + + public function scopeWithoutNoProfile(Builder $query): Builder + { + return $query->whereNot('group_id', app('groups')->byIdentifier('no_profile')->getKey()); + } + public function checkPassword($password) { return Hash::check($password, $this->getAuthPassword()); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index bd080bdcae9..7d358070636 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -31,6 +31,7 @@ class AppServiceProvider extends ServiceProvider 'layout-cache' => Singletons\LayoutCache::class, 'medals' => Singletons\Medals::class, 'smilies' => Singletons\Smilies::class, + 'tags' => Singletons\Tags::class, 'user-cover-presets' => Singletons\UserCoverPresets::class, ]; diff --git a/app/Singletons/Tags.php b/app/Singletons/Tags.php new file mode 100644 index 00000000000..589bad8b557 --- /dev/null +++ b/app/Singletons/Tags.php @@ -0,0 +1,44 @@ +. 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\Singletons; + +use App\Models\Tag; +use App\Traits\Memoizes; +use App\Transformers\TagTransformer; +use Illuminate\Support\Collection; + +class Tags +{ + use Memoizes; + + /** + * @return Collection + */ + public function all(): Collection + { + return $this->memoize(__FUNCTION__, fn () => Tag::all()); + } + + public function get(int $id): ?Tag + { + $allById = $this->memoize( + 'allById', + fn () => $this->all()->keyBy('id'), + ); + + return $allById[$id] ?? null; + } + + public function json(): array + { + return $this->memoize( + __FUNCTION__, + fn () => json_collection($this->all(), new TagTransformer()), + ); + } +} diff --git a/app/Transformers/BeatmapCompactTransformer.php b/app/Transformers/BeatmapCompactTransformer.php index ef8a96c6542..9ec4aa198c3 100644 --- a/app/Transformers/BeatmapCompactTransformer.php +++ b/app/Transformers/BeatmapCompactTransformer.php @@ -18,6 +18,7 @@ class BeatmapCompactTransformer extends TransformerAbstract 'failtimes', 'max_combo', 'owners', + 'top_tag_ids', 'user', ]; @@ -83,6 +84,11 @@ public function includeOwners(Beatmap $beatmap) ]); } + public function includeTopTagIds(Beatmap $beatmap) + { + return $this->primitive($beatmap->topTagIds()); + } + public function includeUser(Beatmap $beatmap) { return $this->item( diff --git a/app/Transformers/BeatmapsetCompactTransformer.php b/app/Transformers/BeatmapsetCompactTransformer.php index 546fe4125fe..f4f95e18cfd 100644 --- a/app/Transformers/BeatmapsetCompactTransformer.php +++ b/app/Transformers/BeatmapsetCompactTransformer.php @@ -40,6 +40,7 @@ class BeatmapsetCompactTransformer extends TransformerAbstract 'ratings', 'recent_favourites', 'related_users', + 'related_tags', 'user', ]; @@ -299,6 +300,24 @@ public function includeRelatedUsers(Beatmapset $beatmapset) return $this->collection($users, new UserCompactTransformer()); } + public function includeRelatedTags(Beatmapset $beatmapset) + { + $beatmaps = $this->beatmaps($beatmapset); + $tagIdSet = new Set($beatmaps->flatMap->topTagIds()->pluck('tag_id')); + + $cachedTags = app('tags'); + $json = []; + + foreach ($tagIdSet as $tagId) { + $tag = $cachedTags->get($tagId); + if ($tag !== null) { + $json[] = $tag; + } + } + + return $this->primitive($json); + } + private function beatmaps(Beatmapset $beatmapset, ?Fractal\ParamBag $params = null): EloquentCollection { $rel = $beatmapset->trashed() || ($params !== null && $params->get('with_trashed')) ? 'allBeatmaps' : 'beatmaps'; diff --git a/app/Transformers/ChangelogEntryTransformer.php b/app/Transformers/ChangelogEntryTransformer.php index f11f9091f63..0d190c36b24 100644 --- a/app/Transformers/ChangelogEntryTransformer.php +++ b/app/Transformers/ChangelogEntryTransformer.php @@ -39,11 +39,11 @@ public function includeGithubUser(ChangelogEntry $entry) public function includeMessage(ChangelogEntry $entry) { - return $this->primitive($entry->publicMessage()); + return $this->primitive($entry->message); } public function includeMessageHtml(ChangelogEntry $entry) { - return $this->primitive($entry->publicMessageHtml()); + return $this->primitive($entry->messageHtml()); } } diff --git a/app/Transformers/Multiplayer/PlaylistItemTransformer.php b/app/Transformers/Multiplayer/PlaylistItemTransformer.php index 99cd7f98d83..f7b331b05e9 100644 --- a/app/Transformers/Multiplayer/PlaylistItemTransformer.php +++ b/app/Transformers/Multiplayer/PlaylistItemTransformer.php @@ -24,6 +24,7 @@ public function transform(PlaylistItem $item) 'ruleset_id' => $item->ruleset_id, 'allowed_mods' => $item->allowed_mods, 'required_mods' => $item->required_mods, + 'freestyle' => $item->freestyle, 'expired' => $item->expired, 'owner_id' => $item->owner_id, 'playlist_order' => $item->playlist_order, diff --git a/app/Transformers/Multiplayer/RoomTransformer.php b/app/Transformers/Multiplayer/RoomTransformer.php index c96cae14b56..cc3d0cf93c1 100644 --- a/app/Transformers/Multiplayer/RoomTransformer.php +++ b/app/Transformers/Multiplayer/RoomTransformer.php @@ -23,6 +23,26 @@ class RoomTransformer extends TransformerAbstract 'recent_participants', ]; + public static function createShowResponse(Room $room): array + { + return json_item( + $room->loadMissing([ + 'host', + 'playlist.beatmap.baseMaxCombo', + 'playlist.beatmap.beatmapset', + ]), + new static(), + [ + 'current_user_score.playlist_item_attempts', + 'host.country', + 'playlist.beatmap.beatmapset', + 'playlist.beatmap.checksum', + 'playlist.beatmap.max_combo', + 'recent_participants', + ], + ); + } + public function transform(Room $room) { return [ diff --git a/app/helpers.php b/app/helpers.php index 81685d9c2bc..5960595cd21 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -55,15 +55,11 @@ function atom_id(string $namespace, $id = null): string return 'tag:'.request()->getHttpHost().',2019:'.$namespace.($id === null ? '' : "/{$id}"); } -function background_image($url, $proxy = true) +function background_image($url): string { - if (!present($url)) { - return ''; - } - - $url = $proxy ? proxy_media($url) : $url; - - return sprintf(' style="background-image:url(\'%s\');" ', e($url)); + return present($url) + ? sprintf(' style="background-image:url(\'%s\');" ', e($url)) + : ''; } function beatmap_timestamp_format($ms) @@ -840,7 +836,9 @@ function forum_user_link(int $id, string $username, string|null $colour, int|nul function is_api_request(): bool { - return str_starts_with(rawurldecode(Request::getPathInfo()), '/api/'); + $url = rawurldecode(Request::getPathInfo()); + return str_starts_with($url, '/api/') + || str_starts_with($url, '/_lio/'); } function is_http(string $url): bool @@ -1718,6 +1716,10 @@ function parse_time_to_carbon($value) if ($value instanceof DateTime) { return Carbon\Carbon::instance($value); } + + if ($value instanceof Carbon\CarbonImmutable) { + return $value->toMutable(); + } } function format_duration_for_display(int $seconds) diff --git a/composer.lock b/composer.lock index a5f11026f9f..97ce1ad5739 100644 --- a/composer.lock +++ b/composer.lock @@ -5060,16 +5060,16 @@ }, { "name": "nesbot/carbon", - "version": "2.72.5", + "version": "2.72.6", "source": { "type": "git", - "url": "https://github.com/briannesbitt/Carbon.git", - "reference": "afd46589c216118ecd48ff2b95d77596af1e57ed" + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "1e9d50601e7035a4c61441a208cb5bed73e108c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/afd46589c216118ecd48ff2b95d77596af1e57ed", - "reference": "afd46589c216118ecd48ff2b95d77596af1e57ed", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/1e9d50601e7035a4c61441a208cb5bed73e108c5", + "reference": "1e9d50601e7035a4c61441a208cb5bed73e108c5", "shasum": "" }, "require": { @@ -5089,7 +5089,7 @@ "doctrine/orm": "^2.7 || ^3.0", "friendsofphp/php-cs-fixer": "^3.0", "kylekatarnls/multi-tester": "^2.0", - "ondrejmirtes/better-reflection": "*", + "ondrejmirtes/better-reflection": "<6", "phpmd/phpmd": "^2.9", "phpstan/extension-installer": "^1.0", "phpstan/phpstan": "^0.12.99 || ^1.7.14", @@ -5102,10 +5102,6 @@ ], "type": "library", "extra": { - "branch-alias": { - "dev-master": "3.x-dev", - "dev-2.x": "2.x-dev" - }, "laravel": { "providers": [ "Carbon\\Laravel\\ServiceProvider" @@ -5115,6 +5111,10 @@ "includes": [ "extension.neon" ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" } }, "autoload": { @@ -5163,7 +5163,7 @@ "type": "tidelift" } ], - "time": "2024-06-03T19:18:41+00:00" + "time": "2024-12-27T09:28:11+00:00" }, { "name": "nette/schema", @@ -8073,16 +8073,16 @@ }, { "name": "symfony/cache-contracts", - "version": "v3.5.0", + "version": "v3.5.1", "source": { "type": "git", "url": "https://github.com/symfony/cache-contracts.git", - "reference": "df6a1a44c890faded49a5fca33c2d5c5fd3c2197" + "reference": "15a4f8e5cd3bce9aeafc882b1acab39ec8de2c1b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/df6a1a44c890faded49a5fca33c2d5c5fd3c2197", - "reference": "df6a1a44c890faded49a5fca33c2d5c5fd3c2197", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/15a4f8e5cd3bce9aeafc882b1acab39ec8de2c1b", + "reference": "15a4f8e5cd3bce9aeafc882b1acab39ec8de2c1b", "shasum": "" }, "require": { @@ -8091,12 +8091,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -8129,7 +8129,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/cache-contracts/tree/v3.5.0" + "source": "https://github.com/symfony/cache-contracts/tree/v3.5.1" }, "funding": [ { @@ -8145,7 +8145,7 @@ "type": "tidelift" } ], - "time": "2024-04-18T09:32:20+00:00" + "time": "2024-09-25T14:20:29+00:00" }, { "name": "symfony/console", @@ -8325,12 +8325,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -8530,16 +8530,16 @@ }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.5.0", + "version": "v3.5.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "8f93aec25d41b72493c6ddff14e916177c9efc50" + "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/8f93aec25d41b72493c6ddff14e916177c9efc50", - "reference": "8f93aec25d41b72493c6ddff14e916177c9efc50", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/7642f5e970b672283b7823222ae8ef8bbc160b9f", + "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f", "shasum": "" }, "require": { @@ -8548,12 +8548,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -8586,7 +8586,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.1" }, "funding": [ { @@ -8602,7 +8602,7 @@ "type": "tidelift" } ], - "time": "2024-04-18T09:32:20+00:00" + "time": "2024-09-25T14:20:29+00:00" }, { "name": "symfony/finder", @@ -8763,16 +8763,16 @@ }, { "name": "symfony/http-client-contracts", - "version": "v3.5.0", + "version": "v3.5.2", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "20414d96f391677bf80078aa55baece78b82647d" + "reference": "ee8d807ab20fcb51267fdace50fbe3494c31e645" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/20414d96f391677bf80078aa55baece78b82647d", - "reference": "20414d96f391677bf80078aa55baece78b82647d", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/ee8d807ab20fcb51267fdace50fbe3494c31e645", + "reference": "ee8d807ab20fcb51267fdace50fbe3494c31e645", "shasum": "" }, "require": { @@ -8780,12 +8780,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -8821,7 +8821,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.5.0" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.5.2" }, "funding": [ { @@ -8837,7 +8837,7 @@ "type": "tidelift" } ], - "time": "2024-04-18T09:32:20+00:00" + "time": "2024-12-07T08:49:48+00:00" }, { "name": "symfony/http-foundation", @@ -9609,8 +9609,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -10133,16 +10133,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.5.0", + "version": "v3.5.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "bd1d9e59a81d8fa4acdcea3f617c581f7475a80f" + "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/bd1d9e59a81d8fa4acdcea3f617c581f7475a80f", - "reference": "bd1d9e59a81d8fa4acdcea3f617c581f7475a80f", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/e53260aabf78fb3d63f8d79d69ece59f80d5eda0", + "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0", "shasum": "" }, "require": { @@ -10155,12 +10155,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -10196,7 +10196,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.5.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.5.1" }, "funding": [ { @@ -10212,7 +10212,7 @@ "type": "tidelift" } ], - "time": "2024-04-18T09:32:20+00:00" + "time": "2024-09-25T14:20:29+00:00" }, { "name": "symfony/string", @@ -10398,16 +10398,16 @@ }, { "name": "symfony/translation-contracts", - "version": "v3.5.0", + "version": "v3.5.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "b9d2189887bb6b2e0367a9fc7136c5239ab9b05a" + "reference": "4667ff3bd513750603a09c8dedbea942487fb07c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/b9d2189887bb6b2e0367a9fc7136c5239ab9b05a", - "reference": "b9d2189887bb6b2e0367a9fc7136c5239ab9b05a", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/4667ff3bd513750603a09c8dedbea942487fb07c", + "reference": "4667ff3bd513750603a09c8dedbea942487fb07c", "shasum": "" }, "require": { @@ -10415,12 +10415,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -10456,7 +10456,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.5.0" + "source": "https://github.com/symfony/translation-contracts/tree/v3.5.1" }, "funding": [ { @@ -10472,7 +10472,7 @@ "type": "tidelift" } ], - "time": "2024-04-18T09:32:20+00:00" + "time": "2024-09-25T14:20:29+00:00" }, { "name": "symfony/uid", diff --git a/database/factories/ScoreTokenFactory.php b/database/factories/ScoreTokenFactory.php index aa7f4950d14..7c7b85f727a 100644 --- a/database/factories/ScoreTokenFactory.php +++ b/database/factories/ScoreTokenFactory.php @@ -23,7 +23,8 @@ public function definition(): array 'build_id' => Build::factory(), 'user_id' => User::factory(), - // depends on beatmap_id + // depend on beatmap_id + 'beatmap_hash' => fn (array $attr) => Beatmap::find($attr['beatmap_id'])->checksum, 'ruleset_id' => fn (array $attr) => Beatmap::find($attr['beatmap_id'])->playmode, ]; } diff --git a/database/factories/TagFactory.php b/database/factories/TagFactory.php index f1bb27b044d..8617d60d431 100644 --- a/database/factories/TagFactory.php +++ b/database/factories/TagFactory.php @@ -16,7 +16,7 @@ class TagFactory extends Factory public function definition(): array { return [ - 'name' => fn () => "Tag {$this->faker->word}", + 'name' => fn () => "Tag {$this->faker->unique()->word}", 'description' => fn () => $this->faker->sentence, ]; } diff --git a/database/migrations/2025_01_06_000000_create_osu_logins.php b/database/migrations/2025_01_06_000000_create_osu_logins.php new file mode 100644 index 00000000000..6d0840afa43 --- /dev/null +++ b/database/migrations/2025_01_06_000000_create_osu_logins.php @@ -0,0 +1,36 @@ +. 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 + { + // This is a legacy table. Migration is added for external projects' sake. + if (Schema::hasTable('osu_logins')) { + return; + } + + Schema::create('osu_logins', function (Blueprint $table) { + $table->unsignedInteger('user_id')->default(0); + $table->string('ip', 100)->default(''); + $table->timestamp('date')->useCurrent(); + + $table->index('user_id', 'user_id'); + $table->index('date', 'date'); + $table->index('ip', 'ip'); + }); + } + + public function down(): void + { + Schema::dropIfExists('osu_logins'); + } +}; diff --git a/database/migrations/2025_01_16_000000_add_freestyle_to_multiplayer_playlist_items.php b/database/migrations/2025_01_16_000000_add_freestyle_to_multiplayer_playlist_items.php new file mode 100644 index 00000000000..9136afe63b2 --- /dev/null +++ b/database/migrations/2025_01_16_000000_add_freestyle_to_multiplayer_playlist_items.php @@ -0,0 +1,33 @@ +. 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 +{ + /** + * Run the migrations. + */ + public function up(): void + { + Schema::table('multiplayer_playlist_items', function (Blueprint $table) { + $table->boolean('freestyle')->after('required_mods')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('multiplayer_playlist_items', function (Blueprint $table) { + $table->dropColumn('freestyle'); + }); + } +}; diff --git a/docker/development/prepare.sh b/docker/development/prepare.sh index 7f69ea08701..94ba4806019 100755 --- a/docker/development/prepare.sh +++ b/docker/development/prepare.sh @@ -15,18 +15,24 @@ _run() { docker compose run --rm php "$@" } -_run_dusk() { - docker compose run --rm -e APP_ENV=dusk.local php "$@" -} - -if [ ! -f .env ]; then - echo "Copying default env file" - cp .env.example .env -fi +for envfile in .env .env.testing .env.dusk.local; do + if [ ! -f "${envfile}" ]; then + echo "Copying env file '${envfile}'" + cp "${envfile}.example" "${envfile}" + fi + if [ "${envfile}" != .env.testing ] && ! grep -q '^APP_KEY=.' "${envfile}"; then + echo "Generating app key for env file '${envfile}'" + sed -i -e '/^APP_KEY=.*/d' "${envfile}" + : ${APP_KEY="base64:$(head -c 32 /dev/urandom | base64)"} + echo "APP_KEY=${APP_KEY}" >> "${envfile}" + fi +done if [ -n "${GITHUB_TOKEN:-}" ]; then _run composer config -g github-oauth.github.com "${GITHUB_TOKEN}" - grep ^GITHUB_TOKEN= .env || echo "GITHUB_TOKEN=${GITHUB_TOKEN}" >> .env + for envfile in .env .env.dusk.local; do + grep -q '^GITHUB_TOKEN=' "${envfile}" || echo "GITHUB_TOKEN=${GITHUB_TOKEN}" >> "${envfile}" + done fi docker compose build @@ -37,23 +43,6 @@ _run composer install _run artisan dusk:chrome-driver -if ! grep -q '^APP_KEY=.' .env; then - echo "Generating app key" - _run artisan key:generate -fi - -if [ ! -f .env.testing ]; then - echo "Copying default test env file" - cp .env.testing.example .env.testing -fi - -if [ ! -f .env.dusk.local ]; then - echo "Copying default dusk env file" - cp .env.dusk.local.example .env.dusk.local - echo "Generating app key for dusk" - _run_dusk artisan key:generate -fi - if [ -d storage/oauth-public.key ]; then echo "oauth-public.key is a directory. Removing it" rmdir storage/oauth-public.key diff --git a/docker/development/run.sh b/docker/development/run.sh index 372b5991eb7..4fd8d6eadc5 100755 --- a/docker/development/run.sh +++ b/docker/development/run.sh @@ -20,7 +20,19 @@ _migrate() { } _octane() { - exec ./artisan octane:start --host=0.0.0.0 "$@" + echo_counter=0 + manifest_path='./public/assets/manifest.json' + while [ ! -f "$manifest_path" ]; do + if [ "$echo_counter" -le 0 ]; then + echo_counter=5 + echo "waiting to start octane: ${manifest_path##*/} not found" >&2 + fi + + : $(( echo_counter -= 1 )) + sleep 1 + done + + exec ./artisan octane:start --host=0.0.0.0 "$@" } _schedule() { diff --git a/resources/css/bem/beatmap-discussions-header-top.less b/resources/css/bem/beatmap-discussions-header-top.less index 3ccef7ae580..32c2a199f74 100644 --- a/resources/css/bem/beatmap-discussions-header-top.less +++ b/resources/css/bem/beatmap-discussions-header-top.less @@ -90,6 +90,7 @@ grid-area: owners; font-size: @font-size--title-small; padding: 10px 10px 0; + overflow-wrap: anywhere; } &__stats { diff --git a/resources/css/bem/daily-challenge.less b/resources/css/bem/daily-challenge.less index d0353dd25c1..c3d8031eab4 100644 --- a/resources/css/bem/daily-challenge.less +++ b/resources/css/bem/daily-challenge.less @@ -5,9 +5,31 @@ background: hsl(var(--hsl-b4)); border-radius: @border-radius-large; min-width: 0; + position: relative; display: flex; align-items: center; - padding: 3px; + padding: 1px; + border: 2px solid transparent; + + &--played-today { + border-color: @osu-colour-lime-1; + + &::before { + .fas(); + .center-content(); + background-color: @osu-colour-lime-1; + border-radius: 50%; + color: @osu-colour-b6; + content: @fa-var-check; + font-size: 8px; // icon size + height: 16px; + position: absolute; + right: 0; + top: 0; + transform: translate(50%, -50%); + width: $height; + } + } &__name { font-size: @font-size--normal; @@ -20,7 +42,7 @@ } &__value-box { - border-radius: @border-radius-large; + border-radius: @border-radius-small; background: hsl(var(--hsl-b6)); padding: 5px 10px; } diff --git a/resources/css/bem/team-members-manage.less b/resources/css/bem/team-members-manage.less index 9ddbb0f6544..250e0ddc726 100644 --- a/resources/css/bem/team-members-manage.less +++ b/resources/css/bem/team-members-manage.less @@ -8,7 +8,7 @@ font-size: @font-size--title-small; display: grid; gap: 2px 10px; - grid-template-columns: auto 1fr auto auto auto; + grid-template-columns: 1fr auto auto auto; &__avatar { .default-border-radius(); @@ -38,4 +38,11 @@ font-weight: 600; } } + + &__username { + display: flex; + align-items: center; + width: max-content; + gap: 10px; + } } diff --git a/resources/js/beatmap-discussions/beatmap-owner-editor.tsx b/resources/js/beatmap-discussions/beatmap-owner-editor.tsx index d071c24c6c3..f6d616000f1 100644 --- a/resources/js/beatmap-discussions/beatmap-owner-editor.tsx +++ b/resources/js/beatmap-discussions/beatmap-owner-editor.tsx @@ -189,6 +189,7 @@ export default class BeatmapOwnerEditor extends React.Component {
{this.editing ? ( {
{hasGuestOwners(this.currentBeatmap, this.beatmapset) && ( - - , - }} - pattern={trans('beatmaps.discussions.guest')} - /> - + , + }} + pattern={trans('beatmaps.discussions.guest')} + /> )}
diff --git a/resources/js/beatmap-discussions/user-card.tsx b/resources/js/beatmap-discussions/user-card.tsx index 53ab7e3e283..71a34658c74 100644 --- a/resources/js/beatmap-discussions/user-card.tsx +++ b/resources/js/beatmap-discussions/user-card.tsx @@ -3,6 +3,7 @@ import UserAvatar from 'components/user-avatar'; import UserGroupBadge from 'components/user-group-badge'; +import UserLink from 'components/user-link'; import UserGroupJson from 'interfaces/user-group-json'; import UserJson from 'interfaces/user-json'; import { route } from 'laroute'; @@ -32,12 +33,12 @@ export class UserCard extends React.PureComponent { ) : ( - - + )}
@@ -49,12 +50,12 @@ export class UserCard extends React.PureComponent { ) : ( - {this.renderUsername()} - + )} {!this.props.user.is_bot && !this.props.user.is_deleted && ( (); + + for (const tag of this.beatmapset.related_tags) { + map.set(tag.id, tag); + } + + return map; + } + + @computed + get tags() { + const userTags: TagJsonWithCount[] = []; + + if (this.currentBeatmap.top_tag_ids != null) { + for (const tagId of this.currentBeatmap.top_tag_ids) { + const maybeTag = this.relatedTags.get(tagId.tag_id); + if (maybeTag == null) continue; + + userTags.push({ ...maybeTag, count: tagId.count } ); + } + } + + return { + mapperTags: this.beatmapset.tags.split(' ').filter(present), + userTags: userTags.sort((a, b) => { + const diff = b.count - a.count; + return diff !== 0 ? diff : a.name.localeCompare(b.name); + }), + }; + } + @computed get usersById() { return keyBy(this.beatmapset.related_users, 'id') as Partial>; diff --git a/resources/js/beatmapsets-show/info.tsx b/resources/js/beatmapsets-show/info.tsx index a90cdb9e042..dcbb8e30f3d 100644 --- a/resources/js/beatmapsets-show/info.tsx +++ b/resources/js/beatmapsets-show/info.tsx @@ -65,6 +65,15 @@ export default class Info extends React.Component { return ret; } + private get tags() { + const tags = this.controller.tags; + + return [ + ...tags.userTags.map((tag) => tag.name), + ...tags.mapperTags, + ]; + } + private get withEditDescription() { return this.controller.beatmapset.description.bbcode != null; } @@ -84,10 +93,6 @@ export default class Info extends React.Component { } render() { - const tags = this.controller.beatmapset.tags - .split(' ') - .filter(present); - return (
{this.isEditingDescription && @@ -191,13 +196,13 @@ export default class Info extends React.Component {
- {tags.length > 0 && + {this.tags.length > 0 &&

{trans('beatmapsets.show.info.tags')}

- {tags.map((tag, i) => ( + {this.tags.map((tag, i) => ( {
{ } try { - this.xhr = apiLookupUsers(userIds); + this.xhr = apiLookupUsers(userIds, this.props.excludeBots); const response = await this.xhr; this.extractValidUsers(response.users); } catch (error) { diff --git a/resources/js/core-legacy/changelog-chart-loader.coffee b/resources/js/core-legacy/changelog-chart-loader.coffee index 2e6a570da3f..25c056497aa 100644 --- a/resources/js/core-legacy/changelog-chart-loader.coffee +++ b/resources/js/core-legacy/changelog-chart-loader.coffee @@ -4,17 +4,28 @@ import ChangelogChart from 'charts/changelog-chart' export default class ChangelogChartLoader - container: document.getElementsByClassName('js-changelog-chart') - constructor: -> - $(window).on 'resize', @resize - $(document).on 'turbo:load', @initialize + document.addEventListener 'turbo:load', @initialize + document.addEventListener 'turbo:before-cache', @reset + initialize: => - return if !@container[0]? + container = document.querySelector('.js-changelog-chart') + + return unless container? + + # reset existing chart + container.innerHTML = '' + + @chart = new ChangelogChart container + @chart.loadData() + window.addEventListener 'resize', @resize + + + reset: => + @chart = null + window.removeEventListener 'resize', @resize - @container[0]._chart = new ChangelogChart @container[0] - @container[0]._chart.loadData() resize: => - @container[0]?._chart.resize() + @chart.resize() diff --git a/resources/js/interfaces/beatmap-json.ts b/resources/js/interfaces/beatmap-json.ts index a7d40211981..f00b9b24a14 100644 --- a/resources/js/interfaces/beatmap-json.ts +++ b/resources/js/interfaces/beatmap-json.ts @@ -17,6 +17,7 @@ interface BeatmapJsonAvailableIncludes { failtimes: BeatmapFailTimesArray; max_combo: number; owners: BeatmapOwnerJson[]; + top_tag_ids: { count: number; tag_id: number }[]; user: UserJson; } diff --git a/resources/js/interfaces/beatmapset-extended-json.ts b/resources/js/interfaces/beatmapset-extended-json.ts index 1a41e2c9d3c..c7ff2f4038d 100644 --- a/resources/js/interfaces/beatmapset-extended-json.ts +++ b/resources/js/interfaces/beatmapset-extended-json.ts @@ -55,6 +55,7 @@ type BeatmapsetJsonForShowIncludes = Required>; diff --git a/resources/js/interfaces/beatmapset-json.ts b/resources/js/interfaces/beatmapset-json.ts index 533ee955779..f6203c3c052 100644 --- a/resources/js/interfaces/beatmapset-json.ts +++ b/resources/js/interfaces/beatmapset-json.ts @@ -9,6 +9,7 @@ import BeatmapsetNominationJson from './beatmapset-nomination-json'; import GenreJson from './genre-json'; import LanguageJson from './language-json'; import Ruleset from './ruleset'; +import TagJson from './tag-json'; import UserJson, { UserJsonDeleted } from './user-json'; export interface Availability { @@ -92,6 +93,7 @@ interface BeatmapsetJsonAvailableIncludes { nominations: BeatmapsetNominationsInterface; ratings: number[]; recent_favourites: UserJson[]; + related_tags: TagJson[]; related_users: UserJson[]; user: UserJson | UserJsonDeleted; } diff --git a/resources/js/interfaces/tag-json.ts b/resources/js/interfaces/tag-json.ts new file mode 100644 index 00000000000..5d1352a7a4b --- /dev/null +++ b/resources/js/interfaces/tag-json.ts @@ -0,0 +1,8 @@ +// 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 default interface TagJson { + description: string; + id: number; + name: string; +} diff --git a/resources/js/profile-page/daily-challenge.tsx b/resources/js/profile-page/daily-challenge.tsx index 44cf465e2c4..abd07f6db93 100644 --- a/resources/js/profile-page/daily-challenge.tsx +++ b/resources/js/profile-page/daily-challenge.tsx @@ -4,6 +4,8 @@ import DailyChallengeUserStatsJson from 'interfaces/daily-challenge-user-stats-json'; import { autorun } from 'mobx'; import { observer } from 'mobx-react'; +import * as moment from 'moment'; +import core from 'osu-core-singleton'; import * as React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { classWithModifiers, Modifiers } from 'utils/css'; @@ -122,10 +124,13 @@ export default class DailyChallenge extends React.Component { return null; } + const playedToday = this.props.stats.last_update !== null && moment.utc(this.props.stats.last_update).isSame(Date.now(), 'day'); + const userIsOnOwnProfile = this.props.stats.user_id === core.currentUser?.id; + return (
diff --git a/resources/js/quick-search/user.tsx b/resources/js/quick-search/user.tsx index 6cd04f5858f..b391e6f9e0d 100644 --- a/resources/js/quick-search/user.tsx +++ b/resources/js/quick-search/user.tsx @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. import FlagCountry from 'components/flag-country'; +import FlagTeam from 'components/flag-team'; import FriendButton from 'components/friend-button'; import SupporterIcon from 'components/supporter-icon'; import UserGroupBadges from 'components/user-group-badges'; @@ -26,6 +27,12 @@ export default function User({ user, modifiers = [] }: { modifiers?: string[]; u
+ {user.team != null && +
+ +
+ } +
{user.username} diff --git a/resources/js/store/store-supporter-tag.tsx b/resources/js/store/store-supporter-tag.tsx index 5e39f229e90..4db15f63c38 100644 --- a/resources/js/store/store-supporter-tag.tsx +++ b/resources/js/store/store-supporter-tag.tsx @@ -224,7 +224,7 @@ export default class StoreSupporterTag extends React.Component { @action private readonly getUser = (username: string) => { - this.xhr = apiLookupUsers([`@${username}`]); + this.xhr = apiLookupUsers([`@${username}`], true); this.xhr .done((response) => runInAction(() => { diff --git a/resources/js/utils/user.ts b/resources/js/utils/user.ts index a403709d51f..1345ac81dee 100644 --- a/resources/js/utils/user.ts +++ b/resources/js/utils/user.ts @@ -4,9 +4,9 @@ import UserJson from 'interfaces/user-json'; import { route } from 'laroute'; -export function apiLookupUsers(idsOrUsernames: (string | null | undefined)[]) { +export function apiLookupUsers(idsOrUsernames: (string | null | undefined)[], excludeBots?: boolean) { return $.ajax(route('users.lookup'), { - data: { ids: idsOrUsernames }, + data: { exclude_bots: excludeBots, ids: idsOrUsernames }, dataType: 'json', }) as JQuery.jqXHR<{ users: UserJson[] }>; } diff --git a/resources/lang/en/teams.php b/resources/lang/en/teams.php index ae637b63d52..a3984e14dff 100644 --- a/resources/lang/en/teams.php +++ b/resources/lang/en/teams.php @@ -4,6 +4,10 @@ // See the LICENCE file in the repository root for full licence text. return [ + 'destroy' => [ + 'ok' => 'Team removed', + ], + 'edit' => [ 'saved' => 'Settings saved successfully', 'title' => 'Team Settings', @@ -66,6 +70,7 @@ 'show' => [ 'bar' => [ + 'destroy' => 'Disband Team', 'part' => 'Leave Team', ], diff --git a/resources/views/follows/modding.blade.php b/resources/views/follows/modding.blade.php index 18f677dec3b..210c646fdc2 100644 --- a/resources/views/follows/modding.blade.php +++ b/resources/views/follows/modding.blade.php @@ -60,7 +60,7 @@
beatmapset->coverURL('list'), false) !!} + {!! background_image($watch->beatmapset->coverURL('list')) !!} class="beatmapset-watches__cover" >
diff --git a/resources/views/forum/topics/_post_info.blade.php b/resources/views/forum/topics/_post_info.blade.php index db4bd562cbd..0b2cc37e46e 100644 --- a/resources/views/forum/topics/_post_info.blade.php +++ b/resources/views/forum/topics/_post_info.blade.php @@ -76,7 +76,7 @@ class="forum-post-info__row forum-post-info__row--title" logo()->url(), false) !!} + {!! background_image($team->logo()->url()) !!} >
diff --git a/resources/views/home/_search_result_user.blade.php b/resources/views/home/_search_result_user.blade.php index 57c4e093add..efeb19a0731 100644 --- a/resources/views/home/_search_result_user.blade.php +++ b/resources/views/home/_search_result_user.blade.php @@ -2,7 +2,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. --}} +@php + use App\Transformers\UserCompactTransformer; +@endphp
-
+ data-modifiers="{{ json_encode(['search']) }}" + data-users="{{ json_encode(json_collection( + $search->data(), + new UserCompactTransformer(), + UserCompactTransformer::CARD_INCLUDES, + )) }}" +>
diff --git a/resources/views/layout/_header_user.blade.php b/resources/views/layout/_header_user.blade.php index 1905100230f..7e7d2338157 100644 --- a/resources/views/layout/_header_user.blade.php +++ b/resources/views/layout/_header_user.blade.php @@ -21,6 +21,6 @@ class="{{ $class }} avatar--guest" class="{{ $class }} {{ Auth::user()->isRestricted() ? 'avatar--restricted' : '' }}" data-click-menu-target="nav2-user-popup" href="{{ route('users.show', Auth::user()) }}" - {!! background_image(Auth::user()->user_avatar, false) !!} + {!! background_image(Auth::user()->user_avatar) !!} > @endif diff --git a/resources/views/layout/_page_header_v4.blade.php b/resources/views/layout/_page_header_v4.blade.php index 963d1093f22..36d78c726da 100644 --- a/resources/views/layout/_page_header_v4.blade.php +++ b/resources/views/layout/_page_header_v4.blade.php @@ -24,7 +24,7 @@ ">
-
+
@if (!$buttons->isEmpty())
+ @if ($buttons->contains('destroy')) +
+ + +
+ @endif @if ($buttons->contains('part'))
name('part'); Route::resource('members', 'Teams\MembersController', ['only' => ['destroy', 'index']]); }); - Route::resource('teams', 'TeamsController', ['only' => ['edit', 'show', 'update']]); + Route::resource('teams', 'TeamsController', ['only' => ['destroy', 'edit', 'show', 'update']]); Route::post('users/check-username-availability', 'UsersController@checkUsernameAvailability')->name('users.check-username-availability'); Route::get('users/lookup', 'Users\LookupController@index')->name('users.lookup'); @@ -431,7 +431,7 @@ }); }); - Route::apiResource('tags', 'BeatmapTagsController', ['only' => ['index', 'store', 'destroy']]); + Route::apiResource('tags', 'BeatmapTagsController', ['only' => ['store', 'destroy']]); }); }); @@ -602,6 +602,11 @@ Route::apiResource('bulk', 'Indexing\BulkController', ['only' => ['store']]); }); + Route::group(['as' => 'multiplayer.', 'namespace' => 'Multiplayer', 'prefix' => 'multiplayer'], function () { + Route::put('rooms/{room}/users/{user}', 'RoomsController@join')->name('rooms.join'); + Route::apiResource('rooms', 'RoomsController', ['only' => ['store']]); + }); + Route::post('user-achievement/{user}/{achievement}/{beatmap?}', 'UsersController@achievement')->name('users.achievement'); Route::group(['as' => 'user-group.'], function () { diff --git a/tests/Controllers/BeatmapTagsControllerTest.php b/tests/Controllers/BeatmapTagsControllerTest.php index 84b17bd570f..f75438c1454 100644 --- a/tests/Controllers/BeatmapTagsControllerTest.php +++ b/tests/Controllers/BeatmapTagsControllerTest.php @@ -12,7 +12,6 @@ use App\Models\Solo\Score; use App\Models\Tag; use App\Models\User; -use Illuminate\Testing\Fluent\AssertableJson; use Tests\TestCase; class BeatmapTagsControllerTest extends TestCase @@ -21,21 +20,6 @@ class BeatmapTagsControllerTest extends TestCase private Beatmap $beatmap; private BeatmapTag $beatmapTag; - public function testIndex(): void - { - $this->actAsScopedUser(User::factory()->create(), ['public']); - - $this - ->get(route('api.beatmaps.tags.index', ['beatmap' => $this->beatmap->getKey()])) - ->assertSuccessful() - ->assertJson(fn (AssertableJson $json) => - $json - ->where('beatmap_tags.0.id', $this->tag->getKey()) - ->where('beatmap_tags.0.name', $this->tag->name) - ->where('beatmap_tags.0.count', 1) - ->etc()); - } - public function testStore(): void { $user = User::factory() diff --git a/tests/Controllers/InterOp/Multiplayer/RoomsControllerTest.php b/tests/Controllers/InterOp/Multiplayer/RoomsControllerTest.php new file mode 100644 index 00000000000..353e1902eea --- /dev/null +++ b/tests/Controllers/InterOp/Multiplayer/RoomsControllerTest.php @@ -0,0 +1,101 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +namespace Tests\Controllers\InterOp\Multiplayer; + +use App\Models\Beatmap; +use App\Models\Chat\UserChannel; +use App\Models\Multiplayer\Room; +use App\Models\User; +use Carbon\CarbonImmutable; +use Tests\TestCase; + +class RoomsControllerTest extends TestCase +{ + private static function startRoomParams(): array + { + $beatmap = Beatmap::factory()->create(); + + return [ + 'ends_at' => CarbonImmutable::now()->addHours(1), + 'name' => 'test room', + 'type' => Room::REALTIME_DEFAULT_TYPE, + 'playlist' => [[ + 'beatmap_id' => $beatmap->getKey(), + 'ruleset_id' => $beatmap->playmode, + ]], + ]; + } + + public function testJoin(): void + { + $room = (new Room())->startGame(User::factory()->create(), static::startRoomParams()); + $user = User::factory()->create(); + + $this->expectCountChange(fn () => UserChannel::count(), 1); + + $this->withInterOpHeader( + route('interop.multiplayer.rooms.join', [ + 'room' => $room->getKey(), + 'user' => $user->getKey(), + ]), + fn ($url) => $this->put($url), + )->assertSuccessful(); + } + + public function testJoinWithPassword(): void + { + $room = (new Room())->startGame(User::factory()->create(), [ + ...static::startRoomParams(), + 'password' => 'hunter2', + ]); + $user = User::factory()->create(); + + $this->expectCountChange(fn () => UserChannel::count(), 1); + + $this->withInterOpHeader( + route('interop.multiplayer.rooms.join', [ + 'room' => $room->getKey(), + 'user' => $user->getKey(), + ]), + fn ($url) => $this->put($url, ['password' => 'hunter2']), + )->assertSuccessful(); + } + + public function testJoinWithPasswordInvalid(): void + { + $room = (new Room())->startGame(User::factory()->create(), [ + ...static::startRoomParams(), + 'password' => 'hunter2', + ]); + $user = User::factory()->create(); + + $this->expectCountChange(fn () => UserChannel::count(), 0); + + $this->withInterOpHeader( + route('interop.multiplayer.rooms.join', [ + 'room' => $room->getKey(), + 'user' => $user->getKey(), + ]), + fn ($url) => $this->put($url, ['password' => '*******']), + )->assertStatus(403); + } + + public function testStore(): void + { + $beatmap = Beatmap::factory()->create(); + $params = [ + ...static::startRoomParams(), + 'user_id' => User::factory()->create()->getKey(), + ]; + + $this->expectCountChange(fn () => Room::count(), 1); + + $this->withInterOpHeader( + route('interop.multiplayer.rooms.store'), + fn ($url) => $this->post($url, $params), + )->assertSuccessful(); + } +} diff --git a/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php b/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php index 2477e8d1518..bdbe0d69f72 100644 --- a/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php +++ b/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php @@ -169,7 +169,7 @@ public function testUpdate($bodyParams, $status) $playlistItem = PlaylistItem::factory()->create(); $room = $playlistItem->room; $build = Build::factory()->create(['allow_ranking' => true]); - $scoreToken = $room->startPlay($user, $playlistItem, 0); + $scoreToken = static::roomStartPlay($user, $playlistItem); $this->withHeaders(['x-token' => static::createClientToken($build)]); diff --git a/tests/Controllers/ScoreTokensControllerTest.php b/tests/Controllers/ScoreTokensControllerTest.php index e75d971fee0..1dc2c84a305 100644 --- a/tests/Controllers/ScoreTokensControllerTest.php +++ b/tests/Controllers/ScoreTokensControllerTest.php @@ -45,7 +45,7 @@ public function testStore(string $beatmapState, int $status): void /** * @dataProvider dataProviderForTestStoreInvalidParameter */ - public function testStoreInvalidParameter(string $paramKey, ?string $paramValue, int $status): void + public function testStoreInvalidParameter(string $paramKey, ?string $paramValue, int $status, string $errorMessage): void { $origClientCheckVersion = $GLOBALS['cfg']['osu']['client']['check_version']; config_set('osu.client.check_version', true); @@ -78,14 +78,6 @@ public function testStoreInvalidParameter(string $paramKey, ?string $paramValue, $this->expectCountChange(fn () => ScoreToken::count(), 0); - $errorMessage = $paramValue === null ? 'missing' : 'invalid'; - $errorMessage .= ' '; - $errorMessage .= $paramKey === 'client_token' - ? ($paramValue === null - ? 'token header' - : 'client hash' - ) : $paramKey; - $this->json( 'POST', route('api.beatmaps.solo.score-tokens.store', $routeParams), @@ -161,14 +153,14 @@ public static function dataProviderForTestStore(): array public static function dataProviderForTestStoreInvalidParameter(): array { return [ - 'invalid client token' => ['client_token', md5('invalid_'), 422], - 'missing client token' => ['client_token', null, 422], + 'invalid client token' => ['client_token', md5('invalid_'), 422, 'invalid client hash'], + 'missing client token' => ['client_token', null, 422, 'missing token header'], - 'invalid ruleset id' => ['ruleset_id', '5', 422], - 'missing ruleset id' => ['ruleset_id', null, 422], + 'invalid ruleset id' => ['ruleset_id', '5', 422, 'invalid ruleset_id'], + 'missing ruleset id' => ['ruleset_id', null, 422, 'missing ruleset_id'], - 'invalid beatmap hash' => ['beatmap_hash', 'xxx', 422], - 'missing beatmap hash' => ['beatmap_hash', null, 422], + 'invalid beatmap hash' => ['beatmap_hash', 'xxx', 422, 'invalid or missing beatmap_hash'], + 'missing beatmap hash' => ['beatmap_hash', null, 422, 'invalid or missing beatmap_hash'], ]; } diff --git a/tests/Libraries/Beatmapset/ChangeBeatmapOwnersTest.php b/tests/Libraries/Beatmapset/ChangeBeatmapOwnersTest.php index dcafe6da068..df6e815b4e4 100644 --- a/tests/Libraries/Beatmapset/ChangeBeatmapOwnersTest.php +++ b/tests/Libraries/Beatmapset/ChangeBeatmapOwnersTest.php @@ -23,6 +23,14 @@ class ChangeBeatmapOwnersTest extends TestCase { + public static function dataProviderForInvalidUser(): array + { + return [ + ['bot'], + ['no_profile'], + ]; + } + public static function dataProviderForUpdateOwner(): array { return [ @@ -68,6 +76,33 @@ public function testMissingUser(): void Bus::assertDispatched(BeatmapOwnerChange::class); } + /** + * @dataProvider dataProviderForInvalidUser + */ + public function testInvalidUser(string $group): void + { + $moderator = User::factory()->withGroup('nat')->create(); + $owner = User::factory()->create(); + $invalidUser = User::factory()->withGroup($group)->create(); + + $beatmap = Beatmap::factory() + ->for(Beatmapset::factory()->pending()->owner($owner)) + ->owner($owner) + ->create(); + + $this->expectCountChange(fn () => BeatmapsetEvent::count(), 0); + + $this->expectExceptionCallable( + fn () => (new ChangeBeatmapOwners($beatmap, [$invalidUser->getKey()], $moderator))->handle(), + InvariantException::class, + ); + + $beatmap = $beatmap->fresh(); + $this->assertEqualsCanonicalizing([$owner->getKey()], $beatmap->getOwners()->pluck('user_id')->toArray()); + + Bus::assertNotDispatched(BeatmapOwnerChange::class); + } + /** * @dataProvider dataProviderForUpdateOwner */ diff --git a/tests/Models/ChangelogEntryTest.php b/tests/Models/ChangelogEntryTest.php index 367da68468b..e4e31beec7c 100644 --- a/tests/Models/ChangelogEntryTest.php +++ b/tests/Models/ChangelogEntryTest.php @@ -12,12 +12,11 @@ class ChangelogEntryTest extends TestCase { /** - * @dataProvider dataForPublicMessageHtmlVisibility + * @dataProvider dataForGetDisplayMessage */ - public function testPublicMessageHtmlVisibility($message, $html) + public function testGetDisplayMessage($message, $html) { - $entry = new ChangelogEntry(compact('message')); - $this->assertSame($html, $entry->publicMessageHtml()); + $this->assertSame($html, ChangelogEntry::getDisplayMessage($message)); } public function testConvertLegacyChangelogWithTitle() @@ -26,7 +25,7 @@ public function testConvertLegacyChangelogWithTitle() $legacy = new Changelog(['message' => $title]); $converted = ChangelogEntry::convertLegacy($legacy); $this->assertSame($title, $converted->title); - $this->assertNull($converted->publicMessageHtml()); + $this->assertNull($converted->messageHtml()); } public function testConvertLegacyChangelogWithTitleAndMessage() @@ -36,7 +35,7 @@ public function testConvertLegacyChangelogWithTitleAndMessage() $legacy = new Changelog(['message' => "{$title}\n\n---\n{$message}"]); $converted = ChangelogEntry::convertLegacy($legacy); $this->assertSame($title, $converted->title); - $this->assertSame("

{$message}

\n
", $converted->publicMessageHtml()); + $this->assertSame("

{$message}

\n
", $converted->messageHtml()); } public function testConvertLegacyChangelogWithMessage() @@ -45,7 +44,7 @@ public function testConvertLegacyChangelogWithMessage() $legacy = new Changelog(['message' => "---\n{$message}"]); $converted = ChangelogEntry::convertLegacy($legacy); $this->assertSame($message, $converted->title); - $this->assertNull($converted->publicMessageHtml()); + $this->assertNull($converted->messageHtml()); } public function testGuessCategoryCapitalise() @@ -131,17 +130,17 @@ public function testIsPrivate() $this->assertTrue(ChangelogEntry::isPrivate($data)); } - public static function dataForPublicMessageHtmlVisibility() + public static function dataForGetDisplayMessage() { return [ - ['Hidden', null], - ["---\nVisible", "

Visible

\n
"], - ["Hidden\n---", null], - ["Hidden\n\n---", null], - ["Hidden\n\n---Still hidden", null], - ["Hidden\n---\n\nStill hidden", null], - ["Hidden\n---\nStill hidden", null], - ["Hidden\n\n---\nVisible", "

Visible

\n
"], + ['Hidden', ''], + ["Visible\n\n---", 'Visible'], + ["---\nHidden", ''], + ['---Hidden', ''], + ['Still hidden---', ''], + ["Hidden\n---\n\nStill hidden", ''], + ["Hidden\n---\nStill hidden", ''], + ["Visible\n\n---\nHidden", 'Visible'], ]; } } diff --git a/tests/Models/ModelCompositePrimaryKeysTest.php b/tests/Models/ModelCompositePrimaryKeysTest.php index d2e02b3edac..58d8b97575f 100644 --- a/tests/Models/ModelCompositePrimaryKeysTest.php +++ b/tests/Models/ModelCompositePrimaryKeysTest.php @@ -10,6 +10,7 @@ use App\Models\BeatmapDifficulty; use App\Models\BeatmapDifficultyAttrib; use App\Models\BeatmapFailtimes; +use App\Models\BeatmapTag; use App\Models\Chat; use App\Models\FavouriteBeatmapset; use App\Models\Forum; @@ -112,6 +113,16 @@ public static function dataProviderBase() ['type' => 'exit'], ['p1', [0, 10], 11], ], + [ + BeatmapTag::class, + [ + 'beatmap_id' => 0, + 'tag_id' => 0, + 'user_id' => 0, + ], + ['tag_id' => 1], + ['updated_at', [Carbon::now()->subDays(5), Carbon::now()->subDays(1)], Carbon::now()], + ], [ Chat\UserChannel::class, [ diff --git a/tests/Models/Multiplayer/RoomTest.php b/tests/Models/Multiplayer/RoomTest.php index 3a1f8307417..ca8a951478b 100644 --- a/tests/Models/Multiplayer/RoomTest.php +++ b/tests/Models/Multiplayer/RoomTest.php @@ -124,7 +124,7 @@ public function testRoomHasEnded() ]); $this->expectException(InvariantException::class); - $room->startPlay($user, $playlistItem, 0); + static::roomStartPlay($user, $playlistItem); } public function testStartPlay(): void @@ -137,7 +137,7 @@ public function testStartPlay(): void $this->expectCountChange(fn () => $room->userHighScores()->count(), 1); $this->expectCountChange(fn () => $playlistItem->scoreTokens()->count(), 1); - $room->startPlay($user, $playlistItem, 0); + static::roomStartPlay($user, $playlistItem); $room->refresh(); $this->assertSame($user->getKey(), $playlistItem->scoreTokens()->last()->user_id); @@ -150,14 +150,14 @@ public function testMaxAttemptsReached() $playlistItem1 = PlaylistItem::factory()->create(['room_id' => $room]); $playlistItem2 = PlaylistItem::factory()->create(['room_id' => $room]); - $room->startPlay($user, $playlistItem1, 0); + static::roomStartPlay($user, $playlistItem1); $this->assertTrue(true); - $room->startPlay($user, $playlistItem2, 0); + static::roomStartPlay($user, $playlistItem2); $this->assertTrue(true); $this->expectException(InvariantException::class); - $room->startPlay($user, $playlistItem1, 0); + static::roomStartPlay($user, $playlistItem1); } public function testMaxAttemptsForItemReached() @@ -174,19 +174,19 @@ public function testMaxAttemptsForItemReached() ]); $initialCount = $playlistItem1->scoreTokens()->count(); - $room->startPlay($user, $playlistItem1, 0); + static::roomStartPlay($user, $playlistItem1); $this->assertSame($initialCount + 1, $playlistItem1->scoreTokens()->count()); $initialCount = $playlistItem1->scoreTokens()->count(); try { - $room->startPlay($user, $playlistItem1, 0); + static::roomStartPlay($user, $playlistItem1); } catch (Exception $ex) { $this->assertTrue($ex instanceof InvariantException); } $this->assertSame($initialCount, $playlistItem1->scoreTokens()->count()); $initialCount = $playlistItem2->scoreTokens()->count(); - $room->startPlay($user, $playlistItem2, 0); + static::roomStartPlay($user, $playlistItem2); $this->assertSame($initialCount + 1, $playlistItem2->scoreTokens()->count()); } diff --git a/tests/Models/Multiplayer/UserScoreAggregateTest.php b/tests/Models/Multiplayer/UserScoreAggregateTest.php index 710e5d52399..8ab32f33a75 100644 --- a/tests/Models/Multiplayer/UserScoreAggregateTest.php +++ b/tests/Models/Multiplayer/UserScoreAggregateTest.php @@ -173,7 +173,7 @@ public function testStartingPlayIncreasesAttempts(): void $user = User::factory()->create(); $playlistItem = $this->createPlaylistItem(); - $this->room->startPlay($user, $playlistItem, 0); + static::roomStartPlay($user, $playlistItem); $agg = UserScoreAggregate::new($user, $this->room); $this->assertSame(1, $agg->attempts); diff --git a/tests/Models/TeamTest.php b/tests/Models/TeamTest.php new file mode 100644 index 00000000000..f15d0ef6999 --- /dev/null +++ b/tests/Models/TeamTest.php @@ -0,0 +1,32 @@ +. 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 Tests\Models; + +use App\Models\Team; +use App\Models\TeamMember; +use App\Models\User; +use Tests\TestCase; + +class TeamTest extends TestCase +{ + public function testDelete(): void + { + $team = Team::factory()->create(); + $team->members()->create(['user_id' => User::factory()->create()->getKey()]); + $otherTeam = Team::factory()->create(); + $otherTeam->members()->create(['user_id' => User::factory()->create()->getKey()]); + + $this->expectCountChange(fn () => Team::count(), -1); + $this->expectCountChange(fn () => TeamMember::count(), -2); + $this->expectCountChange(fn () => $otherTeam->members()->count(), 0); + + $team->fresh()->delete(); + + $this->assertNotNull($otherTeam->fresh()); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 03c9ada27fe..130800a839b 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -16,6 +16,7 @@ use App\Models\Multiplayer\PlaylistItem; use App\Models\Multiplayer\ScoreLink; use App\Models\OAuth\Client; +use App\Models\ScoreToken; use App\Models\User; use Artisan; use Carbon\CarbonInterface; @@ -111,7 +112,7 @@ protected static function resetAppDb(DatabaseManager $database): void protected static function roomAddPlay(User $user, PlaylistItem $playlistItem, array $scoreParams): ScoreLink { return $playlistItem->room->completePlay( - $playlistItem->room->startPlay($user, $playlistItem, 0), + static::roomStartPlay($user, $playlistItem), [ 'accuracy' => 0.5, 'beatmap_id' => $playlistItem->beatmap_id, @@ -126,6 +127,14 @@ protected static function roomAddPlay(User $user, PlaylistItem $playlistItem, ar ); } + protected static function roomStartPlay(User $user, PlaylistItem $playlistItem): ScoreToken + { + return $playlistItem->room->startPlay($user, $playlistItem, [ + 'beatmap_hash' => $playlistItem->beatmap->checksum, + 'build_id' => 0, + ]); + } + protected function setUp(): void { $this->beforeApplicationDestroyed(fn () => $this->runExpectedCountsCallbacks()); @@ -135,6 +144,11 @@ protected function setUp(): void // change config setting because we need more than 1 for the tests. config_set('osu.oauth.max_user_clients', 100); + // Disable caching for the BeatmapTagsController and TagsController tests + // because otherwise multiple run of the tests may use stale cache data. + config_set('osu.tags.beatmap_tags_cache_duration', 0); + config_set('osu.tags.tags_cache_duration', 0); + // Force connections to reset even if transactional tests were not used. // Should fix tests going wonky when different queue drivers are used, or anything that // breaks assumptions of object destructor timing. @@ -360,11 +374,20 @@ protected function runFakeQueue() $this->invokeSetProperty(app('queue'), 'jobs', []); } - protected function withInterOpHeader($url) + protected function withInterOpHeader($url, ?callable $callback = null) { - return $this->withHeaders([ - 'X-LIO-Signature' => hash_hmac('sha1', $url, $GLOBALS['cfg']['osu']['legacy']['shared_interop_secret']), + if ($callback === null) { + $timestampedUrl = $url; + } else { + $connector = strpos($url, '?') === false ? '?' : '&'; + $timestampedUrl = $url.$connector.'timestamp='.time(); + } + + $this->withHeaders([ + 'X-LIO-Signature' => hash_hmac('sha1', $timestampedUrl, $GLOBALS['cfg']['osu']['legacy']['shared_interop_secret']), ]); + + return $callback === null ? $this : $callback($timestampedUrl); } protected function withPersistentSession(SessionStore $session): static diff --git a/tests/api_routes.json b/tests/api_routes.json index 68fe32aac33..007f537198f 100644 --- a/tests/api_routes.json +++ b/tests/api_routes.json @@ -173,22 +173,6 @@ ], "scopes": [] }, - { - "uri": "api/v2/beatmaps/{beatmap}/tags", - "methods": [ - "GET", - "HEAD" - ], - "controller": "App\\Http\\Controllers\\BeatmapTagsController@index", - "middlewares": [ - "App\\Http\\Middleware\\ThrottleRequests:1200,1,api:", - "App\\Http\\Middleware\\RequireScopes", - "App\\Http\\Middleware\\RequireScopes:public" - ], - "scopes": [ - "public" - ] - }, { "uri": "api/v2/beatmaps/{beatmap}/tags", "methods": [