Compare commits

...
3 Commits
Author SHA1 Message Date
nmemmertandClaude Sonnet 5.5 b96e9a79dc Redesign Start Here page: numbered step cards, icons, connectors, mobile stack; v2.10.2
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 09:25:54 -04:00
nmemmertandClaude Sonnet 5.5 2a64f29a00 Second hardening pass: atomic data writes, bible-passage limits, webhook SSRF guard, note sanitizing, CI gates; v2.10.1
Publish Container / build (push) Successful in 15m46s
- Atomic temp-file + rename for all JSON data writes (no truncated files on crash)
- /api/bible-passage: rate limit, range caps (60 verses), bounded cache
- Admin webhooks: https-only, block private/internal/Tailscale addresses (checked at registration and delivery), no redirects
- Resend webhook token compare is timing-safe
- DOMPurify on rendered study notes and broadcast preview
- CI runs lint + tests before build
- Permissions-Policy and HSTS (production) headers; JSON error handler for /api
- Tests for URL safety and atomic writes

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 09:00:28 -04:00
nmemmertandClaude Sonnet 5.5 8a796964b9 Security & hardening pass: scrypt password hashing, persisted admin sessions, upvote/analytics rate limits, dependency fixes, healthcheck, lint cleanup; v2.10.0
Publish Container / build (push) Successful in 17m3s
- Study passwords: salted scrypt; legacy SHA-256 hashes verified and upgraded on login
- Admin sessions persisted (hashed tokens) so deploys no longer log the admin out
- Upvotes: rate limit + one per client per question, approved questions only; rate limit on analytics play/event
- npm audit: 46 -> 4 vulnerabilities (tiptap bumped to 3.31.4)
- Untrack per-user data (study users/notes/progress/etc.) from git
- Dockerfile HEALTHCHECK; node --test suite for password hashing
- Fix email template cards remounting (lost input focus) on each keystroke
- Lint: 34 errors -> 0

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 08:45:39 -04:00
42 changed files with 1240 additions and 1026 deletions

No files matched your search

