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