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 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 #[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 assert_eq!(response.responses.len(), 1);
371 for resp in response.responses {
372 assert!(resp.href.path().contains("%20"));
374 assert!(resp.href.path().contains('.'));
376 }
377 assert_eq!(response.member_responses.len(), 1);
378 for resp in response.member_responses {
379 assert!(resp.href.path().contains("%20"));
381 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 assert!(response.responses.is_empty());
437 }
438}