feat: support hiding folders with --hidden (#73)
This commit is contained in:
parent
c1c6dbc356
commit
eb7a536a3f
13 changed files with 183 additions and 50 deletions
|
@ -52,6 +52,7 @@ OPTIONS:
|
|||
-b, --bind <addr>... Specify bind address
|
||||
-p, --port <port> Specify port to listen on [default: 5000]
|
||||
--path-prefix <path> Specify an path prefix
|
||||
--hidden <names> Comma-separated list of names to hide from directory listings
|
||||
-a, --auth <rule>... Add auth for path
|
||||
--auth-method <value> 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> Path to an SSL/TLS certificate to serve with HTTPS
|
||||
--tls-key <path> 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
|
||||
|
||||
```
|
||||
|
|
14
src/args.rs
14
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,
|
||||
|
|
|
@ -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<PathItem> = 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<W: AsyncWrite + Unpin>(writer: &mut W, dir: &Path) -> BoxResult<()> {
|
||||
async fn zip_dir<W: AsyncWrite + Unpin>(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) {
|
||||
|
|
15
src/utils.rs
15
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<Cow<str>> {
|
|||
.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())
|
||||
}
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
42
tests/hidden.rs
Normal file
42
tests/hidden.rs
Normal file
|
@ -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("<D:href>/.git/</D:href>"), exist);
|
||||
assert_eq!(body.contains("<D:href>/index.html</D:href>"), 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(())
|
||||
}
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue