import rateLimit from 'express-rate-limit' import { BIBLE_BOOKS } from '../../shared/bible-books.js' const passageCache = new Map() const PASSAGE_CACHE_MAX = 2000 const MAX_CHAPTER = 150 const MAX_VERSE = 176 const MAX_VERSES_PER_REQUEST = 60 const passageRateLimiter = rateLimit({ windowMs: 60 * 1000, max: 60, standardHeaders: true, legacyHeaders: false, message: { message: 'Too many passage requests. Please slow down.' }, }) // Keyed by lowercase, whitespace-stripped book name/alias (matching the format // the admin's "Bible Book" field and the /api/bible-passage `book` query param // both use), mapped to bolls.life's numeric book id (canonical order, 1-66). // Derived from the shared BIBLE_BOOKS list so it can't drift from the client's // Scripture Index book map (src/App.tsx's SI_BOOK_MAP). const BOLLS_BOOK_MAP = {} BIBLE_BOOKS.forEach((b, index) => { const bookNum = index + 1 for (const key of [b.name.toLowerCase(), ...(b.aliases ?? [])]) { BOLLS_BOOK_MAP[key.replace(/\s+/g, '')] = bookNum } }) function parseReference(reference) { // Accept a leading book name ("Titus 1:1-2", "1 John 2:3"), en/em dashes and verse-part letters ("1:13a") const stripped = reference .replace(/[–—]/g, '-') .replace(/(\d)[a-z]\b/gi, '$1') .replace(/^\s*(?:[1-3]\s*)?[a-z][a-z .]*?\s*(?=\d+:)/i, '') .trim() const match = stripped.match(/^(\d+):(\d+)(?:-(\d+))?$/) if (!match) return null return { chapter: parseInt(match[1], 10), verseStart: parseInt(match[2], 10), verseEnd: match[3] ? parseInt(match[3], 10) : parseInt(match[2], 10), } } export function register(app) { app.get('/api/bible-passage', passageRateLimiter, async (req, res) => { const book = String(req.query.book ?? '').trim().toLowerCase().replace(/[\s-]/g, '') const reference = String(req.query.reference ?? '').trim() const bookNum = BOLLS_BOOK_MAP[book] if (!bookNum) { res.status(400).json({ message: 'Unknown book.' }) return } const parsed = parseReference(reference) if (!parsed) { res.status(400).json({ message: 'Invalid reference format. Expected format: chapter:verseStart-verseEnd' }) return } if ( parsed.chapter < 1 || parsed.chapter > MAX_CHAPTER || parsed.verseStart < 1 || parsed.verseEnd > MAX_VERSE || parsed.verseEnd < parsed.verseStart || parsed.verseEnd - parsed.verseStart + 1 > MAX_VERSES_PER_REQUEST ) { res.status(400).json({ message: `Passage out of range (max ${MAX_VERSES_PER_REQUEST} verses).` }) return } const cacheKey = `BSB:${bookNum}:${parsed.chapter}:${parsed.verseStart}:${parsed.verseEnd}` if (passageCache.has(cacheKey)) { res.json({ text: passageCache.get(cacheKey) }) return } try { const verseNums = [] for (let v = parsed.verseStart; v <= parsed.verseEnd; v++) verseNums.push(v) const results = await Promise.all( verseNums.map(v => fetch(`https://bolls.life/get-verse/BSB/${bookNum}/${parsed.chapter}/${v}/`, { signal: AbortSignal.timeout(8000) }) .then(r => r.ok ? r.json() : Promise.reject(new Error(`${r.status}`))) ) ) if (results.length === 0) { res.status(404).json({ message: 'Passage not found.' }) return } const text = results.map(v => `v. ${v.verse} ${String(v.text).trim()}`).join('\n\n') if (passageCache.size >= PASSAGE_CACHE_MAX) passageCache.delete(passageCache.keys().next().value) passageCache.set(cacheKey, text) res.json({ text }) } catch { res.status(502).json({ message: 'Unable to fetch passage right now.' }) } }) }