// Verse By Verse Ministry International (versebyverseministry.org): free verse-by-verse Bible teaching. The ministry publishes every study as a podcast feed // (/bible-studies//podcast.xml) with one MP3 per lesson, and a handout (PDF) on each lesson's page. The hub reads the study list from the ministry's own // pages and the feeds, sends the browser straight to each lesson's recording (so the ministry's server does the streaming), and keeps each person's place. const users = require('./users'); const prog = require('./progress')('vbv-progress.json'); // keys vbv:: const BASE = () => (process.env.VBV_URL || 'https://versebyverseministry.org').replace(/\/+$/, ''); const HOSTS = () => { const h = new URL(BASE()).hostname; return [h, 'www.' + h, 'media.' + h.replace(/^www\./, ''), 'versebyverseministry.org', 'media.versebyverseministry.org', 'www.versebyverseministry.org']; }; const allowed = u => { try { const x = new URL(u); return /^https?:$/.test(x.protocol) && HOSTS().includes(x.hostname); } catch { return false; } }; const UA = 'NE-Hub/1.0 (self-hosted; reads the ministry\'s public pages and feeds)'; const CATEGORIES = [['new-testament-books', 'New Testament'], ['old-testament-books', 'Old Testament'], ['topical-series', 'Topical'], ['single-teachings', 'Single teachings'], ['womens-teaching', "Women's"], ['youth-resources', 'Youth']]; const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); }; const fail = (code, message) => Object.assign(new Error(message), { code }); const unent = s => String(s || '').replace(//g, '$1').replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|rsquo|lsquo|ldquo|rdquo|mdash|ndash|nbsp);/gi, (m, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”', mdash: '—', ndash: '–', nbsp: ' ' }[e.toLowerCase()] || (e[0] === '#' ? String.fromCodePoint(e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : +e.slice(1)) : m))); const plain = s => unent(String(s || '').replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim(); async function page(url, max = 3e6) { if (!allowed(url)) throw fail(502, 'Bad address'); let r; try { r = await fetch(url, { headers: { 'user-agent': UA, accept: 'text/html,application/xml,*/*;q=0.5' }, redirect: 'follow', signal: AbortSignal.timeout(15000) }); } catch { throw fail(502, 'Could not reach Verse By Verse Ministry'); } if (!allowed(r.url)) throw fail(502, 'Bad address'); if (r.status === 404) throw fail(404, 'Not found'); if (!r.ok) throw fail(502, 'Verse By Verse Ministry answered ' + r.status); const t = await r.text(); if (t.length > max) throw fail(502, 'Too large'); return t; } const memo = new Map(); // key -> { t, v } async function cached(key, ttl, fn) { const c = memo.get(key); if (c && Date.now() - c.t < ttl) return c.v; const v = await fn(); if (memo.size > 300) memo.clear(); memo.set(key, { t: Date.now(), v }); return v; } const studiesAll = () => cached('studies', 3600e3, async () => { const by = new Map(); await Promise.all(CATEGORIES.map(async ([c, name]) => { let h; try { h = await page(`${BASE()}/bible-studies/category/${c}`); } catch { return; } for (const m of h.matchAll(/
  • ]*>([\s\S]*?)<\/h3>/.exec(card) || [])[1]), lessons: parseInt((/(\d+)\s+Lessons?/i.exec(card) || [])[1]) || 0, teacher: plain((/

    i.audio); if (!items.length) throw fail(404, 'No lessons found'); const img = (/ u ? '/api/vbv/cover?u=' + encodeURIComponent(u) : ''; const publicStudy = s => ({ ...s, cover: coverUrl(s.cover), lessons: s.lessons.map(({ audio, link, ...l }) => l) }); const notesOf = (slug, n, lessonUrl) => cached(`notes:${slug}:${n}`, 6 * 3600e3, async () => { const h = await page(lessonUrl); const pdfs = [...h.matchAll(/https?:\/\/[^"'\s<>]+\.pdf/gi)].map(m => unent(m[0])).filter(allowed); return pdfs[0] || ''; }); const readBody = async req => { const c = []; let n = 0; for await (const d of req) { n += d.length; if (n > 1e5) throw fail(413, 'Too large'); c.push(d); } try { return JSON.parse(Buffer.concat(c).toString('utf8') || '{}'); } catch { throw fail(400, 'Bad request'); } }; async function handle(req, res) { const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/vbv'.length) || '/'; try { let m; if (p === '/studies' && req.method === 'GET') { const q = (u.searchParams.get('q') || '').trim().toLowerCase(), cat = u.searchParams.get('category') || ''; const list = (await studiesAll()).filter(s => (!cat || s.categories.includes(cat)) && (!q || (s.title + ' ' + s.teacher).toLowerCase().includes(q))); return json(res, 200, { categories: CATEGORIES.map(([id, name]) => ({ id, name })), studies: list.map(s => ({ ...s, cover: coverUrl(s.cover) })) }); } if ((m = /^\/study\/([\w-]+)$/.exec(p)) && req.method === 'GET') return json(res, 200, publicStudy(await feed(m[1]))); if ((m = /^\/audio\/([\w-]+)\/(\d+)$/.exec(p)) && req.method === 'GET') { // the recording is on the ministry's media server; the browser is sent straight there (it supports seeking) const l = (await feed(m[1])).lessons[+m[2]]; if (!l) throw fail(404, 'No such lesson'); res.writeHead(302, { location: l.audio, 'cache-control': 'private, max-age=600', 'referrer-policy': 'no-referrer' }); return res.end(); } if ((m = /^\/notes\/([\w-]+)\/(\d+)$/.exec(p)) && req.method === 'GET') { const l = (await feed(m[1])).lessons[+m[2]]; if (!l) throw fail(404, 'No such lesson'); const pdf = l.link && allowed(l.link) ? await notesOf(m[1], m[2], l.link) : ''; if (!pdf) throw fail(404, 'This lesson has no handout'); res.writeHead(302, { location: pdf, 'cache-control': 'private, max-age=600', 'referrer-policy': 'no-referrer' }); return res.end(); } if (p === '/cover' && req.method === 'GET') { const src = u.searchParams.get('u') || ''; if (!allowed(src)) throw fail(404, 'No cover'); const r = await fetch(src, { headers: { 'user-agent': UA }, redirect: 'manual', signal: AbortSignal.timeout(10000) }), type = (r.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(); if (!r.ok || !/^image\/(jpeg|png|gif|webp)$/.test(type)) throw fail(404, 'No cover'); const buf = Buffer.from(await r.arrayBuffer()); if (buf.length > 3e6) throw fail(502, 'Too large'); res.writeHead(200, { 'content-type': type, 'cache-control': 'private, max-age=604800' }); return res.end(buf); } if (p === '/progress') { if (req.method === 'GET') return json(res, 200, prog.load()); if (req.method === 'POST') return json(res, 200, prog.merge(await readBody(req))); } return json(res, 404, { error: 'Not found' }); } catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : 502, { error: e.message }); } } module.exports = { handle };