diff --git a/marketplace/Empostor.Plugin.Chat1.1.0.dll b/marketplace/Empostor.Plugin.Chat1.1.0.dll deleted file mode 100644 index 331e083..0000000 Binary files a/marketplace/Empostor.Plugin.Chat1.1.0.dll and /dev/null differ diff --git a/marketplace/Empostor.Plugin.Code.dll b/marketplace/Empostor.Plugin.Code.dll index 63eff0b..92c9e22 100644 Binary files a/marketplace/Empostor.Plugin.Code.dll and b/marketplace/Empostor.Plugin.Code.dll differ diff --git a/marketplace/Empostor.Plugin.Narrator.dll b/marketplace/Empostor.Plugin.Narrator.dll index b5f88ad..57a0a3b 100644 Binary files a/marketplace/Empostor.Plugin.Narrator.dll and b/marketplace/Empostor.Plugin.Narrator.dll differ diff --git a/marketplace/Empostor.Plugins.FriendCodeValidator.dll b/marketplace/Empostor.Plugins.FriendCodeValidator.dll index 83d79d9..91bdef4 100644 Binary files a/marketplace/Empostor.Plugins.FriendCodeValidator.dll and b/marketplace/Empostor.Plugins.FriendCodeValidator.dll differ diff --git a/marketplace/Empostor.Plugins.PlayerChannel.dll b/marketplace/Empostor.Plugins.PlayerChannel.dll index deabdd2..7c48c77 100644 Binary files a/marketplace/Empostor.Plugins.PlayerChannel.dll and b/marketplace/Empostor.Plugins.PlayerChannel.dll differ diff --git a/marketplace/Empostor.Plugins.QqVerify.dll b/marketplace/Empostor.Plugins.QqVerify.dll index bd96154..313c48e 100644 Binary files a/marketplace/Empostor.Plugins.QqVerify.dll and b/marketplace/Empostor.Plugins.QqVerify.dll differ diff --git a/marketplace/Empostor.Plugins.Titles.dll b/marketplace/Empostor.Plugins.Titles.dll index 157c662..af5c759 100644 Binary files a/marketplace/Empostor.Plugins.Titles.dll and b/marketplace/Empostor.Plugins.Titles.dll differ diff --git a/marketplace/Empostor.Plugins.Titles1.1.0.dll b/marketplace/Empostor.Plugins.Titles1.1.0.dll deleted file mode 100644 index cc3e300..0000000 Binary files a/marketplace/Empostor.Plugins.Titles1.1.0.dll and /dev/null differ diff --git a/marketplace/Empostor.Plugins.Welcome1.1.0.dll b/marketplace/Empostor.Plugins.Welcome1.1.0.dll deleted file mode 100644 index 62c5d20..0000000 Binary files a/marketplace/Empostor.Plugins.Welcome1.1.0.dll and /dev/null differ diff --git a/marketplace/Empostor.Plugins.Welcome1.2.0.dll b/marketplace/Empostor.Plugins.Welcome1.2.0.dll deleted file mode 100644 index 3051521..0000000 Binary files a/marketplace/Empostor.Plugins.Welcome1.2.0.dll and /dev/null differ diff --git a/marketplace/plugins.json b/marketplace/plugins.json index 62774bc..4cad331 100644 --- a/marketplace/plugins.json +++ b/marketplace/plugins.json @@ -9,11 +9,6 @@ "version": "1.0.0", "empostor_version": "2.0.0", "download_url": "https://raw.githubusercontent.com/Empostor/Empostor/master/marketplace/Empostor.Plugin.Chat.dll" - }, - { - "version": "1.1.0", - "empostor_version": "2.0.0", - "download_url": "https://raw.githubusercontent.com/Empostor/Empostor/master/marketplace/Empostor.Plugin.Chat1.1.0.dll" } ] }, @@ -40,16 +35,6 @@ "version": "1.0.0", "empostor_version": "2.0.0", "download_url": "https://raw.githubusercontent.com/Empostor/Empostor/master/marketplace/Empostor.Plugins.Welcome.dll" - }, - { - "version": "1.1.0", - "empostor_version": "2.0.0", - "download_url": "https://raw.githubusercontent.com/Empostor/Empostor/master/marketplace/Empostor.Plugins.Welcome1.1.0.dll" - }, - { - "version": "1.2.0", - "empostor_version": "2.0.0", - "download_url": "https://raw.githubusercontent.com/Empostor/Empostor/master/marketplace/Empostor.Plugins.Welcome1.2.0.dll" } ] }, @@ -89,11 +74,6 @@ "version": "1.0.0", "empostor_version": "2.0.0", "download_url": "https://raw.githubusercontent.com/Empostor/Empostor/master/marketplace/Empostor.Plugins.Titles.dll" - }, - { - "version": "1.1.0", - "empostor_version": "2.0.0", - "download_url": "https://raw.githubusercontent.com/Empostor/Empostor/master/marketplace/Empostor.Plugins.Titles1.1.0.dll" } ] }, diff --git a/src/Empostor.Api/Admin/AdminActions.cs b/src/Empostor.Api/Admin/AdminActions.cs new file mode 100644 index 0000000..2e5a2ba --- /dev/null +++ b/src/Empostor.Api/Admin/AdminActions.cs @@ -0,0 +1,55 @@ +using System; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Empostor.Api.Admin; + +public delegate ValueTask AdminAction(AdminActionContext context); + +/// +/// The result of an admin action. Tells the renderer what to do next. +/// +/// Whether the action completed successfully. +/// An optional toast message shown to the operator. +/// When true, the client re-fetches the panel to reflect new state. +public sealed record AdminActionResult(bool Success, string? Message = null, bool Refresh = true) +{ + public static AdminActionResult Ok(string? message = null, bool refresh = true) => new(true, message, refresh); + + public static AdminActionResult Fail(string message) => new(false, message, true); +} + +public sealed class AdminActionContext +{ + public AdminActionContext( + string extensionId, + string actionId, + string? value, + JsonElement? payload, + IServiceProvider services) + { + ExtensionId = extensionId; + ActionId = actionId; + Value = value; + Payload = payload; + Services = services; + } + + /// The extension that owns the invoked action. + public string ExtensionId { get; } + + /// The action id that was invoked. + public string ActionId { get; } + + /// + /// The new value submitted by the widget (textbox text, toggle on/off, select value, + /// number value, chip value). null for plain button clicks. + /// + public string? Value { get; } + + /// The full JSON body of the request, if the widget sent structured data. + public JsonElement? Payload { get; } + + /// The DI container, for resolving services the handler may need. + public IServiceProvider Services { get; } +} diff --git a/src/Empostor.Api/Admin/AdminPanelBuilder.cs b/src/Empostor.Api/Admin/AdminPanelBuilder.cs new file mode 100644 index 0000000..557b500 --- /dev/null +++ b/src/Empostor.Api/Admin/AdminPanelBuilder.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; + +namespace Empostor.Api.Admin; + +/// +/// Fluent builder used by an to declare its admin-panel content. +/// Register* methods add declarative widgets; widgets that carry a callback get a stable, +/// auto-assigned action id that the client sends back on interaction. +/// +public sealed class AdminPanelBuilder +{ + private readonly Dictionary _actions = new(StringComparer.Ordinal); + private int _actionCounter; + + public AdminPanelBuilder(string extensionId, string title, string icon, string? section) + { + ExtensionId = extensionId; + Title = title; + Icon = icon; + Section = string.IsNullOrWhiteSpace(section) ? "Plugins" : section; + } + + public string ExtensionId { get; } + + public string Title { get; } + + public string Icon { get; } + + public string Section { get; } + + public List Widgets { get; } = new(); + + public IReadOnlyDictionary Actions => _actions; + + public AdminPanelBuilder RegisterButton(Action configure) => Register(configure); + + public AdminPanelBuilder RegisterText(string content, string tone = "default", bool monospace = false) + { + Widgets.Add(new AdminText { Content = content, Tone = tone, Monospace = monospace }); + return this; + } + + public AdminPanelBuilder RegisterText(Action configure) => Register(configure); + + public AdminPanelBuilder RegisterBlock(Action configure) => Register(configure); + + public AdminPanelBuilder RegisterTextbox(Action configure) => Register(configure); + + public AdminPanelBuilder RegisterToggle(Action configure) => Register(configure); + + public AdminPanelBuilder RegisterSelect(Action configure) => Register(configure); + + public AdminPanelBuilder RegisterNumber(Action configure) => Register(configure); + + public AdminPanelBuilder RegisterTable(Action configure) => Register(configure); + + public AdminPanelBuilder RegisterChips(Action configure) => Register(configure); + + public AdminPanelBuilder RegisterDivider() + { + Widgets.Add(new AdminDivider()); + return this; + } + + private AdminPanelBuilder Register(Action configure) + where T : AdminWidget, new() + { + var widget = new T(); + configure(widget); + Bind(widget); + Widgets.Add(widget); + return this; + } + + private void Bind(AdminWidget widget) + { + if (widget.Handler != null) + { + widget.Action = NextAction(); + _actions[widget.Action] = widget.Handler; + } + + if (widget is AdminChips chips) + { + if (chips.AddHandler != null) + { + chips.AddAction = NextAction(); + _actions[chips.AddAction] = chips.AddHandler; + } + + if (chips.RemoveHandler != null) + { + chips.RemoveAction = NextAction(); + _actions[chips.RemoveAction] = chips.RemoveHandler; + } + } + + if (widget is AdminBlock block) + { + foreach (var child in block.Children) + { + Bind(child); + } + } + } + + private string NextAction() => $"{ExtensionId}.a{_actionCounter++}"; +} diff --git a/src/Empostor.Api/Admin/AdminTheme.cs b/src/Empostor.Api/Admin/AdminTheme.cs new file mode 100644 index 0000000..b4f931d --- /dev/null +++ b/src/Empostor.Api/Admin/AdminTheme.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; + +namespace Empostor.Api.Admin; + +/// +/// A theme that reskins the admin panel, in the spirit of VuePress's "theme as plugin". +/// A theme can also be shipped as a folder under Pages/themes/{Id}/theme.json. +/// +public interface IAdminTheme +{ + /// Unique id. Also the folder name for disk-based themes. + string Id { get; } + + /// Human-readable name. + string Name { get; } + + /// Id of a parent theme whose tokens/css/layout this theme inherits. + string? Extends => null; + + /// Build the theme definition (tokens, palette, css, layout). + AdminThemeDefinition Define(); +} + +/// +/// Declarative description of a theme's styling layers. +/// +public sealed class AdminThemeDefinition +{ + /// + /// Design tokens for the default (light) appearance, keyed by CSS custom-property name + /// (e.g. --bg, --a). Use the names in . + /// + public Dictionary Tokens { get; set; } = new(); + + /// + /// Dark-appearance overrides applied under [data-theme="dark"]. When omitted, + /// the framework keeps the light tokens for both modes. + /// + public Dictionary? DarkTokens { get; set; } + + /// Raw CSS appended after the tokens (palette/component styles, animations). + public string? CustomCss { get; set; } + + /// + /// Optional layout template. Slots are {{slot:header}}, {{slot:nav}}, + /// {{slot:content}} and {{slot:footer}}. When omitted the built-in layout is used. + /// + public string? Layout { get; set; } + + /// Named component aliases (component name → HTML fragment) usable in the layout. + public Dictionary Components { get; set; } = new(); +} + +/// +/// The standard set of design-token names the admin panel consumes. Themes should fill +/// these keys so their palette applies to every built-in control. +/// +public static class AdminThemeTokens +{ + public const string Background = "--bg"; + public const string Surface = "--s"; + public const string Border = "--b"; + public const string Text = "--t"; + public const string Muted = "--m"; + public const string Accent = "--a"; + public const string Success = "--g"; + public const string Warning = "--y"; + public const string Danger = "--r"; + public const string Purple = "--p"; + public const string Orange = "--o"; + public const string Field = "--field"; + public const string Hover = "--hover"; + public const string OnAccent = "--on-a"; +} diff --git a/src/Empostor.Api/Admin/AdminWidgets.cs b/src/Empostor.Api/Admin/AdminWidgets.cs new file mode 100644 index 0000000..b1106cf --- /dev/null +++ b/src/Empostor.Api/Admin/AdminWidgets.cs @@ -0,0 +1,177 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Empostor.Api.Admin; + +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(AdminButton), "button")] +[JsonDerivedType(typeof(AdminText), "text")] +[JsonDerivedType(typeof(AdminBlock), "block")] +[JsonDerivedType(typeof(AdminTextbox), "textbox")] +[JsonDerivedType(typeof(AdminToggle), "toggle")] +[JsonDerivedType(typeof(AdminSelect), "select")] +[JsonDerivedType(typeof(AdminNumber), "number")] +[JsonDerivedType(typeof(AdminTable), "table")] +[JsonDerivedType(typeof(AdminChips), "chips")] +[JsonDerivedType(typeof(AdminDivider), "divider")] +public abstract class AdminWidget +{ + public string? Action { get; set; } + + [JsonIgnore] + public AdminAction? Handler { get; set; } +} + +public sealed class AdminButton : AdminWidget +{ + public string Label { get; set; } = string.Empty; + + public string? Icon { get; set; } + + public string Style { get; set; } = "primary"; + + public AdminButton OnClick(AdminAction handler) + { + Handler = handler; + return this; + } +} + +public sealed class AdminText : AdminWidget +{ + public string Content { get; set; } = string.Empty; + + public string Tone { get; set; } = "default"; + + public bool Monospace { get; set; } +} + +public sealed class AdminBlock : AdminWidget +{ + public string? Title { get; set; } + + public string? Description { get; set; } + + public List Children { get; set; } = new(); +} + +public sealed class AdminTextbox : AdminWidget +{ + public string Label { get; set; } = string.Empty; + + public string? Value { get; set; } + + public string? Placeholder { get; set; } + + public bool Secret { get; set; } + + public bool Multiline { get; set; } + + public bool SubmitOnChange { get; set; } + + public AdminTextbox OnSubmit(AdminAction handler) + { + Handler = handler; + return this; + } +} + +public sealed class AdminToggle : AdminWidget +{ + public string Label { get; set; } = string.Empty; + + public bool Value { get; set; } + + public AdminToggle OnChange(AdminAction handler) + { + Handler = handler; + return this; + } +} + +public sealed class AdminSelect : AdminWidget +{ + public string Label { get; set; } = string.Empty; + + public List Options { get; set; } = new(); + + public string? Value { get; set; } + + public AdminSelect OnChange(AdminAction handler) + { + Handler = handler; + return this; + } +} + +public sealed class AdminNumber : AdminWidget +{ + public string Label { get; set; } = string.Empty; + + public int Value { get; set; } + + public int? Min { get; set; } + + public int? Max { get; set; } + + public AdminNumber OnChange(AdminAction handler) + { + Handler = handler; + return this; + } +} + +public sealed class AdminTable : AdminWidget +{ + public List Columns { get; set; } = new(); + + public List> Rows { get; set; } = new(); +} + +public sealed class AdminChips : AdminWidget +{ + public List Items { get; set; } = new(); + + public string? Placeholder { get; set; } + + public string? AddLabel { get; set; } + + public string? AddAction { get; set; } + + public string? RemoveAction { get; set; } + + [JsonIgnore] + public AdminAction? AddHandler { get; set; } + + [JsonIgnore] + public AdminAction? RemoveHandler { get; set; } + + public AdminChips OnAdd(AdminAction handler) + { + AddHandler = handler; + return this; + } + + public AdminChips OnRemove(AdminAction handler) + { + RemoveHandler = handler; + return this; + } +} + +public sealed class AdminDivider : AdminWidget +{ +} + +public sealed record AdminOption(string Label, string Value); + +public sealed class AdminTableCell +{ + public string Text { get; set; } = string.Empty; + + public string? Tone { get; set; } + + public bool Monospace { get; set; } +} + +public sealed record AdminChip(string Value, string Label); diff --git a/src/Empostor.Api/Admin/IAdminExtension.cs b/src/Empostor.Api/Admin/IAdminExtension.cs new file mode 100644 index 0000000..77b73ae --- /dev/null +++ b/src/Empostor.Api/Admin/IAdminExtension.cs @@ -0,0 +1,22 @@ +namespace Empostor.Api.Admin; + +/// +/// Implemented by a plugin to contribute a panel to the admin interface +/// +public interface IAdminExtension +{ + /// Unique, stable id. Also the last path segment of /api/admin/ext/{Id}. + string Id { get; } + + /// Display title shown in the sidebar and panel header. + string Title { get; } + + /// Icon name (from the built-in icon set), rendered as an inline glyph. + string Icon => "puzzle"; + + /// Sidebar grouping label. Defaults to "Plugins". + string Section => "Plugins"; + + /// Declare the panel's widgets and callbacks. + void Build(AdminPanelBuilder builder); +} diff --git a/src/Empostor.Api/Config/AdminConfig.cs b/src/Empostor.Api/Config/AdminConfig.cs index 9102179..8d65c0f 100644 --- a/src/Empostor.Api/Config/AdminConfig.cs +++ b/src/Empostor.Api/Config/AdminConfig.cs @@ -8,5 +8,11 @@ namespace Empostor.Api.Config public string MarketplaceUrl { get; set; } = "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/plugins.json"; + + /// Active admin theme id. Themes live under Pages/themes/{Id} or as plugins. + public string Theme { get; set; } = "default"; + + /// Initial color mode ("light" or "dark"). Operators can still toggle it in the panel. + public string ThemeMode { get; set; } = "dark"; } } diff --git a/src/Empostor.Api/Config/ChatFilterConfig.cs b/src/Empostor.Api/Config/ChatFilterConfig.cs deleted file mode 100644 index 7b48bdc..0000000 --- a/src/Empostor.Api/Config/ChatFilterConfig.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; - -namespace Empostor.Api.Config -{ - public class ChatFilterConfig - { - public const string Section = "ChatFilter"; - - public bool Enabled { get; set; } = false; - - public List BlockedWords { get; set; } = new(); - - public bool BlockMessage { get; set; } = true; - - public int SpamThreshold { get; set; } = 5; - - public int SpamWindowSeconds { get; set; } = 10; - } -} diff --git a/src/Empostor.Api/Config/DiscordWebhookConfig.cs b/src/Empostor.Api/Config/DiscordWebhookConfig.cs deleted file mode 100644 index d3aa972..0000000 --- a/src/Empostor.Api/Config/DiscordWebhookConfig.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Empostor.Api.Config -{ - public class DiscordWebhookConfig - { - public const string Section = "DiscordWebhook"; - - public string MatchmakerUrl { get; set; } = string.Empty; - - public string AdminUrl { get; set; } = string.Empty; - public bool? Enabled { get; set; } - public string? WebhookUrl { get; set; } - public bool? NotifyOnGameCreated { get; set; } - public bool? NotifyOnBan { get; set; } - public bool? NotifyOnReport { get; set; } - public bool? NotifyOnPlayerJoin { get; set; } - public bool? NotifyOnGameEnded { get; set; } - } -} diff --git a/src/Empostor.Api/Config/PlayerStatsConfig.cs b/src/Empostor.Api/Config/PlayerStatsConfig.cs deleted file mode 100644 index f8464f6..0000000 --- a/src/Empostor.Api/Config/PlayerStatsConfig.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Empostor.Api.Config -{ - public class PlayerStatsConfig - { - public const string Section = "PlayerStats"; - - public bool Enabled { get; set; } = false; - - public bool PersistToFile { get; set; } = true; - } -} diff --git a/src/Empostor.Plugin.Code/CodeAdminExtension.cs b/src/Empostor.Plugin.Code/CodeAdminExtension.cs new file mode 100644 index 0000000..10e9cea --- /dev/null +++ b/src/Empostor.Plugin.Code/CodeAdminExtension.cs @@ -0,0 +1,30 @@ +using Empostor.Api.Admin; + +namespace Empostor.Plugin.Code; + +public sealed class CodeAdminExtension : IAdminExtension +{ + private readonly IGameCodeManager _manager; + + public CodeAdminExtension(IGameCodeManager manager) + { + _manager = manager; + } + + public string Id => "game-codes"; + + public string Title => "Game Codes"; + + public string Icon => "database"; + + public void Build(AdminPanelBuilder b) + { + b.RegisterText( + $"Loaded {_manager.SixCharCodes} six-character and {_manager.FourCharCodes} four-character code(s).", + "default"); + b.RegisterText($"Source directory: {_manager.Path}", "muted", monospace: true); + b.RegisterText( + "Codes are read from .txt files at startup. Add or edit files in the directory, then restart the server to reload.", + "muted"); + } +} diff --git a/src/Empostor.Plugin.Code/CodeStartup.cs b/src/Empostor.Plugin.Code/CodeStartup.cs index 64f83d4..85d8dd1 100644 --- a/src/Empostor.Plugin.Code/CodeStartup.cs +++ b/src/Empostor.Plugin.Code/CodeStartup.cs @@ -1,3 +1,4 @@ +using Empostor.Api.Admin; using Empostor.Api.Events; using Empostor.Api.Plugins; using Empostor.Plugin.Code.Handlers; @@ -14,5 +15,6 @@ public sealed class CodeStartup : IPluginStartup { services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); } } diff --git a/src/Empostor.Plugin.Code/Empostor.Plugin.Code.csproj b/src/Empostor.Plugin.Code/Empostor.Plugin.Code.csproj index 073fd93..8befc79 100644 --- a/src/Empostor.Plugin.Code/Empostor.Plugin.Code.csproj +++ b/src/Empostor.Plugin.Code/Empostor.Plugin.Code.csproj @@ -7,4 +7,7 @@ + + + diff --git a/src/Empostor.Plugin.Narrator/Empostor.Plugin.Narrator.csproj b/src/Empostor.Plugin.Narrator/Empostor.Plugin.Narrator.csproj index 717fab0..ac43447 100644 --- a/src/Empostor.Plugin.Narrator/Empostor.Plugin.Narrator.csproj +++ b/src/Empostor.Plugin.Narrator/Empostor.Plugin.Narrator.csproj @@ -7,4 +7,7 @@ + + + diff --git a/src/Empostor.Plugin.Narrator/NarratorAdminExtension.cs b/src/Empostor.Plugin.Narrator/NarratorAdminExtension.cs new file mode 100644 index 0000000..b58ee03 --- /dev/null +++ b/src/Empostor.Plugin.Narrator/NarratorAdminExtension.cs @@ -0,0 +1,97 @@ +using Empostor.Api.Admin; +using Empostor.Api.Plugins; + +namespace Empostor.Plugin.Narrator; + +public sealed class NarratorAdminExtension : IAdminExtension +{ + private readonly NarratorConfig _config; + private readonly string _configPath; + + public NarratorAdminExtension(NarratorConfig config, string configPath) + { + _config = config; + _configPath = configPath; + } + + public string Id => "narrator"; + + public string Title => "Narrator"; + + public string Icon => "chat"; + + public void Build(AdminPanelBuilder b) + { + b.RegisterTextbox(t => + { + t.Label = "DeepSeek API key"; + t.Value = _config.ApiKey; + t.Secret = true; + t.Placeholder = "sk-..."; + t.OnSubmit(async ctx => + { + _config.ApiKey = ctx.Value ?? string.Empty; + PluginConfigLoader.Save(_configPath, _config); + return AdminActionResult.Ok("API key saved."); + }); + }); + + b.RegisterTextbox(t => + { + t.Label = "Model"; + t.Value = _config.Model; + t.OnSubmit(async ctx => + { + _config.Model = ctx.Value ?? string.Empty; + PluginConfigLoader.Save(_configPath, _config); + return AdminActionResult.Ok("Model saved."); + }); + }); + + b.RegisterTextbox(t => + { + t.Label = "API endpoint"; + t.Value = _config.ApiEndpoint; + t.OnSubmit(async ctx => + { + _config.ApiEndpoint = ctx.Value ?? string.Empty; + PluginConfigLoader.Save(_configPath, _config); + return AdminActionResult.Ok("API endpoint saved."); + }); + }); + + b.RegisterNumber(n => + { + n.Label = "Max uses per game"; + n.Value = _config.MaxUsesPerGame; + n.Min = 0; + n.OnChange(async ctx => + { + if (int.TryParse(ctx.Value, out var v) && v >= 0) + { + _config.MaxUsesPerGame = v; + PluginConfigLoader.Save(_configPath, _config); + } + + return AdminActionResult.Ok("Max uses per game saved (applies to new games)."); + }); + }); + + b.RegisterNumber(n => + { + n.Label = "Max uses per meeting"; + n.Value = _config.MaxUsesPerMeeting; + n.Min = 0; + n.OnChange(async ctx => + { + if (int.TryParse(ctx.Value, out var v) && v >= 0) + { + _config.MaxUsesPerMeeting = v; + PluginConfigLoader.Save(_configPath, _config); + } + + return AdminActionResult.Ok("Max uses per meeting saved (applies to new games)."); + }); + }); + } +} diff --git a/src/Empostor.Plugin.Narrator/NarratorStartup.cs b/src/Empostor.Plugin.Narrator/NarratorStartup.cs index c71a971..79c6506 100644 --- a/src/Empostor.Plugin.Narrator/NarratorStartup.cs +++ b/src/Empostor.Plugin.Narrator/NarratorStartup.cs @@ -1,3 +1,4 @@ +using Empostor.Api.Admin; using Empostor.Api.Commands; using Empostor.Api.Events; using Empostor.Api.Plugins; @@ -16,7 +17,8 @@ public sealed class NarratorStartup : IPluginStartup public void ConfigureServices(IServiceCollection services) { - services.AddSingleton(_ => PluginConfigLoader.Load(_configPath)); + var config = PluginConfigLoader.Load(_configPath); + services.AddSingleton(config); services.AddSingleton(); services.AddSingleton(); services.AddSingleton( @@ -24,5 +26,7 @@ public sealed class NarratorStartup : IPluginStartup services.AddSingleton(); services.AddSingleton( sp => sp.GetRequiredService()); + services.AddSingleton( + _ => new NarratorAdminExtension(config, _configPath)); } } diff --git a/src/Empostor.Plugins.ChatFilter/ChatFilterAdminExtension.cs b/src/Empostor.Plugins.ChatFilter/ChatFilterAdminExtension.cs new file mode 100644 index 0000000..c5b1add --- /dev/null +++ b/src/Empostor.Plugins.ChatFilter/ChatFilterAdminExtension.cs @@ -0,0 +1,98 @@ +using System.Linq; +using Empostor.Api.Admin; + +namespace Empostor.Plugins.ChatFilter; + +public sealed class ChatFilterAdminExtension : IAdminExtension +{ + private readonly ChatFilterStore _store; + + public ChatFilterAdminExtension(ChatFilterStore store) + { + _store = store; + } + + public string Id => "chat-filter"; + + public string Title => "Chat Filter"; + + public string Icon => "filter"; + + public void Build(AdminPanelBuilder b) + { + b.RegisterToggle(t => + { + t.Label = "Enable filtering"; + t.Value = _store.Enabled; + t.OnChange(async ctx => + { + _store.Enabled = ctx.Value == "true"; + await _store.SaveAsync(); + return AdminActionResult.Ok("Chat filter " + (_store.Enabled ? "enabled." : "disabled.")); + }); + }); + + b.RegisterToggle(t => + { + t.Label = "Block messages (off = log only)"; + t.Value = _store.BlockMessage; + t.OnChange(async ctx => + { + _store.BlockMessage = ctx.Value == "true"; + await _store.SaveAsync(); + return AdminActionResult.Ok("Saved."); + }); + }); + + b.RegisterNumber(n => + { + n.Label = "Spam threshold (messages)"; + n.Value = _store.SpamThreshold; + n.Min = 0; + n.OnChange(async ctx => + { + if (int.TryParse(ctx.Value, out var v)) + { + _store.SpamThreshold = v; + await _store.SaveAsync(); + } + + return AdminActionResult.Ok("Spam threshold saved."); + }); + }); + + b.RegisterNumber(n => + { + n.Label = "Spam window (seconds)"; + n.Value = _store.SpamWindowSeconds; + n.Min = 1; + n.OnChange(async ctx => + { + if (int.TryParse(ctx.Value, out var v) && v > 0) + { + _store.SpamWindowSeconds = v; + await _store.SaveAsync(); + } + + return AdminActionResult.Ok("Spam window saved."); + }); + }); + + b.RegisterChips(c => + { + c.Placeholder = "Add a blocked word…"; + c.AddLabel = "Add"; + c.Items = _store.BlockedWords.Select(w => new AdminChip(w, w)).ToList(); + c.OnAdd(async ctx => + { + _store.AddWord(ctx.Value ?? string.Empty); + return AdminActionResult.Ok("Word added."); + }); + c.OnRemove(async ctx => + { + _store.RemoveWord(ctx.Value ?? string.Empty); + return AdminActionResult.Ok("Word removed."); + }); + }); + } +} diff --git a/src/Empostor.Plugins.ChatFilter/ChatFilterConfig.cs b/src/Empostor.Plugins.ChatFilter/ChatFilterConfig.cs new file mode 100644 index 0000000..c4b0f63 --- /dev/null +++ b/src/Empostor.Plugins.ChatFilter/ChatFilterConfig.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +namespace Empostor.Plugins.ChatFilter; + +public sealed class ChatFilterConfig +{ + public bool Enabled { get; set; } + + public List BlockedWords { get; set; } = new(); + + public bool BlockMessage { get; set; } = true; + + public int SpamThreshold { get; set; } = 5; + + public int SpamWindowSeconds { get; set; } = 10; +} diff --git a/src/Empostor.Server/Service/Admin/Chat/ChatFilterListener.cs b/src/Empostor.Plugins.ChatFilter/ChatFilterListener.cs similarity index 95% rename from src/Empostor.Server/Service/Admin/Chat/ChatFilterListener.cs rename to src/Empostor.Plugins.ChatFilter/ChatFilterListener.cs index 9e9d4c7..7f5e016 100644 --- a/src/Empostor.Server/Service/Admin/Chat/ChatFilterListener.cs +++ b/src/Empostor.Plugins.ChatFilter/ChatFilterListener.cs @@ -5,9 +5,9 @@ using Empostor.Api.Events; using Empostor.Api.Events.Player; using Microsoft.Extensions.Logging; -namespace Empostor.Server.Service.Admin.Chat; +namespace Empostor.Plugins.ChatFilter; -internal sealed class ChatFilterListener : IEventListener +public sealed class ChatFilterListener : IEventListener { private readonly ILogger _logger; private readonly ChatFilterStore _store; @@ -30,7 +30,6 @@ internal sealed class ChatFilterListener : IEventListener var playerKey = e.ClientPlayer.Client.FriendCode ?? e.ClientPlayer.Client.Name; var message = e.Message; - // Word filter var words = _store.BlockedWords; if (words.Count > 0) { @@ -63,7 +62,6 @@ internal sealed class ChatFilterListener : IEventListener } } - // Spam rate limit var threshold = _store.SpamThreshold; if (threshold > 0) { diff --git a/src/Empostor.Plugins.ChatFilter/ChatFilterPlugin.cs b/src/Empostor.Plugins.ChatFilter/ChatFilterPlugin.cs new file mode 100644 index 0000000..985a31a --- /dev/null +++ b/src/Empostor.Plugins.ChatFilter/ChatFilterPlugin.cs @@ -0,0 +1,24 @@ +using System.Threading.Tasks; +using Empostor.Api.Plugins; +using Microsoft.Extensions.Logging; + +namespace Empostor.Plugins.ChatFilter; + +[EmpostorPlugin("cn.hayashiume.chatfilter", "Chat Filter", "HayashiUme", "2.0.0")] +public sealed class ChatFilterPlugin : PluginBase +{ + private readonly ILogger _logger; + + public ChatFilterPlugin(ILogger logger) + { + _logger = logger; + } + + public override ValueTask EnableAsync() + { + _logger.LogInformation("[ChatFilter] Enabled. Configure rules in the admin panel (Plugins → Chat Filter)."); + return default; + } + + public override ValueTask DisableAsync() => default; +} diff --git a/src/Empostor.Plugins.ChatFilter/ChatFilterPluginStartup.cs b/src/Empostor.Plugins.ChatFilter/ChatFilterPluginStartup.cs new file mode 100644 index 0000000..48013cc --- /dev/null +++ b/src/Empostor.Plugins.ChatFilter/ChatFilterPluginStartup.cs @@ -0,0 +1,19 @@ +using Empostor.Api.Admin; +using Empostor.Api.Events; +using Empostor.Api.Plugins; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Empostor.Plugins.ChatFilter; + +public sealed class ChatFilterPluginStartup : IPluginStartup +{ + public void ConfigureHost(IHostBuilder host) { } + + public void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } +} diff --git a/src/Empostor.Server/Service/Admin/Chat/ChatFilterStore.cs b/src/Empostor.Plugins.ChatFilter/ChatFilterStore.cs similarity index 87% rename from src/Empostor.Server/Service/Admin/Chat/ChatFilterStore.cs rename to src/Empostor.Plugins.ChatFilter/ChatFilterStore.cs index 00f19c7..6771891 100644 --- a/src/Empostor.Server/Service/Admin/Chat/ChatFilterStore.cs +++ b/src/Empostor.Plugins.ChatFilter/ChatFilterStore.cs @@ -1,28 +1,28 @@ using System; using System.Collections.Generic; +using System.IO; using System.Threading.Tasks; -using Empostor.Api.Config; +using Empostor.Api.Plugins; using Empostor.Api.Service; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -namespace Empostor.Server.Service.Admin.Chat; +namespace Empostor.Plugins.ChatFilter; public sealed class ChatFilterStore : JsonDataStore { - private readonly object _wordsLock = new(); + private const string ConfigFile = "[Empostor.Plugins.ChatFilter]Config.json"; + private readonly object _wordsLock = new(); private List _blockedWords = new(); - public ChatFilterStore(ILogger logger, IOptions config) + public ChatFilterStore(ILogger logger) : base(logger, legacyPath: null) { Load(); - // If no persisted file was loaded, fall back to config.json defaults if (_blockedWords.Count == 0 && !Enabled) { - var cfg = config.Value; + var cfg = PluginConfigLoader.Load(ConfigPath()); Enabled = cfg.Enabled; BlockMessage = cfg.BlockMessage; SpamThreshold = cfg.SpamThreshold; @@ -121,4 +121,6 @@ public sealed class ChatFilterStore : JsonDataStore _blockedWords = new List(data.BlockedWords ?? new List()); } } + + private static string ConfigPath() => Path.Combine(Directory.GetCurrentDirectory(), ConfigFile); } diff --git a/src/Empostor.Plugins.ChatFilter/Empostor.Plugins.ChatFilter.csproj b/src/Empostor.Plugins.ChatFilter/Empostor.Plugins.ChatFilter.csproj new file mode 100644 index 0000000..8c9e7a4 --- /dev/null +++ b/src/Empostor.Plugins.ChatFilter/Empostor.Plugins.ChatFilter.csproj @@ -0,0 +1,10 @@ + + + net8.0 + Empostor.Plugins.ChatFilter + enable + + + + + diff --git a/src/Empostor.Plugins.ChatFilter/packages.lock.json b/src/Empostor.Plugins.ChatFilter/packages.lock.json new file mode 100644 index 0000000..ac11d80 --- /dev/null +++ b/src/Empostor.Plugins.ChatFilter/packages.lock.json @@ -0,0 +1,137 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "BouncyCastle.Cryptography": { + "type": "Transitive", + "resolved": "2.6.1", + "contentHash": "vZsG2YILhthgRqO+ZVgRff4ZFKKTl0v7kqaVBLCtRvpREhfBP33pcWrdA3PRYgWuFL1RxiUFvjMUHTdBZlJcoA==" + }, + "Microsoft.AspNetCore.Http.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "Nxs7Z1q3f1STfLYKJSVXCs1iBl+Ya6E8o4Oy1bCxJ/rNI44E/0f6tbsrVqAWfB7jlnJfyaAtIalBVxPKUPQb4Q==", + "dependencies": { + "Microsoft.AspNetCore.Http.Features": "2.2.0", + "System.Text.Encodings.Web": "4.5.0" + } + }, + "Microsoft.AspNetCore.Http.Features": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "ziFz5zH8f33En4dX81LW84I6XrYXKf9jg6aM39cM+LffN9KJahViKZ61dGMSO2gd3e+qe5yBRwsesvyqlZaSMg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "9.0.6", + "contentHash": "0Zn6nR/6g+90MxskZyOOMPQvnPnrrGu6bytPwkV+azDcTtCSuQ1+GJUrg8Klmnrjk1i6zMpw2lXijl+tw7Q3kA==" + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "JHYCQG7HmugNYUhOl368g+NMxYE/N/AiclCYRNlgCY9eVyiBkOHMwK4x60RYMxv9EL3+rmj1mqHvdCiPpC+D4Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "System.Diagnostics.DiagnosticSource": "8.0.0" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ZbaMlhJlpisjuWbvXr4LdAst/1XxH3vZ6A0BsgTphZ2L4PGuxRLz7Jr/S7mkAAnOn78Vu0fKhEgNF5JO3zfjqQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AG7HWwVRdCHlaA++1oKDxLsXIBxmDpMPb3VoyOoAghEWnkUvEAdYQUwnV4jJbAaa/nMYNiEh5ByoLauZBEiovg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Diagnostics.Abstractions": "8.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.ObjectPool": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "X21b8D6z58EG34OMLH955750mFaEaGs5xLGe9Vyi3059Cgb3sk+p2k/JlQXaieEBRrk+SVlFzDei3Q6PdVzJOA==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "JOVOfqpnqlVLUzINQ2fox8evY2SKLYJ3BV8QDe/Jyp21u1T7r45x/R/5QdteURMR5r01GxeJSBBUOCOyaNXA3g==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" + }, + "Serilog": { + "type": "Transitive", + "resolved": "4.3.1-dev-02373", + "contentHash": "f0HNPaleOox5++8zqxAdi9afnUUkoznLOmd0ur/UnLbzl8bIaPyyBGOpbIJmC1kz2vHfkQ0fXh6KXMdlz7bcLQ==" + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "c9xLpVz6PL9lp/djOWtk5KPDZq3cSYpmXoJQY524EOtuFl5z9ZtsotpsyrDW40U1DRnQSYvcPKEUV0X//u6gkQ==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "Xg4G4Indi4dqP1iuAiMSwpiWS54ZghzR644OtsRCm/m/lBMG8dUBhLVN7hLm8NNrNTR+iGbshCPTwrvxZPlm4g==" + }, + "empostor.api": { + "type": "Project", + "dependencies": { + "Microsoft.AspNetCore.Http.Abstractions": "[2.2.0, )", + "Microsoft.Extensions.Hosting.Abstractions": "[8.0.0, )", + "Microsoft.Extensions.Logging.Abstractions": "[8.0.0, )", + "NextFast.Hazel": "[1.0.2-dev, )", + "NextFast.Hazel.Abstractions": "[1.0.2-dev, )" + } + }, + "NextFast.Hazel": { + "type": "Project", + "dependencies": { + "BouncyCastle.Cryptography": "[2.6.1, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[9.0.6, )", + "Microsoft.Extensions.ObjectPool": "[8.0.1, )", + "NextFast.Hazel.Abstractions": "[1.0.2-dev, )", + "Serilog": "[4.3.1-dev-02373, )" + } + }, + "NextFast.Hazel.Abstractions": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/src/Empostor.Plugins.DiscordWebhook/DiscordWebhookAdminExtension.cs b/src/Empostor.Plugins.DiscordWebhook/DiscordWebhookAdminExtension.cs new file mode 100644 index 0000000..ea0ee19 --- /dev/null +++ b/src/Empostor.Plugins.DiscordWebhook/DiscordWebhookAdminExtension.cs @@ -0,0 +1,51 @@ +using Empostor.Api.Admin; + +namespace Empostor.Plugins.DiscordWebhook; + +public sealed class DiscordWebhookAdminExtension : IAdminExtension +{ + private readonly DiscordWebhookStore _store; + + public DiscordWebhookAdminExtension(DiscordWebhookStore store) + { + _store = store; + } + + public string Id => "discord-webhook"; + + public string Title => "Discord Webhook"; + + public string Icon => "webhook"; + + public void Build(AdminPanelBuilder b) + { + b.RegisterBlock(block => + { + block.Title = "Webhook URLs"; + block.Description = "Leave a URL empty to disable its notifications."; + block.Children.Add(new AdminTextbox + { + Label = "Matchmaker URL", + Value = _store.MatchmakerUrl, + Placeholder = "https://discord.com/api/webhooks/...", + }.OnSubmit(async ctx => + { + _store.MatchmakerUrl = ctx.Value ?? string.Empty; + await _store.SaveAsync(); + return AdminActionResult.Ok("Matchmaker webhook saved."); + })); + + block.Children.Add(new AdminTextbox + { + Label = "Admin URL", + Value = _store.AdminUrl, + Placeholder = "https://discord.com/api/webhooks/...", + }.OnSubmit(async ctx => + { + _store.AdminUrl = ctx.Value ?? string.Empty; + await _store.SaveAsync(); + return AdminActionResult.Ok("Admin webhook saved."); + })); + }); + } +} diff --git a/src/Empostor.Plugins.DiscordWebhook/DiscordWebhookConfig.cs b/src/Empostor.Plugins.DiscordWebhook/DiscordWebhookConfig.cs new file mode 100644 index 0000000..ec1b57c --- /dev/null +++ b/src/Empostor.Plugins.DiscordWebhook/DiscordWebhookConfig.cs @@ -0,0 +1,11 @@ +namespace Empostor.Plugins.DiscordWebhook; + +public sealed class DiscordWebhookConfig +{ + public string MatchmakerUrl { get; set; } = string.Empty; + + public string AdminUrl { get; set; } = string.Empty; + + /// Legacy single-webhook fallback used when both URLs are empty. + public string WebhookUrl { get; set; } = string.Empty; +} diff --git a/src/Empostor.Server/Service/Api/DiscordWebhookListener.cs b/src/Empostor.Plugins.DiscordWebhook/DiscordWebhookListener.cs similarity index 95% rename from src/Empostor.Server/Service/Api/DiscordWebhookListener.cs rename to src/Empostor.Plugins.DiscordWebhook/DiscordWebhookListener.cs index c57520c..d5b5af1 100644 --- a/src/Empostor.Server/Service/Api/DiscordWebhookListener.cs +++ b/src/Empostor.Plugins.DiscordWebhook/DiscordWebhookListener.cs @@ -7,11 +7,12 @@ using System.Text.Json; using System.Threading.Tasks; using Empostor.Api.Events; using Empostor.Api.Events.Player; +using Empostor.Api.Innersloth; using Microsoft.Extensions.Logging; -namespace Empostor.Server.Service.Api; +namespace Empostor.Plugins.DiscordWebhook; -internal sealed class DiscordWebhookListener : IEventListener +public sealed class DiscordWebhookListener : IEventListener { private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = false }; @@ -29,8 +30,6 @@ internal sealed class DiscordWebhookListener : IEventListener _config = config; } - // ── Matchmaker events (fire-and-forget, do not block game flow) ── - [EventListener] public void OnGameCreated(IGameCreatedEvent e) { @@ -90,8 +89,6 @@ internal sealed class DiscordWebhookListener : IEventListener }); } - // ── Admin events (fire-and-forget) ── - [EventListener] public void OnPlayerLeft(IGamePlayerLeftEvent e) { diff --git a/src/Empostor.Plugins.DiscordWebhook/DiscordWebhookPlugin.cs b/src/Empostor.Plugins.DiscordWebhook/DiscordWebhookPlugin.cs new file mode 100644 index 0000000..05d2cbe --- /dev/null +++ b/src/Empostor.Plugins.DiscordWebhook/DiscordWebhookPlugin.cs @@ -0,0 +1,24 @@ +using System.Threading.Tasks; +using Empostor.Api.Plugins; +using Microsoft.Extensions.Logging; + +namespace Empostor.Plugins.DiscordWebhook; + +[EmpostorPlugin("cn.hayashiume.discordwebhook", "Discord Webhook", "HayashiUme", "2.0.0")] +public sealed class DiscordWebhookPlugin : PluginBase +{ + private readonly ILogger _logger; + + public DiscordWebhookPlugin(ILogger logger) + { + _logger = logger; + } + + public override ValueTask EnableAsync() + { + _logger.LogInformation("[DiscordWebhook] Enabled. Configure URLs in the admin panel (Plugins → Discord Webhook)."); + return default; + } + + public override ValueTask DisableAsync() => default; +} diff --git a/src/Empostor.Plugins.DiscordWebhook/DiscordWebhookPluginStartup.cs b/src/Empostor.Plugins.DiscordWebhook/DiscordWebhookPluginStartup.cs new file mode 100644 index 0000000..7476e80 --- /dev/null +++ b/src/Empostor.Plugins.DiscordWebhook/DiscordWebhookPluginStartup.cs @@ -0,0 +1,20 @@ +using Empostor.Api.Admin; +using Empostor.Api.Events; +using Empostor.Api.Plugins; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Empostor.Plugins.DiscordWebhook; + +public sealed class DiscordWebhookPluginStartup : IPluginStartup +{ + public void ConfigureHost(IHostBuilder host) { } + + public void ConfigureServices(IServiceCollection services) + { + services.AddHttpClient(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } +} diff --git a/src/Empostor.Server/Service/Api/DiscordWebhookStore.cs b/src/Empostor.Plugins.DiscordWebhook/DiscordWebhookStore.cs similarity index 54% rename from src/Empostor.Server/Service/Api/DiscordWebhookStore.cs rename to src/Empostor.Plugins.DiscordWebhook/DiscordWebhookStore.cs index 4b0b6ee..acc4123 100644 --- a/src/Empostor.Server/Service/Api/DiscordWebhookStore.cs +++ b/src/Empostor.Plugins.DiscordWebhook/DiscordWebhookStore.cs @@ -1,31 +1,28 @@ +using System.IO; using System.Text.Json.Serialization; using System.Threading.Tasks; -using Empostor.Api.Config; +using Empostor.Api.Plugins; using Empostor.Api.Service; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -namespace Empostor.Server.Service.Api; +namespace Empostor.Plugins.DiscordWebhook; public sealed class DiscordWebhookStore : JsonDataStore { - public DiscordWebhookStore(ILogger logger, IOptions config) + private const string ConfigFile = "[Empostor.Plugins.DiscordWebhook]Config.json"; + + public DiscordWebhookStore(ILogger logger) : base(logger, legacyPath: "discord_webhook.json") { JsonOpts.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; - - // Try loading from persisted file (handled by base.Load if file exists) Load(); - // If no persisted data was loaded, fall back to config.json defaults if (string.IsNullOrEmpty(MatchmakerUrl) && string.IsNullOrEmpty(AdminUrl)) { - var cfg = config.Value; - MatchmakerUrl = cfg.MatchmakerUrl; - AdminUrl = cfg.AdminUrl; - - // Migrate legacy WebhookUrl if present - MigrateLegacy(cfg); + var cfg = PluginConfigLoader.Load(ConfigPath()); + var legacy = string.IsNullOrWhiteSpace(cfg.WebhookUrl) ? string.Empty : cfg.WebhookUrl; + MatchmakerUrl = string.IsNullOrWhiteSpace(cfg.MatchmakerUrl) ? legacy : cfg.MatchmakerUrl; + AdminUrl = string.IsNullOrWhiteSpace(cfg.AdminUrl) ? legacy : cfg.AdminUrl; } } @@ -49,19 +46,5 @@ public sealed class DiscordWebhookStore : JsonDataStore public new async ValueTask SaveAsync() => await base.SaveAsync(); - private static void MigrateLegacy(DiscordWebhookConfig cfg) - { - if (!string.IsNullOrWhiteSpace(cfg.WebhookUrl)) - { - if (string.IsNullOrWhiteSpace(cfg.MatchmakerUrl)) - { - cfg.MatchmakerUrl = cfg.WebhookUrl; - } - - if (string.IsNullOrWhiteSpace(cfg.AdminUrl)) - { - cfg.AdminUrl = cfg.WebhookUrl; - } - } - } + private static string ConfigPath() => Path.Combine(Directory.GetCurrentDirectory(), ConfigFile); } diff --git a/src/Empostor.Plugins.DiscordWebhook/Empostor.Plugins.DiscordWebhook.csproj b/src/Empostor.Plugins.DiscordWebhook/Empostor.Plugins.DiscordWebhook.csproj new file mode 100644 index 0000000..dea12ea --- /dev/null +++ b/src/Empostor.Plugins.DiscordWebhook/Empostor.Plugins.DiscordWebhook.csproj @@ -0,0 +1,11 @@ + + + net8.0 + Empostor.Plugins.DiscordWebhook + enable + + + + + + diff --git a/src/Empostor.Plugins.DiscordWebhook/packages.lock.json b/src/Empostor.Plugins.DiscordWebhook/packages.lock.json new file mode 100644 index 0000000..889b108 --- /dev/null +++ b/src/Empostor.Plugins.DiscordWebhook/packages.lock.json @@ -0,0 +1,208 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "Microsoft.Extensions.Http": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "cWz4caHwvx0emoYe7NkHPxII/KkTI8R/LC9qdqJqnKv2poTJ4e2qqPGQqvRoQ5kaSA4FU5IV3qFAuLuOhoqULQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Diagnostics": "8.0.0", + "Microsoft.Extensions.Logging": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0" + } + }, + "BouncyCastle.Cryptography": { + "type": "Transitive", + "resolved": "2.6.1", + "contentHash": "vZsG2YILhthgRqO+ZVgRff4ZFKKTl0v7kqaVBLCtRvpREhfBP33pcWrdA3PRYgWuFL1RxiUFvjMUHTdBZlJcoA==" + }, + "Microsoft.AspNetCore.Http.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "Nxs7Z1q3f1STfLYKJSVXCs1iBl+Ya6E8o4Oy1bCxJ/rNI44E/0f6tbsrVqAWfB7jlnJfyaAtIalBVxPKUPQb4Q==", + "dependencies": { + "Microsoft.AspNetCore.Http.Features": "2.2.0", + "System.Text.Encodings.Web": "4.5.0" + } + }, + "Microsoft.AspNetCore.Http.Features": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "ziFz5zH8f33En4dX81LW84I6XrYXKf9jg6aM39cM+LffN9KJahViKZ61dGMSO2gd3e+qe5yBRwsesvyqlZaSMg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "0J/9YNXTMWSZP2p2+nvl8p71zpSwokZXZuJW+VjdErkegAnFdO1XlqtA62SJtgVYHdKu3uPxJHcMR/r35HwFBA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "mBMoXLsr5s1y2zOHWmKsE9veDcx8h1x/c3rz4baEdQKTeDcmQAPNbB54Pi/lhFO3K431eEq6PFbMgLaa6PHFfA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "V8S3bsm50ig6JSyrbcJJ8bW2b9QLGouz+G1miK3UTaOWmMtFwNNNzUf4AleyDWUmTrWMLNnFSLEQtxmxgNQnNQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "9.0.6", + "contentHash": "0Zn6nR/6g+90MxskZyOOMPQvnPnrrGu6bytPwkV+azDcTtCSuQ1+GJUrg8Klmnrjk1i6zMpw2lXijl+tw7Q3kA==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3PZp/YSkIXrF7QK7PfC1bkyRYwqOHpWFad8Qx+4wkuumAeXo1NHaxpS9LboNA9OvNSAu+QOVlXbMyoY+pHSqcw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Diagnostics.Abstractions": "8.0.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "8.0.0" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "JHYCQG7HmugNYUhOl368g+NMxYE/N/AiclCYRNlgCY9eVyiBkOHMwK4x60RYMxv9EL3+rmj1mqHvdCiPpC+D4Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "System.Diagnostics.DiagnosticSource": "8.0.0" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ZbaMlhJlpisjuWbvXr4LdAst/1XxH3vZ6A0BsgTphZ2L4PGuxRLz7Jr/S7mkAAnOn78Vu0fKhEgNF5JO3zfjqQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AG7HWwVRdCHlaA++1oKDxLsXIBxmDpMPb3VoyOoAghEWnkUvEAdYQUwnV4jJbAaa/nMYNiEh5ByoLauZBEiovg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Diagnostics.Abstractions": "8.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "tvRkov9tAJ3xP51LCv3FJ2zINmv1P8Hi8lhhtcKGqM+ImiTCC84uOPEI4z8Cdq2C3o9e+Aa0Gw0rmrsJD77W+w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.ObjectPool": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "X21b8D6z58EG34OMLH955750mFaEaGs5xLGe9Vyi3059Cgb3sk+p2k/JlQXaieEBRrk+SVlFzDei3Q6PdVzJOA==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "JOVOfqpnqlVLUzINQ2fox8evY2SKLYJ3BV8QDe/Jyp21u1T7r45x/R/5QdteURMR5r01GxeJSBBUOCOyaNXA3g==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "0f4DMRqEd50zQh+UyJc+/HiBsZ3vhAQALgdkcQEalSH1L2isdC7Yj54M3cyo5e+BeO5fcBQ7Dxly8XiBBcvRgw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.Configuration.Binder": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" + }, + "Serilog": { + "type": "Transitive", + "resolved": "4.3.1-dev-02373", + "contentHash": "f0HNPaleOox5++8zqxAdi9afnUUkoznLOmd0ur/UnLbzl8bIaPyyBGOpbIJmC1kz2vHfkQ0fXh6KXMdlz7bcLQ==" + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "c9xLpVz6PL9lp/djOWtk5KPDZq3cSYpmXoJQY524EOtuFl5z9ZtsotpsyrDW40U1DRnQSYvcPKEUV0X//u6gkQ==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "Xg4G4Indi4dqP1iuAiMSwpiWS54ZghzR644OtsRCm/m/lBMG8dUBhLVN7hLm8NNrNTR+iGbshCPTwrvxZPlm4g==" + }, + "empostor.api": { + "type": "Project", + "dependencies": { + "Microsoft.AspNetCore.Http.Abstractions": "[2.2.0, )", + "Microsoft.Extensions.Hosting.Abstractions": "[8.0.0, )", + "Microsoft.Extensions.Logging.Abstractions": "[8.0.0, )", + "NextFast.Hazel": "[1.0.2-dev, )", + "NextFast.Hazel.Abstractions": "[1.0.2-dev, )" + } + }, + "NextFast.Hazel": { + "type": "Project", + "dependencies": { + "BouncyCastle.Cryptography": "[2.6.1, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[9.0.6, )", + "Microsoft.Extensions.ObjectPool": "[8.0.1, )", + "NextFast.Hazel.Abstractions": "[1.0.2-dev, )", + "Serilog": "[4.3.1-dev-02373, )" + } + }, + "NextFast.Hazel.Abstractions": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/src/Empostor.Plugins.FriendCodeValidator/Empostor.Plugins.FriendCodeValidator.csproj b/src/Empostor.Plugins.FriendCodeValidator/Empostor.Plugins.FriendCodeValidator.csproj index 655a27f..a73421d 100644 --- a/src/Empostor.Plugins.FriendCodeValidator/Empostor.Plugins.FriendCodeValidator.csproj +++ b/src/Empostor.Plugins.FriendCodeValidator/Empostor.Plugins.FriendCodeValidator.csproj @@ -7,4 +7,7 @@ + + + diff --git a/src/Empostor.Plugins.FriendCodeValidator/FriendCodeValidatorAdminExtension.cs b/src/Empostor.Plugins.FriendCodeValidator/FriendCodeValidatorAdminExtension.cs new file mode 100644 index 0000000..befabeb --- /dev/null +++ b/src/Empostor.Plugins.FriendCodeValidator/FriendCodeValidatorAdminExtension.cs @@ -0,0 +1,26 @@ +using Empostor.Api.Admin; + +namespace Empostor.Plugins.FriendCodeValidator; + +public sealed class FriendCodeValidatorAdminExtension : IAdminExtension +{ + public string Id => "friend-code-validator"; + + public string Title => "Friend Code Validator"; + + public string Icon => "filter"; + + public void Build(AdminPanelBuilder b) + { + b.RegisterText( + "Enforces a valid friend code on every join: a word plus a 4-digit suffix (word#0000).", + "default"); + b.RegisterText( + "Format: ^([a-zA-Z]+)#(\\d{4})$, with the word split into an (A, B) or (B, A) pair from the built-in dictionaries.", + "muted", + monospace: true); + b.RegisterText( + "This plugin has no configurable options — the rules and dictionaries are compiled in.", + "muted"); + } +} diff --git a/src/Empostor.Plugins.FriendCodeValidator/FriendCodeValidatorStartup.cs b/src/Empostor.Plugins.FriendCodeValidator/FriendCodeValidatorStartup.cs index 31ddd3a..1cd89e8 100644 --- a/src/Empostor.Plugins.FriendCodeValidator/FriendCodeValidatorStartup.cs +++ b/src/Empostor.Plugins.FriendCodeValidator/FriendCodeValidatorStartup.cs @@ -1,3 +1,4 @@ +using Empostor.Api.Admin; using Empostor.Api.Events; using Empostor.Api.Plugins; using Microsoft.Extensions.DependencyInjection; @@ -12,6 +13,7 @@ namespace Empostor.Plugins.FriendCodeValidator public void ConfigureServices(IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); } } } diff --git a/src/Empostor.Plugins.PlayerChannel/Empostor.Plugins.PlayerChannel.csproj b/src/Empostor.Plugins.PlayerChannel/Empostor.Plugins.PlayerChannel.csproj index a1e9ee7..17eda32 100644 --- a/src/Empostor.Plugins.PlayerChannel/Empostor.Plugins.PlayerChannel.csproj +++ b/src/Empostor.Plugins.PlayerChannel/Empostor.Plugins.PlayerChannel.csproj @@ -7,4 +7,7 @@ + + + diff --git a/src/Empostor.Plugins.PlayerChannel/PlayerChannelAdminExtension.cs b/src/Empostor.Plugins.PlayerChannel/PlayerChannelAdminExtension.cs new file mode 100644 index 0000000..4f13055 --- /dev/null +++ b/src/Empostor.Plugins.PlayerChannel/PlayerChannelAdminExtension.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Empostor.Api.Admin; +using Empostor.Api.Plugins; + +namespace Empostor.Plugins.PlayerChannel; + +public sealed class PlayerChannelAdminExtension : IAdminExtension +{ + private readonly PlayerChannelConfig _config; + private readonly string _configPath; + + public PlayerChannelAdminExtension(PlayerChannelConfig config, string configPath) + { + _config = config; + _configPath = configPath; + } + + public string Id => "player-channel"; + + public string Title => "Player Channels"; + + public string Icon => "link"; + + public void Build(AdminPanelBuilder b) + { + b.RegisterTable(table => + { + table.Columns = new List { "Channel", "Friend Codes" }; + table.Rows = _config.Channels.Select(c => new List + { + new() { Text = c.Name }, + new() { Text = string.Join(", ", c.FriendCodes), Monospace = true }, + }).ToList(); + }); + + b.RegisterTextbox(t => + { + t.Label = "Edit channels (JSON)"; + t.Multiline = true; + t.Value = JsonSerializer.Serialize(_config.Channels, new JsonSerializerOptions { WriteIndented = true }); + t.OnSubmit(async ctx => + { + try + { + var list = JsonSerializer.Deserialize>(ctx.Value ?? "[]"); + if (list == null) + { + return AdminActionResult.Fail("Invalid JSON."); + } + + _config.Channels = list; + PluginConfigLoader.Save(_configPath, _config); + return AdminActionResult.Ok($"Saved {list.Count} channel(s)."); + } + catch (JsonException ex) + { + return AdminActionResult.Fail("Invalid JSON: " + ex.Message); + } + }); + }); + } +} diff --git a/src/Empostor.Plugins.PlayerChannel/PlayerChannelCommand.cs b/src/Empostor.Plugins.PlayerChannel/PlayerChannelCommand.cs index 996f524..08d8e08 100644 --- a/src/Empostor.Plugins.PlayerChannel/PlayerChannelCommand.cs +++ b/src/Empostor.Plugins.PlayerChannel/PlayerChannelCommand.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Threading.Tasks; using Empostor.Api.Commands; @@ -7,19 +6,11 @@ namespace Empostor.Plugins.PlayerChannel; public sealed class PlayerChannelCommand : ICommand { - private readonly Dictionary _fcToChannel; + private readonly PlayerChannelConfig _config; public PlayerChannelCommand(PlayerChannelConfig config) { - _fcToChannel = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var channel in config.Channels) - { - foreach (var fc in channel.FriendCodes) - { - if (!string.IsNullOrWhiteSpace(fc)) - _fcToChannel[fc] = channel; - } - } + _config = config; } public string Name => "channel"; @@ -43,7 +34,7 @@ public sealed class PlayerChannelCommand : ICommand return true; } - if (!_fcToChannel.TryGetValue(senderFc, out var channel)) + if (!TryGetChannel(senderFc, out var channel)) { await ctx.PlayerControl.SendChatToPlayerAsync( T(ctx, "playerchannel.not_in_channel", "[Refuse Channel] Not in any channel"), @@ -66,6 +57,27 @@ public sealed class PlayerChannelCommand : ICommand return true; } + private bool TryGetChannel(string friendCode, out ChannelEntry channel) + { + channel = null!; + if (string.IsNullOrWhiteSpace(friendCode)) + return false; + + foreach (var c in _config.Channels) + { + foreach (var fc in c.FriendCodes) + { + if (!string.IsNullOrWhiteSpace(fc) && string.Equals(fc, friendCode, StringComparison.OrdinalIgnoreCase)) + { + channel = c; + return true; + } + } + } + + return false; + } + private static string T(CommandContext ctx, string key, string defaultText) { string result = ctx.Lang.Get(key, ctx.SenderLanguage); diff --git a/src/Empostor.Plugins.PlayerChannel/PlayerChannelStartup.cs b/src/Empostor.Plugins.PlayerChannel/PlayerChannelStartup.cs index 1dae141..477e990 100644 --- a/src/Empostor.Plugins.PlayerChannel/PlayerChannelStartup.cs +++ b/src/Empostor.Plugins.PlayerChannel/PlayerChannelStartup.cs @@ -1,3 +1,4 @@ +using Empostor.Api.Admin; using Empostor.Api.Commands; using Empostor.Api.Plugins; using Microsoft.Extensions.DependencyInjection; @@ -20,5 +21,7 @@ public sealed class PlayerChannelStartup : IPluginStartup services.AddSingleton(); services.AddSingleton( sp => sp.GetRequiredService()); + services.AddSingleton( + _ => new PlayerChannelAdminExtension(config, _configPath)); } } diff --git a/src/Empostor.Plugins.PlayerStats/Empostor.Plugins.PlayerStats.csproj b/src/Empostor.Plugins.PlayerStats/Empostor.Plugins.PlayerStats.csproj new file mode 100644 index 0000000..81b8657 --- /dev/null +++ b/src/Empostor.Plugins.PlayerStats/Empostor.Plugins.PlayerStats.csproj @@ -0,0 +1,10 @@ + + + net8.0 + Empostor.Plugins.PlayerStats + enable + + + + + diff --git a/src/Empostor.Plugins.PlayerStats/PlayerStatsAdminExtension.cs b/src/Empostor.Plugins.PlayerStats/PlayerStatsAdminExtension.cs new file mode 100644 index 0000000..10bdf93 --- /dev/null +++ b/src/Empostor.Plugins.PlayerStats/PlayerStatsAdminExtension.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using System.Linq; +using Empostor.Api.Admin; + +namespace Empostor.Plugins.PlayerStats; + +public sealed class PlayerStatsAdminExtension : IAdminExtension +{ + private readonly PlayerStatsStore _store; + + public PlayerStatsAdminExtension(PlayerStatsStore store) + { + _store = store; + } + + public string Id => "player-stats"; + + public string Title => "Player Stats"; + + public string Icon => "stats"; + + public void Build(AdminPanelBuilder b) + { + b.RegisterToggle(t => + { + t.Label = "Enable stat recording"; + t.Value = _store.Enabled; + t.OnChange(async ctx => + { + _store.SetEnabled(ctx.Value == "true"); + return AdminActionResult.Ok("Stat recording " + (_store.Enabled ? "enabled." : "disabled.")); + }); + }); + + b.RegisterButton(btn => + { + btn.Label = "Reset all stats"; + btn.Icon = "trash"; + btn.Style = "danger"; + btn.OnClick(async ctx => + { + _store.ClearAll(); + return AdminActionResult.Ok("All statistics reset."); + }); + }); + + b.RegisterTable(table => + { + table.Columns = new List + { + "Friend Code", "Name", "Games", "Wins", "Losses", "Imp. Wins", "Kills", "Deaths", "Tasks", "Exiled", + }; + table.Rows = _store.GetAll().Select(s => new List + { + new() { Text = s.FriendCode, Monospace = true }, + new() { Text = s.LastKnownName ?? "—" }, + new() { Text = s.GamesPlayed.ToString() }, + new() { Text = s.Wins.ToString() }, + new() { Text = s.Losses.ToString() }, + new() { Text = s.ImpostorWins.ToString() }, + new() { Text = s.Kills.ToString() }, + new() { Text = s.Deaths.ToString() }, + new() { Text = s.TasksCompleted.ToString() }, + new() { Text = s.TimesExiled.ToString() }, + }).ToList(); + }); + } +} diff --git a/src/Empostor.Plugins.PlayerStats/PlayerStatsConfig.cs b/src/Empostor.Plugins.PlayerStats/PlayerStatsConfig.cs new file mode 100644 index 0000000..9713984 --- /dev/null +++ b/src/Empostor.Plugins.PlayerStats/PlayerStatsConfig.cs @@ -0,0 +1,8 @@ +namespace Empostor.Plugins.PlayerStats; + +public sealed class PlayerStatsConfig +{ + public bool Enabled { get; set; } + + public bool PersistToFile { get; set; } = true; +} diff --git a/src/Empostor.Plugins.PlayerStats/PlayerStatsEntry.cs b/src/Empostor.Plugins.PlayerStats/PlayerStatsEntry.cs new file mode 100644 index 0000000..49f1356 --- /dev/null +++ b/src/Empostor.Plugins.PlayerStats/PlayerStatsEntry.cs @@ -0,0 +1,30 @@ +using System; + +namespace Empostor.Plugins.PlayerStats; + +public sealed class PlayerStatsEntry +{ + public string FriendCode { get; init; } = string.Empty; + + public string? LastKnownName { get; set; } + + public int GamesPlayed { get; set; } + + public int Wins { get; set; } + + public int Losses { get; set; } + + public int ImpostorWins { get; set; } + + public int Kills { get; set; } + + public int Deaths { get; set; } + + public int TasksCompleted { get; set; } + + public int TimesExiled { get; set; } + + public DateTime FirstSeen { get; init; } = DateTime.UtcNow; + + public DateTime LastSeen { get; set; } = DateTime.UtcNow; +} diff --git a/src/Empostor.Server/Service/Stat/PlayerStatsListener.cs b/src/Empostor.Plugins.PlayerStats/PlayerStatsListener.cs similarity index 83% rename from src/Empostor.Server/Service/Stat/PlayerStatsListener.cs rename to src/Empostor.Plugins.PlayerStats/PlayerStatsListener.cs index e93a99c..a9199a4 100644 --- a/src/Empostor.Server/Service/Stat/PlayerStatsListener.cs +++ b/src/Empostor.Plugins.PlayerStats/PlayerStatsListener.cs @@ -1,28 +1,24 @@ using System.Linq; -using Empostor.Api.Config; using Empostor.Api.Events; using Empostor.Api.Events.Player; using Empostor.Api.Games; using Empostor.Api.Net.Inner.Objects; -using Microsoft.Extensions.Options; -namespace Empostor.Server.Service.Stat; +namespace Empostor.Plugins.PlayerStats; -internal sealed class PlayerStatsListener : IEventListener +public sealed class PlayerStatsListener : IEventListener { - private readonly PlayerStatsConfig _config; private readonly PlayerStatsStore _store; - public PlayerStatsListener(IOptions config, PlayerStatsStore store) + public PlayerStatsListener(PlayerStatsStore store) { - _config = config.Value; _store = store; } [EventListener] public void OnPlayerMurder(IPlayerMurderEvent e) { - if (!_config.Enabled) + if (!_store.Enabled) { return; } @@ -44,7 +40,7 @@ internal sealed class PlayerStatsListener : IEventListener [EventListener] public void OnPlayerCompletedTask(IPlayerCompletedTaskEvent e) { - if (!_config.Enabled) + if (!_store.Enabled) { return; } @@ -60,7 +56,7 @@ internal sealed class PlayerStatsListener : IEventListener [EventListener] public void OnPlayerExile(IPlayerExileEvent e) { - if (!_config.Enabled) + if (!_store.Enabled) { return; } @@ -76,7 +72,7 @@ internal sealed class PlayerStatsListener : IEventListener [EventListener] public void OnGameEnded(IGameEndedEvent e) { - if (!_config.Enabled) + if (!_store.Enabled) { return; } diff --git a/src/Empostor.Plugins.PlayerStats/PlayerStatsPlugin.cs b/src/Empostor.Plugins.PlayerStats/PlayerStatsPlugin.cs new file mode 100644 index 0000000..4b236ce --- /dev/null +++ b/src/Empostor.Plugins.PlayerStats/PlayerStatsPlugin.cs @@ -0,0 +1,24 @@ +using System.Threading.Tasks; +using Empostor.Api.Plugins; +using Microsoft.Extensions.Logging; + +namespace Empostor.Plugins.PlayerStats; + +[EmpostorPlugin("cn.hayashiume.playerstats", "Player Stats", "HayashiUme", "2.0.0")] +public sealed class PlayerStatsPlugin : PluginBase +{ + private readonly ILogger _logger; + + public PlayerStatsPlugin(ILogger logger) + { + _logger = logger; + } + + public override ValueTask EnableAsync() + { + _logger.LogInformation("[PlayerStats] Enabled. View and reset statistics in the admin panel (Plugins → Player Stats)."); + return default; + } + + public override ValueTask DisableAsync() => default; +} diff --git a/src/Empostor.Plugins.PlayerStats/PlayerStatsPluginStartup.cs b/src/Empostor.Plugins.PlayerStats/PlayerStatsPluginStartup.cs new file mode 100644 index 0000000..f51f1c5 --- /dev/null +++ b/src/Empostor.Plugins.PlayerStats/PlayerStatsPluginStartup.cs @@ -0,0 +1,21 @@ +using Empostor.Api.Admin; +using Empostor.Api.Commands; +using Empostor.Api.Events; +using Empostor.Api.Plugins; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Empostor.Plugins.PlayerStats; + +public sealed class PlayerStatsPluginStartup : IPluginStartup +{ + public void ConfigureHost(IHostBuilder host) { } + + public void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } +} diff --git a/src/Empostor.Server/Service/Stat/PlayerStatsStore.cs b/src/Empostor.Plugins.PlayerStats/PlayerStatsStore.cs similarity index 80% rename from src/Empostor.Server/Service/Stat/PlayerStatsStore.cs rename to src/Empostor.Plugins.PlayerStats/PlayerStatsStore.cs index 872adb2..ad844fb 100644 --- a/src/Empostor.Server/Service/Stat/PlayerStatsStore.cs +++ b/src/Empostor.Plugins.PlayerStats/PlayerStatsStore.cs @@ -1,47 +1,37 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.IO; using System.Linq; +using Empostor.Api.Plugins; using Empostor.Api.Service; using Microsoft.Extensions.Logging; -namespace Empostor.Server.Service.Stat; - -public sealed class PlayerStatsEntry -{ - public string FriendCode { get; init; } = string.Empty; - - public string? LastKnownName { get; set; } - - public int GamesPlayed { get; set; } - - public int Wins { get; set; } - - public int Losses { get; set; } - - public int ImpostorWins { get; set; } - - public int Kills { get; set; } - - public int Deaths { get; set; } - - public int TasksCompleted { get; set; } - - public int TimesExiled { get; set; } - - public DateTime FirstSeen { get; init; } = DateTime.UtcNow; - - public DateTime LastSeen { get; set; } = DateTime.UtcNow; -} +namespace Empostor.Plugins.PlayerStats; public sealed class PlayerStatsStore : JsonDataStore> { + private const string ConfigFile = "[Empostor.Plugins.PlayerStats]Config.json"; + private ConcurrentDictionary _stats = new(StringComparer.OrdinalIgnoreCase); public PlayerStatsStore(ILogger logger) : base(logger, legacyPath: "Data/player_stats.json") { Load(); + var cfg = PluginConfigLoader.Load(ConfigPath()); + Enabled = cfg.Enabled; + } + + public bool Enabled { get; private set; } + + public void SetEnabled(bool value) + { + Enabled = value; + var path = ConfigPath(); + var cfg = PluginConfigLoader.Load(path); + cfg.Enabled = value; + PluginConfigLoader.Save(path, cfg); } public PlayerStatsEntry GetOrCreate(string friendCode, string? name = null) @@ -133,4 +123,6 @@ public sealed class PlayerStatsStore : JsonDataStore> private static string Normalize(string friendCode) => (friendCode ?? string.Empty).Trim(); + + private static string ConfigPath() => Path.Combine(Directory.GetCurrentDirectory(), ConfigFile); } diff --git a/src/Empostor.Server/Commands/Commands/StatCommand.cs b/src/Empostor.Plugins.PlayerStats/StatCommand.cs similarity index 84% rename from src/Empostor.Server/Commands/Commands/StatCommand.cs rename to src/Empostor.Plugins.PlayerStats/StatCommand.cs index 35ed227..9ff8e54 100644 --- a/src/Empostor.Server/Commands/Commands/StatCommand.cs +++ b/src/Empostor.Plugins.PlayerStats/StatCommand.cs @@ -1,20 +1,15 @@ using System.Threading.Tasks; using Empostor.Api.Commands; -using Empostor.Api.Config; -using Empostor.Server.Service.Stat; -using Microsoft.Extensions.Options; -namespace Empostor.Server.Commands.Commands; +namespace Empostor.Plugins.PlayerStats; public sealed class StatCommand : ICommand { private readonly PlayerStatsStore _store; - private readonly PlayerStatsConfig _config; - public StatCommand(PlayerStatsStore store, IOptions config) + public StatCommand(PlayerStatsStore store) { _store = store; - _config = config.Value; } public string Name => "stat"; @@ -27,7 +22,7 @@ public sealed class StatCommand : ICommand public async ValueTask ExecuteAsync(CommandContext ctx) { - if (!_config.Enabled) + if (!_store.Enabled) { await ctx.PlayerControl.SendChatToPlayerAsync( ctx.GetString("command.stat.disabled"), ctx.PlayerControl); diff --git a/src/Empostor.Plugins.PlayerStats/packages.lock.json b/src/Empostor.Plugins.PlayerStats/packages.lock.json new file mode 100644 index 0000000..ac11d80 --- /dev/null +++ b/src/Empostor.Plugins.PlayerStats/packages.lock.json @@ -0,0 +1,137 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "BouncyCastle.Cryptography": { + "type": "Transitive", + "resolved": "2.6.1", + "contentHash": "vZsG2YILhthgRqO+ZVgRff4ZFKKTl0v7kqaVBLCtRvpREhfBP33pcWrdA3PRYgWuFL1RxiUFvjMUHTdBZlJcoA==" + }, + "Microsoft.AspNetCore.Http.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "Nxs7Z1q3f1STfLYKJSVXCs1iBl+Ya6E8o4Oy1bCxJ/rNI44E/0f6tbsrVqAWfB7jlnJfyaAtIalBVxPKUPQb4Q==", + "dependencies": { + "Microsoft.AspNetCore.Http.Features": "2.2.0", + "System.Text.Encodings.Web": "4.5.0" + } + }, + "Microsoft.AspNetCore.Http.Features": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "ziFz5zH8f33En4dX81LW84I6XrYXKf9jg6aM39cM+LffN9KJahViKZ61dGMSO2gd3e+qe5yBRwsesvyqlZaSMg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "9.0.6", + "contentHash": "0Zn6nR/6g+90MxskZyOOMPQvnPnrrGu6bytPwkV+azDcTtCSuQ1+GJUrg8Klmnrjk1i6zMpw2lXijl+tw7Q3kA==" + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "JHYCQG7HmugNYUhOl368g+NMxYE/N/AiclCYRNlgCY9eVyiBkOHMwK4x60RYMxv9EL3+rmj1mqHvdCiPpC+D4Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "System.Diagnostics.DiagnosticSource": "8.0.0" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ZbaMlhJlpisjuWbvXr4LdAst/1XxH3vZ6A0BsgTphZ2L4PGuxRLz7Jr/S7mkAAnOn78Vu0fKhEgNF5JO3zfjqQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AG7HWwVRdCHlaA++1oKDxLsXIBxmDpMPb3VoyOoAghEWnkUvEAdYQUwnV4jJbAaa/nMYNiEh5ByoLauZBEiovg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Diagnostics.Abstractions": "8.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.ObjectPool": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "X21b8D6z58EG34OMLH955750mFaEaGs5xLGe9Vyi3059Cgb3sk+p2k/JlQXaieEBRrk+SVlFzDei3Q6PdVzJOA==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "JOVOfqpnqlVLUzINQ2fox8evY2SKLYJ3BV8QDe/Jyp21u1T7r45x/R/5QdteURMR5r01GxeJSBBUOCOyaNXA3g==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" + }, + "Serilog": { + "type": "Transitive", + "resolved": "4.3.1-dev-02373", + "contentHash": "f0HNPaleOox5++8zqxAdi9afnUUkoznLOmd0ur/UnLbzl8bIaPyyBGOpbIJmC1kz2vHfkQ0fXh6KXMdlz7bcLQ==" + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "c9xLpVz6PL9lp/djOWtk5KPDZq3cSYpmXoJQY524EOtuFl5z9ZtsotpsyrDW40U1DRnQSYvcPKEUV0X//u6gkQ==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "Xg4G4Indi4dqP1iuAiMSwpiWS54ZghzR644OtsRCm/m/lBMG8dUBhLVN7hLm8NNrNTR+iGbshCPTwrvxZPlm4g==" + }, + "empostor.api": { + "type": "Project", + "dependencies": { + "Microsoft.AspNetCore.Http.Abstractions": "[2.2.0, )", + "Microsoft.Extensions.Hosting.Abstractions": "[8.0.0, )", + "Microsoft.Extensions.Logging.Abstractions": "[8.0.0, )", + "NextFast.Hazel": "[1.0.2-dev, )", + "NextFast.Hazel.Abstractions": "[1.0.2-dev, )" + } + }, + "NextFast.Hazel": { + "type": "Project", + "dependencies": { + "BouncyCastle.Cryptography": "[2.6.1, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[9.0.6, )", + "Microsoft.Extensions.ObjectPool": "[8.0.1, )", + "NextFast.Hazel.Abstractions": "[1.0.2-dev, )", + "Serilog": "[4.3.1-dev-02373, )" + } + }, + "NextFast.Hazel.Abstractions": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/src/Empostor.Plugins.QqVerify/Empostor.Plugins.QqVerify.csproj b/src/Empostor.Plugins.QqVerify/Empostor.Plugins.QqVerify.csproj index 4135b29..81ae811 100644 --- a/src/Empostor.Plugins.QqVerify/Empostor.Plugins.QqVerify.csproj +++ b/src/Empostor.Plugins.QqVerify/Empostor.Plugins.QqVerify.csproj @@ -7,4 +7,7 @@ + + + diff --git a/src/Empostor.Plugins.QqVerify/QqVerifyAdminExtension.cs b/src/Empostor.Plugins.QqVerify/QqVerifyAdminExtension.cs new file mode 100644 index 0000000..c96ec54 --- /dev/null +++ b/src/Empostor.Plugins.QqVerify/QqVerifyAdminExtension.cs @@ -0,0 +1,39 @@ +using Empostor.Api.Admin; +using Empostor.Api.Plugins; + +namespace Empostor.Plugins.QqVerify; + +public sealed class QqVerifyAdminExtension : IAdminExtension +{ + private readonly QqVerifyConfig _config; + private readonly string _configPath; + + public QqVerifyAdminExtension(QqVerifyConfig config, string configPath) + { + _config = config; + _configPath = configPath; + } + + public string Id => "qq-verify"; + + public string Title => "QQ Verify"; + + public string Icon => "shield"; + + public void Build(AdminPanelBuilder b) + { + b.RegisterTextbox(t => + { + t.Label = "Bot secret"; + t.Value = _config.BotSecret; + t.Secret = true; + t.Placeholder = "change-bot-secret"; + t.OnSubmit(async ctx => + { + _config.BotSecret = ctx.Value ?? string.Empty; + PluginConfigLoader.Save(_configPath, _config); + return AdminActionResult.Ok("Bot secret saved."); + }); + }); + } +} diff --git a/src/Empostor.Plugins.QqVerify/QqVerifyStartup.cs b/src/Empostor.Plugins.QqVerify/QqVerifyStartup.cs index db0cf9e..a45f961 100644 --- a/src/Empostor.Plugins.QqVerify/QqVerifyStartup.cs +++ b/src/Empostor.Plugins.QqVerify/QqVerifyStartup.cs @@ -1,3 +1,4 @@ +using Empostor.Api.Admin; using Empostor.Api.Commands; using Empostor.Api.Plugins; using Empostor.Api.Service.Admin.Verify; @@ -16,10 +17,13 @@ public sealed class QqVerifyStartup : IPluginStartup public void ConfigureServices(IServiceCollection services) { - services.AddSingleton(_ => PluginConfigLoader.Load(_configPath)); + var config = PluginConfigLoader.Load(_configPath); + services.AddSingleton(config); services.AddSingleton(); services.AddSingleton(); services.AddSingleton( sp => sp.GetRequiredService()); + services.AddSingleton( + _ => new QqVerifyAdminExtension(config, _configPath)); } } diff --git a/src/Empostor.Plugins.QqVerify/QqVerifyStore.cs b/src/Empostor.Plugins.QqVerify/QqVerifyStore.cs index f3e391c..8dcf3d2 100644 --- a/src/Empostor.Plugins.QqVerify/QqVerifyStore.cs +++ b/src/Empostor.Plugins.QqVerify/QqVerifyStore.cs @@ -9,11 +9,11 @@ public sealed class QqVerifyStore : IVerifyStore private readonly object _lock = new(); private readonly Dictionary _pending = new(); private static readonly TimeSpan Expiry = TimeSpan.FromMinutes(10); - private readonly string _botSecret; + private readonly QqVerifyConfig _config; public QqVerifyStore(QqVerifyConfig config) { - _botSecret = config.BotSecret; + _config = config; } public void AddPending(string friendCode, string qqNumber) @@ -28,7 +28,7 @@ public sealed class QqVerifyStore : IVerifyStore public bool ValidateSecret(string secret) { - return !string.IsNullOrWhiteSpace(secret) && secret == _botSecret; + return !string.IsNullOrWhiteSpace(secret) && secret == _config.BotSecret; } public bool TryConfirm(string friendCode, string qqNumber) diff --git a/src/Empostor.Plugins.QqVerify/packages.lock.json b/src/Empostor.Plugins.QqVerify/packages.lock.json index 01c3989..ac11d80 100644 --- a/src/Empostor.Plugins.QqVerify/packages.lock.json +++ b/src/Empostor.Plugins.QqVerify/packages.lock.json @@ -119,9 +119,6 @@ "NextFast.Hazel.Abstractions": "[1.0.2-dev, )" } }, - "empostor.api.innersloth.generator": { - "type": "Project" - }, "NextFast.Hazel": { "type": "Project", "dependencies": { diff --git a/src/Empostor.Plugins.Titles/Empostor.Plugins.Titles.csproj b/src/Empostor.Plugins.Titles/Empostor.Plugins.Titles.csproj index 68e8960..d553281 100644 --- a/src/Empostor.Plugins.Titles/Empostor.Plugins.Titles.csproj +++ b/src/Empostor.Plugins.Titles/Empostor.Plugins.Titles.csproj @@ -7,4 +7,7 @@ + + + diff --git a/src/Empostor.Plugins.Titles/TitlesAdminExtension.cs b/src/Empostor.Plugins.Titles/TitlesAdminExtension.cs new file mode 100644 index 0000000..81e475e --- /dev/null +++ b/src/Empostor.Plugins.Titles/TitlesAdminExtension.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Empostor.Api.Admin; +using Empostor.Api.Plugins; +using Empostor.Plugins.Titles.Service; + +namespace Empostor.Plugins.Titles; + +public sealed class TitlesAdminExtension : IAdminExtension +{ + private readonly TitlesConfig _config; + private readonly string _configPath; + private readonly FriendCodeTitleListener _listener; + + public TitlesAdminExtension(TitlesConfig config, string configPath, FriendCodeTitleListener listener) + { + _config = config; + _configPath = configPath; + _listener = listener; + } + + public string Id => "titles"; + + public string Title => "Title System"; + + public string Icon => "list"; + + public void Build(AdminPanelBuilder b) + { + b.RegisterTable(table => + { + table.Columns = new List { "Friend Code", "Title" }; + table.Rows = _config.Titles.Select(t => new List + { + new() { Text = t.FriendCode, Monospace = true }, + new() { Text = t.Title }, + }).ToList(); + }); + + b.RegisterTextbox(t => + { + t.Label = "Edit titles (JSON)"; + t.Multiline = true; + t.Value = JsonSerializer.Serialize(_config.Titles, new JsonSerializerOptions { WriteIndented = true }); + t.OnSubmit(async ctx => + { + try + { + var list = JsonSerializer.Deserialize>(ctx.Value ?? "[]"); + if (list == null) + { + return AdminActionResult.Fail("Invalid JSON."); + } + + _config.Titles = list; + PluginConfigLoader.Save(_configPath, _config); + _listener.Reload(); + return AdminActionResult.Ok($"Saved {list.Count} title(s)."); + } + catch (JsonException ex) + { + return AdminActionResult.Fail("Invalid JSON: " + ex.Message); + } + }); + }); + } +} diff --git a/src/Empostor.Plugins.Titles/TitlesPluginStartup.cs b/src/Empostor.Plugins.Titles/TitlesPluginStartup.cs index d64cc78..b8ced79 100644 --- a/src/Empostor.Plugins.Titles/TitlesPluginStartup.cs +++ b/src/Empostor.Plugins.Titles/TitlesPluginStartup.cs @@ -1,3 +1,4 @@ +using Empostor.Api.Admin; using Empostor.Api.Events; using Empostor.Api.Plugins; using Empostor.Plugins.Titles.Service; @@ -17,10 +18,14 @@ public sealed class TitlesPluginStartup : IPluginHttpStartup public void ConfigureServices(IServiceCollection services) { - services.AddSingleton(_ => PluginConfigLoader.Load(_configPath)); + var config = PluginConfigLoader.Load(_configPath); + services.AddSingleton(config); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton( + sp => new TitlesAdminExtension(config, _configPath, sp.GetRequiredService())); } public void ConfigureWebApplication(IApplicationBuilder builder) diff --git a/src/Empostor.Server/Commands/CommandBootstrapper.cs b/src/Empostor.Server/Commands/CommandBootstrapper.cs index 037822e..175b99b 100644 --- a/src/Empostor.Server/Commands/CommandBootstrapper.cs +++ b/src/Empostor.Server/Commands/CommandBootstrapper.cs @@ -12,20 +12,17 @@ internal sealed class CommandBootstrapper : IHostedService { private readonly ILogger _logger; private readonly CommandService _commands; - private readonly StatCommand _statCommand; private readonly BanCommand _banCommand; private readonly IEnumerable _pluginCommands; public CommandBootstrapper( ILogger logger, CommandService commands, - StatCommand statCommand, BanCommand banCommand, IEnumerable pluginCommands) { _logger = logger; _commands = commands; - _statCommand = statCommand; _banCommand = banCommand; _pluginCommands = pluginCommands; } @@ -41,7 +38,6 @@ internal sealed class CommandBootstrapper : IHostedService _commands.Register(new EndCommand()); _commands.Register(new KickCommand()); _commands.Register(_banCommand); - _commands.Register(_statCommand); _commands.RegisterAll(_pluginCommands); _logger.LogInformation( "[Commands] Registered {Count} command(s): {Names}", diff --git a/src/Empostor.Server/Http/Admin/AdminExtensionRegistry.cs b/src/Empostor.Server/Http/Admin/AdminExtensionRegistry.cs new file mode 100644 index 0000000..8cce8d8 --- /dev/null +++ b/src/Empostor.Server/Http/Admin/AdminExtensionRegistry.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Empostor.Api.Admin; +using Microsoft.Extensions.Logging; + +namespace Empostor.Server.Http.Admin; + +/// +/// Holds every registered (contributed by plugins via DI) +/// and builds their widget panels on demand. +/// +public sealed class AdminExtensionRegistry +{ + private readonly ILogger _logger; + private readonly IReadOnlyList _extensions; + + public AdminExtensionRegistry(IEnumerable extensions, ILogger logger) + { + _logger = logger; + _extensions = extensions + .OrderBy(e => e.Section, StringComparer.OrdinalIgnoreCase) + .ThenBy(e => e.Title, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public IReadOnlyList Extensions => _extensions; + + public IAdminExtension? Find(string id) + => _extensions.FirstOrDefault(e => string.Equals(e.Id, id, StringComparison.OrdinalIgnoreCase)); + + public AdminPanelBuilder Build(IAdminExtension extension) + { + var builder = new AdminPanelBuilder(extension.Id, extension.Title, extension.Icon, extension.Section); + try + { + extension.Build(builder); + } + catch (Exception ex) + { + _logger.LogError(ex, "Admin extension '{Id}' failed to build its panel", extension.Id); + } + + return builder; + } + + public AdminPanelBuilder? Build(string id) + { + var extension = Find(id); + return extension == null ? null : Build(extension); + } +} diff --git a/src/Empostor.Server/Http/Admin/AdminThemeRegistry.cs b/src/Empostor.Server/Http/Admin/AdminThemeRegistry.cs new file mode 100644 index 0000000..6872fac --- /dev/null +++ b/src/Empostor.Server/Http/Admin/AdminThemeRegistry.cs @@ -0,0 +1,249 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using Empostor.Api.Admin; +using Microsoft.Extensions.Logging; + +namespace Empostor.Server.Http.Admin; + +public sealed record AdminThemeInfo(string Id, string Name); + +/// +/// Resolves admin-panel themes. Themes come from compiled plugins +/// and from folders under Pages/themes/{Id}/theme.json. A theme may extend another via +/// ; tokens/css are inherited and overridden layer by layer. +/// +public sealed class AdminThemeRegistry +{ + private readonly ILogger _logger; + private readonly Dictionary _entries = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _resolved = new(StringComparer.OrdinalIgnoreCase); + + public AdminThemeRegistry(IEnumerable compiled, ILogger logger) + { + _logger = logger; + + Add("default", "Default", null, new AdminThemeDefinition()); + + foreach (var theme in compiled) + { + try + { + Add(theme.Id, theme.Name, theme.Extends, theme.Define()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Admin theme '{Id}' failed to define", theme.Id); + } + } + + LoadDiskThemes(); + Resolve(); + } + + public IReadOnlyList Themes + => _entries.Values + .OrderBy(e => e.Id == "default" ? 0 : 1) + .ThenBy(e => e.Id, StringComparer.OrdinalIgnoreCase) + .Select(e => new AdminThemeInfo(e.Id, e.Name)) + .ToList(); + + public bool Exists(string id) => _entries.ContainsKey(id); + + public AdminThemeDefinition? Resolve(string id) + => _resolved.TryGetValue(id, out var definition) ? definition : null; + + public string BuildCss(string id) + { + var definition = Resolve(id) ?? new AdminThemeDefinition(); + var sb = new StringBuilder(); + + if (definition.Tokens.Count > 0) + { + sb.Append(":root{"); + foreach (var (key, value) in definition.Tokens) + { + sb.Append(key).Append(':').Append(value).Append(';'); + } + + sb.Append('}'); + } + + if (definition.DarkTokens is { Count: > 0 }) + { + sb.Append("[data-theme=\"dark\"]{"); + foreach (var (key, value) in definition.DarkTokens) + { + sb.Append(key).Append(':').Append(value).Append(';'); + } + + sb.Append('}'); + } + + if (!string.IsNullOrEmpty(definition.CustomCss)) + { + sb.Append(definition.CustomCss); + } + + return sb.ToString(); + } + + private void Add(string id, string name, string? extends, AdminThemeDefinition definition) + { + _entries[id] = new ThemeEntry { Id = id, Name = name, Extends = extends, Definition = definition }; + } + + private void LoadDiskThemes() + { + var themesDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Pages", "themes"); + if (!Directory.Exists(themesDir)) + { + return; + } + + foreach (var dir in Directory.EnumerateDirectories(themesDir)) + { + var path = Path.Combine(dir, "theme.json"); + if (!File.Exists(path)) + { + continue; + } + + try + { + var json = File.ReadAllText(path); + var disk = JsonSerializer.Deserialize(json); + if (disk == null || string.IsNullOrWhiteSpace(disk.Id)) + { + continue; + } + + var definition = new AdminThemeDefinition + { + Tokens = disk.Tokens ?? new Dictionary(), + DarkTokens = disk.DarkTokens, + CustomCss = disk.CustomCss, + Layout = disk.Layout, + Components = disk.Components ?? new Dictionary(), + }; + + Add(disk.Id, disk.Name ?? disk.Id, disk.Extends, definition); + _logger.LogInformation("Loaded admin theme '{Id}' from {Path}", disk.Id, path); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load admin theme from {Path}", path); + } + } + } + + private void Resolve() + { + var resolving = new HashSet(StringComparer.OrdinalIgnoreCase); + + AdminThemeDefinition ResolveOne(string id) + { + if (_resolved.TryGetValue(id, out var cached)) + { + return cached; + } + + if (!_entries.TryGetValue(id, out var entry)) + { + return new AdminThemeDefinition(); + } + + if (!resolving.Add(id)) + { + _logger.LogWarning("Admin theme inheritance cycle detected at '{Id}'", id); + return entry.Definition; + } + + var merged = entry.Definition; + if (!string.IsNullOrWhiteSpace(entry.Extends)) + { + var parent = ResolveOne(entry.Extends); + merged = Merge(parent, entry.Definition); + } + + resolving.Remove(id); + _resolved[id] = merged; + return merged; + } + + foreach (var id in _entries.Keys.ToList()) + { + ResolveOne(id); + } + } + + private static AdminThemeDefinition Merge(AdminThemeDefinition parent, AdminThemeDefinition child) + { + var tokens = new Dictionary(parent.Tokens); + foreach (var (key, value) in child.Tokens) + { + tokens[key] = value; + } + + var darkTokens = new Dictionary(parent.DarkTokens ?? new Dictionary()); + if (child.DarkTokens != null) + { + foreach (var (key, value) in child.DarkTokens) + { + darkTokens[key] = value; + } + } + + var components = new Dictionary(parent.Components); + foreach (var (key, value) in child.Components) + { + components[key] = value; + } + + var customCss = string.IsNullOrEmpty(parent.CustomCss) + ? child.CustomCss + : parent.CustomCss + "\n" + child.CustomCss; + + return new AdminThemeDefinition + { + Tokens = tokens, + DarkTokens = darkTokens, + CustomCss = customCss, + Layout = child.Layout ?? parent.Layout, + Components = components, + }; + } + + private sealed class ThemeEntry + { + public string Id { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public string? Extends { get; set; } + + public AdminThemeDefinition Definition { get; set; } = new(); + } + + private sealed class DiskTheme + { + public string? Id { get; set; } + + public string? Name { get; set; } + + public string? Extends { get; set; } + + public Dictionary? Tokens { get; set; } + + public Dictionary? DarkTokens { get; set; } + + public string? CustomCss { get; set; } + + public string? Layout { get; set; } + + public Dictionary? Components { get; set; } + } +} diff --git a/src/Empostor.Server/Http/AdminController.cs b/src/Empostor.Server/Http/AdminController.cs index 98a0f20..6225bc4 100644 --- a/src/Empostor.Server/Http/AdminController.cs +++ b/src/Empostor.Server/Http/AdminController.cs @@ -9,13 +9,14 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Threading.Tasks; +using Empostor.Api.Admin; using Empostor.Api.Config; using Empostor.Api.Games; using Empostor.Api.Games.Managers; using Empostor.Api.Net; using Empostor.Api.Net.Manager; +using Empostor.Server.Http.Admin; using Empostor.Server.Service.Admin.Ban; -using Empostor.Server.Service.Admin.Chat; using Empostor.Server.Service.Admin.Reactor; using Empostor.Server.Service.Admin.Report; using Empostor.Server.Service.Api; @@ -45,14 +46,12 @@ namespace Empostor.Server.Http private readonly AdminConfig _config; private readonly ReportStore _reportStore; private readonly PlayerLogStore _playerLogs; - private readonly PlayerStatsStore _playerStats; - private readonly PlayerStatsConfig _statsConfig; - private readonly ChatFilterStore _chatFilter; - private readonly DiscordWebhookStore _discordWebhook; private readonly HplpStore _hplpStore; private readonly IOptions _serverConfig; private readonly IOptions _httpServerConfig; private readonly IpGeolocationService _ipGeo; + private readonly AdminExtensionRegistry _extensions; + private readonly AdminThemeRegistry _themes; private readonly string _passwordHash; private static readonly ConcurrentDictionary _loginFailures = new(); @@ -64,14 +63,12 @@ namespace Empostor.Server.Http IOptions config, ReportStore reportStore, PlayerLogStore playerLogs, - PlayerStatsStore playerStats, - IOptions statsConfig, - ChatFilterStore chatFilter, - DiscordWebhookStore discordWebhook, HplpStore hplpStore, IOptions serverConfig, IOptions httpServerConfig, - IpGeolocationService ipGeo) + IpGeolocationService ipGeo, + AdminExtensionRegistry extensions, + AdminThemeRegistry themes) { _logger = logger; _gameManager = gameManager; @@ -80,14 +77,12 @@ namespace Empostor.Server.Http _config = config.Value; _reportStore = reportStore; _playerLogs = playerLogs; - _playerStats = playerStats; - _statsConfig = statsConfig.Value; - _chatFilter = chatFilter; - _discordWebhook = discordWebhook; _hplpStore = hplpStore; _serverConfig = serverConfig; _httpServerConfig = httpServerConfig; _ipGeo = ipGeo; + _extensions = extensions; + _themes = themes; _passwordHash = ComputeHash(_config.Password); if (string.IsNullOrEmpty(_config.Password) @@ -202,6 +197,135 @@ namespace Empostor.Server.Http return Ok(strings); } + [HttpGet("/api/admin/ext")] + public IActionResult GetExtensions() + { + if (!IsAuthenticated()) + { + return Unauthorized(); + } + + return Ok(_extensions.Extensions.Select(e => new + { + id = e.Id, + title = e.Title, + icon = e.Icon, + section = e.Section, + })); + } + + [HttpGet("/api/admin/ext/{id}")] + public IActionResult GetExtension(string id) + { + if (!IsAuthenticated()) + { + return Unauthorized(); + } + + var builder = _extensions.Build(id); + if (builder == null) + { + return NotFound(Err("Extension not found")); + } + + return Ok(new + { + id = builder.ExtensionId, + title = builder.Title, + icon = builder.Icon, + section = builder.Section, + widgets = builder.Widgets, + }); + } + + [HttpPost("/api/admin/ext/{id}/{action}")] + public async Task InvokeExtension(string id, string action) + { + if (!IsAuthenticated()) + { + return Unauthorized(); + } + + var builder = _extensions.Build(id); + if (builder == null) + { + return NotFound(Err("Extension not found")); + } + + if (!builder.Actions.TryGetValue(action, out var handler)) + { + return NotFound(Err("Action not found")); + } + + string? value = null; + JsonElement? payload = null; + try + { + using var doc = await JsonDocument.ParseAsync(Request.Body); + payload = doc.RootElement.Clone(); + if (payload.Value.ValueKind == JsonValueKind.Object + && payload.Value.TryGetProperty("value", out var raw)) + { + value = raw.ValueKind switch + { + JsonValueKind.String => raw.GetString(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Number => raw.GetRawText(), + _ => null, + }; + } + } + catch (JsonException) + { + // No/invalid body; the action still runs without a value. + } + + var context = new AdminActionContext(builder.ExtensionId, action, value, payload, HttpContext.RequestServices); + try + { + var result = await handler(context); + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Admin extension action '{Id}/{Action}' failed", id, action); + return Ok(AdminActionResult.Fail($"Action failed: {ex.Message}")); + } + } + + [HttpGet("/api/admin/themes")] + public IActionResult GetThemes() + { + if (!IsAuthenticated()) + { + return Unauthorized(); + } + + return Ok(new + { + themes = _themes.Themes, + @default = _config.Theme, + defaultMode = _config.ThemeMode, + }); + } + + [HttpGet("/api/admin/theme/{id}/css")] + public IActionResult GetThemeCss(string id) + { + if (!IsAuthenticated()) + { + return Unauthorized(); + } + + if (!_themes.Exists(id)) + { + return NotFound(); + } + + return Content(_themes.BuildCss(id), "text/css; charset=utf-8"); + } + [HttpGet("/api/admin/status")] public IActionResult Status() { @@ -607,217 +731,6 @@ namespace Empostor.Server.Http return File(data, "application/json; charset=utf-8", name); } - [HttpGet("/api/admin/player/stats")] - public IActionResult GetPlayerStats() - { - if (!IsAuthenticated()) - { - return Unauthorized(); - } - - if (!_statsConfig.Enabled) - { - return Ok(new { enabled = false }); - } - - return Ok(new - { - enabled = true, - players = _playerStats.GetAll().Select(s => new - { - friendCode = s.FriendCode, - name = s.LastKnownName ?? "—", - gamesPlayed = s.GamesPlayed, - wins = s.Wins, - losses = s.Losses, - impostorWins = s.ImpostorWins, - kills = s.Kills, - deaths = s.Deaths, - tasksCompleted = s.TasksCompleted, - timesExiled = s.TimesExiled, - firstSeen = s.FirstSeen.ToString("yyyy-MM-dd HH:mm:ss"), - lastSeen = s.LastSeen.ToString("yyyy-MM-dd HH:mm:ss"), - }), - }); - } - - [HttpGet("/api/admin/player/stats/{friendCode}")] - public IActionResult GetPlayerStat(string friendCode) - { - if (!IsAuthenticated()) - { - return Unauthorized(); - } - - var s = _playerStats.GetByFriendCode(friendCode); - if (s == null) - { - return NotFound(Err("Player not found")); - } - - return Ok(new - { - friendCode = s.FriendCode, - name = s.LastKnownName ?? "—", - gamesPlayed = s.GamesPlayed, - wins = s.Wins, - losses = s.Losses, - impostorWins = s.ImpostorWins, - kills = s.Kills, - deaths = s.Deaths, - tasksCompleted = s.TasksCompleted, - timesExiled = s.TimesExiled, - firstSeen = s.FirstSeen.ToString("yyyy-MM-dd HH:mm:ss"), - lastSeen = s.LastSeen.ToString("yyyy-MM-dd HH:mm:ss"), - }); - } - - [HttpPost("/api/admin/player/stats/reset")] - public IActionResult ResetPlayerStats() - { - if (!IsAuthenticated()) - { - return Unauthorized(); - } - - _playerStats.ClearAll(); - return Ok(new { reset = true }); - } - - [HttpGet("/api/admin/chatfilter")] - public IActionResult GetChatFilter() - { - if (!IsAuthenticated()) - { - return Unauthorized(); - } - - return Ok(new - { - enabled = _chatFilter.Enabled, - blockedWords = _chatFilter.BlockedWords, - blockMessage = _chatFilter.BlockMessage, - spamThreshold = _chatFilter.SpamThreshold, - spamWindowSeconds = _chatFilter.SpamWindowSeconds, - }); - } - - [HttpPost("/api/admin/chatfilter/words/add")] - public IActionResult AddChatFilterWord([FromBody] ChatFilterWordReq req) - { - if (!IsAuthenticated()) - { - return Unauthorized(); - } - - if (string.IsNullOrWhiteSpace(req.Word)) - { - return BadRequest(Err("Word required")); - } - - _chatFilter.AddWord(req.Word); - return Ok(new { blockedWords = _chatFilter.BlockedWords }); - } - - [HttpPost("/api/admin/chatfilter/words/remove")] - public IActionResult RemoveChatFilterWord([FromBody] ChatFilterWordReq req) - { - if (!IsAuthenticated()) - { - return Unauthorized(); - } - - if (string.IsNullOrWhiteSpace(req.Word)) - { - return BadRequest(Err("Word required")); - } - - _chatFilter.RemoveWord(req.Word); - return Ok(new { blockedWords = _chatFilter.BlockedWords }); - } - - [HttpPost("/api/admin/chatfilter/settings")] - public async Task UpdateChatFilterSettings([FromBody] ChatFilterSettingsReq req) - { - if (!IsAuthenticated()) - { - return Unauthorized(); - } - - if (req.Enabled.HasValue) - { - _chatFilter.Enabled = req.Enabled.Value; - } - - if (req.BlockMessage.HasValue) - { - _chatFilter.BlockMessage = req.BlockMessage.Value; - } - - if (req.SpamThreshold.HasValue) - { - _chatFilter.SpamThreshold = req.SpamThreshold.Value; - } - - if (req.SpamWindowSeconds.HasValue) - { - _chatFilter.SpamWindowSeconds = req.SpamWindowSeconds.Value; - } - - await _chatFilter.SaveAsync(); - - return Ok(new - { - enabled = _chatFilter.Enabled, - blockedWords = _chatFilter.BlockedWords, - blockMessage = _chatFilter.BlockMessage, - spamThreshold = _chatFilter.SpamThreshold, - spamWindowSeconds = _chatFilter.SpamWindowSeconds, - }); - } - - [HttpGet("/api/admin/discord")] - public IActionResult GetDiscordWebhook() - { - if (!IsAuthenticated()) - { - return Unauthorized(); - } - - return Ok(new - { - matchmakerUrl = _discordWebhook.MatchmakerUrl, - adminUrl = _discordWebhook.AdminUrl, - }); - } - - [HttpPost("/api/admin/discord")] - public async Task UpdateDiscordWebhook([FromBody] DiscordWebhookSettingsReq req) - { - if (!IsAuthenticated()) - { - return Unauthorized(); - } - - if (req.MatchmakerUrl != null) - { - _discordWebhook.MatchmakerUrl = req.MatchmakerUrl; - } - - if (req.AdminUrl != null) - { - _discordWebhook.AdminUrl = req.AdminUrl; - } - - await _discordWebhook.SaveAsync(); - - return Ok(new - { - matchmakerUrl = _discordWebhook.MatchmakerUrl, - adminUrl = _discordWebhook.AdminUrl, - }); - } - [HttpGet("/api/admin/hplp")] public IActionResult GetHplp() { @@ -998,12 +911,6 @@ namespace Empostor.Server.Http public sealed record GamePublicReq(string GameCode, bool IsPublic); - public sealed record ChatFilterWordReq(string Word); - - public sealed record ChatFilterSettingsReq(bool? Enabled, bool? BlockMessage, int? SpamThreshold, int? SpamWindowSeconds); - - public sealed record DiscordWebhookSettingsReq(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. diff --git a/src/Empostor.Server/Http/AdminTemplateDefaults.cs b/src/Empostor.Server/Http/AdminTemplateDefaults.cs index 022e9b9..44fcbc7 100644 --- a/src/Empostor.Server/Http/AdminTemplateDefaults.cs +++ b/src/Empostor.Server/Http/AdminTemplateDefaults.cs @@ -160,6 +160,23 @@ internal static class AdminTemplateDefaults Empostor Admin