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