2026-07-06 11:06:05 -05:00
|
|
|
// import required so Node type-strips this .cts (it has no other import).
|
|
|
|
|
import type { Db } from '../types/db';
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
const crypto = require('crypto');
|
|
|
|
|
const fs = require('fs');
|
|
|
|
|
const path = require('path');
|
|
|
|
|
const Database = require('better-sqlite3');
|
refactor(server): migrate middleware, workers, and db layer to TypeScript (.cts)
- middleware/ (5): requireAuth, securityHeaders, errorFormatter, csrf,
rateLimiter — fully typed against the shared http Req/Res/Next types.
- workers/dailyWorker — fully typed.
- db/ (4): database, subscriptionCatalogSeed, migrations/versionedMigrations,
migrations/legacyReconcileMigrations — large dynamic schema/migration infra,
converted with @ts-nocheck (typing deferred). All requires updated to .cts.
Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 11:34:08 -05:00
|
|
|
const { closeDb, getDb, getDbPath } = require('../db/database.cts');
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
const BACKUP_DIR = path.resolve(
|
2026-07-06 14:23:53 -05:00
|
|
|
process.env.BACKUP_PATH || path.join(path.dirname(getDbPath()), '..', 'backups'),
|
2026-05-03 19:51:57 -05:00
|
|
|
);
|
2026-07-06 14:23:53 -05:00
|
|
|
const BACKUP_ID_RE =
|
|
|
|
|
/^(?:bill-tracker-backup|pre-restore|imported-backup|scheduled-backup)-\d{8}-\d{6}-\d{3}Z-[a-f0-9]{8}\.sqlite$/;
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-05-09 13:03:36 -05:00
|
|
|
// ─── Helper Functions ─────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-07-06 11:06:05 -05:00
|
|
|
function ensureBackupDir(): void {
|
2026-05-03 19:51:57 -05:00
|
|
|
fs.mkdirSync(BACKUP_DIR, { recursive: true, mode: 0o700 });
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 11:06:05 -05:00
|
|
|
function timestampPart(date: Date = new Date()): string {
|
2026-07-06 14:23:53 -05:00
|
|
|
return date
|
|
|
|
|
.toISOString()
|
2026-05-03 19:51:57 -05:00
|
|
|
.replace(/[-:]/g, '')
|
|
|
|
|
.replace('T', '-')
|
|
|
|
|
.replace(/\.\d{3}Z$/, `-${String(date.getMilliseconds()).padStart(3, '0')}Z`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 11:06:05 -05:00
|
|
|
function makeBackupId(prefix = 'bill-tracker-backup'): string {
|
2026-05-03 19:51:57 -05:00
|
|
|
return `${prefix}-${timestampPart()}-${crypto.randomBytes(4).toString('hex')}.sqlite`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 11:06:05 -05:00
|
|
|
function assertValidBackupId(id: unknown): string {
|
2026-05-03 19:51:57 -05:00
|
|
|
if (
|
|
|
|
|
typeof id !== 'string' ||
|
|
|
|
|
id.includes('/') ||
|
|
|
|
|
id.includes('\\') ||
|
|
|
|
|
path.isAbsolute(id) ||
|
|
|
|
|
!BACKUP_ID_RE.test(id)
|
|
|
|
|
) {
|
2026-07-06 11:06:05 -05:00
|
|
|
const err = new Error('Invalid backup id') as any;
|
2026-05-03 19:51:57 -05:00
|
|
|
err.status = 400;
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
|
|
|
|
return id;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 11:06:05 -05:00
|
|
|
function backupPathForId(id: string): string {
|
2026-05-03 19:51:57 -05:00
|
|
|
assertValidBackupId(id);
|
|
|
|
|
ensureBackupDir();
|
|
|
|
|
const resolved = path.resolve(BACKUP_DIR, id);
|
|
|
|
|
const relative = path.relative(BACKUP_DIR, resolved);
|
|
|
|
|
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
2026-07-06 11:06:05 -05:00
|
|
|
const err = new Error('Invalid backup id') as any;
|
2026-05-03 19:51:57 -05:00
|
|
|
err.status = 400;
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
|
|
|
|
return resolved;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 11:06:05 -05:00
|
|
|
/** Generates SHA-256 checksum for a file. */
|
|
|
|
|
function checksumFile(filePath: string): string {
|
2026-05-03 19:51:57 -05:00
|
|
|
const hash = crypto.createHash('sha256');
|
|
|
|
|
hash.update(fs.readFileSync(filePath));
|
|
|
|
|
return hash.digest('hex');
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 11:06:05 -05:00
|
|
|
/** Validates a backup file's SHA-256 checksum. */
|
|
|
|
|
function validateChecksum(filePath: string, expectedChecksum: unknown): boolean {
|
2026-05-09 13:03:36 -05:00
|
|
|
if (typeof expectedChecksum !== 'string' || !/^[a-f0-9]{64}$/i.test(expectedChecksum)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-07-06 11:06:05 -05:00
|
|
|
|
2026-05-09 13:03:36 -05:00
|
|
|
const actualChecksum = checksumFile(filePath);
|
|
|
|
|
return actualChecksum.toLowerCase() === expectedChecksum.toLowerCase();
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 11:06:05 -05:00
|
|
|
function cleanupSqliteSidecars(filePath: string): void {
|
2026-05-03 19:51:57 -05:00
|
|
|
for (const suffix of ['-wal', '-shm']) {
|
|
|
|
|
try {
|
|
|
|
|
const sidecar = `${filePath}${suffix}`;
|
|
|
|
|
if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar);
|
|
|
|
|
} catch {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 11:06:05 -05:00
|
|
|
function metadataForFile(filePath: string): any {
|
2026-05-03 19:51:57 -05:00
|
|
|
const id = path.basename(filePath);
|
|
|
|
|
assertValidBackupId(id);
|
|
|
|
|
const stat = fs.statSync(filePath);
|
|
|
|
|
const type = id.startsWith('pre-restore-')
|
|
|
|
|
? 'pre-restore'
|
|
|
|
|
: id.startsWith('imported-backup-')
|
|
|
|
|
? 'imported'
|
|
|
|
|
: id.startsWith('scheduled-backup-')
|
|
|
|
|
? 'scheduled'
|
|
|
|
|
: 'manual';
|
|
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
filename: id,
|
|
|
|
|
type,
|
|
|
|
|
created_at: stat.birthtime.toISOString(),
|
|
|
|
|
modified_at: stat.mtime.toISOString(),
|
|
|
|
|
size_bytes: stat.size,
|
|
|
|
|
checksum: checksumFile(filePath),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 11:06:05 -05:00
|
|
|
function listBackups(): any[] {
|
2026-05-03 19:51:57 -05:00
|
|
|
ensureBackupDir();
|
2026-07-06 14:23:53 -05:00
|
|
|
return fs
|
|
|
|
|
.readdirSync(BACKUP_DIR)
|
2026-07-06 11:06:05 -05:00
|
|
|
.filter((name: string) => BACKUP_ID_RE.test(name))
|
|
|
|
|
.map((name: string) => {
|
2026-05-03 19:51:57 -05:00
|
|
|
const filePath = backupPathForId(name);
|
|
|
|
|
if (!fs.statSync(filePath).isFile()) return null;
|
|
|
|
|
return metadataForFile(filePath);
|
|
|
|
|
})
|
|
|
|
|
.filter(Boolean)
|
2026-07-06 11:06:05 -05:00
|
|
|
.sort((a: any, b: any) => b.modified_at.localeCompare(a.modified_at));
|
2026-05-03 19:51:57 -05:00
|
|
|
}
|
|
|
|
|
|
2026-07-06 11:06:05 -05:00
|
|
|
function validateSqliteDatabase(filePath: string): void {
|
|
|
|
|
let db: any;
|
2026-05-03 19:51:57 -05:00
|
|
|
try {
|
|
|
|
|
db = new Database(filePath, { readonly: true, fileMustExist: true });
|
|
|
|
|
const result = db.pragma('integrity_check', { simple: true });
|
|
|
|
|
if (result !== 'ok') {
|
2026-07-06 11:06:05 -05:00
|
|
|
const err = new Error('Backup failed SQLite integrity check') as any;
|
2026-05-03 19:51:57 -05:00
|
|
|
err.status = 400;
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
|
|
|
|
db.prepare('SELECT name FROM sqlite_master LIMIT 1').get();
|
2026-07-06 11:06:05 -05:00
|
|
|
} catch (err: any) {
|
2026-05-03 19:51:57 -05:00
|
|
|
if (err.status) throw err;
|
2026-07-06 11:06:05 -05:00
|
|
|
const safeErr = new Error('Backup is not a valid SQLite database') as any;
|
2026-05-03 19:51:57 -05:00
|
|
|
safeErr.status = 400;
|
|
|
|
|
throw safeErr;
|
|
|
|
|
} finally {
|
|
|
|
|
if (db) db.close();
|
|
|
|
|
cleanupSqliteSidecars(filePath);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 11:06:05 -05:00
|
|
|
async function createBackup(prefix = 'bill-tracker-backup'): Promise<any> {
|
2026-05-03 19:51:57 -05:00
|
|
|
ensureBackupDir();
|
|
|
|
|
const db = getDb();
|
|
|
|
|
db.pragma('wal_checkpoint(TRUNCATE)');
|
|
|
|
|
|
|
|
|
|
const id = makeBackupId(prefix);
|
|
|
|
|
const finalPath = backupPathForId(id);
|
|
|
|
|
const tempPath = `${finalPath}.partial`;
|
|
|
|
|
|
|
|
|
|
if (fs.existsSync(finalPath) || fs.existsSync(tempPath)) {
|
2026-07-06 11:06:05 -05:00
|
|
|
const err = new Error('Backup file already exists') as any;
|
2026-05-03 19:51:57 -05:00
|
|
|
err.status = 409;
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await db.backup(tempPath);
|
|
|
|
|
validateSqliteDatabase(tempPath);
|
|
|
|
|
fs.renameSync(tempPath, finalPath);
|
|
|
|
|
fs.chmodSync(finalPath, 0o600);
|
2026-05-30 21:20:51 -05:00
|
|
|
return metadataForFile(finalPath);
|
2026-05-03 19:51:57 -05:00
|
|
|
} catch (err) {
|
2026-07-06 14:23:53 -05:00
|
|
|
try {
|
|
|
|
|
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
|
|
|
|
} catch {}
|
2026-05-03 19:51:57 -05:00
|
|
|
cleanupSqliteSidecars(tempPath);
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 11:06:05 -05:00
|
|
|
async function importBackupBuffer(buffer: any, options: any = {}): Promise<any> {
|
2026-05-03 19:51:57 -05:00
|
|
|
ensureBackupDir();
|
|
|
|
|
|
|
|
|
|
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
|
2026-07-06 11:06:05 -05:00
|
|
|
const err = new Error('Backup upload is required') as any;
|
2026-05-03 19:51:57 -05:00
|
|
|
err.status = 400;
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const id = makeBackupId('imported-backup');
|
|
|
|
|
const finalPath = backupPathForId(id);
|
|
|
|
|
const tempPath = `${finalPath}.upload`;
|
|
|
|
|
|
|
|
|
|
if (fs.existsSync(finalPath) || fs.existsSync(tempPath)) {
|
2026-07-06 11:06:05 -05:00
|
|
|
const err = new Error('Backup file already exists') as any;
|
2026-05-03 19:51:57 -05:00
|
|
|
err.status = 409;
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
fs.writeFileSync(tempPath, buffer, { flag: 'wx', mode: 0o600 });
|
2026-07-06 11:06:05 -05:00
|
|
|
|
2026-05-09 13:03:36 -05:00
|
|
|
// SHA-256 checksum validation
|
|
|
|
|
const providedChecksum = options.expectedChecksum;
|
|
|
|
|
if (providedChecksum) {
|
|
|
|
|
if (!validateChecksum(tempPath, providedChecksum)) {
|
|
|
|
|
fs.unlinkSync(tempPath);
|
|
|
|
|
cleanupSqliteSidecars(tempPath);
|
2026-07-06 11:06:05 -05:00
|
|
|
const err = new Error('Backup integrity verification failed: checksum mismatch') as any;
|
2026-05-09 13:03:36 -05:00
|
|
|
err.status = 400;
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-06 11:06:05 -05:00
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
validateSqliteDatabase(tempPath);
|
|
|
|
|
fs.renameSync(tempPath, finalPath);
|
|
|
|
|
fs.chmodSync(finalPath, 0o600);
|
|
|
|
|
return metadataForFile(finalPath);
|
|
|
|
|
} catch (err) {
|
2026-07-06 14:23:53 -05:00
|
|
|
try {
|
|
|
|
|
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
|
|
|
|
} catch {}
|
2026-05-03 19:51:57 -05:00
|
|
|
cleanupSqliteSidecars(tempPath);
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 11:06:05 -05:00
|
|
|
function getBackupFile(id: string): { path: string; metadata: any } {
|
2026-05-03 19:51:57 -05:00
|
|
|
const filePath = backupPathForId(id);
|
|
|
|
|
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
2026-07-06 11:06:05 -05:00
|
|
|
const err = new Error('Backup not found') as any;
|
2026-05-03 19:51:57 -05:00
|
|
|
err.status = 404;
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
path: filePath,
|
|
|
|
|
metadata: metadataForFile(filePath),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 11:06:05 -05:00
|
|
|
function deleteBackup(id: string) {
|
2026-05-03 19:51:57 -05:00
|
|
|
const backup = getBackupFile(id);
|
|
|
|
|
fs.unlinkSync(backup.path);
|
|
|
|
|
cleanupSqliteSidecars(backup.path);
|
|
|
|
|
return { deleted: true, id: backup.metadata.id, deleted_at: new Date().toISOString() };
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 11:06:05 -05:00
|
|
|
function applyRetention(retentionCount: any, options: any = {}) {
|
2026-05-03 19:51:57 -05:00
|
|
|
const keep = parseInt(retentionCount, 10);
|
|
|
|
|
if (!Number.isInteger(keep) || keep < 1) return { deleted: [] };
|
|
|
|
|
|
2026-05-30 21:20:51 -05:00
|
|
|
const type = options.type || null;
|
|
|
|
|
const backups = type
|
2026-07-06 11:06:05 -05:00
|
|
|
? listBackups().filter((backup: any) => backup.type === type)
|
2026-05-30 21:20:51 -05:00
|
|
|
: listBackups();
|
|
|
|
|
|
|
|
|
|
// listBackups() is already sorted newest-first; delete everything beyond `keep`.
|
|
|
|
|
const toDelete = backups.slice(keep);
|
2026-07-06 11:06:05 -05:00
|
|
|
const deleted: any[] = [];
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
for (const backup of toDelete) {
|
|
|
|
|
try {
|
|
|
|
|
deleted.push(deleteBackup(backup.id).id);
|
|
|
|
|
} catch {
|
2026-05-14 01:17:05 -05:00
|
|
|
// Retention should never cause a backup operation to fail.
|
2026-05-03 19:51:57 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { deleted };
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 11:06:05 -05:00
|
|
|
function applyScheduledRetention(retentionCount: any) {
|
2026-05-30 21:20:51 -05:00
|
|
|
return applyRetention(retentionCount, { type: 'scheduled' });
|
|
|
|
|
}
|
2026-05-14 01:17:05 -05:00
|
|
|
|
2026-07-06 11:06:05 -05:00
|
|
|
async function restoreBackup(id: string): Promise<any> {
|
2026-05-03 19:51:57 -05:00
|
|
|
const source = getBackupFile(id);
|
|
|
|
|
validateSqliteDatabase(source.path);
|
|
|
|
|
|
|
|
|
|
const preRestoreBackup = await createBackup('pre-restore');
|
|
|
|
|
const livePath = path.resolve(getDbPath());
|
|
|
|
|
const liveDir = path.dirname(livePath);
|
2026-07-06 14:23:53 -05:00
|
|
|
const tempRestorePath = path.join(
|
|
|
|
|
liveDir,
|
|
|
|
|
`.restore-${Date.now()}-${crypto.randomBytes(4).toString('hex')}.sqlite`,
|
|
|
|
|
);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
fs.copyFileSync(source.path, tempRestorePath, fs.constants.COPYFILE_EXCL);
|
|
|
|
|
validateSqliteDatabase(tempRestorePath);
|
|
|
|
|
|
|
|
|
|
closeDb();
|
|
|
|
|
fs.renameSync(tempRestorePath, livePath);
|
|
|
|
|
|
|
|
|
|
for (const suffix of ['-wal', '-shm']) {
|
|
|
|
|
try {
|
|
|
|
|
const sidecar = `${livePath}${suffix}`;
|
|
|
|
|
if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar);
|
|
|
|
|
} catch {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getDb();
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
restored_from: source.metadata.id,
|
|
|
|
|
pre_restore_backup: preRestoreBackup.id,
|
|
|
|
|
restored_at: new Date().toISOString(),
|
|
|
|
|
restart_required: false,
|
|
|
|
|
};
|
|
|
|
|
} catch (err) {
|
2026-07-06 14:23:53 -05:00
|
|
|
try {
|
|
|
|
|
if (fs.existsSync(tempRestorePath)) fs.unlinkSync(tempRestorePath);
|
|
|
|
|
} catch {}
|
2026-05-03 19:51:57 -05:00
|
|
|
cleanupSqliteSidecars(tempRestorePath);
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = {
|
|
|
|
|
BACKUP_DIR,
|
|
|
|
|
assertValidBackupId,
|
2026-05-14 01:17:05 -05:00
|
|
|
applyRetention,
|
2026-05-03 19:51:57 -05:00
|
|
|
applyScheduledRetention,
|
|
|
|
|
createBackup,
|
|
|
|
|
deleteBackup,
|
|
|
|
|
getBackupFile,
|
|
|
|
|
importBackupBuffer,
|
|
|
|
|
listBackups,
|
|
|
|
|
restoreBackup,
|
2026-05-09 13:03:36 -05:00
|
|
|
checksumFile,
|
|
|
|
|
validateChecksum,
|
2026-05-03 19:51:57 -05:00
|
|
|
};
|