pim.rs
⎇
Raw
1//! CalDAV and CardDAV.
2//!
3//! URL layout under [`PIM`]:
4//!
5//! * `/principals/{user}/`
6//! * `/calendars/{user}/` and `/addressbooks/{user}/`, the homes
7//! * `/calendars/{user}/{collection}/` and `.../{collection}/{object}`, the
8//! same for address books
9//!
10//! The protocol logic is `pimdav`'s. This module authenticates, maps URLs onto
11//! the store and assembles the responses.
12
13use std::sync::Arc;
14
15use api_types::PIM;
16use axum::body::Body;
17use axum::extract::State;
18use axum::http::header::{ALLOW, CONTENT_TYPE, ETAG, LOCATION};
19use axum::http::{HeaderMap, Method, Request, Response, StatusCode};
20use axum::response::IntoResponse;
21use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
22use pimdav::calcard::icalendar::ICalendar;
23use pimdav::calcard::vcard::VCard;
24use pimdav::render::{self, TooManyInstances};
25use pimdav::report::{self, Props, Refused, Report};
26use pimdav::xml::{
27 self, APPLE, CALDAV, CALSERVER, CARDDAV, DAV, Name, Propfind, Update, el, hrefs, with_attr,
28 with_children, with_text,
29};
30use pimdav::zone::{self, Zone};
31use pimdav::{filter, freebusy, object};
32use sha2::{Digest, Sha256};
33use xmltree::Element;
34
35use crate::db::{PimCollection, PimKind, PimObject, PimWrite, Precondition};
36use crate::error::{ApiError, AppState};
37
38/// Largest object a PUT may store. Contacts carry photos inline.
39const MAX_RESOURCE_SIZE: usize = 10 * 1024 * 1024;
40
41/// Largest XML request body.
42const MAX_XML_SIZE: usize = 1024 * 1024;
43
44/// The domain of the addresses users schedule with. `.invalid` is reserved
45/// (RFC 2606), so nothing sent there can reach anyone.
46const MAIL_DOMAIN: &str = "filebrowser.invalid";
47
48/// Characters escaped in an href segment.
49const SEGMENT: &AsciiSet = &CONTROLS
50 .add(b' ')
51 .add(b'"')
52 .add(b'#')
53 .add(b'%')
54 .add(b'/')
55 .add(b'<')
56 .add(b'>')
57 .add(b'?')
58 .add(b'[')
59 .add(b']')
60 .add(b'`')
61 .add(b'{')
62 .add(b'}');
63
64type Reply = Result<Response<Body>, ApiError>;
65
66/// `{WELL_KNOWN_CALDAV}` and `{WELL_KNOWN_CARDDAV}`.
67pub async fn well_known() -> Response<Body> {
68 (
69 StatusCode::MOVED_PERMANENTLY,
70 [(LOCATION, format!("{PIM}/"))],
71 )
72 .into_response()
73}
74
75/// `{PIM}` and everything under it.
76pub async fn handle(State(state): State<Arc<AppState>>, req: Request<Body>) -> Response<Body> {
77 let Some((user_id, _)) = super::dav::authenticate(&state, req.headers()).await else {
78 return super::dav::challenge();
79 };
80 serve(&state, user_id, req)
81 .await
82 .unwrap_or_else(IntoResponse::into_response)
83}
84
85/// The signed-in user.
86struct Me {
87 id: i64,
88 name: String,
89 /// The user segment of the hrefs: the name as the request spelled it.
90 /// A client that asked for `/ALICE/` must get hrefs it recognises.
91 path: String,
92}
93
94impl Me {
95 fn principal(&self) -> String {
96 format!("{PIM}/principals/{}/", seg(&self.path))
97 }
98
99 fn home(&self, kind: PimKind) -> String {
100 format!("{PIM}/{}/{}/", kind_segment(kind), seg(&self.path))
101 }
102
103 fn collection(&self, kind: PimKind, slug: &str) -> String {
104 format!("{}{}/", self.home(kind), seg(slug))
105 }
106
107 fn object(&self, kind: PimKind, slug: &str, name: &str) -> String {
108 format!("{}{}", self.collection(kind, slug), seg(name))
109 }
110}
111
112async fn serve(state: &AppState, user_id: i64, req: Request<Body>) -> Reply {
113 let Some(name) = state.db.user_name(user_id).await? else {
114 return Ok(status(StatusCode::UNAUTHORIZED));
115 };
116 let path = req.uri().path().strip_prefix(PIM).unwrap_or_default();
117 let Some(target) = parse_target(path) else {
118 return Ok(status(StatusCode::NOT_FOUND));
119 };
120 // ponytail: own resources only. Sharing between users comes with the
121 // access model.
122 if target
123 .owner()
124 .is_some_and(|o| !o.eq_ignore_ascii_case(&name))
125 {
126 return Ok(status(StatusCode::FORBIDDEN));
127 }
128 let me = Me {
129 id: user_id,
130 path: target.owner().unwrap_or(&name).to_string(),
131 name,
132 };
133 state.db.pim_ensure_defaults(me.id).await?;
134
135 let method = req.method().clone();
136 let (parts, body) = req.into_parts();
137 match method.as_str() {
138 "OPTIONS" => Ok(options()),
139 "PROPFIND" => propfind(state, &me, &target, &parts.headers, body).await,
140 "PROPPATCH" => proppatch(state, &me, &target, body).await,
141 "MKCALENDAR" | "MKCOL" => mkcol(state, &me, &target, method.as_str(), body).await,
142 "GET" | "HEAD" => get(state, &me, &target, method == Method::HEAD).await,
143 "PUT" => put(state, &me, &target, &parts.headers, body).await,
144 "DELETE" => delete(state, &me, &target, &parts.headers).await,
145 "REPORT" => report(state, &me, &target, body).await,
146 "MOVE" => move_object(state, &me, &target, &parts.headers).await,
147 _ => Ok(status(StatusCode::METHOD_NOT_ALLOWED)),
148 }
149}
150
151#[derive(Debug)]
152enum Target {
153 Root,
154 Principal(String),
155 Home(PimKind, String),
156 Collection(PimKind, String, String),
157 Object(PimKind, String, String, String),
158}
159
160impl Target {
161 fn owner(&self) -> Option<&str> {
162 match self {
163 Target::Root => None,
164 Target::Principal(u)
165 | Target::Home(_, u)
166 | Target::Collection(_, u, _)
167 | Target::Object(_, u, _, _) => Some(u),
168 }
169 }
170}
171
172fn parse_target(path: &str) -> Option<Target> {
173 let segs = path
174 .split('/')
175 .filter(|s| !s.is_empty())
176 .map(|s| {
177 let s = percent_decode_str(s).decode_utf8().ok()?;
178 (s != "." && s != "..").then(|| s.into_owned())
179 })
180 .collect::<Option<Vec<_>>>()?;
181 let kind = |s: &str| match s {
182 "calendars" => Some(PimKind::Calendar),
183 "addressbooks" => Some(PimKind::AddressBook),
184 _ => None,
185 };
186 let mut it = segs.into_iter();
187 let Some(first) = it.next() else {
188 return Some(Target::Root);
189 };
190 let rest: Vec<String> = it.collect();
191 if first == "principals" {
192 return match <[String; 1]>::try_from(rest) {
193 Ok([user]) => Some(Target::Principal(user)),
194 Err(_) => None,
195 };
196 }
197 let kind = kind(&first)?;
198 let mut rest = rest.into_iter();
199 Some(match (rest.next(), rest.next(), rest.next(), rest.next()) {
200 (Some(u), None, None, None) => Target::Home(kind, u),
201 (Some(u), Some(c), None, None) => Target::Collection(kind, u, c),
202 (Some(u), Some(c), Some(o), None) => Target::Object(kind, u, c, o),
203 _ => return None,
204 })
205}
206
207fn kind_segment(kind: PimKind) -> &'static str {
208 match kind {
209 PimKind::Calendar => "calendars",
210 PimKind::AddressBook => "addressbooks",
211 }
212}
213
214fn seg(s: &str) -> String {
215 utf8_percent_encode(s, SEGMENT).to_string()
216}
217
218fn status(code: StatusCode) -> Response<Body> {
219 code.into_response()
220}
221
222fn xml_response(code: StatusCode, body: String) -> Response<Body> {
223 (
224 code,
225 [(CONTENT_TYPE, "application/xml; charset=utf-8")],
226 body,
227 )
228 .into_response()
229}
230
231/// A failed precondition, named in a `<d:error>` body.
232fn error(code: StatusCode, condition: Element) -> Response<Body> {
233 xml_response(code, xml::error(condition))
234}
235
236fn options() -> Response<Body> {
237 (
238 StatusCode::OK,
239 [
240 ("dav", "1, 3, calendar-access, addressbook, extended-mkcol"),
241 (
242 ALLOW.as_str(),
243 "OPTIONS, GET, HEAD, PUT, DELETE, MOVE, PROPFIND, PROPPATCH, MKCALENDAR, MKCOL, REPORT",
244 ),
245 ],
246 )
247 .into_response()
248}
249
250async fn read_body(body: Body, limit: usize) -> Option<axum::body::Bytes> {
251 axum::body::to_bytes(body, limit).await.ok()
252}
253
254// ---------------------------------------------------------------------------
255// PROPFIND
256// ---------------------------------------------------------------------------
257
258/// A resource PROPFIND can describe.
259enum Res {
260 Root,
261 Principal,
262 Home,
263 Collection(PimKind, PimCollection),
264 Object(PimKind, PimObject),
265}
266
267async fn propfind(
268 state: &AppState,
269 me: &Me,
270 target: &Target,
271 headers: &HeaderMap,
272 body: Body,
273) -> Reply {
274 // Missing means infinity to RFC 4918, but clients that omit it mean 0.
275 let deep = match headers.get("depth").and_then(|v| v.to_str().ok()) {
276 None | Some("0") => false,
277 Some("1") => true,
278 Some(_) => {
279 return Ok(error(
280 StatusCode::FORBIDDEN,
281 el(DAV, "propfind-finite-depth"),
282 ));
283 }
284 };
285 let Some(body) = read_body(body, MAX_XML_SIZE).await else {
286 return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
287 };
288 let Ok(request) = xml::propfind(&body) else {
289 return Ok(status(StatusCode::BAD_REQUEST));
290 };
291
292 let mut list: Vec<(String, Res)> = Vec::new();
293 match target {
294 Target::Root => list.push((format!("{PIM}/"), Res::Root)),
295 Target::Principal(_) => list.push((me.principal(), Res::Principal)),
296 Target::Home(kind, _) => {
297 list.push((me.home(*kind), Res::Home));
298 if deep {
299 for c in state.db.pim_collections(me.id, *kind).await? {
300 list.push((me.collection(*kind, &c.slug), Res::Collection(*kind, c)));
301 }
302 }
303 }
304 Target::Collection(kind, _, slug) => {
305 let Some(c) = state.db.pim_collection(me.id, *kind, slug).await? else {
306 return Ok(status(StatusCode::NOT_FOUND));
307 };
308 if deep {
309 for o in state.db.pim_objects(c.id).await? {
310 let href = me.object(*kind, &c.slug, &o.name);
311 list.push((href, Res::Object(*kind, o)));
312 }
313 }
314 list.insert(
315 0,
316 (me.collection(*kind, &c.slug), Res::Collection(*kind, c)),
317 );
318 }
319 Target::Object(kind, _, slug, name) => {
320 let found = match state.db.pim_collection(me.id, *kind, slug).await? {
321 Some(c) => state.db.pim_object(c.id, name).await?,
322 None => None,
323 };
324 let Some((o, _)) = found else {
325 return Ok(status(StatusCode::NOT_FOUND));
326 };
327 list.push((me.object(*kind, slug, name), Res::Object(*kind, o)));
328 }
329 }
330
331 let responses: Vec<xml::Response> = list
332 .into_iter()
333 .map(|(href, res)| select(href, &request, props(me, &res)))
334 .collect();
335 Ok(multistatus(&responses, None))
336}
337
338/// The response for one resource: the requested ones of `all`, and 404 for
339/// those it lacks.
340fn select(href: String, request: &Propfind, all: Vec<Element>) -> xml::Response {
341 let mut r = xml::Response::new(href);
342 match request {
343 Propfind::AllProp(_) => all.into_iter().for_each(|p| r.push(200, p)),
344 Propfind::PropName => all.iter().for_each(|p| r.push(200, Name::of(p).element())),
345 Propfind::Prop(names) => {
346 for n in names {
347 match all.iter().find(|p| Name::of(p) == *n) {
348 Some(p) => r.push(200, p.clone()),
349 None => r.push(404, n.element()),
350 }
351 }
352 }
353 }
354 if r.propstats.is_empty() {
355 r.status = Some(200);
356 }
357 r
358}
359
360fn multistatus(responses: &[xml::Response], tail: Option<Element>) -> Response<Body> {
361 xml_response(
362 StatusCode::MULTI_STATUS,
363 xml::multistatus_with(&Name::new(DAV, "multistatus"), responses, tail),
364 )
365}
366
367/// Every live property of a resource, with its value.
368fn props(me: &Me, res: &Res) -> Vec<Element> {
369 let text = |ns: &str, local: &str, v: &str| with_text(el(ns, local), v);
370 let href_prop = |ns: &str, local: &str, h: &str| with_children(el(ns, local), hrefs([h]));
371 let resourcetype = |types: &[(&str, &str)]| {
372 with_children(
373 el(DAV, "resourcetype"),
374 types.iter().map(|(ns, l)| el(ns, l)),
375 )
376 };
377 let mut out = vec![href_prop(DAV, "current-user-principal", &me.principal())];
378 match res {
379 Res::Root => out.push(resourcetype(&[(DAV, "collection")])),
380 Res::Principal => {
381 let principal = me.principal();
382 let addresses = [
383 format!("mailto:{}@{MAIL_DOMAIN}", seg(&me.name)),
384 principal.clone(),
385 format!("urn:uuid:{}", principal_uuid(me.id)),
386 ];
387 out.extend([
388 resourcetype(&[(DAV, "collection"), (DAV, "principal")]),
389 text(DAV, "displayname", &me.name),
390 href_prop(DAV, "principal-URL", &principal),
391 href_prop(CALDAV, "calendar-home-set", &me.home(PimKind::Calendar)),
392 href_prop(
393 CARDDAV,
394 "addressbook-home-set",
395 &me.home(PimKind::AddressBook),
396 ),
397 with_children(
398 el(CALDAV, "calendar-user-address-set"),
399 hrefs(addresses.iter().map(String::as_str)),
400 ),
401 text(CALDAV, "calendar-user-type", "INDIVIDUAL"),
402 privileges(),
403 ]);
404 }
405 Res::Home => out.extend([
406 resourcetype(&[(DAV, "collection")]),
407 href_prop(DAV, "owner", &me.principal()),
408 privileges(),
409 ]),
410 Res::Collection(kind, c) => {
411 let (types, desc) = match kind {
412 PimKind::Calendar => ((CALDAV, "calendar"), (CALDAV, "calendar-description")),
413 PimKind::AddressBook => (
414 (CARDDAV, "addressbook"),
415 (CARDDAV, "addressbook-description"),
416 ),
417 };
418 out.extend([
419 resourcetype(&[(DAV, "collection"), types]),
420 href_prop(DAV, "owner", &me.principal()),
421 privileges(),
422 supported_reports(*kind),
423 text(CALSERVER, "getctag", &format!("{}-{}", c.id, c.seq)),
424 text(DAV, "sync-token", &sync_token(c)),
425 text(
426 if *kind == PimKind::Calendar {
427 CALDAV
428 } else {
429 CARDDAV
430 },
431 "max-resource-size",
432 &MAX_RESOURCE_SIZE.to_string(),
433 ),
434 ]);
435 if let Some(v) = &c.displayname {
436 out.push(text(DAV, "displayname", v));
437 }
438 if let Some(v) = &c.description {
439 out.push(text(desc.0, desc.1, v));
440 }
441 match kind {
442 PimKind::Calendar => {
443 out.push(with_children(
444 el(CALDAV, "supported-calendar-component-set"),
445 c.components
446 .split(',')
447 .map(|comp| with_attr(el(CALDAV, "comp"), "name", comp)),
448 ));
449 out.push(with_children(
450 el(CALDAV, "supported-calendar-data"),
451 [with_attr(
452 with_attr(el(CALDAV, "calendar-data"), "content-type", "text/calendar"),
453 "version",
454 "2.0",
455 )],
456 ));
457 if let Some(v) = &c.color {
458 out.push(text(APPLE, "calendar-color", v));
459 }
460 if let Some(v) = &c.sort_order {
461 out.push(text(APPLE, "calendar-order", v));
462 }
463 if let Some(v) = &c.timezone {
464 out.push(text(CALDAV, "calendar-timezone", v));
465 }
466 }
467 PimKind::AddressBook => out.push(with_children(
468 el(CARDDAV, "supported-address-data"),
469 ["3.0", "4.0"].map(|v| {
470 with_attr(
471 with_attr(
472 el(CARDDAV, "address-data-type"),
473 "content-type",
474 "text/vcard",
475 ),
476 "version",
477 v,
478 )
479 }),
480 )),
481 }
482 }
483 Res::Object(kind, o) => {
484 out.extend([
485 resourcetype(&[]),
486 text(DAV, "getetag", &o.etag),
487 text(DAV, "getcontenttype", &content_type(*kind, &o.component)),
488 text(DAV, "getcontentlength", &o.size.to_string()),
489 ]);
490 if let Ok(t) = chrono::DateTime::parse_from_rfc3339(&o.modified_at) {
491 let http_date = t.to_utc().format("%a, %d %b %Y %H:%M:%S GMT").to_string();
492 out.push(text(DAV, "getlastmodified", &http_date));
493 }
494 }
495 }
496 out
497}
498
499fn supported_reports(kind: PimKind) -> Element {
500 let reports: &[(&str, &str)] = match kind {
501 PimKind::Calendar => &[
502 (CALDAV, "calendar-multiget"),
503 (CALDAV, "calendar-query"),
504 (CALDAV, "free-busy-query"),
505 (DAV, "sync-collection"),
506 ],
507 PimKind::AddressBook => &[
508 (CARDDAV, "addressbook-multiget"),
509 (CARDDAV, "addressbook-query"),
510 (DAV, "sync-collection"),
511 ],
512 };
513 with_children(
514 el(DAV, "supported-report-set"),
515 reports.iter().map(|(ns, local)| {
516 with_children(
517 el(DAV, "supported-report"),
518 [with_children(el(DAV, "report"), [el(ns, local)])],
519 )
520 }),
521 )
522}
523
524fn privileges() -> Element {
525 let names = [
526 "all",
527 "read",
528 "write",
529 "write-properties",
530 "write-content",
531 "bind",
532 "unbind",
533 "read-current-user-privilege-set",
534 ];
535 with_children(
536 el(DAV, "current-user-privilege-set"),
537 names.map(|n| with_children(el(DAV, "privilege"), [el(DAV, n)])),
538 )
539}
540
541/// Carries the collection id, so a token handed out for a deleted
542/// collection never matches the one that later takes its URL.
543fn sync_token(c: &PimCollection) -> String {
544 format!("urn:fbng:sync:{}-{}", c.id, c.seq)
545}
546
547/// A stable UUID per account, for the `urn:uuid:` calendar user address.
548fn principal_uuid(user_id: i64) -> String {
549 let h = crate::hex(&Sha256::digest(format!("filebrowser-ng principal {user_id}"))[..16]);
550 format!(
551 "{}-{}-{}-{}-{}",
552 &h[..8],
553 &h[8..12],
554 &h[12..16],
555 &h[16..20],
556 &h[20..]
557 )
558}
559
560fn content_type(kind: PimKind, component: &str) -> String {
561 match kind {
562 PimKind::Calendar => format!("text/calendar; charset=utf-8; component={component}"),
563 PimKind::AddressBook => "text/vcard; charset=utf-8".to_string(),
564 }
565}
566
567// ---------------------------------------------------------------------------
568// PROPPATCH, MKCALENDAR, MKCOL
569// ---------------------------------------------------------------------------
570
571async fn proppatch(state: &AppState, me: &Me, target: &Target, body: Body) -> Reply {
572 let Target::Collection(kind, _, slug) = target else {
573 return Ok(status(StatusCode::FORBIDDEN));
574 };
575 let Some(mut col) = state.db.pim_collection(me.id, *kind, slug).await? else {
576 return Ok(status(StatusCode::NOT_FOUND));
577 };
578 let Some(body) = read_body(body, MAX_XML_SIZE).await else {
579 return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
580 };
581 let Ok(update) = xml::update(&body) else {
582 return Ok(status(StatusCode::BAD_REQUEST));
583 };
584 let (ok, results) = apply(*kind, &mut col, &update, false);
585 if ok {
586 state.db.pim_update_collection(&col).await?;
587 }
588 let mut r = xml::Response::new(me.collection(*kind, slug));
589 for (code, prop) in results {
590 r.push(code, prop);
591 }
592 Ok(multistatus(&[r], None))
593}
594
595async fn mkcol(state: &AppState, me: &Me, target: &Target, method: &str, body: Body) -> Reply {
596 let Target::Collection(kind, _, slug) = target else {
597 return Ok(status(StatusCode::FORBIDDEN));
598 };
599 let calendar = method == "MKCALENDAR";
600 if calendar && *kind != PimKind::Calendar {
601 return Ok(status(StatusCode::FORBIDDEN));
602 }
603 if state.db.pim_collection(me.id, *kind, slug).await?.is_some() {
604 return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
605 }
606 let Some(body) = read_body(body, MAX_XML_SIZE).await else {
607 return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
608 };
609 let Ok(update) = xml::update(&body) else {
610 return Ok(status(StatusCode::BAD_REQUEST));
611 };
612 // A plain MKCOL makes a plain collection, which a calendar home cannot
613 // hold. An address book home takes it as an address book.
614 let typed = update
615 .set
616 .iter()
617 .any(|p| Name::of(p).is(DAV, "resourcetype"));
618 if !calendar && *kind == PimKind::Calendar && !typed {
619 return Ok(status(StatusCode::FORBIDDEN));
620 }
621 let mut col = PimCollection {
622 slug: slug.clone(),
623 components: match kind {
624 PimKind::Calendar => "VEVENT,VTODO,VJOURNAL".to_string(),
625 PimKind::AddressBook => String::new(),
626 },
627 ..Default::default()
628 };
629 let (ok, results) = apply(*kind, &mut col, &update, true);
630 if !ok {
631 let root = match calendar {
632 true => Name::new(CALDAV, "mkcalendar-response"),
633 false => Name::new(DAV, "mkcol-response"),
634 };
635 let propstats = group(results);
636 return Ok(xml_response(
637 StatusCode::FORBIDDEN,
638 xml::propstat_document(&root, &propstats),
639 ));
640 }
641 if !state.db.pim_create_collection(me.id, *kind, &col).await? {
642 return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
643 }
644 Ok(status(StatusCode::CREATED))
645}
646
647fn group(results: Vec<(u16, Element)>) -> Vec<(u16, Vec<Element>)> {
648 let mut r = xml::Response::default();
649 for (code, prop) in results {
650 r.push(code, prop);
651 }
652 r.propstats
653}
654
655/// Applies property changes to `col`. Returns whether all of them are
656/// allowed, and each property with its status. Nothing may be stored unless
657/// all are: RFC 4918 makes PROPPATCH atomic.
658fn apply(
659 kind: PimKind,
660 col: &mut PimCollection,
661 update: &Update,
662 creating: bool,
663) -> (bool, Vec<(u16, Element)>) {
664 let cal = kind == PimKind::Calendar;
665 let mut results = Vec::new();
666 for p in &update.set {
667 let name = Name::of(p);
668 let value = || Some(xml::text(p)).filter(|v| !v.is_empty());
669 let ok = match (name.ns.as_str(), name.local.as_str()) {
670 (DAV, "displayname") => {
671 col.displayname = value();
672 true
673 }
674 (CALDAV, "calendar-description") if cal => {
675 col.description = value();
676 true
677 }
678 (CARDDAV, "addressbook-description") if !cal => {
679 col.description = value();
680 true
681 }
682 (APPLE, "calendar-color") if cal => {
683 col.color = value();
684 true
685 }
686 (APPLE, "calendar-order") if cal => {
687 col.sort_order = value();
688 true
689 }
690 (CALDAV, "calendar-timezone") if cal => {
691 let tz = value();
692 let valid = tz.as_deref().is_none_or(is_timezone);
693 if valid {
694 col.timezone = tz;
695 }
696 valid
697 }
698 (DAV, "resourcetype") if creating => {
699 let wanted = match kind {
700 PimKind::Calendar => (CALDAV, "calendar"),
701 PimKind::AddressBook => (CARDDAV, "addressbook"),
702 };
703 xml::child(p, wanted.0, wanted.1).is_some()
704 }
705 (CALDAV, "supported-calendar-component-set") if creating && cal => {
706 let comps: Vec<_> = xml::elements(p)
707 .filter(|c| Name::of(c).is(CALDAV, "comp"))
708 .filter_map(|c| c.attributes.get("name"))
709 .map(|n| n.to_ascii_uppercase())
710 .collect();
711 let valid = !comps.is_empty()
712 && comps
713 .iter()
714 .all(|c| ["VEVENT", "VTODO", "VJOURNAL"].contains(&c.as_str()));
715 if valid {
716 col.components = comps.join(",");
717 }
718 valid
719 }
720 _ => false,
721 };
722 results.push((if ok { 200 } else { 403 }, name.element()));
723 }
724 for name in &update.remove {
725 let field = match (name.ns.as_str(), name.local.as_str()) {
726 (DAV, "displayname") => Some(&mut col.displayname),
727 (CALDAV, "calendar-description") if cal => Some(&mut col.description),
728 (CARDDAV, "addressbook-description") if !cal => Some(&mut col.description),
729 (APPLE, "calendar-color") if cal => Some(&mut col.color),
730 (APPLE, "calendar-order") if cal => Some(&mut col.sort_order),
731 (CALDAV, "calendar-timezone") if cal => Some(&mut col.timezone),
732 _ => None,
733 };
734 let ok = field.map(|f| *f = None).is_some();
735 results.push((if ok { 200 } else { 403 }, name.element()));
736 }
737 let ok = results.iter().all(|(code, _)| *code == 200);
738 if !ok {
739 for (code, _) in &mut results {
740 if *code == 200 {
741 *code = 424;
742 }
743 }
744 }
745 (ok, results)
746}
747
748/// A VCALENDAR holding a VTIMEZONE, as `calendar-timezone` must be.
749fn is_timezone(v: &str) -> bool {
750 use pimdav::calcard::icalendar::{ICalendar, ICalendarComponentType};
751 ICalendar::parse(v).is_ok_and(|c| {
752 c.components
753 .iter()
754 .any(|c| c.component_type == ICalendarComponentType::VTimezone)
755 })
756}
757
758// ---------------------------------------------------------------------------
759// Objects
760// ---------------------------------------------------------------------------
761
762async fn get(state: &AppState, me: &Me, target: &Target, head: bool) -> Reply {
763 let Target::Object(kind, _, slug, name) = target else {
764 return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
765 };
766 let found = match state.db.pim_collection(me.id, *kind, slug).await? {
767 Some(c) => state.db.pim_object(c.id, name).await?,
768 None => None,
769 };
770 let Some((o, data)) = found else {
771 return Ok(status(StatusCode::NOT_FOUND));
772 };
773 let body = if head {
774 Body::empty()
775 } else {
776 Body::from(data)
777 };
778 Ok((
779 StatusCode::OK,
780 [
781 (CONTENT_TYPE, content_type(*kind, &o.component)),
782 (ETAG, o.etag),
783 ],
784 body,
785 )
786 .into_response())
787}
788
789async fn put(state: &AppState, me: &Me, target: &Target, headers: &HeaderMap, body: Body) -> Reply {
790 let Target::Object(kind, _, slug, name) = target else {
791 return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
792 };
793 let Some(col) = state.db.pim_collection(me.id, *kind, slug).await? else {
794 return Ok(status(StatusCode::CONFLICT));
795 };
796 let ns = match kind {
797 PimKind::Calendar => CALDAV,
798 PimKind::AddressBook => CARDDAV,
799 };
800 let Some(data) = read_body(body, MAX_RESOURCE_SIZE).await else {
801 return Ok(error(StatusCode::FORBIDDEN, el(ns, "max-resource-size")));
802 };
803 let parsed = match kind {
804 PimKind::Calendar => {
805 let supported: Vec<&str> = col.components.split(',').collect();
806 object::calendar(&data, &supported).map(|o| (o.uid, o.component.to_string()))
807 }
808 PimKind::AddressBook => {
809 object::vcard(&data).map(|uid| (uid.unwrap_or_else(|| name.clone()), "VCARD".into()))
810 }
811 };
812 let (uid, component) = match parsed {
813 Ok(v) => v,
814 Err(invalid) => return Ok(error(StatusCode::FORBIDDEN, invalid.condition())),
815 };
816 let etag = format!("\"{}\"", crate::hex(&Sha256::digest(&data)[..16]));
817 let obj = PimObject {
818 name: name.clone(),
819 uid,
820 component,
821 etag: etag.clone(),
822 ..Default::default()
823 };
824 // The stored bytes are the request bytes, so the ETag may be returned.
825 match state
826 .db
827 .pim_put_object(col.id, &obj, &data, &precondition(headers))
828 .await?
829 {
830 PimWrite::Created => Ok((StatusCode::CREATED, [(ETAG, etag)]).into_response()),
831 PimWrite::Updated => Ok((StatusCode::NO_CONTENT, [(ETAG, etag)]).into_response()),
832 PimWrite::PreconditionFailed => Ok(status(StatusCode::PRECONDITION_FAILED)),
833 PimWrite::UidConflict(holder) => Ok(error(
834 StatusCode::FORBIDDEN,
835 with_children(
836 el(ns, "no-uid-conflict"),
837 hrefs([me.object(*kind, slug, &holder).as_str()]),
838 ),
839 )),
840 PimWrite::Deleted | PimWrite::NotFound => Ok(status(StatusCode::INTERNAL_SERVER_ERROR)),
841 }
842}
843
844async fn delete(state: &AppState, me: &Me, target: &Target, headers: &HeaderMap) -> Reply {
845 let (kind, slug, name) = match target {
846 Target::Collection(k, _, s) => (k, s, None),
847 Target::Object(k, _, s, n) => (k, s, Some(n)),
848 _ => return Ok(status(StatusCode::FORBIDDEN)),
849 };
850 let Some(col) = state.db.pim_collection(me.id, *kind, slug).await? else {
851 return Ok(status(StatusCode::NOT_FOUND));
852 };
853 let Some(name) = name else {
854 state.db.pim_delete_collection(col.id).await?;
855 return Ok(status(StatusCode::NO_CONTENT));
856 };
857 Ok(
858 match state
859 .db
860 .pim_delete_object(col.id, name, &precondition(headers))
861 .await?
862 {
863 PimWrite::Deleted => status(StatusCode::NO_CONTENT),
864 PimWrite::NotFound => status(StatusCode::NOT_FOUND),
865 PimWrite::PreconditionFailed => status(StatusCode::PRECONDITION_FAILED),
866 _ => status(StatusCode::INTERNAL_SERVER_ERROR),
867 },
868 )
869}
870
871fn precondition(headers: &HeaderMap) -> Precondition {
872 let header = |name: &str| {
873 headers
874 .get(name)
875 .and_then(|v| v.to_str().ok())
876 .map(str::to_string)
877 };
878 Precondition {
879 if_match: header("if-match"),
880 if_none_match: header("if-none-match"),
881 }
882}
883
884// ---------------------------------------------------------------------------
885// REPORT
886// ---------------------------------------------------------------------------
887
888async fn report(state: &AppState, me: &Me, target: &Target, body: Body) -> Reply {
889 let Some(body) = read_body(body, MAX_XML_SIZE).await else {
890 return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
891 };
892 let report = match report::parse(&body) {
893 Ok(r) => r,
894 Err(Refused::Invalid) => return Ok(status(StatusCode::BAD_REQUEST)),
895 Err(Refused::Condition(c)) => return Ok(error(StatusCode::FORBIDDEN, c.element())),
896 };
897 let unsupported = || Ok(error(StatusCode::FORBIDDEN, el(DAV, "supported-report")));
898 let Target::Collection(kind, _, slug) = target else {
899 return unsupported();
900 };
901 let calendar_report = matches!(
902 report,
903 Report::CalendarMultiget { .. } | Report::CalendarQuery { .. } | Report::FreeBusy(_)
904 );
905 let card_report = matches!(
906 report,
907 Report::AddressbookMultiget { .. } | Report::AddressbookQuery { .. }
908 );
909 if (calendar_report && *kind != PimKind::Calendar)
910 || (card_report && *kind != PimKind::AddressBook)
911 {
912 return unsupported();
913 }
914 let Some(col) = state.db.pim_collection(me.id, *kind, slug).await? else {
915 return Ok(status(StatusCode::NOT_FOUND));
916 };
917 let floating = col
918 .timezone
919 .as_deref()
920 .and_then(zone::from_vtimezone)
921 .unwrap_or(Zone::Utc);
922 let out = Out {
923 me,
924 kind: *kind,
925 col: &col,
926 };
927
928 match report {
929 Report::CalendarMultiget { props, hrefs }
930 | Report::AddressbookMultiget { props, hrefs } => {
931 let mut responses = Vec::new();
932 for href in hrefs {
933 let found = match own_object(me, *kind, &href) {
934 Some((slug, name)) if slug == col.slug => {
935 state.db.pim_object(col.id, &name).await?
936 }
937 _ => None,
938 };
939 responses.push(match found {
940 // The href as the client wrote it, so it can match it.
941 Some((o, data)) => match out.object(&o, &data, &props, &floating) {
942 Ok(r) => xml::Response { href, ..r },
943 Err(TooManyInstances) => return Ok(too_many()),
944 },
945 None => xml::Response::status(href, 404),
946 });
947 }
948 Ok(multistatus(&responses, None))
949 }
950 Report::CalendarQuery {
951 props,
952 filter,
953 timezone,
954 } => {
955 let floating = timezone.unwrap_or(floating);
956 let mut responses = Vec::new();
957 for (o, data) in state.db.pim_objects_with_data(col.id).await? {
958 let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) else {
959 continue;
960 };
961 if filter::matches_calendar(&cal, &filter, &floating) {
962 match out.object(&o, &data, &props, &floating) {
963 Ok(r) => responses.push(r),
964 Err(TooManyInstances) => return Ok(too_many()),
965 }
966 }
967 }
968 Ok(multistatus(&responses, None))
969 }
970 Report::AddressbookQuery {
971 props,
972 filter,
973 limit,
974 } => {
975 let mut responses = Vec::new();
976 let mut truncated = false;
977 for (o, data) in state.db.pim_objects_with_data(col.id).await? {
978 let Ok(card) = VCard::parse(String::from_utf8_lossy(&data).as_ref()) else {
979 continue;
980 };
981 if !filter::matches_card(&card, &filter) {
982 continue;
983 }
984 if limit.is_some_and(|n| responses.len() >= n) {
985 truncated = true;
986 break;
987 }
988 if let Ok(r) = out.object(&o, &data, &props, &floating) {
989 responses.push(r);
990 }
991 }
992 if truncated {
993 responses.push(out.over_limit());
994 }
995 Ok(multistatus(&responses, None))
996 }
997 Report::SyncCollection {
998 token,
999 props,
1000 limit,
1001 } => {
1002 let since = if token.is_empty() {
1003 None
1004 } else {
1005 match parse_sync_token(&token) {
1006 Some((id, seq)) if id == col.id && seq <= col.seq => Some(seq),
1007 _ => return Ok(error(StatusCode::FORBIDDEN, el(DAV, "valid-sync-token"))),
1008 }
1009 };
1010 let mut changes = state.db.pim_changes(col.id, since).await?;
1011 let truncated = limit.is_some_and(|n| changes.len() > n);
1012 if let Some(n) = limit {
1013 changes.truncate(n);
1014 }
1015 // A truncated answer hands out the token of its last change, so
1016 // the next sync resumes after it.
1017 let seq = match (truncated, changes.last()) {
1018 (true, Some((_, s, _))) => *s,
1019 (_, last) => col.seq.max(last.map_or(0, |(_, s, _)| *s)),
1020 };
1021 let mut responses = Vec::new();
1022 for (name, _, deleted) in changes {
1023 let href = me.object(*kind, &col.slug, &name);
1024 let found = match deleted {
1025 true => None,
1026 false => state.db.pim_object(col.id, &name).await?,
1027 };
1028 responses.push(match found {
1029 Some((o, data)) => match out.object(&o, &data, &props, &floating) {
1030 Ok(r) => r,
1031 Err(TooManyInstances) => return Ok(too_many()),
1032 },
1033 None => xml::Response::status(href, 404),
1034 });
1035 }
1036 if truncated {
1037 responses.push(out.over_limit());
1038 }
1039 let token = format!("urn:fbng:sync:{}-{seq}", col.id);
1040 Ok(multistatus(
1041 &responses,
1042 Some(with_text(el(DAV, "sync-token"), token)),
1043 ))
1044 }
1045 Report::FreeBusy(range) => {
1046 let mut busy = Vec::new();
1047 for (_, data) in state.db.pim_objects_with_data(col.id).await? {
1048 if let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) {
1049 // ponytail: one period per instance, so a long range over
1050 // a frequent series makes a long answer.
1051 busy.extend(freebusy::busy(&cal, &range, &floating));
1052 }
1053 }
1054 let body = freebusy::vfreebusy(&freebusy::merge(busy), &range, chrono::Utc::now());
1055 Ok((
1056 StatusCode::OK,
1057 [(CONTENT_TYPE, "text/calendar; charset=utf-8")],
1058 body,
1059 )
1060 .into_response())
1061 }
1062 }
1063}
1064
1065/// What a REPORT answer about one collection needs.
1066struct Out<'a> {
1067 me: &'a Me,
1068 kind: PimKind,
1069 col: &'a PimCollection,
1070}
1071
1072impl Out<'_> {
1073 fn object(
1074 &self,
1075 o: &PimObject,
1076 data: &[u8],
1077 props: &Props,
1078 floating: &Zone,
1079 ) -> Result<xml::Response, TooManyInstances> {
1080 let mut all = self::props(self.me, &Res::Object(self.kind, o.clone()));
1081 let raw = String::from_utf8_lossy(data);
1082 if let Some(req) = &props.calendar {
1083 let text = render::calendar_data(&raw, req, floating)?;
1084 all.push(with_text(el(CALDAV, "calendar-data"), text));
1085 }
1086 if let Some(req) = &props.address {
1087 all.push(with_text(
1088 el(CARDDAV, "address-data"),
1089 render::address_data(&raw, req),
1090 ));
1091 }
1092 let href = self.me.object(self.kind, &self.col.slug, &o.name);
1093 Ok(select(href, &props.find, all))
1094 }
1095
1096 /// The response a query or sync adds when a client limit cut it short.
1097 fn over_limit(&self) -> xml::Response {
1098 let mut r = xml::Response::status(self.me.collection(self.kind, &self.col.slug), 507);
1099 r.error = Some(el(DAV, "number-of-matches-within-limits"));
1100 r
1101 }
1102}
1103
1104fn too_many() -> Response<Body> {
1105 error(StatusCode::FORBIDDEN, el(CALDAV, "max-instances"))
1106}
1107
1108/// `(collection id, seq)` of a token [`sync_token`] made.
1109fn parse_sync_token(token: &str) -> Option<(i64, i64)> {
1110 let (id, seq) = token.strip_prefix("urn:fbng:sync:")?.split_once('-')?;
1111 Some((id.parse().ok()?, seq.parse().ok()?))
1112}
1113
1114/// `(collection slug, object name)` of an href to one of the user's own
1115/// objects of `kind`. Takes a path or a full URL.
1116fn own_object(me: &Me, kind: PimKind, href: &str) -> Option<(String, String)> {
1117 let path = match href.starts_with('/') {
1118 true => href.to_string(),
1119 false => href.parse::<axum::http::Uri>().ok()?.path().to_string(),
1120 };
1121 match parse_target(path.strip_prefix(PIM)?)? {
1122 Target::Object(k, owner, slug, name)
1123 if k == kind && owner.eq_ignore_ascii_case(&me.name) =>
1124 {
1125 Some((slug, name))
1126 }
1127 _ => None,
1128 }
1129}
1130
1131// ---------------------------------------------------------------------------
1132// MOVE
1133// ---------------------------------------------------------------------------
1134
1135async fn move_object(state: &AppState, me: &Me, target: &Target, headers: &HeaderMap) -> Reply {
1136 let Target::Object(kind, _, slug, name) = target else {
1137 return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
1138 };
1139 let destination = headers.get("destination").and_then(|v| v.to_str().ok());
1140 let Some((to_slug, to_name)) = destination.and_then(|d| own_object(me, *kind, d)) else {
1141 return Ok(status(StatusCode::FORBIDDEN));
1142 };
1143 if (&to_slug, &to_name) == (slug, name) {
1144 return Ok(status(StatusCode::FORBIDDEN));
1145 }
1146 let Some(from) = state.db.pim_collection(me.id, *kind, slug).await? else {
1147 return Ok(status(StatusCode::NOT_FOUND));
1148 };
1149 let Some(to) = state.db.pim_collection(me.id, *kind, &to_slug).await? else {
1150 return Ok(status(StatusCode::CONFLICT));
1151 };
1152 let Some((obj, _)) = state.db.pim_object(from.id, name).await? else {
1153 return Ok(status(StatusCode::NOT_FOUND));
1154 };
1155 if *kind == PimKind::Calendar && !to.components.split(',').any(|c| c == obj.component) {
1156 return Ok(error(
1157 StatusCode::FORBIDDEN,
1158 el(CALDAV, "supported-calendar-component"),
1159 ));
1160 }
1161 let overwrite = headers.get("overwrite").and_then(|v| v.to_str().ok()) != Some("F");
1162 Ok(
1163 match state
1164 .db
1165 .pim_move_object(
1166 from.id,
1167 name,
1168 to.id,
1169 &to_name,
1170 overwrite,
1171 &precondition(headers),
1172 )
1173 .await?
1174 {
1175 PimWrite::Created => status(StatusCode::CREATED),
1176 PimWrite::Updated => status(StatusCode::NO_CONTENT),
1177 PimWrite::NotFound => status(StatusCode::NOT_FOUND),
1178 PimWrite::PreconditionFailed => status(StatusCode::PRECONDITION_FAILED),
1179 PimWrite::UidConflict(holder) => error(
1180 StatusCode::FORBIDDEN,
1181 with_children(
1182 el(
1183 match kind {
1184 PimKind::Calendar => CALDAV,
1185 PimKind::AddressBook => CARDDAV,
1186 },
1187 "no-uid-conflict",
1188 ),
1189 hrefs([me.object(*kind, &to_slug, &holder).as_str()]),
1190 ),
1191 ),
1192 PimWrite::Deleted => status(StatusCode::INTERNAL_SERVER_ERROR),
1193 },
1194 )
1195}
1196