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//!
7//! The UI never parses iCalendar or vCard: these endpoints do.
8
9use std::collections::{HashMap, HashSet};
10use std::sync::Arc;
11
12use api_types::{
13 OBJECTS_SUFFIX, OkResp, PHOTO_SUFFIX, PIM_COLLECTIONS, PimAttendee, PimContact,
14 PimContactDetail, PimEventDetail, PimInstance, PimInstances, PimInvitation, PimLabeled,
15 PimObjectDetail, PimPerson, PimReply, PimShareMode,
16};
17use axum::Json;
18use axum::extract::{Path as AxumPath, Query, State};
19use axum::http::StatusCode;
20use chrono::{DateTime, SecondsFormat, TimeDelta, Utc};
21use pimdav::calcard::icalendar::{ICalendar, ICalendarParticipationStatus, ICalendarProperty};
22use pimdav::expand::expand;
23use pimdav::itip::{self, Role};
24use pimdav::principal::UserType;
25use pimdav::view::{self, Card, EventInfo, Person};
26use pimdav::zone::{self, Zone};
27use serde::Deserialize;
28
29use crate::api::common::SessionUser;
30use crate::api::common::blocking;
31use crate::api::pim::{BIRTHDAYS, DIRECTORY, INBOX, etag_of, generated, 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 sources = Vec::new();
140 for (id, owner) in readable(&state, &auth, PimKind::Calendar).await? {
141 if wanted.as_ref().is_some_and(|w| !w.contains(&id)) {
142 continue;
143 }
144 sources.push((id, owner, members_of(&state, owner, id).await?));
145 }
146 let (mut out, truncated) = blocking(move || -> Result<_, ApiError> {
147 let mut out = Vec::new();
148 let mut truncated = false;
149 'all: for (id, owner, members) in sources {
150 let owns = dir.is(owner);
151 for (obj, data) in members {
152 let Some(cal) = parse(&data) else {
153 continue;
154 };
155 let exp = expand(&cal, from..to, zone.clone());
156 truncated |= exp.truncated;
157 let mut infos: HashMap<usize, EventInfo> = HashMap::new();
158 for i in exp.instances {
159 if out.len() == MAX_INSTANCES {
160 truncated = true;
161 break 'all;
162 }
163 let info = infos
164 .entry(i.component)
165 .or_insert_with(|| view::event_info(&cal, i.component, &owns));
166 out.push(PimInstance {
167 collection_id: id,
168 name: obj.name.clone(),
169 uid: obj.uid.clone(),
170 recurrence_id: i.recurrence_id.map(rfc3339),
171 start: rfc3339(i.start),
172 end: rfc3339(i.end),
173 all_day: info.all_day,
174 component: info.component.clone(),
175 summary: info.summary.clone(),
176 location: info.location.clone(),
177 status: info.status.clone(),
178 transparent: info.transparent,
179 has_attendees: !info.attendees.is_empty(),
180 partstat: info.partstat().map(str::to_string),
181 organizer: info.organizer.as_ref().map(display),
182 });
183 }
184 }
185 }
186 Ok((out, truncated))
187 })
188 .await?;
189 out.sort_by(|a, b| (&a.start, &a.end).cmp(&(&b.start, &b.end)));
190 Ok(Json(PimInstances {
191 instances: out,
192 truncated,
193 }))
194}
195
196#[derive(Deserialize)]
197pub struct DetailQuery {
198 recurrence_id: Option<String>,
199 tz: Option<String>,
200}
201
202/// GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}
203pub async fn object(
204 State(state): State<Arc<AppState>>,
205 auth: SessionUser,
206 AxumPath((id, name)): AxumPath<(i64, String)>,
207 Query(q): Query<DetailQuery>,
208) -> Result<Json<PimObjectDetail>, ApiError> {
209 let not_found = || ApiError::new(StatusCode::NOT_FOUND, "object not found");
210 let (owner, kind, col, writable) = reachable(&state, &auth, id).await?;
211 let found = match generated(col.id) {
212 true => members_of(&state, owner, col.id)
213 .await?
214 .into_iter()
215 .find(|(o, _)| o.name == name),
216 false => state.db.pim_object(col.id, &name).await?,
217 };
218 let (obj, data) = &found.ok_or_else(not_found)?;
219 match kind {
220 PimKind::Calendar => {
221 let cal = parse(data).ok_or_else(not_found)?;
222 let zone = floating(q.tz.as_deref());
223 let rid = q.recurrence_id.as_deref().map(parse_time).transpose()?;
224 let index = view::component_for(&cal, rid, &zone).ok_or_else(not_found)?;
225 let dir = Directory::load(&state).await?;
226 let owns = dir.is(owner);
227 let info = view::event_info(&cal, index, &owns);
228 let answers = may_answer(&state, &auth, owner, col.id).await?;
229 let attendee = matches!(itip::role(&cal, &owns), Ok(Role::Attendee));
230 Ok(Json(PimObjectDetail::Event(PimEventDetail {
231 collection_id: id,
232 name: obj.name.clone(),
233 uid: obj.uid.clone(),
234 component: info.component,
235 summary: info.summary,
236 description: info.description,
237 location: info.location,
238 url: info.url,
239 status: info.status,
240 transparent: info.transparent,
241 all_day: info.all_day,
242 categories: info.categories,
243 rrule: info.rrule,
244 organizer: info.organizer.as_ref().map(wire_person),
245 attendees: info
246 .attendees
247 .iter()
248 .map(|a| PimAttendee {
249 person: wire_person(&a.person),
250 partstat: a.partstat.clone(),
251 role: a.role.clone(),
252 is_owner: a.is_owner,
253 })
254 .collect(),
255 can_edit: writable,
256 can_reply: attendee && answers,
257 })))
258 }
259 PimKind::AddressBook => {
260 let card = view::card(&String::from_utf8_lossy(data));
261 let members = match card.is_group {
262 true => {
263 let by_uid: HashMap<String, String> = members_of(&state, owner, col.id)
264 .await?
265 .iter()
266 .map(|(_, d)| view::card(&String::from_utf8_lossy(d)))
267 .filter_map(|c| Some((c.uid?, c.full_name)))
268 .collect();
269 card.members
270 .iter()
271 .map(|m| by_uid.get(m).cloned().unwrap_or_else(|| m.clone()))
272 .collect()
273 }
274 false => Vec::new(),
275 };
276 let photo_url = card.has_photo.then(|| {
277 format!(
278 "{PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{}{PHOTO_SUFFIX}",
279 seg(&obj.name)
280 )
281 });
282 Ok(Json(PimObjectDetail::Contact(contact_detail(
283 id, obj, card, members, photo_url, writable,
284 ))))
285 }
286 }
287}
288
289fn labeled(v: Vec<view::Labeled>) -> Vec<PimLabeled> {
290 v.into_iter()
291 .map(|l| PimLabeled {
292 label: l.label,
293 value: l.value,
294 })
295 .collect()
296}
297
298fn contact_detail(
299 id: i64,
300 obj: &PimObject,
301 card: Card,
302 members: Vec<String>,
303 photo_url: Option<String>,
304 can_edit: bool,
305) -> PimContactDetail {
306 PimContactDetail {
307 collection_id: id,
308 name: obj.name.clone(),
309 uid: card.uid,
310 full_name: card.full_name,
311 org: card.org,
312 title: card.title,
313 emails: labeled(card.emails),
314 phones: labeled(card.phones),
315 addresses: labeled(card.addresses),
316 urls: labeled(card.urls),
317 birthday: card.birthday,
318 anniversary: card.anniversary,
319 note: card.note,
320 is_group: card.is_group,
321 members,
322 photo_url,
323 can_edit,
324 }
325}
326
327/// Whether the signed-in user may answer invitations in a calendar of
328/// `owner`: their own, or one lent with `rw+schedule`.
329async fn may_answer(
330 state: &AppState,
331 auth: &SessionUser,
332 owner: i64,
333 collection_id: i64,
334) -> Result<bool, ApiError> {
335 if collection_id <= DIRECTORY {
336 return Ok(false);
337 }
338 if owner == state.db.principal_of(auth.user.id).await? {
339 return Ok(true);
340 }
341 Ok(state
342 .db
343 .pim_shared_collection(auth.user.id, PimKind::Calendar, collection_id)
344 .await?
345 .is_some_and(|(_, _, mode)| mode == PimShareMode::RwSchedule))
346}
347
348#[derive(Deserialize)]
349pub struct ContactsQuery {
350 q: Option<String>,
351 collections: Option<String>,
352}
353
354/// GET {PIM_CONTACTS}
355pub async fn contacts(
356 State(state): State<Arc<AppState>>,
357 auth: SessionUser,
358 Query(q): Query<ContactsQuery>,
359) -> Result<Json<Vec<PimContact>>, ApiError> {
360 let needle = q.q.as_deref().map(str::trim).unwrap_or("").to_lowercase();
361 let wanted = wanted(q.collections.as_deref());
362 let mut sources = Vec::new();
363 for (id, owner) in readable(&state, &auth, PimKind::AddressBook).await? {
364 if wanted.as_ref().is_some_and(|w| !w.contains(&id)) {
365 continue;
366 }
367 sources.push((id, members_of(&state, owner, id).await?));
368 }
369 let mut out = blocking(move || -> Result<_, ApiError> {
370 let mut out = Vec::new();
371 for (id, members) in sources {
372 for (obj, data) in members {
373 let c = view::card(&String::from_utf8_lossy(&data));
374 let hit = needle.is_empty()
375 || [Some(&c.full_name), c.org.as_ref()]
376 .into_iter()
377 .flatten()
378 .chain(c.emails.iter().map(|e| &e.value))
379 .chain(c.phones.iter().map(|p| &p.value))
380 .any(|v| v.to_lowercase().contains(&needle));
381 if !hit {
382 continue;
383 }
384 out.push(PimContact {
385 collection_id: id,
386 name: obj.name,
387 full_name: c.full_name,
388 org: c.org,
389 email: c.emails.into_iter().next().map(|e| e.value),
390 phone: c.phones.into_iter().next().map(|p| p.value),
391 has_photo: c.has_photo,
392 is_group: c.is_group,
393 });
394 }
395 }
396 Ok(out)
397 })
398 .await?;
399 out.sort_by_cached_key(|c| (c.full_name.to_lowercase(), c.collection_id));
400 Ok(Json(out))
401}
402
403#[derive(Deserialize)]
404pub struct TzQuery {
405 tz: Option<String>,
406}
407
408/// GET {PIM_INVITATIONS}: in the signed-in user's own calendars, the series
409/// and instances they are invited to and have not answered, and whose next
410/// instance is still ahead.
411pub async fn invitations(
412 State(state): State<Arc<AppState>>,
413 auth: SessionUser,
414 Query(q): Query<TzQuery>,
415) -> Result<Json<Vec<PimInvitation>>, ApiError> {
416 let db = &state.db;
417 let pid = db.principal_of(auth.user.id).await?;
418 let dir = Directory::load(&state).await?;
419 let owns = dir.is(pid);
420 let zone = floating(q.tz.as_deref());
421 let now = Utc::now();
422 let window = now..now + TimeDelta::days(INVITATION_HORIZON_DAYS);
423 let mut out = Vec::new();
424 for col in db.pim_collections(pid, PimKind::Calendar).await? {
425 if col.slug == INBOX {
426 continue;
427 }
428 for (obj, data) in db.pim_objects_with_data(col.id).await? {
429 // Most objects invite no one: skip their parse.
430 if !data.windows(8).any(|w| w.eq_ignore_ascii_case(b"ATTENDEE")) {
431 continue;
432 }
433 let Some(cal) = parse(&data) else {
434 continue;
435 };
436 if !matches!(itip::role(&cal, &owns), Ok(Role::Attendee)) {
437 continue;
438 }
439 let instances = expand(&cal, window.clone(), zone.clone()).instances;
440 for (index, c) in cal.components.iter().enumerate() {
441 if !view::is_item(c) {
442 continue;
443 }
444 let info = view::event_info(&cal, index, &owns);
445 if info.partstat() != Some("NEEDS-ACTION")
446 || info.status.as_deref() == Some("CANCELLED")
447 {
448 continue;
449 }
450 let Some(next) = instances.iter().find(|i| i.component == index) else {
451 continue;
452 };
453 let is_override = c.has_property(&ICalendarProperty::RecurrenceId);
454 out.push(PimInvitation {
455 collection_id: col.id,
456 name: obj.name.clone(),
457 uid: obj.uid.clone(),
458 recurrence_id: is_override
459 .then_some(next.recurrence_id)
460 .flatten()
461 .map(rfc3339),
462 summary: info.summary.clone(),
463 location: info.location.clone(),
464 organizer: info.organizer.as_ref().map(wire_person),
465 start: rfc3339(next.start),
466 end: rfc3339(next.end),
467 all_day: info.all_day,
468 recurring: info.rrule.is_some() && !is_override,
469 rrule: info.rrule.clone().filter(|_| !is_override),
470 });
471 }
472 }
473 }
474 out.sort_by(|a, b| a.start.cmp(&b.start));
475 Ok(Json(out))
476}
477
478/// POST {PIM_INVITATIONS}: writes the answer into the calendar owner's copy
479/// through the same path as a client's PUT, so the organizer gets the REPLY.
480pub async fn reply(
481 State(state): State<Arc<AppState>>,
482 auth: SessionUser,
483 Json(body): Json<PimReply>,
484) -> Result<Json<OkResp>, ApiError> {
485 let answer = match body.partstat.to_ascii_uppercase().as_str() {
486 "ACCEPTED" => ICalendarParticipationStatus::Accepted,
487 "TENTATIVE" => ICalendarParticipationStatus::Tentative,
488 "DECLINED" => ICalendarParticipationStatus::Declined,
489 _ => {
490 return Err(ApiError::new(
491 StatusCode::BAD_REQUEST,
492 "partstat must be ACCEPTED, TENTATIVE or DECLINED",
493 ));
494 }
495 };
496 let rid = body.recurrence_id.as_deref().map(parse_time).transpose()?;
497 let (owner, kind, col, _) = reachable(&state, &auth, body.collection_id).await?;
498 if kind != PimKind::Calendar || !may_answer(&state, &auth, owner, col.id).await? {
499 return Err(ApiError::new(StatusCode::FORBIDDEN, "cannot answer here"));
500 }
501 let not_found = || ApiError::new(StatusCode::NOT_FOUND, "object not found");
502 let _lock = pim_schedule::LOCK.lock().await;
503 let (obj, old) = state
504 .db
505 .pim_object(col.id, &body.name)
506 .await?
507 .ok_or_else(not_found)?;
508 let cal = parse(&old).ok_or_else(not_found)?;
509 let dir = Directory::load(&state).await?;
510 let principal = dir.get(owner).cloned().ok_or_else(not_found)?;
511 let owns = dir.is(owner);
512 if !matches!(itip::role(&cal, &owns), Ok(Role::Attendee)) {
513 return Err(ApiError::new(
514 StatusCode::BAD_REQUEST,
515 "the calendar owner is not an attendee",
516 ));
517 }
518 let zone = floating(body.tz.as_deref());
519 let new = itip::respond(&cal, &owns, answer, rid, &zone).to_string();
520 let me = state.db.principal_of(auth.user.id).await?;
521 let w = Writer {
522 owner: &principal,
523 may_schedule: true,
524 sent_by: (me != owner)
525 .then(|| format!("mailto:{}", mailto(&auth.user.name, UserType::Individual))),
526 };
527 let stored = match pim_schedule::put(
528 &state,
529 &dir,
530 &w,
531 (col.id, &obj.name),
532 Some(&old),
533 new.as_bytes(),
534 )
535 .await?
536 {
537 Ok(s) => s,
538 Err(condition) => return Err(ApiError::new(StatusCode::FORBIDDEN, &condition.name)),
539 };
540 let mut ops = vec![PimOp::Put {
541 collection_id: col.id,
542 obj: PimObject {
543 etag: etag_of(&stored.data),
544 schedule_tag: stored.schedule_tag.clone(),
545 ..obj
546 },
547 data: stored.data,
548 }];
549 ops.extend(stored.ops);
550 state.db.pim_apply(&ops).await?;
551 Ok(Json(OkResp {}))
552}
553