Compare commits

...

36 Commits

Author SHA1 Message Date
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
24 changed files with 1447 additions and 1150 deletions

View File

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

View File

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

View File

@@ -2,6 +2,65 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [0.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 ## [0.34.0] - 2023-06-01
### Bug Fixes ### Bug Fixes

1115
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

103
README.md
View File

@@ -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 Dufs is a distinctive utility file server - https://github.com/sigoden/dufs
Usage: dufs [OPTIONS] [serve_path] Usage: dufs [OPTIONS] [serve-path]
Arguments: Arguments:
[serve_path] Specific path to serve [default: .] [serve-path] Specific path to serve [default: .]
Options: Options:
-c, --config <config> Specify configuration file
-b, --bind <addrs> Specify bind address or unix socket -b, --bind <addrs> Specify bind address or unix socket
-p, --port <port> Specify port to listen on [default: 5000] -p, --port <port> Specify port to listen on [default: 5000]
--path-prefix <path> Specify a path prefix --path-prefix <path> Specify a path prefix
--hidden <value> Hide paths from directory listings, separated by `,` --hidden <value> Hide paths from directory listings, e.g. tmp,*.log,*.lock
-a, --auth <rules> Add auth role -a, --auth <rules> Add auth roles, e.g. user:pass@/dir1:rw,/dir2
--auth-method <value> Select auth method [default: digest] [possible values: basic, digest]
-A, --allow-all Allow all operations -A, --allow-all Allow all operations
--allow-upload Allow upload files/folders --allow-upload Allow upload files/folders
--allow-delete Allow delete files/folders --allow-delete Allow delete files/folders
@@ -70,11 +70,11 @@ Options:
--render-index Serve index.html when requesting a directory, returns 404 if not found index.html --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-try-index Serve index.html when requesting a directory, returns directory listing if not found index.html
--render-spa Serve SPA(Single Page Application) --render-spa Serve SPA(Single Page Application)
--assets <path> Use custom assets to override builtin assets --assets <path> Set the path to the assets directory for overriding the built-in 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
--log-format <format> Customize http log format --log-format <format> Customize http log format
--completions <shell> Print shell completion script for <shell> [possible values: bash, elvish, fish, powershell, zsh] --completions <shell> Print shell completion script for <shell> [possible values: bash, elvish, fish, powershell, zsh]
--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 -h, --help Print help
-V, --version Print version -V, --version Print version
``` ```
@@ -126,7 +126,7 @@ dufs --render-index
Require username/password Require username/password
``` ```
dufs -a /@admin:123 dufs -a admin:123@/:rw
``` ```
Listen on specific host:ip 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 With authorization
``` ```
curl --user user:pass --digest http://192.168.8.10:5000/file # digest auth curl http://192.168.8.10:5000/file --user user:pass # basic 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 --digest # digest auth
``` ```
<details> <details>
@@ -206,12 +206,14 @@ curl --user user:pass http://192.168.8.10:5000/file # basic auth
Dufs supports account based 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 [user:pass]@path[:rw][,path[:rw]...][|...] dufs -a user:pass@path1:rw,path2|user2:pass2@path1
dufs -a user:pass@path1:rw,path2 -a user2:pass2@path1
``` ```
1: Multiple rules are separated by "|"
2: User and pass are the account name and password, if omitted, it is an anonymous user 1. Multiple rules are separated by "|"
3: One rule can set multiple paths, separated by "," 2. User and pass are the account name and password, if omitted, it is an anonymous user
4: Add `:rw` after the path to indicate that the path has read and write permissions, otherwise the path has readonly permissions. 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@/:rw dufs -A -a admin:admin@/:rw
@@ -241,10 +243,33 @@ dufs -A -a user:pass@/dir1:rw,/dir2:rw,dir3
`user` has all permissions for `/dir1/*` and `/dir2/*`, has readonly permissions for `/dir3/`. `user` has all permissions for `/dir1/*` and `/dir2/*`, has readonly permissions for `/dir3/`.
``` ```
dufs -a admin:admin@/ dufs -A -a admin:admin@/
``` ```
Since dufs only allows viewing/downloading, `admin` can only view/download files. 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 ### Hide Paths
Dufs supports hiding paths from directory listings via option `--hidden <glob>,...`. Dufs supports hiding paths from directory listings via option `--hidden <glob>,...`.
@@ -307,13 +332,12 @@ dufs --log-format '$remote_addr $remote_user "$request" $status' -a /@admin:admi
All options can be set using environment variables prefixed with `DUFS_`. All options can be set using environment variables prefixed with `DUFS_`.
``` ```
[ROOT_DIR] DUFS_ROOT_DIR=/dir [serve-path] DUFS_SERVE_PATH=/dir
-b, --bind <addrs> DUFS_BIND=0.0.0.0 -b, --bind <addrs> DUFS_BIND=0.0.0.0
-p, --port <port> DUFS_PORT=5000 -p, --port <port> DUFS_PORT=5000
--path-prefix <path> DUFS_PATH_RREFIX=/path --path-prefix <path> DUFS_PATH_PREFIX=/path
--hidden <value> DUFS_HIDDEN=*.log --hidden <value> DUFS_HIDDEN=*.log
-a, --auth <rules> DUFS_AUTH="admin:admin@/:rw|@/" -a, --auth <rules> DUFS_AUTH="admin:admin@/:rw|@/"
--auth-method <value> DUFS_AUTH_METHOD=basic
-A, --allow-all DUFS_ALLOW_ALL=true -A, --allow-all DUFS_ALLOW_ALL=true
--allow-upload DUFS_ALLOW_UPLOAD=true --allow-upload DUFS_ALLOW_UPLOAD=true
--allow-delete DUFS_ALLOW_DELETE=true --allow-delete DUFS_ALLOW_DELETE=true
@@ -325,9 +349,44 @@ All options can be set using environment variables prefixed with `DUFS_`.
--render-try-index DUFS_RENDER_TRY_INDEX=true --render-try-index DUFS_RENDER_TRY_INDEX=true
--render-spa DUFS_RENDER_SPA=true --render-spa DUFS_RENDER_SPA=true
--assets <path> DUFS_ASSETS=/assets --assets <path> DUFS_ASSETS=/assets
--log-format <format> DUFS_LOG_FORMAT=""
--tls-cert <path> DUFS_TLS_CERT=cert.pem --tls-cert <path> DUFS_TLS_CERT=cert.pem
--tls-key <path> DUFS_TLS_KEY=key.pem --tls-key <path> DUFS_TLS_KEY=key.pem
--log-format <format> DUFS_LOG_FORMAT="" ```
## 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 ### Customize UI
@@ -343,7 +402,7 @@ Your assets folder must contains a `index.html` file.
`index.html` can use the following placeholder variables to retrieve internal data. `index.html` can use the following placeholder variables to retrieve internal data.
- `__INDEX_DATA__`: directory listing data - `__INDEX_DATA__`: directory listing data
- `__ASSERTS_PREFIX__`: assets url prefix - `__ASSETS_PREFIX__`: assets url prefix
</details> </details>

View File

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

View File

@@ -4,12 +4,12 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/x-icon" href="__ASSERTS_PREFIX__favicon.ico"> <link rel="icon" type="image/x-icon" href="__ASSETS_PREFIX__favicon.ico">
<link rel="stylesheet" href="__ASSERTS_PREFIX__index.css"> <link rel="stylesheet" href="__ASSETS_PREFIX__index.css">
<script> <script>
DATA = __INDEX_DATA__ DATA = __INDEX_DATA__
</script> </script>
<script src="__ASSERTS_PREFIX__index.js"></script> <script src="__ASSETS_PREFIX__index.js"></script>
</head> </head>
<body> <body>
@@ -77,7 +77,7 @@
<input id="search" name="q" type="text" maxlength="128" autocomplete="off" tabindex="1"> <input id="search" name="q" type="text" maxlength="128" autocomplete="off" tabindex="1">
<input type="submit" hidden /> <input type="submit" hidden />
</form> </form>
<div class="toolbox2"> <div class="toolbox-right">
<div class="login-btn hidden" title="Login for upload/move/delete/edit permissions"> <div class="login-btn hidden" title="Login for upload/move/delete/edit permissions">
<svg width="16" height="16" viewBox="0 0 16 16"> <svg width="16" height="16" viewBox="0 0 16 16">
<path fill-rule="evenodd" <path fill-rule="evenodd"
@@ -91,6 +91,7 @@
<path <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" /> 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> </svg>
<span class="user-name"></span>
</div> </div>
<div class="save-btn hidden" title="Save file"> <div class="save-btn hidden" title="Save file">
<svg viewBox="0 0 1024 1024" width="24" height="24"> <svg viewBox="0 0 1024 1024" width="24" height="24">

View File

@@ -86,6 +86,10 @@ let $editor;
* @type Element * @type Element
*/ */
let $userBtn; let $userBtn;
/**
* @type Element
*/
let $userName;
function ready() { function ready() {
$pathsTable = document.querySelector(".paths-table") $pathsTable = document.querySelector(".paths-table")
@@ -95,6 +99,7 @@ function ready() {
$emptyFolder = document.querySelector(".empty-folder"); $emptyFolder = document.querySelector(".empty-folder");
$editor = document.querySelector(".editor"); $editor = document.querySelector(".editor");
$userBtn = document.querySelector(".user-btn"); $userBtn = document.querySelector(".user-btn");
$userName = document.querySelector(".user-name");
addBreadcrumb(DATA.href, DATA.uri_prefix); addBreadcrumb(DATA.href, DATA.uri_prefix);
@@ -218,8 +223,11 @@ Uploader.runQueue = async () => {
let uploader = Uploader.queues.shift(); let uploader = Uploader.queues.shift();
if (!Uploader.auth) { if (!Uploader.auth) {
Uploader.auth = true; Uploader.auth = true;
const success = await checkAuth(true); try {
Uploader.auth = !!success; await checkAuth()
} catch {
Uploader.auth = false;
}
} }
uploader.ajax(); uploader.ajax();
} }
@@ -313,13 +321,13 @@ function renderPathsTableHead() {
<tr> <tr>
${headerItems.map(item => { ${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 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.sort === item.name) {
if (PARAMS.order === "asc") { if (PARAMS.order === "desc") {
order = "desc"; order = "asc";
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 {
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>` 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(); const qs = new URLSearchParams({ ...PARAMS, order, sort: item.name }).toString();
@@ -435,11 +443,18 @@ function setupDropzone() {
function setupAuth() { function setupAuth() {
if (DATA.user) { if (DATA.user) {
$userBtn.classList.remove("hidden"); $userBtn.classList.remove("hidden");
$userBtn.title = DATA.user; $userName.textContent = DATA.user;
} else { } else {
const $loginBtn = document.querySelector(".login-btn"); const $loginBtn = document.querySelector(".login-btn");
$loginBtn.classList.remove("hidden"); $loginBtn.classList.remove("hidden");
$loginBtn.addEventListener("click", () => checkAuth(true)); $loginBtn.addEventListener("click", async () => {
try {
await checkAuth()
location.reload();
} catch (err) {
alert(err.message);
}
});
} }
} }
@@ -651,25 +666,15 @@ async function saveChange() {
} }
} }
async function checkAuth(alert = false) { async function checkAuth() {
if (!DATA.auth) return; if (!DATA.auth) return;
try { const res = await fetch(baseUrl(), {
const res = await fetch(baseUrl(), { method: "WRITEABLE",
method: "WRITEABLE", });
}); await assertResOK(res);
await assertResOK(res); document.querySelector(".login-btn").classList.add("hidden");
document.querySelector(".login-btn").classList.add("hidden"); $userBtn.classList.remove("hidden");
$userBtn.classList.remove("hidden"); $userName.textContent = "";
$userBtn.title = "";
return true;
} catch (err) {
let message = `Check auth, ${err.message}`;
if (alert) {
alert(message);
} else {
throw new Error(message);
}
}
} }
/** /**
@@ -808,7 +813,7 @@ function encodedStr(rawStr) {
async function assertResOK(res) { async function assertResOK(res) {
if (!(res.status >= 200 && res.status < 300)) { if (!(res.status >= 200 && res.status < 300)) {
throw new Error(await res.text()) throw new Error(await res.text() || `Invalid status ${res.status}`);
} }
} }

View File

@@ -2,17 +2,13 @@ use anyhow::{bail, Context, Result};
use clap::builder::PossibleValuesParser; use clap::builder::PossibleValuesParser;
use clap::{value_parser, Arg, ArgAction, ArgMatches, Command}; use clap::{value_parser, Arg, ArgAction, ArgMatches, Command};
use clap_complete::{generate, Generator, Shell}; use clap_complete::{generate, Generator, Shell};
#[cfg(feature = "tls")] use serde::{Deserialize, Deserializer};
use rustls::{Certificate, PrivateKey};
use std::env; use std::env;
use std::net::IpAddr; use std::net::IpAddr;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use crate::auth::AccessControl; use crate::auth::AccessControl;
use crate::auth::AuthMethod; use crate::http_logger::HttpLogger;
use crate::log_http::{LogHttp, DEFAULT_LOG_FORMAT};
#[cfg(feature = "tls")]
use crate::tls::{load_certs, load_private_key};
use crate::utils::encode_uri; use crate::utils::encode_uri;
pub fn build_cli() -> Command { pub fn build_cli() -> Command {
@@ -25,12 +21,20 @@ pub fn build_cli() -> Command {
env!("CARGO_PKG_REPOSITORY") env!("CARGO_PKG_REPOSITORY")
)) ))
.arg( .arg(
Arg::new("serve_path") Arg::new("serve-path")
.env("DUFS_SERVE_PATH") .env("DUFS_SERVE_PATH")
.hide_env(true) .hide_env(true)
.default_value(".")
.value_parser(value_parser!(PathBuf)) .value_parser(value_parser!(PathBuf))
.help("Specific path to serve"), .help("Specific path to serve [default: .]"),
)
.arg(
Arg::new("config")
.env("DUFS_SERVE_PATH")
.hide_env(true)
.short('c')
.long("config")
.value_parser(value_parser!(PathBuf))
.help("Specify configuration file"),
) )
.arg( .arg(
Arg::new("bind") Arg::new("bind")
@@ -49,9 +53,8 @@ pub fn build_cli() -> Command {
.hide_env(true) .hide_env(true)
.short('p') .short('p')
.long("port") .long("port")
.default_value("5000")
.value_parser(value_parser!(u16)) .value_parser(value_parser!(u16))
.help("Specify port to listen on") .help("Specify port to listen on [default: 5000]")
.value_name("port"), .value_name("port"),
) )
.arg( .arg(
@@ -67,7 +70,7 @@ pub fn build_cli() -> Command {
.env("DUFS_HIDDEN") .env("DUFS_HIDDEN")
.hide_env(true) .hide_env(true)
.long("hidden") .long("hidden")
.help("Hide paths from directory listings, separated by `,`") .help("Hide paths from directory listings, e.g. tmp,*.log,*.lock")
.value_name("value"), .value_name("value"),
) )
.arg( .arg(
@@ -76,13 +79,14 @@ pub fn build_cli() -> Command {
.hide_env(true) .hide_env(true)
.short('a') .short('a')
.long("auth") .long("auth")
.help("Add auth role") .help("Add auth roles, e.g. user:pass@/dir1:rw,/dir2")
.action(ArgAction::Append) .action(ArgAction::Append)
.value_delimiter('|') .value_delimiter('|')
.value_name("rules"), .value_name("rules"),
) )
.arg( .arg(
Arg::new("auth-method") Arg::new("auth-method")
.hide(true)
.env("DUFS_AUTH_METHOD") .env("DUFS_AUTH_METHOD")
.hide_env(true) .hide_env(true)
.long("auth-method") .long("auth-method")
@@ -177,9 +181,24 @@ pub fn build_cli() -> Command {
.env("DUFS_ASSETS") .env("DUFS_ASSETS")
.hide_env(true) .hide_env(true)
.long("assets") .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_parser(value_parser!(PathBuf))
.value_name("path") .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")] #[cfg(feature = "tls")]
@@ -203,38 +222,32 @@ pub fn build_cli() -> Command {
.help("Path to the SSL/TLS certificate's private key"), .help("Path to the SSL/TLS certificate's private key"),
); );
app.arg( app
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>"),
)
} }
pub fn print_completions<G: Generator>(gen: G, cmd: &mut Command) { pub fn print_completions<G: Generator>(gen: G, cmd: &mut Command) {
generate(gen, cmd, cmd.get_name().to_string(), &mut std::io::stdout()); 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 { 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 addrs: Vec<BindAddr>,
pub port: u16, pub port: u16,
pub path: PathBuf, #[serde(skip)]
pub path_is_file: bool, pub path_is_file: bool,
pub path_prefix: String, pub path_prefix: String,
#[serde(skip)]
pub uri_prefix: String, pub uri_prefix: String,
pub hidden: Vec<String>, pub hidden: Vec<String>,
pub auth_method: AuthMethod, #[serde(deserialize_with = "deserialize_access_control")]
pub auth: AccessControl, pub auth: AccessControl,
pub allow_all: bool,
pub allow_upload: bool, pub allow_upload: bool,
pub allow_delete: bool, pub allow_delete: bool,
pub allow_search: bool, pub allow_search: bool,
@@ -244,12 +257,12 @@ pub struct Args {
pub render_spa: bool, pub render_spa: bool,
pub render_try_index: bool, pub render_try_index: bool,
pub enable_cors: bool, pub enable_cors: bool,
pub assets_path: Option<PathBuf>, pub assets: Option<PathBuf>,
pub log_http: LogHttp, #[serde(deserialize_with = "deserialize_log_http")]
#[cfg(feature = "tls")] #[serde(rename = "log-format")]
pub tls: Option<(Vec<Certificate>, PrivateKey)>, pub http_logger: HttpLogger,
#[cfg(not(feature = "tls"))] pub tls_cert: Option<PathBuf>,
pub tls: Option<()>, pub tls_key: Option<PathBuf>,
} }
impl Args { impl Args {
@@ -258,95 +271,164 @@ impl Args {
/// If a parsing error occurred, exit the process and print out informative /// If a parsing error occurred, exit the process and print out informative
/// error message to user. /// error message to user.
pub fn parse(matches: ArgMatches) -> Result<Args> { pub fn parse(matches: ArgMatches) -> Result<Args> {
let port = *matches.get_one::<u16>("port").unwrap(); let mut args = Self {
let addrs = matches serve_path: default_serve_path(),
.get_many::<String>("bind") addrs: BindAddr::parse_addrs(&["0.0.0.0", "::"]).unwrap(),
.map(|bind| bind.map(|v| v.as_str()).collect()) port: 5000,
.unwrap_or_else(|| vec!["0.0.0.0", "::"]); ..Default::default()
let addrs: Vec<BindAddr> = Args::parse_addrs(&addrs)?; };
let path = Args::parse_path(matches.get_one::<PathBuf>("serve_path").unwrap())?;
let path_is_file = path.metadata()?.is_file(); if let Some(config_path) = matches.get_one::<PathBuf>("config") {
let path_prefix = matches let contents = std::fs::read_to_string(config_path)
.get_one::<String>("path-prefix") .with_context(|| format!("Failed to read config at {}", config_path.display()))?;
.map(|v| v.trim_matches('/').to_owned()) args = serde_yaml::from_str(&contents)
.unwrap_or_default(); .with_context(|| format!("Failed to load config at {}", config_path.display()))?;
let uri_prefix = if path_prefix.is_empty() { }
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() "/".to_owned()
} else { } 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") .get_one::<String>("hidden")
.map(|v| v.split(',').map(|x| x.to_string()).collect()) .map(|v| v.split(',').map(|x| x.to_string()).collect())
.unwrap_or_default(); {
let enable_cors = matches.get_flag("enable-cors"); args.hidden = hidden;
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)?;
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");
#[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))
}
_ => 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 { if !args.enable_cors {
addrs, args.enable_cors = matches.get_flag("enable-cors");
port, }
path,
path_is_file, if let Some(rules) = matches.get_many::<String>("auth") {
path_prefix, let rules: Vec<_> = rules.map(|v| v.as_str()).collect();
uri_prefix, args.auth = AccessControl::new(&rules)?;
hidden, }
auth_method,
auth, if !args.allow_all {
enable_cors, args.allow_all = matches.get_flag("allow-all");
allow_delete, }
allow_upload,
allow_search, let allow_all = args.allow_all;
allow_symlink,
allow_archive, if !args.allow_upload {
render_index, args.allow_upload = allow_all || matches.get_flag("allow-upload");
render_try_index, }
render_spa, if !args.allow_delete {
tls, args.allow_delete = allow_all || matches.get_flag("allow-delete");
log_http, }
assets_path, 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")]
{
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) => {}
}
}
#[cfg(not(feature = "tls"))]
{
args.tls_cert = None;
args.tls_key = None;
}
Ok(args)
} }
fn parse_addrs(addrs: &[&str]) -> Result<Vec<BindAddr>> { 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 bind_addrs = vec![];
let mut invalid_addrs = vec![]; let mut invalid_addrs = vec![];
for addr in addrs { for addr in addrs {
@@ -368,32 +450,32 @@ impl Args {
} }
Ok(bind_addrs) 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)
})
.with_context(|| format!("Failed to access path `{}`", path.display()))
}
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)] fn deserialize_bind_addrs<'de, D>(deserializer: D) -> Result<Vec<BindAddr>, D::Error>
pub enum BindAddr { where
Address(IpAddr), D: Deserializer<'de>,
Path(PathBuf), {
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

@@ -11,10 +11,10 @@ use std::{
}; };
use uuid::Uuid; use uuid::Uuid;
use crate::utils::unix_now; use crate::{args::Args, utils::unix_now};
const REALM: &str = "DUFS"; const REALM: &str = "DUFS";
const DIGEST_AUTH_TIMEOUT: u32 = 86400; const DIGEST_AUTH_TIMEOUT: u32 = 604800; // 7 days
lazy_static! { lazy_static! {
static ref NONCESTARTHASH: Context = { static ref NONCESTARTHASH: Context = {
@@ -25,21 +25,29 @@ lazy_static! {
}; };
} }
#[derive(Debug, Default)] #[derive(Debug)]
pub struct AccessControl { pub struct AccessControl {
use_hashed_password: bool,
users: IndexMap<String, (String, AccessPaths)>, users: IndexMap<String, (String, AccessPaths)>,
anony: Option<AccessPaths>, anony: Option<AccessPaths>,
} }
impl Default for AccessControl {
fn default() -> Self {
AccessControl {
use_hashed_password: false,
anony: Some(AccessPaths::new(AccessPerm::ReadWrite)),
users: IndexMap::new(),
}
}
}
impl AccessControl { impl AccessControl {
pub fn new(raw_rules: &[&str]) -> Result<Self> { pub fn new(raw_rules: &[&str]) -> Result<Self> {
if raw_rules.is_empty() { if raw_rules.is_empty() {
return Ok(AccessControl { return Ok(Default::default());
anony: Some(AccessPaths::new(AccessPerm::ReadWrite)),
users: IndexMap::new(),
});
} }
let mut use_hashed_password = false;
let create_err = |v: &str| anyhow!("Invalid auth `{v}`"); let create_err = |v: &str| anyhow!("Invalid auth `{v}`");
let mut anony = None; let mut anony = None;
let mut anony_paths = vec![]; let mut anony_paths = vec![];
@@ -67,6 +75,9 @@ impl AccessControl {
if user.is_empty() || pass.is_empty() { if user.is_empty() || pass.is_empty() {
return Err(create_err(rule)); return Err(create_err(rule));
} }
if pass.starts_with("$6$") {
use_hashed_password = true;
}
users.insert(user.to_string(), (pass.to_string(), paths)); users.insert(user.to_string(), (pass.to_string(), paths));
} else { } else {
return Err(create_err(rule)); return Err(create_err(rule));
@@ -77,11 +88,15 @@ impl AccessControl {
paths.add(path, perm) paths.add(path, perm)
} }
} }
Ok(Self { users, anony }) Ok(Self {
use_hashed_password,
users,
anony,
})
} }
pub fn valid(&self) -> bool { pub fn exist(&self) -> bool {
!self.users.is_empty() || self.anony.is_some() !self.users.is_empty()
} }
pub fn guard( pub fn guard(
@@ -89,18 +104,14 @@ impl AccessControl {
path: &str, path: &str,
method: &Method, method: &Method,
authorization: Option<&HeaderValue>, authorization: Option<&HeaderValue>,
auth_method: AuthMethod,
) -> (Option<String>, Option<AccessPaths>) { ) -> (Option<String>, Option<AccessPaths>) {
if let Some(authorization) = authorization { if let Some(authorization) = authorization {
if let Some(user) = auth_method.get_user(authorization) { if let Some(user) = get_auth_user(authorization) {
if let Some((pass, paths)) = self.users.get(&user) { if let Some((pass, paths)) = self.users.get(&user) {
if method == Method::OPTIONS { if method == Method::OPTIONS {
return (Some(user), Some(AccessPaths::new(AccessPerm::ReadOnly))); return (Some(user), Some(AccessPaths::new(AccessPerm::ReadOnly)));
} }
if auth_method if check_auth(authorization, method.as_str(), &user, pass).is_some() {
.check(authorization, method.as_str(), &user, pass)
.is_some()
{
return (Some(user), paths.find(path, !is_readonly_method(method))); return (Some(user), paths.find(path, !is_readonly_method(method)));
} else { } else {
return (None, None); return (None, None);
@@ -229,8 +240,8 @@ impl AccessPaths {
pub enum AccessPerm { pub enum AccessPerm {
#[default] #[default]
IndexOnly, IndexOnly,
ReadWrite,
ReadOnly, ReadOnly,
ReadWrite,
} }
impl AccessPerm { impl AccessPerm {
@@ -243,151 +254,127 @@ impl AccessPerm {
} }
} }
fn is_readonly_method(method: &Method) -> bool { pub fn www_authenticate(args: &Args) -> Result<HeaderValue> {
method == Method::GET let value = if args.auth.use_hashed_password {
|| method == Method::OPTIONS format!("Basic realm=\"{}\"", REALM)
|| method == Method::HEAD } else {
|| method.as_str() == "PROPFIND" let nonce = create_nonce()?;
format!(
"Digest realm=\"{}\", nonce=\"{}\", qop=\"auth\", Basic realm=\"{}\"",
REALM, nonce, REALM
)
};
Ok(HeaderValue::from_str(&value)?)
} }
#[derive(Debug, Clone)] pub fn get_auth_user(authorization: &HeaderValue) -> Option<String> {
pub enum AuthMethod { if let Some(value) = strip_prefix(authorization.as_bytes(), b"Basic ") {
Basic, let value: Vec<u8> = general_purpose::STANDARD.decode(value).ok()?;
Digest, 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
}
} }
impl AuthMethod { pub fn check_auth(
pub fn www_auth(&self, stale: bool) -> Result<String> { authorization: &HeaderValue,
match self { method: &str,
AuthMethod::Basic => Ok(format!("Basic realm=\"{REALM}\"")), auth_user: &str,
AuthMethod::Digest => { auth_pass: &str,
let str_stale = if stale { "stale=true," } else { "" }; ) -> Option<()> {
Ok(format!( if let Some(value) = strip_prefix(authorization.as_bytes(), b"Basic ") {
"Digest realm=\"{}\",nonce=\"{}\",{}qop=\"auth\"", let value: Vec<u8> = general_purpose::STANDARD.decode(value).ok()?;
REALM, let parts: Vec<&str> = std::str::from_utf8(&value).ok()?.split(':').collect();
create_nonce()?,
str_stale if parts[0] != auth_user {
)) return None;
}
} }
}
pub fn get_user(&self, authorization: &HeaderValue) -> Option<String> { if auth_pass.starts_with("$6$") {
match self { if let Ok(()) = sha_crypt::sha512_check(parts[1], auth_pass) {
AuthMethod::Basic => { return Some(());
let value: Vec<u8> = general_purpose::STANDARD
.decode(strip_prefix(authorization.as_bytes(), b"Basic ")?)
.ok()?;
let parts: Vec<&str> = std::str::from_utf8(&value).ok()?.split(':').collect();
Some(parts[0].to_string())
}
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())
} }
} else if parts[1] == auth_pass {
return Some(());
} }
}
fn check( None
&self, } else if let Some(value) = strip_prefix(authorization.as_bytes(), b"Digest ") {
authorization: &HeaderValue, let digest_map = to_headermap(value).ok()?;
method: &str, if let (Some(username), Some(nonce), Some(user_response)) = (
auth_user: &str, digest_map
auth_pass: &str, .get(b"username".as_ref())
) -> Option<()> { .and_then(|b| std::str::from_utf8(b).ok()),
match self { digest_map.get(b"nonce".as_ref()),
AuthMethod::Basic => { digest_map.get(b"response".as_ref()),
let basic_value: Vec<u8> = general_purpose::STANDARD ) {
.decode(strip_prefix(authorization.as_bytes(), b"Basic ")?) match validate_nonce(nonce) {
.ok()?; Ok(true) => {}
let parts: Vec<&str> = std::str::from_utf8(&basic_value).ok()?.split(':').collect(); _ => return None,
}
if parts[0] != auth_user { if auth_user != username {
return None; return None;
}
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()?;
if let (Some(username), Some(nonce), Some(user_response)) = (
digest_map
.get(b"username".as_ref())
.and_then(|b| std::str::from_utf8(b).ok()),
digest_map.get(b"nonce".as_ref()),
digest_map.get(b"response".as_ref()),
) {
match validate_nonce(nonce) {
Ok(true) => {}
_ => return None,
}
if auth_user != username {
return None;
}
let mut h = Context::new(); let mut h = Context::new();
h.consume(format!("{}:{}:{}", auth_user, REALM, auth_pass).as_bytes()); h.consume(format!("{}:{}:{}", auth_user, REALM, auth_pass).as_bytes());
let auth_pass = format!("{:x}", h.compute()); let auth_pass = format!("{:x}", h.compute());
let mut ha = Context::new(); let mut ha = Context::new();
ha.consume(method); ha.consume(method);
ha.consume(b":"); ha.consume(b":");
if let Some(uri) = digest_map.get(b"uri".as_ref()) { if let Some(uri) = digest_map.get(b"uri".as_ref()) {
ha.consume(uri); ha.consume(uri);
} }
let ha = format!("{:x}", ha.compute()); let ha = format!("{:x}", ha.compute());
let mut correct_response = None; let mut correct_response = None;
if let Some(qop) = digest_map.get(b"qop".as_ref()) { if let Some(qop) = digest_map.get(b"qop".as_ref()) {
if qop == &b"auth".as_ref() || qop == &b"auth-int".as_ref() { if qop == &b"auth".as_ref() || qop == &b"auth-int".as_ref() {
correct_response = Some({ correct_response = Some({
let mut c = Context::new(); let mut c = Context::new();
c.consume(&auth_pass); c.consume(&auth_pass);
c.consume(b":"); c.consume(b":");
c.consume(nonce); c.consume(nonce);
c.consume(b":"); c.consume(b":");
if let Some(nc) = digest_map.get(b"nc".as_ref()) { if let Some(nc) = digest_map.get(b"nc".as_ref()) {
c.consume(nc); c.consume(nc);
}
c.consume(b":");
if let Some(cnonce) = digest_map.get(b"cnonce".as_ref()) {
c.consume(cnonce);
}
c.consume(b":");
c.consume(qop);
c.consume(b":");
c.consume(&*ha);
format!("{:x}", c.compute())
});
} }
} c.consume(b":");
let correct_response = match correct_response { if let Some(cnonce) = digest_map.get(b"cnonce".as_ref()) {
Some(r) => r, c.consume(cnonce);
None => {
let mut c = Context::new();
c.consume(&auth_pass);
c.consume(b":");
c.consume(nonce);
c.consume(b":");
c.consume(&*ha);
format!("{:x}", c.compute())
} }
}; c.consume(b":");
if correct_response.as_bytes() == *user_response { c.consume(qop);
return Some(()); c.consume(b":");
} c.consume(&*ha);
format!("{:x}", c.compute())
});
} }
None }
let correct_response = match correct_response {
Some(r) => r,
None => {
let mut c = Context::new();
c.consume(&auth_pass);
c.consume(b":");
c.consume(nonce);
c.consume(b":");
c.consume(&*ha);
format!("{:x}", c.compute())
}
};
if correct_response.as_bytes() == *user_response {
return Some(());
} }
} }
None
} else {
None
} }
} }
@@ -419,6 +406,13 @@ fn validate_nonce(nonce: &[u8]) -> Result<bool> {
bail!("invalid nonce"); 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]> { fn strip_prefix<'a>(search: &'a [u8], prefix: &[u8]) -> Option<&'a [u8]> {
let l = prefix.len(); let l = prefix.len();
if search.len() < l { if search.len() < l {
@@ -519,4 +513,16 @@ mod tests {
assert_eq!(paths.find("dir2", true), None); assert_eq!(paths.find("dir2", true), None);
assert!(paths.find("dir1/file", true).is_some()); 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"#; pub const DEFAULT_LOG_FORMAT: &str = r#"$remote_addr "$request" $status"#;
#[derive(Debug)] #[derive(Debug)]
pub struct LogHttp { pub struct HttpLogger {
elements: Vec<LogElement>, elements: Vec<LogElement>,
} }
impl Default for HttpLogger {
fn default() -> Self {
DEFAULT_LOG_FORMAT.parse().unwrap()
}
}
#[derive(Debug)] #[derive(Debug)]
enum LogElement { enum LogElement {
Variable(String), Variable(String),
@@ -16,8 +22,8 @@ enum LogElement {
Literal(String), Literal(String),
} }
impl LogHttp { impl HttpLogger {
pub fn data(&self, req: &Request, args: &Arc<Args>) -> HashMap<String, String> { pub fn data(&self, req: &Request) -> HashMap<String, String> {
let mut data = HashMap::default(); let mut data = HashMap::default();
for element in self.elements.iter() { for element in self.elements.iter() {
match element { match element {
@@ -26,10 +32,8 @@ impl LogHttp {
data.insert(name.to_string(), format!("{} {}", req.method(), req.uri())); data.insert(name.to_string(), format!("{} {}", req.method(), req.uri()));
} }
"remote_user" => { "remote_user" => {
if let Some(user) = req if let Some(user) =
.headers() req.headers().get("authorization").and_then(get_auth_user)
.get("authorization")
.and_then(|v| args.auth_method.get_user(v))
{ {
data.insert(name.to_string(), 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; type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut elements = vec![]; let mut elements = vec![];

View File

@@ -1,6 +1,6 @@
mod args; mod args;
mod auth; mod auth;
mod log_http; mod http_logger;
mod logger; mod logger;
mod server; mod server;
mod streamer; mod streamer;
@@ -16,7 +16,7 @@ extern crate log;
use crate::args::{build_cli, print_completions, Args}; use crate::args::{build_cli, print_completions, Args};
use crate::server::{Request, Server}; use crate::server::{Request, Server};
#[cfg(feature = "tls")] #[cfg(feature = "tls")]
use crate::tls::{TlsAcceptor, TlsStream}; use crate::tls::{load_certs, load_private_key, TlsAcceptor, TlsStream};
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use std::net::{IpAddr, SocketAddr, TcpListener as StdTcpListener}; use std::net::{IpAddr, SocketAddr, TcpListener as StdTcpListener};
@@ -88,9 +88,12 @@ fn serve(
BindAddr::Address(ip) => { BindAddr::Address(ip) => {
let incoming = create_addr_incoming(SocketAddr::new(*ip, port)) let incoming = create_addr_incoming(SocketAddr::new(*ip, port))
.with_context(|| format!("Failed to bind `{ip}:{port}`"))?; .with_context(|| format!("Failed to bind `{ip}:{port}`"))?;
match args.tls.as_ref() {
match (&args.tls_cert, &args.tls_key) {
#[cfg(feature = "tls")] #[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() let config = ServerConfig::builder()
.with_safe_defaults() .with_safe_defaults()
.with_no_client_auth() .with_no_client_auth()
@@ -105,11 +108,7 @@ fn serve(
tokio::spawn(hyper::Server::builder(accepter).serve(new_service)); tokio::spawn(hyper::Server::builder(accepter).serve(new_service));
handles.push(server); handles.push(server);
} }
#[cfg(not(feature = "tls"))] (None, None) => {
Some(_) => {
unreachable!()
}
None => {
let new_service = make_service_fn(move |socket: &AddrStream| { let new_service = make_service_fn(move |socket: &AddrStream| {
let remote_addr = socket.remote_addr(); let remote_addr = socket.remote_addr();
serve_func(Some(remote_addr)) serve_func(Some(remote_addr))
@@ -118,6 +117,9 @@ fn serve(
tokio::spawn(hyper::Server::builder(incoming).serve(new_service)); tokio::spawn(hyper::Server::builder(incoming).serve(new_service));
handles.push(server); handles.push(server);
} }
_ => {
unreachable!()
}
}; };
} }
BindAddr::Path(path) => { BindAddr::Path(path) => {
@@ -195,7 +197,11 @@ fn print_listening(args: Arc<Args>) -> Result<()> {
IpAddr::V4(_) => format!("{}:{}", addr, args.port), IpAddr::V4(_) => format!("{}:{}", addr, args.port),
IpAddr::V6(_) => 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) format!("{}://{}{}", protocol, addr, args.uri_prefix)
} }
BindAddr::Path(path) => path.display().to_string(), BindAddr::Path(path) => path.display().to_string(),

View File

@@ -1,6 +1,6 @@
#![allow(clippy::too_many_arguments)] #![allow(clippy::too_many_arguments)]
use crate::auth::AccessPaths; use crate::auth::{www_authenticate, AccessPaths, AccessPerm};
use crate::streamer::Streamer; use crate::streamer::Streamer;
use crate::utils::{ use crate::utils::{
decode_uri, encode_uri, get_file_mtime_and_mode, get_file_name, glob, try_get_file_name, decode_uri, encode_uri, get_file_mtime_and_mode, get_file_name, glob, try_get_file_name,
@@ -26,12 +26,13 @@ use hyper::header::{
use hyper::{Body, Method, StatusCode, Uri}; use hyper::{Body, Method, StatusCode, Uri};
use serde::Serialize; use serde::Serialize;
use std::borrow::Cow; use std::borrow::Cow;
use std::cmp::Ordering;
use std::collections::HashMap; use std::collections::HashMap;
use std::fs::Metadata; use std::fs::Metadata;
use std::io::SeekFrom; use std::io::SeekFrom;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{self, AtomicBool};
use std::sync::Arc; use std::sync::Arc;
use std::time::SystemTime; use std::time::SystemTime;
use tokio::fs::File; use tokio::fs::File;
@@ -70,13 +71,13 @@ impl Server {
encode_uri(&format!( encode_uri(&format!(
"{}{}", "{}{}",
&args.uri_prefix, &args.uri_prefix,
get_file_name(&args.path) get_file_name(&args.serve_path)
)), )),
] ]
} else { } else {
vec![] 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"))?), Some(path) => Cow::Owned(std::fs::read_to_string(path.join("index.html"))?),
None => Cow::Borrowed(INDEX_HTML), None => Cow::Borrowed(INDEX_HTML),
}; };
@@ -95,9 +96,9 @@ impl Server {
addr: Option<SocketAddr>, addr: Option<SocketAddr>,
) -> Result<Response, hyper::Error> { ) -> Result<Response, hyper::Error> {
let uri = req.uri().clone(); 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 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 { if let Some(addr) = addr {
http_log_data.insert("remote_addr".to_string(), addr.ip().to_string()); http_log_data.insert("remote_addr".to_string(), addr.ip().to_string());
} }
@@ -105,8 +106,8 @@ impl Server {
let mut res = match self.clone().handle(req).await { let mut res = match self.clone().handle(req).await {
Ok(res) => { Ok(res) => {
http_log_data.insert("status".to_string(), res.status().as_u16().to_string()); http_log_data.insert("status".to_string(), res.status().as_u16().to_string());
if !uri.path().starts_with(&assets_prefix) { if !uri.path().starts_with(assets_prefix) {
self.args.log_http.log(&http_log_data, None); self.args.http_logger.log(&http_log_data, None);
} }
res res
} }
@@ -116,7 +117,7 @@ impl Server {
*res.status_mut() = status; *res.status_mut() = status;
http_log_data.insert("status".to_string(), status.as_u16().to_string()); http_log_data.insert("status".to_string(), status.as_u16().to_string());
self.args self.args
.log_http .http_logger
.log(&http_log_data, Some(err.to_string())); .log(&http_log_data, Some(err.to_string()));
res res
} }
@@ -148,12 +149,7 @@ impl Server {
} }
}; };
let guard = self.args.auth.guard( let guard = self.args.auth.guard(&relative_path, &method, authorization);
&relative_path,
&method,
authorization,
self.args.auth_method.clone(),
);
let (user, access_paths) = match guard { let (user, access_paths) = match guard {
(None, None) => { (None, None) => {
@@ -184,7 +180,7 @@ impl Server {
.iter() .iter()
.any(|v| v.as_str() == req_path) .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?; .await?;
} else { } else {
status_not_found(&mut res); status_not_found(&mut res);
@@ -340,6 +336,12 @@ impl Server {
method => match method.as_str() { method => match method.as_str() {
"PROPFIND" => { "PROPFIND" => {
if is_dir { if is_dir {
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) self.handle_propfind_dir(path, headers, access_paths, &mut res)
.await?; .await?;
} else if is_file { } else if is_file {
@@ -425,7 +427,12 @@ impl Server {
futures::pin_mut!(body_reader); 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; *res.status_mut() = StatusCode::CREATED;
Ok(()) Ok(())
@@ -453,7 +460,7 @@ impl Server {
) -> Result<()> { ) -> Result<()> {
let mut paths = vec![]; let mut paths = vec![];
if exist { if exist {
paths = match self.list_dir(path, path, access_paths).await { paths = match self.list_dir(path, path, access_paths.clone()).await {
Ok(paths) => paths, Ok(paths) => paths,
Err(_) => { Err(_) => {
status_forbid(res); status_forbid(res);
@@ -461,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( async fn handle_search_dir(
@@ -483,12 +499,14 @@ impl Server {
let hidden = Arc::new(self.args.hidden.to_vec()); let hidden = Arc::new(self.args.hidden.to_vec());
let hidden = hidden.clone(); let hidden = hidden.clone();
let running = self.running.clone(); let running = self.running.clone();
let access_paths = access_paths.clone();
let search_paths = tokio::task::spawn_blocking(move || { let search_paths = tokio::task::spawn_blocking(move || {
let mut paths: Vec<PathBuf> = vec![]; let mut paths: Vec<PathBuf> = vec![];
for dir in access_paths.leaf_paths(&path_buf) { for dir in access_paths.leaf_paths(&path_buf) {
let mut it = WalkDir::new(&dir).into_iter(); let mut it = WalkDir::new(&dir).into_iter();
it.next();
while let Some(Ok(entry)) = it.next() { while let Some(Ok(entry)) = it.next() {
if !running.load(Ordering::SeqCst) { if !running.load(atomic::Ordering::SeqCst) {
break; break;
} }
let entry_path = entry.path(); let entry_path = entry.path();
@@ -526,7 +544,16 @@ 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( async fn handle_zip_dir(
@@ -593,7 +620,7 @@ impl Server {
res: &mut Response, res: &mut Response,
) -> Result<()> { ) -> Result<()> {
if path.extension().is_none() { 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) self.handle_send_file(&path, headers, head_only, res)
.await?; .await?;
} else { } else {
@@ -609,7 +636,7 @@ impl Server {
res: &mut Response, res: &mut Response,
) -> Result<bool> { ) -> Result<bool> {
if let Some(name) = req_path.strip_prefix(&self.assets_prefix) { 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) => { Some(assets_path) => {
let path = assets_path.join(name); let path = assets_path.join(name);
self.handle_send_file(&path, headers, false, res).await?; self.handle_send_file(&path, headers, false, res).await?;
@@ -749,7 +776,10 @@ impl Server {
) -> Result<()> { ) -> Result<()> {
let (file, meta) = tokio::join!(fs::File::open(path), fs::metadata(path),); let (file, meta) = tokio::join!(fs::File::open(path), fs::metadata(path),);
let (file, meta) = (file?, meta?); 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![]; let mut buffer: Vec<u8> = vec![];
file.take(1024).read_to_end(&mut buffer).await?; file.take(1024).read_to_end(&mut buffer).await?;
let editable = meta.len() <= TEXT_MAX_SIZE && content_inspector::inspect(&buffer).is_text(); let editable = meta.len() <= TEXT_MAX_SIZE && content_inspector::inspect(&buffer).is_text();
@@ -759,7 +789,7 @@ impl Server {
uri_prefix: self.args.uri_prefix.clone(), uri_prefix: self.args.uri_prefix.clone(),
allow_upload: self.args.allow_upload, allow_upload: self.args.allow_upload,
allow_delete: self.args.allow_delete, allow_delete: self.args.allow_delete,
auth: self.args.auth.valid(), auth: self.args.auth.exist(),
user, user,
editable, editable,
}; };
@@ -767,7 +797,7 @@ impl Server {
.typed_insert(ContentType::from(mime_guess::mime::TEXT_HTML_UTF_8)); .typed_insert(ContentType::from(mime_guess::mime::TEXT_HTML_UTF_8));
let output = self let output = self
.html .html
.replace("__ASSERTS_PREFIX__", &self.assets_prefix) .replace("__ASSETS_PREFIX__", &self.assets_prefix)
.replace("__INDEX_DATA__", &serde_json::to_string(&data)?); .replace("__INDEX_DATA__", &serde_json::to_string(&data)?);
res.headers_mut() res.headers_mut()
.typed_insert(ContentLength(output.as_bytes().len() as u64)); .typed_insert(ContentLength(output.as_bytes().len() as u64));
@@ -795,12 +825,15 @@ impl Server {
}, },
None => 1, 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], Some(v) => vec![v],
None => vec![], None => vec![],
}; };
if depth != 0 { if depth != 0 {
match self.list_dir(path, &self.args.path, access_paths).await { match self
.list_dir(path, &self.args.serve_path, access_paths)
.await
{
Ok(child) => paths.extend(child), Ok(child) => paths.extend(child),
Err(_) => { Err(_) => {
status_forbid(res); status_forbid(res);
@@ -820,7 +853,7 @@ impl Server {
} }
async fn handle_propfind_file(&self, path: &Path, res: &mut Response) -> Result<()> { 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())); res_multistatus(res, &pathitem.to_dav_xml(self.args.uri_prefix.as_str()));
} else { } else {
status_not_found(res); status_not_found(res);
@@ -920,17 +953,16 @@ impl Server {
query_params: &HashMap<String, String>, query_params: &HashMap<String, String>,
head_only: bool, head_only: bool,
user: Option<String>, user: Option<String>,
access_paths: AccessPaths,
res: &mut Response, res: &mut Response,
) -> Result<()> { ) -> Result<()> {
if let Some(sort) = query_params.get("sort") { if let Some(sort) = query_params.get("sort") {
if sort == "name" { if sort == "name" {
paths.sort_by(|v1, v2| { paths.sort_by(|v1, v2| v1.sort_by_name(v2))
alphanumeric_sort::compare_str(v1.name.to_lowercase(), v2.name.to_lowercase())
})
} else if sort == "mtime" { } 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" { } 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 if query_params
.get("order") .get("order")
@@ -940,7 +972,7 @@ impl Server {
paths.reverse() paths.reverse()
} }
} else { } else {
paths.sort_unstable(); paths.sort_by(|v1, v2| v1.sort_by_name(v2))
} }
if query_params.contains_key("simple") { if query_params.contains_key("simple") {
let output = paths let output = paths
@@ -964,17 +996,21 @@ impl Server {
} }
return Ok(()); 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 { let data = IndexData {
kind: DataKind::Index, kind: DataKind::Index,
href, href,
uri_prefix: self.args.uri_prefix.clone(), uri_prefix: self.args.uri_prefix.clone(),
allow_upload: self.args.allow_upload, allow_upload: self.args.allow_upload && readwrite,
allow_delete: self.args.allow_delete, allow_delete: self.args.allow_delete && readwrite,
allow_search: self.args.allow_search, allow_search: self.args.allow_search,
allow_archive: self.args.allow_archive, allow_archive: self.args.allow_archive,
dir_exists: exist, dir_exists: exist,
auth: self.args.auth.valid(), auth: self.args.auth.exist(),
user, user,
paths, paths,
}; };
@@ -986,7 +1022,7 @@ impl Server {
res.headers_mut() res.headers_mut()
.typed_insert(ContentType::from(mime_guess::mime::TEXT_HTML_UTF_8)); .typed_insert(ContentType::from(mime_guess::mime::TEXT_HTML_UTF_8));
self.html self.html
.replace("__ASSERTS_PREFIX__", &self.assets_prefix) .replace("__ASSETS_PREFIX__", &self.assets_prefix)
.replace("__INDEX_DATA__", &serde_json::to_string(&data)?) .replace("__INDEX_DATA__", &serde_json::to_string(&data)?)
}; };
res.headers_mut() res.headers_mut()
@@ -999,9 +1035,9 @@ impl Server {
} }
fn auth_reject(&self, res: &mut Response) -> Result<()> { fn auth_reject(&self, res: &mut Response) -> Result<()> {
let value = self.args.auth_method.www_auth(false)?;
set_webdav_headers(res); set_webdav_headers(res);
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 // set 401 to make the browser pop up the login box
*res.status_mut() = StatusCode::UNAUTHORIZED; *res.status_mut() = StatusCode::UNAUTHORIZED;
Ok(()) Ok(())
@@ -1011,7 +1047,7 @@ impl Server {
fs::canonicalize(path) fs::canonicalize(path)
.await .await
.ok() .ok()
.map(|v| v.starts_with(&self.args.path)) .map(|v| v.starts_with(&self.args.serve_path))
.unwrap_or_default() .unwrap_or_default()
} }
@@ -1034,12 +1070,10 @@ impl Server {
}; };
let authorization = headers.get(AUTHORIZATION); let authorization = headers.get(AUTHORIZATION);
let guard = self.args.auth.guard( let guard = self
&relative_path, .args
req.method(), .auth
authorization, .guard(&relative_path, req.method(), authorization);
self.args.auth_method.clone(),
);
match guard { match guard {
(_, Some(_)) => {} (_, Some(_)) => {}
@@ -1079,14 +1113,14 @@ impl Server {
fn join_path(&self, path: &str) -> Option<PathBuf> { fn join_path(&self, path: &str) -> Option<PathBuf> {
if path.is_empty() { if path.is_empty() {
return Some(self.args.path.clone()); return Some(self.args.serve_path.clone());
} }
let path = if cfg!(windows) { let path = if cfg!(windows) {
path.replace('/', "\\") path.replace('/', "\\")
} else { } else {
path.to_string() path.to_string()
}; };
Some(self.args.path.join(path)) Some(self.args.serve_path.join(path))
} }
async fn list_dir( async fn list_dir(
@@ -1242,12 +1276,45 @@ impl PathItem {
), ),
} }
} }
pub fn base_name(&self) -> &str { pub fn base_name(&self) -> &str {
self.name.split('/').last().unwrap_or_default() 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 { enum PathType {
Dir, Dir,
SymlinkDir, SymlinkDir,
@@ -1255,6 +1322,24 @@ enum PathType {
SymlinkFile, 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 { fn to_timestamp(time: &SystemTime) -> u64 {
time.duration_since(SystemTime::UNIX_EPOCH) time.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default() .unwrap_or_default()
@@ -1286,17 +1371,15 @@ fn add_cors(res: &mut Response) {
.typed_insert(AccessControlAllowCredentials); .typed_insert(AccessControlAllowCredentials);
res.headers_mut().insert( res.headers_mut().insert(
"Access-Control-Allow-Methods", "Access-Control-Allow-Methods",
HeaderValue::from_static("GET,HEAD,PUT,OPTIONS,DELETE,PROPFIND,COPY,MOVE"), HeaderValue::from_static("*"),
); );
res.headers_mut().insert( res.headers_mut().insert(
"Access-Control-Allow-Headers", "Access-Control-Allow-Headers",
HeaderValue::from_static("Authorization,Destination,Range,Content-Type"), HeaderValue::from_static("Authorization,*"),
); );
res.headers_mut().insert( res.headers_mut().insert(
"Access-Control-Expose-Headers", "Access-Control-Expose-Headers",
HeaderValue::from_static( HeaderValue::from_static("Authorization,*"),
"WWW-Authenticate,Content-Range,Accept-Ranges,Content-Disposition",
),
); );
} }
@@ -1329,8 +1412,9 @@ async fn zip_dir<W: AsyncWrite + Unpin>(
let mut paths: Vec<PathBuf> = vec![]; let mut paths: Vec<PathBuf> = vec![];
for dir in access_paths.leaf_paths(&dir_clone) { for dir in access_paths.leaf_paths(&dir_clone) {
let mut it = WalkDir::new(&dir).into_iter(); let mut it = WalkDir::new(&dir).into_iter();
it.next();
while let Some(Ok(entry)) = it.next() { while let Some(Ok(entry)) = it.next() {
if !running.load(Ordering::SeqCst) { if !running.load(atomic::Ordering::SeqCst) {
break; break;
} }
let entry_path = entry.path(); let entry_path = entry.path();

View File

@@ -29,6 +29,49 @@ fn auth(#[with(&["--auth", "user:pass@/:rw", "-A"])] server: TestServer) -> Resu
Ok(()) Ok(())
} }
const HASHED_PASSWORD_AUTH: &str = "user:$6$gQxZwKyWn/ZmWEA2$4uV7KKMnSUnET2BtWTj/9T5.Jq3h/MdkOlnIl5hdlTxDZ4MZKmJ.kl6C.NL9xnNPqC4lVHC1vuI0E5cLpTJX81@/:rw"; // user:pass
#[rstest]
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] #[rstest]
fn auth_skip(#[with(&["--auth", "@/"])] server: TestServer) -> Result<(), Error> { fn auth_skip(#[with(&["--auth", "@/"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?; let resp = reqwest::blocking::get(server.url())?;
@@ -108,8 +151,8 @@ fn auth_nest_share(
} }
#[rstest] #[rstest]
#[case(server(&["--auth", "user:pass@/:rw", "--auth-method", "basic", "-A"]), "user", "pass")] #[case(server(&["--auth", "user:pass@/:rw", "-A"]), "user", "pass")]
#[case(server(&["--auth", "u1:p1@/:rw", "--auth-method", "basic", "-A"]), "u1", "p1")] #[case(server(&["--auth", "u1:p1@/:rw", "-A"]), "u1", "p1")]
fn auth_basic( fn auth_basic(
#[case] server: TestServer, #[case] server: TestServer,
#[case] user: &str, #[case] user: &str,
@@ -184,3 +227,34 @@ fn auth_partial_index(
); );
Ok(()) 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<_>>(); .collect::<Vec<_>>();
assert!(!urls.is_empty()); assert!(!urls.is_empty());
for url in urls { reqwest::blocking::get(urls[0])?.error_for_status()?;
reqwest::blocking::get(url)?.error_for_status()?;
}
child.kill()?; 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!( assert_eq!(
resp.headers().get("access-control-allow-methods").unwrap(), resp.headers().get("access-control-allow-methods").unwrap(),
"GET,HEAD,PUT,OPTIONS,DELETE,PROPFIND,COPY,MOVE" "*"
); );
assert_eq!( assert_eq!(
resp.headers().get("access-control-allow-headers").unwrap(), resp.headers().get("access-control-allow-headers").unwrap(),
"Authorization,Destination,Range,Content-Type" "Authorization,*"
); );
assert_eq!( assert_eq!(
resp.headers().get("access-control-expose-headers").unwrap(), resp.headers().get("access-control-expose-headers").unwrap(),
"WWW-Authenticate,Content-Range,Accept-Ranges,Content-Disposition" "Authorization,*"
); );
Ok(()) 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,7 +46,7 @@ pub fn tmpdir() -> TempDir {
let tmpdir = assert_fs::TempDir::new().expect("Couldn't create a temp dir for tests"); let tmpdir = assert_fs::TempDir::new().expect("Couldn't create a temp dir for tests");
for file in FILES { for file in FILES {
if *file == BIN_FILE { if *file == BIN_FILE {
tmpdir.child(file).write_binary(b"bin\0\0123").unwrap(); tmpdir.child(file).write_binary(b"bin\0\x00123").unwrap();
} else { } else {
tmpdir tmpdir
.child(file) .child(file)
@@ -58,7 +58,7 @@ pub fn tmpdir() -> TempDir {
if *directory == DIR_ASSETS { if *directory == DIR_ASSETS {
tmpdir tmpdir
.child(format!("{}{}", directory, "index.html")) .child(format!("{}{}", directory, "index.html"))
.write_str("__ASSERTS_PREFIX__index.js;DATA = __INDEX_DATA__") .write_str("__ASSETS_PREFIX__index.js;DATA = __INDEX_DATA__")
.unwrap(); .unwrap();
} else { } else {
for file in FILES { for file in FILES {
@@ -68,7 +68,7 @@ pub fn tmpdir() -> TempDir {
if *file == BIN_FILE { if *file == BIN_FILE {
tmpdir tmpdir
.child(format!("{directory}{file}")) .child(format!("{directory}{file}"))
.write_binary(b"bin\0\0123") .write_binary(b"bin\0\x00123")
.unwrap(); .unwrap();
} else { } else {
tmpdir tmpdir

View File

@@ -123,6 +123,15 @@ fn get_dir_search3(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
Ok(()) 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] #[rstest]
fn head_dir_search(#[with(&["-A"])] server: TestServer) -> Result<(), Error> { fn head_dir_search(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"HEAD", format!("{}?q={}", server.url(), "test.html")).send()?; let resp = fetch!(b"HEAD", format!("{}?q={}", server.url(), "test.html")).send()?;

View File

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

View File

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

View File

@@ -59,7 +59,8 @@ pub fn encode_uri(v: &str) -> String {
parts.join("/") 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 lines: Vec<&str> = content.lines().collect();
let line = lines.iter().find(|v| v.contains("DATA ="))?; let line = lines.iter().find(|v| v.contains("DATA ="))?;
let line_col = line.find("DATA =").unwrap() + 6; let line_col = line.find("DATA =").unwrap() + 6;