Skip to content

Commit

Permalink
Add interop endpoint to create and join multiplayer room
Browse files Browse the repository at this point in the history
  • Loading branch information
nanaya committed Jan 10, 2025
1 parent 69d48c8 commit d0be2dc
Show file tree
Hide file tree
Showing 8 changed files with 200 additions and 43 deletions.
35 changes: 35 additions & 0 deletions app/Http/Controllers/InterOp/Multiplayer/RoomsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the GNU Affero General Public License v3.0.
// See the LICENCE file in the repository root for full licence text.

namespace App\Http\Controllers\InterOp\Multiplayer;

use App\Http\Controllers\Controller;
use App\Models\Multiplayer\Room;
use App\Models\User;
use App\Transformers\Multiplayer\RoomTransformer;

class RoomsController extends Controller
{
public function join(string $id, string $userId)
{
$user = User::findOrFail($userId);
$room = Room::findOrFail($id);

$room->assertCorrectPassword(get_string(request('password')));
$room->join($user);

return RoomTransformer::createShowResponse($room);
}

public function store()
{
$params = \Request::all();
$user = User::findOrFail(get_int($params['user_id'] ?? null));

$room = (new Room())->startGame($user, $params);

return RoomTransformer::createShowResponse($room);
}
}
47 changes: 8 additions & 39 deletions app/Http/Controllers/Multiplayer/RoomsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

namespace App\Http\Controllers\Multiplayer;

use App\Exceptions\InvariantException;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Ranking\DailyChallengeController;
use App\Models\Model;
Expand Down Expand Up @@ -101,24 +100,18 @@ public function index()

public function join($roomId, $userId)
{
$currentUser = \Auth::user();
// this allows admins/whatever to add users to games in the future
if (get_int($userId) !== auth()->user()->user_id) {
if (get_int($userId) !== $currentUser->getKey()) {
abort(403);
}

$room = Room::findOrFail($roomId);
$room->assertCorrectPassword(get_string(request('password')));

if ($room->password !== null) {
$password = get_param_value(request('password'), null);

if ($password === null || !hash_equals(hash('sha256', $room->password), hash('sha256', $password))) {
abort(403, osu_trans('multiplayer.room.invalid_password'));
}
}

$room->join(auth()->user());
$room->join($currentUser);

return $this->createJoinedRoomResponse($room);
return RoomTransformer::createShowResponse($room);
}

public function leaderboard($roomId)
Expand Down Expand Up @@ -168,7 +161,7 @@ public function show($id)
}

if (is_api_request()) {
return $this->createJoinedRoomResponse($room);
return RoomTransformer::createShowResponse($room);
}

if ($room->category === 'daily_challenge') {
Expand Down Expand Up @@ -200,32 +193,8 @@ public function show($id)

public function store()
{
try {
$room = (new Room())->startGame(auth()->user(), request()->all());

return $this->createJoinedRoomResponse($room);
} catch (InvariantException $e) {
return error_popup($e->getMessage(), $e->getStatusCode());
}
}
$room = (new Room())->startGame(\Auth::user(), \Request::all());

private function createJoinedRoomResponse($room)
{
return json_item(
$room->loadMissing([
'host',
'playlist.beatmap.beatmapset',
'playlist.beatmap.baseMaxCombo',
]),
'Multiplayer\Room',
[
'current_user_score.playlist_item_attempts',
'host.country',
'playlist.beatmap.beatmapset',
'playlist.beatmap.checksum',
'playlist.beatmap.max_combo',
'recent_participants',
]
);
return RoomTransformer::createShowResponse($room);
}
}
12 changes: 12 additions & 0 deletions app/Models/Multiplayer/Room.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
namespace App\Models\Multiplayer;

use App\Casts\PresentString;
use App\Exceptions\AuthorizationException;
use App\Exceptions\InvariantException;
use App\Models\Beatmap;
use App\Models\Chat\Channel;
Expand Down Expand Up @@ -332,6 +333,17 @@ public function scopeWithRecentParticipantIds($query, ?int $limit = null)
", 'recent_participant_ids');
}

public function assertCorrectPassword(?string $password): void
{
if ($this->password === null) {
return;
}

if ($password === null || !hash_equals(hash('sha256', $this->password), hash('sha256', $password))) {
throw new AuthorizationException(osu_trans('multiplayer.room.invalid_password'));
}
}

public function difficultyRange()
{
$extraQuery = true;
Expand Down
20 changes: 20 additions & 0 deletions app/Transformers/Multiplayer/RoomTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ class RoomTransformer extends TransformerAbstract
'recent_participants',
];

public static function createShowResponse(Room $room): array
{
return json_item(
$room->loadMissing([
'host',
'playlist.beatmap.baseMaxCombo',
'playlist.beatmap.beatmapset',
]),
new static(),
[
'current_user_score.playlist_item_attempts',
'host.country',
'playlist.beatmap.beatmapset',
'playlist.beatmap.checksum',
'playlist.beatmap.max_combo',
'recent_participants',
],
);
}

