From 5cdbb3b9d3c3d259f9227065ced5204f8ecd6318 Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Sun, 2 Nov 2025 21:06:43 +0100 Subject: [PATCH] migrate birthday store to sqlite --- Cargo.lock | 2 +- crates/store/Cargo.toml | 1 - crates/store/src/contact_birthday_store.rs | 209 ----------- crates/store/src/lib.rs | 4 +- crates/store_sqlite/Cargo.toml | 1 + .../addressbook_store/birthday_calendar.rs | 353 ++++++++++++++++++ .../mod.rs} | 37 +- src/app.rs | 11 +- src/main.rs | 6 +- 9 files changed, 388 insertions(+), 236 deletions(-) delete mode 100644 crates/store/src/contact_birthday_store.rs create mode 100644 crates/store_sqlite/src/addressbook_store/birthday_calendar.rs rename crates/store_sqlite/src/{addressbook_store.rs => addressbook_store/mod.rs} (96%) diff --git a/Cargo.lock b/Cargo.lock index ba16073..84cdb25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3227,7 +3227,6 @@ dependencies = [ "rustical_store_sqlite", "rustical_xml", "serde", - "sha2", "thiserror 2.0.17", "tokio", "tower", @@ -3250,6 +3249,7 @@ dependencies = [ "rustical_ical", "rustical_store", "serde", + "sha2", "sqlx", "thiserror 2.0.17", "tokio", diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml index 98823af..2426311 100644 --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -11,7 +11,6 @@ publish = false anyhow = { workspace = true } async-trait = { workspace = true } serde = { workspace = true } -sha2 = { workspace = true } ical = { workspace = true } chrono = { workspace = true } regex = { workspace = true } diff --git a/crates/store/src/contact_birthday_store.rs b/crates/store/src/contact_birthday_store.rs deleted file mode 100644 index 507f328..0000000 --- a/crates/store/src/contact_birthday_store.rs +++ /dev/null @@ -1,209 +0,0 @@ -use crate::{ - Addressbook, AddressbookStore, Calendar, CalendarStore, Error, calendar::CalendarMetadata, - combined_calendar_store::PrefixedCalendarStore, -}; -use async_trait::async_trait; -use derive_more::derive::Constructor; -use rustical_ical::{AddressObject, CalendarObject, CalendarObjectType}; -use sha2::{Digest, Sha256}; -use std::{collections::HashMap, sync::Arc}; - -pub const BIRTHDAYS_PREFIX: &str = "_birthdays_"; - -#[derive(Constructor, Clone)] -pub struct ContactBirthdayStore(Arc); - -impl PrefixedCalendarStore for ContactBirthdayStore { - const PREFIX: &'static str = BIRTHDAYS_PREFIX; -} - -fn birthday_calendar(addressbook: Addressbook) -> Calendar { - Calendar { - principal: addressbook.principal, - id: format!("{}{}", BIRTHDAYS_PREFIX, addressbook.id), - meta: CalendarMetadata { - displayname: addressbook - .displayname - .map(|name| format!("{name} birthdays")), - order: 0, - description: None, - color: None, - }, - timezone_id: None, - deleted_at: addressbook.deleted_at, - synctoken: addressbook.synctoken, - subscription_url: None, - push_topic: { - let mut hasher = Sha256::new(); - hasher.update("birthdays"); - hasher.update(addressbook.push_topic); - format!("{:x}", hasher.finalize()) - }, - components: vec![CalendarObjectType::Event], - } -} - -/// Objects are all prefixed with `BIRTHDAYS_PREFIX` -#[async_trait] -impl CalendarStore for ContactBirthdayStore { - async fn get_calendar( - &self, - principal: &str, - id: &str, - show_deleted: bool, - ) -> Result { - let id = id.strip_prefix(BIRTHDAYS_PREFIX).ok_or(Error::NotFound)?; - let addressbook = self.0.get_addressbook(principal, id, show_deleted).await?; - Ok(birthday_calendar(addressbook)) - } - - async fn get_calendars(&self, principal: &str) -> Result, Error> { - let addressbooks = self.0.get_addressbooks(principal).await?; - Ok(addressbooks.into_iter().map(birthday_calendar).collect()) - } - - async fn get_deleted_calendars(&self, principal: &str) -> Result, Error> { - let addressbooks = self.0.get_deleted_addressbooks(principal).await?; - Ok(addressbooks.into_iter().map(birthday_calendar).collect()) - } - - async fn update_calendar( - &self, - _principal: String, - _id: String, - _calendar: Calendar, - ) -> Result<(), Error> { - Err(Error::ReadOnly) - } - - async fn insert_calendar(&self, _calendar: Calendar) -> Result<(), Error> { - Err(Error::ReadOnly) - } - async fn delete_calendar( - &self, - _principal: &str, - _name: &str, - _use_trashbin: bool, - ) -> Result<(), Error> { - Err(Error::ReadOnly) - } - - async fn restore_calendar(&self, _principal: &str, _name: &str) -> Result<(), Error> { - Err(Error::ReadOnly) - } - - async fn import_calendar( - &self, - _calendar: Calendar, - _objects: Vec, - _merge_existing: bool, - ) -> Result<(), Error> { - Err(Error::ReadOnly) - } - - async fn sync_changes( - &self, - principal: &str, - cal_id: &str, - synctoken: i64, - ) -> Result<(Vec, Vec, i64), Error> { - let cal_id = cal_id - .strip_prefix(BIRTHDAYS_PREFIX) - .ok_or(Error::NotFound)?; - let (objects, deleted_objects, new_synctoken) = - self.0.sync_changes(principal, cal_id, synctoken).await?; - let objects: Result>, rustical_ical::Error> = objects - .iter() - .map(AddressObject::get_birthday_object) - .collect(); - let objects = objects?.into_iter().flatten().collect(); - - Ok((objects, deleted_objects, new_synctoken)) - } - - async fn calendar_metadata( - &self, - principal: &str, - cal_id: &str, - ) -> Result { - let cal_id = cal_id - .strip_prefix(BIRTHDAYS_PREFIX) - .ok_or(Error::NotFound)?; - self.0.addressbook_metadata(principal, cal_id).await - } - - async fn get_objects( - &self, - principal: &str, - cal_id: &str, - ) -> Result, Error> { - let cal_id = cal_id - .strip_prefix(BIRTHDAYS_PREFIX) - .ok_or(Error::NotFound)?; - let objects: Result>, rustical_ical::Error> = - self.0 - .get_objects(principal, cal_id) - .await? - .iter() - .map(AddressObject::get_significant_dates) - .collect(); - let objects = objects? - .into_iter() - .flat_map(HashMap::into_values) - .collect(); - - Ok(objects) - } - - async fn get_object( - &self, - principal: &str, - cal_id: &str, - object_id: &str, - show_deleted: bool, - ) -> Result { - let cal_id = cal_id - .strip_prefix(BIRTHDAYS_PREFIX) - .ok_or(Error::NotFound)?; - let (addressobject_id, date_type) = object_id.rsplit_once('-').ok_or(Error::NotFound)?; - self.0 - .get_object(principal, cal_id, addressobject_id, show_deleted) - .await? - .get_significant_dates()? - .remove(date_type) - .ok_or(Error::NotFound) - } - - async fn put_object( - &self, - _principal: String, - _cal_id: String, - _object: CalendarObject, - _overwrite: bool, - ) -> Result<(), Error> { - Err(Error::ReadOnly) - } - - async fn delete_object( - &self, - _principal: &str, - _cal_id: &str, - _object_id: &str, - _use_trashbin: bool, - ) -> Result<(), Error> { - Err(Error::ReadOnly) - } - - async fn restore_object( - &self, - _principal: &str, - _cal_id: &str, - _object_id: &str, - ) -> Result<(), Error> { - Err(Error::ReadOnly) - } - - fn is_read_only(&self, _cal_id: &str) -> bool { - true - } -} diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 73ac4d2..2dd9050 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -7,7 +7,6 @@ pub use error::Error; pub mod auth; mod calendar; mod combined_calendar_store; -mod contact_birthday_store; mod secret; mod subscription_store; pub mod synctoken; @@ -17,8 +16,7 @@ pub mod tests; pub use addressbook_store::AddressbookStore; pub use calendar_store::CalendarStore; -pub use combined_calendar_store::CombinedCalendarStore; -pub use contact_birthday_store::ContactBirthdayStore; +pub use combined_calendar_store::{CombinedCalendarStore, PrefixedCalendarStore}; pub use secret::Secret; pub use subscription_store::*; diff --git a/crates/store_sqlite/Cargo.toml b/crates/store_sqlite/Cargo.toml index e4b67f4..084c4cf 100644 --- a/crates/store_sqlite/Cargo.toml +++ b/crates/store_sqlite/Cargo.toml @@ -29,3 +29,4 @@ uuid.workspace = true pbkdf2.workspace = true rustical_ical.workspace = true rstest = { workspace = true, optional = true } +sha2.workspace = true diff --git a/crates/store_sqlite/src/addressbook_store/birthday_calendar.rs b/crates/store_sqlite/src/addressbook_store/birthday_calendar.rs new file mode 100644 index 0000000..71ef5a6 --- /dev/null +++ b/crates/store_sqlite/src/addressbook_store/birthday_calendar.rs @@ -0,0 +1,353 @@ +use crate::addressbook_store::SqliteAddressbookStore; +use async_trait::async_trait; +use chrono::NaiveDateTime; +use rustical_ical::{AddressObject, CalendarObject, CalendarObjectType}; +use rustical_store::{ + Addressbook, AddressbookStore, Calendar, CalendarMetadata, CalendarStore, CollectionMetadata, + Error, PrefixedCalendarStore, +}; +use sha2::{Digest, Sha256}; +use sqlx::{Executor, Sqlite}; +use std::collections::HashMap; +use tracing::instrument; + +pub const BIRTHDAYS_PREFIX: &str = "_birthdays_"; + +struct BirthdayCalendarJoinRow { + principal: String, + id: String, + displayname: Option, + description: Option, + order: i64, + color: Option, + timezone_id: Option, + deleted_at: Option, + push_topic: String, + + addr_synctoken: i64, +} + +impl From for Calendar { + fn from(value: BirthdayCalendarJoinRow) -> Self { + Self { + principal: value.principal, + id: format!("{}{}", BIRTHDAYS_PREFIX, value.id), + meta: CalendarMetadata { + displayname: value.displayname, + order: value.order, + description: value.description, + color: value.color, + }, + deleted_at: value.deleted_at, + components: vec![CalendarObjectType::Event], + timezone_id: value.timezone_id, + synctoken: value.addr_synctoken, + subscription_url: None, + push_topic: value.push_topic, + } + } +} + +impl PrefixedCalendarStore for SqliteAddressbookStore { + const PREFIX: &'static str = BIRTHDAYS_PREFIX; +} + +impl SqliteAddressbookStore { + #[instrument] + pub async fn _get_birthday_calendar<'e, E: Executor<'e, Database = Sqlite>>( + executor: E, + principal: &str, + id: &str, + show_deleted: bool, + ) -> Result { + let cal = sqlx::query_as!( + BirthdayCalendarJoinRow, + r#"SELECT principal, id, displayname, description, "order", color, timezone_id, deleted_at, addr_synctoken, push_topic + FROM birthday_calendars + INNER JOIN ( + SELECT principal AS addr_principal, + id AS addr_id, + synctoken AS addr_synctoken + FROM addressbooks + ) ON (principal, id) = (addr_principal, addr_id) + WHERE (principal, id) = (?, ?) + AND ((deleted_at IS NULL) OR ?) + "#, + principal, + id, + show_deleted + ) + .fetch_one(executor) + .await + .map_err(crate::Error::from)?; + Ok(cal.into()) + } + + #[instrument] + pub async fn _get_birthday_calendars<'e, E: Executor<'e, Database = Sqlite>>( + executor: E, + principal: &str, + deleted: bool, + ) -> Result, Error> { + Ok( + sqlx::query_as!( + BirthdayCalendarJoinRow, + r#"SELECT principal, id, displayname, description, "order", color, timezone_id, deleted_at, addr_synctoken, push_topic + FROM birthday_calendars + INNER JOIN ( + SELECT principal AS addr_principal, + id AS addr_id, + synctoken AS addr_synctoken + FROM addressbooks + ) ON (principal, id) = (addr_principal, addr_id) + WHERE principal = ? + AND ( + (deleted_at IS NULL AND NOT ?) -- not deleted, want not deleted + OR (deleted_at IS NOT NULL AND ?) -- deleted, want deleted + ) + "#, + principal, + deleted, + deleted + ) + .fetch_all(executor) + .await + .map_err(crate::Error::from).map(|cals| cals.into_iter().map(BirthdayCalendarJoinRow::into).collect())?) + } + + #[instrument] + pub async fn _insert_birthday_calendar<'e, E: Executor<'e, Database = Sqlite>>( + executor: E, + addressbook: Addressbook, + ) -> Result<(), rustical_store::Error> { + let birthday_name = addressbook + .displayname + .map(|name| format!("{name} birthdays")); + let birthday_push_topic = { + let mut hasher = Sha256::new(); + hasher.update("birthdays"); + hasher.update(addressbook.push_topic); + format!("{:x}", hasher.finalize()) + }; + + sqlx::query!( + r#"INSERT INTO birthday_calendars (principal, id, displayname, push_topic) + VALUES (?, ?, ?, ?)"#, + addressbook.principal, + addressbook.id, + birthday_name, + birthday_push_topic, + ) + .execute(executor) + .await + .map_err(crate::Error::from)?; + Ok(()) + } + + #[instrument] + async fn _update_birthday_calendar<'e, E: Executor<'e, Database = Sqlite>>( + executor: E, + principal: &str, + calendar: &Calendar, + ) -> Result<(), Error> { + let result = sqlx::query!( + r#"UPDATE birthday_calendars SET principal = ?, id = ?, displayname = ?, description = ?, "order" = ?, color = ?, timezone_id = ?, push_topic = ? + WHERE (principal, id) = (?, ?)"#, + calendar.principal, + calendar.id, + calendar.meta.displayname, + calendar.meta.description, + calendar.meta.order, + calendar.meta.color, + calendar.timezone_id, + calendar.push_topic, + principal, + calendar.id, + ).execute(executor).await.map_err(crate::Error::from)?; + if result.rows_affected() == 0 { + return Err(rustical_store::Error::NotFound); + } + Ok(()) + } +} + +#[async_trait] +impl CalendarStore for SqliteAddressbookStore { + #[instrument] + async fn get_calendar( + &self, + principal: &str, + id: &str, + show_deleted: bool, + ) -> Result { + let id = id.strip_prefix(BIRTHDAYS_PREFIX).ok_or(Error::NotFound)?; + Self::_get_birthday_calendar(&self.db, principal, id, show_deleted).await + } + + #[instrument] + async fn get_calendars(&self, principal: &str) -> Result, Error> { + Self::_get_birthday_calendars(&self.db, principal, false).await + } + + #[instrument] + async fn get_deleted_calendars(&self, principal: &str) -> Result, Error> { + Self::_get_birthday_calendars(&self.db, principal, true).await + } + + #[instrument] + async fn update_calendar( + &self, + principal: String, + id: String, + mut calendar: Calendar, + ) -> Result<(), Error> { + assert_eq!(id, calendar.id); + calendar.id = calendar + .id + .strip_prefix(BIRTHDAYS_PREFIX) + .ok_or(Error::NotFound)? + .to_string(); + Self::_update_birthday_calendar(&self.db, &principal, &calendar).await + } + + #[instrument] + async fn insert_calendar(&self, _calendar: Calendar) -> Result<(), Error> { + Err(Error::ReadOnly) + } + + #[instrument] + async fn delete_calendar( + &self, + _principal: &str, + _name: &str, + _use_trashbin: bool, + ) -> Result<(), Error> { + Err(Error::ReadOnly) + } + + #[instrument] + async fn restore_calendar(&self, _principal: &str, _name: &str) -> Result<(), Error> { + Err(Error::ReadOnly) + } + + #[instrument] + async fn import_calendar( + &self, + _calendar: Calendar, + _objects: Vec, + _merge_existing: bool, + ) -> Result<(), Error> { + Err(Error::ReadOnly) + } + + #[instrument] + async fn sync_changes( + &self, + principal: &str, + cal_id: &str, + synctoken: i64, + ) -> Result<(Vec, Vec, i64), Error> { + let cal_id = cal_id + .strip_prefix(BIRTHDAYS_PREFIX) + .ok_or(Error::NotFound)?; + let (objects, deleted_objects, new_synctoken) = + AddressbookStore::sync_changes(self, principal, cal_id, synctoken).await?; + let objects: Result>, rustical_ical::Error> = objects + .iter() + .map(AddressObject::get_birthday_object) + .collect(); + let objects = objects?.into_iter().flatten().collect(); + + Ok((objects, deleted_objects, new_synctoken)) + } + + #[instrument] + async fn calendar_metadata( + &self, + principal: &str, + cal_id: &str, + ) -> Result { + let cal_id = cal_id + .strip_prefix(BIRTHDAYS_PREFIX) + .ok_or(Error::NotFound)?; + self.addressbook_metadata(principal, cal_id).await + } + + #[instrument] + async fn get_objects( + &self, + principal: &str, + cal_id: &str, + ) -> Result, Error> { + let cal_id = cal_id + .strip_prefix(BIRTHDAYS_PREFIX) + .ok_or(Error::NotFound)?; + let objects: Result>, rustical_ical::Error> = + AddressbookStore::get_objects(self, principal, cal_id) + .await? + .iter() + .map(AddressObject::get_significant_dates) + .collect(); + let objects = objects? + .into_iter() + .flat_map(HashMap::into_values) + .collect(); + + Ok(objects) + } + + #[instrument] + async fn get_object( + &self, + principal: &str, + cal_id: &str, + object_id: &str, + show_deleted: bool, + ) -> Result { + let cal_id = cal_id + .strip_prefix(BIRTHDAYS_PREFIX) + .ok_or(Error::NotFound)?; + let (addressobject_id, date_type) = object_id.rsplit_once('-').ok_or(Error::NotFound)?; + AddressbookStore::get_object(self, principal, cal_id, addressobject_id, show_deleted) + .await? + .get_significant_dates()? + .remove(date_type) + .ok_or(Error::NotFound) + } + + #[instrument] + async fn put_object( + &self, + _principal: String, + _cal_id: String, + _object: CalendarObject, + _overwrite: bool, + ) -> Result<(), Error> { + Err(Error::ReadOnly) + } + + #[instrument] + async fn delete_object( + &self, + _principal: &str, + _cal_id: &str, + _object_id: &str, + _use_trashbin: bool, + ) -> Result<(), Error> { + Err(Error::ReadOnly) + } + + #[instrument] + async fn restore_object( + &self, + _principal: &str, + _cal_id: &str, + _object_id: &str, + ) -> Result<(), Error> { + Err(Error::ReadOnly) + } + + fn is_read_only(&self, _cal_id: &str) -> bool { + true + } +} diff --git a/crates/store_sqlite/src/addressbook_store.rs b/crates/store_sqlite/src/addressbook_store/mod.rs similarity index 96% rename from crates/store_sqlite/src/addressbook_store.rs rename to crates/store_sqlite/src/addressbook_store/mod.rs index 497bfdb..e4794dd 100644 --- a/crates/store_sqlite/src/addressbook_store.rs +++ b/crates/store_sqlite/src/addressbook_store/mod.rs @@ -11,6 +11,8 @@ use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction}; use tokio::sync::mpsc::Sender; use tracing::{error, instrument}; +pub mod birthday_calendar; + #[derive(Debug, Clone)] struct AddressObjectRow { id: String, @@ -116,7 +118,7 @@ impl SqliteAddressbookStore { async fn _insert_addressbook<'e, E: Executor<'e, Database = Sqlite>>( executor: E, - addressbook: Addressbook, + addressbook: &Addressbook, ) -> Result<(), rustical_store::Error> { sqlx::query!( r#"INSERT INTO addressbooks (principal, id, displayname, description, push_topic) @@ -283,9 +285,9 @@ impl SqliteAddressbookStore { async fn _put_object<'e, E: Executor<'e, Database = Sqlite>>( executor: E, - principal: String, - addressbook_id: String, - object: AddressObject, + principal: &str, + addressbook_id: &str, + object: &AddressObject, overwrite: bool, ) -> Result<(), rustical_store::Error> { let (object_id, vcf) = (object.get_id(), object.get_vcf()); @@ -405,7 +407,15 @@ impl AddressbookStore for SqliteAddressbookStore { &self, addressbook: Addressbook, ) -> Result<(), rustical_store::Error> { - Self::_insert_addressbook(&self.db, addressbook).await + let mut tx = self + .db + .begin_with(BEGIN_IMMEDIATE) + .await + .map_err(crate::Error::from)?; + Self::_insert_addressbook(&mut *tx, &addressbook).await?; + Self::_insert_birthday_calendar(&mut *tx, addressbook).await?; + tx.commit().await.map_err(crate::Error::from)?; + Ok(()) } #[instrument] @@ -521,14 +531,7 @@ impl AddressbookStore for SqliteAddressbookStore { let object_id = object.get_id().to_owned(); - Self::_put_object( - &mut *tx, - principal.clone(), - addressbook_id.clone(), - object, - overwrite, - ) - .await?; + Self::_put_object(&mut *tx, &principal, &addressbook_id, &object, overwrite).await?; let sync_token = log_object_operation( &mut tx, @@ -659,15 +662,15 @@ impl AddressbookStore for SqliteAddressbookStore { return Err(Error::AlreadyExists); } if existing.is_none() { - Self::_insert_addressbook(&mut *tx, addressbook.clone()).await?; + Self::_insert_addressbook(&mut *tx, &addressbook).await?; } for object in objects { Self::_put_object( &mut *tx, - addressbook.principal.clone(), - addressbook.id.clone(), - object, + &addressbook.principal, + &addressbook.id, + &object, false, ) .await?; diff --git a/src/app.rs b/src/app.rs index 7634d2c..2a16b91 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,7 +16,8 @@ use rustical_frontend::{FrontendConfig, frontend_router}; use rustical_oidc::OidcConfig; use rustical_store::auth::AuthenticationProvider; use rustical_store::{ - AddressbookStore, CalendarStore, CombinedCalendarStore, ContactBirthdayStore, SubscriptionStore, + AddressbookStore, CalendarStore, CombinedCalendarStore, PrefixedCalendarStore, + SubscriptionStore, }; use std::sync::Arc; use std::time::Duration; @@ -33,7 +34,11 @@ use tracing::field::display; clippy::too_many_lines, clippy::cognitive_complexity )] -pub fn make_app( +pub fn make_app< + AS: AddressbookStore + PrefixedCalendarStore, + CS: CalendarStore, + S: SubscriptionStore, +>( addr_store: Arc, cal_store: Arc, subscription_store: Arc, @@ -45,7 +50,7 @@ pub fn make_app( session_cookie_samesite_strict: bool, payload_limit_mb: usize, ) -> Router<()> { - let birthday_store = Arc::new(ContactBirthdayStore::new(addr_store.clone())); + let birthday_store = addr_store.clone(); let combined_cal_store = Arc::new(CombinedCalendarStore::new(cal_store).with_store(birthday_store)); diff --git a/src/main.rs b/src/main.rs index b4d88f3..706fa20 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,9 @@ use figment::Figment; use figment::providers::{Env, Format, Toml}; use rustical_dav_push::DavPushController; use rustical_store::auth::AuthenticationProvider; -use rustical_store::{AddressbookStore, CalendarStore, CollectionOperation, SubscriptionStore}; +use rustical_store::{ + AddressbookStore, CalendarStore, CollectionOperation, PrefixedCalendarStore, SubscriptionStore, +}; use rustical_store_sqlite::addressbook_store::SqliteAddressbookStore; use rustical_store_sqlite::calendar_store::SqliteCalendarStore; use rustical_store_sqlite::principal_store::SqlitePrincipalStore; @@ -56,7 +58,7 @@ async fn get_data_stores( migrate: bool, config: &DataStoreConfig, ) -> Result<( - Arc, + Arc, Arc, Arc, Arc,