pim_views.rs
⎇
Raw
1//! What the web UI shows of calendars and address books (session-authenticated):
2//! - `GET {PIM_INSTANCES}` — occurrences in a range
3//! - `GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}` — one event or contact
4//! - `GET {PIM_CONTACTS}` — contacts, searched
5//! - `GET {PIM_INVITATIONS}`, `POST` a reply — unanswered invitations
6//! - `GET {PIM_PREVIEW}` — what an `.ics`/`.vcf` file holds
7//!
8//! The UI never parses iCalendar or vCard: these endpoints do.
9
10use std::collections::{HashMap, HashSet};
11use std::sync::Arc;
12
13use api_types::{
14 OBJECTS_SUFFIX, OkResp, PHOTO_SUFFIX, PIM_COLLECTIONS, PimAttendee, PimContact,
15 PimContactDetail, PimEventDetail, PimInstance, PimInstances, PimInvitation, PimLabeled,
16 PimObjectDetail, PimPerson, PimReply, PimShareMode,
17};
18use axum::Json;
19use axum::extract::{Path as AxumPath, Query, State};
20use axum::http::StatusCode;
21use chrono::{DateTime, SecondsFormat, TimeDelta, Utc};
22use pimdav::calcard::icalendar::{ICalendar, ICalendarParticipationStatus, ICalendarProperty};
23use pimdav::expand::expand;
24use pimdav::itip::{self, Role};
25use pimdav::principal::UserType;
26use pimdav::view::{self, Card, EventInfo, Person};
27use pimdav::zone::{self, Zone};
28use serde::Deserialize;
29
30use crate::api::common::SessionUser;
31use crate::api::pim::{BIRTHDAYS, DIRECTORY, INBOX, etag_of, mailto, members_of, seg};
32use crate::api::pim_api::reachable;
33use crate::api::pim_schedule::{self, Directory, Writer};
34use crate::db::{PimKind, PimObject, PimOp};
35use crate::error::{ApiError, AppState};
36
37/// The widest range `GET {PIM_INSTANCES}` expands.
38const MAX_RANGE_DAYS: i64 = 400;
39/// The most instances one answer holds.
40const MAX_INSTANCES: usize = 5000;
41/// How far ahead an invitation's next instance is looked for.
42const INVITATION_HORIZON_DAYS: i64 = 3653;
43
44fn rfc3339(t: DateTime<Utc>) -> String {
45 t.to_rfc3339_opts(SecondsFormat::Secs, true)
46}
47
48fn parse_time(s: &str) -> Result<DateTime<Utc>, ApiError> {
49 DateTime::parse_from_rfc3339(s)
50 .map(|t| t.with_timezone(&Utc))
51 .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "times must be RFC 3339"))
52}
53
54/// The zone all-day and floating times are read in: the viewer's.
55fn floating(tz: Option<&str>) -> Zone {
56 tz.and_then(zone::by_name).unwrap_or(Zone::Utc)
57}
58
59fn wanted(ids: Option<&str>) -> Option<HashSet<i64>> {
60 ids.map(|s| s.split(',').filter_map(|i| i.trim().parse().ok()).collect())
61}
62
63/// `(collection id, owner principal)` of the calendars or address books the
64/// signed-in user reads: own ones, the generated one and lent ones. Not the
65/// scheduling inbox.
66async fn readable(
67 state: &AppState,
68 auth: &SessionUser,
69 kind: PimKind,
70) -> Result<Vec<(i64, i64)>, ApiError> {
71 let db = &state.db;
72 let pid = db.principal_of(auth.user.id).await?;
73 db.pim_ensure_defaults(pid).await?;
74 let mut out: Vec<(i64, i64)> = db
75 .pim_collections(pid, kind)
76 .await?
77 .into_iter()
78 .filter(|c| c.slug != INBOX)
79 .map(|c| (c.id, pid))
80 .collect();
81 out.push(match kind {
82 PimKind::Calendar => (BIRTHDAYS, pid),
83 PimKind::AddressBook => (DIRECTORY, pid),
84 });
85 for (col, _, _) in db.pim_shared_collections(auth.user.id, kind).await? {
86 if let Some((owner, _, _)) = db.pim_collection_by_id(col.id).await? {
87 out.push((col.id, owner));
88 }
89 }
90 Ok(out)
91}
92
93fn parse(data: &[u8]) -> Option<ICalendar> {
94 ICalendar::parse(String::from_utf8_lossy(data).as_ref()).ok()
95}
96
97fn display(p: &Person) -> String {
98 p.name.clone().unwrap_or_else(|| {
99 p.address
100 .strip_prefix("mailto:")
101 .unwrap_or(&p.address)
102 .to_string()
103 })
104}
105
106fn wire_person(p: &Person) -> PimPerson {
107 PimPerson {
108 name: p.name.clone(),
109 address: p.address.clone(),
110 }
111}
112
113#[derive(Deserialize)]
114pub struct InstancesQuery {
115 from: String,
116 to: String,
117 tz: Option<String>,
118 collections: Option<String>,
119}
120
121/// GET {PIM_INSTANCES}
122// ponytail: parses and expands every object of every calendar on each call.
123// Index each object's first and last instance if large calendars get slow.
124pub async fn instances(
125 State(state): State<Arc<AppState>>,
126 auth: SessionUser,
127 Query(q): Query<InstancesQuery>,
128) -> Result<Json<PimInstances>, ApiError> {
129 let (from, to) = (parse_time(&q.from)?, parse_time(&q.to)?);
130 if to <= from || to - from > TimeDelta::days(MAX_RANGE_DAYS) {
131 return Err(ApiError::new(
132 StatusCode::BAD_REQUEST,
133 "the range must be positive and at most 400 days",
134 ));
135 }
136 let zone = floating(q.tz.as_deref());
137 let wanted = wanted(q.collections.as_deref());
138 let dir = Directory::load(&state).await?;
139 let mut out = Vec::new();
140 let mut truncated = false;
141 'all: for (id, owner) in readable(&state, &auth, PimKind::Calendar).await? {
142 if wanted.as_ref().is_some_and(|w| !w.contains(&id)) {
143 continue;
144 }
145 let owns = dir.is(owner);
146 for (obj, data) in members_of(&state, owner, id).await? {
147 let Some(cal) = parse(&data) else {
148 continue;
149 };
150 let exp = expand(&cal, from..to, zone.clone());
151 truncated |= exp.truncated;
152 let mut infos: HashMap<usize, EventInfo> = HashMap::new();
153 for i in exp.instances {
154 if out.len() == MAX_INSTANCES {
155 truncated = true;
156 break 'all;
157 }
158 let info = infos
159 .entry(i.component)
160 .or_insert_with(|| view::event_info(&cal, i.component, &owns));
161 out.push(PimInstance {
162 collection_id: id,
163 name: obj.name.clone(),
164 uid: obj.uid.clone(),
165 recurrence_id: i.recurrence_id.map(rfc3339),
166 start: rfc3339(i.start),
167 end: rfc3339(i.end),
168 all_day: info.all_day,
169 component: info.component.clone(),
170 summary: info.summary.clone(),
171 location: info.location.clone(),
172 status: info.status.clone(),
173 transparent: info.transparent,
174 has_attendees: !info.attendees.is_empty(),
175 partstat: info.partstat().map(str::to_string),
176 organizer: info.organizer.as_ref().map(display),
177 });
178 }
179 }
180 }
181 out.sort_by(|a, b| (&a.start, &a.end).cmp(&(&b.start, &b.end)));
182 Ok(Json(PimInstances {
183 instances: out,
184 truncated,
185 }))
186}
187
188#[derive(Deserialize)]
189pub struct DetailQuery {
190 recurrence_id: Option<String>,
191 tz: Option<String>,
192}
193
194/// GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}
195pub async fn object(
196 State(state): State<Arc<AppState>>,
197 auth: SessionUser,
198 AxumPath((id, name)): AxumPath<(i64, String)>,
199 Query(q): Query<DetailQuery>,
200) -> Result<Json<PimObjectDetail>, ApiError> {
201 let not_found = || ApiError::new(StatusCode::NOT_FOUND, "object not found");
202 let (owner, kind, col, writable) = reachable(&state, &auth, id).await?;
203 let members = members_of(&state, owner, col.id).await?;
204 let (obj, data) = members
205 .iter()
206 .find(|(o, _)| o.name == name)
207 .ok_or_else(not_found)?;
208 match kind {
209 PimKind::Calendar => {
210 let cal = parse(data).ok_or_else(not_found)?;
211 let zone = floating(q.tz.as_deref());
212 let rid = q.recurrence_id.as_deref().map(parse_time).transpose()?;
213 let index = view::component_for(&cal, rid, &zone).ok_or_else(not_found)?;
214 let dir = Directory::load(&state).await?;
215 let owns = dir.is(owner);
216 let info = view::event_info(&cal, index, &owns);
217 let answers = may_answer(&state, &auth, owner, col.id).await?;
218 let attendee = matches!(itip::role(&cal, &owns), Ok(Role::Attendee));
219 Ok(Json(PimObjectDetail::Event(PimEventDetail {
220 collection_id: id,
221 name: obj.name.clone(),
222 uid: obj.uid.clone(),
223 component: info.component,
224 summary: info.summary,
225 description: info.description,
226 location: info.location,
227 url: info.url,
228 status: info.status,
229 transparent: info.transparent,
230 all_day: info.all_day,
231 categories: info.categories,
232 rrule: info.rrule,
233 organizer: info.organizer.as_ref().map(wire_person),
234 attendees: info
235 .attendees
236 .iter()
237 .map(|a| PimAttendee {
238 person: wire_person(&a.person),
239 partstat: a.partstat.clone(),
240 role: a.role.clone(),
241 is_owner: a.is_owner,
242 })
243 .collect(),
244 can_edit: writable,
245 can_reply: attendee && answers,
246 })))
247 }
248 PimKind::AddressBook => {
249 let card = view::card(&String::from_utf8_lossy(data));
250 let members = match card.is_group {
251 true => {
252 let by_uid: HashMap<String, String> = members
253 .iter()
254 .map(|(_, d)| view::card(&String::from_utf8_lossy(d)))
255 .filter_map(|c| Some((c.uid?, c.full_name)))
256 .collect();
257 card.members
258 .iter()
259 .map(|m| by_uid.get(m).cloned().unwrap_or_else(|| m.clone()))
260 .collect()
261 }
262 false => Vec::new(),
263 };
264 let photo_url = card.has_photo.then(|| {
265 format!(
266 "{PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{}{PHOTO_SUFFIX}",
267 seg(&obj.name)
268 )
269 });
270 Ok(Json(PimObjectDetail::Contact(contact_detail(
271 id, obj, card, members, photo_url, writable,
272 ))))
273 }
274 }
275}
276
277fn labeled(v: Vec<view::Labeled>) -> Vec<PimLabeled> {
278 v.into_iter()
279 .map(|l| PimLabeled {
280 label: l.label,
281 value: l.value,
282 })
283 .collect()
284}
285
286fn contact_detail(
287 id: i64,
288 obj: &PimObject,
289 card: Card,
290 members: Vec<String>,
291 photo_url: Option<String>,
292 can_edit: bool,
293) -> PimContactDetail {
294 PimContactDetail {
295 collection_id: id,
296 name: obj.name.clone(),
297 uid: card.uid,
298 full_name: card.full_name,
299 org: card.org,
300 title: card.title,
301 emails: labeled(card.emails),
302 phones: labeled(card.phones),
303 addresses: labeled(card.addresses),
304 urls: labeled(card.urls),
305 birthday: card.birthday,
306 anniversary: card.anniversary,
307 note: card.note,
308 is_group: card.is_group,
309 members,
310 photo_url,
311 can_edit,
312 }
313}
314
315/// Whether the signed-in user may answer invitations in a calendar of
316/// `owner`: their own, or one lent with `rw+schedule`.
317async fn may_answer(
318 state: &AppState,
319 auth: &SessionUser,
320 owner: i64,
321 collection_id: i64,
322) -> Result<bool, ApiError> {
323 if collection_id <= DIRECTORY {
324 return Ok(false);
325 }
326 if owner == state.db.principal_of(auth.user.id).await? {
327 return Ok(true);
328 }
329 Ok(state
330 .db
331 .pim_shared_collection(auth.user.id, PimKind::Calendar, collection_id)
332 .await?
333 .is_some_and(|(_, _, mode)| mode == PimShareMode::RwSchedule))
334}
335
336#[derive(Deserialize)]
337pub struct ContactsQuery {
338 q: Option<String>,
339 collections: Option<String>,
340}
341
342/// GET {PIM_CONTACTS}
343pub async fn contacts(
344 State(state): State<Arc<AppState>>,
345 auth: SessionUser,
346 Query(q): Query<ContactsQuery>,
347) -> Result<Json<Vec<PimContact>>, ApiError> {
348 let needle = q.q.as_deref().map(str::trim).unwrap_or("").to_lowercase();
349 let wanted = wanted(q.collections.as_deref());
350 let mut out = Vec::new();
351 for (id, owner) in readable(&state, &auth, PimKind::AddressBook).await? {
352 if wanted.as_ref().is_some_and(|w| !w.contains(&id)) {
353 continue;
354 }
355 for (obj, data) in members_of(&state, owner, id).await? {
356 let c = view::card(&String::from_utf8_lossy(&data));
357 let hit = needle.is_empty()
358 || [Some(&c.full_name), c.org.as_ref()]
359 .into_iter()
360 .flatten()
361 .chain(c.emails.iter().map(|e| &e.value))
362 .chain(c.phones.iter().map(|p| &p.value))
363 .any(|v| v.to_lowercase().contains(&needle));
364 if !hit {
365 continue;
366 }
367 out.push(PimContact {
368 collection_id: id,
369 name: obj.name,
370 full_name: c.full_name,
371 org: c.org,
372 email: c.emails.into_iter().next().map(|e| e.value),
373 phone: c.phones.into_iter().next().map(|p| p.value),
374 has_photo: c.has_photo,
375 is_group: c.is_group,
376 });
377 }
378 }
379 out.sort_by_cached_key(|c| (c.full_name.to_lowercase(), c.collection_id));
380 Ok(Json(out))
381}
382
383#[derive(Deserialize)]
384pub struct TzQuery {
385 tz: Option<String>,
386}
387
388/// GET {PIM_INVITATIONS}: in the signed-in user's own calendars, the series
389/// and instances they are invited to and have not answered, and whose next
390/// instance is still ahead.
391pub async fn invitations(
392 State(state): State<Arc<AppState>>,
393 auth: SessionUser,
394 Query(q): Query<TzQuery>,
395) -> Result<Json<Vec<PimInvitation>>, ApiError> {
396 let db = &state.db;
397 let pid = db.principal_of(auth.user.id).await?;
398 let dir = Directory::load(&state).await?;
399 let owns = dir.is(pid);
400 let zone = floating(q.tz.as_deref());
401 let now = Utc::now();
402 let window = now..now + TimeDelta::days(INVITATION_HORIZON_DAYS);
403 let mut out = Vec::new();
404 for col in db.pim_collections(pid, PimKind::Calendar).await? {
405 if col.slug == INBOX {
406 continue;
407 }
408 for (obj, data) in db.pim_objects_with_data(col.id).await? {
409 // Most objects invite no one: skip their parse.
410 if !data.windows(8).any(|w| w.eq_ignore_ascii_case(b"ATTENDEE")) {
411 continue;
412 }
413 let Some(cal) = parse(&data) else {
414 continue;
415 };
416 if !matches!(itip::role(&cal, &owns), Ok(Role::Attendee)) {
417 continue;
418 }
419 let instances = expand(&cal, window.clone(), zone.clone()).instances;
420 for (index, c) in cal.components.iter().enumerate() {
421 if !view::is_item(c) {
422 continue;
423 }
424 let info = view::event_info(&cal, index, &owns);
425 if info.partstat() != Some("NEEDS-ACTION")
426 || info.status.as_deref() == Some("CANCELLED")
427 {
428 continue;
429 }
430 let Some(next) = instances.iter().find(|i| i.component == index) else {
431 continue;
432 };
433 let is_override = c.has_property(&ICalendarProperty::RecurrenceId);
434 out.push(PimInvitation {
435 collection_id: col.id,
436 name: obj.name.clone(),
437 uid: obj.uid.clone(),
438 recurrence_id: is_override
439 .then_some(next.recurrence_id)
440 .flatten()
441 .map(rfc3339),
442 summary: info.summary.clone(),
443 location: info.location.clone(),
444 organizer: info.organizer.as_ref().map(wire_person),
445 start: rfc3339(next.start),
446 end: rfc3339(next.end),
447 all_day: info.all_day,
448 recurring: info.rrule.is_some() && !is_override,
449 rrule: info.rrule.clone().filter(|_| !is_override),
450 });
451 }
452 }
453 }
454 out.sort_by(|a, b| a.start.cmp(&b.start));
455 Ok(Json(out))
456}
457
458/// POST {PIM_INVITATIONS}: writes the answer into the calendar owner's copy
459/// through the same path as a client's PUT, so the organizer gets the REPLY.
460pub async fn reply(
461 State(state): State<Arc<AppState>>,
462 auth: SessionUser,
463 Json(body): Json<PimReply>,
464) -> Result<Json<OkResp>, ApiError> {
465 let answer = match body.partstat.to_ascii_uppercase().as_str() {
466 "ACCEPTED" => ICalendarParticipationStatus::Accepted,
467 "TENTATIVE" => ICalendarParticipationStatus::Tentative,
468 "DECLINED" => ICalendarParticipationStatus::Declined,
469 _ => {
470 return Err(ApiError::new(
471 StatusCode::BAD_REQUEST,
472 "partstat must be ACCEPTED, TENTATIVE or DECLINED",
473 ));
474 }
475 };
476 let rid = body.recurrence_id.as_deref().map(parse_time).transpose()?;
477 let (owner, kind, col, _) = reachable(&state, &auth, body.collection_id).await?;
478 if kind != PimKind::Calendar || !may_answer(&state, &auth, owner, col.id).await? {
479 return Err(ApiError::new(StatusCode::FORBIDDEN, "cannot answer here"));
480 }
481 let not_found = || ApiError::new(StatusCode::NOT_FOUND, "object not found");
482 let _lock = pim_schedule::LOCK.lock().await;
483 let (obj, old) = state
484 .db
485 .pim_object(col.id, &body.name)
486 .await?
487 .ok_or_else(not_found)?;
488 let cal = parse(&old).ok_or_else(not_found)?;
489 let dir = Directory::load(&state).await?;
490 let principal = dir.get(owner).cloned().ok_or_else(not_found)?;
491 let owns = dir.is(owner);
492 if !matches!(itip::role(&cal, &owns), Ok(Role::Attendee)) {
493 return Err(ApiError::new(
494 StatusCode::BAD_REQUEST,
495 "the calendar owner is not an attendee",
496 ));
497 }
498 let zone = floating(body.tz.as_deref());
499 let new = itip::respond(&cal, &owns, answer, rid, &zone).to_string();
500 let me = state.db.principal_of(auth.user.id).await?;
501 let w = Writer {
502 owner: &principal,
503 may_schedule: true,
504 sent_by: (me != owner)
505 .then(|| format!("mailto:{}", mailto(&auth.user.name, UserType::Individual))),
506 };
507 let stored = match pim_schedule::put(
508 &state,
509 &dir,
510 &w,
511 (col.id, &obj.name),
512 Some(&old),
513 new.as_bytes(),
514 )
515 .await?
516 {
517 Ok(s) => s,
518 Err(condition) => return Err(ApiError::new(StatusCode::FORBIDDEN, &condition.name)),
519 };
520 let mut ops = vec![PimOp::Put {
521 collection_id: col.id,
522 obj: PimObject {
523 etag: etag_of(&stored.data),
524 schedule_tag: stored.schedule_tag.clone(),
525 ..obj
526 },
527 data: stored.data,
528 }];
529 ops.extend(stored.ops);
530 state.db.pim_apply(&ops).await?;
531 Ok(Json(OkResp {}))
532}
533