Compare commits

..

5 Commits

Author SHA1 Message Date
sigoden
4605701366 chore(release): version v0.12.1 2022-06-04 13:39:03 +08:00
sigoden
b7c550e09b chore(release): version v0.12.0 2022-06-04 13:21:46 +08:00
sigoden
fff8fc3ac5 chore: incorrect icon of uploaded file 2022-06-04 13:20:39 +08:00
sigoden
0616602659 feat: remove unzip uploaded feature (#11)
Use drag&drop/webdav to upload folders
2022-06-04 13:01:17 +08:00
sigoden
0a64762df4 feat: support webdav (#10) 2022-06-04 12:51:56 +08:00
7 changed files with 302 additions and 92 deletions

View File

@@ -2,6 +2,13 @@
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.12.1] - 2022-06-04
### Features
- Support webdav ([#10](https://github.com/sigoden/duf/issues/10))
- Remove unzip uploaded feature ([#11](https://github.com/sigoden/duf/issues/11))
## [0.11.0] - 2022-06-03 ## [0.11.0] - 2022-06-03
### Features ### Features

2
Cargo.lock generated
View File

@@ -286,7 +286,7 @@ dependencies = [
[[package]] [[package]]
name = "duf" name = "duf"
version = "0.11.0" version = "0.12.1"
dependencies = [ dependencies = [
"async-walkdir", "async-walkdir",
"async_zip", "async_zip",

View File

@@ -1,15 +1,15 @@
[package] [package]
name = "duf" name = "duf"
version = "0.11.0" version = "0.12.1"
edition = "2021" edition = "2021"
authors = ["sigoden <sigoden@gmail.com>"] authors = ["sigoden <sigoden@gmail.com>"]
description = "Duf is a fully functional file server." description = "Duf is a simple file server."
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
homepage = "https://github.com/sigoden/duf" homepage = "https://github.com/sigoden/duf"
repository = "https://github.com/sigoden/duf" repository = "https://github.com/sigoden/duf"
autotests = false autotests = false
categories = ["command-line-utilities", "web-programming::http-server"] categories = ["command-line-utilities", "web-programming::http-server"]
keywords = ["static", "file", "server", "http", "cli"] keywords = ["static", "file", "server", "webdav", "cli"]
[dependencies] [dependencies]
clap = { version = "3", default-features = false, features = ["std", "cargo"] } clap = { version = "3", default-features = false, features = ["std", "cargo"] }

View File

@@ -3,7 +3,7 @@
[![CI](https://github.com/sigoden/duf/actions/workflows/ci.yaml/badge.svg)](https://github.com/sigoden/duf/actions/workflows/ci.yaml) [![CI](https://github.com/sigoden/duf/actions/workflows/ci.yaml/badge.svg)](https://github.com/sigoden/duf/actions/workflows/ci.yaml)
[![Crates](https://img.shields.io/crates/v/duf.svg)](https://crates.io/crates/duf) [![Crates](https://img.shields.io/crates/v/duf.svg)](https://crates.io/crates/duf)
Duf is a fully functional file server. Duf is a simple file server. Support static serve, search, upload, delete, webdav...
![demo](https://user-images.githubusercontent.com/4012553/171526189-09afc2de-793f-4216-b3d5-31ea408d3610.png) ![demo](https://user-images.githubusercontent.com/4012553/171526189-09afc2de-793f-4216-b3d5-31ea408d3610.png)
@@ -15,9 +15,9 @@ Duf is a fully functional file server.
- Upload files and folders (Drag & Drop) - Upload files and folders (Drag & Drop)
- Delete files - Delete files
- Basic authentication - Basic authentication
- Upload zip file then unzip
- Partial responses (Parallel/Resume download) - Partial responses (Parallel/Resume download)
- Support https/tls - Support https
- Support webdav
- Easy to use with curl - Easy to use with curl
## Install ## Install
@@ -41,7 +41,7 @@ Download from [Github Releases](https://github.com/sigoden/duf/releases), unzip
## CLI ## CLI
``` ```
Duf is a fully functional file server. Duf is a simple file server.
USAGE: USAGE:
duf [OPTIONS] [path] duf [OPTIONS] [path]
@@ -127,12 +127,6 @@ Upload a file
curl --upload-file some-file http://127.0.0.1:5000/some-file curl --upload-file some-file http://127.0.0.1:5000/some-file
``` ```
Unzip zip file when unload
```
curl --upload-file some-folder.zip http://127.0.0.1:5000/some-folder.zip?unzip
```
Delete a file/folder Delete a file/folder
``` ```

View File

@@ -1,3 +1,13 @@
/**
* @typedef {object} PathItem
* @property {"Dir"|"SymlinkDir"|"File"|"SymlinkFile"} path_type
* @property {boolean} is_symlink
* @property {string} name
* @property {number} mtime
* @property {number} size
*/
/** /**
* @type Element * @type Element
*/ */
@@ -8,9 +18,21 @@ let $pathsTable, $pathsTableBody, $uploadersTable;
let baseDir; let baseDir;
class Uploader { class Uploader {
/**
* @type number
*/
idx; idx;
/**
* @type File
*/
file; file;
/**
* @type string
*/
name; name;
/**
* @type Element
*/
$uploadStatus; $uploadStatus;
static globalIdx = 0; static globalIdx = 0;
constructor(file, dirs) { constructor(file, dirs) {
@@ -22,9 +44,6 @@ class Uploader {
upload() { upload() {
const { file, idx, name } = this; const { file, idx, name } = this;
let url = getUrl(name); let url = getUrl(name);
if (file.name == baseDir + ".zip") {
url += "?unzip";
}
$uploadersTable.insertAdjacentHTML("beforeend", ` $uploadersTable.insertAdjacentHTML("beforeend", `
<tr id="upload${idx}" class="uploader"> <tr id="upload${idx}" class="uploader">
<td class="path cell-name"> <td class="path cell-name">
@@ -40,7 +59,7 @@ class Uploader {
ajax.upload.addEventListener("progress", e => this.progress(e), false); ajax.upload.addEventListener("progress", e => this.progress(e), false);
ajax.addEventListener("readystatechange", () => { ajax.addEventListener("readystatechange", () => {
if(ajax.readyState === 4) { if(ajax.readyState === 4) {
if (ajax.status == 200) { if (ajax.status >= 200 && ajax.status < 300) {
this.complete(); this.complete();
} else { } else {
this.fail(); this.fail();
@@ -67,6 +86,10 @@ class Uploader {
} }
} }
/**
* Add breadcumb
* @param {string} value
*/
function addBreadcrumb(value) { function addBreadcrumb(value) {
const $breadcrumb = document.querySelector(".breadcrumb"); const $breadcrumb = document.querySelector(".breadcrumb");
const parts = value.split("/").filter(v => !!v); const parts = value.split("/").filter(v => !!v);
@@ -89,6 +112,11 @@ function addBreadcrumb(value) {
} }
} }
/**
* Add pathitem
* @param {PathItem} file
* @param {number} index
*/
function addPath(file, index) { function addPath(file, index) {
const url = getUrl(file.name) const url = getUrl(file.name)
let actionDelete = ""; let actionDelete = "";
@@ -132,6 +160,11 @@ function addPath(file, index) {
</tr>`) </tr>`)
} }
/**
* Delete pathitem
* @param {number} index
* @returns
*/
async function deletePath(index) { async function deletePath(index) {
const file = DATA.paths[index]; const file = DATA.paths[index];
if (!file) return; if (!file) return;
@@ -142,7 +175,7 @@ async function deletePath(index) {
const res = await fetch(getUrl(file.name), { const res = await fetch(getUrl(file.name), {
method: "DELETE", method: "DELETE",
}); });
if (res.status === 200) { if (res.status >= 200 && res.status < 300) {
document.getElementById(`addPath${index}`).remove(); document.getElementById(`addPath${index}`).remove();
DATA.paths[index] = null; DATA.paths[index] = null;
if (!DATA.paths.find(v => !!v)) { if (!DATA.paths.find(v => !!v)) {

View File

@@ -134,7 +134,9 @@ impl Args {
let address = matches.value_of("address").unwrap_or_default().to_owned(); let address = matches.value_of("address").unwrap_or_default().to_owned();
let port = matches.value_of_t::<u16>("port")?; let port = matches.value_of_t::<u16>("port")?;
let path = Args::parse_path(matches.value_of_os("path").unwrap_or_default())?; let path = Args::parse_path(matches.value_of_os("path").unwrap_or_default())?;
let path_prefix = matches.value_of("path-prefix").map(|v| v.to_owned()); let path_prefix = matches
.value_of("path-prefix")
.map(|v| v.trim_matches('/').to_owned());
let cors = matches.is_present("cors"); let cors = matches.is_present("cors");
let auth = matches.value_of("auth").map(|v| v.to_owned()); let auth = matches.value_of("auth").map(|v| v.to_owned());
let no_auth_access = matches.is_present("no-auth-access"); let no_auth_access = matches.is_present("no-auth-access");

View File

@@ -1,10 +1,9 @@
use crate::{Args, BoxResult}; use crate::{Args, BoxResult};
use async_walkdir::WalkDir; use async_walkdir::WalkDir;
use async_zip::read::seek::ZipFileReader;
use async_zip::write::{EntryOptions, ZipFileWriter}; use async_zip::write::{EntryOptions, ZipFileWriter};
use async_zip::Compression; use async_zip::Compression;
use chrono::Local; use chrono::{Local, TimeZone, Utc};
use futures::stream::StreamExt; use futures::stream::StreamExt;
use futures::TryStreamExt; use futures::TryStreamExt;
use get_if_addrs::get_if_addrs; use get_if_addrs::get_if_addrs;
@@ -18,7 +17,7 @@ use hyper::header::{
WWW_AUTHENTICATE, WWW_AUTHENTICATE,
}; };
use hyper::service::{make_service_fn, service_fn}; use hyper::service::{make_service_fn, service_fn};
use hyper::{Body, Method, StatusCode}; use hyper::{Body, Method, StatusCode, Uri};
use percent_encoding::percent_decode; use percent_encoding::percent_decode;
use rustls::ServerConfig; use rustls::ServerConfig;
use serde::Serialize; use serde::Serialize;
@@ -189,8 +188,8 @@ impl InnerService {
return Ok(res); return Ok(res);
} }
match *req.method() { match req.method() {
Method::GET => { &Method::GET => {
let headers = req.headers(); let headers = req.headers();
if is_dir { if is_dir {
if render_index || render_spa { if render_index || render_spa {
@@ -212,28 +211,46 @@ impl InnerService {
status!(res, StatusCode::NOT_FOUND); status!(res, StatusCode::NOT_FOUND);
} }
} }
Method::OPTIONS => { &Method::OPTIONS => {
status!(res, StatusCode::NO_CONTENT); self.handle_method_options(&mut res);
} }
Method::PUT => { &Method::PUT => {
if !allow_upload || (!allow_delete && is_file) { if !allow_upload || (!allow_delete && is_file) {
status!(res, StatusCode::FORBIDDEN); status!(res, StatusCode::FORBIDDEN);
} else { } else {
self.handle_upload(path, req, &mut res).await?; self.handle_upload(path, req, &mut res).await?;
} }
} }
Method::DELETE => { &Method::DELETE => {
if !allow_delete { if !allow_delete {
status!(res, StatusCode::FORBIDDEN); status!(res, StatusCode::FORBIDDEN);
} else if !is_miss { } else if !is_miss {
self.handle_delete(path, is_dir).await? self.handle_delete(path, is_dir, &mut res).await?
} else { } else {
status!(res, StatusCode::NOT_FOUND); status!(res, StatusCode::NOT_FOUND);
} }
} }
method => match method.as_str() {
"PROPFIND" => {
if is_dir {
self.handle_propfind_dir(path, &mut res).await?;
} else if is_file {
self.handle_propfind_file(path, &mut res).await?;
} else {
status!(res, StatusCode::NOT_FOUND);
}
}
"MKCOL" if allow_upload && is_miss => self.handle_mkcol(path, &mut res).await?,
"COPY" if allow_upload && !is_miss => {
self.handle_copy(path, req.headers(), &mut res).await?
}
"MOVE" if allow_upload && allow_delete && !is_miss => {
self.handle_move(path, req.headers(), &mut res).await?
}
_ => { _ => {
status!(res, StatusCode::METHOD_NOT_ALLOWED); status!(res, StatusCode::METHOD_NOT_ALLOWED);
} }
},
} }
Ok(res) Ok(res)
} }
@@ -244,20 +261,7 @@ impl InnerService {
mut req: Request, mut req: Request,
res: &mut Response, res: &mut Response,
) -> BoxResult<()> { ) -> BoxResult<()> {
let ensure_parent = match path.parent() { ensure_path_parent(path).await?;
Some(parent) => match fs::metadata(parent).await {
Ok(meta) => meta.is_dir(),
Err(_) => {
fs::create_dir_all(parent).await?;
true
}
},
None => false,
};
if !ensure_parent {
status!(res, StatusCode::FORBIDDEN);
return Ok(());
}
let mut file = fs::File::create(&path).await?; let mut file = fs::File::create(&path).await?;
@@ -271,43 +275,31 @@ impl InnerService {
io::copy(&mut body_reader, &mut file).await?; io::copy(&mut body_reader, &mut file).await?;
let query = req.uri().query().unwrap_or_default(); status!(res, StatusCode::CREATED);
if query == "unzip" {
if let Err(e) = self.unzip_file(path).await {
eprintln!("Failed to unzip {}, {}", path.display(), e);
status!(res, StatusCode::BAD_REQUEST);
}
fs::remove_file(&path).await?;
}
Ok(()) Ok(())
} }
async fn handle_delete(&self, path: &Path, is_dir: bool) -> BoxResult<()> { async fn handle_delete(&self, path: &Path, is_dir: bool, res: &mut Response) -> BoxResult<()> {
match is_dir { match is_dir {
true => fs::remove_dir_all(path).await?, true => fs::remove_dir_all(path).await?,
false => fs::remove_file(path).await?, false => fs::remove_file(path).await?,
} }
status!(res, StatusCode::NO_CONTENT);
Ok(()) Ok(())
} }
async fn handle_ls_dir(&self, path: &Path, exist: bool, res: &mut Response) -> BoxResult<()> { async fn handle_ls_dir(&self, path: &Path, exist: bool, res: &mut Response) -> BoxResult<()> {
let mut paths: Vec<PathItem> = vec![]; let mut paths = vec![];
if exist { if exist {
let mut rd = match fs::read_dir(path).await { paths = match self.list_dir(path, path, false).await {
Ok(rd) => rd, Ok(paths) => paths,
Err(_) => { Err(_) => {
status!(res, StatusCode::FORBIDDEN); status!(res, StatusCode::FORBIDDEN);
return Ok(()); return Ok(());
} }
}
}; };
while let Some(entry) = rd.next_entry().await? {
let entry_path = entry.path();
if let Ok(Some(item)) = self.to_pathitem(entry_path, path.to_path_buf()).await {
paths.push(item);
}
}
}
self.send_index(path, paths, res) self.send_index(path, paths, res)
} }
@@ -461,6 +453,110 @@ impl InnerService {
Ok(()) Ok(())
} }
fn handle_method_options(&self, res: &mut Response) {
let allow_upload = self.args.allow_upload;
let allow_delete = self.args.allow_delete;
let mut methods = vec!["GET", "PROPFIND", "OPTIONS"];
if allow_upload {
methods.extend(["PUT", "COPY", "MKCOL"]);
}
if allow_delete {
methods.push("DELETE");
}
if allow_upload && allow_delete {
methods.push("COPY");
}
let value = methods.join(",").parse().unwrap();
res.headers_mut().insert("Allow", value);
res.headers_mut().insert("DAV", "1".parse().unwrap());
status!(res, StatusCode::NO_CONTENT);
}
async fn handle_propfind_dir(&self, path: &Path, res: &mut Response) -> BoxResult<()> {
let paths = match self.list_dir(path, &self.args.path, true).await {
Ok(paths) => paths,
Err(_) => {
status!(res, StatusCode::FORBIDDEN);
return Ok(());
}
};
let output = paths
.iter()
.map(|v| v.xml(self.args.path_prefix.as_ref()))
.fold(String::new(), |mut acc, v| {
acc.push_str(&v);
acc
});
res_propfind(res, &output);
Ok(())
}
async fn handle_propfind_file(&self, path: &Path, res: &mut Response) -> BoxResult<()> {
if let Some(pathitem) = self.to_pathitem(path, &self.args.path).await? {
res_propfind(res, &pathitem.xml(self.args.path_prefix.as_ref()));
} else {
status!(res, StatusCode::NOT_FOUND);
}
Ok(())
}
async fn handle_mkcol(&self, path: &Path, res: &mut Response) -> BoxResult<()> {
fs::create_dir_all(path).await?;
status!(res, StatusCode::CREATED);
Ok(())
}
async fn handle_copy(
&self,
path: &Path,
headers: &HeaderMap<HeaderValue>,
res: &mut Response,
) -> BoxResult<()> {
let dest = match self.extract_dest(headers) {
Some(dest) => dest,
None => {
status!(res, StatusCode::BAD_REQUEST);
return Ok(());
}
};
let meta = fs::symlink_metadata(path).await?;
if meta.is_dir() {
status!(res, StatusCode::BAD_REQUEST);
return Ok(());
}
ensure_path_parent(&dest).await?;
fs::copy(path, &dest).await?;
status!(res, StatusCode::NO_CONTENT);
Ok(())
}
async fn handle_move(
&self,
path: &Path,
headers: &HeaderMap<HeaderValue>,
res: &mut Response,
) -> BoxResult<()> {
let dest = match self.extract_dest(headers) {
Some(dest) => dest,
None => {
status!(res, StatusCode::BAD_REQUEST);
return Ok(());
}
};
ensure_path_parent(&dest).await?;
fs::rename(path, &dest).await?;
status!(res, StatusCode::NO_CONTENT);
Ok(())
}
fn send_index( fn send_index(
&self, &self,
path: &Path, path: &Path,
@@ -534,30 +630,10 @@ impl InnerService {
.unwrap_or_default() .unwrap_or_default()
} }
async fn unzip_file(&self, path: &Path) -> BoxResult<()> { fn extract_dest(&self, headers: &HeaderMap<HeaderValue>) -> Option<PathBuf> {
let root = path.parent().unwrap(); let dest = headers.get("Destination")?.to_str().ok()?;
let mut zip = ZipFileReader::new(File::open(&path).await?).await?; let uri: Uri = dest.parse().ok()?;
for i in 0..zip.entries().len() { self.extract_path(uri.path())
let entry = &zip.entries()[i];
let entry_name = entry.name();
let entry_path = root.join(entry_name);
if entry_name.ends_with('/') {
fs::create_dir_all(entry_path).await?;
} else {
if !self.args.allow_delete && fs::metadata(&entry_path).await.is_ok() {
continue;
}
if let Some(parent) = entry_path.parent() {
if fs::symlink_metadata(parent).await.is_err() {
fs::create_dir_all(&parent).await?;
}
}
let mut outfile = fs::File::create(&entry_path).await?;
let mut reader = zip.entry_reader(i).await?;
io::copy(&mut reader, &mut outfile).await?;
}
}
Ok(())
} }
fn extract_path(&self, path: &str) -> Option<PathBuf> { fn extract_path(&self, path: &str) -> Option<PathBuf> {
@@ -585,6 +661,26 @@ impl InnerService {
} }
} }
async fn list_dir(
&self,
entry_path: &Path,
base_path: &Path,
include_entry: bool,
) -> BoxResult<Vec<PathItem>> {
let mut paths: Vec<PathItem> = vec![];
if include_entry {
paths.push(self.to_pathitem(entry_path, base_path).await?.unwrap())
}
let mut rd = fs::read_dir(entry_path).await?;
while let Ok(Some(entry)) = rd.next_entry().await {
let entry_path = entry.path();
if let Ok(Some(item)) = self.to_pathitem(entry_path.as_path(), base_path).await {
paths.push(item);
}
}
Ok(paths)
}
async fn to_pathitem<P: AsRef<Path>>( async fn to_pathitem<P: AsRef<Path>>(
&self, &self,
path: P, path: P,
@@ -610,9 +706,15 @@ impl InnerService {
PathType::Dir | PathType::SymlinkDir => None, PathType::Dir | PathType::SymlinkDir => None,
PathType::File | PathType::SymlinkFile => Some(meta.len()), PathType::File | PathType::SymlinkFile => Some(meta.len()),
}; };
let base_name = rel_path
.file_name()
.and_then(|v| v.to_str())
.unwrap_or("/")
.to_owned();
let name = normalize_path(rel_path); let name = normalize_path(rel_path);
Ok(Some(PathItem { Ok(Some(PathItem {
path_type, path_type,
base_name,
name, name,
mtime, mtime,
size, size,
@@ -620,7 +722,7 @@ impl InnerService {
} }
} }
#[derive(Debug, Serialize, Eq, PartialEq, Ord, PartialOrd)] #[derive(Debug, Serialize)]
struct IndexData { struct IndexData {
breadcrumb: String, breadcrumb: String,
paths: Vec<PathItem>, paths: Vec<PathItem>,
@@ -631,11 +733,63 @@ struct IndexData {
#[derive(Debug, Serialize, Eq, PartialEq, Ord, PartialOrd)] #[derive(Debug, Serialize, Eq, PartialEq, Ord, PartialOrd)]
struct PathItem { struct PathItem {
path_type: PathType, path_type: PathType,
base_name: String,
name: String, name: String,
mtime: u64, mtime: u64,
size: Option<u64>, size: Option<u64>,
} }
impl PathItem {
pub fn xml(&self, prefix: Option<&String>) -> String {
let prefix = match prefix {
Some(value) => format!("/{}/", value),
None => "/".to_owned(),
};
let mtime = Utc.timestamp_millis(self.mtime as i64).to_rfc2822();
match self.path_type {
PathType::Dir | PathType::SymlinkDir => format!(
r#"<D:response>
<D:href>{}{}</D:href>
<D:propstat>
<D:prop>
<D:displayname>{}</D:displayname>
<D:getlastmodified>{}</D:getlastmodified>
<D:resourcetype><D:collection/></D:resourcetype>
<D:lockdiscovery/>
<D:supportedlock>
</D:supportedlock>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>"#,
prefix, self.name, self.base_name, mtime
),
PathType::File | PathType::SymlinkFile => format!(
r#"<D:response>
<D:href>{}{}</D:href>
<D:propstat>
<D:prop>
<D:displayname>{}</D:displayname>
<D:getcontentlength>{}</D:getcontentlength>
<D:getlastmodified>{}</D:getlastmodified>
<D:resourcetype></D:resourcetype>
<D:lockdiscovery/>
<D:supportedlock>
</D:supportedlock>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>"#,
prefix,
self.name,
self.base_name,
self.size.unwrap_or_default(),
mtime
),
}
}
}
#[derive(Debug, Serialize, Eq, PartialEq, Ord, PartialOrd)] #[derive(Debug, Serialize, Eq, PartialEq, Ord, PartialOrd)]
enum PathType { enum PathType {
Dir, Dir,
@@ -659,6 +813,15 @@ fn normalize_path<P: AsRef<Path>>(path: P) -> String {
} }
} }
async fn ensure_path_parent(path: &Path) -> BoxResult<()> {
if let Some(parent) = path.parent() {
if fs::symlink_metadata(parent).await.is_err() {
fs::create_dir_all(&parent).await?;
}
}
Ok(())
}
fn add_cors(res: &mut Response) { fn add_cors(res: &mut Response) {
res.headers_mut() res.headers_mut()
.typed_insert(AccessControlAllowOrigin::ANY); .typed_insert(AccessControlAllowOrigin::ANY);
@@ -669,6 +832,17 @@ fn add_cors(res: &mut Response) {
); );
} }
fn res_propfind(res: &mut Response, content: &str) {
*res.status_mut() = StatusCode::MULTI_STATUS;
*res.body_mut() = Body::from(format!(
r#"<?xml version="1.0" encoding="utf-8" ?>
<D:multistatus xmlns:D="DAV:">
{}
</D:multistatus>"#,
content,
));
}
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) -> BoxResult<()> {
let mut writer = ZipFileWriter::new(writer); let mut writer = ZipFileWriter::new(writer);
let mut walkdir = WalkDir::new(dir); let mut walkdir = WalkDir::new(dir);