Skip to content

Commit

Permalink
Profile Backups - Concept
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
refringe committed Dec 13, 2024
1 parent f12a5d3 commit ca062d6
Show file tree
Hide file tree
Showing 7 changed files with 189 additions and 1 deletion.
10 changes: 10 additions & 0 deletions project/assets/configs/backup.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"enabled": true,
"maxBackups": 10,
"directory": "./user/backups/profiles",
"dateFormat": "YYYY-MM-DD_HH-MM-SS",
"backupInterval": {
"enabled": true,
"intervalMinutes": 45
}
}
2 changes: 1 addition & 1 deletion project/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions project/src/callbacks/SaveCallbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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<void> {
this.backupService.init();
this.saveServer.load();
}

Expand Down
2 changes: 2 additions & 0 deletions project/src/di/Container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -695,6 +696,7 @@ export class Container {

private static registerServices(depContainer: DependencyContainer): void {
// Services
depContainer.register<BackupService>("BackupService", BackupService, { lifecycle: Lifecycle.Singleton });
depContainer.register<DatabaseService>("DatabaseService", DatabaseService, { lifecycle: Lifecycle.Singleton });
depContainer.register<ImageRouteService>("ImageRouteService", ImageRouteService, {
lifecycle: Lifecycle.Singleton,
Expand Down
1 change: 1 addition & 0 deletions project/src/models/enums/ConfigTypes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export enum ConfigTypes {
AIRDROP = "spt-airdrop",
BACKUP = "spt-backup",
BOT = "spt-bot",
PMC = "spt-pmc",
CORE = "spt-core",
Expand Down
15 changes: 15 additions & 0 deletions project/src/models/spt/config/IBackupConfig.ts
Original file line number Diff line number Diff line change
@@ -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;
}
157 changes: 157 additions & 0 deletions project/src/services/BackupService.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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);
}
}

0 comments on commit ca062d6

Please sign in to comment.