Files
pondering-orb/web/providers/iptv-org.js
T
Diablo_Rain c58fa9591b feat: initial release of The Pondering Orb
A retro CRT-themed web TV that channel-surfs live HLS streams through a
security-hardened, same-origin Rust relay.

Front end (vanilla JS + HLS.js, no framework/build step):
- SourceProvider abstraction + canonical Channel/Program model so public
  streams and future self-hosted media share one UI (MockProvider built first
  to keep the UI provably source-agnostic; IptvOrgProvider for live data)
- Red/black phosphor CRT theme, virtualized 1994-style guide (The Almanac),
  cable-box zapping with 7-segment OSD, scrying-static transitions
- Theater/Dim/Ambient immersion modes, dead-stream "orb clouds over" handling
- The Lexicon in-world naming with mundane-mode; full a11y + reduced-motion
- HLS.js pinned + Subresource-Integrity verified

Rust relay (hyper 1.x + tokio-rustls/ring + hickory-resolver):
- Serves the SPA and proxies HLS from one origin (no CORS, untainted canvas)
- SSRF defense: resolve -> reject private/loopback/link-local/metadata IPs ->
  connect to the validated IP (no re-resolve, defeats DNS-rebind), fail-closed
- HTTPS-only egress, re-validated redirects, manifest URL rewrite, size/
  concurrency caps, strict CSP + security headers, path-traversal guard
- /metrics observability + structured logs (host only, never raw URLs)

Verified: all SSRF probes rejected, CSP clean, real Apple HLS manifest
rewritten, ~39k iptv-org channels fetched end-to-end, tests green, zero warnings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 05:34:09 -04:00

177 lines
7.0 KiB
JavaScript

