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; } // 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; 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 }); } private async Task HandleGetConfigAsync(HttpListenerResponse response) { // Vrátíme pouze bezpečné konfigurace (ne hesla apod.) var config = new { tcpPort = _config.TcpPort, httpPort = _config.HttpPort, maxPlayersPerLobby = _config.MaxPlayersPerLobby, killDistanceM = _config.KillDistanceM, killCooldownMs = _config.KillCooldownMs, discussionPhaseMs = _config.DiscussionPhaseMs, votingPhaseMs = _config.VotingPhaseMs, taskStartDistanceM = _config.TaskStartDistanceM, sabotageCooldownMs = _config.SabotageCooldownMs, criticalMeltdownDeadlineMs = _config.CriticalMeltdownDeadlineMs, maxSpeedMps = _config.MaxSpeedMps, teleportThresholdMeters = _config.TeleportThresholdMeters }; await WriteJsonAsync(response, config); } 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); // Aplikuj změny if (data.TryGetProperty("killDistanceM", out var kd)) _config.KillDistanceM = kd.GetDouble(); if (data.TryGetProperty("killCooldownMs", out var kc)) _config.KillCooldownMs = kc.GetInt32(); if (data.TryGetProperty("discussionPhaseMs", out var dp)) _config.DiscussionPhaseMs = dp.GetInt32(); if (data.TryGetProperty("votingPhaseMs", out var vp)) _config.VotingPhaseMs = vp.GetInt32(); if (data.TryGetProperty("maxSpeedMps", out var ms)) _config.MaxSpeedMps = ms.GetDouble(); _logger.LogInformation("Config updated via admin panel"); await WriteJsonAsync(response, new { success = true }); } 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 }); } #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 await SendLobbyStateAsync(ws, lobby); 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) { await SendLobbyStateAsync(ws, lobby); } 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); } } private async Task SendLobbyStateAsync(WebSocket ws, LobbyActor lobby) { 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 = lobby.MapData // Overpass data pro vykreslení mapy }, 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; } }