Skip to content

Commit acc941b

Browse files
authored
Add CardDAV (RFC 6352) support (#57)
Implement CardDAV functionality following the same patterns as CalDAV: - Add carddav feature flag with calcard dependency for vCard parsing - Implement MKADDRESSBOOK method for creating address book collections - Add REPORT support for addressbook-query and addressbook-multiget - Add addressbook-home-set property for client discovery - Add supported-address-data property (vCard 3.0 and 4.0) - Implement is_addressbook() trait method for DavMetaData - Add CardDAV example server with Axum
1 parent 4a0d59a commit acc941b

18 files changed

+1838
-64
lines changed

Cargo.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ default = ["localfs", "memfs"]
2222
actix-compat = [ "actix-web" ]
2323
warp-compat = [ "warp", "hyper" ]
2424
caldav = [ "icalendar" ]
25-
all = [ "actix-compat", "warp-compat", "caldav" ]
25+
carddav = [ "calcard" ]
26+
all = [ "actix-compat", "warp-compat", "caldav", "carddav" ]
2627
localfs = ["libc", "lru", "tokio/rt-multi-thread", "parking_lot", "reflink-copy"]
2728
memfs = ["libc"]
2829

@@ -38,6 +39,10 @@ required-features = [ "warp-compat" ]
3839
name = "caldav"
3940
required-features = [ "caldav" ]
4041

42+
[[example]]
43+
name = "carddav"
44+
required-features = [ "carddav" ]
45+
4146
[dependencies]
4247
bytes = "1.0.1"
4348
dyn-clone = "1"
@@ -67,6 +72,7 @@ warp = { version = "0.3.0", optional = true, default-features = false }
6772
actix-web = { version = "4.0.0-beta.15", default-features = false, optional = true }
6873
reflink-copy = { version = "0.1.14", optional = true }
6974
icalendar = { version = "0.17.1", optional = true }
75+
calcard = { version = "0.3", default-features = false, optional = true }
7076
derive-where = "1.6.0"
7177

7278
[dev-dependencies]

examples/carddav.rs

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
//! CardDAV server example
2+
//!
3+
//! This example demonstrates how to set up a CardDAV server using the dav-server library.
4+
//! CardDAV is an extension of WebDAV for contact/address book data management.
5+
//!
6+
//! Usage:
7+
//! cargo run --example carddav --features carddav
8+
//!
9+
//! The server will be available at http://localhost:8080
10+
//! You can connect to it using CardDAV clients like Thunderbird, Apple Contacts, etc.
11+
12+
use axum::{
13+
Extension, Router,
14+
body::Body,
15+
extract::Request,
16+
http::{HeaderValue, StatusCode},
17+
middleware::{self, Next},
18+
response::IntoResponse,
19+
routing::any,
20+
};
21+
use dav_server::{DavHandler, carddav::DEFAULT_CARDDAV_DIRECTORY, fakels::FakeLs, localfs};
22+
use http_body_util::BodyExt;
23+
use std::sync::Arc;
24+
use tokio::net::TcpListener;
25+
26+
#[tokio::main]
27+
async fn main() {
28+
env_logger::init();
29+
let addr = "127.0.0.1:8080";
30+
31+
let dav_server = DavHandler::builder()
32+
.filesystem(localfs::LocalFs::new("/tmp", true, false, false))
33+
.locksystem(FakeLs::new())
34+
.autoindex(true)
35+
.build_handler();
36+
37+
let router = Router::new()
38+
.route("/.well-known/carddav", any(handle_carddav_redirect))
39+
.route("/", any(handle_carddav))
40+
.route("/{*path}", any(handle_carddav))
41+
.layer(Extension(Arc::new(dav_server)))
42+
.layer(middleware::from_fn(log_request_middleware));
43+
44+
let listener = TcpListener::bind(&addr).await.unwrap();
45+
46+
println!("CardDAV server listening on http://{}", addr);
47+
println!(
48+
"Address book collections can be accessed at http://{}{}",
49+
addr, DEFAULT_CARDDAV_DIRECTORY
50+
);
51+
println!();
52+
println!(
53+
"NOTE: This example stores data in a temporary directory (/tmp). Data may be lost when the server stops or when temporary files are cleaned."
54+
);
55+
println!();
56+
println!("To create an address book collection, use:");
57+
println!(
58+
" curl -i -X MKADDRESSBOOK http://{}{}/my-contacts/",
59+
addr, DEFAULT_CARDDAV_DIRECTORY
60+
);
61+
println!();
62+
println!("To add a contact, use:");
63+
println!(
64+
" curl -i -X PUT http://{}{}/my-contacts/contact1.vcf \\",
65+
addr, DEFAULT_CARDDAV_DIRECTORY
66+
);
67+
println!(" -H 'Content-Type: text/vcard' \\");
68+
println!(" --data-binary @contact.vcf");
69+
println!();
70+
println!("Example contact.vcf content:");
71+
println!("BEGIN:VCARD");
72+
println!("VERSION:3.0");
73+
println!("UID:12345@example.com");
74+
println!("FN:John Doe");
75+
println!("N:Doe;John;;;");
76+
println!("EMAIL:john.doe@example.com");
77+
println!("TEL:+1-555-123-4567");
78+
println!("END:VCARD");
79+
80+
axum::serve(listener, router).await.unwrap();
81+
}
82+
83+
async fn handle_carddav_redirect() -> (
84+
StatusCode,
85+
[(axum::http::header::HeaderName, HeaderValue); 1],
86+
) {
87+
(
88+
StatusCode::MOVED_PERMANENTLY,
89+
[(
90+
axum::http::header::LOCATION,
91+
HeaderValue::from_static(DEFAULT_CARDDAV_DIRECTORY),
92+
)],
93+
)
94+
}
95+
96+
async fn handle_carddav(
97+
Extension(dav): Extension<Arc<DavHandler>>,
98+
req: Request,
99+
) -> impl IntoResponse {
100+
dav.handle(req).await
101+
}
102+
103+
async fn log_request_middleware(request: Request, next: Next) -> impl IntoResponse {
104+
// Print request line and headers
105+
println!("\n========== CLIENT REQUEST ==========");
106+
println!("{} {}", request.method(), request.uri(),);
107+
println!("--- Headers ---");
108+
for (name, value) in request.headers() {
109+
println!("{}: {}", name, value.to_str().unwrap_or("<binary>"));
110+
}
111+
112+
// Read and print body
113+
let (parts, body) = request.into_parts();
114+
let collected = body.collect().await.unwrap_or_default();
115+
let body_bytes = collected.to_bytes();
116+
117+
if !body_bytes.is_empty() {
118+
println!("--- Body ---");
119+
if let Ok(body_str) = std::str::from_utf8(&body_bytes) {
120+
println!("{}", body_str);
121+
} else {
122+
println!("<binary data: {} bytes>", body_bytes.len());
123+
}
124+
}
125+
println!("====================================\n");
126+
127+
// Reconstruct request with body
128+
let request = axum::http::Request::from_parts(parts, Body::from(body_bytes));
129+
130+
next.run(request).await
131+
}
132+
133+
#[cfg(not(feature = "carddav"))]
134+
fn main() {
135+
eprintln!("This example requires the 'carddav' feature to be enabled.");
136+
eprintln!("Run with: cargo run --example carddav --features carddav");
137+
std::process::exit(1);
138+
}

src/caldav.rs

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ use xmltree::Element;
1010

1111
use crate::davpath::DavPath;
1212

13+
// Re-export shared filter types
14+
pub use crate::dav_filters::{ParameterFilter, TextMatch};
15+
1316
// CalDAV XML namespaces
1417
pub const NS_CALDAV_URI: &str = "urn:ietf:params:xml:ns:caldav";
1518
pub const NS_CALENDARSERVER_URI: &str = "http://calendarserver.org/ns/";
@@ -116,6 +119,10 @@ pub struct ComponentFilter {
116119
pub comp_filters: Vec<ComponentFilter>,
117120
}
118121

122+
/// CalDAV property filter with time-range support
123+
///
124+
/// Note: CalDAV property filters include time-range which is not present
125+
/// in the shared ParameterFilter. CardDAV has a similar struct without time_range.
119126
#[derive(Debug, Clone)]
120127
pub struct PropertyFilter {
121128
pub name: String,
@@ -125,20 +132,6 @@ pub struct PropertyFilter {
125132
pub param_filters: Vec<ParameterFilter>,
126133
}
127134

128-
#[derive(Debug, Clone)]
129-
pub struct ParameterFilter {
130-
pub name: String,
131-
pub is_not_defined: bool,
132-
pub text_match: Option<TextMatch>,
133-
}
134-
135-
#[derive(Debug, Clone)]
136-
pub struct TextMatch {
137-
pub text: String,
138-
pub collation: Option<String>,
139-
pub negate_condition: bool,
140-
}
141-
142135
#[derive(Debug, Clone)]
143136
pub struct TimeRange {
144137
/// ISO 8601 format
@@ -227,18 +220,44 @@ pub fn is_calendar_data(content: &[u8]) -> bool {
227220
}
228221

229222
/// Validate iCalendar data using the icalendar crate
223+
///
224+
/// This function validates that the content is a well-formed iCalendar object.
225+
/// Use this function in your application layer to validate calendar data
226+
/// before or after writing to the filesystem.
227+
///
228+
/// # Example
229+
///
230+
/// ```ignore
231+
/// use dav_server::caldav::validate_calendar_data;
232+
///
233+
/// let ical = "BEGIN:VCALENDAR\nVERSION:2.0\nBEGIN:VEVENT\n...";
234+
/// match validate_calendar_data(ical) {
235+
/// Ok(_) => println!("Valid iCalendar"),
236+
/// Err(e) => println!("Invalid iCalendar: {}", e),
237+
/// }
238+
/// ```
239+
#[cfg(feature = "caldav")]
230240
pub fn validate_calendar_data(content: &str) -> Result<Calendar, String> {
231241
content
232242
.parse::<Calendar>()
233243
.map_err(|e| format!("Invalid iCalendar data: {}", e))
234244
}
235245

236246
/// Extract the UID from calendar data
247+
///
248+
/// Handles both standard `UID:value` and properties with parameters.
237249
pub fn extract_calendar_uid(content: &str) -> Option<String> {
238250
for line in content.lines() {
239251
let line = line.trim();
240-
if let Some(uid) = line.strip_prefix("UID:") {
241-
return Some(uid.to_string());
252+
// Handle simple UID:VALUE
253+
if let Some(value) = line.strip_prefix("UID:") {
254+
return Some(value.to_string());
255+
}
256+
// Handle UID with parameters: UID;PARAMS:VALUE
257+
if let Some(rest) = line.strip_prefix("UID;")
258+
&& let Some(colon_pos) = rest.find(':')
259+
{
260+
return Some(rest[colon_pos + 1..].to_string());
242261
}
243262
}
244263
None

0 commit comments

Comments
 (0)