From 9893883b19a475927b2d47d96618ade000b5d202 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:25:52 -0400 Subject: [PATCH] Split server/index.js into one file per area Routes move to server/routes/ (auth, account, projects, reader, sharing, admin); index.js keeps setup only. A smoke test walks 90 API calls against the real server and compares every status with the pre-split behaviour. Co-Authored-By: Claude Sonnet 5.5 --- TODO.md | 2 +- server/index.js | 1061 +----------------------------- server/limiter.js | 23 + server/routes.smoke.expected.txt | 90 +++ server/routes.smoke.test.js | 167 +++++ server/routes/account.js | 133 ++++ server/routes/admin.js | 153 +++++ server/routes/auth.js | 324 +++++++++ server/routes/projects.js | 104 +++ server/routes/reader.js | 154 +++++ server/routes/sharing.js | 150 +++++ server/userPayload.js | 16 + 12 files changed, 1337 insertions(+), 1040 deletions(-) create mode 100644 server/limiter.js create mode 100644 server/routes.smoke.expected.txt create mode 100644 server/routes.smoke.test.js create mode 100644 server/routes/account.js create mode 100644 server/routes/admin.js create mode 100644 server/routes/auth.js create mode 100644 server/routes/projects.js create mode 100644 server/routes/reader.js create mode 100644 server/routes/sharing.js create mode 100644 server/userPayload.js diff --git a/TODO.md b/TODO.md index 5ef2042..53da92b 100644 --- a/TODO.md +++ b/TODO.md @@ -11,7 +11,7 @@ Ideas for the app that haven't been built yet, roughly in the order I'd do them. "restore a snapshot" button (snapshots already exist under `DATA_DIR/backups`). - [ ] **Checks on every push.** Run tests and lint on the Gitea server, plus a few real-browser tests (Playwright) for sign-in, creating a study and syncing; so far everything is tested in simulation. -- [ ] **Split `server/index.js`** (now about 1,100 lines) into one file per area (auth, projects, sharing, reader data, admin). +- [x] **Split `server/index.js`** into one file per area (`server/routes/`: auth, account, projects, reader, sharing, admin). ## Teaching together - [ ] **Comments between co-teachers.** Notes on a chunk addressed to the other person, instead of editing each other's text. diff --git a/server/index.js b/server/index.js index 1451664..16975d9 100644 --- a/server/index.js +++ b/server/index.js @@ -1,65 +1,29 @@ import express from 'express'; import compression from 'compression'; import session from 'express-session'; -import QRCode from 'qrcode'; -import { randomUUID, randomBytes } from 'crypto'; +import { randomBytes } from 'crypto'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; -import { - initDb, dataDir, listPasskeys, getAllProjects, getProject, getProjectEdited, upsertProject, deleteProject, - countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects, - enableTotp, disableTotp, setBackupCodeHashes, setPodcastName, - setShareToken, clearShareToken, getShareSettings, setShareSettings, getSharedByToken, getSharePassHash, - setOutlineShareToken, clearOutlineShareToken, - adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject, - setUserPassword, destroyAllSessionsForUser, - getAllReaderInk, setReaderInkPage, getReaderMarks, setReaderMarks, getUserPrefs, setUserPrefs, - getReadingProgress, setReadingProgress, - setReminderSettings, setVotdSettings, setPrayerReminder, getPrayers, setPrayers, - getReaderHistory, setReaderHistory, - canEditProject, listVersions, getVersion, restoreVersion, - isProjectOwner, createEditInvite, listCollaborators, countOpenEditInvites, removeCollaborator, acceptEditInvite, - getSetting, setSetting, createInvite, listInvites, deleteInvite, findOpenInvite, useInvite, - setPasswordReset, getPasswordReset, bumpPasswordResetAttempts, clearPasswordReset, -} from './db.js'; +import { initDb, dataDir } from './db.js'; import { SqliteSessionStore } from './sessionStore.js'; -import { - isValidEmail, isValidPassword, hashPassword, verifyPassword, requireAuth, requireAdmin, isAdminEmail, - generateTotpSecret, totpKeyUri, verifyTotpToken, - generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword, - generateResetCode, normalizeResetCode, -} from './auth.js'; -import { mergePrayers } from './prayers.js'; -import { votdAvailable } from './votd.js'; -import { startReminderScheduler, sendTestReminder, isValidTimeZone, assertPublicUrl, sendNtfy } from './reminders.js'; +import { startReminderScheduler } from './reminders.js'; +import { startBackupScheduler } from './backups.js'; import { registerBibleRoutes } from './bibleApi.js'; import { registerIqBibleRoutes } from './iqbible.js'; -import { registerDeviceRoutes, noteSignIn } from './devices.js'; -import { expiryFromDays, hashPasscode, checkShareAccess } from './shareLinks.js'; -import { SIGNUP_MODES, isSignupMode, checkSignup, generateInviteCode, normalizeInviteCode } from './signup.js'; -import { mergeHistory } from './readerHistory.js'; -import { buildAccountExport, accountExportName } from './accountExport.js'; -import { startBackupScheduler, runBackup, restoreSnapshot, snapshotPath } from './backups.js'; -import { logEvent, recentEvents, diskUse, backupStatus, uptimeSeconds } from './health.js'; -import { cleanMarks } from './readerMarks.js'; -import { cleanPrefsPatch, mergePrefs } from './prefs.js'; -import { isSaveConflict, parseBase } from './projectSync.js'; -import { buildOutlinePayload } from './outlineShare.js'; - -// Shape returned to the client for the signed-in user (login, MFA verify, /auth/me). -function userPayload(user) { - return { - id: user.id, email: user.email, totpEnabled: user.totpEnabled, podcastName: user.podcastName ?? null, - isAdmin: isAdminEmail(user.email), - reminderEnabled: user.reminderEnabled, ntfyUrl: user.ntfyUrl ?? null, ntfyTopic: user.ntfyTopic ?? null, - reminderTime: user.reminderTime ?? null, - votdEnabled: !!user.votdEnabled, votdTime: user.votdTime ?? null, votdAvailable: votdAvailable(), - prayerEnabled: !!user.prayerEnabled, prayerTime: user.prayerTime ?? null, - loginAlerts: !!user.loginAlerts, passkeyCount: listPasskeys(user.id).length, - }; -} +import { registerDeviceRoutes } from './devices.js'; +import { requireAuth } from './auth.js'; +import { userPayload } from './userPayload.js'; +import { isLimited, recordFailure, clearFailures, TOO_MANY } from './limiter.js'; +import { registerAuthRoutes } from './routes/auth.js'; +import { registerAccountRoutes } from './routes/account.js'; +import { registerProjectRoutes } from './routes/projects.js'; +import { registerReaderRoutes } from './routes/reader.js'; +import { registerSharingRoutes } from './routes/sharing.js'; +import { registerAdminRoutes } from './routes/admin.js'; +// The server's setup: sessions and middleware, then each area registers its own routes (server/routes/*.js, +// plus the Bible text, IQ Bible and passkey modules), then the built app is served and the server starts. const __dirname = dirname(fileURLToPath(import.meta.url)); const app = express(); const PORT = process.env.PORT || 3001; @@ -87,40 +51,6 @@ function sessionSecret() { // Trust the reverse proxy (needed for secure cookies to work behind nginx/etc). app.set('trust proxy', 1); -// In-memory limiter for sign-in style endpoints: 10 FAILED attempts per 15 min, tracked per IP and per -// account (key). Successful sign-ins don't count, so a shared IP doesn't lock everyone out. -const _failures = new Map(); -const FAIL_WINDOW = 15 * 60 * 1000; -const FAIL_MAX = 10; -setInterval(() => { - const now = Date.now(); - for (const [k, e] of _failures) if (now > e.resetAt) _failures.delete(k); -}, 60 * 60 * 1000).unref(); -function isLimited(...keys) { - const now = Date.now(); - return keys.some((k) => { const e = _failures.get(k); return e && now <= e.resetAt && e.count >= FAIL_MAX; }); -} -function recordFailure(...keys) { - const now = Date.now(); - for (const k of keys) { - const e = _failures.get(k); - if (!e || now > e.resetAt) _failures.set(k, { count: 1, resetAt: now + FAIL_WINDOW }); - else e.count += 1; - } -} -function clearFailures(...keys) { keys.forEach((k) => _failures.delete(k)); } -const TOO_MANY = { error: 'Too many attempts. Please wait 15 minutes.' }; - -// Compared against when an email isn't registered, so a login takes the same time either way. -const DUMMY_HASH = '$2b$10$UbGSJvxosy5oInS6zfvqM.gWOUv9ypVg81ou.jWOswHyYgUjpkhJK'; - -// TOTP codes already used (userId -> code), so a captured code can't be replayed inside its time window. -const _usedTotp = new Map(); -function totpAlreadyUsed(userId, token) { return _usedTotp.get(userId) === token; } -function markTotpUsed(userId, token) { - _usedTotp.set(userId, token); - setTimeout(() => { if (_usedTotp.get(userId) === token) _usedTotp.delete(userId); }, 90 * 1000).unref(); -} // Gzip responses — the interlinear JSON files shrink ~10x, and JS/CSS/API payloads too. app.use(compression()); @@ -149,962 +79,15 @@ app.get('/api/health', (_req, res) => { res.json({ ok: true }); }); -// --------------------------------------------------------------------------- -// Auth -// --------------------------------------------------------------------------- - -app.post('/api/auth/register', async (req, res) => { - try { - if (isLimited(`ip:${req.ip}`)) return res.status(429).json(TOO_MANY); - recordFailure(`ip:${req.ip}`); // every registration attempt counts - const email = String(req.body?.email ?? '').trim().toLowerCase(); - const password = String(req.body?.password ?? ''); - - const inviteCode = normalizeInviteCode(req.body?.invite); - const allowed = checkSignup({ mode: getSetting('signup', 'open'), userCount: countUsers(), inviteOk: !!findOpenInvite(inviteCode) }); - if (!allowed.ok) return res.status(allowed.status).json({ error: allowed.error }); - if (!isValidEmail(email)) { - return res.status(400).json({ error: 'Enter a valid email address.' }); - } - if (!isValidPassword(password)) { - return res.status(400).json({ error: 'Password must be at least 8 characters.' }); - } - if (getUserByEmail(email)) { - return res.status(409).json({ error: 'An account with that email already exists.' }); - } - - const passwordHash = await hashPassword(password); - const user = createUser({ id: randomUUID(), email, passwordHash }); - if (getSetting('signup', 'open') === 'invite' && countUsers() > 1 && !useInvite(inviteCode, user.id)) { - adminDeleteUser(user.id); // someone used the code between the check and now - return res.status(403).json({ error: 'That invite code was just used. Ask for a new one.' }); - } - - // The very first account inherits any projects created before multi-user support existed. - if (countUsers() === 1) { - claimOrphanProjects(user.id); - } - - req.session.regenerate((err) => { - if (err) return res.status(500).json({ error: 'Could not create session.' }); - req.session.userId = user.id; - noteSignIn(req, user, 'register'); - res.json({ - id: user.id, email: user.email, totpEnabled: false, podcastName: null, isAdmin: isAdminEmail(user.email), - reminderEnabled: false, ntfyUrl: null, ntfyTopic: null, reminderTime: null, - }); - }); - } catch (err) { - console.error('POST /api/auth/register error:', err); - res.status(500).json({ error: 'Failed to register.' }); - } -}); - -app.post('/api/auth/login', async (req, res) => { - try { - const email = String(req.body?.email ?? '').trim().toLowerCase(); - const password = String(req.body?.password ?? ''); - const keys = [`ip:${req.ip}`, `acct:${email}`]; - if (isLimited(...keys)) return res.status(429).json(TOO_MANY); - - const user = getUserByEmail(email); - const valid = await verifyPassword(password, user?.passwordHash ?? DUMMY_HASH) && !!user; - if (!valid) { - recordFailure(...keys); - logEvent('signin', `Wrong password for ${email.slice(0, 80) || '(blank)'} from ${req.ip}`); - return res.status(401).json({ error: 'Incorrect email or password.' }); - } - clearFailures(`acct:${email}`); - - req.session.regenerate((err) => { - if (err) return res.status(500).json({ error: 'Could not create session.' }); - if (user.totpEnabled) { - // Password is correct, but the session stays unauthenticated (no userId) - // until a valid TOTP/backup code lands on /api/auth/mfa/verify. - req.session.pendingUserId = user.id; - return res.json({ mfaRequired: true }); - } - req.session.userId = user.id; - noteSignIn(req, user, 'password'); - res.json(userPayload(user)); - }); - } catch (err) { - console.error('POST /api/auth/login error:', err); - res.status(500).json({ error: 'Failed to log in.' }); - } -}); - -app.post('/api/auth/mfa/verify', async (req, res) => { - try { - const pendingUserId = req.session?.pendingUserId; - if (!pendingUserId) { - return res.status(400).json({ error: 'No sign-in in progress.' }); - } - const keys = [`ip:${req.ip}`, `mfa:${pendingUserId}`]; - if (isLimited(...keys)) return res.status(429).json(TOO_MANY); - const user = getUserById(pendingUserId); - if (!user || !user.totpEnabled) { - return res.status(400).json({ error: 'No sign-in in progress.' }); - } - - const token = req.body?.token; - const backupCode = req.body?.backupCode; - let ok = token ? verifyTotpToken(String(token), user.totpSecret) && !totpAlreadyUsed(user.id, String(token)) : false; - if (ok) markTotpUsed(user.id, String(token)); - - if (!ok && backupCode) { - const remaining = await consumeBackupCode(String(backupCode), user.backupCodeHashes); - if (remaining) { - setBackupCodeHashes(user.id, remaining); - ok = true; - } - } - - if (!ok) { - recordFailure(...keys); - logEvent('signin', `Wrong two-factor code for ${user.email} from ${req.ip}`, { userId: user.id }); - return res.status(401).json({ error: 'Invalid code.' }); - } - clearFailures(`mfa:${pendingUserId}`); - - req.session.regenerate((err) => { - if (err) return res.status(500).json({ error: 'Could not create session.' }); - req.session.userId = user.id; - noteSignIn(req, user, 'mfa'); - res.json(userPayload(user)); - }); - } catch (err) { - console.error('POST /api/auth/mfa/verify error:', err); - res.status(500).json({ error: 'Failed to verify code.' }); - } -}); - -// --------------------------------------------------------------------------- -// Forgot password. There is no email here, so the one-time code goes to the account's ntfy topic (the same -// one used for reading reminders). Accounts without a topic still go through the admin. The answer is the -// same whether or not the account exists, so this can't be used to find out who has one. -// --------------------------------------------------------------------------- -const RESET_MINUTES = 15; -const RESET_REPLY = { ok: true, message: 'If that account has a notification topic set up, a reset code was just sent to it. Codes last 15 minutes. No topic? Ask the administrator for a temporary password.' }; - -app.post('/api/auth/forgot', async (req, res) => { - try { - const email = String(req.body?.email ?? '').trim().toLowerCase(); - const keys = [`ip:${req.ip}`, `forgot:${email}`]; - if (isLimited(...keys)) return res.status(429).json(TOO_MANY); - recordFailure(...keys); // every request counts - const user = isValidEmail(email) ? getUserByEmail(email) : null; - if (user?.ntfyTopic) { - const code = generateResetCode(); - setPasswordReset(user.id, await hashPassword(code), Date.now() + RESET_MINUTES * 60 * 1000); - sendNtfy(user, { title: 'Password reset code', body: `Your Bible Study reset code is ${code}. It works for ${RESET_MINUTES} minutes. If you didn't ask for it, ignore this.`, tags: 'key' }) - .catch((err) => console.error('Password reset notification failed:', err.message)); - } - res.json(RESET_REPLY); - } catch (err) { - console.error('POST /api/auth/forgot error:', err); - res.status(500).json({ error: 'Failed to start the reset.' }); - } -}); - -app.post('/api/auth/reset', async (req, res) => { - try { - const email = String(req.body?.email ?? '').trim().toLowerCase(); - const keys = [`ip:${req.ip}`, `reset:${email}`]; - if (isLimited(...keys)) return res.status(429).json(TOO_MANY); - const fail = (error) => { recordFailure(...keys); return res.status(400).json({ error }); }; - const newPassword = String(req.body?.newPassword ?? ''); - if (!isValidPassword(newPassword)) return res.status(400).json({ error: 'New password must be at least 8 characters.' }); - - const user = getUserByEmail(email); - const reset = user ? getPasswordReset(user.id) : null; - const code = normalizeResetCode(req.body?.code); - // Always spend one bcrypt compare, so timing doesn't show whether a code was issued - const codeOk = await verifyPassword(code, reset?.codeHash ?? DUMMY_HASH) && !!reset && reset.attempts < 5; - if (reset && !codeOk) bumpPasswordResetAttempts(user.id); - if (!codeOk) return fail('That code is wrong or has expired. Ask for a new one.'); - if (user.totpEnabled) { - const token = String(req.body?.token ?? ''); - if (!verifyTotpToken(token, user.totpSecret) || totpAlreadyUsed(user.id, token)) return fail('Enter the current 6-digit code from your authenticator app too.'); - markTotpUsed(user.id, token); - } - setUserPassword(user.id, await hashPassword(newPassword)); - clearPasswordReset(user.id); - destroyAllSessionsForUser(user.id); - clearFailures(...keys); - res.json({ ok: true }); - } catch (err) { - console.error('POST /api/auth/reset error:', err); - res.status(500).json({ error: 'Failed to reset the password.' }); - } -}); - -// What the sign-in page needs to know: whether to offer "create an account", and whether it needs an invite code. -app.get('/api/auth/signup-mode', (_req, res) => { - res.json({ mode: countUsers() === 0 ? 'open' : getSetting('signup', 'open') }); -}); - -app.post('/api/auth/logout', (req, res) => { - req.session.destroy(() => { - res.clearCookie('connect.sid'); - res.json({ ok: true }); - }); -}); - -app.get('/api/auth/me', (req, res) => { - const user = req.session?.userId ? getUserById(req.session.userId) : null; - if (!user) return res.status(401).json({ error: 'Not signed in.' }); - res.json(userPayload(user)); -}); - -// --------------------------------------------------------------------------- -// PATCH /api/auth/profile — update account-level settings (currently just podcastName) -// --------------------------------------------------------------------------- -app.patch('/api/auth/profile', requireAuth, (req, res) => { - try { - const podcastName = String(req.body?.podcastName ?? '').trim().slice(0, 200); - setPodcastName(req.session.userId, podcastName || null); - res.json({ podcastName: podcastName || null }); - } catch (err) { - console.error('PATCH /api/auth/profile error:', err); - res.status(500).json({ error: 'Failed to save profile.' }); - } -}); - -// --------------------------------------------------------------------------- -// PATCH /api/auth/reminder-settings — daily reading reminder via ntfy.sh -// --------------------------------------------------------------------------- -app.patch('/api/auth/reminder-settings', requireAuth, async (req, res) => { - try { - const reminderEnabled = !!req.body?.reminderEnabled; - const ntfyTopic = String(req.body?.ntfyTopic ?? '').trim(); - const ntfyUrl = String(req.body?.ntfyUrl ?? '').trim(); - const reminderTime = String(req.body?.reminderTime ?? '').trim(); - const reminderTz = isValidTimeZone(req.body?.reminderTz) ? String(req.body.reminderTz) : null; - - if (ntfyTopic && !/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopic)) { - return res.status(400).json({ error: 'Topic can only contain letters, numbers, "-" and "_".' }); - } - if (ntfyUrl && !/^https?:\/\/.{1,200}$/.test(ntfyUrl)) { - return res.status(400).json({ error: 'Server URL must start with http:// or https://.' }); - } - if (ntfyUrl) { - try { await assertPublicUrl(ntfyUrl); } catch (err) { return res.status(400).json({ error: err.message }); } - } - if (reminderEnabled) { - if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to enable reminders.' }); - if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(reminderTime)) { - return res.status(400).json({ error: 'Reminder time must be in HH:MM format.' }); - } - } - - // the verse of the day is optional, and only offered when the server can look it up - const votdEnabled = !!req.body?.votdEnabled && votdAvailable(); - const votdTime = String(req.body?.votdTime ?? '').trim(); - if (votdEnabled) { - if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to send the verse of the day.' }); - if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(votdTime)) return res.status(400).json({ error: 'The verse of the day time must be in HH:MM format.' }); - } - setVotdSettings(req.session.userId, { votdEnabled, votdTime: votdTime || null }); - const prayerEnabled = !!req.body?.prayerEnabled; - const prayerTime = String(req.body?.prayerTime ?? '').trim(); - if (prayerEnabled) { - if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to send the prayer reminder.' }); - if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(prayerTime)) return res.status(400).json({ error: 'The prayer reminder time must be in HH:MM format.' }); - } - setPrayerReminder(req.session.userId, { prayerEnabled, prayerTime: prayerTime || null }); - setReminderSettings(req.session.userId, { - ntfyUrl: ntfyUrl || null, - ntfyTopic: ntfyTopic || null, - reminderTime: reminderTime || null, - reminderEnabled, - reminderTz, - }); - res.json({ - reminderEnabled, ntfyUrl: ntfyUrl || null, ntfyTopic: ntfyTopic || null, reminderTime: reminderTime || null, - votdEnabled, votdTime: votdTime || null, prayerEnabled, prayerTime: prayerTime || null, - }); - } catch (err) { - console.error('PATCH /api/auth/reminder-settings error:', err); - res.status(500).json({ error: 'Failed to save reminder settings.' }); - } -}); - -// --------------------------------------------------------------------------- -// POST /api/auth/change-password — self-service password change (Account Settings) -// --------------------------------------------------------------------------- -app.post('/api/auth/change-password', requireAuth, async (req, res) => { - try { - const user = getUserById(req.session.userId); - const currentPassword = String(req.body?.currentPassword ?? ''); - const newPassword = String(req.body?.newPassword ?? ''); - - if (isLimited(`pw:${user.id}`)) return res.status(429).json(TOO_MANY); - const valid = await verifyPassword(currentPassword, user.passwordHash); - if (!valid) { - recordFailure(`pw:${user.id}`); - return res.status(401).json({ error: 'Current password is incorrect.' }); - } - if (!isValidPassword(newPassword)) { - return res.status(400).json({ error: 'New password must be at least 8 characters.' }); - } - - clearFailures(`pw:${user.id}`); - const passwordHash = await hashPassword(newPassword); - setUserPassword(user.id, passwordHash); - // Sign out every other session (e.g. a stolen one) but keep this one logged in. - destroyAllSessionsForUser(user.id, req.sessionID); - res.json({ ok: true }); - } catch (err) { - console.error('POST /api/auth/change-password error:', err); - res.status(500).json({ error: 'Failed to change password.' }); - } -}); - -// --------------------------------------------------------------------------- -// Two-factor auth setup (requires an already-authenticated session) -// --------------------------------------------------------------------------- - -app.post('/api/auth/mfa/setup', requireAuth, (req, res) => { - try { - const user = getUserById(req.session.userId); - const secret = generateTotpSecret(); - // Held only in the session until confirmed with a real code — never written - // to the DB (and 2FA never turned on) unless /mfa/enable succeeds below. - req.session.pendingTotpSecret = secret; - QRCode.toDataURL(totpKeyUri(user.email, secret), (err, qrCodeDataUrl) => { - if (err) return res.status(500).json({ error: 'Failed to generate QR code.' }); - res.json({ secret, qrCodeDataUrl }); - }); - } catch (err) { - console.error('POST /api/auth/mfa/setup error:', err); - res.status(500).json({ error: 'Failed to start 2FA setup.' }); - } -}); - -app.post('/api/auth/mfa/enable', requireAuth, async (req, res) => { - try { - const secret = req.session.pendingTotpSecret; - if (!secret) { - return res.status(400).json({ error: 'Start 2FA setup first.' }); - } - if (isLimited(`mfaset:${req.session.userId}`)) return res.status(429).json(TOO_MANY); - if (!verifyTotpToken(String(req.body?.token ?? ''), secret)) { - recordFailure(`mfaset:${req.session.userId}`); - return res.status(401).json({ error: 'That code didn\'t match. Check your authenticator app and try again.' }); - } - - const backupCodes = generateBackupCodes(); - const backupCodeHashes = await hashBackupCodes(backupCodes); - enableTotp(req.session.userId, secret, backupCodeHashes); - delete req.session.pendingTotpSecret; - res.json({ backupCodes }); - } catch (err) { - console.error('POST /api/auth/mfa/enable error:', err); - res.status(500).json({ error: 'Failed to enable 2FA.' }); - } -}); - -app.post('/api/auth/mfa/disable', requireAuth, async (req, res) => { - try { - const user = getUserById(req.session.userId); - if (isLimited(`pw:${user.id}`)) return res.status(429).json(TOO_MANY); - const valid = await verifyPassword(String(req.body?.password ?? ''), user.passwordHash); - if (!valid) { - recordFailure(`pw:${user.id}`); - return res.status(401).json({ error: 'Incorrect password.' }); - } - disableTotp(user.id); - res.json({ ok: true }); - } catch (err) { - console.error('POST /api/auth/mfa/disable error:', err); - res.status(500).json({ error: 'Failed to disable 2FA.' }); - } -}); - -// --------------------------------------------------------------------------- -// --------------------------------------------------------------------------- -// GET /api/account/export — everything this account holds, as one restorable file -// --------------------------------------------------------------------------- -app.get('/api/account/export', requireAuth, (req, res) => { - try { - const userId = req.session.userId; - const user = getUserById(userId); - const backup = buildAccountExport(user, { - projects: () => getAllProjects(userId).map((p) => getProject(p.id, userId)).filter(Boolean), - prefs: () => getUserPrefs(userId), - marks: () => getReaderMarks(userId), - ink: () => getAllReaderInk(userId), - plans: () => getReadingProgress(userId), - history: () => getReaderHistory(userId), - prayers: () => getPrayers(userId).filter((p) => !p.deleted), - }); - res.set('Content-Disposition', `attachment; filename="${accountExportName()}"`); - res.json(backup); - } catch (err) { - console.error('GET /api/account/export error:', err); - res.status(500).json({ error: 'Failed to export your data.' }); - } -}); - -// GET /api/projects — list all project summaries owned by the current user -// --------------------------------------------------------------------------- -app.get('/api/projects', requireAuth, (req, res) => { - try { - const projects = getAllProjects(req.session.userId); - res.json(projects); - } catch (err) { - console.error('GET /api/projects error:', err); - res.status(500).json({ error: 'Failed to list projects.' }); - } -}); - -// --------------------------------------------------------------------------- -// GET /api/projects/:id — fetch a single full project owned by the current user -// --------------------------------------------------------------------------- -app.get('/api/projects/:id', requireAuth, (req, res) => { - try { - const project = getProject(req.params.id, req.session.userId); - if (!project) return res.status(404).json({ error: 'Project not found.' }); - res.json(project); - } catch (err) { - console.error('GET /api/projects/:id error:', err); - res.status(500).json({ error: 'Failed to load project.' }); - } -}); - -// --------------------------------------------------------------------------- -// PUT /api/projects/:id — create or update a project owned by the current user -// --------------------------------------------------------------------------- -app.put('/api/projects/:id', requireAuth, (req, res) => { - try { - const body = req.body; - if (!body || typeof body !== 'object') { - return res.status(400).json({ error: 'Invalid JSON body.' }); - } - if (!body.id || !body.title || !Array.isArray(body.chapters)) { - return res.status(400).json({ error: 'Missing required fields: id, title, chapters.' }); - } - if (body.id !== req.params.id) { - return res.status(400).json({ error: 'URL id does not match body id.' }); - } - if (body.id.length > 100) return res.status(400).json({ error: 'Invalid project id.' }); - if (body.title.length > 500) return res.status(400).json({ error: 'Project title is too long.' }); - // Refuse to overwrite a copy another device saved after the one this device last saw - const stored = getProjectEdited(body.id, req.session.userId); - if (isSaveConflict({ stored, base: parseBase(req.get('X-Base-Edited')), force: req.query.force === '1' })) { - return res.status(409).json({ error: 'This study was changed on another device.', code: 'conflict', serverEdited: stored }); - } - const saved = upsertProject(body, req.session.userId); - if (!saved) { - return res.status(403).json({ error: 'That project belongs to a different account.' }); - } - res.json(saved); - } catch (err) { - console.error('PUT /api/projects/:id error:', err); - res.status(500).json({ error: 'Failed to save project.' }); - } -}); - -// --------------------------------------------------------------------------- -// Earlier versions of a study: list, look at one, and put one back (the copy it replaces is kept too) -// --------------------------------------------------------------------------- -app.get('/api/projects/:id/versions', requireAuth, (req, res) => { - if (!canEditProject(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' }); - res.json({ versions: listVersions(req.params.id) }); -}); - -app.get('/api/projects/:id/versions/:versionId', requireAuth, (req, res) => { - if (!canEditProject(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' }); - const version = getVersion(req.params.id, Number(req.params.versionId)); - if (!version) return res.status(404).json({ error: 'That version is gone.' }); - res.json(version); -}); - -app.post('/api/projects/:id/versions/:versionId/restore', requireAuth, (req, res) => { - try { - const restored = restoreVersion(req.params.id, Number(req.params.versionId), req.session.userId); - if (!restored) return res.status(404).json({ error: 'That version is gone.' }); - res.json(restored); - } catch (err) { - console.error('POST restore error:', err); - res.status(500).json({ error: 'Failed to restore that version.' }); - } -}); - -// --------------------------------------------------------------------------- -// DELETE /api/projects/:id — remove a project owned by the current user -// --------------------------------------------------------------------------- -app.delete('/api/projects/:id', requireAuth, (req, res) => { - try { - deleteProject(req.params.id, req.session.userId); - res.json({ ok: true }); - } catch (err) { - console.error('DELETE /api/projects/:id error:', err); - res.status(500).json({ error: 'Failed to delete project.' }); - } -}); - -// --------------------------------------------------------------------------- -// Reader ink — cross-device draw annotations on Bible chapters -// --------------------------------------------------------------------------- - -// GET /api/reader/ink — all saved ink pages for the current user -app.get('/api/reader/ink', requireAuth, (req, res) => { - try { - res.json(getAllReaderInk(req.session.userId)); - } catch (err) { - console.error('GET /api/reader/ink error:', err); - res.status(500).json({ error: 'Failed to load reader ink.' }); - } -}); - -// PUT /api/reader/ink/:book/:chapter — save (upsert) one page's ink -app.put('/api/reader/ink/:book/:chapter', requireAuth, (req, res) => { - try { - const { book, chapter } = req.params; - const { strokes } = req.body ?? {}; - if (!/^[1-3A-Za-z]{3}$/.test(book) || !/^\d{1,3}$/.test(chapter)) return res.status(400).json({ error: 'Invalid book or chapter.' }); - if (!Array.isArray(strokes)) return res.status(400).json({ error: 'strokes must be an array.' }); - if (strokes.length > 20000) return res.status(413).json({ error: 'Too much ink on one page.' }); - setReaderInkPage(req.session.userId, book, chapter, strokes); - res.json({ ok: true }); - } catch (err) { - console.error('PUT /api/reader/ink error:', err); - res.status(500).json({ error: 'Failed to save reader ink.' }); - } -}); - -// GET /api/prefs — the user's synced display preferences ({ prefs: { key: { v, at } } }) -app.get('/api/prefs', requireAuth, (req, res) => { - try { - res.json({ prefs: getUserPrefs(req.session.userId) }); - } catch (err) { - console.error('GET /api/prefs error:', err); - res.status(500).json({ error: 'Failed to load preferences.' }); - } -}); - -// PUT /api/prefs — merge changed preferences in (newest change per key wins); returns the result -app.put('/api/prefs', requireAuth, (req, res) => { - try { - const merged = mergePrefs(getUserPrefs(req.session.userId), cleanPrefsPatch(req.body)); - setUserPrefs(req.session.userId, merged); - res.json({ prefs: merged }); - } catch (err) { - console.error('PUT /api/prefs error:', err); - res.status(500).json({ error: 'Failed to save preferences.' }); - } -}); - -// GET /api/reader/marks — the user's bookmarks + highlights, with an updatedAt to compare against -app.get('/api/reader/marks', requireAuth, (req, res) => { - try { - res.json(getReaderMarks(req.session.userId)); - } catch (err) { - console.error('GET /api/reader/marks error:', err); - res.status(500).json({ error: 'Failed to load bookmarks and highlights.' }); - } -}); - -// GET /api/prayers — the prayer journal; PUT merges this device's copy in (newer prayer wins) and returns the whole -app.get('/api/prayers', requireAuth, (req, res) => { - try { res.json({ prayers: mergePrayers(getPrayers(req.session.userId), []) }); } catch (err) { - console.error('GET /api/prayers error:', err); - res.status(500).json({ error: 'Failed to load your prayers.' }); - } -}); -app.put('/api/prayers', requireAuth, (req, res) => { - try { - const merged = mergePrayers(getPrayers(req.session.userId), req.body?.prayers); - setPrayers(req.session.userId, merged); - res.json({ prayers: merged }); - } catch (err) { - console.error('PUT /api/prayers error:', err); - res.status(500).json({ error: 'Failed to save your prayers.' }); - } -}); - -// GET /api/reader/history — what has been read, and PUT merges this device's copy in and returns the whole -app.get('/api/reader/history', requireAuth, (req, res) => { - try { res.json(mergeHistory(getReaderHistory(req.session.userId), {})); } catch (err) { - console.error('GET /api/reader/history error:', err); - res.status(500).json({ error: 'Failed to load your reading history.' }); - } -}); -app.put('/api/reader/history', requireAuth, (req, res) => { - try { - const merged = mergeHistory(getReaderHistory(req.session.userId), req.body); - setReaderHistory(req.session.userId, merged); - res.json(merged); - } catch (err) { - console.error('PUT /api/reader/history error:', err); - res.status(500).json({ error: 'Failed to save your reading history.' }); - } -}); - -// PUT /api/reader/marks — replace the user's bookmarks + highlights ({ bookmarks, highlights }) -app.put('/api/reader/marks', requireAuth, (req, res) => { - try { - const marks = cleanMarks(req.body); - if (!marks) return res.status(400).json({ error: 'Body must be an object.' }); - res.json({ ok: true, updatedAt: setReaderMarks(req.session.userId, marks) }); - } catch (err) { - console.error('PUT /api/reader/marks error:', err); - res.status(500).json({ error: 'Failed to save bookmarks and highlights.' }); - } -}); - -// --------------------------------------------------------------------------- -// Reading plan progress — cross-device sync for started/completed reading plans -// --------------------------------------------------------------------------- - -// GET /api/reading-plans — the current user's whole progress list +registerAuthRoutes(app); +registerAccountRoutes(app); +registerProjectRoutes(app); +registerReaderRoutes(app); registerBibleRoutes(app, requireAuth); registerIqBibleRoutes(app, requireAuth); registerDeviceRoutes(app, { requireAuth, userPayload, isLimited, recordFailure, clearFailures, tooMany: TOO_MANY }); - -app.get('/api/reading-plans', requireAuth, (req, res) => { - try { - res.json(getReadingProgress(req.session.userId)); - } catch (err) { - console.error('GET /api/reading-plans error:', err); - res.status(500).json({ error: 'Failed to load reading plan progress.' }); - } -}); - -// PUT /api/reading-plans — overwrite the current user's whole progress list -// (an object: { progress: [...], customPlans: [...] }) -app.put('/api/reading-plans', requireAuth, (req, res) => { - try { - if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) { - return res.status(400).json({ error: 'Body must be an object.' }); - } - const { progress, customPlans, activity } = req.body; - if ([progress, customPlans, activity].some((v) => v !== undefined && !Array.isArray(v)) - || (progress?.length ?? 0) > 500 || (customPlans?.length ?? 0) > 200 || (activity?.length ?? 0) > 20000) { - return res.status(400).json({ error: 'progress, customPlans and activity must be reasonably sized lists.' }); - } - setReadingProgress(req.session.userId, { progress: progress ?? [], customPlans: customPlans ?? [], activity: activity ?? [] }); - res.json({ ok: true }); - } catch (err) { - console.error('PUT /api/reading-plans error:', err); - res.status(500).json({ error: 'Failed to save reading plan progress.' }); - } -}); - -// POST /api/auth/reminder-test — sends a test notification to the topic in the request -// body (so it can be tried before saving), or to the saved settings. -const _lastReminderTest = new Map(); // userId -> timestamp, to keep test sends from being spammed -app.post('/api/auth/reminder-test', requireAuth, async (req, res) => { - const userId = req.session.userId; - if (Date.now() - (_lastReminderTest.get(userId) ?? 0) < 5000) { - return res.status(429).json({ error: 'Wait a few seconds before sending another test.' }); - } - _lastReminderTest.set(userId, Date.now()); - const saved = getUserById(userId); - const ntfyTopic = String(req.body?.ntfyTopic ?? saved?.ntfyTopic ?? '').trim(); - const ntfyUrl = String(req.body?.ntfyUrl ?? saved?.ntfyUrl ?? '').trim(); - if (!ntfyTopic) return res.status(400).json({ error: 'Enter a topic first.' }); - if (!/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopic)) { - return res.status(400).json({ error: 'Topic can only contain letters, numbers, "-" and "_".' }); - } - if (ntfyUrl && !/^https?:\/\/.{1,200}$/.test(ntfyUrl)) { - return res.status(400).json({ error: 'Server URL must start with http:// or https://.' }); - } - try { - await sendTestReminder({ ntfyTopic, ntfyUrl }); - res.json({ ok: true }); - } catch (err) { - res.status(502).json({ error: err.message }); - } -}); - -// --------------------------------------------------------------------------- -// Read-only share links -// --------------------------------------------------------------------------- - -// GET /api/projects/:id/share — current share status for the project owner -app.get('/api/projects/:id/share', requireAuth, (req, res) => { - try { - const settings = getShareSettings(req.params.id, req.session.userId); - if (!settings) return res.status(404).json({ error: 'Project not found.' }); - res.json({ shareToken: settings.token, expiresAt: settings.expiresAt, hasPasscode: settings.hasPasscode }); - } catch (err) { - console.error('GET /api/projects/:id/share error:', err); - res.status(500).json({ error: 'Failed to load share status.' }); - } -}); - -// Sets up (or changes) a read-only link: body { expiresInDays?: number | null, passcode?: string | null }. -// Leaving a field out keeps what it was; null/"" clears it. Returns the (new or existing) token. -async function saveLink(req, res, outline) { - try { - const userId = req.session.userId; - const settings = getShareSettings(req.params.id, userId, outline); - if (!settings) return res.status(404).json({ error: 'Project not found.' }); - const token = settings.token || randomUUID().replace(/-/g, ''); - if (!settings.token && !(outline ? setOutlineShareToken : setShareToken)(req.params.id, userId, token)) return res.status(404).json({ error: 'Project not found.' }); - const body = req.body ?? {}; - const expiresAt = 'expiresInDays' in body ? expiryFromDays(body.expiresInDays) : settings.expiresAt; - // a blank passcode field means "keep it"; null clears; a string sets - const current = getSharePassHash(req.params.id, userId, outline); - const passHash = 'passcode' in body ? (body.passcode === null ? null : await hashPasscode(body.passcode) ?? current) : current; - setShareSettings(req.params.id, userId, { expiresAt, passHash }, outline); - res.json({ shareToken: token, expiresAt, hasPasscode: !!passHash }); - } catch (err) { - console.error(`POST share (${outline ? 'outline' : 'study'}) error:`, err); - res.status(500).json({ error: 'Failed to enable sharing.' }); - } -} -app.post('/api/projects/:id/share', requireAuth, (req, res) => saveLink(req, res, false)); - -// DELETE /api/projects/:id/share — revoke the share link -app.delete('/api/projects/:id/share', requireAuth, (req, res) => { - try { - clearShareToken(req.params.id, req.session.userId); - res.json({ ok: true }); - } catch (err) { - console.error('DELETE /api/projects/:id/share error:', err); - res.status(500).json({ error: 'Failed to revoke sharing.' }); - } -}); - -// --------------------------------------------------------------------------- -// Co-teachers: other accounts that may edit a study. The owner makes a single-use invite link; whoever -// opens it while signed in joins. The owner (or the co-teacher themself) can remove an editor. -// --------------------------------------------------------------------------- -app.get('/api/projects/:id/collaborators', requireAuth, (req, res) => { - if (!isProjectOwner(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' }); - res.json({ collaborators: listCollaborators(req.params.id), pendingInvites: countOpenEditInvites(req.params.id) }); -}); - -app.post('/api/projects/:id/collaborators/invite', requireAuth, (req, res) => { - if (!isProjectOwner(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' }); - const token = randomUUID().replace(/-/g, ''); - const expiresAt = Date.now() + 7 * 86_400_000; - createEditInvite(token, req.params.id, expiresAt); - res.json({ token, expiresAt }); -}); - -app.delete('/api/projects/:id/collaborators/:userId', requireAuth, (req, res) => { - const me = req.session.userId; - if (!isProjectOwner(req.params.id, me) && req.params.userId !== me) return res.status(403).json({ error: 'Only the owner can remove someone else.' }); - removeCollaborator(req.params.id, req.params.userId); - res.json({ ok: true }); -}); - -app.post('/api/join/:token', requireAuth, (req, res) => { - const result = acceptEditInvite(req.params.token, req.session.userId); - if (result.error) return res.status(400).json({ error: result.error }); - res.json(result); -}); - -// Read-only outline links. A separate token from the study share link, and the public endpoint returns -// only the outlines (see outlineShare.js), never the notes or scripts. -app.get('/api/projects/:id/outline-share', requireAuth, (req, res) => { - try { - const settings = getShareSettings(req.params.id, req.session.userId, true); - res.json({ shareToken: settings?.token ?? null, expiresAt: settings?.expiresAt ?? null, hasPasscode: !!settings?.hasPasscode }); - } catch (err) { - console.error('GET /api/projects/:id/outline-share error:', err); - res.status(500).json({ error: 'Failed to load share status.' }); - } -}); - -app.post('/api/projects/:id/outline-share', requireAuth, (req, res) => saveLink(req, res, true)); - -app.delete('/api/projects/:id/outline-share', requireAuth, (req, res) => { - try { - clearOutlineShareToken(req.params.id, req.session.userId); - res.json({ ok: true }); - } catch (err) { - console.error('DELETE /api/projects/:id/outline-share error:', err); - res.status(500).json({ error: 'Failed to revoke sharing.' }); - } -}); - -// A wrong passcode counts as a failed attempt for that link and address -async function openSharedLink(req, res, outline) { - const found = getSharedByToken(req.params.token, outline); - if (!found) { res.status(404).json({ error: outline ? 'This outline link is invalid or has been turned off.' : 'This share link is invalid or has been revoked.' }); return null; } - const keys = [`ip:${req.ip}`, `share:${req.params.token}`]; - if (isLimited(...keys)) { res.status(429).json(TOO_MANY); return null; } - const access = await checkShareAccess({ expiresAt: found.expiresAt, passHash: found.passHash, supplied: req.get('X-Share-Passcode') }); - if (!access.ok) { - if (access.needsPasscode && req.get('X-Share-Passcode')) recordFailure(...keys); - res.status(access.status).json({ error: access.error, needsPasscode: !!access.needsPasscode, expired: access.status === 410 }); - return null; - } - return found.project; -} - -// GET /api/share-outline/:token — PUBLIC, no login required (a passcode may be needed) -app.get('/api/share-outline/:token', async (req, res) => { - try { - const project = await openSharedLink(req, res, true); - if (project) res.json(buildOutlinePayload(project)); - } catch (err) { - console.error('GET /api/share-outline/:token error:', err); - res.status(500).json({ error: 'Failed to load the shared outline.' }); - } -}); - -// GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only (a passcode may be needed) -app.get('/api/share/:token', async (req, res) => { - try { - const project = await openSharedLink(req, res, false); - if (project) res.json(project); - } catch (err) { - console.error('GET /api/share/:token error:', err); - res.status(500).json({ error: 'Failed to load shared project.' }); - } -}); - -// --------------------------------------------------------------------------- -// Admin — restricted to the single designated admin account (see ADMIN_EMAIL -// in server/auth.js). Full visibility/control over every user and project. -// --------------------------------------------------------------------------- - -app.get('/api/admin/users', requireAuth, requireAdmin, (req, res) => { - try { - res.json(adminGetAllUsers()); - } catch (err) { - console.error('GET /api/admin/users error:', err); - res.status(500).json({ error: 'Failed to list users.' }); - } -}); - -app.delete('/api/admin/users/:id', requireAuth, requireAdmin, (req, res) => { - try { - if (req.params.id === req.session.userId) { - return res.status(400).json({ error: "Can't delete your own admin account." }); - } - adminDeleteUser(req.params.id); - destroyAllSessionsForUser(req.params.id); - res.json({ ok: true }); - } catch (err) { - console.error('DELETE /api/admin/users/:id error:', err); - res.status(500).json({ error: 'Failed to delete user.' }); - } -}); - -// POST /api/admin/users/:id/reset-password — sets a random temporary password and -// signs the user out everywhere, since there's no self-service "forgot password" flow yet. -app.post('/api/admin/users/:id/reset-password', requireAuth, requireAdmin, async (req, res) => { - try { - const temporaryPassword = generateTemporaryPassword(); - const passwordHash = await hashPassword(temporaryPassword); - setUserPassword(req.params.id, passwordHash); - destroyAllSessionsForUser(req.params.id); - res.json({ temporaryPassword }); - } catch (err) { - console.error('POST /api/admin/users/:id/reset-password error:', err); - res.status(500).json({ error: 'Failed to reset password.' }); - } -}); - -// Who may sign up, and the invite codes for it -// Server health: disk use, snapshots, and a short log of failures (in memory; resets on restart). -app.get('/api/admin/health', requireAuth, requireAdmin, (_req, res) => { - try { - const emailOf = (id) => (id ? getUserById(id)?.email ?? null : null); - const withUser = (e) => ({ ...e, email: emailOf(e.userId) }); - res.json({ - uptimeSeconds: uptimeSeconds(), node: process.version, dataDir: dataDir(), - disk: diskUse(), backups: backupStatus(), - iqConfigured: !!process.env.IQBIBLE_API_KEY, - signinFailures: recentEvents('signin', 30).map(withUser), - notifyFailures: recentEvents('notify', 30).map(withUser), - backupFailures: recentEvents('backup', 10), - }); - } catch (err) { - console.error('GET /api/admin/health error:', err); - res.status(500).json({ error: 'Failed to read server health.' }); - } -}); - -app.post('/api/admin/backups', requireAuth, requireAdmin, async (_req, res) => { - try { - await runBackup(); - res.json(backupStatus()); - } catch (err) { - console.error('POST /api/admin/backups error:', err); - logEvent('backup', err.message); - res.status(500).json({ error: 'The snapshot could not be made.' }); - } -}); - -app.get('/api/admin/backups/:name', requireAuth, requireAdmin, (req, res) => { - const file = snapshotPath(req.params.name); - if (!file) return res.status(404).json({ error: 'No such snapshot.' }); - res.download(file, req.params.name); -}); - -// Restoring replaces every account's data, so the snapshot's name has to be typed back as confirmation. -app.post('/api/admin/backups/:name/restore', requireAuth, requireAdmin, async (req, res) => { - if (!snapshotPath(req.params.name)) return res.status(404).json({ error: 'No such snapshot.' }); - if (req.body?.confirm !== req.params.name) return res.status(400).json({ error: 'Type the snapshot’s name to confirm.' }); - try { - const result = await restoreSnapshot(req.params.name); - res.json({ restored: result.restored, safetyCopy: result.safetyCopy.split('/').pop() }); - } catch (err) { - console.error('POST /api/admin/backups/:name/restore error:', err); - res.status(500).json({ error: 'The snapshot could not be restored.' }); - } -}); - -app.get('/api/admin/signup', requireAuth, requireAdmin, (_req, res) => { - res.json({ mode: getSetting('signup', 'open'), invites: listInvites() }); -}); - -app.put('/api/admin/signup', requireAuth, requireAdmin, (req, res) => { - if (!isSignupMode(req.body?.mode)) return res.status(400).json({ error: `Mode must be one of: ${SIGNUP_MODES.join(', ')}.` }); - setSetting('signup', req.body.mode); - res.json({ mode: req.body.mode }); -}); - -app.post('/api/admin/invites', requireAuth, requireAdmin, (req, res) => { - const days = Math.min(90, Math.max(1, Number(req.body?.days) || 7)); - const code = generateInviteCode(); - createInvite(code, String(req.body?.note ?? '').trim().slice(0, 80), Date.now() + days * 86_400_000); - res.json({ code, expiresInDays: days }); -}); - -app.delete('/api/admin/invites/:code', requireAuth, requireAdmin, (req, res) => { - deleteInvite(normalizeInviteCode(req.params.code)); - res.json({ ok: true }); -}); - -app.get('/api/admin/projects', requireAuth, requireAdmin, (req, res) => { - try { - res.json(adminGetAllProjects()); - } catch (err) { - console.error('GET /api/admin/projects error:', err); - res.status(500).json({ error: 'Failed to list projects.' }); - } -}); - -app.get('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => { - try { - const project = adminGetProject(req.params.id); - if (!project) return res.status(404).json({ error: 'Project not found.' }); - res.json(project); - } catch (err) { - console.error('GET /api/admin/projects/:id error:', err); - res.status(500).json({ error: 'Failed to load project.' }); - } -}); - -app.delete('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => { - try { - adminDeleteProject(req.params.id); - res.json({ ok: true }); - } catch (err) { - console.error('DELETE /api/admin/projects/:id error:', err); - res.status(500).json({ error: 'Failed to delete project.' }); - } -}); +registerSharingRoutes(app); +registerAdminRoutes(app); // --------------------------------------------------------------------------- // Serve Vite production build (when NODE_ENV=production) diff --git a/server/limiter.js b/server/limiter.js new file mode 100644 index 0000000..f44bd51 --- /dev/null +++ b/server/limiter.js @@ -0,0 +1,23 @@ +// In-memory limiter for sign-in style endpoints: 10 FAILED attempts per 15 min, tracked per IP and per +// account (key). Successful sign-ins don't count, so a shared IP doesn't lock everyone out. +const _failures = new Map(); +const FAIL_WINDOW = 15 * 60 * 1000; +const FAIL_MAX = 10; +setInterval(() => { + const now = Date.now(); + for (const [k, e] of _failures) if (now > e.resetAt) _failures.delete(k); +}, 60 * 60 * 1000).unref(); +export function isLimited(...keys) { + const now = Date.now(); + return keys.some((k) => { const e = _failures.get(k); return e && now <= e.resetAt && e.count >= FAIL_MAX; }); +} +export function recordFailure(...keys) { + const now = Date.now(); + for (const k of keys) { + const e = _failures.get(k); + if (!e || now > e.resetAt) _failures.set(k, { count: 1, resetAt: now + FAIL_WINDOW }); + else e.count += 1; + } +} +export function clearFailures(...keys) { keys.forEach((k) => _failures.delete(k)); } +export const TOO_MANY = { error: 'Too many attempts. Please wait 15 minutes.' }; diff --git a/server/routes.smoke.expected.txt b/server/routes.smoke.expected.txt new file mode 100644 index 0000000..cde3e84 --- /dev/null +++ b/server/routes.smoke.expected.txt @@ -0,0 +1,90 @@ +anon GET /api/health 200 +anon GET /api/auth/signup-mode 200 +anon GET /api/auth/me 401 +anon GET /api/projects 401 +anon GET /api/admin/users 401 +admin POST /api/auth/register 200 +sam POST /api/auth/register 200 +anon POST /api/auth/register 400 +anon POST /api/auth/login 401 +admin POST /api/auth/login 200 +admin GET /api/auth/me 200 +sam GET /api/auth/me 200 +anon POST /api/auth/forgot 200 +anon POST /api/auth/reset 400 +admin PATCH /api/auth/profile 200 +admin PATCH /api/auth/reminder-settings 400 +admin POST /api/auth/reminder-test 400 +admin POST /api/auth/change-password 401 +admin POST /api/auth/mfa/setup 200 +admin POST /api/auth/mfa/enable 401 +anon POST /api/auth/mfa/verify 400 +admin GET /api/account/export 200 +admin PUT /api/projects/s1 200 +admin PUT /api/projects/s1 400 +admin PUT /api/projects/s1 400 +admin GET /api/projects 200 +admin GET /api/projects/s1 200 +sam GET /api/projects/s1 404 +admin PUT /api/projects/s1 409 +admin GET /api/projects/s1/versions 200 +admin GET /api/projects/s1/versions/999 404 +admin POST /api/projects/s1/versions/999/restore 404 +sam GET /api/projects/s1/versions 404 +admin PUT /api/reader/ink/ROM/8 200 +admin PUT /api/reader/ink/ROM/8 400 +admin PUT /api/reader/ink/ROMANS/8 400 +admin GET /api/reader/ink 200 +admin PUT /api/prefs 200 +admin GET /api/prefs 200 +admin PUT /api/reader/marks 200 +admin GET /api/reader/marks 200 +admin PUT /api/reader/marks 400 +admin PUT /api/prayers 200 +admin GET /api/prayers 200 +admin PUT /api/reader/history 200 +admin GET /api/reader/history 200 +admin PUT /api/reading-plans 200 +admin PUT /api/reading-plans 400 +admin GET /api/reading-plans 200 +admin GET /api/iq/status 200 +admin GET /api/iq/nope 404 +admin GET /api/passkeys 200 +admin GET /api/sessions 200 +admin POST /api/auth/passkey/options 200 +anon GET /api/bible/nope 404 +admin POST /api/projects/s1/share 200 +admin GET /api/projects/s1/share 200 +anon GET /api/share/:token 200 +anon GET /api/share/doesnotexist 404 +admin POST /api/projects/s1/outline-share 200 +anon GET /api/share-outline/:token 200 +admin GET /api/projects/s1/outline-share 200 +admin DELETE /api/projects/s1/outline-share 200 +anon GET /api/share-outline/:token 404 +admin DELETE /api/projects/s1/share 200 +admin POST /api/projects/s1/collaborators/invite 200 +sam POST /api/join/:token 200 +admin GET /api/projects/s1/collaborators 200 +sam GET /api/projects/s1/versions 200 +admin DELETE /api/projects/s1/collaborators/:id 200 +sam GET /api/admin/users 403 +admin GET /api/admin/users 200 +admin GET /api/admin/projects 200 +admin GET /api/admin/projects/s1 200 +admin GET /api/admin/signup 200 +admin PUT /api/admin/signup 400 +admin PUT /api/admin/signup 200 +admin POST /api/admin/invites 200 +admin DELETE /api/admin/invites/:code 200 +admin GET /api/admin/health 200 +admin POST /api/admin/backups 200 +admin GET /api/admin/backups/nope.db 404 +admin POST /api/admin/backups/projects-2000-01-01-00-00.db/restore 404 +sam GET /api/auth/me 200 +admin POST /api/admin/users/:id/reset-password 200 +sam GET /api/auth/me 401 +admin DELETE /api/admin/users/:id 200 +admin DELETE /api/projects/s1 200 +admin POST /api/auth/logout 200 +admin GET /api/auth/me 401 diff --git a/server/routes.smoke.test.js b/server/routes.smoke.test.js new file mode 100644 index 0000000..49f89ca --- /dev/null +++ b/server/routes.smoke.test.js @@ -0,0 +1,167 @@ +// @vitest-environment node +// Starts the real server (server/index.js) on a spare port with a throwaway data folder and walks the whole API +// the way the app does: accounts, studies, synced reader data, sharing, co-teaching, admin. It records the status of +// every call, so reorganising the server's files can't quietly change what any route answers. +import { afterAll, beforeAll, expect, test } from 'vitest'; +import { spawn } from 'child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { createServer } from 'net'; + +const ADMIN = 'admin@x.test'; +let proc; let dir; let base; +const jars = {}; // who -> cookie +const log = []; + +const freePort = () => new Promise((resolve) => { const s = createServer(); s.listen(0, () => { const { port } = s.address(); s.close(() => resolve(port)); }); }); + +async function call(who, method, path, body, headers = {}) { + const res = await fetch(base + path, { + method, headers: { 'Content-Type': 'application/json', ...(jars[who] ? { Cookie: jars[who] } : {}), ...headers }, + body: body === undefined ? undefined : JSON.stringify(body), redirect: 'manual', + }); + const set = res.headers.get('set-cookie'); + if (set) jars[who] = set.split(';')[0]; + const text = await res.text(); + let json = null; try { json = JSON.parse(text); } catch { /* not JSON */ } + log.push(`${who} ${method} ${path.replace(/[0-9a-f]{20,}/g, ':token').replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, ':id').replace(/[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}/g, ':code')} ${res.status}`); + return { status: res.status, json, text }; +} + +beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), 'smoke-')); + const port = await freePort(); + base = `http://127.0.0.1:${port}`; + proc = spawn('node', ['server/index.js'], { + env: { ...process.env, PORT: String(port), DATA_DIR: dir, ADMIN_EMAIL: ADMIN, SESSION_SECRET: 'smoke', BACKUP_DISABLED: '1', NODE_ENV: 'development', IQBIBLE_API_KEY: '' }, + stdio: 'ignore', + }); + for (let i = 0; i < 100; i += 1) { + try { if ((await fetch(`${base}/api/health`)).ok) return; } catch { /* not up yet */ } + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error('server did not start'); +}, 20000); +afterAll(() => { proc?.kill(); rmSync(dir, { recursive: true, force: true }); }); + +const study = (id, title = 'Romans') => ({ id, title, translation: 'BSB', lastEdited: 1, chapters: [{ book: 'Romans', bookAbbrev: 'ROM', chapter: '8', verses: [{ number: 1, text: 'x' }], chunks: [] }] }); + +test('the whole API answers the way it always has', async () => { + // ---- public ---- + expect((await call('anon', 'GET', '/api/health')).status).toBe(200); + expect((await call('anon', 'GET', '/api/auth/signup-mode')).json.mode).toBe('open'); + expect((await call('anon', 'GET', '/api/auth/me')).status).toBe(401); + expect((await call('anon', 'GET', '/api/projects')).status).toBe(401); + expect((await call('anon', 'GET', '/api/admin/users')).status).toBe(401); + + // ---- accounts ---- + expect((await call('admin', 'POST', '/api/auth/register', { email: ADMIN, password: 'correct-horse-1' })).status).toBe(200); + expect((await call('sam', 'POST', '/api/auth/register', { email: 'sam@x.test', password: 'correct-horse-2' })).status).toBe(200); + expect((await call('anon', 'POST', '/api/auth/register', { email: 'bad', password: 'x' })).status).toBe(400); + expect((await call('anon', 'POST', '/api/auth/login', { email: ADMIN, password: 'wrong-password' })).status).toBe(401); + expect((await call('admin', 'POST', '/api/auth/login', { email: ADMIN, password: 'correct-horse-1' })).status).toBe(200); + const me = await call('admin', 'GET', '/api/auth/me'); + expect(me.json).toMatchObject({ email: ADMIN, isAdmin: true, totpEnabled: false, passkeyCount: 0 }); + expect((await call('sam', 'GET', '/api/auth/me')).json.isAdmin).toBe(false); + expect((await call('anon', 'POST', '/api/auth/forgot', { email: 'sam@x.test' })).json.ok).toBe(true); + expect((await call('anon', 'POST', '/api/auth/reset', { email: 'sam@x.test', code: 'nope', newPassword: 'another-pass-3' })).status).toBe(400); + expect((await call('admin', 'PATCH', '/api/auth/profile', { podcastName: 'My Show' })).status).toBe(200); + expect((await call('admin', 'PATCH', '/api/auth/reminder-settings', { reminderEnabled: true, ntfyTopic: '', reminderTime: '08:00' })).status).toBe(400); + expect((await call('admin', 'POST', '/api/auth/reminder-test', { ntfyTopic: 'bad topic!' })).status).toBe(400); + expect((await call('admin', 'POST', '/api/auth/change-password', { currentPassword: 'nope', newPassword: 'brand-new-pass-4' })).status).toBe(401); + expect((await call('admin', 'POST', '/api/auth/mfa/setup')).json.secret).toBeTruthy(); + expect((await call('admin', 'POST', '/api/auth/mfa/enable', { token: '000000' })).status).toBe(401); + expect((await call('anon', 'POST', '/api/auth/mfa/verify', { token: '000000' })).status).toBe(400); + expect((await call('admin', 'GET', '/api/account/export')).json.app).toBeTruthy(); + + // ---- studies ---- + expect((await call('admin', 'PUT', '/api/projects/s1', study('s1'))).status).toBe(200); + expect((await call('admin', 'PUT', '/api/projects/s1', study('other'))).status).toBe(400); + expect((await call('admin', 'PUT', '/api/projects/s1', { id: 's1' })).status).toBe(400); + expect((await call('admin', 'GET', '/api/projects')).json).toHaveLength(1); + expect((await call('admin', 'GET', '/api/projects/s1')).json.title).toBe('Romans'); + expect((await call('sam', 'GET', '/api/projects/s1')).status).toBe(404); + expect((await call('admin', 'PUT', '/api/projects/s1', { ...study('s1', 'Romans 8'), lastEdited: 2 }, { 'X-Base-Edited': '1' })).status).toBe(409); // another device saved since + expect((await call('admin', 'GET', '/api/projects/s1/versions')).json.versions).toBeInstanceOf(Array); + expect((await call('admin', 'GET', '/api/projects/s1/versions/999')).status).toBe(404); + expect((await call('admin', 'POST', '/api/projects/s1/versions/999/restore')).status).toBe(404); + expect((await call('sam', 'GET', '/api/projects/s1/versions')).status).toBe(404); + + // ---- synced reader data ---- + expect((await call('admin', 'PUT', '/api/reader/ink/ROM/8', { strokes: [{ p: 1 }] })).status).toBe(200); + expect((await call('admin', 'PUT', '/api/reader/ink/ROM/8', { strokes: 'no' })).status).toBe(400); + expect((await call('admin', 'PUT', '/api/reader/ink/ROMANS/8', { strokes: [] })).status).toBe(400); + expect(Object.keys((await call('admin', 'GET', '/api/reader/ink')).json)).toHaveLength(1); + expect((await call('admin', 'PUT', '/api/prefs', { prefs: { theme: { v: 'dark', at: 5 } } })).json.prefs.theme.v).toBe('dark'); + expect((await call('admin', 'GET', '/api/prefs')).json.prefs.theme.v).toBe('dark'); + expect((await call('admin', 'PUT', '/api/reader/marks', { bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [] })).json.ok).toBe(true); + expect((await call('admin', 'GET', '/api/reader/marks')).json.bookmarks['JHN-3-16']).toBe('#fef08a'); + expect((await call('admin', 'PUT', '/api/reader/marks', 'x')).status).toBe(400); + expect((await call('admin', 'PUT', '/api/prayers', { prayers: [{ id: 'p1', text: 'Peace', createdAt: 1, updatedAt: 2 }] })).json.prayers).toHaveLength(1); + expect((await call('admin', 'GET', '/api/prayers')).json.prayers).toHaveLength(1); + expect((await call('admin', 'PUT', '/api/reader/history', { 'JHN-3': 1 })).status).toBe(200); + expect((await call('admin', 'GET', '/api/reader/history')).status).toBe(200); + expect((await call('admin', 'PUT', '/api/reading-plans', { progress: [{ id: 'r1' }], customPlans: [], activity: ['2026-10-01'] })).json.ok).toBe(true); + expect((await call('admin', 'PUT', '/api/reading-plans', { progress: 'no' })).status).toBe(400); + expect((await call('admin', 'GET', '/api/reading-plans')).json.progress).toHaveLength(1); + + // ---- other areas mounted on the app ---- + expect((await call('admin', 'GET', '/api/iq/status')).json.available).toBe(false); + expect((await call('admin', 'GET', '/api/iq/nope')).status).toBe(404); + expect((await call('admin', 'GET', '/api/passkeys')).json).toEqual([]); + expect((await call('admin', 'GET', '/api/sessions')).json).toHaveLength(1); + expect((await call('admin', 'POST', '/api/auth/passkey/options')).json.challenge).toBeTruthy(); + expect((await call('anon', 'GET', '/api/bible/nope')).status).toBeGreaterThanOrEqual(400); + + // ---- sharing ---- + const share = await call('admin', 'POST', '/api/projects/s1/share', {}); + expect(share.status).toBe(200); + expect((await call('admin', 'GET', '/api/projects/s1/share')).status).toBe(200); + expect((await call('anon', 'GET', `/api/share/${share.json.shareToken}`)).status).toBe(200); + expect((await call('anon', 'GET', '/api/share/doesnotexist')).status).toBe(404); + const outline = await call('admin', 'POST', '/api/projects/s1/outline-share', {}); + expect((await call('anon', 'GET', `/api/share-outline/${outline.json.shareToken}`)).status).toBe(200); + expect((await call('admin', 'GET', '/api/projects/s1/outline-share')).status).toBe(200); + expect((await call('admin', 'DELETE', '/api/projects/s1/outline-share')).status).toBe(200); + expect((await call('anon', 'GET', `/api/share-outline/${outline.json.shareToken}`)).status).toBe(404); + expect((await call('admin', 'DELETE', '/api/projects/s1/share')).status).toBe(200); + const invite = await call('admin', 'POST', '/api/projects/s1/collaborators/invite'); + expect(invite.status).toBe(200); + expect((await call('sam', 'POST', `/api/join/${invite.json.token}`)).status).toBe(200); + expect((await call('admin', 'GET', '/api/projects/s1/collaborators')).status).toBe(200); + expect((await call('sam', 'GET', '/api/projects/s1/versions')).status).toBe(200); + expect((await call('admin', 'DELETE', `/api/projects/s1/collaborators/${me.json.id}`)).status).toBe(200); + + // ---- admin ---- + expect((await call('sam', 'GET', '/api/admin/users')).status).toBe(403); + expect((await call('admin', 'GET', '/api/admin/users')).json).toHaveLength(2); + expect((await call('admin', 'GET', '/api/admin/projects')).status).toBe(200); + expect((await call('admin', 'GET', '/api/admin/projects/s1')).status).toBe(200); + expect((await call('admin', 'GET', '/api/admin/signup')).json.mode).toBe('open'); + expect((await call('admin', 'PUT', '/api/admin/signup', { mode: 'nonsense' })).status).toBe(400); + expect((await call('admin', 'PUT', '/api/admin/signup', { mode: 'invite' })).status).toBe(200); + const code = await call('admin', 'POST', '/api/admin/invites', { note: 'x', days: 7 }); + expect(code.status).toBe(200); + expect((await call('admin', 'DELETE', `/api/admin/invites/${encodeURIComponent(code.json.code ?? 'none')}`)).status).toBeGreaterThanOrEqual(200); + const health = await call('admin', 'GET', '/api/admin/health'); + expect(health.json).toMatchObject({ iqConfigured: false }); + expect(health.json.signinFailures.length).toBeGreaterThan(0); // the wrong password above + expect((await call('admin', 'POST', '/api/admin/backups')).status).toBe(200); + expect((await call('admin', 'GET', '/api/admin/backups/nope.db')).status).toBe(404); + expect((await call('admin', 'POST', '/api/admin/backups/projects-2000-01-01-00-00.db/restore', { confirm: 'x' })).status).toBe(404); + const samId = (await call('sam', 'GET', '/api/auth/me')).json.id; + expect((await call('admin', 'POST', `/api/admin/users/${samId}/reset-password`)).json.temporaryPassword).toBeTruthy(); + expect((await call('sam', 'GET', '/api/auth/me')).status).toBe(401); // signed out everywhere + expect((await call('admin', 'DELETE', `/api/admin/users/${samId}`)).status).toBe(200); + + // ---- finishing ---- + expect((await call('admin', 'DELETE', '/api/projects/s1')).status).toBe(200); + expect((await call('admin', 'POST', '/api/auth/logout')).status).toBe(200); + expect((await call('admin', 'GET', '/api/auth/me')).status).toBe(401); + + // Every call and its status, in order. Run with UPDATE_SMOKE=1 to rewrite it after a deliberate API change. + const file = new URL('./routes.smoke.expected.txt', import.meta.url); + if (process.env.UPDATE_SMOKE === '1') writeFileSync(file, `${log.join('\n')}\n`); + expect(`${log.join('\n')}\n`).toBe(readFileSync(file, 'utf8')); +}, 60000); diff --git a/server/routes/account.js b/server/routes/account.js new file mode 100644 index 0000000..be3d7bc --- /dev/null +++ b/server/routes/account.js @@ -0,0 +1,133 @@ +// Account settings: profile, reminder settings, test notification, and the full data export. +import { getAllProjects, getAllReaderInk, getPrayers, getProject, getReaderHistory, getReaderMarks, getReadingProgress, getUserById, getUserPrefs, setPodcastName, setPrayerReminder, setReminderSettings, setVotdSettings } from '../db.js'; +import { requireAuth } from '../auth.js'; +import { votdAvailable } from '../votd.js'; +import { assertPublicUrl, isValidTimeZone, sendTestReminder } from '../reminders.js'; +import { accountExportName, buildAccountExport } from '../accountExport.js'; + +export function registerAccountRoutes(app) { + // --------------------------------------------------------------------------- + // PATCH /api/auth/profile — update account-level settings (currently just podcastName) + // --------------------------------------------------------------------------- + app.patch('/api/auth/profile', requireAuth, (req, res) => { + try { + const podcastName = String(req.body?.podcastName ?? '').trim().slice(0, 200); + setPodcastName(req.session.userId, podcastName || null); + res.json({ podcastName: podcastName || null }); + } catch (err) { + console.error('PATCH /api/auth/profile error:', err); + res.status(500).json({ error: 'Failed to save profile.' }); + } + }); + + // --------------------------------------------------------------------------- + // PATCH /api/auth/reminder-settings — daily reading reminder via ntfy.sh + // --------------------------------------------------------------------------- + app.patch('/api/auth/reminder-settings', requireAuth, async (req, res) => { + try { + const reminderEnabled = !!req.body?.reminderEnabled; + const ntfyTopic = String(req.body?.ntfyTopic ?? '').trim(); + const ntfyUrl = String(req.body?.ntfyUrl ?? '').trim(); + const reminderTime = String(req.body?.reminderTime ?? '').trim(); + const reminderTz = isValidTimeZone(req.body?.reminderTz) ? String(req.body.reminderTz) : null; + + if (ntfyTopic && !/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopic)) { + return res.status(400).json({ error: 'Topic can only contain letters, numbers, "-" and "_".' }); + } + if (ntfyUrl && !/^https?:\/\/.{1,200}$/.test(ntfyUrl)) { + return res.status(400).json({ error: 'Server URL must start with http:// or https://.' }); + } + if (ntfyUrl) { + try { await assertPublicUrl(ntfyUrl); } catch (err) { return res.status(400).json({ error: err.message }); } + } + if (reminderEnabled) { + if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to enable reminders.' }); + if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(reminderTime)) { + return res.status(400).json({ error: 'Reminder time must be in HH:MM format.' }); + } + } + + // the verse of the day is optional, and only offered when the server can look it up + const votdEnabled = !!req.body?.votdEnabled && votdAvailable(); + const votdTime = String(req.body?.votdTime ?? '').trim(); + if (votdEnabled) { + if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to send the verse of the day.' }); + if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(votdTime)) return res.status(400).json({ error: 'The verse of the day time must be in HH:MM format.' }); + } + setVotdSettings(req.session.userId, { votdEnabled, votdTime: votdTime || null }); + const prayerEnabled = !!req.body?.prayerEnabled; + const prayerTime = String(req.body?.prayerTime ?? '').trim(); + if (prayerEnabled) { + if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to send the prayer reminder.' }); + if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(prayerTime)) return res.status(400).json({ error: 'The prayer reminder time must be in HH:MM format.' }); + } + setPrayerReminder(req.session.userId, { prayerEnabled, prayerTime: prayerTime || null }); + setReminderSettings(req.session.userId, { + ntfyUrl: ntfyUrl || null, + ntfyTopic: ntfyTopic || null, + reminderTime: reminderTime || null, + reminderEnabled, + reminderTz, + }); + res.json({ + reminderEnabled, ntfyUrl: ntfyUrl || null, ntfyTopic: ntfyTopic || null, reminderTime: reminderTime || null, + votdEnabled, votdTime: votdTime || null, prayerEnabled, prayerTime: prayerTime || null, + }); + } catch (err) { + console.error('PATCH /api/auth/reminder-settings error:', err); + res.status(500).json({ error: 'Failed to save reminder settings.' }); + } + }); + + // --------------------------------------------------------------------------- + // --------------------------------------------------------------------------- + // GET /api/account/export — everything this account holds, as one restorable file + // --------------------------------------------------------------------------- + app.get('/api/account/export', requireAuth, (req, res) => { + try { + const userId = req.session.userId; + const user = getUserById(userId); + const backup = buildAccountExport(user, { + projects: () => getAllProjects(userId).map((p) => getProject(p.id, userId)).filter(Boolean), + prefs: () => getUserPrefs(userId), + marks: () => getReaderMarks(userId), + ink: () => getAllReaderInk(userId), + plans: () => getReadingProgress(userId), + history: () => getReaderHistory(userId), + prayers: () => getPrayers(userId).filter((p) => !p.deleted), + }); + res.set('Content-Disposition', `attachment; filename="${accountExportName()}"`); + res.json(backup); + } catch (err) { + console.error('GET /api/account/export error:', err); + res.status(500).json({ error: 'Failed to export your data.' }); + } + }); + + // POST /api/auth/reminder-test — sends a test notification to the topic in the request + // body (so it can be tried before saving), or to the saved settings. + const _lastReminderTest = new Map(); // userId -> timestamp, to keep test sends from being spammed + app.post('/api/auth/reminder-test', requireAuth, async (req, res) => { + const userId = req.session.userId; + if (Date.now() - (_lastReminderTest.get(userId) ?? 0) < 5000) { + return res.status(429).json({ error: 'Wait a few seconds before sending another test.' }); + } + _lastReminderTest.set(userId, Date.now()); + const saved = getUserById(userId); + const ntfyTopic = String(req.body?.ntfyTopic ?? saved?.ntfyTopic ?? '').trim(); + const ntfyUrl = String(req.body?.ntfyUrl ?? saved?.ntfyUrl ?? '').trim(); + if (!ntfyTopic) return res.status(400).json({ error: 'Enter a topic first.' }); + if (!/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopic)) { + return res.status(400).json({ error: 'Topic can only contain letters, numbers, "-" and "_".' }); + } + if (ntfyUrl && !/^https?:\/\/.{1,200}$/.test(ntfyUrl)) { + return res.status(400).json({ error: 'Server URL must start with http:// or https://.' }); + } + try { + await sendTestReminder({ ntfyTopic, ntfyUrl }); + res.json({ ok: true }); + } catch (err) { + res.status(502).json({ error: err.message }); + } + }); +} diff --git a/server/routes/admin.js b/server/routes/admin.js new file mode 100644 index 0000000..817fcc9 --- /dev/null +++ b/server/routes/admin.js @@ -0,0 +1,153 @@ +// Admin only: users, studies, sign-up rules and invites, server health and snapshots. +import { adminDeleteProject, adminDeleteUser, adminGetAllProjects, adminGetAllUsers, adminGetProject, createInvite, dataDir, deleteInvite, destroyAllSessionsForUser, getSetting, getUserById, listInvites, setSetting, setUserPassword } from '../db.js'; +import { generateTemporaryPassword, hashPassword, requireAdmin, requireAuth } from '../auth.js'; +import { SIGNUP_MODES, generateInviteCode, isSignupMode, normalizeInviteCode } from '../signup.js'; +import { restoreSnapshot, runBackup, snapshotPath } from '../backups.js'; +import { backupStatus, diskUse, logEvent, recentEvents, uptimeSeconds } from '../health.js'; + +export function registerAdminRoutes(app) { + // --------------------------------------------------------------------------- + // Admin — restricted to the single designated admin account (see ADMIN_EMAIL + // in server/auth.js). Full visibility/control over every user and project. + // --------------------------------------------------------------------------- + + app.get('/api/admin/users', requireAuth, requireAdmin, (req, res) => { + try { + res.json(adminGetAllUsers()); + } catch (err) { + console.error('GET /api/admin/users error:', err); + res.status(500).json({ error: 'Failed to list users.' }); + } + }); + + app.delete('/api/admin/users/:id', requireAuth, requireAdmin, (req, res) => { + try { + if (req.params.id === req.session.userId) { + return res.status(400).json({ error: "Can't delete your own admin account." }); + } + adminDeleteUser(req.params.id); + destroyAllSessionsForUser(req.params.id); + res.json({ ok: true }); + } catch (err) { + console.error('DELETE /api/admin/users/:id error:', err); + res.status(500).json({ error: 'Failed to delete user.' }); + } + }); + + // POST /api/admin/users/:id/reset-password — sets a random temporary password and + // signs the user out everywhere, since there's no self-service "forgot password" flow yet. + app.post('/api/admin/users/:id/reset-password', requireAuth, requireAdmin, async (req, res) => { + try { + const temporaryPassword = generateTemporaryPassword(); + const passwordHash = await hashPassword(temporaryPassword); + setUserPassword(req.params.id, passwordHash); + destroyAllSessionsForUser(req.params.id); + res.json({ temporaryPassword }); + } catch (err) { + console.error('POST /api/admin/users/:id/reset-password error:', err); + res.status(500).json({ error: 'Failed to reset password.' }); + } + }); + + // Who may sign up, and the invite codes for it + // Server health: disk use, snapshots, and a short log of failures (in memory; resets on restart). + app.get('/api/admin/health', requireAuth, requireAdmin, (_req, res) => { + try { + const emailOf = (id) => (id ? getUserById(id)?.email ?? null : null); + const withUser = (e) => ({ ...e, email: emailOf(e.userId) }); + res.json({ + uptimeSeconds: uptimeSeconds(), node: process.version, dataDir: dataDir(), + disk: diskUse(), backups: backupStatus(), + iqConfigured: !!process.env.IQBIBLE_API_KEY, + signinFailures: recentEvents('signin', 30).map(withUser), + notifyFailures: recentEvents('notify', 30).map(withUser), + backupFailures: recentEvents('backup', 10), + }); + } catch (err) { + console.error('GET /api/admin/health error:', err); + res.status(500).json({ error: 'Failed to read server health.' }); + } + }); + + app.post('/api/admin/backups', requireAuth, requireAdmin, async (_req, res) => { + try { + await runBackup(); + res.json(backupStatus()); + } catch (err) { + console.error('POST /api/admin/backups error:', err); + logEvent('backup', err.message); + res.status(500).json({ error: 'The snapshot could not be made.' }); + } + }); + + app.get('/api/admin/backups/:name', requireAuth, requireAdmin, (req, res) => { + const file = snapshotPath(req.params.name); + if (!file) return res.status(404).json({ error: 'No such snapshot.' }); + res.download(file, req.params.name); + }); + + // Restoring replaces every account's data, so the snapshot's name has to be typed back as confirmation. + app.post('/api/admin/backups/:name/restore', requireAuth, requireAdmin, async (req, res) => { + if (!snapshotPath(req.params.name)) return res.status(404).json({ error: 'No such snapshot.' }); + if (req.body?.confirm !== req.params.name) return res.status(400).json({ error: 'Type the snapshot’s name to confirm.' }); + try { + const result = await restoreSnapshot(req.params.name); + res.json({ restored: result.restored, safetyCopy: result.safetyCopy.split('/').pop() }); + } catch (err) { + console.error('POST /api/admin/backups/:name/restore error:', err); + res.status(500).json({ error: 'The snapshot could not be restored.' }); + } + }); + + app.get('/api/admin/signup', requireAuth, requireAdmin, (_req, res) => { + res.json({ mode: getSetting('signup', 'open'), invites: listInvites() }); + }); + + app.put('/api/admin/signup', requireAuth, requireAdmin, (req, res) => { + if (!isSignupMode(req.body?.mode)) return res.status(400).json({ error: `Mode must be one of: ${SIGNUP_MODES.join(', ')}.` }); + setSetting('signup', req.body.mode); + res.json({ mode: req.body.mode }); + }); + + app.post('/api/admin/invites', requireAuth, requireAdmin, (req, res) => { + const days = Math.min(90, Math.max(1, Number(req.body?.days) || 7)); + const code = generateInviteCode(); + createInvite(code, String(req.body?.note ?? '').trim().slice(0, 80), Date.now() + days * 86_400_000); + res.json({ code, expiresInDays: days }); + }); + + app.delete('/api/admin/invites/:code', requireAuth, requireAdmin, (req, res) => { + deleteInvite(normalizeInviteCode(req.params.code)); + res.json({ ok: true }); + }); + + app.get('/api/admin/projects', requireAuth, requireAdmin, (req, res) => { + try { + res.json(adminGetAllProjects()); + } catch (err) { + console.error('GET /api/admin/projects error:', err); + res.status(500).json({ error: 'Failed to list projects.' }); + } + }); + + app.get('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => { + try { + const project = adminGetProject(req.params.id); + if (!project) return res.status(404).json({ error: 'Project not found.' }); + res.json(project); + } catch (err) { + console.error('GET /api/admin/projects/:id error:', err); + res.status(500).json({ error: 'Failed to load project.' }); + } + }); + + app.delete('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => { + try { + adminDeleteProject(req.params.id); + res.json({ ok: true }); + } catch (err) { + console.error('DELETE /api/admin/projects/:id error:', err); + res.status(500).json({ error: 'Failed to delete project.' }); + } + }); +} diff --git a/server/routes/auth.js b/server/routes/auth.js new file mode 100644 index 0000000..743de4b --- /dev/null +++ b/server/routes/auth.js @@ -0,0 +1,324 @@ +// Accounts: sign up, sign in (password + two-factor), password reset, change password, two-factor setup. +import QRCode from 'qrcode'; +import { randomUUID } from 'crypto'; +import { adminDeleteUser, bumpPasswordResetAttempts, claimOrphanProjects, clearPasswordReset, countUsers, createUser, destroyAllSessionsForUser, disableTotp, enableTotp, findOpenInvite, getPasswordReset, getSetting, getUserByEmail, getUserById, setBackupCodeHashes, setPasswordReset, setUserPassword, useInvite } from '../db.js'; +import { consumeBackupCode, generateBackupCodes, generateResetCode, generateTotpSecret, hashBackupCodes, hashPassword, isAdminEmail, isValidEmail, isValidPassword, normalizeResetCode, requireAuth, totpKeyUri, verifyPassword, verifyTotpToken } from '../auth.js'; +import { sendNtfy } from '../reminders.js'; +import { noteSignIn } from '../devices.js'; +import { checkSignup, normalizeInviteCode } from '../signup.js'; +import { logEvent } from '../health.js'; +import { userPayload } from '../userPayload.js'; +import { TOO_MANY, clearFailures, isLimited, recordFailure } from '../limiter.js'; + +export function registerAuthRoutes(app) { + // Compared against when an email isn't registered, so a login takes the same time either way. + const DUMMY_HASH = '$2b$10$UbGSJvxosy5oInS6zfvqM.gWOUv9ypVg81ou.jWOswHyYgUjpkhJK'; + + // TOTP codes already used (userId -> code), so a captured code can't be replayed inside its time window. + const _usedTotp = new Map(); + function totpAlreadyUsed(userId, token) { return _usedTotp.get(userId) === token; } + function markTotpUsed(userId, token) { + _usedTotp.set(userId, token); + setTimeout(() => { if (_usedTotp.get(userId) === token) _usedTotp.delete(userId); }, 90 * 1000).unref(); + } + + // --------------------------------------------------------------------------- + // Auth + // --------------------------------------------------------------------------- + + app.post('/api/auth/register', async (req, res) => { + try { + if (isLimited(`ip:${req.ip}`)) return res.status(429).json(TOO_MANY); + recordFailure(`ip:${req.ip}`); // every registration attempt counts + const email = String(req.body?.email ?? '').trim().toLowerCase(); + const password = String(req.body?.password ?? ''); + + const inviteCode = normalizeInviteCode(req.body?.invite); + const allowed = checkSignup({ mode: getSetting('signup', 'open'), userCount: countUsers(), inviteOk: !!findOpenInvite(inviteCode) }); + if (!allowed.ok) return res.status(allowed.status).json({ error: allowed.error }); + if (!isValidEmail(email)) { + return res.status(400).json({ error: 'Enter a valid email address.' }); + } + if (!isValidPassword(password)) { + return res.status(400).json({ error: 'Password must be at least 8 characters.' }); + } + if (getUserByEmail(email)) { + return res.status(409).json({ error: 'An account with that email already exists.' }); + } + + const passwordHash = await hashPassword(password); + const user = createUser({ id: randomUUID(), email, passwordHash }); + if (getSetting('signup', 'open') === 'invite' && countUsers() > 1 && !useInvite(inviteCode, user.id)) { + adminDeleteUser(user.id); // someone used the code between the check and now + return res.status(403).json({ error: 'That invite code was just used. Ask for a new one.' }); + } + + // The very first account inherits any projects created before multi-user support existed. + if (countUsers() === 1) { + claimOrphanProjects(user.id); + } + + req.session.regenerate((err) => { + if (err) return res.status(500).json({ error: 'Could not create session.' }); + req.session.userId = user.id; + noteSignIn(req, user, 'register'); + res.json({ + id: user.id, email: user.email, totpEnabled: false, podcastName: null, isAdmin: isAdminEmail(user.email), + reminderEnabled: false, ntfyUrl: null, ntfyTopic: null, reminderTime: null, + }); + }); + } catch (err) { + console.error('POST /api/auth/register error:', err); + res.status(500).json({ error: 'Failed to register.' }); + } + }); + + app.post('/api/auth/login', async (req, res) => { + try { + const email = String(req.body?.email ?? '').trim().toLowerCase(); + const password = String(req.body?.password ?? ''); + const keys = [`ip:${req.ip}`, `acct:${email}`]; + if (isLimited(...keys)) return res.status(429).json(TOO_MANY); + + const user = getUserByEmail(email); + const valid = await verifyPassword(password, user?.passwordHash ?? DUMMY_HASH) && !!user; + if (!valid) { + recordFailure(...keys); + logEvent('signin', `Wrong password for ${email.slice(0, 80) || '(blank)'} from ${req.ip}`); + return res.status(401).json({ error: 'Incorrect email or password.' }); + } + clearFailures(`acct:${email}`); + + req.session.regenerate((err) => { + if (err) return res.status(500).json({ error: 'Could not create session.' }); + if (user.totpEnabled) { + // Password is correct, but the session stays unauthenticated (no userId) + // until a valid TOTP/backup code lands on /api/auth/mfa/verify. + req.session.pendingUserId = user.id; + return res.json({ mfaRequired: true }); + } + req.session.userId = user.id; + noteSignIn(req, user, 'password'); + res.json(userPayload(user)); + }); + } catch (err) { + console.error('POST /api/auth/login error:', err); + res.status(500).json({ error: 'Failed to log in.' }); + } + }); + + app.post('/api/auth/mfa/verify', async (req, res) => { + try { + const pendingUserId = req.session?.pendingUserId; + if (!pendingUserId) { + return res.status(400).json({ error: 'No sign-in in progress.' }); + } + const keys = [`ip:${req.ip}`, `mfa:${pendingUserId}`]; + if (isLimited(...keys)) return res.status(429).json(TOO_MANY); + const user = getUserById(pendingUserId); + if (!user || !user.totpEnabled) { + return res.status(400).json({ error: 'No sign-in in progress.' }); + } + + const token = req.body?.token; + const backupCode = req.body?.backupCode; + let ok = token ? verifyTotpToken(String(token), user.totpSecret) && !totpAlreadyUsed(user.id, String(token)) : false; + if (ok) markTotpUsed(user.id, String(token)); + + if (!ok && backupCode) { + const remaining = await consumeBackupCode(String(backupCode), user.backupCodeHashes); + if (remaining) { + setBackupCodeHashes(user.id, remaining); + ok = true; + } + } + + if (!ok) { + recordFailure(...keys); + logEvent('signin', `Wrong two-factor code for ${user.email} from ${req.ip}`, { userId: user.id }); + return res.status(401).json({ error: 'Invalid code.' }); + } + clearFailures(`mfa:${pendingUserId}`); + + req.session.regenerate((err) => { + if (err) return res.status(500).json({ error: 'Could not create session.' }); + req.session.userId = user.id; + noteSignIn(req, user, 'mfa'); + res.json(userPayload(user)); + }); + } catch (err) { + console.error('POST /api/auth/mfa/verify error:', err); + res.status(500).json({ error: 'Failed to verify code.' }); + } + }); + + // --------------------------------------------------------------------------- + // Forgot password. There is no email here, so the one-time code goes to the account's ntfy topic (the same + // one used for reading reminders). Accounts without a topic still go through the admin. The answer is the + // same whether or not the account exists, so this can't be used to find out who has one. + // --------------------------------------------------------------------------- + const RESET_MINUTES = 15; + const RESET_REPLY = { ok: true, message: 'If that account has a notification topic set up, a reset code was just sent to it. Codes last 15 minutes. No topic? Ask the administrator for a temporary password.' }; + + app.post('/api/auth/forgot', async (req, res) => { + try { + const email = String(req.body?.email ?? '').trim().toLowerCase(); + const keys = [`ip:${req.ip}`, `forgot:${email}`]; + if (isLimited(...keys)) return res.status(429).json(TOO_MANY); + recordFailure(...keys); // every request counts + const user = isValidEmail(email) ? getUserByEmail(email) : null; + if (user?.ntfyTopic) { + const code = generateResetCode(); + setPasswordReset(user.id, await hashPassword(code), Date.now() + RESET_MINUTES * 60 * 1000); + sendNtfy(user, { title: 'Password reset code', body: `Your Bible Study reset code is ${code}. It works for ${RESET_MINUTES} minutes. If you didn't ask for it, ignore this.`, tags: 'key' }) + .catch((err) => console.error('Password reset notification failed:', err.message)); + } + res.json(RESET_REPLY); + } catch (err) { + console.error('POST /api/auth/forgot error:', err); + res.status(500).json({ error: 'Failed to start the reset.' }); + } + }); + + app.post('/api/auth/reset', async (req, res) => { + try { + const email = String(req.body?.email ?? '').trim().toLowerCase(); + const keys = [`ip:${req.ip}`, `reset:${email}`]; + if (isLimited(...keys)) return res.status(429).json(TOO_MANY); + const fail = (error) => { recordFailure(...keys); return res.status(400).json({ error }); }; + const newPassword = String(req.body?.newPassword ?? ''); + if (!isValidPassword(newPassword)) return res.status(400).json({ error: 'New password must be at least 8 characters.' }); + + const user = getUserByEmail(email); + const reset = user ? getPasswordReset(user.id) : null; + const code = normalizeResetCode(req.body?.code); + // Always spend one bcrypt compare, so timing doesn't show whether a code was issued + const codeOk = await verifyPassword(code, reset?.codeHash ?? DUMMY_HASH) && !!reset && reset.attempts < 5; + if (reset && !codeOk) bumpPasswordResetAttempts(user.id); + if (!codeOk) return fail('That code is wrong or has expired. Ask for a new one.'); + if (user.totpEnabled) { + const token = String(req.body?.token ?? ''); + if (!verifyTotpToken(token, user.totpSecret) || totpAlreadyUsed(user.id, token)) return fail('Enter the current 6-digit code from your authenticator app too.'); + markTotpUsed(user.id, token); + } + setUserPassword(user.id, await hashPassword(newPassword)); + clearPasswordReset(user.id); + destroyAllSessionsForUser(user.id); + clearFailures(...keys); + res.json({ ok: true }); + } catch (err) { + console.error('POST /api/auth/reset error:', err); + res.status(500).json({ error: 'Failed to reset the password.' }); + } + }); + + // What the sign-in page needs to know: whether to offer "create an account", and whether it needs an invite code. + app.get('/api/auth/signup-mode', (_req, res) => { + res.json({ mode: countUsers() === 0 ? 'open' : getSetting('signup', 'open') }); + }); + + app.post('/api/auth/logout', (req, res) => { + req.session.destroy(() => { + res.clearCookie('connect.sid'); + res.json({ ok: true }); + }); + }); + + app.get('/api/auth/me', (req, res) => { + const user = req.session?.userId ? getUserById(req.session.userId) : null; + if (!user) return res.status(401).json({ error: 'Not signed in.' }); + res.json(userPayload(user)); + }); + + // --------------------------------------------------------------------------- + // POST /api/auth/change-password — self-service password change (Account Settings) + // --------------------------------------------------------------------------- + app.post('/api/auth/change-password', requireAuth, async (req, res) => { + try { + const user = getUserById(req.session.userId); + const currentPassword = String(req.body?.currentPassword ?? ''); + const newPassword = String(req.body?.newPassword ?? ''); + + if (isLimited(`pw:${user.id}`)) return res.status(429).json(TOO_MANY); + const valid = await verifyPassword(currentPassword, user.passwordHash); + if (!valid) { + recordFailure(`pw:${user.id}`); + return res.status(401).json({ error: 'Current password is incorrect.' }); + } + if (!isValidPassword(newPassword)) { + return res.status(400).json({ error: 'New password must be at least 8 characters.' }); + } + + clearFailures(`pw:${user.id}`); + const passwordHash = await hashPassword(newPassword); + setUserPassword(user.id, passwordHash); + // Sign out every other session (e.g. a stolen one) but keep this one logged in. + destroyAllSessionsForUser(user.id, req.sessionID); + res.json({ ok: true }); + } catch (err) { + console.error('POST /api/auth/change-password error:', err); + res.status(500).json({ error: 'Failed to change password.' }); + } + }); + + // --------------------------------------------------------------------------- + // Two-factor auth setup (requires an already-authenticated session) + // --------------------------------------------------------------------------- + + app.post('/api/auth/mfa/setup', requireAuth, (req, res) => { + try { + const user = getUserById(req.session.userId); + const secret = generateTotpSecret(); + // Held only in the session until confirmed with a real code — never written + // to the DB (and 2FA never turned on) unless /mfa/enable succeeds below. + req.session.pendingTotpSecret = secret; + QRCode.toDataURL(totpKeyUri(user.email, secret), (err, qrCodeDataUrl) => { + if (err) return res.status(500).json({ error: 'Failed to generate QR code.' }); + res.json({ secret, qrCodeDataUrl }); + }); + } catch (err) { + console.error('POST /api/auth/mfa/setup error:', err); + res.status(500).json({ error: 'Failed to start 2FA setup.' }); + } + }); + + app.post('/api/auth/mfa/enable', requireAuth, async (req, res) => { + try { + const secret = req.session.pendingTotpSecret; + if (!secret) { + return res.status(400).json({ error: 'Start 2FA setup first.' }); + } + if (isLimited(`mfaset:${req.session.userId}`)) return res.status(429).json(TOO_MANY); + if (!verifyTotpToken(String(req.body?.token ?? ''), secret)) { + recordFailure(`mfaset:${req.session.userId}`); + return res.status(401).json({ error: 'That code didn\'t match. Check your authenticator app and try again.' }); + } + + const backupCodes = generateBackupCodes(); + const backupCodeHashes = await hashBackupCodes(backupCodes); + enableTotp(req.session.userId, secret, backupCodeHashes); + delete req.session.pendingTotpSecret; + res.json({ backupCodes }); + } catch (err) { + console.error('POST /api/auth/mfa/enable error:', err); + res.status(500).json({ error: 'Failed to enable 2FA.' }); + } + }); + + app.post('/api/auth/mfa/disable', requireAuth, async (req, res) => { + try { + const user = getUserById(req.session.userId); + if (isLimited(`pw:${user.id}`)) return res.status(429).json(TOO_MANY); + const valid = await verifyPassword(String(req.body?.password ?? ''), user.passwordHash); + if (!valid) { + recordFailure(`pw:${user.id}`); + return res.status(401).json({ error: 'Incorrect password.' }); + } + disableTotp(user.id); + res.json({ ok: true }); + } catch (err) { + console.error('POST /api/auth/mfa/disable error:', err); + res.status(500).json({ error: 'Failed to disable 2FA.' }); + } + }); +} diff --git a/server/routes/projects.js b/server/routes/projects.js new file mode 100644 index 0000000..24bcfb1 --- /dev/null +++ b/server/routes/projects.js @@ -0,0 +1,104 @@ +// Studies: list, load, save (with conflict checks), earlier versions, delete. +import { canEditProject, deleteProject, getAllProjects, getProject, getProjectEdited, getVersion, listVersions, restoreVersion, upsertProject } from '../db.js'; +import { requireAuth } from '../auth.js'; +import { isSaveConflict, parseBase } from '../projectSync.js'; + +export function registerProjectRoutes(app) { + // GET /api/projects — list all project summaries owned by the current user + // --------------------------------------------------------------------------- + app.get('/api/projects', requireAuth, (req, res) => { + try { + const projects = getAllProjects(req.session.userId); + res.json(projects); + } catch (err) { + console.error('GET /api/projects error:', err); + res.status(500).json({ error: 'Failed to list projects.' }); + } + }); + + // --------------------------------------------------------------------------- + // GET /api/projects/:id — fetch a single full project owned by the current user + // --------------------------------------------------------------------------- + app.get('/api/projects/:id', requireAuth, (req, res) => { + try { + const project = getProject(req.params.id, req.session.userId); + if (!project) return res.status(404).json({ error: 'Project not found.' }); + res.json(project); + } catch (err) { + console.error('GET /api/projects/:id error:', err); + res.status(500).json({ error: 'Failed to load project.' }); + } + }); + + // --------------------------------------------------------------------------- + // PUT /api/projects/:id — create or update a project owned by the current user + // --------------------------------------------------------------------------- + app.put('/api/projects/:id', requireAuth, (req, res) => { + try { + const body = req.body; + if (!body || typeof body !== 'object') { + return res.status(400).json({ error: 'Invalid JSON body.' }); + } + if (!body.id || !body.title || !Array.isArray(body.chapters)) { + return res.status(400).json({ error: 'Missing required fields: id, title, chapters.' }); + } + if (body.id !== req.params.id) { + return res.status(400).json({ error: 'URL id does not match body id.' }); + } + if (body.id.length > 100) return res.status(400).json({ error: 'Invalid project id.' }); + if (body.title.length > 500) return res.status(400).json({ error: 'Project title is too long.' }); + // Refuse to overwrite a copy another device saved after the one this device last saw + const stored = getProjectEdited(body.id, req.session.userId); + if (isSaveConflict({ stored, base: parseBase(req.get('X-Base-Edited')), force: req.query.force === '1' })) { + return res.status(409).json({ error: 'This study was changed on another device.', code: 'conflict', serverEdited: stored }); + } + const saved = upsertProject(body, req.session.userId); + if (!saved) { + return res.status(403).json({ error: 'That project belongs to a different account.' }); + } + res.json(saved); + } catch (err) { + console.error('PUT /api/projects/:id error:', err); + res.status(500).json({ error: 'Failed to save project.' }); + } + }); + + // --------------------------------------------------------------------------- + // Earlier versions of a study: list, look at one, and put one back (the copy it replaces is kept too) + // --------------------------------------------------------------------------- + app.get('/api/projects/:id/versions', requireAuth, (req, res) => { + if (!canEditProject(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' }); + res.json({ versions: listVersions(req.params.id) }); + }); + + app.get('/api/projects/:id/versions/:versionId', requireAuth, (req, res) => { + if (!canEditProject(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' }); + const version = getVersion(req.params.id, Number(req.params.versionId)); + if (!version) return res.status(404).json({ error: 'That version is gone.' }); + res.json(version); + }); + + app.post('/api/projects/:id/versions/:versionId/restore', requireAuth, (req, res) => { + try { + const restored = restoreVersion(req.params.id, Number(req.params.versionId), req.session.userId); + if (!restored) return res.status(404).json({ error: 'That version is gone.' }); + res.json(restored); + } catch (err) { + console.error('POST restore error:', err); + res.status(500).json({ error: 'Failed to restore that version.' }); + } + }); + + // --------------------------------------------------------------------------- + // DELETE /api/projects/:id — remove a project owned by the current user + // --------------------------------------------------------------------------- + app.delete('/api/projects/:id', requireAuth, (req, res) => { + try { + deleteProject(req.params.id, req.session.userId); + res.json({ ok: true }); + } catch (err) { + console.error('DELETE /api/projects/:id error:', err); + res.status(500).json({ error: 'Failed to delete project.' }); + } + }); +} diff --git a/server/routes/reader.js b/server/routes/reader.js new file mode 100644 index 0000000..a0cd709 --- /dev/null +++ b/server/routes/reader.js @@ -0,0 +1,154 @@ +// Reading data synced between devices: ink, preferences, bookmarks and highlights, prayers, reading history, plan progress. +import { getAllReaderInk, getPrayers, getReaderHistory, getReaderMarks, getReadingProgress, getUserPrefs, setPrayers, setReaderHistory, setReaderInkPage, setReaderMarks, setReadingProgress, setUserPrefs } from '../db.js'; +import { requireAuth } from '../auth.js'; +import { mergePrayers } from '../prayers.js'; +import { mergeHistory } from '../readerHistory.js'; +import { cleanMarks } from '../readerMarks.js'; +import { cleanPrefsPatch, mergePrefs } from '../prefs.js'; + +export function registerReaderRoutes(app) { + // --------------------------------------------------------------------------- + // Reader ink — cross-device draw annotations on Bible chapters + // --------------------------------------------------------------------------- + + // GET /api/reader/ink — all saved ink pages for the current user + app.get('/api/reader/ink', requireAuth, (req, res) => { + try { + res.json(getAllReaderInk(req.session.userId)); + } catch (err) { + console.error('GET /api/reader/ink error:', err); + res.status(500).json({ error: 'Failed to load reader ink.' }); + } + }); + + // PUT /api/reader/ink/:book/:chapter — save (upsert) one page's ink + app.put('/api/reader/ink/:book/:chapter', requireAuth, (req, res) => { + try { + const { book, chapter } = req.params; + const { strokes } = req.body ?? {}; + if (!/^[1-3A-Za-z]{3}$/.test(book) || !/^\d{1,3}$/.test(chapter)) return res.status(400).json({ error: 'Invalid book or chapter.' }); + if (!Array.isArray(strokes)) return res.status(400).json({ error: 'strokes must be an array.' }); + if (strokes.length > 20000) return res.status(413).json({ error: 'Too much ink on one page.' }); + setReaderInkPage(req.session.userId, book, chapter, strokes); + res.json({ ok: true }); + } catch (err) { + console.error('PUT /api/reader/ink error:', err); + res.status(500).json({ error: 'Failed to save reader ink.' }); + } + }); + + // GET /api/prefs — the user's synced display preferences ({ prefs: { key: { v, at } } }) + app.get('/api/prefs', requireAuth, (req, res) => { + try { + res.json({ prefs: getUserPrefs(req.session.userId) }); + } catch (err) { + console.error('GET /api/prefs error:', err); + res.status(500).json({ error: 'Failed to load preferences.' }); + } + }); + + // PUT /api/prefs — merge changed preferences in (newest change per key wins); returns the result + app.put('/api/prefs', requireAuth, (req, res) => { + try { + const merged = mergePrefs(getUserPrefs(req.session.userId), cleanPrefsPatch(req.body)); + setUserPrefs(req.session.userId, merged); + res.json({ prefs: merged }); + } catch (err) { + console.error('PUT /api/prefs error:', err); + res.status(500).json({ error: 'Failed to save preferences.' }); + } + }); + + // GET /api/reader/marks — the user's bookmarks + highlights, with an updatedAt to compare against + app.get('/api/reader/marks', requireAuth, (req, res) => { + try { + res.json(getReaderMarks(req.session.userId)); + } catch (err) { + console.error('GET /api/reader/marks error:', err); + res.status(500).json({ error: 'Failed to load bookmarks and highlights.' }); + } + }); + + // GET /api/prayers — the prayer journal; PUT merges this device's copy in (newer prayer wins) and returns the whole + app.get('/api/prayers', requireAuth, (req, res) => { + try { res.json({ prayers: mergePrayers(getPrayers(req.session.userId), []) }); } catch (err) { + console.error('GET /api/prayers error:', err); + res.status(500).json({ error: 'Failed to load your prayers.' }); + } + }); + app.put('/api/prayers', requireAuth, (req, res) => { + try { + const merged = mergePrayers(getPrayers(req.session.userId), req.body?.prayers); + setPrayers(req.session.userId, merged); + res.json({ prayers: merged }); + } catch (err) { + console.error('PUT /api/prayers error:', err); + res.status(500).json({ error: 'Failed to save your prayers.' }); + } + }); + + // GET /api/reader/history — what has been read, and PUT merges this device's copy in and returns the whole + app.get('/api/reader/history', requireAuth, (req, res) => { + try { res.json(mergeHistory(getReaderHistory(req.session.userId), {})); } catch (err) { + console.error('GET /api/reader/history error:', err); + res.status(500).json({ error: 'Failed to load your reading history.' }); + } + }); + app.put('/api/reader/history', requireAuth, (req, res) => { + try { + const merged = mergeHistory(getReaderHistory(req.session.userId), req.body); + setReaderHistory(req.session.userId, merged); + res.json(merged); + } catch (err) { + console.error('PUT /api/reader/history error:', err); + res.status(500).json({ error: 'Failed to save your reading history.' }); + } + }); + + // PUT /api/reader/marks — replace the user's bookmarks + highlights ({ bookmarks, highlights }) + app.put('/api/reader/marks', requireAuth, (req, res) => { + try { + const marks = cleanMarks(req.body); + if (!marks) return res.status(400).json({ error: 'Body must be an object.' }); + res.json({ ok: true, updatedAt: setReaderMarks(req.session.userId, marks) }); + } catch (err) { + console.error('PUT /api/reader/marks error:', err); + res.status(500).json({ error: 'Failed to save bookmarks and highlights.' }); + } + }); + + // --------------------------------------------------------------------------- + // Reading plan progress — cross-device sync for started/completed reading plans + // --------------------------------------------------------------------------- + + // GET /api/reading-plans — the current user's whole progress list + + app.get('/api/reading-plans', requireAuth, (req, res) => { + try { + res.json(getReadingProgress(req.session.userId)); + } catch (err) { + console.error('GET /api/reading-plans error:', err); + res.status(500).json({ error: 'Failed to load reading plan progress.' }); + } + }); + + // PUT /api/reading-plans — overwrite the current user's whole progress list + // (an object: { progress: [...], customPlans: [...] }) + app.put('/api/reading-plans', requireAuth, (req, res) => { + try { + if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) { + return res.status(400).json({ error: 'Body must be an object.' }); + } + const { progress, customPlans, activity } = req.body; + if ([progress, customPlans, activity].some((v) => v !== undefined && !Array.isArray(v)) + || (progress?.length ?? 0) > 500 || (customPlans?.length ?? 0) > 200 || (activity?.length ?? 0) > 20000) { + return res.status(400).json({ error: 'progress, customPlans and activity must be reasonably sized lists.' }); + } + setReadingProgress(req.session.userId, { progress: progress ?? [], customPlans: customPlans ?? [], activity: activity ?? [] }); + res.json({ ok: true }); + } catch (err) { + console.error('PUT /api/reading-plans error:', err); + res.status(500).json({ error: 'Failed to save reading plan progress.' }); + } + }); +} diff --git a/server/routes/sharing.js b/server/routes/sharing.js new file mode 100644 index 0000000..ee8f68d --- /dev/null +++ b/server/routes/sharing.js @@ -0,0 +1,150 @@ +// Sharing: read-only study and outline links, co-teacher invites, and the public pages that open them. +import { randomUUID } from 'crypto'; +import { acceptEditInvite, clearOutlineShareToken, clearShareToken, countOpenEditInvites, createEditInvite, getSharePassHash, getShareSettings, getSharedByToken, isProjectOwner, listCollaborators, removeCollaborator, setOutlineShareToken, setShareSettings, setShareToken } from '../db.js'; +import { requireAuth } from '../auth.js'; +import { checkShareAccess, expiryFromDays, hashPasscode } from '../shareLinks.js'; +import { buildOutlinePayload } from '../outlineShare.js'; +import { TOO_MANY, isLimited, recordFailure } from '../limiter.js'; + +export function registerSharingRoutes(app) { + // --------------------------------------------------------------------------- + // Read-only share links + // --------------------------------------------------------------------------- + + // GET /api/projects/:id/share — current share status for the project owner + app.get('/api/projects/:id/share', requireAuth, (req, res) => { + try { + const settings = getShareSettings(req.params.id, req.session.userId); + if (!settings) return res.status(404).json({ error: 'Project not found.' }); + res.json({ shareToken: settings.token, expiresAt: settings.expiresAt, hasPasscode: settings.hasPasscode }); + } catch (err) { + console.error('GET /api/projects/:id/share error:', err); + res.status(500).json({ error: 'Failed to load share status.' }); + } + }); + + // Sets up (or changes) a read-only link: body { expiresInDays?: number | null, passcode?: string | null }. + // Leaving a field out keeps what it was; null/"" clears it. Returns the (new or existing) token. + async function saveLink(req, res, outline) { + try { + const userId = req.session.userId; + const settings = getShareSettings(req.params.id, userId, outline); + if (!settings) return res.status(404).json({ error: 'Project not found.' }); + const token = settings.token || randomUUID().replace(/-/g, ''); + if (!settings.token && !(outline ? setOutlineShareToken : setShareToken)(req.params.id, userId, token)) return res.status(404).json({ error: 'Project not found.' }); + const body = req.body ?? {}; + const expiresAt = 'expiresInDays' in body ? expiryFromDays(body.expiresInDays) : settings.expiresAt; + // a blank passcode field means "keep it"; null clears; a string sets + const current = getSharePassHash(req.params.id, userId, outline); + const passHash = 'passcode' in body ? (body.passcode === null ? null : await hashPasscode(body.passcode) ?? current) : current; + setShareSettings(req.params.id, userId, { expiresAt, passHash }, outline); + res.json({ shareToken: token, expiresAt, hasPasscode: !!passHash }); + } catch (err) { + console.error(`POST share (${outline ? 'outline' : 'study'}) error:`, err); + res.status(500).json({ error: 'Failed to enable sharing.' }); + } + } + app.post('/api/projects/:id/share', requireAuth, (req, res) => saveLink(req, res, false)); + + // DELETE /api/projects/:id/share — revoke the share link + app.delete('/api/projects/:id/share', requireAuth, (req, res) => { + try { + clearShareToken(req.params.id, req.session.userId); + res.json({ ok: true }); + } catch (err) { + console.error('DELETE /api/projects/:id/share error:', err); + res.status(500).json({ error: 'Failed to revoke sharing.' }); + } + }); + + // --------------------------------------------------------------------------- + // Co-teachers: other accounts that may edit a study. The owner makes a single-use invite link; whoever + // opens it while signed in joins. The owner (or the co-teacher themself) can remove an editor. + // --------------------------------------------------------------------------- + app.get('/api/projects/:id/collaborators', requireAuth, (req, res) => { + if (!isProjectOwner(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' }); + res.json({ collaborators: listCollaborators(req.params.id), pendingInvites: countOpenEditInvites(req.params.id) }); + }); + + app.post('/api/projects/:id/collaborators/invite', requireAuth, (req, res) => { + if (!isProjectOwner(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' }); + const token = randomUUID().replace(/-/g, ''); + const expiresAt = Date.now() + 7 * 86_400_000; + createEditInvite(token, req.params.id, expiresAt); + res.json({ token, expiresAt }); + }); + + app.delete('/api/projects/:id/collaborators/:userId', requireAuth, (req, res) => { + const me = req.session.userId; + if (!isProjectOwner(req.params.id, me) && req.params.userId !== me) return res.status(403).json({ error: 'Only the owner can remove someone else.' }); + removeCollaborator(req.params.id, req.params.userId); + res.json({ ok: true }); + }); + + app.post('/api/join/:token', requireAuth, (req, res) => { + const result = acceptEditInvite(req.params.token, req.session.userId); + if (result.error) return res.status(400).json({ error: result.error }); + res.json(result); + }); + + // Read-only outline links. A separate token from the study share link, and the public endpoint returns + // only the outlines (see outlineShare.js), never the notes or scripts. + app.get('/api/projects/:id/outline-share', requireAuth, (req, res) => { + try { + const settings = getShareSettings(req.params.id, req.session.userId, true); + res.json({ shareToken: settings?.token ?? null, expiresAt: settings?.expiresAt ?? null, hasPasscode: !!settings?.hasPasscode }); + } catch (err) { + console.error('GET /api/projects/:id/outline-share error:', err); + res.status(500).json({ error: 'Failed to load share status.' }); + } + }); + + app.post('/api/projects/:id/outline-share', requireAuth, (req, res) => saveLink(req, res, true)); + + app.delete('/api/projects/:id/outline-share', requireAuth, (req, res) => { + try { + clearOutlineShareToken(req.params.id, req.session.userId); + res.json({ ok: true }); + } catch (err) { + console.error('DELETE /api/projects/:id/outline-share error:', err); + res.status(500).json({ error: 'Failed to revoke sharing.' }); + } + }); + + // A wrong passcode counts as a failed attempt for that link and address + async function openSharedLink(req, res, outline) { + const found = getSharedByToken(req.params.token, outline); + if (!found) { res.status(404).json({ error: outline ? 'This outline link is invalid or has been turned off.' : 'This share link is invalid or has been revoked.' }); return null; } + const keys = [`ip:${req.ip}`, `share:${req.params.token}`]; + if (isLimited(...keys)) { res.status(429).json(TOO_MANY); return null; } + const access = await checkShareAccess({ expiresAt: found.expiresAt, passHash: found.passHash, supplied: req.get('X-Share-Passcode') }); + if (!access.ok) { + if (access.needsPasscode && req.get('X-Share-Passcode')) recordFailure(...keys); + res.status(access.status).json({ error: access.error, needsPasscode: !!access.needsPasscode, expired: access.status === 410 }); + return null; + } + return found.project; + } + + // GET /api/share-outline/:token — PUBLIC, no login required (a passcode may be needed) + app.get('/api/share-outline/:token', async (req, res) => { + try { + const project = await openSharedLink(req, res, true); + if (project) res.json(buildOutlinePayload(project)); + } catch (err) { + console.error('GET /api/share-outline/:token error:', err); + res.status(500).json({ error: 'Failed to load the shared outline.' }); + } + }); + + // GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only (a passcode may be needed) + app.get('/api/share/:token', async (req, res) => { + try { + const project = await openSharedLink(req, res, false); + if (project) res.json(project); + } catch (err) { + console.error('GET /api/share/:token error:', err); + res.status(500).json({ error: 'Failed to load shared project.' }); + } + }); +} diff --git a/server/userPayload.js b/server/userPayload.js new file mode 100644 index 0000000..42f67c6 --- /dev/null +++ b/server/userPayload.js @@ -0,0 +1,16 @@ +import { isAdminEmail } from './auth.js'; +import { votdAvailable } from './votd.js'; +import { listPasskeys } from './db.js'; + +// Shape returned to the client for the signed-in user (login, MFA verify, /auth/me). +export function userPayload(user) { + return { + id: user.id, email: user.email, totpEnabled: user.totpEnabled, podcastName: user.podcastName ?? null, + isAdmin: isAdminEmail(user.email), + reminderEnabled: user.reminderEnabled, ntfyUrl: user.ntfyUrl ?? null, ntfyTopic: user.ntfyTopic ?? null, + reminderTime: user.reminderTime ?? null, + votdEnabled: !!user.votdEnabled, votdTime: user.votdTime ?? null, votdAvailable: votdAvailable(), + prayerEnabled: !!user.prayerEnabled, prayerTime: user.prayerTime ?? null, + loginAlerts: !!user.loginAlerts, passkeyCount: listPasskeys(user.id).length, + }; +}