0
0
mirror of https://github.com/sp-tarkov/server.git synced 2025-02-13 09:50:43 -05:00
server/project/src/services/BackupService.ts
Refringe a38dac1e4e
Backup Service Profile Directory Error
This changes the backup service to log in debug instead of error when the profiles directory can't be found. The profiles directory will not exist on initial run, so this is actually expected to catch until it's created.
2024-12-15 23:29:18 -05:00

256 lines
9.2 KiB
TypeScript

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.debug("Skipping profile backup: Unable to read profiles directory");
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;
}
}