const fs = require('fs'); const path = require('path'); const os = require('os'); const { execSync } = require('child_process'); const { getDb } = require('../db'); const APP_VERSION = '2.0.0'; const MAX_STORED_BACKUPS = 20; function getPaths() { const root = path.join(__dirname, '..'); const dataDir = process.env.DB_PATH ? path.dirname(path.resolve(process.env.DB_PATH)) : path.join(root, 'data'); return { root, dataDir, dbPath: process.env.DB_PATH || path.join(dataDir, 'consulting.db'), sessionsPath: path.join(dataDir, 'sessions.db'), uploadsDir: path.join(dataDir, 'uploads'), backupsDir: path.join(dataDir, 'backups') }; } function ensureBackupsDir() { const { backupsDir } = getPaths(); if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true }); return backupsDir; } function checkpointDatabases() { try { const db = getDb(); db.pragma('wal_checkpoint(TRUNCATE)'); } catch (e) { /* ignore */ } const { sessionsPath } = getPaths(); if (fs.existsSync(sessionsPath)) { try { const Database = require('better-sqlite3'); const sdb = new Database(sessionsPath); sdb.pragma('wal_checkpoint(TRUNCATE)'); sdb.close(); } catch (e) { /* ignore */ } } } function collectStats() { const db = getDb(); const tables = ['clients', 'engagements', 'racks', 'rack_devices', 'data_diagrams', 'projects', 'invoices', 'assessments', 'users']; const counts = {}; for (const t of tables) { try { counts[t] = db.prepare(`SELECT COUNT(*) as c FROM ${t}`).get().c; } catch (e) { counts[t] = 0; } } const { dbPath, sessionsPath, uploadsDir, dataDir } = getPaths(); let uploadsBytes = 0; let uploadFiles = 0; if (fs.existsSync(uploadsDir)) { const walk = (dir) => { for (const name of fs.readdirSync(dir)) { const p = path.join(dir, name); const st = fs.statSync(p); if (st.isDirectory()) walk(p); else { uploadsBytes += st.size; uploadFiles++; } } }; walk(uploadsDir); } return { counts, dbSizeBytes: fs.existsSync(dbPath) ? fs.statSync(dbPath).size : 0, sessionsSizeBytes: fs.existsSync(sessionsPath) ? fs.statSync(sessionsPath).size : 0, uploadsBytes, uploadFiles, dataDir }; } function formatBytes(n) { if (n < 1024) return n + ' B'; if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB'; return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB'; } function timestampLabel() { const d = new Date(); const pad = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; } function safeBackupName(name) { return /^mek-tech-backup-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6}\.tar\.gz$/.test(name); } function pruneOldBackups() { const list = listBackups(); if (list.length <= MAX_STORED_BACKUPS) return; const toRemove = list.slice(MAX_STORED_BACKUPS); for (const b of toRemove) { try { fs.unlinkSync(b.path); } catch (e) { /* ignore */ } } } function createBackup() { ensureBackupsDir(); checkpointDatabases(); const paths = getPaths(); const stats = collectStats(); const label = timestampLabel(); const bundleName = `mek-tech-backup-${label}`; const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mek-backup-')); const bundleDir = path.join(tmpDir, bundleName); try { fs.mkdirSync(bundleDir, { recursive: true }); const manifest = { app: 'mek-tech-consulting-framework', version: APP_VERSION, created_at: new Date().toISOString(), stats: stats.counts, includes: [] }; if (fs.existsSync(paths.dbPath)) { fs.copyFileSync(paths.dbPath, path.join(bundleDir, 'consulting.db')); manifest.includes.push('consulting.db'); } if (fs.existsSync(paths.sessionsPath)) { fs.copyFileSync(paths.sessionsPath, path.join(bundleDir, 'sessions.db')); manifest.includes.push('sessions.db'); } if (fs.existsSync(paths.uploadsDir)) { copyDirSync(paths.uploadsDir, path.join(bundleDir, 'uploads')); manifest.includes.push('uploads/'); } fs.writeFileSync(path.join(bundleDir, 'manifest.json'), JSON.stringify(manifest, null, 2)); const archiveName = `${bundleName}.tar.gz`; const archivePath = path.join(paths.backupsDir, archiveName); execSync(`tar -czf "${archivePath}" -C "${tmpDir}" "${bundleName}"`, { stdio: 'pipe' }); const size = fs.statSync(archivePath).size; pruneOldBackups(); return { filename: archiveName, path: archivePath, size, sizeLabel: formatBytes(size), created_at: manifest.created_at, stats: stats.counts }; } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } } function copyDirSync(src, dest) { fs.mkdirSync(dest, { recursive: true }); for (const name of fs.readdirSync(src)) { const s = path.join(src, name); const d = path.join(dest, name); if (fs.statSync(s).isDirectory()) copyDirSync(s, d); else fs.copyFileSync(s, d); } } function listBackups() { ensureBackupsDir(); const { backupsDir } = getPaths(); if (!fs.existsSync(backupsDir)) return []; return fs.readdirSync(backupsDir) .filter(f => f.endsWith('.tar.gz') && safeBackupName(f)) .map(f => { const p = path.join(backupsDir, f); const st = fs.statSync(p); return { filename: f, path: p, size: st.size, sizeLabel: formatBytes(st.size), created_at: st.mtime.toISOString() }; }) .sort((a, b) => b.created_at.localeCompare(a.created_at)); } function getBackupPath(filename) { if (!safeBackupName(filename)) return null; const p = path.join(getPaths().backupsDir, filename); return fs.existsSync(p) ? p : null; } function deleteBackup(filename) { const p = getBackupPath(filename); if (!p) return false; fs.unlinkSync(p); return true; } function validateArchive(archivePath) { if (!fs.existsSync(archivePath)) throw new Error('Backupbestand niet gevonden'); const listing = execSync(`tar -tzf "${archivePath}"`, { encoding: 'utf8' }); if (!listing.includes('manifest.json')) throw new Error('Ongeldig backup: manifest.json ontbreekt'); if (!listing.includes('consulting.db')) throw new Error('Ongeldig backup: consulting.db ontbreekt'); return listing; } function restoreBackup(archivePath) { validateArchive(archivePath); const auto = createBackup(); const paths = getPaths(); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mek-restore-')); try { execSync(`tar -xzf "${archivePath}" -C "${tmpDir}"`, { stdio: 'pipe' }); const entries = fs.readdirSync(tmpDir); const bundleDir = entries.length === 1 ? path.join(tmpDir, entries[0]) : tmpDir; const manifestPath = path.join(bundleDir, 'manifest.json'); if (!fs.existsSync(manifestPath)) throw new Error('manifest.json niet gevonden in backup'); checkpointDatabases(); const dbDest = paths.dbPath; const dbSrc = path.join(bundleDir, 'consulting.db'); if (fs.existsSync(dbSrc)) { for (const ext of ['-wal', '-shm']) { const sidecar = dbDest + ext; if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar); } fs.copyFileSync(dbSrc, dbDest); } const sessSrc = path.join(bundleDir, 'sessions.db'); if (fs.existsSync(sessSrc)) { for (const ext of ['-wal', '-shm']) { const sidecar = paths.sessionsPath + ext; if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar); } fs.copyFileSync(sessSrc, paths.sessionsPath); } const uploadsSrc = path.join(bundleDir, 'uploads'); if (fs.existsSync(uploadsSrc)) { if (fs.existsSync(paths.uploadsDir)) fs.rmSync(paths.uploadsDir, { recursive: true, force: true }); copyDirSync(uploadsSrc, paths.uploadsDir); } return { success: true, preRestoreBackup: auto.filename, restored_at: new Date().toISOString() }; } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } } module.exports = { getPaths, collectStats, formatBytes, createBackup, listBackups, getBackupPath, deleteBackup, restoreBackup, safeBackupName, APP_VERSION };