Files
study-bible-project/server/bibleApi.js
T
nmemmertandClaude Sonnet 5.5 9e722a296d
Build and Push Docker Image / build-and-push (push) Failing after 20s
Redesign the Reader; add ESV
New reading surface with paper/sepia/dark themes, slim top and bottom bars,
and sheets for navigation, search, settings, audio and per-verse study tools.
Plan progress is built into the reader. Page-flip mode removed. ESV added via
the server-side proxy (ESV_API_KEY).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 09:11:47 -04:00

127 lines
6.8 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.' });
}
});
}