Inject OBS custom CSS into website sources

This commit is contained in:
Franz Rolfsvaag 2026-07-18 22:18:35 +02:00
parent 1cd2a62302
commit a90f7071d9
21 changed files with 485 additions and 90 deletions

View File

@ -1,5 +1,11 @@
# Lumi changelog # 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 ## 0.2.12
- Fixed OBS Browser Source transparency by applying OBS's standard transparent-page CSS to clean overlay pages and embedded frame surfaces. - Fixed OBS Browser Source transparency by applying OBS's standard transparent-page CSS to clean overlay pages and embedded frame surfaces.

View File

@ -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 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 render page and editor now share a fixed full-resolution canvas, transparent OBS
defaults, and explicit Browser Source dimensions so responsive third-party alerts 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: Remaining work:
@ -680,6 +683,7 @@ This section is for Lumi chat answer feedback and AI Improvement Center work, no
## Done ## 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: 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. - 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.

View File

@ -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 addresses are periodically refreshed but are not fetched by the Lumi server for
security; browser load-timeout recovery still applies. security; browser load-timeout recovery still applies.
Website sources also support per-edge percentage cropping, zoom, and isolated Website sources support per-edge percentage cropping, zoom, and document-level
frame appearance CSS. Custom CSS is scoped to the source frame so it cannot restyle Website CSS. Lumi retrieves the public website document through a scoped,
the Lumi render page or other sources. Standard browser cross-origin security revocable source URL and appends the stylesheet to its `<head>`, matching how OBS
does not allow parent-page CSS to select elements inside a third-party iframe; injects Custom CSS after the main document loads. The proxied document runs in a
use the provider's own appearance settings, crop, and zoom to hide unwanted sandboxed opaque origin, receives no Lumi login/session data, does not expose the
portions of an external site. Frame-level CSS can target overlay token in its location, and cannot use Lumi as a private-network proxy.
`.lumi-overlay-frame`, for example
`.lumi-overlay-frame { filter: saturate(1.2); }`. 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 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: 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; } 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 The Lumi document, canvas stage, embedded frame surface, and injected website
default. A third-party page can still explicitly paint its own background; a body are transparent by default. Administrators can extend or override the
normal parent iframe cannot override that cross-origin page CSS. default stylesheet per source just as they do in OBS Browser Source properties.
## OBS WebSocket ## OBS WebSocket

View File

