672 lines
25 KiB
C#
672 lines
25 KiB
C#
using System.Threading.RateLimiting;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.HttpLogging;
|
|
using Microsoft.AspNetCore.HttpOverrides;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
using Microsoft.AspNetCore.ResponseCompression;
|
|
using Microsoft.OpenApi.Models;
|
|
using PurrLobby.Services;
|
|
using PurrLobby.Models;
|
|
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
builder.Services.AddProblemDetails();
|
|
builder.Services.AddHttpLogging(o =>
|
|
{
|
|
o.LoggingFields = HttpLoggingFields.RequestScheme | HttpLoggingFields.RequestMethod | HttpLoggingFields.RequestPath | HttpLoggingFields.ResponseStatusCode;
|
|
});
|
|
builder.Services.AddResponseCompression(o =>
|
|
{
|
|
o.Providers.Add<BrotliCompressionProvider>();
|
|
o.Providers.Add<GzipCompressionProvider>();
|
|
});
|
|
builder.Services.AddRateLimiter(o =>
|
|
{
|
|
o.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
|
|
{
|
|
var key = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
|
return RateLimitPartition.GetFixedWindowLimiter(key, _ => new FixedWindowRateLimiterOptions
|
|
{
|
|
PermitLimit = 300,
|
|
Window = TimeSpan.FromMinutes(1),
|
|
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
|
QueueLimit = 100
|
|
});
|
|
});
|
|
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);
|
|
};
|
|
});
|
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
|
{
|
|
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost;
|
|
options.KnownNetworks.Clear();
|
|
options.KnownProxies.Clear();
|
|
});
|
|
builder.Services.AddEndpointsApiExplorer();
|
|
builder.Services.AddSwaggerGen(o =>
|
|
{
|
|
o.SwaggerDoc("v1", new OpenApiInfo
|
|
{
|
|
Title = "PurrLobby API",
|
|
Version = "v1",
|
|
Description = "PurrLobby is a secure lightweight lobby service with token-based authentication. All lobby endpoints require a valid session token and gameId cookie.",
|
|
Contact = new OpenApiContact { Name = "PurrLobby", Url = new Uri("https://purrlobby.exil.dev") }
|
|
});
|
|
|
|
o.AddSecurityDefinition("gameIdCookie", new OpenApiSecurityScheme
|
|
{
|
|
Type = SecuritySchemeType.ApiKey,
|
|
In = ParameterLocation.Cookie,
|
|
Name = "gameId",
|
|
Description = "Game scope cookie set by POST /session/game."
|
|
});
|
|
|
|
o.AddSecurityDefinition("bearerAuth", new OpenApiSecurityScheme
|
|
{
|
|
Type = SecuritySchemeType.Http,
|
|
Scheme = "bearer",
|
|
BearerFormat = "JWT",
|
|
Description = "Secure session token obtained from POST /auth/create. Include in Authorization header as 'Bearer <token>' or as 'token' query parameter."
|
|
});
|
|
|
|
o.AddSecurityRequirement(new OpenApiSecurityRequirement
|
|
{
|
|
{
|
|
new OpenApiSecurityScheme
|
|
{
|
|
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "gameIdCookie" }
|
|
},
|
|
Array.Empty<string>()
|
|
},
|
|
{
|
|
new OpenApiSecurityScheme
|
|
{
|
|
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "bearerAuth" }
|
|
},
|
|
Array.Empty<string>()
|
|
}
|
|
});
|
|
});
|
|
builder.Services.AddSingleton<IAuthenticationService, AuthenticationService>();
|
|
builder.Services.AddSingleton<ILobbyEventHub, LobbyEventHub>();
|
|
builder.Services.AddSingleton<ILobbyService, LobbyService>();
|
|
builder.Services.AddRazorPages(); // Register Razor Pages services
|
|
var app = builder.Build();
|
|
if (!app.Environment.IsDevelopment())
|
|
{
|
|
app.UseExceptionHandler();
|
|
app.UseHsts();
|
|
app.UseHttpsRedirection();
|
|
}
|
|
else
|
|
{
|
|
app.UseDeveloperExceptionPage();
|
|
}
|
|
app.UseForwardedHeaders();
|
|
app.UseResponseCompression();
|
|
app.UseHttpLogging();
|
|
app.UseRateLimiter();
|
|
app.UseAuthentication();
|
|
app.UseWebSockets();
|
|
app.UseStaticFiles();
|
|
app.UseSwagger();
|
|
app.UseSwaggerUI();
|
|
app.MapRazorPages(); // Enable Razor Pages
|
|
|
|
// Authentication endpoints
|
|
app.MapPost("/auth/create", async (IAuthenticationService authService, CreateSessionRequest req, CancellationToken ct) =>
|
|
{
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(req.UserId) || string.IsNullOrWhiteSpace(req.DisplayName))
|
|
return Results.BadRequest(new { error = "UserId and DisplayName are required" });
|
|
|
|
var session = await authService.CreateSessionAsync(req.UserId, req.DisplayName, ct);
|
|
return Results.Ok(session);
|
|
}
|
|
catch (ArgumentException ax)
|
|
{
|
|
return Results.BadRequest(new { error = ax.Message });
|
|
}
|
|
catch
|
|
{
|
|
return Results.Problem("Internal server error", statusCode: 500);
|
|
}
|
|
})
|
|
.WithTags("Authentication")
|
|
.WithOpenApi(op =>
|
|
{
|
|
op.Summary = "Create authentication session";
|
|
op.Description = "Creates a secure session token for a user. Returns a session token that must be used for all subsequent API calls.";
|
|
op.Security.Clear();
|
|
return op;
|
|
})
|
|
.Accepts<CreateSessionRequest>("application/json")
|
|
.Produces<UserSession>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.Produces(StatusCodes.Status500InternalServerError);
|
|
|
|
app.MapPost("/auth/validate", async (IAuthenticationService authService, ValidateTokenRequest req, CancellationToken ct) =>
|
|
{
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(req.Token))
|
|
return Results.BadRequest(new { error = "Token is required" });
|
|
|
|
var result = await authService.ValidateTokenAsync(req.Token, ct);
|
|
return Results.Ok(result);
|
|
}
|
|
catch
|
|
{
|
|
return Results.Problem("Internal server error", statusCode: 500);
|
|
}
|
|
})
|
|
.WithTags("Authentication")
|
|
.WithOpenApi(op =>
|
|
{
|
|
op.Summary = "Validate session token";
|
|
op.Description = "Validates a session token and returns user information if valid.";
|
|
op.Security.Clear();
|
|
return op;
|
|
})
|
|
.Accepts<ValidateTokenRequest>("application/json")
|
|
.Produces<TokenValidationResult>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.Produces(StatusCodes.Status500InternalServerError);
|
|
|
|
app.MapPost("/auth/revoke", async (IAuthenticationService authService, RevokeTokenRequest req, CancellationToken ct) =>
|
|
{
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(req.Token))
|
|
return Results.BadRequest(new { error = "Token is required" });
|
|
|
|
var result = await authService.RevokeTokenAsync(req.Token, ct);
|
|
return Results.Ok(result);
|
|
}
|
|
catch
|
|
{
|
|
return Results.Problem("Internal server error", statusCode: 500);
|
|
}
|
|
})
|
|
.WithTags("Authentication")
|
|
.WithOpenApi(op =>
|
|
{
|
|
op.Summary = "Revoke session token";
|
|
op.Description = "Revokes a session token, making it invalid for future use.";
|
|
op.Security.Clear();
|
|
return op;
|
|
})
|
|
.Accepts<RevokeTokenRequest>("application/json")
|
|
.Produces<bool>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.Produces(StatusCodes.Status500InternalServerError);
|
|
|
|
static CookieOptions BuildStdCookieOptions() => new()
|
|
{
|
|
HttpOnly = true,
|
|
Secure = true,
|
|
SameSite = SameSiteMode.Lax,
|
|
IsEssential = true,
|
|
Expires = DateTimeOffset.UtcNow.AddDays(7),
|
|
Domain = "purrlobby.exil.dev"
|
|
};
|
|
|
|
// 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" });
|
|
})
|
|
.WithTags("Session")
|
|
.WithOpenApi(op =>
|
|
{
|
|
op.Summary = "Identify game by setting a cookie";
|
|
op.Description = "Sets a 'gameId' cookie used by lobby endpoints. Provide your game GUID in the request body.";
|
|
op.Security.Clear();
|
|
return op;
|
|
})
|
|
.Accepts<SetGameRequest>("application/json")
|
|
.Produces(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
// 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;
|
|
if (!request.Cookies.TryGetValue("gameId", out var v)) return false;
|
|
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
|
|
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 user = http.Items["User"] as AuthenticatedUser;
|
|
if (user == null)
|
|
return Results.Unauthorized();
|
|
|
|
try
|
|
{
|
|
var lobby = await service.CreateLobbyAsync(gameId, user.SessionToken, req.MaxPlayers, req.Properties, ct);
|
|
return Results.Ok(lobby);
|
|
}
|
|
catch (ArgumentException ax)
|
|
{
|
|
return Results.BadRequest(ax.Message);
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
return Results.Unauthorized();
|
|
}
|
|
})
|
|
.WithTags("Lobbies")
|
|
.WithOpenApi(op =>
|
|
{
|
|
op.Summary = "Create a lobby";
|
|
op.Description = "Creates a new lobby for the current game. Requires valid session token for authentication.";
|
|
return op;
|
|
})
|
|
.Accepts<CreateLobbyRequest>("application/json")
|
|
.Produces<Lobby>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
// join lobby
|
|
app.MapPost("/lobbies/{lobbyId}/join", 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 lobby = await service.JoinLobbyAsync(gameId, lobbyId, user.SessionToken, ct);
|
|
return lobby is null ? Results.NotFound() : Results.Ok(lobby);
|
|
}).WithTags("Lobbies")
|
|
.WithOpenApi(op =>
|
|
{
|
|
op.Summary = "Join a lobby";
|
|
op.Description = "Adds the authenticated user to the specified lobby. Session token is automatically extracted from request.";
|
|
return op;
|
|
})
|
|
|
|
.Produces<Lobby>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
// leave lobby by id
|
|
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 user = http.Items["User"] as AuthenticatedUser;
|
|
if (user == null)
|
|
return Results.Unauthorized();
|
|
|
|
var ok = await service.LeaveLobbyAsync(gameId, lobbyId, user.SessionToken, ct);
|
|
return ok ? Results.Ok() : Results.NotFound();
|
|
}).WithTags("Lobbies")
|
|
.WithOpenApi(op =>
|
|
{
|
|
op.Summary = "Leave a lobby";
|
|
op.Description = "Removes the authenticated user from the lobby.";
|
|
return op;
|
|
})
|
|
.Produces(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
|
|
|
|
// search lobbies
|
|
app.MapGet("/lobbies/search", async (HttpContext http, ILobbyService service, int maxRoomsToFind = 10, CancellationToken ct = default) =>
|
|
{
|
|
if (!TryGetGameIdFromCookie(http.Request, out var gameId))
|
|
return Results.BadRequest("Missing or invalid gameId cookie");
|
|
var lobbies = await service.SearchLobbiesAsync(gameId, maxRoomsToFind, null, ct);
|
|
return Results.Ok(lobbies);
|
|
})
|
|
.WithTags("Lobbies")
|
|
.WithOpenApi(op =>
|
|
{
|
|
op.Summary = "Search available lobbies";
|
|
op.Description = "Finds joinable lobbies for the current game. Excludes started or full lobbies. Does not require authentication.";
|
|
op.Parameters.Add(new OpenApiParameter
|
|
{
|
|
Name = "maxRoomsToFind",
|
|
In = ParameterLocation.Query,
|
|
Required = false,
|
|
Description = "Max rooms to return (default 10)",
|
|
Schema = new OpenApiSchema { Type = "integer", Format = "int32" }
|
|
});
|
|
return op;
|
|
})
|
|
.Produces<List<Lobby>>(StatusCodes.Status200OK)
|
|
.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 user = http.Items["User"] as AuthenticatedUser;
|
|
if (user == null)
|
|
return Results.Unauthorized();
|
|
|
|
var lobby = await service.GetLobbyAsync(gameId, lobbyId, user.SessionToken, ct);
|
|
return lobby is null ? Results.NotFound() : Results.Ok(lobby);
|
|
})
|
|
.WithTags("Lobbies")
|
|
.WithOpenApi(op =>
|
|
{
|
|
op.Summary = "Get lobby details";
|
|
op.Description = "Returns lobby object including ownership flag relative to the authenticated user.";
|
|
return op;
|
|
})
|
|
.Produces<Lobby>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces(StatusCodes.Status400BadRequest);
|
|
|
|
// lobby members
|
|
app.MapGet("/lobbies/{lobbyId}/members", 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 members = await service.GetLobbyMembersAsync(gameId, lobbyId, ct);
|
|
return members.Count == 0 ? Results.NotFound() : Results.Ok(members);
|
|
})
|
|
.WithTags("Lobbies")
|
|
.WithOpenApi(op =>
|
|
{
|
|
op.Summary = "Get members of a lobby";
|
|
op.Description = "Returns current members of lobby in the current game, including readiness state. Requires authentication and membership in the lobby.";
|
|
return op;
|
|
})
|
|
.Produces<List<LobbyUser>>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
// lobby data get
|
|
app.MapGet("/lobbies/{lobbyId}/data/{key}", async (HttpContext http, ILobbyService service, string lobbyId, string key, CancellationToken ct) =>
|
|
{
|
|
if (!TryGetGameIdFromCookie(http.Request, out var gameId))
|
|
return Results.BadRequest("Missing or invalid gameId cookie");
|
|
var v = await service.GetLobbyDataAsync(gameId, lobbyId, key, ct);
|
|
return v is null ? Results.NotFound() : Results.Ok(v);
|
|
})
|
|
.WithTags("Lobbies")
|
|
.WithOpenApi(op =>
|
|
{
|
|
op.Summary = "Get a lobby data value";
|
|
op.Description = "Retrieves a single property value for the lobby within the current game.";
|
|
return op;
|
|
})
|
|
.Produces<string>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
// lobby data set
|
|
app.MapPost("/lobbies/{lobbyId}/data", async (HttpContext http, ILobbyService service, string lobbyId, LobbyDataRequest req, CancellationToken ct) =>
|
|
{
|
|
if (!TryGetGameIdFromCookie(http.Request, out var gameId))
|
|
return Results.BadRequest("Missing or invalid gameId cookie");
|
|
if (string.IsNullOrWhiteSpace(req.Key)) return Results.BadRequest("Key is required");
|
|
|
|
var user = http.Items["User"] as AuthenticatedUser;
|
|
if (user == null)
|
|
return Results.Unauthorized();
|
|
|
|
var ok = await service.SetLobbyDataAsync(gameId, lobbyId, user.SessionToken, req.Key, req.Value, ct);
|
|
return ok ? Results.Ok() : Results.NotFound();
|
|
})
|
|
.WithTags("Lobbies")
|
|
.WithOpenApi(op =>
|
|
{
|
|
op.Summary = "Set a lobby data value";
|
|
op.Description = "Sets or updates a single property on lobby within the current game. Only lobby owners can set data. Broadcasts a lobby_data event to subscribers.";
|
|
return op;
|
|
})
|
|
.Accepts<LobbyDataRequest>("application/json")
|
|
.Produces(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
// ready toggle
|
|
app.MapPost("/lobbies/{lobbyId}/ready", async (HttpContext http, ILobbyService service, string lobbyId, ReadyRequest req, 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.SetIsReadyAsync(gameId, lobbyId, user.SessionToken, req.IsReady, ct);
|
|
return ok ? Results.Ok() : Results.NotFound();
|
|
})
|
|
.WithTags("Lobbies")
|
|
.WithOpenApi(op =>
|
|
{
|
|
op.Summary = "Set member ready state";
|
|
op.Description = "Sets readiness for the authenticated user in the lobby. Request body must include isReady. Broadcasts a member_ready event.";
|
|
return op;
|
|
})
|
|
.Accepts<ReadyRequest>("application/json")
|
|
.Produces(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.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) =>
|
|
{
|
|
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.SetLobbyStartedAsync(gameId, lobbyId, user.SessionToken, ct);
|
|
return ok ? Results.Ok() : Results.NotFound();
|
|
})
|
|
.WithTags("Lobbies")
|
|
.WithOpenApi(op =>
|
|
{
|
|
op.Summary = "Start lobby";
|
|
op.Description = "Owner only. Marks lobby as started and broadcasts lobby_started. Only lobby owners can start lobbies.";
|
|
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, IAuthenticationService authService, CancellationToken ct) =>
|
|
{
|
|
if (!http.WebSockets.IsWebSocketRequest)
|
|
return Results.BadRequest("Expected WebSocket");
|
|
|
|
if (!TryGetGameIdFromCookie(http.Request, out var gameId))
|
|
return Results.BadRequest("Missing or invalid gameId cookie");
|
|
|
|
// 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, token, socket, ct);
|
|
return Results.Empty;
|
|
});
|
|
|
|
// websocket documentation endpoint
|
|
app.MapGet("/ws/docs", () => Results.Ok(new {
|
|
endpoint = "/ws/lobbies/{lobbyId}",
|
|
protocol = "WebSocket",
|
|
description = "Real-time lobby updates WebSocket connection",
|
|
authentication = new {
|
|
methods = new[] {
|
|
"Authorization: Bearer <token>",
|
|
"Query parameter: ?token=<token>"
|
|
},
|
|
requirements = new[] {
|
|
"Valid session token required",
|
|
"Valid gameId cookie required",
|
|
"User must be lobby member"
|
|
}
|
|
},
|
|
events = new {
|
|
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')"
|
|
},
|
|
exampleConnection = "new WebSocket('wss://purrlobby.exil.dev/ws/lobbies/your-lobby-id?token=your-session-token')"
|
|
}))
|
|
.WithTags("WebSocket")
|
|
.WithOpenApi(op =>
|
|
{
|
|
op.Summary = "WebSocket API documentation";
|
|
op.Description = "Returns documentation for the lobby WebSocket endpoint including events and authentication.";
|
|
op.Security.Clear();
|
|
return op;
|
|
});
|
|
|
|
// health
|
|
app.MapGet("/health", () => Results.Ok(new { status = "healthy" }))
|
|
.WithTags("Health")
|
|
.WithOpenApi(op =>
|
|
{
|
|
op.Summary = "Service health";
|
|
op.Description = "Returns a 200 response to indicate the service is running.";
|
|
op.Security.Clear();
|
|
return op;
|
|
})
|
|
.Produces(StatusCodes.Status200OK);
|
|
|
|
// stats
|
|
app.MapGet("/stats/global/players", async (ILobbyService service, CancellationToken ct) => Results.Ok(await service.GetGlobalPlayerCountAsync(ct)))
|
|
.WithTags("Stats").WithSummary("Get total active players globally").WithDescription("Counts all players across all lobbies (all games).")
|
|
.WithOpenApi(op => { op.Security.Clear(); return op; })
|
|
.Produces<int>(StatusCodes.Status200OK);
|
|
app.MapGet("/stats/global/lobbies", async (ILobbyService service, CancellationToken ct) => Results.Ok(await service.GetGlobalLobbyCountAsync(ct)))
|
|
.WithTags("Stats").WithSummary("Get total lobbies globally").WithDescription("Counts all lobbies across all games, including started ones.")
|
|
.WithOpenApi(op => { op.Security.Clear(); return op; })
|
|
.Produces<int>(StatusCodes.Status200OK);
|
|
app.MapGet("/stats/{gameId:guid}/lobbies", async (ILobbyService service, Guid gameId, CancellationToken ct) => Results.Ok(await service.GetLobbyCountByGameAsync(gameId, ct)))
|
|
.WithTags("Stats").WithSummary("Get lobby count for a game").WithDescription("Counts all lobbies for the specified game.")
|
|
.WithOpenApi()
|
|
.Produces<int>(StatusCodes.Status200OK);
|
|
app.MapGet("/stats/{gameId:guid}/players", async (ILobbyService service, Guid gameId, CancellationToken ct) => Results.Ok(await service.GetActivePlayersByGameAsync(gameId, ct)))
|
|
.WithTags("Stats").WithSummary("Get active players for a game").WithDescription("Returns distinct active players across all lobbies for the specified game.")
|
|
.WithOpenApi()
|
|
.Produces<List<LobbyUser>>(StatusCodes.Status200OK);
|
|
|
|
// run
|
|
app.Run();
|
|
|
|
// dto records
|
|
public record SetGameRequest(Guid GameId);
|
|
public record CreateLobbyRequest(string OwnerDisplayName, int MaxPlayers, Dictionary<string, string>? Properties);
|
|
public record ReadyRequest(bool IsReady);
|
|
public record LobbyDataRequest(string Key, string Value);
|
|
|
|
// Authentication DTOs
|
|
public record CreateSessionRequest(string UserId, string DisplayName);
|
|
public record ValidateTokenRequest(string Token);
|
|
public record RevokeTokenRequest(string Token); |