feat: change backup format to plain SQL, admin-only cron, backup now button, import restore UI

This commit is contained in:
ff
2026-04-11 23:37:43 -04:00
parent 4025ca45e0
commit 66a3d271f1
6 changed files with 329 additions and 269 deletions

View File

@@ -3,30 +3,22 @@ import { spawn } from "child_process";
import path from "path";
import os from "os";
import fs from "fs";
import multer from "multer";
import { prisma } from "@repo/db/client";
import { storage } from "../storage";
import archiver from "archiver";
import { backupDatabaseToPath } from "../services/databaseBackupService";
const restoreUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 500 * 1024 * 1024 }, // 500 MB
fileFilter: (_req, file, cb) => {
if (file.originalname.toLowerCase().endsWith(".sql")) cb(null, true);
else cb(new Error("Only .sql files are allowed"));
},
});
const router = Router();
/**
* Create a database backup
*
* - Uses pg_dump in directory format for parallel dump to a tmp dir
* - Uses 'archiver' to create zip or gzipped tar stream directly to response
* - Supports explicit override via BACKUP_ARCHIVE_FORMAT env var ('zip' or 'tar')
* - Ensures cleanup of tmp dir on success/error/client disconnect
*/
// helper to remove directory (sync to keep code straightforward)
function safeRmDir(dir: string) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch (e) {
/* ignore */
}
}
router.post("/backup", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
@@ -34,32 +26,12 @@ router.post("/backup", async (req: Request, res: Response): Promise<any> => {
return res.status(401).json({ error: "Unauthorized" });
}
const destination = await storage.getActiveBackupDestination(userId);
const filename = `dental_backup_${Date.now()}.sql`;
// create a unique tmp directory for directory-format dump
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dental_backup_")); // MUST
// Decide archive format
// BACKUP_ARCHIVE_FORMAT can be 'zip' or 'tar' (case-insensitive)
const forced = (process.env.BACKUP_ARCHIVE_FORMAT || "").toLowerCase();
const useZip =
forced === "zip"
? true
: forced === "tar"
? false
: process.platform === "win32";
const filename = useZip
? `dental_backup_${Date.now()}.zip`
: `dental_backup_${Date.now()}.tar.gz`;
// Spawn pg_dump
// Spawn pg_dump in plain SQL format, streaming stdout directly to response
const pgDump = spawn(
"pg_dump",
[
"-Fd", // DIRECTORY format (required for parallel dump)
"-j",
"4", // number of parallel jobs — MUST be >0 for parallelism
"--no-acl",
"--no-owner",
"-h",
@@ -67,8 +39,6 @@ router.post("/backup", async (req: Request, res: Response): Promise<any> => {
"-U",
process.env.DB_USER || "postgres",
process.env.DB_NAME || "dental_db",
"-f",
tmpDir, // write parallely
],
{
env: {
@@ -84,127 +54,51 @@ router.post("/backup", async (req: Request, res: Response): Promise<any> => {
});
pgDump.on("error", (err) => {
safeRmDir(tmpDir);
console.error("Failed to start pg_dump:", err);
// If headers haven't been sent, respond; otherwise just end socket
if (!res.headersSent) {
return res
.status(500)
.json({ error: "Failed to run pg_dump", details: err.message });
res.status(500).json({ error: "Failed to run pg_dump", details: err.message });
} else {
res.destroy(err);
}
});
// Buffer first chunk to detect early failure before sending headers
let headersSent = false;
pgDump.stdout.once("data", (firstChunk) => {
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
res.setHeader("Content-Type", "application/sql");
headersSent = true;
res.write(firstChunk);
pgDump.stdout.pipe(res);
});
pgDump.on("close", async (code) => {
if (code !== 0) {
safeRmDir(tmpDir);
console.error("pg_dump failed:", pgStderr || `exit ${code}`);
if (!res.headersSent) {
if (!headersSent) {
return res.status(500).json({
error: "Backup failed",
details: pgStderr || `pg_dump exited with ${code}`,
});
} else {
// headers already sent — destroy response
res.destroy(new Error("pg_dump failed"));
return;
}
}
// pg_dump succeeded — stream archive directly to response using archiver
// Set headers before piping
res.setHeader(
"Content-Disposition",
`attachment; filename="${filename}"`
);
res.setHeader(
"Content-Type",
useZip ? "application/zip" : "application/gzip"
);
const archive = archiver(
useZip ? "zip" : "tar",
useZip ? {} : { gzip: true, gzipOptions: { level: 6 } }
);
let archErr: string | null = null;
archive.on("error", (err) => {
archErr = err.message;
console.error("Archiver error:", err);
// attempt to respond with error if possible
try {
if (!res.headersSent) {
res.status(500).json({
error: "Failed to create archive",
details: err.message,
});
} else {
// if streaming already started, destroy the connection
res.destroy(err);
}
} catch (e) {
// swallow
} finally {
safeRmDir(tmpDir);
}
});
// If client disconnects while streaming
res.once("close", () => {
// destroy archiver (stop processing) and cleanup tmpDir
try {
archive.destroy();
} catch (e) {}
safeRmDir(tmpDir);
});
// When streaming finishes successfully
res.once("finish", async () => {
// cleanup the tmp dir used by pg_dump
safeRmDir(tmpDir);
// update metadata (try/catch so it won't break response flow)
try {
await storage.createBackup(userId);
await storage.deleteNotificationsByType(userId, "BACKUP");
} catch (err) {
console.error("Backup saved but metadata update failed:", err);
}
});
// Pipe archive into response
archive.pipe(res);
// Add the dumped directory contents to the archive root
// `directory(source, dest)` where dest is false/'' to place contents at archive root
archive.directory(tmpDir + path.sep, false);
// finalize archive (this starts streaming)
try {
await archive.finalize();
} catch (err: any) {
console.error("Failed to finalize archive:", err);
// if headers not sent, send 500; otherwise destroy
try {
if (!res.headersSent) {
res.status(500).json({
error: "Failed to finalize archive",
details: String(err),
});
} else {
res.destroy(err);
}
} catch (e) {}
safeRmDir(tmpDir);
await storage.createBackup(userId);
await storage.deleteNotificationsByType(userId, "BACKUP");
} catch (err) {
console.error("Backup saved but metadata update failed:", err);
}
if (!res.writableEnded) res.end();
});
} catch (err: any) {
console.error("Unexpected error in /backup:", err);
if (!res.headersSent) {
return res
.status(500)
.json({ message: "Internal server error", details: String(err) });
res.status(500).json({ message: "Internal server error", details: String(err) });
} else {
res.destroy(err);
}
@@ -418,7 +312,7 @@ router.post("/backup-path", async (req, res) => {
});
}
const filename = `dental_backup_${Date.now()}.zip`;
const filename = `dental_backup_${Date.now()}.sql`;
try {
await backupDatabaseToPath({
@@ -439,4 +333,43 @@ router.post("/backup-path", async (req, res) => {
}
});
router.post("/restore", restoreUpload.single("file"), async (req: Request, res: Response): Promise<any> => {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ error: "Unauthorized" });
if (!req.file) return res.status(400).json({ error: "No file provided" });
const psql = spawn(
"psql",
[
"-h", process.env.DB_HOST || "localhost",
"-U", process.env.DB_USER || "postgres",
process.env.DB_NAME || "dental_db",
],
{
env: { ...process.env, PGPASSWORD: process.env.DB_PASSWORD },
}
);
let stderr = "";
psql.stderr.on("data", (d) => (stderr += d.toString()));
psql.on("error", (err) => {
console.error("Failed to start psql:", err);
if (!res.headersSent)
res.status(500).json({ error: "Failed to run psql", details: err.message });
});
psql.on("close", (code) => {
if (code !== 0) {
console.error("psql restore failed:", stderr);
return res.status(500).json({ error: "Restore failed", details: stderr });
}
res.json({ success: true });
});
psql.stdin.write(req.file.buffer);
psql.stdin.end();
});
export default router;