// iptv-org.js — IptvOrgProvider. Real public channels via the iptv-org JSON API.
//
// Data source of truth = the JSON API (CORS-clean GitHub Pages), NOT raw M3U:
// join channels.json + streams.json + logos.json, EPG via guides.json (XMLTV).
// Stream URLs are routed through the same-origin /relay so HLS.js can play them
// without CORS and the ambient canvas stays untainted.
//
// SECURITY: every field from the API is treated as hostile input. We never use
// innerHTML anywhere; here we additionally scheme-allowlist URLs and cap sizes.
import { SourceProvider } from './source-provider.js';
const API = 'https://iptv-org.github.io/api';
const MAX_CHANNELS = 20000; // anti client-side DoS
const MAX_FIELD = 200; // cap any displayed string
const clip = (s) => (typeof s === 'string' ? s.slice(0, MAX_FIELD) : '');
/** Only https logos (or data:). Anything else → dropped. */
function safeLogo(url) {
if (typeof url !== 'string') return undefined;
if (/^https:\/\//i.test(url) || /^data:image\//i.test(url)) return url;
return undefined;
}
/** Only https stream URLs survive. */
function safeStream(url) {
return (typeof url === 'string' && /^https:\/\//i.test(url)) ? url : null;
}
/** Wrap an upstream URL so it flows through the hardened same-origin relay. */
function viaRelay(url) { return '/relay?url=' + encodeURIComponent(url); }
async function getJSON(url, useRelay) {
// Under the production CSP (connect-src 'self') the JSON API must also flow
// through the relay; on a plain static server we fetch it directly.
const target = useRelay ? viaRelay(url) : url;
const r = await fetch(target, { credentials: 'omit', referrerPolicy: 'no-referrer' });
if (!r.ok) throw new Error(`${url}${r.status}`);
return r.json();
}
export class IptvOrgProvider extends SourceProvider {
constructor(opts = {}) {
super();
this.useRelay = opts.useRelay !== false; // default on (same-origin server)
this._channels = null;
this._streamsByCh = new Map(); // channelId -> https url[]
this._guideByCh = new Map(); // channelId -> xmltv url
this._epgCache = new Map(); // channelId -> Program[]
}
get id() { return 'iptv-org'; }
get name() { return 'iptv-org'; }
get supportsGuides() { return true; }
async listChannels() {
if (this._channels) return this._channels;
const [channels, streams, logos, guides] = await Promise.all([
getJSON(`${API}/channels.json`, this.useRelay),
getJSON(`${API}/streams.json`, this.useRelay),
getJSON(`${API}/logos.json`, this.useRelay).catch(() => []),
getJSON(`${API}/guides.json`, this.useRelay).catch(() => []),
]);
// streams: channel -> [https urls]
for (const s of streams) {
const cid = s.channel;
const url = safeStream(s.url);
if (!cid || !url) continue;
(this._streamsByCh.get(cid) || this._streamsByCh.set(cid, []).get(cid)).push(url);
}
// first guide source per channel
for (const g of guides) {
if (g.channel && g.site && !this._guideByCh.has(g.channel)) {
// guides.json entries point at grabber sites, not always direct XMLTV;
// we store the channel's xmltv id for matching when an EPG feed is wired.
this._guideByCh.set(g.channel, g);
}
}
const logoByCh = new Map();
for (const l of logos) {
if (l.channel && !logoByCh.has(l.channel)) {
const u = safeLogo(l.url); if (u) logoByCh.set(l.channel, u);
}
}
/** @type {import('./source-provider.js').Channel[]} */
const out = [];
for (const c of channels) {
if (out.length >= MAX_CHANNELS) break;
const urls = this._streamsByCh.get(c.id);
if (!urls || !urls.length) continue; // only channels we can actually play
const playable = this.useRelay ? urls.map(viaRelay) : urls;
out.push({
id: `iptv-org:${c.id}`,
source: 'iptv-org',
name: clip(c.name) || c.id,
logo: logoByCh.get(c.id),
group: clip((c.categories && c.categories[0]) || ''),
country: clip(c.country || ''),
language: clip((c.languages && c.languages[0]) || ''),
kind: 'live',
streamUrls: playable,
guideId: c.id,
});
}
this._channels = out;
return out;
}
async getStreamHandle(channelId) {
const ch = (this._channels || []).find((c) => c.id === channelId);
if (!ch || !ch.streamUrls.length) return null;
return { url: ch.streamUrls[0], protocol: 'hls' };
}
async healthCheck(channelId) {
const ch = (this._channels || []).find((c) => c.id === channelId);
if (!ch || !ch.streamUrls.length) return { ok: false, error: 'no stream' };
try {
// probe through the relay (same SSRF-validated path); HEAD then bail
const r = await fetch(ch.streamUrls[0], { method: 'GET', credentials: 'omit' });
return { ok: r.ok, error: r.ok ? undefined : `status ${r.status}` };
} catch (e) { return { ok: false, error: String(e) }; }
}
/**
* EPG. Parses XMLTV with DOMParser (never innerHTML) into Program[].
* Phase-1 stub: returns cached programs if an XMLTV feed has been provided via
* setEpgXml(); otherwise empty (guide still renders channel rows + now/next is
* simply blank until an EPG source is wired in Phase 1.5).
*/
async getPrograms(channelId, window) {
const cached = this._epgCache.get(channelId);
if (!cached) return [];
return cached.filter((p) => p.stop > window.from && p.start < window.to);
}
/** Parse a raw XMLTV string and populate the EPG cache (safe: DOMParser). */
setEpgXml(xmlString) {
const doc = new DOMParser().parseFromString(xmlString, 'application/xml');
if (doc.querySelector('parsererror')) return;
// map xmltv channel id -> our namespaced id
const idMap = new Map((this._channels || []).map((c) => [c.guideId, c.id]));
for (const prog of doc.getElementsByTagName('programme')) {
const xmlCh = prog.getAttribute('channel');
const cid = idMap.get(xmlCh);
if (!cid) continue;
const start = parseXmltvTime(prog.getAttribute('start'));
const stop = parseXmltvTime(prog.getAttribute('stop'));
if (!start || !stop) continue;
const titleEl = prog.getElementsByTagName('title')[0];
const descEl = prog.getElementsByTagName('desc')[0];
const arr = this._epgCache.get(cid) || this._epgCache.set(cid, []).get(cid);
arr.push({
channelId: cid,
title: clip(titleEl ? titleEl.textContent : 'Program'),
desc: clip(descEl ? descEl.textContent : ''),
start, stop,
});
}
}
}
/** XMLTV time: "20260615013000 +0000" → epoch ms. */
function parseXmltvTime(s) {
if (!s) return 0;
const m = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})?\s*([+-]\d{4})?/.exec(s.trim());
if (!m) return 0;
const [, Y, Mo, D, H, Mi, S, tz] = m;
let ms = Date.UTC(+Y, +Mo - 1, +D, +H, +Mi, +(S || 0));
if (tz) {
const sign = tz[0] === '-' ? -1 : 1;
ms -= sign * ((+tz.slice(1, 3)) * 60 + (+tz.slice(3, 5))) * 60000;
}
return ms;
}