Skip to main content

onvif_server/service/
events.rs

1use async_trait::async_trait;
2use bytes::Bytes;
3use chrono::{DateTime, Duration, Utc};
4use quick_xml::events::Event;
5use quick_xml::NsReader;
6use soap_server::{escape_text, SoapFault, SoapHandler};
7use std::collections::HashMap;
8use std::sync::{Arc, Mutex};
9use uuid::Uuid;
10
11use crate::error::OnvifError;
12use crate::service::xml_util::extract_text_ns;
13use crate::traits::EventService;
14
15/// ONVIF namespaces accepted for event request elements.
16const ONVIF_EVENTS_NS: &[u8] = b"http://www.onvif.org/ver10/events/wsdl";
17const ONVIF_SCHEMA_NS: &[u8] = b"http://www.onvif.org/ver10/schema";
18/// WS-BaseNotification namespace (used for InitialTerminationTime in CreatePullPointSubscription).
19const WSNT_NS: &[u8] = b"http://docs.oasis-open.org/wsn/b-2";
20
21/// Default subscription lifetime in seconds when no (or unparseable)
22/// `InitialTerminationTime` is provided by the client.
23const DEFAULT_SUBSCRIPTION_SECS: i64 = 60;
24
25struct SubscriptionInfo {
26    termination_time: DateTime<Utc>,
27}
28
29#[allow(dead_code)]
30pub struct EventServiceHandler {
31    pub(crate) svc: Arc<dyn EventService>,
32    pub(crate) xaddr: String,
33    subscriptions: Arc<Mutex<HashMap<String, SubscriptionInfo>>>,
34}
35
36impl EventServiceHandler {
37    pub fn new(svc: Arc<dyn EventService>, xaddr: impl Into<String>) -> Self {
38        Self {
39            svc,
40            xaddr: xaddr.into(),
41            subscriptions: Arc::new(Mutex::new(HashMap::new())),
42        }
43    }
44}
45
46#[async_trait]
47impl SoapHandler for EventServiceHandler {
48    async fn handle(&self, body: Bytes) -> Result<Bytes, SoapFault> {
49        // Delegate through handle_with_headers with an empty header slice.
50        self.handle_with_headers(body, &[]).await
51    }
52
53    /// Override to receive SOAP header fragments so WS-Addressing reference
54    /// parameters (echoed SubscriptionId) can be used for routing.
55    async fn handle_with_headers(
56        &self,
57        body: Bytes,
58        headers: &[Bytes],
59    ) -> Result<Bytes, SoapFault> {
60        let op = extract_local_name(&body)?;
61        match op.as_str() {
62            "GetEventProperties" => self.handle_get_event_properties().await,
63            "CreatePullPointSubscription" => {
64                self.handle_create_pull_point_subscription(&body).await
65            }
66            "PullMessages" => self.handle_pull_messages(&body, headers).await,
67            "Unsubscribe" => self.handle_unsubscribe(&body, headers).await,
68            _ => Err(OnvifError::ActionNotSupported.into_soap_fault()),
69        }
70    }
71}
72
73fn extract_local_name(body: &Bytes) -> Result<String, SoapFault> {
74    let mut reader = NsReader::from_reader(body.as_ref());
75    reader.config_mut().trim_text(true);
76    loop {
77        match reader
78            .read_resolved_event()
79            .map_err(|e| SoapFault::sender(format!("{e}")))?
80        {
81            (_, Event::Start(e)) | (_, Event::Empty(e)) => {
82                let local = std::str::from_utf8(e.local_name().as_ref())
83                    .map_err(|e| SoapFault::sender(format!("{e}")))?
84                    .to_string();
85                return Ok(local);
86            }
87            (_, Event::Eof) => return Err(SoapFault::sender("Empty body".to_string())),
88            _ => {}
89        }
90    }
91}
92
93fn extract_text_element(body: &Bytes, element_name: &str) -> Result<String, SoapFault> {
94    extract_text_ns(body, element_name, &[ONVIF_EVENTS_NS, ONVIF_SCHEMA_NS])
95}
96
97/// Resolve the SubscriptionId for PullMessages / Unsubscribe.
98///
99/// WS-Addressing reference parameters: the SubscriptionId the server placed inside
100/// `ReferenceParameters` at CreatePullPointSubscription time is echoed back by
101/// conforming clients as a direct child of `<SOAP:Header>`.  Parse it from the
102/// first header element whose local name is `SubscriptionId` (accepted in the
103/// ONVIF events namespace or unbound).  Fall back to the body element for
104/// backwards-compat with clients that embed it in the operation body.
105fn resolve_subscription_id(body: &Bytes, headers: &[Bytes]) -> Result<String, SoapFault> {
106    // 1. Try headers first (WS-Addressing reference parameter echo).
107    for header in headers {
108        if let Ok(id) = extract_text_ns(
109            header,
110            "SubscriptionId",
111            &[ONVIF_EVENTS_NS, ONVIF_SCHEMA_NS],
112        ) {
113            if !id.is_empty() {
114                return Ok(id);
115            }
116        }
117    }
118
119    // 2. Fall back to body element (backward compat).
120    extract_text_element(body, "SubscriptionId")
121}
122
123/// Parse an `InitialTerminationTime` value into a `Duration`.
124///
125/// Accepts:
126/// - ISO 8601 duration strings: `PT<seconds>S` (e.g. `PT60S`, `PT300S`).
127///   Only seconds-level resolution is required for ONVIF pull-point subscriptions.
128/// - Absolute RFC 3339 / ISO 8601 datetimes: interpreted as an absolute expiry.
129/// - Anything unparseable: returns `None` (caller should use the default).
130fn parse_termination_time(value: &str, now: DateTime<Utc>) -> Option<Duration> {
131    let trimmed = value.trim();
132
133    // ISO 8601 duration: must start with 'P'.
134    if trimmed.starts_with('P') {
135        return parse_iso8601_duration(trimmed);
136    }
137
138    // Try absolute datetime (RFC 3339).
139    if let Ok(absolute) = trimmed.parse::<DateTime<Utc>>() {
140        let delta = absolute.signed_duration_since(now);
141        if delta > Duration::zero() {
142            return Some(delta);
143        }
144        // Already-past time — use default.
145        return None;
146    }
147
148    None
149}
150
151/// Parse a bounded subset of ISO 8601 durations: `P[nY][nM][nDT[nH][nM][nS]]`.
152/// Only handles whole-unit fields; fractional seconds are truncated.
153/// Returns `None` if the string does not conform.
154fn parse_iso8601_duration(s: &str) -> Option<Duration> {
155    // Strip leading 'P'.
156    let s = s.strip_prefix('P')?;
157
158    let mut total_secs: i64 = 0;
159    let (date_part, time_part) = if let Some(t) = s.split_once('T') {
160        (t.0, Some(t.1))
161    } else {
162        (s, None)
163    };
164
165    // Parse date fields: nY, nM, nD.
166    let mut remaining = date_part;
167    total_secs += consume_duration_field(&mut remaining, 'Y')? * 365 * 86400;
168    total_secs += consume_duration_field(&mut remaining, 'M')? * 30 * 86400;
169    total_secs += consume_duration_field(&mut remaining, 'D')? * 86400;
170
171    // Parse time fields: nH, nM, nS.
172    if let Some(time) = time_part {
173        let mut rem = time;
174        total_secs += consume_duration_field(&mut rem, 'H')? * 3600;
175        total_secs += consume_duration_field(&mut rem, 'M')? * 60;
176        // Seconds: allow fractional (truncate).
177        total_secs += consume_duration_seconds(&mut rem)?;
178        if !rem.is_empty() {
179            return None;
180        }
181    } else if !remaining.is_empty() {
182        return None;
183    }
184
185    Some(Duration::seconds(total_secs))
186}
187
188/// Consume an optional `<integer><designator>` prefix from `*s`, returning the
189/// integer (or 0 if this designator is absent).  Returns `None` on parse error.
190fn consume_duration_field(s: &mut &str, designator: char) -> Option<i64> {
191    if let Some(pos) = s.find(designator) {
192        // Everything before the designator must be a non-empty integer.
193        let num_str = &s[..pos];
194        if num_str.is_empty() {
195            return None;
196        }
197        let n: i64 = num_str.parse().ok()?;
198        *s = &s[pos + designator.len_utf8()..];
199        Some(n)
200    } else {
201        Some(0)
202    }
203}
204
205/// Consume optional `<integer-or-float>S` from the front of `*s`, returning the
206/// truncated integer seconds.  Returns 0 (not None) if 'S' is absent.
207fn consume_duration_seconds(s: &mut &str) -> Option<i64> {
208    if let Some(pos) = s.find('S') {
209        let num_str = &s[..pos];
210        if num_str.is_empty() {
211            return None;
212        }
213        // Accept integer or decimal (e.g. "30.5S" → 30).
214        let n: i64 = if let Some(dot) = num_str.find('.') {
215            num_str[..dot].parse().ok()?
216        } else {
217            num_str.parse().ok()?
218        };
219        *s = &s[pos + 1..];
220        Some(n)
221    } else {
222        Some(0)
223    }
224}
225
226impl EventServiceHandler {
227    async fn handle_get_event_properties(&self) -> Result<Bytes, SoapFault> {
228        let xml = r#"<tev:GetEventPropertiesResponse xmlns:tev="http://www.onvif.org/ver10/events/wsdl" xmlns:wsnt="http://docs.oasis-open.org/wsn/b-2" xmlns:wstop="http://docs.oasis-open.org/wsn/t-1"><tev:TopicNamespaceLocation>http://www.onvif.org/onvif/ver10/topics/topicns.xml</tev:TopicNamespaceLocation><wsnt:FixedTopicSet>true</wsnt:FixedTopicSet><wstop:TopicSet/><wsnt:TopicExpressionDialect>http://docs.oasis-open.org/wsn/t-1/TopicExpression/Concrete</wsnt:TopicExpressionDialect><wsnt:TopicExpressionDialect>http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet</wsnt:TopicExpressionDialect><tev:MessageContentFilterDialect>http://www.onvif.org/ver10/tev/messageContentFilter/ItemFilter</tev:MessageContentFilterDialect><tev:MessageContentSchemaLocation>http://www.onvif.org/ver10/schema/onvif.xsd</tev:MessageContentSchemaLocation></tev:GetEventPropertiesResponse>"#;
229        Ok(Bytes::from(xml))
230    }
231
232    async fn handle_create_pull_point_subscription(
233        &self,
234        body: &Bytes,
235    ) -> Result<Bytes, SoapFault> {
236        let sub_id = Uuid::new_v4().to_string();
237        let now = Utc::now();
238
239        // Parse InitialTerminationTime from the request body.
240        // The element may appear in the ONVIF events namespace, the WS-Notification
241        // namespace (wsnt), or unbound — accept all three.
242        // Accept either an ISO 8601 duration (e.g. PT60S) or an absolute datetime.
243        // Fall back to DEFAULT_SUBSCRIPTION_SECS if absent or unparseable.
244        let lifetime = extract_text_ns(
245            body,
246            "InitialTerminationTime",
247            &[ONVIF_EVENTS_NS, ONVIF_SCHEMA_NS, WSNT_NS],
248        )
249        .ok()
250        .and_then(|v| parse_termination_time(&v, now))
251        .unwrap_or_else(|| Duration::seconds(DEFAULT_SUBSCRIPTION_SECS));
252
253        let termination = now + lifetime;
254
255        {
256            let mut subs = self
257                .subscriptions
258                .lock()
259                .map_err(|e| SoapFault::sender(format!("lock poisoned: {e}")))?;
260            subs.insert(
261                sub_id.clone(),
262                SubscriptionInfo {
263                    termination_time: termination,
264                },
265            );
266        }
267
268        let xml = format!(
269            r#"<tev:CreatePullPointSubscriptionResponse xmlns:tev="http://www.onvif.org/ver10/events/wsdl" xmlns:wsa5="http://www.w3.org/2005/08/addressing" xmlns:wsnt="http://docs.oasis-open.org/wsn/b-2"><tev:SubscriptionReference><wsa5:Address>{xaddr}</wsa5:Address><wsa5:ReferenceParameters><tev:SubscriptionId>{sub_id}</tev:SubscriptionId></wsa5:ReferenceParameters></tev:SubscriptionReference><wsnt:CurrentTime>{current}</wsnt:CurrentTime><wsnt:TerminationTime>{termination}</wsnt:TerminationTime></tev:CreatePullPointSubscriptionResponse>"#,
270            xaddr = escape_text(&self.xaddr),
271            sub_id = escape_text(&sub_id),
272            current = now.to_rfc3339(),
273            termination = termination.to_rfc3339(),
274        );
275        Ok(Bytes::from(xml))
276    }
277
278    async fn handle_pull_messages(
279        &self,
280        body: &Bytes,
281        headers: &[Bytes],
282    ) -> Result<Bytes, SoapFault> {
283        // Resolve SubscriptionId: header (WS-Addressing ref param) first, body fallback.
284        let sub_id = resolve_subscription_id(body, headers)?;
285
286        let now = Utc::now();
287        let termination_time = {
288            let subs = self
289                .subscriptions
290                .lock()
291                .map_err(|e| SoapFault::sender(format!("lock poisoned: {e}")))?;
292            match subs.get(&sub_id) {
293                Some(info) if info.termination_time > now => info.termination_time,
294                Some(_) => {
295                    // Subscription exists but has expired.
296                    // Log detail server-side; return generic fault reason to client (#12).
297                    eprintln!("[onvif-events] PullMessages: subscription expired (id not echoed to client)");
298                    return Err(SoapFault::sender("Subscription not found".to_string()));
299                }
300                None => {
301                    // Log detail server-side; return generic fault reason to client (#12).
302                    eprintln!("[onvif-events] PullMessages: unknown subscription (id not echoed to client)");
303                    return Err(SoapFault::sender("Subscription not found".to_string()));
304                }
305            }
306        };
307
308        let xml = format!(
309            r#"<tev:PullMessagesResponse xmlns:tev="http://www.onvif.org/ver10/events/wsdl" xmlns:wsnt="http://docs.oasis-open.org/wsn/b-2"><tev:CurrentTime>{current}</tev:CurrentTime><tev:TerminationTime>{termination}</tev:TerminationTime></tev:PullMessagesResponse>"#,
310            current = now.to_rfc3339(),
311            termination = termination_time.to_rfc3339(),
312        );
313        Ok(Bytes::from(xml))
314    }
315
316    async fn handle_unsubscribe(
317        &self,
318        body: &Bytes,
319        headers: &[Bytes],
320    ) -> Result<Bytes, SoapFault> {
321        // Resolve SubscriptionId: header (WS-Addressing ref param) first, body fallback.
322        let sub_id = resolve_subscription_id(body, headers)?;
323
324        {
325            let mut subs = self
326                .subscriptions
327                .lock()
328                .map_err(|e| SoapFault::sender(format!("lock poisoned: {e}")))?;
329            if subs.remove(&sub_id).is_none() {
330                // Unknown or already-expired subscription — log server-side, fault to client (#12).
331                eprintln!("[onvif-events] Unsubscribe: unknown or expired subscription (id not echoed to client)");
332                return Err(SoapFault::sender("Subscription not found".to_string()));
333            }
334        }
335
336        let xml =
337            r#"<tev:UnsubscribeResponse xmlns:tev="http://www.onvif.org/ver10/events/wsdl"/>"#;
338        Ok(Bytes::from(xml))
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    // ── parse_iso8601_duration unit tests ─────────────────────────────────────
347
348    #[test]
349    fn parse_pt60s_returns_60_seconds() {
350        let d = parse_iso8601_duration("PT60S").unwrap();
351        assert_eq!(d.num_seconds(), 60);
352    }
353
354    #[test]
355    fn parse_pt300s_returns_300_seconds() {
356        let d = parse_iso8601_duration("PT300S").unwrap();
357        assert_eq!(d.num_seconds(), 300);
358    }
359
360    #[test]
361    fn parse_pt1h_returns_3600_seconds() {
362        let d = parse_iso8601_duration("PT1H").unwrap();
363        assert_eq!(d.num_seconds(), 3600);
364    }
365
366    #[test]
367    fn parse_pt1h30m_returns_5400_seconds() {
368        let d = parse_iso8601_duration("PT1H30M").unwrap();
369        assert_eq!(d.num_seconds(), 5400);
370    }
371
372    #[test]
373    fn parse_p1d_returns_86400_seconds() {
374        let d = parse_iso8601_duration("P1D").unwrap();
375        assert_eq!(d.num_seconds(), 86400);
376    }
377
378    #[test]
379    fn parse_empty_string_returns_none() {
380        assert!(parse_iso8601_duration("").is_none());
381    }
382
383    #[test]
384    fn parse_garbage_returns_none() {
385        assert!(parse_iso8601_duration("garbage").is_none());
386        assert!(parse_iso8601_duration("60S").is_none()); // missing 'P'
387    }
388
389    // ── parse_termination_time absolute datetime tests ────────────────────────
390
391    #[test]
392    fn parse_future_absolute_datetime_returns_positive_duration() {
393        let now = Utc::now();
394        let future = now + Duration::seconds(120);
395        let s = future.to_rfc3339();
396        let d = parse_termination_time(&s, now).unwrap();
397        // Should be approximately 120 seconds (allow ±2s for rounding).
398        assert!(d.num_seconds() >= 118 && d.num_seconds() <= 122);
399    }
400
401    #[test]
402    fn parse_past_absolute_datetime_returns_none() {
403        let now = Utc::now();
404        let past = now - Duration::seconds(10);
405        let s = past.to_rfc3339();
406        assert!(parse_termination_time(&s, now).is_none());
407    }
408
409    // ── resolve_subscription_id: header-first, body-fallback ─────────────────
410
411    #[test]
412    fn resolve_sub_id_from_header_takes_priority_over_body() {
413        let body = Bytes::from(
414            r#"<tev:PullMessages xmlns:tev="http://www.onvif.org/ver10/events/wsdl"><tev:SubscriptionId>body-id</tev:SubscriptionId></tev:PullMessages>"#,
415        );
416        let header = Bytes::from(
417            r#"<tev:SubscriptionId xmlns:tev="http://www.onvif.org/ver10/events/wsdl">header-id</tev:SubscriptionId>"#,
418        );
419        let id = resolve_subscription_id(&body, &[header]).unwrap();
420        assert_eq!(id, "header-id");
421    }
422
423    #[test]
424    fn resolve_sub_id_falls_back_to_body_when_no_header() {
425        let body = Bytes::from(
426            r#"<tev:PullMessages xmlns:tev="http://www.onvif.org/ver10/events/wsdl"><tev:SubscriptionId>body-id</tev:SubscriptionId></tev:PullMessages>"#,
427        );
428        let id = resolve_subscription_id(&body, &[]).unwrap();
429        assert_eq!(id, "body-id");
430    }
431
432    #[test]
433    fn resolve_sub_id_unbound_namespace_in_header_accepted() {
434        // Header element with no namespace binding — must be accepted.
435        let body = Bytes::from(
436            r#"<tev:PullMessages xmlns:tev="http://www.onvif.org/ver10/events/wsdl"><tev:SubscriptionId>body-id</tev:SubscriptionId></tev:PullMessages>"#,
437        );
438        let header = Bytes::from(r#"<SubscriptionId>bare-id</SubscriptionId>"#);
439        let id = resolve_subscription_id(&body, &[header]).unwrap();
440        assert_eq!(id, "bare-id");
441    }
442}