From 640a2970c3437cf7df83389b9bdec5e0741a7465 Mon Sep 17 00:00:00 2001 From: Twyco Date: Thu, 3 Sep 2026 16:42:04 +0200 Subject: [PATCH] Handle authenticated room instance takeover --- app/Enums/RoomStateChange.php | 1 + app/Events/RoomStateChanged.php | 7 +- .../Controllers/EmbeddedRoomController.php | 7 ++ app/Http/Controllers/RoomController.php | 5 ++ app/Http/Controllers/RoomPlayerController.php | 7 ++ app/Http/Middleware/EnsureRoomParticipant.php | 46 ++++++++++- app/Http/Requests/ShowEmbeddedRoomRequest.php | 25 ++++-- app/Http/Resources/CurrentPlayerResource.php | 1 + app/Models/Player.php | 2 + .../Rooms/RoomParticipationService.php | 62 ++++++++++++-- .../Rooms/RoomPlayerInstanceService.php | 25 ++++++ ...dd_active_instance_id_to_players_table.php | 28 +++++++ resources/js/composables/useRoomRealtime.ts | 10 +++ resources/js/pages/embedded/Error.vue | 12 ++- resources/js/types/room.ts | 3 + tests/Feature/EmbeddedRoomTest.php | 80 +++++++++++++++++-- tests/Feature/RoomBroadcastingTest.php | 19 +++++ tests/Feature/RoomLifecycleTest.php | 33 ++++++++ 18 files changed, 351 insertions(+), 22 deletions(-) create mode 100644 app/Services/Rooms/RoomPlayerInstanceService.php create mode 100644 database/migrations/2026_09_03_142925_add_active_instance_id_to_players_table.php diff --git a/app/Enums/RoomStateChange.php b/app/Enums/RoomStateChange.php index f5bc9a4..70aa386 100644 --- a/app/Enums/RoomStateChange.php +++ b/app/Enums/RoomStateChange.php @@ -7,6 +7,7 @@ enum RoomStateChange: string case PlayerJoined = 'player_joined'; case PlayerUpdated = 'player_updated'; case PlayerKicked = 'player_kicked'; + case PlayerInstanceReplaced = 'player_instance_replaced'; case VoteChanged = 'vote_changed'; case CardsRevealed = 'cards_revealed'; case VotesReset = 'votes_reset'; diff --git a/app/Events/RoomStateChanged.php b/app/Events/RoomStateChanged.php index 11a851c..a12aeb2 100644 --- a/app/Events/RoomStateChanged.php +++ b/app/Events/RoomStateChanged.php @@ -22,6 +22,7 @@ class RoomStateChanged implements ShouldBroadcastNow public readonly RoomStateChange $change, public readonly ?array $state = null, public readonly ?int $affectedPlayerId = null, + public readonly ?string $affectedInstanceId = null, ) {} public function broadcastOn(): Channel @@ -35,7 +36,7 @@ class RoomStateChanged implements ShouldBroadcastNow } /** - * @return array{version: int, change: string, state: array|null, affected_player_id?: int} + * @return array{version: int, change: string, state: array|null, affected_player_id?: int, affected_instance_id?: string} */ public function broadcastWith(): array { @@ -49,6 +50,10 @@ class RoomStateChanged implements ShouldBroadcastNow $payload['affected_player_id'] = $this->affectedPlayerId; } + if ($this->affectedInstanceId !== null) { + $payload['affected_instance_id'] = $this->affectedInstanceId; + } + return $payload; } } diff --git a/app/Http/Controllers/EmbeddedRoomController.php b/app/Http/Controllers/EmbeddedRoomController.php index e6d33cf..4b5a6f4 100644 --- a/app/Http/Controllers/EmbeddedRoomController.php +++ b/app/Http/Controllers/EmbeddedRoomController.php @@ -8,6 +8,7 @@ use App\Http\Resources\RoomResource; use App\Models\Room; use App\Services\Rooms\RoomPageService; use App\Services\Rooms\RoomParticipationService; +use App\Services\Rooms\RoomPlayerInstanceService; use Inertia\Inertia; use Inertia\Response; @@ -16,6 +17,7 @@ class EmbeddedRoomController extends Controller public function __construct( private readonly RoomParticipationService $roomParticipation, private readonly RoomPageService $roomPage, + private readonly RoomPlayerInstanceService $playerInstances, ) {} public function __invoke(ShowEmbeddedRoomRequest $request): Response @@ -33,6 +35,11 @@ class EmbeddedRoomController extends Controller $validated['player_name'], $request->session()->get("room_players.{$room->id}"), ); + $player = $this->roomParticipation->activateInstance( + $room, + $player, + $this->playerInstances->for($request), + ); $request->session()->put("room_players.{$room->id}", $player->id); diff --git a/app/Http/Controllers/RoomController.php b/app/Http/Controllers/RoomController.php index 6a61835..aa67bd9 100644 --- a/app/Http/Controllers/RoomController.php +++ b/app/Http/Controllers/RoomController.php @@ -10,6 +10,7 @@ use App\Models\Player; use App\Models\Room; use App\Services\Rooms\RoomCreationService; use App\Services\Rooms\RoomPageService; +use App\Services\Rooms\RoomPlayerInstanceService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; @@ -20,6 +21,7 @@ class RoomController extends Controller public function __construct( private readonly RoomCreationService $roomCreation, private readonly RoomPageService $roomPage, + private readonly RoomPlayerInstanceService $playerInstances, ) {} public function create(Request $request): Response @@ -40,6 +42,9 @@ class RoomController extends Controller $validated, $request->user(), ); + $player->update([ + 'active_instance_id' => $this->playerInstances->for($request), + ]); $request->session()->put( "room_players.{$room->id}", diff --git a/app/Http/Controllers/RoomPlayerController.php b/app/Http/Controllers/RoomPlayerController.php index d810782..d2b60f0 100644 --- a/app/Http/Controllers/RoomPlayerController.php +++ b/app/Http/Controllers/RoomPlayerController.php @@ -4,12 +4,14 @@ namespace App\Http\Controllers; use App\Http\Requests\JoinRoomRequest; use App\Services\Rooms\RoomParticipationService; +use App\Services\Rooms\RoomPlayerInstanceService; use Illuminate\Http\RedirectResponse; class RoomPlayerController extends Controller { public function __construct( private readonly RoomParticipationService $roomParticipation, + private readonly RoomPlayerInstanceService $playerInstances, ) {} public function join(JoinRoomRequest $request): RedirectResponse @@ -23,6 +25,11 @@ class RoomPlayerController extends Controller $request->user(), is_array($sessionPlayerIds) ? $sessionPlayerIds : [], ); + $player = $this->roomParticipation->activateInstance( + $room, + $player, + $this->playerInstances->for($request), + ); $request->session()->put( "room_players.{$room->id}", diff --git a/app/Http/Middleware/EnsureRoomParticipant.php b/app/Http/Middleware/EnsureRoomParticipant.php index 7cdbdd2..3307592 100644 --- a/app/Http/Middleware/EnsureRoomParticipant.php +++ b/app/Http/Middleware/EnsureRoomParticipant.php @@ -3,6 +3,8 @@ namespace App\Http\Middleware; use App\Models\Room; +use App\Services\Rooms\RoomParticipationService; +use App\Services\Rooms\RoomPlayerInstanceService; use Closure; use Illuminate\Http\Request; use Inertia\Inertia; @@ -10,6 +12,11 @@ use Symfony\Component\HttpFoundation\Response; class EnsureRoomParticipant { + public function __construct( + private readonly RoomParticipationService $roomParticipation, + private readonly RoomPlayerInstanceService $playerInstances, + ) {} + /** * Handle an incoming request. * @@ -44,13 +51,43 @@ class EnsureRoomParticipant } $playerId = $request->session()->get($sessionKey); + $hasSessionPlayer = is_numeric($playerId); + $instanceId = $this->playerInstances->for($request); - $player = is_numeric($playerId) + $player = $hasSessionPlayer ? $room->players()->find((int) $playerId) : null; - if ($player === null && $playerId) { + if ($player === null && $hasSessionPlayer) { $request->session()->forget($sessionKey); + + return to_route('room.join', [ + 'join_code' => $room->join_code, + ])->withErrors([ + 'join_code' => 'Diese Spieler-Instanz ist nicht mehr aktiv.', + ]); + } + + if ( + $player !== null + && $player->active_instance_id !== null + && $player->active_instance_id !== $instanceId + ) { + $request->session()->forget($sessionKey); + + return to_route('room.join', [ + 'join_code' => $room->join_code, + ])->withErrors([ + 'join_code' => 'Dein Account ist diesem Raum in einer anderen Instanz beigetreten.', + ]); + } + + if ($player !== null) { + $player = $this->roomParticipation->activateInstance( + $room, + $player, + $instanceId, + ); } if ($player === null && $request->user() !== null) { @@ -59,6 +96,11 @@ class EnsureRoomParticipant ->first(); if ($player !== null) { + $player = $this->roomParticipation->activateInstance( + $room, + $player, + $instanceId, + ); $request->session()->put($sessionKey, $player->id); } } diff --git a/app/Http/Requests/ShowEmbeddedRoomRequest.php b/app/Http/Requests/ShowEmbeddedRoomRequest.php index 2e14d7a..5d423c1 100644 --- a/app/Http/Requests/ShowEmbeddedRoomRequest.php +++ b/app/Http/Requests/ShowEmbeddedRoomRequest.php @@ -13,6 +13,8 @@ use Inertia\Inertia; class ShowEmbeddedRoomRequest extends FormRequest { + private const string PLAYER_NAME_TAKEN_MESSAGE = 'Dieser Anzeigename ist in dem Raum bereits vorhanden.'; + /** * Determine if the user is authorized to make this request. */ @@ -72,16 +74,18 @@ class ShowEmbeddedRoomRequest extends FormRequest } $sessionPlayerId = $this->session()->get("room_players.{$room->id}"); - $existingPlayer = is_numeric($sessionPlayerId) - ? $room->players()->find((int) $sessionPlayerId) - : null; + $existingPlayer = null; - if ($existingPlayer === null && $this->user() !== null) { + if ($this->user() !== null) { $existingPlayer = $room->players() ->where('user_id', $this->user()->id) ->first(); } + if ($existingPlayer === null && is_numeric($sessionPlayerId)) { + $existingPlayer = $room->players()->find((int) $sessionPlayerId); + } + $nameIsTaken = Player::query() ->where('room_id', $room->id) ->where('name', $this->input('player_name')) @@ -94,7 +98,7 @@ class ShowEmbeddedRoomRequest extends FormRequest if ($nameIsTaken) { $validator->errors()->add( 'player_name', - 'Dieser Anzeigename ist in dem Raum bereits vorhanden.', + self::PLAYER_NAME_TAKEN_MESSAGE, ); } }, @@ -112,9 +116,18 @@ class ShowEmbeddedRoomRequest extends FormRequest protected function failedValidation(Validator $validator): never { + $message = $validator->errors()->first(); + $showLoginAction = $this->user() === null + && $message === self::PLAYER_NAME_TAKEN_MESSAGE; + + if ($showLoginAction) { + $this->session()->put('url.intended', $this->fullUrl()); + } + throw new HttpResponseException( Inertia::render('embedded/Error', [ - 'message' => $validator->errors()->first(), + 'message' => $message, + 'showLoginAction' => $showLoginAction, ])->toResponse($this)->setStatusCode(422), ); } diff --git a/app/Http/Resources/CurrentPlayerResource.php b/app/Http/Resources/CurrentPlayerResource.php index d23a471..674eee2 100644 --- a/app/Http/Resources/CurrentPlayerResource.php +++ b/app/Http/Resources/CurrentPlayerResource.php @@ -19,6 +19,7 @@ class CurrentPlayerResource extends JsonResource return [ 'id' => $player->id, + 'instance_id' => $player->active_instance_id, 'name' => $player->name, 'is_moderator' => $player->is_moderator, 'current_vote' => $pageData->currentVote, diff --git a/app/Models/Player.php b/app/Models/Player.php index 910dd0b..585bd58 100644 --- a/app/Models/Player.php +++ b/app/Models/Player.php @@ -12,6 +12,7 @@ use Illuminate\Support\Carbon; * @property int $id * @property string $room_id * @property int|null $user_id + * @property string|null $active_instance_id * @property string $name * @property bool $is_moderator * @property Carbon|null $last_seen_at @@ -25,6 +26,7 @@ class Player extends Model protected $fillable = [ 'room_id', 'user_id', + 'active_instance_id', 'name', 'is_moderator', 'last_seen_at', diff --git a/app/Services/Rooms/RoomParticipationService.php b/app/Services/Rooms/RoomParticipationService.php index 40b6724..14008d1 100644 --- a/app/Services/Rooms/RoomParticipationService.php +++ b/app/Services/Rooms/RoomParticipationService.php @@ -205,6 +205,17 @@ class RoomParticipationService ?User $user, mixed $sessionPlayerId, ): ?Player { + if ($user !== null) { + $userPlayer = $room->players() + ->where('user_id', $user->id) + ->lockForUpdate() + ->first(); + + if ($userPlayer !== null) { + return $userPlayer; + } + } + if (is_numeric($sessionPlayerId)) { $sessionPlayer = $room->players() ->whereKey((int) $sessionPlayerId) @@ -216,14 +227,53 @@ class RoomParticipationService } } - if ($user === null) { - return null; + return null; + } + + public function activateInstance(Room $room, Player $player, string $instanceId): Player + { + [$activePlayer, $replacedInstanceId, $stateVersion] = DB::transaction(function () use ($room, $player, $instanceId): array { + $lockedRoom = Room::query() + ->lockForUpdate() + ->findOrFail($room->id); + $lockedPlayer = $lockedRoom->players() + ->lockForUpdate() + ->findOrFail($player->id); + + if ($lockedPlayer->active_instance_id === $instanceId) { + return [$lockedPlayer, null, null]; + } + + $replacedInstanceId = $lockedPlayer->active_instance_id; + $lockedPlayer->update([ + 'active_instance_id' => $instanceId, + 'last_seen_at' => now(), + ]); + + if ($replacedInstanceId === null) { + return [$lockedPlayer, null, null]; + } + + $lockedRoom->update([ + 'last_activity_at' => now(), + 'state_version' => $lockedRoom->state_version + 1, + ]); + + return [$lockedPlayer, $replacedInstanceId, $lockedRoom->state_version]; + }); + + if (is_string($replacedInstanceId) && is_int($stateVersion)) { + RoomStateChanged::dispatch( + $room->id, + $stateVersion, + RoomStateChange::PlayerInstanceReplaced, + $this->realtimeState->for($room), + $activePlayer->id, + $replacedInstanceId, + ); } - return $room->players() - ->where('user_id', $user->id) - ->lockForUpdate() - ->first(); + return $activePlayer; } public function kick( diff --git a/app/Services/Rooms/RoomPlayerInstanceService.php b/app/Services/Rooms/RoomPlayerInstanceService.php new file mode 100644 index 0000000..b864048 --- /dev/null +++ b/app/Services/Rooms/RoomPlayerInstanceService.php @@ -0,0 +1,25 @@ +session()->get(self::SESSION_KEY); + + if (is_string($instanceId) && Str::isUuid($instanceId)) { + return $instanceId; + } + + $instanceId = (string) Str::uuid(); + $request->session()->put(self::SESSION_KEY, $instanceId); + + return $instanceId; + } +} diff --git a/database/migrations/2026_09_03_142925_add_active_instance_id_to_players_table.php b/database/migrations/2026_09_03_142925_add_active_instance_id_to_players_table.php new file mode 100644 index 0000000..31627e2 --- /dev/null +++ b/database/migrations/2026_09_03_142925_add_active_instance_id_to_players_table.php @@ -0,0 +1,28 @@ +uuid('active_instance_id')->nullable()->after('user_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('players', function (Blueprint $table) { + $table->dropColumn('active_instance_id'); + }); + } +}; diff --git a/resources/js/composables/useRoomRealtime.ts b/resources/js/composables/useRoomRealtime.ts index fd9d5e4..5760342 100644 --- a/resources/js/composables/useRoomRealtime.ts +++ b/resources/js/composables/useRoomRealtime.ts @@ -42,6 +42,16 @@ export function useRoomRealtime( return; } + if ( + payload.change === 'player_instance_replaced' && + payload.affected_player_id === currentPlayer.value.id && + payload.affected_instance_id === currentPlayer.value.instance_id + ) { + router.visit(home().url, { replace: true }); + + return; + } + if (payload.change === 'room_closed') { router.visit( join({ query: { join_code: room.value.join_code } }).url, diff --git a/resources/js/pages/embedded/Error.vue b/resources/js/pages/embedded/Error.vue index 7bb4522..c462861 100644 --- a/resources/js/pages/embedded/Error.vue +++ b/resources/js/pages/embedded/Error.vue @@ -1,9 +1,12 @@ @@ -19,6 +22,13 @@ defineProps<{

Raum kann nicht geöffnet werden

{{ message }}

+ + Anmelden +

Bitte ändere Raumcode oder Anzeigename auf der Home-Seite der Fillibri Hub App und öffne Scrum Poker erneut. diff --git a/resources/js/types/room.ts b/resources/js/types/room.ts index 952e86d..d89fa3f 100644 --- a/resources/js/types/room.ts +++ b/resources/js/types/room.ts @@ -30,6 +30,7 @@ export type RoomPlayer = { export type CurrentRoomPlayer = { id: number; + instance_id: string; name: string; is_moderator: boolean; current_vote: string | null; @@ -163,6 +164,7 @@ export type RoomStateChange = | 'player_joined' | 'player_updated' | 'player_kicked' + | 'player_instance_replaced' | 'vote_changed' | 'cards_revealed' | 'votes_reset' @@ -177,6 +179,7 @@ export type RoomStateChangedPayload = { change: RoomStateChange; state: RoomRealtimeState | null; affected_player_id?: number; + affected_instance_id?: string; }; export type RoomRealtimeState = Pick< diff --git a/tests/Feature/EmbeddedRoomTest.php b/tests/Feature/EmbeddedRoomTest.php index 812fecb..ff10a2b 100644 --- a/tests/Feature/EmbeddedRoomTest.php +++ b/tests/Feature/EmbeddedRoomTest.php @@ -1,7 +1,10 @@ create([ + 'email' => 'nico@example.com', + ]); $room = createEmbeddedRoom(); - $room->players()->create(['name' => 'Nico']); - - $this->get(route('embedded.room', [ + $room->players()->create([ + 'user_id' => $user->id, + 'name' => 'Nico', + ]); + $url = route('embedded.room', [ 'join_code' => $room->join_code, 'player_name' => 'Nico', - ])) + ]); + + $this->get($url) ->assertUnprocessable() ->assertInertia(fn (Assert $page) => $page ->component('embedded/Error') - ->where('message', 'Dieser Anzeigename ist in dem Raum bereits vorhanden.')); + ->where('message', 'Dieser Anzeigename ist in dem Raum bereits vorhanden.') + ->where('showLoginAction', true)) + ->assertSessionHas('url.intended', $url); + + $this->post(route('login.store'), [ + 'email' => 'nico@example.com', + 'password' => 'password', + ])->assertRedirect($url); + + $this->get($url) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('embedded/Room') + ->where('currentPlayer.name', 'Nico')); +}); + +test('a new account instance takes over the embedded player and kicks the previous instance', function () { + Event::fake([RoomStateChanged::class]); + $user = User::factory()->create(); + $room = createEmbeddedRoom(); + $player = $room->players()->create([ + 'user_id' => $user->id, + 'name' => 'Nico', + 'active_instance_id' => '019b8f80-37c4-71a3-b415-5f2697332c16', + ]); + $url = route('embedded.room', [ + 'join_code' => $room->join_code, + 'player_name' => 'Nico', + ]); + + $this->actingAs($user) + ->withSession([ + 'room_player_instance_id' => '019b8f80-37c4-71a3-b415-5f2697332c17', + ]) + ->get($url) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->where('currentPlayer.id', $player->id) + ->where('currentPlayer.instance_id', '019b8f80-37c4-71a3-b415-5f2697332c17')); + + expect($player->fresh()->active_instance_id) + ->toBe('019b8f80-37c4-71a3-b415-5f2697332c17'); + + Event::assertDispatched( + RoomStateChanged::class, + fn (RoomStateChanged $event): bool => $event->change === RoomStateChange::PlayerInstanceReplaced + && $event->affectedPlayerId === $player->id + && $event->affectedInstanceId === '019b8f80-37c4-71a3-b415-5f2697332c16', + ); + + $this->withSession([ + "room_players.{$room->id}" => $player->id, + 'room_player_instance_id' => '019b8f80-37c4-71a3-b415-5f2697332c16', + ]) + ->post(route('room.vote.store', $room), ['value' => '5']) + ->assertRedirect(route('room.join', ['join_code' => $room->join_code])); + + $this->assertDatabaseCount('votes', 0); }); test('embedded access shows a dedicated error for an unknown room code', function () { @@ -151,7 +218,8 @@ test('embedded access shows a dedicated error for an unknown room code', functio ->assertUnprocessable() ->assertInertia(fn (Assert $page) => $page ->component('embedded/Error') - ->where('message', 'Dieser Raumcode existiert nicht.')); + ->where('message', 'Dieser Raumcode existiert nicht.') + ->where('showLoginAction', false)); }); test('embedded logout returns to the same room as a guest without duplicating the player', function () { diff --git a/tests/Feature/RoomBroadcastingTest.php b/tests/Feature/RoomBroadcastingTest.php index c76ce5a..5586471 100644 --- a/tests/Feature/RoomBroadcastingTest.php +++ b/tests/Feature/RoomBroadcastingTest.php @@ -45,6 +45,25 @@ test('room state broadcasts synchronously expose sanitized state', function () { ]); }); +test('player instance replacement broadcasts the displaced instance', function () { + $event = new RoomStateChanged( + '019b8f80-37c4-71a3-b415-5f2697332c16', + 8, + RoomStateChange::PlayerInstanceReplaced, + null, + 42, + '019b8f80-37c4-71a3-b415-5f2697332c17', + ); + + expect($event->broadcastWith())->toBe([ + 'version' => 8, + 'change' => 'player_instance_replaced', + 'state' => null, + 'affected_player_id' => 42, + 'affected_instance_id' => '019b8f80-37c4-71a3-b415-5f2697332c17', + ]); +}); + test('joining a room broadcasts the new room version', function () { Event::fake([RoomStateChanged::class]); $room = createBroadcastRoom(); diff --git a/tests/Feature/RoomLifecycleTest.php b/tests/Feature/RoomLifecycleTest.php index f030667..af6bc1f 100644 --- a/tests/Feature/RoomLifecycleTest.php +++ b/tests/Feature/RoomLifecycleTest.php @@ -1,11 +1,14 @@ assertSessionHas("room_players.{$room->id}", $player->id); }); +test('a new account instance takes over the player during a regular room join', function () { + $user = User::factory()->create(); + $room = createLifecycleRoom(); + $player = $room->players()->create([ + 'user_id' => $user->id, + 'name' => 'Room nickname', + 'active_instance_id' => '019b8f80-37c4-71a3-b415-5f2697332c16', + ]); + Event::fake([RoomStateChanged::class]); + + $this->actingAs($user) + ->withSession([ + 'room_player_instance_id' => '019b8f80-37c4-71a3-b415-5f2697332c17', + ]) + ->post(route('room.code'), [ + 'join_code' => $room->join_code, + 'player_name' => 'Room nickname', + ]) + ->assertRedirect(route('room.show', $room)); + + expect($player->fresh()->active_instance_id) + ->toBe('019b8f80-37c4-71a3-b415-5f2697332c17'); + + Event::assertDispatched( + RoomStateChanged::class, + fn (RoomStateChanged $event): bool => $event->change === RoomStateChange::PlayerInstanceReplaced + && $event->affectedPlayerId === $player->id, + ); +}); + test('an authenticated player can restore room access without the previous session', function () { $user = User::factory()->create(); $room = createLifecycleRoom(['owner_id' => $user->id]);