Lol
This commit is contained in:
430
AdminPanel.cs
430
AdminPanel.cs
@@ -150,7 +150,22 @@ public class AdminPanel
|
||||
await HandleStatsWebSocketAsync(context, sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// P13d: archive endpoints with archive id in the URL path
|
||||
// Layout: /admin/api/archive/<id>/events and /admin/api/archive/<id>/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)
|
||||
{
|
||||
@@ -194,6 +209,11 @@ public class AdminPanel
|
||||
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 });
|
||||
@@ -330,44 +350,114 @@ public class AdminPanel
|
||||
await WriteJsonAsync(response, new { lobbies });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task HandleGetConfigAsync(HttpListenerResponse response)
|
||||
{
|
||||
// Vrátíme pouze bezpečné konfigurace (ne hesla apod.)
|
||||
var config = new
|
||||
// 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 }))
|
||||
{
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 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.)
|
||||
/// </summary>
|
||||
private async Task HandleSetConfigAsync(HttpListenerRequest request, HttpListenerResponse response)
|
||||
{
|
||||
using var reader = new StreamReader(request.InputStream);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
var data = JsonSerializer.Deserialize<JsonElement>(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 });
|
||||
|
||||
// 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)
|
||||
@@ -552,7 +642,285 @@ public class AdminPanel
|
||||
|
||||
await WriteJsonAsync(response, new { success = true });
|
||||
}
|
||||
|
||||
|
||||
#region ═══════════════════════════════════════════════════════════════════
|
||||
// P13d: ARCHIVE
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// P13d: list every archived lobby on disk. Persistence.ArchiveLobby
|
||||
/// drops them at <DataPath>/archive/<lobbyId>_<yyyyMMddHHmmss>/. 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.
|
||||
/// </summary>
|
||||
private async Task HandleListArchivesAsync(HttpListenerResponse response)
|
||||
{
|
||||
var archiveDir = Path.Combine(_config.DataPath, "archive");
|
||||
if (!Directory.Exists(archiveDir))
|
||||
{
|
||||
await WriteJsonAsync(response, new { archives = Array.Empty<object>() });
|
||||
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 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string, List<object>>();
|
||||
var playerNames = new Dictionary<string, string>();
|
||||
|
||||
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<object>();
|
||||
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 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user