namespace Aeshnidae.DatBackup; /// /// Admin commands for the dat snapshot store. /// /// One command with subcommands rather than five separate ones, because they are /// only ever used together and a stray "/datrestore" in a chat window should not be /// one keystroke from replacing a gigabyte of game data. /// public static class Commands { [CommandHandler("datbackup", AccessLevel.Admin, CommandHandlerFlag.None, 0, "Snapshot, inspect and restore the server's dat files.", "/datbackup - take a snapshot now\n" + "/datbackup list - what is stored\n" + "/datbackup verify [id]- re-hash a snapshot's stored copies (newest if no id)\n" + "/datbackup restore - put a snapshot back (server must not hold the dats)")] public static void HandleDatBackup(Session session, params string[] parameters) { var sub = parameters.Length > 0 ? parameters[0].ToLowerInvariant() : "take"; var store = Mod.Settings.ResolvedBackupDirectory; switch (sub) { case "take": Take(session, store); break; case "list": ListAll(session, store); break; case "verify": Verify(session, store, parameters.Skip(1).FirstOrDefault()); break; case "restore": Restore(session, store, parameters.Skip(1).FirstOrDefault()); break; default: Reply(session, $"Unknown subcommand '{sub}'. Try list, verify or restore."); break; } } private static void Take(Session session, string store) { var source = Mod.Settings.ResolvedDatDirectory; Reply(session, $"Snapshotting {source} ..."); var (snapshot, message) = SnapshotStore.Take( store, source, "manual", Mod.Settings.KeepSnapshots, m => ModManager.Log($"[{Mod.Name}] {m}")); Reply(session, snapshot is null ? $"Failed: {message}" : message); } private static void ListAll(Session session, string store) { var all = SnapshotStore.List(store); if (all.Count == 0) { Reply(session, $"No snapshots in {store}."); return; } var text = new StringBuilder() .AppendLine($"{all.Count} snapshot(s) in {store}, using {SnapshotStore.StoreSize(store) / 1024 / 1024:N0} MB on disk:"); foreach (var s in all) text.AppendLine($" {s.Id} {s.TakenUtc.ToLocalTime():yyyy-MM-dd HH:mm} " + $"{s.Files.Count} file(s), {s.TotalSize / 1024 / 1024:N0} MB [{s.Label}]"); Reply(session, text.ToString()); } private static void Verify(Session session, string store, string? id) { var snapshot = id is null ? SnapshotStore.Latest(store) : SnapshotStore.Find(store, id); if (snapshot is null) { Reply(session, id is null ? "There are no snapshots to verify." : $"No snapshot '{id}'."); return; } Reply(session, $"Verifying {snapshot.Id} - this re-reads every stored copy ..."); var (ok, problems) = SnapshotStore.Verify(store, snapshot); if (ok) { Reply(session, $"Snapshot {snapshot.Id} is intact - {snapshot.Files.Count} file(s) match their hashes."); return; } var text = new StringBuilder().AppendLine($"Snapshot {snapshot.Id} has problems:"); foreach (var p in problems) text.AppendLine($" {p}"); Reply(session, text.ToString()); } private static void Restore(Session session, string store, string? id) { if (id is null) { Reply(session, "Name the snapshot to restore - /datbackup list shows the ids."); return; } var snapshot = SnapshotStore.Find(store, id); if (snapshot is null) { Reply(session, $"No snapshot '{id}'."); return; } // Restore into wherever the snapshot came from. Restoring a server dat set // into a client profile, or the reverse, is never what anyone means. var destination = snapshot.Source; var (ok, message) = SnapshotStore.Restore( store, snapshot, destination, m => ModManager.Log($"[{Mod.Name}] {m}")); Reply(session, message); if (ok) Reply(session, "Restart the server so it reads the restored dats."); } [CommandHandler("datbackup-reload", AccessLevel.Admin, CommandHandlerFlag.None, 0, "Restart the Aeshnidae.DatBackup mod, re-reading Settings.json.", "/datbackup-reload")] public static void HandleReload(Session session, params string[] parameters) { var container = Mod.Container; if (container is null) { Reply(session, $"{Mod.Name} is not loaded."); return; } container.Restart(); Reply(session, $"{Mod.Name} restarted."); } /// /// Console and in-game both reach these commands, and a null session is how the /// console arrives - so every reply goes through here. /// /// One chat message per line, with any carriage return stripped: /// StringBuilder.AppendLine emits Environment.NewLine, which is CR LF on Windows. /// The client breaks the line on LF and renders the stray CR as an unmapped glyph - /// those are the musical notes. Matches every other mod here. /// private static void Reply(Session? session, string message) { if (session?.Player is null) { Console.WriteLine(message); return; } foreach (var line in (message ?? "").Split('\n')) { var text = line.TrimEnd('\r'); if (!string.IsNullOrWhiteSpace(text)) session.Player.SendMessage(text); } } }