1use chrono::{DateTime, Utc};
4
5use crate::models::Camera;
6
7fn hik_channel(stream: &str) -> &'static str {
9 if stream == "sub" {
10 "102"
11 } else {
12 "101"
13 }
14}
15
16pub(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 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
31pub 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 _ => return None,
67 };
68
69 Some(format!("rtsp://{creds}{host}:{port}{path}"))
70}
71
72pub fn record_url(cam: &Camera) -> Option<String> {
74 stream_url(cam, &cam.record_stream)
75}
76
77fn hik_replay_time(t: DateTime<Utc>) -> String {
79 t.format("%Y%m%dT%H%M%SZ").to_string()
80}
81
82pub 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
115const ALLOWED_SCHEMES: &[&str] = &["rtsp", "rtsps", "http", "https"];
118
119pub 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
137pub 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 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 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}