mirror of
https://github.com/Empostor/Empostor.git
synced 2026-10-20 01:46:07 +00:00
842 lines
28 KiB
C#
842 lines
28 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Runtime.InteropServices;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Threading.Tasks;
|
|
using Empostor.Api.Admin;
|
|
using Empostor.Api.Config;
|
|
using Empostor.Api.Games;
|
|
using Empostor.Api.Games.Managers;
|
|
using Empostor.Api.Net;
|
|
using Empostor.Api.Net.Manager;
|
|
using Empostor.Server.Http.Admin;
|
|
using Empostor.Server.Service.Api;
|
|
using Empostor.Server.Service.Shared;
|
|
using Empostor.Server.Utils;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace Empostor.Server.Http
|
|
{
|
|
[ApiController]
|
|
public sealed class AdminController : ControllerBase
|
|
{
|
|
private static readonly DateTime StartTime = DateTime.UtcNow;
|
|
private static Dictionary<string, string>? _adminStrings;
|
|
private static readonly object _stringsLock = new();
|
|
|
|
private const int LoginLockoutThreshold = 5;
|
|
private static readonly TimeSpan LoginLockoutDuration = TimeSpan.FromMinutes(15);
|
|
|
|
private readonly ILogger<AdminController> _logger;
|
|
private readonly IGameManager _gameManager;
|
|
private readonly IClientManager _clientManager;
|
|
private readonly BanStore _bans;
|
|
private readonly AdminConfig _config;
|
|
private readonly ReportStore _reportStore;
|
|
private readonly HplpStore _hplpStore;
|
|
private readonly IOptions<ServerConfig> _serverConfig;
|
|
private readonly IOptions<HttpServerConfig> _httpServerConfig;
|
|
private readonly IpGeolocationService _ipGeo;
|
|
private readonly AdminExtensionRegistry _extensions;
|
|
private readonly AdminThemeRegistry _themes;
|
|
private readonly string _passwordHash;
|
|
private static readonly ConcurrentDictionary<string, (int Count, DateTime FirstAttempt)> _loginFailures = new();
|
|
|
|
public AdminController(
|
|
ILogger<AdminController> logger,
|
|
IGameManager gameManager,
|
|
IClientManager clientManager,
|
|
BanStore bans,
|
|
IOptions<AdminConfig> config,
|
|
ReportStore reportStore,
|
|
HplpStore hplpStore,
|
|
IOptions<ServerConfig> serverConfig,
|
|
IOptions<HttpServerConfig> httpServerConfig,
|
|
IpGeolocationService ipGeo,
|
|
AdminExtensionRegistry extensions,
|
|
AdminThemeRegistry themes)
|
|
{
|
|
_logger = logger;
|
|
_gameManager = gameManager;
|
|
_clientManager = clientManager;
|
|
_bans = bans;
|
|
_config = config.Value;
|
|
_reportStore = reportStore;
|
|
_hplpStore = hplpStore;
|
|
_serverConfig = serverConfig;
|
|
_httpServerConfig = httpServerConfig;
|
|
_ipGeo = ipGeo;
|
|
_extensions = extensions;
|
|
_themes = themes;
|
|
_passwordHash = ComputeHash(_config.Password);
|
|
|
|
if (string.IsNullOrEmpty(_config.Password)
|
|
|| _config.Password == "CHANGE-ME"
|
|
|| _config.Password == "admin123")
|
|
{
|
|
_logger.LogWarning(
|
|
"AdminSECURITY: Default or empty admin password detected! Set a strong password in config.json -> Admin.Password to protect the admin panel.");
|
|
}
|
|
}
|
|
|
|
private static Dictionary<string, string> LoadStrings()
|
|
{
|
|
var path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Pages", "AdminStrings.json");
|
|
if (!System.IO.File.Exists(path))
|
|
{
|
|
return new Dictionary<string, string>();
|
|
}
|
|
|
|
var json = System.IO.File.ReadAllText(path);
|
|
return JsonSerializer.Deserialize<Dictionary<string, string>>(json)
|
|
?? new Dictionary<string, string>();
|
|
}
|
|
|
|
private bool IsAuthenticated()
|
|
=> Request.Cookies.TryGetValue("empostor_admin", out var v)
|
|
&& ConstantTimeEquals(v, _passwordHash);
|
|
|
|
[HttpGet("/admin")]
|
|
public IActionResult Panel()
|
|
=> Content(IsAuthenticated() ? AdminHtml : LoginHtml, "text/html; charset=utf-8");
|
|
|
|
[HttpPost("/admin/login")]
|
|
public IActionResult Login([FromForm] string password)
|
|
{
|
|
var ip = GetClientIp()?.ToString() ?? IPAddress.Loopback.ToString();
|
|
|
|
// Rate-limit check: block IPs that have failed too many times recently
|
|
if (_loginFailures.TryGetValue(ip, out var entry))
|
|
{
|
|
if (entry.Count >= LoginLockoutThreshold
|
|
&& DateTime.UtcNow - entry.FirstAttempt < LoginLockoutDuration)
|
|
{
|
|
var remaining = (int)(LoginLockoutDuration - (DateTime.UtcNow - entry.FirstAttempt)).TotalMinutes;
|
|
_logger.LogWarning("AdminRate-limited login attempt from {Ip} (locked out for ~{Min}m more)", ip, Math.Max(1, remaining));
|
|
return Content(LoginHtml.Replace("<!--ERR-->",
|
|
$"<p style='color:var(--r);margin-top:8px'>Too many failed attempts. Try again in {Math.Max(1, remaining)} minute(s).</p>"),
|
|
"text/html; charset=utf-8");
|
|
}
|
|
|
|
// Reset if lockout window has passed
|
|
if (DateTime.UtcNow - entry.FirstAttempt >= LoginLockoutDuration)
|
|
{
|
|
_loginFailures.TryRemove(ip, out _);
|
|
}
|
|
}
|
|
|
|
var submittedHash = ComputeHash(password);
|
|
|
|
if (!ConstantTimeEquals(submittedHash, _passwordHash))
|
|
{
|
|
_loginFailures.AddOrUpdate(ip,
|
|
_ => (1, DateTime.UtcNow),
|
|
(_, e) => (e.Count + 1, e.FirstAttempt));
|
|
|
|
_logger.LogWarning("AdminFailed login from {Ip} (attempt {Count})", ip,
|
|
_loginFailures.TryGetValue(ip, out var updated) ? updated.Count : 1);
|
|
return Content(LoginHtml.Replace("<!--ERR-->",
|
|
"<p style='color:var(--r);margin-top:8px'>Incorrect password.</p>"),
|
|
"text/html; charset=utf-8");
|
|
}
|
|
|
|
// Successful login — clear rate-limit entry for this IP
|
|
_loginFailures.TryRemove(ip, out _);
|
|
|
|
Response.Cookies.Append("empostor_admin", _passwordHash, new CookieOptions
|
|
{
|
|
HttpOnly = true,
|
|
Secure = Request.IsHttps,
|
|
SameSite = SameSiteMode.Strict,
|
|
MaxAge = TimeSpan.FromHours(8),
|
|
});
|
|
return Redirect("/admin");
|
|
}
|
|
|
|
[HttpPost("/admin/logout")]
|
|
public IActionResult Logout() { Response.Cookies.Delete("empostor_admin"); return Redirect("/admin"); }
|
|
|
|
[HttpGet("/api/admin/strings")]
|
|
public IActionResult GetStrings()
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
var strings = LoadStrings();
|
|
lock (_stringsLock) { _adminStrings = strings; }
|
|
return Ok(strings);
|
|
}
|
|
|
|
[HttpPost("/api/admin/strings/reload")]
|
|
public IActionResult ReloadStrings()
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
var strings = LoadStrings();
|
|
lock (_stringsLock) { _adminStrings = strings; }
|
|
return Ok(strings);
|
|
}
|
|
|
|
[HttpGet("/api/admin/ext")]
|
|
public IActionResult GetExtensions()
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
return Ok(_extensions.Extensions.Select(e => new
|
|
{
|
|
id = e.Id,
|
|
title = e.Title,
|
|
icon = e.Icon,
|
|
section = e.Section,
|
|
autoRefresh = e.AutoRefresh,
|
|
}));
|
|
}
|
|
|
|
[HttpGet("/api/admin/ext/{id}")]
|
|
public IActionResult GetExtension(string id)
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
var builder = _extensions.Build(id);
|
|
if (builder == null)
|
|
{
|
|
return NotFound(Err("Extension not found"));
|
|
}
|
|
|
|
return Ok(new
|
|
{
|
|
id = builder.ExtensionId,
|
|
title = builder.Title,
|
|
icon = builder.Icon,
|
|
section = builder.Section,
|
|
widgets = builder.Widgets,
|
|
});
|
|
}
|
|
|
|
[HttpPost("/api/admin/ext/{id}/{actionName}")]
|
|
public async Task<IActionResult> InvokeExtension(string id, string actionName)
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
var builder = _extensions.Build(id);
|
|
if (builder == null)
|
|
{
|
|
return NotFound(Err("Extension not found"));
|
|
}
|
|
|
|
if (!builder.Actions.TryGetValue(actionName, out var handler))
|
|
{
|
|
return NotFound(Err("Action not found"));
|
|
}
|
|
|
|
string? value = null;
|
|
JsonElement? payload = null;
|
|
try
|
|
{
|
|
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
|
payload = doc.RootElement.Clone();
|
|
if (payload.Value.ValueKind == JsonValueKind.Object
|
|
&& payload.Value.TryGetProperty("value", out var raw))
|
|
{
|
|
value = raw.ValueKind switch
|
|
{
|
|
JsonValueKind.String => raw.GetString(),
|
|
JsonValueKind.True => "true",
|
|
JsonValueKind.False => "false",
|
|
JsonValueKind.Number => raw.GetRawText(),
|
|
_ => null,
|
|
};
|
|
}
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
// No/invalid body; the action still runs without a value.
|
|
}
|
|
|
|
var context = new AdminActionContext(builder.ExtensionId, actionName, value, payload, HttpContext.RequestServices);
|
|
try
|
|
{
|
|
var result = await handler(context);
|
|
return Ok(result);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Admin extension action '{Id}/{Action}' failed", id, actionName);
|
|
return Ok(AdminActionResult.Fail($"Action failed: {ex.Message}"));
|
|
}
|
|
}
|
|
|
|
[HttpGet("/api/admin/themes")]
|
|
public IActionResult GetThemes()
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
// Pick up themes dropped into Pages/themes without requiring a restart.
|
|
_themes.Rescan();
|
|
return Ok(new
|
|
{
|
|
themes = _themes.Themes,
|
|
@default = _config.Theme,
|
|
defaultMode = _config.ThemeMode,
|
|
});
|
|
}
|
|
|
|
[HttpGet("/api/admin/theme/{id}/css")]
|
|
public IActionResult GetThemeCss(string id)
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
if (!_themes.Exists(id))
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
return Content(_themes.BuildCss(id), "text/css; charset=utf-8");
|
|
}
|
|
|
|
[HttpGet("/api/admin/status")]
|
|
public IActionResult Status()
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
var up = DateTime.UtcNow - StartTime;
|
|
var games = _gameManager.Games.ToList();
|
|
var (ib, fb) = _bans.Stats();
|
|
return Ok(new
|
|
{
|
|
uptime = Fmt(up),
|
|
uptimeSeconds = (long)up.TotalSeconds,
|
|
startTime = StartTime.ToString("yyyy-MM-dd HH:mm:ss") + " UTC",
|
|
totalGames = games.Count,
|
|
totalPlayers = _clientManager.Clients.Count(),
|
|
publicGames = games.Count(g => g.IsPublic),
|
|
activeGames = games.Count(g => g.GameState == GameStates.Started),
|
|
bannedIps = ib,
|
|
bannedFriendCodes = fb,
|
|
runtime = RuntimeInformation.FrameworkDescription,
|
|
os = RuntimeInformation.OSDescription,
|
|
pid = Environment.ProcessId,
|
|
});
|
|
}
|
|
|
|
[HttpGet("/api/admin/games")]
|
|
public IActionResult GetGames([FromQuery] string? state = null)
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
var games = _gameManager.Games.AsEnumerable();
|
|
if (!string.IsNullOrWhiteSpace(state)
|
|
&& Enum.TryParse<GameStates>(state, ignoreCase: true, out var filterState))
|
|
{
|
|
games = games.Where(g => g.GameState == filterState);
|
|
}
|
|
|
|
return Ok(games.Select(Snap));
|
|
}
|
|
|
|
[HttpGet("/api/admin/clients")]
|
|
public async Task<IActionResult> GetClients()
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
var clients = _clientManager.Clients.ToList();
|
|
var snaps = new List<object>(clients.Count);
|
|
foreach (var c in clients)
|
|
{
|
|
var ip = c.Connection?.EndPoint?.Address;
|
|
var location = await _ipGeo.GetLocationAsync(ip);
|
|
snaps.Add(CSnap(c, location));
|
|
}
|
|
|
|
return Ok(snaps);
|
|
}
|
|
|
|
[HttpGet("/api/admin/bans")]
|
|
public IActionResult GetBans()
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
return Ok(new { ips = _bans.AllIpBans(), friendCodes = _bans.AllFriendCodeBans() });
|
|
}
|
|
|
|
[HttpPost("/api/admin/broadcast")]
|
|
public async Task<IActionResult> Broadcast([FromBody] BroadcastReq req)
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(req.Message))
|
|
{
|
|
return BadRequest(Err("Message required"));
|
|
}
|
|
|
|
var sent = 0;
|
|
foreach (var g in _gameManager.Games)
|
|
{
|
|
var host = g.Host?.Character;
|
|
if (host != null) { await host.SendChatAsync($"[Server] {req.Message}"); sent++; }
|
|
}
|
|
|
|
return Ok(new { sent });
|
|
}
|
|
|
|
[HttpPost("/api/admin/message")]
|
|
public async Task<IActionResult> GameMessage([FromBody] GameMsgReq req)
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
var game = FindGame(req.GameCode);
|
|
if (game == null)
|
|
{
|
|
return NotFound(Err($"Game '{req.GameCode}' not found"));
|
|
}
|
|
|
|
var host = game.Host?.Character;
|
|
if (host == null)
|
|
{
|
|
return BadRequest(Err("No host character"));
|
|
}
|
|
|
|
await host.SendChatAsync($"Admin{req.Message}");
|
|
return Ok(new { ok = true });
|
|
}
|
|
|
|
[HttpPost("/api/admin/kick")]
|
|
public async Task<IActionResult> Kick([FromBody] ClientIdReq req)
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
var c = FindClient(req.ClientId);
|
|
if (c == null)
|
|
{
|
|
return NotFound(Err($"Client {req.ClientId} not found"));
|
|
}
|
|
|
|
await c.DisconnectAsync(DisconnectReason.Custom, req.Reason ?? "Kicked by admin");
|
|
|
|
if (c.Player != null)
|
|
{
|
|
await c.Player.KickAsync();
|
|
}
|
|
|
|
return Ok(new { kicked = true, name = c.Name });
|
|
}
|
|
|
|
[HttpPost("/api/admin/ban/ip")]
|
|
public async Task<IActionResult> BanIp([FromBody] BanIpReq req)
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
if (!IPAddress.TryParse(req.Ip, out var ip))
|
|
{
|
|
return BadRequest(Err("Invalid IP"));
|
|
}
|
|
|
|
var entry = _bans.BanIp(ip, req.Reason ?? "Banned by admin", BanStore.ParseDuration(req.Duration));
|
|
var kicked = 0;
|
|
foreach (var c in _clientManager.Clients.ToList())
|
|
{
|
|
var cip = c.Connection?.EndPoint?.Address;
|
|
if (cip != null && Norm(cip) == Norm(ip))
|
|
{
|
|
await c.DisconnectAsync(DisconnectReason.Custom, req.Reason ?? "Banned by admin");
|
|
|
|
if (c.Player != null)
|
|
{
|
|
await c.Player.BanAsync();
|
|
}
|
|
|
|
kicked++;
|
|
}
|
|
}
|
|
|
|
return Ok(new { banned = entry, disconnected = kicked });
|
|
}
|
|
|
|
[HttpPost("/api/admin/ban/fc")]
|
|
public async Task<IActionResult> BanFc([FromBody] BanFcReq req)
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(req.FriendCode))
|
|
{
|
|
return BadRequest(Err("FriendCode required"));
|
|
}
|
|
|
|
var entry = _bans.BanFriendCode(req.FriendCode, req.Reason ?? "Banned by admin", BanStore.ParseDuration(req.Duration));
|
|
var kicked = 0;
|
|
foreach (var c in _clientManager.Clients.ToList())
|
|
{
|
|
if (c.FriendCode == req.FriendCode)
|
|
{
|
|
await c.DisconnectAsync(DisconnectReason.Custom, req.Reason ?? "Banned by admin");
|
|
|
|
if (c.Player != null)
|
|
{
|
|
await c.Player.BanAsync();
|
|
}
|
|
|
|
kicked++;
|
|
}
|
|
}
|
|
|
|
return Ok(new { banned = entry, disconnected = kicked });
|
|
}
|
|
|
|
[HttpPost("/api/admin/unban/ip")]
|
|
public IActionResult UnbanIp([FromBody] UnbanReq req)
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
if (!IPAddress.TryParse(req.Value, out var ip))
|
|
{
|
|
return BadRequest(Err("Invalid IP"));
|
|
}
|
|
|
|
return Ok(new { removed = _bans.UnbanIp(ip) });
|
|
}
|
|
|
|
[HttpPost("/api/admin/unban/fc")]
|
|
public IActionResult UnbanFc([FromBody] UnbanReq req)
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
return Ok(new { removed = _bans.UnbanFriendCode(req.Value) });
|
|
}
|
|
|
|
[HttpPost("/api/admin/game/end")]
|
|
public async Task<IActionResult> EndGame([FromBody] GameCodeReq req)
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
var g = FindGame(req.GameCode);
|
|
if (g == null)
|
|
{
|
|
return NotFound(Err($"Game '{req.GameCode}' not found"));
|
|
}
|
|
|
|
var players = g.Players.ToList();
|
|
foreach (var p in players)
|
|
{
|
|
await p.Client.DisconnectAsync(DisconnectReason.Custom, req.Reason ?? "Game ended by admin");
|
|
await p.KickAsync();
|
|
}
|
|
|
|
return Ok(new { ended = req.GameCode, playersKicked = players.Count });
|
|
}
|
|
|
|
[HttpPost("/api/admin/game/public")]
|
|
public async Task<IActionResult> SetPublic([FromBody] GamePublicReq req)
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
var g = FindGame(req.GameCode);
|
|
if (g == null)
|
|
{
|
|
return NotFound(Err($"Game '{req.GameCode}' not found"));
|
|
}
|
|
|
|
await g.SetPrivacyAsync(req.IsPublic);
|
|
return Ok(new { gameCode = req.GameCode, isPublic = req.IsPublic });
|
|
}
|
|
|
|
[HttpGet("/api/admin/reports")]
|
|
public IActionResult GetReports()
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
return Ok(_reportStore.GetRecent(200).Select(r => new
|
|
{
|
|
time = r.Time.ToString("yyyy-MM-dd HH:mm:ss"),
|
|
gameCode = r.GameCode,
|
|
reporterName = r.ReporterName,
|
|
reporterFc = r.ReporterFriendCode ?? "—",
|
|
reportedName = r.ReportedName ?? "—",
|
|
reportedFc = r.ReportedFriendCode ?? "—",
|
|
reason = r.Reason.ToString(),
|
|
outcome = r.Outcome.ToString(),
|
|
}));
|
|
}
|
|
|
|
[HttpGet("/api/admin/hplp")]
|
|
public IActionResult GetHplp()
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
return Ok(new
|
|
{
|
|
enabled = _hplpStore.Enabled,
|
|
regionId = _hplpStore.RegionId,
|
|
regionName = _hplpStore.RegionName,
|
|
publicUrl = _hplpStore.PublicUrl,
|
|
});
|
|
}
|
|
|
|
[HttpPost("/api/admin/hplp")]
|
|
public async Task<IActionResult> UpdateHplp([FromBody] HplpSettingsReq req)
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
if (req.Enabled.HasValue)
|
|
{
|
|
_hplpStore.Enabled = req.Enabled.Value;
|
|
}
|
|
|
|
if (req.RegionId != null)
|
|
{
|
|
_hplpStore.RegionId = req.RegionId;
|
|
}
|
|
|
|
if (req.RegionName != null)
|
|
{
|
|
_hplpStore.RegionName = req.RegionName;
|
|
}
|
|
|
|
if (req.PublicUrl != null)
|
|
{
|
|
_hplpStore.PublicUrl = req.PublicUrl;
|
|
}
|
|
|
|
await _hplpStore.SaveAsync();
|
|
|
|
return Ok(new
|
|
{
|
|
enabled = _hplpStore.Enabled,
|
|
regionId = _hplpStore.RegionId,
|
|
regionName = _hplpStore.RegionName,
|
|
publicUrl = _hplpStore.PublicUrl,
|
|
});
|
|
}
|
|
|
|
private IGame? FindGame(string code)
|
|
{
|
|
try { return _gameManager.Find(new GameCode(code.ToUpperInvariant())); }
|
|
catch { return null; }
|
|
}
|
|
|
|
private IClient? FindClient(int id) => _clientManager.Clients.FirstOrDefault(c => c.Id == id);
|
|
|
|
private static object Snap(IGame g) => new
|
|
{
|
|
code = GameCodeParser.IntToGameName(g.Code),
|
|
state = g.GameState.ToString(),
|
|
isPublic = g.IsPublic,
|
|
playerCount = g.PlayerCount,
|
|
maxPlayers = g.Options.MaxPlayers,
|
|
map = g.Options.Map.ToString(),
|
|
impostors = g.Options.NumImpostors,
|
|
host = g.Host?.Client.Name ?? "—",
|
|
hostFc = g.Host?.Client.FriendCode ?? "—",
|
|
players = g.Players.Select(p => new
|
|
{
|
|
id = p.Client.Id,
|
|
name = p.Client.Name,
|
|
friendCode = p.Client.FriendCode ?? "—",
|
|
isHost = p.IsHost,
|
|
platform = p.Client.PlatformSpecificData?.Platform.ToString() ?? "Unknown",
|
|
ip = p.Client.Connection?.EndPoint?.Address?.ToString() ?? "—",
|
|
}).ToList(),
|
|
};
|
|
|
|
private static object CSnap(IClient c, string location = "")
|
|
{
|
|
var reactor = c.GetReactorMods();
|
|
var level = c.Player?.Character?.PlayerInfo?.PlayerLevel;
|
|
return new
|
|
{
|
|
id = c.Id,
|
|
name = c.Name,
|
|
friendCode = c.FriendCode ?? "—",
|
|
gameVersion = c.GameVersion.ToString(),
|
|
platform = c.PlatformSpecificData?.Platform.ToString() ?? "Unknown",
|
|
platformName = c.PlatformSpecificData?.PlatformName ?? string.Empty,
|
|
language = LanguageHelper.GetDisplayName(c.Language),
|
|
level = level is null or 0 or uint.MaxValue ? (int?)null : (int)level.Value,
|
|
inGame = c.Player != null,
|
|
gameCode = c.Player != null ? GameCodeParser.IntToGameName(c.Player.Game.Code) : "—",
|
|
ip = c.Connection?.EndPoint?.Address?.ToString() ?? "—",
|
|
location = location,
|
|
reactor = reactor == null ? null : new
|
|
{
|
|
protocolVersion = reactor.ProtocolVersion,
|
|
mods = System.Linq.Enumerable.Select(reactor.Mods, m => new
|
|
{
|
|
id = m.Id,
|
|
version = m.Version,
|
|
required = m.RequiredOnAllClients,
|
|
}).ToArray(),
|
|
},
|
|
};
|
|
}
|
|
|
|
private static string Norm(IPAddress ip)
|
|
=> ip.IsIPv4MappedToIPv6 ? ip.MapToIPv4().ToString() : ip.ToString();
|
|
|
|
private IPAddress? GetClientIp()
|
|
{
|
|
// CDN / reverse proxy headers first so we get the real client IP,
|
|
// not the CDN/nginx IP. Using Connection.RemoteIpAddress directly
|
|
// would rate-limit or ban the proxy instead of the actual user.
|
|
return RealIpResolver.Resolve(HttpContext);
|
|
}
|
|
|
|
private static object Err(string msg) => new { error = msg };
|
|
|
|
private static string Fmt(TimeSpan t)
|
|
{
|
|
if (t.TotalDays >= 1)
|
|
{
|
|
return $"{(int)t.TotalDays}d {t.Hours}h {t.Minutes}m";
|
|
}
|
|
|
|
if (t.TotalHours >= 1)
|
|
{
|
|
return $"{t.Hours}h {t.Minutes}m {t.Seconds}s";
|
|
}
|
|
|
|
return $"{t.Minutes}m {t.Seconds}s";
|
|
}
|
|
|
|
public sealed record BroadcastReq(string Message);
|
|
|
|
public sealed record GameMsgReq(string GameCode, string Message);
|
|
|
|
public sealed record ClientIdReq(int ClientId, string? Reason = null);
|
|
|
|
public sealed record BanIpReq(string Ip, string? Reason, string? Duration = null);
|
|
|
|
public sealed record BanFcReq(string FriendCode, string? Reason, string? Duration = null);
|
|
|
|
public sealed record UnbanReq(string Value);
|
|
|
|
public sealed record GameCodeReq(string GameCode, string? Reason = null);
|
|
|
|
public sealed record GamePublicReq(string GameCode, bool IsPublic);
|
|
|
|
public sealed record HplpSettingsReq(bool? Enabled, string? RegionId, string? RegionName, string? PublicUrl);
|
|
|
|
// HTML pages are auto-generated to BaseDirectory/Pages/ on first access.
|
|
// Server operators can customize them by editing the files on disk.
|
|
private static string? _loginHtml;
|
|
private static string? _adminHtml;
|
|
|
|
private string LoginHtml => _loginHtml ??= GetPage("login.html", AdminTemplateDefaults.LoginHtml);
|
|
private string AdminHtml => _adminHtml ??= GetPage("admin.html", AdminTemplateDefaults.AdminHtml);
|
|
|
|
private static string GetPage(string fileName, string defaultContent)
|
|
{
|
|
try
|
|
{
|
|
var dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Pages");
|
|
var fullPath = Path.Combine(dir, fileName);
|
|
|
|
if (!System.IO.File.Exists(fullPath))
|
|
{
|
|
Directory.CreateDirectory(dir);
|
|
System.IO.File.WriteAllText(fullPath, defaultContent);
|
|
}
|
|
|
|
return System.IO.File.ReadAllText(fullPath);
|
|
}
|
|
catch
|
|
{
|
|
return defaultContent;
|
|
}
|
|
}
|
|
|
|
internal static string ComputeHash(string input)
|
|
{
|
|
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
|
return Convert.ToHexString(bytes);
|
|
}
|
|
|
|
internal static bool ConstantTimeEquals(string a, string b)
|
|
{
|
|
if (a.Length != b.Length)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return CryptographicOperations.FixedTimeEquals(
|
|
Encoding.UTF8.GetBytes(a),
|
|
Encoding.UTF8.GetBytes(b));
|
|
}
|
|
}
|
|
}
|