Skip to main content

dav_server/
carddav.rs

1//! CardDAV (vCard Extensions to WebDAV) support
2//!
3//! This module provides CardDAV functionality on top of the base WebDAV implementation.
4//! CardDAV is defined in RFC 6352 and provides standardized access to address book data
5//! using the vCard format.
6
7#[cfg(feature = "carddav")]
8use calcard::vcard::VCard;
9use xmltree::Element;
10
11use crate::davpath::DavPath;
12
13// Re-export shared filter types
14pub use crate::dav_filters::{ParameterFilter, TextMatch};
15
16// CardDAV XML namespaces
17pub const NS_CARDDAV_URI: &str = "urn:ietf:params:xml:ns:carddav";
18
19/// The default carddav directory, which is being used for the preprovided filesystems. Path is without trailing slash
20pub const DEFAULT_CARDDAV_NAME: &str = "addressbooks";
21pub const DEFAULT_CARDDAV_DIRECTORY: &str = "/addressbooks";
22pub const DEFAULT_CARDDAV_DIRECTORY_ENDSLASH: &str = "/addressbooks/";
23
24/// Default maximum resource size for address book entries (1MB)
25pub const DEFAULT_MAX_RESOURCE_SIZE: u64 = 1024 * 1024;
26
27/// CardDAV address book collection properties
28#[derive(Debug, Clone)]
29pub struct AddressBookProperties {
30    pub description: Option<String>,
31    pub max_resource_size: Option<u64>,
32    pub display_name: Option<String>,
33}
34
35impl Default for AddressBookProperties {
36    fn default() -> Self {
37        Self {
38            description: None,
39            max_resource_size: Some(DEFAULT_MAX_RESOURCE_SIZE),
40            display_name: None,
41        }
42    }
43}
44
45/// Address book query filters for REPORT requests
46#[derive(Debug, Clone)]
47pub struct AddressBookQuery {
48    pub prop_filter: Option<PropertyFilter>,
49    pub properties: Vec<String>,
50    pub limit: Option<u32>,
51}
52
53/// CardDAV property filter
54///
55/// Note: CardDAV property filters are similar to CalDAV but without time_range.
56/// Both use the shared TextMatch and ParameterFilter types.
57#[derive(Debug, Clone)]
58pub struct PropertyFilter {
59    pub name: String,
60    pub is_not_defined: bool,
61    pub text_match: Option<TextMatch>,
62    pub param_filters: Vec<ParameterFilter>,
63}
64
65/// CardDAV REPORT request types
66#[derive(Debug, Clone)]
67pub enum CardDavReportType {
68    AddressBookQuery(AddressBookQuery),
69    AddressBookMultiget { hrefs: Vec<String> },
70}
71
72/// Helper functions for CardDAV XML generation
73pub fn create_supported_address_data() -> Element {
74    let mut elem = Element::new("CARD:supported-address-data");
75    elem.namespace = Some(NS_CARDDAV_URI.to_string());
76
77    let mut address_data = Element::new("CARD:address-data-type");
78    address_data.namespace = Some(NS_CARDDAV_URI.to_string());
79    address_data
80        .attributes
81        .insert("content-type".to_string(), "text/vcard".to_string());
82    address_data
83        .attributes
84        .insert("version".to_string(), "3.0".to_string());
85
86    elem.children.push(xmltree::XMLNode::Element(address_data));
87
88    // Also support vCard 4.0
89    let mut address_data_v4 = Element::new("CARD:address-data-type");
90    address_data_v4.namespace = Some(NS_CARDDAV_URI.to_string());
91    address_data_v4
92        .attributes
93        .insert("content-type".to_string(), "text/vcard".to_string());
94    address_data_v4
95        .attributes
96        .insert("version".to_string(), "4.0".to_string());
97
98    elem.children
99        .push(xmltree::XMLNode::Element(address_data_v4));
100
101    elem
102}
103
104pub fn create_addressbook_home_set(prefix: &str, path: &str) -> Element {
105    log::debug!("create_addressbook_home_set prefix: {:#?}", prefix);
106    log::debug!("create_addressbook_home_set path: {:#?}", path);
107
108    let mut elem = Element::new("CARD:addressbook-home-set");
109    elem.namespace = Some(NS_CARDDAV_URI.to_string());
110
111    let mut href = Element::new("D:href");
112    href.namespace = Some("DAV:".to_string());
113    href.children
114        .push(xmltree::XMLNode::Text(format!("{prefix}{path}")));
115
116    elem.children.push(xmltree::XMLNode::Element(href));
117    elem
118}
119
120/// Check if a path is within the default CardDAV directory. Expects path without prefix.
121pub(crate) fn is_path_in_carddav_directory(dav_path: &DavPath) -> bool {
122    let path_string = dav_path.to_string();
123    path_string.len() > DEFAULT_CARDDAV_DIRECTORY_ENDSLASH.len()
124        && path_string.starts_with(DEFAULT_CARDDAV_DIRECTORY_ENDSLASH)
125}
126
127/// Check if content appears to be vCard data
128pub fn is_vcard_data(content: &[u8]) -> bool {
129    if !content.starts_with(b"BEGIN:VCARD") {
130        return false;
131    }
132
133    let trimmed = content.trim_ascii_end();
134    trimmed.ends_with(b"END:VCARD")
135}
136
137/// Validate vCard data using the calcard crate
138///
139/// This function validates that the content is a well-formed vCard.
140/// Use this function in your application layer to validate vCard data
141/// before or after writing to the filesystem.
142///
143/// # Example
144///
145/// ```ignore
146/// use dav_server::carddav::validate_vcard_data;
147///
148/// let vcard = "BEGIN:VCARD\nVERSION:3.0\nFN:Test\nEND:VCARD";
149/// match validate_vcard_data(vcard) {
150///     Ok(_) => println!("Valid vCard"),
151///     Err(e) => println!("Invalid vCard: {}", e),
152/// }
153/// ```
154#[cfg(feature = "carddav")]
155pub fn validate_vcard_data(content: &str) -> Result<VCard, String> {
156    VCard::parse(content).map_err(|e| format!("Invalid vCard data: {:?}", e))
157}
158
159/// Validate vCard data and check for required properties
160///
161/// This is a stricter validation that ensures the vCard has required properties
162/// like VERSION and FN (formatted name) as required by RFC 6350.
163///
164/// Returns an error message describing what's missing or invalid.
165#[cfg(feature = "carddav")]
166pub fn validate_vcard_strict(content: &str) -> Result<(), String> {
167    // First, try to parse the vCard
168    let vcard = validate_vcard_data(content)?;
169
170    // Check for VERSION property
171    if vcard.version().is_none() {
172        return Err("Missing required VERSION property".to_string());
173    }
174
175    // FN is required in vCard 3.0 and 4.0
176    // Check if FN exists in the parsed vCard or via string extraction
177    if extract_vcard_fn(content).is_none() {
178        return Err("Missing required FN (formatted name) property".to_string());
179    }
180
181    Ok(())
182}
183
184/// Extract the UID from vCard data
185///
186/// Handles both standard `UID:value` and grouped properties like `item1.UID:value`.
187/// Also handles properties with parameters like `UID;VALUE=TEXT:value`.
188pub fn extract_vcard_uid(content: &str) -> Option<String> {
189    for line in content.lines() {
190        let line = line.trim();
191        if let Some(uid) = extract_vcard_property_value(line, "UID") {
192            return Some(uid);
193        }
194    }
195    None
196}
197
198/// Extract the FN (formatted name) from vCard data
199///
200/// Handles both standard `FN:value` and grouped properties like `item1.FN:value`.
201/// Also handles properties with parameters like `FN;CHARSET=UTF-8:value`.
202pub fn extract_vcard_fn(content: &str) -> Option<String> {
203    for line in content.lines() {
204        let line = line.trim();
205        if let Some(fn_value) = extract_vcard_property_value(line, "FN") {
206            return Some(fn_value);
207        }
208    }
209    None
210}
211
212/// Helper function to extract a vCard property value, handling groups and parameters
213///
214/// Supports formats:
215/// - `PROPERTY:value`
216/// - `group.PROPERTY:value`
217/// - `PROPERTY;param=val:value`
218/// - `group.PROPERTY;param=val:value`
219fn extract_vcard_property_value(line: &str, property_name: &str) -> Option<String> {
220    // Find the colon that separates the property name from the value
221    let colon_pos = line.find(':')?;
222    let property_part = &line[..colon_pos];
223    let value = &line[colon_pos + 1..];
224
225    // Check if property part matches (with optional group prefix and parameters)
226    // The property name is before any semicolon (which starts parameters)
227    let name_part = property_part.split(';').next()?;
228
229    // Check for group prefix (e.g., "item1.UID" -> "UID")
230    let actual_name = if let Some(dot_pos) = name_part.find('.') {
231        &name_part[dot_pos + 1..]
232    } else {
233        name_part
234    };
235
236    if actual_name.eq_ignore_ascii_case(property_name) {
237        Some(value.to_string())
238    } else {
239        None
240    }
241}