280 lines
8.2 KiB
JavaScript
280 lines
8.2 KiB
JavaScript
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
|
|
};
|