diff --git a/src/Empostor.Api.Innersloth.Generator/packages.lock.json b/src/Empostor.Api.Innersloth.Generator/packages.lock.json index fb68fad..5850ca3 100644 --- a/src/Empostor.Api.Innersloth.Generator/packages.lock.json +++ b/src/Empostor.Api.Innersloth.Generator/packages.lock.json @@ -150,6 +150,7 @@ "System.Runtime.CompilerServices.Unsafe": "4.5.3" } } - } + }, + ".NETStandard,Version=v2.0/win-x64": {} } } \ No newline at end of file diff --git a/src/Empostor.Api/Config/HplpConfig.cs b/src/Empostor.Api/Config/HplpConfig.cs new file mode 100644 index 0000000..5d6a098 --- /dev/null +++ b/src/Empostor.Api/Config/HplpConfig.cs @@ -0,0 +1,34 @@ +namespace Empostor.Api.Config +{ + /// + /// Configuration for the HPLP (HTTP Public Lobby List Protocol) endpoint, + /// which allows Starlight clients to discover games hosted on this server. + /// + public class HplpConfig + { + public const string Section = "HPLP"; + + /// + /// Whether the HPLP endpoint (GET /x-api/games) is enabled. + /// + public bool Enabled { get; set; } = false; + + /// + /// Region identifier reported to Starlight clients (HPLP region_id). + /// + public string RegionId { get; set; } = "default"; + + /// + /// Human-readable region name displayed in the Starlight lobby UI. + /// + public string RegionName { get; set; } = "Empostor Server"; + + /// + /// Public URL used by Starlight clients to connect to this server. + /// When empty, the URL is auto-constructed as http://{PublicIp}:{HttpPort}. + /// Set this to a custom value (e.g. https://your-domain.com) when + /// running behind a reverse proxy with TLS. + /// + public string PublicUrl { get; set; } = string.Empty; + } +} diff --git a/src/Empostor.Api/Service/JsonDataStore.cs b/src/Empostor.Api/Service/JsonDataStore.cs new file mode 100644 index 0000000..b6f1ba2 --- /dev/null +++ b/src/Empostor.Api/Service/JsonDataStore.cs @@ -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; + +/// +/// Abstract base class for JSON file-based data persistence. +/// Stores data under ./Data/{ClassName}Data.json. +/// Subclasses only need to implement and . +/// +/// The serializable data type. +public abstract class JsonDataStore : IDisposable +{ + /// + /// JSON serialization options used for save/load. + /// Override in subclass constructor to customize (e.g. add ). + /// + protected JsonSerializerOptions JsonOpts { get; } = new() { WriteIndented = true }; + + /// + /// Full path to the data file. + /// Derived automatically from the subclass name: ./Data/{ClassName}Data.json + /// + protected string FilePath { get; } + + /// + /// Semaphore used to prevent concurrent writes. + /// + protected SemaphoreSlim SaveLock { get; } = new(1, 1); + + /// + /// Logger instance for this store. + /// + protected ILogger Logger { get; } + + /// + /// Creates a new instance. + /// + /// Logger for this store. + /// + /// 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). + /// + 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); + } + } + } + + /// + /// Build a serializable snapshot from the current in-memory state. + /// + protected abstract TData GetSnapshot(); + + /// + /// Restore in-memory state from a previously-saved snapshot. + /// + protected abstract void ApplySnapshot(TData data); + + /// + /// 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. + /// + 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(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); + } + } + + /// + /// 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. + /// + 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(); + } + } + + /// + /// Fire-and-forget save. Schedules a background save and returns immediately. + /// Useful for high-frequency writes where waiting is not needed. + /// + 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(); + } + } + } + + /// + public void Dispose() + { + SaveLock.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/Empostor.Api/Service/PlayerConnectStore.cs b/src/Empostor.Api/Service/PlayerConnectStore.cs index 88f7c00..2f56913 100644 --- a/src/Empostor.Api/Service/PlayerConnectStore.cs +++ b/src/Empostor.Api/Service/PlayerConnectStore.cs @@ -14,14 +14,37 @@ public sealed class PlayerConnectStore : IDisposable private readonly ConcurrentDictionary _lastConnect; private readonly Timer _saveTimer; private readonly ILogger _logger; + private readonly string _filePath; private bool _dirty; public PlayerConnectStore(ILogger 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 Load() + private ConcurrentDictionary 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(); } - var json = File.ReadAllText(path); + var json = File.ReadAllText(_filePath); return JsonSerializer.Deserialize>(json) ?? new ConcurrentDictionary(); } @@ -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); } } diff --git a/src/Empostor.Api/packages.lock.json b/src/Empostor.Api/packages.lock.json index 9d991b4..049e555 100644 --- a/src/Empostor.Api/packages.lock.json +++ b/src/Empostor.Api/packages.lock.json @@ -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": {} } } \ No newline at end of file diff --git a/src/Empostor.Server/Http/AdminController.cs b/src/Empostor.Server/Http/AdminController.cs index 60a92c0..82e5d4a 100644 --- a/src/Empostor.Server/Http/AdminController.cs +++ b/src/Empostor.Server/Http/AdminController.cs @@ -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; + private readonly IOptions _httpServerConfig; private readonly IpGeolocationService _ipGeo; private readonly string _passwordHash; private static readonly ConcurrentDictionary _loginFailures = new(); @@ -65,6 +68,9 @@ namespace Empostor.Server.Http IOptions statsConfig, ChatFilterStore chatFilter, DiscordWebhookStore discordWebhook, + HplpStore hplpStore, + IOptions serverConfig, + IOptions 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 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 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; diff --git a/src/Empostor.Server/Http/AdminTemplateDefaults.cs b/src/Empostor.Server/Http/AdminTemplateDefaults.cs index d69904f..b710ab1 100644 --- a/src/Empostor.Server/Http/AdminTemplateDefaults.cs +++ b/src/Empostor.Server/Http/AdminTemplateDefaults.cs @@ -694,6 +694,7 @@ internal static class AdminTemplateDefaults Statistics Chat Filter Discord + HPLP Actions Broadcast @@ -1059,6 +1060,32 @@ internal static class AdminTemplateDefaults + + + HPLP (Public Lobby List) + Save Settings + + + + Enable HPLP endpoint (GET /x-api/games) + + + + Region ID + + + + Region Name + + + + Public URL (optional) + + Used by Starlight clients to connect. Auto-generated as http://ip:port if empty. + + + + @@ -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() { diff --git a/src/Empostor.Server/Http/HplpController.cs b/src/Empostor.Server/Http/HplpController.cs new file mode 100644 index 0000000..33261f5 --- /dev/null +++ b/src/Empostor.Server/Http/HplpController.cs @@ -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; + +/// +/// Implements the HPLP (HTTP Public Lobby List Protocol) for Starlight client discovery. +/// Serves GET /x-api/games with active games and region metadata. +/// +[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, + IOptions 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(); + } + + return Array.Empty(); + } +} diff --git a/src/Empostor.Server/Net/Client.cs b/src/Empostor.Server/Net/Client.cs index 09963e0..2530638 100644 --- a/src/Empostor.Server/Net/Client.cs +++ b/src/Empostor.Server/Net/Client.cs @@ -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, diff --git a/src/Empostor.Server/Pages/AdminStrings.json b/src/Empostor.Server/Pages/AdminStrings.json index 0d735be..722e413 100644 --- a/src/Empostor.Server/Pages/AdminStrings.json +++ b/src/Empostor.Server/Pages/AdminStrings.json @@ -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." } diff --git a/src/Empostor.Server/Program.cs b/src/Empostor.Server/Program.cs index f52c64d..f3be7c9 100644 --- a/src/Empostor.Server/Program.cs +++ b/src/Empostor.Server/Program.cs @@ -115,6 +115,7 @@ namespace Empostor.Server services.Configure(host.Configuration.GetSection(HttpServerConfig.Section)); services.Configure(host.Configuration.GetSection(AdminConfig.Section)); services.Configure(host.Configuration.GetSection(DiscordWebhookConfig.Section)); + services.Configure(host.Configuration.GetSection(HplpConfig.Section)); services.Configure(host.Configuration.GetSection(PlayerStatsConfig.Section)); services.Configure(host.Configuration.GetSection(ChatFilterConfig.Section)); services.Configure(host.Configuration.GetSection(AuthApiConfig.Section)); @@ -205,6 +206,7 @@ namespace Empostor.Server services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Empostor.Server/Service/Admin/Ban/BanStore.cs b/src/Empostor.Server/Service/Admin/Ban/BanStore.cs index 7ff3bcb..851a91a 100644 --- a/src/Empostor.Server/Service/Admin/Ban/BanStore.cs +++ b/src/Empostor.Server/Service/Admin/Ban/BanStore.cs @@ -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 { - private static readonly string BanFile = - Path.Combine(Directory.GetCurrentDirectory(), "bans.json"); - - private static readonly JsonSerializerOptions JsonOpts = - new() { WriteIndented = true }; - - private readonly ILogger _logger; - private readonly SemaphoreSlim _saveLock = new(1, 1); - private ConcurrentDictionary _ips = new(); private ConcurrentDictionary _friendCodes = new(); public BanStore(ILogger 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(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? Ips { get; set; } diff --git a/src/Empostor.Server/Service/Admin/Chat/ChatFilterStore.cs b/src/Empostor.Server/Service/Admin/Chat/ChatFilterStore.cs index 4dbbea0..00f19c7 100644 --- a/src/Empostor.Server/Service/Admin/Chat/ChatFilterStore.cs +++ b/src/Empostor.Server/Service/Admin/Chat/ChatFilterStore.cs @@ -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 { private readonly object _wordsLock = new(); - private List _blockedWords; + private List _blockedWords = new(); - public ChatFilterStore(IOptions config) + public ChatFilterStore(ILogger logger, IOptions config) + : base(logger, legacyPath: null) { - var cfg = config.Value; - Enabled = cfg.Enabled; - BlockMessage = cfg.BlockMessage; - SpamThreshold = cfg.SpamThreshold; - SpamWindowSeconds = cfg.SpamWindowSeconds; - _blockedWords = new List(cfg.BlockedWords ?? new List()); + 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(cfg.BlockedWords ?? new List()); + } } 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(_blockedWords), + }; + } + } + + protected override void ApplySnapshot(ChatFilterConfig data) + { + Enabled = data.Enabled; + BlockMessage = data.BlockMessage; + SpamThreshold = data.SpamThreshold; + SpamWindowSeconds = data.SpamWindowSeconds; + lock (_wordsLock) + { + _blockedWords = new List(data.BlockedWords ?? new List()); + } } } diff --git a/src/Empostor.Server/Service/Admin/Report/ReportStore.cs b/src/Empostor.Server/Service/Admin/Report/ReportStore.cs index 2ded824..3b4be26 100644 --- a/src/Empostor.Server/Service/Admin/Report/ReportStore.cs +++ b/src/Empostor.Server/Service/Admin/Report/ReportStore.cs @@ -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> { - 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 _logger; - private readonly List _reports = new(); private readonly object _lock = new(); - private readonly SemaphoreSlim _saveLock = new(1, 1); + private readonly List _reports = new(); private const int Max = 500; public ReportStore(ILogger logger) + : base(logger, legacyPath: "Data/reports.json") { - _logger = logger; Load(); } @@ -39,7 +29,7 @@ namespace Empostor.Server.Service.Admin.Report } } - SaveAsync(); + SaveFireAndForget(); } public List GetRecent(int count = 100) @@ -50,82 +40,40 @@ namespace Empostor.Server.Service.Admin.Report } } - private void SaveAsync() + protected override List GetSnapshot() { - _ = Task.Run(async () => + lock (_lock) { - if (!await _saveLock.WaitAsync(0)) - { - return; - } - - try - { - Directory.CreateDirectory(DataDir); - List 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>(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 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; } + } } } diff --git a/src/Empostor.Server/Service/Api/DiscordWebhookStore.cs b/src/Empostor.Server/Service/Api/DiscordWebhookStore.cs index 745a742..4b0b6ee 100644 --- a/src/Empostor.Server/Service/Api/DiscordWebhookStore.cs +++ b/src/Empostor.Server/Service/Api/DiscordWebhookStore.cs @@ -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 { - 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 _logger; - private readonly SemaphoreSlim _saveLock = new(1, 1); - public DiscordWebhookStore(ILogger logger, IOptions 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(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)) diff --git a/src/Empostor.Server/Service/Api/HplpStore.cs b/src/Empostor.Server/Service/Api/HplpStore.cs new file mode 100644 index 0000000..b15bd66 --- /dev/null +++ b/src/Empostor.Server/Service/Api/HplpStore.cs @@ -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 +{ + public HplpStore(ILogger logger, IOptions 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(); +} diff --git a/src/Empostor.Server/Service/Stat/PlayerLogStore.cs b/src/Empostor.Server/Service/Stat/PlayerLogStore.cs index 6f63792..105cd67 100644 --- a/src/Empostor.Server/Service/Stat/PlayerLogStore.cs +++ b/src/Empostor.Server/Service/Stat/PlayerLogStore.cs @@ -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> { - 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 _logger; private readonly ConcurrentQueue _entries = new(); - private readonly SemaphoreSlim _saveLock = new(1, 1); private int _count; public PlayerLogStore(ILogger 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 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 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 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>(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(); } diff --git a/src/Empostor.Server/Service/Stat/PlayerStatsStore.cs b/src/Empostor.Server/Service/Stat/PlayerStatsStore.cs index 6a68934..872adb2 100644 --- a/src/Empostor.Server/Service/Stat/PlayerStatsStore.cs +++ b/src/Empostor.Server/Service/Stat/PlayerStatsStore.cs @@ -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> { - 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 _logger; - private readonly SemaphoreSlim _saveLock = new(1, 1); private ConcurrentDictionary _stats = new(StringComparer.OrdinalIgnoreCase); public PlayerStatsStore(ILogger 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 GetAll() @@ -134,52 +120,17 @@ public sealed class PlayerStatsStore : IDisposable public void ClearAll() { _stats.Clear(); - SaveAsync(); + SaveFireAndForget(); } - private void SaveAsync() + protected override List GetSnapshot() => _stats.Values.ToList(); + + protected override void ApplySnapshot(List 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>(File.ReadAllText(StatsFile)); - if (list != null) - { - _stats = new ConcurrentDictionary( - 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( + data.ToDictionary(e => e.FriendCode, StringComparer.OrdinalIgnoreCase)); } private static string Normalize(string friendCode) => (friendCode ?? string.Empty).Trim(); - - public void Dispose() => _saveLock.Dispose(); } diff --git a/src/Empostor.Server/config.json b/src/Empostor.Server/config.json index 506a5fe..130a954 100644 --- a/src/Empostor.Server/config.json +++ b/src/Empostor.Server/config.json @@ -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": "" diff --git a/src/Empostor.Server/packages.lock.json b/src/Empostor.Server/packages.lock.json index 9d36848..4588685 100644 --- a/src/Empostor.Server/packages.lock.json +++ b/src/Empostor.Server/packages.lock.json @@ -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, )",