// 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/graph'), 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.' }); } }); }