From 69e51197bf7d8755626c3350f233e09103510ecc Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Tue, 26 May 2026 06:51:33 +0200 Subject: [PATCH] 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) --- .../service-admin-http/routes-admin.ts | 56 ++++++++++++++++++- server/src/shared/bundle.ts | 35 +++++++++++- server/src/shared/db/mappers.ts | 3 + server/src/shared/db/migrations-pg.ts | 3 + server/src/shared/db/migrations.ts | 10 ++++ server/src/shared/db/repository.ts | 13 ++++- server/src/shared/types.ts | 6 ++ server/src/web-templates/admin-pages.tsx | 7 ++- 8 files changed, 124 insertions(+), 9 deletions(-) diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index 5d237d0..09ee114 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -111,9 +111,20 @@ async function uniqueCameraName(deps: AdminDeps, rawName: string): Promise"); +} + +/** + * Build a playable RTSP URL by injecting credentials into a raw URI. + * Used for the legacy `rtsp_uri` column (display / backward compat). + * For ONVIF-discovered streams the NEW path is: store components separately + * and build the final URL at bundle time. This function is still used for + * the camera row's `rtsp_url` and the stream's display-only `rtsp_uri`. + */ function rtspWithCredentials(raw: string, username: string, password: string): string { - // ONVIF returns XML — URIs may contain & instead of & - let clean = raw.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); + const clean = decodeXmlEntities(raw); if (!username) return clean; try { const url = new URL(clean); @@ -126,6 +137,23 @@ function rtspWithCredentials(raw: string, username: string, password: string): s } } +/** Parse an RTSP URI into host / port / path components. */ +function parseRtspComponents(raw: string): { host: string | null; port: number | null; path: string | null } { + const clean = decodeXmlEntities(raw); + try { + const url = new URL(clean); + if (url.protocol !== "rtsp:") return { host: null, port: null, path: null }; + const host = url.hostname || null; + const port = url.port ? Number(url.port) : 554; + // path + decoded query string (no hash — RTSP doesn't use fragments) + let path = url.pathname || "/"; + if (url.search) path += url.search; + return { host, port, path }; + } catch { + return { host: null, port: null, path: null }; + } +} + function formValue(v: FormValue): string { return Array.isArray(v) ? (v[0] ?? "") : (v ?? ""); } @@ -184,6 +212,7 @@ async function importDiscoveredCamera( ): Promise { if (streams.length === 0) return null; const main = streams.find((s) => s.role === "main") ?? streams[0]!; + // Camera row's rtsp_url: full URL with credentials for display / backward compat. const mainRtspUrl = rtspWithCredentials(main.stream_uri, username, password); const name = await uniqueCameraName(deps, rawName || "ONVIF camera"); @@ -200,11 +229,32 @@ async function importDiscoveredCamera( const width = stream.width == null ? null : Number(stream.width); const height = stream.height == null ? null : Number(stream.height); const framerate = stream.framerate == null ? null : Number(stream.framerate); + + // Parse RTSP URI into components. Credentials come from the camera row + // at bundle time — do NOT bake them into the stream's rtsp_uri. + const cleanUri = decodeXmlEntities(stream.stream_uri); + const components = parseRtspComponents(stream.stream_uri); + + // Stream rtsp_uri: store the XML-decoded URI WITHOUT credentials for + // display / backward compat. Bundle generation builds the final + // playable URL from components + camera credentials. + let displayUri = cleanUri; + try { + const parsed = new URL(cleanUri); + // Strip any credentials the ONVIF device may have embedded + parsed.username = ""; + parsed.password = ""; + displayUri = parsed.toString(); + } catch { /* keep cleanUri as-is */ } + await deps.repo.createCameraStream({ camera_id: cam.id, role: stream.role === "main" || stream.role === "sub" ? stream.role : "other", name: stream.profile_name || stream.role, - rtsp_uri: rtspWithCredentials(stream.stream_uri, username, password), + rtsp_uri: displayUri, + rtsp_host: components.host ?? onvifHost, + rtsp_port: components.port ?? 554, + rtsp_path: components.path ?? null, profile_token: stream.profile_token || null, width: Number.isFinite(width) ? width : null, height: Number.isFinite(height) ? height : null, diff --git a/server/src/shared/bundle.ts b/server/src/shared/bundle.ts index 44fc233..28b013b 100644 --- a/server/src/shared/bundle.ts +++ b/server/src/shared/bundle.ts @@ -8,6 +8,36 @@ 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; @@ -25,6 +55,7 @@ export interface BundleCamera { id: number; role: string; name: string; + /** Final playable RTSP URL with properly encoded credentials. */ rtsp_uri: string; width: number | null; height: number | null; @@ -313,7 +344,9 @@ export async function generateBundle( id: s.id, role: s.role, name: s.name, - rtsp_uri: s.rtsp_uri, + // 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, diff --git a/server/src/shared/db/mappers.ts b/server/src/shared/db/mappers.ts index d73bf2a..8fa279b 100644 --- a/server/src/shared/db/mappers.ts +++ b/server/src/shared/db/mappers.ts @@ -184,6 +184,9 @@ export function rowToCameraStream(r: Row): CameraStream { name: s(r["name"]), profile_token: sn(r["profile_token"]), rtsp_uri: s(r["rtsp_uri"]), + rtsp_host: sn(r["rtsp_host"]), + rtsp_port: nn(r["rtsp_port"]), + rtsp_path: sn(r["rtsp_path"]), width: nn(r["width"]), height: nn(r["height"]), encoding: sn(r["encoding"]), diff --git a/server/src/shared/db/migrations-pg.ts b/server/src/shared/db/migrations-pg.ts index 3bae9df..5c2ee92 100644 --- a/server/src/shared/db/migrations-pg.ts +++ b/server/src/shared/db/migrations-pg.ts @@ -178,6 +178,9 @@ export const TENANT_MIGRATIONS: readonly string[] = [ name TEXT NOT NULL, profile_token TEXT, rtsp_uri TEXT NOT NULL, + rtsp_host TEXT, + rtsp_port INTEGER DEFAULT 554, + rtsp_path TEXT, width INTEGER, height INTEGER, encoding TEXT, diff --git a/server/src/shared/db/migrations.ts b/server/src/shared/db/migrations.ts index a7c96d6..cf199bd 100644 --- a/server/src/shared/db/migrations.ts +++ b/server/src/shared/db/migrations.ts @@ -1088,4 +1088,14 @@ export const MIGRATIONS: readonly MigrationEntry[] = [ UNIQUE(camera_id, topic) ) STRICT`, `CREATE INDEX IF NOT EXISTS idx_camera_event_subs_camera ON camera_event_subscriptions(camera_id)`, + + // ---- camera_streams: RTSP component columns for ONVIF-discovered streams --- + // Stores host/port/path separately so bundle generation can build the final + // URL with properly encoded credentials from the camera row. Existing streams + // with only rtsp_uri continue to work (backward compat for RTSP-type cameras). + (db: DatabaseSync) => { + addColumnIfNotExists(db, "camera_streams", "rtsp_host", "TEXT"); + addColumnIfNotExists(db, "camera_streams", "rtsp_port", "INTEGER DEFAULT 554"); + addColumnIfNotExists(db, "camera_streams", "rtsp_path", "TEXT"); + }, ]; diff --git a/server/src/shared/db/repository.ts b/server/src/shared/db/repository.ts index a92a245..c9348a8 100644 --- a/server/src/shared/db/repository.ts +++ b/server/src/shared/db/repository.ts @@ -1042,6 +1042,9 @@ export class Repository { name: string; rtsp_uri: string; profile_token?: string | null; + rtsp_host?: string | null; + rtsp_port?: number | null; + rtsp_path?: string | null; width?: number | null; height?: number | null; encoding?: string | null; @@ -1051,15 +1054,19 @@ export class Repository { }): Promise { const result = await this._run( `INSERT INTO camera_streams - (camera_id, role, name, profile_token, rtsp_uri, width, height, - encoding, framerate, bitrate_kbps, is_discovered) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`, + (camera_id, role, name, profile_token, rtsp_uri, + rtsp_host, rtsp_port, rtsp_path, + width, height, encoding, framerate, bitrate_kbps, is_discovered) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`, [ input.camera_id, input.role, input.name, input.profile_token ?? null, input.rtsp_uri, + input.rtsp_host ?? null, + input.rtsp_port ?? null, + input.rtsp_path ?? null, input.width ?? null, input.height ?? null, input.encoding ?? null, diff --git a/server/src/shared/types.ts b/server/src/shared/types.ts index 10cd08a..9883324 100644 --- a/server/src/shared/types.ts +++ b/server/src/shared/types.ts @@ -139,6 +139,12 @@ export interface CameraStream { name: string; profile_token: string | null; rtsp_uri: string; + /** Extracted host (nullable — falls back to camera's onvif_host). */ + rtsp_host: string | null; + /** RTSP port (nullable — defaults to 554). */ + rtsp_port: number | null; + /** Path + query string from ONVIF discovery (e.g. `/Streaming/Channels/101?transportmode=unicast`). */ + rtsp_path: string | null; width: number | null; height: number | null; encoding: string | null; diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 0e26f65..407b910 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -1167,7 +1167,7 @@ interface CameraEditProps { camera: Camera; labels: Array<{ label_id: number; name: string }>; allLabels: Label[]; - streams: Array<{ id: number; role: string; name: string; rtsp_uri: string }>; + streams: Array<{ id: number; role: string; name: string; rtsp_uri: string; rtsp_host: string | null; rtsp_port: number | null; rtsp_path: string | null }>; subscriptions: CameraSubscription[]; eventSubscriptions?: CameraEventSubscription[]; error?: string; @@ -1454,13 +1454,16 @@ export function CameraEditPage(props: CameraEditProps) { {props.streams.length > 0 ? (
- + {props.streams.map((s) => ( + + + ))}
RoleNameURI
RoleNameURIHostPortPath
{s.role} {s.name} {maskRtspPassword(s.rtsp_uri)}{s.rtsp_host ?? ""}{s.rtsp_port != null ? String(s.rtsp_port) : ""}{s.rtsp_path ?? ""}