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>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-09 08:45:39 -04:00
1 parent e2150e1cd7
commit 8a796964b9
27 files changed
+880 -1034

No files matched your search

+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',
},
},
])
+604 -656
View File
File diff suppressed because it is too large. Load diff
+6 -3
View File
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
"version": "2.9.8",
"version": "2.10.0",
"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,8 +18,10 @@
"@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",
"express": "^5.2.1",
+41 -5
View File
@@ -1,5 +1,6 @@
import { createHash, randomUUID, timingSafeEqual, createHmac, randomFillSync } from 'node:crypto'
import { readFile, writeFile } from 'node:fs/promises'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { parseCookies, cookieSecureFlag } from './helpers.js'
import { DATA_DIR } from './paths.js'
@@ -17,6 +18,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 +246,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 +266,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 +275,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
}
+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)
+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')
}
+81 -70
View File
@@ -23,7 +23,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,6 +397,72 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
)
}
// 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
renderImageAssetSelector: (value: string | null | undefined, onChange: (value: string) => void, fieldId: string) => React.ReactNode
renderImagePreview: (value: string | null | undefined, alt: string) => React.ReactNode
expandedEmail: string | null
setExpandedEmail: (key: string | null) => void
type: 'newsletter' | 'study' | 'transactional'
icon: string
title: string
trigger: string
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' : ''}`}>
<button
type="button"
className="admin-et-card-header"
onClick={() => setExpandedEmail(isOpen ? null : openKey)}
>
<span className="admin-et-card-icon" aria-hidden="true">{icon}</span>
<div className="admin-et-card-meta">
<div className="admin-et-card-title-row">
<span className={`admin-et-badge admin-et-badge--${type}`}>{type}</span>
<strong className="admin-et-card-title">{title}</strong>
</div>
<p className="admin-et-card-trigger">⚡ {trigger}</p>
</div>
<span className="admin-et-card-chevron">{isOpen ? '▲' : '▼'}</span>
</button>
{isOpen && (
<div className="admin-et-card-body">
{autoVars && (
<p className="admin-et-card-vars">
<span className="admin-et-card-vars-label">Auto-inserted:</span> {autoVars}
</p>
)}
{fieldKeys.map(key => {
const field = FIELDS.find(f => f.key === key && f.section === 'email-templates')
if (!field) return null
return (
<div className="admin-field" key={key}>
<label htmlFor={`field-et-${key}`}>{field.label}</label>
{field.multiline
? <textarea id={`field-et-${key}`} rows={3} value={form[key as StringField] as string} onChange={e => handleChange(key as StringField, e.target.value)} />
: <input id={`field-et-${key}`} type="text" value={form[key as StringField] as string} onChange={e => handleChange(key as StringField, e.target.value)} />
}
{key.includes('ImageUrl') && renderImageAssetSelector(form[key as StringField] as string, value => handleChange(key as StringField, value), `field-et-${key}-asset`)}
{key.includes('ImageUrl') && renderImagePreview(form[key as StringField] as string, `${field.label} preview`)}
</div>
)
})}
</div>
)}
</div>
)
}
function EmailTemplatesPanel({
form,
handleChange,
@@ -412,62 +478,6 @@ function EmailTemplatesPanel({
}) {
const [expandedEmail, setExpandedEmail] = useState<string | null>(null)
function EmailTemplateCard({
type, icon, title, trigger, autoVars, fieldKeys, openKey,
}: {
type: 'newsletter' | 'study' | 'transactional'
icon: string
title: string
trigger: string
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' : ''}`}>
<button
type="button"
className="admin-et-card-header"
onClick={() => setExpandedEmail(isOpen ? null : openKey)}
>
<span className="admin-et-card-icon" aria-hidden="true">{icon}</span>
<div className="admin-et-card-meta">
<div className="admin-et-card-title-row">
<span className={`admin-et-badge admin-et-badge--${type}`}>{type}</span>
<strong className="admin-et-card-title">{title}</strong>
</div>
<p className="admin-et-card-trigger">⚡ {trigger}</p>
</div>
<span className="admin-et-card-chevron">{isOpen ? '▲' : '▼'}</span>
</button>
{isOpen && (
<div className="admin-et-card-body">
{autoVars && (
<p className="admin-et-card-vars">
<span className="admin-et-card-vars-label">Auto-inserted:</span> {autoVars}
</p>
)}
{fieldKeys.map(key => {
const field = FIELDS.find(f => f.key === key && f.section === 'email-templates')
if (!field) return null
return (
<div className="admin-field" key={key}>
<label htmlFor={`field-et-${key}`}>{field.label}</label>
{field.multiline
? <textarea id={`field-et-${key}`} rows={3} value={form[key as StringField] as string} onChange={e => handleChange(key as StringField, e.target.value)} />
: <input id={`field-et-${key}`} type="text" value={form[key as StringField] as string} onChange={e => handleChange(key as StringField, e.target.value)} />
}
{key.includes('ImageUrl') && renderImageAssetSelector(form[key as StringField] as string, value => handleChange(key as StringField, value), `field-et-${key}-asset`)}
{key.includes('ImageUrl') && renderImagePreview(form[key as StringField] as string, `${field.label} preview`)}
</div>
)
})}
</div>
)}
</div>
)
}
return (
<section className="admin-panel-section" aria-label="Email Templates">
@@ -476,79 +486,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 +1981,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(() => {
+7 -9
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"
/>
)}
<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>
@@ -3339,7 +3337,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() {
+5 -3
View File
@@ -17,10 +17,12 @@ export function useAutosave(
interval = 30_000,
) {
const saveFnRef = useRef(saveFn)
saveFnRef.current = saveFn
const isDirtyRef = useRef(isDirty)
isDirtyRef.current = isDirty
useEffect(() => {
saveFnRef.current = saveFn
isDirtyRef.current = isDirty
})
useEffect(() => {
const id = window.setInterval(() => {
+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))
}
})