Lumi/src/services/overlay-web-documents.js
2026-07-18 22:18:35 +02:00

169 lines
7.1 KiB
JavaScript

const dns = require("dns").promises;
const net = require("net");
const { obsBrowserSourceCss } = require("./obs-browser-defaults");
const MAX_DOCUMENT_BYTES = 5 * 1024 * 1024;
const MAX_REDIRECTS = 4;
function isPrivateAddress(address) {
const version = net.isIP(address);
if (version === 4) {
const [a, b, c] = address.split(".").map(Number);
return a === 0 || a === 10 || a === 127 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 0) ||
(a === 192 && b === 168) ||
(a === 192 && b === 88 && c === 99) ||
(a === 198 && (b === 18 || b === 19)) ||
(a === 198 && b === 51 && c === 100) ||
(a === 203 && b === 0 && c === 113) ||
a >= 224;
}
if (version === 6) {
const normalized = String(address).toLowerCase();
return normalized === "::" || normalized === "::1" || normalized.startsWith("fc") ||
normalized.startsWith("fd") || /^fe[89ab]/.test(normalized) || normalized.startsWith("ff") ||
normalized.startsWith("::ffff:") || normalized.startsWith("2001:db8:");
}
return true;
}
async function isPrivateTarget(value, { resolveHost } = {}) {
const parsed = new URL(value);
if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password) return true;
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
if (hostname === "localhost" || hostname.endsWith(".localhost") || hostname.endsWith(".local")) return true;
if (net.isIP(hostname)) return isPrivateAddress(hostname);
const addresses = resolveHost
? await resolveHost(hostname)
: (await dns.lookup(hostname, { all: true, verbatim: true })).map((entry) => entry.address);
return !addresses.length || addresses.some(isPrivateAddress);
}
async function fetchPublicOverlay(url, signal, redirects = 0, options = {}) {
if (redirects > MAX_REDIRECTS) throw new Error("Too many redirects.");
if (await isPrivateTarget(url, options)) return null;
const fetchImpl = options.fetchImpl || fetch;
const response = await fetchImpl(url, {
method: "GET",
redirect: "manual",
signal,
headers: {
Accept: "text/html,application/xhtml+xml;q=0.9,*/*;q=0.2",
"Accept-Language": String(options.acceptLanguage || "en").slice(0, 200),
"User-Agent": String(options.userAgent || "Lumi-Overlay/1.0").slice(0, 500)
}
});
if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
await response.body?.cancel?.().catch(() => {});
const nextUrl = new URL(response.headers.get("location"), url).toString();
return fetchPublicOverlay(nextUrl, signal, redirects + 1, options);
}
return response;
}
async function boundedText(response, maxBytes = MAX_DOCUMENT_BYTES) {
const declared = Number(response.headers.get("content-length"));
if (Number.isFinite(declared) && declared > maxBytes) throw new Error("The website document is too large.");
if (!response.body?.getReader) {
const text = await response.text();
if (Buffer.byteLength(text, "utf8") > maxBytes) throw new Error("The website document is too large.");
return text;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let bytes = 0;
let output = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > maxBytes) {
await reader.cancel().catch(() => {});
throw new Error("The website document is too large.");
}
output += decoder.decode(value, { stream: true });
}
return output + decoder.decode();
}
function htmlAttribute(value) {
return String(value || "")
.replaceAll("&", "&amp;")
.replaceAll('"', "&quot;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function safeStyleText(value) {
return obsBrowserSourceCss(value).replace(/<\/style/gi, "<\\/style");
}
function loadEndCssScript(value) {
const encoded = JSON.stringify(obsBrowserSourceCss(value)).replaceAll("<", "\\u003c");
return `<script id="lumi-obs-browser-css-loader">(()=>{const css=${encoded};const apply=()=>{document.getElementById("lumi-obs-browser-css")?.remove();const style=document.createElement("style");style.id="lumi-obs-browser-css";style.appendChild(document.createTextNode(css));(document.head||document.documentElement).appendChild(style)};document.readyState==="complete"?apply():window.addEventListener("load",apply,{once:true})})()</script>`;
}
function injectDocumentCss(html, upstreamUrl, customCss) {
let document = String(html || "");
const existingBase = document.match(/<base\b[^>]*href\s*=\s*["']([^"']+)["'][^>]*>/i)?.[1];
const documentBase = existingBase ? new URL(existingBase, upstreamUrl).toString() : upstreamUrl;
document = document
.replace(/<base\b[^>]*>/gi, "")
.replace(/<meta\b[^>]*http-equiv\s*=\s*["']?content-security-policy["']?[^>]*>/gi, "");
const headStart = `<base href="${htmlAttribute(documentBase)}"><meta name="referrer" content="no-referrer">`;
const style = `<style id="lumi-obs-browser-css">${safeStyleText(customCss)}</style>`;
const loadEndScript = loadEndCssScript(customCss);
if (/<head\b[^>]*>/i.test(document)) {
document = document.replace(/<head\b[^>]*>/i, (match) => `${match}${headStart}`);
document = /<\/head\s*>/i.test(document)
? document.replace(/<\/head\s*>/i, `${style}</head>`)
: `${document}${style}`;
} else {
document = `<head>${headStart}${style}</head>${document}`;
}
return /<\/body\s*>/i.test(document)
? document.replace(/<\/body\s*>/i, `${loadEndScript}</body>`)
: `${document}${loadEndScript}`;
}
async function loadInjectedOverlayDocument(url, customCss, options = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), Number(options.timeoutMs) || 12000);
try {
const response = await fetchPublicOverlay(url, controller.signal, 0, options);
if (!response) {
const error = new Error("Private and local website addresses cannot use page CSS injection. Use direct compatibility mode for this source.");
error.code = "PRIVATE_TARGET";
throw error;
}
if (!response.ok) {
await response.body?.cancel?.().catch(() => {});
throw new Error(`The website returned HTTP ${response.status}.`);
}
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
if (contentType && !/(?:text\/html|application\/xhtml\+xml)/i.test(contentType)) {
await response.body?.cancel?.().catch(() => {});
throw new Error("The website did not return an HTML document.");
}
const html = await boundedText(response, Number(options.maxBytes) || MAX_DOCUMENT_BYTES);
if (!html.trim()) throw new Error("The website returned an empty document.");
return {
html: injectDocumentCss(html, response.url || url, customCss),
upstreamUrl: response.url || url
};
} finally {
clearTimeout(timeout);
}
}
module.exports = {
fetchPublicOverlay,
injectDocumentCss,
isPrivateAddress,
isPrivateTarget,
loadInjectedOverlayDocument
};