namespace GeoSus.Server; using System.Collections.Concurrent; using System.Net; using System.Net.WebSockets; using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; /// /// Admin Panel - Webová administrace serveru /// /// Funkce: /// - Přehled stavu serveru /// - Real-time spectate mode pro lobby /// - Konfigurace serveru za běhu /// - Informace o hráčích /// - WebSocket pro live updates /// public class AdminPanel { private readonly ILogger _logger; private readonly ServerConfig _config; private readonly LobbyManager _lobbyManager; private readonly StatsDb _statsDb; // Bezpečnost private readonly string _adminPasswordHash; private readonly ConcurrentDictionary _sessions = new(); private readonly TimeSpan _sessionTimeout = TimeSpan.FromHours(8); // WebSocket connections pro spectate private readonly ConcurrentDictionary _spectators = new(); public AdminPanel( ILogger logger, ServerConfig config, LobbyManager lobbyManager, StatsDb statsDb) { _logger = logger; _config = config; _lobbyManager = lobbyManager; _statsDb = statsDb; // Hash admin hesla (z env nebo default) var adminPassword = Environment.GetEnvironmentVariable("GEOSUS_ADMIN_PASSWORD") ?? "admin123"; _adminPasswordHash = HashPassword(adminPassword); _logger.LogInformation("Admin Panel inicializován"); } #region ═══════════════════════════════════════════════════════════════════ // AUTENTIZACE // ════════════════════════════════════════════════════════════════════════ #endregion private static string HashPassword(string password) { using var sha256 = SHA256.Create(); var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(password + "GeoSus_Salt_2026")); return Convert.ToBase64String(bytes); } private string? ValidateSession(HttpListenerRequest request) { var cookie = request.Cookies["geosus_admin_session"]; if (cookie == null) return null; if (_sessions.TryGetValue(cookie.Value, out var session)) { if (DateTime.UtcNow - session.LastActivity < _sessionTimeout) { session.LastActivity = DateTime.UtcNow; return cookie.Value; } _sessions.TryRemove(cookie.Value, out _); } return null; } private string CreateSession() { var sessionId = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); _sessions[sessionId] = new AdminSession { LastActivity = DateTime.UtcNow }; return sessionId; } #region ═══════════════════════════════════════════════════════════════════ // HTTP HANDLING // ════════════════════════════════════════════════════════════════════════ #endregion public async Task HandleRequestAsync(HttpListenerContext context) { var request = context.Request; var response = context.Response; var path = request.Url?.AbsolutePath ?? "/"; try { // Login endpoint - vždy přístupný if (path == "/admin/login" && request.HttpMethod == "POST") { await HandleLoginAsync(request, response); return; } // Statické soubory pro login stránku - bez autentizace if (path == "/admin" || path == "/admin/") { await ServeStaticFileAsync(response, "admin.html", "text/html"); return; } // CSS a JS musí být dostupné bez autentizace (pro login stránku) if (path == "/admin/app.js") { await ServeStaticFileAsync(response, "admin.js", "application/javascript"); return; } if (path == "/admin/style.css") { await ServeStaticFileAsync(response, "admin.css", "text/css"); return; } // Všechno ostatní vyžaduje autentizaci var sessionId = ValidateSession(request); if (sessionId == null) { response.StatusCode = 401; await WriteJsonAsync(response, new { error = "Unauthorized", redirect = "/admin" }); return; } // WebSocket pro spectate if (path.StartsWith("/admin/ws/spectate/") && request.IsWebSocketRequest) { var lobbyId = path["/admin/ws/spectate/".Length..]; await HandleSpectateWebSocketAsync(context, lobbyId, sessionId); return; } // WebSocket pro server stats if (path == "/admin/ws/stats" && request.IsWebSocketRequest) { await HandleStatsWebSocketAsync(context, sessionId); return; } // P13d: archive endpoints with archive id in the URL path // Layout: /admin/api/archive//events and /admin/api/archive//positions if (path.StartsWith("/admin/api/archive/") && path.EndsWith("/events")) { var archiveId = path["/admin/api/archive/".Length..^"/events".Length]; await HandleArchiveEventsAsync(archiveId, response); return; } if (path.StartsWith("/admin/api/archive/") && path.EndsWith("/positions")) { var archiveId = path["/admin/api/archive/".Length..^"/positions".Length]; await HandleArchivePositionsAsync(archiveId, response); return; } // REST API endpointy switch (path) { case "/admin/api/status": await HandleStatusAsync(response); break; case "/admin/api/lobbies": await HandleLobbiesAsync(response); break; case "/admin/api/config": if (request.HttpMethod == "GET") await HandleGetConfigAsync(response); else if (request.HttpMethod == "POST") await HandleSetConfigAsync(request, response); break; case "/admin/api/players": await HandlePlayersAsync(request, response); break; case "/admin/api/kick": await HandleKickAsync(request, response); break; case "/admin/api/broadcast": await HandleBroadcastAsync(request, response); break; // ── P12: lobby manipulation endpoints ──────────────────── case "/admin/api/lobby/end": await HandleLobbyForceEndAsync(request, response); break; case "/admin/api/lobby/phase": await HandleLobbySetPhaseAsync(request, response); break; case "/admin/api/lobby/settings": await HandleLobbyUpdateSettingsAsync(request, response); break; // ── P13d: archive listing endpoint ─────────────────────── case "/admin/api/archive": await HandleListArchivesAsync(response); break; case "/admin/api/logout": _sessions.TryRemove(sessionId, out _); response.SetCookie(new Cookie("geosus_admin_session", "", "/admin") { Expired = true }); await WriteJsonAsync(response, new { success = true }); break; default: response.StatusCode = 404; await WriteJsonAsync(response, new { error = "Not found" }); break; } } catch (Exception ex) { _logger.LogError(ex, "Admin panel error: {Path}", path); // Don't try to write response for WebSocket requests (already submitted) if (!request.IsWebSocketRequest) { try { response.StatusCode = 500; await WriteJsonAsync(response, new { error = ex.Message }); } catch { /* Response may already be sent */ } } } } #region ═══════════════════════════════════════════════════════════════════ // LOGIN // ════════════════════════════════════════════════════════════════════════ #endregion private async Task HandleLoginAsync(HttpListenerRequest request, HttpListenerResponse response) { using var reader = new StreamReader(request.InputStream); var body = await reader.ReadToEndAsync(); var data = JsonSerializer.Deserialize(body); var password = data.GetProperty("password").GetString() ?? ""; var hash = HashPassword(password); if (hash == _adminPasswordHash) { var sessionId = CreateSession(); response.SetCookie(new Cookie("geosus_admin_session", sessionId, "/admin") { HttpOnly = true, Expires = DateTime.Now.Add(_sessionTimeout) }); _logger.LogInformation("Admin login successful from {IP}", request.RemoteEndPoint); await WriteJsonAsync(response, new { success = true }); } else { _logger.LogWarning("Admin login failed from {IP}", request.RemoteEndPoint); response.StatusCode = 401; await WriteJsonAsync(response, new { error = "Invalid password" }); } } #region ═══════════════════════════════════════════════════════════════════ // API ENDPOINTS // ════════════════════════════════════════════════════════════════════════ #endregion private async Task HandleStatusAsync(HttpListenerResponse response) { var uptime = DateTime.UtcNow - Program.StartTime; var status = new { status = "online", version = "1.0.0", uptime = new { days = uptime.Days, hours = uptime.Hours, minutes = uptime.Minutes, seconds = uptime.Seconds, totalSeconds = (long)uptime.TotalSeconds }, lobbies = new { active = _lobbyManager.LobbyCount, players = _lobbyManager.TotalPlayerCount }, memory = new { usedMb = GC.GetTotalMemory(false) / 1024 / 1024, gcCollections = new { gen0 = GC.CollectionCount(0), gen1 = GC.CollectionCount(1), gen2 = GC.CollectionCount(2) } }, sessions = _sessions.Count, spectators = _spectators.Count }; await WriteJsonAsync(response, status); } private async Task HandleLobbiesAsync(HttpListenerResponse response) { var lobbies = _lobbyManager.GetAllLobbies().Select(l => new { id = l.LobbyId, joinCode = l.JoinCode, phase = l.Phase.ToString(), players = l.GetPlayers().Select(p => new { id = p.PlayerId, name = p.DisplayName, state = p.State.ToString(), role = p.Role.ToString(), position = new { lat = p.Position.Lat, lng = p.Position.Lon }, isHost = p.IsHost }), playerCount = l.PlayerCount, settings = new { impostorCount = l.Settings.ImpostorCount, taskCount = l.Settings.TaskCount, center = new { lat = l.Settings.PlayAreaCenter.Lat, lng = l.Settings.PlayAreaCenter.Lon }, radius = l.Settings.PlayAreaRadius }, createdAt = l.CreatedAt, activeSabotage = l.ActiveSabotage?.Type.ToString() }); await WriteJsonAsync(response, new { lobbies }); } /// /// P13b: full ServerConfig round-trip. The previous hand-rolled selective /// serialization only exposed ~10 fields; the admin panel now needs the /// entire appsettings.json shape (~50 fields). We serialize through the /// same JsonOptions the file loader uses so camelCase / enum-as-string /// stays consistent. The StatsApiKey is masked in the GET response so /// the secret doesn't leak into a browser dump. /// private async Task HandleGetConfigAsync(HttpListenerResponse response) { // Round-trip through JSON to get a clean copy + apply the server's // own naming policy / enum converters. Mask StatsApiKey on the way out. var json = JsonSerializer.Serialize(_config, JsonOptions.Indented); var node = JsonDocument.Parse(json).RootElement; // Re-emit with the api-key masked. using var ms = new MemoryStream(); using (var writer = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = true })) { writer.WriteStartObject(); foreach (var prop in node.EnumerateObject()) { if (prop.NameEquals("statsApiKey")) { var raw = prop.Value.ValueKind == JsonValueKind.String ? prop.Value.GetString() : null; if (string.IsNullOrEmpty(raw)) writer.WriteNull("statsApiKey"); else writer.WriteString("statsApiKey", "********"); continue; } prop.WriteTo(writer); } writer.WriteEndObject(); } response.ContentType = "application/json; charset=utf-8"; var bytes = ms.ToArray(); response.ContentLength64 = bytes.Length; await response.OutputStream.WriteAsync(bytes); } /// /// P13b: accept arbitrary subset of ServerConfig fields. Only updates /// fields the request actually carries; ignores unknown fields. Persists /// the file at appsettings.json on every successful update so changes /// survive restart. Apply rule: changes affect ONLY new lobbies created /// after this point - existing lobbies keep their snapshot of the values /// taken at their creation time. (See LobbySettings.FromDefaults.) /// private async Task HandleSetConfigAsync(HttpListenerRequest request, HttpListenerResponse response) { using var reader = new StreamReader(request.InputStream); var body = await reader.ReadToEndAsync(); var data = JsonSerializer.Deserialize(body); // Reflection-based update: walk every public settable property on // ServerConfig, look for a matching camelCased field in the JSON, // try to coerce. Skip the masked key sentinel so the user can leave // ******** in the form without nuking their real key. var props = typeof(ServerConfig).GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); int applied = 0; foreach (var p in props) { if (!p.CanWrite) continue; var camel = char.ToLowerInvariant(p.Name[0]) + p.Name[1..]; if (!data.TryGetProperty(camel, out var el)) continue; try { if (p.Name == nameof(ServerConfig.StatsApiKey) && el.ValueKind == JsonValueKind.String && el.GetString() == "********") { continue; // masked sentinel - leave existing } object? val = (Type.GetTypeCode(p.PropertyType)) switch { TypeCode.Int32 => el.ValueKind == JsonValueKind.Number ? el.GetInt32() : int.TryParse(el.GetString(), out var i) ? i : (object?)null, TypeCode.Int64 => el.ValueKind == JsonValueKind.Number ? el.GetInt64() : long.TryParse(el.GetString(), out var l) ? l : (object?)null, TypeCode.Double => el.ValueKind == JsonValueKind.Number ? el.GetDouble() : double.TryParse(el.GetString(), out var d) ? d : (object?)null, TypeCode.Boolean => el.ValueKind == JsonValueKind.True ? true : el.ValueKind == JsonValueKind.False ? false : (object?)null, TypeCode.String => el.ValueKind == JsonValueKind.String ? el.GetString() : el.ValueKind == JsonValueKind.Null ? null : (object?)null, _ => p.PropertyType.IsEnum && el.ValueKind == JsonValueKind.String && Enum.TryParse(p.PropertyType, el.GetString(), true, out var ev) ? ev : null }; if (val != null || p.PropertyType == typeof(string)) { p.SetValue(_config, val); applied++; } } catch (Exception ex) { _logger.LogWarning(ex, "Admin SetConfig: failed to set {Property}", p.Name); } } // Persist to disk so the change survives restart. try { _config.Save("appsettings.json"); } catch (Exception ex) { _logger.LogError(ex, "Failed to persist admin config update to appsettings.json"); } _logger.LogInformation("Config updated via admin panel: {Count} field(s)", applied); await WriteJsonAsync(response, new { success = true, fieldsApplied = applied }); } private async Task HandlePlayersAsync(HttpListenerRequest request, HttpListenerResponse response) { var query = request.QueryString; var search = query["search"] ?? ""; var allPlayers = _lobbyManager.GetAllLobbies() .SelectMany(l => l.GetPlayers().Select(p => new { playerId = p.PlayerId, displayName = p.DisplayName, lobbyId = l.LobbyId, joinCode = l.JoinCode, state = p.State.ToString(), role = p.Role.ToString(), position = new { lat = p.Position.Lat, lng = p.Position.Lon }, isHost = p.IsHost, cheatScore = p.CheatScore })) .Where(p => string.IsNullOrEmpty(search) || p.displayName.Contains(search, StringComparison.OrdinalIgnoreCase) || p.playerId.Contains(search, StringComparison.OrdinalIgnoreCase)) .ToList(); await WriteJsonAsync(response, new { players = allPlayers, total = allPlayers.Count }); } private async Task HandleKickAsync(HttpListenerRequest request, HttpListenerResponse response) { using var reader = new StreamReader(request.InputStream); var body = await reader.ReadToEndAsync(); var data = JsonSerializer.Deserialize(body); var playerId = data.GetProperty("playerId").GetString(); var reason = data.TryGetProperty("reason", out var r) ? r.GetString() : "Kicked by admin"; // Najdi hráče a kickni ho foreach (var lobby in _lobbyManager.GetAllLobbies()) { if (lobby.TryKickPlayer(playerId!, reason!)) { _logger.LogInformation("Player {PlayerId} kicked by admin: {Reason}", playerId, reason); await WriteJsonAsync(response, new { success = true }); return; } } response.StatusCode = 404; await WriteJsonAsync(response, new { error = "Player not found" }); } private async Task HandleBroadcastAsync(HttpListenerRequest request, HttpListenerResponse response) { using var reader = new StreamReader(request.InputStream); var body = await reader.ReadToEndAsync(); var data = JsonSerializer.Deserialize(body); var message = data.GetProperty("message").GetString(); var lobbyId = data.TryGetProperty("lobbyId", out var l) ? l.GetString() : null; if (string.IsNullOrEmpty(lobbyId)) { // Broadcast všem foreach (var lobby in _lobbyManager.GetAllLobbies()) { lobby.BroadcastSystemMessage(message!); } } else { // Broadcast do konkrétní lobby var lobby = _lobbyManager.GetLobby(lobbyId); lobby?.BroadcastSystemMessage(message!); } _logger.LogInformation("Admin broadcast: {Message}", message); await WriteJsonAsync(response, new { success = true }); } // ── P12: lobby manipulation endpoint handlers ──────────────────────────── /// /// Force-end a game in a specific lobby. POST { "lobbyId": "...", "reason": "optional" }. /// Returns { success: true } on application, 404 if lobby missing, or /// 409 if the lobby was already in Lobby/Ended phase (nothing to end). /// private async Task HandleLobbyForceEndAsync(HttpListenerRequest request, HttpListenerResponse response) { using var reader = new StreamReader(request.InputStream); var body = await reader.ReadToEndAsync(); var data = JsonSerializer.Deserialize(body); var lobbyId = data.GetProperty("lobbyId").GetString(); var reason = data.TryGetProperty("reason", out var r) ? (r.GetString() ?? "ended by admin") : "ended by admin"; var lobby = _lobbyManager.GetLobby(lobbyId!); if (lobby == null) { response.StatusCode = 404; await WriteJsonAsync(response, new { error = "Lobby not found" }); return; } var ok = await lobby.TryForceEndGameAsync(reason); if (!ok) { response.StatusCode = 409; await WriteJsonAsync(response, new { error = "Cannot force-end - lobby not in an active game phase" }); return; } await WriteJsonAsync(response, new { success = true }); } /// /// Force a lobby into a specific phase. POST { "lobbyId": "...", "phase": "Playing|Meeting|Ended|..." }. /// Voting is rejected (it's not a real wire phase - lives inside Meeting). /// private async Task HandleLobbySetPhaseAsync(HttpListenerRequest request, HttpListenerResponse response) { using var reader = new StreamReader(request.InputStream); var body = await reader.ReadToEndAsync(); var data = JsonSerializer.Deserialize(body); var lobbyId = data.GetProperty("lobbyId").GetString(); var phase = data.GetProperty("phase").GetString(); var lobby = _lobbyManager.GetLobby(lobbyId!); if (lobby == null) { response.StatusCode = 404; await WriteJsonAsync(response, new { error = "Lobby not found" }); return; } var ok = await lobby.TryAdminSetPhaseAsync(phase!); if (!ok) { response.StatusCode = 400; await WriteJsonAsync(response, new { error = "Invalid phase (rejected: 'Voting' is not a wire phase)" }); return; } await WriteJsonAsync(response, new { success = true }); } /// /// Update lobby settings. POST { "lobbyId": "...", "radius": ..., "impostorCount": ..., "taskCount": ..., "tiePolicy": "..." }. /// All settings fields optional - only those present in the body are /// applied. Lobby must be in Lobby phase; mid-game settings changes are /// rejected to keep gameplay consistent. /// private async Task HandleLobbyUpdateSettingsAsync(HttpListenerRequest request, HttpListenerResponse response) { using var reader = new StreamReader(request.InputStream); var body = await reader.ReadToEndAsync(); var data = JsonSerializer.Deserialize(body); var lobbyId = data.GetProperty("lobbyId").GetString(); double? radius = data.TryGetProperty("radius", out var rd) && rd.ValueKind == JsonValueKind.Number ? rd.GetDouble() : (double?)null; int? impostors = data.TryGetProperty("impostorCount", out var ic) && ic.ValueKind == JsonValueKind.Number ? ic.GetInt32() : (int?)null; int? tasks = data.TryGetProperty("taskCount", out var tc) && tc.ValueKind == JsonValueKind.Number ? tc.GetInt32() : (int?)null; string? tiePolicy = data.TryGetProperty("tiePolicy", out var tp) ? tp.GetString() : null; var lobby = _lobbyManager.GetLobby(lobbyId!); if (lobby == null) { response.StatusCode = 404; await WriteJsonAsync(response, new { error = "Lobby not found" }); return; } var ok = await lobby.TryUpdateLobbySettingsAsync(radius, impostors, tasks, tiePolicy); if (!ok) { response.StatusCode = 409; await WriteJsonAsync(response, new { error = "Cannot update settings - lobby is past the Lobby phase" }); return; } await WriteJsonAsync(response, new { success = true }); } #region ═══════════════════════════════════════════════════════════════════ // P13d: ARCHIVE // ════════════════════════════════════════════════════════════════════════ #endregion /// /// P13d: list every archived lobby on disk. Persistence.ArchiveLobby /// drops them at /archive/_/. We parse /// the directory name back into (lobbyId, timestamp) for the UI, count /// the WAL events, and report on-disk size so the operator can spot /// abnormally-large or empty runs at a glance. /// private async Task HandleListArchivesAsync(HttpListenerResponse response) { var archiveDir = Path.Combine(_config.DataPath, "archive"); if (!Directory.Exists(archiveDir)) { await WriteJsonAsync(response, new { archives = Array.Empty() }); return; } var archives = Directory.GetDirectories(archiveDir) .Select(d => new DirectoryInfo(d)) .OrderByDescending(d => d.CreationTimeUtc) .Select(d => { var name = d.Name; var lastUnderscore = name.LastIndexOf('_'); string lobbyId = name; string timestampRaw = ""; if (lastUnderscore > 0) { lobbyId = name[..lastUnderscore]; timestampRaw = name[(lastUnderscore + 1)..]; } string isoTimestamp = ""; if (DateTime.TryParseExact(timestampRaw, "yyyyMMddHHmmss", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out var parsedTs)) { isoTimestamp = parsedTs.ToString("o"); } else { // Fallback: directory creation time. isoTimestamp = d.CreationTimeUtc.ToString("o"); } int eventCount = 0; long sizeBytes = 0; try { foreach (var w in Directory.GetFiles(d.FullName, "wal_*.ndjson")) { var fi = new FileInfo(w); sizeBytes += fi.Length; eventCount += File.ReadLines(w).Count(l => !string.IsNullOrWhiteSpace(l)); } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to stat archive {Dir}", d.FullName); } return new { id = name, lobbyId, timestamp = isoTimestamp, eventCount, sizeBytes }; }) .ToList(); await WriteJsonAsync(response, new { archives }); } /// /// P13d: stream a single archive's WAL events back to the admin panel as /// a JSON array. The on-disk format is NDJSON (one already-serialized /// GameEvent per line); we glue the lines with commas inside [] rather /// than re-parsing each one - the admin panel doesn't need the parsed /// tree on the server side, and zero-copy keeps memory usage bounded /// even for hour-long lobbies. /// private async Task HandleArchiveEventsAsync(string archiveId, HttpListenerResponse response) { if (!IsSafeArchiveId(archiveId)) { response.StatusCode = 400; await WriteJsonAsync(response, new { error = "Invalid archive id" }); return; } var archivePath = Path.Combine(_config.DataPath, "archive", archiveId); if (!Directory.Exists(archivePath)) { response.StatusCode = 404; await WriteJsonAsync(response, new { error = "Archive not found" }); return; } var walFiles = Directory.GetFiles(archivePath, "wal_*.ndjson") .OrderBy(f => f) .ToList(); // Buffer the whole array in memory so we can set Content-Length and // avoid chunked transfer headaches. Typical lobby archives are well // under 10MB; if we ever need to stream, swap in chunked-encoding here. var sb = new StringBuilder(); sb.Append('['); bool first = true; foreach (var w in walFiles) { string[] lines; try { lines = await File.ReadAllLinesAsync(w); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to read archive WAL {Path}", w); continue; } foreach (var line in lines) { if (string.IsNullOrWhiteSpace(line)) continue; if (!first) sb.Append(','); sb.Append(line); first = false; } } sb.Append(']'); response.ContentType = "application/json; charset=utf-8"; var bytes = Encoding.UTF8.GetBytes(sb.ToString()); response.ContentLength64 = bytes.Length; await response.OutputStream.WriteAsync(bytes); } /// /// P13d: extract per-player polylines from an archive's WAL. We don't /// persist UpdatePosition (high-rate client telemetry), so we reconstruct /// movement traces from the events that DO carry coordinates: kill/body/ /// task-start/meeting-call/etc. Each event with a `location` or `position` /// payload is attributed to the actor (preferred) or the victim/clientUuid /// inside the payload, then stitched into a per-player time-ordered list /// the frontend renders as a Leaflet polyline. /// private async Task HandleArchivePositionsAsync(string archiveId, HttpListenerResponse response) { if (!IsSafeArchiveId(archiveId)) { response.StatusCode = 400; await WriteJsonAsync(response, new { error = "Invalid archive id" }); return; } var archivePath = Path.Combine(_config.DataPath, "archive", archiveId); if (!Directory.Exists(archivePath)) { response.StatusCode = 404; await WriteJsonAsync(response, new { error = "Archive not found" }); return; } var walFiles = Directory.GetFiles(archivePath, "wal_*.ndjson") .OrderBy(f => f) .ToList(); var polylines = new Dictionary>(); var playerNames = new Dictionary(); foreach (var w in walFiles) { string[] lines; try { lines = await File.ReadAllLinesAsync(w); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to read archive WAL {Path}", w); continue; } foreach (var line in lines) { if (string.IsNullOrWhiteSpace(line)) continue; JsonDocument? doc = null; try { doc = JsonDocument.Parse(line); } catch { continue; } using (doc) { var root = doc.RootElement; string eventType = root.TryGetProperty("eventType", out var et) ? (et.GetString() ?? "") : ""; string actor = root.TryGetProperty("actor", out var a) ? (a.GetString() ?? "") : ""; string ts = root.TryGetProperty("timestamp", out var t) ? (t.GetString() ?? "") : ""; if (!root.TryGetProperty("payload", out var payload) || payload.ValueKind != JsonValueKind.Object) continue; // Capture display names from PlayerJoined for legend labels. if (eventType == "PlayerJoined") { string cid = payload.TryGetProperty("clientUuid", out var c) ? (c.GetString() ?? "") : ""; string dn = payload.TryGetProperty("displayName", out var d) ? (d.GetString() ?? "") : ""; if (!string.IsNullOrEmpty(cid) && !string.IsNullOrEmpty(dn)) playerNames[cid] = dn; continue; } // Find a (lat,lon) pair in either `location` or `position`. double? lat = null, lon = null; if (payload.TryGetProperty("location", out var loc) && loc.ValueKind == JsonValueKind.Object) { if (loc.TryGetProperty("lat", out var la) && la.ValueKind == JsonValueKind.Number) lat = la.GetDouble(); if (loc.TryGetProperty("lon", out var lo) && lo.ValueKind == JsonValueKind.Number) lon = lo.GetDouble(); } else if (payload.TryGetProperty("position", out var pos) && pos.ValueKind == JsonValueKind.Object) { if (pos.TryGetProperty("lat", out var la) && la.ValueKind == JsonValueKind.Number) lat = la.GetDouble(); if (pos.TryGetProperty("lon", out var lo) && lo.ValueKind == JsonValueKind.Number) lon = lo.GetDouble(); } if (!lat.HasValue || !lon.HasValue) continue; // Attribute the position to a specific player. PlayerKilled // gives us the victim's last position (more useful than the // killer's for trace reconstruction); everything else falls // through to actor / payload.clientUuid. string? pid = null; if (eventType == "PlayerKilled") pid = payload.TryGetProperty("victimId", out var v) ? v.GetString() : null; else if (!string.IsNullOrEmpty(actor)) pid = actor; else if (payload.TryGetProperty("clientUuid", out var c2)) pid = c2.GetString(); if (string.IsNullOrEmpty(pid)) continue; if (!polylines.TryGetValue(pid, out var list)) { list = new List(); polylines[pid] = list; } list.Add(new { lat = lat.Value, lon = lon.Value, ts, evt = eventType }); } } } var result = polylines.Select(kvp => new { playerId = kvp.Key, displayName = playerNames.TryGetValue(kvp.Key, out var n) ? n : kvp.Key, points = kvp.Value }).ToList(); await WriteJsonAsync(response, new { polylines = result }); } /// /// Reject any archive id containing a path-traversal token. We also bar /// raw separators because the on-disk layout is one flat directory per /// archive - no nesting expected. /// private static bool IsSafeArchiveId(string archiveId) { if (string.IsNullOrEmpty(archiveId)) return false; if (archiveId.Contains("..")) return false; if (archiveId.Contains('/') || archiveId.Contains('\\')) return false; return true; } #region ═══════════════════════════════════════════════════════════════════ // WEBSOCKET - SPECTATE // ════════════════════════════════════════════════════════════════════════ #endregion private async Task HandleSpectateWebSocketAsync(HttpListenerContext context, string lobbyId, string sessionId) { var wsContext = await context.AcceptWebSocketAsync(null); var ws = wsContext.WebSocket; var spectatorId = Guid.NewGuid().ToString("N")[..8]; var lobby = _lobbyManager.GetLobby(lobbyId); if (lobby == null) { try { await ws.CloseAsync(WebSocketCloseStatus.InvalidPayloadData, "Lobby not found", CancellationToken.None); } catch (WebSocketException) { /* Client already disconnected */ } return; } var connection = new SpectatorConnection { WebSocket = ws, LobbyId = lobbyId, SessionId = sessionId }; _spectators[spectatorId] = connection; _logger.LogInformation("Spectator {Id} connected to lobby {LobbyId}", spectatorId, lobbyId); try { // Pošli initial state - include the heavy mapData payload exactly // once per spectator session. The push loop below sends just the // dynamic fields (positions/votes/sabotage/etc.) at 5Hz. await SendLobbyStateAsync(ws, lobby, includeMapData: true); connection.MapDataSent = true; var buffer = new byte[4096]; var updateInterval = TimeSpan.FromMilliseconds(200); var cts = new CancellationTokenSource(); var receivedMessage = new TaskCompletionSource<(bool closed, string? message)>(); // Spustíme receive task separátně var receiveTask = Task.Run(async () => { try { while (ws.State == WebSocketState.Open && !cts.Token.IsCancellationRequested) { var result = await ws.ReceiveAsync(new ArraySegment(buffer), cts.Token); if (result.MessageType == WebSocketMessageType.Close) { return true; // closed } if (result.Count > 0) { var message = Encoding.UTF8.GetString(buffer, 0, result.Count); await HandleSpectatorMessageAsync(ws, lobby!, message); } } } catch (OperationCanceledException) { } catch (WebSocketException) { } return ws.State != WebSocketState.Open; }); // Hlavní smyčka posílá state while (ws.State == WebSocketState.Open && !receiveTask.IsCompleted) { lobby = _lobbyManager.GetLobby(lobbyId); if (lobby != null) { // Subsequent ticks: skip mapData (heavy + immutable during // a game). Spectator's frontend cached it from the initial // push. await SendLobbyStateAsync(ws, lobby, includeMapData: false); } else { break; } try { await Task.WhenAny(receiveTask, Task.Delay(updateInterval, cts.Token)); } catch (OperationCanceledException) { break; } } cts.Cancel(); // Gracefully close if still open if (ws.State == WebSocketState.Open) { try { await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, lobby == null ? "Lobby closed" : "Closing", CancellationToken.None); } catch (WebSocketException) { /* Already closed by client */ } } } catch (WebSocketException) { // Client disconnected - this is normal } catch (Exception ex) { _logger.LogDebug(ex, "Spectate WebSocket error"); } finally { _spectators.TryRemove(spectatorId, out _); _logger.LogInformation("Spectator {Id} disconnected", spectatorId); } } /// /// Push the lobby state to a spectator's WebSocket. When `includeMapData` /// is false (the default for the periodic 5Hz push loop) the heavy /// Overpass payload is omitted - the frontend keeps the copy it received /// on the initial push. This was the dominant cost in the "admin panel /// slow to show spectating" complaint: a typical lobby's mapData is tens /// of KB and serializing it 5x per second per spectator added up fast. /// private async Task SendLobbyStateAsync(WebSocket ws, LobbyActor lobby, bool includeMapData = true) { var state = new { type = "state", lobby = new { id = lobby.LobbyId, joinCode = lobby.JoinCode, phase = lobby.Phase.ToString(), phaseEndTime = lobby.PhaseEndTime?.ToString("o"), players = lobby.GetPlayers().Select(p => new { id = p.PlayerId, name = p.DisplayName, state = p.State.ToString(), role = p.Role.ToString(), lat = p.Position.Lat, lng = p.Position.Lon, isHost = p.IsHost, cheatScore = p.CheatScore, killCooldownEnd = p.KillCooldownEnd?.ToString("o"), votedFor = p.VotedFor }), tasks = lobby.GetTasks().Select(t => new { id = t.TaskId, name = t.Name, lat = t.Location.Lat, lng = t.Location.Lon, completedBy = t.CompletedBy.ToList() }), bodies = lobby.GetBodies().Select(b => new { victimId = b.VictimId, lat = b.Position.Lat, lng = b.Position.Lon, reportedAt = b.ReportedAt?.ToString("o") }), sabotage = lobby.ActiveSabotage != null ? new { type = lobby.ActiveSabotage.Type.ToString(), deadline = lobby.ActiveSabotage.Deadline?.ToString("o"), repairStations = lobby.ActiveSabotage.RepairStations.Select(rs => new { id = rs.StationId, lat = rs.Location.Lat, lng = rs.Location.Lon, isRepaired = rs.IsRepaired }) } : null, votes = lobby.Phase == GamePhase.Voting ? lobby.GetVotes() : null, settings = new { center = new { lat = lobby.Settings.PlayAreaCenter.Lat, lng = lobby.Settings.PlayAreaCenter.Lon }, radius = lobby.Settings.PlayAreaRadius, impostorCount = lobby.Settings.ImpostorCount, taskCount = lobby.Settings.TaskCount }, // mapData on first push only - frontend caches it client-side. mapData = includeMapData ? lobby.MapData : null }, timestamp = DateTime.UtcNow.ToString("o") }; var json = JsonSerializer.Serialize(state, JsonOptions.Default); var bytes = Encoding.UTF8.GetBytes(json); if (ws.State == WebSocketState.Open) { await ws.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, CancellationToken.None); } } private async Task HandleSpectatorMessageAsync(WebSocket ws, LobbyActor lobby, string message) { try { var data = JsonSerializer.Deserialize(message); var type = data.GetProperty("type").GetString(); switch (type) { case "ping": await SendJsonAsync(ws, new { type = "pong" }); break; case "getPlayerHistory": var playerId = data.GetProperty("playerId").GetString(); var stats = _statsDb.GetPlayerStats(playerId!); await SendJsonAsync(ws, new { type = "playerHistory", stats }); break; } } catch { } } #region ═══════════════════════════════════════════════════════════════════ // WEBSOCKET - SERVER STATS // ════════════════════════════════════════════════════════════════════════ #endregion private async Task HandleStatsWebSocketAsync(HttpListenerContext context, string sessionId) { var wsContext = await context.AcceptWebSocketAsync(null); var ws = wsContext.WebSocket; try { var buffer = new byte[1024]; var updateInterval = TimeSpan.FromSeconds(1); var cts = new CancellationTokenSource(); // Spustíme receive task separátně - bude běžet celou dobu var receiveTask = Task.Run(async () => { try { while (ws.State == WebSocketState.Open && !cts.Token.IsCancellationRequested) { var result = await ws.ReceiveAsync(new ArraySegment(buffer), cts.Token); if (result.MessageType == WebSocketMessageType.Close) { return; } } } catch (OperationCanceledException) { } catch (WebSocketException) { } }); // Hlavní smyčka posílá stats while (ws.State == WebSocketState.Open && !receiveTask.IsCompleted) { var uptime = DateTime.UtcNow - Program.StartTime; var stats = new { type = "stats", uptime = (long)uptime.TotalSeconds, lobbies = _lobbyManager.LobbyCount, players = _lobbyManager.TotalPlayerCount, memoryMb = GC.GetTotalMemory(false) / 1024 / 1024, spectators = _spectators.Count, timestamp = DateTime.UtcNow.ToString("o") }; await SendJsonAsync(ws, stats); // Čekáme na interval nebo ukončení receive tasku var delayTask = Task.Delay(updateInterval, cts.Token); try { await Task.WhenAny(receiveTask, delayTask); } catch (OperationCanceledException) { break; } } cts.Cancel(); if (ws.State == WebSocketState.Open) { try { await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", CancellationToken.None); } catch (WebSocketException) { /* Already closed */ } } } catch (WebSocketException) { } catch (Exception ex) { _logger.LogDebug(ex, "Stats WebSocket error"); } } #region ═══════════════════════════════════════════════════════════════════ // HELPERS // ════════════════════════════════════════════════════════════════════════ #endregion private static async Task WriteJsonAsync(HttpListenerResponse response, object data) { response.ContentType = "application/json"; var json = JsonSerializer.Serialize(data, JsonOptions.Default); var bytes = Encoding.UTF8.GetBytes(json); response.ContentLength64 = bytes.Length; await response.OutputStream.WriteAsync(bytes); } private static async Task SendJsonAsync(WebSocket ws, object data) { var json = JsonSerializer.Serialize(data, JsonOptions.Default); var bytes = Encoding.UTF8.GetBytes(json); if (ws.State == WebSocketState.Open) { await ws.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, CancellationToken.None); } } private async Task ServeStaticFileAsync(HttpListenerResponse response, string filename, string contentType) { var content = GetEmbeddedResource(filename); if (content == null) { response.StatusCode = 404; return; } response.ContentType = contentType + "; charset=utf-8"; var bytes = Encoding.UTF8.GetBytes(content); response.ContentLength64 = bytes.Length; await response.OutputStream.WriteAsync(bytes); } private string? GetEmbeddedResource(string filename) { // Embedded resources z AdminResources třídy return filename switch { "admin.html" => AdminResources.Html, "admin.css" => AdminResources.Css, "admin.js" => AdminResources.JavaScript, _ => null }; } } public class AdminSession { public DateTime LastActivity { get; set; } } public class SpectatorConnection { public required WebSocket WebSocket { get; set; } public required string LobbyId { get; set; } public required string SessionId { get; set; } /// /// P12: per-spectator flag to ensure we serialize the heavy `mapData` /// payload only once at session start. The previous push loop included /// it on every 200ms tick, which dominated CPU + bandwidth and made the /// admin panel feel laggy ("slow to show spectating") even though the /// position/vote/role updates were already arriving promptly. /// public bool MapDataSent { get; set; } = false; }