Files
hope-events/backend/src/utils/backupUtils.js
T
joshuaandClaude Sonnet 5 54b89d4f4b Add calendar export, SEO, error monitoring, backups, audit trail, and a starter test suite
Six site improvements picked from a "what could be better" review, plus a Jest
test suite covering the two areas with the trickiest money-handling history
in this project (early-bird pricing tranches, donation-leg accounting):

- "Add to calendar" .ics download on event pages and in confirmation emails
- sitemap.xml, robots.txt, and Open Graph/Twitter metadata for public pages
- Sentry error monitoring (backend + frontend), a no-op until SENTRY_DSN is set
- Nightly local pg_dump backups with a Site Settings tab to browse/trigger/download
- Admin audit trail for refunds, donations, manual registrations, event and
  settings changes, and staff-initiated cancellations
- Jest tests reproducing and guarding against the 1.8.0 tranche-pricing bug
  and the 1.4.2 donation-balance-inflation bug

Wallet passes (Google/Apple) were scoped out of this round — Apple Wallet
needs a paid Apple Developer account the project doesn't have yet, and the
user preferred shipping both together later rather than Google alone now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 14:50:11 +02:00

88 lines
3.5 KiB
JavaScript

const { spawn } = require('child_process');
const { pipeline } = require('stream/promises');
const zlib = require('zlib');
const fs = require('fs');
const path = require('path');
const { getSettingSync } = require('./settingsCache');
const BACKUP_DIR = path.join(__dirname, '..', '..', 'backups');
// Local-disk-only by design (no offsite/cloud upload) — matches the exact filename shape
// this module generates, and is reused to validate download requests against path traversal.
const BACKUP_FILENAME_RE = /^backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.sql\.gz$/;
function ensureBackupDir() {
if (!fs.existsSync(BACKUP_DIR)) fs.mkdirSync(BACKUP_DIR, { recursive: true });
}
function backupFilename(date = new Date()) {
return `backup-${date.toISOString().replace(/[:.]/g, '-')}.sql.gz`;
}
/**
* Run `pg_dump` against DATABASE_URL, gzip its output, and write it to backend/backups/.
* Requires the `pg_dump` binary to be installed on this host (Postgres itself is hosted
* separately) — a deploy-environment prerequisite, not something this code can satisfy.
*
* @returns {Promise<{ filename: string, path: string }>}
*/
async function runBackup() {
ensureBackupDir();
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) throw new Error('DATABASE_URL is not configured');
const filename = backupFilename();
const filePath = path.join(BACKUP_DIR, filename);
const pgDump = spawn('pg_dump', [databaseUrl, '--no-owner', '--no-privileges'], { stdio: ['ignore', 'pipe', 'pipe'] });
let stderr = '';
pgDump.stderr.on('data', (d) => { stderr += d.toString(); });
const exitPromise = new Promise((resolve, reject) => {
pgDump.on('error', (err) => reject(new Error(`Failed to start pg_dump: ${err.message}. Is it installed on this host?`)));
pgDump.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`pg_dump exited with code ${code}: ${stderr.slice(0, 500)}`));
});
});
const pipelinePromise = pipeline(pgDump.stdout, zlib.createGzip(), fs.createWriteStream(filePath));
try {
// Both must succeed: the process exiting cleanly, and the gzip write finishing —
// a mid-dump failure must not leave a truncated file looking like a real backup.
await Promise.all([exitPromise, pipelinePromise]);
} catch (err) {
await fs.promises.unlink(filePath).catch(() => {});
throw err;
}
const retainCount = parseInt(getSettingSync('backup_retain_count', '14'), 10) || 14;
await rotateBackups(retainCount);
return { filename, path: filePath };
}
/** @returns {Promise<Array<{ filename: string, size: number, createdAt: Date }>>} newest first */
async function listBackups() {
ensureBackupDir();
const files = await fs.promises.readdir(BACKUP_DIR);
const backups = await Promise.all(
files.filter((f) => BACKUP_FILENAME_RE.test(f)).map(async (f) => {
const stat = await fs.promises.stat(path.join(BACKUP_DIR, f));
return { filename: f, size: stat.size, createdAt: stat.mtime };
})
);
return backups.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
/** Deletes backups beyond the retention count, oldest first. */
async function rotateBackups(retainCount) {
const backups = await listBackups();
const toDelete = backups.slice(retainCount);
await Promise.all(toDelete.map((b) => fs.promises.unlink(path.join(BACKUP_DIR, b.filename)).catch(() => {})));
return { deleted: toDelete.length };
}
module.exports = { runBackup, listBackups, rotateBackups, BACKUP_DIR, BACKUP_FILENAME_RE };