//! Principals: attendee search (RFC 3744 9.4 and Apple's //! calendarserver-principal-search) and the system address book. use xmltree::Element; use crate::filter::{Collation, MatchType, TextMatch}; use crate::report::Refused; use crate::xml::{CALDAV, CALSERVER, DAV, Name, Propfind, child, elements, text}; /// The `calendar-user-type` of a principal. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UserType { Individual, Room, Resource, } impl UserType { pub fn as_str(self) -> &'static str { match self { UserType::Individual => "INDIVIDUAL", UserType::Room => "ROOM", UserType::Resource => "RESOURCE", } } } /// What a search can see of a principal. #[derive(Debug, Clone)] pub struct Principal<'a> { /// The URL segment. pub name: &'a str, pub display: &'a str, /// The calendar user addresses, `mailto:` ones included. pub addresses: &'a [String], pub kind: UserType, } #[derive(Debug, Clone, PartialEq)] pub struct Search { pub terms: Vec, /// `allof`: every term must match. Otherwise one is enough. pub all: bool, /// Only principals of this type, from a calendarserver search context. pub kind: Option, pub find: Propfind, pub limit: Option, } #[derive(Debug, Clone, PartialEq)] pub struct Term { /// The properties the text is looked for in. Empty means all searchable /// ones and the name. pub props: Vec, pub text: TextMatch, } /// The properties `principal-search-property-set` offers. pub const SEARCHABLE: [(&str, &str, &str); 3] = [ (DAV, "displayname", "Display name"), (CALDAV, "calendar-user-address-set", "Calendar user address"), (CALDAV, "calendar-user-type", "Calendar user type"), ]; impl Search { pub fn matches(&self, p: &Principal) -> bool { if self.kind.is_some_and(|k| k != p.kind) { return false; } let hit = |t: &Term| { let any = t.props.is_empty(); let wants = |ns: &str, local: &str| any || t.props.iter().any(|n| n.is(ns, local)); (any && t.text.matches(p.name)) || (wants(DAV, "displayname") && t.text.matches(p.display)) || (wants(CALDAV, "calendar-user-type") && t.text.matches(p.kind.as_str())) || ((wants(CALDAV, "calendar-user-address-set") || wants(CALSERVER, "email-address-set")) && p.addresses.iter().any(|a| { t.text.matches(a) || a.strip_prefix("mailto:").is_some_and(|m| t.text.matches(m)) })) }; // No terms lists every principal. match self.all || self.terms.is_empty() { true => self.terms.iter().all(hit), false => self.terms.iter().any(hit), } } } fn term_text(e: &Element) -> Result { let match_type = match e.attributes.get("match-type").map(String::as_str) { None | Some("contains") => MatchType::Contains, Some("starts-with") => MatchType::StartsWith, Some("ends-with") => MatchType::EndsWith, Some("equals") => MatchType::Equals, Some(_) => return Err(Refused::Invalid), }; Ok(TextMatch { text: text(e), collation: Collation::UnicodeCasemap, match_type, negate: false, }) } /// The properties in ``, and any stray property element beside it: /// python-caldav puts them there. fn prop_names(root: &Element) -> Propfind { let mut names: Vec = child(root, DAV, "prop") .map(|p| elements(p).map(Name::of).collect()) .unwrap_or_default(); let structure = [ (DAV, "prop"), (DAV, "property-search"), (DAV, "apply-to-principal-collection-set"), (DAV, "limit"), (CALSERVER, "search-token"), (CALSERVER, "limit"), ]; names.extend( elements(root) .map(Name::of) .filter(|n| !structure.iter().any(|(ns, l)| n.is(ns, l))), ); Propfind::Prop(names) } fn nresults(root: &Element, ns: &str) -> Result, Refused> { child(root, ns, "limit") .and_then(|l| child(l, ns, "nresults")) .map(|n| text(n).parse().map_err(|_| Refused::Invalid)) .transpose() } /// A `DAV:principal-property-search` body. pub fn property_search(root: &Element) -> Result { let mut terms = Vec::new(); for s in elements(root).filter(|e| Name::of(e).is(DAV, "property-search")) { let props = child(s, DAV, "prop") .map(|p| elements(p).map(Name::of).collect()) .unwrap_or_default(); let text = term_text(child(s, DAV, "match").ok_or(Refused::Invalid)?)?; terms.push(Term { props, text }); } Ok(Search { terms, all: root.attributes.get("test").map(String::as_str) != Some("anyof"), kind: None, find: prop_names(root), limit: nresults(root, DAV)?, }) } /// A `calendarserver-principal-search` body. Every token must match one of /// the name, the display name or an address. pub fn calendarserver_search(root: &Element) -> Result { let terms: Vec = elements(root) .filter(|e| Name::of(e).is(CALSERVER, "search-token")) .map(|e| { term_text(e).map(|text| Term { props: Vec::new(), text, }) }) .collect::>()?; let kind = match root.attributes.get("context").map(String::as_str) { Some("location") => Some(UserType::Room), Some("resource") => Some(UserType::Resource), Some("user") => Some(UserType::Individual), _ => None, }; Ok(Search { terms, all: root.attributes.get("test").map(String::as_str) != Some("anyof"), kind, find: prop_names(root), limit: nresults(root, CALSERVER)?, }) } /// The system address book's vCard of a principal. pub fn card(uid: &str, p: &Principal, email: &str) -> String { let kind = match p.kind { UserType::Individual => "individual", UserType::Room => "location", // RFC 6869. UserType::Resource => "device", }; let fn_ = escape(p.display); format!( "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:{uid}\r\nFN:{fn_}\r\nN:{fn_};;;;\r\nEMAIL;TYPE=INTERNET:{}\r\nKIND:{kind}\r\nEND:VCARD\r\n", escape(email) ) } fn escape(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { match c { '\\' | ',' | ';' => { out.push('\\'); out.push(c); } '\n' => out.push_str("\\n"), '\r' => {} _ => out.push(c), } } out }