1d43875e5a
Critical fixes: - sanitizeLoadedHitStats/VisitorStats: restore full state shape so a snapshot restore no longer crashes hit-counting middleware (missing byPathReal, byPathBot, byDayReal, byDayBot, botReasons, ipHashIndex) - /questions/share/🆔 read state.questions only, not draft questions - inbound-email: validate date with Number.isFinite before toISOString - study-reminders: wrap each send in try/catch so one failure doesn't block remaining users; persist sent-markers after each success Security: - getClientIp: use req.ip (trust-proxy-resolved) instead of raw x-forwarded-for header to prevent IP spoofing - env-snapshot.env: delete immediately after backup tar stream ends so secrets don't linger on disk between exports Correctness / UX: - contact form: email failures no longer 500 the user after the submission is already saved; log and fall through instead - study-account profile: cap data URI avatar at 6 MB - admin enrollment PATCH: validate slug against study catalog - signup: return 503 at MAX_STUDY_USERS instead of silently dropping oldest accounts Memory leaks: - contactHits, downloadHits Maps: prune stale entries at 5000 entries - resendEmailSubmissionIndex: trim to 2000 entries (oldest first) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
252 lines
9.1 KiB
JavaScript
252 lines
9.1 KiB
JavaScript
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(() => {})
|
|
}
|
|
},
|
|
)
|
|
}
|