diff --git a/CHANGELOG.md b/CHANGELOG.md index 0583c7c..86f31d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Lumi changelog +## 0.2.13 + +- Changed external overlay Website CSS from frame-only styling to document-level injection, matching OBS Browser Source Custom CSS behavior. +- Applied OBS's transparent body stylesheet inside alert websites by default while allowing administrators to add selectors for provider-specific elements. +- Added scoped revocable website-render tickets, opaque sandboxing, no-referrer handling, bounded public document retrieval, private-network blocking, and a direct compatibility fallback. + ## 0.2.12 - Fixed OBS Browser Source transparency by applying OBS's standard transparent-page CSS to clean overlay pages and embedded frame surfaces. diff --git a/TODO.md b/TODO.md index 1e34e6c..425fff5 100644 --- a/TODO.md +++ b/TODO.md @@ -22,7 +22,10 @@ syncs supported OBS scene, transition, canvas, output, permission, and source state without WebSocket credentials, with a one-action setup importer. The clean render page and editor now share a fixed full-resolution canvas, transparent OBS defaults, and explicit Browser Source dimensions so responsive third-party alerts -render consistently in the preview and OBS. +render consistently in the preview and OBS. Website CSS is injected into the +third-party document itself through scoped revocable render tickets, matching OBS +Custom CSS behavior while keeping the page isolated from Lumi sessions and local +network targets. Remaining work: @@ -680,6 +683,7 @@ This section is for Lumi chat answer feedback and AI Improvement Center work, no ## Done +- 2026-07-18: Replaced frame-only website styling with OBS-style document CSS injection. External alert pages now receive OBS's transparent body CSS by default plus administrator selectors inside the website document; scoped revocable source tickets, opaque sandboxing, no-referrer handling, bounded public fetching, and private-network blocking protect Lumi and its overlay secrets. - 2026-07-18: Fixed OBS overlay transparency and preview fidelity by applying OBS's exact default Browser Source CSS, rendering the editor and clean output from the same full-resolution canvas coordinate system, keeping iframe surfaces transparent, and showing the required OBS width/height plus copyable CSS in setup. - 2026-07-18: Reworked shared logging repo-wide with structured component/category/event/request metadata, recursive secret redaction, bounded details, automatic retention, scoped plugin loggers, platform/command/webhook/lifecycle/admin HTTP auditing, live `/admin/logs` updates, complete server-side filters and summaries, and text/JSONL exports. diff --git a/docs/obs-overlays.md b/docs/obs-overlays.md index 3acb7fe..df9a4e2 100644 --- a/docs/obs-overlays.md +++ b/docs/obs-overlays.md @@ -97,14 +97,18 @@ content. Retry count and refresh interval are configurable. Local/private websit addresses are periodically refreshed but are not fetched by the Lumi server for security; browser load-timeout recovery still applies. -Website sources also support per-edge percentage cropping, zoom, and isolated -frame appearance CSS. Custom CSS is scoped to the source frame so it cannot restyle -the Lumi render page or other sources. Standard browser cross-origin security -does not allow parent-page CSS to select elements inside a third-party iframe; -use the provider's own appearance settings, crop, and zoom to hide unwanted -portions of an external site. Frame-level CSS can target -`.lumi-overlay-frame`, for example -`.lumi-overlay-frame { filter: saturate(1.2); }`. +Website sources support per-edge percentage cropping, zoom, and document-level +Website CSS. Lumi retrieves the public website document through a scoped, +revocable source URL and appends the stylesheet to its ``, matching how OBS +injects Custom CSS after the main document loads. The proxied document runs in a +sandboxed opaque origin, receives no Lumi login/session data, does not expose the +overlay token in its location, and cannot use Lumi as a private-network proxy. + +CSS injection is enabled by default and can target the website's own elements, +for example `body`, `.alert`, or provider-specific containers. Direct +compatibility mode is available for private/local pages and unusual sites that +cannot run through document injection; direct mode intentionally cannot change +cross-origin page content. Lumi applies OBS's standard transparent Browser Source CSS to every clean render page and shows the same copyable value in the Browser Source setup section: @@ -113,9 +117,9 @@ page and shows the same copyable value in the Browser Source setup section: body { background-color: rgba(0, 0, 0, 0); margin: 0px auto; overflow: hidden; } ``` -The Lumi document, canvas stage, and embedded frame surface are transparent by -default. A third-party page can still explicitly paint its own background; a -normal parent iframe cannot override that cross-origin page CSS. +The Lumi document, canvas stage, embedded frame surface, and injected website +body are transparent by default. Administrators can extend or override the +default stylesheet per source just as they do in OBS Browser Source properties. ## OBS WebSocket diff --git a/knowledge/core/lumi-core.md b/knowledge/core/lumi-core.md index 8383a8c..6b5b156 100644 --- a/knowledge/core/lumi-core.md +++ b/knowledge/core/lumi-core.md @@ -14,7 +14,7 @@ editable: false Lumi is the core web UI and bot runtime. ## Runtime Package: lumi-bot -Version: 0.2.12 +Version: 0.2.13 ## Routes - GET /api/events - POST /api/destructive-confirmations diff --git a/package-lock.json b/package-lock.json index bc450b0..098a4d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lumi-bot", - "version": "0.2.12", + "version": "0.2.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lumi-bot", - "version": "0.2.12", + "version": "0.2.13", "dependencies": { "adm-zip": "^0.5.12", "better-sqlite3": "^11.5.0", diff --git a/package.json b/package.json index 0ec4226..83abddb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lumi-bot", - "version": "0.2.12", + "version": "0.2.13", "private": true, "type": "commonjs", "scripts": { diff --git a/release-index.json b/release-index.json index 9b1b185..2d55975 100644 --- a/release-index.json +++ b/release-index.json @@ -2,6 +2,36 @@ "schema_version": 1, "channel": "stable", "releases": [ + { + "version": "0.2.13", + "ref": "refs/tags/v0.2.13", + "released_at": "2026-07-18", + "installable": true, + "rollback_safe": true, + "replaces_versions": [ + "1.2.0" + ], + "data_policy": "preserve", + "dependency_policy": "sync_on_restart", + "migration_notes": "Adds OBS-style document CSS injection for website overlay sources with scoped render tickets and a compatibility fallback. Existing overlays, website URLs, CSS, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets are preserved.", + "plugins": { + "auto-vc": "0.1.6", + "birthday": "0.1.3", + "economy-framework": "0.2.10", + "economy-games": "0.1.7", + "expression-interaction": "0.2.1", + "lumi_ai": "0.8.5", + "moderation": "0.1.5", + "okf": "0.1.1", + "quotes": "0.1.2", + "sample-plugin": "0.1.0", + "throne_wishlist": "0.1.2", + "welcome_messages": "0.1.1" + }, + "tools": { + "lumi_ai_web_search": "0.1.1" + } + }, { "version": "0.2.12", "ref": "refs/tags/v0.2.12", diff --git a/scripts/verify-all.js b/scripts/verify-all.js index 3492c97..1c0bbaa 100644 --- a/scripts/verify-all.js +++ b/scripts/verify-all.js @@ -24,6 +24,7 @@ const checks = [ "scripts/verify-command-preview-confirmations.js", "scripts/verify-destructive-actions.js", "scripts/verify-overlays.js", + "scripts/verify-overlay-web-documents.js", "scripts/verify-webhooks.js" ]; diff --git a/scripts/verify-overlay-web-documents.js b/scripts/verify-overlay-web-documents.js new file mode 100644 index 0000000..d611550 --- /dev/null +++ b/scripts/verify-overlay-web-documents.js @@ -0,0 +1,63 @@ +const assert = require("assert"); +const { + injectDocumentCss, + loadInjectedOverlayDocument +} = require("../src/services/overlay-web-documents"); +const { OBS_BROWSER_DEFAULT_CSS } = require("../src/services/obs-browser-defaults"); + +async function run() { + const injected = injectDocumentCss( + "Alerts
Hello
", + "https://alerts.example/widgets/main/", + ".alert { font-size: 64px; }" + ); + assert(injected.includes('')); + assert(injected.includes('')); @@ -237,6 +263,7 @@ try { assert.strictEqual(obsLayout.scaleY, 1); assert(connectorScript.includes('id: "obs_browser_bridge"') && connectorScript.includes("acceptReport(report")); assert(routeScript.includes("/obs-bridge") && routeScript.includes("/obs/import")); + assert(routeScript.includes('/overlay-web/:ticket') && routeScript.includes("loadInjectedOverlayDocument")); console.log("Overlay verification passed: CRUD, fixed-canvas preview parity, transparent OBS defaults, layout controls, external recovery, token revocation, encrypted OBS credentials, and permissions."); } finally { diff --git a/scripts/verify-release-metadata.js b/scripts/verify-release-metadata.js index 8155c0c..587f2f2 100644 --- a/scripts/verify-release-metadata.js +++ b/scripts/verify-release-metadata.js @@ -4,8 +4,8 @@ const path = require("path"); const { findSafeTarget } = require("../src/services/versioning"); const root = path.join(__dirname, ".."); -const releaseVersion = "0.2.12"; -const previousCoreVersion = "0.2.11"; +const releaseVersion = "0.2.13"; +const previousCoreVersion = "0.2.12"; const earliestCompatibleCoreVersion = "0.1.9"; const changedPlugins = { "auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" }, @@ -87,4 +87,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0"); assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2"); assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true); -console.log("Release metadata verification passed: core 0.2.12, Lumi AI 0.8.5, and synchronized package metadata."); +console.log("Release metadata verification passed: core 0.2.13, Lumi AI 0.8.5, and synchronized package metadata."); diff --git a/scripts/verify-update-system.js b/scripts/verify-update-system.js index 80130c3..15c374f 100644 --- a/scripts/verify-update-system.js +++ b/scripts/verify-update-system.js @@ -16,7 +16,7 @@ function readJson(relativePath) { const releaseIndex = readJson("release-index.json"); const releaseVersions = releaseIndex.releases.map((release) => release.version); -assert.deepEqual(releaseVersions, ["0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]); +assert.deepEqual(releaseVersions, ["0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]); assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique"); for (const release of releaseIndex.releases) { assert.equal(normalizeRepositoryRef(release.ref), release.ref); @@ -37,6 +37,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) { const baseTarget = { current_version: "0.2.4", available_versions: [ + { version: "0.2.13", ref: "refs/tags/v0.2.13", rollback_safe: true }, { version: "0.2.12", ref: "refs/tags/v0.2.12", rollback_safe: true }, { version: "0.2.11", ref: "refs/tags/v0.2.11", rollback_safe: true }, { version: "0.2.10", ref: "refs/tags/v0.2.10", rollback_safe: true }, @@ -72,7 +73,7 @@ const corrected = buildStatus({ channel: "stable" }); assert.equal(corrected.version_correction, true); -assert.equal(corrected.safe_target_version, "0.2.12"); +assert.equal(corrected.safe_target_version, "0.2.13"); assert.equal(corrected.update_available, true); assert.equal(corrected.blocked, false); diff --git a/src/services/obs-browser-defaults.js b/src/services/obs-browser-defaults.js index e6289ea..6dd5944 100644 --- a/src/services/obs-browser-defaults.js +++ b/src/services/obs-browser-defaults.js @@ -1,5 +1,13 @@ const OBS_BROWSER_DEFAULT_CSS = "body { background-color: rgba(0, 0, 0, 0); margin: 0px auto; overflow: hidden; }"; +function obsBrowserSourceCss(value) { + const custom = String(value || "").trim(); + if (!custom) return OBS_BROWSER_DEFAULT_CSS; + if (custom.includes(OBS_BROWSER_DEFAULT_CSS)) return custom.slice(0, 12000); + return `${OBS_BROWSER_DEFAULT_CSS}\n\n${custom}`.slice(0, 12000); +} + module.exports = { - OBS_BROWSER_DEFAULT_CSS + OBS_BROWSER_DEFAULT_CSS, + obsBrowserSourceCss }; diff --git a/src/services/overlay-modules.js b/src/services/overlay-modules.js index d3c0b15..45e18fe 100644 --- a/src/services/overlay-modules.js +++ b/src/services/overlay-modules.js @@ -1,4 +1,5 @@ const moduleTypes = new Map(); +const { obsBrowserSourceCss } = require("./obs-browser-defaults"); function number(value, fallback, min, max) { const parsed = Number(value); @@ -162,7 +163,8 @@ registerOverlayModuleType({ cropBottom: number(input.cropBottom, 0, 0, 95), cropLeft: number(input.cropLeft, 0, 0, 95), zoom: number(input.zoom, 1, 0.1, 5), - customCss: text(input.customCss, "", 12000) + injectPageCss: input.injectPageCss !== false && input.injectPageCss !== "false" && input.injectPageCss !== "off", + customCss: obsBrowserSourceCss(text(input.customCss, "", 12000)) }; } }); diff --git a/src/services/overlay-routes.js b/src/services/overlay-routes.js index eb7be15..41d1b05 100644 --- a/src/services/overlay-routes.js +++ b/src/services/overlay-routes.js @@ -1,6 +1,5 @@ -const dns = require("dns").promises; -const net = require("net"); const { subscribeWebEvents } = require("./web-events"); +const { createLogger } = require("./logger"); const { OVERLAY_CAPABILITIES, canAccessOverlay @@ -27,64 +26,24 @@ const { reorderOverlays, reorderScenes, resolvePublicOverlay, + resolveWebSourceTicket, saveObsSettings, setActiveScene, updateModule, updateModuleTransform, updateOverlay, - updateScene + updateScene, + webSourceRenderUrl } = require("./overlays"); const { OBS_BROWSER_DEFAULT_CSS } = require("./obs-browser-defaults"); +const { fetchPublicOverlay, loadInjectedOverlayDocument } = require("./overlay-web-documents"); const { listOverlayConnectorProviders, overlayConnectorManager } = require("./overlay-connectors"); const externalHealthCache = new Map(); - -function isPrivateAddress(address) { - if (!address) return true; - if (net.isIPv4(address)) { - const parts = address.split(".").map(Number); - return parts[0] === 10 || parts[0] === 127 || parts[0] === 0 || - (parts[0] === 169 && parts[1] === 254) || - (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || - (parts[0] === 192 && parts[1] === 168); - } - const normalized = String(address).toLowerCase(); - return normalized === "::1" || normalized === "::" || normalized.startsWith("fc") || - normalized.startsWith("fd") || normalized.startsWith("fe8") || normalized.startsWith("fe9") || - normalized.startsWith("fea") || normalized.startsWith("feb") || normalized.startsWith("::ffff:127."); -} - -async function isPrivateHealthTarget(url) { - const parsed = new URL(url); - const hostname = parsed.hostname.toLowerCase(); - if (hostname === "localhost" || hostname.endsWith(".localhost")) return true; - if (net.isIP(hostname)) return isPrivateAddress(hostname); - const addresses = await dns.lookup(hostname, { all: true, verbatim: true }); - return !addresses.length || addresses.some((entry) => isPrivateAddress(entry.address)); -} - -async function fetchPublicOverlay(url, signal, redirects = 0) { - if (redirects > 4) throw new Error("Too many redirects."); - if (await isPrivateHealthTarget(url)) return null; - const response = await fetch(url, { - method: "GET", - redirect: "manual", - signal, - headers: { - Accept: "text/html,application/xhtml+xml,text/plain;q=0.8,*/*;q=0.2", - "User-Agent": "Lumi-Overlay-Health/1.0" - } - }); - 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); - } - return response; -} +const overlayWebLog = createLogger("core:overlay-web", { category: "integration" }); async function probeExternalOverlay(url) { const controller = new AbortController(); @@ -149,11 +108,59 @@ function noStore(res) { res.set("Pragma", "no-cache"); } +function browserSourceResponseHeaders(res) { + noStore(res); + res.set("Referrer-Policy", "no-referrer"); + res.set("Cross-Origin-Resource-Policy", "cross-origin"); + res.set("Content-Security-Policy", [ + "sandbox allow-scripts allow-forms allow-popups allow-presentation", + "default-src * data: blob: 'unsafe-inline' 'unsafe-eval'", + "connect-src * data: blob: ws: wss:", + "font-src * data: blob:", + "frame-src * data: blob:", + "img-src * data: blob:", + "media-src * data: blob:", + "script-src * data: blob: 'unsafe-inline' 'unsafe-eval'", + "style-src * data: blob: 'unsafe-inline'", + "worker-src * data: blob:" + ].join("; ")); +} + +function browserSourceErrorHtml(message) { + const safe = String(message || "The website could not be loaded.") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); + return `
${safe}
`; +} + function publicOverlayContext(req) { return resolvePublicOverlay(req.params.overlayToken, req.params.sceneToken || null); } function registerPublicOverlayRoutes(app) { + app.get("/overlay-web/:ticket", async (req, res) => { + browserSourceResponseHeaders(res); + const source = resolveWebSourceTicket(req.params.ticket); + if (!source?.config?.url || source.config.injectPageCss === false) { + return res.status(404).send(browserSourceErrorHtml("Website source unavailable.")); + } + try { + const document = await loadInjectedOverlayDocument(source.config.url, source.config.customCss, { + userAgent: req.get("user-agent"), + acceptLanguage: req.get("accept-language") + }); + return res.type("html").send(document.html); + } catch (error) { + overlayWebLog.warn("Website CSS injection failed.", { + module_id: source.moduleId, + error_code: error?.code || null, + error: error?.message || "Unknown website loading error." + }, { event: "overlay.web_css_injection_failed" }); + return res.status(error?.code === "PRIVATE_TARGET" ? 422 : 502).type("html").send(browserSourceErrorHtml(error.message)); + } + }); + const registerVariant = (basePath, fixed) => { app.post(`${basePath}/obs-bridge`, (req, res) => { noStore(res); @@ -324,6 +331,7 @@ function moduleInput(body) { startAt: body.start_at, showControls: booleanField(body, "show_controls"), allowPointerEvents: booleanField(body, "allow_pointer_events"), + injectPageCss: booleanField(body, "inject_page_css"), anchor: body.anchor, horizontalAlign: body.horizontal_align, verticalAlign: body.vertical_align, @@ -394,7 +402,12 @@ function registerOverlayAdminRoutes(app) { type: module.type, renderType: getOverlayModuleType(module.type)?.renderType || module.type, enabled: module.enabled, - config: module.config + config: { + ...module.config, + ...((getOverlayModuleType(module.type)?.renderType || module.type) === "web" && module.config?.injectPageCss !== false + ? { renderUrl: webSourceRenderUrl(module.id) } + : {}) + } })) })) }).replace(/= 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 +}; diff --git a/src/services/overlays.js b/src/services/overlays.js index af1e650..c00dbc7 100644 --- a/src/services/overlays.js +++ b/src/services/overlays.js @@ -7,7 +7,9 @@ const { createStoredToken, decryptSecret, encryptSecret, - tokenHash + scopedSignature, + tokenHash, + verifyScopedSignature } = require("./overlay-secrets"); const overlayChanges = new EventEmitter(); @@ -429,6 +431,43 @@ function resolvePublicOverlay(overlayToken, sceneToken = null) { return { overlay, fixedScene }; } +function webSourceTicketRecord(moduleId) { + return db.prepare( + `SELECT m.*, s.overlay_id, s.public_token_hash AS scene_token_hash, s.enabled AS scene_enabled, + o.public_token_hash AS overlay_token_hash, o.enabled AS overlay_enabled + FROM overlay_modules m + JOIN overlay_scenes s ON s.id = m.scene_id + JOIN overlays o ON o.id = s.overlay_id + WHERE m.id = ?` + ).get(moduleId); +} + +function webSourceTicketValue(row) { + return [row.id, row.scene_id, row.overlay_id, row.overlay_token_hash, row.scene_token_hash].join(":"); +} + +function webSourceRenderUrl(moduleId) { + const row = webSourceTicketRecord(moduleId); + if (!row || getOverlayModuleType(row.type)?.renderType !== "web") return null; + const signature = scopedSignature("overlay-web-source", webSourceTicketValue(row)); + return `/overlay-web/${row.id}.${row.scene_id}.${signature}`; +} + +function resolveWebSourceTicket(ticket) { + const [moduleId, sceneId, signature, ...extra] = String(ticket || "").split("."); + if (!moduleId || !sceneId || !signature || extra.length) return null; + const row = webSourceTicketRecord(moduleId); + if (!row || row.scene_id !== sceneId || !row.overlay_enabled || !row.scene_enabled || !row.enabled) return null; + if (getOverlayModuleType(row.type)?.renderType !== "web") return null; + if (!verifyScopedSignature("overlay-web-source", webSourceTicketValue(row), signature)) return null; + return { + overlayId: row.overlay_id, + sceneId: row.scene_id, + moduleId: row.id, + config: parseConfig(row.config_json) + }; +} + function buildPublicState(overlayId, fixedSceneId = null) { const overlay = db.prepare("SELECT id, enabled, active_scene_id, canvas_width, canvas_height, updated_at FROM overlays WHERE id = ?").get(overlayId); if (!overlay) return { exists: false, enabled: false, revision: Date.now(), scene: null }; @@ -441,13 +480,12 @@ function buildPublicState(overlayId, fixedSceneId = null) { if (!scene || !scene.enabled) return { exists: true, enabled: true, revision: overlay.updated_at, canvas, scene: null }; const modules = db.prepare("SELECT id, type, name, config_json FROM overlay_modules WHERE scene_id = ? AND enabled = 1 ORDER BY sort_order, created_at") .all(scene.id) - .map((module) => ({ - id: module.id, - type: module.type, - renderType: getOverlayModuleType(module.type)?.renderType || null, - name: module.name, - config: parseConfig(module.config_json) - })) + .map((module) => { + const renderType = getOverlayModuleType(module.type)?.renderType || null; + const config = parseConfig(module.config_json); + if (renderType === "web" && config.injectPageCss !== false) config.renderUrl = webSourceRenderUrl(module.id); + return { id: module.id, type: module.type, renderType, name: module.name, config }; + }) .filter((module) => module.renderType); return { exists: true, @@ -533,10 +571,12 @@ module.exports = { reorderOverlays, reorderScenes, resolvePublicOverlay, + resolveWebSourceTicket, saveObsSettings, setActiveScene, updateModule, updateModuleTransform, updateOverlay, - updateScene + updateScene, + webSourceRenderUrl }; diff --git a/src/web/public/overlay-renderer.js b/src/web/public/overlay-renderer.js index 2f05065..370ecc6 100644 --- a/src/web/public/overlay-renderer.js +++ b/src/web/public/overlay-renderer.js @@ -93,13 +93,15 @@ function buildWebsite(module, values, options) { const frame = document.createElement("iframe"); + const sourceUrl = values.renderUrl || values.url; + const injectedDocument = Boolean(values.renderUrl); frame.className = "lumi-overlay-frame"; - frame.src = values.url; - frame.dataset.originalUrl = values.url; + frame.src = sourceUrl; + frame.dataset.originalUrl = sourceUrl; frame.dataset.moduleId = module.id; let sandbox = "allow-scripts allow-forms allow-popups allow-presentation"; try { - if (new URL(values.url, window.location.href).origin !== window.location.origin) sandbox += " allow-same-origin"; + if (!injectedDocument && new URL(sourceUrl, window.location.href).origin !== window.location.origin) sandbox += " allow-same-origin"; } catch {} frame.sandbox = sandbox; frame.referrerPolicy = "no-referrer"; @@ -119,7 +121,6 @@ width: ${layout.width}%; height: ${layout.height}%; left: ${layout.left}%; top: ${layout.top}%; } - ${values.customCss || ""} `; shadow.append(style, frame); return root; diff --git a/src/web/views/admin-overlay-detail.ejs b/src/web/views/admin-overlay-detail.ejs index 7ea177e..83875ab 100644 --- a/src/web/views/admin-overlay-detail.ejs +++ b/src/web/views/admin-overlay-detail.ejs @@ -142,6 +142,7 @@
+
Website CSS

