creating/joining/leaving works now finally

This commit is contained in:
Exil Productions
2025-09-12 19:15:37 +02:00
parent 3f46c6f5a5
commit 1e8b375bb9
2 changed files with 164 additions and 56 deletions

View File

@@ -8,26 +8,18 @@ using Microsoft.OpenApi.Models;
using PurrLobby.Services; using PurrLobby.Services;
using PurrLobby.Models; using PurrLobby.Models;
// boot app
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// problem details
builder.Services.AddProblemDetails(); builder.Services.AddProblemDetails();
// basic http logging
builder.Services.AddHttpLogging(o => builder.Services.AddHttpLogging(o =>
{ {
o.LoggingFields = HttpLoggingFields.RequestScheme | HttpLoggingFields.RequestMethod | HttpLoggingFields.RequestPath | HttpLoggingFields.ResponseStatusCode; o.LoggingFields = HttpLoggingFields.RequestScheme | HttpLoggingFields.RequestMethod | HttpLoggingFields.RequestPath | HttpLoggingFields.ResponseStatusCode;
}); });
// compression brotli gzip
builder.Services.AddResponseCompression(o => builder.Services.AddResponseCompression(o =>
{ {
o.Providers.Add<BrotliCompressionProvider>(); o.Providers.Add<BrotliCompressionProvider>();
o.Providers.Add<GzipCompressionProvider>(); o.Providers.Add<GzipCompressionProvider>();
}); });
// rate limit per ip
builder.Services.AddRateLimiter(o => builder.Services.AddRateLimiter(o =>
{ {
o.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context => o.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
@@ -42,17 +34,18 @@ builder.Services.AddRateLimiter(o =>
}); });
}); });
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests; 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<ForwardedHeadersOptions>(options => builder.Services.Configure<ForwardedHeadersOptions>(options =>
{ {
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost; options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost;
options.KnownNetworks.Clear(); options.KnownNetworks.Clear();
options.KnownProxies.Clear(); options.KnownProxies.Clear();
}); });
// swagger setup
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(o => builder.Services.AddSwaggerGen(o =>
{ {
@@ -72,6 +65,14 @@ builder.Services.AddSwaggerGen(o =>
Description = "Game scope cookie set by POST /session/game." 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 o.AddSecurityRequirement(new OpenApiSecurityRequirement
{ {
{ {
@@ -83,14 +84,9 @@ builder.Services.AddSwaggerGen(o =>
} }
}); });
}); });
// service singletons
builder.Services.AddSingleton<ILobbyEventHub, LobbyEventHub>(); builder.Services.AddSingleton<ILobbyEventHub, LobbyEventHub>();
builder.Services.AddSingleton<ILobbyService, LobbyService>(); builder.Services.AddSingleton<ILobbyService, LobbyService>();
var app = builder.Build(); var app = builder.Build();
// prod vs dev
if (!app.Environment.IsDevelopment()) if (!app.Environment.IsDevelopment())
{ {
app.UseExceptionHandler(); app.UseExceptionHandler();
@@ -101,39 +97,32 @@ else
app.UseDeveloperExceptionPage(); app.UseDeveloperExceptionPage();
} }
app.UseHttpsRedirection(); app.UseHttpsRedirection();
// middleware order
app.UseForwardedHeaders(); app.UseForwardedHeaders();
app.UseResponseCompression(); app.UseResponseCompression();
app.UseHttpLogging(); app.UseHttpLogging();
app.UseRateLimiter(); app.UseRateLimiter();
app.UseWebSockets(); app.UseWebSockets();
// static files
app.UseDefaultFiles(); app.UseDefaultFiles();
app.UseStaticFiles(); app.UseStaticFiles();
// swagger ui
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(); app.UseSwaggerUI();
static CookieOptions BuildStdCookieOptions() => new()
// set 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, HttpOnly = true,
Secure = true, Secure = true,
SameSite = SameSiteMode.Lax, SameSite = SameSiteMode.Lax,
IsEssential = true, IsEssential = true,
Expires = DateTimeOffset.UtcNow.AddDays(7), Expires = DateTimeOffset.UtcNow.AddDays(7),
Domain = "purrlobby.exil.dev" Domain = "purrlobby.exil.dev"
}; };
http.Response.Cookies.Append("gameId", gameId.ToString(), opts);
// 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");
http.Response.Cookies.Append("gameId", gameId.ToString(), BuildStdCookieOptions());
return Results.Ok(new { message = "GameId stored" }); return Results.Ok(new { message = "GameId stored" });
}) })
.WithTags("Session") .WithTags("Session")
@@ -149,7 +138,27 @@ app.MapPost("/session/game", (HttpContext http, SetGameRequest req) =>
.Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status400BadRequest)
.ProducesProblem(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) static bool TryGetGameIdFromCookie(HttpRequest request, out Guid gameId)
{ {
gameId = Guid.Empty; gameId = Guid.Empty;
@@ -157,20 +166,38 @@ static bool TryGetGameIdFromCookie(HttpRequest request, out Guid gameId)
return Guid.TryParse(v, out 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 // create lobby
app.MapPost("/lobbies", async (HttpContext http, ILobbyService service, CreateLobbyRequest req, CancellationToken ct) => app.MapPost("/lobbies", async (HttpContext http, ILobbyService service, CreateLobbyRequest req, CancellationToken ct) =>
{ {
if (!TryGetGameIdFromCookie(http.Request, out var gameId)) if (!TryGetGameIdFromCookie(http.Request, out var gameId))
return Results.BadRequest("Missing or invalid gameId cookie"); return Results.BadRequest("Missing or invalid gameId cookie");
if (req.MaxPlayers <= 0) return Results.BadRequest("MaxPlayers must be > 0"); 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); 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); return Results.Ok(lobby);
}
catch (ArgumentException ax)
{
return Results.BadRequest(ax.Message);
}
}) })
.WithTags("Lobbies") .WithTags("Lobbies")
.WithOpenApi(op => .WithOpenApi(op =>
{ {
op.Summary = "Create a lobby"; 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; return op;
}) })
.Accepts<CreateLobbyRequest>("application/json") .Accepts<CreateLobbyRequest>("application/json")
@@ -183,13 +210,15 @@ app.MapPost("/lobbies/{lobbyId}/join", async (HttpContext http, ILobbyService se
{ {
if (!TryGetGameIdFromCookie(http.Request, out var gameId)) if (!TryGetGameIdFromCookie(http.Request, out var gameId))
return Results.BadRequest("Missing or invalid gameId cookie"); 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); return lobby is null ? Results.NotFound() : Results.Ok(lobby);
}).WithTags("Lobbies") }).WithTags("Lobbies")
.WithOpenApi(op => .WithOpenApi(op =>
{ {
op.Summary = "Join a lobby"; 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; return op;
}) })
.Accepts<JoinLobbyRequest>("application/json") .Accepts<JoinLobbyRequest>("application/json")
@@ -199,20 +228,20 @@ app.MapPost("/lobbies/{lobbyId}/join", async (HttpContext http, ILobbyService se
.ProducesProblem(StatusCodes.Status400BadRequest); .ProducesProblem(StatusCodes.Status400BadRequest);
// leave lobby by id // 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)) if (!TryGetGameIdFromCookie(http.Request, out var gameId))
return Results.BadRequest("Missing or invalid gameId cookie"); 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(); return ok ? Results.Ok() : Results.NotFound();
}).WithTags("Lobbies") }).WithTags("Lobbies")
.WithOpenApi(op => .WithOpenApi(op =>
{ {
op.Summary = "Leave a lobby"; 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; return op;
}) })
.Accepts<LeaveLobbyRequest>("application/json")
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status400BadRequest)
@@ -229,7 +258,7 @@ app.MapPost("/users/{userId}/leave", async (HttpContext http, ILobbyService serv
.WithOpenApi(op => .WithOpenApi(op =>
{ {
op.Summary = "Force a user to leave their lobby"; 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; return op;
}) })
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
@@ -264,6 +293,26 @@ app.MapGet("/lobbies/search", async (HttpContext http, ILobbyService service, in
.Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status400BadRequest)
.ProducesProblem(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<Lobby>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
// lobby members // lobby members
app.MapGet("/lobbies/{lobbyId}/members", async (HttpContext http, ILobbyService service, string lobbyId, CancellationToken ct) => 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)) if (!TryGetGameIdFromCookie(http.Request, out var gameId))
return Results.BadRequest("Missing or invalid gameId cookie"); 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); var ok = await service.SetIsReadyAsync(gameId, lobbyId, req.UserId, req.IsReady, ct);
return ok ? Results.Ok() : Results.NotFound(); return ok ? Results.Ok() : Results.NotFound();
}) })
@@ -339,7 +388,7 @@ app.MapPost("/lobbies/{lobbyId}/ready", async (HttpContext http, ILobbyService s
.WithOpenApi(op => .WithOpenApi(op =>
{ {
op.Summary = "Set member ready state"; 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; return op;
}) })
.Accepts<ReadyRequest>("application/json") .Accepts<ReadyRequest>("application/json")
@@ -348,6 +397,55 @@ app.MapPost("/lobbies/{lobbyId}/ready", async (HttpContext http, ILobbyService s
.Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status400BadRequest)
.ProducesProblem(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<AllReadyRequest>("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 // 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, CancellationToken ct) =>
{ {
@@ -357,9 +455,7 @@ app.Map("/ws/lobbies/{lobbyId}", async (HttpContext http, string lobbyId, ILobby
if (!TryGetGameIdFromCookie(http.Request, out var gameId)) if (!TryGetGameIdFromCookie(http.Request, out var gameId))
return Results.BadRequest("Missing or invalid gameId cookie"); return Results.BadRequest("Missing or invalid gameId cookie");
var userId = http.Request.Query["userId"].ToString(); var userId = EnsureUserIdCookie(http);
if (string.IsNullOrWhiteSpace(userId))
return Results.BadRequest("Missing userId query parameter");
using var socket = await http.WebSockets.AcceptWebSocketAsync(); using var socket = await http.WebSockets.AcceptWebSocketAsync();
await hub.HandleConnectionAsync(gameId, lobbyId, userId, socket, ct); 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 => }).WithTags("Lobbies").WithOpenApi(op =>
{ {
op.Summary = "Lobby Websocket"; 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; return op;
}); });
@@ -406,8 +502,8 @@ app.Run();
// dto records // dto records
public record SetGameRequest(Guid GameId); public record SetGameRequest(Guid GameId);
public record CreateLobbyRequest(string OwnerUserId, string OwnerDisplayName, int MaxPlayers, Dictionary<string, string>? Properties); public record CreateLobbyRequest(string OwnerDisplayName, int MaxPlayers, Dictionary<string, string>? Properties);
public record JoinLobbyRequest(string UserId, string DisplayName); public record JoinLobbyRequest(string DisplayName);
public record LeaveLobbyRequest(string UserId);
public record ReadyRequest(string UserId, bool IsReady); public record ReadyRequest(string UserId, bool IsReady);
public record AllReadyRequest(string UserId);
public record LobbyDataRequest(string Key, string Value); public record LobbyDataRequest(string Key, string Value);

View File

@@ -17,6 +17,7 @@ public interface ILobbyService
Task<List<LobbyUser>> GetLobbyMembersAsync(Guid gameId, string lobbyId, CancellationToken ct = default); Task<List<LobbyUser>> GetLobbyMembersAsync(Guid gameId, string lobbyId, CancellationToken ct = default);
Task<bool> SetAllReadyAsync(Guid gameId, string lobbyId, CancellationToken ct = default); Task<bool> SetAllReadyAsync(Guid gameId, string lobbyId, CancellationToken ct = default);
Task<bool> SetLobbyStartedAsync(Guid gameId, string lobbyId, CancellationToken ct = default); Task<bool> SetLobbyStartedAsync(Guid gameId, string lobbyId, CancellationToken ct = default);
Task<Lobby?> GetLobbyAsync(Guid gameId, string lobbyId, string currentUserId, CancellationToken ct = default);
// stats // stats
Task<int> GetGlobalPlayerCountAsync(CancellationToken ct = default); Task<int> GetGlobalPlayerCountAsync(CancellationToken ct = default);
@@ -363,6 +364,17 @@ public class LobbyService : ILobbyService
return Task.FromResult(true); return Task.FromResult(true);
} }
public Task<Lobby?> GetLobbyAsync(Guid gameId, string lobbyId, string currentUserId, CancellationToken ct = default)
{
if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(currentUserId))
return Task.FromResult<Lobby?>(null);
if (!_lobbies.TryGetValue(lobbyId, out var state))
return Task.FromResult<Lobby?>(null);
if (state.GameId != gameId)
return Task.FromResult<Lobby?>(null);
return Task.FromResult<Lobby?>(Project(state, currentUserId));
}
public Task<int> GetGlobalPlayerCountAsync(CancellationToken ct = default) public Task<int> GetGlobalPlayerCountAsync(CancellationToken ct = default)
{ {
var total = 0; var total = 0;