import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { promises as fs } from "node:fs"; import { join } from "node:path"; import { createHash } from "node:crypto"; import { sql } from "@/db"; import { STORAGE_DIR } from "@/lib/env"; const run = promisify(execFile); /** Nightly backup: pg_dump (custom format) + tar of the manuscript store, encrypted with AES-256 (openssl, pbkdf2), 14-day retention, optional rsync to a second node. */ export async function runBackup() { const dir = process.env.BACKUP_DIR ?? join(process.env.HOME ?? "/tmp", "backups", "wp-spboucher-ai"); await fs.mkdir(dir, { recursive: true, mode: 0o700 }); const stamp = new Date().toISOString().replace(/[-:]/g, "").slice(0, 13); const pgBin = process.env.PG_BIN ?? "/opt/homebrew/opt/postgresql@17/bin"; const adminUrl = process.env.DATABASE_URL_ADMIN ?? process.env.DATABASE_URL!; const pass = process.env.BACKUP_PASSPHRASE; if (!pass) throw new Error("BACKUP_PASSPHRASE missing"); const log: string[] = []; let ok = true; let remoteOk: boolean | null = null; const dump = join(dir, `wp-${stamp}.dump`); const tar = join(dir, `wp-files-${stamp}.tar`); try { await run(join(pgBin, "pg_dump"), ["-Fc", "-f", dump, adminUrl], { maxBuffer: 1 << 24 }); log.push("pg_dump ok"); await run("tar", ["-cf", tar, "-C", STORAGE_DIR, "files", "previews"], { maxBuffer: 1 << 24 }); log.push("tar ok"); for (const f of [dump, tar]) { await run("openssl", ["enc", "-aes-256-cbc", "-pbkdf2", "-salt", "-in", f, "-out", f + ".enc", "-pass", "env:WP_BK"], { env: { ...process.env, WP_BK: pass } }); await fs.unlink(f); } log.push("encrypted"); } catch (e) { ok = false; log.push("ERROR " + (e as Error).message.slice(0, 500)); } const outs = [dump + ".enc", tar + ".enc"]; let bytes = 0; const sums: string[] = []; for (const f of outs) { try { const b = await fs.readFile(f); bytes += b.length; sums.push(createHash("sha256").update(b).digest("hex")); } catch { /* missing on failure */ } } // Retention: 14 days. for (const n of await fs.readdir(/*turbopackIgnore: true*/ dir)) { const p = join(dir, n); const st = await fs.stat(p); if (st.isFile() && Date.now() - st.mtimeMs > 14 * 864e5) await fs.unlink(p); } if (ok && process.env.BACKUP_REMOTE) { try { await run("rsync", ["-az", "--delete", "-e", "ssh -o BatchMode=yes -o ConnectTimeout=15", dir + "/", process.env.BACKUP_REMOTE + "/"], { maxBuffer: 1 << 22 }); remoteOk = true; log.push("rsync ok → " + process.env.BACKUP_REMOTE); } catch (e) { remoteOk = false; log.push("rsync failed: " + (e as Error).message.slice(0, 300)); } } await sql`INSERT INTO backups (kind, path, bytes, sha256, ok, log, remote_ok) VALUES ('nightly', ${outs.join(";")}, ${bytes}, ${sums.join(";")}, ${ok}, ${log.join("\n")}, ${remoteOk})`; await sql`DELETE FROM backups WHERE created_at < now() - interval '120 days'`; if (!ok) throw new Error(log.join(" | ")); return { ok, bytes, files: outs, remoteOk }; }