spb/wp-spboucher-ai
Public
1import { execFile } from "node:child_process";2import { promisify } from "node:util";3import { promises as fs } from "node:fs";4import { join } from "node:path";5import { createHash } from "node:crypto";6import { sql } from "@/db";7import { STORAGE_DIR } from "@/lib/env";8const run = promisify(execFile);9/** 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. */10export async function runBackup() {11 const dir = process.env.BACKUP_DIR ?? join(process.env.HOME ?? "/tmp", "backups", "wp-spboucher-ai");12 await fs.mkdir(dir, { recursive: true, mode: 0o700 });13 const stamp = new Date().toISOString().replace(/[-:]/g, "").slice(0, 13);14 const pgBin = process.env.PG_BIN ?? "/opt/homebrew/opt/postgresql@17/bin";15 const adminUrl = process.env.DATABASE_URL_ADMIN ?? process.env.DATABASE_URL!;16 const pass = process.env.BACKUP_PASSPHRASE; if (!pass) throw new Error("BACKUP_PASSPHRASE missing");17 const log: string[] = []; let ok = true; let remoteOk: boolean | null = null;18 const dump = join(dir, `wp-${stamp}.dump`); const tar = join(dir, `wp-files-${stamp}.tar`);19 try {20 await run(join(pgBin, "pg_dump"), ["-Fc", "-f", dump, adminUrl], { maxBuffer: 1 << 24 }); log.push("pg_dump ok");21 await run("tar", ["-cf", tar, "-C", STORAGE_DIR, "files", "previews"], { maxBuffer: 1 << 24 }); log.push("tar ok");22 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); }23 log.push("encrypted");24 } catch (e) { ok = false; log.push("ERROR " + (e as Error).message.slice(0, 500)); }25 const outs = [dump + ".enc", tar + ".enc"]; let bytes = 0; const sums: string[] = [];26 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 */ } }27 // Retention: 14 days.28 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); }29 if (ok && process.env.BACKUP_REMOTE) {30 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); }31 catch (e) { remoteOk = false; log.push("rsync failed: " + (e as Error).message.slice(0, 300)); }32 }33 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})`;34 await sql`DELETE FROM backups WHERE created_at < now() - interval '120 days'`;35 if (!ok) throw new Error(log.join(" | "));36 return { ok, bytes, files: outs, remoteOk };37}38