principal.rs
⎇
Raw
1//! Principals: attendee search (RFC 3744 9.4 and Apple's
2//! calendarserver-principal-search) and the system address book.
3
4use xmltree::Element;
5
6use crate::filter::{Collation, TextMatch, match_type};
7use crate::report::{Refused, limit};
8use crate::text::escape_text;
9use crate::xml::{CALDAV, CALSERVER, DAV, Name, Propfind, child, elements, text};
10
11/// The `calendar-user-type` of a principal.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum UserType {
14 Individual,
15 Room,
16 Resource,
17}
18
19impl UserType {
20 pub fn as_str(self) -> &'static str {
21 match self {
22 UserType::Individual => "INDIVIDUAL",
23 UserType::Room => "ROOM",
24 UserType::Resource => "RESOURCE",
25 }
26 }
27}
28
29/// What a search can see of a principal.
30#[derive(Debug, Clone)]
31pub struct Principal<'a> {
32 /// The URL segment.
33 pub name: &'a str,
34 pub display: &'a str,
35 /// The calendar user addresses, `mailto:` ones included.
36 pub addresses: &'a [String],
37 pub kind: UserType,
38}
39
40#[derive(Debug, Clone, PartialEq)]
41pub struct Search {
42 pub terms: Vec<Term>,
43 /// `allof`: every term must match. Otherwise one is enough.
44 pub all: bool,
45 /// Only principals of this type, from a calendarserver search context.
46 pub kind: Option<UserType>,
47 pub find: Propfind,
48 pub limit: Option<usize>,
49}
50
51#[derive(Debug, Clone, PartialEq)]
52pub struct Term {
53 /// The properties the text is looked for in. Empty means all searchable
54 /// ones and the name.
55 pub props: Vec<Name>,
56 pub text: TextMatch,
57}
58
59/// The properties `principal-search-property-set` offers.
60pub const SEARCHABLE: [(&str, &str, &str); 3] = [
61 (DAV, "displayname", "Display name"),
62 (CALDAV, "calendar-user-address-set", "Calendar user address"),
63 (CALDAV, "calendar-user-type", "Calendar user type"),
64];
65
66impl Search {
67 pub fn matches(&self, p: &Principal) -> bool {
68 if self.kind.is_some_and(|k| k != p.kind) {
69 return false;
70 }
71 let hit = |t: &Term| {
72 let any = t.props.is_empty();
73 let wants = |ns: &str, local: &str| any || t.props.iter().any(|n| n.is(ns, local));
74 (any && t.text.matches(p.name))
75 || (wants(DAV, "displayname") && t.text.matches(p.display))
76 || (wants(CALDAV, "calendar-user-type") && t.text.matches(p.kind.as_str()))
77 || ((wants(CALDAV, "calendar-user-address-set")
78 || wants(CALSERVER, "email-address-set"))
79 && p.addresses.iter().any(|a| {
80 t.text.matches(a)
81 || a.strip_prefix("mailto:").is_some_and(|m| t.text.matches(m))
82 }))
83 };
84 // No terms lists every principal.
85 match self.all || self.terms.is_empty() {
86 true => self.terms.iter().all(hit),
87 false => self.terms.iter().any(hit),
88 }
89 }
90}
91
92fn term_text(e: &Element) -> Result<TextMatch, Refused> {
93 Ok(TextMatch {
94 text: text(e),
95 collation: Collation::UnicodeCasemap,
96 match_type: match_type(e)?,
97 negate: false,
98 })
99}
100
101/// The properties in `<prop>`, and any stray property element beside it:
102/// python-caldav puts them there.
103fn prop_names(root: &Element) -> Propfind {
104 let mut names: Vec<Name> = child(root, DAV, "prop")
105 .map(|p| elements(p).map(Name::of).collect())
106 .unwrap_or_default();
107 let structure = [
108 (DAV, "prop"),
109 (DAV, "property-search"),
110 (DAV, "apply-to-principal-collection-set"),
111 (DAV, "limit"),
112 (CALSERVER, "search-token"),
113 (CALSERVER, "limit"),
114 ];
115 names.extend(
116 elements(root)
117 .map(Name::of)
118 .filter(|n| !structure.iter().any(|(ns, l)| n.is(ns, l))),
119 );
120 Propfind::Prop(names)
121}
122
123/// A `DAV:principal-property-search` body.
124pub fn property_search(root: &Element) -> Result<Search, Refused> {
125 let mut terms = Vec::new();
126 for s in elements(root).filter(|e| Name::of(e).is(DAV, "property-search")) {
127 let props = child(s, DAV, "prop")
128 .map(|p| elements(p).map(Name::of).collect())
129 .unwrap_or_default();
130 let text = term_text(child(s, DAV, "match").ok_or(Refused::Invalid)?)?;
131 terms.push(Term { props, text });
132 }
133 Ok(Search {
134 terms,
135 all: root.attributes.get("test").map(String::as_str) != Some("anyof"),
136 kind: None,
137 find: prop_names(root),
138 limit: limit(root, DAV)?,
139 })
140}
141
142/// A `calendarserver-principal-search` body. Every token must match one of
143/// the name, the display name or an address.
144pub fn calendarserver_search(root: &Element) -> Result<Search, Refused> {
145 let terms: Vec<Term> = elements(root)
146 .filter(|e| Name::of(e).is(CALSERVER, "search-token"))
147 .map(|e| {
148 term_text(e).map(|text| Term {
149 props: Vec::new(),
150 text,
151 })
152 })
153 .collect::<Result<_, _>>()?;
154 let kind = match root.attributes.get("context").map(String::as_str) {
155 Some("location") => Some(UserType::Room),
156 Some("resource") => Some(UserType::Resource),
157 Some("user") => Some(UserType::Individual),
158 _ => None,
159 };
160 Ok(Search {
161 terms,
162 all: root.attributes.get("test").map(String::as_str) != Some("anyof"),
163 kind,
164 find: prop_names(root),
165 limit: limit(root, CALSERVER)?,
166 })
167}
168
169/// The system address book's vCard of a principal.
170pub fn card(uid: &str, p: &Principal, email: &str) -> String {
171 let kind = match p.kind {
172 UserType::Individual => "individual",
173 UserType::Room => "location",
174 // RFC 6869.
175 UserType::Resource => "device",
176 };
177 let fn_ = escape_text(p.display);
178 format!(
179 "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",
180 escape_text(email)
181 )
182}
183