From e59364e12633ff8b148db59fcf2fb8faff624d24 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Tue, 3 Mar 2026 20:01:10 -0500 Subject: [PATCH] Add database backup/restore feature with admin UI - Backup service using VACUUM INTO for WAL-safe atomic snapshots - Server-side storage in DATA_DIR/backups with download/upload API routes - Restore with integrity validation and automatic pre-restore safety backup - Scheduled daily backups via cron with configurable retention - Admin-only settings UI consolidated under single Server section - Clean up account section sign-out button, fix switch sub-pixel rendering Co-Authored-By: Claude Opus 4.6 --- .../settings/_components/account-section.tsx | 10 +- .../settings/_components/backup-actions.ts | 58 +++ .../settings/_components/backup-section.tsx | 408 ++++++++++++++++++ .../settings/_components/server-section.tsx | 61 +-- app/(pages)/settings/page.tsx | 38 +- app/api/backup/[filename]/route.ts | 45 ++ app/api/backup/restore/route.ts | 36 ++ app/globals.css | 3 +- components/ui/switch.tsx | 4 +- instrumentation.ts | 4 + lib/cron.ts | 22 + lib/db/client.ts | 3 + lib/services/backup.ts | 184 ++++++++ 13 files changed, 826 insertions(+), 50 deletions(-) create mode 100644 app/(pages)/settings/_components/backup-actions.ts create mode 100644 app/(pages)/settings/_components/backup-section.tsx create mode 100644 app/api/backup/[filename]/route.ts create mode 100644 app/api/backup/restore/route.ts create mode 100644 lib/services/backup.ts diff --git a/app/(pages)/settings/_components/account-section.tsx b/app/(pages)/settings/_components/account-section.tsx index 0c8800f..e4c0b07 100644 --- a/app/(pages)/settings/_components/account-section.tsx +++ b/app/(pages)/settings/_components/account-section.tsx @@ -2,6 +2,7 @@ import { IconLogout, IconUser } from "@tabler/icons-react"; import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; import { Card, CardContent, @@ -50,20 +51,17 @@ export function AccountSection({ Member since {memberSince}

- - - + diff --git a/app/(pages)/settings/_components/backup-actions.ts b/app/(pages)/settings/_components/backup-actions.ts new file mode 100644 index 0000000..4c23548 --- /dev/null +++ b/app/(pages)/settings/_components/backup-actions.ts @@ -0,0 +1,58 @@ +"use server"; + +import { headers } from "next/headers"; +import { auth } from "@/lib/auth/server"; +import { + type BackupInfo, + createBackup, + deleteBackup, + listBackups, +} from "@/lib/services/backup"; +import { getSetting, setSetting } from "@/lib/services/settings"; + +async function getAdminSession() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) throw new Error("Unauthorized"); + if (session.user.role !== "admin") throw new Error("Forbidden"); + return session; +} + +export async function createBackupAction(): Promise { + await getAdminSession(); + return createBackup(); +} + +export async function listBackupsAction(): Promise { + await getAdminSession(); + return listBackups(); +} + +export async function deleteBackupAction(filename: string): Promise { + await getAdminSession(); + deleteBackup(filename); +} + +export async function setScheduledBackupAction( + enabled: boolean, +): Promise { + await getAdminSession(); + setSetting("scheduledBackups", String(enabled)); +} + +export async function getScheduledBackupSettings(): Promise<{ + enabled: boolean; + maxRetention: number; +}> { + await getAdminSession(); + return { + enabled: getSetting("scheduledBackups") === "true", + maxRetention: Number.parseInt(getSetting("maxBackupRetention") ?? "7", 10), + }; +} + +export async function setMaxBackupsAction(max: number): Promise { + await getAdminSession(); + if (max < 1 || max > 30) + throw new Error("Max backups must be between 1 and 30"); + setSetting("maxBackupRetention", String(max)); +} diff --git a/app/(pages)/settings/_components/backup-section.tsx b/app/(pages)/settings/_components/backup-section.tsx new file mode 100644 index 0000000..6f9be08 --- /dev/null +++ b/app/(pages)/settings/_components/backup-section.tsx @@ -0,0 +1,408 @@ +"use client"; + +import { + IconCalendarRepeat, + IconCloudDownload, + IconCloudUpload, + IconDatabaseExport, + IconPlus, + IconTrash, +} from "@tabler/icons-react"; +import { formatDistanceToNow } from "date-fns"; +import { AnimatePresence, motion } from "motion/react"; +import { useRef, useState } from "react"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { CardContent, CardDescription, CardTitle } from "@/components/ui/card"; +import { Spinner } from "@/components/ui/spinner"; +import { Switch } from "@/components/ui/switch"; +import type { BackupInfo } from "@/lib/services/backup"; +import { + createBackupAction, + deleteBackupAction, + setMaxBackupsAction, + setScheduledBackupAction, +} from "./backup-actions"; + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function formatBackupDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +export function BackupSection({ + initialBackups, + initialScheduledEnabled, + initialMaxRetention, +}: { + initialBackups: BackupInfo[]; + initialScheduledEnabled: boolean; + initialMaxRetention: number; +}) { + const [backups, setBackups] = useState(initialBackups); + const [creating, setCreating] = useState(false); + const [deleting, setDeleting] = useState(null); + const [restoring, setRestoring] = useState(false); + const [scheduledEnabled, setScheduledEnabled] = useState( + initialScheduledEnabled, + ); + const [maxRetention, setMaxRetention] = useState(initialMaxRetention); + const [togglingSchedule, setTogglingSchedule] = useState(false); + const [restoreDialogOpen, setRestoreDialogOpen] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const fileInputRef = useRef(null); + + async function handleCreateBackup() { + setCreating(true); + try { + const backup = await createBackupAction(); + setBackups((prev) => [backup, ...prev]); + toast.success("Backup created"); + } catch { + toast.error("Failed to create backup"); + } finally { + setCreating(false); + } + } + + async function handleDelete(filename: string) { + const previous = backups; + setDeleting(filename); + setBackups((prev) => prev.filter((b) => b.filename !== filename)); + try { + await deleteBackupAction(filename); + toast.success("Backup deleted"); + } catch { + setBackups(previous); + toast.error("Failed to delete backup"); + } finally { + setDeleting(null); + } + } + + async function handleRestore(file: File) { + setRestoring(true); + try { + const formData = new FormData(); + formData.append("file", file); + const res = await fetch("/api/backup/restore", { + method: "POST", + body: formData, + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Restore failed"); + } + toast.success("Database restored. Reloading..."); + setTimeout(() => window.location.reload(), 1500); + } catch (err) { + const message = err instanceof Error ? err.message : "Restore failed"; + toast.error(message); + } finally { + setRestoring(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + } + + async function handleToggleScheduled(checked: boolean) { + const previous = scheduledEnabled; + setScheduledEnabled(checked); + setTogglingSchedule(true); + try { + await setScheduledBackupAction(checked); + toast.success( + checked ? "Scheduled backups enabled" : "Scheduled backups disabled", + ); + } catch { + setScheduledEnabled(previous); + toast.error("Failed to update scheduled backup setting"); + } finally { + setTogglingSchedule(false); + } + } + + async function handleMaxRetentionChange(value: number) { + const previous = maxRetention; + setMaxRetention(value); + try { + await setMaxBackupsAction(value); + } catch { + setMaxRetention(previous); + toast.error("Failed to update retention setting"); + } + } + + return ( + <> + {/* Create backup header */} + +
+
+
+ +
+
+ Database snapshots + + {backups.length > 0 + ? `${backups.length} backup${backups.length !== 1 ? "s" : ""} stored` + : "No backups yet"} + +
+
+ +
+
+ + {/* Backup list */} + + {backups.length > 0 && ( + +
+ {backups.map((backup) => ( + +
+
+
+ + {formatBackupDate(backup.createdAt)} + + + {formatBytes(backup.sizeBytes)} + + + {formatDistanceToNow(new Date(backup.createdAt), { + addSuffix: true, + })} + +
+ {backup.filename.startsWith("pre-restore") && ( + + Pre-restore safety backup + + )} +
+
+
+
+
+ ))} +
+
+ )} +
+ + {/* Restore */} + +
+
+
+ +
+
+ Restore + + Upload a .db file to replace the current database. A safety + backup is created first. + +
+
+ { + const file = e.target.files?.[0]; + if (file) { + setSelectedFile(file); + setRestoreDialogOpen(true); + } + }} + /> + { + if (selectedFile) handleRestore(selectedFile); + setRestoreDialogOpen(false); + setSelectedFile(null); + }} + onCancel={() => { + setRestoreDialogOpen(false); + setSelectedFile(null); + if (fileInputRef.current) fileInputRef.current.value = ""; + }} + /> + +
+
+ + {/* Scheduled backups */} + +
+
+
+ +
+
+ Scheduled backups + + Daily at 2:00 AM + {scheduledEnabled && ( + + {" "} + · keeping last{" "} + + + )} + +
+
+ +
+
+ + ); +} + +function RestoreDialog({ + open, + onOpenChange, + onConfirm, + onCancel, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + onCancel: () => void; +}) { + return ( + + + + Restore database? + + This will replace your entire database with the uploaded file. A + safety backup of your current data will be created first. Active + sessions may need to refresh after restore. + + + + Cancel + Restore + + + + ); +} diff --git a/app/(pages)/settings/_components/server-section.tsx b/app/(pages)/settings/_components/server-section.tsx index bde49d3..9ac9a93 100644 --- a/app/(pages)/settings/_components/server-section.tsx +++ b/app/(pages)/settings/_components/server-section.tsx @@ -1,14 +1,9 @@ "use client"; -import { IconServerCog, IconUserPlus } from "@tabler/icons-react"; +import { IconUserPlus } from "@tabler/icons-react"; import { useState } from "react"; import { toast } from "sonner"; -import { - Card, - CardContent, - CardDescription, - CardTitle, -} from "@/components/ui/card"; +import { CardContent, CardDescription, CardTitle } from "@/components/ui/card"; import { Switch } from "@/components/ui/switch"; import { toggleRegistration } from "./actions"; @@ -38,39 +33,25 @@ export function ServerSection({ } return ( -
-
- -

- Server -

- - Admin only - -
- - -
-
-
- -
-
- Open registration - - Allow new users to create accounts. Useful for adding - household members. - -
-
- + +
+
+
+
- - -
+
+ Open registration + + Allow new users to create accounts + +
+
+ +
+
); } diff --git a/app/(pages)/settings/page.tsx b/app/(pages)/settings/page.tsx index 6eecd6c..af93689 100644 --- a/app/(pages)/settings/page.tsx +++ b/app/(pages)/settings/page.tsx @@ -1,12 +1,16 @@ +import { IconServerCog } from "@tabler/icons-react"; import { desc, eq } from "drizzle-orm"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; +import { Card } from "@/components/ui/card"; import { auth } from "@/lib/auth/server"; import { db } from "@/lib/db/client"; import { webhookConnections, webhookEventLog } from "@/lib/db/schema"; +import { listBackups } from "@/lib/services/backup"; import { getSetting } from "@/lib/services/settings"; import { APP_VERSION, GIT_COMMIT } from "@/lib/version"; import { AccountSection } from "./_components/account-section"; +import { BackupSection } from "./_components/backup-section"; import { IntegrationsSection } from "./_components/integrations-section"; import { ServerSection } from "./_components/server-section"; import { SettingsShell } from "./_components/settings-shell"; @@ -53,6 +57,14 @@ export default async function SettingsPage() { ? getSetting("registrationOpen") === "true" : false; + const backups = isAdmin ? listBackups() : []; + const scheduledBackupsEnabled = isAdmin + ? getSetting("scheduledBackups") === "true" + : false; + const maxBackupRetention = isAdmin + ? Number.parseInt(getSetting("maxBackupRetention") ?? "7", 10) + : 7; + const repoUrl = "https://github.com/jakejarvis/sofa"; return ( @@ -97,7 +109,31 @@ export default async function SettingsPage() { }} /> - {isAdmin && } + {isAdmin && ( +
+
+ +

+ Server +

+ + Admin only + +
+
+ + + + + + +
+
+ )} ); } diff --git a/app/api/backup/[filename]/route.ts b/app/api/backup/[filename]/route.ts new file mode 100644 index 0000000..80e4155 --- /dev/null +++ b/app/api/backup/[filename]/route.ts @@ -0,0 +1,45 @@ +import { statSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { headers } from "next/headers"; +import { type NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth/server"; +import { getBackupPath } from "@/lib/services/backup"; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ filename: string }> }, +) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (session.user.role !== "admin") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { filename } = await params; + + // Sanitize to prevent path traversal + const safe = path.basename(filename); + if (!safe || safe !== filename || safe.includes("..")) { + return NextResponse.json({ error: "Invalid filename" }, { status: 400 }); + } + + const backupPath = getBackupPath(safe); + if (!backupPath) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const buffer = await readFile(backupPath); + const stat = statSync(backupPath); + + return new NextResponse(new Uint8Array(buffer), { + status: 200, + headers: { + "Content-Type": "application/x-sqlite3", + "Content-Disposition": `attachment; filename="${safe}"`, + "Content-Length": String(stat.size), + }, + }); +} diff --git a/app/api/backup/restore/route.ts b/app/api/backup/restore/route.ts new file mode 100644 index 0000000..6a34757 --- /dev/null +++ b/app/api/backup/restore/route.ts @@ -0,0 +1,36 @@ +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth/server"; +import { restoreFromBackup } from "@/lib/services/backup"; + +const MAX_SIZE = 500 * 1024 * 1024; // 500MB + +export async function POST(req: Request) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (session.user.role !== "admin") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const formData = await req.formData(); + const file = formData.get("file") as File | null; + if (!file) { + return NextResponse.json({ error: "No file provided" }, { status: 400 }); + } + + if (file.size > MAX_SIZE) { + return NextResponse.json({ error: "File too large" }, { status: 413 }); + } + + const buffer = Buffer.from(await file.arrayBuffer()); + + try { + restoreFromBackup(buffer); + return NextResponse.json({ success: true }); + } catch (err) { + const message = err instanceof Error ? err.message : "Restore failed"; + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/app/globals.css b/app/globals.css index 690e025..2cd7870 100644 --- a/app/globals.css +++ b/app/globals.css @@ -100,7 +100,8 @@ /* https://ui.shadcn.com/docs/components/radix/button#cursor */ button:not(:disabled), - [role="button"]:not(:disabled) { + [role="button"]:not(:disabled), + [role="switch"]:not(:disabled) { cursor: pointer; } } diff --git a/components/ui/switch.tsx b/components/ui/switch.tsx index ac99be9..6f87d29 100644 --- a/components/ui/switch.tsx +++ b/components/ui/switch.tsx @@ -16,14 +16,14 @@ function Switch({ data-slot="switch" data-size={size} className={cn( - "data-checked:bg-primary data-unchecked:bg-input focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:ring-2 aria-invalid:ring-2 data-disabled:cursor-not-allowed data-disabled:opacity-50 data-[size=default]:h-[16.6px] data-[size=default]:w-[28px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px]", + "data-checked:bg-primary data-unchecked:bg-input focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:ring-2 aria-invalid:ring-2 data-disabled:cursor-not-allowed data-disabled:opacity-50 data-[size=default]:h-[18px] data-[size=default]:w-[30px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px]", className, )} {...props} > ); diff --git a/instrumentation.ts b/instrumentation.ts index 0ff8364..443d49f 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -15,6 +15,10 @@ export async function register() { ensureImageDirs(); } + // Ensure backup directory exists + const { ensureBackupDir } = await import("@/lib/services/backup"); + ensureBackupDir(); + // Run database migrations on startup const { runMigrations } = await import("@/lib/db/migrate"); runMigrations(); diff --git a/lib/cron.ts b/lib/cron.ts index 640188a..2596340 100644 --- a/lib/cron.ts +++ b/lib/cron.ts @@ -9,6 +9,11 @@ import { } from "@/lib/db/schema"; import { createLogger } from "@/lib/logger"; import { refreshAvailability } from "@/lib/services/availability"; +import { + createBackup, + ensureBackupDir, + pruneBackups, +} from "@/lib/services/backup"; import { cacheEpisodeStills, cacheImagesForTitle, @@ -20,6 +25,7 @@ import { refreshTitle, refreshTvChildren, } from "@/lib/services/metadata"; +import { getSetting } from "@/lib/services/settings"; import { getTvDetails } from "@/lib/tmdb/client"; const log = createLogger("cron"); @@ -207,9 +213,25 @@ async function cacheImagesJob() { } } +async function scheduledBackupJob() { + const enabled = getSetting("scheduledBackups"); + if (enabled !== "true") { + log.debug("Scheduled backups disabled, skipping"); + return; + } + + ensureBackupDir(); + createBackup(); + + const maxStr = getSetting("maxBackupRetention"); + const max = maxStr ? Number.parseInt(maxStr, 10) : 7; + pruneBackups(max); +} + export function startJobs() { if (jobs.size > 0) return; + schedule("scheduledBackup", "0 2 * * *", scheduledBackupJob); schedule("nightlyRefreshLibrary", "0 3 * * *", nightlyRefreshLibrary); schedule("refreshAvailability", "0 */6 * * *", refreshAvailabilityJob); schedule("refreshRecommendations", "0 */12 * * *", refreshRecommendationsJob); diff --git a/lib/db/client.ts b/lib/db/client.ts index f63f5eb..cd378ac 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -46,6 +46,9 @@ export const db = new Proxy({} as ReturnType, { }, }); +/** Close the current connection, and clear singletons so the Proxy re-initializes on next access. */ export function closeDatabase() { globalForDb._client?.close(); + globalForDb._client = undefined; + globalForDb._db = undefined; } diff --git a/lib/services/backup.ts b/lib/services/backup.ts new file mode 100644 index 0000000..ef03ce7 --- /dev/null +++ b/lib/services/backup.ts @@ -0,0 +1,184 @@ +import { Database } from "bun:sqlite"; +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { format } from "date-fns"; +import { sql } from "drizzle-orm"; +import { closeDatabase, db } from "@/lib/db/client"; +import { createLogger } from "@/lib/logger"; + +const log = createLogger("backup"); + +const DATA_DIR = process.env.DATA_DIR || "./data"; +const DATABASE_URL = + process.env.DATABASE_URL || path.join(DATA_DIR, "sqlite.db"); +const BACKUP_DIR = path.join(DATA_DIR, "backups"); + +const BACKUP_PATTERN = /^sofa-backup-\d{4}-\d{2}-\d{2}-\d{6}\.db$/; +const PRE_RESTORE_PATTERN = /^pre-restore-\d{4}-\d{2}-\d{2}-\d{6}\.db$/; + +export interface BackupInfo { + filename: string; + sizeBytes: number; + createdAt: string; +} + +export function ensureBackupDir() { + if (!existsSync(BACKUP_DIR)) { + mkdirSync(BACKUP_DIR, { recursive: true }); + } +} + +function isValidBackupFilename(filename: string): boolean { + const base = path.basename(filename); + return ( + base === filename && + !filename.includes("..") && + (BACKUP_PATTERN.test(filename) || PRE_RESTORE_PATTERN.test(filename)) + ); +} + +export function createBackup(prefix = "sofa-backup"): BackupInfo { + ensureBackupDir(); + + const timestamp = format(new Date(), "yyyy-MM-dd-HHmmss"); + const filename = `${prefix}-${timestamp}.db`; + const dest = path.join(BACKUP_DIR, filename); + + // VACUUM INTO atomically creates a clean, self-contained copy (safe for WAL mode) + db.run(sql.raw(`VACUUM INTO '${dest.replace(/'/g, "''")}'`)); + + const stat = statSync(dest); + log.info(`Created backup: ${filename} (${stat.size} bytes)`); + + return { + filename, + sizeBytes: stat.size, + createdAt: stat.mtime.toISOString(), + }; +} + +export function listBackups(): BackupInfo[] { + ensureBackupDir(); + + const files = readdirSync(BACKUP_DIR).filter( + (f) => BACKUP_PATTERN.test(f) || PRE_RESTORE_PATTERN.test(f), + ); + + return files + .map((filename) => { + const stat = statSync(path.join(BACKUP_DIR, filename)); + return { + filename, + sizeBytes: stat.size, + createdAt: stat.mtime.toISOString(), + }; + }) + .sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); +} + +export function deleteBackup(filename: string): void { + if (!isValidBackupFilename(filename)) { + throw new Error("Invalid backup filename"); + } + + const filePath = path.join(BACKUP_DIR, filename); + if (!existsSync(filePath)) { + throw new Error("Backup not found"); + } + + unlinkSync(filePath); + log.info(`Deleted backup: ${filename}`); +} + +export function getBackupPath(filename: string): string | null { + if (!isValidBackupFilename(filename)) return null; + + const filePath = path.join(BACKUP_DIR, filename); + return existsSync(filePath) ? filePath : null; +} + +export async function readBackupFile(filename: string): Promise { + const filePath = getBackupPath(filename); + if (!filePath) return null; + return readFile(filePath); +} + +export function restoreFromBackup(buffer: Buffer): void { + ensureBackupDir(); + + const timestamp = format(new Date(), "yyyy-MM-dd-HHmmss"); + const tempPath = path.join(BACKUP_DIR, `_restore-temp-${timestamp}.db`); + + try { + // Write uploaded file to temp location + writeFileSync(tempPath, buffer); + + // Validate it's a real SQLite database + const testDb = new Database(tempPath, { readonly: true }); + try { + const result = testDb.query("PRAGMA integrity_check").get() as { + integrity_check: string; + }; + if (result?.integrity_check !== "ok") { + throw new Error("Database integrity check failed"); + } + + // Check for key app tables + const tables = testDb + .query( + "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('user', 'titles', 'userTitleStatus')", + ) + .all() as { name: string }[]; + if (tables.length < 2) { + throw new Error("Invalid backup: missing expected application tables"); + } + } finally { + testDb.close(); + } + + // Create safety backup before replacing + log.info("Creating pre-restore safety backup..."); + createBackup("pre-restore"); + + // Replace the database + log.info("Replacing database..."); + closeDatabase(); + copyFileSync(tempPath, DATABASE_URL); + + // Clean up WAL/SHM files from the old database + const walPath = `${DATABASE_URL}-wal`; + const shmPath = `${DATABASE_URL}-shm`; + if (existsSync(walPath)) unlinkSync(walPath); + if (existsSync(shmPath)) unlinkSync(shmPath); + + log.info("Database restored successfully"); + } finally { + // Clean up temp file + if (existsSync(tempPath)) unlinkSync(tempPath); + } +} + +export function pruneBackups(maxKeep: number): void { + const backups = listBackups().filter((b) => BACKUP_PATTERN.test(b.filename)); + + if (backups.length <= maxKeep) return; + + const toDelete = backups.slice(maxKeep); + for (const backup of toDelete) { + deleteBackup(backup.filename); + } + + log.info(`Pruned ${toDelete.length} old backup(s), kept ${maxKeep}`); +}