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>
88 lines
3.5 KiB
JavaScript
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 };
|