mirror of
https://github.com/Empostor/Empostor.git
synced 2026-10-20 01:46:07 +00:00
Support Starlight HPLP
This commit is contained in:
1 parent
24eb5d3371
commit
0a5e3b3173
20 files changed
+762
-345
No files matched your search
@@ -150,6 +150,7 @@
|
||||
"System.Runtime.CompilerServices.Unsafe": "4.5.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
".NETStandard,Version=v2.0/win-x64": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace Empostor.Api.Config
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration for the HPLP (HTTP Public Lobby List Protocol) endpoint,
|
||||
/// which allows Starlight clients to discover games hosted on this server.
|
||||
/// </summary>
|
||||
public class HplpConfig
|
||||
{
|
||||
public const string Section = "HPLP";
|
||||
|
||||
/// <summary>
|
||||
/// Whether the HPLP endpoint (<c>GET /x-api/games</c>) is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Region identifier reported to Starlight clients (HPLP <c>region_id</c>).
|
||||
/// </summary>
|
||||
public string RegionId { get; set; } = "default";
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable region name displayed in the Starlight lobby UI.
|
||||
/// </summary>
|
||||
public string RegionName { get; set; } = "Empostor Server";
|
||||
|
||||
/// <summary>
|
||||
/// Public URL used by Starlight clients to connect to this server.
|
||||
/// When empty, the URL is auto-constructed as <c>http://{PublicIp}:{HttpPort}</c>.
|
||||
/// Set this to a custom value (e.g. <c>https://your-domain.com</c>) when
|
||||
/// running behind a reverse proxy with TLS.
|
||||
/// </summary>
|
||||
public string PublicUrl { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Empostor.Api.Service;
|
||||
|
||||
/// <summary>
|
||||
/// Abstract base class for JSON file-based data persistence.
|
||||
/// Stores data under <c>./Data/{ClassName}Data.json</c>.
|
||||
/// Subclasses only need to implement <see cref="GetSnapshot"/> and <see cref="ApplySnapshot"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TData">The serializable data type.</typeparam>
|
||||
public abstract class JsonDataStore<TData> : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// JSON serialization options used for save/load.
|
||||
/// Override in subclass constructor to customize (e.g. add <see cref="JsonSerializerOptions.DefaultIgnoreCondition"/>).
|
||||
/// </summary>
|
||||
protected JsonSerializerOptions JsonOpts { get; } = new() { WriteIndented = true };
|
||||
|
||||
/// <summary>
|
||||
/// Full path to the data file.
|
||||
/// Derived automatically from the subclass name: <c>./Data/{ClassName}Data.json</c>
|
||||
/// </summary>
|
||||
protected string FilePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Semaphore used to prevent concurrent writes.
|
||||
/// </summary>
|
||||
protected SemaphoreSlim SaveLock { get; } = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Logger instance for this store.
|
||||
/// </summary>
|
||||
protected ILogger Logger { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger for this store.</param>
|
||||
/// <param name="legacyPath">
|
||||
/// If provided and the legacy file exists but the new path does not,
|
||||
/// the legacy file is automatically copied to the new path (one-time migration).
|
||||
/// </param>
|
||||
protected JsonDataStore(ILogger logger, string? legacyPath = null)
|
||||
{
|
||||
Logger = logger;
|
||||
|
||||
// Derive file name from class name: "DiscordWebhookStore" → "DiscordWebhookData.json"
|
||||
var name = GetType().Name;
|
||||
if (name.EndsWith("Store", StringComparison.Ordinal))
|
||||
{
|
||||
name = name.Substring(0, name.Length - 5) + "Data";
|
||||
}
|
||||
|
||||
FilePath = Path.Combine(Directory.GetCurrentDirectory(), "Data", name + ".json");
|
||||
|
||||
// One-time legacy migration
|
||||
if (legacyPath != null && File.Exists(legacyPath) && !File.Exists(FilePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(FilePath);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
File.Copy(legacyPath, FilePath);
|
||||
Logger.LogInformation("{Name} migrated from {Legacy} to {New}", GetType().Name, legacyPath, FilePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "{Name} failed to migrate {Legacy}", GetType().Name, legacyPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a serializable snapshot from the current in-memory state.
|
||||
/// </summary>
|
||||
protected abstract TData GetSnapshot();
|
||||
|
||||
/// <summary>
|
||||
/// Restore in-memory state from a previously-saved snapshot.
|
||||
/// </summary>
|
||||
protected abstract void ApplySnapshot(TData data);
|
||||
|
||||
/// <summary>
|
||||
/// Load data from the file on disk. If the file does not exist or fails to parse,
|
||||
/// the store remains in its default (constructor-initialized) state.
|
||||
/// </summary>
|
||||
protected void Load()
|
||||
{
|
||||
if (!File.Exists(FilePath))
|
||||
{
|
||||
Logger.LogDebug("{Name} no data file at {Path}, using defaults", GetType().Name, FilePath);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(FilePath);
|
||||
var data = JsonSerializer.Deserialize<TData>(json, JsonOpts);
|
||||
if (data != null)
|
||||
{
|
||||
ApplySnapshot(data);
|
||||
Logger.LogInformation("{Name} loaded from {Path}", GetType().Name, FilePath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "{Name} failed to load {Path}, using defaults", GetType().Name, FilePath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save the current state to disk asynchronously, with a non-blocking write lock.
|
||||
/// If a save is already in progress, this call is silently skipped.
|
||||
/// </summary>
|
||||
protected async ValueTask SaveAsync()
|
||||
{
|
||||
if (!await SaveLock.WaitAsync(0))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(FilePath);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
var snapshot = GetSnapshot();
|
||||
var json = JsonSerializer.Serialize(snapshot, JsonOpts);
|
||||
await File.WriteAllTextAsync(FilePath, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "{Name} failed to save {Path}", GetType().Name, FilePath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SaveLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fire-and-forget save. Schedules a background save and returns immediately.
|
||||
/// Useful for high-frequency writes where waiting is not needed.
|
||||
/// </summary>
|
||||
protected void SaveFireAndForget()
|
||||
{
|
||||
_ = Task.Run(SaveAsyncAsTask);
|
||||
|
||||
async Task SaveAsyncAsTask()
|
||||
{
|
||||
if (!await SaveLock.WaitAsync(0))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(FilePath);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
var snapshot = GetSnapshot();
|
||||
var json = JsonSerializer.Serialize(snapshot, JsonOpts);
|
||||
await File.WriteAllTextAsync(FilePath, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "{Name} failed to save {Path}", GetType().Name, FilePath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SaveLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
SaveLock.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -14,14 +14,37 @@ public sealed class PlayerConnectStore : IDisposable
|
||||
private readonly ConcurrentDictionary<string, DateTime> _lastConnect;
|
||||
private readonly Timer _saveTimer;
|
||||
private readonly ILogger<PlayerConnectStore> _logger;
|
||||
private readonly string _filePath;
|
||||
private bool _dirty;
|
||||
|
||||
public PlayerConnectStore(ILogger<PlayerConnectStore> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_filePath = Path.Combine(Directory.GetCurrentDirectory(), "Data", "PlayerConnectData.json");
|
||||
_lastConnect = Load();
|
||||
_saveTimer = new Timer(_ => SaveIfDirty(), null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
|
||||
_instance = this;
|
||||
|
||||
// One-time migration from old path
|
||||
var legacyPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Data", "PlayerConnect.json");
|
||||
if (File.Exists(legacyPath) && !File.Exists(_filePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_filePath);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
File.Copy(legacyPath, _filePath);
|
||||
logger.LogInformation("PlayerConnectStore migrated from {Legacy} to {New}", legacyPath, _filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "PlayerConnectStore failed to migrate {Legacy}", legacyPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordDisconnect(string productUserId)
|
||||
@@ -57,18 +80,16 @@ public sealed class PlayerConnectStore : IDisposable
|
||||
return local.ToString("yyyy-MM-dd HH:mm:ss");
|
||||
}
|
||||
|
||||
private static ConcurrentDictionary<string, DateTime> Load()
|
||||
private ConcurrentDictionary<string, DateTime> Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Data");
|
||||
var path = Path.Combine(dir, "PlayerConnect.json");
|
||||
if (!File.Exists(path))
|
||||
if (!File.Exists(_filePath))
|
||||
{
|
||||
return new ConcurrentDictionary<string, DateTime>();
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(path);
|
||||
var json = File.ReadAllText(_filePath);
|
||||
return JsonSerializer.Deserialize<ConcurrentDictionary<string, DateTime>>(json)
|
||||
?? new ConcurrentDictionary<string, DateTime>();
|
||||
}
|
||||
@@ -87,11 +108,14 @@ public sealed class PlayerConnectStore : IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
var dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Data");
|
||||
Directory.CreateDirectory(dir);
|
||||
var path = Path.Combine(dir, "PlayerConnect.json");
|
||||
var dir = Path.GetDirectoryName(_filePath);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(_lastConnect);
|
||||
File.WriteAllText(path, json);
|
||||
File.WriteAllText(_filePath, json);
|
||||
_dirty = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -105,5 +129,6 @@ public sealed class PlayerConnectStore : IDisposable
|
||||
_saveTimer.Dispose();
|
||||
SaveIfDirty();
|
||||
_instance = null;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,12 @@
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[8.0.28, )",
|
||||
"resolved": "8.0.28",
|
||||
"contentHash": "XMqgVjlLxLqWmEh3c49haXLQwsMNtvo6YscUaqfvEGfg1iA8hnYgkUVq3i9Zu9gKeNKMWiiZKVwZExc/qyEAsQ=="
|
||||
},
|
||||
"StyleCop.Analyzers": {
|
||||
"type": "Direct",
|
||||
"requested": "[1.2.0-beta.556, )",
|
||||
@@ -139,6 +145,7 @@
|
||||
"NextFast.Hazel.Abstractions": {
|
||||
"type": "Project"
|
||||
}
|
||||
}
|
||||
},
|
||||
"net8.0/win-x64": {}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,9 @@ namespace Empostor.Server.Http
|
||||
private readonly PlayerStatsConfig _statsConfig;
|
||||
private readonly ChatFilterStore _chatFilter;
|
||||
private readonly DiscordWebhookStore _discordWebhook;
|
||||
private readonly HplpStore _hplpStore;
|
||||
private readonly IOptions<ServerConfig> _serverConfig;
|
||||
private readonly IOptions<HttpServerConfig> _httpServerConfig;
|
||||
private readonly IpGeolocationService _ipGeo;
|
||||
private readonly string _passwordHash;
|
||||
private static readonly ConcurrentDictionary<string, (int Count, DateTime FirstAttempt)> _loginFailures = new();
|
||||
@@ -65,6 +68,9 @@ namespace Empostor.Server.Http
|
||||
IOptions<PlayerStatsConfig> statsConfig,
|
||||
ChatFilterStore chatFilter,
|
||||
DiscordWebhookStore discordWebhook,
|
||||
HplpStore hplpStore,
|
||||
IOptions<ServerConfig> serverConfig,
|
||||
IOptions<HttpServerConfig> httpServerConfig,
|
||||
IpGeolocationService ipGeo)
|
||||
{
|
||||
_logger = logger;
|
||||
@@ -78,6 +84,9 @@ namespace Empostor.Server.Http
|
||||
_statsConfig = statsConfig.Value;
|
||||
_chatFilter = chatFilter;
|
||||
_discordWebhook = discordWebhook;
|
||||
_hplpStore = hplpStore;
|
||||
_serverConfig = serverConfig;
|
||||
_httpServerConfig = httpServerConfig;
|
||||
_ipGeo = ipGeo;
|
||||
_passwordHash = ComputeHash(_config.Password);
|
||||
|
||||
@@ -672,7 +681,7 @@ namespace Empostor.Server.Http
|
||||
}
|
||||
|
||||
[HttpPost("/api/admin/chatfilter/settings")]
|
||||
public IActionResult UpdateChatFilterSettings([FromBody] ChatFilterSettingsReq req)
|
||||
public async Task<IActionResult> UpdateChatFilterSettings([FromBody] ChatFilterSettingsReq req)
|
||||
{
|
||||
if (!IsAuthenticated())
|
||||
{
|
||||
@@ -699,6 +708,8 @@ namespace Empostor.Server.Http
|
||||
_chatFilter.SpamWindowSeconds = req.SpamWindowSeconds.Value;
|
||||
}
|
||||
|
||||
await _chatFilter.SaveAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
enabled = _chatFilter.Enabled,
|
||||
@@ -751,6 +762,62 @@ namespace Empostor.Server.Http
|
||||
});
|
||||
}
|
||||
|
||||
[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())); }
|
||||
@@ -881,6 +948,8 @@ namespace Empostor.Server.Http
|
||||
|
||||
public sealed record DiscordWebhookSettingsReq(string? MatchmakerUrl, string? AdminUrl);
|
||||
|
||||
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;
|
||||
|
||||
@@ -694,6 +694,7 @@ internal static class AdminTemplateDefaults
|
||||
<div class="ni" onclick="nav('st')"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 20V10M12 20V4M6 20v-6"/></svg><span data-i18n="nav.statistics">Statistics</span></div>
|
||||
<div class="ni" onclick="nav('cf')"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg><span data-i18n="nav.chat_filter">Chat Filter</span></div>
|
||||
<div class="ni" onclick="nav('dw')"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg><span data-i18n="nav.discord">Discord</span></div>
|
||||
<div class="ni" onclick="nav('hp')"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg><span data-i18n="nav.hplp">HPLP</span></div>
|
||||
<div class="nsep"></div>
|
||||
<div class="nlbl" data-i18n="nav.actions">Actions</div>
|
||||
<div class="ni" onclick="nav('bc')"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 2H7a2 2 0 00-2 2v16l5-3 5 3V4a2 2 0 00-2-2z"/><path d="M10 9h4M10 13h4"/></svg><span data-i18n="nav.broadcast">Broadcast</span></div>
|
||||
@@ -1059,6 +1060,32 @@ internal static class AdminTemplateDefaults
|
||||
</div>
|
||||
<div id="dw-r" class="msg"></div>
|
||||
</div>
|
||||
<div id="p-hp" class="pnl">
|
||||
<div class="form">
|
||||
<h2 style="margin:0"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg><span data-i18n="hplp.title">HPLP (Public Lobby List)</span></h2>
|
||||
<button class="bp bsm" onclick="saveHplpSettings()"><span data-i18n="hplp.save">Save Settings</span></button>
|
||||
<div style="margin-top:12px">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
|
||||
<input type="checkbox" id="hp-enabled" onchange="saveHplpSettings()">
|
||||
<span data-i18n="hplp.enabled">Enable HPLP endpoint (GET /x-api/games)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field" style="margin-top:10px">
|
||||
<label data-i18n="hplp.region_id">Region ID</label>
|
||||
<input type="text" id="hp-region-id" data-i18n-placeholder="hplp.region_id_placeholder" placeholder="e.g. meu, default" onchange="saveHplpSettings()">
|
||||
</div>
|
||||
<div class="field" style="margin-top:10px">
|
||||
<label data-i18n="hplp.region_name">Region Name</label>
|
||||
<input type="text" id="hp-region-name" data-i18n-placeholder="hplp.region_name_placeholder" placeholder="e.g. Modded EU" onchange="saveHplpSettings()">
|
||||
</div>
|
||||
<div class="field" style="margin-top:10px">
|
||||
<label data-i18n="hplp.public_url">Public URL (optional)</label>
|
||||
<input type="text" id="hp-public-url" data-i18n-placeholder="hplp.public_url_placeholder" placeholder="Leave empty to auto-generate from server config" onchange="saveHplpSettings()">
|
||||
<div style="font-size:11px;color:var(--m);margin-top:4px"><span data-i18n="hplp.public_url_desc">Used by Starlight clients to connect. Auto-generated as http://ip:port if empty.</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="hp-r" class="msg"></div>
|
||||
</div>
|
||||
</ct>
|
||||
</main>
|
||||
<!-- Client Detail Modal -->
|
||||
@@ -1420,6 +1447,37 @@ internal static class AdminTemplateDefaults
|
||||
}
|
||||
}
|
||||
|
||||
async function fHplp() {
|
||||
const { data } = await api('GET', '/api/admin/hplp');
|
||||
document.getElementById('hp-enabled').checked = data.enabled;
|
||||
const regionIdEl = document.getElementById('hp-region-id');
|
||||
const regionNameEl = document.getElementById('hp-region-name');
|
||||
const publicUrlEl = document.getElementById('hp-public-url');
|
||||
if (document.activeElement !== regionIdEl) {
|
||||
regionIdEl.value = data.regionId || '';
|
||||
}
|
||||
if (document.activeElement !== regionNameEl) {
|
||||
regionNameEl.value = data.regionName || '';
|
||||
}
|
||||
if (document.activeElement !== publicUrlEl) {
|
||||
publicUrlEl.value = data.publicUrl || '';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveHplpSettings() {
|
||||
const { ok, data } = await api('POST', '/api/admin/hplp', {
|
||||
enabled: document.getElementById('hp-enabled').checked,
|
||||
regionId: document.getElementById('hp-region-id').value.trim(),
|
||||
regionName: document.getElementById('hp-region-name').value.trim(),
|
||||
publicUrl: document.getElementById('hp-public-url').value.trim()
|
||||
});
|
||||
if (ok) {
|
||||
msg('hp-r', true, _('hplp.saved', 'HPLP settings saved.'));
|
||||
} else {
|
||||
msg('hp-r', false, data.error ?? 'Error');
|
||||
}
|
||||
}
|
||||
|
||||
function refreshTab() {
|
||||
if (cur === 'ov') fGames('ov-t', true);
|
||||
if (cur === 'gm') fGames('gm-t', false);
|
||||
@@ -1431,6 +1489,7 @@ internal static class AdminTemplateDefaults
|
||||
if (cur === 'st') fStats();
|
||||
if (cur === 'cf') fChatFilter();
|
||||
if (cur === 'dw') fDiscordWebhook();
|
||||
if (cur === 'hp') fHplp();
|
||||
}
|
||||
|
||||
async function doBc() {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Empostor.Api.Config;
|
||||
using Empostor.Api.Games;
|
||||
using Empostor.Api.Games.Managers;
|
||||
using Empostor.Api.Innersloth;
|
||||
using Empostor.Server.Service.Admin.Reactor;
|
||||
using Empostor.Server.Service.Api;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Empostor.Server.Http;
|
||||
|
||||
/// <summary>
|
||||
/// Implements the HPLP (HTTP Public Lobby List Protocol) for Starlight client discovery.
|
||||
/// Serves <c>GET /x-api/games</c> with active games and region metadata.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("/x-api")]
|
||||
public sealed class HplpController : ControllerBase
|
||||
{
|
||||
private readonly IGameManager _gameManager;
|
||||
private readonly HplpStore _hplpStore;
|
||||
private readonly ServerConfig _serverConfig;
|
||||
private readonly HttpServerConfig _httpConfig;
|
||||
|
||||
public HplpController(
|
||||
IGameManager gameManager,
|
||||
HplpStore hplpStore,
|
||||
IOptions<ServerConfig> serverConfig,
|
||||
IOptions<HttpServerConfig> httpConfig)
|
||||
{
|
||||
_gameManager = gameManager;
|
||||
_hplpStore = hplpStore;
|
||||
_serverConfig = serverConfig.Value;
|
||||
_httpConfig = httpConfig.Value;
|
||||
}
|
||||
|
||||
[HttpGet("games")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
public IActionResult GetGames()
|
||||
{
|
||||
if (!_hplpStore.Enabled)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "HPLP is not enabled on this server." });
|
||||
}
|
||||
|
||||
var regionUrl = ResolveRegionUrl();
|
||||
var regionId = _hplpStore.RegionId;
|
||||
|
||||
var games = _gameManager.Games.Select(game => new
|
||||
{
|
||||
code = (string)game.Code,
|
||||
host_name = game.DisplayName ?? game.Host?.Client.Name ?? "—",
|
||||
status = MapStatus(game.GameState),
|
||||
player_count = game.PlayerCount,
|
||||
max_players = (int)game.Options.MaxPlayers,
|
||||
chat_lang = (int)game.Options.Keywords,
|
||||
map_id = (int)game.Options.Map,
|
||||
region_id = regionId,
|
||||
mods = GetMods(game),
|
||||
}).ToList();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
games,
|
||||
regions = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
id = regionId,
|
||||
name = _hplpStore.RegionName,
|
||||
url = regionUrl,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private string ResolveRegionUrl()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_hplpStore.PublicUrl))
|
||||
{
|
||||
return _hplpStore.PublicUrl.TrimEnd('/');
|
||||
}
|
||||
|
||||
var publicIp = _serverConfig.ResolvePublicIp();
|
||||
return $"http://{publicIp}:{_httpConfig.ListenPort}";
|
||||
}
|
||||
|
||||
private static string MapStatus(GameStates state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
GameStates.NotStarted => "Lobby",
|
||||
GameStates.Starting => "Lobby",
|
||||
GameStates.Started => "Started",
|
||||
GameStates.Ended => "Ended",
|
||||
GameStates.Destroyed => "Ended",
|
||||
_ => "Lobby",
|
||||
};
|
||||
}
|
||||
|
||||
private static object[] GetMods(IGame game)
|
||||
{
|
||||
// Try Reactor mods from the host's handshake first.
|
||||
var reactorMods = game.Host?.Client.GetReactorMods();
|
||||
if (reactorMods != null && reactorMods.Mods.Count > 0)
|
||||
{
|
||||
return reactorMods.Mods.Select(m => new
|
||||
{
|
||||
id = m.Id,
|
||||
version = m.Version,
|
||||
flags = m.RequiredOnAllClients ? 1 : 0,
|
||||
}).ToArray<object>();
|
||||
}
|
||||
|
||||
return Array.Empty<object>();
|
||||
}
|
||||
}
|
||||
@@ -514,7 +514,7 @@ namespace Empostor.Server.Net
|
||||
outcome = reportEvent.Outcome;
|
||||
}
|
||||
|
||||
_reportStore.Add(new ReportEntry
|
||||
_reportStore.Add(new ReportStore.ReportEntry
|
||||
{
|
||||
GameCode = gameCode.ToString(),
|
||||
ReporterName = Name,
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"nav.statistics": "Statistics",
|
||||
"nav.chat_filter": "Chat Filter",
|
||||
"nav.discord": "Discord",
|
||||
"nav.hplp": "HPLP",
|
||||
"nav.reload_lang": "Reload Lang",
|
||||
|
||||
"status.games": "Games",
|
||||
@@ -224,5 +225,17 @@
|
||||
"discord.admin_url_placeholder": "https://discord.com/api/webhooks/...",
|
||||
"discord.admin_desc": "Player Banned, Player Reported",
|
||||
"discord.hint": "Leave a URL empty to disable its notifications. Leave both empty to disable all webhooks.",
|
||||
"discord.saved": "Discord webhook settings saved."
|
||||
"discord.saved": "Discord webhook settings saved.",
|
||||
|
||||
"hplp.title": "HPLP (Public Lobby List)",
|
||||
"hplp.save": "Save Settings",
|
||||
"hplp.enabled": "Enable HPLP endpoint (GET /x-api/games)",
|
||||
"hplp.region_id": "Region ID",
|
||||
"hplp.region_id_placeholder": "e.g. meu, default",
|
||||
"hplp.region_name": "Region Name",
|
||||
"hplp.region_name_placeholder": "e.g. Modded EU",
|
||||
"hplp.public_url": "Public URL (optional)",
|
||||
"hplp.public_url_placeholder": "Leave empty to auto-generate from server config",
|
||||
"hplp.public_url_desc": "Used by Starlight clients to connect. Auto-generated as http://ip:port if empty.",
|
||||
"hplp.saved": "HPLP settings saved."
|
||||
}
|
||||
@@ -115,6 +115,7 @@ namespace Empostor.Server
|
||||
services.Configure<HttpServerConfig>(host.Configuration.GetSection(HttpServerConfig.Section));
|
||||
services.Configure<AdminConfig>(host.Configuration.GetSection(AdminConfig.Section));
|
||||
services.Configure<DiscordWebhookConfig>(host.Configuration.GetSection(DiscordWebhookConfig.Section));
|
||||
services.Configure<HplpConfig>(host.Configuration.GetSection(HplpConfig.Section));
|
||||
services.Configure<PlayerStatsConfig>(host.Configuration.GetSection(PlayerStatsConfig.Section));
|
||||
services.Configure<ChatFilterConfig>(host.Configuration.GetSection(ChatFilterConfig.Section));
|
||||
services.Configure<AuthApiConfig>(host.Configuration.GetSection(AuthApiConfig.Section));
|
||||
@@ -205,6 +206,7 @@ namespace Empostor.Server
|
||||
services.AddSingleton<PlayerStatsStore>();
|
||||
services.AddSingleton<StatCommand>();
|
||||
services.AddSingleton<DiscordWebhookStore>();
|
||||
services.AddSingleton<HplpStore>();
|
||||
services.AddSingleton<IEventListener, DiscordWebhookListener>();
|
||||
services.AddSingleton<IEventListener, PlayerStatsListener>();
|
||||
services.AddSingleton<ChatFilterStore>();
|
||||
|
||||
@@ -1,33 +1,22 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using Empostor.Api.Service;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Empostor.Server.Service.Admin.Ban
|
||||
{
|
||||
public sealed class BanStore : IDisposable
|
||||
public sealed class BanStore : JsonDataStore<BanStore.BanData>
|
||||
{
|
||||
private static readonly string BanFile =
|
||||
Path.Combine(Directory.GetCurrentDirectory(), "bans.json");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts =
|
||||
new() { WriteIndented = true };
|
||||
|
||||
private readonly ILogger<BanStore> _logger;
|
||||
private readonly SemaphoreSlim _saveLock = new(1, 1);
|
||||
|
||||
private ConcurrentDictionary<string, BanEntry> _ips = new();
|
||||
private ConcurrentDictionary<string, BanEntry> _friendCodes = new();
|
||||
|
||||
public BanStore(ILogger<BanStore> logger)
|
||||
: base(logger, legacyPath: "bans.json")
|
||||
{
|
||||
_logger = logger;
|
||||
Load();
|
||||
}
|
||||
|
||||
@@ -40,7 +29,7 @@ namespace Empostor.Server.Service.Admin.Ban
|
||||
var key = Normalize(ip);
|
||||
var entry = new BanEntry { Value = key, Reason = reason, BannedAt = DateTime.UtcNow };
|
||||
_ips[key] = entry;
|
||||
SaveAsync();
|
||||
SaveFireAndForget();
|
||||
return entry;
|
||||
}
|
||||
|
||||
@@ -49,7 +38,7 @@ namespace Empostor.Server.Service.Admin.Ban
|
||||
var r = _ips.TryRemove(key, out _);
|
||||
if (r)
|
||||
{
|
||||
SaveAsync();
|
||||
SaveFireAndForget();
|
||||
}
|
||||
|
||||
return r;
|
||||
@@ -59,7 +48,7 @@ namespace Empostor.Server.Service.Admin.Ban
|
||||
{
|
||||
var entry = new BanEntry { Value = fc, Reason = reason, BannedAt = DateTime.UtcNow };
|
||||
_friendCodes[fc] = entry;
|
||||
SaveAsync();
|
||||
SaveFireAndForget();
|
||||
return entry;
|
||||
}
|
||||
|
||||
@@ -68,7 +57,7 @@ namespace Empostor.Server.Service.Admin.Ban
|
||||
var r = _friendCodes.TryRemove(key, out _);
|
||||
if (r)
|
||||
{
|
||||
SaveAsync();
|
||||
SaveFireAndForget();
|
||||
}
|
||||
|
||||
return r;
|
||||
@@ -82,51 +71,22 @@ namespace Empostor.Server.Service.Admin.Ban
|
||||
|
||||
public (int IpCount, int FcCount) Stats() => (_ips.Count, _friendCodes.Count);
|
||||
|
||||
private void SaveAsync()
|
||||
protected override BanData GetSnapshot() => new()
|
||||
{
|
||||
_ = System.Threading.Tasks.Task.Run(async () =>
|
||||
{
|
||||
if (!await _saveLock.WaitAsync(0))
|
||||
{
|
||||
return;
|
||||
}
|
||||
Ips = new(_ips),
|
||||
FriendCodes = new(_friendCodes),
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var data = new BanData { Ips = new(_ips), FriendCodes = new(_friendCodes) };
|
||||
await File.WriteAllTextAsync(BanFile, JsonSerializer.Serialize(data, JsonOpts));
|
||||
}
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "BanStoreFailed to save bans.json"); }
|
||||
finally { _saveLock.Release(); }
|
||||
});
|
||||
}
|
||||
|
||||
private void Load()
|
||||
protected override void ApplySnapshot(BanData data)
|
||||
{
|
||||
if (!File.Exists(BanFile))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var data = JsonSerializer.Deserialize<BanData>(File.ReadAllText(BanFile));
|
||||
if (data != null)
|
||||
{
|
||||
_ips = new(data.Ips ?? new());
|
||||
_friendCodes = new(data.FriendCodes ?? new());
|
||||
_logger.LogInformation("BanStoreLoaded {I} IP bans + {F} FC bans", _ips.Count, _friendCodes.Count);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "BanStoreFailed to load bans.json"); }
|
||||
_ips = new(data.Ips ?? new());
|
||||
_friendCodes = new(data.FriendCodes ?? new());
|
||||
}
|
||||
|
||||
private static string Normalize(IPAddress ip)
|
||||
=> ip.IsIPv4MappedToIPv6 ? ip.MapToIPv4().ToString() : ip.ToString();
|
||||
|
||||
public void Dispose() => _saveLock.Dispose();
|
||||
|
||||
private sealed class BanData
|
||||
public sealed class BanData
|
||||
{
|
||||
[JsonPropertyName("ips")]
|
||||
public Dictionary<string, BanEntry>? Ips { get; set; }
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Empostor.Api.Config;
|
||||
using Empostor.Api.Service;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Empostor.Server.Service.Admin.Chat;
|
||||
|
||||
public sealed class ChatFilterStore
|
||||
public sealed class ChatFilterStore : JsonDataStore<ChatFilterConfig>
|
||||
{
|
||||
private readonly object _wordsLock = new();
|
||||
|
||||
private List<string> _blockedWords;
|
||||
private List<string> _blockedWords = new();
|
||||
|
||||
public ChatFilterStore(IOptions<ChatFilterConfig> config)
|
||||
public ChatFilterStore(ILogger<ChatFilterStore> logger, IOptions<ChatFilterConfig> config)
|
||||
: base(logger, legacyPath: null)
|
||||
{
|
||||
var cfg = config.Value;
|
||||
Enabled = cfg.Enabled;
|
||||
BlockMessage = cfg.BlockMessage;
|
||||
SpamThreshold = cfg.SpamThreshold;
|
||||
SpamWindowSeconds = cfg.SpamWindowSeconds;
|
||||
_blockedWords = new List<string>(cfg.BlockedWords ?? new List<string>());
|
||||
Load();
|
||||
|
||||
// If no persisted file was loaded, fall back to config.json defaults
|
||||
if (_blockedWords.Count == 0 && !Enabled)
|
||||
{
|
||||
var cfg = config.Value;
|
||||
Enabled = cfg.Enabled;
|
||||
BlockMessage = cfg.BlockMessage;
|
||||
SpamThreshold = cfg.SpamThreshold;
|
||||
SpamWindowSeconds = cfg.SpamWindowSeconds;
|
||||
_blockedWords = new List<string>(cfg.BlockedWords ?? new List<string>());
|
||||
}
|
||||
}
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
@@ -57,6 +67,8 @@ public sealed class ChatFilterStore
|
||||
|
||||
_blockedWords.Add(trimmed);
|
||||
}
|
||||
|
||||
SaveFireAndForget();
|
||||
}
|
||||
|
||||
public bool RemoveWord(string word)
|
||||
@@ -66,10 +78,47 @@ public sealed class ChatFilterStore
|
||||
return false;
|
||||
}
|
||||
|
||||
var removed = false;
|
||||
lock (_wordsLock)
|
||||
{
|
||||
return _blockedWords.RemoveAll(
|
||||
removed = _blockedWords.RemoveAll(
|
||||
w => w.Equals(word.Trim(), StringComparison.OrdinalIgnoreCase)) > 0;
|
||||
}
|
||||
|
||||
if (removed)
|
||||
{
|
||||
SaveFireAndForget();
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
public new async ValueTask SaveAsync() => await base.SaveAsync();
|
||||
|
||||
protected override ChatFilterConfig GetSnapshot()
|
||||
{
|
||||
lock (_wordsLock)
|
||||
{
|
||||
return new ChatFilterConfig
|
||||
{
|
||||
Enabled = Enabled,
|
||||
BlockMessage = BlockMessage,
|
||||
SpamThreshold = SpamThreshold,
|
||||
SpamWindowSeconds = SpamWindowSeconds,
|
||||
BlockedWords = new List<string>(_blockedWords),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
protected override void ApplySnapshot(ChatFilterConfig data)
|
||||
{
|
||||
Enabled = data.Enabled;
|
||||
BlockMessage = data.BlockMessage;
|
||||
SpamThreshold = data.SpamThreshold;
|
||||
SpamWindowSeconds = data.SpamWindowSeconds;
|
||||
lock (_wordsLock)
|
||||
{
|
||||
_blockedWords = new List<string>(data.BlockedWords ?? new List<string>());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,20 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Empostor.Api.Service;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Empostor.Server.Service.Admin.Report
|
||||
{
|
||||
public sealed class ReportStore : IDisposable
|
||||
public sealed class ReportStore : JsonDataStore<List<ReportStore.ReportEntry>>
|
||||
{
|
||||
private static readonly string DataDir = Path.Combine(Directory.GetCurrentDirectory(), "Data");
|
||||
private static readonly string ReportsFile = Path.Combine(DataDir, "reports.json");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = true };
|
||||
|
||||
private readonly ILogger<ReportStore> _logger;
|
||||
private readonly List<ReportEntry> _reports = new();
|
||||
private readonly object _lock = new();
|
||||
private readonly SemaphoreSlim _saveLock = new(1, 1);
|
||||
private readonly List<ReportEntry> _reports = new();
|
||||
private const int Max = 500;
|
||||
|
||||
public ReportStore(ILogger<ReportStore> logger)
|
||||
: base(logger, legacyPath: "Data/reports.json")
|
||||
{
|
||||
_logger = logger;
|
||||
Load();
|
||||
}
|
||||
|
||||
@@ -39,7 +29,7 @@ namespace Empostor.Server.Service.Admin.Report
|
||||
}
|
||||
}
|
||||
|
||||
SaveAsync();
|
||||
SaveFireAndForget();
|
||||
}
|
||||
|
||||
public List<ReportEntry> GetRecent(int count = 100)
|
||||
@@ -50,82 +40,40 @@ namespace Empostor.Server.Service.Admin.Report
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveAsync()
|
||||
protected override List<ReportEntry> GetSnapshot()
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
lock (_lock)
|
||||
{
|
||||
if (!await _saveLock.WaitAsync(0))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(DataDir);
|
||||
List<ReportEntry> snapshot;
|
||||
lock (_lock)
|
||||
{
|
||||
snapshot = _reports.ToList();
|
||||
}
|
||||
|
||||
await File.WriteAllTextAsync(ReportsFile, JsonSerializer.Serialize(snapshot, JsonOpts));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "ReportFailed to save reports");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_saveLock.Release();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void Load()
|
||||
{
|
||||
if (!File.Exists(ReportsFile))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var list = JsonSerializer.Deserialize<List<ReportEntry>>(File.ReadAllText(ReportsFile));
|
||||
if (list != null)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_reports.AddRange(list.Take(Max));
|
||||
}
|
||||
|
||||
_logger.LogInformation("ReportLoaded {Count} report(s)", _reports.Count);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "ReportFailed to load reports file");
|
||||
return _reports.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _saveLock.Dispose();
|
||||
}
|
||||
protected override void ApplySnapshot(List<ReportEntry> data)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_reports.Clear();
|
||||
_reports.AddRange(data.Take(Max));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ReportEntry
|
||||
{
|
||||
public DateTime Time { get; init; } = DateTime.UtcNow;
|
||||
public sealed class ReportEntry
|
||||
{
|
||||
public DateTime Time { get; init; } = DateTime.UtcNow;
|
||||
|
||||
public string GameCode { get; init; } = string.Empty;
|
||||
public string GameCode { get; init; } = string.Empty;
|
||||
|
||||
public string ReporterName { get; init; } = string.Empty;
|
||||
public string ReporterName { get; init; } = string.Empty;
|
||||
|
||||
public string? ReporterFriendCode { get; init; }
|
||||
public string? ReporterFriendCode { get; init; }
|
||||
|
||||
public string? ReportedName { get; init; }
|
||||
public string? ReportedName { get; init; }
|
||||
|
||||
public string? ReportedFriendCode { get; init; }
|
||||
public string? ReportedFriendCode { get; init; }
|
||||
|
||||
public ReportReasons Reason { get; init; }
|
||||
public ReportReasons Reason { get; init; }
|
||||
|
||||
public ReportOutcome Outcome { get; init; }
|
||||
public ReportOutcome Outcome { get; init; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,58 +1,32 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Empostor.Api.Config;
|
||||
using Empostor.Api.Service;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Empostor.Server.Service.Api;
|
||||
|
||||
public sealed class DiscordWebhookStore
|
||||
public sealed class DiscordWebhookStore : JsonDataStore<DiscordWebhookConfig>
|
||||
{
|
||||
private static readonly string ConfigFile = Path.Combine(Directory.GetCurrentDirectory(), "discord_webhook.json");
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
private readonly ILogger<DiscordWebhookStore> _logger;
|
||||
private readonly SemaphoreSlim _saveLock = new(1, 1);
|
||||
|
||||
public DiscordWebhookStore(ILogger<DiscordWebhookStore> logger, IOptions<DiscordWebhookConfig> config)
|
||||
: base(logger, legacyPath: "discord_webhook.json")
|
||||
{
|
||||
_logger = logger;
|
||||
JsonOpts.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
||||
|
||||
DiscordWebhookConfig? cfg = null;
|
||||
// Try loading from persisted file (handled by base.Load if file exists)
|
||||
Load();
|
||||
|
||||
if (File.Exists(ConfigFile))
|
||||
// If no persisted data was loaded, fall back to config.json defaults
|
||||
if (string.IsNullOrEmpty(MatchmakerUrl) && string.IsNullOrEmpty(AdminUrl))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(ConfigFile);
|
||||
cfg = JsonSerializer.Deserialize<DiscordWebhookConfig>(json);
|
||||
if (cfg != null)
|
||||
{
|
||||
_logger.LogInformation("DiscordWebhookLoaded from {File}", ConfigFile);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "DiscordWebhookFailed to load {File}, using defaults", ConfigFile);
|
||||
}
|
||||
var cfg = config.Value;
|
||||
MatchmakerUrl = cfg.MatchmakerUrl;
|
||||
AdminUrl = cfg.AdminUrl;
|
||||
|
||||
// Migrate legacy WebhookUrl if present
|
||||
MigrateLegacy(cfg);
|
||||
}
|
||||
|
||||
// Fall back to config.json values
|
||||
cfg ??= config.Value;
|
||||
|
||||
// Migrate legacy WebhookUrl to new fields if present and new fields are empty
|
||||
MigrateLegacy(cfg);
|
||||
|
||||
MatchmakerUrl = cfg.MatchmakerUrl;
|
||||
AdminUrl = cfg.AdminUrl;
|
||||
}
|
||||
|
||||
public string MatchmakerUrl { get; set; } = string.Empty;
|
||||
@@ -65,29 +39,16 @@ public sealed class DiscordWebhookStore
|
||||
AdminUrl = AdminUrl,
|
||||
};
|
||||
|
||||
public async ValueTask SaveAsync()
|
||||
{
|
||||
if (!await _saveLock.WaitAsync(0))
|
||||
{
|
||||
return;
|
||||
}
|
||||
protected override DiscordWebhookConfig GetSnapshot() => Snapshot;
|
||||
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(Snapshot, JsonOpts);
|
||||
await File.WriteAllTextAsync(ConfigFile, json);
|
||||
_logger.LogInformation("DiscordWebhookSaved to {File}", ConfigFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "DiscordWebhookFailed to save {File}", ConfigFile);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_saveLock.Release();
|
||||
}
|
||||
protected override void ApplySnapshot(DiscordWebhookConfig data)
|
||||
{
|
||||
MatchmakerUrl = data.MatchmakerUrl;
|
||||
AdminUrl = data.AdminUrl;
|
||||
}
|
||||
|
||||
public new async ValueTask SaveAsync() => await base.SaveAsync();
|
||||
|
||||
private static void MigrateLegacy(DiscordWebhookConfig cfg)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(cfg.WebhookUrl))
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
using Empostor.Api.Config;
|
||||
using Empostor.Api.Service;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Empostor.Server.Service.Api;
|
||||
|
||||
public sealed class HplpStore : JsonDataStore<HplpConfig>
|
||||
{
|
||||
public HplpStore(ILogger<HplpStore> logger, IOptions<HplpConfig> config)
|
||||
: base(logger, legacyPath: "hplp.json")
|
||||
{
|
||||
JsonOpts.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
||||
|
||||
// Try loading from persisted file
|
||||
Load();
|
||||
|
||||
// If no persisted data, fall back to config.json defaults
|
||||
if (string.IsNullOrEmpty(RegionId) && string.IsNullOrEmpty(RegionName) && string.IsNullOrEmpty(PublicUrl))
|
||||
{
|
||||
var cfg = config.Value;
|
||||
Enabled = cfg.Enabled;
|
||||
RegionId = cfg.RegionId;
|
||||
RegionName = cfg.RegionName;
|
||||
PublicUrl = cfg.PublicUrl;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public string RegionId { get; set; } = "default";
|
||||
|
||||
public string RegionName { get; set; } = "Empostor Server";
|
||||
|
||||
public string PublicUrl { get; set; } = string.Empty;
|
||||
|
||||
public HplpConfig Snapshot => new()
|
||||
{
|
||||
Enabled = Enabled,
|
||||
RegionId = RegionId,
|
||||
RegionName = RegionName,
|
||||
PublicUrl = PublicUrl,
|
||||
};
|
||||
|
||||
protected override HplpConfig GetSnapshot() => Snapshot;
|
||||
|
||||
protected override void ApplySnapshot(HplpConfig data)
|
||||
{
|
||||
Enabled = data.Enabled;
|
||||
RegionId = data.RegionId;
|
||||
RegionName = data.RegionName;
|
||||
PublicUrl = data.PublicUrl;
|
||||
}
|
||||
|
||||
public new async ValueTask SaveAsync() => await base.SaveAsync();
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Empostor.Api.Service;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Empostor.Server.Service.Stat;
|
||||
@@ -28,22 +27,15 @@ public sealed class PlayerLogEntry
|
||||
public string? Detail { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PlayerLogStore : IDisposable
|
||||
public sealed class PlayerLogStore : JsonDataStore<List<PlayerLogEntry>>
|
||||
{
|
||||
private static readonly string DataDir = Path.Combine(Directory.GetCurrentDirectory(), "Data");
|
||||
private static readonly string LogsFile = Path.Combine(DataDir, "player_logs.json");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = true };
|
||||
|
||||
private const int MaxEntries = 10000;
|
||||
private readonly ILogger<PlayerLogStore> _logger;
|
||||
private readonly ConcurrentQueue<PlayerLogEntry> _entries = new();
|
||||
private readonly SemaphoreSlim _saveLock = new(1, 1);
|
||||
private int _count;
|
||||
|
||||
public PlayerLogStore(ILogger<PlayerLogStore> logger)
|
||||
: base(logger, legacyPath: "Data/player_logs.json")
|
||||
{
|
||||
_logger = logger;
|
||||
Load();
|
||||
}
|
||||
|
||||
@@ -68,7 +60,7 @@ public sealed class PlayerLogStore : IDisposable
|
||||
Interlocked.Decrement(ref _count);
|
||||
}
|
||||
|
||||
SaveAsync();
|
||||
SaveFireAndForget();
|
||||
}
|
||||
|
||||
public List<PlayerLogEntry> GetAll() => _entries.ToList();
|
||||
@@ -92,64 +84,22 @@ public sealed class PlayerLogStore : IDisposable
|
||||
return Encoding.UTF8.GetBytes(json);
|
||||
}
|
||||
|
||||
private void SaveAsync()
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
if (!await _saveLock.WaitAsync(0))
|
||||
{
|
||||
return;
|
||||
}
|
||||
protected override List<PlayerLogEntry> GetSnapshot() => _entries.ToList();
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(DataDir);
|
||||
var list = _entries.ToList();
|
||||
await File.WriteAllTextAsync(LogsFile, JsonSerializer.Serialize(list, JsonOpts));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "PlayerLogFailed to save logs");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_saveLock.Release();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void Load()
|
||||
protected override void ApplySnapshot(List<PlayerLogEntry> data)
|
||||
{
|
||||
if (!File.Exists(LogsFile))
|
||||
_entries.Clear();
|
||||
_count = 0;
|
||||
foreach (var entry in data)
|
||||
{
|
||||
return;
|
||||
_entries.Enqueue(entry);
|
||||
Interlocked.Increment(ref _count);
|
||||
}
|
||||
|
||||
try
|
||||
while (_count > MaxEntries)
|
||||
{
|
||||
var list = JsonSerializer.Deserialize<List<PlayerLogEntry>>(File.ReadAllText(LogsFile));
|
||||
if (list != null)
|
||||
{
|
||||
foreach (var entry in list)
|
||||
{
|
||||
_entries.Enqueue(entry);
|
||||
Interlocked.Increment(ref _count);
|
||||
}
|
||||
|
||||
while (_count > MaxEntries)
|
||||
{
|
||||
_entries.TryDequeue(out _);
|
||||
Interlocked.Decrement(ref _count);
|
||||
}
|
||||
|
||||
_logger.LogInformation("PlayerLogLoaded {Count} log(s)", _count);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "PlayerLogFailed to load logs file");
|
||||
_entries.TryDequeue(out _);
|
||||
Interlocked.Decrement(ref _count);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _saveLock.Dispose();
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Empostor.Api.Service;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Empostor.Server.Service.Stat;
|
||||
@@ -37,24 +34,13 @@ public sealed class PlayerStatsEntry
|
||||
public DateTime LastSeen { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class PlayerStatsStore : IDisposable
|
||||
public sealed class PlayerStatsStore : JsonDataStore<List<PlayerStatsEntry>>
|
||||
{
|
||||
private static readonly string DataDir =
|
||||
Path.Combine(Directory.GetCurrentDirectory(), "Data");
|
||||
|
||||
private static readonly string StatsFile =
|
||||
Path.Combine(DataDir, "player_stats.json");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts =
|
||||
new() { WriteIndented = true };
|
||||
|
||||
private readonly ILogger<PlayerStatsStore> _logger;
|
||||
private readonly SemaphoreSlim _saveLock = new(1, 1);
|
||||
private ConcurrentDictionary<string, PlayerStatsEntry> _stats = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public PlayerStatsStore(ILogger<PlayerStatsStore> logger)
|
||||
: base(logger, legacyPath: "Data/player_stats.json")
|
||||
{
|
||||
_logger = logger;
|
||||
Load();
|
||||
}
|
||||
|
||||
@@ -78,28 +64,28 @@ public sealed class PlayerStatsStore : IDisposable
|
||||
{
|
||||
var e = GetOrCreate(friendCode);
|
||||
e.Kills++;
|
||||
SaveAsync();
|
||||
SaveFireAndForget();
|
||||
}
|
||||
|
||||
public void RecordDeath(string friendCode)
|
||||
{
|
||||
var e = GetOrCreate(friendCode);
|
||||
e.Deaths++;
|
||||
SaveAsync();
|
||||
SaveFireAndForget();
|
||||
}
|
||||
|
||||
public void RecordTaskCompleted(string friendCode)
|
||||
{
|
||||
var e = GetOrCreate(friendCode);
|
||||
e.TasksCompleted++;
|
||||
SaveAsync();
|
||||
SaveFireAndForget();
|
||||
}
|
||||
|
||||
public void RecordExile(string friendCode)
|
||||
{
|
||||
var e = GetOrCreate(friendCode);
|
||||
e.TimesExiled++;
|
||||
SaveAsync();
|
||||
SaveFireAndForget();
|
||||
}
|
||||
|
||||
public void RecordGameEnd(string friendCode, string? name, bool isCrewmateWin, bool wasImpostor)
|
||||
@@ -125,7 +111,7 @@ public sealed class PlayerStatsStore : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
SaveAsync();
|
||||
SaveFireAndForget();
|
||||
}
|
||||
|
||||
public List<PlayerStatsEntry> GetAll()
|
||||
@@ -134,52 +120,17 @@ public sealed class PlayerStatsStore : IDisposable
|
||||
public void ClearAll()
|
||||
{
|
||||
_stats.Clear();
|
||||
SaveAsync();
|
||||
SaveFireAndForget();
|
||||
}
|
||||
|
||||
private void SaveAsync()
|
||||
protected override List<PlayerStatsEntry> GetSnapshot() => _stats.Values.ToList();
|
||||
|
||||
protected override void ApplySnapshot(List<PlayerStatsEntry> data)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
if (!await _saveLock.WaitAsync(0))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(DataDir);
|
||||
var list = _stats.Values.ToList();
|
||||
await File.WriteAllTextAsync(StatsFile, JsonSerializer.Serialize(list, JsonOpts));
|
||||
}
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "PlayerStatsFailed to save stats"); }
|
||||
finally { _saveLock.Release(); }
|
||||
});
|
||||
}
|
||||
|
||||
private void Load()
|
||||
{
|
||||
if (!File.Exists(StatsFile))
|
||||
{
|
||||
_logger.LogDebug("PlayerStatsNo stats file found, starting fresh");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var list = JsonSerializer.Deserialize<List<PlayerStatsEntry>>(File.ReadAllText(StatsFile));
|
||||
if (list != null)
|
||||
{
|
||||
_stats = new ConcurrentDictionary<string, PlayerStatsEntry>(
|
||||
list.ToDictionary(e => e.FriendCode, StringComparer.OrdinalIgnoreCase));
|
||||
_logger.LogInformation("PlayerStatsLoaded {Count} player stats", _stats.Count);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "PlayerStatsFailed to load stats file"); }
|
||||
_stats = new ConcurrentDictionary<string, PlayerStatsEntry>(
|
||||
data.ToDictionary(e => e.FriendCode, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string Normalize(string friendCode)
|
||||
=> (friendCode ?? string.Empty).Trim();
|
||||
|
||||
public void Dispose() => _saveLock.Dispose();
|
||||
}
|
||||
@@ -53,6 +53,12 @@
|
||||
"Password": "",
|
||||
"MarketplaceUrl": "https://raw.githubusercontent.com/Empostor/Empostor/master/marketplace/plugins.json"
|
||||
},
|
||||
"HPLP": {
|
||||
"Enabled": false,
|
||||
"RegionId": "default",
|
||||
"RegionName": "Empostor Server",
|
||||
"PublicUrl": ""
|
||||
},
|
||||
"DiscordWebhook": {
|
||||
"MatchmakerUrl": "",
|
||||
"AdminUrl": ""
|
||||
|
||||
@@ -58,6 +58,12 @@
|
||||
"resolved": "8.0.1",
|
||||
"contentHash": "X21b8D6z58EG34OMLH955750mFaEaGs5xLGe9Vyi3059Cgb3sk+p2k/JlQXaieEBRrk+SVlFzDei3Q6PdVzJOA=="
|
||||
},
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[8.0.28, )",
|
||||
"resolved": "8.0.28",
|
||||
"contentHash": "XMqgVjlLxLqWmEh3c49haXLQwsMNtvo6YscUaqfvEGfg1iA8hnYgkUVq3i9Zu9gKeNKMWiiZKVwZExc/qyEAsQ=="
|
||||
},
|
||||
"Serilog.Extensions.Hosting": {
|
||||
"type": "Direct",
|
||||
"requested": "[8.0.0, )",
|
||||
|
||||
Reference in new issue
Block a user