// #region Imports & Configuration import fs from 'node:fs'; import path from 'node:path'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import env from '../config/env.js'; import { getProvider } from '../db/store.js'; import { createLogger } from '../util/logger.js'; import { getMimeType } from '../tools/vfs.js'; const execFileAsync = promisify(execFile); const backupLog = createLogger('backup'); let scheduledTimer = null; let scheduledInterval = null; let isServiceRunning = false; let activeBackupPromise = null; // #endregion // #region Path Helpers // MARK: resolves standard directories for backups and source assets export function resolveBackupPaths() { const rootDir = env.backup?.dir || path.resolve(process.cwd(), 'data/backups'); const vfsDir = path.resolve(process.cwd(), 'data/vfs'); const storageDir = path.resolve(process.cwd(), 'data/storage'); const sqlitePath = env.sqlite?.filename || path.resolve(process.cwd(), 'data/dash.db'); return { rootDir, hourlyDir: path.join(rootDir, 'hourly'), dailyDir: path.join(rootDir, 'daily'), monthlyDir: path.join(rootDir, 'monthly'), tempDir: path.join(rootDir, '.tmp'), vfsDir, storageDir, sqlitePath, }; } // MARK: ensures all backup tier directories exist export function ensureBackupDirectories() { const paths = resolveBackupPaths(); for (const dir of [paths.rootDir, paths.hourlyDir, paths.dailyDir, paths.monthlyDir, paths.tempDir]) { fs.mkdirSync(dir, { recursive: true }); } return paths; } // #endregion // #region Format & Timestamp Helpers // MARK: formats current Date into zero-padded parts function formatDateTimeComponents(date = new Date()) { const pad = (val) => String(val).padStart(2, '0'); const year = date.getFullYear(); const month = pad(date.getMonth() + 1); const day = pad(date.getDate()); const hours = pad(date.getHours()); const minutes = pad(date.getMinutes()); const seconds = pad(date.getSeconds()); return { timestamp: `${year}-${month}-${day}_${hours}-${minutes}-${seconds}`, dateKey: `${year}-${month}-${day}`, monthKey: `${year}-${month}`, isoString: date.toISOString(), }; } // #endregion // #region Archiving & Database Snapshotting // MARK: archives a directory into tar (uncompressed) or tar.gz (compressed) async function archiveDirectory(sourcePath, destinationArchive, { gzip = false } = {}) { fs.mkdirSync(path.dirname(destinationArchive), { recursive: true }); if (fs.existsSync(destinationArchive)) { fs.unlinkSync(destinationArchive); } const sourceExists = fs.existsSync(sourcePath); if (!sourceExists) { // Create an empty archive if source directory does not exist const flags = gzip ? ['-czf', destinationArchive, '-T', '/dev/null'] : ['-cf', destinationArchive, '-T', '/dev/null']; await execFileAsync('tar', flags); return { exists: false, sizeBytes: fs.statSync(destinationArchive).size }; } const parentDir = path.dirname(sourcePath); const folderName = path.basename(sourcePath); const flags = gzip ? ['-czf', destinationArchive, '-C', parentDir, folderName] : ['-cf', destinationArchive, '-C', parentDir, folderName]; try { await execFileAsync('tar', flags); } catch (err) { // GNU tar returns exit code 1 if files changed while being read; treat as non-fatal if (err.code === 1 && /file changed as we read it/i.test(err.stderr || '')) { backupLog.warn(`tar warning: ${err.stderr.trim()}`); } else { throw err; } } const stats = fs.statSync(destinationArchive); return { exists: true, sizeBytes: stats.size }; } // MARK: creates an atomic SQLite database snapshot file async function snapshotSqliteDatabase(targetFile) { if (env.dbProvider === 'mssql') { const warningMessage = 'MSSQL database backup is not implemented; skipping database backup.'; console.warn(`[backup] WARNING: ${warningMessage}`); backupLog.warn(warningMessage); return { success: false, reason: 'mssql_unsupported', sizeBytes: 0 }; } fs.mkdirSync(path.dirname(targetFile), { recursive: true }); if (fs.existsSync(targetFile)) { fs.unlinkSync(targetFile); } try { const provider = await getProvider(); if (provider?.db && typeof provider.db.backup === 'function') { await provider.db.backup(targetFile); const stats = fs.statSync(targetFile); return { success: true, sizeBytes: stats.size }; } if (provider?.query) { const escapedPath = targetFile.replace(/'/g, "''"); await provider.query(`VACUUM INTO '${escapedPath}'`); const stats = fs.statSync(targetFile); return { success: true, sizeBytes: stats.size }; } } catch (err) { backupLog.warn(`Online SQLite backup method failed (${err.message}), falling back to direct copy`); } // Direct file fallback const { sqlitePath } = resolveBackupPaths(); if (fs.existsSync(sqlitePath)) { fs.copyFileSync(sqlitePath, targetFile); const stats = fs.statSync(targetFile); return { success: true, sizeBytes: stats.size }; } throw new Error(`SQLite source file not found at ${sqlitePath}`); } // #endregion // #region Promotion & Retention // MARK: links or copies all files from one backup folder into another function mirrorBackupDirectory(sourceDir, targetDir) { fs.mkdirSync(targetDir, { recursive: true }); for (const fileName of fs.readdirSync(sourceDir)) { const srcFile = path.join(sourceDir, fileName); const destFile = path.join(targetDir, fileName); if (fs.existsSync(destFile)) { fs.unlinkSync(destFile); } try { fs.linkSync(srcFile, destFile); } catch { fs.copyFileSync(srcFile, destFile); } } } // MARK: enforces retention limits for hourly (50) and daily (50) tiers export function pruneBackups() { const { hourlyDir, dailyDir } = resolveBackupPaths(); const hourlyLimit = env.backup?.hourlyRetentionCount || 50; const dailyLimit = env.backup?.dailyRetentionCount || 50; let hourlyPruned = 0; let dailyPruned = 0; // Prune hourly backups: keep last 50 if (fs.existsSync(hourlyDir)) { const entries = fs.readdirSync(hourlyDir, { withFileTypes: true }) .filter((e) => e.isDirectory() && e.name.startsWith('backup-')) .map((e) => e.name) .sort(); if (entries.length > hourlyLimit) { const toRemove = entries.slice(0, entries.length - hourlyLimit); for (const name of toRemove) { const target = path.join(hourlyDir, name); fs.rmSync(target, { recursive: true, force: true }); hourlyPruned++; } } } // Prune daily backups: keep last 50 if (fs.existsSync(dailyDir)) { const entries = fs.readdirSync(dailyDir, { withFileTypes: true }) .filter((e) => e.isDirectory() && e.name.startsWith('backup-')) .map((e) => e.name) .sort(); if (entries.length > dailyLimit) { const toRemove = entries.slice(0, entries.length - dailyLimit); for (const name of toRemove) { const target = path.join(dailyDir, name); fs.rmSync(target, { recursive: true, force: true }); dailyPruned++; } } } // Monthly backups are kept forever — no pruning is performed on monthlyDir. if (hourlyPruned > 0 || dailyPruned > 0) { backupLog.info(`Pruned backups: ${hourlyPruned} hourly, ${dailyPruned} daily.`); } return { hourlyPruned, dailyPruned }; } // #endregion // #region Core Backup Execution // MARK: executes a full backup and promotes to daily/monthly tiers as appropriate export async function createHourlyBackup() { if (activeBackupPromise) { backupLog.warn('A backup is already in progress, waiting for it to finish...'); return activeBackupPromise; } activeBackupPromise = (async () => { const start = Date.now(); const paths = ensureBackupDirectories(); const { timestamp, dateKey, monthKey, isoString } = formatDateTimeComponents(new Date()); const backupId = `backup-${timestamp}`; const stagingDir = path.join(paths.tempDir, `${backupId}-${Date.now()}`); const finalHourlyDir = path.join(paths.hourlyDir, backupId); backupLog.info(`Starting backup (${backupId})...`); fs.mkdirSync(stagingDir, { recursive: true }); try { // 1. VFS -> gzipped tar (vfs.tar.gz) const vfsResult = await archiveDirectory(paths.vfsDir, path.join(stagingDir, 'vfs.tar.gz'), { gzip: true }); // 2. Storage images -> uncompressed tar (storage.tar) const storageResult = await archiveDirectory(paths.storageDir, path.join(stagingDir, 'storage.tar'), { gzip: false }); // 3. SQLite DB -> file (dash.db) const dbFileName = env.sqlite?.filename ? path.basename(env.sqlite.filename) : 'dash.db'; const dbResult = await snapshotSqliteDatabase(path.join(stagingDir, dbFileName)); // 4. Manifest const totalSize = (vfsResult.sizeBytes || 0) + (storageResult.sizeBytes || 0) + (dbResult.sizeBytes || 0); const manifest = { id: backupId, timestamp: isoString, dateKey, monthKey, dbProvider: env.dbProvider, files: { vfs: { name: 'vfs.tar.gz', sizeBytes: vfsResult.sizeBytes, sourceExists: vfsResult.exists, }, storage: { name: 'storage.tar', sizeBytes: storageResult.sizeBytes, sourceExists: storageResult.exists, }, database: { name: dbResult.success ? dbFileName : null, sizeBytes: dbResult.sizeBytes, backedUp: dbResult.success, reason: dbResult.reason || null, }, }, totalSizeBytes: totalSize, durationMs: Date.now() - start, }; fs.writeFileSync(path.join(stagingDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8'); // 5. Atomically move staging to hourly directory if (fs.existsSync(finalHourlyDir)) { fs.rmSync(finalHourlyDir, { recursive: true, force: true }); } fs.renameSync(stagingDir, finalHourlyDir); // 6. Promote to daily backup (keep first backup of the day) const dailyBackupName = `backup-${dateKey}`; const targetDailyDir = path.join(paths.dailyDir, dailyBackupName); let promotedDaily = false; if (!fs.existsSync(targetDailyDir)) { mirrorBackupDirectory(finalHourlyDir, targetDailyDir); promotedDaily = true; backupLog.info(`Promoted backup to daily tier (${dailyBackupName})`); } // 7. Promote to monthly backup (keep first backup of the month forever) const monthlyBackupName = `backup-${monthKey}`; const targetMonthlyDir = path.join(paths.monthlyDir, monthlyBackupName); let promotedMonthly = false; if (!fs.existsSync(targetMonthlyDir)) { mirrorBackupDirectory(finalHourlyDir, targetMonthlyDir); promotedMonthly = true; backupLog.info(`Promoted backup to monthly tier (${monthlyBackupName})`); } // 8. Enforce retention limits const pruneResult = pruneBackups(); const elapsed = Date.now() - start; backupLog.info( `Backup completed successfully in ${elapsed}ms (${(totalSize / 1024 / 1024).toFixed(2)} MB total)` ); return { success: true, id: backupId, timestamp: isoString, promotedDaily, promotedMonthly, totalSizeBytes: totalSize, pruneResult, manifest, }; } catch (err) { backupLog.error(`Backup failed: ${err.message}`); if (fs.existsSync(stagingDir)) { fs.rmSync(stagingDir, { recursive: true, force: true }); } throw err; } })(); try { return await activeBackupPromise; } finally { activeBackupPromise = null; } } // #endregion // #region Backup Inspection & Status // MARK: returns list of all backups across all tiers with sizes and timestamps export function listBackups() { const paths = ensureBackupDirectories(); function readTier(dir) { if (!fs.existsSync(dir)) return []; return fs.readdirSync(dir, { withFileTypes: true }) .filter((e) => e.isDirectory() && e.name.startsWith('backup-')) .map((e) => { const folderPath = path.join(dir, e.name); const manifestPath = path.join(folderPath, 'manifest.json'); let manifest = null; if (fs.existsSync(manifestPath)) { try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch { // ignore corrupt manifest } } let totalSize = 0; for (const file of fs.readdirSync(folderPath)) { try { totalSize += fs.statSync(path.join(folderPath, file)).size; } catch { // ignore stat errors } } return { name: e.name, path: folderPath, totalSizeBytes: totalSize, timestamp: manifest?.timestamp || null, manifest, }; }) .sort((a, b) => b.name.localeCompare(a.name)); } const hourly = readTier(paths.hourlyDir); const daily = readTier(paths.dailyDir); const monthly = readTier(paths.monthlyDir); return { counts: { hourly: hourly.length, daily: daily.length, monthly: monthly.length, }, hourly, daily, monthly, }; } // MARK: returns service runtime status and config export function getBackupServiceStatus() { const paths = resolveBackupPaths(); const listing = listBackups(); return { enabled: env.backup?.enabled ?? true, isRunning: isServiceRunning, dbProvider: env.dbProvider, mssqlWarning: env.dbProvider === 'mssql', backupDir: paths.rootDir, retention: { hourlyMax: env.backup?.hourlyRetentionCount || 50, dailyMax: env.backup?.dailyRetentionCount || 50, monthly: 'unlimited (forever)', }, counts: listing.counts, latestHourly: listing.hourly[0] || null, latestDaily: listing.daily[0] || null, latestMonthly: listing.monthly[0] || null, }; } // MARK: resolves an existing backup directory across hourly, daily, or monthly tiers export function resolveBackupDir(backupId) { if (!backupId || typeof backupId !== 'string') return null; const cleanId = path.basename(backupId.trim()); if (!cleanId || cleanId !== backupId.trim() || cleanId === '.' || cleanId === '..') { return null; } const paths = resolveBackupPaths(); for (const dir of [paths.hourlyDir, paths.dailyDir, paths.monthlyDir]) { const exact = path.join(dir, cleanId); if (fs.existsSync(exact) && fs.statSync(exact).isDirectory()) { return exact; } if (!cleanId.startsWith('backup-')) { const prefixed = path.join(dir, `backup-${cleanId}`); if (fs.existsSync(prefixed) && fs.statSync(prefixed).isDirectory()) { return prefixed; } } } return null; } // MARK: lists VFS files belonging to a specific company in a backup archive export async function listBackupVfsFiles({ backupId, companyId }) { const backupDir = resolveBackupDir(backupId); if (!backupDir) { throw new Error('Backup not found'); } const vfsTarPath = path.join(backupDir, 'vfs.tar.gz'); if (!fs.existsSync(vfsTarPath)) { throw new Error('VFS archive not found in backup'); } const targetCompanyId = Number(companyId); if (!Number.isInteger(targetCompanyId) || targetCompanyId <= 0) { throw new Error('Invalid companyId'); } const prefix = `vfs/${targetCompanyId}/`; let stdout; try { const res = await execFileAsync('tar', ['-tvzf', vfsTarPath, '--full-time', '--utc', prefix]); stdout = res.stdout; } catch (err) { if (err.code === 2 && /Not found in archive/i.test(err.stderr || '')) { return { backupId: path.basename(backupDir), companyId: targetCompanyId, files: [], }; } throw err; } const files = []; for (const line of stdout.split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('d')) continue; const match = trimmed.match(/^([^\s]+)\s+([^\s]+)\s+(\d+)\s+(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\s+(.+)$/); if (!match) continue; const [, _perms, _owner, sizeStr, dateStr, timeStr, entryPath] = match; if (!entryPath.startsWith(prefix)) continue; const relName = entryPath.slice(prefix.length); if (!relName) continue; files.push({ name: relName, size: parseInt(sizeStr, 10), mime: getMimeType(relName), mtime: `${dateStr}T${timeStr}.000Z`, }); } files.sort((a, b) => a.name.localeCompare(b.name)); return { backupId: path.basename(backupDir), companyId: targetCompanyId, files, }; } // MARK: restores a single VFS file from a backup archive to the active company VFS directory export async function recoverBackupVfsFile({ backupId, companyId, filePath }) { const backupDir = resolveBackupDir(backupId); if (!backupDir) { throw new Error('Backup not found'); } const vfsTarPath = path.join(backupDir, 'vfs.tar.gz'); if (!fs.existsSync(vfsTarPath)) { throw new Error('VFS archive not found in backup'); } const targetCompanyId = Number(companyId); if (!Number.isInteger(targetCompanyId) || targetCompanyId <= 0) { throw new Error('Invalid companyId'); } if (!filePath || typeof filePath !== 'string' || !filePath.trim()) { throw new Error('File path is required'); } let cleanPath = filePath.trim().replace(/^vfs:\/+/i, '').replace(/^api\/vfs\//i, '').replace(/^vfs\//i, ''); cleanPath = cleanPath.replace(/^\/+/, ''); try { cleanPath = decodeURIComponent(cleanPath); } catch { throw new Error('Invalid file path'); } if (cleanPath.includes('\0') || cleanPath.includes('\\')) { throw new Error('Invalid file path'); } const segments = cleanPath.split('/').filter((s) => s && s !== '.'); if (segments.some((s) => s === '..')) { throw new Error('Path escapes virtual filesystem boundary'); } if (segments.length === 0) { throw new Error('File path is required'); } const normalizedRelPath = segments.join('/'); const entryPath = `vfs/${targetCompanyId}/${normalizedRelPath}`; let buffer; try { const res = await execFileAsync('tar', ['-xzf', vfsTarPath, '-O', entryPath], { encoding: 'buffer', maxBuffer: 100 * 1024 * 1024, }); buffer = res.stdout; } catch (err) { if (err.code === 2 && /Not found in archive/i.test(err.stderr || '')) { throw new Error('File not found in backup'); } throw err; } const paths = resolveBackupPaths(); const companyVfsDir = path.resolve(paths.vfsDir, String(targetCompanyId)); const targetDiskPath = path.resolve(companyVfsDir, normalizedRelPath); if (!targetDiskPath.startsWith(companyVfsDir + path.sep)) { throw new Error('Path escapes virtual filesystem boundary'); } fs.mkdirSync(path.dirname(targetDiskPath), { recursive: true }); fs.writeFileSync(targetDiskPath, buffer); backupLog.info( `Recovered VFS file "${normalizedRelPath}" for company ${targetCompanyId} from backup ${path.basename(backupDir)} (${buffer.length} bytes)` ); return { success: true, backupId: path.basename(backupDir), companyId: targetCompanyId, path: normalizedRelPath, sizeBytes: buffer.length, mime: getMimeType(normalizedRelPath), }; } // #endregion // #region Service Lifecycle // MARK: starts the periodic backup service timer and initial check export function startBackupService() { if (isServiceRunning) return; if (env.backup?.enabled === false) { backupLog.info('Backup service is disabled by configuration.'); return; } isServiceRunning = true; ensureBackupDirectories(); if (env.dbProvider === 'mssql') { const warning = 'MSSQL database backup is not implemented; skipping database backup.'; console.warn(`[backup] WARNING: ${warning}`); backupLog.warn(warning); } // Determine when the last hourly backup was taken const listing = listBackups(); const lastHourly = listing.hourly[0]; let shouldRunInitial = false; if (!lastHourly || !lastHourly.timestamp) { shouldRunInitial = true; } else { const lastTime = new Date(lastHourly.timestamp).getTime(); const diffMinutes = (Date.now() - lastTime) / (1000 * 60); if (diffMinutes >= 55) { shouldRunInitial = true; } } if (shouldRunInitial) { // Run after 5s initial warmup delay scheduledTimer = setTimeout(() => { scheduledTimer = null; createHourlyBackup().catch((err) => { backupLog.error(`Initial backup error: ${err.message}`); }); }, 5000); } // Schedule next run at the start of the next hour const now = new Date(); const nextHour = new Date(now); nextHour.setHours(nextHour.getHours() + 1, 0, 0, 0); const msUntilNextHour = Math.max(1000, nextHour.getTime() - now.getTime()); scheduledTimer = setTimeout(() => { scheduledTimer = null; createHourlyBackup().catch((err) => { backupLog.error(`Scheduled hourly backup error: ${err.message}`); }); // Set recurring 1-hour interval const intervalMs = env.backup?.intervalMs || 60 * 60 * 1000; scheduledInterval = setInterval(() => { createHourlyBackup().catch((err) => { backupLog.error(`Recurring hourly backup error: ${err.message}`); }); }, intervalMs); }, msUntilNextHour); backupLog.info(`Backup service started. Next run scheduled in ${Math.round(msUntilNextHour / 1000)}s.`); } // MARK: stops the backup service and clears all active timers export async function stopBackupService() { isServiceRunning = false; if (scheduledTimer) { clearTimeout(scheduledTimer); scheduledTimer = null; } if (scheduledInterval) { clearInterval(scheduledInterval); scheduledInterval = null; } if (activeBackupPromise) { backupLog.info('Waiting for active backup to complete...'); await activeBackupPromise; } backupLog.info('Backup service stopped.'); } // #endregion