Files
picpeak/backend/src/services/restoreService.js
T
paul 20dd43c093
Mirror to GitHub / mirror (push) Successful in 28s
Test and Lint / backend-test (push) Successful in 1m26s
continuous-integration/drone/push Build is failing
Test and Lint / frontend-test (push) Failing after 2m18s
Version and Release / version-bump (push) Successful in 42s
Version and Release / trigger-drone (push) Successful in 3s
feat: implement comprehensive backup and restore system with S3 support
- Add S3/MinIO storage adapter with multipart upload support
- Implement database backup service for SQLite and PostgreSQL
- Create backup manifest generator for tracking backup contents
- Enhance backup service with S3 integration and incremental backups
- Add restore service with safety measures and rollback capability
- Create comprehensive test suite for all backup functionality
- Add admin API endpoints for backup/restore management
- Implement frontend UI with dashboard, configuration, and restore wizard
- Add roadmap section to README with implemented backup feature

This implementation provides:
- Multiple backup destinations (local, rsync, S3/MinIO)
- Intelligent change detection to minimize backup frequency
- Full database backups with compression
- Manifest-based restore with integrity validation
- Pre-restore safety backups with rollback
- Comprehensive error handling and monitoring
- User-friendly admin interface

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 09:05:52 +02:00

1221 lines
40 KiB
JavaScript

