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>
207 lines
12 KiB
JavaScript
207 lines
12 KiB
JavaScript
// Proxy for licensed translations (NLT, CSB, NASB) served by api.bible.
|
|
// The API key stays on the server (API_BIBLE_KEY, loaded from .env if present);
|
|
// the browser only ever talks to /api/bible/...
|
|
import { existsSync, readFileSync } from 'fs';
|
|
import { fileURLToPath } from 'url';
|
|
import { dirname, join } from 'path';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
// Minimal .env loader so the key doesn't have to be exported in every launcher
|
|
// (setup.sh, systemd, docker). Real environment variables win.
|
|
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 API_BASE = 'https://rest.api.bible/v1';
|
|
|
|
// Canonical book order (index + 1 = the book number the ESV API uses in ranges).
|
|
const BOOK_ORDER = ['GEN','EXO','LEV','NUM','DEU','JOS','JDG','RUT','1SA','2SA','1KI','2KI','1CH','2CH','EZR','NEH','EST','JOB','PSA','PRO','ECC','SNG','ISA','JER','LAM','EZK','DAN','HOS','JOL','AMO','OBA','JON','MIC','NAM','HAB','ZEP','HAG','ZEC','MAL','MAT','MRK','LUK','JHN','ACT','ROM','1CO','2CO','GAL','EPH','PHP','COL','1TH','2TH','1TI','2TI','TIT','PHM','HEB','JAS','1PE','2PE','1JN','2JN','3JN','JUD','REV'];
|
|
|
|
// ESV search returns "Psalm 23:1"-style references; map the names back to abbreviations.
|
|
const ESV_NAMES = ['Genesis','Exodus','Leviticus','Numbers','Deuteronomy','Joshua','Judges','Ruth','1 Samuel','2 Samuel','1 Kings','2 Kings','1 Chronicles','2 Chronicles','Ezra','Nehemiah','Esther','Job','Psalm','Proverbs','Ecclesiastes','Song of Solomon','Isaiah','Jeremiah','Lamentations','Ezekiel','Daniel','Hosea','Joel','Amos','Obadiah','Jonah','Micah','Nahum','Habakkuk','Zephaniah','Haggai','Zechariah','Malachi','Matthew','Mark','Luke','John','Acts','Romans','1 Corinthians','2 Corinthians','Galatians','Ephesians','Philippians','Colossians','1 Thessalonians','2 Thessalonians','1 Timothy','2 Timothy','Titus','Philemon','Hebrews','James','1 Peter','2 Peter','1 John','2 John','3 John','Jude','Revelation'];
|
|
const ABBREV_BY_NAME = new Map(ESV_NAMES.map((n, i) => [n.toLowerCase(), BOOK_ORDER[i]]));
|
|
ABBREV_BY_NAME.set('psalms', 'PSA');
|
|
ABBREV_BY_NAME.set('song of songs', 'SNG');
|
|
|
|
export function parseEsvReference(ref) {
|
|
const m = String(ref).match(/^(.+?)\s+(\d+):(\d+)/);
|
|
const bookAbbrev = m && ABBREV_BY_NAME.get(m[1].toLowerCase());
|
|
return bookAbbrev ? { bookAbbrev, chapter: Number(m[2]), verse: Number(m[3]) } : null;
|
|
}
|
|
|
|
export const LICENSED_TRANSLATIONS = {
|
|
ESV: { provider: 'esv', name: 'English Standard Version' },
|
|
NLT: { provider: 'api.bible', bibleId: 'd6e14a625393b4da-01', name: 'New Living Translation' },
|
|
CSB: { provider: 'api.bible', bibleId: 'a556c5305ee15c3f-01', name: 'Christian Standard Bible' },
|
|
NASB: { provider: 'api.bible', bibleId: 'b8ee27bcd1cae43a-01', name: 'New American Standard Bible (1995)' },
|
|
};
|
|
|
|
const CACHE_TTL = 24 * 60 * 60 * 1000;
|
|
const CACHE_MAX = 1000;
|
|
// Crossway limits how much ESV text an app may store, so ESV chapters are only
|
|
// held briefly and in small numbers (enough to absorb repeat loads).
|
|
const ESV_CACHE_TTL = 60 * 60 * 1000;
|
|
const ESV_CACHE_MAX = 20;
|
|
const cache = new Map(); // "NLT/PSA/23" -> { at, payload }
|
|
|
|
// api.bible returns plain text with inline "[12]" verse markers, poetry line
|
|
// breaks, and stray "#" / nbsp characters. Split it back into { number, text }.
|
|
export function parseChapterText(content) {
|
|
const clean = String(content ?? '')
|
|
.replace(/\u00a0/g, ' ')
|
|
.replace(/#/g, '');
|
|
const parts = clean.split(/\[(\d+)\]/);
|
|
const verses = [];
|
|
for (let i = 1; i < parts.length; i += 2) {
|
|
const text = parts[i + 1].replace(/\s+/g, ' ').trim().replace(/\s+,$/, '');
|
|
if (text) verses.push({ number: Number(parts[i]), text });
|
|
}
|
|
return verses;
|
|
}
|
|
|
|
export function registerBibleRoutes(app, requireAuth) {
|
|
app.get('/api/bible/translations', requireAuth, (_req, res) => {
|
|
res.json(Object.entries(LICENSED_TRANSLATIONS).map(([id, t]) => ({
|
|
id, name: t.name, available: Boolean(t.provider === 'esv' ? process.env.ESV_API_KEY : process.env.API_BIBLE_KEY),
|
|
})));
|
|
});
|
|
|
|
// Whole-Bible search for the licensed translations, proxied so the keys stay
|
|
// server-side. Results are { bookAbbrev, chapter, verse, text }; nothing is cached.
|
|
const SEARCH_PAGE_SIZE = 25;
|
|
app.get('/api/bible/:translation/search', requireAuth, async (req, res) => {
|
|
const id = req.params.translation.toUpperCase();
|
|
const translation = LICENSED_TRANSLATIONS[id];
|
|
const q = String(req.query.q ?? '').trim();
|
|
const page = Math.max(1, Math.min(200, parseInt(req.query.page, 10) || 1));
|
|
if (!translation) return res.status(404).json({ error: 'Unknown translation.' });
|
|
if (q.length < 2 || q.length > 100) return res.status(400).json({ error: 'Search must be 2 to 100 characters.' });
|
|
const isEsv = translation.provider === 'esv';
|
|
const apiKey = isEsv ? process.env.ESV_API_KEY : process.env.API_BIBLE_KEY;
|
|
if (!apiKey) return res.status(503).json({ error: `${isEsv ? 'ESV_API_KEY' : 'API_BIBLE_KEY'} is not configured on the server.` });
|
|
|
|
try {
|
|
let results;
|
|
let total;
|
|
if (isEsv) {
|
|
const qs = new URLSearchParams({ q, 'page-size': String(SEARCH_PAGE_SIZE), page: String(page) });
|
|
const upstream = await fetch(`https://api.esv.org/v3/passage/search/?${qs}`, { headers: { Authorization: `Token ${apiKey}` } });
|
|
if (upstream.status === 429) return res.status(429).json({ error: 'ESV rate limit reached. Try again in a minute.' });
|
|
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
|
|
const body = await upstream.json();
|
|
total = body.total_results ?? 0;
|
|
results = (body.results ?? []).map((r) => {
|
|
const ref = parseEsvReference(r.reference);
|
|
return ref && { ...ref, text: String(r.content ?? '').replace(/<[^>]+>/g, '').trim() };
|
|
}).filter(Boolean);
|
|
} else {
|
|
const qs = new URLSearchParams({ query: q, limit: String(SEARCH_PAGE_SIZE), offset: String((page - 1) * SEARCH_PAGE_SIZE), sort: 'canonical' });
|
|
const upstream = await fetch(`${API_BASE}/bibles/${translation.bibleId}/search?${qs}`, { headers: { 'api-key': apiKey } });
|
|
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
|
|
const body = await upstream.json();
|
|
total = body.data?.total ?? 0;
|
|
results = (body.data?.verses ?? []).map((v) => ({
|
|
bookAbbrev: v.bookId,
|
|
chapter: Number(String(v.chapterId).split('.')[1]),
|
|
verse: Number(String(v.id).split('.')[2]),
|
|
text: String(v.text ?? '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(),
|
|
})).filter((r) => r.bookAbbrev && r.chapter && r.verse);
|
|
}
|
|
res.json({ translation: id, query: q, page, total, hasMore: page * SEARCH_PAGE_SIZE < total, results });
|
|
} catch {
|
|
res.status(502).json({ error: 'Could not reach the Bible provider.' });
|
|
}
|
|
});
|
|
|
|
// ESV chapter audio (read by David Cochran Heath). The ESV API answers with a
|
|
// redirect to a public mp3; hand that URL to the browser's <audio> element.
|
|
app.get('/api/bible/ESV/audio/:book/:chapter', requireAuth, async (req, res) => {
|
|
const { book, chapter } = req.params;
|
|
const bookNum = BOOK_ORDER.indexOf(book.toUpperCase()) + 1;
|
|
if (!bookNum || !/^\d{1,3}$/.test(chapter)) return res.status(400).json({ error: 'Invalid book or chapter.' });
|
|
if (!process.env.ESV_API_KEY) return res.status(503).json({ error: 'ESV_API_KEY is not configured on the server.' });
|
|
const prefix = String(bookNum).padStart(2, '0') + String(Number(chapter)).padStart(3, '0');
|
|
try {
|
|
const upstream = await fetch(`https://api.esv.org/v3/passage/audio/?q=${prefix}001-${prefix}999`, {
|
|
headers: { Authorization: `Token ${process.env.ESV_API_KEY}` }, redirect: 'manual',
|
|
});
|
|
const url = upstream.headers.get('location');
|
|
if (upstream.status === 429) return res.status(429).json({ error: 'ESV rate limit reached. Try again in a minute.' });
|
|
if (!url) return res.status(502).json({ error: 'ESV audio is not available for this chapter.' });
|
|
res.json({ url });
|
|
} catch {
|
|
res.status(502).json({ error: 'Could not reach the ESV audio service.' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/bible/:translation/:book/:chapter', requireAuth, async (req, res) => {
|
|
const translation = LICENSED_TRANSLATIONS[req.params.translation.toUpperCase()];
|
|
const { book, chapter } = req.params;
|
|
if (!translation) return res.status(404).json({ error: 'Unknown translation.' });
|
|
if (!/^[1-3A-Z]{3}$/i.test(book) || !/^\d{1,3}$/.test(chapter)) {
|
|
return res.status(400).json({ error: 'Invalid book or chapter.' });
|
|
}
|
|
const id = req.params.translation.toUpperCase();
|
|
const isEsv = translation.provider === 'esv';
|
|
const apiKey = isEsv ? process.env.ESV_API_KEY : process.env.API_BIBLE_KEY;
|
|
if (!apiKey) return res.status(503).json({ error: `${isEsv ? 'ESV_API_KEY' : 'API_BIBLE_KEY'} is not configured on the server.` });
|
|
|
|
const key = `${id}/${book.toUpperCase()}/${chapter}`;
|
|
const ttl = isEsv ? ESV_CACHE_TTL : CACHE_TTL;
|
|
const hit = cache.get(key);
|
|
if (hit && Date.now() - hit.at < ttl) return res.json(hit.payload);
|
|
|
|
try {
|
|
let payload;
|
|
if (isEsv) {
|
|
const bookNum = BOOK_ORDER.indexOf(book.toUpperCase()) + 1;
|
|
if (!bookNum) return res.status(400).json({ error: 'Invalid book.' });
|
|
const prefix = String(bookNum).padStart(2, '0') + String(Number(chapter)).padStart(3, '0');
|
|
const qs = new URLSearchParams({
|
|
q: `${prefix}001-${prefix}999`,
|
|
'include-passage-references': 'false', 'include-verse-numbers': 'true', 'include-first-verse-numbers': 'true',
|
|
'include-footnotes': 'false', 'include-headings': 'false', 'include-short-copyright': 'false',
|
|
'include-passage-horizontal-lines': 'false', 'include-heading-horizontal-lines': 'false',
|
|
});
|
|
const upstream = await fetch(`https://api.esv.org/v3/passage/text/?${qs}`, { headers: { Authorization: `Token ${apiKey}` } });
|
|
if (upstream.status === 429) return res.status(429).json({ error: 'ESV rate limit reached. Try again in a minute.' });
|
|
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
|
|
const body = await upstream.json();
|
|
const verses = parseChapterText(body.passages?.[0]);
|
|
if (verses.length === 0) return res.status(404).json({ error: 'Chapter not found.' });
|
|
payload = { translation: id, verses, copyright: '', fumsToken: null };
|
|
} else {
|
|
const qs = new URLSearchParams({
|
|
'content-type': 'text', 'include-notes': 'false', 'include-titles': 'false',
|
|
'include-chapter-numbers': 'false', 'include-verse-numbers': 'true', 'include-verse-spans': 'false',
|
|
});
|
|
const upstream = await fetch(
|
|
`${API_BASE}/bibles/${translation.bibleId}/chapters/${book.toUpperCase()}.${chapter}?${qs}`,
|
|
{ headers: { 'api-key': apiKey } },
|
|
);
|
|
if (upstream.status === 404) return res.status(404).json({ error: 'Chapter not found.' });
|
|
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
|
|
const body = await upstream.json();
|
|
const verses = parseChapterText(body.data?.content);
|
|
if (verses.length === 0) return res.status(502).json({ error: 'Bible provider returned no verses.' });
|
|
payload = { translation: id, verses, copyright: body.data?.copyright ?? '', fumsToken: body.meta?.fumsToken ?? null };
|
|
}
|
|
const max = isEsv ? ESV_CACHE_MAX : CACHE_MAX;
|
|
if (cache.size >= CACHE_MAX + ESV_CACHE_MAX) cache.delete(cache.keys().next().value);
|
|
if (isEsv) {
|
|
const esvKeys = [...cache.keys()].filter((k) => k.startsWith('ESV/'));
|
|
if (esvKeys.length >= max) cache.delete(esvKeys[0]);
|
|
}
|
|
cache.set(key, { at: Date.now(), payload });
|
|
res.json(payload);
|
|
} catch {
|
|
res.status(502).json({ error: 'Could not reach the Bible provider.' });
|
|
}
|
|
});
|
|
}
|