mirror of
https://github.com/sp-tarkov/server.git
synced 2025-02-12 16:10:43 -05:00
Profile Backups (#983)
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 a configurable interval. I think it still needs work. - [x] The folder name dates should be used to detect which old backups should be removed - [x] Not sure about the interval implementation... - [x] Could make the clean method thinner - [x] Remove VFS; I don't believe it's needed for copy operations - [x] Save a list of active mods used by the backed up profiles --------- Co-authored-by: Chomp <27521899+chompDev@users.noreply.github.com> Co-authored-by: Chomp <dev@dev.sp-tarkov.com>
This commit is contained in:
parent
571819906e
commit
ef14ad27e1
9
project/assets/configs/backup.json
Normal file
9
project/assets/configs/backup.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"maxBackups": 15,
|
||||
"directory": "./user/profiles/backups",
|
||||
"backupInterval": {
|
||||
"enabled": false,
|
||||
"intervalMinutes": 120
|
||||
}
|
||||
}
|
@ -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",
|
||||
|
@ -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<void> {
|
||||
this.backupService.init();
|
||||
this.saveServer.load();
|
||||
}
|
||||
|
||||
|
@ -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", BackupService, { lifecycle: Lifecycle.Singleton });
|
||||
depContainer.register<DatabaseService>("DatabaseService", DatabaseService, { lifecycle: Lifecycle.Singleton });
|
||||
depContainer.register<ImageRouteService>("ImageRouteService", ImageRouteService, {
|
||||
lifecycle: Lifecycle.Singleton,
|
||||
|
@ -1,5 +1,6 @@
|
||||
export enum ConfigTypes {
|
||||
AIRDROP = "spt-airdrop",
|
||||
BACKUP = "spt-backup",
|
||||
BOT = "spt-bot",
|
||||
PMC = "spt-pmc",
|
||||
CORE = "spt-core",
|
||||
|
14
project/src/models/spt/config/IBackupConfig.ts
Normal file
14
project/src/models/spt/config/IBackupConfig.ts
Normal file
@ -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;
|
||||
}
|
255
project/src/services/BackupService.ts
Normal file
255
project/src/services/BackupService.ts
Normal file
@ -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<void> {
|
||||
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<void>[] = 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<string[]> {
|
||||
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<void> {
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user