mirror of
https://github.com/sigoden/dufs.git
synced 2026-04-09 17:13:02 +03:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
835438fc2a | ||
|
|
d445b78f96 | ||
|
|
881a67e1a4 | ||
|
|
bb5a5564b4 | ||
|
|
2cf6d39032 | ||
|
|
c500ce7acc | ||
|
|
f87c52fda2 |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -2,6 +2,23 @@
|
||||
|
||||
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
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
526
Cargo.lock
generated
526
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "dufs"
|
||||
version = "0.42.0"
|
||||
version = "0.43.0"
|
||||
edition = "2021"
|
||||
authors = ["sigoden <sigoden@gmail.com>"]
|
||||
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 }
|
||||
assert_fs = "1"
|
||||
port_check = "0.2"
|
||||
rstest = "0.22"
|
||||
rstest = "0.23"
|
||||
regex = "1"
|
||||
url = "2"
|
||||
predicates = "3"
|
||||
|
||||
13
README.md
13
README.md
@@ -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
|
||||
```
|
||||
|
||||
Health checks
|
||||
|
||||
```sh
|
||||
curl http://127.0.0.1:5000/__dufs__/health
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><h2>Advanced topics</h2></summary>
|
||||
<summary><h2>Advanced Topics</h2></summary>
|
||||
|
||||
### Access Control
|
||||
|
||||
@@ -247,8 +253,7 @@ DUFS supports the use of sha-512 hashed password.
|
||||
Create hashed password
|
||||
|
||||
```
|
||||
$ mkpasswd -m sha-512 -s
|
||||
Password: 123456
|
||||
$ mkpasswd -m sha-512 123456
|
||||
$6$tWMB51u6Kb2ui3wd$5gVHP92V9kZcMwQeKTjyTRgySsYJu471Jb1I6iHQ8iZ6s07GgCIO69KcPBRuwPE5tDq05xMAzye0NxVKuJdYs/
|
||||
```
|
||||
|
||||
@@ -396,6 +401,8 @@ Dufs allows users to customize the UI with your own assets.
|
||||
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.
|
||||
|
||||
`index.html` can use the following placeholder variables to retrieve internal data.
|
||||
|
||||
@@ -478,6 +478,8 @@ function addPath(file, index) {
|
||||
${actionEdit}
|
||||
</td>`;
|
||||
|
||||
let sizeDisplay = isDir ? `${file.size} ${file.size === 1 ? "item" : "items"}` : formatSize(file.size).join(" ");
|
||||
|
||||
$pathsTableBody.insertAdjacentHTML("beforeend", `
|
||||
<tr id="addPath${index}">
|
||||
<td class="path cell-icon">
|
||||
@@ -487,7 +489,7 @@ function addPath(file, index) {
|
||||
<a href="${url}" ${isDir ? "" : `target="_blank"`}>${encodedName}</a>
|
||||
</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}
|
||||
</tr>`);
|
||||
}
|
||||
|
||||
22
src/args.rs
22
src/args.rs
@@ -461,28 +461,30 @@ impl Args {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum BindAddr {
|
||||
Address(IpAddr),
|
||||
Path(PathBuf),
|
||||
IpAddr(IpAddr),
|
||||
#[cfg(unix)]
|
||||
SocketPath(String),
|
||||
}
|
||||
|
||||
impl BindAddr {
|
||||
fn parse_addrs(addrs: &[&str]) -> Result<Vec<Self>> {
|
||||
let mut bind_addrs = vec![];
|
||||
#[cfg(not(unix))]
|
||||
let mut invalid_addrs = vec![];
|
||||
for addr in addrs {
|
||||
match addr.parse::<IpAddr>() {
|
||||
Ok(v) => {
|
||||
bind_addrs.push(BindAddr::Address(v));
|
||||
bind_addrs.push(BindAddr::IpAddr(v));
|
||||
}
|
||||
Err(_) => {
|
||||
if cfg!(unix) {
|
||||
bind_addrs.push(BindAddr::Path(PathBuf::from(addr)));
|
||||
} else {
|
||||
#[cfg(unix)]
|
||||
bind_addrs.push(BindAddr::SocketPath(addr.to_string()));
|
||||
#[cfg(not(unix))]
|
||||
invalid_addrs.push(*addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
if !invalid_addrs.is_empty() {
|
||||
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.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.port, 3000);
|
||||
@@ -740,8 +742,8 @@ hidden:
|
||||
assert_eq!(
|
||||
args.addrs,
|
||||
vec![
|
||||
BindAddr::Address("127.0.0.1".parse().unwrap()),
|
||||
BindAddr::Address("192.168.8.10".parse().unwrap())
|
||||
BindAddr::IpAddr("127.0.0.1".parse().unwrap()),
|
||||
BindAddr::IpAddr("192.168.8.10".parse().unwrap())
|
||||
]
|
||||
);
|
||||
assert_eq!(args.hidden, ["tmp", "*.log", "*.lock"]);
|
||||
|
||||
@@ -307,17 +307,17 @@ pub fn check_auth(
|
||||
) -> Option<()> {
|
||||
if let Some(value) = strip_prefix(authorization.as_bytes(), b"Basic ") {
|
||||
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;
|
||||
}
|
||||
|
||||
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(());
|
||||
}
|
||||
} else if parts[1] == auth_pass {
|
||||
} else if pass == auth_pass {
|
||||
return Some(());
|
||||
}
|
||||
|
||||
|
||||
34
src/main.rs
34
src/main.rs
@@ -78,7 +78,7 @@ fn serve(args: Args, running: Arc<AtomicBool>) -> Result<Vec<JoinHandle<()>>> {
|
||||
for bind_addr in addrs.iter() {
|
||||
let server_handle = server_handle.clone();
|
||||
match bind_addr {
|
||||
BindAddr::Address(ip) => {
|
||||
BindAddr::IpAddr(ip) => {
|
||||
let listener = create_listener(SocketAddr::new(*ip, port))
|
||||
.with_context(|| format!("Failed to bind `{ip}:{port}`"))?;
|
||||
|
||||
@@ -140,14 +140,21 @@ fn serve(args: Args, running: Arc<AtomicBool>) -> Result<Vec<JoinHandle<()>>> {
|
||||
}
|
||||
};
|
||||
}
|
||||
BindAddr::Path(path) => {
|
||||
if path.exists() {
|
||||
std::fs::remove_file(path)?;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
BindAddr::SocketPath(path) => {
|
||||
let socket_path = if path.starts_with("@")
|
||||
&& cfg!(any(target_os = "linux", target_os = "android"))
|
||||
{
|
||||
let listener = tokio::net::UnixListener::bind(path)
|
||||
.with_context(|| format!("Failed to bind `{}`", path.display()))?;
|
||||
let mut path_buf = path.as_bytes().to_vec();
|
||||
path_buf[0] = b'\0';
|
||||
unsafe { std::ffi::OsStr::from_encoded_bytes_unchecked(&path_buf) }
|
||||
.to_os_string()
|
||||
} else {
|
||||
let _ = std::fs::remove_file(path);
|
||||
path.into()
|
||||
};
|
||||
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 {
|
||||
@@ -162,7 +169,6 @@ fn serve(args: Args, running: Arc<AtomicBool>) -> Result<Vec<JoinHandle<()>>> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(handles)
|
||||
}
|
||||
|
||||
@@ -207,7 +213,7 @@ fn check_addrs(args: &Args) -> Result<(Vec<BindAddr>, Vec<BindAddr>)> {
|
||||
let (ipv4_addrs, ipv6_addrs) = interface_addrs()?;
|
||||
for bind_addr in args.addrs.iter() {
|
||||
match bind_addr {
|
||||
BindAddr::Address(ip) => match &ip {
|
||||
BindAddr::IpAddr(ip) => match &ip {
|
||||
IpAddr::V4(_) => {
|
||||
if !ipv4_addrs.is_empty() {
|
||||
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());
|
||||
print_addrs.push(bind_addr.clone())
|
||||
@@ -246,10 +253,10 @@ fn interface_addrs() -> Result<(Vec<BindAddr>, Vec<BindAddr>)> {
|
||||
for iface in ifaces.into_iter() {
|
||||
let ip = iface.ip();
|
||||
if ip.is_ipv4() {
|
||||
ipv4_addrs.push(BindAddr::Address(ip))
|
||||
ipv4_addrs.push(BindAddr::IpAddr(ip))
|
||||
}
|
||||
if ip.is_ipv6() {
|
||||
ipv6_addrs.push(BindAddr::Address(ip))
|
||||
ipv6_addrs.push(BindAddr::IpAddr(ip))
|
||||
}
|
||||
}
|
||||
Ok((ipv4_addrs, ipv6_addrs))
|
||||
@@ -260,7 +267,7 @@ fn print_listening(args: &Args, print_addrs: &[BindAddr]) -> Result<String> {
|
||||
let urls = print_addrs
|
||||
.iter()
|
||||
.map(|bind_addr| match bind_addr {
|
||||
BindAddr::Address(addr) => {
|
||||
BindAddr::IpAddr(addr) => {
|
||||
let addr = match addr {
|
||||
IpAddr::V4(_) => 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)
|
||||
}
|
||||
BindAddr::Path(path) => path.display().to_string(),
|
||||
#[cfg(unix)]
|
||||
BindAddr::SocketPath(path) => path.to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ const INDEX_NAME: &str = "index.html";
|
||||
const BUF_SIZE: usize = 65536;
|
||||
const EDITABLE_TEXT_MAX_SIZE: u64 = 4194304; // 4M
|
||||
const RESUMABLE_UPLOAD_MIN_SIZE: u64 = 20971520; // 20M
|
||||
const HEALTH_CHECK_PATH: &str = "__dufs__/health";
|
||||
|
||||
pub struct Server {
|
||||
args: Args,
|
||||
@@ -171,7 +172,7 @@ impl Server {
|
||||
|
||||
if method == Method::GET
|
||||
&& self
|
||||
.handle_assets(&relative_path, headers, &mut res)
|
||||
.handle_internal(&relative_path, headers, &mut res)
|
||||
.await?
|
||||
{
|
||||
return Ok(res);
|
||||
@@ -682,7 +683,7 @@ impl Server {
|
||||
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(
|
||||
reader_stream
|
||||
.map_ok(Frame::data)
|
||||
@@ -738,7 +739,7 @@ impl Server {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_assets(
|
||||
async fn handle_internal(
|
||||
&self,
|
||||
req_path: &str,
|
||||
headers: &HeaderMap<HeaderValue>,
|
||||
@@ -789,6 +790,12 @@ impl Server {
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
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 {
|
||||
Ok(false)
|
||||
}
|
||||
@@ -899,7 +906,7 @@ impl Server {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let reader_stream = ReaderStream::new(file);
|
||||
let reader_stream = ReaderStream::with_capacity(file, BUF_SIZE);
|
||||
let stream_body = StreamBody::new(
|
||||
reader_stream
|
||||
.map_ok(Frame::data)
|
||||
@@ -1363,8 +1370,15 @@ impl Server {
|
||||
};
|
||||
let mtime = to_timestamp(&meta.modified()?);
|
||||
let size = match path_type {
|
||||
PathType::Dir | PathType::SymlinkDir => None,
|
||||
PathType::File | PathType::SymlinkFile => Some(meta.len()),
|
||||
PathType::Dir | PathType::SymlinkDir => {
|
||||
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 name = normalize_path(rel_path);
|
||||
@@ -1416,7 +1430,7 @@ struct PathItem {
|
||||
path_type: PathType,
|
||||
name: String,
|
||||
mtime: u64,
|
||||
size: Option<u64>,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
impl PathItem {
|
||||
@@ -1450,21 +1464,18 @@ impl PathItem {
|
||||
),
|
||||
PathType::File | PathType::SymlinkFile => format!(
|
||||
r#"<D:response>
|
||||
<D:href>{}</D:href>
|
||||
<D:href>{href}</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:displayname>{}</D:displayname>
|
||||
<D:displayname>{displayname}</D:displayname>
|
||||
<D:getcontentlength>{}</D:getcontentlength>
|
||||
<D:getlastmodified>{}</D:getlastmodified>
|
||||
<D:getlastmodified>{mtime}</D:getlastmodified>
|
||||
<D:resourcetype></D:resourcetype>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>"#,
|
||||
href,
|
||||
displayname,
|
||||
self.size.unwrap_or_default(),
|
||||
mtime
|
||||
self.size
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -1491,16 +1502,7 @@ impl PathItem {
|
||||
|
||||
pub fn sort_by_size(&self, other: &Self) -> Ordering {
|
||||
match self.path_type.cmp(&other.path_type) {
|
||||
Ordering::Equal => {
|
||||
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))
|
||||
}
|
||||
}
|
||||
Ordering::Equal => self.size.cmp(&other.size),
|
||||
v => v,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,17 +57,18 @@ fn invalid_auth(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const HASHED_PASSWORD_AUTH: &str = "user:$6$gQxZwKyWn/ZmWEA2$4uV7KKMnSUnET2BtWTj/9T5.Jq3h/MdkOlnIl5hdlTxDZ4MZKmJ.kl6C.NL9xnNPqC4lVHC1vuI0E5cLpTJX81@/:rw"; // user:pass
|
||||
|
||||
#[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(
|
||||
#[with(&["--auth", HASHED_PASSWORD_AUTH, "-A"])] server: TestServer,
|
||||
#[case] server: TestServer,
|
||||
#[case] user: &str,
|
||||
#[case] pass: &str,
|
||||
) -> Result<(), Error> {
|
||||
let url = format!("{}file1", server.url());
|
||||
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
|
||||
assert_eq!(resp.status(), 401);
|
||||
if let Err(err) =
|
||||
send_with_digest_auth(fetch!(b"PUT", &url).body(b"abc".to_vec()), "user", "pass")
|
||||
if let Err(err) = send_with_digest_auth(fetch!(b"PUT", &url).body(b"abc".to_vec()), user, pass)
|
||||
{
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -76,7 +77,7 @@ fn auth_hashed_password(
|
||||
}
|
||||
let resp = fetch!(b"PUT", &url)
|
||||
.body(b"abc".to_vec())
|
||||
.basic_auth("user", Some("pass"))
|
||||
.basic_auth(user, Some(pass))
|
||||
.send()?;
|
||||
assert_eq!(resp.status(), 201);
|
||||
Ok(())
|
||||
|
||||
31
tests/health.rs
Normal file
31
tests/health.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user