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>
This commit is contained in:
Mitchell R 2026-05-26 06:51:33 +02:00
parent b6e929d2ad
commit 69e51197bf
No known key found for this signature in database
8 changed files with 124 additions and 9 deletions

View file

@ -111,9 +111,20 @@ async function uniqueCameraName(deps: AdminDeps, rawName: string): Promise<strin
return name;
}
/** Decode XML entities that ONVIF SOAP responses may embed in URIs. */
function decodeXmlEntities(raw: string): string {
return raw.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">");
}
/**
* 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 &amp; instead of &
let clean = raw.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/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<number | null> {
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,

View file

@ -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,

View file

@ -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"]),

View file

@ -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,

View file

@ -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");
},
];

View file

@ -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<CameraStream> {
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,

View file

@ -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;

View file

@ -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 ? (
<div class="table-wrap">
<table>
<thead><tr><th>Role</th><th>Name</th><th>URI</th></tr></thead>
<thead><tr><th>Role</th><th>Name</th><th>URI</th><th>Host</th><th>Port</th><th>Path</th></tr></thead>
<tbody>
{props.streams.map((s) => (
<tr>
<td><span class="badge badge-gray">{s.role}</span></td>
<td>{s.name}</td>
<td style="font-size:0.8rem; word-break:break-all">{maskRtspPassword(s.rtsp_uri)}</td>
<td style="font-size:0.8rem">{s.rtsp_host ?? ""}</td>
<td style="font-size:0.8rem">{s.rtsp_port != null ? String(s.rtsp_port) : ""}</td>
<td style="font-size:0.8rem; word-break:break-all">{s.rtsp_path ?? ""}</td>
</tr>
))}
</tbody>