+6
View File
@@ -31,6 +31,12 @@ jobs:
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Lint
run: npm run lint
- name: Run tests
run: npm test
- name: Run production build
run: npm run build
+6
View File
@@ -33,6 +33,12 @@ jobs:
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Lint
run: npm run lint
- name: Run tests
run: npm test
- name: Run production build
run: npm run build
+13
View File
@@ -42,3 +42,16 @@ data/study-sessions.json
*.njsproj
*.sln
*.sw?
# Persisted admin sessions (hashed tokens)
data/admin-sessions.json
# Per-user / visitor data — lives only in the server's data volume, never in git
data/study-users.json
data/study-notes/
data/study-progress/
data/study-reminders.json
data/study-community.json
data/analytics-events.json
data/admin-reply-history.json
data/episode-plays.json
+2
View File
@@ -35,4 +35,6 @@ COPY entrypoint.sh ./entrypoint.sh
RUN chmod +x ./entrypoint.sh
EXPOSE 4173
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||4173)+'/api/admin-auth/status').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ENTRYPOINT ["./entrypoint.sh"]
-15
View File
@@ -1,15 +0,0 @@
{
"items": [
{
"id": "da3f5f52-f0a3-4a36-9f52-7938b82e899a",
"submissionId": "0c2e3dc4-5ee3-42e9-a81b-172bedc16713",
"toEmail": "hello@versebyversewithnate.us",
"toName": "NoAdmin Signup",
"fromEmail": "hello@versebyversewithnate.us",
"subject": "Thanks for reaching out to Verse by Verse with Nate",
"preview": "Thank you for reaching out.\n\nI appreciate your message and wanted to follow up personally. I just wanted to say thank you for your email.\n\n~Nate",
"sentAt": "2026-05-07T12:54:06.683Z"
}
],
"updatedAt": "2026-05-07T12:54:06.685Z"
}
-65
View File
@@ -1,65 +0,0 @@
{
"scrollDepth": {
"/episodes": {
"25": 4,
"50": 4,
"75": 2,
"90": 0
},
"/": {
"25": 2,
"50": 2,
"75": 2,
"90": 2
},
"/about": {
"25": 6,
"50": 6,
"75": 6,
"90": 6
}
},
"timeOnPage": {
"/": {
"totalSeconds": 64,
"count": 5
},
"/episodes": {
"totalSeconds": 318967,
"count": 305
},
"/episodes/movn6tk3": {
"totalSeconds": 445,
"count": 3
},
"/about": {
"totalSeconds": 63497,
"count": 128
}
},
"outboundClicks": {},
"linkClicks": {
"https://ko-fi.com/versebyversewithnate": {
"count": 1,
"internal": false
},
"/": {
"count": 1,
"internal": true
},
"/about": {
"count": 1,
"internal": true
}
},
"utmSources": {},
"searchQueries": {},
"notFound": {},
"audioEvents": {
"Living Between Two Appearings": {
"pauses": 0,
"completions": 0,
"totalListenSeconds": 43
}
}
}
-8
View File
@@ -1,8 +0,0 @@
{
"Living Between Two Appearings": {
"total": 1,
"byDay": {
"2026-07-22": 1
}
}
}
-50
View File
@@ -1,50 +0,0 @@
{
"posts": [
{
"id": "fd6f2d9c-8abc-43b2-8e93-c58c0cb1ff7b",
"studySlug": "colossians",
"sectionId": "",
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
"authorName": "Test Student",
"message": "reading",
"createdAt": "2026-06-02T17:41:48.684Z",
"replies": []
},
{
"id": "001f2545-e1ee-4748-902b-5da5c47554bb",
"studySlug": "colossians",
"sectionId": "",
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
"authorName": "Test Student",
"message": "sool",
"createdAt": "2026-06-02T17:41:41.608Z",
"replies": []
},
{
"id": "c40e4c10-47a3-42e8-88bd-78116af5babe",
"studySlug": "colossians",
"sectionId": "",
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
"authorName": "Test Student",
"message": "test",
"createdAt": "2026-06-02T17:41:16.795Z",
"replies": [
{
"id": "efe0bf3d-66e8-4dad-98c1-2e8c742e3120",
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
"authorName": "Test Student",
"message": "test",
"createdAt": "2026-06-02T17:41:25.003Z"
},
{
"id": "1d6c3bd1-5ada-4f98-8ca3-5ed15bd00a68",
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
"authorName": "Test Student",
"message": "test",
"createdAt": "2026-06-02T17:41:34.406Z"
}
]
}
],
"updatedAt": "2026-06-02T17:41:48.685Z"
}
@@ -1,3 +0,0 @@
{
"colossians--1-1-2": "test....teset"
}
@@ -1,3 +0,0 @@
{
"1-1-2": "Test"
}
@@ -1,17 +0,0 @@
{
"byStudy": {
"colossians": {
"completedSectionIds": [
"1-1-2",
"1-3-8"
],
"quizAnswers": {
"1-1-2": [
"test",
"test"
]
}
}
},
"updatedAt": "2026-06-08T20:30:19.689Z"
}
-4
View File
@@ -1,4 +0,0 @@
{
"users": {},
"updatedAt": "2026-09-03T21:13:33.725Z"
}
-94
View File
@@ -1,94 +0,0 @@
{
"users": [
{
"id": "9b74971e-bfdc-4e64-a8d7-87cd8f2bb5c4",
"username": "student.test@versebyverse.local",
"passwordHash": "6500cc2e717d1d646f371dba1ec918d2f2f9fddbb8576c792df69a1f2340dcd9",
"displayName": "Student Test",
"subscribeNewsletter": false,
"studyRemindersEnabled": false,
"enrolledStudySlugs": [],
"avatarUrl": "",
"createdAt": null,
"updatedAt": "2026-08-10T12:39:25.413Z",
"lastLoginAt": "2026-05-21T15:41:16.259Z",
"reengagementSentAt": {},
"pendingEmailChange": null,
"twoFaMethod": null,
"totpSecret": null,
"totpVerified": false,
"totpEnabledAt": null,
"totpRecoveryCodes": [],
"remindersPausedUntil": null,
"reminderFrequency": "immediate",
"weeklyDigestSentAt": null
},
{
"id": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
"username": "test.user@versebyverse.local",
"passwordHash": "fc89481d72dbea9af1d4f054faad48813227fa018da39259272dd27b5e493bb5",
"displayName": "Test Student",
"subscribeNewsletter": false,
"studyRemindersEnabled": false,
"enrolledStudySlugs": [
"colossians"
],
"avatarUrl": "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2288%22%20height%3D%2288%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20rx%3D%2218%22%20ry%3D%2218%22%20fill%3D%22%2311100d%22%2F%3E%3Ctext%20x%3D%2250%25%22%20y%3D%2250%25%22%20dominant-baseline%3D%22middle%22%20text-anchor%3D%22middle%22%20font-size%3D%2256%22%3E%F0%9F%A6%81%3C%2Ftext%3E%3C%2Fsvg%3E",
"createdAt": null,
"updatedAt": "2026-06-08T20:46:30.804Z",
"lastLoginAt": "2026-06-08T20:46:30.804Z",
"reengagementSentAt": {},
"pendingEmailChange": null,
"twoFaMethod": null,
"totpSecret": null,
"totpVerified": false,
"totpEnabledAt": null,
"totpRecoveryCodes": [],
"totpSecretPending": "FGR7UI5TWA4HB4DURCXEUXATTVVVJDJ5",
"remindersPausedUntil": null,
"reminderFrequency": "immediate",
"weeklyDigestSentAt": null
},
{
"id": "81c01e32-d94e-4e19-93ea-e5cd7644ea6a",
"username": "test.avatar@versebyverse.local",
"passwordHash": "fc89481d72dbea9af1d4f054faad48813227fa018da39259272dd27b5e493bb5",
"displayName": "Avatar Test",
"subscribeNewsletter": false,
"studyRemindersEnabled": false,
"enrolledStudySlugs": [],
"avatarUrl": "data:image/svg+xml;utf8,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"88\" height=\"88\"%3E%3Crect width=\"100%\" height=\"100%\" rx=\"18\" ry=\"18\" fill=\"%2311100d\"/%3E%3Ctext x=\"50%\" y=\"50%\" dominant-baseline=\"middle\" text-anchor=\"middle\" font-size=\"56\"%3E🐱%3C/text%3E%3C/svg%3E",
"createdAt": null,
"updatedAt": null,
"lastLoginAt": "2026-06-02T17:33:13.669Z",
"reengagementSentAt": {},
"pendingEmailChange": null,
"twoFaMethod": null,
"totpSecret": null,
"totpVerified": false,
"totpEnabledAt": null,
"totpRecoveryCodes": [],
"remindersPausedUntil": null,
"reminderFrequency": "immediate",
"weeklyDigestSentAt": null
},
{
"id": "6cd08599-b8a8-4aa9-809d-dd606cf21635",
"username": "nmemmert@outlook.com",
"passwordHash": "534c7ff8f203149c82881728eada86729bc69ef069db2f4b8f7202d7cf8b11f9",
"displayName": "",
"subscribeNewsletter": true,
"studyRemindersEnabled": false,
"pendingEmailChange": null,
"enrolledStudySlugs": [
"colossians",
"titus"
],
"createdAt": "2026-09-03T20:14:39.297Z",
"updatedAt": "2026-09-03T20:14:49.258Z",
"lastLoginAt": "2026-09-03T20:14:39.297Z",
"firstVisitAt": "2026-09-03T20:14:51.933Z"
}
],
"updatedAt": "2026-09-03T20:14:51.933Z"
}
+4
View File
@@ -19,5 +19,9 @@ export default defineConfig([
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
// Reset-state-on-prop-change effects are used throughout; flagged for gradual cleanup rather than blocking lint.
'react-hooks/set-state-in-effect': 'warn',
},
},
])
+615 -657
View File
File diff suppressed because it is too large. Load diff
+7 -3
View File
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
"version": "2.9.8",
"version": "2.10.2",
"type": "module",
"scripts": {
"dev": "vite",
@@ -10,6 +10,7 @@
"build": "tsc -b && vite build",
"start": "node --env-file=.env server.js",
"lint": "eslint .",
"test": "node --test test/*.test.js",
"preview": "vite preview"
},
"dependencies": {
@@ -17,10 +18,13 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tiptap/react": "^3.27.0",
"@tiptap/starter-kit": "^3.27.0",
"@tiptap/core": "^3.31.4",
"@tiptap/pm": "^3.31.4",
"@tiptap/react": "^3.31.4",
"@tiptap/starter-kit": "^3.31.4",
"chart.js": "^4.4.0",
"docx": "^9.6.1",
"dompurify": "^3.4.16",
"express": "^5.2.1",
"express-rate-limit": "^8.4.1",
"fuse.js": "^7.4.2",
+16
View File
@@ -84,6 +84,10 @@ app.use((_req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('X-Frame-Options', 'SAMEORIGIN')
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()')
if (process.env.NODE_ENV === 'production') {
res.setHeader('Strict-Transport-Security', 'max-age=31536000')
}
res.setHeader(
'Content-Security-Policy',
[
@@ -147,6 +151,18 @@ app.use((req, res, next) => {
// Public routes (robots, sitemap, static, SPA fallback)
registerPublic(app)
// JSON error responses for API routes instead of Express's default HTML page.
app.use((err, req, res, _next) => {
if (res.headersSent) return
const status = Number.isInteger(err?.status) && err.status >= 400 && err.status < 600 ? err.status : 500
if (status >= 500) console.error('[error]', req.method, req.path, err)
if (req.path.startsWith('/api/')) {
res.status(status).json({ message: status >= 500 ? 'Internal server error.' : String(err?.message ?? 'Request failed.') })
return
}
res.status(status).type('text/plain').send(status >= 500 ? 'Internal server error.' : 'Bad request.')
})
const PORT = Number(process.env.PORT ?? 4173)
Promise.all([
loadHitStatsFromDisk(),
+17
View File
@@ -0,0 +1,17 @@
import { writeFile as fsWriteFile, rename, unlink } from 'node:fs/promises'
import { randomBytes } from 'node:crypto'
// Drop-in replacement for fs.promises.writeFile that never leaves a truncated
// file behind: the data goes to a sibling temp file which is then renamed over
// the target (atomic on the same filesystem). A crash mid-write leaves the
// previous version intact instead of a half-written JSON file.
export async function writeFile(file, data, options) {
const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`
try {
await fsWriteFile(tmp, data, options)
await rename(tmp, file)
} catch (err) {
await unlink(tmp).catch(() => {})
throw err
}
}
+43 -6
View File
@@ -1,5 +1,7 @@
import { createHash, randomUUID, timingSafeEqual, createHmac, randomFillSync } from 'node:crypto'
import { readFile, writeFile } from 'node:fs/promises'
import { readFile } from 'node:fs/promises'
import { writeFile } from './atomic-write.js'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { parseCookies, cookieSecureFlag } from './helpers.js'
import { DATA_DIR } from './paths.js'
@@ -17,6 +19,38 @@ const ADMIN_SESSION_ABSOLUTE_TTL_MS = 30 * 24 * 60 * 60 * 1000
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
const adminSessions = new Map()
// Sessions survive restarts/deploys. Only a SHA-256 of each token is written to
// disk, so the file alone cannot be used to hijack a session. Sliding-expiry
// refreshes are not persisted; a restart reverts to the last persisted expiry.
const ADMIN_SESSIONS_FILE = path.join(DATA_DIR, 'admin-sessions.json')
let adminSessionsWrite = Promise.resolve()
function persistAdminSessions() {
const sessions = []
for (const [tokenHash, session] of adminSessions) {
if (session && typeof session === 'object') sessions.push({ tokenHash, ...session })
}
adminSessionsWrite = adminSessionsWrite
.then(() => writeFile(ADMIN_SESSIONS_FILE, JSON.stringify({ sessions }), { encoding: 'utf8', mode: 0o600 }))
.catch(err => { console.error('[admin-sessions] write failed:', err) })
}
// Loaded sessions are keyed by token hash; lookups hash the cookie value, so the
// in-memory map is keyed by hash throughout.
function adminSessionKey(token) {
return sha256(token)
}
try {
const parsed = JSON.parse(readFileSync(ADMIN_SESSIONS_FILE, 'utf8'))
const now = Date.now()
for (const e of Array.isArray(parsed?.sessions) ? parsed.sessions : []) {
if (typeof e?.tokenHash !== 'string' || typeof e.expiresAt !== 'number' || typeof e.absoluteExpiresAt !== 'number') continue
if (e.expiresAt <= now || e.absoluteExpiresAt <= now) continue
adminSessions.set(e.tokenHash, { expiresAt: e.expiresAt, absoluteExpiresAt: e.absoluteExpiresAt })
}
} catch { /* no persisted sessions */ }
export function sha256(value) {
return createHash('sha256').update(String(value)).digest('hex')
}
@@ -213,13 +247,15 @@ export function consumePendingSession(token) {
export function createAdminSession() {
const token = randomUUID()
const now = Date.now()
adminSessions.set(token, { expiresAt: now + ADMIN_SESSION_TTL_MS, absoluteExpiresAt: now + ADMIN_SESSION_ABSOLUTE_TTL_MS })
adminSessions.set(adminSessionKey(token), { expiresAt: now + ADMIN_SESSION_TTL_MS, absoluteExpiresAt: now + ADMIN_SESSION_ABSOLUTE_TTL_MS })
persistAdminSessions()
return token
}
export function deleteAdminSession(token) {
if (token) {
adminSessions.delete(token)
adminSessions.delete(adminSessionKey(token))
persistAdminSessions()
}
}
@@ -231,7 +267,8 @@ export function isValidAdminSession(req) {
if (!sessionToken) return false
const now = Date.now()
const session = adminSessions.get(sessionToken)
const key = adminSessionKey(sessionToken)
const session = adminSessions.get(key)
if (!session) return false
// Support legacy sessions stored as a plain number (expiresAt)
@@ -239,11 +276,11 @@ export function isValidAdminSession(req) {
const absoluteExpiresAt = typeof session === 'object' ? session.absoluteExpiresAt : Infinity
if (expiresAt <= now || absoluteExpiresAt <= now) {
adminSessions.delete(sessionToken)
adminSessions.delete(key)
return false
}
adminSessions.set(sessionToken, { expiresAt: now + ADMIN_SESSION_TTL_MS, absoluteExpiresAt })
adminSessions.set(key, { expiresAt: now + ADMIN_SESSION_TTL_MS, absoluteExpiresAt })
return true
}
+7 -3
View File
@@ -1,5 +1,7 @@
import { migrateScriptKeys } from './episode-script-keys.js'
import { mkdir, readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises'
import { mkdir, readFile, readdir, stat, unlink } from 'node:fs/promises'
import { writeFile } from './atomic-write.js'
import { assertPublicHttpsUrl } from './url-safety.js'
import path from 'node:path'
import { randomUUID } from 'node:crypto'
import { sanitizeSiteContent } from './helpers.js'
@@ -533,12 +535,14 @@ export async function fireWebhooks(eventType, payload) {
if (targets.length === 0) return
const body = JSON.stringify({ event: eventType, data: payload, firedAt: new Date().toISOString() })
for (const wh of targets) {
fetch(wh.url, {
// Re-check at delivery time: DNS may have changed since the webhook was registered.
assertPublicHttpsUrl(wh.url).then(() => fetch(wh.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'User-Agent': 'Siteforge-Webhook/1.0' },
body,
redirect: 'manual',
signal: AbortSignal.timeout(8000),
})
}))
.then(r => {
appendAuditEntry('webhook-fired', `${eventType} → ${wh.url} (${r.status})`)
})
+2 -1
View File
@@ -1,4 +1,5 @@
import { readFile, writeFile } from 'node:fs/promises'
import { readFile } from 'node:fs/promises'
import { writeFile } from './atomic-write.js'
import { sendNuggetsUploadNotification } from './email.js'
import {
SEAFILE_URL,
+10 -2
View File
@@ -1,4 +1,6 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { mkdir, readFile } from 'node:fs/promises'
import { writeFile } from '../atomic-write.js'
import { assertPublicHttpsUrl } from '../url-safety.js'
import { sanitizeSiteContent } from '../helpers.js'
import { requireAdminAuth, isValidAdminSession } from '../auth.js'
import {
@@ -524,12 +526,18 @@ export function register(app) {
res.json({ webhooks: state.webhooks })
})
app.post('/api/admin-webhooks', requireAdminAuth, (req, res) => {
app.post('/api/admin-webhooks', requireAdminAuth, async (req, res) => {
const { url, events } = req.body ?? {}
if (!url || typeof url !== 'string' || !/^https:\/\//i.test(url.trim())) {
res.status(400).json({ message: 'A valid HTTPS URL is required.' })
return
}
try {
await assertPublicHttpsUrl(url.trim())
} catch (err) {
res.status(400).json({ message: `Webhook URL rejected: ${err.message}` })
return
}
if (state.webhooks.length >= 50) {
res.status(400).json({ message: 'Maximum of 50 webhooks reached.' })
return
+11
View File
@@ -1,4 +1,5 @@
import { createHash, randomUUID } from 'node:crypto'
import rateLimit from 'express-rate-limit'
import { requireAdminAuth, isValidAdminSession } from '../auth.js'
import { getClientIp, hasVisitorConsent, setConsentCookie, parseCookies } from '../helpers.js'
import { getStudyUserFromRequest } from '../study-helpers.js'
@@ -208,7 +209,17 @@ export function recordVisitor(req, res, overridePath = null, overrideReferrer =
}
}
const analyticsWriteLimiter = rateLimit({
windowMs: 60 * 1000,
max: 120,
standardHeaders: true,
legacyHeaders: false,
message: { ok: false, reason: 'rate-limited' },
})
export function register(app) {
app.use(['/api/analytics/play', '/api/analytics/event'], analyticsWriteLimiter)
app.post('/api/analytics-consent', (req, res) => {
const consent = req.body?.consent === true
setConsentCookie(res, consent)
+25 -1
View File
@@ -1,6 +1,19 @@
import rateLimit from 'express-rate-limit'
import { BIBLE_BOOKS } from '../../shared/bible-books.js'
const passageCache = new Map()
const PASSAGE_CACHE_MAX = 2000
const MAX_CHAPTER = 150
const MAX_VERSE = 176
const MAX_VERSES_PER_REQUEST = 60
const passageRateLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
message: { message: 'Too many passage requests. Please slow down.' },
})
// Keyed by lowercase, whitespace-stripped book name/alias (matching the format
// the admin's "Bible Book" field and the /api/bible-passage `book` query param
@@ -27,7 +40,7 @@ function parseReference(reference) {
}
export function register(app) {
app.get('/api/bible-passage', async (req, res) => {
app.get('/api/bible-passage', passageRateLimiter, async (req, res) => {
const book = String(req.query.book ?? '').trim().toLowerCase().replace(/[\s-]/g, '')
const reference = String(req.query.reference ?? '').trim()
@@ -43,6 +56,16 @@ export function register(app) {
return
}
if (
parsed.chapter < 1 || parsed.chapter > MAX_CHAPTER
|| parsed.verseStart < 1 || parsed.verseEnd > MAX_VERSE
|| parsed.verseEnd < parsed.verseStart
|| parsed.verseEnd - parsed.verseStart + 1 > MAX_VERSES_PER_REQUEST
) {
res.status(400).json({ message: `Passage out of range (max ${MAX_VERSES_PER_REQUEST} verses).` })
return
}
const cacheKey = `BSB:${bookNum}:${parsed.chapter}:${parsed.verseStart}:${parsed.verseEnd}`
if (passageCache.has(cacheKey)) {
res.json({ text: passageCache.get(cacheKey) })
@@ -66,6 +89,7 @@ export function register(app) {
}
const text = results.map(v => `v. ${v.verse} ${String(v.text).trim()}`).join('\n\n')
if (passageCache.size >= PASSAGE_CACHE_MAX) passageCache.delete(passageCache.keys().next().value)
passageCache.set(cacheKey, text)
res.json({ text })
} catch {
+4 -2
View File
@@ -1,4 +1,4 @@
import { randomUUID } from 'node:crypto'
import { randomUUID, timingSafeEqual } from 'node:crypto'
import { Resend } from 'resend'
import { requireAdminAuth } from '../auth.js'
import { escapeHtml, splitName, buildAbsoluteUrl } from '../helpers.js'
@@ -311,7 +311,9 @@ export function register(app) {
|| String(req.query?.token || '').trim()
|| (req.get('authorization') || '').replace(/^Bearer\s+/i, '').trim()
if (!providedToken || providedToken !== expectedToken) {
const tokenA = Buffer.from(providedToken, 'utf8')
const tokenB = Buffer.from(expectedToken, 'utf8')
if (!providedToken || tokenA.length !== tokenB.length || !timingSafeEqual(tokenA, tokenB)) {
res.status(401).json({ message: 'Unauthorized webhook.' }); return
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto'
import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises'
import { readFile, unlink, mkdir } from 'node:fs/promises'
import { writeFile } from '../atomic-write.js'
import path from 'node:path'
import multer from 'multer'
import rateLimit from 'express-rate-limit'
+26 -6
View File
@@ -4,8 +4,21 @@ import { MAX_QUESTIONS } from '../config.js'
import { state } from '../state.js'
import { queueQuestionsWrite, queueDraftQuestionsWrite } from '../data.js'
import { buildQuestionAnsweredEmailTemplate, getResendFromAddress, getResendReplyToAddress, sendResendEmailWithRetry, getCanonicalBaseUrl } from '../email.js'
import { buildAbsoluteUrl } from '../helpers.js'
import { buildAbsoluteUrl, getClientIp } from '../helpers.js'
import { Resend } from 'resend'
import rateLimit from 'express-rate-limit'
const upvoteRateLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { message: 'Too many votes. Please try again later.' },
})
// One upvote per client per question until the process restarts.
const upvotedBy = new Set()
const UPVOTED_BY_MAX = 50000
function ensureDraftQuestions() {
if (state.draftQuestions !== null) return
@@ -148,15 +161,22 @@ export function register(app) {
})
// Anonymous upvote — no auth required, lightweight increment
app.post('/api/questions/:id/upvote', (req, res) => {
app.post('/api/questions/:id/upvote', upvoteRateLimiter, (req, res) => {
const { id } = req.params
// Look in live questions first, then draft
const liveQ = state.questions.find(q => q.id === id && q.isApproved === true)
const draftQ = state.draftQuestions ? state.draftQuestions.find(q => q.id === id) : null
const question = liveQ ?? draftQ
// Only approved questions are public, so only they can be upvoted.
const source = state.draftQuestions ?? state.questions
const question = source.find(q => q.id === id && q.isApproved === true)
const liveQ = question && state.questions.includes(question) ? question : null
const draftQ = question && state.draftQuestions?.includes(question) ? question : null
if (!question) {
res.status(404).json({ message: 'Question not found.' }); return
}
const voteKey = `${getClientIp(req)}|${id}`
if (upvotedBy.has(voteKey)) {
res.json({ ok: true, upvotes: question.upvotes ?? 0, duplicate: true }); return
}
if (upvotedBy.size >= UPVOTED_BY_MAX) upvotedBy.clear()
upvotedBy.add(voteKey)
question.upvotes = ((question.upvotes ?? 0) + 1)
if (liveQ) queueQuestionsWrite()
if (draftQ) queueDraftQuestionsWrite()
+3 -8
View File
@@ -21,6 +21,7 @@ import {
import {
requireStudyAuth,
hashStudyPassword,
verifyStudyPassword,
hashEmailChangeToken,
normalizeStudyUsername,
isValidStudyUsername,
@@ -173,10 +174,7 @@ export function register(app) {
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)) {
if (!verifyStudyPassword(currentPassword, user.passwordHash)) {
res.status(401).json({ message: 'Current password is incorrect.' })
return
}
@@ -378,10 +376,7 @@ export function register(app) {
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)) {
if (!verifyStudyPassword(currentPassword, user.passwordHash)) {
res.status(401).json({ message: 'Current password is incorrect.' })
return
}
+11 -9
View File
@@ -20,6 +20,8 @@ import {
normalizeStudyUsername,
isValidStudyUsername,
hashStudyPassword,
verifyStudyPassword,
studyPasswordNeedsRehash,
hashPasswordResetToken,
hashEmailVerifyToken,
findStudyUserByUsername,
@@ -46,6 +48,8 @@ import {
getCanonicalBaseUrl,
} from '../email.js'
const DUMMY_PASSWORD_HASH = hashStudyPassword('timing-equalizer')
const studyAuthRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
@@ -164,18 +168,19 @@ export function register(app) {
const user = findStudyUserByUsername(username)
if (!user) {
verifyStudyPassword(password, DUMMY_PASSWORD_HASH) // equalize timing with the real-user path
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)) {
if (!verifyStudyPassword(password, user.passwordHash)) {
res.status(401).json({ message: 'Invalid email or password.' })
return
}
if (studyPasswordNeedsRehash(user.passwordHash)) {
user.passwordHash = hashStudyPassword(password)
queueStudyUsersWrite()
}
const twoFaMethod = user.twoFaMethod ?? (user.totpSecret && user.totpVerified ? 'app' : null)
if (twoFaMethod === 'app' && user.totpSecret && user.totpVerified) {
@@ -342,10 +347,7 @@ export function register(app) {
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)) {
if (!verifyStudyPassword(typeof password === 'string' ? password : '', user.passwordHash)) {
res.status(401).json({ message: 'Incorrect password.' })
return
}
+35 -2
View File
@@ -1,4 +1,4 @@
import { createHash, createHmac, randomUUID } from 'node:crypto'
import { createHash, createHmac, randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'node:crypto'
import path from 'node:path'
import {
parseCookies,
@@ -56,10 +56,43 @@ export function getStudySlugFromNoteId(sectionId) {
// ── Password / token hashing ───────────────────────────────────────────────
export function hashStudyPassword(password) {
const LEGACY_HASH_RE = /^[0-9a-f]{64}$/
function legacyHashStudyPassword(password) {
return createHash('sha256').update(`study-user:${String(password)}`).digest('hex')
}
// New hashes are `scrypt$<salt hex>$<hash hex>`. Legacy accounts hold an
// unsalted SHA-256 hex digest; verifyStudyPassword accepts both and
// studyPasswordNeedsRehash lets login upgrade them transparently.
export function hashStudyPassword(password) {
const salt = randomBytes(16)
const hash = scryptSync(String(password), salt, 64)
return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`
}
export function verifyStudyPassword(password, stored) {
if (typeof stored !== 'string' || !stored) return false
let expected
let actual
if (stored.startsWith('scrypt$')) {
const [, saltHex, hashHex] = stored.split('$')
if (!saltHex || !hashHex) return false
expected = Buffer.from(hashHex, 'hex')
actual = scryptSync(String(password), Buffer.from(saltHex, 'hex'), expected.length)
} else if (LEGACY_HASH_RE.test(stored)) {
expected = Buffer.from(stored, 'hex')
actual = Buffer.from(legacyHashStudyPassword(password), 'hex')
} else {
return false
}
return expected.length === actual.length && timingSafeEqual(expected, actual)
}
export function studyPasswordNeedsRehash(stored) {
return typeof stored === 'string' && !stored.startsWith('scrypt$')
}
export function hashEmailChangeToken(token) {
return createHash('sha256').update(`study-email-change:${String(token)}`).digest('hex')
}
+50
View File
@@ -0,0 +1,50 @@
import { lookup } from 'node:dns/promises'
import net from 'node:net'
function isPrivateIPv4(ip) {
const [a, b] = ip.split('.').map(Number)
return (
a === 0 || a === 10 || a === 127
|| (a === 100 && b >= 64 && b <= 127) // CGNAT / Tailscale
|| (a === 169 && b === 254) // link-local, cloud metadata
|| (a === 172 && b >= 16 && b <= 31)
|| (a === 192 && b === 168)
|| a >= 224 // multicast / reserved
)
}
export function isPrivateAddress(ip) {
if (net.isIPv4(ip)) return isPrivateIPv4(ip)
if (net.isIPv6(ip)) {
const lower = ip.toLowerCase()
if (lower === '::' || lower === '::1') return true
if (lower.startsWith('fc') || lower.startsWith('fd')) return true // unique local
if (/^fe[89ab]/.test(lower)) return true // link-local
const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)
if (mapped) return isPrivateIPv4(mapped[1])
return false
}
return true // not an IP at all: treat as unsafe
}
// Throws unless `rawUrl` is an https URL whose host resolves only to public
// addresses. Called when a webhook is registered and again before each delivery
// (DNS can change between the two).
export async function assertPublicHttpsUrl(rawUrl) {
let parsed
try {
parsed = new URL(rawUrl)
} catch {
throw new Error('Malformed URL.')
}
if (parsed.protocol !== 'https:') throw new Error('Only https:// URLs are allowed.')
if (parsed.username || parsed.password) throw new Error('URLs with embedded credentials are not allowed.')
const host = parsed.hostname.replace(/^\[|\]$/g, '')
if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || host.endsWith('.internal')) {
throw new Error('Internal hostnames are not allowed.')
}
const addresses = net.isIP(host) ? [{ address: host }] : await lookup(host, { all: true })
if (addresses.length === 0 || addresses.some(a => isPrivateAddress(a.address))) {
throw new Error('URL resolves to a private or internal address.')
}
}
+42 -30
View File
@@ -1,3 +1,4 @@
import DOMPurify from 'dompurify'
import React, { useEffect, useRef, useState } from 'react'
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
import { arrayMove, SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'
@@ -23,7 +24,7 @@ function highlightLaneOf(link: PodcastFeaturedLink): HighlightLane {
// Splits highlights into the ones belonging to a series (moved onto its Finished Book) and the rest (kept).
function splitHighlightsForLane(links: PodcastFeaturedLink[] | undefined, lane: HighlightLane) {
const all = links ?? []
const ending = all.filter(link => highlightLaneOf(link) === lane).map(({ lane: _lane, ...link }) => link)
const ending = all.filter(link => highlightLaneOf(link) === lane).map(link => { const copy = { ...link }; delete copy.lane; return copy })
const kept = all.filter(link => highlightLaneOf(link) !== lane)
return { ending, kept }
}
@@ -397,24 +398,18 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
)
}
function EmailTemplatesPanel({
form,
handleChange,
renderSaveStatus,
renderImageAssetSelector,
renderImagePreview,
// Module-level (not nested in EmailTemplatesPanel) so its identity is stable across
// renders — otherwise every keystroke remounts the card and the focused input loses focus.
function EmailTemplateCard({
type, icon, title, trigger, autoVars, fieldKeys, openKey,
form, handleChange, renderImageAssetSelector, renderImagePreview, expandedEmail, setExpandedEmail,
}: {
form: SiteContent
handleChange: (key: StringField, value: string) => void
renderSaveStatus: () => React.ReactNode
renderImageAssetSelector: (value: string | null | undefined, onChange: (value: string) => void, fieldId: string) => React.ReactNode
renderImagePreview: (value: string | null | undefined, alt: string) => React.ReactNode
}) {
const [expandedEmail, setExpandedEmail] = useState<string | null>(null)
function EmailTemplateCard({
type, icon, title, trigger, autoVars, fieldKeys, openKey,
}: {
expandedEmail: string | null
setExpandedEmail: (key: string | null) => void
type: 'newsletter' | 'study' | 'transactional'
icon: string
title: string
@@ -422,7 +417,7 @@ function EmailTemplatesPanel({
autoVars?: string
fieldKeys: string[]
openKey: string
}) {
}) {
const isOpen = expandedEmail === openKey
return (
<div className={`admin-et-card admin-et-card--${type}${isOpen ? ' admin-et-card--open' : ''}`}>
@@ -467,7 +462,23 @@ function EmailTemplatesPanel({
)}
</div>
)
}
}
function EmailTemplatesPanel({
form,
handleChange,
renderSaveStatus,
renderImageAssetSelector,
renderImagePreview,
}: {
form: SiteContent
handleChange: (key: StringField, value: string) => void
renderSaveStatus: () => React.ReactNode
renderImageAssetSelector: (value: string | null | undefined, onChange: (value: string) => void, fieldId: string) => React.ReactNode
renderImagePreview: (value: string | null | undefined, alt: string) => React.ReactNode
}) {
const [expandedEmail, setExpandedEmail] = useState<string | null>(null)
return (
<section className="admin-panel-section" aria-label="Email Templates">
@@ -476,79 +487,79 @@ function EmailTemplatesPanel({
<p>Every automated email the site sends — edit subject lines, body copy, and sign-offs. Publish to apply changes.</p>
</div>
<EmailTemplateCard type="newsletter" icon="📬" title="Newsletter Welcome"
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="newsletter" icon="📬" title="Newsletter Welcome"
trigger="Someone subscribes via the homepage form or contact page"
autoVars="Subscriber's first name, Spotify / Apple / Amazon links"
fieldKeys={['welcomeEmailSubject','welcomeEmailImageUrl','welcomeEmailGreetingPrefix','welcomeEmailIntro','welcomeEmailCurrentSeries','welcomeEmailStartHereTitle','welcomeEmailStartHereSummary','welcomeEmailStartHereUrl','welcomeEmailStartHereLinkLabel','welcomeEmailWhatToExpect1','welcomeEmailWhatToExpect2','welcomeEmailWhatToExpect3','welcomeEmailScripture','welcomeEmailScriptureRef','welcomeEmailSignoff','welcomeEmailSpotifyUrl','welcomeEmailSpotifyBtnLabel','welcomeEmailAppleUrl','welcomeEmailAppleBtnLabel','welcomeEmailAmazonUrl']}
openKey="newsletter-welcome" />
<EmailTemplateCard type="study" icon="📖" title="Study Account — New Account"
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="study" icon="📖" title="Study Account — New Account"
trigger="A student creates a study account"
autoVars="Student's display name, link to study hub, link to account page"
fieldKeys={['studyWelcomeEmailSubject','studyWelcomeEmailBody','studyWelcomeEmailCtaLabel','studyWelcomeEmailCtaPath','studyWelcomeEmailSignoff']}
openKey="study-welcome" />
<EmailTemplateCard type="study" icon="🗑️" title="Study Account — Account Deleted"
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="study" icon="🗑️" title="Study Account — Account Deleted"
trigger="A student (or admin) deletes a study account"
autoVars="Student's display name, link to create a new account"
fieldKeys={['studyDeletedEmailSubject','studyDeletedEmailBody','studyDeletedEmailCtaLabel','studyDeletedEmailCtaPath','studyDeletedEmailSignoff']}
openKey="study-deleted" />
<EmailTemplateCard type="study" icon="🔔" title="Study Account — New Lesson Unlocked"
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="study" icon="🔔" title="Study Account — New Lesson Unlocked"
trigger="A lesson releases for a student who has reminders turned on"
autoVars="Student's name, lesson title, scripture reference, study track name — button links directly to the lesson (auto-generated, not editable)"
fieldKeys={['studyReminderEmailSubjectPrefix','studyReminderEmailBody','studyReminderEmailCtaLabel','studyReminderEmailSignoff']}
openKey="study-reminder" />
<EmailTemplateCard type="study" icon="💤" title="Study Account — Re-engagement Reminders"
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="study" icon="💤" title="Study Account — Re-engagement Reminders"
trigger="A student with reminders enabled hasn't logged in for the configured number of days (fires up to 3 tiers, one per check)"
autoVars="Student's name, study title, study URL, and a one-click unsubscribe link are auto-inserted"
fieldKeys={['studyReengagementDays','studyReengagementT1Subject','studyReengagementT1Body','studyReengagementT1Cta','studyReengagementT2Subject','studyReengagementT2Body','studyReengagementT2Cta','studyReengagementT3Subject','studyReengagementT3Body','studyReengagementT3Cta']}
openKey="study-reengagement" />
<EmailTemplateCard type="study" icon="🏅" title="Study Account — Completion Certificate"
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="study" icon="🏅" title="Study Account — Completion Certificate"
trigger="A student earns their certificate by completing all lessons in a study"
autoVars="Student's name, study title, and a link to the public certificate are auto-inserted"
fieldKeys={['studyCertificateEmailSubject','studyCertificateEmailBody','studyCertificateEmailCtaLabel','studyCertificateEmailSignoff']}
openKey="study-certificate" />
<EmailTemplateCard type="study" icon="📅" title="Study Account — Weekly Lesson Digest"
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="study" icon="📅" title="Study Account — Weekly Lesson Digest"
trigger="Students who choose 'Weekly Digest' mode receive one summary email per week listing all newly released lessons"
autoVars="Student's name, lesson list (title, scripture reference, link), and a one-click unsubscribe link"
fieldKeys={['studyWeeklyDigestEmailSubject','studyWeeklyDigestEmailBody','studyWeeklyDigestEmailSignoff']}
openKey="study-weekly-digest" />
<EmailTemplateCard type="transactional" icon="🔐" title="Account Security — Email Change Confirmation"
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="transactional" icon="🔐" title="Account Security — Email Change Confirmation"
trigger="A student requests to change their account email address"
autoVars="Button links to a one-time verification URL (auto-generated per request, not editable)"
fieldKeys={['emailChangeSubject','emailChangeBody','emailChangeCtaLabel']}
openKey="email-change" />
<EmailTemplateCard type="transactional" icon="✅" title="Account Security — Verify Email Address"
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="transactional" icon="✅" title="Account Security — Verify Email Address"
trigger="A student creates a study account (sent alongside the welcome email)"
autoVars="Student's display name, button links to a one-time verification URL (auto-generated, expires in 7 days, not editable)"
fieldKeys={['emailVerifySubject','emailVerifyBody','emailVerifyCtaLabel']}
openKey="email-verify" />
<EmailTemplateCard type="transactional" icon="🔑" title="Account Security — Password Reset"
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="transactional" icon="🔑" title="Account Security — Password Reset"
trigger="A student requests a password reset from the sign-in page"
autoVars="Student's display name, button links to a one-time reset URL (auto-generated, expires in 1 hour, not editable)"
fieldKeys={['passwordResetSubject','passwordResetBody','passwordResetCtaLabel']}
openKey="password-reset" />
<EmailTemplateCard type="transactional" icon="🛡️" title="Account Security — Password Changed"
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="transactional" icon="🛡️" title="Account Security — Password Changed"
trigger="A student's password is changed (self-service reset, in-account change, or admin reset) — confirms the change and that other sessions were signed out"
autoVars="Student's display name, link to account settings"
fieldKeys={['passwordChangedSubject','passwordChangedBody']}
openKey="password-changed" />
<EmailTemplateCard type="transactional" icon="📟" title="Two-Factor Auth — Email Sign-In Code"
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="transactional" icon="📟" title="Two-Factor Auth — Email Sign-In Code"
trigger="A student with email-based 2FA signs in, or requests a resend during login"
autoVars="The 6-digit code is auto-generated and inserted between the body text and expiry note — not editable"
fieldKeys={['twoFaOtpEmailSubject','twoFaOtpEmailBody','twoFaOtpEmailExpiry']}
openKey="2fa-otp" />
<EmailTemplateCard type="transactional" icon="💬" title="Q&amp;A — Question Answered Notification"
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="transactional" icon="💬" title="Q&amp;A — Question Answered Notification"
trigger={'An admin approves a Q&A question submitted with "Email me when answered" checked'}
autoVars={'Submitter\'s first name, their original question text, the answer (truncated to 600 chars), a direct link to the answer'}
fieldKeys={['qaAnsweredEmailSubject','qaAnsweredEmailBody','qaAnsweredEmailCtaLabel','qaAnsweredEmailSignoff']}
@@ -1971,6 +1982,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
.then(r => (r.ok ? r.json() : Promise.reject()))
.then(data => setDownloadStats((data as { counts: Record<string, number> }).counts ?? {}))
.catch(() => {})
// eslint-disable-next-line react-hooks/exhaustive-deps -- re-fetch is driven explicitly when the range changes
}, [])
useEffect(() => {
@@ -6857,7 +6869,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
<details style={{ marginBottom: '1rem' }}>
<summary style={{ cursor: 'pointer', fontSize: '0.85rem', opacity: 0.7 }}>Preview rendered HTML</summary>
<div style={{ marginTop: '0.75rem', padding: '1rem', background: 'var(--admin-card-bg)', borderRadius: '6px', border: '1px solid var(--admin-border)', fontSize: '0.9rem' }}
dangerouslySetInnerHTML={{ __html: broadcastBody.replace(/\{\{name\}\}/g, 'Nate (preview)') }} />
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(broadcastBody.replace(/\{\{name\}\}/g, 'Nate (preview)')) }} />
</details>
)}
{bcAudience?.segmentConfigured === false && <p className="admin-save-error">RESEND_SEGMENT_ID isn’t set on the server, so there’s no Resend segment to send to.</p>}
+165
View File
@@ -7854,6 +7854,171 @@
cursor: pointer;
transition: all 0.2s ease;
}
/* ── Start Here page ── */
.start-page {
padding: clamp(2.5rem, 6vw, 4.5rem) 1.25rem clamp(3rem, 7vw, 5rem);
background:
radial-gradient(ellipse at top, rgba(201, 168, 76, 0.12) 0%, transparent 55%),
var(--brand-black);
}
.start-hero {
max-width: 720px;
margin: 0 auto clamp(2rem, 5vw, 3.25rem);
text-align: center;
}
.start-hero h1 {
font-family: var(--brand-font-heading);
font-size: clamp(2.1rem, 5.5vw, 3.4rem);
line-height: 1.1;
color: var(--brand-warm-white);
margin: 0.4rem 0 1rem;
}
.start-intro {
font-family: var(--brand-font-body);
font-size: 1.1rem;
font-weight: 300;
line-height: 1.7;
color: var(--brand-muted);
margin: 0;
}
.start-steps {
list-style: none;
margin: 0 auto;
padding: 0;
max-width: 1040px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1.25rem;
}
.start-step {
position: relative;
display: flex;
flex-direction: column;
padding: 1.5rem 1.5rem 1.6rem;
background: linear-gradient(180deg, rgba(26, 26, 26, 0.95), rgba(16, 16, 16, 0.95));
border: 1px solid rgba(201, 168, 76, 0.22);
border-radius: 16px;
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.35);
text-align: left;
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.start-step:hover {
transform: translateY(-3px);
border-color: rgba(201, 168, 76, 0.55);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
}
/* Connector between cards on the desktop row */
.start-step:not(:last-child)::after {
content: '';
position: absolute;
top: 2.6rem;
right: -1.25rem;
width: 1.25rem;
border-top: 1px dashed rgba(201, 168, 76, 0.45);
}
.start-step-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.1rem;
}
.start-step-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.6rem;
height: 2.6rem;
border-radius: 50%;
background: rgba(201, 168, 76, 0.14);
border: 1px solid rgba(201, 168, 76, 0.6);
color: var(--brand-gold, #c9a84c);
font-family: var(--brand-font-heading);
font-size: 1.35rem;
font-weight: 700;
}
.start-step-icon {
font-size: 1.6rem;
opacity: 0.9;
}
.start-step-label {
margin: 0 0 0.3rem;
font-size: 0.72rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--brand-gold, #c9a84c);
}
.start-step-title {
margin: 0 0 0.7rem;
font-family: var(--brand-font-heading);
font-size: 1.45rem;
line-height: 1.2;
color: var(--brand-warm-white);
}
.start-step-body {
flex: 1;
margin: 0 0 1.4rem;
font-family: var(--brand-font-body);
font-size: 1rem;
line-height: 1.65;
color: #d6c7a6;
}
.start-step-cta {
align-self: flex-start;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
white-space: nowrap;
font-size: 0.9rem;
padding: 0.7rem 1.35rem;
}
.start-back {
margin: 2.25rem 0 0;
text-align: center;
}
.start-back a {
color: var(--brand-muted);
text-decoration: none;
font-size: 0.95rem;
}
.start-back a:hover {
color: var(--brand-warm-white);
text-decoration: underline;
}
@media (max-width: 900px) {
.start-steps {
grid-template-columns: 1fr;
max-width: 560px;
}
.start-step:not(:last-child)::after {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.start-step { transition: none; }
.start-step:hover { transform: none; }
}
/* New here page cards */
.start-grid {
display: grid;
+38 -29
View File
@@ -859,14 +859,12 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
{hasPrimaryGuide && (
<Link to="/downloads/primary-guide" className="resource-download-card resource-download-card--link resource-download-card--featured">
<div className="resource-download-header">
{(content.seriesImageUrl || '/images/titus-cover.png') && (
<img
src={content.seriesImageUrl || '/images/titus-cover.png'}
alt={content.seriesTitle}
className="resource-link-image resource-link-image--featured"
loading="lazy"
/>
)}
<div className="resource-download-meta">
<div className="featured-badge">Featured</div>
<span className="resource-link-label">{content.studyGuideTitle}</span>
@@ -2612,46 +2610,57 @@ function ContactPage({ content }: { content: SiteContent }) {
)
}
// Splits "Step 2: Use Q&A to Go Deeper" into its number and title so the number can be
// rendered as a badge. Titles without a "Step N:" prefix fall back to the position.
function splitStepTitle(title: string | undefined, fallbackNumber: number) {
const text = (title ?? '').trim()
const match = /^step\s*(\d+)\s*[:.\-–—]\s*(.+)$/i.exec(text)
return match ? { number: match[1], title: match[2] } : { number: String(fallbackNumber), title: text }
}
function StartHerePage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
`Start Here | ${siteTitle}`,
content.startHereIntro || 'A beginner\'s guide to Verse by Verse with Nate.',
)
const steps = [
{ icon: '🎧', title: content.startHereStep1Title, body: content.startHereStep1Body, cta: content.startHereStep1Cta, to: '/episodes', primary: false },
{ icon: '🔎', title: content.startHereStep2Title, body: content.startHereStep2Body, cta: content.startHereStep2Cta, to: '/questions', primary: false },
{ icon: '✉️', title: content.startHereStep3Title, body: content.startHereStep3Body, cta: content.startHereStep3Cta, to: '/contact', primary: true },
]
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<main className="thanks-page" aria-label="Start Here">
<div className="thanks-card" style={{ maxWidth: '980px', width: '100%' }}>
<main className="start-page" aria-labelledby="start-here-heading">
<header className="start-hero">
<p className="eyebrow">Verse by Verse with Nate</p>
<h1>{content.startHereHeading}</h1>
<p>
{content.startHereIntro}
</p>
<h1 id="start-here-heading">{content.startHereHeading}</h1>
{content.startHereIntro && <p className="start-intro">{content.startHereIntro}</p>}
</header>
<div className="start-grid" style={{ marginTop: '1.25rem' }}>
<article className="start-card">
<h3>{content.startHereStep1Title}</h3>
<p>{content.startHereStep1Body}</p>
<Link to="/episodes" className="btn-secondary">{content.startHereStep1Cta}</Link>
</article>
<article className="start-card">
<h3>{content.startHereStep2Title}</h3>
<p>{content.startHereStep2Body}</p>
<Link to="/questions" className="btn-secondary">{content.startHereStep2Cta}</Link>
</article>
<article className="start-card">
<h3>{content.startHereStep3Title}</h3>
<p>{content.startHereStep3Body}</p>
<Link to="/contact" className="btn-primary">{content.startHereStep3Cta}</Link>
</article>
<ol className="start-steps">
{steps.map((step, index) => {
const { number, title } = splitStepTitle(step.title, index + 1)
return (
<li className="start-step" key={step.to}>
<div className="start-step-top">
<span className="start-step-number" aria-hidden="true">{number}</span>
<span className="start-step-icon" aria-hidden="true">{step.icon}</span>
</div>
<p className="start-step-label">Step {number}</p>
<h2 className="start-step-title">{title}</h2>
<p className="start-step-body">{step.body}</p>
<Link to={step.to} className={`${step.primary ? 'btn-primary' : 'btn-secondary'} start-step-cta`}>
{step.cta} <span aria-hidden="true">→</span>
</Link>
</li>
)
})}
</ol>
<div style={{ marginTop: '1.4rem' }}>
<Link to="/" className="btn-secondary">Back to Site</Link>
</div>
</div>
<p className="start-back"><Link to="/">← Back to site</Link></p>
</main>
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
@@ -3339,7 +3348,7 @@ export default function App() {
script.id = 'kofi-overlay-script'
script.src = 'https://storage.ko-fi.com/cdn/scripts/overlay-widget.umd.min.js'
script.onload = () => {
;(window as any).kofiWidgetOverlay?.draw(username, {
;(window as unknown as { kofiWidgetOverlay?: { draw: (user: string, opts: Record<string, string>) => void } }).kofiWidgetOverlay?.draw(username, {
type: 'floating-chat',
'floating-chat.donateButton.text': 'Support the Show',
'floating-chat.donateButton.background-color': '#29abe0',
+1 -1
View File
@@ -75,7 +75,7 @@ export function useScrollDepthTracking() {
// --- Time on page ---
export function useTimeOnPage() {
const location = useLocation()
const enteredAt = useRef(Date.now())
const enteredAt = useRef(0)
const path = useRef(location.pathname)
useEffect(() => {
+1 -1
View File
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
import type { ChangeEvent } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
const EMAIL_REGEX = /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/
const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]{1,64}@[a-zA-Z0-9.-]{1,253}\.[a-zA-Z]{2,}$/
interface ContactFields {
firstName: string
+2 -2
View File
@@ -18,7 +18,7 @@ function formatBytes(b: number) {
}
function saveDraft(ep: string, passage: string, desc: string) {
try { localStorage.setItem(DRAFT_KEY, JSON.stringify({ ep, passage, desc })) } catch {}
try { localStorage.setItem(DRAFT_KEY, JSON.stringify({ ep, passage, desc })) } catch { /* storage unavailable */ }
}
function loadDraft() {
@@ -30,7 +30,7 @@ function loadDraft() {
}
function clearDraft() {
try { localStorage.removeItem(DRAFT_KEY) } catch {}
try { localStorage.removeItem(DRAFT_KEY) } catch { /* storage unavailable */ }
}
export function NuggetsHostPortal() {
+4 -2
View File
@@ -17,10 +17,12 @@ export function useAutosave(
interval = 30_000,
) {
const saveFnRef = useRef(saveFn)
saveFnRef.current = saveFn
const isDirtyRef = useRef(isDirty)
useEffect(() => {
saveFnRef.current = saveFn
isDirtyRef.current = isDirty
})
useEffect(() => {
const id = window.setInterval(() => {
+2 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import DOMPurify from 'dompurify'
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
import type { SiteContent, StudyProgram, StudySection } from './content'
import { DEFAULT_COLOSSIANS_STUDY_SECTIONS } from './legacyColossiansData'
@@ -2791,7 +2792,7 @@ export function ColossiansStudyNotesPage({ content }: Props) {
html?.trim() ? (
<div key={key} style={{ marginBottom: '1.25rem' }}>
<p className="study-module-focus" style={{ marginBottom: '0.5rem' }}>{ANCHOR_LABELS[key] ?? key}</p>
<div className="study-note-rich-content" dangerouslySetInnerHTML={{ __html: html }} />
<div className="study-note-rich-content" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />
</div>
) : null
))
+26
View File
@@ -0,0 +1,26 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, readFile, readdir, writeFile as plainWrite, rm } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { writeFile } from '../server/atomic-write.js'
test('replaces file contents and leaves no temp files', async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), 'atomic-'))
const file = path.join(dir, 'a.json')
await plainWrite(file, 'old')
await writeFile(file, '{"ok":true}', 'utf8')
assert.equal(await readFile(file, 'utf8'), '{"ok":true}')
assert.deepEqual(await readdir(dir), ['a.json'])
await rm(dir, { recursive: true })
})
test('a failed write keeps the old file and cleans up', async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), 'atomic-'))
const file = path.join(dir, 'a.json')
await plainWrite(file, 'old')
await assert.rejects(() => writeFile(file, { not: 'a string' }))
assert.equal(await readFile(file, 'utf8'), 'old')
assert.deepEqual(await readdir(dir), ['a.json'])
await rm(dir, { recursive: true })
})
+27
View File
@@ -0,0 +1,27 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { hashStudyPassword, verifyStudyPassword, studyPasswordNeedsRehash } from '../server/study-helpers.js'
test('new hashes are salted scrypt and verify', () => {
const a = hashStudyPassword('correct horse')
const b = hashStudyPassword('correct horse')
assert.match(a, /^scrypt\$[0-9a-f]+\$[0-9a-f]+$/)
assert.notEqual(a, b)
assert.ok(verifyStudyPassword('correct horse', a))
assert.ok(!verifyStudyPassword('wrong', a))
assert.ok(!studyPasswordNeedsRehash(a))
})
test('legacy SHA-256 hashes still verify and are flagged for rehash', () => {
const legacy = createHash('sha256').update('study-user:hunter2hunter2').digest('hex')
assert.ok(verifyStudyPassword('hunter2hunter2', legacy))
assert.ok(!verifyStudyPassword('nope', legacy))
assert.ok(studyPasswordNeedsRehash(legacy))
})
test('malformed stored values never verify', () => {
for (const bad of ['', undefined, null, 'scrypt$', 'scrypt$zz$', 'abc']) {
assert.ok(!verifyStudyPassword('x', bad))
}
})
+17
View File
@@ -0,0 +1,17 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { isPrivateAddress, assertPublicHttpsUrl } from '../server/url-safety.js'
test('private, loopback, link-local and tailscale addresses are flagged', () => {
for (const ip of ['127.0.0.1', '10.0.0.5', '192.168.1.1', '172.16.0.1', '169.254.169.254', '100.64.1.1', '::1', 'fd00::1', 'fe80::1', '::ffff:10.0.0.1']) {
assert.ok(isPrivateAddress(ip), ip)
}
for (const ip of ['8.8.8.8', '1.1.1.1', '2606:4700:4700::1111']) assert.ok(!isPrivateAddress(ip), ip)
})
test('assertPublicHttpsUrl rejects unsafe URLs', async () => {
for (const url of ['http://example.com', 'https://localhost/x', 'https://127.0.0.1/', 'https://169.254.169.254/latest', 'https://user:pw@example.com', 'https://nas.local/hook', 'not a url']) {
await assert.rejects(() => assertPublicHttpsUrl(url), url)
}
await assert.doesNotReject(() => assertPublicHttpsUrl('https://8.8.8.8/hook'))
})