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