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
8use chrono::Utc;
9use percent_encoding::percent_decode_str;
10use pimdav::calcard::icalendar::{ICalendar, ICalendarComponentType};
11use pimdav::itip::{self, Message, Method, Role};
12use pimdav::principal::UserType;
13use sha2::{Digest, Sha256};
14use tokio::sync::Mutex;
15use xmltree::Element;
16
17use super::pim::{MAIL_DOMAIN, etag_of, principal_name, principal_uuid};
18use crate::db::{PimObject, PimOp, PimPrincipal};
19use crate::error::{ApiError, AppState};
20
21/// Held from reading a calendar object to committing the change, so that a
22/// change and the writes it causes see a consistent store.
23// ponytail: one lock for every object write. Per-UID locks if write
24// throughput ever matters.
25pub(crate) static LOCK: Mutex<()> = Mutex::const_new(());
26
27/// The delivery status codes of RFC 6638, 3.2.9.
28const DELIVERED: &str = "1.2";
29/// An address in this server's domains that names no one.
30const INVALID_USER: &str = "3.7";
31/// An address outside this server: there is no iMIP to reach it.
32const NO_ROUTE: &str = "5.2";
33/// The recipient has no calendar for the component.
34const REFUSED: &str = "5.3";
35
36/// Every principal, for mapping calendar user addresses.
37pub(crate) struct Directory(Vec<PimPrincipal>);
38
39enum Recipient<'a> {
40 Local(&'a PimPrincipal),
41 Unknown,
42 External,
43}
44
45fn found(p: Option<&PimPrincipal>) -> Recipient<'_> {
46 match p {
47 Some(p) => Recipient::Local(p),
48 None => Recipient::Unknown,
49 }
50}
51
52impl Directory {
53 pub(crate) async fn load(state: &AppState) -> Result<Self, ApiError> {
54 Ok(Directory(state.db.pim_principals().await?))
55 }
56
57 pub(crate) fn get(&self, id: i64) -> Option<&PimPrincipal> {
58 self.0.iter().find(|p| p.id == id)
59 }
60
61 /// The forms `calendar-user-address-set` lists: the mailto address, the
62 /// principal URL and the `urn:uuid:`. Compared without case.
63 fn resolve(&self, addr: &str) -> Recipient<'_> {
64 let addr = addr.trim();
65 let lower = addr.to_ascii_lowercase();
66 if let Some(rest) = lower.strip_prefix("mailto:") {
67 let Some((local, domain)) = rest.rsplit_once('@') else {
68 return Recipient::External;
69 };
70 let kind = match domain.strip_suffix(MAIL_DOMAIN) {
71 Some("") => UserType::Individual,
72 Some("rooms.") => UserType::Room,
73 Some("resources.") => UserType::Resource,
74 _ => return Recipient::External,
75 };
76 let name = percent_decode_str(local).decode_utf8_lossy();
77 return found(
78 self.0
79 .iter()
80 .find(|p| p.kind == kind && p.name.eq_ignore_ascii_case(&name)),
81 );
82 }
83 if let Some(uuid) = lower.strip_prefix("urn:uuid:") {
84 return found(self.0.iter().find(|p| principal_uuid(p.id) == uuid));
85 }
86 match principal_name(addr) {
87 Some(name) => found(self.0.iter().find(|p| p.name.eq_ignore_ascii_case(&name))),
88 None if lower.starts_with('/') || lower.starts_with("http") => Recipient::Unknown,
89 None => Recipient::External,
90 }
91 }
92
93 /// Whether an address names principal `id`.
94 pub(crate) fn is(&self, id: i64) -> impl Fn(&str) -> bool + '_ {
95 move |a: &str| matches!(self.resolve(a), Recipient::Local(p) if p.id == id)
96 }
97}
98
99/// What a PUT of a calendar object stores, and what else it writes.
100pub(crate) struct Stored {
101 pub data: Vec<u8>,
102 /// Whether `data` differs from the request body.
103 pub changed: bool,
104 pub schedule_tag: Option<String>,
105 pub ops: Vec<PimOp>,
106}
107
108/// A PUT of `body` over `old` in a calendar of `owner`. `Err` names a failed
109/// scheduling precondition.
110pub(crate) async fn put(
111 state: &AppState,
112 dir: &Directory,
113 owner: &PimPrincipal,
114 old: Option<&[u8]>,
115 body: &[u8],
116) -> Result<Result<Stored, Element>, ApiError> {
117 let parse = |b: &[u8]| ICalendar::parse(String::from_utf8_lossy(b).as_ref()).ok();
118 let Some(sent) = parse(body) else {
119 return Ok(Ok(unchanged(body, None)));
120 };
121 let owns = dir.is(owner.id);
122 let role = match itip::role(&sent, &owns) {
123 Ok(r) => r,
124 Err(refused) => return Ok(Err(refused.condition())),
125 };
126 let old = old.and_then(parse);
127 let old_role = old.as_ref().and_then(|o| itip::role(o, &owns).ok());
128 let now = Utc::now();
129 let mut ops = Vec::new();
130
131 let stored = match (role, old_role) {
132 (Role::Organizer, _) => {
133 let old = old.as_ref().filter(|_| old_role == Some(Role::Organizer));
134 let (store, messages) = itip::organize(old, Some(sent.clone()), &owns, now);
135 let mut store = store.expect("a PUT stores");
136 for m in &messages {
137 if let Some(status) = deliver(state, dir, owner, m, &mut ops).await? {
138 itip::set_attendee_status(&mut store, &m.to, status);
139 }
140 }
141 store
142 }
143 (Role::Attendee, Some(Role::Attendee)) => {
144 let old = old.as_ref().expect("an attendee role needs the old object");
145 let (mut store, reply) = match itip::attend(old, sent.clone(), &owns, now) {
146 Ok(v) => v,
147 Err(refused) => return Ok(Err(refused.condition())),
148 };
149 if let Some(reply) = reply {
150 let status = reply_to(state, dir, owner, &reply, &mut ops).await?;
151 itip::set_organizer_status(&mut store, status);
152 }
153 store
154 }
155 // No longer a scheduling object, or a copy the attendee brings in
156 // itself (RFC 6638, 3.2.2.2): stored as sent.
157 (_, previous) => {
158 if let Some(old) = &old {
159 ops.extend(removed(state, dir, owner, old, previous, true).await?);
160 }
161 let tag = (role != Role::None).then(|| etag_of(body));
162 return Ok(Ok(Stored {
163 ops,
164 ..unchanged(body, tag)
165 }));
166 }
167 };
168 let changed = stored != sent;
169 let data = match changed {
170 true => stored.to_string().into_bytes(),
171 false => body.to_vec(),
172 };
173 Ok(Ok(Stored {
174 schedule_tag: Some(etag_of(&data)),
175 data,
176 changed,
177 ops,
178 }))
179}
180
181fn unchanged(body: &[u8], schedule_tag: Option<String>) -> Stored {
182 Stored {
183 data: body.to_vec(),
184 changed: false,
185 schedule_tag,
186 ops: Vec::new(),
187 }
188}
189
190/// The writes a DELETE of `old` from a calendar of `owner` causes. `reply`
191/// is false for `Schedule-Reply: F` (RFC 6638, 8.1).
192pub(crate) async fn delete(
193 state: &AppState,
194 dir: &Directory,
195 owner: &PimPrincipal,
196 old: &[u8],
197 reply: bool,
198) -> Result<Vec<PimOp>, ApiError> {
199 let Ok(old) = ICalendar::parse(String::from_utf8_lossy(old).as_ref()) else {
200 return Ok(Vec::new());
201 };
202 let role = itip::role(&old, &dir.is(owner.id)).ok();
203 removed(state, dir, owner, &old, role, reply).await
204}
205
206/// An organizer object going away cancels; an attendee copy declines.
207async fn removed(
208 state: &AppState,
209 dir: &Directory,
210 owner: &PimPrincipal,
211 old: &ICalendar,
212 role: Option<Role>,
213 reply: bool,
214) -> Result<Vec<PimOp>, ApiError> {
215 let owns = dir.is(owner.id);
216 let now = Utc::now();
217 let mut ops = Vec::new();
218 match role {
219 Some(Role::Organizer) => {
220 let (_, messages) = itip::organize(Some(old), None, &owns, now);
221 for m in &messages {
222 deliver(state, dir, owner, m, &mut ops).await?;
223 }
224 }
225 Some(Role::Attendee) if reply => {
226 if let Some(m) = itip::decline(old, &owns, now) {
227 reply_to(state, dir, owner, &m, &mut ops).await?;
228 }
229 }
230 _ => {}
231 }
232 Ok(ops)
233}
234
235/// A REQUEST or CANCEL from `sender` into the recipient's calendar and
236/// inbox. Returns the delivery status, `None` for the sender itself.
237async fn deliver(
238 state: &AppState,
239 dir: &Directory,
240 sender: &PimPrincipal,
241 m: &Message,
242 ops: &mut Vec<PimOp>,
243) -> Result<Option<&'static str>, ApiError> {
244 let p = match dir.resolve(&m.to) {
245 Recipient::Local(p) if p.id == sender.id => return Ok(None),
246 Recipient::Local(p) => p,
247 Recipient::Unknown => return Ok(Some(INVALID_USER)),
248 Recipient::External => return Ok(Some(NO_ROUTE)),
249 };
250 ensure(state, p).await?;
251 let Some((uid, component)) = identity(&m.cal) else {
252 return Ok(Some(REFUSED));
253 };
254 let copy = state.db.pim_find_uid(p.id, &uid).await?;
255 let current = copy
256 .as_ref()
257 .and_then(|(_, _, d)| ICalendar::parse(String::from_utf8_lossy(d).as_ref()).ok());
258 if let Some(next) = itip::receive(current.as_ref(), m) {
259 let data = next.to_string().into_bytes();
260 let etag = etag_of(&data);
261 let (collection_id, name, schedule_tag) = match copy {
262 // Only the others' answers changed: the attendee's pending edit
263 // may still go through (RFC 6638, 3.2.10).
264 Some((id, obj, _)) => (
265 id,
266 obj.name,
267 if m.quiet {
268 obj.schedule_tag
269 } else {
270 Some(etag.clone())
271 },
272 ),
273 None => match state.db.pim_calendar_for(p.id, &component).await? {
274 Some(c) => (
275 c.id,
276 format!("{}.ics", &crate::hex(&Sha256::digest(&uid))[..32]),
277 Some(etag.clone()),
278 ),
279 None => return Ok(Some(REFUSED)),
280 },
281 };
282 ops.push(PimOp::Put {
283 collection_id,
284 obj: PimObject {
285 name,
286 uid,
287 component: component.clone(),
288 etag,
289 schedule_tag,
290 ..Default::default()
291 },
292 data,
293 });
294 }
295 if !m.quiet {
296 ops.push(inbox(p, m, &component));
297 }
298 Ok(Some(DELIVERED))
299}
300
301/// An attendee's REPLY: applied to the organizer's object, passed on to the
302/// other attendees, and left in the organizer's inbox. Returns the delivery
303/// status for the attendee's copy.
304async fn reply_to(
305 state: &AppState,
306 dir: &Directory,
307 attendee: &PimPrincipal,
308 m: &Message,
309 ops: &mut Vec<PimOp>,
310) -> Result<&'static str, ApiError> {
311 let organizer = match dir.resolve(&m.to) {
312 Recipient::Local(p) => p,
313 Recipient::Unknown => return Ok(INVALID_USER),
314 Recipient::External => return Ok(NO_ROUTE),
315 };
316 let Some((uid, component)) = identity(&m.cal) else {
317 return Ok(REFUSED);
318 };
319 // RFC 6638, 4.2: a reply to an object the organizer no longer has is
320 // ignored.
321 let Some((collection_id, obj, data)) = state.db.pim_find_uid(organizer.id, &uid).await? else {
322 return Ok(NO_ROUTE);
323 };
324 let Ok(before) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) else {
325 return Ok(NO_ROUTE);
326 };
327 let mut after = before.clone();
328 let replier = dir.is(attendee.id);
329 if itip::apply_reply(&mut after, &m.cal, &replier) {
330 let data = after.to_string().into_bytes();
331 ops.push(PimOp::Put {
332 collection_id,
333 obj: PimObject {
334 etag: etag_of(&data),
335 ..obj
336 },
337 data,
338 });
339 // The others learn the new answer without a new Schedule-Tag.
340 let organizes = dir.is(organizer.id);
341 for mut other in itip::messages(Some(&before), Some(&after), &organizes, &[], Utc::now()) {
342 if other.method == Method::Request && !replier(&other.to) {
343 other.quiet = true;
344 deliver(state, dir, organizer, &other, ops).await?;
345 }
346 }
347 }
348 ops.push(inbox(organizer, m, &component));
349 Ok(DELIVERED)
350}
351
352async fn ensure(state: &AppState, p: &PimPrincipal) -> Result<(), ApiError> {
353 match p.kind {
354 UserType::Individual => state.db.pim_ensure_defaults(p.id).await?,
355 _ => state.db.pim_ensure_inbox(p.id).await?,
356 }
357 Ok(())
358}
359
360fn inbox(p: &PimPrincipal, m: &Message, component: &str) -> PimOp {
361 let data = m.cal.to_string().into_bytes();
362 let stamp = Utc::now().timestamp_nanos_opt().unwrap_or_default();
363 let seed = format!(
364 "{}\n{}\n{stamp}\n{:?}",
365 m.to,
366 String::from_utf8_lossy(&data),
367 m.method
368 );
369 let name = format!("{}.ics", &crate::hex(&Sha256::digest(seed))[..32]);
370 PimOp::Inbox {
371 principal_id: p.id,
372 obj: PimObject {
373 // Inbox messages share UIDs, and the store keeps UIDs unique.
374 uid: name.clone(),
375 name,
376 component: component.to_string(),
377 etag: etag_of(&data),
378 ..Default::default()
379 },
380 data,
381 }
382}
383
384/// UID and component type of a scheduling message or object.
385fn identity(cal: &ICalendar) -> Option<(String, String)> {
386 let c = cal.components.iter().find(|c| {
387 matches!(
388 c.component_type,
389 ICalendarComponentType::VEvent
390 | ICalendarComponentType::VTodo
391 | ICalendarComponentType::VJournal
392 )
393 })?;
394 Some((c.uid()?.to_string(), c.component_type.as_str().to_string()))
395}
396