@ -14,7 +14,7 @@ editable: false
Lumi is the core web UI and bot runtime. Lumi is the core web UI and bot runtime.
## Runtime ## Runtime
Package: lumi-bot Package: lumi-bot
Version: 0.2.12 Version: 0.2.13
## Routes ## Routes
- GET /api/events - GET /api/events
- POST /api/destructive-confirmations - POST /api/destructive-confirmations

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.2.12", "version": "0.2.13",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.2.12", "version": "0.2.13",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.12", "adm-zip": "^0.5.12",
"better-sqlite3": "^11.5.0", "better-sqlite3": "^11.5.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.2.12", "version": "0.2.13",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {

View File

@ -2,6 +2,36 @@
"schema_version": 1, "schema_version": 1,
"channel": "stable", "channel": "stable",
"releases": [ "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", "version": "0.2.12",
"ref": "refs/tags/v0.2.12", "ref": "refs/tags/v0.2.12",

View File

@ -24,6 +24,7 @@ const checks = [
"scripts/verify-command-preview-confirmations.js", "scripts/verify-command-preview-confirmations.js",
"scripts/verify-destructive-actions.js", "scripts/verify-destructive-actions.js",
"scripts/verify-overlays.js", "scripts/verify-overlays.js",
"scripts/verify-overlay-web-documents.js",
"scripts/verify-webhooks.js" "scripts/verify-webhooks.js"
]; ];

View File

@ -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(
"<!doctype html><html><head><title>Alerts</title></head><body><div class=\"alert\">Hello</div></body></html>",
"https://alerts.example/widgets/main/",
".alert { font-size: 64px; }"
);
assert(injected.includes('<base href="https://alerts.example/widgets/main/">'));
assert(injected.includes('<style id="lumi-obs-browser-css">'));
assert(injected.includes(OBS_BROWSER_DEFAULT_CSS));
assert(injected.includes(".alert { font-size: 64px; }"));
assert(injected.indexOf("lumi-obs-browser-css") > injected.indexOf("<title>Alerts</title>"), "website CSS must be appended after the website head content like OBS");
assert(injected.includes('window.addEventListener("load",apply,{once:true})'), "website CSS must be re-applied when the main document finishes loading like OBS");
const withBase = injectDocumentCss(
'<html><head><base href="../assets/"></head><body></body></html>',
"https://alerts.example/widgets/main/",
""
);
assert(withBase.includes('<base href="https://alerts.example/widgets/assets/">'));
assert.strictEqual((withBase.match(/<base\b/gi) || []).length, 1);
let fetched = null;
const loaded = await loadInjectedOverlayDocument("https://alerts.example/widget", "body { color: white; }", {
resolveHost: async () => ["93.184.216.34"],
fetchImpl: async (url, options) => {
fetched = { url, options };
return new Response("<html><head></head><body>Widget</body></html>", {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" }
});
}
});
assert.strictEqual(fetched.url, "https://alerts.example/widget");
assert.strictEqual(fetched.options.redirect, "manual");
assert(loaded.html.includes(OBS_BROWSER_DEFAULT_CSS));
assert(loaded.html.includes("body { color: white; }"));
let privateFetchAttempted = false;
await assert.rejects(
loadInjectedOverlayDocument("http://127.0.0.1:8080/alerts", "", {
fetchImpl: async () => {
privateFetchAttempted = true;
throw new Error("must not fetch");
}
}),
/Private and local website addresses/
);
assert.strictEqual(privateFetchAttempted, false, "CSS injection must not turn Lumi into a private-network proxy");
console.log("Overlay website document verification passed: OBS-default CSS injection, custom selectors, base URLs, bounded public fetching, and private-network blocking.");
}
run().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@ -14,6 +14,7 @@ for (const file of [
"settings.js", "settings.js",
"web-events.js", "web-events.js",
"overlay-secrets.js", "overlay-secrets.js",
"obs-browser-defaults.js",
"overlay-modules.js", "overlay-modules.js",
"overlays.js", "overlays.js",
"overlay-permissions.js", "overlay-permissions.js",
@ -113,6 +114,8 @@ try {
assert.strictEqual(webConfig.healthCheck, true); assert.strictEqual(webConfig.healthCheck, true);
assert.strictEqual(webConfig.refreshIntervalSeconds, 30); assert.strictEqual(webConfig.refreshIntervalSeconds, 30);
assert.strictEqual(webConfig.retryLimit, 5); assert.strictEqual(webConfig.retryLimit, 5);
assert.strictEqual(webConfig.injectPageCss, true);
assert.strictEqual(webConfig.customCss, "body { background-color: rgba(0, 0, 0, 0); margin: 0px auto; overflow: hidden; }");
const videoConfig = overlayModules.normalizeModuleConfig("video", { const videoConfig = overlayModules.normalizeModuleConfig("video", {
url: "https://example.com/alert.webm", url: "https://example.com/alert.webm",
playBehavior: "loop", playBehavior: "loop",
@ -143,7 +146,9 @@ try {
}); });
assert.strictEqual(styledWebConfig.cropLeft, 12.5); assert.strictEqual(styledWebConfig.cropLeft, 12.5);
assert.strictEqual(styledWebConfig.zoom, 1.4); assert.strictEqual(styledWebConfig.zoom, 1.4);
assert.strictEqual(styledWebConfig.customCss, "iframe { filter: grayscale(1); }"); assert(styledWebConfig.customCss.endsWith("iframe { filter: grayscale(1); }"));
assert(styledWebConfig.customCss.startsWith("body { background-color: rgba(0, 0, 0, 0);"));
assert.strictEqual(overlayModules.normalizeModuleConfig("web", { injectPageCss: false }).injectPageCss, false);
overlays.updateOverlay(created.id, { canvasWidth: 1280, canvasHeight: 720 }); overlays.updateOverlay(created.id, { canvasWidth: 1280, canvasHeight: 720 });
assert.deepStrictEqual(overlays.buildPublicState(created.id).canvas, { width: 1280, height: 720 }); assert.deepStrictEqual(overlays.buildPublicState(created.id).canvas, { width: 1280, height: 720 });
@ -153,10 +158,26 @@ try {
assert.strictEqual(overlays.buildPublicState(created.id).scene.id, second.id); assert.strictEqual(overlays.buildPublicState(created.id).scene.id, second.id);
assert.strictEqual(overlays.buildPublicState(created.id, created.scenes[0].id).scene.name, "Live"); assert.strictEqual(overlays.buildPublicState(created.id, created.scenes[0].id).scene.name, "Live");
const websiteId = overlays.addModule(created.scenes[0].id, {
name: "Alerts",
type: "web",
enabled: true,
config: { url: "https://alerts.example/widget" }
});
const websiteState = overlays.buildPublicState(created.id, created.scenes[0].id);
const websiteRenderUrl = websiteState.scene.modules.find((module) => module.id === websiteId).config.renderUrl;
const websiteTicket = websiteRenderUrl.split("/").at(-1);
assert(websiteRenderUrl.startsWith("/overlay-web/"));
assert.strictEqual(overlays.resolveWebSourceTicket(websiteTicket).moduleId, websiteId);
const oldOverlayToken = created.public_token; const oldOverlayToken = created.public_token;
const newOverlayToken = overlays.regenerateOverlayToken(created.id); const newOverlayToken = overlays.regenerateOverlayToken(created.id);
assert.strictEqual(overlays.resolvePublicOverlay(oldOverlayToken), null, "regeneration must revoke old overlay tokens"); assert.strictEqual(overlays.resolvePublicOverlay(oldOverlayToken), null, "regeneration must revoke old overlay tokens");
assert(overlays.resolvePublicOverlay(newOverlayToken)); assert(overlays.resolvePublicOverlay(newOverlayToken));
assert.strictEqual(overlays.resolveWebSourceTicket(websiteTicket), null, "overlay URL regeneration must revoke website render tickets");
const refreshedWebsiteUrl = overlays.buildPublicState(created.id, created.scenes[0].id).scene.modules.find((module) => module.id === websiteId).config.renderUrl;
const refreshedWebsiteTicket = refreshedWebsiteUrl.split("/").at(-1);
assert(overlays.resolveWebSourceTicket(refreshedWebsiteTicket));
const refreshed = overlays.getOverlay(created.id, { includeSecrets: true }); const refreshed = overlays.getOverlay(created.id, { includeSecrets: true });
const firstScene = refreshed.scenes.find((scene) => scene.name === "Live"); const firstScene = refreshed.scenes.find((scene) => scene.name === "Live");
@ -164,6 +185,8 @@ try {
const newSceneToken = overlays.regenerateSceneToken(firstScene.id); const newSceneToken = overlays.regenerateSceneToken(firstScene.id);
assert.strictEqual(overlays.resolvePublicOverlay(newOverlayToken, oldSceneToken), null, "regeneration must revoke old fixed scene tokens"); assert.strictEqual(overlays.resolvePublicOverlay(newOverlayToken, oldSceneToken), null, "regeneration must revoke old fixed scene tokens");
assert(overlays.resolvePublicOverlay(newOverlayToken, newSceneToken)); assert(overlays.resolvePublicOverlay(newOverlayToken, newSceneToken));
assert.strictEqual(overlays.resolveWebSourceTicket(refreshedWebsiteTicket), null, "scene URL regeneration must revoke website render tickets");
overlays.deleteModule(websiteId);
overlays.deleteScene(second.id); overlays.deleteScene(second.id);
assert.throws(() => overlays.deleteScene(firstScene.id), /at least one scene/); assert.throws(() => overlays.deleteScene(firstScene.id), /at least one scene/);
@ -206,6 +229,7 @@ try {
assert(editorView.includes('data-module-fields="text"') && editorView.includes('data-module-fields="web"')); assert(editorView.includes('data-module-fields="text"') && editorView.includes('data-module-fields="web"'));
assert(editorView.includes('data-module-fields="video"') && editorView.includes('data-module-fields="audio"')); assert(editorView.includes('data-module-fields="video"') && editorView.includes('data-module-fields="audio"'));
assert(editorView.includes('name="custom_css"') && editorView.includes('name="crop_left"')); assert(editorView.includes('name="custom_css"') && editorView.includes('name="crop_left"'));
assert(editorView.includes('name="inject_page_css"') && editorView.includes("Apply CSS inside the website"));
assert(editorView.includes("obsBrowserDefaultCss") && editorView.includes("OBS Browser Source setup")); assert(editorView.includes("obsBrowserDefaultCss") && editorView.includes("OBS Browser Source setup"));
assert(editorView.includes("data-color-picker") && editorView.includes("overlay-transform-disclosure")); assert(editorView.includes("data-color-picker") && editorView.includes("overlay-transform-disclosure"));
assert(editorView.includes('data-confirm-action="/admin/overlays/<%= overlay.id %>/modules/<%= module.id %>/delete"'), "source deletion must use the shared timed-confirmation action flow"); assert(editorView.includes('data-confirm-action="/admin/overlays/<%= overlay.id %>/modules/<%= module.id %>/delete"'), "source deletion must use the shared timed-confirmation action flow");
@ -216,6 +240,8 @@ try {
assert(rendererScript.includes("buildMedia") && rendererScript.includes("buildWebsite")); assert(rendererScript.includes("buildMedia") && rendererScript.includes("buildWebsite"));
assert(rendererScript.includes('frame.setAttribute("allow", "autoplay; fullscreen")')); assert(rendererScript.includes('frame.setAttribute("allow", "autoplay; fullscreen")'));
assert(rendererScript.includes('frame.setAttribute("allowtransparency", "true")')); assert(rendererScript.includes('frame.setAttribute("allowtransparency", "true")'));
assert(rendererScript.includes("values.renderUrl || values.url"));
assert(!rendererScript.includes('${values.customCss || ""}'), "website CSS must be injected into the page, not scoped to the outer frame");
assert(rendererScript.includes('className = "lumi-overlay-stage"'), "preview and OBS must share a fixed canvas stage"); assert(rendererScript.includes('className = "lumi-overlay-stage"'), "preview and OBS must share a fixed canvas stage");
assert.strictEqual(OBS_BROWSER_DEFAULT_CSS, "body { background-color: rgba(0, 0, 0, 0); margin: 0px auto; overflow: hidden; }"); assert.strictEqual(OBS_BROWSER_DEFAULT_CSS, "body { background-color: rgba(0, 0, 0, 0); margin: 0px auto; overflow: hidden; }");
assert(renderView.includes('<style id="obs-browser-default-css"><%- obsBrowserDefaultCss %></style>')); assert(renderView.includes('<style id="obs-browser-default-css"><%- obsBrowserDefaultCss %></style>'));
@ -237,6 +263,7 @@ try {
assert.strictEqual(obsLayout.scaleY, 1); assert.strictEqual(obsLayout.scaleY, 1);
assert(connectorScript.includes('id: "obs_browser_bridge"') && connectorScript.includes("acceptReport(report")); assert(connectorScript.includes('id: "obs_browser_bridge"') && connectorScript.includes("acceptReport(report"));
assert(routeScript.includes("/obs-bridge") && routeScript.includes("/obs/import")); 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."); 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 { } finally {

View File

@ -4,8 +4,8 @@ const path = require("path");
const { findSafeTarget } = require("../src/services/versioning"); const { findSafeTarget } = require("../src/services/versioning");
const root = path.join(__dirname, ".."); const root = path.join(__dirname, "..");
const releaseVersion = "0.2.12"; const releaseVersion = "0.2.13";
const previousCoreVersion = "0.2.11"; const previousCoreVersion = "0.2.12";
const earliestCompatibleCoreVersion = "0.1.9"; const earliestCompatibleCoreVersion = "0.1.9";
const changedPlugins = { const changedPlugins = {
"auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" }, "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(webSearch.minimum_lumi_ai_version, "0.8.2");
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true); 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.");

View File

@ -16,7 +16,7 @@ function readJson(relativePath) {
const releaseIndex = readJson("release-index.json"); const releaseIndex = readJson("release-index.json");
const releaseVersions = releaseIndex.releases.map((release) => release.version); 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"); assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
for (const release of releaseIndex.releases) { for (const release of releaseIndex.releases) {
assert.equal(normalizeRepositoryRef(release.ref), release.ref); assert.equal(normalizeRepositoryRef(release.ref), release.ref);
@ -37,6 +37,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
const baseTarget = { const baseTarget = {
current_version: "0.2.4", current_version: "0.2.4",
available_versions: [ 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.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.11", ref: "refs/tags/v0.2.11", rollback_safe: true },
{ version: "0.2.10", ref: "refs/tags/v0.2.10", 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" channel: "stable"
}); });
assert.equal(corrected.version_correction, true); 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.update_available, true);
assert.equal(corrected.blocked, false); assert.equal(corrected.blocked, false);

View File

@ -1,5 +1,13 @@
const OBS_BROWSER_DEFAULT_CSS = "body { background-color: rgba(0, 0, 0, 0); margin: 0px auto; overflow: hidden; }"; 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 = { module.exports = {
OBS_BROWSER_DEFAULT_CSS OBS_BROWSER_DEFAULT_CSS,
obsBrowserSourceCss
}; };

View File

@ -1,4 +1,5 @@
const moduleTypes = new Map(); const moduleTypes = new Map();
const { obsBrowserSourceCss } = require("./obs-browser-defaults");
function number(value, fallback, min, max) { function number(value, fallback, min, max) {
const parsed = Number(value); const parsed = Number(value);
@ -162,7 +163,8 @@ registerOverlayModuleType({
cropBottom: number(input.cropBottom, 0, 0, 95), cropBottom: number(input.cropBottom, 0, 0, 95),
cropLeft: number(input.cropLeft, 0, 0, 95), cropLeft: number(input.cropLeft, 0, 0, 95),
zoom: number(input.zoom, 1, 0.1, 5), 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))
}; };
} }
}); });

View File

@ -1,6 +1,5 @@
const dns = require("dns").promises;
const net = require("net");
const { subscribeWebEvents } = require("./web-events"); const { subscribeWebEvents } = require("./web-events");
const { createLogger } = require("./logger");
const { const {
OVERLAY_CAPABILITIES, OVERLAY_CAPABILITIES,
canAccessOverlay canAccessOverlay
@ -27,64 +26,24 @@ const {
reorderOverlays, reorderOverlays,
reorderScenes, reorderScenes,
resolvePublicOverlay, resolvePublicOverlay,
resolveWebSourceTicket,
saveObsSettings, saveObsSettings,
setActiveScene, setActiveScene,
updateModule, updateModule,
updateModuleTransform, updateModuleTransform,
updateOverlay, updateOverlay,
updateScene updateScene,
webSourceRenderUrl
} = require("./overlays"); } = require("./overlays");
const { OBS_BROWSER_DEFAULT_CSS } = require("./obs-browser-defaults"); const { OBS_BROWSER_DEFAULT_CSS } = require("./obs-browser-defaults");
const { fetchPublicOverlay, loadInjectedOverlayDocument } = require("./overlay-web-documents");
const { const {
listOverlayConnectorProviders, listOverlayConnectorProviders,
overlayConnectorManager overlayConnectorManager
} = require("./overlay-connectors"); } = require("./overlay-connectors");
const externalHealthCache = new Map(); const externalHealthCache = new Map();
const overlayWebLog = createLogger("core:overlay-web", { category: "integration" });
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;
}
async function probeExternalOverlay(url) { async function probeExternalOverlay(url) {
const controller = new AbortController(); const controller = new AbortController();
@ -149,11 +108,59 @@ function noStore(res) {
res.set("Pragma", "no-cache"); 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
return `<!doctype html><html><head><style>${OBS_BROWSER_DEFAULT_CSS}body{color:#fff;font:600 16px/1.4 system-ui,sans-serif;padding:18px;box-sizing:border-box}.error{display:inline-block;padding:10px 12px;border:1px solid rgba(248,113,113,.8);border-radius:8px;background:rgba(15,23,42,.9)}</style></head><body><div class="error">${safe}</div></body></html>`;
}
function publicOverlayContext(req) { function publicOverlayContext(req) {
return resolvePublicOverlay(req.params.overlayToken, req.params.sceneToken || null); return resolvePublicOverlay(req.params.overlayToken, req.params.sceneToken || null);
} }
function registerPublicOverlayRoutes(app) { 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) => { const registerVariant = (basePath, fixed) => {
app.post(`${basePath}/obs-bridge`, (req, res) => { app.post(`${basePath}/obs-bridge`, (req, res) => {
noStore(res); noStore(res);
@ -324,6 +331,7 @@ function moduleInput(body) {
startAt: body.start_at, startAt: body.start_at,
showControls: booleanField(body, "show_controls"), showControls: booleanField(body, "show_controls"),
allowPointerEvents: booleanField(body, "allow_pointer_events"), allowPointerEvents: booleanField(body, "allow_pointer_events"),
injectPageCss: booleanField(body, "inject_page_css"),
anchor: body.anchor, anchor: body.anchor,
horizontalAlign: body.horizontal_align, horizontalAlign: body.horizontal_align,
verticalAlign: body.vertical_align, verticalAlign: body.vertical_align,
@ -394,7 +402,12 @@ function registerOverlayAdminRoutes(app) {
type: module.type, type: module.type,
renderType: getOverlayModuleType(module.type)?.renderType || module.type, renderType: getOverlayModuleType(module.type)?.renderType || module.type,
enabled: module.enabled, 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(/</g, "\\u003c"), }).replace(/</g, "\\u003c"),

View File

@ -17,6 +17,18 @@ function tokenHash(token) {
return crypto.createHash("sha256").update(String(token || "")).digest("hex"); return crypto.createHash("sha256").update(String(token || "")).digest("hex");
} }
function scopedSignature(scope, value) {
return crypto.createHmac("sha256", encryptionKey())
.update(`${String(scope || "")}:${String(value || "")}`)
.digest("base64url");
}
function verifyScopedSignature(scope, value, signature) {
const expected = Buffer.from(scopedSignature(scope, value));
const received = Buffer.from(String(signature || ""));
return expected.length === received.length && crypto.timingSafeEqual(expected, received);
}
function encryptSecret(value) { function encryptSecret(value) {
if (value === null || value === undefined || value === "") return null; if (value === null || value === undefined || value === "") return null;
const iv = crypto.randomBytes(12); const iv = crypto.randomBytes(12);
@ -58,5 +70,7 @@ module.exports = {
decryptSecret, decryptSecret,
encryptSecret, encryptSecret,
generateToken, generateToken,
tokenHash scopedSignature,
tokenHash,
verifyScopedSignature
}; };

View File

@ -0,0 +1,168 @@
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
};

View File

@ -7,7 +7,9 @@ const {
createStoredToken, createStoredToken,
decryptSecret, decryptSecret,
encryptSecret, encryptSecret,
tokenHash scopedSignature,
tokenHash,
verifyScopedSignature
} = require("./overlay-secrets"); } = require("./overlay-secrets");
const overlayChanges = new EventEmitter(); const overlayChanges = new EventEmitter();
@ -429,6 +431,43 @@ function resolvePublicOverlay(overlayToken, sceneToken = null) {
return { overlay, fixedScene }; 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) { 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); 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 }; 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 }; 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") 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) .all(scene.id)
.map((module) => ({ .map((module) => {
id: module.id, const renderType = getOverlayModuleType(module.type)?.renderType || null;
type: module.type, const config = parseConfig(module.config_json);
renderType: getOverlayModuleType(module.type)?.renderType || null, if (renderType === "web" && config.injectPageCss !== false) config.renderUrl = webSourceRenderUrl(module.id);
name: module.name, return { id: module.id, type: module.type, renderType, name: module.name, config };
config: parseConfig(module.config_json) })
}))
.filter((module) => module.renderType); .filter((module) => module.renderType);
return { return {
exists: true, exists: true,
@ -533,10 +571,12 @@ module.exports = {
reorderOverlays, reorderOverlays,
reorderScenes, reorderScenes,
resolvePublicOverlay, resolvePublicOverlay,
resolveWebSourceTicket,
saveObsSettings, saveObsSettings,
setActiveScene, setActiveScene,
updateModule, updateModule,
updateModuleTransform, updateModuleTransform,
updateOverlay, updateOverlay,
updateScene updateScene,
webSourceRenderUrl
}; };

View File

@ -93,13 +93,15 @@
function buildWebsite(module, values, options) { function buildWebsite(module, values, options) {
const frame = document.createElement("iframe"); const frame = document.createElement("iframe");
const sourceUrl = values.renderUrl || values.url;
const injectedDocument = Boolean(values.renderUrl);
frame.className = "lumi-overlay-frame"; frame.className = "lumi-overlay-frame";
frame.src = values.url; frame.src = sourceUrl;
frame.dataset.originalUrl = values.url; frame.dataset.originalUrl = sourceUrl;
frame.dataset.moduleId = module.id; frame.dataset.moduleId = module.id;
let sandbox = "allow-scripts allow-forms allow-popups allow-presentation"; let sandbox = "allow-scripts allow-forms allow-popups allow-presentation";
try { 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 {} } catch {}
frame.sandbox = sandbox; frame.sandbox = sandbox;
frame.referrerPolicy = "no-referrer"; frame.referrerPolicy = "no-referrer";
@ -119,7 +121,6 @@
width: ${layout.width}%; height: ${layout.height}%; width: ${layout.width}%; height: ${layout.height}%;
left: ${layout.left}%; top: ${layout.top}%; left: ${layout.left}%; top: ${layout.top}%;
} }
${values.customCss || ""}
`; `;
shadow.append(style, frame); shadow.append(style, frame);
return root; return root;

View File

@ -142,6 +142,7 @@
<div class="field full overlay-conditional-fields" data-module-fields="web"> <div class="field full overlay-conditional-fields" data-module-fields="web">
<label>Overlay website address <button type="button" class="overlay-help" data-tooltip="Paste a StreamElements alert link or another browser-overlay URL. Lumi keeps it isolated from your admin login.">?</button><input name="url" value="<%= module.type === "web" ? value(config, "url") : "" %>" placeholder="https://streamelements.com/overlay/..." /></label> <label>Overlay website address <button type="button" class="overlay-help" data-tooltip="Paste a StreamElements alert link or another browser-overlay URL. Lumi keeps it isolated from your admin login.">?</button><input name="url" value="<%= module.type === "web" ? value(config, "url") : "" %>" placeholder="https://streamelements.com/overlay/..." /></label>
<div class="overlay-switch-field"><span class="overlay-field-label">Website CSS</span><label class="switch"><input class="switch-input" type="checkbox" name="inject_page_css" <%= value(config,"injectPageCss",true) !== false ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Apply CSS inside the website</span></label><p class="hint">Recommended. Turn this off only if a website will not load through CSS injection.</p></div>
<div class="overlay-switch-field"><span class="overlay-field-label">Automatic recovery</span><label class="switch"><input class="switch-input" type="checkbox" name="auto_refresh" <%= value(config,"autoRefresh",false) ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Refresh stale or broken content</span></label></div> <div class="overlay-switch-field"><span class="overlay-field-label">Automatic recovery</span><label class="switch"><input class="switch-input" type="checkbox" name="auto_refresh" <%= value(config,"autoRefresh",false) ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Refresh stale or broken content</span></label></div>
<div class="overlay-switch-field"><span class="overlay-field-label">Check website replies</span><label class="switch"><input class="switch-input" type="checkbox" name="health_check" <%= value(config,"healthCheck",true) !== false ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Check for errors</span></label></div> <div class="overlay-switch-field"><span class="overlay-field-label">Check website replies</span><label class="switch"><input class="switch-input" type="checkbox" name="health_check" <%= value(config,"healthCheck",true) !== false ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Check for errors</span></label></div>
<label>Refresh every<select name="refresh_interval_seconds"><% [[60,"1 minute"],[300,"5 minutes"],[900,"15 minutes"],[1800,"30 minutes"],[3600,"1 hour"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= Number(value(config,"refreshIntervalSeconds",300)) === id ? "selected" : "" %>><%= label %></option><% }) %></select></label> <label>Refresh every<select name="refresh_interval_seconds"><% [[60,"1 minute"],[300,"5 minutes"],[900,"15 minutes"],[1800,"30 minutes"],[3600,"1 hour"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= Number(value(config,"refreshIntervalSeconds",300)) === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
@ -151,7 +152,7 @@
<label>Crop bottom %<input type="number" name="crop_bottom" min="0" max="95" step="0.1" value="<%= value(config,"cropBottom",0) %>" /></label> <label>Crop bottom %<input type="number" name="crop_bottom" min="0" max="95" step="0.1" value="<%= value(config,"cropBottom",0) %>" /></label>
<label>Crop left %<input type="number" name="crop_left" min="0" max="95" step="0.1" value="<%= value(config,"cropLeft",0) %>" /></label> <label>Crop left %<input type="number" name="crop_left" min="0" max="95" step="0.1" value="<%= value(config,"cropLeft",0) %>" /></label>
<label>Zoom<input type="number" name="zoom" min="0.1" max="5" step="0.1" value="<%= value(config,"zoom",1) %>" /></label> <label>Zoom<input type="number" name="zoom" min="0.1" max="5" step="0.1" value="<%= value(config,"zoom",1) %>" /></label>
<label class="overlay-css-field">Frame appearance CSS <button type="button" class="overlay-help" data-tooltip="This can style the frame itself. Browser security prevents Lumi from changing elements or a background explicitly drawn inside a different website; use that provider's settings, crop, and zoom for its content.">?</button><textarea name="custom_css" rows="6" maxlength="12000" placeholder=".lumi-overlay-frame { filter: saturate(1.2); }"><%= value(config,"customCss","") %></textarea></label> <label class="overlay-css-field">Website CSS <button type="button" class="overlay-help" data-tooltip="Lumi injects this stylesheet into the loaded website document, like OBS Custom CSS. It can target body and any other elements on the source page.">?</button><textarea name="custom_css" rows="6" maxlength="12000" placeholder="<%= obsBrowserDefaultCss %>"><%= value(config,"customCss",obsBrowserDefaultCss) %></textarea><small class="hint">OBS's transparent background CSS is included by default. Add selectors below it to hide or restyle parts of the alert website.</small></label>
<div><div class="button-group"><button type="button" class="button subtle" data-refresh-source="<%= module.id %>">Refresh now</button><button type="button" class="button subtle" data-check-source="<%= module.id %>">Check website</button></div><small class="hint" data-source-health="<%= module.id %>"></small></div> <div><div class="button-group"><button type="button" class="button subtle" data-refresh-source="<%= module.id %>">Refresh now</button><button type="button" class="button subtle" data-check-source="<%= module.id %>">Check website</button></div><small class="hint" data-source-health="<%= module.id %>"></small></div>
</div> </div>
@ -169,7 +170,7 @@
<% if (scene.modules.length > 1) { %><form method="post" action="/admin/overlays/<%= overlay.id %>/scenes/<%= scene.id %>/modules/reorder" class="overlay-order-form" data-reorder-form><div class="field full"><label>Layer order <button type="button" class="overlay-help" data-tooltip="Sources later in the list appear in front of earlier sources.">?</button></label><div class="overlay-reorder-list" data-reorder-list><% scene.modules.forEach((module) => { %><button type="button" class="button subtle" data-reorder-id="<%= module.id %>"><%= module.name %></button><% }) %></div><input type="hidden" name="ids" data-reorder-value /></div><div class="form-actions"><button class="button subtle" type="submit">Save layer order</button></div></form><% } %> <% if (scene.modules.length > 1) { %><form method="post" action="/admin/overlays/<%= overlay.id %>/scenes/<%= scene.id %>/modules/reorder" class="overlay-order-form" data-reorder-form><div class="field full"><label>Layer order <button type="button" class="overlay-help" data-tooltip="Sources later in the list appear in front of earlier sources.">?</button></label><div class="overlay-reorder-list" data-reorder-list><% scene.modules.forEach((module) => { %><button type="button" class="button subtle" data-reorder-id="<%= module.id %>"><%= module.name %></button><% }) %></div><input type="hidden" name="ids" data-reorder-value /></div><div class="form-actions"><button class="button subtle" type="submit">Save layer order</button></div></form><% } %>
<details class="overlay-add-source"><summary>Add a source</summary><form method="post" action="/admin/overlays/<%= overlay.id %>/scenes/<%= scene.id %>/modules" class="form-grid overlay-source-form" data-module-editor><div class="field"><label>Name</label><input name="name" required placeholder="Headline" /></div><div class="field"><label>Source type</label><select name="type" data-module-type-select><% moduleTypes.forEach((type) => { %><option value="<%= type.id %>"><%= type.label %></option><% }) %></select></div><input type="hidden" name="enabled" value="on" /><input type="hidden" name="anchor" value="top-left" /><input type="hidden" name="x" value="0" /><input type="hidden" name="y" value="0" /><input type="hidden" name="width" value="50" /><input type="hidden" name="height" value="25" /><div class="field full overlay-conditional-fields" data-module-fields="text"><label>Text<textarea name="text" rows="2">New text</textarea></label></div><div class="field full overlay-conditional-fields" data-module-fields="image"><label>Image address<input name="url" placeholder="https://example.com/image.png" /></label></div><div class="field full overlay-conditional-fields" data-module-fields="video"><label>Video address<input name="url" placeholder="https://example.com/clip.webm" /></label><label>Playback<select name="play_behavior"><option value="once">Play once</option><option value="loop">Loop</option><option value="manual">Manual</option></select></label><label>Volume<input type="number" name="volume" min="0" max="1" step="0.05" value="1" /></label></div><div class="field full overlay-conditional-fields" data-module-fields="audio"><label>Audio address<input name="url" placeholder="https://example.com/alert.ogg" /></label><label>Playback<select name="play_behavior"><option value="once">Play once</option><option value="loop">Loop</option><option value="manual">Manual</option></select></label><label>Volume<input type="number" name="volume" min="0" max="1" step="0.05" value="1" /></label></div><div class="field full overlay-conditional-fields" data-module-fields="web"><label>Overlay website address<input name="url" placeholder="https://streamelements.com/overlay/..." /></label><label><input type="checkbox" name="health_check" checked /> Check for website errors</label><label>Custom frame CSS<textarea name="custom_css" rows="3" maxlength="12000"></textarea></label></div><div class="form-actions"><button class="button" type="submit">Add source</button></div></form></details> <details class="overlay-add-source"><summary>Add a source</summary><form method="post" action="/admin/overlays/<%= overlay.id %>/scenes/<%= scene.id %>/modules" class="form-grid overlay-source-form" data-module-editor><div class="field"><label>Name</label><input name="name" required placeholder="Headline" /></div><div class="field"><label>Source type</label><select name="type" data-module-type-select><% moduleTypes.forEach((type) => { %><option value="<%= type.id %>"><%= type.label %></option><% }) %></select></div><input type="hidden" name="enabled" value="on" /><input type="hidden" name="anchor" value="top-left" /><input type="hidden" name="x" value="0" /><input type="hidden" name="y" value="0" /><input type="hidden" name="width" value="50" /><input type="hidden" name="height" value="25" /><div class="field full overlay-conditional-fields" data-module-fields="text"><label>Text<textarea name="text" rows="2">New text</textarea></label></div><div class="field full overlay-conditional-fields" data-module-fields="image"><label>Image address<input name="url" placeholder="https://example.com/image.png" /></label></div><div class="field full overlay-conditional-fields" data-module-fields="video"><label>Video address<input name="url" placeholder="https://example.com/clip.webm" /></label><label>Playback<select name="play_behavior"><option value="once">Play once</option><option value="loop">Loop</option><option value="manual">Manual</option></select></label><label>Volume<input type="number" name="volume" min="0" max="1" step="0.05" value="1" /></label></div><div class="field full overlay-conditional-fields" data-module-fields="audio"><label>Audio address<input name="url" placeholder="https://example.com/alert.ogg" /></label><label>Playback<select name="play_behavior"><option value="once">Play once</option><option value="loop">Loop</option><option value="manual">Manual</option></select></label><label>Volume<input type="number" name="volume" min="0" max="1" step="0.05" value="1" /></label></div><div class="field full overlay-conditional-fields" data-module-fields="web"><label>Overlay website address<input name="url" placeholder="https://streamelements.com/overlay/..." /></label><label><input type="checkbox" name="health_check" checked /> Check for website errors</label><label><input type="checkbox" name="inject_page_css" checked /> Apply CSS inside the website</label><label>Website CSS<textarea name="custom_css" rows="3" maxlength="12000"><%= obsBrowserDefaultCss %></textarea></label></div><div class="form-actions"><button class="button" type="submit">Add source</button></div></form></details>
</div> </div>
</details> </details>
<% }) %> <% }) %>

View File

@ -1,6 +1,6 @@
{ {
"name": "Lumi Core", "name": "Lumi Core",
"version": "0.2.12", "version": "0.2.13",
"channel": "stable", "channel": "stable",
"released_at": "2026-07-18", "released_at": "2026-07-18",
"compatible_from": "0.1.9", "compatible_from": "0.1.9",
@ -8,7 +8,7 @@
"replaces_versions": [ "replaces_versions": [
"1.2.0" "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, "rollback_safe": true,
"requirements": [ "requirements": [
"Node.js 18 or newer" "Node.js 18 or newer"
@ -169,6 +169,18 @@
], ],
"rollback_safe": true, "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." "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."
} }
] ]
} }