From b8d023b02a59b428020354e161a9d3d834ed1e68 Mon Sep 17 00:00:00 2001 From: neckfire Date: Wed, 15 Jul 2026 09:56:34 +0200 Subject: [PATCH] feat(admin): read-only back-office at /admin (LAN-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filament-style dark UI served directly by the API: - Dashboard: player/entity/userdata/titledata counts + recent players - /admin/players: searchable player list (name/id/steam) - /admin/players/{id}: full player view — balance/faction/Fortuna cards, inventory table, and every UserData key pretty-printed (collapsible) - /admin/titledata + /admin/titledata/{key}: browse in-memory title data Restricted to LAN/loopback callers (RemoteIpAddress check) so it is never reachable from the public internet even though :8443 is port-forwarded. Co-Authored-By: Claude Opus 4.8 --- .../Controllers/AdminController.cs | 291 ++++++++++++++++++ .../Services/Database/DbEntityService.cs | 6 + .../Services/Database/DbUserDataService.cs | 13 + .../Services/Database/DbUserService.cs | 6 + 4 files changed, 316 insertions(+) create mode 100644 src/Prospect.Server.Api/Controllers/AdminController.cs diff --git a/src/Prospect.Server.Api/Controllers/AdminController.cs b/src/Prospect.Server.Api/Controllers/AdminController.cs new file mode 100644 index 0000000..0cd6041 --- /dev/null +++ b/src/Prospect.Server.Api/Controllers/AdminController.cs @@ -0,0 +1,291 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Prospect.Server.Api.Services.Database; +using Prospect.Server.Api.Services.UserData; + +namespace Prospect.Server.Api.Controllers; + +// Read-only back-office to visualise everything relevant in the database. +// Served at /admin, restricted to LAN / loopback callers (an admin panel must not be +// reachable from the public internet even though :8443 is port-forwarded). +[Route("admin")] +public class AdminController : ControllerBase +{ + private readonly DbUserService _userService; + private readonly DbEntityService _entityService; + private readonly DbUserDataService _userDataService; + private readonly TitleDataService _titleDataService; + + public AdminController(DbUserService userService, DbEntityService entityService, + DbUserDataService userDataService, TitleDataService titleDataService) + { + _userService = userService; + _entityService = entityService; + _userDataService = userDataService; + _titleDataService = titleDataService; + } + + // ---------- pages ---------- + + [HttpGet("")] + public async Task Index() + { + if (!IsLan()) return Denied(); + + var users = await _userService.GetAllAsync(); + var entities = await _entityService.GetAllAsync(); + var dataCount = await _userDataService.CountAsync(); + var titleKeys = _titleDataService.Find(new List()); + + var sb = new StringBuilder(); + sb.Append("
"); + sb.Append(Card("Joueurs", users.Count.ToString())); + sb.Append(Card("Entités (title_player)", entities.Count.ToString())); + sb.Append(Card("Docs UserData", dataCount.ToString())); + sb.Append(Card("Clés TitleData", titleKeys.Count.ToString())); + sb.Append("
"); + + sb.Append("

Joueurs récents

"); + sb.Append(""); + foreach (var u in users.Take(15)) + { + var auth = u.Auth != null ? string.Join(", ", u.Auth.Select(a => $"{a.Type}:{a.Key}")) : ""; + sb.Append($"" + + $""); + } + sb.Append("
DisplayNamePlayFab IdAuth
{Esc(u.DisplayName)}{Esc(u.Id)}{Esc(auth)}Voir
"); + sb.Append("

Tous les joueurs →   TitleData →

"); + + return Html(Layout("Dashboard", sb.ToString())); + } + + [HttpGet("players")] + public async Task Players([FromQuery] string? q) + { + if (!IsLan()) return Denied(); + + var users = await _userService.GetAllAsync(); + var entities = (await _entityService.GetAllAsync()) + .GroupBy(e => e.UserId) + .ToDictionary(g => g.Key, g => g.First().Id); + + if (!string.IsNullOrWhiteSpace(q)) + { + var needle = q.Trim().ToLowerInvariant(); + users = users.Where(u => + (u.DisplayName ?? "").ToLowerInvariant().Contains(needle) || + (u.Id ?? "").ToLowerInvariant().Contains(needle) || + (u.Auth != null && u.Auth.Any(a => (a.Key ?? "").ToLowerInvariant().Contains(needle)))).ToList(); + } + + var sb = new StringBuilder(); + sb.Append($""); + sb.Append($"

{users.Count} joueur(s)

"); + sb.Append(""); + foreach (var u in users.OrderBy(u => u.DisplayName)) + { + var auth = u.Auth != null ? string.Join(", ", u.Auth.Select(a => $"{a.Type}:{a.Key}")) : ""; + var ent = entities.TryGetValue(u.Id, out var eid) ? eid : ""; + sb.Append($"" + + $""); + } + sb.Append("
DisplayNamePlayFab IdEntity IdAuth
{Esc(u.DisplayName)}{Esc(u.Id)}{Esc(ent)}{Esc(auth)}Voir
"); + return Html(Layout("Joueurs", sb.ToString())); + } + + [HttpGet("players/{id}")] + public async Task Player(string id) + { + if (!IsLan()) return Denied(); + + var user = await _userService.FindByIdAsync(id); + if (user == null) return Html(Layout("Joueur introuvable", "

Aucun joueur avec cet id.

")); + + var entity = await _entityService.FindAsync(id); + var data = await _userDataService.GetAllForUserAsync(id); + var map = data.ToDictionary(d => d.Key, d => d); + + var sb = new StringBuilder(); + var auth = user.Auth != null ? string.Join(", ", user.Auth.Select(a => $"{a.Type}:{a.Key}")) : ""; + sb.Append($"

{Esc(user.DisplayName)}

"); + sb.Append($"

PlayFabId {Esc(user.Id)} · Entity {Esc(entity?.Id ?? "-")} · {Esc(auth)}

"); + + // Summary cards + sb.Append("
"); + if (map.TryGetValue("Balance", out var bal)) + { + sb.Append(Card("K-Marks", GetNum(bal.Value, "SoftCurrency").ToString())); + sb.Append(Card("Aurum", GetNum(bal.Value, "HardCurrency").ToString())); + sb.Append(Card("Insurance", GetNum(bal.Value, "InsuranceCurrency").ToString())); + } + foreach (var f in new[] { "Korolev", "Osiris", "ICA" }) + if (map.TryGetValue("FactionProgression" + f, out var fp)) + sb.Append(Card("Rép. " + f, (fp.Value ?? "0").Trim())); + foreach (var s in new[] { "2", "3" }) + if (map.TryGetValue($"FortunaPass{s}_SeasonXp", out var xp) && (xp.Value ?? "0").Trim() != "0") + sb.Append(Card($"Fortuna S{s} XP", (xp.Value ?? "0").Trim())); + sb.Append("
"); + + // Inventory table + if (map.TryGetValue("Inventory", out var inv)) + { + sb.Append("

Inventaire

"); + sb.Append(InventoryTable(inv.Value)); + } + + // All keys + sb.Append("

Toutes les données

"); + foreach (var d in data) + { + sb.Append("
" + Esc(d.Key) + + $" · {d.Value?.Length ?? 0} car · maj {d.LastUpdated:yyyy-MM-dd HH:mm}{(d.Public ? " · public" : "")}"); + sb.Append($"
{Esc(Pretty(d.Value))}
"); + } + + return Html(Layout("Joueur · " + user.DisplayName, sb.ToString())); + } + + [HttpGet("titledata")] + public IActionResult TitleData() + { + if (!IsLan()) return Denied(); + + var all = _titleDataService.Find(new List()); + var sb = new StringBuilder(); + sb.Append(""); + foreach (var kv in all.OrderBy(k => k.Key)) + sb.Append($"" + + $""); + sb.Append("
CléTaille
{Esc(kv.Key)}{kv.Value?.Length ?? 0} carVoir
"); + return Html(Layout("TitleData", sb.ToString())); + } + + [HttpGet("titledata/{key}")] + public IActionResult TitleDataKey(string key) + { + if (!IsLan()) return Denied(); + + var all = _titleDataService.Find(new List { key }); + if (!all.TryGetValue(key, out var value)) + return Html(Layout("Clé introuvable", "

Aucune clé TitleData avec ce nom.

")); + + // Large values (Blueprints, Contracts…) can be huge — pretty-print only if reasonable. + var body = value != null && value.Length > 300_000 + ? $"

Valeur volumineuse ({value.Length} car) — affichage brut.

{Esc(value)}
" + : $"
{Esc(Pretty(value))}
"; + return Html(Layout("TitleData · " + key, $"

{Esc(key)}

" + body)); + } + + // ---------- helpers ---------- + + private bool IsLan() + { + var ip = HttpContext.Connection.RemoteIpAddress; + if (ip == null) return false; + if (IPAddress.IsLoopback(ip)) return true; + if (ip.IsIPv4MappedToIPv6) ip = ip.MapToIPv4(); + if (ip.AddressFamily == AddressFamily.InterNetwork) + { + var b = ip.GetAddressBytes(); + if (b[0] == 10) return true; // 10.0.0.0/8 + if (b[0] == 192 && b[1] == 168) return true; // 192.168.0.0/16 + if (b[0] == 172 && b[1] >= 16 && b[1] <= 31) return true; // 172.16.0.0/12 + if (b[0] == 127) return true; + return false; + } + // IPv6 unique-local (fc00::/7) or link-local (fe80::/10) + var s = ip.GetAddressBytes(); + if ((s[0] & 0xFE) == 0xFC) return true; + if (s[0] == 0xFE && (s[1] & 0xC0) == 0x80) return true; + return false; + } + + private IActionResult Denied() => + StatusCode(403, "Admin back-office is LAN-only. Access it from the local network (e.g. https://192.168.1.136:8443/admin)."); + + private IActionResult Html(string html) => Content(html, "text/html; charset=utf-8"); + + private static string Esc(string? s) => WebUtility.HtmlEncode(s ?? ""); + + private static string Pretty(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return json ?? ""; + try + { + using var doc = JsonDocument.Parse(json); + return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true }); + } + catch { return json; } + } + + private static long GetNum(string? json, string prop) + { + if (string.IsNullOrWhiteSpace(json)) return 0; + try + { + using var doc = JsonDocument.Parse(json); + foreach (var p in doc.RootElement.EnumerateObject()) + if (string.Equals(p.Name, prop, StringComparison.OrdinalIgnoreCase) && p.Value.ValueKind == JsonValueKind.Number) + return p.Value.GetInt64(); + } + catch { } + return 0; + } + + private static string InventoryTable(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return "

vide

"; + try + { + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind != JsonValueKind.Array) return $"
{Esc(Pretty(json))}
"; + var sb = new StringBuilder(); + var count = doc.RootElement.GetArrayLength(); + sb.Append($"

{count} item(s)

"); + sb.Append(""); + foreach (var it in doc.RootElement.EnumerateArray()) + { + string S(string k) => it.TryGetProperty(k, out var v) ? (v.ValueKind == JsonValueKind.Number ? v.GetRawText() : v.ToString()) : ""; + sb.Append($"" + + $""); + } + sb.Append("
baseItemIdamountdurabilityinsuranceitemId
{Esc(S("baseItemId"))}{Esc(S("amount"))}{Esc(S("durability"))}{Esc(S("insurance"))}{Esc(S("itemId"))}
"); + return sb.ToString(); + } + catch { return $"
{Esc(Pretty(json))}
"; } + } + + private static string Card(string label, string value) => + $"
{Esc(value)}
{Esc(label)}
"; + + private static string Layout(string title, string body) => $@" + +{Esc(title)} · The Cycle Admin + +
The Cycle · Admin +DashboardJoueursTitleData +read-only · LAN
+
{body}
"; +} diff --git a/src/Prospect.Server.Api/Services/Database/DbEntityService.cs b/src/Prospect.Server.Api/Services/Database/DbEntityService.cs index f560329..af4e538 100644 --- a/src/Prospect.Server.Api/Services/Database/DbEntityService.cs +++ b/src/Prospect.Server.Api/Services/Database/DbEntityService.cs @@ -16,6 +16,12 @@ public class DbEntityService : BaseDbService return await Collection.Find(user => user.UserId == userId).SingleOrDefaultAsync(); } + // Admin back-office: list every entity (title_player_account). + public async Task> GetAllAsync() + { + return await Collection.Find(_ => true).ToListAsync(); + } + private async Task CreateAsync(string userId) { var user = new PlayFabEntity diff --git a/src/Prospect.Server.Api/Services/Database/DbUserDataService.cs b/src/Prospect.Server.Api/Services/Database/DbUserDataService.cs index a33a28e..53a70d8 100644 --- a/src/Prospect.Server.Api/Services/Database/DbUserDataService.cs +++ b/src/Prospect.Server.Api/Services/Database/DbUserDataService.cs @@ -46,6 +46,19 @@ public class DbUserDataService : BaseDbService return await query.ToCursorAsync(); } + // Admin back-office: every key/value stored for a player. + public async Task> GetAllForUserAsync(string playFabId) + { + return await Collection.Find(data => data.PlayFabId == playFabId) + .SortBy(data => data.Key).ToListAsync(); + } + + // Admin back-office: total stored key/value documents. + public async Task CountAsync() + { + return await Collection.CountDocumentsAsync(_ => true); + } + public async Task UpdateValueAsync(string dataId, string value) { var update = Builders.Update diff --git a/src/Prospect.Server.Api/Services/Database/DbUserService.cs b/src/Prospect.Server.Api/Services/Database/DbUserService.cs index d951d80..57c9891 100644 --- a/src/Prospect.Server.Api/Services/Database/DbUserService.cs +++ b/src/Prospect.Server.Api/Services/Database/DbUserService.cs @@ -24,6 +24,12 @@ public class DbUserService : BaseDbService return await Collection.Find(user => user.Id == id).FirstOrDefaultAsync(); } + // Admin back-office: list every player. + public async Task> GetAllAsync() + { + return await Collection.Find(_ => true).ToListAsync(); + } + // Persist a display-name change (UpdateUserTitleDisplayName). Without this the rename // is echoed back to the client but lost on next login. public async Task UpdateDisplayNameAsync(string id, string displayName) -- 2.54.0