Skip to main content

rustical_caldav/calendar/methods/report/
mod.rs

1use crate::{
2    CalDavPrincipalUri, Error,
3    calendar::CalendarResourceService,
4    calendar_object::{
5        CalendarObjectPropWrapper, CalendarObjectPropWrapperName, resource::CalendarObjectResource,
6    },
7};
8use axum::{
9    Extension,
10    extract::{MatchedPath, OriginalUri, Path, State},
11    response::IntoResponse,
12};
13use calendar_multiget::{CalendarMultigetRequest, get_objects_calendar_multiget};
14use calendar_query::{CalendarQueryRequest, get_objects_calendar_query};
15use http::{StatusCode, Uri};
16use rustical_dav::{
17    resource::{PrincipalUri, Resource},
18    rfc_3986_percent_encode,
19    xml::{
20        MultistatusElement, PropfindType, multistatus::ResponseElement,
21        sync_collection::SyncCollectionRequest,
22    },
23};
24use rustical_dav_push::DavPushStore;
25use rustical_ical::CalendarObject;
26use rustical_store::{CalendarStore, auth::Principal};
27use rustical_xml::{XmlDeserialize, XmlDocument};
28use sync_collection::handle_sync_collection;
29use tracing::instrument;
30
31mod calendar_multiget;
32pub mod calendar_query;
33mod sync_collection;
34
35#[derive(XmlDeserialize, XmlDocument, Clone, Debug, PartialEq)]
36pub(crate) enum ReportRequest {
37    #[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
38    CalendarMultiget(CalendarMultigetRequest),
39    #[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
40    CalendarQuery(CalendarQueryRequest),
41    #[xml(ns = "rustical_dav::namespace::NS_DAV")]
42    SyncCollection(SyncCollectionRequest<CalendarObjectPropWrapperName>),
43}
44
45impl ReportRequest {
46    const fn props(&self) -> &PropfindType<CalendarObjectPropWrapperName> {
47        match &self {
48            Self::CalendarMultiget(CalendarMultigetRequest { prop, .. })
49            | Self::CalendarQuery(CalendarQueryRequest { prop, .. })
50            | Self::SyncCollection(SyncCollectionRequest { prop, .. }) => prop,
51        }
52    }
53}
54
55#[allow(clippy::too_many_arguments)]
56fn objects_response(
57    objects: Vec<(String, CalendarObject)>,
58    not_found: Vec<Uri>,
59    path: &str,
60    principal: &str,
61    puri: &impl PrincipalUri,
62    user: &Principal,
63    prop: &PropfindType<CalendarObjectPropWrapperName>,
64    omit_outside_time_range: bool,
65) -> Result<MultistatusElement<CalendarObjectPropWrapper, String>, Error> {
66    let mut responses = Vec::new();
67    for (object_id, object) in objects {
68        let path = format!(
69            "{path}/{object_id}.ics",
70            path = path.trim_end_matches('/'),
71            object_id = rfc_3986_percent_encode(&object_id)
72        );
73
74        let resource = CalendarObjectResource {
75            object,
76            object_id,
77            principal: principal.to_owned(),
78        };
79        let response = match resource.propfind(&path, prop, None, puri, user) {
80            Ok(response) => response,
81            // NotFound is thrown when the expanded calendar object doesn't fall into the request
82            // time range
83            Err(Error::NotFound) if omit_outside_time_range => continue,
84            Err(err) => return Err(err),
85        };
86
87        responses.push(response);
88    }
89
90    let not_found_responses = not_found
91        .into_iter()
92        .map(|href| ResponseElement {
93            href,
94            status: Some(StatusCode::NOT_FOUND),
95            propstat: vec![],
96        })
97        .collect();
98
99    Ok(MultistatusElement {
100        responses,
101        member_responses: not_found_responses,
102        ..Default::default()
103    })
104}
105
106#[instrument(skip(cal_store))]
107pub async fn route_report_calendar<C: CalendarStore, DP: DavPushStore>(
108    Path((principal, cal_id)): Path<(String, String)>,
109    user: Principal,
110    Extension(puri): Extension<CalDavPrincipalUri>,
111    State(CalendarResourceService { cal_store, .. }): State<CalendarResourceService<C, DP>>,
112    OriginalUri(uri): OriginalUri,
113    matched_path: MatchedPath,
114    body: String,
115) -> Result<impl IntoResponse, Error> {
116    if !user.is_principal(&principal) {
117        return Err(Error::Unauthorized);
118    }
119
120    let request = ReportRequest::parse_str(&body)?;
121    let props = request.props();
122
123    Ok(match &request {
124        ReportRequest::CalendarQuery(cal_query) => {
125            let objects =
126                get_objects_calendar_query(cal_query, &principal, &cal_id, cal_store.as_ref())
127                    .await?;
128            objects_response(
129                objects,
130                vec![],
131                uri.path(),
132                &principal,
133                &puri,
134                &user,
135                props,
136                true,
137            )?
138        }
139        ReportRequest::CalendarMultiget(cal_multiget) => {
140            let (objects, not_found) = get_objects_calendar_multiget(
141                cal_multiget,
142                &uri,
143                &principal,
144                &cal_id,
145                cal_store.as_ref(),
146            )
147            .await?;
148            objects_response(
149                objects,
150                not_found,
151                uri.path(),
152                &principal,
153                &puri,
154                &user,
155                props,
156                false,
157            )?
158        }
159        ReportRequest::SyncCollection(sync_collection) => {
160            handle_sync_collection(
161                sync_collection,
162                uri.path(),
163                &puri,
164                &user,
165                &principal,
166                &cal_id,
167                cal_store.as_ref(),
168            )
169            .await?
170        }
171    })
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::calendar_object::{CalendarData, CalendarObjectPropName, ExpandElement};
178    use axum::{Router, body::Body};
179    use calendar_query::{CompFilterElement, FilterElement, TimeRangeElement};
180    use chrono::{TimeZone, Utc};
181    use http::Request;
182    use rstest::rstest;
183    use rustical_dav::{extensions::CommonPropertiesPropName, xml::PropElement};
184    use rustical_ical::UtcDateTime;
185    use rustical_xml::{NamespaceOwned, ValueDeserialize};
186    use tower::ServiceExt;
187
188    #[test]
189    fn test_xml_calendar_data() {
190        let report_request = ReportRequest::parse_str(r#"
191            <?xml version="1.0" encoding="UTF-8"?>
192            <calendar-multiget xmlns="urn:ietf:params:xml:ns:caldav" xmlns:D="DAV:">
193                <D:prop>
194                    <D:getetag/>
195                    <calendar-data>
196                        <expand start="20250426T220000Z" end="20250503T220000Z"/>
197                    </calendar-data>
198                </D:prop>
199                <D:href>/caldav/user/user/6f787542-5256-401a-8db97003260da/ae7a998fdfd1d84a20391168962c62b</D:href>
200            </calendar-multiget>
201        "#).unwrap();
202
203        assert_eq!(
204            report_request,
205            ReportRequest::CalendarMultiget(CalendarMultigetRequest {
206                prop: rustical_dav::xml::PropfindType::Prop(PropElement(vec![
207                    CalendarObjectPropWrapperName::CalendarObject(CalendarObjectPropName::Getetag),
208                    CalendarObjectPropWrapperName::CalendarObject(CalendarObjectPropName::CalendarData(
209                        CalendarData { comp: None, prop: None, expand: Some(ExpandElement {
210                        start: <UtcDateTime as ValueDeserialize>::deserialize("20250426T220000Z").unwrap(),
211                        end: <UtcDateTime as ValueDeserialize>::deserialize("20250503T220000Z").unwrap(),
212                    }), limit_recurrence_set: None, limit_freebusy_set: None }
213                    )),
214                ], vec![])),
215                href: vec![
216                    "/caldav/user/user/6f787542-5256-401a-8db97003260da/ae7a998fdfd1d84a20391168962c62b".parse().unwrap()
217                ]
218            })
219        );
220    }
221
222    #[test]
223    fn test_xml_calendar_query() {
224        let report_request = ReportRequest::parse_str(
225            r#"
226            <?xml version='1.0' encoding='UTF-8' ?>
227            <CAL:calendar-query xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav">
228                <prop>
229                    <getetag />
230                </prop>
231                <CAL:filter>
232                    <CAL:comp-filter name="VCALENDAR">
233                        <CAL:comp-filter name="VEVENT">
234                            <CAL:time-range start="20240924T143437Z" />
235                        </CAL:comp-filter>
236                    </CAL:comp-filter>
237                </CAL:filter>
238            </CAL:calendar-query>"#,
239        )
240        .unwrap();
241        assert_eq!(
242            report_request,
243            ReportRequest::CalendarQuery(CalendarQueryRequest {
244                prop: rustical_dav::xml::PropfindType::Prop(PropElement(
245                    vec![CalendarObjectPropWrapperName::CalendarObject(
246                        CalendarObjectPropName::Getetag
247                    ),],
248                    vec![]
249                )),
250                filter: Some(FilterElement {
251                    comp_filter: CompFilterElement {
252                        is_not_defined: None,
253                        time_range: None,
254                        prop_filter: vec![],
255                        comp_filter: vec![CompFilterElement {
256                            is_not_defined: None,
257                            time_range: Some(TimeRangeElement {
258                                start: Some(
259                                    <UtcDateTime as ValueDeserialize>::deserialize(
260                                        "20240924T143437Z"
261                                    )
262                                    .unwrap()
263                                ),
264                                end: None
265                            }),
266                            prop_filter: vec![],
267                            comp_filter: vec![],
268                            name: "VEVENT".to_owned()
269                        }],
270                        name: "VCALENDAR".to_owned()
271                    }
272                }),
273                timezone: None,
274                timezone_id: None,
275            })
276        );
277    }
278
279    #[test]
280    fn test_xml_calendar_multiget() {
281        let report_request = ReportRequest::parse_str(r#"
282            <?xml version="1.0" encoding="UTF-8"?>
283            <calendar-multiget xmlns="urn:ietf:params:xml:ns:caldav" xmlns:D="DAV:">
284                <D:prop>
285                    <D:getetag/>
286                    <D:displayname/>
287                    <D:invalid-prop/>
288                </D:prop>
289                <D:href>/caldav/user/user/6f787542-5256-401a-8db97003260da/ae7a998fdfd1d84a20391168962c62b</D:href>
290            </calendar-multiget>
291        "#).unwrap();
292
293        assert_eq!(
294            report_request,
295            ReportRequest::CalendarMultiget(CalendarMultigetRequest {
296                prop: rustical_dav::xml::PropfindType::Prop(PropElement(vec![
297                    CalendarObjectPropWrapperName::CalendarObject(CalendarObjectPropName::Getetag),
298                    CalendarObjectPropWrapperName::Common(CommonPropertiesPropName::Displayname),
299                ], vec![(Some(NamespaceOwned("DAV:".to_owned())), "invalid-prop".to_string())])),
300                href: vec![
301                    "/caldav/user/user/6f787542-5256-401a-8db97003260da/ae7a998fdfd1d84a20391168962c62b".parse().unwrap()
302                ]
303            })
304        );
305    }
306
307    /// Ensure that the path extractor urldecodes all paths
308    #[tokio::test]
309    #[rstest]
310    #[case("user", "user")]
311    #[case("user%20with%20space", "user with space")]
312    #[case("asd%40asd%2Ede", "asd@asd.de")]
313    #[case("slash%2Fslash", "slash/slash")]
314    async fn test_path_extractor_urlencoding(#[case] input: &str, #[case] expected: &'static str) {
315        let app = Router::new().route(
316            "/{yeet}",
317            axum::routing::get(async |Path(path): Path<String>| path),
318        );
319        let req = Request::builder()
320            .uri(format!("/{input}"))
321            .body(Body::empty())
322            .unwrap();
323        let resp = app.oneshot(req).await.unwrap();
324
325        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
326            .await
327            .unwrap();
328        let body = String::from_utf8(bytes.to_vec()).unwrap();
329        assert_eq!(body, expected);
330    }
331
332    #[rstest]
333    fn test_objects_response() {
334        let response = objects_response(
335            vec![(
336                "found with space".to_string(),
337                CalendarObject::from_ics(
338                    r"BEGIN:VCALENDAR
339VERSION:2.0
340PRODID:-//Example Corp.//CalDAV Client//EN
341BEGIN:VEVENT
342UID:20010712T182145Z-123401@example.com
343DTSTAMP:20060712T182145Z
344DTSTART:20060714T170000Z
345DTEND:20060715T040000Z
346SUMMARY:Bastille Day Party
347END:VEVENT
348END:VCALENDAR"
349                        .to_string(),
350                )
351                .unwrap(),
352            )],
353            vec!["/caldav/principal/user/not%20found.ics".parse().unwrap()],
354            "/caldav/principal/user%40rustical.dev/cal",
355            "user@rustical.dev",
356            &CalDavPrincipalUri::new("/caldav"),
357            &Principal {
358                id: "user@rustical.dev".to_string(),
359                displayname: None,
360                principal_type: rustical_store::auth::PrincipalType::Individual,
361                password: None,
362                memberships: vec![],
363            },
364            &PropfindType::Propname,
365            false,
366        )
367        .unwrap();
368
369        // Make sure we get responses for both
370        assert_eq!(response.responses.len(), 1);
371        for resp in response.responses {
372            // Make sure spaces are escaped
373            assert!(resp.href.path().contains("%20"));
374            // Make sure periods are not escaped
375            assert!(resp.href.path().contains('.'));
376        }
377        assert_eq!(response.member_responses.len(), 1);
378        for resp in response.member_responses {
379            // Make sure spaces are escaped
380            assert!(resp.href.path().contains("%20"));
381            // Make sure periods are not escaped
382            assert!(resp.href.path().contains('.'));
383        }
384    }
385
386    #[rstest]
387    fn test_objects_outside_time_range() {
388        let response = objects_response(
389            vec![(
390                "no-occurence-in-range".to_string(),
391                CalendarObject::from_ics(
392                    r"BEGIN:VCALENDAR
393VERSION:2.0
394PRODID:-//Example Corp.//CalDAV Client//EN
395BEGIN:VEVENT
396UID:20010712T182145Z-123401@example.com
397DTSTAMP:20060712T182145Z
398DTSTART:20060714T170000Z
399RRULE:FREQ=YEARLY;COUNT=1
400DTEND:20060715T040000Z
401SUMMARY:Bastille Day Party
402END:VEVENT
403END:VCALENDAR"
404                        .to_string(),
405                )
406                .unwrap(),
407            )],
408            vec![],
409            "/caldav/principal/user%40rustical.dev/cal",
410            "user@rustical.dev",
411            &CalDavPrincipalUri::new("/caldav"),
412            &Principal {
413                id: "user@rustical.dev".to_string(),
414                displayname: None,
415                principal_type: rustical_store::auth::PrincipalType::Individual,
416                password: None,
417                memberships: vec![],
418            },
419            &PropfindType::Prop(PropElement(
420                vec![CalendarObjectPropWrapperName::CalendarObject(
421                    CalendarObjectPropName::CalendarData(CalendarData {
422                        expand: Some(ExpandElement {
423                            start: UtcDateTime(Utc.with_ymd_and_hms(1900, 1, 1, 0, 0, 0).unwrap()),
424                            end: UtcDateTime(Utc.with_ymd_and_hms(1901, 1, 1, 0, 0, 0).unwrap()),
425                        }),
426                        ..Default::default()
427                    }),
428                )],
429                vec![],
430            )),
431            true,
432        )
433        .unwrap();
434
435        // Make sure we get responses for both
436        assert!(response.responses.is_empty());
437    }
438}