Update from Git Manager GUI
This commit is contained in:
183
src/backup/BackupManager.js
Normal file
183
src/backup/BackupManager.js
Normal file
@@ -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<Array>}
|
||||
*/
|
||||
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
|
||||
59
src/backup/BackupProvider.js
Normal file
59
src/backup/BackupProvider.js
Normal file
@@ -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>} 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<Buffer>}
|
||||
*/
|
||||
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
|
||||
83
src/backup/LocalProvider.js
Normal file
83
src/backup/LocalProvider.js
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user