const { spawn } = require('child_process'); const { pipeline } = require('stream/promises'); const zlib = require('zlib'); const fs = require('fs'); const path = require('path'); const { getSettingSync } = require('./settingsCache'); const BACKUP_DIR = path.join(__dirname, '..', '..', 'backups'); // Local-disk-only by design (no offsite/cloud upload) — matches the exact filename shape // this module generates, and is reused to validate download requests against path traversal. const BACKUP_FILENAME_RE = /^backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.sql\.gz$/; function ensureBackupDir() { if (!fs.existsSync(BACKUP_DIR)) fs.mkdirSync(BACKUP_DIR, { recursive: true }); } function backupFilename(date = new Date()) { return `backup-${date.toISOString().replace(/[:.]/g, '-')}.sql.gz`; } /** * Run `pg_dump` against DATABASE_URL, gzip its output, and write it to backend/backups/. * Requires the `pg_dump` binary to be installed on this host (Postgres itself is hosted * separately) — a deploy-environment prerequisite, not something this code can satisfy. * * @returns {Promise<{ filename: string, path: string }>} */ async function runBackup() { ensureBackupDir(); const databaseUrl = process.env.DATABASE_URL; if (!databaseUrl) throw new Error('DATABASE_URL is not configured'); const filename = backupFilename(); const filePath = path.join(BACKUP_DIR, filename); const pgDump = spawn('pg_dump', [databaseUrl, '--no-owner', '--no-privileges'], { stdio: ['ignore', 'pipe', 'pipe'] }); let stderr = ''; pgDump.stderr.on('data', (d) => { stderr += d.toString(); }); const exitPromise = new Promise((resolve, reject) => { pgDump.on('error', (err) => reject(new Error(`Failed to start pg_dump: ${err.message}. Is it installed on this host?`))); pgDump.on('close', (code) => { if (code === 0) resolve(); else reject(new Error(`pg_dump exited with code ${code}: ${stderr.slice(0, 500)}`)); }); }); const pipelinePromise = pipeline(pgDump.stdout, zlib.createGzip(), fs.createWriteStream(filePath)); try { // Both must succeed: the process exiting cleanly, and the gzip write finishing — // a mid-dump failure must not leave a truncated file looking like a real backup. await Promise.all([exitPromise, pipelinePromise]); } catch (err) { await fs.promises.unlink(filePath).catch(() => {}); throw err; } const retainCount = parseInt(getSettingSync('backup_retain_count', '14'), 10) || 14; await rotateBackups(retainCount); return { filename, path: filePath }; } /** @returns {Promise>} newest first */ async function listBackups() { ensureBackupDir(); const files = await fs.promises.readdir(BACKUP_DIR); const backups = await Promise.all( files.filter((f) => BACKUP_FILENAME_RE.test(f)).map(async (f) => { const stat = await fs.promises.stat(path.join(BACKUP_DIR, f)); return { filename: f, size: stat.size, createdAt: stat.mtime }; }) ); return backups.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); } /** Deletes backups beyond the retention count, oldest first. */ async function rotateBackups(retainCount) { const backups = await listBackups(); const toDelete = backups.slice(retainCount); await Promise.all(toDelete.map((b) => fs.promises.unlink(path.join(BACKUP_DIR, b.filename)).catch(() => {}))); return { deleted: toDelete.length }; } module.exports = { runBackup, listBackups, rotateBackups, BACKUP_DIR, BACKUP_FILENAME_RE };