44 lines
3.0 KiB
TypeScript
44 lines
3.0 KiB
TypeScript
import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import Database from 'better-sqlite3';
|
|
import type { DatabaseConnection } from './db';
|
|
import { ContentError } from './content';
|
|
import { withContentLock } from './write-lock';
|
|
|
|
function separatePaths(source: string, target: string) {
|
|
const a = path.resolve(source); const b = path.resolve(target);
|
|
if (a === b || a.startsWith(b + path.sep) || b.startsWith(a + path.sep)) throw new ContentError('Backup and data directories must be separate, non-overlapping paths');
|
|
return { source: a, target: b };
|
|
}
|
|
export async function backup(connection: DatabaseConnection, dataDir: string, targetDir: string) {
|
|
const { source, target } = separatePaths(dataDir, targetDir);
|
|
if (existsSync(target)) throw new ContentError('Choose a new backup directory');
|
|
return withContentLock(async () => {
|
|
mkdirSync(target, { recursive: true, mode: 0o700 });
|
|
await connection.sqlite.backup(path.join(target, 'portfolio.sqlite'));
|
|
if (existsSync(path.join(source, 'uploads'))) cpSync(path.join(source, 'uploads'), path.join(target, 'uploads'), { recursive: true });
|
|
if (existsSync(path.join(source, 'auth-secret'))) cpSync(path.join(source, 'auth-secret'), path.join(target, 'auth-secret'));
|
|
writeFileSync(path.join(target, 'manifest.json'), JSON.stringify({ format: 1, createdAt: new Date().toISOString() }) + '\n', { mode: 0o600 });
|
|
}, source);
|
|
}
|
|
export function restore(sourceDir: string, targetDir: string) {
|
|
const { source, target } = separatePaths(sourceDir, targetDir);
|
|
// Restore into a NEW directory. The operator swaps volumes while the app is stopped.
|
|
// Never overwrite an open SQLite database or recursively delete user data.
|
|
if (existsSync(target)) throw new ContentError('Restore requires a new, empty destination path');
|
|
const manifest = JSON.parse(readFileSync(path.join(source, 'manifest.json'), 'utf8'));
|
|
if (manifest.format !== 1) throw new ContentError('Unsupported backup format');
|
|
const check = new Database(path.join(source, 'portfolio.sqlite'), { readonly: true, fileMustExist: true });
|
|
try {
|
|
if (check.pragma('integrity_check', { simple: true }) !== 'ok') throw new ContentError('Database integrity check failed');
|
|
const rows = check.prepare('SELECT key FROM media').all() as { key: string }[];
|
|
if (rows.some(row => !/^[0-9a-f-]{36}\.(pdf|webp)$/.test(row.key) || !existsSync(path.join(source, 'uploads', row.key)))) throw new ContentError('Backup is missing uploaded files');
|
|
} finally { check.close(); }
|
|
const staging = `${target}.restore-${Date.now()}`;
|
|
mkdirSync(staging, { recursive: true, mode: 0o700 });
|
|
for (const name of ['portfolio.sqlite', 'uploads', 'auth-secret']) if (existsSync(path.join(source, name))) cpSync(path.join(source, name), path.join(staging, name), { recursive: true });
|
|
const restored = new Database(path.join(staging, 'portfolio.sqlite'));
|
|
try { restored.prepare('DELETE FROM session').run(); } finally { restored.close(); }
|
|
renameSync(staging, target);
|
|
}
|