diff --git a/PurrLobby/Program.cs b/PurrLobby/Program.cs index 73d7161..23200d8 100644 --- a/PurrLobby/Program.cs +++ b/PurrLobby/Program.cs @@ -8,26 +8,18 @@ using Microsoft.OpenApi.Models; using PurrLobby.Services; using PurrLobby.Models; -// boot app + var builder = WebApplication.CreateBuilder(args); - -// problem details builder.Services.AddProblemDetails(); - -// basic http logging builder.Services.AddHttpLogging(o => { o.LoggingFields = HttpLoggingFields.RequestScheme | HttpLoggingFields.RequestMethod | HttpLoggingFields.RequestPath | HttpLoggingFields.ResponseStatusCode; }); - -// compression brotli gzip builder.Services.AddResponseCompression(o => { o.Providers.Add(); o.Providers.Add(); }); - -// rate limit per ip builder.Services.AddRateLimiter(o => { o.GlobalLimiter = PartitionedRateLimiter.Create(context => @@ -42,17 +34,18 @@ builder.Services.AddRateLimiter(o => }); }); o.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + o.OnRejected = async (context, ct) => + { + context.HttpContext.Response.Headers.RetryAfter = "60"; + await context.HttpContext.Response.WriteAsync("Woah there partner calm down, you sending to much info :O", ct); + }; }); - -// trust proxy headers builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost; options.KnownNetworks.Clear(); options.KnownProxies.Clear(); }); - -// swagger setup builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(o => { @@ -72,6 +65,14 @@ builder.Services.AddSwaggerGen(o => Description = "Game scope cookie set by POST /session/game." }); + o.AddSecurityDefinition("userIdCookie", new OpenApiSecurityScheme + { + Type = SecuritySchemeType.ApiKey, + In = ParameterLocation.Cookie, + Name = "userId", + Description = "Player identity cookie generated by the server (POST /session/user)." + }); + o.AddSecurityRequirement(new OpenApiSecurityRequirement { { @@ -83,14 +84,9 @@ builder.Services.AddSwaggerGen(o => } }); }); - -// service singletons builder.Services.AddSingleton(); builder.Services.AddSingleton(); - var app = builder.Build(); - -// prod vs dev if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler(); @@ -101,39 +97,32 @@ else app.UseDeveloperExceptionPage(); } app.UseHttpsRedirection(); - -// middleware order app.UseForwardedHeaders(); app.UseResponseCompression(); app.UseHttpLogging(); app.UseRateLimiter(); app.UseWebSockets(); - -// static files app.UseDefaultFiles(); app.UseStaticFiles(); - -// swagger ui app.UseSwagger(); app.UseSwaggerUI(); +static CookieOptions BuildStdCookieOptions() => new() +{ + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Lax, + IsEssential = true, + Expires = DateTimeOffset.UtcNow.AddDays(7), + Domain = "purrlobby.exil.dev" +}; -// set gameId cookie +// set / update gameId cookie app.MapPost("/session/game", (HttpContext http, SetGameRequest req) => { var gameId = req.GameId; if (gameId == Guid.Empty) return Results.BadRequest("Invalid GameId"); - - var opts = new CookieOptions - { - HttpOnly = true, - Secure = true, - SameSite = SameSiteMode.Lax, - IsEssential = true, - Expires = DateTimeOffset.UtcNow.AddDays(7), - Domain = "purrlobby.exil.dev" - }; - http.Response.Cookies.Append("gameId", gameId.ToString(), opts); + http.Response.Cookies.Append("gameId", gameId.ToString(), BuildStdCookieOptions()); return Results.Ok(new { message = "GameId stored" }); }) .WithTags("Session") @@ -149,7 +138,27 @@ app.MapPost("/session/game", (HttpContext http, SetGameRequest req) => .Produces(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status400BadRequest); -// cookie helper +// create / ensure userId cookie +app.MapPost("/session/user", (HttpContext http) => +{ + if (!http.Request.Cookies.TryGetValue("userId", out var existing) || string.IsNullOrWhiteSpace(existing)) + { + existing = Guid.NewGuid().ToString("N"); + http.Response.Cookies.Append("userId", existing, BuildStdCookieOptions()); + } + return Results.Ok(new { userId = existing }); +}) +.WithTags("Session") +.WithOpenApi(op => +{ + op.Summary = "Ensure a server-generated user id"; + op.Description = "Creates (if absent) and returns the server-generated 'userId' cookie used to identify the player. No input required."; + op.Security.Clear(); + return op; +}) +.Produces(StatusCodes.Status200OK); + +// cookie helpers static bool TryGetGameIdFromCookie(HttpRequest request, out Guid gameId) { gameId = Guid.Empty; @@ -157,20 +166,38 @@ static bool TryGetGameIdFromCookie(HttpRequest request, out Guid gameId) return Guid.TryParse(v, out gameId); } +static string EnsureUserIdCookie(HttpContext http) +{ + if (http.Request.Cookies.TryGetValue("userId", out var v) && !string.IsNullOrWhiteSpace(v)) + return v; + var newId = Guid.NewGuid().ToString("N"); + http.Response.Cookies.Append("userId", newId, BuildStdCookieOptions()); + return newId; +} + // create lobby app.MapPost("/lobbies", async (HttpContext http, ILobbyService service, CreateLobbyRequest req, CancellationToken ct) => { if (!TryGetGameIdFromCookie(http.Request, out var gameId)) return Results.BadRequest("Missing or invalid gameId cookie"); if (req.MaxPlayers <= 0) return Results.BadRequest("MaxPlayers must be > 0"); - var lobby = await service.CreateLobbyAsync(gameId, req.OwnerUserId, req.OwnerDisplayName, req.MaxPlayers, req.Properties, ct); - return Results.Ok(lobby); + if (string.IsNullOrWhiteSpace(req.OwnerDisplayName)) return Results.BadRequest("OwnerDisplayName required"); + try + { + var ownerUserId = EnsureUserIdCookie(http); + var lobby = await service.CreateLobbyAsync(gameId, ownerUserId, req.OwnerDisplayName, req.MaxPlayers, req.Properties, ct); + return Results.Ok(lobby); + } + catch (ArgumentException ax) + { + return Results.BadRequest(ax.Message); + } }) .WithTags("Lobbies") .WithOpenApi(op => { op.Summary = "Create a lobby"; - op.Description = "Creates a new lobby for the current game (scoped by the 'gameId' cookie). The creator is added as the owner and first member."; + op.Description = "Creates a new lobby for the current game. Owner user id is server generated (cookie 'userId')."; return op; }) .Accepts("application/json") @@ -183,13 +210,15 @@ app.MapPost("/lobbies/{lobbyId}/join", async (HttpContext http, ILobbyService se { if (!TryGetGameIdFromCookie(http.Request, out var gameId)) return Results.BadRequest("Missing or invalid gameId cookie"); - var lobby = await service.JoinLobbyAsync(gameId, lobbyId, req.UserId, req.DisplayName, ct); + if (string.IsNullOrWhiteSpace(req.DisplayName)) return Results.BadRequest("DisplayName required"); + var userId = EnsureUserIdCookie(http); + var lobby = await service.JoinLobbyAsync(gameId, lobbyId, userId, req.DisplayName, ct); return lobby is null ? Results.NotFound() : Results.Ok(lobby); }).WithTags("Lobbies") .WithOpenApi(op => { op.Summary = "Join a lobby"; - op.Description = "Adds the user to the specified lobby if it belongs to the current game and has capacity."; + op.Description = "Adds the current cookie user (server generated userId) to the specified lobby."; return op; }) .Accepts("application/json") @@ -199,20 +228,20 @@ app.MapPost("/lobbies/{lobbyId}/join", async (HttpContext http, ILobbyService se .ProducesProblem(StatusCodes.Status400BadRequest); // leave lobby by id -app.MapPost("/lobbies/{lobbyId}/leave", async (HttpContext http, ILobbyService service, string lobbyId, LeaveLobbyRequest req, CancellationToken ct) => +app.MapPost("/lobbies/{lobbyId}/leave", 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 ok = await service.LeaveLobbyAsync(gameId, lobbyId, req.UserId, ct); + var userId = EnsureUserIdCookie(http); + var ok = await service.LeaveLobbyAsync(gameId, lobbyId, userId, ct); return ok ? Results.Ok() : Results.NotFound(); }).WithTags("Lobbies") .WithOpenApi(op => { op.Summary = "Leave a lobby"; - op.Description = "Removes the user from the lobby. If the owner leaves, ownership transfers to the first remaining member. Empty lobbies are deleted."; + op.Description = "Removes the current cookie user from the lobby."; return op; }) -.Accepts("application/json") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status400BadRequest) @@ -229,7 +258,7 @@ app.MapPost("/users/{userId}/leave", async (HttpContext http, ILobbyService serv .WithOpenApi(op => { op.Summary = "Force a user to leave their lobby"; - op.Description = "Removes the user from whichever lobby they are currently in for the current game."; + op.Description = "Removes the specified user from whichever lobby they are currently in for the current game."; return op; }) .Produces(StatusCodes.Status200OK) @@ -264,6 +293,26 @@ app.MapGet("/lobbies/search", async (HttpContext http, ILobbyService service, in .Produces(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status400BadRequest); +// get lobby by id +app.MapGet("/lobbies/{lobbyId}", 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 userId = EnsureUserIdCookie(http); + var lobby = await service.GetLobbyAsync(gameId, lobbyId, userId, ct); + return lobby is null ? Results.NotFound() : Results.Ok(lobby); +}) +.WithTags("Lobbies") +.WithOpenApi(op => +{ + op.Summary = "Get lobby details"; + op.Description = "Returns the lobby object including ownership flag relative to current cookie user."; + return op; +}) +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status404NotFound) +.Produces(StatusCodes.Status400BadRequest); + // lobby members app.MapGet("/lobbies/{lobbyId}/members", async (HttpContext http, ILobbyService service, string lobbyId, CancellationToken ct) => { @@ -331,7 +380,7 @@ app.MapPost("/lobbies/{lobbyId}/ready", async (HttpContext http, ILobbyService s { if (!TryGetGameIdFromCookie(http.Request, out var gameId)) return Results.BadRequest("Missing or invalid gameId cookie"); - if (string.IsNullOrWhiteSpace(req.UserId)) return Results.BadRequest("Id is required"); + if (string.IsNullOrWhiteSpace(req.UserId)) return Results.BadRequest("UserId required"); var ok = await service.SetIsReadyAsync(gameId, lobbyId, req.UserId, req.IsReady, ct); return ok ? Results.Ok() : Results.NotFound(); }) @@ -339,7 +388,7 @@ app.MapPost("/lobbies/{lobbyId}/ready", async (HttpContext http, ILobbyService s .WithOpenApi(op => { op.Summary = "Set member ready state"; - op.Description = "Sets the readiness of a member in the specified lobby within the current game. Broadcasts a member_ready event to subscribers."; + op.Description = "Sets readiness for the specified user in the lobby. Request body must include userId and isReady. Broadcasts a member_ready event."; return op; }) .Accepts("application/json") @@ -348,6 +397,55 @@ app.MapPost("/lobbies/{lobbyId}/ready", async (HttpContext http, ILobbyService s .Produces(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status400BadRequest); +// set all ready +app.MapPost("/lobbies/{lobbyId}/ready/all", async (HttpContext http, ILobbyService service, string lobbyId, AllReadyRequest req, CancellationToken ct) => +{ + if (!TryGetGameIdFromCookie(http.Request, out var gameId)) + return Results.BadRequest("Missing or invalid gameId cookie"); + if (string.IsNullOrWhiteSpace(req.UserId)) return Results.BadRequest("UserId required"); + var lobby = await service.GetLobbyAsync(gameId, lobbyId, req.UserId, ct); + if (lobby is null) return Results.NotFound(); + if (!lobby.IsOwner) return Results.StatusCode(StatusCodes.Status403Forbidden); + var ok = await service.SetAllReadyAsync(gameId, lobbyId, ct); + return ok ? Results.Ok() : Results.BadRequest(); +}) +.WithTags("Lobbies") +.WithOpenApi(op => +{ + op.Summary = "Mark all members ready"; + op.Description = "Owner only. Sets all members' ready state to true and broadcasts all_ready. Requires userId in body identifying the acting owner."; + return op; +}) +.Accepts("application/json") +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status403Forbidden) +.Produces(StatusCodes.Status404NotFound) +.Produces(StatusCodes.Status400BadRequest); + +// start lobby +app.MapPost("/lobbies/{lobbyId}/start", 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 userId = EnsureUserIdCookie(http); + var lobby = await service.GetLobbyAsync(gameId, lobbyId, userId, ct); + if (lobby is null) return Results.NotFound(); + if (!lobby.IsOwner) return Results.StatusCode(StatusCodes.Status403Forbidden); + var ok = await service.SetLobbyStartedAsync(gameId, lobbyId, ct); + return ok ? Results.Ok() : Results.BadRequest(); +}) +.WithTags("Lobbies") +.WithOpenApi(op => +{ + op.Summary = "Start lobby"; + op.Description = "Owner only. Marks lobby as started and broadcasts lobby_started."; + return op; +}) +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status403Forbidden) +.Produces(StatusCodes.Status404NotFound) +.Produces(StatusCodes.Status400BadRequest); + // lobby websocket app.Map("/ws/lobbies/{lobbyId}", async (HttpContext http, string lobbyId, ILobbyEventHub hub, CancellationToken ct) => { @@ -357,9 +455,7 @@ 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 userId = http.Request.Query["userId"].ToString(); - if (string.IsNullOrWhiteSpace(userId)) - return Results.BadRequest("Missing userId query parameter"); + var userId = EnsureUserIdCookie(http); using var socket = await http.WebSockets.AcceptWebSocketAsync(); await hub.HandleConnectionAsync(gameId, lobbyId, userId, socket, ct); @@ -367,7 +463,7 @@ app.Map("/ws/lobbies/{lobbyId}", async (HttpContext http, string lobbyId, ILobby }).WithTags("Lobbies").WithOpenApi(op => { op.Summary = "Lobby Websocket"; - op.Description = "The Websocket that is used to recive lobby specifc updates"; + op.Description = "WebSocket for lobby-specific updates. Uses server-generated 'userId' cookie."; return op; }); @@ -406,8 +502,8 @@ app.Run(); // dto records public record SetGameRequest(Guid GameId); -public record CreateLobbyRequest(string OwnerUserId, string OwnerDisplayName, int MaxPlayers, Dictionary? Properties); -public record JoinLobbyRequest(string UserId, string DisplayName); -public record LeaveLobbyRequest(string UserId); +public record CreateLobbyRequest(string OwnerDisplayName, int MaxPlayers, Dictionary? Properties); +public record JoinLobbyRequest(string DisplayName); public record ReadyRequest(string UserId, bool IsReady); +public record AllReadyRequest(string UserId); public record LobbyDataRequest(string Key, string Value); diff --git a/PurrLobby/Services/LobbyService.cs b/PurrLobby/Services/LobbyService.cs index 89cab8a..9532a57 100644 --- a/PurrLobby/Services/LobbyService.cs +++ b/PurrLobby/Services/LobbyService.cs @@ -17,6 +17,7 @@ public interface ILobbyService Task> GetLobbyMembersAsync(Guid gameId, string lobbyId, CancellationToken ct = default); Task SetAllReadyAsync(Guid gameId, string lobbyId, CancellationToken ct = default); Task SetLobbyStartedAsync(Guid gameId, string lobbyId, CancellationToken ct = default); + Task GetLobbyAsync(Guid gameId, string lobbyId, string currentUserId, CancellationToken ct = default); // stats Task GetGlobalPlayerCountAsync(CancellationToken ct = default); @@ -363,6 +364,17 @@ public class LobbyService : ILobbyService return Task.FromResult(true); } + public Task GetLobbyAsync(Guid gameId, string lobbyId, string currentUserId, CancellationToken ct = default) + { + if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(currentUserId)) + return Task.FromResult(null); + if (!_lobbies.TryGetValue(lobbyId, out var state)) + return Task.FromResult(null); + if (state.GameId != gameId) + return Task.FromResult(null); + return Task.FromResult(Project(state, currentUserId)); + } + public Task GetGlobalPlayerCountAsync(CancellationToken ct = default) { var total = 0;