public function transform(Room $room)
{
return [
Expand Down
8 changes: 7 additions & 1 deletion app/helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -840,7 +840,9 @@ function forum_user_link(int $id, string $username, string|null $colour, int|nul

function is_api_request(): bool
{
return str_starts_with(rawurldecode(Request::getPathInfo()), '/api/');
$url = rawurldecode(Request::getPathInfo());
return str_starts_with($url, '/api/')
|| str_starts_with($url, '/_lio/');
}

function is_http(string $url): bool
Expand Down Expand Up @@ -1718,6 +1720,10 @@ function parse_time_to_carbon($value)
if ($value instanceof DateTime) {
return Carbon\Carbon::instance($value);
}

if ($value instanceof Carbon\CarbonImmutable) {
return $value->toMutable();
}
}

function format_duration_for_display(int $seconds)
Expand Down
5 changes: 5 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,11 @@
Route::apiResource('bulk', 'Indexing\BulkController', ['only' => ['store']]);
});

Route::group(['as' => 'multiplayer.', 'namespace' => 'Multiplayer', 'prefix' => 'multiplayer'], function () {
Route::put('rooms/{room}/users/{user}', 'RoomsController@join')->name('rooms.join');
Route::apiResource('rooms', 'RoomsController', ['only' => ['store']]);
});

Route::post('user-achievement/{user}/{achievement}/{beatmap?}', 'UsersController@achievement')->name('users.achievement');

Route::group(['as' => 'user-group.'], function () {
Expand Down
101 changes: 101 additions & 0 deletions tests/Controllers/InterOp/Multiplayer/RoomsControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the GNU Affero General Public License v3.0.
// See the LICENCE file in the repository root for full licence text.

namespace Tests\Controllers\InterOp\Multiplayer;

use App\Models\Beatmap;
use App\Models\Chat\UserChannel;
use App\Models\Multiplayer\Room;
use App\Models\User;
use Carbon\CarbonImmutable;
use Tests\TestCase;

class RoomsControllerTest extends TestCase
{
private static function startRoomParams(): array
{
$beatmap = Beatmap::factory()->create();

return [
'ends_at' => CarbonImmutable::now()->addHours(1),
'name' => 'test room',
'type' => Room::REALTIME_DEFAULT_TYPE,
'playlist' => [[
'beatmap_id' => $beatmap->getKey(),
'ruleset_id' => $beatmap->playmode,
]],
];
}

public function testJoin(): void
{
$room = (new Room())->startGame(User::factory()->create(), static::startRoomParams());
$user = User::factory()->create();

$this->expectCountChange(fn () => UserChannel::count(), 1);

$this->withInterOpHeader(
route('interop.multiplayer.rooms.join', [
'room' => $room->getKey(),
'user' => $user->getKey(),
]),
fn ($url) => $this->put($url),
)->assertSuccessful();
}

public function testJoinWithPassword(): void
{
$room = (new Room())->startGame(User::factory()->create(), [
...static::startRoomParams(),
'password' => 'hunter2',
]);
$user = User::factory()->create();

$this->expectCountChange(fn () => UserChannel::count(), 1);

$this->withInterOpHeader(
route('interop.multiplayer.rooms.join', [
'room' => $room->getKey(),
'user' => $user->getKey(),
]),
fn ($url) => $this->put($url, ['password' => 'hunter2']),
)->assertSuccessful();
}

public function testJoinWithPasswordInvalid(): void
{
$room = (new Room())->startGame(User::factory()->create(), [
...static::startRoomParams(),
'password' => 'hunter2',
]);
$user = User::factory()->create();

$this->expectCountChange(fn () => UserChannel::count(), 0);

$this->withInterOpHeader(
route('interop.multiplayer.rooms.join', [
'room' => $room->getKey(),
'user' => $user->getKey(),
]),
fn ($url) => $this->put($url, ['password' => '*******']),
)->assertStatus(403);
}

public function testStore(): void
{
$beatmap = Beatmap::factory()->create();
$params = [
...static::startRoomParams(),
'user_id' => User::factory()->create()->getKey(),
];

$this->expectCountChange(fn () => Room::count(), 1);

$this->withInterOpHeader(
route('interop.multiplayer.rooms.store'),
fn ($url) => $this->post($url, $params),
)->assertSuccessful();
}
}
15 changes: 12 additions & 3 deletions tests/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -365,11 +365,20 @@ protected function runFakeQueue()
$this->invokeSetProperty(app('queue'), 'jobs', []);
}

protected function withInterOpHeader($url)
protected function withInterOpHeader($url, ?callable $callback = null)
{
return $this->withHeaders([
'X-LIO-Signature' => hash_hmac('sha1', $url, $GLOBALS['cfg']['osu']['legacy']['shared_interop_secret']),
if ($callback === null) {
$timestampedUrl = $url;
} else {
$connector = strpos($url, '?') === false ? '?' : '&';
$timestampedUrl = $url.$connector.'timestamp='.time();
}

$this->withHeaders([
'X-LIO-Signature' => hash_hmac('sha1', $timestampedUrl, $GLOBALS['cfg']['osu']['legacy']['shared_interop_secret']),
]);

return $callback === null ? $this : $callback($timestampedUrl);
}

protected function withPersistentSession(SessionStore $session): static
Expand Down

0 comments on commit d0be2dc

Please sign in to comment.