//! JSON management of calendars and address books (session-authenticated): //! - `GET`, `POST {PIM_COLLECTIONS}` — own, lent and generated; a new one //! - `PUT`, `DELETE {PIM_COLLECTIONS}/{id}` — change or delete one, or end its loan //! - `GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}` — loans of an own collection //! - `POST {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}` — lend it, or change a loan //! - `GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}{CANDIDATES_SUFFIX}` — who it can be lent to //! - `DELETE {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}/{user_id}` — end a loan //! - `GET {PIM_SHARES}` — the own feed links and loans //! - `GET`, `POST {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}` — public feeds of an own collection //! - `DELETE {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}/{link_id}` — revoke a feed //! - `POST {PIM_COLLECTIONS}/{id}{IMPORT_SUFFIX}` — import a file //! - `POST {PIM_IMPORT_NEW}` — import a file as a new collection //! - `GET {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}` — download //! - `GET {PIM_SYSTEM_EXPORT}` — the same for the system address book //! //! - `GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}{PHOTO_SUFFIX}` — a contact's photo //! //! Public: `GET {FEED}/{token}` — a collection as one file. use std::collections::HashMap; use std::sync::Arc; use api_types::{ AdminPimLink, CreatePimCollection, CreatePimLink, CreatePimShare, FEED, OkResp, PimCollectionInfo, PimCollectionKind, PimImportNew, PimImportResult, PimLend, PimLinkInfo, PimOwnShares, PimShareCandidate, PimShareInfo, PimShareMode, PimSkipped, UpdatePimCollection, }; use axum::Json; use axum::body::Body; use axum::extract::{Path as AxumPath, Query, State}; use axum::http::header::{CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE, ETAG, IF_NONE_MATCH}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use pimdav::bundle::{self, Detail}; use pimdav::principal::UserType; use pimdav::{contact, object}; use sha2::{Digest, Sha256}; use crate::api::common::{SessionUser, blocking, hash_password, validate_password}; use crate::api::dav::challenge; use crate::api::files::disposition; use crate::api::pim::{ BIRTHDAYS, BIRTHDAYS_SLUG, DIRECTORY, DIRECTORY_SLUG, INBOX, MAX_COLLECTIONS, MAX_DESCRIPTION, MAX_DISPLAYNAME, MAX_RESOURCE_SIZE, OUTBOX, SHARED_PREFIX, collection_href, delete_own, etag_of, generated, mailto, members_of, valid_text, }; use crate::api::pim_schedule::{self, Directory, object_name}; use crate::api::pim_views; use crate::auth; use crate::db::{PimCollection, PimKind, PimLink, PimObject, PimOp, PropPlace, User}; use crate::error::{ApiError, AppState}; /// The largest file an import reads. const MAX_IMPORT: usize = 20 * 1024 * 1024; /// Largest total an import may split into. Each object carries a copy of /// the time zones it names. const MAX_SPLIT: usize = 128 * 1024 * 1024; /// How many skipped objects an import names. const MAX_SKIPPED: usize = 100; pub(super) fn wire_kind(kind: PimKind) -> PimCollectionKind { match kind { PimKind::Calendar => PimCollectionKind::Calendar, PimKind::AddressBook => PimCollectionKind::Addressbook, } } fn name_of(c: &PimCollection) -> String { c.displayname.clone().unwrap_or_else(|| c.slug.clone()) } /// A collection as `GET {PIM_COLLECTIONS}` lists it. fn info( c: &PimCollection, kind: PimKind, url: String, owner: &str, mode: Option, ) -> PimCollectionInfo { PimCollectionInfo { id: c.id, kind: wire_kind(kind), name: name_of(c), url, owner: owner.to_string(), mode, generated: generated(c.id), color: c.color.clone(), description: c.description.clone(), components: c .components .split(',') .filter(|s| !s.is_empty()) .map(str::to_string) .collect(), transparent: c.transparent, is_default: false, shares: 0, links: 0, } } /// GET {PIM_COLLECTIONS} pub async fn list( State(state): State>, auth: SessionUser, ) -> Result>, ApiError> { let me = &auth.user; let pid = state.db.principal_of(me.id).await?; state.db.pim_ensure_defaults(pid).await?; let default = state .db .pim_calendar_for(pid, "VEVENT") .await? .map(|c| c.id); let counts = state.db.pim_share_counts(pid).await?; let mut out = Vec::new(); for kind in [PimKind::Calendar, PimKind::AddressBook] { for c in state.db.pim_collections(pid, kind).await? { if kind == PimKind::Calendar && c.slug == INBOX { continue; } let url = collection_href(&me.name, kind, &c.slug, None); let (shares, links) = counts.get(&c.id).copied().unwrap_or_default(); out.push(PimCollectionInfo { is_default: default == Some(c.id), shares, links, ..info(&c, kind, url, &me.name, None) }); } let (slug, generated) = match kind { PimKind::Calendar => (BIRTHDAYS_SLUG, generated_info(BIRTHDAYS)), PimKind::AddressBook => (DIRECTORY_SLUG, generated_info(DIRECTORY)), }; let url = collection_href(&me.name, kind, slug, None); out.push(info(&generated, kind, url, &me.name, None)); for (c, owner, mode) in state.db.pim_shared_collections(me.id, kind).await? { let url = collection_href(&me.name, kind, &c.slug, Some(c.id)); out.push(info(&c, kind, url, &owner, Some(mode))); } } Ok(Json(out)) } /// The generated collections are gray, so they never look like one of the /// user's own. Keep it out of the web UI's palette. const GENERATED_COLOR: &str = "#94a3b8"; /// A generated collection without its members, which listing it needs /// not build. fn generated_info(id: i64) -> PimCollection { match id { BIRTHDAYS => PimCollection { id, slug: BIRTHDAYS_SLUG.to_string(), displayname: Some("Birthdays".to_string()), color: Some(GENERATED_COLOR.to_string()), components: "VEVENT".to_string(), transparent: true, ..Default::default() }, _ => PimCollection { id, slug: DIRECTORY_SLUG.to_string(), displayname: Some("Directory".to_string()), color: Some(GENERATED_COLOR.to_string()), ..Default::default() }, } } fn db_kind(kind: PimCollectionKind) -> PimKind { match kind { PimCollectionKind::Calendar => PimKind::Calendar, PimCollectionKind::Addressbook => PimKind::AddressBook, } } /// `#rgb`, `#rrggbb` or `#rrggbbaa`: what clients write to `calendar-color`. fn valid_color(c: &str) -> bool { c.strip_prefix('#') .is_some_and(|h| [3, 6, 8].contains(&h.len()) && h.bytes().all(|b| b.is_ascii_hexdigit())) } fn bad_request(msg: &str) -> ApiError { ApiError::new(StatusCode::BAD_REQUEST, msg) } /// A URL segment from a display name: ASCII letters, digits and dashes. fn slug_of(name: &str, kind: PimKind) -> String { let mut slug = String::new(); for c in name.chars().flat_map(char::to_lowercase) { match c { 'a'..='z' | '0'..='9' => slug.push(c), _ if !slug.ends_with('-') && !slug.is_empty() => slug.push('-'), _ => {} } } let slug: String = slug.trim_end_matches('-').chars().take(40).collect(); match slug.trim_end_matches('-') { "" => match kind { PimKind::Calendar => "calendar".to_string(), PimKind::AddressBook => "contacts".to_string(), }, s => s.to_string(), } } /// POST {PIM_COLLECTIONS} pub async fn create( State(state): State>, auth: SessionUser, Json(body): Json, ) -> Result, ApiError> { let color = body.color.filter(|c| !c.trim().is_empty()); if color.as_deref().is_some_and(|c| !valid_color(c)) { return Err(bad_request("invalid color")); } let info = create_collection( &state, &auth.user, db_kind(body.kind), &body.name, color, body.description .map(|d| d.trim().to_string()) .filter(|d| !d.is_empty()), &body.components, ) .await?; Ok(Json(info)) } /// A new own collection, with a slug made from its name. async fn create_collection( state: &AppState, me: &User, kind: PimKind, name: &str, color: Option, description: Option, components: &[String], ) -> Result { let pid = state.db.principal_of(me.id).await?; let name = name.trim(); if name.is_empty() { return Err(bad_request("a name is required")); } if !valid_text(name, MAX_DISPLAYNAME, false) { return Err(bad_request("invalid name")); } if description .as_deref() .is_some_and(|d| !valid_text(d, MAX_DESCRIPTION, true)) { return Err(bad_request("invalid description")); } let components = match kind { PimKind::Calendar if components.is_empty() => "VEVENT,VTODO,VJOURNAL".to_string(), PimKind::Calendar => { let comps: Vec = components .iter() .map(|c| c.trim().to_ascii_uppercase()) .collect(); if !comps .iter() .all(|c| ["VEVENT", "VTODO", "VJOURNAL"].contains(&c.as_str())) { return Err(bad_request("unknown component type")); } comps.join(",") } PimKind::AddressBook => String::new(), }; let base = slug_of(name, kind); // A suffix would turn "shared" into the lent form "shared-2". let base = match format!("{base}-").starts_with(SHARED_PREFIX) { true => format!("own-{base}"), false => base, }; let reserved = [DIRECTORY_SLUG, BIRTHDAYS_SLUG, INBOX, OUTBOX].contains(&base.as_str()); let mut col = PimCollection { displayname: Some(name.to_string()), description, color, components, ..Default::default() }; let _lock = pim_schedule::LOCK.lock().await; let count = state.db.pim_collections(pid, kind).await?; if count.iter().filter(|c| c.slug != INBOX).count() >= MAX_COLLECTIONS { return Err(ApiError::new(StatusCode::FORBIDDEN, "too many collections")); } for n in 1..100 { let slug = match n { 1 if !reserved => base.clone(), 1 => continue, n => format!("{base}-{n}"), }; col.slug = slug.clone(); if state.db.pim_create_collection(pid, kind, &col, &[]).await? { let c = state .db .pim_collection(pid, kind, &slug) .await? .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?; let url = collection_href(&me.name, kind, &slug, None); return Ok(info(&c, kind, url, &me.name, None)); } } Err(ApiError::new(StatusCode::CONFLICT, "no free name")) } /// PUT {PIM_COLLECTIONS}/{id} pub async fn update( State(state): State>, auth: SessionUser, AxumPath(id): AxumPath, Json(body): Json, ) -> Result, ApiError> { let id = own(&state, &auth, id).await?; let (_, kind, mut col) = state .db .pim_collection_by_id(id) .await? .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?; let before = col.clone(); if let Some(name) = body.name { let name = name.trim(); if name.is_empty() { return Err(bad_request("a name is required")); } if !valid_text(name, MAX_DISPLAYNAME, false) { return Err(bad_request("invalid name")); } col.displayname = Some(name.to_string()); } if let Some(color) = body.color { let color = color.trim(); if !color.is_empty() && !valid_color(color) { return Err(bad_request("invalid color")); } col.color = (!color.is_empty()).then(|| color.to_string()); } if let Some(d) = body.description { if !valid_text(&d, MAX_DESCRIPTION, true) { return Err(bad_request("invalid description")); } col.description = (!d.trim().is_empty()).then(|| d.trim().to_string()); } if let Some(t) = body.transparent { if kind != PimKind::Calendar { return Err(bad_request("transparent needs a calendar")); } col.transparent = t; } state .db .pim_patch(PropPlace::Collection(id), Some((&before, &col)), &[], &[]) .await?; let url = collection_href(&auth.user.name, kind, &col.slug, None); Ok(Json(info(&col, kind, url, &auth.user.name, None))) } /// DELETE {PIM_COLLECTIONS}/{id}: an own collection, or the loan of a lent /// one. pub async fn delete( State(state): State>, auth: SessionUser, AxumPath(id): AxumPath, ) -> Result, ApiError> { let (owner, kind, col, _) = reachable(&state, &auth, id).await?; let pid = state.db.principal_of(auth.user.id).await?; if generated(id) { return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only collection")); } if owner != pid { let _lock = pim_schedule::LOCK.lock().await; state.db.pim_remove_share(id, auth.user.id).await?; return Ok(Json(OkResp {})); } match delete_own(&state, pid, kind, &col).await? { Ok(()) => Ok(Json(OkResp {})), Err(_) => Err(ApiError::localized( StatusCode::CONFLICT, "the calendar that receives invitations cannot be deleted", "err_default_calendar", )), } } /// The id of a collection the signed-in user owns, or 404. async fn own(state: &AppState, auth: &SessionUser, id: i64) -> Result { let pid = state.db.principal_of(auth.user.id).await?; match state.db.pim_collection_by_id(id).await? { // The inbox is not lent: it holds messages, not events. Some((owner, _, c)) if owner == pid && c.slug != INBOX => Ok(id), _ => Err(ApiError::new(StatusCode::NOT_FOUND, "collection not found")), } } /// GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX} pub async fn shares( State(state): State>, auth: SessionUser, AxumPath(id): AxumPath, ) -> Result>, ApiError> { let id = own(&state, &auth, id).await?; let out = state .db .pim_shares(id) .await? .into_iter() .map(|(user_id, user_name, mode)| PimShareInfo { user_id, user_name, mode, }) .collect(); Ok(Json(out)) } /// GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}{CANDIDATES_SUFFIX} /// /// Every signed-in user already sees all accounts in principal search and /// the system address book, so listing them here reveals nothing new. pub async fn share_candidates( State(state): State>, auth: SessionUser, AxumPath(id): AxumPath, ) -> Result>, ApiError> { let id = own(&state, &auth, id).await?; let out = state .db .pim_share_candidates(id, auth.user.id) .await? .into_iter() .map(|(name, display_name)| PimShareCandidate { name, display_name }) .collect(); Ok(Json(out)) } /// POST {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX} pub async fn share( State(state): State>, auth: SessionUser, AxumPath(id): AxumPath, Json(body): Json, ) -> Result, ApiError> { // PUT checks the access again under LOCK, so a narrower share applies at // once. Under it, the collection cannot go before the share is written. let _lock = pim_schedule::LOCK.lock().await; let id = own(&state, &auth, id).await?; let name = body.user.trim(); let found = match state.db.pim_principal(name).await? { Some(p) => p.user_id.map(|uid| (uid, p.name)), // The lookup hides disabled accounts. Their loans still take a new mode. None => state .db .pim_shares(id) .await? .into_iter() .find(|(_, n, _)| n == name) .map(|(uid, n, _)| (uid, n)), }; let Some((user_id, user_name)) = found else { return Err(ApiError::new(StatusCode::NOT_FOUND, "user not found")); }; if user_id == auth.user.id { return Err(ApiError::new( StatusCode::BAD_REQUEST, "a collection cannot be shared with its owner", )); } state.db.pim_set_share(id, user_id, body.mode).await?; Ok(Json(PimShareInfo { user_id, user_name, mode: body.mode, })) } /// DELETE {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}/{user_id} pub async fn unshare( State(state): State>, auth: SessionUser, AxumPath((id, user_id)): AxumPath<(i64, i64)>, ) -> Result, ApiError> { let id = own(&state, &auth, id).await?; let _lock = pim_schedule::LOCK.lock().await; if !state.db.pim_remove_share(id, user_id).await? { return Err(ApiError::new(StatusCode::NOT_FOUND, "share not found")); } Ok(Json(OkResp {})) } /// A collection the signed-in user may read: its owner principal, kind, the /// collection, and whether they may also write it. The inbox is not one. pub(super) async fn reachable( state: &AppState, auth: &SessionUser, id: i64, ) -> Result<(i64, PimKind, PimCollection, bool), ApiError> { let not_found = || ApiError::new(StatusCode::NOT_FOUND, "collection not found"); let pid = state.db.principal_of(auth.user.id).await?; if generated(id) { let (kind, col) = match id { BIRTHDAYS => (PimKind::Calendar, generated_info(BIRTHDAYS)), DIRECTORY => (PimKind::AddressBook, generated_info(DIRECTORY)), _ => return Err(not_found()), }; return Ok((pid, kind, col, false)); } let (owner, kind, c) = state .db .pim_collection_by_id(id) .await? .ok_or_else(not_found)?; if c.slug == INBOX { return Err(not_found()); } if owner == pid { return Ok((owner, kind, c, true)); } match state .db .pim_shared_collection(auth.user.id, kind, id) .await? { Some((_, _, mode)) => Ok((owner, kind, c, mode != PimShareMode::Ro)), None => Err(not_found()), } } /// GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}{PHOTO_SUFFIX} /// /// Always a WebP thumbnail, never the stored bytes: those come from a client /// and could be HTML or SVG with script. Without a thumbnail cache it is made /// on each request; a matching ETag still skips the decode. pub async fn photo( State(state): State>, auth: SessionUser, AxumPath((id, name)): AxumPath<(i64, String)>, headers: HeaderMap, ) -> Result { let no_photo = || ApiError::new(StatusCode::NOT_FOUND, "no photo"); let (_, kind, _, _) = reachable(&state, &auth, id).await?; if kind != PimKind::AddressBook { return Err(no_photo()); } let (obj, data) = state.db.pim_object(id, &name).await?.ok_or_else(no_photo)?; let cached = [ (ETAG, obj.etag.clone()), (CACHE_CONTROL, "private, no-cache".to_string()), ]; if headers.get(IF_NONE_MATCH).and_then(|v| v.to_str().ok()) == Some(obj.etag.as_str()) { return Ok((StatusCode::NOT_MODIFIED, cached).into_response()); } let image = contact::photo(&String::from_utf8_lossy(&data)).ok_or_else(no_photo)?; let bytes = match &state.thumbs { Some(thumbs) => { thumbs .of_bytes(&format!("pim-photo {}", obj.etag), image) .await } None => crate::thumb::of_image(image).await, } .ok_or_else(no_photo)?; Ok((cached, [(CONTENT_TYPE, "image/webp")], bytes).into_response()) } fn extension(kind: PimKind) -> &'static str { match kind { PimKind::Calendar => "ics", PimKind::AddressBook => "vcf", } } pub(super) fn link_info(link: &PimLink, kind: PimKind) -> PimLinkInfo { PimLinkInfo { id: link.id, path: format!("{FEED}/{}.{}", link.token, extension(kind)), busy_only: link.busy_only, created_at: link.created_at.clone(), expires_at: link.expires_at.clone(), has_password: link.password_hash.is_some(), } } pub fn feed_entry(r: crate::db::PimLinkWithOwner) -> AdminPimLink { AdminPimLink { link: link_info(&r.link, r.kind), collection_id: r.link.collection_id, collection_name: r.collection_name, kind: wire_kind(r.kind), owner_id: r.owner_id, owner_name: r.owner_name, owner_active: r.owner_active, } } /// GET {PIM_SHARES} pub async fn own_shares( State(state): State>, auth: SessionUser, ) -> Result, ApiError> { let links = state.db.pim_links_with_owner(Some(auth.user.id)).await?; let lends = state.db.pim_lends(auth.user.id).await?; Ok(Json(PimOwnShares { links: links.into_iter().map(feed_entry).collect(), lends: lends .into_iter() .map( |(collection_id, collection_name, kind, user_id, user_name, mode)| PimLend { collection_id, collection_name, kind: wire_kind(kind), share: PimShareInfo { user_id, user_name, mode, }, }, ) .collect(), })) } /// GET {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX} pub async fn links( State(state): State>, auth: SessionUser, AxumPath(id): AxumPath, ) -> Result>, ApiError> { let id = own(&state, &auth, id).await?; let (_, kind, _) = state .db .pim_collection_by_id(id) .await? .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?; let links = state.db.pim_links(id).await?; Ok(Json(links.iter().map(|l| link_info(l, kind)).collect())) } /// POST {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX} pub async fn create_link( State(state): State>, auth: SessionUser, AxumPath(id): AxumPath, Json(body): Json, ) -> Result, ApiError> { let id = own(&state, &auth, id).await?; let (_, kind, _) = state .db .pim_collection_by_id(id) .await? .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?; if body.busy_only && kind != PimKind::Calendar { return Err(ApiError::new( StatusCode::BAD_REQUEST, "busy_only needs a calendar", )); } // As for shares: an unparseable expiry would never expire. if let Some(e) = &body.expires_at && chrono::DateTime::parse_from_rfc3339(e).is_err() { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "expires_at must be an RFC 3339 timestamp", "err_bad_expires_at", )); } let password_hash = match body.password.as_deref().map(str::trim) { Some(pw) if !pw.is_empty() => { validate_password(pw)?; Some(hash_password(pw).await?) } _ => None, }; let link = state .db .pim_create_link( id, &auth::short_token(), body.busy_only, body.expires_at.as_deref(), password_hash.as_deref(), ) .await?; Ok(Json(link_info(&link, kind))) } /// DELETE {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}/{link_id} pub async fn delete_link( State(state): State>, auth: SessionUser, AxumPath((id, link_id)): AxumPath<(i64, i64)>, ) -> Result, ApiError> { let id = own(&state, &auth, id).await?; if !state.db.pim_delete_link(id, link_id).await? { return Err(ApiError::new(StatusCode::NOT_FOUND, "link not found")); } Ok(Json(OkResp {})) } /// GET {FEED}/{token} pub async fn feed( State(state): State>, AxumPath(file): AxumPath, headers: HeaderMap, ) -> Result { let token = file .strip_suffix(".ics") .or_else(|| file.strip_suffix(".vcf")) .unwrap_or(&file); let Some(link) = state.db.pim_link_by_token(token).await? else { return Ok(StatusCode::NOT_FOUND.into_response()); }; if link.is_expired() { return Ok(StatusCode::GONE.into_response()); } // Basic with the user name ignored, like a protected share mount. if let Some(hash) = link.password_hash.clone() { let Some((_, password)) = auth::basic_credentials(&headers) else { return Ok(challenge()); }; // The hash is of the trimmed password, as for file shares. let password = password.trim(); let (pw, id, tok) = (password.to_string(), link.id, link.token.clone()); // A negative realm: share ids are positive, and one share's password // must never open a feed with the same id. let ok = auth::verify_cached(-link.id, "", password, move || async move { auth::throttle(&tok).await; let ok = auth::verify_password_async(&pw, &hash).await; auth::record_login(&tok, ok); ok.then_some(id) }) .await; if ok.is_none() { return Ok(challenge()); } } let Some((owner, kind, col)) = state.db.pim_collection_by_id(link.collection_id).await? else { return Ok(StatusCode::NOT_FOUND.into_response()); }; let etag = format!( "\"feed-{}-{}{}\"", col.id, col.seq, if link.busy_only { "-busy" } else { "" } ); let unchanged = headers .get(IF_NONE_MATCH) .and_then(|v| v.to_str().ok()) .is_some_and(|v| { v.split(',') .map(|t| t.trim().trim_start_matches("W/")) .any(|t| t == etag || t == "*") }); if unchanged { return Ok((StatusCode::NOT_MODIFIED, [(ETAG, etag)]).into_response()); } let detail = match link.busy_only { true => Detail::Busy, false => Detail::Public, }; let body = render(&state, owner, kind, &col, detail).await?; Ok(( [ (CONTENT_TYPE, mime(kind).to_string()), (ETAG, etag), (CACHE_CONTROL, "no-cache".to_string()), ], body, ) .into_response()) } fn mime(kind: PimKind) -> &'static str { match kind { PimKind::Calendar => "text/calendar; charset=utf-8", PimKind::AddressBook => "text/vcard; charset=utf-8", } } async fn render( state: &AppState, owner: i64, kind: PimKind, col: &PimCollection, detail: Detail, ) -> Result { let objects = members_of(state, owner, col.id).await?; let name = name_of(col); blocking(move || -> Result { let texts: Vec = objects .into_iter() .map(|(_, d)| String::from_utf8_lossy(&d).into_owned()) .collect(); let texts: Vec<&str> = texts.iter().map(String::as_str).collect(); Ok(match kind { PimKind::Calendar => bundle::calendar(&texts, Some(&name), detail), PimKind::AddressBook => bundle::cards(&texts), }) }) .await } /// GET {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX} pub async fn export( State(state): State>, auth: SessionUser, AxumPath(id): AxumPath, ) -> Result { let (owner, kind, col, _) = reachable(&state, &auth, id).await?; let body = render(&state, owner, kind, &col, Detail::All).await?; Ok(download(kind, &name_of(&col), body)) } /// GET {PIM_SYSTEM_EXPORT} pub async fn export_system( State(state): State>, _auth: SessionUser, ) -> Result { let (col, body) = system_cards(&state).await?; Ok(download(PimKind::AddressBook, &name_of(&col), body)) } async fn system_cards(state: &AppState) -> Result<(PimCollection, String), ApiError> { let col = crate::api::pim::directory_collection(state).await?; let members = crate::api::pim::directory(state).await?; let texts: Vec = members .into_iter() .map(|(_, d)| String::from_utf8_lossy(&d).into_owned()) .collect(); let texts: Vec<&str> = texts.iter().map(String::as_str).collect(); Ok((col, bundle::cards(&texts))) } fn download(kind: PimKind, name: &str, body: String) -> Response { let file = format!("{}.{}", name.replace(['/', '\\'], "_"), extension(kind)); ( [ (CONTENT_TYPE, mime(kind).to_string()), (CONTENT_DISPOSITION, disposition("attachment", &file)), ], body, ) .into_response() } /// POST {PIM_COLLECTIONS}/{id}{IMPORT_SUFFIX} /// /// Each object goes through the checks of a PUT and is skipped where a PUT /// would fail. An object whose UID the collection already has replaces it. /// Scheduling runs as for a PUT. pub async fn import( State(state): State>, auth: SessionUser, AxumPath(id): AxumPath, body: Body, ) -> Result, ApiError> { let (_, kind, col, writable) = reachable(&state, &auth, id).await?; if !writable { return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only collection")); } let text = read_import(body).await?; let parts = split_import(kind, &text)?; Ok(Json(import_parts(&state, &auth, kind, &col, parts).await?)) } #[derive(serde::Deserialize)] pub struct ImportNewQuery { kind: PimCollectionKind, name: Option, file: Option, color: Option, } /// POST {PIM_IMPORT_NEW}: a file as a new collection. Its name comes from the /// request, else from the file's own name for itself, else from the file /// name. When nothing can be imported, the collection is removed again. pub async fn import_new( State(state): State>, auth: SessionUser, Query(q): Query, body: Body, ) -> Result, ApiError> { let kind = db_kind(q.kind); let text = read_import(body).await?; let parts = split_import(kind, &text)?; let (own_name, own_color) = match kind { PimKind::Calendar => bundle::calendar_meta(&text), PimKind::AddressBook => (None, None), }; let nonempty = |s: Option| s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); // A name from the file that cannot be stored falls back to the next one. let usable = |s: Option| nonempty(s).filter(|s| valid_text(s, MAX_DISPLAYNAME, false)); let stem = q .file .map(|f| f.rsplit_once('.').map_or(f.clone(), |(s, _)| s.to_string())); let name = nonempty(q.name) .or(usable(own_name)) .or(usable(stem)) .ok_or_else(|| bad_request("a name is required"))?; // COLOR may be a CSS color name, which the web UI cannot show. let color = own_color .filter(|c| valid_color(c)) .or(q.color.filter(|c| valid_color(c))); let pid = state.db.principal_of(auth.user.id).await?; state.db.pim_ensure_defaults(pid).await?; let info = create_collection(&state, &auth.user, kind, &name, color, None, &[]).await?; let (_, _, col) = state .db .pim_collection_by_id(info.id) .await? .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?; let result = import_parts(&state, &auth, kind, &col, parts).await; let keep = matches!(&result, Ok(r) if r.created + r.updated > 0); if !keep { // Empty and never lent or synced: nothing to cancel, nobody to tell. if delete_own(&state, pid, kind, &col).await?.is_err() { return Err(ApiError::new( StatusCode::CONFLICT, "the empty collection could not be removed", )); } } Ok(Json(PimImportNew { collection: keep.then_some(info), result: result?, })) } async fn read_import(body: Body) -> Result { let data = axum::body::to_bytes(body, MAX_IMPORT) .await .map_err(|_| ApiError::new(StatusCode::PAYLOAD_TOO_LARGE, "file too large"))? .to_vec(); // Old phone exports are often Latin-1. Ok(String::from_utf8(data) .unwrap_or_else(|e| e.into_bytes().iter().map(|&b| b as char).collect())) } /// One text per resource of an import file. fn split_import(kind: PimKind, text: &str) -> Result, ApiError> { // From the content, so importing the same file twice updates. let mut new_uid = |text: &str| crate::hex(&Sha256::digest(text))[..32].to_string(); let parts = match kind { PimKind::Calendar => { bundle::split_calendar(text, &mut new_uid, MAX_SPLIT).ok_or_else(|| { ApiError::new( StatusCode::PAYLOAD_TOO_LARGE, "the file splits into too much data", ) })? } PimKind::AddressBook => bundle::split_cards(text, &mut new_uid), }; if parts.is_empty() { return Err(ApiError::new( StatusCode::BAD_REQUEST, "the file holds no calendar or address objects", )); } Ok(parts) } /// Each part is stored as a PUT would store it, scheduling included. A part /// a PUT would refuse is skipped. async fn import_parts( state: &AppState, auth: &SessionUser, kind: PimKind, col: &PimCollection, parts: Vec, ) -> Result { let supported: Vec = col.components.split(',').map(str::to_string).collect(); let checked = blocking(move || -> Result<_, ApiError> { let supported: Vec<&str> = supported.iter().map(String::as_str).collect(); let now = chrono::Utc::now(); Ok(parts .into_iter() .map(|part| check_part(kind, &supported, now, part)) .collect::>()) }) .await?; let _lock = pim_schedule::LOCK.lock().await; // The collection or the share may have gone while the file was checked. let (owner, _, _, writable) = reachable(state, auth, col.id).await?; if !writable { return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only collection")); } let may_schedule = pim_views::may_answer(state, auth, owner, col.id).await?; let me = state.db.principal_of(auth.user.id).await?; let dir = Directory::load(state).await?; let owner = dir .get(owner) .cloned() .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?; let w = pim_schedule::Writer { owner: &owner, may_schedule, sent_by: (owner.id != me) .then(|| format!("mailto:{}", mailto(&auth.user.name, UserType::Individual))), }; let mut result = PimImportResult { created: 0, updated: 0, skipped_total: 0, skipped: Vec::new(), }; let mut skip = |uid: Option, reason: &str| { result.skipped_total += 1; if result.skipped.len() < MAX_SKIPPED { result.skipped.push(PimSkipped { uid, reason: reason.to_string(), }); } }; // Names given in this import, so a UID seen twice updates its first copy. let mut names: HashMap = HashMap::new(); let mut ops = Vec::new(); let (mut created, mut updated) = (0, 0); for part in checked { let (uid, component, data) = match part { Ok(v) => v, Err((uid, reason)) => { skip(uid, &reason); continue; } }; let existing = match names.get(&uid) { Some(name) => { // Scheduling reads the stored copy. state.db.pim_apply(&std::mem::take(&mut ops)).await?; Some(name.clone()) } None => state.db.pim_uid_holder(col.id, &uid, "").await?, }; let name = existing.clone().unwrap_or_else(|| object_name(&uid, kind)); let stored = match kind { PimKind::Calendar => { let old = match &existing { Some(n) => state.db.pim_object(col.id, n).await?.map(|(_, d)| d), None => None, }; let at = (col.id, name.as_str()); match pim_schedule::put(state, &dir, &w, at, old.as_deref(), &data).await? { Ok(s) => s, Err(condition) => { skip(Some(uid), &condition.name); continue; } } } PimKind::AddressBook => pim_schedule::Stored { data, changed: false, schedule_tag: None, ops: Vec::new(), }, }; match existing { Some(_) => updated += 1, None => created += 1, } names.insert(uid.clone(), name.clone()); ops.push(PimOp::Put { collection_id: col.id, obj: PimObject { name, uid, component, etag: etag_of(&stored.data), schedule_tag: stored.schedule_tag, ..Default::default() }, data: stored.data, }); // Later parts see the copies and room bookings this one wrote. if !stored.ops.is_empty() { ops.extend(stored.ops); state.db.pim_apply(&std::mem::take(&mut ops)).await?; } } state.db.pim_apply(&ops).await?; result.created = created; result.updated = updated; Ok(result) } /// A skipped import part: its UID if readable, and the reason. type Skip = (Option, String); /// One import part as `(uid, component, data)`, or why it is skipped. fn check_part( kind: PimKind, supported: &[&str], now: chrono::DateTime, part: String, ) -> Result<(String, String, Vec), Skip> { // Read from the raw text when the object does not parse as a whole. let raw_uid = |part: &str| { part.lines() .find_map(|l| l.strip_prefix("UID:")) .map(|u| u.trim().to_string()) }; if part.len() > MAX_RESOURCE_SIZE { return Err((raw_uid(&part), "max-resource-size".into())); } let checked = match kind { PimKind::Calendar => { object::calendar(part.as_bytes(), supported).map(|o| (o.uid, o.component.to_string())) } PimKind::AddressBook => { object::vcard(part.as_bytes()).map(|u| (u.unwrap_or_default(), "VCARD".into())) } }; let (uid, component) = checked.map_err(|invalid| (raw_uid(&part), invalid.condition().name))?; let data = match kind { PimKind::Calendar => { object::with_dtstamp(part.as_bytes(), now).unwrap_or_else(|| part.into_bytes()) } PimKind::AddressBook => part.into_bytes(), }; Ok((uid, component, data)) }