]> OzVa Git service - ozva-cloud/commitdiff
feat: support webdav (#10)
authorsigoden <sigoden@gmail.com>
Sat, 4 Jun 2022 04:51:56 +0000 (12:51 +0800)
committerGitHub <noreply@github.com>
Sat, 4 Jun 2022 04:51:56 +0000 (12:51 +0800)
README.md
assets/index.js
src/args.rs
src/server.rs

index 78d0d248958a579de51110ab6787efd47874926d..a57f9ca4ef484a33e5ff14ecea9181a1516f8f15 100644 (file)
--- a/README.md
+++ b/README.md
@@ -18,6 +18,7 @@ Duf is a fully functional file server.
 - Upload zip file then unzip
 - Partial responses (Parallel/Resume download)
 - Support https/tls
+- Support webdav
 - Easy to use with curl
 
 ## Install
index f2209936da2b8b140e93e1cd7422a3dd3bdc59dc..a9d9498d5867874e0ca6ffb72938c834c99613a3 100644 (file)
@@ -1,3 +1,14 @@
+/**
+ * @typedef {object} PathItem
+ * @property {"Dir"|"SymlinkDir"|"File"|"SymlinkFile"} path_type
+ * @property {boolean} is_symlink
+ * @property {string} base_name
+ * @property {string} name
+ * @property {number} mtime
+ * @property {number} size
+ */
+
+
 /**
  * @type Element
  */
@@ -8,9 +19,21 @@ let $pathsTable, $pathsTableBody, $uploadersTable;
 let baseDir;
 
 class Uploader {
+  /**
+   * @type number
+   */
   idx;
+  /**
+   * @type File
+   */
   file;
+  /**
+   * @type string
+   */
   name;
+  /**
+   * @type Element
+   */
   $uploadStatus;
   static globalIdx = 0;
   constructor(file, dirs) {
@@ -40,7 +63,7 @@ class Uploader {
     ajax.upload.addEventListener("progress", e => this.progress(e), false);
     ajax.addEventListener("readystatechange", () => {
       if(ajax.readyState === 4) {
-        if (ajax.status == 200) {
+        if (ajax.status >= 200 && ajax.status < 300) {
           this.complete();
         } else {
           this.fail();
@@ -67,6 +90,10 @@ class Uploader {
   }
 }
 
+/**
+ * Add breadcumb
+ * @param {string} value 
+ */
 function addBreadcrumb(value) {
   const $breadcrumb = document.querySelector(".breadcrumb");
   const parts = value.split("/").filter(v => !!v);
@@ -89,6 +116,11 @@ function addBreadcrumb(value) {
   }
 }
 
+/**
+ * Add pathitem
+ * @param {PathItem} file 
+ * @param {number} index 
+ */
 function addPath(file, index) {
   const url = getUrl(file.name)
   let actionDelete = "";
@@ -123,7 +155,7 @@ function addPath(file, index) {
   $pathsTableBody.insertAdjacentHTML("beforeend", `
 <tr id="addPath${index}">
   <td class="path cell-name">
-    <div>${getSvg(file.path_type)}</div>
+    <div>${getSvg(file)}</div>
     <a href="${url}" title="${file.name}">${file.name}</a>
   </td>
   <td class="cell-mtime">${formatMtime(file.mtime)}</td>
@@ -132,6 +164,11 @@ function addPath(file, index) {
 </tr>`)
 }
 
+/**
+ * Delete pathitem
+ * @param {number} index 
+ * @returns 
+ */
 async function deletePath(index) {
   const file = DATA.paths[index];
   if (!file) return;
@@ -142,7 +179,7 @@ async function deletePath(index) {
     const res = await fetch(getUrl(file.name), {
       method: "DELETE",
     });
-    if (res.status === 200) {
+    if (res.status >= 200 && res.status < 300) {
         document.getElementById(`addPath${index}`).remove();
         DATA.paths[index] = null;
         if (!DATA.paths.find(v => !!v)) {
@@ -201,8 +238,13 @@ function getUrl(name) {
     return url;
 }
 
-function getSvg(path_type) {
-  switch (path_type) {
+/**
+ * Get svg icon
+ * @param {PathItem} file
+ * @returns 
+ */
+function getSvg(file) {
+  switch (file.path_type) {
     case "Dir":
       return `<svg height="16" viewBox="0 0 14 16" width="14"><path fill-rule="evenodd" d="M13 4H7V3c0-.66-.31-1-1-1H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1zM6 4H1V3h5v1z"></path></svg>`;
     case "File":
index c5e510deaecc03f276de14cf49a866a36e6e12c0..2bcd05fe8a38773bdcdaf99dbbe083fda965ef69 100644 (file)
@@ -134,7 +134,9 @@ impl Args {
         let address = matches.value_of("address").unwrap_or_default().to_owned();
         let port = matches.value_of_t::<u16>("port")?;
         let path = Args::parse_path(matches.value_of_os("path").unwrap_or_default())?;
-        let path_prefix = matches.value_of("path-prefix").map(|v| v.to_owned());
+        let path_prefix = matches
+            .value_of("path-prefix")
+            .map(|v| v.trim_matches('/').to_owned());
         let cors = matches.is_present("cors");
         let auth = matches.value_of("auth").map(|v| v.to_owned());
         let no_auth_access = matches.is_present("no-auth-access");
index d1f38e777507d177a81f4dab80b4e0654675cbc8..f883f5494548cd8e218dba517ea54362284f156f 100644 (file)
@@ -4,7 +4,7 @@ use async_walkdir::WalkDir;
 use async_zip::read::seek::ZipFileReader;
 use async_zip::write::{EntryOptions, ZipFileWriter};
 use async_zip::Compression;
-use chrono::Local;
+use chrono::{Local, TimeZone, Utc};
 use futures::stream::StreamExt;
 use futures::TryStreamExt;
 use get_if_addrs::get_if_addrs;
@@ -18,7 +18,7 @@ use hyper::header::{
     WWW_AUTHENTICATE,
 };
 use hyper::service::{make_service_fn, service_fn};
-use hyper::{Body, Method, StatusCode};
+use hyper::{Body, Method, StatusCode, Uri};
 use percent_encoding::percent_decode;
 use rustls::ServerConfig;
 use serde::Serialize;
@@ -189,8 +189,8 @@ impl InnerService {
             return Ok(res);
         }
 
-        match *req.method() {
-            Method::GET => {
+        match req.method() {
+            &Method::GET => {
                 let headers = req.headers();
                 if is_dir {
                     if render_index || render_spa {
@@ -212,28 +212,46 @@ impl InnerService {
                     status!(res, StatusCode::NOT_FOUND);
                 }
             }
-            Method::OPTIONS => {
-                status!(res, StatusCode::NO_CONTENT);
+            &Method::OPTIONS => {
+                self.handle_method_options(&mut res);
             }
-            Method::PUT => {
+            &Method::PUT => {
                 if !allow_upload || (!allow_delete && is_file) {
                     status!(res, StatusCode::FORBIDDEN);
                 } else {
                     self.handle_upload(path, req, &mut res).await?;
                 }
             }
-            Method::DELETE => {
+            &Method::DELETE => {
                 if !allow_delete {
                     status!(res, StatusCode::FORBIDDEN);
                 } else if !is_miss {
-                    self.handle_delete(path, is_dir).await?
+                    self.handle_delete(path, is_dir, &mut res).await?
                 } else {
                     status!(res, StatusCode::NOT_FOUND);
                 }
             }
-            _ => {
-                status!(res, StatusCode::METHOD_NOT_ALLOWED);
-            }
+            method => match method.as_str() {
+                "PROPFIND" => {
+                    if is_dir {
+                        self.handle_propfind_dir(path, &mut res).await?;
+                    } else if is_file {
+                        self.handle_propfind_file(path, &mut res).await?;
+                    } else {
+                        status!(res, StatusCode::NOT_FOUND);
+                    }
+                }
+                "MKCOL" if allow_upload && is_miss => self.handle_mkcol(path, &mut res).await?,
+                "COPY" if allow_upload && !is_miss => {
+                    self.handle_copy(path, req.headers(), &mut res).await?
+                }
+                "MOVE" if allow_upload && allow_delete && !is_miss => {
+                    self.handle_move(path, req.headers(), &mut res).await?
+                }
+                _ => {
+                    status!(res, StatusCode::METHOD_NOT_ALLOWED);
+                }
+            },
         }
         Ok(res)
     }
@@ -244,20 +262,7 @@ impl InnerService {
         mut req: Request,
         res: &mut Response,
     ) -> BoxResult<()> {
-        let ensure_parent = match path.parent() {
-            Some(parent) => match fs::metadata(parent).await {
-                Ok(meta) => meta.is_dir(),
-                Err(_) => {
-                    fs::create_dir_all(parent).await?;
-                    true
-                }
-            },
-            None => false,
-        };
-        if !ensure_parent {
-            status!(res, StatusCode::FORBIDDEN);
-            return Ok(());
-        }
+        ensure_path_parent(path).await?;
 
         let mut file = fs::File::create(&path).await?;
 
@@ -280,34 +285,31 @@ impl InnerService {
             fs::remove_file(&path).await?;
         }
 
+        status!(res, StatusCode::CREATED);
         Ok(())
     }
 
-    async fn handle_delete(&self, path: &Path, is_dir: bool) -> BoxResult<()> {
+    async fn handle_delete(&self, path: &Path, is_dir: bool, res: &mut Response) -> BoxResult<()> {
         match is_dir {
             true => fs::remove_dir_all(path).await?,
             false => fs::remove_file(path).await?,
         }
+
+        status!(res, StatusCode::NO_CONTENT);
         Ok(())
     }
 
     async fn handle_ls_dir(&self, path: &Path, exist: bool, res: &mut Response) -> BoxResult<()> {
-        let mut paths: Vec<PathItem> = vec![];
+        let mut paths = vec![];
         if exist {
-            let mut rd = match fs::read_dir(path).await {
-                Ok(rd) => rd,
+            paths = match self.list_dir(path, path, false).await {
+                Ok(paths) => paths,
                 Err(_) => {
                     status!(res, StatusCode::FORBIDDEN);
                     return Ok(());
                 }
-            };
-            while let Some(entry) = rd.next_entry().await? {
-                let entry_path = entry.path();
-                if let Ok(Some(item)) = self.to_pathitem(entry_path, path.to_path_buf()).await {
-                    paths.push(item);
-                }
             }
-        }
+        };
         self.send_index(path, paths, res)
     }
 
@@ -461,6 +463,110 @@ impl InnerService {
         Ok(())
     }
 
+    fn handle_method_options(&self, res: &mut Response) {
+        let allow_upload = self.args.allow_upload;
+        let allow_delete = self.args.allow_delete;
+        let mut methods = vec!["GET", "PROPFIND", "OPTIONS"];
+        if allow_upload {
+            methods.extend(["PUT", "COPY", "MKCOL"]);
+        }
+        if allow_delete {
+            methods.push("DELETE");
+        }
+        if allow_upload && allow_delete {
+            methods.push("COPY");
+        }
+        let value = methods.join(",").parse().unwrap();
+        res.headers_mut().insert("Allow", value);
+        res.headers_mut().insert("DAV", "1".parse().unwrap());
+
+        status!(res, StatusCode::NO_CONTENT);
+    }
+
+    async fn handle_propfind_dir(&self, path: &Path, res: &mut Response) -> BoxResult<()> {
+        let paths = match self.list_dir(path, &self.args.path, true).await {
+            Ok(paths) => paths,
+            Err(_) => {
+                status!(res, StatusCode::FORBIDDEN);
+                return Ok(());
+            }
+        };
+        let output = paths
+            .iter()
+            .map(|v| v.xml(self.args.path_prefix.as_ref()))
+            .fold(String::new(), |mut acc, v| {
+                acc.push_str(&v);
+                acc
+            });
+        res_propfind(res, &output);
+        Ok(())
+    }
+
+    async fn handle_propfind_file(&self, path: &Path, res: &mut Response) -> BoxResult<()> {
+        if let Some(pathitem) = self.to_pathitem(path, &self.args.path).await? {
+            res_propfind(res, &pathitem.xml(self.args.path_prefix.as_ref()));
+        } else {
+            status!(res, StatusCode::NOT_FOUND);
+        }
+        Ok(())
+    }
+
+    async fn handle_mkcol(&self, path: &Path, res: &mut Response) -> BoxResult<()> {
+        fs::create_dir_all(path).await?;
+        status!(res, StatusCode::CREATED);
+        Ok(())
+    }
+
+    async fn handle_copy(
+        &self,
+        path: &Path,
+        headers: &HeaderMap<HeaderValue>,
+        res: &mut Response,
+    ) -> BoxResult<()> {
+        let dest = match self.extract_dest(headers) {
+            Some(dest) => dest,
+            None => {
+                status!(res, StatusCode::BAD_REQUEST);
+                return Ok(());
+            }
+        };
+
+        let meta = fs::symlink_metadata(path).await?;
+        if meta.is_dir() {
+            status!(res, StatusCode::BAD_REQUEST);
+            return Ok(());
+        }
+
+        ensure_path_parent(&dest).await?;
+
+        fs::copy(path, &dest).await?;
+
+        status!(res, StatusCode::NO_CONTENT);
+        Ok(())
+    }
+
+    async fn handle_move(
+        &self,
+        path: &Path,
+        headers: &HeaderMap<HeaderValue>,
+        res: &mut Response,
+    ) -> BoxResult<()> {
+        let dest = match self.extract_dest(headers) {
+            Some(dest) => dest,
+            None => {
+                status!(res, StatusCode::BAD_REQUEST);
+                return Ok(());
+            }
+        };
+
+        ensure_path_parent(&dest).await?;
+
+        fs::rename(path, &dest).await?;
+
+        status!(res, StatusCode::NO_CONTENT);
+        Ok(())
+    }
+
     fn send_index(
         &self,
         path: &Path,
@@ -547,11 +653,7 @@ impl InnerService {
                 if !self.args.allow_delete && fs::metadata(&entry_path).await.is_ok() {
                     continue;
                 }
-                if let Some(parent) = entry_path.parent() {
-                    if fs::symlink_metadata(parent).await.is_err() {
-                        fs::create_dir_all(&parent).await?;
-                    }
-                }
+                ensure_path_parent(&entry_path).await?;
                 let mut outfile = fs::File::create(&entry_path).await?;
                 let mut reader = zip.entry_reader(i).await?;
                 io::copy(&mut reader, &mut outfile).await?;
@@ -560,6 +662,12 @@ impl InnerService {
         Ok(())
     }
 
+    fn extract_dest(&self, headers: &HeaderMap<HeaderValue>) -> Option<PathBuf> {
+        let dest = headers.get("Destination")?.to_str().ok()?;
+        let uri: Uri = dest.parse().ok()?;
+        self.extract_path(uri.path())
+    }
+
     fn extract_path(&self, path: &str) -> Option<PathBuf> {
         let decoded_path = percent_decode(path[1..].as_bytes()).decode_utf8().ok()?;
         let slashes_switched = if cfg!(windows) {
@@ -585,6 +693,26 @@ impl InnerService {
         }
     }
 
+    async fn list_dir(
+        &self,
+        entry_path: &Path,
+        base_path: &Path,
+        include_entry: bool,
+    ) -> BoxResult<Vec<PathItem>> {
+        let mut paths: Vec<PathItem> = vec![];
+        if include_entry {
+            paths.push(self.to_pathitem(entry_path, base_path).await?.unwrap())
+        }
+        let mut rd = fs::read_dir(entry_path).await?;
+        while let Ok(Some(entry)) = rd.next_entry().await {
+            let entry_path = entry.path();
+            if let Ok(Some(item)) = self.to_pathitem(entry_path.as_path(), base_path).await {
+                paths.push(item);
+            }
+        }
+        Ok(paths)
+    }
+
     async fn to_pathitem<P: AsRef<Path>>(
         &self,
         path: P,
@@ -610,9 +738,15 @@ impl InnerService {
             PathType::Dir | PathType::SymlinkDir => None,
             PathType::File | PathType::SymlinkFile => Some(meta.len()),
         };
+        let base_name = rel_path
+            .file_name()
+            .and_then(|v| v.to_str())
+            .unwrap_or("/")
+            .to_owned();
         let name = normalize_path(rel_path);
         Ok(Some(PathItem {
             path_type,
+            base_name,
             name,
             mtime,
             size,
@@ -620,7 +754,7 @@ impl InnerService {
     }
 }
 
-#[derive(Debug, Serialize, Eq, PartialEq, Ord, PartialOrd)]
+#[derive(Debug, Serialize)]
 struct IndexData {
     breadcrumb: String,
     paths: Vec<PathItem>,
@@ -631,11 +765,63 @@ struct IndexData {
 #[derive(Debug, Serialize, Eq, PartialEq, Ord, PartialOrd)]
 struct PathItem {
     path_type: PathType,
+    base_name: String,
     name: String,
     mtime: u64,
     size: Option<u64>,
 }
 
+impl PathItem {
+    pub fn xml(&self, prefix: Option<&String>) -> String {
+        let prefix = match prefix {
+            Some(value) => format!("/{}/", value),
+            None => "/".to_owned(),
+        };
+        let mtime = Utc.timestamp_millis(self.mtime as i64).to_rfc2822();
+        match self.path_type {
+            PathType::Dir | PathType::SymlinkDir => format!(
+                r#"<D:response>
+<D:href>{}{}</D:href>
+<D:propstat>
+<D:prop>
+<D:displayname>{}</D:displayname>
+<D:getlastmodified>{}</D:getlastmodified>
+<D:resourcetype><D:collection/></D:resourcetype>
+<D:lockdiscovery/>
+<D:supportedlock>
+</D:supportedlock>
+</D:prop>
+<D:status>HTTP/1.1 200 OK</D:status>
+</D:propstat>
+</D:response>"#,
+                prefix, self.name, self.base_name, mtime
+            ),
+            PathType::File | PathType::SymlinkFile => format!(
+                r#"<D:response>
+<D:href>{}{}</D:href>
+<D:propstat>
+<D:prop>
+<D:displayname>{}</D:displayname>
+<D:getcontentlength>{}</D:getcontentlength>
+<D:getlastmodified>{}</D:getlastmodified>
+<D:resourcetype></D:resourcetype>
+<D:lockdiscovery/>
+<D:supportedlock>
+</D:supportedlock>
+</D:prop>
+<D:status>HTTP/1.1 200 OK</D:status>
+</D:propstat>
+</D:response>"#,
+                prefix,
+                self.name,
+                self.base_name,
+                self.size.unwrap_or_default(),
+                mtime
+            ),
+        }
+    }
+}
+
 #[derive(Debug, Serialize, Eq, PartialEq, Ord, PartialOrd)]
 enum PathType {
     Dir,
@@ -659,6 +845,15 @@ fn normalize_path<P: AsRef<Path>>(path: P) -> String {
     }
 }
 
+async fn ensure_path_parent(path: &Path) -> BoxResult<()> {
+    if let Some(parent) = path.parent() {
+        if fs::symlink_metadata(parent).await.is_err() {
+            fs::create_dir_all(&parent).await?;
+        }
+    }
+    Ok(())
+}
+
 fn add_cors(res: &mut Response) {
     res.headers_mut()
         .typed_insert(AccessControlAllowOrigin::ANY);
@@ -669,6 +864,17 @@ fn add_cors(res: &mut Response) {
     );
 }
 
+fn res_propfind(res: &mut Response, content: &str) {
+    *res.status_mut() = StatusCode::MULTI_STATUS;
+    *res.body_mut() = Body::from(format!(
+        r#"<?xml version="1.0" encoding="utf-8" ?>
+<D:multistatus xmlns:D="DAV:">
+{}
+</D:multistatus>"#,
+        content,
+    ));
+}
+
 async fn zip_dir<W: AsyncWrite + Unpin>(writer: &mut W, dir: &Path) -> BoxResult<()> {
     let mut writer = ZipFileWriter::new(writer);
     let mut walkdir = WalkDir::new(dir);