const dns = require("dns").promises; const net = require("net"); const { subscribeWebEvents } = require("./web-events"); const { OVERLAY_CAPABILITIES, canAccessOverlay } = require("./overlay-permissions"); const { getOverlayModuleType, listOverlayModuleTypes } = require("./overlay-modules"); const { addModule, addScene, buildPublicState, createOverlay, deleteModule, deleteOverlay, deleteScene, duplicateModule, duplicateOverlay, duplicateScene, getObsSettings, getOverlay, listOverlays, regenerateOverlayToken, regenerateSceneToken, refreshModule, reorderModules, reorderOverlays, reorderScenes, resolvePublicOverlay, saveObsSettings, setActiveScene, updateModule, updateModuleTransform, updateOverlay, updateScene } = require("./overlays"); 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; } async function probeExternalOverlay(url) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 8000); try { const response = await fetchPublicOverlay(url, controller.signal); if (!response) { return { healthy: true, skipped: true, status: null, reason: "Private and local website addresses are refreshed but not probed by Lumi." }; } if (!response.ok) { response.body?.cancel?.().catch?.(() => {}); return { healthy: false, status: response.status, reason: `The website returned HTTP ${response.status}.` }; } const contentType = String(response.headers.get("content-type") || "").toLowerCase(); if (contentType && !/(html|xhtml|text|javascript|json|svg)/i.test(contentType)) { response.body?.cancel?.().catch?.(() => {}); return { healthy: false, status: response.status, reason: "The website returned an unexpected content type." }; } const reader = response.body?.getReader?.(); let sample = ""; if (reader) { const decoder = new TextDecoder(); while (sample.length < 65536) { const { done, value } = await reader.read(); if (done) break; sample += decoder.decode(value, { stream: true }); } await reader.cancel().catch(() => {}); } if (!sample.trim()) { return { healthy: false, status: response.status, reason: "The website returned an empty reply." }; } if (contentType.includes("html") && !/<(?:!doctype|html|head|body|script|style|div|canvas|svg)\b/i.test(sample)) { return { healthy: false, status: response.status, reason: "The website reply did not look like a usable overlay page." }; } return { healthy: true, status: response.status, reason: null, checked_at: Date.now(), etag: response.headers.get("etag") || null, last_modified: response.headers.get("last-modified") || null }; } catch (error) { return { healthy: false, status: null, reason: error?.name === "AbortError" ? "The website took too long to reply." : "The website could not be reached." }; } finally { clearTimeout(timeout); } } function noStore(res) { res.set("Cache-Control", "no-store, private"); res.set("Pragma", "no-cache"); } function publicOverlayContext(req) { return resolvePublicOverlay(req.params.overlayToken, req.params.sceneToken || null); } function registerPublicOverlayRoutes(app) { const registerVariant = (basePath, fixed) => { app.post(`${basePath}/obs-bridge`, (req, res) => { noStore(res); const context = publicOverlayContext(req); if (!context || !context.overlay.enabled || (fixed && !context.fixedScene?.enabled)) { return res.status(404).json({ accepted: false, error: "Overlay unavailable." }); } try { const result = overlayConnectorManager.reportBrowserBridge(context.overlay.id, req.body || {}); return res.json({ accepted: Boolean(result.accepted), enabled: Boolean(result.enabled), state: result.state || "disabled" }); } catch (error) { return res.status(400).json({ accepted: false, error: error.message }); } }); app.get(`${basePath}/module-health`, async (req, res) => { noStore(res); const context = publicOverlayContext(req); if (!context) return res.status(404).json({ healthy: false, reason: "Overlay unavailable." }); const state = buildPublicState(context.overlay.id, fixed ? context.fixedScene.id : null); const module = state.scene?.modules?.find((item) => item.id === req.query.module_id && item.renderType === "web"); if (!module?.config?.url) return res.status(404).json({ healthy: false, reason: "Website source unavailable." }); const cacheKey = `${context.overlay.id}:${module.id}`; const cached = externalHealthCache.get(cacheKey); if (cached && Date.now() - cached.at < 15000) return res.json(cached.result); const result = await probeExternalOverlay(module.config.url); externalHealthCache.set(cacheKey, { at: Date.now(), result }); return res.status(result.healthy ? 200 : 502).json(result); }); app.get(`${basePath}/state`, (req, res) => { noStore(res); const context = publicOverlayContext(req); if (!context) return res.status(404).json({ exists: false, enabled: false, scene: null }); return res.json(buildPublicState(context.overlay.id, fixed ? context.fixedScene.id : null)); }); app.get(`${basePath}/events`, (req, res) => { const context = publicOverlayContext(req); if (!context) return res.status(404).end(); return subscribeWebEvents(req, res, { scopes: [`overlay:${context.overlay.id}`], initialEvents: [{ event: "overlay:changed", payload: { revision: context.overlay.updated_at } }] }); }); app.get(basePath, (req, res) => { noStore(res); const context = publicOverlayContext(req); if (!context || !context.overlay.enabled || (fixed && !context.fixedScene.enabled)) { return res.status(404).send("Overlay unavailable."); } const statePath = `${req.path.replace(/\/$/, "")}/state`; const eventsPath = `${req.path.replace(/\/$/, "")}/events`; const healthPath = `${req.path.replace(/\/$/, "")}/module-health`; const bridgePath = `${req.path.replace(/\/$/, "")}/obs-bridge`; res.set("Content-Security-Policy", "default-src 'none'; img-src https: http: data:; frame-src https: http:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self';"); return res.render("overlay-render", { statePath, eventsPath, healthPath, bridgePath, initialStateJson: JSON.stringify(buildPublicState(context.overlay.id, fixed ? context.fixedScene.id : null)).replace(/ { if (!canAccessOverlay(req.session?.user, req.params.id, capability)) { if (req.path.startsWith("/api/")) return res.status(403).json({ error: "Access denied." }); return res.status(403).render("error", { title: "Access denied", message: "You do not have access to that overlay." }); } next(); }; } function requireSceneInOverlay(req, res, next) { const overlay = getOverlay(req.params.id); if (!overlay?.scenes.some((scene) => scene.id === req.params.sceneId)) { if (req.path.startsWith("/api/")) return res.status(404).json({ error: "Scene not found." }); return res.status(404).render("error", { title: "Scene not found", message: "That scene is not part of this overlay." }); } next(); } function requireModuleInOverlay(req, res, next) { const overlay = getOverlay(req.params.id); if (!overlay?.scenes.some((scene) => scene.modules.some((module) => module.id === req.params.moduleId))) { if (req.path.startsWith("/api/")) return res.status(404).json({ error: "Source not found." }); return res.status(404).render("error", { title: "Source not found", message: "That source is not part of this overlay." }); } next(); } function adminOnly(req, res, next) { if (!req.session?.user?.isAdmin) { if (req.path.startsWith("/api/")) return res.status(403).json({ error: "Access denied." }); return res.status(403).render("error", { title: "Access denied", message: "Administrator access is required." }); } next(); } function flash(req, type, message) { req.session.flash = { type, message }; } function redirectWithResult(req, res, pathValue, work, success) { try { const value = work(); flash(req, "success", success); return res.redirect(pathValue); } catch (error) { flash(req, "error", error.message); return res.redirect(pathValue); } } function booleanField(body, name) { return body[name] === true || body[name] === "true" || body[name] === "on" || body[name] === "1"; } function orderedIds(body) { if (Array.isArray(body.ids)) return body.ids; if (typeof body.ids === "string") { try { const parsed = JSON.parse(body.ids); if (Array.isArray(parsed)) return parsed; } catch {} return body.ids.split(",").map((id) => id.trim()).filter(Boolean); } return []; } function moduleInput(body) { return { name: body.name, type: body.type, enabled: booleanField(body, "enabled"), config: { text: body.text, url: body.url, x: body.x, y: body.y, width: body.width, height: body.height, opacity: body.opacity, fontSize: body.font_size, fontWeight: body.font_weight, color: body.color, background: body.background, align: body.align, padding: body.padding, fit: body.fit, playBehavior: body.play_behavior, muted: booleanField(body, "muted"), volume: body.volume, playbackRate: body.playback_rate, startAt: body.start_at, showControls: booleanField(body, "show_controls"), allowPointerEvents: booleanField(body, "allow_pointer_events"), anchor: body.anchor, horizontalAlign: body.horizontal_align, verticalAlign: body.vertical_align, overflow: body.text_overflow, autoRefresh: booleanField(body, "auto_refresh"), healthCheck: booleanField(body, "health_check"), refreshIntervalSeconds: body.refresh_interval_seconds, retryLimit: body.retry_limit, cropTop: body.crop_top, cropRight: body.crop_right, cropBottom: body.crop_bottom, cropLeft: body.crop_left, zoom: body.zoom, customCss: body.custom_css } }; } function baseUrl(req) { return `${req.protocol}://${req.get("host")}`; } function registerOverlayAdminRoutes(app) { app.get("/admin/overlays", adminOnly, (req, res) => { noStore(res); res.render("admin-overlays", { title: "OBS overlays", overlays: listOverlays({ includeSecrets: true }), baseUrl: baseUrl(req) }); }); app.post("/admin/overlays", adminOnly, (req, res) => redirectWithResult( req, res, "/admin/overlays", () => createOverlay({ name: req.body.name, description: req.body.description, sceneName: req.body.scene_name || "Main" }), "Overlay created." )); app.post("/admin/overlays/reorder", adminOnly, (req, res) => redirectWithResult(req, res, "/admin/overlays", () => reorderOverlays(orderedIds(req.body)), "Overlay order saved.")); app.get("/admin/overlays/:id", requireOverlayAccess(OVERLAY_CAPABILITIES.MANAGE), async (req, res) => { noStore(res); const canViewSecrets = canAccessOverlay(req.session.user, req.params.id, OVERLAY_CAPABILITIES.VIEW_SECRETS); const canManageObs = canAccessOverlay(req.session.user, req.params.id, OVERLAY_CAPABILITIES.MANAGE_OBS); const overlay = getOverlay(req.params.id, { includeSecrets: canViewSecrets }); if (!overlay) return res.status(404).render("error", { title: "Overlay not found", message: "That overlay does not exist." }); res.render("admin-overlay-detail", { title: overlay.name, overlay, canViewSecrets, canManageObs, baseUrl: baseUrl(req), moduleTypes: listOverlayModuleTypes(), editorStateJson: JSON.stringify({ overlayId: overlay.id, canvas: { width: overlay.canvas_width || 1920, height: overlay.canvas_height || 1080 }, activeSceneId: overlay.active_scene_id, scenes: overlay.scenes.map((scene) => ({ id: scene.id, name: scene.name, enabled: scene.enabled, modules: scene.modules.map((module) => ({ id: module.id, name: module.name, type: module.type, renderType: getOverlayModuleType(module.type)?.renderType || module.type, enabled: module.enabled, config: module.config })) })) }).replace(/ redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => updateOverlay(req.params.id, { name: req.body.name, description: req.body.description, enabled: booleanField(req.body, "enabled"), canvasWidth: req.body.canvas_width, canvasHeight: req.body.canvas_height }), "Overlay saved.")); app.post("/admin/overlays/:id/duplicate", requireOverlayAccess(), (req, res) => redirectWithResult(req, res, "/admin/overlays", () => duplicateOverlay(req.params.id, req.body.name), "Overlay duplicated with new private URLs.")); app.post("/admin/overlays/:id/delete", requireOverlayAccess(), (req, res) => redirectWithResult(req, res, "/admin/overlays", () => deleteOverlay(req.params.id), "Overlay deleted.")); app.post("/admin/overlays/:id/token/revoke", requireOverlayAccess(OVERLAY_CAPABILITIES.VIEW_SECRETS), (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => regenerateOverlayToken(req.params.id), "All Browser Source URLs for this overlay were regenerated. The old URLs are revoked.")); app.post("/admin/overlays/:id/active-scene", requireOverlayAccess(OVERLAY_CAPABILITIES.CONTROL_SCENE), (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => setActiveScene(req.params.id, req.body.scene_id, { source: "admin" }), "Active overlay scene changed.")); app.post("/admin/overlays/:id/scenes", requireOverlayAccess(), (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => addScene(req.params.id, { name: req.body.name, obsSceneName: req.body.obs_scene_name }), "Scene created.")); app.post("/admin/overlays/:id/scenes/reorder", requireOverlayAccess(), (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => reorderScenes(req.params.id, orderedIds(req.body)), "Scene order saved.")); app.post("/admin/overlays/:id/scenes/:sceneId", requireOverlayAccess(), requireSceneInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => updateScene(req.params.sceneId, { name: req.body.name, enabled: booleanField(req.body, "enabled"), obsSceneName: req.body.obs_scene_name }), "Scene saved.")); app.post("/admin/overlays/:id/scenes/:sceneId/duplicate", requireOverlayAccess(), requireSceneInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => duplicateScene(req.params.sceneId, req.body.name), "Scene duplicated with a new private URL.")); app.post("/admin/overlays/:id/scenes/:sceneId/delete", requireOverlayAccess(), requireSceneInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => deleteScene(req.params.sceneId), "Scene deleted.")); app.post("/admin/overlays/:id/scenes/:sceneId/token/revoke", requireOverlayAccess(OVERLAY_CAPABILITIES.VIEW_SECRETS), requireSceneInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => regenerateSceneToken(req.params.sceneId), "Fixed scene URL regenerated. The old URL is revoked.")); app.post("/admin/overlays/:id/scenes/:sceneId/modules", requireOverlayAccess(), requireSceneInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => addModule(req.params.sceneId, moduleInput(req.body)), "Source added.")); app.post("/admin/overlays/:id/scenes/:sceneId/modules/reorder", requireOverlayAccess(), requireSceneInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => reorderModules(req.params.sceneId, orderedIds(req.body)), "Layer order saved.")); app.post("/admin/overlays/:id/modules/:moduleId", requireOverlayAccess(), requireModuleInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => updateModule(req.params.moduleId, moduleInput(req.body)), "Source saved.")); app.post("/admin/overlays/:id/modules/:moduleId/duplicate", requireOverlayAccess(), requireModuleInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => duplicateModule(req.params.moduleId, req.body.name), "Source copied.")); app.post("/admin/overlays/:id/modules/:moduleId/delete", requireOverlayAccess(), requireModuleInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => deleteModule(req.params.moduleId), "Source deleted.")); app.post("/api/admin/overlays/:id/modules/:moduleId/transform", requireOverlayAccess(), requireModuleInOverlay, (req, res) => { noStore(res); try { res.json({ ok: true, module: updateModuleTransform(req.params.moduleId, req.body || {}) }); } catch (error) { res.status(400).json({ ok: false, error: error.message }); } }); app.post("/api/admin/overlays/:id/modules/:moduleId/refresh", requireOverlayAccess(), requireModuleInOverlay, (req, res) => { noStore(res); try { refreshModule(req.params.moduleId); res.json({ ok: true, message: "Source refresh requested." }); } catch (error) { res.status(400).json({ ok: false, error: error.message }); } }); app.get("/api/admin/overlays/:id/modules/:moduleId/health", requireOverlayAccess(), requireModuleInOverlay, async (req, res) => { noStore(res); const overlay = getOverlay(req.params.id); const module = overlay?.scenes.flatMap((scene) => scene.modules).find((item) => item.id === req.params.moduleId); if (!module || module.type !== "web" || !module.config?.url) { return res.status(404).json({ ok: false, healthy: false, reason: "Website source unavailable." }); } const result = await probeExternalOverlay(module.config.url); return res.json({ ok: true, ...result }); }); app.post("/admin/overlays/:id/obs", requireOverlayAccess(OVERLAY_CAPABILITIES.MANAGE_OBS), async (req, res) => { try { saveObsSettings(req.params.id, { enabled: booleanField(req.body, "enabled"), provider: req.body.provider, endpoint: req.body.endpoint, password: req.body.password, clearPassword: booleanField(req.body, "clear_password"), syncDirection: req.body.sync_direction, reconnect: booleanField(req.body, "reconnect") }); await overlayConnectorManager.restart(req.params.id); flash(req, "success", "OBS connector settings saved."); } catch (error) { flash(req, "error", error.message); } res.redirect(`/admin/overlays/${req.params.id}`); }); app.get("/api/admin/overlays/:id/obs/status", requireOverlayAccess(OVERLAY_CAPABILITIES.MANAGE_OBS), async (req, res) => { noStore(res); res.json({ ok: true, ...(await overlayConnectorManager.status(req.params.id, { includeScenes: true })) }); }); app.post("/api/admin/overlays/:id/obs/test", requireOverlayAccess(OVERLAY_CAPABILITIES.MANAGE_OBS), async (req, res) => { noStore(res); try { const existing = getObsSettings(req.params.id, { includeSecret: true }); const result = await overlayConnectorManager.test({ overlayId: req.params.id, provider: req.body.provider || existing.provider, endpoint: req.body.endpoint || existing.endpoint, password: req.body.password || existing.password }); res.json(result); } catch (error) { res.status(400).json({ ok: false, error: error.message }); } }); app.post("/api/admin/overlays/:id/obs/scene", requireOverlayAccess(OVERLAY_CAPABILITIES.MANAGE_OBS), async (req, res) => { noStore(res); try { res.json({ ok: true, ...(await overlayConnectorManager.setObsScene(req.params.id, req.body.scene_name)) }); } catch (error) { res.status(400).json({ ok: false, error: error.message }); } }); app.post("/api/admin/overlays/:id/obs/import", requireOverlayAccess(OVERLAY_CAPABILITIES.MANAGE_OBS), async (req, res) => { noStore(res); let syncSuppressed = false; try { const status = await overlayConnectorManager.status(req.params.id, { includeScenes: true }); if (status.state !== "connected" || !status.scenes?.length) { throw new Error("Connect OBS with scene-reading permission before importing its setup."); } overlayConnectorManager.suppressSync(req.params.id, true); syncSuppressed = true; let overlay = getOverlay(req.params.id); let created = 0; let mapped = 0; for (const obsSceneName of status.scenes) { if (overlay.scenes.some((scene) => scene.obs_scene_name === obsSceneName)) continue; const sameName = overlay.scenes.find((scene) => !scene.obs_scene_name && scene.name.toLowerCase() === obsSceneName.toLowerCase()); if (sameName) { updateScene(sameName.id, { name: sameName.name, enabled: sameName.enabled, obsSceneName }); mapped += 1; } else { addScene(req.params.id, { name: obsSceneName, obsSceneName }); created += 1; } overlay = getOverlay(req.params.id); } if (status.canvas_width && status.canvas_height) { updateOverlay(req.params.id, { name: overlay.name, description: overlay.description, enabled: overlay.enabled, canvasWidth: status.canvas_width, canvasHeight: status.canvas_height }); } overlay = getOverlay(req.params.id); const current = overlay.scenes.find((scene) => scene.obs_scene_name === status.current_scene && scene.enabled); if (current && current.id !== overlay.active_scene_id) setActiveScene(req.params.id, current.id, { source: "obs" }); return res.json({ ok: true, created, mapped, canvas_updated: Boolean(status.canvas_width && status.canvas_height), message: `OBS setup applied: ${created} scene${created === 1 ? "" : "s"} added, ${mapped} existing scene${mapped === 1 ? "" : "s"} matched.` }); } catch (error) { return res.status(400).json({ ok: false, error: error.message }); } finally { if (syncSuppressed) overlayConnectorManager.suppressSync(req.params.id, false); } }); } module.exports = { registerOverlayAdminRoutes, registerPublicOverlayRoutes };