feat: support hiding folders with --hidden (#73)

This commit is contained in:
sigoden 2022-06-25 08:15:16 +08:00 committed by GitHub
parent c1c6dbc356
commit eb7a536a3f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 183 additions and 50 deletions

View file

@ -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
```

View file

@ -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,

View file

@ -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) {

View file

@ -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())
}

View file

@ -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(())
}

View file

@ -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(())
}

View file

@ -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(())
}

View file

@ -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
View 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(())
}

View file

@ -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(())
}

View file

@ -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(())
}

View file

@ -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(())
}

View file

@ -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);