using System.Collections.Concurrent; using System.Diagnostics; using System.Text.Json; using System.Text.Json.Nodes; using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.DI; using SPTarkov.Server.Core.Models.Common; using SPTarkov.Server.Core.Models.Eft.Common; using SPTarkov.Server.Core.Models.Eft.Profile; using SPTarkov.Server.Core.Models.Spt.Config; using SPTarkov.Server.Core.Models.Utils; using SPTarkov.Server.Core.Services; using SPTarkov.Server.Core.Utils; using LogLevel = SPTarkov.Server.Core.Models.Spt.Logging.LogLevel; namespace SPTarkov.Server.Core.Servers; [Injectable(InjectionType.Singleton)] public class SaveServer( FileUtil fileUtil, IEnumerable saveLoadRouters, JsonUtil jsonUtil, HashUtil hashUtil, ServerLocalisationService serverLocalisationService, ProfileValidatorService profileValidatorService, BackupService backupService, ISptLogger logger, ConfigServer configServer ) { protected const string profileFilepath = "user/profiles/"; // onLoad = require("../bindings/SaveLoad"); [Obsolete("This will be removed in the next version of SPT")] protected readonly Dictionary> onBeforeSaveCallbacks = new(); protected readonly ConcurrentDictionary profiles = new(); protected readonly ConcurrentDictionary saveMd5 = new(); protected readonly ConcurrentDictionary saveLocks = new(); /// /// Add callback to occur prior to saving profile changes /// /// ID for the save callback /// Callback to execute prior to running SaveServer.saveProfile() [Obsolete("This will be removed in the next version of SPT")] public void AddBeforeSaveCallback(string id, Func callback) { onBeforeSaveCallbacks[id] = callback; } /// /// Remove a callback from being executed prior to saving profile in SaveServer.saveProfile() /// /// ID of Callback to remove [Obsolete("This will be removed in the next version of SPT")] public void RemoveBeforeSaveCallback(string id) { onBeforeSaveCallbacks.Remove(id); } /// /// Load all profiles in /user/profiles folder into memory (this.profiles) /// public async Task LoadAsync() { // get files to load if (!fileUtil.DirectoryExists(profileFilepath)) { fileUtil.CreateDirectory(profileFilepath); } var files = fileUtil.GetFiles(profileFilepath).Where(item => fileUtil.GetFileExtension(item) == "json"); // load profiles var stopwatch = Stopwatch.StartNew(); foreach (var file in files) { // Only allow files that fit the criteria of being a mongo id be parsed var filename = Path.GetFileNameWithoutExtension(file); if (MongoId.IsValidMongoId(filename)) { await LoadProfileAsync(fileUtil.StripExtension(file)); } } stopwatch.Stop(); if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"{files.Count()} Profiles took: {stopwatch.ElapsedMilliseconds}ms to load."); } } /// /// Save changes for each profile from memory into user/profiles json /// public async Task SaveAsync() { // Save every profile var totalTime = 0L; foreach (var sessionID in profiles) { totalTime += await SaveProfileAsync(sessionID.Key); } if (profiles.Count > 0 && logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"Saved {profiles.Count} profiles, took: {totalTime}ms"); } } /// /// Get a player profile from memory /// /// Session ID /// SptProfile of the player /// Thrown when sessionId is null / empty or no profiles with that ID are found public SptProfile GetProfile(MongoId sessionId) { if (sessionId.IsEmpty) { throw new Exception("session id provided was empty, did you restart the server while the game was running?"); } if (profiles == null || profiles.IsEmpty) { throw new Exception($"no profiles found in saveServer with id: {sessionId}"); } if (!profiles.TryGetValue(sessionId, out var sptProfile)) { throw new Exception($"no profile found for sessionId: {sessionId}"); } return sptProfile; } public bool ProfileExists(MongoId id) { return profiles.ContainsKey(id); } /// /// Gets all profiles from memory /// /// Dictionary of Profiles with their ID as Keys. public Dictionary GetProfiles() { return profiles.ToDictionary(); } /// /// Delete a profile by id (Does not remove the profile file!) /// /// ID of profile to remove /// True when deleted, false when profile not found public bool DeleteProfileById(MongoId sessionID) { if (profiles.ContainsKey(sessionID)) { if (profiles.TryRemove(sessionID, out _)) { return true; } } return false; } /// /// Create a new profile in memory with empty pmc/scav objects /// /// Basic profile data /// Thrown when profile already exists public void CreateProfile(Info profileInfo) { if (!profileInfo.ProfileId.HasValue) { // TODO: Localize me throw new Exception("Creating profile failed: profile has no sessionId"); } if (profiles.ContainsKey(profileInfo.ProfileId.Value)) { // TODO: Localize me throw new Exception($"Creating profile failed: profile already exists for sessionId: {profileInfo.ProfileId}"); } profiles.TryAdd( profileInfo.ProfileId.Value, new SptProfile { ProfileInfo = profileInfo, CharacterData = new Characters { PmcData = new PmcData(), ScavData = new PmcData() }, } ); } /// /// Add full profile in memory by key (info.id) /// /// Profile to save public void AddProfile(SptProfile profileDetails) { profiles.TryAdd(profileDetails.ProfileInfo!.ProfileId!.Value, profileDetails); } /// /// Look up profile json in user/profiles by id and store in memory.
/// Execute saveLoadRouters callbacks after being loaded into memory. ///
/// ID of profile to store in memory public async Task LoadProfileAsync(MongoId sessionID) { var filePath = Path.Combine(profileFilepath, $"{sessionID}.json"); if (fileUtil.FileExists(filePath)) // File found, store in profiles[] { JsonObject? profile = null; try { profile = await jsonUtil.DeserializeFromFileAsync(filePath); } catch (JsonException e) { // If the profile fails to deserialize, it may have corrupted, try to restore from a backup logger.Warning($"Failed loading profile for {sessionID.ToString()}. Attempting to load backup"); // We make a copy of the profile before overwriting it, just incase var corruptBackupPath = Path.Combine(profileFilepath, $"{sessionID}-corrupt.json"); File.Copy(filePath, corruptBackupPath, true); if (backupService.RestoreProfile(sessionID)) { profile = await jsonUtil.DeserializeFromFileAsync(filePath); logger.Success("Profile restored from backup!"); } else { throw new Exception("Failed to restore profile backup", e); } } if (profile is not null) { try { profiles[sessionID] = profileValidatorService.MigrateAndValidateProfile(profile); } catch (InvalidOperationException ex) { logger.Critical($"Failed to load profile with ID '{sessionID}'"); logger.Critical(ex.ToString()); } } } // We don't proceed further here as only one object in the profile has data in it. if (IsProfileInvalidOrUnloadable(sessionID)) { return; } // Run callbacks foreach (var callback in saveLoadRouters) // HealthSaveLoadRouter, InraidSaveLoadRouter, InsuranceSaveLoadRouter, ProfileSaveLoadRouter. THESE SHOULD EXIST IN HERE { profiles[sessionID] = callback.HandleLoad(GetProfile(sessionID)); } } /// /// Save changes from in-memory profile to user/profiles json /// Execute onBeforeSaveCallbacks callbacks prior to being saved to json /// /// Profile id (user/profiles/id.json) /// Time taken to save the profile in seconds public async Task SaveProfileAsync(MongoId sessionID) { // No need to save profiles that have been marked as invalid if (IsProfileInvalidOrUnloadable(sessionID)) { return 0; } // Lock based on sessionID so we don't attempt to write to the same save file // multiple times at the same time, leading to file access contention SemaphoreSlim saveLock = saveLocks.GetOrAdd(sessionID, _ => new SemaphoreSlim(1, 1)); await saveLock.WaitAsync(); Stopwatch start; try { var filePath = Path.Combine(profileFilepath, $"{sessionID}.json"); // Run pre-save callbacks before we save into json foreach (var callback in onBeforeSaveCallbacks) { var previous = profiles[sessionID]; try { profiles[sessionID] = onBeforeSaveCallbacks[callback.Key](profiles[sessionID]); } catch (Exception e) { logger.Error(serverLocalisationService.GetText("profile_save_callback_error", new { callback, error = e })); profiles[sessionID] = previous; } } start = Stopwatch.StartNew(); var jsonProfile = jsonUtil.Serialize(profiles[sessionID], !configServer.GetConfig().Features.CompressProfile); var fmd5 = await hashUtil.GenerateHashForDataAsync(HashingAlgorithm.MD5, jsonProfile); if (!saveMd5.TryGetValue(sessionID, out var currentMd5) || currentMd5 != fmd5) { saveMd5[sessionID] = fmd5; // save profile to disk await fileUtil.WriteFileAsync(filePath, jsonProfile); } start.Stop(); } finally { saveLock.Release(); } return start.ElapsedMilliseconds; } /// /// Remove a physical profile json from user/profiles /// /// Profile ID to remove /// True if successful public bool RemoveProfile(MongoId sessionID) { var file = Path.Combine(profileFilepath, $"{sessionID}.json"); if (profiles.ContainsKey(sessionID)) { profiles.TryRemove(sessionID, out _); if (!fileUtil.DeleteFile(file)) { logger.Error($"Unable to delete file, not found: {file}"); } } return !fileUtil.FileExists(file); } /// /// Determines whether the specified profile is marked as invalid or cannot be loaded. /// /// The ID of the profile to check. /// /// true if the profile is invalid or unloadable; otherwise, false. /// public bool IsProfileInvalidOrUnloadable(MongoId sessionID) { if ( profiles.TryGetValue(sessionID, out var profile) && profile.ProfileInfo!.InvalidOrUnloadableProfile is not null && profile.ProfileInfo!.InvalidOrUnloadableProfile!.Value ) { return true; } return false; } }