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("&", "&") .replaceAll('"', """) .replaceAll("<", "<") .replaceAll(">", ">"); } function safeStyleText(value) { return obsBrowserSourceCss(value).replace(/<\/style/gi, "<\\/style"); } function loadEndCssScript(value) { const encoded = JSON.stringify(obsBrowserSourceCss(value)).replaceAll("<", "\\u003c"); return ``; } function injectDocumentCss(html, upstreamUrl, customCss) { let document = String(html || ""); const existingBase = document.match(/]*href\s*=\s*["']([^"']+)["'][^>]*>/i)?.[1]; const documentBase = existingBase ? new URL(existingBase, upstreamUrl).toString() : upstreamUrl; document = document .replace(/]*>/gi, "") .replace(/]*http-equiv\s*=\s*["']?content-security-policy["']?[^>]*>/gi, ""); const headStart = ``; const style = ``; const loadEndScript = loadEndCssScript(customCss); if (/]*>/i.test(document)) { document = document.replace(/]*>/i, (match) => `${match}${headStart}`); document = /<\/head\s*>/i.test(document) ? document.replace(/<\/head\s*>/i, `${style}`) : `${document}${style}`; } else { document = `${headStart}${style}${document}`; } return /<\/body\s*>/i.test(document) ? document.replace(/<\/body\s*>/i, `${loadEndScript}`) : `${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 };