Fix Docker

This commit is contained in:
Exil Productions
2025-12-10 03:24:28 +01:00
parent 6143c1ca6e
commit 28eb886790
12 changed files with 677 additions and 214 deletions

View File

@@ -0,0 +1,94 @@
using Microsoft.AspNetCore.Mvc;
using PurrLobby.Models;
using PurrLobby.Services;
namespace PurrLobby.Controllers;
[ApiController]
[Route("auth")]
public class AuthenticationController : ControllerBase
{
private readonly IAuthenticationService _authService;
public AuthenticationController(IAuthenticationService authService)
{
_authService = authService;
}
[HttpPost("create")]
public async Task<ActionResult<UserSession>> CreateSession([FromBody] CreateSessionRequest request)
{
try
{
if (string.IsNullOrWhiteSpace(request.UserId) || string.IsNullOrWhiteSpace(request.DisplayName))
{
return BadRequest(new { error = "UserId and DisplayName are required" });
}
var session = await _authService.CreateSessionAsync(request.UserId, request.DisplayName);
return Ok(session);
}
catch (ArgumentException ex)
{
return BadRequest(new { error = ex.Message });
}
catch
{
return StatusCode(500, new { error = "Internal server error" });
}
}
[HttpPost("validate")]
public async Task<ActionResult<TokenValidationResult>> ValidateToken([FromBody] ValidateTokenRequest request)
{
try
{
if (string.IsNullOrWhiteSpace(request.Token))
{
return BadRequest(new { error = "Token is required" });
}
var result = await _authService.ValidateTokenAsync(request.Token);
return Ok(result);
}
catch
{
return StatusCode(500, new { error = "Internal server error" });
}
}
[HttpPost("revoke")]
public async Task<ActionResult<bool>> RevokeToken([FromBody] RevokeTokenRequest request)
{
try
{
if (string.IsNullOrWhiteSpace(request.Token))
{
return BadRequest(new { error = "Token is required" });
}
var result = await _authService.RevokeTokenAsync(request.Token);
return Ok(result);
}
catch
{
return StatusCode(500, new { error = "Internal server error" });
}
}
}
public class CreateSessionRequest
{
public required string UserId { get; set; }
public required string DisplayName { get; set; }
}
public class ValidateTokenRequest
{
public required string Token { get; set; }
}
public class RevokeTokenRequest
{
public required string Token { get; set; }
}

View File

@@ -0,0 +1,94 @@
using System.Net;
using System.Text.Json;
using PurrLobby.Models;
using PurrLobby.Services;
namespace PurrLobby.Middleware;
public class AuthenticationMiddleware
{
private readonly RequestDelegate _next;
private readonly IAuthenticationService _authService;
private static readonly string[] ExcludedPaths =
{
"/auth/create",
"/health",
"/metrics"
};
public AuthenticationMiddleware(RequestDelegate next, IAuthenticationService authService)
{
_next = next;
_authService = authService;
}
public async Task InvokeAsync(HttpContext context)
{
var path = context.Request.Path.Value?.ToLowerInvariant() ?? string.Empty;
// Skip authentication for excluded paths
if (ExcludedPaths.Any(excluded => path.StartsWith(excluded)))
{
await _next(context);
return;
}
// Extract token from Authorization header or query parameter
var token = ExtractToken(context);
if (string.IsNullOrEmpty(token))
{
await WriteUnauthorizedResponse(context, "Authentication token required");
return;
}
var validation = await _authService.ValidateTokenAsync(token);
if (!validation.IsValid)
{
await WriteUnauthorizedResponse(context, validation.ErrorMessage ?? "Invalid token");
return;
}
// Add user info to context for downstream handlers
context.Items["User"] = new AuthenticatedUser
{
UserId = validation.UserId!,
DisplayName = validation.DisplayName!,
SessionToken = token
};
await _next(context);
}
private static string? ExtractToken(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();
}
private static async Task WriteUnauthorizedResponse(HttpContext context, string message)
{
context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
context.Response.ContentType = "application/json";
var response = new { error = "Unauthorized", message };
var json = JsonSerializer.Serialize(response);
await context.Response.WriteAsync(json);
}
}
public static class AuthenticationMiddlewareExtensions
{
public static IApplicationBuilder UseAuthentication(this IApplicationBuilder builder)
{
return builder.UseMiddleware<AuthenticationMiddleware>();
}
}

View File

@@ -0,0 +1,37 @@
using System.Security.Cryptography;
using System.Text;
namespace PurrLobby.Models;
public record UserSession
{
public required string SessionToken { get; init; }
public required string UserId { get; init; }
public required string DisplayName { get; init; }
public DateTime CreatedAtUtc { get; init; } = DateTime.UtcNow;
public DateTime ExpiresAtUtc { get; init; }
public string? PublicKey { get; init; }
}
public class AuthenticatedUser
{
public required string UserId { get; init; }
public required string DisplayName { get; init; }
public required string SessionToken { get; init; }
}
public class TokenValidationResult
{
public bool IsValid { get; init; }
public string? UserId { get; init; }
public string? DisplayName { get; init; }
public string? ErrorMessage { get; init; }
}
public static class SecurityConstants
{
public const int TokenLength = 64;
public const int SessionExpirationHours = 24;
public const int MaxDisplayNameLength = 64;
public const int MaxUserIdLength = 128;
}

