Compare commits

...

42 Commits

Author SHA1 Message Date
sigoden
22cf74e3c0 update deps 2024-02-13 03:16:57 +00:00
sigoden
6a6ac37be4 chore: release v0.40.0 2024-02-13 01:19:40 +00:00
sigoden
920b70abc4 refactor: improve resolve_path and handle_assets, abandon guard_path (#360) 2024-02-07 16:27:22 +08:00
sigoden
015713bc6d chore: update deps 2024-02-06 09:32:31 +00:00
sigoden
3c75a9c4cc fix: guard req and destination path (#359) 2024-02-06 17:23:18 +08:00
sigoden
871e8276ff chore: add SECURITY.md 2024-02-05 00:09:25 +00:00
sigoden
f92c8ee91d refactor: improve invalid auth (#356) 2024-01-19 10:25:11 +08:00
sigoden
95eb648411 feat: revert supporting for forbidden permission (#352) 2024-01-17 11:31:26 +08:00
sigoden
3354b1face refactor: do not try to bind ipv6 if no ipv6 (#348) 2024-01-16 09:03:27 +08:00
sigoden
9b348fc945 chore: fix typos 2024-01-15 12:53:59 +00:00
sigoden
e1fabc7349 chore: update readme 2024-01-11 09:07:40 +00:00
sigoden
58a46f7c3a chore: release v0.39.0 (#345) 2024-01-11 16:50:25 +08:00
sigoden
ef757281b3 chore: release v0.39.0 2024-01-11 08:31:56 +00:00
sigoden
de0614816a refactor: propfind with auth no need to list all (#344) 2024-01-11 16:10:10 +08:00
sigoden
81d2c49e3f chore: update bug_report issue template 2024-01-11 07:04:44 +00:00
sigoden
ee21894452 feat: supports resumable uploads (#343) 2024-01-11 14:56:30 +08:00
sigoden
0ac0c048ec fix: corrupted zip when downloading large folders (#337) 2024-01-07 10:50:15 +08:00
sigoden
17063454d3 chore: update bug_report issue tempalte 2024-01-05 00:37:41 +00:00
sigoden
af347f9cf0 feat: auth supports forbidden permissions (#329) 2023-12-23 18:36:46 +08:00
sigoden
006e03ed30 fix: serve files with names containing newline char (#328) 2023-12-23 15:40:41 +08:00
sigoden
77f86a4c60 fix: auth precedence (#325) 2023-12-21 17:28:13 +08:00
sigoden
a66f95b39f chore: log error during connection 2023-12-21 08:08:15 +00:00
sigoden
52506bc01f refactor: optimize http range parsing and handling (#323) 2023-12-21 15:46:55 +08:00
sigoden
270cc0cba2 feat: upgrade to hyper 1.0 (#321) 2023-12-21 14:24:20 +08:00
sigoden
5988442d5c chore: remove debug print 2023-12-14 11:08:10 +00:00
sigoden
3873f4794a feat: add --compress option (#319) 2023-12-14 18:59:28 +08:00
plantatorbob
cd84dff87f fix: upload more than 100 files in directory (#317) 2023-12-11 18:28:11 +08:00
sigoden
8590f3e841 chore: improve readme 2023-12-09 09:17:36 +00:00
sigoden
44a4ddf973 refactor: change the value name of --config (#313) 2023-12-07 15:14:41 +08:00
sigoden
37800f630d refactor: change the format of www-authenticate (#312) 2023-12-07 15:04:14 +08:00
sigoden
5c850256f4 feat: empty search ?q= list all paths (#311) 2023-12-07 06:55:17 +08:00
sigoden
0cec573579 chore: release v0.38.0 2023-11-29 07:49:50 +08:00
sigoden
073b098111 feat: ui supports view file (#301) 2023-11-28 07:14:53 +08:00
sigoden
6ff8b29b69 feat: more flexible config values (#299) 2023-11-27 04:24:25 +08:00
sigoden
7584fe3d08 feat: deprecate the use of | to separate auth rules (#298) 2023-11-26 22:15:49 +08:00
sigoden
653cd167d0 feat: password can contain : @ | (#297) 2023-11-26 20:47:57 +08:00
sigoden
ab29e39148 chore: trivial updates 2023-11-26 15:04:12 +08:00
sigoden
f8d6859354 refactor: ui improve uploading progress (#296) 2023-11-26 10:23:37 +08:00
sigoden
130435c387 chore: update readme 2023-11-25 19:07:37 +08:00
sigoden
afdfde01f0 fix: unable to start if config file omit bind/port fields (#294) 2023-11-25 18:54:36 +08:00
sigoden
ae97c714d6 refactor: ui change the cursor for upload-btn to a pointer (#291) 2023-11-21 16:24:59 +08:00
sigoden
c352dab470 refactor: take improvements from the edge browser (#289) 2023-11-15 19:44:44 +08:00
29 changed files with 2190 additions and 1188 deletions

View File

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

View File

@@ -2,6 +2,65 @@
All notable changes to this project will be documented in this file.
## [0.40.0] - 2024-02-13
### Bug Fixes
- Guard req and destination path ([#359](https://github.com/sigoden/dufs/issues/359))
### Features
- Revert supporting for forbidden permission ([#352](https://github.com/sigoden/dufs/issues/352))
### Refactor
- Do not try to bind ipv6 if no ipv6 ([#348](https://github.com/sigoden/dufs/issues/348))
- Improve invalid auth ([#356](https://github.com/sigoden/dufs/issues/356))
- Improve resolve_path and handle_assets, abandon guard_path ([#360](https://github.com/sigoden/dufs/issues/360))
## [0.39.0] - 2024-01-11
### Bug Fixes
- Upload more than 100 files in directory ([#317](https://github.com/sigoden/dufs/issues/317))
- Auth precedence ([#325](https://github.com/sigoden/dufs/issues/325))
- Serve files with names containing newline char ([#328](https://github.com/sigoden/dufs/issues/328))
- Corrupted zip when downloading large folders ([#337](https://github.com/sigoden/dufs/issues/337))
### Features
- Empty search `?q=` list all paths ([#311](https://github.com/sigoden/dufs/issues/311))
- Add `--compress` option ([#319](https://github.com/sigoden/dufs/issues/319))
- Upgrade to hyper 1.0 ([#321](https://github.com/sigoden/dufs/issues/321))
- Auth supports forbidden permissions ([#329](https://github.com/sigoden/dufs/issues/329))
- Supports resumable uploads ([#343](https://github.com/sigoden/dufs/issues/343))
### Refactor
- Change the format of www-authenticate ([#312](https://github.com/sigoden/dufs/issues/312))
- Change the value name of `--config` ([#313](https://github.com/sigoden/dufs/issues/313))
- Optimize http range parsing and handling ([#323](https://github.com/sigoden/dufs/issues/323))
- Propfind with auth no need to list all ([#344](https://github.com/sigoden/dufs/issues/344))
## [0.38.0] - 2023-11-28
### Bug Fixes
- Unable to start if config file omit bind/port fields ([#294](https://github.com/sigoden/dufs/issues/294))
### Features
- Password can contain `:` `@` `|` ([#297](https://github.com/sigoden/dufs/issues/297))
- Deprecate the use of `|` to separate auth rules ([#298](https://github.com/sigoden/dufs/issues/298))
- More flexible config values ([#299](https://github.com/sigoden/dufs/issues/299))
- Ui supports view file ([#301](https://github.com/sigoden/dufs/issues/301))
### Refactor
- Take improvements from the edge browser ([#289](https://github.com/sigoden/dufs/issues/289))
- Ui change the cursor for upload-btn to a pointer ([#291](https://github.com/sigoden/dufs/issues/291))
- Ui improve uploading progress ([#296](https://github.com/sigoden/dufs/issues/296))
## [0.37.1] - 2023-11-08
### Bug Fixes

921
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "dufs"
version = "0.37.1"
version = "0.40.0"
edition = "2021"
authors = ["sigoden <sigoden@gmail.com>"]
description = "Dufs is a distinctive utility file server"
@@ -11,26 +11,25 @@ categories = ["command-line-utilities", "web-programming::http-server"]
keywords = ["static", "file", "server", "webdav", "cli"]
[dependencies]
clap = { version = "4", features = ["wrap_help", "env"] }
clap_complete = "4"
clap = { version = "~4.4", features = ["wrap_help", "env"] }
clap_complete = "~4.4"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util", "signal"]}
tokio-util = { version = "0.7", features = ["io-util", "compat"] }
hyper = { version = "0.14", features = ["http1", "server", "tcp", "stream"] }
hyper = { version = "1.0", features = ["http1", "server"] }
percent-encoding = "2.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
futures = "0.3"
async_zip = { version = "0.0.15", default-features = false, features = ["deflate", "chrono", "tokio"] }
headers = "0.3"
futures-util = { version = "0.3", default-features = false, features = ["alloc"] }
async_zip = { version = "0.0.16", default-features = false, features = ["deflate", "bzip2", "xz", "chrono", "tokio"] }
headers = "0.4"
mime_guess = "2.0"
if-addrs = "0.10.1"
rustls = { version = "0.21", default-features = false, features = ["tls12"], optional = true }
rustls-pemfile = { version = "1", optional = true }
tokio-rustls = { version = "0.24", optional = true }
if-addrs = "0.11"
rustls-pemfile = { version = "2.0", optional = true }
tokio-rustls = { version = "0.25", optional = true }
md5 = "0.7"
lazy_static = "1.4"
uuid = { version = "1.4", features = ["v4", "fast-rng"] }
uuid = { version = "1.7", features = ["v4", "fast-rng"] }
urlencoding = "2.1"
xml-rs = "0.8"
log = "0.4"
@@ -42,15 +41,21 @@ alphanumeric-sort = "1.4"
content_inspector = "0.2"
anyhow = "1.0"
chardetng = "0.1"
glob = "0.3.1"
indexmap = "2.0"
serde_yaml = "0.9.27"
sha-crypt = "0.5.0"
base64 = "0.21.5"
glob = "0.3"
indexmap = "2.2"
serde_yaml = "0.9"
sha-crypt = "0.5"
base64 = "0.21"
smart-default = "0.7"
rustls-pki-types = "1.2"
hyper-util = { version = "0.1", features = ["server-auto", "tokio"] }
http-body-util = "0.1"
bytes = "1.5"
pin-project-lite = "0.2"
[features]
default = ["tls"]
tls = ["rustls", "rustls-pemfile", "tokio-rustls"]
tls = ["rustls-pemfile", "tokio-rustls"]
[dev-dependencies]
assert_cmd = "2"
@@ -60,7 +65,7 @@ port_check = "0.1"
rstest = "0.18"
regex = "1"
url = "2"
diqwest = { version = "1", features = ["blocking", "rustls-tls"], default-features = false }
diqwest = { version = "2.0", features = ["blocking"], default-features = false }
predicates = "3"
[profile.release]

155
README.md
View File

@@ -13,7 +13,7 @@ Dufs is a distinctive utility file server that supports static serving, uploadin
- Download folder as zip file
- Upload files and folders (Drag & Drop)
- Create/Edit/Search files
- Partial responses (Parallel/Resume download)
- Resumable/partial uploads/downloads
- Access control
- Support https
- Support webdav
@@ -54,7 +54,7 @@ Arguments:
[serve-path] Specific path to serve [default: .]
Options:
-c, --config <config> Specify configuration file
-c, --config <file> Specify configuration file
-b, --bind <addrs> Specify bind address or unix socket
-p, --port <port> Specify port to listen on [default: 5000]
--path-prefix <path> Specify a path prefix
@@ -72,6 +72,7 @@ Options:
--render-spa Serve SPA(Single Page Application)
--assets <path> Set the path to the assets directory for overriding the built-in assets
--log-format <format> Customize http log format
--compress <level> Set zip compress level [default: low] [possible values: none, low, medium, high]
--completions <shell> Print shell completion script for <shell> [possible values: bash, elvish, fish, powershell, zsh]
--tls-cert <path> Path to an SSL/TLS certificate to serve with HTTPS
--tls-key <path> Path to the SSL/TLS certificate's private key
@@ -81,7 +82,7 @@ Options:
## Examples
Serve current working directory in readonly mode
Serve current working directory in read-only mode
```
dufs
@@ -150,52 +151,66 @@ dufs --tls-cert my.crt --tls-key my.key
Upload a file
```
```sh
curl -T path-to-file http://127.0.0.1:5000/new-path/path-to-file
```
Download a file
```
```sh
curl http://127.0.0.1:5000/path-to-file
```
Download a folder as zip file
```
```sh
curl -o path-to-folder.zip http://127.0.0.1:5000/path-to-folder?zip
```
Delete a file/folder
```
```sh
curl -X DELETE http://127.0.0.1:5000/path-to-file-or-folder
```
Create a directory
```
```sh
curl -X MKCOL https://127.0.0.1:5000/path-to-folder
```
Move the file/folder to the new path
```
```sh
curl -X MOVE https://127.0.0.1:5000/path -H "Destination: https://127.0.0.1:5000/new-path"
```
List/search directory contents
```
curl http://127.0.0.1:5000?simple # output names only, just like `ls -1`
```sh
curl http://127.0.0.1:5000?q=Dockerfile # search for files, similar to `find -name Dockerfile`
curl http://127.0.0.1:5000?simple # output names only, similar to `ls -1`
curl http://127.0.0.1:5000?json # output paths in json format
curl http://127.0.0.1:5000?q=Dockerfile&simple # search for files, just like `find -name Dockerfile`
```
With authorization
With authorization (Both basic or digest auth works)
```sh
curl http://127.0.0.1:5000/file --user user:pass # basic auth
curl http://127.0.0.1:5000/file --user user:pass --digest # digest auth
```
curl http://192.168.8.10:5000/file --user user:pass # basic auth
curl http://192.168.8.10:5000/file --user user:pass --digest # digest auth
Resumable downloads
```sh
curl -C- -o file http://127.0.0.1:5000/file
```
Resumable uploads
```sh
upload_offset=$(curl -I -s http://127.0.0.1:5000/file | tr -d '\r' | sed -n 's/content-length: //p')
dd skip=$upload_offset if=file status=none ibs=1 | \
curl -X PATCH -H "X-Update-Range: append" --data-binary @- http://127.0.0.1:5000/file
```
<details>
@@ -206,46 +221,21 @@ curl http://192.168.8.10:5000/file --user user:pass --digest # digest aut
Dufs supports account based access control. You can control who can do what on which path with `--auth`/`-a`.
```
dufs -a user:pass@path1:rw,path2|user2:pass2@path1
dufs -a user:pass@path1:rw,path2 -a user2:pass2@path1
dufs -a admin:admin@/:rw -a guest:guest@/
dufs -a user:pass@/:rw,/dir1 -a @/
```
1. Multiple rules are separated by "|"
2. User and pass are the account name and password, if omitted, it is an anonymous user
3. One rule can set multiple paths, separated by ","
4. Add `:rw` after the path to indicate that the path has read and write permissions, otherwise the path has readonly permissions.
1. Use `@` to separate the account and paths. No account means anonymous user.
2. Use `:` to separate the username and password of the account.
3. Use `,` to separate paths.
4. Use path suffix `:rw`/`:ro` set permissions: `read-write`/`read-only`. `:ro` can be omitted.
```
dufs -A -a admin:admin@/:rw
```
`admin` has all permissions for all paths.
- `-a admin:admin@/:rw`: `admin` has complete permissions for all paths.
- `-a guest:guest@/`: `guest` has read-only permissions for all paths.
- `-a user:pass@/:rw,/dir1`: `user` has read-write permissions for `/*`, has read-only permissions for `/dir1/*`.
- `-a @/`: All paths is publicly accessible, everyone can view/download it.
```
dufs -A -a admin:admin@/:rw -a guest:guest@/
```
`guest` has readonly permissions for all paths.
```
dufs -A -a admin:admin@/:rw -a @/
```
All paths is public, everyone can view/download it.
```
dufs -A -a admin:admin@/:rw -a user1:pass1@/user1:rw -a user2:pass2@/user2
dufs -A -a "admin:admin@/:rw|user1:pass1@/user1:rw|user2:pass2@/user2"
```
`user1` has all permissions for `/user1/*` path.
`user2` has all permissions for `/user2/*` path.
```
dufs -A -a user:pass@/dir1:rw,/dir2:rw,dir3
```
`user` has all permissions for `/dir1/*` and `/dir2/*`, has readonly permissions for `/dir3/`.
```
dufs -A -a admin:admin@/
```
Since dufs only allows viewing/downloading, `admin` can only view/download files.
> There are no restrictions on using ':' and '@' characters in a password. For example, `user:pa:ss@1@/:rw` is valid, the password is `pa:ss@1`.
#### Hashed Password
@@ -261,13 +251,13 @@ $6$qCAVUG7yn7t/hH4d$BWm8r5MoDywNmDP/J3V2S2a6flmKHC1IpblfoqZfuK.LtLBZ0KFXP9QIfJP8
Use hashed password
```
dufs -A -a 'admin:$6$qCAVUG7yn7t/hH4d$BWm8r5MoDywNmDP/J3V2S2a6flmKHC1IpblfoqZfuK.LtLBZ0KFXP9QIfJP8RqL8MCw4isdheoAMTuwOz.pAO/@/:rw'
dufs -a 'admin:$6$qCAVUG7yn7t/hH4d$BWm8r5MoDywNmDP/J3V2S2a6flmKHC1IpblfoqZfuK.LtLBZ0KFXP9QIfJP8RqL8MCw4isdheoAMTuwOz.pAO/@/:rw'
```
Two important things for hashed passwords:
1. Dufs only supports SHA-512 hashed passwords, so ensure that the password string always starts with `$6$`.
2. Digest auth does not work with hashed passwords.
1. Dufs only supports sha-512 hashed passwords, so ensure that the password string always starts with `$6$`.
2. Digest authentication does not function properly with hashed passwords.
### Hide Paths
@@ -281,9 +271,10 @@ dufs --hidden .git,.DS_Store,tmp
> The glob used in --hidden only matches file and directory names, not paths. So `--hidden dir1/file` is invalid.
```sh
dufs --hidden '.*' # hidden dotfiles
dufs --hidden '*/' # hidden all folders
dufs --hidden '*.log,*.lock' # hidden by exts
dufs --hidden '.*' # hidden dotfiles
dufs --hidden '*/' # hidden all folders
dufs --hidden '*.log,*.lock' # hidden by exts
dufs --hidden '*.log' --hidden '*.lock'
```
### Log Format
@@ -332,27 +323,28 @@ dufs --log-format '$remote_addr $remote_user "$request" $status' -a /@admin:admi
All options can be set using environment variables prefixed with `DUFS_`.
```
[serve-path] DUFS_SERVE_PATH=/dir
--config <path> DUFS_CONFIG=config.yaml
-b, --bind <addrs> DUFS_BIND=0.0.0.0
-p, --port <port> DUFS_PORT=5000
--path-prefix <path> DUFS_PATH_PREFIX=/path
--hidden <value> DUFS_HIDDEN=*.log
-a, --auth <rules> DUFS_AUTH="admin:admin@/:rw|@/"
-A, --allow-all DUFS_ALLOW_ALL=true
--allow-upload DUFS_ALLOW_UPLOAD=true
--allow-delete DUFS_ALLOW_DELETE=true
--allow-search DUFS_ALLOW_SEARCH=true
--allow-symlink DUFS_ALLOW_SYMLINK=true
--allow-archive DUFS_ALLOW_ARCHIVE=true
--enable-cors DUFS_ENABLE_CORS=true
--render-index DUFS_RENDER_INDEX=true
--render-try-index DUFS_RENDER_TRY_INDEX=true
--render-spa DUFS_RENDER_SPA=true
--assets <path> DUFS_ASSETS=/assets
--log-format <format> DUFS_LOG_FORMAT=""
--tls-cert <path> DUFS_TLS_CERT=cert.pem
--tls-key <path> DUFS_TLS_KEY=key.pem
[serve-path] DUFS_SERVE_PATH="."
--config <file> DUFS_CONFIG=config.yaml
-b, --bind <addrs> DUFS_BIND=0.0.0.0
-p, --port <port> DUFS_PORT=5000
--path-prefix <path> DUFS_PATH_PREFIX=/static
--hidden <value> DUFS_HIDDEN=tmp,*.log,*.lock
-a, --auth <rules> DUFS_AUTH="admin:admin@/:rw|@/"
-A, --allow-all DUFS_ALLOW_ALL=true
--allow-upload DUFS_ALLOW_UPLOAD=true
--allow-delete DUFS_ALLOW_DELETE=true
--allow-search DUFS_ALLOW_SEARCH=true
--allow-symlink DUFS_ALLOW_SYMLINK=true
--allow-archive DUFS_ALLOW_ARCHIVE=true
--enable-cors DUFS_ENABLE_CORS=true
--render-index DUFS_RENDER_INDEX=true
--render-try-index DUFS_RENDER_TRY_INDEX=true
--render-spa DUFS_RENDER_SPA=true
--assets <path> DUFS_ASSETS=/assets
--log-format <format> DUFS_LOG_FORMAT=""
--compress <compress> DUFS_COMPRESS="low"
--tls-cert <path> DUFS_TLS_CERT=cert.pem
--tls-key <path> DUFS_TLS_KEY=key.pem
```
## Configuration File
@@ -363,8 +355,7 @@ The following are the configuration items:
```yaml
serve-path: '.'
bind:
- 192.168.8.10
bind: 0.0.0.0
port: 5000
path-prefix: /dufs
hidden:
@@ -374,6 +365,7 @@ hidden:
auth:
- admin:admin@/:rw
- user:pass@/src:rw,/share
- '@/' # According to the YAML spec, quoting is required.
allow-all: false
allow-upload: true
allow-delete: true
@@ -386,6 +378,7 @@ render-try-index: true
render-spa: true
assets: ./assets/
log-format: '$remote_addr "$request" $status $http_user_agent'
compress: low
tls-cert: tests/data/cert.pem
tls-key: tests/data/key_pkcs1.pem
```
@@ -409,7 +402,7 @@ Your assets folder must contains a `index.html` file.
## License
Copyright (c) 2022 dufs-developers.
Copyright (c) 2022-2024 dufs-developers.
dufs is made available under the terms of either the MIT License or the Apache License 2.0, at your option.

21
SECURITY.md Normal file
View File

@@ -0,0 +1,21 @@
# Security Policy
## Supported Versions
The latest release of *dufs* is supported. The fixes for any security issues found will be included
in the next release.
## Reporting a Vulnerability
Please [use *dufs*'s security advisory reporting tool provided by
GitHub](https://github.com/sigoden/dufs/security/advisories/new) to report security issues.
We strive to fix security issues as quickly as possible. Across the industry, often the developers'
slowness in developing and releasing a fix is the biggest delay in the process; we take pride in
minimizing this delay as much as we practically can. We encourage you to also minimize the delay
between when you find an issue and when you contact us. You do not need to convince us to take your
report seriously. You don't need to create a PoC or a patch if that would slow down your reporting.
You don't need an elaborate write-up. A short, informal note about the issue is good. We can always
communicate later to fill in any details we need after that first note is shared with us.

View File

@@ -73,6 +73,10 @@ body {
display: none;
}
.upload-file label {
cursor: pointer;
}
.searchbar {
display: flex;
flex-wrap: nowrap;
@@ -103,11 +107,6 @@ body {
cursor: pointer;
}
.upload-status span {
width: 70px;
display: inline-block;
}
.main {
padding: 3.3em 1em 0;
}
@@ -134,6 +133,10 @@ body {
padding-left: 0.6em;
}
.cell-status span {
display: inline-block;
}
.paths-table thead a {
color: unset;
text-decoration: none;
@@ -208,6 +211,7 @@ body {
height: calc(100vh - 5rem);
border: 1px solid #ced4da;
outline: none;
padding: 5px;
}
.toolbox-right {
@@ -217,6 +221,7 @@ body {
.save-btn {
cursor: pointer;
-webkit-user-select: none;
user-select: none;
}
@@ -233,6 +238,10 @@ body {
font-style: italic;
}
.retry-btn {
cursor: pointer;
}
@media (min-width: 768px) {
.path a {
min-width: 400px;

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
@@ -48,7 +48,7 @@
d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z" />
</svg>
</label>
<input type="file" id="file" name="file" multiple>
<input type="file" id="file" title="Upload files" name="file" multiple>
</div>
<div class="control new-folder hidden" title="New folder">
<svg width="16" height="16" viewBox="0 0 16 16">
@@ -74,7 +74,7 @@
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>
<input id="search" name="q" type="text" maxlength="128" autocomplete="off" tabindex="1">
<input id="search" title="Searching for folders or files" name="q" type="text" maxlength="128" autocomplete="off" tabindex="1">
<input type="submit" hidden />
</form>
<div class="toolbox-right">
@@ -122,7 +122,7 @@
</div>
<div class="editor-page hidden">
<div class="not-editable hidden"></div>
<textarea class="editor hidden" cols="10"></textarea>
<textarea id="editor" class="editor hidden" aria-label="Editor" cols="10"></textarea>
</div>
</div>
<script>

View File

@@ -10,7 +10,7 @@
* @typedef {object} DATA
* @property {string} href
* @property {string} uri_prefix
* @property {"Index" | "Edit"} kind
* @property {"Index" | "Edit" | "View"} kind
* @property {PathItem[]} paths
* @property {boolean} allow_upload
* @property {boolean} allow_delete
@@ -55,9 +55,15 @@ const ICONS = {
download: `<svg width="16" height="16" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg>`,
move: `<svg width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M1.5 1.5A.5.5 0 0 0 1 2v4.8a2.5 2.5 0 0 0 2.5 2.5h9.793l-3.347 3.346a.5.5 0 0 0 .708.708l4.2-4.2a.5.5 0 0 0 0-.708l-4-4a.5.5 0 0 0-.708.708L13.293 8.3H3.5A1.5 1.5 0 0 1 2 6.8V2a.5.5 0 0 0-.5-.5z"/></svg>`,
edit: `<svg width="16" height="16" viewBox="0 0 16 16"><path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/></svg>`,
delete: `<svg width="16" height="16" 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>`,
delete: `<svg width="16" height="16" viewBox="0 0 16 16"><path d="M6.854 7.146a.5.5 0 1 0-.708.708L7.293 9l-1.147 1.146a.5.5 0 0 0 .708.708L8 9.707l1.146 1.147a.5.5 0 0 0 .708-.708L8.707 9l1.147-1.146a.5.5 0 0 0-.708-.708L8 8.293 6.854 7.146z"/><path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5v2z"/></svg>`,
view: `<svg width="16" height="16" viewBox="0 0 16 16"><path d="M4 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 1h8a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1"/></svg>`,
}
/**
* @type Map<string, Uploader>
*/
const failUploaders = new Map();
/**
* @type Element
*/
@@ -113,32 +119,38 @@ function ready() {
document.title = `Edit ${DATA.href} - Dufs`;
document.querySelector(".editor-page").classList.remove("hidden");;
setupEditPage();
setupEditorPage();
} else if (DATA.kind == "View") {
document.title = `View ${DATA.href} - Dufs`;
document.querySelector(".editor-page").classList.remove("hidden");;
setupEditorPage();
}
}
class Uploader {
/**
*
* @param {File} file
* @param {string[]} dirs
*
* @param {File} file
* @param {string[]} pathParts
*/
constructor(file, dirs) {
constructor(file, pathParts) {
/**
* @type Element
*/
this.$uploadStatus = null
this.uploaded = 0;
this.uploadOffset = 0;
this.lastUptime = 0;
this.name = [...dirs, file.name].join("/");
this.name = [...pathParts, file.name].join("/");
this.idx = Uploader.globalIdx++;
this.file = file;
this.url = newUrl(this.name);
}
upload() {
const { idx, name } = this;
const url = newUrl(name);
const { idx, name, url } = this;
const encodedName = encodedStr(name);
$uploadersTable.insertAdjacentHTML("beforeend", `
<tr id="upload${idx}" class="uploader">
@@ -154,13 +166,25 @@ class Uploader {
$emptyFolder.classList.add("hidden");
this.$uploadStatus = document.getElementById(`uploadStatus${idx}`);
this.$uploadStatus.innerHTML = '-';
this.$uploadStatus.addEventListener("click", e => {
const nodeId = e.target.id;
const matches = /^retry(\d+)$/.exec(nodeId);
if (matches) {
const id = parseInt(matches[1]);
let uploader = failUploaders.get(id);
if (uploader) uploader.retry();
}
});
Uploader.queues.push(this);
Uploader.runQueue();
}
ajax() {
const url = newUrl(this.name);
const { url } = this;
this.uploaded = 0;
this.lastUptime = Date.now();
const ajax = new XMLHttpRequest();
ajax.upload.addEventListener("progress", e => this.progress(e), false);
ajax.addEventListener("readystatechange", () => {
@@ -168,37 +192,64 @@ class Uploader {
if (ajax.status >= 200 && ajax.status < 300) {
this.complete();
} else {
this.fail();
if (ajax.status != 0) {
this.fail(`${ajax.status} ${ajax.statusText}`);
}
}
}
})
ajax.addEventListener("error", () => this.fail(), false);
ajax.addEventListener("abort", () => this.fail(), false);
ajax.open("PUT", url);
ajax.send(this.file);
if (this.uploadOffset > 0) {
ajax.open("PATCH", url);
ajax.setRequestHeader("X-Update-Range", "append");
ajax.send(this.file.slice(this.uploadOffset));
} else {
ajax.open("PUT", url);
ajax.send(this.file);
// setTimeout(() => ajax.abort(), 3000);
}
}
async retry() {
const { url } = this;
let res = await fetch(url, {
method: "HEAD",
});
let uploadOffset = 0;
if (res.status == 200) {
let value = res.headers.get("content-length");
uploadOffset = parseInt(value) || 0;
}
this.uploadOffset = uploadOffset;
this.ajax()
}
progress(event) {
const now = Date.now();
const speed = (event.loaded - this.uploaded) / (now - this.lastUptime) * 1000;
const [speedValue, speedUnit] = formatSize(speed);
const speedText = `${speedValue}${speedUnit.toLowerCase()}/s`;
const progress = formatPercent((event.loaded / event.total) * 100);
const speedText = `${speedValue} ${speedUnit}/s`;
const progress = formatPercent(((event.loaded + this.uploadOffset) / this.file.size) * 100);
const duration = formatDuration((event.total - event.loaded) / speed)
this.$uploadStatus.innerHTML = `<span>${speedText}</span><span>${progress}</span><span>${duration}</span>`;
this.$uploadStatus.innerHTML = `<span style="width: 80px;">${speedText}</span><span>${progress} ${duration}</span>`;
this.uploaded = event.loaded;
this.lastUptime = now;
}
complete() {
this.$uploadStatus.innerHTML = ``;
const $uploadStatusNew = this.$uploadStatus.cloneNode(true);
$uploadStatusNew.innerHTML = ``;
this.$uploadStatus.parentNode.replaceChild($uploadStatusNew, this.$uploadStatus);
this.$uploadStatus = null;
failUploaders.delete(this.idx);
Uploader.runnings--;
Uploader.runQueue();
}
fail() {
this.$uploadStatus.innerHTML = ``;
fail(reason = "") {
this.$uploadStatus.innerHTML = `<span style="width: 20px;" title="${reason}">✗</span><span class="retry-btn" id="retry${this.idx}" title="Retry">↻</span>`;
failUploaders.set(this.idx, this);
Uploader.runnings--;
Uploader.runQueue();
}
@@ -234,7 +285,7 @@ Uploader.runQueue = async () => {
/**
* Add breadcrumb
* @param {string} href
* @param {string} href
* @param {string} uri_prefix
*/
function addBreadcrumb(href, uri_prefix) {
@@ -257,7 +308,7 @@ function addBreadcrumb(href, uri_prefix) {
}
const encodedName = encodedStr(name);
if (i === 0) {
$breadcrumb.insertAdjacentHTML("beforeend", `<a href="${path}"><svg width="16" height="16" viewBox="0 0 16 16"><path d="M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5z"/></svg></a>`);
$breadcrumb.insertAdjacentHTML("beforeend", `<a href="${path}" title="Root"><svg width="16" height="16" viewBox="0 0 16 16"><path d="M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5z"/></svg></a>`);
} else if (i === len - 1) {
$breadcrumb.insertAdjacentHTML("beforeend", `<b>${encodedName}</b>`);
} else {
@@ -359,8 +410,8 @@ function renderPathsTableBody() {
/**
* Add pathitem
* @param {PathItem} file
* @param {number} index
* @param {PathItem} file
* @param {number} index
*/
function addPath(file, index) {
const encodedName = encodedStr(file.name);
@@ -369,6 +420,7 @@ function addPath(file, index) {
let actionDownload = "";
let actionMove = "";
let actionEdit = "";
let actionView = "";
let isDir = file.path_type.endsWith("Dir");
if (isDir) {
url += "/";
@@ -394,9 +446,13 @@ function addPath(file, index) {
actionDelete = `
<div onclick="deletePath(${index})" class="action-btn" id="deleteBtn${index}" title="Delete">${ICONS.delete}</div>`;
}
if (!actionEdit && !isDir) {
actionView = `<a class="action-btn" title="View file" target="_blank" href="${url}?view">${ICONS.view}</a>`;
}
let actionCell = `
<td class="cell-actions">
${actionDownload}
${actionView}
${actionMove}
${actionDelete}
${actionEdit}
@@ -504,32 +560,40 @@ function setupNewFile() {
});
}
async function setupEditPage() {
async function setupEditorPage() {
const url = baseUrl();
const $download = document.querySelector(".download");
$download.classList.remove("hidden");
$download.href = url;
const $moveFile = document.querySelector(".move-file");
$moveFile.classList.remove("hidden");
$moveFile.addEventListener("click", async () => {
const query = location.href.slice(url.length);
const newFileUrl = await doMovePath(url);
if (newFileUrl) {
location.href = newFileUrl + query;
}
});
const $deleteFile = document.querySelector(".delete-file");
$deleteFile.classList.remove("hidden");
$deleteFile.addEventListener("click", async () => {
const url = baseUrl();
const name = baseName(url);
await doDeletePath(name, url, () => {
location.href = location.href.split("/").slice(0, -1).join("/");
if (DATA.kind == "Edit") {
const $moveFile = document.querySelector(".move-file");
$moveFile.classList.remove("hidden");
$moveFile.addEventListener("click", async () => {
const query = location.href.slice(url.length);
const newFileUrl = await doMovePath(url);
if (newFileUrl) {
location.href = newFileUrl + query;
}
});
})
const $deleteFile = document.querySelector(".delete-file");
$deleteFile.classList.remove("hidden");
$deleteFile.addEventListener("click", async () => {
const url = baseUrl();
const name = baseName(url);
await doDeletePath(name, url, () => {
location.href = location.href.split("/").slice(0, -1).join("/");
});
})
const $saveBtn = document.querySelector(".save-btn");
$saveBtn.classList.remove("hidden");
$saveBtn.addEventListener("click", saveChange);
} else if (DATA.kind == "View") {
$editor.readonly = true;
}
if (!DATA.editable) {
const $notEditable = document.querySelector(".not-editable");
@@ -544,10 +608,6 @@ async function setupEditPage() {
return;
}
const $saveBtn = document.querySelector(".save-btn");
$saveBtn.classList.remove("hidden");
$saveBtn.addEventListener("click", saveChange);
$editor.classList.remove("hidden");
try {
const res = await fetch(baseUrl());
@@ -568,8 +628,8 @@ async function setupEditPage() {
/**
* Delete path
* @param {number} index
* @returns
* @param {number} index
* @returns
*/
async function deletePath(index) {
const file = DATA.paths[index];
@@ -601,8 +661,8 @@ async function doDeletePath(name, url, cb) {
/**
* Move path
* @param {number} index
* @returns
* @param {number} index
* @returns
*/
async function movePath(index) {
const file = DATA.paths[index];
@@ -679,7 +739,7 @@ async function checkAuth() {
/**
* Create a folder
* @param {string} name
* @param {string} name
*/
async function createFolder(name) {
const url = newUrl(name);
@@ -717,8 +777,16 @@ async function addFileEntries(entries, dirs) {
new Uploader(file, dirs).upload();
});
} else if (entry.isDirectory) {
const dirReader = entry.createReader()
dirReader.readEntries(entries => addFileEntries(entries, [...dirs, entry.name]));
const dirReader = entry.createReader();
const successCallback = entries => {
if (entries.length > 0) {
addFileEntries(entries, [...dirs, entry.name]);
dirReader.readEntries(successCallback);
}
};
dirReader.readEntries(successCallback);
}
}
}
@@ -778,7 +846,7 @@ function padZero(value, size) {
}
function formatSize(size) {
if (size == null) return []
if (size == null) return [0, "B"]
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
if (size == 0) return [0, "B"];
const i = parseInt(Math.floor(Math.log(size) / Math.log(1024)));

View File

@@ -1,8 +1,10 @@
use anyhow::{bail, Context, Result};
use clap::builder::PossibleValuesParser;
use clap::{value_parser, Arg, ArgAction, ArgMatches, Command};
use async_zip::Compression;
use clap::builder::{PossibleValue, PossibleValuesParser};
use clap::{value_parser, Arg, ArgAction, ArgMatches, Command, ValueEnum};
use clap_complete::{generate, Generator, Shell};
use serde::{Deserialize, Deserializer};
use smart_default::SmartDefault;
use std::env;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
@@ -34,7 +36,8 @@ pub fn build_cli() -> Command {
.short('c')
.long("config")
.value_parser(value_parser!(PathBuf))
.help("Specify configuration file"),
.help("Specify configuration file")
.value_name("file"),
)
.arg(
Arg::new("bind")
@@ -70,6 +73,8 @@ pub fn build_cli() -> Command {
.env("DUFS_HIDDEN")
.hide_env(true)
.long("hidden")
.action(ArgAction::Append)
.value_delimiter(',')
.help("Hide paths from directory listings, e.g. tmp,*.log,*.lock")
.value_name("value"),
)
@@ -81,7 +86,6 @@ pub fn build_cli() -> Command {
.long("auth")
.help("Add auth roles, e.g. user:pass@/dir1:rw,/dir2")
.action(ArgAction::Append)
.value_delimiter('|')
.value_name("rules"),
)
.arg(
@@ -193,6 +197,15 @@ pub fn build_cli() -> Command {
.value_name("format")
.help("Customize http log format"),
)
.arg(
Arg::new("compress")
.env("DUFS_COMPRESS")
.hide_env(true)
.value_parser(clap::builder::EnumValueParser::<Compress>::new())
.long("compress")
.value_name("level")
.help("Set zip compress level [default: low]")
)
.arg(
Arg::new("completions")
.long("completions")
@@ -229,21 +242,27 @@ pub fn print_completions<G: Generator>(gen: G, cmd: &mut Command) {
generate(gen, cmd, cmd.get_name().to_string(), &mut std::io::stdout());
}
#[derive(Debug, Deserialize, Default)]
#[derive(Debug, Deserialize, SmartDefault, PartialEq)]
#[serde(default)]
#[serde(rename_all = "kebab-case")]
pub struct Args {
#[serde(default = "default_serve_path")]
#[default(default_serve_path())]
pub serve_path: PathBuf,
#[serde(deserialize_with = "deserialize_bind_addrs")]
#[serde(rename = "bind")]
#[serde(default = "default_addrs")]
#[default(default_addrs())]
pub addrs: Vec<BindAddr>,
#[serde(default = "default_port")]
#[default(default_port())]
pub port: u16,
#[serde(skip)]
pub path_is_file: bool,
pub path_prefix: String,
#[serde(skip)]
pub uri_prefix: String,
#[serde(deserialize_with = "deserialize_string_or_vec")]
pub hidden: Vec<String>,
#[serde(deserialize_with = "deserialize_access_control")]
pub auth: AccessControl,
@@ -261,6 +280,7 @@ pub struct Args {
#[serde(deserialize_with = "deserialize_log_http")]
#[serde(rename = "log-format")]
pub http_logger: HttpLogger,
pub compress: Compress,
pub tls_cert: Option<PathBuf>,
pub tls_key: Option<PathBuf>,
}
@@ -271,12 +291,7 @@ impl Args {
/// If a parsing error occurred, exit the process and print out informative
/// error message to user.
pub fn parse(matches: ArgMatches) -> Result<Args> {
let mut args = Self {
serve_path: default_serve_path(),
addrs: BindAddr::parse_addrs(&["0.0.0.0", "::"]).unwrap(),
port: 5000,
..Default::default()
};
let mut args = Self::default();
if let Some(config_path) = matches.get_one::<PathBuf>("config") {
let contents = std::fs::read_to_string(config_path)
@@ -288,6 +303,7 @@ impl Args {
if let Some(path) = matches.get_one::<PathBuf>("serve-path") {
args.serve_path = path.clone()
}
args.serve_path = Self::sanitize_path(args.serve_path)?;
if let Some(port) = matches.get_one::<u16>("port") {
@@ -311,11 +327,15 @@ impl Args {
format!("/{}/", &encode_uri(&args.path_prefix))
};
if let Some(hidden) = matches
.get_one::<String>("hidden")
.map(|v| v.split(',').map(|x| x.to_string()).collect())
{
args.hidden = hidden;
if let Some(hidden) = matches.get_many::<String>("hidden") {
args.hidden = hidden.cloned().collect();
} else {
let mut hidden = vec![];
std::mem::swap(&mut args.hidden, &mut hidden);
args.hidden = hidden
.into_iter()
.flat_map(|v| v.split(',').map(|v| v.to_string()).collect::<Vec<String>>())
.collect();
}
if !args.enable_cors {
@@ -360,10 +380,6 @@ impl Args {
args.render_spa = matches.get_flag("render-spa");
}
if let Some(log_format) = matches.get_one::<String>("log-format") {
args.http_logger = log_format.parse()?;
}
if let Some(assets_path) = matches.get_one::<PathBuf>("assets") {
args.assets = Some(assets_path.clone());
}
@@ -372,6 +388,14 @@ impl Args {
args.assets = Some(Args::sanitize_assets_path(assets_path)?);
}
if let Some(log_format) = matches.get_one::<String>("log-format") {
args.http_logger = log_format.parse()?;
}
if let Some(compress) = matches.get_one::<Compress>("compress") {
args.compress = *compress;
}
#[cfg(feature = "tls")]
{
if let Some(tls_cert) = matches.get_one::<PathBuf>("tls-cert") {
@@ -452,12 +476,109 @@ impl BindAddr {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Compress {
None,
Low,
Medium,
High,
}
impl Default for Compress {
fn default() -> Self {
Self::Low
}
}
impl ValueEnum for Compress {
fn value_variants<'a>() -> &'a [Self] {
&[Self::None, Self::Low, Self::Medium, Self::High]
}
fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
Some(match self {
Compress::None => PossibleValue::new("none"),
Compress::Low => PossibleValue::new("low"),
Compress::Medium => PossibleValue::new("medium"),
Compress::High => PossibleValue::new("high"),
})
}
}
impl Compress {
pub fn to_compression(self) -> Compression {
match self {
Compress::None => Compression::Stored,
Compress::Low => Compression::Deflate,
Compress::Medium => Compression::Bz,
Compress::High => Compression::Xz,
}
}
}
fn deserialize_bind_addrs<'de, D>(deserializer: D) -> Result<Vec<BindAddr>, D::Error>
where
D: Deserializer<'de>,
{
let addrs: Vec<&str> = Vec::deserialize(deserializer)?;
BindAddr::parse_addrs(&addrs).map_err(serde::de::Error::custom)
struct StringOrVec;
impl<'de> serde::de::Visitor<'de> for StringOrVec {
type Value = Vec<BindAddr>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("string or list of strings")
}
fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
BindAddr::parse_addrs(&[s]).map_err(serde::de::Error::custom)
}
fn visit_seq<S>(self, seq: S) -> Result<Self::Value, S::Error>
where
S: serde::de::SeqAccess<'de>,
{
let addrs: Vec<&'de str> =
Deserialize::deserialize(serde::de::value::SeqAccessDeserializer::new(seq))?;
BindAddr::parse_addrs(&addrs).map_err(serde::de::Error::custom)
}
}
deserializer.deserialize_any(StringOrVec)
}
fn deserialize_string_or_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
struct StringOrVec;
impl<'de> serde::de::Visitor<'de> for StringOrVec {
type Value = Vec<String>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("string or list of strings")
}
fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(vec![s.to_owned()])
}
fn visit_seq<S>(self, seq: S) -> Result<Self::Value, S::Error>
where
S: serde::de::SeqAccess<'de>,
{
Deserialize::deserialize(serde::de::value::SeqAccessDeserializer::new(seq))
}
}
deserializer.deserialize_any(StringOrVec)
}
fn deserialize_access_control<'de, D>(deserializer: D) -> Result<AccessControl, D::Error>
@@ -479,3 +600,136 @@ where
fn default_serve_path() -> PathBuf {
PathBuf::from(".")
}
fn default_addrs() -> Vec<BindAddr> {
BindAddr::parse_addrs(&["0.0.0.0", "::"]).unwrap()
}
fn default_port() -> u16 {
5000
}
#[cfg(test)]
mod tests {
use super::*;
use assert_fs::prelude::*;
#[test]
fn test_default() {
let cli = build_cli();
let matches = cli.try_get_matches_from(vec![""]).unwrap();
let args = Args::parse(matches).unwrap();
let cwd = Args::sanitize_path(std::env::current_dir().unwrap()).unwrap();
assert_eq!(args.serve_path, cwd);
assert_eq!(args.port, default_port());
assert_eq!(args.addrs, default_addrs());
}
#[test]
fn test_args_from_cli1() {
let tmpdir = assert_fs::TempDir::new().unwrap();
let cli = build_cli();
let matches = cli
.try_get_matches_from(vec![
"",
"--hidden",
"tmp,*.log,*.lock",
&tmpdir.to_string_lossy(),
])
.unwrap();
let args = Args::parse(matches).unwrap();
assert_eq!(args.serve_path, Args::sanitize_path(&tmpdir).unwrap());
assert_eq!(args.hidden, ["tmp", "*.log", "*.lock"]);
}
#[test]
fn test_args_from_cli2() {
let cli = build_cli();
let matches = cli
.try_get_matches_from(vec![
"", "--hidden", "tmp", "--hidden", "*.log", "--hidden", "*.lock",
])
.unwrap();
let args = Args::parse(matches).unwrap();
assert_eq!(args.hidden, ["tmp", "*.log", "*.lock"]);
}
#[test]
fn test_args_from_empty_config_file() {
let tmpdir = assert_fs::TempDir::new().unwrap();
let config_file = tmpdir.child("config.yaml");
config_file.write_str("").unwrap();
let cli = build_cli();
let matches = cli
.try_get_matches_from(vec!["", "-c", &config_file.to_string_lossy()])
.unwrap();
let args = Args::parse(matches).unwrap();
let cwd = Args::sanitize_path(std::env::current_dir().unwrap()).unwrap();
assert_eq!(args.serve_path, cwd);
assert_eq!(args.port, default_port());
assert_eq!(args.addrs, default_addrs());
}
#[test]
fn test_args_from_config_file1() {
let tmpdir = assert_fs::TempDir::new().unwrap();
let config_file = tmpdir.child("config.yaml");
let contents = format!(
r#"
serve-path: {}
bind: 0.0.0.0
port: 3000
allow-upload: true
hidden: tmp,*.log,*.lock
"#,
tmpdir.display()
);
config_file.write_str(&contents).unwrap();
let cli = build_cli();
let matches = cli
.try_get_matches_from(vec!["", "-c", &config_file.to_string_lossy()])
.unwrap();
let args = Args::parse(matches).unwrap();
assert_eq!(args.serve_path, Args::sanitize_path(&tmpdir).unwrap());
assert_eq!(
args.addrs,
vec![BindAddr::Address("0.0.0.0".parse().unwrap())]
);
assert_eq!(args.hidden, ["tmp", "*.log", "*.lock"]);
assert_eq!(args.port, 3000);
assert!(args.allow_upload);
}
#[test]
fn test_args_from_config_file2() {
let tmpdir = assert_fs::TempDir::new().unwrap();
let config_file = tmpdir.child("config.yaml");
let contents = r#"
bind:
- 127.0.0.1
- 192.168.8.10
hidden:
- tmp
- '*.log'
- '*.lock'
"#;
config_file.write_str(contents).unwrap();
let cli = build_cli();
let matches = cli
.try_get_matches_from(vec!["", "-c", &config_file.to_string_lossy()])
.unwrap();
let args = Args::parse(matches).unwrap();
assert_eq!(
args.addrs,
vec![
BindAddr::Address("127.0.0.1".parse().unwrap()),
BindAddr::Address("192.168.8.10".parse().unwrap())
]
);
assert_eq!(args.hidden, ["tmp", "*.log", "*.lock"]);
}
}

View File

@@ -1,7 +1,9 @@
use crate::{args::Args, server::Response, utils::unix_now};
use anyhow::{anyhow, bail, Result};
use base64::{engine::general_purpose, Engine as _};
use headers::HeaderValue;
use hyper::Method;
use hyper::{header::WWW_AUTHENTICATE, Method};
use indexmap::IndexMap;
use lazy_static::lazy_static;
use md5::Context;
@@ -11,8 +13,6 @@ use std::{
};
use uuid::Uuid;
use crate::{args::Args, utils::unix_now};
const REALM: &str = "DUFS";
const DIGEST_AUTH_TIMEOUT: u32 = 604800; // 7 days
@@ -25,19 +25,19 @@ lazy_static! {
};
}
#[derive(Debug)]
#[derive(Debug, Clone, PartialEq)]
pub struct AccessControl {
use_hashed_password: bool,
users: IndexMap<String, (String, AccessPaths)>,
anony: Option<AccessPaths>,
anonymous: Option<AccessPaths>,
}
impl Default for AccessControl {
fn default() -> Self {
AccessControl {
use_hashed_password: false,
anony: Some(AccessPaths::new(AccessPerm::ReadWrite)),
users: IndexMap::new(),
anonymous: Some(AccessPaths::new(AccessPerm::ReadWrite)),
}
}
}
@@ -47,51 +47,47 @@ impl AccessControl {
if raw_rules.is_empty() {
return Ok(Default::default());
}
let new_raw_rules = split_rules(raw_rules);
let mut use_hashed_password = false;
let create_err = |v: &str| anyhow!("Invalid auth `{v}`");
let mut anony = None;
let mut anony_paths = vec![];
let mut users = IndexMap::new();
for rule in raw_rules {
let (user, list) = rule.split_once('@').ok_or_else(|| create_err(rule))?;
if user.is_empty() && anony.is_some() {
bail!("Invalid auth, duplicate anonymous rules");
}
let mut paths = AccessPaths::default();
for value in list.trim_matches(',').split(',') {
let (path, perm) = match value.split_once(':') {
None => (value, AccessPerm::ReadOnly),
Some((path, "rw")) => (path, AccessPerm::ReadWrite),
_ => return Err(create_err(rule)),
};
if user.is_empty() {
anony_paths.push((path, perm));
let mut annoy_paths = None;
let mut account_paths_pairs = vec![];
for rule in &new_raw_rules {
let (account, paths) =
split_account_paths(rule).ok_or_else(|| anyhow!("Invalid auth `{rule}`"))?;
if account.is_empty() {
if annoy_paths.is_some() {
bail!("Invalid auth, no duplicate anonymous rules");
}
paths.add(path, perm);
}
if user.is_empty() {
anony = Some(paths);
} else if let Some((user, pass)) = user.split_once(':') {
annoy_paths = Some(paths)
} else if let Some((user, pass)) = account.split_once(':') {
if user.is_empty() || pass.is_empty() {
return Err(create_err(rule));
bail!("Invalid auth `{rule}`");
}
if pass.starts_with("$6$") {
use_hashed_password = true;
}
users.insert(user.to_string(), (pass.to_string(), paths));
} else {
return Err(create_err(rule));
account_paths_pairs.push((user, pass, paths));
}
}
for (path, perm) in anony_paths {
for (_, (_, paths)) in users.iter_mut() {
paths.add(path, perm)
}
let mut anonymous = None;
if let Some(paths) = annoy_paths {
let mut access_paths = AccessPaths::default();
access_paths.merge(paths);
anonymous = Some(access_paths);
}
let mut users = IndexMap::new();
for (user, pass, paths) in account_paths_pairs.into_iter() {
let mut access_paths = anonymous.clone().unwrap_or_default();
access_paths
.merge(paths)
.ok_or_else(|| anyhow!("Invalid auth `{user}:{pass}@{paths}"))?;
if pass.starts_with("$6$") {
use_hashed_password = true;
}
users.insert(user.to_string(), (pass.to_string(), access_paths));
}
Ok(Self {
use_hashed_password,
users,
anony,
anonymous,
})
}
@@ -113,18 +109,18 @@ impl AccessControl {
}
if check_auth(authorization, method.as_str(), &user, pass).is_some() {
return (Some(user), paths.find(path, !is_readonly_method(method)));
} else {
return (None, None);
}
}
}
return (None, None);
}
if method == Method::OPTIONS {
return (None, Some(AccessPaths::new(AccessPerm::ReadOnly)));
}
if let Some(paths) = self.anony.as_ref() {
if let Some(paths) = self.anonymous.as_ref() {
return (None, paths.find(path, !is_readonly_method(method)));
}
@@ -150,13 +146,26 @@ impl AccessPaths {
self.perm
}
fn set_perm(&mut self, perm: AccessPerm) {
if self.perm < perm {
self.perm = perm
pub fn set_perm(&mut self, perm: AccessPerm) {
if !perm.indexonly() {
self.perm = perm;
}
}
pub fn add(&mut self, path: &str, perm: AccessPerm) {
pub fn merge(&mut self, paths: &str) -> Option<()> {
for item in paths.trim_matches(',').split(',') {
let (path, perm) = match item.split_once(':') {
None => (item, AccessPerm::ReadOnly),
Some((path, "ro")) => (path, AccessPerm::ReadOnly),
Some((path, "rw")) => (path, AccessPerm::ReadWrite),
_ => return None,
};
self.add(path, perm);
}
Some(())
}
fn add(&mut self, path: &str, perm: AccessPerm) {
let path = path.trim_matches('/');
if path.is_empty() {
self.set_perm(perm);
@@ -190,7 +199,11 @@ impl AccessPaths {
}
fn find_impl(&self, parts: &[&str], perm: AccessPerm) -> Option<AccessPaths> {
let perm = self.perm.max(perm);
let perm = if !self.perm.indexonly() {
self.perm
} else {
perm
};
if parts.is_empty() {
if perm.indexonly() {
return Some(self.clone());
@@ -211,24 +224,24 @@ impl AccessPaths {
child.find_impl(&parts[1..], perm)
}
pub fn child_paths(&self) -> Vec<&String> {
pub fn child_names(&self) -> Vec<&String> {
self.children.keys().collect()
}
pub fn leaf_paths(&self, base: &Path) -> Vec<PathBuf> {
pub fn child_paths(&self, base: &Path) -> Vec<PathBuf> {
if !self.perm().indexonly() {
return vec![base.to_path_buf()];
}
let mut output = vec![];
self.leaf_paths_impl(&mut output, base);
self.child_paths_impl(&mut output, base);
output
}
fn leaf_paths_impl(&self, output: &mut Vec<PathBuf>, base: &Path) {
fn child_paths_impl(&self, output: &mut Vec<PathBuf>, base: &Path) {
for (name, child) in self.children.iter() {
let base = base.join(name);
if child.perm().indexonly() {
child.leaf_paths_impl(output, &base);
child.child_paths_impl(output, &base);
} else {
output.push(base)
}
@@ -245,26 +258,30 @@ pub enum AccessPerm {
}
impl AccessPerm {
pub fn readwrite(&self) -> bool {
self == &AccessPerm::ReadWrite
}
pub fn indexonly(&self) -> bool {
self == &AccessPerm::IndexOnly
}
pub fn readwrite(&self) -> bool {
self == &AccessPerm::ReadWrite
}
}
pub fn www_authenticate(args: &Args) -> Result<HeaderValue> {
let value = if args.auth.use_hashed_password {
format!("Basic realm=\"{}\"", REALM)
pub fn www_authenticate(res: &mut Response, args: &Args) -> Result<()> {
if args.auth.use_hashed_password {
let basic = HeaderValue::from_str(&format!("Basic realm=\"{}\"", REALM))?;
res.headers_mut().insert(WWW_AUTHENTICATE, basic);
} else {
let nonce = create_nonce()?;
format!(
"Digest realm=\"{}\", nonce=\"{}\", qop=\"auth\", Basic realm=\"{}\"",
REALM, nonce, REALM
)
};
Ok(HeaderValue::from_str(&value)?)
let digest = HeaderValue::from_str(&format!(
"Digest realm=\"{}\", nonce=\"{}\", qop=\"auth\"",
REALM, nonce
))?;
let basic = HeaderValue::from_str(&format!("Basic realm=\"{}\"", REALM))?;
res.headers_mut().append(WWW_AUTHENTICATE, digest);
res.headers_mut().append(WWW_AUTHENTICATE, basic);
}
Ok(())
}
pub fn get_auth_user(authorization: &HeaderValue) -> Option<String> {
@@ -476,24 +493,90 @@ fn create_nonce() -> Result<String> {
Ok(n[..34].to_string())
}
fn split_account_paths(s: &str) -> Option<(&str, &str)> {
let i = s.find("@/")?;
Some((&s[0..i], &s[i + 1..]))
}
fn split_rules(rules: &[&str]) -> Vec<String> {
let mut output = vec![];
for rule in rules {
let parts: Vec<&str> = rule.split('|').collect();
let mut rules_list = vec![];
let mut concated_part = String::new();
for (i, part) in parts.iter().enumerate() {
if part.contains("@/") {
concated_part.push_str(part);
let mut concated_part_tmp = String::new();
std::mem::swap(&mut concated_part_tmp, &mut concated_part);
rules_list.push(concated_part_tmp);
continue;
}
concated_part.push_str(part);
if i < parts.len() - 1 {
concated_part.push('|');
}
}
if !concated_part.is_empty() {
rules_list.push(concated_part)
}
output.extend(rules_list);
}
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_split_account_paths() {
assert_eq!(
split_account_paths("user:pass@/:rw"),
Some(("user:pass", "/:rw"))
);
assert_eq!(
split_account_paths("user:pass@@/:rw"),
Some(("user:pass@", "/:rw"))
);
assert_eq!(
split_account_paths("user:pass@1@/:rw"),
Some(("user:pass@1", "/:rw"))
);
}
#[test]
fn test_compact_split_rules() {
assert_eq!(
split_rules(&["user1:pass1@/:rw|user2:pass2@/:rw"]),
["user1:pass1@/:rw", "user2:pass2@/:rw"]
);
assert_eq!(
split_rules(&["user1:pa|ss1@/:rw|user2:pa|ss2@/:rw"]),
["user1:pa|ss1@/:rw", "user2:pa|ss2@/:rw"]
);
assert_eq!(
split_rules(&["user1:pa|ss1@/:rw|@/"]),
["user1:pa|ss1@/:rw", "@/"]
);
}
#[test]
fn test_access_paths() {
let mut paths = AccessPaths::default();
paths.add("/dir1", AccessPerm::ReadWrite);
paths.add("/dir2/dir1", AccessPerm::ReadWrite);
paths.add("/dir2/dir2", AccessPerm::ReadOnly);
paths.add("/dir2/dir3/dir1", AccessPerm::ReadWrite);
paths.add("/dir2/dir21", AccessPerm::ReadWrite);
paths.add("/dir2/dir21/dir211", AccessPerm::ReadOnly);
paths.add("/dir2/dir22", AccessPerm::ReadOnly);
paths.add("/dir2/dir22/dir221", AccessPerm::ReadWrite);
paths.add("/dir2/dir23/dir231", AccessPerm::ReadWrite);
assert_eq!(
paths.leaf_paths(Path::new("/tmp")),
paths.child_paths(Path::new("/tmp")),
[
"/tmp/dir1",
"/tmp/dir2/dir1",
"/tmp/dir2/dir2",
"/tmp/dir2/dir3/dir1"
"/tmp/dir2/dir21",
"/tmp/dir2/dir22",
"/tmp/dir2/dir23/dir231",
]
.iter()
.map(PathBuf::from)
@@ -502,27 +585,31 @@ mod tests {
assert_eq!(
paths
.find("dir2", false)
.map(|v| v.leaf_paths(Path::new("/tmp/dir2"))),
.map(|v| v.child_paths(Path::new("/tmp/dir2"))),
Some(
["/tmp/dir2/dir1", "/tmp/dir2/dir2", "/tmp/dir2/dir3/dir1"]
.iter()
.map(PathBuf::from)
.collect::<Vec<_>>()
[
"/tmp/dir2/dir21",
"/tmp/dir2/dir22",
"/tmp/dir2/dir23/dir231"
]
.iter()
.map(PathBuf::from)
.collect::<Vec<_>>()
)
);
assert_eq!(paths.find("dir2", true), None);
assert!(paths.find("dir1/file", true).is_some());
}
#[test]
fn test_access_paths_perm() {
let mut paths = AccessPaths::default();
assert_eq!(paths.perm(), AccessPerm::IndexOnly);
paths.set_perm(AccessPerm::ReadOnly);
assert_eq!(paths.perm(), AccessPerm::ReadOnly);
paths.set_perm(AccessPerm::ReadWrite);
assert_eq!(paths.perm(), AccessPerm::ReadWrite);
paths.set_perm(AccessPerm::ReadOnly);
assert_eq!(paths.perm(), AccessPerm::ReadWrite);
assert_eq!(
paths.find("dir1/file", true),
Some(AccessPaths::new(AccessPerm::ReadWrite))
);
assert_eq!(
paths.find("dir2/dir21/file", true),
Some(AccessPaths::new(AccessPerm::ReadWrite))
);
assert_eq!(
paths.find("dir2/dir21/dir211/file", false),
Some(AccessPaths::new(AccessPerm::ReadOnly))
);
assert_eq!(paths.find("dir2/dir21/dir211/file", true), None);
}
}

View File

@@ -4,7 +4,7 @@ use crate::{auth::get_auth_user, server::Request};
pub const DEFAULT_LOG_FORMAT: &str = r#"$remote_addr "$request" $status"#;
#[derive(Debug)]
#[derive(Debug, Clone, PartialEq)]
pub struct HttpLogger {
elements: Vec<LogElement>,
}
@@ -15,7 +15,7 @@ impl Default for HttpLogger {
}
}
#[derive(Debug)]
#[derive(Debug, Clone, PartialEq)]
enum LogElement {
Variable(String),
Header(String),

105
src/http_utils.rs Normal file
View File

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

View File

@@ -1,38 +1,37 @@
mod args;
mod auth;
mod http_logger;
mod http_utils;
mod logger;
mod server;
mod streamer;
#[cfg(feature = "tls")]
mod tls;
#[cfg(unix)]
mod unix;
mod utils;
#[macro_use]
extern crate log;
use crate::args::{build_cli, print_completions, Args};
use crate::server::{Request, Server};
use crate::server::Server;
#[cfg(feature = "tls")]
use crate::tls::{load_certs, load_private_key, TlsAcceptor, TlsStream};
use crate::utils::{load_certs, load_private_key};
use anyhow::{anyhow, Context, Result};
use std::net::{IpAddr, SocketAddr, TcpListener as StdTcpListener};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use args::BindAddr;
use clap_complete::Shell;
use futures::future::join_all;
use tokio::net::TcpListener;
use tokio::task::JoinHandle;
use futures_util::future::join_all;
use hyper::server::conn::{AddrIncoming, AddrStream};
use hyper::service::{make_service_fn, service_fn};
use hyper::{body::Incoming, service::service_fn, Request};
use hyper_util::{
rt::{TokioExecutor, TokioIo},
server::conn::auto::Builder,
};
use std::net::{IpAddr, SocketAddr, TcpListener as StdTcpListener};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use tokio::{net::TcpListener, task::JoinHandle};
#[cfg(feature = "tls")]
use rustls::ServerConfig;
use tokio_rustls::{rustls::ServerConfig, TlsAcceptor};
#[tokio::main]
async fn main() -> Result<()> {
@@ -44,11 +43,13 @@ async fn main() -> Result<()> {
print_completions(*generator, &mut cmd);
return Ok(());
}
let args = Args::parse(matches)?;
let args = Arc::new(args);
let mut args = Args::parse(matches)?;
let (new_addrs, print_addrs) = check_addrs(&args)?;
args.addrs = new_addrs;
let running = Arc::new(AtomicBool::new(true));
let handles = serve(args.clone(), running.clone())?;
print_listening(args)?;
let listening = print_listening(&args, &print_addrs)?;
let handles = serve(args, running.clone())?;
println!("{listening}");
tokio::select! {
ret = join_all(handles) => {
@@ -66,56 +67,62 @@ async fn main() -> Result<()> {
}
}
fn serve(
args: Arc<Args>,
running: Arc<AtomicBool>,
) -> Result<Vec<JoinHandle<Result<(), hyper::Error>>>> {
let inner = Arc::new(Server::init(args.clone(), running)?);
let mut handles = vec![];
fn serve(args: Args, running: Arc<AtomicBool>) -> Result<Vec<JoinHandle<()>>> {
let addrs = args.addrs.clone();
let port = args.port;
for bind_addr in args.addrs.iter() {
let inner = inner.clone();
let serve_func = move |remote_addr: Option<SocketAddr>| {
let inner = inner.clone();
async move {
Ok::<_, hyper::Error>(service_fn(move |req: Request| {
let inner = inner.clone();
inner.call(req, remote_addr)
}))
}
};
let tls_config = (args.tls_cert.clone(), args.tls_key.clone());
let server_handle = Arc::new(Server::init(args, running)?);
let mut handles = vec![];
for bind_addr in addrs.iter() {
let server_handle = server_handle.clone();
match bind_addr {
BindAddr::Address(ip) => {
let incoming = create_addr_incoming(SocketAddr::new(*ip, port))
let listener = create_listener(SocketAddr::new(*ip, port))
.with_context(|| format!("Failed to bind `{ip}:{port}`"))?;
match (&args.tls_cert, &args.tls_key) {
match &tls_config {
#[cfg(feature = "tls")]
(Some(cert_file), Some(key_file)) => {
let certs = load_certs(cert_file)?;
let key = load_private_key(key_file)?;
let config = ServerConfig::builder()
.with_safe_defaults()
let mut config = ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs.clone(), key.clone())?;
.with_single_cert(certs, key)?;
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
let config = Arc::new(config);
let accepter = TlsAcceptor::new(config.clone(), incoming);
let new_service = make_service_fn(move |socket: &TlsStream| {
let remote_addr = socket.remote_addr();
serve_func(Some(remote_addr))
let tls_accepter = TlsAcceptor::from(config);
let handle = tokio::spawn(async move {
loop {
let (cnx, addr) = listener.accept().await.unwrap();
let Ok(stream) = tls_accepter.accept(cnx).await else {
warn!("During cls handshake connection from {}", addr);
continue;
};
let stream = TokioIo::new(stream);
tokio::spawn(handle_stream(
server_handle.clone(),
stream,
Some(addr),
));
}
});
let server =
tokio::spawn(hyper::Server::builder(accepter).serve(new_service));
handles.push(server);
handles.push(handle);
}
(None, None) => {
let new_service = make_service_fn(move |socket: &AddrStream| {
let remote_addr = socket.remote_addr();
serve_func(Some(remote_addr))
let handle = tokio::spawn(async move {
loop {
let (cnx, addr) = listener.accept().await.unwrap();
let stream = TokioIo::new(cnx);
tokio::spawn(handle_stream(
server_handle.clone(),
stream,
Some(addr),
));
}
});
let server =
tokio::spawn(hyper::Server::builder(incoming).serve(new_service));
handles.push(server);
handles.push(handle);
}
_ => {
unreachable!()
@@ -130,10 +137,15 @@ fn serve(
{
let listener = tokio::net::UnixListener::bind(path)
.with_context(|| format!("Failed to bind `{}`", path.display()))?;
let acceptor = unix::UnixAcceptor::from_listener(listener);
let new_service = make_service_fn(move |_| serve_func(None));
let server = tokio::spawn(hyper::Server::builder(acceptor).serve(new_service));
handles.push(server);
let handle = tokio::spawn(async move {
loop {
let (cnx, _) = listener.accept().await.unwrap();
let stream = TokioIo::new(cnx);
tokio::spawn(handle_stream(server_handle.clone(), stream, None));
}
});
handles.push(handle);
}
}
}
@@ -141,7 +153,30 @@ fn serve(
Ok(handles)
}
fn create_addr_incoming(addr: SocketAddr) -> Result<AddrIncoming> {
async fn handle_stream<T>(handle: Arc<Server>, stream: TokioIo<T>, addr: Option<SocketAddr>)
where
T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
let hyper_service =
service_fn(move |request: Request<Incoming>| handle.clone().call(request, addr));
let ret = Builder::new(TokioExecutor::new())
.serve_connection_with_upgrades(stream, hyper_service)
.await;
if let Err(err) = ret {
let scope = match addr {
Some(addr) => format!(" from {}", addr),
None => String::new(),
};
match err.downcast_ref::<std::io::Error>() {
Some(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => {}
_ => warn!("Serving connection{}: {}", scope, err),
}
}
}
fn create_listener(addr: SocketAddr) -> Result<TcpListener> {
use socket2::{Domain, Protocol, Socket, Type};
let socket = Socket::new(Domain::for_address(addr), Type::STREAM, Some(Protocol::TCP))?;
if addr.is_ipv6() {
@@ -152,45 +187,68 @@ fn create_addr_incoming(addr: SocketAddr) -> Result<AddrIncoming> {
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)
let listener = TcpListener::from_std(std_listener)?;
Ok(listener)
}
fn print_listening(args: Arc<Args>) -> Result<()> {
let mut bind_addrs = vec![];
let (mut ipv4, mut ipv6) = (false, false);
fn check_addrs(args: &Args) -> Result<(Vec<BindAddr>, Vec<BindAddr>)> {
let mut new_addrs = vec![];
let mut print_addrs = vec![];
let (ipv4_addrs, ipv6_addrs) = interface_addrs()?;
for bind_addr in args.addrs.iter() {
match bind_addr {
BindAddr::Address(ip) => {
if ip.is_unspecified() {
if ip.is_ipv6() {
ipv6 = true;
} else {
ipv4 = true;
BindAddr::Address(ip) => match &ip {
IpAddr::V4(_) => {
if !ipv4_addrs.is_empty() {
new_addrs.push(bind_addr.clone());
if ip.is_unspecified() {
print_addrs.extend(ipv4_addrs.clone());
} else {
print_addrs.push(bind_addr.clone());
}
}
} else {
bind_addrs.push(bind_addr.clone());
}
}
_ => bind_addrs.push(bind_addr.clone()),
}
}
if ipv4 || ipv6 {
let ifaces =
if_addrs::get_if_addrs().with_context(|| "Failed to get local interface addresses")?;
for iface in ifaces.into_iter() {
let local_ip = iface.ip();
if ipv4 && local_ip.is_ipv4() {
bind_addrs.push(BindAddr::Address(local_ip))
}
if ipv6 && local_ip.is_ipv6() {
bind_addrs.push(BindAddr::Address(local_ip))
IpAddr::V6(_) => {
if !ipv6_addrs.is_empty() {
new_addrs.push(bind_addr.clone());
if ip.is_unspecified() {
print_addrs.extend(ipv6_addrs.clone());
} else {
print_addrs.push(bind_addr.clone())
}
}
}
},
_ => {
new_addrs.push(bind_addr.clone());
print_addrs.push(bind_addr.clone())
}
}
}
bind_addrs.sort_unstable();
let urls = bind_addrs
.into_iter()
print_addrs.sort_unstable();
Ok((new_addrs, print_addrs))
}
fn interface_addrs() -> Result<(Vec<BindAddr>, Vec<BindAddr>)> {
let (mut ipv4_addrs, mut ipv6_addrs) = (vec![], vec![]);
let ifaces =
if_addrs::get_if_addrs().with_context(|| "Failed to get local interface addresses")?;
for iface in ifaces.into_iter() {
let ip = iface.ip();
if ip.is_ipv4() {
ipv4_addrs.push(BindAddr::Address(ip))
}
if ip.is_ipv6() {
ipv6_addrs.push(BindAddr::Address(ip))
}
}
Ok((ipv4_addrs, ipv6_addrs))
}
fn print_listening(args: &Args, print_addrs: &[BindAddr]) -> Result<String> {
let mut output = String::new();
let urls = print_addrs
.iter()
.map(|bind_addr| match bind_addr {
BindAddr::Address(addr) => {
let addr = match addr {
@@ -209,17 +267,17 @@ fn print_listening(args: Arc<Args>) -> Result<()> {
.collect::<Vec<_>>();
if urls.len() == 1 {
println!("Listening on {}", urls[0]);
output.push_str(&format!("Listening on {}", urls[0]))
} else {
let info = urls
.iter()
.map(|v| format!(" {v}"))
.collect::<Vec<String>>()
.join("\n");
println!("Listening on:\n{info}\n");
output.push_str(&format!("Listening on:\n{info}\n"))
}
Ok(())
Ok(output)
}
async fn shutdown_signal() {

View File

@@ -1,29 +1,33 @@
#![allow(clippy::too_many_arguments)]
use crate::auth::{www_authenticate, AccessPaths, AccessPerm};
use crate::streamer::Streamer;
use crate::http_utils::{body_full, IncomingStream, LengthLimitedStream};
use crate::utils::{
decode_uri, encode_uri, get_file_mtime_and_mode, get_file_name, glob, try_get_file_name,
decode_uri, encode_uri, get_file_mtime_and_mode, get_file_name, glob, parse_range,
try_get_file_name,
};
use crate::Args;
use anyhow::{anyhow, Result};
use walkdir::WalkDir;
use xml::escape::escape_str_pcdata;
use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipDateTime, ZipEntryBuilder};
use anyhow::{anyhow, Result};
use async_zip::{tokio::write::ZipFileWriter, Compression, ZipDateTime, ZipEntryBuilder};
use bytes::Bytes;
use chrono::{LocalResult, TimeZone, Utc};
use futures::TryStreamExt;
use futures_util::{pin_mut, TryStreamExt};
use headers::{
AcceptRanges, AccessControlAllowCredentials, AccessControlAllowOrigin, ContentLength,
ContentType, ETag, HeaderMap, HeaderMapExt, IfModifiedSince, IfNoneMatch, IfRange,
LastModified, Range,
AcceptRanges, AccessControlAllowCredentials, AccessControlAllowOrigin, CacheControl,
ContentLength, ContentType, ETag, HeaderMap, HeaderMapExt, IfModifiedSince, IfNoneMatch,
IfRange, LastModified, Range,
};
use hyper::header::{
HeaderValue, AUTHORIZATION, CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE,
RANGE, WWW_AUTHENTICATE,
use http_body_util::{combinators::BoxBody, BodyExt, StreamBody};
use hyper::body::Frame;
use hyper::{
body::Incoming,
header::{
HeaderValue, AUTHORIZATION, CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_RANGE,
CONTENT_TYPE, RANGE,
},
Method, StatusCode, Uri,
};
use hyper::{Body, Method, StatusCode, Uri};
use serde::Serialize;
use std::borrow::Cow;
use std::cmp::Ordering;
@@ -31,19 +35,22 @@ use std::collections::HashMap;
use std::fs::Metadata;
use std::io::SeekFrom;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{self, AtomicBool};
use std::sync::Arc;
use std::time::SystemTime;
use tokio::fs::File;
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWrite};
use tokio::{fs, io};
use tokio_util::compat::FuturesAsyncWriteCompatExt;
use tokio_util::io::StreamReader;
use uuid::Uuid;
pub type Request = hyper::Request<Body>;
pub type Response = hyper::Response<Body>;
use tokio_util::compat::FuturesAsyncWriteCompatExt;
use tokio_util::io::{ReaderStream, StreamReader};
use uuid::Uuid;
use walkdir::WalkDir;
use xml::escape::escape_str_pcdata;
pub type Request = hyper::Request<Incoming>;
pub type Response = hyper::Response<BoxBody<Bytes, anyhow::Error>>;
const INDEX_HTML: &str = include_str!("../assets/index.html");
const INDEX_CSS: &str = include_str!("../assets/index.css");
@@ -51,10 +58,11 @@ const INDEX_JS: &str = include_str!("../assets/index.js");
const FAVICON_ICO: &[u8] = include_bytes!("../assets/favicon.ico");
const INDEX_NAME: &str = "index.html";
const BUF_SIZE: usize = 65536;
const TEXT_MAX_SIZE: u64 = 4194304; // 4M
const EDITABLE_TEXT_MAX_SIZE: u64 = 4194304; // 4M
const RESUMABLE_UPLOAD_MIN_SIZE: u64 = 20971520; // 20M
pub struct Server {
args: Arc<Args>,
args: Args,
assets_prefix: String,
html: Cow<'static, str>,
single_file_req_paths: Vec<String>,
@@ -62,8 +70,8 @@ pub struct Server {
}
impl Server {
pub fn init(args: Arc<Args>, running: Arc<AtomicBool>) -> Result<Self> {
let assets_prefix = format!("{}__dufs_v{}_", args.uri_prefix, env!("CARGO_PKG_VERSION"));
pub fn init(args: Args, running: Arc<AtomicBool>) -> Result<Self> {
let assets_prefix = format!("__dufs_v{}__/", env!("CARGO_PKG_VERSION"));
let single_file_req_paths = if args.path_is_file {
vec![
args.uri_prefix.to_string(),
@@ -136,19 +144,23 @@ impl Server {
let headers = req.headers();
let method = req.method().clone();
if method == Method::GET && self.handle_assets(req_path, headers, &mut res).await? {
return Ok(res);
}
let authorization = headers.get(AUTHORIZATION);
let relative_path = match self.resolve_path(req_path) {
Some(v) => v,
None => {
status_forbid(&mut res);
status_bad_request(&mut res, "Invalid Path");
return Ok(res);
}
};
if method == Method::GET
&& self
.handle_assets(&relative_path, headers, &mut res)
.await?
{
return Ok(res);
}
let authorization = headers.get(AUTHORIZATION);
let guard = self.args.auth.guard(&relative_path, &method, authorization);
let (user, access_paths) = match guard {
@@ -290,7 +302,10 @@ impl Server {
}
} else if is_file {
if query_params.contains_key("edit") {
self.handle_edit_file(path, head_only, user, &mut res)
self.handle_edit_file(path, DataKind::Edit, head_only, user, &mut res)
.await?;
} else if query_params.contains_key("view") {
self.handle_edit_file(path, DataKind::View, head_only, user, &mut res)
.await?;
} else {
self.handle_send_file(path, headers, head_only, &mut res)
@@ -318,10 +333,37 @@ impl Server {
set_webdav_headers(&mut res);
}
Method::PUT => {
if !allow_upload || (!allow_delete && is_file && size > 0) {
if is_dir || !allow_upload || (!allow_delete && size > 0) {
status_forbid(&mut res);
} else {
self.handle_upload(path, req, &mut res).await?;
self.handle_upload(path, None, size, req, &mut res).await?;
}
}
Method::PATCH => {
if is_miss {
status_not_found(&mut res);
} else if !allow_upload {
status_forbid(&mut res);
} else {
let offset = match parse_upload_offset(headers, size) {
Ok(v) => v,
Err(err) => {
status_bad_request(&mut res, &err.to_string());
return Ok(res);
}
};
match offset {
Some(offset) => {
if offset < size && !allow_delete {
status_forbid(&mut res);
}
self.handle_upload(path, Some(offset), size, req, &mut res)
.await?;
}
None => {
*res.status_mut() = StatusCode::METHOD_NOT_ALLOWED;
}
}
}
}
Method::DELETE => {
@@ -336,12 +378,13 @@ impl Server {
method => match method.as_str() {
"PROPFIND" => {
if is_dir {
let access_paths = if access_paths.perm().indexonly() {
// see https://github.com/sigoden/dufs/issues/229
AccessPaths::new(AccessPerm::ReadOnly)
} else {
access_paths
};
let access_paths =
if access_paths.perm().indexonly() && authorization.is_none() {
// see https://github.com/sigoden/dufs/issues/229
AccessPaths::new(AccessPerm::ReadOnly)
} else {
access_paths
};
self.handle_propfind_dir(path, headers, access_paths, &mut res)
.await?;
} else if is_file {
@@ -362,7 +405,7 @@ impl Server {
status_forbid(&mut res);
} else if !is_miss {
*res.status_mut() = StatusCode::METHOD_NOT_ALLOWED;
*res.body_mut() = Body::from("Already exists");
*res.body_mut() = body_full("Already exists");
} else {
self.handle_mkcol(path, &mut res).await?;
}
@@ -408,33 +451,48 @@ impl Server {
Ok(res)
}
async fn handle_upload(&self, path: &Path, mut req: Request, res: &mut Response) -> Result<()> {
async fn handle_upload(
&self,
path: &Path,
upload_offset: Option<u64>,
size: u64,
req: Request,
res: &mut Response,
) -> Result<()> {
ensure_path_parent(path).await?;
let mut file = match fs::File::create(&path).await {
Ok(v) => v,
Err(_) => {
status_forbid(res);
return Ok(());
let (mut file, status) = match upload_offset {
None => (fs::File::create(path).await?, StatusCode::CREATED),
Some(offset) if offset == size => (
fs::OpenOptions::new().append(true).open(path).await?,
StatusCode::NO_CONTENT,
),
Some(offset) => {
let mut file = fs::OpenOptions::new().write(true).open(path).await?;
file.seek(SeekFrom::Start(offset)).await?;
(file, StatusCode::NO_CONTENT)
}
};
let stream = IncomingStream::new(req.into_body());
let body_with_io_error = req
.body_mut()
.map_err(|err| io::Error::new(io::ErrorKind::Other, err));
let body_with_io_error = stream.map_err(|err| io::Error::new(io::ErrorKind::Other, err));
let body_reader = StreamReader::new(body_with_io_error);
futures::pin_mut!(body_reader);
pin_mut!(body_reader);
let ret = io::copy(&mut body_reader, &mut file).await;
let size = fs::metadata(path)
.await
.map(|v| v.len())
.unwrap_or_default();
if ret.is_err() {
tokio::fs::remove_file(&path).await?;
if upload_offset.is_none() && size < RESUMABLE_UPLOAD_MIN_SIZE {
let _ = tokio::fs::remove_file(&path).await;
}
ret?;
}
*res.status_mut() = StatusCode::CREATED;
*res.status_mut() = status;
Ok(())
}
@@ -494,7 +552,11 @@ impl Server {
.get("q")
.ok_or_else(|| anyhow!("invalid q"))?
.to_lowercase();
if !search.is_empty() {
if search.is_empty() {
return self
.handle_ls_dir(path, true, query_params, head_only, user, access_paths, res)
.await;
} else {
let path_buf = path.to_path_buf();
let hidden = Arc::new(self.args.hidden.to_vec());
let hidden = hidden.clone();
@@ -502,7 +564,7 @@ impl Server {
let access_paths = access_paths.clone();
let search_paths = tokio::task::spawn_blocking(move || {
let mut paths: Vec<PathBuf> = vec![];
for dir in access_paths.leaf_paths(&path_buf) {
for dir in access_paths.child_paths(&path_buf) {
let mut it = WalkDir::new(&dir).into_iter();
it.next();
while let Some(Ok(entry)) = it.next() {
@@ -565,7 +627,7 @@ impl Server {
) -> Result<()> {
let (mut writer, reader) = tokio::io::duplex(BUF_SIZE);
let filename = try_get_file_name(path)?;
set_content_diposition(res, false, &format!("{}.zip", filename))?;
set_content_disposition(res, false, &format!("{}.zip", filename))?;
res.headers_mut()
.insert("content-type", HeaderValue::from_static("application/zip"));
if head_only {
@@ -574,13 +636,29 @@ impl Server {
let path = path.to_owned();
let hidden = self.args.hidden.clone();
let running = self.running.clone();
let compression = self.args.compress.to_compression();
tokio::spawn(async move {
if let Err(e) = zip_dir(&mut writer, &path, access_paths, &hidden, running).await {
if let Err(e) = zip_dir(
&mut writer,
&path,
access_paths,
&hidden,
compression,
running,
)
.await
{
error!("Failed to zip {}, {}", path.display(), e);
}
});
let reader = Streamer::new(reader, BUF_SIZE);
*res.body_mut() = Body::wrap_stream(reader.into_stream());
let reader_stream = ReaderStream::new(reader);
let stream_body = StreamBody::new(
reader_stream
.map_ok(Frame::data)
.map_err(|err| anyhow!("{err}")),
);
let boxed_body = stream_body.boxed();
*res.body_mut() = boxed_body;
Ok(())
}
@@ -639,23 +717,30 @@ impl Server {
match self.args.assets.as_ref() {
Some(assets_path) => {
let path = assets_path.join(name);
self.handle_send_file(&path, headers, false, res).await?;
if path.exists() {
self.handle_send_file(&path, headers, false, res).await?;
} else {
status_not_found(res);
return Ok(true);
}
}
None => match name {
"index.js" => {
*res.body_mut() = Body::from(INDEX_JS);
*res.body_mut() = body_full(INDEX_JS);
res.headers_mut().insert(
"content-type",
HeaderValue::from_static("application/javascript"),
HeaderValue::from_static("application/javascript; charset=UTF-8"),
);
}
"index.css" => {
*res.body_mut() = Body::from(INDEX_CSS);
res.headers_mut()
.insert("content-type", HeaderValue::from_static("text/css"));
*res.body_mut() = body_full(INDEX_CSS);
res.headers_mut().insert(
"content-type",
HeaderValue::from_static("text/css; charset=UTF-8"),
);
}
"favicon.ico" => {
*res.body_mut() = Body::from(FAVICON_ICO);
*res.body_mut() = body_full(FAVICON_ICO);
res.headers_mut()
.insert("content-type", HeaderValue::from_static("image/x-icon"));
}
@@ -666,7 +751,11 @@ impl Server {
}
res.headers_mut().insert(
"cache-control",
HeaderValue::from_static("max-age=2592000, public"),
HeaderValue::from_static("public, max-age=31536000, immutable"),
);
res.headers_mut().insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
Ok(true)
} else {
@@ -683,6 +772,7 @@ impl Server {
) -> Result<()> {
let (file, meta) = tokio::join!(fs::File::open(path), fs::metadata(path),);
let (mut file, meta) = (file?, meta?);
let size = meta.len();
let mut use_range = true;
if let Some((etag, last_modified)) = extract_cache_headers(&meta) {
let cached = {
@@ -714,7 +804,12 @@ impl Server {
}
let range = if use_range {
parse_range(headers)
headers.get(RANGE).map(|range| {
range
.to_str()
.ok()
.and_then(|range| parse_range(range, size))
})
} else {
None
};
@@ -725,31 +820,31 @@ impl Server {
);
let filename = try_get_file_name(path)?;
set_content_diposition(res, true, filename)?;
set_content_disposition(res, true, filename)?;
res.headers_mut().typed_insert(AcceptRanges::bytes());
let size = meta.len();
if let Some(range) = range {
if range
.end
.map_or_else(|| range.start < size, |v| v >= range.start)
&& file.seek(SeekFrom::Start(range.start)).await.is_ok()
{
let end = range.end.unwrap_or(size - 1).min(size - 1);
let part_size = end - range.start + 1;
let reader = Streamer::new(file, BUF_SIZE);
if let Some((start, end)) = range {
file.seek(SeekFrom::Start(start)).await?;
let range_size = end - start + 1;
*res.status_mut() = StatusCode::PARTIAL_CONTENT;
let content_range = format!("bytes {}-{}/{}", range.start, end, size);
let content_range = format!("bytes {}-{}/{}", start, end, size);
res.headers_mut()
.insert(CONTENT_RANGE, content_range.parse()?);
res.headers_mut()
.insert(CONTENT_LENGTH, format!("{part_size}").parse()?);
.insert(CONTENT_LENGTH, format!("{range_size}").parse()?);
if head_only {
return Ok(());
}
*res.body_mut() = Body::wrap_stream(reader.into_stream_sized(part_size));
let stream_body = StreamBody::new(
LengthLimitedStream::new(file, range_size as usize)
.map_ok(Frame::data)
.map_err(|err| anyhow!("{err}")),
);
let boxed_body = stream_body.boxed();
*res.body_mut() = boxed_body;
} else {
*res.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
res.headers_mut()
@@ -761,8 +856,15 @@ impl Server {
if head_only {
return Ok(());
}
let reader = Streamer::new(file, BUF_SIZE);
*res.body_mut() = Body::wrap_stream(reader.into_stream());
let reader_stream = ReaderStream::new(file);
let stream_body = StreamBody::new(
reader_stream
.map_ok(Frame::data)
.map_err(|err| anyhow!("{err}")),
);
let boxed_body = stream_body.boxed();
*res.body_mut() = boxed_body;
}
Ok(())
}
@@ -770,6 +872,7 @@ impl Server {
async fn handle_edit_file(
&self,
path: &Path,
kind: DataKind,
head_only: bool,
user: Option<String>,
res: &mut Response,
@@ -782,10 +885,11 @@ impl Server {
);
let mut buffer: Vec<u8> = vec![];
file.take(1024).read_to_end(&mut buffer).await?;
let editable = meta.len() <= TEXT_MAX_SIZE && content_inspector::inspect(&buffer).is_text();
let editable =
meta.len() <= EDITABLE_TEXT_MAX_SIZE && content_inspector::inspect(&buffer).is_text();
let data = EditData {
href,
kind: DataKind::Edit,
kind,
uri_prefix: self.args.uri_prefix.clone(),
allow_upload: self.args.allow_upload,
allow_delete: self.args.allow_delete,
@@ -797,14 +901,17 @@ impl Server {
.typed_insert(ContentType::from(mime_guess::mime::TEXT_HTML_UTF_8));
let output = self
.html
.replace("__ASSETS_PREFIX__", &self.assets_prefix)
.replace(
"__ASSETS_PREFIX__",
&format!("{}{}", self.args.uri_prefix, self.assets_prefix),
)
.replace("__INDEX_DATA__", &serde_json::to_string(&data)?);
res.headers_mut()
.typed_insert(ContentLength(output.as_bytes().len() as u64));
if head_only {
return Ok(());
}
*res.body_mut() = output.into();
*res.body_mut() = body_full(output);
Ok(())
}
@@ -819,7 +926,7 @@ impl Server {
Some(v) => match v.to_str().ok().and_then(|v| v.parse().ok()) {
Some(v) => v,
None => {
*res.status_mut() = StatusCode::BAD_REQUEST;
status_bad_request(res, "");
return Ok(());
}
},
@@ -919,7 +1026,7 @@ impl Server {
res.headers_mut()
.insert("lock-token", format!("<{token}>").parse()?);
*res.body_mut() = Body::from(format!(
*res.body_mut() = body_full(format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<D:prop xmlns:D="DAV:"><D:lockdiscovery><D:activelock>
<D:locktoken><D:href>{token}</D:href></D:locktoken>
@@ -990,7 +1097,7 @@ impl Server {
.typed_insert(ContentType::from(mime_guess::mime::TEXT_HTML_UTF_8));
res.headers_mut()
.typed_insert(ContentLength(output.as_bytes().len() as u64));
*res.body_mut() = output.into();
*res.body_mut() = body_full(output);
if head_only {
return Ok(());
}
@@ -1022,23 +1129,31 @@ impl Server {
res.headers_mut()
.typed_insert(ContentType::from(mime_guess::mime::TEXT_HTML_UTF_8));
self.html
.replace("__ASSETS_PREFIX__", &self.assets_prefix)
.replace(
"__ASSETS_PREFIX__",
&format!("{}{}", self.args.uri_prefix, self.assets_prefix),
)
.replace("__INDEX_DATA__", &serde_json::to_string(&data)?)
};
res.headers_mut()
.typed_insert(ContentLength(output.as_bytes().len() as u64));
res.headers_mut()
.typed_insert(CacheControl::new().with_no_cache());
res.headers_mut().insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
if head_only {
return Ok(());
}
*res.body_mut() = output.into();
*res.body_mut() = body_full(output);
Ok(())
}
fn auth_reject(&self, res: &mut Response) -> Result<()> {
set_webdav_headers(res);
res.headers_mut()
.append(WWW_AUTHENTICATE, www_authenticate(&self.args)?);
// set 401 to make the browser pop up the login box
www_authenticate(res, &self.args)?;
*res.status_mut() = StatusCode::UNAUTHORIZED;
Ok(())
}
@@ -1053,18 +1168,13 @@ impl Server {
fn extract_dest(&self, req: &Request, res: &mut Response) -> Option<PathBuf> {
let headers = req.headers();
let dest_path = match self.extract_destination_header(headers) {
let dest_path = match self
.extract_destination_header(headers)
.and_then(|dest| self.resolve_path(&dest))
{
Some(dest) => dest,
None => {
*res.status_mut() = StatusCode::BAD_REQUEST;
return None;
}
};
let relative_path = match self.resolve_path(&dest_path) {
Some(v) => v,
None => {
*res.status_mut() = StatusCode::BAD_REQUEST;
status_bad_request(res, "Invalid Destination");
return None;
}
};
@@ -1073,7 +1183,7 @@ impl Server {
let guard = self
.args
.auth
.guard(&relative_path, req.method(), authorization);
.guard(&dest_path, req.method(), authorization);
match guard {
(_, Some(_)) => {}
@@ -1083,7 +1193,7 @@ impl Server {
}
};
let dest = match self.join_path(&relative_path) {
let dest = match self.join_path(&dest_path) {
Some(dest) => dest,
None => {
*res.status_mut() = StatusCode::BAD_REQUEST;
@@ -1101,13 +1211,30 @@ impl Server {
}
fn resolve_path(&self, path: &str) -> Option<String> {
let path = path.trim_matches('/');
let path = decode_uri(path)?;
let prefix = self.args.path_prefix.as_str();
if prefix == "/" {
return Some(path.to_string());
let path = path.trim_matches('/');
let mut parts = vec![];
for comp in Path::new(path).components() {
if let Component::Normal(v) = comp {
let v = v.to_string_lossy();
if cfg!(windows) {
let chars: Vec<char> = v.chars().collect();
if chars.len() == 2 && chars[1] == ':' && chars[0].is_ascii_alphabetic() {
return None;
}
}
parts.push(v);
} else {
return None;
}
}
path.strip_prefix(prefix.trim_start_matches('/'))
let new_path = parts.join("/");
let path_prefix = self.args.path_prefix.as_str();
if path_prefix.is_empty() {
return Some(new_path);
}
new_path
.strip_prefix(path_prefix.trim_start_matches('/'))
.map(|v| v.trim_matches('/').to_string())
}
@@ -1131,7 +1258,7 @@ impl Server {
) -> Result<Vec<PathItem>> {
let mut paths: Vec<PathItem> = vec![];
if access_paths.perm().indexonly() {
for name in access_paths.child_paths() {
for name in access_paths.child_names() {
let entry_path = entry_path.join(name);
self.add_pathitem(&mut paths, base_path, &entry_path).await;
}
@@ -1186,10 +1313,11 @@ impl Server {
}
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, PartialEq)]
enum DataKind {
Index,
Edit,
View,
}
#[derive(Debug, Serialize)]
@@ -1389,7 +1517,7 @@ fn res_multistatus(res: &mut Response, content: &str) {
"content-type",
HeaderValue::from_static("application/xml; charset=utf-8"),
);
*res.body_mut() = Body::from(format!(
*res.body_mut() = body_full(format!(
r#"<?xml version="1.0" encoding="utf-8" ?>
<D:multistatus xmlns:D="DAV:">
{content}
@@ -1402,6 +1530,7 @@ async fn zip_dir<W: AsyncWrite + Unpin>(
dir: &Path,
access_paths: AccessPaths,
hidden: &[String],
compression: Compression,
running: Arc<AtomicBool>,
) -> Result<()> {
let mut writer = ZipFileWriter::with_tokio(writer);
@@ -1410,7 +1539,7 @@ async fn zip_dir<W: AsyncWrite + Unpin>(
let dir_clone = dir.to_path_buf();
let zip_paths = tokio::task::spawn_blocking(move || {
let mut paths: Vec<PathBuf> = vec![];
for dir in access_paths.leaf_paths(&dir_clone) {
for dir in access_paths.child_paths(&dir_clone) {
let mut it = WalkDir::new(&dir).into_iter();
it.next();
while let Some(Ok(entry)) = it.next() {
@@ -1455,7 +1584,7 @@ async fn zip_dir<W: AsyncWrite + Unpin>(
None => continue,
};
let (datetime, mode) = get_file_mtime_and_mode(&zip_path).await?;
let builder = ZipEntryBuilder::new(filename.into(), Compression::Deflate)
let builder = ZipEntryBuilder::new(filename.into(), compression)
.unix_permissions(mode)
.last_modification_date(ZipDateTime::from_chrono(&datetime));
let mut file = File::open(&zip_path).await?;
@@ -1476,59 +1605,46 @@ fn extract_cache_headers(meta: &Metadata) -> Option<(ETag, LastModified)> {
Some((etag, last_modified))
}
#[derive(Debug)]
struct RangeValue {
start: u64,
end: Option<u64>,
}
fn parse_range(headers: &HeaderMap<HeaderValue>) -> Option<RangeValue> {
let range_hdr = headers.get(RANGE)?;
let hdr = range_hdr.to_str().ok()?;
let mut sp = hdr.splitn(2, '=');
let units = sp.next()?;
if units == "bytes" {
let range = sp.next()?;
let mut sp_range = range.splitn(2, '-');
let start: u64 = sp_range.next()?.parse().ok()?;
let end: Option<u64> = if let Some(end) = sp_range.next() {
if end.is_empty() {
None
} else {
Some(end.parse().ok()?)
}
} else {
None
};
Some(RangeValue { start, end })
} else {
None
}
}
fn status_forbid(res: &mut Response) {
*res.status_mut() = StatusCode::FORBIDDEN;
*res.body_mut() = Body::from("Forbidden");
*res.body_mut() = body_full("Forbidden");
}
fn status_not_found(res: &mut Response) {
*res.status_mut() = StatusCode::NOT_FOUND;
*res.body_mut() = Body::from("Not Found");
*res.body_mut() = body_full("Not Found");
}
fn status_no_content(res: &mut Response) {
*res.status_mut() = StatusCode::NO_CONTENT;
}
fn set_content_diposition(res: &mut Response, inline: bool, filename: &str) -> Result<()> {
fn status_bad_request(res: &mut Response, body: &str) {
*res.status_mut() = StatusCode::BAD_REQUEST;
if !body.is_empty() {
*res.body_mut() = body_full(body.to_string());
}
}
fn set_content_disposition(res: &mut Response, inline: bool, filename: &str) -> Result<()> {
let kind = if inline { "inline" } else { "attachment" };
let filename: String = filename
.chars()
.map(|ch| {
if ch.is_ascii_control() && ch != '\t' {
' '
} else {
ch
}
})
.collect();
let value = if filename.is_ascii() {
HeaderValue::from_str(&format!("{kind}; filename=\"{}\"", filename,))?
} else {
HeaderValue::from_str(&format!(
"{kind}; filename=\"{}\"; filename*=UTF-8''{}",
filename,
encode_uri(filename),
encode_uri(&filename),
))?
};
res.headers_mut().insert(CONTENT_DISPOSITION, value);
@@ -1549,10 +1665,12 @@ fn is_hidden(hidden: &[String], file_name: &str, is_dir_type: bool) -> bool {
fn set_webdav_headers(res: &mut Response) {
res.headers_mut().insert(
"Allow",
HeaderValue::from_static("GET,HEAD,PUT,OPTIONS,DELETE,PROPFIND,COPY,MOVE"),
HeaderValue::from_static("GET,HEAD,PUT,OPTIONS,DELETE,PATCH,PROPFIND,COPY,MOVE"),
);
res.headers_mut().insert(
"DAV",
HeaderValue::from_static("1, 2, 3, sabredav-partialupdate"),
);
res.headers_mut()
.insert("DAV", HeaderValue::from_static("1,2"));
}
async fn get_content_type(path: &Path) -> Result<String> {
@@ -1585,3 +1703,17 @@ async fn get_content_type(path: &Path) -> Result<String> {
};
Ok(content_type)
}
fn parse_upload_offset(headers: &HeaderMap<HeaderValue>, size: u64) -> Result<Option<u64>> {
let value = match headers.get("x-update-range") {
Some(v) => v,
None => return Ok(None),
};
let err = || anyhow!("Invalid X-Update-Range Header");
let value = value.to_str().map_err(|_| err())?;
if value == "append" {
return Ok(Some(size));
}
let (start, _) = parse_range(value, size).ok_or_else(err)?;
Ok(Some(start))
}

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Utc};
#[cfg(feature = "tls")]
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
use std::{
borrow::Cow,
path::Path,
@@ -58,26 +60,115 @@ pub fn glob(pattern: &str, target: &str) -> bool {
pat.matches(target)
}
#[test]
fn test_glob_key() {
assert!(glob("", ""));
assert!(glob(".*", ".git"));
assert!(glob("abc", "abc"));
assert!(glob("a*c", "abc"));
assert!(glob("a?c", "abc"));
assert!(glob("a*c", "abbc"));
assert!(glob("*c", "abc"));
assert!(glob("a*", "abc"));
assert!(glob("?c", "bc"));
assert!(glob("a?", "ab"));
assert!(!glob("abc", "adc"));
assert!(!glob("abc", "abcd"));
assert!(!glob("a?c", "abbc"));
assert!(!glob("*.log", "log"));
assert!(glob("*.abc-cba", "xyz.abc-cba"));
assert!(glob("*.abc-cba", "123.xyz.abc-cba"));
assert!(glob("*.log", ".log"));
assert!(glob("*.log", "a.log"));
assert!(glob("*/", "abc/"));
assert!(!glob("*/", "abc"));
// Load public certificate from file.
#[cfg(feature = "tls")]
pub fn load_certs<T: AsRef<Path>>(filename: T) -> Result<Vec<CertificateDer<'static>>> {
// Open certificate file.
let cert_file = std::fs::File::open(filename.as_ref())
.with_context(|| format!("Failed to access `{}`", filename.as_ref().display()))?;
let mut reader = std::io::BufReader::new(cert_file);
// Load and return certificate.
let mut certs = vec![];
for cert in rustls_pemfile::certs(&mut reader) {
let cert = cert.with_context(|| "Failed to load certificate")?;
certs.push(cert)
}
if certs.is_empty() {
anyhow::bail!("No supported certificate in file");
}
Ok(certs)
}
// Load private key from file.
#[cfg(feature = "tls")]
pub fn load_private_key<T: AsRef<Path>>(filename: T) -> Result<PrivateKeyDer<'static>> {
let key_file = std::fs::File::open(filename.as_ref())
.with_context(|| format!("Failed to access `{}`", filename.as_ref().display()))?;
let mut reader = std::io::BufReader::new(key_file);
// Load and return a single private key.
for key in rustls_pemfile::read_all(&mut reader) {
let key = key.with_context(|| "There was a problem with reading private key")?;
match key {
rustls_pemfile::Item::Pkcs1Key(key) => return Ok(PrivateKeyDer::Pkcs1(key)),
rustls_pemfile::Item::Pkcs8Key(key) => return Ok(PrivateKeyDer::Pkcs8(key)),
rustls_pemfile::Item::Sec1Key(key) => return Ok(PrivateKeyDer::Sec1(key)),
_ => {}
}
}
anyhow::bail!("No supported private key in file");
}
pub fn parse_range(range: &str, size: u64) -> Option<(u64, u64)> {
let (unit, range) = range.split_once('=')?;
if unit != "bytes" || range.contains(',') {
return None;
}
let (start, end) = range.split_once('-')?;
if start.is_empty() {
let offset = end.parse::<u64>().ok()?;
if offset <= size {
Some((size - offset, size - 1))
} else {
None
}
} else {
let start = start.parse::<u64>().ok()?;
if start < size {
if end.is_empty() {
Some((start, size - 1))
} else {
let end = end.parse::<u64>().ok()?;
if end < size {
Some((start, end))
} else {
None
}
}
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_glob_key() {
assert!(glob("", ""));
assert!(glob(".*", ".git"));
assert!(glob("abc", "abc"));
assert!(glob("a*c", "abc"));
assert!(glob("a?c", "abc"));
assert!(glob("a*c", "abbc"));
assert!(glob("*c", "abc"));
assert!(glob("a*", "abc"));
assert!(glob("?c", "bc"));
assert!(glob("a?", "ab"));
assert!(!glob("abc", "adc"));
assert!(!glob("abc", "abcd"));
assert!(!glob("a?c", "abbc"));
assert!(!glob("*.log", "log"));
assert!(glob("*.abc-cba", "xyz.abc-cba"));
assert!(glob("*.abc-cba", "123.xyz.abc-cba"));
assert!(glob("*.log", ".log"));
assert!(glob("*.log", "a.log"));
assert!(glob("*/", "abc/"));
assert!(!glob("*/", "abc"));
}
#[test]
fn test_parse_range() {
assert_eq!(parse_range("bytes=0-499", 500), Some((0, 499)));
assert_eq!(parse_range("bytes=0-", 500), Some((0, 499)));
assert_eq!(parse_range("bytes=299-", 500), Some((299, 499)));
assert_eq!(parse_range("bytes=-500", 500), Some((0, 499)));
assert_eq!(parse_range("bytes=-300", 500), Some((200, 499)));
assert_eq!(parse_range("bytes=500-", 500), None);
assert_eq!(parse_range("bytes=-501", 500), None);
assert_eq!(parse_range("bytes=0-500", 500), None);
}
}

View File

@@ -11,10 +11,11 @@ use std::process::{Command, Stdio};
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{ver}_index.js");
let index_css = format!("/__dufs_v{ver}_index.css");
let favicon_ico = format!("/__dufs_v{ver}_favicon.ico");
let index_js = format!("/__dufs_v{ver}__/index.js");
let index_css = format!("/__dufs_v{ver}__/index.css");
let favicon_ico = format!("/__dufs_v{ver}__/favicon.ico");
let text = resp.text()?;
println!("{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}""#)));
@@ -24,7 +25,7 @@ fn assets(server: TestServer) -> Result<(), Error> {
#[rstest]
fn asset_js(server: TestServer) -> Result<(), Error> {
let url = format!(
"{}__dufs_v{}_index.js",
"{}__dufs_v{}__/index.js",
server.url(),
env!("CARGO_PKG_VERSION")
);
@@ -32,7 +33,7 @@ fn asset_js(server: TestServer) -> Result<(), Error> {
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/javascript"
"application/javascript; charset=UTF-8"
);
Ok(())
}
@@ -40,20 +41,23 @@ fn asset_js(server: TestServer) -> Result<(), Error> {
#[rstest]
fn asset_css(server: TestServer) -> Result<(), Error> {
let url = format!(
"{}__dufs_v{}_index.css",
"{}__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");
assert_eq!(
resp.headers().get("content-type").unwrap(),
"text/css; charset=UTF-8"
);
Ok(())
}
#[rstest]
fn asset_ico(server: TestServer) -> Result<(), Error> {
let url = format!(
"{}__dufs_v{}_favicon.ico",
"{}__dufs_v{}__/favicon.ico",
server.url(),
env!("CARGO_PKG_VERSION")
);
@@ -67,9 +71,9 @@ fn asset_ico(server: TestServer) -> Result<(), Error> {
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{ver}_index.js");
let index_css = format!("/xyz/__dufs_v{ver}_index.css");
let favicon_ico = format!("/xyz/__dufs_v{ver}_favicon.ico");
let index_js = format!("/xyz/__dufs_v{ver}__/index.js");
let index_css = format!("/xyz/__dufs_v{ver}__/index.css");
let favicon_ico = format!("/xyz/__dufs_v{ver}__/favicon.ico");
let text = resp.text()?;
assert!(text.contains(&format!(r#"href="{index_css}""#)));
assert!(text.contains(&format!(r#"href="{favicon_ico}""#)));
@@ -82,7 +86,7 @@ fn asset_js_with_prefix(
#[with(&["--path-prefix", "xyz"])] server: TestServer,
) -> Result<(), Error> {
let url = format!(
"{}xyz/__dufs_v{}_index.js",
"{}xyz/__dufs_v{}__/index.js",
server.url(),
env!("CARGO_PKG_VERSION")
);
@@ -90,7 +94,7 @@ fn asset_js_with_prefix(
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/javascript"
"application/javascript; charset=UTF-8"
);
Ok(())
}
@@ -111,7 +115,7 @@ fn assets_override(tmpdir: TempDir, port: u16) -> Result<(), Error> {
let url = format!("http://localhost:{port}");
let resp = reqwest::blocking::get(&url)?;
assert!(resp.text()?.starts_with(&format!(
"/__dufs_v{}_index.js;DATA",
"/__dufs_v{}__/index.js;DATA",
env!("CARGO_PKG_VERSION")
)));
let resp = reqwest::blocking::get(&url)?;

View File

@@ -10,7 +10,15 @@ use rstest::rstest;
fn no_auth(#[with(&["--auth", "user:pass@/:rw", "-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?;
assert_eq!(resp.status(), 401);
assert!(resp.headers().contains_key("www-authenticate"));
let values: Vec<&str> = resp
.headers()
.get_all("www-authenticate")
.iter()
.map(|v| v.to_str().unwrap())
.collect();
assert!(values[0].starts_with("Digest"));
assert!(values[1].starts_with("Basic"));
let url = format!("{}file1", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 401);
@@ -18,17 +26,38 @@ fn no_auth(#[with(&["--auth", "user:pass@/:rw", "-A"])] server: TestServer) -> R
}
#[rstest]
fn auth(#[with(&["--auth", "user:pass@/:rw", "-A"])] server: TestServer) -> Result<(), Error> {
#[case(server(&["--auth", "user:pass@/:rw", "-A"]), "user", "pass")]
#[case(server(&["--auth", "user:pa:ss@1@/:rw", "-A"]), "user", "pa:ss@1")]
fn auth(#[case] server: TestServer, #[case] user: &str, #[case] pass: &str) -> Result<(), Error> {
let url = format!("{}file1", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 401);
let resp = fetch!(b"PUT", &url)
.body(b"abc".to_vec())
.send_with_digest_auth("user", "pass")?;
.send_with_digest_auth(user, pass)?;
assert_eq!(resp.status(), 201);
Ok(())
}
#[rstest]
fn invalid_auth(
#[with(&["-a", "user:pass@/:rw", "-a", "@/", "-A"])] server: TestServer,
) -> Result<(), Error> {
let resp = fetch!(b"GET", server.url())
.basic_auth("user", Some("-"))
.send()?;
assert_eq!(resp.status(), 401);
let resp = fetch!(b"GET", server.url())
.basic_auth("-", Some("pass"))
.send()?;
assert_eq!(resp.status(), 401);
let resp = fetch!(b"GET", server.url())
.header("Authorization", "Basic Og==")
.send()?;
assert_eq!(resp.status(), 401);
Ok(())
}
const HASHED_PASSWORD_AUTH: &str = "user:$6$gQxZwKyWn/ZmWEA2$4uV7KKMnSUnET2BtWTj/9T5.Jq3h/MdkOlnIl5hdlTxDZ4MZKmJ.kl6C.NL9xnNPqC4lVHC1vuI0E5cLpTJX81@/:rw"; // user:pass
#[rstest]
@@ -57,7 +86,7 @@ fn auth_hashed_password(
#[rstest]
fn auth_and_public(
#[with(&["--auth", "user:pass@/:rw|@/", "-A"])] server: TestServer,
#[with(&["-a", "user:pass@/:rw", "-a", "@/", "-A"])] server: TestServer,
) -> Result<(), Error> {
let url = format!("{}file1", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
@@ -91,6 +120,20 @@ fn auth_skip_on_options_method(
#[rstest]
fn auth_check(
#[with(&["--auth", "user:pass@/:rw", "--auth", "user2:pass2@/", "-A"])] server: TestServer,
) -> Result<(), Error> {
let url = format!("{}index.html", server.url());
let resp = fetch!(b"WRITEABLE", &url).send()?;
assert_eq!(resp.status(), 401);
let resp = fetch!(b"WRITEABLE", &url).send_with_digest_auth("user2", "pass2")?;
assert_eq!(resp.status(), 403);
let resp = fetch!(b"WRITEABLE", &url).send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 200);
Ok(())
}
#[rstest]
fn auth_compact_rules(
#[with(&["--auth", "user:pass@/:rw|user2:pass2@/", "-A"])] server: TestServer,
) -> Result<(), Error> {
let url = format!("{}index.html", server.url());
@@ -105,7 +148,7 @@ fn auth_check(
#[rstest]
fn auth_readonly(
#[with(&["--auth", "user:pass@/:rw|user2:pass2@/", "-A"])] server: TestServer,
#[with(&["--auth", "user:pass@/:rw", "--auth", "user2:pass2@/", "-A"])] server: TestServer,
) -> Result<(), Error> {
let url = format!("{}index.html", server.url());
let resp = fetch!(b"GET", &url).send()?;
@@ -122,7 +165,7 @@ fn auth_readonly(
#[rstest]
fn auth_nest(
#[with(&["--auth", "user:pass@/:rw|user2:pass2@/", "--auth", "user3:pass3@/dir1:rw", "-A"])]
#[with(&["--auth", "user:pass@/:rw", "--auth", "user2:pass2@/", "--auth", "user3:pass3@/dir1:rw", "-A"])]
server: TestServer,
) -> Result<(), Error> {
let url = format!("{}dir1/file1", server.url());
@@ -230,7 +273,7 @@ fn auth_partial_index(
#[rstest]
fn no_auth_propfind_dir(
#[with(&["--auth", "user:pass@/:rw", "--auth", "@/dir-assets", "-A"])] server: TestServer,
#[with(&["--auth", "admin:admin@/:rw", "--auth", "@/dir-assets", "-A"])] server: TestServer,
) -> Result<(), Error> {
let resp = fetch!(b"PROPFIND", server.url()).send()?;
assert_eq!(resp.status(), 207);
@@ -240,9 +283,22 @@ fn no_auth_propfind_dir(
Ok(())
}
#[rstest]
fn auth_propfind_dir(
#[with(&["--auth", "admin:admin@/:rw", "--auth", "user:pass@/dir-assets", "-A"])]
server: TestServer,
) -> Result<(), Error> {
let resp = fetch!(b"PROPFIND", server.url()).send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 207);
let body = resp.text()?;
assert!(body.contains("<D:href>/dir-assets/</D:href>"));
assert!(!body.contains("<D:href>/dir1/</D:href>"));
Ok(())
}
#[rstest]
fn auth_data(
#[with(&["--auth", "user:pass@/:rw|@/", "-A"])] server: TestServer,
#[with(&["-a", "user:pass@/:rw", "-a", "@/", "-A"])] server: TestServer,
) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?;
let content = resp.text()?;
@@ -258,3 +314,22 @@ fn auth_data(
assert_eq!(json["allow_upload"], serde_json::Value::Bool(true));
Ok(())
}
#[rstest]
fn auth_precedence(
#[with(&["--auth", "user:pass@/dir1:rw,/dir1/test.txt", "-A"])] server: TestServer,
) -> Result<(), Error> {
let url = format!("{}dir1/test.txt", server.url());
let resp = fetch!(b"PUT", &url)
.body(b"abc".to_vec())
.send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 403);
let url = format!("{}dir1/file1", server.url());
let resp = fetch!(b"PUT", &url)
.body(b"abc".to_vec())
.send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 201);
Ok(())
}

View File

@@ -16,7 +16,14 @@ pub const BIN_FILE: &str = "😀.bin";
/// File names for testing purpose
#[allow(dead_code)]
pub static FILES: &[&str] = &["test.txt", "test.html", "index.html", BIN_FILE];
pub static FILES: &[&str] = &[
"test.txt",
"test.html",
"index.html",
#[cfg(not(target_os = "windows"))]
"file\n1.txt",
BIN_FILE,
];
/// Directory names for testing directory don't exist
#[allow(dead_code)]

View File

@@ -59,7 +59,7 @@ fn hidden_search_dir(#[case] server: TestServer, #[case] exist: bool) -> Result<
#[rstest]
#[case(server(&["--hidden", "hidden/"]), "dir4/", 1)]
#[case(server(&["--hidden", "hidden"]), "dir4/", 0)]
fn hidden_dir_noly(
fn hidden_dir_only(
#[case] server: TestServer,
#[case] dir: &str,
#[case] count: usize,

View File

@@ -40,7 +40,12 @@ fn head_dir_404(server: TestServer) -> Result<(), Error> {
}
#[rstest]
fn get_dir_zip(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
#[case(server(&["--allow-archive"] as &[&str]))]
#[case(server(&["--allow-archive", "--compress", "none"]))]
#[case(server(&["--allow-archive", "--compress", "low"]))]
#[case(server(&["--allow-archive", "--compress", "medium"]))]
#[case(server(&["--allow-archive", "--compress", "high"]))]
fn get_dir_zip(#[case] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?zip", server.url()))?;
assert_eq!(resp.status(), 200);
assert_eq!(
@@ -147,9 +152,7 @@ fn head_dir_search(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
#[rstest]
fn empty_search(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?q=", server.url()))?;
assert_eq!(resp.status(), 200);
let paths = utils::retrieve_index_paths(&resp.text()?);
assert!(paths.is_empty());
assert_resp_paths!(resp);
Ok(())
}
@@ -204,6 +207,18 @@ fn get_file_emoji_path(server: TestServer) -> Result<(), Error> {
Ok(())
}
#[cfg(not(target_os = "windows"))]
#[rstest]
fn get_file_newline_path(server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}file%0A1.txt", server.url()))?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-disposition").unwrap(),
"inline; filename=\"file 1.txt\""
);
Ok(())
}
#[rstest]
fn get_file_edit(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"GET", format!("{}index.html?edit", server.url())).send()?;
@@ -235,9 +250,12 @@ fn options_dir(server: TestServer) -> Result<(), Error> {
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("allow").unwrap(),
"GET,HEAD,PUT,OPTIONS,DELETE,PROPFIND,COPY,MOVE"
"GET,HEAD,PUT,OPTIONS,DELETE,PATCH,PROPFIND,COPY,MOVE"
);
assert_eq!(
resp.headers().get("dav").unwrap(),
"1, 2, 3, sabredav-partialupdate"
);
assert_eq!(resp.headers().get("dav").unwrap(), "1,2");
Ok(())
}
@@ -315,3 +333,19 @@ fn get_file_content_type(server: TestServer) -> Result<(), Error> {
);
Ok(())
}
#[rstest]
fn resumable_upload(#[with(&["--allow-upload"])] server: TestServer) -> Result<(), Error> {
let url = format!("{}file1", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 201);
let resp = fetch!(b"PATCH", &url)
.header("X-Update-Range", "append")
.body(b"123".to_vec())
.send()?;
assert_eq!(resp.status(), 204);
let resp = reqwest::blocking::get(url)?;
assert_eq!(resp.status(), 200);
assert_eq!(resp.text().unwrap(), "abc123");
Ok(())
}

View File

@@ -74,5 +74,7 @@ fn no_log(tmpdir: TempDir, port: u16, #[case] args: &[&str]) -> Result<(), Error
let output = std::str::from_utf8(&buf[0..buf_len])?;
assert_eq!(output.lines().last().unwrap(), "");
child.kill()?;
Ok(())
}

View File

@@ -2,7 +2,7 @@ mod fixtures;
mod utils;
use fixtures::{server, Error, TestServer};
use headers::HeaderValue;
use reqwest::header::HeaderValue;
use rstest::rstest;
#[rstest]
@@ -23,14 +23,10 @@ fn get_file_range_beyond(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"GET", format!("{}index.html", server.url()))
.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.status(), 416);
assert_eq!(resp.headers().get("content-range").unwrap(), "bytes */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");
assert_eq!(resp.headers().get("content-length").unwrap(), "0");
Ok(())
}

View File

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

View File

@@ -20,7 +20,7 @@ macro_rules! assert_resp_paths {
#[macro_export]
macro_rules! fetch {
($method:literal, $url:expr) => {
reqwest::blocking::Client::new().request(hyper::Method::from_bytes($method)?, $url)
reqwest::blocking::Client::new().request(reqwest::Method::from_bytes($method)?, $url)
};
}

View File

@@ -49,7 +49,7 @@ fn propfind_404(server: TestServer) -> Result<(), Error> {
#[rstest]
fn propfind_double_slash(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"PROPFIND", format!("{}/", server.url())).send()?;
let resp = fetch!(b"PROPFIND", server.url()).send()?;
assert_eq!(resp.status(), 207);
Ok(())
}