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:
2026-09-30 18:29:59 +05:30
parent b296e8a74a
commit c6a2c392d9
9 changed files with 253 additions and 55 deletions

View File

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