From 0a64762df4dccc3ea4dd2c5a9126994b15d7d57b Mon Sep 17 00:00:00 2001 From: sigoden Date: Sat, 4 Jun 2022 12:51:56 +0800 Subject: [PATCH] feat: support webdav (#10) --- README.md | 1 + assets/index.js | 52 ++++++++- src/args.rs | 4 +- src/server.rs | 292 +++++++++++++++++++++++++++++++++++++++++------- 4 files changed, 300 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 78d0d24..a57f9ca 100644 --- 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 diff --git a/assets/index.js b/assets/index.js index f220993..a9d9498 100644 --- a/assets/index.js +++ b/assets/index.js @@ -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", ` -
${getSvg(file.path_type)}
+
${getSvg(file)}
${file.name} ${formatMtime(file.mtime)} @@ -132,6 +164,11 @@ function addPath(file, index) { `) } +/** + * 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 ``; case "File": diff --git a/src/args.rs b/src/args.rs index c5e510d..2bcd05f 100644 --- a/src/args.rs +++ b/src/args.rs @@ -134,7 +134,9 @@ impl Args { let address = matches.value_of("address").unwrap_or_default().to_owned(); let port = matches.value_of_t::("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"); diff --git a/src/server.rs b/src/server.rs index d1f38e7..f883f54 100644 --- a/src/server.rs +++ b/src/server.rs @@ -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 = 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, + 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, + 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) -> Option { + 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 { 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> { + let mut paths: Vec = 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>( &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, @@ -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, } +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#" +{}{} + + +{} +{} + + + + + +HTTP/1.1 200 OK + +"#, + prefix, self.name, self.base_name, mtime + ), + PathType::File | PathType::SymlinkFile => format!( + r#" +{}{} + + +{} +{} +{} + + + + + +HTTP/1.1 200 OK + +"#, + 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>(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#" + +{} +"#, + content, + )); +} + async fn zip_dir(writer: &mut W, dir: &Path) -> BoxResult<()> { let mut writer = ZipFileWriter::new(writer); let mut walkdir = WalkDir::new(dir);