Compare commits
3
Commits
e2150e1cd7
...
b96e9a79dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b96e9a79dc | ||
|
|
2a64f29a00 | ||
|
|
8a796964b9 |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
+615
-657
File diff suppressed because it is too large.
Load diff
+7
-3
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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
@@ -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})`)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
@@ -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&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 +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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
useEffect(() => {
|
||||
saveFnRef.current = saveFn
|
||||
isDirtyRef.current = isDirty
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => {
|
||||
|
||||
+2
-1
@@ -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
|
||||
))
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
@@ -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'))
|
||||
})
|
||||
Reference in new issue
Block a user