+2
.env.example
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+6
database/factories/UserFactory.php
+28
database/migrations/2024_07_02_000001_create_beatmap_owners.php
+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
+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
+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
+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
+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
+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
+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
+5
resources/css/bem/input-container.less
+2
resources/css/bem/username-input.less
+2
resources/css/bem/username-input.less
+3
-4
resources/js/beatmap-discussions/beatmap-list.tsx
+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
+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
+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
+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
+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
+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
+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
+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
+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() {
+17
resources/js/beatmap-discussions/user-link-list.tsx
+17
resources/js/beatmap-discussions/user-link-list.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 UserLink from 'components/user-link';
5
+
import UserJson from 'interfaces/user-json';
6
+
import React from 'react';
7
+
import { joinComponents } from 'utils/lang';
8
+
9
+
interface Props {
10
+
users: Pick<UserJson, 'id' | 'username'>[];
11
+
}
12
+
13
+
export default class UserLinkList extends React.PureComponent<Props> {
14
+
render() {
15
+
return joinComponents(this.props.users.map((user) => <UserLink key={user.id} user={user} />));
16
+
}
17
+
}
+1
-1
resources/js/beatmapset-panel/beatmaps-popup.tsx
+1
-1
resources/js/beatmapset-panel/beatmaps-popup.tsx
+3
-3
resources/js/beatmapsets-show/controller.ts
+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
+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
+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
+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
+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
+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
+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
+4
resources/js/interfaces/beatmapset-event-json.ts
+3
-2
resources/js/interfaces/beatmapset-extended-json.ts
+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
+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
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
-1
resources/js/interfaces/user-json.ts
+12
resources/js/interfaces/with-beatmap-owners.ts
+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
+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
+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
+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
resources/lang/en/beatmaps.php
+1
resources/lang/en/multiplayer.php
+1
resources/lang/en/multiplayer.php
+1
-1
resources/views/teams/show.blade.php
+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
+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
-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
+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
+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
+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",