Harden sync and security; add IQ Bible, templates, password reset, backups
Build and Push Docker Image / build-and-push (push) Successful in 7m14s
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:
1 parent
6bb392933a
commit
f274bc9c8a
67 files changed
+4734
-1569
No files matched your search
@@ -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`;
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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, '');
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user