Skip to main content

netget/server/dav_common/
client.rs

1//! The DAV client engine CalDAV and CardDAV share: RFC 6764 discovery, PROPFIND, REPORT
2//! queries and multistatus parsing, object GET/PUT/DELETE with preconditions, and collection
3//! creation. Outgoing objects are validated with the server's own parser.
4use super::object;
5use super::server::Kind;
6use super::xml::{self, CALDAV, CARDDAV, CS, DAV};
7use crate::client::http_fetch::FetchClient;
8use crate::client::llm_budget::call_llm_for_client;
9use crate::llm::actions::client_trait::{Client, ClientActionResult};
10use crate::logging::emit::Log;
11use crate::protocol::{ConnectContext, Event, EventType};
12use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
13use crate::state::{AccessLogOwner, ClientStatus};
14use anyhow::{bail, ensure, Context, Result};
15use base64::Engine;
16use hyper::Method;
17use serde_json::{json, Value};
18use std::net::SocketAddr;
19use std::sync::LazyLock;
20use std::time::Duration;
21use tokio::sync::mpsc;
22
23pub const DEFAULT_SCHEME: &str = "https";
24const TIMEOUT: Duration = Duration::from_secs(30);
25
26pub struct ClientFlavor {
27    pub kind: Kind,
28    pub name: &'static str,
29    pub prefix: &'static str,
30    pub connected_event: &'static LazyLock<EventType>,
31    pub response_event: &'static LazyLock<EventType>,
32    pub protocol: &'static (dyn Client + Sync),
33}
34
35impl ClientFlavor {
36    fn home_prop(&self) -> (&'static str, &'static str) {
37        match self.kind {
38            Kind::Calendar => (CALDAV, "calendar-home-set"),
39            Kind::AddressBook => (CARDDAV, "addressbook-home-set"),
40        }
41    }
42    fn collection_marker(&self) -> (&'static str, &'static str) {
43        match self.kind {
44            Kind::Calendar => (CALDAV, "calendar"),
45            Kind::AddressBook => (CARDDAV, "addressbook"),
46        }
47    }
48    fn content_type(&self) -> &'static str {
49        match self.kind {
50            Kind::Calendar => "text/calendar; charset=utf-8",
51            Kind::AddressBook => "text/vcard; charset=utf-8",
52        }
53    }
54}
55
56struct Conn {
57    fetch: FetchClient,
58    base: String,
59    auth: Option<String>,
60}
61
62struct Answer {
63    status: u16,
64    etag: Option<String>,
65    location: Option<String>,
66    body: String,
67}
68
69impl Conn {
70    async fn send(
71        &self,
72        method: &str,
73        path: &str,
74        depth: Option<&str>,
75        body: Option<(&str, String)>,
76        headers: &[(&str, String)],
77    ) -> Result<Answer> {
78        let m = Method::from_bytes(method.as_bytes())?;
79        let url = if path.starts_with("http") {
80            path.to_owned()
81        } else {
82            format!("{}{path}", self.base)
83        };
84        let mut r = self.fetch.request(m, &url);
85        if let Some(a) = &self.auth {
86            r = r.header("Authorization", format!("Basic {a}"));
87        }
88        if let Some(d) = depth {
89            r = r.header("Depth", d);
90        }
91        for (k, v) in headers {
92            r = r.header(*k, v);
93        }
94        if let Some((ct, b)) = body {
95            r = r.header("Content-Type", ct).body(b);
96        }
97        let resp = r.send().await?;
98        let status = resp.status().as_u16();
99        let h = |n: &str| {
100            resp.headers()
101                .get(n)
102                .and_then(|v| v.to_str().ok())
103                .map(str::to_owned)
104        };
105        let (etag, location) = (h("etag"), h("location"));
106        let body = String::from_utf8_lossy(&resp.bytes().await?).into_owned();
107        Ok(Answer {
108            status,
109            etag,
110            location,
111            body,
112        })
113    }
114
115    async fn propfind(
116        &self,
117        path: &str,
118        depth: &str,
119        props: &[(&str, &str)],
120    ) -> Result<Vec<(String, xml::El)>> {
121        let inner: String = props
122            .iter()
123            .map(|(ns, n)| format!("<{} xmlns=\"{ns}\"/>", n))
124            .collect();
125        let body = format!("<?xml version=\"1.0\" encoding=\"utf-8\"?><propfind xmlns=\"DAV:\"><prop>{inner}</prop></propfind>");
126        let a = self
127            .send(
128                "PROPFIND",
129                path,
130                Some(depth),
131                Some(("application/xml; charset=utf-8", body)),
132                &[],
133            )
134            .await?;
135        ensure!(
136            a.status == 207,
137            "PROPFIND {path} answered HTTP {}",
138            a.status
139        );
140        responses(&a.body)
141    }
142}
143
144/// `(href, merged 200 props)` for each `<D:response>` of a multistatus body.
145fn responses(body: &str) -> Result<Vec<(String, xml::El)>> {
146    let root = xml::parse(body.as_bytes())?;
147    ensure!(root.is(DAV, "multistatus"), "expected a multistatus body");
148    let mut out = Vec::new();
149    for r in root.children_named(DAV, "response") {
150        let href = r
151            .child(DAV, "href")
152            .map(|h| h.text.trim().to_owned())
153            .unwrap_or_default();
154        let mut merged = xml::El {
155            ns: DAV.into(),
156            name: "prop".into(),
157            ..Default::default()
158        };
159        for ps in r.children_named(DAV, "propstat") {
160            let ok = ps
161                .child(DAV, "status")
162                .is_some_and(|s| s.text.contains(" 200"));
163            if let (true, Some(p)) = (ok, ps.child(DAV, "prop")) {
164                merged.children.extend(p.children.iter().cloned());
165            }
166        }
167        out.push((href, merged));
168    }
169    Ok(out)
170}
171
172fn path_of(href: &str) -> String {
173    match href.split_once("://") {
174        Some((_, rest)) => rest
175            .find('/')
176            .map(|i| rest[i..].to_owned())
177            .unwrap_or_else(|| "/".into()),
178        None => href.to_owned(),
179    }
180}
181
182fn href_in(p: &xml::El, ns: &str, name: &str) -> Option<String> {
183    p.child(ns, name)
184        .and_then(|e| e.child(DAV, "href"))
185        .map(|h| path_of(h.text.trim()))
186}
187
188async fn discover(conn: &Conn, flavor: &ClientFlavor) -> Result<(String, String)> {
189    // RFC 6764 ยง5: the well-known URL redirects to the context path; follow up to three hops.
190    let mut path = format!("/.well-known/{}", flavor.prefix);
191    let mut principal = None;
192    for _ in 0..3 {
193        let a = conn.send("PROPFIND", &path, Some("0"), Some(("application/xml; charset=utf-8", "<?xml version=\"1.0\"?><propfind xmlns=\"DAV:\"><prop><current-user-principal/></prop></propfind>".into())), &[]).await?;
194        match a.status {
195            301 | 302 | 307 | 308 => {
196                path = path_of(&a.location.context("redirect without Location")?)
197            }
198            207 => {
199                principal = responses(&a.body)?
200                    .first()
201                    .and_then(|(_, p)| href_in(p, DAV, "current-user-principal"));
202                break;
203            }
204            _ => break,
205        }
206    }
207    if principal.is_none() {
208        principal = conn
209            .propfind("/", "0", &[(DAV, "current-user-principal")])
210            .await
211            .ok()
212            .and_then(|r| {
213                r.first()
214                    .and_then(|(_, p)| href_in(p, DAV, "current-user-principal"))
215            });
216    }
217    let principal = principal.context("the server names no current-user-principal")?;
218    let (ns, n) = flavor.home_prop();
219    let home = conn
220        .propfind(&principal, "0", &[(ns, n)])
221        .await?
222        .first()
223        .and_then(|(_, p)| href_in(p, ns, n))
224        .with_context(|| format!("the principal has no {n}"))?;
225    Ok((principal, home))
226}
227
228async fn collections(conn: &Conn, flavor: &ClientFlavor, home: &str) -> Result<Vec<Value>> {
229    let (mns, mname) = flavor.collection_marker();
230    let found = conn
231        .propfind(
232            home,
233            "1",
234            &[(DAV, "resourcetype"), (DAV, "displayname"), (CS, "getctag")],
235        )
236        .await?;
237    Ok(found
238        .into_iter()
239        .filter(|(_, p)| p.child(DAV, "resourcetype").is_some_and(|r| r.child(mns, mname).is_some()))
240        .map(|(h, p)| {
241            let path = path_of(&h);
242            let name = path.trim_end_matches('/').rsplit('/').next().unwrap_or("").to_owned();
243            json!({"href": path, "name": name, "displayname": p.child(DAV, "displayname").map(|d| d.text.clone()), "ctag": p.child(CS, "getctag").map(|d| d.text.clone())})
244        })
245        .collect())
246}
247
248pub async fn connect(ctx: ConnectContext, flavor: &'static ClientFlavor) -> Result<SocketAddr> {
249    let p = ctx.startup_params.as_ref();
250    let opt = |k: &str| -> Result<Option<String>> {
251        Ok(p.map(|p| p.get_optional_string(k)).transpose()?.flatten())
252    };
253    let scheme = opt("scheme")?.unwrap_or_else(|| DEFAULT_SCHEME.to_owned());
254    ensure!(
255        scheme == "http" || scheme == "https",
256        "scheme must be http or https"
257    );
258    let base = format!("{scheme}://{}", ctx.remote_addr);
259    crate::client::http_fetch::check_url(&base)?;
260    #[cfg(not(target_arch = "wasm32"))]
261    let fetch = FetchClient::from_reqwest(
262        crate::llm::ollama_client::configured_for_endpoint(
263            reqwest::Client::builder()
264                .timeout(TIMEOUT)
265                .redirect(reqwest::redirect::Policy::none()),
266            &base,
267        )
268        .build()?,
269    );
270    #[cfg(target_arch = "wasm32")]
271    let fetch = FetchClient::transport(TIMEOUT);
272    let auth = match opt("username")? {
273        Some(u) => Some(
274            base64::engine::general_purpose::STANDARD
275                .encode(format!("{u}:{}", opt("password")?.unwrap_or_default())),
276        ),
277        None => None,
278    };
279    let conn = Conn {
280        fetch: fetch
281            .with_max_body(xml::MAX_BODY * 4)
282            .with_user_agent("netget-dav"),
283        base,
284        auth,
285    };
286    let (principal, home) = discover(&conn, flavor)
287        .await
288        .context("discovering the principal and home")?;
289    let colls = collections(&conn, flavor, &home).await?;
290    let local: SocketAddr = "0.0.0.0:0".parse()?;
291    ctx.state
292        .update_client_status(ctx.client_id, ClientStatus::Connected)
293        .await;
294    let external =
295        crate::client::command_support::register_command_channel(&ctx.state, ctx.client_id).await;
296    let (internal_tx, internal_rx) = mpsc::channel::<Value>(16);
297    let (event_tx, mut event_rx) = mpsc::channel::<Event>(16);
298    event_tx.try_send(Event::new(
299        flavor.connected_event,
300        json!({"principal": principal, "home": home, "collections": colls}),
301    ))?;
302    let events_ctx = ctx.clone();
303    let dispatcher = tokio::spawn(async move {
304        while let Some(event) = event_rx.recv().await {
305            let instruction = events_ctx
306                .state
307                .get_instruction_for_client(events_ctx.client_id)
308                .await
309                .unwrap_or_default();
310            let memory = events_ctx
311                .state
312                .get_memory_for_client(events_ctx.client_id)
313                .await
314                .unwrap_or_default();
315            match call_llm_for_client(
316                &events_ctx.llm_client,
317                &events_ctx.state,
318                events_ctx.client_id.to_string(),
319                &instruction,
320                &memory,
321                Some(&event),
322                flavor.protocol,
323                &events_ctx.status_tx,
324            )
325            .await
326            {
327                Ok(result) => {
328                    if let Some(memory) = result.memory_updates {
329                        events_ctx
330                            .state
331                            .set_memory_for_client(events_ctx.client_id, memory)
332                            .await;
333                    }
334                    for action in result.actions {
335                        if internal_tx.send(action).await.is_err() {
336                            return;
337                        }
338                    }
339                }
340                Err(e) => Log::new(Some(&events_ctx.status_tx))
341                    .warn(format!("{} client handler: {e}", flavor.name)),
342            }
343        }
344    });
345    ctx.state
346        .register_client_task(ctx.client_id, dispatcher)
347        .await;
348    let session_ctx = ctx.clone();
349    let task = tokio::spawn(async move {
350        let result = session(
351            &session_ctx,
352            flavor,
353            &conn,
354            &home,
355            external,
356            internal_rx,
357            event_tx,
358        )
359        .await;
360        let status = match result {
361            Ok(()) => ClientStatus::Disconnected,
362            Err(e) => {
363                Log::new(Some(&session_ctx.status_tx))
364                    .warn(format!("{} client ended: {e}", flavor.name));
365                ClientStatus::Error(e.to_string())
366            }
367        };
368        session_ctx
369            .state
370            .update_client_status(session_ctx.client_id, status)
371            .await;
372        session_ctx
373            .state
374            .remove_client_handle(session_ctx.client_id)
375            .await;
376        let _ = session_ctx.status_tx.send("__UPDATE_UI__".into());
377    });
378    ctx.state.register_client_task(ctx.client_id, task).await;
379    Ok(local)
380}
381
382fn reply(command: Option<ClientCommand>, outcome: Result<ClientSendOutcome>) {
383    if let Some(c) = command {
384        crate::client::command_support::reply(c, outcome);
385    }
386}
387
388async fn session(
389    ctx: &ConnectContext,
390    flavor: &ClientFlavor,
391    conn: &Conn,
392    home: &str,
393    mut external: mpsc::Receiver<ClientCommand>,
394    mut internal: mpsc::Receiver<Value>,
395    events: mpsc::Sender<Event>,
396) -> Result<()> {
397    loop {
398        let (action, command) = tokio::select! {
399            c = external.recv() => match c { Some(c) => (c.action.clone(), Some(c)), None => return Ok(()) },
400            a = internal.recv() => match a { Some(a) => (a, None), None => return Ok(()) },
401        };
402        match flavor.protocol.execute_action(action.clone()) {
403            Ok(ClientActionResult::Disconnect) => {
404                reply(command, Ok(ClientSendOutcome::Disconnected));
405                return Ok(());
406            }
407            Ok(_) => {}
408            Err(e) => {
409                reply(
410                    command,
411                    Ok(ClientSendOutcome::Rejected {
412                        error: e.to_string(),
413                    }),
414                );
415                continue;
416            }
417        }
418        let outcome = perform(flavor, conn, home, &action).await;
419        if command.is_some() {
420            ctx.state
421                .record_access_log(
422                    AccessLogOwner::Client(ctx.client_id.as_u32()),
423                    flavor.name,
424                    None,
425                    "injected_action",
426                    json!({"type": action["type"], "collection": action["collection"]}),
427                    vec![json!({"ok": outcome.is_ok()})],
428                )
429                .await;
430        }
431        match outcome {
432            Ok(data) => {
433                reply(command, Ok(ClientSendOutcome::Sent { bytes_sent: 0 }));
434                events
435                    .send(Event::new(flavor.response_event, data))
436                    .await
437                    .context("DAV event consumer stopped")?;
438            }
439            Err(e) => {
440                Log::new(Some(&ctx.status_tx)).warn(format!("{} request failed: {e}", flavor.name));
441                reply(command, Err(e));
442            }
443        }
444    }
445}
446
447fn collection_path(home: &str, coll: &str) -> String {
448    if coll.starts_with('/') {
449        format!("{}/", coll.trim_end_matches('/'))
450    } else {
451        format!("{}/{}/", home.trim_end_matches('/'), coll)
452    }
453}
454
455fn objects_from(
456    found: Vec<(String, xml::El)>,
457    coll: &str,
458    data: bool,
459    flavor: &ClientFlavor,
460) -> Vec<Value> {
461    let (dns, dname) = match flavor.kind {
462        Kind::Calendar => (CALDAV, "calendar-data"),
463        Kind::AddressBook => (CARDDAV, "address-data"),
464    };
465    found
466        .into_iter()
467        .map(|(h, p)| (path_of(&h), p))
468        .filter(|(h, _)| h.trim_end_matches('/') != coll.trim_end_matches('/'))
469        .map(|(h, p)| {
470            let name = h.rsplit('/').next().unwrap_or("").to_owned();
471            let mut o = json!({"href": h, "name": name, "etag": p.child(DAV, "getetag").map(|e| e.text.clone())});
472            if data {
473                o["data"] = json!(p.child(dns, dname).map(|d| d.text.clone()));
474            }
475            o
476        })
477        .collect()
478}
479
480async fn perform(flavor: &ClientFlavor, conn: &Conn, home: &str, a: &Value) -> Result<Value> {
481    let op = a["type"]
482        .as_str()
483        .unwrap_or_default()
484        .strip_prefix(&format!("{}_", flavor.prefix))
485        .unwrap_or_default()
486        .to_owned();
487    let coll = collection_path(home, a["collection"].as_str().unwrap_or_default());
488    let item = format!("{coll}{}", a["name"].as_str().unwrap_or_default());
489    let mut out = json!({"operation": op});
490    let status = match op.as_str() {
491        "list" => {
492            let found = conn
493                .propfind(&coll, "1", &[(DAV, "getetag"), (DAV, "getcontenttype")])
494                .await?;
495            out["objects"] = json!(objects_from(found, &coll, false, flavor));
496            207
497        }
498        "get" => {
499            let r = conn.send("GET", &item, None, None, &[]).await?;
500            if r.status == 200 {
501                out["data"] = json!(r.body);
502                out["etag"] = json!(r.etag);
503            }
504            r.status
505        }
506        "put" => {
507            let data = a["data"].as_str().unwrap_or_default().to_owned();
508            let mut headers = Vec::new();
509            if let Some(e) = a["if_match"].as_str() {
510                headers.push(("If-Match", e.to_owned()));
511            }
512            if a["create_only"].as_bool().unwrap_or(false) {
513                headers.push(("If-None-Match", "*".to_owned()));
514            }
515            let r = conn
516                .send(
517                    "PUT",
518                    &item,
519                    None,
520                    Some((flavor.content_type(), data)),
521                    &headers,
522                )
523                .await?;
524            out["etag"] = json!(r.etag);
525            if r.status >= 400 {
526                out["error"] = json!(r.body.chars().take(512).collect::<String>());
527            }
528            r.status
529        }
530        "delete" => {
531            let headers: Vec<(&str, String)> = a["if_match"]
532                .as_str()
533                .map(|e| vec![("If-Match", e.to_owned())])
534                .unwrap_or_default();
535            let r = conn
536                .send(
537                    "DELETE",
538                    if a["name"].is_string() { &item } else { &coll },
539                    None,
540                    None,
541                    &headers,
542                )
543                .await?;
544            r.status
545        }
546        "query" => {
547            let body = match flavor.kind {
548                Kind::Calendar => {
549                    let comp = a["component"].as_str().unwrap_or("VEVENT");
550                    let range = match (a["start"].as_str(), a["end"].as_str()) {
551                        (None, None) => String::new(),
552                        (s, e) => format!(
553                            "<C:time-range{}{}/>",
554                            s.map(|s| format!(" start=\"{}\"", xml::escape(s)))
555                                .unwrap_or_default(),
556                            e.map(|e| format!(" end=\"{}\"", xml::escape(e)))
557                                .unwrap_or_default()
558                        ),
559                    };
560                    format!("<?xml version=\"1.0\" encoding=\"utf-8\"?><C:calendar-query xmlns:D=\"DAV:\" xmlns:C=\"{CALDAV}\"><D:prop><D:getetag/><C:calendar-data/></D:prop><C:filter><C:comp-filter name=\"VCALENDAR\"><C:comp-filter name=\"{}\">{range}</C:comp-filter></C:comp-filter></C:filter></C:calendar-query>", xml::escape(comp))
561                }
562                Kind::AddressBook => {
563                    let prop = a["property"].as_str().unwrap_or("FN");
564                    let mt = a["match_type"].as_str().unwrap_or("contains");
565                    format!("<?xml version=\"1.0\" encoding=\"utf-8\"?><CR:addressbook-query xmlns:D=\"DAV:\" xmlns:CR=\"{CARDDAV}\"><D:prop><D:getetag/><CR:address-data/></D:prop><CR:filter><CR:prop-filter name=\"{}\"><CR:text-match collation=\"i;unicode-casemap\" match-type=\"{}\">{}</CR:text-match></CR:prop-filter></CR:filter></CR:addressbook-query>", xml::escape(prop), xml::escape(mt), xml::escape(a["text"].as_str().unwrap_or_default()))
566                }
567            };
568            let r = conn
569                .send(
570                    "REPORT",
571                    &coll,
572                    Some("1"),
573                    Some(("application/xml; charset=utf-8", body)),
574                    &[],
575                )
576                .await?;
577            if r.status == 207 {
578                out["objects"] = json!(objects_from(responses(&r.body)?, &coll, true, flavor));
579            }
580            r.status
581        }
582        "make_collection" => {
583            let name = a["displayname"]
584                .as_str()
585                .map(|d| format!("<D:displayname>{}</D:displayname>", xml::escape(d)))
586                .unwrap_or_default();
587            let (method, body) = match flavor.kind {
588                Kind::Calendar => ("MKCALENDAR", format!("<?xml version=\"1.0\" encoding=\"utf-8\"?><C:mkcalendar xmlns:D=\"DAV:\" xmlns:C=\"{CALDAV}\"><D:set><D:prop>{name}</D:prop></D:set></C:mkcalendar>")),
589                Kind::AddressBook => ("MKCOL", format!("<?xml version=\"1.0\" encoding=\"utf-8\"?><D:mkcol xmlns:D=\"DAV:\" xmlns:CR=\"{CARDDAV}\"><D:set><D:prop><D:resourcetype><D:collection/><CR:addressbook/></D:resourcetype>{name}</D:prop></D:set></D:mkcol>")),
590            };
591            conn.send(
592                method,
593                &coll,
594                None,
595                Some(("application/xml; charset=utf-8", body)),
596                &[],
597            )
598            .await?
599            .status
600        }
601        other => bail!("unsupported operation {other}"),
602    };
603    out["status"] = json!(status);
604    Ok(out)
605}
606
607/// Shape checks shared by both clients' `execute_action`.
608pub fn check_action(flavor_prefix: &str, kind: Kind, v: &Value) -> Result<ClientActionResult> {
609    let ty = v["type"].as_str().unwrap_or_default();
610    if ty == "disconnect" {
611        return Ok(ClientActionResult::Disconnect);
612    }
613    let op = ty
614        .strip_prefix(&format!("{flavor_prefix}_"))
615        .context("unknown DAV client action")?;
616    ensure!(
617        matches!(
618            op,
619            "list" | "get" | "put" | "delete" | "query" | "make_collection"
620        ),
621        "unknown DAV client action {ty}"
622    );
623    let coll = v["collection"].as_str().context("collection is required")?;
624    ensure!(
625        !coll.is_empty()
626            && coll.len() <= 512
627            && !coll.contains("..")
628            && !crate::utils::sanitize::has_controls(&coll),
629        "collection is a name or an absolute path"
630    );
631    if matches!(op, "get" | "put") {
632        ensure!(
633            v["name"].as_str().is_some_and(super::server::segment_ok),
634            "name must be a simple file name"
635        );
636    }
637    if op == "put" {
638        let data = v["data"].as_str().context("data is required")?;
639        match kind {
640            Kind::Calendar => {
641                object::check_calendar(data)?;
642            }
643            Kind::AddressBook => {
644                object::check_vcard(data)?;
645            }
646        }
647    }
648    if op == "query" && kind == Kind::Calendar {
649        for k in ["start", "end"] {
650            if let Some(t) = v[k].as_str() {
651                ensure!(
652                    t.ends_with('Z') && object::timestamp(t).is_ok(),
653                    "{k} is a UTC date-time like 20261005T000000Z"
654                );
655            }
656        }
657    }
658    Ok(ClientActionResult::Custom {
659        name: ty.into(),
660        data: v.clone(),
661    })
662}