Files
study-bible-project/server/backups.js
nmemmertandClaude Sonnet 5.5 f274bc9c8a
Build and Push Docker Image / build-and-push (push) Successful in 7m14s
Harden sync and security; add IQ Bible, templates, password reset, backups
Sync and data safety
- Autosave flushes on leaving a study or the tab; storage-full errors are reported
- Unsynced studies, deletes and ink are queued and retried when back online
- Deletes propagate across devices (with a "Keep it" undo) and no longer resurrect
- Server assigns study versions (no client-clock conflicts); "Combine both"
  merges a conflicted study chunk by chunk
- Reading-plan progress made offline is merged, not overwritten; plans, ink and
  marks are cleared from the device on sign-out

Security
- Block SSRF through the ntfy reminder URL; failure-only rate limiting per IP and
  account; constant-time login; TOTP replay protection; input validation;
  SESSION_SECRET required in production; admin user delete fixed
- Forgot password: one-time code sent to the account's ntfy topic

Features
- IQ Bible proxy (route allow-list, text-only caching per its terms): more
  commentaries, lexicons, study aids, daily verse and devotional, plan calendar
  export, narrations, podcast background
- Study templates, reading-plan catch-up, account export, daily database snapshots

Tests and tooling
- ESLint config; App tests split into src/test; tests for sync queues, library
  hook, merge, templates, IQ Bible UI and reset form

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 08:13:36 -04:00

46 lines
2.0 KiB
JavaScript

// Automatic daily snapshots of the whole database (every account), kept next to it in DATA_DIR/backups.
// A snapshot is a complete, consistent copy made while the app keeps running, and is restored with
// scripts/restore-data.sh. BACKUP_KEEP sets how many to keep (default 7); BACKUP_DISABLED=1 turns it off.
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'fs';
import { join } from 'path';
import { backupDatabase, dataDir } from './db.js';
const DAY = 24 * 60 * 60 * 1000;
export const backupDir = () => join(dataDir(), 'backups');
const stamp = (d) => d.toISOString().replace(/[:T]/g, '-').slice(0, 16);
const isSnapshot = (name) => /^projects-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}\.db$/.test(name);
export function listSnapshots(dir = backupDir()) {
if (!existsSync(dir)) return [];
return readdirSync(dir).filter(isSnapshot).sort().reverse()
.map((name) => ({ name, bytes: statSync(join(dir, name)).size, at: statSync(join(dir, name)).mtimeMs }));
}
// Deletes all but the newest `keep` snapshots; returns the names removed.
export function pruneSnapshots(keep = 7, dir = backupDir()) {
const old = listSnapshots(dir).slice(Math.max(1, keep));
old.forEach((s) => unlinkSync(join(dir, s.name)));
return old.map((s) => s.name);
}
export async function runBackup(now = new Date()) {
const dir = backupDir();
mkdirSync(dir, { recursive: true });
const file = join(dir, `projects-${stamp(now)}.db`);
await backupDatabase(file);
pruneSnapshots(Number(process.env.BACKUP_KEEP) || 7, dir);
return file;
}
// Takes a snapshot when the newest is more than a day old (checked hourly, and once at start-up).
export function startBackupScheduler() {
if (process.env.BACKUP_DISABLED === '1') return;
const tick = () => {
const latest = listSnapshots()[0];
if (latest && Date.now() - latest.at < DAY) return;
runBackup().then((f) => console.log(`Database snapshot saved: ${f}`)).catch((err) => console.error('Database snapshot failed:', err.message));
};
setTimeout(tick, 30_000).unref();
setInterval(tick, 60 * 60 * 1000).unref();
}