diff --git a/src/backup/BackupManager.js b/src/backup/BackupManager.js new file mode 100644 index 0000000..e223bc5 --- /dev/null +++ b/src/backup/BackupManager.js @@ -0,0 +1,183 @@ +/** + * BackupManager — Orchestrates backup operations + */ + +const archiver = require('archiver') +const { createReadStream, createWriteStream } = require('fs') +const { mkdir } = require('fs').promises +const path = require('path') +const { Transform } = require('stream') + +class BackupManager { + constructor(provider) { + this.provider = provider + } + + /** + * Create a backup from a project folder + * @param {string} projectPath - Path to project folder + * @param {string} repoName - Repository name (used for filenames) + * @returns {Promise<{filename, size, timestamp}>} + */ + async createBackup(projectPath, repoName) { + try { + // Create ZIP buffer + const buffer = await this._createZip(projectPath) + const timestamp = this._getTimestamp() + const filename = `${repoName}-backup-${timestamp}.zip` + + // Upload to provider + const result = await this.provider.uploadBackup(buffer, filename) + + // Cleanup old backups + await this._cleanupOldBackups(repoName) + + return { + filename, + size: result.size || buffer.length, + timestamp + } + } catch (err) { + throw new Error(`Backup creation failed: ${err.message}`) + } + } + + /** + * List all backups for a repository + * @param {string} repoName - Repository name + * @returns {Promise} + */ + async listBackups(repoName) { + try { + const backups = await this.provider.listBackups() + return backups + .filter(b => b.name.startsWith(repoName)) + .sort((a, b) => new Date(b.date || b.name) - new Date(a.date || a.name)) + } catch (err) { + throw new Error(`Failed to list backups: ${err.message}`) + } + } + + /** + * Restore a backup to a target folder + * @param {string} repoName - Repository name + * @param {string} filename - Backup filename + * @param {string} targetPath - Target folder path + */ + async restoreBackup(repoName, filename, targetPath) { + try { + // Download backup + const buffer = await this.provider.downloadBackup(filename) + + // Extract ZIP + await this._extractZip(buffer, targetPath) + + return { ok: true, restored: filename } + } catch (err) { + throw new Error(`Restore failed: ${err.message}`) + } + } + + /** + * Delete a backup + * @param {string} filename - Backup filename + */ + async deleteBackup(filename) { + try { + await this.provider.deleteBackup(filename) + return { ok: true } + } catch (err) { + throw new Error(`Delete failed: ${err.message}`) + } + } + + // ==================== PRIVATE METHODS ==================== + + /** + * Create ZIP buffer from project folder + * Excludes: .git, node_modules, dist, build, .env + */ + async _createZip(projectPath) { + return new Promise((resolve, reject) => { + const output = [] + const archive = archiver('zip', { zlib: { level: 5 } }) + + archive.on('data', chunk => output.push(chunk)) + archive.on('end', () => resolve(Buffer.concat(output))) + archive.on('error', reject) + + // Add files with exclusions + archive.glob('**/*', { + cwd: projectPath, + ignore: [ + '.git/**', + '.git', + 'node_modules/**', + 'node_modules', + 'dist/**', + 'dist', + 'build/**', + 'build', + '.env', + '.env.local', + '.env.*.local', + '*.log', + 'data/backups/**' + ], + dot: true + }) + + archive.finalize() + }) + } + + /** + * Extract ZIP buffer to target folder + */ + async _extractZip(buffer, targetPath) { + const unzipper = require('unzipper') + + return new Promise((resolve, reject) => { + const { Readable } = require('stream') + const stream = Readable.from(buffer) + + stream + .pipe(unzipper.Extract({ path: targetPath })) + .on('close', resolve) + .on('error', reject) + }) + } + + /** + * Get ISO timestamp (YYYYMMDD-HHMMSS format) + */ + _getTimestamp() { + const now = new Date() + const year = now.getFullYear() + const month = String(now.getMonth() + 1).padStart(2, '0') + const day = String(now.getDate()).padStart(2, '0') + const hours = String(now.getHours()).padStart(2, '0') + const minutes = String(now.getMinutes()).padStart(2, '0') + const seconds = String(now.getSeconds()).padStart(2, '0') + + return `${year}${month}${day}-${hours}${minutes}${seconds}` + } + + /** + * Cleanup old backups (keep only last N versions) + */ + async _cleanupOldBackups(repoName, maxVersions = 5) { + try { + const backups = await this.listBackups(repoName) + + for (let i = maxVersions; i < backups.length; i++) { + await this.provider.deleteBackup(backups[i].name) + } + } catch (err) { + // Silently ignore cleanup errors + console.warn(`Cleanup warning: ${err.message}`) + } + } +} + +module.exports = BackupManager diff --git a/src/backup/BackupProvider.js b/src/backup/BackupProvider.js new file mode 100644 index 0000000..6d0fe98 --- /dev/null +++ b/src/backup/BackupProvider.js @@ -0,0 +1,59 @@ +/** + * BackupProvider — Abstract base class for backup providers + * All providers must implement these methods + */ + +class BackupProvider { + /** + * Authenticate with the backup service + * @param {Object} credentials - Provider-specific credentials + */ + async authenticate(credentials) { + throw new Error('authenticate() not implemented in ' + this.constructor.name) + } + + /** + * Upload a backup file + * @param {Buffer} buffer - File content + * @param {string} filename - Filename (e.g., 'repo-backup-2025-03-24.zip') + * @returns {Promise<{size: number}>} + */ + async uploadBackup(buffer, filename) { + throw new Error('uploadBackup() not implemented in ' + this.constructor.name) + } + + /** + * List all backups for a repository + * @returns {Promise} Array of {name, size, date} + */ + async listBackups() { + throw new Error('listBackups() not implemented in ' + this.constructor.name) + } + + /** + * Download a specific backup + * @param {string} filename - Filename to download + * @returns {Promise} + */ + async downloadBackup(filename) { + throw new Error('downloadBackup() not implemented in ' + this.constructor.name) + } + + /** + * Delete a backup file + * @param {string} filename - Filename to delete + */ + async deleteBackup(filename) { + throw new Error('deleteBackup() not implemented in ' + this.constructor.name) + } + + /** + * Test connection to backup service + * @returns {Promise<{ok: boolean, error?: string}>} + */ + async testConnection() { + throw new Error('testConnection() not implemented in ' + this.constructor.name) + } +} + +module.exports = BackupProvider diff --git a/src/backup/LocalProvider.js b/src/backup/LocalProvider.js new file mode 100644 index 0000000..e32f508 --- /dev/null +++ b/src/backup/LocalProvider.js @@ -0,0 +1,83 @@ +/** + * LocalProvider — Backup provider for local folders + */ + +const path = require('path') +const fs = require('fs').promises +const BackupProvider = require('./BackupProvider') + +class LocalProvider extends BackupProvider { + constructor() { + super() + this.basePath = null + } + + async authenticate(credentials) { + const basePath = String(credentials && credentials.basePath ? credentials.basePath : '').trim() + if (!basePath) { + throw new Error('Lokaler Backup-Ordner fehlt') + } + + this.basePath = path.resolve(basePath) + await fs.mkdir(this.basePath, { recursive: true }) + } + + async testConnection() { + if (!this.basePath) return { ok: false, error: 'Not authenticated' } + + try { + const stat = await fs.stat(this.basePath) + if (!stat.isDirectory()) { + return { ok: false, error: 'Pfad ist kein Ordner' } + } + return { ok: true } + } catch (err) { + return { ok: false, error: err.message } + } + } + + async uploadBackup(buffer, filename) { + if (!this.basePath) throw new Error('Not authenticated') + + const target = path.join(this.basePath, filename) + await fs.writeFile(target, buffer) + return { size: buffer.length } + } + + async listBackups() { + if (!this.basePath) throw new Error('Not authenticated') + + const entries = await fs.readdir(this.basePath, { withFileTypes: true }) + const files = entries.filter(e => e.isFile() && e.name.endsWith('.zip')) + + const backups = [] + for (const f of files) { + const full = path.join(this.basePath, f.name) + const stat = await fs.stat(full) + backups.push({ + name: f.name, + size: stat.size, + date: stat.mtime.toISOString() + }) + } + + backups.sort((a, b) => new Date(b.date) - new Date(a.date)) + return backups + } + + async downloadBackup(filename) { + if (!this.basePath) throw new Error('Not authenticated') + + const file = path.join(this.basePath, filename) + return fs.readFile(file) + } + + async deleteBackup(filename) { + if (!this.basePath) throw new Error('Not authenticated') + + const file = path.join(this.basePath, filename) + await fs.unlink(file) + } +} + +module.exports = LocalProvider