Compare commits

..

60 Commits

Author SHA1 Message Date
sigoden
743db47f90 chore: release v0.37.1 2023-11-08 11:11:36 +08:00
sigoden
a476c15a09 fix: use DUFS_CONFIG to specify the config file path (#286) 2023-11-08 11:10:47 +08:00
sigoden
0d74fa3ec5 chore: release v0.37.0 2023-11-08 10:41:24 +08:00
sigoden
b83cc6938b chore: update readme 2023-11-07 22:45:53 +08:00
sigoden
a187b14885 chore: update deps and ci (#284) 2023-11-04 19:47:13 +08:00
sigoden
d3de3db0d9 feat: support hashed password (#283) 2023-11-04 18:12:58 +08:00
sigoden
80ac9afe68 refactor: improve code quanity (#282)
- rename LogHttp to HttpLogger
2023-11-04 17:10:38 +08:00
sigoden
4ef07737e1 feat: support config file with --config option (#281) 2023-11-04 16:58:19 +08:00
sigoden
5782c5f413 chore: update description for --auth 2023-11-03 21:08:05 +08:00
sigoden
8b4cab1e69 fix: auto delete half-uploaded files (#280) 2023-11-03 20:58:53 +08:00
sigoden
70300b133c feat: deprecate --auth-method, as both options are available (#279)
* feat: deprecate `--auth-method`, both are avaiable

* send one www-authenticate with two schemes
2023-11-03 20:36:23 +08:00
sigoden
7ea4bb808d refactor: optimize tests 2023-11-03 15:25:20 +08:00
sigoden
6766e0d437 fix: ui show user-name next to the user-icon (#278) 2023-11-03 14:55:07 +08:00
tieway59
53c9bc8bea refactor: remove one clone on assets_prefix (#270)
This clone is not consist with the usage of `assets_prefix` in following
code and it's unnecessary.

Signed-off-by: TieWay59 <tieway59@foxmail.com>
2023-10-05 08:50:24 +08:00
sigoden
60df3b473c fix: sort path ignore case (#264) 2023-09-06 23:25:04 +08:00
sigoden
6510ae8be9 chore: release v0.36.0 2023-08-24 18:46:30 +08:00
sigoden
9545fb6e37 fix: ui readonly if no write perm (#258) 2023-08-24 18:32:34 +08:00
sigoden
0fd0f11298 chore: update deps 2023-08-24 16:46:38 +08:00
figsoda
46aa8fcc02 test: remove dependency on native tls (#255) 2023-08-15 11:01:25 +08:00
sigoden
09bb738866 chore: update changelog 2023-08-15 07:29:02 +08:00
sigoden
3612ef10d1 chore: release 0.35.0 (#254)
* chore: release 0.35.0

* update release profile
2023-08-15 07:24:22 +08:00
sigoden
7ac2039a36 chore: update deps 2023-08-14 17:31:52 +08:00
sigoden
7f83de765a fix: typo __ASSERTS_PREFIX__ (#252) 2023-08-13 15:05:45 +08:00
sigoden
9b3779b13a chore: update readme
close #247
2023-07-20 06:33:17 +08:00
sigoden
11a52f29c4 chore: fix release ci (#244) 2023-07-15 16:34:22 +08:00
sigoden
10204c723f chore: fix clippy (#245) 2023-07-15 16:27:13 +08:00
sigoden
204421643d chore: update ci (#242) 2023-07-04 10:25:49 +08:00
sigoden
d9706d75ef feat: sort by type first, then sort by name/mtime/size (#241) 2023-07-04 10:10:48 +08:00
sigoden
40df0bd2f9 chore: update readme 2023-06-18 08:55:42 +08:00
sigoden
a53411b4d6 fix: search should ignore entry path (#235) 2023-06-15 08:28:21 +08:00
ElmTran
609017b2f5 chore: Update README.md (#233)
update examples on new auth.
2023-06-13 08:23:05 +08:00
sigoden
7dc0b0e218 chore: release v0.34.2 2023-06-05 11:51:56 +08:00
sigoden
6be36b8e51 fix: webdav only see public folder even logging in (#231) 2023-06-05 11:40:31 +08:00
sigoden
8be545d3da fix: ui refresh page after login (#230) 2023-06-03 10:09:02 +08:00
sigoden
4f3a8d275b chore: release v0.34.1 2023-06-02 19:44:35 +08:00
sigoden
9c412f4276 refactor: ui checkAuth (#226) 2023-06-02 19:35:30 +08:00
sigoden
27c269d6a0 fix: allow all cors headers and methods (#225) 2023-06-02 19:07:43 +08:00
sigoden
57b4a74279 fix: auth logic (#224) 2023-06-02 18:38:59 +08:00
sigoden
1112b936b8 chore: release v0.34.0 2023-06-02 07:16:43 +08:00
sigoden
033d37c4d4 chore: update cli --auth help text 2023-06-02 06:49:06 +08:00
sigoden
577eea5fa4 chore: ui js refactor 2023-06-01 22:22:36 +08:00
sigoden
d22be95dda chore: update deps 2023-06-01 22:06:01 +08:00
sigoden
8d7c1fbf53 fix: ui set default max uploading to 1 (#220) 2023-06-01 21:32:22 +08:00
sigoden
4622c48120 fix: ui path table show move action (#219) 2023-06-01 20:33:21 +08:00
sigoden
f8ea41638f feat: new auth (#218)
The access level path control used by dufs has two disadvantages:

1. One path cannot support multiple users
2. It is very troublesome to set multiple paths for one user

So it needs to be refactored.
The new auth is account based, it closes #207, closes #208.

BREAKING CHANGE: new auth
2023-06-01 18:52:05 +08:00
nq5
2890b3929d chore: correct spelling and grammar in index.js (#216) 2023-06-01 07:35:41 +08:00
sigoden
f5c0aefd8e refactor: cli positional rename root => SERVE_PATH(#215) 2023-05-30 16:49:16 +08:00
Jesse Hu
8a1e7674df feat: show precise file size with decimal (#210) 2023-05-18 12:01:02 +08:00
sigoden
3c6206849f chore: trivial improvements 2023-04-01 16:10:34 +08:00
sigoden
652f836c23 feat: add timestamp metadata to generated zip file (#204) 2023-03-31 23:48:23 +08:00
sigoden
fb5b50f059 fix: URL-encoded filename when downloading in safari (#203)
* fix: URL-encoded filename when downloading in safari

* add test
2023-03-31 22:52:07 +08:00
sigoden
e43554b795 feat: webui editing support multiple encodings (#197) 2023-03-17 11:22:21 +08:00
sigoden
10ec34872d chore(release): version 0.33.0 2023-03-17 09:06:01 +08:00
sigoden
3ff16d254b chore: update deps 2023-03-17 08:54:38 +08:00
sigoden
29a04c8d74 refactor: improve error handle (#195) 2023-03-12 15:20:40 +08:00
sigoden
c92e45f2da fix: basic auth sometimes does not work (#194) 2023-03-12 12:58:36 +08:00
sigoden
8d7a9053e2 chore: update deps 2023-03-06 10:09:24 +08:00
sigoden
0e12b285cd fix: hidden don't works on some files (#188)
like --hidden '*.abc-cba' matches xyz.abc-cba but do not matches 123.xyz.abc-cba
2023-03-03 07:15:46 +08:00
sigoden
45f4f5fc58 feat: guess plain text encoding then set content-type charset (#186) 2023-03-01 09:36:59 +08:00
horizon
6dcb4dcd76 fix: cors allow-request-header add content-type (#184)
* fix: cors allow-request-header add content-type

* add content-type test
2023-02-27 07:28:33 +08:00
27 changed files with 2356 additions and 1606 deletions

View File

@@ -29,16 +29,12 @@ jobs:
RUSTFLAGS: --deny warnings
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Install Rust Toolchain Components
uses: actions-rs/toolchain@v1
with:
components: clippy, rustfmt
override: true
toolchain: stable
uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v1
- uses: Swatinem/rust-cache@v2
- name: Test
run: cargo test --all

View File

@@ -27,7 +27,7 @@ jobs:
- target: aarch64-pc-windows-msvc
os: windows-latest
use-cross: true
cargo-flags: "--no-default-features"
cargo-flags: ""
- target: x86_64-apple-darwin
os: macos-latest
cargo-flags: ""
@@ -71,30 +71,42 @@ jobs:
use-cross: true
cargo-flags: "--no-default-features"
runs-on: ${{matrix.os}}
env:
BUILD_CMD: cargo
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Check Tag
id: check-tag
shell: bash
run: |
tag=${GITHUB_REF##*/}
echo "::set-output name=version::$tag"
if [[ "$tag" =~ [0-9]+.[0-9]+.[0-9]+$ ]]; then
echo "::set-output name=rc::false"
ver=${GITHUB_REF##*/}
echo "version=$ver" >> $GITHUB_OUTPUT
if [[ "$ver" =~ [0-9]+.[0-9]+.[0-9]+$ ]]; then
echo "rc=false" >> $GITHUB_OUTPUT
else
echo "::set-output name=rc::true"
echo "rc=true" >> $GITHUB_OUTPUT
fi
- name: Install Rust Toolchain Components
uses: actions-rs/toolchain@v1
uses: dtolnay/rust-toolchain@stable
with:
override: true
target: ${{ matrix.target }}
toolchain: stable
profile: minimal # minimal component installation (ie, no documentation)
targets: ${{ matrix.target }}
# Since rust 1.72, mips platforms are tier 3
toolchain: 1.71
- name: Install cross
if: matrix.use-cross
uses: taiki-e/install-action@v2
with:
tool: cross
- name: Overwrite build command env variable
if: matrix.use-cross
shell: bash
run: echo "BUILD_CMD=cross" >> $GITHUB_ENV
- name: Show Version Information (Rust, cargo, GCC)
shell: bash
@@ -107,11 +119,8 @@ jobs:
rustc -V
- name: Build
uses: actions-rs/cargo@v1
with:
use-cross: ${{ matrix.use-cross }}
command: build
args: --locked --release --target=${{ matrix.target }} ${{ matrix.cargo-flags }}
shell: bash
run: $BUILD_CMD build --locked --release --target=${{ matrix.target }} ${{ matrix.cargo-flags }}
- name: Build Archive
shell: bash
@@ -123,8 +132,7 @@ jobs:
set -euxo pipefail
bin=${GITHUB_REPOSITORY##*/}
src=`pwd`
dist=$src/dist
dist_dir=`pwd`/dist
name=$bin-$version-$target
executable=target/$target/release/$bin
@@ -132,22 +140,22 @@ jobs:
executable=$executable.exe
fi
mkdir $dist
cp $executable $dist
cd $dist
mkdir $dist_dir
cp $executable $dist_dir
cd $dist_dir
if [[ "$RUNNER_OS" == "Windows" ]]; then
archive=$dist/$name.zip
archive=$dist_dir/$name.zip
7z a $archive *
echo "::set-output name=archive::`pwd -W`/$name.zip"
echo "archive=dist/$name.zip" >> $GITHUB_OUTPUT
else
archive=$dist/$name.tar.gz
tar czf $archive *
echo "::set-output name=archive::$archive"
archive=$dist_dir/$name.tar.gz
tar -czf $archive *
echo "archive=dist/$name.tar.gz" >> $GITHUB_OUTPUT
fi
- name: Publish Archive
uses: softprops/action-gh-release@v0.1.5
uses: softprops/action-gh-release@v1
if: ${{ startsWith(github.ref, 'refs/tags/') }}
with:
draft: false
@@ -163,16 +171,16 @@ jobs:
needs: release
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v2
uses: docker/build-push-action@v4
with:
build-args: |
REPO=${{ github.repository }}
@@ -191,13 +199,11 @@ jobs:
runs-on: ubuntu-latest
needs: release
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
- name: Publish
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable
- name: Publish
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }}
run: cargo publish

View File

@@ -2,6 +2,106 @@
All notable changes to this project will be documented in this file.
## [0.37.1] - 2023-11-08
### Bug Fixes
- Use DUFS_CONFIG to specify the config file path ([#286](https://github.com/sigoden/dufs/issues/286)
## [0.37.0] - 2023-11-08
### Bug Fixes
- Sort path ignore case ([#264](https://github.com/sigoden/dufs/issues/264))
- Ui show user-name next to the user-icon ([#278](https://github.com/sigoden/dufs/issues/278))
- Auto delete half-uploaded files ([#280](https://github.com/sigoden/dufs/issues/280))
### Features
- Deprecate `--auth-method`, as both options are available ([#279](https://github.com/sigoden/dufs/issues/279))
- Support config file with `--config` option ([#281](https://github.com/sigoden/dufs/issues/281))
- Support hashed password ([#283](https://github.com/sigoden/dufs/issues/283))
### Refactor
- Remove one clone on `assets_prefix` ([#270](https://github.com/sigoden/dufs/issues/270))
- Optimize tests
- Improve code quanity ([#282](https://github.com/sigoden/dufs/issues/282))
## [0.36.0] - 2023-08-24
### Bug Fixes
- Ui readonly if no write perm ([#258](https://github.com/sigoden/dufs/issues/258))
### Testing
- Remove dependency on native tls ([#255](https://github.com/sigoden/dufs/issues/255))
## [0.35.0] - 2023-08-14
### Bug Fixes
- Search should ignore entry path ([#235](https://github.com/sigoden/dufs/issues/235))
- Typo __ASSERTS_PREFIX__ ([#252](https://github.com/sigoden/dufs/issues/252))
### Features
- Sort by type first, then sort by name/mtime/size ([#241](https://github.com/sigoden/dufs/issues/241))
## [0.34.2] - 2023-06-05
### Bug Fixes
- Ui refresh page after login ([#230](https://github.com/sigoden/dufs/issues/230))
- Webdav only see public folder even logging in ([#231](https://github.com/sigoden/dufs/issues/231))
## [0.34.1] - 2023-06-02
### Bug Fixes
- Auth logic ([#224](https://github.com/sigoden/dufs/issues/224))
- Allow all cors headers and methods ([#225](https://github.com/sigoden/dufs/issues/225))
### Refactor
- Ui checkAuth ([#226](https://github.com/sigoden/dufs/issues/226))
## [0.34.0] - 2023-06-01
### Bug Fixes
- URL-encoded filename when downloading in safari ([#203](https://github.com/sigoden/dufs/issues/203))
- Ui path table show move action ([#219](https://github.com/sigoden/dufs/issues/219))
- Ui set default max uploading to 1 ([#220](https://github.com/sigoden/dufs/issues/220))
### Features
- Webui editing support multiple encodings ([#197](https://github.com/sigoden/dufs/issues/197))
- Add timestamp metadata to generated zip file ([#204](https://github.com/sigoden/dufs/issues/204))
- Show precise file size with decimal ([#210](https://github.com/sigoden/dufs/issues/210))
- [**breaking**] New auth ([#218](https://github.com/sigoden/dufs/issues/218))
### Refactor
- Cli positional rename root => SERVE_PATH([#215](https://github.com/sigoden/dufs/issues/215))
## [0.33.0] - 2023-03-17
### Bug Fixes
- Cors allow-request-header add content-type ([#184](https://github.com/sigoden/dufs/issues/184))
- Hidden don't works on some files ([#188](https://github.com/sigoden/dufs/issues/188))
- Basic auth sometimes does not work ([#194](https://github.com/sigoden/dufs/issues/194))
### Features
- Guess plain text encoding then set content-type charset ([#186](https://github.com/sigoden/dufs/issues/186))
### Refactor
- Improve error handle ([#195](https://github.com/sigoden/dufs/issues/195))
## [0.32.0] - 2023-02-22
### Bug Fixes

1317
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "dufs"
version = "0.32.0"
version = "0.37.1"
edition = "2021"
authors = ["sigoden <sigoden@gmail.com>"]
description = "Dufs is a distinctive utility file server"
@@ -13,35 +13,40 @@ keywords = ["static", "file", "server", "webdav", "cli"]
[dependencies]
clap = { version = "4", features = ["wrap_help", "env"] }
clap_complete = "4"
chrono = "0.4"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util", "signal"]}
tokio-util = { version = "0.7", features = ["io-util"] }
tokio-util = { version = "0.7", features = ["io-util", "compat"] }
hyper = { version = "0.14", features = ["http1", "server", "tcp", "stream"] }
percent-encoding = "2.1"
percent-encoding = "2.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
futures = "0.3"
base64 = "0.21"
async_zip = { version = "0.0.12", default-features = false, features = ["deflate"] }
async_zip = { version = "0.0.15", default-features = false, features = ["deflate", "chrono", "tokio"] }
headers = "0.3"
mime_guess = "2.0"
if-addrs = "0.8"
rustls = { version = "0.20", default-features = false, features = ["tls12"], optional = true }
if-addrs = "0.10.1"
rustls = { version = "0.21", default-features = false, features = ["tls12"], optional = true }
rustls-pemfile = { version = "1", optional = true }
tokio-rustls = { version = "0.23", optional = true }
tokio-rustls = { version = "0.24", optional = true }
md5 = "0.7"
lazy_static = "1.4"
uuid = { version = "1.1", features = ["v4", "fast-rng"] }
uuid = { version = "1.4", features = ["v4", "fast-rng"] }
urlencoding = "2.1"
xml-rs = "0.8"
log = "0.4"
socket2 = "0.4"
socket2 = "0.5"
async-stream = "0.3"
walkdir = "2.3"
form_urlencoded = "1.0"
form_urlencoded = "1.2"
alphanumeric-sort = "1.4"
content_inspector = "0.2"
anyhow = "1.0"
chardetng = "0.1"
glob = "0.3.1"
indexmap = "2.0"
serde_yaml = "0.9.27"
sha-crypt = "0.5.0"
base64 = "0.21.5"
[features]
default = ["tls"]
@@ -52,14 +57,15 @@ assert_cmd = "2"
reqwest = { version = "0.11", features = ["blocking", "multipart", "rustls-tls"], default-features = false }
assert_fs = "1"
port_check = "0.1"
rstest = "0.16"
rstest = "0.18"
regex = "1"
url = "2"
diqwest = { version = "1", features = ["blocking"] }
predicates = "2"
indexmap = "1.9"
diqwest = { version = "1", features = ["blocking", "rustls-tls"], default-features = false }
predicates = "3"
[profile.release]
opt-level = 3
lto = true
strip = true
opt-level = "z"
codegen-units = 1
panic = "abort"
strip = "symbols"

159
README.md
View File

@@ -14,7 +14,7 @@ Dufs is a distinctive utility file server that supports static serving, uploadin
- Upload files and folders (Drag & Drop)
- Create/Edit/Search files
- Partial responses (Parallel/Resume download)
- Path level access control
- Access control
- Support https
- Support webdav
- Easy to use with curl
@@ -48,18 +48,18 @@ Download from [Github Releases](https://github.com/sigoden/dufs/releases), unzip
```
Dufs is a distinctive utility file server - https://github.com/sigoden/dufs
Usage: dufs [OPTIONS] [root]
Usage: dufs [OPTIONS] [serve-path]
Arguments:
[root] Specific path to serve [default: .]
[serve-path] Specific path to serve [default: .]
Options:
-c, --config <config> Specify configuration file
-b, --bind <addrs> Specify bind address or unix socket
-p, --port <port> Specify port to listen on [default: 5000]
--path-prefix <path> Specify a path prefix
--hidden <value> Hide paths from directory listings, separated by `,`
-a, --auth <rules> Add auth for path
--auth-method <value> Select auth method [default: digest] [possible values: basic, digest]
--hidden <value> Hide paths from directory listings, e.g. tmp,*.log,*.lock
-a, --auth <rules> Add auth roles, e.g. user:pass@/dir1:rw,/dir2
-A, --allow-all Allow all operations
--allow-upload Allow upload files/folders
--allow-delete Allow delete files/folders
@@ -70,13 +70,13 @@ Options:
--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 directory listing if not found index.html
--render-spa Serve SPA(Single Page Application)
--assets <path> Use custom assets to override builtin assets
--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
--assets <path> Set the path to the assets directory for overriding the built-in assets
--log-format <format> Customize http log format
--completions <shell> Print shell completion script for <shell> [possible values: bash, elvish, fish, powershell, zsh]
-h, --help Print help information
-V, --version Print version information
--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
-V, --version Print version
```
## Examples
@@ -126,7 +126,7 @@ dufs --render-index
Require username/password
```
dufs -a /@admin:123
dufs -a admin:123@/:rw
```
Listen on specific host:ip
@@ -194,8 +194,8 @@ curl http://127.0.0.1:5000?q=Dockerfile&simple # search for files, just like
With authorization
```
curl --user user:pass --digest http://192.168.8.10:5000/file # digest auth
curl --user user:pass http://192.168.8.10:5000/file # basic auth
curl http://192.168.8.10:5000/file --user user:pass # basic auth
curl http://192.168.8.10:5000/file --user user:pass --digest # digest auth
```
<details>
@@ -203,61 +203,87 @@ curl --user user:pass http://192.168.8.10:5000/file # basic auth
### Access Control
Dufs supports path level access control. You can control who can do what on which path with `--auth`/`-a`.
Dufs supports account based access control. You can control who can do what on which path with `--auth`/`-a`.
```
dufs -a <path>@<readwrite>
dufs -a <path>@<readwrite>@<readonly>
dufs -a <path>@<readwrite>@*
dufs -a user:pass@path1:rw,path2|user2:pass2@path1
dufs -a user:pass@path1:rw,path2 -a user2:pass2@path1
```
- `<path>`: Protected url path
- `<readwrite>`: Account with readwrite permissions. If dufs is run with `dufs --allow-all`, the permissions are upload/delete/search/view/download. If dufs is run with `dufs --allow-upload`, the permissions are upload/view/download.
- `<readonly>`: Account with readonly permissions. The permissions are search/view/download if dufs allow search, otherwise view/download..
1. Multiple rules are separated by "|"
2. User and pass are the account name and password, if omitted, it is an anonymous user
3. One rule can set multiple paths, separated by ","
4. Add `:rw` after the path to indicate that the path has read and write permissions, otherwise the path has readonly permissions.
```
dufs -A -a /@admin:admin
dufs -A -a admin:admin@/:rw
```
`admin` has all permissions for all paths.
```
dufs -A -a /@admin:admin@guest:guest
dufs -A -a admin:admin@/:rw -a guest:guest@/
```
`guest` has readonly permissions for all paths.
```
dufs -A -a /@admin:admin@*
dufs -A -a admin:admin@/:rw -a @/
```
All paths is public, everyone can view/download it.
```
dufs -A -a /@admin:admin -a /user1@user1:pass1 -a /user2@pass2:user2
dufs -A -a admin:admin@/:rw -a user1:pass1@/user1:rw -a user2:pass2@/user2
dufs -A -a "admin:admin@/:rw|user1:pass1@/user1:rw|user2:pass2@/user2"
```
`user1` has all permissions for `/user1*` path.
`user2` has all permissions for `/user2*` path.
`user1` has all permissions for `/user1/*` path.
`user2` has all permissions for `/user2/*` path.
```
dufs -a /@admin:admin
dufs -A -a user:pass@/dir1:rw,/dir2:rw,dir3
```
`user` has all permissions for `/dir1/*` and `/dir2/*`, has readonly permissions for `/dir3/`.
```
dufs -A -a admin:admin@/
```
Since dufs only allows viewing/downloading, `admin` can only view/download files.
#### Hashed Password
DUFS supports the use of sha-512 hashed password.
Create hashed password
```
$ mkpasswd -m sha-512 -s
Password: 123456
$6$qCAVUG7yn7t/hH4d$BWm8r5MoDywNmDP/J3V2S2a6flmKHC1IpblfoqZfuK.LtLBZ0KFXP9QIfJP8RqL8MCw4isdheoAMTuwOz.pAO/
```
Use hashed password
```
dufs -A -a 'admin:$6$qCAVUG7yn7t/hH4d$BWm8r5MoDywNmDP/J3V2S2a6flmKHC1IpblfoqZfuK.LtLBZ0KFXP9QIfJP8RqL8MCw4isdheoAMTuwOz.pAO/@/:rw'
```
Two important things for hashed passwords:
1. Dufs only supports SHA-512 hashed passwords, so ensure that the password string always starts with `$6$`.
2. Digest auth does not work with hashed passwords.
### Hide Paths
Dufs supports hiding paths from directory listings via option `--hidden`.
Dufs supports hiding paths from directory listings via option `--hidden <glob>,...`.
```
dufs --hidden .git,.DS_Store,tmp
```
`--hidden` also supports a variant glob:
- `?` matches any single character
- `*` matches any (possibly empty) sequence of characters
- `**`, `[..]`, `[!..]` is not supported
> The glob used in --hidden only matches file and directory names, not paths. So `--hidden dir1/file` is invalid.
```sh
dufs --hidden '.*'
dufs --hidden '*.log,*.lock'
dufs --hidden '.*' # hidden dotfiles
dufs --hidden '*/' # hidden all folders
dufs --hidden '*.log,*.lock' # hidden by exts
```
### Log Format
@@ -305,7 +331,64 @@ dufs --log-format '$remote_addr $remote_user "$request" $status' -a /@admin:admi
All options can be set using environment variables prefixed with `DUFS_`.
`dufs --port 8080 --allow-all` is equal to `DUFS_PORT=8080 DUFS_ALLOW_ALL=true dufs`.
```
[serve-path] DUFS_SERVE_PATH=/dir
--config <path> DUFS_CONFIG=config.yaml
-b, --bind <addrs> DUFS_BIND=0.0.0.0
-p, --port <port> DUFS_PORT=5000
--path-prefix <path> DUFS_PATH_PREFIX=/path
--hidden <value> DUFS_HIDDEN=*.log
-a, --auth <rules> DUFS_AUTH="admin:admin@/:rw|@/"
-A, --allow-all DUFS_ALLOW_ALL=true
--allow-upload DUFS_ALLOW_UPLOAD=true
--allow-delete DUFS_ALLOW_DELETE=true
--allow-search DUFS_ALLOW_SEARCH=true
--allow-symlink DUFS_ALLOW_SYMLINK=true
--allow-archive DUFS_ALLOW_ARCHIVE=true
--enable-cors DUFS_ENABLE_CORS=true
--render-index DUFS_RENDER_INDEX=true
--render-try-index DUFS_RENDER_TRY_INDEX=true
--render-spa DUFS_RENDER_SPA=true
--assets <path> DUFS_ASSETS=/assets
--log-format <format> DUFS_LOG_FORMAT=""
--tls-cert <path> DUFS_TLS_CERT=cert.pem
--tls-key <path> DUFS_TLS_KEY=key.pem
```
## Configuration File
You can specify and use the configuration file by selecting the option `--config <path-to-config.yaml>`.
The following are the configuration items:
```yaml
serve-path: '.'
bind:
- 192.168.8.10
port: 5000
path-prefix: /dufs
hidden:
- tmp
- '*.log'
- '*.lock'
auth:
- admin:admin@/:rw
- user:pass@/src:rw,/share
allow-all: false
allow-upload: true
allow-delete: true
allow-search: true
allow-symlink: true
allow-archive: true
enable-cors: true
render-index: true
render-try-index: true
render-spa: true
assets: ./assets/
log-format: '$remote_addr "$request" $status $http_user_agent'
tls-cert: tests/data/cert.pem
tls-key: tests/data/key_pkcs1.pem
```
### Customize UI
@@ -320,7 +403,7 @@ Your assets folder must contains a `index.html` file.
`index.html` can use the following placeholder variables to retrieve internal data.
- `__INDEX_DATA__`: directory listing data
- `__ASSERTS_PREFIX__`: assets url prefix
- `__ASSETS_PREFIX__`: assets url prefix
</details>

View File

@@ -148,7 +148,7 @@ body {
}
.paths-table .cell-actions {
width: 75px;
width: 90px;
display: flex;
padding-left: 0.6em;
}
@@ -210,7 +210,7 @@ body {
outline: none;
}
.toolbox2 {
.toolbox-right {
margin-left: auto;
margin-right: 2em;
}
@@ -220,6 +220,15 @@ body {
user-select: none;
}
.user-btn {
display: flex;
align-items: center;
}
.user-name {
padding-left: 3px;
}
.not-editable {
font-style: italic;
}

View File

@@ -4,12 +4,12 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/x-icon" href="__ASSERTS_PREFIX__favicon.ico">
<link rel="stylesheet" href="__ASSERTS_PREFIX__index.css">
<link rel="icon" type="image/x-icon" href="__ASSETS_PREFIX__favicon.ico">
<link rel="stylesheet" href="__ASSETS_PREFIX__index.css">
<script>
DATA = __INDEX_DATA__
</script>
<script src="__ASSERTS_PREFIX__index.js"></script>
<script src="__ASSETS_PREFIX__index.js"></script>
</head>
<body>
@@ -77,7 +77,7 @@
<input id="search" name="q" type="text" maxlength="128" autocomplete="off" tabindex="1">
<input type="submit" hidden />
</form>
<div class="toolbox2">
<div class="toolbox-right">
<div class="login-btn hidden" title="Login for upload/move/delete/edit permissions">
<svg width="16" height="16" viewBox="0 0 16 16">
<path fill-rule="evenodd"
@@ -91,6 +91,7 @@
<path
d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4Zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10Z" />
</svg>
<span class="user-name"></span>
</div>
<div class="save-btn hidden" title="Save file">
<svg viewBox="0 0 1024 1024" width="24" height="24">

View File

@@ -22,6 +22,8 @@
* @property {string} editable
*/
var DUFS_MAX_UPLOADINGS = 1;
/**
* @type {DATA} DATA
*/
@@ -84,6 +86,10 @@ let $editor;
* @type Element
*/
let $userBtn;
/**
* @type Element
*/
let $userName;
function ready() {
$pathsTable = document.querySelector(".paths-table")
@@ -93,6 +99,7 @@ function ready() {
$emptyFolder = document.querySelector(".empty-folder");
$editor = document.querySelector(".editor");
$userBtn = document.querySelector(".user-btn");
$userName = document.querySelector(".user-name");
addBreadcrumb(DATA.href, DATA.uri_prefix);
@@ -103,7 +110,7 @@ function ready() {
setupIndexPage();
} else if (DATA.kind == "Edit") {
document.title = `Edit of ${DATA.href} - Dufs`;
document.title = `Edit ${DATA.href} - Dufs`;
document.querySelector(".editor-page").classList.remove("hidden");;
setupEditPage();
@@ -152,7 +159,6 @@ class Uploader {
}
ajax() {
Uploader.runnings += 1;
const url = newUrl(this.name);
this.lastUptime = Date.now();
const ajax = new XMLHttpRequest();
@@ -187,13 +193,13 @@ class Uploader {
complete() {
this.$uploadStatus.innerHTML = ``;
Uploader.runnings -= 1;
Uploader.runnings--;
Uploader.runQueue();
}
fail() {
this.$uploadStatus.innerHTML = ``;
Uploader.runnings -= 1;
Uploader.runnings--;
Uploader.runQueue();
}
}
@@ -211,12 +217,17 @@ Uploader.queues = [];
Uploader.runQueue = async () => {
if (Uploader.runnings > 2) return;
if (Uploader.runnings >= DUFS_MAX_UPLOADINGS) return;
if (Uploader.queues.length == 0) return;
Uploader.runnings++;
let uploader = Uploader.queues.shift();
if (!uploader) return;
if (!Uploader.auth) {
Uploader.auth = true;
await login();
try {
await checkAuth()
} catch {
Uploader.auth = false;
}
}
uploader.ajax();
}
@@ -262,7 +273,7 @@ function setupIndexPage() {
if (DATA.allow_archive) {
const $download = document.querySelector(".download");
$download.href = baseUrl() + "?zip";
$download.title = "Download folder as div .zip file";
$download.title = "Download folder as a .zip file";
$download.classList.remove("hidden");
}
@@ -310,13 +321,13 @@ function renderPathsTableHead() {
<tr>
${headerItems.map(item => {
let svg = `<svg width="12" height="12" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M11.5 15a.5.5 0 0 0 .5-.5V2.707l3.146 3.147a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 1 0 .708.708L11 2.707V14.5a.5.5 0 0 0 .5.5zm-7-14a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L4 13.293V1.5a.5.5 0 0 1 .5-.5z"/></svg>`;
let order = "asc";
let order = "desc";
if (PARAMS.sort === item.name) {
if (PARAMS.order === "asc") {
order = "desc";
svg = `<svg width="12" height="12" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 15a.5.5 0 0 0 .5-.5V2.707l3.146 3.147a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 1 0 .708.708L7.5 2.707V14.5a.5.5 0 0 0 .5.5z"/></svg>`
} else {
if (PARAMS.order === "desc") {
order = "asc";
svg = `<svg width="12" height="12" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 1a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L7.5 13.293V1.5A.5.5 0 0 1 8 1z"/></svg>`
} else {
svg = `<svg width="12" height="12" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 15a.5.5 0 0 0 .5-.5V2.707l3.146 3.147a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 1 0 .708.708L7.5 2.707V14.5a.5.5 0 0 0 .5.5z"/></svg>`
}
}
const qs = new URLSearchParams({ ...PARAMS, order, sort: item.name }).toString();
@@ -375,9 +386,8 @@ function addPath(file, index) {
}
if (DATA.allow_delete) {
if (DATA.allow_upload) {
if (isDir) {
actionMove = `<div onclick="movePath(${index})" class="action-btn" id="moveBtn${index}" title="Move to new path">${ICONS.move}</div>`;
} else {
if (!isDir) {
actionEdit = `<a class="action-btn" title="Edit file" target="_blank" href="${url}?edit">${ICONS.edit}</a>`;
}
}
@@ -388,8 +398,8 @@ function addPath(file, index) {
<td class="cell-actions">
${actionDownload}
${actionMove}
${actionEdit}
${actionDelete}
${actionEdit}
</td>`
$pathsTableBody.insertAdjacentHTML("beforeend", `
@@ -433,11 +443,18 @@ function setupDropzone() {
function setupAuth() {
if (DATA.user) {
$userBtn.classList.remove("hidden");
$userBtn.title = DATA.user;
$userName.textContent = DATA.user;
} else {
const $loginBtn = document.querySelector(".login-btn");
$loginBtn.classList.remove("hidden");
$loginBtn.addEventListener("click", () => login(true));
$loginBtn.addEventListener("click", async () => {
try {
await checkAuth()
location.reload();
} catch (err) {
alert(err.message);
}
});
}
}
@@ -522,7 +539,7 @@ async function setupEditPage() {
$notEditable.insertAdjacentHTML("afterend", `<iframe src="${url}" sandbox width="100%" height="${window.innerHeight - 100}px"></iframe>`)
} else {
$notEditable.classList.remove("hidden");
$notEditable.textContent = "Cannot edit because it is too large or binary.";
$notEditable.textContent = "Cannot edit because file is too large or binary.";
}
return;
}
@@ -535,8 +552,15 @@ async function setupEditPage() {
try {
const res = await fetch(baseUrl());
await assertResOK(res);
const text = await res.text();
$editor.value = text;
const encoding = getEncoding(res.headers.get("content-type"));
if (encoding === "utf-8") {
$editor.value = await res.text();
} else {
const bytes = await res.arrayBuffer();
const dataView = new DataView(bytes)
const decoder = new TextDecoder(encoding)
$editor.value = decoder.decode(dataView);
}
} catch (err) {
alert(`Failed get file, ${err.message}`);
}
@@ -564,7 +588,7 @@ async function deletePath(index) {
async function doDeletePath(name, url, cb) {
if (!confirm(`Delete \`${name}\`?`)) return;
try {
await login();
await checkAuth();
const res = await fetch(url, {
method: "DELETE",
});
@@ -604,12 +628,12 @@ async function doMovePath(fileUrl) {
const newFileUrl = fileUrlObj.origin + prefix + newPath.split("/").map(encodeURIComponent).join("/");
try {
await login();
await checkAuth();
const res1 = await fetch(newFileUrl, {
method: "HEAD",
});
if (res1.status === 200) {
if (!confirm("Override exsis file?")) {
if (!confirm("Override existing file?")) {
return;
}
}
@@ -636,29 +660,21 @@ async function saveChange() {
method: "PUT",
body: $editor.value,
});
location.reload();
} catch (err) {
alert(`Failed to save file, ${err.message}`);
}
}
async function login(alert = false) {
async function checkAuth() {
if (!DATA.auth) return;
try {
const res = await fetch(baseUrl(), {
method: "WRITEABLE",
});
await assertResOK(res);
document.querySelector(".login-btn").classList.add("hidden");
$userBtn.classList.remove("hidden");
$userBtn.title = "";
} catch (err) {
let message = `Cannot login, ${err.message}`;
if (alert) {
alert(message);
} else {
throw new Error(message);
}
}
$userName.textContent = "";
}
/**
@@ -668,7 +684,7 @@ async function login(alert = false) {
async function createFolder(name) {
const url = newUrl(name);
try {
await login();
await checkAuth();
const res = await fetch(url, {
method: "MKCOL",
});
@@ -682,7 +698,7 @@ async function createFolder(name) {
async function createFile(name) {
const url = newUrl(name);
try {
await login();
await checkAuth();
const res = await fetch(url, {
method: "PUT",
body: "",
@@ -766,7 +782,11 @@ function formatSize(size) {
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
if (size == 0) return [0, "B"];
const i = parseInt(Math.floor(Math.log(size) / Math.log(1024)));
return [Math.round(size / Math.pow(1024, i), 2), sizes[i]];
ratio = 1
if (i >= 3) {
ratio = 100
}
return [Math.round(size * ratio / Math.pow(1024, i), 2) / ratio, sizes[i]];
}
function formatDuration(seconds) {
@@ -793,6 +813,17 @@ function encodedStr(rawStr) {
async function assertResOK(res) {
if (!(res.status >= 200 && res.status < 300)) {
throw new Error(await res.text())
throw new Error(await res.text() || `Invalid status ${res.status}`);
}
}
function getEncoding(contentType) {
const charset = contentType?.split(";")[1];
if (/charset/i.test(charset)) {
let encoding = charset.split("=")[1];
if (encoding) {
return encoding.toLowerCase()
}
}
return 'utf-8'
}

View File

@@ -1,18 +1,14 @@
use anyhow::{anyhow, bail, Result};
use anyhow::{bail, Context, Result};
use clap::builder::PossibleValuesParser;
use clap::{value_parser, Arg, ArgAction, ArgMatches, Command};
use clap_complete::{generate, Generator, Shell};
#[cfg(feature = "tls")]
use rustls::{Certificate, PrivateKey};
use serde::{Deserialize, Deserializer};
use std::env;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use crate::auth::AccessControl;
use crate::auth::AuthMethod;
use crate::log_http::{LogHttp, DEFAULT_LOG_FORMAT};
#[cfg(feature = "tls")]
use crate::tls::{load_certs, load_private_key};
use crate::http_logger::HttpLogger;
use crate::utils::encode_uri;
pub fn build_cli() -> Command {
@@ -25,12 +21,20 @@ pub fn build_cli() -> Command {
env!("CARGO_PKG_REPOSITORY")
))
.arg(
Arg::new("root")
.env("DUFS_ROOT")
Arg::new("serve-path")
.env("DUFS_SERVE_PATH")
.hide_env(true)
.default_value(".")
.value_parser(value_parser!(PathBuf))
.help("Specific path to serve"),
.help("Specific path to serve [default: .]"),
)
.arg(
Arg::new("config")
.env("DUFS_CONFIG")
.hide_env(true)
.short('c')
.long("config")
.value_parser(value_parser!(PathBuf))
.help("Specify configuration file"),
)
.arg(
Arg::new("bind")
@@ -49,9 +53,8 @@ pub fn build_cli() -> Command {
.hide_env(true)
.short('p')
.long("port")
.default_value("5000")
.value_parser(value_parser!(u16))
.help("Specify port to listen on")
.help("Specify port to listen on [default: 5000]")
.value_name("port"),
)
.arg(
@@ -67,7 +70,7 @@ pub fn build_cli() -> Command {
.env("DUFS_HIDDEN")
.hide_env(true)
.long("hidden")
.help("Hide paths from directory listings, separated by `,`")
.help("Hide paths from directory listings, e.g. tmp,*.log,*.lock")
.value_name("value"),
)
.arg(
@@ -76,13 +79,14 @@ pub fn build_cli() -> Command {
.hide_env(true)
.short('a')
.long("auth")
.help("Add auth for path")
.help("Add auth roles, e.g. user:pass@/dir1:rw,/dir2")
.action(ArgAction::Append)
.value_delimiter(',')
.value_delimiter('|')
.value_name("rules"),
)
.arg(
Arg::new("auth-method")
.hide(true)
.env("DUFS_AUTH_METHOD")
.hide_env(true)
.long("auth-method")
@@ -177,9 +181,24 @@ pub fn build_cli() -> Command {
.env("DUFS_ASSETS")
.hide_env(true)
.long("assets")
.help("Use custom assets to override builtin assets")
.help("Set the path to the assets directory for overriding the built-in assets")
.value_parser(value_parser!(PathBuf))
.value_name("path")
)
.arg(
Arg::new("log-format")
.env("DUFS_LOG_FORMAT")
.hide_env(true)
.long("log-format")
.value_name("format")
.help("Customize http log format"),
)
.arg(
Arg::new("completions")
.long("completions")
.value_name("shell")
.value_parser(value_parser!(Shell))
.help("Print shell completion script for <shell>"),
);
#[cfg(feature = "tls")]
@@ -203,38 +222,32 @@ pub fn build_cli() -> Command {
.help("Path to the SSL/TLS certificate's private key"),
);
app.arg(
Arg::new("log-format")
.env("DUFS_LOG_FORMAT")
.hide_env(true)
.long("log-format")
.value_name("format")
.help("Customize http log format"),
)
.arg(
Arg::new("completions")
.long("completions")
.value_name("shell")
.value_parser(value_parser!(Shell))
.help("Print shell completion script for <shell>"),
)
app
}
pub fn print_completions<G: Generator>(gen: G, cmd: &mut Command) {
generate(gen, cmd, cmd.get_name().to_string(), &mut std::io::stdout());
}
#[derive(Debug)]
#[derive(Debug, Deserialize, Default)]
#[serde(default)]
#[serde(rename_all = "kebab-case")]
pub struct Args {
#[serde(default = "default_serve_path")]
pub serve_path: PathBuf,
#[serde(deserialize_with = "deserialize_bind_addrs")]
#[serde(rename = "bind")]
pub addrs: Vec<BindAddr>,
pub port: u16,
pub path: PathBuf,
#[serde(skip)]
pub path_is_file: bool,
pub path_prefix: String,
#[serde(skip)]
pub uri_prefix: String,
pub hidden: Vec<String>,
pub auth_method: AuthMethod,
#[serde(deserialize_with = "deserialize_access_control")]
pub auth: AccessControl,
pub allow_all: bool,
pub allow_upload: bool,
pub allow_delete: bool,
pub allow_search: bool,
@@ -244,12 +257,12 @@ pub struct Args {
pub render_spa: bool,
pub render_try_index: bool,
pub enable_cors: bool,
pub assets_path: Option<PathBuf>,
pub log_http: LogHttp,
#[cfg(feature = "tls")]
pub tls: Option<(Vec<Certificate>, PrivateKey)>,
#[cfg(not(feature = "tls"))]
pub tls: Option<()>,
pub assets: Option<PathBuf>,
#[serde(deserialize_with = "deserialize_log_http")]
#[serde(rename = "log-format")]
pub http_logger: HttpLogger,
pub tls_cert: Option<PathBuf>,
pub tls_key: Option<PathBuf>,
}
impl Args {
@@ -258,95 +271,164 @@ impl Args {
/// If a parsing error occurred, exit the process and print out informative
/// error message to user.
pub fn parse(matches: ArgMatches) -> Result<Args> {
let port = *matches.get_one::<u16>("port").unwrap();
let addrs = matches
.get_many::<String>("bind")
.map(|bind| bind.map(|v| v.as_str()).collect())
.unwrap_or_else(|| vec!["0.0.0.0", "::"]);
let addrs: Vec<BindAddr> = Args::parse_addrs(&addrs)?;
let path = Args::parse_path(matches.get_one::<PathBuf>("root").unwrap())?;
let path_is_file = path.metadata()?.is_file();
let path_prefix = matches
.get_one::<String>("path-prefix")
.map(|v| v.trim_matches('/').to_owned())
.unwrap_or_default();
let uri_prefix = if path_prefix.is_empty() {
let mut args = Self {
serve_path: default_serve_path(),
addrs: BindAddr::parse_addrs(&["0.0.0.0", "::"]).unwrap(),
port: 5000,
..Default::default()
};
if let Some(config_path) = matches.get_one::<PathBuf>("config") {
let contents = std::fs::read_to_string(config_path)
.with_context(|| format!("Failed to read config at {}", config_path.display()))?;
args = serde_yaml::from_str(&contents)
.with_context(|| format!("Failed to load config at {}", config_path.display()))?;
}
if let Some(path) = matches.get_one::<PathBuf>("serve-path") {
args.serve_path = path.clone()
}
args.serve_path = Self::sanitize_path(args.serve_path)?;
if let Some(port) = matches.get_one::<u16>("port") {
args.port = *port
}
if let Some(addrs) = matches.get_many::<String>("bind") {
let addrs: Vec<_> = addrs.map(|v| v.as_str()).collect();
args.addrs = BindAddr::parse_addrs(&addrs)?;
}
args.path_is_file = args.serve_path.metadata()?.is_file();
if let Some(path_prefix) = matches.get_one::<String>("path-prefix") {
args.path_prefix = path_prefix.clone();
}
args.path_prefix = args.path_prefix.trim_matches('/').to_string();
args.uri_prefix = if args.path_prefix.is_empty() {
"/".to_owned()
} else {
format!("/{}/", &encode_uri(&path_prefix))
format!("/{}/", &encode_uri(&args.path_prefix))
};
let hidden: Vec<String> = matches
if let Some(hidden) = matches
.get_one::<String>("hidden")
.map(|v| v.split(',').map(|x| x.to_string()).collect())
.unwrap_or_default();
let enable_cors = matches.get_flag("enable-cors");
let auth: Vec<&str> = matches
.get_many::<String>("auth")
.map(|auth| auth.map(|v| v.as_str()).collect())
.unwrap_or_default();
let auth_method = match matches.get_one::<String>("auth-method").unwrap().as_str() {
"basic" => AuthMethod::Basic,
_ => AuthMethod::Digest,
};
let auth = AccessControl::new(&auth, &uri_prefix)?;
let allow_upload = matches.get_flag("allow-all") || matches.get_flag("allow-upload");
let allow_delete = matches.get_flag("allow-all") || matches.get_flag("allow-delete");
let allow_search = matches.get_flag("allow-all") || matches.get_flag("allow-search");
let allow_symlink = matches.get_flag("allow-all") || matches.get_flag("allow-symlink");
let allow_archive = matches.get_flag("allow-all") || matches.get_flag("allow-archive");
let render_index = matches.get_flag("render-index");
let render_try_index = matches.get_flag("render-try-index");
let render_spa = matches.get_flag("render-spa");
{
args.hidden = hidden;
}
if !args.enable_cors {
args.enable_cors = matches.get_flag("enable-cors");
}
if let Some(rules) = matches.get_many::<String>("auth") {
let rules: Vec<_> = rules.map(|v| v.as_str()).collect();
args.auth = AccessControl::new(&rules)?;
}
if !args.allow_all {
args.allow_all = matches.get_flag("allow-all");
}
let allow_all = args.allow_all;
if !args.allow_upload {
args.allow_upload = allow_all || matches.get_flag("allow-upload");
}
if !args.allow_delete {
args.allow_delete = allow_all || matches.get_flag("allow-delete");
}
if !args.allow_search {
args.allow_search = allow_all || matches.get_flag("allow-search");
}
if !args.allow_symlink {
args.allow_symlink = allow_all || matches.get_flag("allow-symlink");
}
if !args.allow_archive {
args.allow_archive = allow_all || matches.get_flag("allow-archive");
}
if !args.render_index {
args.render_index = matches.get_flag("render-index");
}
if !args.render_try_index {
args.render_try_index = matches.get_flag("render-try-index");
}
if !args.render_spa {
args.render_spa = matches.get_flag("render-spa");
}
if let Some(log_format) = matches.get_one::<String>("log-format") {
args.http_logger = log_format.parse()?;
}
if let Some(assets_path) = matches.get_one::<PathBuf>("assets") {
args.assets = Some(assets_path.clone());
}
if let Some(assets_path) = &args.assets {
args.assets = Some(Args::sanitize_assets_path(assets_path)?);
}
#[cfg(feature = "tls")]
let tls = match (
matches.get_one::<PathBuf>("tls-cert"),
matches.get_one::<PathBuf>("tls-key"),
) {
(Some(certs_file), Some(key_file)) => {
let certs = load_certs(certs_file)?;
let key = load_private_key(key_file)?;
Some((certs, key))
{
if let Some(tls_cert) = matches.get_one::<PathBuf>("tls-cert") {
args.tls_cert = Some(tls_cert.clone())
}
if let Some(tls_key) = matches.get_one::<PathBuf>("tls-key") {
args.tls_key = Some(tls_key.clone())
}
match (&args.tls_cert, &args.tls_key) {
(Some(_), Some(_)) => {}
(Some(_), _) => bail!("No tls-key set"),
(_, Some(_)) => bail!("No tls-cert set"),
(None, None) => {}
}
}
_ => None,
};
#[cfg(not(feature = "tls"))]
let tls = None;
let log_http: LogHttp = matches
.get_one::<String>("log-format")
.map(|v| v.as_str())
.unwrap_or(DEFAULT_LOG_FORMAT)
.parse()?;
let assets_path = match matches.get_one::<PathBuf>("assets") {
Some(v) => Some(Args::parse_assets_path(v)?),
None => None,
};
Ok(Args {
addrs,
port,
path,
path_is_file,
path_prefix,
uri_prefix,
hidden,
auth_method,
auth,
enable_cors,
allow_delete,
allow_upload,
allow_search,
allow_symlink,
allow_archive,
render_index,
render_try_index,
render_spa,
tls,
log_http,
assets_path,
})
{
args.tls_cert = None;
args.tls_key = None;
}
fn parse_addrs(addrs: &[&str]) -> Result<Vec<BindAddr>> {
Ok(args)
}
fn sanitize_path<P: AsRef<Path>>(path: P) -> Result<PathBuf> {
let path = path.as_ref();
if !path.exists() {
bail!("Path `{}` doesn't exist", path.display());
}
env::current_dir()
.and_then(|mut p| {
p.push(path); // If path is absolute, it replaces the current path.
std::fs::canonicalize(p)
})
.with_context(|| format!("Failed to access path `{}`", path.display()))
}
fn sanitize_assets_path<P: AsRef<Path>>(path: P) -> Result<PathBuf> {
let path = Self::sanitize_path(path)?;
if !path.join("index.html").exists() {
bail!("Path `{}` doesn't contains index.html", path.display());
}
Ok(path)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum BindAddr {
Address(IpAddr),
Path(PathBuf),
}
impl BindAddr {
fn parse_addrs(addrs: &[&str]) -> Result<Vec<Self>> {
let mut bind_addrs = vec![];
let mut invalid_addrs = vec![];
for addr in addrs {
@@ -368,32 +450,32 @@ impl Args {
}
Ok(bind_addrs)
}
fn parse_path<P: AsRef<Path>>(path: P) -> Result<PathBuf> {
let path = path.as_ref();
if !path.exists() {
bail!("Path `{}` doesn't exist", path.display());
}
env::current_dir()
.and_then(|mut p| {
p.push(path); // If path is absolute, it replaces the current path.
std::fs::canonicalize(p)
})
.map_err(|err| anyhow!("Failed to access path `{}`: {}", path.display(), err,))
}
fn parse_assets_path<P: AsRef<Path>>(path: P) -> Result<PathBuf> {
let path = Self::parse_path(path)?;
if !path.join("index.html").exists() {
bail!("Path `{}` doesn't contains index.html", path.display());
}
Ok(path)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum BindAddr {
Address(IpAddr),
Path(PathBuf),
fn deserialize_bind_addrs<'de, D>(deserializer: D) -> Result<Vec<BindAddr>, D::Error>
where
D: Deserializer<'de>,
{
let addrs: Vec<&str> = Vec::deserialize(deserializer)?;
BindAddr::parse_addrs(&addrs).map_err(serde::de::Error::custom)
}
fn deserialize_access_control<'de, D>(deserializer: D) -> Result<AccessControl, D::Error>
where
D: Deserializer<'de>,
{
let rules: Vec<&str> = Vec::deserialize(deserializer)?;
AccessControl::new(&rules).map_err(serde::de::Error::custom)
}
fn deserialize_log_http<'de, D>(deserializer: D) -> Result<HttpLogger, D::Error>
where
D: Deserializer<'de>,
{
let value: String = Deserialize::deserialize(deserializer)?;
value.parse().map_err(serde::de::Error::custom)
}
fn default_serve_path() -> PathBuf {
PathBuf::from(".")
}

View File

@@ -2,15 +2,19 @@ use anyhow::{anyhow, bail, Result};
use base64::{engine::general_purpose, Engine as _};
use headers::HeaderValue;
use hyper::Method;
use indexmap::IndexMap;
use lazy_static::lazy_static;
use md5::Context;
use std::collections::HashMap;
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use uuid::Uuid;
use crate::utils::{encode_uri, unix_now};
use crate::{args::Args, utils::unix_now};
const REALM: &str = "DUFS";
const DIGEST_AUTH_TIMEOUT: u32 = 86400;
const DIGEST_AUTH_TIMEOUT: u32 = 604800; // 7 days
lazy_static! {
static ref NONCESTARTHASH: Context = {
@@ -23,55 +27,76 @@ lazy_static! {
#[derive(Debug)]
pub struct AccessControl {
rules: HashMap<String, PathControl>,
use_hashed_password: bool,
users: IndexMap<String, (String, AccessPaths)>,
anony: Option<AccessPaths>,
}
#[derive(Debug)]
pub struct PathControl {
readwrite: Account,
readonly: Option<Account>,
share: bool,
impl Default for AccessControl {
fn default() -> Self {
AccessControl {
use_hashed_password: false,
anony: Some(AccessPaths::new(AccessPerm::ReadWrite)),
users: IndexMap::new(),
}
}
}
impl AccessControl {
pub fn new(raw_rules: &[&str], uri_prefix: &str) -> Result<Self> {
let mut rules = HashMap::default();
pub fn new(raw_rules: &[&str]) -> Result<Self> {
if raw_rules.is_empty() {
return Ok(Self { rules });
return Ok(Default::default());
}
let mut use_hashed_password = false;
let create_err = |v: &str| anyhow!("Invalid auth `{v}`");
let mut anony = None;
let mut anony_paths = vec![];
let mut users = IndexMap::new();
for rule in raw_rules {
let parts: Vec<&str> = rule.split('@').collect();
let create_err = || anyhow!("Invalid auth `{rule}`");
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);
let (user, list) = rule.split_once('@').ok_or_else(|| create_err(rule))?;
if user.is_empty() && anony.is_some() {
bail!("Invalid auth, duplicate anonymous rules");
}
[path, readwrite, readonly] => {
let (readonly, share) = if *readonly == "*" {
(None, true)
let mut paths = AccessPaths::default();
for value in list.trim_matches(',').split(',') {
let (path, perm) = match value.split_once(':') {
None => (value, AccessPerm::ReadOnly),
Some((path, "rw")) => (path, AccessPerm::ReadWrite),
_ => return Err(create_err(rule)),
};
if user.is_empty() {
anony_paths.push((path, perm));
}
paths.add(path, perm);
}
if user.is_empty() {
anony = Some(paths);
} else if let Some((user, pass)) = user.split_once(':') {
if user.is_empty() || pass.is_empty() {
return Err(create_err(rule));
}
if pass.starts_with("$6$") {
use_hashed_password = true;
}
users.insert(user.to_string(), (pass.to_string(), paths));
} 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()),
return Err(create_err(rule));
}
}
Ok(Self { rules })
for (path, perm) in anony_paths {
for (_, (_, paths)) in users.iter_mut() {
paths.add(path, perm)
}
}
Ok(Self {
use_hashed_password,
users,
anony,
})
}
pub fn valid(&self) -> bool {
!self.rules.is_empty()
pub fn exist(&self) -> bool {
!self.users.is_empty()
}
pub fn guard(
@@ -79,186 +104,208 @@ impl AccessControl {
path: &str,
method: &Method,
authorization: Option<&HeaderValue>,
auth_method: AuthMethod,
) -> GuardType {
if self.rules.is_empty() {
return GuardType::ReadWrite;
) -> (Option<String>, Option<AccessPaths>) {
if let Some(authorization) = authorization {
if let Some(user) = get_auth_user(authorization) {
if let Some((pass, paths)) = self.users.get(&user) {
if method == Method::OPTIONS {
return (Some(user), Some(AccessPaths::new(AccessPerm::ReadOnly)));
}
if check_auth(authorization, method.as_str(), &user, pass).is_some() {
return (Some(user), paths.find(path, !is_readonly_method(method)));
} else {
return (None, None);
}
}
}
}
if method == Method::OPTIONS {
return GuardType::ReadOnly;
return (None, Some(AccessPaths::new(AccessPerm::ReadOnly)));
}
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 let Some(paths) = self.anony.as_ref() {
return (None, paths.find(path, !is_readonly_method(method)));
}
}
}
}
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
(None, None)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum GuardType {
Reject,
ReadWrite,
ReadOnly,
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct AccessPaths {
perm: AccessPerm,
children: IndexMap<String, AccessPaths>,
}
impl GuardType {
pub fn is_reject(&self) -> bool {
*self == GuardType::Reject
impl AccessPaths {
pub fn new(perm: AccessPerm) -> Self {
Self {
perm,
..Default::default()
}
}
}
fn sanitize_path(path: &str, uri_prefix: &str) -> String {
let new_path = match (uri_prefix, path) {
("/", "/") => "/".into(),
(_, "/") => uri_prefix.trim_end_matches('/').into(),
_ => format!("{}{}", uri_prefix, path.trim_matches('/')),
};
encode_uri(&new_path)
}
pub fn perm(&self) -> AccessPerm {
self.perm
}
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 set_perm(&mut self, perm: AccessPerm) {
if self.perm < perm {
self.perm = perm
}
}
fn is_readonly_method(method: &Method) -> bool {
method == Method::GET
|| method == Method::OPTIONS
|| method == Method::HEAD
|| method.as_str() == "PROPFIND"
}
pub fn add(&mut self, path: &str, perm: AccessPerm) {
let path = path.trim_matches('/');
if path.is_empty() {
self.set_perm(perm);
} else {
let parts: Vec<&str> = path.split('/').collect();
self.add_impl(&parts, perm);
}
}
#[derive(Debug, Clone)]
struct Account {
user: String,
pass: String,
}
fn add_impl(&mut self, parts: &[&str], perm: AccessPerm) {
let parts_len = parts.len();
if parts_len == 0 {
self.set_perm(perm);
return;
}
let child = self.children.entry(parts[0].to_string()).or_default();
child.add_impl(&parts[1..], perm)
}
impl Account {
fn new(data: &str) -> Option<Self> {
let p: Vec<&str> = data.trim().split(':').collect();
if p.len() != 2 {
pub fn find(&self, path: &str, writable: bool) -> Option<AccessPaths> {
let parts: Vec<&str> = path
.trim_matches('/')
.split('/')
.filter(|v| !v.is_empty())
.collect();
let target = self.find_impl(&parts, self.perm)?;
if writable && !target.perm().readwrite() {
return None;
}
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()),
})
Some(target)
}
fn find_impl(&self, parts: &[&str], perm: AccessPerm) -> Option<AccessPaths> {
let perm = self.perm.max(perm);
if parts.is_empty() {
if perm.indexonly() {
return Some(self.clone());
} else {
return Some(AccessPaths::new(perm));
}
}
let child = match self.children.get(parts[0]) {
Some(v) => v,
None => {
if perm.indexonly() {
return None;
} else {
return Some(AccessPaths::new(perm));
}
}
};
child.find_impl(&parts[1..], perm)
}
pub fn child_paths(&self) -> Vec<&String> {
self.children.keys().collect()
}
pub fn leaf_paths(&self, base: &Path) -> Vec<PathBuf> {
if !self.perm().indexonly() {
return vec![base.to_path_buf()];
}
let mut output = vec![];
self.leaf_paths_impl(&mut output, base);
output
}
fn leaf_paths_impl(&self, output: &mut Vec<PathBuf>, base: &Path) {
for (name, child) in self.children.iter() {
let base = base.join(name);
if child.perm().indexonly() {
child.leaf_paths_impl(output, &base);
} else {
output.push(base)
}
}
}
}
#[derive(Debug, Clone)]
pub enum AuthMethod {
Basic,
Digest,
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum AccessPerm {
#[default]
IndexOnly,
ReadOnly,
ReadWrite,
}
impl AuthMethod {
pub fn www_auth(&self, stale: bool) -> Result<String> {
match self {
AuthMethod::Basic => Ok(format!("Basic realm=\"{REALM}\"")),
AuthMethod::Digest => {
let str_stale = if stale { "stale=true," } else { "" };
Ok(format!(
"Digest realm=\"{}\",nonce=\"{}\",{}qop=\"auth\"",
REALM,
create_nonce()?,
str_stale
))
impl AccessPerm {
pub fn readwrite(&self) -> bool {
self == &AccessPerm::ReadWrite
}
pub fn indexonly(&self) -> bool {
self == &AccessPerm::IndexOnly
}
}
pub fn get_user(&self, authorization: &HeaderValue) -> Option<String> {
match self {
AuthMethod::Basic => {
let value: Vec<u8> = general_purpose::STANDARD_NO_PAD
.decode(strip_prefix(authorization.as_bytes(), b"Basic ")?)
.ok()?;
}
pub fn www_authenticate(args: &Args) -> Result<HeaderValue> {
let value = if args.auth.use_hashed_password {
format!("Basic realm=\"{}\"", REALM)
} else {
let nonce = create_nonce()?;
format!(
"Digest realm=\"{}\", nonce=\"{}\", qop=\"auth\", Basic realm=\"{}\"",
REALM, nonce, REALM
)
};
Ok(HeaderValue::from_str(&value)?)
}
pub fn get_auth_user(authorization: &HeaderValue) -> Option<String> {
if let Some(value) = strip_prefix(authorization.as_bytes(), b"Basic ") {
let value: Vec<u8> = general_purpose::STANDARD.decode(value).ok()?;
let parts: Vec<&str> = std::str::from_utf8(&value).ok()?.split(':').collect();
Some(parts[0].to_string())
} else if let Some(value) = strip_prefix(authorization.as_bytes(), b"Digest ") {
let digest_map = to_headermap(value).ok()?;
let username = digest_map.get(b"username".as_ref())?;
std::str::from_utf8(username).map(|v| v.to_string()).ok()
} else {
None
}
AuthMethod::Digest => {
let digest_value = strip_prefix(authorization.as_bytes(), b"Digest ")?;
let digest_map = to_headermap(digest_value).ok()?;
digest_map
.get(b"username".as_ref())
.and_then(|b| std::str::from_utf8(b).ok())
.map(|v| v.to_string())
}
}
}
pub fn validate(
&self,
}
pub fn check_auth(
authorization: &HeaderValue,
method: &str,
auth_user: &str,
auth_pass: &str,
) -> Option<()> {
match self {
AuthMethod::Basic => {
let basic_value: Vec<u8> = general_purpose::STANDARD_NO_PAD
.decode(strip_prefix(authorization.as_bytes(), b"Basic ")?)
.ok()?;
let parts: Vec<&str> = std::str::from_utf8(&basic_value).ok()?.split(':').collect();
) -> Option<()> {
if let Some(value) = strip_prefix(authorization.as_bytes(), b"Basic ") {
let value: Vec<u8> = general_purpose::STANDARD.decode(value).ok()?;
let parts: Vec<&str> = std::str::from_utf8(&value).ok()?.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 {
if auth_pass.starts_with("$6$") {
if let Ok(()) = sha_crypt::sha512_check(parts[1], auth_pass) {
return Some(());
}
} else if parts[1] == auth_pass {
return Some(());
}
None
}
AuthMethod::Digest => {
let digest_value = strip_prefix(authorization.as_bytes(), b"Digest ")?;
let digest_map = to_headermap(digest_value).ok()?;
} else if let Some(value) = strip_prefix(authorization.as_bytes(), b"Digest ") {
let digest_map = to_headermap(value).ok()?;
if let (Some(username), Some(nonce), Some(user_response)) = (
digest_map
.get(b"username".as_ref())
@@ -273,6 +320,11 @@ impl AuthMethod {
if auth_user != username {
return None;
}
let mut h = Context::new();
h.consume(format!("{}:{}:{}", auth_user, REALM, auth_pass).as_bytes());
let auth_pass = format!("{:x}", h.compute());
let mut ha = Context::new();
ha.consume(method);
ha.consume(b":");
@@ -285,7 +337,7 @@ impl AuthMethod {
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(&auth_pass);
c.consume(b":");
c.consume(nonce);
c.consume(b":");
@@ -308,7 +360,7 @@ impl AuthMethod {
Some(r) => r,
None => {
let mut c = Context::new();
c.consume(auth_pass);
c.consume(&auth_pass);
c.consume(b":");
c.consume(nonce);
c.consume(b":");
@@ -317,13 +369,12 @@ impl AuthMethod {
}
};
if correct_response.as_bytes() == *user_response {
// grant access
return Some(());
}
}
None
}
}
} else {
None
}
}
@@ -355,6 +406,13 @@ fn validate_nonce(nonce: &[u8]) -> Result<bool> {
bail!("invalid nonce");
}
fn is_readonly_method(method: &Method) -> bool {
method == Method::GET
|| method == Method::OPTIONS
|| method == Method::HEAD
|| method.as_str() == "PROPFIND"
}
fn strip_prefix<'a>(search: &'a [u8], prefix: &[u8]) -> Option<&'a [u8]> {
let l = prefix.len();
if search.len() < l {
@@ -417,3 +475,54 @@ fn create_nonce() -> Result<String> {
let n = format!("{:08x}{:032x}", secs, h.compute());
Ok(n[..34].to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_access_paths() {
let mut paths = AccessPaths::default();
paths.add("/dir1", AccessPerm::ReadWrite);
paths.add("/dir2/dir1", AccessPerm::ReadWrite);
paths.add("/dir2/dir2", AccessPerm::ReadOnly);
paths.add("/dir2/dir3/dir1", AccessPerm::ReadWrite);
assert_eq!(
paths.leaf_paths(Path::new("/tmp")),
[
"/tmp/dir1",
"/tmp/dir2/dir1",
"/tmp/dir2/dir2",
"/tmp/dir2/dir3/dir1"
]
.iter()
.map(PathBuf::from)
.collect::<Vec<_>>()
);
assert_eq!(
paths
.find("dir2", false)
.map(|v| v.leaf_paths(Path::new("/tmp/dir2"))),
Some(
["/tmp/dir2/dir1", "/tmp/dir2/dir2", "/tmp/dir2/dir3/dir1"]
.iter()
.map(PathBuf::from)
.collect::<Vec<_>>()
)
);
assert_eq!(paths.find("dir2", true), None);
assert!(paths.find("dir1/file", true).is_some());
}
#[test]
fn test_access_paths_perm() {
let mut paths = AccessPaths::default();
assert_eq!(paths.perm(), AccessPerm::IndexOnly);
paths.set_perm(AccessPerm::ReadOnly);
assert_eq!(paths.perm(), AccessPerm::ReadOnly);
paths.set_perm(AccessPerm::ReadWrite);
assert_eq!(paths.perm(), AccessPerm::ReadWrite);
paths.set_perm(AccessPerm::ReadOnly);
assert_eq!(paths.perm(), AccessPerm::ReadWrite);
}
}

View File

@@ -1,14 +1,20 @@
use std::{collections::HashMap, str::FromStr, sync::Arc};
use std::{collections::HashMap, str::FromStr};
use crate::{args::Args, server::Request};
use crate::{auth::get_auth_user, server::Request};
pub const DEFAULT_LOG_FORMAT: &str = r#"$remote_addr "$request" $status"#;
#[derive(Debug)]
pub struct LogHttp {
pub struct HttpLogger {
elements: Vec<LogElement>,
}
impl Default for HttpLogger {
fn default() -> Self {
DEFAULT_LOG_FORMAT.parse().unwrap()
}
}
#[derive(Debug)]
enum LogElement {
Variable(String),
@@ -16,8 +22,8 @@ enum LogElement {
Literal(String),
}
impl LogHttp {
pub fn data(&self, req: &Request, args: &Arc<Args>) -> HashMap<String, String> {
impl HttpLogger {
pub fn data(&self, req: &Request) -> HashMap<String, String> {
let mut data = HashMap::default();
for element in self.elements.iter() {
match element {
@@ -26,10 +32,8 @@ impl LogHttp {
data.insert(name.to_string(), format!("{} {}", req.method(), req.uri()));
}
"remote_user" => {
if let Some(user) = req
.headers()
.get("authorization")
.and_then(|v| args.auth_method.get_user(v))
if let Some(user) =
req.headers().get("authorization").and_then(get_auth_user)
{
data.insert(name.to_string(), user);
}
@@ -66,7 +70,7 @@ impl LogHttp {
}
}
impl FromStr for LogHttp {
impl FromStr for HttpLogger {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut elements = vec![];

View File

@@ -1,6 +1,6 @@
mod args;
mod auth;
mod log_http;
mod http_logger;
mod logger;
mod server;
mod streamer;
@@ -16,9 +16,9 @@ extern crate log;
use crate::args::{build_cli, print_completions, Args};
use crate::server::{Request, Server};
#[cfg(feature = "tls")]
use crate::tls::{TlsAcceptor, TlsStream};
use crate::tls::{load_certs, load_private_key, TlsAcceptor, TlsStream};
use anyhow::{anyhow, Result};
use anyhow::{anyhow, Context, Result};
use std::net::{IpAddr, SocketAddr, TcpListener as StdTcpListener};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
@@ -35,14 +35,7 @@ use hyper::service::{make_service_fn, service_fn};
use rustls::ServerConfig;
#[tokio::main]
async fn main() {
run().await.unwrap_or_else(|err| {
eprintln!("error: {err}");
std::process::exit(1);
})
}
async fn run() -> Result<()> {
async fn main() -> Result<()> {
logger::init().map_err(|e| anyhow!("Failed to init logger, {e}"))?;
let cmd = build_cli();
let matches = cmd.get_matches();
@@ -94,10 +87,13 @@ fn serve(
match bind_addr {
BindAddr::Address(ip) => {
let incoming = create_addr_incoming(SocketAddr::new(*ip, port))
.map_err(|e| anyhow!("Failed to bind `{ip}:{port}`, {e}"))?;
match args.tls.as_ref() {
.with_context(|| format!("Failed to bind `{ip}:{port}`"))?;
match (&args.tls_cert, &args.tls_key) {
#[cfg(feature = "tls")]
Some((certs, key)) => {
(Some(cert_file), Some(key_file)) => {
let certs = load_certs(cert_file)?;
let key = load_private_key(key_file)?;
let config = ServerConfig::builder()
.with_safe_defaults()
.with_no_client_auth()
@@ -112,11 +108,7 @@ fn serve(
tokio::spawn(hyper::Server::builder(accepter).serve(new_service));
handles.push(server);
}
#[cfg(not(feature = "tls"))]
Some(_) => {
unreachable!()
}
None => {
(None, None) => {
let new_service = make_service_fn(move |socket: &AddrStream| {
let remote_addr = socket.remote_addr();
serve_func(Some(remote_addr))
@@ -125,6 +117,9 @@ fn serve(
tokio::spawn(hyper::Server::builder(incoming).serve(new_service));
handles.push(server);
}
_ => {
unreachable!()
}
};
}
BindAddr::Path(path) => {
@@ -134,7 +129,7 @@ fn serve(
#[cfg(unix)]
{
let listener = tokio::net::UnixListener::bind(path)
.map_err(|e| anyhow!("Failed to bind `{}`, {e}", path.display()))?;
.with_context(|| format!("Failed to bind `{}`", path.display()))?;
let acceptor = unix::UnixAcceptor::from_listener(listener);
let new_service = make_service_fn(move |_| serve_func(None));
let server = tokio::spawn(hyper::Server::builder(acceptor).serve(new_service));
@@ -181,8 +176,8 @@ fn print_listening(args: Arc<Args>) -> Result<()> {
}
}
if ipv4 || ipv6 {
let ifaces = if_addrs::get_if_addrs()
.map_err(|e| anyhow!("Failed to get local interface addresses: {e}"))?;
let ifaces =
if_addrs::get_if_addrs().with_context(|| "Failed to get local interface addresses")?;
for iface in ifaces.into_iter() {
let local_ip = iface.ip();
if ipv4 && local_ip.is_ipv4() {
@@ -202,7 +197,11 @@ fn print_listening(args: Arc<Args>) -> Result<()> {
IpAddr::V4(_) => format!("{}:{}", addr, args.port),
IpAddr::V6(_) => format!("[{}]:{}", addr, args.port),
};
let protocol = if args.tls.is_some() { "https" } else { "http" };
let protocol = if args.tls_cert.is_some() {
"https"
} else {
"http"
};
format!("{}://{}{}", protocol, addr, args.uri_prefix)
}
BindAddr::Path(path) => path.display().to_string(),

View File

@@ -1,18 +1,23 @@
#![allow(clippy::too_many_arguments)]
use crate::auth::{www_authenticate, AccessPaths, AccessPerm};
use crate::streamer::Streamer;
use crate::utils::{decode_uri, encode_uri, get_file_name, glob, try_get_file_name};
use crate::utils::{
decode_uri, encode_uri, get_file_mtime_and_mode, get_file_name, glob, try_get_file_name,
};
use crate::Args;
use anyhow::{anyhow, Result};
use walkdir::WalkDir;
use xml::escape::escape_str_pcdata;
use async_zip::write::ZipFileWriter;
use async_zip::{Compression, ZipEntryBuilder};
use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipDateTime, ZipEntryBuilder};
use chrono::{LocalResult, TimeZone, Utc};
use futures::TryStreamExt;
use headers::{
AcceptRanges, AccessControlAllowCredentials, AccessControlAllowOrigin, Connection,
ContentLength, ContentType, ETag, HeaderMap, HeaderMapExt, IfModifiedSince, IfNoneMatch,
IfRange, LastModified, Range,
AcceptRanges, AccessControlAllowCredentials, AccessControlAllowOrigin, ContentLength,
ContentType, ETag, HeaderMap, HeaderMapExt, IfModifiedSince, IfNoneMatch, IfRange,
LastModified, Range,
};
use hyper::header::{
HeaderValue, AUTHORIZATION, CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE,
@@ -21,17 +26,19 @@ use hyper::header::{
use hyper::{Body, Method, StatusCode, Uri};
use serde::Serialize;
use std::borrow::Cow;
use std::cmp::Ordering;
use std::collections::HashMap;
use std::fs::Metadata;
use std::io::SeekFrom;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{self, AtomicBool};
use std::sync::Arc;
use std::time::SystemTime;
use tokio::fs::File;
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWrite};
use tokio::{fs, io};
use tokio_util::compat::FuturesAsyncWriteCompatExt;
use tokio_util::io::StreamReader;
use uuid::Uuid;
@@ -64,13 +71,13 @@ impl Server {
encode_uri(&format!(
"{}{}",
&args.uri_prefix,
get_file_name(&args.path)
get_file_name(&args.serve_path)
)),
]
} else {
vec![]
};
let html = match args.assets_path.as_ref() {
let html = match args.assets.as_ref() {
Some(path) => Cow::Owned(std::fs::read_to_string(path.join("index.html"))?),
None => Cow::Borrowed(INDEX_HTML),
};
@@ -89,9 +96,9 @@ impl Server {
addr: Option<SocketAddr>,
) -> Result<Response, hyper::Error> {
let uri = req.uri().clone();
let assets_prefix = self.assets_prefix.clone();
let assets_prefix = &self.assets_prefix;
let enable_cors = self.args.enable_cors;
let mut http_log_data = self.args.log_http.data(&req, &self.args);
let mut http_log_data = self.args.http_logger.data(&req);
if let Some(addr) = addr {
http_log_data.insert("remote_addr".to_string(), addr.ip().to_string());
}
@@ -99,8 +106,8 @@ impl Server {
let mut res = match self.clone().handle(req).await {
Ok(res) => {
http_log_data.insert("status".to_string(), res.status().as_u16().to_string());
if !uri.path().starts_with(&assets_prefix) {
self.args.log_http.log(&http_log_data, None);
if !uri.path().starts_with(assets_prefix) {
self.args.http_logger.log(&http_log_data, None);
}
res
}
@@ -110,7 +117,7 @@ impl Server {
*res.status_mut() = status;
http_log_data.insert("status".to_string(), status.as_u16().to_string());
self.args
.log_http
.http_logger
.log(&http_log_data, Some(err.to_string()));
res
}
@@ -134,16 +141,27 @@ impl Server {
}
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() {
let relative_path = match self.resolve_path(req_path) {
Some(v) => v,
None => {
status_forbid(&mut res);
return Ok(res);
}
};
let guard = self.args.auth.guard(&relative_path, &method, authorization);
let (user, access_paths) = match guard {
(None, None) => {
self.auth_reject(&mut res)?;
return Ok(res);
}
(Some(_), None) => {
status_forbid(&mut res);
return Ok(res);
}
(x, Some(y)) => (x, y),
};
let query = req.uri().query().unwrap_or_default();
let query_params: HashMap<String, String> = form_urlencoded::parse(query.as_bytes())
@@ -162,15 +180,14 @@ impl Server {
.iter()
.any(|v| v.as_str() == req_path)
{
self.handle_send_file(&self.args.path, headers, head_only, &mut res)
self.handle_send_file(&self.args.serve_path, headers, head_only, &mut res)
.await?;
} else {
status_not_found(&mut res);
}
return Ok(res);
}
let path = match self.extract_path(req_path) {
let path = match self.join_path(&relative_path) {
Some(v) => v,
None => {
status_forbid(&mut res);
@@ -207,31 +224,38 @@ impl Server {
status_not_found(&mut res);
return Ok(res);
}
self.handle_zip_dir(path, head_only, &mut res).await?;
self.handle_zip_dir(path, head_only, access_paths, &mut res)
.await?;
} else if allow_search && query_params.contains_key("q") {
let user = self.retrieve_user(authorization);
self.handle_search_dir(path, &query_params, head_only, user, &mut res)
self.handle_search_dir(
path,
&query_params,
head_only,
user,
access_paths,
&mut res,
)
.await?;
} else {
let user = self.retrieve_user(authorization);
self.handle_render_index(
path,
&query_params,
headers,
head_only,
user,
access_paths,
&mut res,
)
.await?;
}
} else if render_index || render_spa {
let user = self.retrieve_user(authorization);
self.handle_render_index(
path,
&query_params,
headers,
head_only,
user,
access_paths,
&mut res,
)
.await?;
@@ -240,19 +264,32 @@ impl Server {
status_not_found(&mut res);
return Ok(res);
}
self.handle_zip_dir(path, head_only, &mut res).await?;
self.handle_zip_dir(path, head_only, access_paths, &mut res)
.await?;
} else if allow_search && query_params.contains_key("q") {
let user = self.retrieve_user(authorization);
self.handle_search_dir(path, &query_params, head_only, user, &mut res)
self.handle_search_dir(
path,
&query_params,
head_only,
user,
access_paths,
&mut res,
)
.await?;
} else {
let user = self.retrieve_user(authorization);
self.handle_ls_dir(path, true, &query_params, head_only, user, &mut res)
self.handle_ls_dir(
path,
true,
&query_params,
head_only,
user,
access_paths,
&mut res,
)
.await?;
}
} else if is_file {
if query_params.contains_key("edit") {
let user = self.retrieve_user(authorization);
self.handle_edit_file(path, head_only, user, &mut res)
.await?;
} else {
@@ -263,8 +300,15 @@ impl Server {
self.handle_render_spa(path, headers, head_only, &mut res)
.await?;
} else if allow_upload && req_path.ends_with('/') {
let user = self.retrieve_user(authorization);
self.handle_ls_dir(path, false, &query_params, head_only, user, &mut res)
self.handle_ls_dir(
path,
false,
&query_params,
head_only,
user,
access_paths,
&mut res,
)
.await?;
} else {
status_not_found(&mut res);
@@ -292,7 +336,14 @@ impl Server {
method => match method.as_str() {
"PROPFIND" => {
if is_dir {
self.handle_propfind_dir(path, headers, &mut res).await?;
let access_paths = if access_paths.perm().indexonly() {
// see https://github.com/sigoden/dufs/issues/229
AccessPaths::new(AccessPerm::ReadOnly)
} else {
access_paths
};
self.handle_propfind_dir(path, headers, access_paths, &mut res)
.await?;
} else if is_file {
self.handle_propfind_file(path, &mut res).await?;
} else {
@@ -376,7 +427,12 @@ impl Server {
futures::pin_mut!(body_reader);
io::copy(&mut body_reader, &mut file).await?;
let ret = io::copy(&mut body_reader, &mut file).await;
if ret.is_err() {
tokio::fs::remove_file(&path).await?;
ret?;
}
*res.status_mut() = StatusCode::CREATED;
Ok(())
@@ -399,11 +455,12 @@ impl Server {
query_params: &HashMap<String, String>,
head_only: bool,
user: Option<String>,
access_paths: AccessPaths,
res: &mut Response,
) -> Result<()> {
let mut paths = vec![];
if exist {
paths = match self.list_dir(path, path).await {
paths = match self.list_dir(path, path, access_paths.clone()).await {
Ok(paths) => paths,
Err(_) => {
status_forbid(res);
@@ -411,7 +468,16 @@ impl Server {
}
}
};
self.send_index(path, paths, exist, query_params, head_only, user, res)
self.send_index(
path,
paths,
exist,
query_params,
head_only,
user,
access_paths,
res,
)
}
async fn handle_search_dir(
@@ -420,6 +486,7 @@ impl Server {
query_params: &HashMap<String, String>,
head_only: bool,
user: Option<String>,
access_paths: AccessPaths,
res: &mut Response,
) -> Result<()> {
let mut paths: Vec<PathItem> = vec![];
@@ -432,11 +499,14 @@ impl Server {
let hidden = Arc::new(self.args.hidden.to_vec());
let hidden = hidden.clone();
let running = self.running.clone();
let access_paths = access_paths.clone();
let search_paths = tokio::task::spawn_blocking(move || {
let mut it = WalkDir::new(&path_buf).into_iter();
let mut paths: Vec<PathBuf> = vec![];
for dir in access_paths.leaf_paths(&path_buf) {
let mut it = WalkDir::new(&dir).into_iter();
it.next();
while let Some(Ok(entry)) = it.next() {
if !running.load(Ordering::SeqCst) {
if !running.load(atomic::Ordering::SeqCst) {
break;
}
let entry_path = entry.path();
@@ -464,6 +534,7 @@ impl Server {
}
paths.push(entry_path.to_path_buf());
}
}
paths
})
.await?;
@@ -473,19 +544,28 @@ impl Server {
}
}
}
self.send_index(path, paths, true, query_params, head_only, user, res)
self.send_index(
path,
paths,
true,
query_params,
head_only,
user,
access_paths,
res,
)
}
async fn handle_zip_dir(&self, path: &Path, head_only: bool, res: &mut Response) -> Result<()> {
async fn handle_zip_dir(
&self,
path: &Path,
head_only: bool,
access_paths: AccessPaths,
res: &mut Response,
) -> Result<()> {
let (mut writer, reader) = tokio::io::duplex(BUF_SIZE);
let filename = try_get_file_name(path)?;
res.headers_mut().insert(
CONTENT_DISPOSITION,
HeaderValue::from_str(&format!(
"attachment; filename=\"{}.zip\"",
encode_uri(filename),
))?,
);
set_content_diposition(res, false, &format!("{}.zip", filename))?;
res.headers_mut()
.insert("content-type", HeaderValue::from_static("application/zip"));
if head_only {
@@ -495,7 +575,7 @@ impl Server {
let hidden = self.args.hidden.clone();
let running = self.running.clone();
tokio::spawn(async move {
if let Err(e) = zip_dir(&mut writer, &path, &hidden, running).await {
if let Err(e) = zip_dir(&mut writer, &path, access_paths, &hidden, running).await {
error!("Failed to zip {}, {}", path.display(), e);
}
});
@@ -511,6 +591,7 @@ impl Server {
headers: &HeaderMap<HeaderValue>,
head_only: bool,
user: Option<String>,
access_paths: AccessPaths,
res: &mut Response,
) -> Result<()> {
let index_path = path.join(INDEX_NAME);
@@ -523,7 +604,7 @@ impl Server {
self.handle_send_file(&index_path, headers, head_only, res)
.await?;
} else if self.args.render_try_index {
self.handle_ls_dir(path, true, query_params, head_only, user, res)
self.handle_ls_dir(path, true, query_params, head_only, user, access_paths, res)
.await?;
} else {
status_not_found(res)
@@ -539,7 +620,7 @@ impl Server {
res: &mut Response,
) -> Result<()> {
if path.extension().is_none() {
let path = self.args.path.join(INDEX_NAME);
let path = self.args.serve_path.join(INDEX_NAME);
self.handle_send_file(&path, headers, head_only, res)
.await?;
} else {
@@ -555,7 +636,7 @@ impl Server {
res: &mut Response,
) -> Result<bool> {
if let Some(name) = req_path.strip_prefix(&self.assets_prefix) {
match self.args.assets_path.as_ref() {
match self.args.assets.as_ref() {
Some(assets_path) => {
let path = assets_path.join(name);
self.handle_send_file(&path, headers, false, res).await?;
@@ -638,20 +719,13 @@ impl Server {
None
};
if let Some(mime) = mime_guess::from_path(path).first() {
res.headers_mut().typed_insert(ContentType::from(mime));
} else {
res.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("application/octet-stream"),
HeaderValue::from_str(&get_content_type(path).await?)?,
);
}
let filename = try_get_file_name(path)?;
res.headers_mut().insert(
CONTENT_DISPOSITION,
HeaderValue::from_str(&format!("inline; filename=\"{}\"", encode_uri(filename),))?,
);
set_content_diposition(res, true, filename)?;
res.headers_mut().typed_insert(AcceptRanges::bytes());
@@ -702,7 +776,10 @@ impl Server {
) -> Result<()> {
let (file, meta) = tokio::join!(fs::File::open(path), fs::metadata(path),);
let (file, meta) = (file?, meta?);
let href = format!("/{}", normalize_path(path.strip_prefix(&self.args.path)?));
let href = format!(
"/{}",
normalize_path(path.strip_prefix(&self.args.serve_path)?)
);
let mut buffer: Vec<u8> = vec![];
file.take(1024).read_to_end(&mut buffer).await?;
let editable = meta.len() <= TEXT_MAX_SIZE && content_inspector::inspect(&buffer).is_text();
@@ -712,7 +789,7 @@ impl Server {
uri_prefix: self.args.uri_prefix.clone(),
allow_upload: self.args.allow_upload,
allow_delete: self.args.allow_delete,
auth: self.args.auth.valid(),
auth: self.args.auth.exist(),
user,
editable,
};
@@ -720,7 +797,7 @@ impl Server {
.typed_insert(ContentType::from(mime_guess::mime::TEXT_HTML_UTF_8));
let output = self
.html
.replace("__ASSERTS_PREFIX__", &self.assets_prefix)
.replace("__ASSETS_PREFIX__", &self.assets_prefix)
.replace("__INDEX_DATA__", &serde_json::to_string(&data)?);
res.headers_mut()
.typed_insert(ContentLength(output.as_bytes().len() as u64));
@@ -735,6 +812,7 @@ impl Server {
&self,
path: &Path,
headers: &HeaderMap<HeaderValue>,
access_paths: AccessPaths,
res: &mut Response,
) -> Result<()> {
let depth: u32 = match headers.get("depth") {
@@ -747,12 +825,15 @@ impl Server {
},
None => 1,
};
let mut paths = match self.to_pathitem(path, &self.args.path).await? {
let mut paths = match self.to_pathitem(path, &self.args.serve_path).await? {
Some(v) => vec![v],
None => vec![],
};
if depth != 0 {
match self.list_dir(path, &self.args.path).await {
match self
.list_dir(path, &self.args.serve_path, access_paths)
.await
{
Ok(child) => paths.extend(child),
Err(_) => {
status_forbid(res);
@@ -772,7 +853,7 @@ impl Server {
}
async fn handle_propfind_file(&self, path: &Path, res: &mut Response) -> Result<()> {
if let Some(pathitem) = self.to_pathitem(path, &self.args.path).await? {
if let Some(pathitem) = self.to_pathitem(path, &self.args.serve_path).await? {
res_multistatus(res, &pathitem.to_dav_xml(self.args.uri_prefix.as_str()));
} else {
status_not_found(res);
@@ -872,17 +953,16 @@ impl Server {
query_params: &HashMap<String, String>,
head_only: bool,
user: Option<String>,
access_paths: AccessPaths,
res: &mut Response,
) -> Result<()> {
if let Some(sort) = query_params.get("sort") {
if sort == "name" {
paths.sort_by(|v1, v2| {
alphanumeric_sort::compare_str(v1.name.to_lowercase(), v2.name.to_lowercase())
})
paths.sort_by(|v1, v2| v1.sort_by_name(v2))
} else if sort == "mtime" {
paths.sort_by(|v1, v2| v1.mtime.cmp(&v2.mtime))
paths.sort_by(|v1, v2| v1.sort_by_mtime(v2))
} else if sort == "size" {
paths.sort_by(|v1, v2| v1.size.unwrap_or(0).cmp(&v2.size.unwrap_or(0)))
paths.sort_by(|v1, v2| v1.sort_by_size(v2))
}
if query_params
.get("order")
@@ -892,7 +972,7 @@ impl Server {
paths.reverse()
}
} else {
paths.sort_unstable();
paths.sort_by(|v1, v2| v1.sort_by_name(v2))
}
if query_params.contains_key("simple") {
let output = paths
@@ -916,17 +996,21 @@ impl Server {
}
return Ok(());
}
let href = format!("/{}", normalize_path(path.strip_prefix(&self.args.path)?));
let href = format!(
"/{}",
normalize_path(path.strip_prefix(&self.args.serve_path)?)
);
let readwrite = access_paths.perm().readwrite();
let data = IndexData {
kind: DataKind::Index,
href,
uri_prefix: self.args.uri_prefix.clone(),
allow_upload: self.args.allow_upload,
allow_delete: self.args.allow_delete,
allow_upload: self.args.allow_upload && readwrite,
allow_delete: self.args.allow_delete && readwrite,
allow_search: self.args.allow_search,
allow_archive: self.args.allow_archive,
dir_exists: exist,
auth: self.args.auth.valid(),
auth: self.args.auth.exist(),
user,
paths,
};
@@ -938,7 +1022,7 @@ impl Server {
res.headers_mut()
.typed_insert(ContentType::from(mime_guess::mime::TEXT_HTML_UTF_8));
self.html
.replace("__ASSERTS_PREFIX__", &self.assets_prefix)
.replace("__ASSETS_PREFIX__", &self.assets_prefix)
.replace("__INDEX_DATA__", &serde_json::to_string(&data)?)
};
res.headers_mut()
@@ -951,10 +1035,10 @@ impl Server {
}
fn auth_reject(&self, res: &mut Response) -> Result<()> {
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()?);
res.headers_mut()
.append(WWW_AUTHENTICATE, www_authenticate(&self.args)?);
// set 401 to make the browser pop up the login box
*res.status_mut() = StatusCode::UNAUTHORIZED;
Ok(())
}
@@ -963,7 +1047,7 @@ impl Server {
fs::canonicalize(path)
.await
.ok()
.map(|v| v.starts_with(&self.args.path))
.map(|v| v.starts_with(&self.args.serve_path))
.unwrap_or_default()
}
@@ -976,20 +1060,30 @@ impl Server {
return None;
}
};
let authorization = headers.get(AUTHORIZATION);
let guard_type = self.args.auth.guard(
&dest_path,
req.method(),
authorization,
self.args.auth_method.clone(),
);
if guard_type.is_reject() {
*res.status_mut() = StatusCode::FORBIDDEN;
*res.body_mut() = Body::from("Forbidden");
let relative_path = match self.resolve_path(&dest_path) {
Some(v) => v,
None => {
*res.status_mut() = StatusCode::BAD_REQUEST;
return None;
}
};
let dest = match self.extract_path(&dest_path) {
let authorization = headers.get(AUTHORIZATION);
let guard = self
.args
.auth
.guard(&relative_path, req.method(), authorization);
match guard {
(_, Some(_)) => {}
_ => {
status_forbid(res);
return None;
}
};
let dest = match self.join_path(&relative_path) {
Some(dest) => dest,
None => {
*res.status_mut() = StatusCode::BAD_REQUEST;
@@ -1006,49 +1100,61 @@ impl Server {
Some(uri.path().to_string())
}
fn extract_path(&self, path: &str) -> Option<PathBuf> {
let mut slash_stripped_path = path;
while let Some(p) = slash_stripped_path.strip_prefix('/') {
slash_stripped_path = p
fn resolve_path(&self, path: &str) -> Option<String> {
let path = path.trim_matches('/');
let path = decode_uri(path)?;
let prefix = self.args.path_prefix.as_str();
if prefix == "/" {
return Some(path.to_string());
}
let decoded_path = decode_uri(slash_stripped_path)?;
let slashes_switched = if cfg!(windows) {
decoded_path.replace('/', "\\")
} else {
decoded_path.into_owned()
};
let stripped_path = match self.strip_path_prefix(&slashes_switched) {
Some(path) => path,
None => return None,
};
Some(self.args.path.join(stripped_path))
path.strip_prefix(prefix.trim_start_matches('/'))
.map(|v| v.trim_matches('/').to_string())
}
fn strip_path_prefix<'a, P: AsRef<Path>>(&self, path: &'a P) -> Option<&'a Path> {
let path = path.as_ref();
if self.args.path_prefix.is_empty() {
Some(path)
} else {
path.strip_prefix(&self.args.path_prefix).ok()
fn join_path(&self, path: &str) -> Option<PathBuf> {
if path.is_empty() {
return Some(self.args.serve_path.clone());
}
let path = if cfg!(windows) {
path.replace('/', "\\")
} else {
path.to_string()
};
Some(self.args.serve_path.join(path))
}
async fn list_dir(&self, entry_path: &Path, base_path: &Path) -> Result<Vec<PathItem>> {
async fn list_dir(
&self,
entry_path: &Path,
base_path: &Path,
access_paths: AccessPaths,
) -> Result<Vec<PathItem>> {
let mut paths: Vec<PathItem> = vec![];
if access_paths.perm().indexonly() {
for name in access_paths.child_paths() {
let entry_path = entry_path.join(name);
self.add_pathitem(&mut paths, base_path, &entry_path).await;
}
} else {
let mut rd = fs::read_dir(entry_path).await?;
while let Ok(Some(entry)) = rd.next_entry().await {
let entry_path = entry.path();
let base_name = get_file_name(&entry_path);
if let Ok(Some(item)) = self.to_pathitem(entry_path.as_path(), base_path).await {
if is_hidden(&self.args.hidden, base_name, item.is_dir()) {
continue;
}
paths.push(item);
self.add_pathitem(&mut paths, base_path, &entry_path).await;
}
}
Ok(paths)
}
async fn add_pathitem(&self, paths: &mut Vec<PathItem>, base_path: &Path, entry_path: &Path) {
let base_name = get_file_name(entry_path);
if let Ok(Some(item)) = self.to_pathitem(entry_path, base_path).await {
if is_hidden(&self.args.hidden, base_name, item.is_dir()) {
return;
}
paths.push(item);
}
}
async fn to_pathitem<P: AsRef<Path>>(&self, path: P, base_path: P) -> Result<Option<PathItem>> {
let path = path.as_ref();
let (meta, meta2) = tokio::join!(fs::metadata(&path), fs::symlink_metadata(&path));
@@ -1078,10 +1184,6 @@ impl Server {
size,
}))
}
fn retrieve_user(&self, authorization: Option<&HeaderValue>) -> Option<String> {
self.args.auth_method.get_user(authorization?)
}
}
#[derive(Debug, Serialize)]
@@ -1174,12 +1276,45 @@ impl PathItem {
),
}
}
pub fn base_name(&self) -> &str {
self.name.split('/').last().unwrap_or_default()
}
pub fn sort_by_name(&self, other: &Self) -> Ordering {
match self.path_type.cmp(&other.path_type) {
Ordering::Equal => {
alphanumeric_sort::compare_str(self.name.to_lowercase(), other.name.to_lowercase())
}
v => v,
}
}
pub fn sort_by_mtime(&self, other: &Self) -> Ordering {
match self.path_type.cmp(&other.path_type) {
Ordering::Equal => self.mtime.cmp(&other.mtime),
v => v,
}
}
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))
}
}
v => v,
}
}
}
#[derive(Debug, Serialize, Eq, PartialEq, Ord, PartialOrd)]
#[derive(Debug, Serialize, Eq, PartialEq)]
enum PathType {
Dir,
SymlinkDir,
@@ -1187,6 +1322,24 @@ enum PathType {
SymlinkFile,
}
impl Ord for PathType {
fn cmp(&self, other: &Self) -> Ordering {
let to_value = |t: &Self| -> u8 {
if matches!(t, Self::Dir | Self::SymlinkDir) {
0
} else {
1
}
};
to_value(self).cmp(&to_value(other))
}
}
impl PartialOrd for PathType {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
fn to_timestamp(time: &SystemTime) -> u64 {
time.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
@@ -1218,17 +1371,15 @@ fn add_cors(res: &mut Response) {
.typed_insert(AccessControlAllowCredentials);
res.headers_mut().insert(
"Access-Control-Allow-Methods",
HeaderValue::from_static("GET,HEAD,PUT,OPTIONS,DELETE,PROPFIND,COPY,MOVE"),
HeaderValue::from_static("*"),
);
res.headers_mut().insert(
"Access-Control-Allow-Headers",
HeaderValue::from_static("Authorization,Destination,Range"),
HeaderValue::from_static("Authorization,*"),
);
res.headers_mut().insert(
"Access-Control-Expose-Headers",
HeaderValue::from_static(
"WWW-Authenticate,Content-Range,Accept-Ranges,Content-Disposition",
),
HeaderValue::from_static("Authorization,*"),
);
}
@@ -1249,18 +1400,21 @@ fn res_multistatus(res: &mut Response, content: &str) {
async fn zip_dir<W: AsyncWrite + Unpin>(
writer: &mut W,
dir: &Path,
access_paths: AccessPaths,
hidden: &[String],
running: Arc<AtomicBool>,
) -> Result<()> {
let mut writer = ZipFileWriter::new(writer);
let mut writer = ZipFileWriter::with_tokio(writer);
let hidden = Arc::new(hidden.to_vec());
let hidden = hidden.clone();
let dir_path_buf = dir.to_path_buf();
let dir_clone = dir.to_path_buf();
let zip_paths = tokio::task::spawn_blocking(move || {
let mut it = WalkDir::new(&dir_path_buf).into_iter();
let mut paths: Vec<PathBuf> = vec![];
for dir in access_paths.leaf_paths(&dir_clone) {
let mut it = WalkDir::new(&dir).into_iter();
it.next();
while let Some(Ok(entry)) = it.next() {
if !running.load(Ordering::SeqCst) {
if !running.load(atomic::Ordering::SeqCst) {
break;
}
let entry_path = entry.path();
@@ -1291,6 +1445,7 @@ async fn zip_dir<W: AsyncWrite + Unpin>(
}
paths.push(entry_path.to_path_buf());
}
}
paths
})
.await?;
@@ -1299,12 +1454,14 @@ async fn zip_dir<W: AsyncWrite + Unpin>(
Some(v) => v,
None => continue,
};
let builder =
ZipEntryBuilder::new(filename.into(), Compression::Deflate).unix_permissions(0o644);
let (datetime, mode) = get_file_mtime_and_mode(&zip_path).await?;
let builder = ZipEntryBuilder::new(filename.into(), Compression::Deflate)
.unix_permissions(mode)
.last_modification_date(ZipDateTime::from_chrono(&datetime));
let mut file = File::open(&zip_path).await?;
let mut file_writer = writer.write_entry_stream(builder).await?;
let mut file_writer = writer.write_entry_stream(builder).await?.compat_write();
io::copy(&mut file, &mut file_writer).await?;
file_writer.close().await?;
file_writer.into_inner().close().await?;
}
writer.close().await?;
Ok(())
@@ -1363,6 +1520,21 @@ fn status_no_content(res: &mut Response) {
*res.status_mut() = StatusCode::NO_CONTENT;
}
fn set_content_diposition(res: &mut Response, inline: bool, filename: &str) -> Result<()> {
let kind = if inline { "inline" } else { "attachment" };
let value = if filename.is_ascii() {
HeaderValue::from_str(&format!("{kind}; filename=\"{}\"", filename,))?
} else {
HeaderValue::from_str(&format!(
"{kind}; filename=\"{}\"; filename*=UTF-8''{}",
filename,
encode_uri(filename),
))?
};
res.headers_mut().insert(CONTENT_DISPOSITION, value);
Ok(())
}
fn is_hidden(hidden: &[String], file_name: &str, is_dir_type: bool) -> bool {
hidden.iter().any(|v| {
if is_dir_type {
@@ -1382,3 +1554,34 @@ fn set_webdav_headers(res: &mut Response) {
res.headers_mut()
.insert("DAV", HeaderValue::from_static("1,2"));
}
async fn get_content_type(path: &Path) -> Result<String> {
let mut buffer: Vec<u8> = vec![];
fs::File::open(path)
.await?
.take(1024)
.read_to_end(&mut buffer)
.await?;
let mime = mime_guess::from_path(path).first();
let is_text = content_inspector::inspect(&buffer).is_text();
let content_type = if is_text {
let mut detector = chardetng::EncodingDetector::new();
detector.feed(&buffer, buffer.len() < 1024);
let (enc, confident) = detector.guess_assess(None, true);
let charset = if confident {
format!("; charset={}", enc.name())
} else {
"".into()
};
match mime {
Some(m) => format!("{m}{charset}"),
None => format!("text/plain{charset}"),
}
} else {
match mime {
Some(m) => m.to_string(),
None => "application/octet-stream".into(),
}
};
Ok(content_type)
}

View File

@@ -1,4 +1,4 @@
use anyhow::{anyhow, bail, Result};
use anyhow::{anyhow, bail, Context as AnyhowContext, Result};
use core::task::{Context, Poll};
use futures::ready;
use hyper::server::accept::Accept;
@@ -128,12 +128,11 @@ impl Accept for TlsAcceptor {
pub fn load_certs<T: AsRef<Path>>(filename: T) -> Result<Vec<Certificate>> {
// Open certificate file.
let cert_file = fs::File::open(filename.as_ref())
.map_err(|e| anyhow!("Failed to access `{}`, {e}", filename.as_ref().display()))?;
.with_context(|| format!("Failed to access `{}`", filename.as_ref().display()))?;
let mut reader = io::BufReader::new(cert_file);
// Load and return certificate.
let certs =
rustls_pemfile::certs(&mut reader).map_err(|_| anyhow!("Failed to load certificate"))?;
let certs = rustls_pemfile::certs(&mut reader).with_context(|| "Failed to load certificate")?;
if certs.is_empty() {
bail!("No supported certificate in file");
}
@@ -143,12 +142,12 @@ pub fn load_certs<T: AsRef<Path>>(filename: T) -> Result<Vec<Certificate>> {
// Load private key from file.
pub fn load_private_key<T: AsRef<Path>>(filename: T) -> Result<PrivateKey> {
let key_file = fs::File::open(filename.as_ref())
.map_err(|e| anyhow!("Failed to access `{}`, {e}", filename.as_ref().display()))?;
.with_context(|| format!("Failed to access `{}`", filename.as_ref().display()))?;
let mut reader = io::BufReader::new(key_file);
// Load and return a single private key.
let keys = rustls_pemfile::read_all(&mut reader)
.map_err(|e| anyhow!("There was a problem with reading private key: {e}"))?
.with_context(|| "There was a problem with reading private key")?
.into_iter()
.find_map(|item| match item {
rustls_pemfile::Item::RSAKey(key)

View File

@@ -1,4 +1,5 @@
use anyhow::{anyhow, Result};
use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Utc};
use std::{
borrow::Cow,
path::Path,
@@ -8,7 +9,7 @@ use std::{
pub fn unix_now() -> Result<Duration> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|err| anyhow!("Invalid system time, {err}"))
.with_context(|| "Invalid system time")
}
pub fn encode_uri(v: &str) -> String {
@@ -28,51 +29,33 @@ pub fn get_file_name(path: &Path) -> &str {
.unwrap_or_default()
}
#[cfg(unix)]
pub async fn get_file_mtime_and_mode(path: &Path) -> Result<(DateTime<Utc>, u16)> {
use std::os::unix::prelude::MetadataExt;
let meta = tokio::fs::metadata(path).await?;
let datetime: DateTime<Utc> = meta.modified()?.into();
Ok((datetime, meta.mode() as u16))
}
#[cfg(not(unix))]
pub async fn get_file_mtime_and_mode(path: &Path) -> Result<(DateTime<Utc>, u16)> {
let meta = tokio::fs::metadata(&path).await?;
let datetime: DateTime<Utc> = meta.modified()?.into();
Ok((datetime, 0o644))
}
pub fn try_get_file_name(path: &Path) -> Result<&str> {
path.file_name()
.and_then(|v| v.to_str())
.ok_or_else(|| anyhow!("Failed to get file name of `{}`", path.display()))
}
pub fn glob(source: &str, target: &str) -> bool {
let ss: Vec<char> = source.chars().collect();
let mut iter = target.chars();
let mut i = 0;
'outer: while i < ss.len() {
let s = ss[i];
match s {
'*' => match ss.get(i + 1) {
Some(s_next) => {
for t in iter.by_ref() {
if t == *s_next {
i += 2;
continue 'outer;
}
}
return false;
}
None => return true,
},
'?' => match iter.next() {
Some(_) => {
i += 1;
continue;
}
None => return false,
},
_ => match iter.next() {
Some(t) => {
if s == t {
i += 1;
continue;
}
return false;
}
None => return false,
},
}
}
iter.next().is_none()
pub fn glob(pattern: &str, target: &str) -> bool {
let pat = match ::glob::Pattern::new(pattern) {
Ok(pat) => pat,
Err(_) => return false,
};
pat.matches(target)
}
#[test]
@@ -91,6 +74,10 @@ fn test_glob_key() {
assert!(!glob("abc", "abcd"));
assert!(!glob("a?c", "abbc"));
assert!(!glob("*.log", "log"));
assert!(glob("*.abc-cba", "xyz.abc-cba"));
assert!(glob("*.abc-cba", "123.xyz.abc-cba"));
assert!(glob("*.log", ".log"));
assert!(glob("*.log", "a.log"));
assert!(glob("*/", "abc/"));
assert!(!glob("*/", "abc"));
}

View File

@@ -3,10 +3,11 @@ mod utils;
use diqwest::blocking::WithDigestAuth;
use fixtures::{server, Error, TestServer};
use indexmap::IndexSet;
use rstest::rstest;
#[rstest]
fn no_auth(#[with(&["--auth", "/@user:pass", "-A"])] server: TestServer) -> Result<(), Error> {
fn no_auth(#[with(&["--auth", "user:pass@/:rw", "-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 +18,7 @@ fn no_auth(#[with(&["--auth", "/@user:pass", "-A"])] server: TestServer) -> Resu
}
#[rstest]
fn auth(#[with(&["--auth", "/@user:pass", "-A"])] server: TestServer) -> Result<(), Error> {
fn auth(#[with(&["--auth", "user:pass@/:rw", "-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);
@@ -28,8 +29,51 @@ fn auth(#[with(&["--auth", "/@user:pass", "-A"])] server: TestServer) -> Result<
Ok(())
}
const HASHED_PASSWORD_AUTH: &str = "user:$6$gQxZwKyWn/ZmWEA2$4uV7KKMnSUnET2BtWTj/9T5.Jq3h/MdkOlnIl5hdlTxDZ4MZKmJ.kl6C.NL9xnNPqC4lVHC1vuI0E5cLpTJX81@/:rw"; // user:pass
#[rstest]
fn auth_skip(#[with(&["--auth", "/@user:pass@*"])] server: TestServer) -> Result<(), Error> {
fn auth_hashed_password(
#[with(&["--auth", HASHED_PASSWORD_AUTH, "-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);
if let Err(err) = fetch!(b"PUT", &url)
.body(b"abc".to_vec())
.send_with_digest_auth("user", "pass")
{
assert_eq!(
format!("{err:?}"),
r#"DigestAuth(MissingRequired("realm", "Basic realm=\"DUFS\""))"#
);
}
let resp = fetch!(b"PUT", &url)
.body(b"abc".to_vec())
.basic_auth("user", Some("pass"))
.send()?;
assert_eq!(resp.status(), 201);
Ok(())
}
#[rstest]
fn auth_and_public(
#[with(&["--auth", "user:pass@/:rw|@/", "-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())
.send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 201);
let resp = fetch!(b"GET", &url).send()?;
assert_eq!(resp.status(), 200);
assert_eq!(resp.text()?, "abc");
Ok(())
}
#[rstest]
fn auth_skip(#[with(&["--auth", "@/"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?;
assert_eq!(resp.status(), 200);
Ok(())
@@ -37,7 +81,7 @@ fn auth_skip(#[with(&["--auth", "/@user:pass@*"])] server: TestServer) -> Result
#[rstest]
fn auth_skip_on_options_method(
#[with(&["--auth", "/@user:pass"])] server: TestServer,
#[with(&["--auth", "user:pass@/:rw"])] server: TestServer,
) -> Result<(), Error> {
let url = format!("{}index.html", server.url());
let resp = fetch!(b"OPTIONS", &url).send()?;
@@ -47,13 +91,13 @@ fn auth_skip_on_options_method(
#[rstest]
fn auth_check(
#[with(&["--auth", "/@user:pass@user2:pass2", "-A"])] server: TestServer,
#[with(&["--auth", "user:pass@/:rw|user2:pass2@/", "-A"])] server: TestServer,
) -> Result<(), Error> {
let url = format!("{}index.html", server.url());
let resp = fetch!(b"WRITEABLE", &url).send()?;
assert_eq!(resp.status(), 401);
let resp = fetch!(b"WRITEABLE", &url).send_with_digest_auth("user2", "pass2")?;
assert_eq!(resp.status(), 401);
assert_eq!(resp.status(), 403);
let resp = fetch!(b"WRITEABLE", &url).send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 200);
Ok(())
@@ -61,7 +105,7 @@ fn auth_check(
#[rstest]
fn auth_readonly(
#[with(&["--auth", "/@user:pass@user2:pass2", "-A"])] server: TestServer,
#[with(&["--auth", "user:pass@/:rw|user2:pass2@/", "-A"])] server: TestServer,
) -> Result<(), Error> {
let url = format!("{}index.html", server.url());
let resp = fetch!(b"GET", &url).send()?;
@@ -72,13 +116,13 @@ fn auth_readonly(
let resp = fetch!(b"PUT", &url)
.body(b"abc".to_vec())
.send_with_digest_auth("user2", "pass2")?;
assert_eq!(resp.status(), 401);
assert_eq!(resp.status(), 403);
Ok(())
}
#[rstest]
fn auth_nest(
#[with(&["--auth", "/@user:pass@user2:pass2", "--auth", "/dir1@user3:pass3", "-A"])]
#[with(&["--auth", "user:pass@/:rw|user2:pass2@/", "--auth", "user3:pass3@/dir1:rw", "-A"])]
server: TestServer,
) -> Result<(), Error> {
let url = format!("{}dir1/file1", server.url());
@@ -97,7 +141,8 @@ fn auth_nest(
#[rstest]
fn auth_nest_share(
#[with(&["--auth", "/@user:pass@*", "--auth", "/dir1@user3:pass3", "-A"])] server: TestServer,
#[with(&["--auth", "@/", "--auth", "user:pass@/:rw", "--auth", "user3:pass3@/dir1:rw", "-A"])]
server: TestServer,
) -> Result<(), Error> {
let url = format!("{}index.html", server.url());
let resp = fetch!(b"GET", &url).send()?;
@@ -106,15 +151,19 @@ fn auth_nest_share(
}
#[rstest]
#[case(server(&["--auth", "user:pass@/:rw", "-A"]), "user", "pass")]
#[case(server(&["--auth", "u1:p1@/:rw", "-A"]), "u1", "p1")]
fn auth_basic(
#[with(&["--auth", "/@user:pass", "--auth-method", "basic", "-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);
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(())
@@ -122,7 +171,8 @@ fn auth_basic(
#[rstest]
fn auth_webdav_move(
#[with(&["--auth", "/@user:pass@*", "--auth", "/dir1@user3:pass3", "-A"])] server: TestServer,
#[with(&["--auth", "user:pass@/:rw", "--auth", "user3:pass3@/dir1:rw", "-A"])]
server: TestServer,
) -> Result<(), Error> {
let origin_url = format!("{}dir1/test.html", server.url());
let new_url = format!("{}test2.html", server.url());
@@ -135,7 +185,8 @@ fn auth_webdav_move(
#[rstest]
fn auth_webdav_copy(
#[with(&["--auth", "/@user:pass@*", "--auth", "/dir1@user3:pass3", "-A"])] server: TestServer,
#[with(&["--auth", "user:pass@/:rw", "--auth", "user3:pass3@/dir1:rw", "-A"])]
server: TestServer,
) -> Result<(), Error> {
let origin_url = format!("{}dir1/test.html", server.url());
let new_url = format!("{}test2.html", server.url());
@@ -148,7 +199,7 @@ fn auth_webdav_copy(
#[rstest]
fn auth_path_prefix(
#[with(&["--auth", "/@user:pass", "--path-prefix", "xyz", "-A"])] server: TestServer,
#[with(&["--auth", "user:pass@/:rw", "--path-prefix", "xyz", "-A"])] server: TestServer,
) -> Result<(), Error> {
let url = format!("{}xyz/index.html", server.url());
let resp = fetch!(b"GET", &url).send()?;
@@ -157,3 +208,53 @@ fn auth_path_prefix(
assert_eq!(resp.status(), 200);
Ok(())
}
#[rstest]
fn auth_partial_index(
#[with(&["--auth", "user:pass@/dir1:rw,/dir2:rw", "-A"])] server: TestServer,
) -> Result<(), Error> {
let resp = fetch!(b"GET", server.url()).send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 200);
let paths = utils::retrieve_index_paths(&resp.text()?);
assert_eq!(paths, IndexSet::from(["dir1/".into(), "dir2/".into()]));
let resp = fetch!(b"GET", format!("{}?q={}", server.url(), "test.html"))
.send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 200);
let paths = utils::retrieve_index_paths(&resp.text()?);
assert_eq!(
paths,
IndexSet::from(["dir1/test.html".into(), "dir2/test.html".into()])
);
Ok(())
}
#[rstest]
fn no_auth_propfind_dir(
#[with(&["--auth", "user:pass@/:rw", "--auth", "@/dir-assets", "-A"])] server: TestServer,
) -> Result<(), Error> {
let resp = fetch!(b"PROPFIND", server.url()).send()?;
assert_eq!(resp.status(), 207);
let body = resp.text()?;
assert!(body.contains("<D:href>/dir-assets/</D:href>"));
assert!(body.contains("<D:href>/dir1/</D:href>"));
Ok(())
}
#[rstest]
fn auth_data(
#[with(&["--auth", "user:pass@/:rw|@/", "-A"])] server: TestServer,
) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?;
let content = resp.text()?;
let json = utils::retrive_json(&content).unwrap();
assert_eq!(json["allow_delete"], serde_json::Value::Bool(false));
assert_eq!(json["allow_upload"], serde_json::Value::Bool(false));
let resp = fetch!(b"GET", server.url())
.basic_auth("user", Some("pass"))
.send()?;
let content = resp.text()?;
let json = utils::retrive_json(&content).unwrap();
assert_eq!(json["allow_delete"], serde_json::Value::Bool(true));
assert_eq!(json["allow_upload"], serde_json::Value::Bool(true));
Ok(())
}

View File

@@ -76,9 +76,7 @@ fn validate_printed_urls(tmpdir: TempDir, port: u16, #[case] args: &[&str]) -> R
.collect::<Vec<_>>();
assert!(!urls.is_empty());
for url in urls {
reqwest::blocking::get(url)?.error_for_status()?;
}
reqwest::blocking::get(urls[0])?.error_for_status()?;
child.kill()?;

56
tests/config.rs Normal file
View File

@@ -0,0 +1,56 @@
mod fixtures;
mod utils;
use assert_cmd::prelude::*;
use assert_fs::TempDir;
use diqwest::blocking::WithDigestAuth;
use fixtures::{port, tmpdir, wait_for_port, Error};
use rstest::rstest;
use std::path::PathBuf;
use std::process::{Command, Stdio};
#[rstest]
fn use_config_file(tmpdir: TempDir, port: u16) -> Result<(), Error> {
let config_path = get_config_path().display().to_string();
let mut child = Command::cargo_bin("dufs")?
.arg(tmpdir.path())
.arg("-p")
.arg(port.to_string())
.args(["--config", &config_path])
.stdout(Stdio::piped())
.spawn()?;
wait_for_port(port);
let url = format!("http://localhost:{port}/dufs/index.html");
let resp = fetch!(b"GET", &url).send()?;
assert_eq!(resp.status(), 401);
let url = format!("http://localhost:{port}/dufs/index.html");
let resp = fetch!(b"GET", &url).send_with_digest_auth("user", "pass")?;
assert_eq!(resp.text()?, "This is index.html");
let url = format!("http://localhost:{port}/dufs?simple");
let resp = fetch!(b"GET", &url).send_with_digest_auth("user", "pass")?;
let text: String = resp.text().unwrap();
assert!(text.split('\n').any(|c| c == "dir1/"));
assert!(!text.split('\n').any(|c| c == "dir3/"));
assert!(!text.split('\n').any(|c| c == "test.txt"));
let url = format!("http://localhost:{port}/dufs/dir1/upload.txt");
let resp = fetch!(b"PUT", &url)
.body("Hello")
.send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 201);
child.kill()?;
Ok(())
}
fn get_config_path() -> PathBuf {
let mut path = std::env::current_dir().expect("Failed to get current directory");
path.push("tests");
path.push("data");
path.push("config.yaml");
path
}

View File

@@ -19,15 +19,15 @@ fn cors(#[with(&["--enable-cors"])] server: TestServer) -> Result<(), Error> {
);
assert_eq!(
resp.headers().get("access-control-allow-methods").unwrap(),
"GET,HEAD,PUT,OPTIONS,DELETE,PROPFIND,COPY,MOVE"
"*"
);
assert_eq!(
resp.headers().get("access-control-allow-headers").unwrap(),
"Authorization,Destination,Range"
"Authorization,*"
);
assert_eq!(
resp.headers().get("access-control-expose-headers").unwrap(),
"WWW-Authenticate,Content-Range,Accept-Ranges,Content-Disposition"
"Authorization,*"
);
Ok(())
}

9
tests/data/config.yaml Normal file
View File

@@ -0,0 +1,9 @@
bind:
- 0.0.0.0
path-prefix: dufs
hidden:
- dir3
- test.txt
auth:
- user:pass@/:rw
allow-upload: true

View File

@@ -46,23 +46,20 @@ pub fn tmpdir() -> TempDir {
let tmpdir = assert_fs::TempDir::new().expect("Couldn't create a temp dir for tests");
for file in FILES {
if *file == BIN_FILE {
tmpdir
.child(file)
.write_binary(b"bin\0\0123")
.expect("Couldn't write to file");
tmpdir.child(file).write_binary(b"bin\0\x00123").unwrap();
} else {
tmpdir
.child(file)
.write_str(&format!("This is {file}"))
.expect("Couldn't write to file");
.unwrap();
}
}
for directory in DIRECTORIES {
if *directory == DIR_ASSETS {
tmpdir
.child(format!("{}{}", directory, "index.html"))
.write_str("__ASSERTS_PREFIX__index.js;DATA = __INDEX_DATA__")
.expect("Couldn't write to file");
.write_str("__ASSETS_PREFIX__index.js;DATA = __INDEX_DATA__")
.unwrap();
} else {
for file in FILES {
if *directory == DIR_NO_INDEX && *file == "index.html" {
@@ -71,18 +68,38 @@ pub fn tmpdir() -> TempDir {
if *file == BIN_FILE {
tmpdir
.child(format!("{directory}{file}"))
.write_binary(b"bin\0\0123")
.expect("Couldn't write to file");
.write_binary(b"bin\0\x00123")
.unwrap();
} else {
tmpdir
.child(format!("{directory}{file}"))
.write_str(&format!("This is {directory}{file}"))
.expect("Couldn't write to file");
.unwrap();
}
}
}
}
tmpdir.child("dir4/hidden").touch().unwrap();
tmpdir
.child("content-types/bin.tar")
.write_binary(b"\x7f\x45\x4c\x46\x02\x01\x00\x00")
.unwrap();
tmpdir
.child("content-types/bin")
.write_binary(b"\x7f\x45\x4c\x46\x02\x01\x00\x00")
.unwrap();
tmpdir
.child("content-types/file-utf8.txt")
.write_str("世界")
.unwrap();
tmpdir
.child("content-types/file-gbk.txt")
.write_binary(b"\xca\xc0\xbd\xe7")
.unwrap();
tmpdir
.child("content-types/file")
.write_str("世界")
.unwrap();
tmpdir
}

View File

@@ -123,6 +123,15 @@ fn get_dir_search3(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
Ok(())
}
#[rstest]
fn get_dir_search4(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}dir1?q=dir1&simple", server.url()))?;
assert_eq!(resp.status(), 200);
let text = resp.text().unwrap();
assert!(text.is_empty());
Ok(())
}
#[rstest]
fn head_dir_search(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"HEAD", format!("{}?q={}", server.url(), "test.html")).send()?;
@@ -148,7 +157,10 @@ fn empty_search(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
fn get_file(server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}index.html", server.url()))?;
assert_eq!(resp.status(), 200);
assert_eq!(resp.headers().get("content-type").unwrap(), "text/html");
assert_eq!(
resp.headers().get("content-type").unwrap(),
"text/html; charset=UTF-8"
);
assert_eq!(resp.headers().get("accept-ranges").unwrap(), "bytes");
assert!(resp.headers().contains_key("etag"));
assert!(resp.headers().contains_key("last-modified"));
@@ -161,7 +173,10 @@ fn get_file(server: TestServer) -> Result<(), Error> {
fn head_file(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"HEAD", format!("{}index.html", server.url())).send()?;
assert_eq!(resp.status(), 200);
assert_eq!(resp.headers().get("content-type").unwrap(), "text/html");
assert_eq!(
resp.headers().get("content-type").unwrap(),
"text/html; charset=UTF-8"
);
assert_eq!(resp.headers().get("accept-ranges").unwrap(), "bytes");
assert!(resp.headers().contains_key("content-disposition"));
assert!(resp.headers().contains_key("etag"));
@@ -178,6 +193,17 @@ fn get_file_404(server: TestServer) -> Result<(), Error> {
Ok(())
}
#[rstest]
fn get_file_emoji_path(server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}{BIN_FILE}", server.url()))?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-disposition").unwrap(),
"inline; filename=\"😀.bin\"; filename*=UTF-8''%F0%9F%98%80.bin"
);
Ok(())
}
#[rstest]
fn get_file_edit(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"GET", format!("{}index.html?edit", server.url())).send()?;
@@ -259,3 +285,33 @@ fn delete_file_404(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
assert_eq!(resp.status(), 404);
Ok(())
}
#[rstest]
fn get_file_content_type(server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}content-types/bin.tar", server.url()))?;
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/x-tar"
);
let resp = reqwest::blocking::get(format!("{}content-types/bin", server.url()))?;
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/octet-stream"
);
let resp = reqwest::blocking::get(format!("{}content-types/file-utf8.txt", server.url()))?;
assert_eq!(
resp.headers().get("content-type").unwrap(),
"text/plain; charset=UTF-8"
);
let resp = reqwest::blocking::get(format!("{}content-types/file-gbk.txt", server.url()))?;
assert_eq!(
resp.headers().get("content-type").unwrap(),
"text/plain; charset=GBK"
);
let resp = reqwest::blocking::get(format!("{}content-types/file", server.url()))?;
assert_eq!(
resp.headers().get("content-type").unwrap(),
"text/plain; charset=UTF-8"
);
Ok(())
}

View File

@@ -11,8 +11,8 @@ use std::io::Read;
use std::process::{Command, Stdio};
#[rstest]
#[case(&["-a", "/@user:pass", "--log-format", "$remote_user"], false)]
#[case(&["-a", "/@user:pass", "--log-format", "$remote_user", "--auth-method", "basic"], true)]
#[case(&["-a", "user:pass@/:rw", "--log-format", "$remote_user"], false)]
#[case(&["-a", "user:pass@/:rw", "--log-format", "$remote_user"], true)]
fn log_remote_user(
tmpdir: TempDir,
port: u16,
@@ -41,7 +41,7 @@ fn log_remote_user(
assert_eq!(resp.status(), 200);
let mut buf = [0; 1000];
let mut buf = [0; 2048];
let buf_len = stdout.read(&mut buf)?;
let output = std::str::from_utf8(&buf[0..buf_len])?;
@@ -69,7 +69,7 @@ fn no_log(tmpdir: TempDir, port: u16, #[case] args: &[&str]) -> Result<(), Error
let resp = fetch!(b"GET", &format!("http://localhost:{port}")).send()?;
assert_eq!(resp.status(), 200);
let mut buf = [0; 1000];
let mut buf = [0; 2048];
let buf_len = stdout.read(&mut buf)?;
let output = std::str::from_utf8(&buf[0..buf_len])?;

View File

@@ -53,7 +53,7 @@ fn path_prefix_single_file(tmpdir: TempDir, port: u16, #[case] file: &str) -> Re
let resp = reqwest::blocking::get(format!("http://localhost:{port}/xyz/index.html"))?;
assert_eq!(resp.text()?, "This is index.html");
let resp = reqwest::blocking::get(format!("http://localhost:{port}"))?;
assert_eq!(resp.status(), 404);
assert_eq!(resp.status(), 403);
child.kill()?;
Ok(())

View File

@@ -7,6 +7,8 @@ use predicates::str::contains;
use reqwest::blocking::ClientBuilder;
use rstest::rstest;
use crate::fixtures::port;
/// Can start the server with TLS and receive encrypted responses.
#[rstest]
#[case(server(&[
@@ -33,11 +35,19 @@ fn tls_works(#[case] server: TestServer) -> Result<(), Error> {
/// Wrong path for cert throws error.
#[rstest]
fn wrong_path_cert() -> Result<(), Error> {
let port = port().to_string();
Command::cargo_bin("dufs")?
.args(["--tls-cert", "wrong", "--tls-key", "tests/data/key.pem"])
.args([
"--tls-cert",
"wrong",
"--tls-key",
"tests/data/key.pem",
"--port",
&port,
])
.assert()
.failure()
.stderr(contains("error: Failed to access `wrong`"));
.stderr(contains("Failed to access `wrong`"));
Ok(())
}
@@ -45,11 +55,19 @@ fn wrong_path_cert() -> Result<(), Error> {
/// Wrong paths for key throws errors.
#[rstest]
fn wrong_path_key() -> Result<(), Error> {
let port = port().to_string();
Command::cargo_bin("dufs")?
.args(["--tls-cert", "tests/data/cert.pem", "--tls-key", "wrong"])
.args([
"--tls-cert",
"tests/data/cert.pem",
"--tls-key",
"wrong",
"--port",
&port,
])
.assert()
.failure()
.stderr(contains("error: Failed to access `wrong`"));
.stderr(contains("Failed to access `wrong`"));
Ok(())
}

View File

@@ -59,7 +59,8 @@ pub fn encode_uri(v: &str) -> String {
parts.join("/")
}
fn retrive_json(content: &str) -> Option<Value> {
#[allow(dead_code)]
pub fn retrive_json(content: &str) -> Option<Value> {
let lines: Vec<&str> = content.lines().collect();
let line = lines.iter().find(|v| v.contains("DATA ="))?;
let line_col = line.find("DATA =").unwrap() + 6;