Files
towercontrol/services/backup.ts
Julien Froidefond da0565472d feat: enhance settings and backup functionality
- Updated status descriptions in `SettingsIndexPageClient` to reflect current functionality.
- Added a new backup management section in settings for better user access.
- Modified `BackupService` to include backup type in filenames, improving clarity and organization.
- Enhanced backup file parsing to support both new and old filename formats, ensuring backward compatibility.
2025-09-20 16:21:50 +02:00

488 lines
15 KiB
TypeScript

import { promises as fs } from 'fs';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import { prisma } from './database';
import { userPreferencesService } from './user-preferences';
const execAsync = promisify(exec);
export interface BackupConfig {
enabled: boolean;
interval: 'hourly' | 'daily' | 'weekly';
maxBackups: number;
backupPath: string;
includeUploads?: boolean;
compression?: boolean;
}
export interface BackupInfo {
id: string;
filename: string;
size: number;
createdAt: Date;
type: 'manual' | 'automatic';
status: 'success' | 'failed' | 'in_progress';
error?: string;
}
export class BackupService {
private get defaultConfig(): BackupConfig {
return {
enabled: true,
interval: 'hourly',
maxBackups: 5,
backupPath: this.getDefaultBackupPath(),
includeUploads: true,
compression: true,
};
}
private getDefaultBackupPath(): string {
// 1. Variable d'environnement explicite
if (process.env.BACKUP_STORAGE_PATH) {
return path.resolve(process.cwd(), process.env.BACKUP_STORAGE_PATH);
}
// 2. Chemin par défaut selon l'environnement
return process.env.NODE_ENV === 'production'
? path.join(process.cwd(), 'data', 'backups') // Docker: /app/data/backups
: path.join(process.cwd(), 'backups'); // Local: ./backups
}
private config: BackupConfig;
constructor(config?: Partial<BackupConfig>) {
this.config = { ...this.defaultConfig, ...config };
// Charger la config depuis la DB de manière asynchrone
this.loadConfigFromDB().catch(() => {
// Ignorer les erreurs de chargement initial
});
}
/**
* Charge la configuration depuis la base de données
*/
private async loadConfigFromDB(): Promise<void> {
try {
const preferences = await userPreferencesService.getAllPreferences();
if (preferences.viewPreferences && typeof preferences.viewPreferences === 'object') {
const backupConfig = (preferences.viewPreferences as Record<string, unknown>).backupConfig;
if (backupConfig) {
this.config = { ...this.defaultConfig, ...backupConfig };
}
}
} catch (error) {
console.warn('Could not load backup config from DB, using defaults:', error);
}
}
/**
* Sauvegarde la configuration dans la base de données
*/
private async saveConfigToDB(): Promise<void> {
try {
// Pour l'instant, on stocke la config backup en tant que JSON dans viewPreferences
// TODO: Ajouter un champ dédié dans le schéma pour la config backup
await prisma.userPreferences.upsert({
where: { id: 'default' },
update: {
viewPreferences: JSON.parse(JSON.stringify({
...(await userPreferencesService.getViewPreferences()),
backupConfig: this.config
}))
},
create: {
id: 'default',
kanbanFilters: {},
viewPreferences: JSON.parse(JSON.stringify({ backupConfig: this.config })),
columnVisibility: {},
jiraConfig: {}
}
});
} catch (error) {
console.error('Failed to save backup config to DB:', error);
}
}
/**
* Crée une sauvegarde complète de la base de données
*/
async createBackup(type: 'manual' | 'automatic' = 'manual'): Promise<BackupInfo> {
const backupId = `backup_${Date.now()}`;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `towercontrol_${type}_${timestamp}.db`;
const backupPath = path.join(this.getCurrentBackupPath(), filename);
console.log(`🔄 Starting ${type} backup: ${filename}`);
try {
// Créer le dossier de backup si nécessaire
await this.ensureBackupDirectory();
// Créer la sauvegarde SQLite (sans vérification de santé pour éviter les conflits)
await this.createSQLiteBackup(backupPath);
// Compresser si activé
let finalPath = backupPath;
if (this.config.compression) {
finalPath = await this.compressBackup(backupPath);
await fs.unlink(backupPath); // Supprimer le fichier non compressé
}
// Obtenir les stats du fichier
const stats = await fs.stat(finalPath);
const backupInfo: BackupInfo = {
id: backupId,
filename: path.basename(finalPath),
size: stats.size,
createdAt: new Date(),
type,
status: 'success',
};
// Nettoyer les anciennes sauvegardes
await this.cleanOldBackups();
console.log(`✅ Backup completed: ${backupInfo.filename} (${this.formatFileSize(backupInfo.size)})`);
return backupInfo;
} catch (error) {
console.error(`❌ Backup failed:`, error);
return {
id: backupId,
filename,
size: 0,
createdAt: new Date(),
type,
status: 'failed',
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Crée une sauvegarde SQLite en utilisant la commande .backup
*/
private async createSQLiteBackup(backupPath: string): Promise<void> {
// Résoudre le chemin de la base de données
let dbPath: string;
if (process.env.BACKUP_DATABASE_PATH) {
// Utiliser la variable spécifique aux backups
dbPath = path.resolve(process.cwd(), process.env.BACKUP_DATABASE_PATH);
} else if (process.env.DATABASE_URL) {
// Fallback sur DATABASE_URL si BACKUP_DATABASE_PATH n'est pas défini
dbPath = path.resolve(process.env.DATABASE_URL.replace('file:', ''));
} else {
// Chemin par défaut vers prisma/dev.db
dbPath = path.resolve(process.cwd(), 'prisma', 'dev.db');
}
// Vérifier que le fichier source existe
try {
await fs.stat(dbPath);
} catch (error) {
console.error(`❌ Source database not found: ${dbPath}`, error);
throw new Error(`Source database not found: ${dbPath}`);
}
// Méthode 1: Utiliser sqlite3 CLI (plus fiable)
try {
const command = `sqlite3 "${dbPath}" ".backup '${backupPath}'"`;
await execAsync(command);
console.log(`✅ SQLite backup created using CLI: ${backupPath}`);
return;
} catch (cliError) {
console.warn(`⚠️ SQLite CLI backup failed, trying copy method:`, cliError);
}
// Méthode 2: Copie simple du fichier (fallback)
try {
await fs.copyFile(dbPath, backupPath);
console.log(`✅ SQLite backup created using file copy: ${backupPath}`);
} catch (copyError) {
throw new Error(`Failed to create SQLite backup: ${copyError}`);
}
}
/**
* Compresse une sauvegarde
*/
private async compressBackup(filePath: string): Promise<string> {
const compressedPath = `${filePath}.gz`;
try {
const command = `gzip -c "${filePath}" > "${compressedPath}"`;
await execAsync(command);
console.log(`✅ Backup compressed: ${compressedPath}`);
return compressedPath;
} catch (error) {
console.warn(`⚠️ Compression failed, keeping uncompressed backup:`, error);
return filePath;
}
}
/**
* Restaure une sauvegarde
*/
async restoreBackup(filename: string): Promise<void> {
const backupPath = path.join(this.getCurrentBackupPath(), filename);
// Résoudre le chemin de la base de données
let dbPath: string;
if (process.env.BACKUP_DATABASE_PATH) {
// Utiliser la variable spécifique aux backups
dbPath = path.resolve(process.cwd(), process.env.BACKUP_DATABASE_PATH);
} else if (process.env.DATABASE_URL) {
// Fallback sur DATABASE_URL si BACKUP_DATABASE_PATH n'est pas défini
dbPath = path.resolve(process.env.DATABASE_URL.replace('file:', ''));
} else {
// Chemin par défaut vers prisma/dev.db
dbPath = path.resolve(process.cwd(), 'prisma', 'dev.db');
}
console.log(`🔄 Restore paths - backup: ${backupPath}, target: ${dbPath}`);
console.log(`🔄 Starting restore from: ${filename}`);
try {
// Vérifier que le fichier de sauvegarde existe
await fs.access(backupPath);
// Décompresser si nécessaire
let sourceFile = backupPath;
if (filename.endsWith('.gz')) {
const tempFile = backupPath.replace('.gz', '');
console.log(`🔄 Decompressing ${backupPath} to ${tempFile}`);
try {
await execAsync(`gunzip -c "${backupPath}" > "${tempFile}"`);
console.log(`✅ Decompression successful`);
// Vérifier que le fichier décompressé existe
await fs.access(tempFile);
console.log(`✅ Decompressed file exists: ${tempFile}`);
sourceFile = tempFile;
} catch (decompError) {
console.error(`❌ Decompression failed:`, decompError);
throw decompError;
}
}
// Créer une sauvegarde de la base actuelle avant restauration
const currentBackup = await this.createBackup('manual');
console.log(`✅ Current database backed up as: ${currentBackup.filename}`);
// Fermer toutes les connexions
await prisma.$disconnect();
// Vérifier que le fichier source existe
await fs.access(sourceFile);
console.log(`✅ Source file verified: ${sourceFile}`);
// Remplacer la base de données
console.log(`🔄 Copying ${sourceFile} to ${dbPath}`);
await fs.copyFile(sourceFile, dbPath);
console.log(`✅ Database file copied successfully`);
// Nettoyer le fichier temporaire si décompressé
if (sourceFile !== backupPath) {
await fs.unlink(sourceFile);
}
// Reconnecter à la base
await prisma.$connect();
// Vérifier l'intégrité après restauration
await this.verifyDatabaseHealth();
console.log(`✅ Database restored from: ${filename}`);
} catch (error) {
console.error(`❌ Restore failed:`, error);
throw new Error(`Failed to restore backup: ${error}`);
}
}
/**
* Obtient le chemin de sauvegarde actuel (toujours à jour)
* Force la relecture des variables d'environnement à chaque appel
*/
private getCurrentBackupPath(): string {
// Toujours recalculer depuis les variables d'environnement
// pour éviter les problèmes de cache lors des refresh
return this.getDefaultBackupPath();
}
/**
* Liste toutes les sauvegardes disponibles
*/
async listBackups(): Promise<BackupInfo[]> {
try {
const currentBackupPath = this.getCurrentBackupPath();
await this.ensureBackupDirectory();
const files = await fs.readdir(currentBackupPath);
const backups: BackupInfo[] = [];
for (const file of files) {
if (file.startsWith('towercontrol_') && (file.endsWith('.db') || file.endsWith('.db.gz'))) {
const filePath = path.join(currentBackupPath, file);
const stats = await fs.stat(filePath);
// Extraire le type et la date du nom de fichier
// Nouveau format: towercontrol_manual_2025-09-18T14-12-05-737Z.db
// Ancien format: towercontrol_2025-09-18T14-12-05-737Z.db (considéré comme automatic)
let type: 'manual' | 'automatic' = 'automatic';
let dateMatch = file.match(/towercontrol_(manual|automatic)_(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)/);
if (!dateMatch) {
// Format ancien sans type - considérer comme automatic
dateMatch = file.match(/towercontrol_(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)/);
if (dateMatch) {
dateMatch = [dateMatch[0], 'automatic', dateMatch[1]]; // Restructurer pour compatibilité
}
} else {
type = dateMatch[1] as 'manual' | 'automatic';
}
let createdAt = stats.birthtime;
if (dateMatch && dateMatch[2]) {
// Convertir le format de fichier vers ISO string valide
// Format: 2025-09-18T14-12-05-737Z -> 2025-09-18T14:12:05.737Z
const isoString = dateMatch[2]
.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/, 'T$1:$2:$3.$4Z');
createdAt = new Date(isoString);
}
backups.push({
id: file,
filename: file,
size: stats.size,
createdAt,
type,
status: 'success',
});
}
}
return backups.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
} catch (error) {
console.error('Error listing backups:', error);
return [];
}
}
/**
* Supprime une sauvegarde
*/
async deleteBackup(filename: string): Promise<void> {
const backupPath = path.join(this.getCurrentBackupPath(), filename);
try {
await fs.unlink(backupPath);
console.log(`✅ Backup deleted: ${filename}`);
} catch (error) {
console.error(`❌ Failed to delete backup ${filename}:`, error);
throw error;
}
}
/**
* Vérifie l'intégrité de la base de données
*/
async verifyDatabaseHealth(): Promise<void> {
try {
// Test de connexion simple
await prisma.$queryRaw`SELECT 1`;
// Vérification de l'intégrité SQLite
const result = await prisma.$queryRaw<{integrity_check: string}[]>`PRAGMA integrity_check`;
if (result.length > 0 && result[0].integrity_check !== 'ok') {
throw new Error(`Database integrity check failed: ${result[0].integrity_check}`);
}
console.log('✅ Database health check passed');
} catch (error) {
console.error('❌ Database health check failed:', error);
throw error;
}
}
/**
* Nettoie les anciennes sauvegardes selon la configuration
*/
private async cleanOldBackups(): Promise<void> {
try {
const backups = await this.listBackups();
if (backups.length > this.config.maxBackups) {
const toDelete = backups.slice(this.config.maxBackups);
for (const backup of toDelete) {
await this.deleteBackup(backup.filename);
}
console.log(`🧹 Cleaned ${toDelete.length} old backups`);
}
} catch (error) {
console.error('Error cleaning old backups:', error);
}
}
/**
* S'assure que le dossier de backup existe
*/
private async ensureBackupDirectory(): Promise<void> {
const currentBackupPath = this.getCurrentBackupPath();
try {
await fs.access(currentBackupPath);
} catch {
await fs.mkdir(currentBackupPath, { recursive: true });
console.log(`📁 Created backup directory: ${currentBackupPath}`);
}
}
/**
* Formate la taille de fichier
*/
private formatFileSize(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
/**
* Met à jour la configuration
*/
async updateConfig(newConfig: Partial<BackupConfig>): Promise<void> {
this.config = { ...this.config, ...newConfig };
await this.saveConfigToDB();
}
/**
* Obtient la configuration actuelle
*/
getConfig(): BackupConfig {
// Retourner une config avec le chemin à jour
return {
...this.config,
backupPath: this.getCurrentBackupPath()
};
}
}
// Instance singleton
export const backupService = new BackupService();