kitchen_fridge/
client.rs

1//! This module provides a client to connect to a CalDAV server
2
3use std::error::Error;
4use std::convert::TryFrom;
5use std::collections::HashMap;
6use std::sync::{Arc, Mutex};
7
8use async_trait::async_trait;
9use reqwest::{Method, StatusCode};
10use reqwest::header::CONTENT_TYPE;
11use minidom::Element;
12use url::Url;
13use csscolorparser::Color;
14
15use crate::resource::Resource;
16use crate::utils::{find_elem, find_elems};
17use crate::calendar::remote_calendar::RemoteCalendar;
18use crate::calendar::SupportedComponents;
19use crate::traits::CalDavSource;
20use crate::traits::BaseCalendar;
21use crate::traits::DavCalendar;
22
23
24static DAVCLIENT_BODY: &str = r#"
25    <d:propfind xmlns:d="DAV:">
26       <d:prop>
27           <d:current-user-principal />
28       </d:prop>
29    </d:propfind>
30"#;
31
32static HOMESET_BODY: &str = r#"
33    <d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" >
34      <d:self/>
35      <d:prop>
36        <c:calendar-home-set />
37      </d:prop>
38    </d:propfind>
39"#;
40
41static CAL_BODY: &str = r#"
42    <d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" >
43       <d:prop>
44         <d:displayname />
45         <E:calendar-color xmlns:E="http://apple.com/ns/ical/"/>
46         <d:resourcetype />
47         <c:supported-calendar-component-set />
48       </d:prop>
49    </d:propfind>
50"#;
51
52
53
54pub(crate) async fn sub_request(resource: &Resource, method: &str, body: String, depth: u32) -> Result<String, Box<dyn Error>> {
55    let method = method.parse()
56        .expect("invalid method name");
57
58    let res = reqwest::Client::new()
59        .request(method, resource.url().clone())
60        .header("Depth", depth)
61        .header(CONTENT_TYPE, "application/xml")
62        .basic_auth(resource.username(), Some(resource.password()))
63        .body(body)
64        .send()
65        .await?;
66
67    if res.status().is_success() == false {
68        return Err(format!("Unexpected HTTP status code {:?}", res.status()).into());
69    }
70
71    let text = res.text().await?;
72    Ok(text)
73}
74
75pub(crate) async fn sub_request_and_extract_elem(resource: &Resource, body: String, items: &[&str]) -> Result<String, Box<dyn Error>> {
76    let text = sub_request(resource, "PROPFIND", body, 0).await?;
77
78    let mut current_element: &Element = &text.parse()?;
79    for item in items {
80        current_element = match find_elem(&current_element, item) {
81            Some(elem) => elem,
82            None => return Err(format!("missing element {}", item).into()),
83        }
84    }
85    Ok(current_element.text())
86}
87
88pub(crate) async fn sub_request_and_extract_elems(resource: &Resource, method: &str, body: String, item: &str) -> Result<Vec<Element>, Box<dyn Error>> {
89    let text = sub_request(resource, method, body, 1).await?;
90
91    let element: &Element = &text.parse()?;
92    Ok(find_elems(&element, item)
93        .iter()
94        .map(|elem| (*elem).clone())
95        .collect()
96    )
97}
98
99
100/// A CalDAV data source that fetches its data from a CalDAV server
101#[derive(Debug)]
102pub struct Client {
103    resource: Resource,
104
105    /// The interior mutable part of a Client.
106    /// This data may be retrieved once and then cached
107    cached_replies: Mutex<CachedReplies>,
108}
109
110
111#[derive(Debug, Default)]
112struct CachedReplies {
113    principal: Option<Resource>,
114    calendar_home_set: Option<Resource>,
115    calendars: Option<HashMap<Url, Arc<Mutex<RemoteCalendar>>>>,
116}
117
118impl Client {
119    /// Create a client. This does not start a connection
120    pub fn new<S: AsRef<str>, T: ToString, U: ToString>(url: S, username: T, password: U) -> Result<Self, Box<dyn Error>> {
121        let url = Url::parse(url.as_ref())?;
122
123        Ok(Self{
124            resource: Resource::new(url, username.to_string(), password.to_string()),
125            cached_replies: Mutex::new(CachedReplies::default()),
126        })
127    }
128
129    /// Return the Principal URL, or fetch it from server if not known yet
130    async fn get_principal(&self) -> Result<Resource, Box<dyn Error>> {
131        if let Some(p) = &self.cached_replies.lock().unwrap().principal {
132            return Ok(p.clone());
133        }
134
135        let href = sub_request_and_extract_elem(&self.resource, DAVCLIENT_BODY.into(), &["current-user-principal", "href"]).await?;
136        let principal_url = self.resource.combine(&href);
137        self.cached_replies.lock().unwrap().principal = Some(principal_url.clone());
138        log::debug!("Principal URL is {}", href);
139
140        return Ok(principal_url);
141    }
142
143    /// Return the Homeset URL, or fetch it from server if not known yet
144    async fn get_cal_home_set(&self) -> Result<Resource, Box<dyn Error>> {
145        if let Some(h) = &self.cached_replies.lock().unwrap().calendar_home_set {
146            return Ok(h.clone());
147        }
148        let principal_url = self.get_principal().await?;
149
150        let href = sub_request_and_extract_elem(&principal_url, HOMESET_BODY.into(), &["calendar-home-set", "href"]).await?;
151        let chs_url = self.resource.combine(&href);
152        self.cached_replies.lock().unwrap().calendar_home_set = Some(chs_url.clone());
153        log::debug!("Calendar home set URL is {:?}", href);
154
155        Ok(chs_url)
156    }
157
158    async fn populate_calendars(&self) -> Result<(), Box<dyn Error>> {
159        let cal_home_set = self.get_cal_home_set().await?;
160
161        let reps = sub_request_and_extract_elems(&cal_home_set, "PROPFIND", CAL_BODY.to_string(), "response").await?;
162        let mut calendars = HashMap::new();
163        for rep in reps {
164            let display_name = find_elem(&rep, "displayname").map(|e| e.text()).unwrap_or("<no name>".to_string());
165            log::debug!("Considering calendar {}", display_name);
166
167            // We filter out non-calendar items
168            let resource_types = match find_elem(&rep, "resourcetype") {
169                None => continue,
170                Some(rt) => rt,
171            };
172            let mut found_calendar_type = false;
173            for resource_type in resource_types.children() {
174                if resource_type.name() == "calendar" {
175                    found_calendar_type = true;
176                    break;
177                }
178            }
179            if found_calendar_type == false {
180                continue;
181            }
182
183            // We filter out the root calendar collection, that has an empty supported-calendar-component-set
184            let el_supported_comps = match find_elem(&rep, "supported-calendar-component-set") {
185                None => continue,
186                Some(comps) => comps,
187            };
188            if el_supported_comps.children().count() == 0 {
189                continue;
190            }
191
192            let calendar_href = match find_elem(&rep, "href") {
193                None => {
194                    log::warn!("Calendar {} has no URL! Ignoring it.", display_name);
195                    continue;
196                },
197                Some(h) => h.text(),
198            };
199
200            let this_calendar_url = self.resource.combine(&calendar_href);
201
202            let supported_components = match crate::calendar::SupportedComponents::try_from(el_supported_comps.clone()) {
203                Err(err) => {
204                    log::warn!("Calendar {} has invalid supported components ({})! Ignoring it.", display_name, err);
205                    continue;
206                },
207                Ok(sc) => sc,
208            };
209
210            let this_calendar_color = find_elem(&rep, "calendar-color")
211                .and_then(|col| {
212                    col.texts().next()
213                        .and_then(|t| csscolorparser::parse(t).ok())
214                });
215
216            let this_calendar = RemoteCalendar::new(display_name, this_calendar_url, supported_components, this_calendar_color);
217            log::info!("Found calendar {}", this_calendar.name());
218            calendars.insert(this_calendar.url().clone(), Arc::new(Mutex::new(this_calendar)));
219        }
220
221        let mut replies = self.cached_replies.lock().unwrap();
222        replies.calendars = Some(calendars);
223        Ok(())
224    }
225
226}
227
228#[async_trait]
229impl CalDavSource<RemoteCalendar> for Client {
230    async fn get_calendars(&self) -> Result<HashMap<Url, Arc<Mutex<RemoteCalendar>>>, Box<dyn Error>> {
231        self.populate_calendars().await?;
232
233        match &self.cached_replies.lock().unwrap().calendars {
234            Some(cals) => {
235                return Ok(cals.clone())
236            },
237            None => return Err("No calendars available".into())
238        };
239    }
240
241    async fn get_calendar(&self, url: &Url) -> Option<Arc<Mutex<RemoteCalendar>>> {
242        if let Err(err) = self.populate_calendars().await {
243            log::warn!("Unable to fetch calendars: {}", err);
244            return None;
245        }
246
247        self.cached_replies.lock().unwrap()
248            .calendars
249            .as_ref()
250            .and_then(|cals| cals.get(url))
251            .map(|cal| cal.clone())
252    }
253
254    async fn create_calendar(&mut self, url: Url, name: String, supported_components: SupportedComponents, color: Option<Color>) -> Result<Arc<Mutex<RemoteCalendar>>, Box<dyn Error>> {
255        self.populate_calendars().await?;
256
257        match self.cached_replies.lock().unwrap().calendars.as_ref() {
258            None => return Err("No calendars have been fetched".into()),
259            Some(cals) => {
260                if cals.contains_key(&url) {
261                    return Err("This calendar already exists".into());
262                }
263            },
264        }
265
266        let creation_body = calendar_body(name, supported_components, color);
267
268        let response = reqwest::Client::new()
269            .request(Method::from_bytes(b"MKCALENDAR").unwrap(), url.clone())
270            .header(CONTENT_TYPE, "application/xml")
271            .basic_auth(self.resource.username(), Some(self.resource.password()))
272            .body(creation_body)
273            .send()
274            .await?;
275
276        let status = response.status();
277        if status != StatusCode::CREATED {
278            return Err(format!("Unexpected HTTP status code. Expected CREATED, got {}", status.as_u16()).into());
279        }
280
281        self.get_calendar(&url).await.ok_or(format!("Unable to insert calendar {:?}", url).into())
282    }
283}
284
285fn calendar_body(name: String, supported_components: SupportedComponents, color: Option<Color>) -> String {
286    let color_property = match color {
287        None => "".to_string(),
288        Some(color) => format!("<D:calendar-color xmlns:D=\"http://apple.com/ns/ical/\">{}FF</D:calendar-color>", color.to_hex_string().to_ascii_uppercase()),
289    };
290
291    // This is taken from https://tools.ietf.org/html/rfc4791#page-24
292    format!(r#"<?xml version="1.0" encoding="utf-8" ?>
293        <B:mkcalendar xmlns:B="urn:ietf:params:xml:ns:caldav">
294            <A:set xmlns:A="DAV:">
295                <A:prop>
296                    <A:displayname>{}</A:displayname>
297                    {}
298                    {}
299                </A:prop>
300            </A:set>
301        </B:mkcalendar>
302        "#,
303        name,
304        color_property,
305        supported_components.to_xml_string(),
306    )
307}