From 103ac0b1f952b06fcf22919e1415f4d0ec2c644a Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Tue, 10 Jun 2025 17:23:11 +0200 Subject: [PATCH] Implement download feature for calendars and addressbooks Fixes #70 --- Cargo.lock | 4 + Cargo.toml | 1 + crates/caldav/Cargo.toml | 2 + crates/caldav/src/calendar/methods/get.rs | 96 +++++++++++++++++++ crates/caldav/src/calendar/methods/mod.rs | 1 + crates/caldav/src/calendar/service.rs | 8 ++ crates/carddav/Cargo.toml | 1 + crates/carddav/src/addressbook/methods/get.rs | 59 ++++++++++++ crates/carddav/src/addressbook/methods/mod.rs | 1 + crates/carddav/src/addressbook/service.rs | 8 ++ crates/frontend/Cargo.toml | 5 +- crates/frontend/public/assets/style.css | 16 +++- .../frontend/public/templates/pages/user.html | 26 +++-- crates/frontend/src/routes/app_token.rs | 2 + crates/ical/src/icalendar/event.rs | 4 +- crates/ical/src/icalendar/object.rs | 4 + 16 files changed, 223 insertions(+), 15 deletions(-) create mode 100644 crates/caldav/src/calendar/methods/get.rs create mode 100644 crates/carddav/src/addressbook/methods/get.rs diff --git a/Cargo.lock b/Cargo.lock index 9a98513..f93be36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2693,6 +2693,8 @@ dependencies = [ "futures-util", "headers", "http", + "ical", + "percent-encoding", "quick-xml", "rustical_dav", "rustical_dav_push", @@ -2722,6 +2724,7 @@ dependencies = [ "derive_more", "futures-util", "http", + "percent-encoding", "quick-xml", "rustical_dav", "rustical_dav_push", @@ -2797,6 +2800,7 @@ dependencies = [ "hex", "http", "mime_guess", + "percent-encoding", "rand 0.9.1", "rust-embed", "rustical_ical", diff --git a/Cargo.toml b/Cargo.toml index b784276..17e17e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,6 +98,7 @@ tower-http = { version = "0.6", features = [ "normalize-path", "catch-panic", ] } +percent-encoding = "2.3" rustical_dav = { path = "./crates/dav/" } rustical_dav_push = { path = "./crates/dav_push/" } rustical_store = { path = "./crates/store/" } diff --git a/crates/caldav/Cargo.toml b/crates/caldav/Cargo.toml index 6d11624..6781cef 100644 --- a/crates/caldav/Cargo.toml +++ b/crates/caldav/Cargo.toml @@ -25,6 +25,8 @@ rustical_store = { workspace = true } chrono = { workspace = true } chrono-tz = { workspace = true } sha2 = { workspace = true } +ical.workspace = true +percent-encoding.workspace = true rustical_xml.workspace = true uuid.workspace = true rustical_dav_push.workspace = true diff --git a/crates/caldav/src/calendar/methods/get.rs b/crates/caldav/src/calendar/methods/get.rs new file mode 100644 index 0000000..f24f67d --- /dev/null +++ b/crates/caldav/src/calendar/methods/get.rs @@ -0,0 +1,96 @@ +use crate::Error; +use crate::calendar::CalendarResourceService; +use axum::body::Body; +use axum::extract::State; +use axum::{extract::Path, response::Response}; +use headers::{ContentType, HeaderMapExt}; +use http::{HeaderValue, StatusCode, header}; +use ical::generator::{Emitter, IcalCalendarBuilder}; +use ical::property::Property; +use percent_encoding::{CONTROLS, utf8_percent_encode}; +use rustical_ical::{CalendarObjectComponent, EventObject, JournalObject, TodoObject}; +use rustical_store::{CalendarStore, SubscriptionStore, auth::User}; +use std::collections::HashMap; +use std::str::FromStr; +use tracing::instrument; + +#[instrument(skip(cal_store))] +pub async fn route_get( + Path((principal, calendar_id)): Path<(String, String)>, + State(CalendarResourceService { cal_store, .. }): State>, + user: User, +) -> Result { + if !user.is_principal(&principal) { + return Err(crate::Error::Unauthorized); + } + + let calendar = cal_store.get_calendar(&principal, &calendar_id).await?; + if !user.is_principal(&calendar.principal) { + return Err(crate::Error::Unauthorized); + } + + let calendar = cal_store.get_calendar(&principal, &calendar_id).await?; + + let mut timezones = HashMap::new(); + let objects = cal_store.get_objects(&principal, &calendar_id).await?; + + let mut ical_calendar_builder = IcalCalendarBuilder::version("4.0") + .gregorian() + .prodid("RustiCal"); + if calendar.displayname.is_some() { + ical_calendar_builder = ical_calendar_builder.set(Property { + name: "X-WR-CALNAME".to_owned(), + value: calendar.displayname, + params: None, + }); + } + if calendar.description.is_some() { + ical_calendar_builder = ical_calendar_builder.set(Property { + name: "X-WR-CALDESC".to_owned(), + value: calendar.description, + params: None, + }); + } + if calendar.timezone_id.is_some() { + ical_calendar_builder = ical_calendar_builder.set(Property { + name: "X-WR-TIMEZONE".to_owned(), + value: calendar.timezone_id, + params: None, + }); + } + let mut ical_calendar = ical_calendar_builder.build(); + + for object in &objects { + match object.get_data() { + CalendarObjectComponent::Event(EventObject { + event, + timezones: object_timezones, + .. + }) => { + timezones.extend(object_timezones); + ical_calendar.events.push(event.clone()); + } + CalendarObjectComponent::Todo(TodoObject { todo, .. }) => { + ical_calendar.todos.push(todo.clone()); + } + CalendarObjectComponent::Journal(JournalObject { journal, .. }) => { + ical_calendar.journals.push(journal.clone()); + } + } + } + + let mut resp = Response::builder().status(StatusCode::OK); + let hdrs = resp.headers_mut().unwrap(); + hdrs.typed_insert(ContentType::from_str("text/calendar").unwrap()); + + let filename = format!("{}_{}.ics", calendar.principal, calendar.id); + let filename = utf8_percent_encode(&filename, CONTROLS); + hdrs.insert( + header::CONTENT_DISPOSITION, + HeaderValue::from_str(&format!( + "attachement; filename*=UTF-8''{filename}; filename={filename}", + )) + .unwrap(), + ); + Ok(resp.body(Body::new(ical_calendar.generate())).unwrap()) +} diff --git a/crates/caldav/src/calendar/methods/mod.rs b/crates/caldav/src/calendar/methods/mod.rs index a4e758f..3acf321 100644 --- a/crates/caldav/src/calendar/methods/mod.rs +++ b/crates/caldav/src/calendar/methods/mod.rs @@ -1,3 +1,4 @@ pub mod mkcalendar; // pub mod post; +pub mod get; pub mod report; diff --git a/crates/caldav/src/calendar/service.rs b/crates/caldav/src/calendar/service.rs index e82a46b..5721206 100644 --- a/crates/caldav/src/calendar/service.rs +++ b/crates/caldav/src/calendar/service.rs @@ -1,3 +1,4 @@ +use crate::calendar::methods::get::route_get; use crate::calendar::methods::mkcalendar::route_mkcalendar; use crate::calendar::methods::report::route_report_calendar; use crate::calendar::resource::CalendarResource; @@ -118,6 +119,13 @@ impl AxumMethods for CalendarResourceSer }) } + fn get() -> Option BoxFuture<'static, Result>> { + Some(|state, req| { + let mut service = Handler::with_state(route_get::, state); + Box::pin(Service::call(&mut service, req)) + }) + } + fn mkcalendar() -> Option BoxFuture<'static, Result>> { Some(|state, req| { diff --git a/crates/carddav/Cargo.toml b/crates/carddav/Cargo.toml index ee577a2..c899639 100644 --- a/crates/carddav/Cargo.toml +++ b/crates/carddav/Cargo.toml @@ -29,3 +29,4 @@ rustical_dav_push.workspace = true rustical_ical.workspace = true http.workspace = true tower-http.workspace = true +percent-encoding.workspace = true diff --git a/crates/carddav/src/addressbook/methods/get.rs b/crates/carddav/src/addressbook/methods/get.rs new file mode 100644 index 0000000..9df2d97 --- /dev/null +++ b/crates/carddav/src/addressbook/methods/get.rs @@ -0,0 +1,59 @@ +use crate::Error; +use crate::addressbook::AddressbookResourceService; +use crate::addressbook::resource::AddressbookResource; +use axum::body::Body; +use axum::extract::{Path, State}; +use axum::response::Response; +use axum_extra::headers::{ContentType, HeaderMapExt}; +use http::{HeaderValue, StatusCode, header}; +use percent_encoding::{CONTROLS, utf8_percent_encode}; +use rustical_dav::privileges::UserPrivilege; +use rustical_dav::resource::Resource; +use rustical_ical::AddressObject; +use rustical_store::auth::User; +use rustical_store::{AddressbookStore, SubscriptionStore}; +use std::str::FromStr; +use tracing::instrument; + +#[instrument(skip(addr_store))] +pub async fn route_get( + Path((principal, addressbook_id)): Path<(String, String)>, + State(AddressbookResourceService { addr_store, .. }): State>, + user: User, +) -> Result { + if !user.is_principal(&principal) { + return Err(Error::Unauthorized); + } + + let addressbook = addr_store + .get_addressbook(&principal, &addressbook_id, false) + .await?; + let addressbook_resource = AddressbookResource(addressbook); + if !addressbook_resource + .get_user_privileges(&user)? + .has(&UserPrivilege::Read) + { + return Err(Error::Unauthorized); + } + + let objects = addr_store.get_objects(&principal, &addressbook_id).await?; + let vcf = objects + .iter() + .map(AddressObject::get_vcf) + .collect::>() + .join("\r\n"); + + let mut resp = Response::builder().status(StatusCode::OK); + let hdrs = resp.headers_mut().unwrap(); + hdrs.typed_insert(ContentType::from_str("text/vcard").unwrap()); + let filename = format!("{}_{}.vcf", principal, addressbook_id); + let filename = utf8_percent_encode(&filename, CONTROLS); + hdrs.insert( + header::CONTENT_DISPOSITION, + HeaderValue::from_str(&format!( + "attachement; filename*=UTF-8''{filename}; filename={filename}", + )) + .unwrap(), + ); + Ok(resp.body(Body::new(vcf)).unwrap()) +} diff --git a/crates/carddav/src/addressbook/methods/mod.rs b/crates/carddav/src/addressbook/methods/mod.rs index c6afcf7..3aa53ec 100644 --- a/crates/carddav/src/addressbook/methods/mod.rs +++ b/crates/carddav/src/addressbook/methods/mod.rs @@ -1,3 +1,4 @@ pub mod mkcol; // pub mod post; +pub mod get; pub mod report; diff --git a/crates/carddav/src/addressbook/service.rs b/crates/carddav/src/addressbook/service.rs index 71daaec..6901c60 100644 --- a/crates/carddav/src/addressbook/service.rs +++ b/crates/carddav/src/addressbook/service.rs @@ -2,6 +2,7 @@ use super::methods::mkcol::route_mkcol; use super::methods::report::route_report_addressbook; use crate::address_object::AddressObjectResourceService; use crate::address_object::resource::AddressObjectResource; +use crate::addressbook::methods::get::route_get; use crate::addressbook::resource::AddressbookResource; use crate::{CardDavPrincipalUri, Error}; use async_trait::async_trait; @@ -121,6 +122,13 @@ impl AxumMethods for AddressbookReso }) } + fn get() -> Option BoxFuture<'static, Result>> { + Some(|state, req| { + let mut service = Handler::with_state(route_get::, state); + Box::pin(Service::call(&mut service, req)) + }) + } + fn mkcol() -> Option BoxFuture<'static, Result>> { Some(|state, req| { let mut service = Handler::with_state(route_mkcol::, state); diff --git a/crates/frontend/Cargo.toml b/crates/frontend/Cargo.toml index 4690a31..ec24bc1 100644 --- a/crates/frontend/Cargo.toml +++ b/crates/frontend/Cargo.toml @@ -29,6 +29,7 @@ uuid.workspace = true url.workspace = true tracing.workspace = true rustical_oidc.workspace = true -axum-extra.workspace= true +axum-extra.workspace = true headers.workspace = true -tower-sessions = "0.14" +tower-sessions.workspace = true +percent-encoding.workspace = true diff --git a/crates/frontend/public/assets/style.css b/crates/frontend/public/assets/style.css index 2b33a7d..4d7a918 100644 --- a/crates/frontend/public/assets/style.css +++ b/crates/frontend/public/assets/style.css @@ -62,7 +62,8 @@ html { background-color: var(--background-color); } -button { +button, +.button { border: none; background: var(--primary-color); padding: 8px 12px; @@ -163,9 +164,9 @@ table { "title comps color-chip" "description . color-chip" "subscription-url . color-chip" - "restore . color-chip" + "actions . color-chip" ". . color-chip"; - grid-template-rows: 12px auto auto auto 12px; + grid-template-rows: 12px auto auto auto auto 12px; grid-template-columns: min-content auto 80px; color: inherit; text-decoration: none; @@ -184,6 +185,10 @@ table { white-space: nowrap; } + span { + margin: 8px initial; + } + .comps { grid-area: comps; @@ -212,8 +217,9 @@ table { grid-area: color-chip; } - .restore-form { - grid-area: restore; + .actions { + grid-area: actions; + width: fit-content; } &:hover { diff --git a/crates/frontend/public/templates/pages/user.html b/crates/frontend/public/templates/pages/user.html index 6799119..38d1b42 100644 --- a/crates/frontend/public/templates/pages/user.html +++ b/crates/frontend/public/templates/pages/user.html @@ -78,6 +78,11 @@ {% if let Some(subscription_url) = calendar.subscription_url %} {{ subscription_url }} {% endif %} +
+
+ +
+
@@ -101,9 +106,11 @@ {% if let Some(description) = calendar.description %}{{ description }}{% endif %} -
- -
+
+
+ +
+
@@ -168,6 +175,11 @@ {% if let Some(description) = addressbook.description %}{{ description }}{% endif %} +
+
+ +
+
{% else %} @@ -184,9 +196,11 @@ {% if let Some(description) = addressbook.description %}{{ description }}{% endif %} -
- -
+
+
+ +
+
{% endfor %} diff --git a/crates/frontend/src/routes/app_token.rs b/crates/frontend/src/routes/app_token.rs index d7520e2..56c7a22 100644 --- a/crates/frontend/src/routes/app_token.rs +++ b/crates/frontend/src/routes/app_token.rs @@ -10,6 +10,7 @@ use axum::{ use axum_extra::extract::Host; use headers::{ContentType, HeaderMapExt}; use http::{HeaderValue, StatusCode, header}; +use percent_encoding::{CONTROLS, utf8_percent_encode}; use rand::{Rng, distr::Alphanumeric}; use rustical_store::auth::{AuthenticationProvider, User}; use serde::Deserialize; @@ -79,6 +80,7 @@ pub async fn route_post_app_token( ContentType::from_str("application/x-apple-aspen-config; charset=utf-8").unwrap(), ); let filename = format!("rustical-{}.mobileconfig", user_id); + let filename = utf8_percent_encode(&filename, CONTROLS); hdrs.insert( header::CONTENT_DISPOSITION, HeaderValue::from_str(&format!( diff --git a/crates/ical/src/icalendar/event.rs b/crates/ical/src/icalendar/event.rs index 6a2fbdc..b612ae5 100644 --- a/crates/ical/src/icalendar/event.rs +++ b/crates/ical/src/icalendar/event.rs @@ -11,8 +11,8 @@ use std::{collections::HashMap, str::FromStr}; #[derive(Debug, Clone)] pub struct EventObject { - pub(crate) event: IcalEvent, - pub(crate) timezones: HashMap, + pub event: IcalEvent, + pub timezones: HashMap, pub(crate) ics: String, } diff --git a/crates/ical/src/icalendar/object.rs b/crates/ical/src/icalendar/object.rs index b9b679b..7bf311f 100644 --- a/crates/ical/src/icalendar/object.rs +++ b/crates/ical/src/icalendar/object.rs @@ -136,6 +136,10 @@ impl CalendarObject { )) } + pub fn get_data(&self) -> &CalendarObjectComponent { + &self.data + } + pub fn get_id(&self) -> &str { &self.id }