diff --git a/server/Cargo.lock b/server/Cargo.lock index 4efb270..f0913c3 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1254,6 +1254,7 @@ dependencies = [ "tempfile", "time", "tokio", + "url", "uuid", ] diff --git a/server/db/Cargo.toml b/server/db/Cargo.toml index e2e29c7..7cabd26 100644 --- a/server/db/Cargo.toml +++ b/server/db/Cargo.toml @@ -41,6 +41,7 @@ smallvec = "1.0" tempfile = "3.2.0" time = "0.1" tokio = { version = "1.0", features = ["macros", "parking_lot", "rt-multi-thread", "sync"] } +url = "2.1.1" uuid = { version = "0.8", features = ["std", "v4"] } itertools = "0.10.0" diff --git a/server/db/db.rs b/server/db/db.rs index 49d7049..3a95dc9 100644 --- a/server/db/db.rs +++ b/server/db/db.rs @@ -54,6 +54,7 @@ use std::str; use std::string::String; use std::sync::Arc; use std::vec::Vec; +use url::Url; use uuid::Uuid; /// Expected schema version. See `guide/schema.md` for more information. @@ -499,7 +500,7 @@ pub struct LiveSegment { #[derive(Clone, Debug, Default)] pub struct StreamChange { pub sample_file_dir_id: Option, - pub rtsp_url: String, + pub rtsp_url: Option, pub record: bool, pub flush_if_sec: i64, } @@ -681,7 +682,7 @@ impl StreamStateChanger { } } if !have_data - && sc.rtsp_url.is_empty() + && sc.rtsp_url.is_none() && sc.sample_file_dir_id.is_none() && !sc.record { @@ -709,7 +710,7 @@ impl StreamStateChanger { "#, )?; let rows = stmt.execute(named_params! { - ":rtsp_url": &sc.rtsp_url, + ":rtsp_url": &sc.rtsp_url.as_ref().map(Url::as_str), ":record": sc.record, ":flush_if_sec": sc.flush_if_sec, ":sample_file_dir_id": sc.sample_file_dir_id, @@ -723,7 +724,7 @@ impl StreamStateChanger { streams.push((sid, Some((camera_id, type_, sc)))); } } else { - if sc.rtsp_url.is_empty() && sc.sample_file_dir_id.is_none() && !sc.record { + if sc.rtsp_url.is_none() && sc.sample_file_dir_id.is_none() && !sc.record { // Do nothing; there is no record and we want to keep it that way. continue; } @@ -742,7 +743,7 @@ impl StreamStateChanger { ":camera_id": camera_id, ":sample_file_dir_id": sc.sample_file_dir_id, ":type": type_.as_str(), - ":rtsp_url": &sc.rtsp_url, + ":rtsp_url": &sc.rtsp_url.as_ref().map(Url::as_str), ":record": sc.record, ":flush_if_sec": sc.flush_if_sec, })?; @@ -767,7 +768,7 @@ impl StreamStateChanger { type_, camera_id, sample_file_dir_id: sc.sample_file_dir_id, - rtsp_url: mem::replace(&mut sc.rtsp_url, String::new()), + rtsp_url: sc.rtsp_url.take().map(String::from).unwrap_or_default(), retain_bytes: 0, flush_if_sec: sc.flush_if_sec, range: None, @@ -790,10 +791,10 @@ impl StreamStateChanger { }); } (Entry::Vacant(_), None) => {} - (Entry::Occupied(e), Some((_, _, sc))) => { + (Entry::Occupied(e), Some((_, _, mut sc))) => { let e = e.into_mut(); e.sample_file_dir_id = sc.sample_file_dir_id; - e.rtsp_url = sc.rtsp_url; + e.rtsp_url = sc.rtsp_url.take().map(String::from).unwrap_or_default(); e.record = sc.record; e.flush_if_sec = sc.flush_if_sec; } diff --git a/server/db/testutil.rs b/server/db/testutil.rs index 50d9fcb..cb59f5c 100644 --- a/server/db/testutil.rs +++ b/server/db/testutil.rs @@ -89,7 +89,7 @@ impl TestDb { streams: [ db::StreamChange { sample_file_dir_id: Some(sample_file_dir_id), - rtsp_url: "rtsp://test-camera/main".to_owned(), + rtsp_url: Some(url::Url::parse("rtsp://test-camera/main").unwrap()), record: true, flush_if_sec, }, diff --git a/server/src/cmds/config/cameras.rs b/server/src/cmds/config/cameras.rs index 368df52..8edc0ee 100644 --- a/server/src/cmds/config/cameras.rs +++ b/server/src/cmds/config/cameras.rs @@ -5,17 +5,17 @@ use crate::stream::{self, Opener}; use base::strutil::{decode_size, encode_size}; use cursive::traits::{Boxable, Finder, Identifiable}; -use cursive::views; +use cursive::views::{self, ViewRef}; use cursive::Cursive; use db::writer; -use failure::Error; +use failure::{bail, Error, ResultExt}; use std::collections::BTreeMap; use std::str::FromStr; use std::sync::Arc; use url::Url; /// Builds a `CameraChange` from an active `edit_camera_dialog`. -fn get_change(siv: &mut Cursive) -> db::CameraChange { +fn get_change(siv: &mut Cursive) -> Result { // Note: these find_name calls are separate statements, which seems to be important: // https://github.com/gyscos/Cursive/issues/144 let sn = siv @@ -62,49 +62,75 @@ fn get_change(siv: &mut Cursive) -> db::CameraChange { streams: Default::default(), }; for &t in &db::ALL_STREAM_TYPES { - let u = siv - .find_name::(&format!("{}_rtsp_url", t.as_str())) - .unwrap() - .get_content() - .as_str() - .into(); - let r = siv + let rtsp_url = parse_url( + siv.find_name::(&format!("{}_rtsp_url", t.as_str())) + .unwrap() + .get_content() + .as_str(), + )?; + let record = siv .find_name::(&format!("{}_record", t.as_str())) .unwrap() .is_checked(); - let f = i64::from_str( + let flush_if_sec = i64::from_str( siv.find_name::(&format!("{}_flush_if_sec", t.as_str())) .unwrap() .get_content() .as_str(), ) .unwrap_or(0); - let d = *siv + let sample_file_dir_id = *siv .find_name::>>(&format!("{}_sample_file_dir", t.as_str())) .unwrap() .selection() .unwrap(); c.streams[t.index()] = db::StreamChange { - rtsp_url: u, - sample_file_dir_id: d, - record: r, - flush_if_sec: f, + rtsp_url, + sample_file_dir_id, + record, + flush_if_sec, }; } - c + Ok(c) +} + +/// Attempts to parse a URL field into a sort-of-validated URL. +fn parse_url(raw: &str) -> Result, Error> { + if raw.is_empty() { + return Ok(None); + } + let url = url::Url::parse(&raw).with_context(|_| format!("can't parse {:?} as URL", &raw))?; + if url.scheme() != "rtsp" { + bail!("Expected URL scheme rtsp:// in URL {}", &url); + } + if !url.username().is_empty() || url.password().is_some() { + bail!( + "Unexpected credentials in URL {}; use the username and password fields instead", + &url + ); + } + Ok(Some(url)) } fn press_edit(siv: &mut Cursive, db: &Arc, id: Option) { - let change = get_change(siv); - - let result = { + let result = (|| { + let change = get_change(siv)?; + for (i, stream) in change.streams.iter().enumerate() { + if stream.record && (stream.rtsp_url.is_none() || stream.sample_file_dir_id.is_none()) { + let type_ = db::StreamType::from_index(i).unwrap(); + bail!( + "Can't record {} stream without RTSP URL and sample file directory", + type_.as_str() + ); + } + } let mut l = db.lock(); if let Some(id) = id { l.update_camera(id, change) } else { l.add_camera(change).map(|_| ()) } - }; + })(); if let Err(e) = result { siv.add_layer( views::Dialog::text(format!("Unable to add camera: {}", e)) @@ -140,18 +166,21 @@ fn press_test_inner( } fn press_test(siv: &mut Cursive, t: db::StreamType) { - let c = get_change(siv); - let url = match Url::parse(&c.streams[t.index()].rtsp_url) { + let mut c = match get_change(siv) { Ok(u) => u, Err(e) => { siv.add_layer( - views::Dialog::text(format!("Unparseable URL: {}", e)) + views::Dialog::text(format!("{}", e)) .title("Stream test failed") .dismiss_button("Back"), ); return; } }; + let url = c.streams[t.index()] + .rtsp_url + .take() + .expect("test button only enabled when URL set"); let username = c.username; let password = c.password; @@ -317,6 +346,11 @@ fn actually_delete(siv: &mut Cursive, db: &Arc, id: i32) { } } +fn edit_url(content: &str, mut test_button: ViewRef) { + let enable_test = matches!(parse_url(content), Ok(Some(_))); + test_button.set_enabled(enable_test); +} + /// Adds or updates a camera. /// (The former if `item` is None; the latter otherwise.) fn edit_camera_dialog(db: &Arc, siv: &mut Cursive, item: &Option) { @@ -358,13 +392,21 @@ fn edit_camera_dialog(db: &Arc, siv: &mut Cursive, item: &Option(&format!("{}_test", type_.as_str())) + .unwrap(); + edit_url(content, test_button) + }) .with_name(format!("{}_rtsp_url", type_.as_str())) .full_width(), ) .child(views::DummyView) - .child(views::Button::new("Test", move |siv| { - press_test(siv, type_) - })), + .child( + views::Button::new("Test", move |siv| press_test(siv, type_)) + .disabled() + .with_name(format!("{}_test", type_.as_str())), + ), ) .child( "sample file dir", @@ -431,6 +473,10 @@ fn edit_camera_dialog(db: &Arc, siv: &mut Cursive, item: &Option(&format!("{}_test", t.as_str())) + .unwrap(); + edit_url(&s.rtsp_url, test_button); dialog.call_on_name( &format!("{}_usage_cap", t.as_str()), |v: &mut views::TextView| v.set_content(u),