diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4fb17ed..a5dfbee 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -88,7 +88,13 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" name = "app" version = "0.1.0" dependencies = [ + "anyhow", + "chrono", + "diqwest", + "icalendar", "log", + "minidom", + "reqwest", "serde", "serde_json", "tauri", @@ -393,6 +399,15 @@ dependencies = [ "toml 0.9.7", ] +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.39" @@ -449,11 +464,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.0", ] +[[package]] +name = "chrono-tz" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efdce149c370f133a071ca8ef6ea340b7b88748ab0810097a9e2976eaa34b4f3" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf 0.11.3", +] + +[[package]] +name = "chrono-tz-build" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f10f8c9340e31fc120ff885fcdb54a0b48e474bbd77cab557f0c30a3e569402" +dependencies = [ + "parse-zoneinfo", + "phf_codegen 0.11.3", +] + [[package]] name = "combine" version = "4.6.7" @@ -464,6 +502,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -678,6 +729,30 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "digest_auth" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3054f4e81d395e50822796c5e99ca522e6ba7be98947d6d4b0e5e61640bdb894" +dependencies = [ + "digest", + "hex", + "md-5", + "rand 0.8.5", + "sha2", +] + +[[package]] +name = "diqwest" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "753559f3659efa905c873c59efad5ef0f5e94912b3dbd2069331c554daa33d81" +dependencies = [ + "digest_auth", + "reqwest", + "url", +] + [[package]] name = "dirs" version = "6.0.0" @@ -1512,6 +1587,20 @@ dependencies = [ "cc", ] +[[package]] +name = "icalendar" +version = "0.17.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f25bc68d1c3113be52919708c870cabe55ba0646b9dade87913fe565aa956a3b" +dependencies = [ + "chrono", + "chrono-tz", + "iso8601", + "nom", + "nom-language", + "uuid", +] + [[package]] name = "ico" version = "0.4.0" @@ -1694,6 +1783,15 @@ dependencies = [ "serde", ] +[[package]] +name = "iso8601" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1082f0c48f143442a1ac6122f67e360ceee130b967af4d50996e5154a45df46" +dependencies = [ + "nom", +] + [[package]] name = "itoa" version = "1.0.15" @@ -1924,6 +2022,16 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.7.6" @@ -1945,6 +2053,15 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minidom" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d0766d3b25519bd1462d4ca35480e1efabe19727bc4c269aba3b255c0a7a66" +dependencies = [ + "rxml", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2029,6 +2146,24 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom-language" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2de2bc5b451bfedaef92c90b8939a8fff5770bdcc1fafd6239d086aab8fa6b29" +dependencies = [ + "nom", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2381,6 +2516,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3135,6 +3279,26 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rxml" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288457ad3607c08953ca5604229ebb03878a0b4491d8f545d547281c2b3e0c5" +dependencies = [ + "bytes", + "futures-core", + "rxml_validation", +] + +[[package]] +name = "rxml_validation" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "826e80413b9a35e9d33217b3dcac04cf95f6559d15944b93887a08be5496c4a4" +dependencies = [ + "compact_str", +] + [[package]] name = "ryu" version = "1.0.20" @@ -3529,6 +3693,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.8.9" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4ca5cca..cdee57d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,3 +27,9 @@ tauri-plugin-keep-screen-on = "0.1.2" tauri-plugin-upload = "2" tauri-plugin-fs = "2" tauri-plugin-tts = { git = "https://github.com/cfpwastaken/tauri-plugin-tts.git" } +reqwest = { version = "0.12.23", default-features = false, features = ["rustls-tls"] } +diqwest = "3.1.0" +minidom = "0.17.0" +icalendar = { version = "0.17.5", features = ["chrono-tz"] } +anyhow = "1.0.100" +chrono = "0.4.42" diff --git a/src-tauri/src/dav.rs b/src-tauri/src/dav.rs new file mode 100644 index 0000000..6701421 --- /dev/null +++ b/src-tauri/src/dav.rs @@ -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#" + + + + + +"#; + +static HOMESET_BODY: &str = r#" + + + + + +"#; + +static CAL_BODY: &str = r#" + + + + + + + +"#; + +static EVENT_BODY: &str = r#" + + + + + + + + + + + + + +"#; + +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 { + 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> + Send; +} + +impl ReqwestAuth for RequestBuilder { + fn send_authed(self, scheme: &AuthScheme, username: &str, password: &str) -> impl std::future::Future> + 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 { + 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 { + 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, + cal_url: Option, +} + +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 { + 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> { + 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 { + 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> { + 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) + } +} diff --git a/src-tauri/src/dav_commands.rs b/src-tauri/src/dav_commands.rs new file mode 100644 index 0000000..0571359 --- /dev/null +++ b/src-tauri/src/dav_commands.rs @@ -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 { + 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, 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, + end: Option, + description: Option, + location: Option, +} + +pub fn dateperhapstime_to_string(d: DatePerhapsTime) -> Option { + 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, String> { + let events = calendar.clone().get_events(&credentials).await.map_err(|e| format!("Failed to fetch events: {}", e))?; + let mut res: Vec = 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) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2e74354..c239605 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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"); } diff --git a/src/lib/components/lnv/sidebar/settings/DeveloperSidebar.svelte b/src/lib/components/lnv/sidebar/settings/DeveloperSidebar.svelte index 53ee162..d327e26 100644 --- a/src/lib/components/lnv/sidebar/settings/DeveloperSidebar.svelte +++ b/src/lib/components/lnv/sidebar/settings/DeveloperSidebar.svelte @@ -1,5 +1,6 @@