mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { IconLogout, IconUser } from "@tabler/icons-react";
|
import { IconLogout, IconUser } from "@tabler/icons-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -50,20 +51,17 @@ export function AccountSection({
|
|||||||
Member since {memberSince}
|
Member since {memberSince}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<Button
|
||||||
<CardContent className="pt-0">
|
variant="ghost"
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await signOut();
|
await signOut();
|
||||||
router.push("/");
|
router.push("/");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}}
|
}}
|
||||||
className="inline-flex h-9 items-center gap-2 rounded-lg border border-border/50 px-4 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
>
|
||||||
<IconLogout size={14} />
|
<IconLogout size={14} />
|
||||||
Sign out
|
Sign out
|
||||||
</button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<BackupInfo> {
|
||||||
|
await getAdminSession();
|
||||||
|
return createBackup();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listBackupsAction(): Promise<BackupInfo[]> {
|
||||||
|
await getAdminSession();
|
||||||
|
return listBackups();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBackupAction(filename: string): Promise<void> {
|
||||||
|
await getAdminSession();
|
||||||
|
deleteBackup(filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setScheduledBackupAction(
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await getAdminSession();
|
||||||
|
if (max < 1 || max > 30)
|
||||||
|
throw new Error("Max backups must be between 1 and 30");
|
||||||
|
setSetting("maxBackupRetention", String(max));
|
||||||
|
}
|
||||||
@@ -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<BackupInfo[]>(initialBackups);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState<string | null>(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<File | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(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 */}
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<IconDatabaseExport size={16} className="text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle>Database snapshots</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{backups.length > 0
|
||||||
|
? `${backups.length} backup${backups.length !== 1 ? "s" : ""} stored`
|
||||||
|
: "No backups yet"}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleCreateBackup} disabled={creating}>
|
||||||
|
{creating ? <Spinner className="size-3" /> : <IconPlus size={14} />}
|
||||||
|
{creating ? "Creating..." : "New backup"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
{/* Backup list */}
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{backups.length > 0 && (
|
||||||
|
<CardContent className="border-t border-border/30 pt-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{backups.map((backup) => (
|
||||||
|
<motion.div
|
||||||
|
key={backup.filename}
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="group flex items-center gap-3 rounded-md px-2.5 py-1.5 transition-colors hover:bg-muted/40">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-xs font-medium text-foreground">
|
||||||
|
{formatBackupDate(backup.createdAt)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{formatBytes(backup.sizeBytes)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground/50">
|
||||||
|
{formatDistanceToNow(new Date(backup.createdAt), {
|
||||||
|
addSuffix: true,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{backup.filename.startsWith("pre-restore") && (
|
||||||
|
<span className="text-[10px] text-primary/70">
|
||||||
|
Pre-restore safety backup
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
render={
|
||||||
|
<a
|
||||||
|
href={`/api/backup/${backup.filename}`}
|
||||||
|
download
|
||||||
|
title="Download"
|
||||||
|
aria-label="Download backup"
|
||||||
|
>
|
||||||
|
<IconCloudDownload />
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
disabled={deleting === backup.filename}
|
||||||
|
title="Delete"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{deleting === backup.filename ? (
|
||||||
|
<Spinner className="size-3" />
|
||||||
|
) : (
|
||||||
|
<IconTrash />
|
||||||
|
)}
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete backup?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete the backup from{" "}
|
||||||
|
<strong>
|
||||||
|
{formatBackupDate(backup.createdAt)}
|
||||||
|
</strong>
|
||||||
|
. This cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => handleDelete(backup.filename)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Restore */}
|
||||||
|
<CardContent className="border-t border-border/30 pt-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted/50">
|
||||||
|
<IconCloudUpload size={16} className="text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle>Restore</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Upload a .db file to replace the current database. A safety
|
||||||
|
backup is created first.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".db"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
setSelectedFile(file);
|
||||||
|
setRestoreDialogOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<RestoreDialog
|
||||||
|
open={restoreDialogOpen}
|
||||||
|
onOpenChange={setRestoreDialogOpen}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (selectedFile) handleRestore(selectedFile);
|
||||||
|
setRestoreDialogOpen(false);
|
||||||
|
setSelectedFile(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setRestoreDialogOpen(false);
|
||||||
|
setSelectedFile(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={restoring}
|
||||||
|
>
|
||||||
|
{restoring ? (
|
||||||
|
<Spinner className="size-3" />
|
||||||
|
) : (
|
||||||
|
<IconCloudUpload size={14} />
|
||||||
|
)}
|
||||||
|
{restoring ? "Restoring..." : "Upload"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
{/* Scheduled backups */}
|
||||||
|
<CardContent className="border-t border-border/30 pt-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted/50">
|
||||||
|
<IconCalendarRepeat size={16} className="text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle>Scheduled backups</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Daily at 2:00 AM
|
||||||
|
{scheduledEnabled && (
|
||||||
|
<span className="text-muted-foreground/50">
|
||||||
|
{" "}
|
||||||
|
· keeping last{" "}
|
||||||
|
<select
|
||||||
|
value={maxRetention}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleMaxRetentionChange(Number(e.target.value))
|
||||||
|
}
|
||||||
|
className="inline h-auto appearance-none border-b border-dashed border-muted-foreground/30 bg-transparent px-0.5 text-xs text-muted-foreground outline-none hover:border-muted-foreground/60 focus:border-primary"
|
||||||
|
>
|
||||||
|
{[3, 5, 7, 14, 30].map((n) => (
|
||||||
|
<option key={n} value={n}>
|
||||||
|
{n}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={scheduledEnabled}
|
||||||
|
onCheckedChange={handleToggleScheduled}
|
||||||
|
disabled={togglingSchedule}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RestoreDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Restore database?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
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.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={onConfirm}>Restore</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { IconServerCog, IconUserPlus } from "@tabler/icons-react";
|
import { IconUserPlus } from "@tabler/icons-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { toggleRegistration } from "./actions";
|
import { toggleRegistration } from "./actions";
|
||||||
|
|
||||||
@@ -38,17 +33,6 @@ export function ServerSection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
|
||||||
<div className="mb-3 flex items-center gap-2">
|
|
||||||
<IconServerCog size={16} className="text-muted-foreground" />
|
|
||||||
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
||||||
Server
|
|
||||||
</h2>
|
|
||||||
<span className="rounded-md bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
|
|
||||||
Admin only
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Card className="border-l-2 border-l-primary/30">
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
@@ -58,8 +42,7 @@ export function ServerSection({
|
|||||||
<div>
|
<div>
|
||||||
<CardTitle>Open registration</CardTitle>
|
<CardTitle>Open registration</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Allow new users to create accounts. Useful for adding
|
Allow new users to create accounts
|
||||||
household members.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,7 +53,5 @@ export function ServerSection({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
|
import { IconServerCog } from "@tabler/icons-react";
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
import { auth } from "@/lib/auth/server";
|
import { auth } from "@/lib/auth/server";
|
||||||
import { db } from "@/lib/db/client";
|
import { db } from "@/lib/db/client";
|
||||||
import { webhookConnections, webhookEventLog } from "@/lib/db/schema";
|
import { webhookConnections, webhookEventLog } from "@/lib/db/schema";
|
||||||
|
import { listBackups } from "@/lib/services/backup";
|
||||||
import { getSetting } from "@/lib/services/settings";
|
import { getSetting } from "@/lib/services/settings";
|
||||||
import { APP_VERSION, GIT_COMMIT } from "@/lib/version";
|
import { APP_VERSION, GIT_COMMIT } from "@/lib/version";
|
||||||
import { AccountSection } from "./_components/account-section";
|
import { AccountSection } from "./_components/account-section";
|
||||||
|
import { BackupSection } from "./_components/backup-section";
|
||||||
import { IntegrationsSection } from "./_components/integrations-section";
|
import { IntegrationsSection } from "./_components/integrations-section";
|
||||||
import { ServerSection } from "./_components/server-section";
|
import { ServerSection } from "./_components/server-section";
|
||||||
import { SettingsShell } from "./_components/settings-shell";
|
import { SettingsShell } from "./_components/settings-shell";
|
||||||
@@ -53,6 +57,14 @@ export default async function SettingsPage() {
|
|||||||
? getSetting("registrationOpen") === "true"
|
? getSetting("registrationOpen") === "true"
|
||||||
: false;
|
: 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";
|
const repoUrl = "https://github.com/jakejarvis/sofa";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,7 +109,31 @@ export default async function SettingsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<IntegrationsSection initialConnections={connections} />
|
<IntegrationsSection initialConnections={connections} />
|
||||||
{isAdmin && <ServerSection initialRegistrationOpen={registrationOpen} />}
|
{isAdmin && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<IconServerCog size={16} className="text-muted-foreground" />
|
||||||
|
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
Server
|
||||||
|
</h2>
|
||||||
|
<span className="rounded-md bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
|
||||||
|
Admin only
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Card className="border-l-2 border-l-primary/30">
|
||||||
|
<ServerSection initialRegistrationOpen={registrationOpen} />
|
||||||
|
</Card>
|
||||||
|
<Card className="border-l-2 border-l-primary/30">
|
||||||
|
<BackupSection
|
||||||
|
initialBackups={backups}
|
||||||
|
initialScheduledEnabled={scheduledBackupsEnabled}
|
||||||
|
initialMaxRetention={maxBackupRetention}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</SettingsShell>
|
</SettingsShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -100,7 +100,8 @@
|
|||||||
|
|
||||||
/* https://ui.shadcn.com/docs/components/radix/button#cursor */
|
/* https://ui.shadcn.com/docs/components/radix/button#cursor */
|
||||||
button:not(:disabled),
|
button:not(:disabled),
|
||||||
[role="button"]:not(:disabled) {
|
[role="button"]:not(:disabled),
|
||||||
|
[role="switch"]:not(:disabled) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,14 +16,14 @@ function Switch({
|
|||||||
data-slot="switch"
|
data-slot="switch"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SwitchPrimitive.Thumb
|
<SwitchPrimitive.Thumb
|
||||||
data-slot="switch-thumb"
|
data-slot="switch-thumb"
|
||||||
className="bg-background dark:data-unchecked:bg-foreground dark:data-checked:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-3.5 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0"
|
className="bg-background dark:data-unchecked:bg-foreground dark:data-checked:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-3.5 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-1px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-1px)] group-data-[size=default]/switch:data-unchecked:translate-x-px group-data-[size=sm]/switch:data-unchecked:translate-x-px"
|
||||||
/>
|
/>
|
||||||
</SwitchPrimitive.Root>
|
</SwitchPrimitive.Root>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export async function register() {
|
|||||||
ensureImageDirs();
|
ensureImageDirs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure backup directory exists
|
||||||
|
const { ensureBackupDir } = await import("@/lib/services/backup");
|
||||||
|
ensureBackupDir();
|
||||||
|
|
||||||
// Run database migrations on startup
|
// Run database migrations on startup
|
||||||
const { runMigrations } = await import("@/lib/db/migrate");
|
const { runMigrations } = await import("@/lib/db/migrate");
|
||||||
runMigrations();
|
runMigrations();
|
||||||
|
|||||||
+22
@@ -9,6 +9,11 @@ import {
|
|||||||
} from "@/lib/db/schema";
|
} from "@/lib/db/schema";
|
||||||
import { createLogger } from "@/lib/logger";
|
import { createLogger } from "@/lib/logger";
|
||||||
import { refreshAvailability } from "@/lib/services/availability";
|
import { refreshAvailability } from "@/lib/services/availability";
|
||||||
|
import {
|
||||||
|
createBackup,
|
||||||
|
ensureBackupDir,
|
||||||
|
pruneBackups,
|
||||||
|
} from "@/lib/services/backup";
|
||||||
import {
|
import {
|
||||||
cacheEpisodeStills,
|
cacheEpisodeStills,
|
||||||
cacheImagesForTitle,
|
cacheImagesForTitle,
|
||||||
@@ -20,6 +25,7 @@ import {
|
|||||||
refreshTitle,
|
refreshTitle,
|
||||||
refreshTvChildren,
|
refreshTvChildren,
|
||||||
} from "@/lib/services/metadata";
|
} from "@/lib/services/metadata";
|
||||||
|
import { getSetting } from "@/lib/services/settings";
|
||||||
import { getTvDetails } from "@/lib/tmdb/client";
|
import { getTvDetails } from "@/lib/tmdb/client";
|
||||||
|
|
||||||
const log = createLogger("cron");
|
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() {
|
export function startJobs() {
|
||||||
if (jobs.size > 0) return;
|
if (jobs.size > 0) return;
|
||||||
|
|
||||||
|
schedule("scheduledBackup", "0 2 * * *", scheduledBackupJob);
|
||||||
schedule("nightlyRefreshLibrary", "0 3 * * *", nightlyRefreshLibrary);
|
schedule("nightlyRefreshLibrary", "0 3 * * *", nightlyRefreshLibrary);
|
||||||
schedule("refreshAvailability", "0 */6 * * *", refreshAvailabilityJob);
|
schedule("refreshAvailability", "0 */6 * * *", refreshAvailabilityJob);
|
||||||
schedule("refreshRecommendations", "0 */12 * * *", refreshRecommendationsJob);
|
schedule("refreshRecommendations", "0 */12 * * *", refreshRecommendationsJob);
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ export const db = new Proxy({} as ReturnType<typeof drizzle>, {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Close the current connection, and clear singletons so the Proxy re-initializes on next access. */
|
||||||
export function closeDatabase() {
|
export function closeDatabase() {
|
||||||
globalForDb._client?.close();
|
globalForDb._client?.close();
|
||||||
|
globalForDb._client = undefined;
|
||||||
|
globalForDb._db = undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Buffer | null> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user