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
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:
1 parent
e2150e1cd7
commit
8a796964b9
27 files changed
+880
-1034
No files matched your search
+13
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"Living Between Two Appearings": {
|
||||
"total": 1,
|
||||
"byDay": {
|
||||
"2026-07-22": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"users": {},
|
||||
"updatedAt": "2026-09-03T21:13:33.725Z"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
])
|
||||
Generated
+604
-656
File diff suppressed because it is too large.
Load diff
+6
-3
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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&A — Question Answered Notification"
|
||||
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="transactional" icon="💬" title="Q&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
@@ -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
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user