1use crate::llm::actions::{
29 protocol_trait::{ActionResult, Protocol, Server},
30 ActionDefinition, Parameter,
31};
32use crate::protocol::log_template::LogTemplate;
33use crate::protocol::EventType;
34use crate::state::app_state::AppState;
35use anyhow::{Context, Result};
36use serde_json::json;
37use std::sync::LazyLock;
38
39pub struct WebDavProtocol;
41
42impl WebDavProtocol {
43 pub fn new() -> Self {
44 Self
45 }
46}
47
48impl Default for WebDavProtocol {
49 fn default() -> Self {
50 Self::new()
51 }
52}
53
54fn xml_escape(s: &str) -> String {
65 let mut out = String::with_capacity(s.len());
66 for c in s.chars() {
67 match c {
68 '&' => out.push_str("&"),
69 '<' => out.push_str("<"),
70 '>' => out.push_str(">"),
71 '"' => out.push_str("""),
72 '\'' => out.push_str("'"),
73 _ => out.push(c),
74 }
75 }
76 out
77}
78
79fn percent_encode_path(path: &str) -> String {
85 let mut out = String::with_capacity(path.len());
86 for &b in path.as_bytes() {
87 let c = b as char;
88 if c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_' | '.' | '~') {
89 out.push(c);
90 } else {
91 out.push_str(&format!("%{:02X}", b));
92 }
93 }
94 out
95}
96
97pub(crate) fn percent_decode(s: &str) -> String {
100 fn hex(b: u8) -> Option<u8> {
101 match b {
102 b'0'..=b'9' => Some(b - b'0'),
103 b'a'..=b'f' => Some(b - b'a' + 10),
104 b'A'..=b'F' => Some(b - b'A' + 10),
105 _ => None,
106 }
107 }
108 let bytes = s.as_bytes();
109 let mut out = Vec::with_capacity(bytes.len());
110 let mut i = 0;
111 while i < bytes.len() {
112 if bytes[i] == b'%' && i + 2 < bytes.len() {
113 if let (Some(h), Some(l)) = (hex(bytes[i + 1]), hex(bytes[i + 2])) {
114 out.push(h * 16 + l);
115 i += 3;
116 continue;
117 }
118 }
119 out.push(bytes[i]);
120 i += 1;
121 }
122 String::from_utf8_lossy(&out).into_owned()
123}
124
125fn http_date_now() -> String {
131 chrono::Utc::now()
132 .format("%a, %d %b %Y %H:%M:%S GMT")
133 .to_string()
134}
135
136fn absolute(path: &str) -> String {
138 if path.starts_with('/') {
139 path.to_string()
140 } else {
141 format!("/{}", path)
142 }
143}
144
145fn as_collection(path: &str) -> String {
147 let p = absolute(path);
148 if p.ends_with('/') {
149 p
150 } else {
151 format!("{}/", p)
152 }
153}
154
155fn display_name(path: &str) -> String {
157 path.trim_end_matches('/')
158 .rsplit('/')
159 .next()
160 .filter(|s| !s.is_empty())
161 .unwrap_or("/")
162 .to_string()
163}
164
165struct DavResource {
167 href: String,
168 name: String,
169 is_collection: bool,
170 size: u64,
171 content_type: String,
172 last_modified: String,
173}
174
175impl DavResource {
176 fn render(&self, out: &mut String) {
177 out.push_str("<D:response><D:href>");
178 out.push_str(&xml_escape(&percent_encode_path(&self.href)));
179 out.push_str("</D:href><D:propstat><D:prop><D:displayname>");
180 out.push_str(&xml_escape(&self.name));
181 out.push_str("</D:displayname><D:getlastmodified>");
182 out.push_str(&xml_escape(&self.last_modified));
183 out.push_str("</D:getlastmodified>");
184 if self.is_collection {
185 out.push_str("<D:resourcetype><D:collection/></D:resourcetype>");
186 } else {
187 out.push_str("<D:resourcetype/>");
188 out.push_str(&format!(
189 "<D:getcontentlength>{}</D:getcontentlength>",
190 self.size
191 ));
192 }
193 out.push_str("<D:getcontenttype>");
194 out.push_str(&xml_escape(&self.content_type));
195 out.push_str("</D:getcontenttype></D:prop><D:status>HTTP/1.1 200 OK</D:status>");
196 out.push_str("</D:propstat></D:response>");
197 }
198}
199
200fn render_multistatus(resources: &[DavResource]) -> String {
201 let mut out = String::from(
202 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<D:multistatus xmlns:D=\"DAV:\">",
203 );
204 for r in resources {
205 r.render(&mut out);
206 }
207 out.push_str("</D:multistatus>");
208 out
209}
210
211fn webdav_output(
214 status: u16,
215 headers: Vec<(String, String)>,
216 body: String,
217) -> Result<ActionResult> {
218 let header_map: serde_json::Map<String, serde_json::Value> = headers
219 .into_iter()
220 .map(|(k, v)| (k, serde_json::Value::String(v)))
221 .collect();
222 let payload = json!({
223 "status": status,
224 "headers": serde_json::Value::Object(header_map),
225 "body": body,
226 });
227 Ok(ActionResult::Output(serde_json::to_vec(&payload)?))
228}
229
230fn parse_status(value: &serde_json::Value) -> Option<u16> {
232 let n = match value {
233 serde_json::Value::Number(n) => n.as_u64(),
234 serde_json::Value::String(s) => s.trim().parse::<u64>().ok(),
235 _ => None,
236 }?;
237 if (100..=599).contains(&n) {
238 Some(n as u16)
239 } else {
240 None
241 }
242}
243
244fn as_text(value: &serde_json::Value) -> String {
246 match value {
247 serde_json::Value::Null => String::new(),
248 serde_json::Value::String(s) => s.clone(),
249 other => other.to_string(),
250 }
251}
252
253impl Protocol for WebDavProtocol {
258 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
261 Vec::new()
262 }
263
264 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
265 vec![
266 send_webdav_listing_action(),
267 send_webdav_file_action(),
268 send_webdav_status_action(),
269 ]
270 }
271
272 fn get_event_types(&self) -> Vec<EventType> {
273 vec![WEBDAV_REQUEST_EVENT.clone()]
274 }
275
276 fn protocol_name(&self) -> &'static str {
277 "WebDAV"
278 }
279 fn stack_name(&self) -> &'static str {
280 "ETH>IP>TCP>HTTP>WEBDAV"
281 }
282 fn keywords(&self) -> Vec<&'static str> {
283 vec!["webdav", "dav"]
284 }
285 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
286 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
287
288 ProtocolMetadataV2::builder()
289 .state(DevelopmentState::Beta)
305 .implementation(
306 "hyper v1.0 HTTP/1.1 with WebDAV methods answered directly; DAV:multistatus \
307 XML generated from model-supplied entries. No filesystem of any kind.",
308 )
309 .llm_control(
310 "Everything the client observes: directory listings, file content, and the \
311 status code of every write (PUT/MKCOL/DELETE/COPY/MOVE/PROPPATCH)",
312 )
313 .e2e_testing(
314 "TWO independent clients. \
315 (1) reqwest_dav + mocked LLM, tests/server/webdav/test.rs: PROPFIND listing \
316 parsed into typed entries through the library's own serde_xml_rs schema, \
317 PUT/GET round-trip, MKCOL, refusal. \
318 (2) the real `curl` binary, tests/server/webdav/real_client_test.rs: \
319 PROPFIND, MKCOL, PUT, GET and COPY in one session. curl is a GENERIC HTTP \
320 client, which the root CLAUDE.md rules out as evidence for a protocol layered \
321 ON HTTP -- but PROPFIND, MKCOL and COPY are not HTTP verbs, 207 is not an \
322 HTTP status, and DAV:multistatus is not an HTTP document, so for the layer \
323 this server actually implements curl is a real WebDAV client. It adds what \
324 reqwest_dav cannot: the multistatus is read as XML rather than deserialised \
325 into a fixed schema, so a body that is not well-formed fails outright; one \
326 entry is named `notes & drafts.txt`, which requires RFC 3986 percent-encoding \
327 in the href and XML escaping in the displayname -- two different rules on the \
328 same string; and COPY's Destination header is echoed back through the model, \
329 so the assertion is that a WebDAV header was parsed rather than that a 201 \
330 came back. \
331 It FAILS rather than skips when curl is absent, and is not #[ignore]d. \
332 Verified non-vacuous by breaking the server twice: dropping xml_escape from \
333 displayname still answered 207 but made the body invalid XML (`Cannot find \
334 ';' after '&'`), and dropping percent_encode_path from the href left a \
335 well-formed document whose href was `/documents/notes & drafts.txt`. \
336 UNPROVEN by either client: PROPPATCH, LOCK/UNLOCK enforcement, Depth: \
337 infinity, binary bodies, authentication and TLS.",
338 )
339 .notes(
340 "There is no storage: a PUT is remembered only if the model chooses to \
341 remember it (server memory), so a GET after a PUT returns whatever the model \
342 says. Text content only - no binary file bodies. OPTIONS, LOCK and UNLOCK are \
343 answered by the server without a model call; locks are never enforced. No \
344 authentication, no TLS, no PROPPATCH dead-property storage.",
345 )
346 .max_inbound_bytes(crate::server::webdav::MAX_REQUEST_BODY)
347 .request_only(
348 "WebDAV is HTTP request/response; a server cannot send a peer anything unprompted",
349 )
350 .build()
351 }
352 fn description(&self) -> &'static str {
353 "WebDAV file server with an LLM-supplied virtual filesystem"
354 }
355 fn example_prompt(&self) -> &'static str {
356 "Serve a WebDAV share on port 8080 with a /documents folder containing readme.txt"
357 }
358 fn group_name(&self) -> &'static str {
359 "Web & File"
360 }
361
362 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
363 use crate::llm::actions::StartupExamples;
364
365 StartupExamples::new(
366 json!({
368 "type": "open_server",
369 "port": 8080,
370 "base_stack": "webdav",
371 "instruction": "Serve a WebDAV share whose root contains a 'documents' \
372 collection and a file 'readme.txt' holding 'Hello from \
373 NetGet'. Accept PUT (201 for a new path, 204 for an existing \
374 one) and record what was written in memory so a later GET \
375 returns it."
376 }),
377 json!({
379 "type": "open_server",
380 "port": 8080,
381 "base_stack": "webdav",
382 "event_handlers": [{
383 "event_pattern": "webdav_request",
384 "handler": {
385 "type": "script",
386 "language": "python",
387 "code": "import sys, json\n\
388 e = json.load(sys.stdin)['event']\n\
389 m, p = e['method'], e['path']\n\
390 files = {'/readme.txt': 'Hello from NetGet'}\n\
391 if m == 'PROPFIND' and p in ('/', ''):\n\
392 \x20 a = [{'type': 'send_webdav_listing', 'path': '/',\n\
393 \x20 'entries': [{'name': 'readme.txt', 'size': 17,\n\
394 \x20 'content_type': 'text/plain'}]}]\n\
395 elif m in ('GET', 'HEAD') and p in files:\n\
396 \x20 a = [{'type': 'send_webdav_file', 'content': files[p],\n\
397 \x20 'content_type': 'text/plain'}]\n\
398 else:\n\
399 \x20 a = [{'type': 'send_webdav_status', 'status': 404}]\n\
400 print(json.dumps({'actions': a}))"
401 }
402 }]
403 }),
404 json!({
406 "type": "open_server",
407 "port": 8080,
408 "base_stack": "webdav",
409 "event_handlers": [{
410 "event_pattern": "webdav_request",
411 "handler": {
412 "type": "static",
413 "actions": [{
414 "type": "send_webdav_status",
415 "status": 404,
416 "body": "Not Found"
417 }]
418 }
419 }]
420 }),
421 )
422 }
423}
424
425impl Server for WebDavProtocol {
430 fn spawn(
431 &self,
432 ctx: crate::protocol::SpawnContext,
433 ) -> std::pin::Pin<
434 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
435 > {
436 Box::pin(async move {
437 use crate::server::webdav::WebDavServer;
438 WebDavServer::spawn_with_llm_actions(
439 ctx.legacy_listen_addr(),
440 ctx.llm_client,
441 ctx.state,
442 ctx.status_tx,
443 ctx.server_id,
444 )
445 .await
446 })
447 }
448
449 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
450 let action_type = action
451 .get("type")
452 .and_then(|v| v.as_str())
453 .context("Missing 'type' field in action")?;
454
455 match action_type {
456 "send_webdav_listing" => self.execute_send_webdav_listing(&action),
457 "send_webdav_file" => self.execute_send_webdav_file(&action),
458 "send_webdav_status" => self.execute_send_webdav_status(&action),
459 other => Err(anyhow::anyhow!(
460 "Unknown WebDAV action '{}'. WebDAV understands send_webdav_listing, \
461 send_webdav_file and send_webdav_status.",
462 other
463 )),
464 }
465 }
466}
467
468impl WebDavProtocol {
469 fn execute_send_webdav_listing(&self, action: &serde_json::Value) -> Result<ActionResult> {
472 let raw_path = action
473 .get("path")
474 .and_then(|v| v.as_str())
475 .unwrap_or("/")
476 .trim();
477 let is_collection = action
478 .get("is_collection")
479 .and_then(|v| v.as_bool())
480 .unwrap_or(true);
481
482 let now = http_date_now();
483 let last_modified = action
484 .get("last_modified")
485 .and_then(|v| v.as_str())
486 .unwrap_or(&now)
487 .to_string();
488
489 let self_href = if is_collection {
490 as_collection(raw_path)
491 } else {
492 absolute(raw_path)
493 };
494
495 let mut resources = vec![DavResource {
496 name: display_name(&self_href),
497 is_collection,
498 size: action.get("size").and_then(|v| v.as_u64()).unwrap_or(0),
499 content_type: action
500 .get("content_type")
501 .and_then(|v| v.as_str())
502 .unwrap_or(if is_collection {
503 "httpd/unix-directory"
504 } else {
505 "application/octet-stream"
506 })
507 .to_string(),
508 last_modified: last_modified.clone(),
509 href: self_href.clone(),
510 }];
511
512 match action.get("entries") {
517 None | Some(serde_json::Value::Null) => {}
518 Some(serde_json::Value::Array(items)) => {
519 let base = as_collection(&self_href);
520 for (i, item) in items.iter().enumerate() {
521 let name = item
522 .get("name")
523 .and_then(|v| v.as_str())
524 .map(|s| s.trim_matches('/'))
525 .filter(|s| !s.is_empty())
526 .with_context(|| {
527 format!(
528 "send_webdav_listing entry #{} has no non-empty 'name'; every \
529 entry needs the file or folder name it should be listed under",
530 i
531 )
532 })?;
533 let entry_is_collection = item
534 .get("is_collection")
535 .and_then(|v| v.as_bool())
536 .unwrap_or(false);
537 let href = if entry_is_collection {
538 format!("{}{}/", base, name)
539 } else {
540 format!("{}{}", base, name)
541 };
542 resources.push(DavResource {
543 href,
544 name: name.to_string(),
545 is_collection: entry_is_collection,
546 size: item.get("size").and_then(|v| v.as_u64()).unwrap_or(0),
547 content_type: item
548 .get("content_type")
549 .and_then(|v| v.as_str())
550 .unwrap_or(if entry_is_collection {
551 "httpd/unix-directory"
552 } else {
553 "application/octet-stream"
554 })
555 .to_string(),
556 last_modified: item
557 .get("last_modified")
558 .and_then(|v| v.as_str())
559 .unwrap_or(&last_modified)
560 .to_string(),
561 });
562 }
563 }
564 Some(other) => {
565 return Err(anyhow::anyhow!(
566 "send_webdav_listing 'entries' must be an array of \
567 {{name, is_collection, size, content_type}} objects, got {}",
568 other
569 ))
570 }
571 }
572
573 webdav_output(
574 207,
575 vec![(
576 "Content-Type".to_string(),
577 "application/xml; charset=utf-8".to_string(),
578 )],
579 render_multistatus(&resources),
580 )
581 }
582
583 fn execute_send_webdav_file(&self, action: &serde_json::Value) -> Result<ActionResult> {
585 let content = action
586 .get("content")
587 .map(as_text)
588 .context("send_webdav_file requires 'content' (the file's text content)")?;
589
590 let status = match action.get("status") {
591 None | Some(serde_json::Value::Null) => 200,
592 Some(v) => parse_status(v).with_context(|| {
593 format!(
594 "send_webdav_file 'status' {} is not an HTTP status code between 100 and 599",
595 v
596 )
597 })?,
598 };
599
600 let content_type = action
601 .get("content_type")
602 .and_then(|v| v.as_str())
603 .unwrap_or("text/plain; charset=utf-8")
604 .to_string();
605
606 webdav_output(
607 status,
608 vec![("Content-Type".to_string(), content_type)],
609 content,
610 )
611 }
612
613 fn execute_send_webdav_status(&self, action: &serde_json::Value) -> Result<ActionResult> {
615 let status_value = action
616 .get("status")
617 .context("send_webdav_status requires 'status' (e.g. 201 for a created resource)")?;
618 let status = parse_status(status_value).with_context(|| {
619 format!(
620 "send_webdav_status 'status' {} is not an HTTP status code between 100 and 599",
621 status_value
622 )
623 })?;
624
625 let body = action.get("body").map(as_text).unwrap_or_default();
626
627 let headers = action
628 .get("headers")
629 .and_then(|v| v.as_object())
630 .map(|obj| {
631 obj.iter()
632 .filter(|(_, v)| !v.is_null())
633 .map(|(k, v)| (k.clone(), as_text(v)))
634 .collect::<Vec<_>>()
635 })
636 .unwrap_or_default();
637
638 webdav_output(status, headers, body)
639 }
640}
641
642fn send_webdav_listing_action() -> ActionDefinition {
647 ActionDefinition {
648 name: "send_webdav_listing".to_string(),
649 description:
650 "Answer a PROPFIND with a 207 Multi-Status listing. This is the ONLY way to make a \
651 directory or a file's properties visible to a WebDAV client. The response always \
652 describes the resource named by 'path' itself, plus one entry per element of \
653 'entries' (which you omit for Depth: 0, or when the path is a file). There is no \
654 filesystem behind this server: whatever you list here IS the directory, so keep it \
655 consistent with what you have listed before and with any PUT you accepted. The \
656 DAV:multistatus XML is generated for you - never write XML into these fields."
657 .to_string(),
658 parameters: vec![
659 Parameter {
660 name: "path".to_string(),
661 type_hint: "string".to_string(),
662 description: "The path being listed. Echo the 'path' field of the webdav_request \
663 event exactly; the client matches the href in the reply against the URL it \
664 requested, so a different path makes the listing useless to it."
665 .to_string(),
666 required: true,
667 },
668 Parameter {
669 name: "entries".to_string(),
670 type_hint: "array".to_string(),
671 description: "The members of this collection, as an array of objects. Each: \
672 'name' (required, a single file or folder name, no slashes), 'is_collection' \
673 (boolean, default false), 'size' (number, byte length of the file's content, \
674 default 0), 'content_type' (string, e.g. \"text/plain\"), 'last_modified' \
675 (RFC 1123 date string, defaults to now). Omit or use [] when the request had \
676 Depth: 0 or when 'path' is a file."
677 .to_string(),
678 required: false,
679 },
680 Parameter {
681 name: "is_collection".to_string(),
682 type_hint: "boolean".to_string(),
683 description: "Whether 'path' itself is a collection (directory). Default true. \
684 Set false when answering a PROPFIND on a single file."
685 .to_string(),
686 required: false,
687 },
688 Parameter {
689 name: "size".to_string(),
690 type_hint: "number".to_string(),
691 description:
692 "Byte length of the resource at 'path' when it is a file (is_collection false)."
693 .to_string(),
694 required: false,
695 },
696 Parameter {
697 name: "content_type".to_string(),
698 type_hint: "string".to_string(),
699 description: "MIME type of the resource at 'path'. Defaults to \
700 httpd/unix-directory for a collection, application/octet-stream for a file."
701 .to_string(),
702 required: false,
703 },
704 Parameter {
705 name: "last_modified".to_string(),
706 type_hint: "string".to_string(),
707 description: "RFC 1123 date, e.g. \"Wed, 10 Apr 2024 14:00:00 GMT\". Defaults to \
708 the current time, and is inherited by entries that do not set their own."
709 .to_string(),
710 required: false,
711 },
712 ],
713 example: json!({
714 "type": "send_webdav_listing",
715 "path": "/",
716 "entries": [
717 { "name": "documents", "is_collection": true },
718 { "name": "readme.txt", "size": 17, "content_type": "text/plain" }
719 ]
720 }),
721 log_template: Some(
722 LogTemplate::new()
723 .with_info("-> 207 {path} ({output_bytes}B)")
724 .with_debug("WebDAV multistatus for {path}")
725 .with_trace("WebDAV listing: {json_pretty(.)}"),
726 ),
727 }
728}
729
730fn send_webdav_file_action() -> ActionDefinition {
731 ActionDefinition {
732 name: "send_webdav_file".to_string(),
733 description:
734 "Return the content of a file for a GET or HEAD request. The body is sent as UTF-8 \
735 text in one piece; binary files (images, archives) cannot be produced, and there is \
736 no way to stream or resume. If the path should not exist, use send_webdav_status \
737 with 404 instead of returning empty content."
738 .to_string(),
739 parameters: vec![
740 Parameter {
741 name: "content".to_string(),
742 type_hint: "string".to_string(),
743 description: "The file's full content as text. Must be consistent with the size \
744 you reported for this path in any send_webdav_listing."
745 .to_string(),
746 required: true,
747 },
748 Parameter {
749 name: "content_type".to_string(),
750 type_hint: "string".to_string(),
751 description:
752 "MIME type sent as Content-Type. Default \"text/plain; charset=utf-8\"."
753 .to_string(),
754 required: false,
755 },
756 Parameter {
757 name: "status".to_string(),
758 type_hint: "number".to_string(),
759 description: "HTTP status, default 200. Use 206 only if you really are answering \
760 a Range request with a partial body."
761 .to_string(),
762 required: false,
763 },
764 ],
765 example: json!({
766 "type": "send_webdav_file",
767 "content": "Hello from NetGet",
768 "content_type": "text/plain"
769 }),
770 log_template: Some(
771 LogTemplate::new()
772 .with_info("-> {status} file ({output_bytes}B)")
773 .with_debug("WebDAV file body: {output_bytes}B, type={content_type}")
774 .with_trace("WebDAV file: {json_pretty(.)}"),
775 ),
776 }
777}
778
779fn send_webdav_status_action() -> ActionDefinition {
780 ActionDefinition {
781 name: "send_webdav_status".to_string(),
782 description:
783 "Answer with a status code and no file body. This is how every write is accepted or \
784 refused - PUT, DELETE, MKCOL, COPY, MOVE, PROPPATCH - and how a missing path is \
785 reported. RFC 4918 statuses worth knowing: 201 Created (PUT that created a new \
786 resource, successful MKCOL), 204 No Content (PUT that overwrote an existing one, \
787 successful DELETE/MOVE/COPY over an existing target), 403 Forbidden (read-only \
788 share), 404 Not Found, 405 Method Not Allowed (MKCOL on a path that already \
789 exists), 409 Conflict (parent collection does not exist), 412 Precondition Failed \
790 (Overwrite: F and the destination exists), 507 Insufficient Storage. Refusing is \
791 always a valid answer; saying nothing at all is not, and produces a 503."
792 .to_string(),
793 parameters: vec![
794 Parameter {
795 name: "status".to_string(),
796 type_hint: "number".to_string(),
797 description: "HTTP status code between 100 and 599.".to_string(),
798 required: true,
799 },
800 Parameter {
801 name: "body".to_string(),
802 type_hint: "string".to_string(),
803 description: "Optional explanatory text. Leave it out for 201/204, which must \
804 have no body."
805 .to_string(),
806 required: false,
807 },
808 Parameter {
809 name: "headers".to_string(),
810 type_hint: "object".to_string(),
811 description: "Optional response headers as a flat name->value object, e.g. \
812 {\"Location\": \"/moved/here.txt\"}. Content-Length and Date are added \
813 automatically; headers that are not legal HTTP are dropped."
814 .to_string(),
815 required: false,
816 },
817 ],
818 example: json!({
819 "type": "send_webdav_status",
820 "status": 201
821 }),
822 log_template: Some(
823 LogTemplate::new()
824 .with_info("-> {status}")
825 .with_debug("WebDAV status {status}")
826 .with_trace("WebDAV status: {json_pretty(.)}"),
827 ),
828 }
829}
830
831pub static SEND_WEBDAV_LISTING_ACTION: LazyLock<ActionDefinition> =
832 LazyLock::new(send_webdav_listing_action);
833pub static SEND_WEBDAV_FILE_ACTION: LazyLock<ActionDefinition> =
834 LazyLock::new(send_webdav_file_action);
835pub static SEND_WEBDAV_STATUS_ACTION: LazyLock<ActionDefinition> =
836 LazyLock::new(send_webdav_status_action);
837
838pub static WEBDAV_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
848 EventType::new(
849 "webdav_request",
850 "WebDAV request received (PROPFIND, GET, PUT, MKCOL, DELETE, COPY, MOVE, PROPPATCH)",
851 json!({
852 "type": "send_webdav_listing",
853 "path": "/",
854 "entries": [
855 { "name": "documents", "is_collection": true },
856 { "name": "readme.txt", "size": 17, "content_type": "text/plain" }
857 ]
858 }),
859 )
860 .with_parameters(vec![
861 Parameter {
862 name: "method".to_string(),
863 type_hint: "string".to_string(),
864 description: "WebDAV/HTTP method: PROPFIND, GET, HEAD, PUT, DELETE, MKCOL, COPY, \
865 MOVE, PROPPATCH or POST."
866 .to_string(),
867 required: true,
868 },
869 Parameter {
870 name: "path".to_string(),
871 type_hint: "string".to_string(),
872 description: "Requested path, percent-decoded, without query string (e.g. \
873 '/documents/readme.txt'). Echo this back in send_webdav_listing."
874 .to_string(),
875 required: true,
876 },
877 Parameter {
878 name: "depth".to_string(),
879 type_hint: "string".to_string(),
880 description: "Depth header for PROPFIND/COPY/MOVE: \"0\" (this resource only), \
881 \"1\" (this resource and its immediate members) or \"infinity\". Present only \
882 when the client sent it; PROPFIND without it means infinity."
883 .to_string(),
884 required: false,
885 },
886 Parameter {
887 name: "destination".to_string(),
888 type_hint: "string".to_string(),
889 description: "Target path of a COPY or MOVE, taken from the Destination header and \
890 reduced to a path."
891 .to_string(),
892 required: false,
893 },
894 Parameter {
895 name: "overwrite".to_string(),
896 type_hint: "string".to_string(),
897 description: "Overwrite header of a COPY/MOVE: \"T\" (replace the destination) or \
898 \"F\" (fail with 412 if it exists)."
899 .to_string(),
900 required: false,
901 },
902 Parameter {
903 name: "headers".to_string(),
904 type_hint: "object".to_string(),
905 description: "All request headers as a name->value object.".to_string(),
906 required: true,
907 },
908 Parameter {
909 name: "body".to_string(),
910 type_hint: "string".to_string(),
911 description: "Request body as UTF-8 text: the file content on a PUT, the XML \
912 <propfind>/<propertyupdate> document on PROPFIND/PROPPATCH. Empty string when \
913 there is no body. Lossy when body_is_binary is true."
914 .to_string(),
915 required: false,
916 },
917 Parameter {
918 name: "body_bytes".to_string(),
919 type_hint: "number".to_string(),
920 description: "Size of the request body in bytes before UTF-8 decoding.".to_string(),
921 required: false,
922 },
923 Parameter {
924 name: "body_is_binary".to_string(),
925 type_hint: "boolean".to_string(),
926 description: "Present and true when the body is not valid UTF-8, meaning 'body' is a \
927 lossy rendering and the exact bytes are not available to you."
928 .to_string(),
929 required: false,
930 },
931 ])
932 .with_actions(vec![
933 SEND_WEBDAV_LISTING_ACTION.clone(),
934 SEND_WEBDAV_FILE_ACTION.clone(),
935 SEND_WEBDAV_STATUS_ACTION.clone(),
936 ])
937 .with_alternative_example(json!({
938 "type": "send_webdav_file",
939 "content": "Hello from NetGet",
940 "content_type": "text/plain"
941 }))
942 .with_alternative_example(json!({
943 "type": "send_webdav_status",
944 "status": 201
945 }))
946 .with_log_template(
947 LogTemplate::new()
948 .with_info("{client_ip} {method} {path} -> {status} ({duration_ms}ms)")
949 .with_debug("WebDAV {method} {path} depth={depth} from {client_ip}")
950 .with_trace("WebDAV request: {json_pretty(.)}"),
951 )
952});