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