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::object;
23use pimdav::xml::{
24 self, APPLE, CALDAV, CALSERVER, CARDDAV, DAV, Name, Propfind, Update, el, hrefs, with_attr,
25 with_children, with_text,
26};
27use sha2::{Digest, Sha256};
28use xmltree::Element;
29
30use crate::db::{PimCollection, PimKind, PimObject, PimWrite, Precondition};
31use crate::error::{ApiError, AppState};
32
33/// Largest object a PUT may store. Contacts carry photos inline.
34const MAX_RESOURCE_SIZE: usize = 10 * 1024 * 1024;
35
36/// Largest XML request body.
37const MAX_XML_SIZE: usize = 1024 * 1024;
38
39/// The domain of the addresses users schedule with. `.invalid` is reserved
40/// (RFC 2606), so nothing sent there can reach anyone.
41const MAIL_DOMAIN: &str = "filebrowser.invalid";
42
43/// Characters escaped in an href segment.
44const SEGMENT: &AsciiSet = &CONTROLS
45 .add(b' ')
46 .add(b'"')
47 .add(b'#')
48 .add(b'%')
49 .add(b'/')
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
59type Reply = Result<Response<Body>, ApiError>;
60
61/// `{WELL_KNOWN_CALDAV}` and `{WELL_KNOWN_CARDDAV}`.
62pub async fn well_known() -> Response<Body> {
63 (
64 StatusCode::MOVED_PERMANENTLY,
65 [(LOCATION, format!("{PIM}/"))],
66 )
67 .into_response()
68}
69
70/// `{PIM}` and everything under it.
71pub async fn handle(State(state): State<Arc<AppState>>, req: Request<Body>) -> Response<Body> {
72 let Some((user_id, _)) = super::dav::authenticate(&state, req.headers()).await else {
73 return super::dav::challenge();
74 };
75 serve(&state, user_id, req)
76 .await
77 .unwrap_or_else(IntoResponse::into_response)
78}
79
80/// The signed-in user.
81struct Me {
82 id: i64,
83 name: String,
84}
85
86impl Me {
87 fn principal(&self) -> String {
88 format!("{PIM}/principals/{}/", seg(&self.name))
89 }
90
91 fn home(&self, kind: PimKind) -> String {
92 format!("{PIM}/{}/{}/", kind_segment(kind), seg(&self.name))
93 }
94
95 fn collection(&self, kind: PimKind, slug: &str) -> String {
96 format!("{}{}/", self.home(kind), seg(slug))
97 }
98
99 fn object(&self, kind: PimKind, slug: &str, name: &str) -> String {
100 format!("{}{}", self.collection(kind, slug), seg(name))
101 }
102}
103
104async fn serve(state: &AppState, user_id: i64, req: Request<Body>) -> Reply {
105 let Some(name) = state.db.user_name(user_id).await? else {
106 return Ok(status(StatusCode::UNAUTHORIZED));
107 };
108 let me = Me { id: user_id, name };
109 let path = req.uri().path().strip_prefix(PIM).unwrap_or_default();
110 let Some(target) = parse_target(path) else {
111 return Ok(status(StatusCode::NOT_FOUND));
112 };
113 // ponytail: own resources only. Sharing between users comes with the
114 // access model.
115 if target
116 .owner()
117 .is_some_and(|o| !o.eq_ignore_ascii_case(&me.name))
118 {
119 return Ok(status(StatusCode::FORBIDDEN));
120 }
121 state.db.pim_ensure_defaults(me.id).await?;
122
123 let method = req.method().clone();
124 let (parts, body) = req.into_parts();
125 match method.as_str() {
126 "OPTIONS" => Ok(options()),
127 "PROPFIND" => propfind(state, &me, &target, &parts.headers, body).await,
128 "PROPPATCH" => proppatch(state, &me, &target, body).await,
129 "MKCALENDAR" | "MKCOL" => mkcol(state, &me, &target, method.as_str(), body).await,
130 "GET" | "HEAD" => get(state, &me, &target, method == Method::HEAD).await,
131 "PUT" => put(state, &me, &target, &parts.headers, body).await,
132 "DELETE" => delete(state, &me, &target, &parts.headers).await,
133 "REPORT" => Ok(error(StatusCode::FORBIDDEN, el(DAV, "supported-report"))),
134 _ => Ok(status(StatusCode::METHOD_NOT_ALLOWED)),
135 }
136}
137
138#[derive(Debug)]
139enum Target {
140 Root,
141 Principal(String),
142 Home(PimKind, String),
143 Collection(PimKind, String, String),
144 Object(PimKind, String, String, String),
145}
146
147impl Target {
148 fn owner(&self) -> Option<&str> {
149 match self {
150 Target::Root => None,
151 Target::Principal(u)
152 | Target::Home(_, u)
153 | Target::Collection(_, u, _)
154 | Target::Object(_, u, _, _) => Some(u),
155 }
156 }
157}
158
159fn parse_target(path: &str) -> Option<Target> {
160 let segs = path
161 .split('/')
162 .filter(|s| !s.is_empty())
163 .map(|s| {
164 let s = percent_decode_str(s).decode_utf8().ok()?;
165 (s != "." && s != "..").then(|| s.into_owned())
166 })
167 .collect::<Option<Vec<_>>>()?;
168 let kind = |s: &str| match s {
169 "calendars" => Some(PimKind::Calendar),
170 "addressbooks" => Some(PimKind::AddressBook),
171 _ => None,
172 };
173 let mut it = segs.into_iter();
174 let Some(first) = it.next() else {
175 return Some(Target::Root);
176 };
177 let rest: Vec<String> = it.collect();
178 if first == "principals" {
179 return match <[String; 1]>::try_from(rest) {
180 Ok([user]) => Some(Target::Principal(user)),
181 Err(_) => None,
182 };
183 }
184 let kind = kind(&first)?;
185 let mut rest = rest.into_iter();
186 Some(match (rest.next(), rest.next(), rest.next(), rest.next()) {
187 (Some(u), None, None, None) => Target::Home(kind, u),
188 (Some(u), Some(c), None, None) => Target::Collection(kind, u, c),
189 (Some(u), Some(c), Some(o), None) => Target::Object(kind, u, c, o),
190 _ => return None,
191 })
192}
193
194fn kind_segment(kind: PimKind) -> &'static str {
195 match kind {
196 PimKind::Calendar => "calendars",
197 PimKind::AddressBook => "addressbooks",
198 }
199}
200
201fn seg(s: &str) -> String {
202 utf8_percent_encode(s, SEGMENT).to_string()
203}
204
205fn status(code: StatusCode) -> Response<Body> {
206 code.into_response()
207}
208
209fn xml_response(code: StatusCode, body: String) -> Response<Body> {
210 (
211 code,
212 [(CONTENT_TYPE, "application/xml; charset=utf-8")],
213 body,
214 )
215 .into_response()
216}
217
218/// A failed precondition, named in a `<d:error>` body.
219fn error(code: StatusCode, condition: Element) -> Response<Body> {
220 xml_response(code, xml::error(condition))
221}
222
223fn options() -> Response<Body> {
224 (
225 StatusCode::OK,
226 [
227 ("dav", "1, 3, calendar-access, addressbook, extended-mkcol"),
228 (
229 ALLOW.as_str(),
230 "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCALENDAR, MKCOL, REPORT",
231 ),
232 ],
233 )
234 .into_response()
235}
236
237async fn read_body(body: Body, limit: usize) -> Option<axum::body::Bytes> {
238 axum::body::to_bytes(body, limit).await.ok()
239}
240
241// ---------------------------------------------------------------------------
242// PROPFIND
243// ---------------------------------------------------------------------------
244
245/// A resource PROPFIND can describe.
246enum Res {
247 Root,
248 Principal,
249 Home,
250 Collection(PimKind, PimCollection),
251 Object(PimKind, PimObject),
252}
253
254async fn propfind(
255 state: &AppState,
256 me: &Me,
257 target: &Target,
258 headers: &HeaderMap,
259 body: Body,
260) -> Reply {
261 // Missing means infinity to RFC 4918, but clients that omit it mean 0.
262 let deep = match headers.get("depth").and_then(|v| v.to_str().ok()) {
263 None | Some("0") => false,
264 Some("1") => true,
265 Some(_) => {
266 return Ok(error(
267 StatusCode::FORBIDDEN,
268 el(DAV, "propfind-finite-depth"),
269 ));
270 }
271 };
272 let Some(body) = read_body(body, MAX_XML_SIZE).await else {
273 return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
274 };
275 let Ok(request) = xml::propfind(&body) else {
276 return Ok(status(StatusCode::BAD_REQUEST));
277 };
278
279 let mut list: Vec<(String, Res)> = Vec::new();
280 match target {
281 Target::Root => list.push((format!("{PIM}/"), Res::Root)),
282 Target::Principal(_) => list.push((me.principal(), Res::Principal)),
283 Target::Home(kind, _) => {
284 list.push((me.home(*kind), Res::Home));
285 if deep {
286 for c in state.db.pim_collections(me.id, *kind).await? {
287 list.push((me.collection(*kind, &c.slug), Res::Collection(*kind, c)));
288 }
289 }
290 }
291 Target::Collection(kind, _, slug) => {
292 let Some(c) = state.db.pim_collection(me.id, *kind, slug).await? else {
293 return Ok(status(StatusCode::NOT_FOUND));
294 };
295 if deep {
296 for o in state.db.pim_objects(c.id).await? {
297 let href = me.object(*kind, &c.slug, &o.name);
298 list.push((href, Res::Object(*kind, o)));
299 }
300 }
301 list.insert(
302 0,
303 (me.collection(*kind, &c.slug), Res::Collection(*kind, c)),
304 );
305 }
306 Target::Object(kind, _, slug, name) => {
307 let found = match state.db.pim_collection(me.id, *kind, slug).await? {
308 Some(c) => state.db.pim_object(c.id, name).await?,
309 None => None,
310 };
311 let Some((o, _)) = found else {
312 return Ok(status(StatusCode::NOT_FOUND));
313 };
314 list.push((me.object(*kind, slug, name), Res::Object(*kind, o)));
315 }
316 }
317
318 let responses: Vec<xml::Response> = list
319 .into_iter()
320 .map(|(href, res)| {
321 let mut r = xml::Response::new(href);
322 let all = props(me, &res);
323 match &request {
324 Propfind::AllProp(_) => all.into_iter().for_each(|p| r.push(200, p)),
325 Propfind::PropName => all.iter().for_each(|p| r.push(200, Name::of(p).element())),
326 Propfind::Prop(names) => {
327 for n in names {
328 match all.iter().find(|p| Name::of(p) == *n) {
329 Some(p) => r.push(200, p.clone()),
330 None => r.push(404, n.element()),
331 }
332 }
333 }
334 }
335 r
336 })
337 .collect();
338 Ok(xml_response(
339 StatusCode::MULTI_STATUS,
340 xml::multistatus(&Name::new(DAV, "multistatus"), &responses),
341 ))
342}
343
344/// Every live property of a resource, with its value.
345fn props(me: &Me, res: &Res) -> Vec<Element> {
346 let text = |ns: &str, local: &str, v: &str| with_text(el(ns, local), v);
347 let href_prop = |ns: &str, local: &str, h: &str| with_children(el(ns, local), hrefs([h]));
348 let resourcetype = |types: &[(&str, &str)]| {
349 with_children(
350 el(DAV, "resourcetype"),
351 types.iter().map(|(ns, l)| el(ns, l)),
352 )
353 };
354 let mut out = vec![href_prop(DAV, "current-user-principal", &me.principal())];
355 match res {
356 Res::Root => out.push(resourcetype(&[(DAV, "collection")])),
357 Res::Principal => {
358 let principal = me.principal();
359 let addresses = [
360 format!("mailto:{}@{MAIL_DOMAIN}", seg(&me.name)),
361 principal.clone(),
362 format!("urn:uuid:{}", principal_uuid(me.id)),
363 ];
364 out.extend([
365 resourcetype(&[(DAV, "collection"), (DAV, "principal")]),
366 text(DAV, "displayname", &me.name),
367 href_prop(DAV, "principal-URL", &principal),
368 href_prop(CALDAV, "calendar-home-set", &me.home(PimKind::Calendar)),
369 href_prop(
370 CARDDAV,
371 "addressbook-home-set",
372 &me.home(PimKind::AddressBook),
373 ),
374 with_children(
375 el(CALDAV, "calendar-user-address-set"),
376 hrefs(addresses.iter().map(String::as_str)),
377 ),
378 text(CALDAV, "calendar-user-type", "INDIVIDUAL"),
379 privileges(),
380 ]);
381 }
382 Res::Home => out.extend([
383 resourcetype(&[(DAV, "collection")]),
384 href_prop(DAV, "owner", &me.principal()),
385 privileges(),
386 ]),
387 Res::Collection(kind, c) => {
388 let (types, desc) = match kind {
389 PimKind::Calendar => ((CALDAV, "calendar"), (CALDAV, "calendar-description")),
390 PimKind::AddressBook => (
391 (CARDDAV, "addressbook"),
392 (CARDDAV, "addressbook-description"),
393 ),
394 };
395 out.extend([
396 resourcetype(&[(DAV, "collection"), types]),
397 href_prop(DAV, "owner", &me.principal()),
398 privileges(),
399 // Empty until the REPORTs exist.
400 el(DAV, "supported-report-set"),
401 text(CALSERVER, "getctag", &format!("{}-{}", c.id, c.seq)),
402 text(DAV, "sync-token", &sync_token(c)),
403 text(
404 if *kind == PimKind::Calendar {
405 CALDAV
406 } else {
407 CARDDAV
408 },
409 "max-resource-size",
410 &MAX_RESOURCE_SIZE.to_string(),
411 ),
412 ]);
413 if let Some(v) = &c.displayname {
414 out.push(text(DAV, "displayname", v));
415 }
416 if let Some(v) = &c.description {
417 out.push(text(desc.0, desc.1, v));
418 }
419 match kind {
420 PimKind::Calendar => {
421 out.push(with_children(
422 el(CALDAV, "supported-calendar-component-set"),
423 c.components
424 .split(',')
425 .map(|comp| with_attr(el(CALDAV, "comp"), "name", comp)),
426 ));
427 out.push(with_children(
428 el(CALDAV, "supported-calendar-data"),
429 [with_attr(
430 with_attr(el(CALDAV, "calendar-data"), "content-type", "text/calendar"),
431 "version",
432 "2.0",
433 )],
434 ));
435 if let Some(v) = &c.color {
436 out.push(text(APPLE, "calendar-color", v));
437 }
438 if let Some(v) = &c.sort_order {
439 out.push(text(APPLE, "calendar-order", v));
440 }
441 if let Some(v) = &c.timezone {
442 out.push(text(CALDAV, "calendar-timezone", v));
443 }
444 }
445 PimKind::AddressBook => out.push(with_children(
446 el(CARDDAV, "supported-address-data"),
447 ["3.0", "4.0"].map(|v| {
448 with_attr(
449 with_attr(
450 el(CARDDAV, "address-data-type"),
451 "content-type",
452 "text/vcard",
453 ),
454 "version",
455 v,
456 )
457 }),
458 )),
459 }
460 }
461 Res::Object(kind, o) => {
462 out.extend([
463 resourcetype(&[]),
464 text(DAV, "getetag", &o.etag),
465 text(DAV, "getcontenttype", &content_type(*kind, &o.component)),
466 text(DAV, "getcontentlength", &o.size.to_string()),
467 ]);
468 if let Ok(t) = chrono::DateTime::parse_from_rfc3339(&o.modified_at) {
469 let http_date = t.to_utc().format("%a, %d %b %Y %H:%M:%S GMT").to_string();
470 out.push(text(DAV, "getlastmodified", &http_date));
471 }
472 }
473 }
474 out
475}
476
477fn privileges() -> Element {
478 let names = [
479 "all",
480 "read",
481 "write",
482 "write-properties",
483 "write-content",
484 "bind",
485 "unbind",
486 "read-current-user-privilege-set",
487 ];
488 with_children(
489 el(DAV, "current-user-privilege-set"),
490 names.map(|n| with_children(el(DAV, "privilege"), [el(DAV, n)])),
491 )
492}
493
494/// Carries the collection id, so a token handed out for a deleted
495/// collection never matches the one that later takes its URL.
496fn sync_token(c: &PimCollection) -> String {
497 format!("urn:fbng:sync:{}-{}", c.id, c.seq)
498}
499
500/// A stable UUID per account, for the `urn:uuid:` calendar user address.
501fn principal_uuid(user_id: i64) -> String {
502 let h = crate::hex(&Sha256::digest(format!("filebrowser-ng principal {user_id}"))[..16]);
503 format!(
504 "{}-{}-{}-{}-{}",
505 &h[..8],
506 &h[8..12],
507 &h[12..16],
508 &h[16..20],
509 &h[20..]
510 )
511}
512
513fn content_type(kind: PimKind, component: &str) -> String {
514 match kind {
515 PimKind::Calendar => format!("text/calendar; charset=utf-8; component={component}"),
516 PimKind::AddressBook => "text/vcard; charset=utf-8".to_string(),
517 }
518}
519
520// ---------------------------------------------------------------------------
521// PROPPATCH, MKCALENDAR, MKCOL
522// ---------------------------------------------------------------------------
523
524async fn proppatch(state: &AppState, me: &Me, target: &Target, body: Body) -> Reply {
525 let Target::Collection(kind, _, slug) = target else {
526 return Ok(status(StatusCode::FORBIDDEN));
527 };
528 let Some(mut col) = state.db.pim_collection(me.id, *kind, slug).await? else {
529 return Ok(status(StatusCode::NOT_FOUND));
530 };
531 let Some(body) = read_body(body, MAX_XML_SIZE).await else {
532 return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
533 };
534 let Ok(update) = xml::update(&body) else {
535 return Ok(status(StatusCode::BAD_REQUEST));
536 };
537 let (ok, results) = apply(*kind, &mut col, &update, false);
538 if ok {
539 state.db.pim_update_collection(&col).await?;
540 }
541 let mut r = xml::Response::new(me.collection(*kind, slug));
542 for (code, prop) in results {
543 r.push(code, prop);
544 }
545 Ok(xml_response(
546 StatusCode::MULTI_STATUS,
547 xml::multistatus(&Name::new(DAV, "multistatus"), &[r]),
548 ))
549}
550
551async fn mkcol(state: &AppState, me: &Me, target: &Target, method: &str, body: Body) -> Reply {
552 let Target::Collection(kind, _, slug) = target else {
553 return Ok(status(StatusCode::FORBIDDEN));
554 };
555 let calendar = method == "MKCALENDAR";
556 if calendar && *kind != PimKind::Calendar {
557 return Ok(status(StatusCode::FORBIDDEN));
558 }
559 if state.db.pim_collection(me.id, *kind, slug).await?.is_some() {
560 return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
561 }
562 let Some(body) = read_body(body, MAX_XML_SIZE).await else {
563 return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
564 };
565 let Ok(update) = xml::update(&body) else {
566 return Ok(status(StatusCode::BAD_REQUEST));
567 };
568 // A plain MKCOL makes a plain collection, which a calendar home cannot
569 // hold. An address book home takes it as an address book.
570 let typed = update
571 .set
572 .iter()
573 .any(|p| Name::of(p).is(DAV, "resourcetype"));
574 if !calendar && *kind == PimKind::Calendar && !typed {
575 return Ok(status(StatusCode::FORBIDDEN));
576 }
577 let mut col = PimCollection {
578 slug: slug.clone(),
579 components: match kind {
580 PimKind::Calendar => "VEVENT,VTODO".to_string(),
581 PimKind::AddressBook => String::new(),
582 },
583 ..Default::default()
584 };
585 let (ok, results) = apply(*kind, &mut col, &update, true);
586 if !ok {
587 let root = match calendar {
588 true => Name::new(CALDAV, "mkcalendar-response"),
589 false => Name::new(DAV, "mkcol-response"),
590 };
591 let propstats = group(results);
592 return Ok(xml_response(
593 StatusCode::FORBIDDEN,
594 xml::propstat_document(&root, &propstats),
595 ));
596 }
597 if !state.db.pim_create_collection(me.id, *kind, &col).await? {
598 return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
599 }
600 Ok(status(StatusCode::CREATED))
601}
602
603fn group(results: Vec<(u16, Element)>) -> Vec<(u16, Vec<Element>)> {
604 let mut r = xml::Response::default();
605 for (code, prop) in results {
606 r.push(code, prop);
607 }
608 r.propstats
609}
610
611/// Applies property changes to `col`. Returns whether all of them are
612/// allowed, and each property with its status. Nothing may be stored unless
613/// all are: RFC 4918 makes PROPPATCH atomic.
614fn apply(
615 kind: PimKind,
616 col: &mut PimCollection,
617 update: &Update,
618 creating: bool,
619) -> (bool, Vec<(u16, Element)>) {
620 let cal = kind == PimKind::Calendar;
621 let mut results = Vec::new();
622 for p in &update.set {
623 let name = Name::of(p);
624 let value = || Some(xml::text(p)).filter(|v| !v.is_empty());
625 let ok = match (name.ns.as_str(), name.local.as_str()) {
626 (DAV, "displayname") => {
627 col.displayname = value();
628 true
629 }
630 (CALDAV, "calendar-description") if cal => {
631 col.description = value();
632 true
633 }
634 (CARDDAV, "addressbook-description") if !cal => {
635 col.description = value();
636 true
637 }
638 (APPLE, "calendar-color") if cal => {
639 col.color = value();
640 true
641 }
642 (APPLE, "calendar-order") if cal => {
643 col.sort_order = value();
644 true
645 }
646 (CALDAV, "calendar-timezone") if cal => {
647 let tz = value();
648 let valid = tz.as_deref().is_none_or(is_timezone);
649 if valid {
650 col.timezone = tz;
651 }
652 valid
653 }
654 (DAV, "resourcetype") if creating => {
655 let wanted = match kind {
656 PimKind::Calendar => (CALDAV, "calendar"),
657 PimKind::AddressBook => (CARDDAV, "addressbook"),
658 };
659 xml::child(p, wanted.0, wanted.1).is_some()
660 }
661 (CALDAV, "supported-calendar-component-set") if creating && cal => {
662 let comps: Vec<_> = xml::elements(p)
663 .filter(|c| Name::of(c).is(CALDAV, "comp"))
664 .filter_map(|c| c.attributes.get("name"))
665 .map(|n| n.to_ascii_uppercase())
666 .collect();
667 let valid = !comps.is_empty()
668 && comps
669 .iter()
670 .all(|c| ["VEVENT", "VTODO", "VJOURNAL"].contains(&c.as_str()));
671 if valid {
672 col.components = comps.join(",");
673 }
674 valid
675 }
676 _ => false,
677 };
678 results.push((if ok { 200 } else { 403 }, name.element()));
679 }
680 for name in &update.remove {
681 let field = match (name.ns.as_str(), name.local.as_str()) {
682 (DAV, "displayname") => Some(&mut col.displayname),
683 (CALDAV, "calendar-description") if cal => Some(&mut col.description),
684 (CARDDAV, "addressbook-description") if !cal => Some(&mut col.description),
685 (APPLE, "calendar-color") if cal => Some(&mut col.color),
686 (APPLE, "calendar-order") if cal => Some(&mut col.sort_order),
687 (CALDAV, "calendar-timezone") if cal => Some(&mut col.timezone),
688 _ => None,
689 };
690 let ok = field.map(|f| *f = None).is_some();
691 results.push((if ok { 200 } else { 403 }, name.element()));
692 }
693 let ok = results.iter().all(|(code, _)| *code == 200);
694 if !ok {
695 for (code, _) in &mut results {
696 if *code == 200 {
697 *code = 424;
698 }
699 }
700 }
701 (ok, results)
702}
703
704/// A VCALENDAR holding a VTIMEZONE, as `calendar-timezone` must be.
705fn is_timezone(v: &str) -> bool {
706 use pimdav::calcard::icalendar::{ICalendar, ICalendarComponentType};
707 ICalendar::parse(v).is_ok_and(|c| {
708 c.components
709 .iter()
710 .any(|c| c.component_type == ICalendarComponentType::VTimezone)
711 })
712}
713
714// ---------------------------------------------------------------------------
715// Objects
716// ---------------------------------------------------------------------------
717
718async fn get(state: &AppState, me: &Me, target: &Target, head: bool) -> Reply {
719 let Target::Object(kind, _, slug, name) = target else {
720 return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
721 };
722 let found = match state.db.pim_collection(me.id, *kind, slug).await? {
723 Some(c) => state.db.pim_object(c.id, name).await?,
724 None => None,
725 };
726 let Some((o, data)) = found else {
727 return Ok(status(StatusCode::NOT_FOUND));
728 };
729 let body = if head {
730 Body::empty()
731 } else {
732 Body::from(data)
733 };
734 Ok((
735 StatusCode::OK,
736 [
737 (CONTENT_TYPE, content_type(*kind, &o.component)),
738 (ETAG, o.etag),
739 ],
740 body,
741 )
742 .into_response())
743}
744
745async fn put(state: &AppState, me: &Me, target: &Target, headers: &HeaderMap, body: Body) -> Reply {
746 let Target::Object(kind, _, slug, name) = target else {
747 return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
748 };
749 let Some(col) = state.db.pim_collection(me.id, *kind, slug).await? else {
750 return Ok(status(StatusCode::CONFLICT));
751 };
752 let ns = match kind {
753 PimKind::Calendar => CALDAV,
754 PimKind::AddressBook => CARDDAV,
755 };
756 let Some(data) = read_body(body, MAX_RESOURCE_SIZE).await else {
757 return Ok(error(StatusCode::FORBIDDEN, el(ns, "max-resource-size")));
758 };
759 let parsed = match kind {
760 PimKind::Calendar => {
761 let supported: Vec<&str> = col.components.split(',').collect();
762 object::calendar(&data, &supported).map(|o| (o.uid, o.component.to_string()))
763 }
764 PimKind::AddressBook => {
765 object::vcard(&data).map(|uid| (uid.unwrap_or_else(|| name.clone()), "VCARD".into()))
766 }
767 };
768 let (uid, component) = match parsed {
769 Ok(v) => v,
770 Err(invalid) => return Ok(error(StatusCode::FORBIDDEN, invalid.condition())),
771 };
772 let etag = format!("\"{}\"", crate::hex(&Sha256::digest(&data)[..16]));
773 let obj = PimObject {
774 name: name.clone(),
775 uid,
776 component,
777 etag: etag.clone(),
778 ..Default::default()
779 };
780 // The stored bytes are the request bytes, so the ETag may be returned.
781 match state
782 .db
783 .pim_put_object(col.id, &obj, &data, &precondition(headers))
784 .await?
785 {
786 PimWrite::Created => Ok((StatusCode::CREATED, [(ETAG, etag)]).into_response()),
787 PimWrite::Updated => Ok((StatusCode::NO_CONTENT, [(ETAG, etag)]).into_response()),
788 PimWrite::PreconditionFailed => Ok(status(StatusCode::PRECONDITION_FAILED)),
789 PimWrite::UidConflict(holder) => Ok(error(
790 StatusCode::FORBIDDEN,
791 with_children(
792 el(ns, "no-uid-conflict"),
793 hrefs([me.object(*kind, slug, &holder).as_str()]),
794 ),
795 )),
796 PimWrite::Deleted | PimWrite::NotFound => Ok(status(StatusCode::INTERNAL_SERVER_ERROR)),
797 }
798}
799
800async fn delete(state: &AppState, me: &Me, target: &Target, headers: &HeaderMap) -> Reply {
801 let (kind, slug, name) = match target {
802 Target::Collection(k, _, s) => (k, s, None),
803 Target::Object(k, _, s, n) => (k, s, Some(n)),
804 _ => return Ok(status(StatusCode::FORBIDDEN)),
805 };
806 let Some(col) = state.db.pim_collection(me.id, *kind, slug).await? else {
807 return Ok(status(StatusCode::NOT_FOUND));
808 };
809 let Some(name) = name else {
810 state.db.pim_delete_collection(col.id).await?;
811 return Ok(status(StatusCode::NO_CONTENT));
812 };
813 Ok(
814 match state
815 .db
816 .pim_delete_object(col.id, name, &precondition(headers))
817 .await?
818 {
819 PimWrite::Deleted => status(StatusCode::NO_CONTENT),
820 PimWrite::NotFound => status(StatusCode::NOT_FOUND),
821 PimWrite::PreconditionFailed => status(StatusCode::PRECONDITION_FAILED),
822 _ => status(StatusCode::INTERNAL_SERVER_ERROR),
823 },
824 )
825}
826
827fn precondition(headers: &HeaderMap) -> Precondition {
828 let header = |name: &str| {
829 headers
830 .get(name)
831 .and_then(|v| v.to_str().ok())
832 .map(str::to_string)
833 };
834 Precondition {
835 if_match: header("if-match"),
836 if_none_match: header("if-none-match"),
837 }
838}
839