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 instance = view::instance_for(&cal, index, rid, &zone);
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 start: instance.as_ref().map(|i| rfc3339(i.start)),
244 end: instance.as_ref().map(|i| rfc3339(i.end)),
245 categories: info.categories,
246 rrule: info.rrule,
247 organizer: info.organizer.as_ref().map(wire_person),
248 attendees: info
249 .attendees
250 .iter()
251 .map(|a| PimAttendee {
252 person: wire_person(&a.person),
253 partstat: a.partstat.clone(),
254 role: a.role.clone(),
255 is_owner: a.is_owner,
256 })
257 .collect(),
258 can_edit: writable,
259 can_reply: attendee && answers,
260 })))
261 }
262 PimKind::AddressBook => {
263 let card = view::card(&String::from_utf8_lossy(data));
264 let members = match card.is_group {
265 true => {
266 let by_uid: HashMap<String, String> = members_of(&state, owner, col.id)
267 .await?
268 .iter()
269 .map(|(_, d)| view::card(&String::from_utf8_lossy(d)))
270 .filter_map(|c| Some((c.uid?, c.full_name)))
271 .collect();
272 card.members
273 .iter()
274 .map(|m| by_uid.get(m).cloned().unwrap_or_else(|| m.clone()))
275 .collect()
276 }
277 false => Vec::new(),
278 };
279 let photo_url = card.has_photo.then(|| {
280 format!(
281 "{PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{}{PHOTO_SUFFIX}",
282 seg(&obj.name)
283 )
284 });
285 Ok(Json(PimObjectDetail::Contact(contact_detail(
286 id, obj, card, members, photo_url, writable,
287 ))))
288 }
289 }
290}
291
292fn labeled(v: Vec<view::Labeled>) -> Vec<PimLabeled> {
293 v.into_iter()
294 .map(|l| PimLabeled {
295 label: l.label,
296 value: l.value,
297 })
298 .collect()
299}
300
301fn contact_detail(
302 id: i64,
303 obj: &PimObject,
304 card: Card,
305 members: Vec<String>,
306 photo_url: Option<String>,
307 can_edit: bool,
308) -> PimContactDetail {
309 PimContactDetail {
310 collection_id: id,
311 name: obj.name.clone(),
312 uid: card.uid,
313 full_name: card.full_name,
314 org: card.org,
315 title: card.title,
316 emails: labeled(card.emails),
317 phones: labeled(card.phones),
318 addresses: labeled(card.addresses),
319 urls: labeled(card.urls),
320 birthday: card.birthday,
321 anniversary: card.anniversary,
322 note: card.note,
323 is_group: card.is_group,
324 members,
325 photo_url,
326 can_edit,
327 }
328}
329
330/// Whether the signed-in user may answer invitations in a calendar of
331/// `owner`: their own, or one lent with `rw+schedule`.
332pub(super) async fn may_answer(
333 state: &AppState,
334 auth: &SessionUser,
335 owner: i64,
336 collection_id: i64,
337) -> Result<bool, ApiError> {
338 if collection_id <= DIRECTORY {
339 return Ok(false);
340 }
341 if owner == state.db.principal_of(auth.user.id).await? {
342 return Ok(true);
343 }
344 Ok(state
345 .db
346 .pim_shared_collection(auth.user.id, PimKind::Calendar, collection_id)
347 .await?
348 .is_some_and(|(_, _, mode)| mode == PimShareMode::RwSchedule))
349}
350
351#[derive(Deserialize)]
352pub struct ContactsQuery {
353 q: Option<String>,
354 collections: Option<String>,
355}
356
357/// GET {PIM_CONTACTS}
358pub async fn contacts(
359 State(state): State<Arc<AppState>>,
360 auth: SessionUser,
361 Query(q): Query<ContactsQuery>,
362) -> Result<Json<Vec<PimContact>>, ApiError> {
363 let needle = q.q.as_deref().map(str::trim).unwrap_or("").to_lowercase();
364 let wanted = wanted(q.collections.as_deref());
365 let mut sources = Vec::new();
366 for (id, owner) in readable(&state, &auth, PimKind::AddressBook).await? {
367 if wanted.as_ref().is_some_and(|w| !w.contains(&id)) {
368 continue;
369 }
370 sources.push((id, members_of(&state, owner, id).await?));
371 }
372 let mut out = blocking(move || -> Result<_, ApiError> {
373 let mut out = Vec::new();
374 for (id, members) in sources {
375 for (obj, data) in members {
376 let c = view::card(&String::from_utf8_lossy(&data));
377 let hit = needle.is_empty()
378 || [Some(&c.full_name), c.org.as_ref()]
379 .into_iter()
380 .flatten()
381 .chain(c.emails.iter().map(|e| &e.value))
382 .chain(c.phones.iter().map(|p| &p.value))
383 .any(|v| v.to_lowercase().contains(&needle));
384 if !hit {
385 continue;
386 }
387 out.push(PimContact {
388 collection_id: id,
389 name: obj.name,
390 full_name: c.full_name,
391 org: c.org,
392 email: c.emails.into_iter().next().map(|e| e.value),
393 phone: c.phones.into_iter().next().map(|p| p.value),
394 has_photo: c.has_photo,
395 is_group: c.is_group,
396 });
397 }
398 }
399 Ok(out)
400 })
401 .await?;
402 out.sort_by_cached_key(|c| (c.full_name.to_lowercase(), c.collection_id));
403 Ok(Json(out))
404}
405
406#[derive(Deserialize)]
407pub struct TzQuery {
408 tz: Option<String>,
409}
410
411/// GET {PIM_INVITATIONS}: in the signed-in user's own calendars, the series
412/// and instances they are invited to and have not answered, and whose next
413/// instance is still ahead.
414pub async fn invitations(
415 State(state): State<Arc<AppState>>,
416 auth: SessionUser,
417 Query(q): Query<TzQuery>,
418) -> Result<Json<Vec<PimInvitation>>, ApiError> {
419 let db = &state.db;
420 let pid = db.principal_of(auth.user.id).await?;
421 let dir = Directory::load(&state).await?;
422 let owns = dir.is(pid);
423 let zone = floating(q.tz.as_deref());
424 let now = Utc::now();
425 let window = now..now + TimeDelta::days(INVITATION_HORIZON_DAYS);
426 let mut out = Vec::new();
427 for col in db.pim_collections(pid, PimKind::Calendar).await? {
428 if col.slug == INBOX {
429 continue;
430 }
431 for (obj, data) in db.pim_objects_with_data(col.id).await? {
432 // Most objects invite no one: skip their parse.
433 if !data.windows(8).any(|w| w.eq_ignore_ascii_case(b"ATTENDEE")) {
434 continue;
435 }
436 let Some(cal) = parse(&data) else {
437 continue;
438 };
439 if !matches!(itip::role(&cal, &owns), Ok(Role::Attendee)) {
440 continue;
441 }
442 let instances = expand(&cal, window.clone(), zone.clone()).instances;
443 for (index, c) in cal.components.iter().enumerate() {
444 if !view::is_item(c) {
445 continue;
446 }
447 let info = view::event_info(&cal, index, &owns);
448 if info.partstat() != Some("NEEDS-ACTION")
449 || info.status.as_deref() == Some("CANCELLED")
450 {
451 continue;
452 }
453 let Some(next) = instances.iter().find(|i| i.component == index) else {
454 continue;
455 };
456 let is_override = c.has_property(&ICalendarProperty::RecurrenceId);
457 out.push(PimInvitation {
458 collection_id: col.id,
459 name: obj.name.clone(),
460 uid: obj.uid.clone(),
461 recurrence_id: is_override
462 .then_some(next.recurrence_id)
463 .flatten()
464 .map(rfc3339),
465 summary: info.summary.clone(),
466 location: info.location.clone(),
467 organizer: info.organizer.as_ref().map(wire_person),
468 start: rfc3339(next.start),
469 end: rfc3339(next.end),
470 all_day: info.all_day,
471 recurring: info.rrule.is_some() && !is_override,
472 rrule: info.rrule.clone().filter(|_| !is_override),
473 });
474 }
475 }
476 }
477 out.sort_by(|a, b| a.start.cmp(&b.start));
478 Ok(Json(out))
479}
480
481/// POST {PIM_INVITATIONS}: writes the answer into the calendar owner's copy
482/// through the same path as a client's PUT, so the organizer gets the REPLY.
483pub async fn reply(
484 State(state): State<Arc<AppState>>,
485 auth: SessionUser,
486 Json(body): Json<PimReply>,
487) -> Result<Json<OkResp>, ApiError> {
488 let answer = match body.partstat.to_ascii_uppercase().as_str() {
489 "ACCEPTED" => ICalendarParticipationStatus::Accepted,
490 "TENTATIVE" => ICalendarParticipationStatus::Tentative,
491 "DECLINED" => ICalendarParticipationStatus::Declined,
492 _ => {
493 return Err(ApiError::new(
494 StatusCode::BAD_REQUEST,
495 "partstat must be ACCEPTED, TENTATIVE or DECLINED",
496 ));
497 }
498 };
499 let rid = body.recurrence_id.as_deref().map(parse_time).transpose()?;
500 let (owner, kind, col, _) = reachable(&state, &auth, body.collection_id).await?;
501 if kind != PimKind::Calendar || !may_answer(&state, &auth, owner, col.id).await? {
502 return Err(ApiError::new(StatusCode::FORBIDDEN, "cannot answer here"));
503 }
504 let not_found = || ApiError::new(StatusCode::NOT_FOUND, "object not found");
505 let _lock = pim_schedule::LOCK.lock().await;
506 let (obj, old) = state
507 .db
508 .pim_object(col.id, &body.name)
509 .await?
510 .ok_or_else(not_found)?;
511 let cal = parse(&old).ok_or_else(not_found)?;
512 let dir = Directory::load(&state).await?;
513 let principal = dir.get(owner).cloned().ok_or_else(not_found)?;
514 let owns = dir.is(owner);
515 if !matches!(itip::role(&cal, &owns), Ok(Role::Attendee)) {
516 return Err(ApiError::new(
517 StatusCode::BAD_REQUEST,
518 "the calendar owner is not an attendee",
519 ));
520 }
521 let zone = floating(body.tz.as_deref());
522 let new = itip::respond(&cal, &owns, answer, rid, &zone).to_string();
523 let me = state.db.principal_of(auth.user.id).await?;
524 let w = Writer {
525 owner: &principal,
526 may_schedule: true,
527 sent_by: (me != owner)
528 .then(|| format!("mailto:{}", mailto(&auth.user.name, UserType::Individual))),
529 };
530 let stored = match pim_schedule::put(
531 &state,
532 &dir,
533 &w,
534 (col.id, &obj.name),
535 Some(&old),
536 new.as_bytes(),
537 )
538 .await?
539 {
540 Ok(s) => s,
541 Err(condition) => return Err(ApiError::new(StatusCode::FORBIDDEN, &condition.name)),
542 };
543 let mut ops = vec![PimOp::Put {
544 collection_id: col.id,
545 obj: PimObject {
546 etag: etag_of(&stored.data),
547 schedule_tag: stored.schedule_tag.clone(),
548 ..obj
549 },
550 data: stored.data,
551 }];
552 ops.extend(stored.ops);
553 state.db.pim_apply(&ops).await?;
554 Ok(Json(OkResp {}))
555}
556