import { mkdir, mkdtemp, readdir, rm, cp, writeFile, unlink } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import express from 'express' import * as tar from 'tar' import { requireAdminAuth } from '../auth.js' import { DATA_DIR } from '../config.js' import { state } from '../state.js' import { loadHitStatsFromDisk, loadVisitorStatsFromDisk, loadContactSubmissionsFromDisk, loadReplyTemplatesFromDisk, loadReplyHistoryFromDisk, loadQuestionsFromDisk, loadDraftQuestionsFromDisk, loadStudyUsersFromDisk, loadStudyCommunityFromDisk, loadStudyRemindersFromDisk, loadStudyCommentsFromDisk, loadStudyCertificatesFromDisk, loadEpisodeScriptsFromDisk, migrateStudyNotesIfNeeded, loadDownloadCountsFromDisk, loadQrCodesFromDisk, loadEpisodePlaysFromDisk, loadPodcastChecklistFromDisk, loadAnalyticsEventsFromDisk, createBackupSnapshot, refreshContentCaches, } from '../data.js' // Portable server settings captured into env-snapshot.env at export time so a // backup carries the runtime configuration too (Docker deployments have no // .env file — settings live in container env vars). Machine-specific vars // (PORT, NODE_ENV, SITEFORGE_DATA_DIR, ALLOW_INSECURE_COOKIES, build info) // are deliberately excluded. Values are NOT applied automatically on restore. const ENV_SNAPSHOT_KEYS = [ 'ADMIN_PASSWORD', 'RESEND_API_KEY', 'RESEND_CONTACTS_API_KEY', 'RESEND_WEBHOOK_TOKEN', 'INBOUND_EMAIL_SECRET', 'RESEND_FROM', 'RESEND_TO', 'RESEND_REPLY_TO', 'RESEND_AUTOMATION_WELCOME', 'RESEND_SEGMENT_ID', 'RESEND_REMINDER_SUBJECT', 'RESEND_WELCOME_SUBJECT', 'RESEND_WELCOME_IMAGE_URL', 'RESEND_WELCOME_EPISODE_URL', 'RESEND_WELCOME_WEBSITE_URL', 'RESEND_WELCOME_SPOTIFY_URL', 'RESEND_WELCOME_APPLE_URL', 'RESEND_WELCOME_AMAZON_URL', 'CACHE_PURGE_WEBHOOK_URL', 'DEPLOY_WEBHOOK_URL', 'CONTACT_EMAIL_COOLDOWN_MS', 'TRUST_PROXY_HOPS', 'TITUS_STUDY_FILE', 'TITUS_STUDY_DOWNLOAD_NAME', ] // Write env snapshot into DATA_DIR for inclusion in the archive, then delete // it immediately after the stream finishes so secrets don't linger on disk. async function writeEnvSnapshot() { const lines = [ '# Siteforge environment snapshot — regenerated on every full backup export.', '# Contains secrets (admin password, API keys): keep this backup private.', '# These values are NOT applied automatically on restore. Set them as', '# container environment variables (or in .env) on the new server.', `# Exported at ${new Date().toISOString()}`, '', ] for (const key of ENV_SNAPSHOT_KEYS) { const value = process.env[key] if (typeof value === 'string' && value.trim() !== '') lines.push(`${key}=${value}`) } await writeFile(path.join(DATA_DIR, 'env-snapshot.env'), `${lines.join('\n')}\n`, 'utf8') } async function deleteEnvSnapshot() { await unlink(path.join(DATA_DIR, 'env-snapshot.env')).catch(() => {}) } // Files that identify an archive as a Siteforge data backup. At least one // must be present at the top level of an uploaded archive before we restore. const KNOWN_DATA_FILES = [ 'admin-content.json', 'admin-content-draft.json', 'study-users.json', 'hit-stats.json', 'visitor-stats.json', 'questions.json', ] // Wait for every queued disk write so the archive reflects current state. async function flushPendingWrites() { await Promise.allSettled([ state.hitStatsWritePromise, state.visitorStatsWritePromise, state.contactSubmissionsWritePromise, state.questionsWritePromise, state.draftQuestionsWritePromise, state.replyTemplatesWritePromise, state.replyHistoryWritePromise, state.podcastChecklistWritePromise, state.studyUsersWritePromise, state.studyCommunityWritePromise, state.studyRemindersWritePromise, state.studyCommentsWritePromise, state.studyCertificatesWritePromise, state.episodeScriptsWritePromise, state.downloadCountsWritePromise, state.episodePlaysWritePromise, state.analyticsEventsWritePromise, state.qrCodesWritePromise, ...state.studyNotesWriteQueues.values(), ...state.studyProgressWriteQueues.values(), ]) } // Rebuild all in-memory state from whatever is now on disk (mirrors startup). async function reloadStateFromDisk() { state.studyNotesCache.clear() state.studyNotesWriteQueues.clear() state.studyProgressCache.clear() state.studyProgressWriteQueues.clear() // Restored study users may not match current sessions — force re-login. state.studySessions.clear() await Promise.all([ loadHitStatsFromDisk(), loadVisitorStatsFromDisk(), loadContactSubmissionsFromDisk(), loadReplyTemplatesFromDisk(), loadReplyHistoryFromDisk(), loadQuestionsFromDisk(), loadDraftQuestionsFromDisk(), loadStudyUsersFromDisk(), loadStudyCommunityFromDisk(), loadStudyRemindersFromDisk(), loadStudyCommentsFromDisk(), loadStudyCertificatesFromDisk(), loadEpisodeScriptsFromDisk(), migrateStudyNotesIfNeeded(), loadDownloadCountsFromDisk(), loadQrCodesFromDisk(), loadEpisodePlaysFromDisk(), loadPodcastChecklistFromDisk(), loadAnalyticsEventsFromDisk(), refreshContentCaches(), ]) } export function register(app) { // Download the entire data directory as a tar.gz (excluding the automatic // snapshot folder, which is derived from the other files). app.get('/api/admin-backup/export', requireAdminAuth, async (_req, res) => { try { await flushPendingWrites() await writeEnvSnapshot() const entries = (await readdir(DATA_DIR)).filter(name => name !== 'backups') if (entries.length === 0) { res.status(500).json({ message: 'Data directory is empty — nothing to export.' }) return } const stamp = new Date().toISOString().replace(/[:.]/g, '-') res.setHeader('Content-Type', 'application/gzip') res.setHeader('Content-Disposition', `attachment; filename="siteforge-data-${stamp}.tar.gz"`) const archive = tar.create({ gzip: true, cwd: DATA_DIR, portable: true }, entries) archive.on('error', err => { console.error('[admin-backup] export stream failed:', err) res.destroy(err) }) archive.on('end', () => { deleteEnvSnapshot() }) res.on('close', () => { deleteEnvSnapshot() }) archive.pipe(res) } catch (err) { console.error('[admin-backup] export failed:', err) deleteEnvSnapshot() if (!res.headersSent) res.status(500).json({ message: 'Full backup export failed.' }) } }) // Restore the entire data directory from an uploaded tar.gz produced by the // export endpoint, then reload all in-memory state from the restored files. app.post( '/api/admin-backup/import', requireAdminAuth, express.raw({ type: () => true, limit: '500mb' }), async (req, res) => { let workDir = null try { const body = req.body if (!Buffer.isBuffer(body) || body.length === 0) { res.status(400).json({ message: 'Upload the .tar.gz file produced by the full backup export.' }) return } if (body[0] !== 0x1f || body[1] !== 0x8b) { res.status(400).json({ message: 'File is not a gzip archive (.tar.gz expected).' }) return } workDir = await mkdtemp(path.join(os.tmpdir(), 'siteforge-import-')) const archivePath = path.join(workDir, 'import.tar.gz') await writeFile(archivePath, body) const extractDir = path.join(workDir, 'extracted') await mkdir(extractDir) // node-tar strips absolute paths and rejects entries that escape cwd. await tar.extract({ file: archivePath, cwd: extractDir }) const extractedEntries = await readdir(extractDir) if (!extractedEntries.some(name => KNOWN_DATA_FILES.includes(name))) { res.status(400).json({ message: 'Archive does not look like a Siteforge data backup.' }) return } // Settle queued writes so nothing overwrites the restored files, and // keep a snapshot of the pre-import state in the backups folder. await flushPendingWrites() await createBackupSnapshot('pre-import') // Replace current data with the archive contents. The snapshot folder // is preserved unless the archive itself contains one. const currentEntries = await readdir(DATA_DIR) for (const name of currentEntries) { if (name === 'backups' && !extractedEntries.includes('backups')) continue await rm(path.join(DATA_DIR, name), { recursive: true, force: true }) } for (const name of extractedEntries) { await cp(path.join(extractDir, name), path.join(DATA_DIR, name), { recursive: true }) } await reloadStateFromDisk() await createBackupSnapshot('post-import') res.json({ ok: true, restoredEntries: extractedEntries.length }) } catch (err) { console.error('[admin-backup] import failed:', err) res.status(500).json({ message: 'Full data restore failed. Check the server logs — data may need manual attention.' }) } finally { if (workDir) await rm(workDir, { recursive: true, force: true }).catch(() => {}) } }, ) }