Harden sync and security; add IQ Bible, templates, password reset, backups
Build and Push Docker Image / build-and-push (push) Successful in 7m14s

Sync and data safety
- Autosave flushes on leaving a study or the tab; storage-full errors are reported
- Unsynced studies, deletes and ink are queued and retried when back online
- Deletes propagate across devices (with a "Keep it" undo) and no longer resurrect
- Server assigns study versions (no client-clock conflicts); "Combine both"
  merges a conflicted study chunk by chunk
- Reading-plan progress made offline is merged, not overwritten; plans, ink and
  marks are cleared from the device on sign-out

Security
- Block SSRF through the ntfy reminder URL; failure-only rate limiting per IP and
  account; constant-time login; TOTP replay protection; input validation;
  SESSION_SECRET required in production; admin user delete fixed
- Forgot password: one-time code sent to the account's ntfy topic

Features
- IQ Bible proxy (route allow-list, text-only caching per its terms): more
  commentaries, lexicons, study aids, daily verse and devotional, plan calendar
  export, narrations, podcast background
- Study templates, reading-plan catch-up, account export, daily database snapshots

Tests and tooling
- ESLint config; App tests split into src/test; tests for sync queues, library
  hook, merge, templates, IQ Bible UI and reset form

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 08:13:36 -04:00
1 parent 6bb392933a
commit f274bc9c8a
67 files changed
+4734 -1569

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
// Everything one account holds on the server, in the same file format the app's own "Download backup"
// makes, so it can be restored from Settings on any device.
export const BACKUP_APP = 'bible-study-project';
export const BACKUP_VERSION = 1;
// `store` supplies the account's data: { projects(), prefs(), marks(), ink(), plans() }.
export function buildAccountExport(user, store, now = new Date()) {
const prefs = store.prefs() ?? {};
const marks = store.marks() ?? {};
const plans = store.plans() ?? {};
return {
app: BACKUP_APP,
version: BACKUP_VERSION,
exportedAt: now.toISOString(),
account: user.email,
source: 'server',
projects: store.projects(),
reader: {
bookmarks: marks.bookmarks ?? {},
highlights: marks.highlights ?? [],
colorLabels: prefs.colorLabels?.v ?? {},
ink: store.ink() ?? {},
},
readingPlans: {
progress: plans.progress ?? [],
customPlans: plans.customPlans ?? [],
activity: plans.activity ?? [],
},
};
}
export const accountExportName = (now = new Date()) => `bible-study-account-${now.toISOString().slice(0, 10)}.json`;
+33
View File
@@ -0,0 +1,33 @@
// @vitest-environment node
import { test, expect } from 'vitest';
import { buildAccountExport, accountExportName, BACKUP_APP } from './accountExport.js';
import { listSnapshots, pruneSnapshots } from './backups.js';
import { mkdtempSync, writeFileSync, readdirSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
test('the account export has the same shape as the in-app backup', () => {
const out = buildAccountExport({ email: 'a@b.co' }, {
projects: () => [{ id: 'p', title: 'T', chapters: [] }],
prefs: () => ({ colorLabels: { v: { '#fef08a': 'Promises' }, at: 1 } }),
marks: () => ({ bookmarks: { 'JHN-3-16': '#fff' }, highlights: [{ id: 'h' }] }),
ink: () => ({ JHN_3: [[1, 2]] }),
plans: () => ({ progress: [{ id: 'x' }] }),
}, new Date('2026-10-06T10:00:00Z'));
expect(out.app).toBe(BACKUP_APP);
expect(out.account).toBe('a@b.co');
expect(out.reader.colorLabels).toEqual({ '#fef08a': 'Promises' });
expect(out.reader.bookmarks['JHN-3-16']).toBe('#fff');
expect(out.readingPlans).toEqual({ progress: [{ id: 'x' }], customPlans: [], activity: [] });
expect(accountExportName(new Date('2026-10-06T10:00:00Z'))).toBe('bible-study-account-2026-10-06.json');
});
test('only the newest snapshots are kept, and always at least one', () => {
const dir = mkdtempSync(join(tmpdir(), 'snap-'));
['2026-10-01-03-00', '2026-10-02-03-00', '2026-10-03-03-00', '2026-10-04-03-00'].forEach((d) => writeFileSync(join(dir, `projects-${d}.db`), 'x'));
writeFileSync(join(dir, 'notes.txt'), 'not a snapshot');
expect(listSnapshots(dir).map((s) => s.name)[0]).toBe('projects-2026-10-04-03-00.db');
expect(pruneSnapshots(2, dir)).toEqual(['projects-2026-10-02-03-00.db', 'projects-2026-10-01-03-00.db']);
expect(pruneSnapshots(0, dir)).toHaveLength(1); // keep at least the newest
expect(readdirSync(dir).sort()).toEqual(['notes.txt', 'projects-2026-10-04-03-00.db']);
});
+7
View File
@@ -92,3 +92,10 @@ export async function consumeBackupCode(submitted, hashes) {
export function generateTemporaryPassword() {
return randomBytes(6).toString('hex');
}
// A one-time password-reset code: 8 characters from an alphabet without look-alikes (no 0/O, 1/I/L).
const RESET_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
export function generateResetCode() {
return Array.from(randomBytes(8), (b) => RESET_ALPHABET[b % RESET_ALPHABET.length]).join('');
}
export const normalizeResetCode = (code) => String(code ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '');
+45
View File
@@ -0,0 +1,45 @@
// Automatic daily snapshots of the whole database (every account), kept next to it in DATA_DIR/backups.
// A snapshot is a complete, consistent copy made while the app keeps running, and is restored with
// scripts/restore-data.sh. BACKUP_KEEP sets how many to keep (default 7); BACKUP_DISABLED=1 turns it off.
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'fs';
import { join } from 'path';
import { backupDatabase, dataDir } from './db.js';
const DAY = 24 * 60 * 60 * 1000;
export const backupDir = () => join(dataDir(), 'backups');
const stamp = (d) => d.toISOString().replace(/[:T]/g, '-').slice(0, 16);
const isSnapshot = (name) => /^projects-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}\.db$/.test(name);
export function listSnapshots(dir = backupDir()) {
if (!existsSync(dir)) return [];
return readdirSync(dir).filter(isSnapshot).sort().reverse()
.map((name) => ({ name, bytes: statSync(join(dir, name)).size, at: statSync(join(dir, name)).mtimeMs }));
}
// Deletes all but the newest `keep` snapshots; returns the names removed.
export function pruneSnapshots(keep = 7, dir = backupDir()) {
const old = listSnapshots(dir).slice(Math.max(1, keep));
old.forEach((s) => unlinkSync(join(dir, s.name)));
return old.map((s) => s.name);
}
export async function runBackup(now = new Date()) {
const dir = backupDir();
mkdirSync(dir, { recursive: true });
const file = join(dir, `projects-${stamp(now)}.db`);
await backupDatabase(file);
pruneSnapshots(Number(process.env.BACKUP_KEEP) || 7, dir);
return file;
}
// Takes a snapshot when the newest is more than a day old (checked hourly, and once at start-up).
export function startBackupScheduler() {
if (process.env.BACKUP_DISABLED === '1') return;
const tick = () => {
const latest = listSnapshots()[0];
if (latest && Date.now() - latest.at < DAY) return;
runBackup().then((f) => console.log(`Database snapshot saved: ${f}`)).catch((err) => console.error('Database snapshot failed:', err.message));
};
setTimeout(tick, 30_000).unref();
setInterval(tick, 60 * 60 * 1000).unref();
}
+1 -1
View File
@@ -53,7 +53,7 @@ const cache = new Map(); // "NLT/PSA/23" -> { at, payload }
// breaks, and stray "#" / nbsp characters. Split it back into { number, text }.
export function parseChapterText(content) {
const clean = String(content ?? '')
.replace(/ /g, ' ')
.replace(/\u00a0/g, ' ')
.replace(/#/g, '');
const parts = clean.split(/\[(\d+)\]/);
const verses = [];
+49 -2
View File
@@ -9,6 +9,13 @@ const DB_PATH = join(DATA_DIR, 'projects.db');
let db;
export const dataDir = () => DATA_DIR;
/** A consistent copy of the whole database, made while it is in use. */
export function backupDatabase(destPath) {
return db.backup(destPath);
}
// ---------------------------------------------------------------------------
// Init — create tables if they don't exist, migrate older schemas
// ---------------------------------------------------------------------------
@@ -89,6 +96,16 @@ export function initDb() {
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_outline_share_token ON projects(outline_share_token) WHERE outline_share_token IS NOT NULL');
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_share_token ON projects(share_token) WHERE share_token IS NOT NULL');
// One-time codes for "forgot password": hashed, short-lived, limited tries.
db.exec(`
CREATE TABLE IF NOT EXISTS password_resets (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
expires INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0
);
`);
// Reader ink — one row per user + book + chapter, stored as JSON
db.exec(`
CREATE TABLE IF NOT EXISTS reader_ink (
@@ -232,6 +249,30 @@ export function setLastReminderDate(userId, dateString) {
db.prepare('UPDATE users SET last_reminder_date = ? WHERE id = ?').run(dateString, userId);
}
// ---------------------------------------------------------------------------
// Password reset codes
// ---------------------------------------------------------------------------
export function setPasswordReset(userId, codeHash, expires) {
db.prepare(`
INSERT INTO password_resets (user_id, code_hash, expires, attempts) VALUES (?, ?, ?, 0)
ON CONFLICT(user_id) DO UPDATE SET code_hash = excluded.code_hash, expires = excluded.expires, attempts = 0
`).run(userId, codeHash, expires);
}
export function getPasswordReset(userId) {
const row = db.prepare('SELECT code_hash AS codeHash, expires, attempts FROM password_resets WHERE user_id = ?').get(userId);
return row && row.expires > Date.now() ? row : null;
}
export function bumpPasswordResetAttempts(userId) {
db.prepare('UPDATE password_resets SET attempts = attempts + 1 WHERE user_id = ?').run(userId);
}
export function clearPasswordReset(userId) {
db.prepare('DELETE FROM password_resets WHERE user_id = ?').run(userId);
}
/** Rewrites the remaining backup-code hashes after one is used (single-use codes). */
export function setBackupCodeHashes(userId, backupCodeHashes) {
db.prepare('UPDATE users SET backup_codes = ? WHERE id = ?').run(JSON.stringify(backupCodeHashes), userId);
@@ -373,7 +414,9 @@ export function upsertProject(project, userId) {
return null;
}
const lastEdited = project.lastEdited ?? Date.now();
// The server's clock decides the version, so a device with a wrong clock can't hide or fake changes.
const prev = db.prepare('SELECT last_edited AS lastEdited FROM projects WHERE id = ?').get(project.id);
const lastEdited = Math.max(Date.now(), (prev?.lastEdited ?? 0) + 1);
const chapterSummary = buildSummary(project);
const updated = { ...project, lastEdited };
@@ -468,7 +511,11 @@ export function adminGetAllUsers() {
/** Deletes a user account. Their projects are left in place (orphaned, not cascade-deleted) so data isn't lost by accident. */
export function adminDeleteUser(userId) {
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
// projects.user_id is a foreign key, so release the projects first (they stay as unowned rows).
db.transaction(() => {
db.prepare('UPDATE projects SET user_id = NULL, share_token = NULL, outline_share_token = NULL WHERE user_id = ?').run(userId);
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
})();
}
/** Every project across every user, with the owner's email, for the admin projects list. */
+156 -21
View File
@@ -16,15 +16,20 @@ import {
getAllReaderInk, setReaderInkPage, getReaderMarks, setReaderMarks, getUserPrefs, setUserPrefs,
getReadingProgress, setReadingProgress,
setReminderSettings,
setPasswordReset, getPasswordReset, bumpPasswordResetAttempts, clearPasswordReset,
} 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 { startReminderScheduler, sendTestReminder, isValidTimeZone } from './reminders.js';
import { startReminderScheduler, sendTestReminder, isValidTimeZone, assertPublicUrl, sendNtfy } from './reminders.js';
import { registerBibleRoutes } from './bibleApi.js';
import { registerIqBibleRoutes } from './iqbible.js';
import { buildAccountExport, accountExportName } from './accountExport.js';
import { startBackupScheduler } from './backups.js';
import { cleanMarks } from './readerMarks.js';
import { cleanPrefsPatch, mergePrefs } from './prefs.js';
import { isSaveConflict, parseBase } from './projectSync.js';
@@ -46,26 +51,46 @@ const PORT = process.env.PORT || 3001;
const isProd = process.env.NODE_ENV === 'production';
if (isProd && !process.env.SESSION_SECRET) {
console.warn('WARNING: SESSION_SECRET is not set. Set it to a long random string in production.');
console.error('SESSION_SECRET is not set. Set it to a long random string before running in production.');
process.exit(1);
}
// Trust the reverse proxy (needed for secure cookies to work behind nginx/etc).
app.set('trust proxy', 1);
// Simple in-memory rate limiter for auth endpoints — 10 attempts per 15 min per IP.
const _authAttempts = new Map();
// 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 [ip, e] of _authAttempts) if (now > e.resetAt) _authAttempts.delete(ip);
}, 60 * 60 * 1000);
function checkAuthRateLimit(ip) {
for (const [k, e] of _failures) if (now > e.resetAt) _failures.delete(k);
}, 60 * 60 * 1000).unref();
function isLimited(...keys) {
const now = Date.now();
const window = 15 * 60 * 1000;
const e = _authAttempts.get(ip) ?? { count: 0, resetAt: now + window };
if (now > e.resetAt) { e.count = 0; e.resetAt = now + window; }
e.count += 1;
_authAttempts.set(ip, e);
return e.count > 10;
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.
@@ -101,7 +126,8 @@ app.get('/api/health', (_req, res) => {
app.post('/api/auth/register', async (req, res) => {
try {
if (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' });
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 ?? '');
@@ -139,15 +165,18 @@ app.post('/api/auth/register', async (req, res) => {
app.post('/api/auth/login', async (req, res) => {
try {
if (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' });
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 = user && await verifyPassword(password, user.passwordHash);
const valid = await verifyPassword(password, user?.passwordHash ?? DUMMY_HASH) && !!user;
if (!valid) {
recordFailure(...keys);
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.' });
@@ -168,11 +197,12 @@ app.post('/api/auth/login', async (req, res) => {
app.post('/api/auth/mfa/verify', async (req, res) => {
try {
if (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' });
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.' });
@@ -180,7 +210,8 @@ app.post('/api/auth/mfa/verify', async (req, res) => {
const token = req.body?.token;
const backupCode = req.body?.backupCode;
let ok = token ? verifyTotpToken(String(token), user.totpSecret) : false;
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);
@@ -191,8 +222,10 @@ app.post('/api/auth/mfa/verify', async (req, res) => {
}
if (!ok) {
recordFailure(...keys);
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.' });
@@ -205,6 +238,66 @@ app.post('/api/auth/mfa/verify', async (req, res) => {
}
});
// ---------------------------------------------------------------------------
// 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.' });
}
});
app.post('/api/auth/logout', (req, res) => {
req.session.destroy(() => {
res.clearCookie('connect.sid');
@@ -235,7 +328,7 @@ app.patch('/api/auth/profile', requireAuth, (req, res) => {
// ---------------------------------------------------------------------------
// PATCH /api/auth/reminder-settings — daily reading reminder via ntfy.sh
// ---------------------------------------------------------------------------
app.patch('/api/auth/reminder-settings', requireAuth, (req, res) => {
app.patch('/api/auth/reminder-settings', requireAuth, async (req, res) => {
try {
const reminderEnabled = !!req.body?.reminderEnabled;
const ntfyTopic = String(req.body?.ntfyTopic ?? '').trim();
@@ -249,6 +342,9 @@ app.patch('/api/auth/reminder-settings', requireAuth, (req, res) => {
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)) {
@@ -281,14 +377,17 @@ app.post('/api/auth/change-password', requireAuth, async (req, res) => {
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.
@@ -327,7 +426,9 @@ app.post('/api/auth/mfa/enable', requireAuth, async (req, res) => {
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.' });
}
@@ -345,8 +446,10 @@ app.post('/api/auth/mfa/enable', requireAuth, async (req, res) => {
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);
@@ -358,6 +461,28 @@ app.post('/api/auth/mfa/disable', requireAuth, async (req, res) => {
});
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// 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),
});
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) => {
@@ -448,8 +573,10 @@ app.get('/api/reader/ink', requireAuth, (req, res) => {
app.put('/api/reader/ink/:book/:chapter', requireAuth, (req, res) => {
try {
const { book, chapter } = req.params;
const { strokes } = req.body;
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) {
@@ -508,6 +635,7 @@ app.put('/api/reader/marks', requireAuth, (req, res) => {
// GET /api/reading-plans — the current user's whole progress list
registerBibleRoutes(app, requireAuth);
registerIqBibleRoutes(app, requireAuth);
app.get('/api/reading-plans', requireAuth, (req, res) => {
try {
@@ -525,7 +653,12 @@ app.put('/api/reading-plans', requireAuth, (req, res) => {
if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) {
return res.status(400).json({ error: 'Body must be an object.' });
}
setReadingProgress(req.session.userId, req.body);
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);
@@ -677,6 +810,7 @@ app.delete('/api/admin/users/:id', requireAuth, requireAdmin, (req, res) => {
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);
@@ -756,6 +890,7 @@ if (isProd) {
// ---------------------------------------------------------------------------
initDb();
startReminderScheduler();
startBackupScheduler();
app.listen(PORT, () => {
console.log(`Bible Study API running on http://localhost:${PORT}`);
if (isProd) {
+143
View File
@@ -0,0 +1,143 @@
// Proxy for the IQ Bible API (https://api.iqbible.com): commentaries, lexicons, original-language text,
// places, topics, devotionals, narrations and more. The API key stays on the server (IQBIBLE_API_KEY,
// from the environment or .env); the browser only talks to /api/iq/... and only to the routes listed here.
// Only verse/chapter text is cached (up to 24h, as the API's terms require); see isTextPath.
import { existsSync, readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const envPath = join(__dirname, '..', '.env');
if (existsSync(envPath)) {
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2');
}
}
const BASE = () => (process.env.IQBIBLE_API_BASE || 'https://api.iqbible.com/api/v2').replace(/\/+$/, '');
const S = '[\\w.~%-]+'; // one path segment
const route = (pattern, opts = {}) => ({ re: new RegExp(`^${pattern.replace(/@/g, S)}/?$`), ...opts });
// Every upstream route the app may reach. Anything else is a 404 here, whatever the browser asks for.
export const ROUTES = [
route('/commentaries'), route('/commentaries/@/@/@'), route('/commentaries/@/@/@/@'),
route('/books/@/info'), route('/books/@/chapters/@/info'), route('/books/@/commentaries'), route('/books/abbreviations'),
route('/lexicon/sources'), route('/lexicon/crosswalk/@'),
route('/lexicon/(?:greek|hebrew)/(?:strongs|bdb|lsj|abbott-smith)/@'), route('/lexicon/greek/lsj/word/@'),
route('/original-language/@/@'), route('/original-language/@/@/@'), route('/morphology/@'),
route('/geo/@/@'), route('/geo/@/@/@'), route('/geo/places/@'),
route('/topics'), route('/topics/@'), route('/topics/@/@'), route('/topics/@/@/@'),
route('/genealogies/@'), route('/bible-characters/@'), route('/bible-people/@'), route('/bible-people/@/@'),
route('/harmony'), route('/harmony/@'), route('/textual-variants/@/@'), route('/textual-variants/@/@/@'),
route('/chronology/for/@/@'), route('/reference/weights-measures'),
route('/cross-references/@/@'), route('/cross-references/@/@/@'),
route('/dictionaries'), route('/dictionaries/@'),
route('/devotionals/today'), route('/devotionals/@/@'),
route('/bibles'), route('/bibles/languages'), route('/bibles/parallel'), route('/bibles/@'), route('/bibles/@/@'), route('/bibles/@/verse-of-the-day'), route('/bibles/@/reading-plan'),
route('/bibles/@/@/@/@'), route('/bibles/@/@/@'),
route('/audio'), route('/audio/@/@/@', { cache: false }),
route('/ai/context', { heavy: true }), route('/bibles/@/search', { heavy: true }), route('/parse/citations'),
];
// Verse and chapter text is the one thing the API's terms let (and require) a client to cache, for up to 24h.
// Everything else caps at 1 hour and needs written permission, so it is only cached when IQBIBLE_CACHE_HOURS
// is set (do that only once permission is on file).
const TEXT_PATH = /^\/bibles\/[\w]+\/(?!random\/|search|reading-plan|verse-of-the-day|verses|books|word-count)[\w.~%-]+\/\d+(?:\/[\d-]+)?$/;
export const isTextPath = (p) => TEXT_PATH.test(p.split('?')[0]);
const QUERY_KEY = /^[\w-]{1,40}$/;
// Maps an incoming sub-path + query onto an upstream URL, or null when it isn't an allowed route.
// The API key can never be supplied by the browser (apikey is dropped).
export function resolveIqPath(path, query = {}) {
const clean = `/${String(path ?? '').replace(/^\/+/, '')}`;
if (clean.includes('..') || clean.includes('//') || clean.length > 300) return null;
const hit = ROUTES.find((r) => r.re.test(clean));
if (!hit) return null;
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(query).sort(([a], [b]) => a.localeCompare(b))) {
if (!QUERY_KEY.test(k) || k.toLowerCase() === 'apikey' || typeof v !== 'string' || v.length > 200) continue;
qs.append(k, v);
}
const q = qs.toString();
const key = `${clean}${q ? `?${q}` : ''}`;
return { key, cache: hit.cache !== false, heavy: !!hit.heavy, text: isTextPath(clean) };
}
// ---- caching, bounded by the API's own licensing summary ----
let policy = null; // { textMs, otherMs }
let policyAt = 0;
async function cacheTtl(headers, isText) {
if (!policy || Date.now() - policyAt > 6 * 3600_000) {
try {
const res = await fetch(`${BASE()}/licensing`, { headers, signal: AbortSignal.timeout(8000) });
const s = (await res.json())?.summary;
const allowed = s?.caching_allowed !== false;
const textHours = allowed ? Math.min(Number(s?.caching_verse_chapter_hours) || 24, 24) : 0;
const optIn = Number(process.env.IQBIBLE_CACHE_HOURS) || 0; // only with the API owner's written permission
const otherHours = allowed ? Math.min(optIn, Number(s?.caching_max_hours) || 1) : 0;
policy = { textMs: textHours * 3600_000, otherMs: otherHours * 3600_000 };
} catch {
policy = { textMs: 0, otherMs: 0 };
}
policyAt = Date.now();
}
return isText ? policy.textMs : policy.otherMs;
}
const cache = new Map(); // key -> { at, status, type, body }
const CACHE_MAX = 1500;
const hits = new Map(); // `${userId}:${bucket}` -> { n, resetAt }
function overLimit(userId, heavy) {
const bucket = heavy ? 'h' : 'n';
const max = heavy ? 10 : 120; // per minute, per signed-in user (the API budget is shared by everyone)
const k = `${userId}:${bucket}`;
const now = Date.now();
const e = hits.get(k);
if (!e || now > e.resetAt) { hits.set(k, { n: 1, resetAt: now + 60_000 }); return false; }
e.n += 1;
return e.n > max;
}
export function registerIqBibleRoutes(app, requireAuth) {
const apiKey = () => process.env.IQBIBLE_API_KEY;
app.get('/api/iq/status', requireAuth, (_req, res) => res.json({ available: Boolean(apiKey()) }));
app.get('/api/iq/*', requireAuth, async (req, res) => {
const target = resolveIqPath(req.params[0], req.query);
if (!target) return res.status(404).json({ error: 'Unknown IQ Bible route.' });
if (!apiKey()) return res.status(503).json({ error: 'IQBIBLE_API_KEY is not configured on the server.' });
if (overLimit(req.session.userId, target.heavy)) return res.status(429).json({ error: 'Slow down a little and try again in a minute.' });
const headers = { 'X-IQBible-API-Key': apiKey(), Accept: '*/*' };
const hit = target.cache && (target.text || process.env.IQBIBLE_CACHE_HOURS) ? cache.get(target.key) : null;
if (hit && Date.now() - hit.at < hit.ttl) {
res.status(hit.status).type(hit.type).send(hit.body);
return;
}
try {
const upstream = await fetch(`${BASE()}${target.key}`, { headers, signal: AbortSignal.timeout(20_000) });
const body = Buffer.from(await upstream.arrayBuffer());
const type = upstream.headers.get('content-type') || 'application/json';
if (upstream.status === 401) return res.status(502).json({ error: 'The IQ Bible key was rejected. Check IQBIBLE_API_KEY.' });
if (upstream.status === 429) {
const retry = upstream.headers.get('retry-after');
if (retry) res.set('Retry-After', retry);
return res.status(429).json({ error: 'The IQ Bible allowance is used up for now. Try again later.' });
}
if (upstream.ok && target.cache && (target.text || process.env.IQBIBLE_CACHE_HOURS)) {
const ttl = await cacheTtl(headers, target.text);
if (ttl > 0) {
if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value);
cache.set(target.key, { at: Date.now(), ttl, status: upstream.status, type, body });
}
}
res.status(upstream.status).type(type).send(body);
} catch {
res.status(502).json({ error: 'Could not reach the IQ Bible service.' });
}
});
}
+34
View File
@@ -0,0 +1,34 @@
// @vitest-environment node
import { test, expect } from 'vitest';
import { resolveIqPath } from './iqbible.js';
test('only listed IQ Bible routes are reachable', () => {
expect(resolveIqPath('commentaries/gill/JHN/3/16')?.key).toBe('/commentaries/gill/JHN/3/16');
expect(resolveIqPath('/geo/JHN/3')?.cache).toBe(true);
expect(resolveIqPath('/me')).toBeNull();
expect(resolveIqPath('/licensing')).toBeNull();
expect(resolveIqPath('/commentaries/gill/../me')).toBeNull();
expect(resolveIqPath('/lexicon/greek/strongs/G26')).not.toBeNull();
});
test('lexicon sources and audio routes', () => {
expect(resolveIqPath('/lexicon/greek/lsj/G0026')).not.toBeNull();
expect(resolveIqPath('/lexicon/hebrew/bdb/BDB1005')).not.toBeNull();
expect(resolveIqPath('/audio/eng_kjv/JHN/3')?.cache).toBe(false);
expect(resolveIqPath('/ai/context', { ref: 'JHN.3.16' })?.heavy).toBe(true);
});
test('the browser cannot send its own key and the query is normalised', () => {
const r = resolveIqPath('/bibles/eng_kjv/reading-plan', { days: '30', apikey: 'x', 'bad key': '1', format: 'ical' });
expect(r.key).toBe('/bibles/eng_kjv/reading-plan?days=30&format=ical');
});
test('only verse and chapter text counts as cacheable text', async () => {
const { isTextPath } = await import('./iqbible.js');
expect(isTextPath('/bibles/eng_kjv/JHN/3')).toBe(true);
expect(isTextPath('/bibles/eng_kjv/JHN/3/16')).toBe(true);
expect(isTextPath('/bibles/eng_kjv/random/verse')).toBe(false);
expect(isTextPath('/bibles/eng_kjv/verse-of-the-day')).toBe(false);
expect(isTextPath('/commentaries/gill/JHN/3')).toBe(false);
expect(resolveIqPath('/bibles/parallel', { v: 'eng_kjv,spa_rv' })).not.toBeNull();
});
+48 -2
View File
@@ -2,12 +2,48 @@
// a free pub/sub push service: POST a message to a topic URL, anyone
// subscribed to that topic (via the ntfy app or a browser) gets notified.
// No API keys or push-subscription plumbing needed.
import { lookup } from 'dns/promises';
import { isIP } from 'net';
import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress } from './db.js';
// A reminder still goes out if the server was briefly down (or restarting) at the
// exact minute — as long as it's within this window after the chosen time.
const GRACE_MINUTES = 180;
const SEND_TIMEOUT_MS = 10_000;
// A failed send is retried at most this often, so a dead ntfy server isn't hit every minute for hours.
const RETRY_AFTER_MS = 15 * 60 * 1000;
const _lastAttempt = new Map(); // userId -> timestamp of the last failed send
// True for loopback, private, link-local and other non-public addresses (IPv4 and IPv6).
export function isPrivateAddress(addr) {
const v = isIP(addr);
if (v === 4) {
const [a, b] = addr.split('.').map(Number);
return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
|| (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127) || a >= 224;
}
if (v === 6) {
const l = addr.toLowerCase();
if (l === '::' || l === '::1') return true;
if (l.startsWith('::ffff:')) return isPrivateAddress(l.slice(7));
return /^f[cd]/.test(l) || /^fe[89ab]/.test(l);
}
return true;
}
// Rejects server URLs that would make this server call itself or its private network.
// A self-hosted ntfy on the LAN can be allowed by the server's admin with ALLOW_PRIVATE_NTFY=1.
export async function assertPublicUrl(raw) {
if (process.env.ALLOW_PRIVATE_NTFY === '1') return;
let u;
try { u = new URL(raw); } catch { throw new Error('Server URL is not valid.'); }
if (!/^https?:$/.test(u.protocol)) throw new Error('Server URL must start with http:// or https://.');
const host = u.hostname.replace(/^\[|\]$/g, '');
if (/^localhost$|\.localhost$|\.local$|\.internal$/i.test(host)) throw new Error('Server URL must be a public address.');
const addrs = isIP(host) ? [host] : (await lookup(host, { all: true }).catch(() => [])).map((a) => a.address);
if (addrs.length === 0) throw new Error(`Could not find ${host}.`);
if (addrs.some(isPrivateAddress)) throw new Error('Server URL must be a public address.');
}
export function isValidTimeZone(tz) {
if (!tz) return false;
@@ -40,17 +76,23 @@ export function isDue(user, now = new Date()) {
export async function sendNtfy(user, { title, body, tags = 'open_book' }) {
const base = (user.ntfyUrl || 'https://ntfy.sh').replace(/\/+$/, '');
const url = `${base}/${encodeURIComponent(user.ntfyTopic)}`;
await assertPublicUrl(url);
const headers = { Title: title, Tags: tags };
if (process.env.APP_URL) headers.Click = process.env.APP_URL; // tapping the notification opens the app
let res;
try {
res = await fetch(url, { method: 'POST', headers, body, signal: AbortSignal.timeout(SEND_TIMEOUT_MS) });
res = await fetch(url, { method: 'POST', headers, body, redirect: 'error', signal: AbortSignal.timeout(SEND_TIMEOUT_MS) });
} catch (err) {
throw new Error(err.name === 'TimeoutError' ? `Timed out reaching ${base}` : `Could not reach ${base} (${err.cause?.code ?? err.message})`);
throw new Error(err.name === 'TimeoutError' ? `Timed out reaching ${base}` : `Could not reach ${base} (${err.cause?.code ?? err.message})`, { cause: err });
}
if (!res.ok) throw new Error(`ntfy responded ${res.status}`);
}
// Has the user already finished a reading day on `date` ("YYYY-MM-DD", in their timezone)?
export function readToday(progress, date) {
return Array.isArray(progress?.activity) && progress.activity.includes(date);
}
export function reminderBody(progress) {
const active = (Array.isArray(progress?.progress) ? progress.progress : []).filter((p) => !p.completedAt).length;
return active > 1 ? `Don't forget today's reading! You have ${active} plans in progress.` : "Don't forget today's reading!";
@@ -67,7 +109,11 @@ export async function checkReminders(now = new Date()) {
const progress = getReadingProgress(user.id);
const hasActivePlan = Array.isArray(progress?.progress) && progress.progress.some((p) => !p.completedAt);
if (!hasActivePlan) continue; // nothing to nudge them about
if (readToday(progress, zonedNow(now, user.reminderTz).date)) continue; // already read today: no nudge
if (now.getTime() - (_lastAttempt.get(user.id) ?? 0) < RETRY_AFTER_MS) continue;
_lastAttempt.set(user.id, now.getTime());
await sendNtfy(user, { title: 'Reading Plan Reminder', body: reminderBody(progress) });
_lastAttempt.delete(user.id);
setLastReminderDate(user.id, zonedNow(now, user.reminderTz).date);
} catch (err) {
console.error(`Reading reminder failed for user ${user.id}:`, err.message);
+15
View File
@@ -1,6 +1,7 @@
// @vitest-environment node
import http from 'node:http';
import { test, expect, afterEach } from 'vitest';
process.env.ALLOW_PRIVATE_NTFY = '1'; // the tests talk to a local server
import { isDue, isValidTimeZone, reminderBody, sendNtfy, sendTestReminder, zonedNow } from './reminders.js';
const at = (iso) => new Date(iso);
@@ -84,3 +85,17 @@ test('an ntfy error response becomes a readable failure', async () => {
test('an unreachable server says so instead of hanging', async () => {
await expect(sendNtfy({ ntfyUrl: 'http://127.0.0.1:9', ntfyTopic: 'abc' }, { title: 't', body: 'b' })).rejects.toThrow(/Could not reach/);
});
test('private and loopback addresses are rejected as reminder servers', async () => {
const { isPrivateAddress, assertPublicUrl, readToday } = await import('./reminders.js');
for (const a of ['127.0.0.1', '10.1.2.3', '192.168.0.5', '172.16.0.1', '169.254.169.254', '::1', 'fd00::1', '::ffff:10.0.0.1']) expect(isPrivateAddress(a)).toBe(true);
expect(isPrivateAddress('8.8.8.8')).toBe(false);
const prev = process.env.ALLOW_PRIVATE_NTFY;
delete process.env.ALLOW_PRIVATE_NTFY;
await expect(assertPublicUrl('http://localhost:3001')).rejects.toThrow();
await expect(assertPublicUrl('http://169.254.169.254/latest')).rejects.toThrow();
await expect(assertPublicUrl('ftp://example.com')).rejects.toThrow();
process.env.ALLOW_PRIVATE_NTFY = prev;
expect(readToday({ activity: ['2026-09-29'] }, '2026-09-29')).toBe(true);
expect(readToday({ activity: [] }, '2026-09-29')).toBe(false);
});