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

View File

@@ -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

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 post;
pub mod get;
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::report::route_report_calendar;
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>>>
{
Some(|state, req| {

View File

@@ -29,3 +29,4 @@ rustical_dav_push.workspace = true
rustical_ical.workspace = true
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 post;
pub mod get;
pub mod report;

View File

@@ -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<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>>> {
Some(|state, req| {
let mut service = Handler::with_state(route_mkcol::<AS, S>, state);

View File

@@ -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

View File

@@ -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 {

View File

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

View File

@@ -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<AP: AuthenticationProvider>(
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!(

View File

@@ -11,8 +11,8 @@ use std::{collections::HashMap, str::FromStr};
#[derive(Debug, Clone)]
pub struct EventObject {
pub(crate) event: IcalEvent,
pub(crate) timezones: HashMap<String, IcalTimeZone>,
pub event: IcalEvent,
pub timezones: HashMap<String, IcalTimeZone>,
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 {
&self.id
}