// Internet radio. The station list comes from the community Radio Browser directory (free, no key), with The Journey FM (Liberty University, Lynchburg VA) // always on top. The hub fetches each stream itself and passes it on: many stations only offer plain http, which a browser refuses to play inside an https page, // and the hub also reads the "now playing" text out of the stream so the player can show the song. Each person keeps their own list of saved stations. const fs = require('fs'), path = require('path'), http = require('http'), https = require('https'), dns = require('dns'), net = require('net'); const users = require('./users'); const FEATURED = [{ id: 'journeyfm', name: 'The Journey FM', url: process.env.JOURNEYFM_STREAM_URL || 'http://ic.liberty.edu:8000/WRVL', homepage: 'https://www.myjourneyfm.com/', favicon: '', tags: 'christian,contemporary christian,worship', country: 'United States', state: 'Virginia', codec: 'MP3', bitrate: 192, featured: true, }]; const JOURNEY_NOW = () => process.env.JOURNEYFM_NOW_URL || 'https://www.myjourneyfm.com/wp-json/custom/v2/recentlyPlayed'; const DIRECTORY = () => process.env.RADIO_BROWSER_URL ? [process.env.RADIO_BROWSER_URL.replace(/\/+$/, '')] : ['https://de1.api.radio-browser.info', 'https://at1.api.radio-browser.info', 'https://nl1.api.radio-browser.info']; const UA = 'NE-Hub/1.0 (self-hosted)'; const PLAYABLE = /^(MP3|AAC|AAC\+|OGG|VORBIS|OPUS)$/i; 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 }); // ---- the hub never connects to a private or local address on behalf of a station (the address is checked after it is looked up, so a name that points inside cannot slip through) const isPrivate = a => { if (process.env.RADIO_ALLOW_PRIVATE === '1') return false; if (net.isIPv4(a)) { const [x, y] = a.split('.').map(Number); return x === 0 || x === 10 || x === 127 || (x === 169 && y === 254) || (x === 172 && y >= 16 && y <= 31) || (x === 192 && y === 168) || (x === 100 && y >= 64 && y <= 127) || x >= 224; } if (net.isIPv6(a)) { const l = a.toLowerCase(); return l === '::1' || l === '::' || l.startsWith('fe80') || l.startsWith('fc') || l.startsWith('fd') || (l.startsWith('::ffff:') && isPrivate(l.slice(7))); } return true; }; const safeLookup = (host, opts, cb) => { if (typeof opts === 'function') { cb = opts; opts = {}; } dns.lookup(host, { ...opts, all: true }, (err, list) => { if (err) return cb(err); const ok = list.filter(x => !isPrivate(x.address)); if (!ok.length) return cb(new Error('That address is not allowed')); if (opts.all) return cb(null, ok); cb(null, ok[0].address, ok[0].family); }); }; function open(url, { headers = {}, timeout = 12000, hops = 4 } = {}) { // GET with redirects; resolves with the response (not yet read) return new Promise((ok, no) => { let u; try { u = new URL(url); } catch { return no(fail(400, 'Bad address')); } if (!/^https?:$/.test(u.protocol)) return no(fail(400, 'Bad address')); const lit = u.hostname.replace(/^\[|\]$/g, ''); if (net.isIP(lit) && isPrivate(lit)) return no(fail(502, 'That address is not allowed')); // (an address written as numbers never goes through the lookup below) const req = (u.protocol === 'https:' ? https : http).get(u, { headers: { 'user-agent': UA, ...headers }, lookup: safeLookup, timeout }, res => { if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) { res.resume(); if (!hops) return no(fail(502, 'Too many redirects')); return open(new URL(res.headers.location, u).href, { headers, timeout, hops: hops - 1 }).then(ok, no); } ok(res); }); req.on('timeout', () => req.destroy(fail(504, 'The station did not answer'))); req.on('error', e => no(e.code && e.code >= 400 ? e : fail(502, 'Could not reach that address'))); }); } async function readText(url, max = 2e6, opts) { const res = await open(url, opts); if (res.statusCode >= 400) { res.resume(); throw fail(502, 'Answered ' + res.statusCode); } const c = []; let n = 0; for await (const d of res) { n += d.length; if (n > max) { res.destroy(); throw fail(502, 'Too large'); } c.push(d); } return Buffer.concat(c).toString('utf8'); } // ---- Radio Browser async function directory(p) { let last; for (const base of DIRECTORY()) { try { return JSON.parse(await readText(base + p, 2e6, { timeout: 8000 })); } catch (e) { last = e; } } throw fail(502, 'The radio directory is not answering' + (last && last.message ? ' (' + last.message + ')' : '')); } const tidy = s => ({ id: s.stationuuid, name: String(s.name || '').replace(/\s+/g, ' ').trim().slice(0, 80), url: s.url_resolved || s.url, homepage: /^https?:/.test(s.homepage || '') ? s.homepage : '', favicon: /^https?:/.test(s.favicon || '') ? s.favicon : '', tags: String(s.tags || '').split(',').map(x => x.trim()).filter(Boolean).slice(0, 4).join(','), country: s.country || '', state: s.state || '', codec: s.codec || '', bitrate: +s.bitrate || 0, votes: +s.votes || 0, }); const usable = s => s && s.stationuuid && /^https?:\/\//.test(s.url_resolved || s.url || '') && !s.hls && PLAYABLE.test(s.codec || '') && String(s.name || '').trim(); const known = new Map(); // station id -> { t, st } async function station(id) { const f = FEATURED.find(x => x.id === id) || mine().find(x => x.id === id); if (f) return f; if (!/^[\w-]{8,64}$/.test(id)) throw fail(400, 'Bad station'); const c = known.get(id); if (c && Date.now() - c.t < 3600e3) return c.st; const [s] = await directory('/json/stations/byuuid/' + id); if (!usable(s)) throw fail(404, 'Station not found'); const st = tidy(s); if (known.size > 500) known.clear(); known.set(id, { t: Date.now(), st }); return st; } const favFile = () => path.join(users.dir(), 'radio.json'); const mine = () => { try { const l = JSON.parse(fs.readFileSync(favFile(), 'utf8')); return Array.isArray(l) ? l : []; } catch { return []; } }; const saveMine = l => { const f = favFile(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(l), { mode: 0o600 }); fs.renameSync(tmp, f); }; 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'); } }; // ---- now playing: the Journey's own feed, or the song title carried inside the stream const nowMap = new Map(); // person:station -> { title, t } const clean = s => String(s || '').replace(/\s+/g, ' ').trim().slice(0, 160); const titleCase = s => s && s === s.toUpperCase() ? s.toLowerCase().replace(/(^|[\s(&\/-])([a-z])/g, (m, a, b) => a + b.toUpperCase()) : s; async function journeyNow(recent) { const rows = JSON.parse(await readText(JOURNEY_NOW() + (recent ? '/getRecentlyPlayed' : '/getNowPlaying'), 1e6, { timeout: 8000 })); return (Array.isArray(rows) ? rows : []).filter(r => r && r.TITLE && r.ARTIST).map(r => ({ title: titleCase(clean(r.TITLE)), artist: titleCase(clean(r.ARTIST)), at: r.ACTIVITY_DATE || '' })); // rows with no artist are the station's own sound bites } /** Pull the song title out of a stream: every `metaint` bytes of audio are followed by a small text block (StreamTitle='...';), which must not reach the player. */ function icyFilter(metaint, onTitle) { let toMeta = metaint, need = -1, buf = Buffer.alloc(0); return chunk => { const out = []; let i = 0; while (i < chunk.length) { if (need >= 0) { const take = Math.min(need - buf.length, chunk.length - i); buf = Buffer.concat([buf, chunk.subarray(i, i + take)]); i += take; if (buf.length === need) { const m = /StreamTitle='([^]*?)';/.exec(buf.toString('utf8')); if (m) onTitle(clean(m[1])); need = -1; buf = Buffer.alloc(0); toMeta = metaint; } } else if (toMeta === 0) { need = chunk[i] * 16; i++; if (need === 0) { need = -1; toMeta = metaint; } } else { const take = Math.min(toMeta, chunk.length - i); out.push(chunk.subarray(i, i + take)); i += take; toMeta -= take; } } return Buffer.concat(out); }; } async function handle(req, res) { const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/radio'.length) || '/', me = users.current(); try { let m; if (p === '/search' && req.method === 'GET') { const q = clean(u.searchParams.get('q')).slice(0, 60), tag = clean(u.searchParams.get('tag')).slice(0, 30), off = Math.min(500, Math.max(0, +u.searchParams.get('offset') || 0)); const qs = new URLSearchParams({ hidebroken: 'true', order: 'clickcount', reverse: 'true', limit: '30', offset: String(off), is_https: u.searchParams.get('https') === '1' ? 'true' : '' }); if (!qs.get('is_https')) qs.delete('is_https'); if (q) qs.set('name', q); if (tag) qs.set('tag', tag); if (!q && !tag) qs.set('tag', 'christian'); const rows = await directory('/json/stations/search?' + qs), items = rows.filter(usable).map(tidy); for (const st of items) known.set(st.id, { t: Date.now(), st }); if (known.size > 500) known.clear(); // saving or playing one of these needs no second look-up return json(res, 200, { items, more: rows.length >= 30 }); } if (p === '/stations' && req.method === 'GET') return json(res, 200, { featured: FEATURED, mine: mine() }); if (p === '/stations' && req.method === 'POST') { const b = await readBody(req), s = await station(String(b.id || '')); if (s.featured) return json(res, 200, { saved: true }); const l = mine().filter(x => x.id !== s.id); l.push(s); saveMine(l.slice(-200)); return json(res, 200, { saved: true }); } if ((m = /^\/stations\/([\w-]+)$/.exec(p)) && req.method === 'DELETE') { saveMine(mine().filter(x => x.id !== m[1])); return json(res, 200, { removed: true }); } if ((m = /^\/now\/([\w-]+)$/.exec(p)) && req.method === 'GET') { if (m[1] === 'journeyfm') { let now = null, recent = []; try { recent = await journeyNow(true); now = recent[0] || null; } catch {} return json(res, 200, { title: now ? now.title : '', artist: now ? now.artist : '', recent: recent.slice(0, 12) }); } const n = nowMap.get(me.id + ':' + m[1]); return json(res, 200, { title: n && Date.now() - n.t < 600e3 ? n.title : '', artist: '', recent: [] }); } if (p === '/icon' && req.method === 'GET') { // station logos come through the hub too (they are often plain http, and fetching them directly would tell their servers who is listening) const res2 = await open(u.searchParams.get('u') || '', { timeout: 8000 }), type = String(res2.headers['content-type'] || '').split(';')[0].toLowerCase(); if (res2.statusCode !== 200 || !/^image\/(png|jpeg|gif|webp|x-icon|vnd\.microsoft\.icon|svg\+xml)$/.test(type)) { res2.resume(); throw fail(404, 'No icon'); } const c = []; let n = 0; for await (const d of res2) { n += d.length; if (n > 5e5) { res2.destroy(); throw fail(404, 'No icon'); } c.push(d); } res.writeHead(200, { 'content-type': type === 'image/svg+xml' ? 'image/svg+xml' : type, 'cache-control': 'private, max-age=604800', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'", 'x-content-type-options': 'nosniff' }); return res.end(Buffer.concat(c)); } if ((m = /^\/play\/([\w-]+)$/.exec(p)) && req.method === 'GET') { const s = await station(m[1]), up = await open(s.url, { headers: { 'icy-metadata': '1' }, timeout: 15000 }); if (up.statusCode >= 400) { up.resume(); throw fail(502, s.name + ' answered ' + up.statusCode); } const type = String(up.headers['content-type'] || '').split(';')[0].trim().toLowerCase(); if (/mpegurl|x-scpls|text\/|html/.test(type)) { up.resume(); throw fail(502, s.name + ' sent a playlist, not a stream'); } const metaint = +up.headers['icy-metaint'] || 0, key = me.id + ':' + s.id, filter = metaint ? icyFilter(metaint, t => nowMap.set(key, { title: t, t: Date.now() })) : null; res.writeHead(200, { 'content-type': type.startsWith('audio/') || type === 'application/ogg' ? type : 'audio/mpeg', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'accept-ranges': 'none' }); const close = () => { try { up.destroy(); } catch {} }; res.on('close', close); up.on('error', () => res.destroy()); up.on('data', d => { if (!res.write(filter ? filter(d) : d)) { up.pause(); res.once('drain', () => up.resume()); } }); up.on('end', () => res.end()); return; } return json(res, 404, { error: 'Not found' }); } catch (e) { if (res.headersSent) return res.destroy(); return json(res, e.code >= 400 && e.code < 600 ? e.code : 502, { error: e.message }); } } module.exports = { handle, icyFilter, isPrivate };