Implement download feature for calendars and addressbooks

Fixes #70
This commit is contained in:
Lennart
2025-06-10 17:23:11 +02:00
parent 300a0024ee
commit 103ac0b1f9
16 changed files with 223 additions and 15 deletions

4
Cargo.lock generated
View File

@@ -2693,6 +2693,8 @@ dependencies = [
"futures-util", "futures-util",
"headers", "headers",
"http", "http",
"ical",
"percent-encoding",
"quick-xml", "quick-xml",
"rustical_dav", "rustical_dav",
"rustical_dav_push", "rustical_dav_push",
@@ -2722,6 +2724,7 @@ dependencies = [
"derive_more", "derive_more",
"futures-util", "futures-util",
"http", "http",
"percent-encoding",
"quick-xml", "quick-xml",
"rustical_dav", "rustical_dav",
"rustical_dav_push", "rustical_dav_push",
@@ -2797,6 +2800,7 @@ dependencies = [
"hex", "hex",
"http", "http",
"mime_guess", "mime_guess",
"percent-encoding",
"rand 0.9.1", "rand 0.9.1",
"rust-embed", "rust-embed",
"rustical_ical", "rustical_ical",

View File

@@ -98,6 +98,7 @@ tower-http = { version = "0.6", features = [
"normalize-path", "normalize-path",
"catch-panic", "catch-panic",
] } ] }
percent-encoding = "2.3"
rustical_dav = { path = "./crates/dav/" } rustical_dav = { path = "./crates/dav/" }
rustical_dav_push = { path = "./crates/dav_push/" } rustical_dav_push = { path = "./crates/dav_push/" }
rustical_store = { path = "./crates/store/" } rustical_store = { path = "./crates/store/" }

View File

@@ -25,6 +25,8 @@ rustical_store = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
chrono-tz = { workspace = true } chrono-tz = { workspace = true }
sha2 = { workspace = true } sha2 = { workspace = true }
ical.workspace = true
percent-encoding.workspace = true
rustical_xml.workspace = true rustical_xml.workspace = true
uuid.workspace = true uuid.workspace = true
rustical_dav_push.workspace = true rustical_dav_push.workspace = true

View File

@@ -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<C: CalendarStore, S: SubscriptionStore>(
Path((principal, calendar_id)): Path<(String, String)>,
State(CalendarResourceService { cal_store, .. }): State<CalendarResourceService<C, S>>,
user: User,
) -> Result<Response, Error> {
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())
}

View File

@@ -1,3 +1,4 @@
pub mod mkcalendar; pub mod mkcalendar;
// pub mod post; // pub mod post;
pub mod get;
pub mod report; pub mod report;

View File

