mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 16:56:33 +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;
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
// 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<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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue