Files
study-bible-project/server/iqbible.js
T
nmemmertandClaude Sonnet 5.5 bdb04cdedc
Build and Push Docker Image / build-and-push (push) Successful in 10m22s
Add cross-reference map, admin health page, passkeys and device list, settings tabs
- Reader: chapter cross-reference map (M) from IQ Bible's reference graph
- Admin: Health tab with disk use, snapshots (back up, download, restore), failure logs
- Passkey sign-in (WebAuthn), signed-in device list, sign out others, optional sign-in alerts
- Settings split into Account, Preferences, Notifications, Security, Your data tabs
- ntfy server field: catch a topic typed into the server box

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 12:52:49 -04:00

144 lines
7.7 KiB
JavaScript

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