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>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
// mock.js — MockProvider. Built FIRST, on purpose.
|
||||
//
|
||||
// Proves the UI is fully source-agnostic with ZERO network: fake channels, a
|
||||
// rolling "scheduled-live" EPG timeline (so now/next + the guide populate), and
|
||||
// a couple of real CORS-clean public TEST streams so playback can be exercised
|
||||
// standalone (no relay needed). One channel points at a dead URL to exercise the
|
||||
// "the orb clouds over" no-signal path.
|
||||
|
||||
import { SourceProvider } from './source-provider.js';
|
||||
|
||||
/** Tiny inline-SVG logo so channels render with no network. */
|
||||
function logo(initials, accent = '#ff2b2b') {
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="96" height="54">` +
|
||||
`<rect width="96" height="54" fill="#0a0000"/>` +
|
||||
`<rect x="1" y="1" width="94" height="52" fill="none" stroke="${accent}" stroke-width="1.5"/>` +
|
||||
`<text x="48" y="34" font-family="monospace" font-size="22" font-weight="bold" ` +
|
||||
`text-anchor="middle" fill="${accent}">${initials}</text></svg>`;
|
||||
return 'data:image/svg+xml,' + encodeURIComponent(svg);
|
||||
}
|
||||
|
||||
// CORS-clean public test streams (HLS). Stable, broadcaster-hosted demos.
|
||||
const TEST_STREAMS = {
|
||||
bipbop: 'https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/bipbop_4x3_variant.m3u8',
|
||||
mux: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8',
|
||||
bbb: 'https://test-streams.mux.dev/test_001/stream.m3u8',
|
||||
dead: 'https://stream.invalid.example/offline.m3u8',
|
||||
};
|
||||
|
||||
const TITLE_POOL = {
|
||||
'Arcane Cinema': ['The Reanimator', 'Hex of the Hollow', 'Salt & Sigil', 'Midnight Grimoire',
|
||||
'The Pondering Hour', 'Witchlight', 'The Veil Lifts', 'Candle & Bone'],
|
||||
'Hack the Planet': ['Packets at Dawn', 'Root of All Evil', 'The Blue Box', 'Zero Cool',
|
||||
'Buffer Overflow', 'The Last Daemon', 'Kernel Panic', 'Shell Games'],
|
||||
'Old Signals': ['Test Pattern Theatre', 'Static & Snow', 'The Late Late Broadcast',
|
||||
'Color Bars', 'Sign-Off Sermon', 'Dead Air'],
|
||||
'The Almanac': ['What Fate Foretells', 'Stars & Portents', 'The Long Now', 'Tomorrow, Maybe'],
|
||||
};
|
||||
|
||||
function buildSchedule(channelId, group, now) {
|
||||
/** @type {import('./source-provider.js').Program[]} */
|
||||
const programs = [];
|
||||
const titles = TITLE_POOL[group] || TITLE_POOL['Old Signals'];
|
||||
// Deterministic-ish per channel so reloads look stable.
|
||||
let seed = [...channelId].reduce((a, c) => a + c.charCodeAt(0), 0);
|
||||
const rand = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
|
||||
// Start two hours back, fill ~10h.
|
||||
let t = now - 2 * 3600 * 1000;
|
||||
t = t - (t % (30 * 60 * 1000)); // align to :00/:30
|
||||
const end = now + 8 * 3600 * 1000;
|
||||
let i = Math.floor(rand() * titles.length);
|
||||
while (t < end) {
|
||||
const slots = 1 + Math.floor(rand() * 3); // 30/60/90 min
|
||||
const dur = slots * 30 * 60 * 1000;
|
||||
programs.push({
|
||||
channelId,
|
||||
title: titles[i % titles.length],
|
||||
desc: 'A scheduled broadcast. Tuning in drops you mid-program, live — just like real TV.',
|
||||
start: t,
|
||||
stop: t + dur,
|
||||
});
|
||||
t += dur;
|
||||
i++;
|
||||
}
|
||||
return programs;
|
||||
}
|
||||
|
||||
const CHANNELS = [
|
||||
{ n: 2, name: 'Arcane Cinema', group: 'Arcane Cinema', stream: 'mux', init: 'AC' },
|
||||
{ n: 3, name: 'Hack the Planet',group: 'Hack the Planet', stream: 'bbb', init: 'HP' },
|
||||
{ n: 4, name: 'Bip-Bop Test', group: 'Old Signals', stream: 'bipbop', init: 'BB' },
|
||||
{ n: 5, name: 'The Late Show', group: 'Old Signals', stream: 'mux', init: 'LS' },
|
||||
{ n: 6, name: 'Dead Air', group: 'Old Signals', stream: 'dead', init: 'XX' },
|
||||
{ n: 7, name: 'The Almanac', group: 'The Almanac', stream: 'bbb', init: 'AL' },
|
||||
];
|
||||
|
||||
export class MockProvider extends SourceProvider {
|
||||
get id() { return 'mock'; }
|
||||
get name() { return 'The Mock Realm'; }
|
||||
get supportsGuides() { return true; }
|
||||
|
||||
async listChannels() {
|
||||
return CHANNELS.map((c) => ({
|
||||
id: `mock:${c.n}`,
|
||||
source: 'mock',
|
||||
name: c.name,
|
||||
number: c.n,
|
||||
logo: logo(c.init),
|
||||
group: c.group,
|
||||
country: 'XX',
|
||||
language: 'eng',
|
||||
kind: 'live',
|
||||
streamUrls: [TEST_STREAMS[c.stream]],
|
||||
guideId: `mock:${c.n}`,
|
||||
}));
|
||||
}
|
||||
|
||||
async getStreamHandle(channelId) {
|
||||
const c = CHANNELS.find((x) => `mock:${x.n}` === channelId);
|
||||
if (!c) return null;
|
||||
// Mock returns direct URLs (test streams are CORS-clean) so the UI runs standalone.
|
||||
return { url: TEST_STREAMS[c.stream], protocol: 'hls' };
|
||||
}
|
||||
|
||||
async getPrograms(channelId, window) {
|
||||
const c = CHANNELS.find((x) => `mock:${x.n}` === channelId);
|
||||
if (!c) return [];
|
||||
const all = buildSchedule(channelId, c.group, Date.now());
|
||||
return all.filter((p) => p.stop > window.from && p.start < window.to);
|
||||
}
|
||||
|
||||
async healthCheck(channelId) {
|
||||
const c = CHANNELS.find((x) => `mock:${x.n}` === channelId);
|
||||
if (c && c.stream === 'dead') return { ok: false, error: 'mock offline channel' };
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user