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 std::collections::HashSet;
12
13use chrono::{DateTime, Utc};
14use percent_encoding::percent_decode_str;
15use pimdav::calcard::icalendar::{ICalendar, ICalendarComponentType};
16use pimdav::filter::TimeRange;
17use pimdav::freebusy::{self, Period};
18use pimdav::itip::{self, Message, Method, Role};
19use pimdav::principal::UserType;
20use pimdav::xml::{CALDAV, el, hrefs, with_children};
21use pimdav::zone::{self, Zone};
22use sha2::{Digest, Sha256};
23use tokio::sync::Mutex;
24use xmltree::Element;
25
26use super::pim::{
27 INBOX, MAIL_DOMAIN, OUTBOX, collection_href, etag_of, local_part, need_privilege,
28 principal_name, principal_uuid, seg,
29};
30use crate::api::common::blocking;
31use crate::db::{PimKind, PimObject, PimOp, PimPrincipal};
32use crate::error::{ApiError, AppState};
33
34/// Held from reading a calendar object to committing the change, so that a
35/// change and the writes it causes see a consistent store.
36// ponytail: one lock for every object write. Per-UID locks if write
37// throughput ever matters.
38pub(crate) static LOCK: Mutex<()> = Mutex::const_new(());
39
40/// The delivery status codes of RFC 6638, 3.2.9.
41const DELIVERED: &str = "1.2";
42/// An address in this server's domains that names no one.
43const INVALID_USER: &str = "3.7";
44/// An address outside this server: there is no iMIP to reach it.
45const NO_ROUTE: &str = "5.2";
46/// The recipient has no calendar for the component.
47const REFUSED: &str = "5.3";
48/// The recipient holds an object with this UID that is not its copy of the
49/// sender's meeting (RFC 6638: no scheduling privileges).
50const NO_AUTHORITY: &str = "3.8";
51
52/// Who writes into a calendar, as far as scheduling cares.
53pub(crate) struct Writer<'a> {
54 pub owner: &'a PimPrincipal,
55 /// May send messages as the owner (RFC 6638 `schedule-send`).
56 pub may_schedule: bool,
57 /// The writer's address when it is not the owner, for SENT-BY.
58 pub sent_by: Option<String>,
59}
60
61impl Writer<'_> {
62 /// The owner itself.
63 pub(crate) fn owner(owner: &PimPrincipal) -> Writer<'_> {
64 Writer {
65 owner,
66 may_schedule: true,
67 sent_by: None,
68 }
69 }
70
71 /// 403 `need-privileges` on the owner's outbox.
72 fn refused(&self, privilege: &str) -> Element {
73 let outbox = collection_href(&self.owner.name, PimKind::Calendar, OUTBOX, None);
74 need_privilege(&outbox, CALDAV, privilege)
75 }
76}
77
78/// Every principal, for mapping calendar user addresses.
79#[derive(Clone)]
80pub(crate) struct Directory(Vec<PimPrincipal>);
81
82enum Recipient<'a> {
83 Local(&'a PimPrincipal),
84 Unknown,
85 External,
86}
87
88fn found(p: Option<&PimPrincipal>) -> Recipient<'_> {
89 match p {
90 Some(p) => Recipient::Local(p),
91 None => Recipient::Unknown,
92 }
93}
94
95impl Directory {
96 pub(crate) async fn load(state: &AppState) -> Result<Self, ApiError> {
97 Ok(Directory(state.db.pim_principals().await?))
98 }
99
100 pub(crate) fn get(&self, id: i64) -> Option<&PimPrincipal> {
101 self.0.iter().find(|p| p.id == id)
102 }
103
104 /// With `p` added. [`Self::load`] leaves disabled accounts out.
105 pub(crate) fn with(mut self, p: &PimPrincipal) -> Self {
106 if self.get(p.id).is_none() {
107 self.0.push(p.clone());
108 }
109 self
110 }
111
112 /// The forms `calendar-user-address-set` lists: the mailto address, the
113 /// principal URL and the `urn:uuid:`. Compared without case.
114 fn resolve(&self, addr: &str) -> Recipient<'_> {
115 let addr = addr.trim();
116 let lower = addr.to_ascii_lowercase();
117 if let Some(rest) = lower.strip_prefix("mailto:") {
118 let Some((local, domain)) = rest.rsplit_once('@') else {
119 return Recipient::External;
120 };
121 let kind = match domain.strip_suffix(MAIL_DOMAIN) {
122 Some("") => UserType::Individual,
123 Some("rooms.") => UserType::Room,
124 Some("resources.") => UserType::Resource,
125 // The tombstone of a deleted principal.
126 Some("deleted.") => return Recipient::Unknown,
127 _ => return Recipient::External,
128 };
129 let name = percent_decode_str(local).decode_utf8_lossy();
130 return found(
131 self.0
132 .iter()
133 .find(|p| p.kind == kind && p.name.eq_ignore_ascii_case(&name)),
134 );
135 }
136 if let Some(uuid) = lower.strip_prefix("urn:uuid:") {
137 return found(self.0.iter().find(|p| principal_uuid(p.id) == uuid));
138 }
139 match principal_name(addr) {
140 Some(name) => found(self.0.iter().find(|p| p.name.eq_ignore_ascii_case(&name))),
141 None if lower.starts_with('/') || lower.starts_with("http") => Recipient::Unknown,
142 None => Recipient::External,
143 }
144 }
145
146 /// Whether an address names principal `id`.
147 pub(crate) fn is(&self, id: i64) -> impl Fn(&str) -> bool + '_ {
148 move |a: &str| matches!(self.resolve(a), Recipient::Local(p) if p.id == id)
149 }
150}
151
152/// The writes that make other principals' objects forget `gone` before it
153/// is deleted, after `retracted`, the writes of [`retract`]. Its addresses
154/// become a tombstone in `deleted.` of the mail domain, which names no one,
155/// so a later principal of the same name gets nothing meant for the old one.
156/// Commit them together with the delete, holding [`LOCK`].
157pub(crate) async fn forget(
158 state: &AppState,
159 gone: &PimPrincipal,
160 mut retracted: Vec<PimOp>,
161) -> Result<Vec<PimOp>, ApiError> {
162 let dir = Directory(vec![gone.clone()]);
163 let is_gone = dir.is(gone.id);
164 let encoded = local_part(&gone.name);
165 let tombstone = format!("mailto:{encoded}-{}@deleted.{MAIL_DOMAIN}", gone.id);
166 let uuid = principal_uuid(gone.id);
167 let needles = [gone.name.as_str(), encoded.as_str(), uuid.as_str()];
168 let rewrite = |data: &[u8]| {
169 itip::forget(&String::from_utf8_lossy(data), &is_gone, &tombstone).map(String::into_bytes)
170 };
171 let retracted_puts: HashSet<(i64, &str)> = retracted
172 .iter()
173 .filter_map(|op| match op {
174 PimOp::Put {
175 collection_id, obj, ..
176 } => Some((*collection_id, obj.name.as_str())),
177 _ => None,
178 })
179 .collect();
180 let mut ops = Vec::new();
181 for (collection_id, obj, data) in state.db.pim_objects_mentioning(gone.id, &needles).await? {
182 if retracted_puts.contains(&(collection_id, obj.name.as_str())) {
183 continue;
184 }
185 let Some(data) = rewrite(&data) else {
186 continue;
187 };
188 ops.push(PimOp::Put {
189 collection_id,
190 obj: PimObject {
191 etag: etag_of(&data),
192 ..obj
193 },
194 data,
195 });
196 }
197 for op in &mut retracted {
198 if let PimOp::Put { obj, data, .. } | PimOp::Inbox { obj, data, .. } = op
199 && let Some(new) = rewrite(data)
200 {
201 obj.etag = etag_of(&new);
202 *data = new;
203 }
204 }
205 // Last, because inbox writes drop the oldest messages; a rewrite after
206 // them would bring a dropped one back.
207 ops.extend(retracted);
208 Ok(ops)
209}
210
211/// What a PUT of a calendar object stores, and what else it writes.
212pub(crate) struct Stored {
213 pub data: Vec<u8>,
214 /// Whether `data` differs from the request body.
215 pub changed: bool,
216 pub schedule_tag: Option<String>,
217 pub ops: Vec<PimOp>,
218}
219
220/// A PUT of `body` over `old` into collection `at.0` under the name `at.1`.
221/// `Err` names a failed scheduling precondition.
222pub(crate) async fn put(
223 state: &AppState,
224 dir: &Directory,
225 w: &Writer<'_>,
226 at: (i64, &str),
227 old: Option<&[u8]>,
228 body: &[u8],
229) -> Result<Result<Stored, Element>, ApiError> {
230 let parse = |b: &[u8]| ICalendar::parse(String::from_utf8_lossy(b).as_ref()).ok();
231 let Some(sent) = parse(body) else {
232 return Ok(Ok(unchanged(body, None)));
233 };
234 let owner = w.owner;
235 let owns = dir.is(owner.id);
236 let role = match itip::role(&sent, &owns) {
237 Ok(r) => r,
238 Err(refused) => return Ok(Err(refused.condition())),
239 };
240 if role != Role::None
241 && let Some(holder) = elsewhere(state, owner, &sent, at).await?
242 {
243 return Ok(Err(holder));
244 }
245 let old = old.and_then(parse);
246 let old_role = old.as_ref().and_then(|o| itip::role(o, &owns).ok());
247 let now = Utc::now();
248 let mut ops = Vec::new();
249
250 let stored = match (role, old_role) {
251 (Role::Organizer, _) => {
252 let old = old.as_ref().filter(|_| old_role == Some(Role::Organizer));
253 let (mut store, force) = itip::prepare(old, &sent, &owns);
254 let mut messages = itip::messages(old, Some(&store), &owns, &force, now);
255 if !messages.is_empty() {
256 if !w.may_schedule {
257 return Ok(Err(w.refused("schedule-send-invite")));
258 }
259 itip::stamp_sender(&mut store, &owns, w.sent_by.as_deref());
260 messages = itip::messages(old, Some(&store), &owns, &force, now);
261 }
262 // Rooms answer first, so the others' copies carry their answers.
263 if answer_rooms(state, dir, owner, &mut store, &messages, &mut ops, now).await? {
264 messages = itip::messages(old, Some(&store), &owns, &force, now);
265 }
266 for m in &messages {
267 if let Some(status) = deliver(state, dir, owner, m, &mut ops).await? {
268 itip::set_attendee_status(&mut store, &m.to, status);
269 }
270 }
271 store
272 }
273 (Role::Attendee, Some(Role::Attendee)) => {
274 let old = old.as_ref().expect("an attendee role needs the old object");
275 let (mut store, reply) = match itip::attend(old, sent.clone(), &owns, now) {
276 Ok(v) => v,
277 Err(refused) => return Ok(Err(refused.condition())),
278 };
279 if let Some(mut reply) = reply {
280 if !w.may_schedule {
281 return Ok(Err(w.refused("schedule-send-reply")));
282 }
283 itip::stamp_sender(&mut store, &owns, w.sent_by.as_deref());
284 itip::stamp_sender(&mut reply.cal, &owns, w.sent_by.as_deref());
285 let status = reply_to(state, dir, owner, &reply, &mut ops).await?;
286 itip::set_organizer_status(&mut store, status);
287 }
288 store
289 }
290 // No longer a scheduling object, or a copy the attendee brings in
291 // itself (RFC 6638, 3.2.2.2): stored as sent.
292 (_, previous) => {
293 if let Some(old) = &old {
294 match removed(state, dir, w, old, previous, true).await? {
295 Ok(more) => ops.extend(more),
296 Err(refused) => return Ok(Err(refused)),
297 }
298 }
299 let tag = (role != Role::None).then(|| etag_of(body));
300 return Ok(Ok(Stored {
301 ops,
302 ..unchanged(body, tag)
303 }));
304 }
305 };
306 let changed = stored != sent;
307 let data = match changed {
308 true => stored.to_string().into_bytes(),
309 false => body.to_vec(),
310 };
311 Ok(Ok(Stored {
312 schedule_tag: Some(etag_of(&data)),
313 data,
314 changed,
315 ops,
316 }))
317}
318
319/// The Schedule-Tag an import stores with `body`, `None` for an object that
320/// schedules nothing. An import sends no messages: the object is stored as
321/// sent. `Err` names the precondition that refuses it.
322pub(crate) async fn import_tag(
323 state: &AppState,
324 dir: &Directory,
325 owner: &PimPrincipal,
326 at: (i64, &str),
327 body: &[u8],
328) -> Result<Result<Option<String>, Element>, ApiError> {
329 let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(body).as_ref()) else {
330 return Ok(Ok(None));
331 };
332 match itip::role(&cal, &dir.is(owner.id)) {
333 Err(refused) => Ok(Err(refused.condition())),
334 Ok(Role::None) => Ok(Ok(None)),
335 Ok(_) => Ok(match elsewhere(state, owner, &cal, at).await? {
336 Some(holder) => Err(holder),
337 None => Ok(Some(etag_of(body))),
338 }),
339 }
340}
341
342/// The resource name the server picks for an object it creates.
343pub(crate) fn object_name(uid: &str, kind: PimKind) -> String {
344 let ext = match kind {
345 PimKind::Calendar => "ics",
346 PimKind::AddressBook => "vcf",
347 };
348 format!("{}.{ext}", &crate::hex(&Sha256::digest(uid))[..32])
349}
350
351fn unchanged(body: &[u8], schedule_tag: Option<String>) -> Stored {
352 Stored {
353 data: body.to_vec(),
354 changed: false,
355 schedule_tag,
356 ops: Vec::new(),
357 }
358}
359
360/// The writes a DELETE of `old` causes. `reply` is false for
361/// `Schedule-Reply: F` (RFC 6638, 8.1). `Err` names a lacking privilege.
362pub(crate) async fn delete(
363 state: &AppState,
364 dir: &Directory,
365 w: &Writer<'_>,
366 old: &[u8],
367 reply: bool,
368) -> Result<Result<Vec<PimOp>, Element>, ApiError> {
369 let Ok(old) = ICalendar::parse(String::from_utf8_lossy(old).as_ref()) else {
370 return Ok(Ok(Vec::new()));
371 };
372 let role = itip::role(&old, &dir.is(w.owner.id)).ok();
373 removed(state, dir, w, &old, role, reply).await
374}
375
376/// The writes that cancel or decline every object of the collections for
377/// their attendees, as deleting each object would. Hold [`LOCK`].
378pub(crate) async fn retract(
379 state: &AppState,
380 dir: &Directory,
381 owner: &PimPrincipal,
382 collection_ids: &[i64],
383) -> Result<Result<Vec<PimOp>, Element>, ApiError> {
384 let w = Writer::owner(owner);
385 let mut ops = Vec::new();
386 for &id in collection_ids {
387 for (_, data) in state.db.pim_objects_with_data(id).await? {
388 match delete(state, dir, &w, &data, true).await? {
389 Ok(more) => ops.extend(more),
390 Err(refused) => return Ok(Err(refused)),
391 }
392 }
393 }
394 Ok(Ok(ops))
395}
396
397/// An organizer object going away cancels; an attendee copy declines.
398async fn removed(
399 state: &AppState,
400 dir: &Directory,
401 w: &Writer<'_>,
402 old: &ICalendar,
403 role: Option<Role>,
404 reply: bool,
405) -> Result<Result<Vec<PimOp>, Element>, ApiError> {
406 let owner = w.owner;
407 let owns = dir.is(owner.id);
408 let now = Utc::now();
409 let mut old = old.clone();
410 itip::stamp_sender(&mut old, &owns, w.sent_by.as_deref());
411 let mut ops = Vec::new();
412 match role {
413 Some(Role::Organizer) => {
414 let (_, messages) = itip::organize(Some(&old), None, &owns, now);
415 if !messages.is_empty() && !w.may_schedule {
416 return Ok(Err(w.refused("schedule-send-invite")));
417 }
418 for m in &messages {
419 deliver(state, dir, owner, m, &mut ops).await?;
420 }
421 }
422 Some(Role::Attendee) if reply => {
423 if let Some(m) = itip::decline(&old, &owns, now) {
424 if !w.may_schedule {
425 return Ok(Err(w.refused("schedule-send-reply")));
426 }
427 reply_to(state, dir, owner, &m, &mut ops).await?;
428 }
429 }
430 _ => {}
431 }
432 Ok(Ok(ops))
433}
434
435/// The resource of the owner that already schedules this UID elsewhere:
436/// RFC 6638 allows one per UID (3.2.4.1).
437async fn elsewhere(
438 state: &AppState,
439 owner: &PimPrincipal,
440 cal: &ICalendar,
441 (collection_id, name): (i64, &str),
442) -> Result<Option<Element>, ApiError> {
443 let Some((uid, _)) = identity(cal) else {
444 return Ok(None);
445 };
446 let Some((holder_id, holder, _)) = state.db.pim_find_uid(owner.id, &uid).await? else {
447 return Ok(None);
448 };
449 if holder_id == collection_id && holder.name == name {
450 return Ok(None);
451 }
452 let slug = match state.db.pim_collection_by_id(holder_id).await? {
453 Some((_, _, c)) => c.slug,
454 None => return Ok(None),
455 };
456 let href = collection_href(&owner.name, PimKind::Calendar, &slug, None) + &seg(&holder.name);
457 Ok(Some(with_children(
458 el(CALDAV, "unique-scheduling-object-resource"),
459 hrefs([href.as_str()]),
460 )))
461}
462
463/// Rooms and resources answer their invitations at once: accepted where
464/// free, declined where their bookings overlap. The answers go into the
465/// organizer's `store` and inbox. Returns whether any room answered.
466async fn answer_rooms(
467 state: &AppState,
468 dir: &Directory,
469 organizer: &PimPrincipal,
470 store: &mut ICalendar,
471 messages: &[Message],
472 ops: &mut Vec<PimOp>,
473 now: DateTime<Utc>,
474) -> Result<bool, ApiError> {
475 let mut answered = false;
476 for m in messages
477 .iter()
478 .filter(|m| m.method == Method::Request && !m.quiet)
479 {
480 let Recipient::Local(room) = dir.resolve(&m.to) else {
481 continue;
482 };
483 let Some((uid, component)) = identity(&m.cal) else {
484 continue;
485 };
486 if room.kind == UserType::Individual {
487 continue;
488 }
489 let Some(calendar) = state.db.pim_calendar_for(room.id, &component).await? else {
490 continue;
491 };
492 let Ok(copy) = copy_of(state, dir, room, organizer, &uid).await? else {
493 continue;
494 };
495 let Some(received) = itip::receive(copy.as_ref().map(|(_, _, c)| c), m) else {
496 continue;
497 };
498 let is_room = dir.is(room.id);
499 let window = now..now + itip::answer_horizon(&received);
500 let taken = busy_of(state, dir, room, &window, Some(&uid)).await?;
501 let floating = floating_of(calendar.timezone.as_deref());
502 let answer = itip::auto_answer(&received, &is_room, &taken, &window, &floating);
503 let Ok((_, Some(reply))) = itip::attend(&received, answer, &is_room, now) else {
504 continue;
505 };
506 answered |= itip::apply_reply(store, &reply.cal, &is_room);
507 ops.push(inbox(organizer, &reply, &component));
508 }
509 Ok(answered)
510}
511
512/// The busy time a principal shows to scheduling: its opaque calendars that
513/// take events, never the inbox. Objects with UID `skip` do not count.
514// ponytail: reads every object of those calendars per call. Keep busy
515// periods in a table if principals grow large calendars.
516pub(crate) async fn busy_of(
517 state: &AppState,
518 dir: &Directory,
519 p: &PimPrincipal,
520 range: &TimeRange,
521 skip: Option<&str>,
522) -> Result<Vec<Period>, ApiError> {
523 let mut calendars = Vec::new();
524 for c in state.db.pim_collections(p.id, PimKind::Calendar).await? {
525 if c.slug == INBOX || c.transparent || !c.components.split(',').any(|x| x == "VEVENT") {
526 continue;
527 }
528 let objects = state.db.pim_objects_with_data(c.id).await?;
529 calendars.push((floating_of(c.timezone.as_deref()), objects));
530 }
531 let (dir, id, range, skip) = (dir.clone(), p.id, range.clone(), skip.map(str::to_string));
532 blocking(move || -> Result<_, ApiError> {
533 let me = dir.is(id);
534 let mut busy = Vec::new();
535 for (floating, objects) in calendars {
536 for (o, data) in objects {
537 if skip.as_deref().is_some_and(|u| u == o.uid) {
538 continue;
539 }
540 if let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) {
541 busy.extend(freebusy::busy(&cal, &range, &floating, Some(&me)));
542 }
543 }
544 }
545 Ok(freebusy::merge(busy))
546 })
547 .await
548}
549
550fn floating_of(timezone: Option<&str>) -> Zone {
551 timezone.and_then(zone::from_vtimezone).unwrap_or(Zone::Utc)
552}
553
554/// The answers to a free-busy request to an outbox (RFC 6638, 5.2): per
555/// recipient its address, the REQUEST-STATUS and the VFREEBUSY reply.
556pub(crate) async fn free_busy(
557 state: &AppState,
558 dir: &Directory,
559 req: &freebusy::Request,
560) -> Result<Vec<(String, &'static str, Option<String>)>, ApiError> {
561 let now = Utc::now();
562 let mut out = Vec::new();
563 for to in &req.attendees {
564 let (status, data) = match dir.resolve(to) {
565 Recipient::Local(p) => {
566 let busy = busy_of(state, dir, p, &req.range, None).await?;
567 ("2.0;Success", Some(freebusy::reply(&busy, req, to, now)))
568 }
569 Recipient::Unknown => ("3.7;Invalid calendar user", None),
570 Recipient::External => ("5.2;Invalid calendar service", None),
571 };
572 out.push((to.clone(), status, data));
573 }
574 Ok(out)
575}
576
577/// A REQUEST or CANCEL from `sender` into the recipient's calendar and
578/// inbox. Returns the delivery status, `None` for the sender itself.
579async fn deliver(
580 state: &AppState,
581 dir: &Directory,
582 sender: &PimPrincipal,
583 m: &Message,
584 ops: &mut Vec<PimOp>,
585) -> Result<Option<&'static str>, ApiError> {
586 let p = match dir.resolve(&m.to) {
587 Recipient::Local(p) if p.id == sender.id => return Ok(None),
588 Recipient::Local(p) => p,
589 Recipient::Unknown => return Ok(Some(INVALID_USER)),
590 Recipient::External => return Ok(Some(NO_ROUTE)),
591 };
592 ensure(state, p).await?;
593 let Some((uid, component)) = identity(&m.cal) else {
594 return Ok(Some(REFUSED));
595 };
596 let Ok(copy) = copy_of(state, dir, p, sender, &uid).await? else {
597 return Ok(Some(NO_AUTHORITY));
598 };
599 if let Some(next) = itip::receive(copy.as_ref().map(|(_, _, c)| c), m) {
600 let data = next.to_string().into_bytes();
601 let etag = etag_of(&data);
602 let (collection_id, name, schedule_tag) = match copy {
603 // Only the others' answers changed: the attendee's pending edit
604 // may still go through (RFC 6638, 3.2.10).
605 Some((id, obj, _)) => (
606 id,
607 obj.name,
608 if m.quiet {
609 obj.schedule_tag
610 } else {
611 Some(etag.clone())
612 },
613 ),
614 None => match state.db.pim_calendar_for(p.id, &component).await? {
615 Some(c) => (
616 c.id,
617 object_name(&uid, PimKind::Calendar),
618 Some(etag.clone()),
619 ),
620 None => return Ok(Some(REFUSED)),
621 },
622 };
623 ops.push(PimOp::Put {
624 collection_id,
625 obj: PimObject {
626 name,
627 uid,
628 component: component.clone(),
629 etag,
630 schedule_tag,
631 ..Default::default()
632 },
633 data,
634 });
635 }
636 if !m.quiet {
637 ops.push(inbox(p, m, &component));
638 }
639 Ok(Some(DELIVERED))
640}
641
642/// An attendee's REPLY: applied to the organizer's object, passed on to the
643/// other attendees, and left in the organizer's inbox. Returns the delivery
644/// status for the attendee's copy.
645async fn reply_to(
646 state: &AppState,
647 dir: &Directory,
648 attendee: &PimPrincipal,
649 m: &Message,
650 ops: &mut Vec<PimOp>,
651) -> Result<&'static str, ApiError> {
652 let organizer = match dir.resolve(&m.to) {
653 Recipient::Local(p) => p,
654 Recipient::Unknown => return Ok(INVALID_USER),
655 Recipient::External => return Ok(NO_ROUTE),
656 };
657 let Some((uid, component)) = identity(&m.cal) else {
658 return Ok(REFUSED);
659 };
660 // RFC 6638, 4.2: a reply to an object the organizer no longer has is
661 // ignored.
662 let Some((collection_id, obj, data)) = state.db.pim_find_uid(organizer.id, &uid).await? else {
663 return Ok(NO_ROUTE);
664 };
665 let Ok(before) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) else {
666 return Ok(NO_ROUTE);
667 };
668 // A UID alone proves nothing: only the organizer's own object takes it.
669 if !matches!(
670 itip::role(&before, &dir.is(organizer.id)),
671 Ok(Role::Organizer)
672 ) {
673 return Ok(NO_AUTHORITY);
674 }
675 let replier = dir.is(attendee.id);
676 if !matches!(itip::role(&before, &replier), Ok(Role::Attendee)) {
677 return Ok(NO_AUTHORITY);
678 }
679 let mut after = before.clone();
680 if itip::apply_reply(&mut after, &m.cal, &replier) {
681 let data = after.to_string().into_bytes();
682 ops.push(PimOp::Put {
683 collection_id,
684 obj: PimObject {
685 etag: etag_of(&data),
686 ..obj
687 },
688 data,
689 });
690 // The others learn the new answer without a new Schedule-Tag.
691 let organizes = dir.is(organizer.id);
692 for mut other in itip::messages(Some(&before), Some(&after), &organizes, &[], Utc::now()) {
693 if other.method == Method::Request && !replier(&other.to) {
694 other.quiet = true;
695 deliver(state, dir, organizer, &other, ops).await?;
696 }
697 }
698 }
699 ops.push(inbox(organizer, m, &component));
700 Ok(DELIVERED)
701}
702
703/// Whether `copy` is `p`'s attendee copy of a meeting `organizer` runs. A
704/// message may change only that: anyone can pick any UID.
705fn attends(dir: &Directory, copy: &ICalendar, p: &PimPrincipal, organizer: &PimPrincipal) -> bool {
706 matches!(itip::role(copy, &dir.is(p.id)), Ok(Role::Attendee))
707 && itip::organizer(copy).is_some_and(dir.is(organizer.id))
708}
709
710/// `p`'s copy of the meeting with `uid` and where it is stored. `Err` if
711/// `p` holds that UID in an object the message may not touch.
712async fn copy_of(
713 state: &AppState,
714 dir: &Directory,
715 p: &PimPrincipal,
716 organizer: &PimPrincipal,
717 uid: &str,
718) -> Result<Result<Option<(i64, PimObject, ICalendar)>, ()>, ApiError> {
719 let Some((id, obj, data)) = state.db.pim_find_uid(p.id, uid).await? else {
720 return Ok(Ok(None));
721 };
722 Ok(
723 match ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) {
724 Ok(c) if attends(dir, &c, p, organizer) => Ok(Some((id, obj, c))),
725 _ => Err(()),
726 },
727 )
728}
729
730async fn ensure(state: &AppState, p: &PimPrincipal) -> Result<(), ApiError> {
731 match p.kind {
732 UserType::Individual => state.db.pim_ensure_defaults(p.id).await?,
733 _ => state.db.pim_ensure_inbox(p.id).await?,
734 }
735 Ok(())
736}
737
738fn inbox(p: &PimPrincipal, m: &Message, component: &str) -> PimOp {
739 let data = m.cal.to_string().into_bytes();
740 let stamp = Utc::now().timestamp_nanos_opt().unwrap_or_default();
741 let seed = format!(
742 "{}\n{}\n{stamp}\n{:?}",
743 m.to,
744 String::from_utf8_lossy(&data),
745 m.method
746 );
747 let name = format!("{}.ics", &crate::hex(&Sha256::digest(seed))[..32]);
748 PimOp::Inbox {
749 principal_id: p.id,
750 obj: PimObject {
751 // Inbox messages share UIDs, and the store keeps UIDs unique.
752 uid: name.clone(),
753 name,
754 component: component.to_string(),
755 etag: etag_of(&data),
756 ..Default::default()
757 },
758 data,
759 }
760}
761
762/// UID and component type of a scheduling message or object.
763fn identity(cal: &ICalendar) -> Option<(String, String)> {
764 let c = cal.components.iter().find(|c| {
765 matches!(
766 c.component_type,
767 ICalendarComponentType::VEvent
768 | ICalendarComponentType::VTodo
769 | ICalendarComponentType::VJournal
770 )
771 })?;
772 Some((c.uid()?.to_string(), c.component_type.as_str().to_string()))
773}
774