Skip to main content

heldar_kernel/
camera_url.rs

1//! RTSP URL construction from vendor templates, plus credential masking.
2
3use chrono::{DateTime, Utc};
4
5use crate::models::Camera;
6
7/// Map a logical stream name to a HikVision channel id (101 = main, 102 = sub).
8fn hik_channel(stream: &str) -> &'static str {
9    if stream == "sub" {
10        "102"
11    } else {
12        "101"
13    }
14}
15
16/// Percent-encode the reserved characters that would break the `user:pass@host` userinfo section.
17pub(crate) fn encode_userinfo(s: &str) -> String {
18    let mut out = String::with_capacity(s.len());
19    for b in s.bytes() {
20        match b {
21            // RFC 3986 unreserved + a few safe chars
22            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
23                out.push(b as char)
24            }
25            _ => out.push_str(&format!("%{:02X}", b)),
26        }
27    }
28    out
29}
30
31/// Build the RTSP URL (with credentials) for the given stream ("main" | "sub").
32/// Honors an explicit per-stream URL override; otherwise builds from the vendor template.
33pub fn stream_url(cam: &Camera, stream: &str) -> Option<String> {
34    let explicit = if stream == "sub" {
35        cam.sub_stream_url.as_deref()
36    } else {
37        cam.main_stream_url.as_deref()
38    };
39    if let Some(u) = explicit {
40        if !u.trim().is_empty() {
41            return Some(u.trim().to_string());
42        }
43    }
44
45    let host = cam.address.as_deref()?.trim();
46    if host.is_empty() {
47        return None;
48    }
49    let port = cam.rtsp_port;
50
51    let creds = match (cam.username.as_deref(), cam.password.as_deref()) {
52        (Some(u), Some(p)) if !u.is_empty() => {
53            format!("{}:{}@", encode_userinfo(u), encode_userinfo(p))
54        }
55        (Some(u), _) if !u.is_empty() => format!("{}@", encode_userinfo(u)),
56        _ => String::new(),
57    };
58
59    let path = match cam.vendor.as_str() {
60        "hikvision" => format!("/Streaming/Channels/{}", hik_channel(stream)),
61        "dahua" => format!(
62            "/cam/realmonitor?channel=1&subtype={}",
63            if stream == "sub" { "1" } else { "0" }
64        ),
65        // generic/onvif: without an explicit URL we cannot guess a path.
66        _ => return None,
67    };
68
69    Some(format!("rtsp://{creds}{host}:{port}{path}"))
70}
71
72/// The RTSP URL for the stream this camera records.
73pub fn record_url(cam: &Camera) -> Option<String> {
74    stream_url(cam, &cam.record_stream)
75}
76
77/// A Hikvision-style replay timestamp: `20260613T120500Z` (UTC, used by ISAPI/RTSP playback).
78fn hik_replay_time(t: DateTime<Utc>) -> String {
79    t.format("%Y%m%dT%H%M%SZ").to_string()
80}
81
82/// Build the replay (playback) URL used by ANR to pull a `[start, end]` window from the camera's
83/// ONBOARD storage. Honors a per-camera `anr_replay_url_template` (with `{start}` / `{end}`
84/// placeholders, filled with Hikvision-format timestamps); otherwise defaults to the Hikvision RTSP
85/// playback endpoint (`/Streaming/tracks/{channel}?starttime=..&endtime=..`) built from the camera's
86/// address + credentials. Returns `None` when there is no template and no host/credentials to build
87/// one from. Best-effort and camera-dependent — see `services/anr.rs`.
88pub fn anr_replay_url(cam: &Camera, start: DateTime<Utc>, end: DateTime<Utc>) -> Option<String> {
89    let s = hik_replay_time(start);
90    let e = hik_replay_time(end);
91    if let Some(tpl) = cam.anr_replay_url_template.as_deref() {
92        let tpl = tpl.trim();
93        if !tpl.is_empty() {
94            return Some(tpl.replace("{start}", &s).replace("{end}", &e));
95        }
96    }
97    let host = cam.address.as_deref()?.trim();
98    if host.is_empty() {
99        return None;
100    }
101    let port = cam.rtsp_port;
102    let creds = match (cam.username.as_deref(), cam.password.as_deref()) {
103        (Some(u), Some(p)) if !u.is_empty() => {
104            format!("{}:{}@", encode_userinfo(u), encode_userinfo(p))
105        }
106        (Some(u), _) if !u.is_empty() => format!("{}@", encode_userinfo(u)),
107        _ => String::new(),
108    };
109    let channel = hik_channel(&cam.record_stream);
110    Some(format!(
111        "rtsp://{creds}{host}:{port}/Streaming/tracks/{channel}?starttime={s}&endtime={e}"
112    ))
113}
114
115/// Schemes permitted for explicit camera stream URLs. Excludes `file:`, `gopher:`, etc., which
116/// would let ffmpeg/ffprobe/MediaMTX read local files or reach unintended protocols (SSRF/LFI).
117const ALLOWED_SCHEMES: &[&str] = &["rtsp", "rtsps", "http", "https"];
118
119/// Validate an operator-supplied stream URL: must parse and use an allowed scheme.
120pub fn validate_stream_url(url: &str) -> Result<(), String> {
121    let url = url.trim();
122    let Some((scheme, _)) = url.split_once("://") else {
123        return Err(format!(
124            "invalid stream URL `{}` (no scheme://)",
125            mask_url(url)
126        ));
127    };
128    let scheme = scheme.to_ascii_lowercase();
129    if !ALLOWED_SCHEMES.contains(&scheme.as_str()) {
130        return Err(format!(
131            "stream URL scheme `{scheme}` not allowed; use one of {ALLOWED_SCHEMES:?}"
132        ));
133    }
134    Ok(())
135}
136
137/// Replace `user:pass@` (or `user@`) credentials in an RTSP/HTTP URL with `***` for safe logging/display.
138pub fn mask_url(url: &str) -> String {
139    let Some(scheme_end) = url.find("://") else {
140        return url.to_string();
141    };
142    let after = scheme_end + 3;
143    // The userinfo/host boundary is the LAST '@' before the first '/' of the authority; using the
144    // last '@' ensures a literal '@' inside the password (from an explicit URL) is fully masked.
145    let authority_end = url[after..]
146        .find('/')
147        .map(|i| after + i)
148        .unwrap_or(url.len());
149    if let Some(at_rel) = url[after..authority_end].rfind('@') {
150        let at = after + at_rel;
151        format!("{}***@{}", &url[..after], &url[at + 1..])
152    } else {
153        url.to_string()
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::models::Camera;
161    use chrono::Utc;
162    use serde_json::json;
163    use sqlx::types::Json;
164
165    fn base() -> Camera {
166        Camera {
167            id: "cam1".into(),
168            site_id: None,
169            name: "Cam 1".into(),
170            vendor: "hikvision".into(),
171            model: None,
172            address: Some("192.168.0.2".into()),
173            rtsp_port: 554,
174            username: Some("admin".into()),
175            password: Some("p@ss/w:rd".into()),
176            main_stream_url: None,
177            sub_stream_url: None,
178            record_stream: "main".into(),
179            codec: None,
180            resolution_main: None,
181            resolution_sub: None,
182            fps_main: None,
183            fps_sub: None,
184            capabilities: Json(json!({})),
185            record_enabled: true,
186            segment_seconds: 60,
187            retention_hours: 24,
188            storage_quota_bytes: None,
189            record_audio: false,
190            record_mode: "continuous".into(),
191            pre_roll_seconds: 10,
192            post_roll_seconds: 30,
193            mirror_enabled: false,
194            anr_enabled: false,
195            anr_replay_url_template: None,
196            enabled: true,
197            created_at: Utc::now(),
198            updated_at: Utc::now(),
199        }
200    }
201
202    #[test]
203    fn hikvision_main_url_percent_encodes_credentials() {
204        let c = base();
205        assert_eq!(
206            stream_url(&c, "main").unwrap(),
207            "rtsp://admin:p%40ss%2Fw%3Ard@192.168.0.2:554/Streaming/Channels/101"
208        );
209    }
210
211    #[test]
212    fn hikvision_sub_uses_channel_102() {
213        assert!(stream_url(&base(), "sub")
214            .unwrap()
215            .ends_with("/Streaming/Channels/102"));
216    }
217
218    #[test]
219    fn explicit_override_takes_precedence() {
220        let mut c = base();
221        c.main_stream_url = Some("rtsp://example/stream".into());
222        assert_eq!(stream_url(&c, "main").unwrap(), "rtsp://example/stream");
223    }
224
225    #[test]
226    fn generic_vendor_without_url_is_none() {
227        let mut c = base();
228        c.vendor = "generic".into();
229        c.main_stream_url = None;
230        assert!(stream_url(&c, "main").is_none());
231    }
232
233    #[test]
234    fn mask_url_hides_credentials() {
235        assert_eq!(
236            mask_url("rtsp://admin:secret@10.0.0.1:554/Streaming/Channels/101"),
237            "rtsp://***@10.0.0.1:554/Streaming/Channels/101"
238        );
239        assert_eq!(mask_url("rtsp://10.0.0.1:554/x"), "rtsp://10.0.0.1:554/x");
240    }
241
242    #[test]
243    fn mask_url_handles_at_in_password() {
244        // An explicit URL with a literal '@' in the password must be fully masked (use last '@').
245        assert_eq!(
246            mask_url("rtsp://user:p@ss@10.0.0.1:554/x"),
247            "rtsp://***@10.0.0.1:554/x"
248        );
249    }
250
251    #[test]
252    fn anr_replay_url_default_hikvision_playback() {
253        let c = base();
254        let start = parse_t("2026-06-13T12:00:00Z");
255        let end = parse_t("2026-06-13T12:01:30Z");
256        assert_eq!(
257            anr_replay_url(&c, start, end).unwrap(),
258            "rtsp://admin:p%40ss%2Fw%3Ard@192.168.0.2:554/Streaming/tracks/101?\
259             starttime=20260613T120000Z&endtime=20260613T120130Z"
260        );
261    }
262
263    #[test]
264    fn anr_replay_url_honors_template_placeholders() {
265        let mut c = base();
266        c.anr_replay_url_template = Some("rtsp://cam/replay?s={start}&e={end}".into());
267        assert_eq!(
268            anr_replay_url(
269                &c,
270                parse_t("2026-06-13T12:00:00Z"),
271                parse_t("2026-06-13T12:00:05Z")
272            )
273            .unwrap(),
274            "rtsp://cam/replay?s=20260613T120000Z&e=20260613T120005Z"
275        );
276    }
277
278    #[test]
279    fn anr_replay_url_none_without_host_or_template() {
280        let mut c = base();
281        c.vendor = "generic".into();
282        c.address = None;
283        c.anr_replay_url_template = None;
284        assert!(anr_replay_url(&c, Utc::now(), Utc::now()).is_none());
285    }
286
287    fn parse_t(s: &str) -> DateTime<Utc> {
288        DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
289    }
290
291    #[test]
292    fn validate_stream_url_rejects_dangerous_schemes() {
293        assert!(validate_stream_url("rtsp://10.0.0.1:554/x").is_ok());
294        assert!(validate_stream_url("https://cam/stream").is_ok());
295        assert!(validate_stream_url("file:///etc/passwd").is_err());
296        assert!(validate_stream_url("gopher://x").is_err());
297        assert!(validate_stream_url("not-a-url").is_err());
298    }
299}