Build and Push Docker Image / build-and-push (push) Successful in 7m14s
Sync and data safety - Autosave flushes on leaving a study or the tab; storage-full errors are reported - Unsynced studies, deletes and ink are queued and retried when back online - Deletes propagate across devices (with a "Keep it" undo) and no longer resurrect - Server assigns study versions (no client-clock conflicts); "Combine both" merges a conflicted study chunk by chunk - Reading-plan progress made offline is merged, not overwritten; plans, ink and marks are cleared from the device on sign-out Security - Block SSRF through the ntfy reminder URL; failure-only rate limiting per IP and account; constant-time login; TOTP replay protection; input validation; SESSION_SECRET required in production; admin user delete fixed - Forgot password: one-time code sent to the account's ntfy topic Features - IQ Bible proxy (route allow-list, text-only caching per its terms): more commentaries, lexicons, study aids, daily verse and devotional, plan calendar export, narrations, podcast background - Study templates, reading-plan catch-up, account export, daily database snapshots Tests and tooling - ESLint config; App tests split into src/test; tests for sync queues, library hook, merge, templates, IQ Bible UI and reset form Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
144 lines
7.7 KiB
JavaScript
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/@/@'), 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.' });
|
|
}
|
|
});
|
|
}
|