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