refractor server.js
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
import { mkdir, stat, unlink, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { inferImageExtensionFromDataUrl, normalizeAssetBaseName } from '../helpers.js'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import { UPLOADS_DIR } from '../config.js'
|
||||
import { state } from '../state.js'
|
||||
import {
|
||||
listUploadedAssets,
|
||||
readUploadsMetadata,
|
||||
writeUploadsMetadata,
|
||||
getUserNotesFilePath,
|
||||
queueStudyUsersWrite,
|
||||
} from '../data.js'
|
||||
import {
|
||||
hashStudyPassword,
|
||||
getStudyCatalog,
|
||||
} from '../study-helpers.js'
|
||||
|
||||
export function register(app) {
|
||||
app.get('/api/admin-assets', requireAdminAuth, async (_req, res) => {
|
||||
try {
|
||||
const assets = await listUploadedAssets()
|
||||
res.json({ assets })
|
||||
} catch {
|
||||
res.status(500).json({ message: 'Could not list uploaded assets.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/admin-assets', requireAdminAuth, async (req, res) => {
|
||||
try {
|
||||
const filename = typeof req.body?.filename === 'string' ? req.body.filename : ''
|
||||
const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
|
||||
const ext = inferImageExtensionFromDataUrl(dataUrl)
|
||||
|
||||
if (!ext) {
|
||||
res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, GIF, PDF, DOC, or DOCX data URL.' })
|
||||
return
|
||||
}
|
||||
|
||||
const base64 = dataUrl.split(',')[1] ?? ''
|
||||
const buffer = Buffer.from(base64, 'base64')
|
||||
if (buffer.length === 0 || buffer.length > (8 * 1024 * 1024)) {
|
||||
res.status(400).json({ message: 'Upload must be between 1 byte and 8MB.' })
|
||||
return
|
||||
}
|
||||
|
||||
const baseName = normalizeAssetBaseName(filename.replace(/\.[a-z0-9]+$/i, ''))
|
||||
const finalName = `${baseName}-${Date.now()}${ext}`
|
||||
|
||||
await mkdir(UPLOADS_DIR, { recursive: true })
|
||||
await writeFile(path.join(UPLOADS_DIR, finalName), buffer)
|
||||
const metadata = await readUploadsMetadata()
|
||||
metadata[finalName] = []
|
||||
await writeUploadsMetadata(metadata)
|
||||
|
||||
res.json({ ok: true, asset: { filename: finalName, url: `/uploads/${finalName}` } })
|
||||
} catch {
|
||||
res.status(500).json({ message: 'Upload failed.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.patch('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => {
|
||||
try {
|
||||
const { filename } = req.params
|
||||
if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..')) {
|
||||
res.status(400).json({ message: 'Invalid filename.' })
|
||||
return
|
||||
}
|
||||
|
||||
const tags = Array.isArray(req.body?.tags)
|
||||
? req.body.tags.filter(tag => typeof tag === 'string').map(tag => tag.trim()).filter(Boolean)
|
||||
: []
|
||||
|
||||
const filePath = path.join(UPLOADS_DIR, filename)
|
||||
await stat(filePath)
|
||||
|
||||
const metadata = await readUploadsMetadata()
|
||||
metadata[filename] = tags
|
||||
await writeUploadsMetadata(metadata)
|
||||
|
||||
res.json({ ok: true, tags })
|
||||
} catch {
|
||||
res.status(404).json({ message: 'Asset not found.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => {
|
||||
try {
|
||||
const { filename } = req.params
|
||||
if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..')) {
|
||||
res.status(400).json({ message: 'Invalid filename.' })
|
||||
return
|
||||
}
|
||||
|
||||
await unlink(path.join(UPLOADS_DIR, filename))
|
||||
const metadata = await readUploadsMetadata()
|
||||
delete metadata[filename]
|
||||
await writeUploadsMetadata(metadata)
|
||||
res.json({ ok: true })
|
||||
} catch {
|
||||
res.status(404).json({ message: 'Asset not found.' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Admin: Study User Management ─────────────────────────────────────────
|
||||
|
||||
app.get('/api/admin/study-users', requireAdminAuth, async (req, res) => {
|
||||
const catalog = getStudyCatalog()
|
||||
const users = await Promise.all(state.studyUsers.map(async user => {
|
||||
let noteCount = 0
|
||||
try {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
const notesRaw = await readFile(getUserNotesFilePath(user.id), 'utf8').catch(() => '{}')
|
||||
const notes = JSON.parse(notesRaw)
|
||||
noteCount = Object.values(notes).filter(n => typeof n === 'string' && n.trim()).length
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const enrolledStudies = (user.enrolledStudySlugs ?? []).map(slug => {
|
||||
const study = catalog.find(s => s.slug === slug)
|
||||
return study ? { slug, title: study.title } : { slug, title: slug }
|
||||
})
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName ?? '',
|
||||
createdAt: user.createdAt ?? null,
|
||||
lastLoginAt: user.lastLoginAt ?? null,
|
||||
enrolledStudies,
|
||||
noteCount,
|
||||
subscribeNewsletter: user.subscribeNewsletter !== false,
|
||||
studyRemindersEnabled: user.studyRemindersEnabled === true,
|
||||
}
|
||||
}))
|
||||
|
||||
res.json({ users })
|
||||
})
|
||||
|
||||
app.patch('/api/admin/study-users/:id', requireAdminAuth, (req, res) => {
|
||||
const user = state.studyUsers.find(u => u.id === req.params.id)
|
||||
if (!user) { res.status(404).json({ message: 'User not found.' }); return }
|
||||
|
||||
const { displayName, newPassword, addEnrollment, removeEnrollment } = req.body ?? {}
|
||||
|
||||
if (typeof displayName === 'string') {
|
||||
user.displayName = displayName.trim().slice(0, 80)
|
||||
}
|
||||
|
||||
if (typeof newPassword === 'string') {
|
||||
if (newPassword.length < 8 || newPassword.length > 200) {
|
||||
res.status(400).json({ message: 'Password must be 8–200 characters.' }); return
|
||||
}
|
||||
user.passwordHash = hashStudyPassword(newPassword)
|
||||
for (const [token, session] of state.studySessions) {
|
||||
if (session.userId === user.id) state.studySessions.delete(token)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof addEnrollment === 'string' && addEnrollment.trim()) {
|
||||
const slug = addEnrollment.trim()
|
||||
if (!Array.isArray(user.enrolledStudySlugs)) user.enrolledStudySlugs = []
|
||||
if (!user.enrolledStudySlugs.includes(slug)) user.enrolledStudySlugs.push(slug)
|
||||
}
|
||||
|
||||
if (typeof removeEnrollment === 'string' && removeEnrollment.trim()) {
|
||||
const slug = removeEnrollment.trim()
|
||||
user.enrolledStudySlugs = (user.enrolledStudySlugs ?? []).filter(s => s !== slug)
|
||||
}
|
||||
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
res.json({ ok: true, displayName: user.displayName, enrolledStudySlugs: user.enrolledStudySlugs })
|
||||
})
|
||||
|
||||
app.delete('/api/admin/study-users/:id', requireAdminAuth, async (req, res) => {
|
||||
const user = state.studyUsers.find(u => u.id === req.params.id)
|
||||
if (!user) { res.status(404).json({ message: 'User not found.' }); return }
|
||||
|
||||
for (const [token, session] of state.studySessions) {
|
||||
if (session.userId === user.id) state.studySessions.delete(token)
|
||||
}
|
||||
|
||||
state.studyUsers = state.studyUsers.filter(u => u.id !== user.id)
|
||||
queueStudyUsersWrite()
|
||||
|
||||
state.studyNotesCache.delete(user.id)
|
||||
try { await unlink(getUserNotesFilePath(user.id)) } catch { /* no notes file is fine */ }
|
||||
|
||||
res.json({ ok: true })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import qrcode from 'qrcode'
|
||||
import { parseCookies } from '../helpers.js'
|
||||
import {
|
||||
isAdminPasswordConfigured,
|
||||
isValidAdminSession,
|
||||
requireAdminAuth,
|
||||
setAdminSessionCookie,
|
||||
clearAdminSessionCookie,
|
||||
createAdminSession,
|
||||
deleteAdminSession,
|
||||
isAdminPasswordValid,
|
||||
isTotpEnabled,
|
||||
loadTotpState,
|
||||
saveTotpState,
|
||||
generateTotpSecret,
|
||||
getTotpUri,
|
||||
verifyTotpCode,
|
||||
generateRecoveryCodes,
|
||||
hashRecoveryCode,
|
||||
consumeRecoveryCode,
|
||||
createPendingSession,
|
||||
consumePendingSession,
|
||||
} from '../auth.js'
|
||||
|
||||
const ADMIN_SESSION_COOKIE = 'vbn_admin_session'
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
export function register(app) {
|
||||
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', loginRateLimiter, async (req, res) => {
|
||||
const password = typeof req.body?.password === 'string' ? req.body.password : ''
|
||||
|
||||
if (!isAdminPasswordConfigured()) {
|
||||
res.status(503).json({ message: 'ADMIN_PASSWORD is not configured on the server.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isAdminPasswordValid(password)) {
|
||||
res.status(401).json({ message: 'Invalid password.' })
|
||||
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 })
|
||||
})
|
||||
|
||||
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 totpState = await loadTotpState()
|
||||
if (!totpState?.secret || !totpState?.verified) {
|
||||
res.status(400).json({ message: 'TOTP is not configured.' })
|
||||
return
|
||||
}
|
||||
|
||||
const codeStr = typeof code === 'string' ? code.trim() : ''
|
||||
|
||||
if (verifyTotpCode(totpState.secret, codeStr)) {
|
||||
const sessionToken = createAdminSession()
|
||||
setAdminSessionCookie(res, sessionToken)
|
||||
res.json({ ok: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (consumeRecoveryCode(totpState, codeStr)) {
|
||||
await saveTotpState(totpState)
|
||||
const sessionToken = createAdminSession()
|
||||
setAdminSessionCookie(res, sessionToken)
|
||||
res.json({ ok: true, usedRecoveryCode: true, remainingRecoveryCodes: totpState.hashedRecoveryCodes.length })
|
||||
return
|
||||
}
|
||||
|
||||
res.status(401).json({ message: 'Invalid code. Try again or use a recovery code.' })
|
||||
})
|
||||
|
||||
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)
|
||||
const existing = await loadTotpState()
|
||||
await saveTotpState({ ...existing, secret, verified: false })
|
||||
res.json({ qrDataUrl, secret })
|
||||
})
|
||||
|
||||
app.post('/api/admin-auth/totp-setup-confirm', requireAdminAuth, async (req, res) => {
|
||||
const { code } = req.body ?? {}
|
||||
const totpState = await loadTotpState()
|
||||
|
||||
if (!totpState?.secret) {
|
||||
res.status(400).json({ message: 'No TOTP setup in progress. Call /totp-setup-init first.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!verifyTotpCode(totpState.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: totpState.secret,
|
||||
verified: true,
|
||||
hashedRecoveryCodes: recoveryCodes.map(hashRecoveryCode),
|
||||
enabledAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
res.json({ ok: true, recoveryCodes })
|
||||
})
|
||||
|
||||
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 })
|
||||
})
|
||||
|
||||
app.post('/api/admin-auth/totp-regen-recovery', requireAdminAuth, async (req, res) => {
|
||||
const totpState = await loadTotpState()
|
||||
if (!totpState?.secret || !totpState?.verified) {
|
||||
res.status(400).json({ message: 'TOTP is not enabled.' })
|
||||
return
|
||||
}
|
||||
const recoveryCodes = generateRecoveryCodes()
|
||||
await saveTotpState({ ...totpState, 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]
|
||||
deleteAdminSession(sessionToken)
|
||||
clearAdminSessionCookie(res)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { sanitizeSiteContent } from '../helpers.js'
|
||||
import { requireAdminAuth, isValidAdminSession } from '../auth.js'
|
||||
import {
|
||||
DATA_DIR,
|
||||
DATA_FILE,
|
||||
DRAFT_DATA_FILE,
|
||||
QUESTIONS_FILE,
|
||||
DEFAULT_SEO,
|
||||
DEFAULT_LEGAL,
|
||||
DEFAULT_REDIRECT_RULES,
|
||||
DEFAULT_PODCAST_FEATURED_LINKS,
|
||||
MAX_QUESTIONS,
|
||||
EMPTY_HIT_STATS,
|
||||
EMPTY_VISITOR_STATS,
|
||||
} from '../config.js'
|
||||
import { state } from '../state.js'
|
||||
import {
|
||||
loadSiteContentFile,
|
||||
getStorageStatus,
|
||||
refreshContentCaches,
|
||||
queueHitStatsWrite,
|
||||
queueVisitorStatsWrite,
|
||||
queueContactSubmissionsWrite,
|
||||
queueReplyTemplatesWrite,
|
||||
queueReplyHistoryWrite,
|
||||
queuePodcastChecklistWrite,
|
||||
createBackupSnapshot,
|
||||
listBackupPreviews,
|
||||
readBackupPreview,
|
||||
restoreFromBackup,
|
||||
sanitizePodcastChecklist,
|
||||
} from '../data.js'
|
||||
import {
|
||||
pruneStatsByDays,
|
||||
filterSiteContentByReleaseDate,
|
||||
} from '../study-helpers.js'
|
||||
|
||||
function invokeWebhook(url, action) {
|
||||
if (!url) {
|
||||
return Promise.resolve({ ok: false, message: `${action} webhook URL is not configured.` })
|
||||
}
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action, at: new Date().toISOString(), source: 'siteforge-admin' }),
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) return { ok: false, message: `${action} webhook failed with ${response.status}.` }
|
||||
return { ok: true, message: `${action} webhook triggered.` }
|
||||
})
|
||||
.catch(err => ({ ok: false, message: err instanceof Error ? err.message : `${action} webhook failed.` }))
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
app.get('/api/admin-content', async (req, res) => {
|
||||
const source = req.query?.source === 'draft' ? 'draft' : 'published'
|
||||
if (source === 'draft' && !isValidAdminSession(req)) {
|
||||
res.status(401).json({ message: 'Unauthorized' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await loadSiteContentFile(source === 'draft' ? DRAFT_DATA_FILE : DATA_FILE)
|
||||
if (source === 'published') {
|
||||
const safeSiteContent = filterSiteContentByReleaseDate(parsed.siteContent)
|
||||
res.json({ ...parsed, siteContent: safeSiteContent })
|
||||
return
|
||||
}
|
||||
res.json(parsed)
|
||||
} catch {
|
||||
if (source === 'draft') {
|
||||
res.status(404).json({ message: 'No saved draft content file yet.' })
|
||||
return
|
||||
}
|
||||
res.status(404).json({ message: 'No saved admin content file yet.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/admin-content-state', requireAdminAuth, (_req, res) => {
|
||||
res.json({
|
||||
publishState: state.publishState,
|
||||
hasDraft: Boolean(state.cachedDraftSiteContent),
|
||||
hasPublished: Boolean(state.cachedSiteContent),
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/api/admin-storage-status', requireAdminAuth, async (_req, res) => {
|
||||
const status = await getStorageStatus()
|
||||
res.json(status)
|
||||
})
|
||||
|
||||
app.get('/api/admin-podcast-checklist', requireAdminAuth, (_req, res) => {
|
||||
res.json({ checklist: state.podcastChecklist })
|
||||
})
|
||||
|
||||
app.put('/api/admin-podcast-checklist', requireAdminAuth, async (req, res) => {
|
||||
try {
|
||||
const safeChecklist = sanitizePodcastChecklist(req.body?.checklist)
|
||||
state.podcastChecklist = safeChecklist
|
||||
await queuePodcastChecklistWrite()
|
||||
res.json({ ok: true, checklist: safeChecklist })
|
||||
} catch {
|
||||
res.status(500).json({ message: 'Failed to save podcast checklist.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/site-config', async (_req, res) => {
|
||||
try {
|
||||
const parsed = await loadSiteContentFile(DATA_FILE)
|
||||
const siteContent = parsed.siteContent ?? {}
|
||||
res.json({
|
||||
seo: siteContent.seo ?? DEFAULT_SEO,
|
||||
legal: siteContent.legal ?? DEFAULT_LEGAL,
|
||||
redirects: siteContent.redirects ?? DEFAULT_REDIRECT_RULES,
|
||||
podcastFeaturedLinks: siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS,
|
||||
publishState: state.publishState,
|
||||
updatedAt: parsed.updatedAt ?? null,
|
||||
})
|
||||
} catch {
|
||||
res.json({
|
||||
seo: DEFAULT_SEO,
|
||||
legal: DEFAULT_LEGAL,
|
||||
redirects: DEFAULT_REDIRECT_RULES,
|
||||
podcastFeaturedLinks: DEFAULT_PODCAST_FEATURED_LINKS,
|
||||
publishState: state.publishState,
|
||||
updatedAt: null,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
app.put('/api/admin-content-draft', requireAdminAuth, async (req, res) => {
|
||||
try {
|
||||
const { siteContent } = req.body ?? {}
|
||||
|
||||
if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) {
|
||||
res.status(400).json({ message: 'Invalid payload: siteContent must be an object.' })
|
||||
return
|
||||
}
|
||||
|
||||
const safeSiteContent = sanitizeSiteContent(siteContent)
|
||||
const updatedAt = new Date().toISOString()
|
||||
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(
|
||||
DRAFT_DATA_FILE,
|
||||
JSON.stringify({ siteContent: safeSiteContent, updatedAt }, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
state.cachedDraftSiteContent = safeSiteContent
|
||||
state.publishState.draftUpdatedAt = updatedAt
|
||||
|
||||
res.json({ ok: true, updatedAt })
|
||||
} catch (err) {
|
||||
console.error('[admin-content-draft] persist error:', err)
|
||||
const reason = err instanceof Error ? err.message : 'Unknown write error'
|
||||
res.status(500).json({ message: `Failed to persist admin draft content to ${DATA_DIR}: ${reason}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/admin-content/publish', requireAdminAuth, async (_req, res) => {
|
||||
try {
|
||||
const source = state.cachedDraftSiteContent
|
||||
? { siteContent: state.cachedDraftSiteContent, updatedAt: state.publishState.draftUpdatedAt ?? new Date().toISOString() }
|
||||
: await loadSiteContentFile(DRAFT_DATA_FILE)
|
||||
|
||||
const publishedAt = new Date().toISOString()
|
||||
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(
|
||||
DATA_FILE,
|
||||
JSON.stringify({ siteContent: source.siteContent, updatedAt: publishedAt }, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
state.cachedSiteContent = source.siteContent
|
||||
state.publishState.publishedAt = publishedAt
|
||||
|
||||
if (state.draftQuestions !== null) {
|
||||
state.questions = state.draftQuestions.slice(0, MAX_QUESTIONS)
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(
|
||||
QUESTIONS_FILE,
|
||||
JSON.stringify({ questions: state.questions, updatedAt: publishedAt }, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
}
|
||||
|
||||
await createBackupSnapshot('post-publish')
|
||||
res.json({ ok: true, publishedAt })
|
||||
} catch (err) {
|
||||
console.error('[admin-content-publish] persist error:', err)
|
||||
const reason = err instanceof Error ? err.message : 'Unknown write error'
|
||||
res.status(500).json({ message: `Failed to publish draft content to ${DATA_DIR}: ${reason}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.put('/api/admin-content', requireAdminAuth, async (req, res) => {
|
||||
try {
|
||||
const { siteContent } = req.body ?? {}
|
||||
|
||||
if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) {
|
||||
res.status(400).json({ message: 'Invalid payload: siteContent must be an object.' })
|
||||
return
|
||||
}
|
||||
|
||||
const safeSiteContent = sanitizeSiteContent(siteContent)
|
||||
const updatedAt = new Date().toISOString()
|
||||
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(
|
||||
DATA_FILE,
|
||||
JSON.stringify({ siteContent: safeSiteContent, updatedAt }, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
state.cachedSiteContent = safeSiteContent
|
||||
state.publishState.publishedAt = updatedAt
|
||||
|
||||
res.json({ ok: true })
|
||||
} catch {
|
||||
res.status(500).json({ message: 'Failed to persist admin content.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/admin-ops/status', requireAdminAuth, (_req, res) => {
|
||||
res.json({
|
||||
buildCommit: process.env.BUILD_COMMIT ?? null,
|
||||
buildNumber: process.env.BUILD_NUMBER ?? null,
|
||||
deployedAt: process.env.DEPLOYED_AT ?? null,
|
||||
cachePurge: state.lastCachePurgeStatus,
|
||||
deployHook: state.lastDeployHookStatus,
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/admin-ops/purge-cache', requireAdminAuth, async (_req, res) => {
|
||||
const result = await invokeWebhook(process.env.CACHE_PURGE_WEBHOOK_URL ?? '', 'cache-purge')
|
||||
state.lastCachePurgeStatus = { ok: result.ok, at: new Date().toISOString(), error: result.ok ? null : result.message }
|
||||
if (!result.ok) {
|
||||
res.status(400).json({ message: result.message })
|
||||
return
|
||||
}
|
||||
res.json({ ok: true, message: result.message })
|
||||
})
|
||||
|
||||
app.post('/api/admin-ops/deploy', requireAdminAuth, async (_req, res) => {
|
||||
const result = await invokeWebhook(process.env.DEPLOY_WEBHOOK_URL ?? '', 'deploy')
|
||||
state.lastDeployHookStatus = { ok: result.ok, at: new Date().toISOString(), error: result.ok ? null : result.message }
|
||||
if (!result.ok) {
|
||||
res.status(400).json({ message: result.message })
|
||||
return
|
||||
}
|
||||
res.json({ ok: true, message: result.message })
|
||||
})
|
||||
|
||||
app.get('/api/admin-stats/export', requireAdminAuth, async (_req, res) => {
|
||||
let adminContent = null
|
||||
let draftContent = null
|
||||
try {
|
||||
const raw = await readFile(DATA_FILE, 'utf8')
|
||||
adminContent = JSON.parse(raw)
|
||||
} catch { adminContent = null }
|
||||
|
||||
try {
|
||||
const rawDraft = await readFile(DRAFT_DATA_FILE, 'utf8')
|
||||
draftContent = JSON.parse(rawDraft)
|
||||
} catch { draftContent = null }
|
||||
|
||||
res.json({
|
||||
exportedAt: new Date().toISOString(),
|
||||
adminContent,
|
||||
draftContent,
|
||||
publishState: state.publishState,
|
||||
hitStats: state.hitStats,
|
||||
visitorStats: state.visitorStats,
|
||||
contactSubmissions: state.contactSubmissions,
|
||||
replyTemplates: state.replyTemplates,
|
||||
replyHistory: state.replyHistory,
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/admin-stats/clear', requireAdminAuth, (_req, res) => {
|
||||
state.hitStats = { ...EMPTY_HIT_STATS }
|
||||
state.visitorStats = { ...EMPTY_VISITOR_STATS }
|
||||
queueHitStatsWrite()
|
||||
queueVisitorStatsWrite()
|
||||
createBackupSnapshot('post-clear').catch(() => {})
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.post('/api/admin-stats/prune', requireAdminAuth, (req, res) => {
|
||||
const result = pruneStatsByDays(req.body?.days)
|
||||
queueHitStatsWrite()
|
||||
queueVisitorStatsWrite()
|
||||
createBackupSnapshot('post-prune').catch(() => {})
|
||||
res.json({ ok: true, ...result })
|
||||
})
|
||||
|
||||
app.post('/api/admin-stats/backup', requireAdminAuth, async (_req, res) => {
|
||||
await createBackupSnapshot('manual')
|
||||
res.json({ ok: true, backup: state.lastBackupStatus })
|
||||
})
|
||||
|
||||
app.get('/api/admin-stats/backups', requireAdminAuth, async (_req, res) => {
|
||||
try {
|
||||
const backups = await listBackupPreviews()
|
||||
res.json({ backups })
|
||||
} catch {
|
||||
res.status(500).json({ message: 'Could not list backups.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/admin-stats/backup-preview', requireAdminAuth, async (req, res) => {
|
||||
try {
|
||||
const { filename } = req.body ?? {}
|
||||
const preview = await readBackupPreview(filename)
|
||||
res.json({ preview })
|
||||
} catch (err) {
|
||||
res.status(400).json({ message: err instanceof Error ? err.message : 'Could not load backup preview.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/admin-stats/restore', requireAdminAuth, async (req, res) => {
|
||||
try {
|
||||
const { filename } = req.body ?? {}
|
||||
await restoreFromBackup(filename)
|
||||
const backups = await listBackupPreviews()
|
||||
res.json({ ok: true, restored: filename, backups })
|
||||
} catch (err) {
|
||||
res.status(400).json({ message: err instanceof Error ? err.message : 'Restore failed.' })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import { getClientIp, hasVisitorConsent, setConsentCookie, parseCookies } from '../helpers.js'
|
||||
import {
|
||||
VISITOR_COOKIE,
|
||||
MAX_RECENT_VISITS,
|
||||
} from '../config.js'
|
||||
import { state } from '../state.js'
|
||||
import { queueVisitorStatsWrite, queueHitStatsWrite, normalizeMessageType } from '../data.js'
|
||||
import {
|
||||
detectBot,
|
||||
sanitizeUserAgent,
|
||||
detectDevice,
|
||||
sanitizeReferrer,
|
||||
normalizeHitPath,
|
||||
isPrivateOrLocalIp,
|
||||
buildTopLocations,
|
||||
buildLastNDaysStats,
|
||||
shouldCountHit,
|
||||
recordHit,
|
||||
} from '../study-helpers.js'
|
||||
import { getStudyCatalog } from '../study-helpers.js'
|
||||
|
||||
async function resolveGeo(ip) {
|
||||
if (!ip || isPrivateOrLocalIp(ip)) {
|
||||
return { country: 'Local/Unknown', state: 'Local/Unknown', county: 'Local/Unknown', city: 'Local/Unknown' }
|
||||
}
|
||||
|
||||
const cached = state.visitorStats.geoCacheByIp[ip]
|
||||
if (cached) return cached
|
||||
|
||||
const providers = [
|
||||
async () => {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 2500)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://ip-api.com/json/${encodeURIComponent(ip)}?fields=status,country,regionName,city,district`,
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
if (!response.ok) return null
|
||||
const data = await response.json()
|
||||
if (data?.status !== 'success') return null
|
||||
return { country: data?.country || 'Unknown', state: data?.regionName || 'Unknown', county: data?.district || 'Unknown', city: data?.city || 'Unknown' }
|
||||
} finally { clearTimeout(timeout) }
|
||||
},
|
||||
async () => {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 2500)
|
||||
try {
|
||||
const response = await fetch(`https://ipwho.is/${encodeURIComponent(ip)}`, { signal: controller.signal })
|
||||
if (!response.ok) return null
|
||||
const data = await response.json()
|
||||
if (!data?.success) return null
|
||||
return { country: data?.country || 'Unknown', state: data?.region || 'Unknown', county: data?.region || 'Unknown', city: data?.city || 'Unknown' }
|
||||
} finally { clearTimeout(timeout) }
|
||||
},
|
||||
]
|
||||
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
const geo = await provider()
|
||||
if (geo) {
|
||||
state.visitorStats.geoCacheByIp[ip] = geo
|
||||
queueVisitorStatsWrite()
|
||||
return geo
|
||||
}
|
||||
} catch { /* Try next provider */ }
|
||||
}
|
||||
|
||||
const fallback = { country: 'Unknown', state: 'Unknown', county: 'Unknown', city: 'Unknown' }
|
||||
state.visitorStats.geoCacheByIp[ip] = fallback
|
||||
queueVisitorStatsWrite()
|
||||
return fallback
|
||||
}
|
||||
|
||||
export async function recordVisitor(req, res, overridePath = null, overrideReferrer = null) {
|
||||
const cookies = parseCookies(req.headers.cookie)
|
||||
let visitorId = cookies[VISITOR_COOKIE]
|
||||
if (!visitorId) {
|
||||
visitorId = randomUUID()
|
||||
res.append('Set-Cookie', `${VISITOR_COOKIE}=${encodeURIComponent(visitorId)}; Max-Age=31536000; Path=/; SameSite=Lax`)
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString()
|
||||
const pathKey = overridePath ? normalizeHitPath(overridePath) : normalizeHitPath(req.path)
|
||||
const referrer = overrideReferrer !== null ? sanitizeReferrer(overrideReferrer) : sanitizeReferrer(req.get('referer') || req.get('referrer') || '')
|
||||
const ip = getClientIp(req)
|
||||
const ua = sanitizeUserAgent(req.get('user-agent'))
|
||||
const device = detectDevice(ua)
|
||||
|
||||
const ipHash = createHash('sha256').update(ip).digest('hex')
|
||||
const geo = await resolveGeo(ip)
|
||||
|
||||
const existingIdByIp = state.visitorStats.ipHashIndex[ipHash]
|
||||
if (existingIdByIp && existingIdByIp !== visitorId) {
|
||||
visitorId = existingIdByIp
|
||||
res.append('Set-Cookie', `${VISITOR_COOKIE}=${encodeURIComponent(visitorId)}; Max-Age=31536000; Path=/; SameSite=Lax`)
|
||||
}
|
||||
|
||||
const existingVisitor = state.visitorStats.visitors[visitorId]
|
||||
const isReturning = Boolean(existingVisitor)
|
||||
|
||||
if (!existingVisitor) {
|
||||
state.visitorStats.uniqueVisitors += 1
|
||||
state.visitorStats.ipHashIndex[ipHash] = visitorId
|
||||
} else {
|
||||
state.visitorStats.returningVisits += 1
|
||||
}
|
||||
|
||||
const nextVisitCount = (existingVisitor?.visitCount ?? 0) + 1
|
||||
const userAgents = Array.from(new Set([...(existingVisitor?.userAgents ?? []), ua])).slice(0, 5)
|
||||
const prevHistory = existingVisitor?.pageHistory ?? []
|
||||
const pageHistory = [...prevHistory, { at: nowIso, path: pathKey, referrer }].slice(-100)
|
||||
|
||||
state.visitorStats.visitors[visitorId] = {
|
||||
visitorId, ip, ipHash,
|
||||
firstSeenAt: existingVisitor?.firstSeenAt ?? nowIso,
|
||||
lastSeenAt: nowIso,
|
||||
visitCount: nextVisitCount,
|
||||
lastPath: pathKey,
|
||||
returningVisitor: isReturning,
|
||||
location: geo,
|
||||
userAgents,
|
||||
device,
|
||||
pageHistory,
|
||||
}
|
||||
|
||||
state.visitorStats.totalVisits += 1
|
||||
state.visitorStats.firstVisitAt = state.visitorStats.firstVisitAt ?? nowIso
|
||||
state.visitorStats.lastVisitAt = nowIso
|
||||
state.visitorStats.recentVisits.unshift({
|
||||
at: nowIso, visitorId, ip, path: pathKey, referrer, device,
|
||||
country: geo.country, state: geo.state, county: geo.county, city: geo.city,
|
||||
returningVisitor: isReturning, visitCount: nextVisitCount,
|
||||
})
|
||||
state.visitorStats.recentVisits = state.visitorStats.recentVisits.slice(0, MAX_RECENT_VISITS)
|
||||
|
||||
queueVisitorStatsWrite()
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
app.post('/api/analytics-consent', (req, res) => {
|
||||
const consent = req.body?.consent === true
|
||||
setConsentCookie(res, consent)
|
||||
res.json({ ok: true, consent })
|
||||
})
|
||||
|
||||
app.post('/api/analytics/pageview', async (req, res) => {
|
||||
if (!hasVisitorConsent(req)) {
|
||||
res.json({ ok: false, reason: 'no-consent' }); return
|
||||
}
|
||||
const ua = req.get('user-agent') ?? ''
|
||||
const { isBot } = detectBot(ua)
|
||||
if (isBot) {
|
||||
res.json({ ok: false, reason: 'bot' }); return
|
||||
}
|
||||
const rawPath = typeof req.body?.path === 'string' ? req.body.path : '/'
|
||||
const rawReferrer = typeof req.body?.referrer === 'string' ? req.body.referrer : ''
|
||||
recordHit(rawPath, false)
|
||||
queueHitStatsWrite()
|
||||
await recordVisitor(req, res, rawPath, rawReferrer)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.get('/api/admin-stats', requireAdminAuth, (_req, res) => {
|
||||
const topPaths = Object.entries(state.hitStats.byPath)
|
||||
.sort((a, b) => b[1] - a[1]).slice(0, 10).map(([pathKey, hits]) => ({ path: pathKey, hits }))
|
||||
const topPathsReal = Object.entries(state.hitStats.byPathReal)
|
||||
.sort((a, b) => b[1] - a[1]).slice(0, 10).map(([pathKey, hits]) => ({ path: pathKey, hits }))
|
||||
const topPathsBot = Object.entries(state.hitStats.byPathBot)
|
||||
.sort((a, b) => b[1] - a[1]).slice(0, 10).map(([pathKey, hits]) => ({ path: pathKey, hits }))
|
||||
|
||||
const last7Days = buildLastNDaysStats(7)
|
||||
const last7DaysReal = last7Days.map(item => ({ day: item.day, hits: state.hitStats.byDayReal?.[item.day] ?? 0 }))
|
||||
const last7DaysBot = last7Days.map(item => ({ day: item.day, hits: state.hitStats.byDayBot?.[item.day] ?? 0 }))
|
||||
|
||||
const last30Days = buildLastNDaysStats(30)
|
||||
const last30DaysTotal = last30Days.reduce((sum, item) => sum + item.hits, 0)
|
||||
const last30DaysRealTotal = last30Days.reduce((sum, item) => sum + (state.hitStats.byDayReal?.[item.day] ?? 0), 0)
|
||||
const last30DaysBotTotal = last30Days.reduce((sum, item) => sum + (state.hitStats.byDayBot?.[item.day] ?? 0), 0)
|
||||
|
||||
const botReasons = Object.entries(state.hitStats.botReasons ?? {})
|
||||
.sort((a, b) => b[1] - a[1]).slice(0, 10).map(([reason, count]) => ({ reason, count }))
|
||||
|
||||
const recentVisitorRows = state.visitorStats.recentVisits.slice(0, 100).map(row => {
|
||||
const fullVisitor = state.visitorStats.visitors[row.visitorId]
|
||||
return { ...row, pageHistory: fullVisitor?.pageHistory ?? [] }
|
||||
})
|
||||
|
||||
const enrollmentCountsBySlug = {}
|
||||
for (const user of state.studyUsers) {
|
||||
const userEnrollments = Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : []
|
||||
for (const studySlug of userEnrollments) {
|
||||
enrollmentCountsBySlug[studySlug] = (enrollmentCountsBySlug[studySlug] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
const enrollmentsByStudy = getStudyCatalog()
|
||||
.map(study => ({ slug: study.slug, title: study.title, count: enrollmentCountsBySlug[study.slug] ?? 0 }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
|
||||
const studyCatalogBySlug = new Map(getStudyCatalog().map(study => [study.slug, study]))
|
||||
const users = state.studyUsers
|
||||
.map(user => {
|
||||
const enrolledStudySlugs = Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : []
|
||||
const enrolledStudies = enrolledStudySlugs.map(slug => {
|
||||
const study = studyCatalogBySlug.get(slug)
|
||||
return study ? { slug: study.slug, title: study.title } : null
|
||||
}).filter(Boolean)
|
||||
return { id: user.id, username: user.username, displayName: user.displayName ?? '', enrolledStudies }
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (b.enrolledStudies.length !== a.enrolledStudies.length) return b.enrolledStudies.length - a.enrolledStudies.length
|
||||
return a.username.localeCompare(b.username)
|
||||
})
|
||||
|
||||
const enrolledUsers = state.studyUsers.filter(user => (user.enrolledStudySlugs?.length ?? 0) > 0).length
|
||||
const totalEnrollments = Object.values(enrollmentCountsBySlug).reduce((sum, count) => sum + count, 0)
|
||||
|
||||
res.json({
|
||||
totalHits: state.hitStats.totalHits,
|
||||
realHits: state.hitStats.realHits ?? 0,
|
||||
botHits: state.hitStats.botHits ?? 0,
|
||||
firstHitAt: state.hitStats.firstHitAt,
|
||||
lastHitAt: state.hitStats.lastHitAt,
|
||||
topPaths, topPathsReal, topPathsBot,
|
||||
last7Days, last7DaysReal, last7DaysBot,
|
||||
last30DaysTotal, last30DaysRealTotal, last30DaysBotTotal,
|
||||
botReasons,
|
||||
visitors: {
|
||||
totalVisits: state.visitorStats.totalVisits,
|
||||
uniqueVisitors: state.visitorStats.uniqueVisitors,
|
||||
returningVisits: state.visitorStats.returningVisits,
|
||||
firstVisitAt: state.visitorStats.firstVisitAt,
|
||||
lastVisitAt: state.visitorStats.lastVisitAt,
|
||||
topCountries: buildTopLocations(recentVisitorRows, 'country'),
|
||||
topStates: buildTopLocations(recentVisitorRows, 'state'),
|
||||
topCounties: buildTopLocations(recentVisitorRows, 'county'),
|
||||
topCities: buildTopLocations(recentVisitorRows, 'city'),
|
||||
deviceBreakdown: (() => {
|
||||
const counts = { mobile: 0, desktop: 0, tablet: 0, unknown: 0 }
|
||||
for (const row of recentVisitorRows) {
|
||||
const d = row.device ?? 'unknown'
|
||||
counts[d] = (counts[d] ?? 0) + 1
|
||||
}
|
||||
return counts
|
||||
})(),
|
||||
topReferrers: (() => {
|
||||
const counts = {}
|
||||
for (const row of recentVisitorRows) {
|
||||
if (!row.referrer) continue
|
||||
counts[row.referrer] = (counts[row.referrer] ?? 0) + 1
|
||||
}
|
||||
return Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([referrer, count]) => ({ referrer, count }))
|
||||
})(),
|
||||
last30DaysReal: buildLastNDaysStats(30).map(item => ({ day: item.day, hits: state.hitStats.byDayReal?.[item.day] ?? 0 })),
|
||||
recentVisits: recentVisitorRows,
|
||||
},
|
||||
writeStatus: {
|
||||
hitStats: state.lastHitStatsWrite,
|
||||
visitorStats: state.lastVisitorStatsWrite,
|
||||
backups: state.lastBackupStatus,
|
||||
cachePurge: state.lastCachePurgeStatus,
|
||||
deployHook: state.lastDeployHookStatus,
|
||||
},
|
||||
contactTotals: {
|
||||
totalSubmissions: state.contactSubmissions.length,
|
||||
totalQuestions: state.contactSubmissions.filter(entry => normalizeMessageType(entry?.messageType) === 'question').length,
|
||||
},
|
||||
studyEnrollment: {
|
||||
totalUsers: state.studyUsers.length,
|
||||
enrolledUsers,
|
||||
totalEnrollments,
|
||||
enrollmentsByStudy,
|
||||
users,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Resend } from 'resend'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import { escapeHtml, splitName } from '../helpers.js'
|
||||
import {
|
||||
MAX_CONTACT_SUBMISSIONS,
|
||||
MAX_QUESTIONS,
|
||||
USE_RESEND_AUTOMATION_WELCOME,
|
||||
DEFAULT_SEO,
|
||||
ADMIN_REPLY_FROM,
|
||||
} from '../config.js'
|
||||
import { state } from '../state.js'
|
||||
import {
|
||||
queueContactSubmissionsWrite,
|
||||
queueQuestionsWrite,
|
||||
queueDraftQuestionsWrite,
|
||||
queueReplyTemplatesWrite,
|
||||
queueReplyHistoryWrite,
|
||||
normalizeContactEmailStatus,
|
||||
normalizeMessageType,
|
||||
sanitizeReplyTemplates,
|
||||
sanitizeReplyHistory,
|
||||
} from '../data.js'
|
||||
import {
|
||||
noteContactEmailCooldown,
|
||||
extractTagValue,
|
||||
mapResendEventToStatus,
|
||||
extractResendMessageId,
|
||||
} from '../study-helpers.js'
|
||||
import {
|
||||
getResendFromAddress,
|
||||
getResendReplyToAddress,
|
||||
getResendInboxAddress,
|
||||
getAddressDomain,
|
||||
buildContactWelcomeEmailTemplate,
|
||||
buildContactAdminNotificationTemplate,
|
||||
buildAdminReplyTemplate,
|
||||
sendResendEmailWithRetry,
|
||||
syncContactToResend,
|
||||
} from '../email.js'
|
||||
|
||||
function upsertContactEmailStatus(submissionId, stream, patch) {
|
||||
if (!submissionId || typeof submissionId !== 'string') return
|
||||
if (!stream || typeof stream !== 'string') return
|
||||
const at = typeof patch?.lastEventAt === 'string' ? patch.lastEventAt : new Date().toISOString()
|
||||
let updated = false
|
||||
|
||||
state.contactSubmissions = state.contactSubmissions.map(submission => {
|
||||
if (submission.id !== submissionId) return submission
|
||||
const next = normalizeContactEmailStatus(submission.emailStatus, submission.subscribe === true)
|
||||
const current = next[stream] ?? { status: 'pending', lastEventAt: null, lastEventType: null, resendEmailId: null, error: null }
|
||||
next[stream] = { ...current, ...patch, lastEventAt: at }
|
||||
updated = true
|
||||
return { ...submission, emailStatus: next }
|
||||
})
|
||||
|
||||
if (updated) queueContactSubmissionsWrite()
|
||||
}
|
||||
|
||||
function registerResendMessageForSubmission(submissionId, stream, sendResult) {
|
||||
const resendMessageId = extractResendMessageId(sendResult)
|
||||
if (!resendMessageId || !submissionId || !stream) return
|
||||
state.resendEmailSubmissionIndex.set(resendMessageId, { submissionId, stream })
|
||||
upsertContactEmailStatus(submissionId, stream, { resendEmailId: resendMessageId })
|
||||
}
|
||||
|
||||
function shouldSendWelcomeEmail({ subscribe }) {
|
||||
return subscribe === true
|
||||
}
|
||||
|
||||
const contactHits = new Map()
|
||||
|
||||
function contactRateLimit(req, res, next) {
|
||||
const ip = req.ip ?? 'unknown'
|
||||
const now = Date.now()
|
||||
const windowMs = 10 * 60 * 1000
|
||||
const entry = contactHits.get(ip) ?? { count: 0, start: now }
|
||||
if (now - entry.start > windowMs) { entry.count = 0; entry.start = now }
|
||||
entry.count += 1
|
||||
contactHits.set(ip, entry)
|
||||
if (entry.count > 5) {
|
||||
res.status(429).json({ message: 'Too many messages. Please wait a few minutes.' })
|
||||
return
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
app.post('/api/contact', contactRateLimit, async (req, res) => {
|
||||
try {
|
||||
const { firstName, lastName, email, message, messageType, subscribe, _honey } = req.body ?? {}
|
||||
|
||||
if (_honey) { res.json({ ok: true }); return }
|
||||
|
||||
if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) {
|
||||
res.status(400).json({ message: 'First name is required.' }); return
|
||||
}
|
||||
if (lastName !== undefined && lastName !== null && (typeof lastName !== 'string' || lastName.trim().length > 100)) {
|
||||
res.status(400).json({ message: 'Last name is too long.' }); return
|
||||
}
|
||||
if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
|
||||
res.status(400).json({ message: 'A valid email address is required.' }); return
|
||||
}
|
||||
if (!message || typeof message !== 'string' || message.trim().length < 5 || message.trim().length > 3000) {
|
||||
res.status(400).json({ message: 'Message must be between 5 and 3000 characters.' }); return
|
||||
}
|
||||
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
console.error('[contact] RESEND_API_KEY env var not set')
|
||||
res.status(503).json({ message: 'The contact form is not yet configured on the server.' }); return
|
||||
}
|
||||
|
||||
const trimmedName = [firstName.trim(), typeof lastName === 'string' ? lastName.trim() : ''].filter(Boolean).join(' ')
|
||||
const trimmedEmail = email.trim()
|
||||
const trimmedMessage = message.trim()
|
||||
const normalizedMessageType = normalizeMessageType(messageType)
|
||||
const cooldown = noteContactEmailCooldown(trimmedEmail)
|
||||
if (!cooldown.ok) {
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil(cooldown.retryAfterMs / 1000))
|
||||
res.status(429).json({ message: `Please wait ${retryAfterSeconds}s before sending another message from this email.` }); return
|
||||
}
|
||||
|
||||
const submittedAt = new Date().toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
const shouldSendWelcome = shouldSendWelcomeEmail({ subscribe })
|
||||
|
||||
const wantsWelcome = subscribe === true
|
||||
const submission = {
|
||||
id: randomUUID(),
|
||||
submittedAt: new Date().toISOString(),
|
||||
name: trimmedName,
|
||||
email: trimmedEmail,
|
||||
message: trimmedMessage,
|
||||
messageType: normalizedMessageType,
|
||||
subscribe: wantsWelcome,
|
||||
archived: false,
|
||||
emailStatus: normalizeContactEmailStatus(null, wantsWelcome),
|
||||
}
|
||||
state.contactSubmissions.unshift(submission)
|
||||
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
|
||||
queueContactSubmissionsWrite()
|
||||
|
||||
if (normalizedMessageType === 'question') {
|
||||
const question = {
|
||||
id: randomUUID(),
|
||||
submittedAt: new Date().toISOString(),
|
||||
firstName: splitName(trimmedName).firstName,
|
||||
email: trimmedEmail,
|
||||
question: trimmedMessage,
|
||||
answer: '',
|
||||
answeredAt: null,
|
||||
isApproved: false,
|
||||
approvedAt: null,
|
||||
}
|
||||
state.questions.unshift(question)
|
||||
state.questions = state.questions.slice(0, MAX_QUESTIONS)
|
||||
if (state.draftQuestions !== null) {
|
||||
state.draftQuestions.unshift(question)
|
||||
state.draftQuestions = state.draftQuestions.slice(0, MAX_QUESTIONS)
|
||||
queueDraftQuestionsWrite()
|
||||
}
|
||||
queueQuestionsWrite()
|
||||
}
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const adminInbox = getResendInboxAddress()
|
||||
const replyToAddress = getResendReplyToAddress()
|
||||
const fromAddress = getResendFromAddress()
|
||||
const safeMessageTypeTag = normalizedMessageType.replace(/[^a-z0-9_-]/gi, '-').toLowerCase()
|
||||
const adminTemplate = buildContactAdminNotificationTemplate({ normalizedMessageType, trimmedName, trimmedEmail, submittedAt, trimmedMessage })
|
||||
let welcomeSent = false
|
||||
|
||||
if (subscribe === true) {
|
||||
await syncContactToResend(trimmedName, trimmedEmail)
|
||||
}
|
||||
|
||||
if (shouldSendWelcome && !USE_RESEND_AUTOMATION_WELCOME) {
|
||||
const greetingName = splitName(trimmedName).firstName?.trim() ?? ''
|
||||
let publishedSiteContent = state.cachedSiteContent
|
||||
if (!publishedSiteContent) {
|
||||
try {
|
||||
const { loadSiteContentFile } = await import('../data.js')
|
||||
const { DATA_FILE } = await import('../config.js')
|
||||
const published = await loadSiteContentFile(DATA_FILE)
|
||||
publishedSiteContent = published?.siteContent ?? null
|
||||
} catch { publishedSiteContent = null }
|
||||
}
|
||||
|
||||
const emailConfig = publishedSiteContent ?? {}
|
||||
const welcomeBaseUrl = typeof emailConfig?.seo?.canonicalUrl === 'string' && emailConfig.seo.canonicalUrl.trim()
|
||||
? emailConfig.seo.canonicalUrl.trim()
|
||||
: DEFAULT_SEO.canonicalUrl
|
||||
const welcomeSubject = process.env.RESEND_WELCOME_SUBJECT ?? emailConfig.welcomeEmailSubject ?? 'Welcome to Verse by Verse with Nate'
|
||||
const welcomeGreetingPrefix = emailConfig.welcomeEmailGreetingPrefix?.trim() || "Glad you're here"
|
||||
const welcomeHeading = greetingName
|
||||
? `${escapeHtml(welcomeGreetingPrefix)}, ${escapeHtml(greetingName)}.`
|
||||
: `${escapeHtml(welcomeGreetingPrefix)}.`
|
||||
const { buildAbsoluteUrl } = await import('../helpers.js')
|
||||
const welcomeTemplate = buildContactWelcomeEmailTemplate({
|
||||
greetingName,
|
||||
welcomeIntro: emailConfig.welcomeEmailIntro?.trim() || 'Thanks for subscribing to Verse by Verse with Nate - a Bible teaching podcast where we slow down, dig into the text, and pull out the nuggets God has for us word by word.',
|
||||
welcomeCurrentSeries: emailConfig.welcomeEmailCurrentSeries?.trim() || "Right now we're working through the book of Titus - a short letter packed with practical wisdom about grace, godliness, and what the Christian life looks like when it's rooted in sound doctrine.",
|
||||
welcomeStartHereTitle: emailConfig.welcomeEmailStartHereTitle?.trim() || 'Episode 1 - Introduction to Titus',
|
||||
welcomeStartHereSummary: emailConfig.welcomeEmailStartHereSummary?.trim() || 'Who wrote it, who received it, and why it still matters.',
|
||||
welcomeExpect1: emailConfig.welcomeEmailWhatToExpect1?.trim() || 'Verse-by-verse teaching - we go slow and let the text speak for itself.',
|
||||
welcomeExpect2: emailConfig.welcomeEmailWhatToExpect2?.trim() || 'Greek word studies - the kind that open up meaning without being a lecture.',
|
||||
welcomeExpect3: emailConfig.welcomeEmailWhatToExpect3?.trim() || 'New episodes + study notes delivered right to your inbox.',
|
||||
welcomeScripture: emailConfig.welcomeEmailScripture?.trim() || 'For the grace of God has appeared, bringing salvation to all people.',
|
||||
welcomeScriptureRef: emailConfig.welcomeEmailScriptureRef?.trim() || 'Titus 2:11 - BSB',
|
||||
welcomeSignoff: emailConfig.welcomeEmailSignoff?.trim() || 'Grace and peace,\nNate',
|
||||
welcomeHeading,
|
||||
welcomeSpotifyUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_SPOTIFY_URL ?? emailConfig.welcomeEmailSpotifyUrl ?? '/spotify'),
|
||||
welcomeAppleUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_APPLE_URL ?? emailConfig.welcomeEmailAppleUrl ?? '/apple'),
|
||||
welcomeAmazonUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_AMAZON_URL ?? emailConfig.welcomeEmailAmazonUrl ?? '/amazon'),
|
||||
welcomeWebsiteUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_WEBSITE_URL ?? emailConfig.welcomeEmailWebsiteUrl ?? '/'),
|
||||
welcomeEpisodeUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_EPISODE_URL ?? emailConfig.welcomeEmailStartHereUrl ?? '/start-here'),
|
||||
welcomeImageUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_IMAGE_URL ?? emailConfig.welcomeEmailImageUrl ?? '/images/podcast-art.jpeg'),
|
||||
welcomeSpotifyBtnLabel: emailConfig.welcomeEmailSpotifyBtnLabel?.trim() || 'Listen on Spotify',
|
||||
welcomeAppleBtnLabel: emailConfig.welcomeEmailAppleBtnLabel?.trim() || 'Apple Podcasts',
|
||||
welcomeStartHereLinkLabel: emailConfig.welcomeEmailStartHereLinkLabel?.trim() || 'Open Start Here page',
|
||||
})
|
||||
|
||||
try {
|
||||
const welcomeSendResult = await sendResendEmailWithRetry({
|
||||
resend,
|
||||
context: 'contact-welcome',
|
||||
payload: {
|
||||
from: fromAddress,
|
||||
to: [trimmedEmail],
|
||||
replyTo: replyToAddress,
|
||||
subject: welcomeSubject,
|
||||
tags: [
|
||||
{ name: 'flow', value: 'contact-welcome' },
|
||||
{ name: 'message_type', value: safeMessageTypeTag },
|
||||
{ name: 'submission_id', value: submission.id },
|
||||
],
|
||||
headers: {
|
||||
'List-Unsubscribe': `<mailto:${replyToAddress}?subject=Unsubscribe>`,
|
||||
'X-Contact-Submission-Id': submission.id,
|
||||
},
|
||||
text: welcomeTemplate.text,
|
||||
html: welcomeTemplate.html,
|
||||
},
|
||||
})
|
||||
registerResendMessageForSubmission(submission.id, 'welcome', welcomeSendResult)
|
||||
upsertContactEmailStatus(submission.id, 'welcome', { status: 'sent', lastEventType: 'email.sent', error: null })
|
||||
welcomeSent = true
|
||||
} catch (welcomeErr) {
|
||||
upsertContactEmailStatus(submission.id, 'welcome', {
|
||||
status: 'failed',
|
||||
lastEventType: 'email.failed',
|
||||
error: String(welcomeErr?.message ?? welcomeErr ?? 'unknown error').slice(0, 600),
|
||||
})
|
||||
throw welcomeErr
|
||||
}
|
||||
} else if (shouldSendWelcome && USE_RESEND_AUTOMATION_WELCOME) {
|
||||
upsertContactEmailStatus(submission.id, 'welcome', { status: 'automation-enabled', lastEventType: 'email.automation.enabled', error: null })
|
||||
}
|
||||
|
||||
try {
|
||||
const adminSendResult = await sendResendEmailWithRetry({
|
||||
resend,
|
||||
context: 'contact-admin-notification',
|
||||
payload: {
|
||||
from: fromAddress,
|
||||
to: [adminInbox],
|
||||
replyTo: trimmedEmail,
|
||||
subject: adminTemplate.subject,
|
||||
tags: [
|
||||
{ name: 'flow', value: 'contact-admin' },
|
||||
{ name: 'message_type', value: safeMessageTypeTag },
|
||||
{ name: 'submission_id', value: submission.id },
|
||||
],
|
||||
headers: { 'X-Contact-Submission-Id': submission.id },
|
||||
text: adminTemplate.text,
|
||||
html: adminTemplate.html,
|
||||
},
|
||||
})
|
||||
registerResendMessageForSubmission(submission.id, 'adminNotification', adminSendResult)
|
||||
upsertContactEmailStatus(submission.id, 'adminNotification', { status: 'sent', lastEventType: 'email.sent', error: null })
|
||||
} catch (adminSendErr) {
|
||||
upsertContactEmailStatus(submission.id, 'adminNotification', {
|
||||
status: 'failed',
|
||||
lastEventType: 'email.failed',
|
||||
error: String(adminSendErr?.message ?? adminSendErr ?? 'unknown error').slice(0, 600),
|
||||
})
|
||||
throw adminSendErr
|
||||
}
|
||||
|
||||
res.json({ ok: true, welcomeSent, welcomeHandledByAutomation: shouldSendWelcome && USE_RESEND_AUTOMATION_WELCOME })
|
||||
} catch (err) {
|
||||
console.error('[contact] send error:', err)
|
||||
res.status(500).json({ message: 'Failed to send your message. Please try again or email us directly.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/resend/webhook', (req, res) => {
|
||||
const expectedToken = typeof process.env.RESEND_WEBHOOK_TOKEN === 'string' ? process.env.RESEND_WEBHOOK_TOKEN.trim() : ''
|
||||
if (!expectedToken) {
|
||||
res.status(503).json({ message: 'Webhook token is not configured.' }); return
|
||||
}
|
||||
|
||||
const providedToken = (req.get('x-webhook-token') || '').trim()
|
||||
|| (req.get('x-resend-webhook-token') || '').trim()
|
||||
|| String(req.query?.token || '').trim()
|
||||
|| (req.get('authorization') || '').replace(/^Bearer\s+/i, '').trim()
|
||||
|
||||
if (!providedToken || providedToken !== expectedToken) {
|
||||
res.status(401).json({ message: 'Unauthorized webhook.' }); return
|
||||
}
|
||||
|
||||
const body = req.body && typeof req.body === 'object' ? req.body : {}
|
||||
const eventType = typeof body.type === 'string' ? body.type.trim() : ''
|
||||
const data = body.data && typeof body.data === 'object' ? body.data : {}
|
||||
const tags = Array.isArray(data.tags) ? data.tags : []
|
||||
|
||||
const resendMessageId = (
|
||||
typeof data.email_id === 'string' && data.email_id.trim()
|
||||
? data.email_id.trim()
|
||||
: (typeof data.emailId === 'string' && data.emailId.trim()
|
||||
? data.emailId.trim()
|
||||
: (typeof data.id === 'string' && data.id.trim() ? data.id.trim() : ''))
|
||||
)
|
||||
|
||||
const indexed = resendMessageId ? state.resendEmailSubmissionIndex.get(resendMessageId) : null
|
||||
const taggedSubmissionId = extractTagValue(tags, 'submission_id')
|
||||
const submissionId = indexed?.submissionId || taggedSubmissionId
|
||||
|
||||
const flow = extractTagValue(tags, 'flow')
|
||||
const stream = indexed?.stream
|
||||
|| (flow === 'contact-welcome' ? 'welcome' : '')
|
||||
|| (flow === 'contact-admin' ? 'adminNotification' : '')
|
||||
|| (flow === 'admin-reply' ? 'adminReply' : '')
|
||||
|
||||
if (!submissionId || !stream) {
|
||||
res.json({ ok: true, ignored: true }); return
|
||||
}
|
||||
|
||||
upsertContactEmailStatus(submissionId, stream, {
|
||||
status: mapResendEventToStatus(eventType),
|
||||
lastEventType: eventType || 'webhook.event',
|
||||
resendEmailId: resendMessageId || null,
|
||||
error: typeof data?.message === 'string' ? data.message.slice(0, 600) : null,
|
||||
})
|
||||
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.get('/api/admin-contact-email-health', requireAdminAuth, (_req, res) => {
|
||||
const fromAddress = getResendFromAddress()
|
||||
const replyToAddress = getResendReplyToAddress()
|
||||
const fromDomain = getAddressDomain(fromAddress)
|
||||
const replyDomain = getAddressDomain(replyToAddress)
|
||||
const warnings = []
|
||||
|
||||
if (!process.env.RESEND_API_KEY) warnings.push('RESEND_API_KEY is missing.')
|
||||
if (!fromDomain) warnings.push('RESEND_FROM is missing or invalid.')
|
||||
if (fromDomain.endsWith('resend.dev')) warnings.push('RESEND_FROM uses resend.dev. Prefer a verified custom domain.')
|
||||
if (fromDomain && replyDomain && fromDomain !== replyDomain) warnings.push('Sender and reply-to domains are different.')
|
||||
if (!process.env.RESEND_WEBHOOK_TOKEN) warnings.push('RESEND_WEBHOOK_TOKEN is not configured.')
|
||||
warnings.push('Verify SPF, DKIM, and DMARC for the sender domain.')
|
||||
|
||||
const recent = state.contactSubmissions.slice(0, 300)
|
||||
const failed = recent.filter(item => {
|
||||
const status = normalizeContactEmailStatus(item.emailStatus, item.subscribe === true)
|
||||
return ['failed', 'bounced', 'complained'].includes(status.welcome.status)
|
||||
|| ['failed', 'bounced', 'complained'].includes(status.adminNotification.status)
|
||||
|| ['failed', 'bounced', 'complained'].includes(status.adminReply.status)
|
||||
}).length
|
||||
|
||||
res.json({
|
||||
resendApiConfigured: Boolean(process.env.RESEND_API_KEY),
|
||||
webhookConfigured: Boolean(process.env.RESEND_WEBHOOK_TOKEN),
|
||||
fromAddress,
|
||||
replyToAddress,
|
||||
fromDomain,
|
||||
replyDomain,
|
||||
warnings,
|
||||
recentSubmissionFailures: failed,
|
||||
trackedSubmissions: recent.length,
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/api/admin-contact-submissions', requireAdminAuth, (_req, res) => {
|
||||
res.json({ submissions: state.contactSubmissions.slice(0, 300) })
|
||||
})
|
||||
|
||||
app.patch('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => {
|
||||
const { id } = req.params
|
||||
if (typeof id !== 'string' || !id.trim()) {
|
||||
res.status(400).json({ message: 'Invalid submission id.' }); return
|
||||
}
|
||||
|
||||
const archived = req.body?.archived === true
|
||||
let found = false
|
||||
state.contactSubmissions = state.contactSubmissions.map(item => {
|
||||
if (item.id !== id) return item
|
||||
found = true
|
||||
return { ...item, archived }
|
||||
})
|
||||
|
||||
if (!found) {
|
||||
res.status(404).json({ message: 'Submission not found.' }); return
|
||||
}
|
||||
|
||||
queueContactSubmissionsWrite()
|
||||
res.json({ ok: true, archived })
|
||||
})
|
||||
|
||||
app.delete('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => {
|
||||
const { id } = req.params
|
||||
if (typeof id !== 'string' || !id.trim()) {
|
||||
res.status(400).json({ message: 'Invalid submission id.' }); return
|
||||
}
|
||||
|
||||
const startLength = state.contactSubmissions.length
|
||||
state.contactSubmissions = state.contactSubmissions.filter(item => item.id !== id)
|
||||
if (state.contactSubmissions.length === startLength) {
|
||||
res.status(404).json({ message: 'Submission not found.' }); return
|
||||
}
|
||||
|
||||
queueContactSubmissionsWrite()
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.get('/api/admin-reply-config', requireAdminAuth, (_req, res) => {
|
||||
res.json({
|
||||
fromEmail: getResendReplyToAddress(),
|
||||
fromIdentity: getResendFromAddress() || ADMIN_REPLY_FROM,
|
||||
resendApiConfigured: Boolean(process.env.RESEND_API_KEY),
|
||||
canSendReplies: Boolean(process.env.RESEND_API_KEY),
|
||||
note: process.env.RESEND_API_KEY
|
||||
? 'App is configured to attempt sends through Resend. Delivery still depends on Resend sender/domain verification.'
|
||||
: 'RESEND_API_KEY is missing, so admin replies cannot be sent yet.',
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/api/admin-contact-reply-templates', requireAdminAuth, (_req, res) => {
|
||||
res.json({ templates: state.replyTemplates })
|
||||
})
|
||||
|
||||
app.put('/api/admin-contact-reply-templates', requireAdminAuth, (req, res) => {
|
||||
const nextTemplates = sanitizeReplyTemplates(req.body?.templates)
|
||||
state.replyTemplates = nextTemplates
|
||||
queueReplyTemplatesWrite()
|
||||
res.json({ ok: true, templates: state.replyTemplates })
|
||||
})
|
||||
|
||||
app.get('/api/admin-contact-reply-history', requireAdminAuth, (_req, res) => {
|
||||
res.json({ items: state.replyHistory.slice(0, 100) })
|
||||
})
|
||||
|
||||
app.post('/api/admin-contact-submissions/:id/reply', requireAdminAuth, async (req, res) => {
|
||||
try {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
res.status(503).json({ message: 'RESEND_API_KEY is not configured on the server.' }); return
|
||||
}
|
||||
|
||||
const { id } = req.params
|
||||
const subject = typeof req.body?.subject === 'string' ? req.body.subject.trim() : ''
|
||||
const message = typeof req.body?.message === 'string' ? req.body.message.trim() : ''
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
res.status(400).json({ message: 'Invalid submission id.' }); return
|
||||
}
|
||||
if (!subject || subject.length > 180) {
|
||||
res.status(400).json({ message: 'Subject is required and must be 180 characters or fewer.' }); return
|
||||
}
|
||||
if (!message || message.length > 6000) {
|
||||
res.status(400).json({ message: 'Message is required and must be 6000 characters or fewer.' }); return
|
||||
}
|
||||
|
||||
const submission = state.contactSubmissions.find(entry => entry.id === id)
|
||||
if (!submission) {
|
||||
res.status(404).json({ message: 'Submission not found.' }); return
|
||||
}
|
||||
if (!submission.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(submission.email)) {
|
||||
res.status(400).json({ message: 'Submission does not have a valid email address.' }); return
|
||||
}
|
||||
|
||||
const recipientName = splitName(submission.name).firstName || submission.name || 'friend'
|
||||
const html = buildAdminReplyTemplate({ recipientName, message })
|
||||
const replyToAddress = getResendReplyToAddress()
|
||||
const fromAddress = getResendFromAddress()
|
||||
const text = `Hi ${recipientName},\n\n${message}\n\nGrace and peace,\nVerse by Verse with Nate\n${replyToAddress}`
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
|
||||
const sendResult = await sendResendEmailWithRetry({
|
||||
resend,
|
||||
context: 'admin-contact-reply',
|
||||
payload: {
|
||||
from: fromAddress || ADMIN_REPLY_FROM,
|
||||
to: [submission.email],
|
||||
subject,
|
||||
replyTo: replyToAddress,
|
||||
tags: [
|
||||
{ name: 'flow', value: 'admin-reply' },
|
||||
{ name: 'message_type', value: submission.messageType ?? 'general' },
|
||||
{ name: 'submission_id', value: submission.id },
|
||||
],
|
||||
headers: { 'X-Contact-Submission-Id': submission.id },
|
||||
text,
|
||||
html,
|
||||
},
|
||||
})
|
||||
registerResendMessageForSubmission(submission.id, 'adminReply', sendResult)
|
||||
upsertContactEmailStatus(submission.id, 'adminReply', { status: 'sent', lastEventType: 'email.sent', error: null })
|
||||
|
||||
state.replyHistory.unshift({
|
||||
id: randomUUID(),
|
||||
submissionId: submission.id,
|
||||
toEmail: submission.email,
|
||||
toName: submission.name,
|
||||
fromEmail: replyToAddress,
|
||||
subject,
|
||||
preview: message.slice(0, 500),
|
||||
sentAt: new Date().toISOString(),
|
||||
})
|
||||
state.replyHistory = state.replyHistory.slice(0, 500)
|
||||
queueReplyHistoryWrite()
|
||||
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
if (typeof req.params?.id === 'string' && req.params.id.trim()) {
|
||||
upsertContactEmailStatus(req.params.id.trim(), 'adminReply', {
|
||||
status: 'failed',
|
||||
lastEventType: 'email.failed',
|
||||
error: String(err?.message ?? err ?? 'unknown error').slice(0, 600),
|
||||
})
|
||||
}
|
||||
console.error('[admin-reply] send error:', err)
|
||||
res.status(500).json({ message: 'Failed to send reply email.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/admin-subscribers', requireAdminAuth, (_req, res) => {
|
||||
const seen = new Set()
|
||||
const subscribers = state.contactSubmissions
|
||||
.filter(entry => entry.subscribe === true && entry.email && !seen.has(entry.email) && seen.add(entry.email))
|
||||
.map(entry => ({
|
||||
name: entry.name,
|
||||
email: entry.email,
|
||||
subscribedAt: entry.submittedAt,
|
||||
source: entry.message?.startsWith('Requested') ? 'download' : 'contact-form',
|
||||
}))
|
||||
.sort((a, b) => new Date(b.subscribedAt).getTime() - new Date(a.subscribedAt).getTime())
|
||||
res.json({ subscribers, total: subscribers.length })
|
||||
})
|
||||
|
||||
app.post('/api/admin-subscribers/export', requireAdminAuth, (_req, res) => {
|
||||
const seen = new Set()
|
||||
const rows = [['Name', 'Email', 'Subscribed At', 'Source']]
|
||||
state.contactSubmissions
|
||||
.filter(entry => entry.subscribe === true && entry.email && !seen.has(entry.email) && seen.add(entry.email))
|
||||
.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
|
||||
.forEach(entry => {
|
||||
const source = entry.message?.startsWith('Requested') ? 'download' : 'contact-form'
|
||||
rows.push([entry.name, entry.email, entry.submittedAt, source])
|
||||
})
|
||||
const csv = rows.map(row => row.map(cell => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(',')).join('\n')
|
||||
res.setHeader('Content-Type', 'text/csv')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="subscribers-${new Date().toISOString().slice(0, 10)}.csv"`)
|
||||
res.send(csv)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import { TITUS_STUDY_FILE, TITUS_STUDY_DOWNLOAD_NAME } from '../config.js'
|
||||
import { state } from '../state.js'
|
||||
import {
|
||||
loadSiteContentFile,
|
||||
queueContactSubmissionsWrite,
|
||||
normalizeContactEmailStatus,
|
||||
normalizeMessageType,
|
||||
incrementDownloadCount,
|
||||
} from '../data.js'
|
||||
import {
|
||||
createTitusDownloadToken,
|
||||
consumeTitusDownloadToken,
|
||||
sanitizeUrl,
|
||||
} from '../study-helpers.js'
|
||||
import { syncContactToResend } from '../email.js'
|
||||
import { DATA_FILE, MAX_CONTACT_SUBMISSIONS } from '../config.js'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
const downloadHits = new Map()
|
||||
|
||||
function studyDownloadRateLimit(req, res, next) {
|
||||
const ip = req.ip ?? 'unknown'
|
||||
const now = Date.now()
|
||||
const windowMs = 10 * 60 * 1000
|
||||
const entry = downloadHits.get(ip) ?? { count: 0, start: now }
|
||||
if (now - entry.start > windowMs) { entry.count = 0; entry.start = now }
|
||||
entry.count += 1
|
||||
downloadHits.set(ip, entry)
|
||||
if (entry.count > 10) {
|
||||
res.status(429).json({ message: 'Too many download requests. Please wait a few minutes.' })
|
||||
return
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
function addContactSubmission({ name, email, message, messageType, subscribe }) {
|
||||
const wantsWelcome = subscribe === true
|
||||
const submission = {
|
||||
id: randomUUID(),
|
||||
submittedAt: new Date().toISOString(),
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
messageType: normalizeMessageType(messageType),
|
||||
subscribe: wantsWelcome,
|
||||
archived: false,
|
||||
emailStatus: normalizeContactEmailStatus(null, wantsWelcome),
|
||||
}
|
||||
state.contactSubmissions.unshift(submission)
|
||||
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
|
||||
queueContactSubmissionsWrite()
|
||||
return submission
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
app.post('/api/study-downloads/titus', studyDownloadRateLimit, async (req, res) => {
|
||||
try {
|
||||
const { firstName, lastName, email, subscribe, _honey } = req.body ?? {}
|
||||
|
||||
if (_honey) { res.json({ ok: true }); return }
|
||||
|
||||
if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) {
|
||||
res.status(400).json({ message: 'First name is required.' }); return
|
||||
}
|
||||
if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) {
|
||||
res.status(400).json({ message: 'Last name is required.' }); return
|
||||
}
|
||||
if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
|
||||
res.status(400).json({ message: 'A valid email address is required.' }); 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()
|
||||
const trimmedLastName = lastName.trim()
|
||||
const trimmedName = `${trimmedFirstName} ${trimmedLastName}`.trim()
|
||||
const trimmedEmail = email.trim()
|
||||
const wantsSubscribe = subscribe !== false
|
||||
|
||||
addContactSubmission({ name: trimmedName, email: trimmedEmail, message: 'Requested Titus study download.', messageType: 'general', subscribe: wantsSubscribe })
|
||||
|
||||
if (wantsSubscribe) {
|
||||
await syncContactToResend(trimmedName, trimmedEmail)
|
||||
}
|
||||
|
||||
incrementDownloadCount('titus-study')
|
||||
|
||||
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) {
|
||||
console.error('[study-download] request error:', err)
|
||||
res.status(500).json({ message: 'Failed to process your request. Please try again.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/study-downloads/titus/file', async (req, res) => {
|
||||
const token = typeof req.query?.token === 'string' ? req.query.token : ''
|
||||
if (!token || !consumeTitusDownloadToken(token)) {
|
||||
res.status(403).json({ message: 'Invalid or expired download link. Submit the form again.' }); return
|
||||
}
|
||||
|
||||
try {
|
||||
await stat(TITUS_STUDY_FILE)
|
||||
res.download(TITUS_STUDY_FILE, TITUS_STUDY_DOWNLOAD_NAME)
|
||||
} catch {
|
||||
res.status(503).json({ message: 'The Titus study file is not configured yet.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/resource-download', studyDownloadRateLimit, async (req, res) => {
|
||||
try {
|
||||
const { resourceId, firstName, lastName, email, subscribe, _honey } = req.body ?? {}
|
||||
|
||||
if (_honey) { res.json({ ok: true }); return }
|
||||
|
||||
if (!resourceId || typeof resourceId !== 'string') {
|
||||
res.status(400).json({ message: 'Resource ID is required.' }); return
|
||||
}
|
||||
|
||||
const published = await loadSiteContentFile(DATA_FILE)
|
||||
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.' }); return
|
||||
}
|
||||
|
||||
if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) {
|
||||
res.status(400).json({ message: 'First name is required.' }); return
|
||||
}
|
||||
if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) {
|
||||
res.status(400).json({ message: 'Last name is required.' }); return
|
||||
}
|
||||
if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
|
||||
res.status(400).json({ message: 'A valid email address is required.' }); return
|
||||
}
|
||||
|
||||
const trimmedFirstName = firstName.trim()
|
||||
const trimmedLastName = lastName.trim()
|
||||
const trimmedName = `${trimmedFirstName} ${trimmedLastName}`.trim()
|
||||
const trimmedEmail = email.trim()
|
||||
const wantsSubscribe = subscribe !== false
|
||||
|
||||
addContactSubmission({
|
||||
name: trimmedName,
|
||||
email: trimmedEmail,
|
||||
message: `Requested resource download: ${resource.label ?? resource.url}`,
|
||||
messageType: 'general',
|
||||
subscribe: wantsSubscribe,
|
||||
})
|
||||
|
||||
if (wantsSubscribe) {
|
||||
await syncContactToResend(trimmedName, trimmedEmail)
|
||||
}
|
||||
|
||||
incrementDownloadCount(`resource:${resourceId}`)
|
||||
res.json({ ok: true, downloadUrl: resource.url.trim() })
|
||||
} catch (err) {
|
||||
console.error('[resource-download] request error:', err)
|
||||
res.status(500).json({ message: 'Failed to process your request. Please try again.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/admin-download-stats', requireAdminAuth, (_req, res) => {
|
||||
res.json({ counts: state.downloadCounts })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { sanitizeUrl } from '../study-helpers.js'
|
||||
|
||||
const RSS_FEED_URL = 'https://anchor.fm/nmemmert/podcast/rss'
|
||||
let episodesCache = null
|
||||
let episodesCacheAt = 0
|
||||
const EPISODES_CACHE_TTL = 30 * 60 * 1000
|
||||
|
||||
function extractCdata(raw) {
|
||||
const cdata = /^<!\[CDATA\[([\s\S]*?)\]\]>$/.exec(raw.trim())
|
||||
return cdata ? cdata[1].trim() : raw.trim()
|
||||
}
|
||||
|
||||
function parseRssItems(xml, limit = Infinity) {
|
||||
const items = []
|
||||
const itemRegex = /<item>([\s\S]*?)<\/item>/g
|
||||
let match
|
||||
while ((match = itemRegex.exec(xml)) !== null && items.length < limit) {
|
||||
const block = match[1]
|
||||
const titleRaw = /<title>([\s\S]*?)<\/title>/.exec(block)?.[1] ?? ''
|
||||
const title = extractCdata(titleRaw)
|
||||
if (!title) continue
|
||||
|
||||
const pubDate = (/<pubDate>([\s\S]*?)<\/pubDate>/.exec(block)?.[1] ?? '').trim()
|
||||
const guidRaw = /<guid[^>]*>([\s\S]*?)<\/guid>/.exec(block)?.[1] ?? ''
|
||||
const guid = extractCdata(guidRaw)
|
||||
const enclosureUrl = /<enclosure[^>]+url="([^"]+)"/.exec(block)?.[1] ?? ''
|
||||
const link = guid.startsWith('http') ? guid : enclosureUrl
|
||||
const descRaw = /<description>([\s\S]*?)<\/description>/.exec(block)?.[1] ?? ''
|
||||
const descText = extractCdata(descRaw).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
|
||||
const duration = (/<itunes:duration>([\s\S]*?)<\/itunes:duration>/.exec(block)?.[1] ?? '').trim()
|
||||
const episode = (/<itunes:episode>([\s\S]*?)<\/itunes:episode>/.exec(block)?.[1] ?? '').trim()
|
||||
items.push({
|
||||
title, pubDate, link,
|
||||
description: descText.slice(0, 220) + (descText.length > 220 ? '…' : ''),
|
||||
duration, episode,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
async function fetchAllEpisodes() {
|
||||
const now = Date.now()
|
||||
if (episodesCache && (now - episodesCacheAt) < EPISODES_CACHE_TTL) {
|
||||
return episodesCache
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 8000)
|
||||
const response = await fetch(RSS_FEED_URL, { signal: controller.signal })
|
||||
clearTimeout(timeout)
|
||||
if (!response.ok) throw new Error(`RSS fetch failed: ${response.status}`)
|
||||
const xml = await response.text()
|
||||
const episodes = parseRssItems(xml)
|
||||
episodesCache = episodes
|
||||
episodesCacheAt = now
|
||||
return episodes
|
||||
}
|
||||
|
||||
function toSpotifyEpisodeEmbedUrl(urlValue) {
|
||||
if (!urlValue) return ''
|
||||
try {
|
||||
const parsed = new URL(urlValue)
|
||||
if (parsed.protocol !== 'https:') return ''
|
||||
const host = parsed.hostname.toLowerCase()
|
||||
const parts = parsed.pathname.split('/').filter(Boolean)
|
||||
if (host === 'open.spotify.com') {
|
||||
if (parts[0] === 'embed' && parts[1] === 'episode' && parts[2]) {
|
||||
return `https://open.spotify.com/embed/episode/${parts[2]}?utm_source=generator`
|
||||
}
|
||||
if (parts[0] === 'episode' && parts[1]) {
|
||||
return `https://open.spotify.com/embed/episode/${parts[1]}?utm_source=generator`
|
||||
}
|
||||
}
|
||||
} catch { return '' }
|
||||
return ''
|
||||
}
|
||||
|
||||
function decodeEscapedJsonUrl(value) {
|
||||
return String(value || '').replace(/\\u002F/g, '/').replace(/\\\//g, '/')
|
||||
}
|
||||
|
||||
function extractSpotifyEpisodeIdFromCreatorHtml(html, sourceUrl) {
|
||||
const input = String(html || '')
|
||||
if (!input) return ''
|
||||
const sourceEpisodeSlug = /-([A-Za-z0-9]+)(?:\/|$)/.exec(sourceUrl)?.[1] ?? ''
|
||||
const blockRegex = /"episodeId":"([^"]+)"[\s\S]*?"spotifyUrl":"([^"]+)"/g
|
||||
let match
|
||||
let firstEpisodeId = ''
|
||||
while ((match = blockRegex.exec(input)) !== null) {
|
||||
const episodeSlug = match[1]
|
||||
const spotifyUrl = decodeEscapedJsonUrl(match[2])
|
||||
const episodeId = /\/episode\/([A-Za-z0-9]+)/.exec(spotifyUrl)?.[1]
|
||||
if (!firstEpisodeId && episodeId) firstEpisodeId = episodeId
|
||||
if (sourceEpisodeSlug && episodeSlug === sourceEpisodeSlug && episodeId) return episodeId
|
||||
}
|
||||
if (firstEpisodeId) return firstEpisodeId
|
||||
const urlMatch = /"spotifyUrl":"(https:\\u002F\\u002Fopen\.spotify\.com\\u002Fepisode\\u002F([A-Za-z0-9]+))/.exec(input)
|
||||
return urlMatch ? (urlMatch[2] || '') : ''
|
||||
}
|
||||
|
||||
function isAllowedSpotifyResolverHost(hostname) {
|
||||
const host = String(hostname || '').toLowerCase()
|
||||
return host === 'open.spotify.com' || host === 'creators.spotify.com' || host === 'anchor.fm' || host === 'podcasters.spotify.com'
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
app.get('/api/spotify/embed-url', async (req, res) => {
|
||||
const incoming = typeof req.query.url === 'string' ? req.query.url.trim() : ''
|
||||
const safeInput = sanitizeUrl(incoming)
|
||||
|
||||
if (!safeInput || safeInput.startsWith('/')) {
|
||||
res.status(400).json({ message: 'A valid episode URL is required.' }); return
|
||||
}
|
||||
|
||||
let parsed
|
||||
try {
|
||||
parsed = new URL(safeInput)
|
||||
} catch {
|
||||
res.status(400).json({ message: 'Malformed URL.' }); return
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'https:' || !isAllowedSpotifyResolverHost(parsed.hostname)) {
|
||||
res.status(400).json({ message: 'Unsupported episode URL host.' }); return
|
||||
}
|
||||
|
||||
const directEmbed = toSpotifyEpisodeEmbedUrl(safeInput)
|
||||
if (directEmbed) {
|
||||
res.json({ embedUrl: directEmbed, resolvedFrom: 'direct' }); return
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 8000)
|
||||
const response = await fetch(safeInput, {
|
||||
signal: controller.signal,
|
||||
headers: { 'User-Agent': 'Siteforge/1.0 (+https://versebyversewithnate.us)', Accept: 'text/html' },
|
||||
})
|
||||
clearTimeout(timeout)
|
||||
|
||||
if (!response.ok) {
|
||||
res.status(404).json({ message: 'Could not fetch episode page.' }); return
|
||||
}
|
||||
|
||||
const html = await response.text()
|
||||
const spotifyEpisodeId = extractSpotifyEpisodeIdFromCreatorHtml(html, safeInput)
|
||||
|
||||
if (!spotifyEpisodeId) {
|
||||
res.status(404).json({ message: 'Could not resolve Spotify episode ID from page.' }); return
|
||||
}
|
||||
|
||||
const embedUrl = `https://open.spotify.com/embed/episode/${spotifyEpisodeId}?utm_source=generator`
|
||||
res.json({ embedUrl, resolvedFrom: 'page-fetch' })
|
||||
} catch (err) {
|
||||
console.error('[spotify/embed-url] resolve error:', err.message)
|
||||
res.status(500).json({ message: 'Could not resolve Spotify embed URL right now.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/episodes', async (_req, res) => {
|
||||
try {
|
||||
const episodes = await fetchAllEpisodes()
|
||||
res.json({ episodes: episodes.slice(0, 6) })
|
||||
} catch (err) {
|
||||
console.error('[episodes] RSS fetch error:', err.message)
|
||||
res.json({ episodes: (episodesCache ?? []).slice(0, 6) })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/episodes/all', async (_req, res) => {
|
||||
try {
|
||||
const episodes = await fetchAllEpisodes()
|
||||
res.json({ episodes })
|
||||
} catch (err) {
|
||||
console.error('[episodes/all] RSS fetch error:', err.message)
|
||||
res.json({ episodes: episodesCache ?? [] })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import express from 'express'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { escapeHtml, escapeXml, injectSeoIntoHtml, normalizeSitemapPath } from '../helpers.js'
|
||||
import {
|
||||
DIST_DIR,
|
||||
DIST_IMAGES_DIR,
|
||||
PUBLIC_IMAGES_DIR,
|
||||
UPLOADS_DIR,
|
||||
INDEX_FILE,
|
||||
DEFAULT_SEO,
|
||||
} from '../config.js'
|
||||
import { state } from '../state.js'
|
||||
import { loadSiteContentFile } from '../data.js'
|
||||
import { DATA_FILE } from '../config.js'
|
||||
import { sanitizeRedirectRules } from '../study-helpers.js'
|
||||
|
||||
export function register(app) {
|
||||
app.get('/robots.txt', async (_req, res) => {
|
||||
let content = state.cachedSiteContent
|
||||
if (!content) {
|
||||
try {
|
||||
const parsed = await loadSiteContentFile(DATA_FILE)
|
||||
content = parsed.siteContent
|
||||
} catch { content = {} }
|
||||
}
|
||||
|
||||
const seo = content?.seo ?? DEFAULT_SEO
|
||||
const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl
|
||||
const root = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
|
||||
|
||||
res.type('text/plain').send(
|
||||
['User-agent: *', 'Allow: /', `Sitemap: ${root}/sitemap.xml`].join('\n'),
|
||||
)
|
||||
})
|
||||
|
||||
app.get('/sitemap.xml', async (_req, res) => {
|
||||
let content = state.cachedSiteContent
|
||||
if (!content) {
|
||||
try {
|
||||
const parsed = await loadSiteContentFile(DATA_FILE)
|
||||
content = parsed.siteContent
|
||||
} catch { content = {} }
|
||||
}
|
||||
|
||||
const seo = content?.seo ?? DEFAULT_SEO
|
||||
const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl
|
||||
const root = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
|
||||
const paths = Array.isArray(seo.sitemapPaths) && seo.sitemapPaths.length > 0
|
||||
? seo.sitemapPaths
|
||||
: DEFAULT_SEO.sitemapPaths
|
||||
|
||||
const urls = paths
|
||||
.map(item => normalizeSitemapPath(item))
|
||||
.filter(Boolean)
|
||||
.map(item => `${root}${item}`)
|
||||
|
||||
const xml = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
...urls.map(url => ` <url><loc>${escapeXml(url)}</loc></url>`),
|
||||
'</urlset>',
|
||||
].join('\n')
|
||||
|
||||
res.type('application/xml').send(xml)
|
||||
})
|
||||
|
||||
// Redirect rules middleware
|
||||
app.use((req, res, next) => {
|
||||
const rules = sanitizeRedirectRules(state.cachedSiteContent?.redirects)
|
||||
const match = rules.find(rule => rule.path === req.path)
|
||||
if (!match) { next(); return }
|
||||
res.redirect(match.statusCode === 302 ? 302 : 301, match.target)
|
||||
})
|
||||
|
||||
// Static files
|
||||
app.use('/images', express.static(DIST_IMAGES_DIR))
|
||||
app.use('/images', express.static(PUBLIC_IMAGES_DIR))
|
||||
app.use('/uploads', express.static(UPLOADS_DIR))
|
||||
|
||||
// Social share stub for questions
|
||||
app.get('/questions/share/:id', (req, res) => {
|
||||
const id = req.params.id
|
||||
if (!id || !/^[\w-]{1,120}$/.test(id)) {
|
||||
res.redirect(302, '/questions'); return
|
||||
}
|
||||
const sourceQuestions = state.draftQuestions ?? state.questions
|
||||
const question = sourceQuestions.find(q => q.id === id && q.isApproved === true && q.answer)
|
||||
if (!question) {
|
||||
res.redirect(302, '/questions'); return
|
||||
}
|
||||
|
||||
const BASE = 'https://versebyversewithnate.us'
|
||||
const canonicalUrl = `${BASE}/questions#qa-${encodeURIComponent(id)}`
|
||||
const shareUrl = `${BASE}/questions/share/${encodeURIComponent(id)}`
|
||||
const ogTitle = escapeHtml(question.question.length > 100
|
||||
? `${question.question.slice(0, 97)}…`
|
||||
: question.question)
|
||||
const answerSnippet = question.answer.replace(/\n+/g, ' ').trim()
|
||||
const ogDescription = escapeHtml(answerSnippet.length > 200
|
||||
? `${answerSnippet.slice(0, 197)}…`
|
||||
: answerSnippet)
|
||||
const ogImage = `${BASE}/images/banner.png`
|
||||
|
||||
res.type('html').send(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>${ogTitle} — Verse by Verse with Nate</title>
|
||||
<meta name="description" content="${ogDescription}"/>
|
||||
<meta property="og:type" content="article"/>
|
||||
<meta property="og:site_name" content="Verse by Verse with Nate"/>
|
||||
<meta property="og:url" content="${escapeHtml(shareUrl)}"/>
|
||||
<meta property="og:title" content="${ogTitle}"/>
|
||||
<meta property="og:description" content="${ogDescription}"/>
|
||||
<meta property="og:image" content="${escapeHtml(ogImage)}"/>
|
||||
<meta property="og:image:alt" content="${ogTitle}"/>
|
||||
<meta name="twitter:card" content="summary_large_image"/>
|
||||
<meta name="twitter:title" content="${ogTitle}"/>
|
||||
<meta name="twitter:description" content="${ogDescription}"/>
|
||||
<meta name="twitter:image" content="${escapeHtml(ogImage)}"/>
|
||||
<link rel="canonical" href="${escapeHtml(shareUrl)}"/>
|
||||
<meta http-equiv="refresh" content="0;url=${escapeHtml(canonicalUrl)}"/>
|
||||
<script>location.replace(${JSON.stringify(canonicalUrl)})</script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>`)
|
||||
})
|
||||
|
||||
// SPA static files and fallback
|
||||
app.use(express.static(DIST_DIR))
|
||||
|
||||
app.use(async (_req, res) => {
|
||||
try {
|
||||
const html = await readFile(INDEX_FILE, 'utf8')
|
||||
res.type('html').send(injectSeoIntoHtml(html, state.cachedSiteContent))
|
||||
} catch {
|
||||
res.status(503).send('Frontend build not found. Run "npm run build" first.')
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import { MAX_QUESTIONS } from '../config.js'
|
||||
import { state } from '../state.js'
|
||||
import { queueQuestionsWrite, queueDraftQuestionsWrite } from '../data.js'
|
||||
|
||||
function ensureDraftQuestions() {
|
||||
if (state.draftQuestions !== null) return
|
||||
state.draftQuestions = state.questions.slice(0, MAX_QUESTIONS)
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
app.get('/api/questions', (_req, res) => {
|
||||
const sourceQuestions = state.draftQuestions ?? state.questions
|
||||
const publicQuestions = sourceQuestions.filter(q => q.isApproved === true && q.answer && q.answer.trim().length > 0)
|
||||
res.json({ questions: publicQuestions })
|
||||
})
|
||||
|
||||
app.get('/api/admin-questions', requireAdminAuth, (_req, res) => {
|
||||
res.json({ questions: state.draftQuestions ?? state.questions })
|
||||
})
|
||||
|
||||
app.post('/api/admin-questions', requireAdminAuth, (req, res) => {
|
||||
const firstName = typeof req.body?.firstName === 'string' ? req.body.firstName.trim() : ''
|
||||
const email = typeof req.body?.email === 'string' ? req.body.email.trim() : ''
|
||||
const questionText = typeof req.body?.question === 'string' ? req.body.question.trim() : ''
|
||||
const answerText = typeof req.body?.answer === 'string' ? req.body.answer.trim() : ''
|
||||
const approveNow = req.body?.approve === true
|
||||
|
||||
if (!firstName || firstName.length > 100) {
|
||||
res.status(400).json({ message: 'First name is required and must be 100 characters or fewer.' }); return
|
||||
}
|
||||
if (!questionText || questionText.length < 5 || questionText.length > 3000) {
|
||||
res.status(400).json({ message: 'Question must be between 5 and 3000 characters.' }); return
|
||||
}
|
||||
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
res.status(400).json({ message: 'If provided, email must be a valid email address.' }); return
|
||||
}
|
||||
if (answerText.length > 5000) {
|
||||
res.status(400).json({ message: 'Answer must be 5000 characters or fewer.' }); return
|
||||
}
|
||||
|
||||
ensureDraftQuestions()
|
||||
const now = new Date().toISOString()
|
||||
const created = {
|
||||
id: randomUUID(),
|
||||
submittedAt: now,
|
||||
firstName,
|
||||
email,
|
||||
question: questionText,
|
||||
answer: answerText,
|
||||
answeredAt: answerText ? now : null,
|
||||
isApproved: approveNow,
|
||||
approvedAt: approveNow ? now : null,
|
||||
}
|
||||
|
||||
state.draftQuestions.unshift(created)
|
||||
state.draftQuestions = state.draftQuestions.slice(0, MAX_QUESTIONS)
|
||||
queueDraftQuestionsWrite()
|
||||
|
||||
res.status(201).json({ ok: true, question: created })
|
||||
})
|
||||
|
||||
app.post('/api/admin-questions/:id/answer', requireAdminAuth, (req, res) => {
|
||||
const { id } = req.params
|
||||
const { answer } = req.body ?? {}
|
||||
|
||||
if (!answer || typeof answer !== 'string' || answer.trim().length < 1 || answer.trim().length > 5000) {
|
||||
res.status(400).json({ message: 'Answer must be between 1 and 5000 characters.' }); return
|
||||
}
|
||||
|
||||
ensureDraftQuestions()
|
||||
const question = state.draftQuestions.find(q => q.id === id)
|
||||
if (!question) {
|
||||
res.status(404).json({ message: 'Question not found.' }); return
|
||||
}
|
||||
|
||||
question.answer = answer.trim()
|
||||
question.answeredAt = new Date().toISOString()
|
||||
queueDraftQuestionsWrite()
|
||||
|
||||
res.json({ ok: true, question })
|
||||
})
|
||||
|
||||
app.post('/api/admin-questions/:id/approve', requireAdminAuth, (req, res) => {
|
||||
const { id } = req.params
|
||||
const { approved } = req.body ?? {}
|
||||
|
||||
ensureDraftQuestions()
|
||||
const question = state.draftQuestions.find(q => q.id === id)
|
||||
if (!question) {
|
||||
res.status(404).json({ message: 'Question not found.' }); return
|
||||
}
|
||||
|
||||
question.isApproved = approved === true
|
||||
question.approvedAt = approved === true ? new Date().toISOString() : null
|
||||
queueDraftQuestionsWrite()
|
||||
|
||||
res.json({ ok: true, question })
|
||||
})
|
||||
|
||||
app.delete('/api/admin-questions/:id', requireAdminAuth, (req, res) => {
|
||||
const { id } = req.params
|
||||
ensureDraftQuestions()
|
||||
const index = state.draftQuestions.findIndex(q => q.id === id)
|
||||
|
||||
if (index === -1) {
|
||||
res.status(404).json({ message: 'Question not found.' }); return
|
||||
}
|
||||
|
||||
state.draftQuestions.splice(index, 1)
|
||||
queueDraftQuestionsWrite()
|
||||
|
||||
res.json({ ok: true })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
import { randomUUID, timingSafeEqual } from 'node:crypto'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import { Document, Packer, Paragraph, HeadingLevel, TextRun } from 'docx'
|
||||
import { mkdir, unlink, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { inferImageExtensionFromDataUrl, normalizeAssetBaseName, escapeHtml, buildAbsoluteUrl } from '../helpers.js'
|
||||
import { UPLOADS_DIR, EMAIL_CHANGE_TOKEN_TTL_MS } from '../config.js'
|
||||
import { state } from '../state.js'
|
||||
import {
|
||||
queueStudyUsersWrite,
|
||||
readUploadsMetadata,
|
||||
writeUploadsMetadata,
|
||||
loadUserNotes,
|
||||
loadUserProgress,
|
||||
getUserNotesFilePath,
|
||||
} from '../data.js'
|
||||
import {
|
||||
requireStudyAuth,
|
||||
hashStudyPassword,
|
||||
hashEmailChangeToken,
|
||||
normalizeStudyUsername,
|
||||
isValidStudyUsername,
|
||||
findStudyUserByUsername,
|
||||
getStudyAvatarUrl,
|
||||
isStudyUserEnrolled,
|
||||
normalizeStudySlug,
|
||||
getStudyCatalog,
|
||||
clearStudySessionCookie,
|
||||
} from '../study-helpers.js'
|
||||
import {
|
||||
sendStudyAccountDeletedEmail,
|
||||
syncContactToResend,
|
||||
buildBrandedEmailHtml,
|
||||
getCanonicalBaseUrl,
|
||||
getResendFromAddress,
|
||||
} from '../email.js'
|
||||
import { Resend } from 'resend'
|
||||
|
||||
const studyAuthRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 20,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { message: 'Too many attempts. Please wait 15 minutes and try again.' },
|
||||
skipSuccessfulRequests: true,
|
||||
})
|
||||
|
||||
export function register(app) {
|
||||
app.get('/api/study-account/export-notes', requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const notes = await loadUserNotes(user.id)
|
||||
const progress = await loadUserProgress(user.id)
|
||||
|
||||
const sectionMeta = {}
|
||||
const content = state.cachedSiteContent
|
||||
const studies = content && Array.isArray(content.studies) && content.studies.length > 0
|
||||
? content.studies
|
||||
: [{ slug: 'colossians', title: 'Colossians: Rooted in Christ', description: '', sections: content?.colossiansStudySections ?? [] }]
|
||||
|
||||
for (const study of studies) {
|
||||
for (const section of (study.sections ?? [])) {
|
||||
sectionMeta[`${study.slug}--${section.id}`] = {
|
||||
studyTitle: study.title,
|
||||
studyDescription: typeof study.description === 'string' ? study.description : '',
|
||||
title: section.title,
|
||||
reference: section.reference,
|
||||
studyQuestions: Array.isArray(section.studyQuestions) ? section.studyQuestions : [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const studyEntries = {}
|
||||
|
||||
for (const [noteKey, noteText] of Object.entries(notes)) {
|
||||
if (!noteText?.trim()) continue
|
||||
const dashIndex = noteKey.indexOf('--')
|
||||
const studySlug = dashIndex >= 0 ? noteKey.slice(0, dashIndex) : 'unknown'
|
||||
const sectionId = dashIndex >= 0 ? noteKey.slice(dashIndex + 2) : noteKey
|
||||
if (!studyEntries[studySlug]) studyEntries[studySlug] = {}
|
||||
studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {}
|
||||
studyEntries[studySlug][sectionId].noteText = noteText.trim()
|
||||
}
|
||||
|
||||
for (const [studySlug, studyProgress] of Object.entries(progress.byStudy)) {
|
||||
const quizAnswersBySection = studyProgress.quizAnswers || {}
|
||||
for (const [sectionId, answers] of Object.entries(quizAnswersBySection)) {
|
||||
if (!Array.isArray(answers) || answers.length === 0) continue
|
||||
if (!studyEntries[studySlug]) studyEntries[studySlug] = {}
|
||||
studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {}
|
||||
studyEntries[studySlug][sectionId].quizAnswers = answers.filter(answer => typeof answer === 'string' && answer.trim()).map(answer => answer.trim())
|
||||
}
|
||||
}
|
||||
|
||||
const studySlugs = Array.from(new Set([
|
||||
...Object.keys(studyEntries),
|
||||
...Object.values(studies).map(study => study.slug),
|
||||
]))
|
||||
|
||||
const docChildren = [
|
||||
new Paragraph({ text: 'Verse by Verse with Nate', heading: HeadingLevel.TITLE }),
|
||||
new Paragraph({ text: 'My Study Export', heading: HeadingLevel.HEADING_1, spacing: { after: 240 } }),
|
||||
new Paragraph({ text: `Student: ${user.displayName || user.username}`, spacing: { after: 120 } }),
|
||||
new Paragraph({ text: `Exported ${new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}`, italics: true, spacing: { after: 400 } }),
|
||||
]
|
||||
|
||||
for (const studySlug of studySlugs) {
|
||||
const study = studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
|
||||
const studyTitle = study?.title || studySlug
|
||||
const studyDescription = typeof study?.description === 'string' ? study.description : ''
|
||||
const sectionIds = studyEntries[studySlug] ? Object.keys(studyEntries[studySlug]) : []
|
||||
|
||||
if (sectionIds.length === 0) continue
|
||||
|
||||
docChildren.push(new Paragraph({ text: studyTitle, heading: HeadingLevel.HEADING_1, spacing: { before: 400 } }))
|
||||
if (studyDescription) {
|
||||
docChildren.push(new Paragraph({ text: studyDescription, spacing: { after: 240 } }))
|
||||
}
|
||||
const noteCount = sectionIds.filter(sectionId => studyEntries[studySlug][sectionId].noteText).length
|
||||
const quizCount = sectionIds.filter(sectionId => Array.isArray(studyEntries[studySlug][sectionId].quizAnswers) && studyEntries[studySlug][sectionId].quizAnswers.length > 0).length
|
||||
docChildren.push(new Paragraph({ text: `Notes: ${noteCount} | Quiz sections: ${quizCount}`, italics: true, spacing: { after: 240 } }))
|
||||
|
||||
const orderedSectionIds = study?.sections?.map(section => section.id).filter(id => sectionIds.includes(id)) ?? sectionIds
|
||||
for (const sectionId of orderedSectionIds) {
|
||||
const entry = studyEntries[studySlug][sectionId]
|
||||
if (!entry) continue
|
||||
const meta = sectionMeta[`${studySlug}--${sectionId}`] || { title: sectionId, reference: '' }
|
||||
docChildren.push(new Paragraph({ text: meta.title, heading: HeadingLevel.HEADING_2, spacing: { before: 240 } }))
|
||||
if (meta.reference) {
|
||||
docChildren.push(new Paragraph({ children: [new TextRun({ text: meta.reference, italics: true, color: '555555' })], spacing: { after: 120 } }))
|
||||
}
|
||||
if (entry.noteText) {
|
||||
docChildren.push(new Paragraph({ text: 'Notes', heading: HeadingLevel.HEADING_3, spacing: { before: 120 } }))
|
||||
for (const line of entry.noteText.split('\n')) {
|
||||
docChildren.push(new Paragraph({ text: line.trim(), spacing: { after: 80 } }))
|
||||
}
|
||||
}
|
||||
if (Array.isArray(meta.studyQuestions) && meta.studyQuestions.length > 0) {
|
||||
docChildren.push(new Paragraph({ text: 'Quiz Questions', heading: HeadingLevel.HEADING_3, spacing: { before: 160 } }))
|
||||
meta.studyQuestions.forEach((question, index) => {
|
||||
docChildren.push(new Paragraph({ children: [new TextRun({ text: `${index + 1}. `, bold: true }), new TextRun({ text: question })], spacing: { after: 80 } }))
|
||||
})
|
||||
}
|
||||
if (Array.isArray(entry.quizAnswers) && entry.quizAnswers.length > 0) {
|
||||
docChildren.push(new Paragraph({ text: 'Quiz Answers', heading: HeadingLevel.HEADING_3, spacing: { before: 160 } }))
|
||||
entry.quizAnswers.forEach((answer, index) => {
|
||||
docChildren.push(new Paragraph({ children: [new TextRun({ text: `Answer ${index + 1}: `, bold: true }), new TextRun({ text: answer })], spacing: { after: 80 } }))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (docChildren.length <= 4) {
|
||||
docChildren.push(new Paragraph({ text: 'No notes or quiz answers saved yet.', spacing: { before: 200 } }))
|
||||
}
|
||||
|
||||
const doc = new Document({ creator: 'Verse by Verse with Nate', title: 'My Study Export', sections: [{ children: docChildren }] })
|
||||
const buffer = await Packer.toBuffer(doc)
|
||||
const filename = `my-study-export-${new Date().toISOString().slice(0, 10)}.docx`
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`)
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
|
||||
res.send(buffer)
|
||||
})
|
||||
|
||||
app.post('/api/study-account/change-password', studyAuthRateLimiter, requireStudyAuth, (req, res) => {
|
||||
const user = req.studyUser
|
||||
const currentPassword = typeof req.body?.currentPassword === 'string' ? req.body.currentPassword : ''
|
||||
const newPassword = typeof req.body?.newPassword === 'string' ? req.body.newPassword : ''
|
||||
|
||||
const currentHash = hashStudyPassword(currentPassword)
|
||||
const a = Buffer.from(currentHash, 'utf8')
|
||||
const b = Buffer.from(user.passwordHash, 'utf8')
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
res.status(401).json({ message: 'Current password is incorrect.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword.length < 8 || newPassword.length > 200) {
|
||||
res.status(400).json({ message: 'New password must be 8–200 characters.' })
|
||||
return
|
||||
}
|
||||
|
||||
user.passwordHash = hashStudyPassword(newPassword)
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.get('/api/study-account/overview', requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const notes = await loadUserNotes(user.id)
|
||||
const progress = await loadUserProgress(user.id)
|
||||
const noteEntries = Object.entries(notes)
|
||||
|
||||
const studies = getStudyCatalog().map(study => {
|
||||
const totalLessons = Array.isArray(state.cachedSiteContent?.studies)
|
||||
? (state.cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === study.slug)?.sections?.length ?? 0)
|
||||
: 0
|
||||
const noteCount = noteEntries.filter(([key, value]) => key.startsWith(`${study.slug}--`) && typeof value === 'string' && value.trim()).length
|
||||
const completedLessons = progress.byStudy[study.slug]?.completedSectionIds?.length ?? 0
|
||||
return {
|
||||
slug: study.slug,
|
||||
title: study.title,
|
||||
status: study.status,
|
||||
enrolled: isStudyUserEnrolled(user, study.slug),
|
||||
totalLessons,
|
||||
completedLessons,
|
||||
noteCount,
|
||||
}
|
||||
})
|
||||
|
||||
res.json({
|
||||
profile: {
|
||||
username: user.username,
|
||||
displayName: user.displayName ?? '',
|
||||
subscribeNewsletter: user.subscribeNewsletter !== false,
|
||||
studyRemindersEnabled: user.studyRemindersEnabled === true,
|
||||
avatarUrl: getStudyAvatarUrl(user),
|
||||
},
|
||||
stats: {
|
||||
noteCount: Object.keys(notes).length,
|
||||
memberSince: user.createdAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
},
|
||||
studies,
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/study-account/profile', requireStudyAuth, (req, res) => {
|
||||
const user = req.studyUser
|
||||
const displayName = typeof req.body?.displayName === 'string' ? req.body.displayName.trim().slice(0, 80) : ''
|
||||
const avatarUrl = typeof req.body?.avatarUrl === 'string' ? req.body.avatarUrl.trim() : ''
|
||||
if (avatarUrl && !/^https?:\/\//i.test(avatarUrl) && !avatarUrl.startsWith('/uploads/') && !avatarUrl.startsWith('data:image/')) {
|
||||
res.status(400).json({ message: 'Avatar must be a valid uploaded image, data URI, or https URL.' })
|
||||
return
|
||||
}
|
||||
|
||||
user.displayName = displayName
|
||||
user.avatarUrl = avatarUrl
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
res.json({ ok: true, displayName: user.displayName, avatarUrl: user.avatarUrl || getStudyAvatarUrl(user) })
|
||||
})
|
||||
|
||||
app.post('/api/study-account/avatar-upload', requireStudyAuth, async (req, res) => {
|
||||
try {
|
||||
const filename = typeof req.body?.filename === 'string' ? req.body.filename : ''
|
||||
const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
|
||||
const ext = inferImageExtensionFromDataUrl(dataUrl)
|
||||
|
||||
if (!ext) {
|
||||
res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, or GIF data URL.' })
|
||||
return
|
||||
}
|
||||
|
||||
const base64 = dataUrl.split(',')[1] ?? ''
|
||||
const buffer = Buffer.from(base64, 'base64')
|
||||
if (buffer.length === 0 || buffer.length > (4 * 1024 * 1024)) {
|
||||
res.status(400).json({ message: 'Upload must be between 1 byte and 4MB.' })
|
||||
return
|
||||
}
|
||||
|
||||
const baseName = normalizeAssetBaseName(filename.replace(/\.[a-z0-9]+$/i, ''))
|
||||
const finalName = `${baseName || 'avatar'}-${Date.now()}${ext}`
|
||||
|
||||
await mkdir(UPLOADS_DIR, { recursive: true })
|
||||
await writeFile(path.join(UPLOADS_DIR, finalName), buffer)
|
||||
const metadata = await readUploadsMetadata()
|
||||
metadata[finalName] = []
|
||||
await writeUploadsMetadata(metadata)
|
||||
|
||||
res.json({ ok: true, url: `/uploads/${finalName}` })
|
||||
} catch (err) {
|
||||
console.error('[study-account-avatar-upload] upload error:', err)
|
||||
res.status(500).json({ message: 'Avatar upload failed.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.patch('/api/study-account/preferences', requireStudyAuth, (req, res) => {
|
||||
const user = req.studyUser
|
||||
const subscribeNewsletter = req.body?.subscribeNewsletter === true
|
||||
const studyRemindersEnabled = req.body?.studyRemindersEnabled === true
|
||||
user.subscribeNewsletter = subscribeNewsletter
|
||||
user.studyRemindersEnabled = studyRemindersEnabled
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
|
||||
if (subscribeNewsletter) {
|
||||
syncContactToResend(user.displayName || user.username, user.username).catch(err => console.error('[study-account] resend sync error:', err))
|
||||
}
|
||||
|
||||
res.json({ ok: true, subscribeNewsletter: user.subscribeNewsletter, studyRemindersEnabled: user.studyRemindersEnabled === true })
|
||||
})
|
||||
|
||||
app.post('/api/study-account/request-email-change', studyAuthRateLimiter, requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const newEmail = normalizeStudyUsername(req.body?.newEmail)
|
||||
const currentPassword = typeof req.body?.currentPassword === 'string' ? req.body.currentPassword : ''
|
||||
|
||||
if (!isValidStudyUsername(newEmail)) {
|
||||
res.status(400).json({ message: 'Please enter a valid email address.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (newEmail === user.username) {
|
||||
res.status(400).json({ message: 'That is already your current email.' })
|
||||
return
|
||||
}
|
||||
|
||||
const existing = findStudyUserByUsername(newEmail)
|
||||
if (existing && existing.id !== user.id) {
|
||||
res.status(409).json({ message: 'An account with that email already exists.' })
|
||||
return
|
||||
}
|
||||
|
||||
const currentHash = hashStudyPassword(currentPassword)
|
||||
const a = Buffer.from(currentHash, 'utf8')
|
||||
const b = Buffer.from(user.passwordHash, 'utf8')
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
res.status(401).json({ message: 'Current password is incorrect.' })
|
||||
return
|
||||
}
|
||||
|
||||
const rawToken = randomUUID()
|
||||
const tokenHash = hashEmailChangeToken(rawToken)
|
||||
const expiresAt = Date.now() + EMAIL_CHANGE_TOKEN_TTL_MS
|
||||
|
||||
user.pendingEmailChange = { newEmail, tokenHash, expiresAt, requestedAt: new Date().toISOString() }
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
|
||||
if (process.env.RESEND_API_KEY) {
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const baseUrl = getCanonicalBaseUrl()
|
||||
const verifyUrl = buildAbsoluteUrl(baseUrl, `/study/account?verifyEmailToken=${encodeURIComponent(rawToken)}`)
|
||||
const cfg = state.cachedSiteContent ?? {}
|
||||
const emailChangeSubject = cfg.emailChangeSubject?.trim() || 'Confirm your new email address'
|
||||
const emailChangeBody = cfg.emailChangeBody?.trim() || 'Click the link below to confirm your new account email. If you did not request this change, ignore this message.'
|
||||
const emailChangeCtaLabel = cfg.emailChangeCtaLabel?.trim() || 'Confirm Email Change'
|
||||
const { error } = await resend.emails.send({
|
||||
from: getResendFromAddress(),
|
||||
to: [newEmail],
|
||||
subject: emailChangeSubject,
|
||||
text: `${emailChangeBody}\n\n${verifyUrl}`,
|
||||
html: buildBrandedEmailHtml({
|
||||
title: emailChangeSubject,
|
||||
eyebrow: 'Account Security',
|
||||
bodyHtml: `<p style="margin:0 0 16px;">${escapeHtml(emailChangeBody)}</p>`,
|
||||
ctaLabel: emailChangeCtaLabel,
|
||||
ctaUrl: verifyUrl,
|
||||
footerHtml: `<p style="margin:0;font-family:Georgia,serif;font-size:12px;color:#7a7060;">Verse by Verse with Nate</p>`,
|
||||
}),
|
||||
})
|
||||
if (error) {
|
||||
console.error('[study-account] email change send error:', error)
|
||||
res.status(503).json({ message: 'Could not send verification email right now.' })
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[study-account] email change send exception:', err)
|
||||
res.status(503).json({ message: 'Could not send verification email right now.' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ ok: true, verificationSent: true })
|
||||
})
|
||||
|
||||
app.post('/api/study-account/verify-email-change', studyAuthRateLimiter, requireStudyAuth, (req, res) => {
|
||||
const user = req.studyUser
|
||||
const token = typeof req.body?.token === 'string' ? req.body.token.trim() : ''
|
||||
const pending = user.pendingEmailChange
|
||||
|
||||
if (!token || !pending || !pending.tokenHash) {
|
||||
res.status(400).json({ message: 'No pending email change request found.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (pending.expiresAt <= Date.now()) {
|
||||
user.pendingEmailChange = null
|
||||
queueStudyUsersWrite()
|
||||
res.status(400).json({ message: 'This verification link has expired. Request a new email change.' })
|
||||
return
|
||||
}
|
||||
|
||||
const submittedHash = hashEmailChangeToken(token)
|
||||
const a = Buffer.from(submittedHash, 'utf8')
|
||||
const b = Buffer.from(pending.tokenHash, 'utf8')
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
res.status(400).json({ message: 'Invalid verification token.' })
|
||||
return
|
||||
}
|
||||
|
||||
const newEmail = normalizeStudyUsername(pending.newEmail)
|
||||
if (!isValidStudyUsername(newEmail)) {
|
||||
user.pendingEmailChange = null
|
||||
queueStudyUsersWrite()
|
||||
res.status(400).json({ message: 'Pending email address is invalid.' })
|
||||
return
|
||||
}
|
||||
|
||||
const existing = findStudyUserByUsername(newEmail)
|
||||
if (existing && existing.id !== user.id) {
|
||||
user.pendingEmailChange = null
|
||||
queueStudyUsersWrite()
|
||||
res.status(409).json({ message: 'An account with that email already exists.' })
|
||||
return
|
||||
}
|
||||
|
||||
user.username = newEmail
|
||||
user.pendingEmailChange = null
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
|
||||
if (user.subscribeNewsletter !== false) {
|
||||
syncContactToResend(user.displayName || user.username, user.username).catch(err => console.error('[study-account] resend sync error:', err))
|
||||
}
|
||||
|
||||
res.json({ ok: true, username: user.username })
|
||||
})
|
||||
|
||||
app.get('/api/study-account/stats', requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const notes = await loadUserNotes(user.id)
|
||||
res.json({
|
||||
noteCount: Object.keys(notes).length,
|
||||
memberSince: user.createdAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/api/study-account', requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const deletedEmail = user.username
|
||||
const deletedDisplayName = user.displayName || user.username
|
||||
|
||||
for (const [token, session] of state.studySessions) {
|
||||
if (session.userId === user.id) state.studySessions.delete(token)
|
||||
}
|
||||
|
||||
state.studyUsers = state.studyUsers.filter(u => u.id !== user.id)
|
||||
queueStudyUsersWrite()
|
||||
|
||||
state.studyNotesCache.delete(user.id)
|
||||
try { await unlink(getUserNotesFilePath(user.id)) } catch { /* no notes file is fine */ }
|
||||
|
||||
sendStudyAccountDeletedEmail(deletedEmail, deletedDisplayName).catch(err => {
|
||||
console.error('[study-account] delete email error:', err)
|
||||
})
|
||||
|
||||
clearStudySessionCookie(res)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import qrcode from 'qrcode'
|
||||
import {
|
||||
generateTotpSecret,
|
||||
verifyTotpCode,
|
||||
generateRecoveryCodes,
|
||||
} from '../auth.js'
|
||||
import { parseCookies } from '../helpers.js'
|
||||
import { STUDY_SESSION_COOKIE, MAX_STUDY_USERS, MAX_CONTACT_SUBMISSIONS } from '../config.js'
|
||||
import { state } from '../state.js'
|
||||
import {
|
||||
queueStudyUsersWrite,
|
||||
queueContactSubmissionsWrite,
|
||||
normalizeContactEmailStatus,
|
||||
normalizeMessageType,
|
||||
} from '../data.js'
|
||||
import {
|
||||
normalizeStudyUsername,
|
||||
isValidStudyUsername,
|
||||
hashStudyPassword,
|
||||
findStudyUserByUsername,
|
||||
getStudyAvatarUrl,
|
||||
createStudySession,
|
||||
setStudySessionCookie,
|
||||
clearStudySessionCookie,
|
||||
requireStudyAuth,
|
||||
createStudyTotpPendingToken,
|
||||
consumeStudyTotpPendingToken,
|
||||
generateEmailOtp,
|
||||
storeEmailOtp,
|
||||
verifyEmailOtp,
|
||||
getStudyUserFromRequest,
|
||||
} from '../study-helpers.js'
|
||||
import { sendEmailOtp, sendStudyWelcomeEmail, syncContactToResend } from '../email.js'
|
||||
|
||||
const studyAuthRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 20,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { message: 'Too many attempts. Please wait 15 minutes and try again.' },
|
||||
skipSuccessfulRequests: true,
|
||||
})
|
||||
|
||||
export function register(app) {
|
||||
app.get('/api/study-auth/status', (req, res) => {
|
||||
const user = getStudyUserFromRequest(req)
|
||||
res.json({
|
||||
authenticated: Boolean(user),
|
||||
username: user?.username ?? '',
|
||||
displayName: user?.displayName ?? '',
|
||||
subscribeNewsletter: user?.subscribeNewsletter !== false,
|
||||
studyRemindersEnabled: user?.studyRemindersEnabled === true,
|
||||
avatarUrl: user ? getStudyAvatarUrl(user) : '',
|
||||
enrolledStudySlugs: Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [],
|
||||
totpEnabled: Boolean(user && (user.twoFaMethod === 'app' || user.twoFaMethod === 'email') && (user.twoFaMethod === 'email' || (user.totpSecret && user.totpVerified))),
|
||||
twoFaMethod: user?.twoFaMethod ?? null,
|
||||
totpRecoveryCodesRemaining: user?.twoFaMethod === 'app' ? (user.totpRecoveryCodes?.length ?? 0) : 0,
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/study-auth/signup', studyAuthRateLimiter, async (req, res) => {
|
||||
const username = normalizeStudyUsername(req.body?.username)
|
||||
const password = typeof req.body?.password === 'string' ? req.body.password : ''
|
||||
const subscribe = req.body?.subscribe === true
|
||||
const displayName = typeof req.body?.displayName === 'string' ? req.body.displayName.trim().slice(0, 80) : ''
|
||||
|
||||
if (!isValidStudyUsername(username)) {
|
||||
res.status(400).json({ message: 'Please enter a valid email address.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof password !== 'string' || password.length < 8 || password.length > 200) {
|
||||
res.status(400).json({ message: 'Password must be 8-200 characters.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (findStudyUserByUsername(username)) {
|
||||
res.status(409).json({ message: 'An account with that email already exists.' })
|
||||
return
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const user = {
|
||||
id: randomUUID(),
|
||||
username,
|
||||
passwordHash: hashStudyPassword(password),
|
||||
displayName,
|
||||
subscribeNewsletter: subscribe,
|
||||
studyRemindersEnabled: false,
|
||||
pendingEmailChange: null,
|
||||
enrolledStudySlugs: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastLoginAt: now,
|
||||
}
|
||||
|
||||
state.studyUsers.push(user)
|
||||
if (state.studyUsers.length > MAX_STUDY_USERS) {
|
||||
state.studyUsers = state.studyUsers.slice(state.studyUsers.length - MAX_STUDY_USERS)
|
||||
}
|
||||
queueStudyUsersWrite()
|
||||
|
||||
if (subscribe) {
|
||||
const wantsWelcome = true
|
||||
const submission = {
|
||||
id: randomUUID(),
|
||||
submittedAt: now,
|
||||
name: displayName || username,
|
||||
email: username,
|
||||
message: '',
|
||||
messageType: 'general',
|
||||
subscribe: wantsWelcome,
|
||||
archived: false,
|
||||
emailStatus: normalizeContactEmailStatus(null, wantsWelcome),
|
||||
}
|
||||
state.contactSubmissions.unshift(submission)
|
||||
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
|
||||
queueContactSubmissionsWrite()
|
||||
syncContactToResend(displayName || username, username).catch(err => console.error('[study-signup] resend sync error:', err))
|
||||
}
|
||||
|
||||
sendStudyWelcomeEmail(username, displayName || username).catch(err => console.error('[study-signup] welcome email error:', err))
|
||||
|
||||
const sessionToken = createStudySession(user.id)
|
||||
setStudySessionCookie(res, sessionToken)
|
||||
res.json({
|
||||
ok: true,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
subscribeNewsletter: user.subscribeNewsletter,
|
||||
studyRemindersEnabled: user.studyRemindersEnabled === true,
|
||||
avatarUrl: getStudyAvatarUrl(user.username),
|
||||
enrolledStudySlugs: user.enrolledStudySlugs,
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => {
|
||||
const username = normalizeStudyUsername(req.body?.username)
|
||||
const password = typeof req.body?.password === 'string' ? req.body.password : ''
|
||||
const user = findStudyUserByUsername(username)
|
||||
|
||||
if (!user) {
|
||||
res.status(401).json({ message: 'Invalid email or password.' })
|
||||
return
|
||||
}
|
||||
|
||||
const submittedHash = hashStudyPassword(password)
|
||||
const expectedHash = user.passwordHash
|
||||
const a = Buffer.from(submittedHash, 'utf8')
|
||||
const b = Buffer.from(expectedHash, 'utf8')
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
res.status(401).json({ message: 'Invalid email or password.' })
|
||||
return
|
||||
}
|
||||
|
||||
const twoFaMethod = user.twoFaMethod ?? (user.totpSecret && user.totpVerified ? 'app' : null)
|
||||
if (twoFaMethod === 'app' && user.totpSecret && user.totpVerified) {
|
||||
const pendingToken = createStudyTotpPendingToken(user.id)
|
||||
res.json({ totpRequired: true, pendingToken, method: 'app' })
|
||||
return
|
||||
}
|
||||
if (twoFaMethod === 'email') {
|
||||
const code = generateEmailOtp()
|
||||
storeEmailOtp(user.id, code)
|
||||
const pendingToken = createStudyTotpPendingToken(user.id)
|
||||
sendEmailOtp(user.username, code).catch(err => console.error('[email-otp] login send error:', err))
|
||||
res.json({ totpRequired: true, pendingToken, method: 'email' })
|
||||
return
|
||||
}
|
||||
|
||||
user.lastLoginAt = new Date().toISOString()
|
||||
user.updatedAt = user.lastLoginAt
|
||||
queueStudyUsersWrite()
|
||||
|
||||
const sessionToken = createStudySession(user.id)
|
||||
setStudySessionCookie(res, sessionToken)
|
||||
res.json({
|
||||
ok: true,
|
||||
username: user.username,
|
||||
displayName: user.displayName ?? '',
|
||||
subscribeNewsletter: user.subscribeNewsletter !== false,
|
||||
studyRemindersEnabled: user.studyRemindersEnabled === true,
|
||||
avatarUrl: getStudyAvatarUrl(user.username),
|
||||
enrolledStudySlugs: user.enrolledStudySlugs ?? [],
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/study-auth/totp-verify', studyAuthRateLimiter, (req, res) => {
|
||||
const { pendingToken, code } = req.body ?? {}
|
||||
const userId = consumeStudyTotpPendingToken(pendingToken)
|
||||
if (!userId) {
|
||||
res.status(401).json({ message: 'Session expired or invalid. Please sign in again.' })
|
||||
return
|
||||
}
|
||||
const user = state.studyUsers.find(u => u.id === userId)
|
||||
if (!user || !user.totpSecret || !user.totpVerified) {
|
||||
res.status(400).json({ message: '2FA is not configured for this account.' })
|
||||
return
|
||||
}
|
||||
|
||||
const codeStr = typeof code === 'string' ? code.replace(/\s/g, '') : ''
|
||||
const twoFaMethod = user.twoFaMethod ?? (user.totpSecret && user.totpVerified ? 'app' : null)
|
||||
|
||||
function completeLogin(extra = {}) {
|
||||
user.lastLoginAt = new Date().toISOString()
|
||||
user.updatedAt = user.lastLoginAt
|
||||
queueStudyUsersWrite()
|
||||
const sessionToken = createStudySession(user.id)
|
||||
setStudySessionCookie(res, sessionToken)
|
||||
res.json({ ok: true, ...extra, username: user.username, displayName: user.displayName ?? '', subscribeNewsletter: user.subscribeNewsletter !== false, studyRemindersEnabled: user.studyRemindersEnabled === true, avatarUrl: getStudyAvatarUrl(user.username), enrolledStudySlugs: user.enrolledStudySlugs ?? [] })
|
||||
}
|
||||
|
||||
if (twoFaMethod === 'email') {
|
||||
const result = verifyEmailOtp(user.id, codeStr)
|
||||
if (result === 'ok') { completeLogin(); return }
|
||||
if (result === 'expired') { res.status(401).json({ message: 'Code expired. Please sign in again to receive a new code.' }); return }
|
||||
if (result === 'too-many') { res.status(401).json({ message: 'Too many attempts. Please sign in again.' }); return }
|
||||
res.status(401).json({ message: 'Invalid code. Check your email and try again.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (verifyTotpCode(user.totpSecret, codeStr)) {
|
||||
completeLogin()
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.isArray(user.totpRecoveryCodes) && user.totpRecoveryCodes.length > 0) {
|
||||
const normalised = codeStr.replace(/-/g, '').toUpperCase()
|
||||
const matchIdx = user.totpRecoveryCodes.findIndex(h => {
|
||||
try { return createHash('sha256').update(normalised).digest('hex') === h } catch { return false }
|
||||
})
|
||||
if (matchIdx !== -1) {
|
||||
user.totpRecoveryCodes.splice(matchIdx, 1)
|
||||
completeLogin({ usedRecoveryCode: true, remainingRecoveryCodes: user.totpRecoveryCodes.length })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
res.status(401).json({ message: 'Invalid code. Try again or use a recovery code.' })
|
||||
})
|
||||
|
||||
app.post('/api/study-auth/totp-setup-init', requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const secret = generateTotpSecret()
|
||||
const label = user.username
|
||||
const issuer = 'Verse by Verse with Nate'
|
||||
const uri = `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(label)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`
|
||||
const qrDataUrl = await qrcode.toDataURL(uri)
|
||||
user.totpSecretPending = secret
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
res.json({ qrDataUrl, secret })
|
||||
})
|
||||
|
||||
app.post('/api/study-auth/totp-setup-confirm', requireStudyAuth, (req, res) => {
|
||||
const user = req.studyUser
|
||||
const { code } = req.body ?? {}
|
||||
if (!user.totpSecretPending) {
|
||||
res.status(400).json({ message: 'No 2FA setup in progress. Start setup first.' })
|
||||
return
|
||||
}
|
||||
if (!verifyTotpCode(user.totpSecretPending, typeof code === 'string' ? code.replace(/\s/g, '') : '')) {
|
||||
res.status(401).json({ message: 'Code incorrect. Scan the QR code again and try once more.' })
|
||||
return
|
||||
}
|
||||
const recoveryCodes = generateRecoveryCodes()
|
||||
user.totpSecret = user.totpSecretPending
|
||||
user.totpVerified = true
|
||||
user.twoFaMethod = 'app'
|
||||
user.totpEnabledAt = new Date().toISOString()
|
||||
user.totpRecoveryCodes = recoveryCodes.map(c => createHash('sha256').update(c.replace(/-/g, '').toUpperCase()).digest('hex'))
|
||||
delete user.totpSecretPending
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
res.json({ ok: true, recoveryCodes })
|
||||
})
|
||||
|
||||
app.post('/api/study-auth/2fa-setup-email', studyAuthRateLimiter, requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const code = generateEmailOtp()
|
||||
storeEmailOtp(user.id, code)
|
||||
await sendEmailOtp(user.username, code)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.post('/api/study-auth/2fa-setup-email-confirm', studyAuthRateLimiter, requireStudyAuth, (req, res) => {
|
||||
const user = req.studyUser
|
||||
const { code } = req.body ?? {}
|
||||
const result = verifyEmailOtp(user.id, typeof code === 'string' ? code.trim() : '')
|
||||
if (result === 'expired') { res.status(401).json({ message: 'Code expired. Please request a new one.' }); return }
|
||||
if (result === 'too-many') { res.status(401).json({ message: 'Too many attempts. Please request a new code.' }); return }
|
||||
if (result !== 'ok') { res.status(401).json({ message: 'Invalid code. Check your email and try again.' }); return }
|
||||
user.twoFaMethod = 'email'
|
||||
user.totpSecret = null
|
||||
user.totpVerified = false
|
||||
user.totpRecoveryCodes = []
|
||||
delete user.totpSecretPending
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.post('/api/study-auth/email-otp-resend', studyAuthRateLimiter, async (req, res) => {
|
||||
const { pendingToken } = req.body ?? {}
|
||||
const entry = state.studyTotpPendingTokens.get(pendingToken)
|
||||
if (!entry || Date.now() > entry.expiresAt) { res.status(401).json({ message: 'Session expired. Please sign in again.' }); return }
|
||||
const user = state.studyUsers.find(u => u.id === entry.userId)
|
||||
if (!user) { res.status(404).json({ message: 'User not found.' }); return }
|
||||
const code = generateEmailOtp()
|
||||
storeEmailOtp(user.id, code)
|
||||
await sendEmailOtp(user.username, code)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.post('/api/study-auth/totp-disable', studyAuthRateLimiter, requireStudyAuth, (req, res) => {
|
||||
const user = req.studyUser
|
||||
const { password } = req.body ?? {}
|
||||
const submittedHash = hashStudyPassword(typeof password === 'string' ? password : '')
|
||||
const a = Buffer.from(submittedHash, 'utf8')
|
||||
const b = Buffer.from(user.passwordHash, 'utf8')
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
res.status(401).json({ message: 'Incorrect password.' })
|
||||
return
|
||||
}
|
||||
user.twoFaMethod = null
|
||||
user.totpSecret = null
|
||||
user.totpVerified = false
|
||||
user.totpRecoveryCodes = []
|
||||
delete user.totpSecretPending
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.post('/api/study-auth/totp-regen-recovery', requireStudyAuth, (req, res) => {
|
||||
const user = req.studyUser
|
||||
if (!user.totpSecret || !user.totpVerified) {
|
||||
res.status(400).json({ message: '2FA is not enabled.' })
|
||||
return
|
||||
}
|
||||
const recoveryCodes = generateRecoveryCodes()
|
||||
user.totpRecoveryCodes = recoveryCodes.map(c => createHash('sha256').update(c.replace(/-/g, '').toUpperCase()).digest('hex'))
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
res.json({ ok: true, recoveryCodes })
|
||||
})
|
||||
|
||||
app.post('/api/study-auth/logout', (req, res) => {
|
||||
const cookies = parseCookies(req.headers.cookie)
|
||||
const token = cookies[STUDY_SESSION_COOKIE]
|
||||
if (token) {
|
||||
state.studySessions.delete(token)
|
||||
}
|
||||
clearStudySessionCookie(res)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import {
|
||||
requireStudyAuth,
|
||||
normalizeStudySlug,
|
||||
normalizeLessonSectionId,
|
||||
getStudySlugFromNoteId,
|
||||
isStudyUserEnrolled,
|
||||
isEnrollableStudySlug,
|
||||
getStudyTitleBySlug,
|
||||
findStudyUserById,
|
||||
getStudyAvatarUrl,
|
||||
getStudyCatalog,
|
||||
} from '../study-helpers.js'
|
||||
import { state } from '../state.js'
|
||||
import {
|
||||
queueStudyUsersWrite,
|
||||
queueStudyCommunityWrite,
|
||||
loadUserNotes,
|
||||
queueUserNotesWrite,
|
||||
loadUserProgress,
|
||||
queueUserProgressWrite,
|
||||
sanitizeStudyCommunityPosts,
|
||||
} from '../data.js'
|
||||
import { MAX_STUDY_NOTE_LENGTH, MAX_STUDY_NOTES_PER_USER, MAX_STUDY_ENROLLMENTS_PER_USER } from '../config.js'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
export function register(app) {
|
||||
// ── Enrollment ────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/study-enrollment', requireStudyAuth, (req, res) => {
|
||||
const user = req.studyUser
|
||||
res.json({
|
||||
enrolledStudySlugs: Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : [],
|
||||
availableStudies: getStudyCatalog()
|
||||
.filter(study => study.status !== 'planned')
|
||||
.map(study => ({ slug: study.slug, title: study.title })),
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/study-enrollment/:studySlug', requireStudyAuth, (req, res) => {
|
||||
const user = req.studyUser
|
||||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||||
if (!studySlug || !isEnrollableStudySlug(studySlug)) {
|
||||
res.status(404).json({ message: 'Study not found.' })
|
||||
return
|
||||
}
|
||||
|
||||
const enrolled = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
|
||||
if (!enrolled.includes(studySlug)) {
|
||||
user.enrolledStudySlugs = [...enrolled, studySlug].slice(0, MAX_STUDY_ENROLLMENTS_PER_USER)
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
}
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
studySlug,
|
||||
studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
|
||||
enrolledStudySlugs: user.enrolledStudySlugs,
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/api/study-enrollment/:studySlug', requireStudyAuth, (req, res) => {
|
||||
const user = req.studyUser
|
||||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||||
if (!studySlug || !isEnrollableStudySlug(studySlug)) {
|
||||
res.status(404).json({ message: 'Study not found.' })
|
||||
return
|
||||
}
|
||||
|
||||
const enrolled = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
|
||||
if (enrolled.includes(studySlug)) {
|
||||
user.enrolledStudySlugs = enrolled.filter(slug => slug !== studySlug)
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
}
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
studySlug,
|
||||
studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
|
||||
enrolledStudySlugs: user.enrolledStudySlugs,
|
||||
})
|
||||
})
|
||||
|
||||
// ── Notes ─────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/study-notes', requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const notes = await loadUserNotes(user.id)
|
||||
res.json({ notes })
|
||||
})
|
||||
|
||||
app.get('/api/study-notes/:sectionId', requireStudyAuth, async (req, res) => {
|
||||
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
||||
if (!sectionId) {
|
||||
res.status(400).json({ message: 'Invalid section id.' })
|
||||
return
|
||||
}
|
||||
const user = req.studyUser
|
||||
const noteStudySlug = getStudySlugFromNoteId(sectionId)
|
||||
if (noteStudySlug && !isStudyUserEnrolled(user, noteStudySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to access notes.' })
|
||||
return
|
||||
}
|
||||
const notes = await loadUserNotes(user.id)
|
||||
res.json({ note: notes[sectionId] ?? '' })
|
||||
})
|
||||
|
||||
app.put('/api/study-notes/:sectionId', requireStudyAuth, async (req, res) => {
|
||||
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
||||
if (!sectionId) {
|
||||
res.status(400).json({ message: 'Invalid section id.' })
|
||||
return
|
||||
}
|
||||
const user = req.studyUser
|
||||
const noteStudySlug = getStudySlugFromNoteId(sectionId)
|
||||
if (noteStudySlug && !isStudyUserEnrolled(user, noteStudySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to save notes.' })
|
||||
return
|
||||
}
|
||||
const rawNote = typeof req.body?.note === 'string' ? req.body.note : ''
|
||||
const note = rawNote.trim().slice(0, MAX_STUDY_NOTE_LENGTH)
|
||||
const notes = await loadUserNotes(user.id)
|
||||
|
||||
if (!note) {
|
||||
delete notes[sectionId]
|
||||
} else {
|
||||
const existingCount = Object.keys(notes).length
|
||||
if (!notes[sectionId] && existingCount >= MAX_STUDY_NOTES_PER_USER) {
|
||||
res.status(400).json({ message: 'Notes limit reached for this account.' })
|
||||
return
|
||||
}
|
||||
notes[sectionId] = note
|
||||
}
|
||||
|
||||
state.studyNotesCache.set(user.id, notes)
|
||||
queueUserNotesWrite(user.id)
|
||||
res.json({ ok: true, note })
|
||||
})
|
||||
|
||||
// ── Progress ──────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/study-progress/:studySlug', requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||||
if (!studySlug) {
|
||||
res.status(400).json({ message: 'Study slug is required.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to view progress.' })
|
||||
return
|
||||
}
|
||||
|
||||
const progress = await loadUserProgress(user.id)
|
||||
const completedSectionIds = progress.byStudy[studySlug]?.completedSectionIds ?? []
|
||||
res.json({ studySlug, completedSectionIds })
|
||||
})
|
||||
|
||||
app.post('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||||
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
||||
if (!studySlug || !sectionId) {
|
||||
res.status(400).json({ message: 'Invalid study slug or section id.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to update progress.' })
|
||||
return
|
||||
}
|
||||
|
||||
const progress = await loadUserProgress(user.id)
|
||||
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [] }
|
||||
if (!studyProgress.completedSectionIds.includes(sectionId)) {
|
||||
studyProgress.completedSectionIds = [...studyProgress.completedSectionIds, sectionId]
|
||||
}
|
||||
progress.byStudy[studySlug] = studyProgress
|
||||
progress.updatedAt = new Date().toISOString()
|
||||
state.studyProgressCache.set(user.id, progress)
|
||||
queueUserProgressWrite(user.id)
|
||||
|
||||
res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds })
|
||||
})
|
||||
|
||||
app.delete('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||||
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
||||
if (!studySlug || !sectionId) {
|
||||
res.status(400).json({ message: 'Invalid study slug or section id.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to update progress.' })
|
||||
return
|
||||
}
|
||||
|
||||
const progress = await loadUserProgress(user.id)
|
||||
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [] }
|
||||
studyProgress.completedSectionIds = studyProgress.completedSectionIds.filter(id => id !== sectionId)
|
||||
progress.byStudy[studySlug] = studyProgress
|
||||
progress.updatedAt = new Date().toISOString()
|
||||
state.studyProgressCache.set(user.id, progress)
|
||||
queueUserProgressWrite(user.id)
|
||||
|
||||
res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds })
|
||||
})
|
||||
|
||||
// ── Quiz ──────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/study-quiz/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||||
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
||||
if (!studySlug || !sectionId) {
|
||||
res.status(400).json({ message: 'Invalid study slug or section id.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to view quiz answers.' })
|
||||
return
|
||||
}
|
||||
|
||||
const progress = await loadUserProgress(user.id)
|
||||
const quizAnswers = progress.byStudy[studySlug]?.quizAnswers?.[sectionId] ?? []
|
||||
res.json({ studySlug, sectionId, answers: quizAnswers })
|
||||
})
|
||||
|
||||
app.post('/api/study-quiz/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||||
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
||||
if (!studySlug || !sectionId) {
|
||||
res.status(400).json({ message: 'Invalid study slug or section id.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to save quiz answers.' })
|
||||
return
|
||||
}
|
||||
|
||||
const rawAnswers = req.body?.answers
|
||||
const answers = Array.isArray(rawAnswers)
|
||||
? rawAnswers.map(answer => typeof answer === 'string' ? answer.trim() : '').filter(Boolean)
|
||||
: []
|
||||
|
||||
const progress = await loadUserProgress(user.id)
|
||||
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [] }
|
||||
studyProgress.quizAnswers = studyProgress.quizAnswers || {}
|
||||
studyProgress.quizAnswers[sectionId] = answers
|
||||
progress.byStudy[studySlug] = studyProgress
|
||||
progress.updatedAt = new Date().toISOString()
|
||||
state.studyProgressCache.set(user.id, progress)
|
||||
queueUserProgressWrite(user.id)
|
||||
|
||||
res.json({ ok: true, studySlug, sectionId, answers })
|
||||
})
|
||||
|
||||
// ── Community ─────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/study-community', requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const studySlug = normalizeStudySlug(typeof req.query?.studySlug === 'string' ? req.query.studySlug : '')
|
||||
|
||||
if (!studySlug) {
|
||||
res.status(400).json({ message: 'Study slug is required.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to view the community.' })
|
||||
return
|
||||
}
|
||||
|
||||
const posts = state.studyCommunityPosts
|
||||
.filter(post => post.studySlug === studySlug)
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.slice(0, 50)
|
||||
.map(post => {
|
||||
const author = findStudyUserById(post.authorUserId)
|
||||
return {
|
||||
...post,
|
||||
authorAvatarUrl: getStudyAvatarUrl(author || post.authorName || ''),
|
||||
replies: Array.isArray(post.replies)
|
||||
? post.replies.map(reply => {
|
||||
const replyAuthor = findStudyUserById(reply.authorUserId)
|
||||
return { ...reply, authorAvatarUrl: getStudyAvatarUrl(replyAuthor || reply.authorName || '') }
|
||||
})
|
||||
: [],
|
||||
}
|
||||
})
|
||||
|
||||
res.json({ studySlug, posts })
|
||||
})
|
||||
|
||||
app.post('/api/study-community/posts', requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const studySlug = normalizeStudySlug(req.body?.studySlug)
|
||||
const sectionId = typeof req.body?.sectionId === 'string' && /^[a-z0-9-]{1,80}$/i.test(req.body.sectionId) ? req.body.sectionId.trim() : ''
|
||||
const message = typeof req.body?.message === 'string' ? req.body.message.trim().slice(0, 3000) : ''
|
||||
|
||||
if (!studySlug || !message) {
|
||||
res.status(400).json({ message: 'Study slug and message are required.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to post in the community.' })
|
||||
return
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const authorName = user.displayName?.trim() || (user.username?.includes('@') ? user.username.split('@')[0] : user.username)
|
||||
const post = {
|
||||
id: randomUUID(),
|
||||
studySlug,
|
||||
sectionId,
|
||||
authorUserId: user.id,
|
||||
authorName,
|
||||
authorAvatarUrl: getStudyAvatarUrl(user.username),
|
||||
message,
|
||||
createdAt: now,
|
||||
replies: [],
|
||||
}
|
||||
|
||||
state.studyCommunityPosts.unshift(post)
|
||||
state.studyCommunityPosts = sanitizeStudyCommunityPosts(state.studyCommunityPosts).slice(0, 500)
|
||||
queueStudyCommunityWrite()
|
||||
|
||||
res.json({ ok: true, post })
|
||||
})
|
||||
|
||||
app.post('/api/study-community/posts/:postId/replies', requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const postId = typeof req.params.postId === 'string' ? req.params.postId.trim() : ''
|
||||
const message = typeof req.body?.message === 'string' ? req.body.message.trim().slice(0, 3000) : ''
|
||||
|
||||
if (!postId || !message) {
|
||||
res.status(400).json({ message: 'Post id and message are required.' })
|
||||
return
|
||||
}
|
||||
|
||||
const post = state.studyCommunityPosts.find(item => item.id === postId)
|
||||
if (!post) {
|
||||
res.status(404).json({ message: 'Post not found.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStudyUserEnrolled(user, post.studySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to reply in the community.' })
|
||||
return
|
||||
}
|
||||
|
||||
const reply = {
|
||||
id: randomUUID(),
|
||||
authorUserId: user.id,
|
||||
authorName: user.displayName?.trim() || (user.username?.includes('@') ? user.username.split('@')[0] : user.username),
|
||||
authorAvatarUrl: getStudyAvatarUrl(user.username),
|
||||
message,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
post.replies = Array.isArray(post.replies) ? post.replies : []
|
||||
post.replies.push(reply)
|
||||
post.replies = sanitizeStudyCommunityPosts([post])[0]?.replies ?? []
|
||||
queueStudyCommunityWrite()
|
||||
|
||||
res.json({ ok: true, reply })
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user