mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 19:06:34 +00:00
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:
parent
b6e929d2ad
commit
69e51197bf
8 changed files with 124 additions and 9 deletions
|
|
@ -111,9 +111,20 @@ async function uniqueCameraName(deps: AdminDeps, rawName: string): Promise<strin
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Decode XML entities that ONVIF SOAP responses may embed in URIs. */
|
||||||
|
function decodeXmlEntities(raw: string): string {
|
||||||
|
return raw.replace(/&/g, "&").replace(/</g, "<").replace(/>/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 {
|
function rtspWithCredentials(raw: string, username: string, password: string): string {
|
||||||
// ONVIF returns XML — URIs may contain & instead of &
|
const clean = decodeXmlEntities(raw);
|
||||||
let clean = raw.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
||||||
if (!username) return clean;
|
if (!username) return clean;
|
||||||
try {
|
try {
|
||||||
const url = new URL(clean);
|
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 {
|
function formValue(v: FormValue): string {
|
||||||
return Array.isArray(v) ? (v[0] ?? "") : (v ?? "");
|
return Array.isArray(v) ? (v[0] ?? "") : (v ?? "");
|
||||||
}
|
}
|
||||||
|
|
@ -184,6 +212,7 @@ async function importDiscoveredCamera(
|
||||||
): Promise<number | null> {
|
): Promise<number | null> {
|
||||||
if (streams.length === 0) return null;
|
if (streams.length === 0) return null;
|
||||||
const main = streams.find((s) => s.role === "main") ?? streams[0]!;
|
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 mainRtspUrl = rtspWithCredentials(main.stream_uri, username, password);
|
||||||
const name = await uniqueCameraName(deps, rawName || "ONVIF camera");
|
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 width = stream.width == null ? null : Number(stream.width);
|
||||||
const height = stream.height == null ? null : Number(stream.height);
|
const height = stream.height == null ? null : Number(stream.height);
|
||||||
const framerate = stream.framerate == null ? null : Number(stream.framerate);
|
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({
|
await deps.repo.createCameraStream({
|
||||||
camera_id: cam.id,
|
camera_id: cam.id,
|
||||||
role: stream.role === "main" || stream.role === "sub" ? stream.role : "other",
|
role: stream.role === "main" || stream.role === "sub" ? stream.role : "other",
|
||||||
name: stream.profile_name || stream.role,
|
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,
|
profile_token: stream.profile_token || null,
|
||||||
width: Number.isFinite(width) ? width : null,
|
width: Number.isFinite(width) ? width : null,
|
||||||
height: Number.isFinite(height) ? height : null,
|
height: Number.isFinite(height) ? height : null,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,36 @@ import { createHash } from "node:crypto";
|
||||||
import type { Observable } from "@bsb/base";
|
import type { Observable } from "@bsb/base";
|
||||||
import type { Repository } from "./db/repository.js";
|
import type { Repository } from "./db/repository.js";
|
||||||
import type { SecretsApi } from "./secrets.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 {
|
export interface BundleCamera {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -25,6 +55,7 @@ export interface BundleCamera {
|
||||||
id: number;
|
id: number;
|
||||||
role: string;
|
role: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
/** Final playable RTSP URL with properly encoded credentials. */
|
||||||
rtsp_uri: string;
|
rtsp_uri: string;
|
||||||
width: number | null;
|
width: number | null;
|
||||||
height: number | null;
|
height: number | null;
|
||||||
|
|
@ -313,7 +344,9 @@ export async function generateBundle(
|
||||||
id: s.id,
|
id: s.id,
|
||||||
role: s.role,
|
role: s.role,
|
||||||
name: s.name,
|
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,
|
width: s.width,
|
||||||
height: s.height,
|
height: s.height,
|
||||||
encoding: s.encoding,
|
encoding: s.encoding,
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,9 @@ export function rowToCameraStream(r: Row): CameraStream {
|
||||||
name: s(r["name"]),
|
name: s(r["name"]),
|
||||||
profile_token: sn(r["profile_token"]),
|
profile_token: sn(r["profile_token"]),
|
||||||
rtsp_uri: s(r["rtsp_uri"]),
|
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"]),
|
width: nn(r["width"]),
|
||||||
height: nn(r["height"]),
|
height: nn(r["height"]),
|
||||||
encoding: sn(r["encoding"]),
|
encoding: sn(r["encoding"]),
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,9 @@ export const TENANT_MIGRATIONS: readonly string[] = [
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
profile_token TEXT,
|
profile_token TEXT,
|
||||||
rtsp_uri TEXT NOT NULL,
|
rtsp_uri TEXT NOT NULL,
|
||||||
|
rtsp_host TEXT,
|
||||||
|
rtsp_port INTEGER DEFAULT 554,
|
||||||
|
rtsp_path TEXT,
|
||||||
width INTEGER,
|
width INTEGER,
|
||||||
height INTEGER,
|
height INTEGER,
|
||||||
encoding TEXT,
|
encoding TEXT,
|
||||||
|
|
|
||||||
|
|
@ -1088,4 +1088,14 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
|
||||||
UNIQUE(camera_id, topic)
|
UNIQUE(camera_id, topic)
|
||||||
) STRICT`,
|
) STRICT`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_camera_event_subs_camera ON camera_event_subscriptions(camera_id)`,
|
`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");
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1042,6 +1042,9 @@ export class Repository {
|
||||||
name: string;
|
name: string;
|
||||||
rtsp_uri: string;
|
rtsp_uri: string;
|
||||||
profile_token?: string | null;
|
profile_token?: string | null;
|
||||||
|
rtsp_host?: string | null;
|
||||||
|
rtsp_port?: number | null;
|
||||||
|
rtsp_path?: string | null;
|
||||||
width?: number | null;
|
width?: number | null;
|
||||||
height?: number | null;
|
height?: number | null;
|
||||||
encoding?: string | null;
|
encoding?: string | null;
|
||||||
|
|
@ -1051,15 +1054,19 @@ export class Repository {
|
||||||
}): Promise<CameraStream> {
|
}): Promise<CameraStream> {
|
||||||
const result = await this._run(
|
const result = await this._run(
|
||||||
`INSERT INTO camera_streams
|
`INSERT INTO camera_streams
|
||||||
(camera_id, role, name, profile_token, rtsp_uri, width, height,
|
(camera_id, role, name, profile_token, rtsp_uri,
|
||||||
encoding, framerate, bitrate_kbps, is_discovered)
|
rtsp_host, rtsp_port, rtsp_path,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
|
width, height, encoding, framerate, bitrate_kbps, is_discovered)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
|
||||||
[
|
[
|
||||||
input.camera_id,
|
input.camera_id,
|
||||||
input.role,
|
input.role,
|
||||||
input.name,
|
input.name,
|
||||||
input.profile_token ?? null,
|
input.profile_token ?? null,
|
||||||
input.rtsp_uri,
|
input.rtsp_uri,
|
||||||
|
input.rtsp_host ?? null,
|
||||||
|
input.rtsp_port ?? null,
|
||||||
|
input.rtsp_path ?? null,
|
||||||
input.width ?? null,
|
input.width ?? null,
|
||||||
input.height ?? null,
|
input.height ?? null,
|
||||||
input.encoding ?? null,
|
input.encoding ?? null,
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,12 @@ export interface CameraStream {
|
||||||
name: string;
|
name: string;
|
||||||
profile_token: string | null;
|
profile_token: string | null;
|
||||||
rtsp_uri: string;
|
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;
|
width: number | null;
|
||||||
height: number | null;
|
height: number | null;
|
||||||
encoding: string | null;
|
encoding: string | null;
|
||||||
|
|
|
||||||
|
|
@ -1167,7 +1167,7 @@ interface CameraEditProps {
|
||||||
camera: Camera;
|
camera: Camera;
|
||||||
labels: Array<{ label_id: number; name: string }>;
|
labels: Array<{ label_id: number; name: string }>;
|
||||||
allLabels: Label[];
|
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[];
|
subscriptions: CameraSubscription[];
|
||||||
eventSubscriptions?: CameraEventSubscription[];
|
eventSubscriptions?: CameraEventSubscription[];
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|
@ -1454,13 +1454,16 @@ export function CameraEditPage(props: CameraEditProps) {
|
||||||
{props.streams.length > 0 ? (
|
{props.streams.length > 0 ? (
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<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>
|
<tbody>
|
||||||
{props.streams.map((s) => (
|
{props.streams.map((s) => (
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class="badge badge-gray">{s.role}</span></td>
|
<td><span class="badge badge-gray">{s.role}</span></td>
|
||||||
<td>{s.name}</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; 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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue