From 86feb4e1892031d333ff22e77cc929124258d4df Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Sun, 27 Oct 2024 14:10:01 +0100 Subject: [PATCH] Add initial carddav support --- Cargo.lock | 6 +- crates/carddav/Cargo.toml | 38 +- crates/carddav/src/address_object/methods.rs | 111 ++++++ crates/carddav/src/address_object/mod.rs | 2 + crates/carddav/src/address_object/resource.rs | 167 ++++++++ .../carddav/src/addressbook/methods/mkcol.rs | 90 +++++ crates/carddav/src/addressbook/methods/mod.rs | 2 + .../methods/report/addressbook_multiget.rs | 118 ++++++ .../src/addressbook/methods/report/mod.rs | 68 ++++ .../methods/report/sync_collection.rs | 100 +++++ crates/carddav/src/addressbook/mod.rs | 3 + crates/carddav/src/addressbook/prop.rs | 137 +++++++ crates/carddav/src/addressbook/resource.rs | 286 ++++++++++++++ crates/carddav/src/error.rs | 49 +++ crates/carddav/src/lib.rs | 86 +++- crates/carddav/src/principal/mod.rs | 159 ++++++++ crates/carddav/src/root/mod.rs | 95 +++++ crates/store/migrations/2_addressbook.sql | 34 ++ crates/store/src/addressbook_store.rs | 64 +++ crates/store/src/calendar_store.rs | 4 +- crates/store/src/lib.rs | 5 +- crates/store/src/model/address_object.rs | 28 ++ crates/store/src/model/addressbook.rs | 32 ++ crates/store/src/model/mod.rs | 6 + .../src/sqlite_store/addressbook_store.rs | 371 ++++++++++++++++++ .../calendar_store.rs} | 36 +- crates/store/src/sqlite_store/mod.rs | 35 ++ src/app.rs | 19 +- src/config.rs | 8 +- src/main.rs | 29 +- 30 files changed, 2094 insertions(+), 94 deletions(-) create mode 100644 crates/carddav/src/address_object/methods.rs create mode 100644 crates/carddav/src/address_object/mod.rs create mode 100644 crates/carddav/src/address_object/resource.rs create mode 100644 crates/carddav/src/addressbook/methods/mkcol.rs create mode 100644 crates/carddav/src/addressbook/methods/mod.rs create mode 100644 crates/carddav/src/addressbook/methods/report/addressbook_multiget.rs create mode 100644 crates/carddav/src/addressbook/methods/report/mod.rs create mode 100644 crates/carddav/src/addressbook/methods/report/sync_collection.rs create mode 100644 crates/carddav/src/addressbook/mod.rs create mode 100644 crates/carddav/src/addressbook/prop.rs create mode 100644 crates/carddav/src/addressbook/resource.rs create mode 100644 crates/carddav/src/error.rs create mode 100644 crates/carddav/src/principal/mod.rs create mode 100644 crates/carddav/src/root/mod.rs create mode 100644 crates/store/migrations/2_addressbook.sql create mode 100644 crates/store/src/addressbook_store.rs create mode 100644 crates/store/src/model/address_object.rs create mode 100644 crates/store/src/model/addressbook.rs create mode 100644 crates/store/src/sqlite_store/addressbook_store.rs rename crates/store/src/{sqlite_store.rs => sqlite_store/calendar_store.rs} (91%) create mode 100644 crates/store/src/sqlite_store/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 2eb9ce9..e6709a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2563,16 +2563,20 @@ dependencies = [ "anyhow", "async-trait", "base64 0.22.1", + "chrono", + "derive_more 1.0.0", "futures-util", "quick-xml", "roxmltree", "rustical_dav", "rustical_store", "serde", - "serde_json", "strum", "thiserror", "tokio", + "tracing", + "tracing-actix-web", + "url", ] [[package]] diff --git a/crates/carddav/Cargo.toml b/crates/carddav/Cargo.toml index d22af91..01e0994 100644 --- a/crates/carddav/Cargo.toml +++ b/crates/carddav/Cargo.toml @@ -7,22 +7,22 @@ repository.workspace = true publish = false [dependencies] -actix-web = "4.9" -actix-web-httpauth = "0.8" -anyhow = { version = "1.0", features = ["backtrace"] } -base64 = "0.22" -futures-util = "0.3" -quick-xml = { version = "0.36", features = [ - "serde", - "serde-types", - "serialize", -] } -roxmltree = "0.20" -rustical_store = { path = "../store/" } -rustical_dav = { path = "../dav/" } -serde = { version = "1.0", features = ["serde_derive", "derive"] } -serde_json = "1.0" -tokio = { version = "1", features = ["sync", "full"] } -async-trait = "0.1" -thiserror = "1.0" -strum = { version = "0.26", features = ["strum_macros", "derive"] } +anyhow = { workspace = true } +actix-web = { workspace = true } +async-trait = { workspace = true } +thiserror = { workspace = true } +quick-xml = { workspace = true } +strum = { workspace = true } +tracing = { workspace = true } +tracing-actix-web = { workspace = true } +futures-util = { workspace = true } +derive_more = { workspace = true } +actix-web-httpauth = { workspace = true } +base64 = { workspace = true } +serde = { workspace = true } +tokio = { workspace = true } +roxmltree = { workspace = true } +url = { workspace = true } +rustical_dav = { workspace = true } +rustical_store = { workspace = true } +chrono = { workspace = true } diff --git a/crates/carddav/src/address_object/methods.rs b/crates/carddav/src/address_object/methods.rs new file mode 100644 index 0000000..5f6792f --- /dev/null +++ b/crates/carddav/src/address_object/methods.rs @@ -0,0 +1,111 @@ +use super::resource::AddressObjectPathComponents; +use crate::Error; +use actix_web::http::header; +use actix_web::http::header::HeaderValue; +use actix_web::web::{Data, Path}; +use actix_web::HttpRequest; +use actix_web::HttpResponse; +use rustical_store::auth::User; +use rustical_store::model::AddressObject; +use rustical_store::AddressbookStore; +use tokio::sync::RwLock; +use tracing::instrument; +use tracing_actix_web::RootSpan; + +#[instrument(parent = root_span.id(), skip(store, root_span))] +pub async fn get_object( + path: Path, + store: Data>, + user: User, + root_span: RootSpan, +) -> Result { + let AddressObjectPathComponents { + principal, + cal_id, + object_id, + } = path.into_inner(); + + if user.id != principal { + return Ok(HttpResponse::Unauthorized().body("")); + } + + let addressbook = store + .read() + .await + .get_addressbook(&principal, &cal_id) + .await?; + if user.id != addressbook.principal { + return Ok(HttpResponse::Unauthorized().body("")); + } + + let object = store + .read() + .await + .get_object(&principal, &cal_id, &object_id) + .await?; + + Ok(HttpResponse::Ok() + .insert_header(("ETag", object.get_etag())) + .insert_header(("Content-Type", "text/calendar")) + .body(object.get_vcf().to_owned())) +} + +#[instrument(parent = root_span.id(), skip(store, req, root_span))] +pub async fn put_object( + path: Path, + store: Data>, + body: String, + user: User, + req: HttpRequest, + root_span: RootSpan, +) -> Result { + let AddressObjectPathComponents { + principal, + cal_id: addressbook_id, + object_id, + } = path.into_inner(); + + if user.id != principal { + return Ok(HttpResponse::Unauthorized().body("")); + } + + let addressbook = store + .read() + .await + .get_addressbook(&principal, &addressbook_id) + .await?; + if user.id != addressbook.principal { + return Ok(HttpResponse::Unauthorized().body("")); + } + + // TODO: implement If-Match + // + let mut store_write = store.write().await; + + if Some(&HeaderValue::from_static("*")) == req.headers().get(header::IF_NONE_MATCH) { + // Only write if not existing + match store_write + .get_object(&principal, &addressbook_id, &object_id) + .await + { + Ok(_) => { + // Conflict + return Ok(HttpResponse::Conflict().body("Resource with this URI already exists")); + } + Err(rustical_store::Error::NotFound) => { + // Path unused, we can proceed + } + Err(err) => { + // Some unknown error :( + return Err(err.into()); + } + } + } + + let object = AddressObject::from_vcf(object_id, body)?; + store_write + .put_object(principal, addressbook_id, object) + .await?; + + Ok(HttpResponse::Created().body("")) +} diff --git a/crates/carddav/src/address_object/mod.rs b/crates/carddav/src/address_object/mod.rs new file mode 100644 index 0000000..20447fe --- /dev/null +++ b/crates/carddav/src/address_object/mod.rs @@ -0,0 +1,2 @@ +pub mod methods; +pub mod resource; diff --git a/crates/carddav/src/address_object/resource.rs b/crates/carddav/src/address_object/resource.rs new file mode 100644 index 0000000..e75a61a --- /dev/null +++ b/crates/carddav/src/address_object/resource.rs @@ -0,0 +1,167 @@ +use crate::Error; +use actix_web::{dev::ResourceMap, web::Data, HttpRequest}; +use async_trait::async_trait; +use derive_more::derive::{From, Into}; +use rustical_dav::resource::{InvalidProperty, Resource, ResourceService}; +use rustical_store::{model::AddressObject, AddressbookStore}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use strum::{EnumString, VariantNames}; +use tokio::sync::RwLock; + +use super::methods::{get_object, put_object}; + +pub struct AddressObjectResourceService { + pub addr_store: Arc>, + pub path: String, + pub principal: String, + pub cal_id: String, + pub object_id: String, +} + +#[derive(EnumString, Debug, VariantNames, Clone)] +#[strum(serialize_all = "kebab-case")] +pub enum AddressObjectPropName { + Getetag, + AddressData, + Getcontenttype, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(rename_all = "kebab-case")] +pub enum AddressObjectProp { + // WebDAV (RFC 2518) + Getetag(String), + Getcontenttype(String), + + // CalDAV (RFC 4791) + #[serde(rename = "CARD:address-data")] + AddressData(String), + #[serde(other)] + Invalid, +} + +impl InvalidProperty for AddressObjectProp { + fn invalid_property(&self) -> bool { + matches!(self, Self::Invalid) + } +} + +#[derive(Clone, From, Into)] +pub struct AddressObjectResource(AddressObject); + +impl Resource for AddressObjectResource { + type PropName = AddressObjectPropName; + type Prop = AddressObjectProp; + type Error = Error; + + fn get_prop( + &self, + _rmap: &ResourceMap, + prop: Self::PropName, + ) -> Result { + Ok(match prop { + AddressObjectPropName::Getetag => AddressObjectProp::Getetag(self.0.get_etag()), + AddressObjectPropName::AddressData => { + AddressObjectProp::AddressData(self.0.get_vcf().to_owned()) + } + AddressObjectPropName::Getcontenttype => { + AddressObjectProp::Getcontenttype("text/calendar;charset=utf-8".to_owned()) + } + }) + } + + #[inline] + fn resource_name() -> &'static str { + "caldav_calendar_object" + } +} + +#[derive(Debug, Clone)] +pub struct AddressObjectPathComponents { + pub principal: String, + pub cal_id: String, + pub object_id: String, +} + +impl<'de> Deserialize<'de> for AddressObjectPathComponents { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + type Inner = (String, String, String); + let (principal, calendar, mut object) = Inner::deserialize(deserializer)?; + if object.ends_with(".ics") { + object.truncate(object.len() - 4); + } + Ok(Self { + principal, + cal_id: calendar, + object_id: object, + }) + } +} + +#[async_trait(?Send)] +impl ResourceService for AddressObjectResourceService { + type PathComponents = AddressObjectPathComponents; + type Resource = AddressObjectResource; + type MemberType = AddressObjectResource; + type Error = Error; + + async fn new( + req: &HttpRequest, + path_components: Self::PathComponents, + ) -> Result { + let AddressObjectPathComponents { + principal, + cal_id, + object_id, + } = path_components; + + let addr_store = req + .app_data::>>() + .expect("no addressbook store in app_data!") + .clone() + .into_inner(); + + Ok(Self { + addr_store, + principal, + cal_id, + object_id, + path: req.path().to_string(), + }) + } + + async fn get_resource(&self, principal: String) -> Result { + if self.principal != principal { + return Err(Error::Unauthorized); + } + let event = self + .addr_store + .read() + .await + .get_object(&self.principal, &self.cal_id, &self.object_id) + .await?; + Ok(event.into()) + } + + async fn save_resource(&self, _file: Self::Resource) -> Result<(), Self::Error> { + Err(Error::NotImplemented) + } + + async fn delete_resource(&self, use_trashbin: bool) -> Result<(), Self::Error> { + self.addr_store + .write() + .await + .delete_object(&self.principal, &self.cal_id, &self.object_id, use_trashbin) + .await?; + Ok(()) + } + + #[inline] + fn actix_additional_routes(res: actix_web::Resource) -> actix_web::Resource { + res.get(get_object::).put(put_object::) + } +} diff --git a/crates/carddav/src/addressbook/methods/mkcol.rs b/crates/carddav/src/addressbook/methods/mkcol.rs new file mode 100644 index 0000000..47c6806 --- /dev/null +++ b/crates/carddav/src/addressbook/methods/mkcol.rs @@ -0,0 +1,90 @@ +use crate::Error; +use actix_web::web::Path; +use actix_web::{web::Data, HttpResponse}; +use rustical_store::model::Addressbook; +use rustical_store::{auth::User, AddressbookStore}; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; + +#[derive(Deserialize, Serialize, Clone, Debug)] +#[serde(rename_all = "kebab-case")] +pub struct Resourcetype { + #[serde(rename = "CARD:addressbook", alias = "addressbook")] + addressbook: Option<()>, + collection: Option<()>, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "kebab-case")] +pub struct MkcolAddressbookProp { + resourcetype: Option, + displayname: Option, + description: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct PropElement { + prop: T, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "kebab-case")] +#[serde(rename = "mkcol")] +struct MkcolRequest { + set: PropElement, +} + +pub async fn route_mkcol( + path: Path<(String, String)>, + body: String, + user: User, + store: Data>, +) -> Result { + let (principal, addressbook_id) = path.into_inner(); + if principal != user.id { + return Err(Error::Unauthorized); + } + + let request: MkcolRequest = quick_xml::de::from_str(&body)?; + let request = request.set.prop; + + let addressbook = Addressbook { + id: addressbook_id.to_owned(), + principal: principal.to_owned(), + displayname: request.displayname, + description: request.description, + deleted_at: None, + synctoken: 0, + }; + + match store + .read() + .await + .get_addressbook(&principal, &addressbook_id) + .await + { + Err(rustical_store::Error::NotFound) => { + // No conflict, no worries + } + Ok(_) => { + // oh no, there's a conflict + return Ok(HttpResponse::Conflict().body("An addressbook already exists at this URI")); + } + Err(err) => { + // some other error + return Err(err.into()); + } + } + + match store.write().await.insert_addressbook(addressbook).await { + // TODO: The spec says we should return a mkcol-response. + // However, it works without one but breaks on iPadOS when using an empty one :) + Ok(()) => Ok(HttpResponse::Created() + .insert_header(("Cache-Control", "no-cache")) + .body("")), + Err(err) => { + dbg!(err.to_string()); + Err(err.into()) + } + } +} diff --git a/crates/carddav/src/addressbook/methods/mod.rs b/crates/carddav/src/addressbook/methods/mod.rs new file mode 100644 index 0000000..769c33a --- /dev/null +++ b/crates/carddav/src/addressbook/methods/mod.rs @@ -0,0 +1,2 @@ +pub mod mkcol; +pub mod report; diff --git a/crates/carddav/src/addressbook/methods/report/addressbook_multiget.rs b/crates/carddav/src/addressbook/methods/report/addressbook_multiget.rs new file mode 100644 index 0000000..b4c2f25 --- /dev/null +++ b/crates/carddav/src/addressbook/methods/report/addressbook_multiget.rs @@ -0,0 +1,118 @@ +use crate::{ + address_object::resource::{AddressObjectProp, AddressObjectResource}, + principal::PrincipalResource, + Error, +}; +use actix_web::{ + dev::{Path, ResourceDef}, + http::StatusCode, + HttpRequest, +}; +use rustical_dav::{ + methods::propfind::{PropElement, PropfindType}, + resource::Resource, + xml::{ + multistatus::{PropstatWrapper, ResponseElement}, + MultistatusElement, + }, +}; +use rustical_store::{model::AddressObject, AddressbookStore}; +use serde::Deserialize; +use tokio::sync::RwLock; + +#[derive(Deserialize, Clone, Debug)] +#[serde(rename_all = "kebab-case")] +#[allow(dead_code)] +pub struct AddressbookMultigetRequest { + #[serde(flatten)] + prop: PropfindType, + href: Vec, +} + +pub async fn get_objects_addressbook_multiget( + addressbook_multiget: &AddressbookMultigetRequest, + principal_url: &str, + principal: &str, + addressbook_id: &str, + store: &RwLock, +) -> Result<(Vec, Vec), Error> { + let resource_def = + ResourceDef::prefix(principal_url).join(&ResourceDef::new("/{addressbook_id}/{object_id}")); + + let mut result = vec![]; + let mut not_found = vec![]; + + let store = store.read().await; + for href in &addressbook_multiget.href { + let mut path = Path::new(href.as_str()); + if !resource_def.capture_match_info(&mut path) { + not_found.push(href.to_owned()); + }; + if path.get("addressbook_id").unwrap() != addressbook_id { + not_found.push(href.to_owned()); + } + let object_id = path.get("object_id").unwrap(); + match store.get_object(principal, addressbook_id, object_id).await { + Ok(object) => result.push(object), + Err(rustical_store::Error::NotFound) => not_found.push(href.to_owned()), + // TODO: Maybe add error handling on a per-object basis + Err(err) => return Err(err.into()), + }; + } + + Ok((result, not_found)) +} + +pub async fn handle_addressbook_multiget( + addr_multiget: AddressbookMultigetRequest, + req: HttpRequest, + principal: &str, + cal_id: &str, + addr_store: &RwLock, +) -> Result, String>, Error> { + let principal_url = PrincipalResource::get_url(req.resource_map(), vec![principal]).unwrap(); + let (objects, not_found) = get_objects_addressbook_multiget( + &addr_multiget, + &principal_url, + principal, + cal_id, + addr_store, + ) + .await?; + + let props = match addr_multiget.prop { + PropfindType::Allprop => { + vec!["allprop".to_owned()] + } + PropfindType::Propname => { + vec!["propname".to_owned()] + } + PropfindType::Prop(PropElement { prop: prop_tags }) => prop_tags.into_inner(), + }; + let props: Vec<&str> = props.iter().map(String::as_str).collect(); + + let mut responses = Vec::new(); + for object in objects { + let path = format!("{}/{}", req.path(), object.get_id()); + responses.push(AddressObjectResource::from(object).propfind( + &path, + props.clone(), + req.resource_map(), + )?); + } + + let not_found_responses = not_found + .into_iter() + .map(|path| ResponseElement { + href: path, + status: Some(format!("HTTP/1.1 {}", StatusCode::NOT_FOUND)), + ..Default::default() + }) + .collect(); + + Ok(MultistatusElement { + responses, + member_responses: not_found_responses, + ..Default::default() + }) +} diff --git a/crates/carddav/src/addressbook/methods/report/mod.rs b/crates/carddav/src/addressbook/methods/report/mod.rs new file mode 100644 index 0000000..d3b13da --- /dev/null +++ b/crates/carddav/src/addressbook/methods/report/mod.rs @@ -0,0 +1,68 @@ +use crate::Error; +use actix_web::{ + web::{Data, Path}, + HttpRequest, Responder, +}; +use addressbook_multiget::{handle_addressbook_multiget, AddressbookMultigetRequest}; +use rustical_store::{auth::User, AddressbookStore}; +use serde::{Deserialize, Serialize}; +use sync_collection::{handle_sync_collection, SyncCollectionRequest}; +use tokio::sync::RwLock; +use tracing::instrument; + +mod addressbook_multiget; +mod sync_collection; + +#[derive(Deserialize, Serialize, Clone, Debug)] +#[serde(rename_all = "kebab-case")] +pub enum PropQuery { + Allprop, + Prop, + Propname, +} + +#[derive(Deserialize, Clone, Debug)] +#[serde(rename_all = "kebab-case")] +pub enum ReportRequest { + AddressbookMultiget(AddressbookMultigetRequest), + SyncCollection(SyncCollectionRequest), +} + +#[instrument(skip(req, addr_store))] +pub async fn route_report_addressbook( + path: Path<(String, String)>, + body: String, + user: User, + req: HttpRequest, + addr_store: Data>, +) -> Result { + let (principal, addressbook_id) = path.into_inner(); + if principal != user.id { + return Err(Error::Unauthorized); + } + + let request: ReportRequest = quick_xml::de::from_str(&body)?; + + Ok(match request.clone() { + ReportRequest::AddressbookMultiget(addr_multiget) => { + handle_addressbook_multiget( + addr_multiget, + req, + &principal, + &addressbook_id, + &addr_store, + ) + .await? + } + ReportRequest::SyncCollection(sync_collection) => { + handle_sync_collection( + sync_collection, + req, + &principal, + &addressbook_id, + &addr_store, + ) + .await? + } + }) +} diff --git a/crates/carddav/src/addressbook/methods/report/sync_collection.rs b/crates/carddav/src/addressbook/methods/report/sync_collection.rs new file mode 100644 index 0000000..0487c49 --- /dev/null +++ b/crates/carddav/src/addressbook/methods/report/sync_collection.rs @@ -0,0 +1,100 @@ +use crate::{ + address_object::resource::{AddressObjectProp, AddressObjectResource}, + Error, +}; +use actix_web::{http::StatusCode, HttpRequest}; +use rustical_dav::{ + methods::propfind::{PropElement, PropfindType}, + resource::Resource, + xml::{ + multistatus::{PropstatWrapper, ResponseElement}, + MultistatusElement, + }, +}; +use rustical_store::{ + model::addressbook::{format_synctoken, parse_synctoken}, + AddressbookStore, +}; +use serde::Deserialize; +use tokio::sync::RwLock; + +#[derive(Deserialize, Clone, Debug)] +#[serde(rename_all = "kebab-case")] +enum SyncLevel { + #[serde(rename = "1")] + One, + Infinity, +} + +#[derive(Deserialize, Clone, Debug)] +#[serde(rename_all = "kebab-case")] +#[allow(dead_code)] +// +// +// +pub struct SyncCollectionRequest { + sync_token: String, + sync_level: SyncLevel, + #[serde(flatten)] + pub prop: PropfindType, + limit: Option, +} + +pub async fn handle_sync_collection( + sync_collection: SyncCollectionRequest, + req: HttpRequest, + principal: &str, + addressbook_id: &str, + addr_store: &RwLock, +) -> Result, String>, Error> { + let props = match sync_collection.prop { + PropfindType::Allprop => { + vec!["allprop".to_owned()] + } + PropfindType::Propname => { + vec!["propname".to_owned()] + } + PropfindType::Prop(PropElement { prop: prop_tags }) => prop_tags.into_inner(), + }; + let props: Vec<&str> = props.iter().map(String::as_str).collect(); + + let old_synctoken = parse_synctoken(&sync_collection.sync_token).unwrap_or(0); + let (new_objects, deleted_objects, new_synctoken) = addr_store + .read() + .await + .sync_changes(principal, addressbook_id, old_synctoken) + .await?; + + let mut responses = Vec::new(); + for object in new_objects { + let path = AddressObjectResource::get_url( + req.resource_map(), + vec![principal, addressbook_id, &object.get_id()], + ) + .unwrap(); + responses.push(AddressObjectResource::from(object).propfind( + &path, + props.clone(), + req.resource_map(), + )?); + } + + for object_id in deleted_objects { + let path = AddressObjectResource::get_url( + req.resource_map(), + vec![principal, addressbook_id, &object_id], + ) + .unwrap(); + responses.push(ResponseElement { + href: path, + status: Some(format!("HTTP/1.1 {}", StatusCode::NOT_FOUND)), + ..Default::default() + }); + } + + Ok(MultistatusElement { + responses, + sync_token: Some(format_synctoken(new_synctoken)), + ..Default::default() + }) +} diff --git a/crates/carddav/src/addressbook/mod.rs b/crates/carddav/src/addressbook/mod.rs new file mode 100644 index 0000000..0cb72f5 --- /dev/null +++ b/crates/carddav/src/addressbook/mod.rs @@ -0,0 +1,3 @@ +pub mod methods; +pub mod prop; +pub mod resource; diff --git a/crates/carddav/src/addressbook/prop.rs b/crates/carddav/src/addressbook/prop.rs new file mode 100644 index 0000000..7c6b9c8 --- /dev/null +++ b/crates/carddav/src/addressbook/prop.rs @@ -0,0 +1,137 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct AddressDataType { + #[serde(rename = "@content-type")] + pub content_type: String, + #[serde(rename = "@version")] + pub version: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct SupportedAddressData { + #[serde(rename = "CARD:address-data-type", alias = "address-data-type")] + address_data_type: Vec, +} + +impl Default for SupportedAddressData { + fn default() -> Self { + Self { + address_data_type: vec![ + AddressDataType { + content_type: "text/vcard".to_owned(), + version: "3.0".to_owned(), + }, + AddressDataType { + content_type: "text/vcard".to_owned(), + version: "4.0".to_owned(), + }, + ], + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +#[serde(rename_all = "kebab-case")] +pub struct Resourcetype { + #[serde(rename = "CARD:addressbook", alias = "addressbook")] + addressbook: (), + collection: (), +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum UserPrivilege { + Read, + ReadAcl, + Write, + WriteAcl, + WriteContent, + ReadCurrentUserPrivilegeSet, + Bind, + Unbind, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct UserPrivilegeWrapper { + #[serde(rename = "$value")] + privilege: UserPrivilege, +} + +impl From for UserPrivilegeWrapper { + fn from(value: UserPrivilege) -> Self { + Self { privilege: value } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct UserPrivilegeSet { + privilege: Vec, +} + +impl Default for UserPrivilegeSet { + fn default() -> Self { + Self { + privilege: vec![ + UserPrivilege::Read.into(), + UserPrivilege::ReadAcl.into(), + UserPrivilege::Write.into(), + UserPrivilege::WriteAcl.into(), + UserPrivilege::WriteContent.into(), + UserPrivilege::ReadCurrentUserPrivilegeSet.into(), + UserPrivilege::Bind.into(), + UserPrivilege::Unbind.into(), + ], + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ReportMethod { + AddressbookMultiget, + SyncCollection, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct ReportWrapper { + #[serde(rename = "$value")] + report: ReportMethod, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct SupportedReportWrapper { + report: ReportWrapper, +} + +impl From for SupportedReportWrapper { + fn from(value: ReportMethod) -> Self { + Self { + report: ReportWrapper { report: value }, + } + } +} + +// RFC 3253 section-3.1.5 +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct SupportedReportSet { + supported_report: Vec, +} + +impl Default for SupportedReportSet { + fn default() -> Self { + Self { + supported_report: vec![ + ReportMethod::AddressbookMultiget.into(), + ReportMethod::SyncCollection.into(), + ], + } + } +} diff --git a/crates/carddav/src/addressbook/resource.rs b/crates/carddav/src/addressbook/resource.rs new file mode 100644 index 0000000..f1443d2 --- /dev/null +++ b/crates/carddav/src/addressbook/resource.rs @@ -0,0 +1,286 @@ +use super::methods::mkcol::route_mkcol; +use super::methods::report::route_report_addressbook; +use super::prop::{Resourcetype, SupportedAddressData, SupportedReportSet, UserPrivilegeSet}; +use crate::address_object::resource::AddressObjectResource; +use crate::principal::PrincipalResource; +use crate::Error; +use actix_web::dev::ResourceMap; +use actix_web::http::Method; +use actix_web::web; +use actix_web::{web::Data, HttpRequest}; +use async_trait::async_trait; +use derive_more::derive::{From, Into}; +use rustical_dav::resource::{InvalidProperty, Resource, ResourceService}; +use rustical_dav::xml::HrefElement; +use rustical_store::model::Addressbook; +use rustical_store::AddressbookStore; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; +use std::sync::Arc; +use strum::{EnumString, VariantNames}; +use tokio::sync::RwLock; + +pub struct AddressbookResourceService { + pub addr_store: Arc>, + pub path: String, + pub principal: String, + pub addressbook_id: String, +} + +#[derive(EnumString, Debug, VariantNames, Clone)] +#[strum(serialize_all = "kebab-case")] +pub enum AddressbookPropName { + Resourcetype, + Displayname, + Getcontenttype, + CurrentUserPrincipal, + Owner, + CurrentUserPrivilegeSet, + AddressbookDescription, + SupportedAddressData, + SupportedReportSet, + MaxResourceSize, + SyncToken, + Getctag, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AddressbookProp { + // WebDAV (RFC 2518) + Resourcetype(Resourcetype), + Displayname(Option), + Getcontenttype(String), + + // WebDAV Current Principal Extension (RFC 5397) + CurrentUserPrincipal(HrefElement), + + // WebDAV Access Control (RFC 3744) + Owner(HrefElement), + CurrentUserPrivilegeSet(UserPrivilegeSet), + + // CardDAV (RFC 6352) + #[serde( + rename = "CARD:addressbook-description", + alias = "addressbook-description" + )] + AddressbookDescription(Option), + #[serde( + rename = "CARD:supported-address-data", + alias = "supported-address-data" + )] + SupportedAddressData(SupportedAddressData), + SupportedReportSet(SupportedReportSet), + MaxResourceSize(i64), + + // Collection Synchronization (RFC 6578) + SyncToken(String), + + // Didn't find the spec + Getctag(String), + + #[serde(other)] + Invalid, +} + +impl InvalidProperty for AddressbookProp { + fn invalid_property(&self) -> bool { + matches!(self, Self::Invalid) + } +} + +#[derive(Clone, Debug, From, Into)] +pub struct AddressbookResource(Addressbook); + +impl Resource for AddressbookResource { + type PropName = AddressbookPropName; + type Prop = AddressbookProp; + type Error = Error; + + fn get_prop( + &self, + rmap: &ResourceMap, + prop: Self::PropName, + ) -> Result { + Ok(match prop { + AddressbookPropName::Resourcetype => { + AddressbookProp::Resourcetype(Resourcetype::default()) + } + AddressbookPropName::CurrentUserPrincipal => { + AddressbookProp::CurrentUserPrincipal(HrefElement::new( + PrincipalResource::get_url(rmap, vec![&self.0.principal]).unwrap(), + )) + } + AddressbookPropName::Owner => AddressbookProp::Owner(HrefElement::new( + PrincipalResource::get_url(rmap, vec![&self.0.principal]).unwrap(), + )), + AddressbookPropName::Displayname => { + AddressbookProp::Displayname(self.0.displayname.clone()) + } + AddressbookPropName::Getcontenttype => { + AddressbookProp::Getcontenttype("text/vcard;charset=utf-8".to_owned()) + } + AddressbookPropName::MaxResourceSize => AddressbookProp::MaxResourceSize(10000000), + AddressbookPropName::CurrentUserPrivilegeSet => { + AddressbookProp::CurrentUserPrivilegeSet(UserPrivilegeSet::default()) + } + AddressbookPropName::SupportedReportSet => { + AddressbookProp::SupportedReportSet(SupportedReportSet::default()) + } + AddressbookPropName::AddressbookDescription => { + AddressbookProp::AddressbookDescription(self.0.description.to_owned()) + } + AddressbookPropName::SupportedAddressData => { + AddressbookProp::SupportedAddressData(SupportedAddressData::default()) + } + AddressbookPropName::SyncToken => AddressbookProp::SyncToken(self.0.format_synctoken()), + AddressbookPropName::Getctag => AddressbookProp::Getctag(self.0.format_synctoken()), + }) + } + + fn set_prop(&mut self, prop: Self::Prop) -> Result<(), rustical_dav::Error> { + match prop { + AddressbookProp::Resourcetype(_) => Err(rustical_dav::Error::PropReadOnly), + AddressbookProp::CurrentUserPrincipal(_) => Err(rustical_dav::Error::PropReadOnly), + AddressbookProp::Owner(_) => Err(rustical_dav::Error::PropReadOnly), + AddressbookProp::Displayname(displayname) => { + self.0.displayname = displayname; + Ok(()) + } + AddressbookProp::AddressbookDescription(description) => { + self.0.description = description; + Ok(()) + } + AddressbookProp::Getcontenttype(_) => Err(rustical_dav::Error::PropReadOnly), + AddressbookProp::MaxResourceSize(_) => Err(rustical_dav::Error::PropReadOnly), + AddressbookProp::CurrentUserPrivilegeSet(_) => Err(rustical_dav::Error::PropReadOnly), + AddressbookProp::SupportedReportSet(_) => Err(rustical_dav::Error::PropReadOnly), + AddressbookProp::SupportedAddressData(_) => Err(rustical_dav::Error::PropReadOnly), + AddressbookProp::SyncToken(_) => Err(rustical_dav::Error::PropReadOnly), + AddressbookProp::Getctag(_) => Err(rustical_dav::Error::PropReadOnly), + AddressbookProp::Invalid => Err(rustical_dav::Error::PropReadOnly), + } + } + + fn remove_prop(&mut self, prop: Self::PropName) -> Result<(), rustical_dav::Error> { + match prop { + AddressbookPropName::Resourcetype => Err(rustical_dav::Error::PropReadOnly), + AddressbookPropName::CurrentUserPrincipal => Err(rustical_dav::Error::PropReadOnly), + AddressbookPropName::Owner => Err(rustical_dav::Error::PropReadOnly), + AddressbookPropName::Displayname => { + self.0.displayname = None; + Ok(()) + } + AddressbookPropName::AddressbookDescription => { + self.0.description = None; + Ok(()) + } + AddressbookPropName::Getcontenttype => Err(rustical_dav::Error::PropReadOnly), + AddressbookPropName::MaxResourceSize => Err(rustical_dav::Error::PropReadOnly), + AddressbookPropName::CurrentUserPrivilegeSet => Err(rustical_dav::Error::PropReadOnly), + AddressbookPropName::SupportedReportSet => Err(rustical_dav::Error::PropReadOnly), + AddressbookPropName::SupportedAddressData => Err(rustical_dav::Error::PropReadOnly), + AddressbookPropName::SyncToken => Err(rustical_dav::Error::PropReadOnly), + AddressbookPropName::Getctag => Err(rustical_dav::Error::PropReadOnly), + } + } + + #[inline] + fn resource_name() -> &'static str { + "carddav_addressbook" + } +} + +#[async_trait(?Send)] +impl ResourceService for AddressbookResourceService { + type MemberType = AddressObjectResource; + type PathComponents = (String, String); // principal, addressbook_id + type Resource = AddressbookResource; + type Error = Error; + + async fn get_resource(&self, principal: String) -> Result { + if self.principal != principal { + return Err(Error::Unauthorized); + } + let addressbook = self + .addr_store + .read() + .await + .get_addressbook(&self.principal, &self.addressbook_id) + .await + .map_err(|_e| Error::NotFound)?; + Ok(addressbook.into()) + } + + async fn get_members( + &self, + rmap: &ResourceMap, + ) -> Result, Self::Error> { + Ok(self + .addr_store + .read() + .await + .get_objects(&self.principal, &self.addressbook_id) + .await? + .into_iter() + .map(|object| { + ( + AddressObjectResource::get_url( + rmap, + vec![&self.principal, &self.addressbook_id, object.get_id()], + ) + .unwrap(), + object.into(), + ) + }) + .collect()) + } + + async fn new( + req: &HttpRequest, + path_components: Self::PathComponents, + ) -> Result { + let addr_store = req + .app_data::>>() + .expect("no addressbook store in app_data!") + .clone() + .into_inner(); + + Ok(Self { + path: req.path().to_owned(), + principal: path_components.0, + addressbook_id: path_components.1, + addr_store, + }) + } + + async fn save_resource(&self, file: Self::Resource) -> Result<(), Self::Error> { + self.addr_store + .write() + .await + .update_addressbook( + self.principal.to_owned(), + self.addressbook_id.to_owned(), + file.into(), + ) + .await?; + Ok(()) + } + + async fn delete_resource(&self, use_trashbin: bool) -> Result<(), Self::Error> { + self.addr_store + .write() + .await + .delete_addressbook(&self.principal, &self.addressbook_id, use_trashbin) + .await?; + Ok(()) + } + + #[inline] + fn actix_additional_routes(res: actix_web::Resource) -> actix_web::Resource { + let mkcol_method = web::method(Method::from_str("MKCOL").unwrap()); + let report_method = web::method(Method::from_str("REPORT").unwrap()); + res.route(mkcol_method.to(route_mkcol::)) + .route(report_method.to(route_report_addressbook::)) + } +} diff --git a/crates/carddav/src/error.rs b/crates/carddav/src/error.rs new file mode 100644 index 0000000..5edb3de --- /dev/null +++ b/crates/carddav/src/error.rs @@ -0,0 +1,49 @@ +use actix_web::{http::StatusCode, HttpResponse}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Unauthorized")] + Unauthorized, + + #[error("Not Found")] + NotFound, + + #[error("Not implemented")] + NotImplemented, + + #[error(transparent)] + StoreError(#[from] rustical_store::Error), + + #[error(transparent)] + DavError(#[from] rustical_dav::Error), + + #[error(transparent)] + XmlDecodeError(#[from] quick_xml::DeError), + + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +impl actix_web::ResponseError for Error { + fn status_code(&self) -> actix_web::http::StatusCode { + match self { + Error::StoreError(err) => match err { + rustical_store::Error::NotFound => StatusCode::NOT_FOUND, + rustical_store::Error::InvalidIcs(_) => StatusCode::BAD_REQUEST, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }, + Error::DavError(err) => err.status_code(), + Error::Unauthorized => StatusCode::UNAUTHORIZED, + Error::XmlDecodeError(_) => StatusCode::BAD_REQUEST, + Error::NotImplemented => StatusCode::INTERNAL_SERVER_ERROR, + Error::Other(_) => StatusCode::INTERNAL_SERVER_ERROR, + Error::NotFound => StatusCode::NOT_FOUND, + } + } + fn error_response(&self) -> actix_web::HttpResponse { + match self { + Error::DavError(err) => err.error_response(), + _ => HttpResponse::build(self.status_code()).body(self.to_string()), + } + } +} diff --git a/crates/carddav/src/lib.rs b/crates/carddav/src/lib.rs index ab1729e..3e4104a 100644 --- a/crates/carddav/src/lib.rs +++ b/crates/carddav/src/lib.rs @@ -1,17 +1,81 @@ -use actix_web::{web, HttpResponse, Responder}; +use actix_web::{ + dev::Service, + http::{ + header::{HeaderName, HeaderValue}, + Method, StatusCode, + }, + web::{self, Data}, +}; +use address_object::resource::AddressObjectResourceService; +use addressbook::resource::AddressbookResourceService; +pub use error::Error; +use futures_util::FutureExt; +use principal::PrincipalResourceService; +use root::RootResourceService; +use rustical_dav::resource::ResourceService; +use rustical_store::{ + auth::{AuthenticationMiddleware, AuthenticationProvider}, + AddressbookStore, +}; +use std::sync::Arc; +use tokio::sync::RwLock; + +pub mod address_object; +pub mod addressbook; +pub mod error; +pub mod principal; +pub mod root; pub fn configure_well_known(cfg: &mut web::ServiceConfig, carddav_root: String) { cfg.service(web::redirect("/carddav", carddav_root).permanent()); } -pub fn configure_dav(_cfg: &mut web::ServiceConfig, _prefix: String) {} - -pub async fn options_handler() -> impl Responder { - HttpResponse::Ok() - .insert_header(( - "Allow", - "OPTIONS, GET, HEAD, POST, PUT, REPORT, PROPFIND, PROPPATCH, MKCOL", - )) - .insert_header(("DAV", "1, 2, 3, addressbook, extended-mkcol")) - .body("options") +pub fn configure_dav( + cfg: &mut web::ServiceConfig, + auth_provider: Arc, + store: Arc>, +) { + cfg.service( + web::scope("") + .wrap(AuthenticationMiddleware::new(auth_provider)) + .wrap_fn(|req, srv| { + // Middleware to set the DAV header + // Could be more elegant if actix_web::guard::RegisteredMethods was public :( + let method = req.method().clone(); + srv.call(req).map(move |res| { + if method == Method::OPTIONS { + return res.map(|mut response| { + if response.status() == StatusCode::METHOD_NOT_ALLOWED { + response.headers_mut().insert( + HeaderName::from_static("dav"), + HeaderValue::from_static( + "1, 2, 3, access-control, addressbook, extended-mkcol", + ), + ); + *response.response_mut().status_mut() = StatusCode::OK; + } + response + }); + } + res + }) + }) + .app_data(Data::from(store.clone())) + .service(RootResourceService::actix_resource()) + .service( + web::scope("/user").service( + web::scope("/{principal}") + .service(PrincipalResourceService::::actix_resource()) + .service( + web::scope("/{addressbook}") + .service(AddressbookResourceService::::actix_resource()) + .service( + web::scope("/{object}").service( + AddressObjectResourceService::::actix_resource(), + ), + ), + ), + ), + ), + ); } diff --git a/crates/carddav/src/principal/mod.rs b/crates/carddav/src/principal/mod.rs new file mode 100644 index 0000000..5bbd323 --- /dev/null +++ b/crates/carddav/src/principal/mod.rs @@ -0,0 +1,159 @@ +use crate::addressbook::resource::AddressbookResource; +use crate::Error; +use actix_web::dev::ResourceMap; +use actix_web::web::Data; +use actix_web::HttpRequest; +use async_trait::async_trait; +use rustical_dav::resource::{InvalidProperty, Resource, ResourceService}; +use rustical_dav::xml::HrefElement; +use rustical_store::AddressbookStore; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use strum::{EnumString, VariantNames}; +use tokio::sync::RwLock; + +pub struct PrincipalResourceService { + principal: String, + addr_store: Arc>, +} + +#[derive(Clone)] +pub struct PrincipalResource { + principal: String, +} + +#[derive(Deserialize, Serialize, Default, Debug)] +#[serde(rename_all = "kebab-case")] +pub struct Resourcetype { + principal: (), + collection: (), +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "kebab-case")] +pub enum PrincipalProp { + // WebDAV (RFC 2518) + Resourcetype(Resourcetype), + + // WebDAV Access Control (RFC 3744) + #[serde(rename = "principal-URL")] + PrincipalUrl(HrefElement), + + // WebDAV Current Principal Extension (RFC 5397) + CurrentUserPrincipal(HrefElement), + + // CardDAV (RFC 6352) + #[serde(rename = "CARD:addressbook-home-set")] + AddressbookHomeSet(HrefElement), + #[serde(rename = "CARD:principal-address")] + PrincipalAddress(Option), + #[serde(other)] + Invalid, +} + +impl InvalidProperty for PrincipalProp { + fn invalid_property(&self) -> bool { + matches!(self, Self::Invalid) + } +} + +#[derive(EnumString, Debug, VariantNames, Clone)] +#[strum(serialize_all = "kebab-case")] +pub enum PrincipalPropName { + Resourcetype, + CurrentUserPrincipal, + #[strum(serialize = "principal-URL")] + PrincipalUrl, + AddressbookHomeSet, + PrincipalAddress, +} + +impl Resource for PrincipalResource { + type PropName = PrincipalPropName; + type Prop = PrincipalProp; + type Error = Error; + + fn get_prop( + &self, + rmap: &ResourceMap, + prop: Self::PropName, + ) -> Result { + let principal_href = HrefElement::new(Self::get_url(rmap, vec![&self.principal]).unwrap()); + + Ok(match prop { + PrincipalPropName::Resourcetype => PrincipalProp::Resourcetype(Resourcetype::default()), + PrincipalPropName::CurrentUserPrincipal => { + PrincipalProp::CurrentUserPrincipal(principal_href) + } + PrincipalPropName::PrincipalUrl => PrincipalProp::PrincipalUrl(principal_href), + PrincipalPropName::AddressbookHomeSet => { + PrincipalProp::AddressbookHomeSet(principal_href) + } + PrincipalPropName::PrincipalAddress => PrincipalProp::PrincipalAddress(None), + }) + } + + #[inline] + fn resource_name() -> &'static str { + "carddav_principal" + } +} + +#[async_trait(?Send)] +impl ResourceService for PrincipalResourceService { + type PathComponents = (String,); + type MemberType = AddressbookResource; + type Resource = PrincipalResource; + type Error = Error; + + async fn new( + req: &HttpRequest, + (principal,): Self::PathComponents, + ) -> Result { + let addr_store = req + .app_data::>>() + .expect("no addressbook store in app_data!") + .clone() + .into_inner(); + + Ok(Self { + addr_store, + principal, + }) + } + + async fn get_resource(&self, principal: String) -> Result { + if self.principal != principal { + return Err(Error::Unauthorized); + } + Ok(PrincipalResource { + principal: self.principal.to_owned(), + }) + } + + async fn get_members( + &self, + rmap: &ResourceMap, + ) -> Result, Self::Error> { + let addressbooks = self + .addr_store + .read() + .await + .get_addressbooks(&self.principal) + .await?; + Ok(addressbooks + .into_iter() + .map(|addressbook| { + ( + AddressbookResource::get_url(rmap, vec![&self.principal, &addressbook.id]) + .unwrap(), + addressbook.into(), + ) + }) + .collect()) + } + + async fn save_resource(&self, _file: Self::Resource) -> Result<(), Self::Error> { + Err(Error::NotImplemented) + } +} diff --git a/crates/carddav/src/root/mod.rs b/crates/carddav/src/root/mod.rs new file mode 100644 index 0000000..eab037e --- /dev/null +++ b/crates/carddav/src/root/mod.rs @@ -0,0 +1,95 @@ +use crate::principal::PrincipalResource; +use crate::Error; +use actix_web::dev::ResourceMap; +use actix_web::HttpRequest; +use async_trait::async_trait; +use rustical_dav::resource::{InvalidProperty, Resource, ResourceService}; +use rustical_dav::xml::HrefElement; +use serde::{Deserialize, Serialize}; +use strum::{EnumString, VariantNames}; + +#[derive(EnumString, Debug, VariantNames, Clone)] +#[strum(serialize_all = "kebab-case")] +pub enum RootPropName { + Resourcetype, + // Defined by RFC 5397 + CurrentUserPrincipal, +} + +#[derive(Deserialize, Serialize, Default, Debug)] +#[serde(rename_all = "kebab-case")] +pub struct Resourcetype { + collection: (), +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "kebab-case")] +pub enum RootProp { + // WebDAV (RFC 2518) + Resourcetype(Resourcetype), + + // WebDAV Current Principal Extension (RFC 5397) + CurrentUserPrincipal(HrefElement), + #[serde(other)] + Invalid, +} + +impl InvalidProperty for RootProp { + fn invalid_property(&self) -> bool { + matches!(self, Self::Invalid) + } +} + +#[derive(Clone)] +pub struct RootResource { + principal: String, +} + +impl Resource for RootResource { + type PropName = RootPropName; + type Prop = RootProp; + type Error = Error; + + fn get_prop( + &self, + rmap: &ResourceMap, + prop: Self::PropName, + ) -> Result { + Ok(match prop { + RootPropName::Resourcetype => RootProp::Resourcetype(Resourcetype::default()), + RootPropName::CurrentUserPrincipal => RootProp::CurrentUserPrincipal(HrefElement::new( + PrincipalResource::get_url(rmap, vec![&self.principal]).unwrap(), + )), + }) + } + + #[inline] + fn resource_name() -> &'static str { + "carddav_root" + } +} + +pub struct RootResourceService; + +#[async_trait(?Send)] +impl ResourceService for RootResourceService { + type PathComponents = (); + type MemberType = PrincipalResource; + type Resource = RootResource; + type Error = Error; + + async fn new( + _req: &HttpRequest, + _path_components: Self::PathComponents, + ) -> Result { + Ok(Self) + } + + async fn get_resource(&self, principal: String) -> Result { + Ok(RootResource { principal }) + } + + async fn save_resource(&self, _file: Self::Resource) -> Result<(), Self::Error> { + Err(Error::NotImplemented) + } +} diff --git a/crates/store/migrations/2_addressbook.sql b/crates/store/migrations/2_addressbook.sql new file mode 100644 index 0000000..5d20292 --- /dev/null +++ b/crates/store/migrations/2_addressbook.sql @@ -0,0 +1,34 @@ +CREATE TABLE addressbooks ( + principal TEXT NOT NULL, + id TEXT NOT NULL, + synctoken INTEGER DEFAULT 0 NOT NULL, + displayname TEXT, + description TEXT, + deleted_at DATETIME, + PRIMARY KEY (principal, id) +); + +CREATE TABLE addressobjects ( + principal TEXT NOT NULL, + addressbook_id TEXT NOT NULL, + id TEXT NOT NULL, + vcf TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + deleted_at DATETIME, + PRIMARY KEY (principal, addressbook_id, id), + FOREIGN KEY (principal, addressbook_id) + REFERENCES addressbooks (principal, id) ON DELETE CASCADE +); + +CREATE TABLE addressobjectchangelog ( + -- The actual sync token is the SQLite field 'ROWID' + principal TEXT NOT NULL, + addressbook_id TEXT NOT NULL, + object_id TEXT NOT NULL, + operation INTEGER NOT NULL, + synctoken INTEGER DEFAULT 0 NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (principal, addressbook_id, created_at), + FOREIGN KEY (principal, addressbook_id) + REFERENCES addressbooks (principal, id) ON DELETE CASCADE +); diff --git a/crates/store/src/addressbook_store.rs b/crates/store/src/addressbook_store.rs new file mode 100644 index 0000000..d0e7fa9 --- /dev/null +++ b/crates/store/src/addressbook_store.rs @@ -0,0 +1,64 @@ +use crate::{ + error::Error, + model::{AddressObject, Addressbook}, +}; +use async_trait::async_trait; + +#[async_trait] +pub trait AddressbookStore: Send + Sync + 'static { + async fn get_addressbook(&self, principal: &str, id: &str) -> Result; + async fn get_addressbooks(&self, principal: &str) -> Result, Error>; + + async fn update_addressbook( + &mut self, + principal: String, + id: String, + addressbook: Addressbook, + ) -> Result<(), Error>; + async fn insert_addressbook(&mut self, addressbook: Addressbook) -> Result<(), Error>; + async fn delete_addressbook( + &mut self, + principal: &str, + name: &str, + use_trashbin: bool, + ) -> Result<(), Error>; + async fn restore_addressbook(&mut self, principal: &str, name: &str) -> Result<(), Error>; + + async fn sync_changes( + &self, + principal: &str, + addressbook_id: &str, + synctoken: i64, + ) -> Result<(Vec, Vec, i64), Error>; + + async fn get_objects( + &self, + principal: &str, + addressbook_id: &str, + ) -> Result, Error>; + async fn get_object( + &self, + principal: &str, + addressbook_id: &str, + object_id: &str, + ) -> Result; + async fn put_object( + &mut self, + principal: String, + addressbook_id: String, + object: AddressObject, + ) -> Result<(), Error>; + async fn delete_object( + &mut self, + principal: &str, + addressbook_id: &str, + object_id: &str, + use_trashbin: bool, + ) -> Result<(), Error>; + async fn restore_object( + &mut self, + principal: &str, + addressbook_id: &str, + object_id: &str, + ) -> Result<(), Error>; +} diff --git a/crates/store/src/calendar_store.rs b/crates/store/src/calendar_store.rs index ab67477..e7b5b5e 100644 --- a/crates/store/src/calendar_store.rs +++ b/crates/store/src/calendar_store.rs @@ -1,9 +1,7 @@ -use anyhow::Result; -use async_trait::async_trait; - use crate::error::Error; use crate::model::object::CalendarObject; use crate::model::Calendar; +use async_trait::async_trait; #[async_trait] pub trait CalendarStore: Send + Sync + 'static { diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index d749032..ca3a682 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -1,8 +1,11 @@ +pub mod addressbook_store; pub mod calendar_store; pub mod error; pub mod model; pub mod sqlite_store; pub mod timestamp; -pub use calendar_store::CalendarStore; pub use error::Error; pub mod auth; + +pub use addressbook_store::AddressbookStore; +pub use calendar_store::CalendarStore; diff --git a/crates/store/src/model/address_object.rs b/crates/store/src/model/address_object.rs new file mode 100644 index 0000000..250fe11 --- /dev/null +++ b/crates/store/src/model/address_object.rs @@ -0,0 +1,28 @@ +use sha2::{Digest, Sha256}; + +use crate::Error; + +#[derive(Debug, Clone)] +pub struct AddressObject { + id: String, + vcf: String, +} + +impl AddressObject { + pub fn from_vcf(object_id: String, vcf: String) -> Result { + Ok(Self { id: object_id, vcf }) + } + pub fn get_id(&self) -> &str { + &self.id + } + pub fn get_etag(&self) -> String { + let mut hasher = Sha256::new(); + hasher.update(&self.id); + hasher.update(self.get_vcf()); + format!("{:x}", hasher.finalize()) + } + + pub fn get_vcf(&self) -> &str { + &self.vcf + } +} diff --git a/crates/store/src/model/addressbook.rs b/crates/store/src/model/addressbook.rs new file mode 100644 index 0000000..5c3c899 --- /dev/null +++ b/crates/store/src/model/addressbook.rs @@ -0,0 +1,32 @@ +use chrono::NaiveDateTime; + +#[derive(Debug, Clone)] +pub struct Addressbook { + pub id: String, + pub principal: String, + pub displayname: Option, + pub description: Option, + pub deleted_at: Option, + pub synctoken: i64, +} + +impl Addressbook { + pub fn format_synctoken(&self) -> String { + format_synctoken(self.synctoken) + } +} + +// TODO: make nicer +const SYNC_NAMESPACE: &str = "github.com/lennart-k/rustical/ns/"; + +pub fn format_synctoken(synctoken: i64) -> String { + format!("{}{}", SYNC_NAMESPACE, synctoken) +} + +pub fn parse_synctoken(synctoken: &str) -> Option { + if !synctoken.starts_with(SYNC_NAMESPACE) { + return None; + } + let (_, synctoken) = synctoken.split_at(SYNC_NAMESPACE.len()); + synctoken.parse::().ok() +} diff --git a/crates/store/src/model/mod.rs b/crates/store/src/model/mod.rs index 039f027..822227e 100644 --- a/crates/store/src/model/mod.rs +++ b/crates/store/src/model/mod.rs @@ -5,3 +5,9 @@ pub mod todo; pub use calendar::Calendar; pub use object::CalendarObject; + +pub mod addressbook; +pub use addressbook::Addressbook; + +pub mod address_object; +pub use address_object::AddressObject; diff --git a/crates/store/src/sqlite_store/addressbook_store.rs b/crates/store/src/sqlite_store/addressbook_store.rs new file mode 100644 index 0000000..927ccfd --- /dev/null +++ b/crates/store/src/sqlite_store/addressbook_store.rs @@ -0,0 +1,371 @@ +use super::SqliteStore; +use crate::Error; +use crate::{ + model::{AddressObject, Addressbook}, + AddressbookStore, +}; +use async_trait::async_trait; +use serde::Serialize; +use sqlx::{Sqlite, Transaction}; +use tracing::instrument; + +#[derive(Debug, Clone)] +struct AddressObjectRow { + id: String, + vcf: String, +} + +impl TryFrom for AddressObject { + type Error = Error; + + fn try_from(value: AddressObjectRow) -> Result { + Self::from_vcf(value.id, value.vcf) + } +} + +#[derive(Debug, Clone, Serialize, sqlx::Type)] +#[serde(rename_all = "kebab-case")] +enum AddressbookChangeOperation { + // There's no distinction between Add and Modify + Add, + Delete, +} + +// Logs an operation to the events +async fn log_object_operation( + tx: &mut Transaction<'_, Sqlite>, + principal: &str, + addressbook_id: &str, + object_id: &str, + operation: AddressbookChangeOperation, +) -> Result<(), Error> { + sqlx::query!( + r#" + UPDATE addressbooks + SET synctoken = synctoken + 1 + WHERE (principal, id) = (?1, ?2)"#, + principal, + addressbook_id + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + r#" + INSERT INTO addressobjectchangelog (principal, addressbook_id, object_id, operation, synctoken) + VALUES (?1, ?2, ?3, ?4, ( + SELECT synctoken FROM addressbooks WHERE (principal, id) = (?1, ?2) + ))"#, + principal, + addressbook_id, + object_id, + operation + ) + .execute(&mut **tx) + .await?; + Ok(()) +} + +#[async_trait] +impl AddressbookStore for SqliteStore { + #[instrument] + async fn get_addressbook(&self, principal: &str, id: &str) -> Result { + let addressbook = sqlx::query_as!( + Addressbook, + r#"SELECT principal, id, synctoken, displayname, description, deleted_at + FROM addressbooks + WHERE (principal, id) = (?, ?)"#, + principal, + id + ) + .fetch_one(&self.db) + .await?; + Ok(addressbook) + } + + #[instrument] + async fn get_addressbooks(&self, principal: &str) -> Result, Error> { + let addressbooks = sqlx::query_as!( + Addressbook, + r#"SELECT principal, id, synctoken, displayname, description, deleted_at + FROM addressbooks + WHERE principal = ? AND deleted_at IS NULL"#, + principal + ) + .fetch_all(&self.db) + .await?; + Ok(addressbooks) + } + + #[instrument] + async fn update_addressbook( + &mut self, + principal: String, + id: String, + addressbook: Addressbook, + ) -> Result<(), Error> { + let result = sqlx::query!( + r#"UPDATE addressbooks SET principal = ?, id = ?, displayname = ?, description = ? + WHERE (principal, id) = (?, ?)"#, + addressbook.principal, + addressbook.id, + addressbook.displayname, + addressbook.description, + principal, + id + ) + .execute(&self.db) + .await?; + if result.rows_affected() == 0 { + return Err(Error::NotFound); + } + Ok(()) + } + + #[instrument] + async fn insert_addressbook(&mut self, addressbook: Addressbook) -> Result<(), Error> { + sqlx::query!( + r#"INSERT INTO addressbooks (principal, id, displayname, description) + VALUES (?, ?, ?, ?)"#, + addressbook.principal, + addressbook.id, + addressbook.displayname, + addressbook.description, + ) + .execute(&self.db) + .await?; + Ok(()) + } + + #[instrument] + async fn delete_addressbook( + &mut self, + principal: &str, + addressbook_id: &str, + use_trashbin: bool, + ) -> Result<(), Error> { + match use_trashbin { + true => { + sqlx::query!( + r#"UPDATE addressbooks SET deleted_at = datetime() WHERE (principal, id) = (?, ?)"#, + principal, addressbook_id + ) + .execute(&self.db) + .await?; + } + false => { + sqlx::query!( + r#"DELETE FROM addressbooks WHERE (principal, id) = (?, ?)"#, + principal, + addressbook_id + ) + .execute(&self.db) + .await?; + } + }; + Ok(()) + } + + #[instrument] + async fn restore_addressbook( + &mut self, + principal: &str, + addressbook_id: &str, + ) -> Result<(), Error> { + sqlx::query!( + r"UPDATE addressbooks SET deleted_at = NULL WHERE (principal, id) = (?, ?)", + principal, + addressbook_id + ) + .execute(&self.db) + .await?; + Ok(()) + } + + #[instrument] + async fn sync_changes( + &self, + principal: &str, + addressbook_id: &str, + synctoken: i64, + ) -> Result<(Vec, Vec, i64), Error> { + struct Row { + object_id: String, + synctoken: i64, + } + let changes = sqlx::query_as!( + Row, + r#" + SELECT DISTINCT object_id, max(0, synctoken) as "synctoken!: i64" from addressobjectchangelog + WHERE synctoken > ? + ORDER BY synctoken ASC + "#, + synctoken + ) + .fetch_all(&self.db) + .await?; + + let mut objects = vec![]; + let mut deleted_objects = vec![]; + + let new_synctoken = changes + .last() + .map(|&Row { synctoken, .. }| synctoken) + .unwrap_or(0); + + for Row { object_id, .. } in changes { + match self.get_object(principal, addressbook_id, &object_id).await { + Ok(object) => objects.push(object), + Err(Error::NotFound) => deleted_objects.push(object_id), + Err(err) => return Err(err), + } + } + + Ok((objects, deleted_objects, new_synctoken)) + } + + #[instrument] + async fn get_objects( + &self, + principal: &str, + addressbook_id: &str, + ) -> Result, Error> { + sqlx::query_as!( + AddressObjectRow, + "SELECT id, vcf FROM addressobjects WHERE principal = ? AND addressbook_id = ? AND deleted_at IS NULL", + principal, + addressbook_id + ) + .fetch_all(&self.db) + .await? + .into_iter() + .map(|row| row.try_into()) + .collect() + } + + #[instrument] + async fn get_object( + &self, + principal: &str, + addressbook_id: &str, + object_id: &str, + ) -> Result { + Ok(sqlx::query_as!( + AddressObjectRow, + "SELECT id, vcf FROM addressobjects WHERE (principal, addressbook_id, id) = (?, ?, ?)", + principal, + addressbook_id, + object_id + ) + .fetch_one(&self.db) + .await? + .try_into()?) + } + + #[instrument] + async fn put_object( + &mut self, + principal: String, + addressbook_id: String, + object: AddressObject, + ) -> Result<(), Error> { + let mut tx = self.db.begin().await?; + + let (object_id, vcf) = (object.get_id(), object.get_vcf()); + + sqlx::query!( + "REPLACE INTO addressobjects (principal, addressbook_id, id, vcf) VALUES (?, ?, ?, ?)", + principal, + addressbook_id, + object_id, + vcf + ) + .execute(&mut *tx) + .await?; + + log_object_operation( + &mut tx, + &principal, + &addressbook_id, + object_id, + AddressbookChangeOperation::Add, + ) + .await?; + + tx.commit().await?; + Ok(()) + } + + #[instrument] + async fn delete_object( + &mut self, + principal: &str, + addressbook_id: &str, + object_id: &str, + use_trashbin: bool, + ) -> Result<(), Error> { + let mut tx = self.db.begin().await?; + + match use_trashbin { + true => { + sqlx::query!( + "UPDATE addressobjects SET deleted_at = datetime(), updated_at = datetime() WHERE (principal, addressbook_id, id) = (?, ?, ?)", + principal, + addressbook_id, + object_id + ) + .execute(&mut *tx) + .await?; + } + false => { + sqlx::query!( + "DELETE FROM addressobjects WHERE addressbook_id = ? AND id = ?", + addressbook_id, + object_id + ) + .execute(&mut *tx) + .await?; + } + }; + log_object_operation( + &mut tx, + principal, + addressbook_id, + object_id, + AddressbookChangeOperation::Delete, + ) + .await?; + tx.commit().await?; + Ok(()) + } + + #[instrument] + async fn restore_object( + &mut self, + principal: &str, + addressbook_id: &str, + object_id: &str, + ) -> Result<(), Error> { + let mut tx = self.db.begin().await?; + + sqlx::query!( + r#"UPDATE addressobjects SET deleted_at = NULL, updated_at = datetime() WHERE (principal, addressbook_id, id) = (?, ?, ?)"#, + principal, + addressbook_id, + object_id + ) + .execute(&mut *tx) + .await?; + + log_object_operation( + &mut tx, + principal, + addressbook_id, + object_id, + AddressbookChangeOperation::Delete, + ) + .await?; + tx.commit().await?; + Ok(()) + } +} diff --git a/crates/store/src/sqlite_store.rs b/crates/store/src/sqlite_store/calendar_store.rs similarity index 91% rename from crates/store/src/sqlite_store.rs rename to crates/store/src/sqlite_store/calendar_store.rs index d45eec4..9031ef7 100644 --- a/crates/store/src/sqlite_store.rs +++ b/crates/store/src/sqlite_store/calendar_store.rs @@ -1,24 +1,14 @@ +use super::SqliteStore; use crate::model::object::CalendarObject; use crate::model::Calendar; use crate::{CalendarStore, Error}; use anyhow::Result; use async_trait::async_trait; use serde::Serialize; +use sqlx::Sqlite; use sqlx::Transaction; -use sqlx::{sqlite::SqliteConnectOptions, Pool, Sqlite, SqlitePool}; use tracing::instrument; -#[derive(Debug)] -pub struct SqliteCalendarStore { - db: SqlitePool, -} - -impl SqliteCalendarStore { - pub fn new(db: SqlitePool) -> Self { - Self { db } - } -} - #[derive(Debug, Clone)] struct CalendarObjectRow { id: String, @@ -77,7 +67,7 @@ async fn log_object_operation( } #[async_trait] -impl CalendarStore for SqliteCalendarStore { +impl CalendarStore for SqliteStore { #[instrument] async fn get_calendar(&self, principal: &str, id: &str) -> Result { let cal = sqlx::query_as!( @@ -380,23 +370,3 @@ impl CalendarStore for SqliteCalendarStore { Ok((objects, deleted_objects, new_synctoken)) } } - -pub async fn create_db_pool(db_url: &str, migrate: bool) -> anyhow::Result> { - let db = SqlitePool::connect_with( - SqliteConnectOptions::new() - .filename(db_url) - .create_if_missing(true), - ) - .await?; - if migrate { - println!("Running database migrations"); - sqlx::migrate!("./migrations").run(&db).await?; - } - Ok(db) -} - -pub async fn create_test_store() -> anyhow::Result { - let db = SqlitePool::connect("sqlite::memory:").await?; - sqlx::migrate!("./migrations").run(&db).await?; - Ok(SqliteCalendarStore::new(db)) -} diff --git a/crates/store/src/sqlite_store/mod.rs b/crates/store/src/sqlite_store/mod.rs new file mode 100644 index 0000000..1b5f3ea --- /dev/null +++ b/crates/store/src/sqlite_store/mod.rs @@ -0,0 +1,35 @@ +use sqlx::{sqlite::SqliteConnectOptions, Pool, Sqlite, SqlitePool}; + +pub mod addressbook_store; +pub mod calendar_store; + +#[derive(Debug)] +pub struct SqliteStore { + db: SqlitePool, +} + +impl SqliteStore { + pub fn new(db: SqlitePool) -> Self { + Self { db } + } +} + +pub async fn create_db_pool(db_url: &str, migrate: bool) -> anyhow::Result> { + let db = SqlitePool::connect_with( + SqliteConnectOptions::new() + .filename(db_url) + .create_if_missing(true), + ) + .await?; + if migrate { + println!("Running database migrations"); + sqlx::migrate!("./migrations").run(&db).await?; + } + Ok(db) +} + +pub async fn create_test_store() -> anyhow::Result { + let db = SqlitePool::connect("sqlite::memory:").await?; + sqlx::migrate!("./migrations").run(&db).await?; + Ok(SqliteStore::new(db)) +} diff --git a/src/app.rs b/src/app.rs index dbd21c0..4116692 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,12 +4,13 @@ use actix_web::middleware::NormalizePath; use actix_web::{web, App}; use rustical_frontend::configure_frontend; use rustical_store::auth::AuthenticationProvider; -use rustical_store::CalendarStore; +use rustical_store::{AddressbookStore, CalendarStore}; use std::sync::Arc; use tokio::sync::RwLock; use tracing_actix_web::TracingLogger; -pub fn make_app( +pub fn make_app( + addr_store: Arc>, cal_store: Arc>, auth_provider: Arc, ) -> App< @@ -28,15 +29,15 @@ pub fn make_app( .service(web::scope("/caldav").configure(|cfg| { rustical_caldav::configure_dav(cfg, auth_provider.clone(), cal_store.clone()) })) - .service( - web::scope("/carddav") - .configure(|cfg| rustical_carddav::configure_dav(cfg, "/carddav".to_string())), - ) + .service(web::scope("/carddav").configure(|cfg| { + rustical_carddav::configure_dav(cfg, auth_provider.clone(), addr_store.clone()) + })) .service( web::scope("/.well-known") - .configure(|cfg| rustical_caldav::configure_well_known(cfg, "/caldav".to_string())), // .configure(|cfg| { - // rustical_carddav::configure_well_known(cfg, "/carddav".to_string()) - // }), + .configure(|cfg| rustical_caldav::configure_well_known(cfg, "/caldav".to_string())) + .configure(|cfg| { + rustical_carddav::configure_well_known(cfg, "/carddav".to_string()) + }), ) .service( web::scope("/frontend") diff --git a/src/config.rs b/src/config.rs index 649dd4b..21d11b3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,14 +9,14 @@ pub struct HttpConfig { } #[derive(Debug, Deserialize, Serialize)] -pub struct SqliteCalendarStoreConfig { +pub struct SqliteDataStoreConfig { pub db_url: String, } #[derive(Debug, Deserialize, Serialize)] #[serde(tag = "backend", rename_all = "snake_case")] -pub enum CalendarStoreConfig { - Sqlite(SqliteCalendarStoreConfig), +pub enum DataStoreConfig { + Sqlite(SqliteDataStoreConfig), } #[derive(Debug, Deserialize, Serialize)] @@ -32,7 +32,7 @@ pub struct TracingConfig { #[derive(Debug, Deserialize, Serialize)] pub struct Config { - pub calendar_store: CalendarStoreConfig, + pub data_store: DataStoreConfig, pub auth: AuthConfig, pub http: HttpConfig, pub frontend: FrontendConfig, diff --git a/src/main.rs b/src/main.rs index 93f8f3e..b1be647 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,7 @@ use actix_web::HttpServer; use anyhow::Result; use app::make_app; use clap::Parser; -use config::{CalendarStoreConfig, SqliteCalendarStoreConfig, TracingConfig}; +use config::{DataStoreConfig, SqliteDataStoreConfig, TracingConfig}; use opentelemetry::global; use opentelemetry::trace::TracerProvider; use opentelemetry::KeyValue; @@ -14,8 +14,8 @@ use opentelemetry_sdk::{runtime, Resource}; use opentelemetry_semantic_conventions::resource::{SERVICE_NAME, SERVICE_VERSION}; use opentelemetry_semantic_conventions::SCHEMA_URL; use rustical_store::auth::StaticUserStore; -use rustical_store::sqlite_store::{create_db_pool, SqliteCalendarStore}; -use rustical_store::CalendarStore; +use rustical_store::sqlite_store::{create_db_pool, SqliteStore}; +use rustical_store::{AddressbookStore, CalendarStore}; use std::fs; use std::sync::Arc; use std::time::Duration; @@ -38,17 +38,20 @@ struct Args { migrate: bool, } -async fn get_cal_store( +async fn get_data_stores( migrate: bool, - config: &CalendarStoreConfig, -) -> Result>> { - let cal_store: Arc> = match &config { - CalendarStoreConfig::Sqlite(SqliteCalendarStoreConfig { db_url }) => { + config: &DataStoreConfig, +) -> Result<( + Arc>, + Arc>, +)> { + Ok(match &config { + DataStoreConfig::Sqlite(SqliteDataStoreConfig { db_url }) => { let db = create_db_pool(db_url, migrate).await?; - Arc::new(RwLock::new(SqliteCalendarStore::new(db))) + let sqlite_store = Arc::new(RwLock::new(SqliteStore::new(db))); + (sqlite_store.clone(), sqlite_store.clone()) } - }; - Ok(cal_store) + }) } pub fn init_tracer() -> Tracer { @@ -104,13 +107,13 @@ async fn main() -> Result<()> { setup_tracing(&config.tracing); - let cal_store = get_cal_store(args.migrate, &config.calendar_store).await?; + let (addr_store, cal_store) = get_data_stores(args.migrate, &config.data_store).await?; let user_store = Arc::new(match config.auth { config::AuthConfig::Static(config) => StaticUserStore::new(config), }); - HttpServer::new(move || make_app(cal_store.clone(), user_store.clone())) + HttpServer::new(move || make_app(addr_store.clone(), cal_store.clone(), user_store.clone())) .bind((config.http.host, config.http.port))? .run() .await?;