Recommended. Turn this off only if a website will not load through CSS injection.

Automatic recovery
Check website replies
@@ -151,7 +152,7 @@ - +
@@ -169,7 +170,7 @@ <% if (scene.modules.length > 1) { %>
<% scene.modules.forEach((module) => { %><% }) %>
<% } %> -
Add a source
+
Add a source
<% }) %> diff --git a/update-manifest.json b/update-manifest.json index 470dbf9..db8778d 100644 --- a/update-manifest.json +++ b/update-manifest.json @@ -1,6 +1,6 @@ { "name": "Lumi Core", - "version": "0.2.12", + "version": "0.2.13", "channel": "stable", "released_at": "2026-07-18", "compatible_from": "0.1.9", @@ -8,7 +8,7 @@ "replaces_versions": [ "1.2.0" ], - "migration_notes": "Includes the 1.2.0 version correction, production diagnostics, Windows/network-share-safe updates, Node.js 24-compatible dependency startup, structured redacted logging, and OBS overlay transparency/layout parity fixes. Lumi preserves existing overlays, tokens, scenes, sources, settings, databases, logs, plugin data, community knowledge, AI models, runtimes, uploads, feedback, and secrets.", + "migration_notes": "Includes the 1.2.0 version correction, production diagnostics, Windows/network-share-safe updates, Node.js 24-compatible dependency startup, structured redacted logging, OBS overlay layout parity, and OBS-style website document CSS injection. Lumi preserves existing overlays, website URLs, CSS, tokens, scenes, sources, settings, databases, logs, plugin data, community knowledge, AI models, runtimes, uploads, feedback, and secrets.", "rollback_safe": true, "requirements": [ "Node.js 18 or newer" @@ -169,6 +169,18 @@ ], "rollback_safe": true, "migration_notes": "Fixes OBS overlay transparency and preview-to-output layout parity; existing overlay data, tokens, scenes, sources, and all other local data remain preserved." + }, + { + "version": "0.2.13", + "channel": "stable", + "released_at": "2026-07-18", + "compatible_from": "0.1.9", + "migration_kind": "patch", + "replaces_versions": [ + "1.2.0" + ], + "rollback_safe": true, + "migration_notes": "Adds OBS-style website document CSS injection with secure scoped render tickets; existing overlay sources, URLs, CSS, tokens, and all other local data remain preserved." } ] }