diff --git a/README.md b/README.md index 04798e1..52bb3d5 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ OPTIONS: -b, --bind ... Specify bind address -p, --port Specify port to listen on [default: 5000] --path-prefix Specify an path prefix + --hidden Comma-separated list of names to hide from directory listings -a, --auth ... Add auth for path --auth-method Select auth method [default: digest] [possible values: basic, digest] -A, --allow-all Allow all operations @@ -61,7 +62,7 @@ OPTIONS: --allow-symlink Allow symlink to files/folders outside root directory --enable-cors Enable CORS, sets `Access-Control-Allow-Origin: *` --render-index Serve index.html when requesting a directory, returns 404 if not found index.html - --render-try-index Serve index.html when requesting a directory, returns file listing if not found index.html + --render-try-index Serve index.html when requesting a directory, returns directory listing if not found index.html --render-spa Serve SPA(Single Page Application) --tls-cert Path to an SSL/TLS certificate to serve with HTTPS --tls-key Path to the SSL/TLS certificate's private key @@ -125,6 +126,12 @@ Listen on a specific port dufs -p 80 ``` +Hide folders from directory listing + +``` +dufs --hidden .git,.DS_Store +``` + Use https ``` diff --git a/src/args.rs b/src/args.rs index 4253112..a91e00b 100644 --- a/src/args.rs +++ b/src/args.rs @@ -48,6 +48,12 @@ fn app() -> Command<'static> { .value_name("path") .help("Specify an path prefix"), ) + .arg( + Arg::new("hidden") + .long("hidden") + .help("Comma-separated list of names to hide from directory listings") + .value_name("names"), + ) .arg( Arg::new("auth") .short('a') @@ -104,7 +110,7 @@ fn app() -> Command<'static> { .arg( Arg::new("render-try-index") .long("render-try-index") - .help("Serve index.html when requesting a directory, returns file listing if not found index.html"), + .help("Serve index.html when requesting a directory, returns directory listing if not found index.html"), ) .arg( Arg::new("render-spa") @@ -137,6 +143,7 @@ pub struct Args { pub path_is_file: bool, pub path_prefix: String, pub uri_prefix: String, + pub hidden: String, pub auth_method: AuthMethod, pub auth: AccessControl, pub allow_upload: bool, @@ -173,6 +180,10 @@ impl Args { } else { format!("/{}/", &path_prefix) }; + let hidden: String = matches + .value_of("hidden") + .map(|v| format!(",{},", v)) + .unwrap_or_default(); let enable_cors = matches.is_present("enable-cors"); let auth: Vec<&str> = matches .values_of("auth") @@ -206,6 +217,7 @@ impl Args { path_is_file, path_prefix, uri_prefix, + hidden, auth_method, auth, enable_cors, diff --git a/src/server.rs b/src/server.rs index 6c8f384..7c58076 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,9 +1,9 @@ use crate::streamer::Streamer; -use crate::utils::{decode_uri, encode_uri}; +use crate::utils::{decode_uri, encode_uri, get_file_name, try_get_file_name}; use crate::{Args, BoxResult}; +use async_walkdir::{Filtering, WalkDir}; use xml::escape::escape_str_pcdata; -use async_walkdir::WalkDir; use async_zip::write::{EntryOptions, ZipFileWriter}; use async_zip::Compression; use chrono::{TimeZone, Utc}; @@ -162,7 +162,8 @@ impl Server { self.handle_zip_dir(path, head_only, &mut res).await?; } else if allow_search && query.starts_with("q=") { let q = decode_uri(&query[2..]).unwrap_or_default(); - self.handle_query_dir(path, &q, head_only, &mut res).await?; + self.handle_search_dir(path, &q, head_only, &mut res) + .await?; } else { self.handle_ls_dir(path, true, head_only, &mut res).await?; } @@ -322,28 +323,39 @@ impl Server { self.send_index(path, paths, exist, head_only, res) } - async fn handle_query_dir( + async fn handle_search_dir( &self, path: &Path, - query: &str, + search: &str, head_only: bool, res: &mut Response, ) -> BoxResult<()> { let mut paths: Vec = vec![]; - let mut walkdir = WalkDir::new(path); - while let Some(entry) = walkdir.next().await { - if let Ok(entry) = entry { - if !entry - .file_name() - .to_string_lossy() + let hidden = self.args.hidden.to_string(); + let search = search.to_string(); + let mut walkdir = WalkDir::new(path).filter(move |entry| { + let hidden_cloned = hidden.clone(); + let search_cloned = search.clone(); + async move { + let entry_path = entry.path(); + let base_name = get_file_name(&entry_path); + if is_hidden(&hidden_cloned, base_name) { + return Filtering::IgnoreDir; + } + if !base_name .to_lowercase() - .contains(&query.to_lowercase()) + .contains(&search_cloned.to_lowercase()) { - continue; + return Filtering::Ignore; } if fs::symlink_metadata(entry.path()).await.is_err() { - continue; + return Filtering::Ignore; } + Filtering::Continue + } + }); + while let Some(entry) = walkdir.next().await { + if let Ok(entry) = entry { if let Ok(Some(item)) = self.to_pathitem(entry.path(), path.to_path_buf()).await { paths.push(item); } @@ -359,7 +371,7 @@ impl Server { res: &mut Response, ) -> BoxResult<()> { let (mut writer, reader) = tokio::io::duplex(BUF_SIZE); - let filename = get_file_name(path)?; + let filename = try_get_file_name(path)?; res.headers_mut().insert( CONTENT_DISPOSITION, HeaderValue::from_str(&format!( @@ -374,8 +386,9 @@ impl Server { return Ok(()); } let path = path.to_owned(); + let hidden = self.args.hidden.clone(); tokio::spawn(async move { - if let Err(e) = zip_dir(&mut writer, &path).await { + if let Err(e) = zip_dir(&mut writer, &path, &hidden).await { error!("Failed to zip {}, {}", path.display(), e); } }); @@ -513,7 +526,7 @@ impl Server { ); } - let filename = get_file_name(path)?; + let filename = try_get_file_name(path)?; res.headers_mut().insert( CONTENT_DISPOSITION, HeaderValue::from_str(&format!("inline; filename=\"{}\"", encode_uri(filename),)) @@ -802,6 +815,10 @@ DATA = {} let mut rd = fs::read_dir(entry_path).await?; while let Ok(Some(entry)) = rd.next_entry().await { let entry_path = entry.path(); + let base_name = get_file_name(&entry_path); + if is_hidden(&self.args.hidden, base_name) { + continue; + } if let Ok(Some(item)) = self.to_pathitem(entry_path.as_path(), base_path).await { paths.push(item); } @@ -910,11 +927,8 @@ impl PathItem { ), } } - fn base_name(&self) -> &str { - Path::new(&self.name) - .file_name() - .and_then(|v| v.to_str()) - .unwrap_or_default() + pub fn base_name(&self) -> &str { + self.name.split('/').last().unwrap_or_default() } } @@ -978,19 +992,30 @@ fn res_multistatus(res: &mut Response, content: &str) { )); } -async fn zip_dir(writer: &mut W, dir: &Path) -> BoxResult<()> { +async fn zip_dir(writer: &mut W, dir: &Path, hidden: &str) -> BoxResult<()> { let mut writer = ZipFileWriter::new(writer); - let mut walkdir = WalkDir::new(dir); + let hidden = hidden.to_string(); + let mut walkdir = WalkDir::new(dir).filter(move |entry| { + let hidden = hidden.clone(); + async move { + let entry_path = entry.path(); + let base_name = get_file_name(&entry_path); + if is_hidden(&hidden, base_name) { + return Filtering::IgnoreDir; + } + let meta = match fs::symlink_metadata(entry.path()).await { + Ok(meta) => meta, + Err(_) => return Filtering::Ignore, + }; + if !meta.is_file() { + return Filtering::Ignore; + } + Filtering::Continue + } + }); while let Some(entry) = walkdir.next().await { if let Ok(entry) = entry { let entry_path = entry.path(); - let meta = match fs::symlink_metadata(entry.path()).await { - Ok(meta) => meta, - Err(_) => continue, - }; - if !meta.is_file() { - continue; - } let filename = match entry_path.strip_prefix(dir).ok().and_then(|v| v.to_str()) { Some(v) => v, None => continue, @@ -1061,10 +1086,8 @@ fn status_no_content(res: &mut Response) { *res.status_mut() = StatusCode::NO_CONTENT; } -fn get_file_name(path: &Path) -> BoxResult<&str> { - path.file_name() - .and_then(|v| v.to_str()) - .ok_or_else(|| format!("Failed to get file name of `{}`", path.display()).into()) +fn is_hidden(hidden: &str, file_name: &str) -> bool { + hidden.contains(&format!(",{},", file_name)) } fn set_webdav_headers(res: &mut Response) { diff --git a/src/utils.rs b/src/utils.rs index ac2c8fe..6a27b65 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,4 +1,5 @@ -use std::borrow::Cow; +use crate::BoxResult; +use std::{borrow::Cow, path::Path}; pub fn encode_uri(v: &str) -> String { let parts: Vec<_> = v.split('/').map(urlencoding::encode).collect(); @@ -10,3 +11,15 @@ pub fn decode_uri(v: &str) -> Option> { .decode_utf8() .ok() } + +pub fn get_file_name(path: &Path) -> &str { + path.file_name() + .and_then(|v| v.to_str()) + .unwrap_or_default() +} + +pub fn try_get_file_name(path: &Path) -> BoxResult<&str> { + path.file_name() + .and_then(|v| v.to_str()) + .ok_or_else(|| format!("Failed to get file name of `{}`", path.display()).into()) +} diff --git a/tests/allow.rs b/tests/allow.rs index 6a1f4cd..f83a891 100644 --- a/tests/allow.rs +++ b/tests/allow.rs @@ -67,7 +67,7 @@ fn allow_search(#[with(&["--allow-search"])] server: TestServer) -> Result<(), E let paths = utils::retrive_index_paths(&resp.text()?); assert!(!paths.is_empty()); for p in paths { - assert!(p.contains(&"test.html")); + assert!(p.contains("test.html")); } Ok(()) } diff --git a/tests/args.rs b/tests/args.rs index f2bec63..83086e9 100644 --- a/tests/args.rs +++ b/tests/args.rs @@ -10,7 +10,7 @@ use std::process::{Command, Stdio}; #[rstest] fn path_prefix_index(#[with(&["--path-prefix", "xyz"])] server: TestServer) -> Result<(), Error> { let resp = reqwest::blocking::get(format!("{}{}", server.url(), "xyz"))?; - assert_index_resp!(resp); + assert_resp_paths!(resp); Ok(()) } diff --git a/tests/assets.rs b/tests/assets.rs index b5a1e95..ecc5662 100644 --- a/tests/assets.rs +++ b/tests/assets.rs @@ -59,3 +59,35 @@ fn asset_ico(server: TestServer) -> Result<(), Error> { assert_eq!(resp.headers().get("content-type").unwrap(), "image/x-icon"); Ok(()) } + +#[rstest] +fn assets_with_prefix(#[with(&["--path-prefix", "xyz"])] server: TestServer) -> Result<(), Error> { + let ver = env!("CARGO_PKG_VERSION"); + let resp = reqwest::blocking::get(format!("{}xyz/", server.url()))?; + let index_js = format!("/xyz/__dufs_v{}_index.js", ver); + let index_css = format!("/xyz/__dufs_v{}_index.css", ver); + let favicon_ico = format!("/xyz/__dufs_v{}_favicon.ico", ver); + let text = resp.text()?; + assert!(text.contains(&format!(r#"href="{}""#, index_css))); + assert!(text.contains(&format!(r#"href="{}""#, favicon_ico))); + assert!(text.contains(&format!(r#"src="{}""#, index_js))); + Ok(()) +} + +#[rstest] +fn asset_js_with_prefix( + #[with(&["--path-prefix", "xyz"])] server: TestServer, +) -> Result<(), Error> { + let url = format!( + "{}xyz/__dufs_v{}_index.js", + server.url(), + env!("CARGO_PKG_VERSION") + ); + let resp = reqwest::blocking::get(url)?; + assert_eq!(resp.status(), 200); + assert_eq!( + resp.headers().get("content-type").unwrap(), + "application/javascript" + ); + Ok(()) +} diff --git a/tests/fixtures.rs b/tests/fixtures.rs index f97276b..f53457f 100644 --- a/tests/fixtures.rs +++ b/tests/fixtures.rs @@ -23,9 +23,13 @@ pub static DIR_NO_FOUND: &str = "dir-no-found/"; #[allow(dead_code)] pub static DIR_NO_INDEX: &str = "dir-no-index/"; +/// Directory names for testing hidden +#[allow(dead_code)] +pub static DIR_GIT: &str = ".git/"; + /// Directory names for testing purpose #[allow(dead_code)] -pub static DIRECTORIES: &[&str] = &["dira/", "dirb/", "dirc/", DIR_NO_INDEX]; +pub static DIRECTORIES: &[&str] = &["dira/", "dirb/", "dirc/", DIR_NO_INDEX, DIR_GIT]; /// Test fixture which creates a temporary directory with a few files and directories inside. /// The directories also contain files. diff --git a/tests/hidden.rs b/tests/hidden.rs new file mode 100644 index 0000000..1f4f91f --- /dev/null +++ b/tests/hidden.rs @@ -0,0 +1,42 @@ +mod fixtures; +mod utils; + +use fixtures::{server, Error, TestServer}; +use rstest::rstest; + +#[rstest] +#[case(server(&[] as &[&str]), true)] +#[case(server(&["--hidden", ".git,index.html"]), false)] +fn hidden_get_dir(#[case] server: TestServer, #[case] exist: bool) -> Result<(), Error> { + let resp = reqwest::blocking::get(server.url())?; + assert_eq!(resp.status(), 200); + let paths = utils::retrive_index_paths(&resp.text()?); + assert_eq!(paths.contains(".git/"), exist); + assert_eq!(paths.contains("index.html"), exist); + Ok(()) +} + +#[rstest] +#[case(server(&[] as &[&str]), true)] +#[case(server(&["--hidden", ".git,index.html"]), false)] +fn hidden_propfind_dir(#[case] server: TestServer, #[case] exist: bool) -> Result<(), Error> { + let resp = fetch!(b"PROPFIND", server.url()).send()?; + assert_eq!(resp.status(), 207); + let body = resp.text()?; + assert_eq!(body.contains("/.git/"), exist); + assert_eq!(body.contains("/index.html"), exist); + Ok(()) +} + +#[rstest] +#[case(server(&["--allow-search"] as &[&str]), true)] +#[case(server(&["--allow-search", "--hidden", ".git,test.html"]), false)] +fn hidden_search_dir(#[case] server: TestServer, #[case] exist: bool) -> Result<(), Error> { + let resp = reqwest::blocking::get(format!("{}?q={}", server.url(), "test.html"))?; + assert_eq!(resp.status(), 200); + let paths = utils::retrive_index_paths(&resp.text()?); + for p in paths { + assert_eq!(p.contains("test.html"), exist); + } + Ok(()) +} diff --git a/tests/http.rs b/tests/http.rs index daa8e22..bb52356 100644 --- a/tests/http.rs +++ b/tests/http.rs @@ -7,7 +7,7 @@ use rstest::rstest; #[rstest] fn get_dir(server: TestServer) -> Result<(), Error> { let resp = reqwest::blocking::get(server.url())?; - assert_index_resp!(resp); + assert_resp_paths!(resp); Ok(()) } @@ -69,7 +69,7 @@ fn get_dir_search(#[with(&["-A"])] server: TestServer) -> Result<(), Error> { let paths = utils::retrive_index_paths(&resp.text()?); assert!(!paths.is_empty()); for p in paths { - assert!(p.contains(&"test.html")); + assert!(p.contains("test.html")); } Ok(()) } @@ -81,7 +81,7 @@ fn get_dir_search2(#[with(&["-A"])] server: TestServer) -> Result<(), Error> { let paths = utils::retrive_index_paths(&resp.text()?); assert!(!paths.is_empty()); for p in paths { - assert!(p.contains(&"😀.bin")); + assert!(p.contains("😀.bin")); } Ok(()) } diff --git a/tests/render.rs b/tests/render.rs index 9611113..9ecfd8e 100644 --- a/tests/render.rs +++ b/tests/render.rs @@ -1,7 +1,7 @@ mod fixtures; mod utils; -use fixtures::{server, Error, TestServer, DIR_NO_FOUND, DIR_NO_INDEX}; +use fixtures::{server, Error, TestServer, DIR_NO_FOUND, DIR_NO_INDEX, FILES}; use rstest::rstest; #[rstest] @@ -30,12 +30,12 @@ fn render_try_index(#[with(&["--render-try-index"])] server: TestServer) -> Resu #[rstest] fn render_try_index2(#[with(&["--render-try-index"])] server: TestServer) -> Result<(), Error> { let resp = reqwest::blocking::get(format!("{}{}", server.url(), DIR_NO_INDEX))?; - let files: Vec<&str> = self::fixtures::FILES + let files: Vec<&str> = FILES .iter() .filter(|v| **v != "index.html") .cloned() .collect(); - assert_index_resp!(resp, files); + assert_resp_paths!(resp, files); Ok(()) } diff --git a/tests/tls.rs b/tests/tls.rs index 94c7ab8..ca4c65c 100644 --- a/tests/tls.rs +++ b/tests/tls.rs @@ -22,7 +22,7 @@ fn tls_works(#[case] server: TestServer) -> Result<(), Error> { .danger_accept_invalid_certs(true) .build()?; let resp = client.get(server.url()).send()?.error_for_status()?; - assert_index_resp!(resp); + assert_resp_paths!(resp); Ok(()) } diff --git a/tests/utils.rs b/tests/utils.rs index d33a473..a0cff6e 100644 --- a/tests/utils.rs +++ b/tests/utils.rs @@ -2,9 +2,9 @@ use serde_json::Value; use std::collections::HashSet; #[macro_export] -macro_rules! assert_index_resp { +macro_rules! assert_resp_paths { ($resp:ident) => { - assert_index_resp!($resp, self::fixtures::FILES) + assert_resp_paths!($resp, self::fixtures::FILES) }; ($resp:ident, $files:expr) => { assert_eq!($resp.status(), 200);