//! What the web UI shows of calendars and address books (session-authenticated): //! - `GET {PIM_INSTANCES}` — occurrences in a range //! - `GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}` — one event or contact //! - `GET {PIM_CONTACTS}` — contacts, searched //! - `GET {PIM_INVITATIONS}`, `POST` a reply — unanswered invitations //! //! The UI never parses iCalendar or vCard: these endpoints do. use std::collections::{HashMap, HashSet}; use std::sync::Arc; use api_types::{ OBJECTS_SUFFIX, OkResp, PHOTO_SUFFIX, PIM_COLLECTIONS, PimAttendee, PimContact, PimContactDetail, PimEventDetail, PimInstance, PimInstances, PimInvitation, PimLabeled, PimObjectDetail, PimPerson, PimReply, PimShareMode, }; use axum::Json; use axum::extract::{Path as AxumPath, Query, State}; use axum::http::StatusCode; use chrono::{DateTime, SecondsFormat, TimeDelta, Utc}; use pimdav::calcard::icalendar::{ ICalendar, ICalendarComponentType, ICalendarParticipationStatus, ICalendarProperty, }; use pimdav::expand::expand; use pimdav::itip::{self, Role}; use pimdav::principal::UserType; use pimdav::render; use pimdav::view::{self, Card, EventInfo, Person}; use pimdav::zone::{self, Zone}; use serde::Deserialize; use crate::api::common::SessionUser; use crate::api::common::blocking; use crate::api::pim::{BIRTHDAYS, DIRECTORY, INBOX, etag_of, generated, mailto, members_of, seg}; use crate::api::pim_api::reachable; use crate::api::pim_schedule::{self, Directory, Writer}; use crate::db::{PimKind, PimObject, PimOp}; use crate::error::{ApiError, AppState}; /// The widest range `GET {PIM_INSTANCES}` expands. const MAX_RANGE_DAYS: i64 = 400; /// The most instances one answer holds. const MAX_INSTANCES: usize = 5000; /// How far ahead an invitation's next instance is looked for. const INVITATION_HORIZON_DAYS: i64 = 3653; fn rfc3339(t: DateTime) -> String { t.to_rfc3339_opts(SecondsFormat::Secs, true) } fn parse_time(s: &str) -> Result, ApiError> { DateTime::parse_from_rfc3339(s) .map(|t| t.with_timezone(&Utc)) .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "times must be RFC 3339")) } /// The zone all-day and floating times are read in: the viewer's. fn floating(tz: Option<&str>) -> Zone { tz.and_then(zone::by_name).unwrap_or(Zone::Utc) } fn wanted(ids: Option<&str>) -> Option> { ids.map(|s| s.split(',').filter_map(|i| i.trim().parse().ok()).collect()) } /// `(collection id, owner principal)` of the calendars or address books the /// signed-in user reads: own ones, the generated one and lent ones. Not the /// scheduling inbox. async fn readable( state: &AppState, auth: &SessionUser, kind: PimKind, ) -> Result, ApiError> { let db = &state.db; let pid = db.principal_of(auth.user.id).await?; db.pim_ensure_defaults(pid).await?; let mut out: Vec<(i64, i64)> = db .pim_collections(pid, kind) .await? .into_iter() .filter(|c| c.slug != INBOX) .map(|c| (c.id, pid)) .collect(); out.push(match kind { PimKind::Calendar => (BIRTHDAYS, pid), PimKind::AddressBook => (DIRECTORY, pid), }); for (col, _, _) in db.pim_shared_collections(auth.user.id, kind).await? { if let Some((owner, _, _)) = db.pim_collection_by_id(col.id).await? { out.push((col.id, owner)); } } Ok(out) } fn parse(data: &[u8]) -> Option { render::parse(&String::from_utf8_lossy(data)) } fn display(p: &Person) -> String { p.name.clone().unwrap_or_else(|| { p.address .strip_prefix("mailto:") .unwrap_or(&p.address) .to_string() }) } fn wire_person(p: &Person) -> PimPerson { PimPerson { name: p.name.clone(), address: p.address.clone(), } } #[derive(Deserialize)] pub struct InstancesQuery { from: String, to: String, tz: Option, collections: Option, } /// GET {PIM_INSTANCES} // ponytail: parses and expands every object of every calendar on each call. // Index each object's first and last instance if large calendars get slow. pub async fn instances( State(state): State>, auth: SessionUser, Query(q): Query, ) -> Result, ApiError> { let (from, to) = (parse_time(&q.from)?, parse_time(&q.to)?); if to <= from || to - from > TimeDelta::days(MAX_RANGE_DAYS) { return Err(ApiError::new( StatusCode::BAD_REQUEST, "the range must be positive and at most 400 days", )); } let zone = floating(q.tz.as_deref()); let wanted = wanted(q.collections.as_deref()); let dir = Directory::load(&state).await?; let mut sources = Vec::new(); for (id, owner) in readable(&state, &auth, PimKind::Calendar).await? { if wanted.as_ref().is_some_and(|w| !w.contains(&id)) { continue; } sources.push((id, owner, members_of(&state, owner, id).await?)); } let (mut out, truncated) = blocking(move || -> Result<_, ApiError> { let mut out = Vec::new(); let mut truncated = false; 'all: for (id, owner, members) in sources { let owns = dir.is(owner); for (obj, data) in members { let Some(cal) = parse(&data) else { continue; }; let exp = expand(&cal, from..to, zone.clone()); truncated |= exp.truncated; let mut infos: HashMap = HashMap::new(); for i in exp.instances { if cal.components[i.component].component_type != ICalendarComponentType::VEvent { continue; } if out.len() == MAX_INSTANCES { truncated = true; break 'all; } let info = infos .entry(i.component) .or_insert_with(|| view::event_info(&cal, i.component, &owns)); out.push(PimInstance { collection_id: id, name: obj.name.clone(), uid: obj.uid.clone(), recurrence_id: i.recurrence_id.map(rfc3339), start: rfc3339(i.start), end: rfc3339(i.end), all_day: info.all_day, component: info.component.clone(), summary: info.summary.clone(), location: info.location.clone(), status: info.status.clone(), transparent: info.transparent, has_attendees: !info.attendees.is_empty(), partstat: info.partstat().map(str::to_string), organizer: info.organizer.as_ref().map(display), }); } } } Ok((out, truncated)) }) .await?; out.sort_by(|a, b| (&a.start, &a.end).cmp(&(&b.start, &b.end))); Ok(Json(PimInstances { instances: out, truncated, })) } #[derive(Deserialize)] pub struct DetailQuery { recurrence_id: Option, tz: Option, } /// GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name} pub async fn object( State(state): State>, auth: SessionUser, AxumPath((id, name)): AxumPath<(i64, String)>, Query(q): Query, ) -> Result, ApiError> { let not_found = || ApiError::new(StatusCode::NOT_FOUND, "object not found"); let (owner, kind, col, writable) = reachable(&state, &auth, id).await?; let found = match generated(col.id) { true => members_of(&state, owner, col.id) .await? .into_iter() .find(|(o, _)| o.name == name), false => state.db.pim_object(col.id, &name).await?, }; let (obj, data) = &found.ok_or_else(not_found)?; match kind { PimKind::Calendar => { let cal = parse(data).ok_or_else(not_found)?; let zone = floating(q.tz.as_deref()); let rid = q.recurrence_id.as_deref().map(parse_time).transpose()?; let index = view::component_for(&cal, rid, &zone).ok_or_else(not_found)?; let dir = Directory::load(&state).await?; let owns = dir.is(owner); let instance = view::instance_for(&cal, index, rid, &zone); // An instance a THISANDFUTURE override moved takes its text too. let info = view::event_info( &cal, instance.as_ref().map_or(index, |i| i.component), &owns, ); let answers = may_answer(&state, &auth, owner, col.id).await?; let attendee = matches!(itip::role(&cal, &owns), Ok(Role::Attendee)); Ok(Json(PimObjectDetail::Event(PimEventDetail { collection_id: id, name: obj.name.clone(), uid: obj.uid.clone(), component: info.component, summary: info.summary, description: info.description, location: info.location, url: info.url, status: info.status, transparent: info.transparent, all_day: info.all_day, start: instance.as_ref().map(|i| rfc3339(i.start)), end: instance.as_ref().map(|i| rfc3339(i.end)), categories: info.categories, rrule: info.rrule, organizer: info.organizer.as_ref().map(wire_person), attendees: info .attendees .iter() .map(|a| PimAttendee { person: wire_person(&a.person), partstat: a.partstat.clone(), role: a.role.clone(), is_owner: a.is_owner, }) .collect(), is_override: cal.components[index].has_property(&ICalendarProperty::RecurrenceId), series_partstat: view::component_for(&cal, None, &zone) .filter(|&m| !cal.components[m].has_property(&ICalendarProperty::RecurrenceId)) .and_then(|m| { view::event_info(&cal, m, &owns) .partstat() .map(str::to_string) }), can_edit: writable, can_reply: attendee && answers, }))) } PimKind::AddressBook => { let card = view::card(&String::from_utf8_lossy(data)); let members = match card.is_group { true => { let by_uid: HashMap = members_of(&state, owner, col.id) .await? .iter() .map(|(_, d)| view::card(&String::from_utf8_lossy(d))) .filter_map(|c| Some((c.uid?, c.full_name))) .collect(); card.members .iter() .map(|m| by_uid.get(m).cloned().unwrap_or_else(|| m.clone())) .collect() } false => Vec::new(), }; // photo() reads stored objects only. let photo_url = (card.has_photo && !generated(col.id)).then(|| { format!( "{PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{}{PHOTO_SUFFIX}", seg(&obj.name) ) }); Ok(Json(PimObjectDetail::Contact(contact_detail( id, obj, card, members, photo_url, writable, )))) } } } fn labeled(v: Vec) -> Vec { v.into_iter() .map(|l| PimLabeled { label: l.label, value: l.value, }) .collect() } fn contact_detail( id: i64, obj: &PimObject, card: Card, members: Vec, photo_url: Option, can_edit: bool, ) -> PimContactDetail { PimContactDetail { collection_id: id, name: obj.name.clone(), uid: card.uid, full_name: card.full_name, org: card.org, title: card.title, emails: labeled(card.emails), phones: labeled(card.phones), addresses: labeled(card.addresses), urls: labeled(card.urls), birthday: card.birthday, anniversary: card.anniversary, note: card.note, is_group: card.is_group, members, photo_url, can_edit, } } /// Whether the signed-in user may answer invitations in a calendar of /// `owner`: their own, or one lent with `rw+schedule`. pub(super) async fn may_answer( state: &AppState, auth: &SessionUser, owner: i64, collection_id: i64, ) -> Result { if collection_id <= DIRECTORY { return Ok(false); } if owner == state.db.principal_of(auth.user.id).await? { return Ok(true); } Ok(state .db .pim_shared_collection(auth.user.id, PimKind::Calendar, collection_id) .await? .is_some_and(|(_, _, mode)| mode == PimShareMode::RwSchedule)) } #[derive(Deserialize)] pub struct ContactsQuery { q: Option, collections: Option, } /// GET {PIM_CONTACTS} pub async fn contacts( State(state): State>, auth: SessionUser, Query(q): Query, ) -> Result>, ApiError> { let needle = q.q.as_deref().map(str::trim).unwrap_or("").to_lowercase(); let wanted = wanted(q.collections.as_deref()); let mut sources = Vec::new(); for (id, owner) in readable(&state, &auth, PimKind::AddressBook).await? { if wanted.as_ref().is_some_and(|w| !w.contains(&id)) { continue; } sources.push((id, members_of(&state, owner, id).await?)); } let mut out = blocking(move || -> Result<_, ApiError> { let mut out = Vec::new(); for (id, members) in sources { for (obj, data) in members { let c = view::card(&String::from_utf8_lossy(&data)); let hit = needle.is_empty() || [Some(&c.full_name), c.org.as_ref()] .into_iter() .flatten() .chain(c.emails.iter().map(|e| &e.value)) .chain(c.phones.iter().map(|p| &p.value)) .any(|v| v.to_lowercase().contains(&needle)); if !hit { continue; } out.push(PimContact { collection_id: id, name: obj.name, full_name: c.full_name, org: c.org, email: c.emails.into_iter().next().map(|e| e.value), phone: c.phones.into_iter().next().map(|p| p.value), has_photo: c.has_photo && !generated(id), is_group: c.is_group, }); } } Ok(out) }) .await?; out.sort_by_cached_key(|c| (c.full_name.to_lowercase(), c.collection_id)); Ok(Json(out)) } #[derive(Deserialize)] pub struct TzQuery { tz: Option, } /// GET {PIM_INVITATIONS}: in the signed-in user's own calendars, the series /// and instances they are invited to and have not answered, and whose next /// instance is still ahead. pub async fn invitations( State(state): State>, auth: SessionUser, Query(q): Query, ) -> Result>, ApiError> { let db = &state.db; let pid = db.principal_of(auth.user.id).await?; let dir = Directory::load(&state).await?; let owns = dir.is(pid); let zone = floating(q.tz.as_deref()); let now = Utc::now(); let window = now..now + TimeDelta::days(INVITATION_HORIZON_DAYS); let mut out = Vec::new(); for col in db.pim_collections(pid, PimKind::Calendar).await? { if col.slug == INBOX { continue; } for (obj, data) in db.pim_objects_with_data(col.id).await? { // Most objects invite no one: skip their parse. if !data.windows(8).any(|w| w.eq_ignore_ascii_case(b"ATTENDEE")) { continue; } let Some(cal) = parse(&data) else { continue; }; if !matches!(itip::role(&cal, &owns), Ok(Role::Attendee)) { continue; } let instances = expand(&cal, window.clone(), zone.clone()).instances; for (index, c) in cal.components.iter().enumerate() { if c.component_type != ICalendarComponentType::VEvent { continue; } let info = view::event_info(&cal, index, &owns); if info.partstat() != Some("NEEDS-ACTION") || info.status.as_deref() == Some("CANCELLED") { continue; } let Some(next) = instances.iter().find(|i| i.component == index) else { continue; }; let is_override = c.has_property(&ICalendarProperty::RecurrenceId); out.push(PimInvitation { collection_id: col.id, name: obj.name.clone(), uid: obj.uid.clone(), recurrence_id: is_override .then_some(next.recurrence_id) .flatten() .map(rfc3339), summary: info.summary.clone(), location: info.location.clone(), organizer: info.organizer.as_ref().map(wire_person), start: rfc3339(next.start), end: rfc3339(next.end), all_day: info.all_day, recurring: info.rrule.is_some() && !is_override, rrule: info.rrule.clone().filter(|_| !is_override), }); } } } out.sort_by(|a, b| a.start.cmp(&b.start)); Ok(Json(out)) } /// POST {PIM_INVITATIONS}: writes the answer into the calendar owner's copy /// through the same path as a client's PUT, so the organizer gets the REPLY. pub async fn reply( State(state): State>, auth: SessionUser, Json(body): Json, ) -> Result, ApiError> { let answer = match body.partstat.to_ascii_uppercase().as_str() { "ACCEPTED" => ICalendarParticipationStatus::Accepted, "TENTATIVE" => ICalendarParticipationStatus::Tentative, "DECLINED" => ICalendarParticipationStatus::Declined, _ => { return Err(ApiError::new( StatusCode::BAD_REQUEST, "partstat must be ACCEPTED, TENTATIVE or DECLINED", )); } }; let rid = body.recurrence_id.as_deref().map(parse_time).transpose()?; let _lock = pim_schedule::LOCK.lock().await; let (owner, kind, col, _) = reachable(&state, &auth, body.collection_id).await?; if kind != PimKind::Calendar || !may_answer(&state, &auth, owner, col.id).await? { return Err(ApiError::new(StatusCode::FORBIDDEN, "cannot answer here")); } let not_found = || ApiError::new(StatusCode::NOT_FOUND, "object not found"); let (obj, old) = state .db .pim_object(col.id, &body.name) .await? .ok_or_else(not_found)?; let cal = parse(&old).ok_or_else(not_found)?; let dir = Directory::load(&state).await?; let principal = dir.get(owner).cloned().ok_or_else(not_found)?; let owns = dir.is(owner); if !matches!(itip::role(&cal, &owns), Ok(Role::Attendee)) { return Err(ApiError::new( StatusCode::BAD_REQUEST, "the calendar owner is not an attendee", )); } let zone = floating(body.tz.as_deref()); let new = itip::respond(&cal, &owns, answer, rid, &zone) .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "instance not found"))?; let new = render::write(&new); let me = state.db.principal_of(auth.user.id).await?; let w = Writer { owner: &principal, may_schedule: true, sent_by: (me != owner) .then(|| format!("mailto:{}", mailto(&auth.user.name, UserType::Individual))), quiet: false, }; let stored = match pim_schedule::put( &state, &dir, &w, (col.id, &obj.name), Some(&old), new.as_bytes(), ) .await? { Ok(s) => s, Err(condition) => return Err(ApiError::new(StatusCode::FORBIDDEN, &condition.name)), }; let mut ops = vec![PimOp::Put { collection_id: col.id, obj: PimObject { etag: etag_of(&stored.data), schedule_tag: stored.schedule_tag.clone(), ..obj }, data: stored.data, }]; ops.extend(stored.ops); state.db.pim_apply(&ops).await?; Ok(Json(OkResp {})) }