import { Router, Request, Response } from "express"; 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 { backupDatabaseToPath } from "../services/databaseBackupService"; import archiver from "archiver"; import { getOrCreateApiKey, regenerateApiKey, readSyncConfig, writeSyncConfig, } from "../services/networkSyncConfigService"; import { runNetworkSync, runNetworkFilesSync } from "../services/networkSyncService"; const UPLOADS_DIR = path.join(process.cwd(), "uploads"); const MIGRATIONS_DIR = path.resolve(__dirname, "../../../../packages/db/prisma/migrations"); // Applies migration SQL files that are missing from the database. // Reads each folder in the migrations directory in sorted order, checks // whether the migration is already recorded as successfully applied in // _prisma_migrations, and runs the SQL if not. Safe to call after any // restore because it uses IF NOT EXISTS semantics or checks first. async function applyMissingMigrations() { let folders: string[]; try { folders = fs.readdirSync(MIGRATIONS_DIR) .filter((name) => fs.statSync(path.join(MIGRATIONS_DIR, name)).isDirectory()) .sort(); } catch { console.warn("Could not read migrations directory, skipping post-restore migration."); return; } // Fetch the set of successfully-applied migration names from the DB. let applied: Set; try { const rows = await prisma.$queryRaw<{ migration_name: string }[]>` SELECT migration_name FROM "_prisma_migrations" WHERE finished_at IS NOT NULL `; applied = new Set(rows.map((r) => r.migration_name)); } catch { // _prisma_migrations may not exist in very old backups; proceed anyway. applied = new Set(); } for (const folder of folders) { if (applied.has(folder)) continue; const sqlFile = path.join(MIGRATIONS_DIR, folder, "migration.sql"); if (!fs.existsSync(sqlFile)) continue; const sql = fs.readFileSync(sqlFile, "utf8"); try { await prisma.$executeRawUnsafe(sql); console.log(`Applied migration: ${folder}`); } catch (err: any) { // Log but continue — some statements may already exist (e.g. after a // partial restore) and that is acceptable. console.warn(`Migration ${folder} had errors (may already be applied):`, err.message); } } } const restoreUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 500 * 1024 * 1024 }, // 500 MB fileFilter: (_req, file, cb) => { const name = file.originalname.toLowerCase(); if (name.endsWith(".sql") || name.endsWith(".zip")) cb(null, true); else cb(new Error("Only .sql or .zip files are allowed")); }, }); const router = Router(); router.post("/backup", async (req: Request, res: Response): Promise => { try { const userId = req.user?.id; if (!userId) { return res.status(401).json({ error: "Unauthorized" }); } const filename = `dental_backup_${Date.now()}.sql`; // Spawn pg_dump in plain SQL format, streaming stdout directly to response const pgDump = spawn( "pg_dump", [ "--no-acl", "--no-owner", "-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 pgStderr = ""; pgDump.stderr.on("data", (chunk) => { pgStderr += chunk.toString(); }); pgDump.on("error", (err) => { console.error("Failed to start pg_dump:", err); if (!res.headersSent) { 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) { console.error("pg_dump failed:", pgStderr || `exit ${code}`); if (!headersSent) { return res.status(500).json({ error: "Backup failed", details: pgStderr || `pg_dump exited with ${code}`, }); } else { res.destroy(new Error("pg_dump failed")); return; } } try { 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) { 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 => { try { const userId = req.user?.id; if (!userId) { return res.status(401).json({ error: "Unauthorized" }); } const size = await prisma.$queryRaw<{ 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", }); } }); // ============================== // Backup Destination CRUD // ============================== // CREATE / UPDATE destination router.post("/destination", async (req, res) => { const userId = req.user?.id; const { path: destinationPath } = req.body; if (!userId) return res.status(401).json({ error: "Unauthorized" }); if (!destinationPath) return res.status(400).json({ error: "Path is required" }); // validate path exists if (!fs.existsSync(destinationPath)) { return res.status(400).json({ error: "Backup path does not exist or drive not connected", }); } try { const destination = await storage.createBackupDestination( userId, destinationPath ); res.json(destination); } catch (err) { console.error(err); res.status(500).json({ error: "Failed to save backup destination" }); } }); // GET all destinations router.get("/destination", async (req, res) => { const userId = req.user?.id; if (!userId) return res.status(401).json({ error: "Unauthorized" }); const destinations = await storage.getAllBackupDestination(userId); res.json(destinations); }); // UPDATE destination router.put("/destination/:id", async (req, res) => { const userId = req.user?.id; const id = Number(req.params.id); const { path: destinationPath } = req.body; if (!userId) return res.status(401).json({ error: "Unauthorized" }); if (!destinationPath) return res.status(400).json({ error: "Path is required" }); if (!fs.existsSync(destinationPath)) { return res.status(400).json({ error: "Path does not exist" }); } const updated = await storage.updateBackupDestination( id, userId, destinationPath ); res.json(updated); }); // DELETE destination router.delete("/destination/:id", async (req, res) => { const userId = req.user?.id; const id = Number(req.params.id); if (!userId) return res.status(401).json({ error: "Unauthorized" }); await storage.deleteBackupDestination(id, userId); res.json({ success: true }); }); // GET directory listing for folder browser router.get("/browse", async (req, res) => { const userId = req.user?.id; if (!userId) return res.status(401).json({ error: "Unauthorized" }); const requestedPath = (req.query.path as string) || "/"; // Resolve and sanitize — must be absolute const resolved = path.resolve(requestedPath); try { const entries = fs.readdirSync(resolved, { withFileTypes: true }); const dirs = entries .filter((e) => e.isDirectory()) .map((e) => ({ name: e.name, path: path.join(resolved, e.name), })) .sort((a, b) => a.name.localeCompare(b.name)); const parent = resolved !== "/" ? path.dirname(resolved) : null; res.json({ current: resolved, parent, dirs }); } catch (err: any) { res.status(400).json({ error: err.message || "Cannot read directory" }); } }); // GET usb backup setting router.get("/usb-backup-setting", async (req, res) => { const userId = req.user?.id; if (!userId) return res.status(401).json({ error: "Unauthorized" }); const user = await storage.getUser(userId); if (!user) return res.status(404).json({ error: "User not found" }); res.json({ usbBackupEnabled: user.usbBackupEnabled }); }); // PUT usb backup setting router.put("/usb-backup-setting", async (req, res) => { const userId = req.user?.id; if (!userId) return res.status(401).json({ error: "Unauthorized" }); const { usbBackupEnabled } = req.body; if (typeof usbBackupEnabled !== "boolean") { return res.status(400).json({ error: "usbBackupEnabled must be a boolean" }); } const updated = await storage.updateUser(userId, { usbBackupEnabled }); if (!updated) return res.status(404).json({ error: "User not found" }); res.json({ usbBackupEnabled: updated.usbBackupEnabled }); }); // GET auto backup setting router.get("/auto-backup-setting", async (req, res) => { const userId = req.user?.id; if (!userId) return res.status(401).json({ error: "Unauthorized" }); const user = await storage.getUser(userId); if (!user) return res.status(404).json({ error: "User not found" }); res.json({ autoBackupEnabled: user.autoBackupEnabled }); }); // PUT auto backup setting router.put("/auto-backup-setting", async (req, res) => { const userId = req.user?.id; if (!userId) return res.status(401).json({ error: "Unauthorized" }); const { autoBackupEnabled } = req.body; if (typeof autoBackupEnabled !== "boolean") { return res.status(400).json({ error: "autoBackupEnabled must be a boolean" }); } const updated = await storage.updateUser(userId, { autoBackupEnabled }); if (!updated) return res.status(404).json({ error: "User not found" }); res.json({ autoBackupEnabled: updated.autoBackupEnabled }); }); router.post("/backup-path", async (req, res) => { const userId = req.user?.id; if (!userId) return res.status(401).json({ error: "Unauthorized" }); const destination = await storage.getActiveBackupDestination(userId); if (!destination) { return res.status(400).json({ error: "No backup destination configured", }); } if (!fs.existsSync(destination.path)) { return res.status(400).json({ error: "Backup destination not found. External drive may be disconnected.", }); } const filename = `dental_backup_${Date.now()}.zip`; try { await backupDatabaseToPath({ destinationPath: destination.path, filename, }); await storage.createBackup(userId); await storage.deleteNotificationsByType(userId, "BACKUP"); res.json({ success: true, filename }); } catch (err: any) { console.error(err); res.status(500).json({ error: "Backup to destination failed", details: err.message, }); } }); router.post("/restore", restoreUpload.single("file"), async (req: Request, res: Response): Promise => { 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 isZip = req.file.originalname.toLowerCase().endsWith(".zip"); // For zip files, write to a temp file so unzip can read it let tmpZipPath: string | null = null; if (isZip) { tmpZipPath = path.join(os.tmpdir(), `restore_${Date.now()}.zip`); fs.writeFileSync(tmpZipPath, req.file.buffer); } // Drop and recreate the public schema so existing tables don't block the restore. try { await prisma.$executeRawUnsafe(`DROP SCHEMA public CASCADE`); await prisma.$executeRawUnsafe(`CREATE SCHEMA public`); } catch (err: any) { if (tmpZipPath) try { fs.unlinkSync(tmpZipPath); } catch {} console.error("Failed to reset schema before restore:", err); return res.status(500).json({ error: "Failed to reset database schema", details: err.message }); } 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) => { if (tmpZipPath) try { fs.unlinkSync(tmpZipPath); } catch {} 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", async (code) => { if (tmpZipPath) try { fs.unlinkSync(tmpZipPath); } catch {} if (code !== 0) { console.error("psql restore failed:", stderr); return res.status(500).json({ error: "Restore failed", details: stderr }); } // Reconnect Prisma after schema was replaced try { await prisma.$disconnect(); await prisma.$connect(); } catch (_) {} // Apply any migrations the backup may be missing. We run each migration // SQL file directly instead of using `prisma migrate deploy` because the // restored _prisma_migrations table may contain orphaned entries (migration // names that have no matching file) which cause Prisma CLI to abort. try { await applyMissingMigrations(); } catch (err) { console.error("applyMissingMigrations failed after restore:", err); } res.json({ success: true }); }); if (isZip && tmpZipPath) { // Pipe the first .sql entry from the zip directly into psql stdin const unzip = spawn("unzip", ["-p", tmpZipPath, "*.sql"]); let unzipErr = ""; unzip.stderr.on("data", (d) => (unzipErr += d.toString())); unzip.on("error", (err) => { if (tmpZipPath) try { fs.unlinkSync(tmpZipPath); } catch {} if (!res.headersSent) res.status(500).json({ error: "Failed to extract zip", details: err.message }); }); unzip.stdout.pipe(psql.stdin); } else { psql.stdin.write(req.file.buffer); psql.stdin.end(); } }); // ============================== // Network Backup — Source Role // ============================== // GET /network-backup-key — return (or auto-generate) this machine's API key router.get("/network-backup-key", async (req, res) => { if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" }); res.json({ apiKey: getOrCreateApiKey() }); }); // POST /network-backup-key/regenerate — generate a new key router.post("/network-backup-key/regenerate", async (req, res) => { if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" }); res.json({ apiKey: regenerateApiKey() }); }); // GET /network-backup — streams a live pg_dump; authenticated by API key header only router.get("/network-backup", async (req: Request, res: Response): Promise => { const providedKey = req.headers["x-network-backup-key"] as string | undefined; if (!providedKey) return res.status(401).json({ error: "Missing X-Network-Backup-Key header" }); const storedKey = getOrCreateApiKey(); if (providedKey !== storedKey) return res.status(401).json({ error: "Invalid API key" }); const pg = spawn( "pg_dump", [ "--no-acl", "--no-owner", "-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 } } ); res.setHeader("Content-Type", "application/octet-stream"); res.setHeader( "Content-Disposition", `attachment; filename="network_backup_${Date.now()}.sql"` ); pg.stdout.pipe(res); let stderr = ""; pg.stderr.on("data", (d) => (stderr += d.toString())); pg.on("error", (err) => { if (!res.headersSent) res.status(500).json({ error: "pg_dump failed", details: err.message }); }); pg.on("close", (code) => { if (code !== 0) console.error("pg_dump for network backup failed:", stderr); }); }); // GET /network-backup-files — streams uploads/ as a zip; authenticated by API key header only router.get("/network-backup-files", (req: Request, res: Response): any => { const providedKey = req.headers["x-network-backup-key"] as string | undefined; if (!providedKey) return res.status(401).json({ error: "Missing X-Network-Backup-Key header" }); const storedKey = getOrCreateApiKey(); if (providedKey !== storedKey) return res.status(401).json({ error: "Invalid API key" }); if (!fs.existsSync(UPLOADS_DIR)) { return res.status(200).end(); // nothing to send } res.setHeader("Content-Type", "application/zip"); res.setHeader("Content-Disposition", `attachment; filename="network_uploads_${Date.now()}.zip"`); const archive = archiver("zip", { zlib: { level: 6 } }); archive.on("error", (err) => { if (!res.headersSent) res.status(500).json({ error: "Failed to create archive", details: err.message }); }); archive.pipe(res); archive.directory(UPLOADS_DIR, false); // zip contents without the "uploads" prefix archive.finalize(); }); // ============================== // Network Backup — Receiver Role // ============================== // GET /network-sync-config router.get("/network-sync-config", (req, res) => { if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" }); res.json(readSyncConfig()); }); // PUT /network-sync-config router.put("/network-sync-config", (req: Request, res: Response): any => { if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" }); const { enabled, syncHour, sourceUrl, apiKey } = req.body; const updated = writeSyncConfig({ enabled, syncHour, sourceUrl, apiKey }); res.json(updated); }); // POST /network-sync-now — trigger an immediate pull sync router.post("/network-sync-now", async (req: Request, res: Response): Promise => { if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" }); const config = readSyncConfig(); if (!config.sourceUrl || !config.apiKey) { return res .status(400) .json({ error: "Source URL and API key must be configured before syncing" }); } try { await runNetworkSync(config.sourceUrl, config.apiKey); await runNetworkFilesSync(config.sourceUrl, config.apiKey); writeSyncConfig({ lastSyncAt: new Date().toISOString(), lastSyncStatus: "success", lastSyncError: null }); res.json({ success: true, syncedAt: new Date() }); } catch (err: any) { writeSyncConfig({ lastSyncAt: new Date().toISOString(), lastSyncStatus: "failed", lastSyncError: err.message }); res.status(500).json({ error: "Sync failed", details: err.message }); } }); export default router;