const fs = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
const zlib = require('zlib');
const { pipeline } = require('stream/promises');
const { createReadStream, createWriteStream } = require('fs');
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
const { db } = require('../database/db');
const knexConfig = require('../../knexfile');
const logger = require('../utils/logger');
const backupManifest = require('./backupManifest');
const S3StorageAdapter = require('./storage/s3Storage');
const { queueEmail } = require('./emailProcessor');
const { formatBoolean } = require('../utils/dbCompat');
const os = require('os');
/**
* Restore Service with Extreme Safety Measures
*
* This service handles restoration of backups with multiple safety checks,
* validation, and rollback capabilities. It prioritizes data safety over speed.
*
* Features:
* - Pre-restore validation and integrity checks
* - Automatic pre-restore backup creation
* - Atomic operations where possible
* - Comprehensive rollback capability
* - Detailed logging of every action
* - Dry-run mode for testing
* - Multiple restore options (full, database-only, files-only, selective)
* - S3 support with resume capability
* - Post-restore verification
*
* @class RestoreService
*/
class RestoreService {
constructor() {
this.isRunning = false;
this.currentProgress = null;
this.restoreLog = [];
this.preRestoreBackupPath = null;
this.dbType = knexConfig.client === 'pg' ? 'postgresql' : 'sqlite';
this.tempDir = path.join(os.tmpdir(), 'picpeak-restore');
}
/**
* Main restore method with comprehensive safety checks
*
* @param {Object} options - Restore options
* @param {string} options.source - Backup source (file path or S3 URL)
* @param {string} options.manifestPath - Path to backup manifest
* @param {string} options.restoreType - 'full', 'database', 'files', or 'selective'
* @param {Array} options.selectedItems - For selective restore, array of items to restore
* @param {boolean} options.dryRun - If true, performs validation only
* @param {boolean} options.skipPreBackup - Skip automatic pre-restore backup (dangerous!)
* @param {boolean} options.force - Force restore even with warnings (dangerous!)
* @param {Object} options.s3Config - S3 configuration for S3-based backups
* @returns {Promise<Object>} - Restore result
*/
async restore(options) {
if (this.isRunning) {
throw new Error('Restore operation already in progress');
}
this.isRunning = true;
this.restoreLog = [];
const startTime = new Date();
let restoreRun = null;
try {
// Validate options
this.validateRestoreOptions(options);
this.log('info', 'Starting restore operation', { options: this.sanitizeOptions(options) });
// Create restore run record
const [runId] = await db('restore_runs').insert({
started_at: startTime,
status: 'running',
restore_type: options.restoreType,
source: options.source,
manifest_path: options.manifestPath,
is_dry_run: options.dryRun || false
});
restoreRun = { id: runId };
// Step 1: Load and validate manifest
this.updateProgress('Loading and validating manifest...');
const manifest = await this.loadAndValidateManifest(options.manifestPath, options.s3Config);
this.log('info', 'Manifest loaded and validated', {
backupId: manifest.backup.id,
backupType: manifest.backup.type,
backupTime: manifest.backup.timestamp
});
// Step 2: Pre-restore validation
this.updateProgress('Performing pre-restore validation...');
const validation = await this.performPreRestoreValidation(manifest, options);
if (!validation.isValid && !options.force) {
throw new Error(`Pre-restore validation failed: ${validation.errors.join(', ')}`);
}
if (validation.warnings.length > 0) {
this.log('warn', 'Pre-restore validation warnings', { warnings: validation.warnings });
if (!options.force) {
throw new Error(`Restore blocked due to warnings (use force to override): ${validation.warnings.join(', ')}`);
}
}
// Step 3: Check disk space
this.updateProgress('Checking available disk space...');
const spaceCheck = await this.checkDiskSpace(manifest, options);
if (!spaceCheck.hasEnoughSpace) {
throw new Error(`Insufficient disk space. Required: ${spaceCheck.requiredFormatted}, Available: ${spaceCheck.availableFormatted}`);
}
// Dry run mode - stop here after validation
if (options.dryRun) {
this.log('info', 'Dry run completed successfully');
await db('restore_runs').where('id', runId).update({
completed_at: new Date(),
status: 'completed',
statistics: JSON.stringify({
dryRun: true,
validation,
spaceCheck,
manifest: {
backupId: manifest.backup.id,
fileCount: manifest.files.count,
totalSize: manifest.files.total_size
}
})
});
return {
success: true,
dryRun: true,
validation,
spaceCheck,
logs: this.restoreLog
};
}
// Step 4: Create pre-restore backup (unless explicitly skipped)
if (!options.skipPreBackup) {
this.updateProgress('Creating pre-restore safety backup...');
this.preRestoreBackupPath = await this.createPreRestoreBackup(options);
this.log('info', 'Pre-restore backup created', { path: this.preRestoreBackupPath });
} else {
this.log('warn', 'Pre-restore backup skipped at user request');
}
// Step 5: Download backup if from S3
let localBackupPath = options.source;
if (options.source.startsWith('s3://')) {
this.updateProgress('Downloading backup from S3...');
localBackupPath = await this.downloadFromS3(options.source, manifest, options);
}
// Step 6: Perform the actual restore based on type
let restoreResult;
switch (options.restoreType) {
case 'full':
restoreResult = await this.performFullRestore(localBackupPath, manifest, options);
break;
case 'database':
restoreResult = await this.performDatabaseRestore(localBackupPath, manifest, options);
break;
case 'files':
restoreResult = await this.performFilesRestore(localBackupPath, manifest, options);
break;
case 'selective':
restoreResult = await this.performSelectiveRestore(localBackupPath, manifest, options);
break;
default:
throw new Error(`Unknown restore type: ${options.restoreType}`);
}
// Step 7: Post-restore verification
this.updateProgress('Performing post-restore verification...');
const verification = await this.performPostRestoreVerification(manifest, options);
if (!verification.isValid) {
this.log('error', 'Post-restore verification failed', { errors: verification.errors });
// Attempt rollback
if (this.preRestoreBackupPath) {
await this.attemptRollback(this.preRestoreBackupPath);
}
throw new Error(`Post-restore verification failed: ${verification.errors.join(', ')}`);
}
// Step 8: Clean up temporary files
if (localBackupPath !== options.source) {
await fs.unlink(localBackupPath).catch(err =>
this.log('warn', 'Failed to clean up temporary backup file', { error: err.message })
);
}
// Calculate duration
const endTime = new Date();
const durationSeconds = Math.round((endTime - startTime) / 1000);
// Update restore run record
await db('restore_runs').where('id', runId).update({
completed_at: endTime,
status: 'completed',
duration_seconds: durationSeconds,
pre_restore_backup_path: this.preRestoreBackupPath,
statistics: JSON.stringify({
...restoreResult,
verification,
durationSeconds
})
});
// Send success notification
await this.sendRestoreNotification('success', {
restoreType: options.restoreType,
duration: durationSeconds,
filesRestored: restoreResult.filesRestored || 0,
backupId: manifest.backup.id
});
this.log('info', 'Restore completed successfully', {
duration: `${durationSeconds}s`,
result: restoreResult
});
return {
success: true,
duration: durationSeconds,
result: restoreResult,
verification,
preRestoreBackup: this.preRestoreBackupPath,
logs: this.restoreLog
};
} catch (error) {
this.log('error', 'Restore failed', { error: error.message, stack: error.stack });
// Update restore run record
if (restoreRun) {
await db('restore_runs').where('id', restoreRun.id).update({
completed_at: new Date(),
status: 'failed',
error_message: error.message,
restore_log: JSON.stringify(this.restoreLog)
});
}
// Send failure notification
await this.sendRestoreNotification('failure', {
error: error.message,
restoreType: options.restoreType
});
throw error;
} finally {
this.isRunning = false;
this.currentProgress = null;
// Clean up temp directory
try {
await fs.rmdir(this.tempDir, { recursive: true });
} catch (err) {
// Ignore cleanup errors
}
}
}
/**
* Validate restore options
*/
validateRestoreOptions(options) {
if (!options.source) {
throw new Error('Backup source is required');
}
if (!options.manifestPath) {
throw new Error('Manifest path is required');
}
if (!options.restoreType) {
throw new Error('Restore type is required');
}
const validTypes = ['full', 'database', 'files', 'selective'];
if (!validTypes.includes(options.restoreType)) {
throw new Error(`Invalid restore type. Must be one of: ${validTypes.join(', ')}`);
}
if (options.restoreType === 'selective' && (!options.selectedItems || options.selectedItems.length === 0)) {
throw new Error('Selected items are required for selective restore');
}
if (options.source.startsWith('s3://') && !options.s3Config) {
throw new Error('S3 configuration is required for S3-based backups');
}
}
/**
* Load and validate manifest
*/
async loadAndValidateManifest(manifestPath, s3Config) {
let manifest;
if (manifestPath.startsWith('s3://')) {
// Download manifest from S3
const tempManifestPath = path.join(this.tempDir, 'manifest.json');
await fs.mkdir(this.tempDir, { recursive: true });
await this.downloadFileFromS3(manifestPath, tempManifestPath, s3Config);
manifest = await backupManifest.loadManifest(tempManifestPath);
} else {
manifest = await backupManifest.loadManifest(manifestPath);
}
// Validate manifest
backupManifest.validateManifest(manifest);
return manifest;
}
/**
* Perform pre-restore validation
*/
async performPreRestoreValidation(manifest, options) {
const validation = {
isValid: true,
errors: [],
warnings: []
};
try {
// Check backup integrity
if (manifest.verification && manifest.verification.total_checksum) {
const calculatedChecksum = backupManifest.calculateManifestChecksum(manifest);
if (calculatedChecksum !== manifest.verification.total_checksum) {
validation.errors.push('Manifest checksum verification failed');
validation.isValid = false;
}
}
// Check backup age
const backupAge = Date.now() - new Date(manifest.backup.timestamp).getTime();
const ageInDays = backupAge / (1000 * 60 * 60 * 24);
if (ageInDays > 30) {
validation.warnings.push(`Backup is ${Math.round(ageInDays)} days old`);
}
// Check version compatibility
const currentVersion = require('../../package.json').version;
if (manifest.application.version !== currentVersion) {
validation.warnings.push(
`Version mismatch: backup from v${manifest.application.version}, current v${currentVersion}`
);
}
// Check database compatibility
if (options.restoreType === 'full' || options.restoreType === 'database') {
const currentDbType = this.dbType;
if (manifest.database.type !== currentDbType) {
validation.errors.push(
`Database type mismatch: backup is ${manifest.database.type}, current is ${currentDbType}`
);
validation.isValid = false;
}
}
// Check if restoring would overwrite existing data
if (options.restoreType === 'full' || options.restoreType === 'database') {
const eventCount = await db('events').count('* as count').first();
if (eventCount && eventCount.count > 0) {
validation.warnings.push(`Database contains ${eventCount.count} existing events that will be overwritten`);
}
}
// Check for active users
const activeUsers = await db('admin_users')
.where('is_active', formatBoolean(true))
.count('* as count')
.first();
if (activeUsers && activeUsers.count > 0) {
validation.warnings.push(`There are ${activeUsers.count} active admin users`);
}
} catch (error) {
validation.errors.push(`Validation error: ${error.message}`);
validation.isValid = false;
}
return validation;
}
/**
* Check available disk space
*/
async checkDiskSpace(manifest, options) {
const { statvfs } = require('fs');
const statvfsAsync = promisify(statvfs);
try {
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const stats = await statvfsAsync(storagePath);
const blockSize = stats.bsize || stats.f_bsize || 4096;
const availableBytes = stats.bavail * blockSize;
// Calculate required space (with 20% buffer)
let requiredBytes = 0;
if (options.restoreType === 'full' || options.restoreType === 'files') {
requiredBytes = manifest.files.total_size * 1.2;
}
if (options.restoreType === 'full' || options.restoreType === 'database') {
requiredBytes += (manifest.database.size || 0) * 1.2;
}
// Add space for pre-restore backup
if (!options.skipPreBackup) {
const currentUsage = await this.calculateCurrentStorageUsage();
requiredBytes += currentUsage * 1.1; // 10% buffer for backup
}
return {
hasEnoughSpace: availableBytes > requiredBytes,
availableBytes,
requiredBytes,
availableFormatted: this.formatBytes(availableBytes),
requiredFormatted: this.formatBytes(requiredBytes)
};
} catch (error) {
// Fallback for systems without statvfs
this.log('warn', 'Could not check disk space', { error: error.message });
return {
hasEnoughSpace: true, // Assume we have space if we can't check
availableBytes: 0,
requiredBytes: 0,
availableFormatted: 'Unknown',
requiredFormatted: 'Unknown'
};
}
}
/**
* Create pre-restore backup
*/
async createPreRestoreBackup(options) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupName = `pre-restore-${timestamp}`;
const backupPath = path.join(this.tempDir, backupName);
await fs.mkdir(backupPath, { recursive: true });
try {
// Backup database
if (options.restoreType === 'full' || options.restoreType === 'database') {
this.log('info', 'Backing up current database...');
const dbBackupPath = path.join(backupPath, 'database.sql');
if (this.dbType === 'sqlite') {
const dbPath = knexConfig.connection.filename;
await execAsync(`sqlite3 "${dbPath}" ".backup '${dbBackupPath}'"`);
} else {
// PostgreSQL backup
const { host, port, user, password, database } = knexConfig.connection;
const env = { ...process.env, PGPASSWORD: password };
await execAsync(
`pg_dump -h ${host} -p ${port} -U ${user} -d ${database} > "${dbBackupPath}"`,
{ env }
);
}
// Compress database backup
await this.compressFile(dbBackupPath, `${dbBackupPath}.gz`);
await fs.unlink(dbBackupPath);
}
// Backup files
if (options.restoreType === 'full' || options.restoreType === 'files') {
this.log('info', 'Backing up current files...');
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const filesBackupPath = path.join(backupPath, 'files.tar.gz');
await execAsync(`tar -czf "${filesBackupPath}" -C "${path.dirname(storagePath)}" "${path.basename(storagePath)}"`);
}
// Create backup manifest
const backupManifest = {
timestamp: new Date().toISOString(),
type: 'pre-restore-safety-backup',
restoreOptions: this.sanitizeOptions(options),
contents: await fs.readdir(backupPath)
};
await fs.writeFile(
path.join(backupPath, 'backup-manifest.json'),
JSON.stringify(backupManifest, null, 2)
);
return backupPath;
} catch (error) {
// Clean up on failure
await fs.rmdir(backupPath, { recursive: true }).catch(() => {});
throw new Error(`Failed to create pre-restore backup: ${error.message}`);
}
}
/**
* Download backup from S3
*/
async downloadFromS3(s3Url, manifest, options) {
const s3PathMatch = s3Url.match(/^s3:\/\/([^\/]+)\/(.+)$/);
if (!s3PathMatch) {
throw new Error('Invalid S3 URL format');
}
const [, bucket, prefix] = s3PathMatch;
const s3Client = new S3StorageAdapter({
...options.s3Config,
bucket
});
const localPath = path.join(this.tempDir, 'restore-download');
await fs.mkdir(localPath, { recursive: true });
try {
// Test S3 connection
await s3Client.testConnection();
// Download database backup if needed
if (options.restoreType === 'full' || options.restoreType === 'database') {
if (manifest.database.backup_file) {
const dbS3Key = path.posix.join(prefix, 'database', path.basename(manifest.database.backup_file));
const localDbPath = path.join(localPath, 'database', path.basename(manifest.database.backup_file));
await fs.mkdir(path.dirname(localDbPath), { recursive: true });
this.log('info', 'Downloading database backup from S3...', { key: dbS3Key });
await s3Client.download(dbS3Key, localDbPath, {
onProgress: (loaded, total) => {
const percent = Math.round((loaded / total) * 100);
this.updateProgress(`Downloading database backup: ${percent}%`);
}
});
}
}
// Download files if needed
if (options.restoreType === 'full' || options.restoreType === 'files') {
const filesToDownload = options.restoreType === 'selective'
? options.selectedItems
: manifest.files.manifest;
let downloaded = 0;
for (const file of filesToDownload) {
const s3Key = path.posix.join(prefix, file.path);
const localFilePath = path.join(localPath, file.path);
await fs.mkdir(path.dirname(localFilePath), { recursive: true });
try {
await s3Client.download(s3Key, localFilePath, {
onProgress: (loaded, total) => {
const filePercent = Math.round((loaded / total) * 100);
const totalPercent = Math.round(((downloaded + (loaded / total)) / filesToDownload.length) * 100);
this.updateProgress(`Downloading files: ${totalPercent}% (${file.path}: ${filePercent}%)`);
}
});
// Verify checksum if available
if (file.checksum) {
const downloadedChecksum = await this.calculateChecksum(localFilePath);
if (downloadedChecksum !== file.checksum) {
throw new Error(`Checksum mismatch for ${file.path}`);
}
}
downloaded++;
} catch (error) {
this.log('error', `Failed to download ${file.path}`, { error: error.message });
throw error;
}
}
}
return localPath;
} catch (error) {
// Clean up on failure
await fs.rmdir(localPath, { recursive: true }).catch(() => {});
throw new Error(`Failed to download from S3: ${error.message}`);
}
}
/**
* Perform full restore (database + files)
*/
async performFullRestore(backupPath, manifest, options) {
const result = {
databaseRestored: false,
filesRestored: 0,
errors: []
};
try {
// Restore database first
const dbResult = await this.performDatabaseRestore(backupPath, manifest, options);
result.databaseRestored = dbResult.success;
// Then restore files
const filesResult = await this.performFilesRestore(backupPath, manifest, options);
result.filesRestored = filesResult.filesRestored;
return result;
} catch (error) {
result.errors.push(error.message);
throw new Error(`Full restore failed: ${error.message}`);
}
}
/**
* Perform database-only restore
*/
async performDatabaseRestore(backupPath, manifest, options) {
this.updateProgress('Restoring database...');
const dbBackupFile = manifest.database.backup_file;
if (!dbBackupFile) {
throw new Error('No database backup file found in manifest');
}
const dbBackupPath = path.join(backupPath, 'database', path.basename(dbBackupFile));
// Check if backup file exists
try {
await fs.access(dbBackupPath);
} catch (error) {
throw new Error(`Database backup file not found: ${dbBackupPath}`);
}
// Decompress if needed
let restoreFile = dbBackupPath;
if (dbBackupPath.endsWith('.gz')) {
this.log('info', 'Decompressing database backup...');
const decompressedPath = dbBackupPath.replace('.gz', '');
await this.decompressFile(dbBackupPath, decompressedPath);
restoreFile = decompressedPath;
}
try {
if (this.dbType === 'sqlite') {
// SQLite restore
const dbPath = knexConfig.connection.filename;
// Close all database connections
await db.destroy();
// Backup current database
const currentBackup = `${dbPath}.restore-backup`;
await fs.copyFile(dbPath, currentBackup);
try {
// Restore from backup
await execAsync(`sqlite3 "${dbPath}" ".restore '${restoreFile}'"`);
// Verify integrity
const integrityCheck = await execAsync(`sqlite3 "${dbPath}" "PRAGMA integrity_check"`);
if (!integrityCheck.stdout.includes('ok')) {
throw new Error('Database integrity check failed after restore');
}
// Remove backup of previous database
await fs.unlink(currentBackup);
} catch (error) {
// Rollback on failure
await fs.copyFile(currentBackup, dbPath);
await fs.unlink(currentBackup);
throw error;
}
} else {
// PostgreSQL restore
const { host, port, user, password, database } = knexConfig.connection;
const env = { ...process.env, PGPASSWORD: password };
// Drop and recreate database (extremely dangerous!)
this.log('warn', 'Dropping and recreating PostgreSQL database...');
await execAsync(
`psql -h ${host} -p ${port} -U ${user} -c "DROP DATABASE IF EXISTS ${database}"`,
{ env }
);
await execAsync(
`psql -h ${host} -p ${port} -U ${user} -c "CREATE DATABASE ${database}"`,
{ env }
);
// Restore from backup
await execAsync(
`psql -h ${host} -p ${port} -U ${user} -d ${database} < "${restoreFile}"`,
{ env, maxBuffer: 1024 * 1024 * 100 } // 100MB buffer
);
}
// Re-initialize database connection
const { db: newDb } = require('../database/db');
// Run migrations to ensure schema is up to date
this.log('info', 'Running database migrations...');
await newDb.migrate.latest();
return { success: true };
} catch (error) {
this.log('error', 'Database restore failed', { error: error.message });
throw error;
} finally {
// Clean up decompressed file
if (restoreFile !== dbBackupPath) {
await fs.unlink(restoreFile).catch(() => {});
}
}
}
/**
* Perform files-only restore
*/
async performFilesRestore(backupPath, manifest, options) {
this.updateProgress('Restoring files...');
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const filesToRestore = options.restoreType === 'selective'
? options.selectedItems.filter(item => item.type === 'file')
: manifest.files.manifest;
let restoredCount = 0;
const errors = [];
for (const file of filesToRestore) {
try {
const sourcePath = path.join(backupPath, file.path);
const targetPath = path.join(storagePath, file.path);
// Check if source file exists
try {
await fs.access(sourcePath);
} catch (error) {
errors.push(`Source file not found: ${file.path}`);
continue;
}
// Create target directory
await fs.mkdir(path.dirname(targetPath), { recursive: true });
// Check if target exists and create backup
let targetBackup = null;
try {
await fs.access(targetPath);
targetBackup = `${targetPath}.restore-backup`;
await fs.copyFile(targetPath, targetBackup);
} catch (error) {
// Target doesn't exist, no backup needed
}
try {
// Copy file
await fs.copyFile(sourcePath, targetPath);
// Verify checksum if available
if (file.checksum) {
const restoredChecksum = await this.calculateChecksum(targetPath);
if (restoredChecksum !== file.checksum) {
throw new Error('Checksum verification failed');
}
}
// Set file permissions if available
if (file.permissions) {
await fs.chmod(targetPath, file.permissions);
}
// Set modification time
if (file.modified) {
const mtime = new Date(file.modified);
await fs.utimes(targetPath, mtime, mtime);
}
// Remove backup of previous file
if (targetBackup) {
await fs.unlink(targetBackup);
}
restoredCount++;
if (restoredCount % 100 === 0) {
this.updateProgress(`Restored ${restoredCount}/${filesToRestore.length} files`);
}
} catch (error) {
// Rollback on failure
if (targetBackup) {
await fs.copyFile(targetBackup, targetPath);
await fs.unlink(targetBackup);
}
throw error;
}
} catch (error) {
errors.push(`Failed to restore ${file.path}: ${error.message}`);
this.log('error', `Failed to restore file ${file.path}`, { error: error.message });
}
}
if (errors.length > 0 && errors.length === filesToRestore.length) {
throw new Error('All file restorations failed');
}
return {
filesRestored: restoredCount,
totalFiles: filesToRestore.length,
errors
};
}
/**
* Perform selective restore
*/
async performSelectiveRestore(backupPath, manifest, options) {
const result = {
itemsRestored: 0,
errors: []
};
// Separate selected items by type
const databaseItems = options.selectedItems.filter(item => item.type === 'database');
const fileItems = options.selectedItems.filter(item => item.type === 'file');
// Restore database items (tables)
if (databaseItems.length > 0) {
this.log('warn', 'Selective database restore not implemented yet');
result.errors.push('Selective database restore not implemented');
}
// Restore file items
if (fileItems.length > 0) {
const filesResult = await this.performFilesRestore(backupPath, manifest, {
...options,
selectedItems: fileItems
});
result.itemsRestored += filesResult.filesRestored;
result.errors.push(...filesResult.errors);
}
return result;
}
/**
* Perform post-restore verification
*/
async performPostRestoreVerification(manifest, options) {
const verification = {
isValid: true,
errors: [],
checksums: {}
};
try {
// Verify database
if (options.restoreType === 'full' || options.restoreType === 'database') {
// Check database connectivity
try {
await db.raw('SELECT 1');
} catch (error) {
verification.errors.push('Database connection failed after restore');
verification.isValid = false;
}
// Compare table checksums if available
if (manifest.database.row_counts) {
for (const [table, expected] of Object.entries(manifest.database.row_counts)) {
try {
const result = await db(table).count('* as count').first();
if (result.count !== expected.rowCount) {
verification.errors.push(
`Table ${table} row count mismatch: expected ${expected.rowCount}, got ${result.count}`
);
}
} catch (error) {
verification.errors.push(`Failed to verify table ${table}: ${error.message}`);
}
}
}
}
// Verify files
if (options.restoreType === 'full' || options.restoreType === 'files') {
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const filesToVerify = options.restoreType === 'selective'
? options.selectedItems.filter(item => item.type === 'file')
: manifest.files.manifest.slice(0, 100); // Verify first 100 files for performance
for (const file of filesToVerify) {
const filePath = path.join(storagePath, file.path);
try {
await fs.access(filePath);
if (file.checksum) {
const actualChecksum = await this.calculateChecksum(filePath);
verification.checksums[file.path] = {
expected: file.checksum,
actual: actualChecksum,
match: actualChecksum === file.checksum
};
if (actualChecksum !== file.checksum) {
verification.errors.push(`Checksum mismatch for ${file.path}`);
}
}
} catch (error) {
verification.errors.push(`File not found after restore: ${file.path}`);
}
}
}
} catch (error) {
verification.errors.push(`Verification error: ${error.message}`);
verification.isValid = false;
}
verification.isValid = verification.errors.length === 0;
return verification;
}
/**
* Attempt rollback using pre-restore backup
*/
async attemptRollback(preRestoreBackupPath) {
this.log('warn', 'Attempting rollback to pre-restore state...');
try {
// Read backup manifest
const manifestPath = path.join(preRestoreBackupPath, 'backup-manifest.json');
const backupManifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
// Restore database if backed up
const dbBackupPath = path.join(preRestoreBackupPath, 'database.sql.gz');
if (await fs.access(dbBackupPath).then(() => true).catch(() => false)) {
const decompressedPath = dbBackupPath.replace('.gz', '');
await this.decompressFile(dbBackupPath, decompressedPath);
if (this.dbType === 'sqlite') {
const dbPath = knexConfig.connection.filename;
await execAsync(`sqlite3 "${dbPath}" ".restore '${decompressedPath}'"`);
} else {
const { host, port, user, password, database } = knexConfig.connection;
const env = { ...process.env, PGPASSWORD: password };
await execAsync(
`psql -h ${host} -p ${port} -U ${user} -d ${database} < "${decompressedPath}"`,
{ env }
);
}
await fs.unlink(decompressedPath);
}
// Restore files if backed up
const filesBackupPath = path.join(preRestoreBackupPath, 'files.tar.gz');
if (await fs.access(filesBackupPath).then(() => true).catch(() => false)) {
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
await execAsync(`tar -xzf "${filesBackupPath}" -C "${path.dirname(storagePath)}"`);
}
this.log('info', 'Rollback completed successfully');
} catch (error) {
this.log('error', 'Rollback failed', { error: error.message });
throw new Error(`Rollback failed: ${error.message}. Manual intervention may be required.`);
}
}
// Utility methods
/**
* Calculate file checksum
*/
async calculateChecksum(filePath) {
const hash = crypto.createHash('sha256');
const stream = createReadStream(filePath);
return new Promise((resolve, reject) => {
stream.on('data', data => hash.update(data));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
/**
* Compress file using gzip
*/
async compressFile(inputPath, outputPath) {
const gzip = zlib.createGzip({ level: 6 });
const source = createReadStream(inputPath);
const destination = createWriteStream(outputPath);
await pipeline(source, gzip, destination);
}
/**
* Decompress gzip file
*/
async decompressFile(inputPath, outputPath) {
const gunzip = zlib.createGunzip();
const source = createReadStream(inputPath);
const destination = createWriteStream(outputPath);
await pipeline(source, gunzip, destination);
}
/**
* Download file from S3
*/
async downloadFileFromS3(s3Url, localPath, s3Config) {
const s3PathMatch = s3Url.match(/^s3:\/\/([^\/]+)\/(.+)$/);
if (!s3PathMatch) {
throw new Error('Invalid S3 URL format');
}
const [, bucket, key] = s3PathMatch;
const s3Client = new S3StorageAdapter({
...s3Config,
bucket
});
await s3Client.download(key, localPath);
}
/**
* Calculate current storage usage
*/
async calculateCurrentStorageUsage() {
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
let totalSize = 0;
async function calculateDirSize(dirPath) {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
await calculateDirSize(fullPath);
} else if (entry.isFile()) {
const stats = await fs.stat(fullPath);
totalSize += stats.size;
}
}
}
await calculateDirSize(storagePath);
return totalSize;
}
/**
* Format bytes to human readable
*/
formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
/**
* Update progress
*/
updateProgress(message, details = {}) {
this.currentProgress = {
message,
details,
timestamp: new Date()
};
logger.info(`Restore progress: ${message}`, details);
}
/**
* Get current progress
*/
getProgress() {
return this.currentProgress;
}
/**
* Log message
*/
log(level, message, details = {}) {
const logEntry = {
timestamp: new Date().toISOString(),
level,
message,
details
};
this.restoreLog.push(logEntry);
logger[level](message, details);
}
/**
* Sanitize options for logging
*/
sanitizeOptions(options) {
const sanitized = { ...options };
if (sanitized.s3Config) {
sanitized.s3Config = {
...sanitized.s3Config,
accessKeyId: sanitized.s3Config.accessKeyId ? '***' : undefined,
secretAccessKey: sanitized.s3Config.secretAccessKey ? '***' : undefined
};
}
return sanitized;
}
/**
* Send restore notification
*/
async sendRestoreNotification(type, details) {
try {
const admins = await db('admin_users').where('is_active', formatBoolean(true));
for (const admin of admins) {
if (type === 'success') {
await queueEmail(null, admin.email, 'restore_completed', {
restore_type: details.restoreType,
duration: `${details.duration} seconds`,
files_restored: details.filesRestored,
backup_id: details.backupId,
timestamp: new Date().toISOString()
});
} else {
await queueEmail(null, admin.email, 'restore_failed', {
restore_type: details.restoreType,
error_message: details.error,
timestamp: new Date().toISOString()
});
}
}
} catch (error) {
this.log('error', 'Failed to send restore notification', { error: error.message });
}
}
/**
* Get restore history
*/
async getRestoreHistory(limit = 10) {
return await db('restore_runs')
.orderBy('started_at', 'desc')
.limit(limit);
}
/**
* Generate restore report
*/
generateRestoreReport(restoreResult) {
const report = [];
report.push('=== RESTORE OPERATION REPORT ===');
report.push(`Status: ${restoreResult.success ? 'SUCCESS' : 'FAILED'}`);
report.push(`Duration: ${restoreResult.duration}s`);
report.push(`Dry Run: ${restoreResult.dryRun ? 'Yes' : 'No'}`);
if (restoreResult.result) {
report.push('\n--- Restore Results ---');
report.push(`Database Restored: ${restoreResult.result.databaseRestored ? 'Yes' : 'No'}`);
report.push(`Files Restored: ${restoreResult.result.filesRestored || 0}`);
if (restoreResult.result.errors && restoreResult.result.errors.length > 0) {
report.push(`Errors: ${restoreResult.result.errors.length}`);
restoreResult.result.errors.forEach(err => report.push(` - ${err}`));
}
}
if (restoreResult.verification) {
report.push('\n--- Verification Results ---');
report.push(`Valid: ${restoreResult.verification.isValid ? 'Yes' : 'No'}`);
if (restoreResult.verification.errors.length > 0) {
report.push('Errors:');
restoreResult.verification.errors.forEach(err => report.push(` - ${err}`));
}
}
if (restoreResult.preRestoreBackup) {
report.push('\n--- Safety Backup ---');
report.push(`Location: ${restoreResult.preRestoreBackup}`);
}
report.push('\n--- Operation Log ---');
restoreResult.logs.forEach(log => {
report.push(`[${log.timestamp}] ${log.level.toUpperCase()}: ${log.message}`);
});
return report.join('\n');
}
}
// Create singleton instance
const restoreService = new RestoreService();
module.exports = {
restoreService,
RestoreService // Export class for testing
};