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
31fn decrypted_password(cam: &Camera) -> Option<String> {
36 let stored = cam.password.as_deref()?;
37 match crate::services::secrets::decrypt_stored(stored) {
38 Ok(p) => Some(p),
39 Err(e) => {
40 tracing::error!(camera = %cam.id, "camera credential decrypt failed: {e}");
41 None
42 }
43 }
44}
45
46pub fn stream_url(cam: &Camera, stream: &str) -> Option<String> {
49 let explicit = if stream == "sub" {
50 cam.sub_stream_url.as_deref()
51 } else {
52 cam.main_stream_url.as_deref()
53 };
54 if let Some(u) = explicit {
55 if !u.trim().is_empty() {
56 return Some(u.trim().to_string());
57 }
58 }
59
60 let host = cam.address.as_deref()?.trim();
61 if host.is_empty() {
62 return None;
63 }
64 let port = cam.rtsp_port;
65
66 let password = decrypted_password(cam);
67 let creds = match (cam.username.as_deref(), password.as_deref()) {
68 (Some(u), Some(p)) if !u.is_empty() => {
69 format!("{}:{}@", encode_userinfo(u), encode_userinfo(p))
70 }
71 (Some(u), _) if !u.is_empty() => format!("{}@", encode_userinfo(u)),
72 _ => String::new(),
73 };
74
75 let path = match cam.vendor.as_str() {
76 "hikvision" => format!("/Streaming/Channels/{}", hik_channel(stream)),
77 "dahua" => format!(
78 "/cam/realmonitor?channel=1&subtype={}",
79 if stream == "sub" { "1" } else { "0" }
80 ),
81 _ => return None,
83 };
84
85 Some(format!("rtsp://{creds}{host}:{port}{path}"))
86}
87
88pub fn record_url(cam: &Camera) -> Option<String> {
90 stream_url(cam, &cam.record_stream)
91}
92
93fn hik_replay_time(t: DateTime<Utc>) -> String {
95 t.format("%Y%m%dT%H%M%SZ").to_string()
96}
97
98pub fn anr_replay_url(cam: &Camera, start: DateTime<Utc>, end: DateTime<Utc>) -> Option<String> {
105 let s = hik_replay_time(start);
106 let e = hik_replay_time(end);
107 if let Some(tpl) = cam.anr_replay_url_template.as_deref() {
108 let tpl = tpl.trim();
109 if !tpl.is_empty() {
110 return Some(tpl.replace("{start}", &s).replace("{end}", &e));
111 }
112 }
113 let host = cam.address.as_deref()?.trim();
114 if host.is_empty() {
115 return None;
116 }
117 let port = cam.rtsp_port;
118 let password = decrypted_password(cam);
119 let creds = match (cam.username.as_deref(), password.as_deref()) {
120 (Some(u), Some(p)) if !u.is_empty() => {
121 format!("{}:{}@", encode_userinfo(u), encode_userinfo(p))
122 }
123 (Some(u), _) if !u.is_empty() => format!("{}@", encode_userinfo(u)),
124 _ => String::new(),
125 };
126 let channel = hik_channel(&cam.record_stream);
127 Some(format!(
128 "rtsp://{creds}{host}:{port}/Streaming/tracks/{channel}?starttime={s}&endtime={e}"
129 ))
130}
131
132const ALLOWED_SCHEMES: &[&str] = &["rtsp", "rtsps", "http", "https"];
135
136pub fn validate_stream_url(url: &str) -> Result<(), String> {
138 let url = url.trim();
139 let Some((scheme, _)) = url.split_once("://") else {
140 return Err(format!(
141 "invalid stream URL `{}` (no scheme://)",
142 mask_url(url)
143 ));
144 };
145 let scheme = scheme.to_ascii_lowercase();
146 if !ALLOWED_SCHEMES.contains(&scheme.as_str()) {
147 return Err(format!(
148 "stream URL scheme `{scheme}` not allowed; use one of {ALLOWED_SCHEMES:?}"
149 ));
150 }
151 Ok(())
152}
153
154pub fn mask_url(url: &str) -> String {
156 let Some(scheme_end) = url.find("://") else {
157 return url.to_string();
158 };
159 let after = scheme_end + 3;
160 let authority_end = url[after..]
163 .find('/')
164 .map(|i| after + i)
165 .unwrap_or(url.len());
166 if let Some(at_rel) = url[after..authority_end].rfind('@') {
167 let at = after + at_rel;
168 format!("{}***@{}", &url[..after], &url[at + 1..])
169 } else {
170 url.to_string()
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use crate::models::Camera;
178 use chrono::Utc;
179 use serde_json::json;
180 use sqlx::types::Json;
181
182 fn base() -> Camera {
183 Camera {
184 id: "cam1".into(),
185 site_id: None,
186 name: "Cam 1".into(),
187 vendor: "hikvision".into(),
188 model: None,
189 address: Some("192.168.0.2".into()),
190 rtsp_port: 554,
191 username: Some("admin".into()),
192 password: Some("p@ss/w:rd".into()),
193 main_stream_url: None,
194 sub_stream_url: None,
195 record_stream: "main".into(),
196 codec: None,
197 resolution_main: None,
198 resolution_sub: None,
199 fps_main: None,
200 fps_sub: None,
201 capabilities: Json(json!({})),
202 record_enabled: true,
203 segment_seconds: 60,
204 retention_hours: 24,
205 storage_quota_bytes: None,
206 record_audio: false,
207 record_mode: "continuous".into(),
208 pre_roll_seconds: 10,
209 post_roll_seconds: 30,
210 mirror_enabled: false,
211 anr_enabled: false,
212 anr_replay_url_template: None,
213 enabled: true,
214 priority: 100,
215 created_at: Utc::now(),
216 updated_at: Utc::now(),
217 }
218 }
219
220 #[test]
221 fn hikvision_main_url_percent_encodes_credentials() {
222 let c = base();
223 assert_eq!(
224 stream_url(&c, "main").unwrap(),
225 "rtsp://admin:p%40ss%2Fw%3Ard@192.168.0.2:554/Streaming/Channels/101"
226 );
227 }
228
229 #[test]
230 fn hikvision_sub_uses_channel_102() {
231 assert!(stream_url(&base(), "sub")
232 .unwrap()
233 .ends_with("/Streaming/Channels/102"));
234 }
235
236 #[test]
237 fn explicit_override_takes_precedence() {
238 let mut c = base();
239 c.main_stream_url = Some("rtsp://example/stream".into());
240 assert_eq!(stream_url(&c, "main").unwrap(), "rtsp://example/stream");
241 }
242
243 #[test]
244 fn generic_vendor_without_url_is_none() {
245 let mut c = base();
246 c.vendor = "generic".into();
247 c.main_stream_url = None;
248 assert!(stream_url(&c, "main").is_none());
249 }
250
251 #[test]
252 fn mask_url_hides_credentials() {
253 assert_eq!(
254 mask_url("rtsp://admin:secret@10.0.0.1:554/Streaming/Channels/101"),
255 "rtsp://***@10.0.0.1:554/Streaming/Channels/101"
256 );
257 assert_eq!(mask_url("rtsp://10.0.0.1:554/x"), "rtsp://10.0.0.1:554/x");
258 }
259
260 #[test]
261 fn mask_url_handles_at_in_password() {
262 assert_eq!(
264 mask_url("rtsp://user:p@ss@10.0.0.1:554/x"),
265 "rtsp://***@10.0.0.1:554/x"
266 );
267 }
268
269 #[test]
270 fn anr_replay_url_default_hikvision_playback() {
271 let c = base();
272 let start = parse_t("2026-06-13T12:00:00Z");
273 let end = parse_t("2026-06-13T12:01:30Z");
274 assert_eq!(
275 anr_replay_url(&c, start, end).unwrap(),
276 "rtsp://admin:p%40ss%2Fw%3Ard@192.168.0.2:554/Streaming/tracks/101?\
277 starttime=20260613T120000Z&endtime=20260613T120130Z"
278 );
279 }
280
281 #[test]
282 fn anr_replay_url_honors_template_placeholders() {
283 let mut c = base();
284 c.anr_replay_url_template = Some("rtsp://cam/replay?s={start}&e={end}".into());
285 assert_eq!(
286 anr_replay_url(
287 &c,
288 parse_t("2026-06-13T12:00:00Z"),
289 parse_t("2026-06-13T12:00:05Z")
290 )
291 .unwrap(),
292 "rtsp://cam/replay?s=20260613T120000Z&e=20260613T120005Z"
293 );
294 }
295
296 #[test]
297 fn anr_replay_url_none_without_host_or_template() {
298 let mut c = base();
299 c.vendor = "generic".into();
300 c.address = None;
301 c.anr_replay_url_template = None;
302 assert!(anr_replay_url(&c, Utc::now(), Utc::now()).is_none());
303 }
304
305 fn parse_t(s: &str) -> DateTime<Utc> {
306 DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
307 }
308
309 #[test]
310 fn validate_stream_url_rejects_dangerous_schemes() {
311 assert!(validate_stream_url("rtsp://10.0.0.1:554/x").is_ok());
312 assert!(validate_stream_url("https://cam/stream").is_ok());
313 assert!(validate_stream_url("file:///etc/passwd").is_err());
314 assert!(validate_stream_url("gopher://x").is_err());
315 assert!(validate_stream_url("not-a-url").is_err());
316 }
317}