Compare commits

..

69 Commits

Author SHA1 Message Date
sigoden
936d08545b chore(release): version v0.23.1 2022-07-01 06:47:34 +08:00
sigoden
2e6af671ca fix: permissions of unzipped files (#84) 2022-06-30 19:29:47 +08:00
sigoden
583117c01f fix: safari layout and compatibility (#83) 2022-06-30 10:00:42 +08:00
sigoden
6e1df040b4 chore: update deps 2022-06-29 20:36:18 +08:00
sigoden
f5aa3354e1 chore: add github issule templates 2022-06-29 15:16:04 +08:00
sigoden
3ed0d885fe chore(release): version v0.23.0 2022-06-29 11:01:40 +08:00
sigoden
542e9a4ec5 chore: remove aarch64-linux-android platform 2022-06-29 10:58:43 +08:00
sigoden
5ee2c5504c ci: support more platforms (#76) 2022-06-29 10:51:59 +08:00
sigoden
fd02a53823 chore: replace old get-if-addrs with new if-addrs (#78) 2022-06-29 10:01:01 +08:00
sigoden
6554c1c308 feat: use feature to conditional support tls (#77) 2022-06-29 09:19:09 +08:00
sigoden
fe71600bd2 chore(release): version v0.22.0 2022-06-26 12:43:20 +08:00
sigoden
9cfeee0df0 chore: update args help message and readme 2022-06-25 09:58:39 +08:00
sigoden
eb7a536a3f feat: support hiding folders with --hidden (#73) 2022-06-25 08:15:16 +08:00
sigoden
c1c6dbc356 chore(release): version v0.21.0 2022-06-23 19:34:38 +08:00
sigoden
e29cf4c752 refactor: split css/js from index.html (#68) 2022-06-21 23:01:00 +08:00
sigoden
7f062b6705 feat: use custom logger with timestamp in rfc3339 (#67) 2022-06-21 21:19:51 +08:00
sigoden
ea8b9e9cce fix: escape name contains html escape code (#65) 2022-06-21 19:23:34 +08:00
sigoden
d2270be8fb chore: update changelog 2022-06-21 07:56:24 +08:00
sigoden
2d0dfed456 chore(release): version v0.20.0 2022-06-21 07:52:45 +08:00
sigoden
4058a2db72 feat: add option --allow-search (#62) 2022-06-21 07:23:20 +08:00
sigoden
069cb64889 fix: decodeURI searching string (#61) 2022-06-20 21:51:41 +08:00
sigoden
c85ea06785 chore: update cli help message and reamde 2022-06-20 19:40:09 +08:00
sigoden
68139c6263 chore: little improves 2022-06-20 15:11:39 +08:00
Joe Koop
deb6365a28 feat: added basic auth (#60)
* some small css fixes and changes

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

* most tests are passing

* fixed all the tests

* maybe now CI will pass

* implemented sigoden's suggestions

* test basic auth

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

BREAKING CHANGE: rename duf to dufs
2022-06-19 22:53:51 +08:00
sigoden
3260b52c47 chore: fix breadcrumb 2022-06-19 22:22:49 +08:00
sigoden
7194ebf248 chore: adjust ui 2022-06-19 22:16:43 +08:00
Joe Koop
b1b0fdd4db feat: reactive webpage (#51) 2022-06-19 22:04:59 +08:00
sigoden
db71f75236 feat: ui hidden root dirname (#58)
close #56
2022-06-19 21:23:19 +08:00
sigoden
e66951fd11 refactor: rename --cors to --enable-cors (#57)
BREAKING CHANGE: `--cors` rename to `--enable-cors`
2022-06-19 17:27:09 +08:00
sigoden
051ff8da2d chore: update readme 2022-06-19 15:30:42 +08:00
sigoden
c3ac2a21c9 feat: serve single file (#54)
close #53
2022-06-19 14:23:10 +08:00
sigoden
9c2e9d1503 feat: path level access control (#52)
BREAKING CHANGE: `--auth` is changed, `--no-auth-access` is removed
2022-06-19 11:26:03 +08:00
sigoden
9384cc8587 chore(release): version v0.18.0 2022-06-18 08:05:18 +08:00
sigoden
df48021757 chore: send not found text when 404 2022-06-18 08:03:48 +08:00
sigoden
af866aaaf4 chore: optimize --render-try-index 2022-06-17 19:05:25 +08:00
sigoden
68d238d34d feat: add slash to end of dir href 2022-06-17 19:01:17 +08:00
sigoden
a10150f2f8 chore: update readme 2022-06-17 10:59:19 +08:00
sigoden
5b11bb75dd feat: add option --render-try-index (#47)
close #46
2022-06-17 08:41:01 +08:00
sigoden
6d7da0363c chore(release): version v0.17.1 2022-06-16 10:31:47 +08:00
sigoden
d8f7335053 fix: range request (#44)
close #43
2022-06-16 10:24:32 +08:00
sigoden
7fc8fc6236 chore(release): version v0.17.0 2022-06-15 20:31:53 +08:00
sigoden
c7d42a3f1c fix: webdav propfind dir with slash (#42) 2022-06-15 20:24:53 +08:00
sigoden
3c4bb77023 refactor: trival changes (#41)
- refactor status code
- log remote addr and time in miliseconds
2022-06-15 19:57:28 +08:00
sigoden
12aafa00d8 feat: listen both ipv4 and ipv6 by default (#40) 2022-06-15 19:33:51 +08:00
sigoden
5142430e93 chore(release): version v0.16.0 2022-06-12 13:02:36 +08:00
sigoden
dd8b21f3a6 feat: options method return status 200 2022-06-12 09:33:01 +08:00
sigoden
471bca86c6 test: add integration tests (#36) 2022-06-12 08:43:50 +08:00
sigoden
6b01c143d9 feat: support tls-key in pkcs#8 format (#35) 2022-06-11 15:26:08 +08:00
sigoden
127c90a45e chore: update cargo dependencies version 2022-06-11 14:11:36 +08:00
sigoden
b3890ea094 feat: display upload speed and time left (#34) 2022-06-11 12:34:03 +08:00
sigoden
99f0de6ca0 feat: implement head method (#33) 2022-06-11 10:18:07 +08:00
sigoden
b48a7473fc chore(release): version v0.15.1 2022-06-11 08:52:22 +08:00
sigoden
a84c3b353d fix: cannot upload (#32)
empty folder got js error

close: #31
2022-06-11 08:51:17 +08:00
sigoden
e9383d71ed chore(release): version v0.15.0 2022-06-10 08:41:51 +08:00
sigoden
8258dabe4a fix: query dir param 2022-06-10 08:00:27 +08:00
Joe Koop
0e236b61f6 feat: add empty state placeholder to page(#30)
* added "Empty folder" text to the page

* added text for nonexistent directory and no search results
2022-06-10 07:41:09 +08:00
sigoden
09788ed031 chore: update favicon 2022-06-09 22:49:01 +08:00
Joe Koop
46ebe978ae feat: add basic dark theme (#29) 2022-06-09 22:16:43 +08:00
sigoden
e01f2030e1 chore: optimize code 2022-06-09 21:35:52 +08:00
Joe Koop
8d03ec151a fix: encode webdav href as uri (#28)
* Revert "fix: filename xml escaping"

This reverts commit ce154d9ebc.

* webdav filenames are fixed
2022-06-09 21:28:35 +08:00
sigoden
870e92e306 chore(release): version v0.14.0 2022-06-07 09:02:43 +08:00
sigoden
261c8b6ee5 feat: add favicon (#27)
Return favicon only if requested, avoid 404 errors

close #16
2022-06-07 08:59:44 +08:00
sigoden
5ce7bde05c fix: send index page with content-type (#26) 2022-06-06 11:20:42 +08:00
sigoden
63a7b530bb feat: support ipv6 (#25) 2022-06-06 10:52:12 +08:00
sigoden
7481db5071 chore(release): version v0.13.2 2022-06-06 08:03:00 +08:00
sigoden
b0cc901416 fix: escape path-prefix/url-prefix different 2022-06-06 08:00:26 +08:00
Joe Koop
ce154d9ebc fix: filename xml escaping 2022-06-06 07:54:12 +08:00
40 changed files with 4627 additions and 886 deletions

17
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,17 @@
---
name: Bug report
about: Create a report to help us improve
---
**Problem**
<!-- A clear and concise description of what the bug is. -->
**Log**
If applicable, add logs to help explain your problem.
**Environment:**
- Dufs version:
- Browser/Webdav Info:
- OS Info:

View File

@@ -0,0 +1,16 @@
---
name: Feature Request
about: If you have any interesting advice, you can tell us.
---
## Specific Demand
<!--
What feature do you need, please describe it in detail.
-->
## Implement Suggestion
<!--
If you have any suggestion for complete this feature, you can tell us.
-->

6
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "cargo" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "monthly"

View File

@@ -7,33 +7,67 @@ on:
jobs: jobs:
release: release:
name: Publish to Github Reelases name: Publish to Github Relases
outputs: outputs:
rc: ${{ steps.check-tag.outputs.rc }} rc: ${{ steps.check-tag.outputs.rc }}
strategy: strategy:
matrix: matrix:
target:
- aarch64-unknown-linux-musl
- aarch64-apple-darwin
- x86_64-apple-darwin
- x86_64-pc-windows-msvc
- x86_64-unknown-linux-musl
include: include:
- target: aarch64-unknown-linux-musl - target: aarch64-unknown-linux-musl
os: ubuntu-latest os: ubuntu-latest
use-cross: true use-cross: true
cargo-flags: ""
- target: aarch64-apple-darwin - target: aarch64-apple-darwin
os: macos-latest os: macos-latest
use-cross: true use-cross: true
cargo-flags: ""
- target: aarch64-pc-windows-msvc
os: windows-latest
use-cross: true
cargo-flags: "--no-default-features"
- target: x86_64-apple-darwin - target: x86_64-apple-darwin
os: macos-latest os: macos-latest
cargo-flags: ""
- target: x86_64-pc-windows-msvc - target: x86_64-pc-windows-msvc
os: windows-latest os: windows-latest
cargo-flags: ""
- target: x86_64-unknown-linux-musl - target: x86_64-unknown-linux-musl
os: ubuntu-latest os: ubuntu-latest
use-cross: true use-cross: true
cargo-flags: ""
- target: i686-unknown-linux-musl
os: ubuntu-latest
use-cross: true
cargo-flags: ""
- target: i686-pc-windows-msvc
os: windows-latest
use-cross: true
cargo-flags: ""
- target: armv7-unknown-linux-musleabihf
os: ubuntu-latest
use-cross: true
cargo-flags: ""
- target: arm-unknown-linux-musleabihf
os: ubuntu-latest
use-cross: true
cargo-flags: ""
- target: mips-unknown-linux-musl
os: ubuntu-latest
use-cross: true
cargo-flags: "--no-default-features"
- target: mipsel-unknown-linux-musl
os: ubuntu-latest
use-cross: true
cargo-flags: "--no-default-features"
- target: mips64-unknown-linux-gnuabi64
os: ubuntu-latest
use-cross: true
cargo-flags: "--no-default-features"
- target: mips64el-unknown-linux-gnuabi64
os: ubuntu-latest
use-cross: true
cargo-flags: "--no-default-features"
runs-on: ${{matrix.os}} runs-on: ${{matrix.os}}
steps: steps:
@@ -60,13 +94,6 @@ jobs:
toolchain: stable toolchain: stable
profile: minimal # minimal component installation (ie, no documentation) profile: minimal # minimal component installation (ie, no documentation)
- name: Install prerequisites
shell: bash
run: |
case ${{ matrix.target }} in
aarch64-unknown-linux-musl) sudo apt-get -y update ; sudo apt-get -y install gcc-aarch64-linux-gnu ;;
esac
- name: Show Version Information (Rust, cargo, GCC) - name: Show Version Information (Rust, cargo, GCC)
shell: bash shell: bash
run: | run: |
@@ -82,7 +109,7 @@ jobs:
with: with:
use-cross: ${{ matrix.use-cross }} use-cross: ${{ matrix.use-cross }}
command: build command: build
args: --locked --release --target=${{ matrix.target }} args: --locked --release --target=${{ matrix.target }} ${{ matrix.cargo-flags }}
- name: Build Archive - name: Build Archive
shell: bash shell: bash
@@ -133,6 +160,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: release needs: release
steps: steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v1
- name: Login to DockerHub - name: Login to DockerHub
@@ -141,11 +170,18 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push - name: Build and push
id: docker_build
uses: docker/build-push-action@v2 uses: docker/build-push-action@v2
with: with:
build-args: |
REPO=${{ github.repository }}
VER=${{ github.ref_name }}
platforms: |
linux/amd64
linux/arm64
linux/386
linux/arm/v7
push: ${{ needs.release.outputs.rc == 'false' }} push: ${{ needs.release.outputs.rc == 'false' }}
tags: sigoden/duf:latest, sigoden/duf:${{ github.ref_name }} tags: ${{ github.repository }}:latest, ${{ github.repository }}:${{ github.ref_name }}
publish-crate: publish-crate:
name: Publish to crates.io name: Publish to crates.io

View File

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

1451
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,42 +1,59 @@
[package] [package]
name = "duf" name = "dufs"
version = "0.13.1" version = "0.23.1"
edition = "2021" edition = "2021"
authors = ["sigoden <sigoden@gmail.com>"] authors = ["sigoden <sigoden@gmail.com>"]
description = "Duf is a simple file server." description = "Dufs is a distinctive utility file server"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
homepage = "https://github.com/sigoden/duf" homepage = "https://github.com/sigoden/dufs"
repository = "https://github.com/sigoden/duf" repository = "https://github.com/sigoden/dufs"
autotests = false
categories = ["command-line-utilities", "web-programming::http-server"] categories = ["command-line-utilities", "web-programming::http-server"]
keywords = ["static", "file", "server", "webdav", "cli"] keywords = ["static", "file", "server", "webdav", "cli"]
[dependencies] [dependencies]
clap = { version = "3", default-features = false, features = ["std", "cargo"] } clap = { version = "3", default-features = false, features = ["std", "wrap_help"] }
chrono = "0.4" chrono = "0.4"
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-rustls = "0.23" tokio-util = { version = "0.7", features = ["io-util"] }
tokio-stream = { version = "0.1", features = ["net"] }
tokio-util = { version = "0.7", features = ["codec", "io-util"] }
hyper = { version = "0.14", features = ["http1", "server", "tcp", "stream"] } hyper = { version = "0.14", features = ["http1", "server", "tcp", "stream"] }
percent-encoding = "2.1" percent-encoding = "2.1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
futures = "0.3" futures = "0.3"
base64 = "0.13" base64 = "0.13"
async_zip = "0.0.7" async_zip = { git = "https://github.com/sigoden/rs-async-zip", branch = "patch01" }
async-walkdir = "0.2.0" async-walkdir = "0.2"
headers = "0.3.7" headers = "0.3"
mime_guess = "2.0.4" mime_guess = "2.0"
get_if_addrs = "0.5.3" if-addrs = "0.7"
rustls = { version = "0.20", default-features = false, features = ["tls12"] } rustls = { version = "0.20", default-features = false, features = ["tls12"], optional = true }
rustls-pemfile = "1" rustls-pemfile = { version = "1", optional = true }
md5 = "0.7.0" tokio-rustls = { version = "0.23", optional = true }
lazy_static = "1.4.0" md5 = "0.7"
uuid = { version = "1.1.1", features = ["v4", "fast-rng"] } lazy_static = "1.4"
urlencoding = "2.1.0" uuid = { version = "1.1", features = ["v4", "fast-rng"] }
env_logger = { version = "0.9.0", default-features = false, features = ["humantime"] } urlencoding = "2.1"
log = "0.4.17" xml-rs = "0.8"
log = "0.4"
socket2 = "0.4"
async-stream = "0.3"
[features]
default = ["tls"]
tls = ["rustls", "rustls-pemfile", "tokio-rustls"]
[dev-dependencies]
assert_cmd = "2"
reqwest = { version = "0.11", features = ["blocking", "multipart", "rustls-tls"], default-features = false }
assert_fs = "1"
select = "0.5"
port_check = "0.1"
rstest = "0.15"
regex = "1"
pretty_assertions = "1.2"
url = "2"
diqwest = { version = "1", features = ["blocking"] }
predicates = "2"
[profile.release] [profile.release]
lto = true lto = true

View File

@@ -1,10 +1,18 @@
FROM rust:1.61 as builder FROM alpine as builder
RUN rustup target add x86_64-unknown-linux-musl ARG REPO VER TARGETPLATFORM
RUN apt-get update && apt-get install --no-install-recommends -y musl-tools RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
WORKDIR /app TARGET="x86_64-unknown-linux-musl"; \
COPY . . elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
RUN cargo build --target x86_64-unknown-linux-musl --release TARGET="aarch64-unknown-linux-musl"; \
elif [ "$TARGETPLATFORM" = "linux/386" ]; then \
TARGET="i686-unknown-linux-musl"; \
elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \
TARGET="armv7-unknown-linux-musleabihf"; \
fi && \
wget https://github.com/${REPO}/releases/download/${VER}/dufs-${VER}-${TARGET}.tar.gz && \
tar -xf dufs-${VER}-${TARGET}.tar.gz && \
mv dufs /bin/
FROM scratch FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/duf /bin/ COPY --from=builder /bin/dufs /bin/dufs
ENTRYPOINT ["/bin/duf"] ENTRYPOINT ["/bin/dufs"]

152
README.md
View File

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

BIN
assets/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@@ -1,15 +1,16 @@
html { html {
font-family: -apple-system,BlinkMacSystemFont,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;
} }
body { body {
width: 700px; /* prevent premature breadcrumb wrapping on mobile */
min-width: 500px;
} }
.hidden { .hidden {
display: none; display: none !important;
} }
.head { .head {
@@ -21,6 +22,7 @@ body {
.breadcrumb { .breadcrumb {
font-size: 1.25em; font-size: 1.25em;
padding-right: 0.6em;
} }
.breadcrumb > a { .breadcrumb > a {
@@ -45,12 +47,16 @@ body {
.breadcrumb svg { .breadcrumb svg {
height: 100%; height: 100%;
fill: rgba(3,47,98,0.5); fill: rgba(3,47,98,0.5);
padding-right: 0.5em;
padding-left: 0.5em;
} }
.toolbox { .toolbox {
display: flex; display: flex;
margin-right: 10px;
}
.toolbox > div {
/* vertically align with breadcrumb text */
height: 1.1rem;
} }
.searchbar { .searchbar {
@@ -62,7 +68,7 @@ body {
transition: all .15s; transition: all .15s;
border: 1px #ddd solid; border: 1px #ddd solid;
border-radius: 15px; border-radius: 15px;
margin: 0 0 2px 10px; margin-bottom: 2px;
} }
.searchbar #search { .searchbar #search {
@@ -93,10 +99,20 @@ body {
display: none; display: none;
} }
.upload-status span {
width: 70px;
display: inline-block;
}
.main { .main {
padding: 0 1em; padding: 0 1em;
} }
.empty-folder {
padding-top: 1rem;
font-style: italic;
}
.uploaders-table th, .uploaders-table th,
.paths-table th { .paths-table th {
text-align: left; text-align: left;
@@ -110,11 +126,6 @@ body {
white-space: nowrap; white-space: nowrap;
} }
.uploaders-table .cell-name,
.paths-table .cell-name {
width: 500px;
}
.uploaders-table .cell-status { .uploaders-table .cell-status {
width: 80px; width: 80px;
padding-left: 0.6em; padding-left: 0.6em;
@@ -137,15 +148,14 @@ body {
padding-left: 0.6em; padding-left: 0.6em;
} }
.path svg { .path svg {
height: 100%; 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;
} }
.path { .path {
display: flex;
list-style: none; list-style: none;
} }
@@ -156,6 +166,8 @@ body {
overflow: hidden; overflow: hidden;
display: block; display: block;
text-decoration: none; text-decoration: none;
max-width: calc(100vw - 375px);
min-width: 200px;
} }
.path a:hover { .path a:hover {
@@ -173,3 +185,46 @@ body {
.uploader { .uploader {
padding-right: 1em; padding-right: 1em;
} }
@media (min-width: 768px) {
.path a {
min-width: 400px;
}
}
/* dark theme */
@media (prefers-color-scheme: dark) {
body {
background-color: #000;
}
html,
.breadcrumb>b,
.searchbar #search {
color: #fff;
}
.uploaders-table th,
.paths-table th {
color: #ddd;
}
svg,
.path svg,
.breadcrumb svg {
fill: #fff;
}
.searchbar {
background-color: #111;
border-color: #fff6;
}
.searchbar svg {
fill: #fff6;
}
.path a {
color: #3191ff;
}
}

View File

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

View File

@@ -1,41 +1,48 @@
/** /**
* @typedef {object} PathItem * @typedef {object} PathItem
* @property {"Dir"|"SymlinkDir"|"File"|"SymlinkFile"} path_type * @property {"Dir"|"SymlinkDir"|"File"|"SymlinkFile"} path_type
* @property {boolean} is_symlink
* @property {string} name * @property {string} name
* @property {number} mtime * @property {number} mtime
* @property {number} size * @property {number} size
*/ */
// https://stackoverflow.com/a/901144/3642588
const params = new Proxy(new URLSearchParams(window.location.search), {
get: (searchParams, prop) => searchParams.get(prop),
});
const dirEmptyNote = params.q ? 'No results' : DATA.dir_exists ? 'Empty folder' : 'Folder will be created when a file is uploaded';
/** /**
* @type Element * @type Element
*/ */
let $pathsTable, $pathsTableBody, $uploadersTable; let $pathsTable;
/** /**
* @type string * @type Element
*/ */
let baseDir; let $pathsTableBody;
/**
* @type Element
*/
let $uploadersTable;
/**
* @type Element
*/
let $emptyFolder;
class Uploader { class Uploader {
/** /**
* @type number *
* @param {File} file
* @param {string[]} dirs
*/ */
idx;
/**
* @type File
*/
file;
/**
* @type string
*/
name;
/**
* @type Element
*/
$uploadStatus;
static globalIdx = 0;
constructor(file, dirs) { constructor(file, dirs) {
/**
* @type Element
*/
this.$uploadStatus = null
this.uploaded = 0;
this.lastUptime = 0;
this.name = [...dirs, file.name].join("/"); this.name = [...dirs, file.name].join("/");
this.idx = Uploader.globalIdx++; this.idx = Uploader.globalIdx++;
this.file = file; this.file = file;
@@ -43,17 +50,23 @@ class Uploader {
upload() { upload() {
const { file, idx, name } = this; const { file, idx, name } = this;
let url = getUrl(name); const url = getUrl(name);
const encodedUrl = encodedStr(url);
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-name"> <td class="path cell-icon">
<div>${getSvg("File")}</div> ${getSvg()}
<a href="${url}">${name}</a>
</td> </td>
<td class="cell-status" id="uploadStatus${idx}"></td> <td class="path cell-name">
<a href="${encodedUrl}">${encodedName}</a>
</td>
<td class="cell-status upload-status" id="uploadStatus${idx}"></td>
</tr>`); </tr>`);
$uploadersTable.classList.remove("hidden"); $uploadersTable.classList.remove("hidden");
$emptyFolder.classList.add("hidden");
this.$uploadStatus = document.getElementById(`uploadStatus${idx}`); this.$uploadStatus = document.getElementById(`uploadStatus${idx}`);
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);
@@ -73,8 +86,15 @@ class Uploader {
} }
progress(event) { progress(event) {
const percent = (event.loaded / event.total) * 100; let now = Date.now();
this.$uploadStatus.innerHTML = `${percent.toFixed(2)}%`; let speed = (event.loaded - this.uploaded) / (now - this.lastUptime) * 1000;
let [speedValue, speedUnit] = formatSize(speed);
const speedText = `${speedValue}${speedUnit.toLowerCase()}/s`;
const progress = formatPercent((event.loaded / event.total) * 100);
const duration = formatDuration((event.total - event.loaded) / speed)
this.$uploadStatus.innerHTML = `<span>${speedText}</span><span>${progress}</span><span>${duration}</span>`;
this.uploaded = event.loaded;
this.lastUptime = now;
} }
complete() { complete() {
@@ -86,29 +106,43 @@ class Uploader {
} }
} }
Uploader.globalIdx = 0;
/** /**
* Add breadcrumb * Add breadcrumb
* @param {string} value * @param {string} href
* @param {string} uri_prefix
*/ */
function addBreadcrumb(value) { function addBreadcrumb(href, uri_prefix) {
const $breadcrumb = document.querySelector(".breadcrumb"); const $breadcrumb = document.querySelector(".breadcrumb");
const parts = value.split("/").filter(v => !!v); let parts = [];
if (href === "/") {
parts = [""];
} else {
parts = href.split("/");
}
const len = parts.length; const len = parts.length;
let path = ""; let path = uri_prefix;
for (let i = 0; i < len; i++) { for (let i = 0; i < len; i++) {
const name = parts[i]; const name = parts[i];
if (i > 0) { if (i > 0) {
path += "/" + name; if (!path.endsWith("/")) {
path += "/";
}
path += encodeURI(name);
} }
if (i === len - 1) { const encodedPath = encodedStr(path);
$breadcrumb.insertAdjacentHTML("beforeend", `<b>${name}</b>`); const encodedName = encodedStr(name);
baseDir = name; if (i === 0) {
} else if (i === 0) { $breadcrumb.insertAdjacentHTML("beforeend", `<a href="${encodedPath}"><svg width="16" height="16" viewBox="0 0 16 16"><path d="M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5z"/></svg></a>`);
$breadcrumb.insertAdjacentHTML("beforeend", `<a href="/"><b>${name}</b></a>`); } else if (i === len - 1) {
$breadcrumb.insertAdjacentHTML("beforeend", `<b>${encodedName}</b>`);
} else { } else {
$breadcrumb.insertAdjacentHTML("beforeend", `<a href="${encodeURI(path)}">${name}</a>`); $breadcrumb.insertAdjacentHTML("beforeend", `<a href="${encodedPath}">${encodedName}</a>`);
}
if (i !== len - 1) {
$breadcrumb.insertAdjacentHTML("beforeend", `<span class="separator">/</span>`);
} }
$breadcrumb.insertAdjacentHTML("beforeend", `<span class="separator">/</span>`);
} }
} }
@@ -118,27 +152,31 @@ function addBreadcrumb(value) {
* @param {number} index * @param {number} index
*/ */
function addPath(file, index) { function addPath(file, index) {
const url = getUrl(file.name) const encodedName = encodedStr(file.name);
let url = getUrl(file.name)
let encodedUrl = encodedStr(url);
let actionDelete = ""; let actionDelete = "";
let actionDownload = ""; let actionDownload = "";
if (file.path_type.endsWith("Dir")) { if (file.path_type.endsWith("Dir")) {
url += "/";
encodedUrl += "/";
actionDownload = ` actionDownload = `
<div class="action-btn"> <div class="action-btn">
<a href="${url}?zip" title="Download folder as a .zip file"> <a href="${encodedUrl}?zip" title="Download folder as a .zip file">
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg> <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> </a>
</div>`; </div>`;
} else { } else {
actionDownload = ` actionDownload = `
<div class="action-btn" > <div class="action-btn" >
<a href="${url}" title="Download file" download> <a href="${encodedUrl}" title="Download file" download>
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg> <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> </a>
</div>`; </div>`;
} }
if (DATA.allow_delete) { if (DATA.allow_delete) {
actionDelete = ` actionDelete = `
<div onclick="deletePath(${index})" class="action-btn" id="deleteBtn${index}" title="Delete ${file.name}"> <div onclick="deletePath(${index})" class="action-btn" id="deleteBtn${index}" title="Delete ${encodedName}">
<svg width="16" height="16" fill="currentColor"viewBox="0 0 16 16"><path d="M6.854 7.146a.5.5 0 1 0-.708.708L7.293 9l-1.147 1.146a.5.5 0 0 0 .708.708L8 9.707l1.146 1.147a.5.5 0 0 0 .708-.708L8.707 9l1.147-1.146a.5.5 0 0 0-.708-.708L8 8.293 6.854 7.146z"/><path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5v2z"/></svg> <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>`; </div>`;
} }
@@ -150,12 +188,14 @@ function addPath(file, index) {
$pathsTableBody.insertAdjacentHTML("beforeend", ` $pathsTableBody.insertAdjacentHTML("beforeend", `
<tr id="addPath${index}"> <tr id="addPath${index}">
<td class="path cell-icon">
${getSvg(file.path_type)}
</td>
<td class="path cell-name"> <td class="path cell-name">
<div>${getSvg(file.path_type)}</div> <a href="${encodedUrl}">${encodedName}</a>
<a href="${url}" title="${file.name}">${file.name}</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)}</td> <td class="cell-size">${formatSize(file.size).join(" ")}</td>
${actionCell} ${actionCell}
</tr>`) </tr>`)
} }
@@ -180,6 +220,8 @@ async function deletePath(index) {
DATA.paths[index] = null; DATA.paths[index] = null;
if (!DATA.paths.find(v => !!v)) { if (!DATA.paths.find(v => !!v)) {
$pathsTable.classList.add("hidden"); $pathsTable.classList.add("hidden");
$emptyFolder.textContent = dirEmptyNote;
$emptyFolder.classList.remove("hidden");
} }
} else { } else {
throw new Error(await res.text()) throw new Error(await res.text())
@@ -238,12 +280,12 @@ function getSvg(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 `<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>`;
case "File": case "SymlinkFile":
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 `<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>`;
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 `<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>`;
default: default:
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 `<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>`;
} }
} }
@@ -255,7 +297,7 @@ function formatMtime(mtime) {
const day = padZero(date.getDate(), 2); const day = padZero(date.getDate(), 2);
const hours = padZero(date.getHours(), 2); const hours = padZero(date.getHours(), 2);
const minutes = padZero(date.getMinutes(), 2); const minutes = padZero(date.getMinutes(), 2);
return `${year}/${month}/${day} ${hours}:${minutes}`; return `${year}-${month}-${day} ${hours}:${minutes}`;
} }
function padZero(value, size) { function padZero(value, size) {
@@ -263,19 +305,51 @@ function padZero(value, size) {
} }
function formatSize(size) { function formatSize(size) {
if (!size) return "" if (!size) return []
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, "Byte"];
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]; return [Math.round(size / Math.pow(1024, i), 2), sizes[i]];
}
function formatDuration(seconds) {
seconds = Math.ceil(seconds);
let h = Math.floor(seconds / 3600);
let m = Math.floor((seconds - h * 3600) / 60);
let s = seconds - h * 3600 - m * 60
return `${padZero(h, 2)}:${padZero(m, 2)}:${padZero(s, 2)}`;
}
function formatPercent(precent) {
if (precent > 10) {
return precent.toFixed(1) + "%";
} else {
return precent.toFixed(2) + "%";
}
}
function encodedStr(rawStr) {
return rawStr.replace(/[\u00A0-\u9999<>\&]/g, function(i) {
return '&#'+i.charCodeAt(0)+';';
});
} }
function ready() { function ready() {
document.title = `Index of ${DATA.href} - Dufs`;
$pathsTable = document.querySelector(".paths-table") $pathsTable = document.querySelector(".paths-table")
$pathsTableBody = document.querySelector(".paths-table tbody"); $pathsTableBody = document.querySelector(".paths-table tbody");
$uploadersTable = document.querySelector(".uploaders-table"); $uploadersTable = document.querySelector(".uploaders-table");
$emptyFolder = document.querySelector(".empty-folder");
addBreadcrumb(DATA.breadcrumb); 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);
if (Array.isArray(DATA.paths)) { if (Array.isArray(DATA.paths)) {
const len = DATA.paths.length; const len = DATA.paths.length;
if (len > 0) { if (len > 0) {
@@ -284,6 +358,10 @@ function ready() {
for (let i = 0; i < len; i++) { for (let i = 0; i < len; i++) {
addPath(DATA.paths[i], i); addPath(DATA.paths[i], i);
} }
if (len == 0) {
$emptyFolder.textContent = dirEmptyNote;
$emptyFolder.classList.remove("hidden");
}
} }
if (DATA.allow_upload) { if (DATA.allow_upload) {
dropzone(); dropzone();

View File

@@ -1,25 +1,34 @@
use clap::crate_description; use clap::{AppSettings, Arg, ArgMatches, Command};
use clap::{Arg, ArgMatches}; #[cfg(feature = "tls")]
use rustls::{Certificate, PrivateKey}; use rustls::{Certificate, PrivateKey};
use std::net::SocketAddr; use std::env;
use std::net::IpAddr;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::{env, fs, io};
use crate::auth::parse_auth; use crate::auth::AccessControl;
use crate::auth::AuthMethod;
#[cfg(feature = "tls")]
use crate::tls::{load_certs, load_private_key};
use crate::BoxResult; use crate::BoxResult;
const ABOUT: &str = concat!("\n", crate_description!()); // Add extra newline. fn app() -> Command<'static> {
let app = Command::new(env!("CARGO_CRATE_NAME"))
fn app() -> clap::Command<'static> { .version(env!("CARGO_PKG_VERSION"))
clap::command!() .author(env!("CARGO_PKG_AUTHORS"))
.about(ABOUT) .about(concat!(
env!("CARGO_PKG_DESCRIPTION"),
" - ",
env!("CARGO_PKG_REPOSITORY")
))
.global_setting(AppSettings::DeriveDisplayOrder)
.arg( .arg(
Arg::new("address") Arg::new("bind")
.short('b') .short('b')
.long("bind") .long("bind")
.default_value("0.0.0.0")
.help("Specify bind address") .help("Specify bind address")
.value_name("address"), .multiple_values(true)
.multiple_occurrences(true)
.value_name("addr"),
) )
.arg( .arg(
Arg::new("port") Arg::new("port")
@@ -33,13 +42,36 @@ fn app() -> clap::Command<'static> {
Arg::new("path") Arg::new("path")
.default_value(".") .default_value(".")
.allow_invalid_utf8(true) .allow_invalid_utf8(true)
.help("Path to a root directory for serving files"), .help("Specific path to serve"),
) )
.arg( .arg(
Arg::new("path-prefix") Arg::new("path-prefix")
.long("path-prefix") .long("path-prefix")
.value_name("path") .value_name("path")
.help("Specify an url path prefix"), .help("Specify an path prefix"),
)
.arg(
Arg::new("hidden")
.long("hidden")
.help("Hide directories from directory listings, separated by `,`")
.value_name("value"),
)
.arg(
Arg::new("auth")
.short('a')
.long("auth")
.help("Add auth for path")
.multiple_values(true)
.multiple_occurrences(true)
.value_name("rule"),
)
.arg(
Arg::new("auth-method")
.long("auth-method")
.help("Select auth method")
.possible_values(["basic", "digest"])
.default_value("digest")
.value_name("value"),
) )
.arg( .arg(
Arg::new("allow-all") Arg::new("allow-all")
@@ -57,40 +89,39 @@ fn app() -> clap::Command<'static> {
.long("allow-delete") .long("allow-delete")
.help("Allow delete files/folders"), .help("Allow delete files/folders"),
) )
.arg(
Arg::new("allow-search")
.long("allow-search")
.help("Allow search files/folders"),
)
.arg( .arg(
Arg::new("allow-symlink") Arg::new("allow-symlink")
.long("allow-symlink") .long("allow-symlink")
.help("Allow symlink to files/folders outside root directory"), .help("Allow symlink to files/folders outside root directory"),
) )
.arg(
Arg::new("enable-cors")
.long("enable-cors")
.help("Enable CORS, sets `Access-Control-Allow-Origin: *`"),
)
.arg( .arg(
Arg::new("render-index") Arg::new("render-index")
.long("render-index") .long("render-index")
.help("Render index.html when requesting a directory"), .help("Serve index.html when requesting a directory, returns 404 if not found index.html"),
)
.arg(
Arg::new("render-try-index")
.long("render-try-index")
.help("Serve index.html when requesting a directory, returns directory listing if not found index.html"),
) )
.arg( .arg(
Arg::new("render-spa") Arg::new("render-spa")
.long("render-spa") .long("render-spa")
.help("Render for single-page application"), .help("Serve SPA(Single Page Application)"),
) );
.arg(
Arg::new("auth") #[cfg(feature = "tls")]
.short('a') let app = app
.display_order(1)
.long("auth")
.help("Use HTTP authentication")
.value_name("user:pass"),
)
.arg(
Arg::new("no-auth-access")
.display_order(1)
.long("no-auth-access")
.help("Not required auth when access static files"),
)
.arg(
Arg::new("cors")
.long("cors")
.help("Enable CORS, sets `Access-Control-Allow-Origin: *`"),
)
.arg( .arg(
Arg::new("tls-cert") Arg::new("tls-cert")
.long("tls-cert") .long("tls-cert")
@@ -102,29 +133,38 @@ fn app() -> clap::Command<'static> {
.long("tls-key") .long("tls-key")
.value_name("path") .value_name("path")
.help("Path to the SSL/TLS certificate's private key"), .help("Path to the SSL/TLS certificate's private key"),
) );
app
} }
pub fn matches() -> ArgMatches { pub fn matches() -> ArgMatches {
app().get_matches() app().get_matches()
} }
#[derive(Debug, Clone, Eq, PartialEq)] #[derive(Debug)]
pub struct Args { pub struct Args {
pub address: String, pub addrs: Vec<IpAddr>,
pub port: u16, pub port: u16,
pub path: PathBuf, pub path: PathBuf,
pub path_is_file: bool,
pub path_prefix: String, pub path_prefix: String,
pub uri_prefix: String, pub uri_prefix: String,
pub auth: Option<(String, String)>, pub hidden: String,
pub no_auth_access: bool, pub auth_method: AuthMethod,
pub auth: AccessControl,
pub allow_upload: bool, pub allow_upload: bool,
pub allow_delete: bool, pub allow_delete: bool,
pub allow_search: bool,
pub allow_symlink: bool, pub allow_symlink: bool,
pub render_index: bool, pub render_index: bool,
pub render_spa: bool, pub render_spa: bool,
pub cors: bool, pub render_try_index: bool,
pub enable_cors: bool,
#[cfg(feature = "tls")]
pub tls: Option<(Vec<Certificate>, PrivateKey)>, pub tls: Option<(Vec<Certificate>, PrivateKey)>,
#[cfg(not(feature = "tls"))]
pub tls: Option<()>,
} }
impl Args { impl Args {
@@ -133,9 +173,14 @@ impl Args {
/// If a parsing error ocurred, exit the process and print out informative /// If a parsing error ocurred, 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) -> BoxResult<Args> {
let address = matches.value_of("address").unwrap_or_default().to_owned();
let port = matches.value_of_t::<u16>("port")?; let port = matches.value_of_t::<u16>("port")?;
let addrs = matches
.values_of("bind")
.map(|v| v.collect())
.unwrap_or_else(|| vec!["0.0.0.0", "::"]);
let addrs: Vec<IpAddr> = Args::parse_addrs(&addrs)?;
let path = Args::parse_path(matches.value_of_os("path").unwrap_or_default())?; let path = Args::parse_path(matches.value_of_os("path").unwrap_or_default())?;
let path_is_file = path.metadata()?.is_file();
let path_prefix = matches let path_prefix = matches
.value_of("path-prefix") .value_of("path-prefix")
.map(|v| v.trim_matches('/').to_owned()) .map(|v| v.trim_matches('/').to_owned())
@@ -143,19 +188,30 @@ impl Args {
let uri_prefix = if path_prefix.is_empty() { let uri_prefix = if path_prefix.is_empty() {
"/".to_owned() "/".to_owned()
} else { } else {
format!("/{}/", encode_uri(&path_prefix)) format!("/{}/", &path_prefix)
}; };
let cors = matches.is_present("cors"); let hidden: String = matches
let auth = match matches.value_of("auth") { .value_of("hidden")
Some(auth) => Some(parse_auth(auth)?), .map(|v| format!(",{},", v))
None => None, .unwrap_or_default();
let enable_cors = matches.is_present("enable-cors");
let auth: Vec<&str> = matches
.values_of("auth")
.map(|v| v.collect())
.unwrap_or_default();
let auth_method = match matches.value_of("auth-method").unwrap() {
"basic" => AuthMethod::Basic,
_ => AuthMethod::Digest,
}; };
let no_auth_access = matches.is_present("no-auth-access"); let auth = AccessControl::new(&auth, &uri_prefix)?;
let allow_upload = matches.is_present("allow-all") || matches.is_present("allow-upload"); let allow_upload = matches.is_present("allow-all") || matches.is_present("allow-upload");
let allow_delete = matches.is_present("allow-all") || matches.is_present("allow-delete"); let allow_delete = matches.is_present("allow-all") || matches.is_present("allow-delete");
let allow_search = matches.is_present("allow-all") || matches.is_present("allow-search");
let allow_symlink = matches.is_present("allow-all") || matches.is_present("allow-symlink"); let allow_symlink = matches.is_present("allow-all") || matches.is_present("allow-symlink");
let render_index = matches.is_present("render-index"); let render_index = matches.is_present("render-index");
let render_try_index = matches.is_present("render-try-index");
let render_spa = matches.is_present("render-spa"); let render_spa = matches.is_present("render-spa");
#[cfg(feature = "tls")]
let tls = match (matches.value_of("tls-cert"), matches.value_of("tls-key")) { let tls = match (matches.value_of("tls-cert"), matches.value_of("tls-key")) {
(Some(certs_file), Some(key_file)) => { (Some(certs_file), Some(key_file)) => {
let certs = load_certs(certs_file)?; let certs = load_certs(certs_file)?;
@@ -164,26 +220,50 @@ impl Args {
} }
_ => None, _ => None,
}; };
#[cfg(not(feature = "tls"))]
let tls = None;
Ok(Args { Ok(Args {
address, addrs,
port, port,
path, path,
path_is_file,
path_prefix, path_prefix,
uri_prefix, uri_prefix,
hidden,
auth_method,
auth, auth,
no_auth_access, enable_cors,
cors,
allow_delete, allow_delete,
allow_upload, allow_upload,
allow_search,
allow_symlink, allow_symlink,
render_index, render_index,
render_try_index,
render_spa, render_spa,
tls, tls,
}) })
} }
/// Parse path. fn parse_addrs(addrs: &[&str]) -> BoxResult<Vec<IpAddr>> {
let mut ip_addrs = vec![];
let mut invalid_addrs = vec![];
for addr in addrs {
match addr.parse::<IpAddr>() {
Ok(v) => {
ip_addrs.push(v);
}
Err(_) => {
invalid_addrs.push(*addr);
}
}
}
if !invalid_addrs.is_empty() {
return Err(format!("Invalid bind address `{}`", invalid_addrs.join(",")).into());
}
Ok(ip_addrs)
}
fn parse_path<P: AsRef<Path>>(path: P) -> BoxResult<PathBuf> { fn parse_path<P: AsRef<Path>>(path: P) -> BoxResult<PathBuf> {
let path = path.as_ref(); let path = path.as_ref();
if !path.exists() { if !path.exists() {
@@ -197,48 +277,4 @@ impl Args {
}) })
.map_err(|err| format!("Failed to access path `{}`: {}", path.display(), err,).into()) .map_err(|err| format!("Failed to access path `{}`: {}", path.display(), err,).into())
} }
/// Construct socket address from arguments.
pub fn address(&self) -> BoxResult<SocketAddr> {
format!("{}:{}", self.address, self.port)
.parse()
.map_err(|_| format!("Invalid bind address `{}:{}`", self.address, self.port).into())
}
}
// Load public certificate from file.
pub fn load_certs(filename: &str) -> BoxResult<Vec<Certificate>> {
// Open certificate file.
let certfile =
fs::File::open(&filename).map_err(|e| format!("Failed to open {}: {}", &filename, e))?;
let mut reader = io::BufReader::new(certfile);
// Load and return certificate.
let certs = rustls_pemfile::certs(&mut reader).map_err(|_| "Failed to load certificate")?;
if certs.is_empty() {
return Err("Expected at least one certificate".into());
}
Ok(certs.into_iter().map(Certificate).collect())
}
// Load private key from file.
pub fn load_private_key(filename: &str) -> BoxResult<PrivateKey> {
// Open keyfile.
let keyfile =
fs::File::open(&filename).map_err(|e| format!("Failed to open {}: {}", &filename, e))?;
let mut reader = io::BufReader::new(keyfile);
// Load and return a single private key.
let keys = rustls_pemfile::rsa_private_keys(&mut reader)
.map_err(|e| format!("There was a problem with reading private key: {:?}", e))?;
if keys.len() != 1 {
return Err("Expected a single private key".into());
}
Ok(PrivateKey(keys[0].to_owned()))
}
pub fn encode_uri(v: &str) -> String {
let parts: Vec<_> = v.split('/').map(urlencoding::encode).collect();
parts.join("/")
} }

View File

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

30
src/logger.rs Normal file
View File

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

View File

@@ -1,38 +1,54 @@
mod args; mod args;
mod auth; mod auth;
mod logger;
mod server; mod server;
mod streamer;
#[cfg(feature = "tls")]
mod tls;
mod utils;
#[macro_use] #[macro_use]
extern crate log; extern crate log;
use crate::args::{matches, Args};
use crate::server::{Request, Server};
#[cfg(feature = "tls")]
use crate::tls::{TlsAcceptor, TlsStream};
use std::net::{IpAddr, SocketAddr, TcpListener as StdTcpListener};
use std::sync::Arc;
use futures::future::join_all;
use tokio::net::TcpListener;
use tokio::task::JoinHandle;
use hyper::server::conn::{AddrIncoming, AddrStream};
use hyper::service::{make_service_fn, service_fn};
#[cfg(feature = "tls")]
use rustls::ServerConfig;
pub type BoxResult<T> = Result<T, Box<dyn std::error::Error>>; pub type BoxResult<T> = Result<T, Box<dyn std::error::Error>>;
use std::env;
use std::io::Write;
use crate::args::{encode_uri, matches, Args};
use crate::server::serve;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
run().await.unwrap_or_else(handle_err) run().await.unwrap_or_else(handle_err)
} }
async fn run() -> BoxResult<()> { async fn run() -> BoxResult<()> {
if env::var("RUST_LOG").is_err() { logger::init().map_err(|e| format!("Failed to init logger, {}", e))?;
env::set_var("RUST_LOG", "info")
}
env_logger::builder()
.format(|buf, record| {
let timestamp = buf.timestamp();
writeln!(buf, "[{} {}] {}", timestamp, record.level(), record.args())
})
.init();
let args = Args::parse(matches())?; let args = Args::parse(matches())?;
let args = Arc::new(args);
let handles = serve(args.clone())?;
print_listening(args)?;
tokio::select! { tokio::select! {
ret = serve(args) => { ret = join_all(handles) => {
ret for r in ret {
if let Err(e) = r {
error!("{}", e);
}
}
Ok(())
}, },
_ = shutdown_signal() => { _ = shutdown_signal() => {
Ok(()) Ok(())
@@ -40,6 +56,126 @@ async fn run() -> BoxResult<()> {
} }
} }
fn serve(args: Arc<Args>) -> BoxResult<Vec<JoinHandle<Result<(), hyper::Error>>>> {
let inner = Arc::new(Server::new(args.clone()));
let mut handles = vec![];
let port = args.port;
for ip in args.addrs.iter() {
let inner = inner.clone();
let incoming = create_addr_incoming(SocketAddr::new(*ip, port))
.map_err(|e| format!("Failed to bind `{}:{}`, {}", ip, port, e))?;
let serv_func = move |remote_addr: SocketAddr| {
let inner = inner.clone();
async move {
Ok::<_, hyper::Error>(service_fn(move |req: Request| {
let inner = inner.clone();
inner.call(req, remote_addr)
}))
}
};
match args.tls.as_ref() {
#[cfg(feature = "tls")]
Some((certs, key)) => {
let config = ServerConfig::builder()
.with_safe_defaults()
.with_no_client_auth()
.with_single_cert(certs.clone(), key.clone())?;
let config = Arc::new(config);
let accepter = TlsAcceptor::new(config.clone(), incoming);
let new_service = make_service_fn(move |socket: &TlsStream| {
let remote_addr = socket.remote_addr();
serv_func(remote_addr)
});
let server = tokio::spawn(hyper::Server::builder(accepter).serve(new_service));
handles.push(server);
}
#[cfg(not(feature = "tls"))]
Some(_) => {
unreachable!()
}
None => {
let new_service = make_service_fn(move |socket: &AddrStream| {
let remote_addr = socket.remote_addr();
serv_func(remote_addr)
});
let server = tokio::spawn(hyper::Server::builder(incoming).serve(new_service));
handles.push(server);
}
};
}
Ok(handles)
}
fn create_addr_incoming(addr: SocketAddr) -> BoxResult<AddrIncoming> {
use socket2::{Domain, Protocol, Socket, Type};
let socket = Socket::new(Domain::for_address(addr), Type::STREAM, Some(Protocol::TCP))?;
if addr.is_ipv6() {
socket.set_only_v6(true)?;
}
socket.set_reuse_address(true)?;
socket.bind(&addr.into())?;
socket.listen(1024 /* Default backlog */)?;
let std_listener = StdTcpListener::from(socket);
std_listener.set_nonblocking(true)?;
let incoming = AddrIncoming::from_listener(TcpListener::from_std(std_listener)?)?;
Ok(incoming)
}
fn print_listening(args: Arc<Args>) -> BoxResult<()> {
let mut addrs = vec![];
let (mut ipv4, mut ipv6) = (false, false);
for ip in args.addrs.iter() {
if ip.is_unspecified() {
if ip.is_ipv6() {
ipv6 = true;
} else {
ipv4 = true;
}
} else {
addrs.push(*ip);
}
}
if ipv4 || ipv6 {
let ifaces = if_addrs::get_if_addrs()
.map_err(|e| format!("Failed to get local interface addresses: {}", e))?;
for iface in ifaces.into_iter() {
let local_ip = iface.ip();
if ipv4 && local_ip.is_ipv4() {
addrs.push(local_ip)
}
if ipv6 && local_ip.is_ipv6() {
addrs.push(local_ip)
}
}
}
addrs.sort_unstable();
let urls = addrs
.into_iter()
.map(|addr| match addr {
IpAddr::V4(_) => format!("{}:{}", addr, args.port),
IpAddr::V6(_) => format!("[{}]:{}", addr, args.port),
})
.map(|addr| match &args.tls {
Some(_) => format!("https://{}", addr),
None => format!("http://{}", addr),
})
.map(|url| format!("{}{}", url, args.uri_prefix))
.collect::<Vec<_>>();
if urls.len() == 1 {
println!("Listening on {}", urls[0]);
} else {
let info = urls
.iter()
.map(|v| format!(" {}", v))
.collect::<Vec<String>>()
.join("\n");
println!("Listening on:\n{}\n", info);
}
Ok(())
}
fn handle_err<T>(err: Box<dyn std::error::Error>) -> T { fn handle_err<T>(err: Box<dyn std::error::Error>) -> T {
eprintln!("error: {}", err); eprintln!("error: {}", err);
std::process::exit(1); std::process::exit(1);

File diff suppressed because it is too large Load Diff

68
src/streamer.rs Normal file
View File

@@ -0,0 +1,68 @@
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()
}
}

158
src/tls.rs Normal file
View File

@@ -0,0 +1,158 @@
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::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(filename: &str) -> Result<Vec<Certificate>, Box<dyn std::error::Error>> {
// Open certificate file.
let certfile = fs::File::open(&filename)
.map_err(|e| format!("Failed to access `{}`, {}", &filename, e))?;
let mut reader = io::BufReader::new(certfile);
// 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(filename: &str) -> Result<PrivateKey, Box<dyn std::error::Error>> {
// Open keyfile.
let keyfile = fs::File::open(&filename)
.map_err(|e| format!("Failed to access `{}`, {}", &filename, e))?;
let mut reader = io::BufReader::new(keyfile);
// 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) => Some(key),
_ => None,
})
.ok_or("No supported private key in file")?;
Ok(PrivateKey(keys))
}

25
src/utils.rs Normal file
View File

@@ -0,0 +1,25 @@
use crate::BoxResult;
use std::{borrow::Cow, path::Path};
pub fn encode_uri(v: &str) -> String {
let parts: Vec<_> = v.split('/').map(urlencoding::encode).collect();
parts.join("/")
}
pub fn decode_uri(v: &str) -> Option<Cow<str>> {
percent_encoding::percent_decode(v.as_bytes())
.decode_utf8()
.ok()
}
pub fn get_file_name(path: &Path) -> &str {
path.file_name()
.and_then(|v| v.to_str())
.unwrap_or_default()
}
pub fn try_get_file_name(path: &Path) -> BoxResult<&str> {
path.file_name()
.and_then(|v| v.to_str())
.ok_or_else(|| format!("Failed to get file name of `{}`", path.display()).into())
}

73
tests/allow.rs Normal file
View File

@@ -0,0 +1,73 @@
mod fixtures;
mod utils;
use fixtures::{server, Error, TestServer};
use rstest::rstest;
#[rstest]
fn default_not_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(), 403);
Ok(())
}
#[rstest]
fn default_not_allow_delete(server: TestServer) -> Result<(), Error> {
let url = format!("{}test.html", server.url());
let resp = fetch!(b"DELETE", &url).send()?;
assert_eq!(resp.status(), 403);
Ok(())
}
#[rstest]
fn default_not_exist_dir(server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}404/", server.url()))?;
assert_eq!(resp.status(), 404);
Ok(())
}
#[rstest]
fn allow_upload_not_exist_dir(
#[with(&["--allow-upload"])] server: TestServer,
) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}404/", server.url()))?;
assert_eq!(resp.status(), 200);
Ok(())
}
#[rstest]
fn allow_upload_no_override(#[with(&["--allow-upload"])] server: TestServer) -> Result<(), Error> {
let url = format!("{}index.html", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 403);
Ok(())
}
#[rstest]
fn allow_delete_no_override(#[with(&["--allow-delete"])] server: TestServer) -> Result<(), Error> {
let url = format!("{}index.html", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 403);
Ok(())
}
#[rstest]
fn allow_upload_delete_can_override(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let url = format!("{}index.html", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 201);
Ok(())
}
#[rstest]
fn allow_search(#[with(&["--allow-search"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?q={}", server.url(), "test.html"))?;
assert_eq!(resp.status(), 200);
let paths = utils::retrive_index_paths(&resp.text()?);
assert!(!paths.is_empty());
for p in paths {
assert!(p.contains("test.html"));
}
Ok(())
}

52
tests/args.rs Normal file
View File

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

93
tests/assets.rs Normal file
View File

@@ -0,0 +1,93 @@
mod fixtures;
mod utils;
use fixtures::{server, Error, TestServer};
use rstest::rstest;
#[rstest]
fn assets(server: TestServer) -> Result<(), Error> {
let ver = env!("CARGO_PKG_VERSION");
let resp = reqwest::blocking::get(server.url())?;
let index_js = format!("/__dufs_v{}_index.js", ver);
let index_css = format!("/__dufs_v{}_index.css", ver);
let favicon_ico = format!("/__dufs_v{}_favicon.ico", ver);
let text = resp.text()?;
assert!(text.contains(&format!(r#"href="{}""#, index_css)));
assert!(text.contains(&format!(r#"href="{}""#, favicon_ico)));
assert!(text.contains(&format!(r#"src="{}""#, index_js)));
Ok(())
}
#[rstest]
fn asset_js(server: TestServer) -> Result<(), Error> {
let url = format!(
"{}__dufs_v{}_index.js",
server.url(),
env!("CARGO_PKG_VERSION")
);
let resp = reqwest::blocking::get(url)?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/javascript"
);
Ok(())
}
#[rstest]
fn asset_css(server: TestServer) -> Result<(), Error> {
let url = format!(
"{}__dufs_v{}_index.css",
server.url(),
env!("CARGO_PKG_VERSION")
);
let resp = reqwest::blocking::get(url)?;
assert_eq!(resp.status(), 200);
assert_eq!(resp.headers().get("content-type").unwrap(), "text/css");
Ok(())
}
#[rstest]
fn asset_ico(server: TestServer) -> Result<(), Error> {
let url = format!(
"{}__dufs_v{}_favicon.ico",
server.url(),
env!("CARGO_PKG_VERSION")
);
let resp = reqwest::blocking::get(url)?;
assert_eq!(resp.status(), 200);
assert_eq!(resp.headers().get("content-type").unwrap(), "image/x-icon");
Ok(())
}
#[rstest]
fn assets_with_prefix(#[with(&["--path-prefix", "xyz"])] server: TestServer) -> Result<(), Error> {
let ver = env!("CARGO_PKG_VERSION");
let resp = reqwest::blocking::get(format!("{}xyz/", server.url()))?;
let index_js = format!("/xyz/__dufs_v{}_index.js", ver);
let index_css = format!("/xyz/__dufs_v{}_index.css", ver);
let favicon_ico = format!("/xyz/__dufs_v{}_favicon.ico", ver);
let text = resp.text()?;
assert!(text.contains(&format!(r#"href="{}""#, index_css)));
assert!(text.contains(&format!(r#"href="{}""#, favicon_ico)));
assert!(text.contains(&format!(r#"src="{}""#, index_js)));
Ok(())
}
#[rstest]
fn asset_js_with_prefix(
#[with(&["--path-prefix", "xyz"])] server: TestServer,
) -> Result<(), Error> {
let url = format!(
"{}xyz/__dufs_v{}_index.js",
server.url(),
env!("CARGO_PKG_VERSION")
);
let resp = reqwest::blocking::get(url)?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/javascript"
);
Ok(())
}

97
tests/auth.rs Normal file
View File

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

86
tests/bind.rs Normal file
View File

@@ -0,0 +1,86 @@
mod fixtures;
use fixtures::{port, server, tmpdir, wait_for_port, Error, TestServer};
use assert_cmd::prelude::*;
use assert_fs::fixture::TempDir;
use regex::Regex;
use rstest::rstest;
use std::io::Read;
use std::process::{Command, Stdio};
#[rstest]
#[case(&["-b", "20.205.243.166"])]
fn bind_fails(tmpdir: TempDir, port: u16, #[case] args: &[&str]) -> Result<(), Error> {
Command::cargo_bin("dufs")?
.arg(tmpdir.path())
.arg("-p")
.arg(port.to_string())
.args(args)
.assert()
.stderr(predicates::str::contains("Failed to bind"))
.failure();
Ok(())
}
#[rstest]
#[case(server(&[] as &[&str]), true, true)]
#[case(server(&["-b", "0.0.0.0"]), true, false)]
#[case(server(&["-b", "127.0.0.1", "-b", "::1"]), true, true)]
fn bind_ipv4_ipv6(
#[case] server: TestServer,
#[case] bind_ipv4: bool,
#[case] bind_ipv6: bool,
) -> Result<(), Error> {
assert_eq!(
reqwest::blocking::get(format!("http://127.0.0.1:{}", server.port()).as_str()).is_ok(),
bind_ipv4
);
assert_eq!(
reqwest::blocking::get(format!("http://[::1]:{}", server.port()).as_str()).is_ok(),
bind_ipv6
);
Ok(())
}
#[rstest]
#[case(&[] as &[&str])]
#[case(&["--path-prefix", "/prefix"])]
fn validate_printed_urls(tmpdir: TempDir, port: u16, #[case] args: &[&str]) -> Result<(), Error> {
let mut child = Command::cargo_bin("dufs")?
.arg(tmpdir.path())
.arg("-p")
.arg(port.to_string())
.args(args)
.stdout(Stdio::piped())
.spawn()?;
wait_for_port(port);
let stdout = child.stdout.as_mut().expect("Failed to get stdout");
let mut buf = [0; 1000];
let buf_len = stdout.read(&mut buf)?;
let output = std::str::from_utf8(&buf[0..buf_len])?;
let url_lines = output
.lines()
.take_while(|line| !line.is_empty()) /* non-empty lines */
.collect::<Vec<_>>()
.join("\n");
let urls = Regex::new(r"http://[a-zA-Z0-9\.\[\]:/]+")
.unwrap()
.captures_iter(url_lines.as_str())
.filter_map(|caps| caps.get(0).map(|v| v.as_str()))
.collect::<Vec<_>>();
assert!(!urls.is_empty());
for url in urls {
reqwest::blocking::get(url)?.error_for_status()?;
}
child.kill()?;
Ok(())
}

37
tests/cors.rs Normal file
View File

@@ -0,0 +1,37 @@
mod fixtures;
mod utils;
use fixtures::{server, Error, TestServer};
use rstest::rstest;
#[rstest]
fn cors(#[with(&["--enable-cors"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?;
assert_eq!(
resp.headers().get("access-control-allow-origin").unwrap(),
"*"
);
assert_eq!(
resp.headers().get("access-control-allow-headers").unwrap(),
"range, content-type, accept, origin, www-authenticate"
);
Ok(())
}
#[rstest]
fn cors_options(#[with(&["--enable-cors"])] server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"OPTIONS", server.url()).send()?;
assert_eq!(
resp.headers().get("access-control-allow-origin").unwrap(),
"*"
);
assert_eq!(
resp.headers().get("access-control-allow-headers").unwrap(),
"range, content-type, accept, origin, www-authenticate"
);
Ok(())
}

29
tests/data/cert.pem Normal file
View File

@@ -0,0 +1,29 @@
-----BEGIN CERTIFICATE-----
MIIFCTCCAvGgAwIBAgIUcegjikATvwNSIbN43QybKWIcKSMwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIyMDYxMTA4NTQyMloXDTMyMDYw
ODA4NTQyMlowFDESMBAGA1UEAwwJbG9jYWxob3N0MIICIjANBgkqhkiG9w0BAQEF
AAOCAg8AMIICCgKCAgEAo2wdMbFPkX7CAF/Y+hVj5bwm4dlxhwW2Z9Ic2RZFC5w2
oK2XwyasDBEqDlgv/bN4xObAVlDZ/4/SuTVSDrNB8dtQl7GTWptpbFKJUdNocU88
wqd4k/cLZg2aiQqnZKD88w/AxXnYw+F8yU0pFGj9GX0S5at3/V1hrBVxVO8Y99bb
gnJA8NMm0Pw2xYZS++ULuzoECk0xbNdtbtPrIuweI5mMvsJvtiw67EIdl3N9Lj5p
L4a7X1C0Xk5H4mOcwM0qq3m31HsCW91PMCjU6suo764rx5Jqv0n9HCNxdiSEadCw
f+GrmKtFOw3DcGPETg5AJR8H3rG1agKKjI+vRtL/tZ7coFOhZKXdjGvvUFcWcqO+
GppHh16pzJDXi2qeD9Cu5b2ayM2uBnfV7Q3FjOeDqD+BCJ0ClaqNmAD9TF2htzdu
Inl+G3OJb4cqaYjaF5YmiZISfrimK5eR2I3et5cqnbuDHMKvDfUd9Jgj/2IqPOHJ
EguuXSO7WNKfQmlTv7EN/xrD6jiB/M8ADaSxjCqTbtKNyCbJlu2Wy9WlDXwPkNW8
g70T4Br4U4Iy3N/0w2lAAhiizdC2jkehSKmWE2nmixGSXxkSOMgXQXDJ9RBtDQfd
8ym/ADfyVndUSnHvf9jCH1NPHlFbB7RVSvUHX22Qq63NUvhV32ct+/IyD/qPpl0C
AwEAAaNTMFEwHQYDVR0OBBYEFKwSSbPXBIkmzja3/cNJyqhWy96WMB8GA1UdIwQY
MBaAFKwSSbPXBIkmzja3/cNJyqhWy96WMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI
hvcNAQELBQADggIBAHcrdu1nGDN5YvcHXzbBx73AC921fmn5xxzeFRO7af157g5h
4zornLMk4Obp+UGkMbWK4K0NAQXKKm5WjcmoOHNRg7TgTE7b1gcVuS4phdwlIqA6
eZGg+NWZyeaIJNjdHgWgGoe+S+5Ne1I7sDKiEXrOzITJrDcQgBKFF08kqT6UNY2W
q90m+olPtrewAMgWllpxJ90u4qifPcwP+neDZJim9MhVYtHHeFsmyzlS185iasj8
sxvp5HDTopmz0tDuiLHvOMKmyf7vapsnbqEGngQi2qV9rBmldyRLnWSe8u/FN31f
zhSk1ikSm1cQ/iyL898XexSmTafyaF8ELswdIMHkGZkVQurWeKn3/CEDXokXkpMI
4dlCSgM7SU+XtcjtXbR8/pHpcW2ZnBR0la/qIv81aNKkJeUkTcPC8BUv4jI/oT6z
LRrvRjMnHJjnADACuutlNRU4/e7h1XuvlXgFHsp63k7GJXouoIwdHjfkErZXsoEX
WeS+pPatkT7wbhfgYVwglMRIpgCu++htSRCV/lbSuYzCG6mKtxJyy4eslSjpHNPG
wELDKgzsgLtuTyNfP458O9i8x6wf9J6eVaHe3nqgqkOnnmQxEYnsPaFUMWG1/DYi
U+mA/VdQrPe3J4Z082sCe4MVmTzWlWCDpNFFQpv51NbWzc/kuIZuJCAwoZD0
-----END CERTIFICATE-----

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
openssl req -subj '/CN=localhost' -x509 -newkey rsa:4096 -keyout key_pkcs8.pem -out cert.pem -nodes -days 3650
openssl rsa -in key_pkcs8.pem -out key_pkcs1.pem

51
tests/data/key_pkcs1.pem Normal file
View File

@@ -0,0 +1,51 @@
-----BEGIN RSA PRIVATE KEY-----
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
-----END RSA PRIVATE KEY-----

52
tests/data/key_pkcs8.pem Normal file
View File

@@ -0,0 +1,52 @@
-----BEGIN PRIVATE KEY-----
MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCjbB0xsU+RfsIA
X9j6FWPlvCbh2XGHBbZn0hzZFkULnDagrZfDJqwMESoOWC/9s3jE5sBWUNn/j9K5
NVIOs0Hx21CXsZNam2lsUolR02hxTzzCp3iT9wtmDZqJCqdkoPzzD8DFedjD4XzJ
TSkUaP0ZfRLlq3f9XWGsFXFU7xj31tuCckDw0ybQ/DbFhlL75Qu7OgQKTTFs121u
0+si7B4jmYy+wm+2LDrsQh2Xc30uPmkvhrtfULReTkfiY5zAzSqrebfUewJb3U8w
KNTqy6jvrivHkmq/Sf0cI3F2JIRp0LB/4auYq0U7DcNwY8RODkAlHwfesbVqAoqM
j69G0v+1ntygU6Fkpd2Ma+9QVxZyo74amkeHXqnMkNeLap4P0K7lvZrIza4Gd9Xt
DcWM54OoP4EInQKVqo2YAP1MXaG3N24ieX4bc4lvhyppiNoXliaJkhJ+uKYrl5HY
jd63lyqdu4Mcwq8N9R30mCP/Yio84ckSC65dI7tY0p9CaVO/sQ3/GsPqOIH8zwAN
pLGMKpNu0o3IJsmW7ZbL1aUNfA+Q1byDvRPgGvhTgjLc3/TDaUACGKLN0LaOR6FI
qZYTaeaLEZJfGRI4yBdBcMn1EG0NB93zKb8AN/JWd1RKce9/2MIfU08eUVsHtFVK
9QdfbZCrrc1S+FXfZy378jIP+o+mXQIDAQABAoICAA8zb0PACnauI72FLNmWQ8SK
y9MWNskobt8ZXDWVLLb3wN0RSJyObFJzBkZvTYd7ujAWhq2At8ZdymwP2aIkam3k
yIpTqjEzY4r/4mYKFBz2EeDJUx3wTM8hVM6KfyeNEfpJ7NRxkQgnYUQYOUn8ug40
5B0Y5gBbP0qSdAhCdUAYbDKmDgRYn7Owwid9ZxqnI4A21UlgBMjrAPLeI3EMJYGK
Nt1HmLyJQLHZNIdx5qL2izIwhh1HlH9xKJ6WRW5ppx5sI/1Ps9h3rXX9VlDAwOYK
fWgC5osEhtanNVaX7vkowEIk66E3zSjBDvzVSt031h+d002TmhxSQDDy7IrZ9Y75
Nx0V10evHS2cQiyeTPL4m2n+PkSr8kZWVJjTd1yTgUiMNIz8z7oc/lHaN8YKyK2i
ejTdcaPM2wmi7RSeit1j5UviqiUi2ii9aR83mG52Zuy17iBLf91K+2gHK2jlICpQ
ZRLRU2OGxTnD4jIwUr6QiaUm2Fvf4Y0X/uE0+fgzC7EgVcTLVxU+77DCgWgvn7uy
2nryaVievGYQgU4k1yMXW6t+oIwBdZfFp37vVO0GJGK3m0BlUeNPP00GTK8Jxc4S
rdEwSMDMK1MaV25TA1CmaZ0LIeTZKetaJ5UurJO3nmvUWHMRnQCoeJaYcI1GerP8
8kUEgp9f1gqeQrbFUCABAoIBAQDSNy9nRx8YYijxYb+vmaK2mdV5DL1dn4hzsmz1
Pvdktwb6qj/ZcOERdxHg4pmhIwgkii0wgnVNkp0031CAWnsi5RadimenaH4fJ+/y
HsMdnCiEfDhiz0Alj2PhZUY/80pVw+tNj8WQndUcqJV3XQVeg2fSoTDnLehrjNG1
FgWunuC86hEzChYAMs3+HC5DZBGsE+3TO2Gp9t5O1ATiD3nS2SozLpXq5qGRkS9C
ZJ3VSkPfloYozDE6kr17D0IAp25+0NU673He8oA6HS5ez0PExR8kUp/whIjYkHe8
WasAnaXqpIYR2hV2JeTAd1GxoQElsOtHe6pHmi8zSpWTazbdAoIBAQDHA+qq/lK4
88ySwEIFJ2zr3ZwSxcDXFu/DUiw66CobbZ8I8NIivTD1znFTdBK/YEWZoIslARUA
+gj+Oo8yoh05YL2VJcS4rDb3uLSv8YAxw1x7U35fPVqHJUTcCTrknCL++CvQPfy2
G1AmUX/8EHSE8W++3gZDYTg7kQvBNcg+vjrBAUZ/Qh+q+5+kegqKzMnkXmWLJO0H
nQngizYIpQxS5erpeVYysI0vJoZbjZy21yKBs0A9qCdAA0cVQg70zTp0BT1ZWdQk
b140N3+0PDRcMuNGpNFeU2ipZHBYyXfxleIcj2gek6oeN4Bd6927meSqya1JDnQu
Ic6Ur39w7nWBAoIBAEeWP3BeSfqVwvegoBR+r2ibrpTQf9RYNbGgu3YxATKtGZVX
UPMNe7fk4pPA1yyGr4kx/P2eD7BhE2QkAFgVeAWEGlWAApiB/yFBCndbfnqg6TNk
0hbkGSYxVIQ3Ztr0UQYv9/ur0E3VUfO2fV/ur5tmCH80IWE4wwONKwUOANVciZAL
7duBvchHhNraYe7VLyd4ie77M+Ebjs0tapRJTOU+yx4u6SLbc34NARkNBqrR5ZdI
vVs7XjBiNGCp64IpgnlxSIwPaqnPz1b7BQWIc72Ym2hVMt5HZArL99qOdqt5BD2J
1ZZSvChF80aP26Qv/iIFw7nOJUjI+vXumk1fn4kCggEAKWSgJ4aUMBHNR+yjDq1f
k0NZlfH5pQMJRWfO+UWWlHPdcx+bpUwSZd3VZlUrHRDqCQUCS+UIuFZJDo+hfxeG
2otLgz0CyKJRIH8TXdDuJ+uJTcFU9HwvWxKeWuG0lR4AjWWSv1JVQI83wXCMHSJT
Cd+prVpClTsgVgtaFYqZJqusTr50Kj3XllmVn8EedjYX+97CYgmBH2dDSafKykuB
37AghwUMr6hXBSqPIXW31bOzYqSkxO43k7Ha1AoUUEhrUvxQAUiUvitHvbaZYiRv
g4m2Cdh4D5VrnGfekij9QIs7J1OQhWOTK4vBsEdBrCZbEEa4Owy9j2bKr9Q8voFq
gQKCAQEAl5jk6BVskcHphqB4G1QE97S0shuetg9hcg+wvJxn49ejvcBJulseHpq/
sJ0/bzw6677MTQ/qIB2sitTjWRz7ZPHsrxtBvSkOtfSlBlAAjOgFYqi7CtryRJ5L
V2SIs4uI6GdgOf45gTi03l497sCUGTtvX/HJs24LJNh1ofW49ojnpFo8HxoclefG
08dS9ibSgx+bcQ+OAX8WMZ2kTus9x1haopQSTlQ+ZwZVWz7IdFKwQOdrnNcGpCgN
ouf7sp+Bw7xTGGEvZx1xDa+wLULtAg+7TjeUAheB7l37WE3CtmMfD/1KRXdQi2cQ
afHJpnY0yZEqN1PeDaKVOCQAHbD8qQ==
-----END PRIVATE KEY-----

175
tests/fixtures.rs Normal file
View File

@@ -0,0 +1,175 @@
use assert_cmd::prelude::*;
use assert_fs::fixture::TempDir;
use assert_fs::prelude::*;
use port_check::free_local_port;
use reqwest::Url;
use rstest::fixture;
use std::process::{Child, Command, Stdio};
use std::thread::sleep;
use std::time::{Duration, Instant};
#[allow(dead_code)]
pub type Error = Box<dyn std::error::Error>;
/// File names for testing purpose
#[allow(dead_code)]
pub static FILES: &[&str] = &["test.txt", "test.html", "index.html", "😀.bin"];
/// Directory names for testing diretory don't exist
#[allow(dead_code)]
pub static DIR_NO_FOUND: &str = "dir-no-found/";
/// Directory names for testing diretory don't have index.html
#[allow(dead_code)]
pub static DIR_NO_INDEX: &str = "dir-no-index/";
/// Directory names for testing hidden
#[allow(dead_code)]
pub static DIR_GIT: &str = ".git/";
/// Directory names for testing purpose
#[allow(dead_code)]
pub static DIRECTORIES: &[&str] = &["dira/", "dirb/", "dirc/", DIR_NO_INDEX, DIR_GIT];
/// Test fixture which creates a temporary directory with a few files and directories inside.
/// The directories also contain files.
#[fixture]
#[allow(dead_code)]
pub fn tmpdir() -> TempDir {
let tmpdir = assert_fs::TempDir::new().expect("Couldn't create a temp dir for tests");
for file in FILES {
tmpdir
.child(file)
.write_str(&format!("This is {}", file))
.expect("Couldn't write to file");
}
for directory in DIRECTORIES {
for file in FILES {
if *directory == DIR_NO_INDEX && *file == "index.html" {
continue;
}
tmpdir
.child(format!("{}{}", directory, file))
.write_str(&format!("This is {}{}", directory, file))
.expect("Couldn't write to file");
}
}
tmpdir
}
/// Get a free port.
#[fixture]
#[allow(dead_code)]
pub fn port() -> u16 {
free_local_port().expect("Couldn't find a free local port")
}
/// Run dufs as a server; Start with a temporary directory, a free port and some
/// optional arguments then wait for a while for the server setup to complete.
#[fixture]
#[allow(dead_code)]
pub fn server<I>(#[default(&[] as &[&str])] args: I) -> TestServer
where
I: IntoIterator + Clone,
I::Item: AsRef<std::ffi::OsStr>,
{
let port = port();
let tmpdir = tmpdir();
let child = Command::cargo_bin("dufs")
.expect("Couldn't find test binary")
.arg(tmpdir.path())
.arg("-p")
.arg(port.to_string())
.args(args.clone())
.stdout(Stdio::null())
.spawn()
.expect("Couldn't run test binary");
let is_tls = args
.into_iter()
.any(|x| x.as_ref().to_str().unwrap().contains("tls"));
wait_for_port(port);
TestServer::new(port, tmpdir, child, is_tls)
}
/// Same as `server()` but ignore stderr
#[fixture]
#[allow(dead_code)]
pub fn server_no_stderr<I>(#[default(&[] as &[&str])] args: I) -> TestServer
where
I: IntoIterator + Clone,
I::Item: AsRef<std::ffi::OsStr>,
{
let port = port();
let tmpdir = tmpdir();
let child = Command::cargo_bin("dufs")
.expect("Couldn't find test binary")
.arg(tmpdir.path())
.arg("-p")
.arg(port.to_string())
.args(args.clone())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("Couldn't run test binary");
let is_tls = args
.into_iter()
.any(|x| x.as_ref().to_str().unwrap().contains("tls"));
wait_for_port(port);
TestServer::new(port, tmpdir, child, is_tls)
}
/// Wait a max of 1s for the port to become available.
pub fn wait_for_port(port: u16) {
let start_wait = Instant::now();
while !port_check::is_port_reachable(format!("localhost:{}", port)) {
sleep(Duration::from_millis(100));
if start_wait.elapsed().as_secs() > 1 {
panic!("timeout waiting for port {}", port);
}
}
}
#[allow(dead_code)]
pub struct TestServer {
port: u16,
tmpdir: TempDir,
child: Child,
is_tls: bool,
}
#[allow(dead_code)]
impl TestServer {
pub fn new(port: u16, tmpdir: TempDir, child: Child, is_tls: bool) -> Self {
Self {
port,
tmpdir,
child,
is_tls,
}
}
pub fn url(&self) -> Url {
let protocol = if self.is_tls { "https" } else { "http" };
Url::parse(&format!("{}://localhost:{}", protocol, self.port)).unwrap()
}
pub fn path(&self) -> &std::path::Path {
self.tmpdir.path()
}
pub fn port(&self) -> u16 {
self.port
}
}
impl Drop for TestServer {
fn drop(&mut self) {
self.child.kill().expect("Couldn't kill test server");
self.child.wait().unwrap();
}
}

42
tests/hidden.rs Normal file
View File

@@ -0,0 +1,42 @@
mod fixtures;
mod utils;
use fixtures::{server, Error, TestServer};
use rstest::rstest;
#[rstest]
#[case(server(&[] as &[&str]), true)]
#[case(server(&["--hidden", ".git,index.html"]), false)]
fn hidden_get_dir(#[case] server: TestServer, #[case] exist: bool) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?;
assert_eq!(resp.status(), 200);
let paths = utils::retrive_index_paths(&resp.text()?);
assert_eq!(paths.contains(".git/"), exist);
assert_eq!(paths.contains("index.html"), exist);
Ok(())
}
#[rstest]
#[case(server(&[] as &[&str]), true)]
#[case(server(&["--hidden", ".git,index.html"]), false)]
fn hidden_propfind_dir(#[case] server: TestServer, #[case] exist: bool) -> Result<(), Error> {
let resp = fetch!(b"PROPFIND", server.url()).send()?;
assert_eq!(resp.status(), 207);
let body = resp.text()?;
assert_eq!(body.contains("<D:href>/.git/</D:href>"), exist);
assert_eq!(body.contains("<D:href>/index.html</D:href>"), exist);
Ok(())
}
#[rstest]
#[case(server(&["--allow-search"] as &[&str]), true)]
#[case(server(&["--allow-search", "--hidden", ".git,test.html"]), false)]
fn hidden_search_dir(#[case] server: TestServer, #[case] exist: bool) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?q={}", server.url(), "test.html"))?;
assert_eq!(resp.status(), 200);
let paths = utils::retrive_index_paths(&resp.text()?);
for p in paths {
assert_eq!(p.contains("test.html"), exist);
}
Ok(())
}

197
tests/http.rs Normal file
View File

@@ -0,0 +1,197 @@
mod fixtures;
mod utils;
use fixtures::{server, Error, TestServer};
use rstest::rstest;
#[rstest]
fn get_dir(server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?;
assert_resp_paths!(resp);
Ok(())
}
#[rstest]
fn head_dir(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"HEAD", server.url()).send()?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"text/html; charset=utf-8"
);
assert_eq!(resp.text()?, "");
Ok(())
}
#[rstest]
fn get_dir_404(server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}404/", server.url()))?;
assert_eq!(resp.status(), 404);
Ok(())
}
#[rstest]
fn head_dir_404(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"HEAD", format!("{}404/", server.url())).send()?;
assert_eq!(resp.status(), 404);
Ok(())
}
#[rstest]
fn get_dir_zip(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(())
}
#[rstest]
fn head_dir_zip(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"HEAD", format!("{}?zip", server.url())).send()?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/zip"
);
assert!(resp.headers().contains_key("content-disposition"));
assert_eq!(resp.text()?, "");
Ok(())
}
#[rstest]
fn get_dir_search(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?q={}", server.url(), "test.html"))?;
assert_eq!(resp.status(), 200);
let paths = utils::retrive_index_paths(&resp.text()?);
assert!(!paths.is_empty());
for p in paths {
assert!(p.contains("test.html"));
}
Ok(())
}
#[rstest]
fn get_dir_search2(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?q={}", server.url(), "😀.bin"))?;
assert_eq!(resp.status(), 200);
let paths = utils::retrive_index_paths(&resp.text()?);
assert!(!paths.is_empty());
for p in paths {
assert!(p.contains("😀.bin"));
}
Ok(())
}
#[rstest]
fn head_dir_search(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"HEAD", format!("{}?q={}", server.url(), "test.html")).send()?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"text/html; charset=utf-8"
);
assert_eq!(resp.text()?, "");
Ok(())
}
#[rstest]
fn get_file(server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}index.html", server.url()))?;
assert_eq!(resp.status(), 200);
assert_eq!(resp.headers().get("content-type").unwrap(), "text/html");
assert_eq!(resp.headers().get("accept-ranges").unwrap(), "bytes");
assert!(resp.headers().contains_key("etag"));
assert!(resp.headers().contains_key("last-modified"));
assert!(resp.headers().contains_key("content-length"));
assert_eq!(resp.text()?, "This is index.html");
Ok(())
}
#[rstest]
fn head_file(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"HEAD", format!("{}index.html", server.url())).send()?;
assert_eq!(resp.status(), 200);
assert_eq!(resp.headers().get("content-type").unwrap(), "text/html");
assert_eq!(resp.headers().get("accept-ranges").unwrap(), "bytes");
assert!(resp.headers().contains_key("content-disposition"));
assert!(resp.headers().contains_key("etag"));
assert!(resp.headers().contains_key("last-modified"));
assert!(resp.headers().contains_key("content-length"));
assert_eq!(resp.text()?, "");
Ok(())
}
#[rstest]
fn get_file_404(server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}404", server.url()))?;
assert_eq!(resp.status(), 404);
Ok(())
}
#[rstest]
fn head_file_404(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"HEAD", format!("{}404", server.url())).send()?;
assert_eq!(resp.status(), 404);
Ok(())
}
#[rstest]
fn options_dir(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"OPTIONS", format!("{}index.html", server.url())).send()?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("allow").unwrap(),
"GET,HEAD,PUT,OPTIONS,DELETE,PROPFIND,COPY,MOVE"
);
assert_eq!(resp.headers().get("dav").unwrap(), "1,2");
Ok(())
}
#[rstest]
fn put_file(#[with(&["-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(), 201);
let resp = reqwest::blocking::get(url)?;
assert_eq!(resp.status(), 200);
Ok(())
}
#[rstest]
fn put_file_create_dir(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let url = format!("{}xyz/file1", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 201);
let resp = reqwest::blocking::get(url)?;
assert_eq!(resp.status(), 200);
Ok(())
}
#[rstest]
fn put_file_conflict_dir(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let url = format!("{}dira", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 403);
Ok(())
}
#[rstest]
fn delete_file(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let url = format!("{}test.html", server.url());
let resp = fetch!(b"DELETE", &url).send()?;
assert_eq!(resp.status(), 204);
let resp = reqwest::blocking::get(url)?;
assert_eq!(resp.status(), 404);
Ok(())
}
#[rstest]
fn delete_file_404(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"DELETE", format!("{}file1", server.url())).send()?;
assert_eq!(resp.status(), 404);
Ok(())
}

45
tests/range.rs Normal file
View File

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

67
tests/render.rs Normal file
View File

@@ -0,0 +1,67 @@
mod fixtures;
mod utils;
use fixtures::{server, Error, TestServer, DIR_NO_FOUND, DIR_NO_INDEX, FILES};
use rstest::rstest;
#[rstest]
fn render_index(#[with(&["--render-index"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?;
let text = resp.text()?;
assert_eq!(text, "This is index.html");
Ok(())
}
#[rstest]
fn render_index2(#[with(&["--render-index"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}{}", server.url(), DIR_NO_INDEX))?;
assert_eq!(resp.status(), 404);
Ok(())
}
#[rstest]
fn render_try_index(#[with(&["--render-try-index"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?;
let text = resp.text()?;
assert_eq!(text, "This is index.html");
Ok(())
}
#[rstest]
fn render_try_index2(#[with(&["--render-try-index"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}{}", server.url(), DIR_NO_INDEX))?;
let files: Vec<&str> = FILES
.iter()
.filter(|v| **v != "index.html")
.cloned()
.collect();
assert_resp_paths!(resp, files);
Ok(())
}
#[rstest]
fn render_try_index3(#[with(&["--render-try-index"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}{}?zip", server.url(), DIR_NO_INDEX))?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/zip"
);
Ok(())
}
#[rstest]
fn render_spa(#[with(&["--render-spa"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?;
let text = resp.text()?;
assert_eq!(text, "This is index.html");
Ok(())
}
#[rstest]
fn render_spa2(#[with(&["--render-spa"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}{}", server.url(), DIR_NO_FOUND))?;
let text = resp.text()?;
assert_eq!(text, "This is index.html");
Ok(())
}

46
tests/symlink.rs Normal file
View File

@@ -0,0 +1,46 @@
mod fixtures;
mod utils;
use assert_fs::fixture::TempDir;
use fixtures::{server, tmpdir, Error, TestServer};
use rstest::rstest;
#[cfg(unix)]
use std::os::unix::fs::symlink as symlink_dir;
#[cfg(windows)]
use std::os::windows::fs::symlink_dir;
#[rstest]
fn default_not_allow_symlink(server: TestServer, tmpdir: TempDir) -> Result<(), Error> {
// Create symlink directory "foo" to point outside the root
let dir = "foo";
symlink_dir(tmpdir.path(), server.path().join(dir)).expect("Couldn't create symlink");
let resp = reqwest::blocking::get(format!("{}{}", server.url(), dir))?;
assert_eq!(resp.status(), 404);
let resp = reqwest::blocking::get(format!("{}{}/index.html", server.url(), dir))?;
assert_eq!(resp.status(), 404);
let resp = reqwest::blocking::get(server.url())?;
let paths = utils::retrive_index_paths(&resp.text()?);
assert!(!paths.is_empty());
assert!(!paths.contains(&format!("{}/", dir)));
Ok(())
}
#[rstest]
fn allow_symlink(
#[with(&["--allow-symlink"])] server: TestServer,
tmpdir: TempDir,
) -> Result<(), Error> {
// Create symlink directory "foo" to point outside the root
let dir = "foo";
symlink_dir(tmpdir.path(), server.path().join(dir)).expect("Couldn't create symlink");
let resp = reqwest::blocking::get(format!("{}{}", server.url(), dir))?;
assert_eq!(resp.status(), 200);
let resp = reqwest::blocking::get(format!("{}{}/index.html", server.url(), dir))?;
assert_eq!(resp.status(), 200);
let resp = reqwest::blocking::get(server.url())?;
let paths = utils::retrive_index_paths(&resp.text()?);
assert!(!paths.is_empty());
assert!(paths.contains(&format!("{}/", dir)));
Ok(())
}

51
tests/tls.rs Normal file
View File

@@ -0,0 +1,51 @@
mod fixtures;
mod utils;
use assert_cmd::Command;
use fixtures::{server, Error, TestServer};
use predicates::str::contains;
use reqwest::blocking::ClientBuilder;
use rstest::rstest;
/// Can start the server with TLS and receive encrypted responses.
#[rstest]
#[case(server(&[
"--tls-cert", "tests/data/cert.pem",
"--tls-key", "tests/data/key_pkcs8.pem",
]))]
#[case(server(&[
"--tls-cert", "tests/data/cert.pem",
"--tls-key", "tests/data/key_pkcs1.pem",
]))]
fn tls_works(#[case] server: TestServer) -> Result<(), Error> {
let client = ClientBuilder::new()
.danger_accept_invalid_certs(true)
.build()?;
let resp = client.get(server.url()).send()?.error_for_status()?;
assert_resp_paths!(resp);
Ok(())
}
/// Wrong path for cert throws error.
#[rstest]
fn wrong_path_cert() -> Result<(), Error> {
Command::cargo_bin("dufs")?
.args(&["--tls-cert", "wrong", "--tls-key", "tests/data/key.pem"])
.assert()
.failure()
.stderr(contains("error: Failed to access `wrong`"));
Ok(())
}
/// Wrong paths for key throws errors.
#[rstest]
fn wrong_path_key() -> Result<(), Error> {
Command::cargo_bin("dufs")?
.args(&["--tls-cert", "tests/data/cert.pem", "--tls-key", "wrong"])
.assert()
.failure()
.stderr(contains("error: Failed to access `wrong`"));
Ok(())
}

57
tests/utils.rs Normal file
View File

@@ -0,0 +1,57 @@
use serde_json::Value;
use std::collections::HashSet;
#[macro_export]
macro_rules! assert_resp_paths {
($resp:ident) => {
assert_resp_paths!($resp, self::fixtures::FILES)
};
($resp:ident, $files:expr) => {
assert_eq!($resp.status(), 200);
let body = $resp.text()?;
let paths = self::utils::retrive_index_paths(&body);
assert!(!paths.is_empty());
for file in $files {
assert!(paths.contains(&file.to_string()));
}
};
}
#[macro_export]
macro_rules! fetch {
($method:literal, $url:expr) => {
reqwest::blocking::Client::new().request(hyper::Method::from_bytes($method)?, $url)
};
}
#[allow(dead_code)]
pub fn retrive_index_paths(index: &str) -> HashSet<String> {
retrive_index_paths_impl(index).unwrap_or_default()
}
#[allow(dead_code)]
pub fn encode_uri(v: &str) -> String {
let parts: Vec<_> = v.split('/').map(urlencoding::encode).collect();
parts.join("/")
}
fn retrive_index_paths_impl(index: &str) -> Option<HashSet<String>> {
let lines: Vec<&str> = index.lines().collect();
let line = lines.iter().find(|v| v.contains("DATA ="))?;
let value: Value = line[7..].parse().ok()?;
let paths = 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)
}

203
tests/webdav.rs Normal file
View File

@@ -0,0 +1,203 @@
mod fixtures;
mod utils;
use fixtures::{server, Error, TestServer, FILES};
use rstest::rstest;
use xml::escape::escape_str_pcdata;
#[rstest]
fn propfind_dir(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"PROPFIND", format!("{}dira", server.url())).send()?;
assert_eq!(resp.status(), 207);
let body = resp.text()?;
assert!(body.contains("<D:href>/dira/</D:href>"));
assert!(body.contains("<D:displayname>dira</D:displayname>"));
for f in FILES {
assert!(body.contains(&format!("<D:href>/dira/{}</D:href>", utils::encode_uri(f))));
assert!(body.contains(&format!(
"<D:displayname>{}</D:displayname>",
escape_str_pcdata(f)
)));
}
Ok(())
}
#[rstest]
fn propfind_dir_depth0(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"PROPFIND", format!("{}dira", server.url()))
.header("depth", "0")
.send()?;
assert_eq!(resp.status(), 207);
let body = resp.text()?;
assert!(body.contains("<D:href>/dira/</D:href>"));
assert!(body.contains("<D:displayname>dira</D:displayname>"));
assert_eq!(
body.lines()
.filter(|v| *v == "<D:status>HTTP/1.1 200 OK</D:status>")
.count(),
1
);
Ok(())
}
#[rstest]
fn propfind_404(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"PROPFIND", format!("{}404", server.url())).send()?;
assert_eq!(resp.status(), 404);
Ok(())
}
#[rstest]
fn propfind_file(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"PROPFIND", format!("{}test.html", server.url())).send()?;
assert_eq!(resp.status(), 207);
let body = resp.text()?;
assert!(body.contains("<D:href>/test.html</D:href>"));
assert!(body.contains("<D:displayname>test.html</D:displayname>"));
assert_eq!(
body.lines()
.filter(|v| *v == "<D:status>HTTP/1.1 200 OK</D:status>")
.count(),
1
);
Ok(())
}
#[rstest]
fn proppatch_file(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"PROPPATCH", format!("{}test.html", server.url())).send()?;
assert_eq!(resp.status(), 207);
let body = resp.text()?;
assert!(body.contains("<D:href>/test.html</D:href>"));
Ok(())
}
#[rstest]
fn proppatch_404(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"PROPPATCH", format!("{}404", server.url())).send()?;
assert_eq!(resp.status(), 404);
Ok(())
}
#[rstest]
fn mkcol_dir(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"MKCOL", format!("{}newdir", server.url())).send()?;
assert_eq!(resp.status(), 201);
Ok(())
}
#[rstest]
fn mkcol_not_allow_upload(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"MKCOL", format!("{}newdir", server.url())).send()?;
assert_eq!(resp.status(), 403);
Ok(())
}
#[rstest]
fn copy_file(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let new_url = format!("{}test2.html", server.url());
let resp = fetch!(b"COPY", format!("{}test.html", server.url()))
.header("Destination", &new_url)
.send()?;
assert_eq!(resp.status(), 204);
let resp = reqwest::blocking::get(new_url)?;
assert_eq!(resp.status(), 200);
Ok(())
}
#[rstest]
fn copy_not_allow_upload(server: TestServer) -> Result<(), Error> {
let new_url = format!("{}test2.html", server.url());
let resp = fetch!(b"COPY", format!("{}test.html", server.url()))
.header("Destination", &new_url)
.send()?;
assert_eq!(resp.status(), 403);
Ok(())
}
#[rstest]
fn copy_file_404(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let new_url = format!("{}test2.html", server.url());
let resp = fetch!(b"COPY", format!("{}404", server.url()))
.header("Destination", &new_url)
.send()?;
assert_eq!(resp.status(), 404);
Ok(())
}
#[rstest]
fn move_file(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let origin_url = format!("{}test.html", server.url());
let new_url = format!("{}test2.html", server.url());
let resp = fetch!(b"MOVE", &origin_url)
.header("Destination", &new_url)
.send()?;
assert_eq!(resp.status(), 204);
let resp = reqwest::blocking::get(new_url)?;
assert_eq!(resp.status(), 200);
let resp = reqwest::blocking::get(origin_url)?;
assert_eq!(resp.status(), 404);
Ok(())
}
#[rstest]
fn move_not_allow_upload(#[with(&["--allow-delete"])] server: TestServer) -> Result<(), Error> {
let origin_url = format!("{}test.html", server.url());
let new_url = format!("{}test2.html", server.url());
let resp = fetch!(b"MOVE", &origin_url)
.header("Destination", &new_url)
.send()?;
assert_eq!(resp.status(), 403);
Ok(())
}
#[rstest]
fn move_not_allow_delete(#[with(&["--allow-upload"])] server: TestServer) -> Result<(), Error> {
let origin_url = format!("{}test.html", server.url());
let new_url = format!("{}test2.html", server.url());
let resp = fetch!(b"MOVE", &origin_url)
.header("Destination", &new_url)
.send()?;
assert_eq!(resp.status(), 403);
Ok(())
}
#[rstest]
fn move_file_404(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let new_url = format!("{}test2.html", server.url());
let resp = fetch!(b"MOVE", format!("{}404", server.url()))
.header("Destination", &new_url)
.send()?;
assert_eq!(resp.status(), 404);
Ok(())
}
#[rstest]
fn lock_file(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"LOCK", format!("{}test.html", server.url())).send()?;
assert_eq!(resp.status(), 200);
let body = resp.text()?;
assert!(body.contains("<D:href>/test.html</D:href>"));
Ok(())
}
#[rstest]
fn lock_file_404(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"LOCK", format!("{}404", server.url())).send()?;
assert_eq!(resp.status(), 404);
Ok(())
}
#[rstest]
fn unlock_file(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"LOCK", format!("{}test.html", server.url())).send()?;
assert_eq!(resp.status(), 200);
Ok(())
}
#[rstest]
fn unlock_file_404(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"LOCK", format!("{}404", server.url())).send()?;
assert_eq!(resp.status(), 404);
Ok(())
}