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.Database.Models; using Prospect.Server.Api.Services.GameRef; using Prospect.Server.Api.Services.Steam; 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; private readonly SteamWebApiService _steamWebApi; private readonly GameRefService _gameRef; public AdminController(DbUserService userService, DbEntityService entityService, DbUserDataService userDataService, TitleDataService titleDataService, SteamWebApiService steamWebApi, GameRefService gameRef) { _userService = userService; _entityService = entityService; _userDataService = userDataService; _titleDataService = titleDataService; _steamWebApi = steamWebApi; _gameRef = gameRef; } // ---------- 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)); } [HttpGet("backfill-names")] public async Task BackfillNames() { if (!IsLan()) return Denied(); if (!_steamWebApi.Enabled) return Html(Layout("Backfill", "

Steam Web API non configurée (clé absente).

← Joueurs

")); var users = await _userService.GetAllAsync(); int updated = 0, skipped = 0, failed = 0; var rows = new StringBuilder(); foreach (var u in users) { if (!string.IsNullOrEmpty(u.DisplayName) && u.DisplayName != "Unknown") { skipped++; continue; } var steam = u.Auth?.FirstOrDefault(a => a.Type == PlayFabUserAuthType.Steam)?.Key; if (string.IsNullOrEmpty(steam)) { skipped++; continue; } var persona = await _steamWebApi.GetPersonaNameAsync(steam); if (!string.IsNullOrWhiteSpace(persona)) { await _userService.UpdateDisplayNameAsync(u.Id, persona); updated++; rows.Append($"{Esc(u.Id)}{Esc(persona)}"); } else { failed++; } } var body = $"

Backfill des pseudos Steam

{updated} mis à jour · {skipped} ignorés · {failed} échec(s)

" + (updated > 0 ? $"{rows}
PlayFabIdNouveau nom
" : "") + "

← Joueurs

"; return Html(Layout("Backfill", body)); } [HttpGet("catalog")] public IActionResult Catalog([FromQuery] string? q, [FromQuery] string? cat) { if (!IsLan()) return Denied(); var all = _gameRef.All; IEnumerable> items = all; if (!string.IsNullOrWhiteSpace(cat)) items = items.Where(kv => kv.Value.Category == cat); if (!string.IsNullOrWhiteSpace(q)) { var n = q.Trim().ToLowerInvariant(); items = items.Where(kv => kv.Key.ToLowerInvariant().Contains(n) || (kv.Value.Name ?? "").ToLowerInvariant().Contains(n)); } var cats = all.Values.Select(v => v.Category).Distinct().OrderBy(x => x).ToList(); var list = items.OrderBy(kv => kv.Value.Category).ThenBy(kv => kv.Value.Name).Take(1000).ToList(); var sb = new StringBuilder(); sb.Append($"

Référence dataminée (TCF-Wiki) — {all.Count} entrées connues.

"); sb.Append($""); sb.Append($"

{list.Count} résultat(s){(list.Count >= 1000 ? " (tronqué)" : "")}

"); sb.Append(""); foreach (var kv in list) sb.Append($""); sb.Append("
NomRaretéCatégorieid
{Esc(kv.Value.Name)}{Rarity(kv.Value.Rarity)}{Esc(kv.Value.Category)}{Esc(kv.Key)}
"); return Html(Layout("Catalogue", sb.ToString())); } // ---------- 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 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()) : ""; var baseId = S("baseItemId"); var entry = _gameRef.Get(baseId); var name = entry?.Name ?? baseId; sb.Append($"" + $""); } sb.Append("
NomRaretébaseItemIdqtédurab.assurance
{Esc(name)}{Rarity(entry?.Rarity)}{Esc(baseId)}{Esc(S("amount"))}{Esc(S("durability"))}{Esc(S("insurance"))}
"); return sb.ToString(); } catch { return $"
{Esc(Pretty(json))}
"; } } // Small coloured rarity chip. private static string Rarity(string? r) { if (string.IsNullOrEmpty(r)) return ""; var color = r.ToLowerInvariant() switch { "common" => "#9aa4b2", "uncommon" => "#4caf50", "rare" => "#2f81f7", "epic" => "#a371f7", "legendary" or "exotic" => "#e8a838", _ => "#9aa4b2", }; return $"{Esc(r)}"; } 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 DashboardJoueursCatalogueTitleData read-only · LAN
{body}
"; }