pim_schedule.rs
⎇
Raw
1//! Implicit scheduling (RFC 6638) between the principals of this server.
2//!
3//! `pimdav::itip` decides what a change sends to whom. This module finds the
4//! recipients and their objects, and turns every message into writes that
5//! commit together with the change itself. Nothing leaves the server: an
6//! address outside it gets a delivery failure in its SCHEDULE-STATUS.
7//!
8//! Rooms and resources answer at once, from their own bookings. The outbox
9//! answers free-busy requests from the recipients' calendars.
10
11use chrono::{DateTime, TimeDelta, Utc};
12use percent_encoding::percent_decode_str;
13use pimdav::calcard::icalendar::{ICalendar, ICalendarComponentType};
14use pimdav::filter::TimeRange;
15use pimdav::freebusy::{self, Period};
16use pimdav::itip::{self, Message, Method, Role};
17use pimdav::principal::UserType;
18use pimdav::xml::{CALDAV, el, hrefs, with_children};
19use pimdav::zone::{self, Zone};
20use sha2::{Digest, Sha256};
21use tokio::sync::Mutex;
22use xmltree::Element;
23
24use super::pim::{
25 INBOX, MAIL_DOMAIN, OUTBOX, collection_href, etag_of, need_privilege, principal_name,
26 principal_uuid, seg,
27};
28use crate::db::{PimKind, PimObject, PimOp, PimPrincipal};
29use crate::error::{ApiError, AppState};
30
31/// Held from reading a calendar object to committing the change, so that a
32/// change and the writes it causes see a consistent store.
33// ponytail: one lock for every object write. Per-UID locks if write
34// throughput ever matters.
35pub(crate) static LOCK: Mutex<()> = Mutex::const_new(());
36
37/// The delivery status codes of RFC 6638, 3.2.9.
38const DELIVERED: &str = "1.2";
39/// An address in this server's domains that names no one.
40const INVALID_USER: &str = "3.7";
41/// An address outside this server: there is no iMIP to reach it.
42const NO_ROUTE: &str = "5.2";
43/// The recipient has no calendar for the component.
44const REFUSED: &str = "5.3";
45
46/// How far ahead a room checks a series against its bookings. Later
47/// instances are accepted unchecked.
48const ANSWER_HORIZON: TimeDelta = TimeDelta::days(366);
49
50/// Who writes into a calendar, as far as scheduling cares.
51pub(crate) struct Writer<'a> {
52 pub owner: &'a PimPrincipal,
53 /// May send messages as the owner (RFC 6638 `schedule-send`).
54 pub may_schedule: bool,
55 /// The writer's address when it is not the owner, for SENT-BY.
56 pub sent_by: Option<String>,
57}
58
59impl Writer<'_> {
60 /// The owner itself.
61 pub(crate) fn owner(owner: &PimPrincipal) -> Writer<'_> {
62 Writer {
63 owner,
64 may_schedule: true,
65 sent_by: None,
66 }
67 }
68
69 /// 403 `need-privileges` on the owner's outbox.
70 fn refused(&self, privilege: &str) -> Element {
71 let outbox = collection_href(&self.owner.name, PimKind::Calendar, OUTBOX, None);
72 need_privilege(&outbox, CALDAV, privilege)
73 }
74}
75
76/// Every principal, for mapping calendar user addresses.
77pub(crate) struct Directory(Vec<PimPrincipal>);
78
79enum Recipient<'a> {
80 Local(&'a PimPrincipal),
81 Unknown,
82 External,
83}
84
85fn found(p: Option<&PimPrincipal>) -> Recipient<'_> {
86 match p {
87 Some(p) => Recipient::Local(p),
88 None => Recipient::Unknown,
89 }
90}
91
92impl Directory {
93 pub(crate) async fn load(state: &AppState) -> Result<Self, ApiError> {
94 Ok(Directory(state.db.pim_principals().await?))
95 }
96
97 pub(crate) fn get(&self, id: i64) -> Option<&PimPrincipal> {
98 self.0.iter().find(|p| p.id == id)
99 }
100
101 /// The forms `calendar-user-address-set` lists: the mailto address, the
102 /// principal URL and the `urn:uuid:`. Compared without case.
103 fn resolve(&self, addr: &str) -> Recipient<'_> {
104 let addr = addr.trim();
105 let lower = addr.to_ascii_lowercase();
106 if let Some(rest) = lower.strip_prefix("mailto:") {
107 let Some((local, domain)) = rest.rsplit_once('@') else {
108 return Recipient::External;
109 };
110 let kind = match domain.strip_suffix(MAIL_DOMAIN) {
111 Some("") => UserType::Individual,
112 Some("rooms.") => UserType::Room,
113 Some("resources.") => UserType::Resource,
114 _ => return Recipient::External,
115 };
116 let name = percent_decode_str(local).decode_utf8_lossy();
117 return found(
118 self.0
119 .iter()
120 .find(|p| p.kind == kind && p.name.eq_ignore_ascii_case(&name)),
121 );
122 }
123 if let Some(uuid) = lower.strip_prefix("urn:uuid:") {
124 return found(self.0.iter().find(|p| principal_uuid(p.id) == uuid));
125 }
126 match principal_name(addr) {
127 Some(name) => found(self.0.iter().find(|p| p.name.eq_ignore_ascii_case(&name))),
128 None if lower.starts_with('/') || lower.starts_with("http") => Recipient::Unknown,
129 None => Recipient::External,
130 }
131 }
132
133 /// Whether an address names principal `id`.
134 pub(crate) fn is(&self, id: i64) -> impl Fn(&str) -> bool + '_ {
135 move |a: &str| matches!(self.resolve(a), Recipient::Local(p) if p.id == id)
136 }
137}
138
139/// What a PUT of a calendar object stores, and what else it writes.
140pub(crate) struct Stored {
141 pub data: Vec<u8>,
142 /// Whether `data` differs from the request body.
143 pub changed: bool,
144 pub schedule_tag: Option<String>,
145 pub ops: Vec<PimOp>,
146}
147
148/// A PUT of `body` over `old` into collection `at.0` under the name `at.1`.
149/// `Err` names a failed scheduling precondition.
150pub(crate) async fn put(
151 state: &AppState,
152 dir: &Directory,
153 w: &Writer<'_>,
154 at: (i64, &str),
155 old: Option<&[u8]>,
156 body: &[u8],
157) -> Result<Result<Stored, Element>, ApiError> {
158 let parse = |b: &[u8]| ICalendar::parse(String::from_utf8_lossy(b).as_ref()).ok();
159 let Some(sent) = parse(body) else {
160 return Ok(Ok(unchanged(body, None)));
161 };
162 let owner = w.owner;
163 let owns = dir.is(owner.id);
164 let role = match itip::role(&sent, &owns) {
165 Ok(r) => r,
166 Err(refused) => return Ok(Err(refused.condition())),
167 };
168 if role != Role::None
169 && let Some(holder) = elsewhere(state, owner, &sent, at).await?
170 {
171 return Ok(Err(holder));
172 }
173 let old = old.and_then(parse);
174 let old_role = old.as_ref().and_then(|o| itip::role(o, &owns).ok());
175 let now = Utc::now();
176 let mut ops = Vec::new();
177
178 let stored = match (role, old_role) {
179 (Role::Organizer, _) => {
180 let old = old.as_ref().filter(|_| old_role == Some(Role::Organizer));
181 let (mut store, force) = itip::prepare(old, &sent, &owns);
182 itip::stamp_sender(&mut store, &owns, w.sent_by.as_deref());
183 let mut messages = itip::messages(old, Some(&store), &owns, &force, now);
184 if !messages.is_empty() && !w.may_schedule {
185 return Ok(Err(w.refused("schedule-send-invite")));
186 }
187 // Rooms answer first, so the others' copies carry their answers.
188 if answer_rooms(state, dir, owner, &mut store, &messages, &mut ops, now).await? {
189 messages = itip::messages(old, Some(&store), &owns, &force, now);
190 }
191 for m in &messages {
192 if let Some(status) = deliver(state, dir, owner, m, &mut ops).await? {
193 itip::set_attendee_status(&mut store, &m.to, status);
194 }
195 }
196 store
197 }
198 (Role::Attendee, Some(Role::Attendee)) => {
199 let old = old.as_ref().expect("an attendee role needs the old object");
200 let mut incoming = sent.clone();
201 itip::stamp_sender(&mut incoming, &owns, w.sent_by.as_deref());
202 let (mut store, reply) = match itip::attend(old, incoming, &owns, now) {
203 Ok(v) => v,
204 Err(refused) => return Ok(Err(refused.condition())),
205 };
206 if let Some(reply) = reply {
207 if !w.may_schedule {
208 return Ok(Err(w.refused("schedule-send-reply")));
209 }
210 let status = reply_to(state, dir, owner, &reply, &mut ops).await?;
211 itip::set_organizer_status(&mut store, status);
212 }
213 store
214 }
215 // No longer a scheduling object, or a copy the attendee brings in
216 // itself (RFC 6638, 3.2.2.2): stored as sent.
217 (_, previous) => {
218 if let Some(old) = &old {
219 match removed(state, dir, w, old, previous, true).await? {
220 Ok(more) => ops.extend(more),
221 Err(refused) => return Ok(Err(refused)),
222 }
223 }
224 let tag = (role != Role::None).then(|| etag_of(body));
225 return Ok(Ok(Stored {
226 ops,
227 ..unchanged(body, tag)
228 }));
229 }
230 };
231 let changed = stored != sent;
232 let data = match changed {
233 true => stored.to_string().into_bytes(),
234 false => body.to_vec(),
235 };
236 Ok(Ok(Stored {
237 schedule_tag: Some(etag_of(&data)),
238 data,
239 changed,
240 ops,
241 }))
242}
243
244fn unchanged(body: &[u8], schedule_tag: Option<String>) -> Stored {
245 Stored {
246 data: body.to_vec(),
247 changed: false,
248 schedule_tag,
249 ops: Vec::new(),
250 }
251}
252
253/// The writes a DELETE of `old` causes. `reply` is false for
254/// `Schedule-Reply: F` (RFC 6638, 8.1). `Err` names a lacking privilege.
255pub(crate) async fn delete(
256 state: &AppState,
257 dir: &Directory,
258 w: &Writer<'_>,
259 old: &[u8],
260 reply: bool,
261) -> Result<Result<Vec<PimOp>, Element>, ApiError> {
262 let Ok(old) = ICalendar::parse(String::from_utf8_lossy(old).as_ref()) else {
263 return Ok(Ok(Vec::new()));
264 };
265 let role = itip::role(&old, &dir.is(w.owner.id)).ok();
266 removed(state, dir, w, &old, role, reply).await
267}
268
269/// An organizer object going away cancels; an attendee copy declines.
270async fn removed(
271 state: &AppState,
272 dir: &Directory,
273 w: &Writer<'_>,
274 old: &ICalendar,
275 role: Option<Role>,
276 reply: bool,
277) -> Result<Result<Vec<PimOp>, Element>, ApiError> {
278 let owner = w.owner;
279 let owns = dir.is(owner.id);
280 let now = Utc::now();
281 let mut old = old.clone();
282 itip::stamp_sender(&mut old, &owns, w.sent_by.as_deref());
283 let mut ops = Vec::new();
284 match role {
285 Some(Role::Organizer) => {
286 let (_, messages) = itip::organize(Some(&old), None, &owns, now);
287 if !messages.is_empty() && !w.may_schedule {
288 return Ok(Err(w.refused("schedule-send-invite")));
289 }
290 for m in &messages {
291 deliver(state, dir, owner, m, &mut ops).await?;
292 }
293 }
294 Some(Role::Attendee) if reply => {
295 if let Some(m) = itip::decline(&old, &owns, now) {
296 if !w.may_schedule {
297 return Ok(Err(w.refused("schedule-send-reply")));
298 }
299 reply_to(state, dir, owner, &m, &mut ops).await?;
300 }
301 }
302 _ => {}
303 }
304 Ok(Ok(ops))
305}
306
307/// The resource of the owner that already schedules this UID elsewhere:
308/// RFC 6638 allows one per UID (3.2.4.1).
309async fn elsewhere(
310 state: &AppState,
311 owner: &PimPrincipal,
312 cal: &ICalendar,
313 (collection_id, name): (i64, &str),
314) -> Result<Option<Element>, ApiError> {
315 let Some((uid, _)) = identity(cal) else {
316 return Ok(None);
317 };
318 let Some((holder_id, holder, _)) = state.db.pim_find_uid(owner.id, &uid).await? else {
319 return Ok(None);
320 };
321 if holder_id == collection_id && holder.name == name {
322 return Ok(None);
323 }
324 let slug = match state.db.pim_collection_by_id(holder_id).await? {
325 Some((_, _, c)) => c.slug,
326 None => return Ok(None),
327 };
328 let href = collection_href(&owner.name, PimKind::Calendar, &slug, None) + &seg(&holder.name);
329 Ok(Some(with_children(
330 el(CALDAV, "unique-scheduling-object-resource"),
331 hrefs([href.as_str()]),
332 )))
333}
334
335/// Rooms and resources answer their invitations at once: accepted where
336/// free, declined where their bookings overlap. The answers go into the
337/// organizer's `store` and inbox. Returns whether any room answered.
338async fn answer_rooms(
339 state: &AppState,
340 dir: &Directory,
341 organizer: &PimPrincipal,
342 store: &mut ICalendar,
343 messages: &[Message],
344 ops: &mut Vec<PimOp>,
345 now: DateTime<Utc>,
346) -> Result<bool, ApiError> {
347 let mut answered = false;
348 for m in messages
349 .iter()
350 .filter(|m| m.method == Method::Request && !m.quiet)
351 {
352 let Recipient::Local(room) = dir.resolve(&m.to) else {
353 continue;
354 };
355 let Some((uid, component)) = identity(&m.cal) else {
356 continue;
357 };
358 if room.kind == UserType::Individual {
359 continue;
360 }
361 let Some(calendar) = state.db.pim_calendar_for(room.id, &component).await? else {
362 continue;
363 };
364 let current = state
365 .db
366 .pim_find_uid(room.id, &uid)
367 .await?
368 .and_then(|(_, _, d)| ICalendar::parse(String::from_utf8_lossy(&d).as_ref()).ok());
369 let Some(received) = itip::receive(current.as_ref(), m) else {
370 continue;
371 };
372 let is_room = dir.is(room.id);
373 let window = now..now + ANSWER_HORIZON;
374 let taken = busy_of(state, dir, room, &window, Some(&uid)).await?;
375 let floating = floating_of(calendar.timezone.as_deref());
376 let answer = itip::auto_answer(&received, &is_room, &taken, &window, &floating);
377 let Ok((_, Some(reply))) = itip::attend(&received, answer, &is_room, now) else {
378 continue;
379 };
380 answered |= itip::apply_reply(store, &reply.cal, &is_room);
381 ops.push(inbox(organizer, &reply, &component));
382 }
383 Ok(answered)
384}
385
386/// The busy time a principal shows to scheduling: its opaque calendars that
387/// take events, never the inbox. Objects with UID `skip` do not count.
388// ponytail: reads every object of those calendars per call. Keep busy
389// periods in a table if principals grow large calendars.
390pub(crate) async fn busy_of(
391 state: &AppState,
392 dir: &Directory,
393 p: &PimPrincipal,
394 range: &TimeRange,
395 skip: Option<&str>,
396) -> Result<Vec<Period>, ApiError> {
397 let me = dir.is(p.id);
398 let mut busy = Vec::new();
399 for c in state.db.pim_collections(p.id, PimKind::Calendar).await? {
400 if c.slug == INBOX || c.transparent || !c.components.split(',').any(|x| x == "VEVENT") {
401 continue;
402 }
403 let floating = floating_of(c.timezone.as_deref());
404 for (o, data) in state.db.pim_objects_with_data(c.id).await? {
405 if skip.is_some_and(|u| u == o.uid) {
406 continue;
407 }
408 if let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) {
409 busy.extend(freebusy::busy(&cal, range, &floating, Some(&me)));
410 }
411 }
412 }
413 Ok(freebusy::merge(busy))
414}
415
416fn floating_of(timezone: Option<&str>) -> Zone {
417 timezone.and_then(zone::from_vtimezone).unwrap_or(Zone::Utc)
418}
419
420/// The answers to a free-busy request to an outbox (RFC 6638, 5.2): per
421/// recipient its address, the REQUEST-STATUS and the VFREEBUSY reply.
422pub(crate) async fn free_busy(
423 state: &AppState,
424 dir: &Directory,
425 req: &freebusy::Request,
426) -> Result<Vec<(String, &'static str, Option<String>)>, ApiError> {
427 let now = Utc::now();
428 let mut out = Vec::new();
429 for to in &req.attendees {
430 let (status, data) = match dir.resolve(to) {
431 Recipient::Local(p) => {
432 let busy = busy_of(state, dir, p, &req.range, None).await?;
433 ("2.0;Success", Some(freebusy::reply(&busy, req, to, now)))
434 }
435 Recipient::Unknown => ("3.7;Invalid calendar user", None),
436 Recipient::External => ("5.2;Invalid calendar service", None),
437 };
438 out.push((to.clone(), status, data));
439 }
440 Ok(out)
441}
442
443/// A REQUEST or CANCEL from `sender` into the recipient's calendar and
444/// inbox. Returns the delivery status, `None` for the sender itself.
445async fn deliver(
446 state: &AppState,
447 dir: &Directory,
448 sender: &PimPrincipal,
449 m: &Message,
450 ops: &mut Vec<PimOp>,
451) -> Result<Option<&'static str>, ApiError> {
452 let p = match dir.resolve(&m.to) {
453 Recipient::Local(p) if p.id == sender.id => return Ok(None),
454 Recipient::Local(p) => p,
455 Recipient::Unknown => return Ok(Some(INVALID_USER)),
456 Recipient::External => return Ok(Some(NO_ROUTE)),
457 };
458 ensure(state, p).await?;
459 let Some((uid, component)) = identity(&m.cal) else {
460 return Ok(Some(REFUSED));
461 };
462 let copy = state.db.pim_find_uid(p.id, &uid).await?;
463 let current = copy
464 .as_ref()
465 .and_then(|(_, _, d)| ICalendar::parse(String::from_utf8_lossy(d).as_ref()).ok());
466 if let Some(next) = itip::receive(current.as_ref(), m) {
467 let data = next.to_string().into_bytes();
468 let etag = etag_of(&data);
469 let (collection_id, name, schedule_tag) = match copy {
470 // Only the others' answers changed: the attendee's pending edit
471 // may still go through (RFC 6638, 3.2.10).
472 Some((id, obj, _)) => (
473 id,
474 obj.name,
475 if m.quiet {
476 obj.schedule_tag
477 } else {
478 Some(etag.clone())
479 },
480 ),
481 None => match state.db.pim_calendar_for(p.id, &component).await? {
482 Some(c) => (
483 c.id,
484 format!("{}.ics", &crate::hex(&Sha256::digest(&uid))[..32]),
485 Some(etag.clone()),
486 ),
487 None => return Ok(Some(REFUSED)),
488 },
489 };
490 ops.push(PimOp::Put {
491 collection_id,
492 obj: PimObject {
493 name,
494 uid,
495 component: component.clone(),
496 etag,
497 schedule_tag,
498 ..Default::default()
499 },
500 data,
501 });
502 }
503 if !m.quiet {
504 ops.push(inbox(p, m, &component));
505 }
506 Ok(Some(DELIVERED))
507}
508
509/// An attendee's REPLY: applied to the organizer's object, passed on to the
510/// other attendees, and left in the organizer's inbox. Returns the delivery
511/// status for the attendee's copy.
512async fn reply_to(
513 state: &AppState,
514 dir: &Directory,
515 attendee: &PimPrincipal,
516 m: &Message,
517 ops: &mut Vec<PimOp>,
518) -> Result<&'static str, ApiError> {
519 let organizer = match dir.resolve(&m.to) {
520 Recipient::Local(p) => p,
521 Recipient::Unknown => return Ok(INVALID_USER),
522 Recipient::External => return Ok(NO_ROUTE),
523 };
524 let Some((uid, component)) = identity(&m.cal) else {
525 return Ok(REFUSED);
526 };
527 // RFC 6638, 4.2: a reply to an object the organizer no longer has is
528 // ignored.
529 let Some((collection_id, obj, data)) = state.db.pim_find_uid(organizer.id, &uid).await? else {
530 return Ok(NO_ROUTE);
531 };
532 let Ok(before) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) else {
533 return Ok(NO_ROUTE);
534 };
535 let mut after = before.clone();
536 let replier = dir.is(attendee.id);
537 if itip::apply_reply(&mut after, &m.cal, &replier) {
538 let data = after.to_string().into_bytes();
539 ops.push(PimOp::Put {
540 collection_id,
541 obj: PimObject {
542 etag: etag_of(&data),
543 ..obj
544 },
545 data,
546 });
547 // The others learn the new answer without a new Schedule-Tag.
548 let organizes = dir.is(organizer.id);
549 for mut other in itip::messages(Some(&before), Some(&after), &organizes, &[], Utc::now()) {
550 if other.method == Method::Request && !replier(&other.to) {
551 other.quiet = true;
552 deliver(state, dir, organizer, &other, ops).await?;
553 }
554 }
555 }
556 ops.push(inbox(organizer, m, &component));
557 Ok(DELIVERED)
558}
559
560async fn ensure(state: &AppState, p: &PimPrincipal) -> Result<(), ApiError> {
561 match p.kind {
562 UserType::Individual => state.db.pim_ensure_defaults(p.id).await?,
563 _ => state.db.pim_ensure_inbox(p.id).await?,
564 }
565 Ok(())
566}
567
568fn inbox(p: &PimPrincipal, m: &Message, component: &str) -> PimOp {
569 let data = m.cal.to_string().into_bytes();
570 let stamp = Utc::now().timestamp_nanos_opt().unwrap_or_default();
571 let seed = format!(
572 "{}\n{}\n{stamp}\n{:?}",
573 m.to,
574 String::from_utf8_lossy(&data),
575 m.method
576 );
577 let name = format!("{}.ics", &crate::hex(&Sha256::digest(seed))[..32]);
578 PimOp::Inbox {
579 principal_id: p.id,
580 obj: PimObject {
581 // Inbox messages share UIDs, and the store keeps UIDs unique.
582 uid: name.clone(),
583 name,
584 component: component.to_string(),
585 etag: etag_of(&data),
586 ..Default::default()
587 },
588 data,
589 }
590}
591
592/// UID and component type of a scheduling message or object.
593fn identity(cal: &ICalendar) -> Option<(String, String)> {
594 let c = cal.components.iter().find(|c| {
595 matches!(
596 c.component_type,
597 ICalendarComponentType::VEvent
598 | ICalendarComponentType::VTodo
599 | ICalendarComponentType::VJournal
600 )
601 })?;
602 Some((c.uid()?.to_string(), c.component_type.as_str().to_string()))
603}
604