169 lines
7.1 KiB
JavaScript
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("&", "&")
|
|
.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 `<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
|
|
};
|