Compare commits

..

27 Commits

Author SHA1 Message Date
sigoden
c1c6dbc356 chore(release): version v0.21.0 2022-06-23 19:34:38 +08:00
sigoden
e29cf4c752 refactor: split css/js from index.html (#68) 2022-06-21 23:01:00 +08:00
sigoden
7f062b6705 feat: use custom logger with timestamp in rfc3339 (#67) 2022-06-21 21:19:51 +08:00
sigoden
ea8b9e9cce fix: escape name contains html escape code (#65) 2022-06-21 19:23:34 +08:00
sigoden
d2270be8fb chore: update changelog 2022-06-21 07:56:24 +08:00
sigoden
2d0dfed456 chore(release): version v0.20.0 2022-06-21 07:52:45 +08:00
sigoden
4058a2db72 feat: add option --allow-search (#62) 2022-06-21 07:23:20 +08:00
sigoden
069cb64889 fix: decodeURI searching string (#61) 2022-06-20 21:51:41 +08:00
sigoden
c85ea06785 chore: update cli help message and reamde 2022-06-20 19:40:09 +08:00
sigoden
68139c6263 chore: little improves 2022-06-20 15:11:39 +08:00
Joe Koop
deb6365a28 feat: added basic auth (#60)
* some small css fixes and changes

* added basic auth
https://stackoverflow.com/a/9534652/3642588

* most tests are passing

* fixed all the tests

* maybe now CI will pass

* implemented sigoden's suggestions

* test basic auth

* fixed some little things
2022-06-20 11:25:09 +08:00
sigoden
0d3acb8ae6 chore(release): version v0.19.0 2022-06-19 23:09:43 +08:00
sigoden
a67da8bdd3 feat: rename to dufs (#59)
close #50

BREAKING CHANGE: rename duf to dufs
2022-06-19 22:53:51 +08:00
sigoden
3260b52c47 chore: fix breadcrumb 2022-06-19 22:22:49 +08:00
sigoden
7194ebf248 chore: adjust ui 2022-06-19 22:16:43 +08:00
Joe Koop
b1b0fdd4db feat: reactive webpage (#51) 2022-06-19 22:04:59 +08:00
sigoden
db71f75236 feat: ui hidden root dirname (#58)
close #56
2022-06-19 21:23:19 +08:00
sigoden
e66951fd11 refactor: rename --cors to --enable-cors (#57)
BREAKING CHANGE: `--cors` rename to `--enable-cors`
2022-06-19 17:27:09 +08:00
sigoden
051ff8da2d chore: update readme 2022-06-19 15:30:42 +08:00
sigoden
c3ac2a21c9 feat: serve single file (#54)
close #53
2022-06-19 14:23:10 +08:00
sigoden
9c2e9d1503 feat: path level access control (#52)
BREAKING CHANGE: `--auth` is changed, `--no-auth-access` is removed
2022-06-19 11:26:03 +08:00
sigoden
9384cc8587 chore(release): version v0.18.0 2022-06-18 08:05:18 +08:00
sigoden
df48021757 chore: send not found text when 404 2022-06-18 08:03:48 +08:00
sigoden
af866aaaf4 chore: optimize --render-try-index 2022-06-17 19:05:25 +08:00
sigoden
68d238d34d feat: add slash to end of dir href 2022-06-17 19:01:17 +08:00
sigoden
a10150f2f8 chore: update readme 2022-06-17 10:59:19 +08:00
sigoden
5b11bb75dd feat: add option --render-try-index (#47)
close #46
2022-06-17 08:41:01 +08:00
26 changed files with 1021 additions and 467 deletions

View File

@@ -2,67 +2,113 @@
All notable changes to this project will be documented in this file.
## [0.21.0] - 2022-06-21
### Bug Fixes
- Escape name contains html escape code ([#65](https://github.com/sigoden/dufs/issues/65))
### Features
- Use custom logger with timestamp in rfc3339 ([#67](https://github.com/sigoden/dufs/issues/67))
### Refactor
- Split css/js from index.html ([#68](https://github.com/sigoden/dufs/issues/68))
## [0.20.0] - 2022-06-20
### Bug Fixes
- DecodeURI searching string ([#61](https://github.com/sigoden/dufs/issues/61))
### Features
- Added basic auth ([#60](https://github.com/sigoden/dufs/issues/60))
- Add option --allow-search ([#62](https://github.com/sigoden/dufs/issues/62))
## [0.19.0] - 2022-06-19
### Features
- [**breaking**] Path level access control ([#52](https://github.com/sigoden/dufs/issues/52))
- Serve single file ([#54](https://github.com/sigoden/dufs/issues/54))
- Ui hidden root dirname ([#58](https://github.com/sigoden/dufs/issues/58))
- Reactive webpage ([#51](https://github.com/sigoden/dufs/issues/51))
- [**breaking**] Rename to dufs ([#59](https://github.com/sigoden/dufs/issues/59))
### Refactor
- [**breaking**] Rename --cors to --enable-cors ([#57](https://github.com/sigoden/dufs/issues/57))
## [0.18.0] - 2022-06-18
### Features
- Add option --render-try-index ([#47](https://github.com/sigoden/dufs/issues/47))
- Add slash to end of dir href
## [0.17.1] - 2022-06-16
### Bug Fixes
- Range request ([#44](https://github.com/sigoden/duf/issues/44))
- Range request ([#44](https://github.com/sigoden/dufs/issues/44))
## [0.17.0] - 2022-06-15
### Bug Fixes
- Webdav propfind dir with slash ([#42](https://github.com/sigoden/duf/issues/42))
- Webdav propfind dir with slash ([#42](https://github.com/sigoden/dufs/issues/42))
### Features
- Listen both ipv4 and ipv6 by default ([#40](https://github.com/sigoden/duf/issues/40))
- Listen both ipv4 and ipv6 by default ([#40](https://github.com/sigoden/dufs/issues/40))
### Refactor
- Trival changes ([#41](https://github.com/sigoden/duf/issues/41))
- Trival changes ([#41](https://github.com/sigoden/dufs/issues/41))
## [0.16.0] - 2022-06-12
### Features
- Implement head method ([#33](https://github.com/sigoden/duf/issues/33))
- Display upload speed and time left ([#34](https://github.com/sigoden/duf/issues/34))
- Support tls-key in pkcs#8 format ([#35](https://github.com/sigoden/duf/issues/35))
- Implement head method ([#33](https://github.com/sigoden/dufs/issues/33))
- Display upload speed and time left ([#34](https://github.com/sigoden/dufs/issues/34))
- Support tls-key in pkcs#8 format ([#35](https://github.com/sigoden/dufs/issues/35))
- Options method return status 200
### Testing
- Add integration tests ([#36](https://github.com/sigoden/duf/issues/36))
- Add integration tests ([#36](https://github.com/sigoden/dufs/issues/36))
## [0.15.1] - 2022-06-11
### Bug Fixes
- Cannot upload ([#32](https://github.com/sigoden/duf/issues/32))
- Cannot upload ([#32](https://github.com/sigoden/dufs/issues/32))
## [0.15.0] - 2022-06-10
### Bug Fixes
- Encode webdav href as uri ([#28](https://github.com/sigoden/duf/issues/28))
- Encode webdav href as uri ([#28](https://github.com/sigoden/dufs/issues/28))
- Query dir param
### Features
- Add basic dark theme ([#29](https://github.com/sigoden/duf/issues/29))
- Add empty state placeholder to page([#30](https://github.com/sigoden/duf/issues/30))
- Add basic dark theme ([#29](https://github.com/sigoden/dufs/issues/29))
- Add empty state placeholder to page([#30](https://github.com/sigoden/dufs/issues/30))
## [0.14.0] - 2022-06-07
### Bug Fixes
- Send index page with content-type ([#26](https://github.com/sigoden/duf/issues/26))
- Send index page with content-type ([#26](https://github.com/sigoden/dufs/issues/26))
### Features
- Support ipv6 ([#25](https://github.com/sigoden/duf/issues/25))
- Add favicon ([#27](https://github.com/sigoden/duf/issues/27))
- Support ipv6 ([#25](https://github.com/sigoden/dufs/issues/25))
- Add favicon ([#27](https://github.com/sigoden/dufs/issues/27))
## [0.13.2] - 2022-06-06
@@ -75,11 +121,11 @@ All notable changes to this project will be documented in this file.
### Bug Fixes
- Escape filename ([#21](https://github.com/sigoden/duf/issues/21))
- Escape filename ([#21](https://github.com/sigoden/dufs/issues/21))
### Refactor
- Use logger ([#22](https://github.com/sigoden/duf/issues/22))
- Use logger ([#22](https://github.com/sigoden/dufs/issues/22))
## [0.13.0] - 2022-06-05
@@ -89,16 +135,16 @@ All notable changes to this project will be documented in this file.
### Features
- Implement more webdav methods ([#13](https://github.com/sigoden/duf/issues/13))
- Use digest auth ([#14](https://github.com/sigoden/duf/issues/14))
- Add webdav proppatch handler ([#18](https://github.com/sigoden/duf/issues/18))
- Implement more webdav methods ([#13](https://github.com/sigoden/dufs/issues/13))
- Use digest auth ([#14](https://github.com/sigoden/dufs/issues/14))
- Add webdav proppatch handler ([#18](https://github.com/sigoden/dufs/issues/18))
## [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))
- Support webdav ([#10](https://github.com/sigoden/dufs/issues/10))
- Remove unzip uploaded feature ([#11](https://github.com/sigoden/dufs/issues/11))
## [0.11.0] - 2022-06-03

35
Cargo.lock generated
View File

@@ -423,6 +423,7 @@ dependencies = [
"bitflags",
"clap_lex",
"indexmap",
"terminal_size",
"textwrap",
]
@@ -570,8 +571,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10"
[[package]]
name = "duf"
version = "0.17.1"
name = "dufs"
version = "0.21.0"
dependencies = [
"assert_cmd",
"assert_fs",
@@ -582,7 +583,6 @@ dependencies = [
"chrono",
"clap",
"diqwest",
"env_logger",
"futures",
"get_if_addrs",
"headers",
@@ -628,16 +628,6 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "env_logger"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b2cf0344971ee6c64c31be0d530793fba457d322dfec2810c453d0ef228f9c3"
dependencies = [
"humantime",
"log",
]
[[package]]
name = "event-listener"
version = "2.5.2"
@@ -1032,12 +1022,6 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421"
[[package]]
name = "humantime"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
[[package]]
name = "hyper"
version = "0.14.19"
@@ -2146,6 +2130,16 @@ dependencies = [
"utf-8",
]
[[package]]
name = "terminal_size"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df"
dependencies = [
"libc",
"winapi 0.3.9",
]
[[package]]
name = "termtree"
version = "0.2.4"
@@ -2157,6 +2151,9 @@ name = "textwrap"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb"
dependencies = [
"terminal_size",
]
[[package]]
name = "thiserror"

View File

@@ -1,17 +1,17 @@
[package]
name = "duf"
version = "0.17.1"
name = "dufs"
version = "0.21.0"
edition = "2021"
authors = ["sigoden <sigoden@gmail.com>"]
description = "Duf is a simple file server."
description = "Dufs is a distinctive utility file server"
license = "MIT OR Apache-2.0"
homepage = "https://github.com/sigoden/duf"
repository = "https://github.com/sigoden/duf"
homepage = "https://github.com/sigoden/dufs"
repository = "https://github.com/sigoden/dufs"
categories = ["command-line-utilities", "web-programming::http-server"]
keywords = ["static", "file", "server", "webdav", "cli"]
[dependencies]
clap = { version = "3", default-features = false, features = ["std"] }
clap = { version = "3", default-features = false, features = ["std", "wrap_help"] }
chrono = "0.4"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util", "signal"]}
tokio-rustls = "0.23"
@@ -34,7 +34,6 @@ lazy_static = "1.4"
uuid = { version = "1.1", features = ["v4", "fast-rng"] }
urlencoding = "2.1"
xml-rs = "0.8"
env_logger = { version = "0.9", default-features = false, features = ["humantime"] }
log = "0.4"
socket2 = "0.4"
async-stream = "0.3"

View File

@@ -6,5 +6,5 @@ COPY . .
RUN cargo build --target x86_64-unknown-linux-musl --release
FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/duf /bin/
ENTRYPOINT ["/bin/duf"]
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/dufs /bin/
ENTRYPOINT ["/bin/dufs"]

145
README.md
View File

@@ -1,11 +1,11 @@
# Duf
# Dufs (Old Name: Duf)
[![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)
[![CI](https://github.com/sigoden/dufs/actions/workflows/ci.yaml/badge.svg)](https://github.com/sigoden/dufs/actions/workflows/ci.yaml)
[![Crates](https://img.shields.io/crates/v/dufs.svg)](https://crates.io/crates/dufs)
Duf is a simple file server. Support static serve, search, upload, webdav...
Dufs is a distinctive utility file server that supports static serving, uploading, searching, accessing control, webdav...
![demo](https://user-images.githubusercontent.com/4012553/171526189-09afc2de-793f-4216-b3d5-31ea408d3610.png)
![demo](https://user-images.githubusercontent.com/4012553/174486522-7af350e6-0195-4f4a-8480-d9464fc6452f.png)
## Features
@@ -14,7 +14,7 @@ Duf is a simple file server. Support static serve, search, upload, webdav...
- Upload files and folders (Drag & Drop)
- Search files
- Partial responses (Parallel/Resume download)
- Authentication
- Path level access control
- Support https
- Support webdav
- Easy to use with curl
@@ -24,116 +24,165 @@ Duf is a simple file server. Support static serve, search, upload, webdav...
### With cargo
```
cargo install duf
cargo install dufs
```
### With docker
```
docker run -v /tmp:/tmp -p 5000:5000 --rm -it docker.io/sigoden/duf /tmp
docker run -v `pwd`:/data -p 5000:5000 --rm -it sigoden/dufs /data
```
### Binaries on macOS, Linux, Windows
Download from [Github Releases](https://github.com/sigoden/duf/releases), unzip and add duf to your $PATH.
Download from [Github Releases](https://github.com/sigoden/dufs/releases), unzip and add dufs to your $PATH.
## CLI
```
Duf is a simple file server. - https://github.com/sigoden/duf
Dufs is a distinctive utility file server - https://github.com/sigoden/dufs
USAGE:
duf [OPTIONS] [--] [path]
dufs [OPTIONS] [--] [path]
ARGS:
<path> Path to a root directory for serving files [default: .]
<path> Specific path to serve [default: .]
OPTIONS:
-a, --auth <user:pass> Use HTTP authentication
--no-auth-access Not required auth when access static files
-A, --allow-all Allow all operations
--allow-delete Allow delete files/folders
--allow-symlink Allow symlink to files/folders outside root directory
--allow-upload Allow upload files/folders
-b, --bind <address>... Specify bind address
--cors Enable CORS, sets `Access-Control-Allow-Origin: *`
-h, --help Print help information
-p, --port <port> Specify port to listen on [default: 5000]
--path-prefix <path> Specify an url path prefix
--render-index Render index.html when requesting a directory
--render-spa Render for 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
-V, --version Print version information
-b, --bind <addr>... Specify bind address
-p, --port <port> Specify port to listen on [default: 5000]
--path-prefix <path> Specify an path prefix
-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
--allow-upload Allow upload files/folders
--allow-delete Allow delete files/folders
--allow-search Allow search files/folders
--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-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
-h, --help Print help information
-V, --version Print version information
```
## Examples
You can run this command to start serving your current working directory on 127.0.0.1:5000 by default.
Serve current working directory
```
duf
dufs
```
...or specify which folder you want to serve.
Explicitly allow all operations including upload/delete
```
duf folder_name
```
Allow all operations such as upload, delete
```sh
duf --allow-all
dufs -A
```
Only allow upload operation
```
duf --allow-upload
dufs --allow-upload
```
Serve a single page application (SPA)
Serve a directory
```
duf --render-spa
dufs Downloads
```
Serve a single file
```
dufs linux-distro.iso
```
Serve index.html when requesting a directory
```
dufs --render-index
```
Serve SPA(Single Page Application)
```
dufs --render-spa
```
Require username/password
```
dufs -a /@admin:123
```
Listen on a specific port
```
dufs -p 80
```
Use https
```
duf --tls-cert my.crt --tls-key my.key
dufs --tls-cert my.crt --tls-key my.key
```
## API
Download a file
```
curl http://127.0.0.1:5000/some-file
curl http://127.0.0.1:5000/path-to-file
```
Download a folder as zip file
```
curl -o some-folder.zip http://127.0.0.1:5000/some-folder?zip
curl -o path-to-folder.zip http://127.0.0.1:5000/path-to-folder?zip
```
Upload a file
```
curl --upload-file some-file http://127.0.0.1:5000/some-file
curl --upload-file path-to-file http://127.0.0.1:5000/path-to-file
```
Delete a file/folder
```
curl -X DELETE http://127.0.0.1:5000/some-file
curl -X DELETE http://127.0.0.1:5000/path-to-file
```
## Access Control
Dufs supports path level access control. You can control who can do what on which path with `--auth`/`-a`.
```
dufs -a <path>@<readwrite>[@<readonly>]
```
- `<path>`: Path to protected
- `<readwrite>`: Account with readwrite permission, required
- `<readonly>`: Account with readonly permission, optional
> `<readonly>` can be `*` means `<path>` is public, everyone can access/download it.
For example:
```
dufs -a /@admin:pass@* -a /ui@designer:pass1 -A
```
- All files/folders are public to access/download.
- Account `admin:pass` can upload/delete/download any files/folders.
- Account `designer:pass1` can upload/delete/download any files/folders in the `ui` folder.
## License
Copyright (c) 2022 duf-developers.
Copyright (c) 2022 dufs-developers.
duf is made available under the terms of either the MIT License or the Apache License 2.0, at your option.
dufs is made available under the terms of either the MIT License or the Apache License 2.0, at your option.
See the LICENSE-APACHE and LICENSE-MIT files for license details.

View File

@@ -1,15 +1,16 @@
html {
font-family: -apple-system,BlinkMacSystemFont,Helvetica,Arial,sans-serif;
font-family: -apple-system,BlinkMacSystemFont,Roboto,Helvetica,Arial,sans-serif;
line-height: 1.5;
color: #24292e;
}
body {
width: 700px;
/* prevent premature breadcrumb wrapping on mobile */
min-width: 500px;
}
.hidden {
display: none;
display: none !important;
}
.head {
@@ -21,6 +22,7 @@ body {
.breadcrumb {
font-size: 1.25em;
padding-right: 0.6em;
}
.breadcrumb > a {
@@ -45,12 +47,16 @@ body {
.breadcrumb svg {
height: 100%;
fill: rgba(3,47,98,0.5);
padding-right: 0.5em;
padding-left: 0.5em;
}
.toolbox {
display: flex;
margin-right: 10px;
}
.toolbox > div {
/* vertically align with breadcrumb text */
height: 1.1rem;
}
.searchbar {
@@ -62,7 +68,7 @@ body {
transition: all .15s;
border: 1px #ddd solid;
border-radius: 15px;
margin: 0 0 2px 10px;
margin-bottom: 2px;
}
.searchbar #search {
@@ -120,11 +126,6 @@ body {
white-space: nowrap;
}
.uploaders-table .cell-name,
.paths-table .cell-name {
width: 500px;
}
.uploaders-table .cell-status {
width: 80px;
padding-left: 0.6em;
@@ -147,15 +148,14 @@ body {
padding-left: 0.6em;
}
.path svg {
height: 100%;
fill: rgba(3,47,98,0.5);
padding-right: 0.5em;
vertical-align: text-top;
}
.path {
display: flex;
list-style: none;
}
@@ -166,6 +166,8 @@ body {
overflow: hidden;
display: block;
text-decoration: none;
max-width: calc(100vw - 375px);
min-width: 200px;
}
.path a:hover {
@@ -184,6 +186,12 @@ body {
padding-right: 1em;
}
@media (min-width: 768px) {
.path a {
min-width: 400px;
}
}
/* dark theme */
@media (prefers-color-scheme: dark) {
body {
@@ -202,8 +210,9 @@ body {
}
svg,
.path svg {
fill: #d0e6ff;
.path svg,
.breadcrumb svg {
fill: #fff;
}
.searchbar {

View File

@@ -22,7 +22,7 @@
<input type="file" id="file" name="file" multiple>
</div>
</div>
<form class="searchbar">
<form class="searchbar hidden">
<div class="icon">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/></svg>
</div>
@@ -35,16 +35,16 @@
<table class="uploaders-table hidden">
<thead>
<tr>
<th class="cell-name">Name</th>
<th class="cell-status">Speed - Progress - Time Left</th>
<th class="cell-name" colspan="2">Name</th>
<th class="cell-status">Progress</th>
</tr>
</thead>
</table>
<table class="paths-table hidden">
<thead>
<tr>
<th class="cell-name">Name</th>
<th class="cell-mtime">Date modify</th>
<th class="cell-name" colspan="2">Name</th>
<th class="cell-mtime">Last modified</th>
<th class="cell-size">Size</th>
<th class="cell-actions">Actions</th>
</tr>

View File

@@ -1,7 +1,6 @@
/**
* @typedef {object} PathItem
* @property {"Dir"|"SymlinkDir"|"File"|"SymlinkFile"} path_type
* @property {boolean} is_symlink
* @property {string} name
* @property {number} mtime
* @property {number} size
@@ -30,10 +29,6 @@ let $uploadersTable;
* @type Element
*/
let $emptyFolder;
/**
* @type string
*/
let baseDir;
class Uploader {
/**
@@ -69,12 +64,16 @@ class Uploader {
upload() {
const { file, idx, name } = this;
let url = getUrl(name);
const url = getUrl(name);
const encodedUrl = encodedStr(url);
const encodedName = encodedStr(name);
$uploadersTable.insertAdjacentHTML("beforeend", `
<tr id="upload${idx}" class="uploader">
<td class="path cell-icon">
${getSvg(file.path_type)}
</td>
<td class="path cell-name">
<div>${getSvg("File")}</div>
<a href="${url}">${name}</a>
<a href="${encodedUrl}">${encodedName}</a>
</td>
<td class="cell-status upload-status" id="uploadStatus${idx}"></td>
</tr>`);
@@ -123,27 +122,39 @@ class Uploader {
/**
* Add breadcrumb
* @param {string} value
* @param {string} href
* @param {string} uri_prefix
*/
function addBreadcrumb(value) {
function addBreadcrumb(href, uri_prefix) {
const $breadcrumb = document.querySelector(".breadcrumb");
const parts = value.split("/").filter(v => !!v);
let parts = [];
if (href === "/") {
parts = [""];
} else {
parts = href.split("/");
}
const len = parts.length;
let path = "";
let path = uri_prefix;
for (let i = 0; i < len; i++) {
const name = parts[i];
if (i > 0) {
path += "/" + name;
if (!path.endsWith("/")) {
path += "/";
}
path += encodeURI(name);
}
if (i === len - 1) {
$breadcrumb.insertAdjacentHTML("beforeend", `<b>${name}</b>`);
baseDir = name;
} else if (i === 0) {
$breadcrumb.insertAdjacentHTML("beforeend", `<a href="/"><b>${name}</b></a>`);
const encodedPath = encodedStr(path);
const encodedName = encodedStr(name);
if (i === 0) {
$breadcrumb.insertAdjacentHTML("beforeend", `<a href="${encodedPath}"><svg width="16" height="16" viewBox="0 0 16 16"><path d="M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5z"/></svg></a>`);
} else if (i === len - 1) {
$breadcrumb.insertAdjacentHTML("beforeend", `<b>${encodedName}</b>`);
} else {
$breadcrumb.insertAdjacentHTML("beforeend", `<a href="${encodeURI(path)}">${name}</a>`);
$breadcrumb.insertAdjacentHTML("beforeend", `<a href="${encodedPath}">${encodedName}</a>`);
}
if (i !== len - 1) {
$breadcrumb.insertAdjacentHTML("beforeend", `<span class="separator">/</span>`);
}
$breadcrumb.insertAdjacentHTML("beforeend", `<span class="separator">/</span>`);
}
}
@@ -153,27 +164,31 @@ function addBreadcrumb(value) {
* @param {number} index
*/
function addPath(file, index) {
const url = getUrl(file.name)
const encodedName = encodedStr(file.name);
let url = getUrl(file.name)
let encodedUrl = encodedStr(url);
let actionDelete = "";
let actionDownload = "";
if (file.path_type.endsWith("Dir")) {
url += "/";
encodedUrl += "/";
actionDownload = `
<div class="action-btn">
<a href="${url}?zip" title="Download folder as a .zip file">
<a href="${encodedUrl}?zip" title="Download folder as a .zip file">
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg>
</a>
</div>`;
} else {
actionDownload = `
<div class="action-btn" >
<a href="${url}" title="Download file" download>
<a href="${encodedUrl}" title="Download file" download>
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg>
</a>
</div>`;
}
if (DATA.allow_delete) {
actionDelete = `
<div onclick="deletePath(${index})" class="action-btn" id="deleteBtn${index}" title="Delete ${file.name}">
<div onclick="deletePath(${index})" class="action-btn" id="deleteBtn${index}" title="Delete ${encodedName}">
<svg width="16" height="16" fill="currentColor"viewBox="0 0 16 16"><path d="M6.854 7.146a.5.5 0 1 0-.708.708L7.293 9l-1.147 1.146a.5.5 0 0 0 .708.708L8 9.707l1.146 1.147a.5.5 0 0 0 .708-.708L8.707 9l1.147-1.146a.5.5 0 0 0-.708-.708L8 8.293 6.854 7.146z"/><path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5v2z"/></svg>
</div>`;
}
@@ -185,9 +200,11 @@ function addPath(file, index) {
$pathsTableBody.insertAdjacentHTML("beforeend", `
<tr id="addPath${index}">
<td class="path cell-icon">
${getSvg(file.path_type)}
</td>
<td class="path cell-name">
<div>${getSvg(file.path_type)}</div>
<a href="${url}" title="${file.name}">${file.name}</a>
<a href="${encodedUrl}">${encodedName}</a>
</td>
<td class="cell-mtime">${formatMtime(file.mtime)}</td>
<td class="cell-size">${formatSize(file.size).join(" ")}</td>
@@ -292,7 +309,7 @@ function formatMtime(mtime) {
const day = padZero(date.getDate(), 2);
const hours = padZero(date.getHours(), 2);
const minutes = padZero(date.getMinutes(), 2);
return `${year}/${month}/${day} ${hours}:${minutes}`;
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
function padZero(value, size) {
@@ -323,17 +340,28 @@ function formatPercent(precent) {
}
}
function encodedStr(rawStr) {
return rawStr.replace(/[\u00A0-\u9999<>\&]/g, function(i) {
return '&#'+i.charCodeAt(0)+';';
});
}
function ready() {
document.title = `Index of ${DATA.href} - Dufs`;
$pathsTable = document.querySelector(".paths-table")
$pathsTableBody = document.querySelector(".paths-table tbody");
$uploadersTable = document.querySelector(".uploaders-table");
$emptyFolder = document.querySelector(".empty-folder");
if (params.q) {
document.getElementById('search').value = params.q;
if (DATA.allow_search) {
document.querySelector(".searchbar").classList.remove("hidden");
if (params.q) {
document.getElementById('search').value = params.q;
}
}
addBreadcrumb(DATA.breadcrumb);
addBreadcrumb(DATA.href, DATA.uri_prefix);
if (Array.isArray(DATA.paths)) {
const len = DATA.paths.length;
if (len > 0) {

View File

@@ -1,10 +1,11 @@
use clap::{Arg, ArgMatches, Command};
use clap::{AppSettings, Arg, ArgMatches, Command};
use rustls::{Certificate, PrivateKey};
use std::env;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use crate::auth::parse_auth;
use crate::auth::AccessControl;
use crate::auth::AuthMethod;
use crate::tls::{load_certs, load_private_key};
use crate::BoxResult;
@@ -17,14 +18,15 @@ fn app() -> Command<'static> {
" - ",
env!("CARGO_PKG_REPOSITORY")
))
.global_setting(AppSettings::DeriveDisplayOrder)
.arg(
Arg::new("address")
Arg::new("bind")
.short('b')
.long("bind")
.help("Specify bind address")
.multiple_values(true)
.multiple_occurrences(true)
.value_name("address"),
.value_name("addr"),
)
.arg(
Arg::new("port")
@@ -38,13 +40,30 @@ fn app() -> Command<'static> {
Arg::new("path")
.default_value(".")
.allow_invalid_utf8(true)
.help("Path to a root directory for serving files"),
.help("Specific path to serve"),
)
.arg(
Arg::new("path-prefix")
.long("path-prefix")
.value_name("path")
.help("Specify an url path prefix"),
.help("Specify an path prefix"),
)
.arg(
Arg::new("auth")
.short('a')
.long("auth")
.help("Add auth for path")
.multiple_values(true)
.multiple_occurrences(true)
.value_name("rule"),
)
.arg(
Arg::new("auth-method")
.long("auth-method")
.help("Select auth method")
.possible_values(["basic", "digest"])
.default_value("digest")
.value_name("value"),
)
.arg(
Arg::new("allow-all")
@@ -62,39 +81,35 @@ fn app() -> Command<'static> {
.long("allow-delete")
.help("Allow delete files/folders"),
)
.arg(
Arg::new("allow-search")
.long("allow-search")
.help("Allow search files/folders"),
)
.arg(
Arg::new("allow-symlink")
.long("allow-symlink")
.help("Allow symlink to files/folders outside root directory"),
)
.arg(
Arg::new("enable-cors")
.long("enable-cors")
.help("Enable CORS, sets `Access-Control-Allow-Origin: *`"),
)
.arg(
Arg::new("render-index")
.long("render-index")
.help("Render index.html when requesting a directory"),
.help("Serve index.html when requesting a directory, returns 404 if not found index.html"),
)
.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"),
)
.arg(
Arg::new("render-spa")
.long("render-spa")
.help("Render for single-page application"),
)
.arg(
Arg::new("auth")
.short('a')
.display_order(1)
.long("auth")
.help("Use HTTP authentication")
.value_name("user:pass"),
)
.arg(
Arg::new("no-auth-access")
.display_order(1)
.long("no-auth-access")
.help("Not required auth when access static files"),
)
.arg(
Arg::new("cors")
.long("cors")
.help("Enable CORS, sets `Access-Control-Allow-Origin: *`"),
.help("Serve SPA(Single Page Application)"),
)
.arg(
Arg::new("tls-cert")
@@ -114,21 +129,24 @@ pub fn matches() -> ArgMatches {
app().get_matches()
}
#[derive(Debug, Clone, Eq, PartialEq)]
#[derive(Debug)]
pub struct Args {
pub addrs: Vec<IpAddr>,
pub port: u16,
pub path: PathBuf,
pub path_is_file: bool,
pub path_prefix: String,
pub uri_prefix: String,
pub auth: Option<(String, String)>,
pub no_auth_access: bool,
pub auth_method: AuthMethod,
pub auth: AccessControl,
pub allow_upload: bool,
pub allow_delete: bool,
pub allow_search: bool,
pub allow_symlink: bool,
pub render_index: bool,
pub render_spa: bool,
pub cors: bool,
pub render_try_index: bool,
pub enable_cors: bool,
pub tls: Option<(Vec<Certificate>, PrivateKey)>,
}
@@ -140,11 +158,12 @@ impl Args {
pub fn parse(matches: ArgMatches) -> BoxResult<Args> {
let port = matches.value_of_t::<u16>("port")?;
let addrs = matches
.values_of("address")
.values_of("bind")
.map(|v| v.collect())
.unwrap_or_else(|| vec!["0.0.0.0", "::"]);
let addrs: Vec<IpAddr> = Args::parse_addrs(&addrs)?;
let path = Args::parse_path(matches.value_of_os("path").unwrap_or_default())?;
let path_is_file = path.metadata()?.is_file();
let path_prefix = matches
.value_of("path-prefix")
.map(|v| v.trim_matches('/').to_owned())
@@ -154,16 +173,22 @@ impl Args {
} else {
format!("/{}/", &path_prefix)
};
let cors = matches.is_present("cors");
let auth = match matches.value_of("auth") {
Some(auth) => Some(parse_auth(auth)?),
None => None,
let enable_cors = matches.is_present("enable-cors");
let auth: Vec<&str> = matches
.values_of("auth")
.map(|v| v.collect())
.unwrap_or_default();
let auth_method = match matches.value_of("auth-method").unwrap() {
"basic" => AuthMethod::Basic,
_ => AuthMethod::Digest,
};
let no_auth_access = matches.is_present("no-auth-access");
let auth = AccessControl::new(&auth, &uri_prefix)?;
let allow_upload = matches.is_present("allow-all") || matches.is_present("allow-upload");
let allow_delete = matches.is_present("allow-all") || matches.is_present("allow-delete");
let allow_search = matches.is_present("allow-all") || matches.is_present("allow-search");
let allow_symlink = matches.is_present("allow-all") || matches.is_present("allow-symlink");
let render_index = matches.is_present("render-index");
let render_try_index = matches.is_present("render-try-index");
let render_spa = matches.is_present("render-spa");
let tls = match (matches.value_of("tls-cert"), matches.value_of("tls-key")) {
(Some(certs_file), Some(key_file)) => {
@@ -178,15 +203,18 @@ impl Args {
addrs,
port,
path,
path_is_file,
path_prefix,
uri_prefix,
auth_method,
auth,
no_auth_access,
cors,
enable_cors,
allow_delete,
allow_upload,
allow_search,
allow_symlink,
render_index,
render_try_index,
render_spa,
tls,
})

View File

@@ -1,4 +1,5 @@
use headers::HeaderValue;
use hyper::Method;
use lazy_static::lazy_static;
use md5::Context;
use std::{
@@ -7,9 +8,10 @@ use std::{
};
use uuid::Uuid;
use crate::utils::encode_uri;
use crate::BoxResult;
const REALM: &str = "DUF";
const REALM: &str = "DUFS";
lazy_static! {
static ref NONCESTARTHASH: Context = {
@@ -20,100 +22,279 @@ lazy_static! {
};
}
pub fn generate_www_auth(stale: bool) -> String {
let str_stale = if stale { "stale=true," } else { "" };
format!(
"Digest realm=\"{}\",nonce=\"{}\",{}qop=\"auth\"",
REALM,
create_nonce(),
str_stale
)
#[derive(Debug)]
pub struct AccessControl {
rules: HashMap<String, PathControl>,
}
pub fn parse_auth(auth: &str) -> BoxResult<(String, String)> {
let p: Vec<&str> = auth.trim().split(':').collect();
let err = "Invalid auth value";
if p.len() != 2 {
return Err(err.into());
}
let user = p[0];
let pass = p[1];
let mut h = Context::new();
h.consume(format!("{}:{}:{}", user, REALM, pass).as_bytes());
Ok((user.to_owned(), format!("{:x}", h.compute())))
#[derive(Debug)]
pub struct PathControl {
readwrite: Account,
readonly: Option<Account>,
share: bool,
}
pub fn valid_digest(
header_value: &HeaderValue,
method: &str,
auth_user: &str,
auth_pass: &str,
) -> Option<()> {
let digest_value = strip_prefix(header_value.as_bytes(), b"Digest ")?;
let user_vals = to_headermap(digest_value).ok()?;
if let (Some(username), Some(nonce), Some(user_response)) = (
user_vals
.get(b"username".as_ref())
.and_then(|b| std::str::from_utf8(*b).ok()),
user_vals.get(b"nonce".as_ref()),
user_vals.get(b"response".as_ref()),
) {
match validate_nonce(nonce) {
Ok(true) => {}
_ => return None,
impl AccessControl {
pub fn new(raw_rules: &[&str], uri_prefix: &str) -> BoxResult<Self> {
let mut rules = HashMap::default();
if raw_rules.is_empty() {
return Ok(Self { rules });
}
if auth_user != username {
for rule in raw_rules {
let parts: Vec<&str> = rule.split('@').collect();
let create_err = || format!("Invalid auth `{}`", rule).into();
match parts.as_slice() {
[path, readwrite] => {
let control = PathControl {
readwrite: Account::new(readwrite).ok_or_else(create_err)?,
readonly: None,
share: false,
};
rules.insert(sanitize_path(path, uri_prefix), control);
}
[path, readwrite, readonly] => {
let (readonly, share) = if *readonly == "*" {
(None, true)
} else {
(Some(Account::new(readonly).ok_or_else(create_err)?), false)
};
let control = PathControl {
readwrite: Account::new(readwrite).ok_or_else(create_err)?,
readonly,
share,
};
rules.insert(sanitize_path(path, uri_prefix), control);
}
_ => return Err(create_err()),
}
}
Ok(Self { rules })
}
pub fn guard(
&self,
path: &str,
method: &Method,
authorization: Option<&HeaderValue>,
auth_method: AuthMethod,
) -> GuardType {
if self.rules.is_empty() {
return GuardType::ReadWrite;
}
let mut controls = vec![];
for path in walk_path(path) {
if let Some(control) = self.rules.get(path) {
controls.push(control);
if let Some(authorization) = authorization {
let Account { user, pass } = &control.readwrite;
if auth_method
.validate(authorization, method.as_str(), user, pass)
.is_some()
{
return GuardType::ReadWrite;
}
}
}
}
if is_readonly_method(method) {
for control in controls.into_iter() {
if control.share {
return GuardType::ReadOnly;
}
if let Some(authorization) = authorization {
if let Some(Account { user, pass }) = &control.readonly {
if auth_method
.validate(authorization, method.as_str(), user, pass)
.is_some()
{
return GuardType::ReadOnly;
}
}
}
}
}
GuardType::Reject
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum GuardType {
Reject,
ReadWrite,
ReadOnly,
}
impl GuardType {
pub fn is_reject(&self) -> bool {
*self == GuardType::Reject
}
}
fn sanitize_path(path: &str, uri_prefix: &str) -> String {
encode_uri(&format!("{}{}", uri_prefix, path.trim_matches('/')))
}
fn walk_path(path: &str) -> impl Iterator<Item = &str> {
let mut idx = 0;
path.split('/').enumerate().map(move |(i, part)| {
let end = if i == 0 { 1 } else { idx + part.len() + i };
let value = &path[..end];
idx += part.len();
value
})
}
fn is_readonly_method(method: &Method) -> bool {
method == Method::GET
|| method == Method::OPTIONS
|| method == Method::HEAD
|| method.as_str() == "PROPFIND"
}
#[derive(Debug, Clone)]
struct Account {
user: String,
pass: String,
}
impl Account {
fn new(data: &str) -> Option<Self> {
let p: Vec<&str> = data.trim().split(':').collect();
if p.len() != 2 {
return None;
}
let mut ha = Context::new();
ha.consume(method);
ha.consume(b":");
if let Some(uri) = user_vals.get(b"uri".as_ref()) {
ha.consume(uri);
}
let ha = format!("{:x}", ha.compute());
let mut correct_response = None;
if let Some(qop) = user_vals.get(b"qop".as_ref()) {
if qop == &b"auth".as_ref() || qop == &b"auth-int".as_ref() {
correct_response = Some({
let mut c = Context::new();
c.consume(&auth_pass);
c.consume(b":");
c.consume(nonce);
c.consume(b":");
if let Some(nc) = user_vals.get(b"nc".as_ref()) {
c.consume(nc);
}
c.consume(b":");
if let Some(cnonce) = user_vals.get(b"cnonce".as_ref()) {
c.consume(cnonce);
}
c.consume(b":");
c.consume(qop);
c.consume(b":");
c.consume(&*ha);
format!("{:x}", c.compute())
});
let user = p[0];
let pass = p[1];
let mut h = Context::new();
h.consume(format!("{}:{}:{}", user, REALM, pass).as_bytes());
Some(Account {
user: user.to_owned(),
pass: format!("{:x}", h.compute()),
})
}
}
#[derive(Debug, Clone)]
pub enum AuthMethod {
Basic,
Digest,
}
impl AuthMethod {
pub fn www_auth(&self, stale: bool) -> String {
match self {
AuthMethod::Basic => {
format!("Basic realm=\"{}\"", REALM)
}
}
let correct_response = match correct_response {
Some(r) => r,
None => {
let mut c = Context::new();
c.consume(&auth_pass);
c.consume(b":");
c.consume(nonce);
c.consume(b":");
c.consume(&*ha);
format!("{:x}", c.compute())
AuthMethod::Digest => {
let str_stale = if stale { "stale=true," } else { "" };
format!(
"Digest realm=\"{}\",nonce=\"{}\",{}qop=\"auth\"",
REALM,
create_nonce(),
str_stale
)
}
}
}
pub fn validate(
&self,
authorization: &HeaderValue,
method: &str,
auth_user: &str,
auth_pass: &str,
) -> Option<()> {
match self {
AuthMethod::Basic => {
let value: Vec<u8> =
base64::decode(strip_prefix(authorization.as_bytes(), b"Basic ").unwrap())
.unwrap();
let parts: Vec<&str> = std::str::from_utf8(&value).unwrap().split(':').collect();
if parts[0] != auth_user {
return None;
}
let mut h = Context::new();
h.consume(format!("{}:{}:{}", parts[0], REALM, parts[1]).as_bytes());
let http_pass = format!("{:x}", h.compute());
if http_pass == auth_pass {
return Some(());
}
None
}
AuthMethod::Digest => {
let digest_value = strip_prefix(authorization.as_bytes(), b"Digest ")?;
let user_vals = to_headermap(digest_value).ok()?;
if let (Some(username), Some(nonce), Some(user_response)) = (
user_vals
.get(b"username".as_ref())
.and_then(|b| std::str::from_utf8(*b).ok()),
user_vals.get(b"nonce".as_ref()),
user_vals.get(b"response".as_ref()),
) {
match validate_nonce(nonce) {
Ok(true) => {}
_ => return None,
}
if auth_user != username {
return None;
}
let mut ha = Context::new();
ha.consume(method);
ha.consume(b":");
if let Some(uri) = user_vals.get(b"uri".as_ref()) {
ha.consume(uri);
}
let ha = format!("{:x}", ha.compute());
let mut correct_response = None;
if let Some(qop) = user_vals.get(b"qop".as_ref()) {
if qop == &b"auth".as_ref() || qop == &b"auth-int".as_ref() {
correct_response = Some({
let mut c = Context::new();
c.consume(&auth_pass);
c.consume(b":");
c.consume(nonce);
c.consume(b":");
if let Some(nc) = user_vals.get(b"nc".as_ref()) {
c.consume(nc);
}
c.consume(b":");
if let Some(cnonce) = user_vals.get(b"cnonce".as_ref()) {
c.consume(cnonce);
}
c.consume(b":");
c.consume(qop);
c.consume(b":");
c.consume(&*ha);
format!("{:x}", c.compute())
});
}
}
let correct_response = match correct_response {
Some(r) => r,
None => {
let mut c = Context::new();
c.consume(&auth_pass);
c.consume(b":");
c.consume(nonce);
c.consume(b":");
c.consume(&*ha);
format!("{:x}", c.compute())
}
};
if correct_response.as_bytes() == *user_response {
// grant access
return Some(());
}
}
None
}
};
if correct_response.as_bytes() == *user_response {
// grant access
return Some(());
}
}
None
}
/// Check if a nonce is still valid.

30
src/logger.rs Normal file
View File

@@ -0,0 +1,30 @@
use chrono::{Local, SecondsFormat};
use log::{Level, Metadata, Record};
use log::{LevelFilter, SetLoggerError};
struct SimpleLogger;
impl log::Log for SimpleLogger {
fn enabled(&self, metadata: &Metadata) -> bool {
metadata.level() <= Level::Info
}
fn log(&self, record: &Record) {
if self.enabled(record.metadata()) {
let timestamp = Local::now().to_rfc3339_opts(SecondsFormat::Secs, true);
if record.level() < Level::Info {
eprintln!("{} {} - {}", timestamp, record.level(), record.args());
} else {
println!("{} {} - {}", timestamp, record.level(), record.args());
}
}
}
fn flush(&self) {}
}
static LOGGER: SimpleLogger = SimpleLogger;
pub fn init() -> Result<(), SetLoggerError> {
log::set_logger(&LOGGER).map(|()| log::set_max_level(LevelFilter::Info))
}

View File

@@ -1,8 +1,10 @@
mod args;
mod auth;
mod logger;
mod server;
mod streamer;
mod tls;
mod utils;
#[macro_use]
extern crate log;
@@ -11,9 +13,8 @@ use crate::args::{matches, Args};
use crate::server::{Request, Server};
use crate::tls::{TlsAcceptor, TlsStream};
use std::io::Write;
use std::net::{IpAddr, SocketAddr, TcpListener as StdTcpListener};
use std::{env, sync::Arc};
use std::sync::Arc;
use futures::future::join_all;
use tokio::net::TcpListener;
@@ -31,16 +32,7 @@ async fn main() {
}
async fn run() -> BoxResult<()> {
if env::var("RUST_LOG").is_err() {
env::set_var("RUST_LOG", "info")
}
env_logger::builder()
.format(|buf, record| {
let timestamp = buf.timestamp_millis();
writeln!(buf, "[{} {}] {}", timestamp, record.level(), record.args())
})
.init();
logger::init().map_err(|e| format!("Failed to init logger, {}", e))?;
let args = Args::parse(matches())?;
let args = Arc::new(args);
let handles = serve(args.clone())?;

View File

@@ -1,5 +1,5 @@
use crate::auth::{generate_www_auth, valid_digest};
use crate::streamer::Streamer;
use crate::utils::{decode_uri, encode_uri};
use crate::{Args, BoxResult};
use xml::escape::escape_str_pcdata;
@@ -19,7 +19,6 @@ use hyper::header::{
CONTENT_TYPE, ORIGIN, RANGE, WWW_AUTHENTICATE,
};
use hyper::{Body, Method, StatusCode, Uri};
use percent_encoding::percent_decode;
use serde::Serialize;
use std::fs::Metadata;
use std::io::SeekFrom;
@@ -45,11 +44,16 @@ const BUF_SIZE: usize = 65536;
pub struct Server {
args: Arc<Args>,
assets_prefix: String,
}
impl Server {
pub fn new(args: Arc<Args>) -> Self {
Self { args }
let assets_prefix = format!("{}__dufs_v{}_", args.uri_prefix, env!("CARGO_PKG_VERSION"));
Self {
args,
assets_prefix,
}
}
pub async fn call(
@@ -59,12 +63,15 @@ impl Server {
) -> Result<Response, hyper::Error> {
let method = req.method().clone();
let uri = req.uri().clone();
let cors = self.args.cors;
let assets_prefix = self.assets_prefix.clone();
let enable_cors = self.args.enable_cors;
let mut res = match self.handle(req).await {
Ok(res) => {
let status = res.status().as_u16();
info!(r#"{} "{} {}" - {}"#, addr.ip(), method, uri, status,);
if !uri.path().starts_with(&assets_prefix) {
info!(r#"{} "{} {}" - {}"#, addr.ip(), method, uri, status,);
}
res
}
Err(err) => {
@@ -77,7 +84,7 @@ impl Server {
}
};
if cors {
if enable_cors {
add_cors(&mut res);
}
Ok(res)
@@ -86,16 +93,31 @@ impl Server {
pub async fn handle(self: Arc<Self>, req: Request) -> BoxResult<Response> {
let mut res = Response::default();
if !self.auth_guard(&req, &mut res) {
return Ok(res);
}
let req_path = req.uri().path();
let headers = req.headers();
let method = req.method().clone();
if req_path == "/favicon.ico" && method == Method::GET {
self.handle_send_favicon(req.headers(), &mut res).await?;
if method == Method::GET && self.handle_embed_assets(req_path, &mut res).await? {
return Ok(res);
}
let authorization = headers.get(AUTHORIZATION);
let guard_type = self.args.auth.guard(
req_path,
&method,
authorization,
self.args.auth_method.clone(),
);
if guard_type.is_reject() {
self.auth_reject(&mut res);
return Ok(res);
}
let head_only = method == Method::HEAD;
if self.args.path_is_file {
self.handle_send_file(&self.args.path, headers, head_only, &mut res)
.await?;
return Ok(res);
}
@@ -106,6 +128,7 @@ impl Server {
return Ok(res);
}
};
let path = path.as_path();
let query = req.uri().query().unwrap_or_default();
@@ -117,8 +140,10 @@ impl Server {
let allow_upload = self.args.allow_upload;
let allow_delete = self.args.allow_delete;
let allow_search = self.args.allow_search;
let render_index = self.args.render_index;
let render_spa = self.args.render_spa;
let render_try_index = self.args.render_try_index;
if !self.args.allow_symlink && !is_miss && !self.is_root_contained(path).await {
status_not_found(&mut res);
@@ -127,15 +152,17 @@ impl Server {
match method {
Method::GET | Method::HEAD => {
let head_only = method == Method::HEAD;
if is_dir {
if render_index || render_spa {
if render_try_index && query == "zip" {
self.handle_zip_dir(path, head_only, &mut res).await?;
} else if render_index || render_spa || render_try_index {
self.handle_render_index(path, headers, head_only, &mut res)
.await?;
} else if query == "zip" {
self.handle_zip_dir(path, head_only, &mut res).await?;
} else if let Some(q) = query.strip_prefix("q=") {
self.handle_query_dir(path, q, 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?;
} else {
self.handle_ls_dir(path, true, head_only, &mut res).await?;
}
@@ -215,7 +242,8 @@ impl Server {
"LOCK" => {
// Fake lock
if is_file {
self.handle_lock(req_path, &mut res).await?;
let has_auth = authorization.is_some();
self.handle_lock(req_path, has_auth, &mut res).await?;
} else {
status_not_found(&mut res);
}
@@ -331,10 +359,7 @@ impl Server {
res: &mut Response,
) -> BoxResult<()> {
let (mut writer, reader) = tokio::io::duplex(BUF_SIZE);
let filename = path
.file_name()
.and_then(|v| v.to_str())
.ok_or_else(|| format!("Failed to get name of `{}`", path.display()))?;
let filename = get_file_name(path)?;
res.headers_mut().insert(
CONTENT_DISPOSITION,
HeaderValue::from_str(&format!(
@@ -366,15 +391,17 @@ impl Server {
head_only: bool,
res: &mut Response,
) -> BoxResult<()> {
let path = path.join(INDEX_NAME);
if fs::metadata(&path)
let index_path = path.join(INDEX_NAME);
if fs::metadata(&index_path)
.await
.ok()
.map(|v| v.is_file())
.unwrap_or_default()
{
self.handle_send_file(&path, headers, head_only, res)
self.handle_send_file(&index_path, headers, head_only, res)
.await?;
} else if self.args.render_try_index {
self.handle_ls_dir(path, true, head_only, res).await?;
} else {
status_not_found(res)
}
@@ -398,23 +425,38 @@ impl Server {
Ok(())
}
async fn handle_send_favicon(
&self,
headers: &HeaderMap<HeaderValue>,
res: &mut Response,
) -> BoxResult<()> {
let path = self.args.path.join("favicon.ico");
let meta = fs::metadata(&path).await.ok();
let is_file = meta.map(|v| v.is_file()).unwrap_or_default();
if is_file {
self.handle_send_file(path.as_path(), headers, false, res)
.await?;
async fn handle_embed_assets(&self, req_path: &str, res: &mut Response) -> BoxResult<bool> {
if let Some(name) = req_path.strip_prefix(&self.assets_prefix) {
match name {
"index.js" => {
*res.body_mut() = Body::from(INDEX_JS);
res.headers_mut().insert(
"content-type",
HeaderValue::from_static("application/javascript"),
);
}
"index.css" => {
*res.body_mut() = Body::from(INDEX_CSS);
res.headers_mut()
.insert("content-type", HeaderValue::from_static("text/css"));
}
"favicon.ico" => {
*res.body_mut() = Body::from(FAVICON_ICO);
res.headers_mut()
.insert("content-type", HeaderValue::from_static("image/x-icon"));
}
_ => {
return Ok(false);
}
}
res.headers_mut().insert(
"cache-control",
HeaderValue::from_static("max-age=2592000, public"),
);
Ok(true)
} else {
*res.body_mut() = Body::from(FAVICON_ICO);
res.headers_mut()
.insert("content-type", HeaderValue::from_static("image/x-icon"));
Ok(false)
}
Ok(())
}
async fn handle_send_file(
@@ -471,6 +513,13 @@ impl Server {
);
}
let filename = get_file_name(path)?;
res.headers_mut().insert(
CONTENT_DISPOSITION,
HeaderValue::from_str(&format!("inline; filename=\"{}\"", encode_uri(filename),))
.unwrap(),
);
res.headers_mut().typed_insert(AcceptRanges::bytes());
let size = meta.len();
@@ -613,11 +662,11 @@ impl Server {
Ok(())
}
async fn handle_lock(&self, req_path: &str, res: &mut Response) -> BoxResult<()> {
let token = if self.args.auth.is_none() {
Utc::now().timestamp().to_string()
} else {
async fn handle_lock(&self, req_path: &str, auth: bool, res: &mut Response) -> BoxResult<()> {
let token = if auth {
format!("opaquelocktoken:{}", Uuid::new_v4())
} else {
Utc::now().timestamp().to_string()
};
res.headers_mut().insert(
@@ -663,33 +712,32 @@ impl Server {
res: &mut Response,
) -> BoxResult<()> {
paths.sort_unstable();
let rel_path = match self.args.path.parent() {
Some(p) => path.strip_prefix(p).unwrap(),
None => path,
};
let href = format!("/{}", normalize_path(path.strip_prefix(&self.args.path)?));
let data = IndexData {
breadcrumb: normalize_path(rel_path),
href,
uri_prefix: self.args.uri_prefix.clone(),
paths,
allow_upload: self.args.allow_upload,
allow_delete: self.args.allow_delete,
allow_search: self.args.allow_search,
dir_exists: exist,
};
let data = serde_json::to_string(&data).unwrap();
let asset_js = format!("{}index.js", self.assets_prefix);
let asset_css = format!("{}index.css", self.assets_prefix);
let asset_ico = format!("{}favicon.ico", self.assets_prefix);
let output = INDEX_HTML.replace(
"__SLOT__",
&format!(
r#"
<title>Files in {}/ - Duf</title>
<style>{}</style>
<link rel="icon" type="image/x-icon" href="{}">
<link rel="stylesheet" href="{}">
<script>
const DATA =
{}
{}</script>
DATA = {}
</script>
<script src="{}"></script>
"#,
rel_path.display(),
INDEX_CSS,
data,
INDEX_JS
asset_ico, asset_css, data, asset_js
),
);
res.headers_mut()
@@ -703,34 +751,13 @@ const DATA =
Ok(())
}
fn auth_guard(&self, req: &Request, res: &mut Response) -> bool {
let method = req.method();
let pass = {
match &self.args.auth {
None => true,
Some((user, pass)) => match req.headers().get(AUTHORIZATION) {
Some(value) => {
valid_digest(value, method.as_str(), user.as_str(), pass.as_str()).is_some()
}
None => {
self.args.no_auth_access
&& (method == Method::GET
|| method == Method::OPTIONS
|| method == Method::HEAD
|| method.as_str() == "PROPFIND")
}
},
}
};
if !pass {
let value = generate_www_auth(false);
set_webdav_headers(res);
*res.status_mut() = StatusCode::UNAUTHORIZED;
res.headers_mut().typed_insert(Connection::close());
res.headers_mut()
.insert(WWW_AUTHENTICATE, value.parse().unwrap());
}
pass
fn auth_reject(&self, res: &mut Response) {
let value = self.args.auth_method.www_auth(false);
set_webdav_headers(res);
res.headers_mut().typed_insert(Connection::close());
res.headers_mut()
.insert(WWW_AUTHENTICATE, value.parse().unwrap());
*res.status_mut() = StatusCode::UNAUTHORIZED;
}
async fn is_root_contained(&self, path: &Path) -> bool {
@@ -748,7 +775,7 @@ const DATA =
}
fn extract_path(&self, path: &str) -> Option<PathBuf> {
let decoded_path = percent_decode(path[1..].as_bytes()).decode_utf8().ok()?;
let decoded_path = decode_uri(&path[1..])?;
let slashes_switched = if cfg!(windows) {
decoded_path.replace('/', "\\")
} else {
@@ -819,10 +846,12 @@ const DATA =
#[derive(Debug, Serialize)]
struct IndexData {
breadcrumb: String,
href: String,
uri_prefix: String,
paths: Vec<PathItem>,
allow_upload: bool,
allow_delete: bool,
allow_search: bool,
dir_exists: bool,
}
@@ -1018,23 +1047,26 @@ fn parse_range(headers: &HeaderMap<HeaderValue>) -> Option<RangeValue> {
}
}
fn encode_uri(v: &str) -> String {
let parts: Vec<_> = v.split('/').map(urlencoding::encode).collect();
parts.join("/")
}
fn status_forbid(res: &mut Response) {
*res.status_mut() = StatusCode::FORBIDDEN;
*res.body_mut() = Body::from("Forbidden");
}
fn status_not_found(res: &mut Response) {
*res.status_mut() = StatusCode::NOT_FOUND;
*res.body_mut() = Body::from("Not Found");
}
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 set_webdav_headers(res: &mut Response) {
res.headers_mut().insert(
"Allow",

12
src/utils.rs Normal file
View File

@@ -0,0 +1,12 @@
use std::borrow::Cow;
pub fn encode_uri(v: &str) -> String {
let parts: Vec<_> = v.split('/').map(urlencoding::encode).collect();
parts.join("/")
}
pub fn decode_uri(v: &str) -> Option<Cow<str>> {
percent_encoding::percent_decode(v.as_bytes())
.decode_utf8()
.ok()
}

View File

@@ -59,3 +59,15 @@ fn allow_upload_delete_can_override(#[with(&["-A"])] server: TestServer) -> Resu
assert_eq!(resp.status(), 201);
Ok(())
}
#[rstest]
fn allow_search(#[with(&["--allow-search"])] server: TestServer) -> 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()?);
assert!(!paths.is_empty());
for p in paths {
assert!(p.contains(&"test.html"));
}
Ok(())
}

View File

@@ -1,8 +1,11 @@
mod fixtures;
mod utils;
use fixtures::{server, Error, TestServer};
use assert_cmd::prelude::*;
use assert_fs::fixture::TempDir;
use fixtures::{port, server, tmpdir, wait_for_port, Error, TestServer};
use rstest::rstest;
use std::process::{Command, Stdio};
#[rstest]
fn path_prefix_index(#[with(&["--path-prefix", "xyz"])] server: TestServer) -> Result<(), Error> {
@@ -28,3 +31,22 @@ fn path_prefix_propfind(
assert!(text.contains("<D:href>/xyz/</D:href>"));
Ok(())
}
#[rstest]
#[case("index.html")]
fn serve_single_file(tmpdir: TempDir, port: u16, #[case] file: &str) -> Result<(), Error> {
let mut child = Command::cargo_bin("dufs")?
.arg(tmpdir.path().join(file))
.arg("-p")
.arg(port.to_string())
.stdout(Stdio::piped())
.spawn()?;
wait_for_port(port);
let resp = reqwest::blocking::get(format!("http://localhost:{}/index.html", port))?;
assert_eq!(resp.text()?, "This is index.html");
child.kill()?;
Ok(())
}

61
tests/assets.rs Normal file
View File

@@ -0,0 +1,61 @@
mod fixtures;
mod utils;
use fixtures::{server, Error, TestServer};
use rstest::rstest;
#[rstest]
fn assets(server: TestServer) -> Result<(), Error> {
let ver = env!("CARGO_PKG_VERSION");
let resp = reqwest::blocking::get(server.url())?;
let index_js = format!("/__dufs_v{}_index.js", ver);
let index_css = format!("/__dufs_v{}_index.css", ver);
let favicon_ico = format!("/__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(server: TestServer) -> Result<(), Error> {
let url = format!(
"{}__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(())
}
#[rstest]
fn asset_css(server: TestServer) -> Result<(), Error> {
let url = format!(
"{}__dufs_v{}_index.css",
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(), "text/css");
Ok(())
}
#[rstest]
fn asset_ico(server: TestServer) -> Result<(), Error> {
let url = format!(
"{}__dufs_v{}_favicon.ico",
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(), "image/x-icon");
Ok(())
}

View File

@@ -6,7 +6,7 @@ use fixtures::{server, Error, TestServer};
use rstest::rstest;
#[rstest]
fn no_auth(#[with(&["--auth", "user:pass", "-A"])] server: TestServer) -> Result<(), Error> {
fn no_auth(#[with(&["--auth", "/@user:pass", "-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?;
assert_eq!(resp.status(), 401);
assert!(resp.headers().contains_key("www-authenticate"));
@@ -17,7 +17,7 @@ fn no_auth(#[with(&["--auth", "user:pass", "-A"])] server: TestServer) -> Result
}
#[rstest]
fn auth(#[with(&["--auth", "user:pass", "-A"])] server: TestServer) -> Result<(), Error> {
fn auth(#[with(&["--auth", "/@user:pass", "-A"])] server: TestServer) -> 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);
@@ -29,10 +29,69 @@ fn auth(#[with(&["--auth", "user:pass", "-A"])] server: TestServer) -> Result<()
}
#[rstest]
fn auth_skip_access(
#[with(&["--auth", "user:pass", "--no-auth-access"])] server: TestServer,
) -> Result<(), Error> {
fn auth_skip(#[with(&["--auth", "/@user:pass@*"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?;
assert_eq!(resp.status(), 200);
Ok(())
}
#[rstest]
fn auth_readonly(
#[with(&["--auth", "/@user:pass@user2:pass2", "-A"])] server: TestServer,
) -> Result<(), Error> {
let url = format!("{}index.html", server.url());
let resp = fetch!(b"GET", &url).send()?;
assert_eq!(resp.status(), 401);
let resp = fetch!(b"GET", &url).send_with_digest_auth("user2", "pass2")?;
assert_eq!(resp.status(), 200);
let url = format!("{}file1", server.url());
let resp = fetch!(b"PUT", &url)
.body(b"abc".to_vec())
.send_with_digest_auth("user2", "pass2")?;
assert_eq!(resp.status(), 401);
Ok(())
}
#[rstest]
fn auth_nest(
#[with(&["--auth", "/@user:pass@user2:pass2", "--auth", "/dira@user3:pass3", "-A"])]
server: TestServer,
) -> Result<(), Error> {
let url = format!("{}dira/file1", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 401);
let resp = fetch!(b"PUT", &url)
.body(b"abc".to_vec())
.send_with_digest_auth("user3", "pass3")?;
assert_eq!(resp.status(), 201);
let resp = fetch!(b"PUT", &url)
.body(b"abc".to_vec())
.send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 201);
Ok(())
}
#[rstest]
fn auth_nest_share(
#[with(&["--auth", "/@user:pass@*", "--auth", "/dira@user3:pass3", "-A"])] server: TestServer,
) -> Result<(), Error> {
let url = format!("{}index.html", server.url());
let resp = fetch!(b"GET", &url).send()?;
assert_eq!(resp.status(), 200);
Ok(())
}
#[rstest]
fn auth_basic(
#[with(&["--auth", "/@user:pass", "--auth-method", "basic", "-A"])] server: TestServer,
) -> 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);
let resp = fetch!(b"PUT", &url)
.body(b"abc".to_vec())
.basic_auth("user", Some("pass"))
.send()?;
assert_eq!(resp.status(), 201);
Ok(())
}

View File

@@ -1,19 +1,18 @@
mod fixtures;
use fixtures::{port, server, tmpdir, Error, TestServer};
use fixtures::{port, server, tmpdir, wait_for_port, Error, TestServer};
use assert_cmd::prelude::*;
use assert_fs::fixture::TempDir;
use regex::Regex;
use rstest::rstest;
use std::io::{BufRead, BufReader};
use std::io::Read;
use std::process::{Command, Stdio};
#[rstest]
#[case(&["-b", "20.205.243.166"])]
fn bind_fails(tmpdir: TempDir, port: u16, #[case] args: &[&str]) -> Result<(), Error> {
Command::cargo_bin("duf")?
.env("RUST_LOG", "false")
Command::cargo_bin("dufs")?
.arg(tmpdir.path())
.arg("-p")
.arg(port.to_string())
@@ -50,8 +49,7 @@ fn bind_ipv4_ipv6(
#[case(&[] as &[&str])]
#[case(&["--path-prefix", "/prefix"])]
fn validate_printed_urls(tmpdir: TempDir, port: u16, #[case] args: &[&str]) -> Result<(), Error> {
let mut child = Command::cargo_bin("duf")?
.env("RUST_LOG", "false")
let mut child = Command::cargo_bin("dufs")?
.arg(tmpdir.path())
.arg("-p")
.arg(port.to_string())
@@ -59,22 +57,25 @@ fn validate_printed_urls(tmpdir: TempDir, port: u16, #[case] args: &[&str]) -> R
.stdout(Stdio::piped())
.spawn()?;
// WARN assumes urls list is terminated by an empty line
let url_lines = BufReader::new(child.stdout.take().unwrap())
wait_for_port(port);
let stdout = child.stdout.as_mut().expect("Failed to get stdout");
let mut buf = [0; 1000];
let buf_len = stdout.read(&mut buf)?;
let output = std::str::from_utf8(&buf[0..buf_len])?;
let url_lines = output
.lines()
.map(|line| line.expect("Error reading stdout"))
.take_while(|line| !line.is_empty()) /* non-empty lines */
.collect::<Vec<_>>();
let url_lines = url_lines.join("\n");
.collect::<Vec<_>>()
.join("\n");
let urls = Regex::new(r"http://[a-zA-Z0-9\.\[\]:/]+")
.unwrap()
.captures_iter(url_lines.as_str())
.map(|caps| caps.get(0).unwrap().as_str())
.filter_map(|caps| caps.get(0).map(|v| v.as_str()))
.collect::<Vec<_>>();
assert!(!urls.is_empty());
for url in urls {
reqwest::blocking::get(url)?.error_for_status()?;
}

View File

@@ -5,7 +5,7 @@ use fixtures::{server, Error, TestServer};
use rstest::rstest;
#[rstest]
fn cors(#[with(&["--cors"])] server: TestServer) -> Result<(), Error> {
fn cors(#[with(&["--enable-cors"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?;
assert_eq!(
@@ -21,7 +21,7 @@ fn cors(#[with(&["--cors"])] server: TestServer) -> Result<(), Error> {
}
#[rstest]
fn cors_options(#[with(&["--cors"])] server: TestServer) -> Result<(), Error> {
fn cors_options(#[with(&["--enable-cors"])] server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"OPTIONS", server.url()).send()?;
assert_eq!(

View File

@@ -1,25 +0,0 @@
mod fixtures;
mod utils;
use fixtures::{server, Error, TestServer};
use rstest::rstest;
#[rstest]
fn default_favicon(server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}favicon.ico", server.url()))?;
assert_eq!(resp.status(), 200);
assert_eq!(resp.headers().get("content-type").unwrap(), "image/x-icon");
Ok(())
}
#[rstest]
fn exist_favicon(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let url = format!("{}favicon.ico", server.url());
let data = b"abc";
let resp = fetch!(b"PUT", &url).body(data.to_vec()).send()?;
assert_eq!(resp.status(), 201);
let resp = reqwest::blocking::get(url)?;
assert_eq!(resp.status(), 200);
assert_eq!(resp.bytes()?, data.to_vec());
Ok(())
}

View File

@@ -13,23 +13,13 @@ pub type Error = Box<dyn std::error::Error>;
/// File names for testing purpose
#[allow(dead_code)]
pub static FILES: &[&str] = &[
"test.txt",
"test.html",
"index.html",
"test.mkv",
#[cfg(not(windows))]
"test \" \' & < >.csv",
"😀.data",
"⎙.mp4",
"#[]{}()@!$&'`+,;= %20.test",
#[cfg(unix)]
":?#[]{}<>()@!$&'`|*+,;= %20.test",
#[cfg(not(windows))]
"foo\\bar.test",
];
pub static FILES: &[&str] = &["test.txt", "test.html", "index.html", "😀.bin"];
/// Directory names for testing purpose
/// Directory names for testing diretory don't exist
#[allow(dead_code)]
pub static DIR_NO_FOUND: &str = "dir-no-found/";
/// Directory names for testing diretory don't have index.html
#[allow(dead_code)]
pub static DIR_NO_INDEX: &str = "dir-no-index/";
@@ -37,10 +27,6 @@ pub static DIR_NO_INDEX: &str = "dir-no-index/";
#[allow(dead_code)]
pub static DIRECTORIES: &[&str] = &["dira/", "dirb/", "dirc/", DIR_NO_INDEX];
/// Name of a deeply nested file
#[allow(dead_code)]
pub static DEEPLY_NESTED_FILE: &str = "very/deeply/nested/test.rs";
/// Test fixture which creates a temporary directory with a few files and directories inside.
/// The directories also contain files.
#[fixture]
@@ -55,7 +41,7 @@ pub fn tmpdir() -> TempDir {
}
for directory in DIRECTORIES {
for file in FILES {
if *directory == DIR_NO_INDEX {
if *directory == DIR_NO_INDEX && *file == "index.html" {
continue;
}
tmpdir
@@ -65,10 +51,6 @@ pub fn tmpdir() -> TempDir {
}
}
tmpdir
.child(&DEEPLY_NESTED_FILE)
.write_str("File in a deeply nested directory.")
.expect("Couldn't write to file");
tmpdir
}
@@ -79,7 +61,7 @@ pub fn port() -> u16 {
free_local_port().expect("Couldn't find a free local port")
}
/// Run duf as a server; Start with a temporary directory, a free port and some
/// Run dufs as a server; Start with a temporary directory, a free port and some
/// optional arguments then wait for a while for the server setup to complete.
#[fixture]
#[allow(dead_code)]
@@ -90,9 +72,8 @@ where
{
let port = port();
let tmpdir = tmpdir();
let child = Command::cargo_bin("duf")
let child = Command::cargo_bin("dufs")
.expect("Couldn't find test binary")
.env("RUST_LOG", "false")
.arg(tmpdir.path())
.arg("-p")
.arg(port.to_string())
@@ -118,9 +99,8 @@ where
{
let port = port();
let tmpdir = tmpdir();
let child = Command::cargo_bin("duf")
let child = Command::cargo_bin("dufs")
.expect("Couldn't find test binary")
.env("RUST_LOG", "false")
.arg(tmpdir.path())
.arg("-p")
.arg(port.to_string())
@@ -138,7 +118,7 @@ where
}
/// Wait a max of 1s for the port to become available.
fn wait_for_port(port: u16) {
pub fn wait_for_port(port: u16) {
let start_wait = Instant::now();
while !port_check::is_port_reachable(format!("localhost:{}", port)) {

View File

@@ -63,7 +63,7 @@ fn head_dir_zip(server: TestServer) -> Result<(), Error> {
}
#[rstest]
fn get_dir_search(server: TestServer) -> Result<(), Error> {
fn get_dir_search(#[with(&["-A"])] server: TestServer) -> 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()?);
@@ -75,7 +75,19 @@ fn get_dir_search(server: TestServer) -> Result<(), Error> {
}
#[rstest]
fn head_dir_search(server: TestServer) -> Result<(), Error> {
fn get_dir_search2(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?q={}", server.url(), "😀.bin"))?;
assert_eq!(resp.status(), 200);
let paths = utils::retrive_index_paths(&resp.text()?);
assert!(!paths.is_empty());
for p in paths {
assert!(p.contains(&"😀.bin"));
}
Ok(())
}
#[rstest]
fn head_dir_search(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"HEAD", format!("{}?q={}", server.url(), "test.html")).send()?;
assert_eq!(resp.status(), 200);
assert_eq!(
@@ -105,6 +117,7 @@ fn head_file(server: TestServer) -> Result<(), Error> {
assert_eq!(resp.status(), 200);
assert_eq!(resp.headers().get("content-type").unwrap(), "text/html");
assert_eq!(resp.headers().get("accept-ranges").unwrap(), "bytes");
assert!(resp.headers().contains_key("content-disposition"));
assert!(resp.headers().contains_key("etag"));
assert!(resp.headers().contains_key("last-modified"));
assert!(resp.headers().contains_key("content-length"));

View File

@@ -1,6 +1,7 @@
mod fixtures;
mod utils;
use fixtures::{server, Error, TestServer, DIR_NO_INDEX};
use fixtures::{server, Error, TestServer, DIR_NO_FOUND, DIR_NO_INDEX};
use rstest::rstest;
#[rstest]
@@ -12,12 +13,43 @@ fn render_index(#[with(&["--render-index"])] server: TestServer) -> Result<(), E
}
#[rstest]
fn render_index_404(#[with(&["--render-index"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}/{}", server.url(), DIR_NO_INDEX))?;
fn render_index2(#[with(&["--render-index"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}{}", server.url(), DIR_NO_INDEX))?;
assert_eq!(resp.status(), 404);
Ok(())
}
#[rstest]
fn render_try_index(#[with(&["--render-try-index"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?;
let text = resp.text()?;
assert_eq!(text, "This is index.html");
Ok(())
}
#[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
.iter()
.filter(|v| **v != "index.html")
.cloned()
.collect();
assert_index_resp!(resp, files);
Ok(())
}
#[rstest]
fn render_try_index3(#[with(&["--render-try-index"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}{}?zip", server.url(), DIR_NO_INDEX))?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/zip"
);
Ok(())
}
#[rstest]
fn render_spa(#[with(&["--render-spa"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?;
@@ -27,8 +59,8 @@ fn render_spa(#[with(&["--render-spa"])] server: TestServer) -> Result<(), Error
}
#[rstest]
fn render_spa_no_404(#[with(&["--render-spa"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}/{}", server.url(), DIR_NO_INDEX))?;
fn render_spa2(#[with(&["--render-spa"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}{}", server.url(), DIR_NO_FOUND))?;
let text = resp.text()?;
assert_eq!(text, "This is index.html");
Ok(())

View File

@@ -29,7 +29,7 @@ fn tls_works(#[case] server: TestServer) -> Result<(), Error> {
/// Wrong path for cert throws error.
#[rstest]
fn wrong_path_cert() -> Result<(), Error> {
Command::cargo_bin("duf")?
Command::cargo_bin("dufs")?
.args(&["--tls-cert", "wrong", "--tls-key", "tests/data/key.pem"])
.assert()
.failure()
@@ -41,7 +41,7 @@ fn wrong_path_cert() -> Result<(), Error> {
/// Wrong paths for key throws errors.
#[rstest]
fn wrong_path_key() -> Result<(), Error> {
Command::cargo_bin("duf")?
Command::cargo_bin("dufs")?
.args(&["--tls-cert", "tests/data/cert.pem", "--tls-key", "wrong"])
.assert()
.failure()

View File

@@ -37,12 +37,8 @@ pub fn encode_uri(v: &str) -> String {
fn retrive_index_paths_impl(index: &str) -> Option<HashSet<String>> {
let lines: Vec<&str> = index.lines().collect();
let (i, _) = lines
.iter()
.enumerate()
.find(|(_, v)| v.contains("const DATA"))?;
let line = lines.get(i + 1)?;
let value: Value = line.parse().ok()?;
let line = lines.iter().find(|v| v.contains("DATA ="))?;
let value: Value = line[7..].parse().ok()?;
let paths = value
.get("paths")?
.as_array()?