From be7004f63316027c3e407afb10c566da39da7fa3 Mon Sep 17 00:00:00 2001 From: Exil Productions Date: Wed, 10 Dec 2025 20:49:58 +0100 Subject: [PATCH] ReAdd Webscokets and ready all --- .../Middleware/AuthenticationMiddleware.cs | 3 +- PurrLobby/Program.cs | 76 +++++++++++++++++-- PurrLobby/Services/LobbyService.cs | 37 +++++++++ 3 files changed, 110 insertions(+), 6 deletions(-) diff --git a/PurrLobby/Middleware/AuthenticationMiddleware.cs b/PurrLobby/Middleware/AuthenticationMiddleware.cs index 00e51b7..b017d1e 100644 --- a/PurrLobby/Middleware/AuthenticationMiddleware.cs +++ b/PurrLobby/Middleware/AuthenticationMiddleware.cs @@ -13,7 +13,8 @@ public class AuthenticationMiddleware { "/auth/create", "/health", - "/metrics" + "/metrics", + "/ws/" }; public AuthenticationMiddleware(RequestDelegate next, IAuthenticationService authService) diff --git a/PurrLobby/Program.cs b/PurrLobby/Program.cs index 0772919..2b5c13b 100644 --- a/PurrLobby/Program.cs +++ b/PurrLobby/Program.cs @@ -266,6 +266,20 @@ static bool TryGetGameIdFromCookie(HttpRequest request, out Guid gameId) return Guid.TryParse(v, out gameId); } +// WebSocket token extraction helper +static string? ExtractTokenForWebSocket(HttpContext context) +{ + // Try Authorization header first + var authHeader = context.Request.Headers.Authorization.FirstOrDefault(); + if (!string.IsNullOrEmpty(authHeader) && authHeader.StartsWith("Bearer ")) + { + return authHeader["Bearer ".Length..]; + } + + // Fall back to query parameter + return context.Request.Query["token"].FirstOrDefault(); +} + // create lobby @@ -500,6 +514,32 @@ app.MapPost("/lobbies/{lobbyId}/ready", async (HttpContext http, ILobbyService s .Produces(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status400BadRequest); +// set everyone ready +app.MapPost("/lobbies/{lobbyId}/ready-all", async (HttpContext http, ILobbyService service, string lobbyId, CancellationToken ct) => +{ + if (!TryGetGameIdFromCookie(http.Request, out var gameId)) + return Results.BadRequest("Missing or invalid gameId cookie"); + + var user = http.Items["User"] as AuthenticatedUser; + if (user == null) + return Results.Unauthorized(); + + var ok = await service.SetEveryoneReadyAsync(gameId, lobbyId, user.SessionToken, ct); + return ok ? Results.Ok() : Results.NotFound(); +}) +.WithTags("Lobbies") +.WithOpenApi(op => +{ + op.Summary = "Set all members ready"; + op.Description = "Owner only. Sets all lobby members as ready. Broadcastes an everyone_ready event with affected members list."; + return op; +}) +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status403Forbidden) +.Produces(StatusCodes.Status404NotFound) +.Produces(StatusCodes.Status400BadRequest) +.ProducesProblem(StatusCodes.Status400BadRequest); + // start lobby app.MapPost("/lobbies/{lobbyId}/start", async (HttpContext http, ILobbyService service, string lobbyId, CancellationToken ct) => { @@ -526,7 +566,7 @@ app.MapPost("/lobbies/{lobbyId}/start", async (HttpContext http, ILobbyService s .Produces(StatusCodes.Status400BadRequest); // lobby websocket -app.Map("/ws/lobbies/{lobbyId}", async (HttpContext http, string lobbyId, ILobbyEventHub hub, CancellationToken ct) => +app.Map("/ws/lobbies/{lobbyId}", async (HttpContext http, string lobbyId, ILobbyEventHub hub, IAuthenticationService authService, CancellationToken ct) => { if (!http.WebSockets.IsWebSocketRequest) return Results.BadRequest("Expected WebSocket"); @@ -534,17 +574,43 @@ app.Map("/ws/lobbies/{lobbyId}", async (HttpContext http, string lobbyId, ILobby if (!TryGetGameIdFromCookie(http.Request, out var gameId)) return Results.BadRequest("Missing or invalid gameId cookie"); - var user = http.Items["User"] as AuthenticatedUser; - if (user == null) + // Extract token from Authorization header or query parameter for WebSocket + var token = ExtractTokenForWebSocket(http); + if (string.IsNullOrEmpty(token)) + return Results.Unauthorized(); + + var validation = await authService.ValidateTokenAsync(token, ct); + if (!validation.IsValid) return Results.Unauthorized(); using var socket = await http.WebSockets.AcceptWebSocketAsync(); - await hub.HandleConnectionAsync(gameId, lobbyId, user.SessionToken, socket, ct); + await hub.HandleConnectionAsync(gameId, lobbyId, token, socket, ct); return Results.Empty; }).WithTags("Lobbies").WithOpenApi(op => { op.Summary = "Lobby Websocket"; - op.Description = "WebSocket for lobby-specific updates. Requires valid session token."; + op.Description = @"WebSocket for real-time lobby-specific updates. Requires valid session token provided via Authorization header or 'token' query parameter. + +Authentication: +- Bearer token in Authorization header: 'Authorization: Bearer ' +- Or token as query parameter: '?token=' + +WebSocket Events: +- lobby_created: New lobby created +- member_joined: User joined the lobby +- member_left: User left the lobby +- member_ready: User ready state changed +- everyone_ready: Owner set all members as ready (includes affectedMembers array) +- lobby_data: Lobby property updated +- lobby_started: Lobby started by owner +- lobby_empty: Lobby closed due to no members +- lobby_deleted: Lobby forcefully closed +- ping: Server heartbeat (respond with 'pong') + +Connection Requirements: +- Valid gameId cookie must be set +- Valid session token required +- User must be member of the lobby"; return op; }); diff --git a/PurrLobby/Services/LobbyService.cs b/PurrLobby/Services/LobbyService.cs index a50d899..0addb8b 100644 --- a/PurrLobby/Services/LobbyService.cs +++ b/PurrLobby/Services/LobbyService.cs @@ -12,6 +12,7 @@ public interface ILobbyService Task LeaveLobbyAsync(Guid gameId, string sessionToken, CancellationToken ct = default); Task> SearchLobbiesAsync(Guid gameId, int maxRoomsToFind, Dictionary? filters, CancellationToken ct = default); Task SetIsReadyAsync(Guid gameId, string lobbyId, string sessionToken, bool isReady, CancellationToken ct = default); + Task SetEveryoneReadyAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default); Task SetLobbyDataAsync(Guid gameId, string lobbyId, string sessionToken, string key, string value, CancellationToken ct = default); Task GetLobbyDataAsync(Guid gameId, string lobbyId, string key, CancellationToken ct = default); Task> GetLobbyMembersAsync(Guid gameId, string lobbyId, CancellationToken ct = default); @@ -321,6 +322,42 @@ public class LobbyService : ILobbyService return true; } + public async Task SetEveryoneReadyAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default) + { + if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(sessionToken)) + return false; + + var validation = await _authService.ValidateTokenAsync(sessionToken, ct); + if (!validation.IsValid) + return false; + + if (!_lobbies.TryGetValue(lobbyId, out var state)) + return false; + if (state.GameId != gameId) + return false; + + // Only owner can set everyone ready + if (state.OwnerUserId != validation.UserId) + return false; + + List membersToUpdate; + lock (state) + { + if (state.Started) return false; + membersToUpdate = state.Members.Where(m => !m.IsReady).ToList(); + foreach (var m in membersToUpdate) + { + m.IsReady = true; + } + } + + if (membersToUpdate.Count > 0) + { + _ = _events.BroadcastAsync(gameId, lobbyId, new { type = "everyone_ready", userId = validation.UserId, affectedMembers = membersToUpdate.Select(m => new { m.UserId, m.DisplayName }) }, ct); + } + return true; + } + public async Task SetLobbyDataAsync(Guid gameId, string lobbyId, string sessionToken, string key, string value, CancellationToken ct = default) { if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(sessionToken))