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}`);
+}