1pub mod actions;
3
4pub use actions::WebdavClientProtocol;
5
6use anyhow::{Context, Result};
7use std::collections::HashMap;
8use std::net::SocketAddr;
9use std::sync::Arc;
10use std::sync::OnceLock;
11use std::time::Duration;
12use tokio::sync::mpsc;
13use tracing::{error, info};
14
15use crate::client::llm_budget::call_llm_for_client;
16use crate::client::webdav::actions::WEBDAV_CLIENT_RESPONSE_RECEIVED_EVENT;
17use crate::llm::actions::client_trait::{Client, ClientActionResult};
18use crate::llm::actions::protocol_trait::Protocol;
19use crate::llm::ollama_client::OllamaClient;
20use crate::llm::ClientLlmResult;
21use crate::logging::emit::Log;
22use crate::protocol::Event;
23use crate::state::app_state::AppState;
24use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
25use crate::state::{AccessLogOwner, ClientId, ClientStatus};
26
27static HTTP_CLIENTS: OnceLock<tokio::sync::Mutex<HashMap<String, reqwest::Client>>> =
37 OnceLock::new();
38
39const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
41
42async fn http_client_for(base_url: &str) -> reqwest::Client {
44 let clients = HTTP_CLIENTS.get_or_init(|| tokio::sync::Mutex::new(HashMap::new()));
45
46 let existing = { clients.lock().await.get(base_url).cloned() };
49 if let Some(client) = existing {
50 return client;
51 }
52
53 let owned = base_url.to_string();
54 let built = tokio::task::spawn_blocking(move || {
55 crate::llm::ollama_client::configured_for_endpoint(
56 reqwest::Client::builder()
57 .timeout(REQUEST_TIMEOUT)
58 .redirect(crate::client::http_fetch::same_origin_redirects()),
59 &owned,
60 )
61 .build()
62 .unwrap_or_else(|_| reqwest::Client::new())
63 })
64 .await
65 .unwrap_or_else(|_| reqwest::Client::new());
66
67 clients
68 .lock()
69 .await
70 .entry(base_url.to_string())
71 .or_insert(built)
72 .clone()
73}
74
75pub struct WebdavClient;
77
78impl WebdavClient {
79 pub async fn connect_with_llm_actions(
81 remote_addr: String,
82 _llm_client: OllamaClient,
83 app_state: Arc<AppState>,
84 status_tx: mpsc::UnboundedSender<String>,
85 client_id: ClientId,
86 startup_params: Option<crate::protocol::StartupParams>,
87 ) -> Result<SocketAddr> {
88 info!(
92 "WebDAV client {} initialized for {}",
93 client_id, remote_addr
94 );
95
96 let (default_headers, auth) = match &startup_params {
112 Some(params) => (
113 params.get_optional_object("default_headers")?.cloned(),
114 params.get_optional_string("auth")?,
115 ),
116 None => (None, None),
117 };
118
119 let mut startup_headers = serde_json::Map::new();
120 for (key, value) in default_headers.unwrap_or_default() {
121 startup_headers.insert(key.to_ascii_lowercase(), value);
122 }
123 if let Some(credentials) = auth {
124 use base64::Engine;
125 let encoded = base64::engine::general_purpose::STANDARD.encode(credentials.as_bytes());
126 startup_headers.insert(
127 "authorization".to_string(),
128 serde_json::json!(format!("Basic {encoded}")),
129 );
130 }
131
132 app_state
134 .with_client_mut(client_id, |client| {
135 client.set_protocol_field(
136 "http_client".to_string(),
137 serde_json::json!("initialized"),
138 );
139 client.set_protocol_field("base_url".to_string(), serde_json::json!(remote_addr));
140 if !startup_headers.is_empty() {
141 client.set_protocol_field(
142 "startup_headers".to_string(),
143 serde_json::Value::Object(startup_headers.clone()),
144 );
145 }
146 })
147 .await;
148
149 app_state
151 .update_client_status(client_id, ClientStatus::Connected)
152 .await;
153 Log::new(Some(&status_tx)).info(format!(
154 "WebDAV client {} ready for {}",
155 client_id, remote_addr
156 ));
157 let _ = status_tx.send("__UPDATE_UI__".to_string());
158
159 let command_rx =
164 crate::client::command_support::register_command_channel(&app_state, client_id).await;
165 let cmd_task = tokio::spawn(Self::command_loop(
166 command_rx,
167 client_id,
168 app_state.clone(),
169 _llm_client.clone(),
170 status_tx.clone(),
171 ));
172 app_state.register_client_task(client_id, cmd_task).await;
173
174 let task_registrar = app_state.clone();
178 let task_handle = tokio::spawn(async move {
179 if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
180 let protocol =
181 Arc::new(crate::client::webdav::actions::WebdavClientProtocol::new());
182 let event = Event::new(
183 &crate::client::webdav::actions::WEBDAV_CLIENT_CONNECTED_EVENT,
184 serde_json::json!({
185 "base_url": app_state.with_client_mut(client_id, |c|
186 c.get_protocol_field("base_url").and_then(|v| v.as_str().map(|s| s.to_string()))
187 ).await.flatten().unwrap_or_default(),
188 }),
189 );
190
191 let memory = app_state
192 .get_memory_for_client(client_id)
193 .await
194 .unwrap_or_default();
195
196 match call_llm_for_client(
197 &_llm_client,
198 &app_state,
199 client_id.to_string(),
200 &instruction,
201 &memory,
202 Some(&event),
203 protocol.as_ref(),
204 &status_tx,
205 )
206 .await
207 {
208 Ok(ClientLlmResult {
209 actions,
210 memory_updates,
211 }) => {
212 if let Some(mem) = memory_updates {
214 app_state.set_memory_for_client(client_id, mem).await;
215 }
216
217 for action in actions {
219 match protocol.as_ref().execute_action(action.clone()) {
220 Ok(result) => {
221 if let Err(e) = Self::apply_action(
222 result,
223 Dispatch::Spawn,
224 &_llm_client,
225 &app_state,
226 &status_tx,
227 client_id,
228 )
229 .await
230 {
231 error!("Failed to execute WebDAV action: {}", e);
232 }
233 }
234 Err(e) => {
235 error!("WebDAV action execution error: {}", e);
236 }
237 }
238 }
239 }
240 Err(e) => {
241 error!("LLM error for WebDAV client {}: {}", client_id, e);
242 }
243 }
244 }
245 });
246 task_registrar
247 .register_client_task(client_id, task_handle)
248 .await;
249
250 Ok("0.0.0.0:0".parse().unwrap())
252 }
253
254 async fn command_loop(
263 mut command_rx: tokio::sync::mpsc::Receiver<ClientCommand>,
264 client_id: ClientId,
265 app_state: Arc<AppState>,
266 llm_client: OllamaClient,
267 status_tx: mpsc::UnboundedSender<String>,
268 ) {
269 let protocol = crate::client::webdav::actions::WebdavClientProtocol::new();
270
271 while let Some(command) = command_rx.recv().await {
272 let action = command.action.clone();
273 let outcome = match protocol.execute_action(action.clone()) {
274 Err(e) => Ok(ClientSendOutcome::Rejected {
275 error: e.to_string(),
276 }),
277 Ok(result) => Self::apply_action(
283 result,
284 Dispatch::Await,
285 &llm_client,
286 &app_state,
287 &status_tx,
288 client_id,
289 )
290 .await
291 .map(|applied| match applied {
292 Applied::Disconnect => ClientSendOutcome::Disconnected,
293 Applied::Executed(detail) => ClientSendOutcome::Executed { detail },
294 }),
295 };
296
297 let outcome_json = match &outcome {
298 Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
299 Err(e) => serde_json::json!({"error": e.to_string()}),
300 };
301 app_state
302 .record_access_log(
303 AccessLogOwner::Client(client_id.as_u32()),
304 protocol.protocol_name(),
305 None,
306 "injected_action",
307 action,
308 vec![outcome_json],
309 )
310 .await;
311
312 let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
313 if let Err(e) = &outcome {
314 error!("WebDAV client {} injected action failed: {}", client_id, e);
315 let _ = status_tx.send(format!(
316 "[WARN] Client {} injected action failed: {}",
317 client_id, e
318 ));
319 }
320 let _ = status_tx.send("__UPDATE_UI__".to_string());
321 crate::client::command_support::reply(command, outcome);
322
323 if disconnect {
324 break;
325 }
326 }
327
328 info!("WebDAV client {} command loop stopped", client_id);
329 app_state.remove_client_handle(client_id).await;
330 let _ = status_tx.send("__UPDATE_UI__".to_string());
331 }
332
333 async fn apply_action(
336 result: ClientActionResult,
337 dispatch: Dispatch,
338 llm_client: &OllamaClient,
339 app_state: &Arc<AppState>,
340 status_tx: &mpsc::UnboundedSender<String>,
341 client_id: ClientId,
342 ) -> Result<Applied> {
343 match result {
344 ClientActionResult::Custom { name, data } if name == "webdav_request" => {
345 let method = data
346 .get("method")
347 .and_then(|v| v.as_str())
348 .context("Missing 'method' in webdav_request")?
349 .to_string();
350
351 let path = data
352 .get("path")
353 .and_then(|v| v.as_str())
354 .context("Missing 'path' in webdav_request")?
355 .to_string();
356
357 let mut headers = Vec::new();
359
360 if let Some(depth) = data.get("depth").and_then(|v| v.as_str()) {
362 headers.push(("Depth".to_string(), depth.to_string()));
363 }
364
365 if let Some(destination) = data.get("destination").and_then(|v| v.as_str()) {
367 let base_url = app_state
369 .with_client_mut(client_id, |c| {
370 c.get_protocol_field("base_url")
371 .and_then(|v| v.as_str().map(|s| s.to_string()))
372 })
373 .await
374 .flatten()
375 .unwrap_or_default();
376
377 let dest_url =
379 crate::client::http_fetch::resolve_same_origin(&base_url, destination)?;
380 headers.push(("Destination".to_string(), dest_url));
381 }
382
383 if let Some(overwrite) = data.get("overwrite").and_then(|v| v.as_bool()) {
385 headers.push((
386 "Overwrite".to_string(),
387 if overwrite { "T" } else { "F" }.to_string(),
388 ));
389 }
390
391 if let Some(content_type) = data.get("content_type").and_then(|v| v.as_str()) {
393 headers.push(("Content-Type".to_string(), content_type.to_string()));
394 } else if method == "PUT" {
395 headers.push((
396 "Content-Type".to_string(),
397 "application/octet-stream".to_string(),
398 ));
399 } else if method == "PROPFIND" {
400 headers.push(("Content-Type".to_string(), "application/xml".to_string()));
401 }
402
403 let body = if method == "PROPFIND" {
405 let properties = data
407 .get("properties")
408 .and_then(|v| v.as_array())
409 .map(|arr| {
410 arr.iter()
411 .filter_map(|v| v.as_str().map(|s| s.to_string()))
412 .collect::<Vec<String>>()
413 });
414 Some(Self::build_propfind_body(properties))
415 } else if method == "PUT" {
416 data.get("content")
418 .and_then(|v| v.as_str())
419 .map(|s| s.to_string())
420 } else {
421 None
422 };
423
424 match dispatch {
425 Dispatch::Spawn => {
426 let state_clone = Arc::clone(app_state);
427 let llm_clone = llm_client.clone();
428 let status_clone = status_tx.clone();
429 let (spawn_method, spawn_path) = (method.clone(), path.clone());
430 let request_handle = tokio::spawn(async move {
431 if let Err(e) = WebdavClient::make_request(
432 client_id,
433 spawn_method,
434 spawn_path,
435 Some(headers),
436 body,
437 state_clone,
438 llm_clone,
439 status_clone,
440 )
441 .await
442 {
443 error!("WebDAV request failed: {}", e);
444 }
445 });
446 app_state
447 .register_client_task(client_id, request_handle)
448 .await;
449 Ok(Applied::Executed(format!("{method} {path} dispatched")))
450 }
451 Dispatch::Await => {
452 let exchange = Self::perform_request(
453 client_id,
454 method.clone(),
455 path.clone(),
456 Some(headers),
457 body,
458 app_state,
459 status_tx,
460 )
461 .await?;
462 let detail = format!(
463 "{method} {path} completed -> {} ({} byte body)",
464 exchange.status_code, exchange.body_len
465 );
466 Self::spawn_notify(
467 client_id,
468 exchange.event_data,
469 app_state,
470 llm_client,
471 status_tx,
472 )
473 .await;
474 Ok(Applied::Executed(detail))
475 }
476 }
477 }
478 ClientActionResult::Disconnect => {
479 app_state
480 .update_client_status(client_id, ClientStatus::Disconnected)
481 .await;
482 app_state.remove_client_handle(client_id).await;
483 Log::new(Some(status_tx)).info(format!("WebDAV client {} disconnected", client_id));
484 let _ = status_tx.send("__UPDATE_UI__".to_string());
485 Ok(Applied::Disconnect)
486 }
487 other => Err(anyhow::anyhow!("Unexpected action result: {:?}", other)),
488 }
489 }
490
491 #[allow(clippy::too_many_arguments)]
493 pub async fn make_request(
494 client_id: ClientId,
495 method: String,
496 path: String,
497 headers: Option<Vec<(String, String)>>,
498 body: Option<String>,
499 app_state: Arc<AppState>,
500 llm_client: OllamaClient,
501 status_tx: mpsc::UnboundedSender<String>,
502 ) -> Result<()> {
503 let exchange = Self::perform_request(
504 client_id, method, path, headers, body, &app_state, &status_tx,
505 )
506 .await?;
507 Self::notify_response(
508 client_id,
509 exchange.event_data,
510 app_state,
511 llm_client,
512 status_tx,
513 )
514 .await;
515 Ok(())
516 }
517
518 async fn spawn_notify(
527 client_id: ClientId,
528 event_data: serde_json::Value,
529 app_state: &Arc<AppState>,
530 llm_client: &OllamaClient,
531 status_tx: &mpsc::UnboundedSender<String>,
532 ) {
533 let state_clone = Arc::clone(app_state);
534 let llm_clone = llm_client.clone();
535 let status_clone = status_tx.clone();
536 let notify_handle = tokio::spawn(async move {
537 WebdavClient::notify_response(
538 client_id,
539 event_data,
540 state_clone,
541 llm_clone,
542 status_clone,
543 )
544 .await;
545 });
546 app_state
549 .register_client_task(client_id, notify_handle)
550 .await;
551 }
552
553 async fn run_follow_ups(
561 client_id: ClientId,
562 actions: Vec<serde_json::Value>,
563 app_state: &Arc<AppState>,
564 status_tx: &mpsc::UnboundedSender<String>,
565 ) {
566 use crate::llm::actions::client_trait::{Client, ClientActionResult};
567 let protocol = crate::client::webdav::actions::WebdavClientProtocol::new();
568 for action in actions {
569 let Ok(ClientActionResult::Custom { name, data }) =
570 protocol.execute_action(action.clone())
571 else {
572 continue;
573 };
574 if name != "webdav_request" {
575 info!(
576 "WebDAV client {} follow-up '{}' has no non-notifying path; skipped",
577 client_id, name
578 );
579 continue;
580 }
581 if let Err(e) = Self::perform_request(
582 client_id,
583 data["method"].as_str().unwrap_or("GET").to_string(),
584 data["path"].as_str().unwrap_or("/").to_string(),
585 None,
586 data["body"].as_str().map(|s| s.to_string()),
587 app_state,
588 status_tx,
589 )
590 .await
591 {
592 error!("WebDAV client {} follow-up action failed: {}", client_id, e);
593 }
594 }
595 }
596
597 async fn notify_response(
598 client_id: ClientId,
599 event_data: serde_json::Value,
600 app_state: Arc<AppState>,
601 llm_client: OllamaClient,
602 status_tx: mpsc::UnboundedSender<String>,
603 ) {
604 let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
605 return;
606 };
607 let protocol = Arc::new(crate::client::webdav::actions::WebdavClientProtocol::new());
608 let event = Event::new(&WEBDAV_CLIENT_RESPONSE_RECEIVED_EVENT, event_data);
609 let memory = app_state
610 .get_memory_for_client(client_id)
611 .await
612 .unwrap_or_default();
613
614 match call_llm_for_client(
615 &llm_client,
616 &app_state,
617 client_id.to_string(),
618 &instruction,
619 &memory,
620 Some(&event),
621 protocol.as_ref(),
622 &status_tx,
623 )
624 .await
625 {
626 Ok(ClientLlmResult {
627 actions,
628 memory_updates,
629 }) => {
630 if let Some(mem) = memory_updates {
631 app_state.set_memory_for_client(client_id, mem).await;
632 }
633 Self::run_follow_ups(client_id, actions, &app_state, &status_tx).await;
634 }
635 Err(e) => {
636 error!("LLM error for WebDAV client {}: {}", client_id, e);
637 }
638 }
639 }
640
641 #[allow(clippy::too_many_arguments)]
644 pub async fn perform_request(
645 client_id: ClientId,
646 method: String,
647 path: String,
648 headers: Option<Vec<(String, String)>>,
649 body: Option<String>,
650 app_state: &Arc<AppState>,
651 status_tx: &mpsc::UnboundedSender<String>,
652 ) -> Result<WebdavExchange> {
653 let (base_url, startup_headers) = app_state
656 .with_client_mut(client_id, |client| {
657 (
658 client
659 .get_protocol_field("base_url")
660 .and_then(|v| v.as_str())
661 .map(|s| s.to_string()),
662 client
663 .get_protocol_field("startup_headers")
664 .and_then(|v| v.as_object().cloned()),
665 )
666 })
667 .await
668 .unwrap_or((None, None));
669 let base_url = base_url.context("No base URL found")?;
670
671 let url = crate::client::http_fetch::resolve_same_origin(&base_url, &path)?;
675
676 info!(
677 "WebDAV client {} making request: {} {}",
678 client_id, method, url
679 );
680
681 let http_client = http_client_for(&base_url).await;
683
684 let method_upper = method.to_uppercase();
685 let request_method = match method_upper.as_str() {
686 "GET" => reqwest::Method::GET,
687 "PUT" => reqwest::Method::PUT,
688 "POST" => reqwest::Method::POST,
689 "DELETE" => reqwest::Method::DELETE,
690 "HEAD" => reqwest::Method::HEAD,
691 "OPTIONS" => reqwest::Method::OPTIONS,
692 "PROPFIND" => reqwest::Method::from_bytes(b"PROPFIND")?,
693 "PROPPATCH" => reqwest::Method::from_bytes(b"PROPPATCH")?,
694 "MKCOL" => reqwest::Method::from_bytes(b"MKCOL")?,
695 "COPY" => reqwest::Method::from_bytes(b"COPY")?,
696 "MOVE" => reqwest::Method::from_bytes(b"MOVE")?,
697 "LOCK" => reqwest::Method::from_bytes(b"LOCK")?,
698 "UNLOCK" => reqwest::Method::from_bytes(b"UNLOCK")?,
699 _ => return Err(anyhow::anyhow!("Unsupported WebDAV method: {}", method)),
700 };
701
702 let mut request = http_client.request(request_method, &url);
703
704 let mut merged = serde_json::Map::new();
709 for (key, value) in startup_headers.unwrap_or_default() {
710 merged.insert(key.to_ascii_lowercase(), value);
711 }
712 if let Some(hdrs) = headers {
713 for (key, value) in hdrs {
714 merged.insert(key.to_ascii_lowercase(), serde_json::json!(value));
715 }
716 }
717 for (key, value) in merged {
718 if let Some(val_str) = value.as_str() {
719 request = request.header(&key, val_str);
720 }
721 }
722
723 if let Some(body_str) = body {
725 request = request.body(body_str);
726 }
727
728 match request.send().await {
730 Ok(response) => {
731 let status = response.status();
732 let status_code = status.as_u16();
733
734 let mut resp_headers = serde_json::Map::new();
736 for (name, value) in response.headers() {
737 if let Ok(val_str) = value.to_str() {
738 resp_headers.insert(name.to_string(), serde_json::json!(val_str));
739 }
740 }
741
742 let body_text = response.text().await?;
744
745 info!(
746 "WebDAV client {} received response: {} ({})",
747 client_id, status_code, status
748 );
749
750 let body_len = body_text.len();
753 Ok(WebdavExchange {
754 status_code,
755 body_len,
756 event_data: serde_json::json!({
757 "method": method,
758 "status_code": status_code,
759 "status_text": status.to_string(),
760 "headers": resp_headers,
761 "body": body_text,
762 }),
763 })
764 }
765 Err(e) => {
766 Log::new(Some(status_tx))
767 .error(format!("WebDAV client {} request failed: {}", client_id, e));
768 Err(e.into())
769 }
770 }
771 }
772
773 pub fn build_propfind_body(properties: Option<Vec<String>>) -> String {
775 match properties {
776 Some(props) => {
777 let mut prop_elements = String::new();
778 for prop in props {
779 prop_elements.push_str(&format!("<D:{}/>\n", prop));
780 }
781 format!(
782 r#"<?xml version="1.0" encoding="utf-8"?>
783<D:propfind xmlns:D="DAV:">
784 <D:prop>
785{}
786 </D:prop>
787</D:propfind>"#,
788 prop_elements
789 )
790 }
791 None => {
792 r#"<?xml version="1.0" encoding="utf-8"?>
794<D:propfind xmlns:D="DAV:">
795 <D:allprop/>
796</D:propfind>"#
797 .to_string()
798 }
799 }
800 }
801}
802
803#[derive(Clone, Copy)]
805enum Dispatch {
806 Spawn,
808 Await,
812}
813
814pub struct WebdavExchange {
820 pub status_code: u16,
821 pub body_len: usize,
822 pub event_data: serde_json::Value,
824}
825
826enum Applied {
830 Executed(String),
832 Disconnect,
834}