Files
mek-tech-consulting/backup.js
T

280 lines
8.2 KiB
JavaScript
Raw Normal View History

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
};