the browser-facing portion of osu!

Merge branch 'master' into team-settings

authored by bakaneko and committed by GitHub baf9204c b2e19414

Changed files
+1184 -459
app
config
database
resources
routes
tests
+2
.env.example
··· 166 166 # SEARCH_MINIMUM_LENGTH=2 167 167 168 168 # BEATMAPS_DIFFICULTY_CACHE_SERVER_URL=http://localhost:5001 169 + # BEATMAPS_OWNERS_MAX=10 169 170 # BEATMAPSET_DISCUSSION_KUDOSU_PER_USER=10 170 171 # BEATMAPSET_GUEST_ADVANCED_SEARCH=0 171 172 # BEATMAPSET_MAXIMUM_DISQUALIFIED_RANK_PENALTY_DAYS=7 ··· 289 290 # SCORES_EXPERIMENTAL_RANK_AS_EXTRA=false 290 291 # SCORES_PROCESSING_QUEUE=osu-queue:score-statistics 291 292 # SCORES_SUBMISSION_ENABLED=1 293 + # SCORE_INDEX_MAX_ID_DISTANCE=10_000_000 292 294 293 295 # BANCHO_BOT_USER_ID= 294 296
+31
app/Console/Commands/BeatmapsMigrateOwners.php
··· 1 + <?php 2 + 3 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4 + // See the LICENCE file in the repository root for full licence text. 5 + 6 + namespace App\Console\Commands; 7 + 8 + use App\Models\Beatmap; 9 + use Illuminate\Console\Command; 10 + 11 + class BeatmapsMigrateOwners extends Command 12 + { 13 + protected $signature = 'beatmaps:migrate-owners'; 14 + 15 + protected $description = 'Migrates beatmap owners to new table.'; 16 + 17 + public function handle() 18 + { 19 + $progress = $this->output->createProgressBar(); 20 + 21 + Beatmap::chunkById(1000, function ($beatmaps) use ($progress) { 22 + foreach ($beatmaps as $beatmap) { 23 + $beatmap->beatmapOwners()->firstOrCreate(['user_id' => $beatmap->user_id]); 24 + $progress->advance(); 25 + } 26 + }); 27 + 28 + $progress->finish(); 29 + $this->line(''); 30 + } 31 + }
+4 -22
app/Http/Controllers/BeatmapsController.php
··· 7 7 8 8 use App\Enums\Ruleset; 9 9 use App\Exceptions\InvariantException; 10 - use App\Jobs\Notifications\BeatmapOwnerChange; 11 10 use App\Libraries\BeatmapDifficultyAttributes; 11 + use App\Libraries\Beatmapset\ChangeBeatmapOwners; 12 12 use App\Libraries\Score\BeatmapScores; 13 13 use App\Libraries\Score\UserRank; 14 14 use App\Libraries\Search\ScoreSearch; 15 15 use App\Libraries\Search\ScoreSearchParams; 16 16 use App\Models\Beatmap; 17 - use App\Models\BeatmapsetEvent; 18 17 use App\Models\User; 19 18 use App\Transformers\BeatmapTransformer; 20 19 use App\Transformers\ScoreTransformer; ··· 381 380 public function updateOwner($id) 382 381 { 383 382 $beatmap = Beatmap::findOrFail($id); 384 - $currentUser = auth()->user(); 385 - 386 - priv_check('BeatmapUpdateOwner', $beatmap->beatmapset)->ensureCan(); 383 + $newUserIds = get_arr(request('user_ids'), 'get_int'); 387 384 388 - $newUserId = get_int(request('beatmap.user_id')); 389 - 390 - $beatmap->getConnection()->transaction(function () use ($beatmap, $currentUser, $newUserId) { 391 - $beatmap->setOwner($newUserId); 392 - 393 - BeatmapsetEvent::log(BeatmapsetEvent::BEATMAP_OWNER_CHANGE, $currentUser, $beatmap->beatmapset, [ 394 - 'beatmap_id' => $beatmap->getKey(), 395 - 'beatmap_version' => $beatmap->version, 396 - 'new_user_id' => $beatmap->user_id, 397 - 'new_user_username' => $beatmap->user->username, 398 - ])->saveOrExplode(); 399 - }); 400 - 401 - if ($beatmap->user_id !== $currentUser->getKey()) { 402 - (new BeatmapOwnerChange($beatmap, $currentUser))->dispatch(); 403 - } 385 + (new ChangeBeatmapOwners($beatmap, $newUserIds ?? [], \Auth::user()))->handle(); 404 386 405 387 return $beatmap->beatmapset->defaultDiscussionJson(); 406 388 } ··· 459 441 'score' => json_item( 460 442 $score, 461 443 new ScoreTransformer(), 462 - ['beatmap', ...static::DEFAULT_SCORE_INCLUDES] 444 + ['beatmap.owners', ...static::DEFAULT_SCORE_INCLUDES] 463 445 ), 464 446 ]; 465 447 }
+3
app/Http/Controllers/BeatmapsetsController.php
··· 390 390 "{$beatmapRelation}.baseDifficultyRatings", 391 391 "{$beatmapRelation}.baseMaxCombo", 392 392 "{$beatmapRelation}.failtimes", 393 + "{$beatmapRelation}.beatmapOwners.user", 393 394 'genre', 394 395 'language', 395 396 'user', ··· 402 403 'beatmaps', 403 404 'beatmaps.failtimes', 404 405 'beatmaps.max_combo', 406 + 'beatmaps.owners', 405 407 'converts', 406 408 'converts.failtimes', 409 + 'converts.owners', 407 410 'current_nominations', 408 411 'current_user_attributes', 409 412 'description',
+5 -4
app/Http/Controllers/Multiplayer/RoomsController.php
··· 212 212 private function createJoinedRoomResponse($room) 213 213 { 214 214 return json_item( 215 - $room 216 - ->load('host.country') 217 - ->load('playlist.beatmap.beatmapset') 218 - ->load('playlist.beatmap.baseMaxCombo'), 215 + $room->loadMissing([ 216 + 'host', 217 + 'playlist.beatmap.beatmapset', 218 + 'playlist.beatmap.baseMaxCombo', 219 + ]), 219 220 'Multiplayer\Room', 220 221 [ 221 222 'current_user_score.playlist_item_attempts',
+90 -1
app/Http/Controllers/ScoresController.php
··· 6 6 namespace App\Http\Controllers; 7 7 8 8 use App\Enums\Ruleset; 9 + use App\Models\Beatmap; 9 10 use App\Models\Score\Best\Model as ScoreBest; 10 11 use App\Models\ScoreReplayStats; 11 12 use App\Models\Solo\Score as SoloScore; ··· 22 23 parent::__construct(); 23 24 24 25 $this->middleware('auth', ['except' => [ 26 + 'download', 27 + 'index', 25 28 'show', 26 - 'download', 27 29 ]]); 28 30 29 31 $this->middleware('require-scopes:public'); ··· 117 119 }, $this->makeReplayFilename($score), $responseHeaders); 118 120 } 119 121 122 + /** 123 + * Get Scores 124 + * 125 + * Returns submitted scores. Up to 1000 scores will be returned in order of oldest to latest. 126 + * Most recent scores will be returned if `cursor_string` parameter is not specified. 127 + * 128 + * Obtaining new scores that arrived after the last request can be done by passing `cursor_string` 129 + * parameter from the previous request. 130 + * 131 + * --- 132 + * 133 + * ### Response Format 134 + * 135 + * Field | Type | Notes 136 + * ------------- | ----------------------------- | ----- 137 + * scores | [Score](#score)[] | | 138 + * cursor_string | [CursorString](#cursorstring) | Same value as the request will be returned if there's no new scores 139 + * 140 + * @group Scores 141 + * 142 + * @queryParam ruleset The [Ruleset](#ruleset) to get scores for. 143 + * @queryParam cursor_string Next set of scores 144 + */ 145 + public function index() 146 + { 147 + $params = \Request::all(); 148 + $cursor = cursor_from_params($params); 149 + $isOldScores = false; 150 + if (isset($cursor['id']) && ($idFromCursor = get_int($cursor['id'])) !== null) { 151 + $currentMaxId = SoloScore::max('id'); 152 + $idDistance = $currentMaxId - $idFromCursor; 153 + if ($idDistance > $GLOBALS['cfg']['osu']['scores']['index_max_id_distance']) { 154 + abort(422, 'cursor is too old'); 155 + } 156 + $isOldScores = $idDistance > 10_000; 157 + } 158 + 159 + $rulesetId = null; 160 + if (isset($params['ruleset'])) { 161 + $rulesetId = Beatmap::modeInt(get_string($params['ruleset'])); 162 + 163 + if ($rulesetId === null) { 164 + abort(422, 'invalid ruleset parameter'); 165 + } 166 + } 167 + 168 + return \Cache::remember( 169 + 'score_index:'.($rulesetId ?? '').':'.json_encode($cursor), 170 + $isOldScores ? 600 : 5, 171 + function () use ($cursor, $isOldScores, $rulesetId) { 172 + $cursorHelper = SoloScore::makeDbCursorHelper('old'); 173 + $scoresQuery = SoloScore::forListing()->limit(1_000); 174 + if ($rulesetId !== null) { 175 + $scoresQuery->where('ruleset_id', $rulesetId); 176 + } 177 + if ($cursor === null || $cursorHelper->prepare($cursor) === null) { 178 + // fetch the latest scores when no or invalid cursor is specified 179 + // and reverse result to match the other query (latest score last) 180 + $scores = array_reverse($scoresQuery->orderByDesc('id')->get()->all()); 181 + } else { 182 + $scores = $scoresQuery->cursorSort($cursorHelper, $cursor)->get()->all(); 183 + } 184 + 185 + if ($isOldScores) { 186 + $filteredScores = $scores; 187 + } else { 188 + $filteredScores = []; 189 + foreach ($scores as $score) { 190 + // only return up to but not including the earliest unprocessed scores 191 + if ($score->isProcessed()) { 192 + $filteredScores[] = $score; 193 + } else { 194 + break; 195 + } 196 + } 197 + } 198 + 199 + return [ 200 + 'scores' => json_collection($filteredScores, new ScoreTransformer(ScoreTransformer::TYPE_SOLO)), 201 + // return previous cursor if no result, assuming there's no new scores yet 202 + ...cursor_for_response($cursorHelper->next($filteredScores) ?? $cursor), 203 + ]; 204 + }, 205 + ); 206 + } 207 + 120 208 public function show($rulesetOrSoloId, $legacyId = null) 121 209 { 122 210 if ($legacyId === null) { ··· 136 224 $scoreJson = json_item($score, new ScoreTransformer(), array_merge([ 137 225 'beatmap.max_combo', 138 226 'beatmap.user', 227 + 'beatmap.owners', 139 228 'beatmapset', 140 229 'rank_global', 141 230 ], $userIncludes));
+80
app/Libraries/Beatmapset/ChangeBeatmapOwners.php
··· 1 + <?php 2 + 3 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4 + // See the LICENCE file in the repository root for full licence text. 5 + 6 + declare(strict_types=1); 7 + 8 + namespace App\Libraries\Beatmapset; 9 + 10 + use App\Exceptions\InvariantException; 11 + use App\Jobs\Notifications\BeatmapOwnerChange; 12 + use App\Models\Beatmap; 13 + use App\Models\BeatmapOwner; 14 + use App\Models\BeatmapsetEvent; 15 + use App\Models\User; 16 + use Ds\Set; 17 + 18 + class ChangeBeatmapOwners 19 + { 20 + private Set $userIds; 21 + 22 + public function __construct(private Beatmap $beatmap, array $newUserIds, private User $source) 23 + { 24 + priv_check_user($source, 'BeatmapUpdateOwner', $beatmap->beatmapset)->ensureCan(); 25 + 26 + $this->userIds = new Set($newUserIds); 27 + 28 + if ($this->userIds->count() > $GLOBALS['cfg']['osu']['beatmaps']['owners_max']) { 29 + throw new InvariantException(osu_trans('beatmaps.change_owner.too_many')); 30 + } 31 + 32 + if ($this->userIds->isEmpty()) { 33 + throw new InvariantException('user_ids must be specified'); 34 + } 35 + } 36 + 37 + public function handle(): void 38 + { 39 + $currentOwners = new Set($this->beatmap->getOwners()->pluck('user_id')); 40 + if ($currentOwners->xor($this->userIds)->isEmpty()) { 41 + return; 42 + } 43 + 44 + $newUserIds = $this->userIds->diff($currentOwners); 45 + 46 + if (User::whereIn('user_id', $newUserIds->toArray())->default()->count() !== $newUserIds->count()) { 47 + throw new InvariantException('invalid user_id'); 48 + } 49 + 50 + $this->beatmap->getConnection()->transaction(function () { 51 + $params = array_map( 52 + fn ($userId) => ['beatmap_id' => $this->beatmap->getKey(), 'user_id' => $userId], 53 + $this->userIds->toArray() 54 + ); 55 + 56 + $this->beatmap->fill(['user_id' => $this->userIds->first()])->saveOrExplode(); 57 + $this->beatmap->beatmapOwners()->delete(); 58 + BeatmapOwner::insert($params); 59 + 60 + $this->beatmap->refresh(); 61 + 62 + $newUsers = $this->beatmap->getOwners()->select('id', 'username')->all(); 63 + $beatmapset = $this->beatmap->beatmapset; 64 + $firstMapper = $newUsers[0]; 65 + 66 + BeatmapsetEvent::log(BeatmapsetEvent::BEATMAP_OWNER_CHANGE, $this->source, $beatmapset, [ 67 + 'beatmap_id' => $this->beatmap->getKey(), 68 + 'beatmap_version' => $this->beatmap->version, 69 + // TODO: mainly for compatibility during dev when switching branches, can be removed after deployed. 70 + 'new_user_id' => $firstMapper['id'], 71 + 'new_user_username' => $firstMapper['username'], 72 + 'new_users' => $newUsers, 73 + ])->saveOrExplode(); 74 + 75 + $beatmapset->update(['eligible_main_rulesets' => null]); 76 + }); 77 + 78 + (new BeatmapOwnerChange($this->beatmap, $this->source))->dispatch(); 79 + } 80 + }
+42 -22
app/Models/Beatmap.php
··· 10 10 use App\Libraries\Transactions\AfterCommit; 11 11 use DB; 12 12 use Illuminate\Database\Eloquent\Builder; 13 + use Illuminate\Database\Eloquent\Collection; 13 14 use Illuminate\Database\Eloquent\SoftDeletes; 14 15 15 16 /** 16 17 * @property int $approved 17 - * @property \Illuminate\Database\Eloquent\Collection $beatmapDiscussions BeatmapDiscussion 18 + * @property-read Collection<BeatmapDiscussion> $beatmapDiscussions 19 + * @property-read Collection<BeatmapOwner> $beatmapOwners 18 20 * @property int $beatmap_id 19 21 * @property Beatmapset $beatmapset 20 22 * @property int|null $beatmapset_id ··· 29 31 * @property float $diff_drain 30 32 * @property float $diff_overall 31 33 * @property float $diff_size 32 - * @property \Illuminate\Database\Eloquent\Collection $difficulty BeatmapDifficulty 33 - * @property \Illuminate\Database\Eloquent\Collection $difficultyAttribs BeatmapDifficultyAttrib 34 + * @property-read Collection<BeatmapDifficulty> $difficulty 35 + * @property-read Collection<BeatmapDifficultyAttrib> $difficultyAttribs 34 36 * @property float $difficultyrating 35 - * @property \Illuminate\Database\Eloquent\Collection $failtimes BeatmapFailtimes 37 + * @property-read Collection<BeatmapFailtimes> $failtimes 36 38 * @property string|null $filename 37 39 * @property int $hit_length 38 40 * @property \Carbon\Carbon $last_update 39 41 * @property int $max_combo 40 42 * @property mixed $mode 43 + * @property-read Collection<User> $owners 41 44 * @property int $passcount 42 45 * @property int $playcount 43 46 * @property int $playmode 44 47 * @property int $score_version 45 48 * @property int $total_length 49 + * @property User $user 46 50 * @property int $user_id 47 51 * @property string $version 48 52 * @property string|null $youtube_preview ··· 105 109 public function baseMaxCombo() 106 110 { 107 111 return $this->difficultyAttribs()->noMods()->maxCombo(); 112 + } 113 + 114 + public function beatmapOwners() 115 + { 116 + return $this->hasMany(BeatmapOwner::class); 108 117 } 109 118 110 119 public function beatmapset() ··· 267 276 'baseDifficultyRatings', 268 277 'baseMaxCombo', 269 278 'beatmapDiscussions', 279 + 'beatmapOwners', 270 280 'beatmapset', 271 281 'difficulty', 272 282 'difficultyAttribs', ··· 279 289 }; 280 290 } 281 291 292 + /** 293 + * @return Collection<User> 294 + */ 295 + public function getOwners(): Collection 296 + { 297 + $owners = $this->beatmapOwners->loadMissing('user')->map( 298 + fn ($beatmapOwner) => $beatmapOwner->user ?? new DeletedUser(['user_id' => $beatmapOwner->user_id]) 299 + ); 300 + 301 + // TODO: remove when everything writes to beatmap_owners. 302 + if (!$owners->contains(fn ($beatmapOwner) => $beatmapOwner->user_id === $this->user_id)) { 303 + $owners->prepend($this->user ?? new DeletedUser(['user_id' => $this->user_id])); 304 + } 305 + 306 + return $owners; 307 + } 308 + 309 + public function isOwner(User $user): bool 310 + { 311 + if ($this->user_id === $user->getKey()) { 312 + return true; 313 + } 314 + 315 + return $this->relationLoaded('beatmapOwners') 316 + ? $this->beatmapOwners->contains('user_id', $user->getKey()) 317 + : $this->beatmapOwners()->where('user_id', $user->getKey())->exists(); 318 + } 319 + 282 320 public function maxCombo() 283 321 { 284 322 if (!$this->convert) { ··· 303 341 } 304 342 305 343 return $maxCombo?->value; 306 - } 307 - 308 - public function setOwner($newUserId) 309 - { 310 - if ($newUserId === null) { 311 - throw new InvariantException('user_id must be specified'); 312 - } 313 - 314 - if (User::find($newUserId) === null) { 315 - throw new InvariantException('invalid user_id'); 316 - } 317 - 318 - if ($newUserId === $this->user_id) { 319 - throw new InvariantException('the specified user_id is already the owner'); 320 - } 321 - 322 - $this->fill(['user_id' => $newUserId])->saveOrExplode(); 323 - $this->beatmapset->update(['eligible_main_rulesets' => null]); 324 344 } 325 345 326 346 public function status()
+2 -4
app/Models/BeatmapDiscussion.php
··· 632 632 633 633 public function managedBy(User $user): bool 634 634 { 635 - $id = $user->getKey(); 636 - 637 - return $this->beatmapset->user_id === $id 638 - || ($this->beatmap !== null && $this->beatmap->user_id === $id); 635 + return $this->beatmapset->user_id === $user->getKey() 636 + || ($this->beatmap !== null && $this->beatmap->isOwner($user)); 639 637 } 640 638 641 639 public function userRecentVotesCount($user, $increment = false)
+31
app/Models/BeatmapOwner.php
··· 1 + <?php 2 + 3 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4 + // See the LICENCE file in the repository root for full licence text. 5 + 6 + namespace App\Models; 7 + 8 + /** 9 + * @property Beatmap $beatmap 10 + * @property int $beatmap_id 11 + * @property User $user 12 + * @property int $user_id 13 + */ 14 + class BeatmapOwner extends Model 15 + { 16 + public $incrementing = false; 17 + public $timestamps = false; 18 + 19 + protected $primaryKey = ':composite'; 20 + protected $primaryKeys = ['beatmap_id', 'user_id']; 21 + 22 + public function beatmap() 23 + { 24 + return $this->belongsTo(Beatmap::class, 'beatmap_id'); 25 + } 26 + 27 + public function user() 28 + { 29 + return $this->belongsTo(User::class, 'user_id'); 30 + } 31 + }
+27 -10
app/Models/Beatmapset.php
··· 31 31 use App\Libraries\Transactions\AfterCommit; 32 32 use App\Traits\Memoizes; 33 33 use App\Traits\Validatable; 34 + use App\Transformers\BeatmapsetTransformer; 34 35 use Cache; 35 36 use Carbon\Carbon; 36 37 use DB; ··· 1278 1279 1279 1280 public function defaultDiscussionJson() 1280 1281 { 1282 + $this->loadMissing([ 1283 + 'allBeatmaps', 1284 + 'allBeatmaps.beatmapOwners.user', 1285 + 'allBeatmaps.user', // TODO: for compatibility only, should migrate user_id to BeatmapOwner. 1286 + 'beatmapDiscussions.beatmapDiscussionPosts', 1287 + 'beatmapDiscussions.beatmapDiscussionVotes', 1288 + ]); 1289 + 1290 + foreach ($this->allBeatmaps as $beatmap) { 1291 + $beatmap->setRelation('beatmapset', $this); 1292 + } 1293 + 1294 + $beatmapsByKey = $this->allBeatmaps->keyBy('beatmap_id'); 1295 + 1296 + foreach ($this->beatmapDiscussions as $discussion) { 1297 + // set relations for priv checks. 1298 + $discussion->setRelation('beatmapset', $this); 1299 + 1300 + if ($discussion->beatmap_id !== null) { 1301 + $discussion->setRelation('beatmap', $beatmapsByKey[$discussion->beatmap_id]); 1302 + } 1303 + } 1304 + 1281 1305 return json_item( 1282 - static::with([ 1283 - 'allBeatmaps.beatmapset', 1284 - 'beatmapDiscussions.beatmapDiscussionPosts', 1285 - 'beatmapDiscussions.beatmapDiscussionVotes', 1286 - 'beatmapDiscussions.beatmapset', 1287 - 'beatmapDiscussions.beatmap', 1288 - 'beatmapDiscussions.beatmapDiscussionVotes', 1289 - ])->find($this->getKey()), 1290 - 'Beatmapset', 1306 + $this, 1307 + new BeatmapsetTransformer(), 1291 1308 [ 1292 - 'beatmaps:with_trashed', 1309 + 'beatmaps:with_trashed.owners', 1293 1310 'current_user_attributes', 1294 1311 'discussions', 1295 1312 'discussions.current_user_attributes',
+4
app/Models/Multiplayer/Room.php
··· 603 603 throw new InvariantException('room must have at least one playlist item'); 604 604 } 605 605 606 + if (mb_strlen($this->name) > 100) { 607 + throw new InvariantException(osu_trans('multiplayer.room.errors.name_too_long')); 608 + } 609 + 606 610 PlaylistItem::assertBeatmapsExist($playlistItems); 607 611 608 612 $this->getConnection()->transaction(function () use ($host, $playlistItems) {
+31 -5
app/Models/Solo/Score.php
··· 52 52 */ 53 53 class Score extends Model implements Traits\ReportableInterface 54 54 { 55 - use Traits\Reportable, Traits\WithWeightedPp; 55 + use Traits\Reportable, Traits\WithDbCursorHelper, Traits\WithWeightedPp; 56 + 57 + const DEFAULT_SORT = 'old'; 58 + 59 + const SORTS = [ 60 + 'old' => [['column' => 'id', 'order' => 'ASC']], 61 + ]; 56 62 57 63 public $timestamps = false; 58 64 ··· 163 169 return $query->whereHas('beatmap.beatmapset'); 164 170 } 165 171 172 + public function scopeForListing(Builder $query): Builder 173 + { 174 + return $query->where('ranked', true) 175 + ->leftJoinRelation('processHistory') 176 + ->select([$query->qualifyColumn('*'), 'processed_version']); 177 + } 178 + 166 179 public function scopeForRuleset(Builder $query, string $ruleset): Builder 167 180 { 168 181 return $query->where('ruleset_id', Beatmap::MODES[$ruleset]); ··· 268 281 throw new InvariantException('Invalid accuracy.'); 269 282 } 270 283 271 - // unsigned int (as per the column) 272 - if ($this->total_score === null || $this->total_score < 0 || $this->total_score > 4294967295) { 284 + // int (as per es schema) 285 + if ($this->total_score === null || $this->total_score < 0 || $this->total_score > 2147483647) { 273 286 throw new InvariantException('Invalid total_score.'); 274 287 } 275 288 276 - // unsigned int (no data type enforcement as this goes into the json, but just to match total_score) 289 + // int (no data type enforcement as this goes into the json, but just to match total_score) 277 290 if ( 278 291 $this->data->totalScoreWithoutMods !== null 279 - && ($this->data->totalScoreWithoutMods < 0 || $this->data->totalScoreWithoutMods > 4294967295) 292 + && ($this->data->totalScoreWithoutMods < 0 || $this->data->totalScoreWithoutMods > 2147483647) 280 293 ) { 281 294 throw new InvariantException('Invalid total_score_without_mods.'); 282 295 } ··· 362 375 } 363 376 364 377 return null; 378 + } 379 + 380 + public function isProcessed(): bool 381 + { 382 + if ($this->legacy_score_id !== null) { 383 + return true; 384 + } 385 + 386 + if (array_key_exists('processed_version', $this->attributes)) { 387 + return $this->attributes['processed_version'] !== null; 388 + } 389 + 390 + return $this->processHistory !== null; 365 391 } 366 392 367 393 public function legacyScore(): ?LegacyScore\Best\Model
+5
app/Models/Traits/Es/BeatmapsetSearch.php
··· 22 22 return static::withoutGlobalScopes() 23 23 ->active() 24 24 ->with('beatmaps') // note that the with query will run with the default scopes. 25 + ->with('beatmaps.beatmapOwners') 25 26 ->with('beatmaps.baseDifficultyRatings'); 26 27 } 27 28 ··· 80 81 $beatmapValues[$field] = $beatmap->$field; 81 82 } 82 83 84 + // TODO: remove adding $beatmap->user_id once everything else also populated beatmap_owners by default. 85 + // Duplicate user_id in the array should be fine for now since the field isn't scored for querying. 86 + $beatmapValues['user_id'] = $beatmap->beatmapOwners->pluck('user_id')->add($beatmap->user_id); 83 87 $values[] = $beatmapValues; 84 88 85 89 if ($beatmap->playmode === Beatmap::MODES['osu']) { ··· 96 100 $convertValues[$field] = $convert->$field; 97 101 } 98 102 103 + $convertValues['user_id'] = $beatmapValues['user_id']; // just add a copy for converts too. 99 104 $values[] = $convertValues; 100 105 } 101 106 }
+8 -3
app/Models/User.php
··· 783 783 'cover_preset_id', 784 784 'custom_cover_filename', 785 785 'group_id', 786 + 'laravel_through_key', // added by hasManyThrough relation in Beatmap 786 787 'osu_featurevotes', 787 788 'osu_kudosavailable', 788 789 'osu_kudosdenied', ··· 2236 2237 { 2237 2238 return Beatmapset 2238 2239 ::where('user_id', '<>', $this->getKey()) 2239 - ->whereHas('beatmaps', function (Builder $query) { 2240 - $query->scoreable()->where('user_id', $this->getKey()); 2241 - }) 2240 + ->whereHas( 2241 + 'beatmaps', 2242 + fn (Builder $query) => $query->scoreable()->whereHas( 2243 + 'beatmapOwners', 2244 + fn (Builder $ownerQuery) => $ownerQuery->where('user_id', $this->getKey()) 2245 + ) 2246 + ) 2242 2247 ->with('beatmaps'); 2243 2248 } 2244 2249
+3 -1
app/Singletons/OsuAuthorize.php
··· 618 618 return $prefix.'owner'; 619 619 } 620 620 621 + $beatmapset->loadMissing('beatmaps.beatmapOwners'); 622 + 621 623 foreach ($beatmapset->beatmaps as $beatmap) { 622 - if ($userId === $beatmap->user_id) { 624 + if ($beatmap->isOwner($user)) { 623 625 return $prefix.'owner'; 624 626 } 625 627 }
+10
app/Transformers/BeatmapCompactTransformer.php
··· 8 8 use App\Models\Beatmap; 9 9 use App\Models\BeatmapFailtimes; 10 10 use App\Models\DeletedUser; 11 + use App\Models\User; 11 12 12 13 class BeatmapCompactTransformer extends TransformerAbstract 13 14 { ··· 16 17 'checksum', 17 18 'failtimes', 18 19 'max_combo', 20 + 'owners', 19 21 'user', 20 22 ]; 21 23 ··· 71 73 public function includeMaxCombo(Beatmap $beatmap) 72 74 { 73 75 return $this->primitive($beatmap->maxCombo()); 76 + } 77 + 78 + public function includeOwners(Beatmap $beatmap) 79 + { 80 + return $this->collection($beatmap->getOwners(), fn (User $user) => [ 81 + 'id' => $user->getKey(), 82 + 'username' => $user->username, 83 + ]); 74 84 } 75 85 76 86 public function includeUser(Beatmap $beatmap)
+6 -2
app/Transformers/BeatmapsetCompactTransformer.php
··· 253 253 $userIds = new Set([$beatmapset->user_id]); 254 254 switch ($this->relatedUsersType) { 255 255 case 'discussions': 256 - $userIds->add(...$beatmapset->allBeatmaps->pluck('user_id')); 256 + $beatmaps = $beatmapset->allBeatmaps; 257 + $userIds->add(...$beatmaps->pluck('user_id')); 258 + $userIds->add(...$beatmaps->flatMap->beatmapOwners->pluck('user_id')); 257 259 258 260 foreach ($beatmapset->beatmapDiscussions as $discussion) { 259 261 if (!priv_check('BeatmapDiscussionShow', $discussion)->can()) { ··· 285 287 } 286 288 break; 287 289 case 'show': 288 - $userIds->add(...$this->beatmaps($beatmapset)->pluck('user_id')); 290 + $beatmaps = $this->beatmaps($beatmapset); 291 + $userIds->add(...$beatmaps->pluck('user_id')); 292 + $userIds->add(...$beatmaps->flatMap->beatmapOwners->pluck('user_id')); 289 293 $userIds->add(...$beatmapset->beatmapsetNominationsCurrent->pluck('user_id')); 290 294 break; 291 295 }
+1 -1
app/Transformers/ScoreTransformer.php
··· 105 105 if ($score instanceof SoloScore) { 106 106 $extraAttributes['classic_total_score'] = $score->getClassicTotalScore(); 107 107 $extraAttributes['preserve'] = $score->preserve; 108 - $extraAttributes['processed'] = $score->legacy_score_id !== null || $score->processHistory !== null; 108 + $extraAttributes['processed'] = $score->isProcessed(); 109 109 $extraAttributes['ranked'] = $score->ranked; 110 110 } 111 111
+2 -2
app/helpers.php
··· 1264 1264 1265 1265 $tooltipDate = i18n_date($user->user_regdate); 1266 1266 1267 - $formattedDate = i18n_date($user->user_regdate, null, 'year_month'); 1267 + $formattedDate = i18n_date($user->user_regdate, pattern: 'year_month'); 1268 1268 1269 1269 if ($user->user_regdate < Carbon\Carbon::createFromDate(2008, 1, 1)) { 1270 1270 return '<div title="'.$tooltipDate.'">'.osu_trans('users.show.first_members').'</div>'; ··· 1275 1275 ]); 1276 1276 } 1277 1277 1278 - function i18n_date($datetime, $format = IntlDateFormatter::LONG, $pattern = null) 1278 + function i18n_date($datetime, int $format = IntlDateFormatter::LONG, $pattern = null) 1279 1279 { 1280 1280 $formatter = IntlDateFormatter::create( 1281 1281 App::getLocale(),
+2
config/osu.php
··· 44 44 'beatmaps' => [ 45 45 'max' => 50, 46 46 'max_scores' => 100, 47 + 'owners_max' => get_int(env('BEATMAPS_OWNERS_MAX')) ?? 10, 47 48 48 49 'difficulty_cache' => [ 49 50 'server_url' => presence(env('BEATMAPS_DIFFICULTY_CACHE_SERVER_URL')) ?? 'http://localhost:5001', ··· 188 189 'es_cache_duration' => 60 * (get_float(env('SCORES_ES_CACHE_DURATION')) ?? 0.5), // in minutes, converted to seconds 189 190 'experimental_rank_as_default' => get_bool(env('SCORES_EXPERIMENTAL_RANK_AS_DEFAULT')) ?? false, 190 191 'experimental_rank_as_extra' => get_bool(env('SCORES_EXPERIMENTAL_RANK_AS_EXTRA')) ?? false, 192 + 'index_max_id_distance' => get_int(env('SCORE_INDEX_MAX_ID_DISTANCE')) ?? 10_000_000, 191 193 'processing_queue' => presence(env('SCORES_PROCESSING_QUEUE')) ?? 'osu-queue:score-statistics', 192 194 'submission_enabled' => get_bool(env('SCORES_SUBMISSION_ENABLED')) ?? true, 193 195 ],
+11
database/factories/BeatmapFactory.php
··· 8 8 namespace Database\Factories; 9 9 10 10 use App\Models\Beatmap; 11 + use App\Models\BeatmapOwner; 11 12 use App\Models\Beatmapset; 13 + use App\Models\User; 12 14 use Carbon\Carbon; 13 15 14 16 class BeatmapFactory extends Factory ··· 62 64 return $this->state([ 63 65 'beatmapset_id' => Beatmapset::factory()->state(['active' => false]), 64 66 ]); 67 + } 68 + 69 + public function owner(User $user): static 70 + { 71 + return $this 72 + ->state(['user_id' => $user]) 73 + ->has(BeatmapOwner::factory()->state(fn (array $attr, Beatmap $beatmap) => [ 74 + 'user_id' => $beatmap->user_id, 75 + ])); 65 76 } 66 77 67 78 public function qualified(): static
+25
database/factories/BeatmapOwnerFactory.php
··· 1 + <?php 2 + 3 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4 + // See the LICENCE file in the repository root for full licence text. 5 + 6 + declare(strict_types=1); 7 + 8 + namespace Database\Factories; 9 + 10 + use App\Models\Beatmap; 11 + use App\Models\BeatmapOwner; 12 + use App\Models\User; 13 + 14 + class BeatmapOwnerFactory extends Factory 15 + { 16 + protected $model = BeatmapOwner::class; 17 + 18 + public function definition(): array 19 + { 20 + return [ 21 + 'beatmap_id' => Beatmap::factory(), 22 + 'user_id' => User::factory(), 23 + ]; 24 + } 25 + }
+6
database/factories/UserFactory.php
··· 81 81 ]; 82 82 } 83 83 84 + // convenience for dataProviders so null checks don't have to be called when creating with named state. 85 + public function default() 86 + { 87 + return $this; 88 + } 89 + 84 90 public function restricted() 85 91 { 86 92 return $this
+28
database/migrations/2024_07_02_000001_create_beatmap_owners.php
··· 1 + <?php 2 + 3 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4 + // See the LICENCE file in the repository root for full licence text. 5 + 6 + declare(strict_types=1); 7 + 8 + use Illuminate\Database\Migrations\Migration; 9 + use Illuminate\Database\Schema\Blueprint; 10 + use Illuminate\Support\Facades\Schema; 11 + 12 + return new class extends Migration 13 + { 14 + public function up(): void 15 + { 16 + Schema::create('beatmap_owners', function (Blueprint $table) { 17 + $table->unsignedMediumInteger('beatmap_id'); 18 + $table->unsignedInteger('user_id'); 19 + $table->primary(['beatmap_id', 'user_id']); 20 + $table->index(['user_id', 'beatmap_id'], 'user_id'); 21 + }); 22 + } 23 + 24 + public function down(): void 25 + { 26 + Schema::dropIfExists('beatmap_owners'); 27 + } 28 + };
+37
database/migrations/2024_12_03_102822_add_synthetic_pk_to_beatmapset_version_files.php
··· 1 + <?php 2 + 3 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4 + // See the LICENCE file in the repository root for full licence text. 5 + 6 + use Illuminate\Database\Migrations\Migration; 7 + use Illuminate\Database\Schema\Blueprint; 8 + use Illuminate\Support\Facades\Schema; 9 + 10 + return new class extends Migration 11 + { 12 + /** 13 + * Run the migrations. 14 + */ 15 + public function up(): void 16 + { 17 + Schema::table('beatmapset_version_files', function (Blueprint $table) { 18 + $table->dropPrimary(); 19 + }); 20 + Schema::table('beatmapset_version_files', function (Blueprint $table) { 21 + $table->bigIncrements('id')->first(); 22 + }); 23 + } 24 + 25 + /** 26 + * Reverse the migrations. 27 + */ 28 + public function down(): void 29 + { 30 + Schema::table('beatmapset_version_files', function (Blueprint $table) { 31 + $table->dropColumn('id'); 32 + }); 33 + Schema::table('beatmapset_version_files', function (Blueprint $table) { 34 + $table->primary(['file_id', 'version_id']); 35 + }); 36 + } 37 + };
+2
resources/css/bem-index.less
··· 49 49 @import "bem/beatmap-icon"; 50 50 @import "bem/beatmap-list"; 51 51 @import "bem/beatmap-list-item"; 52 + @import "bem/beatmap-owner"; 52 53 @import "bem/beatmap-owner-editor"; 54 + @import "bem/beatmap-owner-editor-owners"; 53 55 @import "bem/beatmap-pack"; 54 56 @import "bem/beatmap-pack-description"; 55 57 @import "bem/beatmap-pack-items";
+16
resources/css/bem/beatmap-owner-editor-owners.less
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + .beatmap-owner-editor-owners { 5 + display: flex; 6 + flex-direction: row; 7 + flex-wrap: wrap; 8 + gap: 2px; 9 + padding: var(--input-padding); 10 + border-radius: @border-radius-large; 11 + 12 + &--editing { 13 + background: var(--input-bg); 14 + box-shadow: inset 0 0 0 2px var(--input-border-colour); 15 + } 16 + }
+7 -30
resources/css/bem/beatmap-owner-editor.less
··· 5 5 display: contents; 6 6 7 7 &__avatar { 8 - display: flex; 9 - width: 100%; 10 - height: 100%; 8 + width: 20px; 9 + height: $width; 11 10 } 12 11 13 12 &__col { 14 13 display: flex; 15 14 align-items: center; 16 - 17 - &--avatar { 18 - position: relative; 19 - width: 20px; 20 - font-size: $width; 21 - } 15 + gap: 5px; 22 16 23 17 &--buttons { 24 18 display: flex; ··· 37 31 &__button { 38 32 .reset-input(); 39 33 .link-default(); 40 - margin-left: 5px; 41 34 42 35 &[disabled] { 43 36 opacity: 0.5; 44 37 } 45 38 } 46 39 47 - &__input { 48 - .default-border-radius(); 49 - .reset-input(); 50 - background-color: hsl(var(--hsl-b6)); 51 - color: inherit; 52 - padding: 1px 3px; 53 - width: auto; 54 - border: 2px solid transparent; 55 - 56 - &--error { 57 - border-color: hsl(var(--hsl-red-2)); 58 - } 59 - 60 - &--static { 61 - // defined here so it doesn't trigger tooltip in addition to usercard 62 - .ellipsis-overflow(); 63 - background-color: transparent; 64 - padding-left: 0; 65 - padding-right: 0; 66 - } 40 + &__user { 41 + display: flex; 42 + align-items: center; 43 + gap: 5px; 67 44 } 68 45 }
+44
resources/css/bem/beatmap-owner.less
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + .beatmap-owner { 5 + display: flex; 6 + gap: 2px; 7 + 8 + // same colour as the modal. 9 + background-color: hsla(var(--hsl-b5), 0.8); 10 + border-radius: @border-radius-large; 11 + 12 + &__avatar { 13 + width: 20px; 14 + height: $width; 15 + } 16 + 17 + &__remove { 18 + .reset-input(); 19 + 20 + opacity: 0.7; 21 + margin: 2px; 22 + padding: 0 0.3em; 23 + visibility: hidden; 24 + 25 + border-radius: 50%; 26 + display: flex; 27 + place-items: center; 28 + 29 + .link-hover({ 30 + opacity: 1; 31 + background-color: hsl(var(--hsl-b1)); 32 + }); 33 + 34 + &--editing { 35 + visibility: visible; 36 + } 37 + } 38 + 39 + &__user { 40 + display: flex; 41 + align-items: center; 42 + gap: 5px; 43 + } 44 + }
+1 -1
resources/css/bem/beatmaps-owner-editor.less
··· 25 25 grid-gap: 5px; 26 26 font-size: @font-size--normal; 27 27 // mode-icon version avatar username buttons 28 - grid-template-columns: auto minmax(20px, auto) auto minmax(20px, 150px) 70px; 28 + grid-template-columns: auto minmax(20px, auto) minmax(20px, 300px) 70px; 29 29 align-items: center; 30 30 overflow-y: auto; 31 31 max-height: calc(var(--vh) * 60);
+5
resources/css/bem/input-container.less
··· 51 51 } 52 52 } 53 53 54 + &--beatmap-owner-editor { 55 + --input-bg: hsl(var(--hsl-b6)); 56 + --input-padding: 5px; 57 + } 58 + 54 59 &--error { 55 60 --input-border-colour: var(--input-border-error-colour); 56 61 --input-border-focus-colour: var(--input-border-error-focus-colour);
+2
resources/css/bem/username-input.less
··· 18 18 color: hsl(var(--hsl-c1)); 19 19 20 20 flex: 1; 21 + order: 1; 21 22 // can't do much about wrapping the input itself... 22 23 min-width: 40px; 23 24 } 24 25 25 26 &__spinner { 26 27 display: inline-flex; 28 + order: 2; 27 29 align-items: center; 28 30 min-width: 1em; 29 31 }
+3 -4
resources/js/beatmap-discussions/beatmap-list.tsx
··· 6 6 import UserJson from 'interfaces/user-json'; 7 7 import { action, autorun, computed, makeObservable, observable } from 'mobx'; 8 8 import { disposeOnUnmount, observer } from 'mobx-react'; 9 - import { deletedUserJson } from 'models/user'; 10 9 import * as React from 'react'; 11 10 import { makeUrl } from 'utils/beatmapset-discussion-helper'; 12 11 import { blackoutToggle } from 'utils/blackout'; ··· 58 57 href={makeUrl({ beatmap: this.props.discussionsState.currentBeatmap })} 59 58 onClick={this.toggleSelector} 60 59 > 61 - <BeatmapListItem beatmap={this.props.discussionsState.currentBeatmap} mapper={null} modifiers='large' /> 60 + <BeatmapListItem beatmap={this.props.discussionsState.currentBeatmap} modifiers='large' showOwners={false} /> 62 61 <div className='beatmap-list__item-selector-button'> 63 62 <span className='fas fa-chevron-down' /> 64 63 </div> ··· 88 87 beatmap={beatmap} 89 88 beatmapUrl={makeUrl({ beatmap, filter: this.props.discussionsState.currentFilter })} 90 89 beatmapset={this.props.discussionsState.beatmapset} 91 - mapper={this.props.users.get(beatmap.user_id) ?? deletedUserJson} 92 - showNonGuestMapper={false} 90 + showNonGuestOwner={false} 91 + showOwners 93 92 /> 94 93 {count != null && 95 94 <div className='beatmap-list__item-count'>
+84 -126
resources/js/beatmap-discussions/beatmap-owner-editor.tsx
··· 1 1 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 + import InputContainer from 'components/input-container'; 4 5 import { Spinner } from 'components/spinner'; 5 - import UserAvatar from 'components/user-avatar'; 6 - import UserLink from 'components/user-link'; 6 + import UsernameInput from 'components/username-input'; 7 7 import BeatmapJson from 'interfaces/beatmap-json'; 8 + import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; 8 9 import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; 9 10 import UserJson from 'interfaces/user-json'; 11 + import WithBeatmapOwners from 'interfaces/with-beatmap-owners'; 10 12 import { route } from 'laroute'; 11 - import { action, computed, makeObservable, observable, runInAction } from 'mobx'; 13 + import { xor } from 'lodash'; 14 + import { action, makeObservable, observable, runInAction } from 'mobx'; 12 15 import { observer } from 'mobx-react'; 13 16 import { normaliseUsername } from 'models/user'; 14 17 import * as React from 'react'; 15 - import { onErrorWithCallback } from 'utils/ajax'; 18 + import { onError } from 'utils/ajax'; 19 + import { hasGuestOwners } from 'utils/beatmap-helper'; 16 20 import { classWithModifiers } from 'utils/css'; 17 - import { transparentGif } from 'utils/html'; 18 21 import { trans } from 'utils/lang'; 19 - import { apiLookupUsers } from 'utils/user'; 22 + import BeatmapOwner from './beatmap-owner'; 20 23 import DiscussionsState from './discussions-state'; 21 24 22 - interface XhrCollection { 23 - updateOwner: JQuery.jqXHR<BeatmapsetWithDiscussionsJson>; 24 - userLookup: ReturnType<typeof apiLookupUsers>; 25 - } 26 - 27 25 interface Props { 28 - beatmap: BeatmapJson; 29 - beatmapsetUser: UserJson; 30 - discussionsState: DiscussionsState; 31 - user: UserJson; 32 - userByName: Map<string, UserJson>; 26 + beatmap: WithBeatmapOwners<BeatmapJson>; 27 + beatmapset: BeatmapsetExtendedJson; 28 + discussionsState: DiscussionsState; // only for updating the state with the response. 33 29 } 34 30 35 31 @observer 36 32 export default class BeatmapOwnerEditor extends React.Component<Props> { 37 - @observable private checkingUser: string | null = null; 38 - @observable private editing = false; 33 + @observable editing = false; 39 34 private readonly inputRef = React.createRef<HTMLInputElement>(); 40 - @observable private inputUsername: string; 35 + @observable private inputUsername = ''; 41 36 private shouldFocusInputOnNextRender = false; 37 + @observable private showError = false; 38 + private updateOwnerXhr?: JQuery.jqXHR<BeatmapsetWithDiscussionsJson>; 42 39 @observable private updatingOwner = false; 43 - private userLookupTimeout?: number; 44 - private readonly xhr: Partial<XhrCollection> = {}; 40 + @observable private validUsers = new Map<number, UserJson>(); 45 41 46 - @computed 47 - private get inputUser() { 48 - return this.props.userByName.get(normaliseUsername(this.inputUsername)); 42 + private get canSave() { 43 + return this.validUsers.size > 0 && normaliseUsername(this.inputUsername).length === 0; 44 + } 45 + 46 + private get owners() { 47 + return this.props.discussionsState.beatmapOwners(this.props.beatmap); 49 48 } 50 49 51 50 constructor(props: Props) { 52 51 super(props); 53 52 54 - this.inputUsername = props.user.username; 55 - 56 53 makeObservable(this); 57 54 } 58 55 ··· 64 61 } 65 62 66 63 componentWillUnmount() { 67 - window.clearTimeout(this.userLookupTimeout); 68 - Object.values(this.xhr).forEach((xhr) => xhr?.abort()); 64 + this.updateOwnerXhr?.abort(); 69 65 } 70 66 71 67 render() { 72 - const blockClass = classWithModifiers('beatmap-owner-editor', { 73 - editing: this.editing, 74 - }); 75 - 76 68 return ( 77 - <div className={blockClass}> 69 + <div className='beatmap-owner-editor'> 78 70 <div className='beatmap-owner-editor__col'> 79 71 <span className='beatmap-owner-editor__mode'> 80 72 <span className={`fal fa-fw fa-extra-mode-${this.props.beatmap.mode}`} /> ··· 87 79 </span> 88 80 </div> 89 81 90 - <div className='beatmap-owner-editor__col beatmap-owner-editor__col--avatar'> 91 - {this.renderAvatar()} 92 - </div> 93 - 94 82 <div className='beatmap-owner-editor__col'> 95 - {this.renderUsername()} 83 + {this.renderUsernames()} 96 84 </div> 97 85 98 86 <div className='beatmap-owner-editor__col beatmap-owner-editor__col--buttons'> ··· 105 93 @action 106 94 private readonly handleCancelEditingClick = () => { 107 95 this.editing = false; 96 + this.showError = false; 108 97 }; 109 98 110 99 @action ··· 112 101 if (!confirm(trans('beatmap_discussions.owner_editor.reset_confirm'))) return; 113 102 114 103 this.editing = false; 115 - this.updateOwner(this.props.beatmapsetUser.id); 104 + this.updateOwners([this.props.beatmapset.user_id]); 116 105 }; 117 106 118 107 private readonly handleSaveClick = () => { 119 - if (this.inputUser == null) return; 108 + if (!this.canSave) return; 120 109 121 - this.updateOwner(this.inputUser.id); 110 + this.updateOwners([...this.validUsers.keys()]); 122 111 }; 123 112 124 113 @action 125 114 private readonly handleStartEditingClick = () => { 126 115 this.editing = true; 127 116 this.shouldFocusInputOnNextRender = true; 128 - this.inputUsername = this.props.user.username; 129 117 }; 130 118 131 119 @action 132 - private readonly handleUsernameInput = (e: React.ChangeEvent<HTMLInputElement>) => { 133 - this.inputUsername = e.currentTarget.value; 134 - const inputUsernameNormalised = normaliseUsername(this.inputUsername); 135 - 136 - if (inputUsernameNormalised === this.checkingUser) return; 137 - 138 - window.clearTimeout(this.userLookupTimeout); 139 - this.xhr.userLookup?.abort(); 140 - this.checkingUser = this.inputUser == null && inputUsernameNormalised !== '' ? inputUsernameNormalised : null; 141 - 142 - if (this.checkingUser != null) { 143 - this.userLookupTimeout = window.setTimeout(this.userLookup, 500); 120 + private readonly handleUsernameInputValueChanged = (value: string) => { 121 + // field should not be flagged as error on the first lookup. 122 + // reset showError if input is cleared. 123 + if (value === '') { 124 + this.showError = false; 144 125 } 145 - }; 146 126 147 - private readonly handleUsernameInputKeyup = (e: React.KeyboardEvent<HTMLInputElement>) => { 148 - if (e.key === 'Enter') this.handleSaveClick(); 127 + this.inputUsername = value; 149 128 }; 150 129 151 - private renderAvatar() { 152 - if (this.checkingUser != null) { 153 - return <Spinner />; 154 - } 155 - 156 - const user = this.editing 157 - ? (this.inputUser ?? { avatar_url: transparentGif }) 158 - : this.props.user; 159 - 160 - const avatar = <UserAvatar modifiers='full-circle' user={user} />; 161 - 162 - return this.editing 163 - ? avatar 164 - : <UserLink className='beatmap-owner-editor__avatar' user={this.props.user}>{avatar}</UserLink>; 165 - } 130 + @action 131 + private readonly handleValidUsersChanged = (value: Map<number, UserJson>) => { 132 + this.validUsers = value; 133 + this.showError = this.inputUsername !== ''; 134 + }; 166 135 167 136 private renderButtons() { 168 137 if (this.updatingOwner) { ··· 172 141 const reset = ( 173 142 <button 174 143 className='beatmap-owner-editor__button' 175 - disabled={this.props.beatmap.user_id === this.props.beatmapsetUser.id} 144 + disabled={!hasGuestOwners(this.props.beatmap, this.props.beatmapset)} 176 145 onClick={this.handleResetClick} 177 146 > 178 147 <span className='fas fa-fw fa-undo' /> ··· 193 162 194 163 return ( 195 164 <> 196 - <button className='beatmap-owner-editor__button' disabled={this.inputUser == null} onClick={this.handleSaveClick}> 165 + <button className='beatmap-owner-editor__button' disabled={!this.canSave} onClick={this.handleSaveClick}> 197 166 <span className='fas fa-fw fa-check' /> 198 167 </button> 199 168 ··· 206 175 ); 207 176 } 208 177 209 - private renderUsername() { 210 - if (!this.editing) { 211 - return ( 212 - <UserLink 213 - className='beatmap-owner-editor__input beatmap-owner-editor__input--static' 214 - user={this.props.user} 215 - /> 216 - ); 217 - } 178 + private readonly renderOwner = (owner: UserJson, onRemoveClick: (user: UserJson) => void) => ( 179 + <BeatmapOwner key={owner.id} editing={this.editing} onRemoveUser={onRemoveClick} user={owner} /> 180 + ); 218 181 182 + private renderUsernames() { 219 183 return ( 220 - <input 221 - ref={this.inputRef} 222 - className={classWithModifiers('beatmap-owner-editor__input', { 223 - error: this.inputUser == null, 224 - })} 225 - disabled={this.updatingOwner} 226 - onChange={this.handleUsernameInput} 227 - onKeyUp={this.handleUsernameInputKeyup} 228 - value={this.inputUsername} 229 - /> 184 + <InputContainer 185 + hasError={!this.canSave} 186 + modifiers='beatmap-owner-editor' 187 + showError={this.showError} 188 + > 189 + <div className={classWithModifiers('beatmap-owner-editor-owners', { editing: this.editing })}> 190 + {this.editing ? ( 191 + <UsernameInput 192 + initialUsers={this.owners} 193 + // initialValue not set for owner editor as value is reset when cancelled. 194 + modifiers='beatmap-owner-editor' 195 + onEnterPressed={this.handleSaveClick} 196 + onValidUsersChanged={this.handleValidUsersChanged} 197 + onValueChanged={this.handleUsernameInputValueChanged} 198 + renderUser={this.renderOwner} 199 + /> 200 + ) : ( 201 + this.owners.map((owner) => <BeatmapOwner key={owner.id} editing={this.editing} user={owner} />) 202 + )} 203 + </div> 204 + </InputContainer> 230 205 ); 231 206 } 232 207 233 208 @action 234 - private readonly updateOwner = (userId: number) => { 235 - this.xhr.updateOwner?.abort(); 209 + private updateOwners(userIds: number[]) { 210 + this.updateOwnerXhr?.abort(); 236 211 237 - if (this.props.beatmap.user_id === userId) { 212 + if (xor([...this.validUsers.keys()], this.owners.map((owner) => owner.id)).length === 0) { 238 213 this.editing = false; 239 - 240 214 return; 241 215 } 242 216 243 217 this.updatingOwner = true; 244 218 245 - this.xhr.updateOwner = $.ajax(route('beatmaps.update-owner', { beatmap: this.props.beatmap.id }), { 246 - data: { beatmap: { user_id: userId } }, 247 - method: 'PUT', 219 + this.updateOwnerXhr = $.ajax(route('beatmaps.update-owner', { beatmap: this.props.beatmap.id }), { 220 + data: { user_ids: userIds }, 221 + method: 'POST', 248 222 }); 249 - this.xhr.updateOwner.done((beatmapset) => runInAction(() => { 250 - this.props.discussionsState.update({ beatmapset }); 251 - this.editing = false; 252 - })).fail(onErrorWithCallback(() => { 253 - this.updateOwner(userId); 254 - })).always(action(() => { 255 - this.updatingOwner = false; 256 - })); 257 - }; 258 - 259 - private readonly userLookup = () => { 260 - const currentCheckingUser = this.checkingUser; 261 - 262 - if (currentCheckingUser == null) return; 263 - 264 - this.xhr.userLookup = apiLookupUsers([currentCheckingUser]); 265 - this.xhr.userLookup.done((response) => runInAction(() => { 266 - if (response.users.length > 0) { 267 - this.props.userByName.set(currentCheckingUser, response.users[0]); 268 - } 269 - })).fail( 270 - onErrorWithCallback(this.userLookup), 271 - ).always(action(() => { 272 - this.checkingUser = null; 273 - })); 274 - }; 223 + this.updateOwnerXhr 224 + .done((beatmapset) => runInAction(() => { 225 + this.props.discussionsState.update({ beatmapset }); 226 + this.editing = false; 227 + })) 228 + .fail(onError) 229 + .always(action(() => { 230 + this.updatingOwner = false; 231 + })); 232 + } 275 233 }
+46
resources/js/beatmap-discussions/beatmap-owner.tsx
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import UserAvatar from 'components/user-avatar'; 5 + import UserLink from 'components/user-link'; 6 + import UserJson from 'interfaces/user-json'; 7 + import * as React from 'react'; 8 + import { classWithModifiers } from 'utils/css'; 9 + 10 + interface Props { 11 + editing: boolean; 12 + onRemoveUser?: (user: UserJson) => void; 13 + user: UserJson; 14 + } 15 + 16 + function createRemoveOwnerHandler(user: UserJson, onRemoveClick?: NonNullable<Props['onRemoveUser']>) { 17 + return (event: React.MouseEvent<HTMLButtonElement>) => { 18 + event.preventDefault(); 19 + onRemoveClick?.(user); 20 + }; 21 + } 22 + 23 + export default class BeatmapOwner extends React.PureComponent<Props> { 24 + render() { 25 + return ( 26 + <div className='beatmap-owner'> 27 + <UserLink className='beatmap-owner__user' tooltipPosition='top right' user={this.props.user}> 28 + <div className='beatmap-owner__avatar'> 29 + <UserAvatar modifiers='full-circle' user={this.props.user} /> 30 + </div> 31 + <div className='u-ellipsis-overflow'> 32 + {this.props.user.username} 33 + </div> 34 + </UserLink> 35 + 36 + <button 37 + className={classWithModifiers('beatmap-owner__remove', { editing: this.props.editing })} 38 + onClick={createRemoveOwnerHandler(this.props.user, this.props.onRemoveUser)} 39 + > 40 + <span className='fas fa-times' /> 41 + </button> 42 + 43 + </div> 44 + ); 45 + } 46 + }
+33 -18
resources/js/beatmap-discussions/beatmaps-owner-editor.tsx
··· 1 1 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 - import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; 4 + import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; 5 5 import UserJson from 'interfaces/user-json'; 6 - import { makeObservable, observable } from 'mobx'; 6 + import { action, makeObservable } from 'mobx'; 7 7 import { observer } from 'mobx-react'; 8 - import { deletedUserJson, normaliseUsername } from 'models/user'; 9 8 import * as React from 'react'; 10 9 import { group as groupBeatmaps } from 'utils/beatmap-helper'; 11 10 import { trans } from 'utils/lang'; ··· 13 12 import DiscussionsState from './discussions-state'; 14 13 15 14 interface Props { 16 - beatmapset: BeatmapsetExtendedJson; 15 + beatmapset: BeatmapsetWithDiscussionsJson; 17 16 discussionsState: DiscussionsState; 18 17 onClose: () => void; 19 18 users: Map<number | null | undefined, UserJson>; ··· 21 20 22 21 @observer 23 22 export default class BeatmapsOwnerEditor extends React.Component<Props> { 24 - @observable userByName = new Map<string, UserJson>(); 23 + private readonly editorRefs: Partial<Record<number, React.RefObject<BeatmapOwnerEditor>>> = {}; 25 24 26 25 constructor(props: Props) { 27 26 super(props); 28 27 29 - // this will be outdated on new props but it's fine 30 - // as there's separate process handling unknown users 31 - for (const user of this.props.users.values()) { 32 - if (user != null) { 33 - this.userByName.set(normaliseUsername(user.username), user); 28 + for (const beatmap of this.props.beatmapset.beatmaps) { 29 + if (beatmap.deleted_at == null) { 30 + this.editorRefs[beatmap.id] = React.createRef<BeatmapOwnerEditor>(); 34 31 } 35 32 } 36 33 37 34 makeObservable(this); 38 35 } 39 36 37 + componentDidMount() { 38 + document.addEventListener('turbo:before-visit', this.handleBeforeVisit); 39 + } 40 + 41 + componentWillUnmount() { 42 + document.removeEventListener('turbo:before-visit', this.handleBeforeVisit); 43 + } 44 + 40 45 render() { 41 - const beatmapsetUser = this.getUser(this.props.beatmapset.user_id); 42 46 const groupedBeatmaps = [...groupBeatmaps((this.props.beatmapset.beatmaps ?? []).filter( 43 47 (beatmap) => beatmap.deleted_at == null, 44 48 ))]; ··· 51 55 <strong> 52 56 {trans('beatmap_discussions.owner_editor.version')} 53 57 </strong> 54 - <div /> 55 58 <strong> 56 59 {trans('beatmap_discussions.owner_editor.user')} 57 60 </strong> ··· 61 64 beatmaps.map((beatmap) => ( 62 65 <BeatmapOwnerEditor 63 66 key={beatmap.id} 67 + ref={this.editorRefs[beatmap.id]} 64 68 beatmap={beatmap} 65 - beatmapsetUser={beatmapsetUser} 69 + beatmapset={this.props.beatmapset} 66 70 discussionsState={this.props.discussionsState} 67 - user={this.getUser(beatmap.user_id)} 68 - userByName={this.userByName} 69 71 /> 70 72 )) 71 73 ))} ··· 74 76 <div className='beatmaps-owner-editor__row beatmaps-owner-editor__row--footer'> 75 77 <button 76 78 className='btn-osu-big btn-osu-big--rounded-thin' 77 - onClick={this.props.onClose} 79 + onClick={this.handleCloseClick} 78 80 type='button' 79 81 > 80 82 {trans('common.buttons.close')} ··· 84 86 ); 85 87 } 86 88 87 - private getUser(userId: number) { 88 - return this.props.users.get(userId) ?? deletedUserJson; 89 + @action 90 + private readonly handleBeforeVisit = (event: Event) => { 91 + if (this.shouldCancelNavigation()) { 92 + event.preventDefault(); 93 + } 94 + }; 95 + 96 + private readonly handleCloseClick = () => { 97 + if (this.shouldCancelNavigation()) return; 98 + this.props.onClose(); 99 + }; 100 + 101 + private shouldCancelNavigation() { 102 + return Object.values(this.editorRefs).some((ref) => ref?.current?.editing) 103 + && !confirm(trans('common.confirmation_unsaved')); 89 104 } 90 105 }
+14
resources/js/beatmap-discussions/discussions-state.ts
··· 1 1 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 + import BeatmapJson from 'interfaces/beatmap-json'; 4 5 import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; 5 6 import { BeatmapsetStatus } from 'interfaces/beatmapset-json'; 6 7 import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; 7 8 import Ruleset from 'interfaces/ruleset'; 8 9 import UserJson from 'interfaces/user-json'; 10 + import WithBeatmapOwners from 'interfaces/with-beatmap-owners'; 9 11 import { intersectionWith, maxBy, sum } from 'lodash'; 10 12 import { action, computed, makeObservable, observable } from 'mobx'; 13 + import { deletedUserJson } from 'models/user'; 11 14 import core from 'osu-core-singleton'; 12 15 import BeatmapsetDiscussionsShowStore from 'stores/beatmapset-discussions-show-store'; 13 16 import { findDefault, group, sortWithMode } from 'utils/beatmap-helper'; ··· 19 22 20 23 const defaultFilterPraise = new Set<BeatmapsetStatus>(['approved', 'ranked']); 21 24 const jsonId = 'json-discussions-state'; 25 + 26 + function deletedUser(userId: number) { 27 + const user: UserJson = structuredClone(deletedUserJson) as UserJson; // structuredClone copies the Readonly type but is actually mutable. 28 + user.id = userId; 29 + 30 + return user; 31 + } 22 32 23 33 export interface UpdateOptions { 24 34 beatmap_discussion_post_ids: number[]; ··· 415 425 } 416 426 417 427 makeObservable(this); 428 + } 429 + 430 + beatmapOwners(beatmap: WithBeatmapOwners<BeatmapJson>) { 431 + return beatmap.owners.map((user) => this.store.users.get(user.id) ?? deletedUser(user.id)); 418 432 } 419 433 420 434 @action
+1 -1
resources/js/beatmap-discussions/editor-beatmap-selector.tsx
··· 50 50 menuOptions.push({ 51 51 icon: <BeatmapIcon beatmap={beatmap} />, 52 52 id: beatmap.id.toString(), 53 - label: <BeatmapListItem beatmap={beatmap} mapper={null} modifiers={listItemModifier} />, 53 + label: <BeatmapListItem beatmap={beatmap} modifiers={listItemModifier} showOwners={false} />, 54 54 renderIcon: false, 55 55 }); 56 56 });
+4 -4
resources/js/beatmap-discussions/header.tsx
··· 10 10 import HeaderV4 from 'components/header-v4'; 11 11 import PlaymodeTabs from 'components/playmode-tabs'; 12 12 import StringWithComponent from 'components/string-with-component'; 13 - import UserLink from 'components/user-link'; 14 13 import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; 15 14 import Ruleset, { rulesets } from 'interfaces/ruleset'; 16 15 import { route } from 'laroute'; 17 16 import { action, computed, makeObservable } from 'mobx'; 18 17 import { observer } from 'mobx-react'; 19 - import { deletedUserJson } from 'models/user'; 20 18 import * as React from 'react'; 19 + import { hasGuestOwners } from 'utils/beatmap-helper'; 21 20 import { getArtist, getTitle } from 'utils/beatmapset-helper'; 22 21 import { trans, transChoice } from 'utils/lang'; 23 22 import BeatmapList from './beatmap-list'; ··· 27 26 import { Subscribe } from './subscribe'; 28 27 import TypeFilters from './type-filters'; 29 28 import { UserFilter } from './user-filter'; 29 + import UserLinkList from './user-link-list'; 30 30 31 31 interface Props { 32 32 discussionsState: DiscussionsState; ··· 181 181 /> 182 182 <div className={`${bn}__beatmap-stats`}> 183 183 <div className={`${bn}__guest`}> 184 - {this.currentBeatmap.user_id !== this.beatmapset.user_id && ( 184 + {hasGuestOwners(this.currentBeatmap, this.beatmapset) && ( 185 185 <span> 186 186 <StringWithComponent 187 187 mappings={{ 188 - user: <UserLink user={this.users.get(this.currentBeatmap.user_id) ?? deletedUserJson} />, 188 + user: <UserLinkList users={this.currentBeatmap.owners ?? []} />, 189 189 }} 190 190 pattern={trans('beatmaps.discussions.guest')} 191 191 />
+2 -1
resources/js/beatmap-discussions/new-discussion.tsx
··· 16 16 import core from 'osu-core-singleton'; 17 17 import * as React from 'react'; 18 18 import { onError } from 'utils/ajax'; 19 + import { isOwner } from 'utils/beatmap-helper'; 19 20 import { canModeratePosts, formatTimestamp, makeUrl, NearbyDiscussion, nearbyDiscussions, parseTimestamp, validMessageLength } from 'utils/beatmapset-discussion-helper'; 20 21 import { downloadLimited } from 'utils/beatmapset-helper'; 21 22 import { classWithModifiers } from 'utils/css'; ··· 286 287 287 288 const canPostNote = core.currentUser != null 288 289 && (core.currentUser.id === this.beatmapset.user_id 289 - || (core.currentUser.id === this.currentBeatmap.user_id && ['general', 'timeline'].includes(this.currentMode)) 290 + || (isOwner(core.currentUser.id, this.currentBeatmap) && ['general', 'timeline'].includes(this.currentMode)) 290 291 || core.currentUser.is_bng 291 292 || canModeratePosts()); 292 293
+2 -1
resources/js/beatmap-discussions/nominator.tsx
··· 14 14 import core from 'osu-core-singleton'; 15 15 import * as React from 'react'; 16 16 import { onError } from 'utils/ajax'; 17 + import { isOwner } from 'utils/beatmap-helper'; 17 18 import { isUserFullNominator } from 'utils/beatmapset-discussion-helper'; 18 19 import { classWithModifiers } from 'utils/css'; 19 20 import { trans } from 'utils/lang'; ··· 103 104 const userId = core.currentUserOrFail.id; 104 105 105 106 return userId === this.beatmapset.user_id 106 - || this.beatmapset.beatmaps.some((beatmap) => beatmap.deleted_at == null && userId === beatmap.user_id); 107 + || this.beatmapset.beatmaps.some((beatmap) => beatmap.deleted_at == null && isOwner(userId, beatmap)); 107 108 } 108 109 109 110 private get userNominatableModes() {
+1 -1
resources/js/beatmapset-panel/beatmaps-popup.tsx
··· 41 41 className='beatmaps-popup-item' 42 42 href={route('beatmaps.show', { beatmap: beatmap.id })} 43 43 > 44 - <BeatmapListItem beatmap={beatmap} mapper={null} /> 44 + <BeatmapListItem beatmap={beatmap} showOwners={false} /> 45 45 </a> 46 46 )); 47 47
+3 -3
resources/js/beatmapsets-show/controller.ts
··· 5 5 import UserJson from 'interfaces/user-json'; 6 6 import { keyBy } from 'lodash'; 7 7 import { action, computed, makeObservable, observable, runInAction } from 'mobx'; 8 - import { deletedUser } from 'models/user'; 8 + import { deletedUserJson } from 'models/user'; 9 9 import core from 'osu-core-singleton'; 10 10 import { find, findDefault, group } from 'utils/beatmap-helper'; 11 11 import { parse } from 'utils/beatmapset-page-hash'; ··· 107 107 $(document).off('turbo:before-cache', this.saveState); 108 108 } 109 109 110 - mapper(beatmap: BeatmapJsonForBeatmapsetShow) { 111 - return this.usersById[beatmap.user_id] ?? deletedUser; 110 + owners(beatmap: BeatmapJsonForBeatmapsetShow) { 111 + return beatmap.owners.map((mapper) => this.usersById[mapper.id] ?? deletedUserJson); 112 112 } 113 113 114 114 @action
+4 -3
resources/js/beatmapsets-show/header.tsx
··· 1 1 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 + import UserLinkList from 'beatmap-discussions/user-link-list'; 4 5 import BeatmapsetBadge from 'components/beatmapset-badge'; 5 6 import BeatmapsetCover from 'components/beatmapset-cover'; 6 7 import BeatmapsetMapping from 'components/beatmapset-mapping'; 7 8 import BigButton from 'components/big-button'; 8 9 import StringWithComponent from 'components/string-with-component'; 9 - import UserLink from 'components/user-link'; 10 10 import { createTooltip } from 'components/user-list-popup'; 11 11 import { route } from 'laroute'; 12 12 import { action, computed, makeObservable } from 'mobx'; 13 13 import { observer } from 'mobx-react'; 14 14 import core from 'osu-core-singleton'; 15 15 import * as React from 'react'; 16 + import { hasGuestOwners } from 'utils/beatmap-helper'; 16 17 import { downloadLimited, getArtist, getTitle, toggleFavourite } from 'utils/beatmapset-helper'; 17 18 import { classWithModifiers } from 'utils/css'; 18 19 import { formatNumber } from 'utils/html'; ··· 290 291 <span className='beatmapset-header__diff-name'> 291 292 {beatmap.version} 292 293 293 - {beatmap.user_id !== this.controller.beatmapset.user_id && ( 294 + {hasGuestOwners(beatmap, this.controller.beatmapset) && ( 294 295 <span className='beatmapset-header__diff-extra'> 295 296 <StringWithComponent 296 297 mappings={{ 297 - mapper: <UserLink user={this.controller.mapper(beatmap)} />, 298 + mapper: <UserLinkList users={this.controller.owners(beatmap)} />, 298 299 }} 299 300 pattern={trans('beatmapsets.show.details.mapped_by')} 300 301 />
+19 -18
resources/js/components/beatmap-list-item.tsx
··· 1 1 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 + import UserLinkList from 'beatmap-discussions/user-link-list'; 4 5 import DifficultyBadge from 'components/difficulty-badge'; 5 - import UserLink from 'components/user-link'; 6 6 import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; 7 7 import BeatmapJson from 'interfaces/beatmap-json'; 8 8 import BeatmapsetJson from 'interfaces/beatmapset-json'; 9 - import UserJson from 'interfaces/user-json'; 9 + import { hasOwners } from 'interfaces/with-beatmap-owners'; 10 10 import * as React from 'react'; 11 + import { hasGuestOwners } from 'utils/beatmap-helper'; 11 12 import { classWithModifiers, Modifiers } from 'utils/css'; 12 13 import { trans } from 'utils/lang'; 13 14 import StringWithComponent from './string-with-component'; ··· 21 22 22 23 type MapperProps = { 23 24 beatmapset: BeatmapsetJson; 24 - mapper: Pick<UserJson, 'id' | 'username'>; 25 - showNonGuestMapper: boolean; 25 + showNonGuestOwner: boolean; 26 + showOwners: true; 26 27 } | { 27 - mapper: null; 28 + showOwners: false; 28 29 }; 29 30 30 31 type Props = BaseProps & MapperProps; ··· 51 52 : version} 52 53 {' '} 53 54 <span className='beatmap-list-item__mapper'> 54 - {this.renderMapper()} 55 + {this.renderOwners()} 55 56 </span> 56 57 </div> 57 58 </div> ··· 59 60 ); 60 61 } 61 62 62 - private renderMapper() { 63 - if (this.props.mapper == null) { 63 + private renderOwners() { 64 + if (!this.props.showOwners) return null; 65 + 66 + const owners = this.props.beatmap.owners; 67 + if (owners == null || owners.length === 0) { 64 68 return null; 65 69 } 66 70 67 - const isGuestMap = this.props.beatmapset.user_id !== this.props.beatmap.user_id; 71 + const userId = this.props.beatmapset.user_id; 72 + const visibleOwners = this.props.showNonGuestOwner 73 + ? owners 74 + : owners.filter((mapper) => mapper.id !== userId); 68 75 69 - if (!isGuestMap && !this.props.showNonGuestMapper) { 76 + if (visibleOwners.length === 0) { 70 77 return null; 71 78 } 72 79 73 - const translationKey = isGuestMap 80 + const translationKey = hasOwners(this.props.beatmap) && hasGuestOwners(this.props.beatmap, this.props.beatmapset) 74 81 ? 'mapped_by_guest' 75 82 : 'mapped_by'; 76 - 77 - const mapper = isGuestMap 78 - ? this.props.mapper 79 - : { id: this.props.beatmapset.user_id, username: this.props.beatmapset.creator }; 80 83 81 84 return ( 82 85 <StringWithComponent 83 - mappings={{ 84 - mapper: <UserLink user={mapper} />, 85 - }} 86 + mappings={{ mapper: <UserLinkList users={visibleOwners} /> }} 86 87 pattern={trans(`beatmapsets.show.details.${translationKey}`)} 87 88 /> 88 89 );
+4 -1
resources/js/components/beatmapset-event.tsx
··· 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 4 import PlainTextPreview from 'beatmap-discussions/plain-text-preview'; 5 + import UserLinkList from 'beatmap-discussions/user-link-list'; 5 6 import BeatmapsetCover from 'components/beatmapset-cover'; 6 7 import TimeWithTooltip from 'components/time-with-tooltip'; 7 8 import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; ··· 198 199 } 199 200 case 'beatmap_owner_change': { 200 201 const data = this.props.event.comment; 201 - params.new_user = <a href={route('users.show', { user: data.new_user_id })}>{data.new_user_username}</a>; 202 + const users = data.new_users ?? [{ id: data.new_user_id, username: data.new_user_username }]; 203 + 202 204 params.beatmap = <a href={route('beatmaps.show', { beatmap: data.beatmap_id })}>{data.beatmap_version}</a>; 205 + params.new_user = <UserLinkList users={users} />; 203 206 break; 204 207 } 205 208 case 'nomination_reset_received': {
+3 -3
resources/js/components/username-input.tsx
··· 1 1 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 - import UserJson, { UserJsonMinimum } from 'interfaces/user-json'; 4 + import UserJson from 'interfaces/user-json'; 5 5 import { debounce } from 'lodash'; 6 6 import { action, makeObservable, observable, runInAction } from 'mobx'; 7 7 import { observer } from 'mobx-react'; ··· 69 69 render() { 70 70 return ( 71 71 <div className={classWithModifiers('username-input', this.props.modifiers)}> 72 - {this.renderValidUsers()} 73 72 <input 74 73 className='username-input__input' 75 74 id={this.props.id} ··· 81 80 onPaste={this.handleUsersInputPaste} 82 81 value={this.input} 83 82 /> 83 + {this.renderValidUsers()} 84 84 <BusySpinner busy={this.busy} /> 85 85 </div> 86 86 ); ··· 123 123 }; 124 124 125 125 @action 126 - private readonly handleRemoveUser = (user: UserJsonMinimum) => { 126 + private readonly handleRemoveUser = (user: UserJson) => { 127 127 this.validUsers.delete(user.id); 128 128 this.props.onValidUsersChanged?.(this.validUsers); 129 129 };
+3
resources/js/interfaces/beatmap-json.ts
··· 1 1 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 + import BeatmapOwnerJson from './beatmap-owner-json'; 4 5 import BeatmapsetJson from './beatmapset-json'; 5 6 import Ruleset from './ruleset'; 6 7 import UserJson from './user-json'; ··· 15 16 checksum: string | null; 16 17 failtimes: BeatmapFailTimesArray; 17 18 max_combo: number; 19 + owners: BeatmapOwnerJson[]; 18 20 user: UserJson; 19 21 } 20 22 ··· 30 32 } 31 33 32 34 type BeatmapJson = BeatmapJsonDefaultAttributes & Partial<BeatmapJsonAvailableIncludes>; 35 + 33 36 export default BeatmapJson;
+8
resources/js/interfaces/beatmap-owner-json.ts
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import UserJson from './user-json'; 5 + 6 + type BeatmapOwnerJson = Pick<UserJson, 'id' | 'username'>; 7 + 8 + export default BeatmapOwnerJson;
+4
resources/js/interfaces/beatmapset-event-json.ts
··· 222 222 beatmap_version: string; 223 223 new_user_id: number; 224 224 new_user_username: string; 225 + new_users?: { 226 + id: number; 227 + username: string; 228 + }[]; 225 229 }; 226 230 type: 'beatmap_owner_change'; 227 231 }
+3 -2
resources/js/interfaces/beatmapset-extended-json.ts
··· 4 4 import BeatmapExtendedJson from './beatmap-extended-json'; 5 5 import BeatmapsetJson, { Availability } from './beatmapset-json'; 6 6 import Ruleset from './ruleset'; 7 + import WithBeatmapOwners from './with-beatmap-owners'; 7 8 8 9 interface NominationsSummary { 9 10 current: number; ··· 42 43 export default BeatmapsetExtendedJson; 43 44 44 45 interface BeatmapsetJsonForShowOverrideIncludes { 45 - beatmaps: (BeatmapExtendedJson & Required<Pick<BeatmapExtendedJson, 'failtimes' | 'max_combo'>>)[]; 46 - converts: (BeatmapExtendedJson & Required<Pick<BeatmapExtendedJson, 'failtimes'>>)[]; 46 + beatmaps: (WithBeatmapOwners<BeatmapExtendedJson> & Required<Pick<BeatmapExtendedJson, 'failtimes' | 'max_combo'>>)[]; 47 + converts: (WithBeatmapOwners<BeatmapExtendedJson> & Required<Pick<BeatmapExtendedJson, 'failtimes'>>)[]; 47 48 } 48 49 49 50 type BeatmapsetJsonForShowIncludes = Required<Pick<BeatmapsetExtendedJson,
+4 -1
resources/js/interfaces/beatmapset-with-discussions-json.ts
··· 1 1 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 + import BeatmapExtendedJson from './beatmap-extended-json'; 4 5 import { BeatmapsetDiscussionJsonForShow } from './beatmapset-discussion-json'; 5 6 import BeatmapsetExtendedJson from './beatmapset-extended-json'; 7 + import WithBeatmapOwners from './with-beatmap-owners'; 6 8 7 - type DiscussionsRequiredAttributes = 'beatmaps' | 'current_user_attributes' | 'eligible_main_rulesets' | 'events' | 'nominations' | 'related_users'; 9 + type DiscussionsRequiredAttributes = 'current_user_attributes' | 'eligible_main_rulesets' | 'events' | 'nominations' | 'related_users'; 8 10 type BeatmapsetWithDiscussionsJson = 9 11 Omit<BeatmapsetExtendedJson, keyof OverrideIncludes> 10 12 & OverrideIncludes 11 13 & Required<Pick<BeatmapsetExtendedJson, DiscussionsRequiredAttributes>>; 12 14 13 15 interface OverrideIncludes { 16 + beatmaps: WithBeatmapOwners<BeatmapExtendedJson>[]; 14 17 discussions: BeatmapsetDiscussionJsonForShow[]; 15 18 } 16 19
+2 -2
resources/js/interfaces/solo-score-json.ts
··· 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 4 import BeatmapExtendedJson from './beatmap-extended-json'; 5 - import BeatmapJson from './beatmap-json'; 6 5 import Rank from './rank'; 7 6 import Ruleset from './ruleset'; 8 7 import { ScoreJsonAvailableIncludes, ScoreJsonDefaultIncludes } from './score-json'; 9 8 import ScoreModJson from './score-mod-json'; 9 + import WithBeatmapOwners from './with-beatmap-owners'; 10 10 11 11 export type SoloScoreStatisticsAttribute = 12 12 | 'good' ··· 80 80 export type SoloScoreJsonForShow = SoloScoreJson 81 81 & Required<Pick<SoloScoreJson, 'beatmapset' | 'best_id' | 'rank_global' | 'user'>> 82 82 & { 83 - beatmap: BeatmapExtendedJson & Required<Pick<BeatmapJson, 'user'>>; 83 + beatmap: WithBeatmapOwners<BeatmapExtendedJson>; 84 84 }; 85 85 86 86 export type SoloScoreJsonForUser = SoloScoreJson & Required<Pick<SoloScoreJson, 'beatmap' | 'beatmapset'>>;
-1
resources/js/interfaces/user-json.ts
··· 104 104 105 105 // FIXME: Using Partial isn't quite correct as the keys are there but the values are null. 106 106 export type UserJsonDeleted = Partial<UserJson> & { username: string }; 107 - export type UserJsonMinimum = Pick<UserJson, 'avatar_url' | 'id' | 'username'>;
+12
resources/js/interfaces/with-beatmap-owners.ts
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import BeatmapJson from './beatmap-json'; 5 + 6 + type WithBeatmapOwners<T extends BeatmapJson> = T & Required<Pick<T, 'owners'>>; 7 + 8 + export function hasOwners<T extends BeatmapJson>(beatmap: T): beatmap is WithBeatmapOwners<T> { 9 + return beatmap.owners != null; 10 + } 11 + 12 + export default WithBeatmapOwners;
+4 -4
resources/js/scores-show/beatmap-info.tsx
··· 3 3 4 4 import BeatmapListItem from 'components/beatmap-list-item'; 5 5 import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; 6 - import BeatmapJson from 'interfaces/beatmap-json'; 7 6 import BeatmapsetJson from 'interfaces/beatmapset-json'; 7 + import WithBeatmapOwners from 'interfaces/with-beatmap-owners'; 8 8 import { route } from 'laroute'; 9 9 import * as React from 'react'; 10 10 import { getArtist, getTitle } from 'utils/beatmapset-helper'; 11 11 import { trans } from 'utils/lang'; 12 12 13 13 interface Props { 14 - beatmap: BeatmapExtendedJson & Required<Pick<BeatmapJson, 'user'>>; 14 + beatmap: WithBeatmapOwners<BeatmapExtendedJson>; 15 15 beatmapset: BeatmapsetJson; 16 16 } 17 17 ··· 38 38 beatmapUrl={beatmapUrl} 39 39 beatmapset={beatmapset} 40 40 inline 41 - mapper={beatmap.user} 42 41 modifiers='score' 43 - showNonGuestMapper 42 + showNonGuestOwner 43 + showOwners 44 44 /> 45 45 </span> 46 46 </div>
+10
resources/js/utils/beatmap-helper.ts
··· 4 4 import * as d3 from 'd3'; 5 5 import { isValid as isBeatmapExtendedJson } from 'interfaces/beatmap-extended-json'; 6 6 import BeatmapJson from 'interfaces/beatmap-json'; 7 + import BeatmapsetJson from 'interfaces/beatmapset-json'; 7 8 import Ruleset, { rulesets } from 'interfaces/ruleset'; 9 + import WithBeatmapOwners from 'interfaces/with-beatmap-owners'; 8 10 import * as _ from 'lodash'; 9 11 import core from 'osu-core-singleton'; 10 12 import { parseJsonNullable } from 'utils/json'; ··· 97 99 }); 98 100 99 101 return ret; 102 + } 103 + 104 + export function hasGuestOwners(beatmap: WithBeatmapOwners<BeatmapJson>, beatmapset: BeatmapsetJson) { 105 + return beatmap.owners.some((owner) => owner.id !== beatmapset.user_id); 106 + } 107 + 108 + export function isOwner(userId: number, beatmap: WithBeatmapOwners<BeatmapJson>) { 109 + return beatmap.owners.some((owner) => owner.id === userId); 100 110 } 101 111 102 112 export function rulesetName(id: number): Ruleset {
+4 -2
resources/js/utils/beatmapset-discussion-helper.ts
··· 12 12 import BeatmapsetJson from 'interfaces/beatmapset-json'; 13 13 import Ruleset, { rulesets } from 'interfaces/ruleset'; 14 14 import UserJson from 'interfaces/user-json'; 15 + import WithBeatmapOwners from 'interfaces/with-beatmap-owners'; 15 16 import { route } from 'laroute'; 16 17 import { assign, padStart, sortBy } from 'lodash'; 17 18 import * as moment from 'moment'; 18 19 import core from 'osu-core-singleton'; 19 20 import { currentUrl } from 'utils/turbolinks'; 20 21 import { linkHtml, openBeatmapEditor } from 'utils/url'; 22 + import { isOwner } from './beatmap-helper'; 21 23 import { getInt } from './math'; 22 24 23 25 interface BadgeGroupParams { 24 26 beatmapset?: BeatmapsetJson; 25 - currentBeatmap?: BeatmapJson | null; 27 + currentBeatmap?: WithBeatmapOwners<BeatmapJson> | null; 26 28 discussion: BeatmapsetDiscussionJson; 27 29 user?: UserJson; 28 30 } ··· 91 93 return mapperGroup; 92 94 } 93 95 94 - if (currentBeatmap != null && discussion.beatmap_id === currentBeatmap.id && user.id === currentBeatmap.user_id) { 96 + if (currentBeatmap != null && discussion.beatmap_id === currentBeatmap.id && isOwner(user.id, currentBeatmap)) { 95 97 return guestGroup; 96 98 } 97 99
+4
resources/lang/en/beatmaps.php
··· 4 4 // See the LICENCE file in the repository root for full licence text. 5 5 6 6 return [ 7 + 'change_owner' => [ 8 + 'too_many' => 'Too many guest mappers.', 9 + ], 10 + 7 11 'discussion-votes' => [ 8 12 'update' => [ 9 13 'error' => 'Failed updating vote',
+1
resources/lang/en/multiplayer.php
··· 19 19 20 20 'errors' => [ 21 21 'duration_too_long' => 'Duration is too long.', 22 + 'name_too_long' => 'Room name is too long.', 22 23 ], 23 24 24 25 'status' => [
+1 -1
resources/views/teams/show.blade.php
··· 72 72 <div class="team-info-entry"> 73 73 <div class="team-info-entry__title">{{ osu_trans('teams.show.info.created') }}</div> 74 74 <div class="team-info-entry__value"> 75 - {{ i18n_date($team->created_at, null, 'year_month') }} 75 + {{ i18n_date($team->created_at, pattern: 'year_month') }} 76 76 </div> 77 77 </div> 78 78 @if (present($team->url))
+3 -1
routes/web.php
··· 43 43 Route::group(['as' => 'beatmaps.', 'prefix' => '{beatmap}'], function () { 44 44 Route::get('scores', 'BeatmapsController@scores')->name('scores'); 45 45 Route::get('solo-scores', 'BeatmapsController@soloScores')->name('solo-scores'); 46 - Route::put('update-owner', 'BeatmapsController@updateOwner')->name('update-owner'); 46 + Route::post('update-owner', 'BeatmapsController@updateOwner')->name('update-owner'); 47 47 }); 48 48 }); 49 49 ··· 505 505 Route::get('{rulesetOrScore}/{score}/download', 'ScoresController@download')->middleware(ThrottleRequests::getApiThrottle('scores_download'))->name('download-legacy'); 506 506 507 507 Route::get('{rulesetOrScore}/{score?}', 'ScoresController@show')->name('show'); 508 + 509 + Route::get('/', 'ScoresController@index'); 508 510 }); 509 511 510 512 // Beatmapsets
-151
tests/Controllers/BeatmapsControllerTest.php
··· 9 9 10 10 use App\Models\Beatmap; 11 11 use App\Models\Beatmapset; 12 - use App\Models\BeatmapsetEvent; 13 12 use App\Models\User; 14 13 use Illuminate\Testing\Fluent\AssertableJson; 15 14 use Tests\TestCase; ··· 183 182 ->get(route('api.beatmaps.show', ['beatmap' => $beatmap->getKey()])) 184 183 ->assertSuccessful() 185 184 ->assertJsonPath('id', $beatmap->getKey()); 186 - } 187 - 188 - public function testUpdateOwner(): void 189 - { 190 - $otherUser = User::factory()->create(); 191 - $beatmapset = Beatmapset::factory()->create([ 192 - 'approved' => Beatmapset::STATES['pending'], 193 - 'user_id' => $this->user, 194 - ]); 195 - $this->beatmap->update([ 196 - 'beatmapset_id' => $beatmapset->getKey(), 197 - 'user_id' => $this->user->getKey(), 198 - ]); 199 - 200 - $beatmapsetEventCount = BeatmapsetEvent::count(); 201 - 202 - $this->actingAsVerified($this->user) 203 - ->json('PUT', route('beatmaps.update-owner', $this->beatmap), [ 204 - 'beatmap' => ['user_id' => $otherUser->getKey()], 205 - ])->assertSuccessful(); 206 - 207 - $this->assertSame($otherUser->getKey(), $this->beatmap->fresh()->user_id); 208 - $this->assertSame($beatmapsetEventCount + 1, BeatmapsetEvent::count()); 209 - } 210 - 211 - public function testUpdateOwnerInvalidState(): void 212 - { 213 - $otherUser = User::factory()->create(); 214 - $beatmapset = Beatmapset::factory()->create([ 215 - 'approved' => Beatmapset::STATES['qualified'], 216 - 'user_id' => $this->user, 217 - ]); 218 - $this->beatmap->update([ 219 - 'beatmapset_id' => $beatmapset->getKey(), 220 - 'user_id' => $this->user->getKey(), 221 - ]); 222 - 223 - $beatmapsetEventCount = BeatmapsetEvent::count(); 224 - 225 - $this->actingAsVerified($this->user) 226 - ->json('PUT', route('beatmaps.update-owner', $this->beatmap), [ 227 - 'beatmap' => ['user_id' => $otherUser->getKey()], 228 - ])->assertStatus(403); 229 - 230 - $this->assertSame($this->user->getKey(), $this->beatmap->fresh()->user_id); 231 - $this->assertSame($beatmapsetEventCount, BeatmapsetEvent::count()); 232 - } 233 - 234 - public function testUpdateOwnerInvalidUser(): void 235 - { 236 - $beatmapset = Beatmapset::factory()->create([ 237 - 'approved' => Beatmapset::STATES['pending'], 238 - 'user_id' => $this->user, 239 - ]); 240 - $this->beatmap->update([ 241 - 'beatmapset_id' => $beatmapset->getKey(), 242 - 'user_id' => $this->user->getKey(), 243 - ]); 244 - 245 - $beatmapsetEventCount = BeatmapsetEvent::count(); 246 - 247 - $this->actingAsVerified($this->user) 248 - ->json('PUT', route('beatmaps.update-owner', $this->beatmap), [ 249 - 'beatmap' => ['user_id' => User::max('user_id') + 1], 250 - ])->assertStatus(422); 251 - 252 - $this->assertSame($this->user->getKey(), $this->beatmap->fresh()->user_id); 253 - $this->assertSame($beatmapsetEventCount, BeatmapsetEvent::count()); 254 - } 255 - 256 - /** 257 - * @dataProvider dataProviderForTestUpdateOwnerLoved 258 - */ 259 - public function testUpdateOwnerLoved(int $approved, bool $ok): void 260 - { 261 - $moderator = User::factory()->withGroup('loved')->create(); 262 - $this->beatmap->beatmapset->update([ 263 - 'approved' => $approved, 264 - 'approved_date' => now(), 265 - ]); 266 - 267 - $this->expectCountChange(fn () => BeatmapsetEvent::count(), $ok ? 1 : 0); 268 - $expectedOwner = $ok ? $this->user->getKey() : $this->beatmap->fresh()->user_id; 269 - 270 - $this->actingAsVerified($moderator) 271 - ->json('PUT', route('beatmaps.update-owner', $this->beatmap), [ 272 - 'beatmap' => ['user_id' => $this->user->getKey()], 273 - ])->assertStatus($ok ? 200 : 403); 274 - 275 - $this->assertSame($expectedOwner, $this->beatmap->fresh()->user_id); 276 - } 277 - 278 - public function testUpdateOwnerModerator(): void 279 - { 280 - $moderator = User::factory()->withGroup('nat')->create(); 281 - $this->beatmap->beatmapset->update([ 282 - 'approved' => Beatmapset::STATES['ranked'], 283 - 'approved_date' => now(), 284 - ]); 285 - 286 - $this->expectCountChange(fn () => BeatmapsetEvent::count(), 1); 287 - 288 - $this->actingAsVerified($moderator) 289 - ->json('PUT', route('beatmaps.update-owner', $this->beatmap), [ 290 - 'beatmap' => ['user_id' => $this->user->getKey()], 291 - ])->assertSuccessful(); 292 - 293 - $this->assertSame($this->user->getKey(), $this->beatmap->fresh()->user_id); 294 - } 295 - 296 - public function testUpdateOwnerNotOwner(): void 297 - { 298 - $otherUser = User::factory()->create(); 299 - $beatmapset = Beatmapset::factory()->create(['user_id' => $this->user]); 300 - $this->beatmap->update([ 301 - 'beatmapset_id' => $beatmapset->getKey(), 302 - 'user_id' => $this->user->getKey(), 303 - ]); 304 - 305 - $beatmapsetEventCount = BeatmapsetEvent::count(); 306 - 307 - $this->actingAsVerified($otherUser) 308 - ->json('PUT', route('beatmaps.update-owner', $this->beatmap), [ 309 - 'beatmap' => ['user_id' => $otherUser->getKey()], 310 - ])->assertStatus(403); 311 - 312 - $this->assertSame($this->user->getKey(), $this->beatmap->fresh()->user_id); 313 - $this->assertSame($beatmapsetEventCount, BeatmapsetEvent::count()); 314 - } 315 - 316 - public function testUpdateOwnerSameOwner(): void 317 - { 318 - $beatmapset = Beatmapset::factory()->create([ 319 - 'approved' => Beatmapset::STATES['pending'], 320 - 'user_id' => $this->user, 321 - ]); 322 - $this->beatmap->update([ 323 - 'beatmapset_id' => $beatmapset->getKey(), 324 - 'user_id' => $this->user->getKey(), 325 - ]); 326 - 327 - $beatmapsetEventCount = BeatmapsetEvent::count(); 328 - 329 - $this->actingAsVerified($this->user) 330 - ->json('PUT', route('beatmaps.update-owner', $this->beatmap), [ 331 - 'beatmap' => ['user_id' => $this->user->getKey()], 332 - ])->assertStatus(422); 333 - 334 - $this->assertSame($this->user->getKey(), $this->beatmap->fresh()->user_id); 335 - $this->assertSame($beatmapsetEventCount, BeatmapsetEvent::count()); 336 185 } 337 186 338 187 public static function dataProviderForTestLookupForApi(): array
+278
tests/Libraries/Beatmapset/ChangeBeatmapOwnersTest.php
··· 1 + <?php 2 + 3 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4 + // See the LICENCE file in the repository root for full licence text. 5 + 6 + declare(strict_types=1); 7 + 8 + namespace Tests\Libraries\Beatmapset; 9 + 10 + use App\Exceptions\AuthorizationException; 11 + use App\Exceptions\InvariantException; 12 + use App\Jobs\Notifications\BeatmapOwnerChange; 13 + use App\Libraries\Beatmapset\ChangeBeatmapOwners; 14 + use App\Models\Beatmap; 15 + use App\Models\BeatmapOwner; 16 + use App\Models\Beatmapset; 17 + use App\Models\BeatmapsetEvent; 18 + use App\Models\DeletedUser; 19 + use App\Models\User; 20 + use Arr; 21 + use Bus; 22 + use Tests\TestCase; 23 + 24 + class ChangeBeatmapOwnersTest extends TestCase 25 + { 26 + public static function dataProviderForUpdateOwner(): array 27 + { 28 + return [ 29 + 'existing restricted user' => [['restricted', 'default'], true], 30 + 'new restricted user' => [['default', 'restricted'], false], 31 + 'new user' => [['default', 'default'], true], 32 + ]; 33 + } 34 + 35 + public static function dataProviderForUpdateOwnerLoved(): array 36 + { 37 + return [ 38 + [Beatmapset::STATES['graveyard'], true], 39 + [Beatmapset::STATES['loved'], true], 40 + [Beatmapset::STATES['ranked'], false], 41 + [Beatmapset::STATES['wip'], false], 42 + ]; 43 + } 44 + 45 + public function testMissingUser(): void 46 + { 47 + $moderator = User::factory()->withGroup('nat')->create(); 48 + $otherUser = User::factory()->create(); 49 + $missingUserId = User::max('user_id') + 1; 50 + $userIds = [$missingUserId, $otherUser->getKey()]; 51 + 52 + $beatmap = Beatmap::factory() 53 + ->state(['user_id' => $missingUserId]) 54 + ->has(BeatmapOwner::factory()->state(['user_id' => $missingUserId])) 55 + ->for(Beatmapset::factory()->pending()->state(['user_id' => $missingUserId])) 56 + ->create(); 57 + 58 + $this->expectCountChange(fn () => BeatmapsetEvent::count(), 1); 59 + 60 + (new ChangeBeatmapOwners($beatmap, $userIds, $moderator))->handle(); 61 + 62 + $beatmap = $beatmap->fresh(); 63 + $newOwners = $beatmap->getOwners(); 64 + $this->assertEqualsCanonicalizing($userIds, $newOwners->pluck('user_id')->toArray()); 65 + $this->assertTrue($newOwners->find($missingUserId) instanceof DeletedUser); 66 + $this->assertSame($userIds[0], $beatmap->user_id); 67 + 68 + Bus::assertDispatched(BeatmapOwnerChange::class); 69 + } 70 + 71 + /** 72 + * @dataProvider dataProviderForUpdateOwner 73 + */ 74 + public function testUpdateOwner(array $states, bool $success): void 75 + { 76 + $factory = User::factory(); 77 + $moderator = $factory->withGroup('nat')->create(); 78 + $users = array_map(fn ($state) => $factory->$state()->create(), $states); 79 + $owner = $users[0]; 80 + 81 + $beatmap = Beatmap::factory() 82 + ->for(Beatmapset::factory()->pending()->owner($owner)) 83 + ->owner($owner) 84 + ->create(); 85 + 86 + $this->expectCountChange(fn () => BeatmapsetEvent::count(), $success ? 1 : 0); 87 + 88 + $this->expectExceptionCallable( 89 + fn () => (new ChangeBeatmapOwners($beatmap, Arr::pluck($users, 'user_id'), $moderator))->handle(), 90 + $success ? null : InvariantException::class, 91 + ); 92 + 93 + $beatmap = $beatmap->fresh(); 94 + 95 + if ($success) { 96 + $this->assertEqualsCanonicalizing(Arr::pluck($users, 'user_id'), $beatmap->getOwners()->pluck('user_id')->toArray()); 97 + $this->assertSame($users[0]->getKey(), $beatmap->user_id); 98 + Bus::assertDispatched(BeatmapOwnerChange::class); 99 + } else { 100 + $this->assertEqualsCanonicalizing([$owner->getKey()], $beatmap->getOwners()->pluck('user_id')->toArray()); 101 + Bus::assertNotDispatched(BeatmapOwnerChange::class); 102 + } 103 + } 104 + 105 + public function testUpdateOwnerExistingRestrictedUser(): void 106 + { 107 + $source = User::factory()->withGroup('gmt')->create(); 108 + $owner = User::factory()->restricted()->create(); 109 + $users = [User::factory()->create(), $owner]; 110 + $ownerId = $owner->getKey(); 111 + 112 + $beatmap = Beatmap::factory() 113 + ->for(Beatmapset::factory()->pending()->owner($owner)) 114 + ->owner($owner) 115 + ->create(); 116 + 117 + $this->expectCountChange(fn () => BeatmapsetEvent::count(), 1); 118 + 119 + (new ChangeBeatmapOwners($beatmap, Arr::pluck($users, 'user_id'), $source))->handle(); 120 + 121 + $beatmap = $beatmap->fresh(); 122 + $newOwners = $beatmap->getOwners(); 123 + $this->assertCount(count($users), $newOwners); 124 + $this->assertEqualsCanonicalizing(Arr::pluck($users, 'user_id'), $newOwners->pluck('user_id')->toArray()); 125 + 126 + Bus::assertDispatched(BeatmapOwnerChange::class); 127 + } 128 + 129 + public function testUpdateOwnerInvalidState(): void 130 + { 131 + $user = User::factory()->create(); 132 + $owner = User::factory()->create(); 133 + $beatmap = Beatmap::factory() 134 + ->for(Beatmapset::factory()->qualified()->owner($owner)) 135 + ->owner($owner) 136 + ->create(); 137 + 138 + $this->expectCountChange(fn () => BeatmapsetEvent::count(), 0); 139 + $this->expectExceptionCallable( 140 + fn () => (new ChangeBeatmapOwners($beatmap, [$user->getKey()], $owner))->handle(), 141 + AuthorizationException::class 142 + ); 143 + 144 + $beatmap = $beatmap->fresh(); 145 + $this->assertEqualsCanonicalizing([$owner->getKey()], $beatmap->getOwners()->pluck('user_id')->toArray()); 146 + $this->assertSame($owner->getKey(), $beatmap->user_id); 147 + 148 + Bus::assertNotDispatched(BeatmapOwnerChange::class); 149 + } 150 + 151 + public function testUpdateOwnerInvalidUser(): void 152 + { 153 + $owner = User::factory()->create(); 154 + $beatmap = Beatmap::factory() 155 + ->for(Beatmapset::factory()->pending()->owner($owner)) 156 + ->owner($owner) 157 + ->create(); 158 + 159 + $this->expectCountChange(fn () => BeatmapsetEvent::count(), 0); 160 + $this->expectExceptionCallable( 161 + fn () => (new ChangeBeatmapOwners($beatmap, [User::max('user_id') + 1], $owner))->handle(), 162 + InvariantException::class 163 + ); 164 + 165 + $beatmap = $beatmap->fresh(); 166 + $this->assertEqualsCanonicalizing([$owner->getKey()], $beatmap->getOwners()->pluck('user_id')->toArray()); 167 + $this->assertSame($owner->getKey(), $beatmap->user_id); 168 + 169 + Bus::assertNotDispatched(BeatmapOwnerChange::class); 170 + } 171 + 172 + /** 173 + * @dataProvider dataProviderForUpdateOwnerLoved 174 + */ 175 + public function testUpdateOwnerLoved(int $approved, bool $ok): void 176 + { 177 + $moderator = User::factory()->withGroup('loved')->create(); 178 + $user = User::factory()->create(); 179 + $owner = User::factory()->create(); 180 + $beatmap = Beatmap::factory() 181 + ->for(Beatmapset::factory()->state([ 182 + 'approved' => $approved, 183 + 'approved_date' => now(), 184 + ])->owner($owner)) 185 + ->owner($owner) 186 + ->create(); 187 + 188 + $this->expectCountChange(fn () => BeatmapsetEvent::count(), $ok ? 1 : 0); 189 + 190 + $this->expectExceptionCallable( 191 + fn () => (new ChangeBeatmapOwners($beatmap, [$user->getKey()], $moderator))->handle(), 192 + $ok ? null : AuthorizationException::class, 193 + ); 194 + 195 + $beatmap = $beatmap->fresh(); 196 + $expectedUser = $ok ? $user : $owner; 197 + $this->assertEqualsCanonicalizing([$expectedUser->getKey()], $beatmap->getOwners()->pluck('user_id')->toArray()); 198 + $this->assertSame($expectedUser->getKey(), $beatmap->user_id); 199 + 200 + if ($ok) { 201 + Bus::assertDispatched(BeatmapOwnerChange::class); 202 + } else { 203 + Bus::assertNotDispatched(BeatmapOwnerChange::class); 204 + } 205 + } 206 + 207 + public function testUpdateOwnerModerator(): void 208 + { 209 + $moderator = User::factory()->withGroup('nat')->create(); 210 + $user = User::factory()->create(); 211 + $owner = User::factory()->create(); 212 + $beatmap = Beatmap::factory() 213 + ->for(Beatmapset::factory()->state([ 214 + 'approved' => Beatmapset::STATES['ranked'], 215 + 'approved_date' => now(), 216 + ])->owner($owner)) 217 + ->owner($owner) 218 + ->create(); 219 + 220 + $this->expectCountChange(fn () => BeatmapsetEvent::count(), 1); 221 + 222 + (new ChangeBeatmapOwners($beatmap, [$user->getKey()], $moderator))->handle(); 223 + 224 + $beatmap = $beatmap->fresh(); 225 + $this->assertEqualsCanonicalizing([$user->getKey()], $beatmap->getOwners()->pluck('user_id')->toArray()); 226 + $this->assertSame($user->getKey(), $beatmap->user_id); 227 + } 228 + 229 + public function testUpdateOwnerNotOwner(): void 230 + { 231 + $user = User::factory()->create(); 232 + $owner = User::factory()->create(); 233 + $beatmap = Beatmap::factory() 234 + ->for(Beatmapset::factory()->state([ 235 + 'approved' => Beatmapset::STATES['ranked'], 236 + 'approved_date' => now(), 237 + ])->owner($owner)) 238 + ->owner($owner) 239 + ->create(); 240 + 241 + $this->expectCountChange(fn () => BeatmapsetEvent::count(), 0); 242 + $this->expectExceptionCallable( 243 + fn () => (new ChangeBeatmapOwners($beatmap, [$user->getKey()], $user))->handle(), 244 + AuthorizationException::class, 245 + ); 246 + 247 + $beatmap = $beatmap->fresh(); 248 + $this->assertEqualsCanonicalizing([$owner->getKey()], $beatmap->getOwners()->pluck('user_id')->toArray()); 249 + $this->assertSame($owner->getKey(), $beatmap->user_id); 250 + 251 + Bus::assertNotDispatched(BeatmapOwnerChange::class); 252 + } 253 + 254 + public function testUpdateOwnerSameOwner(): void 255 + { 256 + $owner = User::factory()->create(); 257 + $beatmap = Beatmap::factory() 258 + ->for(Beatmapset::factory()->pending()->owner($owner)) 259 + ->owner($owner) 260 + ->create(); 261 + 262 + $this->expectCountChange(fn () => BeatmapsetEvent::count(), 0); 263 + (new ChangeBeatmapOwners($beatmap, [$owner->getKey()], $owner))->handle(); 264 + 265 + $beatmap = $beatmap->fresh(); 266 + $this->assertEqualsCanonicalizing([$owner->getKey()], $beatmap->getOwners()->pluck('user_id')->toArray()); 267 + $this->assertSame($owner->getKey(), $beatmap->user_id); 268 + 269 + Bus::assertNotDispatched(BeatmapOwnerChange::class); 270 + } 271 + 272 + protected function setUp(): void 273 + { 274 + parent::setUp(); 275 + 276 + Bus::fake([BeatmapOwnerChange::class]); 277 + } 278 + }
+14 -2
tests/Models/BeatmapsetTest.php
··· 13 13 use App\Jobs\CheckBeatmapsetCovers; 14 14 use App\Jobs\Notifications\BeatmapsetDisqualify; 15 15 use App\Jobs\Notifications\BeatmapsetResetNominations; 16 + use App\Libraries\Beatmapset\ChangeBeatmapOwners; 16 17 use App\Libraries\Beatmapset\NominateBeatmapset; 17 18 use App\Models\Beatmap; 18 19 use App\Models\BeatmapDiscussion; ··· 803 804 $bngUser1 = User::factory()->withGroup('bng', ['osu', 'taiko'])->create(); 804 805 $bngUser2 = User::factory()->withGroup('bng', ['osu', 'taiko'])->create(); 805 806 $bngLimitedUser = User::factory()->withGroup('bng_limited', ['osu', 'taiko'])->create(); 807 + $natUser = User::factory()->withGroup('nat')->create(); 806 808 807 809 // make taiko tha main ruleset 808 810 $beatmapset = $this->beatmapsetFactory() ··· 819 821 $beatmapset->fresh()->nominate($bngUser1, ['osu']); 820 822 821 823 // main ruleset should now be osu 822 - $beatmapset->beatmaps()->where('playmode', 1)->first()->setOwner($guest->getKey()); 823 - $beatmapset->beatmaps()->where('playmode', 0)->last()->setOwner($beatmapset->user_id); 824 + (new ChangeBeatmapOwners( 825 + $beatmapset->beatmaps()->where('playmode', 1)->first(), 826 + [$guest->getKey()], 827 + $natUser 828 + ))->handle(); 829 + 830 + (new ChangeBeatmapOwners( 831 + $beatmapset->beatmaps()->where('playmode', 0)->last(), 832 + [$beatmapset->user_id], 833 + $natUser 834 + ))->handle(); 835 + 824 836 $beatmapset->refresh(); 825 837 826 838 $this->assertSame('osu', $beatmapset->mainRuleset());
+16
tests/api_routes.json
··· 1081 1081 ] 1082 1082 }, 1083 1083 { 1084 + "uri": "api/v2/scores", 1085 + "methods": [ 1086 + "GET", 1087 + "HEAD" 1088 + ], 1089 + "controller": "App\\Http\\Controllers\\ScoresController@index", 1090 + "middlewares": [ 1091 + "App\\Http\\Middleware\\ThrottleRequests:1200,1,api:", 1092 + "App\\Http\\Middleware\\RequireScopes", 1093 + "App\\Http\\Middleware\\RequireScopes:public" 1094 + ], 1095 + "scopes": [ 1096 + "public" 1097 + ] 1098 + }, 1099 + { 1084 1100 "uri": "api/v2/beatmapsets/search", 1085 1101 "methods": [ 1086 1102 "GET",