View File

@@ -3,7 +3,8 @@ namespace PurrLobby.Models;
// user in lobby
public class LobbyUser
{
public required string Id { get; init; }
public required string SessionToken { get; init; }
public required string UserId { get; init; }
public required string DisplayName { get; init; }
public int userPing { get; set; }
public bool IsReady { get; set; }

View File

@@ -1,4 +1,4 @@
using System.Threading.RateLimiting;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpLogging;
using Microsoft.AspNetCore.HttpOverrides;
@@ -84,6 +84,7 @@ builder.Services.AddSwaggerGen(o =>
}
});
});
builder.Services.AddSingleton<IAuthenticationService, AuthenticationService>();
builder.Services.AddSingleton<ILobbyEventHub, LobbyEventHub>();
builder.Services.AddSingleton<ILobbyService, LobbyService>();
builder.Services.AddRazorPages(); // Register Razor Pages services
@@ -92,16 +93,17 @@ if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler();
app.UseHsts();
app.UseHttpsRedirection();
}
else
{
app.UseDeveloperExceptionPage();
}
app.UseHttpsRedirection();
app.UseForwardedHeaders();
app.UseResponseCompression();
app.UseHttpLogging();
app.UseRateLimiter();
app.UseAuthentication();
app.UseWebSockets();
app.UseStaticFiles();
app.UseSwagger();
@@ -167,14 +169,7 @@ 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) =>
@@ -182,17 +177,24 @@ app.MapPost("/lobbies", async (HttpContext http, ILobbyService service, CreateLo
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");
if (string.IsNullOrWhiteSpace(req.OwnerDisplayName)) return Results.BadRequest("OwnerDisplayName required");
var user = http.Items["User"] as AuthenticatedUser;
if (user == null)
return Results.Unauthorized();
try
{
var ownerUserId = EnsureUserIdCookie(http);
var lobby = await service.CreateLobbyAsync(gameId, ownerUserId, req.OwnerDisplayName, req.MaxPlayers, req.Properties, ct);
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 =>
@@ -207,13 +209,16 @@ app.MapPost("/lobbies", async (HttpContext http, ILobbyService service, CreateLo
.ProducesProblem(StatusCodes.Status400BadRequest);
// join lobby
app.MapPost("/lobbies/{lobbyId}/join", async (HttpContext http, ILobbyService service, string lobbyId, JoinLobbyRequest req, CancellationToken ct) =>
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");
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);
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 =>
@@ -222,7 +227,7 @@ app.MapPost("/lobbies/{lobbyId}/join", async (HttpContext http, ILobbyService se
op.Description = "Adds the current cookie user (server generated userId) to the specified lobby.";
return op;
})
.Accepts<JoinLobbyRequest>("application/json")
.Produces<Lobby>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest)
@@ -233,8 +238,12 @@ app.MapPost("/lobbies/{lobbyId}/leave", async (HttpContext http, ILobbyService s
{
if (!TryGetGameIdFromCookie(http.Request, out var gameId))
return Results.BadRequest("Missing or invalid gameId cookie");
var userId = EnsureUserIdCookie(http);
var ok = await service.LeaveLobbyAsync(gameId, lobbyId, userId, ct);
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 =>
@@ -248,24 +257,7 @@ app.MapPost("/lobbies/{lobbyId}/leave", async (HttpContext http, ILobbyService s
.Produces(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status400BadRequest);
// leave any lobby by user id
app.MapPost("/users/{userId}/leave", async (HttpContext http, ILobbyService service, string userId, CancellationToken ct) =>
{
if (!TryGetGameIdFromCookie(http.Request, out var gameId))
return Results.BadRequest("Missing or invalid gameId cookie");
var ok = await service.LeaveLobbyAsync(gameId, userId, ct);
return ok ? Results.Ok() : Results.NotFound();
}).WithTags("Lobbies")
.WithOpenApi(op =>
{
op.Summary = "Force a user to leave their lobby";
op.Description = "Removes the specified user from whichever lobby they are currently in for the current game.";
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) =>
@@ -299,8 +291,12 @@ app.MapGet("/lobbies/{lobbyId}", async (HttpContext http, ILobbyService service,
{
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);
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")
@@ -360,7 +356,12 @@ app.MapPost("/lobbies/{lobbyId}/data", async (HttpContext http, ILobbyService se
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 ok = await service.SetLobbyDataAsync(gameId, lobbyId, req.Key, req.Value, ct);
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")
@@ -381,8 +382,12 @@ 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("UserId required");
var ok = await service.SetIsReadyAsync(gameId, lobbyId, req.UserId, req.IsReady, ct);
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")
@@ -398,42 +403,18 @@ 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<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();
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 =>
@@ -456,15 +437,17 @@ 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 = EnsureUserIdCookie(http);
var user = http.Items["User"] as AuthenticatedUser;
if (user == null)
return Results.Unauthorized();
using var socket = await http.WebSockets.AcceptWebSocketAsync();
await hub.HandleConnectionAsync(gameId, lobbyId, userId, socket, ct);
await hub.HandleConnectionAsync(gameId, lobbyId, user.SessionToken, socket, ct);
return Results.Empty;
}).WithTags("Lobbies").WithOpenApi(op =>
{
op.Summary = "Lobby Websocket";
op.Description = "WebSocket for lobby-specific updates. Uses server-generated 'userId' cookie.";
op.Description = "WebSocket for lobby-specific updates. Requires valid session token.";
return op;
});
@@ -504,7 +487,5 @@ app.Run();
// dto records
public record SetGameRequest(Guid GameId);
public record CreateLobbyRequest(string OwnerDisplayName, int MaxPlayers, Dictionary<string, string>? Properties);
public record JoinLobbyRequest(string DisplayName);
public record ReadyRequest(string UserId, bool IsReady);
public record AllReadyRequest(string UserId);
public record ReadyRequest(bool IsReady);
public record LobbyDataRequest(string Key, string Value);

View File

@@ -6,7 +6,7 @@
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:63036;http://localhost:63037"
"applicationUrl": "https://localhost:7443;http://localhost:5080"
}
}
}

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
@@ -19,14 +19,12 @@
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
</ItemGroup>
<!-- Copy static web assets to output on build so running the exe from bin works -->
<ItemGroup>
<Content Update="wwwroot\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<!-- Copy certificates to output directory -->
<ItemGroup>
<Content Include="Certs\*.pem">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>

View File

@@ -0,0 +1,178 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using PurrLobby.Models;
namespace PurrLobby.Services;
public interface IAuthenticationService
{
Task<UserSession> CreateSessionAsync(string userId, string displayName, CancellationToken ct = default);
Task<TokenValidationResult> ValidateTokenAsync(string token, CancellationToken ct = default);
Task<bool> RevokeTokenAsync(string token, CancellationToken ct = default);
Task<bool> ExtendSessionAsync(string token, CancellationToken ct = default);
Task CleanupExpiredSessionsAsync(CancellationToken ct = default);
}
public class AuthenticationService : IAuthenticationService
{
private readonly ConcurrentDictionary<string, UserSession> _activeSessions = new();
private readonly Timer _cleanupTimer;
public AuthenticationService()
{
// Start cleanup timer to run every hour
_cleanupTimer = new Timer(async _ => await CleanupExpiredSessionsAsync(),
null, TimeSpan.FromHours(1), TimeSpan.FromHours(1));
}
public Task<UserSession> CreateSessionAsync(string userId, string displayName, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(userId) || userId.Length > SecurityConstants.MaxUserIdLength)
throw new ArgumentException("Invalid user ID", nameof(userId));
if (string.IsNullOrWhiteSpace(displayName))
throw new ArgumentException("Display name cannot be empty", nameof(displayName));
var sanitizedDisplayName = SanitizeString(displayName, SecurityConstants.MaxDisplayNameLength);
var sessionToken = GenerateSecureToken();
var keyPair = GenerateKeyPair();
var session = new UserSession
{
SessionToken = sessionToken,
UserId = userId,
DisplayName = sanitizedDisplayName,
ExpiresAtUtc = DateTime.UtcNow.AddHours(SecurityConstants.SessionExpirationHours),
PublicKey = keyPair.Public
};
_activeSessions[sessionToken] = session;
return Task.FromResult(session);
}
public Task<TokenValidationResult> ValidateTokenAsync(string token, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(token))
return Task.FromResult(new TokenValidationResult
{
IsValid = false,
ErrorMessage = "Token is required"
});
if (!_activeSessions.TryGetValue(token, out var session))
return Task.FromResult(new TokenValidationResult
{
IsValid = false,
ErrorMessage = "Invalid token"
});
if (DateTime.UtcNow > session.ExpiresAtUtc)
{
_activeSessions.TryRemove(token, out _);
return Task.FromResult(new TokenValidationResult
{
IsValid = false,
ErrorMessage = "Token expired"
});
}
return Task.FromResult(new TokenValidationResult
{
IsValid = true,
UserId = session.UserId,
DisplayName = session.DisplayName
});
}
public Task<bool> RevokeTokenAsync(string token, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(token))
return Task.FromResult(false);
return Task.FromResult(_activeSessions.TryRemove(token, out _));
}
public Task<bool> ExtendSessionAsync(string token, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(token))
return Task.FromResult(false);
if (_activeSessions.TryGetValue(token, out var session))
{
var updatedSession = session with
{
ExpiresAtUtc = DateTime.UtcNow.AddHours(SecurityConstants.SessionExpirationHours)
};
_activeSessions[token] = updatedSession;
return Task.FromResult(true);
}
return Task.FromResult(false);
}
public async Task CleanupExpiredSessionsAsync(CancellationToken ct = default)
{
var now = DateTime.UtcNow;
var expiredTokens = new List<string>();
foreach (var kvp in _activeSessions)
{
if (now > kvp.Value.ExpiresAtUtc)
{
expiredTokens.Add(kvp.Key);
}
}
foreach (var token in expiredTokens)
{
_activeSessions.TryRemove(token, out _);
}
await Task.CompletedTask;
}
private static string GenerateSecureToken()
{
var bytes = RandomNumberGenerator.GetBytes(SecurityConstants.TokenLength);
return Convert.ToBase64String(bytes)
.Replace("+", "-")
.Replace("/", "_")
.Replace("=", "");
}
private static (string Public, string Private) GenerateKeyPair()
{
using var rsa = RSA.Create(2048);
var publicKey = Convert.ToBase64String(rsa.ExportRSAPublicKey());
var privateKey = Convert.ToBase64String(rsa.ExportRSAPrivateKey());
return (publicKey, privateKey);
}
private static string SanitizeString(string? input, int maxLength)
{
if (string.IsNullOrWhiteSpace(input))
return string.Empty;
var trimmed = input.Trim();
if (trimmed.Length > maxLength)
trimmed = trimmed.Substring(0, maxLength);
// Remove potentially harmful characters
var sb = new StringBuilder();
foreach (var c in trimmed)
{
if (char.IsControl(c) && c != '\t' && c != '\n' && c != '\r')
continue;
sb.Append(c);
}
return sb.ToString();
}
public void Dispose()
{
_cleanupTimer?.Dispose();
}
}

View File

@@ -7,7 +7,7 @@ namespace PurrLobby.Services;
public interface ILobbyEventHub
{
Task HandleConnectionAsync(Guid gameId, string lobbyId, string userId, WebSocket socket, CancellationToken ct);
Task HandleConnectionAsync(Guid gameId, string lobbyId, string sessionToken, WebSocket socket, CancellationToken ct);
Task BroadcastAsync(Guid gameId, string lobbyId, object evt, CancellationToken ct = default);
Task CloseLobbyAsync(Guid gameId, string lobbyId, CancellationToken ct = default);
}
@@ -21,6 +21,7 @@ public class LobbyEventHub : ILobbyEventHub
private sealed class Subscriber
{
public required string SessionToken { get; init; }
public required string UserId { get; init; }
public DateTime LastPongUtc { get; set; } = DateTime.UtcNow;
}
@@ -39,6 +40,7 @@ public class LobbyEventHub : ILobbyEventHub
};
private readonly IServiceScopeFactory _scopeFactory;
private readonly IAuthenticationService _authService;
// ping settings
private const int PingIntervalSeconds = 10;
@@ -47,16 +49,29 @@ public class LobbyEventHub : ILobbyEventHub
// idle cleanup delay
private const int IdleLobbyCleanupDelaySeconds = 45;
public LobbyEventHub(IServiceScopeFactory scopeFactory)
public LobbyEventHub(IServiceScopeFactory scopeFactory, IAuthenticationService authService)
{
_scopeFactory = scopeFactory;
_authService = authService;
}
public async Task HandleConnectionAsync(Guid gameId, string lobbyId, string userId, WebSocket socket, CancellationToken ct)
public async Task HandleConnectionAsync(Guid gameId, string lobbyId, string sessionToken, WebSocket socket, CancellationToken ct)
{
// Validate session token before allowing connection
var validation = await _authService.ValidateTokenAsync(sessionToken, ct);
if (!validation.IsValid)
{
try
{
await socket.CloseAsync(WebSocketCloseStatus.PolicyViolation, "Invalid session token", ct);
}
catch { }
return;
}
var key = new LobbyKey(gameId, lobbyId);
var bag = _subscribers.GetOrAdd(key, _ => new());
var sub = new Subscriber { UserId = userId, LastPongUtc = DateTime.UtcNow };
var sub = new Subscriber { SessionToken = sessionToken, UserId = validation.UserId!, LastPongUtc = DateTime.UtcNow };
bag.TryAdd(socket, sub);
EnsurePingLoopStarted(key);
@@ -150,11 +165,11 @@ public class LobbyEventHub : ILobbyEventHub
{
bag.TryRemove(ws, out _);
try { if (ws.State == WebSocketState.Open) await ws.CloseAsync(WebSocketCloseStatus.PolicyViolation, "pong timeout", CancellationToken.None); } catch { }
try
try
{
using var scope = _scopeFactory.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<ILobbyService>();
await svc.LeaveLobbyAsync(key.GameId, key.LobbyId, sub.UserId, CancellationToken.None);
await svc.LeaveLobbyAsync(key.GameId, sub.SessionToken, CancellationToken.None);
}
catch { }
}
@@ -180,7 +195,7 @@ public class LobbyEventHub : ILobbyEventHub
{
foreach (var m in members)
{
try { await svc.LeaveLobbyAsync(key.GameId, key.LobbyId, m.Id, CancellationToken.None); } catch { }
try { await svc.LeaveLobbyAsync(key.GameId, key.LobbyId, m.SessionToken, CancellationToken.None); } catch { }
}
}
}
@@ -284,7 +299,7 @@ public class LobbyEventHub : ILobbyEventHub
{
foreach (var m in members)
{
try { await svc.LeaveLobbyAsync(key.GameId, key.LobbyId, m.Id, CancellationToken.None); } catch { }
try { await svc.LeaveLobbyAsync(key.GameId, key.LobbyId, m.SessionToken, CancellationToken.None); } catch { }
}
}

View File

@@ -6,18 +6,18 @@ namespace PurrLobby.Services;
// lobby service core logic
public interface ILobbyService
{
Task<Lobby> CreateLobbyAsync(Guid gameId, string ownerUserId, string ownerDisplayName, int maxPlayers, Dictionary<string, string>? properties, CancellationToken ct = default);
Task<Lobby?> JoinLobbyAsync(Guid gameId, string lobbyId, string userId, string displayName, CancellationToken ct = default);
Task<bool> LeaveLobbyAsync(Guid gameId, string lobbyId, string userId, CancellationToken ct = default);
Task<bool> LeaveLobbyAsync(Guid gameId, string userId, CancellationToken ct = default);
Task<Lobby> CreateLobbyAsync(Guid gameId, string sessionToken, int maxPlayers, Dictionary<string, string>? properties, CancellationToken ct = default);
Task<Lobby?> JoinLobbyAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default);
Task<bool> LeaveLobbyAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default);
Task<bool> LeaveLobbyAsync(Guid gameId, string sessionToken, CancellationToken ct = default);
Task<List<Lobby>> SearchLobbiesAsync(Guid gameId, int maxRoomsToFind, Dictionary<string, string>? filters, CancellationToken ct = default);
Task<bool> SetIsReadyAsync(Guid gameId, string lobbyId, string userId, bool isReady, CancellationToken ct = default);
Task<bool> SetLobbyDataAsync(Guid gameId, string lobbyId, string key, string value, CancellationToken ct = default);
Task<bool> SetIsReadyAsync(Guid gameId, string lobbyId, string sessionToken, bool isReady, CancellationToken ct = default);
Task<bool> SetLobbyDataAsync(Guid gameId, string lobbyId, string sessionToken, string key, string value, CancellationToken ct = default);
Task<string?> GetLobbyDataAsync(Guid gameId, string lobbyId, string key, 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> SetLobbyStartedAsync(Guid gameId, string lobbyId, CancellationToken ct = default);
Task<Lobby?> GetLobbyAsync(Guid gameId, string lobbyId, string currentUserId, CancellationToken ct = default);
Task<bool> SetLobbyStartedAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default);
Task<Lobby?> GetLobbyAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default);
// stats
Task<int> GetGlobalPlayerCountAsync(CancellationToken ct = default);
@@ -31,6 +31,7 @@ internal class LobbyState
{
public required string Id { get; init; }
public required Guid GameId { get; init; }
public required string OwnerSessionToken { get; set; }
public required string OwnerUserId { get; set; }
public int MaxPlayers { get; init; }
public DateTime CreatedAtUtc { get; init; } = DateTime.UtcNow;
@@ -52,13 +53,15 @@ public class LobbyService : ILobbyService
private const int MaxPropertyCount = 32;
private readonly ConcurrentDictionary<string, LobbyState> _lobbies = new();
// user index key gameIdN:userId -> lobbyId
// user index key gameIdN:sessionToken -> lobbyId
private readonly ConcurrentDictionary<string, string> _userLobbyIndexByGame = new();
private readonly ILobbyEventHub _events;
private readonly IAuthenticationService _authService;
public LobbyService(ILobbyEventHub events)
public LobbyService(ILobbyEventHub events, IAuthenticationService authService)
{
_events = events;
_authService = authService;
}
private static string SanitizeString(string? s, int maxLen)
@@ -66,7 +69,7 @@ public class LobbyService : ILobbyService
private static bool IsInvalidId(string? id) => string.IsNullOrWhiteSpace(id) || id.Length > 128;
private static Lobby Project(LobbyState s, string? currentUserId = null)
private async Task<Lobby> ProjectAsync(LobbyState s, string? currentSessionToken = null, CancellationToken ct = default)
{
var lobby = new Lobby
{
@@ -75,21 +78,34 @@ public class LobbyService : ILobbyService
LobbyId = s.Id,
LobbyCode = s.LobbyCode,
MaxPlayers = s.MaxPlayers,
IsOwner = currentUserId != null && string.Equals(s.OwnerUserId, currentUserId, StringComparison.Ordinal)
IsOwner = false
};
if (currentSessionToken != null)
{
var validation = await _authService.ValidateTokenAsync(currentSessionToken, ct);
if (validation.IsValid && string.Equals(s.OwnerUserId, validation.UserId, StringComparison.Ordinal))
{
lobby.IsOwner = true;
}
}
foreach (var kv in s.Properties)
lobby.Properties[kv.Key] = kv.Value;
foreach (var m in s.Members)
lobby.Members.Add(new LobbyUser { Id = m.Id, DisplayName = m.DisplayName, IsReady = m.IsReady });
lobby.Members.Add(new LobbyUser { SessionToken = m.SessionToken, UserId = m.UserId, DisplayName = m.DisplayName, IsReady = m.IsReady });
return lobby;
}
public async Task<Lobby> CreateLobbyAsync(Guid gameId, string ownerUserId, string ownerDisplayName, int maxPlayers, Dictionary<string, string>? properties, CancellationToken ct = default)
public async Task<Lobby> CreateLobbyAsync(Guid gameId, string sessionToken, int maxPlayers, Dictionary<string, string>? properties, CancellationToken ct = default)
{
if (gameId == Guid.Empty || IsInvalidId(ownerUserId))
throw new ArgumentException("Invalid gameId or ownerUserId");
if (gameId == Guid.Empty || IsInvalidId(sessionToken))
throw new ArgumentException("Invalid gameId or sessionToken");
var validation = await _authService.ValidateTokenAsync(sessionToken, ct);
if (!validation.IsValid)
throw new UnauthorizedAccessException("Invalid session token");
var display = SanitizeString(ownerDisplayName, DisplayNameMaxLength);
var clampedPlayers = Math.Clamp(maxPlayers, MinPlayers, MaxPlayersLimit);
string GenerateLobbyCode()
@@ -112,7 +128,8 @@ public class LobbyService : ILobbyService
{
Id = Guid.NewGuid().ToString("N"),
GameId = gameId,
OwnerUserId = ownerUserId,
OwnerSessionToken = sessionToken,
OwnerUserId = validation.UserId!,
MaxPlayers = clampedPlayers,
Name = properties != null && properties.TryGetValue("Name", out var n) ? SanitizeString(n, NameMaxLength) : string.Empty,
LobbyCode = GenerateLobbyCode()
@@ -132,75 +149,100 @@ public class LobbyService : ILobbyService
state.Members.Add(new LobbyUser
{
Id = ownerUserId,
DisplayName = display,
SessionToken = sessionToken,
UserId = validation.UserId!,
DisplayName = validation.DisplayName!,
IsReady = false
});
_lobbies[state.Id] = state;
_userLobbyIndexByGame[$"{gameId:N}:{ownerUserId}"] = state.Id;
_userLobbyIndexByGame[$"{gameId:N}:{sessionToken}"] = state.Id;
await _events.BroadcastAsync(gameId, state.Id, new { type = "lobby_created", lobbyId = state.Id, ownerUserId, ownerDisplayName = display, maxPlayers = state.MaxPlayers }, ct);
await _events.BroadcastAsync(gameId, state.Id, new { type = "lobby_created", lobbyId = state.Id, ownerUserId = validation.UserId, ownerDisplayName = validation.DisplayName, maxPlayers = state.MaxPlayers }, ct);
return Project(state, ownerUserId);
return await ProjectAsync(state, sessionToken, ct);
}
public Task<Lobby?> JoinLobbyAsync(Guid gameId, string lobbyId, string userId, string displayName, CancellationToken ct = default)
public async Task<Lobby?> JoinLobbyAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default)
{
if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(userId))
return Task.FromResult<Lobby?>(null);
if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(sessionToken))
return null;
var validation = await _authService.ValidateTokenAsync(sessionToken, ct);
if (!validation.IsValid)
return null;
if (!_lobbies.TryGetValue(lobbyId, out var state))
return Task.FromResult<Lobby?>(null);
return null;
if (state.GameId != gameId)
return Task.FromResult<Lobby?>(null);
return null;
// prevent multi lobby join per game
if (_userLobbyIndexByGame.TryGetValue($"{gameId:N}:{userId}", out var existingLobbyId) && existingLobbyId != lobbyId)
return Task.FromResult<Lobby?>(null);
var name = SanitizeString(displayName, DisplayNameMaxLength);
if (_userLobbyIndexByGame.TryGetValue($"{gameId:N}:{sessionToken}", out var existingLobbyId) && existingLobbyId != lobbyId)
return null;
LobbyUser? existingMember = null;
bool canJoin = false;
lock (state)
{
if (state.Started) return Task.FromResult<Lobby?>(null);
if (state.Members.Any(m => m.Id == userId))
return Task.FromResult<Lobby?>(Project(state, userId));
if (state.Members.Count >= state.MaxPlayers)
return Task.FromResult<Lobby?>(null);
state.Members.Add(new LobbyUser { Id = userId, DisplayName = name, IsReady = false });
if (state.Started) return null;
existingMember = state.Members.FirstOrDefault(m => m.SessionToken == sessionToken);
if (existingMember != null)
canJoin = true;
else if (state.Members.Count < state.MaxPlayers)
{
state.Members.Add(new LobbyUser {
SessionToken = sessionToken,
UserId = validation.UserId!,
DisplayName = validation.DisplayName!,
IsReady = false
});
canJoin = true;
}
}
_userLobbyIndexByGame[$"{gameId:N}:{userId}"] = lobbyId;
_ = _events.BroadcastAsync(gameId, lobbyId, new { type = "member_joined", userId, displayName = name }, ct);
return Task.FromResult<Lobby?>(Project(state, userId));
if (!canJoin) return null;
if (existingMember != null)
return await ProjectAsync(state, sessionToken, ct);
_userLobbyIndexByGame[$"{gameId:N}:{sessionToken}"] = lobbyId;
_ = _events.BroadcastAsync(gameId, lobbyId, new { type = "member_joined", userId = validation.UserId, displayName = validation.DisplayName }, ct);
return await ProjectAsync(state, sessionToken, ct);
}
public Task<bool> LeaveLobbyAsync(Guid gameId, string lobbyId, string userId, CancellationToken ct = default)
public async Task<bool> LeaveLobbyAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default)
{
if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(userId))
return Task.FromResult(false);
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 Task.FromResult(false);
return false;
if (state.GameId != gameId)
return Task.FromResult(false);
return false;
var removed = false;
string? newOwner = null;
lock (state)
{
var idx = state.Members.FindIndex(m => m.Id == userId);
var idx = state.Members.FindIndex(m => m.SessionToken == sessionToken);
if (idx >= 0)
{
var member = state.Members[idx];
state.Members.RemoveAt(idx);
removed = true;
if (state.OwnerUserId == userId && state.Members.Count > 0)
if (state.OwnerUserId == member.UserId && state.Members.Count > 0)
{
state.OwnerUserId = state.Members[0].Id; // promote first
newOwner = state.OwnerUserId;
var newOwnerMember = state.Members[0];
state.OwnerUserId = newOwnerMember.UserId;
state.OwnerSessionToken = newOwnerMember.SessionToken;
newOwner = newOwnerMember.UserId;
}
}
}
_userLobbyIndexByGame.TryRemove($"{gameId:N}:{userId}", out _);
_userLobbyIndexByGame.TryRemove($"{gameId:N}:{sessionToken}", out _);
if (removed)
{
// remove lobby if empty
@@ -212,28 +254,28 @@ public class LobbyService : ILobbyService
}
else
{
_ = _events.BroadcastAsync(gameId, lobbyId, new { type = "member_left", userId, newOwnerUserId = newOwner }, ct);
_ = _events.BroadcastAsync(gameId, lobbyId, new { type = "member_left", userId = validation.UserId, newOwnerUserId = newOwner }, ct);
}
}
return Task.FromResult(removed);
return removed;
}
public Task<bool> LeaveLobbyAsync(Guid gameId, string userId, CancellationToken ct = default)
public async Task<bool> LeaveLobbyAsync(Guid gameId, string sessionToken, CancellationToken ct = default)
{
if (gameId == Guid.Empty || IsInvalidId(userId))
return Task.FromResult(false);
if (gameId == Guid.Empty || IsInvalidId(sessionToken))
return false;
if (_userLobbyIndexByGame.TryGetValue($"{gameId:N}:{userId}", out var lobbyId))
if (_userLobbyIndexByGame.TryGetValue($"{gameId:N}:{sessionToken}", out var lobbyId))
{
return LeaveLobbyAsync(gameId, lobbyId, userId, ct);
return await LeaveLobbyAsync(gameId, lobbyId, sessionToken, ct);
}
return Task.FromResult(false);
return false;
}
public Task<List<Lobby>> SearchLobbiesAsync(Guid gameId, int maxRoomsToFind, Dictionary<string, string>? filters, CancellationToken ct = default)
public async Task<List<Lobby>> SearchLobbiesAsync(Guid gameId, int maxRoomsToFind, Dictionary<string, string>? filters, CancellationToken ct = default)
{
if (gameId == Guid.Empty)
return Task.FromResult(new List<Lobby>());
return new List<Lobby>();
var take = Math.Clamp(maxRoomsToFind, 1, 100);
IEnumerable<LobbyState> query = _lobbies.Values.Where(l => l.GameId == gameId && !l.Started && l.Members.Count < l.MaxPlayers);
@@ -247,55 +289,72 @@ public class LobbyService : ILobbyService
query = query.Where(l => l.Properties.TryGetValue(k, out var pv) && string.Equals(pv, v, StringComparison.OrdinalIgnoreCase));
}
}
var list = query.OrderByDescending(l => l.CreatedAtUtc).Take(take).Select(s => Project(s)).ToList();
return Task.FromResult(list);
var list = new List<Lobby>();
foreach (var state in query.OrderByDescending(l => l.CreatedAtUtc).Take(take))
{
list.Add(await ProjectAsync(state, null, ct));
}
return list;
}
public Task<bool> SetIsReadyAsync(Guid gameId, string lobbyId, string userId, bool isReady, CancellationToken ct = default)
public async Task<bool> SetIsReadyAsync(Guid gameId, string lobbyId, string sessionToken, bool isReady, CancellationToken ct = default)
{
if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(userId))
return Task.FromResult(false);
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 Task.FromResult(false);
return false;
if (state.GameId != gameId)
return Task.FromResult(false);
return false;
lock (state)
{
if (state.Started) return Task.FromResult(false);
var m = state.Members.FirstOrDefault(x => x.Id == userId);
if (m is null) return Task.FromResult(false);
if (state.Started) return false;
var m = state.Members.FirstOrDefault(x => x.SessionToken == sessionToken);
if (m is null) return false;
m.IsReady = isReady;
}
_ = _events.BroadcastAsync(gameId, lobbyId, new { type = "member_ready", userId, isReady }, ct);
return Task.FromResult(true);
_ = _events.BroadcastAsync(gameId, lobbyId, new { type = "member_ready", userId = validation.UserId, isReady }, ct);
return true;
}
public Task<bool> SetLobbyDataAsync(Guid gameId, string lobbyId, string key, string value, CancellationToken ct = default)
public async Task<bool> SetLobbyDataAsync(Guid gameId, string lobbyId, string sessionToken, string key, string value, CancellationToken ct = default)
{
if (gameId == Guid.Empty || IsInvalidId(lobbyId))
return Task.FromResult(false);
if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(sessionToken))
return false;
if (string.IsNullOrWhiteSpace(key))
return Task.FromResult(false);
return false;
var validation = await _authService.ValidateTokenAsync(sessionToken, ct);
if (!validation.IsValid)
return false;
if (!_lobbies.TryGetValue(lobbyId, out var state))
return Task.FromResult(false);
return false;
if (state.GameId != gameId)
return Task.FromResult(false);
return false;
// Only owner can set lobby data
if (state.OwnerUserId != validation.UserId)
return false;
lock (state)
{
var k = SanitizeString(key, PropertyKeyMaxLength);
if (string.IsNullOrEmpty(k)) return Task.FromResult(false);
if (string.IsNullOrEmpty(k)) return false;
var v = SanitizeString(value, PropertyValueMaxLength);
if (!state.Properties.ContainsKey(k) && state.Properties.Count >= MaxPropertyCount)
return Task.FromResult(false);
return false;
state.Properties[k] = v;
if (string.Equals(k, "Name", StringComparison.OrdinalIgnoreCase))
state.Name = SanitizeString(v, NameMaxLength);
}
_ = _events.BroadcastAsync(gameId, lobbyId, new { type = "lobby_data", key, value }, ct);
return Task.FromResult(true);
return true;
}
public Task<string?> GetLobbyDataAsync(Guid gameId, string lobbyId, string key, CancellationToken ct = default)
@@ -323,56 +382,59 @@ public class LobbyService : ILobbyService
{
return Task.FromResult(state.Members.Select(m => new LobbyUser
{
Id = m.Id,
SessionToken = m.SessionToken,
UserId = m.UserId,
DisplayName = m.DisplayName,
IsReady = m.IsReady
}).ToList());
}
}
public Task<bool> SetAllReadyAsync(Guid gameId, string lobbyId, CancellationToken ct = default)
public async Task<bool> SetLobbyStartedAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default)
{
if (gameId == Guid.Empty || IsInvalidId(lobbyId))
return Task.FromResult(false);
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 Task.FromResult(false);
return false;
if (state.GameId != gameId)
return Task.FromResult(false);
lock (state)
{
if (state.Started) return Task.FromResult(false);
foreach (var m in state.Members)
m.IsReady = true;
}
_ = _events.BroadcastAsync(gameId, lobbyId, new { type = "all_ready" }, ct);
return Task.FromResult(true);
}
public Task<bool> SetLobbyStartedAsync(Guid gameId, string lobbyId, CancellationToken ct = default)
{
if (gameId == Guid.Empty || IsInvalidId(lobbyId))
return Task.FromResult(false);
if (!_lobbies.TryGetValue(lobbyId, out var state))
return Task.FromResult(false);
if (state.GameId != gameId)
return Task.FromResult(false);
if (state.Started) return Task.FromResult(false);
return false;
// Only owner can start lobby
if (state.OwnerUserId != validation.UserId)
return false;
if (state.Started) return false;
state.Started = true;
_ = _events.BroadcastAsync(gameId, lobbyId, new { type = "lobby_started" }, ct);
return Task.FromResult(true);
return true;
}
public Task<Lobby?> GetLobbyAsync(Guid gameId, string lobbyId, string currentUserId, CancellationToken ct = default)
public async Task<Lobby?> GetLobbyAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default)
{
if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(currentUserId))
return Task.FromResult<Lobby?>(null);
if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(sessionToken))
return null;
var validation = await _authService.ValidateTokenAsync(sessionToken, ct);
if (!validation.IsValid)
return null;
if (!_lobbies.TryGetValue(lobbyId, out var state))
return Task.FromResult<Lobby?>(null);
return null;
if (state.GameId != gameId)
return Task.FromResult<Lobby?>(null);
return Task.FromResult<Lobby?>(Project(state, currentUserId));
return null;
// Check if user is member of this lobby
if (!state.Members.Any(m => m.SessionToken == sessionToken))
return null;
return await ProjectAsync(state, sessionToken, ct);
}
public Task<int> GetGlobalPlayerCountAsync(CancellationToken ct = default)
@@ -411,7 +473,7 @@ public class LobbyService : ILobbyService
foreach (var m in state.Members)
{
// unique per user id in game
players[m.Id] = new LobbyUser { Id = m.Id, DisplayName = m.DisplayName, IsReady = m.IsReady };
players[m.UserId] = new LobbyUser { SessionToken = m.SessionToken, UserId = m.UserId, DisplayName = m.DisplayName, IsReady = m.IsReady };
}
}
}

View File

@@ -5,9 +5,12 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "purrlobby.exil.dev",
"AllowedHosts": "*",
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://0.0.0.0:8080"
},
"Https": {
"Url": "https://0.0.0.0:443",
"Certificate": {

View File

@@ -13,8 +13,9 @@ services:
context: .
target: final
ports:
- 443:8080
network_mode: "host"
- 443:443
- 8080:8080
# The commented out section below is an example of how to define a PostgreSQL
# database that your application can use. `depends_on` tells Docker Compose to
# start the database before your application. The `db-data` volume persists the
@@ -42,7 +43,6 @@ services:
# interval: 10s
# timeout: 5s
# retries: 5
# volumes:
# db-data:
# secrets: