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"; import { readRcloneConfig, writeRcloneConfig } from "../services/rcloneConfigService"; import { checkRcloneInstalled, isServerRunning, startWebDavServer, stopWebDavServer, runRclonePull } from "../services/rcloneService"; import { importLatestBackup } from "../services/autoImportService"; const UPLOADS_DIR = path.join(process.cwd(), "uploads"); const SCHEMA_PATH = path.resolve(__dirname, "../../../../packages/db/prisma/schema.prisma"); function runDbPush(): Promise { return new Promise((resolve, reject) => { const proc = spawn("npx", ["prisma", "db", "push", "--config", path.resolve(__dirname, "../../../../packages/db/prisma/prisma.config.ts"), "--schema", SCHEMA_PATH, "--accept-data-loss"], { cwd: process.cwd(), env: process.env, }); let stderr = ""; proc.stdout.on("data", (d) => console.log(`[db-push] ${d.toString().trim()}`)); proc.stderr.on("data", (d) => { const line = d.toString().trim(); stderr += line + "\n"; console.log(`[db-push] ${line}`); }); proc.on("error", (err) => reject(new Error(`Failed to run prisma db push: ${err.message}`))); proc.on("close", (code) => { if (code !== 0) return reject(new Error(`prisma db push failed (exit ${code}): ${stderr}`)); console.log("[db-push] Schema synced successfully"); resolve(); }); }); } 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, usbBackupHour: user.usbBackupHour ?? 21 }); }); // 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, usbBackupHour } = req.body; const patch: any = {}; if (typeof usbBackupEnabled === "boolean") patch.usbBackupEnabled = usbBackupEnabled; if (typeof usbBackupHour === "number") patch.usbBackupHour = usbBackupHour; const updated = await storage.updateUser(userId, patch); if (!updated) return res.status(404).json({ error: "User not found" }); res.json({ usbBackupEnabled: updated.usbBackupEnabled, usbBackupHour: updated.usbBackupHour ?? 21 }); }); // 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, autoBackupHour: user.autoBackupHour ?? 20 }); }); // 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, autoBackupHour } = req.body; const patch: any = {}; if (typeof autoBackupEnabled === "boolean") patch.autoBackupEnabled = autoBackupEnabled; if (typeof autoBackupHour === "number") patch.autoBackupHour = autoBackupHour; const updated = await storage.updateUser(userId, patch); if (!updated) return res.status(404).json({ error: "User not found" }); res.json({ autoBackupEnabled: updated.autoBackupEnabled, autoBackupHour: updated.autoBackupHour ?? 20 }); }); 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 runDbPush(); } catch (err) { console.error("[restore] db push 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: typeof sourceUrl === "string" ? sourceUrl.trim() : sourceUrl, apiKey: typeof apiKey === "string" ? apiKey.trim() : 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 }); } }); // ============================== // Rclone Backup // ============================== router.get("/rclone-config", (req, res) => { if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" }); res.json(readRcloneConfig()); }); router.put("/rclone-config", (req: Request, res: Response): any => { if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" }); const { serverEnabled, serverPort, receiverEnabled, receiverSyncHour, sourceIp, sourcePort, autoImportEnabled, autoImportHour } = req.body; const patch: any = {}; if (typeof serverEnabled === "boolean") patch.serverEnabled = serverEnabled; if (typeof serverPort === "number") patch.serverPort = serverPort; if (typeof receiverEnabled === "boolean") patch.receiverEnabled = receiverEnabled; if (typeof receiverSyncHour === "number") patch.receiverSyncHour = receiverSyncHour; if (typeof sourceIp === "string") patch.sourceIp = sourceIp.trim(); if (typeof sourcePort === "number") patch.sourcePort = sourcePort; if (typeof autoImportEnabled === "boolean") patch.autoImportEnabled = autoImportEnabled; if (typeof autoImportHour === "number") patch.autoImportHour = autoImportHour; const updated = writeRcloneConfig(patch); res.json(updated); }); router.get("/rclone-status", async (req, res) => { if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" }); const installed = await checkRcloneInstalled(); const serverRunning = isServerRunning(); res.json({ installed, serverRunning }); }); router.post("/rclone-server/start", async (req: Request, res: Response): Promise => { if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" }); try { await startWebDavServer(); res.json({ success: true, running: true }); } catch (err: any) { res.status(500).json({ error: "Failed to start rclone server", details: err.message }); } }); router.post("/rclone-server/stop", (req: Request, res: Response): any => { if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" }); stopWebDavServer(); res.json({ success: true, running: false }); }); router.post("/rclone-pull-now", async (req: Request, res: Response): Promise => { if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" }); try { await runRclonePull(); writeRcloneConfig({ lastSyncAt: new Date().toISOString(), lastSyncStatus: "success", lastSyncError: null }); res.json({ success: true, syncedAt: new Date() }); } catch (err: any) { writeRcloneConfig({ lastSyncAt: new Date().toISOString(), lastSyncStatus: "failed", lastSyncError: err.message }); res.status(500).json({ error: "Rclone pull failed", details: err.message }); } }); router.post("/auto-import-now", async (req: Request, res: Response): Promise => { if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" }); try { await importLatestBackup(); writeRcloneConfig({ lastImportAt: new Date().toISOString(), lastImportStatus: "success", lastImportError: null }); res.json({ success: true, importedAt: new Date() }); } catch (err: any) { writeRcloneConfig({ lastImportAt: new Date().toISOString(), lastImportStatus: "failed", lastImportError: err.message }); res.status(500).json({ error: "Auto-import failed", details: err.message }); } }); export default router;