1use 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
144fn 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 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
607pub 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}