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/.
124 lines
No EOL
5.4 KiB
JavaScript
124 lines
No EOL
5.4 KiB
JavaScript
import { EgressError } from "./errors.js";
|
|
import { EgressPolicy } from "./policy.js";
|
|
/** Parse attribute boundaries rather than finding URI= inside other quoted values. */
|
|
function attributeUris(attributes, offset) {
|
|
const uris = [];
|
|
if (!/^\s*[A-Z0-9-]+\s*=/.test(attributes))
|
|
return uris;
|
|
let cursor = 0;
|
|
const seen = new Set();
|
|
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) {
|
|
if (!/^#EXTM3U(?:\r?\n|$)/.test(playlist) || playlist.includes("\u0000"))
|
|
throw new EgressError("INVALID_PLAYLIST", "Missing HLS header");
|
|
const uris = [];
|
|
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, base, policy) {
|
|
const baseUrl = (await policy.validate(base)).url;
|
|
const endpoints = [];
|
|
// Sequential validation bounds DNS work even for large playlists; shared URI pins within this parse.
|
|
const cache = new Map();
|
|
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;
|
|
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, base, policy = new EgressPolicy()) {
|
|
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, base, policy = new EgressPolicy()) {
|
|
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;
|
|
}
|
|
//# sourceMappingURL=hls.js.map
|