Compare commits

..

115 Commits

Author SHA1 Message Date
sigoden
58a46f7c3a chore: release v0.39.0 (#345) 2024-01-11 16:50:25 +08:00
sigoden
ef757281b3 chore: release v0.39.0 2024-01-11 08:31:56 +00:00
sigoden
de0614816a refactor: propfind with auth no need to list all (#344) 2024-01-11 16:10:10 +08:00
sigoden
81d2c49e3f chore: update bug_report issue template 2024-01-11 07:04:44 +00:00
sigoden
ee21894452 feat: supports resumable uploads (#343) 2024-01-11 14:56:30 +08:00
sigoden
0ac0c048ec fix: corrupted zip when downloading large folders (#337) 2024-01-07 10:50:15 +08:00
sigoden
17063454d3 chore: update bug_report issue tempalte 2024-01-05 00:37:41 +00:00
sigoden
af347f9cf0 feat: auth supports forbidden permissions (#329) 2023-12-23 18:36:46 +08:00
sigoden
006e03ed30 fix: serve files with names containing newline char (#328) 2023-12-23 15:40:41 +08:00
sigoden
77f86a4c60 fix: auth precedence (#325) 2023-12-21 17:28:13 +08:00
sigoden
a66f95b39f chore: log error during connection 2023-12-21 08:08:15 +00:00
sigoden
52506bc01f refactor: optimize http range parsing and handling (#323) 2023-12-21 15:46:55 +08:00
sigoden
270cc0cba2 feat: upgrade to hyper 1.0 (#321) 2023-12-21 14:24:20 +08:00
sigoden
5988442d5c chore: remove debug print 2023-12-14 11:08:10 +00:00
sigoden
3873f4794a feat: add --compress option (#319) 2023-12-14 18:59:28 +08:00
plantatorbob
cd84dff87f fix: upload more than 100 files in directory (#317) 2023-12-11 18:28:11 +08:00
sigoden
8590f3e841 chore: improve readme 2023-12-09 09:17:36 +00:00
sigoden
44a4ddf973 refactor: change the value name of --config (#313) 2023-12-07 15:14:41 +08:00
sigoden
37800f630d refactor: change the format of www-authenticate (#312) 2023-12-07 15:04:14 +08:00
sigoden
5c850256f4 feat: empty search ?q= list all paths (#311) 2023-12-07 06:55:17 +08:00
sigoden
0cec573579 chore: release v0.38.0 2023-11-29 07:49:50 +08:00
sigoden
073b098111 feat: ui supports view file (#301) 2023-11-28 07:14:53 +08:00
sigoden
6ff8b29b69 feat: more flexible config values (#299) 2023-11-27 04:24:25 +08:00
sigoden
7584fe3d08 feat: deprecate the use of | to separate auth rules (#298) 2023-11-26 22:15:49 +08:00
sigoden
653cd167d0 feat: password can contain : @ | (#297) 2023-11-26 20:47:57 +08:00
sigoden
ab29e39148 chore: trivial updates 2023-11-26 15:04:12 +08:00
sigoden
f8d6859354 refactor: ui improve uploading progress (#296) 2023-11-26 10:23:37 +08:00
sigoden
130435c387 chore: update readme 2023-11-25 19:07:37 +08:00
sigoden
afdfde01f0 fix: unable to start if config file omit bind/port fields (#294) 2023-11-25 18:54:36 +08:00
sigoden
ae97c714d6 refactor: ui change the cursor for upload-btn to a pointer (#291) 2023-11-21 16:24:59 +08:00
sigoden
c352dab470 refactor: take improvements from the edge browser (#289) 2023-11-15 19:44:44 +08:00
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
sigoden
65da9bedee chore(release): version 0.32.0 (#183) 2023-02-24 08:21:57 +08:00
sigoden
e468d823cc chore: update readme 2023-02-22 11:26:17 +08:00
sigoden
902a60563d chore: ui change edit icon 2023-02-22 10:37:54 +08:00
sigoden
f6c2ed2974 chore: optimize ui 2023-02-22 10:09:34 +08:00
sigoden
8f4cbb4826 chore: use anyhow to handle error 2023-02-21 17:23:24 +08:00
sigoden
2064d7803a chore: bump deps 2023-02-21 16:39:57 +08:00
sigoden
ad0be71557 chore: optimize for test auth 2023-02-21 16:16:49 +08:00
sigoden
6d9758c71d feat: ui improves the login experience (#182)
close #157 #158
2023-02-21 12:42:40 +08:00
sigoden
a61fda6e80 feat: support new file (#180) 2023-02-21 08:45:52 +08:00
sigoden
6625c4d3d0 chore: optimize ui 2023-02-21 08:14:03 +08:00
sigoden
dd6973468c feat: support edit files (#179)
close #172
2023-02-20 22:50:24 +08:00
sigoden
c6c78a16c5 chore: optimize ui 2023-02-20 17:23:31 +08:00
sigoden
111103f26b fix: clear search input also clear query (#178)
close #161
2023-02-20 12:07:40 +08:00
sigoden
7d6d7d49ca feat: API to search and list directories (#177)
use `?simple` to output path name only.
use `?json` to output paths in json format.
By default, output html page.

close #166
2023-02-20 11:05:53 +08:00
sigoden
c6dcaf95d4 chore: hide env keys from help text (#176) 2023-02-19 22:48:41 +08:00
sigoden
b7c5119c2e feat: hiding only directories instead of files (#175)
A `--hidden` pattern with `/` suffix means hiding only directories not files.
A `--hidden` pattern without `/` will hide matching files and directories.
2023-02-19 22:03:59 +08:00
horizon
0000bd27f5 fix: remove Method::Options auth check (#168)
* fix: remove Method::Options auth check

* add tests

---------

Co-authored-by: sigoden <sigoden@gmail.com>
2023-02-19 12:30:14 +08:00
sigoden
47883376c1 chore: fix cargo clippy (#174) 2023-02-19 12:24:42 +08:00
MuXiu1997
fea9bf988a feat: use env var for args (#170)
closed #160
2023-02-19 11:40:14 +08:00
MoonFruit
b6d555158c chore: add install instruction for Homebrew (#163) 2022-12-27 10:16:23 +08:00
sigoden
628d863d2e chore: improve code quanity 2022-12-11 15:18:44 +08:00
sigoden
8d9705caa4 feat: add option --allow-archive (#152)
BREAKING CHANGE: explicitly allow download folder as zip file
2022-12-10 11:09:42 +08:00
Kian-Meng Ang
7eef4407fc docs: fix typos (#147)
Found via `codespell -S target -L crate,nd`
2022-12-10 09:18:54 +08:00
Sylvain Prat
f061365587 fix: set the STOPSIGNAL to SIGINT for Dockerfile 2022-12-10 08:31:46 +08:00
40 changed files with 4920 additions and 2452 deletions

View File

@@ -9,9 +9,10 @@ about: Create a report to help us improve
**Log** **Log**
If applicable, add logs to help explain your problem. The dufs log is crucial for locating the problem, so please do not omit it.
**Environment:** **Environment:**
- Dufs version: - Dufs version:
- Browser/Webdav Info: - Browser/Webdav info:
- OS Info: - OS info:
- Proxy server: e.g. nginx, cloudflare

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

@@ -7,7 +7,9 @@ on:
jobs: jobs:
release: release:
name: Publish to Github Relases name: Publish to Github Releases
permissions:
contents: write
outputs: outputs:
rc: ${{ steps.check-tag.outputs.rc }} rc: ${{ steps.check-tag.outputs.rc }}
@@ -25,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: ""
@@ -69,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
@@ -105,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
@@ -121,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
@@ -130,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
@@ -161,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 }}
@@ -189,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,167 @@
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.39.0] - 2024-01-11
### Bug Fixes
- Upload more than 100 files in directory ([#317](https://github.com/sigoden/dufs/issues/317))
- Auth precedence ([#325](https://github.com/sigoden/dufs/issues/325))
- Serve files with names containing newline char ([#328](https://github.com/sigoden/dufs/issues/328))
- Corrupted zip when downloading large folders ([#337](https://github.com/sigoden/dufs/issues/337))
### Features
- Empty search `?q=` list all paths ([#311](https://github.com/sigoden/dufs/issues/311))
- Add `--compress` option ([#319](https://github.com/sigoden/dufs/issues/319))
- Upgrade to hyper 1.0 ([#321](https://github.com/sigoden/dufs/issues/321))
- Auth supports forbidden permissions ([#329](https://github.com/sigoden/dufs/issues/329))
- Supports resumable uploads ([#343](https://github.com/sigoden/dufs/issues/343))
### Refactor
- Change the format of www-authenticate ([#312](https://github.com/sigoden/dufs/issues/312))
- Change the value name of `--config` ([#313](https://github.com/sigoden/dufs/issues/313))
- Optimize http range parsing and handling ([#323](https://github.com/sigoden/dufs/issues/323))
- Propfind with auth no need to list all ([#344](https://github.com/sigoden/dufs/issues/344))
## [0.38.0] - 2023-11-28
### Bug Fixes
- Unable to start if config file omit bind/port fields ([#294](https://github.com/sigoden/dufs/issues/294))
### Features
- Password can contain `:` `@` `|` ([#297](https://github.com/sigoden/dufs/issues/297))
- Deprecate the use of `|` to separate auth rules ([#298](https://github.com/sigoden/dufs/issues/298))
- More flexible config values ([#299](https://github.com/sigoden/dufs/issues/299))
- Ui supports view file ([#301](https://github.com/sigoden/dufs/issues/301))
### Refactor
- Take improvements from the edge browser ([#289](https://github.com/sigoden/dufs/issues/289))
- Ui change the cursor for upload-btn to a pointer ([#291](https://github.com/sigoden/dufs/issues/291))
- Ui improve uploading progress ([#296](https://github.com/sigoden/dufs/issues/296))
## [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
- Set the STOPSIGNAL to SIGINT for Dockerfile
- Remove Method::Options auth check ([#168](https://github.com/sigoden/dufs/issues/168))
- Clear search input also clear query ([#178](https://github.com/sigoden/dufs/issues/178))
### Features
- [**breaking**] Add option --allow-archive ([#152](https://github.com/sigoden/dufs/issues/152))
- Use env var for args ([#170](https://github.com/sigoden/dufs/issues/170))
- Hiding only directories instead of files ([#175](https://github.com/sigoden/dufs/issues/175))
- API to search and list directories ([#177](https://github.com/sigoden/dufs/issues/177))
- Support edit files ([#179](https://github.com/sigoden/dufs/issues/179))
- Support new file ([#180](https://github.com/sigoden/dufs/issues/180))
- Ui improves the login experience ([#182](https://github.com/sigoden/dufs/issues/182))
## [0.31.0] - 2022-11-11 ## [0.31.0] - 2022-11-11
### Bug Fixes ### Bug Fixes
@@ -40,7 +201,7 @@ All notable changes to this project will be documented in this file.
### Bug Fixes ### Bug Fixes
- File path contains special charactors ([#114](https://github.com/sigoden/dufs/issues/114)) - File path contains special characters ([#114](https://github.com/sigoden/dufs/issues/114))
### Features ### Features
@@ -78,7 +239,7 @@ All notable changes to this project will be documented in this file.
### Bug Fixes ### Bug Fixes
- Unexpect stack overflow when searching a lot ([#87](https://github.com/sigoden/dufs/issues/87)) - Unexpected stack overflow when searching a lot ([#87](https://github.com/sigoden/dufs/issues/87))
### Features ### Features
@@ -171,7 +332,7 @@ All notable changes to this project will be documented in this file.
### Refactor ### Refactor
- Trival changes ([#41](https://github.com/sigoden/dufs/issues/41)) - Trivial changes ([#41](https://github.com/sigoden/dufs/issues/41))
## [0.16.0] - 2022-06-12 ## [0.16.0] - 2022-06-12

1712
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.31.0" version = "0.39.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"
@@ -11,53 +11,66 @@ categories = ["command-line-utilities", "web-programming::http-server"]
keywords = ["static", "file", "server", "webdav", "cli"] keywords = ["static", "file", "server", "webdav", "cli"]
[dependencies] [dependencies]
clap = { version = "4", features = ["wrap_help"] } 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"] } tokio-util = { version = "0.7", features = ["io-util", "compat"] }
hyper = { version = "0.14", features = ["http1", "server", "tcp", "stream"] } hyper = { version = "1.0", features = ["http1", "server"] }
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-util = { version = "0.3", default-features = false, features = ["alloc"] }
base64 = "0.13" async_zip = { version = "0.0.16", default-features = false, features = ["deflate", "bzip2", "xz", "chrono", "tokio"] }
async_zip = { version = "0.0.8", default-features = false, features = ["deflate"] } headers = "0.4"
headers = "0.3"
mime_guess = "2.0" mime_guess = "2.0"
if-addrs = "0.7" if-addrs = "0.11"
rustls = { version = "0.20", default-features = false, features = ["tls12"], optional = true } rustls-pemfile = { version = "2.0", optional = true }
rustls-pemfile = { version = "1", optional = true } tokio-rustls = { version = "0.25", optional = true }
tokio-rustls = { version = "0.23", 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.4" 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"
anyhow = "1.0"
chardetng = "0.1"
glob = "0.3"
indexmap = "2.0"
serde_yaml = "0.9"
sha-crypt = "0.5"
base64 = "0.21"
smart-default = "0.7"
rustls-pki-types = "1.0"
hyper-util = { version = "0.1", features = ["server-auto", "tokio"] }
http-body-util = "0.1"
bytes = "1.5"
pin-project-lite = "0.2"
[features] [features]
default = ["tls"] default = ["tls"]
tls = ["rustls", "rustls-pemfile", "tokio-rustls"] tls = ["rustls-pemfile", "tokio-rustls"]
[dev-dependencies] [dev-dependencies]
assert_cmd = "2" 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.15" rstest = "0.18"
regex = "1" regex = "1"
url = "2" url = "2"
diqwest = { version = "1", features = ["blocking"] } diqwest = { version = "2.0", features = ["blocking"], default-features = false }
predicates = "2" predicates = "3"
indexmap = "1.9"
[profile.release] [profile.release]
opt-level = 3
lto = true lto = true
strip = true codegen-units = 1
opt-level = "z" panic = "abort"
strip = "symbols"

View File

@@ -15,4 +15,5 @@ RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
FROM scratch FROM scratch
COPY --from=builder /bin/dufs /bin/dufs COPY --from=builder /bin/dufs /bin/dufs
STOPSIGNAL SIGINT
ENTRYPOINT ["/bin/dufs"] ENTRYPOINT ["/bin/dufs"]

216
README.md
View File

@@ -5,16 +5,16 @@
Dufs is a distinctive utility file server that supports static serving, uploading, searching, accessing control, webdav... Dufs is a distinctive utility file server that supports static serving, uploading, searching, accessing control, webdav...
![demo](https://user-images.githubusercontent.com/4012553/189362357-b2f7aa6b-9df0-4438-a57c-c8f92850fc4f.png) ![demo](https://user-images.githubusercontent.com/4012553/220513063-ff0f186b-ac54-4682-9af4-47a9781dee0d.png)
## Features ## Features
- Serve static files - Serve static files
- Download folder as zip file - Download folder as zip file
- Upload files and folders (Drag & Drop) - Upload files and folders (Drag & Drop)
- Search files - Create/Edit/Search files
- Partial responses (Parallel/Resume download) - Resumable/partial uploads/downloads
- Path level access control - Access control
- Support https - Support https
- Support webdav - Support webdav
- Easy to use with curl - Easy to use with curl
@@ -33,6 +33,12 @@ cargo install dufs
docker run -v `pwd`:/data -p 5000:5000 --rm -it sigoden/dufs /data -A docker run -v `pwd`:/data -p 5000:5000 --rm -it sigoden/dufs /data -A
``` ```
### With [Homebrew](https://brew.sh)
```
brew install dufs
```
### Binaries on macOS, Linux, Windows ### Binaries on macOS, Linux, Windows
Download from [Github Releases](https://github.com/sigoden/dufs/releases), unzip and add dufs to your $PATH. Download from [Github Releases](https://github.com/sigoden/dufs/releases), unzip and add dufs to your $PATH.
@@ -42,45 +48,47 @@ 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] [root] Usage: dufs [OPTIONS] [serve-path]
Arguments: Arguments:
[root] Specific path to serve [default: .] [serve-path] Specific path to serve [default: .]
Options: Options:
-c, --config <file> 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 for path -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
--allow-search Allow search files/folders --allow-search Allow search files/folders
--allow-symlink Allow symlink to files/folders outside root directory --allow-symlink Allow symlink to files/folders outside root directory
--allow-archive Allow zip archive generation
--enable-cors Enable CORS, sets `Access-Control-Allow-Origin: *` --enable-cors Enable CORS, sets `Access-Control-Allow-Origin: *`
--render-index Serve index.html when requesting a directory, returns 404 if not found index.html --render-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
--log-format <format> Customize http log format
--compress <level> Set zip compress level [default: low] [possible values: none, low, medium, high]
--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-cert <path> Path to an SSL/TLS certificate to serve with HTTPS
--tls-key <path> Path to the SSL/TLS certificate's private key --tls-key <path> Path to the SSL/TLS certificate's private key
--log-format <format> Customize http log format -h, --help Print help
--completions <shell> Print shell completion script for <shell> [possible values: bash, elvish, fish, powershell, zsh] -V, --version Print version
-h, --help Print help information
-V, --version Print version information
``` ```
## Examples ## Examples
Serve current working directory Serve current working directory in read-only mode
``` ```
dufs dufs
``` ```
Allow all operations like upload/delete/search... Allow all operations like upload/delete/search/create/edit...
``` ```
dufs -A dufs -A
@@ -119,7 +127,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
@@ -164,66 +172,109 @@ Delete a file/folder
curl -X DELETE http://127.0.0.1:5000/path-to-file-or-folder curl -X DELETE http://127.0.0.1:5000/path-to-file-or-folder
``` ```
Create a directory
```
curl -X MKCOL https://127.0.0.1:5000/path-to-folder
```
Move the file/folder to the new path
```
curl -X MOVE https://127.0.0.1:5000/path -H "Destination: https://127.0.0.1:5000/new-path"
```
List/search directory contents
```
curl http://127.0.0.1:5000?q=Dockerfile # search for files, similar to `find -name Dockerfile`
curl http://127.0.0.1:5000?simple # output names only, similar to `ls -1`
curl http://127.0.0.1:5000?json # output paths in json format
```
With authorization
```
curl http://127.0.0.1:5000/file --user user:pass # basic auth
curl http://127.0.0.1:5000/file --user user:pass --digest # digest auth
```
Resumable downloads
```
curl -C- -o file http://127.0.0.1:5000/file
```
Resumable uploads
```
upload_offset=$(curl -I -s http://127.0.0.1:5000/file | tr -d '\r' | sed -n 's/content-length: //p')
dd skip=$upload_offset if=file status=none ibs=1 | \
curl -X PATCH -H "X-Update-Range: append" --data-binary @- http://127.0.0.1:5000/file
```
<details> <details>
<summary><h2>Advanced topics</h2></summary> <summary><h2>Advanced topics</h2></summary>
### Access Control ### 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 admin:admin@/:rw -a guest:guest@/
dufs -a <path>@<readwrite>@<readonly> dufs -a user:pass@/:rw,/dir1,/dir2:- -a @/
dufs -a <path>@<readwrite>@*
``` ```
- `<path>`: Protected url path 1. Use `@` to separate the account and paths. No account means anonymous user.
- `<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. 2. Use `:` to separate the username and password of the account.
- `<readonly>`: Account with readonly permissions. The permissions are search/view/download if dufs allow search, otherwise view/download.. 3. Use `,` to separate paths.
4. Use path suffix `:rw`, `:ro`, `:-` to set permissions: `read-write`, `read-only`, `forbidden`. `:ro` can be omitted.
- `-a admin:admin@/:rw`: `admin` has complete permissions for all paths.
- `-a guest:guest@/`: `guest` has read-only permissions for all paths.
- `-a user:pass@/:rw,/dir1,/dir2:-`: `user` has read-write permissions for `/*`, has read-only permissions for `/dir1/*`, but is fordden for `/dir2/*`.
- `-a @/`: All paths is publicly accessible, everyone can view/download it.
> There are no restrictions on using ':' and '@' characters in a password. For example, `user:pa:ss@1@/:rw` is valid, the password is `pa:ss@1`.
#### Hashed Password
DUFS supports the use of sha-512 hashed password.
Create hashed password
``` ```
dufs -A -a /@admin:admin $ mkpasswd -m sha-512 -s
Password: 123456
$6$qCAVUG7yn7t/hH4d$BWm8r5MoDywNmDP/J3V2S2a6flmKHC1IpblfoqZfuK.LtLBZ0KFXP9QIfJP8RqL8MCw4isdheoAMTuwOz.pAO/
``` ```
`admin` has all permissions for all paths.
Use hashed password
``` ```
dufs -A -a /@admin:admin@guest:guest dufs -a 'admin:$6$qCAVUG7yn7t/hH4d$BWm8r5MoDywNmDP/J3V2S2a6flmKHC1IpblfoqZfuK.LtLBZ0KFXP9QIfJP8RqL8MCw4isdheoAMTuwOz.pAO/@/:rw'
``` ```
`guest` has readonly permissions for all paths.
``` Two important things for hashed passwords:
dufs -A -a /@admin:admin@*
```
All paths is public, everyone can view/download it.
``` 1. Dufs only supports sha-512 hashed passwords, so ensure that the password string always starts with `$6$`.
dufs -A -a /@admin:admin -a /user1@user1:pass1 -a /user2@pass2:user2 2. Digest authentication does not function properly with hashed passwords.
```
`user1` has all permissions for `/user1*` path.
`user2` has all permissions for `/user2*` path.
```
dufs -a /@admin:admin
```
Since dufs only allows viewing/downloading, `admin` can only view/download files.
### Hide Paths ### 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 dufs --hidden .git,.DS_Store,tmp
``` ```
`--hidden` also supports a variant glob: > The glob used in --hidden only matches file and directory names, not paths. So `--hidden dir1/file` is invalid.
- `?` matches any single character
- `*` matches any (possibly empty) sequence of characters
- `**`, `[..]`, `[!..]` is not supported
```sh ```sh
dufs --hidden '.*' dufs --hidden '.*' # hidden dotfiles
dufs --hidden '*.log,*.lock' dufs --hidden '*/' # hidden all folders
dufs --hidden '*.log,*.lock' # hidden by exts
dufs --hidden '*.log' --hidden '*.lock'
``` ```
### Log Format ### Log Format
@@ -267,6 +318,71 @@ dufs --log-format '$remote_addr $remote_user "$request" $status' -a /@admin:admi
2022-08-06T07:04:37+08:00 INFO - 127.0.0.1 admin "GET /" 200 2022-08-06T07:04:37+08:00 INFO - 127.0.0.1 admin "GET /" 200
``` ```
## Environment variables
All options can be set using environment variables prefixed with `DUFS_`.
```
[serve-path] DUFS_SERVE_PATH="."
--config <file> 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=/static
--hidden <value> DUFS_HIDDEN=tmp,*.log,*.lock
-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=""
--compress <compress> DUFS_COMPRESS="low"
--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: 0.0.0.0
port: 5000
path-prefix: /dufs
hidden:
- tmp
- '*.log'
- '*.lock'
auth:
- admin:admin@/:rw
- user:pass@/src:rw,/share
- '@/' # According to the YAML spec, quoting is required.
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'
compress: low
tls-cert: tests/data/cert.pem
tls-key: tests/data/key_pkcs1.pem
```
### Customize UI ### Customize UI
Dufs allows users to customize the UI with your own assets. Dufs allows users to customize the UI with your own assets.
@@ -280,7 +396,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

@@ -1,5 +1,5 @@
html { html {
font-family: -apple-system,BlinkMacSystemFont,Roboto,Helvetica,Arial,sans-serif; font-family: -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif;
line-height: 1.5; line-height: 1.5;
color: #24292e; color: #24292e;
} }
@@ -7,6 +7,7 @@ html {
body { body {
/* prevent premature breadcrumb wrapping on mobile */ /* prevent premature breadcrumb wrapping on mobile */
min-width: 500px; min-width: 500px;
margin: 0;
} }
.hidden { .hidden {
@@ -17,7 +18,10 @@ body {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
padding: 1em 1em 0; padding: 0.6em 1em;
position: fixed;
width: 100%;
background-color: white;
} }
.breadcrumb { .breadcrumb {
@@ -25,28 +29,28 @@ body {
padding-right: 0.6em; padding-right: 0.6em;
} }
.breadcrumb > a { .breadcrumb>a {
color: #0366d6; color: #0366d6;
text-decoration: none; text-decoration: none;
} }
.breadcrumb > a:hover { .breadcrumb>a:hover {
text-decoration: underline; text-decoration: underline;
} }
/* final breadcrumb */ /* final breadcrumb */
.breadcrumb > b { .breadcrumb>b {
color: #24292e; color: #24292e;
} }
.breadcrumb > .separator { .breadcrumb>.separator {
color: #586069; color: #586069;
padding: 0 0.25em; padding: 0 0.25em;
} }
.breadcrumb svg { .breadcrumb svg {
height: 100%; height: 100%;
fill: rgba(3,47,98,0.5); fill: rgba(3, 47, 98, 0.5);
} }
.toolbox { .toolbox {
@@ -54,7 +58,8 @@ body {
margin-right: 10px; margin-right: 10px;
} }
.toolbox > div { .toolbox>a,
.toolbox>div {
/* vertically align with breadcrumb text */ /* vertically align with breadcrumb text */
height: 1.1rem; height: 1.1rem;
} }
@@ -68,6 +73,10 @@ body {
display: none; display: none;
} }
.upload-file label {
cursor: pointer;
}
.searchbar { .searchbar {
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: nowrap;
@@ -87,7 +96,6 @@ body {
font-size: 16px; font-size: 16px;
line-height: 16px; line-height: 16px;
padding: 1px; padding: 1px;
font-family: helvetica neue,luxi sans,Tahoma,hiragino sans gb,STHeiti,sans-serif;
background-color: transparent; background-color: transparent;
border: none; border: none;
outline: none; outline: none;
@@ -99,17 +107,11 @@ body {
cursor: pointer; cursor: pointer;
} }
.upload-status span {
width: 70px;
display: inline-block;
}
.main { .main {
padding: 0 1em; padding: 3.3em 1em 0;
} }
.empty-folder { .empty-folder {
padding-top: 1rem;
font-style: italic; font-style: italic;
} }
@@ -131,12 +133,16 @@ body {
padding-left: 0.6em; padding-left: 0.6em;
} }
.cell-status span {
display: inline-block;
}
.paths-table thead a { .paths-table thead a {
color: unset; color: unset;
text-decoration: none; text-decoration: none;
} }
.paths-table thead a > span { .paths-table thead a>span {
padding-left: 2px; padding-left: 2px;
} }
@@ -145,7 +151,7 @@ body {
} }
.paths-table .cell-actions { .paths-table .cell-actions {
width: 75px; width: 90px;
display: flex; display: flex;
padding-left: 0.6em; padding-left: 0.6em;
} }
@@ -163,7 +169,7 @@ body {
.path svg { .path svg {
height: 16px; height: 16px;
fill: rgba(3,47,98,0.5); fill: rgba(3, 47, 98, 0.5);
padding-right: 0.5em; padding-right: 0.5em;
vertical-align: text-top; vertical-align: text-top;
} }
@@ -189,6 +195,7 @@ body {
.action-btn { .action-btn {
padding-right: 0.3em; padding-right: 0.3em;
cursor: pointer;
} }
.uploaders-table { .uploaders-table {
@@ -199,6 +206,42 @@ body {
padding-right: 1em; padding-right: 1em;
} }
.editor {
width: 100%;
height: calc(100vh - 5rem);
border: 1px solid #ced4da;
outline: none;
padding: 5px;
}
.toolbox-right {
margin-left: auto;
margin-right: 2em;
}
.save-btn {
cursor: pointer;
-webkit-user-select: none;
user-select: none;
}
.user-btn {
display: flex;
align-items: center;
}
.user-name {
padding-left: 3px;
}
.not-editable {
font-style: italic;
}
.retry-btn {
cursor: pointer;
}
@media (min-width: 768px) { @media (min-width: 768px) {
.path a { .path a {
min-width: 400px; min-width: 400px;
@@ -228,6 +271,10 @@ body {
fill: #fff; fill: #fff;
} }
.head {
background-color: #111;
}
.searchbar { .searchbar {
background-color: #111; background-color: #111;
border-color: #fff6; border-color: #fff6;
@@ -244,4 +291,9 @@ body {
.paths-table tbody tr:hover { .paths-table tbody tr:hover {
background-color: #1a1a1a; background-color: #1a1a1a;
} }
.editor {
background: black;
color: white;
}
} }

View File

@@ -1,65 +1,133 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en-US">
<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>
<div class="head"> <div class="head">
<div class="breadcrumb"></div> <div class="breadcrumb"></div>
<div class="toolbox"> <div class="toolbox">
<div> <a href="" class="control download hidden" title="Download file" download="">
<a href="?zip" title="Download folder as a .zip file"> <svg width="16" height="16" viewBox="0 0 16 16">
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg> <path
</a> d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z" />
<path
d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z" />
</svg>
</a>
<div class="control move-file hidden" title="Move to new path">
<svg class="icon-move" width="16" height="16" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M1.5 1.5A.5.5 0 0 0 1 2v4.8a2.5 2.5 0 0 0 2.5 2.5h9.793l-3.347 3.346a.5.5 0 0 0 .708.708l4.2-4.2a.5.5 0 0 0 0-.708l-4-4a.5.5 0 0 0-.708.708L13.293 8.3H3.5A1.5 1.5 0 0 1 2 6.8V2a.5.5 0 0 0-.5-.5z">
</path>
</svg>
</div>
<div class="control delete-file hidden" title="Delete">
<svg class="icon-delete" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path
d="M6.854 7.146a.5.5 0 1 0-.708.708L7.293 9l-1.147 1.146a.5.5 0 0 0 .708.708L8 9.707l1.146 1.147a.5.5 0 0 0 .708-.708L8.707 9l1.147-1.146a.5.5 0 0 0-.708-.708L8 8.293 6.854 7.146z" />
<path
d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5v2z" />
</svg>
</div> </div>
<div class="control upload-file hidden" title="Upload files"> <div class="control upload-file hidden" title="Upload files">
<label for="file"> <label for="file">
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z"/></svg> <svg width="16" height="16" viewBox="0 0 16 16">
<path
d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z" />
<path
d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z" />
</svg>
</label> </label>
<input type="file" id="file" name="file" multiple> <input type="file" id="file" title="Upload files" name="file" multiple>
</div> </div>
<div class="control new-folder hidden" title="New folder"> <div class="control new-folder hidden" title="New folder">
<svg width="16" height="16" viewBox="0 0 16 16"> <svg width="16" height="16" viewBox="0 0 16 16">
<path d="m.5 3 .04.87a1.99 1.99 0 0 0-.342 1.311l.637 7A2 2 0 0 0 2.826 14H9v-1H2.826a1 1 0 0 1-.995-.91l-.637-7A1 1 0 0 1 2.19 4h11.62a1 1 0 0 1 .996 1.09L14.54 8h1.005l.256-2.819A2 2 0 0 0 13.81 3H9.828a2 2 0 0 1-1.414-.586l-.828-.828A2 2 0 0 0 6.172 1H2.5a2 2 0 0 0-2 2zm5.672-1a1 1 0 0 1 .707.293L7.586 3H2.19c-.24 0-.47.042-.683.12L1.5 2.98a1 1 0 0 1 1-.98h3.672z"/> <path
<path d="M13.5 10a.5.5 0 0 1 .5.5V12h1.5a.5.5 0 1 1 0 1H14v1.5a.5.5 0 1 1-1 0V13h-1.5a.5.5 0 0 1 0-1H13v-1.5a.5.5 0 0 1 .5-.5z"/> d="m.5 3 .04.87a1.99 1.99 0 0 0-.342 1.311l.637 7A2 2 0 0 0 2.826 14H9v-1H2.826a1 1 0 0 1-.995-.91l-.637-7A1 1 0 0 1 2.19 4h11.62a1 1 0 0 1 .996 1.09L14.54 8h1.005l.256-2.819A2 2 0 0 0 13.81 3H9.828a2 2 0 0 1-1.414-.586l-.828-.828A2 2 0 0 0 6.172 1H2.5a2 2 0 0 0-2 2zm5.672-1a1 1 0 0 1 .707.293L7.586 3H2.19c-.24 0-.47.042-.683.12L1.5 2.98a1 1 0 0 1 1-.98h3.672z" />
<path
d="M13.5 10a.5.5 0 0 1 .5.5V12h1.5a.5.5 0 1 1 0 1H14v1.5a.5.5 0 1 1-1 0V13h-1.5a.5.5 0 0 1 0-1H13v-1.5a.5.5 0 0 1 .5-.5z" />
</svg>
</div>
<div class="control new-file hidden" title="New File">
<svg width="16" height="16" viewBox="0 0 16 16">
<path
d="M8 6.5a.5.5 0 0 1 .5.5v1.5H10a.5.5 0 0 1 0 1H8.5V11a.5.5 0 0 1-1 0V9.5H6a.5.5 0 0 1 0-1h1.5V7a.5.5 0 0 1 .5-.5z" />
<path
d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z" />
</svg> </svg>
</div> </div>
</div> </div>
<form class="searchbar hidden"> <form class="searchbar hidden">
<div class="icon"> <div class="icon">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/></svg> <svg width="16" height="16" viewBox="0 0 16 16">
<path
d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z" />
</svg>
</div> </div>
<input id="search" name="q" type="text" maxlength="128" autocomplete="off" tabindex="1" required> <input id="search" title="Searching for folders or files" name="q" type="text" maxlength="128" autocomplete="off" tabindex="1">
<input type="submit" hidden /> <input type="submit" hidden />
</form> </form>
<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"
d="M6 3.5a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v9a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-2a.5.5 0 0 0-1 0v2A1.5 1.5 0 0 0 6.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 14.5 2h-8A1.5 1.5 0 0 0 5 3.5v2a.5.5 0 0 0 1 0v-2z" />
<path fill-rule="evenodd"
d="M11.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 1 0-.708.708L10.293 7.5H1.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z" />
</svg>
</div>
<div class="user-btn hidden">
<svg width="16" height="16" viewBox="0 0 16 16">
<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">
<path
d="M426.666667 682.666667v42.666666h170.666666v-42.666666h-170.666666z m-42.666667-85.333334h298.666667v128h42.666666V418.133333L605.866667 298.666667H298.666667v426.666666h42.666666v-128h42.666667z m260.266667-384L810.666667 379.733333V810.666667H213.333333V213.333333h430.933334zM341.333333 341.333333h85.333334v170.666667H341.333333V341.333333z"
fill="#444444" p-id="8311"></path>
</svg>
</div>
</div>
</div> </div>
<div class="main"> <div class="main">
<div class="empty-folder hidden"></div> <div class="index-page hidden">
<table class="uploaders-table hidden"> <div class="empty-folder hidden"></div>
<thead> <table class="uploaders-table hidden">
<tr> <thead>
<th class="cell-name" colspan="2">Name</th> <tr>
<th class="cell-status">Progress</th> <th class="cell-name" colspan="2">Name</th>
</tr> <th class="cell-status">Progress</th>
</thead> </tr>
</table> </thead>
<table class="paths-table hidden"> </table>
<thead> <table class="paths-table hidden">
</thead> <thead>
<tbody> </thead>
</tbody> <tbody>
</table> </tbody>
</table>
</div>
<div class="editor-page hidden">
<div class="not-editable hidden"></div>
<textarea id="editor" class="editor hidden" aria-label="Editor" cols="10"></textarea>
</div>
</div> </div>
<script> <script>
window.addEventListener("DOMContentLoaded", ready); window.addEventListener("DOMContentLoaded", ready);
</script> </script>
</body> </body>
</html> </html>

View File

@@ -10,13 +10,20 @@
* @typedef {object} DATA * @typedef {object} DATA
* @property {string} href * @property {string} href
* @property {string} uri_prefix * @property {string} uri_prefix
* @property {"Index" | "Edit" | "View"} kind
* @property {PathItem[]} paths * @property {PathItem[]} paths
* @property {boolean} allow_upload * @property {boolean} allow_upload
* @property {boolean} allow_delete * @property {boolean} allow_delete
* @property {boolean} allow_search * @property {boolean} allow_search
* @property {boolean} allow_archive
* @property {boolean} auth
* @property {string} user
* @property {boolean} dir_exists * @property {boolean} dir_exists
* @property {string} editable
*/ */
var DUFS_MAX_UPLOADINGS = 1;
/** /**
* @type {DATA} DATA * @type {DATA} DATA
*/ */
@@ -31,8 +38,32 @@ var DATA;
*/ */
const PARAMS = Object.fromEntries(new URLSearchParams(window.location.search).entries()); const PARAMS = Object.fromEntries(new URLSearchParams(window.location.search).entries());
const IFRAME_FORMATS = [
".pdf",
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".svg",
".mp4", ".mov", ".avi", ".wmv", ".flv", ".webm",
".mp3", ".ogg", ".wav", ".m4a",
];
const dirEmptyNote = PARAMS.q ? 'No results' : DATA.dir_exists ? 'Empty folder' : 'Folder will be created when a file is uploaded'; const dirEmptyNote = PARAMS.q ? 'No results' : DATA.dir_exists ? 'Empty folder' : 'Folder will be created when a file is uploaded';
const ICONS = {
dir: `<svg height="16" viewBox="0 0 14 16" width="14"><path fill-rule="evenodd" d="M13 4H7V3c0-.66-.31-1-1-1H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1zM6 4H1V3h5v1z"></path></svg>`,
symlinkFile: `<svg height="16" viewBox="0 0 12 16" width="12"><path fill-rule="evenodd" d="M8.5 1H1c-.55 0-1 .45-1 1v12c0 .55.45 1 1 1h10c.55 0 1-.45 1-1V4.5L8.5 1zM11 14H1V2h7l3 3v9zM6 4.5l4 3-4 3v-2c-.98-.02-1.84.22-2.55.7-.71.48-1.19 1.25-1.45 2.3.02-1.64.39-2.88 1.13-3.73.73-.84 1.69-1.27 2.88-1.27v-2H6z"></path></svg>`,
symlinkDir: `<svg height="16" viewBox="0 0 14 16" width="14"><path fill-rule="evenodd" d="M13 4H7V3c0-.66-.31-1-1-1H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1zM1 3h5v1H1V3zm6 9v-2c-.98-.02-1.84.22-2.55.7-.71.48-1.19 1.25-1.45 2.3.02-1.64.39-2.88 1.13-3.73C4.86 8.43 5.82 8 7.01 8V6l4 3-4 3H7z"></path></svg>`,
file: `<svg height="16" viewBox="0 0 12 16" width="12"><path fill-rule="evenodd" d="M6 5H2V4h4v1zM2 8h7V7H2v1zm0 2h7V9H2v1zm0 2h7v-1H2v1zm10-7.5V14c0 .55-.45 1-1 1H1c-.55 0-1-.45-1-1V2c0-.55.45-1 1-1h7.5L12 4.5zM11 5L8 2H1v12h10V5z"></path></svg>`,
download: `<svg width="16" height="16" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg>`,
move: `<svg width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M1.5 1.5A.5.5 0 0 0 1 2v4.8a2.5 2.5 0 0 0 2.5 2.5h9.793l-3.347 3.346a.5.5 0 0 0 .708.708l4.2-4.2a.5.5 0 0 0 0-.708l-4-4a.5.5 0 0 0-.708.708L13.293 8.3H3.5A1.5 1.5 0 0 1 2 6.8V2a.5.5 0 0 0-.5-.5z"/></svg>`,
edit: `<svg width="16" height="16" viewBox="0 0 16 16"><path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/></svg>`,
delete: `<svg width="16" height="16" viewBox="0 0 16 16"><path d="M6.854 7.146a.5.5 0 1 0-.708.708L7.293 9l-1.147 1.146a.5.5 0 0 0 .708.708L8 9.707l1.146 1.147a.5.5 0 0 0 .708-.708L8.707 9l1.147-1.146a.5.5 0 0 0-.708-.708L8 8.293 6.854 7.146z"/><path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5v2z"/></svg>`,
view: `<svg width="16" height="16" viewBox="0 0 16 16"><path d="M4 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 1h8a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1"/></svg>`,
}
/**
* @type Map<string, Uploader>
*/
const failUploaders = new Map();
/** /**
* @type Element * @type Element
*/ */
@@ -56,34 +87,75 @@ let $emptyFolder;
/** /**
* @type Element * @type Element
*/ */
let $newFolder; let $editor;
/**
* @type Element
*/
let $userBtn;
/**
* @type Element
*/
let $userName;
function ready() {
$pathsTable = document.querySelector(".paths-table")
$pathsTableHead = document.querySelector(".paths-table thead");
$pathsTableBody = document.querySelector(".paths-table tbody");
$uploadersTable = document.querySelector(".uploaders-table");
$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);
if (DATA.kind == "Index") {
document.title = `Index of ${DATA.href} - Dufs`;
document.querySelector(".index-page").classList.remove("hidden");
setupIndexPage();
} else if (DATA.kind == "Edit") {
document.title = `Edit ${DATA.href} - Dufs`;
document.querySelector(".editor-page").classList.remove("hidden");;
setupEditorPage();
} else if (DATA.kind == "View") {
document.title = `View ${DATA.href} - Dufs`;
document.querySelector(".editor-page").classList.remove("hidden");;
setupEditorPage();
}
}
class Uploader { class Uploader {
/** /**
* *
* @param {File} file * @param {File} file
* @param {string[]} dirs * @param {string[]} pathParts
*/ */
constructor(file, dirs) { constructor(file, pathParts) {
/** /**
* @type Element * @type Element
*/ */
this.$uploadStatus = null this.$uploadStatus = null
this.uploaded = 0; this.uploaded = 0;
this.uploadOffset = 0;
this.lastUptime = 0; this.lastUptime = 0;
this.name = [...dirs, file.name].join("/"); this.name = [...pathParts, file.name].join("/");
this.idx = Uploader.globalIdx++; this.idx = Uploader.globalIdx++;
this.file = file; this.file = file;
this.url = newUrl(this.name);
} }
upload() { upload() {
const { idx, name } = this; const { idx, name, url } = this;
const url = getUrl(name);
const encodedName = encodedStr(name); const encodedName = encodedStr(name);
$uploadersTable.insertAdjacentHTML("beforeend", ` $uploadersTable.insertAdjacentHTML("beforeend", `
<tr id="upload${idx}" class="uploader"> <tr id="upload${idx}" class="uploader">
<td class="path cell-icon"> <td class="path cell-icon">
${getSvg()} ${getPathSvg()}
</td> </td>
<td class="path cell-name"> <td class="path cell-name">
<a href="${url}">${encodedName}</a> <a href="${url}">${encodedName}</a>
@@ -94,53 +166,91 @@ class Uploader {
$emptyFolder.classList.add("hidden"); $emptyFolder.classList.add("hidden");
this.$uploadStatus = document.getElementById(`uploadStatus${idx}`); this.$uploadStatus = document.getElementById(`uploadStatus${idx}`);
this.$uploadStatus.innerHTML = '-'; this.$uploadStatus.innerHTML = '-';
this.$uploadStatus.addEventListener("click", e => {
const nodeId = e.target.id;
const matches = /^retry(\d+)$/.exec(nodeId);
if (matches) {
const id = parseInt(matches[1]);
let uploader = failUploaders.get(id);
if (uploader) uploader.retry();
}
});
Uploader.queues.push(this); Uploader.queues.push(this);
Uploader.runQueue(); Uploader.runQueue();
} }
ajax() { ajax() {
Uploader.runnings += 1; const { url } = this;
const url = getUrl(this.name);
this.uploaded = 0;
this.lastUptime = Date.now(); this.lastUptime = Date.now();
const ajax = new XMLHttpRequest(); const ajax = new XMLHttpRequest();
ajax.upload.addEventListener("progress", e => this.progress(e), false); ajax.upload.addEventListener("progress", e => this.progress(e), false);
ajax.addEventListener("readystatechange", () => { ajax.addEventListener("readystatechange", () => {
if(ajax.readyState === 4) { if (ajax.readyState === 4) {
if (ajax.status >= 200 && ajax.status < 300) { if (ajax.status >= 200 && ajax.status < 300) {
this.complete(); this.complete();
} else { } else {
this.fail(); if (ajax.status != 0) {
this.fail(`${ajax.status} ${ajax.statusText}`);
}
} }
} }
}) })
ajax.addEventListener("error", () => this.fail(), false); ajax.addEventListener("error", () => this.fail(), false);
ajax.addEventListener("abort", () => this.fail(), false); ajax.addEventListener("abort", () => this.fail(), false);
ajax.open("PUT", url); if (this.uploadOffset > 0) {
ajax.send(this.file); ajax.open("PATCH", url);
ajax.setRequestHeader("X-Update-Range", "append");
ajax.send(this.file.slice(this.uploadOffset));
} else {
ajax.open("PUT", url);
ajax.send(this.file);
// setTimeout(() => ajax.abort(), 3000);
}
} }
async retry() {
const { url } = this;
let res = await fetch(url, {
method: "HEAD",
});
let uploadOffset = 0;
if (res.status == 200) {
let value = res.headers.get("content-length");
uploadOffset = parseInt(value) || 0;
}
this.uploadOffset = uploadOffset;
this.ajax()
}
progress(event) { progress(event) {
const now = Date.now(); const now = Date.now();
const speed = (event.loaded - this.uploaded) / (now - this.lastUptime) * 1000; const speed = (event.loaded - this.uploaded) / (now - this.lastUptime) * 1000;
const [speedValue, speedUnit] = formatSize(speed); const [speedValue, speedUnit] = formatSize(speed);
const speedText = `${speedValue}${speedUnit.toLowerCase()}/s`; const speedText = `${speedValue} ${speedUnit}/s`;
const progress = formatPercent((event.loaded / event.total) * 100); const progress = formatPercent(((event.loaded + this.uploadOffset) / this.file.size) * 100);
const duration = formatDuration((event.total - event.loaded) / speed) const duration = formatDuration((event.total - event.loaded) / speed)
this.$uploadStatus.innerHTML = `<span>${speedText}</span><span>${progress}</span><span>${duration}</span>`; this.$uploadStatus.innerHTML = `<span style="width: 80px;">${speedText}</span><span>${progress} ${duration}</span>`;
this.uploaded = event.loaded; this.uploaded = event.loaded;
this.lastUptime = now; this.lastUptime = now;
} }
complete() { complete() {
this.$uploadStatus.innerHTML = ``; const $uploadStatusNew = this.$uploadStatus.cloneNode(true);
Uploader.runnings -= 1; $uploadStatusNew.innerHTML = ``;
this.$uploadStatus.parentNode.replaceChild($uploadStatusNew, this.$uploadStatus);
this.$uploadStatus = null;
failUploaders.delete(this.idx);
Uploader.runnings--;
Uploader.runQueue(); Uploader.runQueue();
} }
fail() { fail(reason = "") {
this.$uploadStatus.innerHTML = ``; this.$uploadStatus.innerHTML = `<span style="width: 20px;" title="${reason}">✗</span><span class="retry-btn" id="retry${this.idx}" title="Retry">↻</span>`;
Uploader.runnings -= 1; failUploaders.set(this.idx, this);
Uploader.runnings--;
Uploader.runQueue(); Uploader.runQueue();
} }
} }
@@ -149,16 +259,27 @@ Uploader.globalIdx = 0;
Uploader.runnings = 0; Uploader.runnings = 0;
Uploader.auth = false;
/** /**
* @type Uploader[] * @type Uploader[]
*/ */
Uploader.queues = []; Uploader.queues = [];
Uploader.runQueue = () => { 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(); let uploader = Uploader.queues.shift();
if (!uploader) return; if (!Uploader.auth) {
Uploader.auth = true;
try {
await checkAuth()
} catch {
Uploader.auth = false;
}
}
uploader.ajax(); uploader.ajax();
} }
@@ -181,13 +302,13 @@ function addBreadcrumb(href, uri_prefix) {
const name = parts[i]; const name = parts[i];
if (i > 0) { if (i > 0) {
if (!path.endsWith("/")) { if (!path.endsWith("/")) {
path += "/"; path += "/";
} }
path += encodeURIComponent(name); path += encodeURIComponent(name);
} }
const encodedName = encodedStr(name); const encodedName = encodedStr(name);
if (i === 0) { if (i === 0) {
$breadcrumb.insertAdjacentHTML("beforeend", `<a href="${path}"><svg width="16" height="16" viewBox="0 0 16 16"><path d="M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5z"/></svg></a>`); $breadcrumb.insertAdjacentHTML("beforeend", `<a href="${path}" title="Root"><svg width="16" height="16" viewBox="0 0 16 16"><path d="M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5z"/></svg></a>`);
} else if (i === len - 1) { } else if (i === len - 1) {
$breadcrumb.insertAdjacentHTML("beforeend", `<b>${encodedName}</b>`); $breadcrumb.insertAdjacentHTML("beforeend", `<b>${encodedName}</b>`);
} else { } else {
@@ -199,6 +320,33 @@ function addBreadcrumb(href, uri_prefix) {
} }
} }
function setupIndexPage() {
if (DATA.allow_archive) {
const $download = document.querySelector(".download");
$download.href = baseUrl() + "?zip";
$download.title = "Download folder as a .zip file";
$download.classList.remove("hidden");
}
if (DATA.allow_upload) {
setupDropzone();
setupUploadFile();
setupNewFolder();
setupNewFile();
}
if (DATA.auth) {
setupAuth();
}
if (DATA.allow_search) {
setupSearch()
}
renderPathsTableHead();
renderPathsTableBody();
}
/** /**
* Render path table thead * Render path table thead
*/ */
@@ -223,20 +371,20 @@ function renderPathsTableHead() {
$pathsTableHead.insertAdjacentHTML("beforeend", ` $pathsTableHead.insertAdjacentHTML("beforeend", `
<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>` 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 { } 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 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();
const icon = `<span>${svg}</span>` const icon = `<span>${svg}</span>`
return `<th class="cell-${item.name}" ${item.props}><a href="?${qs}">${item.text}${icon}</a></th>` return `<th class="cell-${item.name}" ${item.props}><a href="?${qs}">${item.text}${icon}</a></th>`
}).join("\n")} }).join("\n")}
<th class="cell-actions">Actions</th> <th class="cell-actions">Actions</th>
</tr> </tr>
`); `);
@@ -267,52 +415,56 @@ function renderPathsTableBody() {
*/ */
function addPath(file, index) { function addPath(file, index) {
const encodedName = encodedStr(file.name); const encodedName = encodedStr(file.name);
let url = getUrl(file.name) let url = newUrl(file.name)
let actionDelete = ""; let actionDelete = "";
let actionDownload = ""; let actionDownload = "";
let actionMove = ""; let actionMove = "";
if (file.path_type.endsWith("Dir")) { let actionEdit = "";
let actionView = "";
let isDir = file.path_type.endsWith("Dir");
if (isDir) {
url += "/"; url += "/";
actionDownload = ` if (DATA.allow_archive) {
<div class="action-btn"> actionDownload = `
<a href="${url}?zip" title="Download folder as a .zip file"> <div class="action-btn">
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg> <a href="${url}?zip" title="Download folder as a .zip file">${ICONS.download}</a>
</a> </div>`;
</div>`; }
} else { } else {
actionDownload = ` actionDownload = `
<div class="action-btn" > <div class="action-btn" >
<a href="${url}" title="Download file" download> <a href="${url}" title="Download file" download>${ICONS.download}</a>
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg>
</a>
</div>`; </div>`;
} }
if (DATA.allow_delete) { if (DATA.allow_delete) {
if (DATA.allow_upload) { if (DATA.allow_upload) {
actionMove = ` actionMove = `<div onclick="movePath(${index})" class="action-btn" id="moveBtn${index}" title="Move to new path">${ICONS.move}</div>`;
<div onclick="movePath(${index})" class="action-btn" id="moveBtn${index}" title="Move to new path"> if (!isDir) {
<svg width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M1.5 1.5A.5.5 0 0 0 1 2v4.8a2.5 2.5 0 0 0 2.5 2.5h9.793l-3.347 3.346a.5.5 0 0 0 .708.708l4.2-4.2a.5.5 0 0 0 0-.708l-4-4a.5.5 0 0 0-.708.708L13.293 8.3H3.5A1.5 1.5 0 0 1 2 6.8V2a.5.5 0 0 0-.5-.5z"/></svg> actionEdit = `<a class="action-btn" title="Edit file" target="_blank" href="${url}?edit">${ICONS.edit}</a>`;
</div>`; }
} }
actionDelete = ` actionDelete = `
<div onclick="deletePath(${index})" class="action-btn" id="deleteBtn${index}" title="Delete"> <div onclick="deletePath(${index})" class="action-btn" id="deleteBtn${index}" title="Delete">${ICONS.delete}</div>`;
<svg width="16" height="16" fill="currentColor"viewBox="0 0 16 16"><path d="M6.854 7.146a.5.5 0 1 0-.708.708L7.293 9l-1.147 1.146a.5.5 0 0 0 .708.708L8 9.707l1.146 1.147a.5.5 0 0 0 .708-.708L8.707 9l1.147-1.146a.5.5 0 0 0-.708-.708L8 8.293 6.854 7.146z"/><path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5v2z"/></svg> }
</div>`; if (!actionEdit && !isDir) {
actionView = `<a class="action-btn" title="View file" target="_blank" href="${url}?view">${ICONS.view}</a>`;
} }
let actionCell = ` let actionCell = `
<td class="cell-actions"> <td class="cell-actions">
${actionDownload} ${actionDownload}
${actionView}
${actionMove} ${actionMove}
${actionDelete} ${actionDelete}
${actionEdit}
</td>` </td>`
$pathsTableBody.insertAdjacentHTML("beforeend", ` $pathsTableBody.insertAdjacentHTML("beforeend", `
<tr id="addPath${index}"> <tr id="addPath${index}">
<td class="path cell-icon"> <td class="path cell-icon">
${getSvg(file.path_type)} ${getPathSvg(file.path_type)}
</td> </td>
<td class="path cell-name"> <td class="path cell-name">
<a href="${url}">${encodedName}</a> <a href="${url}" ${isDir ? "" : `target="_blank"`}>${encodedName}</a>
</td> </td>
<td class="cell-mtime">${formatMtime(file.mtime)}</td> <td class="cell-mtime">${formatMtime(file.mtime)}</td>
<td class="cell-size">${formatSize(file.size).join(" ")}</td> <td class="cell-size">${formatSize(file.size).join(" ")}</td>
@@ -320,6 +472,160 @@ function addPath(file, index) {
</tr>`) </tr>`)
} }
function setupDropzone() {
["drag", "dragstart", "dragend", "dragover", "dragenter", "dragleave", "drop"].forEach(name => {
document.addEventListener(name, e => {
e.preventDefault();
e.stopPropagation();
});
});
document.addEventListener("drop", async e => {
if (!e.dataTransfer.items[0].webkitGetAsEntry) {
const files = e.dataTransfer.files.filter(v => v.size > 0);
for (const file of files) {
new Uploader(file, []).upload();
}
} else {
const entries = [];
const len = e.dataTransfer.items.length;
for (let i = 0; i < len; i++) {
entries.push(e.dataTransfer.items[i].webkitGetAsEntry());
}
addFileEntries(entries, [])
}
});
}
function setupAuth() {
if (DATA.user) {
$userBtn.classList.remove("hidden");
$userName.textContent = DATA.user;
} else {
const $loginBtn = document.querySelector(".login-btn");
$loginBtn.classList.remove("hidden");
$loginBtn.addEventListener("click", async () => {
try {
await checkAuth()
location.reload();
} catch (err) {
alert(err.message);
}
});
}
}
function setupSearch() {
const $searchbar = document.querySelector(".searchbar");
$searchbar.classList.remove("hidden");
$searchbar.addEventListener("submit", event => {
event.preventDefault();
const formData = new FormData($searchbar);
const q = formData.get("q");
let href = baseUrl();
if (q) {
href += "?q=" + q;
}
location.href = href;
});
if (PARAMS.q) {
document.getElementById('search').value = PARAMS.q;
}
}
function setupUploadFile() {
document.querySelector(".upload-file").classList.remove("hidden");
document.getElementById("file").addEventListener("change", async e => {
const files = e.target.files;
for (let file of files) {
new Uploader(file, []).upload();
}
});
}
function setupNewFolder() {
const $newFolder = document.querySelector(".new-folder");
$newFolder.classList.remove("hidden");
$newFolder.addEventListener("click", () => {
const name = prompt("Enter folder name");
if (name) createFolder(name);
});
}
function setupNewFile() {
const $newFile = document.querySelector(".new-file");
$newFile.classList.remove("hidden");
$newFile.addEventListener("click", () => {
const name = prompt("Enter file name");
if (name) createFile(name);
});
}
async function setupEditorPage() {
const url = baseUrl();
const $download = document.querySelector(".download");
$download.classList.remove("hidden");
$download.href = url;
if (DATA.kind == "Edit") {
const $moveFile = document.querySelector(".move-file");
$moveFile.classList.remove("hidden");
$moveFile.addEventListener("click", async () => {
const query = location.href.slice(url.length);
const newFileUrl = await doMovePath(url);
if (newFileUrl) {
location.href = newFileUrl + query;
}
});
const $deleteFile = document.querySelector(".delete-file");
$deleteFile.classList.remove("hidden");
$deleteFile.addEventListener("click", async () => {
const url = baseUrl();
const name = baseName(url);
await doDeletePath(name, url, () => {
location.href = location.href.split("/").slice(0, -1).join("/");
});
})
const $saveBtn = document.querySelector(".save-btn");
$saveBtn.classList.remove("hidden");
$saveBtn.addEventListener("click", saveChange);
} else if (DATA.kind == "View") {
$editor.readonly = true;
}
if (!DATA.editable) {
const $notEditable = document.querySelector(".not-editable");
const url = baseUrl();
const ext = extName(baseName(url));
if (IFRAME_FORMATS.find(v => v === ext)) {
$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 file is too large or binary.";
}
return;
}
$editor.classList.remove("hidden");
try {
const res = await fetch(baseUrl());
await assertResOK(res);
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}`);
}
}
/** /**
* Delete path * Delete path
* @param {number} index * @param {number} index
@@ -328,30 +634,31 @@ function addPath(file, index) {
async function deletePath(index) { async function deletePath(index) {
const file = DATA.paths[index]; const file = DATA.paths[index];
if (!file) return; if (!file) return;
await doDeletePath(file.name, newUrl(file.name), () => {
document.getElementById(`addPath${index}`)?.remove();
DATA.paths[index] = null;
if (!DATA.paths.find(v => !!v)) {
$pathsTable.classList.add("hidden");
$emptyFolder.textContent = dirEmptyNote;
$emptyFolder.classList.remove("hidden");
}
})
}
if (!confirm(`Delete \`${file.name}\`?`)) return; async function doDeletePath(name, url, cb) {
if (!confirm(`Delete \`${name}\`?`)) return;
try { try {
const res = await fetch(getUrl(file.name), { await checkAuth();
const res = await fetch(url, {
method: "DELETE", method: "DELETE",
}); });
if (res.status >= 200 && res.status < 300) { await assertResOK(res);
document.getElementById(`addPath${index}`).remove(); cb();
DATA.paths[index] = null;
if (!DATA.paths.find(v => !!v)) {
$pathsTable.classList.add("hidden");
$emptyFolder.textContent = dirEmptyNote;
$emptyFolder.classList.remove("hidden");
}
} else {
throw new Error(await res.text())
}
} catch (err) { } catch (err) {
alert(`Cannot delete \`${file.name}\`, ${err.message}`); alert(`Cannot delete \`${file.name}\`, ${err.message}`);
} }
} }
/** /**
* Move path * Move path
* @param {number} index * @param {number} index
@@ -360,8 +667,14 @@ async function deletePath(index) {
async function movePath(index) { async function movePath(index) {
const file = DATA.paths[index]; const file = DATA.paths[index];
if (!file) return; if (!file) return;
const fileUrl = newUrl(file.name);
const newFileUrl = await doMovePath(fileUrl);
if (newFileUrl) {
location.href = newFileUrl.split("/").slice(0, -1).join("/");
}
}
const fileUrl = getUrl(file.name); async function doMovePath(fileUrl) {
const fileUrlObj = new URL(fileUrl) const fileUrlObj = new URL(fileUrl)
const prefix = DATA.uri_prefix.slice(0, -1); const prefix = DATA.uri_prefix.slice(0, -1);
@@ -375,44 +688,53 @@ async function movePath(index) {
const newFileUrl = fileUrlObj.origin + prefix + newPath.split("/").map(encodeURIComponent).join("/"); const newFileUrl = fileUrlObj.origin + prefix + newPath.split("/").map(encodeURIComponent).join("/");
try { try {
const res = await fetch(fileUrl, { await checkAuth();
const res1 = await fetch(newFileUrl, {
method: "HEAD",
});
if (res1.status === 200) {
if (!confirm("Override existing file?")) {
return;
}
}
const res2 = await fetch(fileUrl, {
method: "MOVE", method: "MOVE",
headers: { headers: {
"Destination": newFileUrl, "Destination": newFileUrl,
} }
}); });
if (res.status >= 200 && res.status < 300) { await assertResOK(res2);
location.href = newFileUrl.split("/").slice(0, -1).join("/") return newFileUrl;
} else {
throw new Error(await res.text())
}
} catch (err) { } catch (err) {
alert(`Cannot move \`${filePath}\` to \`${newPath}\`, ${err.message}`); alert(`Cannot move \`${filePath}\` to \`${newPath}\`, ${err.message}`);
} }
} }
function dropzone() {
["drag", "dragstart", "dragend", "dragover", "dragenter", "dragleave", "drop"].forEach(name => { /**
document.addEventListener(name, e => { * Save editor change
e.preventDefault(); */
e.stopPropagation(); async function saveChange() {
}); try {
}); await fetch(baseUrl(), {
document.addEventListener("drop", e => { method: "PUT",
if (!e.dataTransfer.items[0].webkitGetAsEntry) { body: $editor.value,
const files = e.dataTransfer.files.filter(v => v.size > 0);
for (const file of files) {
new Uploader(file, []).upload();
}
} else {
const entries = [];
const len = e.dataTransfer.items.length;
for (let i = 0; i < len; i++) {
entries.push(e.dataTransfer.items[i].webkitGetAsEntry());
}
addFileEntries(entries, [])
}
}); });
location.reload();
} catch (err) {
alert(`Failed to save file, ${err.message}`);
}
}
async function checkAuth() {
if (!DATA.auth) return;
const res = await fetch(baseUrl(), {
method: "WRITEABLE",
});
await assertResOK(res);
document.querySelector(".login-btn").classList.add("hidden");
$userBtn.classList.remove("hidden");
$userName.textContent = "";
} }
/** /**
@@ -420,19 +742,34 @@ function dropzone() {
* @param {string} name * @param {string} name
*/ */
async function createFolder(name) { async function createFolder(name) {
const url = getUrl(name); const url = newUrl(name);
try { try {
await checkAuth();
const res = await fetch(url, { const res = await fetch(url, {
method: "MKCOL", method: "MKCOL",
}); });
if (res.status >= 200 && res.status < 300) { await assertResOK(res);
location.href = url; location.href = url;
}
} catch (err) { } catch (err) {
alert(`Cannot create folder \`${name}\`, ${err.message}`); alert(`Cannot create folder \`${name}\`, ${err.message}`);
} }
} }
async function createFile(name) {
const url = newUrl(name);
try {
await checkAuth();
const res = await fetch(url, {
method: "PUT",
body: "",
});
await assertResOK(res);
location.href = url + "?edit";
} catch (err) {
alert(`Cannot create file \`${name}\`, ${err.message}`);
}
}
async function addFileEntries(entries, dirs) { async function addFileEntries(entries, dirs) {
for (const entry of entries) { for (const entry of entries) {
if (entry.isFile) { if (entry.isFile) {
@@ -440,30 +777,56 @@ async function addFileEntries(entries, dirs) {
new Uploader(file, dirs).upload(); new Uploader(file, dirs).upload();
}); });
} else if (entry.isDirectory) { } else if (entry.isDirectory) {
const dirReader = entry.createReader() const dirReader = entry.createReader();
dirReader.readEntries(entries => addFileEntries(entries, [...dirs, entry.name]));
const successCallback = entries => {
if (entries.length > 0) {
addFileEntries(entries, [...dirs, entry.name]);
dirReader.readEntries(successCallback);
}
};
dirReader.readEntries(successCallback);
} }
} }
} }
function getUrl(name) { function newUrl(name) {
let url = location.href.split('?')[0]; let url = baseUrl();
if (!url.endsWith("/")) url += "/"; if (!url.endsWith("/")) url += "/";
url += name.split("/").map(encodeURIComponent).join("/"); url += name.split("/").map(encodeURIComponent).join("/");
return url; return url;
} }
function getSvg(path_type) { function baseUrl() {
return location.href.split('?')[0];
}
function baseName(url) {
return decodeURIComponent(url.split("/").filter(v => v.length > 0).slice(-1)[0])
}
function extName(filename) {
const dotIndex = filename.lastIndexOf('.');
if (dotIndex === -1 || dotIndex === 0 || dotIndex === filename.length - 1) {
return '';
}
return filename.substring(dotIndex);
}
function getPathSvg(path_type) {
switch (path_type) { switch (path_type) {
case "Dir": case "Dir":
return `<svg height="16" viewBox="0 0 14 16" width="14"><path fill-rule="evenodd" d="M13 4H7V3c0-.66-.31-1-1-1H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1zM6 4H1V3h5v1z"></path></svg>`; return ICONS.dir;
case "SymlinkFile": case "SymlinkFile":
return `<svg height="16" viewBox="0 0 12 16" width="12"><path fill-rule="evenodd" d="M8.5 1H1c-.55 0-1 .45-1 1v12c0 .55.45 1 1 1h10c.55 0 1-.45 1-1V4.5L8.5 1zM11 14H1V2h7l3 3v9zM6 4.5l4 3-4 3v-2c-.98-.02-1.84.22-2.55.7-.71.48-1.19 1.25-1.45 2.3.02-1.64.39-2.88 1.13-3.73.73-.84 1.69-1.27 2.88-1.27v-2H6z"></path></svg>`; return ICONS.symlinkFile;
case "SymlinkDir": case "SymlinkDir":
return `<svg height="16" viewBox="0 0 14 16" width="14"><path fill-rule="evenodd" d="M13 4H7V3c0-.66-.31-1-1-1H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1zM1 3h5v1H1V3zm6 9v-2c-.98-.02-1.84.22-2.55.7-.71.48-1.19 1.25-1.45 2.3.02-1.64.39-2.88 1.13-3.73C4.86 8.43 5.82 8 7.01 8V6l4 3-4 3H7z"></path></svg>`; return ICONS.symlinkDir;
default: default:
return `<svg height="16" viewBox="0 0 12 16" width="12"><path fill-rule="evenodd" d="M6 5H2V4h4v1zM2 8h7V7H2v1zm0 2h7V9H2v1zm0 2h7v-1H2v1zm10-7.5V14c0 .55-.45 1-1 1H1c-.55 0-1-.45-1-1V2c0-.55.45-1 1-1h7.5L12 4.5zM11 5L8 2H1v12h10V5z"></path></svg>`; return ICONS.file;
} }
} }
@@ -483,11 +846,15 @@ function padZero(value, size) {
} }
function formatSize(size) { function formatSize(size) {
if (!size) return [] if (size == null) return [0, "B"]
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
if (size == 0) return [0, "Byte"]; if (size == 0) return [0, "B"];
const i = parseInt(Math.floor(Math.log(size) / Math.log(1024))); 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) { function formatDuration(seconds) {
@@ -498,55 +865,33 @@ function formatDuration(seconds) {
return `${padZero(h, 2)}:${padZero(m, 2)}:${padZero(s, 2)}`; return `${padZero(h, 2)}:${padZero(m, 2)}:${padZero(s, 2)}`;
} }
function formatPercent(precent) { function formatPercent(percent) {
if (precent > 10) { if (percent > 10) {
return precent.toFixed(1) + "%"; return percent.toFixed(1) + "%";
} else { } else {
return precent.toFixed(2) + "%"; return percent.toFixed(2) + "%";
} }
} }
function encodedStr(rawStr) { function encodedStr(rawStr) {
return rawStr.replace(/[\u00A0-\u9999<>\&]/g, function(i) { return rawStr.replace(/[\u00A0-\u9999<>\&]/g, function (i) {
return '&#'+i.charCodeAt(0)+';'; return '&#' + i.charCodeAt(0) + ';';
}); });
} }
function ready() { async function assertResOK(res) {
document.title = `Index of ${DATA.href} - Dufs`; if (!(res.status >= 200 && res.status < 300)) {
$pathsTable = document.querySelector(".paths-table") throw new Error(await res.text() || `Invalid status ${res.status}`);
$pathsTableHead = document.querySelector(".paths-table thead");
$pathsTableBody = document.querySelector(".paths-table tbody");
$uploadersTable = document.querySelector(".uploaders-table");
$emptyFolder = document.querySelector(".empty-folder");
$newFolder = document.querySelector(".new-folder");
if (DATA.allow_search) {
document.querySelector(".searchbar").classList.remove("hidden");
if (PARAMS.q) {
document.getElementById('search').value = PARAMS.q;
}
}
addBreadcrumb(DATA.href, DATA.uri_prefix);
renderPathsTableHead();
renderPathsTableBody();
if (DATA.allow_upload) {
dropzone();
if (DATA.allow_delete) {
$newFolder.classList.remove("hidden");
$newFolder.addEventListener("click", () => {
const name = prompt("Enter name of new folder");
if (name) createFolder(name);
});
}
document.querySelector(".upload-file").classList.remove("hidden");
document.getElementById("file").addEventListener("change", e => {
const files = e.target.files;
for (let file of files) {
new Uploader(file, []).upload();
}
});
} }
} }
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,19 +1,17 @@
use clap::builder::PossibleValuesParser; use anyhow::{bail, Context, Result};
use clap::{value_parser, Arg, ArgAction, ArgMatches, Command}; use async_zip::Compression;
use clap::builder::{PossibleValue, PossibleValuesParser};
use clap::{value_parser, Arg, ArgAction, ArgMatches, Command, ValueEnum};
use clap_complete::{generate, Generator, Shell}; use clap_complete::{generate, Generator, Shell};
#[cfg(feature = "tls")] use serde::{Deserialize, Deserializer};
use rustls::{Certificate, PrivateKey}; use smart_default::SmartDefault;
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;
use crate::BoxResult;
pub fn build_cli() -> Command { pub fn build_cli() -> Command {
let app = Command::new(env!("CARGO_CRATE_NAME")) let app = Command::new(env!("CARGO_CRATE_NAME"))
@@ -25,13 +23,26 @@ pub fn build_cli() -> Command {
env!("CARGO_PKG_REPOSITORY") env!("CARGO_PKG_REPOSITORY")
)) ))
.arg( .arg(
Arg::new("root") Arg::new("serve-path")
.default_value(".") .env("DUFS_SERVE_PATH")
.hide_env(true)
.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_CONFIG")
.hide_env(true)
.short('c')
.long("config")
.value_parser(value_parser!(PathBuf))
.help("Specify configuration file")
.value_name("file"),
) )
.arg( .arg(
Arg::new("bind") Arg::new("bind")
.env("DUFS_BIND")
.hide_env(true)
.short('b') .short('b')
.long("bind") .long("bind")
.help("Specify bind address or unix socket") .help("Specify bind address or unix socket")
@@ -41,36 +52,47 @@ pub fn build_cli() -> Command {
) )
.arg( .arg(
Arg::new("port") Arg::new("port")
.env("DUFS_PORT")
.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(
Arg::new("path-prefix") Arg::new("path-prefix")
.env("DUFS_PATH_PREFIX")
.hide_env(true)
.long("path-prefix") .long("path-prefix")
.value_name("path") .value_name("path")
.help("Specify a path prefix"), .help("Specify a path prefix"),
) )
.arg( .arg(
Arg::new("hidden") Arg::new("hidden")
.env("DUFS_HIDDEN")
.hide_env(true)
.long("hidden") .long("hidden")
.help("Hide paths from directory listings, separated by `,`") .action(ArgAction::Append)
.value_delimiter(',')
.help("Hide paths from directory listings, e.g. tmp,*.log,*.lock")
.value_name("value"), .value_name("value"),
) )
.arg( .arg(
Arg::new("auth") Arg::new("auth")
.env("DUFS_AUTH")
.hide_env(true)
.short('a') .short('a')
.long("auth") .long("auth")
.help("Add auth for path") .help("Add auth roles, e.g. user:pass@/dir1:rw,/dir2")
.action(ArgAction::Append) .action(ArgAction::Append)
.value_delimiter(',')
.value_name("rules"), .value_name("rules"),
) )
.arg( .arg(
Arg::new("auth-method") Arg::new("auth-method")
.hide(true)
.env("DUFS_AUTH_METHOD")
.hide_env(true)
.long("auth-method") .long("auth-method")
.help("Select auth method") .help("Select auth method")
.value_parser(PossibleValuesParser::new(["basic", "digest"])) .value_parser(PossibleValuesParser::new(["basic", "digest"]))
@@ -79,6 +101,8 @@ pub fn build_cli() -> Command {
) )
.arg( .arg(
Arg::new("allow-all") Arg::new("allow-all")
.env("DUFS_ALLOW_ALL")
.hide_env(true)
.short('A') .short('A')
.long("allow-all") .long("allow-all")
.action(ArgAction::SetTrue) .action(ArgAction::SetTrue)
@@ -86,64 +110,116 @@ pub fn build_cli() -> Command {
) )
.arg( .arg(
Arg::new("allow-upload") Arg::new("allow-upload")
.env("DUFS_ALLOW_UPLOAD")
.hide_env(true)
.long("allow-upload") .long("allow-upload")
.action(ArgAction::SetTrue) .action(ArgAction::SetTrue)
.help("Allow upload files/folders"), .help("Allow upload files/folders"),
) )
.arg( .arg(
Arg::new("allow-delete") Arg::new("allow-delete")
.env("DUFS_ALLOW_DELETE")
.hide_env(true)
.long("allow-delete") .long("allow-delete")
.action(ArgAction::SetTrue) .action(ArgAction::SetTrue)
.help("Allow delete files/folders"), .help("Allow delete files/folders"),
) )
.arg( .arg(
Arg::new("allow-search") Arg::new("allow-search")
.env("DUFS_ALLOW_SEARCH")
.hide_env(true)
.long("allow-search") .long("allow-search")
.action(ArgAction::SetTrue) .action(ArgAction::SetTrue)
.help("Allow search files/folders"), .help("Allow search files/folders"),
) )
.arg( .arg(
Arg::new("allow-symlink") Arg::new("allow-symlink")
.env("DUFS_ALLOW_SYMLINK")
.hide_env(true)
.long("allow-symlink") .long("allow-symlink")
.action(ArgAction::SetTrue) .action(ArgAction::SetTrue)
.help("Allow symlink to files/folders outside root directory"), .help("Allow symlink to files/folders outside root directory"),
) )
.arg(
Arg::new("allow-archive")
.env("DUFS_ALLOW_ARCHIVE")
.hide_env(true)
.long("allow-archive")
.action(ArgAction::SetTrue)
.help("Allow zip archive generation"),
)
.arg( .arg(
Arg::new("enable-cors") Arg::new("enable-cors")
.env("DUFS_ENABLE_CORS")
.hide_env(true)
.long("enable-cors") .long("enable-cors")
.action(ArgAction::SetTrue) .action(ArgAction::SetTrue)
.help("Enable CORS, sets `Access-Control-Allow-Origin: *`"), .help("Enable CORS, sets `Access-Control-Allow-Origin: *`"),
) )
.arg( .arg(
Arg::new("render-index") Arg::new("render-index")
.env("DUFS_RENDER_INDEX")
.hide_env(true)
.long("render-index") .long("render-index")
.action(ArgAction::SetTrue) .action(ArgAction::SetTrue)
.help("Serve index.html when requesting a directory, returns 404 if not found index.html"), .help("Serve index.html when requesting a directory, returns 404 if not found index.html"),
) )
.arg( .arg(
Arg::new("render-try-index") Arg::new("render-try-index")
.env("DUFS_RENDER_TRY_INDEX")
.hide_env(true)
.long("render-try-index") .long("render-try-index")
.action(ArgAction::SetTrue) .action(ArgAction::SetTrue)
.help("Serve index.html when requesting a directory, returns directory listing if not found index.html"), .help("Serve index.html when requesting a directory, returns directory listing if not found index.html"),
) )
.arg( .arg(
Arg::new("render-spa") Arg::new("render-spa")
.env("DUFS_RENDER_SPA")
.hide_env(true)
.long("render-spa") .long("render-spa")
.action(ArgAction::SetTrue) .action(ArgAction::SetTrue)
.help("Serve SPA(Single Page Application)"), .help("Serve SPA(Single Page Application)"),
) )
.arg( .arg(
Arg::new("assets") Arg::new("assets")
.env("DUFS_ASSETS")
.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("compress")
.env("DUFS_COMPRESS")
.hide_env(true)
.value_parser(clap::builder::EnumValueParser::<Compress>::new())
.long("compress")
.value_name("level")
.help("Set zip compress level [default: low]")
)
.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")]
let app = app let app = app
.arg( .arg(
Arg::new("tls-cert") Arg::new("tls-cert")
.env("DUFS_TLS_CERT")
.hide_env(true)
.long("tls-cert") .long("tls-cert")
.value_name("path") .value_name("path")
.value_parser(value_parser!(PathBuf)) .value_parser(value_parser!(PathBuf))
@@ -151,151 +227,232 @@ pub fn build_cli() -> Command {
) )
.arg( .arg(
Arg::new("tls-key") Arg::new("tls-key")
.env("DUFS_TLS_KEY")
.hide_env(true)
.long("tls-key") .long("tls-key")
.value_name("path") .value_name("path")
.value_parser(value_parser!(PathBuf)) .value_parser(value_parser!(PathBuf))
.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")
.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, SmartDefault, PartialEq)]
#[serde(default)]
#[serde(rename_all = "kebab-case")]
pub struct Args { pub struct Args {
#[serde(default = "default_serve_path")]
#[default(default_serve_path())]
pub serve_path: PathBuf,
#[serde(deserialize_with = "deserialize_bind_addrs")]
#[serde(rename = "bind")]
#[serde(default = "default_addrs")]
#[default(default_addrs())]
pub addrs: Vec<BindAddr>, pub addrs: Vec<BindAddr>,
#[serde(default = "default_port")]
#[default(default_port())]
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,
#[serde(deserialize_with = "deserialize_string_or_vec")]
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,
pub allow_symlink: bool, pub allow_symlink: bool,
pub allow_archive: bool,
pub render_index: bool, pub render_index: bool,
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 compress: Compress,
pub tls: Option<()>, pub tls_cert: Option<PathBuf>,
pub tls_key: Option<PathBuf>,
} }
impl Args { impl Args {
/// Parse command-line arguments. /// Parse command-line arguments.
/// ///
/// If a parsing error ocurred, 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) -> BoxResult<Args> { pub fn parse(matches: ArgMatches) -> Result<Args> {
let port = *matches.get_one::<u16>("port").unwrap(); let mut args = Self::default();
let addrs = matches
.get_many::<String>("bind") if let Some(config_path) = matches.get_one::<PathBuf>("config") {
.map(|bind| bind.map(|v| v.as_str()).collect()) let contents = std::fs::read_to_string(config_path)
.unwrap_or_else(|| vec!["0.0.0.0", "::"]); .with_context(|| format!("Failed to read config at {}", config_path.display()))?;
let addrs: Vec<BindAddr> = Args::parse_addrs(&addrs)?; args = serde_yaml::from_str(&contents)
let path = Args::parse_path(matches.get_one::<PathBuf>("root").unwrap())?; .with_context(|| format!("Failed to load config at {}", config_path.display()))?;
let path_is_file = path.metadata()?.is_file(); }
let path_prefix = matches
.get_one::<String>("path-prefix") if let Some(path) = matches.get_one::<PathBuf>("serve-path") {
.map(|v| v.trim_matches('/').to_owned()) args.serve_path = path.clone()
.unwrap_or_default(); }
let uri_prefix = if path_prefix.is_empty() {
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
.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 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 let Some(hidden) = matches.get_many::<String>("hidden") {
addrs, args.hidden = hidden.cloned().collect();
port, } else {
path, let mut hidden = vec![];
path_is_file, std::mem::swap(&mut args.hidden, &mut hidden);
path_prefix, args.hidden = hidden
uri_prefix, .into_iter()
hidden, .flat_map(|v| v.split(',').map(|v| v.to_string()).collect::<Vec<String>>())
auth_method, .collect();
auth, }
enable_cors,
allow_delete, if !args.enable_cors {
allow_upload, args.enable_cors = matches.get_flag("enable-cors");
allow_search, }
allow_symlink,
render_index, if let Some(rules) = matches.get_many::<String>("auth") {
render_try_index, let rules: Vec<_> = rules.map(|v| v.as_str()).collect();
render_spa, args.auth = AccessControl::new(&rules)?;
tls, }
log_http,
assets_path, 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(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)?);
}
if let Some(log_format) = matches.get_one::<String>("log-format") {
args.http_logger = log_format.parse()?;
}
if let Some(compress) = matches.get_one::<Compress>("compress") {
args.compress = *compress;
}
#[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]) -> BoxResult<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 {
@@ -313,36 +470,266 @@ impl Args {
} }
} }
if !invalid_addrs.is_empty() { if !invalid_addrs.is_empty() {
return Err(format!("Invalid bind address `{}`", invalid_addrs.join(",")).into()); bail!("Invalid bind address `{}`", invalid_addrs.join(","));
} }
Ok(bind_addrs) Ok(bind_addrs)
} }
}
fn parse_path<P: AsRef<Path>>(path: P) -> BoxResult<PathBuf> { #[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
let path = path.as_ref(); #[serde(rename_all = "lowercase")]
if !path.exists() { pub enum Compress {
return Err(format!("Path `{}` doesn't exist", path.display()).into()); None,
} Low,
Medium,
High,
}
env::current_dir() impl Default for Compress {
.and_then(|mut p| { fn default() -> Self {
p.push(path); // If path is absolute, it replaces the current path. Self::Low
std::fs::canonicalize(p)
})
.map_err(|err| format!("Failed to access path `{}`: {}", path.display(), err,).into())
}
fn parse_assets_path<P: AsRef<Path>>(path: P) -> BoxResult<PathBuf> {
let path = Self::parse_path(path)?;
if !path.join("index.html").exists() {
return Err(format!("Path `{}` doesn't contains index.html", path.display()).into());
}
Ok(path)
} }
} }
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] impl ValueEnum for Compress {
pub enum BindAddr { fn value_variants<'a>() -> &'a [Self] {
Address(IpAddr), &[Self::None, Self::Low, Self::Medium, Self::High]
Path(PathBuf), }
fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
Some(match self {
Compress::None => PossibleValue::new("none"),
Compress::Low => PossibleValue::new("low"),
Compress::Medium => PossibleValue::new("medium"),
Compress::High => PossibleValue::new("high"),
})
}
}
impl Compress {
pub fn to_compression(self) -> Compression {
match self {
Compress::None => Compression::Stored,
Compress::Low => Compression::Deflate,
Compress::Medium => Compression::Bz,
Compress::High => Compression::Xz,
}
}
}
fn deserialize_bind_addrs<'de, D>(deserializer: D) -> Result<Vec<BindAddr>, D::Error>
where
D: Deserializer<'de>,
{
struct StringOrVec;
impl<'de> serde::de::Visitor<'de> for StringOrVec {
type Value = Vec<BindAddr>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("string or list of strings")
}
fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
BindAddr::parse_addrs(&[s]).map_err(serde::de::Error::custom)
}
fn visit_seq<S>(self, seq: S) -> Result<Self::Value, S::Error>
where
S: serde::de::SeqAccess<'de>,
{
let addrs: Vec<&'de str> =
Deserialize::deserialize(serde::de::value::SeqAccessDeserializer::new(seq))?;
BindAddr::parse_addrs(&addrs).map_err(serde::de::Error::custom)
}
}
deserializer.deserialize_any(StringOrVec)
}
fn deserialize_string_or_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
struct StringOrVec;
impl<'de> serde::de::Visitor<'de> for StringOrVec {
type Value = Vec<String>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("string or list of strings")
}
fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(vec![s.to_owned()])
}
fn visit_seq<S>(self, seq: S) -> Result<Self::Value, S::Error>
where
S: serde::de::SeqAccess<'de>,
{
Deserialize::deserialize(serde::de::value::SeqAccessDeserializer::new(seq))
}
}
deserializer.deserialize_any(StringOrVec)
}
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(".")
}
fn default_addrs() -> Vec<BindAddr> {
BindAddr::parse_addrs(&["0.0.0.0", "::"]).unwrap()
}
fn default_port() -> u16 {
5000
}
#[cfg(test)]
mod tests {
use super::*;
use assert_fs::prelude::*;
#[test]
fn test_default() {
let cli = build_cli();
let matches = cli.try_get_matches_from(vec![""]).unwrap();
let args = Args::parse(matches).unwrap();
let cwd = Args::sanitize_path(std::env::current_dir().unwrap()).unwrap();
assert_eq!(args.serve_path, cwd);
assert_eq!(args.port, default_port());
assert_eq!(args.addrs, default_addrs());
}
#[test]
fn test_args_from_cli1() {
let tmpdir = assert_fs::TempDir::new().unwrap();
let cli = build_cli();
let matches = cli
.try_get_matches_from(vec![
"",
"--hidden",
"tmp,*.log,*.lock",
&tmpdir.to_string_lossy(),
])
.unwrap();
let args = Args::parse(matches).unwrap();
assert_eq!(args.serve_path, Args::sanitize_path(&tmpdir).unwrap());
assert_eq!(args.hidden, ["tmp", "*.log", "*.lock"]);
}
#[test]
fn test_args_from_cli2() {
let cli = build_cli();
let matches = cli
.try_get_matches_from(vec![
"", "--hidden", "tmp", "--hidden", "*.log", "--hidden", "*.lock",
])
.unwrap();
let args = Args::parse(matches).unwrap();
assert_eq!(args.hidden, ["tmp", "*.log", "*.lock"]);
}
#[test]
fn test_args_from_empty_config_file() {
let tmpdir = assert_fs::TempDir::new().unwrap();
let config_file = tmpdir.child("config.yaml");
config_file.write_str("").unwrap();
let cli = build_cli();
let matches = cli
.try_get_matches_from(vec!["", "-c", &config_file.to_string_lossy()])
.unwrap();
let args = Args::parse(matches).unwrap();
let cwd = Args::sanitize_path(std::env::current_dir().unwrap()).unwrap();
assert_eq!(args.serve_path, cwd);
assert_eq!(args.port, default_port());
assert_eq!(args.addrs, default_addrs());
}
#[test]
fn test_args_from_config_file1() {
let tmpdir = assert_fs::TempDir::new().unwrap();
let config_file = tmpdir.child("config.yaml");
let contents = format!(
r#"
serve-path: {}
bind: 0.0.0.0
port: 3000
allow-upload: true
hidden: tmp,*.log,*.lock
"#,
tmpdir.display()
);
config_file.write_str(&contents).unwrap();
let cli = build_cli();
let matches = cli
.try_get_matches_from(vec!["", "-c", &config_file.to_string_lossy()])
.unwrap();
let args = Args::parse(matches).unwrap();
assert_eq!(args.serve_path, Args::sanitize_path(&tmpdir).unwrap());
assert_eq!(
args.addrs,
vec![BindAddr::Address("0.0.0.0".parse().unwrap())]
);
assert_eq!(args.hidden, ["tmp", "*.log", "*.lock"]);
assert_eq!(args.port, 3000);
assert!(args.allow_upload);
}
#[test]
fn test_args_from_config_file2() {
let tmpdir = assert_fs::TempDir::new().unwrap();
let config_file = tmpdir.child("config.yaml");
let contents = r#"
bind:
- 127.0.0.1
- 192.168.8.10
hidden:
- tmp
- '*.log'
- '*.lock'
"#;
config_file.write_str(contents).unwrap();
let cli = build_cli();
let matches = cli
.try_get_matches_from(vec!["", "-c", &config_file.to_string_lossy()])
.unwrap();
let args = Args::parse(matches).unwrap();
assert_eq!(
args.addrs,
vec![
BindAddr::Address("127.0.0.1".parse().unwrap()),
BindAddr::Address("192.168.8.10".parse().unwrap())
]
);
assert_eq!(args.hidden, ["tmp", "*.log", "*.lock"]);
}
} }

View File

@@ -1,18 +1,20 @@
use crate::{args::Args, server::Response, utils::unix_now};
use anyhow::{anyhow, bail, Result};
use base64::{engine::general_purpose, Engine as _};
use headers::HeaderValue; use headers::HeaderValue;
use hyper::Method; use hyper::{header::WWW_AUTHENTICATE, Method};
use indexmap::IndexMap;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use md5::Context; use md5::Context;
use std::{ use std::{
collections::HashMap, collections::HashMap,
time::{SystemTime, UNIX_EPOCH}, path::{Path, PathBuf},
}; };
use uuid::Uuid; use uuid::Uuid;
use crate::utils::encode_uri;
use crate::BoxResult;
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 = {
@@ -23,53 +25,74 @@ lazy_static! {
}; };
} }
#[derive(Debug)] #[derive(Debug, Clone, PartialEq)]
pub struct AccessControl { pub struct AccessControl {
rules: HashMap<String, PathControl>, use_hashed_password: bool,
users: IndexMap<String, (String, AccessPaths)>,
anony: Option<AccessPaths>,
} }
#[derive(Debug)] impl Default for AccessControl {
pub struct PathControl { fn default() -> Self {
readwrite: Account, AccessControl {
readonly: Option<Account>, use_hashed_password: false,
share: bool, anony: Some(AccessPaths::new(AccessPerm::ReadWrite)),
users: IndexMap::new(),
}
}
} }
impl AccessControl { impl AccessControl {
pub fn new(raw_rules: &[&str], uri_prefix: &str) -> BoxResult<Self> { pub fn new(raw_rules: &[&str]) -> Result<Self> {
let mut rules = HashMap::default();
if raw_rules.is_empty() { if raw_rules.is_empty() {
return Ok(Self { rules }); return Ok(Default::default());
} }
for rule in raw_rules { let new_raw_rules = split_rules(raw_rules);
let parts: Vec<&str> = rule.split('@').collect(); let mut use_hashed_password = false;
let create_err = || format!("Invalid auth `{}`", rule).into(); let mut annoy_paths = None;
match parts.as_slice() { let mut account_paths_pairs = vec![];
[path, readwrite] => { for rule in &new_raw_rules {
let control = PathControl { let (account, paths) =
readwrite: Account::new(readwrite).ok_or_else(create_err)?, split_account_paths(rule).ok_or_else(|| anyhow!("Invalid auth `{rule}`"))?;
readonly: None, if account.is_empty() {
share: false, if annoy_paths.is_some() {
}; bail!("Invalid auth, no duplicate anonymous rules");
rules.insert(sanitize_path(path, uri_prefix), control);
} }
[path, readwrite, readonly] => { annoy_paths = Some(paths)
let (readonly, share) = if *readonly == "*" { } else if let Some((user, pass)) = account.split_once(':') {
(None, true) if user.is_empty() || pass.is_empty() {
} else { bail!("Invalid auth `{rule}`");
(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()), account_paths_pairs.push((user, pass, paths));
} }
} }
Ok(Self { rules }) let mut anony = None;
if let Some(paths) = annoy_paths {
let mut access_paths = AccessPaths::default();
access_paths.merge(paths);
anony = Some(access_paths);
}
let mut users = IndexMap::new();
for (user, pass, paths) in account_paths_pairs.into_iter() {
let mut access_paths = anony.clone().unwrap_or_default();
access_paths
.merge(paths)
.ok_or_else(|| anyhow!("Invalid auth `{user}:{pass}@{paths}"))?;
if pass.starts_with("$6$") {
use_hashed_password = true;
}
users.insert(user.to_string(), (pass.to_string(), access_paths));
}
Ok(Self {
use_hashed_password,
users,
anony,
})
}
pub fn exist(&self) -> bool {
!self.users.is_empty()
} }
pub fn guard( pub fn guard(
@@ -77,261 +100,322 @@ impl AccessControl {
path: &str, path: &str,
method: &Method, method: &Method,
authorization: Option<&HeaderValue>, authorization: Option<&HeaderValue>,
auth_method: AuthMethod, ) -> (Option<String>, Option<AccessPaths>) {
) -> GuardType { if let Some(authorization) = authorization {
if self.rules.is_empty() { if let Some(user) = get_auth_user(authorization) {
return GuardType::ReadWrite; if let Some((pass, paths)) = self.users.get(&user) {
} if method == Method::OPTIONS {
let mut controls = vec![]; return (Some(user), Some(AccessPaths::new(AccessPerm::ReadOnly)));
for path in walk_path(path) { }
if let Some(control) = self.rules.get(path) { if check_auth(authorization, method.as_str(), &user, pass).is_some() {
controls.push(control); return (Some(user), paths.find(path, !is_readonly_method(method)));
if let Some(authorization) = authorization { } else {
let Account { user, pass } = &control.readwrite; return (None, None);
if auth_method
.validate(authorization, method.as_str(), user, pass)
.is_some()
{
return GuardType::ReadWrite;
} }
} }
} }
} }
if is_readonly_method(method) {
for control in controls.into_iter() { if method == Method::OPTIONS {
if control.share { return (None, Some(AccessPaths::new(AccessPerm::ReadOnly)));
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
if let Some(paths) = self.anony.as_ref() {
return (None, paths.find(path, !is_readonly_method(method)));
}
(None, None)
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Default, Clone, PartialEq, Eq)]
pub enum GuardType { pub struct AccessPaths {
Reject, perm: AccessPerm,
ReadWrite, children: IndexMap<String, AccessPaths>,
ReadOnly,
} }
impl GuardType { impl AccessPaths {
pub fn is_reject(&self) -> bool { pub fn new(perm: AccessPerm) -> Self {
*self == GuardType::Reject Self {
perm,
..Default::default()
}
} }
}
fn sanitize_path(path: &str, uri_prefix: &str) -> String { pub fn perm(&self) -> AccessPerm {
let new_path = match (uri_prefix, path) { self.perm
("/", "/") => "/".into(), }
(_, "/") => uri_prefix.trim_end_matches('/').into(),
_ => format!("{}{}", uri_prefix, path.trim_matches('/')),
};
encode_uri(&new_path)
}
fn walk_path(path: &str) -> impl Iterator<Item = &str> { pub fn set_perm(&mut self, perm: AccessPerm) {
let mut idx = 0; if !perm.inherit() {
path.split('/').enumerate().map(move |(i, part)| { self.perm = perm;
let end = if i == 0 { 1 } else { idx + part.len() + i }; }
let value = &path[..end]; }
idx += part.len();
value
})
}
fn is_readonly_method(method: &Method) -> bool { pub fn merge(&mut self, paths: &str) -> Option<()> {
method == Method::GET for item in paths.trim_matches(',').split(',') {
|| method == Method::OPTIONS let (path, perm) = match item.split_once(':') {
|| method == Method::HEAD None => (item, AccessPerm::ReadOnly),
|| method.as_str() == "PROPFIND" Some((path, "ro")) => (path, AccessPerm::ReadOnly),
} Some((path, "rw")) => (path, AccessPerm::ReadWrite),
Some((path, "-")) => (path, AccessPerm::Forbidden),
_ => return None,
};
self.add(path, perm);
}
Some(())
}
#[derive(Debug, Clone)] fn add(&mut self, path: &str, perm: AccessPerm) {
struct Account { let path = path.trim_matches('/');
user: String, if path.is_empty() {
pass: String, self.set_perm(perm);
} } else {
let parts: Vec<&str> = path.split('/').collect();
self.add_impl(&parts, perm);
}
}
impl Account { fn add_impl(&mut self, parts: &[&str], perm: AccessPerm) {
fn new(data: &str) -> Option<Self> { let parts_len = parts.len();
let p: Vec<&str> = data.trim().split(':').collect(); if parts_len == 0 {
if p.len() != 2 { self.set_perm(perm);
return;
}
let child = self.children.entry(parts[0].to_string()).or_default();
child.add_impl(&parts[1..], perm)
}
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 target.perm().forbidden() {
return None; return None;
} }
let user = p[0]; if writable && !target.perm().readwrite() {
let pass = p[1]; return None;
let mut h = Context::new(); }
h.consume(format!("{}:{}:{}", user, REALM, pass).as_bytes()); Some(target)
Some(Account {
user: user.to_owned(),
pass: format!("{:x}", h.compute()),
})
} }
}
#[derive(Debug, Clone)] fn find_impl(&self, parts: &[&str], perm: AccessPerm) -> Option<AccessPaths> {
pub enum AuthMethod { let perm = if !self.perm.inherit() {
Basic, self.perm
Digest, } else {
} perm
};
impl AuthMethod { if parts.is_empty() {
pub fn www_auth(&self, stale: bool) -> String { if perm.inherit() {
match self { return Some(self.clone());
AuthMethod::Basic => { } else {
format!("Basic realm=\"{}\"", REALM) return Some(AccessPaths::new(perm));
}
AuthMethod::Digest => {
let str_stale = if stale { "stale=true," } else { "" };
format!(
"Digest realm=\"{}\",nonce=\"{}\",{}qop=\"auth\"",
REALM,
create_nonce(),
str_stale
)
} }
} }
} let child = match self.children.get(parts[0]) {
pub fn get_user(&self, authorization: &HeaderValue) -> Option<String> { Some(v) => v,
match self { None => {
AuthMethod::Basic => { if perm.inherit() {
let value: Vec<u8> =
base64::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_vals = to_headermap(digest_value).ok()?;
digest_vals
.get(b"username".as_ref())
.and_then(|b| std::str::from_utf8(b).ok())
.map(|v| v.to_string())
}
}
}
pub fn validate(
&self,
authorization: &HeaderValue,
method: &str,
auth_user: &str,
auth_pass: &str,
) -> Option<()> {
match self {
AuthMethod::Basic => {
let basic_value: Vec<u8> =
base64::decode(strip_prefix(authorization.as_bytes(), b"Basic ")?).ok()?;
let parts: Vec<&str> = std::str::from_utf8(&basic_value).ok()?.split(':').collect();
if parts[0] != auth_user {
return None; return None;
} else {
return Some(AccessPaths::new(perm));
} }
let mut h = Context::new();
h.consume(format!("{}:{}:{}", parts[0], REALM, parts[1]).as_bytes());
let http_pass = format!("{:x}", h.compute());
if http_pass == auth_pass {
return Some(());
}
None
} }
AuthMethod::Digest => { };
let digest_value = strip_prefix(authorization.as_bytes(), b"Digest ")?; child.find_impl(&parts[1..], perm)
let digest_vals = to_headermap(digest_value).ok()?; }
if let (Some(username), Some(nonce), Some(user_response)) = (
digest_vals pub fn child_names(&self) -> Vec<&String> {
.get(b"username".as_ref()) self.children.keys().collect()
.and_then(|b| std::str::from_utf8(b).ok()), }
digest_vals.get(b"nonce".as_ref()),
digest_vals.get(b"response".as_ref()), pub fn child_paths(&self, base: &Path) -> Vec<PathBuf> {
) { if !self.perm().inherit() {
match validate_nonce(nonce) { return vec![base.to_path_buf()];
Ok(true) => {} }
_ => return None, let mut output = vec![];
} self.child_paths_impl(&mut output, base);
if auth_user != username { output
return None; }
}
let mut ha = Context::new(); fn child_paths_impl(&self, output: &mut Vec<PathBuf>, base: &Path) {
ha.consume(method); for (name, child) in self.children.iter() {
ha.consume(b":"); let base = base.join(name);
if let Some(uri) = digest_vals.get(b"uri".as_ref()) { if child.perm().inherit() {
ha.consume(uri); child.child_paths_impl(output, &base);
} } else {
let ha = format!("{:x}", ha.compute()); output.push(base)
let mut correct_response = None;
if let Some(qop) = digest_vals.get(b"qop".as_ref()) {
if qop == &b"auth".as_ref() || qop == &b"auth-int".as_ref() {
correct_response = Some({
let mut c = Context::new();
c.consume(auth_pass);
c.consume(b":");
c.consume(nonce);
c.consume(b":");
if let Some(nc) = digest_vals.get(b"nc".as_ref()) {
c.consume(nc);
}
c.consume(b":");
if let Some(cnonce) = digest_vals.get(b"cnonce".as_ref()) {
c.consume(cnonce);
}
c.consume(b":");
c.consume(qop);
c.consume(b":");
c.consume(&*ha);
format!("{:x}", c.compute())
});
}
}
let correct_response = match correct_response {
Some(r) => r,
None => {
let mut c = Context::new();
c.consume(auth_pass);
c.consume(b":");
c.consume(nonce);
c.consume(b":");
c.consume(&*ha);
format!("{:x}", c.compute())
}
};
if correct_response.as_bytes() == *user_response {
// grant access
return Some(());
}
}
None
} }
} }
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum AccessPerm {
#[default]
Inherit,
ReadOnly,
ReadWrite,
Forbidden,
}
impl AccessPerm {
pub fn inherit(&self) -> bool {
self == &AccessPerm::Inherit
}
pub fn readwrite(&self) -> bool {
self == &AccessPerm::ReadWrite
}
pub fn forbidden(&self) -> bool {
self == &AccessPerm::Forbidden
}
}
pub fn www_authenticate(res: &mut Response, args: &Args) -> Result<()> {
if args.auth.use_hashed_password {
let basic = HeaderValue::from_str(&format!("Basic realm=\"{}\"", REALM))?;
res.headers_mut().insert(WWW_AUTHENTICATE, basic);
} else {
let nonce = create_nonce()?;
let digest = HeaderValue::from_str(&format!(
"Digest realm=\"{}\", nonce=\"{}\", qop=\"auth\"",
REALM, nonce
))?;
let basic = HeaderValue::from_str(&format!("Basic realm=\"{}\"", REALM))?;
res.headers_mut().append(WWW_AUTHENTICATE, digest);
res.headers_mut().append(WWW_AUTHENTICATE, basic);
}
Ok(())
}
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
}
}
pub fn check_auth(
authorization: &HeaderValue,
method: &str,
auth_user: &str,
auth_pass: &str,
) -> 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;
}
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
} 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())
.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();
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":");
if let Some(uri) = digest_map.get(b"uri".as_ref()) {
ha.consume(uri);
}
let ha = format!("{:x}", ha.compute());
let mut correct_response = None;
if let Some(qop) = digest_map.get(b"qop".as_ref()) {
if qop == &b"auth".as_ref() || qop == &b"auth-int".as_ref() {
correct_response = Some({
let mut c = Context::new();
c.consume(&auth_pass);
c.consume(b":");
c.consume(nonce);
c.consume(b":");
if let Some(nc) = digest_map.get(b"nc".as_ref()) {
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())
});
}
}
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
}
}
/// Check if a nonce is still valid. /// Check if a nonce is still valid.
/// Return an error if it was never valid /// Return an error if it was never valid
fn validate_nonce(nonce: &[u8]) -> Result<bool, ()> { fn validate_nonce(nonce: &[u8]) -> Result<bool> {
if nonce.len() != 34 { if nonce.len() != 34 {
return Err(()); bail!("invalid nonce");
} }
//parse hex //parse hex
if let Ok(n) = std::str::from_utf8(nonce) { if let Ok(n) = std::str::from_utf8(nonce) {
//get time //get time
if let Ok(secs_nonce) = u32::from_str_radix(&n[..8], 16) { if let Ok(secs_nonce) = u32::from_str_radix(&n[..8], 16) {
//check time //check time
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap(); let now = unix_now()?;
let secs_now = now.as_secs() as u32; let secs_now = now.as_secs() as u32;
if let Some(dur) = secs_now.checked_sub(secs_nonce) { if let Some(dur) = secs_now.checked_sub(secs_nonce) {
@@ -345,7 +429,14 @@ fn validate_nonce(nonce: &[u8]) -> Result<bool, ()> {
} }
} }
} }
Err(()) 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]> {
@@ -375,7 +466,7 @@ fn to_headermap(header: &[u8]) -> Result<HashMap<&[u8], &[u8]>, ()> {
} }
i += 1; i += 1;
} }
sep.push(i); // same len for both Vecs sep.push(i);
i = 0; i = 0;
let mut ret = HashMap::new(); let mut ret = HashMap::new();
@@ -384,7 +475,7 @@ fn to_headermap(header: &[u8]) -> Result<HashMap<&[u8], &[u8]>, ()> {
i += 1; i += 1;
} }
if a <= i || k <= 1 + a { if a <= i || k <= 1 + a {
//keys and vals must contain one char //keys and values must contain one char
return Err(()); return Err(());
} }
let key = &header[i..a]; let key = &header[i..a];
@@ -401,12 +492,135 @@ fn to_headermap(header: &[u8]) -> Result<HashMap<&[u8], &[u8]>, ()> {
Ok(ret) Ok(ret)
} }
fn create_nonce() -> String { fn create_nonce() -> Result<String> {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap(); let now = unix_now()?;
let secs = now.as_secs() as u32; let secs = now.as_secs() as u32;
let mut h = NONCESTARTHASH.clone(); let mut h = NONCESTARTHASH.clone();
h.consume(secs.to_be_bytes()); h.consume(secs.to_be_bytes());
let n = format!("{:08x}{:032x}", secs, h.compute()); let n = format!("{:08x}{:032x}", secs, h.compute());
n[..34].to_string() Ok(n[..34].to_string())
}
fn split_account_paths(s: &str) -> Option<(&str, &str)> {
let i = s.find("@/")?;
Some((&s[0..i], &s[i + 1..]))
}
fn split_rules(rules: &[&str]) -> Vec<String> {
let mut output = vec![];
for rule in rules {
let parts: Vec<&str> = rule.split('|').collect();
let mut rules_list = vec![];
let mut concated_part = String::new();
for (i, part) in parts.iter().enumerate() {
if part.contains("@/") {
concated_part.push_str(part);
let mut concated_part_tmp = String::new();
std::mem::swap(&mut concated_part_tmp, &mut concated_part);
rules_list.push(concated_part_tmp);
continue;
}
concated_part.push_str(part);
if i < parts.len() - 1 {
concated_part.push('|');
}
}
if !concated_part.is_empty() {
rules_list.push(concated_part)
}
output.extend(rules_list);
}
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_split_account_paths() {
assert_eq!(
split_account_paths("user:pass@/:rw"),
Some(("user:pass", "/:rw"))
);
assert_eq!(
split_account_paths("user:pass@@/:rw"),
Some(("user:pass@", "/:rw"))
);
assert_eq!(
split_account_paths("user:pass@1@/:rw"),
Some(("user:pass@1", "/:rw"))
);
}
#[test]
fn test_compact_split_rules() {
assert_eq!(
split_rules(&["user1:pass1@/:rw|user2:pass2@/:rw"]),
["user1:pass1@/:rw", "user2:pass2@/:rw"]
);
assert_eq!(
split_rules(&["user1:pa|ss1@/:rw|user2:pa|ss2@/:rw"]),
["user1:pa|ss1@/:rw", "user2:pa|ss2@/:rw"]
);
assert_eq!(
split_rules(&["user1:pa|ss1@/:rw|@/"]),
["user1:pa|ss1@/:rw", "@/"]
);
}
#[test]
fn test_access_paths() {
let mut paths = AccessPaths::default();
paths.add("/dir1", AccessPerm::ReadWrite);
paths.add("/dir2/dir21", AccessPerm::ReadWrite);
paths.add("/dir2/dir21/dir211", AccessPerm::ReadOnly);
paths.add("/dir2/dir21/dir212", AccessPerm::Forbidden);
paths.add("/dir2/dir22", AccessPerm::ReadOnly);
paths.add("/dir2/dir22/dir221", AccessPerm::ReadWrite);
paths.add("/dir2/dir23/dir231", AccessPerm::ReadWrite);
assert_eq!(
paths.child_paths(Path::new("/tmp")),
[
"/tmp/dir1",
"/tmp/dir2/dir21",
"/tmp/dir2/dir22",
"/tmp/dir2/dir23/dir231",
]
.iter()
.map(PathBuf::from)
.collect::<Vec<_>>()
);
assert_eq!(
paths
.find("dir2", false)
.map(|v| v.child_paths(Path::new("/tmp/dir2"))),
Some(
[
"/tmp/dir2/dir21",
"/tmp/dir2/dir22",
"/tmp/dir2/dir23/dir231"
]
.iter()
.map(PathBuf::from)
.collect::<Vec<_>>()
)
);
assert_eq!(paths.find("dir2", true), None);
assert_eq!(
paths.find("dir1/file", true),
Some(AccessPaths::new(AccessPerm::ReadWrite))
);
assert_eq!(
paths.find("dir2/dir21/file", true),
Some(AccessPaths::new(AccessPerm::ReadWrite))
);
assert_eq!(
paths.find("dir2/dir21/dir211/file", false),
Some(AccessPaths::new(AccessPerm::ReadOnly))
);
assert_eq!(paths.find("dir2/dir21/dir211/file", true), None);
assert_eq!(paths.find("dir2/dir21/dir212", false), None);
}
} }

View File

@@ -1,35 +1,39 @@
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, Clone, PartialEq)]
pub struct LogHttp { pub struct HttpLogger {
elems: Vec<LogElement>, elements: Vec<LogElement>,
} }
#[derive(Debug)] impl Default for HttpLogger {
fn default() -> Self {
DEFAULT_LOG_FORMAT.parse().unwrap()
}
}
#[derive(Debug, Clone, PartialEq)]
enum LogElement { enum LogElement {
Variable(String), Variable(String),
Header(String), Header(String),
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 elem in self.elems.iter() { for element in self.elements.iter() {
match elem { match element {
LogElement::Variable(name) => match name.as_str() { LogElement::Variable(name) => match name.as_str() {
"request" => { "request" => {
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);
} }
@@ -47,12 +51,12 @@ impl LogHttp {
data data
} }
pub fn log(&self, data: &HashMap<String, String>, err: Option<String>) { pub fn log(&self, data: &HashMap<String, String>, err: Option<String>) {
if self.elems.is_empty() { if self.elements.is_empty() {
return; return;
} }
let mut output = String::new(); let mut output = String::new();
for elem in self.elems.iter() { for element in self.elements.iter() {
match elem { match element {
LogElement::Literal(value) => output.push_str(value.as_str()), LogElement::Literal(value) => output.push_str(value.as_str()),
LogElement::Header(name) | LogElement::Variable(name) => { LogElement::Header(name) | LogElement::Variable(name) => {
output.push_str(data.get(name).map(|v| v.as_str()).unwrap_or("-")) output.push_str(data.get(name).map(|v| v.as_str()).unwrap_or("-"))
@@ -66,24 +70,24 @@ impl LogHttp {
} }
} }
impl FromStr for LogHttp { impl FromStr for HttpLogger {
type Err = Box<dyn std::error::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 elems = vec![]; let mut elements = vec![];
let mut is_var = false; let mut is_var = false;
let mut cache = String::new(); let mut cache = String::new();
for c in format!("{} ", s).chars() { for c in format!("{s} ").chars() {
if c == '$' { if c == '$' {
if !cache.is_empty() { if !cache.is_empty() {
elems.push(LogElement::Literal(cache.to_string())); elements.push(LogElement::Literal(cache.to_string()));
} }
cache.clear(); cache.clear();
is_var = true; is_var = true;
} else if is_var && !(c.is_alphanumeric() || c == '_') { } else if is_var && !(c.is_alphanumeric() || c == '_') {
if let Some(value) = cache.strip_prefix("$http_") { if let Some(value) = cache.strip_prefix("$http_") {
elems.push(LogElement::Header(value.replace('_', "-").to_string())); elements.push(LogElement::Header(value.replace('_', "-").to_string()));
} else if let Some(value) = cache.strip_prefix('$') { } else if let Some(value) = cache.strip_prefix('$') {
elems.push(LogElement::Variable(value.to_string())); elements.push(LogElement::Variable(value.to_string()));
} }
cache.clear(); cache.clear();
is_var = false; is_var = false;
@@ -92,8 +96,8 @@ impl FromStr for LogHttp {
} }
let cache = cache.trim(); let cache = cache.trim();
if !cache.is_empty() { if !cache.is_empty() {
elems.push(LogElement::Literal(cache.to_string())); elements.push(LogElement::Literal(cache.to_string()));
} }
Ok(Self { elems }) Ok(Self { elements })
} }
} }

105
src/http_utils.rs Normal file
View File

@@ -0,0 +1,105 @@
use bytes::{Bytes, BytesMut};
use futures_util::Stream;
use http_body_util::{combinators::BoxBody, BodyExt, Full};
use hyper::body::{Body, Incoming};
use std::{
pin::Pin,
task::{Context, Poll},
};
use tokio::io::AsyncRead;
use tokio_util::io::poll_read_buf;
#[derive(Debug)]
pub struct IncomingStream {
inner: Incoming,
}
impl IncomingStream {
pub fn new(inner: Incoming) -> Self {
Self { inner }
}
}
impl Stream for IncomingStream {
type Item = Result<Bytes, anyhow::Error>;
#[inline]
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
loop {
match futures_util::ready!(Pin::new(&mut self.inner).poll_frame(cx)?) {
Some(frame) => match frame.into_data() {
Ok(data) => return Poll::Ready(Some(Ok(data))),
Err(_frame) => {}
},
None => return Poll::Ready(None),
}
}
}
}
pin_project_lite::pin_project! {
pub struct LengthLimitedStream<R> {
#[pin]
reader: Option<R>,
remaining: usize,
buf: BytesMut,
capacity: usize,
}
}
impl<R> LengthLimitedStream<R> {
pub fn new(reader: R, limit: usize) -> Self {
Self {
reader: Some(reader),
remaining: limit,
buf: BytesMut::new(),
capacity: 4096,
}
}
}
impl<R: AsyncRead> Stream for LengthLimitedStream<R> {
type Item = std::io::Result<Bytes>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let mut this = self.as_mut().project();
if *this.remaining == 0 {
self.project().reader.set(None);
return Poll::Ready(None);
}
let reader = match this.reader.as_pin_mut() {
Some(r) => r,
None => return Poll::Ready(None),
};
if this.buf.capacity() == 0 {
this.buf.reserve(*this.capacity);
}
match poll_read_buf(reader, cx, &mut this.buf) {
Poll::Pending => Poll::Pending,
Poll::Ready(Err(err)) => {
self.project().reader.set(None);
Poll::Ready(Some(Err(err)))
}
Poll::Ready(Ok(0)) => {
self.project().reader.set(None);
Poll::Ready(None)
}
Poll::Ready(Ok(_)) => {
let mut chunk = this.buf.split();
let chunk_size = (*this.remaining).min(chunk.len());
chunk.truncate(chunk_size);
*this.remaining -= chunk_size;
Poll::Ready(Some(Ok(chunk.freeze())))
}
}
}
}
pub fn body_full(content: impl Into<hyper::body::Bytes>) -> BoxBody<Bytes, anyhow::Error> {
Full::new(content.into())
.map_err(anyhow::Error::new)
.boxed()
}

View File

@@ -1,47 +1,41 @@
mod args; mod args;
mod auth; mod auth;
mod log_http; mod http_logger;
mod http_utils;
mod logger; mod logger;
mod server; mod server;
mod streamer;
#[cfg(feature = "tls")]
mod tls;
#[cfg(unix)]
mod unix;
mod utils; mod utils;
#[macro_use] #[macro_use]
extern crate log; 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::Server;
#[cfg(feature = "tls")] #[cfg(feature = "tls")]
use crate::tls::{TlsAcceptor, TlsStream}; use crate::utils::{load_certs, load_private_key};
use std::net::{IpAddr, SocketAddr, TcpListener as StdTcpListener};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use anyhow::{anyhow, Context, Result};
use args::BindAddr; use args::BindAddr;
use clap_complete::Shell; use clap_complete::Shell;
use futures::future::join_all; use futures_util::future::join_all;
use tokio::net::TcpListener;
use tokio::task::JoinHandle;
use hyper::server::conn::{AddrIncoming, AddrStream}; use hyper::{body::Incoming, service::service_fn, Request};
use hyper::service::{make_service_fn, service_fn}; use hyper_util::{
rt::{TokioExecutor, TokioIo},
server::conn::auto::Builder,
};
use std::net::{IpAddr, SocketAddr, TcpListener as StdTcpListener};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use tokio::{net::TcpListener, task::JoinHandle};
#[cfg(feature = "tls")] #[cfg(feature = "tls")]
use rustls::ServerConfig; use tokio_rustls::{rustls::ServerConfig, TlsAcceptor};
pub type BoxResult<T> = Result<T, Box<dyn std::error::Error>>;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() -> Result<()> {
run().await.unwrap_or_else(handle_err) logger::init().map_err(|e| anyhow!("Failed to init logger, {e}"))?;
}
async fn run() -> BoxResult<()> {
logger::init().map_err(|e| format!("Failed to init logger, {}", e))?;
let cmd = build_cli(); let cmd = build_cli();
let matches = cmd.get_matches(); let matches = cmd.get_matches();
if let Some(generator) = matches.get_one::<Shell>("completions") { if let Some(generator) = matches.get_one::<Shell>("completions") {
@@ -50,10 +44,10 @@ async fn run() -> BoxResult<()> {
return Ok(()); return Ok(());
} }
let args = Args::parse(matches)?; let args = Args::parse(matches)?;
let args = Arc::new(args);
let running = Arc::new(AtomicBool::new(true)); let running = Arc::new(AtomicBool::new(true));
let handles = serve(args.clone(), running.clone())?; let listening = print_listening(&args)?;
print_listening(args)?; let handles = serve(args, running.clone())?;
println!("{listening}");
tokio::select! { tokio::select! {
ret = join_all(handles) => { ret = join_all(handles) => {
@@ -71,58 +65,66 @@ async fn run() -> BoxResult<()> {
} }
} }
fn serve( fn serve(args: Args, running: Arc<AtomicBool>) -> Result<Vec<JoinHandle<()>>> {
args: Arc<Args>, let addrs = args.addrs.clone();
running: Arc<AtomicBool>,
) -> BoxResult<Vec<JoinHandle<Result<(), hyper::Error>>>> {
let inner = Arc::new(Server::new(args.clone(), running));
let mut handles = vec![];
let port = args.port; let port = args.port;
for bind_addr in args.addrs.iter() { let tls_config = (args.tls_cert.clone(), args.tls_key.clone());
let inner = inner.clone(); let server_handle = Arc::new(Server::init(args, running)?);
let serve_func = move |remote_addr: Option<SocketAddr>| { let mut handles = vec![];
let inner = inner.clone(); for bind_addr in addrs.iter() {
async move { let server_handle = server_handle.clone();
Ok::<_, hyper::Error>(service_fn(move |req: Request| {
let inner = inner.clone();
inner.call(req, remote_addr)
}))
}
};
match bind_addr { match bind_addr {
BindAddr::Address(ip) => { BindAddr::Address(ip) => {
let incoming = create_addr_incoming(SocketAddr::new(*ip, port)) let listener = create_listener(SocketAddr::new(*ip, port))
.map_err(|e| format!("Failed to bind `{}:{}`, {}", ip, port, e))?; .with_context(|| format!("Failed to bind `{ip}:{port}`"))?;
match args.tls.as_ref() {
match &tls_config {
#[cfg(feature = "tls")] #[cfg(feature = "tls")]
Some((certs, key)) => { (Some(cert_file), Some(key_file)) => {
let config = ServerConfig::builder() let certs = load_certs(cert_file)?;
.with_safe_defaults() let key = load_private_key(key_file)?;
let mut config = ServerConfig::builder()
.with_no_client_auth() .with_no_client_auth()
.with_single_cert(certs.clone(), key.clone())?; .with_single_cert(certs, key)?;
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
let config = Arc::new(config); let config = Arc::new(config);
let accepter = TlsAcceptor::new(config.clone(), incoming); let tls_accepter = TlsAcceptor::from(config);
let new_service = make_service_fn(move |socket: &TlsStream| {
let remote_addr = socket.remote_addr(); let handle = tokio::spawn(async move {
serve_func(Some(remote_addr)) loop {
let (cnx, addr) = listener.accept().await.unwrap();
let Ok(stream) = tls_accepter.accept(cnx).await else {
warn!("During cls handshake connection from {}", addr);
continue;
};
let stream = TokioIo::new(stream);
tokio::spawn(handle_stream(
server_handle.clone(),
stream,
Some(addr),
));
}
}); });
let server =
tokio::spawn(hyper::Server::builder(accepter).serve(new_service)); handles.push(handle);
handles.push(server);
} }
#[cfg(not(feature = "tls"))] (None, None) => {
Some(_) => { let handle = tokio::spawn(async move {
loop {
let (cnx, addr) = listener.accept().await.unwrap();
let stream = TokioIo::new(cnx);
tokio::spawn(handle_stream(
server_handle.clone(),
stream,
Some(addr),
));
}
});
handles.push(handle);
}
_ => {
unreachable!() unreachable!()
} }
None => {
let new_service = make_service_fn(move |socket: &AddrStream| {
let remote_addr = socket.remote_addr();
serve_func(Some(remote_addr))
});
let server =
tokio::spawn(hyper::Server::builder(incoming).serve(new_service));
handles.push(server);
}
}; };
} }
BindAddr::Path(path) => { BindAddr::Path(path) => {
@@ -132,11 +134,16 @@ fn serve(
#[cfg(unix)] #[cfg(unix)]
{ {
let listener = tokio::net::UnixListener::bind(path) let listener = tokio::net::UnixListener::bind(path)
.map_err(|e| format!("Failed to bind `{}`, {}", path.display(), e))?; .with_context(|| format!("Failed to bind `{}`", path.display()))?;
let acceptor = unix::UnixAcceptor::from_listener(listener); let handle = tokio::spawn(async move {
let new_service = make_service_fn(move |_| serve_func(None)); loop {
let server = tokio::spawn(hyper::Server::builder(acceptor).serve(new_service)); let (cnx, _) = listener.accept().await.unwrap();
handles.push(server); let stream = TokioIo::new(cnx);
tokio::spawn(handle_stream(server_handle.clone(), stream, None));
}
});
handles.push(handle);
} }
} }
} }
@@ -144,7 +151,30 @@ fn serve(
Ok(handles) Ok(handles)
} }
fn create_addr_incoming(addr: SocketAddr) -> BoxResult<AddrIncoming> { async fn handle_stream<T>(handle: Arc<Server>, stream: TokioIo<T>, addr: Option<SocketAddr>)
where
T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
let hyper_service =
service_fn(move |request: Request<Incoming>| handle.clone().call(request, addr));
let ret = Builder::new(TokioExecutor::new())
.serve_connection_with_upgrades(stream, hyper_service)
.await;
if let Err(err) = ret {
let scope = match addr {
Some(addr) => format!(" from {}", addr),
None => String::new(),
};
match err.downcast_ref::<std::io::Error>() {
Some(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => {}
_ => warn!("Serving connection{}: {}", scope, err),
}
}
}
fn create_listener(addr: SocketAddr) -> Result<TcpListener> {
use socket2::{Domain, Protocol, Socket, Type}; use socket2::{Domain, Protocol, Socket, Type};
let socket = Socket::new(Domain::for_address(addr), Type::STREAM, Some(Protocol::TCP))?; let socket = Socket::new(Domain::for_address(addr), Type::STREAM, Some(Protocol::TCP))?;
if addr.is_ipv6() { if addr.is_ipv6() {
@@ -155,11 +185,12 @@ fn create_addr_incoming(addr: SocketAddr) -> BoxResult<AddrIncoming> {
socket.listen(1024 /* Default backlog */)?; socket.listen(1024 /* Default backlog */)?;
let std_listener = StdTcpListener::from(socket); let std_listener = StdTcpListener::from(socket);
std_listener.set_nonblocking(true)?; std_listener.set_nonblocking(true)?;
let incoming = AddrIncoming::from_listener(TcpListener::from_std(std_listener)?)?; let listener = TcpListener::from_std(std_listener)?;
Ok(incoming) Ok(listener)
} }
fn print_listening(args: Arc<Args>) -> BoxResult<()> { fn print_listening(args: &Args) -> Result<String> {
let mut output = String::new();
let mut bind_addrs = vec![]; let mut bind_addrs = vec![];
let (mut ipv4, mut ipv6) = (false, false); let (mut ipv4, mut ipv6) = (false, false);
for bind_addr in args.addrs.iter() { for bind_addr in args.addrs.iter() {
@@ -179,8 +210,8 @@ fn print_listening(args: Arc<Args>) -> BoxResult<()> {
} }
} }
if ipv4 || ipv6 { if ipv4 || ipv6 {
let ifaces = if_addrs::get_if_addrs() let ifaces =
.map_err(|e| format!("Failed to get local interface addresses: {}", e))?; if_addrs::get_if_addrs().with_context(|| "Failed to get local interface addresses")?;
for iface in ifaces.into_iter() { for iface in ifaces.into_iter() {
let local_ip = iface.ip(); let local_ip = iface.ip();
if ipv4 && local_ip.is_ipv4() { if ipv4 && local_ip.is_ipv4() {
@@ -200,7 +231,11 @@ fn print_listening(args: Arc<Args>) -> BoxResult<()> {
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(),
@@ -208,22 +243,17 @@ fn print_listening(args: Arc<Args>) -> BoxResult<()> {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if urls.len() == 1 { if urls.len() == 1 {
println!("Listening on {}", urls[0]); output.push_str(&format!("Listening on {}", urls[0]))
} else { } else {
let info = urls let info = urls
.iter() .iter()
.map(|v| format!(" {}", v)) .map(|v| format!(" {v}"))
.collect::<Vec<String>>() .collect::<Vec<String>>()
.join("\n"); .join("\n");
println!("Listening on:\n{}\n", info); output.push_str(&format!("Listening on:\n{info}\n"))
} }
Ok(()) Ok(output)
}
fn handle_err<T>(err: Box<dyn std::error::Error>) -> T {
eprintln!("error: {}", err);
std::process::exit(1);
} }
async fn shutdown_signal() { async fn shutdown_signal() {

File diff suppressed because it is too large Load Diff

View File

@@ -1,68 +0,0 @@
use async_stream::stream;
use futures::{Stream, StreamExt};
use std::io::Error;
use std::pin::Pin;
use tokio::io::{AsyncRead, AsyncReadExt};
pub struct Streamer<R>
where
R: AsyncRead + Unpin + Send + 'static,
{
reader: R,
buf_size: usize,
}
impl<R> Streamer<R>
where
R: AsyncRead + Unpin + Send + 'static,
{
#[inline]
pub fn new(reader: R, buf_size: usize) -> Self {
Self { reader, buf_size }
}
pub fn into_stream(
mut self,
) -> Pin<Box<impl ?Sized + Stream<Item = Result<Vec<u8>, Error>> + 'static>> {
let stream = stream! {
loop {
let mut buf = vec![0; self.buf_size];
let r = self.reader.read(&mut buf).await?;
if r == 0 {
break
}
buf.truncate(r);
yield Ok(buf);
}
};
stream.boxed()
}
// allow truncation as truncated remaining is always less than buf_size: usize
pub fn into_stream_sized(
mut self,
max_length: u64,
) -> Pin<Box<impl ?Sized + Stream<Item = Result<Vec<u8>, Error>> + 'static>> {
let stream = stream! {
let mut remaining = max_length;
loop {
if remaining == 0 {
break;
}
let bs = if remaining >= self.buf_size as u64 {
self.buf_size
} else {
remaining as usize
};
let mut buf = vec![0; bs];
let r = self.reader.read(&mut buf).await?;
if r == 0 {
break;
} else {
buf.truncate(r);
yield Ok(buf);
}
remaining -= r as u64;
}
};
stream.boxed()
}
}

View File

@@ -1,164 +0,0 @@
use core::task::{Context, Poll};
use futures::ready;
use hyper::server::accept::Accept;
use hyper::server::conn::{AddrIncoming, AddrStream};
use rustls::{Certificate, PrivateKey};
use std::future::Future;
use std::net::SocketAddr;
use std::path::Path;
use std::pin::Pin;
use std::sync::Arc;
use std::{fs, io};
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tokio_rustls::rustls::ServerConfig;
enum State {
Handshaking(tokio_rustls::Accept<AddrStream>),
Streaming(tokio_rustls::server::TlsStream<AddrStream>),
}
// tokio_rustls::server::TlsStream doesn't expose constructor methods,
// so we have to TlsAcceptor::accept and handshake to have access to it
// TlsStream implements AsyncRead/AsyncWrite handshaking tokio_rustls::Accept first
pub struct TlsStream {
state: State,
remote_addr: SocketAddr,
}
impl TlsStream {
fn new(stream: AddrStream, config: Arc<ServerConfig>) -> TlsStream {
let remote_addr = stream.remote_addr();
let accept = tokio_rustls::TlsAcceptor::from(config).accept(stream);
TlsStream {
state: State::Handshaking(accept),
remote_addr,
}
}
pub fn remote_addr(&self) -> SocketAddr {
self.remote_addr
}
}
impl AsyncRead for TlsStream {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context,
buf: &mut ReadBuf,
) -> Poll<io::Result<()>> {
let pin = self.get_mut();
match pin.state {
State::Handshaking(ref mut accept) => match ready!(Pin::new(accept).poll(cx)) {
Ok(mut stream) => {
let result = Pin::new(&mut stream).poll_read(cx, buf);
pin.state = State::Streaming(stream);
result
}
Err(err) => Poll::Ready(Err(err)),
},
State::Streaming(ref mut stream) => Pin::new(stream).poll_read(cx, buf),
}
}
}
impl AsyncWrite for TlsStream {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
let pin = self.get_mut();
match pin.state {
State::Handshaking(ref mut accept) => match ready!(Pin::new(accept).poll(cx)) {
Ok(mut stream) => {
let result = Pin::new(&mut stream).poll_write(cx, buf);
pin.state = State::Streaming(stream);
result
}
Err(err) => Poll::Ready(Err(err)),
},
State::Streaming(ref mut stream) => Pin::new(stream).poll_write(cx, buf),
}
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
match self.state {
State::Handshaking(_) => Poll::Ready(Ok(())),
State::Streaming(ref mut stream) => Pin::new(stream).poll_flush(cx),
}
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
match self.state {
State::Handshaking(_) => Poll::Ready(Ok(())),
State::Streaming(ref mut stream) => Pin::new(stream).poll_shutdown(cx),
}
}
}
pub struct TlsAcceptor {
config: Arc<ServerConfig>,
incoming: AddrIncoming,
}
impl TlsAcceptor {
pub fn new(config: Arc<ServerConfig>, incoming: AddrIncoming) -> TlsAcceptor {
TlsAcceptor { config, incoming }
}
}
impl Accept for TlsAcceptor {
type Conn = TlsStream;
type Error = io::Error;
fn poll_accept(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<Self::Conn, Self::Error>>> {
let pin = self.get_mut();
match ready!(Pin::new(&mut pin.incoming).poll_accept(cx)) {
Some(Ok(sock)) => Poll::Ready(Some(Ok(TlsStream::new(sock, pin.config.clone())))),
Some(Err(e)) => Poll::Ready(Some(Err(e))),
None => Poll::Ready(None),
}
}
}
// Load public certificate from file.
pub fn load_certs<T: AsRef<Path>>(
filename: T,
) -> Result<Vec<Certificate>, Box<dyn std::error::Error>> {
// Open certificate file.
let cert_file = fs::File::open(filename.as_ref())
.map_err(|e| format!("Failed to access `{}`, {}", filename.as_ref().display(), e))?;
let mut reader = io::BufReader::new(cert_file);
// Load and return certificate.
let certs = rustls_pemfile::certs(&mut reader).map_err(|_| "Failed to load certificate")?;
if certs.is_empty() {
return Err("No supported certificate in file".into());
}
Ok(certs.into_iter().map(Certificate).collect())
}
// Load private key from file.
pub fn load_private_key<T: AsRef<Path>>(
filename: T,
) -> Result<PrivateKey, Box<dyn std::error::Error>> {
let key_file = fs::File::open(filename.as_ref())
.map_err(|e| format!("Failed to access `{}`, {}", filename.as_ref().display(), e))?;
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| format!("There was a problem with reading private key: {:?}", e))?
.into_iter()
.find_map(|item| match item {
rustls_pemfile::Item::RSAKey(key)
| rustls_pemfile::Item::PKCS8Key(key)
| rustls_pemfile::Item::ECKey(key) => Some(key),
_ => None,
})
.ok_or("No supported private key in file")?;
Ok(PrivateKey(keys))
}

View File

@@ -1,31 +0,0 @@
use hyper::server::accept::Accept;
use tokio::net::UnixListener;
use std::pin::Pin;
use std::task::{Context, Poll};
pub struct UnixAcceptor {
inner: UnixListener,
}
impl UnixAcceptor {
pub fn from_listener(listener: UnixListener) -> Self {
Self { inner: listener }
}
}
impl Accept for UnixAcceptor {
type Conn = tokio::net::UnixStream;
type Error = std::io::Error;
fn poll_accept(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<Self::Conn, Self::Error>>> {
match self.inner.poll_accept(cx) {
Poll::Pending => Poll::Pending,
Poll::Ready(Ok((socket, _addr))) => Poll::Ready(Some(Ok(socket))),
Poll::Ready(Err(err)) => Poll::Ready(Some(Err(err))),
}
}
}

View File

@@ -1,5 +1,18 @@
use crate::BoxResult; use anyhow::{anyhow, Context, Result};
use std::{borrow::Cow, path::Path}; use chrono::{DateTime, Utc};
#[cfg(feature = "tls")]
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
use std::{
borrow::Cow,
path::Path,
time::{Duration, SystemTime, UNIX_EPOCH},
};
pub fn unix_now() -> Result<Duration> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.with_context(|| "Invalid system time")
}
pub fn encode_uri(v: &str) -> String { pub fn encode_uri(v: &str) -> String {
let parts: Vec<_> = v.split('/').map(urlencoding::encode).collect(); let parts: Vec<_> = v.split('/').map(urlencoding::encode).collect();
@@ -18,69 +31,144 @@ pub fn get_file_name(path: &Path) -> &str {
.unwrap_or_default() .unwrap_or_default()
} }
pub fn try_get_file_name(path: &Path) -> BoxResult<&str> { #[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() path.file_name()
.and_then(|v| v.to_str()) .and_then(|v| v.to_str())
.ok_or_else(|| format!("Failed to get file name of `{}`", path.display()).into()) .ok_or_else(|| anyhow!("Failed to get file name of `{}`", path.display()))
} }
pub fn glob(source: &str, target: &str) -> bool { pub fn glob(pattern: &str, target: &str) -> bool {
let ss: Vec<char> = source.chars().collect(); let pat = match ::glob::Pattern::new(pattern) {
let mut iter = target.chars(); Ok(pat) => pat,
let mut i = 0; Err(_) => return false,
'outer: while i < ss.len() { };
let s = ss[i]; pat.matches(target)
match s { }
'*' => match ss.get(i + 1) {
Some(s_next) => { // Load public certificate from file.
for t in iter.by_ref() { #[cfg(feature = "tls")]
if t == *s_next { pub fn load_certs<T: AsRef<Path>>(filename: T) -> Result<Vec<CertificateDer<'static>>> {
i += 2; // Open certificate file.
continue 'outer; let cert_file = std::fs::File::open(filename.as_ref())
} .with_context(|| format!("Failed to access `{}`", filename.as_ref().display()))?;
} let mut reader = std::io::BufReader::new(cert_file);
return false;
} // Load and return certificate.
None => return true, let mut certs = vec![];
}, for cert in rustls_pemfile::certs(&mut reader) {
'?' => match iter.next() { let cert = cert.with_context(|| "Failed to load certificate")?;
Some(_) => { certs.push(cert)
i += 1; }
continue; if certs.is_empty() {
} anyhow::bail!("No supported certificate in file");
None => return false, }
}, Ok(certs)
_ => match iter.next() { }
Some(t) => {
if s == t { // Load private key from file.
i += 1; #[cfg(feature = "tls")]
continue; pub fn load_private_key<T: AsRef<Path>>(filename: T) -> Result<PrivateKeyDer<'static>> {
} let key_file = std::fs::File::open(filename.as_ref())
return false; .with_context(|| format!("Failed to access `{}`", filename.as_ref().display()))?;
} let mut reader = std::io::BufReader::new(key_file);
None => return false,
}, // Load and return a single private key.
for key in rustls_pemfile::read_all(&mut reader) {
let key = key.with_context(|| "There was a problem with reading private key")?;
match key {
rustls_pemfile::Item::Pkcs1Key(key) => return Ok(PrivateKeyDer::Pkcs1(key)),
rustls_pemfile::Item::Pkcs8Key(key) => return Ok(PrivateKeyDer::Pkcs8(key)),
rustls_pemfile::Item::Sec1Key(key) => return Ok(PrivateKeyDer::Sec1(key)),
_ => {}
} }
} }
iter.next().is_none() anyhow::bail!("No supported private key in file");
} }
#[test] pub fn parse_range(range: &str, size: u64) -> Option<(u64, u64)> {
fn test_glob_key() { let (unit, range) = range.split_once('=')?;
assert!(glob("", "")); if unit != "bytes" || range.contains(',') {
assert!(glob(".*", ".git")); return None;
assert!(glob("abc", "abc")); }
assert!(glob("a*c", "abc")); let (start, end) = range.split_once('-')?;
assert!(glob("a?c", "abc")); if start.is_empty() {
assert!(glob("a*c", "abbc")); let offset = end.parse::<u64>().ok()?;
assert!(glob("*c", "abc")); if offset <= size {
assert!(glob("a*", "abc")); Some((size - offset, size - 1))
assert!(glob("?c", "bc")); } else {
assert!(glob("a?", "ab")); None
assert!(!glob("abc", "adc")); }
assert!(!glob("abc", "abcd")); } else {
assert!(!glob("a?c", "abbc")); let start = start.parse::<u64>().ok()?;
assert!(!glob("*.log", "log")); if start < size {
assert!(glob("*.log", ".log")); if end.is_empty() {
assert!(glob("*.log", "a.log")); Some((start, size - 1))
} else {
let end = end.parse::<u64>().ok()?;
if end < size {
Some((start, end))
} else {
None
}
}
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_glob_key() {
assert!(glob("", ""));
assert!(glob(".*", ".git"));
assert!(glob("abc", "abc"));
assert!(glob("a*c", "abc"));
assert!(glob("a?c", "abc"));
assert!(glob("a*c", "abbc"));
assert!(glob("*c", "abc"));
assert!(glob("a*", "abc"));
assert!(glob("?c", "bc"));
assert!(glob("a?", "ab"));
assert!(!glob("abc", "adc"));
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"));
}
#[test]
fn test_parse_range() {
assert_eq!(parse_range("bytes=0-499", 500), Some((0, 499)));
assert_eq!(parse_range("bytes=0-", 500), Some((0, 499)));
assert_eq!(parse_range("bytes=299-", 500), Some((299, 499)));
assert_eq!(parse_range("bytes=-500", 500), Some((0, 499)));
assert_eq!(parse_range("bytes=-300", 500), Some((200, 499)));
assert_eq!(parse_range("bytes=500-", 500), None);
assert_eq!(parse_range("bytes=-501", 500), None);
assert_eq!(parse_range("bytes=0-500", 500), None);
}
} }

View File

@@ -20,6 +20,13 @@ fn default_not_allow_delete(server: TestServer) -> Result<(), Error> {
Ok(()) Ok(())
} }
#[rstest]
fn default_not_allow_archive(server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?zip", server.url()))?;
assert_eq!(resp.status(), 404);
Ok(())
}
#[rstest] #[rstest]
fn default_not_exist_dir(server: TestServer) -> Result<(), Error> { fn default_not_exist_dir(server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}404/", server.url()))?; let resp = reqwest::blocking::get(format!("{}404/", server.url()))?;
@@ -71,3 +78,15 @@ fn allow_search(#[with(&["--allow-search"])] server: TestServer) -> Result<(), E
} }
Ok(()) Ok(())
} }
#[rstest]
fn allow_archive(#[with(&["--allow-archive"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?zip", server.url()))?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/zip"
);
assert!(resp.headers().contains_key("content-disposition"));
Ok(())
}

View File

@@ -11,13 +11,13 @@ use std::process::{Command, Stdio};
fn assets(server: TestServer) -> Result<(), Error> { fn assets(server: TestServer) -> Result<(), Error> {
let ver = env!("CARGO_PKG_VERSION"); let ver = env!("CARGO_PKG_VERSION");
let resp = reqwest::blocking::get(server.url())?; let resp = reqwest::blocking::get(server.url())?;
let index_js = format!("/__dufs_v{}_index.js", ver); let index_js = format!("/__dufs_v{ver}_index.js");
let index_css = format!("/__dufs_v{}_index.css", ver); let index_css = format!("/__dufs_v{ver}_index.css");
let favicon_ico = format!("/__dufs_v{}_favicon.ico", ver); let favicon_ico = format!("/__dufs_v{ver}_favicon.ico");
let text = resp.text()?; let text = resp.text()?;
assert!(text.contains(&format!(r#"href="{}""#, index_css))); assert!(text.contains(&format!(r#"href="{index_css}""#)));
assert!(text.contains(&format!(r#"href="{}""#, favicon_ico))); assert!(text.contains(&format!(r#"href="{favicon_ico}""#)));
assert!(text.contains(&format!(r#"src="{}""#, index_js))); assert!(text.contains(&format!(r#"src="{index_js}""#)));
Ok(()) Ok(())
} }
@@ -32,7 +32,7 @@ fn asset_js(server: TestServer) -> Result<(), Error> {
assert_eq!(resp.status(), 200); assert_eq!(resp.status(), 200);
assert_eq!( assert_eq!(
resp.headers().get("content-type").unwrap(), resp.headers().get("content-type").unwrap(),
"application/javascript" "application/javascript; charset=UTF-8"
); );
Ok(()) Ok(())
} }
@@ -46,7 +46,10 @@ fn asset_css(server: TestServer) -> Result<(), Error> {
); );
let resp = reqwest::blocking::get(url)?; let resp = reqwest::blocking::get(url)?;
assert_eq!(resp.status(), 200); assert_eq!(resp.status(), 200);
assert_eq!(resp.headers().get("content-type").unwrap(), "text/css"); assert_eq!(
resp.headers().get("content-type").unwrap(),
"text/css; charset=UTF-8"
);
Ok(()) Ok(())
} }
@@ -67,13 +70,13 @@ fn asset_ico(server: TestServer) -> Result<(), Error> {
fn assets_with_prefix(#[with(&["--path-prefix", "xyz"])] server: TestServer) -> Result<(), Error> { fn assets_with_prefix(#[with(&["--path-prefix", "xyz"])] server: TestServer) -> Result<(), Error> {
let ver = env!("CARGO_PKG_VERSION"); let ver = env!("CARGO_PKG_VERSION");
let resp = reqwest::blocking::get(format!("{}xyz/", server.url()))?; let resp = reqwest::blocking::get(format!("{}xyz/", server.url()))?;
let index_js = format!("/xyz/__dufs_v{}_index.js", ver); let index_js = format!("/xyz/__dufs_v{ver}_index.js");
let index_css = format!("/xyz/__dufs_v{}_index.css", ver); let index_css = format!("/xyz/__dufs_v{ver}_index.css");
let favicon_ico = format!("/xyz/__dufs_v{}_favicon.ico", ver); let favicon_ico = format!("/xyz/__dufs_v{ver}_favicon.ico");
let text = resp.text()?; let text = resp.text()?;
assert!(text.contains(&format!(r#"href="{}""#, index_css))); assert!(text.contains(&format!(r#"href="{index_css}""#)));
assert!(text.contains(&format!(r#"href="{}""#, favicon_ico))); assert!(text.contains(&format!(r#"href="{favicon_ico}""#)));
assert!(text.contains(&format!(r#"src="{}""#, index_js))); assert!(text.contains(&format!(r#"src="{index_js}""#)));
Ok(()) Ok(())
} }
@@ -90,7 +93,7 @@ fn asset_js_with_prefix(
assert_eq!(resp.status(), 200); assert_eq!(resp.status(), 200);
assert_eq!( assert_eq!(
resp.headers().get("content-type").unwrap(), resp.headers().get("content-type").unwrap(),
"application/javascript" "application/javascript; charset=UTF-8"
); );
Ok(()) Ok(())
} }
@@ -108,7 +111,7 @@ fn assets_override(tmpdir: TempDir, port: u16) -> Result<(), Error> {
wait_for_port(port); wait_for_port(port);
let url = format!("http://localhost:{}", port); let url = format!("http://localhost:{port}");
let resp = reqwest::blocking::get(&url)?; let resp = reqwest::blocking::get(&url)?;
assert!(resp.text()?.starts_with(&format!( assert!(resp.text()?.starts_with(&format!(
"/__dufs_v{}_index.js;DATA", "/__dufs_v{}_index.js;DATA",

View File

@@ -3,13 +3,22 @@ mod utils;
use diqwest::blocking::WithDigestAuth; use diqwest::blocking::WithDigestAuth;
use fixtures::{server, Error, TestServer}; use fixtures::{server, Error, TestServer};
use indexmap::IndexSet;
use rstest::rstest; use rstest::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())?; let resp = reqwest::blocking::get(server.url())?;
assert_eq!(resp.status(), 401); assert_eq!(resp.status(), 401);
assert!(resp.headers().contains_key("www-authenticate")); let values: Vec<&str> = resp
.headers()
.get_all("www-authenticate")
.iter()
.map(|v| v.to_str().unwrap())
.collect();
assert!(values[0].starts_with("Digest"));
assert!(values[1].starts_with("Basic"));
let url = format!("{}file1", server.url()); let url = format!("{}file1", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?; let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 401); assert_eq!(resp.status(), 401);
@@ -17,7 +26,49 @@ fn no_auth(#[with(&["--auth", "/@user:pass", "-A"])] server: TestServer) -> Resu
} }
#[rstest] #[rstest]
fn auth(#[with(&["--auth", "/@user:pass", "-A"])] server: TestServer) -> Result<(), Error> { #[case(server(&["--auth", "user:pass@/:rw", "-A"]), "user", "pass")]
#[case(server(&["--auth", "user:pa:ss@1@/:rw", "-A"]), "user", "pa:ss@1")]
fn auth(#[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())
.send_with_digest_auth(user, pass)?;
assert_eq!(resp.status(), 201);
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(&["-a", "user:pass@/:rw", "-a", "@/", "-A"])] server: TestServer,
) -> Result<(), Error> {
let url = format!("{}file1", server.url()); let url = format!("{}file1", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?; let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 401); assert_eq!(resp.status(), 401);
@@ -25,19 +76,60 @@ fn auth(#[with(&["--auth", "/@user:pass", "-A"])] server: TestServer) -> Result<
.body(b"abc".to_vec()) .body(b"abc".to_vec())
.send_with_digest_auth("user", "pass")?; .send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 201); assert_eq!(resp.status(), 201);
let resp = fetch!(b"GET", &url).send()?;
assert_eq!(resp.status(), 200);
assert_eq!(resp.text()?, "abc");
Ok(()) Ok(())
} }
#[rstest] #[rstest]
fn auth_skip(#[with(&["--auth", "/@user:pass@*"])] 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())?;
assert_eq!(resp.status(), 200); assert_eq!(resp.status(), 200);
Ok(()) Ok(())
} }
#[rstest]
fn auth_skip_on_options_method(
#[with(&["--auth", "user:pass@/:rw"])] server: TestServer,
) -> Result<(), Error> {
let url = format!("{}index.html", server.url());
let resp = fetch!(b"OPTIONS", &url).send()?;
assert_eq!(resp.status(), 200);
Ok(())
}
#[rstest]
fn auth_check(
#[with(&["--auth", "user:pass@/:rw", "--auth", "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(), 403);
let resp = fetch!(b"WRITEABLE", &url).send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 200);
Ok(())
}
#[rstest]
fn auth_compact_rules(
#[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(), 403);
let resp = fetch!(b"WRITEABLE", &url).send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 200);
Ok(())
}
#[rstest] #[rstest]
fn auth_readonly( fn auth_readonly(
#[with(&["--auth", "/@user:pass@user2:pass2", "-A"])] server: TestServer, #[with(&["--auth", "user:pass@/:rw", "--auth", "user2:pass2@/", "-A"])] server: TestServer,
) -> Result<(), Error> { ) -> Result<(), Error> {
let url = format!("{}index.html", server.url()); let url = format!("{}index.html", server.url());
let resp = fetch!(b"GET", &url).send()?; let resp = fetch!(b"GET", &url).send()?;
@@ -48,16 +140,33 @@ fn auth_readonly(
let resp = fetch!(b"PUT", &url) let resp = fetch!(b"PUT", &url)
.body(b"abc".to_vec()) .body(b"abc".to_vec())
.send_with_digest_auth("user2", "pass2")?; .send_with_digest_auth("user2", "pass2")?;
assert_eq!(resp.status(), 401); assert_eq!(resp.status(), 403);
Ok(())
}
#[rstest]
fn auth_forbidden(
#[with(&["--auth", "user:pass@/:rw,/dir1:-", "-A"])] server: TestServer,
) -> Result<(), Error> {
let url = format!("{}file1", server.url());
let resp = fetch!(b"PUT", &url)
.body(b"abc".to_vec())
.send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 201);
let url = format!("{}dir1/file1", server.url());
let resp = fetch!(b"PUT", &url)
.body(b"abc".to_vec())
.send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 403);
Ok(()) Ok(())
} }
#[rstest] #[rstest]
fn auth_nest( fn auth_nest(
#[with(&["--auth", "/@user:pass@user2:pass2", "--auth", "/dira@user3:pass3", "-A"])] #[with(&["--auth", "user:pass@/:rw", "--auth", "user2:pass2@/", "--auth", "user3:pass3@/dir1:rw", "-A"])]
server: TestServer, server: TestServer,
) -> Result<(), Error> { ) -> Result<(), Error> {
let url = format!("{}dira/file1", server.url()); let url = format!("{}dir1/file1", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?; let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 401); assert_eq!(resp.status(), 401);
let resp = fetch!(b"PUT", &url) let resp = fetch!(b"PUT", &url)
@@ -73,7 +182,8 @@ fn auth_nest(
#[rstest] #[rstest]
fn auth_nest_share( fn auth_nest_share(
#[with(&["--auth", "/@user:pass@*", "--auth", "/dira@user3:pass3", "-A"])] server: TestServer, #[with(&["--auth", "@/", "--auth", "user:pass@/:rw", "--auth", "user3:pass3@/dir1:rw", "-A"])]
server: TestServer,
) -> Result<(), Error> { ) -> Result<(), Error> {
let url = format!("{}index.html", server.url()); let url = format!("{}index.html", server.url());
let resp = fetch!(b"GET", &url).send()?; let resp = fetch!(b"GET", &url).send()?;
@@ -82,15 +192,19 @@ fn auth_nest_share(
} }
#[rstest] #[rstest]
#[case(server(&["--auth", "user:pass@/:rw", "-A"]), "user", "pass")]
#[case(server(&["--auth", "u1:p1@/:rw", "-A"]), "u1", "p1")]
fn auth_basic( fn auth_basic(
#[with(&["--auth", "/@user:pass", "--auth-method", "basic", "-A"])] server: TestServer, #[case] server: TestServer,
#[case] user: &str,
#[case] pass: &str,
) -> Result<(), Error> { ) -> Result<(), Error> {
let url = format!("{}file1", server.url()); let url = format!("{}file1", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?; let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 401); assert_eq!(resp.status(), 401);
let resp = fetch!(b"PUT", &url) let resp = fetch!(b"PUT", &url)
.body(b"abc".to_vec()) .body(b"abc".to_vec())
.basic_auth("user", Some("pass")) .basic_auth(user, Some(pass))
.send()?; .send()?;
assert_eq!(resp.status(), 201); assert_eq!(resp.status(), 201);
Ok(()) Ok(())
@@ -98,9 +212,10 @@ fn auth_basic(
#[rstest] #[rstest]
fn auth_webdav_move( fn auth_webdav_move(
#[with(&["--auth", "/@user:pass@*", "--auth", "/dira@user3:pass3", "-A"])] server: TestServer, #[with(&["--auth", "user:pass@/:rw", "--auth", "user3:pass3@/dir1:rw", "-A"])]
server: TestServer,
) -> Result<(), Error> { ) -> Result<(), Error> {
let origin_url = format!("{}dira/test.html", server.url()); let origin_url = format!("{}dir1/test.html", server.url());
let new_url = format!("{}test2.html", server.url()); let new_url = format!("{}test2.html", server.url());
let resp = fetch!(b"MOVE", &origin_url) let resp = fetch!(b"MOVE", &origin_url)
.header("Destination", &new_url) .header("Destination", &new_url)
@@ -111,9 +226,10 @@ fn auth_webdav_move(
#[rstest] #[rstest]
fn auth_webdav_copy( fn auth_webdav_copy(
#[with(&["--auth", "/@user:pass@*", "--auth", "/dira@user3:pass3", "-A"])] server: TestServer, #[with(&["--auth", "user:pass@/:rw", "--auth", "user3:pass3@/dir1:rw", "-A"])]
server: TestServer,
) -> Result<(), Error> { ) -> Result<(), Error> {
let origin_url = format!("{}dira/test.html", server.url()); let origin_url = format!("{}dir1/test.html", server.url());
let new_url = format!("{}test2.html", server.url()); let new_url = format!("{}test2.html", server.url());
let resp = fetch!(b"COPY", &origin_url) let resp = fetch!(b"COPY", &origin_url)
.header("Destination", &new_url) .header("Destination", &new_url)
@@ -124,7 +240,7 @@ fn auth_webdav_copy(
#[rstest] #[rstest]
fn auth_path_prefix( 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> { ) -> Result<(), Error> {
let url = format!("{}xyz/index.html", server.url()); let url = format!("{}xyz/index.html", server.url());
let resp = fetch!(b"GET", &url).send()?; let resp = fetch!(b"GET", &url).send()?;
@@ -133,3 +249,85 @@ fn auth_path_prefix(
assert_eq!(resp.status(), 200); assert_eq!(resp.status(), 200);
Ok(()) 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", "admin:admin@/: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_propfind_dir(
#[with(&["--auth", "admin:admin@/:rw", "--auth", "user:pass@/dir-assets", "-A"])]
server: TestServer,
) -> Result<(), Error> {
let resp = fetch!(b"PROPFIND", server.url()).send_with_digest_auth("user", "pass")?;
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(&["-a", "user:pass@/:rw", "-a", "@/", "-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(())
}
#[rstest]
fn auth_precedence(
#[with(&["--auth", "user:pass@/dir1:rw,/dir1/test.txt", "-A"])] server: TestServer,
) -> Result<(), Error> {
let url = format!("{}dir1/test.txt", server.url());
let resp = fetch!(b"PUT", &url)
.body(b"abc".to_vec())
.send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 403);
let url = format!("{}dir1/file1", server.url());
let resp = fetch!(b"PUT", &url)
.body(b"abc".to_vec())
.send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 201);
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" "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

@@ -11,9 +11,19 @@ use std::time::{Duration, Instant};
#[allow(dead_code)] #[allow(dead_code)]
pub type Error = Box<dyn std::error::Error>; pub type Error = Box<dyn std::error::Error>;
#[allow(dead_code)]
pub const BIN_FILE: &str = "😀.bin";
/// File names for testing purpose /// File names for testing purpose
#[allow(dead_code)] #[allow(dead_code)]
pub static FILES: &[&str] = &["test.txt", "test.html", "index.html", "😀.bin"]; pub static FILES: &[&str] = &[
"test.txt",
"test.html",
"index.html",
#[cfg(not(target_os = "windows"))]
"file\n1.txt",
BIN_FILE,
];
/// Directory names for testing directory don't exist /// Directory names for testing directory don't exist
#[allow(dead_code)] #[allow(dead_code)]
@@ -33,7 +43,7 @@ pub static DIR_ASSETS: &str = "dir-assets/";
/// Directory names for testing purpose /// Directory names for testing purpose
#[allow(dead_code)] #[allow(dead_code)]
pub static DIRECTORIES: &[&str] = &["dira/", "dirb/", "dirc/", DIR_NO_INDEX, DIR_GIT, DIR_ASSETS]; pub static DIRECTORIES: &[&str] = &["dir1/", "dir2/", "dir3/", DIR_NO_INDEX, DIR_GIT, DIR_ASSETS];
/// Test fixture which creates a temporary directory with a few files and directories inside. /// Test fixture which creates a temporary directory with a few files and directories inside.
/// The directories also contain files. /// The directories also contain files.
@@ -42,29 +52,61 @@ pub static DIRECTORIES: &[&str] = &["dira/", "dirb/", "dirc/", DIR_NO_INDEX, DIR
pub fn tmpdir() -> TempDir { 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 {
tmpdir if *file == BIN_FILE {
.child(file) tmpdir.child(file).write_binary(b"bin\0\x00123").unwrap();
.write_str(&format!("This is {}", file)) } else {
.expect("Couldn't write to file"); tmpdir
.child(file)
.write_str(&format!("This is {file}"))
.unwrap();
}
} }
for directory in DIRECTORIES { for directory in DIRECTORIES {
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__")
.expect("Couldn't write to file"); .unwrap();
} else { } else {
for file in FILES { for file in FILES {
if *directory == DIR_NO_INDEX && *file == "index.html" { if *directory == DIR_NO_INDEX && *file == "index.html" {
continue; continue;
} }
tmpdir if *file == BIN_FILE {
.child(format!("{}{}", directory, file)) tmpdir
.write_str(&format!("This is {}{}", directory, file)) .child(format!("{directory}{file}"))
.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}"))
.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 tmpdir
} }
@@ -108,11 +150,11 @@ where
pub fn wait_for_port(port: u16) { pub fn wait_for_port(port: u16) {
let start_wait = Instant::now(); let start_wait = Instant::now();
while !port_check::is_port_reachable(format!("localhost:{}", port)) { while !port_check::is_port_reachable(format!("localhost:{port}")) {
sleep(Duration::from_millis(100)); sleep(Duration::from_millis(100));
if start_wait.elapsed().as_secs() > 1 { if start_wait.elapsed().as_secs() > 1 {
panic!("timeout waiting for port {}", port); panic!("timeout waiting for port {port}");
} }
} }
} }

View File

@@ -11,7 +11,7 @@ fn hidden_get_dir(#[case] server: TestServer, #[case] exist: bool) -> Result<(),
let resp = reqwest::blocking::get(server.url())?; let resp = reqwest::blocking::get(server.url())?;
assert_eq!(resp.status(), 200); assert_eq!(resp.status(), 200);
let paths = utils::retrieve_index_paths(&resp.text()?); let paths = utils::retrieve_index_paths(&resp.text()?);
assert!(paths.contains("dira/")); assert!(paths.contains("dir1/"));
assert_eq!(paths.contains(".git/"), exist); assert_eq!(paths.contains(".git/"), exist);
assert_eq!(paths.contains("index.html"), exist); assert_eq!(paths.contains("index.html"), exist);
Ok(()) Ok(())
@@ -24,7 +24,7 @@ fn hidden_get_dir2(#[case] server: TestServer, #[case] exist: bool) -> Result<()
let resp = reqwest::blocking::get(server.url())?; let resp = reqwest::blocking::get(server.url())?;
assert_eq!(resp.status(), 200); assert_eq!(resp.status(), 200);
let paths = utils::retrieve_index_paths(&resp.text()?); let paths = utils::retrieve_index_paths(&resp.text()?);
assert!(paths.contains("dira/")); assert!(paths.contains("dir1/"));
assert_eq!(paths.contains("index.html"), exist); assert_eq!(paths.contains("index.html"), exist);
assert_eq!(paths.contains("test.html"), exist); assert_eq!(paths.contains("test.html"), exist);
Ok(()) Ok(())
@@ -37,7 +37,7 @@ fn hidden_propfind_dir(#[case] server: TestServer, #[case] exist: bool) -> Resul
let resp = fetch!(b"PROPFIND", server.url()).send()?; let resp = fetch!(b"PROPFIND", server.url()).send()?;
assert_eq!(resp.status(), 207); assert_eq!(resp.status(), 207);
let body = resp.text()?; let body = resp.text()?;
assert!(body.contains("<D:href>/dira/</D:href>")); assert!(body.contains("<D:href>/dir1/</D:href>"));
assert_eq!(body.contains("<D:href>/.git/</D:href>"), exist); assert_eq!(body.contains("<D:href>/.git/</D:href>"), exist);
assert_eq!(body.contains("<D:href>/index.html</D:href>"), exist); assert_eq!(body.contains("<D:href>/index.html</D:href>"), exist);
Ok(()) Ok(())
@@ -55,3 +55,18 @@ fn hidden_search_dir(#[case] server: TestServer, #[case] exist: bool) -> Result<
} }
Ok(()) Ok(())
} }
#[rstest]
#[case(server(&["--hidden", "hidden/"]), "dir4/", 1)]
#[case(server(&["--hidden", "hidden"]), "dir4/", 0)]
fn hidden_dir_noly(
#[case] server: TestServer,
#[case] dir: &str,
#[case] count: usize,
) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}{}", server.url(), dir))?;
assert_eq!(resp.status(), 200);
let paths = utils::retrieve_index_paths(&resp.text()?);
assert_eq!(paths.len(), count);
Ok(())
}

View File

@@ -1,8 +1,10 @@
mod fixtures; mod fixtures;
mod utils; mod utils;
use fixtures::{server, Error, TestServer}; use fixtures::{server, Error, TestServer, BIN_FILE};
use rstest::rstest; use rstest::rstest;
use serde_json::Value;
use utils::retrive_edit_file;
#[rstest] #[rstest]
fn get_dir(server: TestServer) -> Result<(), Error> { fn get_dir(server: TestServer) -> Result<(), Error> {
@@ -38,7 +40,12 @@ fn head_dir_404(server: TestServer) -> Result<(), Error> {
} }
#[rstest] #[rstest]
fn get_dir_zip(server: TestServer) -> Result<(), Error> { #[case(server(&["--allow-archive"] as &[&str]))]
#[case(server(&["--allow-archive", "--compress", "none"]))]
#[case(server(&["--allow-archive", "--compress", "low"]))]
#[case(server(&["--allow-archive", "--compress", "medium"]))]
#[case(server(&["--allow-archive", "--compress", "high"]))]
fn get_dir_zip(#[case] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?zip", server.url()))?; let resp = reqwest::blocking::get(format!("{}?zip", server.url()))?;
assert_eq!(resp.status(), 200); assert_eq!(resp.status(), 200);
assert_eq!( assert_eq!(
@@ -50,7 +57,33 @@ fn get_dir_zip(server: TestServer) -> Result<(), Error> {
} }
#[rstest] #[rstest]
fn head_dir_zip(server: TestServer) -> Result<(), Error> { fn get_dir_json(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?json", server.url()))?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/json"
);
let json: Value = serde_json::from_str(&resp.text().unwrap()).unwrap();
assert!(json["paths"].as_array().is_some());
Ok(())
}
#[rstest]
fn get_dir_simple(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?simple", server.url()))?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"text/html; charset=utf-8"
);
let text = resp.text().unwrap();
assert!(text.split('\n').any(|v| v == "index.html"));
Ok(())
}
#[rstest]
fn head_dir_zip(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"HEAD", format!("{}?zip", server.url())).send()?; let resp = fetch!(b"HEAD", format!("{}?zip", server.url())).send()?;
assert_eq!(resp.status(), 200); assert_eq!(resp.status(), 200);
assert_eq!( assert_eq!(
@@ -76,16 +109,34 @@ fn get_dir_search(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
#[rstest] #[rstest]
fn get_dir_search2(#[with(&["-A"])] server: TestServer) -> Result<(), Error> { fn get_dir_search2(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?q={}", server.url(), "😀.bin"))?; let resp = reqwest::blocking::get(format!("{}?q={BIN_FILE}", server.url()))?;
assert_eq!(resp.status(), 200); assert_eq!(resp.status(), 200);
let paths = utils::retrieve_index_paths(&resp.text()?); let paths = utils::retrieve_index_paths(&resp.text()?);
assert!(!paths.is_empty()); assert!(!paths.is_empty());
for p in paths { for p in paths {
assert!(p.contains("😀.bin")); assert!(p.contains(BIN_FILE));
} }
Ok(()) Ok(())
} }
#[rstest]
fn get_dir_search3(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?q={}&simple", server.url(), "test.html"))?;
assert_eq!(resp.status(), 200);
let text = resp.text().unwrap();
assert!(text.split('\n').any(|v| v == "test.html"));
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()?;
@@ -101,9 +152,7 @@ fn head_dir_search(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
#[rstest] #[rstest]
fn empty_search(#[with(&["-A"])] server: TestServer) -> Result<(), Error> { fn empty_search(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?q=", server.url()))?; let resp = reqwest::blocking::get(format!("{}?q=", server.url()))?;
assert_eq!(resp.status(), 200); assert_resp_paths!(resp);
let paths = utils::retrieve_index_paths(&resp.text()?);
assert!(paths.is_empty());
Ok(()) Ok(())
} }
@@ -111,7 +160,10 @@ fn empty_search(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
fn get_file(server: TestServer) -> Result<(), Error> { fn get_file(server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}index.html", server.url()))?; let resp = reqwest::blocking::get(format!("{}index.html", server.url()))?;
assert_eq!(resp.status(), 200); 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_eq!(resp.headers().get("accept-ranges").unwrap(), "bytes");
assert!(resp.headers().contains_key("etag")); assert!(resp.headers().contains_key("etag"));
assert!(resp.headers().contains_key("last-modified")); assert!(resp.headers().contains_key("last-modified"));
@@ -124,7 +176,10 @@ fn get_file(server: TestServer) -> Result<(), Error> {
fn head_file(server: TestServer) -> Result<(), Error> { fn head_file(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"HEAD", format!("{}index.html", server.url())).send()?; let resp = fetch!(b"HEAD", format!("{}index.html", server.url())).send()?;
assert_eq!(resp.status(), 200); 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_eq!(resp.headers().get("accept-ranges").unwrap(), "bytes");
assert!(resp.headers().contains_key("content-disposition")); assert!(resp.headers().contains_key("content-disposition"));
assert!(resp.headers().contains_key("etag")); assert!(resp.headers().contains_key("etag"));
@@ -141,6 +196,47 @@ fn get_file_404(server: TestServer) -> Result<(), Error> {
Ok(()) 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(())
}
#[cfg(not(target_os = "windows"))]
#[rstest]
fn get_file_newline_path(server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}file%0A1.txt", server.url()))?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-disposition").unwrap(),
"inline; filename=\"file 1.txt\""
);
Ok(())
}
#[rstest]
fn get_file_edit(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"GET", format!("{}index.html?edit", server.url())).send()?;
assert_eq!(resp.status(), 200);
let editable = retrive_edit_file(&resp.text().unwrap()).unwrap();
assert!(editable);
Ok(())
}
#[rstest]
fn get_file_edit_bin(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"GET", format!("{}{BIN_FILE}?edit", server.url())).send()?;
assert_eq!(resp.status(), 200);
let editable = retrive_edit_file(&resp.text().unwrap()).unwrap();
assert!(!editable);
Ok(())
}
#[rstest] #[rstest]
fn head_file_404(server: TestServer) -> Result<(), Error> { fn head_file_404(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"HEAD", format!("{}404", server.url())).send()?; let resp = fetch!(b"HEAD", format!("{}404", server.url())).send()?;
@@ -154,9 +250,12 @@ fn options_dir(server: TestServer) -> Result<(), Error> {
assert_eq!(resp.status(), 200); assert_eq!(resp.status(), 200);
assert_eq!( assert_eq!(
resp.headers().get("allow").unwrap(), resp.headers().get("allow").unwrap(),
"GET,HEAD,PUT,OPTIONS,DELETE,PROPFIND,COPY,MOVE" "GET,HEAD,PUT,OPTIONS,DELETE,PATCH,PROPFIND,COPY,MOVE"
);
assert_eq!(
resp.headers().get("dav").unwrap(),
"1, 2, 3, sabredav-partialupdate"
); );
assert_eq!(resp.headers().get("dav").unwrap(), "1,2");
Ok(()) Ok(())
} }
@@ -182,7 +281,7 @@ fn put_file_create_dir(#[with(&["-A"])] server: TestServer) -> Result<(), Error>
#[rstest] #[rstest]
fn put_file_conflict_dir(#[with(&["-A"])] server: TestServer) -> Result<(), Error> { fn put_file_conflict_dir(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let url = format!("{}dira", server.url()); let url = format!("{}dir1", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?; let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 403); assert_eq!(resp.status(), 403);
Ok(()) Ok(())
@@ -204,3 +303,49 @@ fn delete_file_404(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
assert_eq!(resp.status(), 404); assert_eq!(resp.status(), 404);
Ok(()) 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(())
}
#[rstest]
fn resumable_upload(#[with(&["--allow-upload"])] 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(), 201);
let resp = fetch!(b"PATCH", &url)
.header("X-Update-Range", "append")
.body(b"123".to_vec())
.send()?;
assert_eq!(resp.status(), 204);
let resp = reqwest::blocking::get(url)?;
assert_eq!(resp.status(), 200);
assert_eq!(resp.text().unwrap(), "abc123");
Ok(())
}

View File

@@ -11,8 +11,8 @@ use std::io::Read;
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
#[rstest] #[rstest]
#[case(&["-a", "/@user:pass", "--log-format", "$remote_user"], false)] #[case(&["-a", "user:pass@/:rw", "--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"], true)]
fn log_remote_user( fn log_remote_user(
tmpdir: TempDir, tmpdir: TempDir,
port: u16, port: u16,
@@ -31,7 +31,7 @@ fn log_remote_user(
let stdout = child.stdout.as_mut().expect("Failed to get stdout"); let stdout = child.stdout.as_mut().expect("Failed to get stdout");
let req = fetch!(b"GET", &format!("http://localhost:{}", port)); let req = fetch!(b"GET", &format!("http://localhost:{port}"));
let resp = if is_basic { let resp = if is_basic {
req.basic_auth("user", Some("pass")).send()? req.basic_auth("user", Some("pass")).send()?
@@ -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])?;
@@ -66,13 +66,15 @@ fn no_log(tmpdir: TempDir, port: u16, #[case] args: &[&str]) -> Result<(), Error
let stdout = child.stdout.as_mut().expect("Failed to get stdout"); let stdout = child.stdout.as_mut().expect("Failed to get stdout");
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])?;
assert_eq!(output.lines().last().unwrap(), ""); assert_eq!(output.lines().last().unwrap(), "");
child.kill()?;
Ok(()) Ok(())
} }

View File

@@ -2,7 +2,7 @@ mod fixtures;
mod utils; mod utils;
use fixtures::{server, Error, TestServer}; use fixtures::{server, Error, TestServer};
use headers::HeaderValue; use reqwest::header::HeaderValue;
use rstest::rstest; use rstest::rstest;
#[rstest] #[rstest]
@@ -23,14 +23,10 @@ fn get_file_range_beyond(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"GET", format!("{}index.html", server.url())) let resp = fetch!(b"GET", format!("{}index.html", server.url()))
.header("range", HeaderValue::from_static("bytes=12-20")) .header("range", HeaderValue::from_static("bytes=12-20"))
.send()?; .send()?;
assert_eq!(resp.status(), 206); assert_eq!(resp.status(), 416);
assert_eq!( assert_eq!(resp.headers().get("content-range").unwrap(), "bytes */18");
resp.headers().get("content-range").unwrap(),
"bytes 12-17/18"
);
assert_eq!(resp.headers().get("accept-ranges").unwrap(), "bytes"); assert_eq!(resp.headers().get("accept-ranges").unwrap(), "bytes");
assert_eq!(resp.headers().get("content-length").unwrap(), "6"); assert_eq!(resp.headers().get("content-length").unwrap(), "0");
assert_eq!(resp.text()?, "x.html");
Ok(()) Ok(())
} }

View File

@@ -1,7 +1,7 @@
mod fixtures; mod fixtures;
mod utils; mod utils;
use fixtures::{server, Error, TestServer, DIR_NO_FOUND, DIR_NO_INDEX, FILES}; use fixtures::{server, Error, TestServer, BIN_FILE, DIR_NO_FOUND, DIR_NO_INDEX, FILES};
use rstest::rstest; use rstest::rstest;
#[rstest] #[rstest]
@@ -40,7 +40,9 @@ fn render_try_index2(#[with(&["--render-try-index"])] server: TestServer) -> Res
} }
#[rstest] #[rstest]
fn render_try_index3(#[with(&["--render-try-index"])] server: TestServer) -> Result<(), Error> { fn render_try_index3(
#[with(&["--render-try-index", "--allow-archive"])] server: TestServer,
) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}{}?zip", server.url(), DIR_NO_INDEX))?; let resp = reqwest::blocking::get(format!("{}{}?zip", server.url(), DIR_NO_INDEX))?;
assert_eq!(resp.status(), 200); assert_eq!(resp.status(), 200);
assert_eq!( assert_eq!(
@@ -54,11 +56,10 @@ fn render_try_index3(#[with(&["--render-try-index"])] server: TestServer) -> Res
#[case(server(&["--render-try-index"] as &[&str]), false)] #[case(server(&["--render-try-index"] as &[&str]), false)]
#[case(server(&["--render-try-index", "--allow-search"] as &[&str]), true)] #[case(server(&["--render-try-index", "--allow-search"] as &[&str]), true)]
fn render_try_index4(#[case] server: TestServer, #[case] searched: bool) -> Result<(), Error> { fn render_try_index4(#[case] server: TestServer, #[case] searched: bool) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}{}?q={}", server.url(), DIR_NO_INDEX, "😀.bin"))?; let resp = reqwest::blocking::get(format!("{}{}?q={}", server.url(), DIR_NO_INDEX, BIN_FILE))?;
assert_eq!(resp.status(), 200); assert_eq!(resp.status(), 200);
let paths = utils::retrieve_index_paths(&resp.text()?); let paths = utils::retrieve_index_paths(&resp.text()?);
assert!(!paths.is_empty()); assert_eq!(paths.iter().all(|v| v.contains(BIN_FILE)), searched);
assert_eq!(paths.iter().all(|v| v.contains("😀.bin")), searched);
Ok(()) Ok(())
} }

View File

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

View File

@@ -7,9 +7,9 @@ use rstest::rstest;
#[rstest] #[rstest]
fn ls_dir_sort_by_name(server: TestServer) -> Result<(), Error> { fn ls_dir_sort_by_name(server: TestServer) -> Result<(), Error> {
let url = server.url(); let url = server.url();
let resp = reqwest::blocking::get(format!("{}?sort=name&order=asc", url))?; let resp = reqwest::blocking::get(format!("{url}?sort=name&order=asc"))?;
let paths1 = self::utils::retrieve_index_paths(&resp.text()?); let paths1 = self::utils::retrieve_index_paths(&resp.text()?);
let resp = reqwest::blocking::get(format!("{}?sort=name&order=desc", url))?; let resp = reqwest::blocking::get(format!("{url}?sort=name&order=desc"))?;
let mut paths2 = self::utils::retrieve_index_paths(&resp.text()?); let mut paths2 = self::utils::retrieve_index_paths(&resp.text()?);
paths2.reverse(); paths2.reverse();
assert_eq!(paths1, paths2); assert_eq!(paths1, paths2);
@@ -19,9 +19,9 @@ fn ls_dir_sort_by_name(server: TestServer) -> Result<(), Error> {
#[rstest] #[rstest]
fn search_dir_sort_by_name(server: TestServer) -> Result<(), Error> { fn search_dir_sort_by_name(server: TestServer) -> Result<(), Error> {
let url = server.url(); let url = server.url();
let resp = reqwest::blocking::get(format!("{}?q={}&sort=name&order=asc", url, "test.html"))?; let resp = reqwest::blocking::get(format!("{url}?q=test.html&sort=name&order=asc"))?;
let paths1 = self::utils::retrieve_index_paths(&resp.text()?); let paths1 = self::utils::retrieve_index_paths(&resp.text()?);
let resp = reqwest::blocking::get(format!("{}?q={}&sort=name&order=desc", url, "test.html"))?; let resp = reqwest::blocking::get(format!("{url}?q=test.html&sort=name&order=desc"))?;
let mut paths2 = self::utils::retrieve_index_paths(&resp.text()?); let mut paths2 = self::utils::retrieve_index_paths(&resp.text()?);
paths2.reverse(); paths2.reverse();
assert_eq!(paths1, paths2); assert_eq!(paths1, paths2);

View File

@@ -22,7 +22,7 @@ fn default_not_allow_symlink(server: TestServer, tmpdir: TempDir) -> Result<(),
let resp = reqwest::blocking::get(server.url())?; let resp = reqwest::blocking::get(server.url())?;
let paths = utils::retrieve_index_paths(&resp.text()?); let paths = utils::retrieve_index_paths(&resp.text()?);
assert!(!paths.is_empty()); assert!(!paths.is_empty());
assert!(!paths.contains(&format!("{}/", dir))); assert!(!paths.contains(&format!("{dir}/")));
Ok(()) Ok(())
} }
@@ -41,6 +41,6 @@ fn allow_symlink(
let resp = reqwest::blocking::get(server.url())?; let resp = reqwest::blocking::get(server.url())?;
let paths = utils::retrieve_index_paths(&resp.text()?); let paths = utils::retrieve_index_paths(&resp.text()?);
assert!(!paths.is_empty()); assert!(!paths.is_empty());
assert!(paths.contains(&format!("{}/", dir))); assert!(paths.contains(&format!("{dir}/")));
Ok(()) Ok(())
} }

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,11 +35,19 @@ 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("error: Failed to access `wrong`")); .stderr(contains("Failed to access `wrong`"));
Ok(()) Ok(())
} }
@@ -45,11 +55,19 @@ 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("error: Failed to access `wrong`")); .stderr(contains("Failed to access `wrong`"));
Ok(()) Ok(())
} }

View File

@@ -20,13 +20,37 @@ macro_rules! assert_resp_paths {
#[macro_export] #[macro_export]
macro_rules! fetch { macro_rules! fetch {
($method:literal, $url:expr) => { ($method:literal, $url:expr) => {
reqwest::blocking::Client::new().request(hyper::Method::from_bytes($method)?, $url) reqwest::blocking::Client::new().request(reqwest::Method::from_bytes($method)?, $url)
}; };
} }
#[allow(dead_code)] #[allow(dead_code)]
pub fn retrieve_index_paths(index: &str) -> IndexSet<String> { pub fn retrieve_index_paths(content: &str) -> IndexSet<String> {
retrieve_index_paths_impl(index).unwrap_or_default() let value = retrive_json(content).unwrap();
let paths = value
.get("paths")
.unwrap()
.as_array()
.unwrap()
.iter()
.flat_map(|v| {
let name = v.get("name")?.as_str()?;
let path_type = v.get("path_type")?.as_str()?;
if path_type.ends_with("Dir") {
Some(format!("{name}/"))
} else {
Some(name.to_owned())
}
})
.collect();
paths
}
#[allow(dead_code)]
pub fn retrive_edit_file(content: &str) -> Option<bool> {
let value = retrive_json(content)?;
let value = value.get("editable").unwrap();
Some(value.as_bool().unwrap())
} }
#[allow(dead_code)] #[allow(dead_code)]
@@ -35,24 +59,11 @@ pub fn encode_uri(v: &str) -> String {
parts.join("/") parts.join("/")
} }
fn retrieve_index_paths_impl(index: &str) -> Option<IndexSet<String>> { #[allow(dead_code)]
let lines: Vec<&str> = index.lines().collect(); 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 = lines.iter().find(|v| v.contains("DATA ="))?;
let line_col = line.find("DATA =").unwrap() + 6; let line_col = line.find("DATA =").unwrap() + 6;
let value: Value = line[line_col..].parse().ok()?; let value: Value = line[line_col..].parse().unwrap();
let paths = value Some(value)
.get("paths")?
.as_array()?
.iter()
.flat_map(|v| {
let name = v.get("name")?.as_str()?;
let path_type = v.get("path_type")?.as_str()?;
if path_type.ends_with("Dir") {
Some(format!("{}/", name))
} else {
Some(name.to_owned())
}
})
.collect();
Some(paths)
} }

View File

@@ -7,13 +7,13 @@ use xml::escape::escape_str_pcdata;
#[rstest] #[rstest]
fn propfind_dir(server: TestServer) -> Result<(), Error> { fn propfind_dir(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"PROPFIND", format!("{}dira", server.url())).send()?; let resp = fetch!(b"PROPFIND", format!("{}dir1", server.url())).send()?;
assert_eq!(resp.status(), 207); assert_eq!(resp.status(), 207);
let body = resp.text()?; let body = resp.text()?;
assert!(body.contains("<D:href>/dira/</D:href>")); assert!(body.contains("<D:href>/dir1/</D:href>"));
assert!(body.contains("<D:displayname>dira</D:displayname>")); assert!(body.contains("<D:displayname>dir1</D:displayname>"));
for f in FILES { for f in FILES {
assert!(body.contains(&format!("<D:href>/dira/{}</D:href>", utils::encode_uri(f)))); assert!(body.contains(&format!("<D:href>/dir1/{}</D:href>", utils::encode_uri(f))));
assert!(body.contains(&format!( assert!(body.contains(&format!(
"<D:displayname>{}</D:displayname>", "<D:displayname>{}</D:displayname>",
escape_str_pcdata(f) escape_str_pcdata(f)
@@ -24,13 +24,13 @@ fn propfind_dir(server: TestServer) -> Result<(), Error> {
#[rstest] #[rstest]
fn propfind_dir_depth0(server: TestServer) -> Result<(), Error> { fn propfind_dir_depth0(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"PROPFIND", format!("{}dira", server.url())) let resp = fetch!(b"PROPFIND", format!("{}dir1", server.url()))
.header("depth", "0") .header("depth", "0")
.send()?; .send()?;
assert_eq!(resp.status(), 207); assert_eq!(resp.status(), 207);
let body = resp.text()?; let body = resp.text()?;
assert!(body.contains("<D:href>/dira/</D:href>")); assert!(body.contains("<D:href>/dir1/</D:href>"));
assert!(body.contains("<D:displayname>dira</D:displayname>")); assert!(body.contains("<D:displayname>dir1</D:displayname>"));
assert_eq!( assert_eq!(
body.lines() body.lines()
.filter(|v| *v == "<D:status>HTTP/1.1 200 OK</D:status>") .filter(|v| *v == "<D:status>HTTP/1.1 200 OK</D:status>")
@@ -102,7 +102,7 @@ fn mkcol_not_allow_upload(server: TestServer) -> Result<(), Error> {
#[rstest] #[rstest]
fn mkcol_already_exists(#[with(&["-A"])] server: TestServer) -> Result<(), Error> { fn mkcol_already_exists(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"MKCOL", format!("{}dira", server.url())).send()?; let resp = fetch!(b"MKCOL", format!("{}dir1", server.url())).send()?;
assert_eq!(resp.status(), 405); assert_eq!(resp.status(), 405);
Ok(()) Ok(())
} }