Harden backup service with locking, validation, and atomic restore

Serialize all backup operations through a promise-based queue
(`withBackupLock`) to prevent race conditions under concurrent
requests. Extract internal helpers so `pruneBackups` and
`deleteBackup` can participate in the same lock.

Strengthen `restoreFromBackup`: write the temp file alongside the
live database (`dbDir`), validate it with a full integrity check,
foreign key check, and a required-tables list before touching the
live DB, then use a synchronous `renameSync` for an atomic swap.
Clear WAL/SHM files synchronously in the same tick to avoid
interleaving. Run migrations after restore so older backups are
brought up-to-date automatically.

Add milliseconds to all backup timestamps (`HHmmssSSS`) to avoid
filename collisions when backups are created in rapid succession.
Update filename regexes to accept both the old and new formats.
This commit is contained in:
2026-03-05 12:58:08 -05:00
parent 704aca1103
commit ff3c8a737b
+122 -46
View File
@@ -1,9 +1,11 @@
import { Database } from "bun:sqlite"; import { Database } from "bun:sqlite";
import { renameSync, unlinkSync } from "node:fs";
import { mkdir, readdir } from "node:fs/promises"; import { mkdir, readdir } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { format } from "date-fns"; import { format } from "date-fns";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { closeDatabase, db } from "@/lib/db/client"; import { closeDatabase, db } from "@/lib/db/client";
import { runMigrations } from "@/lib/db/migrate";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
const log = createLogger("backup"); const log = createLogger("backup");
@@ -13,11 +15,14 @@ const DATABASE_URL =
process.env.DATABASE_URL || path.join(DATA_DIR, "sqlite.db"); process.env.DATABASE_URL || path.join(DATA_DIR, "sqlite.db");
const BACKUP_DIR = path.join(DATA_DIR, "backups"); const BACKUP_DIR = path.join(DATA_DIR, "backups");
const MANUAL_PATTERN = /^sofa-manual-\d{4}-\d{2}-\d{2}-\d{6}\.db$/; const MANUAL_PATTERN = /^sofa-manual-\d{4}-\d{2}-\d{2}-\d{6}(?:\d{3})?\.db$/;
const SCHEDULED_PATTERN = /^sofa-scheduled-\d{4}-\d{2}-\d{2}-\d{6}\.db$/; const SCHEDULED_PATTERN =
const PRE_RESTORE_PATTERN = /^pre-restore-\d{4}-\d{2}-\d{2}-\d{6}\.db$/; /^sofa-scheduled-\d{4}-\d{2}-\d{2}-\d{6}(?:\d{3})?\.db$/;
const PRE_RESTORE_PATTERN =
/^pre-restore-\d{4}-\d{2}-\d{2}-\d{6}(?:\d{3})?\.db$/;
export type BackupSource = "manual" | "scheduled" | "pre-restore"; export type BackupSource = "manual" | "scheduled" | "pre-restore";
type BackupPrefix = "sofa-manual" | "sofa-scheduled" | "pre-restore";
export interface BackupInfo { export interface BackupInfo {
filename: string; filename: string;
@@ -26,6 +31,28 @@ export interface BackupInfo {
source: BackupSource; source: BackupSource;
} }
const REQUIRED_TABLES = [
"account",
"appSettings",
"availabilityOffers",
"cronRuns",
"episodes",
"seasons",
"session",
"titleRecommendations",
"titles",
"user",
"userEpisodeWatches",
"userMovieWatches",
"userRatings",
"userTitleStatus",
"verification",
"webhookConnections",
"webhookEventLog",
] as const;
let backupOpQueue: Promise<void> = Promise.resolve();
function getBackupSource(filename: string): BackupSource { function getBackupSource(filename: string): BackupSource {
if (SCHEDULED_PATTERN.test(filename)) return "scheduled"; if (SCHEDULED_PATTERN.test(filename)) return "scheduled";
if (PRE_RESTORE_PATTERN.test(filename)) return "pre-restore"; if (PRE_RESTORE_PATTERN.test(filename)) return "pre-restore";
@@ -44,6 +71,64 @@ export async function ensureBackupDir() {
await mkdir(BACKUP_DIR, { recursive: true }); await mkdir(BACKUP_DIR, { recursive: true });
} }
async function withBackupLock<T>(fn: () => Promise<T>): Promise<T> {
const previous = backupOpQueue;
let release: (() => void) | undefined;
backupOpQueue = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
try {
return await fn();
} finally {
release?.();
}
}
function unlinkIfExistsSync(filePath: string): void {
try {
unlinkSync(filePath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err;
}
}
}
function validateBackupDatabase(filePath: string): void {
const testDb = new Database(filePath, { readonly: true });
try {
const integrityRows = testDb.query("PRAGMA integrity_check").all() as {
integrity_check: string;
}[];
if (
integrityRows.length === 0 ||
integrityRows.some((row) => row.integrity_check !== "ok")
) {
throw new Error("Database integrity check failed");
}
const foreignKeyErrors = testDb.query("PRAGMA foreign_key_check").all();
if (foreignKeyErrors.length > 0) {
throw new Error("Database foreign key check failed");
}
const tableRows = testDb
.query("SELECT name FROM sqlite_master WHERE type='table'")
.all() as { name: string }[];
const tableSet = new Set(tableRows.map((row) => row.name));
const missing = REQUIRED_TABLES.filter((table) => !tableSet.has(table));
if (missing.length > 0) {
throw new Error(
`Invalid backup: missing required tables (${missing.join(", ")})`,
);
}
} finally {
testDb.close();
}
}
function isValidBackupFilename(filename: string): boolean { function isValidBackupFilename(filename: string): boolean {
const base = path.basename(filename); const base = path.basename(filename);
return ( return (
@@ -51,12 +136,10 @@ function isValidBackupFilename(filename: string): boolean {
); );
} }
export async function createBackup( async function createBackupInternal(prefix: BackupPrefix): Promise<BackupInfo> {
prefix = "sofa-manual",
): Promise<BackupInfo> {
await ensureBackupDir(); await ensureBackupDir();
const timestamp = format(new Date(), "yyyy-MM-dd-HHmmss"); const timestamp = format(new Date(), "yyyy-MM-dd-HHmmssSSS");
const filename = `${prefix}-${timestamp}.db`; const filename = `${prefix}-${timestamp}.db`;
const dest = path.join(BACKUP_DIR, filename); const dest = path.join(BACKUP_DIR, filename);
@@ -74,6 +157,12 @@ export async function createBackup(
}; };
} }
export async function createBackup(
prefix: BackupPrefix = "sofa-manual",
): Promise<BackupInfo> {
return withBackupLock(async () => createBackupInternal(prefix));
}
export async function listBackups(): Promise<BackupInfo[]> { export async function listBackups(): Promise<BackupInfo[]> {
await ensureBackupDir(); await ensureBackupDir();
@@ -95,7 +184,7 @@ export async function listBackups(): Promise<BackupInfo[]> {
); );
} }
export async function deleteBackup(filename: string): Promise<void> { async function deleteBackupInternal(filename: string): Promise<void> {
if (!isValidBackupFilename(filename)) { if (!isValidBackupFilename(filename)) {
throw new Error("Invalid backup filename"); throw new Error("Invalid backup filename");
} }
@@ -109,6 +198,10 @@ export async function deleteBackup(filename: string): Promise<void> {
log.info(`Deleted backup: ${filename}`); log.info(`Deleted backup: ${filename}`);
} }
export async function deleteBackup(filename: string): Promise<void> {
await withBackupLock(async () => deleteBackupInternal(filename));
}
export async function getBackupPath(filename: string): Promise<string | null> { export async function getBackupPath(filename: string): Promise<string | null> {
if (!isValidBackupFilename(filename)) return null; if (!isValidBackupFilename(filename)) return null;
@@ -123,62 +216,44 @@ export async function readBackupFile(filename: string): Promise<Buffer | null> {
} }
export async function restoreFromBackup(buffer: Buffer): Promise<void> { export async function restoreFromBackup(buffer: Buffer): Promise<void> {
await withBackupLock(async () => {
await ensureBackupDir(); await ensureBackupDir();
const timestamp = format(new Date(), "yyyy-MM-dd-HHmmss"); const dbDir = path.dirname(DATABASE_URL);
const tempPath = path.join(BACKUP_DIR, `_restore-temp-${timestamp}.db`); await mkdir(dbDir, { recursive: true });
const timestamp = format(new Date(), "yyyy-MM-dd-HHmmssSSS");
const tempPath = path.join(
dbDir,
`.restore-temp-${timestamp}-${crypto.randomUUID()}.db`,
);
try { try {
// Write uploaded file to temp location
await Bun.write(tempPath, buffer); await Bun.write(tempPath, buffer);
validateBackupDatabase(tempPath);
// 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..."); log.info("Creating pre-restore safety backup...");
await createBackup("pre-restore"); await createBackupInternal("pre-restore");
// Replace the database // Keep the close+replace window synchronous to avoid event-loop interleaving.
log.info("Replacing database..."); log.info("Replacing database...");
closeDatabase(); closeDatabase();
await Bun.write(DATABASE_URL, Bun.file(tempPath)); renameSync(tempPath, DATABASE_URL);
unlinkIfExistsSync(`${DATABASE_URL}-wal`);
// Clean up WAL/SHM files from the old database unlinkIfExistsSync(`${DATABASE_URL}-shm`);
const walFile = Bun.file(`${DATABASE_URL}-wal`);
const shmFile = Bun.file(`${DATABASE_URL}-shm`);
if (await walFile.exists()) await walFile.delete();
if (await shmFile.exists()) await shmFile.delete();
// Ensure restored backups from older app versions are brought up-to-date.
runMigrations();
log.info("Database restored successfully"); log.info("Database restored successfully");
} finally { } finally {
// Clean up temp file
const tempFile = Bun.file(tempPath); const tempFile = Bun.file(tempPath);
if (await tempFile.exists()) await tempFile.delete(); if (await tempFile.exists()) await tempFile.delete();
} }
});
} }
export async function pruneBackups(maxKeep: number): Promise<void> { export async function pruneBackups(maxKeep: number): Promise<void> {
await withBackupLock(async () => {
const backups = (await listBackups()).filter((b) => const backups = (await listBackups()).filter((b) =>
SCHEDULED_PATTERN.test(b.filename), SCHEDULED_PATTERN.test(b.filename),
); );
@@ -187,8 +262,9 @@ export async function pruneBackups(maxKeep: number): Promise<void> {
const toDelete = backups.slice(maxKeep); const toDelete = backups.slice(maxKeep);
for (const backup of toDelete) { for (const backup of toDelete) {
await deleteBackup(backup.filename); await deleteBackupInternal(backup.filename);
} }
log.info(`Pruned ${toDelete.length} old backup(s), kept ${maxKeep}`); log.info(`Pruned ${toDelete.length} old backup(s), kept ${maxKeep}`);
});
} }