Inject OBS custom CSS into website sources
This commit is contained in:
parent
1cd2a62302
commit
a90f7071d9
@ -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.
|
||||||
|
|||||||
6
TODO.md
6
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
|
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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
4
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
63
scripts/verify-overlay-web-documents.js
Normal file
63
scripts/verify-overlay-web-documents.js
Normal 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);
|
||||||
|
});
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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.");
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">");
|
||||||
|
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"),
|
||||||
|
|||||||
@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
168
src/services/overlay-web-documents.js
Normal file
168
src/services/overlay-web-documents.js
Normal 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("&", "&")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeStyleText(value) {
|
||||||
|
return obsBrowserSourceCss(value).replace(/<\/style/gi, "<\\/style");
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEndCssScript(value) {
|
||||||
|
const encoded = JSON.stringify(obsBrowserSourceCss(value)).replaceAll("<", "\\u003c");
|
||||||
|
return `<script id="lumi-obs-browser-css-loader">(()=>{const css=${encoded};const apply=()=>{document.getElementById("lumi-obs-browser-css")?.remove();const style=document.createElement("style");style.id="lumi-obs-browser-css";style.appendChild(document.createTextNode(css));(document.head||document.documentElement).appendChild(style)};document.readyState==="complete"?apply():window.addEventListener("load",apply,{once:true})})()</script>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectDocumentCss(html, upstreamUrl, customCss) {
|
||||||
|
let document = String(html || "");
|
||||||
|
const existingBase = document.match(/<base\b[^>]*href\s*=\s*["']([^"']+)["'][^>]*>/i)?.[1];
|
||||||
|
const documentBase = existingBase ? new URL(existingBase, upstreamUrl).toString() : upstreamUrl;
|
||||||
|
document = document
|
||||||
|
.replace(/<base\b[^>]*>/gi, "")
|
||||||
|
.replace(/<meta\b[^>]*http-equiv\s*=\s*["']?content-security-policy["']?[^>]*>/gi, "");
|
||||||
|
const headStart = `<base href="${htmlAttribute(documentBase)}"><meta name="referrer" content="no-referrer">`;
|
||||||
|
const style = `<style id="lumi-obs-browser-css">${safeStyleText(customCss)}</style>`;
|
||||||
|
const loadEndScript = loadEndCssScript(customCss);
|
||||||
|
if (/<head\b[^>]*>/i.test(document)) {
|
||||||
|
document = document.replace(/<head\b[^>]*>/i, (match) => `${match}${headStart}`);
|
||||||
|
document = /<\/head\s*>/i.test(document)
|
||||||
|
? document.replace(/<\/head\s*>/i, `${style}</head>`)
|
||||||
|
: `${document}${style}`;
|
||||||
|
} else {
|
||||||
|
document = `<head>${headStart}${style}</head>${document}`;
|
||||||
|
}
|
||||||
|
return /<\/body\s*>/i.test(document)
|
||||||
|
? document.replace(/<\/body\s*>/i, `${loadEndScript}</body>`)
|
||||||
|
: `${document}${loadEndScript}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInjectedOverlayDocument(url, customCss, options = {}) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), Number(options.timeoutMs) || 12000);
|
||||||
|
try {
|
||||||
|
const response = await fetchPublicOverlay(url, controller.signal, 0, options);
|
||||||
|
if (!response) {
|
||||||
|
const error = new Error("Private and local website addresses cannot use page CSS injection. Use direct compatibility mode for this source.");
|
||||||
|
error.code = "PRIVATE_TARGET";
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
await response.body?.cancel?.().catch(() => {});
|
||||||
|
throw new Error(`The website returned HTTP ${response.status}.`);
|
||||||
|
}
|
||||||
|
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
|
||||||
|
if (contentType && !/(?:text\/html|application\/xhtml\+xml)/i.test(contentType)) {
|
||||||
|
await response.body?.cancel?.().catch(() => {});
|
||||||
|
throw new Error("The website did not return an HTML document.");
|
||||||
|
}
|
||||||
|
const html = await boundedText(response, Number(options.maxBytes) || MAX_DOCUMENT_BYTES);
|
||||||
|
if (!html.trim()) throw new Error("The website returned an empty document.");
|
||||||
|
return {
|
||||||
|
html: injectDocumentCss(html, response.url || url, customCss),
|
||||||
|
upstreamUrl: response.url || url
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
fetchPublicOverlay,
|
||||||
|
injectDocumentCss,
|
||||||
|
isPrivateAddress,
|
||||||
|
isPrivateTarget,
|
||||||
|
loadInjectedOverlayDocument
|
||||||
|
};
|
||||||
@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
|
|||||||
@ -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."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user