@@ -1,3 +1,4 @@
use crate::calendar::methods::get::route_get;
use crate::calendar::methods::mkcalendar::route_mkcalendar; use crate::calendar::methods::mkcalendar::route_mkcalendar;
use crate::calendar::methods::report::route_report_calendar; use crate::calendar::methods::report::route_report_calendar;
use crate::calendar::resource::CalendarResource; use crate::calendar::resource::CalendarResource;
@@ -118,6 +119,13 @@ impl<C: CalendarStore, S: SubscriptionStore> AxumMethods for CalendarResourceSer
}) })
} }
fn get() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> {
Some(|state, req| {
let mut service = Handler::with_state(route_get::<C, S>, state);
Box::pin(Service::call(&mut service, req))
})
}
fn mkcalendar() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> fn mkcalendar() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>>
{ {
Some(|state, req| { Some(|state, req| {

View File

@@ -29,3 +29,4 @@ rustical_dav_push.workspace = true
rustical_ical.workspace = true rustical_ical.workspace = true
http.workspace = true http.workspace = true
tower-http.workspace = true tower-http.workspace = true
percent-encoding.workspace = true

View File

@@ -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<AS: AddressbookStore, S: SubscriptionStore>(
Path((principal, addressbook_id)): Path<(String, String)>,
State(AddressbookResourceService { addr_store, .. }): State<AddressbookResourceService<AS, S>>,
user: User,
) -> Result<Response, Error> {
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::<Vec<_>>()
.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())
}

View File

@@ -1,3 +1,4 @@
pub mod mkcol; pub mod mkcol;
// pub mod post; // pub mod post;
pub mod get;
pub mod report; pub mod report;

View File

@@ -2,6 +2,7 @@ use super::methods::mkcol::route_mkcol;
use super::methods::report::route_report_addressbook; use super::methods::report::route_report_addressbook;
use crate::address_object::AddressObjectResourceService; use crate::address_object::AddressObjectResourceService;
use crate::address_object::resource::AddressObjectResource; use crate::address_object::resource::AddressObjectResource;
use crate::addressbook::methods::get::route_get;
use crate::addressbook::resource::AddressbookResource; use crate::addressbook::resource::AddressbookResource;
use crate::{CardDavPrincipalUri, Error}; use crate::{CardDavPrincipalUri, Error};
use async_trait::async_trait; use async_trait::async_trait;
@@ -121,6 +122,13 @@ impl<AS: AddressbookStore, S: SubscriptionStore> AxumMethods for AddressbookReso
}) })
} }
fn get() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> {
Some(|state, req| {
let mut service = Handler::with_state(route_get::<AS, S>, state);
Box::pin(Service::call(&mut service, req))
})
}
fn mkcol() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> { fn mkcol() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> {
Some(|state, req| { Some(|state, req| {
let mut service = Handler::with_state(route_mkcol::<AS, S>, state); let mut service = Handler::with_state(route_mkcol::<AS, S>, state);

View File

@@ -29,6 +29,7 @@ uuid.workspace = true
url.workspace = true url.workspace = true
tracing.workspace = true tracing.workspace = true
rustical_oidc.workspace = true rustical_oidc.workspace = true
axum-extra.workspace= true axum-extra.workspace = true
headers.workspace = true headers.workspace = true
tower-sessions = "0.14" tower-sessions.workspace = true
percent-encoding.workspace = true

View File

@@ -62,7 +62,8 @@ html {
background-color: var(--background-color); background-color: var(--background-color);
} }
button { button,
.button {
border: none; border: none;
background: var(--primary-color); background: var(--primary-color);
padding: 8px 12px; padding: 8px 12px;
@@ -163,9 +164,9 @@ table {
"title comps color-chip" "title comps color-chip"
"description . color-chip" "description . color-chip"
"subscription-url . color-chip" "subscription-url . color-chip"
"restore . color-chip" "actions . color-chip"
". . 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; grid-template-columns: min-content auto 80px;
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
@@ -184,6 +185,10 @@ table {
white-space: nowrap; white-space: nowrap;
} }
span {
margin: 8px initial;
}
.comps { .comps {
grid-area: comps; grid-area: comps;
@@ -212,8 +217,9 @@ table {
grid-area: color-chip; grid-area: color-chip;
} }
.restore-form { .actions {
grid-area: restore; grid-area: actions;
width: fit-content;
} }
&:hover { &:hover {

View File

@@ -78,6 +78,11 @@
{% if let Some(subscription_url) = calendar.subscription_url %} {% if let Some(subscription_url) = calendar.subscription_url %}
<span class="subscription-url">{{ subscription_url }}</span> <span class="subscription-url">{{ subscription_url }}</span>
{% endif %} {% endif %}
<div class="actions">
<form action="/caldav/principal/{{ calendar.principal }}/calendar/{{ calendar.id }}" target="_blank" method="GET">
<button type="submit">Download</button>
</form>
</div>
<div class="color-chip"></div> <div class="color-chip"></div>
</a> </a>
</li> </li>
@@ -101,9 +106,11 @@
<span class="description"> <span class="description">
{% if let Some(description) = calendar.description %}{{ description }}{% endif %} {% if let Some(description) = calendar.description %}{{ description }}{% endif %}
</span> </span>
<form action="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id}}/restore" method="POST" class="restore-form"> <div class="actions">
<button type="submit">Restore</button> <form action="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id}}/restore" method="POST" class="restore-form">
</form> <button type="submit">Restore</button>
</form>
</div>
<div class="color-chip"></div> <div class="color-chip"></div>
</a> </a>
</li> </li>
@@ -168,6 +175,11 @@
<span class="description"> <span class="description">
{% if let Some(description) = addressbook.description %}{{ description }}{% endif %} {% if let Some(description) = addressbook.description %}{{ description }}{% endif %}
</span> </span>
<div class="actions">
<form action="/carddav/principal/{{ addressbook.principal }}/{{ addressbook.id }}" target="_blank" method="GET">
<button type="submit">Download</button>
</form>
</div>
</a> </a>
</li> </li>
{% else %} {% else %}
@@ -184,9 +196,11 @@
<span class="description"> <span class="description">
{% if let Some(description) = addressbook.description %}{{ description }}{% endif %} {% if let Some(description) = addressbook.description %}{{ description }}{% endif %}
</span> </span>
<form action="/frontend/user/{{ addressbook.principal }}/addressbook/{{ addressbook.id}}/restore" method="POST" class="restore-form"> <div class="actions">
<button type="submit">Restore</button> <form action="/frontend/user/{{ addressbook.principal }}/addressbook/{{ addressbook.id}}/restore" method="POST" class="restore-form">
</form> <button type="submit">Restore</button>
</form>
</div>
</a> </a>
</li> </li>
{% endfor %} {% endfor %}

View File

@@ -10,6 +10,7 @@ use axum::{
use axum_extra::extract::Host; use axum_extra::extract::Host;
use headers::{ContentType, HeaderMapExt}; use headers::{ContentType, HeaderMapExt};
use http::{HeaderValue, StatusCode, header}; use http::{HeaderValue, StatusCode, header};
use percent_encoding::{CONTROLS, utf8_percent_encode};
use rand::{Rng, distr::Alphanumeric}; use rand::{Rng, distr::Alphanumeric};
use rustical_store::auth::{AuthenticationProvider, User}; use rustical_store::auth::{AuthenticationProvider, User};
use serde::Deserialize; use serde::Deserialize;
@@ -79,6 +80,7 @@ pub async fn route_post_app_token<AP: AuthenticationProvider>(
ContentType::from_str("application/x-apple-aspen-config; charset=utf-8").unwrap(), ContentType::from_str("application/x-apple-aspen-config; charset=utf-8").unwrap(),
); );
let filename = format!("rustical-{}.mobileconfig", user_id); let filename = format!("rustical-{}.mobileconfig", user_id);
let filename = utf8_percent_encode(&filename, CONTROLS);
hdrs.insert( hdrs.insert(
header::CONTENT_DISPOSITION, header::CONTENT_DISPOSITION,
HeaderValue::from_str(&format!( HeaderValue::from_str(&format!(

View File

@@ -11,8 +11,8 @@ use std::{collections::HashMap, str::FromStr};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct EventObject { pub struct EventObject {
pub(crate) event: IcalEvent, pub event: IcalEvent,
pub(crate) timezones: HashMap<String, IcalTimeZone>, pub timezones: HashMap<String, IcalTimeZone>,
pub(crate) ics: String, pub(crate) ics: String,
} }

View File

@@ -136,6 +136,10 @@ impl CalendarObject {
)) ))
} }
pub fn get_data(&self) -> &CalendarObjectComponent {
&self.data
}
pub fn get_id(&self) -> &str { pub fn get_id(&self) -> &str {
&self.id &self.id
} }