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 BeastheimServerMods v1.2.0
plugins/BeastheimServerMods.dll
Decompiled 3 days ago
The result has been truncated due to the large size, download it to view full contents!
using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.IO.Compression; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; using System.Threading; using Beastheim.ServerMods.Afk; using Beastheim.ServerMods.AfkModule; using Beastheim.ServerMods.CharacterModule; using Beastheim.ServerMods.Characters.Admission; using Beastheim.ServerMods.Characters.Backup; using Beastheim.ServerMods.Characters.Client; using Beastheim.ServerMods.Characters.Fch; using Beastheim.ServerMods.Characters.Fresh; using Beastheim.ServerMods.Characters.Names; using Beastheim.ServerMods.Characters.Observe; using Beastheim.ServerMods.Characters.Protocol; using Beastheim.ServerMods.Characters.Registry; using Beastheim.ServerMods.Characters.Store; using Beastheim.ServerMods.Characters.Trace; using Beastheim.ServerMods.Characters.Transfer; using Beastheim.ServerMods.Enforcement; using Beastheim.ServerMods.Enforcement.Client; using Beastheim.ServerMods.Enforcement.Messaging; using Beastheim.ServerMods.Enforcement.Protocol; using Beastheim.ServerMods.Enforcement.Server; using Beastheim.ServerMods.Enforcement.Util; using Beastheim.ServerMods.Status; using Beastheim.ServerMods.WebhookModule; using Beastheim.ServerMods.Webhooks; using BepInEx; using BepInEx.Bootstrap; using BepInEx.Configuration; using BepInEx.Logging; using HarmonyLib; using Microsoft.CodeAnalysis; using Mono.Cecil; using Mono.Collections.Generic; using Steamworks; using TMPro; using UnityEngine; using UnityEngine.Events; using UnityEngine.UI; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: TargetFramework(".NETFramework,Version=v4.7.2", FrameworkDisplayName = ".NET Framework 4.7.2")] [assembly: AssemblyVersion("0.0.0.0")] [module: RefSafetyRules(11)] namespace Microsoft.CodeAnalysis { [CompilerGenerated] [Embedded] internal sealed class EmbeddedAttribute : Attribute { } } namespace System.Runtime.CompilerServices { [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 Beastheim.ServerMods { [BepInPlugin("beastheim.servermods", "Beastheim Server Mods", "1.2.0")] public sealed class Plugin : BaseUnityPlugin { public const string PluginGuid = "beastheim.servermods"; public const string PluginName = "Beastheim Server Mods"; public const string PluginVersion = "1.2.0"; private Harmony _harmony; private Harmony _clientHarmony; private PolicyWatcher _watcher; private ConfigReloader _reloader; private CharacterConfig _chars; private AfkConfig _afk; private Harmony _afkHarmony; private bool _dedicated; private void Awake() { //IL_012c: Unknown result type (might be due to invalid IL or missing references) //IL_0136: Expected O, but got Unknown //IL_01b5: Unknown result type (might be due to invalid IL or missing references) //IL_01bf: Expected O, but got Unknown //IL_0171: Unknown result type (might be due to invalid IL or missing references) //IL_017b: Expected O, but got Unknown _dedicated = Application.isBatchMode; EnforcementConfig config = new EnforcementConfig(((BaseUnityPlugin)this).Config); string text = Path.Combine(Paths.ConfigPath, "BeastheimServerMods"); _watcher = new PolicyWatcher(text, () => config.DebounceSeconds, delegate(string m) { ((BaseUnityPlugin)this).Logger.LogInfo((object)m); }, delegate(string m) { ((BaseUnityPlugin)this).Logger.LogWarning((object)m); }); EnforcementRuntime.Initialize(((BaseUnityPlugin)this).Logger, config, _watcher, "beastheim.servermods"); _reloader = new ConfigReloader(((BaseUnityPlugin)this).Config, config.Describe, delegate(string m) { ((BaseUnityPlugin)this).Logger.LogInfo((object)m); }, delegate(string m) { ((BaseUnityPlugin)this).Logger.LogWarning((object)m); }); _reloader.Start(); _chars = new CharacterConfig(((BaseUnityPlugin)this).Config); CharacterServer.Initialize(((BaseUnityPlugin)this).Logger, _chars); EnforcementRuntime.ModRejectedHook = delegate(string steam, string codes) { WebhookRuntime.ModCheckRejected(steam, codes); }; EnforcementRuntime.ConnectionHook = OnConnection; _harmony = new Harmony("beastheim.servermods"); _harmony.PatchAll(); if (!_dedicated) { CharacterClient.Install(((BaseUnityPlugin)this).Logger, _chars); ResetUi.Initialize(((BaseUnityPlugin)this).Logger, _chars); _clientHarmony = new Harmony("beastheim.servermods.ui"); ResetUiPatches.Apply(_clientHarmony, ((BaseUnityPlugin)this).Logger); } else { EnforcementRuntime.EnsureServerReady(); WebhookRuntime.EnsureInitialised(((BaseUnityPlugin)this).Logger); _afk = new AfkConfig(((BaseUnityPlugin)this).Config); _afkHarmony = new Harmony("beastheim.servermods.afk"); AfkRuntime.Initialize(((BaseUnityPlugin)this).Logger, _afk, _afkHarmony); if (CharacterServer.Enforced && !CharacterServer.Available) { ((BaseUnityPlugin)this).Logger.LogError((object)"Characters are set to Enforce but the character store is not available: players will be refused."); } } ((MonoBehaviour)this).InvokeRepeating("Sweep", 1f, 1f); ((MonoBehaviour)this).InvokeRepeating("FastTick", 0.2f, 0.2f); ((BaseUnityPlugin)this).Logger.LogInfo((object)("Beastheim Server Mods 1.2.0 loaded. " + config.Describe() + ". Characters: " + _chars.Mode.Value.ToString() + ". Policy folder: " + text)); } private void Start() { if (!_dedicated) { EnforcementRuntime.StartPrewarm(); } try { if (Chainloader.PluginInfos.ContainsKey("beastheim.characters.observe")) { string text = "the Observe test build (beastheim.characters.observe) is installed. Remove its plugin folder, its config and its data, then restart. Observe and the authoritative system never run together."; if (_dedicated) { CharacterServer.Disable(text); } else { ((BaseUnityPlugin)this).Logger.LogError((object)("Beastheim Server Mods: " + text)); } } } catch (Exception ex) { ((BaseUnityPlugin)this).Logger.LogWarning((object)("Could not check for the Observe test build: " + ex.GetType().Name)); } } private void OnConnection(ZNet znet, ZNetPeer peer) { if (znet.IsServer()) { WebhookRuntime.EnsureInitialised(((BaseUnityPlugin)this).Logger); CharacterServer.OnNewConnection(znet, peer); } else { CharacterClient.OnNewConnection(peer.m_rpc); } } private void Sweep() { EnforcementRuntime.Sweep(); if (_dedicated) { AfkRuntime.Tick(); } WebhookRuntime.Tick(); } private void FastTick() { CharacterServer.Tick(); } private void Update() { if (!_dedicated) { CharacterClient.Tick(); StatusPoller.Update(); ResetUi.Update(); } } private void OnDestroy() { CharacterServer.Shutdown(); CharacterClient.Shutdown(); if (_reloader != null) { _reloader.Dispose(); } if (_watcher != null) { _watcher.Dispose(); } if (_harmony != null) { _harmony.UnpatchSelf(); } if (_clientHarmony != null) { _clientHarmony.UnpatchSelf(); } if (_afkHarmony != null) { _afkHarmony.UnpatchSelf(); } } } internal static class StatusPoller { public unsafe static void Update() { //IL_0022: Unknown result type (might be due to invalid IL or missing references) //IL_0027: Unknown result type (might be due to invalid IL or missing references) //IL_0034: Unknown result type (might be due to invalid IL or missing references) //IL_0036: Invalid comparison between Unknown and I4 //IL_0043: Unknown result type (might be due to invalid IL or missing references) //IL_0045: Invalid comparison between Unknown and I4 //IL_0050: Unknown result type (might be due to invalid IL or missing references) //IL_0052: Invalid comparison between Unknown and I4 try { BsmStatusTracker tracker = BsmStatus.Tracker; ZNet instance = ZNet.instance; if (!((Object)(object)instance != (Object)null) || !instance.IsServer()) { ConnectionStatus connectionStatus = ZNet.GetConnectionStatus(); bool loading = tracker.Current.Loading; if ((int)connectionStatus == 1 && !loading) { tracker.ConnectStarted(); } else if ((int)connectionStatus == 2) { tracker.VanillaConnected(connected: true); } else if ((int)connectionStatus >= 3 && loading) { tracker.Disconnected(((object)(*(ConnectionStatus*)(&connectionStatus))/*cast due to .constrained prefix*/).ToString()); } if ((Object)(object)Player.m_localPlayer != (Object)null) { tracker.PlayerSpawned(); } else if ((Object)(object)instance == (Object)null && (tracker.Current.State == BsmState.InWorld || tracker.Current.State == BsmState.EnteringWorld)) { tracker.Disconnected(""); } tracker.Tick(); } } catch (Exception) { } } } public static class BsmAfk { public static bool Suppress(string steam64, string reason, double maxSeconds) { try { return AfkRuntime.Suppress(steam64, reason, maxSeconds); } catch { return false; } } public static void Release(string steam64) { try { AfkRuntime.Release(steam64); } catch { } } } public static class BsmIdentity { public const int ApiVersion = 1; public static bool TryGetCharacterId(object connection, out long characterId) { characterId = 0L; try { ZRpc val = (ZRpc)((connection is ZRpc) ? connection : null); return val != null && CharacterServer.TryGetVerifiedCharacter(val, out characterId); } catch { characterId = 0L; return false; } } public static bool TryGetCharacterIdBySteam64(string steam64, out long characterId) { characterId = 0L; try { return CharacterServer.TryGetVerifiedCharacterBySteam(steam64, out characterId); } catch { characterId = 0L; return false; } } } } namespace Beastheim.ServerMods.Afk { public sealed class AfkSettings { public bool Enabled; public double TimeoutSeconds = 1800.0; public double[] WarningSecondsRemaining = new double[2] { 300.0, 60.0 }; public double RadiusMeters = 3.0; public double GraceSeconds = 60.0; public double SleepMaxSeconds = 1200.0; public double FinalizeWaitSeconds = 15.0; public double FinalizeSafetyMarginSeconds = 5.0; public double DrainSeconds = 3.0; public double UnconfirmedGraceSeconds = 3.0; public double SuppressCapSeconds = 600.0; public bool ExemptAdmins = true; public HashSet<string> ExemptSteamIds = new HashSet<string>(StringComparer.Ordinal); public bool CountActionSignals = true; public bool DeadCountsAsIdle = true; public string WarningText = "You appear to be away. You will be disconnected in {minutes} minute(s) unless you move or act."; public string KickTextSaved = "You were disconnected for inactivity. Your character was saved. You can rejoin at any time."; public string KickTextUnconfirmed = "You were disconnected for inactivity. The server could not confirm that your latest progress was saved, so recent changes may be lost. You can rejoin at any time."; public AfkSettings Validated() { AfkSettings s = (AfkSettings)MemberwiseClone(); s.TimeoutSeconds = Math.Max(60.0, s.TimeoutSeconds); s.RadiusMeters = Math.Max(0.25, s.RadiusMeters); s.GraceSeconds = Math.Max(0.0, s.GraceSeconds); s.SleepMaxSeconds = Math.Max(0.0, s.SleepMaxSeconds); s.FinalizeWaitSeconds = Math.Max(2.0, s.FinalizeWaitSeconds); s.FinalizeSafetyMarginSeconds = Math.Max(1.0, s.FinalizeSafetyMarginSeconds); s.DrainSeconds = Math.Max(0.0, s.DrainSeconds); s.UnconfirmedGraceSeconds = Math.Max(0.0, s.UnconfirmedGraceSeconds); s.SuppressCapSeconds = Math.Max(1.0, s.SuppressCapSeconds); s.WarningSecondsRemaining = (from w in (s.WarningSecondsRemaining ?? new double[0]).Where((double w) => w > 0.0 && w < s.TimeoutSeconds).Distinct() orderby w descending select w).ToArray(); s.ExemptSteamIds = new HashSet<string>(s.ExemptSteamIds ?? new HashSet<string>(), StringComparer.Ordinal); s.WarningText = (string.IsNullOrWhiteSpace(s.WarningText) ? new AfkSettings().WarningText : s.WarningText); s.KickTextSaved = (string.IsNullOrWhiteSpace(s.KickTextSaved) ? new AfkSettings().KickTextSaved : s.KickTextSaved); s.KickTextUnconfirmed = (string.IsNullOrWhiteSpace(s.KickTextUnconfirmed) ? new AfkSettings().KickTextUnconfirmed : s.KickTextUnconfirmed); return s; } public static double[] ParseWarningMinutes(string text) { List<double> list = new List<double>(); string[] array = (text ?? "").Split(new char[3] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries); for (int i = 0; i < array.Length; i++) { if (double.TryParse(array[i], NumberStyles.Float, CultureInfo.InvariantCulture, out var result) && result > 0.0 && result < 1440.0) { list.Add(result * 60.0); } } return list.ToArray(); } public static HashSet<string> ParseSteamIds(string text) { HashSet<string> hashSet = new HashSet<string>(StringComparer.Ordinal); string[] array = (text ?? "").Split(new char[6] { ',', ';', ' ', '\n', '\r', '\t' }, StringSplitOptions.RemoveEmptyEntries); for (int i = 0; i < array.Length; i++) { string text2 = array[i].Trim(); if (text2.Length >= 5 && text2.Length <= 24 && text2.All(char.IsDigit)) { hashSet.Add(text2); } } return hashSet; } } public struct AfkPeer { public object Key; public string Steam64; public string Name; public bool Ready; public bool Busy; public bool Admin; public bool CanFinalizeCharacter; public double X; public double Z; public bool InBed; public bool Dead; public long ActionCounter; } public enum AfkActionType { Warn, BeginFinalize, RequestVanillaSave, Notice, Disconnect } public sealed class AfkAction { public AfkActionType Type; public object Key; public string Steam64 = ""; public string Name = ""; public string Text = ""; public double Seconds; public bool Saved; } public enum FinalizeOutcome { Saved, TimedOut, Unavailable, Disconnected } public enum AfkEventType { Warned, WarningCancelled, FinalizeStarted, FinalizeResult, Kicked, KickedUnconfirmed, SuppressionExpired } public sealed class AfkEvent { public AfkEventType Type; public string Steam64 = ""; public string Name = ""; public string Detail = ""; public double IdleSeconds; } public sealed class AfkEngine { private enum Phase { Present, Finalizing, Ending } private sealed class State { public Phase Phase; public string Steam = ""; public string Name = ""; public double ReadyAt = -1.0; public double LastPresence; public double AnchorX; public double AnchorZ; public double SleepStart = -1.0; public double SuppressUntil = -1.0; public double FinalizeDeadline; public double EndAt; public long LastAction; public bool HaveAction; public bool ChatFlag; public bool DisconnectSent; public bool UnconfirmedPath; public int WarnedMask; public string SuppressReason = ""; } private AfkSettings _s = new AfkSettings().Validated(); private readonly Dictionary<object, State> _states = new Dictionary<object, State>(); private readonly Dictionary<string, KeyValuePair<double, string>> _pendingSuppress = new Dictionary<string, KeyValuePair<double, string>>(StringComparer.Ordinal); private readonly List<AfkEvent> _events = new List<AfkEvent>(); public AfkSettings Settings { get { return _s; } set { _s = (value ?? new AfkSettings()).Validated(); if (!_s.Enabled) { _states.Clear(); _pendingSuppress.Clear(); } } } public int TrackedCount => _states.Count; public List<AfkEvent> DrainEvents() { List<AfkEvent> result = new List<AfkEvent>(_events); _events.Clear(); return result; } public List<AfkAction> Observe(IEnumerable<AfkPeer> peers, double now) { List<AfkAction> list = new List<AfkAction>(); if (!_s.Enabled) { if (_states.Count > 0) { _states.Clear(); } return list; } HashSet<object> seen = new HashSet<object>(); foreach (AfkPeer peer in peers) { if (peer.Key != null) { seen.Add(peer.Key); if (!_states.TryGetValue(peer.Key, out var value)) { value = new State { LastPresence = now }; _states[peer.Key] = value; } value.Steam = peer.Steam64 ?? ""; value.Name = peer.Name ?? ""; try { Step(value, peer, now, list); } catch (Exception) { _states.Remove(peer.Key); } } } foreach (object item in _states.Keys.Where((object k) => !seen.Contains(k)).ToList()) { _states.Remove(item); } return list; } public void NoteChat(object key, double now) { if (_s.Enabled && key != null && _states.TryGetValue(key, out var value) && value.Phase == Phase.Present) { value.ChatFlag = true; } } public List<AfkAction> OnFinalizeResult(object key, FinalizeOutcome outcome, double now) { List<AfkAction> list = new List<AfkAction>(); if (_s.Enabled && key != null && _states.TryGetValue(key, out var value) && value.Phase == Phase.Finalizing) { Conclude(key, value, outcome, now, list); } return list; } public bool Suppress(string steam64, string reason, double maxSeconds, double now) { if (!_s.Enabled || string.IsNullOrEmpty(steam64) || !(maxSeconds > 0.0)) { return false; } double num = now + Math.Min(maxSeconds, _s.SuppressCapSeconds); string text = reason ?? ""; bool flag = false; foreach (State value in _states.Values) { if (value.Steam == steam64 && value.Phase == Phase.Present) { value.SuppressUntil = num; value.SuppressReason = text; flag = true; } } if (!flag) { _pendingSuppress[steam64] = new KeyValuePair<double, string>(num, text); } return true; } public void Release(string steam64, double now) { if (string.IsNullOrEmpty(steam64)) { return; } _pendingSuppress.Remove(steam64); foreach (State value in _states.Values) { if (value.Steam == steam64 && value.SuppressUntil >= 0.0) { value.SuppressUntil = -1.0; value.LastPresence = now; value.WarnedMask = 0; } } } private void Step(State st, AfkPeer p, double now, List<AfkAction> actions) { if (st.Phase == Phase.Ending) { if (!st.DisconnectSent && now >= st.EndAt) { st.DisconnectSent = true; actions.Add(new AfkAction { Type = AfkActionType.Disconnect, Key = p.Key, Steam64 = st.Steam, Name = st.Name }); } return; } if (st.Phase == Phase.Finalizing) { if (now >= st.FinalizeDeadline) { Conclude(p.Key, st, (!st.UnconfirmedPath) ? FinalizeOutcome.TimedOut : FinalizeOutcome.Unavailable, now, actions); } return; } if (!p.Ready || p.Busy) { st.ReadyAt = -1.0; Present(st, p, now, _: false); return; } if (st.ReadyAt < 0.0) { st.ReadyAt = now; Present(st, p, now, _: false); return; } if (now < st.ReadyAt + _s.GraceSeconds) { Present(st, p, now, _: false); return; } if ((_s.ExemptAdmins && p.Admin) || _s.ExemptSteamIds.Contains(p.Steam64 ?? "")) { Present(st, p, now, _: false); return; } if (st.SuppressUntil < 0.0 && !string.IsNullOrEmpty(p.Steam64) && _pendingSuppress.TryGetValue(p.Steam64, out var value)) { st.SuppressUntil = value.Key; st.SuppressReason = value.Value; _pendingSuppress.Remove(p.Steam64); } if (st.SuppressUntil >= 0.0) { if (now < st.SuppressUntil) { Present(st, p, now, _: false); return; } _events.Add(new AfkEvent { Type = AfkEventType.SuppressionExpired, Steam64 = st.Steam, Name = st.Name, Detail = st.SuppressReason }); st.SuppressUntil = -1.0; Present(st, p, now, _: false); return; } bool flag = false; double num = p.X - st.AnchorX; double num2 = p.Z - st.AnchorZ; if (num * num + num2 * num2 > _s.RadiusMeters * _s.RadiusMeters) { flag = true; st.AnchorX = p.X; st.AnchorZ = p.Z; } if (_s.CountActionSignals) { if (st.HaveAction && p.ActionCounter != st.LastAction) { flag = true; } st.LastAction = p.ActionCounter; st.HaveAction = true; } if (st.ChatFlag) { flag = true; st.ChatFlag = false; } if (p.InBed) { if (st.SleepStart < 0.0) { st.SleepStart = now; } if (now - st.SleepStart < _s.SleepMaxSeconds) { flag = true; } } else { st.SleepStart = -1.0; } if (p.Dead && !_s.DeadCountsAsIdle) { flag = true; } if (flag) { if (st.WarnedMask != 0) { _events.Add(new AfkEvent { Type = AfkEventType.WarningCancelled, Steam64 = st.Steam, Name = st.Name, IdleSeconds = now - st.LastPresence }); } st.WarnedMask = 0; st.LastPresence = now; return; } double num3 = now - st.LastPresence; double[] warningSecondsRemaining = _s.WarningSecondsRemaining; int num4 = -1; for (int i = 0; i < warningSecondsRemaining.Length; i++) { if (num3 >= _s.TimeoutSeconds - warningSecondsRemaining[i] && (st.WarnedMask & (1 << i)) == 0) { st.WarnedMask |= 1 << i; num4 = i; } } if (num4 >= 0 && num3 < _s.TimeoutSeconds) { double num5 = warningSecondsRemaining[num4]; string text = _s.WarningText.Replace("{minutes}", Math.Ceiling(num5 / 60.0).ToString(CultureInfo.InvariantCulture)).Replace("{seconds}", ((long)Math.Round(num5)).ToString(CultureInfo.InvariantCulture)); actions.Add(new AfkAction { Type = AfkActionType.Warn, Key = p.Key, Steam64 = st.Steam, Name = st.Name, Text = text, Seconds = num5 }); _events.Add(new AfkEvent { Type = AfkEventType.Warned, Steam64 = st.Steam, Name = st.Name, IdleSeconds = num3, Detail = num5.ToString("0", CultureInfo.InvariantCulture) + " s remaining" }); } if (num3 >= _s.TimeoutSeconds) { st.Phase = Phase.Finalizing; _events.Add(new AfkEvent { Type = AfkEventType.FinalizeStarted, Steam64 = st.Steam, Name = st.Name, IdleSeconds = num3, Detail = (p.CanFinalizeCharacter ? "character finalization" : "vanilla save request only (character module not finalizing)") }); if (p.CanFinalizeCharacter) { st.UnconfirmedPath = false; st.FinalizeDeadline = now + _s.FinalizeWaitSeconds + _s.FinalizeSafetyMarginSeconds; actions.Add(new AfkAction { Type = AfkActionType.BeginFinalize, Key = p.Key, Steam64 = st.Steam, Name = st.Name, Seconds = _s.FinalizeWaitSeconds }); } else { st.UnconfirmedPath = true; st.FinalizeDeadline = now + _s.UnconfirmedGraceSeconds; actions.Add(new AfkAction { Type = AfkActionType.RequestVanillaSave, Key = p.Key, Steam64 = st.Steam, Name = st.Name }); } } } private void Present(State st, AfkPeer p, double now, bool _) { st.LastPresence = now; st.AnchorX = p.X; st.AnchorZ = p.Z; st.WarnedMask = 0; st.ChatFlag = false; st.LastAction = p.ActionCounter; st.HaveAction = true; if (!p.InBed) { st.SleepStart = -1.0; } } private void Conclude(object key, State st, FinalizeOutcome outcome, double now, List<AfkAction> actions) { _events.Add(new AfkEvent { Type = AfkEventType.FinalizeResult, Steam64 = st.Steam, Name = st.Name, Detail = outcome.ToString() }); if (outcome == FinalizeOutcome.Disconnected) { _states.Remove(key); return; } bool flag = outcome == FinalizeOutcome.Saved; st.Phase = Phase.Ending; st.EndAt = now + _s.DrainSeconds; actions.Add(new AfkAction { Type = AfkActionType.Notice, Key = key, Steam64 = st.Steam, Name = st.Name, Text = (flag ? _s.KickTextSaved : _s.KickTextUnconfirmed), Saved = flag }); _events.Add(new AfkEvent { Type = (flag ? AfkEventType.Kicked : AfkEventType.KickedUnconfirmed), Steam64 = st.Steam, Name = st.Name, Detail = outcome.ToString() }); } } } namespace Beastheim.ServerMods.Status { public enum BsmState { Idle, Connecting, CheckingMods, ValidatingCharacter, WaitingForServer, SynchronizingWorld, EnteringWorld, InWorld, Rejected, Failed } public sealed class BsmStatusSnapshot { public BsmState State; public string Name = ""; public string Detail = ""; public string RejectionCode = ""; public string RejectionText = ""; public int Sequence; public double EnteredAt; public int StageIndex; public int StageCount; public bool Loading; public int ModsAccepted; public int CharacterAccepted; public bool CharacterCheckExpected; } public sealed class BsmStatusTracker { public const double EnteringHoldSeconds = 2.0; public const int Stages = 6; private BsmStatusSnapshot _s = New(BsmState.Idle, 0, 0.0); private readonly Func<double> _clock; private bool _modsOk; private bool _modsKnown; private bool _charOk; private bool _charKnown; private bool _charExpected; private bool _vanillaConnected; private bool _spawned; public readonly List<string> History = new List<string>(); public BsmStatusSnapshot Current => _s; private bool Active { get { if (_s.State != BsmState.Idle && _s.State != BsmState.InWorld && _s.State != BsmState.Rejected) { return _s.State != BsmState.Failed; } return false; } } public event Action<BsmStatusSnapshot> Changed; public BsmStatusTracker(Func<double> clock = null) { Stopwatch sw = Stopwatch.StartNew(); _clock = clock ?? ((Func<double>)(() => sw.Elapsed.TotalSeconds)); } public void ConnectStarted() { _modsOk = (_modsKnown = (_charOk = (_charKnown = (_vanillaConnected = (_spawned = false))))); _charExpected = false; Set(BsmState.Connecting, "Connecting to the server."); } public void ModListSending() { if (!Active) { ConnectStarted(); } Set(BsmState.CheckingMods, "Checking your mods against the server's list."); } public void ModDecision(bool accepted, string code, string safeText) { if (Active) { _modsKnown = true; _modsOk = accepted; if (!accepted) { Reject(code, safeText); } else { Advance(); } } } public void CharacterCheckExpected() { if (Active) { _charExpected = true; } } public void CharacterClaimSent() { if (Active && _modsKnown && _modsOk) { Set(BsmState.ValidatingCharacter, "Verifying your selected character."); } _charExpected = true; } public void CharacterDecision(bool accepted, string code, string safeText) { if (Active) { _charKnown = true; _charOk = accepted; if (!accepted) { Reject(code, safeText); } else { Advance(); } } } public void VanillaConnected(bool connected) { if (Active && _vanillaConnected != connected) { _vanillaConnected = connected; if (connected) { Advance(); } } } public void PlayerSpawned() { if (_s.State != BsmState.Idle && _s.State != BsmState.InWorld && _s.State != BsmState.Rejected) { _spawned = true; Set(BsmState.EnteringWorld, "Entering Beastheim."); } } public void Disconnected(string reason) { switch (_s.State) { case BsmState.Idle: case BsmState.Rejected: case BsmState.Failed: break; case BsmState.InWorld: Set(BsmState.Idle, ""); break; default: Fail("C.Disconnected", string.IsNullOrEmpty(reason) ? "The connection to the server ended." : reason); break; } } public void Reset() { _modsOk = (_modsKnown = (_charOk = (_charKnown = (_charExpected = (_vanillaConnected = (_spawned = false)))))); Set(BsmState.Idle, ""); } public void Tick() { if (_s.State == BsmState.EnteringWorld && _clock() - _s.EnteredAt >= 2.0) { Set(BsmState.InWorld, ""); } } private void Advance() { if (Active) { BsmState bsmState = (_spawned ? BsmState.EnteringWorld : ((_vanillaConnected && (_modsOk || !_modsKnown) && (_charOk || !_charExpected)) ? BsmState.SynchronizingWorld : ((_modsKnown && _modsOk && (!_charExpected || _charKnown) && (!_charExpected || _charOk)) ? BsmState.WaitingForServer : ((_modsKnown && _modsOk && _charExpected) ? BsmState.ValidatingCharacter : ((!_modsKnown || !_modsOk) ? _s.State : BsmState.WaitingForServer))))); if (bsmState > _s.State) { Set(bsmState, DetailFor(bsmState)); } } } private void Reject(string code, string text) { BsmStatusSnapshot bsmStatusSnapshot = New(BsmState.Rejected, _s.Sequence + 1, _clock()); bsmStatusSnapshot.RejectionCode = code ?? ""; bsmStatusSnapshot.RejectionText = text ?? ""; bsmStatusSnapshot.Detail = "The server refused this connection."; bsmStatusSnapshot.ModsAccepted = (_modsKnown ? (_modsOk ? 1 : 2) : 0); bsmStatusSnapshot.CharacterAccepted = (_charKnown ? (_charOk ? 1 : 2) : 0); bsmStatusSnapshot.CharacterCheckExpected = _charExpected; Publish(bsmStatusSnapshot); } private void Fail(string code, string text) { BsmStatusSnapshot bsmStatusSnapshot = New(BsmState.Failed, _s.Sequence + 1, _clock()); bsmStatusSnapshot.RejectionCode = code ?? ""; bsmStatusSnapshot.RejectionText = text ?? ""; bsmStatusSnapshot.Detail = "The connection did not complete."; bsmStatusSnapshot.ModsAccepted = (_modsKnown ? (_modsOk ? 1 : 2) : 0); bsmStatusSnapshot.CharacterAccepted = (_charKnown ? (_charOk ? 1 : 2) : 0); bsmStatusSnapshot.CharacterCheckExpected = _charExpected; Publish(bsmStatusSnapshot); } private void Set(BsmState state, string detail) { if (state != _s.State || !(detail == _s.Detail)) { BsmStatusSnapshot bsmStatusSnapshot = New(state, _s.Sequence + 1, _clock()); bsmStatusSnapshot.Detail = detail ?? ""; bsmStatusSnapshot.ModsAccepted = (_modsKnown ? (_modsOk ? 1 : 2) : 0); bsmStatusSnapshot.CharacterAccepted = (_charKnown ? (_charOk ? 1 : 2) : 0); bsmStatusSnapshot.CharacterCheckExpected = _charExpected; Publish(bsmStatusSnapshot); } } private void Publish(BsmStatusSnapshot s) { _s = s; History.Add(s.EnteredAt.ToString("0.0", CultureInfo.InvariantCulture) + "s " + s.State.ToString() + ((s.RejectionCode.Length > 0) ? (" [" + s.RejectionCode + "]") : "")); if (History.Count > 40) { History.RemoveAt(0); } Action<BsmStatusSnapshot> action = this.Changed; if (action != null) { try { action(s); } catch { } } } private static string DetailFor(BsmState s) { return s switch { BsmState.Connecting => "Connecting to the server.", BsmState.CheckingMods => "Checking your mods against the server's list.", BsmState.ValidatingCharacter => "Verifying your selected character.", BsmState.WaitingForServer => "Waiting for the server to finish letting you in.", BsmState.SynchronizingWorld => "Loading the world.", BsmState.EnteringWorld => "Entering Beastheim.", _ => "", }; } private static BsmStatusSnapshot New(BsmState state, int seq, double now) { BsmStatusSnapshot bsmStatusSnapshot = new BsmStatusSnapshot { State = state, Sequence = seq, EnteredAt = now, StageCount = 6 }; switch (state) { case BsmState.Connecting: bsmStatusSnapshot.Name = "CONNECTING"; bsmStatusSnapshot.StageIndex = 1; bsmStatusSnapshot.Loading = true; break; case BsmState.CheckingMods: bsmStatusSnapshot.Name = "CHECKING MODS"; bsmStatusSnapshot.StageIndex = 2; bsmStatusSnapshot.Loading = true; break; case BsmState.ValidatingCharacter: bsmStatusSnapshot.Name = "VALIDATING CHARACTER"; bsmStatusSnapshot.StageIndex = 3; bsmStatusSnapshot.Loading = true; break; case BsmState.WaitingForServer: bsmStatusSnapshot.Name = "WAITING FOR THE SERVER"; bsmStatusSnapshot.StageIndex = 4; bsmStatusSnapshot.Loading = true; break; case BsmState.SynchronizingWorld: bsmStatusSnapshot.Name = "SYNCHRONIZING WORLD"; bsmStatusSnapshot.StageIndex = 5; bsmStatusSnapshot.Loading = true; break; case BsmState.EnteringWorld: bsmStatusSnapshot.Name = "ENTERING BEASTHEIM"; bsmStatusSnapshot.StageIndex = 6; bsmStatusSnapshot.Loading = true; break; case BsmState.InWorld: bsmStatusSnapshot.Name = "IN BEASTHEIM"; bsmStatusSnapshot.StageIndex = 6; bsmStatusSnapshot.Loading = false; break; case BsmState.Rejected: bsmStatusSnapshot.Name = "CONNECTION REFUSED"; bsmStatusSnapshot.StageIndex = 0; bsmStatusSnapshot.Loading = false; break; case BsmState.Failed: bsmStatusSnapshot.Name = "CONNECTION FAILED"; bsmStatusSnapshot.StageIndex = 0; bsmStatusSnapshot.Loading = false; break; default: bsmStatusSnapshot.Name = "IDLE"; bsmStatusSnapshot.StageIndex = 0; bsmStatusSnapshot.Loading = false; break; } return bsmStatusSnapshot; } } public static class BsmStatus { public static readonly BsmStatusTracker Tracker; public const string ApiVersion = "1"; public static int State => (int)Tracker.Current.State; public static string StateName => Tracker.Current.Name; public static string Detail => Tracker.Current.Detail; public static bool IsLoading => Tracker.Current.Loading; public static int Sequence => Tracker.Current.Sequence; public static int StageIndex => Tracker.Current.StageIndex; public static int StageCount => Tracker.Current.StageCount; public static string RejectionCode => Tracker.Current.RejectionCode; public static string RejectionText => Tracker.Current.RejectionText; public static bool IsRejected { get { if (Tracker.Current.State != BsmState.Rejected) { return Tracker.Current.State == BsmState.Failed; } return true; } } public static int ModsAccepted => Tracker.Current.ModsAccepted; public static int CharacterAccepted => Tracker.Current.CharacterAccepted; public static event Action<string> StateChanged; internal static void Raise(string name) { Action<string> stateChanged = BsmStatus.StateChanged; if (stateChanged != null) { try { stateChanged(name); } catch { } } } static BsmStatus() { Tracker = new BsmStatusTracker(); Tracker.Changed += delegate(BsmStatusSnapshot s) { Raise(s.Name); }; } } } namespace Beastheim.ServerMods.Webhooks { public sealed class HttpClientPoster : IHttpPoster { private static readonly HttpClient Client = Create(); private static HttpClient Create() { //IL_0015: Unknown result type (might be due to invalid IL or missing references) //IL_001a: Unknown result type (might be due to invalid IL or missing references) //IL_002e: Unknown result type (might be due to invalid IL or missing references) //IL_0044: Expected O, but got Unknown try { ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12; } catch { } HttpClient val = new HttpClient { Timeout = TimeSpan.FromSeconds(30.0) }; val.DefaultRequestHeaders.UserAgent.ParseAdd("BeastheimServerMods/1.2"); return val; } public PostResult Post(string url, string json, int timeoutSeconds) { //IL_001f: Unknown result type (might be due to invalid IL or missing references) //IL_0025: Expected O, but got Unknown try { using CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds))); StringContent val = new StringContent(json, (Encoding)new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), "application/json"); try { HttpResponseMessage result = Client.PostAsync(url, (HttpContent)(object)val, cancellationTokenSource.Token).GetAwaiter().GetResult(); try { PostResult postResult = new PostResult { Status = (int)result.StatusCode }; if (!result.IsSuccessStatusCode) { postResult.RetryAfterSeconds = RetryAfter(result); } return postResult; } finally { ((IDisposable)result)?.Dispose(); } } finally { ((IDisposable)val)?.Dispose(); } } catch (OperationCanceledException) { return new PostResult { Status = 0, Error = "Timeout" }; } catch (Exception ex2) { Exception ex3 = ((ex2 is AggregateException && ((AggregateException)ex2).InnerException != null) ? ((AggregateException)ex2).InnerException : ex2); return new PostResult { Status = 0, Error = ex3.GetType().Name }; } } private static double RetryAfter(HttpResponseMessage response) { try { if (response.Headers.RetryAfter != null && response.Headers.RetryAfter.Delta.HasValue) { return response.Headers.RetryAfter.Delta.Value.TotalSeconds; } IEnumerable<string> source = default(IEnumerable<string>); if (((HttpHeaders)response.Headers).TryGetValues("Retry-After", ref source) && double.TryParse(source.FirstOrDefault(), NumberStyles.Float, CultureInfo.InvariantCulture, out var result)) { return result; } if (((HttpHeaders)response.Headers).TryGetValues("X-RateLimit-Reset-After", ref source) && double.TryParse(source.FirstOrDefault(), NumberStyles.Float, CultureInfo.InvariantCulture, out var result2)) { return result2; } } catch { } return 0.0; } } public sealed class PostResult { public int Status; public double RetryAfterSeconds; public string Error = ""; public bool Ok { get { if (Status >= 200) { return Status < 300; } return false; } } } public interface IHttpPoster { PostResult Post(string url, string json, int timeoutSeconds); } public sealed class WebhookDispatcher { private sealed class Attempt { public WebhookMessage Message; public string Json; public int Attempts; } private sealed class Route { public string Name; public Func<string> Url; public bool Configured; public readonly LinkedList<WebhookEvent> Queue = new LinkedList<WebhookEvent>(); public double Tokens; public double LastRefill; public double NextAttemptAt; public int ConsecutiveFailures; public double CircuitOpenUntil; public string DisabledReason = ""; public Attempt Current; public bool LoggedPaused; } private readonly WebhookSettings _s; private readonly IHttpPoster _http; private readonly Action<string> _log; private readonly object _gate = new object(); private readonly Route _public; private readonly Route _admin; private bool _initialised; public long Sent; public long Dropped; public long Failed; public long Merged; public long Refused; private int _active; public int QueueDepth { get { lock (_gate) { return _public.Queue.Count + _admin.Queue.Count; } } } public bool Busy => Volatile.Read(in _active) > 0; public bool Enabled { get { if (!_public.Configured) { return _admin.Configured; } return true; } } public WebhookDispatcher(WebhookSettings settings, IHttpPoster http, Action<string> log) { _s = settings; _http = http; _log = log ?? ((Action<string>)delegate { }); _public = new Route { Name = "public", Url = () => _s.PublicUrl.Trim(), Configured = settings.PublicConfigured }; _admin = new Route { Name = "admin", Url = () => _s.AdminUrl.Trim(), Configured = settings.AdminConfigured }; } public bool Publish(WebhookEvent e) { if (e == null || !Enabled) { return false; } bool flag = false; lock (_gate) { if (_public.Configured && _s.PublicEvents.Contains(e.Type)) { flag |= Enqueue(_public, e); } if (_admin.Configured && _s.AdminEvents.Contains(e.Type)) { flag |= Enqueue(_admin, e); } } return flag; } private bool Enqueue(Route r, WebhookEvent e) { if (r.DisabledReason.Length > 0) { Refused++; return false; } bool result = true; while (r.Queue.Count >= Math.Max(1, _s.MaxQueue)) { r.Queue.RemoveFirst(); Dropped++; result = false; } r.Queue.AddLast(Clone(e)); return result; } private static WebhookEvent Clone(WebhookEvent e) { return new WebhookEvent { Type = e.Type, Utc = e.Utc, PlayerName = (e.PlayerName ?? ""), Steam64 = (e.Steam64 ?? ""), Code = (e.Code ?? ""), Detail = (e.Detail ?? ""), Count = Math.Max(1, e.Count), Actor = (e.Actor ?? "") }; } public int Step(double now) { Interlocked.Increment(ref _active); try { return StepCore(now); } finally { Interlocked.Decrement(ref _active); } } private int StepCore(double now) { if (!_initialised) { _initialised = true; _public.LastRefill = (_admin.LastRefill = now); _public.Tokens = (_admin.Tokens = _s.BurstSize); } return 0 + StepRoute(_public, now) + StepRoute(_admin, now); } private int StepRoute(Route r, double now) { if (!r.Configured) { return 0; } if (r.DisabledReason.Length > 0) { lock (_gate) { r.Queue.Clear(); } return 0; } if (now < r.CircuitOpenUntil) { return 0; } if (r.CircuitOpenUntil > 0.0 && now >= r.CircuitOpenUntil) { r.CircuitOpenUntil = 0.0; r.ConsecutiveFailures = 0; r.LoggedPaused = false; _log("webhooks: the " + r.Name + " route is trying again"); } if (now < r.NextAttemptAt) { return 0; } double num = Math.Max(0.01, _s.MessagesPerMinutePerRoute / 60.0); r.Tokens = Math.Min(_s.BurstSize, r.Tokens + (now - r.LastRefill) * num); r.LastRefill = now; if (r.Tokens < 1.0) { return 0; } if (r.Current == null) { WebhookEvent webhookEvent = null; int num2 = 0; lock (_gate) { if (r.Queue.Count == 0) { return 0; } webhookEvent = r.Queue.First.Value; r.Queue.RemoveFirst(); if (Mergeable(webhookEvent.Type)) { LinkedListNode<WebhookEvent> linkedListNode = r.Queue.First; while (linkedListNode != null) { LinkedListNode<WebhookEvent>? next = linkedListNode.Next; WebhookEvent value = linkedListNode.Value; if (value.Type == webhookEvent.Type && value.Code == webhookEvent.Code && (value.Utc - webhookEvent.Utc).TotalSeconds <= (double)_s.MergeWindowSeconds && webhookEvent.Count + num2 < 99) { num2 += value.Count; r.Queue.Remove(linkedListNode); } linkedListNode = next; } } } if (num2 > 0) { webhookEvent.Count += num2; Merged += num2; } WebhookMessage webhookMessage = MessageFormatter.Build(webhookEvent, r.Name == "admin", _s); r.Current = new Attempt { Message = webhookMessage, Json = MessageFormatter.ToJson(webhookMessage, _s) }; } r.Tokens -= 1.0; PostResult postResult = Safe(() => _http.Post(r.Url(), r.Current.Json, Math.Max(1, _s.TimeoutSeconds))); r.Current.Attempts++; int result = 1; if (postResult.Ok) { Sent++; r.Current = null; r.ConsecutiveFailures = 0; return result; } if (postResult.Status == 429) { double num3 = Math.Max(1.0, Math.Min(300.0, (postResult.RetryAfterSeconds <= 0.0) ? 5.0 : postResult.RetryAfterSeconds)); r.NextAttemptAt = now + num3; if (r.Current.Attempts >= _s.MaxAttempts * 3) { Failed++; r.Current = null; } return result; } if (postResult.Status == 401 || postResult.Status == 403 || postResult.Status == 404) { r.DisabledReason = "the webhook was refused (HTTP " + postResult.Status + ")"; _log("webhooks: the " + r.Name + " route is switched off until the next restart: " + r.DisabledReason + ". Check the webhook URL in the configuration."); Failed++; r.Current = null; lock (_gate) { r.Queue.Clear(); return result; } } if (postResult.Status == 400) { Failed++; r.Current = null; return result; } if (r.Current.Attempts >= _s.MaxAttempts) { Failed++; r.Current = null; r.ConsecutiveFailures++; if (r.ConsecutiveFailures >= _s.CircuitBreakerFailures) { r.CircuitOpenUntil = now + (double)_s.CircuitBreakerSeconds; if (!r.LoggedPaused) { r.LoggedPaused = true; _log("webhooks: the " + r.Name + " route failed " + r.ConsecutiveFailures + " times in a row and is paused for " + _s.CircuitBreakerSeconds + " s (last result: " + ((postResult.Status == 0) ? postResult.Error : ("HTTP " + postResult.Status)) + ")"); } } } else { r.NextAttemptAt = now + Math.Min(60.0, 2.0 * Math.Pow(2.0, r.Current.Attempts - 1)); } return result; } private static bool Mergeable(WebhookEventType t) { if (t != WebhookEventType.AdmissionRejected && t != WebhookEventType.CharacterRejected && t != WebhookEventType.CharacterDivergent) { return t == WebhookEventType.ServerError; } return true; } private PostResult Safe(Func<PostResult> f) { try { return f() ?? new PostResult { Status = 0, Error = "NullResult" }; } catch (Exception ex) { return new PostResult { Status = 0, Error = ex.GetType().Name }; } } public string Summary() { return "sent " + Sent + ", failed " + Failed + ", dropped " + Dropped + ", merged " + Merged + ", queued " + QueueDepth; } } public sealed class WebhookWorker : IDisposable { private readonly WebhookDispatcher _d; private readonly Func<double> _clock; private readonly Thread _thread; private readonly ManualResetEventSlim _wake = new ManualResetEventSlim(initialState: false); private volatile bool _stop; public bool Started { get; private set; } public WebhookWorker(WebhookDispatcher dispatcher, Func<double> clock = null) { _d = dispatcher; Stopwatch sw = Stopwatch.StartNew(); _clock = clock ?? ((Func<double>)(() => sw.Elapsed.TotalSeconds)); _thread = new Thread(Loop) { IsBackground = true, Name = "BSM webhooks", Priority = ThreadPriority.BelowNormal }; if (_d.Enabled) { _thread.Start(); Started = true; } } public void Wake() { _wake.Set(); } private void Loop() { while (!_stop) { int num = 0; try { num = _d.Step(_clock()); } catch { } _wake.Reset(); _wake.Wait((num > 0) ? 50 : 500); } } public void Stop(double flushSeconds) { double num = _clock() + Math.Max(0.0, flushSeconds); while (_d.Enabled && (_d.QueueDepth > 0 || _d.Busy) && _clock() < num) { Thread.Sleep(20); } _stop = true; _wake.Set(); } public void Dispose() { _stop = true; _wake.Set(); } } public enum WebhookEventType { ServerStarted = 1, ServerStopping, PlayerJoined, PlayerLeft, AdmissionRejected, CharacterEnrolled, CharacterRejected, CharacterDivergent, CharacterReset, CharacterRemoved, AdminAction, ServerError, Test, AfkKick, AfkKickUnsaved } public sealed class WebhookEvent { public WebhookEventType Type; public DateTime Utc = DateTime.UtcNow; public string PlayerName = ""; public string Steam64 = ""; public string Code = ""; public string Detail = ""; public int Count = 1; public string Actor = ""; } public sealed class WebhookSettings { public string PublicUrl = ""; public string AdminUrl = ""; public HashSet<WebhookEventType> PublicEvents = new HashSet<WebhookEventType>(new WebhookEventType[5] { WebhookEventType.ServerStarted, WebhookEventType.ServerStopping, WebhookEventType.PlayerJoined, WebhookEventType.PlayerLeft, WebhookEventType.CharacterEnrolled }); public HashSet<WebhookEventType> AdminEvents = new HashSet<WebhookEventType>(new WebhookEventType[10] { WebhookEventType.AdmissionRejected, WebhookEventType.CharacterRejected, WebhookEventType.CharacterDivergent, WebhookEventType.CharacterReset, WebhookEventType.CharacterRemoved, WebhookEventType.AdminAction, WebhookEventType.ServerError, WebhookEventType.Test, WebhookEventType.AfkKick, WebhookEventType.AfkKickUnsaved }); public bool IncludeSteamIdsOnAdminRoute = true; public string Username = "Beastheim"; public string ServerName = "Beastheim"; public int MaxQueue = 200; public double MessagesPerMinutePerRoute = 20.0; public int BurstSize = 5; public int TimeoutSeconds = 10; public int MaxAttempts = 3; public int CircuitBreakerFailures = 5; public int CircuitBreakerSeconds = 300; public int MergeWindowSeconds = 20; public List<string> AllowedHosts = new List<string> { "discord.com", "discordapp.com", "ptb.discord.com", "canary.discord.com" }; public bool PublicConfigured => WebhookUrl.IsValid(PublicUrl, AllowedHosts); public bool AdminConfigured => WebhookUrl.IsValid(AdminUrl, AllowedHosts); public bool Enabled { get { if (!PublicConfigured) { return AdminConfigured; } return true; } } public static HashSet<WebhookEventType> ParseEvents(string list, out List<string> unknown) { unknown = new List<string>(); HashSet<WebhookEventType> hashSet = new HashSet<WebhookEventType>(); string[] array = (list ?? "").Split(new char[3] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries); foreach (string text in array) { if (Enum.TryParse<WebhookEventType>(text.Trim(), ignoreCase: true, out var result) && Enum.IsDefined(typeof(WebhookEventType), result)) { hashSet.Add(result); } else { unknown.Add(text.Trim()); } } return hashSet; } public string Describe() { return "webhooks: public route " + (PublicConfigured ? "configured" : "not configured") + ", admin route " + (AdminConfigured ? "configured" : "not configured") + (Enabled ? "" : " (module idle)") + ((PublicUrl.Length > 0 && !PublicConfigured) ? " [public URL is not an allowed https webhook URL and is ignored]" : "") + ((AdminUrl.Length > 0 && !AdminConfigured) ? " [admin URL is not an allowed https webhook URL and is ignored]" : ""); } } public static class WebhookUrl { public static bool IsValid(string url, IEnumerable<string> allowedHosts) { if (string.IsNullOrWhiteSpace(url)) { return false; } if (!Uri.TryCreate(url.Trim(), UriKind.Absolute, out Uri u)) { return false; } if (u.Scheme != Uri.UriSchemeHttps) { return false; } if (!allowedHosts.Any((string h) => string.Equals(h, u.Host, StringComparison.OrdinalIgnoreCase))) { return false; } if (u.AbsolutePath.StartsWith("/api/webhooks/", StringComparison.OrdinalIgnoreCase)) { return u.AbsolutePath.Length > "/api/webhooks/".Length + 4; } return false; } } public static class Sanitizer { public static string Inline(string s, int maxChars) { if (string.IsNullOrEmpty(s)) { return ""; } StringBuilder stringBuilder = new StringBuilder(Math.Min(s.Length, maxChars + 4)); bool flag = true; foreach (char c in s) { if (stringBuilder.Length >= maxChars) { if (stringBuilder.Length > 0 && char.IsHighSurrogate(stringBuilder[stringBuilder.Length - 1])) { stringBuilder.Length--; } stringBuilder.Append("…"); break; } UnicodeCategory unicodeCategory = char.GetUnicodeCategory(c); if (unicodeCategory == UnicodeCategory.Control || unicodeCategory == UnicodeCategory.Format || unicodeCategory == UnicodeCategory.PrivateUse || unicodeCategory == UnicodeCategory.OtherNotAssigned || unicodeCategory == UnicodeCategory.LineSeparator || unicodeCategory == UnicodeCategory.ParagraphSeparator) { continue; } if (c == '`') { stringBuilder.Append('’'); flag = false; } else if (char.IsWhiteSpace(c)) { if (!flag) { stringBuilder.Append(' '); } flag = true; } else { stringBuilder.Append(c); flag = false; } } return stringBuilder.ToString().Trim(); } public static string Code(string s, int maxChars) { string text = Inline(s, maxChars); if (text.Length != 0) { return "`" + text + "`"; } return "`?`"; } public static string CodeToken(string s) { if (string.IsNullOrEmpty(s)) { return ""; } StringBuilder stringBuilder = new StringBuilder(); foreach (char c in s) { if (stringBuilder.Length >= 48) { break; } if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == ':' || c == '-') { stringBuilder.Append(c); } } return stringBuilder.ToString(); } } public static class Json { public static string Escape(string s) { if (s == null) { return ""; } StringBuilder stringBuilder = new StringBuilder(s.Length + 8); foreach (char c in s) { switch (c) { case '"': stringBuilder.Append("\\\""); continue; case '\\': stringBuilder.Append("\\\\"); continue; case '\n': stringBuilder.Append("\\n"); continue; case '\r': stringBuilder.Append("\\r"); continue; case '\t': stringBuilder.Append("\\t"); continue; } if (c < ' ' || c == '\u2028' || c == '\u2029') { StringBuilder stringBuilder2 = stringBuilder.Append("\\u"); int num = c; stringBuilder2.Append(num.ToString("x4")); } else { stringBuilder.Append(c); } } return stringBuilder.ToString(); } } public sealed class WebhookMessage { public string Title = ""; public string Description = ""; public int Color; public DateTime Utc; public string Footer = ""; } public static class MessageFormatter { public const int ColorGreen = 4437377; public const int ColorRed = 15746887; public const int ColorAmber = 16426522; public const int ColorBlue = 7506394; public const int ColorGrey = 7634829; public const int ColorPurple = 9133302; public static WebhookMessage Build(WebhookEvent e, bool adminRoute, WebhookSettings s) { WebhookMessage webhookMessage = new WebhookMessage { Utc = e.Utc, Footer = Sanitizer.Inline(s.ServerName, 40) }; string text = Sanitizer.Code(e.PlayerName, 24); string text2 = ((e.Count > 1) ? (" (x" + e.Count + ")") : ""); string text3 = ((adminRoute && s.IncludeSteamIdsOnAdminRoute && e.Steam64.Length > 0) ? ("\nSteam ID: `" + Sanitizer.CodeToken(e.Steam64) + "`") : ""); string text4 = ((e.Code.Length > 0) ? ("\nCode: `" + Sanitizer.CodeToken(e.Code) + "`") : ""); string text5 = ((e.Detail.Length > 0) ? ("\n" + Sanitizer.Inline(e.Detail, 180)) : ""); switch (e.Type) { case WebhookEventType.ServerStarted: webhookMessage.Title = "Server online"; webhookMessage.Description = "The server is up and accepting players."; webhookMessage.Color = 4437377; break; case WebhookEventType.ServerStopping: webhookMessage.Title = "Server stopping"; webhookMessage.Description = "The server is shutting down."; webhookMessage.Color = 15746887; break; case WebhookEventType.PlayerJoined: webhookMessage.Title = "Player joined"; webhookMessage.Description = text + " entered the world." + text3; webhookMessage.Color = 4437377; break; case WebhookEventType.PlayerLeft: webhookMessage.Title = "Player left"; webhookMessage.Description = text + " left the world." + text3; webhookMessage.Color = 16426522; break; case WebhookEventType.CharacterEnrolled: webhookMessage.Title = "A new Viking arrived"; webhookMessage.Description = text + " was registered." + text3; webhookMessage.Color = 7506394; break; case WebhookEventType.AdmissionRejected: webhookMessage.Title = "Mod check failed" + text2; webhookMessage.Description = ((e.PlayerName.Length > 0) ? (text + " was refused.") : "A player was refused.") + text4 + text5 + text3; webhookMessage.Color = 15746887; break; case WebhookEventType.CharacterRejected: webhookMessage.Title = "Character refused" + text2; webhookMessage.Description = ((e.PlayerName.Length > 0) ? (text + " was refused.") : "A character was refused.") + text4 + text3; webhookMessage.Color = 15746887; break; case WebhookEventType.CharacterDivergent: webhookMessage.Title = "Character does not match the server's record" + text2; webhookMessage.Description = ((e.PlayerName.Length > 0) ? text : "A character") + " was refused; the file was kept for review." + text4 + text3; webhookMessage.Color = 16426522; break; case WebhookEventType.CharacterReset: webhookMessage.Title = "Character reset"; webhookMessage.Description = text + " was archived." + text3; webhookMessage.Color = 9133302; break; case WebhookEventType.CharacterRemoved: webhookMessage.Title = "Player removed after admission"; webhookMessage.Description = ((e.PlayerName.Length > 0) ? text : "A player") + " was removed." + text4 + text3; webhookMessage.Color = 15746887; break; case WebhookEventType.AdminAction: webhookMessage.Title = "Administrator action"; webhookMessage.Description = ((e.Actor.Length > 0) ? ("By `" + Sanitizer.Inline(e.Actor, 40) + "`: ") : "") + Sanitizer.Inline(e.Detail, 200) + text4 + text3; webhookMessage.Color = 9133302; break; case WebhookEventType.AfkKick: webhookMessage.Title = "Idle player removed"; webhookMessage.Description = text + " was idle and was disconnected after the character was saved." + text5 + text3; webhookMessage.Color = 16426522; break; case WebhookEventType.AfkKickUnsaved: webhookMessage.Title = "Idle player removed without a confirmed save"; webhookMessage.Description = text + " was idle and was disconnected, but the server did not receive a final save. The last acknowledged character revision stands." + text5 + text3; webhookMessage.Color = 15746887; break; case WebhookEventType.ServerError: webhookMessage.Title = "Server problem"; webhookMessage.Description = Sanitizer.Inline(e.Detail, 220) + text4; webhookMessage.Color = 15746887; break; default: webhookMessage.Title = "Webhook test"; webhookMessage.Description = "This is a test message from Beastheim Server Mods."; webhookMessage.Color = 7634829; break; } return webhookMessage; } public static string ToJson(WebhookMessage m, WebhookSettings s) { StringBuilder stringBuilder = new StringBuilder(512); stringBuilder.Append("{\"username\":\"").Append(Json.Escape(Sanitizer.Inline(s.Username, 32))).Append("\",\"allowed_mentions\":{\"parse\":[]},\"embeds\":[{"); stringBuilder.Append("\"title\":\"").Append(Json.Escape(Trim(m.Title, 256))).Append("\","); stringBuilder.Append("\"description\":\"").Append(Json.Escape(Trim(m.Description, 1900))).Append("\","); stringBuilder.Append("\"color\":").Append(m.Color).Append(','); stringBuilder.Append("\"timestamp\":\"").Append(m.Utc.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture)).Append("\","); stringBuilder.Append("\"footer\":{\"text\":\"").Append(Json.Escape(Trim(m.Footer, 200))).Append("\"}}]}"); return stringBuilder.ToString(); } private static string Trim(string s, int max) { s = s ?? ""; if (s.Length <= max) { return s; } int num = max - 1; if (num > 0 && char.IsHighSurrogate(s[num - 1])) { num--; } return s.Substring(0, num) + "…"; } } } namespace Beastheim.ServerMods.Characters.Transfer { public sealed class TransferTooLargeException : Exception { public TransferTooLargeException(int length) : base("character file of " + length + " bytes exceeds the supported maximum of " + 1048576 + " bytes and will not be transmitted") { } } public sealed class ChunkSender { private readonly byte[] _file; private readonly double _intervalSeconds; private int _next; private double _nextAt = double.NegativeInfinity; public TransferKind Kind { get; private set; } public long TransferId { get; private set; } public long CharacterId { get; private set; } public int ParentSeq { get; private set; } public byte[] Sha256 { get; private set; } public int TotalLength => _file.Length; public int ChunkCount { get; private set; } public int ChunksSent => _next; public bool AllSent => _next >= ChunkCount; public ChunkSender(TransferKind kind, long characterId, int parentSeq, byte[] file, long transferId, int intervalMs = 110) { if (file == null || file.Length < 1) { throw new ArgumentException("empty character file"); } if (file.Length > 1048576) { throw new TransferTooLargeException(file.Length); } if (transferId == 0L) { throw new ArgumentException("transfer id must be non-zero"); } if (parentSeq < 0) { throw new ArgumentException("negative parent revision"); } _file = file; _intervalSeconds = (double)Math.Max(20, intervalMs) / 1000.0; Kind = kind; TransferId = transferId; CharacterId = characterId; ParentSeq = parentSeq; using (SHA256 sHA = SHA256.Create()) { Sha256 = sHA.ComputeHash(file); } ChunkCount = (file.Length + 16384 - 1) / 16384; } public static long NewTransferId() { byte[] array = new byte[8]; using RandomNumberGenerator randomNumberGenerator = RandomNumberGenerator.Create(); long num; do { randomNumberGenerator.GetBytes(array); num = BitConverter.ToInt64(array, 0) & 0x7FFFFFFFFFFFFFFFL; } while (num == 0L); return num; } public bool TryNext(double now, out ChunkMessage chunk) { chunk = null; if (AllSent || now < _nextAt) { return false; } int num = _next * 16384; int num2 = Math.Min(16384, _file.Length - num); byte[] array = new byte[num2]; Buffer.BlockCopy(_file, num, array, 0, num2); chunk = new ChunkMessage { Kind = Kind, TransferId = TransferId, CharacterId = CharacterId, ParentSeq = ParentSeq, TotalLength = _file.Length, Sha256 = Sha256, Index = _next, Count = ChunkCount, Payload = array }; _next++; _nextAt = now + _intervalSeconds; return true; } } public sealed class ChunkReceiver { public enum Status { InProgress, Complete, Error } public sealed class Result { public Status Status; public string Error = ""; public byte[] File; public long TransferId; public TransferKind Kind; public long CharacterId; public int ParentSeq; public byte[] Sha256; public long AbandonedTransferId; } public const double TransferTimeoutSeconds = 30.0; private byte[] _buffer; private int _filled; private int _expectedIndex; private ChunkMessage _first; private double _lastChunkAt; public bool Active => _first != null; public long ActiveTransferId { get { if (_first != null) { return _first.TransferId; } return 0L; } } public int ChunksReceived => _expectedIndex; public Result Feed(ChunkMessage c, double now) { long abandonedTransferId = 0L; if (_first != null && c.TransferId != _first.TransferId) { if (c.Index != 0) { return Fail(c, "chunk " + c.Index + " of transfer " + c.TransferId + " arrived while transfer " + _first.TransferId + " is still open", resetState: false); } abandonedTransferId = _first.TransferId; Reset(); } if (_first == null) { if (c.Index != 0) { return Fail(c, "transfer " + c.TransferId + " started at chunk " + c.Index + " instead of 0", resetState: false); } _first = c; _buffer = new byte[c.TotalLength]; _filled = 0; _expectedIndex = 0; } else if (c.Kind != _first.Kind || c.CharacterId != _first.CharacterId || c.ParentSeq != _first.ParentSeq || c.TotalLength != _first.TotalLength || c.Count != _first.Count || !SameHash(c.Sha256, _first.Sha256)) { return Fail(c, "chunk header differs from the transfer's first chunk", resetState: true); } if (c.Index != _expectedIndex) { return Fail(c, "chunk " + c.Index + " arrived but chunk " + _expectedIndex + " was expected", resetState: true); } Buffer.BlockCopy(c.Payload, 0, _buffer, _filled, c.Payload.Length); _filled += c.Payload.Length; _expectedIndex++; _lastChunkAt = now; Result result = new Result { TransferId = c.TransferId, Kind = c.Kind, CharacterId = c.CharacterId, ParentSeq = c.ParentSeq, Sha256 = c.Sha256, AbandonedTransferId = abandonedTransferId }; if (_expectedIndex < c.Count) { result.Status = Status.InProgress; return result; } if (_filled != c.TotalLength) { return Fail(c, "assembled " + _filled + " bytes, declared " + c.TotalLength, resetState: true); } byte[] a; using (SHA256 sHA = SHA256.Create()) { a = sHA.ComputeHash(_buffer); } if (!SameHash(a, c.Sha256)) { return Fail(c, "assembled file does not match the declared SHA-256", resetState: true); } byte[] buffer = _buffer; Reset(); result.Status = Status.Complete; result.File = buffer; return result; } public long Sweep(double now) { if (_first == null || now - _lastChunkAt < 30.0) { return 0L; } long transferId = _first.TransferId; Reset(); return transferId; } public long Abort() { long result = ((_first == null) ? 0 : _first.TransferId); Reset(); return result; } private Result Fail(ChunkMessage c, string error, bool resetState) { if (resetState) { Reset(); } return new Result { Status = Status.Error, Error = error, TransferId = c.TransferId, Kind = c.Kind, CharacterId = c.CharacterId }; } private void Reset() { _first = null; _buffer = null; _filled = 0; _expectedIndex = 0; } private static bool SameHash(byte[] a, byte[] b) { if (a == null || b == null || a.Length != b.Length) { return false; } int num = 0; for (int i = 0; i < a.Length; i++) { num |= a[i] ^ b[i]; } return num == 0; } } } namespace Beastheim.ServerMods.Characters.Trace { public interface ICharacterTrace { void Event(string name, params object[] keyValues); } public sealed class NullTrace : ICharacterTrace { public static readonly NullTrace Instance = new NullTrace(); public void Event(string name, params object[] keyValues) { } } public sealed class ListTrace : ICharacterTrace { public sealed class Item { public string Name; public Dictionary<string, string> Values = new Dictionary<string, string>(StringComparer.Ordinal); public int Seq; public double TimeSeconds; public string Get(string key) { if (!Values.TryGetValue(key, out var value)) { return null; } return value; } } public readonly List<Item> Items = new List<Item>(); public Func<double> Clock = () => 0.0; public void Event(string name, params object[] kv) { Item item = new Item { Name = name, Seq = Items.Count + 1, TimeSeconds = Clock() }; TraceFormat.Fill(item.Values, kv); Items.Add(item); } public List<Item> Named(string name) { return Items.FindAll((Item i) => i.Name == name); } public int IndexOf(Predicate<Item> p) { return Items.FindIndex(p); } } public sealed class StampedTrace : ICharacterTrace { private readonly string _role; private readonly Action<string> _sink; private readonly Func<DateTime> _utc; private readonly Func<double> _monoMs; private int _seq; public StampedTrace(string role, Action<string> sink, Func<DateTime> utc = null, Func<double> monoMs = null) { _role = role; _sink = sink; _utc = utc ?? ((Func<DateTime>)(() => DateTime.UtcNow)); Stopwatch sw = Stopwatch.StartNew(); _monoMs = monoMs ?? ((Func<double>)(() => sw.Elapsed.TotalMilliseconds)); } public void Event(string name, params object[] keyValues) { string[] obj = new string[9] { _utc().ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture), "Z #", null, null, null, null, null, null, null }; int num = ++_seq; obj[2] = num.ToString(CultureInfo.InvariantCulture); obj[3] = " mono="; obj[4] = Math.Round(_monoMs()).ToString("0", CultureInfo.InvariantCulture); obj[5] = " "; obj[6] = _role; obj[7] = " "; obj[8] = TraceFormat.Line(name, keyValues); string obj2 = string.Concat(obj); try { _sink(obj2); } catch (Exception) { } } } public static class TraceFormat { public static string Short(byte[] sha256) { if (sha256 == null || sha256.Length < 8) { return "none"; } StringBuilder stringBuilder = new StringBuilder(16); for (int i = 0; i < 8; i++) { stringBuilder.Append(sha256[i].ToString("x2", CultureInfo.InvariantCulture)); } return stringBuilder.ToString(); } public static string Value(object o) { if (o == null) { return "null"; } if (o is bool) { if (!(bool)o) { return "false"; } return "true"; } if (o is double num) { return num.ToString("0.###", CultureInfo.InvariantCulture); } if (o is float num2) { return num2.ToString("0.###", CultureInfo.InvariantCulture); } string text = Convert.ToString(o, CultureInfo.InvariantCulture) ?? ""; if (text.Length == 0) { return "\"\""; } bool flag = false; string text2 = text; foreach (char c in text2) { if (c == ' ' || c == '=' || c == '"' || c < ' ') { flag = true; break; } } if (!flag) { return text; } return "\"" + text.Replace("\\", "\\\\").Replace("\"", "'").Replace("\r", " ") .Replace("\n", " ") + "\""; } public static void Fill(Dictionary<string, string> into, object[] kv) { for (int i = 0; i + 1 < kv.Length; i += 2) { into[Convert.ToString(kv[i], CultureInfo.InvariantCulture)] = Value(kv[i + 1]); } } public static string Line(string name, object[] kv) { StringBuilder stringBuilder = new StringBuilder("EVT ").Append(name); for (int i = 0; i + 1 < kv.Length; i += 2) { stringBuilder.Append(' ').Append(Convert.ToString(kv[i], CultureInfo.InvariantCulture)).Append('=') .Append(Value(kv[i + 1])); } return stringBuilder.ToString(); } } } namespace Beastheim.ServerMods.Characters.Store { public interface IStoreFs { void CreateDirectory(string path); void WriteDurable(string path, byte[] data); void Move(string from, string to); byte[] ReadAllBytes(string path); bool Exists(string path); void Delete(string path); string[] GetFiles(string dir, string pattern); string[] GetDirectories(string dir); void AppendDurable(string path, byte[] data); void TruncateTo(string path, long length); void MoveDirectory(string from, string to); void DeleteDirectory(string path); bool DirectoryExists(string path); long FileLength(string path); } public sealed class RealStoreFs : IStoreFs { public void CreateDirectory(string path) { Directory.CreateDirectory(path); } public void WriteDurable(string path, byte[] data) { using FileStream fileStream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None); fileStream.Write(data, 0, data.Length); fileStream.Flush(flushToDisk: true); } public void Move(string from, string to) { File.Move(from, to); } public byte[] ReadAllBytes(string path) { return File.ReadAllBytes(path); } public bool Exists(string path) { return File.Exists(path); } public void Delete(string path) { if (File.Exists(path)) { File.Delete(path); } } public string[] GetFiles(string dir, string pattern) { if (!Directory.Exists(dir)) { return new string[0]; } return Directory.GetFiles(dir, pattern); } public string[] GetDirectories(string dir) { if (!Directory.Exists(dir)) { return new string[0]; } return Directory.GetDirectories(dir); } public void AppendDurable(string path, byte[] data) { using FileStream fileStream = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.Read); fileStream.Write(data, 0, data.Length); fileStream.Flush(flushToDisk: true); } public void TruncateTo(string path, long length) { using FileStream fileStream = new FileStream(path, FileMode.Open, FileAccess.Write, FileShare.Read); fileStream.SetLength(length); fileStream.Flush(flushToDisk: true); } public void MoveDirectory(string from, string to) { Directory.CreateDirectory(Path.GetDirectoryName(to)); Directory.Move(from, to); } public void DeleteDirectory(string path) { if (Directory.Exists(path)) { Directory.Delete(path, recursive: true); } } public bool DirectoryExists(string path) { return Directory.Exists(path); } public long FileLength(string path) { return new FileInfo(path).Length; } } public sealed class RegisteredCharacter { public string Steam64; public long CharacterId; public string Name = ""; public string NameKey = ""; public int HeadSeq; public byte[] HeadSha256 = new byte[0]; public int HeadLength; public DateTime HeadCommittedUtc; public DateTime FirstCommittedUtc; } public interface ICharacterIndex { IList<RegisteredCharacter> GetAccountCharacters(string steam64); string FindOwnerOfCharacterId(long characterId); RegisteredCharacter FindByNameKey(string nameKey); } public enum CommitStatus { Committed, Unchanged, Invalid, Failed } public sealed class CommitResult { public CommitStatus Status; public int Seq; public byte[] Sha256 = new byte[0]; public string Error = ""; public ParsedCharacter Parsed; } public sealed class RevisionStore : IDisposable, ICharacterIndex { private sealed class HeadInfo { public int Seq; public byte[] Sha; public int Length; public string Name; public DateTime Committed; } public const int KeepNewest = 50; public const int KeepDailyDays = 30; private readonly string _root; private readonly IStoreFs _fs; private readonly Func<DateTime> _utcNow; private readonly Action<string> _warn; private readonly object _gate = new object(); private FileStream _lock; private readonly Dictionary<string, Dictionary<long, RegisteredCharacter>> _byAccount = new Dictionary<string, Dictionary<long, RegisteredCharacter>>(StringComparer.Ordinal); private readonly Dictionary<long, string> _owner = new Dictionary<long, string>(); private readonly Dictionary<string, RegisteredCharacter> _byName = new Dictionary<string, RegisteredCharacter>(StringComparer.Ordinal); public RevisionStore(string root, Func<DateTime> utcNow = null, IStoreFs fs = null, Action<string> warn = null, bool takeWriterLock = true) { _root = root; _utcNow = utcNow ?? ((Func<DateTime>)(() => DateTime.UtcNow)); _fs = fs ?? new RealStoreFs(); _warn = warn ?? ((Action<string>)delegate { }); _fs.CreateDirectory(root); if (takeWriterLock) { try { _lock = new FileStream(Path.Combine(root, ".writer-lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read); } catch (IOException ex) { throw new IOException("Another process holds the character store lock: " + ex.Message, ex); } } Rebuild(); } public void Dispose() { if (_lock != null) { _lock.Dispose(); _lock = null; } } public static bool IsValidSteam64(string s) { if (s == null || s.Length != 17) { return false; } foreach (char c in s) { if (c < '0' || c > '9') { return false; } } return s.StartsWith("7656119", StringComparison.Ordinal); } public static string Hex(byte[] b) { StringBuilder stringBuilder = new StringBuilder(b.Length * 2); foreach (byte b2 in b) { stringBuilder.Append(b2.ToString("x2", CultureInfo.InvariantCulture)); } return stringBuilder.ToString(); } private static byte[] Sha(byte[] data) { using SHA256 sHA = SHA256.Create(); return sHA.ComputeHash(data); } private string CharDir(string steam64, long id) { return Path.Combine(Path.Combine(_root, steam64), id.ToString(CultureInfo.InvariantCulture)); } private static string Stem(string dir, int seq) { return Path.Combine(dir, "rev-" + seq.ToString("000000", CultureInfo.InvariantCulture)); } public IList<RegisteredCharacter> GetAccountCharacters(string steam64) { lock (_gate) { if (steam64 == null || !_byAccount.TryGetValue(steam64, out var value)) { return new List<RegisteredCharacter>(); } return (from c in value.Values orderby c.FirstCommittedUtc, c.CharacterId select c).Select(Clone).ToList(); } } public string FindOwnerOfCharacterId(long characterId) { lock (_gate) { string value; return _owner.TryGetValue(characterId, out value) ? value : null; } } public RegisteredCharacter FindByNameKey(string nameKey) { lock (_gate) { RegisteredCharacter value; return (nameKey != null && _byName.TryGetValue(nameKey, out value)) ? Clone(value) : null; } } private static RegisteredCharacter Clone(RegisteredCharacter c) { return new RegisteredCharacter { Steam64 = c.Steam64, CharacterId = c.CharacterId, Name = c.Name, NameKey = c.NameKey, HeadSeq = c.HeadSeq, HeadSha256 = (byte[])c.HeadSha256.Clone(), HeadLength = c.HeadLength, HeadCommittedUtc = c.HeadCommittedUtc, FirstCommittedUtc = c.FirstCommittedUtc }; } public byte[] ReadRevision(string steam64, long characterId, int seq) { lock (_gate) { string text = Stem(CharDir(steam64, characterId), seq); if (!_fs.Exists(text + ".ok") || !_fs.Exists(text + ".fch")) { return null; } byte[] array = _fs.ReadAllBytes(text + ".fch"); return (Hex(Sha(array)) == Encoding.ASCII.GetString(_fs.ReadAllBytes(text + ".ok")).Trim()) ? array : null; } } public List<int> ListCommittedRevisions(string steam64, long characterId) { lock (_gate) { return CommittedSeqs(CharDir(steam64, characterId)); } } public CommitResult Commit(string steam64, long characterId, byte[] file, int parentSeq, TransferKind kind) { lock (_gate) { try { return CommitCore(steam64, characterId, file, parentSeq, kind); } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) { return new CommitResult { Status = CommitStatus.Failed, Error = "storage error: " + ex.GetType().Name + ": " + ex.Message }; } } } private CommitResult CommitCore(string steam64, long characterId, byte[] file, int parentSeq, TransferKind kind) { if (!IsValidSteam64(steam64)) { return Invalid("not a Steam64 ID"); } if (file == null || file.Length < 1 || file.Length > 1048576) { return Invalid("file size outside 1.." + 1048576 + " bytes"); } FchParseResult fchParseResult = FchParser.Parse(file); if (!fchParseResult.Ok) { return new CommitResult { Status = CommitStatus.Invalid, Error = "not a valid character file: " + fchParseResult.Error }; } ParsedCharacter character = fchParseResult.Character; if (character.PlayerId != characterId) { return new CommitResult { Status = CommitStatus.Invalid, Error = "the file's CharacterID " + character.PlayerId + " does not match the transfer's " + characterId, Parsed = character }; } if (string.IsNullOrEmpty(character.Name)) { return new CommitResult { Status = CommitStatus.Invalid, Error = "the file has no character name", Parsed = character }; } string text = CharDir(steam64, characterId); _fs.CreateDirectory(text); CleanupUncommitted(text); byte[] array = Sha(file); List<int> list = CommittedSeqs(text); HeadInfo headInfo = LoadHead(text, list); if (headInfo != null && headInfo.Sha.SequenceEqual(array)) { return new CommitResult { Status = CommitStatus.Unchanged, Seq = headInfo.Seq, Sha256 = array, Parsed = character }; } int seq = ((list.Count != 0) ? list.Max() : 0) + 1; string text2 = Stem(text, seq); DateTime committed = _utcNow(); _fs.WriteDurable(text2 + ".fch.tmp", file); if (!Sha(_fs.ReadAllBytes(text2 + ".fch.tmp")).SequenceEqual(array)) { _fs.Delete(text2 + ".fch.tmp"); return new CommitResult { Status = CommitStatus.Failed, Error = "read-back verification failed" }; } _fs.Move(text2 + ".fch.tmp", text2 + ".fch"); StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append("steam64=").Append(steam64).Append('\n'); stringBuilder.Append("characterId=").Append(characterId.ToString(CultureInfo.InvariantCulture)).Append('\n'); stringBuilder.Append("name=").Append(character.Name.Replace('\n', ' ').Replace('\r', ' ')).Append('\n'); stringBuilder.Append("seq=").Append(seq.ToString(CultureInfo.InvariantCulture)).Append('\n'); stringBuilder.Append("parentSeq=").Append(parentSeq.ToString(CultureInfo.InvariantCulture)).Append('\n'); stringBuilder.Append("kind=").Append(kind.ToString()).Append('\n'); stringBuilder.Append("sha256=").Append(Hex(array)).Append('\n'); stringBuilder.Append("length=").Append(file.Length.ToString(CultureInfo.InvariantCulture)).Append('\n'); stringBuilder.Append("committedUtc=").Append(committed.ToString("o", CultureInfo.InvariantCulture)).Append('\n'); stringBuilder.Append("profileVersion=").Append(character.ProfileVersion.ToString(CultureInfo.InvariantCulture)).Append('\n'); stringBuilder.Append("firstSpawn=").Append(character.FirstSpawn ? "1" : "0").Append('\n'); _fs.WriteDurable(text2 + ".meta.tmp", Encoding.UTF8.GetBytes(stringBuilder.ToString())); _fs.Move(text2 + ".meta.tmp", text2 + ".meta"); _fs.WriteDurable(text2 + ".ok.tmp", Encoding.ASCII.GetBytes(Hex(array))); _fs.Move(text2 + ".ok.tmp", text2 + ".ok"); UpdateIndex(steam64, characterId, character.Name, seq, array, file.Length, committed); return new CommitResult { Status = CommitStatus.Committed, Seq = seq, Sha256 = array, Parsed = character }; } private static CommitResult Invalid(string why) { return new CommitResult { Status = CommitStatus.Invalid, Error = why }; } private List<int> CommittedSeqs(string dir) { List<int> list = new List<int>(); string[] files = _fs.GetFiles(dir, "rev-*.ok"); for (int i = 0; i < files.Length; i++) { string fileName = Path.GetFileName(files[i]); if (fileName.Length == "rev-000000.ok".Length && int.TryParse(fileName.Substring(4, 6), NumberStyles.None, CultureInfo.InvariantCulture, out var result) && result > 0) { list.Add(result); } } list.Sort(); return list; } private HeadInfo LoadHead(string dir, List<int> seqs) { for (int num = seqs.Count - 1; num >= 0; num--) { string text = Stem(dir, seqs[num]); try { if (!_fs.Exists(text + ".fch")) { _warn("revision " + seqs[num] + " in " + dir + " has a commit marker but no data file; skipped"); } else { byte[] array = _fs.ReadAllBytes(text + ".fch"); byte[] array2 = Sha(array); if (!(Hex(array2) != Encoding.ASCII.GetString(_fs.ReadAllBytes(text + ".ok")).Trim())) { Dictionary<string, string> dictionary = ReadMeta(text + ".meta"); if (!dictionary.ContainsKey("committedUtc") || !DateTime.TryParse(dictionary["committedUtc"], CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var result)) { result = DateTime.MinValue; } return new HeadInfo { Seq = seqs[num], Sha = array2, Length = array.Length, Name = (dictionary.ContainsKey("name") ? dictionary["name"] : ""), Committed = result }; } _warn("revision " + seqs[num] + " in " + dir + " fails its hash; skipped"); } } catch (IOException ex) { _warn("revision " + seqs[num] + " in " + dir + " unreadable: " + ex.Message); } } return null; } private Dictionary<string, string> ReadMeta(string path) { Dictionary<string, string> dictionary = new Dictionary<string, string>(StringComparer.Ordinal); if (!_fs.Exists(path)) { return dictionary; } string[] array = Encoding.UTF8.GetString(_fs.ReadAllBytes(path)).Split(new char[1] { '\n' }); foreach (string text in array) { int num = text.IndexOf('='); if (num > 0) { dictionary[text.Substring(0, num)] = text.Substring(num + 1); } } return dictionary; } private void CleanupUncommitted(string dir) { HashSet<int> hashSet = new HashSet<int>(CommittedSeqs(dir)); string[] files = _fs.GetFiles(dir, "*"); foreach (string path in files) { string fileName = Path.GetFileName(path); int result; if (fileName.EndsWith(".tmp", StringComparison.Ordinal)) { SafeDelete(path); } else if (fileName.StartsWith("rev-", StringComparison.Ordinal) && fileName.Length > 10 && int.TryParse(fileName.Substring(4, 6), NumberStyles.None, CultureInfo.InvariantCulture, out result) && !hashSet.Contains(result)) { SafeDelete(path); } } } private void SafeDelete(string path) { try { _fs.Delete(path); } catch (IOException) { } catch (UnauthorizedAccessException) { } } public void Rebuild() { lock (_gate) { _byAccount.Clear(); _owner.Clear(); _byName.Clear(); string[] directories = _fs.GetDirectories(_root); foreach (string text in directories) { string fileName = Path.GetFileName(text); if (!IsValidSteam64(fileName)) { continue; } string[] directories2 = _fs.GetDirectories(text); foreach (string text2 in directories2) { if (!long.TryParse(Path.GetFileName(text2), NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var result)) { continue; } CleanupUncommitted(text2); List<int> list = CommittedSeqs(text2); HeadInfo headInfo = LoadHead(text2, list); if (headInfo == null) { continue; } DateTime value = DateTime.MinValue; if (list.Count > 0) { Dictionary<string, string> dictionary = ReadMeta(Stem(text2, list[0]) + ".meta"); if (dictionary.ContainsKey("committedUtc") && DateTime.TryParse(dictionary["committedUtc"], CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var result2)) { value = result2; } } UpdateIndex(fileName, result, headInfo.Name, headInfo.Seq, headInfo.Sha, headInfo.Length, headInfo.Committed, value); } } } } private void UpdateIndex(string steam, long id, string name, int seq, byte[] sha, int length, DateTime committed, DateTime? first = null) { if (!_byAccount.TryGetValue(steam, out var value)) { value = new Dictionary<long, RegisteredCharacter>(); _byAccount[steam] = value; } if (!value.TryGetValue(id, out var value2)) { RegisteredCharacter obj = new RegisteredCharacter { Steam64 = steam, CharacterId = id, FirstCommittedUtc = (first ?? committed) }; value2 = (value[id] = obj); } else if (first.HasValue) { value2.FirstCommittedUtc = first.Value; } if (!string.IsNullOrEmpty(value2.NameKey) && _byName.ContainsKey(value2.NameKey) && _byName[value2.NameKey] == value2) { _byName.Remove(value2.NameKey); } value2.Name = name; value2.HeadSeq = seq; value2.HeadSha256 = sha; value2.HeadLength = length; value2.HeadCommittedUtc = committed; value2.NameKey = NameNormalizer.KeyOrNull(name) ?? ""; _owner[id] = steam; if (value2.NameKey.Length > 0 && !_byName.ContainsKey(value2.NameKey)) { _byName[value2.NameKey] = value2; } } public string CharacterDirectory(string steam64, long characterId) { return CharDir(steam64, characterId); } public bool HasCharacter(string steam64, long characterId) { lock (_gate) { return _fs.DirectoryExists(CharDir(steam64, characterId)); } } public void DetachCharacter(string steam64, long characterId, string destination) { lock (_gate) { _fs.MoveDirectory(CharDir(steam64, characterId), destination); ForgetIndex(steam64, characterId); } } public void AttachCharacter(string steam64, long characterId, string source) { lock (_gate) { _fs.MoveDirectory(source, CharDir(steam64, characterId)); string dir = CharDir(steam64, characterId); CleanupUncommitted(dir); List<int> seqs = CommittedSeqs(dir); HeadInfo headInfo = LoadHead(dir, seqs); if (headInfo != null) { UpdateIndex(steam64, characterId, headInfo.Name, headInfo.Seq, headInfo.Sha, headInfo.Length, headInfo.Committed); } } } private void ForgetIndex(string steam, long id) { if (_byAccount.TryGetValue(steam, out var value)) { if (value.TryGetValue(id, out var value2)) { if (!string.IsNullOrEmpty(value2.NameKey) && _byName.ContainsKey(value2.NameKey) && _byName[value2.NameKey] == value2) { _byName.Remove(value2.NameKey); } value.Remove(id); } if (value.Count == 0) { _byAccount.Remove(steam); } } _owner.Remove(id); } public RegisteredCharacter HeadOf(string steam64, long characterId) { lock (_gate) { Dictionary<long, RegisteredCharacter> value; RegisteredCharacter value2; return (_byAccount.TryGetValue(steam64, out value) && value.TryGetValue(characterId, out value2)) ? Clone(value2) : null; } } public List<KeyValuePair<string, long>> ListLiveCharacters() { lock (_gate) { List<KeyValuePair<string, long>> list = new List<KeyValuePair<string, long>>(); string[] directories = _fs.GetDirectories(_root); foreach (string text in directories) { string fileName = Path.GetFileName(text); if (!IsValidSteam64(fileName)) { continue; } string[] directories2 = _fs.GetDirectories(text); for (int j = 0; j < directories2.Length; j++) { if (long.TryParse(Path.GetFileName(directories2[j]), NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var result)) { list.Add(new KeyValuePair<string, long>(fileName, result)); } } } return list; } } public int Prune(string steam64, long characterId) { lock (_gate) { string dir = CharDir(steam64, characterId); List<int> list = CommittedSeqs(dir); if (list.Count <= 2) { return 0; } HashSet<int> keep = new HashSet<int>(); keep.Add(list[list.Count - 1]); keep.Add(list[list.Count - 2]); foreach (int item in list.Skip(Math.Max(0, list.Count - 50))) { keep.Add(item); } DateTime dateTime = _utcNow().Date.AddDays(-30.0); Dictionary<DateTime, int> dictionary = new Dictionary<DateTime, int>(); foreach (int item2 in list) { Dictionary<string, string> dictionary2 = ReadMeta(Stem(dir, item2) + ".meta"); if (!dictionary2.ContainsKey("committedUtc") || !DateTime.TryParse(dictionary2["committedUtc"], CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var result)) { keep.Add(item2); } else if (result.Date >= dateTime) { dictionary[result.Date] = item2; } } foreach (int value in dictionary.Values) { keep.Add(value); } int num = 0; foreach (int item3 in list.Where((int x) => !keep.Contains(x))) { string text = Stem(dir, item3); SafeDelete(text + ".ok"); SafeDelete(text + ".meta"); SafeDelete(text + ".fch"); num++; } return num; } } } } namespace Beastheim.ServerMods.Characters.Server { public sealed class ServerCharacterEngine { private readonly RevisionStore _store; private readonly Func<DateTime> _utcNow; private readonly Action<string> _log; private readonly FreshAllowList _allow = FreshAllowList.EvidenceDefault(); private readonly Dictionary<object, ChunkReceiver> _receivers = new Dictionary<object, ChunkReceiver>(); private readonly ICharacterTrace _t; private readonly Dictionary<object, double[]> _timing = new Dictionary<object, double[]>(); public Func<object, int> ConnId = (object c) => 0; public CharacterServerMode Mode { get; private set; } public ServerCharacterEngine(RevisionStore store, CharacterServerMode mode, Action<string> log, Func<DateTime> utcNow = null, ICharacterTrace trace = null) { _t = trace ?? NullTrace.Instance; if (mode == CharacterServerMode.Enforce) { throw new NotSupportedException("Enforce mode is not available in this build; it needs the dedicated-server test gate first."); } _store = store; Mode = mode; _log = log ?? ((Action<string>)delegate { }); _utcNow = utcNow ?? ((Func<DateTime>)(() => DateTime.UtcNow)); } public OutMsg Caps() { return new OutMsg("Beastheim_ServerMods_CharCaps", CapsMessage.ForMode(Mode).ToBytes()); } public IList<OutMsg> OnClaim(object conn, string steam64, byte[] data) { List<OutMsg> list = new List<OutMsg>(); if (Mode == CharacterServerMode.Off) { return list; } if (!ClaimMessage.TryParse(data, out var m, out var error)) { _log("[" + steam64 + "] claim rejected as malformed: " + error); return list; } _t.Event("claim.received", "conn", ConnId(conn), "steam", steam64, "characterId", m.CharacterId, "name", m.Name, "bytes", m.FileLength, "sha", TraceFormat.Short(m.Sha256), "lastAckedSeq", m.LastAckedSeq, "source", m.Source, "unreadable", m.Unreadable); ClaimVerdict claimVerdict = ClaimEvaluator.Evaluate(m, steam64, _store); _t.Event("claim.verdict", "conn", ConnId(conn), "steam", steam64, "code", claimVerdict.Code, "wouldReject", claimVerdict.WouldReject, "needBytes", claimVerdict.NeedBytes, "registeredCharacterId", (claimVerdict.Registered == null) ? 0 : claimVerdict.Registered.CharacterId, "headSeq", (claimVerdict.Registered != null) ? claimVerdict.Registered.HeadSeq : 0, "headSha", (claimVerdict.Registered == null) ? "none" : TraceFormat.Short(claimVerdict.Registered.HeadSha256), "admissionAffected", false); _log("[" + steam64 + "] claim: character " + m.CharacterId + " '" + m.Name + "', " + m.FileLength + " bytes -> " + claimVerdict.Code + (claimVerdict.WouldReject ? (" OBSERVE: would REJECT admission (" + claimVerdict.PlayerMessage + ")") : " OBSERVE: would PASS")); if (claimVerdict.Outcome == ClaimOutcome.Match) { _t.Event("claim.ack.sent", "conn", ConnId(conn), "steam", steam64, "seq", claimVerdict.Registered.HeadSeq, "sha", TraceFormat.Short(claimVerdict.Registered.HeadSha256)); } if (claimVerdict.Outcome == ClaimOutcome.Match) { list.Add(new OutMsg("Beastheim_ServerMods_CharAck", new AckMessage { TransferId = 0L, Status = AckStatus.Unchanged, Seq = claimVerdict.Registered.HeadSeq, Sha256 = claimVerdict.Registered.HeadSha256, Text = "claim matches" }.ToBytes()) { Conn = conn }); } if (claimVerdict.NeedBytes) { _t.Event("need.sent", "conn", ConnId(conn), "steam", steam64, "reason", claimVerdict.NeedReason); } if (claimVerdict.NeedBytes) { list.Add(new OutMsg("Beastheim_ServerMods_CharNeed", new NeedMessage { Reason = claimVerdict.NeedReason }.ToBytes()) { Conn = conn }); } return list; } public IList<OutMsg> OnChunk(object conn, string steam64, byte[] data, double now) { List<OutMsg> list = new List<OutMsg>(); if (Mode == CharacterServerMode.Off) { return list; } if (!ChunkMessage.TryParse(data, out var m, out var error)) { _log("[" + steam64 + "] chunk rejected as malformed: " + error); if (_receivers.TryGetValue(conn, out var value)) { long num = value.Abort(); if (num != 0L) { list.Add(Ack(conn, num, AckStatus.Aborted, "malformed chunk: " + error)); } } return list; } if (!_receivers.TryGetValue(conn, out var value2)) { value2 = new ChunkReceiver(); _receivers[conn] = value2; } if (!_timing.TryGetValue(conn, out var value3) || (long)value3[0] != m.TransferId) { value3 = new double[3] { m.TransferId, now, now }; _timing[conn] = value3; _t.Event("transfer.begin", "conn", ConnId(conn), "steam", steam64, "transferId", m.TransferId, "kind", m.Kind, "characterId", m.CharacterId, "bytes", m.TotalLength, "chunks", m.Count, "declaredSha", TraceFormat.Short(m.Sha256), "parentSeq", m.ParentSeq); } _t.Event("chunk.received", "conn", ConnId(conn), "transferId", m.TransferId, "index", m.Index, "count", m.Count, "payloadBytes", m.Payload.Length, "gapMs", (m.Index == 0) ? (-1.0) : Math.Round((now - value3[2]) * 1000.0), "sinceFirstMs", Math.Round((now - value3[1]) * 1000.0)); value3[2] = now; ChunkReceiver.Result result = value2.Feed(m, now); if (result.AbandonedTransferId != 0L) { _log("[" + steam64 + "] transfer " + result.AbandonedTransferId + " abandoned by the client before it completed; previous revision untouched"); list.Add(Ack(conn, result.AbandonedTransferId, AckStatus.Aborted, "superseded before completion")); } if (result.Status == ChunkReceiver.Status.Error) { _log("[" + steam64 + "] transfer " + result.TransferId + " failed: " + result.Error + "; previous revision untouched"); _t.Event("transfer.aborted", "conn", ConnId(conn), "transferId", result.TransferId, "reason", result.Error, "previousRevisionTouched", false); list.Add(Ack(conn, result.TransferId, AckStatus.Aborted, result.Error)); return list; } if (result.Status == ChunkReceiver.Status.InProgress) { return list; } list.Add(Complete(conn, steam64, result, now)); return list; } private OutMsg Complete(object conn, string steam64, ChunkReceiver.Result r, double now) { RegisteredCharacter registeredCharacter = null; foreach (RegisteredCharacter accountCharacter in _store.GetAccountCharacters(steam64)) { if (accountCharacter.CharacterId == r.CharacterId) { registeredCharacter = accountCharacter; } } byte[] previousBytes = ((registeredCharacter != null) ? _store.ReadRevision(steam64, r.CharacterId, registeredCharacter.HeadSeq) : null); DateTime committedBefore = registeredCharacter?.HeadCommittedUtc ?? DateTime.MinValue; _t.Event("transfer.complete", "conn", ConnId(conn), "steam", steam64, "transferId", r.TransferId, "kind", r.Kind, "bytes", r.File.Length, "declaredSha", TraceFormat.Short(r.Sha256), "reassembledSha", TraceFormat.Short(Sha(r.File)), "shaMatch", true); _t.Event("commit.begin", "conn", ConnId(conn), "transferId", r.TransferId, "characterId", r.CharacterId); Stopwatch stopwatch = Stopwatch.StartNew(); CommitResult commitResult = _store.Commit(steam64, r.CharacterId, r.File, r.ParentSeq, r.Kind); _t.Event("commit.done", "conn", ConnId(conn), "transferId", r.TransferId, "status", commitResult.Status, "seq", commitResult.Seq, "sha", TraceFormat.Short(commitResult.Sha256), "ms", stopwatch.ElapsedMilliseconds, "error", commitResult.Error); if (commitResult.Status == CommitStatus.Committed || commitResult.Status == CommitStatus.Unchanged) { _log("[" + steam64 + "] " + r.Kind.ToString() + " transfer " + r.TransferId + " committed as revision " + commitResult.Seq + ((commitResult.Status == CommitStatus.Unchanged) ? " (unchanged)" : "") + ", " + r.File.Length + " bytes, character " + r.CharacterId + " '" + ((commitResult.Parsed != null) ? commitResult.Parsed.Name : "") + "'"); if (commitResult.Status == CommitStatus.Committed) { Observe(steam64, commitResult, registeredCharacter, previousBytes, committedBefore); } _t.Event("ack.sent", "conn", ConnId(conn), "transferId", r.TransferId, "status", (commitResult.Status == CommitStatus.Committed) ? AckStatus.Committed : AckStatus.Unchanged, "seq", commitResult.Seq, "sha", TraceFormat.Short(commitResult.Sha256), "afterCommit", true); return Ack(conn, r.TransferId, (commitResult.Status == CommitStatus.Committed) ? AckStatus.Committed : AckStatus.Unchanged, "", commitResult.Seq, commitResult.Sha256); } _log("[" + steam64 + "] transfer " + r.TransferId + " NOT stored (" + commitResult.Status.ToString() + "): " + commitResult.Error + "; previous revision untouched"); _t.Event("ack.sent", "conn", ConnId(conn), "transferId", r.Tr