fix: Cleans up file system paths and archiving (#815)

* Makes EnsureDirectory properly work for relative and absolute paths
* Adds a `PathUtility.GetFullPath` which returns full paths for relative paths to `Core.BaseDirectory`. If the path is absolute, it will return as-is.
* Moves EnsureDirectory to `PathUtility`. So `ScriptsHandler.EnsureDirectory` and `AssemblyHandler.EnsureDirectory` are now `PathUtility.EnsureDirectory`
* Fixes crash guard so that it copies accounts properly.
* Changes world save and auto archive to use a random folder name inside of the temp folder.
This commit is contained in:
Kamron Batman 2021-10-05 08:54:44 -07:00 • committed by GitHub
parent da31153b20
commit a55e271a69
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 447 additions and 341 deletions

View file

@ -173,28 +173,6 @@ namespace Server
return null; return null;
} }
public static string EnsureDirectory(string dir)
{
var path = Path.Combine(Core.BaseDirectory, dir);
Directory.CreateDirectory(path);
return path;
}
public static void EnsureDirectory(FileInfo fi)
{
var dir = fi.DirectoryName;
if (dir != null)
{
Directory.CreateDirectory(dir);
}
}
public static void EnsureDirectory(DirectoryInfo di)
{
Directory.CreateDirectory(di.FullName);
}
} }
public class TypeCache public class TypeCache

View file

