diff --git a/.claude/worktrees/agent-a3dddb4d7aa52218e b/.claude/worktrees/agent-a3dddb4d7aa52218e new file mode 160000 index 0000000..e17a818 --- /dev/null +++ b/.claude/worktrees/agent-a3dddb4d7aa52218e @@ -0,0 +1 @@ +Subproject commit e17a818deb6a8ffca16091858be400466698135d diff --git a/data/study-reminders.json b/data/study-reminders.json new file mode 100644 index 0000000..9edc739 --- /dev/null +++ b/data/study-reminders.json @@ -0,0 +1,4 @@ +{ + "users": {}, + "updatedAt": "2026-06-03T19:07:22.282Z" +} \ No newline at end of file diff --git a/server.js b/server.js index eb4a5db..6a80773 100644 --- a/server.js +++ b/server.js @@ -1,4910 +1,74 @@ import express from 'express' -import rateLimit from 'express-rate-limit' -import { mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises' -import { createHash, randomUUID, timingSafeEqual } from 'node:crypto' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { Resend } from 'resend' -import qrcode from 'qrcode' -import { Document, Packer, Paragraph, HeadingLevel, TextRun, AlignmentType } from 'docx' +import { hasVisitorConsent } from './server/helpers.js' +import { BACKUP_INTERVAL_MS } from './server/config.js' +import { state } from './server/state.js' import { - sanitizeSiteContent, - escapeHtml, - escapeXml, - buildAbsoluteUrl, - injectSeoIntoHtml, - normalizeAssetBaseName, - inferImageExtensionFromDataUrl, - getClientIp, - hasVisitorConsent, - setConsentCookie, - splitName, - parseCookies, -} from './server/helpers.js' + loadHitStatsFromDisk, + loadVisitorStatsFromDisk, + loadContactSubmissionsFromDisk, + loadReplyTemplatesFromDisk, + loadReplyHistoryFromDisk, + loadQuestionsFromDisk, + loadDraftQuestionsFromDisk, + loadStudyUsersFromDisk, + loadStudyCommunityFromDisk, + loadStudyRemindersFromDisk, + migrateStudyNotesIfNeeded, + loadDownloadCountsFromDisk, + loadPodcastChecklistFromDisk, + createBackupSnapshot, + refreshContentCaches, + queueHitStatsWrite, +} from './server/data.js' +import { logResendEmailAlignmentWarnings, sendStudyReminderEmail } from './server/email.js' import { - isAdminPasswordConfigured, - isValidAdminSession, - requireAdminAuth, - setAdminSessionCookie, - clearAdminSessionCookie, - createAdminSession, - deleteAdminSession, - validateAdminPasswordSetup, - isAdminPasswordValid, - isTotpEnabled, - loadTotpState, - saveTotpState, - generateTotpSecret, - getTotpUri, - verifyTotpCode, - generateRecoveryCodes, - hashRecoveryCode, - consumeRecoveryCode, - createPendingSession, - consumePendingSession, -} from './server/auth.js' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const DEFAULT_DATA_DIR = path.join(__dirname, 'data') -const configuredDataDir = typeof process.env.SITEFORGE_DATA_DIR === 'string' ? process.env.SITEFORGE_DATA_DIR.trim() : '' -const DATA_DIR = configuredDataDir - ? (path.isAbsolute(configuredDataDir) ? configuredDataDir : path.resolve(__dirname, configuredDataDir)) - : DEFAULT_DATA_DIR -const DATA_FILE = path.join(DATA_DIR, 'admin-content.json') -const DRAFT_DATA_FILE = path.join(DATA_DIR, 'admin-content-draft.json') -const HIT_STATS_FILE = path.join(DATA_DIR, 'hit-stats.json') -const VISITOR_STATS_FILE = path.join(DATA_DIR, 'visitor-stats.json') -const CONTACT_SUBMISSIONS_FILE = path.join(DATA_DIR, 'contact-submissions.json') -const QUESTIONS_FILE = path.join(DATA_DIR, 'questions.json') -const DRAFT_QUESTIONS_FILE = path.join(DATA_DIR, 'questions-draft.json') -const STUDY_USERS_FILE = path.join(DATA_DIR, 'study-users.json') -const STUDY_NOTES_FILE = path.join(DATA_DIR, 'study-notes.json') // legacy — kept only for one-time migration -const STUDY_NOTES_DIR = path.join(DATA_DIR, 'study-notes') -const STUDY_PROGRESS_DIR = path.join(DATA_DIR, 'study-progress') -const STUDY_COMMUNITY_FILE = path.join(DATA_DIR, 'study-community.json') -const REPLY_TEMPLATES_FILE = path.join(DATA_DIR, 'admin-reply-templates.json') -const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.json') -const PODCAST_CHECKLIST_FILE = path.join(DATA_DIR, 'podcast-checklist.json') -const BACKUP_DIR = path.join(DATA_DIR, 'backups') -const UPLOADS_DIR = path.join(DATA_DIR, 'uploads') -const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json') -const DOWNLOAD_COUNTS_FILE = path.join(DATA_DIR, 'download-counts.json') -const STUDY_REMINDERS_FILE = path.join(DATA_DIR, 'study-reminders.json') -const DIST_DIR = path.join(__dirname, 'dist') -const INDEX_FILE = path.join(DIST_DIR, 'index.html') -const DIST_IMAGES_DIR = path.join(DIST_DIR, 'images') -const PUBLIC_IMAGES_DIR = path.join(__dirname, 'public', 'images') -validateAdminPasswordSetup() -const TITUS_STUDY_FILE = process.env.TITUS_STUDY_FILE - ? path.resolve(__dirname, process.env.TITUS_STUDY_FILE) - : path.join(__dirname, 'A_Study_of_Titus.pdf') -const TITUS_STUDY_DOWNLOAD_NAME = process.env.TITUS_STUDY_DOWNLOAD_NAME ?? 'A_Study_of_Titus.pdf' -const DEFAULT_REDIRECT_RULES = [ - { - id: 'spotify', - path: '/spotify', - target: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl', - statusCode: 301, - }, - { - id: 'apple', - path: '/apple', - target: 'https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate', - statusCode: 301, - }, - { - id: 'amazon', - path: '/amazon', - target: 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate', - statusCode: 301, - }, -] - -function normalizeRedirectPath(value) { - if (typeof value !== 'string') return '' - const trimmed = value.trim() - if (!trimmed) return '' - const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}` - const normalized = withSlash.replace(/\/+/g, '/') - if (normalized === '/') return '' - if (normalized.startsWith('/api/') || normalized.startsWith('/admin')) return '' - return normalized -} - -function sanitizeUrl(value) { - if (typeof value !== 'string') return '' - const trimmed = value.trim() - if (!trimmed) return '' - if (trimmed.startsWith('/')) return trimmed - if (/^https?:\/\//i.test(trimmed)) return trimmed - return '' -} - -function getSectionReleaseTime(section) { - if (!section || typeof section !== 'object') return Number.NaN - const candidateValues = [ - section.releasedAt, - section.releaseDate, - section.availableAt, - section.publishAt, - ] - - for (const candidate of candidateValues) { - if (typeof candidate !== 'string' || !candidate.trim()) continue - const releaseTime = Date.parse(candidate) - if (Number.isFinite(releaseTime)) return releaseTime - } - - return Number.NaN -} - -function getSectionReleaseDate(section) { - const releaseTime = getSectionReleaseTime(section) - if (!Number.isFinite(releaseTime)) return null - return new Date(releaseTime) -} - -function isSectionReleased(section) { - const releaseTime = getSectionReleaseTime(section) - if (!Number.isFinite(releaseTime)) return false - return releaseTime <= Date.now() -} - -function redactUnreleasedSection(section) { - if (!section || typeof section !== 'object') return section - if (isSectionReleased(section)) return section - - return { - ...section, - passageText: '', - commentary: '', - greekNotes: [], - studyQuestions: [], - audioEmbedUrl: '', - } -} - -function filterSiteContentByReleaseDate(siteContent) { - if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) return siteContent - - const filteredStudies = Array.isArray(siteContent.studies) - ? siteContent.studies.map(study => { - if (!study || typeof study !== 'object') return study - const sections = Array.isArray(study.sections) ? study.sections.map(redactUnreleasedSection) : [] - return { ...study, sections } - }) - : siteContent.studies - - const filteredLegacySections = Array.isArray(siteContent.colossiansStudySections) - ? siteContent.colossiansStudySections.map(redactUnreleasedSection) - : siteContent.colossiansStudySections - - return { - ...siteContent, - studies: filteredStudies, - colossiansStudySections: filteredLegacySections, - } -} - -function sanitizeRedirectRules(value) { - const source = Array.isArray(value) ? value : [] - const seen = new Set() - const out = [] - - for (const item of source) { - const pathValue = normalizeRedirectPath(item?.path) - const target = sanitizeUrl(item?.target) - const statusCode = Number(item?.statusCode) === 302 ? 302 : 301 - if (!pathValue || !target) continue - if (seen.has(pathValue)) continue - seen.add(pathValue) - out.push({ - id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), - path: pathValue, - target, - statusCode, - }) - } - - return out.length > 0 ? out : DEFAULT_REDIRECT_RULES -} - -const DEFAULT_SEO = { - title: 'Verse by Verse with Nate', - description: 'Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.', - ogTitle: 'Verse by Verse with Nate', - ogDescription: 'A Journey Through Scripture - verse by verse, nugget by nugget.', - ogImage: '/images/podcast-art.jpeg', - canonicalUrl: 'https://versebyversewithnate.us/', - robotsPolicy: 'index,follow', - sitemapPaths: ['/', '/start-here', '/questions', '/privacy', '/terms'], -} -const DEFAULT_LEGAL = { - privacyTitle: 'Privacy Policy', - privacyBody: [ - 'We respect your privacy and collect limited data to operate and improve this site.', - 'If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.', - 'Contact form details are used only to respond to your message and ministry communication requests.', - ], - termsTitle: 'Terms', - termsBody: [ - 'Content on this site is for informational and ministry purposes.', - 'External links are provided for convenience and are subject to third-party policies.', - 'By using this site, you agree to lawful use and respectful communication.', - ], -} -const DEFAULT_PODCAST_FEATURED_LINKS = [] -const DEFAULT_PUBLISH_STATE = { - draftUpdatedAt: null, - publishedAt: null, -} - -const DEFAULT_PODCAST_CHECKLIST_TASKS = [ - { id: 'verify_script', label: 'Verify Script', phase: 'pre' }, - { id: 'read_script', label: 'Read Script', phase: 'pre' }, - { id: 'record', label: 'Record', phase: 'pre' }, - { id: 'mix', label: 'Mix', phase: 'pre' }, - { id: 'edit', label: 'Edit', phase: 'pre' }, - { id: 'video_script', label: 'Run Video Conversion Script', phase: 'pre' }, - { id: 'post_spotify', label: 'Post on Spotify', phase: 'pre' }, - { id: 'update_website', label: 'Update Website', phase: 'post' }, - { id: 'send_email', label: 'Send Email', phase: 'post' }, -] - -function buildChecklistEpisode(series, number) { - const tasks = {} - for (const task of DEFAULT_PODCAST_CHECKLIST_TASKS) { - tasks[task.id] = false - } - - return { - id: `${series.toLowerCase()}-${number}`, - series, - episodeNumber: number, - title: '', - datePublished: '', - expanded: false, - tasks, - } -} - -function buildDefaultPodcastChecklist() { - const titusEpisodes = [11, 12, 13, 14, 15].map(number => buildChecklistEpisode('Titus', number)) - const colossiansEpisodes = Array.from({ length: 27 }, (_, index) => buildChecklistEpisode('Colossians', index + 1)) - - return { - tasks: DEFAULT_PODCAST_CHECKLIST_TASKS, - episodes: [...titusEpisodes, ...colossiansEpisodes], - } -} - -function sanitizeChecklistTask(task) { - const label = typeof task?.label === 'string' ? task.label.trim().slice(0, 120) : '' - if (!label) return null - - const phase = task?.phase === 'post' ? 'post' : 'pre' - const id = typeof task?.id === 'string' && task.id.trim() ? task.id.trim() : randomUUID() - return { id, label, phase } -} - -function sanitizePodcastChecklist(value) { - const fallback = buildDefaultPodcastChecklist() - const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {} - - const taskInput = Array.isArray(source.tasks) ? source.tasks : fallback.tasks - const seenTaskIds = new Set() - const tasks = [] - - for (const item of taskInput) { - const safeTask = sanitizeChecklistTask(item) - if (!safeTask) continue - if (seenTaskIds.has(safeTask.id)) continue - seenTaskIds.add(safeTask.id) - tasks.push(safeTask) - } - - if (tasks.length === 0) { - for (const task of fallback.tasks) { - tasks.push({ ...task }) - seenTaskIds.add(task.id) - } - } - - const taskIds = tasks.map(task => task.id) - const episodesInput = Array.isArray(source.episodes) ? source.episodes : fallback.episodes - const episodes = [] - - for (const item of episodesInput) { - if (!item || typeof item !== 'object' || Array.isArray(item)) continue - - const id = typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID() - const series = typeof item.series === 'string' ? item.series.trim().slice(0, 80) : '' - const title = typeof item.title === 'string' ? item.title.trim().slice(0, 180) : '' - const rawEpisodeNumber = Number(item.episodeNumber) - const episodeNumber = Number.isFinite(rawEpisodeNumber) && rawEpisodeNumber >= 0 - ? Math.round(rawEpisodeNumber) - : null - - const datePublished = typeof item.datePublished === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(item.datePublished.trim()) - ? item.datePublished.trim() - : '' - - const sourceTasks = item.tasks && typeof item.tasks === 'object' && !Array.isArray(item.tasks) - ? item.tasks - : {} - const taskState = {} - for (const taskId of taskIds) { - taskState[taskId] = sourceTasks[taskId] === true - } - - episodes.push({ - id, - series, - episodeNumber, - title, - datePublished, - expanded: item.expanded === true, - tasks: taskState, - }) - } - - if (episodes.length === 0) { - return fallback - } - - return { tasks, episodes } -} - -const DEFAULT_REPLY_TEMPLATES = [ - { - id: 'thanks-for-reaching-out', - label: 'Thank You Reply', - subject: 'Thanks for reaching out to Verse by Verse with Nate', - message: 'Thank you for reaching out.\n\nI appreciate your message and wanted to follow up personally.', - }, - { - id: 'question-received', - label: 'Question Received', - subject: 'Your Bible question was received', - message: 'Thank you for sending your Bible question.\n\nI have received it, and I appreciate you taking the time to write in.', - }, - { - id: 'testimony-thank-you', - label: 'Testimony Thank You', - subject: 'Thank you for sharing your testimony', - message: 'Thank you for sharing what the Lord is doing in your life.\n\nYour message was an encouragement to read.', - }, -] - -let cachedSiteContent = null -let cachedDraftSiteContent = null -let publishState = { ...DEFAULT_PUBLISH_STATE } -let draftQuestions = null -let draftQuestionsWritePromise = Promise.resolve() -let replyTemplates = [...DEFAULT_REPLY_TEMPLATES] -let replyTemplatesWritePromise = Promise.resolve() -let replyHistory = [] -let replyHistoryWritePromise = Promise.resolve() -let podcastChecklist = buildDefaultPodcastChecklist() -let podcastChecklistWritePromise = Promise.resolve() - -async function loadSiteContentFile(filePath) { - const raw = await readFile(filePath, 'utf8') - const parsed = JSON.parse(raw) - const safeSiteContent = sanitizeSiteContent(parsed?.siteContent) - return { - ...parsed, - siteContent: safeSiteContent, - } -} - -async function checkDataDirWritable() { - try { - await mkdir(DATA_DIR, { recursive: true }) - const marker = path.join(DATA_DIR, `.write-test-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`) - await writeFile(marker, 'ok', 'utf8') - await unlink(marker) - return { ok: true, error: null } - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : 'Unknown write test error' } - } -} - -async function getStorageStatus() { - const writable = await checkDataDirWritable() - const files = {} - for (const [key, filePath] of Object.entries({ - adminContent: DATA_FILE, - adminContentDraft: DRAFT_DATA_FILE, - studyUsers: STUDY_USERS_FILE, - })) { - try { - const fileStat = await stat(filePath) - files[key] = { - path: filePath, - exists: true, - sizeBytes: fileStat.size, - mtime: fileStat.mtime.toISOString(), - } - } catch { - files[key] = { - path: filePath, - exists: false, - sizeBytes: 0, - mtime: null, - } - } - } - - return { - dataDir: DATA_DIR, - writable, - files, - } -} - -async function refreshContentCaches() { - try { - const published = await loadSiteContentFile(DATA_FILE) - cachedSiteContent = published.siteContent - if (typeof published?.updatedAt === 'string') { - publishState.publishedAt = published.updatedAt - } - } catch { - cachedSiteContent = null - } - - try { - const draft = await loadSiteContentFile(DRAFT_DATA_FILE) - cachedDraftSiteContent = draft.siteContent - if (typeof draft?.updatedAt === 'string') { - publishState.draftUpdatedAt = draft.updatedAt - } - } catch { - cachedDraftSiteContent = null - } -} - -async function loadDraftQuestionsFromDisk() { - return readFile(DRAFT_QUESTIONS_FILE, 'utf8') - .then(raw => { - const parsed = JSON.parse(raw) - if (Array.isArray(parsed)) { - draftQuestions = parsed.slice(0, MAX_QUESTIONS) - } else if (Array.isArray(parsed?.questions)) { - draftQuestions = parsed.questions.slice(0, MAX_QUESTIONS) - } else { - draftQuestions = null - } - if (typeof parsed?.updatedAt === 'string') { - publishState.draftUpdatedAt = parsed.updatedAt - } - }) - .catch(() => { - draftQuestions = null - }) -} - -function ensureDraftQuestions() { - if (draftQuestions !== null) return - draftQuestions = questions.slice(0, MAX_QUESTIONS) -} - -async function readUploadsMetadata() { - try { - const raw = await readFile(UPLOADS_META_FILE, 'utf8') - return JSON.parse(raw) - } catch { - return {} - } -} - -function loadDownloadCountsFromDisk() { - return readFile(DOWNLOAD_COUNTS_FILE, 'utf8') - .then(raw => { - const parsed = JSON.parse(raw) - downloadCounts = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {} - }) - .catch(() => { - downloadCounts = {} - }) -} - -function queueDownloadCountsWrite() { - downloadCountsWritePromise = downloadCountsWritePromise - .then(async () => { - await mkdir(DATA_DIR, { recursive: true }) - await writeFile(DOWNLOAD_COUNTS_FILE, JSON.stringify(downloadCounts, null, 2), 'utf8') - }) - .catch(err => { - console.error('[download-counts] failed to write:', err) - }) -} - -function incrementDownloadCount(resourceKey) { - downloadCounts[resourceKey] = (downloadCounts[resourceKey] ?? 0) + 1 - queueDownloadCountsWrite() -} - -async function writeUploadsMetadata(metadata) { - await mkdir(DATA_DIR, { recursive: true }) - await writeFile(UPLOADS_META_FILE, JSON.stringify(metadata, null, 2), 'utf8') -} - -async function listUploadedAssets() { - await mkdir(UPLOADS_DIR, { recursive: true }) - const files = await readdir(UPLOADS_DIR) - const imageFiles = files.filter(name => /\.(png|jpe?g|webp|gif|pdf|docx?)$/i.test(name)).sort() - const metadata = await readUploadsMetadata() - - const withStats = await Promise.all(imageFiles.map(async filename => { - const info = await stat(path.join(UPLOADS_DIR, filename)) - return { - filename, - url: `/uploads/${filename}`, - sizeBytes: info.size, - updatedAt: info.mtime.toISOString(), - tags: Array.isArray(metadata[filename]) ? metadata[filename].filter(tag => typeof tag === 'string') : [], - } - })) - - return withStats -} - -async function invokeWebhook(url, action) { - if (!url) { - return { ok: false, message: `${action} webhook URL is not configured.` } - } - - try { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - action, - at: new Date().toISOString(), - source: 'siteforge-admin', - }), - }) - - if (!response.ok) { - return { ok: false, message: `${action} webhook failed with ${response.status}.` } - } - - return { ok: true, message: `${action} webhook triggered.` } - } catch (err) { - return { ok: false, message: err instanceof Error ? err.message : `${action} webhook failed.` } - } -} - -const EMPTY_HIT_STATS = { - totalHits: 0, - realHits: 0, - botHits: 0, - firstHitAt: null, - lastHitAt: null, - byPath: {}, - byPathReal: {}, - byPathBot: {}, - byDay: {}, - byDayReal: {}, - byDayBot: {}, - botReasons: {}, -} - -let hitStats = { ...EMPTY_HIT_STATS } -let hitStatsWritePromise = Promise.resolve() - -const VISITOR_COOKIE = 'vbn_vid' -const CONSENT_COOKIE = 'vbn_analytics_consent' -const MAX_RECENT_VISITS = 1000 -const VISITOR_RETENTION_DAYS_DEFAULT = 180 -const BACKUP_RETENTION_DAYS = 30 -const BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000 -const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000 - -const EMPTY_VISITOR_STATS = { - totalVisits: 0, - uniqueVisitors: 0, - returningVisits: 0, - firstVisitAt: null, - lastVisitAt: null, - visitors: {}, - ipHashIndex: {}, // ipHash → visitorId — prevents same IP counting as multiple unique visitors - recentVisits: [], - geoCacheByIp: {}, -} - -const MAX_CONTACT_SUBMISSIONS = 5000 -const CONTACT_EMAIL_COOLDOWN_MS = Math.max(10 * 1000, Number(process.env.CONTACT_EMAIL_COOLDOWN_MS ?? 60 * 1000) || 60 * 1000) -const DOWNLOAD_TOKEN_TTL_MS = 10 * 60 * 1000 -const titusDownloadTokens = new Map() - -const MAX_QUESTIONS = 1000 -const MAX_STUDY_USERS = 5000 -const MAX_STUDY_ENROLLMENTS_PER_USER = 100 -const MAX_STUDY_NOTES_PER_USER = 500 -const MAX_STUDY_NOTE_LENGTH = 12000 -const EMAIL_CHANGE_TOKEN_TTL_MS = 24 * 60 * 60 * 1000 -const STUDY_SESSION_COOKIE = 'vbn_study_session' -const STUDY_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000 -let visitorStats = { ...EMPTY_VISITOR_STATS } -let visitorStatsWritePromise = Promise.resolve() -let contactSubmissions = [] -let contactSubmissionsWritePromise = Promise.resolve() -const contactSubmitCooldownByEmail = new Map() -const resendEmailSubmissionIndex = new Map() -let questions = [] -let questionsWritePromise = Promise.resolve() -let studyUsers = [] -let studyUsersWritePromise = Promise.resolve() -const studyNotesCache = new Map() // userId -> { [sectionId]: string } -const studyNotesWriteQueues = new Map() // userId -> Promise -let studyReminders = { users: {}, updatedAt: new Date().toISOString() } -let studyRemindersWritePromise = Promise.resolve() -const studyProgressCache = new Map() // userId -> { byStudy: Record } -const studyProgressWriteQueues = new Map() // userId -> Promise -let studyCommunityPosts = [] -let studyCommunityWritePromise = Promise.resolve() -let downloadCounts = {} -let downloadCountsWritePromise = Promise.resolve() -let lastVisitorStatsWrite = { ok: true, at: null, error: null } -let lastHitStatsWrite = { ok: true, at: null, error: null } -let lastBackupStatus = { ok: true, at: null, error: null, file: null } -let lastCachePurgeStatus = { ok: true, at: null, error: null } -let lastDeployHookStatus = { ok: true, at: null, error: null } -const studySessions = new Map() -// Short-lived tokens for 2FA second step: token → { userId, expiresAt } -const studyTotpPendingTokens = new Map() -const STUDY_TOTP_PENDING_TTL_MS = 5 * 60 * 1000 // 5 minutes -// In-memory email OTP store: userId → { codeHash, expiresAt, attempts } -const emailOtpStore = new Map() -const EMAIL_OTP_TTL_MS = 10 * 60 * 1000 // 10 minutes -const EMAIL_OTP_MAX_ATTEMPTS = 5 - -function generateEmailOtp() { - return String(Math.floor(100000 + Math.random() * 900000)) -} - -function hashEmailOtp(code) { - return createHash('sha256').update(String(code).trim()).digest('hex') -} - -function storeEmailOtp(userId, code) { - emailOtpStore.set(userId, { codeHash: hashEmailOtp(code), expiresAt: Date.now() + EMAIL_OTP_TTL_MS, attempts: 0 }) -} - -function verifyEmailOtp(userId, code) { - const entry = emailOtpStore.get(userId) - if (!entry) return 'no-code' - if (Date.now() > entry.expiresAt) { emailOtpStore.delete(userId); return 'expired' } - entry.attempts += 1 - if (entry.attempts > EMAIL_OTP_MAX_ATTEMPTS) { emailOtpStore.delete(userId); return 'too-many' } - if (hashEmailOtp(String(code).trim()) !== entry.codeHash) return 'wrong' - emailOtpStore.delete(userId) - return 'ok' -} - -function createStudyTotpPendingToken(userId) { - const token = randomUUID() - studyTotpPendingTokens.set(token, { userId, expiresAt: Date.now() + STUDY_TOTP_PENDING_TTL_MS }) - return token -} - -function consumeStudyTotpPendingToken(token) { - const entry = studyTotpPendingTokens.get(token) - if (!entry) return null - studyTotpPendingTokens.delete(token) - if (Date.now() > entry.expiresAt) return null - return entry.userId -} - -function sanitizeReplyTemplates(value) { - if (!Array.isArray(value)) return [...DEFAULT_REPLY_TEMPLATES] - const out = value - .filter(item => item && typeof item === 'object') - .map(item => ({ - id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), - label: typeof item.label === 'string' ? item.label.trim().slice(0, 80) : '', - subject: typeof item.subject === 'string' ? item.subject.trim().slice(0, 180) : '', - message: typeof item.message === 'string' ? item.message.trim().slice(0, 6000) : '', - })) - .filter(item => item.label && item.subject && item.message) - - return out.length > 0 ? out : [...DEFAULT_REPLY_TEMPLATES] -} - -function sanitizeReplyHistory(value) { - if (!Array.isArray(value)) return [] - return value - .filter(item => item && typeof item === 'object') - .slice(0, 500) - .map(item => ({ - id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), - submissionId: typeof item.submissionId === 'string' ? item.submissionId : '', - toEmail: typeof item.toEmail === 'string' ? item.toEmail.trim().slice(0, 320) : '', - toName: typeof item.toName === 'string' ? item.toName.trim().slice(0, 200) : '', - fromEmail: typeof item.fromEmail === 'string' ? item.fromEmail.trim().slice(0, 320) : 'hello@versebyversewithnate.us', - subject: typeof item.subject === 'string' ? item.subject.trim().slice(0, 180) : '', - preview: typeof item.preview === 'string' ? item.preview.trim().slice(0, 500) : '', - sentAt: typeof item.sentAt === 'string' ? item.sentAt : new Date().toISOString(), - })) -} - -function normalizeIp(rawIp) { - if (!rawIp) return 'unknown' - - let ip = String(rawIp).trim() - - if (ip.includes(',')) { - ip = ip.split(',')[0].trim() - } - - if (ip.startsWith('::ffff:')) { - ip = ip.slice(7) - } - - if (ip === '::1') { - ip = '127.0.0.1' - } - - return ip || 'unknown' -} - -function isPrivateOrLocalIp(ip) { - return ( - ip === '127.0.0.1' - || ip === 'localhost' - || ip.startsWith('10.') - || ip.startsWith('192.168.') - || /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip) - || ip.startsWith('fc') - || ip.startsWith('fd') - || ip.startsWith('fe80:') - || ip === 'unknown' - ) -} - -function queueVisitorStatsWrite() { - visitorStatsWritePromise = visitorStatsWritePromise - .then(async () => { - await mkdir(DATA_DIR, { recursive: true }) - await writeFile( - VISITOR_STATS_FILE, - JSON.stringify({ - ...visitorStats, - updatedAt: new Date().toISOString(), - }, null, 2), - 'utf8', - ) - lastVisitorStatsWrite = { ok: true, at: new Date().toISOString(), error: null } - }) - .catch(err => { - console.error('[visitor-stats] failed to write visitor stats:', err) - lastVisitorStatsWrite = { ok: false, at: new Date().toISOString(), error: String(err) } - }) -} - -function queueContactSubmissionsWrite() { - contactSubmissionsWritePromise = contactSubmissionsWritePromise - .then(async () => { - await mkdir(DATA_DIR, { recursive: true }) - await writeFile( - CONTACT_SUBMISSIONS_FILE, - JSON.stringify({ - submissions: contactSubmissions, - updatedAt: new Date().toISOString(), - }, null, 2), - 'utf8', - ) - }) - .catch(err => { - console.error('[contact] failed to write submissions:', err) - }) -} - -function queueReplyTemplatesWrite() { - replyTemplatesWritePromise = replyTemplatesWritePromise - .then(async () => { - await mkdir(DATA_DIR, { recursive: true }) - await writeFile( - REPLY_TEMPLATES_FILE, - JSON.stringify({ templates: replyTemplates, updatedAt: new Date().toISOString() }, null, 2), - 'utf8', - ) - }) - .catch(err => { - console.error('[reply-templates] failed to write templates:', err) - }) -} - -function queueReplyHistoryWrite() { - replyHistoryWritePromise = replyHistoryWritePromise - .then(async () => { - await mkdir(DATA_DIR, { recursive: true }) - await writeFile( - REPLY_HISTORY_FILE, - JSON.stringify({ items: replyHistory, updatedAt: new Date().toISOString() }, null, 2), - 'utf8', - ) - }) - .catch(err => { - console.error('[reply-history] failed to write history:', err) - }) -} - -function loadContactSubmissionsFromDisk() { - return readFile(CONTACT_SUBMISSIONS_FILE, 'utf8') - .then(raw => { - const parsed = JSON.parse(raw) - contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.submissions) - }) - .catch(() => { - contactSubmissions = [] - }) -} - -function loadReplyTemplatesFromDisk() { - return readFile(REPLY_TEMPLATES_FILE, 'utf8') - .then(raw => { - const parsed = JSON.parse(raw) - replyTemplates = sanitizeReplyTemplates(parsed?.templates) - }) - .catch(() => { - replyTemplates = [...DEFAULT_REPLY_TEMPLATES] - }) -} - -function loadReplyHistoryFromDisk() { - return readFile(REPLY_HISTORY_FILE, 'utf8') - .then(raw => { - const parsed = JSON.parse(raw) - replyHistory = sanitizeReplyHistory(parsed?.items) - }) - .catch(() => { - replyHistory = [] - }) -} - -function loadPodcastChecklistFromDisk() { - return readFile(PODCAST_CHECKLIST_FILE, 'utf8') - .then(raw => { - const parsed = JSON.parse(raw) - podcastChecklist = sanitizePodcastChecklist(parsed?.checklist) - }) - .catch(() => { - podcastChecklist = buildDefaultPodcastChecklist() - }) -} - -function queuePodcastChecklistWrite() { - const updatedAt = new Date().toISOString() - podcastChecklistWritePromise = podcastChecklistWritePromise.then(async () => { - await mkdir(DATA_DIR, { recursive: true }) - await writeFile( - PODCAST_CHECKLIST_FILE, - JSON.stringify({ checklist: podcastChecklist, updatedAt }, null, 2), - 'utf8', - ) - }) - - return podcastChecklistWritePromise -} - -function normalizeMessageType(value) { - if (value === 'question' || value === 'testimony' || value === 'topic') return value - return 'general' -} - -function createEmailDeliveryState(status = 'pending') { - return { - status, - lastEventAt: null, - lastEventType: null, - resendEmailId: null, - error: null, - } -} - -function normalizeEmailDeliveryState(value, fallbackStatus = 'pending') { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return createEmailDeliveryState(fallbackStatus) - } - - return { - status: typeof value.status === 'string' && value.status.trim() ? value.status.trim().slice(0, 40) : fallbackStatus, - lastEventAt: typeof value.lastEventAt === 'string' ? value.lastEventAt : null, - lastEventType: typeof value.lastEventType === 'string' ? value.lastEventType.trim().slice(0, 120) : null, - resendEmailId: typeof value.resendEmailId === 'string' && value.resendEmailId.trim() ? value.resendEmailId.trim().slice(0, 200) : null, - error: typeof value.error === 'string' && value.error.trim() ? value.error.trim().slice(0, 600) : null, - } -} - -function normalizeContactEmailStatus(value, subscribe) { - const base = value && typeof value === 'object' && !Array.isArray(value) ? value : {} - return { - welcome: normalizeEmailDeliveryState(base.welcome, subscribe === true ? 'pending' : 'not-requested'), - adminNotification: normalizeEmailDeliveryState(base.adminNotification, 'pending'), - adminReply: normalizeEmailDeliveryState(base.adminReply, 'idle'), - } -} - -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 - - contactSubmissions = contactSubmissions.map(submission => { - if (submission.id !== submissionId) return submission - const next = normalizeContactEmailStatus(submission.emailStatus, submission.subscribe === true) - const current = normalizeEmailDeliveryState(next[stream], 'pending') - next[stream] = { - ...current, - ...patch, - lastEventAt: at, - } - updated = true - return { - ...submission, - emailStatus: next, - } - }) - - if (updated) { - queueContactSubmissionsWrite() - } -} - -function extractResendMessageId(result) { - if (!result || typeof result !== 'object') return '' - if (typeof result.id === 'string' && result.id.trim()) return result.id.trim() - if (result.data && typeof result.data === 'object' && typeof result.data.id === 'string' && result.data.id.trim()) { - return result.data.id.trim() - } - return '' -} - -function registerResendMessageForSubmission(submissionId, stream, sendResult) { - const resendMessageId = extractResendMessageId(sendResult) - if (!resendMessageId || !submissionId || !stream) return - resendEmailSubmissionIndex.set(resendMessageId, { submissionId, stream }) - upsertContactEmailStatus(submissionId, stream, { - resendEmailId: resendMessageId, - }) -} - -function noteContactEmailCooldown(emailAddress) { - const normalized = String(emailAddress || '').trim().toLowerCase() - if (!normalized) return { ok: true, retryAfterMs: 0 } - - const now = Date.now() - const lastAt = contactSubmitCooldownByEmail.get(normalized) - if (typeof lastAt === 'number' && now - lastAt < CONTACT_EMAIL_COOLDOWN_MS) { - return { ok: false, retryAfterMs: CONTACT_EMAIL_COOLDOWN_MS - (now - lastAt) } - } - - contactSubmitCooldownByEmail.set(normalized, now) - - // Prevent unbounded growth while keeping this in-memory cache simple. - if (contactSubmitCooldownByEmail.size > 8000) { - const cutoff = now - CONTACT_EMAIL_COOLDOWN_MS * 3 - for (const [email, timestamp] of contactSubmitCooldownByEmail.entries()) { - if (timestamp < cutoff) { - contactSubmitCooldownByEmail.delete(email) - } - } - } - - return { ok: true, retryAfterMs: 0 } -} - -function extractTagValue(tags, name) { - if (!Array.isArray(tags)) return '' - const target = String(name || '').trim().toLowerCase() - if (!target) return '' - for (const tag of tags) { - if (!tag || typeof tag !== 'object') continue - const key = typeof tag.name === 'string' ? tag.name.trim().toLowerCase() : '' - const value = typeof tag.value === 'string' ? tag.value.trim() : '' - if (key === target && value) return value - } - return '' -} - -function mapResendEventToStatus(eventType) { - const normalized = String(eventType || '').trim().toLowerCase() - if (!normalized) return 'updated' - if (normalized.includes('delivered')) return 'delivered' - if (normalized.includes('delivery_delayed') || normalized.includes('delivery delayed')) return 'delayed' - if (normalized.includes('bounce')) return 'bounced' - if (normalized.includes('complain')) return 'complained' - if (normalized.includes('click')) return 'clicked' - if (normalized.includes('open')) return 'opened' - if (normalized.includes('send')) return 'sent' - return 'updated' -} - -function getAddressDomain(addressValue) { - const raw = String(addressValue || '').trim() - if (!raw) return '' - const candidate = raw.includes('<') && raw.includes('>') - ? raw.slice(raw.lastIndexOf('<') + 1, raw.lastIndexOf('>')).trim() - : raw - const at = candidate.lastIndexOf('@') - if (at <= 0 || at === candidate.length - 1) return '' - return candidate.slice(at + 1).toLowerCase() -} - -function logResendEmailAlignmentWarnings() { - const warnings = [] - const fromAddress = getResendFromAddress() - const replyToAddress = getResendReplyToAddress() - const fromDomain = getAddressDomain(fromAddress) - const replyDomain = getAddressDomain(replyToAddress) - const hasApiKey = Boolean(process.env.RESEND_API_KEY) - - if (!hasApiKey) { - warnings.push('RESEND_API_KEY is missing. Contact and reply emails cannot send.') - } - if (!fromDomain) { - warnings.push('RESEND_FROM is missing or malformed. Use a verified domain sender identity.') - } - if (fromDomain.endsWith('resend.dev')) { - warnings.push('RESEND_FROM uses resend.dev. Move to your own verified domain for best deliverability.') - } - if (fromDomain && replyDomain && fromDomain !== replyDomain) { - warnings.push('RESEND_FROM and RESEND_REPLY_TO use different domains. This can weaken alignment.') - } - if (!process.env.RESEND_WEBHOOK_TOKEN) { - warnings.push('RESEND_WEBHOOK_TOKEN is not set. Delivery webhooks are not authenticated.') - } - if (hasApiKey) { - warnings.push('Verify SPF, DKIM, and DMARC for the sender domain to improve inbox placement.') - } - - if (warnings.length > 0) { - console.warn('[email-health] Resend alignment checks:') - for (const warning of warnings) { - console.warn(`[email-health] - ${warning}`) - } - } -} - -const USE_RESEND_AUTOMATION_WELCOME = process.env.RESEND_AUTOMATION_WELCOME === 'true' -const DEFAULT_RESEND_FROM = 'Verse by Verse with Nate ' -const DEFAULT_RESEND_TO = 'hello@versebyversewithnate.us' -const DEFAULT_RESEND_REPLY_TO = 'hello@versebyversewithnate.us' -const ADMIN_REPLY_FROM = DEFAULT_RESEND_FROM - -function shouldSendWelcomeEmail({ subscribe }) { - return subscribe === true -} - -function buildAdminReplyTemplate({ recipientName, message }) { - const safeRecipientName = escapeHtml(recipientName || 'friend') - const safeMessage = escapeHtml(message).replace(/\n/g, '
') - - return ` -
- - - - -
- - - - - - - - - - -
-
Verse by Verse with Nate
-

A Personal Reply

-
-

Hi ${safeRecipientName},

-
${safeMessage}
-

Grace and peace,
Verse by Verse with Nate

-
-

From: hello@versebyversewithnate.us

-
-
-
- ` -} - -function buildContactWelcomeEmailTemplate({ - greetingName, - welcomeIntro, - welcomeCurrentSeries, - welcomeStartHereTitle, - welcomeStartHereSummary, - welcomeExpect1, - welcomeExpect2, - welcomeExpect3, - welcomeScripture, - welcomeScriptureRef, - welcomeSignoff, - welcomeHeading, - welcomeSpotifyUrl, - welcomeAppleUrl, - welcomeAmazonUrl, - welcomeWebsiteUrl, - welcomeEpisodeUrl, - welcomeImageUrl, - welcomeSpotifyBtnLabel = 'Listen on Spotify', - welcomeAppleBtnLabel = 'Apple Podcasts', - welcomeStartHereLinkLabel = 'Open Start Here page', -}) { - const welcomeSignoffHtml = escapeHtml(welcomeSignoff).replace(/\n/g, '
') - - return { - text: - `Welcome to Verse by Verse with Nate!\n\n` + - `${greetingName ? `Glad you're here, ${greetingName}.` : "Glad you're here."}\n\n` + - `${welcomeIntro}\n\n` + - `${welcomeCurrentSeries}\n\n` + - `Start here: ${welcomeEpisodeUrl}\n` + - `${welcomeStartHereTitle}\n` + - `${welcomeStartHereSummary}\n` + - `Spotify: ${welcomeSpotifyUrl}\n` + - `Apple Podcasts: ${welcomeAppleUrl}\n` + - `Amazon Music: ${welcomeAmazonUrl}\n` + - `Website: ${welcomeWebsiteUrl}\n\n` + - `What to expect:\n` + - `- ${welcomeExpect1}\n` + - `- ${welcomeExpect2}\n` + - `- ${welcomeExpect3}\n\n` + - `${welcomeScripture}\n${welcomeScriptureRef}\n\n` + - `${welcomeSignoff}`, - html: - `
` + - `` + - `
` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `
` + - `Verse by Verse with Nate` + - `

Verse by Verse with Nate

` + - `

Verse by verse. Nugget by nugget.

` + - `
` + - `

Welcome

` + - `

${welcomeHeading}

` + - `
` + - `
` + - `

${escapeHtml(welcomeIntro)}

` + - `

${escapeHtml(welcomeCurrentSeries)}

` + - `

If you’re just joining us, the best place to start is Episode 1. It sets the table for everything that follows.

` + - `
` + - `

Start here

` + - `

${escapeHtml(welcomeStartHereTitle)}

` + - `

${escapeHtml(welcomeStartHereSummary)}

` + - `` + - `` + - `` + - `
${escapeHtml(welcomeSpotifyBtnLabel)}${escapeHtml(welcomeAppleBtnLabel)}
` + - `

${escapeHtml(welcomeStartHereLinkLabel)}

` + - `
` + - `

What to expect

` + - `

${escapeHtml(welcomeExpect1)}

` + - `

${escapeHtml(welcomeExpect2)}

` + - `

${escapeHtml(welcomeExpect3)}

` + - `
` + - `
` + - `

“${escapeHtml(welcomeScripture)}”

` + - `

${escapeHtml(welcomeScriptureRef)}

` + - `
` + - `
` + - `

Find the podcast on

` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `
Spotify·Apple Podcasts·Amazon Music·Website
` + - `

You’re receiving this because you subscribed to Verse by Verse with Nate.

` + - `

${welcomeSignoffHtml}

` + - `
` + - `
` + - `
`, - } -} - -function buildContactAdminNotificationTemplate({ - normalizedMessageType, - trimmedName, - trimmedEmail, - submittedAt, - trimmedMessage, -}) { - return { - subject: `Verse by Verse contact (${normalizedMessageType}): ${trimmedName}`, - text: - `New contact form submission\n\n` + - `Message Type: ${normalizedMessageType}\n` + - `Name: ${trimmedName}\n` + - `Email: ${trimmedEmail}\n` + - `Submitted: ${submittedAt}\n\n` + - `Message:\n${trimmedMessage}`, - html: - `
` + - `
` + - `
` + - `
Verse by Verse with Nate
` + - `

New Contact Form Submission

` + - `
` + - `
` + - `

A new message was sent from the website contact form. Reply directly to this email to respond to ${escapeHtml(trimmedName)}.

` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `
Type${escapeHtml(normalizedMessageType)}
Name${escapeHtml(trimmedName)}
Email${escapeHtml(trimmedEmail)}
Submitted${escapeHtml(submittedAt)}
` + - `
` + - `
Message
` + - `
${escapeHtml(trimmedMessage)}
` + - `
` + - `
` + - `
` + - `
`, - } -} - -function getResendFromAddress() { - return process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM -} - -function getResendReplyToAddress() { - return process.env.RESEND_REPLY_TO ?? DEFAULT_RESEND_REPLY_TO -} - -function getResendInboxAddress() { - return process.env.RESEND_TO ?? DEFAULT_RESEND_TO -} - -async function sendResendEmailWithRetry({ resend, payload, context, maxAttempts = 2 }) { - let lastError = null - - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - try { - const result = await resend.emails.send(payload) - if (!result?.error) return result - lastError = result.error - if (attempt < maxAttempts) { - await new Promise(resolve => setTimeout(resolve, 250 * attempt)) - } - } catch (err) { - lastError = err - if (attempt < maxAttempts) { - await new Promise(resolve => setTimeout(resolve, 250 * attempt)) - } - } - } - - throw lastError ?? new Error(`[${context}] email send failed`) -} - -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), - } - - contactSubmissions.unshift(submission) - contactSubmissions = contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS) - queueContactSubmissionsWrite() - return submission -} - -async function syncContactToResend(name, email) { - if (!process.env.RESEND_API_KEY) return - - const { firstName, lastName } = splitName(name) - const contactResend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY) - - try { - const { error: contactError } = await contactResend.contacts.create({ - email, - firstName, - lastName, - unsubscribed: false, - ...(process.env.RESEND_SEGMENT_ID - ? { segments: [{ id: process.env.RESEND_SEGMENT_ID }] } - : {}), - }) - - if (contactError) { - const { error: updateError } = await contactResend.contacts.update({ - email, - firstName, - lastName, - unsubscribed: false, - }) - - if (updateError) { - console.error('[resend] contact sync error:', updateError) - } - } - } catch (err) { - console.error('[resend] contact sync exception:', err) - } -} - -function createTitusDownloadToken(email) { - const token = randomUUID() - titusDownloadTokens.set(token, { - email, - expiresAt: Date.now() + DOWNLOAD_TOKEN_TTL_MS, - }) - return token -} - -function consumeTitusDownloadToken(token) { - const entry = titusDownloadTokens.get(token) - if (!entry) return false - titusDownloadTokens.delete(token) - if (entry.expiresAt <= Date.now()) return false - return true -} - -function sanitizeUserAgent(userAgent) { - if (!userAgent || typeof userAgent !== 'string') return 'unknown' - return userAgent.trim().slice(0, 300) || 'unknown' -} - -function detectDevice(userAgent) { - if (!userAgent || typeof userAgent !== 'string') return 'unknown' - const ua = userAgent.toLowerCase() - if (/tablet|ipad|playbook|silk|(android(?!.*mobile))/.test(ua)) return 'tablet' - if (/mobile|iphone|ipod|android|blackberry|opera mini|opera mobi|iemobile|windows phone|palm|smartphone/.test(ua)) return 'mobile' - return 'desktop' -} - -function sanitizeReferrer(referrer) { - if (!referrer || typeof referrer !== 'string') return '' - try { - const parsed = new URL(referrer.trim()) - return `${parsed.hostname}${parsed.pathname}`.slice(0, 200) - } catch { - return '' - } -} - -function detectBot(userAgent, pathInfo = {}) { - if (!userAgent || typeof userAgent !== 'string') { - return { isBot: true, reason: 'missing-user-agent' } - } - - const ua = userAgent.toLowerCase() - - // Search engine crawlers - if (/googlebot|bingbot|yandexbot|baiduspider|slurp|duckduckbot|sluplicate|googlebot-mobile/.test(ua)) { - return { isBot: true, reason: 'search-crawler' } - } - - // Social media crawlers - if (/facebookexternalhit|twitterbot|linkedinbot|pinterest|whatsapp|slack|discord|telegram|reddit|mastodon/.test(ua)) { - return { isBot: true, reason: 'social-crawler' } - } - - // Headless browsers and automation - if (/headless|phantomjs|puppeteer|playwright|selenium|nightmarebot|watir|webdriver|wdio|nightmare/.test(ua)) { - return { isBot: true, reason: 'headless-browser' } - } - - // Monitoring and uptime checkers - if (/uptimerobot|pingdom|statuspage|pagerduty|sentry|datadog|grafana|prometheus|newrelic|appdynamics/.test(ua)) { - return { isBot: true, reason: 'monitoring-tool' } - } - - // Security scanners and tools - if (/nmap|nikto|masscan|metasploit|nessus|openvas|qualys|burpsuite|zap|acunetix|sqlmap/.test(ua)) { - return { isBot: true, reason: 'security-scanner' } - } - - // HTTP clients and frameworks - if (/^(curl|wget|python|java|go|node|ruby|php|perl|lua|rust)[\/-]/.test(ua)) { - return { isBot: true, reason: 'http-client' } - } - - // Common crawler keywords - if (/bot|crawler|spider|scraper|indexer|reader|fetcher|loader|agent|spyware|tracking|monitor/.test(ua)) { - // But allow some common real user agents that might contain these words - if (!/chrome|firefox|safari|opera|edge|msie|trident|like gecko/.test(ua)) { - return { isBot: true, reason: 'bot-keyword' } - } - } - - return { isBot: false, reason: null } -} - -async function resolveGeo(ip) { - if (!ip || isPrivateOrLocalIp(ip)) { - return { - country: 'Local/Unknown', - state: 'Local/Unknown', - county: 'Local/Unknown', - city: 'Local/Unknown', - } - } - - const cached = 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) { - visitorStats.geoCacheByIp[ip] = geo - queueVisitorStatsWrite() - return geo - } - } catch { - // Try next provider. - } - } - - const fallback = { - country: 'Unknown', - state: 'Unknown', - county: 'Unknown', - city: 'Unknown', - } - visitorStats.geoCacheByIp[ip] = fallback - queueVisitorStatsWrite() - return fallback -} - -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) - - // Resolve canonical visitorId by IP hash — if this IP was seen before under a - // different cookie (e.g. cleared cookies), reuse the existing record so the - // same person is never counted as a second unique visitor. - const existingIdByIp = visitorStats.ipHashIndex[ipHash] - if (existingIdByIp && existingIdByIp !== visitorId) { - // Reuse the existing record for this IP; overwrite cookie with canonical ID - visitorId = existingIdByIp - res.append('Set-Cookie', `${VISITOR_COOKIE}=${encodeURIComponent(visitorId)}; Max-Age=31536000; Path=/; SameSite=Lax`) - } - - const existingVisitor = visitorStats.visitors[visitorId] - const isReturning = Boolean(existingVisitor) - - if (!existingVisitor) { - visitorStats.uniqueVisitors += 1 - visitorStats.ipHashIndex[ipHash] = visitorId - } else { - visitorStats.returningVisits += 1 - } - - const nextVisitCount = (existingVisitor?.visitCount ?? 0) + 1 - const userAgents = Array.from(new Set([...(existingVisitor?.userAgents ?? []), ua])).slice(0, 5) - - // Append to page history, keeping last 100 entries per visitor - const prevHistory = existingVisitor?.pageHistory ?? [] - const pageHistory = [...prevHistory, { at: nowIso, path: pathKey, referrer }].slice(-100) - - visitorStats.visitors[visitorId] = { - visitorId, - ip, - ipHash, - firstSeenAt: existingVisitor?.firstSeenAt ?? nowIso, - lastSeenAt: nowIso, - visitCount: nextVisitCount, - lastPath: pathKey, - returningVisitor: isReturning, - location: geo, - userAgents, - device, - pageHistory, - } - - visitorStats.totalVisits += 1 - visitorStats.firstVisitAt = visitorStats.firstVisitAt ?? nowIso - visitorStats.lastVisitAt = nowIso - 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, - }) - visitorStats.recentVisits = visitorStats.recentVisits.slice(0, MAX_RECENT_VISITS) - - queueVisitorStatsWrite() -} - -function loadVisitorStatsFromDisk() { - return readFile(VISITOR_STATS_FILE, 'utf8') - .then(raw => { - const parsed = JSON.parse(raw) - const loadedVisitors = parsed?.visitors && typeof parsed.visitors === 'object' ? parsed.visitors : {} - - // Rebuild ipHashIndex from saved visitors if not persisted (handles upgrades from old data) - let ipHashIndex = parsed?.ipHashIndex && typeof parsed.ipHashIndex === 'object' ? parsed.ipHashIndex : {} - if (Object.keys(ipHashIndex).length === 0 && Object.keys(loadedVisitors).length > 0) { - for (const [vid, visitor] of Object.entries(loadedVisitors)) { - if (visitor?.ipHash && typeof visitor.ipHash === 'string') { - ipHashIndex[visitor.ipHash] = vid - } - } - } - - visitorStats = { - totalVisits: Number(parsed?.totalVisits) || 0, - uniqueVisitors: Number(parsed?.uniqueVisitors) || 0, - returningVisits: Number(parsed?.returningVisits) || 0, - firstVisitAt: typeof parsed?.firstVisitAt === 'string' ? parsed.firstVisitAt : null, - lastVisitAt: typeof parsed?.lastVisitAt === 'string' ? parsed.lastVisitAt : null, - visitors: loadedVisitors, - ipHashIndex, - recentVisits: Array.isArray(parsed?.recentVisits) ? parsed.recentVisits.slice(0, MAX_RECENT_VISITS) : [], - geoCacheByIp: parsed?.geoCacheByIp && typeof parsed.geoCacheByIp === 'object' ? parsed.geoCacheByIp : {}, - } - }) - .catch(() => { - visitorStats = { ...EMPTY_VISITOR_STATS } - }) -} - -function buildTopLocations(list, key) { - const counts = {} - for (const row of list) { - const val = row?.[key] || 'Unknown' - counts[val] = (counts[val] ?? 0) + 1 - } - return Object.entries(counts) - .sort((a, b) => b[1] - a[1]) - .slice(0, 10) - .map(([name, hits]) => ({ name, hits })) -} - -function pruneStatsByDays(daysRaw) { - const days = Number(daysRaw) - const retentionDays = Number.isFinite(days) && days > 0 ? Math.floor(days) : VISITOR_RETENTION_DAYS_DEFAULT - const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000 - - const keepRecent = visitorStats.recentVisits.filter(v => { - const ts = new Date(v.at).getTime() - return Number.isFinite(ts) && ts >= cutoff - }) - - const allowedVisitorIds = new Set(keepRecent.map(v => v.visitorId)) - const nextVisitors = {} - for (const [id, data] of Object.entries(visitorStats.visitors)) { - const lastSeen = new Date(data.lastSeenAt ?? 0).getTime() - if (allowedVisitorIds.has(id) || (Number.isFinite(lastSeen) && lastSeen >= cutoff)) { - nextVisitors[id] = data - } - } - - const nextByDay = {} - const nextByDayReal = {} - const nextByDayBot = {} - for (const [day, count] of Object.entries(hitStats.byDay)) { - const ts = new Date(`${day}T00:00:00.000Z`).getTime() - if (Number.isFinite(ts) && ts >= cutoff) { - nextByDay[day] = count - nextByDayReal[day] = hitStats.byDayReal?.[day] ?? 0 - nextByDayBot[day] = hitStats.byDayBot?.[day] ?? 0 - } - } - - visitorStats.recentVisits = keepRecent - visitorStats.visitors = nextVisitors - visitorStats.uniqueVisitors = Object.keys(nextVisitors).length - visitorStats.totalVisits = keepRecent.length - visitorStats.returningVisits = keepRecent.filter(v => v.returningVisitor).length - visitorStats.firstVisitAt = keepRecent.length > 0 ? keepRecent[keepRecent.length - 1].at : null - visitorStats.lastVisitAt = keepRecent.length > 0 ? keepRecent[0].at : null - - hitStats.byDay = nextByDay - hitStats.byDayReal = nextByDayReal - hitStats.byDayBot = nextByDayBot - - queueHitStatsWrite() - queueVisitorStatsWrite() - - return { - retentionDays, - remainingVisits: visitorStats.totalVisits, - remainingVisitors: visitorStats.uniqueVisitors, - } -} - -async function createBackupSnapshot(reason = 'scheduled') { - try { - await mkdir(BACKUP_DIR, { recursive: true }) - const stamp = new Date().toISOString().replace(/[:.]/g, '-') - const backupPath = path.join(BACKUP_DIR, `snapshot-${stamp}-${reason}.json`) - - const payload = { - createdAt: new Date().toISOString(), - reason, - adminContent: null, - draftContent: null, - podcastChecklist, - publishState, - hitStats, - visitorStats, - contactSubmissions, - studyCommunityPosts, - replyTemplates, - replyHistory, - } - - try { - const contentRaw = await readFile(DATA_FILE, 'utf8') - payload.adminContent = JSON.parse(contentRaw) - } catch { - payload.adminContent = null - } - - try { - const draftRaw = await readFile(DRAFT_DATA_FILE, 'utf8') - payload.draftContent = JSON.parse(draftRaw) - } catch { - payload.draftContent = null - } - - await writeFile(backupPath, JSON.stringify(payload, null, 2), 'utf8') - - const files = (await readdir(BACKUP_DIR)).filter(name => name.endsWith('.json')).sort() - const maxFiles = BACKUP_RETENTION_DAYS - if (files.length > maxFiles) { - const toDelete = files.slice(0, files.length - maxFiles) - await Promise.all(toDelete.map(name => unlink(path.join(BACKUP_DIR, name)).catch(() => {}))) - } - - lastBackupStatus = { ok: true, at: new Date().toISOString(), error: null, file: path.basename(backupPath) } - } catch (err) { - lastBackupStatus = { ok: false, at: new Date().toISOString(), error: String(err), file: null } - console.error('[backup] failed to create snapshot:', err) - } -} - -async function listBackupFiles() { - await mkdir(BACKUP_DIR, { recursive: true }) - const files = (await readdir(BACKUP_DIR)).filter(name => name.endsWith('.json')).sort().reverse() - return files -} - -async function readBackupPreview(filename) { - if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..') || !filename.endsWith('.json')) { - throw new Error('Invalid backup filename') - } - - const fullPath = path.join(BACKUP_DIR, filename) - const [fileInfo, raw] = await Promise.all([ - stat(fullPath), - readFile(fullPath, 'utf8'), - ]) - const parsed = JSON.parse(raw) - - return { - filename, - sizeBytes: fileInfo.size, - createdAt: typeof parsed?.createdAt === 'string' ? parsed.createdAt : null, - reason: typeof parsed?.reason === 'string' ? parsed.reason : 'unknown', - adminUpdatedAt: typeof parsed?.adminContent?.updatedAt === 'string' ? parsed.adminContent.updatedAt : null, - totalHits: Number(parsed?.hitStats?.totalHits) || 0, - totalVisits: Number(parsed?.visitorStats?.totalVisits) || 0, - } -} - -async function listBackupPreviews() { - const files = await listBackupFiles() - const previews = await Promise.all(files.map(async filename => { - try { - return await readBackupPreview(filename) - } catch { - return { - filename, - sizeBytes: 0, - createdAt: null, - reason: 'unknown', - adminUpdatedAt: null, - totalHits: 0, - totalVisits: 0, - } - } - })) - return previews -} - -function sanitizeLoadedHitStats(value) { - return { - totalHits: Number(value?.totalHits) || 0, - firstHitAt: typeof value?.firstHitAt === 'string' ? value.firstHitAt : null, - lastHitAt: typeof value?.lastHitAt === 'string' ? value.lastHitAt : null, - byPath: value?.byPath && typeof value.byPath === 'object' ? value.byPath : {}, - byDay: value?.byDay && typeof value.byDay === 'object' ? value.byDay : {}, - } -} - -function sanitizeLoadedVisitorStats(value) { - return { - totalVisits: Number(value?.totalVisits) || 0, - uniqueVisitors: Number(value?.uniqueVisitors) || 0, - returningVisits: Number(value?.returningVisits) || 0, - firstVisitAt: typeof value?.firstVisitAt === 'string' ? value.firstVisitAt : null, - lastVisitAt: typeof value?.lastVisitAt === 'string' ? value.lastVisitAt : null, - visitors: value?.visitors && typeof value.visitors === 'object' ? value.visitors : {}, - recentVisits: Array.isArray(value?.recentVisits) ? value.recentVisits.slice(0, MAX_RECENT_VISITS) : [], - geoCacheByIp: value?.geoCacheByIp && typeof value.geoCacheByIp === 'object' ? value.geoCacheByIp : {}, - } -} - -function sanitizeLoadedContactSubmissions(value) { - if (!Array.isArray(value)) return [] - return value - .slice(0, MAX_CONTACT_SUBMISSIONS) - .filter(entry => entry && typeof entry === 'object') - .map(entry => ({ - id: typeof entry.id === 'string' && entry.id.trim() ? entry.id.trim() : randomUUID(), - submittedAt: typeof entry.submittedAt === 'string' ? entry.submittedAt : new Date().toISOString(), - name: typeof entry.name === 'string' ? entry.name.trim().slice(0, 200) : '', - email: typeof entry.email === 'string' ? entry.email.trim().slice(0, 320) : '', - message: typeof entry.message === 'string' ? entry.message.trim().slice(0, 3000) : '', - messageType: normalizeMessageType(entry.messageType), - subscribe: entry.subscribe === true, - archived: entry.archived === true, - emailStatus: normalizeContactEmailStatus(entry.emailStatus, entry.subscribe === true), - })) -} - -async function restoreFromBackup(filename) { - if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..') || !filename.endsWith('.json')) { - throw new Error('Invalid backup filename') - } - - const fullPath = path.join(BACKUP_DIR, filename) - const raw = await readFile(fullPath, 'utf8') - const parsed = JSON.parse(raw) - - await createBackupSnapshot('pre-restore') - - if (parsed?.adminContent && typeof parsed.adminContent === 'object') { - await mkdir(DATA_DIR, { recursive: true }) - await writeFile(DATA_FILE, JSON.stringify(parsed.adminContent, null, 2), 'utf8') - } - - if (parsed?.draftContent && typeof parsed.draftContent === 'object') { - await mkdir(DATA_DIR, { recursive: true }) - await writeFile(DRAFT_DATA_FILE, JSON.stringify(parsed.draftContent, null, 2), 'utf8') - } - - if (parsed?.publishState && typeof parsed.publishState === 'object') { - publishState = { - draftUpdatedAt: typeof parsed.publishState.draftUpdatedAt === 'string' ? parsed.publishState.draftUpdatedAt : null, - publishedAt: typeof parsed.publishState.publishedAt === 'string' ? parsed.publishState.publishedAt : null, - } - } - - hitStats = sanitizeLoadedHitStats(parsed?.hitStats) - visitorStats = sanitizeLoadedVisitorStats(parsed?.visitorStats) - contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions) - replyTemplates = sanitizeReplyTemplates(parsed?.replyTemplates) - replyHistory = sanitizeReplyHistory(parsed?.replyHistory) - podcastChecklist = sanitizePodcastChecklist(parsed?.podcastChecklist) - - queueHitStatsWrite() - queueVisitorStatsWrite() - queueContactSubmissionsWrite() - queueReplyTemplatesWrite() - queueReplyHistoryWrite() - queuePodcastChecklistWrite() - - await Promise.all([ - hitStatsWritePromise, - visitorStatsWritePromise, - contactSubmissionsWritePromise, - replyTemplatesWritePromise, - replyHistoryWritePromise, - podcastChecklistWritePromise, - ]) - await refreshContentCaches() - await createBackupSnapshot('post-restore') -} - -function normalizeHitPath(pathname) { - if (!pathname || pathname === '') return '/' - if (pathname.length > 1 && pathname.endsWith('/')) { - return pathname.slice(0, -1) - } - return pathname -} - -function shouldCountHit(req) { - if (req.method !== 'GET') return false - if (req.path.startsWith('/api/')) return false - if (req.path === '/admin' || req.path.startsWith('/admin/')) return false - if (req.path === '/favicon.ico') return false - - // Ignore direct asset requests and only count document-like requests. - const hasFileExt = path.extname(req.path) !== '' - if (hasFileExt) return false - - const accept = req.get('accept') ?? '' - return accept.includes('text/html') || accept === '*/*' || accept === '' -} - -function queueHitStatsWrite() { - hitStatsWritePromise = hitStatsWritePromise - .then(async () => { - await mkdir(DATA_DIR, { recursive: true }) - await writeFile( - HIT_STATS_FILE, - JSON.stringify({ - ...hitStats, - updatedAt: new Date().toISOString(), - }, null, 2), - 'utf8', - ) - lastHitStatsWrite = { ok: true, at: new Date().toISOString(), error: null } - }) - .catch(err => { - console.error('[stats] failed to write hit stats:', err) - lastHitStatsWrite = { ok: false, at: new Date().toISOString(), error: String(err) } - }) -} - -function recordHit(pathname, isBot = false, botReason = null) { - const nowIso = new Date().toISOString() - const dayKey = nowIso.slice(0, 10) - const safePath = normalizeHitPath(pathname) - - hitStats.totalHits += 1 - hitStats.lastHitAt = nowIso - hitStats.firstHitAt = hitStats.firstHitAt ?? nowIso - - if (isBot) { - hitStats.botHits += 1 - hitStats.byPathBot[safePath] = (hitStats.byPathBot[safePath] ?? 0) + 1 - hitStats.byDayBot[dayKey] = (hitStats.byDayBot[dayKey] ?? 0) + 1 - if (botReason) { - hitStats.botReasons[botReason] = (hitStats.botReasons[botReason] ?? 0) + 1 - } - } else { - hitStats.realHits += 1 - hitStats.byPathReal[safePath] = (hitStats.byPathReal[safePath] ?? 0) + 1 - hitStats.byDayReal[dayKey] = (hitStats.byDayReal[dayKey] ?? 0) + 1 - } - - // Keep legacy byPath and byDay for backward compatibility - hitStats.byPath[safePath] = (hitStats.byPath[safePath] ?? 0) + 1 - hitStats.byDay[dayKey] = (hitStats.byDay[dayKey] ?? 0) + 1 - - queueHitStatsWrite() -} - -function buildLastNDaysStats(days) { - const out = [] - const today = new Date() - - for (let i = days - 1; i >= 0; i -= 1) { - const d = new Date(today) - d.setDate(today.getDate() - i) - const dayKey = d.toISOString().slice(0, 10) - out.push({ day: dayKey, hits: hitStats.byDay[dayKey] ?? 0 }) - } - - return out -} - -function loadHitStatsFromDisk() { - return readFile(HIT_STATS_FILE, 'utf8') - .then(raw => { - const parsed = JSON.parse(raw) - hitStats = { - totalHits: Number(parsed?.totalHits) || 0, - realHits: Number(parsed?.realHits) || 0, - botHits: Number(parsed?.botHits) || 0, - firstHitAt: typeof parsed?.firstHitAt === 'string' ? parsed.firstHitAt : null, - lastHitAt: typeof parsed?.lastHitAt === 'string' ? parsed.lastHitAt : null, - byPath: parsed?.byPath && typeof parsed.byPath === 'object' ? parsed.byPath : {}, - byPathReal: parsed?.byPathReal && typeof parsed.byPathReal === 'object' ? parsed.byPathReal : {}, - byPathBot: parsed?.byPathBot && typeof parsed.byPathBot === 'object' ? parsed.byPathBot : {}, - byDay: parsed?.byDay && typeof parsed.byDay === 'object' ? parsed.byDay : {}, - byDayReal: parsed?.byDayReal && typeof parsed.byDayReal === 'object' ? parsed.byDayReal : {}, - byDayBot: parsed?.byDayBot && typeof parsed.byDayBot === 'object' ? parsed.byDayBot : {}, - botReasons: parsed?.botReasons && typeof parsed.botReasons === 'object' ? parsed.botReasons : {}, - } - }) - .catch(() => { - hitStats = { ...EMPTY_HIT_STATS } - }) -} + detectBot, + sanitizeUserAgent, + shouldCountHit, + recordHit, + scheduleStudyReminders, +} from './server/study-helpers.js' +import { recordVisitor } from './server/routes/analytics.js' + +// Route registrars +import { register as registerAdminAuth } from './server/routes/admin-auth.js' +import { register as registerAdminContent } from './server/routes/admin-content.js' +import { register as registerAdminAssets } from './server/routes/admin-assets.js' +import { register as registerStudyAuth } from './server/routes/study-auth.js' +import { register as registerStudyData } from './server/routes/study-data.js' +import { register as registerStudyAccount } from './server/routes/study-account.js' +import { register as registerContact } from './server/routes/contact.js' +import { register as registerQuestions } from './server/routes/questions.js' +import { register as registerAnalytics } from './server/routes/analytics.js' +import { register as registerDownloads } from './server/routes/downloads.js' +import { register as registerEpisodes } from './server/routes/episodes.js' +import { register as registerPublic } from './server/routes/public.js' const app = express() app.use(express.json({ limit: '10mb' })) const trustProxyHops = Number(process.env.TRUST_PROXY_HOPS ?? 1) app.set('trust proxy', Number.isFinite(trustProxyHops) && trustProxyHops >= 0 ? trustProxyHops : 1) -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, - hasDraft: Boolean(cachedDraftSiteContent), - hasPublished: Boolean(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: podcastChecklist }) -}) - -app.put('/api/admin-podcast-checklist', requireAdminAuth, async (req, res) => { - try { - const safeChecklist = sanitizePodcastChecklist(req.body?.checklist) - 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, - updatedAt: parsed.updatedAt ?? null, - }) - } catch { - res.json({ - seo: DEFAULT_SEO, - legal: DEFAULT_LEGAL, - redirects: DEFAULT_REDIRECT_RULES, - podcastFeaturedLinks: DEFAULT_PODCAST_FEATURED_LINKS, - 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', - ) - - cachedDraftSiteContent = safeSiteContent - 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 = cachedDraftSiteContent - ? { siteContent: cachedDraftSiteContent, updatedAt: 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', - ) - - cachedSiteContent = source.siteContent - publishState.publishedAt = publishedAt - - if (draftQuestions !== null) { - questions = draftQuestions.slice(0, MAX_QUESTIONS) - await mkdir(DATA_DIR, { recursive: true }) - await writeFile( - QUESTIONS_FILE, - JSON.stringify({ 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.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.' }) - } -}) - -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: lastCachePurgeStatus, - deployHook: lastDeployHookStatus, - }) -}) - -app.post('/api/admin-ops/purge-cache', requireAdminAuth, async (_req, res) => { - const result = await invokeWebhook(process.env.CACHE_PURGE_WEBHOOK_URL ?? '', 'cache-purge') - 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') - 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 }) -}) - -function queueQuestionsWrite() { - questionsWritePromise = questionsWritePromise - .then(async () => { - await mkdir(DATA_DIR, { recursive: true }) - await writeFile( - QUESTIONS_FILE, - JSON.stringify({ - questions, - updatedAt: new Date().toISOString(), - }, null, 2), - 'utf8', - ) - }) - .catch(err => { - console.error('[questions] failed to write questions:', err) - }) -} - -function queueDraftQuestionsWrite() { - if (draftQuestions === null) return - draftQuestionsWritePromise = draftQuestionsWritePromise - .then(async () => { - await mkdir(DATA_DIR, { recursive: true }) - await writeFile( - DRAFT_QUESTIONS_FILE, - JSON.stringify({ questions: draftQuestions, updatedAt: new Date().toISOString() }, null, 2), - 'utf8', - ) - publishState.draftUpdatedAt = new Date().toISOString() - }) - .catch(err => { - console.error('[draft-questions] failed to write draft questions:', err) - }) -} - -function loadQuestionsFromDisk() { - return readFile(QUESTIONS_FILE, 'utf8') - .then(raw => { - const parsed = JSON.parse(raw) - if (Array.isArray(parsed)) { - questions = parsed.slice(0, MAX_QUESTIONS) - } else if (Array.isArray(parsed?.questions)) { - questions = parsed.questions.slice(0, MAX_QUESTIONS) - } else { - questions = [] - } - }) - .catch(() => { - questions = [] - }) -} - -function cookieFlags() { - return process.env.NODE_ENV === 'production' ? '; Secure' : '' -} - -function normalizeStudyUsername(value) { - if (typeof value !== 'string') return '' - return value.trim().toLowerCase() -} - -function getStudyAvatarUrl(subject) { - let customAvatar = '' - let username = '' - - if (subject && typeof subject === 'object') { - customAvatar = typeof subject.avatarUrl === 'string' ? subject.avatarUrl.trim() : '' - username = normalizeStudyUsername(subject.username) - } else if (typeof subject === 'string') { - username = normalizeStudyUsername(subject) - } - - if (customAvatar) return customAvatar - if (!username) return '' - const hash = createHash('md5').update(username).digest('hex') - return `https://www.gravatar.com/avatar/${hash}?d=identicon&s=96` -} - -function findStudyUserById(userId) { - if (typeof userId !== 'string' || !userId.trim()) return undefined - return studyUsers.find(user => user.id === userId) -} - -function normalizeStudySlug(value) { - if (typeof value !== 'string') return '' - const trimmed = value.trim().toLowerCase() - return /^[a-z0-9-]{1,80}$/.test(trimmed) ? trimmed : '' -} - -function isValidStudyUsername(value) { - return /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(value) && value.length <= 254 -} - -function getStudyCatalog() { - const fallback = [ - { slug: 'colossians', title: 'Colossians: Rooted in Christ', status: 'active' }, - ] - const content = cachedSiteContent - if (!content || typeof content !== 'object') return fallback - - if (Array.isArray(content.studies) && content.studies.length > 0) { - const out = [] - const seen = new Set() - for (const study of content.studies) { - const slug = normalizeStudySlug(study?.slug) - if (!slug || seen.has(slug)) continue - seen.add(slug) - out.push({ - slug, - title: typeof study?.title === 'string' && study.title.trim() ? study.title.trim() : slug, - status: study?.status === 'planned' ? 'planned' : 'active', - }) - } - if (out.length > 0) return out - } - - return fallback -} - -function isEnrollableStudySlug(studySlug) { - const normalized = normalizeStudySlug(studySlug) - if (!normalized) return false - return getStudyCatalog().some(study => study.slug === normalized && study.status !== 'planned') -} - -function getStudyTitleBySlug(studySlug) { - const normalized = normalizeStudySlug(studySlug) - if (!normalized) return '' - const study = getStudyCatalog().find(item => item.slug === normalized) - return study?.title ?? '' -} - -function isStudyUserEnrolled(user, studySlug) { - const normalized = normalizeStudySlug(studySlug) - if (!normalized || !user) return false - return Array.isArray(user.enrolledStudySlugs) && user.enrolledStudySlugs.includes(normalized) -} - -function hashStudyPassword(password) { - return createHash('sha256').update(`study-user:${String(password)}`).digest('hex') -} - -function hashEmailChangeToken(token) { - return createHash('sha256').update(`study-email-change:${String(token)}`).digest('hex') -} - -function getCanonicalBaseUrl() { - const configured = cachedSiteContent?.seo?.canonicalUrl ?? DEFAULT_SEO.canonicalUrl - if (typeof configured !== 'string' || !configured.trim()) return DEFAULT_SEO.canonicalUrl - return configured.trim() -} - -function buildBrandedEmailHtml({ - title, - eyebrow, - bodyHtml, - ctaLabel, - ctaUrl, - footerHtml, -}) { - const ctaBlock = ctaLabel && ctaUrl - ? `

${escapeHtml(ctaLabel)}

` - : '' - - return ( - `
` + - `` + - `
` + - `` + - `` + - `` + - `` + - `
` + - `

${escapeHtml(eyebrow ?? 'Verse by Verse with Nate')}

` + - `

${escapeHtml(title)}

` + - `
` + - `
${bodyHtml}
` + - `${ctaBlock}` + - `
${footerHtml ?? ''}
` + - `
` + - `
` - ) -} - -async function sendStudyWelcomeEmail(email, displayName) { - if (!process.env.RESEND_API_KEY) return - try { - const resend = new Resend(process.env.RESEND_API_KEY) - const cfg = cachedSiteContent ?? {} - const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend' - const baseUrl = getCanonicalBaseUrl() - const accountUrl = buildAbsoluteUrl(baseUrl, '/study/account') - const subject = cfg.studyWelcomeEmailSubject?.trim() || 'Welcome to the Study Community' - const bodyText = cfg.studyWelcomeEmailBody?.trim() || 'Your student account is ready. Open your studies and continue learning, or manage your account details anytime.' - const ctaLabel = cfg.studyWelcomeEmailCtaLabel?.trim() || 'Open Studies' - const studiesUrl = buildAbsoluteUrl(baseUrl, cfg.studyWelcomeEmailCtaPath?.trim() || '/study') - const signoff = cfg.studyWelcomeEmailSignoff?.trim() || 'Grace and peace,\nVerse by Verse with Nate' - const bodyHtml = ( - `

Welcome, ${escapeHtml(namePart)}.

` + - `

${escapeHtml(bodyText)}

` - ) - const footerHtml = `

${escapeHtml(signoff).replace(/\n/g, '
')}

` - const { error } = await resend.emails.send({ - from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate ', - to: [email], - subject, - text: `Welcome, ${namePart}.\n\n${bodyText}\n\nOpen studies: ${studiesUrl}\nManage account: ${accountUrl}\n\n${signoff}`, - html: buildBrandedEmailHtml({ - title: 'Welcome to the Study Community', - eyebrow: 'Study Account', - bodyHtml, - ctaLabel, - ctaUrl: studiesUrl, - footerHtml: footerHtml + `

Manage your account

`, - }), - }) - if (error) console.error('[study-signup] welcome email send error:', error) - } catch (err) { - console.error('[study-signup] welcome email exception:', err) - } -} - -async function sendEmailOtp(email, code) { - if (!process.env.RESEND_API_KEY) return - try { - const resend = new Resend(process.env.RESEND_API_KEY) - const cfg = cachedSiteContent ?? {} - const subject = cfg.twoFaOtpEmailSubject?.trim() || 'Your sign-in code — Verse by Verse with Nate' - const bodyText = cfg.twoFaOtpEmailBody?.trim() || 'Your two-factor sign-in code is below. Enter it to complete sign-in.' - const expiryText = cfg.twoFaOtpEmailExpiry?.trim() || 'This code expires in 10 minutes. If you did not request this, you can ignore this message.' - await resend.emails.send({ - from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM, - to: [email], - subject, - text: `${bodyText}\n\n${code}\n\n${expiryText}\n\nVerse by Verse with Nate`, - html: buildBrandedEmailHtml({ - title: 'Your Sign-In Code', - eyebrow: 'Account Security', - bodyHtml: - `

${escapeHtml(bodyText)}

` + - `

${code}

` + - `

${escapeHtml(expiryText)}

`, - footerHtml: `

Verse by Verse with Nate

`, - }), - }) - } catch (err) { - console.error('[email-otp] send error:', err) - } -} - -async function sendStudyAccountDeletedEmail(email, displayName) { - if (!process.env.RESEND_API_KEY) return - try { - const resend = new Resend(process.env.RESEND_API_KEY) - const cfg = cachedSiteContent ?? {} - const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend' - const baseUrl = getCanonicalBaseUrl() - const subject = cfg.studyDeletedEmailSubject?.trim() || 'Your study account was deleted' - const bodyText = cfg.studyDeletedEmailBody?.trim() || 'This confirms your study account and saved notes were deleted. If this was not you, please contact us immediately.' - const ctaLabel = cfg.studyDeletedEmailCtaLabel?.trim() || 'Create a New Account' - const signupUrl = buildAbsoluteUrl(baseUrl, cfg.studyDeletedEmailCtaPath?.trim() || '/study/signup') - const signoff = cfg.studyDeletedEmailSignoff?.trim() || 'Verse by Verse with Nate' - const bodyHtml = ( - `

Hi ${escapeHtml(namePart)},

` + - `

${escapeHtml(bodyText)}

` - ) - const footerHtml = `

${escapeHtml(signoff).replace(/\n/g, '
')}

` - const { error } = await resend.emails.send({ - from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate ', - to: [email], - subject, - text: `Hi ${namePart},\n\n${bodyText}\n\nCreate a new account anytime: ${signupUrl}\n\n${signoff}`, - html: buildBrandedEmailHtml({ - title: 'Study Account Deleted', - eyebrow: 'Account Update', - bodyHtml, - ctaLabel, - ctaUrl: signupUrl, - footerHtml, - }), - }) - if (error) console.error('[study-account] delete email send error:', error) - } catch (err) { - console.error('[study-account] delete email exception:', err) - } -} - -function sanitizeStudyUsers(value) { - if (!Array.isArray(value)) return [] - - const out = [] - const seen = new Set() - - for (const item of value) { - const username = normalizeStudyUsername(item?.username) - const passwordHash = typeof item?.passwordHash === 'string' ? item.passwordHash.trim() : '' - if (!isValidStudyUsername(username) || !passwordHash || seen.has(username)) continue - const enrolledStudySlugs = Array.isArray(item?.enrolledStudySlugs) - ? Array.from(new Set(item.enrolledStudySlugs.map(normalizeStudySlug).filter(Boolean))).slice(0, MAX_STUDY_ENROLLMENTS_PER_USER) - : [] - const displayName = typeof item?.displayName === 'string' ? item.displayName.trim().slice(0, 80) : '' - const subscribeNewsletter = item?.subscribeNewsletter !== false - const studyRemindersEnabled = item?.studyRemindersEnabled === true - const pendingEmailChange = item?.pendingEmailChange && typeof item.pendingEmailChange === 'object' && !Array.isArray(item.pendingEmailChange) - ? { - newEmail: isValidStudyUsername(normalizeStudyUsername(item.pendingEmailChange.newEmail)) - ? normalizeStudyUsername(item.pendingEmailChange.newEmail) - : '', - tokenHash: typeof item.pendingEmailChange.tokenHash === 'string' ? item.pendingEmailChange.tokenHash.trim() : '', - expiresAt: typeof item.pendingEmailChange.expiresAt === 'number' ? item.pendingEmailChange.expiresAt : 0, - requestedAt: typeof item.pendingEmailChange.requestedAt === 'string' ? item.pendingEmailChange.requestedAt : null, - } - : null - seen.add(username) - out.push({ - id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), - username, - passwordHash, - displayName, - subscribeNewsletter, - studyRemindersEnabled, - enrolledStudySlugs, - avatarUrl: typeof item?.avatarUrl === 'string' ? item.avatarUrl.trim() : '', - createdAt: typeof item?.createdAt === 'string' ? item.createdAt : null, - updatedAt: typeof item?.updatedAt === 'string' ? item.updatedAt : null, - lastLoginAt: typeof item?.lastLoginAt === 'string' ? item.lastLoginAt : null, - pendingEmailChange, - // 2FA fields - twoFaMethod: item?.twoFaMethod === 'app' || item?.twoFaMethod === 'email' ? item.twoFaMethod : null, - totpSecret: typeof item?.totpSecret === 'string' && item.totpSecret ? item.totpSecret : null, - totpVerified: item?.totpVerified === true, - totpEnabledAt: typeof item?.totpEnabledAt === 'string' ? item.totpEnabledAt : null, - totpRecoveryCodes: Array.isArray(item?.totpRecoveryCodes) ? item.totpRecoveryCodes.filter(h => typeof h === 'string') : [], - totpSecretPending: typeof item?.totpSecretPending === 'string' ? item.totpSecretPending : undefined, - }) - } - - return out.slice(0, MAX_STUDY_USERS) -} - -function sanitizeUserNotes(value) { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {} - const out = {} - let count = 0 - for (const [sectionId, note] of Object.entries(value)) { - if (count >= MAX_STUDY_NOTES_PER_USER) break - if (!/^[a-z0-9-]{1,80}$/i.test(sectionId)) continue - if (typeof note !== 'string') continue - const trimmed = note.trim().slice(0, MAX_STUDY_NOTE_LENGTH) - if (!trimmed) continue - out[sectionId] = trimmed - count += 1 - } - return out -} - -function sanitizeStudyCommunityReply(reply) { - if (!reply || typeof reply !== 'object' || Array.isArray(reply)) return null - const message = typeof reply.message === 'string' ? reply.message.trim().slice(0, 3000) : '' - if (!message) return null - return { - id: typeof reply.id === 'string' && reply.id.trim() ? reply.id.trim() : randomUUID(), - authorUserId: typeof reply.authorUserId === 'string' && reply.authorUserId.trim() ? reply.authorUserId.trim() : '', - authorName: typeof reply.authorName === 'string' ? reply.authorName.trim().slice(0, 120) : '', - message, - createdAt: typeof reply.createdAt === 'string' ? reply.createdAt : new Date().toISOString(), - } -} - -function sanitizeStudyCommunityPosts(value) { - if (!Array.isArray(value)) return [] - - return value - .filter(item => item && typeof item === 'object' && !Array.isArray(item)) - .map(item => { - const studySlug = normalizeStudySlug(item.studySlug) - const sectionId = typeof item.sectionId === 'string' && /^[a-z0-9-]{1,80}$/i.test(item.sectionId) ? item.sectionId.trim() : '' - const message = typeof item.message === 'string' ? item.message.trim().slice(0, 3000) : '' - const replies = Array.isArray(item.replies) - ? item.replies.map(sanitizeStudyCommunityReply).filter(Boolean).slice(0, 50) - : [] - - if (!studySlug || !message) return null - - return { - id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), - studySlug, - sectionId, - authorUserId: typeof item.authorUserId === 'string' && item.authorUserId.trim() ? item.authorUserId.trim() : '', - authorName: typeof item.authorName === 'string' ? item.authorName.trim().slice(0, 120) : '', - message, - createdAt: typeof item.createdAt === 'string' ? item.createdAt : new Date().toISOString(), - replies, - } - }) - .filter(Boolean) -} - -async function loadStudyCommunityFromDisk() { - return readFile(STUDY_COMMUNITY_FILE, 'utf8') - .then(raw => { - const parsed = JSON.parse(raw) - studyCommunityPosts = sanitizeStudyCommunityPosts(parsed?.posts ?? parsed) - }) - .catch(() => { - studyCommunityPosts = [] - }) -} - -function queueStudyCommunityWrite() { - studyCommunityWritePromise = studyCommunityWritePromise - .then(async () => { - await mkdir(DATA_DIR, { recursive: true }) - await writeFile( - STUDY_COMMUNITY_FILE, - JSON.stringify({ posts: studyCommunityPosts, updatedAt: new Date().toISOString() }, null, 2), - 'utf8', - ) - }) - .catch(err => { - console.error('[study-community] failed to write discussion posts:', err) - }) -} - -function getUserNotesFilePath(userId) { - // userId is a UUID — safe as a filename - return path.join(STUDY_NOTES_DIR, `${userId}.json`) -} - -async function loadUserNotes(userId) { - if (studyNotesCache.has(userId)) return studyNotesCache.get(userId) - try { - const raw = await readFile(getUserNotesFilePath(userId), 'utf8') - const notes = sanitizeUserNotes(JSON.parse(raw)) - studyNotesCache.set(userId, notes) - return notes - } catch { - const notes = {} - studyNotesCache.set(userId, notes) - return notes - } -} - -function queueUserNotesWrite(userId) { - const prev = studyNotesWriteQueues.get(userId) ?? Promise.resolve() - const next = prev - .then(async () => { - const notes = studyNotesCache.get(userId) ?? {} - await mkdir(STUDY_NOTES_DIR, { recursive: true }) - await writeFile(getUserNotesFilePath(userId), JSON.stringify(notes, null, 2), 'utf8') - }) - .catch(err => { - console.error(`[study-notes] failed to write notes for user ${userId}:`, err) - }) - studyNotesWriteQueues.set(userId, next) -} - -function getUserProgressFilePath(userId) { - return path.join(STUDY_PROGRESS_DIR, `${userId}.json`) -} - -function sanitizeStudyProgress(value) { - const defaultResult = { byStudy: {}, updatedAt: new Date().toISOString() } - if (!value || typeof value !== 'object') return defaultResult - - const progress = { byStudy: {}, updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : new Date().toISOString() } - if (value.byStudy && typeof value.byStudy === 'object') { - for (const [studySlug, studyData] of Object.entries(value.byStudy)) { - if (typeof studySlug !== 'string' || !studySlug.trim()) continue - const completedSectionIds = Array.isArray(studyData?.completedSectionIds) - ? studyData.completedSectionIds.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim()) - : [] - const quizAnswers = studyData?.quizAnswers && typeof studyData?.quizAnswers === 'object' && !Array.isArray(studyData.quizAnswers) - ? Object.fromEntries( - Object.entries(studyData.quizAnswers) - .filter(([sectionId]) => typeof sectionId === 'string' && sectionId.trim()) - .map(([sectionId, answers]) => [ - sectionId.trim(), - Array.isArray(answers) - ? answers.filter(answer => typeof answer === 'string').map(answer => answer.trim()) - : [], - ]) - ) - : {} - progress.byStudy[studySlug.trim().toLowerCase()] = { - completedSectionIds: Array.from(new Set(completedSectionIds)), - quizAnswers, - } - } - } - - return progress -} - -async function loadUserProgress(userId) { - if (studyProgressCache.has(userId)) return studyProgressCache.get(userId) - try { - const raw = await readFile(getUserProgressFilePath(userId), 'utf8') - const progress = sanitizeStudyProgress(JSON.parse(raw)) - studyProgressCache.set(userId, progress) - return progress - } catch { - const progress = { byStudy: {}, updatedAt: new Date().toISOString() } - studyProgressCache.set(userId, progress) - return progress - } -} - -function queueUserProgressWrite(userId) { - const prev = studyProgressWriteQueues.get(userId) ?? Promise.resolve() - const next = prev - .then(async () => { - const progress = studyProgressCache.get(userId) ?? { byStudy: {}, updatedAt: new Date().toISOString() } - await mkdir(STUDY_PROGRESS_DIR, { recursive: true }) - await writeFile(getUserProgressFilePath(userId), JSON.stringify(progress, null, 2), 'utf8') - }) - .catch(err => { - console.error(`[study-progress] failed to write progress for user ${userId}:`, err) - }) - studyProgressWriteQueues.set(userId, next) -} - -function sanitizeStudyReminders(value) { - const defaultResult = { users: {}, updatedAt: new Date().toISOString() } - if (!value || typeof value !== 'object' || Array.isArray(value)) return defaultResult - - const reminders = { users: {}, updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : new Date().toISOString() } - if (value.users && typeof value.users === 'object') { - for (const [userId, userData] of Object.entries(value.users)) { - if (typeof userId !== 'string' || !userId.trim()) continue - const studies = typeof userData === 'object' && userData && !Array.isArray(userData) - ? userData - : {} - const normalizedStudies = {} - for (const [studySlug, sectionIds] of Object.entries(studies)) { - if (typeof studySlug !== 'string' || !studySlug.trim()) continue - const ids = Array.isArray(sectionIds) - ? sectionIds.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim()) - : [] - if (ids.length > 0) normalizedStudies[studySlug.trim().toLowerCase()] = Array.from(new Set(ids)) - } - reminders.users[userId.trim()] = normalizedStudies - } - } - - return reminders -} - -async function loadStudyRemindersFromDisk() { - try { - const raw = await readFile(STUDY_REMINDERS_FILE, 'utf8') - studyReminders = sanitizeStudyReminders(JSON.parse(raw)) - } catch { - studyReminders = { users: {}, updatedAt: new Date().toISOString() } - } -} - -function queueStudyRemindersWrite() { - studyRemindersWritePromise = studyRemindersWritePromise - .then(async () => { - await mkdir(DATA_DIR, { recursive: true }) - await writeFile(STUDY_REMINDERS_FILE, JSON.stringify(studyReminders, null, 2), 'utf8') - }) - .catch(err => { - console.error('[study-reminders] failed to write reminders:', err) - }) -} - -async function sendStudyReminderEmail(email, displayName, studyTitle, sectionTitle, sectionReference, sectionUrl) { - if (!process.env.RESEND_API_KEY) return - try { - const resend = new Resend(process.env.RESEND_API_KEY) - const cfg = cachedSiteContent ?? {} - const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend' - const subjectPrefix = cfg.studyReminderEmailSubjectPrefix?.trim() || 'New lesson available:' - const bodyText = cfg.studyReminderEmailBody?.trim() || 'A new lesson has been unlocked in your study track. Open it below to continue where you left off.' - const ctaLabel = cfg.studyReminderEmailCtaLabel?.trim() || 'Open the Lesson' - const signoff = cfg.studyReminderEmailSignoff?.trim() || 'Grace and peace,\nVerse by Verse with Nate' - const subject = process.env.RESEND_REMINDER_SUBJECT ?? `${subjectPrefix} ${sectionTitle}` - const bodyHtml = ( - `

Hi ${escapeHtml(namePart)},

` + - `

${escapeHtml(bodyText)}

` + - `

${escapeHtml(sectionTitle)} (${escapeHtml(sectionReference)}) — ${escapeHtml(studyTitle)}

` - ) - const footerHtml = `

${escapeHtml(signoff).replace(/\n/g, '
')}

` - const { error } = await resend.emails.send({ - from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate ', - to: [email], - subject, - text: `Hi ${namePart},\n\n${bodyText}\n\n${sectionTitle} (${sectionReference}) — ${studyTitle}\n\nOpen it here: ${sectionUrl}\n\n${signoff}`, - html: buildBrandedEmailHtml({ - title: 'New Lesson Available', - eyebrow: 'Study Reminder', - bodyHtml, - ctaLabel, - ctaUrl: sectionUrl, - footerHtml, - }), - }) - if (error) console.error('[study-reminder] send error:', error) - } catch (err) { - console.error('[study-reminder] send exception:', err) - } -} - -async function scheduleStudyReminders() { - if (!cachedSiteContent) return - const now = new Date() - const userIds = studyUsers.filter(user => user.studyRemindersEnabled === true).map(user => user.id) - if (userIds.length === 0) return - - for (const user of studyUsers) { - if (user.studyRemindersEnabled !== true) continue - const email = user.username - const displayName = user.displayName || email - const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : [] - if (enrolledStudySlugs.length === 0) continue - - const userSent = studyReminders.users[user.id] ?? {} - for (const studySlug of enrolledStudySlugs) { - const study = Array.isArray(cachedSiteContent.studies) - ? cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug) - : undefined - if (!study) continue - - for (const section of study.sections ?? []) { - const sectionId = section.id - const releaseDate = getSectionReleaseDate(section) - if (!releaseDate) continue - if (releaseDate > now) continue - const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : [] - if (sentForStudy.includes(sectionId)) continue - - const hoursSinceRelease = (now.getTime() - releaseDate.getTime()) / (1000 * 60 * 60) - if (hoursSinceRelease > 24) continue - - const sectionUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/${study.slug}/${section.id}`) - await sendStudyReminderEmail(email, displayName, study.title, section.title, section.reference, sectionUrl) - userSent[studySlug] = [...sentForStudy, sectionId] - studyReminders.users[user.id] = userSent - } - } - } - studyReminders.updatedAt = new Date().toISOString() - queueStudyRemindersWrite() -} - -async function migrateStudyNotesIfNeeded() { - try { - const raw = await readFile(STUDY_NOTES_FILE, 'utf8') - const parsed = JSON.parse(raw) - const notesByUser = parsed?.notesByUser ?? {} - const userIds = Object.keys(notesByUser) - if (userIds.length === 0) return - await mkdir(STUDY_NOTES_DIR, { recursive: true }) - let migrated = 0 - for (const [userId, notes] of Object.entries(notesByUser)) { - const sanitized = sanitizeUserNotes(notes) - if (Object.keys(sanitized).length === 0) continue - const filePath = getUserNotesFilePath(userId) - try { await readFile(filePath, 'utf8'); continue } catch { /* doesn't exist yet */ } - await writeFile(filePath, JSON.stringify(sanitized, null, 2), 'utf8') - migrated += 1 - } - if (migrated > 0) console.log(`[study-notes] migrated ${migrated} users to per-user files`) - } catch { /* no legacy file — nothing to migrate */ } -} - -function queueStudyUsersWrite() { - studyUsersWritePromise = studyUsersWritePromise - .then(async () => { - await mkdir(DATA_DIR, { recursive: true }) - await writeFile( - STUDY_USERS_FILE, - JSON.stringify({ users: studyUsers, updatedAt: new Date().toISOString() }, null, 2), - 'utf8', - ) - }) - .catch(err => { - console.error('[study-users] failed to write users:', err) - }) -} - -function loadStudyUsersFromDisk() { - return readFile(STUDY_USERS_FILE, 'utf8') - .then(raw => { - const parsed = JSON.parse(raw) - const source = Array.isArray(parsed) ? parsed : parsed?.users - studyUsers = sanitizeStudyUsers(source) - }) - .catch(() => { - studyUsers = [] - }) -} - -function findStudyUserByUsername(username) { - return studyUsers.find(user => user.username === normalizeStudyUsername(username)) -} - -function createStudySession(userId) { - const token = randomUUID() - studySessions.set(token, { userId, expiresAt: Date.now() + STUDY_SESSION_TTL_MS }) - return token -} - -function setStudySessionCookie(res, token) { - res.append( - 'Set-Cookie', - `${STUDY_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(STUDY_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`, - ) -} - -function clearStudySessionCookie(res) { - res.append( - 'Set-Cookie', - `${STUDY_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`, - ) -} - -function getStudyUserFromRequest(req) { - const cookies = parseCookies(req.headers.cookie) - const token = cookies[STUDY_SESSION_COOKIE] - if (!token) return null - - const session = studySessions.get(token) - if (!session || session.expiresAt <= Date.now()) { - studySessions.delete(token) - return null - } - - const user = studyUsers.find(item => item.id === session.userId) - if (!user) { - studySessions.delete(token) - return null - } - - session.expiresAt = Date.now() + STUDY_SESSION_TTL_MS - studySessions.set(token, session) - return user -} - -function requireStudyAuth(req, res, next) { - const user = getStudyUserFromRequest(req) - if (!user) { - res.status(401).json({ message: 'Please sign in to save notes.' }) - return - } - req.studyUser = user - next() -} - -function getStudySlugFromNoteId(sectionId) { - if (typeof sectionId !== 'string') return '' - const separatorIndex = sectionId.indexOf('--') - if (separatorIndex <= 0) return '' - return normalizeStudySlug(sectionId.slice(0, separatorIndex)) -} - -function normalizeLessonSectionId(value) { - if (typeof value !== 'string') return '' - const trimmed = value.trim().toLowerCase() - return /^[a-z0-9-]{1,80}$/.test(trimmed) ? trimmed : '' -} - -// Rate limiter: max 10 attempts per 15 minutes per IP on the login endpoint -const loginRateLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 10, - standardHeaders: true, - legacyHeaders: false, - message: { message: 'Too many login attempts. Please wait 15 minutes and try again.' }, - skipSuccessfulRequests: true, -}) - -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, -}) - -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, (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, - } - - studyUsers.push(user) - if (studyUsers.length > MAX_STUDY_USERS) { - studyUsers = studyUsers.slice(studyUsers.length - MAX_STUDY_USERS) - } - queueStudyUsersWrite() - - if (subscribe) { - addContactSubmission({ name: displayName || username, email: username, message: '', messageType: 'general', subscribe: true }) - 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 - } - - // If 2FA is enabled, pause here and return a pending token - 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 ?? [], - }) -}) - -// ── Study 2FA (TOTP) ───────────────────────────────────────────────────────── - -// Step 2 of login: verify TOTP code after password accepted -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 = 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 ?? [] }) - } - - // Email OTP method - 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 - } - - // App (TOTP) method - if (verifyTotpCode(user.totpSecret, codeStr)) { - completeLogin() - return - } - - // Try recovery code - 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.' }) -}) - -// Begin 2FA setup: generate secret + QR 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) - // Store unverified secret temporarily on the user record - user.totpSecretPending = secret - user.updatedAt = new Date().toISOString() - queueStudyUsersWrite() - res.json({ qrDataUrl, secret }) -}) - -// Confirm 2FA setup: verify first code then activate -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 }) -}) - -// Email 2FA setup — step 1: send verification code -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 }) -}) - -// Email 2FA setup — step 2: confirm code and activate -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 } - // Clear any app 2FA and switch to email - user.twoFaMethod = 'email' - user.totpSecret = null - user.totpVerified = false - user.totpRecoveryCodes = [] - delete user.totpSecretPending - user.updatedAt = new Date().toISOString() - queueStudyUsersWrite() - res.json({ ok: true }) -}) - -// Resend email OTP during login (uses pending token to identify user) -app.post('/api/study-auth/email-otp-resend', studyAuthRateLimiter, async (req, res) => { - const { pendingToken } = req.body ?? {} - // Peek at the pending token without consuming it - const entry = studyTotpPendingTokens.get(pendingToken) - if (!entry || Date.now() > entry.expiresAt) { res.status(401).json({ message: 'Session expired. Please sign in again.' }); return } - const user = 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 }) -}) - -// Disable 2FA (requires current password confirmation) -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 }) -}) - -// Regenerate recovery codes (requires active session) -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.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, - }) -}) - -app.post('/api/study-auth/logout', (req, res) => { - const cookies = parseCookies(req.headers.cookie) - const token = cookies[STUDY_SESSION_COOKIE] - if (token) { - studySessions.delete(token) - } - clearStudySessionCookie(res) - res.json({ ok: true }) -}) - -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 - } - - studyNotesCache.set(user.id, notes) - queueUserNotesWrite(user.id) - res.json({ ok: true, note }) -}) - -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() - studyProgressCache.set(user.id, progress) - queueUserProgressWrite(user.id) - - res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds }) -}) - -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() - studyProgressCache.set(user.id, progress) - queueUserProgressWrite(user.id) - - res.json({ ok: true, studySlug, sectionId, answers }) -}) - -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() - studyProgressCache.set(user.id, progress) - queueUserProgressWrite(user.id) - - res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds }) -}) - -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 = 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) - const enrichedPost = { - ...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 || ''), - } - }) - : [], - } - return enrichedPost - }) - - 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: [], - } - - studyCommunityPosts.unshift(post) - studyCommunityPosts = sanitizeStudyCommunityPosts(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 = 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 }) -}) - -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) - - // Build a lookup of sectionId -> { title, reference, studyTitle, description } from cached content - const sectionMeta = {} - const content = 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(cachedSiteContent?.studies) - ? (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 = 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: process.env.RESEND_FROM ?? 'Verse by Verse with Nate ', - to: [newEmail], - subject: emailChangeSubject, - text: `${emailChangeBody}\n\n${verifyUrl}`, - html: buildBrandedEmailHtml({ - title: emailChangeSubject, - eyebrow: 'Account Security', - bodyHtml: `

${escapeHtml(emailChangeBody)}

`, - ctaLabel: emailChangeCtaLabel, - ctaUrl: verifyUrl, - footerHtml: `

Verse by Verse with Nate

`, - }), - }) - 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 - - // Revoke all sessions for this user - for (const [token, session] of studySessions) { - if (session.userId === user.id) studySessions.delete(token) - } - - // Remove from users list and persist - studyUsers = studyUsers.filter(u => u.id !== user.id) - queueStudyUsersWrite() - - // Delete notes file - 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 }) -}) - -// ── Admin: Study User Management ────────────────────────────────────────── - -app.get('/api/admin/study-users', requireAdminAuth, async (req, res) => { - const catalog = getStudyCatalog() - const users = await Promise.all(studyUsers.map(async user => { - let noteCount = 0 - try { - 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 = 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) - // Revoke all active sessions so they must log in with new password - for (const [token, session] of studySessions) { - if (session.userId === user.id) 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 = studyUsers.find(u => u.id === req.params.id) - if (!user) { res.status(404).json({ message: 'User not found.' }); return } - - for (const [token, session] of studySessions) { - if (session.userId === user.id) studySessions.delete(token) - } - - studyUsers = studyUsers.filter(u => u.id !== user.id) - queueStudyUsersWrite() - - studyNotesCache.delete(user.id) - try { await unlink(getUserNotesFilePath(user.id)) } catch { /* no notes file is fine */ } - - res.json({ ok: true }) -}) - -app.get('/api/admin-auth/status', async (req, res) => { - res.json({ - authenticated: isValidAdminSession(req), - configured: isAdminPasswordConfigured(), - totpEnabled: await isTotpEnabled(), - }) -}) - -// Step 1: verify password. If TOTP is enabled, returns a short-lived pending token. -// If TOTP is not yet configured, logs straight in (backwards compatible). -app.post('/api/admin-auth/login', loginRateLimiter, async (req, res) => { - const password = typeof req.body?.password === 'string' ? req.body.password : '' - - if (!isAdminPasswordConfigured()) { - 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 }) -}) - -// Step 2a: verify TOTP code (or recovery code) after password was accepted -app.post('/api/admin-auth/totp-verify', loginRateLimiter, async (req, res) => { - const { pendingToken, code } = req.body ?? {} - - if (!consumePendingSession(pendingToken)) { - res.status(401).json({ message: 'Session expired or invalid. Please sign in again.' }) - return - } - - const state = await loadTotpState() - if (!state?.secret || !state?.verified) { - res.status(400).json({ message: 'TOTP is not configured.' }) - return - } - - const codeStr = typeof code === 'string' ? code.trim() : '' - - // Try TOTP first - if (verifyTotpCode(state.secret, codeStr)) { - const sessionToken = createAdminSession() - setAdminSessionCookie(res, sessionToken) - res.json({ ok: true }) - return - } - - // Try recovery code - if (consumeRecoveryCode(state, codeStr)) { - await saveTotpState(state) - const sessionToken = createAdminSession() - setAdminSessionCookie(res, sessionToken) - res.json({ ok: true, usedRecoveryCode: true, remainingRecoveryCodes: state.hashedRecoveryCodes.length }) - return - } - - res.status(401).json({ message: 'Invalid code. Try again or use a recovery code.' }) -}) - -// TOTP setup: generate a new secret and QR code (admin must be authenticated OR provide valid password) -app.post('/api/admin-auth/totp-setup-init', requireAdminAuth, async (req, res) => { - const secret = generateTotpSecret() - const uri = getTotpUri(secret) - const qrDataUrl = await qrcode.toDataURL(uri) - // Store unverified secret temporarily - const existing = await loadTotpState() - await saveTotpState({ ...existing, secret, verified: false }) - res.json({ qrDataUrl, secret }) -}) - -// TOTP setup: confirm the code to mark TOTP as verified and generate recovery codes -app.post('/api/admin-auth/totp-setup-confirm', requireAdminAuth, async (req, res) => { - const { code } = req.body ?? {} - const state = await loadTotpState() - - if (!state?.secret) { - res.status(400).json({ message: 'No TOTP setup in progress. Call /totp-setup-init first.' }) - return - } - - if (!verifyTotpCode(state.secret, typeof code === 'string' ? code.trim() : '')) { - res.status(401).json({ message: 'Code incorrect. Scan the QR code again and try once more.' }) - return - } - - const recoveryCodes = generateRecoveryCodes() - await saveTotpState({ - secret: state.secret, - verified: true, - hashedRecoveryCodes: recoveryCodes.map(hashRecoveryCode), - enabledAt: new Date().toISOString(), - }) - - res.json({ ok: true, recoveryCodes }) -}) - -// Disable TOTP (requires active admin session) -app.post('/api/admin-auth/totp-disable', requireAdminAuth, async (req, res) => { - await saveTotpState({ secret: null, verified: false, hashedRecoveryCodes: [], disabledAt: new Date().toISOString() }) - res.json({ ok: true }) -}) - -// Regenerate recovery codes (requires active admin session) -app.post('/api/admin-auth/totp-regen-recovery', requireAdminAuth, async (req, res) => { - const state = await loadTotpState() - if (!state?.secret || !state?.verified) { - res.status(400).json({ message: 'TOTP is not enabled.' }) - return - } - const recoveryCodes = generateRecoveryCodes() - await saveTotpState({ ...state, hashedRecoveryCodes: recoveryCodes.map(hashRecoveryCode) }) - res.json({ ok: true, recoveryCodes }) -}) - -app.post('/api/admin-auth/logout', (req, res) => { - const cookies = parseCookies(req.headers.cookie) - const sessionToken = cookies[ADMIN_SESSION_COOKIE] - deleteAdminSession(sessionToken) - clearAdminSessionCookie(res) - res.json({ ok: true }) -}) - -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', - ) - - cachedSiteContent = safeSiteContent - publishState.publishedAt = updatedAt - - res.json({ ok: true }) - } catch { - res.status(500).json({ message: 'Failed to persist admin content.' }) - } -}) - -app.post('/api/analytics-consent', (req, res) => { - const consent = req.body?.consent === true - setConsentCookie(res, consent) - res.json({ ok: true, consent }) -}) - -// Client-side SPA pageview tracking (fires on every React Router navigation) -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) - await recordVisitor(req, res, rawPath, rawReferrer) - res.json({ ok: true }) -}) - -app.get('/api/admin-stats', requireAdminAuth, (_req, res) => { - const topPaths = Object.entries(hitStats.byPath) - .sort((a, b) => b[1] - a[1]) - .slice(0, 10) - .map(([pathKey, hits]) => ({ path: pathKey, hits })) - - const topPathsReal = Object.entries(hitStats.byPathReal) - .sort((a, b) => b[1] - a[1]) - .slice(0, 10) - .map(([pathKey, hits]) => ({ path: pathKey, hits })) - - const topPathsBot = Object.entries(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: hitStats.byDayReal?.[item.day] ?? 0 - })) - const last7DaysBot = last7Days.map(item => ({ - day: item.day, - hits: 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 + (hitStats.byDayReal?.[item.day] ?? 0), 0) - const last30DaysBotTotal = last30Days.reduce((sum, item) => sum + (hitStats.byDayBot?.[item.day] ?? 0), 0) - - const botReasons = Object.entries(hitStats.botReasons ?? {}) - .sort((a, b) => b[1] - a[1]) - .slice(0, 10) - .map(([reason, count]) => ({ reason, count })) - - const recentVisitorRows = visitorStats.recentVisits.slice(0, 100).map(row => { - const fullVisitor = visitorStats.visitors[row.visitorId] - return { ...row, pageHistory: fullVisitor?.pageHistory ?? [] } - }) - const enrollmentCountsBySlug = {} - for (const user of 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 = studyUsers - .map(user => { - const enrolledStudySlugs = Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [] - const enrolledStudies = enrolledStudySlugs - .map(slug => { - const study = studyCatalogBySlug.get(slug) - if (!study) return null - return { slug: study.slug, title: study.title } - }) - .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 = studyUsers.filter(user => (user.enrolledStudySlugs?.length ?? 0) > 0).length - const totalEnrollments = Object.values(enrollmentCountsBySlug).reduce((sum, count) => sum + count, 0) - - res.json({ - totalHits: hitStats.totalHits, - realHits: hitStats.realHits ?? 0, - botHits: hitStats.botHits ?? 0, - firstHitAt: hitStats.firstHitAt, - lastHitAt: hitStats.lastHitAt, - topPaths, - topPathsReal, - topPathsBot, - last7Days, - last7DaysReal, - last7DaysBot, - last30DaysTotal, - last30DaysRealTotal, - last30DaysBotTotal, - botReasons, - visitors: { - totalVisits: visitorStats.totalVisits, - uniqueVisitors: visitorStats.uniqueVisitors, - returningVisits: visitorStats.returningVisits, - firstVisitAt: visitorStats.firstVisitAt, - lastVisitAt: 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: hitStats.byDayReal?.[item.day] ?? 0 })), - recentVisits: recentVisitorRows, - }, - writeStatus: { - hitStats: lastHitStatsWrite, - visitorStats: lastVisitorStatsWrite, - backups: lastBackupStatus, - cachePurge: lastCachePurgeStatus, - deployHook: lastDeployHookStatus, - }, - contactTotals: { - totalSubmissions: contactSubmissions.length, - totalQuestions: contactSubmissions.filter(entry => normalizeMessageType(entry?.messageType) === 'question').length, - }, - studyEnrollment: { - totalUsers: studyUsers.length, - enrolledUsers, - totalEnrollments, - enrollmentsByStudy, - users, - }, - }) -}) - -app.get('/api/admin-contact-submissions', requireAdminAuth, (_req, res) => { - res.json({ submissions: 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 - contactSubmissions = 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.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: replyTemplates }) -}) - -app.put('/api/admin-contact-reply-templates', requireAdminAuth, (req, res) => { - const nextTemplates = sanitizeReplyTemplates(req.body?.templates) - replyTemplates = nextTemplates - queueReplyTemplatesWrite() - res.json({ ok: true, templates: replyTemplates }) -}) - -app.get('/api/admin-contact-reply-history', requireAdminAuth, (_req, res) => { - res.json({ items: replyHistory.slice(0, 100) }) -}) - -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 = contactSubmissions.length - contactSubmissions = contactSubmissions.filter(item => item.id !== id) - if (contactSubmissions.length === startLength) { - res.status(404).json({ message: 'Submission not found.' }) - return - } - - queueContactSubmissionsWrite() - res.json({ ok: true }) -}) - -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 = 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, - }) - - 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(), - }) - replyHistory = 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-download-stats', requireAdminAuth, (_req, res) => { - res.json({ counts: downloadCounts }) -}) - -app.get('/api/admin-subscribers', requireAdminAuth, (_req, res) => { - const seen = new Set() - const subscribers = 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']] - 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) -}) - -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, - hitStats, - visitorStats, - contactSubmissions, - replyTemplates, - replyHistory, - }) -}) - -app.post('/api/admin-stats/clear', requireAdminAuth, (_req, res) => { - hitStats = { ...EMPTY_HIT_STATS } - 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) - 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: 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.' }) - } -}) - +// Register API routes +registerAdminAuth(app) +registerAdminContent(app) +registerAdminAssets(app) +registerStudyAuth(app) +registerStudyData(app) +registerStudyAccount(app) +registerContact(app) +registerQuestions(app) +registerAnalytics(app) +registerDownloads(app) +registerEpisodes(app) + +// Hit-counting middleware (must come before public routes) app.use((req, res, next) => { if (shouldCountHit(req)) { const ua = sanitizeUserAgent(req.get('user-agent')) const botDetection = detectBot(ua) recordHit(req.path, botDetection.isBot, botDetection.reason) + queueHitStatsWrite() if (hasVisitorConsent(req) && !botDetection.isBot) { recordVisitor(req, res).catch(err => { console.error('[visitor-stats] failed to record visitor:', err) @@ -4914,1055 +78,8 @@ app.use((req, res, next) => { next() }) -// Rate-limit contact submissions: max 5 per IP per 10 minutes -const contactHits = new Map() -const downloadHits = 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() -} - -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() -} - -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.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/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/contact', contactRateLimit, async (req, res) => { - try { - const { firstName, lastName, email, message, messageType, subscribe, _honey } = req.body ?? {} - - // Honeypot — silently discard if filled by a bot - 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 submission = addContactSubmission({ - name: trimmedName, - email: trimmedEmail, - message: trimmedMessage, - messageType: normalizedMessageType, - subscribe, - }) - - // If this is a question, also add to questions array for public Q&A section - 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, - } - questions.unshift(question) - questions = questions.slice(0, MAX_QUESTIONS) - if (draftQuestions !== null) { - draftQuestions.unshift(question) - draftQuestions = 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 = cachedSiteContent - if (!publishedSiteContent) { - try { - 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 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.' - const 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.' - const welcomeStartHereTitle = emailConfig.welcomeEmailStartHereTitle?.trim() || 'Episode 1 - Introduction to Titus' - const welcomeStartHereSummary = emailConfig.welcomeEmailStartHereSummary?.trim() || 'Who wrote it, who received it, and why it still matters.' - const welcomeExpect1 = emailConfig.welcomeEmailWhatToExpect1?.trim() || 'Verse-by-verse teaching - we go slow and let the text speak for itself.' - const welcomeExpect2 = emailConfig.welcomeEmailWhatToExpect2?.trim() || 'Greek word studies - the kind that open up meaning without being a lecture.' - const welcomeExpect3 = emailConfig.welcomeEmailWhatToExpect3?.trim() || 'New episodes + study notes delivered right to your inbox.' - const welcomeScripture = emailConfig.welcomeEmailScripture?.trim() || 'For the grace of God has appeared, bringing salvation to all people.' - const welcomeScriptureRef = emailConfig.welcomeEmailScriptureRef?.trim() || 'Titus 2:11 - BSB' - const welcomeSignoff = emailConfig.welcomeEmailSignoff?.trim() || 'Grace and peace,\nNate' - const welcomeSpotifyUrl = buildAbsoluteUrl( - welcomeBaseUrl, - process.env.RESEND_WELCOME_SPOTIFY_URL ?? emailConfig.welcomeEmailSpotifyUrl ?? '/spotify', - ) - const welcomeAppleUrl = buildAbsoluteUrl( - welcomeBaseUrl, - process.env.RESEND_WELCOME_APPLE_URL ?? emailConfig.welcomeEmailAppleUrl ?? '/apple', - ) - const welcomeAmazonUrl = buildAbsoluteUrl( - welcomeBaseUrl, - process.env.RESEND_WELCOME_AMAZON_URL ?? emailConfig.welcomeEmailAmazonUrl ?? '/amazon', - ) - const welcomeWebsiteUrl = buildAbsoluteUrl( - welcomeBaseUrl, - process.env.RESEND_WELCOME_WEBSITE_URL ?? emailConfig.welcomeEmailWebsiteUrl ?? '/', - ) - const welcomeEpisodeUrl = buildAbsoluteUrl( - welcomeBaseUrl, - process.env.RESEND_WELCOME_EPISODE_URL ?? emailConfig.welcomeEmailStartHereUrl ?? '/start-here', - ) - const welcomeImageUrl = buildAbsoluteUrl( - welcomeBaseUrl, - process.env.RESEND_WELCOME_IMAGE_URL ?? emailConfig.welcomeEmailImageUrl ?? '/images/podcast-art.jpeg', - ) - - const welcomeSpotifyBtnLabel = emailConfig.welcomeEmailSpotifyBtnLabel?.trim() || 'Listen on Spotify' - const welcomeAppleBtnLabel = emailConfig.welcomeEmailAppleBtnLabel?.trim() || 'Apple Podcasts' - const welcomeStartHereLinkLabel = emailConfig.welcomeEmailStartHereLinkLabel?.trim() || 'Open Start Here page' - - const welcomeTemplate = buildContactWelcomeEmailTemplate({ - greetingName, - welcomeIntro, - welcomeCurrentSeries, - welcomeStartHereTitle, - welcomeStartHereSummary, - welcomeExpect1, - welcomeExpect2, - welcomeExpect3, - welcomeScripture, - welcomeScriptureRef, - welcomeSignoff, - welcomeHeading, - welcomeSpotifyUrl, - welcomeAppleUrl, - welcomeAmazonUrl, - welcomeWebsiteUrl, - welcomeEpisodeUrl, - welcomeImageUrl, - welcomeSpotifyBtnLabel, - welcomeAppleBtnLabel, - welcomeStartHereLinkLabel, - }) - - 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': ``, - '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 ? 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 = 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, - }) -}) - -// Get all questions (for admin) -app.get('/api/admin-questions', requireAdminAuth, (_req, res) => { - res.json({ questions: draftQuestions ?? questions }) -}) - -// Get only approved public questions (for homepage) -app.get('/api/questions', (_req, res) => { - const sourceQuestions = draftQuestions ?? questions - const publicQuestions = sourceQuestions.filter(q => q.isApproved === true && q.answer && q.answer.trim().length > 0) - res.json({ questions: publicQuestions }) -}) - -// Create a manual question (admin) -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, - } - - draftQuestions.unshift(created) - draftQuestions = draftQuestions.slice(0, MAX_QUESTIONS) - queueDraftQuestionsWrite() - - res.status(201).json({ ok: true, question: created }) -}) - -// Answer a question (admin) -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 = 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 }) -}) - -// Approve/unapprove a question (admin) -app.post('/api/admin-questions/:id/approve', requireAdminAuth, (req, res) => { - const { id } = req.params - const { approved } = req.body ?? {} - - ensureDraftQuestions() - const question = 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 }) -}) - -// Delete a question (admin) -app.delete('/api/admin-questions/:id', requireAdminAuth, (req, res) => { - const { id } = req.params - ensureDraftQuestions() - const index = draftQuestions.findIndex(q => q.id === id) - - if (index === -1) { - res.status(404).json({ message: 'Question not found.' }) - return - } - - draftQuestions.splice(index, 1) - queueDraftQuestionsWrite() - - res.json({ ok: true }) -}) -// ── Episodes (RSS feed proxy) ────────────────────────────────────────────── -const RSS_FEED_URL = 'https://anchor.fm/nmemmert/podcast/rss' -let episodesCache = null -let episodesCacheAt = 0 -const EPISODES_CACHE_TTL = 30 * 60 * 1000 // 30 minutes - -function extractCdata(raw) { - const cdata = /^$/.exec(raw.trim()) - return cdata ? cdata[1].trim() : raw.trim() -} - -function parseRssItems(xml, limit = Infinity) { - const items = [] - const itemRegex = /([\s\S]*?)<\/item>/g - let match - while ((match = itemRegex.exec(xml)) !== null && items.length < limit) { - const block = match[1] - const titleRaw = /([\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 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' -} - -function decodeEscapedJsonUrl(value) { - return String(value || '').replace(/\\u002F/g, '/').replace(/\\\//g, '/') -} - -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 ?? [] }) - } -}) - -app.get('/robots.txt', async (_req, res) => { - let content = 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 = 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) -}) - -app.use((req, res, next) => { - const rules = sanitizeRedirectRules(cachedSiteContent?.redirects) - const match = rules.find(rule => rule.path === req.path) - if (!match) { - next() - return - } - res.redirect(match.statusCode === 302 ? 302 : 301, match.target) -}) - -app.use('/images', express.static(DIST_IMAGES_DIR)) -app.use('/images', express.static(PUBLIC_IMAGES_DIR)) -app.use('/uploads', express.static(UPLOADS_DIR)) - -// Per-question social share stub — serves question-specific OG tags so -// platforms (X, Facebook) render a rich preview card. After the crawl -// delay the page immediately redirects the human visitor to the real SPA URL. -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 = draftQuestions ?? 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)}\u2026` - : question.question) - const answerSnippet = question.answer.replace(/\n+/g, ' ').trim() - const ogDescription = escapeHtml(answerSnippet.length > 200 - ? `${answerSnippet.slice(0, 197)}\u2026` - : 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 - - - - - - - - - - - - - - - - - -`) -}) - -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, cachedSiteContent)) - } catch { - res.status(503).send('Frontend build not found. Run "npm run build" first.') - } -}) +// Public routes (robots, sitemap, static, SPA fallback) +registerPublic(app) const PORT = Number(process.env.PORT ?? 4173) Promise.all([ @@ -5990,21 +107,21 @@ Promise.all([ createBackupSnapshot('scheduled').catch(() => {}) }, BACKUP_INTERVAL_MS) - // Purge expired study sessions every hour to prevent unbounded memory growth + // Purge expired study sessions every hour setInterval(() => { const now = Date.now() - for (const [token, session] of studySessions) { - if (session.expiresAt <= now) studySessions.delete(token) + for (const [token, session] of state.studySessions) { + if (session.expiresAt <= now) state.studySessions.delete(token) } }, 60 * 60 * 1000) // Send study reminder emails for newly released lessons every hour setInterval(() => { - scheduleStudyReminders().catch(err => { + scheduleStudyReminders(sendStudyReminderEmail).catch(err => { console.error('[study-reminders] failed to schedule reminders:', err) }) }, 60 * 60 * 1000) - void scheduleStudyReminders() + void scheduleStudyReminders(sendStudyReminderEmail) app.listen(PORT, () => { logResendEmailAlignmentWarnings() diff --git a/server/config.js b/server/config.js new file mode 100644 index 0000000..6afc6db --- /dev/null +++ b/server/config.js @@ -0,0 +1,217 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { validateAdminPasswordSetup } from './auth.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +// server/config.js is inside server/, so the project root is one level up +export const ROOT_DIR = path.resolve(__dirname, '..') + +const DEFAULT_DATA_DIR = path.join(ROOT_DIR, 'data') +const configuredDataDir = typeof process.env.SITEFORGE_DATA_DIR === 'string' ? process.env.SITEFORGE_DATA_DIR.trim() : '' +export const DATA_DIR = configuredDataDir + ? (path.isAbsolute(configuredDataDir) ? configuredDataDir : path.resolve(ROOT_DIR, configuredDataDir)) + : DEFAULT_DATA_DIR + +export const DATA_FILE = path.join(DATA_DIR, 'admin-content.json') +export const DRAFT_DATA_FILE = path.join(DATA_DIR, 'admin-content-draft.json') +export const HIT_STATS_FILE = path.join(DATA_DIR, 'hit-stats.json') +export const VISITOR_STATS_FILE = path.join(DATA_DIR, 'visitor-stats.json') +export const CONTACT_SUBMISSIONS_FILE = path.join(DATA_DIR, 'contact-submissions.json') +export const QUESTIONS_FILE = path.join(DATA_DIR, 'questions.json') +export const DRAFT_QUESTIONS_FILE = path.join(DATA_DIR, 'questions-draft.json') +export const STUDY_USERS_FILE = path.join(DATA_DIR, 'study-users.json') +export const STUDY_NOTES_FILE = path.join(DATA_DIR, 'study-notes.json') // legacy — kept only for one-time migration +export const STUDY_NOTES_DIR = path.join(DATA_DIR, 'study-notes') +export const STUDY_PROGRESS_DIR = path.join(DATA_DIR, 'study-progress') +export const STUDY_COMMUNITY_FILE = path.join(DATA_DIR, 'study-community.json') +export const REPLY_TEMPLATES_FILE = path.join(DATA_DIR, 'admin-reply-templates.json') +export const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.json') +export const PODCAST_CHECKLIST_FILE = path.join(DATA_DIR, 'podcast-checklist.json') +export const BACKUP_DIR = path.join(DATA_DIR, 'backups') +export const UPLOADS_DIR = path.join(DATA_DIR, 'uploads') +export const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json') +export const DOWNLOAD_COUNTS_FILE = path.join(DATA_DIR, 'download-counts.json') +export const STUDY_REMINDERS_FILE = path.join(DATA_DIR, 'study-reminders.json') + +export const DIST_DIR = path.join(ROOT_DIR, 'dist') +export const INDEX_FILE = path.join(DIST_DIR, 'index.html') +export const DIST_IMAGES_DIR = path.join(DIST_DIR, 'images') +export const PUBLIC_IMAGES_DIR = path.join(ROOT_DIR, 'public', 'images') + +validateAdminPasswordSetup() + +export const TITUS_STUDY_FILE = process.env.TITUS_STUDY_FILE + ? path.resolve(ROOT_DIR, process.env.TITUS_STUDY_FILE) + : path.join(ROOT_DIR, 'A_Study_of_Titus.pdf') +export const TITUS_STUDY_DOWNLOAD_NAME = process.env.TITUS_STUDY_DOWNLOAD_NAME ?? 'A_Study_of_Titus.pdf' + +// Rate limit / timing constants +export const VISITOR_COOKIE = 'vbn_vid' +export const CONSENT_COOKIE = 'vbn_analytics_consent' +export const MAX_RECENT_VISITS = 1000 +export const VISITOR_RETENTION_DAYS_DEFAULT = 180 +export const BACKUP_RETENTION_DAYS = 30 +export const BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000 +export const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000 + +export const MAX_CONTACT_SUBMISSIONS = 5000 +export const CONTACT_EMAIL_COOLDOWN_MS = Math.max(10 * 1000, Number(process.env.CONTACT_EMAIL_COOLDOWN_MS ?? 60 * 1000) || 60 * 1000) +export const DOWNLOAD_TOKEN_TTL_MS = 10 * 60 * 1000 + +export const MAX_QUESTIONS = 1000 +export const MAX_STUDY_USERS = 5000 +export const MAX_STUDY_ENROLLMENTS_PER_USER = 100 +export const MAX_STUDY_NOTES_PER_USER = 500 +export const MAX_STUDY_NOTE_LENGTH = 12000 +export const EMAIL_CHANGE_TOKEN_TTL_MS = 24 * 60 * 60 * 1000 +export const STUDY_SESSION_COOKIE = 'vbn_study_session' +export const STUDY_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000 +export const STUDY_TOTP_PENDING_TTL_MS = 5 * 60 * 1000 +export const EMAIL_OTP_TTL_MS = 10 * 60 * 1000 +export const EMAIL_OTP_MAX_ATTEMPTS = 5 + +export const USE_RESEND_AUTOMATION_WELCOME = process.env.RESEND_AUTOMATION_WELCOME === 'true' +export const DEFAULT_RESEND_FROM = 'Verse by Verse with Nate ' +export const DEFAULT_RESEND_TO = 'hello@versebyversewithnate.us' +export const DEFAULT_RESEND_REPLY_TO = 'hello@versebyversewithnate.us' +export const ADMIN_REPLY_FROM = DEFAULT_RESEND_FROM + +export const DEFAULT_SEO = { + title: 'Verse by Verse with Nate', + description: 'Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.', + ogTitle: 'Verse by Verse with Nate', + ogDescription: 'A Journey Through Scripture - verse by verse, nugget by nugget.', + ogImage: '/images/podcast-art.jpeg', + canonicalUrl: 'https://versebyversewithnate.us/', + robotsPolicy: 'index,follow', + sitemapPaths: ['/', '/start-here', '/questions', '/privacy', '/terms'], +} + +export const DEFAULT_LEGAL = { + privacyTitle: 'Privacy Policy', + privacyBody: [ + 'We respect your privacy and collect limited data to operate and improve this site.', + 'If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.', + 'Contact form details are used only to respond to your message and ministry communication requests.', + ], + termsTitle: 'Terms', + termsBody: [ + 'Content on this site is for informational and ministry purposes.', + 'External links are provided for convenience and are subject to third-party policies.', + 'By using this site, you agree to lawful use and respectful communication.', + ], +} +export const DEFAULT_PODCAST_FEATURED_LINKS = [] +export const DEFAULT_PUBLISH_STATE = { + draftUpdatedAt: null, + publishedAt: null, +} + +export const DEFAULT_REDIRECT_RULES = [ + { + id: 'spotify', + path: '/spotify', + target: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl', + statusCode: 301, + }, + { + id: 'apple', + path: '/apple', + target: 'https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate', + statusCode: 301, + }, + { + id: 'amazon', + path: '/amazon', + target: 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate', + statusCode: 301, + }, +] + +export const DEFAULT_REPLY_TEMPLATES = [ + { + id: 'thanks-for-reaching-out', + label: 'Thank You Reply', + subject: 'Thanks for reaching out to Verse by Verse with Nate', + message: 'Thank you for reaching out.\n\nI appreciate your message and wanted to follow up personally.', + }, + { + id: 'question-received', + label: 'Question Received', + subject: 'Your Bible question was received', + message: 'Thank you for sending your Bible question.\n\nI have received it, and I appreciate you taking the time to write in.', + }, + { + id: 'testimony-thank-you', + label: 'Testimony Thank You', + subject: 'Thank you for sharing your testimony', + message: 'Thank you for sharing what the Lord is doing in your life.\n\nYour message was an encouragement to read.', + }, +] + +export const DEFAULT_PODCAST_CHECKLIST_TASKS = [ + { id: 'verify_script', label: 'Verify Script', phase: 'pre' }, + { id: 'read_script', label: 'Read Script', phase: 'pre' }, + { id: 'record', label: 'Record', phase: 'pre' }, + { id: 'mix', label: 'Mix', phase: 'pre' }, + { id: 'edit', label: 'Edit', phase: 'pre' }, + { id: 'video_script', label: 'Run Video Conversion Script', phase: 'pre' }, + { id: 'post_spotify', label: 'Post on Spotify', phase: 'pre' }, + { id: 'update_website', label: 'Update Website', phase: 'post' }, + { id: 'send_email', label: 'Send Email', phase: 'post' }, +] + +export const EMPTY_HIT_STATS = { + totalHits: 0, + realHits: 0, + botHits: 0, + firstHitAt: null, + lastHitAt: null, + byPath: {}, + byPathReal: {}, + byPathBot: {}, + byDay: {}, + byDayReal: {}, + byDayBot: {}, + botReasons: {}, +} + +export const EMPTY_VISITOR_STATS = { + totalVisits: 0, + uniqueVisitors: 0, + returningVisits: 0, + firstVisitAt: null, + lastVisitAt: null, + visitors: {}, + ipHashIndex: {}, + recentVisits: [], + geoCacheByIp: {}, +} + +// ── Podcast checklist helpers (no state dependency) ──────────────────────── + +function buildChecklistEpisode(series, number) { + const tasks = {} + for (const task of DEFAULT_PODCAST_CHECKLIST_TASKS) { + tasks[task.id] = false + } + return { + id: `${series.toLowerCase()}-${number}`, + series, + episodeNumber: number, + title: '', + datePublished: '', + expanded: false, + tasks, + } +} + +export function buildDefaultPodcastChecklist() { + const titusEpisodes = [11, 12, 13, 14, 15].map(number => buildChecklistEpisode('Titus', number)) + const colossiansEpisodes = Array.from({ length: 27 }, (_, index) => buildChecklistEpisode('Colossians', index + 1)) + return { + tasks: DEFAULT_PODCAST_CHECKLIST_TASKS, + episodes: [...titusEpisodes, ...colossiansEpisodes], + } +} diff --git a/server/data.js b/server/data.js new file mode 100644 index 0000000..f76e38f --- /dev/null +++ b/server/data.js @@ -0,0 +1,1145 @@ +import { mkdir, readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { randomUUID } from 'node:crypto' +import { sanitizeSiteContent } from './helpers.js' +import { + DATA_DIR, + DATA_FILE, + DRAFT_DATA_FILE, + HIT_STATS_FILE, + VISITOR_STATS_FILE, + CONTACT_SUBMISSIONS_FILE, + QUESTIONS_FILE, + DRAFT_QUESTIONS_FILE, + STUDY_USERS_FILE, + STUDY_COMMUNITY_FILE, + STUDY_NOTES_DIR, + STUDY_NOTES_FILE, + STUDY_PROGRESS_DIR, + STUDY_REMINDERS_FILE, + REPLY_TEMPLATES_FILE, + REPLY_HISTORY_FILE, + PODCAST_CHECKLIST_FILE, + BACKUP_DIR, + UPLOADS_DIR, + UPLOADS_META_FILE, + DOWNLOAD_COUNTS_FILE, + EMPTY_HIT_STATS, + EMPTY_VISITOR_STATS, + DEFAULT_REPLY_TEMPLATES, + MAX_QUESTIONS, + MAX_CONTACT_SUBMISSIONS, + MAX_RECENT_VISITS, + BACKUP_RETENTION_DAYS, + MAX_STUDY_USERS, + MAX_STUDY_ENROLLMENTS_PER_USER, + MAX_STUDY_NOTES_PER_USER, + MAX_STUDY_NOTE_LENGTH, + DEFAULT_PODCAST_CHECKLIST_TASKS, + buildDefaultPodcastChecklist, +} from './config.js' +import { state } from './state.js' + +// ── Helpers ──────────────────────────────────────────────────────────────── + +export async function loadSiteContentFile(filePath) { + const raw = await readFile(filePath, 'utf8') + const parsed = JSON.parse(raw) + const safeSiteContent = sanitizeSiteContent(parsed?.siteContent) + return { + ...parsed, + siteContent: safeSiteContent, + } +} + +export async function checkDataDirWritable() { + try { + await mkdir(DATA_DIR, { recursive: true }) + const marker = path.join(DATA_DIR, `.write-test-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`) + await writeFile(marker, 'ok', 'utf8') + await unlink(marker) + return { ok: true, error: null } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : 'Unknown write test error' } + } +} + +export async function getStorageStatus() { + const writable = await checkDataDirWritable() + const files = {} + for (const [key, filePath] of Object.entries({ + adminContent: DATA_FILE, + adminContentDraft: DRAFT_DATA_FILE, + studyUsers: STUDY_USERS_FILE, + })) { + try { + const fileStat = await stat(filePath) + files[key] = { + path: filePath, + exists: true, + sizeBytes: fileStat.size, + mtime: fileStat.mtime.toISOString(), + } + } catch { + files[key] = { + path: filePath, + exists: false, + sizeBytes: 0, + mtime: null, + } + } + } + + return { + dataDir: DATA_DIR, + writable, + files, + } +} + +export async function refreshContentCaches() { + try { + const published = await loadSiteContentFile(DATA_FILE) + state.cachedSiteContent = published.siteContent + if (typeof published?.updatedAt === 'string') { + state.publishState.publishedAt = published.updatedAt + } + } catch { + state.cachedSiteContent = null + } + + try { + const draft = await loadSiteContentFile(DRAFT_DATA_FILE) + state.cachedDraftSiteContent = draft.siteContent + if (typeof draft?.updatedAt === 'string') { + state.publishState.draftUpdatedAt = draft.updatedAt + } + } catch { + state.cachedDraftSiteContent = null + } +} + +// ── Hit stats ────────────────────────────────────────────────────────────── + +export function queueHitStatsWrite() { + state.hitStatsWritePromise = state.hitStatsWritePromise + .then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile( + HIT_STATS_FILE, + JSON.stringify({ + ...state.hitStats, + updatedAt: new Date().toISOString(), + }, null, 2), + 'utf8', + ) + state.lastHitStatsWrite = { ok: true, at: new Date().toISOString(), error: null } + }) + .catch(err => { + console.error('[stats] failed to write hit stats:', err) + state.lastHitStatsWrite = { ok: false, at: new Date().toISOString(), error: String(err) } + }) +} + +export function loadHitStatsFromDisk() { + return readFile(HIT_STATS_FILE, 'utf8') + .then(raw => { + const parsed = JSON.parse(raw) + state.hitStats = { + totalHits: Number(parsed?.totalHits) || 0, + realHits: Number(parsed?.realHits) || 0, + botHits: Number(parsed?.botHits) || 0, + firstHitAt: typeof parsed?.firstHitAt === 'string' ? parsed.firstHitAt : null, + lastHitAt: typeof parsed?.lastHitAt === 'string' ? parsed.lastHitAt : null, + byPath: parsed?.byPath && typeof parsed.byPath === 'object' ? parsed.byPath : {}, + byPathReal: parsed?.byPathReal && typeof parsed.byPathReal === 'object' ? parsed.byPathReal : {}, + byPathBot: parsed?.byPathBot && typeof parsed.byPathBot === 'object' ? parsed.byPathBot : {}, + byDay: parsed?.byDay && typeof parsed.byDay === 'object' ? parsed.byDay : {}, + byDayReal: parsed?.byDayReal && typeof parsed.byDayReal === 'object' ? parsed.byDayReal : {}, + byDayBot: parsed?.byDayBot && typeof parsed.byDayBot === 'object' ? parsed.byDayBot : {}, + botReasons: parsed?.botReasons && typeof parsed.botReasons === 'object' ? parsed.botReasons : {}, + } + }) + .catch(() => { + state.hitStats = { ...EMPTY_HIT_STATS } + }) +} + +// ── Visitor stats ────────────────────────────────────────────────────────── + +export function queueVisitorStatsWrite() { + state.visitorStatsWritePromise = state.visitorStatsWritePromise + .then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile( + VISITOR_STATS_FILE, + JSON.stringify({ + ...state.visitorStats, + updatedAt: new Date().toISOString(), + }, null, 2), + 'utf8', + ) + state.lastVisitorStatsWrite = { ok: true, at: new Date().toISOString(), error: null } + }) + .catch(err => { + console.error('[visitor-stats] failed to write visitor stats:', err) + state.lastVisitorStatsWrite = { ok: false, at: new Date().toISOString(), error: String(err) } + }) +} + +export function loadVisitorStatsFromDisk() { + return readFile(VISITOR_STATS_FILE, 'utf8') + .then(raw => { + const parsed = JSON.parse(raw) + const loadedVisitors = parsed?.visitors && typeof parsed.visitors === 'object' ? parsed.visitors : {} + + let ipHashIndex = parsed?.ipHashIndex && typeof parsed.ipHashIndex === 'object' ? parsed.ipHashIndex : {} + if (Object.keys(ipHashIndex).length === 0 && Object.keys(loadedVisitors).length > 0) { + for (const [vid, visitor] of Object.entries(loadedVisitors)) { + if (visitor?.ipHash && typeof visitor.ipHash === 'string') { + ipHashIndex[visitor.ipHash] = vid + } + } + } + + state.visitorStats = { + totalVisits: Number(parsed?.totalVisits) || 0, + uniqueVisitors: Number(parsed?.uniqueVisitors) || 0, + returningVisits: Number(parsed?.returningVisits) || 0, + firstVisitAt: typeof parsed?.firstVisitAt === 'string' ? parsed.firstVisitAt : null, + lastVisitAt: typeof parsed?.lastVisitAt === 'string' ? parsed.lastVisitAt : null, + visitors: loadedVisitors, + ipHashIndex, + recentVisits: Array.isArray(parsed?.recentVisits) ? parsed.recentVisits.slice(0, MAX_RECENT_VISITS) : [], + geoCacheByIp: parsed?.geoCacheByIp && typeof parsed.geoCacheByIp === 'object' ? parsed.geoCacheByIp : {}, + } + }) + .catch(() => { + state.visitorStats = { ...EMPTY_VISITOR_STATS } + }) +} + +// ── Contact submissions ──────────────────────────────────────────────────── + +export function queueContactSubmissionsWrite() { + state.contactSubmissionsWritePromise = state.contactSubmissionsWritePromise + .then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile( + CONTACT_SUBMISSIONS_FILE, + JSON.stringify({ + submissions: state.contactSubmissions, + updatedAt: new Date().toISOString(), + }, null, 2), + 'utf8', + ) + }) + .catch(err => { + console.error('[contact] failed to write submissions:', err) + }) +} + +export function loadContactSubmissionsFromDisk() { + return readFile(CONTACT_SUBMISSIONS_FILE, 'utf8') + .then(raw => { + const parsed = JSON.parse(raw) + state.contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.submissions) + }) + .catch(() => { + state.contactSubmissions = [] + }) +} + +// ── Questions ────────────────────────────────────────────────────────────── + +export function queueQuestionsWrite() { + state.questionsWritePromise = state.questionsWritePromise + .then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile( + QUESTIONS_FILE, + JSON.stringify({ + questions: state.questions, + updatedAt: new Date().toISOString(), + }, null, 2), + 'utf8', + ) + }) + .catch(err => { + console.error('[questions] failed to write questions:', err) + }) +} + +export function queueDraftQuestionsWrite() { + if (state.draftQuestions === null) return + state.draftQuestionsWritePromise = state.draftQuestionsWritePromise + .then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile( + DRAFT_QUESTIONS_FILE, + JSON.stringify({ questions: state.draftQuestions, updatedAt: new Date().toISOString() }, null, 2), + 'utf8', + ) + state.publishState.draftUpdatedAt = new Date().toISOString() + }) + .catch(err => { + console.error('[draft-questions] failed to write draft questions:', err) + }) +} + +export function loadQuestionsFromDisk() { + return readFile(QUESTIONS_FILE, 'utf8') + .then(raw => { + const parsed = JSON.parse(raw) + if (Array.isArray(parsed)) { + state.questions = parsed.slice(0, MAX_QUESTIONS) + } else if (Array.isArray(parsed?.questions)) { + state.questions = parsed.questions.slice(0, MAX_QUESTIONS) + } else { + state.questions = [] + } + }) + .catch(() => { + state.questions = [] + }) +} + +export async function loadDraftQuestionsFromDisk() { + return readFile(DRAFT_QUESTIONS_FILE, 'utf8') + .then(raw => { + const parsed = JSON.parse(raw) + if (Array.isArray(parsed)) { + state.draftQuestions = parsed.slice(0, MAX_QUESTIONS) + } else if (Array.isArray(parsed?.questions)) { + state.draftQuestions = parsed.questions.slice(0, MAX_QUESTIONS) + } else { + state.draftQuestions = null + } + if (typeof parsed?.updatedAt === 'string') { + state.publishState.draftUpdatedAt = parsed.updatedAt + } + }) + .catch(() => { + state.draftQuestions = null + }) +} + +// ── Reply templates / history ────────────────────────────────────────────── + +export function queueReplyTemplatesWrite() { + state.replyTemplatesWritePromise = state.replyTemplatesWritePromise + .then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile( + REPLY_TEMPLATES_FILE, + JSON.stringify({ templates: state.replyTemplates, updatedAt: new Date().toISOString() }, null, 2), + 'utf8', + ) + }) + .catch(err => { + console.error('[reply-templates] failed to write templates:', err) + }) +} + +export function queueReplyHistoryWrite() { + state.replyHistoryWritePromise = state.replyHistoryWritePromise + .then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile( + REPLY_HISTORY_FILE, + JSON.stringify({ items: state.replyHistory, updatedAt: new Date().toISOString() }, null, 2), + 'utf8', + ) + }) + .catch(err => { + console.error('[reply-history] failed to write history:', err) + }) +} + +export function loadReplyTemplatesFromDisk() { + return readFile(REPLY_TEMPLATES_FILE, 'utf8') + .then(raw => { + const parsed = JSON.parse(raw) + state.replyTemplates = sanitizeReplyTemplates(parsed?.templates) + }) + .catch(() => { + state.replyTemplates = [...DEFAULT_REPLY_TEMPLATES] + }) +} + +export function loadReplyHistoryFromDisk() { + return readFile(REPLY_HISTORY_FILE, 'utf8') + .then(raw => { + const parsed = JSON.parse(raw) + state.replyHistory = sanitizeReplyHistory(parsed?.items) + }) + .catch(() => { + state.replyHistory = [] + }) +} + +// ── Podcast checklist ────────────────────────────────────────────────────── + +export function queuePodcastChecklistWrite() { + const updatedAt = new Date().toISOString() + state.podcastChecklistWritePromise = state.podcastChecklistWritePromise.then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile( + PODCAST_CHECKLIST_FILE, + JSON.stringify({ checklist: state.podcastChecklist, updatedAt }, null, 2), + 'utf8', + ) + }) + return state.podcastChecklistWritePromise +} + +export function loadPodcastChecklistFromDisk() { + return readFile(PODCAST_CHECKLIST_FILE, 'utf8') + .then(raw => { + const parsed = JSON.parse(raw) + state.podcastChecklist = sanitizePodcastChecklist(parsed?.checklist) + }) + .catch(() => { + state.podcastChecklist = buildDefaultPodcastChecklist() + }) +} + +// ── Study users ──────────────────────────────────────────────────────────── + +export function queueStudyUsersWrite() { + state.studyUsersWritePromise = state.studyUsersWritePromise + .then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile( + STUDY_USERS_FILE, + JSON.stringify({ users: state.studyUsers, updatedAt: new Date().toISOString() }, null, 2), + 'utf8', + ) + }) + .catch(err => { + console.error('[study-users] failed to write users:', err) + }) +} + +export function loadStudyUsersFromDisk() { + return readFile(STUDY_USERS_FILE, 'utf8') + .then(raw => { + const parsed = JSON.parse(raw) + const source = Array.isArray(parsed) ? parsed : parsed?.users + state.studyUsers = sanitizeStudyUsers(source) + }) + .catch(() => { + state.studyUsers = [] + }) +} + +// ── Study community ──────────────────────────────────────────────────────── + +export function queueStudyCommunityWrite() { + state.studyCommunityWritePromise = state.studyCommunityWritePromise + .then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile( + STUDY_COMMUNITY_FILE, + JSON.stringify({ posts: state.studyCommunityPosts, updatedAt: new Date().toISOString() }, null, 2), + 'utf8', + ) + }) + .catch(err => { + console.error('[study-community] failed to write discussion posts:', err) + }) +} + +export async function loadStudyCommunityFromDisk() { + return readFile(STUDY_COMMUNITY_FILE, 'utf8') + .then(raw => { + const parsed = JSON.parse(raw) + state.studyCommunityPosts = sanitizeStudyCommunityPosts(parsed?.posts ?? parsed) + }) + .catch(() => { + state.studyCommunityPosts = [] + }) +} + +// ── Study notes (per-user files) ─────────────────────────────────────────── + +export function getUserNotesFilePath(userId) { + return path.join(STUDY_NOTES_DIR, `${userId}.json`) +} + +export async function loadUserNotes(userId) { + if (state.studyNotesCache.has(userId)) return state.studyNotesCache.get(userId) + try { + const raw = await readFile(getUserNotesFilePath(userId), 'utf8') + const notes = sanitizeUserNotes(JSON.parse(raw)) + state.studyNotesCache.set(userId, notes) + return notes + } catch { + const notes = {} + state.studyNotesCache.set(userId, notes) + return notes + } +} + +export function queueUserNotesWrite(userId) { + const prev = state.studyNotesWriteQueues.get(userId) ?? Promise.resolve() + const next = prev + .then(async () => { + const notes = state.studyNotesCache.get(userId) ?? {} + await mkdir(STUDY_NOTES_DIR, { recursive: true }) + await writeFile(getUserNotesFilePath(userId), JSON.stringify(notes, null, 2), 'utf8') + }) + .catch(err => { + console.error(`[study-notes] failed to write notes for user ${userId}:`, err) + }) + state.studyNotesWriteQueues.set(userId, next) +} + +export async function migrateStudyNotesIfNeeded() { + try { + const raw = await readFile(STUDY_NOTES_FILE, 'utf8') + const parsed = JSON.parse(raw) + const notesByUser = parsed?.notesByUser ?? {} + const userIds = Object.keys(notesByUser) + if (userIds.length === 0) return + await mkdir(STUDY_NOTES_DIR, { recursive: true }) + let migrated = 0 + for (const [userId, notes] of Object.entries(notesByUser)) { + const sanitized = sanitizeUserNotes(notes) + if (Object.keys(sanitized).length === 0) continue + const filePath = getUserNotesFilePath(userId) + try { await readFile(filePath, 'utf8'); continue } catch { /* doesn't exist yet */ } + await writeFile(filePath, JSON.stringify(sanitized, null, 2), 'utf8') + migrated += 1 + } + if (migrated > 0) console.log(`[study-notes] migrated ${migrated} users to per-user files`) + } catch { /* no legacy file — nothing to migrate */ } +} + +// ── Study progress ───────────────────────────────────────────────────────── + +export function getUserProgressFilePath(userId) { + return path.join(STUDY_PROGRESS_DIR, `${userId}.json`) +} + +export async function loadUserProgress(userId) { + if (state.studyProgressCache.has(userId)) return state.studyProgressCache.get(userId) + try { + const raw = await readFile(getUserProgressFilePath(userId), 'utf8') + const progress = sanitizeStudyProgress(JSON.parse(raw)) + state.studyProgressCache.set(userId, progress) + return progress + } catch { + const progress = { byStudy: {}, updatedAt: new Date().toISOString() } + state.studyProgressCache.set(userId, progress) + return progress + } +} + +export function queueUserProgressWrite(userId) { + const prev = state.studyProgressWriteQueues.get(userId) ?? Promise.resolve() + const next = prev + .then(async () => { + const progress = state.studyProgressCache.get(userId) ?? { byStudy: {}, updatedAt: new Date().toISOString() } + await mkdir(STUDY_PROGRESS_DIR, { recursive: true }) + await writeFile(getUserProgressFilePath(userId), JSON.stringify(progress, null, 2), 'utf8') + }) + .catch(err => { + console.error(`[study-progress] failed to write progress for user ${userId}:`, err) + }) + state.studyProgressWriteQueues.set(userId, next) +} + +// ── Study reminders ──────────────────────────────────────────────────────── + +export function queueStudyRemindersWrite() { + state.studyRemindersWritePromise = state.studyRemindersWritePromise + .then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile(STUDY_REMINDERS_FILE, JSON.stringify(state.studyReminders, null, 2), 'utf8') + }) + .catch(err => { + console.error('[study-reminders] failed to write reminders:', err) + }) +} + +export async function loadStudyRemindersFromDisk() { + try { + const raw = await readFile(STUDY_REMINDERS_FILE, 'utf8') + state.studyReminders = sanitizeStudyReminders(JSON.parse(raw)) + } catch { + state.studyReminders = { users: {}, updatedAt: new Date().toISOString() } + } +} + +// ── Download counts ──────────────────────────────────────────────────────── + +export function queueDownloadCountsWrite() { + state.downloadCountsWritePromise = state.downloadCountsWritePromise + .then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile(DOWNLOAD_COUNTS_FILE, JSON.stringify(state.downloadCounts, null, 2), 'utf8') + }) + .catch(err => { + console.error('[download-counts] failed to write:', err) + }) +} + +export function loadDownloadCountsFromDisk() { + return readFile(DOWNLOAD_COUNTS_FILE, 'utf8') + .then(raw => { + const parsed = JSON.parse(raw) + state.downloadCounts = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {} + }) + .catch(() => { + state.downloadCounts = {} + }) +} + +export function incrementDownloadCount(resourceKey) { + state.downloadCounts[resourceKey] = (state.downloadCounts[resourceKey] ?? 0) + 1 + queueDownloadCountsWrite() +} + +// ── Uploads ──────────────────────────────────────────────────────────────── + +export async function readUploadsMetadata() { + try { + const raw = await readFile(UPLOADS_META_FILE, 'utf8') + return JSON.parse(raw) + } catch { + return {} + } +} + +export async function writeUploadsMetadata(metadata) { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile(UPLOADS_META_FILE, JSON.stringify(metadata, null, 2), 'utf8') +} + +export async function listUploadedAssets() { + await mkdir(UPLOADS_DIR, { recursive: true }) + const files = await readdir(UPLOADS_DIR) + const imageFiles = files.filter(name => /\.(png|jpe?g|webp|gif|pdf|docx?)$/i.test(name)).sort() + const metadata = await readUploadsMetadata() + + const withStats = await Promise.all(imageFiles.map(async filename => { + const info = await stat(path.join(UPLOADS_DIR, filename)) + return { + filename, + url: `/uploads/${filename}`, + sizeBytes: info.size, + updatedAt: info.mtime.toISOString(), + tags: Array.isArray(metadata[filename]) ? metadata[filename].filter(tag => typeof tag === 'string') : [], + } + })) + + return withStats +} + +// ── Backups ──────────────────────────────────────────────────────────────── + +export async function createBackupSnapshot(reason = 'scheduled') { + try { + await mkdir(BACKUP_DIR, { recursive: true }) + const stamp = new Date().toISOString().replace(/[:.]/g, '-') + const backupPath = path.join(BACKUP_DIR, `snapshot-${stamp}-${reason}.json`) + + const payload = { + createdAt: new Date().toISOString(), + reason, + adminContent: null, + draftContent: null, + podcastChecklist: state.podcastChecklist, + publishState: state.publishState, + hitStats: state.hitStats, + visitorStats: state.visitorStats, + contactSubmissions: state.contactSubmissions, + studyCommunityPosts: state.studyCommunityPosts, + replyTemplates: state.replyTemplates, + replyHistory: state.replyHistory, + } + + try { + const contentRaw = await readFile(DATA_FILE, 'utf8') + payload.adminContent = JSON.parse(contentRaw) + } catch { + payload.adminContent = null + } + + try { + const draftRaw = await readFile(DRAFT_DATA_FILE, 'utf8') + payload.draftContent = JSON.parse(draftRaw) + } catch { + payload.draftContent = null + } + + await writeFile(backupPath, JSON.stringify(payload, null, 2), 'utf8') + + const files = (await readdir(BACKUP_DIR)).filter(name => name.endsWith('.json')).sort() + const maxFiles = BACKUP_RETENTION_DAYS + if (files.length > maxFiles) { + const toDelete = files.slice(0, files.length - maxFiles) + await Promise.all(toDelete.map(name => unlink(path.join(BACKUP_DIR, name)).catch(() => {}))) + } + + state.lastBackupStatus = { ok: true, at: new Date().toISOString(), error: null, file: path.basename(backupPath) } + } catch (err) { + state.lastBackupStatus = { ok: false, at: new Date().toISOString(), error: String(err), file: null } + console.error('[backup] failed to create snapshot:', err) + } +} + +export async function listBackupFiles() { + await mkdir(BACKUP_DIR, { recursive: true }) + const files = (await readdir(BACKUP_DIR)).filter(name => name.endsWith('.json')).sort().reverse() + return files +} + +export async function readBackupPreview(filename) { + if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..') || !filename.endsWith('.json')) { + throw new Error('Invalid backup filename') + } + + const fullPath = path.join(BACKUP_DIR, filename) + const [fileInfo, raw] = await Promise.all([ + stat(fullPath), + readFile(fullPath, 'utf8'), + ]) + const parsed = JSON.parse(raw) + + return { + filename, + sizeBytes: fileInfo.size, + createdAt: typeof parsed?.createdAt === 'string' ? parsed.createdAt : null, + reason: typeof parsed?.reason === 'string' ? parsed.reason : 'unknown', + adminUpdatedAt: typeof parsed?.adminContent?.updatedAt === 'string' ? parsed.adminContent.updatedAt : null, + totalHits: Number(parsed?.hitStats?.totalHits) || 0, + totalVisits: Number(parsed?.visitorStats?.totalVisits) || 0, + } +} + +export async function listBackupPreviews() { + const files = await listBackupFiles() + const previews = await Promise.all(files.map(async filename => { + try { + return await readBackupPreview(filename) + } catch { + return { + filename, + sizeBytes: 0, + createdAt: null, + reason: 'unknown', + adminUpdatedAt: null, + totalHits: 0, + totalVisits: 0, + } + } + })) + return previews +} + +export async function restoreFromBackup(filename) { + if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..') || !filename.endsWith('.json')) { + throw new Error('Invalid backup filename') + } + + const fullPath = path.join(BACKUP_DIR, filename) + const raw = await readFile(fullPath, 'utf8') + const parsed = JSON.parse(raw) + + await createBackupSnapshot('pre-restore') + + if (parsed?.adminContent && typeof parsed.adminContent === 'object') { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile(DATA_FILE, JSON.stringify(parsed.adminContent, null, 2), 'utf8') + } + + if (parsed?.draftContent && typeof parsed.draftContent === 'object') { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile(DRAFT_DATA_FILE, JSON.stringify(parsed.draftContent, null, 2), 'utf8') + } + + if (parsed?.publishState && typeof parsed.publishState === 'object') { + state.publishState = { + draftUpdatedAt: typeof parsed.publishState.draftUpdatedAt === 'string' ? parsed.publishState.draftUpdatedAt : null, + publishedAt: typeof parsed.publishState.publishedAt === 'string' ? parsed.publishState.publishedAt : null, + } + } + + state.hitStats = sanitizeLoadedHitStats(parsed?.hitStats) + state.visitorStats = sanitizeLoadedVisitorStats(parsed?.visitorStats) + state.contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions) + state.replyTemplates = sanitizeReplyTemplates(parsed?.replyTemplates) + state.replyHistory = sanitizeReplyHistory(parsed?.replyHistory) + state.podcastChecklist = sanitizePodcastChecklist(parsed?.podcastChecklist) + + queueHitStatsWrite() + queueVisitorStatsWrite() + queueContactSubmissionsWrite() + queueReplyTemplatesWrite() + queueReplyHistoryWrite() + queuePodcastChecklistWrite() + + await Promise.all([ + state.hitStatsWritePromise, + state.visitorStatsWritePromise, + state.contactSubmissionsWritePromise, + state.replyTemplatesWritePromise, + state.replyHistoryWritePromise, + state.podcastChecklistWritePromise, + ]) + await refreshContentCaches() + await createBackupSnapshot('post-restore') +} + +// ── Sanitize helpers used by load functions ──────────────────────────────── +// (These are defined here rather than study-helpers to avoid circular imports) + +export function normalizeMessageType(value) { + if (value === 'question' || value === 'testimony' || value === 'topic') return value + return 'general' +} + +function createEmailDeliveryState(status = 'pending') { + return { + status, + lastEventAt: null, + lastEventType: null, + resendEmailId: null, + error: null, + } +} + +function normalizeEmailDeliveryState(value, fallbackStatus = 'pending') { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return createEmailDeliveryState(fallbackStatus) + } + return { + status: typeof value.status === 'string' && value.status.trim() ? value.status.trim().slice(0, 40) : fallbackStatus, + lastEventAt: typeof value.lastEventAt === 'string' ? value.lastEventAt : null, + lastEventType: typeof value.lastEventType === 'string' ? value.lastEventType.trim().slice(0, 120) : null, + resendEmailId: typeof value.resendEmailId === 'string' && value.resendEmailId.trim() ? value.resendEmailId.trim().slice(0, 200) : null, + error: typeof value.error === 'string' && value.error.trim() ? value.error.trim().slice(0, 600) : null, + } +} + +export function normalizeContactEmailStatus(value, subscribe) { + const base = value && typeof value === 'object' && !Array.isArray(value) ? value : {} + return { + welcome: normalizeEmailDeliveryState(base.welcome, subscribe === true ? 'pending' : 'not-requested'), + adminNotification: normalizeEmailDeliveryState(base.adminNotification, 'pending'), + adminReply: normalizeEmailDeliveryState(base.adminReply, 'idle'), + } +} + +function sanitizeLoadedContactSubmissions(value) { + if (!Array.isArray(value)) return [] + return value + .slice(0, MAX_CONTACT_SUBMISSIONS) + .filter(entry => entry && typeof entry === 'object') + .map(entry => ({ + id: typeof entry.id === 'string' && entry.id.trim() ? entry.id.trim() : randomUUID(), + submittedAt: typeof entry.submittedAt === 'string' ? entry.submittedAt : new Date().toISOString(), + name: typeof entry.name === 'string' ? entry.name.trim().slice(0, 200) : '', + email: typeof entry.email === 'string' ? entry.email.trim().slice(0, 320) : '', + message: typeof entry.message === 'string' ? entry.message.trim().slice(0, 3000) : '', + messageType: normalizeMessageType(entry.messageType), + subscribe: entry.subscribe === true, + archived: entry.archived === true, + emailStatus: normalizeContactEmailStatus(entry.emailStatus, entry.subscribe === true), + })) +} + +function sanitizeLoadedHitStats(value) { + return { + totalHits: Number(value?.totalHits) || 0, + firstHitAt: typeof value?.firstHitAt === 'string' ? value.firstHitAt : null, + lastHitAt: typeof value?.lastHitAt === 'string' ? value.lastHitAt : null, + byPath: value?.byPath && typeof value.byPath === 'object' ? value.byPath : {}, + byDay: value?.byDay && typeof value.byDay === 'object' ? value.byDay : {}, + } +} + +function sanitizeLoadedVisitorStats(value) { + return { + totalVisits: Number(value?.totalVisits) || 0, + uniqueVisitors: Number(value?.uniqueVisitors) || 0, + returningVisits: Number(value?.returningVisits) || 0, + firstVisitAt: typeof value?.firstVisitAt === 'string' ? value.firstVisitAt : null, + lastVisitAt: typeof value?.lastVisitAt === 'string' ? value.lastVisitAt : null, + visitors: value?.visitors && typeof value.visitors === 'object' ? value.visitors : {}, + recentVisits: Array.isArray(value?.recentVisits) ? value.recentVisits.slice(0, MAX_RECENT_VISITS) : [], + geoCacheByIp: value?.geoCacheByIp && typeof value.geoCacheByIp === 'object' ? value.geoCacheByIp : {}, + } +} + +export function sanitizeReplyTemplates(value) { + if (!Array.isArray(value)) return [...DEFAULT_REPLY_TEMPLATES] + const out = value + .filter(item => item && typeof item === 'object') + .map(item => ({ + id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), + label: typeof item.label === 'string' ? item.label.trim().slice(0, 80) : '', + subject: typeof item.subject === 'string' ? item.subject.trim().slice(0, 180) : '', + message: typeof item.message === 'string' ? item.message.trim().slice(0, 6000) : '', + })) + .filter(item => item.label && item.subject && item.message) + return out.length > 0 ? out : [...DEFAULT_REPLY_TEMPLATES] +} + +export function sanitizeReplyHistory(value) { + if (!Array.isArray(value)) return [] + return value + .filter(item => item && typeof item === 'object') + .slice(0, 500) + .map(item => ({ + id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), + submissionId: typeof item.submissionId === 'string' ? item.submissionId : '', + toEmail: typeof item.toEmail === 'string' ? item.toEmail.trim().slice(0, 320) : '', + toName: typeof item.toName === 'string' ? item.toName.trim().slice(0, 200) : '', + fromEmail: typeof item.fromEmail === 'string' ? item.fromEmail.trim().slice(0, 320) : 'hello@versebyversewithnate.us', + subject: typeof item.subject === 'string' ? item.subject.trim().slice(0, 180) : '', + preview: typeof item.preview === 'string' ? item.preview.trim().slice(0, 500) : '', + sentAt: typeof item.sentAt === 'string' ? item.sentAt : new Date().toISOString(), + })) +} + +function sanitizeChecklistTask(task) { + const label = typeof task?.label === 'string' ? task.label.trim().slice(0, 120) : '' + if (!label) return null + const phase = task?.phase === 'post' ? 'post' : 'pre' + const id = typeof task?.id === 'string' && task.id.trim() ? task.id.trim() : randomUUID() + return { id, label, phase } +} + +export function sanitizePodcastChecklist(value) { + const fallback = buildDefaultPodcastChecklist() + const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {} + + const taskInput = Array.isArray(source.tasks) ? source.tasks : fallback.tasks + const seenTaskIds = new Set() + const tasks = [] + + for (const item of taskInput) { + const safeTask = sanitizeChecklistTask(item) + if (!safeTask) continue + if (seenTaskIds.has(safeTask.id)) continue + seenTaskIds.add(safeTask.id) + tasks.push(safeTask) + } + + if (tasks.length === 0) { + for (const task of fallback.tasks) { + tasks.push({ ...task }) + seenTaskIds.add(task.id) + } + } + + const taskIds = tasks.map(task => task.id) + const episodesInput = Array.isArray(source.episodes) ? source.episodes : fallback.episodes + const episodes = [] + + for (const item of episodesInput) { + if (!item || typeof item !== 'object' || Array.isArray(item)) continue + const id = typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID() + const series = typeof item.series === 'string' ? item.series.trim().slice(0, 80) : '' + const title = typeof item.title === 'string' ? item.title.trim().slice(0, 180) : '' + const rawEpisodeNumber = Number(item.episodeNumber) + const episodeNumber = Number.isFinite(rawEpisodeNumber) && rawEpisodeNumber >= 0 + ? Math.round(rawEpisodeNumber) + : null + const datePublished = typeof item.datePublished === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(item.datePublished.trim()) + ? item.datePublished.trim() + : '' + const sourceTasks = item.tasks && typeof item.tasks === 'object' && !Array.isArray(item.tasks) + ? item.tasks + : {} + const taskState = {} + for (const taskId of taskIds) { + taskState[taskId] = sourceTasks[taskId] === true + } + episodes.push({ id, series, episodeNumber, title, datePublished, expanded: item.expanded === true, tasks: taskState }) + } + + if (episodes.length === 0) { + return fallback + } + + return { tasks, episodes } +} + +function normalizeStudyUsername(value) { + if (typeof value !== 'string') return '' + return value.trim().toLowerCase() +} + +function isValidStudyUsername(value) { + return /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(value) && value.length <= 254 +} + +function normalizeStudySlug(value) { + if (typeof value !== 'string') return '' + const trimmed = value.trim().toLowerCase() + return /^[a-z0-9-]{1,80}$/.test(trimmed) ? trimmed : '' +} + +export function sanitizeStudyUsers(value) { + if (!Array.isArray(value)) return [] + const out = [] + const seen = new Set() + + for (const item of value) { + const username = normalizeStudyUsername(item?.username) + const passwordHash = typeof item?.passwordHash === 'string' ? item.passwordHash.trim() : '' + if (!isValidStudyUsername(username) || !passwordHash || seen.has(username)) continue + const enrolledStudySlugs = Array.isArray(item?.enrolledStudySlugs) + ? Array.from(new Set(item.enrolledStudySlugs.map(normalizeStudySlug).filter(Boolean))).slice(0, MAX_STUDY_ENROLLMENTS_PER_USER) + : [] + const displayName = typeof item?.displayName === 'string' ? item.displayName.trim().slice(0, 80) : '' + const subscribeNewsletter = item?.subscribeNewsletter !== false + const studyRemindersEnabled = item?.studyRemindersEnabled === true + const pendingEmailChange = item?.pendingEmailChange && typeof item.pendingEmailChange === 'object' && !Array.isArray(item.pendingEmailChange) + ? { + newEmail: isValidStudyUsername(normalizeStudyUsername(item.pendingEmailChange.newEmail)) + ? normalizeStudyUsername(item.pendingEmailChange.newEmail) + : '', + tokenHash: typeof item.pendingEmailChange.tokenHash === 'string' ? item.pendingEmailChange.tokenHash.trim() : '', + expiresAt: typeof item.pendingEmailChange.expiresAt === 'number' ? item.pendingEmailChange.expiresAt : 0, + requestedAt: typeof item.pendingEmailChange.requestedAt === 'string' ? item.pendingEmailChange.requestedAt : null, + } + : null + seen.add(username) + out.push({ + id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), + username, + passwordHash, + displayName, + subscribeNewsletter, + studyRemindersEnabled, + enrolledStudySlugs, + avatarUrl: typeof item?.avatarUrl === 'string' ? item.avatarUrl.trim() : '', + createdAt: typeof item?.createdAt === 'string' ? item.createdAt : null, + updatedAt: typeof item?.updatedAt === 'string' ? item.updatedAt : null, + lastLoginAt: typeof item?.lastLoginAt === 'string' ? item.lastLoginAt : null, + pendingEmailChange, + twoFaMethod: item?.twoFaMethod === 'app' || item?.twoFaMethod === 'email' ? item.twoFaMethod : null, + totpSecret: typeof item?.totpSecret === 'string' && item.totpSecret ? item.totpSecret : null, + totpVerified: item?.totpVerified === true, + totpEnabledAt: typeof item?.totpEnabledAt === 'string' ? item.totpEnabledAt : null, + totpRecoveryCodes: Array.isArray(item?.totpRecoveryCodes) ? item.totpRecoveryCodes.filter(h => typeof h === 'string') : [], + totpSecretPending: typeof item?.totpSecretPending === 'string' ? item.totpSecretPending : undefined, + }) + } + return out.slice(0, MAX_STUDY_USERS) +} + +export function sanitizeUserNotes(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + const out = {} + let count = 0 + for (const [sectionId, note] of Object.entries(value)) { + if (count >= MAX_STUDY_NOTES_PER_USER) break + if (!/^[a-z0-9-]{1,80}$/i.test(sectionId)) continue + if (typeof note !== 'string') continue + const trimmed = note.trim().slice(0, MAX_STUDY_NOTE_LENGTH) + if (!trimmed) continue + out[sectionId] = trimmed + count += 1 + } + return out +} + +export function sanitizeStudyProgress(value) { + const defaultResult = { byStudy: {}, updatedAt: new Date().toISOString() } + if (!value || typeof value !== 'object') return defaultResult + + const progress = { byStudy: {}, updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : new Date().toISOString() } + if (value.byStudy && typeof value.byStudy === 'object') { + for (const [studySlug, studyData] of Object.entries(value.byStudy)) { + if (typeof studySlug !== 'string' || !studySlug.trim()) continue + const completedSectionIds = Array.isArray(studyData?.completedSectionIds) + ? studyData.completedSectionIds.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim()) + : [] + const quizAnswers = studyData?.quizAnswers && typeof studyData?.quizAnswers === 'object' && !Array.isArray(studyData.quizAnswers) + ? Object.fromEntries( + Object.entries(studyData.quizAnswers) + .filter(([sectionId]) => typeof sectionId === 'string' && sectionId.trim()) + .map(([sectionId, answers]) => [ + sectionId.trim(), + Array.isArray(answers) + ? answers.filter(answer => typeof answer === 'string').map(answer => answer.trim()) + : [], + ]) + ) + : {} + progress.byStudy[studySlug.trim().toLowerCase()] = { + completedSectionIds: Array.from(new Set(completedSectionIds)), + quizAnswers, + } + } + } + return progress +} + +export function sanitizeStudyReminders(value) { + const defaultResult = { users: {}, updatedAt: new Date().toISOString() } + if (!value || typeof value !== 'object' || Array.isArray(value)) return defaultResult + + const reminders = { users: {}, updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : new Date().toISOString() } + if (value.users && typeof value.users === 'object') { + for (const [userId, userData] of Object.entries(value.users)) { + if (typeof userId !== 'string' || !userId.trim()) continue + const studies = typeof userData === 'object' && userData && !Array.isArray(userData) ? userData : {} + const normalizedStudies = {} + for (const [studySlug, sectionIds] of Object.entries(studies)) { + if (typeof studySlug !== 'string' || !studySlug.trim()) continue + const ids = Array.isArray(sectionIds) + ? sectionIds.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim()) + : [] + if (ids.length > 0) normalizedStudies[studySlug.trim().toLowerCase()] = Array.from(new Set(ids)) + } + reminders.users[userId.trim()] = normalizedStudies + } + } + return reminders +} + +function sanitizeStudyCommunityReply(reply) { + if (!reply || typeof reply !== 'object' || Array.isArray(reply)) return null + const message = typeof reply.message === 'string' ? reply.message.trim().slice(0, 3000) : '' + if (!message) return null + return { + id: typeof reply.id === 'string' && reply.id.trim() ? reply.id.trim() : randomUUID(), + authorUserId: typeof reply.authorUserId === 'string' && reply.authorUserId.trim() ? reply.authorUserId.trim() : '', + authorName: typeof reply.authorName === 'string' ? reply.authorName.trim().slice(0, 120) : '', + message, + createdAt: typeof reply.createdAt === 'string' ? reply.createdAt : new Date().toISOString(), + } +} + +export function sanitizeStudyCommunityPosts(value) { + if (!Array.isArray(value)) return [] + return value + .filter(item => item && typeof item === 'object' && !Array.isArray(item)) + .map(item => { + const studySlug = normalizeStudySlug(item.studySlug) + const sectionId = typeof item.sectionId === 'string' && /^[a-z0-9-]{1,80}$/i.test(item.sectionId) ? item.sectionId.trim() : '' + const message = typeof item.message === 'string' ? item.message.trim().slice(0, 3000) : '' + const replies = Array.isArray(item.replies) + ? item.replies.map(sanitizeStudyCommunityReply).filter(Boolean).slice(0, 50) + : [] + if (!studySlug || !message) return null + return { + id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), + studySlug, + sectionId, + authorUserId: typeof item.authorUserId === 'string' && item.authorUserId.trim() ? item.authorUserId.trim() : '', + authorName: typeof item.authorName === 'string' ? item.authorName.trim().slice(0, 120) : '', + message, + createdAt: typeof item.createdAt === 'string' ? item.createdAt : new Date().toISOString(), + replies, + } + }) + .filter(Boolean) +} diff --git a/server/email.js b/server/email.js new file mode 100644 index 0000000..d980b00 --- /dev/null +++ b/server/email.js @@ -0,0 +1,458 @@ +import { Resend } from 'resend' +import { escapeHtml, buildAbsoluteUrl, splitName } from './helpers.js' +import { + DEFAULT_RESEND_FROM, + DEFAULT_SEO, +} from './config.js' +import { state } from './state.js' + +// ── Address helpers ──────────────────────────────────────────────────────── + +export function getResendFromAddress() { + return process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM +} + +export function getResendReplyToAddress() { + return process.env.RESEND_REPLY_TO ?? 'hello@versebyversewithnate.us' +} + +export function getResendInboxAddress() { + return process.env.RESEND_TO ?? 'hello@versebyversewithnate.us' +} + +export function getCanonicalBaseUrl() { + const configured = state.cachedSiteContent?.seo?.canonicalUrl ?? DEFAULT_SEO.canonicalUrl + if (typeof configured !== 'string' || !configured.trim()) return DEFAULT_SEO.canonicalUrl + return configured.trim() +} + +export function getAddressDomain(addressValue) { + const raw = String(addressValue || '').trim() + if (!raw) return '' + const candidate = raw.includes('<') && raw.includes('>') + ? raw.slice(raw.lastIndexOf('<') + 1, raw.lastIndexOf('>')).trim() + : raw + const at = candidate.lastIndexOf('@') + if (at <= 0 || at === candidate.length - 1) return '' + return candidate.slice(at + 1).toLowerCase() +} + +export function logResendEmailAlignmentWarnings() { + const warnings = [] + const fromAddress = getResendFromAddress() + const replyToAddress = getResendReplyToAddress() + const fromDomain = getAddressDomain(fromAddress) + const replyDomain = getAddressDomain(replyToAddress) + const hasApiKey = Boolean(process.env.RESEND_API_KEY) + + if (!hasApiKey) warnings.push('RESEND_API_KEY is missing. Contact and reply emails cannot send.') + if (!fromDomain) warnings.push('RESEND_FROM is missing or malformed. Use a verified domain sender identity.') + if (fromDomain.endsWith('resend.dev')) warnings.push('RESEND_FROM uses resend.dev. Move to your own verified domain for best deliverability.') + if (fromDomain && replyDomain && fromDomain !== replyDomain) warnings.push('RESEND_FROM and RESEND_REPLY_TO use different domains. This can weaken alignment.') + if (!process.env.RESEND_WEBHOOK_TOKEN) warnings.push('RESEND_WEBHOOK_TOKEN is not set. Delivery webhooks are not authenticated.') + if (hasApiKey) warnings.push('Verify SPF, DKIM, and DMARC for the sender domain to improve inbox placement.') + + if (warnings.length > 0) { + console.warn('[email-health] Resend alignment checks:') + for (const warning of warnings) { + console.warn(`[email-health] - ${warning}`) + } + } +} + +// ── Send helpers ─────────────────────────────────────────────────────────── + +export async function sendResendEmailWithRetry({ resend, payload, context, maxAttempts = 2 }) { + let lastError = null + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const result = await resend.emails.send(payload) + if (!result?.error) return result + lastError = result.error + if (attempt < maxAttempts) { + await new Promise(resolve => setTimeout(resolve, 250 * attempt)) + } + } catch (err) { + lastError = err + if (attempt < maxAttempts) { + await new Promise(resolve => setTimeout(resolve, 250 * attempt)) + } + } + } + throw lastError ?? new Error(`[${context}] email send failed`) +} + +export async function syncContactToResend(name, email) { + if (!process.env.RESEND_API_KEY) return + const { firstName, lastName } = splitName(name) + const contactResend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY) + try { + const { error: contactError } = await contactResend.contacts.create({ + email, + firstName, + lastName, + unsubscribed: false, + ...(process.env.RESEND_SEGMENT_ID ? { segments: [{ id: process.env.RESEND_SEGMENT_ID }] } : {}), + }) + if (contactError) { + const { error: updateError } = await contactResend.contacts.update({ email, firstName, lastName, unsubscribed: false }) + if (updateError) console.error('[resend] contact sync error:', updateError) + } + } catch (err) { + console.error('[resend] contact sync exception:', err) + } +} + +// ── Email HTML builders ──────────────────────────────────────────────────── + +export function buildBrandedEmailHtml({ title, eyebrow, bodyHtml, ctaLabel, ctaUrl, footerHtml }) { + const ctaBlock = ctaLabel && ctaUrl + ? `

${escapeHtml(ctaLabel)}

` + : '' + + return ( + `
` + + `` + + `
` + + `` + + `` + + `` + + `` + + `
` + + `

${escapeHtml(eyebrow ?? 'Verse by Verse with Nate')}

` + + `

${escapeHtml(title)}

` + + `
` + + `
${bodyHtml}
` + + `${ctaBlock}` + + `
${footerHtml ?? ''}
` + + `
` + + `
` + ) +} + +export function buildContactWelcomeEmailTemplate({ + greetingName, + welcomeIntro, + welcomeCurrentSeries, + welcomeStartHereTitle, + welcomeStartHereSummary, + welcomeExpect1, + welcomeExpect2, + welcomeExpect3, + welcomeScripture, + welcomeScriptureRef, + welcomeSignoff, + welcomeHeading, + welcomeSpotifyUrl, + welcomeAppleUrl, + welcomeAmazonUrl, + welcomeWebsiteUrl, + welcomeEpisodeUrl, + welcomeImageUrl, + welcomeSpotifyBtnLabel = 'Listen on Spotify', + welcomeAppleBtnLabel = 'Apple Podcasts', + welcomeStartHereLinkLabel = 'Open Start Here page', +}) { + const welcomeSignoffHtml = escapeHtml(welcomeSignoff).replace(/\n/g, '
') + + return { + text: + `Welcome to Verse by Verse with Nate!\n\n` + + `${greetingName ? `Glad you're here, ${greetingName}.` : "Glad you're here."}\n\n` + + `${welcomeIntro}\n\n` + + `${welcomeCurrentSeries}\n\n` + + `Start here: ${welcomeEpisodeUrl}\n` + + `${welcomeStartHereTitle}\n` + + `${welcomeStartHereSummary}\n` + + `Spotify: ${welcomeSpotifyUrl}\n` + + `Apple Podcasts: ${welcomeAppleUrl}\n` + + `Amazon Music: ${welcomeAmazonUrl}\n` + + `Website: ${welcomeWebsiteUrl}\n\n` + + `What to expect:\n` + + `- ${welcomeExpect1}\n` + + `- ${welcomeExpect2}\n` + + `- ${welcomeExpect3}\n\n` + + `${welcomeScripture}\n${welcomeScriptureRef}\n\n` + + `${welcomeSignoff}`, + html: + `
` + + `` + + `
` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `
` + + `Verse by Verse with Nate` + + `

Verse by Verse with Nate

` + + `

Verse by verse. Nugget by nugget.

` + + `
` + + `

Welcome

` + + `

${welcomeHeading}

` + + `
` + + `
` + + `

${escapeHtml(welcomeIntro)}

` + + `

${escapeHtml(welcomeCurrentSeries)}

` + + `

If you’re just joining us, the best place to start is Episode 1. It sets the table for everything that follows.

` + + `
` + + `

Start here

` + + `

${escapeHtml(welcomeStartHereTitle)}

` + + `

${escapeHtml(welcomeStartHereSummary)}

` + + `` + + `` + + `` + + `
${escapeHtml(welcomeSpotifyBtnLabel)}${escapeHtml(welcomeAppleBtnLabel)}
` + + `

${escapeHtml(welcomeStartHereLinkLabel)}

` + + `
` + + `

What to expect

` + + `

${escapeHtml(welcomeExpect1)}

` + + `

${escapeHtml(welcomeExpect2)}

` + + `

${escapeHtml(welcomeExpect3)}

` + + `
` + + `
` + + `

“${escapeHtml(welcomeScripture)}”

` + + `

${escapeHtml(welcomeScriptureRef)}

` + + `
` + + `
` + + `

Find the podcast on

` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `
Spotify·Apple Podcasts·Amazon Music·Website
` + + `

You’re receiving this because you subscribed to Verse by Verse with Nate.

` + + `

${welcomeSignoffHtml}

` + + `
` + + `
` + + `
`, + } +} + +export function buildContactAdminNotificationTemplate({ + normalizedMessageType, + trimmedName, + trimmedEmail, + submittedAt, + trimmedMessage, +}) { + return { + subject: `Verse by Verse contact (${normalizedMessageType}): ${trimmedName}`, + text: + `New contact form submission\n\n` + + `Message Type: ${normalizedMessageType}\n` + + `Name: ${trimmedName}\n` + + `Email: ${trimmedEmail}\n` + + `Submitted: ${submittedAt}\n\n` + + `Message:\n${trimmedMessage}`, + html: + `
` + + `
` + + `
` + + `
Verse by Verse with Nate
` + + `

New Contact Form Submission

` + + `
` + + `
` + + `

A new message was sent from the website contact form. Reply directly to this email to respond to ${escapeHtml(trimmedName)}.

` + + `` + + `` + + `` + + `` + + `` + + `
Type${escapeHtml(normalizedMessageType)}
Name${escapeHtml(trimmedName)}
Email${escapeHtml(trimmedEmail)}
Submitted${escapeHtml(submittedAt)}
` + + `
` + + `
Message
` + + `
${escapeHtml(trimmedMessage)}
` + + `
` + + `
` + + `
` + + `
`, + } +} + +export function buildAdminReplyTemplate({ recipientName, message }) { + const safeRecipientName = escapeHtml(recipientName || 'friend') + const safeMessage = escapeHtml(message).replace(/\n/g, '
') + return ` +
+ + + + +
+ + + + + + + + + + +
+
Verse by Verse with Nate
+

A Personal Reply

+
+

Hi ${safeRecipientName},

+
${safeMessage}
+

Grace and peace,
Verse by Verse with Nate

+
+

From: hello@versebyversewithnate.us

+
+
+
+ ` +} + +// ── Transactional email senders ──────────────────────────────────────────── + +export async function sendStudyWelcomeEmail(email, displayName) { + if (!process.env.RESEND_API_KEY) return + try { + const resend = new Resend(process.env.RESEND_API_KEY) + const cfg = state.cachedSiteContent ?? {} + const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend' + const baseUrl = getCanonicalBaseUrl() + const accountUrl = buildAbsoluteUrl(baseUrl, '/study/account') + const subject = cfg.studyWelcomeEmailSubject?.trim() || 'Welcome to the Study Community' + const bodyText = cfg.studyWelcomeEmailBody?.trim() || 'Your student account is ready. Open your studies and continue learning, or manage your account details anytime.' + const ctaLabel = cfg.studyWelcomeEmailCtaLabel?.trim() || 'Open Studies' + const studiesUrl = buildAbsoluteUrl(baseUrl, cfg.studyWelcomeEmailCtaPath?.trim() || '/study') + const signoff = cfg.studyWelcomeEmailSignoff?.trim() || 'Grace and peace,\nVerse by Verse with Nate' + const bodyHtml = ( + `

Welcome, ${escapeHtml(namePart)}.

` + + `

${escapeHtml(bodyText)}

` + ) + const footerHtml = `

${escapeHtml(signoff).replace(/\n/g, '
')}

` + const { error } = await resend.emails.send({ + from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM, + to: [email], + subject, + text: `Welcome, ${namePart}.\n\n${bodyText}\n\nOpen studies: ${studiesUrl}\nManage account: ${accountUrl}\n\n${signoff}`, + html: buildBrandedEmailHtml({ + title: 'Welcome to the Study Community', + eyebrow: 'Study Account', + bodyHtml, + ctaLabel, + ctaUrl: studiesUrl, + footerHtml: footerHtml + `

Manage your account

`, + }), + }) + if (error) console.error('[study-signup] welcome email send error:', error) + } catch (err) { + console.error('[study-signup] welcome email exception:', err) + } +} + +export async function sendEmailOtp(email, code) { + if (!process.env.RESEND_API_KEY) return + try { + const resend = new Resend(process.env.RESEND_API_KEY) + const cfg = state.cachedSiteContent ?? {} + const subject = cfg.twoFaOtpEmailSubject?.trim() || 'Your sign-in code — Verse by Verse with Nate' + const bodyText = cfg.twoFaOtpEmailBody?.trim() || 'Your two-factor sign-in code is below. Enter it to complete sign-in.' + const expiryText = cfg.twoFaOtpEmailExpiry?.trim() || 'This code expires in 10 minutes. If you did not request this, you can ignore this message.' + await resend.emails.send({ + from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM, + to: [email], + subject, + text: `${bodyText}\n\n${code}\n\n${expiryText}\n\nVerse by Verse with Nate`, + html: buildBrandedEmailHtml({ + title: 'Your Sign-In Code', + eyebrow: 'Account Security', + bodyHtml: + `

${escapeHtml(bodyText)}

` + + `

${code}

` + + `

${escapeHtml(expiryText)}

`, + footerHtml: `

Verse by Verse with Nate

`, + }), + }) + } catch (err) { + console.error('[email-otp] send error:', err) + } +} + +export async function sendStudyAccountDeletedEmail(email, displayName) { + if (!process.env.RESEND_API_KEY) return + try { + const resend = new Resend(process.env.RESEND_API_KEY) + const cfg = state.cachedSiteContent ?? {} + const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend' + const baseUrl = getCanonicalBaseUrl() + const subject = cfg.studyDeletedEmailSubject?.trim() || 'Your study account was deleted' + const bodyText = cfg.studyDeletedEmailBody?.trim() || 'This confirms your study account and saved notes were deleted. If this was not you, please contact us immediately.' + const ctaLabel = cfg.studyDeletedEmailCtaLabel?.trim() || 'Create a New Account' + const signupUrl = buildAbsoluteUrl(baseUrl, cfg.studyDeletedEmailCtaPath?.trim() || '/study/signup') + const signoff = cfg.studyDeletedEmailSignoff?.trim() || 'Verse by Verse with Nate' + const bodyHtml = ( + `

Hi ${escapeHtml(namePart)},

` + + `

${escapeHtml(bodyText)}

` + ) + const footerHtml = `

${escapeHtml(signoff).replace(/\n/g, '
')}

` + const { error } = await resend.emails.send({ + from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM, + to: [email], + subject, + text: `Hi ${namePart},\n\n${bodyText}\n\nCreate a new account anytime: ${signupUrl}\n\n${signoff}`, + html: buildBrandedEmailHtml({ + title: 'Study Account Deleted', + eyebrow: 'Account Update', + bodyHtml, + ctaLabel, + ctaUrl: signupUrl, + footerHtml, + }), + }) + if (error) console.error('[study-account] delete email send error:', error) + } catch (err) { + console.error('[study-account] delete email exception:', err) + } +} + +export async function sendStudyReminderEmail(email, displayName, studyTitle, sectionTitle, sectionReference, sectionUrl) { + if (!process.env.RESEND_API_KEY) return + try { + const resend = new Resend(process.env.RESEND_API_KEY) + const cfg = state.cachedSiteContent ?? {} + const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend' + const subjectPrefix = cfg.studyReminderEmailSubjectPrefix?.trim() || 'New lesson available:' + const bodyText = cfg.studyReminderEmailBody?.trim() || 'A new lesson has been unlocked in your study track. Open it below to continue where you left off.' + const ctaLabel = cfg.studyReminderEmailCtaLabel?.trim() || 'Open the Lesson' + const signoff = cfg.studyReminderEmailSignoff?.trim() || 'Grace and peace,\nVerse by Verse with Nate' + const subject = process.env.RESEND_REMINDER_SUBJECT ?? `${subjectPrefix} ${sectionTitle}` + const bodyHtml = ( + `

Hi ${escapeHtml(namePart)},

` + + `

${escapeHtml(bodyText)}

` + + `

${escapeHtml(sectionTitle)} (${escapeHtml(sectionReference)}) — ${escapeHtml(studyTitle)}

` + ) + const footerHtml = `

${escapeHtml(signoff).replace(/\n/g, '
')}

` + const { error } = await resend.emails.send({ + from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM, + to: [email], + subject, + text: `Hi ${namePart},\n\n${bodyText}\n\n${sectionTitle} (${sectionReference}) — ${studyTitle}\n\nOpen it here: ${sectionUrl}\n\n${signoff}`, + html: buildBrandedEmailHtml({ + title: 'New Lesson Available', + eyebrow: 'Study Reminder', + bodyHtml, + ctaLabel, + ctaUrl: sectionUrl, + footerHtml, + }), + }) + if (error) console.error('[study-reminder] send error:', error) + } catch (err) { + console.error('[study-reminder] send exception:', err) + } +} diff --git a/server/routes/admin-assets.js b/server/routes/admin-assets.js new file mode 100644 index 0000000..93ac678 --- /dev/null +++ b/server/routes/admin-assets.js @@ -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 }) + }) +} diff --git a/server/routes/admin-auth.js b/server/routes/admin-auth.js new file mode 100644 index 0000000..08baaba --- /dev/null +++ b/server/routes/admin-auth.js @@ -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 }) + }) +} diff --git a/server/routes/admin-content.js b/server/routes/admin-content.js new file mode 100644 index 0000000..ebb0378 --- /dev/null +++ b/server/routes/admin-content.js @@ -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.' }) + } + }) +} diff --git a/server/routes/analytics.js b/server/routes/analytics.js new file mode 100644 index 0000000..d7bcf18 --- /dev/null +++ b/server/routes/analytics.js @@ -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, + }, + }) + }) +} diff --git a/server/routes/contact.js b/server/routes/contact.js new file mode 100644 index 0000000..b87d7fe --- /dev/null +++ b/server/routes/contact.js @@ -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': ``, + '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) + }) +} diff --git a/server/routes/downloads.js b/server/routes/downloads.js new file mode 100644 index 0000000..6731f20 --- /dev/null +++ b/server/routes/downloads.js @@ -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 }) + }) +} diff --git a/server/routes/episodes.js b/server/routes/episodes.js new file mode 100644 index 0000000..5d7b901 --- /dev/null +++ b/server/routes/episodes.js @@ -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 = /^$/.exec(raw.trim()) + return cdata ? cdata[1].trim() : raw.trim() +} + +function parseRssItems(xml, limit = Infinity) { + const items = [] + const itemRegex = /([\s\S]*?)<\/item>/g + let match + while ((match = itemRegex.exec(xml)) !== null && items.length < limit) { + const block = match[1] + const titleRaw = /([\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 ?? [] }) + } + }) +} diff --git a/server/routes/public.js b/server/routes/public.js new file mode 100644 index 0000000..3b6ab9e --- /dev/null +++ b/server/routes/public.js @@ -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 + + + + + + + + + + + + + + + + + +`) + }) + + // 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.') + } + }) +} diff --git a/server/routes/questions.js b/server/routes/questions.js new file mode 100644 index 0000000..20e50cc --- /dev/null +++ b/server/routes/questions.js @@ -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 }) + }) +} diff --git a/server/routes/study-account.js b/server/routes/study-account.js new file mode 100644 index 0000000..c408f0f --- /dev/null +++ b/server/routes/study-account.js @@ -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: `

${escapeHtml(emailChangeBody)}

`, + ctaLabel: emailChangeCtaLabel, + ctaUrl: verifyUrl, + footerHtml: `

Verse by Verse with Nate

`, + }), + }) + 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 }) + }) +} diff --git a/server/routes/study-auth.js b/server/routes/study-auth.js new file mode 100644 index 0000000..6394317 --- /dev/null +++ b/server/routes/study-auth.js @@ -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 }) + }) +} diff --git a/server/routes/study-data.js b/server/routes/study-data.js new file mode 100644 index 0000000..29b77ad --- /dev/null +++ b/server/routes/study-data.js @@ -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 }) + }) +} diff --git a/server/state.js b/server/state.js new file mode 100644 index 0000000..b818e4a --- /dev/null +++ b/server/state.js @@ -0,0 +1,69 @@ +import { + EMPTY_HIT_STATS, + EMPTY_VISITOR_STATS, + DEFAULT_PUBLISH_STATE, + DEFAULT_REPLY_TEMPLATES, + buildDefaultPodcastChecklist, +} from './config.js' + +export const state = { + cachedSiteContent: null, + cachedDraftSiteContent: null, + publishState: { ...DEFAULT_PUBLISH_STATE }, + + hitStats: { ...EMPTY_HIT_STATS }, + hitStatsWritePromise: Promise.resolve(), + lastHitStatsWrite: { ok: true, at: null, error: null }, + + visitorStats: { ...EMPTY_VISITOR_STATS }, + visitorStatsWritePromise: Promise.resolve(), + lastVisitorStatsWrite: { ok: true, at: null, error: null }, + + contactSubmissions: [], + contactSubmissionsWritePromise: Promise.resolve(), + + questions: [], + questionsWritePromise: Promise.resolve(), + + draftQuestions: null, + draftQuestionsWritePromise: Promise.resolve(), + + replyTemplates: [...DEFAULT_REPLY_TEMPLATES], + replyTemplatesWritePromise: Promise.resolve(), + + replyHistory: [], + replyHistoryWritePromise: Promise.resolve(), + + podcastChecklist: buildDefaultPodcastChecklist(), + podcastChecklistWritePromise: Promise.resolve(), + + studyUsers: [], + studyUsersWritePromise: Promise.resolve(), + + studyCommunityPosts: [], + studyCommunityWritePromise: Promise.resolve(), + + studyReminders: { users: {}, updatedAt: new Date().toISOString() }, + studyRemindersWritePromise: Promise.resolve(), + + downloadCounts: {}, + downloadCountsWritePromise: Promise.resolve(), + + lastBackupStatus: { ok: true, at: null, error: null, file: null }, + lastCachePurgeStatus: { ok: true, at: null, error: null }, + lastDeployHookStatus: { ok: true, at: null, error: null }, + + // In-memory caches (not persisted between restarts) + studyNotesCache: new Map(), // userId -> { [sectionId]: string } + studyNotesWriteQueues: new Map(), // userId -> Promise + studyProgressCache: new Map(), // userId -> { byStudy: ... } + studyProgressWriteQueues: new Map(), // userId -> Promise + + studySessions: new Map(), + studyTotpPendingTokens: new Map(), + emailOtpStore: new Map(), + + titusDownloadTokens: new Map(), + contactSubmitCooldownByEmail: new Map(), + resendEmailSubmissionIndex: new Map(), +} diff --git a/server/study-helpers.js b/server/study-helpers.js new file mode 100644 index 0000000..8e99cd1 --- /dev/null +++ b/server/study-helpers.js @@ -0,0 +1,659 @@ +import { createHash, randomUUID } from 'node:crypto' +import path from 'node:path' +import { parseCookies } from './helpers.js' +import { + STUDY_SESSION_COOKIE, + STUDY_SESSION_TTL_MS, + STUDY_TOTP_PENDING_TTL_MS, + EMAIL_OTP_TTL_MS, + EMAIL_OTP_MAX_ATTEMPTS, + CONTACT_EMAIL_COOLDOWN_MS, + DEFAULT_REDIRECT_RULES, +} from './config.js' +import { state } from './state.js' +import { queueStudyRemindersWrite } from './data.js' + +// ── Username / slug normalizers ──────────────────────────────────────────── + +export function normalizeStudyUsername(value) { + if (typeof value !== 'string') return '' + return value.trim().toLowerCase() +} + +export function normalizeStudySlug(value) { + if (typeof value !== 'string') return '' + const trimmed = value.trim().toLowerCase() + return /^[a-z0-9-]{1,80}$/.test(trimmed) ? trimmed : '' +} + +export function isValidStudyUsername(value) { + return /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(value) && value.length <= 254 +} + +export function normalizeLessonSectionId(value) { + if (typeof value !== 'string') return '' + const trimmed = value.trim().toLowerCase() + return /^[a-z0-9-]{1,80}$/.test(trimmed) ? trimmed : '' +} + +export function getStudySlugFromNoteId(sectionId) { + if (typeof sectionId !== 'string') return '' + const separatorIndex = sectionId.indexOf('--') + if (separatorIndex <= 0) return '' + return normalizeStudySlug(sectionId.slice(0, separatorIndex)) +} + +// ── Password / token hashing ─────────────────────────────────────────────── + +export function hashStudyPassword(password) { + return createHash('sha256').update(`study-user:${String(password)}`).digest('hex') +} + +export function hashEmailChangeToken(token) { + return createHash('sha256').update(`study-email-change:${String(token)}`).digest('hex') +} + +// ── Catalog / enrollment helpers ─────────────────────────────────────────── + +export function getStudyCatalog() { + const fallback = [ + { slug: 'colossians', title: 'Colossians: Rooted in Christ', status: 'active' }, + ] + const content = state.cachedSiteContent + if (!content || typeof content !== 'object') return fallback + + if (Array.isArray(content.studies) && content.studies.length > 0) { + const out = [] + const seen = new Set() + for (const study of content.studies) { + const slug = normalizeStudySlug(study?.slug) + if (!slug || seen.has(slug)) continue + seen.add(slug) + out.push({ + slug, + title: typeof study?.title === 'string' && study.title.trim() ? study.title.trim() : slug, + status: study?.status === 'planned' ? 'planned' : 'active', + }) + } + if (out.length > 0) return out + } + + return fallback +} + +export function isEnrollableStudySlug(studySlug) { + const normalized = normalizeStudySlug(studySlug) + if (!normalized) return false + return getStudyCatalog().some(study => study.slug === normalized && study.status !== 'planned') +} + +export function getStudyTitleBySlug(studySlug) { + const normalized = normalizeStudySlug(studySlug) + if (!normalized) return '' + const study = getStudyCatalog().find(item => item.slug === normalized) + return study?.title ?? '' +} + +export function isStudyUserEnrolled(user, studySlug) { + const normalized = normalizeStudySlug(studySlug) + if (!normalized || !user) return false + return Array.isArray(user.enrolledStudySlugs) && user.enrolledStudySlugs.includes(normalized) +} + +export function findStudyUserById(userId) { + if (typeof userId !== 'string' || !userId.trim()) return undefined + return state.studyUsers.find(user => user.id === userId) +} + +export function findStudyUserByUsername(username) { + return state.studyUsers.find(user => user.username === normalizeStudyUsername(username)) +} + +// ── Avatar ───────────────────────────────────────────────────────────────── + +export function getStudyAvatarUrl(subject) { + let customAvatar = '' + let username = '' + + if (subject && typeof subject === 'object') { + customAvatar = typeof subject.avatarUrl === 'string' ? subject.avatarUrl.trim() : '' + username = normalizeStudyUsername(subject.username) + } else if (typeof subject === 'string') { + username = normalizeStudyUsername(subject) + } + + if (customAvatar) return customAvatar + if (!username) return '' + const hash = createHash('md5').update(username).digest('hex') + return `https://www.gravatar.com/avatar/${hash}?d=identicon&s=96` +} + +// ── Session management ───────────────────────────────────────────────────── + +export function cookieFlags() { + return process.env.NODE_ENV === 'production' ? '; Secure' : '' +} + +export function createStudySession(userId) { + const token = randomUUID() + state.studySessions.set(token, { userId, expiresAt: Date.now() + STUDY_SESSION_TTL_MS }) + return token +} + +export function setStudySessionCookie(res, token) { + res.append( + 'Set-Cookie', + `${STUDY_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(STUDY_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`, + ) +} + +export function clearStudySessionCookie(res) { + res.append( + 'Set-Cookie', + `${STUDY_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`, + ) +} + +export function getStudyUserFromRequest(req) { + const cookies = parseCookies(req.headers.cookie) + const token = cookies[STUDY_SESSION_COOKIE] + if (!token) return null + + const session = state.studySessions.get(token) + if (!session || session.expiresAt <= Date.now()) { + state.studySessions.delete(token) + return null + } + + const user = state.studyUsers.find(item => item.id === session.userId) + if (!user) { + state.studySessions.delete(token) + return null + } + + session.expiresAt = Date.now() + STUDY_SESSION_TTL_MS + state.studySessions.set(token, session) + return user +} + +export function requireStudyAuth(req, res, next) { + const user = getStudyUserFromRequest(req) + if (!user) { + res.status(401).json({ message: 'Please sign in to save notes.' }) + return + } + req.studyUser = user + next() +} + +// ── TOTP pending tokens ──────────────────────────────────────────────────── + +export function createStudyTotpPendingToken(userId) { + const token = randomUUID() + state.studyTotpPendingTokens.set(token, { userId, expiresAt: Date.now() + STUDY_TOTP_PENDING_TTL_MS }) + return token +} + +export function consumeStudyTotpPendingToken(token) { + const entry = state.studyTotpPendingTokens.get(token) + if (!entry) return null + state.studyTotpPendingTokens.delete(token) + if (Date.now() > entry.expiresAt) return null + return entry.userId +} + +// ── Email OTP ────────────────────────────────────────────────────────────── + +export function generateEmailOtp() { + return String(Math.floor(100000 + Math.random() * 900000)) +} + +function hashEmailOtp(code) { + return createHash('sha256').update(String(code).trim()).digest('hex') +} + +export function storeEmailOtp(userId, code) { + state.emailOtpStore.set(userId, { codeHash: hashEmailOtp(code), expiresAt: Date.now() + EMAIL_OTP_TTL_MS, attempts: 0 }) +} + +export function verifyEmailOtp(userId, code) { + const entry = state.emailOtpStore.get(userId) + if (!entry) return 'no-code' + if (Date.now() > entry.expiresAt) { state.emailOtpStore.delete(userId); return 'expired' } + entry.attempts += 1 + if (entry.attempts > EMAIL_OTP_MAX_ATTEMPTS) { state.emailOtpStore.delete(userId); return 'too-many' } + if (hashEmailOtp(String(code).trim()) !== entry.codeHash) return 'wrong' + state.emailOtpStore.delete(userId) + return 'ok' +} + +// ── Titus download tokens ────────────────────────────────────────────────── + +export function createTitusDownloadToken(email) { + const token = randomUUID() + state.titusDownloadTokens.set(token, { + email, + expiresAt: Date.now() + (10 * 60 * 1000), + }) + return token +} + +export function consumeTitusDownloadToken(token) { + const entry = state.titusDownloadTokens.get(token) + if (!entry) return false + state.titusDownloadTokens.delete(token) + if (entry.expiresAt <= Date.now()) return false + return true +} + +// ── Release date helpers ─────────────────────────────────────────────────── + +export function getSectionReleaseTime(section) { + if (!section || typeof section !== 'object') return Number.NaN + const candidateValues = [section.releasedAt, section.releaseDate, section.availableAt, section.publishAt] + for (const candidate of candidateValues) { + if (typeof candidate !== 'string' || !candidate.trim()) continue + const releaseTime = Date.parse(candidate) + if (Number.isFinite(releaseTime)) return releaseTime + } + return Number.NaN +} + +export function getSectionReleaseDate(section) { + const releaseTime = getSectionReleaseTime(section) + if (!Number.isFinite(releaseTime)) return null + return new Date(releaseTime) +} + +export function isSectionReleased(section) { + const releaseTime = getSectionReleaseTime(section) + if (!Number.isFinite(releaseTime)) return false + return releaseTime <= Date.now() +} + +export function redactUnreleasedSection(section) { + if (!section || typeof section !== 'object') return section + if (isSectionReleased(section)) return section + return { + ...section, + passageText: '', + commentary: '', + greekNotes: [], + studyQuestions: [], + audioEmbedUrl: '', + } +} + +export function filterSiteContentByReleaseDate(siteContent) { + if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) return siteContent + + const filteredStudies = Array.isArray(siteContent.studies) + ? siteContent.studies.map(study => { + if (!study || typeof study !== 'object') return study + const sections = Array.isArray(study.sections) ? study.sections.map(redactUnreleasedSection) : [] + return { ...study, sections } + }) + : siteContent.studies + + const filteredLegacySections = Array.isArray(siteContent.colossiansStudySections) + ? siteContent.colossiansStudySections.map(redactUnreleasedSection) + : siteContent.colossiansStudySections + + return { + ...siteContent, + studies: filteredStudies, + colossiansStudySections: filteredLegacySections, + } +} + +// ── Analytics helpers ────────────────────────────────────────────────────── + +export function normalizeHitPath(pathname) { + if (!pathname || pathname === '') return '/' + if (pathname.length > 1 && pathname.endsWith('/')) { + return pathname.slice(0, -1) + } + return pathname +} + +export function shouldCountHit(req) { + if (req.method !== 'GET') return false + if (req.path.startsWith('/api/')) return false + if (req.path === '/admin' || req.path.startsWith('/admin/')) return false + if (req.path === '/favicon.ico') return false + const hasFileExt = path.extname(req.path) !== '' + if (hasFileExt) return false + const accept = req.get('accept') ?? '' + return accept.includes('text/html') || accept === '*/*' || accept === '' +} + +export function sanitizeUserAgent(userAgent) { + if (!userAgent || typeof userAgent !== 'string') return 'unknown' + return userAgent.trim().slice(0, 300) || 'unknown' +} + +export function detectDevice(userAgent) { + if (!userAgent || typeof userAgent !== 'string') return 'unknown' + const ua = userAgent.toLowerCase() + if (/tablet|ipad|playbook|silk|(android(?!.*mobile))/.test(ua)) return 'tablet' + if (/mobile|iphone|ipod|android|blackberry|opera mini|opera mobi|iemobile|windows phone|palm|smartphone/.test(ua)) return 'mobile' + return 'desktop' +} + +export function sanitizeReferrer(referrer) { + if (!referrer || typeof referrer !== 'string') return '' + try { + const parsed = new URL(referrer.trim()) + return `${parsed.hostname}${parsed.pathname}`.slice(0, 200) + } catch { + return '' + } +} + +export function detectBot(userAgent) { + if (!userAgent || typeof userAgent !== 'string') { + return { isBot: true, reason: 'missing-user-agent' } + } + const ua = userAgent.toLowerCase() + if (/googlebot|bingbot|yandexbot|baiduspider|slurp|duckduckbot|sluplicate|googlebot-mobile/.test(ua)) { + return { isBot: true, reason: 'search-crawler' } + } + if (/facebookexternalhit|twitterbot|linkedinbot|pinterest|whatsapp|slack|discord|telegram|reddit|mastodon/.test(ua)) { + return { isBot: true, reason: 'social-crawler' } + } + if (/headless|phantomjs|puppeteer|playwright|selenium|nightmarebot|watir|webdriver|wdio|nightmare/.test(ua)) { + return { isBot: true, reason: 'headless-browser' } + } + if (/uptimerobot|pingdom|statuspage|pagerduty|sentry|datadog|grafana|prometheus|newrelic|appdynamics/.test(ua)) { + return { isBot: true, reason: 'monitoring-tool' } + } + if (/nmap|nikto|masscan|metasploit|nessus|openvas|qualys|burpsuite|zap|acunetix|sqlmap/.test(ua)) { + return { isBot: true, reason: 'security-scanner' } + } + if (/^(curl|wget|python|java|go|node|ruby|php|perl|lua|rust)[\/-]/.test(ua)) { + return { isBot: true, reason: 'http-client' } + } + if (/bot|crawler|spider|scraper|indexer|reader|fetcher|loader|agent|spyware|tracking|monitor/.test(ua)) { + if (!/chrome|firefox|safari|opera|edge|msie|trident|like gecko/.test(ua)) { + return { isBot: true, reason: 'bot-keyword' } + } + } + return { isBot: false, reason: null } +} + +export function normalizeIp(rawIp) { + if (!rawIp) return 'unknown' + let ip = String(rawIp).trim() + if (ip.includes(',')) ip = ip.split(',')[0].trim() + if (ip.startsWith('::ffff:')) ip = ip.slice(7) + if (ip === '::1') ip = '127.0.0.1' + return ip || 'unknown' +} + +export function isPrivateOrLocalIp(ip) { + return ( + ip === '127.0.0.1' + || ip === 'localhost' + || ip.startsWith('10.') + || ip.startsWith('192.168.') + || /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip) + || ip.startsWith('fc') + || ip.startsWith('fd') + || ip.startsWith('fe80:') + || ip === 'unknown' + ) +} + +export function buildTopLocations(list, key) { + const counts = {} + for (const row of list) { + const val = row?.[key] || 'Unknown' + counts[val] = (counts[val] ?? 0) + 1 + } + return Object.entries(counts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([name, hits]) => ({ name, hits })) +} + +export function buildLastNDaysStats(days) { + const out = [] + const today = new Date() + for (let i = days - 1; i >= 0; i -= 1) { + const d = new Date(today) + d.setDate(today.getDate() - i) + const dayKey = d.toISOString().slice(0, 10) + out.push({ day: dayKey, hits: state.hitStats.byDay[dayKey] ?? 0 }) + } + return out +} + +export function recordHit(pathname, isBot = false, botReason = null) { + const nowIso = new Date().toISOString() + const dayKey = nowIso.slice(0, 10) + const safePath = normalizeHitPath(pathname) + + state.hitStats.totalHits += 1 + state.hitStats.lastHitAt = nowIso + state.hitStats.firstHitAt = state.hitStats.firstHitAt ?? nowIso + + if (isBot) { + state.hitStats.botHits += 1 + state.hitStats.byPathBot[safePath] = (state.hitStats.byPathBot[safePath] ?? 0) + 1 + state.hitStats.byDayBot[dayKey] = (state.hitStats.byDayBot[dayKey] ?? 0) + 1 + if (botReason) { + state.hitStats.botReasons[botReason] = (state.hitStats.botReasons[botReason] ?? 0) + 1 + } + } else { + state.hitStats.realHits += 1 + state.hitStats.byPathReal[safePath] = (state.hitStats.byPathReal[safePath] ?? 0) + 1 + state.hitStats.byDayReal[dayKey] = (state.hitStats.byDayReal[dayKey] ?? 0) + 1 + } + + state.hitStats.byPath[safePath] = (state.hitStats.byPath[safePath] ?? 0) + 1 + state.hitStats.byDay[dayKey] = (state.hitStats.byDay[dayKey] ?? 0) + 1 +} + +export function pruneStatsByDays(daysRaw) { + const { VISITOR_RETENTION_DAYS_DEFAULT, EMPTY_HIT_STATS: _unused } = { VISITOR_RETENTION_DAYS_DEFAULT: 180 } + const days = Number(daysRaw) + const retentionDays = Number.isFinite(days) && days > 0 ? Math.floor(days) : 180 + const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000 + + const keepRecent = state.visitorStats.recentVisits.filter(v => { + const ts = new Date(v.at).getTime() + return Number.isFinite(ts) && ts >= cutoff + }) + + const allowedVisitorIds = new Set(keepRecent.map(v => v.visitorId)) + const nextVisitors = {} + for (const [id, data] of Object.entries(state.visitorStats.visitors)) { + const lastSeen = new Date(data.lastSeenAt ?? 0).getTime() + if (allowedVisitorIds.has(id) || (Number.isFinite(lastSeen) && lastSeen >= cutoff)) { + nextVisitors[id] = data + } + } + + const nextByDay = {} + const nextByDayReal = {} + const nextByDayBot = {} + for (const [day, count] of Object.entries(state.hitStats.byDay)) { + const ts = new Date(`${day}T00:00:00.000Z`).getTime() + if (Number.isFinite(ts) && ts >= cutoff) { + nextByDay[day] = count + nextByDayReal[day] = state.hitStats.byDayReal?.[day] ?? 0 + nextByDayBot[day] = state.hitStats.byDayBot?.[day] ?? 0 + } + } + + state.visitorStats.recentVisits = keepRecent + state.visitorStats.visitors = nextVisitors + state.visitorStats.uniqueVisitors = Object.keys(nextVisitors).length + state.visitorStats.totalVisits = keepRecent.length + state.visitorStats.returningVisits = keepRecent.filter(v => v.returningVisitor).length + state.visitorStats.firstVisitAt = keepRecent.length > 0 ? keepRecent[keepRecent.length - 1].at : null + state.visitorStats.lastVisitAt = keepRecent.length > 0 ? keepRecent[0].at : null + + state.hitStats.byDay = nextByDay + state.hitStats.byDayReal = nextByDayReal + state.hitStats.byDayBot = nextByDayBot + + return { + retentionDays, + remainingVisits: state.visitorStats.totalVisits, + remainingVisitors: state.visitorStats.uniqueVisitors, + } +} + +// ── Redirect / URL helpers ───────────────────────────────────────────────── + +export function normalizeRedirectPath(value) { + if (typeof value !== 'string') return '' + const trimmed = value.trim() + if (!trimmed) return '' + const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}` + const normalized = withSlash.replace(/\/+/g, '/') + if (normalized === '/') return '' + if (normalized.startsWith('/api/') || normalized.startsWith('/admin')) return '' + return normalized +} + +export function sanitizeUrl(value) { + if (typeof value !== 'string') return '' + const trimmed = value.trim() + if (!trimmed) return '' + if (trimmed.startsWith('/')) return trimmed + if (/^https?:\/\//i.test(trimmed)) return trimmed + return '' +} + +export function sanitizeRedirectRules(value) { + const source = Array.isArray(value) ? value : [] + const seen = new Set() + const out = [] + + for (const item of source) { + const pathValue = normalizeRedirectPath(item?.path) + const target = sanitizeUrl(item?.target) + const statusCode = Number(item?.statusCode) === 302 ? 302 : 301 + if (!pathValue || !target) continue + if (seen.has(pathValue)) continue + seen.add(pathValue) + out.push({ + id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), + path: pathValue, + target, + statusCode, + }) + } + + return out.length > 0 ? out : DEFAULT_REDIRECT_RULES +} + +// ── Contact / email tracking ─────────────────────────────────────────────── + +export function noteContactEmailCooldown(emailAddress) { + const normalized = String(emailAddress || '').trim().toLowerCase() + if (!normalized) return { ok: true, retryAfterMs: 0 } + + const now = Date.now() + const lastAt = state.contactSubmitCooldownByEmail.get(normalized) + if (typeof lastAt === 'number' && now - lastAt < CONTACT_EMAIL_COOLDOWN_MS) { + return { ok: false, retryAfterMs: CONTACT_EMAIL_COOLDOWN_MS - (now - lastAt) } + } + + state.contactSubmitCooldownByEmail.set(normalized, now) + + if (state.contactSubmitCooldownByEmail.size > 8000) { + const cutoff = now - CONTACT_EMAIL_COOLDOWN_MS * 3 + for (const [email, timestamp] of state.contactSubmitCooldownByEmail.entries()) { + if (timestamp < cutoff) state.contactSubmitCooldownByEmail.delete(email) + } + } + + return { ok: true, retryAfterMs: 0 } +} + +export function extractTagValue(tags, name) { + if (!Array.isArray(tags)) return '' + const target = String(name || '').trim().toLowerCase() + if (!target) return '' + for (const tag of tags) { + if (!tag || typeof tag !== 'object') continue + const key = typeof tag.name === 'string' ? tag.name.trim().toLowerCase() : '' + const value = typeof tag.value === 'string' ? tag.value.trim() : '' + if (key === target && value) return value + } + return '' +} + +export function mapResendEventToStatus(eventType) { + const normalized = String(eventType || '').trim().toLowerCase() + if (!normalized) return 'updated' + if (normalized.includes('delivered')) return 'delivered' + if (normalized.includes('delivery_delayed') || normalized.includes('delivery delayed')) return 'delayed' + if (normalized.includes('bounce')) return 'bounced' + if (normalized.includes('complain')) return 'complained' + if (normalized.includes('click')) return 'clicked' + if (normalized.includes('open')) return 'opened' + if (normalized.includes('send')) return 'sent' + return 'updated' +} + +export function extractResendMessageId(result) { + if (!result || typeof result !== 'object') return '' + if (typeof result.id === 'string' && result.id.trim()) return result.id.trim() + if (result.data && typeof result.data === 'object' && typeof result.data.id === 'string' && result.data.id.trim()) { + return result.data.id.trim() + } + return '' +} + +export function normalizeMessageType(value) { + if (value === 'question' || value === 'testimony' || value === 'topic') return value + return 'general' +} + +// ── Study reminder scheduler ─────────────────────────────────────────────── + +export async function scheduleStudyReminders(sendStudyReminderEmail) { + if (!state.cachedSiteContent) return + const now = new Date() + + for (const user of state.studyUsers) { + if (user.studyRemindersEnabled !== true) continue + const email = user.username + const displayName = user.displayName || email + const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : [] + if (enrolledStudySlugs.length === 0) continue + + const userSent = state.studyReminders.users[user.id] ?? {} + for (const studySlug of enrolledStudySlugs) { + const study = Array.isArray(state.cachedSiteContent.studies) + ? state.cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug) + : undefined + if (!study) continue + + for (const section of study.sections ?? []) { + const sectionId = section.id + const releaseDate = getSectionReleaseDate(section) + if (!releaseDate) continue + if (releaseDate > now) continue + const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : [] + if (sentForStudy.includes(sectionId)) continue + + const hoursSinceRelease = (now.getTime() - releaseDate.getTime()) / (1000 * 60 * 60) + if (hoursSinceRelease > 24) continue + + const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/' + const base = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical + const sectionUrl = `${base}/study/${study.slug}/${section.id}` + await sendStudyReminderEmail(email, displayName, study.title, section.title, section.reference, sectionUrl) + userSent[studySlug] = [...sentForStudy, sectionId] + state.studyReminders.users[user.id] = userSent + } + } + } + state.studyReminders.updatedAt = new Date().toISOString() + queueStudyRemindersWrite() +}