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_ical::CalendarObject;
25use rustical_store::{CalendarStore, SubscriptionStore, auth::Principal};
26use rustical_xml::{XmlDeserialize, XmlDocument};
27use std::str::FromStr;
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
55fn objects_response(
56    objects: Vec<(String, CalendarObject)>,
57    not_found: Vec<String>,
58    path: &str,
59    principal: &str,
60    puri: &impl PrincipalUri,
61    user: &Principal,
62    prop: &PropfindType<CalendarObjectPropWrapperName>,
63) -> Result<MultistatusElement<CalendarObjectPropWrapper, String>, Error> {
64    let mut responses = Vec::new();
65    for (object_id, object) in objects {
66        let path = format!(
67            "{path}/{object_id}.ics",
68            path = path.trim_end_matches('/'),
69            object_id = rfc_3986_percent_encode(&object_id)
70        );
71        responses.push(
72            CalendarObjectResource {
73                object,
74                object_id,
75                principal: principal.to_owned(),
76            }
77            .propfind(&path, prop, None, puri, user)?,
78        );
79    }
80
81    let not_found_responses = not_found
82        .into_iter()
83        .map(|path| ResponseElement {
84            href: Uri::from_str(&path).unwrap(),
85            status: Some(StatusCode::NOT_FOUND),
86            propstat: vec![],
87        })
88        .collect();
89
90    Ok(MultistatusElement {
91        responses,
92        member_responses: not_found_responses,
93        ..Default::default()
94    })
95}
96
97#[instrument(skip(cal_store))]
98pub async fn route_report_calendar<C: CalendarStore, S: SubscriptionStore>(
99    Path((principal, cal_id)): Path<(String, String)>,
100    user: Principal,
101    Extension(puri): Extension<CalDavPrincipalUri>,
102    State(CalendarResourceService { cal_store, .. }): State<CalendarResourceService<C, S>>,
103    OriginalUri(uri): OriginalUri,
104    matched_path: MatchedPath,
105    body: String,
106) -> Result<impl IntoResponse, Error> {
107    if !user.is_principal(&principal) {
108        return Err(Error::Unauthorized);
109    }
110
111    let request = ReportRequest::parse_str(&body)?;
112    let props = request.props();
113
114    Ok(match &request {
115        ReportRequest::CalendarQuery(cal_query) => {
116            let objects =
117                get_objects_calendar_query(cal_query, &principal, &cal_id, cal_store.as_ref())
118                    .await?;
119            objects_response(objects, vec![], uri.path(), &principal, &puri, &user, props)?
120        }
121        ReportRequest::CalendarMultiget(cal_multiget) => {
122            let (objects, not_found) = get_objects_calendar_multiget(
123                cal_multiget,
124                &uri,
125                &principal,
126                &cal_id,
127                cal_store.as_ref(),
128            )
129            .await?;
130            objects_response(
131                objects,
132                not_found,
133                uri.path(),
134                &principal,
135                &puri,
136                &user,
137                props,
138            )?
139        }
140        ReportRequest::SyncCollection(sync_collection) => {
141            handle_sync_collection(
142                sync_collection,
143                uri.path(),
144                &puri,
145                &user,
146                &principal,
147                &cal_id,
148                cal_store.as_ref(),
149            )
150            .await?
151        }
152    })
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::calendar_object::{CalendarData, CalendarObjectPropName, ExpandElement};
159    use axum::{Router, body::Body};
160    use calendar_query::{CompFilterElement, FilterElement, TimeRangeElement};
161    use http::Request;
162    use rstest::rstest;
163    use rustical_dav::{extensions::CommonPropertiesPropName, xml::PropElement};
164    use rustical_ical::UtcDateTime;
165    use rustical_xml::{NamespaceOwned, ValueDeserialize};
166    use tower::ServiceExt;
167
168    #[test]
169    fn test_xml_calendar_data() {
170        let report_request = ReportRequest::parse_str(r#"
171            <?xml version="1.0" encoding="UTF-8"?>
172            <calendar-multiget xmlns="urn:ietf:params:xml:ns:caldav" xmlns:D="DAV:">
173                <D:prop>
174                    <D:getetag/>
175                    <calendar-data>
176                        <expand start="20250426T220000Z" end="20250503T220000Z"/>
177                    </calendar-data>
178                </D:prop>
179                <D:href>/caldav/user/user/6f787542-5256-401a-8db97003260da/ae7a998fdfd1d84a20391168962c62b</D:href>
180            </calendar-multiget>
181        "#).unwrap();
182
183        assert_eq!(
184            report_request,
185            ReportRequest::CalendarMultiget(CalendarMultigetRequest {
186                prop: rustical_dav::xml::PropfindType::Prop(PropElement(vec![
187                    CalendarObjectPropWrapperName::CalendarObject(CalendarObjectPropName::Getetag),
188                    CalendarObjectPropWrapperName::CalendarObject(CalendarObjectPropName::CalendarData(
189                        CalendarData { comp: None, prop: None, expand: Some(ExpandElement {
190                        start: <UtcDateTime as ValueDeserialize>::deserialize("20250426T220000Z").unwrap(),
191                        end: <UtcDateTime as ValueDeserialize>::deserialize("20250503T220000Z").unwrap(),
192                    }), limit_recurrence_set: None, limit_freebusy_set: None }
193                    )),
194                ], vec![])),
195                href: vec![
196                    "/caldav/user/user/6f787542-5256-401a-8db97003260da/ae7a998fdfd1d84a20391168962c62b".to_owned()
197                ]
198            })
199        );
200    }
201
202    #[test]
203    fn test_xml_calendar_query() {
204        let report_request = ReportRequest::parse_str(
205            r#"
206            <?xml version='1.0' encoding='UTF-8' ?>
207            <CAL:calendar-query xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav">
208                <prop>
209                    <getetag />
210                </prop>
211                <CAL:filter>
212                    <CAL:comp-filter name="VCALENDAR">
213                        <CAL:comp-filter name="VEVENT">
214                            <CAL:time-range start="20240924T143437Z" />
215                        </CAL:comp-filter>
216                    </CAL:comp-filter>
217                </CAL:filter>
218            </CAL:calendar-query>"#,
219        )
220        .unwrap();
221        assert_eq!(
222            report_request,
223            ReportRequest::CalendarQuery(CalendarQueryRequest {
224                prop: rustical_dav::xml::PropfindType::Prop(PropElement(
225                    vec![CalendarObjectPropWrapperName::CalendarObject(
226                        CalendarObjectPropName::Getetag
227                    ),],
228                    vec![]
229                )),
230                filter: Some(FilterElement {
231                    comp_filter: CompFilterElement {
232                        is_not_defined: None,
233                        time_range: None,
234                        prop_filter: vec![],
235                        comp_filter: vec![CompFilterElement {
236                            is_not_defined: None,
237                            time_range: Some(TimeRangeElement {
238                                start: Some(
239                                    <UtcDateTime as ValueDeserialize>::deserialize(
240                                        "20240924T143437Z"
241                                    )
242                                    .unwrap()
243                                ),
244                                end: None
245                            }),
246                            prop_filter: vec![],
247                            comp_filter: vec![],
248                            name: "VEVENT".to_owned()
249                        }],
250                        name: "VCALENDAR".to_owned()
251                    }
252                }),
253                timezone: None,
254                timezone_id: None,
255            })
256        );
257    }
258
259    #[test]
260    fn test_xml_calendar_multiget() {
261        let report_request = ReportRequest::parse_str(r#"
262            <?xml version="1.0" encoding="UTF-8"?>
263            <calendar-multiget xmlns="urn:ietf:params:xml:ns:caldav" xmlns:D="DAV:">
264                <D:prop>
265                    <D:getetag/>
266                    <D:displayname/>
267                    <D:invalid-prop/>
268                </D:prop>
269                <D:href>/caldav/user/user/6f787542-5256-401a-8db97003260da/ae7a998fdfd1d84a20391168962c62b</D:href>
270            </calendar-multiget>
271        "#).unwrap();
272
273        assert_eq!(
274            report_request,
275            ReportRequest::CalendarMultiget(CalendarMultigetRequest {
276                prop: rustical_dav::xml::PropfindType::Prop(PropElement(vec![
277                    CalendarObjectPropWrapperName::CalendarObject(CalendarObjectPropName::Getetag),
278                    CalendarObjectPropWrapperName::Common(CommonPropertiesPropName::Displayname),
279                ], vec![(Some(NamespaceOwned(Vec::from("DAV:"))), "invalid-prop".to_string())])),
280                href: vec![
281                    "/caldav/user/user/6f787542-5256-401a-8db97003260da/ae7a998fdfd1d84a20391168962c62b".to_owned()
282                ]
283            })
284        );
285    }
286
287    /// Ensure that the path extractor urldecodes all paths
288    #[tokio::test]
289    #[rstest]
290    #[case("user", "user")]
291    #[case("user%20with%20space", "user with space")]
292    #[case("asd%40asd%2Ede", "asd@asd.de")]
293    #[case("slash%2Fslash", "slash/slash")]
294    async fn test_path_extractor_urlencoding(#[case] input: &str, #[case] expected: &'static str) {
295        let app = Router::new().route(
296            "/{yeet}",
297            axum::routing::get(async |Path(path): Path<String>| path),
298        );
299        let req = Request::builder()
300            .uri(format!("/{input}"))
301            .body(Body::empty())
302            .unwrap();
303        let resp = app.oneshot(req).await.unwrap();
304
305        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
306            .await
307            .unwrap();
308        let body = String::from_utf8(bytes.to_vec()).unwrap();
309        assert_eq!(body, expected);
310    }
311
312    #[rstest]
313    fn test_objects_response() {
314        let response = objects_response(
315            vec![(
316                "found with space".to_string(),
317                CalendarObject::from_ics(
318                    r"BEGIN:VCALENDAR
319VERSION:2.0
320PRODID:-//Example Corp.//CalDAV Client//EN
321BEGIN:VEVENT
322UID:20010712T182145Z-123401@example.com
323DTSTAMP:20060712T182145Z
324DTSTART:20060714T170000Z
325DTEND:20060715T040000Z
326SUMMARY:Bastille Day Party
327END:VEVENT
328END:VCALENDAR"
329                        .to_string(),
330                )
331                .unwrap(),
332            )],
333            vec!["/caldav/principal/user/not%20found.ics".to_string()],
334            "/caldav/principal/user%40rustical.dev/cal",
335            "user@rustical.dev",
336            &CalDavPrincipalUri::new("/caldav"),
337            &Principal {
338                id: "user@rustical.dev".to_string(),
339                displayname: None,
340                principal_type: rustical_store::auth::PrincipalType::Individual,
341                password: None,
342                memberships: vec![],
343            },
344            &PropfindType::Propname,
345        )
346        .unwrap();
347
348        // Make sure we get responses for both
349        assert_eq!(response.responses.len(), 1);
350        for resp in response.responses {
351            // Make sure spaces are escaped
352            assert!(resp.href.path().contains("%20"));
353            // Make sure periods are not escaped
354            assert!(resp.href.path().contains('.'));
355        }
356        assert_eq!(response.member_responses.len(), 1);
357        for resp in response.member_responses {
358            // Make sure spaces are escaped
359            assert!(resp.href.path().contains("%20"));
360            // Make sure periods are not escaped
361            assert!(resp.href.path().contains('.'));
362        }
363    }
364}