@ -41,7 +41,7 @@ namespace Server
{ {
var path = Path.Combine(savePath, name); var path = Path.Combine(savePath, name);
AssemblyHandler.EnsureDirectory(path); PathUtility.EnsureDirectory(path);
string binPath = Path.Combine(path, $"{name}.bin"); string binPath = Path.Combine(path, $"{name}.bin");
using var bin = new BinaryFileWriter(binPath, true); using var bin = new BinaryFileWriter(binPath, true);
@ -54,7 +54,7 @@ namespace Server
{ {
var path = Path.Combine(savePath, name); var path = Path.Combine(savePath, name);
AssemblyHandler.EnsureDirectory(path); PathUtility.EnsureDirectory(path);
string binPath = Path.Combine(path, $"{name}.bin"); string binPath = Path.Combine(path, $"{name}.bin");

View file

@ -169,6 +169,28 @@ namespace Server
return str; return str;
} }
public static string TrimMultiline(this string str, string lineSeparator = "\n")
{
var parts = str.Split(lineSeparator);
for (var i = 0; i < parts.Length; i++)
{
parts[i] = parts[i].Trim();
}
return string.Join(lineSeparator, parts);
}
public static string IndentMultiline(this string str, string indent = "\t", string lineSeparator = "\n")
{
var parts = str.Split(lineSeparator);
for (var i = 0; i < parts.Length; i++)
{
parts[i] = $"{indent}{parts[i]}";
}
return string.Join(lineSeparator, parts);
}
public static List<string> Wrap(this string value, int perLine, int maxLines) public static List<string> Wrap(this string value, int perLine, int maxLines)
{ {
if ((value = value?.Trim() ?? "").Length <= 0) if ((value = value?.Trim() ?? "").Length <= 0)

View file

@ -242,7 +242,7 @@ namespace Server
} }
#if DEBUG_TIMERS #if DEBUG_TIMERS
tw.WriteLine("\nStack Traces:"); tw.WriteLine($"{Environment.NewLine}Stack Traces:");
foreach (var kvp in DelayCallTimer._stackTraces) foreach (var kvp in DelayCallTimer._stackTraces)
{ {
tw.WriteLine(kvp.Value); tw.WriteLine(kvp.Value);

View file

@ -0,0 +1,67 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2021 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: PathUtility.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.IO;
using Server.Text;
namespace Server
{
public static class PathUtility
{
public static string EnsureDirectory(string dir)
{
var path = GetFullPath(dir, Core.BaseDirectory);
Directory.CreateDirectory(path);
return path;
}
public static void EnsureDirectory(this FileInfo fi)
{
var dir = GetFullPath(fi.DirectoryName, Core.BaseDirectory);
if (dir != null)
{
Directory.CreateDirectory(dir);
}
}
public static void EnsureDirectory(this DirectoryInfo di)
{
var file = GetFullPath(di.FullName, Core.BaseDirectory);
Directory.CreateDirectory(file);
}
public static string GetFullPath(string relativeOrAbsolutePath) =>
GetFullPath(relativeOrAbsolutePath, Core.BaseDirectory);
public static string GetFullPath(string relativeOrAbsolutePath, string basePath) =>
relativeOrAbsolutePath switch
{
null => null,
"" => basePath,
_ => Path.IsPathRooted(relativeOrAbsolutePath)
? relativeOrAbsolutePath
: Path.GetFullPath(relativeOrAbsolutePath, basePath)
};
public static string EnsureRandomPath(string basePath)
{
Span<byte> bytes = stackalloc byte[8];
Utility.RandomBytes(bytes);
return EnsureDirectory(Path.Combine(basePath, bytes.ToHexString()));
}
}
}

View file

@ -1331,28 +1331,6 @@ namespace Server
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static T Max<T>(T val, T max) where T : IComparable<T> => val.CompareTo(max) > 0 ? val : max; public static T Max<T>(T val, T max) where T : IComparable<T> => val.CompareTo(max) > 0 ? val : max;
public static string TrimMultiline(this string str, string lineSeparator = "\n")
{
var parts = str.Split(lineSeparator);
for (var i = 0; i < parts.Length; i++)
{
parts[i] = parts[i].Trim();
}
return string.Join(lineSeparator, parts);
}
public static string IndentMultiline(this string str, string indent = "\t", string lineSeparator = "\n")
{
var parts = str.Split(lineSeparator);
for (var i = 0; i < parts.Length; i++)
{
parts[i] = $"{indent}{parts[i]}";
}
return string.Join(lineSeparator, parts);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Tidy<T>(this List<T> list) where T : ISerializable public static void Tidy<T>(this List<T> list) where T : ISerializable
{ {

View file

@ -40,7 +40,7 @@ namespace Server
var path = Path.Combine(savePath, typeName); var path = Path.Combine(savePath, typeName);
AssemblyHandler.EnsureDirectory(path); PathUtility.EnsureDirectory(path);
string idxPath = Path.Combine(path, $"{typeName}.idx"); string idxPath = Path.Combine(path, $"{typeName}.idx");
string tdbPath = Path.Combine(path, $"{typeName}.tdb"); string tdbPath = Path.Combine(path, $"{typeName}.tdb");

View file

@ -46,7 +46,6 @@ namespace Server
private static readonly ConcurrentQueue<Item> _decayQueue = new(); private static readonly ConcurrentQueue<Item> _decayQueue = new();
private static string _tempSavePath; // Path to the temporary folder for the save private static string _tempSavePath; // Path to the temporary folder for the save
private static string _savePath; // Path to "Saves" folder
private static bool _enableSaveStats; private static bool _enableSaveStats;
public const bool DirtyTrackingEnabled = false; public const bool DirtyTrackingEnabled = false;
@ -130,6 +129,8 @@ namespace Server
internal static List<Type> MobileTypes { get; } = new(); internal static List<Type> MobileTypes { get; } = new();
internal static List<Type> GuildTypes { get; } = new(); internal static List<Type> GuildTypes { get; } = new();
public static string SavePath { get; private set; }
public static WorldState WorldState { get; private set; } public static WorldState WorldState { get; private set; }
public static bool Saving => WorldState == WorldState.Saving; public static bool Saving => WorldState == WorldState.Saving;
public static bool Running => WorldState is not WorldState.Loading and not WorldState.Initial; public static bool Running => WorldState is not WorldState.Loading and not WorldState.Initial;
@ -141,10 +142,12 @@ namespace Server
public static void Configure() public static void Configure()
{ {
var tempSavePath = ServerConfiguration.GetOrUpdateSetting("world.tempSavePath", "temp"); var tempSavePath = ServerConfiguration.GetSetting("world.tempSavePath", "temp");
_tempSavePath = Path.Combine(Core.BaseDirectory, tempSavePath); _tempSavePath = PathUtility.GetFullPath(tempSavePath);
var savePath = ServerConfiguration.GetOrUpdateSetting("world.savePath", "Saves"); var savePath = ServerConfiguration.GetOrUpdateSetting("world.savePath", "Saves");
_savePath = Path.Combine(Core.BaseDirectory, savePath); SavePath = PathUtility.GetFullPath(savePath);
_enableSaveStats = ServerConfiguration.GetOrUpdateSetting("world.enableSaveStats", false); _enableSaveStats = ServerConfiguration.GetOrUpdateSetting("world.enableSaveStats", false);
// Mobiles & Items // Mobiles & Items
@ -256,7 +259,7 @@ namespace Server
logger.Information("Loading world"); logger.Information("Loading world");
var watch = Stopwatch.StartNew(); var watch = Stopwatch.StartNew();
Persistence.Load(_savePath); Persistence.Load(SavePath);
EventSink.InvokeWorldLoad(); EventSink.InvokeWorldLoad();
ProcessSafetyQueues(); ProcessSafetyQueues();
@ -344,7 +347,7 @@ namespace Server
var timestamp = Utility.GetTimeStamp(); var timestamp = Utility.GetTimeStamp();
var saveStatsPath = Path.Combine(Core.BaseDirectory, $"Logs/Saves/Save-Stats-{timestamp}.log"); var saveStatsPath = Path.Combine(Core.BaseDirectory, $"Logs/Saves/Save-Stats-{timestamp}.log");
AssemblyHandler.EnsureDirectory(saveStatsPath); PathUtility.EnsureDirectory(saveStatsPath);
using var op = new StreamWriter(saveStatsPath, true); using var op = new StreamWriter(saveStatsPath, true);
@ -388,7 +391,7 @@ namespace Server
{ {
Exception exception = null; Exception exception = null;
var tempPath = Path.Combine(_tempSavePath, Utility.GetTimeStamp()); var tempPath = PathUtility.EnsureRandomPath(_tempSavePath);
try try
{ {
@ -417,8 +420,8 @@ namespace Server
{ {
try try
{ {
EventSink.InvokeWorldSavePostSnapshot(_savePath, tempPath); EventSink.InvokeWorldSavePostSnapshot(SavePath, tempPath);
Directory.Move(tempPath, _savePath); Directory.Move(tempPath, SavePath);
} }
catch (Exception ex) catch (Exception ex)
{ {

View file

@ -1,4 +1,5 @@
using System; using System;
using System.Reflection;
using Server.Misc; using Server.Misc;
namespace Server.Tests namespace Server.Tests
@ -8,8 +9,8 @@ namespace Server.Tests
// Global setup // Global setup
static ServerFixture() static ServerFixture()
{ {
Core.Assembly = Assembly.GetExecutingAssembly();
Core.LoopContext = new EventLoopContext(); Core.LoopContext = new EventLoopContext();
Core.Expansion = Expansion.EJ; Core.Expansion = Expansion.EJ;
// Load Configurations // Load Configurations

View file

@ -0,0 +1,101 @@
using System.Collections.Generic;
using System.IO;
using System.Net;
using Server.Accounting.Security;
namespace Server.Accounting
{
public partial class Account
{
private void MigrateFrom(V3Content content)
{
_username = content.Username;
_passwordAlgorithm = content.PasswordAlgorithm;
_password = content.Password;
_accessLevel = content.AccessLevel;
_flags = content.Flags;
Created = content.Created;
_lastLogin = content.LastLogin;
_totalGold = content.TotalGold;
_totalPlat = content.TotalPlat;
_mobiles = content.Mobiles;
_comments = content.Comments;
_tags = content.Tags;
_loginIPs = content.LoginIPs;
_ipRestrictions = content.IpRestrictions;
_totalGameTime = content.TotalGameTime;
_email = content.Email;
}
private void Deserialize(IGenericReader reader, int version)
{
if (version != 2)
{
// Due to a bug where we were not versioning at all, reset so we don't have an issue deserializing
reader.Seek(0, SeekOrigin.Begin);
}
_username = reader.ReadString();
_passwordAlgorithm = version < 2 ? (PasswordProtectionAlgorithm)reader.ReadInt() : reader.ReadEnum<PasswordProtectionAlgorithm>();
_password = reader.ReadString();
_accessLevel = version < 2 ? (AccessLevel)reader.ReadInt() : reader.ReadEnum<AccessLevel>();
_flags = reader.ReadInt();
Created = reader.ReadDateTime();
_lastLogin = reader.ReadDateTime();
_totalGold = reader.ReadInt();
_totalPlat = reader.ReadInt();
var length = reader.ReadInt();
_mobiles = new Mobile[length];
for (int i = 0; i < length; i++)
{
_mobiles[i] = reader.ReadEntity<Mobile>();
}
length = reader.ReadInt();
_comments = length > 0 ? new List<AccountComment>(length) : null;
for (int i = 0; i < length; i++)
{
_comments!.Add(new AccountComment(reader));
}
length = reader.ReadInt();
_tags = length > 0 ? new List<AccountTag>(length) : null;
for (int i = 0; i < length; i++)
{
_tags!.Add(new AccountTag(reader));
}
length = reader.ReadInt();
_loginIPs = new IPAddress[length];
for (int i = 0; i < length; i++)
{
if (version < 2)
{
if (IPAddress.TryParse(reader.ReadString(), out var address))
{
_loginIPs[i] = Utility.Intern(address);
}
}
else
{
_loginIPs[i] = reader.ReadIPAddress();
}
}
length = reader.ReadInt();
_ipRestrictions = new string[length];
for (int i = 0; i < length; i++)
{
_ipRestrictions[i] = reader.ReadString();
}
_totalGameTime = reader.ReadTimeSpan();
_email = reader.ReadString();
Timer.StartTimer(AfterDeserialization);
}
}
}

View file

@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Net; using System.Net;
using System.Xml; using System.Xml;
using Server.Accounting.Security; using Server.Accounting.Security;
@ -357,97 +356,6 @@ namespace Server.Accounting
} }
} }
private void MigrateFrom(V3Content content)
{
_username = content.Username;
_passwordAlgorithm = content.PasswordAlgorithm;
_password = content.Password;
_accessLevel = content.AccessLevel;
_flags = content.Flags;
Created = content.Created;
_lastLogin = content.LastLogin;
_totalGold = content.TotalGold;
_totalPlat = content.TotalPlat;
_mobiles = content.Mobiles;
_comments = content.Comments;
_tags = content.Tags;
_loginIPs = content.LoginIPs;
_ipRestrictions = content.IpRestrictions;
_totalGameTime = content.TotalGameTime;
_email = content.Email;
}
private void Deserialize(IGenericReader reader, int version)
{
if (version != 2)
{
// Due to a bug where we were not versioning at all, reset so we don't have an issue deserializing
reader.Seek(0, SeekOrigin.Begin);
}
_username = reader.ReadString();
_passwordAlgorithm = version < 2 ? (PasswordProtectionAlgorithm)reader.ReadInt() : reader.ReadEnum<PasswordProtectionAlgorithm>();
_password = reader.ReadString();
_accessLevel = version < 2 ? (AccessLevel)reader.ReadInt() : reader.ReadEnum<AccessLevel>();
_flags = reader.ReadInt();
Created = reader.ReadDateTime();
_lastLogin = reader.ReadDateTime();
_totalGold = reader.ReadInt();
_totalPlat = reader.ReadInt();
var length = reader.ReadInt();
_mobiles = new Mobile[length];
for (int i = 0; i < length; i++)
{
_mobiles[i] = reader.ReadEntity<Mobile>();
}
length = reader.ReadInt();
_comments = length > 0 ? new List<AccountComment>(length) : null;
for (int i = 0; i < length; i++)
{
_comments!.Add(new AccountComment(reader));
}
length = reader.ReadInt();
_tags = length > 0 ? new List<AccountTag>(length) : null;
for (int i = 0; i < length; i++)
{
_tags!.Add(new AccountTag(reader));
}
length = reader.ReadInt();
_loginIPs = new IPAddress[length];
for (int i = 0; i < length; i++)
{
if (version < 2)
{
if (IPAddress.TryParse(reader.ReadString(), out var address))
{
_loginIPs[i] = Utility.Intern(address);
}
}
else
{
_loginIPs[i] = reader.ReadIPAddress();
}
}
length = reader.ReadInt();
_ipRestrictions = new string[length];
for (int i = 0; i < length; i++)
{
_ipRestrictions[i] = reader.ReadString();
}
_totalGameTime = reader.ReadTimeSpan();
_email = reader.ReadString();
Timer.StartTimer(AfterDeserialization);
}
/// <summary> /// <summary>
/// Deletes the account, all characters of the account, and all houses of those characters /// Deletes the account, all characters of the account, and all houses of those characters
/// </summary> /// </summary>

View file

@ -50,8 +50,7 @@ namespace Server.Compression
private static string DownloadTarForWindows() private static string DownloadTarForWindows()
{ {
var tempDir = Path.Combine(Core.BaseDirectory, "temp"); var tempDir = PathUtility.EnsureRandomPath(Path.GetTempPath());
AssemblyHandler.EnsureDirectory("temp");
var libarchiveFile = Path.Combine(tempDir, "libarchive.zip"); var libarchiveFile = Path.Combine(tempDir, "libarchive.zip");
using WebClient wc = new WebClient(); using WebClient wc = new WebClient();
@ -82,29 +81,28 @@ namespace Server.Compression
} }
public static bool CreateFromPaths( public static bool CreateFromPaths(
List<string> paths, IEnumerable<string> paths,
string destinationArchiveFileName, string destinationArchiveFileName,
string relativeTo,
string compressCommand = null, string compressCommand = null,
string compressionProgramPath = null string compressionProgramPath = null
) )
{ {
_pathToTar ??= GetPathToTar(); _pathToTar ??= GetPathToTar();
AssemblyHandler.EnsureDirectory(new FileInfo(destinationArchiveFileName)); new FileInfo(destinationArchiveFileName).EnsureDirectory();
var di = new DirectoryInfo(paths[0]);
var directory = di.Parent!.FullName;
using var builder = new ValueStringBuilder(); using var builder = new ValueStringBuilder();
for (var i = 0; i < paths.Count; i++) var i = 0;
foreach (var path in paths)
{ {
var path = paths[i]; builder.Append($"{(i++ > 0 ? " " : "")}\"{Path.GetRelativePath(relativeTo, path)}\"");
builder.Append($"{(i > 0 ? " " : "")}\"{Path.GetRelativePath(directory, path)}\"");
} }
var pathsToCompress = builder.ToString(); var pathsToCompress = builder.ToString();
var tarFlags = compressCommand == null ? "-acf" : "-cf"; var tarFlags = compressCommand == null ? "-acf" : "-cf";
var useExternalCompression = compressCommand != null ? $"--use-compress-program \"{compressCommand}\" " : ""; var useExternalCompression = compressCommand != null ? $"--use-compress-program \"{compressCommand}\" " : "";
var arguments = $"{useExternalCompression}{tarFlags} \"{destinationArchiveFileName}\" -C \"{directory}\" {pathsToCompress}"; var arguments = $"{useExternalCompression}{tarFlags} \"{destinationArchiveFileName}\" -C \"{relativeTo}\" {pathsToCompress}";
return RunTar(arguments, compressionProgramPath) == 0; return RunTar(arguments, compressionProgramPath) == 0;
} }

View file

@ -42,17 +42,15 @@ namespace Server.Compression
} }
} }
return TarArchive.ExtractToDirectory(fileNamePath, outputDirectory, "zstd -d", _pathToZstd); return TarArchive.ExtractToDirectory(fileNamePath, outputDirectory, "zstd -q -d", _pathToZstd);
} }
public static bool CreateFromPaths( public static bool CreateFromPaths(
List<string> paths, IEnumerable<string> paths,
string destinationArchiveFileName, string destinationArchiveFileName,
int compressionLevel = 10 string relativeTo
) )
{ {
Debug.Assert(compressionLevel is >= 1 and <= 22, $"{nameof(compressionLevel)} must be between 1 and 22");
// bsdtar has a bug and hangs, so we are doing it in two steps. // bsdtar has a bug and hangs, so we are doing it in two steps.
if (Core.IsWindows) if (Core.IsWindows)
{ {
@ -60,7 +58,7 @@ namespace Server.Compression
try try
{ {
if (!TarArchive.CreateFromPaths(paths, tempTarArchive)) if (!TarArchive.CreateFromPaths(paths, relativeTo, tempTarArchive))
{ {
return false; return false;
} }
@ -70,7 +68,7 @@ namespace Server.Compression
StartInfo = new ProcessStartInfo StartInfo = new ProcessStartInfo
{ {
FileName = Path.Combine(_pathToZstd, "zstd.exe"), FileName = Path.Combine(_pathToZstd, "zstd.exe"),
Arguments = $"-q -10 \"{tempTarArchive}\" -o \"{destinationArchiveFileName}\"" Arguments = $"-q \"{tempTarArchive}\" -o \"{destinationArchiveFileName}\""
} }
}; };
@ -89,7 +87,7 @@ namespace Server.Compression
} }
} }
return TarArchive.CreateFromPaths(paths, destinationArchiveFileName, "zstd -10", _pathToZstd); return TarArchive.CreateFromPaths(paths, destinationArchiveFileName, relativeTo, "zstd -q", _pathToZstd);
} }
} }
} }

View file

@ -77,7 +77,7 @@ namespace Server.Engines.Spawners
{ {
var stemDir = stem[..^file.Name.Length]; var stemDir = stem[..^file.Name.Length];
var fullOutputDir = Path.Combine(outputDir, stemDir); var fullOutputDir = Path.Combine(outputDir, stemDir);
AssemblyHandler.EnsureDirectory(fullOutputDir); PathUtility.EnsureDirectory(fullOutputDir);
var outputFileName = $"{file.Name[..^file.Extension.Length]}.json"; var outputFileName = $"{file.Name[..^file.Extension.Length]}.json";
ParsePremiumSpawnerFile(file.FullName, Path.Combine(fullOutputDir, outputFileName), options); ParsePremiumSpawnerFile(file.FullName, Path.Combine(fullOutputDir, outputFileName), options);

View file

@ -53,7 +53,7 @@ namespace Server.Engines.Spawners
if (!Path.IsPathRooted(path)) if (!Path.IsPathRooted(path))
{ {
path = Path.Combine(Core.BaseDirectory, path); path = Path.Combine(Core.BaseDirectory, path);
AssemblyHandler.EnsureDirectory(directory); PathUtility.EnsureDirectory(directory);
} }
else if (!Directory.Exists(directory)) else if (!Directory.Exists(directory))
{ {

View file

@ -4,6 +4,7 @@ using System.IO;
using Server.Accounting; using Server.Accounting;
using Server.Logging; using Server.Logging;
using Server.Network; using Server.Network;
using Server.Saves;
namespace Server.Misc namespace Server.Misc
{ {
@ -76,24 +77,23 @@ namespace Server.Misc
} }
} }
private static void CopyFile(string rootOrigin, string rootBackup, string path) private static void DirectoryCopy(string sourceDirName, string destDirName)
{ {
var originPath = Path.Combine(rootOrigin, path); // Get the subdirectories for the specified directory.
if (!File.Exists(originPath)) DirectoryInfo dir = new DirectoryInfo(sourceDirName);
DirectoryInfo[] dirs = dir.GetDirectories();
FileInfo[] files = dir.GetFiles();
foreach (FileInfo file in files)
{ {
return; string tempPath = Path.Combine(destDirName, file.Name);
file.CopyTo(tempPath, false);
} }
var backupPath = Path.Combine(rootBackup, path); foreach (DirectoryInfo subdir in dirs)
Directory.CreateDirectory(Path.GetDirectoryName(backupPath));
try
{ {
File.Copy(originPath, backupPath); string tempPath = Path.Combine(destDirName, subdir.Name);
} DirectoryCopy(subdir.FullName, tempPath);
catch
{
// ignored
} }
} }
@ -105,26 +105,12 @@ namespace Server.Misc
{ {
var timeStamp = Utility.GetTimeStamp(); var timeStamp = Utility.GetTimeStamp();
var root = Core.BaseDirectory; var backupPath = PathUtility.EnsureDirectory(Path.Combine(AutoArchive.BackupPath, "Crashed", timeStamp));
var rootBackup = Path.Combine(root, $"Backups/Crashed/{timeStamp}/"); var savePath = World.SavePath;
var rootOrigin = Path.Combine(root, "Saves/"); if (Directory.Exists(savePath))
{
// Copy files DirectoryCopy(savePath, backupPath);
CopyFile(rootOrigin, rootBackup, "Accounts/Accounts.xml"); }
CopyFile(rootOrigin, rootBackup, "Items/Items.bin");
CopyFile(rootOrigin, rootBackup, "Items/Items.idx");
CopyFile(rootOrigin, rootBackup, "Items/Items.tdb");
CopyFile(rootOrigin, rootBackup, "Mobiles/Mobiles.bin");
CopyFile(rootOrigin, rootBackup, "Mobiles/Mobiles.idx");
CopyFile(rootOrigin, rootBackup, "Mobiles/Mobiles.tdb");
CopyFile(rootOrigin, rootBackup, "Guilds/Guilds.bin");
CopyFile(rootOrigin, rootBackup, "Guilds/Guilds.idx");
CopyFile(rootOrigin, rootBackup, "Regions/Regions.bin");
CopyFile(rootOrigin, rootBackup, "Regions/Regions.idx");
logger.Information("Backup done"); logger.Information("Backup done");
} }
@ -160,7 +146,7 @@ namespace Server.Misc
try try
{ {
op.WriteLine("Mobiles: {0}", World.Mobiles.Count); op.WriteLine($"Accounts: {Accounts.Count}");
} }
catch catch
{ {
@ -169,15 +155,23 @@ namespace Server.Misc
try try
{ {
op.WriteLine("Items: {0}", World.Items.Count); op.WriteLine($"Mobiles: {World.Mobiles.Count}");
} }
catch catch
{ {
// ignored // ignored
} }
op.WriteLine("Exception:"); try
op.WriteLine(e.Exception); {
op.WriteLine($"Items: {World.Items.Count}");
}
catch
{
// ignored
}
op.WriteLine($"Exception: {e.Exception}");
op.WriteLine(); op.WriteLine();
op.WriteLine("Clients:"); op.WriteLine("Clients:");
@ -186,22 +180,22 @@ namespace Server.Misc
{ {
var states = TcpServer.Instances; var states = TcpServer.Instances;
op.WriteLine("- Count: {0}", states.Count); op.WriteLine($"- Count: {states.Count}");
foreach (var ns in TcpServer.Instances) foreach (var ns in TcpServer.Instances)
{ {
op.Write("+ {0}:", ns); op.Write($"+ {ns}:");
if (ns.Account is Account a) if (ns.Account is Account a)
{ {
op.Write(" (account = {0})", a.Username); op.Write($" (account = {a.Username})");
} }
var m = ns.Mobile; var m = ns.Mobile;
if (m != null) if (m != null)
{ {
op.Write(" (mobile = 0x{0:X} '{1}')", m.Serial.Value, m.Name); op.Write($" (mobile = 0x{m.Serial.Value:X} '{m.Name}')");
} }
op.WriteLine(); op.WriteLine();

View file

@ -2,9 +2,9 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Threading; using System.Threading;
using Microsoft.Toolkit.HighPerformance;
using Server.Compression; using Server.Compression;
using Server.Logging; using Server.Logging;
@ -29,6 +29,7 @@ namespace Server.Saves
{ {
private static readonly ILogger logger = LogFactory.GetLogger(typeof(AutoArchive)); private static readonly ILogger logger = LogFactory.GetLogger(typeof(AutoArchive));
private static string _tempArchivePath;
private static int _isArchiving; private static int _isArchiving;
private static DateTime _nextHourlyArchive; private static DateTime _nextHourlyArchive;
private static DateTime _nextDailyArchive; private static DateTime _nextDailyArchive;
@ -40,16 +41,25 @@ namespace Server.Saves
public static Action Prune { get; set; } public static Action Prune { get; set; }
public static string ArchivePath { get; private set; } public static string ArchivePath { get; private set; }
public static string BackupPath { get; private set; } public static string BackupPath { get; private set; }
public static string AutomaticBackupPath { get; private set; }
public static void Configure() public static void Configure()
{ {
BackupPath = ServerConfiguration.GetOrUpdateSetting("autoArchive.backupPath", "Backups/Automatic"); var tempArchivePath = ServerConfiguration.GetSetting("autoArchive.tempArchivePath", "temp");
ArchivePath = ServerConfiguration.GetOrUpdateSetting("autoArchive.archivePath", "Archives"); _tempArchivePath = PathUtility.GetFullPath(tempArchivePath);
_compressionFormat = ServerConfiguration.GetOrUpdateSetting("autoArchive.compressionFormat", CompressionFormat.Zstd);
var backupPath = ServerConfiguration.GetOrUpdateSetting("autoArchive.backupPath", "Backups");
BackupPath = PathUtility.GetFullPath(backupPath);
AutomaticBackupPath = Path.Combine(BackupPath, "Automatic");
var archivePath = ServerConfiguration.GetOrUpdateSetting("autoArchive.archivePath", "Archives");
ArchivePath = PathUtility.GetFullPath(archivePath);
var useLocalArchives = ServerConfiguration.GetOrUpdateSetting("autoArchive.archiveLocally", true); var useLocalArchives = ServerConfiguration.GetOrUpdateSetting("autoArchive.archiveLocally", true);
_enablePruning = ServerConfiguration.GetOrUpdateSetting("autoArchive.enableArchivePruning", true); _enablePruning = ServerConfiguration.GetOrUpdateSetting("autoArchive.enableArchivePruning", true);
_compressionFormat = ServerConfiguration.GetOrUpdateSetting("autoArchive.compressionFormat", CompressionFormat.Zstd);
if (useLocalArchives) if (useLocalArchives)
{ {
Archive = AutoArchiveLocally; Archive = AutoArchiveLocally;
@ -85,8 +95,8 @@ namespace Server.Saves
return; return;
} }
var backupPath = Path.Combine(BackupPath, Utility.GetTimeStamp()); Directory.CreateDirectory(AutomaticBackupPath);
AssemblyHandler.EnsureDirectory(BackupPath); var backupPath = Path.Combine(AutomaticBackupPath, Utility.GetTimeStamp());
Directory.Move(args.OldSavePath, backupPath); Directory.Move(args.OldSavePath, backupPath);
logger.Information($"Created backup at {backupPath}"); logger.Information($"Created backup at {backupPath}");
@ -97,20 +107,16 @@ namespace Server.Saves
private static void RestoreFromArchive() private static void RestoreFromArchive()
{ {
var savePath = Path.Combine(Core.BaseDirectory, ServerConfiguration.GetSetting("world.savePath", "Saves")); var savePath = Path.Combine(Core.BaseDirectory, ServerConfiguration.GetSetting("world.savePath", "Saves"));
if (!Directory.Exists(savePath))
var files = Directory.Exists(savePath) ? Directory.GetFiles(savePath, "????-??-??-??-??-??.*") : null;
if (files == null || files.Length == 0)
{ {
return; return;
} }
var latestFiles = GetInRange(files, DateTime.MinValue, DateTime.MaxValue, true); foreach (var file in PathsByTimestampName(savePath, true))
foreach (var file in latestFiles)
{ {
if (RestoreFromFile(file, savePath)) if (RestoreFromFile(file, savePath))
{ {
File.Delete(file);
break; break;
} }
} }
@ -128,19 +134,10 @@ namespace Server.Saves
logger.Information($"Restoring latest world save from archive {fileName}"); logger.Information($"Restoring latest world save from archive {fileName}");
var tempFolder = Path.Combine(Core.BaseDirectory, "temp"); var tempPath = PathUtility.EnsureRandomPath(_tempArchivePath);
AssemblyHandler.EnsureDirectory(tempFolder); var successful = fileName.EndsWithOrdinal(".tar.zst")
bool successful; ? ZstdArchive.ExtractToDirectory(fi.FullName, tempPath)
: TarArchive.ExtractToDirectory(fi.FullName, tempPath);
if (fileName.EndsWithOrdinal(".tar.zst"))
{
successful = ZstdArchive.ExtractToDirectory(fi.FullName, tempFolder);
}
else
{
TarArchive.ExtractToDirectory(fi.FullName, tempFolder);
successful = true;
}
if (!successful) if (!successful)
{ {
@ -148,25 +145,17 @@ namespace Server.Saves
return false; return false;
} }
var allFolders = Directory.EnumerateDirectories(tempFolder, "????-??-??-??-??-??"); foreach (var folder in PathsByTimestampName(tempPath))
var worldSaveFolders = GetInRange(allFolders, DateTime.MinValue, DateTime.MaxValue);
var first = true;
foreach (var folder in worldSaveFolders)
{ {
if (first) Directory.Delete(savePath, true);
{ var dirInfo = new DirectoryInfo(folder);
first = false; logger.Information($"Restoring backup {dirInfo.Name}");
Directory.Delete(savePath, true); Directory.Move(folder, savePath);
var dirInfo = new DirectoryInfo(folder); break;
logger.Information($"Restoring backup {dirInfo.Name}");
Directory.Move(folder, savePath);
}
else
{
Directory.Delete(folder, true);
}
} }
Directory.Delete(tempPath, true);
return true; return true;
} }
@ -180,14 +169,26 @@ namespace Server.Saves
PruneLocalArchives(ArchivePeriod.Monthly, 12); PruneLocalArchives(ArchivePeriod.Monthly, 12);
} }
if (Directory.Exists(BackupPath)) if (!Directory.Exists(AutomaticBackupPath))
{ {
var allFolders = Directory.EnumerateDirectories(BackupPath, "????-??-??-??-??-??", SearchOption.AllDirectories); return;
var latestBackupFolders = GetInRange(allFolders, DateTime.MinValue, Core.Now.AddMonths(-1)); }
foreach (var folder in latestBackupFolders) var allFolders = Directory.EnumerateDirectories(AutomaticBackupPath);
var threshold = Core.Now.AddMonths(-1);
foreach (var folder in allFolders)
{
var dirName = new DirectoryInfo(folder).Name;
if (!TryGetDate(dirName, out var date))
{ {
logger.Information($"Pruning backup {folder}"); continue;
}
if (date < threshold)
{
logger.Information($"Pruning old backup {folder}");
Directory.Delete(folder, true); Directory.Delete(folder, true);
} }
} }
@ -196,12 +197,14 @@ namespace Server.Saves
private static void PruneLocalArchives(ArchivePeriod period, int minRetained) private static void PruneLocalArchives(ArchivePeriod period, int minRetained)
{ {
var periodStr = period.ToString(); var periodStr = period.ToString();
var path = Path.Combine(ArchivePath, periodStr); var archivePath = Path.Combine(ArchivePath, periodStr);
var allFiles = Directory.EnumerateFiles(path, "????-??-??-??-??-??.*"); if (!Directory.Exists(archivePath))
var archives = GetInRange(allFiles, DateTime.MinValue, DateTime.MaxValue, true); {
return;
}
var periodLowerStr = periodStr.ToLowerInvariant(); var periodLowerStr = periodStr.ToLowerInvariant();
foreach (var archive in archives) foreach (var archive in PathsByTimestampName(archivePath, true))
{ {
if (minRetained > 0) if (minRetained > 0)
{ {
@ -234,98 +237,150 @@ namespace Server.Saves
{ {
if (date >= _nextHourlyArchive) if (date >= _nextHourlyArchive)
{ {
var lastHour = date.Date.AddHours(date.Hour); Rollup(ArchivePeriod.Hourly);
Rollup(ArchivePeriod.Hourly, lastHour.AddHours(-1), lastHour.ToTimeStamp()); _nextHourlyArchive = _nextHourlyArchive.AddHours(1);
_nextHourlyArchive = _nextHourlyArchive.AddHours(2);
} }
if (date >= _nextDailyArchive) if (date >= _nextDailyArchive)
{ {
var yesterday = date.Date.AddDays(-1); Rollup(ArchivePeriod.Daily);
Rollup(ArchivePeriod.Daily, yesterday, yesterday.ToTimeStamp()); _nextDailyArchive = _nextDailyArchive.AddDays(1);
_nextDailyArchive = _nextDailyArchive.AddDays(2);
} }
if (date >= _nextMonthlyArchive) if (date >= _nextMonthlyArchive)
{ {
var lastMonth = new DateTime(date.Year, date.Month, 1).AddMonths(-1); Rollup(ArchivePeriod.Monthly);
Rollup(ArchivePeriod.Monthly, lastMonth, lastMonth.ToTimeStamp()); _nextMonthlyArchive = _nextMonthlyArchive.AddMonths(1);
_nextMonthlyArchive = _nextMonthlyArchive.AddMonths(2);
} }
Prune?.Invoke(); Prune?.Invoke();
_isArchiving = 0; _isArchiving = 0;
}, },
null null
); );
} }
private static void Rollup(ArchivePeriod archivePeriod, DateTime rangeStart, string archiveNameNoExtension) private static void Rollup(ArchivePeriod archivePeriod)
{ {
if (!Directory.Exists(BackupPath)) if (!Directory.Exists(AutomaticBackupPath))
{ {
return; return;
} }
var currentRangeStart = Core.Now.ArchivePeriodStart(archivePeriod);
var allFolders = Directory.EnumerateDirectories(AutomaticBackupPath);
var items = new Dictionary<DateTime, SortedDictionary<DateTime, string>>();
foreach (var path in allFolders)
{
var dirName = new DirectoryInfo(path).Name;
if (!TryGetDate(dirName, out var date))
{
continue;
}
var rangeStart = date.ArchivePeriodStart(archivePeriod);
// Only archive the past
if (rangeStart >= currentRangeStart)
{
continue;
}
if (items.TryGetValue(rangeStart, out var value))
{
value.Add(date, path);
}
else
{
value = new SortedDictionary<DateTime, string>(new DescendingComparer<DateTime>())
{
{ date, path }
};
items.Add(rangeStart, value);
}
}
var archivePeriodStr = archivePeriod.ToString(); var archivePeriodStr = archivePeriod.ToString();
var archivePath = PathUtility.EnsureDirectory(Path.Combine(ArchivePath, archivePeriodStr));
var archivePeriodStrLower = archivePeriodStr.ToLowerInvariant(); var archivePeriodStrLower = archivePeriodStr.ToLowerInvariant();
var stopWatch = new Stopwatch();
stopWatch.Start();
var allFolders = Directory.EnumerateDirectories(BackupPath, "????-??-??-??-??-??");
var rangeEnd = archivePeriod switch
{
ArchivePeriod.Monthly => rangeStart.AddMonths(1),
ArchivePeriod.Daily => rangeStart.AddDays(1),
_ => rangeStart.AddHours(1),
};
var latestFolders = GetInRange(allFolders, rangeStart, rangeEnd).ToList();
// We don't want to re-archive a single save. This usually means we rebooted and found the one we purposefully left behind
if (latestFolders.Count <= 1)
{
return;
}
var archivePath = Path.Combine(ArchivePath, archivePeriodStr);
AssemblyHandler.EnsureDirectory(archivePath);
var extension = _compressionFormat.GetFileExtension(); var extension = _compressionFormat.GetFileExtension();
var archiveFilePath = Path.Combine(archivePath, $"{archiveNameNoExtension}{extension}");
logger.Information($"Creating {archivePeriodStrLower} archive"); // Leave behind 1 hourly for daily, 1 daily for monthly.
var archiveCreated = _compressionFormat == CompressionFormat.Zstd var minimum = archivePeriod != ArchivePeriod.Monthly ? 1 : 0;
? ZstdArchive.CreateFromPaths(latestFolders, archiveFilePath)
: TarArchive.CreateFromPaths(latestFolders, archiveFilePath);
if (archiveCreated) foreach (var (rangeStart, sortedBackups) in items)
{ {
stopWatch.Stop(); var backups = sortedBackups.Values;
var elapsed = stopWatch.Elapsed.TotalSeconds; if (backups.Count == 0)
logger.Information($"Created {archivePeriodStrLower} archive at {archiveFilePath} ({elapsed:F2} seconds)");
// Keep the latest one, but delete the rest.
for (var i = 1; i < latestFolders.Count; i++)
{ {
Directory.Delete(latestFolders[i], true); continue;
} }
}
else var stopWatch = new Stopwatch();
{ stopWatch.Start();
logger.Warning($"Failed to create {archivePeriodStrLower} archive");
var fileName = $"{rangeStart.ToTimeStamp(archivePeriod)}{extension}";
var archiveFilePath = Path.Combine(archivePath, fileName);
var archiveCreated = CreateArchive(archiveFilePath, AutomaticBackupPath, backups);
if (archiveCreated)
{
var elapsed = stopWatch.Elapsed.TotalSeconds;
logger.Information($"Created {archivePeriodStrLower} archive at {archiveFilePath} ({elapsed:F2} seconds)");
var i = minimum;
foreach (var backup in backups)
{
// Keep the latest one, but delete the rest.
if (i-- > 0)
{
continue;
}
Directory.Delete(backup, true);
}
}
else
{
logger.Warning($"Failed to create {archivePeriodStrLower} archive");
}
stopWatch.Stop();
} }
} }
private static IEnumerable<string> GetInRange( private static bool CreateArchive(string archiveFilePath, string relativeTo, IEnumerable<string> backups) =>
IEnumerable<string> allItems, _compressionFormat == CompressionFormat.Zstd
DateTime rangeStart, ? ZstdArchive.CreateFromPaths(backups, archiveFilePath, relativeTo)
DateTime rangeEnd, : TarArchive.CreateFromPaths(backups, archiveFilePath, relativeTo);
bool files = false
) [MethodImpl(MethodImplOptions.AggressiveInlining)]
private static string ToTimeStamp(this DateTime date, ArchivePeriod archivePeriod) =>
archivePeriod switch
{
ArchivePeriod.Monthly => date.ToString("yyyy-MM"),
ArchivePeriod.Daily => date.ToString("yyyy-MM-dd"),
ArchivePeriod.Hourly => date.ToString("yyyy-MM-dd-HH"),
_ => date.ToTimeStamp()
};
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static DateTime ArchivePeriodStart(this DateTime date, ArchivePeriod archivePeriod) =>
archivePeriod switch
{
ArchivePeriod.Monthly => date.Date.AddDays(-(date.Day - 1)),
ArchivePeriod.Daily => date.Date,
ArchivePeriod.Hourly => date.Date.AddHours(date.Hour),
_ => date
};
private static IEnumerable<string> PathsByTimestampName(string path, bool files = false)
{ {
var allItems = files ? Directory.GetFiles(path) : Directory.GetDirectories(path);
var items = new SortedDictionary<DateTime, string>(new DescendingComparer<DateTime>()); var items = new SortedDictionary<DateTime, string>(new DescendingComparer<DateTime>());
foreach (var item in allItems) foreach (var item in allItems)
{ {
@ -333,14 +388,14 @@ namespace Server.Saves
if (files) if (files)
{ {
var fileName = new FileInfo(item).Name; var fileName = new FileInfo(item).Name;
name = fileName[..fileName.IndexOfOrdinal(".")]; name = fileName[..fileName.IndexOf('.')];
} }
else else
{ {
name = new DirectoryInfo(item).Name; name = new DirectoryInfo(item).Name;
} }
if (IsInRange(name, rangeStart, rangeEnd, out var date)) if (TryGetDate(name, out var date))
{ {
items.Add(date, item); items.Add(date, item);
} }
@ -349,38 +404,41 @@ namespace Server.Saves
return items.Values; return items.Values;
} }
private static bool IsInRange(string name, DateTime start, DateTime end, out DateTime date) =>
TryGetDate(name, out date) && start <= date && end >= date;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool TryGetDate(string value, out DateTime date) private static bool TryGetDate(string value, out DateTime date)
{ {
// Expecting YYYY-MM-DD-HH-mm-ss Span<int> parts = stackalloc int[] { 0, 1, 1, 0, 0, 0 };
var split = value.Split("-");
if (split.Length != 6)
{
date = DateTime.MinValue;
return false;
}
try try
{ {
var i = 0;
foreach (var part in value.Tokenize('-'))
{
parts[i++] = int.Parse(part);
}
if (i == 0)
{
date = DateTime.MinValue;
return false;
}
date = new DateTime( date = new DateTime(
int.Parse(split[0]), parts[0],
int.Parse(split[1]), parts[1],
int.Parse(split[2]), parts[2],
int.Parse(split[3]), parts[3],
int.Parse(split[4]), parts[4],
int.Parse(split[5]) parts[5],
DateTimeKind.Utc
); );
return true;
} }
catch catch
{ {
logger.Warning($"Path was not in the correct date format: {value}");
date = DateTime.MinValue; date = DateTime.MinValue;
return false; return false;
} }
return true;
} }
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]