Compare commits
1 Commits
v0.5.8-dem
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c6a2c392d9 |
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