Please disclose if any significant portion of your mod was created using AI tools by adding the 'AI Generated' category. Failing to do so may result in the mod being removed from Thunderstore.
Decompiled source of Bigfrost ServerPortal v1.1.0
BepInEx\plugins\Bigfrost_ServerPortal\Bigfrost_ServerPortal.dll
Decompiled 2 months ago
The result has been truncated due to the large size, download it to view full contents!
using System; using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Reflection; using System.Runtime.CompilerServices; using System.Security; using System.Security.Cryptography; using System.Security.Permissions; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using BepInEx; using BepInEx.Bootstrap; using BepInEx.Configuration; using BepInEx.Logging; using Bifrostheim.Helpers; using Bifrostheim.Systems.Discord; using Bifrostheim.Systems.Web; using HarmonyLib; using Microsoft.CodeAnalysis; using UnityEngine; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: SecurityPermission(SecurityAction.RequestMinimum, SkipVerification = true)] [assembly: AssemblyVersion("0.0.0.0")] [module: UnverifiableCode] [module: RefSafetyRules(11)] namespace Microsoft.CodeAnalysis { [CompilerGenerated] [Embedded] internal sealed class EmbeddedAttribute : Attribute { } } namespace System.Runtime.CompilerServices { [CompilerGenerated] [Embedded] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Event | AttributeTargets.Parameter | AttributeTargets.ReturnValue | AttributeTargets.GenericParameter, AllowMultiple = false, Inherited = false)] internal sealed class NullableAttribute : Attribute { public readonly byte[] NullableFlags; public NullableAttribute(byte P_0) { NullableFlags = new byte[1] { P_0 }; } public NullableAttribute(byte[] P_0) { NullableFlags = P_0; } } [CompilerGenerated] [Embedded] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Method | AttributeTargets.Interface | AttributeTargets.Delegate, AllowMultiple = false, Inherited = false)] internal sealed class NullableContextAttribute : Attribute { public readonly byte Flag; public NullableContextAttribute(byte P_0) { Flag = P_0; } } [CompilerGenerated] [Embedded] [AttributeUsage(AttributeTargets.Module, AllowMultiple = false, Inherited = false)] internal sealed class RefSafetyRulesAttribute : Attribute { public readonly int Version; public RefSafetyRulesAttribute(int P_0) { Version = P_0; } } } namespace Bifrostheim { [BepInPlugin("com.bigai.bigfrost_serverportal", "Bigfrost_ServerPortal", "1.1.0")] public class BifrostheimPlugin : BaseUnityPlugin { public const string PluginGUID = "com.bigai.bigfrost_serverportal"; public const string PluginName = "Bigfrost_ServerPortal"; public const string PluginVersion = "1.1.0"; public static ConfigEntry<bool> EnableWebPortal = null; public static ConfigEntry<int> WebPortalPort = null; internal static ConfigEntry<string> WebAdminPassword = null; public static ConfigEntry<bool> VerboseLogging = null; public static ConfigEntry<float> PlayerHealthPollInterval = null; public static ConfigEntry<string> LifecycleRestartMode = null; public static ConfigEntry<string> LifecycleScriptPath = null; public static ConfigEntry<bool> DailyRestartEnabled = null; public static ConfigEntry<string> DailyRestartTime = null; public static ConfigEntry<bool> DiscordEnabled = null; public static ConfigEntry<string> DiscordWebhookUrl = null; public static ConfigEntry<string> DiscordOverrideChatWebhookUrl = null; public static ConfigEntry<string> DiscordOverrideAdminWebhookUrl = null; public static ConfigEntry<string> DiscordBotUsername = null; public static ConfigEntry<string> DiscordBotAvatarUrl = null; public static ConfigEntry<bool> DiscordUseRichEmbeds = null; public static ConfigEntry<bool> DiscordNotifyPlayerJoin = null; public static ConfigEntry<bool> DiscordNotifyPlayerLeave = null; public static ConfigEntry<bool> DiscordNotifyPlayerDeath = null; public static ConfigEntry<bool> DiscordNotifyServerLifecycle = null; public static ConfigEntry<bool> DiscordNotifyWorldEvents = null; public static ConfigEntry<bool> DiscordNotifyBossMilestones = null; public static ConfigEntry<bool> DiscordNotifyAdminActions = null; public static ConfigEntry<bool> DiscordNotifyChatShouts = null; private static float _fpsAccumulator = 60f; private static bool _isClient; private static BepInExLogListener? _logListener; public static BifrostheimPlugin Instance { get; private set; } = null; public static ManualLogSource Log { get; private set; } = null; public static int CurrentFps { get; private set; } = 60; private void Awake() { //IL_00f4: Unknown result type (might be due to invalid IL or missing references) //IL_00fe: Expected O, but got Unknown //IL_0383: Unknown result type (might be due to invalid IL or missing references) //IL_0389: Invalid comparison between Unknown and I4 //IL_03a0: Unknown result type (might be due to invalid IL or missing references) Instance = this; Log = ((BaseUnityPlugin)this).Logger; try { Log.LogInfo((object)"══════════════════════════════════════════"); Log.LogInfo((object)" Bigfrost_ServerPortal v1.1.0 loading..."); Log.LogInfo((object)"══════════════════════════════════════════"); EnableWebPortal = ((BaseUnityPlugin)this).Config.Bind<bool>("WebPortal", "EnableWebPortal", true, "Enable the embedded web management portal."); WebPortalPort = ((BaseUnityPlugin)this).Config.Bind<int>("WebPortal", "WebPortalPort", 8080, "Port for the embedded web management portal."); WebAdminPassword = ((BaseUnityPlugin)this).Config.Bind<string>("WebPortal", "WebAdminPassword", string.Empty, "Password required for administrative actions in the web portal."); VerboseLogging = ((BaseUnityPlugin)this).Config.Bind<bool>("General", "VerboseLogging", false, "Enable verbose logging in BepInEx console."); PlayerHealthPollInterval = ((BaseUnityPlugin)this).Config.Bind<float>("General", "PlayerHealthPollInterval", 1f, new ConfigDescription("Interval in seconds between player health and death monitoring polls. Default is 1.0s.", (AcceptableValueBase)(object)new AcceptableValueRange<float>(0.2f, 5f), Array.Empty<object>())); LifecycleRestartMode = ((BaseUnityPlugin)this).Config.Bind<string>("Lifecycle", "RestartMode", "ExitOnly", "Server restart strategy: ExitOnly or SpawnProcess."); LifecycleScriptPath = ((BaseUnityPlugin)this).Config.Bind<string>("Lifecycle", "RestartScriptPath", "./start_server.sh", "Path to external restart script when RestartMode is SpawnProcess."); DailyRestartEnabled = ((BaseUnityPlugin)this).Config.Bind<bool>("Lifecycle", "DailyRestartEnabled", false, "Enable automated daily server restart."); DailyRestartTime = ((BaseUnityPlugin)this).Config.Bind<string>("Lifecycle", "DailyRestartTime", "04:00", "Daily restart time in 24h format (HH:mm)."); DiscordEnabled = ((BaseUnityPlugin)this).Config.Bind<bool>("DiscordWebhook", "Enabled", false, "Enable Discord Webhook notifications."); DiscordWebhookUrl = ((BaseUnityPlugin)this).Config.Bind<string>("DiscordWebhook", "WebhookUrl", string.Empty, "Primary Discord Webhook URL for server notifications."); DiscordOverrideChatWebhookUrl = ((BaseUnityPlugin)this).Config.Bind<string>("DiscordWebhook", "OverrideChatWebhookUrl", string.Empty, "Optional dedicated webhook URL for in-game chat/shout relay."); DiscordOverrideAdminWebhookUrl = ((BaseUnityPlugin)this).Config.Bind<string>("DiscordWebhook", "OverrideAdminWebhookUrl", string.Empty, "Optional dedicated webhook URL for admin punishment alerts."); DiscordBotUsername = ((BaseUnityPlugin)this).Config.Bind<string>("DiscordWebhook", "BotUsername", "Bifrostheim Herald", "Display name used for Discord webhook messages."); DiscordBotAvatarUrl = ((BaseUnityPlugin)this).Config.Bind<string>("DiscordWebhook", "BotAvatarUrl", string.Empty, "Avatar image URL used for Discord webhook messages."); DiscordUseRichEmbeds = ((BaseUnityPlugin)this).Config.Bind<bool>("DiscordWebhook", "UseRichEmbeds", true, "Send rich Discord embeds (set to false for minimalist markdown plain text)."); DiscordNotifyPlayerJoin = ((BaseUnityPlugin)this).Config.Bind<bool>("DiscordWebhook", "NotifyPlayerJoin", true, "Send notification when a player joins."); DiscordNotifyPlayerLeave = ((BaseUnityPlugin)this).Config.Bind<bool>("DiscordWebhook", "NotifyPlayerLeave", true, "Send notification when a player leaves."); DiscordNotifyPlayerDeath = ((BaseUnityPlugin)this).Config.Bind<bool>("DiscordWebhook", "NotifyPlayerDeath", true, "Send notification when a player dies or is slain in PvP."); DiscordNotifyServerLifecycle = ((BaseUnityPlugin)this).Config.Bind<bool>("DiscordWebhook", "NotifyServerLifecycle", true, "Send notification on server startup, shutdown, and restart warnings."); DiscordNotifyWorldEvents = ((BaseUnityPlugin)this).Config.Bind<bool>("DiscordWebhook", "NotifyWorldEvents", true, "Send notification when raids start and end."); DiscordNotifyBossMilestones = ((BaseUnityPlugin)this).Config.Bind<bool>("DiscordWebhook", "NotifyBossMilestones", true, "Send notification when a Forsaken boss is summoned or defeated."); DiscordNotifyAdminActions = ((BaseUnityPlugin)this).Config.Bind<bool>("DiscordWebhook", "NotifyAdminActions", true, "Send notification when admin kicks or bans a player."); DiscordNotifyChatShouts = ((BaseUnityPlugin)this).Config.Bind<bool>("DiscordWebhook", "NotifyChatShouts", false, "Relay in-game /s shouts to Discord."); if ((int)SystemInfo.graphicsDeviceType != 4) { _isClient = true; Log.LogWarning((object)(string.Format("[{0}] Client environment detected (GraphicsDevice: {1}). ", "Bigfrost_ServerPortal", SystemInfo.graphicsDeviceType) + "Bigfrost_ServerPortal is designed exclusively for Valheim dedicated servers. The embedded web management server will not be started on client instances.")); return; } if (string.IsNullOrWhiteSpace(WebAdminPassword.Value) || string.Equals(WebAdminPassword.Value.Trim(), "admin", StringComparison.OrdinalIgnoreCase)) { string value = GenerateRandomPassword(); WebAdminPassword.Value = value; ((BaseUnityPlugin)this).Config.Save(); Log.LogWarning((object)"[Bigfrost_ServerPortal] Generated new secure random admin password. Please check configuration file."); } Log.LogInfo((object)"[Bigfrost_ServerPortal] Web Portal active. Admin password is configured in BepInEx/config/com.bigai.bigfrost_serverportal.cfg [WebPortal.WebAdminPassword]"); ConfigSyncManager.OnlinePlayerChecker = ZNetHelper.IsPlayerOnline; _logListener = new BepInExLogListener(); Logger.Listeners.Add((ILogListener)(object)_logListener); DiscordWebhookDispatcher.Initialize(); DiscordEventPatches.ApplyPatches(); if (EnableWebPortal.Value) { WebPortalServer.Start(WebPortalPort.Value, WebAdminPassword.Value); } Log.LogInfo((object)"[Bigfrost_ServerPortal] Initialized successfully."); } catch (Exception arg) { Log.LogError((object)string.Format("[{0}] Failed to initialize: {1}", "Bigfrost_ServerPortal", arg)); } } private void Update() { if (!_isClient) { float unscaledDeltaTime = Time.unscaledDeltaTime; if (unscaledDeltaTime > 0.0001f) { _fpsAccumulator += (1f / unscaledDeltaTime - _fpsAccumulator) * 0.1f; CurrentFps = (int)Math.Round(_fpsAccumulator); } MainThreadDispatcher.PumpQueue(); WebApiRouter.TickLifecycle(); DiscordEventPatches.TickPlayerHealthMonitoring(unscaledDeltaTime); } } private void OnDestroy() { if (!_isClient) { if (DiscordEnabled != null && DiscordEnabled.Value) { DiscordWebhookDispatcher.OnServerLifecycle("\ud83d\uded1 Server Shutting Down", "The dedicated server is shutting down. Safe travels!", 15158332); } DiscordEventPatches.RemovePatches(); DiscordWebhookDispatcher.Shutdown(); WebPortalServer.Stop(); if (_logListener != null) { Logger.Listeners.Remove((ILogListener)(object)_logListener); _logListener.Dispose(); _logListener = null; } Log.LogInfo((object)"[Bigfrost_ServerPortal] Unloaded."); } } private static string GenerateRandomPassword(int length = 16) { int num = 256 / "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789".Length * "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789".Length; char[] array = new char[length]; byte[] array2 = new byte[length * 2]; int num2 = array2.Length; using (RandomNumberGenerator randomNumberGenerator = RandomNumberGenerator.Create()) { for (int i = 0; i < length; i++) { byte b; do { if (num2 >= array2.Length) { randomNumberGenerator.GetBytes(array2); num2 = 0; } b = array2[num2++]; } while (b >= num); array[i] = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"[b % "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789".Length]; } } return new string(array); } } } namespace Bifrostheim.Systems.Web { public static class WebApiRouter { private const int MaxRequestBodySizeBytes = 1048576; private static readonly HashSet<string> _blockedConsoleCommands = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "devcommands", "debugmode", "god", "ghost", "dpspath", "resetwind", "killall", "spawn", "fly", "nocost", "raiseskill" }; private static readonly DateTime StartTime = DateTime.UtcNow; private static readonly LinkedList<ConsoleLogEntry> LogsBuffer = new LinkedList<ConsoleLogEntry>(); private static readonly object LogLock = new object(); private static readonly ConcurrentDictionary<string, (int attempts, DateTime lockoutUntil)> _loginRateLimits = new ConcurrentDictionary<string, (int, DateTime)>(StringComparer.OrdinalIgnoreCase); private static readonly ConcurrentDictionary<string, DateTime> _activeSessions = new ConcurrentDictionary<string, DateTime>(StringComparer.Ordinal); private static ScheduledRestartState _scheduledRestart = new ScheduledRestartState(); private static DailyRestartState _dailyRestart = new DailyRestartState(); private static LifecycleConfigState _lifecycleConfig = new LifecycleConfigState(); private static readonly object _restartLock = new object(); private static DateTime _lastLifecycleTick = DateTime.MinValue; private static readonly HashSet<int> _restartWarningsSent = new HashSet<int>(); private static string _lastDailyRestartDate = string.Empty; private static bool _isExecutingRestart = false; private static readonly List<PendingConfigChange> _pendingChanges = new List<PendingConfigChange>(); private static readonly object _pendingLock = new object(); private static readonly List<SkaldDeathRecordDto> _skaldChronicle = new List<SkaldDeathRecordDto>(); private static readonly object _skaldLock = new object(); [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] private static bool ConstantTimeEquals(string? a, string? b) { if (a == null || b == null) { return false; } byte[] bytes = Encoding.UTF8.GetBytes(a); byte[] bytes2 = Encoding.UTF8.GetBytes(b); using SHA256 sHA = SHA256.Create(); byte[] array = sHA.ComputeHash(bytes); byte[] array2 = sHA.ComputeHash(bytes2); int num = 0; for (int i = 0; i < array.Length; i++) { num |= array[i] ^ array2[i]; } return num == 0; } public static bool IsAllowedOrigin(string? origin, HttpListenerRequest request) { if (string.IsNullOrWhiteSpace(origin)) { return false; } if (!Uri.TryCreate(origin, UriKind.Absolute, out Uri result) || result == null) { return false; } if (result.IsLoopback || string.Equals(result.Host, "localhost", StringComparison.OrdinalIgnoreCase) || string.Equals(result.Host, "127.0.0.1", StringComparison.OrdinalIgnoreCase) || string.Equals(result.Host, "::1", StringComparison.OrdinalIgnoreCase)) { return true; } string text = request.Headers["Host"]; if (!string.IsNullOrWhiteSpace(text)) { string text2 = text; int num = text2.IndexOf(':'); int result2 = -1; if (num >= 0) { int.TryParse(text2.Substring(num + 1), out result2); text2 = text2.Substring(0, num); } if (string.Equals(result.Host, text2, StringComparison.OrdinalIgnoreCase) && (result2 <= 0 || result.Port == result2)) { return true; } } if (request.Url != null && string.Equals(result.Host, request.Url.Host, StringComparison.OrdinalIgnoreCase) && result.Port == request.Url.Port) { return true; } return false; } public static void RecordPendingChange(string module, string moduleName) { lock (_pendingLock) { _pendingChanges.RemoveAll((PendingConfigChange c) => c.module.Equals(module, StringComparison.OrdinalIgnoreCase)); _pendingChanges.Add(new PendingConfigChange { module = module, moduleName = moduleName, timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss") }); } } public static void ClearPendingChanges() { lock (_pendingLock) { _pendingChanges.Clear(); } } public static void AddLog(string level, string source, string text) { lock (LogLock) { LogsBuffer.AddLast(new ConsoleLogEntry { time = DateTime.Now.ToString("HH:mm:ss"), source = source, text = text, level = level }); while (LogsBuffer.Count > 300) { LogsBuffer.RemoveFirst(); } } } public static async Task HandleApiRequestAsync(HttpListenerContext context, string path, string clientIp) { HttpListenerRequest request = context.Request; HttpListenerResponse response = context.Response; string text = request.HttpMethod.ToUpperInvariant(); response.ContentType = "application/json; charset=utf-8"; string text2 = request.Headers["Origin"]; if (!string.IsNullOrEmpty(text2) && IsAllowedOrigin(text2, request)) { response.AddHeader("Access-Control-Allow-Origin", text2); response.AddHeader("Vary", "Origin"); } try { if (path.Equals("/api/auth/login", StringComparison.OrdinalIgnoreCase) && text == "POST") { await HandleAuthLogin(request, response, clientIp); return; } if (path.Equals("/api/auth/verify", StringComparison.OrdinalIgnoreCase) && text == "GET") { await HandleAuthVerify(request, response); return; } if (path.Equals("/api/auth/status", StringComparison.OrdinalIgnoreCase) && text == "GET") { await HandleAuthStatus(request, response); return; } if (!IsAuthorized(request)) { AddLog("warn", "AUTH", "Unauthorized " + text + " request to " + path + " from " + clientIp + "."); await SendJsonAsync(response, 401, new { success = false, error = "Unauthorized: Admin password required." }); return; } if (path.Equals("/api/modules/installed", StringComparison.OrdinalIgnoreCase)) { await HandleGetInstalledModules(response); return; } if (path.Equals("/api/server/telemetry", StringComparison.OrdinalIgnoreCase)) { await HandleGetTelemetry(response); return; } if (path.Equals("/api/players", StringComparison.OrdinalIgnoreCase) && text == "GET") { await HandleGetPlayers(response); return; } if (path.Equals("/api/players/kick", StringComparison.OrdinalIgnoreCase) && text == "POST") { await HandleKickPlayer(request, response, clientIp); return; } if (path.Equals("/api/players/ban", StringComparison.OrdinalIgnoreCase) && text == "POST") { await HandleBanPlayer(request, response, clientIp); return; } if (path.Equals("/api/bans", StringComparison.OrdinalIgnoreCase) && text == "GET") { await HandleGetBans(response); return; } if (path.Equals("/api/bans/unban", StringComparison.OrdinalIgnoreCase) && text == "POST") { await HandleUnbanPlayer(request, response, clientIp); return; } if (path.Equals("/api/bans/add", StringComparison.OrdinalIgnoreCase) && text == "POST") { await HandleAddBan(request, response, clientIp); return; } if (path.Equals("/api/console/logs", StringComparison.OrdinalIgnoreCase) && text == "GET") { await HandleGetLogs(response); return; } if (path.Equals("/api/console/exec", StringComparison.OrdinalIgnoreCase) && text == "POST") { await HandleExecCommand(request, response, clientIp); return; } if (path.Equals("/api/server/save", StringComparison.OrdinalIgnoreCase) && text == "POST") { await HandleForceSave(response, clientIp); return; } if (path.Equals("/api/server/broadcast", StringComparison.OrdinalIgnoreCase) && text == "POST") { await HandleBroadcast(request, response, clientIp); return; } if (path.Equals("/api/server/restart-status", StringComparison.OrdinalIgnoreCase) && text == "GET") { await HandleGetRestartStatus(response); return; } if (path.Equals("/api/server/schedule-restart", StringComparison.OrdinalIgnoreCase) && text == "POST") { await HandleScheduleRestart(request, response, clientIp); return; } if (path.Equals("/api/server/cancel-restart", StringComparison.OrdinalIgnoreCase) && text == "POST") { await HandleCancelRestart(response, clientIp); return; } if (path.Equals("/api/server/daily-restart", StringComparison.OrdinalIgnoreCase) && text == "POST") { await HandleUpdateDailyRestart(request, response, clientIp); return; } if (path.Equals("/api/server/pending-changes", StringComparison.OrdinalIgnoreCase) && text == "GET") { await HandleGetPendingChanges(response); return; } if (path.Equals("/api/server/clear-pending-changes", StringComparison.OrdinalIgnoreCase) && text == "POST") { await HandleClearPendingChanges(response, clientIp); return; } if (path.Equals("/api/server/lifecycle-config", StringComparison.OrdinalIgnoreCase)) { if (text == "GET") { await HandleGetLifecycleConfig(response); } else if (text == "POST") { await HandleSaveLifecycleConfig(request, response, clientIp); } return; } if (path.Equals("/api/discord/config", StringComparison.OrdinalIgnoreCase)) { if (text == "GET") { await HandleGetDiscordConfig(response); } else if (text == "POST") { await HandleSaveDiscordConfig(request, response, clientIp); } return; } if (path.Equals("/api/discord/test", StringComparison.OrdinalIgnoreCase) && text == "POST") { await HandleTestDiscordWebhook(request, response, clientIp); return; } if (path.Equals("/api/modules/charactervault/bindings", StringComparison.OrdinalIgnoreCase) && text == "GET") { await HandleGetCharacterBindings(response); return; } if (path.Equals("/api/modules/charactervault/unbind", StringComparison.OrdinalIgnoreCase) && text == "POST") { await HandleUnbindCharacter(request, response, clientIp); return; } if (path.Equals("/api/modules/charactervault/wipe", StringComparison.OrdinalIgnoreCase) && text == "POST") { await HandleWipeCharacters(response, clientIp); return; } if (path.Equals("/api/modules/valgrind/config", StringComparison.OrdinalIgnoreCase)) { if (text == "GET") { await HandleGetValgrindConfig(response); } else if (text == "POST") { await HandleSaveValgrindConfig(request, response, clientIp); } return; } if (path.Equals("/api/modules/dagrnott/config", StringComparison.OrdinalIgnoreCase)) { if (text == "GET") { await HandleGetDagrNottConfig(response); } else if (text == "POST") { await HandleSaveDagrNottConfig(request, response, clientIp); } return; } if (path.Equals("/api/modules/skald/config", StringComparison.OrdinalIgnoreCase)) { if (text == "GET") { await HandleGetSkaldConfig(response); } else if (text == "POST") { await HandleSaveSkaldConfig(request, response, clientIp); } return; } if (path.Equals("/api/modules/skald/chronicle", StringComparison.OrdinalIgnoreCase) && text == "GET") { await HandleGetSkaldChronicle(response); return; } if (path.Equals("/api/modules/skald/test-death", StringComparison.OrdinalIgnoreCase) && text == "POST") { await HandleTestDeathAnnouncement(request, response, clientIp); return; } if (path.Equals("/api/modules/njoror/config", StringComparison.OrdinalIgnoreCase)) { if (text == "GET") { await HandleGetNjororConfig(response); } else if (text == "POST") { await HandleSaveNjororConfig(request, response, clientIp); } return; } if (path.Equals("/api/other-mods/list", StringComparison.OrdinalIgnoreCase) && text == "GET") { await HandleGetOtherModsList(response); return; } if (path.Equals("/api/other-mods/config", StringComparison.OrdinalIgnoreCase) && text == "GET") { await HandleGetOtherModConfig(request, response); return; } if (path.Equals("/api/other-mods/config/save", StringComparison.OrdinalIgnoreCase) && text == "POST") { await HandleSaveOtherModConfig(request, response, clientIp); return; } if (path.Equals("/api/other-mods/config/reset-defaults", StringComparison.OrdinalIgnoreCase) && text == "POST") { await HandleResetOtherModDefaults(request, response, clientIp); return; } await SendJsonAsync(response, 404, new { error = "API endpoint '" + path + "' not found." }); } catch (InvalidOperationException ex) when (ex.Message.Contains("maximum allowed size")) { AddLog("warn", "SECURITY", "Request body size limit exceeded on '" + path + "' from " + clientIp + ": " + ex.Message); await SendJsonAsync(response, 413, new { error = ex.Message }); } catch (Exception ex2) { BifrostheimPlugin.Log.LogError((object)$"[WebApiRouter] Error handling '{path}': {ex2}"); await SendJsonAsync(response, 500, new { error = ex2.Message }); } } private static bool HasPlugin(params string[] candidateGuids) { Dictionary<string, PluginInfo>.KeyCollection keys = Chainloader.PluginInfos.Keys; foreach (string candidate in candidateGuids) { if (keys.Any((string k) => string.Equals(k, candidate, StringComparison.OrdinalIgnoreCase) || k.IndexOf(candidate, StringComparison.OrdinalIgnoreCase) >= 0)) { return true; } } return false; } private static string GetConfiguredAdminPassword() { string text = BifrostheimPlugin.WebAdminPassword?.Value ?? WebPortalServer.AdminPassword; if (string.IsNullOrWhiteSpace(text)) { return string.Empty; } return text; } public static bool IsAuthorized(HttpListenerRequest request) { string configuredAdminPassword = GetConfiguredAdminPassword(); if (string.IsNullOrWhiteSpace(configuredAdminPassword)) { return false; } if (string.Equals(configuredAdminPassword, "none", StringComparison.OrdinalIgnoreCase) || string.Equals(configuredAdminPassword, "open", StringComparison.OrdinalIgnoreCase)) { return true; } string text = request.Headers["X-Admin-Password"]; if (!string.IsNullOrEmpty(text) && ConstantTimeEquals(text, configuredAdminPassword)) { return true; } string text2 = request.Headers["Authorization"]; if (!string.IsNullOrEmpty(text2)) { string text3 = (text2.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? text2.Substring(7).Trim() : text2.Trim()); if (!string.IsNullOrEmpty(text3)) { if (_activeSessions.TryGetValue(text3, out var value)) { if (DateTime.UtcNow < value) { _activeSessions[text3] = DateTime.UtcNow.AddHours(12.0); return true; } _activeSessions.TryRemove(text3, out var _); } if (ConstantTimeEquals(text3, configuredAdminPassword)) { return true; } } } return false; } private static async Task HandleAuthVerify(HttpListenerRequest request, HttpListenerResponse response) { if (IsAuthorized(request)) { await SendJsonAsync(response, 200, new { authenticated = true, message = "Session valid." }); } else { await SendJsonAsync(response, 401, new { authenticated = false, message = "Unauthorized. Admin password required." }); } } private static async Task HandleAuthStatus(HttpListenerRequest request, HttpListenerResponse response) { string configuredAdminPassword = GetConfiguredAdminPassword(); bool flag = !string.IsNullOrWhiteSpace(configuredAdminPassword) && !string.Equals(configuredAdminPassword, "none", StringComparison.OrdinalIgnoreCase) && !string.Equals(configuredAdminPassword, "open", StringComparison.OrdinalIgnoreCase); bool authenticated = !flag || IsAuthorized(request); await SendJsonAsync(response, 200, new { required = flag, authenticated = authenticated }); } private static async Task HandleAuthLogin(HttpListenerRequest request, HttpListenerResponse response, string clientIp) { if (_loginRateLimits.TryGetValue(clientIp, out (int, DateTime) value) && DateTime.UtcNow < value.Item2) { int num = Math.Max(1, (int)(value.Item2 - DateTime.UtcNow).TotalSeconds); AddLog("warn", "AUTH", $"Blocked login attempt from locked-out address {clientIp} ({num}s remaining)."); await SendJsonAsync(response, 429, new { success = false, message = $"Too many failed attempts. Try again in {num} seconds." }); return; } string text = SimpleJson.DeserializeObject<AuthRequest>(await ReadBodyAsync(request))?.password ?? string.Empty; string configuredAdminPassword = GetConfiguredAdminPassword(); if (!string.IsNullOrWhiteSpace(configuredAdminPassword) && (string.Equals(configuredAdminPassword, "none", StringComparison.OrdinalIgnoreCase) || string.Equals(configuredAdminPassword, "open", StringComparison.OrdinalIgnoreCase))) { string text2 = Guid.NewGuid().ToString("N"); _activeSessions[text2] = DateTime.UtcNow.AddHours(12.0); await SendJsonAsync(response, 200, new { success = true, token = text2, message = "Authentication successful (open access)." }); return; } if (!string.IsNullOrWhiteSpace(configuredAdminPassword) && !string.IsNullOrWhiteSpace(text) && ConstantTimeEquals(text, configuredAdminPassword)) { _loginRateLimits.TryRemove(clientIp, out (int, DateTime) _); string text3 = Guid.NewGuid().ToString("N"); _activeSessions[text3] = DateTime.UtcNow.AddHours(12.0); AddLog("info", "AUTH", "Admin login successful from " + clientIp + "."); await SendJsonAsync(response, 200, new { success = true, token = text3, message = "Authentication successful." }); return; } int attempts = 1; DateTime item = DateTime.MinValue; if (_loginRateLimits.TryGetValue(clientIp, out (int, DateTime) value3)) { attempts = value3.Item1 + 1; } if (attempts >= 5) { item = DateTime.UtcNow.AddMinutes(5.0); AddLog("warn", "AUTH", "Multiple failed admin login attempts from " + clientIp + ". Locked out for 5 minutes."); } else { AddLog("warn", "AUTH", $"Failed admin login attempt from {clientIp} (Attempt {attempts}/5)."); } _loginRateLimits[clientIp] = (attempts, item); await Task.Delay(1000); if (attempts >= 5) { await SendJsonAsync(response, 429, new { success = false, message = "Too many failed attempts. Locked out for 5 minutes." }); } else { await SendJsonAsync(response, 401, new { success = false, message = "Invalid admin password." }); } } private static async Task HandleGetInstalledModules(HttpListenerResponse response) { List<string> list = new List<string>(); if (HasPlugin("com.charactervault.valheim", "com.bigai.charactervault", "com.bigai.charactersvault", "charactervault")) { list.Add("charvault"); } if (HasPlugin("com.bigai.valgrind", "valgrind")) { list.Add("valgrind"); } if (HasPlugin("com.bigai.dagrnott_customdaycycle", "com.bigai.dagrandnott", "com.bigai.dagrnott", "dagrnott_customdaycycle", "dagrandnott", "dagrnott")) { list.Add("dagrnott"); } if (HasPlugin("com.bigai.skald_vikingkillfeed", "com.bigai.skald", "skald_vikingkillfeed", "skald")) { list.Add("skald"); } if (HasPlugin("com.bigai.njoror_fairwinds", "com.bigai.njoror", "njoror_fairwinds", "njoror")) { list.Add("njoror"); } await SendJsonAsync(response, 200, new { installed = list }); } private static async Task HandleGetTelemetry(HttpListenerResponse response) { TimeSpan uptimeSpan = DateTime.UtcNow - StartTime; string uptimeStr = $"{(int)uptimeSpan.TotalHours}h {uptimeSpan.Minutes}m {uptimeSpan.Seconds}s"; int onlineCount = 0; int maxPlayers = 10; int activeZdos = 0; await MainThreadDispatcher.EnqueueAsync(delegate { if ((Object)(object)ZNet.instance != (Object)null) { onlineCount = ZNet.instance.GetNrOfPlayers(); maxPlayers = ZNetHelper.GetServerPlayerLimit(); } if (ZDOMan.instance != null) { activeZdos = ZDOMan.instance.NrOfObjects(); } }); long num; try { using Process process = Process.GetCurrentProcess(); num = process.WorkingSet64 / 1048576; } catch (Exception ex) { ManualLogSource log = BifrostheimPlugin.Log; if (log != null) { log.LogDebug((object)("[WebApiRouter] Failed to query OS working set, falling back to GC total memory: " + ex.Message)); } num = GC.GetTotalMemory(forceFullCollection: false) / 1048576; } ServerTelemetryDto data = new ServerTelemetryDto { uptime = uptimeStr, uptimeSeconds = (long)uptimeSpan.TotalSeconds, onlineCount = onlineCount, maxPlayers = maxPlayers, fps = BifrostheimPlugin.CurrentFps, tickRate = "50 Hz", activeZdos = activeZdos, memoryMb = (int)num }; await SendJsonAsync(response, 200, data); } private static async Task HandleGetPlayers(HttpListenerResponse response) { List<object> playerList = new List<object>(); await MainThreadDispatcher.EnqueueAsync(delegate { //IL_0055: Unknown result type (might be due to invalid IL or missing references) //IL_005a: Unknown result type (might be due to invalid IL or missing references) //IL_0061: Unknown result type (might be due to invalid IL or missing references) //IL_006d: Unknown result type (might be due to invalid IL or missing references) //IL_0079: Unknown result type (might be due to invalid IL or missing references) if ((Object)(object)ZNet.instance != (Object)null) { int currentWorldDay = ZNetHelper.GetCurrentWorldDay(); foreach (ZNetPeer peer in ZNetHelper.GetPeers()) { if (peer != null) { string playerId = ZNetHelper.GetPlayerId(peer); string name = peer.m_playerName ?? "Unknown"; int peerPing = ZNetHelper.GetPeerPing(peer); Vector3 refPos = peer.m_refPos; string pos = $"{refPos.x:F0}, {refPos.y:F0}, {refPos.z:F0}"; (float, float, bool, string, int) playerData = ZNetHelper.GetPlayerData(peer, currentWorldDay); playerList.Add(new { id = playerId, name = name, steamId = playerId, ping = $"{peerPing}ms", pos = pos, zone = playerData.Item4, health = (int)Math.Round(playerData.Item1), maxHealth = (int)Math.Round(playerData.Item2), pvp = playerData.Item3, daysSurvived = playerData.Item5 }); } } } }); await SendJsonAsync(response, 200, playerList); } private static async Task HandleKickPlayer(HttpListenerRequest request, HttpListenerResponse response, string clientIp) { string name = SimpleJson.DeserializeObject<KickRequest>(await ReadBodyAsync(request))?.name?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(name)) { await SendJsonAsync(response, 400, new { success = false, message = "Player name is required." }); return; } bool kicked = false; await MainThreadDispatcher.EnqueueAsync(delegate { if ((Object)(object)ZNet.instance != (Object)null) { foreach (ZNetPeer peer in ZNetHelper.GetPeers()) { if (peer != null && (peer.m_playerName.Equals(name, StringComparison.OrdinalIgnoreCase) || ZNetHelper.GetPlayerId(peer).Equals(name, StringComparison.OrdinalIgnoreCase))) { ZNet.instance.Disconnect(peer); kicked = true; break; } } } }); if (kicked) { AddLog("warn", "KICK", "Kicked player '" + name + "'"); DiscordWebhookDispatcher.OnAdminAction("Kick", name, "Admin", ""); await SendJsonAsync(response, 200, new { success = true, message = "Kicked player '" + name + "'" }); } else { await SendJsonAsync(response, 404, new { success = false, message = "Player '" + name + "' not found online." }); } } private static async Task HandleBanPlayer(HttpListenerRequest request, HttpListenerResponse response, string clientIp) { BanRequest banRequest = SimpleJson.DeserializeObject<BanRequest>(await ReadBodyAsync(request)); string name = banRequest?.name?.Trim() ?? string.Empty; string reason = banRequest?.reason?.Trim() ?? "Banned by administrator"; if (string.IsNullOrWhiteSpace(name)) { await SendJsonAsync(response, 400, new { success = false, message = "Player name or ID is required." }); return; } bool banExecuted = false; bool wasOnlinePeer = false; string targetDisplayName = name; await MainThreadDispatcher.EnqueueAsync(delegate { if ((Object)(object)ZNet.instance != (Object)null) { List<ZNetPeer> peers = ZNetHelper.GetPeers(); ZNetPeer val = null; foreach (ZNetPeer item in peers) { if (item != null && (string.Equals(item.m_playerName, name, StringComparison.OrdinalIgnoreCase) || string.Equals(ZNetHelper.GetPlayerId(item), name, StringComparison.OrdinalIgnoreCase))) { val = item; break; } } string text2 = ((val != null) ? ZNetHelper.GetPlayerId(val) : string.Empty); if (!string.IsNullOrWhiteSpace(text2)) { ZNet.instance.Ban(text2); banExecuted = true; targetDisplayName = name + " (" + text2 + ")"; } if (!string.Equals(name, text2, StringComparison.OrdinalIgnoreCase)) { ZNet.instance.Ban(name); banExecuted = true; } if (val != null) { wasOnlinePeer = true; ZNet.instance.Disconnect(val); } } }); if (banExecuted) { string text = (wasOnlinePeer ? ("Banned and disconnected player '" + targetDisplayName + "'") : ("Added '" + targetDisplayName + "' to ban list")); AddLog("warn", "BAN", text + " (Reason: " + reason + ")"); DiscordWebhookDispatcher.OnAdminAction("Ban", targetDisplayName, "Admin", reason); await SendJsonAsync(response, 200, new { success = true, message = text + "." }); } else { AddLog("warn", "BAN", "Failed ban attempt for '" + name + "': Valheim network instance unavailable."); await SendJsonAsync(response, 503, new { success = false, message = "Server networking unavailable; unable to register ban." }); } } private static async Task HandleGetBans(HttpListenerResponse response) { List<object> bansList = new List<object>(); await MainThreadDispatcher.EnqueueAsync(delegate { if ((Object)(object)ZNet.instance != (Object)null) { foreach (string banned in ZNetHelper.GetBannedList()) { bansList.Add(new { id = banned, name = banned, steamId = banned, bannedAt = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm"), reason = "Server ban", bannedBy = "Administrator" }); } } }); await SendJsonAsync(response, 200, bansList); } private static async Task HandleUnbanPlayer(HttpListenerRequest request, HttpListenerResponse response, string clientIp) { string steamId = SimpleJson.DeserializeObject<UnbanRequest>(await ReadBodyAsync(request))?.steamId?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(steamId)) { await SendJsonAsync(response, 400, new { success = false, message = "Steam ID is required." }); return; } await MainThreadDispatcher.EnqueueAsync(delegate { if ((Object)(object)ZNet.instance != (Object)null) { ZNet.instance.Unban(steamId); } }); AddLog("info", "UNBAN", "Unbanned Steam ID '" + steamId + "'"); await SendJsonAsync(response, 200, new { success = true, message = "Unbanned player '" + steamId + "'" }); } private static async Task HandleAddBan(HttpListenerRequest request, HttpListenerResponse response, string clientIp) { ManualBanRequest req = SimpleJson.DeserializeObject<ManualBanRequest>(await ReadBodyAsync(request)); string steamId = req?.steamId?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(steamId)) { await SendJsonAsync(response, 400, new { success = false, message = "Steam ID is required." }); return; } await MainThreadDispatcher.EnqueueAsync(delegate { if ((Object)(object)ZNet.instance != (Object)null) { ZNet.instance.Ban(steamId); } }); AddLog("warn", "BAN", "Added ban for '" + steamId + "' (Reason: " + req?.reason + ")"); await SendJsonAsync(response, 200, new { success = true, message = "Banned ID '" + steamId + "'" }); } private static async Task HandleGetLogs(HttpListenerResponse response) { List<ConsoleLogEntry> data; lock (LogLock) { data = new List<ConsoleLogEntry>(LogsBuffer); } await SendJsonAsync(response, 200, data); } private static async Task HandleExecCommand(HttpListenerRequest request, HttpListenerResponse response, string clientIp) { string cmd = SimpleJson.DeserializeObject<ExecCommandRequest>(await ReadBodyAsync(request))?.command?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(cmd)) { await SendJsonAsync(response, 400, new { success = false, output = "Command is empty." }); return; } if (cmd.IndexOf('\n') >= 0 || cmd.IndexOf('\r') >= 0) { AddLog("warn", "SECURITY", "Rejected multi-line console command attempt from " + clientIp + "."); await SendJsonAsync(response, 400, new { success = false, output = "Multi-line commands are not allowed." }); return; } string[] array = cmd.Split(new char[2] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); string text = ((array.Length != 0) ? array[0] : string.Empty); if (_blockedConsoleCommands.Contains(text)) { AddLog("warn", "SECURITY", "Blocked restricted console command '" + text + "' from " + clientIp + "."); await SendJsonAsync(response, 403, new { success = false, output = "Command '" + text + "' is restricted from remote execution." }); return; } AddLog("cmd", "ADMIN", "> " + cmd); bool commandDispatched = false; await MainThreadDispatcher.EnqueueAsync(delegate { if ((Object)(object)Console.instance != (Object)null) { ((Terminal)Console.instance).TryRunCommand(cmd, false, false); commandDispatched = true; return; } try { object? obj = typeof(Terminal).GetField("m_terminalInstance", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)?.GetValue(null); Terminal val = (Terminal)((obj is Terminal) ? obj : null); if ((Object)(object)val != (Object)null) { val.TryRunCommand(cmd, false, false); commandDispatched = true; } } catch { } }); if (commandDispatched) { await SendJsonAsync(response, 200, new { success = true, output = "Command '" + cmd + "' dispatched for execution." }); } else { await SendJsonAsync(response, 503, new { success = false, output = "Server terminal console is not initialized on this instance." }); } } private static async Task HandleForceSave(HttpListenerResponse response, string clientIp) { await MainThreadDispatcher.EnqueueAsync(delegate { if ((Object)(object)ZNet.instance != (Object)null) { ZNet.instance.Save(true, false, false); } }); AddLog("info", "SAVE", "World save triggered by administrator."); await SendJsonAsync(response, 200, new { success = true, message = "World save triggered successfully." }); } private static async Task HandleBroadcast(HttpListenerRequest request, HttpListenerResponse response, string clientIp) { string message = SimpleJson.DeserializeObject<BroadcastRequest>(await ReadBodyAsync(request))?.message?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(message)) { await SendJsonAsync(response, 400, new { success = false, message = "Message is empty." }); return; } try { await MainThreadDispatcher.EnqueueAsync(delegate { ZNetHelper.BroadcastServerMessage(message); }); AddLog("info", "BROADCAST", "Broadcast: '" + message + "'"); await SendJsonAsync(response, 200, new { success = true, message = "Broadcast sent." }); } catch (Exception ex) { BifrostheimPlugin.Log.LogError((object)$"[WebApiRouter] Broadcast error: {ex}"); AddLog("error", "BROADCAST", "Broadcast error: " + ex.Message); await SendJsonAsync(response, 200, new { success = false, message = "Broadcast attempted: " + ex.Message }); } } private static async Task HandleGetRestartStatus(HttpListenerResponse response) { List<PendingConfigChange> pendingChanges; lock (_pendingLock) { pendingChanges = _pendingChanges.ToList(); } ScheduledRestartState scheduledRestartState; DailyRestartState dailyRestartState; lock (_restartLock) { scheduledRestartState = new ScheduledRestartState { active = _scheduledRestart.active, minutes = _scheduledRestart.minutes, totalMinutes = _scheduledRestart.totalMinutes, targetTimestamp = _scheduledRestart.targetTimestamp, reason = _scheduledRestart.reason }; dailyRestartState = new DailyRestartState { enabled = _dailyRestart.enabled, time = _dailyRestart.time }; } bool enabled = BifrostheimPlugin.DailyRestartEnabled?.Value ?? dailyRestartState.enabled; string time = BifrostheimPlugin.DailyRestartTime?.Value ?? dailyRestartState.time; string mode = BifrostheimPlugin.LifecycleRestartMode?.Value ?? _lifecycleConfig.mode; string scriptPath = BifrostheimPlugin.LifecycleScriptPath?.Value ?? _lifecycleConfig.scriptPath; var data = new { scheduledRestart = (scheduledRestartState.active ? new { active = true, targetTimestamp = scheduledRestartState.targetTimestamp, totalMinutes = scheduledRestartState.totalMinutes, remainingSeconds = Math.Max(0, (int)((scheduledRestartState.targetTimestamp - DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()) / 1000)), reason = scheduledRestartState.reason } : null), dailyRestart = new { enabled, time }, lifecycleConfig = new { mode, scriptPath }, pendingChanges = pendingChanges }; await SendJsonAsync(response, 200, data); } private static async Task HandleGetPendingChanges(HttpListenerResponse response) { List<PendingConfigChange> pendingChanges; lock (_pendingLock) { pendingChanges = _pendingChanges.ToList(); } await SendJsonAsync(response, 200, new { success = true, pendingChanges = pendingChanges }); } private static async Task HandleClearPendingChanges(HttpListenerResponse response, string clientIp) { ClearPendingChanges(); AddLog("info", "RESTART", "Cleared pending restart notifications list."); await SendJsonAsync(response, 200, new { success = true, message = "Pending changes cleared." }); } private static async Task HandleScheduleRestart(HttpListenerRequest request, HttpListenerResponse response, string clientIp) { ScheduleRestartRequest scheduleRestartRequest = SimpleJson.DeserializeObject<ScheduleRestartRequest>(await ReadBodyAsync(request)); int minutes = scheduleRestartRequest?.minutes ?? 5; string reason = scheduleRestartRequest?.reason ?? "Scheduled maintenance"; long targetTimestamp = DateTimeOffset.UtcNow.AddMinutes(minutes).ToUnixTimeMilliseconds(); lock (_restartLock) { _scheduledRestart = new ScheduledRestartState { active = true, minutes = minutes, totalMinutes = minutes, targetTimestamp = targetTimestamp, reason = reason }; _restartWarningsSent.Clear(); int[] obj = new int[7] { 900, 600, 300, 120, 60, 30, 10 }; int num = minutes * 60; int[] array = obj; foreach (int num2 in array) { if (num2 >= num) { _restartWarningsSent.Add(num2); } } _isExecutingRestart = false; } AddLog("warn", "RESTART", $"Server restart scheduled in {minutes} minutes (Reason: {reason})"); await MainThreadDispatcher.EnqueueAsync(delegate { ZNetHelper.BroadcastServerMessage(string.Format("⚠\ufe0f SERVER RESTART scheduled in {0} minute{1}! Reason: {2}.", minutes, (minutes > 1) ? "s" : "", reason)); }); await SendJsonAsync(response, 200, new { success = true, message = $"Restart scheduled in {minutes} minutes.", targetTimestamp = targetTimestamp }); } private static async Task HandleCancelRestart(HttpListenerResponse response, string clientIp) { lock (_restartLock) { _scheduledRestart = new ScheduledRestartState(); _restartWarningsSent.Clear(); _isExecutingRestart = false; } AddLog("info", "RESTART", "Scheduled server restart cancelled."); await MainThreadDispatcher.EnqueueAsync(delegate { ZNetHelper.BroadcastServerMessage("ℹ\ufe0f The scheduled server restart has been CANCELLED by an administrator."); }); await SendJsonAsync(response, 200, new { success = true, message = "Scheduled restart cancelled." }); } private static async Task HandleUpdateDailyRestart(HttpListenerRequest request, HttpListenerResponse response, string clientIp) { DailyRestartRequest dailyRestartRequest = SimpleJson.DeserializeObject<DailyRestartRequest>(await ReadBodyAsync(request)); lock (_restartLock) { _dailyRestart = new DailyRestartState { enabled = (dailyRestartRequest?.enabled ?? false), time = (dailyRestartRequest?.time ?? "04:00") }; } if (BifrostheimPlugin.DailyRestartEnabled != null) { BifrostheimPlugin.DailyRestartEnabled.Value = _dailyRestart.enabled; } if (BifrostheimPlugin.DailyRestartTime != null) { BifrostheimPlugin.DailyRestartTime.Value = _dailyRestart.time; } try { BifrostheimPlugin instance = BifrostheimPlugin.Instance; if (instance != null) { ConfigFile config = ((BaseUnityPlugin)instance).Config; if (config != null) { config.Save(); } } } catch (Exception ex) { ManualLogSource log = BifrostheimPlugin.Log; if (log != null) { log.LogWarning((object)("[WebApiRouter] Failed to save daily restart config to disk: " + ex.Message)); } } AddLog("info", "RESTART", "Updated daily restart: " + (_dailyRestart.enabled ? ("Enabled at " + _dailyRestart.time) : "Disabled") + " (Saved to config)."); await SendJsonAsync(response, 200, new { success = true, dailyRestart = _dailyRestart }); } private static async Task HandleGetLifecycleConfig(HttpListenerResponse response) { LifecycleConfigState data = new LifecycleConfigState { mode = (BifrostheimPlugin.LifecycleRestartMode?.Value ?? _lifecycleConfig.mode), scriptPath = (BifrostheimPlugin.LifecycleScriptPath?.Value ?? _lifecycleConfig.scriptPath) }; await SendJsonAsync(response, 200, data); } private static async Task HandleSaveLifecycleConfig(HttpListenerRequest request, HttpListenerResponse response, string clientIp) { LifecycleConfigState lifecycleConfigState = SimpleJson.DeserializeObject<LifecycleConfigState>(await ReadBodyAsync(request)); if (lifecycleConfigState != null && !string.IsNullOrWhiteSpace(lifecycleConfigState.mode)) { string text = (lifecycleConfigState.mode.Equals("SpawnProcess", StringComparison.OrdinalIgnoreCase) ? "SpawnProcess" : "ExitOnly"); _lifecycleConfig.mode = text; if (BifrostheimPlugin.LifecycleRestartMode != null) { BifrostheimPlugin.LifecycleRestartMode.Value = text; } try { BifrostheimPlugin instance = BifrostheimPlugin.Instance; if (instance != null) { ConfigFile config = ((BaseUnityPlugin)instance).Config; if (config != null) { config.Save(); } } } catch (Exception ex) { ManualLogSource log = BifrostheimPlugin.Log; if (log != null) { log.LogWarning((object)("[WebApiRouter] Failed to save lifecycle config to disk: " + ex.Message)); } } } LifecycleConfigState lifecycleConfigState2 = new LifecycleConfigState { mode = (BifrostheimPlugin.LifecycleRestartMode?.Value ?? _lifecycleConfig.mode), scriptPath = (BifrostheimPlugin.LifecycleScriptPath?.Value ?? _lifecycleConfig.scriptPath) }; AddLog("info", "RESTART", "Updated restart strategy: " + lifecycleConfigState2.mode + " (Saved to config). Custom scriptPath is managed locally on host."); await SendJsonAsync(response, 200, new { success = true, lifecycleConfig = lifecycleConfigState2 }); } private static string MaskWebhookUrl(string? url) { if (string.IsNullOrWhiteSpace(url)) { return string.Empty; } string text = url.Trim(); if (text.Length <= 12) { return new string('*', text.Length); } int num = text.LastIndexOf('/'); if (num > 0 && num < text.Length - 4) { string text2 = text.Substring(0, num + 1); string text3 = text.Substring(num + 1); if (text3.Length > 8) { return text2 + new string('*', 8) + text3.Substring(text3.Length - 4); } return text2 + new string('*', text3.Length); } return text.Substring(0, 8) + "********" + text.Substring(text.Length - 4); } private static bool IsMaskedUrl(string? url) { return url?.Contains("****") ?? false; } private static async Task HandleGetDiscordConfig(HttpListenerResponse response) { DiscordConfigDto data = new DiscordConfigDto { enabled = (BifrostheimPlugin.DiscordEnabled?.Value ?? false), webhookUrl = MaskWebhookUrl(BifrostheimPlugin.DiscordWebhookUrl?.Value), overrideChatWebhookUrl = MaskWebhookUrl(BifrostheimPlugin.DiscordOverrideChatWebhookUrl?.Value), overrideAdminWebhookUrl = MaskWebhookUrl(BifrostheimPlugin.DiscordOverrideAdminWebhookUrl?.Value), botUsername = (BifrostheimPlugin.DiscordBotUsername?.Value ?? "Bifrostheim Herald"), botAvatarUrl = (BifrostheimPlugin.DiscordBotAvatarUrl?.Value ?? ""), useRichEmbeds = (BifrostheimPlugin.DiscordUseRichEmbeds?.Value ?? true), notifyPlayerJoin = (BifrostheimPlugin.DiscordNotifyPlayerJoin?.Value ?? true), notifyPlayerLeave = (BifrostheimPlugin.DiscordNotifyPlayerLeave?.Value ?? true), notifyPlayerDeath = (BifrostheimPlugin.DiscordNotifyPlayerDeath?.Value ?? true), notifyServerLifecycle = (BifrostheimPlugin.DiscordNotifyServerLifecycle?.Value ?? true), notifyWorldEvents = (BifrostheimPlugin.DiscordNotifyWorldEvents?.Value ?? true), notifyBossMilestones = (BifrostheimPlugin.DiscordNotifyBossMilestones?.Value ?? true), notifyAdminActions = (BifrostheimPlugin.DiscordNotifyAdminActions?.Value ?? true), notifyChatShouts = (BifrostheimPlugin.DiscordNotifyChatShouts?.Value ?? false) }; await SendJsonAsync(response, 200, data); } private static async Task HandleSaveDiscordConfig(HttpListenerRequest request, HttpListenerResponse response, string clientIp) { DiscordConfigDto discordConfigDto = SimpleJson.DeserializeObject<DiscordConfigDto>(await ReadBodyAsync(request)); if (discordConfigDto != null) { if (!string.IsNullOrWhiteSpace(discordConfigDto.webhookUrl) && !IsMaskedUrl(discordConfigDto.webhookUrl) && !DiscordWebhookDispatcher.IsValidDiscordWebhookUrl(discordConfigDto.webhookUrl)) { await SendJsonAsync(response, 400, new { success = false, message = "Primary Discord Webhook URL is invalid. Must use HTTPS and point to an official Discord webhook endpoint." }); return; } if (!string.IsNullOrWhiteSpace(discordConfigDto.overrideChatWebhookUrl) && !IsMaskedUrl(discordConfigDto.overrideChatWebhookUrl) && !DiscordWebhookDispatcher.IsValidDiscordWebhookUrl(discordConfigDto.overrideChatWebhookUrl)) { await SendJsonAsync(response, 400, new { success = false, message = "Chat Override Discord Webhook URL is invalid. Must use HTTPS and point to an official Discord webhook endpoint." }); return; } if (!string.IsNullOrWhiteSpace(discordConfigDto.overrideAdminWebhookUrl) && !IsMaskedUrl(discordConfigDto.overrideAdminWebhookUrl) && !DiscordWebhookDispatcher.IsValidDiscordWebhookUrl(discordConfigDto.overrideAdminWebhookUrl)) { await SendJsonAsync(response, 400, new { success = false, message = "Admin Override Discord Webhook URL is invalid. Must use HTTPS and point to an official Discord webhook endpoint." }); return; } if (BifrostheimPlugin.DiscordEnabled != null) { BifrostheimPlugin.DiscordEnabled.Value = discordConfigDto.enabled; } if (BifrostheimPlugin.DiscordWebhookUrl != null && !IsMaskedUrl(discordConfigDto.webhookUrl)) { BifrostheimPlugin.DiscordWebhookUrl.Value = discordConfigDto.webhookUrl?.Trim() ?? ""; } if (BifrostheimPlugin.DiscordOverrideChatWebhookUrl != null && !IsMaskedUrl(discordConfigDto.overrideChatWebhookUrl)) { BifrostheimPlugin.DiscordOverrideChatWebhookUrl.Value = discordConfigDto.overrideChatWebhookUrl?.Trim() ?? ""; } if (BifrostheimPlugin.DiscordOverrideAdminWebhookUrl != null && !IsMaskedUrl(discordConfigDto.overrideAdminWebhookUrl)) { BifrostheimPlugin.DiscordOverrideAdminWebhookUrl.Value = discordConfigDto.overrideAdminWebhookUrl?.Trim() ?? ""; } if (BifrostheimPlugin.DiscordBotUsername != null) { BifrostheimPlugin.DiscordBotUsername.Value = (string.IsNullOrWhiteSpace(discordConfigDto.botUsername) ? "Bifrostheim Herald" : discordConfigDto.botUsername.Trim()); } if (BifrostheimPlugin.DiscordBotAvatarUrl != null) { BifrostheimPlugin.DiscordBotAvatarUrl.Value = discordConfigDto.botAvatarUrl?.Trim() ?? ""; } if (BifrostheimPlugin.DiscordUseRichEmbeds != null) { BifrostheimPlugin.DiscordUseRichEmbeds.Value = discordConfigDto.useRichEmbeds; } if (BifrostheimPlugin.DiscordNotifyPlayerJoin != null) { BifrostheimPlugin.DiscordNotifyPlayerJoin.Value = discordConfigDto.notifyPlayerJoin; } if (BifrostheimPlugin.DiscordNotifyPlayerLeave != null) { BifrostheimPlugin.DiscordNotifyPlayerLeave.Value = discordConfigDto.notifyPlayerLeave; } if (BifrostheimPlugin.DiscordNotifyPlayerDeath != null) { BifrostheimPlugin.DiscordNotifyPlayerDeath.Value = discordConfigDto.notifyPlayerDeath; } if (BifrostheimPlugin.DiscordNotifyServerLifecycle != null) { BifrostheimPlugin.DiscordNotifyServerLifecycle.Value = discordConfigDto.notifyServerLifecycle; } if (BifrostheimPlugin.DiscordNotifyWorldEvents != null) { BifrostheimPlugin.DiscordNotifyWorldEvents.Value = discordConfigDto.notifyWorldEvents; } if (BifrostheimPlugin.DiscordNotifyBossMilestones != null) { BifrostheimPlugin.DiscordNotifyBossMilestones.Value = discordConfigDto.notifyBossMilestones; } if (BifrostheimPlugin.DiscordNotifyAdminActions != null) { BifrostheimPlugin.DiscordNotifyAdminActions.Value = discordConfigDto.notifyAdminActions; } if (BifrostheimPlugin.DiscordNotifyChatShouts != null) { BifrostheimPlugin.DiscordNotifyChatShouts.Value = discordConfigDto.notifyChatShouts; } try { BifrostheimPlugin instance = BifrostheimPlugin.Instance; if (instance != null) { ConfigFile config = ((BaseUnityPlugin)instance).Config; if (config != null) { config.Save(); } } } catch (Exception ex) { ManualLogSource log = BifrostheimPlugin.Log; if (log != null) { log.LogWarning((object)("[WebApiRouter] Failed to save Discord config to disk: " + ex.Message)); } } AddLog("info", "DISCORD", "Updated Discord webhook configuration (Saved to disk)."); } DiscordConfigDto config2 = new DiscordConfigDto { enabled = (BifrostheimPlugin.DiscordEnabled?.Value ?? false), webhookUrl = MaskWebhookUrl(BifrostheimPlugin.DiscordWebhookUrl?.Value), overrideChatWebhookUrl = MaskWebhookUrl(BifrostheimPlugin.DiscordOverrideChatWebhookUrl?.Value), overrideAdminWebhookUrl = MaskWebhookUrl(BifrostheimPlugin.DiscordOverrideAdminWebhookUrl?.Value), botUsername = (BifrostheimPlugin.DiscordBotUsername?.Value ?? "Bifrostheim Herald"), botAvatarUrl = (BifrostheimPlugin.DiscordBotAvatarUrl?.Value ?? ""), useRichEmbeds = (BifrostheimPlugin.DiscordUseRichEmbeds?.Value ?? true), notifyPlayerJoin = (BifrostheimPlugin.DiscordNotifyPlayerJoin?.Value ?? true), notifyPlayerLeave = (BifrostheimPlugin.DiscordNotifyPlayerLeave?.Value ?? true), notifyPlayerDeath = (BifrostheimPlugin.DiscordNotifyPlayerDeath?.Value ?? true), notifyServerLifecycle = (BifrostheimPlugin.DiscordNotifyServerLifecycle?.Value ?? true), notifyWorldEvents = (BifrostheimPlugin.DiscordNotifyWorldEvents?.Value ?? true), notifyBossMilestones = (BifrostheimPlugin.DiscordNotifyBossMilestones?.Value ?? true), notifyAdminActions = (BifrostheimPlugin.DiscordNotifyAdminActions?.Value ?? true), notifyChatShouts = (BifrostheimPlugin.DiscordNotifyChatShouts?.Value ?? false) }; await SendJsonAsync(response, 200, new { success = true, config = config2 }); } private static async Task HandleTestDiscordWebhook(HttpListenerRequest request, HttpListenerResponse response, string clientIp) { DiscordTestRequestDto discordTestRequestDto = SimpleJson.DeserializeObject<DiscordTestRequestDto>(await ReadBodyAsync(request)); string eventType = ((!string.IsNullOrWhiteSpace(discordTestRequestDto?.eventType)) ? discordTestRequestDto.eventType.Trim() : "server_online"); string text = ((!string.IsNullOrWhiteSpace(discordTestRequestDto?.webhookUrl)) ? discordTestRequestDto.webhookUrl.Trim() : null); if (IsMaskedUrl(text)) { text = null; } if (!string.IsNullOrWhiteSpace(text) && !DiscordWebhookDispatcher.IsValidDiscordWebhookUrl(text)) { await SendJsonAsync(response, 400, new { success = false, message = "Invalid Discord Webhook URL. URL must use HTTPS and point to an official Discord webhook endpoint." }); return; } var (flag, text2) = await DiscordWebhookDispatcher.SendImmediateTestAsync(eventType, text); AddLog(flag ? "info" : "warn", "DISCORD", "Test webhook '" + eventType + "': " + text2); await SendJsonAsync(response, flag ? 200 : 400, new { success = flag, message = text2, eventType = eventType }); } public static void TickLifecycle() { if ((DateTime.UtcNow - _lastLifecycleTick).TotalSeconds < 1.0) { return; } _lastLifecycleTick = DateTime.UtcNow; try { bool flag = false; List<(int, string, string)> list = new List<(int, string, string)>(); lock (_restartLock) { if (_scheduledRestart != null && _scheduledRestart.active && !_isExecutingRestart) { long num = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); int num2 = Math.Max(0, (int)((_scheduledRestart.targetTimestamp - num) / 1000)); int[] array = new int[7] { 900, 600, 300, 120, 60, 30, 10 }; foreach (int num3 in array) { if (num2 <= num3 && !_restartWarningsSent.Contains(num3)) { _restartWarningsSent.Add(num3); string item = ((num3 >= 60) ? string.Format("{0} minute{1}", num3 / 60, (num3 / 60 > 1) ? "s" : "") : $"{num3} seconds"); list.Add((num3, item, _scheduledRestart.reason)); } } if (num2 <= 0) { _isExecutingRestart = true; flag = true; } } bool num4 = BifrostheimPlugin.DailyRestartEnabled?.Value ?? _dailyRestart.enabled; string text = BifrostheimPlugin.DailyRestartTime?.Value ?? _dailyRestart.time; if (num4 && !string.IsNullOrWhiteSpace(text) && (_scheduledRestart == null || !_scheduledRestart.active)) { string text2 = DateTime.Now.ToString("yyyy-MM-dd"); if (DateTime.Now.ToString("HH:mm") == text && _lastDailyRestartDate != text2) { _lastDailyRestartDate = text2; int num5 = 5; long targetTimestamp = DateTimeOffset.UtcNow.AddMinutes(num5).ToUnixTimeMilliseconds(); _scheduledRestart = new ScheduledRestartState { active = true, minutes = num5, totalMinutes = num5, targetTimestamp = targetTimestamp, reason = "Automated daily maintenance" }; _restartWarningsSent.Clear(); list.Add((-1, $"{num5} minutes", "Automated daily maintenance")); } } } foreach (var item2 in list) { if (item2.Item1 == -1) { AddLog("warn", "RESTART", "Daily restart triggered automatically for " + item2.Item2 + " countdown."); ZNetHelper.BroadcastServerMessage("⚠\ufe0f AUTOMATED DAILY RESTART scheduled in " + item2.Item2 + ". World will be saved."); continue; } ZNetHelper.BroadcastServerMessage("⚠\ufe0f SERVER RESTART in " + item2.Item2 + "! Reason: " + item2.Item3 + ". Please find shelter."); AddLog("warn", "RESTART", "Broadcast in-game warning: " + item2.Item2 + " remaining."); if (item2.Item1 == 600 || item2.Item1 == 300 || item2.Item1 == 60) { DiscordWebhookDispatcher.OnServerLifecycle("⏳ Scheduled Server Restart Warning", "Server restarting in " + item2.Item2 + "! Reason: " + item2.Item3 + ". Find shelter!", 15105570, isRestartWarning: true); } } DateTime value = DateTime.UtcNow; if (value.Second == 0) { foreach (string item3 in (from kvp in _activeSessions where kvp.Value < DateTime.UtcNow select kvp.Key).ToList()) { _activeSessions.TryRemove(item3, out value); } foreach (string item4 in (from kvp in _loginRateLimits where DateTime.UtcNow >= kvp.Value.lockoutUntil && kvp.Value.lockoutUntil != DateTime.MinValue select kvp.Key).ToList()) { _loginRateLimits.TryRemove(item4, out (int, DateTime) _); } } if (flag) { ExecuteServerRestartSequence(); } } catch (Exception ex) { ManualLogSource log = BifrostheimPlugin.Log; if (log != null) { log.LogError((object)("[WebApiRouter] Lifecycle tick error: " + ex.Message)); } } } private static void ExecuteServerRestartSequence() { AddLog("warn", "RESTART", "Executing server restart sequence: saving world and terminating process..."); ZNetHelper.BroadcastServerMessage("⚠\ufe0f [SERVER RESTART] Server is restarting NOW. World saving..."); DiscordWebhookDispatcher.OnServerLifecycle("\ud83d\uded1 Server Restarting Now", "Server restart initiated. The world is being saved and the server is restarting.", 15158332); Task.Run(async delegate { _ = 3; try { await MainThreadDispatcher.EnqueueAsync(delegate { try { if ((Object)(object)ZNet.instance != (Object)null) { ZNet.instance.Save(true, false, false); ManualLogSource log7 = BifrostheimPlugin.Log; if (log7 != null) { log7.LogInfo((object)"[WebApiRouter] World save completed before restart."); } { foreach (ZNetPeer peer in ZNetHelper.GetPeers()) { try { ZNet.instance.Disconnect(peer); } catch { } } return; } } } catch (Exception ex3) { ManualLogSource log8 = BifrostheimPlugin.Log; if (log8 != null) { log8.LogError((object)("[WebApiRouter] Error saving world before restart: " + ex3.Message)); } } }); ClearPendingChanges(); WebPortalServer.Stop(); DiscordWebhookDispatcher.Shutdown(); string obj = BifrostheimPlugin.LifecycleRestartMode?.Value ?? _lifecycleConfig.mode; string text = BifrostheimPlugin.LifecycleScriptPath?.Value ?? _lifecycleConfig.scriptPath; if (obj.Equals("SpawnProcess", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(text)) { try { if (File.Exists(text)) { ManualLogSource log = BifrostheimPlugin.Log; if (log != null) { log.LogInfo((object)("[WebApiRouter] Spawning external restart process: '" + text + "'")); } using (Process.Start(new ProcessStartInfo { FileName = text, UseShellExecute = false })) { } } else { ManualLogSource log2 = BifrostheimPlugin.Log; if (log2 != null) { log2.LogWarning((object)("[WebApiRouter] Configured restart script '" + text + "' does not exist on disk. Skipping spawn.")); } } } catch (Exception ex) { ManualLogSource log3 = BifrostheimPlugin.Log; if (log3 != null) { log3.LogError((object)("[WebApiRouter] Failed to spawn external restart script: " + ex.Message)); } } } if (File.Exists("/.dockerenv") || (File.Exists("/proc/1/cgroup") && File.ReadAllText("/proc/1/cgroup").Contains("docker"))) { try { using (Process.Start(new ProcessStartInfo { FileName = "supervisorctl", Arguments = "shutdown", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true })) { ManualLogSource log4 = BifrostheimPlugin.Log; if (log4 != null) { log4.LogInfo((object)"[WebApiRouter] Dispatched 'supervisorctl shutdown' to trigger Docker container restart."); } } } catch (Exception ex2) { ManualLogSource log5 = BifrostheimPlugin.Log; if (log5 != null) { log5.LogDebug((object)("[WebApiRouter] supervisorctl shutdown not available: " + ex2.Message)); } } } await MainThreadDispatcher.EnqueueAsync(delegate { ManualLogSource log7 = BifrostheimPlugin.Log; if (log7 != null) { log7.LogInfo((object)"[WebApiRouter] Terminating server via Application.Quit() and Environment.Exit()."); } Application.Quit(); }); await Task.Delay(500); Environment.Exit(0); await Task.Delay(1000); Process.GetCurrentProcess().Kill(); } catch (Exception arg) { ManualLogSource log6 = BifrostheimPlugin.Log; if (log6 != null) { log6.LogError((object)$"[WebApiRouter] Error during restart execution: {arg}"); } _isExecutingRestart = false; } }); } private static async Task HandleGetCharacterBindings(HttpListenerResponse response) { HashSet<string> onlineIdentifiers = new HashSet<string>(StringComparer.OrdinalIgnoreCase); await MainThreadDispatcher.EnqueueAsync(delegate { if ((Object)(object)ZNet.instance != (Object)null) { foreach (ZNetPeer peer in ZNetHelper.GetPeers()) { if (peer != null) { string playerId = ZNetHelper.GetPlayerId(peer); if (!string.IsNullOrWhiteSpace(playerId)) { onlineIdentifiers.Add(playerId); if (playerId.StartsWith("Steam_", StringComparison.OrdinalIgnoreCase)) { onlineIdentifiers.Add(playerId.Substring(6)); } } if (!string.IsNullOrWhiteSpace(peer.m_playerName)) { onlineIdentifiers.Add(peer.m_playerName); } } } } }); List<Dictionary<string, object>> data = ConfigSyncManager.LoadCharacterVaultBindings(onlineIdentifiers); await SendJsonAsync(response, 200, data); } private static async Task HandleUnbindCharacter(HttpListenerRequest request, HttpListenerResponse response, string clientIp) { string text = SimpleJson.DeserializeObject<UnbindRequest>(await ReadBodyAsync(request))?.steamId?.Trim() ?? string.Empty; if (!string.IsNullOrWhiteSpace(text)) { ConfigSyncManager.UnbindCharacter(text); } AddLog("info", "CHARVAULT", "Unbound character binding for '" + text + "'."); await SendJsonAsync(response, 200, new { success = true }); } private static async Task HandleWipeCharacters(HttpListenerResponse response, string clientIp) { ConfigSyncManager.WipeCharacters(); AddLog("warn", "CHARVAULT", "Triggered character bindings wipe."); await SendJsonAsync(response, 200, new { success = true, message = "CharactersVault data wiped successfully." }); } private static async Task HandleGetValgrindConfig(HttpListenerResponse response) { ValgrindConfigDto data = ConfigSyncManager.LoadValgrindConfig(); await SendJsonAsync(response, 200, data); } private static async Task HandleSaveValgrindConfig(HttpListenerRequest request, HttpListenerResponse response, string clientIp) { ValgrindConfigDto valgrindConfigDto = SimpleJson.DeserializeObject<ValgrindConfigDto>(await ReadBodyAsync(request)); if (valgrindConfigDto != null) { ConfigSyncManager.SaveValgrindConfig(valgrindConfigDto); } RecordPendingChange("valgrind", "Valgrind"); AddLog("info", "VALGRIND", "Updated Valgrind configuration (Mode: " + valgrindConfigDto?.calculationMode + ") - Saved to disk."); ValgrindConfigDto config = ConfigSyncManager.LoadValgrindConfig(); await SendJsonAsync(response, 200, new { success = true, config = config }); } private static async Task HandleGetDagrNottConfig(HttpListenerResponse response) { DagrNottConfigDto data = ConfigSyncManager.LoadDagrNottConfig(); await SendJsonAsync(response, 200, data); } private static async Task HandleSaveDagrNottConfig(HttpListenerRequest request, HttpListenerResponse response, string clientIp) { DagrNottConfigDto dagrNottConfigDto = SimpleJson.DeserializeObject<DagrNottConfigDto>(await ReadBodyAsync(request)); if (dagrNottConfigDto != null) { ConfigSyncManager.SaveDagrNottConfig(dagrNottConfigDto); } RecordPendingChange("dagrnott", "Dagr & Nott"); AddLog("info", "DAGRNOTT", $"Updated Dagr & Nott cycle (Dawn: {dagrNottConfigDto?.dawnMultiplier:F2}x, Day: {dagrNottConfigDto?.dayMultiplier:F2}x, Dusk: {dagrNottConfigDto?.duskMultiplier:F2}x, Night: {dagrNottConfigDto?.nightMultiplier:F2}x | ~{dagrNottConfigDto?.totalMinutes:F1}m total) - Saved to disk."); DagrNottConfigDto config = ConfigSyncManager.LoadDagrNottConfig(); await SendJsonAsync(response, 200, new { success = true, config = config }); } private static async Task HandleGetSkaldConfig(HttpListenerResponse response) { SkaldConfigDto data = ConfigSyncManager.LoadSkaldConfig(); await SendJsonAsync(response, 200, data); } private static async Task HandleSaveSkaldConfig(HttpListenerRequest request, HttpListenerResponse response, string clientIp) { SkaldConfigDto skaldConfigDto = SimpleJson.DeserializeObject<SkaldConfigDto>(await ReadBodyAsync(request)); if (skaldConfigDto != null) { ConfigSyncManager.SaveSkaldConfig(skaldConfigDto); } RecordPendingChange("skald", "Skald"); AddLog("info", "SKALD", "Updated Skald Viking chronicle configuration - Saved to disk."); SkaldConfigDto config = ConfigSyncManager.LoadSkaldConfig(); await SendJsonAsync(response, 200, new { success = true, config = config }); } private static async Task HandleGetSkaldChronicle(HttpListenerResponse response) { try { Type type = null; Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies(); foreach (Assembly assembly in assemblies) { if (assembly.GetName().Name == "Skald" || assembly.GetName().Name == "Skald_VikingKillFeed") { type = assembly.GetType("Skald.Logic.ChronicleRegistry"); if (type != null) { break; } } } if (type != null) { MethodInfo method = type.GetMethod("GetRecentDeaths", BindingFlags.Static | BindingFlags.Public); if (method != null && method.Invoke(null, new object[1] { 100 }) is IEnumerable enumerable) { List<SkaldDeathRecordDto> list = new List<SkaldDeathRecordDto>(); foreach (object item in enumerable) { if (item != null) { Type type2 = item.GetType(); list.Add(new SkaldDeathRecordDto { id = (type2.GetProperty("Id")?.GetValue(item)?.ToString() ?? Guid.NewGuid().ToString()), victimName = (type2.GetProperty("VictimName")?.GetValue(item)?.ToString() ?? ""), victimSteamId = (type2.GetProperty("VictimSteamId")?.GetValue(item)?.ToString() ?? ""), killerName = (type2.GetProperty("KillerName")?.GetValue(item)?.ToString() ?? ""), category = (type2.GetProperty("Category")?.GetValue(item)?.ToString() ?? ""), biome = (type2.GetProperty("Biome")?.GetValue(item)?.ToString() ?? ""), formattedMessage = (type2.GetProperty("FormattedMessage")?.GetValue(item)?.ToString() ?? ""), timestamp = ((type2.GetProperty("Timestamp")?.GetValue(item) is DateTime dateTime) ? dateTime.ToString("yyyy-MM-dd HH:mm:ss") : DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss")) }); } } if (list.Count > 0) { list.Reverse(); await SendJsonAsync(response, 200, list); return; } } } } catch (Exception ex) { ManualLogSource log = BifrostheimPlugin.Log; if (log != null) { log.LogDebug((object)("[WebApiRouter] Skald live reflection query failed, falling back to local chronicle: " + ex.Message)); } } List<SkaldDeathRecordDto> data; lock (_skaldLock) { data = new List<SkaldDeathRecordDto>(_skaldChronicle); } await SendJsonAsync(response, 200, data); } private static async Task HandleTestDeathAnnouncement(HttpListenerRequest request, HttpListenerResponse response, string clientIp) { await ReadBodyAsync(request); SkaldDeathRecordDto skaldDeathRecordDto = new SkaldDeathRecordDto { id = Guid.NewGuid().ToString(), victimName = "VikingWarrior", victimSteamId = "Steam_76561198000000001", killerName = "Troll", category = "Monsters", biome = "BlackForest", formattedMessage = "VikingWarrior was crushed by Troll in Black Forest.", timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss") }; lock (_skaldLock) { _skaldChronicle.Insert(0, skaldDeathRecordDto); if (_skaldChronicle.Count > 100) { _skaldChronicle.RemoveAt(_skaldChronicle.Count - 1); } } await SendJsonAsync(response, 200, new { success = true, record = skaldDeathRecordDto }); } private static async Task HandleGetNjororConfig(HttpListenerResponse response) { NjororConfigDto data = ConfigSyncManager.LoadNjororConfig(); await SendJsonAsync(response, 200, data); } private static async Task HandleSaveNjororConfig(HttpListenerRequest request, HttpListenerResponse response, string clientIp) { NjororConfigDto njororConfigDto = SimpleJson.DeserializeObject<NjororConfigDto>(await ReadBodyAsync(request)); if (njororConfigDto != null) { ConfigSyncManager.SaveNjororConfig(njororConfigDto); } RecordPendingChange("njoror", "Njörðr"); AddLog("info", "NJOROR", "Updated Njörðr fair winds configuration - Saved to disk."); NjororConfigDto config = ConfigSyncManager.LoadNjororConfig(); await SendJsonAsync(response, 200, new { success = true, config = config }); } private static async Task HandleGetOtherModsList(HttpListenerResponse response) { List<OtherModSummaryDto> mods = ConfigSyncManager.ScanOtherModConfigFiles(); await SendJsonAsync(response, 200, new { mods }); } private static async Task HandleGetOtherModConfig(HttpListenerRequest request, HttpListenerResponse response) { string text = request.QueryString["file"]; if (string.IsNullOrWhiteSpace(text)) { await SendJsonAsync(response, 400, new { success = false, message = "Missing 'file' query parameter." }); return; } OtherModConfigDetailDto data = ConfigSyncManager.ParseModConfigFile(text); await SendJsonAsync(response, 200, data); } private static async Task HandleSaveOtherModConfig(HttpListenerRequest request, HttpListenerResponse response, string clientIp) { SaveOtherModConfigRequest saveOtherModConfigRequest = SimpleJson.DeserializeObject<SaveOtherModConfigRequest>(await ReadBodyAsync(request)); if (saveOtherModConfigRequest == null || string.IsNullOrWhiteSpace(saveOtherModConfigRequest.fileName)) { await SendJsonAsync(response, 400, new { success = false, message = "Invalid save request or missing fileName." }); return; } OtherModConfigDetailDto otherModConfigDetailDto = ConfigSyncManager.SaveOtherModConfig(saveOtherModConfigRequest); string text = ((!string.IsNullOrEmpty(otherModConfigDetailDto.displayName)) ? otherModConfigDetailDto.displayName : saveOtherModConfigRequest.fileName); RecordPendingChange(saveOtherModConfigRequest.fileName, text); AddLog("info", "CONFIG", "Updated mod config '" + saveOtherModConfigRequest.fileName + "' (" + text + ") - Staged restart pending."); await SendJsonAsync(response, 200, new { success = true, config = otherModConfigDetailDto }); } private static async Task HandleResetOtherModDefaults(HttpListenerRequest request, HttpListenerResponse response, string clientIp) { ResetOtherModConfigRequest resetOtherModConfigRequest = SimpleJson.DeserializeObject<ResetOtherModConfigRequest>(await ReadBodyAsync(request)); if (resetOtherModConfigRequest == null || string.IsNullOrWhiteSpace(resetOtherModConfigRequest.fileName)) { await SendJsonAsync(response, 400, new { success = false, message = "Invalid reset request or missing fileName." }); return; } OtherModConfigDetailDto otherModConfigDetailDto = ConfigSyncManager.ResetOtherModConfigDefaults(resetOtherModConfigRequest.fileName); string text = ((!string.IsNullOrEmpty(otherModConfigDetailDto.displayName)) ? otherModConfigDetailDto.displayName : resetOtherModConfigRequest.fileName); RecordPendingChange(resetOtherModConfigRequest.fileName, text); AddLog("warn", "CONFIG", "Reset mod config '" + resetOtherModConfigRequest.fileName + "' (" + text + ") to default values."); await SendJsonAsync(response, 200, new { success = true, config = otherModConfigDetailDto }); } private static async Task<string> ReadBodyAsync(HttpListenerRequest request, int maxBytes = 1048576) { if (request.ContentLength64 > maxBytes) { throw new InvalidOperationException($"Request body exceeds maximum allowed size of {maxBytes} bytes."); } Encoding encoding = request.ContentEncoding ?? Encoding.UTF8; using CancellationTokenSource timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(15.0)); using MemoryStream ms = new MemoryStream(); byte[] buffer = new byte[8192]; int totalRead = 0; int num; while ((num = await request.InputStream.ReadAsync(buffer, 0, buffer.Length, timeoutCts.Token)) > 0) { totalRead += num; if (totalRead > maxBytes) { throw new InvalidOperationException($"Request body exceeds maximum allowed size of {maxBytes} bytes."); } ms.Write(buffer, 0, num); } return encoding.GetString(ms.ToArray()); } private static async Task SendJsonAsync(HttpListenerResponse response, int statusCode, object data) { string s = SimpleJson.SerializeObject(data, prettyPrint: false); byte[] bytes = Encoding.UTF8.GetBytes(s); response.StatusCode = statusCode; response.ContentType = "application/json; charset=utf-8"; response.ContentLength64 = bytes.Length; using (Stream stream = response.OutputStream) { await stream.WriteAsync(bytes, 0, bytes.Length); } response.Close(); } } public class ServerTelemetryDto { public string uptime { get; set; } = "0h 0m 0s"; public long uptimeSeconds { get; set; } public int onlineCount { get; set; } public int maxPlayers { get; set; } = 10; public int fps { get; set; } = 60; public string tickRate { get; set; } = "50 Hz"; public int activeZdos { get; set; } public int memoryMb { get; set; } } public class ValgrindConfigDto { public string calculationMode { get; set; } = "TieredBrackets"; public bool useTopNSkillsOnly { get; set; } public int topNSkillsCount { get; set; } = 5; public bool resetAccumulatorOnDeath { get; set; } = true; public bool enableDebugLogging { get; set; } public float earlyGameLossPercent { get; set; } = 8f; public float midGameLossPercent { get; set; } = 5f; public float lateGameLossPercent { get; set; } = 2.5f; public float endgameLossPercent { get; set; } = 1f; public float curveMaxLossPercent { get; set; } = 8f; public float curveMinLossPercent { get; set; } = 1f; } public class DagrNottConfigDto { public float dawnMultiplier { get; set; } = 0.9f; public float dayMultiplier { get; set; } = 0.5f; public float duskMultiplier { get; set; } = 0.9f; public float nightMultiplier { get; set; } = 0.3f; public bool logPhaseTransitions { get; set; } = true; public float dawnMinutes { get; set; } = 5f; public float dayMinutes { get; set; } = 30f; public float duskMinutes { get; set; } = 5f; public float nightMinutes { get; set; } = 20f; public float totalMinutes { get; set; } = 60f; } public class SkaldConfigDto { public bool enabled { get; set; } = true; public bool enableBosses { get; set; } = true; public bool includeBiome { get; set; } = true; public bool logToConsole { get; set; } = true; public string monsterTemplates { get; set; } = "{victim} was slain by a {killer} in the {biome};{victim} was torn apart by a {killer};A {killer} claimed the soul of {victim}"; public string bossTemplates { get; set; } = "{victim} was annihilated by the mythical {killer}!;The legendary {killer} crushed {victim} into dust"; public string overwhelmedMessages { get; set; } = "{victim} was defeated in glorious battle against a horde in the {biome};{victim} fell fighting valiantly against overwhelming odds"; public string genericDeathMessages { get; set; } = "{victim} has departed for the halls of Valhalla;The Norns have cut the thread of {victim}'s life;{victim} died in the {biome}"; } public class SkaldDeathRecordDto { public string id { get; set; } = string.Empty; public string victimName { get; set; } = string.Empty; public string victimSteamId { get; set; } = string.Empty; public string killerName { get; set; } = string.Empty; public string category { get; set; } = string.Empty; public string biome { get; set; } = string.Empty; public string formattedMessage { get; set; } = string.Empty; public string timestamp { get; set; } = string.Empty; } public class NjororConfigDto { public bool enableFairWinds { get; set; } = true; public float headwindMitigationPercent { get; set; } = 60f; public float minWindSpeedMultiplier { get; set; } = 1f; public bool alwaysTailwindInOcean { get; set; } public bool checkDeflectOnWindChange { get; set; } = true; public int checkDeflectTimeSeconds { get; set; } public bool enableWeatherTuning { get; set; } = true; public float stormFrequencyMultiplier { get; set; } = 1f; public float rainFrequencyMultiplier { get; set; } = 1f; public float clearFrequencyMultiplier { get; set; } = 1f; public bool enableSerpentTuning { get; set; } = true; public float daytimeSerpentSpawnChance { get; set; } public float nighttimeSerpentSpawnChance { get; set; } = 5f; public float serpentSpawnIntervalSeconds { get; set; } = 1000f; public bool allowCalmWeatherDaySerpents { get; set; } } public class ConsoleLogEntry { public string time { get; set; } = string.Empty; public string source { get; set; } = string.Empty; public string text { get; set; } = string.Empty; public string level { get; set; } = "info"; } public class AuthRequest { public string? password { get; set; } } public class KickRequest { public string? name { get; set; } } public class BanRequest { public string? name { get; set; } public string? reason { get; set; } } public class UnbanRequest { public string? steamId { get; set; } } public class UnbindRequest { public string? steamId { get; set; } public string? name { get; set; } } public class ManualBanRequest { public string? steamId { get; set; } public string? name { get; set; } public string? reason { get; set; } } public class ExecCommandRequest { public string? command { get; set; } } public class BroadcastRequest { public string? message { get; set; } } public class ScheduleRestartRequest { public int minutes { get; set; } public string? reason { get; set; } } public class DailyRestartRequest { public bool enabled { get; set; } public string? time { get; set; } } public class ScheduledRestartState { public bool active { get; set; } public int minutes { get; set; } public int totalMinutes { get; set; } public long targetTimestamp { get; set; } public string reason { get; set; } = string.Empty; } public class DailyRestartState { public bool enabled { get; set; } public string time { get; set; } = "04:00"; } public class LifecycleConfigState { public string mode { get; set; } = "ExitOnly"; public string scriptPath { get; set; } = string.Empty; } public class PendingConfigChange { public string module { get; set; } = string.Empty; public string moduleName { get; set; } = string.Empty; public string timestamp { get; set; } = string.Empty; } public class OtherModSummaryDto { public string fileName { get; set; } = string.Empty; public string filePath { get; set; } = string.Empty; public string displayName { get; set; } = string.Empty; public string pluginGuid { get; set; } = string.Empty; public string pluginName { get; set; } = string.Empty; public string pluginVersion { get; set; } = string.Empty; public int sectionCount { get; set; } public int settingCount { get; set; } public long fileSizeBytes { get; set; } public string lastModified { get; set; } = string.Empty; public bool isLoadedInGame { get; set; } public bool isFirstParty { get; set; } } public class OtherModConfigEntryDto { public string key { get; set; } = string.Empty; public string value { get; set; } = string.Empty; public string? defaultValue { get; set; } public string valueType { get; set; } = "String"; public string description { get; set; } = string.Empty; public List<string>? acceptableValues { get; set; } public float? minRange { get; set; } public float? maxRange { get; set; } } public class OtherModSectionDto { public string name { get; set; } = string.Empty; public List<OtherModConfigEntryDto> entries { get; set; } = new List<OtherModConfigEntryDto>(); } public class OtherModConfigDetailDto { public string fileName { get; set; } = string.Empty; public string displayName { get; set; } = string.Empty; public string pluginGuid { get; set; } = string.Empty; public string pluginName { get; set; } = string.Empty; public string pluginVersion { get; set; } = string.Empty; public bool isLoadedInGame { get; set; } public List<OtherModSectionDto> sections { get; set; } = new List<OtherModSectionDto>(); public string rawContent { get; set; } = string.Empty; public string lastModified { get; set; } = string.Empty; } public class SaveOtherModConfigRequest { public string fileName { get; set; } = string.Empty; public Dictionary<string, Dictionary<string, string>>? updates { get; set; } public string? rawContent { get; set; } public bool saveRaw { get; set; } } public class ResetOtherModConfigRequest { public string fileName { get; set; } = string.Empty; } public class DiscordConfigDto { public bool enabled { get; set; } public string webhookUrl { get; set; } = string.Empty; public string overrideChatWebhookUrl { get; set; } = string.Empty; public string overrideAdminWebhookUrl { get; set; } = string.Empty; public string botUsername { get; set; } = "Bifrostheim Herald"; public string botAvatarUrl { get; set; } = string.Empty; public bool useRichEmbeds { get; set; } = true; public bool notifyPlayerJoin { get; set; } = true; public bool notifyPlayerLeave { get; set; } = true; public bool notifyPlayerDeath { get; set; } = true; public bool notifyServerLifecycle { get; set; } = true; public bool notifyWorldEvents { get; set; } = true; public bool notifyBossMilestones { get; set; } = true; public bool notifyAdminActions { get; set; } = true; public bool notifyChatShouts { get; set; } } public class DiscordTestRequestDto { public string? eventType { get; set; } public string? webhookUrl { get; set; } } public static class WebPortalServer { private static HttpListener? _listener; private static CancellationTokenSource? _cts; private static byte[]? _embeddedIndexHtmlBytes; private static bool _isRunning; private static readonly object _serverLock = new object(); internal static string AdminPassword { get; private set; } = string.Empty; public static void Start(int port, string password) { lock (_serverLock) { if (_isRunning) { return; } _isRunning = true; } AdminPassword = password ?? string.Empty; _cts = new CancellationTokenSource(); Task.Run(async delegate { try { string[] obj = new string[4] { $"http://*:{port}/", $"http://+:{port}/", $"http://127.0.0.1:{port}/", $"http://localhost:{port}/" }; bool flag = false; string[] array = obj; foreach (string text in array) { try { _listener = new HttpListener(); _listener.Prefixes.Add(text); _listener.Start(); flag = true; BifrostheimPlugin.Log.LogInfo((object)("[WebPortalServer] Listening on " + text)); } catch (Exception ex) { BifrostheimPlugin.Log.LogWarning((object)("[WebPortalServer] Could not bind prefix '" + text + "': " + ex.Message)); try { _listener?.Close(); } catch (Exception ex2) { BifrostheimPlugin.Log.LogDebug((object)("[WebPortalServer] Failed to close listener on prefix bind error: " + ex2.Message)); } continue; } break; } if (!flag || _listener == null) { BifrostheimPlugin.Log.LogError((object)$"[WebPortalServer] Failed to bind HTTP listener on port {port}."); lock (_serverLock) { _isRunning = false; return; } } LoadEmbeddedAssets(); while (!_cts.Token.IsCancellationRequested && _listener.IsListening) { try { ProcessRequestAsync(await _listener.GetContextAsync()); } catch (HttpListenerException) when (_cts.Token.IsCancellationRequested) { break; } catch (Exception ex4) { if (!_cts.Token.IsCancellationRequested) { BifrostheimPlugin.Log.LogWarning((object)("[WebPortalServer] Request accept error: " + ex4.Message)); } } } } catch (Exception arg) { BifrostheimPlugin.Log.LogError((object)$"[WebPortalServer] Server error: {arg}"); } finally { lock (_serverLock) { _isRunning = false; } try { if (_cts?.IsCancellationRequested ?? false) { goto IL_0302; } HttpListener? listener = _listener; if (listener != null && !listener.IsListening) { goto IL_0302; } goto end_IL_02d9; IL_0302: _listener?.Stop(); _listener?.Close(); end_IL_02d9:; } catch (Exception ex5) { BifrostheimPlugin.Log.LogDebug((object)("[WebPortalServer] Error closing listener on exit: " + ex5.Message)); } } }); } public static void Stop() { lock (_serverLock) { if (!_isRunning) { return; } _isRunning = false; } try { _cts?.Cancel(); _listener?.Stop(); _listener?.Close(); BifrostheimPlugin.Log.LogInfo((object)"[WebPortalServer] Web portal server stopped."); } catch (Exception ex) { BifrostheimPlugin.Log.LogWarning((object)("[WebPortalServer] Error stopping server: " + ex.Message)); } } private static async Task ProcessRequestAsync(HttpListenerContext context) { _ = 1; try { string clientIp = GetClientIp(context.Request); string text = context.Request.Url?.AbsolutePath ?? "/"; if (context.Request.HttpMethod.Equals("OPTIONS", StringComparison.OrdinalIgnoreCase)) { string text2 = context.Request.Headers["Origin"]; if (!string.IsNullOrEmpty(text2) && WebApiRouter.IsAllowedOrigin(text2, context.Request)) { context.Response.AddHeader("Access-Control-Allow-Origin", text2); context.Response.AddHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); context.Response.AddHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Admin-Password"); context.Response.AddHeader("Vary", "Origin"); context.Response.StatusCode = 204; } else { context.Response.StatusCode = (string.IsNullOrEmpty(text2) ? 204 : 403); } context.Response.Close(); } else if (text.StartsWith("/api/", StringComparison.OrdinalIgnoreCase)) { await WebApiRouter.HandleApiRequestAsync(context, text, clientIp); } else { await ServeStaticSpaAsync(context.Response); } } catch (HttpListenerException ex) when (ex.ErrorCode == 64 || ex.ErrorCode == 1229) { BifrostheimPlugin.Log.LogDebug((object)("[WebPortalServer] Client disconnected: " + ex.Message)); } catch (IOException ex2) { BifrostheimPlugin.Log.LogDebug((object)("[WebPortalServer] IO abort during request processing: " + ex2.Message)); } catch (Exception arg) { BifrostheimPlugin.Log.LogError((object)$"[WebPortalServer] Error processing request: {arg}"); try { context.Response.StatusCode = 500; context.Response.Close(); } catch (Exception ex3) { BifrostheimPlugin.Log.LogDebug((object)("[WebPortalServer] Failed to return 500 response: " + ex3.Message)); } } } private static async Task ServeStaticSpaAsync(HttpListenerResponse response) { byte[] array = _embeddedIndexHtmlBytes ?? GetDefaultFallbackHtml(); response.StatusCode = 200; response.ContentType = "text/html; charset=utf-8"; response.ContentLength64 = array.Length; response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate"); using (Stream output = response.OutputStream) { await output.WriteAsync(array, 0, array.Length); } response.Close(); } private static void LoadEmbeddedAssets() { try { Assembly executingAssembly = Assembly.GetExecutingAssembly(); string name = "Bifrostheim.dist.index.html"; using (Stream stream = executingAssembly.GetManifestResourceStream(name)) { if (stream != null) { using (MemoryStream memoryStream = new MemoryStream()) { stream.CopyTo(memoryStream); _embeddedIndexHtmlBytes = memoryStream.ToArray(); BifrostheimPlugin.Log.LogInfo((object)$"[WebPortalServer] Loaded embedded React bundle ({_embeddedIndexHtmlBytes.Length / 1024} KB)."); return; } } } string[] manifestResourceNames = executingAssembly.GetManifestResourceNames(); foreach (string text in manifestResourceNames) { if (!text.EndsWith("index.html", StringComparison.OrdinalIgnoreCase)) { continue; } using Stream stream2 = executingAssembly.GetManifestResourceStream(text); if (stream2 != null) { using (MemoryStream memoryStream2 = new MemoryStream()) { stream2.CopyTo(memoryStream2); _embeddedIndexHtmlBytes = memoryStream2.ToArray(); BifrostheimPlugin.Log.LogInfo((object)$"[WebPortalServer] Loaded embedded resource '{text}' ({_embeddedIndexHtmlBytes.Length / 1024} KB)."); return; } } } BifrostheimPlugin.Log.L