//! JSON management of calendars and address books (session-authenticated): //! - `GET {PIM_COLLECTIONS}` — own and lent collections //! - `GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}` — loans of an own collection //! - `POST {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}` — lend it, or change a loan //! - `DELETE {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}/{user_id}` — end a loan //! - `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 //! - `GET`, `POST {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}` — download, or save into a root //! //! Public: `GET {FEED}/{token}` — a collection as one file. use std::collections::HashMap; use std::sync::Arc; use api_types::{ CreatePimLink, CreatePimShare, FEED, OkResp, PimCollectionInfo, PimCollectionKind, PimImportResult, PimLinkInfo, PimRootFile, PimShareInfo, PimShareMode, PimSkipped, }; use axum::Json; use axum::body::Body; use axum::extract::{Path as AxumPath, 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::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, find_root, require_rw_root}; use crate::api::pim::{INBOX, collection_href, etag_of}; use crate::api::pim_schedule::{self, Directory, object_name}; use crate::auth; use crate::db::{PimCollection, PimKind, PimLink, PimObject, PimOp}; use crate::error::{ApiError, AppState}; use crate::fs; /// The largest file an import reads. const MAX_IMPORT: usize = 20 * 1024 * 1024; /// How many skipped objects an import names. const MAX_SKIPPED: usize = 100; 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()) } /// 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 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; } out.push(PimCollectionInfo { id: c.id, kind: wire_kind(kind), name: name_of(&c), url: collection_href(&me.name, kind, &c.slug, None), owner: me.name.clone(), mode: None, }); } for (c, owner, mode) in state.db.pim_shared_collections(me.id, kind).await? { out.push(PimCollectionInfo { id: c.id, kind: wire_kind(kind), name: name_of(&c), url: collection_href(&me.name, kind, &c.slug, Some(c.id)), owner, mode: Some(mode), }); } } Ok(Json(out)) } /// 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)) } /// POST {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX} pub async fn share( State(state): State>, auth: SessionUser, AxumPath(id): AxumPath, Json(body): Json, ) -> Result, ApiError> { let id = own(&state, &auth, id).await?; let user = state .db .pim_principal(body.user.trim()) .await? .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "user not found"))?; let Some(user_id) = user.user_id 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: 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?; 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. 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?; 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()), } } fn extension(kind: PimKind) -> &'static str { match kind { PimKind::Calendar => "ics", PimKind::AddressBook => "vcf", } } 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(), } } /// 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()); }; let (pw, id, tok) = (password.clone(), 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((_, 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, 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, kind: PimKind, col: &PimCollection, detail: Detail, ) -> Result { let objects = state.db.pim_objects_with_data(col.id).await?; 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_of(col)), detail), PimKind::AddressBook => bundle::cards(&texts), }) } /// GET {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX} pub async fn export( State(state): State>, auth: SessionUser, AxumPath(id): AxumPath, ) -> Result { let (_, kind, col, _) = reachable(&state, &auth, id).await?; let body = render(&state, kind, &col, Detail::All).await?; let file = format!( "{}.{}", name_of(&col).replace(['/', '\\'], "_"), extension(kind) ); Ok(( [ (CONTENT_TYPE, mime(kind).to_string()), (CONTENT_DISPOSITION, disposition("attachment", &file)), ], body, ) .into_response()) } /// POST {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}: a new file in a writable root. pub async fn export_to_root( State(state): State>, auth: SessionUser, AxumPath(id): AxumPath, Json(target): Json, ) -> Result, ApiError> { let (_, kind, col, _) = reachable(&state, &auth, id).await?; let root = require_rw_root(&auth.roots, target.root_id)?; let body = render(&state, kind, &col, Detail::All).await?; let (server_root, root_path) = (state.root.clone(), root.path.clone()); blocking(move || { fs::create_file(&server_root, &root_path, &target.path)?; fs::save_file( &server_root, &root_path, &target.path, body.as_bytes(), None, ) }) .await?; Ok(Json(OkResp {})) } /// 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. /// Nothing is sent to attendees or organizers. pub async fn import( State(state): State>, auth: SessionUser, AxumPath(id): AxumPath, headers: HeaderMap, body: Body, ) -> Result, ApiError> { let (owner, kind, col, writable) = reachable(&state, &auth, id).await?; if !writable { return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only collection")); } let too_large = || ApiError::new(StatusCode::PAYLOAD_TOO_LARGE, "file too large"); let json = headers .get(CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .is_some_and(|t| t.starts_with("application/json")); let data = if json { let raw = axum::body::to_bytes(body, 64 * 1024) .await .map_err(|_| too_large())?; let file: PimRootFile = serde_json::from_slice(&raw) .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid JSON body"))?; read_root_file(&state, &auth, file).await? } else { axum::body::to_bytes(body, MAX_IMPORT) .await .map_err(|_| too_large())? .to_vec() }; // Old phone exports are often Latin-1. let text = String::from_utf8(data) .unwrap_or_else(|e| e.into_bytes().iter().map(|&b| b as char).collect()); // 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), 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", )); } let _lock = pim_schedule::LOCK.lock().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 supported: Vec<&str> = col.components.split(',').collect(); let now = chrono::Utc::now(); 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 parts { 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) = match checked { Ok(v) => v, Err(invalid) => { skip(None, &invalid.condition().name); continue; } }; let data = match kind { PimKind::Calendar => { object::with_dtstamp(part.as_bytes(), now).unwrap_or_else(|| part.into_bytes()) } PimKind::AddressBook => part.into_bytes(), }; let existing = match names.get(&uid) { Some(name) => 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 schedule_tag = match kind { PimKind::Calendar => { match pim_schedule::import_tag(&state, &dir, &owner, (col.id, &name), &data).await? { Ok(tag) => tag, Err(condition) => { skip(Some(uid), &condition.name); continue; } } } PimKind::AddressBook => None, }; 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(&data), schedule_tag, ..Default::default() }, data, }); } state.db.pim_apply(&ops).await?; result.created = created; result.updated = updated; Ok(Json(result)) } async fn read_root_file( state: &AppState, auth: &SessionUser, file: PimRootFile, ) -> Result, ApiError> { let root = find_root(&auth.roots, file.root_id)?; let (server_root, root_path) = (state.root.clone(), root.path.clone()); blocking(move || { let full = fs::resolve_path(&server_root, &root_path, &file.path)?; let meta = std::fs::metadata(&full)?; if meta.is_dir() { return Err(ApiError::new(StatusCode::BAD_REQUEST, "not a file")); } if meta.len() > MAX_IMPORT as u64 { return Err(ApiError::new( StatusCode::PAYLOAD_TOO_LARGE, "file too large", )); } Ok(std::fs::read(&full)?) }) .await }