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