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    let mut elem = Element::new("CARD:addressbook-home-set");
106    elem.namespace = Some(NS_CARDDAV_URI.to_string());
107
108    let mut href = Element::new("D:href");
109    href.namespace = Some("DAV:".to_string());
110    href.children
111        .push(xmltree::XMLNode::Text(format!("{prefix}{path}")));
112
113    elem.children.push(xmltree::XMLNode::Element(href));
114    elem
115}
116
117/// Check if a path is within the default CardDAV directory. Expects path without prefix.
118pub(crate) fn is_path_in_carddav_directory(dav_path: &DavPath) -> bool {
119    let path_string = dav_path.to_string();
120    path_string.len() > DEFAULT_CARDDAV_DIRECTORY_ENDSLASH.len()
121        && path_string.starts_with(DEFAULT_CARDDAV_DIRECTORY_ENDSLASH)
122}
123
124/// Check if content appears to be vCard data
125pub fn is_vcard_data(content: &[u8]) -> bool {
126    if !content.starts_with(b"BEGIN:VCARD") {
127        return false;
128    }
129
130    let trimmed = content.trim_ascii_end();
131    trimmed.ends_with(b"END:VCARD")
132}
133
134/// Validate vCard data using the calcard crate
135///
136/// This function validates that the content is a well-formed vCard.
137/// Use this function in your application layer to validate vCard data
138/// before or after writing to the filesystem.
139///
140/// # Example
141///
142/// ```ignore
143/// use dav_server::carddav::validate_vcard_data;
144///
145/// let vcard = "BEGIN:VCARD\nVERSION:3.0\nFN:Test\nEND:VCARD";
146/// match validate_vcard_data(vcard) {
147///     Ok(_) => println!("Valid vCard"),
148///     Err(e) => println!("Invalid vCard: {}", e),
149/// }
150/// ```
151#[cfg(feature = "carddav")]
152pub fn validate_vcard_data(content: &str) -> Result<VCard, String> {
153    VCard::parse(content).map_err(|e| format!("Invalid vCard data: {:?}", e))
154}
155
156/// Validate vCard data and check for required properties
157///
158/// This is a stricter validation that ensures the vCard has required properties
159/// like VERSION and FN (formatted name) as required by RFC 6350.
160///
161/// Returns an error message describing what's missing or invalid.
162#[cfg(feature = "carddav")]
163pub fn validate_vcard_strict(content: &str) -> Result<(), String> {
164    // First, try to parse the vCard
165    let vcard = validate_vcard_data(content)?;
166
167    // Check for VERSION property
168    if vcard.version().is_none() {
169        return Err("Missing required VERSION property".to_string());
170    }
171
172    // FN is required in vCard 3.0 and 4.0
173    // Check if FN exists in the parsed vCard or via string extraction
174    if extract_vcard_fn(content).is_none() {
175        return Err("Missing required FN (formatted name) property".to_string());
176    }
177
178    Ok(())
179}
180
181/// Extract the UID from vCard data
182///
183/// Handles both standard `UID:value` and grouped properties like `item1.UID:value`.
184/// Also handles properties with parameters like `UID;VALUE=TEXT:value`.
185pub fn extract_vcard_uid(content: &str) -> Option<String> {
186    for line in content.lines() {
187        let line = line.trim();
188        if let Some(uid) = extract_vcard_property_value(line, "UID") {
189            return Some(uid);
190        }
191    }
192    None
193}
194
195/// Extract the FN (formatted name) from vCard data
196///
197/// Handles both standard `FN:value` and grouped properties like `item1.FN:value`.
198/// Also handles properties with parameters like `FN;CHARSET=UTF-8:value`.
199pub fn extract_vcard_fn(content: &str) -> Option<String> {
200    for line in content.lines() {
201        let line = line.trim();
202        if let Some(fn_value) = extract_vcard_property_value(line, "FN") {
203            return Some(fn_value);
204        }
205    }
206    None
207}
208
209/// Helper function to extract a vCard property value, handling groups and parameters
210///
211/// Supports formats:
212/// - `PROPERTY:value`
213/// - `group.PROPERTY:value`
214/// - `PROPERTY;param=val:value`
215/// - `group.PROPERTY;param=val:value`
216fn extract_vcard_property_value(line: &str, property_name: &str) -> Option<String> {
217    // Find the colon that separates the property name from the value
218    let colon_pos = line.find(':')?;
219    let property_part = &line[..colon_pos];
220    let value = &line[colon_pos + 1..];
221
222    // Check if property part matches (with optional group prefix and parameters)
223    // The property name is before any semicolon (which starts parameters)
224    let name_part = property_part.split(';').next()?;
225
226    // Check for group prefix (e.g., "item1.UID" -> "UID")
227    let actual_name = if let Some(dot_pos) = name_part.find('.') {
228        &name_part[dot_pos + 1..]
229    } else {
230        name_part
231    };
232
233    if actual_name.eq_ignore_ascii_case(property_name) {
234        Some(value.to_string())
235    } else {
236        None
237    }
238}