using System.Globalization; using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.Models.Spt.Config; using SPTarkov.Server.Core.Models.Spt.Mod; using SPTarkov.Server.Core.Models.Utils; using SPTarkov.Server.Core.Servers; using SPTarkov.Server.Core.Utils; using LogLevel = SPTarkov.Server.Core.Models.Spt.Logging.LogLevel; namespace SPTarkov.Server.Core.Services; [Injectable(InjectionType.Singleton)] public class BackupService { protected const string ProfileDir = "./user/profiles"; protected const string activeModsFilename = "activeMods.json"; protected readonly List ActiveServerMods; protected readonly BackupConfig BackupConfig; // Runs Init() every x minutes protected Timer _backupIntervalTimer; protected SemaphoreSlim BackupLock = new SemaphoreSlim(1, 1); protected long LastBackupTimestamp; protected readonly FileUtil FileUtil; protected readonly JsonUtil JsonUtil; protected readonly ISptLogger Logger; protected readonly TimeUtil TimeUtil; protected readonly IReadOnlyList LoadedMods; private static readonly CultureInfo[] Cultures = [ CultureInfo.InvariantCulture, new CultureInfo("fa-IR") { DateTimeFormat = { Calendar = new PersianCalendar() } }, new CultureInfo("ar-SA") { DateTimeFormat = { Calendar = new HijriCalendar() } }, new CultureInfo("he-IL") { DateTimeFormat = { Calendar = new HebrewCalendar() } }, new CultureInfo("th-TH") { DateTimeFormat = { Calendar = new ThaiBuddhistCalendar() } }, new CultureInfo("ja-JP") { DateTimeFormat = { Calendar = new JapaneseCalendar() } }, ]; public BackupService( ISptLogger logger, IReadOnlyList loadedMods, JsonUtil jsonUtil, TimeUtil timeUtil, ConfigServer configServer, FileUtil fileUtil ) { Logger = logger; JsonUtil = jsonUtil; TimeUtil = timeUtil; FileUtil = fileUtil; LoadedMods = loadedMods; ActiveServerMods = GetActiveServerMods(); BackupConfig = configServer.GetConfig(); } /// /// Start the backup interval if enabled in config. /// public async Task StartBackupSystem() { if (!BackupConfig.BackupInterval.Enabled) { // Not backing up at regular intervals, run once and exit await Init(); return; } _backupIntervalTimer = new Timer( async void (_) => { try { await Init(); } catch (Exception ex) { Logger.Error($"Profile backup failed: {ex.Message}, {ex.StackTrace}"); } }, null, TimeSpan.Zero, TimeSpan.FromMinutes(BackupConfig.BackupInterval.IntervalMinutes) ); } /// /// Run the backup process.
/// This method orchestrates the profile backup service. Handles copying profiles to a backup directory and cleaning /// up old backups if the number exceeds the configured maximum. ///
public async Task Init() { if (!IsEnabled()) { return; } // If the backup lock is already locked, skip backup. This stops multiple backups running at once // Passing 0 is a non-blocking Wait, will return false if the lock can't be acquired bool lockAcquired = await BackupLock.WaitAsync(0); if (!lockAcquired) { return; } try { // Make sure we don't back up too often by using a configurable Cooldown var currentTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); if (currentTimestamp < LastBackupTimestamp + BackupConfig.BackupCooldown) { return; } LastBackupTimestamp = currentTimestamp; var targetDir = GenerateBackupTargetDir(); // Fetch all profiles in the profile directory. List currentProfilePaths; try { currentProfilePaths = FileUtil.GetFiles(ProfileDir); } catch (Exception ex) { Logger.Debug($"Skipping profile backup: Unable to read profiles directory, {ex.Message}"); return; } if (currentProfilePaths.Count == 0) { if (Logger.IsLogEnabled(LogLevel.Debug)) { Logger.Debug("No profiles to backup"); } return; } try { FileUtil.CreateDirectory(targetDir); foreach (var profilePath in currentProfilePaths) { // Get filename + extension, removing the path var profileFileName = FileUtil.GetFileNameAndExtension(profilePath); // Create absolute path to file var relativeSourceFilePath = Path.Combine(ProfileDir, profileFileName); var absoluteDestinationFilePath = Path.Combine(targetDir, profileFileName); if (!FileUtil.CopyFile(relativeSourceFilePath, absoluteDestinationFilePath)) { Logger.Error($"Source file not found: {relativeSourceFilePath}. Cannot copy to: {absoluteDestinationFilePath}"); } } // Write a copy of active mods. await FileUtil.WriteFileAsync(Path.Combine(targetDir, activeModsFilename), JsonUtil.Serialize(ActiveServerMods)); if (Logger.IsLogEnabled(LogLevel.Debug)) { Logger.Debug($"Profile backup created in: {targetDir}"); } } catch (Exception ex) { Logger.Error($"Unable to write to backup profile directory: {ex.Message}"); return; } CleanBackups(); } finally { BackupLock.Release(); } } /// /// Check to see if the backup service is enabled via the config. /// /// True if enabled, false otherwise. protected bool IsEnabled() { if (BackupConfig.Enabled) { return true; } if (Logger.IsLogEnabled(LogLevel.Debug)) { Logger.Debug("Profile backups disabled"); } return false; } /// /// Generates the target directory path for the backup. The directory path is constructed using the `directory` from /// the configuration and the current backup date. /// /// The target directory path for the backup. protected string GenerateBackupTargetDir() { var backupDate = GenerateBackupDate(); return Path.GetFullPath($"{BackupConfig.Directory}/{backupDate}"); } /// /// Generates a formatted backup date string in the format `YYYY-MM-DD_hh-mm-ss`. /// /// The formatted backup date string. protected string GenerateBackupDate() { return TimeUtil.GetDateTimeNow().ToString("yyyy-MM-dd_HH-mm-ss"); } /// /// Cleans up old backups in the backup directory.
/// This method reads the backup directory, and sorts backups by modification time. If the number of backups exceeds /// the configured maximum, it deletes the oldest backups. ///
protected void CleanBackups() { var backupDir = BackupConfig.Directory; var backupPaths = GetBackupPaths(backupDir); // Filter out invalid backup paths by ensuring they contain a valid date. var backupPathsWithCreationDateTime = GetBackupPathsWithCreationTimestamp(backupPaths); var excessCount = backupPathsWithCreationDateTime.Count - BackupConfig.MaxBackups; if (excessCount > 0) { var excessBackupPaths = backupPaths.GetRange(0, excessCount); RemoveExcessBackups(excessBackupPaths); } } protected SortedDictionary GetBackupPathsWithCreationTimestamp(IEnumerable backupPaths) { var result = new SortedDictionary(); foreach (var backupPath in backupPaths) { var date = ExtractDateFromFolderName(backupPath); if (!date.HasValue) { continue; } result.Add(date.Value, backupPath); } return result; } protected string? GetMostRecentProfileBackup(IEnumerable backupPaths, string profileId) { var profileFilename = $"{profileId}.json"; var backupPathsWithCreationDateTime = GetBackupPathsWithCreationTimestamp(backupPaths); foreach (var (backupTimestamp, backupPath) in backupPathsWithCreationDateTime.Reverse()) { var profileBackups = FileUtil.GetFiles(backupPath); var profileBackup = profileBackups.FirstOrDefault(path => path.EndsWith(profileFilename)); if (profileBackup != null) { return profileBackup; } } return null; } /// /// Retrieves and sorts the backup file paths from the specified directory. /// /// The directory to search for backup files. /// List of sorted backup file paths. protected List GetBackupPaths(string dir) { var backups = FileUtil.GetDirectories(dir).ToList(); backups.Sort(CompareBackupDates); return backups; } /// /// Compares two backup folder names based on their extracted dates. /// /// The name of the first backup folder. /// The name of the second backup folder. /// The difference in time between the two dates in milliseconds, or `null` if either date is invalid. protected int CompareBackupDates(string a, string b) { var dateA = ExtractDateFromFolderName(a); var dateB = ExtractDateFromFolderName(b); if (!dateA.HasValue || !dateB.HasValue) { return 0; // Skip comparison if either date is invalid. } return dateA.Value.CompareTo(dateB.Value); } /// /// Extracts a date from a folder name string formatted as `YYYY-MM-DD_hh-mm-ss`. /// /// The name of the folder from which to extract the date. /// A DateTime object if the folder name is in the correct format, otherwise null. protected DateTime? ExtractDateFromFolderName(string folderPath) { var folderName = Path.GetFileName(folderPath); const string format = "yyyy-MM-dd_HH-mm-ss"; var now = DateTime.UtcNow; var minDate = new DateTime(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc); var maxDate = now.AddYears(5); foreach (var culture in Cultures) { if ( DateTime.TryParseExact( folderName, format, culture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var dt ) ) { if (dt >= minDate && dt <= maxDate) { return DateTime.SpecifyKind(dt, DateTimeKind.Utc); } } } Logger.Warning($"Invalid backup folder name format: {folderPath}, [{folderName}]"); return null; } /// /// Removes excess backups from the backup directory. /// /// List of backup file names to be removed. /// A promise that resolves when all specified backups have been removed. protected void RemoveExcessBackups(IEnumerable backupFilenames) { var filePathsToDelete = backupFilenames.Select(x => x); foreach (var pathToDelete in filePathsToDelete) { FileUtil.DeleteDirectory(Path.Combine(pathToDelete), true); if (Logger.IsLogEnabled(LogLevel.Debug)) { Logger.Debug($"Deleted old backup: {pathToDelete}"); } } } /// /// Get a List of active server mod details. /// /// A List of mod names. protected List GetActiveServerMods() { List result = []; foreach (var mod in LoadedMods) { result.Add($"{mod.ModMetadata.Author} - {mod.ModMetadata.Version}"); } return result; } /// /// Restores the most recent profile backup for the given profile Id /// /// The profile ID of the backup to restore /// True on success. False on failure public bool RestoreProfile(string profileId) { var backupDir = BackupConfig.Directory; var backupPaths = GetBackupPaths(backupDir); var mostRecentBackupForProfile = GetMostRecentProfileBackup(backupPaths, profileId); // Verify we have a backup for this profile if (mostRecentBackupForProfile == null) { return false; } // Restore the most recent profile backup var profileFileName = FileUtil.GetFileNameAndExtension(mostRecentBackupForProfile); var targetProfilePath = Path.Combine(ProfileDir, profileFileName); File.Copy(mostRecentBackupForProfile, targetProfilePath, true); return true; } }