feat(admin): read-only back-office at /admin (LAN-only)
Build & Deploy / build (push) Successful in 38s
Build & Deploy / build (push) Successful in 38s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<IActionResult> 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<string>());
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append("<div class='cards'>");
|
||||||
|
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("</div>");
|
||||||
|
|
||||||
|
sb.Append("<h2>Joueurs récents</h2>");
|
||||||
|
sb.Append("<table><thead><tr><th>DisplayName</th><th>PlayFab Id</th><th>Auth</th><th></th></tr></thead><tbody>");
|
||||||
|
foreach (var u in users.Take(15))
|
||||||
|
{
|
||||||
|
var auth = u.Auth != null ? string.Join(", ", u.Auth.Select(a => $"{a.Type}:{a.Key}")) : "";
|
||||||
|
sb.Append($"<tr><td>{Esc(u.DisplayName)}</td><td class='mono'>{Esc(u.Id)}</td><td class='mono small'>{Esc(auth)}</td>" +
|
||||||
|
$"<td><a class='btn' href='/admin/players/{Esc(u.Id)}'>Voir</a></td></tr>");
|
||||||
|
}
|
||||||
|
sb.Append("</tbody></table>");
|
||||||
|
sb.Append("<p><a class='btn' href='/admin/players'>Tous les joueurs →</a> <a class='btn' href='/admin/titledata'>TitleData →</a></p>");
|
||||||
|
|
||||||
|
return Html(Layout("Dashboard", sb.ToString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("players")]
|
||||||
|
public async Task<IActionResult> 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($"<form method='get' class='search'><input name='q' placeholder='Rechercher nom / id / steam…' value='{Esc(q ?? "")}'/><button class='btn'>Rechercher</button></form>");
|
||||||
|
sb.Append($"<p class='muted'>{users.Count} joueur(s)</p>");
|
||||||
|
sb.Append("<table><thead><tr><th>DisplayName</th><th>PlayFab Id</th><th>Entity Id</th><th>Auth</th><th></th></tr></thead><tbody>");
|
||||||
|
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($"<tr><td>{Esc(u.DisplayName)}</td><td class='mono'>{Esc(u.Id)}</td><td class='mono small'>{Esc(ent)}</td>" +
|
||||||
|
$"<td class='mono small'>{Esc(auth)}</td><td><a class='btn' href='/admin/players/{Esc(u.Id)}'>Voir</a></td></tr>");
|
||||||
|
}
|
||||||
|
sb.Append("</tbody></table>");
|
||||||
|
return Html(Layout("Joueurs", sb.ToString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("players/{id}")]
|
||||||
|
public async Task<IActionResult> Player(string id)
|
||||||
|
{
|
||||||
|
if (!IsLan()) return Denied();
|
||||||
|
|
||||||
|
var user = await _userService.FindByIdAsync(id);
|
||||||
|
if (user == null) return Html(Layout("Joueur introuvable", "<p>Aucun joueur avec cet id.</p>"));
|
||||||
|
|
||||||
|
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($"<h2>{Esc(user.DisplayName)}</h2>");
|
||||||
|
sb.Append($"<p class='muted mono'>PlayFabId {Esc(user.Id)} · Entity {Esc(entity?.Id ?? "-")} · {Esc(auth)}</p>");
|
||||||
|
|
||||||
|
// Summary cards
|
||||||
|
sb.Append("<div class='cards'>");
|
||||||
|
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("</div>");
|
||||||
|
|
||||||
|
// Inventory table
|
||||||
|
if (map.TryGetValue("Inventory", out var inv))
|
||||||
|
{
|
||||||
|
sb.Append("<h3>Inventaire</h3>");
|
||||||
|
sb.Append(InventoryTable(inv.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
// All keys
|
||||||
|
sb.Append("<h3>Toutes les données</h3>");
|
||||||
|
foreach (var d in data)
|
||||||
|
{
|
||||||
|
sb.Append("<details><summary>" + Esc(d.Key) +
|
||||||
|
$" <span class='muted small'>· {d.Value?.Length ?? 0} car · maj {d.LastUpdated:yyyy-MM-dd HH:mm}{(d.Public ? " · public" : "")}</span></summary>");
|
||||||
|
sb.Append($"<pre>{Esc(Pretty(d.Value))}</pre></details>");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Html(Layout("Joueur · " + user.DisplayName, sb.ToString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("titledata")]
|
||||||
|
public IActionResult TitleData()
|
||||||
|
{
|
||||||
|
if (!IsLan()) return Denied();
|
||||||
|
|
||||||
|
var all = _titleDataService.Find(new List<string>());
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append("<table><thead><tr><th>Clé</th><th>Taille</th><th></th></tr></thead><tbody>");
|
||||||
|
foreach (var kv in all.OrderBy(k => k.Key))
|
||||||
|
sb.Append($"<tr><td class='mono'>{Esc(kv.Key)}</td><td>{kv.Value?.Length ?? 0} car</td>" +
|
||||||
|
$"<td><a class='btn' href='/admin/titledata/{Esc(kv.Key)}'>Voir</a></td></tr>");
|
||||||
|
sb.Append("</tbody></table>");
|
||||||
|
return Html(Layout("TitleData", sb.ToString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("titledata/{key}")]
|
||||||
|
public IActionResult TitleDataKey(string key)
|
||||||
|
{
|
||||||
|
if (!IsLan()) return Denied();
|
||||||
|
|
||||||
|
var all = _titleDataService.Find(new List<string> { key });
|
||||||
|
if (!all.TryGetValue(key, out var value))
|
||||||
|
return Html(Layout("Clé introuvable", "<p>Aucune clé TitleData avec ce nom.</p>"));
|
||||||
|
|
||||||
|
// Large values (Blueprints, Contracts…) can be huge — pretty-print only if reasonable.
|
||||||
|
var body = value != null && value.Length > 300_000
|
||||||
|
? $"<p class='muted'>Valeur volumineuse ({value.Length} car) — affichage brut.</p><pre>{Esc(value)}</pre>"
|
||||||
|
: $"<pre>{Esc(Pretty(value))}</pre>";
|
||||||
|
return Html(Layout("TitleData · " + key, $"<h2 class='mono'>{Esc(key)}</h2>" + 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 "<p class='muted'>vide</p>";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
if (doc.RootElement.ValueKind != JsonValueKind.Array) return $"<pre>{Esc(Pretty(json))}</pre>";
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
var count = doc.RootElement.GetArrayLength();
|
||||||
|
sb.Append($"<p class='muted'>{count} item(s)</p>");
|
||||||
|
sb.Append("<table><thead><tr><th>baseItemId</th><th>amount</th><th>durability</th><th>insurance</th><th>itemId</th></tr></thead><tbody>");
|
||||||
|
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($"<tr><td>{Esc(S("baseItemId"))}</td><td>{Esc(S("amount"))}</td><td>{Esc(S("durability"))}</td>" +
|
||||||
|
$"<td>{Esc(S("insurance"))}</td><td class='mono small'>{Esc(S("itemId"))}</td></tr>");
|
||||||
|
}
|
||||||
|
sb.Append("</tbody></table>");
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
catch { return $"<pre>{Esc(Pretty(json))}</pre>"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Card(string label, string value) =>
|
||||||
|
$"<div class='card'><div class='v'>{Esc(value)}</div><div class='l'>{Esc(label)}</div></div>";
|
||||||
|
|
||||||
|
private static string Layout(string title, string body) => $@"<!doctype html><html lang='fr'><head>
|
||||||
|
<meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>
|
||||||
|
<title>{Esc(title)} · The Cycle Admin</title>
|
||||||
|
<style>
|
||||||
|
:root{{--bg:#0f1115;--panel:#171a21;--line:#262b36;--txt:#e6e8ec;--muted:#8b93a1;--acc:#e8a838;}}
|
||||||
|
*{{box-sizing:border-box}}body{{margin:0;background:var(--bg);color:var(--txt);font:14px/1.5 system-ui,Segoe UI,Roboto,sans-serif}}
|
||||||
|
header{{background:var(--panel);border-bottom:1px solid var(--line);padding:12px 20px;display:flex;gap:18px;align-items:center;position:sticky;top:0}}
|
||||||
|
header b{{color:var(--acc)}}header a{{color:var(--muted);text-decoration:none}}header a:hover{{color:var(--txt)}}
|
||||||
|
main{{padding:20px;max-width:1200px;margin:0 auto}}
|
||||||
|
h2{{margin:18px 0 10px}}h3{{margin:22px 0 8px;border-bottom:1px solid var(--line);padding-bottom:4px}}
|
||||||
|
.cards{{display:flex;flex-wrap:wrap;gap:12px;margin:12px 0}}
|
||||||
|
.card{{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px 18px;min-width:130px}}
|
||||||
|
.card .v{{font-size:22px;font-weight:700;color:var(--acc)}}.card .l{{color:var(--muted);font-size:12px}}
|
||||||
|
table{{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden}}
|
||||||
|
th,td{{text-align:left;padding:8px 12px;border-bottom:1px solid var(--line);vertical-align:top}}
|
||||||
|
th{{background:#1c2029;color:var(--muted);font-weight:600}}tr:last-child td{{border-bottom:none}}
|
||||||
|
.mono{{font-family:ui-monospace,Consolas,monospace}}.small{{font-size:12px}}.muted{{color:var(--muted)}}
|
||||||
|
.btn{{display:inline-block;background:#222834;color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:5px 12px;text-decoration:none;font-size:13px}}
|
||||||
|
.btn:hover{{border-color:var(--acc);color:var(--acc)}}
|
||||||
|
.search{{display:flex;gap:8px;margin:12px 0}}.search input{{flex:1;background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--txt);padding:8px 12px}}
|
||||||
|
details{{background:var(--panel);border:1px solid var(--line);border-radius:8px;margin:6px 0}}summary{{cursor:pointer;padding:8px 12px}}
|
||||||
|
pre{{margin:0;padding:12px;background:#0b0d11;border-top:1px solid var(--line);overflow:auto;max-height:480px;white-space:pre-wrap;word-break:break-word}}
|
||||||
|
</style></head><body>
|
||||||
|
<header><b>The Cycle · Admin</b>
|
||||||
|
<a href='/admin'>Dashboard</a><a href='/admin/players'>Joueurs</a><a href='/admin/titledata'>TitleData</a>
|
||||||
|
<span class='muted' style='margin-left:auto'>read-only · LAN</span></header>
|
||||||
|
<main>{body}</main></body></html>";
|
||||||
|
}
|
||||||
@@ -16,6 +16,12 @@ public class DbEntityService : BaseDbService<PlayFabEntity>
|
|||||||
return await Collection.Find(user => user.UserId == userId).SingleOrDefaultAsync();
|
return await Collection.Find(user => user.UserId == userId).SingleOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin back-office: list every entity (title_player_account).
|
||||||
|
public async Task<List<PlayFabEntity>> GetAllAsync()
|
||||||
|
{
|
||||||
|
return await Collection.Find(_ => true).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<PlayFabEntity> CreateAsync(string userId)
|
private async Task<PlayFabEntity> CreateAsync(string userId)
|
||||||
{
|
{
|
||||||
var user = new PlayFabEntity
|
var user = new PlayFabEntity
|
||||||
|
|||||||
@@ -46,6 +46,19 @@ public class DbUserDataService : BaseDbService<PlayFabUserData>
|
|||||||
return await query.ToCursorAsync();
|
return await query.ToCursorAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin back-office: every key/value stored for a player.
|
||||||
|
public async Task<List<PlayFabUserData>> 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<long> CountAsync()
|
||||||
|
{
|
||||||
|
return await Collection.CountDocumentsAsync(_ => true);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task UpdateValueAsync(string dataId, string value)
|
public async Task UpdateValueAsync(string dataId, string value)
|
||||||
{
|
{
|
||||||
var update = Builders<PlayFabUserData>.Update
|
var update = Builders<PlayFabUserData>.Update
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ public class DbUserService : BaseDbService<PlayFabUser>
|
|||||||
return await Collection.Find(user => user.Id == id).FirstOrDefaultAsync();
|
return await Collection.Find(user => user.Id == id).FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin back-office: list every player.
|
||||||
|
public async Task<List<PlayFabUser>> GetAllAsync()
|
||||||
|
{
|
||||||
|
return await Collection.Find(_ => true).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
// Persist a display-name change (UpdateUserTitleDisplayName). Without this the rename
|
// Persist a display-name change (UpdateUserTitleDisplayName). Without this the rename
|
||||||
// is echoed back to the client but lost on next login.
|
// is echoed back to the client but lost on next login.
|
||||||
public async Task UpdateDisplayNameAsync(string id, string displayName)
|
public async Task UpdateDisplayNameAsync(string id, string displayName)
|
||||||
|
|||||||
Reference in New Issue
Block a user