// 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']; 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(/ /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, ' ').replace(/\s+,$/, '').trim(); 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), }))); }); 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.' }); } }); }