feat: caldav client
This commit is contained in:
389
src-tauri/src/dav.rs
Normal file
389
src-tauri/src/dav.rs
Normal file
@@ -0,0 +1,389 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use diqwest::WithDigestAuth;
|
||||
use icalendar::{Calendar};
|
||||
use minidom::Element;
|
||||
use reqwest::{
|
||||
header::{CONTENT_TYPE, USER_AGENT}, Client, Method, RequestBuilder, Url
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
static PRINCIPAL_BODY: &str = r#"
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop>
|
||||
<d:current-user-principal />
|
||||
</d:prop>
|
||||
</d:propfind>
|
||||
"#;
|
||||
|
||||
static HOMESET_BODY: &str = r#"
|
||||
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:prop>
|
||||
<c:calendar-home-set />
|
||||
</d:prop>
|
||||
</d:propfind>
|
||||
"#;
|
||||
|
||||
static CAL_BODY: &str = r#"
|
||||
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" >
|
||||
<d:prop>
|
||||
<d:displayname />
|
||||
<d:resourcetype />
|
||||
<c:supported-calendar-component-set />
|
||||
</d:prop>
|
||||
</d:propfind>
|
||||
"#;
|
||||
|
||||
static EVENT_BODY: &str = r#"
|
||||
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:prop>
|
||||
<d:getetag />
|
||||
<c:calendar-data />
|
||||
</d:prop>
|
||||
<c:filter>
|
||||
<c:comp-filter name="VCALENDAR">
|
||||
<c:comp-filter name="VEVENT" >
|
||||
<c:time-range start="{start}" end="{end}" />
|
||||
</c:comp-filter>
|
||||
</c:comp-filter>
|
||||
</c:filter>
|
||||
</c:calendar-query>
|
||||
"#;
|
||||
|
||||
pub fn find_elems(root: &Element, tag: String) -> Vec<&Element> {
|
||||
let mut elems: Vec<&Element> = Vec::new();
|
||||
|
||||
for el in root.children() {
|
||||
if el.name() == tag {
|
||||
elems.push(el);
|
||||
} else {
|
||||
let ret = find_elems(el, tag.clone());
|
||||
elems.extend(ret);
|
||||
}
|
||||
}
|
||||
elems
|
||||
}
|
||||
|
||||
pub fn find_elem(root: &Element, tag: String) -> Option<&Element> {
|
||||
if root.name() == tag {
|
||||
return Some(root);
|
||||
}
|
||||
|
||||
for el in root.children() {
|
||||
if el.name() == tag {
|
||||
return Some(el);
|
||||
} else {
|
||||
let ret = find_elem(el, tag.clone());
|
||||
if ret.is_some() {
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub enum AuthScheme {
|
||||
Basic,
|
||||
Digest,
|
||||
}
|
||||
|
||||
impl std::str::FromStr for AuthScheme {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"basic" => Ok(AuthScheme::Basic),
|
||||
"digest" => Ok(AuthScheme::Digest),
|
||||
_ => Err(format!("Invalid auth scheme: {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for AuthScheme {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AuthScheme::Basic => write!(f, "Basic"),
|
||||
AuthScheme::Digest => write!(f, "Digest"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ReqwestAuth {
|
||||
fn send_authed(self, scheme: &AuthScheme, username: &str, password: &str) -> impl std::future::Future<Output = anyhow::Result<reqwest::Response>> + Send;
|
||||
}
|
||||
|
||||
impl ReqwestAuth for RequestBuilder {
|
||||
fn send_authed(self, scheme: &AuthScheme, username: &str, password: &str) -> impl std::future::Future<Output = anyhow::Result<reqwest::Response>> + Send {
|
||||
async move {
|
||||
let res = match scheme {
|
||||
AuthScheme::Basic => self.basic_auth(username, Some(password)).send().await?,
|
||||
AuthScheme::Digest => self.send_with_digest_auth(username, password).await?,
|
||||
};
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn request(
|
||||
client: &Client,
|
||||
method: Method,
|
||||
url: Url,
|
||||
body: String,
|
||||
depth: u8,
|
||||
credentials: &DAVCredentials,
|
||||
) -> anyhow::Result<Element> {
|
||||
let res = client
|
||||
.request(method, url)
|
||||
.header("Depth", depth.to_string())
|
||||
.header(USER_AGENT, "TrafficCue")
|
||||
.header(CONTENT_TYPE, "application/xml")
|
||||
.body(body)
|
||||
.send_authed(&credentials.scheme, &credentials.username, &credentials.password)
|
||||
.await?;
|
||||
let status = res.status();
|
||||
if !status.is_success() {
|
||||
Err(anyhow::anyhow!("Request failed with status: {}", status))?;
|
||||
}
|
||||
|
||||
let text = res.text().await?;
|
||||
let root: Element = text.parse()?;
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct DAVCredentials {
|
||||
pub scheme: AuthScheme,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl DAVCredentials {
|
||||
pub async fn find_scheme(url: &Url) -> anyhow::Result<AuthScheme> {
|
||||
let client = Client::new();
|
||||
let res = client
|
||||
.request(Method::from_bytes(b"PROPFIND").expect("Invalid method"), url.clone())
|
||||
.header(USER_AGENT, "TrafficCue")
|
||||
.send()
|
||||
.await;
|
||||
if res.is_err() {
|
||||
return Err(anyhow::anyhow!("Failed to connect to server"));
|
||||
}
|
||||
let res = res.unwrap();
|
||||
if res.status().is_success() {
|
||||
return Err(anyhow::anyhow!("Server did not require authentication"));
|
||||
}
|
||||
let headers = res.headers();
|
||||
if let Some(www_auth) = headers.get("www-authenticate") {
|
||||
let www_auth = www_auth.to_str().unwrap_or("");
|
||||
if www_auth.to_lowercase().contains("digest") {
|
||||
return Ok(AuthScheme::Digest);
|
||||
} else if www_auth.to_lowercase().contains("basic") {
|
||||
return Ok(AuthScheme::Basic);
|
||||
}
|
||||
}
|
||||
Err(anyhow::anyhow!("Could not determine authentication scheme"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Client for interacting with a CalDAV server.
|
||||
/// ## Example
|
||||
/// ```
|
||||
/// let client = DAVClient::new("https://cal.example.com/.well-known/caldav", DAVCredentials {
|
||||
/// username: "user".into(),
|
||||
/// password: "pass".into(),
|
||||
/// });
|
||||
/// client.init().await?;
|
||||
/// let calendars = client.get_calendars().await?;
|
||||
/// for cal in calendars {
|
||||
/// println!("{}", cal);
|
||||
/// let events = cal.get_events(&client.credentials).await?;
|
||||
/// for event in events {
|
||||
/// println!("Event:\n{}", event);
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub struct DAVClient {
|
||||
pub url: Url,
|
||||
pub credentials: DAVCredentials,
|
||||
client: Client,
|
||||
|
||||
principal_url: Option<Url>,
|
||||
cal_url: Option<Url>,
|
||||
}
|
||||
|
||||
impl DAVClient {
|
||||
/// **Ensure the URL is actually the CalDAV endpoint, and not just the base URL.**
|
||||
pub fn new(string_url: &str, credentials: DAVCredentials) -> Self {
|
||||
let url = Url::parse(string_url).expect("Invalid URL");
|
||||
Self {
|
||||
url,
|
||||
credentials,
|
||||
client: Client::new(),
|
||||
principal_url: None,
|
||||
cal_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn init(&mut self) -> anyhow::Result<()> {
|
||||
self.get_principal().await?;
|
||||
self.get_cal_homeset().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn _propfind(
|
||||
&mut self,
|
||||
url: Url,
|
||||
body: String,
|
||||
depth: u8,
|
||||
) -> anyhow::Result<Element> {
|
||||
let method = Method::from_bytes(b"PROPFIND").expect("Invalid method");
|
||||
let root = request(
|
||||
&self.client,
|
||||
method,
|
||||
url,
|
||||
body,
|
||||
depth,
|
||||
&self.credentials,
|
||||
).await?;
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
pub async fn get_principal(&mut self) -> anyhow::Result<()> {
|
||||
let root: Element = self
|
||||
._propfind(self.url.clone(), PRINCIPAL_BODY.to_string(), 0)
|
||||
.await?;
|
||||
let principal = find_elem(&root, "current-user-principal".to_string())
|
||||
.ok_or(anyhow::anyhow!("No principal found"))?;
|
||||
let principal_href =
|
||||
find_elem(principal, "href".to_string()).ok_or(anyhow::anyhow!("No href found"))?;
|
||||
let h_str = principal_href.text();
|
||||
let mut p_url = self.url.clone();
|
||||
p_url.set_path(&h_str);
|
||||
self.principal_url = Some(p_url);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_cal_homeset(&mut self) -> anyhow::Result<()> {
|
||||
if self.principal_url.is_none() {
|
||||
return Err(anyhow::anyhow!("Principal URL not set"));
|
||||
}
|
||||
let root: Element = self
|
||||
._propfind(
|
||||
self.principal_url.clone().unwrap(),
|
||||
HOMESET_BODY.to_string(),
|
||||
0,
|
||||
)
|
||||
.await?;
|
||||
let homeset = find_elem(&root, "calendar-home-set".to_string())
|
||||
.ok_or(anyhow::anyhow!("No calendar-home-set found"))?;
|
||||
let href =
|
||||
find_elem(homeset, "href".to_string()).ok_or(anyhow::anyhow!("No href found"))?;
|
||||
let h_str = href.text();
|
||||
let mut cal_url = self.url.clone();
|
||||
cal_url.set_path(&h_str);
|
||||
self.cal_url = Some(cal_url.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_calendars(&mut self) -> anyhow::Result<Vec<DAVCalendar>> {
|
||||
if self.cal_url.is_none() {
|
||||
return Err(anyhow::anyhow!("Calendar URL not set"));
|
||||
}
|
||||
let root: Element = self
|
||||
._propfind(self.cal_url.clone().unwrap(), CAL_BODY.to_string(), 1)
|
||||
.await?;
|
||||
let reps = find_elems(&root, "response".to_string());
|
||||
let mut calendars = Vec::new();
|
||||
for rep in reps {
|
||||
let displayname = find_elem(rep, "displayname".to_string())
|
||||
.ok_or(anyhow::anyhow!("No displayname found"))?
|
||||
.text();
|
||||
if displayname == "" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let resourcetype = find_elem(rep, "resourcetype".to_string())
|
||||
.ok_or(anyhow::anyhow!("No resourcetype found"))?;
|
||||
let is_calendar = find_elem(resourcetype, "calendar".to_string()).is_some();
|
||||
if !is_calendar {
|
||||
continue;
|
||||
}
|
||||
|
||||
let url_base = self.cal_url.as_ref().unwrap();
|
||||
let mut href = url_base.clone();
|
||||
href.set_path(
|
||||
&find_elem(rep, "href".to_string())
|
||||
.ok_or(anyhow::anyhow!("No href found"))?
|
||||
.text(),
|
||||
);
|
||||
calendars.push(DAVCalendar {
|
||||
name: displayname,
|
||||
url: href,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(calendars)
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a CalDAV calendar.
|
||||
/// Can be used to fetch events.
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct DAVCalendar {
|
||||
pub name: String,
|
||||
pub url: Url,
|
||||
}
|
||||
|
||||
impl Display for DAVCalendar {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{} ({})", self.name, self.url)
|
||||
}
|
||||
}
|
||||
|
||||
impl DAVCalendar {
|
||||
pub fn new(name: &str, url: Url) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
url,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn _report(
|
||||
&mut self,
|
||||
credentials: &DAVCredentials,
|
||||
body: String,
|
||||
) -> anyhow::Result<Element> {
|
||||
let client = Client::new();
|
||||
let method = Method::from_bytes(b"REPORT").expect("Invalid method");
|
||||
let root = request(
|
||||
&client,
|
||||
method,
|
||||
self.url.clone(),
|
||||
body,
|
||||
1,
|
||||
credentials,
|
||||
).await?;
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
pub async fn get_events(
|
||||
&mut self,
|
||||
credentials: &DAVCredentials,
|
||||
) -> anyhow::Result<Vec<Calendar>> {
|
||||
let mut events = Vec::new();
|
||||
let time = chrono::Utc::now();
|
||||
let start = time.format("%Y%m%dT000000Z").to_string();
|
||||
let end = time.format("%Y%m%dT235959Z").to_string();
|
||||
let body = EVENT_BODY.replace("{start}", &start).replace("{end}", &end);
|
||||
let root = self._report(credentials, body).await?;
|
||||
let datas = find_elems(&root, "calendar-data".to_string());
|
||||
for data in datas {
|
||||
let etext = data.text();
|
||||
let cal: Calendar = etext.parse().unwrap();
|
||||
events.push(cal);
|
||||
}
|
||||
|
||||
Ok(events)
|
||||
}
|
||||
}
|
||||
64
src-tauri/src/dav_commands.rs
Normal file
64
src-tauri/src/dav_commands.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use chrono::Local;
|
||||
use icalendar::{CalendarComponent, Component, DatePerhapsTime, EventLike};
|
||||
|
||||
use crate::dav::{DAVClient, DAVCalendar, DAVCredentials};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn dav_find_scheme(url: &str) -> Result<String, String> {
|
||||
let url = reqwest::Url::parse(url).map_err(|e| format!("Invalid URL: {}", e))?;
|
||||
let scheme = DAVCredentials::find_scheme(&url).await.map_err(|e| format!("Failed to determine authentication scheme: {}", e))?;
|
||||
Ok(scheme.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn dav_fetch_calendars(url: &str, credentials: DAVCredentials) -> Result<Vec<DAVCalendar>, String> {
|
||||
let mut client = DAVClient::new(url, credentials);
|
||||
client.init().await.map_err(|e| format!("Failed to initialize DAV client: {}", e))?;
|
||||
let calendars = client.get_calendars().await.map_err(|e| format!("Failed to fetch calendars: {}", e))?;
|
||||
Ok(calendars)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct DAVEvent {
|
||||
summary: String,
|
||||
start: Option<String>,
|
||||
end: Option<String>,
|
||||
description: Option<String>,
|
||||
location: Option<String>,
|
||||
}
|
||||
|
||||
pub fn dateperhapstime_to_string(d: DatePerhapsTime) -> Option<String> {
|
||||
match d {
|
||||
DatePerhapsTime::DateTime(dt) => dt.try_into_utc(),
|
||||
DatePerhapsTime::Date(d) => d
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.and_then(|dt| dt.and_local_timezone(Local).earliest())
|
||||
.map(|dt| dt.to_utc()),
|
||||
}.map(|dt| dt.to_rfc3339())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn dav_fetch_events(calendar: DAVCalendar, credentials: DAVCredentials) -> Result<Vec<DAVEvent>, String> {
|
||||
let events = calendar.clone().get_events(&credentials).await.map_err(|e| format!("Failed to fetch events: {}", e))?;
|
||||
let mut res: Vec<DAVEvent> = Vec::new();
|
||||
for event in events {
|
||||
for component in &event.components {
|
||||
if let CalendarComponent::Event(event) = component {
|
||||
let location = event.get_location();
|
||||
if location.is_none() {
|
||||
continue;
|
||||
}
|
||||
let start_at = event.get_start().and_then(dateperhapstime_to_string);
|
||||
let end_at = event.get_end().and_then(dateperhapstime_to_string);
|
||||
res.push(DAVEvent {
|
||||
summary: event.get_summary().unwrap_or("No summary").to_string(),
|
||||
start: start_at,
|
||||
end: end_at,
|
||||
description: event.get_description().map(|s| s.to_string()),
|
||||
location: location.map(|s| s.to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
mod dav;
|
||||
mod dav_commands;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
@@ -15,6 +18,7 @@ pub fn run() {
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![dav_commands::dav_find_scheme, dav_commands::dav_fetch_calendars, dav_commands::dav_fetch_events])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user