import { watch, type FSWatcher } from 'node:fs'; import { copyFile, mkdir, stat } from 'node:fs/promises'; import { join } from 'node:path'; import { Database } from 'bun:sqlite'; import { db } from 'officerdb/db'; import { fofosAdminSettings } from '../db/schema'; import { defaultBackupDir } from './backups'; /** * The contact inbox, and keeping a copy of it somewhere else. * * ── Why this is not part of the database backup ── * * Messages arrive whenever a visitor sends one — which, for a site that only changes when somebody * publishes, is the one thing happening between backups. So they live in their own SQLite file rather * than in Postgres, and this copies that file the moment it changes rather than on a schedule. * * The alternative is a window: a message that arrives at ten past nine and a disk that fails at twenty * past is a message nobody ever sees, and the sender believes they contacted you. * * ── The copy is CURRENT, not a history ── * * One `messages.db` at each destination, overwritten. History comes from the ordinary backup runs, * which take a timestamped copy alongside the dumps. Writing a new timestamped file here instead would * mean one file per message, which is a directory nobody can read and a rotation that evicts real * dumps to make room. * * ── WAL has to be checkpointed first, and this is the whole reason this is not a `cp` ── * * The site opens the mailbox in WAL mode, so a recently written message lives in `messages.db-wal` and * NOT yet in `messages.db`. Copying the main file alone gets a database that is intact, readable, and * missing exactly the message that triggered the copy. `wal_checkpoint(TRUNCATE)` folds the log in * first, so what is copied is everything. */ /** Long enough that a burst of writes is one copy; short enough that a message is protected in seconds. */ const SETTLE_MS = 3000; const FILE = 'messages.db'; export const messagesDir = (root: string): string => join(root, 'messages'); export const messagesFile = (root: string): string => join(messagesDir(root), FILE); /** * Fold the write-ahead log into the main file. * * Opened read-write, briefly, because a checkpoint is a write — and skipped without complaint if the * file is not there or is busy, since the next change will bring us back. */ function checkpoint(path: string): void { try { const handle = new Database(path); handle.exec('PRAGMA wal_checkpoint(TRUNCATE);'); handle.close(); } catch { // Locked, or not a database yet. The copy still happens; it is simply one message behind, and the // next write corrects it. } } /** Copy the mailbox to the local backup directory and, if one is set, to the machine off this one. */ export async function copyMailbox(): Promise<{ ok: boolean; output: string }> { const row = (await db.select().from(fofosAdminSettings).limit(1))[0] ?? null; const root = row?.rootPath; if (!root) return { ok: false, output: 'No root location is set.' }; const source = messagesFile(root); if (!(await stat(source).catch(() => null))) return { ok: false, output: 'No mailbox yet.' }; checkpoint(source); const dir = row?.backupDir ?? defaultBackupDir(); await mkdir(dir, { recursive: true }); await copyFile(source, join(dir, FILE)); const lines = [`${FILE} -> ${dir}`]; let ok = true; if (row?.backupSshHost && row.backupSshPath) { const proc = Bun.spawn( [ 'scp', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', '-o', 'StrictHostKeyChecking=accept-new', source, `${row.backupSshHost}:${row.backupSshPath}/${FILE}`, ], { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' }, ); const [stderr, code] = await Promise.all([new Response(proc.stderr).text(), proc.exited]); ok = code === 0; lines.push(ok ? `${FILE} -> ${row.backupSshHost}:${row.backupSshPath}` : `copy off failed: ${stderr.trim()}`); } return { ok, output: lines.join('\n') }; } let watcher: FSWatcher | null = null; let settle: ReturnType | null = null; /** * Watch the mailbox and copy it whenever it changes. * * The DIRECTORY is watched, not the file. SQLite replaces and recreates files during a checkpoint, and * a watch on an inode that has been replaced goes quiet for good — a watcher that silently stops is * worse than no watcher, because the panel would go on implying the inbox is protected. */ export async function watchMailbox(): Promise { watcher?.close(); watcher = null; if (settle) clearTimeout(settle); settle = null; const row = (await db.select().from(fofosAdminSettings).limit(1))[0] ?? null; if (!row?.rootPath) return; const dir = messagesDir(row.rootPath); await mkdir(dir, { recursive: true }); try { watcher = watch(dir, (_event, name) => { // `messages.db`, `messages.db-wal`, `messages.db-shm` — any of them means the mailbox moved. if (name && !name.startsWith(FILE)) return; if (settle) clearTimeout(settle); settle = setTimeout(() => void copyMailbox().catch(() => {}), SETTLE_MS); }); } catch { // No directory, no permission. Left unwatched rather than throwing: this runs at import, and the // plugin failing to load over a missing folder would take the panel that fixes it down too. watcher = null; } }