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 #[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 assert_eq!(response.responses.len(), 1);
350 for resp in response.responses {
351 assert!(resp.href.path().contains("%20"));
353 assert!(resp.href.path().contains('.'));
355 }
356 assert_eq!(response.member_responses.len(), 1);
357 for resp in response.member_responses {
358 assert!(resp.href.path().contains("%20"));
360 assert!(resp.href.path().contains('.'));
362 }
363 }
364}