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