Files
Empostor/src/Empostor.Server/Http/AdminController.cs
T
2026-07-04 14:14:21 +08:00

967 lines
33 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.Config;
using Empostor.Api.Games;
using Empostor.Api.Games.Managers;
using Empostor.Api.Net;
using Empostor.Api.Net.Manager;
using Empostor.Server.Service.Admin.Ban;
using Empostor.Server.Service.Admin.Chat;
using Empostor.Server.Service.Admin.Reactor;
using Empostor.Server.Service.Admin.Report;
using Empostor.Server.Service.Api;
using Empostor.Server.Service.Stat;
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 PlayerLogStore _playerLogs;
private readonly PlayerStatsStore _playerStats;
private readonly PlayerStatsConfig _statsConfig;
private readonly ChatFilterStore _chatFilter;
private readonly DiscordWebhookStore _discordWebhook;
private readonly IpGeolocationService _ipGeo;
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,
PlayerLogStore playerLogs,
PlayerStatsStore playerStats,
IOptions<PlayerStatsConfig> statsConfig,
ChatFilterStore chatFilter,
DiscordWebhookStore discordWebhook,
IpGeolocationService ipGeo)
{
_logger = logger;
_gameManager = gameManager;
_clientManager = clientManager;
_bans = bans;
_config = config.Value;
_reportStore = reportStore;
_playerLogs = playerLogs;
_playerStats = playerStats;
_statsConfig = statsConfig.Value;
_chatFilter = chatFilter;
_discordWebhook = discordWebhook;
_ipGeo = ipGeo;
_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 = true,
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/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()
{
if (!IsAuthenticated())
{
return Unauthorized();
}
return Ok(_gameManager.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");
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.Value, 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");
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.Value, disconnected = kicked });
}
[HttpPost("/api/admin/unban/ip")]
public IActionResult UnbanIp([FromBody] UnbanReq req)
{
if (!IsAuthenticated())
{
return Unauthorized();
}
return Ok(new { removed = _bans.UnbanIp(req.Value) });
}
[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/player/logs/clients")]
public IActionResult GetPlayerLogClients()
{
if (!IsAuthenticated())
{
return Unauthorized();
}
return Ok(_playerLogs.GetLoggedClientIds().Select(id => new
{
clientId = id,
name = FindClient(id)?.Name ?? "Disconnected",
friendCode = FindClient(id)?.FriendCode ?? "—",
}));
}
[HttpGet("/api/admin/player/logs")]
public IActionResult GetPlayerLogs([FromQuery] int? clientId)
{
if (!IsAuthenticated())
{
return Unauthorized();
}
var logs = clientId.HasValue
? _playerLogs.GetByClient(clientId.Value)
: _playerLogs.GetAll();
return Ok(logs.Select(e => new
{
time = e.Time.ToString("yyyy-MM-dd HH:mm:ss"),
type = e.Type,
clientId = e.ClientId,
playerName = e.PlayerName ?? "—",
friendCode = e.FriendCode ?? "—",
gameCode = e.GameCode ?? "—",
detail = e.Detail ?? "—",
}));
}
[HttpGet("/api/admin/player/logs/export")]
public IActionResult ExportPlayerLogs([FromQuery] int? clientId)
{
if (!IsAuthenticated())
{
return Unauthorized();
}
var data = clientId.HasValue
? _playerLogs.ExportJson(clientId.Value)
: _playerLogs.ExportJson();
var name = clientId.HasValue
? $"player_{clientId}_logs.json"
: "all_player_logs.json";
return File(data, "application/json; charset=utf-8", name);
}
[HttpGet("/api/admin/player/stats")]
public IActionResult GetPlayerStats()
{
if (!IsAuthenticated())
{
return Unauthorized();
}
if (!_statsConfig.Enabled)
{
return Ok(new { enabled = false });
}
return Ok(new
{
enabled = true,
players = _playerStats.GetAll().Select(s => new
{
friendCode = s.FriendCode,
name = s.LastKnownName ?? "—",
gamesPlayed = s.GamesPlayed,
wins = s.Wins,
losses = s.Losses,
impostorWins = s.ImpostorWins,
kills = s.Kills,
deaths = s.Deaths,
tasksCompleted = s.TasksCompleted,
timesExiled = s.TimesExiled,
firstSeen = s.FirstSeen.ToString("yyyy-MM-dd HH:mm:ss"),
lastSeen = s.LastSeen.ToString("yyyy-MM-dd HH:mm:ss"),
}),
});
}
[HttpGet("/api/admin/player/stats/{friendCode}")]
public IActionResult GetPlayerStat(string friendCode)
{
if (!IsAuthenticated())
{
return Unauthorized();
}
var s = _playerStats.GetByFriendCode(friendCode);
if (s == null)
{
return NotFound(Err("Player not found"));
}
return Ok(new
{
friendCode = s.FriendCode,
name = s.LastKnownName ?? "—",
gamesPlayed = s.GamesPlayed,
wins = s.Wins,
losses = s.Losses,
impostorWins = s.ImpostorWins,
kills = s.Kills,
deaths = s.Deaths,
tasksCompleted = s.TasksCompleted,
timesExiled = s.TimesExiled,
firstSeen = s.FirstSeen.ToString("yyyy-MM-dd HH:mm:ss"),
lastSeen = s.LastSeen.ToString("yyyy-MM-dd HH:mm:ss"),
});
}
[HttpPost("/api/admin/player/stats/reset")]
public IActionResult ResetPlayerStats()
{
if (!IsAuthenticated())
{
return Unauthorized();
}
_playerStats.ClearAll();
return Ok(new { reset = true });
}
[HttpGet("/api/admin/chatfilter")]
public IActionResult GetChatFilter()
{
if (!IsAuthenticated())
{
return Unauthorized();
}
return Ok(new
{
enabled = _chatFilter.Enabled,
blockedWords = _chatFilter.BlockedWords,
blockMessage = _chatFilter.BlockMessage,
spamThreshold = _chatFilter.SpamThreshold,
spamWindowSeconds = _chatFilter.SpamWindowSeconds,
});
}
[HttpPost("/api/admin/chatfilter/words/add")]
public IActionResult AddChatFilterWord([FromBody] ChatFilterWordReq req)
{
if (!IsAuthenticated())
{
return Unauthorized();
}
if (string.IsNullOrWhiteSpace(req.Word))
{
return BadRequest(Err("Word required"));
}
_chatFilter.AddWord(req.Word);
return Ok(new { blockedWords = _chatFilter.BlockedWords });
}
[HttpPost("/api/admin/chatfilter/words/remove")]
public IActionResult RemoveChatFilterWord([FromBody] ChatFilterWordReq req)
{
if (!IsAuthenticated())
{
return Unauthorized();
}
if (string.IsNullOrWhiteSpace(req.Word))
{
return BadRequest(Err("Word required"));
}
_chatFilter.RemoveWord(req.Word);
return Ok(new { blockedWords = _chatFilter.BlockedWords });
}
[HttpPost("/api/admin/chatfilter/settings")]
public IActionResult UpdateChatFilterSettings([FromBody] ChatFilterSettingsReq req)
{
if (!IsAuthenticated())
{
return Unauthorized();
}
if (req.Enabled.HasValue)
{
_chatFilter.Enabled = req.Enabled.Value;
}
if (req.BlockMessage.HasValue)
{
_chatFilter.BlockMessage = req.BlockMessage.Value;
}
if (req.SpamThreshold.HasValue)
{
_chatFilter.SpamThreshold = req.SpamThreshold.Value;
}
if (req.SpamWindowSeconds.HasValue)
{
_chatFilter.SpamWindowSeconds = req.SpamWindowSeconds.Value;
}
return Ok(new
{
enabled = _chatFilter.Enabled,
blockedWords = _chatFilter.BlockedWords,
blockMessage = _chatFilter.BlockMessage,
spamThreshold = _chatFilter.SpamThreshold,
spamWindowSeconds = _chatFilter.SpamWindowSeconds,
});
}
[HttpGet("/api/admin/discord")]
public IActionResult GetDiscordWebhook()
{
if (!IsAuthenticated())
{
return Unauthorized();
}
return Ok(new
{
enabled = _discordWebhook.Enabled,
webhookUrl = _discordWebhook.WebhookUrl,
notifyOnGameCreated = _discordWebhook.NotifyOnGameCreated,
notifyOnBan = _discordWebhook.NotifyOnBan,
notifyOnReport = _discordWebhook.NotifyOnReport,
notifyOnPlayerJoin = _discordWebhook.NotifyOnPlayerJoin,
notifyOnGameEnded = _discordWebhook.NotifyOnGameEnded,
});
}
[HttpPost("/api/admin/discord")]
public async Task<IActionResult> UpdateDiscordWebhook([FromBody] DiscordWebhookSettingsReq req)
{
if (!IsAuthenticated())
{
return Unauthorized();
}
if (req.Enabled.HasValue)
{
_discordWebhook.Enabled = req.Enabled.Value;
}
if (req.WebhookUrl != null)
{
_discordWebhook.WebhookUrl = req.WebhookUrl;
}
if (req.NotifyOnGameCreated.HasValue)
{
_discordWebhook.NotifyOnGameCreated = req.NotifyOnGameCreated.Value;
}
if (req.NotifyOnBan.HasValue)
{
_discordWebhook.NotifyOnBan = req.NotifyOnBan.Value;
}
if (req.NotifyOnReport.HasValue)
{
_discordWebhook.NotifyOnReport = req.NotifyOnReport.Value;
}
if (req.NotifyOnPlayerJoin.HasValue)
{
_discordWebhook.NotifyOnPlayerJoin = req.NotifyOnPlayerJoin.Value;
}
if (req.NotifyOnGameEnded.HasValue)
{
_discordWebhook.NotifyOnGameEnded = req.NotifyOnGameEnded.Value;
}
await _discordWebhook.SaveAsync();
return Ok(new
{
enabled = _discordWebhook.Enabled,
webhookUrl = _discordWebhook.WebhookUrl,
notifyOnGameCreated = _discordWebhook.NotifyOnGameCreated,
notifyOnBan = _discordWebhook.NotifyOnBan,
notifyOnReport = _discordWebhook.NotifyOnReport,
notifyOnPlayerJoin = _discordWebhook.NotifyOnPlayerJoin,
notifyOnGameEnded = _discordWebhook.NotifyOnGameEnded,
});
}
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()
{
// Check 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.
var xRealIp = HttpContext.Request.Headers["X-Real-IP"].FirstOrDefault();
if (!string.IsNullOrEmpty(xRealIp) && IPAddress.TryParse(xRealIp, out var realIp))
{
return Normalize(realIp);
}
var xForwardedFor = HttpContext.Request.Headers["X-Forwarded-For"].FirstOrDefault();
if (!string.IsNullOrEmpty(xForwardedFor))
{
var first = xForwardedFor.Split(',')[0].Trim();
if (IPAddress.TryParse(first, out var fwdIp))
{
return Normalize(fwdIp);
}
}
return Normalize(HttpContext.Connection.RemoteIpAddress);
}
private static IPAddress? Normalize(IPAddress? ip)
=> ip?.IsIPv4MappedToIPv6 == true ? ip.MapToIPv4() : ip;
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);
public sealed record BanFcReq(string FriendCode, string? Reason);
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 ChatFilterWordReq(string Word);
public sealed record ChatFilterSettingsReq(bool? Enabled, bool? BlockMessage, int? SpamThreshold, int? SpamWindowSeconds);
public sealed record DiscordWebhookSettingsReq(bool? Enabled, string? WebhookUrl, bool? NotifyOnGameCreated, bool? NotifyOnBan, bool? NotifyOnReport, bool? NotifyOnPlayerJoin, bool? NotifyOnGameEnded);
// 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));
}
}
}