From ca062d63ee388e221e0ddb48edf324874e1bed26 Mon Sep 17 00:00:00 2001 From: Refringe Date: Fri, 13 Dec 2024 01:41:07 -0500 Subject: [PATCH] Profile Backups - Concept Here's a jumping off point for the profile backup feature. Included some basic configuration options. Currently backup runs on server start-up (before the profiles are loaded into memory) and on an configurable interval. I think it still needs work. I don't like how I'm not using the backup folder names to detect which old backups should be removed, and I'm not sure about the interval implementation. Could make the clean method thinner as well. --- project/assets/configs/backup.json | 10 ++ project/package.json | 2 +- project/src/callbacks/SaveCallbacks.ts | 3 + project/src/di/Container.ts | 2 + project/src/models/enums/ConfigTypes.ts | 1 + .../src/models/spt/config/IBackupConfig.ts | 15 ++ project/src/services/BackupService.ts | 157 ++++++++++++++++++ 7 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 project/assets/configs/backup.json create mode 100644 project/src/models/spt/config/IBackupConfig.ts create mode 100644 project/src/services/BackupService.ts diff --git a/project/assets/configs/backup.json b/project/assets/configs/backup.json new file mode 100644 index 000000000..d1634a8f0 --- /dev/null +++ b/project/assets/configs/backup.json @@ -0,0 +1,10 @@ +{ + "enabled": true, + "maxBackups": 10, + "directory": "./user/backups/profiles", + "dateFormat": "YYYY-MM-DD_HH-MM-SS", + "backupInterval": { + "enabled": true, + "intervalMinutes": 45 + } +} diff --git a/project/package.json b/project/package.json index df4ab9171..7524a80ec 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 82c3ee3b9..e32f1ef90 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 4433426c4..3b865a413 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 ccf880d2d..4bd9021c6 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 000000000..7f3450d41 --- /dev/null +++ b/project/src/models/spt/config/IBackupConfig.ts @@ -0,0 +1,15 @@ +import { IBaseConfig } from "@spt/models/spt/config/IBaseConfig"; + +export interface IBackupConfig extends IBaseConfig { + kind: "spt-backup"; + enabled: boolean; + maxBackups: number; + directory: string; + dateFormat: 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 000000000..a1d5bc780 --- /dev/null +++ b/project/src/services/BackupService.ts @@ -0,0 +1,157 @@ +import path from "node:path"; +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 { VFS } from "@spt/utils/VFS"; +import fs from "fs-extra"; +import { inject, injectable } from "tsyringe"; + +@injectable() +export class BackupService { + protected backupConfig: IBackupConfig; + protected readonly profileDir = "./user/profiles"; + + constructor( + @inject("VFS") protected vfs: VFS, + @inject("PrimaryLogger") protected logger: ILogger, + @inject("ConfigServer") protected configServer: ConfigServer, + ) { + this.backupConfig = this.configServer.getConfig(ConfigTypes.BACKUP); + this.startBackupInterval(); + } + + /** + * Create a backup of all user profiles. + */ + public async init(): Promise { + if (!this.isEnabled()) { + return; + } + + const targetDir = this.generateBackupTargetDir(); + + let currentProfiles: string[] = []; + try { + currentProfiles = await fs.readdir(this.profileDir); + } 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.copy(this.profileDir, targetDir); + } catch (error) { + this.logger.error(`Unable to write to backup profile directory: ${error.message}`); + return; + } + + this.logger.debug(`Profile backup created: ${targetDir}`); + + await this.cleanBackups(); + } + + /** + * 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 based on the current date and time. The format is defined by the + * `backupConfig.dateFormat` property. + * + * @returns The formatted backup date string. + */ + protected generateBackupDate(): string { + const now = new Date(); + return this.backupConfig.dateFormat + .toUpperCase() + .replace("YYYY", now.getFullYear().toString()) + .replace("MM", String(now.getMonth() + 1).padStart(2, "0")) + .replace("DD", String(now.getDate()).padStart(2, "0")) + .replace("HH", String(now.getHours()).padStart(2, "0")) + .replace("MM", String(now.getMinutes()).padStart(2, "0")) + .replace("SS", String(now.getSeconds()).padStart(2, "0")); + } + + /** + * 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; + + let backups: string[] = []; + try { + backups = await fs.readdir(backupDir); + } catch (error) { + this.logger.error(`Unable to read backup directory: ${error.message}`); + return; + } + + // Filter directories and sort by modification time. + const backupPaths = backups + .map((backup) => path.join(backupDir, backup)) + .filter((backupPath) => fs.statSync(backupPath).isDirectory()) + .sort((a, b) => { + const aTime = fs.statSync(a).mtimeMs; + const bTime = fs.statSync(b).mtimeMs; + return aTime - bTime; // Oldest first + }); + + // Remove oldest backups if the number exceeds the configured maximum. + const excessCount = backupPaths.length - this.backupConfig.maxBackups; + if (excessCount > 0) { + for (let i = 0; i < excessCount; i++) { + try { + await fs.remove(backupPaths[i]); + this.logger.debug(`Deleted old profile backup: ${backupPaths[i]}`); + } catch (error) { + this.logger.error(`Failed to delete profile backup: ${backupPaths[i]} - ${error.message}`); + } + } + } + } + + /** + * 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); + } +}