Beta: TOTP 2FA, admin asset manager, resource page redesign, rate limiting, and security hardening
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import express from 'express'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import { mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Resend } from 'resend'
|
||||
import qrcode from 'qrcode'
|
||||
import {
|
||||
sanitizeSiteContent,
|
||||
escapeHtml,
|
||||
@@ -28,6 +30,17 @@ import {
|
||||
deleteAdminSession,
|
||||
validateAdminPasswordSetup,
|
||||
isAdminPasswordValid,
|
||||
isTotpEnabled,
|
||||
loadTotpState,
|
||||
saveTotpState,
|
||||
generateTotpSecret,
|
||||
getTotpUri,
|
||||
verifyTotpCode,
|
||||
generateRecoveryCodes,
|
||||
hashRecoveryCode,
|
||||
consumeRecoveryCode,
|
||||
createPendingSession,
|
||||
consumePendingSession,
|
||||
} from './server/auth.js'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
@@ -40,7 +53,6 @@ const VISITOR_STATS_FILE = path.join(DATA_DIR, 'visitor-stats.json')
|
||||
const CONTACT_SUBMISSIONS_FILE = path.join(DATA_DIR, 'contact-submissions.json')
|
||||
const QUESTIONS_FILE = path.join(DATA_DIR, 'questions.json')
|
||||
const DRAFT_QUESTIONS_FILE = path.join(DATA_DIR, 'questions-draft.json')
|
||||
const CHATBOT_FILE = path.join(DATA_DIR, 'chatbot-content.json')
|
||||
const BACKUP_DIR = path.join(DATA_DIR, 'backups')
|
||||
const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
|
||||
const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json')
|
||||
@@ -227,7 +239,7 @@ async function writeUploadsMetadata(metadata) {
|
||||
async function listUploadedAssets() {
|
||||
await mkdir(UPLOADS_DIR, { recursive: true })
|
||||
const files = await readdir(UPLOADS_DIR)
|
||||
const imageFiles = files.filter(name => /\.(png|jpe?g|webp|gif)$/i.test(name)).sort()
|
||||
const imageFiles = files.filter(name => /\.(png|jpe?g|webp|gif|pdf|docx?)$/i.test(name)).sort()
|
||||
const metadata = await readUploadsMetadata()
|
||||
|
||||
const withStats = await Promise.all(imageFiles.map(async filename => {
|
||||
@@ -1090,7 +1102,7 @@ app.post('/api/admin-assets', requireAdminAuth, async (req, res) => {
|
||||
const ext = inferImageExtensionFromDataUrl(dataUrl)
|
||||
|
||||
if (!ext) {
|
||||
res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, or GIF data URL.' })
|
||||
res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, GIF, PDF, DOC, or DOCX data URL.' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1193,93 +1205,6 @@ app.post('/api/admin-ops/deploy', requireAdminAuth, async (_req, res) => {
|
||||
res.json({ ok: true, message: result.message })
|
||||
})
|
||||
|
||||
// ── Chatbot knowledge base ──────────────────────────────────────────────────
|
||||
const MAX_CHATBOT_ENTRIES = 500
|
||||
let chatbotEntries = []
|
||||
let chatbotWritePromise = Promise.resolve()
|
||||
let chatbotFileMtimeMs = 0
|
||||
|
||||
function queueChatbotWrite() {
|
||||
chatbotWritePromise = chatbotWritePromise
|
||||
.then(async () => {
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(
|
||||
CHATBOT_FILE,
|
||||
JSON.stringify(chatbotEntries, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[chatbot] failed to write chatbot content:', err)
|
||||
})
|
||||
}
|
||||
|
||||
async function loadChatbotFromDisk() {
|
||||
try {
|
||||
const [fileStats, raw] = await Promise.all([
|
||||
stat(CHATBOT_FILE),
|
||||
readFile(CHATBOT_FILE, 'utf8'),
|
||||
])
|
||||
const parsed = JSON.parse(raw)
|
||||
chatbotEntries = Array.isArray(parsed) ? parsed.slice(0, MAX_CHATBOT_ENTRIES) : []
|
||||
chatbotFileMtimeMs = fileStats.mtimeMs
|
||||
} catch {
|
||||
chatbotEntries = []
|
||||
chatbotFileMtimeMs = 0
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshChatbotFromDiskIfChanged() {
|
||||
try {
|
||||
const fileStats = await stat(CHATBOT_FILE)
|
||||
if (fileStats.mtimeMs <= chatbotFileMtimeMs) return
|
||||
await loadChatbotFromDisk()
|
||||
} catch {
|
||||
if (chatbotFileMtimeMs === 0) return
|
||||
chatbotEntries = []
|
||||
chatbotFileMtimeMs = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Public: return all chatbot entries for client-side matching
|
||||
app.get('/api/chatbot-content', async (req, res) => {
|
||||
await refreshChatbotFromDiskIfChanged()
|
||||
res.json(chatbotEntries)
|
||||
})
|
||||
|
||||
// Admin: get all entries
|
||||
app.get('/api/admin/chatbot-content', async (req, res) => {
|
||||
if (!isValidAdminSession(req)) { res.status(401).json({ message: 'Not authenticated.' }); return }
|
||||
await refreshChatbotFromDiskIfChanged()
|
||||
res.json(chatbotEntries)
|
||||
})
|
||||
|
||||
// Admin: save full list (replace all)
|
||||
app.post('/api/admin/chatbot-content', (req, res) => {
|
||||
if (!isValidAdminSession(req)) { res.status(401).json({ message: 'Not authenticated.' }); return }
|
||||
const body = req.body
|
||||
if (!Array.isArray(body)) { res.status(400).json({ message: 'Expected array.' }); return }
|
||||
const sanitized = body
|
||||
.filter(e => e && typeof e.title === 'string' && typeof e.content === 'string')
|
||||
.slice(0, MAX_CHATBOT_ENTRIES)
|
||||
.map(e => ({
|
||||
id: typeof e.id === 'string' && e.id ? e.id : randomUUID(),
|
||||
type: ['qa', 'topic', 'episode'].includes(e.type) ? e.type : 'qa',
|
||||
title: String(e.title).trim().slice(0, 500),
|
||||
content: String(e.content).trim().slice(0, 4000),
|
||||
sourceLabel: typeof e.sourceLabel === 'string' ? e.sourceLabel.trim().slice(0, 160) : '',
|
||||
priority: e.priority === true,
|
||||
keywords: Array.isArray(e.keywords)
|
||||
? e.keywords.filter(k => typeof k === 'string').map(k => k.trim().toLowerCase()).slice(0, 20)
|
||||
: [],
|
||||
createdAt: typeof e.createdAt === 'string' ? e.createdAt : new Date().toISOString(),
|
||||
updatedAt: typeof e.updatedAt === 'string' ? e.updatedAt : new Date().toISOString(),
|
||||
}))
|
||||
chatbotEntries = sanitized
|
||||
queueChatbotWrite()
|
||||
res.json({ ok: true, count: chatbotEntries.length })
|
||||
})
|
||||
|
||||
function queueQuestionsWrite() {
|
||||
questionsWritePromise = questionsWritePromise
|
||||
.then(async () => {
|
||||
@@ -1331,14 +1256,27 @@ function loadQuestionsFromDisk() {
|
||||
questions = []
|
||||
})
|
||||
}
|
||||
app.get('/api/admin-auth/status', (req, res) => {
|
||||
// Rate limiter: max 10 attempts per 15 minutes per IP on the login endpoint
|
||||
const loginRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { message: 'Too many login attempts. Please wait 15 minutes and try again.' },
|
||||
skipSuccessfulRequests: true,
|
||||
})
|
||||
|
||||
app.get('/api/admin-auth/status', async (req, res) => {
|
||||
res.json({
|
||||
authenticated: isValidAdminSession(req),
|
||||
configured: isAdminPasswordConfigured(),
|
||||
totpEnabled: await isTotpEnabled(),
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/admin-auth/login', (req, res) => {
|
||||
// Step 1: verify password. If TOTP is enabled, returns a short-lived pending token.
|
||||
// If TOTP is not yet configured, logs straight in (backwards compatible).
|
||||
app.post('/api/admin-auth/login', loginRateLimiter, async (req, res) => {
|
||||
const password = typeof req.body?.password === 'string' ? req.body.password : ''
|
||||
|
||||
if (!isAdminPasswordConfigured()) {
|
||||
@@ -1351,11 +1289,110 @@ app.post('/api/admin-auth/login', (req, res) => {
|
||||
return
|
||||
}
|
||||
|
||||
const totpOn = await isTotpEnabled()
|
||||
if (totpOn) {
|
||||
const pendingToken = createPendingSession()
|
||||
res.json({ totpRequired: true, pendingToken })
|
||||
return
|
||||
}
|
||||
|
||||
const sessionToken = createAdminSession()
|
||||
setAdminSessionCookie(res, sessionToken)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// Step 2a: verify TOTP code (or recovery code) after password was accepted
|
||||
app.post('/api/admin-auth/totp-verify', loginRateLimiter, async (req, res) => {
|
||||
const { pendingToken, code } = req.body ?? {}
|
||||
|
||||
if (!consumePendingSession(pendingToken)) {
|
||||
res.status(401).json({ message: 'Session expired or invalid. Please sign in again.' })
|
||||
return
|
||||
}
|
||||
|
||||
const state = await loadTotpState()
|
||||
if (!state?.secret || !state?.verified) {
|
||||
res.status(400).json({ message: 'TOTP is not configured.' })
|
||||
return
|
||||
}
|
||||
|
||||
const codeStr = typeof code === 'string' ? code.trim() : ''
|
||||
|
||||
// Try TOTP first
|
||||
if (verifyTotpCode(state.secret, codeStr)) {
|
||||
const sessionToken = createAdminSession()
|
||||
setAdminSessionCookie(res, sessionToken)
|
||||
res.json({ ok: true })
|
||||
return
|
||||
}
|
||||
|
||||
// Try recovery code
|
||||
if (consumeRecoveryCode(state, codeStr)) {
|
||||
await saveTotpState(state)
|
||||
const sessionToken = createAdminSession()
|
||||
setAdminSessionCookie(res, sessionToken)
|
||||
res.json({ ok: true, usedRecoveryCode: true, remainingRecoveryCodes: state.hashedRecoveryCodes.length })
|
||||
return
|
||||
}
|
||||
|
||||
res.status(401).json({ message: 'Invalid code. Try again or use a recovery code.' })
|
||||
})
|
||||
|
||||
// TOTP setup: generate a new secret and QR code (admin must be authenticated OR provide valid password)
|
||||
app.post('/api/admin-auth/totp-setup-init', requireAdminAuth, async (req, res) => {
|
||||
const secret = generateTotpSecret()
|
||||
const uri = getTotpUri(secret)
|
||||
const qrDataUrl = await qrcode.toDataURL(uri)
|
||||
// Store unverified secret temporarily
|
||||
const existing = await loadTotpState()
|
||||
await saveTotpState({ ...existing, secret, verified: false })
|
||||
res.json({ qrDataUrl, secret })
|
||||
})
|
||||
|
||||
// TOTP setup: confirm the code to mark TOTP as verified and generate recovery codes
|
||||
app.post('/api/admin-auth/totp-setup-confirm', requireAdminAuth, async (req, res) => {
|
||||
const { code } = req.body ?? {}
|
||||
const state = await loadTotpState()
|
||||
|
||||
if (!state?.secret) {
|
||||
res.status(400).json({ message: 'No TOTP setup in progress. Call /totp-setup-init first.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!verifyTotpCode(state.secret, typeof code === 'string' ? code.trim() : '')) {
|
||||
res.status(401).json({ message: 'Code incorrect. Scan the QR code again and try once more.' })
|
||||
return
|
||||
}
|
||||
|
||||
const recoveryCodes = generateRecoveryCodes()
|
||||
await saveTotpState({
|
||||
secret: state.secret,
|
||||
verified: true,
|
||||
hashedRecoveryCodes: recoveryCodes.map(hashRecoveryCode),
|
||||
enabledAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
res.json({ ok: true, recoveryCodes })
|
||||
})
|
||||
|
||||
// Disable TOTP (requires active admin session)
|
||||
app.post('/api/admin-auth/totp-disable', requireAdminAuth, async (req, res) => {
|
||||
await saveTotpState({ secret: null, verified: false, hashedRecoveryCodes: [], disabledAt: new Date().toISOString() })
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// Regenerate recovery codes (requires active admin session)
|
||||
app.post('/api/admin-auth/totp-regen-recovery', requireAdminAuth, async (req, res) => {
|
||||
const state = await loadTotpState()
|
||||
if (!state?.secret || !state?.verified) {
|
||||
res.status(400).json({ message: 'TOTP is not enabled.' })
|
||||
return
|
||||
}
|
||||
const recoveryCodes = generateRecoveryCodes()
|
||||
await saveTotpState({ ...state, hashedRecoveryCodes: recoveryCodes.map(hashRecoveryCode) })
|
||||
res.json({ ok: true, recoveryCodes })
|
||||
})
|
||||
|
||||
app.post('/api/admin-auth/logout', (req, res) => {
|
||||
const cookies = parseCookies(req.headers.cookie)
|
||||
const sessionToken = cookies[ADMIN_SESSION_COOKIE]
|
||||
@@ -1592,11 +1629,16 @@ app.post('/api/study-downloads/titus', studyDownloadRateLimit, async (req, res)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await stat(TITUS_STUDY_FILE)
|
||||
} catch {
|
||||
res.status(503).json({ message: 'The Titus study file is not configured yet.' })
|
||||
return
|
||||
const published = await loadSiteContentFile(DATA_FILE)
|
||||
const configuredDownloadUrl = sanitizeUrl(published?.siteContent?.studyGuideDownloadUrl)
|
||||
|
||||
if (!configuredDownloadUrl) {
|
||||
try {
|
||||
await stat(TITUS_STUDY_FILE)
|
||||
} catch {
|
||||
res.status(503).json({ message: 'The primary study guide download URL is not configured yet.' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const trimmedFirstName = firstName.trim()
|
||||
@@ -1617,6 +1659,11 @@ app.post('/api/study-downloads/titus', studyDownloadRateLimit, async (req, res)
|
||||
await syncContactToResend(trimmedName, trimmedEmail)
|
||||
}
|
||||
|
||||
if (configuredDownloadUrl) {
|
||||
res.json({ ok: true, downloadUrl: configuredDownloadUrl })
|
||||
return
|
||||
}
|
||||
|
||||
const token = createTitusDownloadToken(trimmedEmail)
|
||||
res.json({ ok: true, downloadUrl: `/api/study-downloads/titus/file?token=${encodeURIComponent(token)}` })
|
||||
} catch (err) {
|
||||
@@ -1640,9 +1687,46 @@ app.post('/api/resource-download', studyDownloadRateLimit, async (req, res) => {
|
||||
}
|
||||
|
||||
const published = await loadSiteContentFile(DATA_FILE)
|
||||
const resource = Array.isArray(published?.siteContent?.customLinks)
|
||||
? published.siteContent.customLinks.find(link => link.id === resourceId && link.placement === 'resources')
|
||||
: undefined
|
||||
const siteContent = published?.siteContent
|
||||
|
||||
function resolveResourceFromId(id) {
|
||||
if (!siteContent || typeof siteContent !== 'object') return null
|
||||
|
||||
const customResources = Array.isArray(siteContent.customLinks)
|
||||
? siteContent.customLinks.filter(link => link?.placement === 'resources')
|
||||
: []
|
||||
|
||||
if (id.startsWith('custom:')) {
|
||||
const customId = id.slice('custom:'.length)
|
||||
const match = customResources.find(link => link.id === customId)
|
||||
return match ? { label: match.label, url: match.url } : null
|
||||
}
|
||||
|
||||
if (id.startsWith('archived:')) {
|
||||
const [, seriesId, ...linkIdParts] = id.split(':')
|
||||
const linkId = linkIdParts.join(':')
|
||||
const archivedSeries = Array.isArray(siteContent.archivedSeries) ? siteContent.archivedSeries : []
|
||||
const series = archivedSeries.find(item => item.id === seriesId)
|
||||
const link = Array.isArray(series?.resourceLinks)
|
||||
? series.resourceLinks.find(item => item.id === linkId)
|
||||
: null
|
||||
return link ? { label: link.label || series?.title, url: link.url } : null
|
||||
}
|
||||
|
||||
const customMatch = customResources.find(link => link.id === id)
|
||||
if (customMatch) return { label: customMatch.label, url: customMatch.url }
|
||||
|
||||
const archivedSeries = Array.isArray(siteContent.archivedSeries) ? siteContent.archivedSeries : []
|
||||
for (const series of archivedSeries) {
|
||||
if (!Array.isArray(series?.resourceLinks)) continue
|
||||
const link = series.resourceLinks.find(item => item.id === id)
|
||||
if (link) return { label: link.label || series?.title, url: link.url }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const resource = resolveResourceFromId(resourceId)
|
||||
|
||||
if (!resource || typeof resource.url !== 'string' || !resource.url.trim()) {
|
||||
res.status(400).json({ message: 'Resource not found.' })
|
||||
@@ -2061,7 +2145,6 @@ Promise.all([
|
||||
loadContactSubmissionsFromDisk(),
|
||||
loadQuestionsFromDisk(),
|
||||
loadDraftQuestionsFromDisk(),
|
||||
loadChatbotFromDisk(),
|
||||
refreshContentCaches(),
|
||||
])
|
||||
.catch(err => {
|
||||
|
||||
Reference in New Issue
Block a user