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>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user