Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
104
packages/egress/src/hls.ts
Normal file
104
packages/egress/src/hls.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { EgressError } from "./errors.js";
|
||||
import { EgressPolicy, type ValidatedEndpoint } from "./policy.js";
|
||||
|
||||
interface UriOccurrence { start: number; end: number; value: string }
|
||||
|
||||
/** Parse attribute boundaries rather than finding URI= inside other quoted values. */
|
||||
function attributeUris(attributes: string, offset: number): UriOccurrence[] {
|
||||
const uris: UriOccurrence[] = [];
|
||||
if (!/^\s*[A-Z0-9-]+\s*=/.test(attributes)) return uris;
|
||||
let cursor = 0;
|
||||
const seen = new Set<string>();
|
||||
while (cursor < attributes.length) {
|
||||
const key = /^\s*([A-Z0-9-]+)\s*=\s*/.exec(attributes.slice(cursor));
|
||||
if (!key) throw new EgressError("INVALID_PLAYLIST", "Malformed HLS attributes");
|
||||
cursor += key[0].length;
|
||||
const quoted = attributes[cursor] === '"';
|
||||
const start = cursor + (quoted ? 1 : 0);
|
||||
const end = quoted ? attributes.indexOf('"', start) : attributes.indexOf(",", start);
|
||||
const valueEnd = end < 0 && !quoted ? attributes.length : end;
|
||||
if (valueEnd < 0) throw new EgressError("INVALID_PLAYLIST", "Unterminated HLS attribute");
|
||||
if (key[1] === "URI" || key[1] === "SERVER-URI") {
|
||||
if (!quoted || start === valueEnd || seen.has(key[1])) throw new EgressError("INVALID_PLAYLIST", "Invalid HLS URI attribute");
|
||||
seen.add(key[1]);
|
||||
uris.push({ start: offset + start, end: offset + valueEnd, value: attributes.slice(start, valueEnd) });
|
||||
}
|
||||
cursor = valueEnd + (quoted ? 1 : 0);
|
||||
while (/\s/.test(attributes[cursor] ?? "") && cursor < attributes.length) cursor++;
|
||||
if (cursor === attributes.length) break;
|
||||
if (attributes[cursor] !== "," || cursor + 1 === attributes.length) throw new EgressError("INVALID_PLAYLIST", "Invalid HLS attribute suffix");
|
||||
cursor++;
|
||||
}
|
||||
return uris;
|
||||
}
|
||||
|
||||
function playlistUris(playlist: string): { uris: UriOccurrence[]; master: boolean } {
|
||||
if (!/^#EXTM3U(?:\r?\n|$)/.test(playlist) || playlist.includes("\u0000")) throw new EgressError("INVALID_PLAYLIST", "Missing HLS header");
|
||||
const uris: UriOccurrence[] = [];
|
||||
let offset = 0;
|
||||
let awaitingVariant = false;
|
||||
let variants = 0;
|
||||
let media = false;
|
||||
for (const line of playlist.split(/(?<=\n)/)) {
|
||||
const text = line.replace(/\r?\n$/, "").trim();
|
||||
if (text && !text.startsWith("#")) {
|
||||
const start = offset + line.indexOf(text);
|
||||
uris.push({ start, end: start + text.length, value: text });
|
||||
if (awaitingVariant) { variants++; awaitingVariant = false; }
|
||||
} else if (text.startsWith("#EXT")) {
|
||||
if (text.startsWith("#EXTINF:")) media = true;
|
||||
if (text.startsWith("#EXT-X-STREAM-INF:")) {
|
||||
if (awaitingVariant) throw new EgressError("INVALID_PLAYLIST", "Variant URI is missing");
|
||||
awaitingVariant = true;
|
||||
}
|
||||
const colon = line.indexOf(":");
|
||||
if (colon >= 0) {
|
||||
const attributes = line.slice(colon + 1).replace(/\r?\n$/, "");
|
||||
const attributesFound = attributeUris(attributes, offset + colon + 1);
|
||||
uris.push(...attributesFound);
|
||||
if (text.startsWith("#EXT-X-I-FRAME-STREAM-INF:")) {
|
||||
if (!attributesFound.length) throw new EgressError("INVALID_PLAYLIST", "I-frame URI is missing");
|
||||
variants++;
|
||||
}
|
||||
}
|
||||
}
|
||||
offset += line.length;
|
||||
}
|
||||
if (awaitingVariant) throw new EgressError("INVALID_PLAYLIST", "Variant URI is missing");
|
||||
return { uris, master: variants > 0 && !media };
|
||||
}
|
||||
|
||||
async function resolveUris(uris: UriOccurrence[], base: string | URL, policy: EgressPolicy): Promise<ValidatedEndpoint[]> {
|
||||
const baseUrl = (await policy.validate(base)).url;
|
||||
const endpoints: ValidatedEndpoint[] = [];
|
||||
// Sequential validation bounds DNS work even for large playlists; shared URI pins within this parse.
|
||||
const cache = new Map<string, ValidatedEndpoint>();
|
||||
for (const occurrence of uris) {
|
||||
if (!occurrence.value || occurrence.value.includes("{$") || /[\s\\\u0000-\u001f\u007f]/u.test(occurrence.value)) throw new EgressError("INVALID_PLAYLIST", "Invalid or unexpanded HLS URI");
|
||||
let absolute: URL;
|
||||
try { absolute = new URL(occurrence.value, baseUrl); } catch { throw new EgressError("INVALID_PLAYLIST", "Invalid HLS URI"); }
|
||||
let endpoint = cache.get(absolute.href);
|
||||
if (!endpoint) { endpoint = await policy.validate(absolute); cache.set(absolute.href, endpoint); }
|
||||
endpoints.push(endpoint);
|
||||
}
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/** Validates master variants and URI-bearing tags (renditions, keys, maps, etc.) with DNS pinning. */
|
||||
export async function validateMasterPlaylist(playlist: string, base: string | URL, policy = new EgressPolicy()): Promise<ValidatedEndpoint[]> {
|
||||
const { uris, master } = playlistUris(playlist);
|
||||
if (!master) throw new EgressError("INVALID_PLAYLIST", "Expected an HLS master playlist");
|
||||
return resolveUris(uris, base, policy);
|
||||
}
|
||||
|
||||
/** Produces absolute, validated child URLs, preserving comments, attributes and newline style. */
|
||||
export async function rewriteSegmentUris(playlist: string, base: string | URL, policy = new EgressPolicy()): Promise<string> {
|
||||
const { uris } = playlistUris(playlist);
|
||||
const endpoints = await resolveUris(uris, base, policy);
|
||||
let rewritten = playlist;
|
||||
for (let index = uris.length - 1; index >= 0; index--) {
|
||||
const uri = uris[index]!;
|
||||
rewritten = rewritten.slice(0, uri.start) + endpoints[index]!.url.href + rewritten.slice(uri.end);
|
||||
}
|
||||
return rewritten;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue