Paste the camera's RTSP address instead of taking it apart by hand
Asked directly: "our cameras have an rtsp url, we can use that to connect them to this software right". Yes - and that has always been the mechanism, which is the point. CameraConfig.source() builds exactly that URL from the parts, and CameraConfig.url has always accepted a whole one and taken priority over them. No form ever offered it. So an operator holding the address their camera's own app shows had to split it into five fields by eye. That is where a password containing @ or / goes wrong, and this repository has already been bitten once by unencoded @ in RTSP credentials. parseRtspUrl lives in shared/cameraMakes.js and is imported by BOTH forms, for the same reason the make picker is: two copies would be worse than not offering it, because an operator trusts a filled-in field. A test asserts both import it. Decisions worth keeping: - Split into fields, not stored whole. Everything else on the form - Test, the make picker, editing later, and the rule that a password is never returned to the browser - works on the parts. A URL kept intact would carry the password back out to every screen that reads a camera. - WHATWG splits user info at the LAST @, which is what makes an unencoded @ inside a password parse the way a person means it. An operator doing it by eye would put "p" in the password box and "ssw0rd@192.168.1.121" in the address box. - Percent-encoded credentials are DECODED, because source() encodes again when it rebuilds the URL. Keeping them encoded would double-encode and the camera would refuse a password that is correct. - The scheme is optional, structure is not. Without requiring a slash, "nonsense" parses as a perfectly good hostname and silently fills the Address field with it - a wrong answer that looks like it worked. A bare address is refused too: the Address field already takes one. - A query string stays with the path. Some cameras carry the channel there, and dropping it opens the wrong channel - which looks like a camera pointed somewhere unexpected. - A URL carrying no credentials does not wipe a password already typed. Tested through node from pytest, the same pattern test_dashboard.py uses, and skipped when node is absent so the suite stays dependency-light. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
40
desktop/frontend/dist/assets/index-DSNu_2FW.js
vendored
40
desktop/frontend/dist/assets/index-DSNu_2FW.js
vendored
File diff suppressed because one or more lines are too long
40
desktop/frontend/dist/assets/index-p8f6baZq.js
vendored
Normal file
40
desktop/frontend/dist/assets/index-p8f6baZq.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
desktop/frontend/dist/index.html
vendored
2
desktop/frontend/dist/index.html
vendored
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Behavision</title>
|
||||
<script type="module" crossorigin src="./assets/index-DSNu_2FW.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-p8f6baZq.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DOJ2bRrM.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { api, message } from '../bridge.js'
|
||||
import { usePolled } from '../hooks.js'
|
||||
import * as Icon from '../ui/icons.jsx'
|
||||
import { MAKES, makeById } from '../../../../shared/cameraMakes.js'
|
||||
import { MAKES, makeById, parseRtspUrl } from '../../../../shared/cameraMakes.js'
|
||||
|
||||
// The camera screen is a picture, not a settings table.
|
||||
//
|
||||
@@ -253,6 +253,27 @@ function CameraSheet({ cam, onClose, onSaved }) {
|
||||
|
||||
const chosen = makeById(make)
|
||||
|
||||
// Paste the whole RTSP URL. It is how people actually hold this
|
||||
// information - it is what the camera's own app shows and what an installer
|
||||
// writes down - and splitting it into five fields by eye is exactly where a
|
||||
// password containing `@` or `/` goes wrong.
|
||||
const [pasted, setPasted] = useState('')
|
||||
const [pasteError, setPasteError] = useState('')
|
||||
function applyUrl(text) {
|
||||
setPasted(text)
|
||||
if (!text.trim()) { setPasteError(''); return }
|
||||
const got = parseRtspUrl(text)
|
||||
if (!got) { setPasteError('That does not look like an RTSP address.'); return }
|
||||
setPasteError('')
|
||||
setTest(null)
|
||||
// Only what the URL actually carried: a URL with no credentials must not
|
||||
// wipe a password the operator typed above it.
|
||||
setF(prev => ({ ...prev, host: got.host, port: got.port, path: got.path,
|
||||
...(got.username ? { username: got.username } : {}),
|
||||
...(got.password ? { password: got.password } : {}) }))
|
||||
setMake('manual')
|
||||
}
|
||||
|
||||
// The camera is picked from a scan of the shop's network rather than typed.
|
||||
// Nobody knows their camera's address; the sticker is under the camera and
|
||||
// the menu is different in every make's app. The scan names ONVIF cameras
|
||||
@@ -316,6 +337,20 @@ function CameraSheet({ cam, onClose, onSaved }) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isNew && !local && (
|
||||
<section className="formsection">
|
||||
<h4>Paste its address</h4>
|
||||
<label className="field"><span>RTSP address</span>
|
||||
<input className="mono" value={pasted} onChange={e => applyUrl(e.target.value)}
|
||||
placeholder="rtsp://admin:password@192.168.1.20:554/ch0_0.264"
|
||||
autoComplete="off" name="rtsp-url" spellCheck="false" />
|
||||
<em className="hint">{pasteError
|
||||
? pasteError
|
||||
: 'If the camera’s own app shows an RTSP address, paste it here and the fields below fill in. Otherwise leave this empty and fill them in yourself.'}</em>
|
||||
</label>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="formsection">
|
||||
<h4>The camera</h4>
|
||||
{isNew && (
|
||||
|
||||
File diff suppressed because one or more lines are too long
2
server/internal/web/dist/index.html
vendored
2
server/internal/web/dist/index.html
vendored
@@ -6,7 +6,7 @@
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<title>Behavision</title>
|
||||
<script type="module" crossorigin src="/assets/index-BuVkBNdU.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BAlXnnln.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D4KGRSVS.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -79,3 +79,48 @@ export const MAKES = [
|
||||
]
|
||||
|
||||
export const makeById = (id) => MAKES.find(m => m.id === id) || MAKES[MAKES.length - 1]
|
||||
|
||||
// Paste the camera's RTSP URL, rather than taking it apart by hand.
|
||||
//
|
||||
// The engine has always accepted a whole URL (`CameraConfig.url` wins over the
|
||||
// parts) and no form has ever offered one - the same gap as the webcam option,
|
||||
// and it costs more here. A URL is how people actually HAVE this information:
|
||||
// it is what the camera's own app shows, what an installer writes down and
|
||||
// what gets pasted into a message. Splitting it into five fields by eye is
|
||||
// where a password containing `@` or `/` goes wrong, and this repository
|
||||
// already records a whole class of bug from unencoded `@` in RTSP credentials.
|
||||
//
|
||||
// Split into fields rather than stored whole, deliberately: everything else on
|
||||
// the form - Test, the make picker, editing later, and the rule that a
|
||||
// password is never returned to the browser - works on the parts. A URL kept
|
||||
// intact would carry the password back out to every screen that reads a
|
||||
// camera.
|
||||
export function parseRtspUrl(raw) {
|
||||
const text = String(raw || '').trim()
|
||||
if (!text) return null
|
||||
const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(text)
|
||||
// A bare `host/path` is a reasonable thing to paste, so the scheme is
|
||||
// optional - but something has to mark this as a URL rather than a word.
|
||||
// Without the slash test, `nonsense` parses as a perfectly good hostname
|
||||
// and silently fills the Address field with it: a wrong answer that looks
|
||||
// like it worked, which is worse than refusing.
|
||||
if (!hasScheme && !text.includes('/')) return null
|
||||
const withScheme = hasScheme ? text : 'rtsp://' + text
|
||||
let u
|
||||
try {
|
||||
u = new URL(withScheme)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!u.hostname) return null
|
||||
// WHATWG splits user info at the LAST `@`, which is what makes an unencoded
|
||||
// `@` inside a password parse the way a person means it.
|
||||
const out = {
|
||||
host: u.hostname,
|
||||
port: Number(u.port) || 554,
|
||||
path: (u.pathname || '') + (u.search || '') || '/',
|
||||
username: decodeURIComponent(u.username || ''),
|
||||
password: decodeURIComponent(u.password || ''),
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
91
tests/test_rtsp_paste.py
Normal file
91
tests/test_rtsp_paste.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""`shared/cameraMakes.js` is imported by BOTH camera forms and has no test
|
||||
runner of its own. This is the same safety net `test_dashboard.py` provides:
|
||||
node is driven from pytest and the check is skipped when node is absent, so
|
||||
the suite stays dependency-light.
|
||||
|
||||
What it guards is the field people actually have. The engine has always
|
||||
accepted a whole RTSP URL (`CameraConfig.url` wins over the parts) and no form
|
||||
ever offered one, so an operator holding the address their camera's own app
|
||||
shows had to take it apart into five fields by eye — which is exactly where a
|
||||
password containing `@` goes wrong, a class of bug this repository has already
|
||||
been bitten by once.
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SHARED = ROOT / "shared" / "cameraMakes.js"
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("node") is None,
|
||||
reason="node not installed")
|
||||
|
||||
|
||||
def parse(text):
|
||||
out = subprocess.run(
|
||||
["node", "--input-type=module", "-e",
|
||||
f"import {{ parseRtspUrl }} from {json.dumps(str(SHARED))};"
|
||||
f"console.log(JSON.stringify(parseRtspUrl({json.dumps(text)})))"],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
assert out.returncode == 0, out.stderr
|
||||
return json.loads(out.stdout.strip())
|
||||
|
||||
|
||||
def test_a_plain_url_becomes_the_five_fields():
|
||||
assert parse("rtsp://admin:Pass123@192.168.1.121:554/ch0_0.264") == {
|
||||
"host": "192.168.1.121", "port": 554, "path": "/ch0_0.264",
|
||||
"username": "admin", "password": "Pass123"}
|
||||
|
||||
|
||||
def test_an_at_sign_in_the_password_survives():
|
||||
"""The one that matters. A URL is split at the LAST `@`, which is what
|
||||
makes an unencoded `@` inside a password parse the way a person means it -
|
||||
and an operator splitting this by eye would put `p` in the password box
|
||||
and `ssw0rd@192.168.1.121` in the address box."""
|
||||
got = parse("rtsp://admin:p@ssw0rd@192.168.1.121:554/Streaming/Channels/101")
|
||||
assert got["password"] == "p@ssw0rd"
|
||||
assert got["host"] == "192.168.1.121"
|
||||
|
||||
|
||||
def test_a_percent_encoded_password_is_decoded():
|
||||
"""Stored decoded, because CameraConfig.source() percent-encodes when it
|
||||
rebuilds the URL. Keeping it encoded would double-encode it and the camera
|
||||
would refuse a password that is correct."""
|
||||
assert parse("rtsp://admin:p%40ss@10.0.0.5/live")["password"] == "p@ss"
|
||||
|
||||
|
||||
def test_the_default_port_is_filled_in():
|
||||
assert parse("rtsp://192.168.1.122/ch0_1.264")["port"] == 554
|
||||
|
||||
|
||||
def test_a_query_string_stays_with_the_path():
|
||||
"""Some cameras carry the channel in a query. Dropping it opens the wrong
|
||||
channel, which looks like a camera pointed somewhere unexpected."""
|
||||
assert parse("rtsp://cam.local:8554/live?channel=1")["path"] == "/live?channel=1"
|
||||
|
||||
|
||||
def test_a_scheme_is_optional_but_structure_is_not():
|
||||
assert parse("192.168.1.122/ch0_1.264")["host"] == "192.168.1.122"
|
||||
# A bare word parses as a perfectly good hostname, so without this it
|
||||
# would silently fill the Address field with it - a wrong answer that
|
||||
# looks like it worked, which is worse than refusing.
|
||||
assert parse("nonsense") is None
|
||||
assert parse("192.168.1.121") is None, "a bare address is not a URL; the Address field takes it"
|
||||
|
||||
|
||||
def test_nothing_is_nothing():
|
||||
assert parse("") is None
|
||||
assert parse(" ") is None
|
||||
|
||||
|
||||
def test_both_forms_import_it():
|
||||
"""Two copies of this would be worse than not offering it, because an
|
||||
operator trusts a filled-in field. Same rule as the make picker."""
|
||||
for form in (ROOT / "desktop/frontend/src/views/Cameras.jsx",
|
||||
ROOT / "web/src/views/CameraSetup.jsx"):
|
||||
src = form.read_text(encoding="utf-8")
|
||||
assert "parseRtspUrl" in src, f"{form.name} does not offer the paste field"
|
||||
assert "cameraMakes.js" in src, f"{form.name} defines its own parser"
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
import { MAKES, makeById } from '../../../shared/cameraMakes.js'
|
||||
import { MAKES, makeById, parseRtspUrl } from '../../../shared/cameraMakes.js'
|
||||
|
||||
// Setting up a camera, for somebody who has never done it.
|
||||
//
|
||||
@@ -30,6 +30,25 @@ export default function CameraSetup({ sites, existing, onClose, onSaved }) {
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const set = (k) => (e) => setForm(f => ({ ...f, [k]: e.target.value }))
|
||||
|
||||
// Paste the whole RTSP address. It is how people actually hold this
|
||||
// information - it is what the camera's own app shows and what an installer
|
||||
// writes down - and splitting it into five fields by eye is where a
|
||||
// password containing `@` or `/` goes wrong.
|
||||
const [pasted, setPasted] = useState('')
|
||||
const [pasteError, setPasteError] = useState('')
|
||||
const applyUrl = (text) => {
|
||||
setPasted(text)
|
||||
if (!text.trim()) { setPasteError(''); return }
|
||||
const got = parseRtspUrl(text)
|
||||
if (!got) { setPasteError('That does not look like an RTSP address.'); return }
|
||||
setPasteError('')
|
||||
// Only what the URL actually carried: one with no credentials must not
|
||||
// wipe a password already typed.
|
||||
setForm(f => ({ ...f, make: 'manual', host: got.host, port: got.port, path: got.path,
|
||||
...(got.username ? { username: got.username } : {}),
|
||||
...(got.password ? { password: got.password } : {}) }))
|
||||
}
|
||||
const chooseMake = (e) => {
|
||||
const m = makeById(e.target.value)
|
||||
// Only overwrite the path when the preset has one, so choosing "I know the
|
||||
@@ -115,6 +134,14 @@ export default function CameraSetup({ sites, existing, onClose, onSaved }) {
|
||||
|
||||
{step === 1 && (
|
||||
<div className="drawer-body">
|
||||
<label>Paste the camera’s RTSP address, if you have one
|
||||
<input className="mono" value={pasted} onChange={(e) => applyUrl(e.target.value)}
|
||||
placeholder="rtsp://admin:password@192.168.0.138:554/ch0_0.264"
|
||||
autoComplete="off" name="rtsp-url" spellCheck="false" />
|
||||
<span className="hint">{pasteError
|
||||
? pasteError
|
||||
: 'Optional. Paste it and the fields below fill in; otherwise fill them in yourself.'}</span>
|
||||
</label>
|
||||
<label>Camera’s address on the shop’s network
|
||||
<input value={form.host} onChange={set('host')}
|
||||
placeholder="192.168.0.138" autoFocus />
|
||||
|
||||
Reference in New Issue
Block a user