248 lines
7.0 KiB
TypeScript
248 lines
7.0 KiB
TypeScript
import { Router, Request, Response } from "express";
|
|
import { spawn } from "child_process";
|
|
import path from "path";
|
|
import os from "os";
|
|
import fs from "fs";
|
|
import { prisma } from "@repo/db/client";
|
|
import { storage } from "../storage";
|
|
import archiver from "archiver";
|
|
|
|
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;
|
|
if (!userId) {
|
|
return res.status(401).json({ error: "Unauthorized" });
|
|
}
|
|
|
|
// 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
|
|
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",
|
|
process.env.DB_HOST || "localhost",
|
|
"-U",
|
|
process.env.DB_USER || "postgres",
|
|
process.env.DB_NAME || "dental_db",
|
|
"-f",
|
|
tmpDir, // write parallely
|
|
],
|
|
{
|
|
env: {
|
|
...process.env,
|
|
PGPASSWORD: process.env.DB_PASSWORD,
|
|
},
|
|
}
|
|
);
|
|
|
|
let pgStderr = "";
|
|
pgDump.stderr.on("data", (chunk) => {
|
|
pgStderr += chunk.toString();
|
|
});
|
|
|
|
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 });
|
|
} else {
|
|
res.destroy(err);
|
|
}
|
|
});
|
|
|
|
pgDump.on("close", async (code) => {
|
|
if (code !== 0) {
|
|
safeRmDir(tmpDir);
|
|
console.error("pg_dump failed:", pgStderr || `exit ${code}`);
|
|
if (!res.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);
|
|
}
|
|
});
|
|
} 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) });
|
|
} else {
|
|
res.destroy(err);
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Get database status (connected, size, records count)
|
|
*/
|
|
router.get("/status", async (req: Request, res: Response): Promise<any> => {
|
|
try {
|
|
const userId = req.user?.id;
|
|
if (!userId) {
|
|
return res.status(401).json({ error: "Unauthorized" });
|
|
}
|
|
|
|
const size = await prisma.$queryRawUnsafe<{ size: string }[]>(
|
|
"SELECT pg_size_pretty(pg_database_size(current_database())) as size"
|
|
);
|
|
|
|
const patientsCount = await storage.getTotalPatientCount();
|
|
const lastBackup = await storage.getLastBackup(userId);
|
|
|
|
res.json({
|
|
connected: true,
|
|
size: size[0]?.size,
|
|
patients: patientsCount,
|
|
lastBackup: lastBackup?.createdAt ?? null,
|
|
});
|
|
} catch (err) {
|
|
console.error("Status error:", err);
|
|
res.status(500).json({
|
|
connected: false,
|
|
error: "Could not fetch database status",
|
|
});
|
|
}
|
|
});
|
|
|
|
export default router;
|