Skip to main content

rustical_caldav/calendar/methods/
mkcalendar.rs

1use crate::Error;
2use crate::calendar::CalendarResourceService;
3use crate::calendar::prop::SupportedCalendarComponentSet;
4use crate::error::Precondition;
5use axum::extract::{Path, State};
6use axum::response::{IntoResponse, Response};
7use caldata::IcalParser;
8use http::{Method, StatusCode};
9use rustical_dav::xml::HrefElement;
10use rustical_ical::CalendarObjectType;
11use rustical_store::auth::Principal;
12use rustical_store::{Calendar, CalendarMetadata, CalendarStore, SubscriptionStore};
13use rustical_xml::{Unparsed, XmlDeserialize, XmlDocument, XmlRootTag};
14use std::str::FromStr;
15use tracing::instrument;
16
17#[derive(XmlDeserialize, Clone, Debug)]
18pub struct MkcolCalendarProp {
19    #[xml(ns = "rustical_dav::namespace::NS_DAV")]
20    displayname: Option<String>,
21    #[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
22    calendar_description: Option<String>,
23    #[xml(ns = "rustical_dav::namespace::NS_ICAL")]
24    calendar_color: Option<String>,
25    #[xml(ns = "rustical_dav::namespace::NS_ICAL")]
26    calendar_order: Option<i64>,
27    #[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
28    calendar_timezone: Option<String>,
29    #[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
30    calendar_timezone_id: Option<String>,
31    #[xml(ns = "rustical_dav::namespace::NS_DAV")]
32    #[allow(dead_code)]
33    resourcetype: Option<Unparsed>,
34    #[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
35    supported_calendar_component_set: Option<SupportedCalendarComponentSet>,
36    #[xml(ns = "rustical_dav::namespace::NS_CALENDARSERVER")]
37    source: Option<HrefElement>,
38    // Ignore that property, we don't support it but also don't want to throw an error
39    #[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
40    #[allow(dead_code)]
41    calendar_free_busy_set: Option<Unparsed>,
42}
43
44#[derive(XmlDeserialize, Clone, Debug)]
45pub struct PropElement {
46    #[xml(ns = "rustical_dav::namespace::NS_DAV")]
47    prop: MkcolCalendarProp,
48}
49
50#[derive(XmlDeserialize, XmlRootTag, Clone, Debug)]
51#[xml(root = "mkcalendar")]
52#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
53struct MkcalendarRequest {
54    #[xml(ns = "rustical_dav::namespace::NS_DAV")]
55    set: PropElement,
56}
57
58#[derive(XmlDeserialize, XmlRootTag, Clone, Debug)]
59#[xml(root = "mkcol")]
60#[xml(ns = "rustical_dav::namespace::NS_DAV")]
61struct MkcolRequest {
62    #[xml(ns = "rustical_dav::namespace::NS_DAV")]
63    set: PropElement,
64}
65
66#[instrument(skip(cal_store))]
67pub async fn route_mkcalendar<C: CalendarStore, S: SubscriptionStore>(
68    Path((principal, cal_id)): Path<(String, String)>,
69    user: Principal,
70    State(CalendarResourceService { cal_store, .. }): State<CalendarResourceService<C, S>>,
71    method: Method,
72    body: String,
73) -> Result<Response, Error> {
74    if !user.is_principal(&principal) {
75        return Err(Error::Unauthorized);
76    }
77
78    let mut request = match method.as_str() {
79        "MKCALENDAR" => MkcalendarRequest::parse_str(&body)?.set.prop,
80        "MKCOL" => MkcolRequest::parse_str(&body)?.set.prop,
81        _ => unreachable!("We never call with another method"),
82    };
83
84    if request.displayname.as_deref() == Some("") {
85        request.displayname = None;
86    }
87
88    let timezone_id = if let Some(tzid) = request.calendar_timezone_id {
89        if chrono_tz::Tz::from_str(&tzid).is_err() {
90            return Err(Error::PreconditionFailed(Precondition::CalendarTimezone(
91                "Invalid timezone ID in calendar-timezone-id",
92            )));
93        }
94        Some(tzid)
95    } else if let Some(tz) = request.calendar_timezone {
96        let calendar = IcalParser::from_slice(tz.as_bytes())
97            .next()
98            .ok_or(Error::PreconditionFailed(Precondition::CalendarTimezone(
99                "No timezone data provided",
100            )))?
101            .map_err(|err| {
102                tracing::error!(%err);
103                Error::PreconditionFailed(Precondition::CalendarTimezone("Error parsing timezone"))
104            })
105            .inspect_err(|e| tracing::error!(%e))?;
106
107        let timezone = calendar
108            .vtimezones
109            .values()
110            .next()
111            .ok_or(Error::PreconditionFailed(Precondition::CalendarTimezone(
112                "No timezone data provided",
113            )))?;
114        let timezone: Option<chrono_tz::Tz> = timezone.into();
115        let timezone = timezone.ok_or(Error::PreconditionFailed(
116            Precondition::CalendarTimezone("No timezone data provided"),
117        ))?;
118
119        Some(timezone.name().to_owned())
120    } else {
121        None
122    };
123
124    let calendar = Calendar {
125        id: cal_id.clone(),
126        principal: principal.clone(),
127        meta: CalendarMetadata {
128            order: request.calendar_order.unwrap_or(0),
129            displayname: request.displayname,
130            color: request.calendar_color,
131            description: request.calendar_description,
132        },
133        timezone_id,
134        deleted_at: None,
135        synctoken: 0,
136        subscription_url: request.source.map(|href| href.href),
137        push_topic: uuid::Uuid::new_v4().to_string(),
138        components: request.supported_calendar_component_set.map_or_else(
139            || {
140                vec![
141                    CalendarObjectType::Event,
142                    CalendarObjectType::Todo,
143                    CalendarObjectType::Journal,
144                ]
145            },
146            Into::into,
147        ),
148    };
149
150    cal_store.insert_calendar(calendar).await?;
151    // The spec says we don't have to return a response everything was successful
152    Ok(StatusCode::CREATED.into_response())
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn test_xml_mkcalendar() {
161        MkcalendarRequest::parse_str(r#"
162            <?xml version='1.0' encoding='UTF-8' ?>
163            <CAL:mkcalendar xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CARD="urn:ietf:params:xml:ns:carddav">
164                <set>
165                    <prop>
166                        <resourcetype>
167                            <collection />
168                            <CAL:calendar />
169                        </resourcetype>
170                        <displayname>jfs</displayname>
171                        <CAL:calendar-description>rggg</CAL:calendar-description>
172                        <n0:calendar-color xmlns:n0="http://apple.com/ns/ical/">#FFF8DCFF</n0:calendar-color>
173                        <CAL:calendar-timezone-id>Europe/Berlin</CAL:calendar-timezone-id>
174                        <CAL:supported-calendar-component-set>
175                            <CAL:comp name="VEVENT"/>
176                            <CAL:comp name="VTODO"/>
177                            <CAL:comp name="VJOURNAL"/>
178                        </CAL:supported-calendar-component-set>
179                        <CAL:calendar-timezone>BEGIN:VCALENDAR\r\nBEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nLAST-MODIFIED:20240422T053450Z\r\nTZURL:https://www.tzurl.org/zoneinfo/Europe/Berlin\r\nX-LIC-LOCATION:Europe/Berlin\r\nX-PROLEPTIC-TZNAME:LMT\r\nBEGIN:STANDARD\r\nTZNAME:CET\r\nTZOFFSETFROM:+005328\r\nTZOFFSETTO:+0100\r\nDTSTART:18930401T000632\r\nEND:STANDARD\r\nBEGIN:DAYLIGHT\r\nTZNAME:CEST\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nDTSTART:19160430T230000\r\nRDATE:19400401T020000\r\nRDATE:19430329T020000\r\nRDATE:19460414T020000\r\nRDATE:19470406T030000\r\nRDATE:19480418T020000\r\nRDATE:19490410T020000\r\nRDATE:19800406T020000\r\nEND:DAYLIGHT\r\nBEGIN:STANDARD\r\nTZNAME:CET\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nDTSTART:19161001T010000\r\nRDATE:19421102T030000\r\nRDATE:19431004T030000\r\nRDATE:19441002T030000\r\nRDATE:19451118T030000\r\nRDATE:19461007T030000\r\nEND:STANDARD\r\nBEGIN:DAYLIGHT\r\nTZNAME:CEST\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nDTSTART:19170416T020000\r\nRRULE:FREQ=YEARLY;UNTIL=19180415T010000Z;BYMONTH=4;BYDAY=3MO\r\nEND:DAYLIGHT\r\nBEGIN:STANDARD\r\nTZNAME:CET\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nDTSTART:19170917T030000\r\nRRULE:FREQ=YEARLY;UNTIL=19180916T010000Z;BYMONTH=9;BYDAY=3MO\r\nEND:STANDARD\r\nBEGIN:DAYLIGHT\r\nTZNAME:CEST\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nDTSTART:19440403T020000\r\nRRULE:FREQ=YEARLY;UNTIL=19450402T010000Z;BYMONTH=4;BYDAY=1MO\r\nEND:DAYLIGHT\r\nBEGIN:DAYLIGHT\r\nTZNAME:CEMT\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0300\r\nDTSTART:19450524T000000\r\nRDATE:19470511T010000\r\nEND:DAYLIGHT\r\nBEGIN:DAYLIGHT\r\nTZNAME:CEST\r\nTZOFFSETFROM:+0300\r\nTZOFFSETTO:+0200\r\nDTSTART:19450924T030000\r\nRDATE:19470629T030000\r\nEND:DAYLIGHT\r\nBEGIN:STANDARD\r\nTZNAME:CET\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0100\r\nDTSTART:19460101T000000\r\nRDATE:19800101T000000\r\nEND:STANDARD\r\nBEGIN:STANDARD\r\nTZNAME:CET\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nDTSTART:19471005T030000\r\nRRULE:FREQ=YEARLY;UNTIL=19491002T010000Z;BYMONTH=10;BYDAY=1SU\r\nEND:STANDARD\r\nBEGIN:STANDARD\r\nTZNAME:CET\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nDTSTART:19800928T030000\r\nRRULE:FREQ=YEARLY;UNTIL=19950924T010000Z;BYMONTH=9;BYDAY=-1SU\r\nEND:STANDARD\r\nBEGIN:DAYLIGHT\r\nTZNAME:CEST\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nDTSTART:19810329T020000\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nEND:DAYLIGHT\r\nBEGIN:STANDARD\r\nTZNAME:CET\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nDTSTART:19961027T030000\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\nEND:VCALENDAR\r\n</CAL:calendar-timezone>
180                    </prop>
181                </set>
182            </CAL:mkcalendar>
183    "#).unwrap();
184    }
185
186    #[test]
187    fn test_xml_mkcol() {
188        MkcolRequest::parse_str(r#"
189            <?xml version='1.0' encoding='UTF-8' ?>
190            <mkcol xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CARD="urn:ietf:params:xml:ns:carddav">
191                <set>
192                    <prop>
193                        <resourcetype>
194                            <collection />
195                            <CAL:calendar />
196                        </resourcetype>
197                        <displayname>jfs</displayname>
198                        <CAL:calendar-description>rggg</CAL:calendar-description>
199                        <n0:calendar-color xmlns:n0="http://apple.com/ns/ical/">#FFF8DCFF</n0:calendar-color>
200                        <CAL:calendar-timezone-id>Europe/Berlin</CAL:calendar-timezone-id>
201                        <CAL:supported-calendar-component-set>
202                            <CAL:comp name="VEVENT"/>
203                            <CAL:comp name="VTODO"/>
204                            <CAL:comp name="VJOURNAL"/>
205                        </CAL:supported-calendar-component-set>
206                        <CAL:calendar-timezone>BEGIN:VCALENDAR\r\nBEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nLAST-MODIFIED:20240422T053450Z\r\nTZURL:https://www.tzurl.org/zoneinfo/Europe/Berlin\r\nX-LIC-LOCATION:Europe/Berlin\r\nX-PROLEPTIC-TZNAME:LMT\r\nBEGIN:STANDARD\r\nTZNAME:CET\r\nTZOFFSETFROM:+005328\r\nTZOFFSETTO:+0100\r\nDTSTART:18930401T000632\r\nEND:STANDARD\r\nBEGIN:DAYLIGHT\r\nTZNAME:CEST\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nDTSTART:19160430T230000\r\nRDATE:19400401T020000\r\nRDATE:19430329T020000\r\nRDATE:19460414T020000\r\nRDATE:19470406T030000\r\nRDATE:19480418T020000\r\nRDATE:19490410T020000\r\nRDATE:19800406T020000\r\nEND:DAYLIGHT\r\nBEGIN:STANDARD\r\nTZNAME:CET\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nDTSTART:19161001T010000\r\nRDATE:19421102T030000\r\nRDATE:19431004T030000\r\nRDATE:19441002T030000\r\nRDATE:19451118T030000\r\nRDATE:19461007T030000\r\nEND:STANDARD\r\nBEGIN:DAYLIGHT\r\nTZNAME:CEST\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nDTSTART:19170416T020000\r\nRRULE:FREQ=YEARLY;UNTIL=19180415T010000Z;BYMONTH=4;BYDAY=3MO\r\nEND:DAYLIGHT\r\nBEGIN:STANDARD\r\nTZNAME:CET\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nDTSTART:19170917T030000\r\nRRULE:FREQ=YEARLY;UNTIL=19180916T010000Z;BYMONTH=9;BYDAY=3MO\r\nEND:STANDARD\r\nBEGIN:DAYLIGHT\r\nTZNAME:CEST\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nDTSTART:19440403T020000\r\nRRULE:FREQ=YEARLY;UNTIL=19450402T010000Z;BYMONTH=4;BYDAY=1MO\r\nEND:DAYLIGHT\r\nBEGIN:DAYLIGHT\r\nTZNAME:CEMT\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0300\r\nDTSTART:19450524T000000\r\nRDATE:19470511T010000\r\nEND:DAYLIGHT\r\nBEGIN:DAYLIGHT\r\nTZNAME:CEST\r\nTZOFFSETFROM:+0300\r\nTZOFFSETTO:+0200\r\nDTSTART:19450924T030000\r\nRDATE:19470629T030000\r\nEND:DAYLIGHT\r\nBEGIN:STANDARD\r\nTZNAME:CET\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0100\r\nDTSTART:19460101T000000\r\nRDATE:19800101T000000\r\nEND:STANDARD\r\nBEGIN:STANDARD\r\nTZNAME:CET\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nDTSTART:19471005T030000\r\nRRULE:FREQ=YEARLY;UNTIL=19491002T010000Z;BYMONTH=10;BYDAY=1SU\r\nEND:STANDARD\r\nBEGIN:STANDARD\r\nTZNAME:CET\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nDTSTART:19800928T030000\r\nRRULE:FREQ=YEARLY;UNTIL=19950924T010000Z;BYMONTH=9;BYDAY=-1SU\r\nEND:STANDARD\r\nBEGIN:DAYLIGHT\r\nTZNAME:CEST\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nDTSTART:19810329T020000\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nEND:DAYLIGHT\r\nBEGIN:STANDARD\r\nTZNAME:CET\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nDTSTART:19961027T030000\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\nEND:VCALENDAR\r\n</CAL:calendar-timezone>
207                    </prop>
208                </set>
209            </mkcol>
210    "#).unwrap();
211    }
212
213    #[test]
214    fn test_xml_mkcol_apple() {
215        let mkcol = MkcalendarRequest::parse_str(
216            r#"
217<?xml version="1.0" encoding="UTF-8"?>
218<B:mkcalendar xmlns:B="urn:ietf:params:xml:ns:caldav">
219  <A:set xmlns:A="DAV:">
220    <A:prop>
221      <B:calendar-free-busy-set>
222        <NO/>
223      </B:calendar-free-busy-set>
224      <G:calendar-color xmlns:G="http://apple.com/ns/ical/" symbolic-color="custom">#0088FF</G:calendar-color>
225      <A:displayname>rdgrdgre</A:displayname>
226      <B:supported-calendar-component-set>
227        <B:comp name="VEVENT"/>
228      </B:supported-calendar-component-set>
229      <B:calendar-timezone>BEGIN:VCALENDAR&#13;
230VERSION:2.0&#13;
231CALSCALE:GREGORIAN&#13;
232BEGIN:VTIMEZONE&#13;
233TZID:Europe/Berlin&#13;
234BEGIN:DAYLIGHT&#13;
235TZOFFSETFROM:+0100&#13;
236RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU&#13;
237DTSTART:19810329T020000&#13;
238TZNAME:CEST&#13;
239TZOFFSETTO:+0200&#13;
240END:DAYLIGHT&#13;
241BEGIN:STANDARD&#13;
242TZOFFSETFROM:+0200&#13;
243RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU&#13;
244DTSTART:19961027T030000&#13;
245TZNAME:CET&#13;
246TZOFFSETTO:+0100&#13;
247END:STANDARD&#13;
248END:VTIMEZONE&#13;
249END:VCALENDAR&#13;
250</B:calendar-timezone>
251      <G:calendar-order xmlns:G="http://apple.com/ns/ical/">5116</G:calendar-order>
252    </A:prop>
253  </A:set>
254</B:mkcalendar>
255    "#,
256        )
257        .unwrap();
258
259        insta::assert_debug_snapshot!(mkcol);
260    }
261}