diff --git a/project/assets/configs/backup.json b/project/assets/configs/backup.json new file mode 100644 index 00000000..4a554158 --- /dev/null +++ b/project/assets/configs/backup.json @@ -0,0 +1,9 @@ +{ + "enabled": true, + "maxBackups": 15, + "directory": "./user/profiles/backups", + "backupInterval": { + "enabled": false, + "intervalMinutes": 120 + } +} diff --git a/project/package.json b/project/package.json index df4ab917..7524a80e 100644 --- a/project/package.json +++ b/project/package.json @@ -38,6 +38,7 @@ "buffer-crc32": "~1.0", "date-fns": "~3.6", "date-fns-tz": "~3.1", + "fs-extra": "^11.2.0", "i18n": "~0.15", "json-fixer": "~1.6", "json5": "~2.2", @@ -70,7 +71,6 @@ "@yao-pkg/pkg": "5.12", "@yao-pkg/pkg-fetch": "3.5.9", "cross-env": "~7.0", - "fs-extra": "~11.2", "gulp": "~5.0", "gulp-decompress": "~3.0", "gulp-download": "~0.0.1", diff --git a/project/src/callbacks/SaveCallbacks.ts b/project/src/callbacks/SaveCallbacks.ts index 82c3ee3b..e32f1ef9 100644 --- a/project/src/callbacks/SaveCallbacks.ts +++ b/project/src/callbacks/SaveCallbacks.ts @@ -4,6 +4,7 @@ import { ConfigTypes } from "@spt/models/enums/ConfigTypes"; import { ICoreConfig } from "@spt/models/spt/config/ICoreConfig"; import { ConfigServer } from "@spt/servers/ConfigServer"; import { SaveServer } from "@spt/servers/SaveServer"; +import { BackupService } from "@spt/services/BackupService"; import { inject, injectable } from "tsyringe"; @injectable() @@ -13,11 +14,13 @@ export class SaveCallbacks implements OnLoad, OnUpdate { constructor( @inject("SaveServer") protected saveServer: SaveServer, @inject("ConfigServer") protected configServer: ConfigServer, + @inject("BackupService") protected backupService: BackupService, ) { this.coreConfig = this.configServer.getConfig(ConfigTypes.CORE); } public async onLoad(): Promise { + this.backupService.init(); this.saveServer.load(); } diff --git a/project/src/di/Container.ts b/project/src/di/Container.ts index 4433426c..3b865a41 100644 --- a/project/src/di/Container.ts +++ b/project/src/di/Container.ts @@ -198,6 +198,7 @@ import { SptWebSocketConnectionHandler } from "@spt/servers/ws/SptWebSocketConne import { DefaultSptWebSocketMessageHandler } from "@spt/servers/ws/message/DefaultSptWebSocketMessageHandler"; import { ISptWebSocketMessageHandler } from "@spt/servers/ws/message/ISptWebSocketMessageHandler"; import { AirdropService } from "@spt/services/AirdropService"; +import { BackupService } from "@spt/services/BackupService"; import { BotEquipmentFilterService } from "@spt/services/BotEquipmentFilterService"; import { BotEquipmentModPoolService } from "@spt/services/BotEquipmentModPoolService"; import { BotGenerationCacheService } from "@spt/services/BotGenerationCacheService"; @@ -695,6 +696,7 @@ export class Container { private static registerServices(depContainer: DependencyContainer): void { // Services + depContainer.register("BackupService", BackupService, { lifecycle: Lifecycle.Singleton }); depContainer.register("DatabaseService", DatabaseService, { lifecycle: Lifecycle.Singleton }); depContainer.register("ImageRouteService", ImageRouteService, { lifecycle: Lifecycle.Singleton, diff --git a/project/src/models/enums/ConfigTypes.ts b/project/src/models/enums/ConfigTypes.ts index ccf880d2..4bd9021c 100644 --- a/project/src/models/enums/ConfigTypes.ts +++ b/project/src/models/enums/ConfigTypes.ts @@ -1,5 +1,6 @@ export enum ConfigTypes { AIRDROP = "spt-airdrop", + BACKUP = "spt-backup", BOT = "spt-bot", PMC = "spt-pmc", CORE = "spt-core", diff --git a/project/src/models/spt/config/IBackupConfig.ts b/project/src/models/spt/config/IBackupConfig.ts new file mode 100644 index 00000000..a53f7c30 --- /dev/null +++ b/project/src/models/spt/config/IBackupConfig.ts @@ -0,0 +1,14 @@ +import { IBaseConfig } from "@spt/models/spt/config/IBaseConfig"; + +export interface IBackupConfig extends IBaseConfig { + kind: "spt-backup"; + enabled: boolean; + maxBackups: number; + directory: string; + backupInterval: IBackupConfigInterval; +} + +export interface IBackupConfigInterval { + enabled: boolean; + intervalMinutes: number; +} diff --git a/project/src/services/BackupService.ts b/project/src/services/BackupService.ts new file mode 100644 index 00000000..591ee312 --- /dev/null +++ b/project/src/services/BackupService.ts @@ -0,0 +1,255 @@ +import path from "node:path"; +import { PreSptModLoader } from "@spt/loaders/PreSptModLoader"; +import { ConfigTypes } from "@spt/models/enums/ConfigTypes"; +import { IBackupConfig } from "@spt/models/spt/config/IBackupConfig"; +import { ILogger } from "@spt/models/spt/utils/ILogger"; +import { ConfigServer } from "@spt/servers/ConfigServer"; +import fs from "fs-extra"; +import { inject, injectable } from "tsyringe"; + +@injectable() +export class BackupService { + protected backupConfig: IBackupConfig; + protected readonly activeServerMods: string[] = []; + protected readonly profileDir = "./user/profiles"; + + constructor( + @inject("PrimaryLogger") protected logger: ILogger, + @inject("PreSptModLoader") protected preSptModLoader: PreSptModLoader, + @inject("ConfigServer") protected configServer: ConfigServer, + ) { + this.backupConfig = this.configServer.getConfig(ConfigTypes.BACKUP); + this.activeServerMods = this.getActiveServerMods(); + this.startBackupInterval(); + } + + /** + * Initializes 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. + * + * @returns A promise that resolves when the backup process is complete. + */ + public async init(): Promise { + if (!this.isEnabled()) { + return; + } + + const targetDir = this.generateBackupTargetDir(); + + // Fetch all profiles in the profile directory. + let currentProfiles: string[] = []; + try { + currentProfiles = await this.fetchProfileFiles(); + } catch (error) { + this.logger.error(`Unable to read profiles directory: ${error.message}`); + return; + } + + if (!currentProfiles.length) { + this.logger.debug("No profiles to backup"); + return; + } + + try { + await fs.ensureDir(targetDir); + + // Track write promises. + const writes: Promise[] = currentProfiles.map((profile) => + fs.copy(path.join(this.profileDir, profile), path.join(targetDir, profile)), + ); + + // Write a copy of active mods. + writes.push(fs.writeJson(path.join(targetDir, "activeMods.json"), this.activeServerMods)); + + await Promise.all(writes); // Wait for all writes to complete. + } catch (error) { + this.logger.error(`Unable to write to backup profile directory: ${error.message}`); + return; + } + + this.logger.debug(`Profile backup created: ${targetDir}`); + + this.cleanBackups(); + } + + /** + * Fetches the names of all JSON files in the profile directory. + * + * This method normalizes the profile directory path and reads all files within it. It then filters the files to + * include only those with a `.json` extension and returns their names. + * + * @returns A promise that resolves to an array of JSON file names. + */ + protected async fetchProfileFiles(): Promise { + const normalizedProfileDir = path.normalize(this.profileDir); + + try { + const allFiles = await fs.readdir(normalizedProfileDir); + return allFiles.filter((file) => path.extname(file).toLowerCase() === ".json"); + } catch (error) { + return Promise.reject(error); + } + } + + /** + * Check to see if the backup service is enabled via the config. + * + * @returns True if enabled, false otherwise. + */ + protected isEnabled(): boolean { + if (!this.backupConfig.enabled) { + this.logger.debug("Profile backups disabled"); + return false; + } + return true; + } + + /** + * Generates the target directory path for the backup. The directory path is constructed using the `directory` from + * the configuration and the current backup date. + * + * @returns The target directory path for the backup. + */ + protected generateBackupTargetDir(): string { + const backupDate = this.generateBackupDate(); + return path.normalize(`${this.backupConfig.directory}/${backupDate}`); + } + + /** + * Generates a formatted backup date string in the format `YYYY-MM-DD_hh-mm-ss`. + * + * @returns The formatted backup date string. + */ + protected generateBackupDate(): string { + const now = new Date(); + const [year, month, day, hour, minute, second] = [ + now.getFullYear(), + now.getMonth() + 1, + now.getDate(), + now.getHours(), + now.getMinutes(), + now.getSeconds(), + ].map((num) => num.toString().padStart(2, "0")); + + return `${year}-${month}-${day}_${hour}-${minute}-${second}`; + } + + /** + * 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. + * + * @returns A promise that resolves when the cleanup is complete. + */ + protected async cleanBackups(): Promise { + const backupDir = this.backupConfig.directory; + const backupPaths = await this.getBackupPaths(backupDir); + + // Filter out invalid backup paths by ensuring they contain a valid date. + const validBackupPaths = backupPaths.filter((path) => this.extractDateFromFolderName(path) !== null); + + const excessCount = validBackupPaths.length - this.backupConfig.maxBackups; + if (excessCount > 0) { + const excessBackups = backupPaths.slice(0, excessCount); + await this.removeExcessBackups(excessBackups); + } + } + + /** + * Retrieves and sorts the backup file paths from the specified directory. + * + * @param dir - The directory to search for backup files. + * @returns A promise that resolves to an array of sorted backup file paths. + */ + private async getBackupPaths(dir: string): Promise { + const backups = await fs.readdir(dir); + return backups.filter((backup) => path.join(dir, backup)).sort(this.compareBackupDates.bind(this)); + } + + /** + * Compares two backup folder names based on their extracted dates. + * + * @param a - The name of the first backup folder. + * @param b - The name of the second backup folder. + * @returns The difference in time between the two dates in milliseconds, or `null` if either date is invalid. + */ + private compareBackupDates(a: string, b: string): number | null { + const dateA = this.extractDateFromFolderName(a); + const dateB = this.extractDateFromFolderName(b); + + if (!dateA || !dateB) { + return null; // Skip comparison if either date is invalid. + } + + return dateA.getTime() - dateB.getTime(); + } + + /** + * Extracts a date from a folder name string formatted as `YYYY-MM-DD_hh-mm-ss`. + * + * @param folderName - The name of the folder from which to extract the date. + * @returns A Date object if the folder name is in the correct format, otherwise null. + */ + private extractDateFromFolderName(folderName: string): Date | null { + const parts = folderName.split(/[-_]/); + if (parts.length !== 6) { + console.warn(`Invalid backup folder name format: ${folderName}`); + return null; + } + + const [year, month, day, hour, minute, second] = parts; + + return new Date(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second)); + } + + /** + * Removes excess backups from the backup directory. + * + * @param backups - An array of backup file names to be removed. + * @returns A promise that resolves when all specified backups have been removed. + */ + private async removeExcessBackups(backups: string[]): Promise { + const removePromises = backups.map((backupPath) => + fs.remove(path.join(this.backupConfig.directory, backupPath)), + ); + await Promise.all(removePromises); + + removePromises.forEach((_promise, index) => { + this.logger.debug(`Deleted old profile backup: ${backups[index]}`); + }); + } + + /** + * Start the backup interval if enabled in the configuration. + */ + protected startBackupInterval(): void { + if (!this.backupConfig.backupInterval.enabled) { + return; + } + + const minutes = this.backupConfig.backupInterval.intervalMinutes * 60 * 1000; // Minutes to milliseconds + setInterval(() => { + this.init().catch((error) => this.logger.error(`Profile backup failed: ${error.message}`)); + }, minutes); + } + + /** + * Get an array of active server mod details. + * + * @returns An array of mod names. + */ + protected getActiveServerMods(): string[] { + const result = []; + + const activeMods = this.preSptModLoader.getImportedModDetails(); + for (const activeModKey in activeMods) { + result.push( + `${activeModKey}-${activeMods[activeModKey].author ?? "unknown"}-${activeMods[activeModKey].version ?? ""}`, + ); + } + return result; + } +}