Compare commits

..

7 Commits

Author SHA1 Message Date
sigoden
835438fc2a chore: release v0.43.0 (#476) 2024-11-05 17:08:43 +08:00
sigoden
d445b78f96 feat: provide healthcheck API (#474) 2024-10-28 07:37:21 +08:00
sigoden
881a67e1a4 feat: support binding abstract unix socket (#468) 2024-10-23 06:57:45 +08:00
sigoden
bb5a5564b4 feat: webui displays subdirectory items (#457) 2024-09-25 22:19:25 +08:00
clxcore
2cf6d39032 fix: resolve speed bottleneck in 10G network (#451)
Co-authored-by: clxcore <clxcore@gmail.com>
2024-09-07 17:57:05 +08:00
sigoden
c500ce7acc fix: auth failed if password contains : (#449) 2024-09-06 21:22:28 +08:00
freedit-dev
f87c52fda2 refactor: do not show size for Dir (#447) 2024-09-06 07:36:15 +08:00
11 changed files with 358 additions and 388 deletions

View File

@@ -2,6 +2,23 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [0.43.0] - 2024-11-04
### Bug Fixes
- Auth failed if password contains `:` ([#449](https://github.com/sigoden/dufs/issues/449))
- Resolve speed bottleneck in 10G network ([#451](https://github.com/sigoden/dufs/issues/451))
### Features
- Webui displays subdirectory items ([#457](https://github.com/sigoden/dufs/issues/457))
- Support binding abstract unix socket ([#468](https://github.com/sigoden/dufs/issues/468))
- Provide healthcheck API ([#474](https://github.com/sigoden/dufs/issues/474))
### Refactor
- Do not show size for Dir ([#447](https://github.com/sigoden/dufs/issues/447))
## [0.42.0] - 2024-09-01 ## [0.42.0] - 2024-09-01
### Bug Fixes ### Bug Fixes

526
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "dufs" name = "dufs"
version = "0.42.0" version = "0.43.0"
edition = "2021" edition = "2021"
authors = ["sigoden <sigoden@gmail.com>"] authors = ["sigoden <sigoden@gmail.com>"]
description = "Dufs is a distinctive utility file server" description = "Dufs is a distinctive utility file server"
@@ -63,7 +63,7 @@ assert_cmd = "2"
reqwest = { version = "0.12", features = ["blocking", "multipart", "rustls-tls"], default-features = false } reqwest = { version = "0.12", features = ["blocking", "multipart", "rustls-tls"], default-features = false }
assert_fs = "1" assert_fs = "1"
port_check = "0.2" port_check = "0.2"
rstest = "0.22" rstest = "0.23"
regex = "1" regex = "1"
url = "2" url = "2"
predicates = "3" predicates = "3"

View File

@@ -216,8 +216,14 @@ dd skip=$upload_offset if=file status=none ibs=1 | \
curl -X PATCH -H "X-Update-Range: append" --data-binary @- http://127.0.0.1:5000/file curl -X PATCH -H "X-Update-Range: append" --data-binary @- http://127.0.0.1:5000/file
``` ```
Health checks
```sh
curl http://127.0.0.1:5000/__dufs__/health
```
<details> <details>
<summary><h2>Advanced topics</h2></summary> <summary><h2>Advanced Topics</h2></summary>
### Access Control ### Access Control
@@ -247,8 +253,7 @@ DUFS supports the use of sha-512 hashed password.
Create hashed password Create hashed password
``` ```
$ mkpasswd -m sha-512 -s $ mkpasswd -m sha-512 123456
Password: 123456
$6$tWMB51u6Kb2ui3wd$5gVHP92V9kZcMwQeKTjyTRgySsYJu471Jb1I6iHQ8iZ6s07GgCIO69KcPBRuwPE5tDq05xMAzye0NxVKuJdYs/ $6$tWMB51u6Kb2ui3wd$5gVHP92V9kZcMwQeKTjyTRgySsYJu471Jb1I6iHQ8iZ6s07GgCIO69KcPBRuwPE5tDq05xMAzye0NxVKuJdYs/
``` ```
@@ -396,6 +401,8 @@ Dufs allows users to customize the UI with your own assets.
dufs --assets my-assets-dir/ dufs --assets my-assets-dir/
``` ```
> If you only need to make slight adjustments to the current UI, you copy dufs's [assets](https://github.com/sigoden/dufs/tree/main/assets) directory and modify it accordingly. The current UI doesn't use any frameworks, just plain HTML/JS/CSS. As long as you have some basic knowledge of web development, it shouldn't be difficult to modify.
Your assets folder must contains a `index.html` file. Your assets folder must contains a `index.html` file.
`index.html` can use the following placeholder variables to retrieve internal data. `index.html` can use the following placeholder variables to retrieve internal data.

View File

@@ -478,6 +478,8 @@ function addPath(file, index) {
${actionEdit} ${actionEdit}
</td>`; </td>`;
let sizeDisplay = isDir ? `${file.size} ${file.size === 1 ? "item" : "items"}` : formatSize(file.size).join(" ");
$pathsTableBody.insertAdjacentHTML("beforeend", ` $pathsTableBody.insertAdjacentHTML("beforeend", `
<tr id="addPath${index}"> <tr id="addPath${index}">
<td class="path cell-icon"> <td class="path cell-icon">
@@ -487,7 +489,7 @@ function addPath(file, index) {
<a href="${url}" ${isDir ? "" : `target="_blank"`}>${encodedName}</a> <a href="${url}" ${isDir ? "" : `target="_blank"`}>${encodedName}</a>
</td> </td>
<td class="cell-mtime">${formatMtime(file.mtime)}</td> <td class="cell-mtime">${formatMtime(file.mtime)}</td>
<td class="cell-size">${formatSize(file.size).join(" ")}</td> <td class="cell-size">${sizeDisplay}</td>
${actionCell} ${actionCell}
</tr>`); </tr>`);
} }

View File

@@ -461,28 +461,30 @@ impl Args {
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum BindAddr { pub enum BindAddr {
Address(IpAddr), IpAddr(IpAddr),
Path(PathBuf), #[cfg(unix)]
SocketPath(String),
} }
impl BindAddr { impl BindAddr {
fn parse_addrs(addrs: &[&str]) -> Result<Vec<Self>> { fn parse_addrs(addrs: &[&str]) -> Result<Vec<Self>> {
let mut bind_addrs = vec![]; let mut bind_addrs = vec![];
#[cfg(not(unix))]
let mut invalid_addrs = vec![]; let mut invalid_addrs = vec![];
for addr in addrs { for addr in addrs {
match addr.parse::<IpAddr>() { match addr.parse::<IpAddr>() {
Ok(v) => { Ok(v) => {
bind_addrs.push(BindAddr::Address(v)); bind_addrs.push(BindAddr::IpAddr(v));
} }
Err(_) => { Err(_) => {
if cfg!(unix) { #[cfg(unix)]
bind_addrs.push(BindAddr::Path(PathBuf::from(addr))); bind_addrs.push(BindAddr::SocketPath(addr.to_string()));
} else { #[cfg(not(unix))]
invalid_addrs.push(*addr); invalid_addrs.push(*addr);
}
} }
} }
} }
#[cfg(not(unix))]
if !invalid_addrs.is_empty() { if !invalid_addrs.is_empty() {
bail!("Invalid bind address `{}`", invalid_addrs.join(",")); bail!("Invalid bind address `{}`", invalid_addrs.join(","));
} }
@@ -710,7 +712,7 @@ hidden: tmp,*.log,*.lock
assert_eq!(args.serve_path, Args::sanitize_path(&tmpdir).unwrap()); assert_eq!(args.serve_path, Args::sanitize_path(&tmpdir).unwrap());
assert_eq!( assert_eq!(
args.addrs, args.addrs,
vec![BindAddr::Address("0.0.0.0".parse().unwrap())] vec![BindAddr::IpAddr("0.0.0.0".parse().unwrap())]
); );
assert_eq!(args.hidden, ["tmp", "*.log", "*.lock"]); assert_eq!(args.hidden, ["tmp", "*.log", "*.lock"]);
assert_eq!(args.port, 3000); assert_eq!(args.port, 3000);
@@ -740,8 +742,8 @@ hidden:
assert_eq!( assert_eq!(
args.addrs, args.addrs,
vec![ vec![
BindAddr::Address("127.0.0.1".parse().unwrap()), BindAddr::IpAddr("127.0.0.1".parse().unwrap()),
BindAddr::Address("192.168.8.10".parse().unwrap()) BindAddr::IpAddr("192.168.8.10".parse().unwrap())
] ]
); );
assert_eq!(args.hidden, ["tmp", "*.log", "*.lock"]); assert_eq!(args.hidden, ["tmp", "*.log", "*.lock"]);

View File

@@ -307,17 +307,17 @@ pub fn check_auth(
) -> Option<()> { ) -> Option<()> {
if let Some(value) = strip_prefix(authorization.as_bytes(), b"Basic ") { if let Some(value) = strip_prefix(authorization.as_bytes(), b"Basic ") {
let value: Vec<u8> = STANDARD.decode(value).ok()?; let value: Vec<u8> = STANDARD.decode(value).ok()?;
let parts: Vec<&str> = std::str::from_utf8(&value).ok()?.split(':').collect(); let (user, pass) = std::str::from_utf8(&value).ok()?.split_once(':')?;
if parts[0] != auth_user { if user != auth_user {
return None; return None;
} }
if auth_pass.starts_with("$6$") { if auth_pass.starts_with("$6$") {
if let Ok(()) = sha_crypt::sha512_check(parts[1], auth_pass) { if let Ok(()) = sha_crypt::sha512_check(pass, auth_pass) {
return Some(()); return Some(());
} }
} else if parts[1] == auth_pass { } else if pass == auth_pass {
return Some(()); return Some(());
} }

View File

@@ -78,7 +78,7 @@ fn serve(args: Args, running: Arc<AtomicBool>) -> Result<Vec<JoinHandle<()>>> {
for bind_addr in addrs.iter() { for bind_addr in addrs.iter() {
let server_handle = server_handle.clone(); let server_handle = server_handle.clone();
match bind_addr { match bind_addr {
BindAddr::Address(ip) => { BindAddr::IpAddr(ip) => {
let listener = create_listener(SocketAddr::new(*ip, port)) let listener = create_listener(SocketAddr::new(*ip, port))
.with_context(|| format!("Failed to bind `{ip}:{port}`"))?; .with_context(|| format!("Failed to bind `{ip}:{port}`"))?;
@@ -140,26 +140,32 @@ fn serve(args: Args, running: Arc<AtomicBool>) -> Result<Vec<JoinHandle<()>>> {
} }
}; };
} }
BindAddr::Path(path) => { #[cfg(unix)]
if path.exists() { BindAddr::SocketPath(path) => {
std::fs::remove_file(path)?; let socket_path = if path.starts_with("@")
} && cfg!(any(target_os = "linux", target_os = "android"))
#[cfg(unix)]
{ {
let listener = tokio::net::UnixListener::bind(path) let mut path_buf = path.as_bytes().to_vec();
.with_context(|| format!("Failed to bind `{}`", path.display()))?; path_buf[0] = b'\0';
let handle = tokio::spawn(async move { unsafe { std::ffi::OsStr::from_encoded_bytes_unchecked(&path_buf) }
loop { .to_os_string()
let Ok((stream, _addr)) = listener.accept().await else { } else {
continue; let _ = std::fs::remove_file(path);
}; path.into()
let stream = TokioIo::new(stream); };
tokio::spawn(handle_stream(server_handle.clone(), stream, None)); let listener = tokio::net::UnixListener::bind(socket_path)
} .with_context(|| format!("Failed to bind `{}`", path))?;
}); let handle = tokio::spawn(async move {
loop {
let Ok((stream, _addr)) = listener.accept().await else {
continue;
};
let stream = TokioIo::new(stream);
tokio::spawn(handle_stream(server_handle.clone(), stream, None));
}
});
handles.push(handle); handles.push(handle);
}
} }
} }
} }
@@ -207,7 +213,7 @@ fn check_addrs(args: &Args) -> Result<(Vec<BindAddr>, Vec<BindAddr>)> {
let (ipv4_addrs, ipv6_addrs) = interface_addrs()?; let (ipv4_addrs, ipv6_addrs) = interface_addrs()?;
for bind_addr in args.addrs.iter() { for bind_addr in args.addrs.iter() {
match bind_addr { match bind_addr {
BindAddr::Address(ip) => match &ip { BindAddr::IpAddr(ip) => match &ip {
IpAddr::V4(_) => { IpAddr::V4(_) => {
if !ipv4_addrs.is_empty() { if !ipv4_addrs.is_empty() {
new_addrs.push(bind_addr.clone()); new_addrs.push(bind_addr.clone());
@@ -229,6 +235,7 @@ fn check_addrs(args: &Args) -> Result<(Vec<BindAddr>, Vec<BindAddr>)> {
} }
} }
}, },
#[cfg(unix)]
_ => { _ => {
new_addrs.push(bind_addr.clone()); new_addrs.push(bind_addr.clone());
print_addrs.push(bind_addr.clone()) print_addrs.push(bind_addr.clone())
@@ -246,10 +253,10 @@ fn interface_addrs() -> Result<(Vec<BindAddr>, Vec<BindAddr>)> {
for iface in ifaces.into_iter() { for iface in ifaces.into_iter() {
let ip = iface.ip(); let ip = iface.ip();
if ip.is_ipv4() { if ip.is_ipv4() {
ipv4_addrs.push(BindAddr::Address(ip)) ipv4_addrs.push(BindAddr::IpAddr(ip))
} }
if ip.is_ipv6() { if ip.is_ipv6() {
ipv6_addrs.push(BindAddr::Address(ip)) ipv6_addrs.push(BindAddr::IpAddr(ip))
} }
} }
Ok((ipv4_addrs, ipv6_addrs)) Ok((ipv4_addrs, ipv6_addrs))
@@ -260,7 +267,7 @@ fn print_listening(args: &Args, print_addrs: &[BindAddr]) -> Result<String> {
let urls = print_addrs let urls = print_addrs
.iter() .iter()
.map(|bind_addr| match bind_addr { .map(|bind_addr| match bind_addr {
BindAddr::Address(addr) => { BindAddr::IpAddr(addr) => {
let addr = match addr { let addr = match addr {
IpAddr::V4(_) => format!("{}:{}", addr, args.port), IpAddr::V4(_) => format!("{}:{}", addr, args.port),
IpAddr::V6(_) => format!("[{}]:{}", addr, args.port), IpAddr::V6(_) => format!("[{}]:{}", addr, args.port),
@@ -272,7 +279,8 @@ fn print_listening(args: &Args, print_addrs: &[BindAddr]) -> Result<String> {
}; };
format!("{}://{}{}", protocol, addr, args.uri_prefix) format!("{}://{}{}", protocol, addr, args.uri_prefix)
} }
BindAddr::Path(path) => path.display().to_string(), #[cfg(unix)]
BindAddr::SocketPath(path) => path.to_string(),
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();

View File

@@ -62,6 +62,7 @@ const INDEX_NAME: &str = "index.html";
const BUF_SIZE: usize = 65536; const BUF_SIZE: usize = 65536;
const EDITABLE_TEXT_MAX_SIZE: u64 = 4194304; // 4M const EDITABLE_TEXT_MAX_SIZE: u64 = 4194304; // 4M
const RESUMABLE_UPLOAD_MIN_SIZE: u64 = 20971520; // 20M const RESUMABLE_UPLOAD_MIN_SIZE: u64 = 20971520; // 20M
const HEALTH_CHECK_PATH: &str = "__dufs__/health";
pub struct Server { pub struct Server {
args: Args, args: Args,
@@ -171,7 +172,7 @@ impl Server {
if method == Method::GET if method == Method::GET
&& self && self
.handle_assets(&relative_path, headers, &mut res) .handle_internal(&relative_path, headers, &mut res)
.await? .await?
{ {
return Ok(res); return Ok(res);
@@ -682,7 +683,7 @@ impl Server {
error!("Failed to zip {}, {}", path.display(), e); error!("Failed to zip {}, {}", path.display(), e);
} }
}); });
let reader_stream = ReaderStream::new(reader); let reader_stream = ReaderStream::with_capacity(reader, BUF_SIZE);
let stream_body = StreamBody::new( let stream_body = StreamBody::new(
reader_stream reader_stream
.map_ok(Frame::data) .map_ok(Frame::data)
@@ -738,7 +739,7 @@ impl Server {
Ok(()) Ok(())
} }
async fn handle_assets( async fn handle_internal(
&self, &self,
req_path: &str, req_path: &str,
headers: &HeaderMap<HeaderValue>, headers: &HeaderMap<HeaderValue>,
@@ -789,6 +790,12 @@ impl Server {
HeaderValue::from_static("nosniff"), HeaderValue::from_static("nosniff"),
); );
Ok(true) Ok(true)
} else if req_path == HEALTH_CHECK_PATH {
res.headers_mut()
.typed_insert(ContentType::from(mime_guess::mime::APPLICATION_JSON));
*res.body_mut() = body_full(r#"{"status":"OK"}"#);
Ok(true)
} else { } else {
Ok(false) Ok(false)
} }
@@ -899,7 +906,7 @@ impl Server {
return Ok(()); return Ok(());
} }
let reader_stream = ReaderStream::new(file); let reader_stream = ReaderStream::with_capacity(file, BUF_SIZE);
let stream_body = StreamBody::new( let stream_body = StreamBody::new(
reader_stream reader_stream
.map_ok(Frame::data) .map_ok(Frame::data)
@@ -1363,8 +1370,15 @@ impl Server {
}; };
let mtime = to_timestamp(&meta.modified()?); let mtime = to_timestamp(&meta.modified()?);
let size = match path_type { let size = match path_type {
PathType::Dir | PathType::SymlinkDir => None, PathType::Dir | PathType::SymlinkDir => {
PathType::File | PathType::SymlinkFile => Some(meta.len()), let mut count = 0;
let mut entries = tokio::fs::read_dir(&path).await?;
while entries.next_entry().await?.is_some() {
count += 1;
}
count
}
PathType::File | PathType::SymlinkFile => meta.len(),
}; };
let rel_path = path.strip_prefix(base_path)?; let rel_path = path.strip_prefix(base_path)?;
let name = normalize_path(rel_path); let name = normalize_path(rel_path);
@@ -1416,7 +1430,7 @@ struct PathItem {
path_type: PathType, path_type: PathType,
name: String, name: String,
mtime: u64, mtime: u64,
size: Option<u64>, size: u64,
} }
impl PathItem { impl PathItem {
@@ -1450,21 +1464,18 @@ impl PathItem {
), ),
PathType::File | PathType::SymlinkFile => format!( PathType::File | PathType::SymlinkFile => format!(
r#"<D:response> r#"<D:response>
<D:href>{}</D:href> <D:href>{href}</D:href>
<D:propstat> <D:propstat>
<D:prop> <D:prop>
<D:displayname>{}</D:displayname> <D:displayname>{displayname}</D:displayname>
<D:getcontentlength>{}</D:getcontentlength> <D:getcontentlength>{}</D:getcontentlength>
<D:getlastmodified>{}</D:getlastmodified> <D:getlastmodified>{mtime}</D:getlastmodified>
<D:resourcetype></D:resourcetype> <D:resourcetype></D:resourcetype>
</D:prop> </D:prop>
<D:status>HTTP/1.1 200 OK</D:status> <D:status>HTTP/1.1 200 OK</D:status>
</D:propstat> </D:propstat>
</D:response>"#, </D:response>"#,
href, self.size
displayname,
self.size.unwrap_or_default(),
mtime
), ),
} }
} }
@@ -1491,16 +1502,7 @@ impl PathItem {
pub fn sort_by_size(&self, other: &Self) -> Ordering { pub fn sort_by_size(&self, other: &Self) -> Ordering {
match self.path_type.cmp(&other.path_type) { match self.path_type.cmp(&other.path_type) {
Ordering::Equal => { Ordering::Equal => self.size.cmp(&other.size),
if self.is_dir() {
alphanumeric_sort::compare_str(
self.name.to_lowercase(),
other.name.to_lowercase(),
)
} else {
self.size.unwrap_or(0).cmp(&other.size.unwrap_or(0))
}
}
v => v, v => v,
} }
} }

View File

@@ -57,17 +57,18 @@ fn invalid_auth(
Ok(()) Ok(())
} }
const HASHED_PASSWORD_AUTH: &str = "user:$6$gQxZwKyWn/ZmWEA2$4uV7KKMnSUnET2BtWTj/9T5.Jq3h/MdkOlnIl5hdlTxDZ4MZKmJ.kl6C.NL9xnNPqC4lVHC1vuI0E5cLpTJX81@/:rw"; // user:pass
#[rstest] #[rstest]
#[case(server(&["--auth", "user:$6$gQxZwKyWn/ZmWEA2$4uV7KKMnSUnET2BtWTj/9T5.Jq3h/MdkOlnIl5hdlTxDZ4MZKmJ.kl6C.NL9xnNPqC4lVHC1vuI0E5cLpTJX81@/:rw", "-A"]), "user", "pass")]
#[case(server(&["--auth", "user:$6$YV1J6OHZAAgbzCbS$V55ZEgvJ6JFdz1nLO4AD696PRHAJYhfQf.Gy2HafrCz5itnbgNTtTgfUSqZrt4BJ7FcpRfSt/QZzAan68pido0@/:rw", "-A"]), "user", "pa:ss@1")]
fn auth_hashed_password( fn auth_hashed_password(
#[with(&["--auth", HASHED_PASSWORD_AUTH, "-A"])] server: TestServer, #[case] server: TestServer,
#[case] user: &str,
#[case] pass: &str,
) -> Result<(), Error> { ) -> Result<(), Error> {
let url = format!("{}file1", server.url()); let url = format!("{}file1", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?; let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 401); assert_eq!(resp.status(), 401);
if let Err(err) = if let Err(err) = send_with_digest_auth(fetch!(b"PUT", &url).body(b"abc".to_vec()), user, pass)
send_with_digest_auth(fetch!(b"PUT", &url).body(b"abc".to_vec()), "user", "pass")
{ {
assert_eq!( assert_eq!(
err.to_string(), err.to_string(),
@@ -76,7 +77,7 @@ fn auth_hashed_password(
} }
let resp = fetch!(b"PUT", &url) let resp = fetch!(b"PUT", &url)
.body(b"abc".to_vec()) .body(b"abc".to_vec())
.basic_auth("user", Some("pass")) .basic_auth(user, Some(pass))
.send()?; .send()?;
assert_eq!(resp.status(), 201); assert_eq!(resp.status(), 201);
Ok(()) Ok(())

31
tests/health.rs Normal file
View File

@@ -0,0 +1,31 @@
mod fixtures;
mod utils;
use fixtures::{server, Error, TestServer};
use rstest::rstest;
const HEALTH_CHECK_PATH: &str = "__dufs__/health";
const HEALTH_CHECK_RESPONSE: &str = r#"{"status":"OK"}"#;
#[rstest]
fn normal_health(server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}{HEALTH_CHECK_PATH}", server.url()))?;
assert_eq!(resp.text()?, HEALTH_CHECK_RESPONSE);
Ok(())
}
#[rstest]
fn auth_health(
#[with(&["--auth", "user:pass@/:rw", "-A"])] server: TestServer,
) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}{HEALTH_CHECK_PATH}", server.url()))?;
assert_eq!(resp.text()?, HEALTH_CHECK_RESPONSE);
Ok(())
}
#[rstest]
fn path_prefix_health(#[with(&["--path-prefix", "xyz"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}xyz/{HEALTH_CHECK_PATH}", server.url()))?;
assert_eq!(resp.text()?, HEALTH_CHECK_RESPONSE);
Ok(())
}