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