Files
hub/vbv.js
T

90 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Verse By Verse Ministry International (versebyverseministry.org): free verse-by-verse Bible teaching. The ministry publishes every study as a podcast feed
// (/bible-studies/<study>/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:<study>:<lesson number>
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(/<!\[CDATA\[([\s\S]*?)\]\]>/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(/<li class="study-item[\s\S]*?<\/li>/g)) {
const card = m[0], slug = (/href="\/bible-studies\/([\w-]+)"/.exec(card) || [])[1]; if (!slug) continue;
const imgs = (/srcset="([^"]+)"/.exec(card) || [])[1], cover = ((imgs || '').split(',').pop() || '').trim().split(' ')[0] || (/<img src="([^"]+)"/.exec(card) || [])[1] || '';
const s = by.get(slug) || { slug, title: plain((/<h3[^>]*>([\s\S]*?)<\/h3>/.exec(card) || [])[1]), lessons: parseInt((/(\d+)\s+Lessons?/i.exec(card) || [])[1]) || 0, teacher: plain((/<p class="body-text[^>]*>([\s\S]*?)<\/p>/.exec(card) || [])[1]), cover: allowed(cover) ? cover : '', categories: [] };
s.categories.push(c); by.set(slug, s);
}
}));
if (!by.size) throw fail(502, 'Could not read the list of studies');
return [...by.values()];
});
const feed = slug => cached('feed:' + slug, 1800e3, async () => {
if (!/^[\w-]{1,80}$/.test(slug)) throw fail(400, 'Bad study');
const x = await page(`${BASE()}/bible-studies/${slug}/podcast.xml`, 5e6), ch = x.replace(/<item>[\s\S]*$/, '');
const tag = (s, t) => plain((new RegExp('<' + t + '>([\\s\\S]*?)</' + t + '>', 'i').exec(s) || [])[1]);
const items = [...x.matchAll(/<item>([\s\S]*?)<\/item>/g)].map((m, i) => { const it = m[1], enc = /<enclosure[^>]*url="([^"]+)"/.exec(it); return { n: i, title: tag(it, 'title').replace(/^\d+\s+/, '') || 'Lesson ' + (i + 1), description: tag(it, 'description'), summary: tag(it, 'itunes:subtitle'), date: tag(it, 'pubDate'), audio: enc && allowed(unent(enc[1])) ? unent(enc[1]) : '', link: tag(it, 'link') }; }).filter(i => i.audio);
if (!items.length) throw fail(404, 'No lessons found');
const img = (/<itunes:image href="([^"]+)"/.exec(ch) || [])[1];
return { slug, title: tag(ch, 'title'), teacher: tag(ch, 'itunes:author'), description: tag(ch, 'itunes:summary') || tag(ch, 'description'), cover: img && allowed(img) ? img : '', lessons: items };
});
const coverUrl = u => 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 };