BetterFrame/server/src/shared/bundle.ts
Mitchell R 69e51197bf
refactor(streams): store RTSP components separately for ONVIF cameras
ONVIF-discovered camera streams now store rtsp_host, rtsp_port, and
rtsp_path as separate columns instead of baking credentials into a
pre-built URL. This fixes XML entity issues (&), special character
password breakage, and credential duplication across streams.

Bundle generation builds the final playable URL at delivery time using
components + camera row credentials with proper URL encoding. Existing
RTSP-type cameras with only rtsp_uri continue to work unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 06:51:33 +02:00

401 lines
13 KiB
TypeScript

/**
* Bundle generation — display-chain routing.
*
* kiosk.display_id → layouts for display → cells → cameras
* No label filtering for v0.1.
*/
import { createHash } from "node:crypto";
import type { Observable } from "@bsb/base";
import type { Repository } from "./db/repository.js";
import type { SecretsApi } from "./secrets.js";
import type { Camera, CameraStream } from "./types.js";
/**
* Build a playable RTSP URL from stream component columns + camera credentials.
* If the stream has rtsp_host/rtsp_path set (ONVIF-discovered), constructs the
* URL from components with properly URL-encoded username and password from the
* camera row. Otherwise falls back to the stream's rtsp_uri as-is (backward
* compat for RTSP-type cameras and legacy data).
*/
function buildStreamRtspUri(stream: CameraStream, cam: Camera): string {
// Only build from components if both host and path are present
if (stream.rtsp_host && stream.rtsp_path != null) {
const host = stream.rtsp_host;
const port = stream.rtsp_port ?? 554;
const path = stream.rtsp_path.startsWith("/") ? stream.rtsp_path : `/${stream.rtsp_path}`;
// Inject credentials from the camera row
let userinfo = "";
if (cam.onvif_username) {
const user = encodeURIComponent(cam.onvif_username);
const pass = cam.onvif_password ? encodeURIComponent(cam.onvif_password) : "";
userinfo = pass ? `${user}:${pass}@` : `${user}@`;
}
const portSuffix = port === 554 ? "" : `:${String(port)}`;
return `rtsp://${userinfo}${host}${portSuffix}${path}`;
}
// Backward compat: use the stored rtsp_uri as-is
return stream.rtsp_uri;
}
export interface BundleCamera {
id: number;
name: string;
type: string;
rtsp_url: string | null;
onvif_host: string | null;
onvif_port: number | null;
onvif_username: string | null;
onvif_password_encrypted: string | null;
event_source: string;
event_sink: string;
stream_policy: string;
streams: Array<{
id: number;
role: string;
name: string;
/** Final playable RTSP URL with properly encoded credentials. */
rtsp_uri: string;
width: number | null;
height: number | null;
encoding: string | null;
framerate: number | null;
}>;
}
export interface BundleCell {
row: number;
col: number;
row_span: number;
col_span: number;
content_type: string;
camera_id: number | null;
stream_selector: string | null;
web_url: string | null;
html_content: string | null;
cooling_timeout_seconds: number | null;
fit: "cover" | "contain" | "fill";
/** Smart URL action steps — automated login/navigation sequence. */
smart_url?: {
steps: Array<{
type: string;
url?: string;
selector?: string;
value?: string;
value_encrypted?: string;
delay_ms?: number;
timeout_ms?: number;
script?: string;
}>;
login_detect_url?: string;
session_check_interval_ms?: number;
};
}
export interface BundleLayout {
id: number;
name: string;
/** Computed from cells: max(col + col_span). 1 if no cells. */
grid_cols: number;
/** Computed from cells: max(row + row_span). 1 if no cells. */
grid_rows: number;
priority: string;
cooling_timeout_seconds: number | null;
preload_camera_ids: number[];
resets_idle_timer: boolean;
/** True if the kiosk's display has this layout as its default_layout_id. */
is_default: boolean;
cells: BundleCell[];
}
export interface BundleDisplay {
id: number;
name: string;
width_px: number;
height_px: number;
idle_timeout_seconds: number;
sleep_timeout_seconds: number;
default_layout_id: number | null;
}
export interface BundleDisplayWithLayouts extends BundleDisplay {
layouts: BundleLayout[];
}
export interface BundleGpioBinding {
id: number;
chip: string;
pin: number;
direction: "in" | "out";
pull: "up" | "down" | "none" | null;
edge: "rising" | "falling" | "both" | null;
topic: string;
}
export interface KioskBundle {
kiosk_id: number;
kiosk_name: string;
/**
* @deprecated Use `displays` (array). Kept for backward compat with older
* kiosk builds that consume a single display. Mirrors `displays[0]`.
*/
display: BundleDisplay;
/**
* @deprecated Use `displays[N].layouts`. Mirrors `displays[0].layouts` for
* older kiosk builds.
*/
layouts: BundleLayout[];
/** All physical displays driven by this kiosk. New (multi-display) shape. */
displays: BundleDisplayWithLayouts[];
cameras: BundleCamera[];
gpio_bindings: BundleGpioBinding[];
version: string;
}
export async function generateBundle(
repo: Repository,
secrets: SecretsApi,
kioskId: number,
clusterKey: string | undefined,
obs?: Observable,
): Promise<KioskBundle | null> {
const span = obs?.startSpan("generateBundle", { "kiosk.id": kioskId });
const kiosk = await repo.getKioskById(kioskId);
if (!kiosk) {
span?.log.info("bundle: kiosk {id} not found", { id: String(kioskId) });
span?.end();
return null;
}
// Per-kiosk encryption key (preferred) — decrypt from server storage.
let kioskEncryptKey: string | undefined;
if (kiosk.encrypt_key_encrypted) {
try {
kioskEncryptKey = secrets.decryptString(kiosk.encrypt_key_encrypted, "kiosk-encrypt");
} catch {
// Decrypt failed — fall back to cluster key.
}
}
// Find all displays for this kiosk (displays now point to kiosks via kiosk_id)
const kioskDisplays = await repo.listDisplaysForKiosk(kioskId);
// Fall back to legacy kiosk.display_id if no displays point to this kiosk yet
const allDisplays = kioskDisplays.length > 0
? kioskDisplays
: (kiosk.display_id ? [await repo.getDisplayById(kiosk.display_id)].filter((d): d is NonNullable<typeof d> => d != null) : []);
// Admin can disable a display — kiosk must never open a window on it.
const displays = allDisplays.filter((d) => d.is_enabled);
if (displays.length === 0) {
span?.log.info("bundle: kiosk {id} has no enabled displays", { id: String(kioskId) });
span?.end();
return null;
}
// Collect camera IDs across ALL displays' layouts (de-duped).
const allLayoutIds = new Set<number>();
for (const d of displays) {
for (const l of await repo.layoutsForDisplayId(d.id)) allLayoutIds.add(l.id);
}
const cameras = await repo.camerasForLayoutIds([...allLayoutIds]);
async function buildLayouts(displayId: number, defaultLayoutId: number | null): Promise<BundleLayout[]> {
const layouts = await repo.layoutsForDisplayId(displayId);
const result: BundleLayout[] = [];
for (const l of layouts) {
const cells = await repo.layoutCells(l.id);
let gridCols = 1;
let gridRows = 1;
for (const c of cells) {
const right = c.col + c.col_span;
const bottom = c.row + c.row_span;
if (right > gridCols) gridCols = right;
if (bottom > gridRows) gridRows = bottom;
}
const bundleCells: BundleCell[] = [];
for (const c of cells) {
// If the cell has an entity, prefer its current content so admin
// edits to the entity propagate without forcing a cell-touch. The
// bundle still ships the legacy camera_id/web_url/html_content shape
// so the existing Rust kiosk consumes it unchanged.
let contentType = c.content_type;
let cameraId = c.camera_id;
let webUrl = c.web_url;
let htmlContent = c.html_content;
if (c.entity_id != null) {
const ent = await repo.getEntityById(c.entity_id);
if (ent) {
// Dashboard entities are surfaced to the kiosk as `web` cells
// pointing at /dash/<dashboard_id> — kiosk WebKit handles them
// identically to user-supplied web cells.
contentType = ent.type === "dashboard" ? "web" : ent.type;
cameraId = ent.type === "camera" ? ent.camera_id : null;
webUrl =
ent.type === "web" ? ent.web_url :
ent.type === "dashboard" && ent.dashboard_id ? `/dash/${ent.dashboard_id}` :
null;
htmlContent = ent.type === "html" ? ent.html_content : null;
}
}
bundleCells.push({
row: c.row,
col: c.col,
row_span: c.row_span,
col_span: c.col_span,
content_type: contentType,
camera_id: cameraId,
stream_selector: c.stream_selector,
web_url: webUrl,
html_content: htmlContent,
cooling_timeout_seconds: c.cooling_timeout_seconds,
fit: c.fit,
// Smart URL: encrypted credentials use per-kiosk key so each
// kiosk's bundle has uniquely encrypted values.
smart_url: c.options?.["smart_url"] ? (() => {
const raw = c.options["smart_url"] as any;
const steps = Array.isArray(raw.steps) ? raw.steps.map((s: any) => {
const step = { ...s };
// Encrypt plaintext values with per-kiosk key for transport.
const ek = kioskEncryptKey ?? clusterKey;
if (step.value && step.type === "fill" && ek) {
step.value_encrypted = secrets.encryptForCluster(step.value, ek);
delete step.value;
}
return step;
}) : [];
return {
steps,
login_detect_url: raw.login_detect_url,
session_check_interval_ms: raw.session_check_interval_ms,
};
})() : undefined,
});
}
result.push({
id: l.id,
name: l.name,
grid_cols: gridCols,
grid_rows: gridRows,
priority: l.priority,
cooling_timeout_seconds: l.cooling_timeout_seconds,
preload_camera_ids: l.preload_camera_ids,
resets_idle_timer: l.resets_idle_timer,
is_default: defaultLayoutId === l.id,
cells: bundleCells,
});
}
return result;
}
const bundleDisplays: BundleDisplayWithLayouts[] = [];
for (const display of displays) {
bundleDisplays.push({
id: display.id,
name: display.name,
width_px: display.width_px,
height_px: display.height_px,
idle_timeout_seconds: display.idle_timeout_seconds,
sleep_timeout_seconds: display.sleep_timeout_seconds,
default_layout_id: display.default_layout_id,
layouts: await buildLayouts(display.id, display.default_layout_id),
});
}
const bundleCameras: BundleCamera[] = [];
for (const cam of cameras) {
const streams = await repo.listCameraStreams(cam.id);
const effectiveStreams = streams.length > 0 ? streams : (
cam.type === "rtsp" && cam.rtsp_url
? [{
id: 0,
role: "main" as const,
name: "Main",
rtsp_uri: cam.rtsp_url,
width: null,
height: null,
encoding: null,
framerate: null,
}]
: []
);
// Encrypt camera password with per-kiosk key if available (stronger
// isolation — compromised SD only exposes this kiosk's cameras). Falls
// back to shared cluster_key for kiosks that paired before per-kiosk
// keys were introduced.
let onvifPwEncrypted: string | null = null;
const encryptKey = kioskEncryptKey ?? clusterKey;
if (cam.onvif_password && encryptKey) {
onvifPwEncrypted = secrets.encryptForCluster(cam.onvif_password, encryptKey);
}
bundleCameras.push({
id: cam.id,
name: cam.name,
type: cam.type,
rtsp_url: cam.rtsp_url,
onvif_host: cam.onvif_host,
onvif_port: cam.onvif_port,
onvif_username: cam.onvif_username,
onvif_password_encrypted: onvifPwEncrypted,
event_source: cam.event_source,
event_sink: cam.event_sink,
stream_policy: cam.stream_policy,
streams: effectiveStreams.map((s) => ({
id: s.id,
role: s.role,
name: s.name,
// Build final playable URL from components + camera credentials
// when available; falls back to stored rtsp_uri for backward compat.
rtsp_uri: "rtsp_host" in s ? buildStreamRtspUri(s as CameraStream, cam) : s.rtsp_uri,
width: s.width,
height: s.height,
encoding: s.encoding,
framerate: s.framerate,
})),
});
}
const gpioBindings: BundleGpioBinding[] = (await repo.listGpioBindings(kioskId)).map((g) => ({
id: g.id,
chip: g.chip,
pin: g.pin,
direction: g.direction,
pull: g.pull,
edge: g.edge,
topic: g.topic,
}));
// Mirror first display into the legacy top-level `display` + `layouts` so
// older kiosk builds keep working unchanged. New builds should read
// `displays`.
const primary = bundleDisplays[0]!;
const bundle: KioskBundle = {
kiosk_id: kioskId,
kiosk_name: kiosk.name,
display: {
id: primary.id,
name: primary.name,
width_px: primary.width_px,
height_px: primary.height_px,
idle_timeout_seconds: primary.idle_timeout_seconds,
sleep_timeout_seconds: primary.sleep_timeout_seconds,
default_layout_id: primary.default_layout_id,
},
layouts: primary.layouts,
displays: bundleDisplays,
cameras: bundleCameras,
gpio_bindings: gpioBindings,
version: "",
};
bundle.version = createHash("sha256")
.update(JSON.stringify(bundle))
.digest("hex");
span?.log.info("bundle generated for kiosk {id} version {ver}", {
id: String(kioskId),
ver: bundle.version.slice(0, 12),
});
span?.end();
return bundle;
}