Compare commits

...

29 Commits

Author SHA1 Message Date
sigoden
d2270be8fb chore: update changelog 2022-06-21 07:56:24 +08:00
sigoden
2d0dfed456 chore(release): version v0.20.0 2022-06-21 07:52:45 +08:00
sigoden
4058a2db72 feat: add option --allow-search (#62) 2022-06-21 07:23:20 +08:00
sigoden
069cb64889 fix: decodeURI searching string (#61) 2022-06-20 21:51:41 +08:00
sigoden
c85ea06785 chore: update cli help message and reamde 2022-06-20 19:40:09 +08:00
sigoden
68139c6263 chore: little improves 2022-06-20 15:11:39 +08:00
Joe Koop
deb6365a28 feat: added basic auth (#60)
* some small css fixes and changes

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

* most tests are passing

* fixed all the tests

* maybe now CI will pass

* implemented sigoden's suggestions

* test basic auth

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

BREAKING CHANGE: rename duf to dufs
2022-06-19 22:53:51 +08:00
sigoden
3260b52c47 chore: fix breadcrumb 2022-06-19 22:22:49 +08:00
sigoden
7194ebf248 chore: adjust ui 2022-06-19 22:16:43 +08:00
Joe Koop
b1b0fdd4db feat: reactive webpage (#51) 2022-06-19 22:04:59 +08:00
sigoden
db71f75236 feat: ui hidden root dirname (#58)
close #56
2022-06-19 21:23:19 +08:00
sigoden
e66951fd11 refactor: rename --cors to --enable-cors (#57)
BREAKING CHANGE: `--cors` rename to `--enable-cors`
2022-06-19 17:27:09 +08:00
sigoden
051ff8da2d chore: update readme 2022-06-19 15:30:42 +08:00
sigoden
c3ac2a21c9 feat: serve single file (#54)
close #53
2022-06-19 14:23:10 +08:00
sigoden
9c2e9d1503 feat: path level access control (#52)
BREAKING CHANGE: `--auth` is changed, `--no-auth-access` is removed
2022-06-19 11:26:03 +08:00
sigoden
9384cc8587 chore(release): version v0.18.0 2022-06-18 08:05:18 +08:00
sigoden
df48021757 chore: send not found text when 404 2022-06-18 08:03:48 +08:00
sigoden
af866aaaf4 chore: optimize --render-try-index 2022-06-17 19:05:25 +08:00
sigoden
68d238d34d feat: add slash to end of dir href 2022-06-17 19:01:17 +08:00
sigoden
a10150f2f8 chore: update readme 2022-06-17 10:59:19 +08:00
sigoden
5b11bb75dd feat: add option --render-try-index (#47)
close #46
2022-06-17 08:41:01 +08:00
sigoden
6d7da0363c chore(release): version v0.17.1 2022-06-16 10:31:47 +08:00
sigoden
d8f7335053 fix: range request (#44)
close #43
2022-06-16 10:24:32 +08:00
sigoden
7fc8fc6236 chore(release): version v0.17.0 2022-06-15 20:31:53 +08:00
sigoden
c7d42a3f1c fix: webdav propfind dir with slash (#42) 2022-06-15 20:24:53 +08:00
sigoden
3c4bb77023 refactor: trival changes (#41)
- refactor status code
- log remote addr and time in miliseconds
2022-06-15 19:57:28 +08:00
sigoden
12aafa00d8 feat: listen both ipv4 and ipv6 by default (#40) 2022-06-15 19:33:51 +08:00
26 changed files with 1550 additions and 690 deletions

View File

@@ -2,47 +2,99 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [0.20.0] - 2022-06-20
### Bug Fixes
- DecodeURI searching string ([#61](https://github.com/sigoden/dufs/issues/61))
### Features
- Added basic auth ([#60](https://github.com/sigoden/dufs/issues/60))
- Add option --allow-search ([#62](https://github.com/sigoden/dufs/issues/62))
## [0.19.0] - 2022-06-19
### Features
- [**breaking**] Path level access control ([#52](https://github.com/sigoden/dufs/issues/52))
- Serve single file ([#54](https://github.com/sigoden/dufs/issues/54))
- Ui hidden root dirname ([#58](https://github.com/sigoden/dufs/issues/58))
- Reactive webpage ([#51](https://github.com/sigoden/dufs/issues/51))
- [**breaking**] Rename to dufs ([#59](https://github.com/sigoden/dufs/issues/59))
### Refactor
- [**breaking**] Rename --cors to --enable-cors ([#57](https://github.com/sigoden/dufs/issues/57))
## [0.18.0] - 2022-06-18
### Features
- Add option --render-try-index ([#47](https://github.com/sigoden/dufs/issues/47))
- Add slash to end of dir href
## [0.17.1] - 2022-06-16
### Bug Fixes
- Range request ([#44](https://github.com/sigoden/dufs/issues/44))
## [0.17.0] - 2022-06-15
### Bug Fixes
- Webdav propfind dir with slash ([#42](https://github.com/sigoden/dufs/issues/42))
### Features
- Listen both ipv4 and ipv6 by default ([#40](https://github.com/sigoden/dufs/issues/40))
### Refactor
- Trival changes ([#41](https://github.com/sigoden/dufs/issues/41))
## [0.16.0] - 2022-06-12 ## [0.16.0] - 2022-06-12
### Features ### Features
- Implement head method ([#33](https://github.com/sigoden/duf/issues/33)) - Implement head method ([#33](https://github.com/sigoden/dufs/issues/33))
- Display upload speed and time left ([#34](https://github.com/sigoden/duf/issues/34)) - Display upload speed and time left ([#34](https://github.com/sigoden/dufs/issues/34))
- Support tls-key in pkcs#8 format ([#35](https://github.com/sigoden/duf/issues/35)) - Support tls-key in pkcs#8 format ([#35](https://github.com/sigoden/dufs/issues/35))
- Options method return status 200 - Options method return status 200
### Testing ### Testing
- Add integration tests ([#36](https://github.com/sigoden/duf/issues/36)) - Add integration tests ([#36](https://github.com/sigoden/dufs/issues/36))
## [0.15.1] - 2022-06-11 ## [0.15.1] - 2022-06-11
### Bug Fixes ### Bug Fixes
- Cannot upload ([#32](https://github.com/sigoden/duf/issues/32)) - Cannot upload ([#32](https://github.com/sigoden/dufs/issues/32))
## [0.15.0] - 2022-06-10 ## [0.15.0] - 2022-06-10
### Bug Fixes ### Bug Fixes
- Encode webdav href as uri ([#28](https://github.com/sigoden/duf/issues/28)) - Encode webdav href as uri ([#28](https://github.com/sigoden/dufs/issues/28))
- Query dir param - Query dir param
### Features ### Features
- Add basic dark theme ([#29](https://github.com/sigoden/duf/issues/29)) - Add basic dark theme ([#29](https://github.com/sigoden/dufs/issues/29))
- Add empty state placeholder to page([#30](https://github.com/sigoden/duf/issues/30)) - Add empty state placeholder to page([#30](https://github.com/sigoden/dufs/issues/30))
## [0.14.0] - 2022-06-07 ## [0.14.0] - 2022-06-07
### Bug Fixes ### Bug Fixes
- Send index page with content-type ([#26](https://github.com/sigoden/duf/issues/26)) - Send index page with content-type ([#26](https://github.com/sigoden/dufs/issues/26))
### Features ### Features
- Support ipv6 ([#25](https://github.com/sigoden/duf/issues/25)) - Support ipv6 ([#25](https://github.com/sigoden/dufs/issues/25))
- Add favicon ([#27](https://github.com/sigoden/duf/issues/27)) - Add favicon ([#27](https://github.com/sigoden/dufs/issues/27))
## [0.13.2] - 2022-06-06 ## [0.13.2] - 2022-06-06
@@ -55,11 +107,11 @@ All notable changes to this project will be documented in this file.
### Bug Fixes ### Bug Fixes
- Escape filename ([#21](https://github.com/sigoden/duf/issues/21)) - Escape filename ([#21](https://github.com/sigoden/dufs/issues/21))
### Refactor ### Refactor
- Use logger ([#22](https://github.com/sigoden/duf/issues/22)) - Use logger ([#22](https://github.com/sigoden/dufs/issues/22))
## [0.13.0] - 2022-06-05 ## [0.13.0] - 2022-06-05
@@ -69,16 +121,16 @@ All notable changes to this project will be documented in this file.
### Features ### Features
- Implement more webdav methods ([#13](https://github.com/sigoden/duf/issues/13)) - Implement more webdav methods ([#13](https://github.com/sigoden/dufs/issues/13))
- Use digest auth ([#14](https://github.com/sigoden/duf/issues/14)) - Use digest auth ([#14](https://github.com/sigoden/dufs/issues/14))
- Add webdav proppatch handler ([#18](https://github.com/sigoden/duf/issues/18)) - Add webdav proppatch handler ([#18](https://github.com/sigoden/dufs/issues/18))
## [0.12.1] - 2022-06-04 ## [0.12.1] - 2022-06-04
### Features ### Features
- Support webdav ([#10](https://github.com/sigoden/duf/issues/10)) - Support webdav ([#10](https://github.com/sigoden/dufs/issues/10))
- Remove unzip uploaded feature ([#11](https://github.com/sigoden/duf/issues/11)) - Remove unzip uploaded feature ([#11](https://github.com/sigoden/dufs/issues/11))
## [0.11.0] - 2022-06-03 ## [0.11.0] - 2022-06-03

126
Cargo.lock generated
View File

@@ -188,6 +188,27 @@ dependencies = [
"wasm-bindgen-futures", "wasm-bindgen-futures",
] ]
[[package]]
name = "async-stream"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dad5c83079eae9969be7fadefe640a1c566901f05ff91ab221de4b6f68d9507e"
dependencies = [
"async-stream-impl",
"futures-core",
]
[[package]]
name = "async-stream-impl"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10f203db73a71dfa2fb6dd22763990fa26f3d2625a6da2da900d23b87d26be27"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "async-task" name = "async-task"
version = "4.2.0" version = "4.2.0"
@@ -395,22 +416,22 @@ dependencies = [
[[package]] [[package]]
name = "clap" name = "clap"
version = "3.1.18" version = "3.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2dbdf4bdacb33466e854ce889eee8dfd5729abf7ccd7664d0a2d60cd384440b" checksum = "6d20de3739b4fb45a17837824f40aa1769cc7655d7a83e68739a77fe7b30c87a"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"clap_lex", "clap_lex",
"indexmap", "indexmap",
"lazy_static", "terminal_size",
"textwrap", "textwrap",
] ]
[[package]] [[package]]
name = "clap_lex" name = "clap_lex"
version = "0.2.0" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a37c35f1112dad5e6e0b1adaff798507497a18fceeb30cceb3bae7d1427b9213" checksum = "5538cd660450ebeb4234cfecf8f2284b844ffc4c50531e66d584ad5b91293613"
dependencies = [ dependencies = [
"os_str_bytes", "os_str_bytes",
] ]
@@ -550,11 +571,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10"
[[package]] [[package]]
name = "duf" name = "dufs"
version = "0.16.0" version = "0.20.0"
dependencies = [ dependencies = [
"assert_cmd", "assert_cmd",
"assert_fs", "assert_fs",
"async-stream",
"async-walkdir", "async-walkdir",
"async_zip", "async_zip",
"base64", "base64",
@@ -578,13 +600,13 @@ dependencies = [
"reqwest", "reqwest",
"rstest", "rstest",
"rustls", "rustls",
"rustls-pemfile 1.0.0", "rustls-pemfile",
"select", "select",
"serde", "serde",
"serde_json", "serde_json",
"socket2",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tokio-stream",
"tokio-util", "tokio-util",
"url", "url",
"urlencoding", "urlencoding",
@@ -853,13 +875,13 @@ dependencies = [
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.6" version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9be70c98951c83b8d2f8f60d7065fa6d5146873094452a1008da8c2f1e4205ad" checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"wasi 0.10.0+wasi-snapshot-preview1", "wasi 0.11.0+wasi-snapshot-preview1",
] ]
[[package]] [[package]]
@@ -1147,9 +1169,9 @@ dependencies = [
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.57" version = "0.3.58"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "671a26f820db17c2a2750743f1dd03bafd15b98c9f30c7c2628c024c05d73397" checksum = "c3fac17f7123a73ca62df411b1bf727ccc805daa070338fda671c86dac1bdc27"
dependencies = [ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
@@ -1696,7 +1718,7 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7"
dependencies = [ dependencies = [
"getrandom 0.2.6", "getrandom 0.2.7",
] ]
[[package]] [[package]]
@@ -1760,9 +1782,9 @@ dependencies = [
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.11.10" version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46a1f7aa4f35e5e8b4160449f51afc758f0ce6454315a9fa7d0d113e958c41eb" checksum = "b75aa69a3f06bbcc66ede33af2af253c6f7a86b1ca0033f60c580a27074fbf92"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",
@@ -1785,13 +1807,14 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustls", "rustls",
"rustls-pemfile 0.3.0", "rustls-pemfile",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
"tokio", "tokio",
"tokio-native-tls", "tokio-native-tls",
"tokio-rustls", "tokio-rustls",
"tower-service",
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
@@ -1862,15 +1885,6 @@ dependencies = [
"webpki", "webpki",
] ]
[[package]]
name = "rustls-pemfile"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ee86d63972a7c661d1536fefe8c3c8407321c3df668891286de28abcd087360"
dependencies = [
"base64",
]
[[package]] [[package]]
name = "rustls-pemfile" name = "rustls-pemfile"
version = "1.0.0" version = "1.0.0"
@@ -2133,6 +2147,16 @@ dependencies = [
"utf-8", "utf-8",
] ]
[[package]]
name = "terminal_size"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df"
dependencies = [
"libc",
"winapi 0.3.9",
]
[[package]] [[package]]
name = "termtree" name = "termtree"
version = "0.2.4" version = "0.2.4"
@@ -2144,6 +2168,9 @@ name = "textwrap"
version = "0.15.0" version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb" checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb"
dependencies = [
"terminal_size",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
@@ -2251,17 +2278,6 @@ dependencies = [
"webpki", "webpki",
] ]
[[package]]
name = "tokio-stream"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df54d54117d6fdc4e4fea40fe1e4e566b3505700e148a6827e59b34b0d2600d9"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.3" version = "0.7.3"
@@ -2331,9 +2347,9 @@ checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.0" version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d22af068fba1eb5edcb4aea19d382b2a3deb4c8f9d475c589b6ada9e0fd493ee" checksum = "5bd2fe26506023ed7b5e1e315add59d6f584c621d037f9368fea9cfb988f368c"
[[package]] [[package]]
name = "unicode-normalization" name = "unicode-normalization"
@@ -2380,7 +2396,7 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd6469f4314d5f1ffec476e05f17cc9a78bc7a27a6a857842170bdf8d6f98d2f" checksum = "dd6469f4314d5f1ffec476e05f17cc9a78bc7a27a6a857842170bdf8d6f98d2f"
dependencies = [ dependencies = [
"getrandom 0.2.6", "getrandom 0.2.7",
"rand 0.8.5", "rand 0.8.5",
] ]
@@ -2462,9 +2478,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.80" version = "0.2.81"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27370197c907c55e3f1a9fbe26f44e937fe6451368324e009cba39e139dc08ad" checksum = "7c53b543413a17a202f4be280a7e5c62a1c69345f5de525ee64f8cfdbc954994"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"wasm-bindgen-macro", "wasm-bindgen-macro",
@@ -2472,9 +2488,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-backend" name = "wasm-bindgen-backend"
version = "0.2.80" version = "0.2.81"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e04185bfa3a779273da532f5025e33398409573f348985af9a1cbf3774d3f4" checksum = "5491a68ab4500fa6b4d726bd67408630c3dbe9c4fe7bda16d5c82a1fd8c7340a"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"lazy_static", "lazy_static",
@@ -2487,9 +2503,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-futures" name = "wasm-bindgen-futures"
version = "0.4.30" version = "0.4.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f741de44b75e14c35df886aff5f1eb73aa114fa5d4d00dcd37b5e01259bf3b2" checksum = "de9a9cec1733468a8c657e57fa2413d2ae2c0129b95e87c5b72b8ace4d13f31f"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys", "js-sys",
@@ -2499,9 +2515,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.80" version = "0.2.81"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17cae7ff784d7e83a2fe7611cfe766ecf034111b49deb850a3dc7699c08251f5" checksum = "c441e177922bc58f1e12c022624b6216378e5febc2f0533e41ba443d505b80aa"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@@ -2509,9 +2525,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.80" version = "0.2.81"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99ec0dc7a4756fffc231aab1b9f2f578d23cd391390ab27f952ae0c9b3ece20b" checksum = "7d94ac45fcf608c1f45ef53e748d35660f168490c10b23704c7779ab8f5c3048"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -2522,15 +2538,15 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.80" version = "0.2.81"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d554b7f530dee5964d9a9468d95c1f8b8acae4f282807e7d27d4b03099a46744" checksum = "6a89911bd99e5f3659ec4acf9c4d93b0a90fe4a2a11f15328472058edc5261be"
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.57" version = "0.3.58"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b17e741662c70c8bd24ac5c5b18de314a2c26c32bf8346ee1e6f53de919c283" checksum = "2fed94beee57daf8dd7d51f2b15dc2bcde92d7a72304cdf662a4371008b71b90"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",

View File

@@ -1,22 +1,21 @@
[package] [package]
name = "duf" name = "dufs"
version = "0.16.0" version = "0.20.0"
edition = "2021" edition = "2021"
authors = ["sigoden <sigoden@gmail.com>"] authors = ["sigoden <sigoden@gmail.com>"]
description = "Duf is a simple file server." description = "Dufs is a distinctive utility file server"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
homepage = "https://github.com/sigoden/duf" homepage = "https://github.com/sigoden/dufs"
repository = "https://github.com/sigoden/duf" repository = "https://github.com/sigoden/dufs"
categories = ["command-line-utilities", "web-programming::http-server"] categories = ["command-line-utilities", "web-programming::http-server"]
keywords = ["static", "file", "server", "webdav", "cli"] keywords = ["static", "file", "server", "webdav", "cli"]
[dependencies] [dependencies]
clap = { version = "3", default-features = false, features = ["std", "cargo"] } clap = { version = "3", default-features = false, features = ["std", "wrap_help"] }
chrono = "0.4" chrono = "0.4"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util", "signal"]} tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util", "signal"]}
tokio-rustls = "0.23" tokio-rustls = "0.23"
tokio-stream = { version = "0.1", features = ["net"] } tokio-util = { version = "0.7", features = ["io-util"] }
tokio-util = { version = "0.7", features = ["codec", "io-util"] }
hyper = { version = "0.14", features = ["http1", "server", "tcp", "stream"] } hyper = { version = "0.14", features = ["http1", "server", "tcp", "stream"] }
percent-encoding = "2.1" percent-encoding = "2.1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
@@ -37,6 +36,8 @@ urlencoding = "2.1"
xml-rs = "0.8" xml-rs = "0.8"
env_logger = { version = "0.9", default-features = false, features = ["humantime"] } env_logger = { version = "0.9", default-features = false, features = ["humantime"] }
log = "0.4" log = "0.4"
socket2 = "0.4"
async-stream = "0.3"
[dev-dependencies] [dev-dependencies]
assert_cmd = "2" assert_cmd = "2"

View File

@@ -6,5 +6,5 @@ COPY . .
RUN cargo build --target x86_64-unknown-linux-musl --release RUN cargo build --target x86_64-unknown-linux-musl --release
FROM scratch FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/duf /bin/ COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/dufs /bin/
ENTRYPOINT ["/bin/duf"] ENTRYPOINT ["/bin/dufs"]

145
README.md
View File

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

View File

@@ -1,15 +1,16 @@
html { html {
font-family: -apple-system,BlinkMacSystemFont,Helvetica,Arial,sans-serif; font-family: -apple-system,BlinkMacSystemFont,Roboto,Helvetica,Arial,sans-serif;
line-height: 1.5; line-height: 1.5;
color: #24292e; color: #24292e;
} }
body { body {
width: 700px; /* prevent premature breadcrumb wrapping on mobile */
min-width: 500px;
} }
.hidden { .hidden {
display: none; display: none !important;
} }
.head { .head {
@@ -21,6 +22,7 @@ body {
.breadcrumb { .breadcrumb {
font-size: 1.25em; font-size: 1.25em;
padding-right: 0.6em;
} }
.breadcrumb > a { .breadcrumb > a {
@@ -45,12 +47,16 @@ body {
.breadcrumb svg { .breadcrumb svg {
height: 100%; height: 100%;
fill: rgba(3,47,98,0.5); fill: rgba(3,47,98,0.5);
padding-right: 0.5em;
padding-left: 0.5em;
} }
.toolbox { .toolbox {
display: flex; display: flex;
margin-right: 10px;
}
.toolbox > div {
/* vertically align with breadcrumb text */
height: 1.1rem;
} }
.searchbar { .searchbar {
@@ -62,7 +68,7 @@ body {
transition: all .15s; transition: all .15s;
border: 1px #ddd solid; border: 1px #ddd solid;
border-radius: 15px; border-radius: 15px;
margin: 0 0 2px 10px; margin-bottom: 2px;
} }
.searchbar #search { .searchbar #search {
@@ -120,11 +126,6 @@ body {
white-space: nowrap; white-space: nowrap;
} }
.uploaders-table .cell-name,
.paths-table .cell-name {
width: 500px;
}
.uploaders-table .cell-status { .uploaders-table .cell-status {
width: 80px; width: 80px;
padding-left: 0.6em; padding-left: 0.6em;
@@ -147,15 +148,14 @@ body {
padding-left: 0.6em; padding-left: 0.6em;
} }
.path svg { .path svg {
height: 100%; height: 100%;
fill: rgba(3,47,98,0.5); fill: rgba(3,47,98,0.5);
padding-right: 0.5em; padding-right: 0.5em;
vertical-align: text-top;
} }
.path { .path {
display: flex;
list-style: none; list-style: none;
} }
@@ -166,6 +166,8 @@ body {
overflow: hidden; overflow: hidden;
display: block; display: block;
text-decoration: none; text-decoration: none;
max-width: calc(100vw - 375px);
min-width: 200px;
} }
.path a:hover { .path a:hover {
@@ -184,6 +186,12 @@ body {
padding-right: 1em; padding-right: 1em;
} }
@media (min-width: 768px) {
.path a {
min-width: 400px;
}
}
/* dark theme */ /* dark theme */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
body { body {
@@ -202,8 +210,9 @@ body {
} }
svg, svg,
.path svg { .path svg,
fill: #d0e6ff; .breadcrumb svg {
fill: #fff;
} }
.searchbar { .searchbar {

View File

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

View File

@@ -1,7 +1,6 @@
/** /**
* @typedef {object} PathItem * @typedef {object} PathItem
* @property {"Dir"|"SymlinkDir"|"File"|"SymlinkFile"} path_type * @property {"Dir"|"SymlinkDir"|"File"|"SymlinkFile"} path_type
* @property {boolean} is_symlink
* @property {string} name * @property {string} name
* @property {number} mtime * @property {number} mtime
* @property {number} size * @property {number} size
@@ -30,10 +29,6 @@ let $uploadersTable;
* @type Element * @type Element
*/ */
let $emptyFolder; let $emptyFolder;
/**
* @type string
*/
let baseDir;
class Uploader { class Uploader {
/** /**
@@ -72,8 +67,10 @@ class Uploader {
let url = getUrl(name); let url = getUrl(name);
$uploadersTable.insertAdjacentHTML("beforeend", ` $uploadersTable.insertAdjacentHTML("beforeend", `
<tr id="upload${idx}" class="uploader"> <tr id="upload${idx}" class="uploader">
<td class="path cell-icon">
${getSvg(file.path_type)}
</td>
<td class="path cell-name"> <td class="path cell-name">
<div>${getSvg("File")}</div>
<a href="${url}">${name}</a> <a href="${url}">${name}</a>
</td> </td>
<td class="cell-status upload-status" id="uploadStatus${idx}"></td> <td class="cell-status upload-status" id="uploadStatus${idx}"></td>
@@ -123,27 +120,37 @@ class Uploader {
/** /**
* Add breadcrumb * Add breadcrumb
* @param {string} value * @param {string} href
* @param {string} uri_prefix
*/ */
function addBreadcrumb(value) { function addBreadcrumb(href, uri_prefix) {
const $breadcrumb = document.querySelector(".breadcrumb"); const $breadcrumb = document.querySelector(".breadcrumb");
const parts = value.split("/").filter(v => !!v); let parts = [];
if (href === "/") {
parts = [""];
} else {
parts = href.split("/");
}
const len = parts.length; const len = parts.length;
let path = ""; let path = uri_prefix;
for (let i = 0; i < len; i++) { for (let i = 0; i < len; i++) {
const name = parts[i]; const name = parts[i];
if (i > 0) { if (i > 0) {
path += "/" + name; if (!path.endsWith("/")) {
path += "/";
}
path += encodeURI(name);
} }
if (i === len - 1) { 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>`);
} else if (i === len - 1) {
$breadcrumb.insertAdjacentHTML("beforeend", `<b>${name}</b>`); $breadcrumb.insertAdjacentHTML("beforeend", `<b>${name}</b>`);
baseDir = name;
} else if (i === 0) {
$breadcrumb.insertAdjacentHTML("beforeend", `<a href="/"><b>${name}</b></a>`);
} else { } else {
$breadcrumb.insertAdjacentHTML("beforeend", `<a href="${encodeURI(path)}">${name}</a>`); $breadcrumb.insertAdjacentHTML("beforeend", `<a href="${path}">${name}</a>`);
}
if (i !== len - 1) {
$breadcrumb.insertAdjacentHTML("beforeend", `<span class="separator">/</span>`);
} }
$breadcrumb.insertAdjacentHTML("beforeend", `<span class="separator">/</span>`);
} }
} }
@@ -153,10 +160,11 @@ function addBreadcrumb(value) {
* @param {number} index * @param {number} index
*/ */
function addPath(file, index) { function addPath(file, index) {
const url = getUrl(file.name) let url = getUrl(file.name)
let actionDelete = ""; let actionDelete = "";
let actionDownload = ""; let actionDownload = "";
if (file.path_type.endsWith("Dir")) { if (file.path_type.endsWith("Dir")) {
url += "/";
actionDownload = ` actionDownload = `
<div class="action-btn"> <div class="action-btn">
<a href="${url}?zip" title="Download folder as a .zip file"> <a href="${url}?zip" title="Download folder as a .zip file">
@@ -185,8 +193,10 @@ function addPath(file, index) {
$pathsTableBody.insertAdjacentHTML("beforeend", ` $pathsTableBody.insertAdjacentHTML("beforeend", `
<tr id="addPath${index}"> <tr id="addPath${index}">
<td class="path cell-icon">
${getSvg(file.path_type)}
</td>
<td class="path cell-name"> <td class="path cell-name">
<div>${getSvg(file.path_type)}</div>
<a href="${url}" title="${file.name}">${file.name}</a> <a href="${url}" title="${file.name}">${file.name}</a>
</td> </td>
<td class="cell-mtime">${formatMtime(file.mtime)}</td> <td class="cell-mtime">${formatMtime(file.mtime)}</td>
@@ -292,7 +302,7 @@ function formatMtime(mtime) {
const day = padZero(date.getDate(), 2); const day = padZero(date.getDate(), 2);
const hours = padZero(date.getHours(), 2); const hours = padZero(date.getHours(), 2);
const minutes = padZero(date.getMinutes(), 2); const minutes = padZero(date.getMinutes(), 2);
return `${year}/${month}/${day} ${hours}:${minutes}`; return `${year}-${month}-${day} ${hours}:${minutes}`;
} }
function padZero(value, size) { function padZero(value, size) {
@@ -329,11 +339,15 @@ function ready() {
$uploadersTable = document.querySelector(".uploaders-table"); $uploadersTable = document.querySelector(".uploaders-table");
$emptyFolder = document.querySelector(".empty-folder"); $emptyFolder = document.querySelector(".empty-folder");
if (params.q) { if (DATA.allow_search) {
document.getElementById('search').value = params.q; document.querySelector(".searchbar").classList.remove("hidden");
if (params.q) {
document.getElementById('search').value = params.q;
}
} }
addBreadcrumb(DATA.breadcrumb);
addBreadcrumb(DATA.href, DATA.uri_prefix);
if (Array.isArray(DATA.paths)) { if (Array.isArray(DATA.paths)) {
const len = DATA.paths.length; const len = DATA.paths.length;
if (len > 0) { if (len > 0) {

View File

@@ -1,25 +1,32 @@
use clap::crate_description; use clap::{AppSettings, Arg, ArgMatches, Command};
use clap::{Arg, ArgMatches};
use rustls::{Certificate, PrivateKey}; use rustls::{Certificate, PrivateKey};
use std::net::{IpAddr, SocketAddr}; use std::env;
use std::net::IpAddr;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::{env, fs, io};
use crate::auth::parse_auth; use crate::auth::AccessControl;
use crate::auth::AuthMethod;
use crate::tls::{load_certs, load_private_key};
use crate::BoxResult; use crate::BoxResult;
const ABOUT: &str = concat!("\n", crate_description!()); // Add extra newline. fn app() -> Command<'static> {
Command::new(env!("CARGO_CRATE_NAME"))
fn app() -> clap::Command<'static> { .version(env!("CARGO_PKG_VERSION"))
clap::command!() .author(env!("CARGO_PKG_AUTHORS"))
.about(ABOUT) .about(concat!(
env!("CARGO_PKG_DESCRIPTION"),
" - ",
env!("CARGO_PKG_REPOSITORY")
))
.global_setting(AppSettings::DeriveDisplayOrder)
.arg( .arg(
Arg::new("address") Arg::new("bind")
.short('b') .short('b')
.long("bind") .long("bind")
.default_value("0.0.0.0")
.help("Specify bind address") .help("Specify bind address")
.value_name("address"), .multiple_values(true)
.multiple_occurrences(true)
.value_name("addr"),
) )
.arg( .arg(
Arg::new("port") Arg::new("port")
@@ -33,13 +40,30 @@ fn app() -> clap::Command<'static> {
Arg::new("path") Arg::new("path")
.default_value(".") .default_value(".")
.allow_invalid_utf8(true) .allow_invalid_utf8(true)
.help("Path to a root directory for serving files"), .help("Specific path to serve"),
) )
.arg( .arg(
Arg::new("path-prefix") Arg::new("path-prefix")
.long("path-prefix") .long("path-prefix")
.value_name("path") .value_name("path")
.help("Specify an url path prefix"), .help("Specify an path prefix"),
)
.arg(
Arg::new("auth")
.short('a')
.long("auth")
.help("Add auth for path")
.multiple_values(true)
.multiple_occurrences(true)
.value_name("rule"),
)
.arg(
Arg::new("auth-method")
.long("auth-method")
.help("Select auth method")
.possible_values(["basic", "digest"])
.default_value("digest")
.value_name("value"),
) )
.arg( .arg(
Arg::new("allow-all") Arg::new("allow-all")
@@ -57,39 +81,35 @@ fn app() -> clap::Command<'static> {
.long("allow-delete") .long("allow-delete")
.help("Allow delete files/folders"), .help("Allow delete files/folders"),
) )
.arg(
Arg::new("allow-search")
.long("allow-search")
.help("Allow search files/folders"),
)
.arg( .arg(
Arg::new("allow-symlink") Arg::new("allow-symlink")
.long("allow-symlink") .long("allow-symlink")
.help("Allow symlink to files/folders outside root directory"), .help("Allow symlink to files/folders outside root directory"),
) )
.arg(
Arg::new("enable-cors")
.long("enable-cors")
.help("Enable CORS, sets `Access-Control-Allow-Origin: *`"),
)
.arg( .arg(
Arg::new("render-index") Arg::new("render-index")
.long("render-index") .long("render-index")
.help("Render index.html when requesting a directory"), .help("Serve index.html when requesting a directory, returns 404 if not found index.html"),
)
.arg(
Arg::new("render-try-index")
.long("render-try-index")
.help("Serve index.html when requesting a directory, returns file listing if not found index.html"),
) )
.arg( .arg(
Arg::new("render-spa") Arg::new("render-spa")
.long("render-spa") .long("render-spa")
.help("Render for single-page application"), .help("Serve SPA(Single Page Application)"),
)
.arg(
Arg::new("auth")
.short('a')
.display_order(1)
.long("auth")
.help("Use HTTP authentication")
.value_name("user:pass"),
)
.arg(
Arg::new("no-auth-access")
.display_order(1)
.long("no-auth-access")
.help("Not required auth when access static files"),
)
.arg(
Arg::new("cors")
.long("cors")
.help("Enable CORS, sets `Access-Control-Allow-Origin: *`"),
) )
.arg( .arg(
Arg::new("tls-cert") Arg::new("tls-cert")
@@ -109,20 +129,24 @@ pub fn matches() -> ArgMatches {
app().get_matches() app().get_matches()
} }
#[derive(Debug, Clone, Eq, PartialEq)] #[derive(Debug)]
pub struct Args { pub struct Args {
pub addr: SocketAddr, pub addrs: Vec<IpAddr>,
pub port: u16,
pub path: PathBuf, pub path: PathBuf,
pub path_is_file: bool,
pub path_prefix: String, pub path_prefix: String,
pub uri_prefix: String, pub uri_prefix: String,
pub auth: Option<(String, String)>, pub auth_method: AuthMethod,
pub no_auth_access: bool, pub auth: AccessControl,
pub allow_upload: bool, pub allow_upload: bool,
pub allow_delete: bool, pub allow_delete: bool,
pub allow_search: bool,
pub allow_symlink: bool, pub allow_symlink: bool,
pub render_index: bool, pub render_index: bool,
pub render_spa: bool, pub render_spa: bool,
pub cors: bool, pub render_try_index: bool,
pub enable_cors: bool,
pub tls: Option<(Vec<Certificate>, PrivateKey)>, pub tls: Option<(Vec<Certificate>, PrivateKey)>,
} }
@@ -132,10 +156,14 @@ impl Args {
/// If a parsing error ocurred, exit the process and print out informative /// If a parsing error ocurred, exit the process and print out informative
/// error message to user. /// error message to user.
pub fn parse(matches: ArgMatches) -> BoxResult<Args> { pub fn parse(matches: ArgMatches) -> BoxResult<Args> {
let ip = matches.value_of("address").unwrap_or_default();
let port = matches.value_of_t::<u16>("port")?; let port = matches.value_of_t::<u16>("port")?;
let addr = to_addr(ip, port)?; let addrs = matches
.values_of("bind")
.map(|v| v.collect())
.unwrap_or_else(|| vec!["0.0.0.0", "::"]);
let addrs: Vec<IpAddr> = Args::parse_addrs(&addrs)?;
let path = Args::parse_path(matches.value_of_os("path").unwrap_or_default())?; let path = Args::parse_path(matches.value_of_os("path").unwrap_or_default())?;
let path_is_file = path.metadata()?.is_file();
let path_prefix = matches let path_prefix = matches
.value_of("path-prefix") .value_of("path-prefix")
.map(|v| v.trim_matches('/').to_owned()) .map(|v| v.trim_matches('/').to_owned())
@@ -145,16 +173,22 @@ impl Args {
} else { } else {
format!("/{}/", &path_prefix) format!("/{}/", &path_prefix)
}; };
let cors = matches.is_present("cors"); let enable_cors = matches.is_present("enable-cors");
let auth = match matches.value_of("auth") { let auth: Vec<&str> = matches
Some(auth) => Some(parse_auth(auth)?), .values_of("auth")
None => None, .map(|v| v.collect())
.unwrap_or_default();
let auth_method = match matches.value_of("auth-method").unwrap() {
"basic" => AuthMethod::Basic,
_ => AuthMethod::Digest,
}; };
let no_auth_access = matches.is_present("no-auth-access"); let auth = AccessControl::new(&auth, &uri_prefix)?;
let allow_upload = matches.is_present("allow-all") || matches.is_present("allow-upload"); let allow_upload = matches.is_present("allow-all") || matches.is_present("allow-upload");
let allow_delete = matches.is_present("allow-all") || matches.is_present("allow-delete"); let allow_delete = matches.is_present("allow-all") || matches.is_present("allow-delete");
let allow_search = matches.is_present("allow-all") || matches.is_present("allow-search");
let allow_symlink = matches.is_present("allow-all") || matches.is_present("allow-symlink"); let allow_symlink = matches.is_present("allow-all") || matches.is_present("allow-symlink");
let render_index = matches.is_present("render-index"); let render_index = matches.is_present("render-index");
let render_try_index = matches.is_present("render-try-index");
let render_spa = matches.is_present("render-spa"); let render_spa = matches.is_present("render-spa");
let tls = match (matches.value_of("tls-cert"), matches.value_of("tls-key")) { let tls = match (matches.value_of("tls-cert"), matches.value_of("tls-key")) {
(Some(certs_file), Some(key_file)) => { (Some(certs_file), Some(key_file)) => {
@@ -166,23 +200,45 @@ impl Args {
}; };
Ok(Args { Ok(Args {
addr, addrs,
port,
path, path,
path_is_file,
path_prefix, path_prefix,
uri_prefix, uri_prefix,
auth_method,
auth, auth,
no_auth_access, enable_cors,
cors,
allow_delete, allow_delete,
allow_upload, allow_upload,
allow_search,
allow_symlink, allow_symlink,
render_index, render_index,
render_try_index,
render_spa, render_spa,
tls, tls,
}) })
} }
/// Parse path. fn parse_addrs(addrs: &[&str]) -> BoxResult<Vec<IpAddr>> {
let mut ip_addrs = vec![];
let mut invalid_addrs = vec![];
for addr in addrs {
match addr.parse::<IpAddr>() {
Ok(v) => {
ip_addrs.push(v);
}
Err(_) => {
invalid_addrs.push(*addr);
}
}
}
if !invalid_addrs.is_empty() {
return Err(format!("Invalid bind address `{}`", invalid_addrs.join(",")).into());
}
Ok(ip_addrs)
}
fn parse_path<P: AsRef<Path>>(path: P) -> BoxResult<PathBuf> { fn parse_path<P: AsRef<Path>>(path: P) -> BoxResult<PathBuf> {
let path = path.as_ref(); let path = path.as_ref();
if !path.exists() { if !path.exists() {
@@ -197,43 +253,3 @@ impl Args {
.map_err(|err| format!("Failed to access path `{}`: {}", path.display(), err,).into()) .map_err(|err| format!("Failed to access path `{}`: {}", path.display(), err,).into())
} }
} }
fn to_addr(ip: &str, port: u16) -> BoxResult<SocketAddr> {
let ip: IpAddr = ip.parse()?;
Ok(SocketAddr::new(ip, port))
}
// Load public certificate from file.
fn load_certs(filename: &str) -> BoxResult<Vec<Certificate>> {
// Open certificate file.
let certfile = fs::File::open(&filename)
.map_err(|e| format!("Failed to access `{}`, {}", &filename, e))?;
let mut reader = io::BufReader::new(certfile);
// Load and return certificate.
let certs = rustls_pemfile::certs(&mut reader).map_err(|_| "Failed to load certificate")?;
if certs.is_empty() {
return Err("No supported certificate in file".into());
}
Ok(certs.into_iter().map(Certificate).collect())
}
// Load private key from file.
fn load_private_key(filename: &str) -> BoxResult<PrivateKey> {
// Open keyfile.
let keyfile = fs::File::open(&filename)
.map_err(|e| format!("Failed to access `{}`, {}", &filename, e))?;
let mut reader = io::BufReader::new(keyfile);
// Load and return a single private key.
let keys = rustls_pemfile::read_all(&mut reader)
.map_err(|e| format!("There was a problem with reading private key: {:?}", e))?
.into_iter()
.find_map(|item| match item {
rustls_pemfile::Item::RSAKey(key) | rustls_pemfile::Item::PKCS8Key(key) => Some(key),
_ => None,
})
.ok_or("No supported private key in file")?;
Ok(PrivateKey(keys))
}

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
use crate::auth::{generate_www_auth, valid_digest}; use crate::streamer::Streamer;
use crate::utils::{decode_uri, encode_uri};
use crate::{Args, BoxResult}; use crate::{Args, BoxResult};
use xml::escape::escape_str_pcdata; use xml::escape::escape_str_pcdata;
@@ -8,134 +9,74 @@ use async_zip::Compression;
use chrono::{TimeZone, Utc}; use chrono::{TimeZone, Utc};
use futures::stream::StreamExt; use futures::stream::StreamExt;
use futures::TryStreamExt; use futures::TryStreamExt;
use get_if_addrs::get_if_addrs;
use headers::{ use headers::{
AcceptRanges, AccessControlAllowHeaders, AccessControlAllowOrigin, ContentLength, ContentRange, AcceptRanges, AccessControlAllowCredentials, AccessControlAllowHeaders,
ContentType, ETag, HeaderMap, HeaderMapExt, IfModifiedSince, IfNoneMatch, IfRange, AccessControlAllowOrigin, Connection, ContentLength, ContentType, ETag, HeaderMap,
LastModified, Range, HeaderMapExt, IfModifiedSince, IfNoneMatch, IfRange, LastModified, Range,
}; };
use hyper::header::{ use hyper::header::{
HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_DISPOSITION, CONTENT_TYPE, ORIGIN, RANGE, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_RANGE,
WWW_AUTHENTICATE, CONTENT_TYPE, ORIGIN, RANGE, WWW_AUTHENTICATE,
}; };
use hyper::service::{make_service_fn, service_fn};
use hyper::{Body, Method, StatusCode, Uri}; use hyper::{Body, Method, StatusCode, Uri};
use percent_encoding::percent_decode;
use rustls::ServerConfig;
use serde::Serialize; use serde::Serialize;
use std::convert::Infallible;
use std::fs::Metadata; use std::fs::Metadata;
use std::net::{IpAddr, SocketAddr}; use std::io::SeekFrom;
use std::net::SocketAddr;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::time::SystemTime; use std::time::SystemTime;
use tokio::fs::File; use tokio::fs::File;
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWrite}; use tokio::io::{AsyncSeekExt, AsyncWrite};
use tokio::net::TcpListener;
use tokio::{fs, io}; use tokio::{fs, io};
use tokio_rustls::TlsAcceptor; use tokio_util::io::StreamReader;
use tokio_util::codec::{BytesCodec, FramedRead};
use tokio_util::io::{ReaderStream, StreamReader};
use uuid::Uuid; use uuid::Uuid;
type Request = hyper::Request<Body>; pub type Request = hyper::Request<Body>;
type Response = hyper::Response<Body>; pub type Response = hyper::Response<Body>;
const INDEX_HTML: &str = include_str!("../assets/index.html"); const INDEX_HTML: &str = include_str!("../assets/index.html");
const INDEX_CSS: &str = include_str!("../assets/index.css"); const INDEX_CSS: &str = include_str!("../assets/index.css");
const INDEX_JS: &str = include_str!("../assets/index.js"); const INDEX_JS: &str = include_str!("../assets/index.js");
const FAVICON_ICO: &[u8] = include_bytes!("../assets/favicon.ico"); const FAVICON_ICO: &[u8] = include_bytes!("../assets/favicon.ico");
const INDEX_NAME: &str = "index.html"; const INDEX_NAME: &str = "index.html";
const BUF_SIZE: usize = 1024 * 16; const BUF_SIZE: usize = 65536;
macro_rules! status { pub struct Server {
($res:ident, $status:expr) => {
*$res.status_mut() = $status;
*$res.body_mut() = Body::from($status.canonical_reason().unwrap_or_default());
};
}
pub async fn serve(args: Args) -> BoxResult<()> {
let args = Arc::new(args);
let inner = Arc::new(InnerService::new(args.clone()));
match args.tls.clone() {
Some((certs, key)) => {
let config = ServerConfig::builder()
.with_safe_defaults()
.with_no_client_auth()
.with_single_cert(certs, key)?;
let tls_acceptor = TlsAcceptor::from(Arc::new(config));
let arc_acceptor = Arc::new(tls_acceptor);
let listener = TcpListener::bind(&args.addr).await?;
let incoming = tokio_stream::wrappers::TcpListenerStream::new(listener);
let incoming =
hyper::server::accept::from_stream(incoming.filter_map(|socket| async {
match socket {
Ok(stream) => match arc_acceptor.clone().accept(stream).await {
Ok(val) => Some(Ok::<_, Infallible>(val)),
Err(_) => None,
},
Err(_) => None,
}
}));
let server = hyper::Server::builder(incoming).serve(make_service_fn(move |_| {
let inner = inner.clone();
async move {
Ok::<_, Infallible>(service_fn(move |req| {
let inner = inner.clone();
inner.call(req)
}))
}
}));
print_listening(&args.addr, &args.uri_prefix, true);
server.await?;
}
None => {
let server = hyper::Server::try_bind(&args.addr)?.serve(make_service_fn(move |_| {
let inner = inner.clone();
async move {
Ok::<_, Infallible>(service_fn(move |req| {
let inner = inner.clone();
inner.call(req)
}))
}
}));
print_listening(&args.addr, &args.uri_prefix, false);
server.await?;
}
}
Ok(())
}
struct InnerService {
args: Arc<Args>, args: Arc<Args>,
} }
impl InnerService { impl Server {
pub fn new(args: Arc<Args>) -> Self { pub fn new(args: Arc<Args>) -> Self {
Self { args } Self { args }
} }
pub async fn call(self: Arc<Self>, req: Request) -> Result<Response, hyper::Error> { pub async fn call(
self: Arc<Self>,
req: Request,
addr: SocketAddr,
) -> Result<Response, hyper::Error> {
let method = req.method().clone(); let method = req.method().clone();
let uri = req.uri().clone(); let uri = req.uri().clone();
let cors = self.args.cors; let enable_cors = self.args.enable_cors;
let mut res = match self.handle(req).await { let mut res = match self.handle(req).await {
Ok(res) => { Ok(res) => {
info!(r#""{} {}" - {}"#, method, uri, res.status()); let status = res.status().as_u16();
info!(r#"{} "{} {}" - {}"#, addr.ip(), method, uri, status,);
res res
} }
Err(err) => { Err(err) => {
let mut res = Response::default(); let mut res = Response::default();
let status = StatusCode::INTERNAL_SERVER_ERROR; let status = StatusCode::INTERNAL_SERVER_ERROR;
status!(res, status); *res.status_mut() = status;
error!(r#""{} {}" - {} {}"#, method, uri, status, err); let status = status.as_u16();
error!(r#"{} "{} {}" - {} {}"#, addr.ip(), method, uri, status, err);
res res
} }
}; };
if cors { if enable_cors {
add_cors(&mut res); add_cors(&mut res);
} }
Ok(res) Ok(res)
@@ -144,26 +85,43 @@ impl InnerService {
pub async fn handle(self: Arc<Self>, req: Request) -> BoxResult<Response> { pub async fn handle(self: Arc<Self>, req: Request) -> BoxResult<Response> {
let mut res = Response::default(); let mut res = Response::default();
if !self.auth_guard(&req, &mut res) {
return Ok(res);
}
let req_path = req.uri().path(); let req_path = req.uri().path();
let headers = req.headers(); let headers = req.headers();
let method = req.method().clone(); let method = req.method().clone();
if req_path == "/favicon.ico" && method == Method::GET { if req_path == "/favicon.ico" && method == Method::GET {
self.handle_send_favicon(req.headers(), &mut res).await?; self.handle_send_favicon(headers, &mut res).await?;
return Ok(res);
}
let authorization = headers.get(AUTHORIZATION);
let guard_type = self.args.auth.guard(
req_path,
&method,
authorization,
self.args.auth_method.clone(),
);
if guard_type.is_reject() {
self.auth_reject(&mut res);
return Ok(res);
}
let head_only = method == Method::HEAD;
if self.args.path_is_file {
self.handle_send_file(&self.args.path, headers, head_only, &mut res)
.await?;
return Ok(res); return Ok(res);
} }
let path = match self.extract_path(req_path) { let path = match self.extract_path(req_path) {
Some(v) => v, Some(v) => v,
None => { None => {
status!(res, StatusCode::FORBIDDEN); status_forbid(&mut res);
return Ok(res); return Ok(res);
} }
}; };
let path = path.as_path(); let path = path.as_path();
let query = req.uri().query().unwrap_or_default(); let query = req.uri().query().unwrap_or_default();
@@ -175,25 +133,29 @@ impl InnerService {
let allow_upload = self.args.allow_upload; let allow_upload = self.args.allow_upload;
let allow_delete = self.args.allow_delete; let allow_delete = self.args.allow_delete;
let allow_search = self.args.allow_search;
let render_index = self.args.render_index; let render_index = self.args.render_index;
let render_spa = self.args.render_spa; let render_spa = self.args.render_spa;
let render_try_index = self.args.render_try_index;
if !self.args.allow_symlink && !is_miss && !self.is_root_contained(path).await { if !self.args.allow_symlink && !is_miss && !self.is_root_contained(path).await {
status!(res, StatusCode::NOT_FOUND); status_not_found(&mut res);
return Ok(res); return Ok(res);
} }
match method { match method {
Method::GET | Method::HEAD => { Method::GET | Method::HEAD => {
let head_only = method == Method::HEAD;
if is_dir { if is_dir {
if render_index || render_spa { if render_try_index && query == "zip" {
self.handle_zip_dir(path, head_only, &mut res).await?;
} else if render_index || render_spa || render_try_index {
self.handle_render_index(path, headers, head_only, &mut res) self.handle_render_index(path, headers, head_only, &mut res)
.await?; .await?;
} else if query == "zip" { } else if query == "zip" {
self.handle_zip_dir(path, head_only, &mut res).await?; self.handle_zip_dir(path, head_only, &mut res).await?;
} else if let Some(q) = query.strip_prefix("q=") { } else if allow_search && query.starts_with("q=") {
self.handle_query_dir(path, q, head_only, &mut res).await?; let q = decode_uri(&query[2..]).unwrap_or_default();
self.handle_query_dir(path, &q, head_only, &mut res).await?;
} else { } else {
self.handle_ls_dir(path, true, head_only, &mut res).await?; self.handle_ls_dir(path, true, head_only, &mut res).await?;
} }
@@ -206,26 +168,26 @@ impl InnerService {
} else if allow_upload && req_path.ends_with('/') { } else if allow_upload && req_path.ends_with('/') {
self.handle_ls_dir(path, false, head_only, &mut res).await?; self.handle_ls_dir(path, false, head_only, &mut res).await?;
} else { } else {
status!(res, StatusCode::NOT_FOUND); status_not_found(&mut res);
} }
} }
Method::OPTIONS => { Method::OPTIONS => {
self.handle_options(&mut res); set_webdav_headers(&mut res);
} }
Method::PUT => { Method::PUT => {
if !allow_upload || (!allow_delete && is_file && size > 0) { if !allow_upload || (!allow_delete && is_file && size > 0) {
status!(res, StatusCode::FORBIDDEN); status_forbid(&mut res);
} else { } else {
self.handle_upload(path, req, &mut res).await?; self.handle_upload(path, req, &mut res).await?;
} }
} }
Method::DELETE => { Method::DELETE => {
if !allow_delete { if !allow_delete {
status!(res, StatusCode::FORBIDDEN); status_forbid(&mut res);
} else if !is_miss { } else if !is_miss {
self.handle_delete(path, is_dir, &mut res).await? self.handle_delete(path, is_dir, &mut res).await?
} else { } else {
status!(res, StatusCode::NOT_FOUND); status_not_found(&mut res);
} }
} }
method => match method.as_str() { method => match method.as_str() {
@@ -235,37 +197,37 @@ impl InnerService {
} else if is_file { } else if is_file {
self.handle_propfind_file(path, &mut res).await?; self.handle_propfind_file(path, &mut res).await?;
} else { } else {
status!(res, StatusCode::NOT_FOUND); status_not_found(&mut res);
} }
} }
"PROPPATCH" => { "PROPPATCH" => {
if is_file { if is_file {
self.handle_proppatch(req_path, &mut res).await?; self.handle_proppatch(req_path, &mut res).await?;
} else { } else {
status!(res, StatusCode::NOT_FOUND); status_not_found(&mut res);
} }
} }
"MKCOL" => { "MKCOL" => {
if !allow_upload || !is_miss { if !allow_upload || !is_miss {
status!(res, StatusCode::FORBIDDEN); status_forbid(&mut res);
} else { } else {
self.handle_mkcol(path, &mut res).await?; self.handle_mkcol(path, &mut res).await?;
} }
} }
"COPY" => { "COPY" => {
if !allow_upload { if !allow_upload {
status!(res, StatusCode::FORBIDDEN); status_forbid(&mut res);
} else if is_miss { } else if is_miss {
status!(res, StatusCode::NOT_FOUND); status_not_found(&mut res);
} else { } else {
self.handle_copy(path, headers, &mut res).await? self.handle_copy(path, headers, &mut res).await?
} }
} }
"MOVE" => { "MOVE" => {
if !allow_upload || !allow_delete { if !allow_upload || !allow_delete {
status!(res, StatusCode::FORBIDDEN); status_forbid(&mut res);
} else if is_miss { } else if is_miss {
status!(res, StatusCode::NOT_FOUND); status_not_found(&mut res);
} else { } else {
self.handle_move(path, headers, &mut res).await? self.handle_move(path, headers, &mut res).await?
} }
@@ -273,21 +235,20 @@ impl InnerService {
"LOCK" => { "LOCK" => {
// Fake lock // Fake lock
if is_file { if is_file {
self.handle_lock(req_path, &mut res).await?; let has_auth = authorization.is_some();
self.handle_lock(req_path, has_auth, &mut res).await?;
} else { } else {
status!(res, StatusCode::NOT_FOUND); status_not_found(&mut res);
} }
} }
"UNLOCK" => { "UNLOCK" => {
// Fake unlock // Fake unlock
if is_miss { if is_miss {
status!(res, StatusCode::NOT_FOUND); status_not_found(&mut res);
} else {
status!(res, StatusCode::OK);
} }
} }
_ => { _ => {
status!(res, StatusCode::METHOD_NOT_ALLOWED); *res.status_mut() = StatusCode::METHOD_NOT_ALLOWED;
} }
}, },
} }
@@ -305,7 +266,7 @@ impl InnerService {
let mut file = match fs::File::create(&path).await { let mut file = match fs::File::create(&path).await {
Ok(v) => v, Ok(v) => v,
Err(_) => { Err(_) => {
status!(res, StatusCode::FORBIDDEN); status_forbid(res);
return Ok(()); return Ok(());
} }
}; };
@@ -320,7 +281,7 @@ impl InnerService {
io::copy(&mut body_reader, &mut file).await?; io::copy(&mut body_reader, &mut file).await?;
status!(res, StatusCode::CREATED); *res.status_mut() = StatusCode::CREATED;
Ok(()) Ok(())
} }
@@ -330,7 +291,7 @@ impl InnerService {
false => fs::remove_file(path).await?, false => fs::remove_file(path).await?,
} }
status!(res, StatusCode::NO_CONTENT); status_no_content(res);
Ok(()) Ok(())
} }
@@ -346,7 +307,7 @@ impl InnerService {
paths = match self.list_dir(path, path).await { paths = match self.list_dir(path, path).await {
Ok(paths) => paths, Ok(paths) => paths,
Err(_) => { Err(_) => {
status!(res, StatusCode::FORBIDDEN); status_forbid(res);
return Ok(()); return Ok(());
} }
} }
@@ -391,10 +352,7 @@ impl InnerService {
res: &mut Response, res: &mut Response,
) -> BoxResult<()> { ) -> BoxResult<()> {
let (mut writer, reader) = tokio::io::duplex(BUF_SIZE); let (mut writer, reader) = tokio::io::duplex(BUF_SIZE);
let filename = path let filename = get_file_name(path)?;
.file_name()
.and_then(|v| v.to_str())
.ok_or_else(|| format!("Failed to get name of `{}`", path.display()))?;
res.headers_mut().insert( res.headers_mut().insert(
CONTENT_DISPOSITION, CONTENT_DISPOSITION,
HeaderValue::from_str(&format!( HeaderValue::from_str(&format!(
@@ -404,7 +362,7 @@ impl InnerService {
.unwrap(), .unwrap(),
); );
res.headers_mut() res.headers_mut()
.insert("content-type", "application/zip".parse().unwrap()); .insert("content-type", HeaderValue::from_static("application/zip"));
if head_only { if head_only {
return Ok(()); return Ok(());
} }
@@ -414,8 +372,8 @@ impl InnerService {
error!("Failed to zip {}, {}", path.display(), e); error!("Failed to zip {}, {}", path.display(), e);
} }
}); });
let stream = ReaderStream::new(reader); let reader = Streamer::new(reader, BUF_SIZE);
*res.body_mut() = Body::wrap_stream(stream); *res.body_mut() = Body::wrap_stream(reader.into_stream());
Ok(()) Ok(())
} }
@@ -426,17 +384,19 @@ impl InnerService {
head_only: bool, head_only: bool,
res: &mut Response, res: &mut Response,
) -> BoxResult<()> { ) -> BoxResult<()> {
let path = path.join(INDEX_NAME); let index_path = path.join(INDEX_NAME);
if fs::metadata(&path) if fs::metadata(&index_path)
.await .await
.ok() .ok()
.map(|v| v.is_file()) .map(|v| v.is_file())
.unwrap_or_default() .unwrap_or_default()
{ {
self.handle_send_file(&path, headers, head_only, res) self.handle_send_file(&index_path, headers, head_only, res)
.await?; .await?;
} else if self.args.render_try_index {
self.handle_ls_dir(path, true, head_only, res).await?;
} else { } else {
status!(res, StatusCode::NOT_FOUND); status_not_found(res)
} }
Ok(()) Ok(())
} }
@@ -453,7 +413,7 @@ impl InnerService {
self.handle_send_file(&path, headers, head_only, res) self.handle_send_file(&path, headers, head_only, res)
.await?; .await?;
} else { } else {
status!(res, StatusCode::NOT_FOUND); status_not_found(res)
} }
Ok(()) Ok(())
} }
@@ -472,7 +432,7 @@ impl InnerService {
} else { } else {
*res.body_mut() = Body::from(FAVICON_ICO); *res.body_mut() = Body::from(FAVICON_ICO);
res.headers_mut() res.headers_mut()
.insert("content-type", "image/x-icon".parse().unwrap()); .insert("content-type", HeaderValue::from_static("image/x-icon"));
} }
Ok(()) Ok(())
} }
@@ -486,7 +446,7 @@ impl InnerService {
) -> BoxResult<()> { ) -> BoxResult<()> {
let (file, meta) = tokio::join!(fs::File::open(path), fs::metadata(path),); let (file, meta) = tokio::join!(fs::File::open(path), fs::metadata(path),);
let (mut file, meta) = (file?, meta?); let (mut file, meta) = (file?, meta?);
let mut maybe_range = true; let mut use_range = true;
if let Some((etag, last_modified)) = extract_cache_headers(&meta) { if let Some((etag, last_modified)) = extract_cache_headers(&meta) {
let cached = { let cached = {
if let Some(if_none_match) = headers.typed_get::<IfNoneMatch>() { if let Some(if_none_match) = headers.typed_get::<IfNoneMatch>() {
@@ -497,66 +457,85 @@ impl InnerService {
false false
} }
}; };
res.headers_mut().typed_insert(last_modified);
res.headers_mut().typed_insert(etag.clone());
if cached { if cached {
status!(res, StatusCode::NOT_MODIFIED); *res.status_mut() = StatusCode::NOT_MODIFIED;
return Ok(()); return Ok(());
} }
res.headers_mut().typed_insert(last_modified);
res.headers_mut().typed_insert(etag.clone());
if headers.typed_get::<Range>().is_some() { if headers.typed_get::<Range>().is_some() {
maybe_range = headers use_range = headers
.typed_get::<IfRange>() .typed_get::<IfRange>()
.map(|if_range| !if_range.is_modified(Some(&etag), Some(&last_modified))) .map(|if_range| !if_range.is_modified(Some(&etag), Some(&last_modified)))
// Always be fresh if there is no validators // Always be fresh if there is no validators
.unwrap_or(true); .unwrap_or(true);
} else { } else {
maybe_range = false; use_range = false;
} }
} }
let file_range = if maybe_range {
if let Some(content_range) = headers let range = if use_range {
.typed_get::<Range>() parse_range(headers)
.and_then(|range| to_content_range(&range, meta.len()))
{
res.headers_mut().typed_insert(content_range.clone());
*res.status_mut() = StatusCode::PARTIAL_CONTENT;
content_range.bytes_range()
} else {
None
}
} else { } else {
None None
}; };
if let Some(mime) = mime_guess::from_path(&path).first() { if let Some(mime) = mime_guess::from_path(&path).first() {
res.headers_mut().typed_insert(ContentType::from(mime)); res.headers_mut().typed_insert(ContentType::from(mime));
}
res.headers_mut().typed_insert(AcceptRanges::bytes());
res.headers_mut()
.typed_insert(ContentLength(meta.len() as u64));
if head_only {
return Ok(());
}
let body = if let Some((begin, end)) = file_range {
file.seek(io::SeekFrom::Start(begin)).await?;
let stream = FramedRead::new(file.take(end - begin + 1), BytesCodec::new());
Body::wrap_stream(stream)
} else { } else {
let stream = FramedRead::new(file, BytesCodec::new()); res.headers_mut().insert(
Body::wrap_stream(stream) CONTENT_TYPE,
}; HeaderValue::from_static("application/octet-stream"),
*res.body_mut() = body; );
Ok(()) }
}
fn handle_options(&self, res: &mut Response) { let filename = get_file_name(path)?;
res.headers_mut().insert( res.headers_mut().insert(
"Allow", CONTENT_DISPOSITION,
"GET,HEAD,PUT,OPTIONS,DELETE,PROPFIND,COPY,MOVE" HeaderValue::from_str(&format!("inline; filename=\"{}\"", encode_uri(filename),))
.parse()
.unwrap(), .unwrap(),
); );
res.headers_mut().insert("DAV", "1".parse().unwrap());
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);
*res.status_mut() = StatusCode::PARTIAL_CONTENT;
let content_range = format!("bytes {}-{}/{}", range.start, end, size);
res.headers_mut()
.insert(CONTENT_RANGE, content_range.parse().unwrap());
res.headers_mut()
.insert(CONTENT_LENGTH, format!("{}", part_size).parse().unwrap());
if head_only {
return Ok(());
}
*res.body_mut() = Body::wrap_stream(reader.into_stream_sized(part_size));
} else {
*res.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
res.headers_mut()
.insert(CONTENT_RANGE, format!("bytes */{}", size).parse().unwrap());
}
} else {
res.headers_mut()
.insert(CONTENT_LENGTH, format!("{}", size).parse().unwrap());
if head_only {
return Ok(());
}
let reader = Streamer::new(file, BUF_SIZE);
*res.body_mut() = Body::wrap_stream(reader.into_stream());
}
Ok(())
} }
async fn handle_propfind_dir( async fn handle_propfind_dir(
@@ -569,7 +548,7 @@ impl InnerService {
Some(v) => match v.to_str().ok().and_then(|v| v.parse().ok()) { Some(v) => match v.to_str().ok().and_then(|v| v.parse().ok()) {
Some(v) => v, Some(v) => v,
None => { None => {
status!(res, StatusCode::BAD_REQUEST); *res.status_mut() = StatusCode::BAD_REQUEST;
return Ok(()); return Ok(());
} }
}, },
@@ -580,7 +559,7 @@ impl InnerService {
match self.list_dir(path, &self.args.path).await { match self.list_dir(path, &self.args.path).await {
Ok(child) => paths.extend(child), Ok(child) => paths.extend(child),
Err(_) => { Err(_) => {
status!(res, StatusCode::FORBIDDEN); status_forbid(res);
return Ok(()); return Ok(());
} }
} }
@@ -600,14 +579,14 @@ impl InnerService {
if let Some(pathitem) = self.to_pathitem(path, &self.args.path).await? { if let Some(pathitem) = self.to_pathitem(path, &self.args.path).await? {
res_multistatus(res, &pathitem.to_dav_xml(self.args.uri_prefix.as_str())); res_multistatus(res, &pathitem.to_dav_xml(self.args.uri_prefix.as_str()));
} else { } else {
status!(res, StatusCode::NOT_FOUND); status_not_found(res);
} }
Ok(()) Ok(())
} }
async fn handle_mkcol(&self, path: &Path, res: &mut Response) -> BoxResult<()> { async fn handle_mkcol(&self, path: &Path, res: &mut Response) -> BoxResult<()> {
fs::create_dir_all(path).await?; fs::create_dir_all(path).await?;
status!(res, StatusCode::CREATED); *res.status_mut() = StatusCode::CREATED;
Ok(()) Ok(())
} }
@@ -620,14 +599,14 @@ impl InnerService {
let dest = match self.extract_dest(headers) { let dest = match self.extract_dest(headers) {
Some(dest) => dest, Some(dest) => dest,
None => { None => {
status!(res, StatusCode::BAD_REQUEST); *res.status_mut() = StatusCode::BAD_REQUEST;
return Ok(()); return Ok(());
} }
}; };
let meta = fs::symlink_metadata(path).await?; let meta = fs::symlink_metadata(path).await?;
if meta.is_dir() { if meta.is_dir() {
status!(res, StatusCode::FORBIDDEN); status_forbid(res);
return Ok(()); return Ok(());
} }
@@ -635,7 +614,7 @@ impl InnerService {
fs::copy(path, &dest).await?; fs::copy(path, &dest).await?;
status!(res, StatusCode::NO_CONTENT); status_no_content(res);
Ok(()) Ok(())
} }
@@ -648,7 +627,7 @@ impl InnerService {
let dest = match self.extract_dest(headers) { let dest = match self.extract_dest(headers) {
Some(dest) => dest, Some(dest) => dest,
None => { None => {
status!(res, StatusCode::BAD_REQUEST); *res.status_mut() = StatusCode::BAD_REQUEST;
return Ok(()); return Ok(());
} }
}; };
@@ -657,20 +636,20 @@ impl InnerService {
fs::rename(path, &dest).await?; fs::rename(path, &dest).await?;
status!(res, StatusCode::NO_CONTENT); status_no_content(res);
Ok(()) Ok(())
} }
async fn handle_lock(&self, req_path: &str, res: &mut Response) -> BoxResult<()> { async fn handle_lock(&self, req_path: &str, auth: bool, res: &mut Response) -> BoxResult<()> {
let token = if self.args.auth.is_none() { let token = if auth {
Utc::now().timestamp().to_string()
} else {
format!("opaquelocktoken:{}", Uuid::new_v4()) format!("opaquelocktoken:{}", Uuid::new_v4())
} else {
Utc::now().timestamp().to_string()
}; };
res.headers_mut().insert( res.headers_mut().insert(
"content-type", "content-type",
"application/xml; charset=utf-8".parse().unwrap(), HeaderValue::from_static("application/xml; charset=utf-8"),
); );
res.headers_mut() res.headers_mut()
.insert("lock-token", format!("<{}>", token).parse().unwrap()); .insert("lock-token", format!("<{}>", token).parse().unwrap());
@@ -711,15 +690,14 @@ impl InnerService {
res: &mut Response, res: &mut Response,
) -> BoxResult<()> { ) -> BoxResult<()> {
paths.sort_unstable(); paths.sort_unstable();
let rel_path = match self.args.path.parent() { let href = format!("/{}", normalize_path(path.strip_prefix(&self.args.path)?));
Some(p) => path.strip_prefix(p).unwrap(),
None => path,
};
let data = IndexData { let data = IndexData {
breadcrumb: normalize_path(rel_path), href: href.clone(),
uri_prefix: self.args.uri_prefix.clone(),
paths, paths,
allow_upload: self.args.allow_upload, allow_upload: self.args.allow_upload,
allow_delete: self.args.allow_delete, allow_delete: self.args.allow_delete,
allow_search: self.args.allow_search,
dir_exists: exist, dir_exists: exist,
}; };
let data = serde_json::to_string(&data).unwrap(); let data = serde_json::to_string(&data).unwrap();
@@ -727,17 +705,14 @@ impl InnerService {
"__SLOT__", "__SLOT__",
&format!( &format!(
r#" r#"
<title>Files in {}/ - Duf</title> <title>Index of {} - Dufs</title>
<style>{}</style> <style>{}</style>
<script> <script>
const DATA = const DATA =
{} {}
{}</script> {}</script>
"#, "#,
rel_path.display(), href, INDEX_CSS, data, INDEX_JS
INDEX_CSS,
data,
INDEX_JS
), ),
); );
res.headers_mut() res.headers_mut()
@@ -751,32 +726,13 @@ const DATA =
Ok(()) Ok(())
} }
fn auth_guard(&self, req: &Request, res: &mut Response) -> bool { fn auth_reject(&self, res: &mut Response) {
let method = req.method(); let value = self.args.auth_method.www_auth(false);
let pass = { set_webdav_headers(res);
match &self.args.auth { res.headers_mut().typed_insert(Connection::close());
None => true, res.headers_mut()
Some((user, pass)) => match req.headers().get(AUTHORIZATION) { .insert(WWW_AUTHENTICATE, value.parse().unwrap());
Some(value) => { *res.status_mut() = StatusCode::UNAUTHORIZED;
valid_digest(value, method.as_str(), user.as_str(), pass.as_str()).is_some()
}
None => {
self.args.no_auth_access
&& (method == Method::GET
|| method == Method::OPTIONS
|| method == Method::HEAD
|| method.as_str() == "PROPFIND")
}
},
}
};
if !pass {
let value = generate_www_auth(false);
status!(res, StatusCode::UNAUTHORIZED);
res.headers_mut()
.insert(WWW_AUTHENTICATE, value.parse().unwrap());
}
pass
} }
async fn is_root_contained(&self, path: &Path) -> bool { async fn is_root_contained(&self, path: &Path) -> bool {
@@ -794,7 +750,7 @@ const DATA =
} }
fn extract_path(&self, path: &str) -> Option<PathBuf> { fn extract_path(&self, path: &str) -> Option<PathBuf> {
let decoded_path = percent_decode(path[1..].as_bytes()).decode_utf8().ok()?; let decoded_path = decode_uri(&path[1..])?;
let slashes_switched = if cfg!(windows) { let slashes_switched = if cfg!(windows) {
decoded_path.replace('/', "\\") decoded_path.replace('/', "\\")
} else { } else {
@@ -865,10 +821,12 @@ const DATA =
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct IndexData { struct IndexData {
breadcrumb: String, href: String,
uri_prefix: String,
paths: Vec<PathItem>, paths: Vec<PathItem>,
allow_upload: bool, allow_upload: bool,
allow_delete: bool, allow_delete: bool,
allow_search: bool,
dir_exists: bool, dir_exists: bool,
} }
@@ -881,9 +839,16 @@ struct PathItem {
} }
impl PathItem { impl PathItem {
pub fn is_dir(&self) -> bool {
self.path_type == PathType::Dir || self.path_type == PathType::SymlinkDir
}
pub fn to_dav_xml(&self, prefix: &str) -> String { pub fn to_dav_xml(&self, prefix: &str) -> String {
let mtime = Utc.timestamp_millis(self.mtime as i64).to_rfc2822(); let mtime = Utc.timestamp_millis(self.mtime as i64).to_rfc2822();
let href = encode_uri(&format!("{}{}", prefix, &self.name)); let mut href = encode_uri(&format!("{}{}", prefix, &self.name));
if self.is_dir() && !href.ends_with('/') {
href.push('/');
}
let displayname = escape_str_pcdata(self.base_name()); let displayname = escape_str_pcdata(self.base_name());
match self.path_type { match self.path_type {
PathType::Dir | PathType::SymlinkDir => format!( PathType::Dir | PathType::SymlinkDir => format!(
@@ -963,6 +928,9 @@ async fn ensure_path_parent(path: &Path) -> BoxResult<()> {
fn add_cors(res: &mut Response) { fn add_cors(res: &mut Response) {
res.headers_mut() res.headers_mut()
.typed_insert(AccessControlAllowOrigin::ANY); .typed_insert(AccessControlAllowOrigin::ANY);
res.headers_mut()
.typed_insert(AccessControlAllowCredentials);
res.headers_mut().typed_insert( res.headers_mut().typed_insert(
vec![RANGE, CONTENT_TYPE, ACCEPT, ORIGIN, WWW_AUTHENTICATE] vec![RANGE, CONTENT_TYPE, ACCEPT, ORIGIN, WWW_AUTHENTICATE]
.into_iter() .into_iter()
@@ -974,7 +942,7 @@ fn res_multistatus(res: &mut Response, content: &str) {
*res.status_mut() = StatusCode::MULTI_STATUS; *res.status_mut() = StatusCode::MULTI_STATUS;
res.headers_mut().insert( res.headers_mut().insert(
"content-type", "content-type",
"application/xml; charset=utf-8".parse().unwrap(), HeaderValue::from_static("application/xml; charset=utf-8"),
); );
*res.body_mut() = Body::from(format!( *res.body_mut() = Body::from(format!(
r#"<?xml version="1.0" encoding="utf-8" ?> r#"<?xml version="1.0" encoding="utf-8" ?>
@@ -1024,77 +992,61 @@ fn extract_cache_headers(meta: &Metadata) -> Option<(ETag, LastModified)> {
Some((etag, last_modified)) Some((etag, last_modified))
} }
fn to_content_range(range: &Range, complete_length: u64) -> Option<ContentRange> { #[derive(Debug)]
use core::ops::Bound::{Included, Unbounded}; struct RangeValue {
let mut iter = range.iter(); start: u64,
let bounds = iter.next(); end: Option<u64>,
if iter.next().is_some() {
// Found multiple byte-range-spec. Drop.
return None;
}
bounds.and_then(|b| match b {
(Included(start), Included(end)) if start <= end && start < complete_length => {
ContentRange::bytes(
start..=end.min(complete_length.saturating_sub(1)),
complete_length,
)
.ok()
}
(Included(start), Unbounded) if start < complete_length => {
ContentRange::bytes(start.., complete_length).ok()
}
(Unbounded, Included(end)) if end > 0 => {
ContentRange::bytes(complete_length.saturating_sub(end).., complete_length).ok()
}
_ => None,
})
} }
fn print_listening(addr: &SocketAddr, prefix: &str, tls: bool) { fn parse_range(headers: &HeaderMap<HeaderValue>) -> Option<RangeValue> {
let prefix = encode_uri(prefix.trim_end_matches('/')); let range_hdr = headers.get(RANGE)?;
let addrs = retrieve_listening_addrs(addr); let hdr = range_hdr.to_str().ok()?;
let protocol = if tls { "https" } else { "http" }; let mut sp = hdr.splitn(2, '=');
if addrs.len() == 1 { let units = sp.next().unwrap();
println!("Listening on {}://{}{}", protocol, addr, prefix); if units == "bytes" {
let range = sp.next()?;
let mut sp_range = range.splitn(2, '-');
let start: u64 = sp_range.next().unwrap().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 { } else {
let message = addrs None
.iter()
.map(|addr| format!(" {}://{}{}", protocol, addr, prefix))
.collect::<Vec<String>>()
.join("\n");
println!("Listening on:\n{}\n", message);
} }
} }
fn retrieve_listening_addrs(addr: &SocketAddr) -> Vec<SocketAddr> { fn status_forbid(res: &mut Response) {
let ip = addr.ip(); *res.status_mut() = StatusCode::FORBIDDEN;
let port = addr.port(); *res.body_mut() = Body::from("Forbidden");
if ip.is_unspecified() {
if let Ok(interfaces) = get_if_addrs() {
let mut ifaces: Vec<IpAddr> = interfaces
.into_iter()
.map(|v| v.ip())
.filter(|v| {
if ip.is_ipv4() {
v.is_ipv4()
} else {
v.is_ipv6()
}
})
.collect();
ifaces.sort();
return ifaces
.into_iter()
.map(|v| SocketAddr::new(v, port))
.collect();
}
}
vec![addr.to_owned()]
} }
fn encode_uri(v: &str) -> String { fn status_not_found(res: &mut Response) {
let parts: Vec<_> = v.split('/').map(urlencoding::encode).collect(); *res.status_mut() = StatusCode::NOT_FOUND;
parts.join("/") *res.body_mut() = Body::from("Not Found");
}
fn status_no_content(res: &mut Response) {
*res.status_mut() = StatusCode::NO_CONTENT;
}
fn get_file_name(path: &Path) -> BoxResult<&str> {
path.file_name()
.and_then(|v| v.to_str())
.ok_or_else(|| format!("Failed to get file name of `{}`", path.display()).into())
}
fn set_webdav_headers(res: &mut Response) {
res.headers_mut().insert(
"Allow",
HeaderValue::from_static("GET,HEAD,PUT,OPTIONS,DELETE,PROPFIND,COPY,MOVE"),
);
res.headers_mut()
.insert("DAV", HeaderValue::from_static("1,2"));
} }

68
src/streamer.rs Normal file
View File

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

158
src/tls.rs Normal file
View File

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

12
src/utils.rs Normal file
View File

@@ -0,0 +1,12 @@
use std::borrow::Cow;
pub fn encode_uri(v: &str) -> String {
let parts: Vec<_> = v.split('/').map(urlencoding::encode).collect();
parts.join("/")
}
pub fn decode_uri(v: &str) -> Option<Cow<str>> {
percent_encoding::percent_decode(v.as_bytes())
.decode_utf8()
.ok()
}

View File

@@ -59,3 +59,15 @@ fn allow_upload_delete_can_override(#[with(&["-A"])] server: TestServer) -> Resu
assert_eq!(resp.status(), 201); assert_eq!(resp.status(), 201);
Ok(()) Ok(())
} }
#[rstest]
fn allow_search(#[with(&["--allow-search"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?q={}", server.url(), "test.html"))?;
assert_eq!(resp.status(), 200);
let paths = utils::retrive_index_paths(&resp.text()?);
assert!(!paths.is_empty());
for p in paths {
assert!(p.contains(&"test.html"));
}
Ok(())
}

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
mod fixtures; mod fixtures;
use fixtures::{port, server, tmpdir, Error, TestServer}; use fixtures::{port, server, tmpdir, wait_for_port, Error, TestServer};
use assert_cmd::prelude::*; use assert_cmd::prelude::*;
use assert_fs::fixture::TempDir; use assert_fs::fixture::TempDir;
@@ -12,32 +12,37 @@ use std::process::{Command, Stdio};
#[rstest] #[rstest]
#[case(&["-b", "20.205.243.166"])] #[case(&["-b", "20.205.243.166"])]
fn bind_fails(tmpdir: TempDir, port: u16, #[case] args: &[&str]) -> Result<(), Error> { fn bind_fails(tmpdir: TempDir, port: u16, #[case] args: &[&str]) -> Result<(), Error> {
Command::cargo_bin("duf")? Command::cargo_bin("dufs")?
.env("RUST_LOG", "false") .env("RUST_LOG", "false")
.arg(tmpdir.path()) .arg(tmpdir.path())
.arg("-p") .arg("-p")
.arg(port.to_string()) .arg(port.to_string())
.args(args) .args(args)
.assert() .assert()
.stderr(predicates::str::contains("creating server listener")) .stderr(predicates::str::contains("Failed to bind"))
.failure(); .failure();
Ok(()) Ok(())
} }
#[rstest] #[rstest]
fn bind_ipv4(server: TestServer) -> Result<(), Error> { #[case(server(&[] as &[&str]), true, true)]
assert!(reqwest::blocking::get(format!("http://127.0.0.1:{}", server.port()).as_str()).is_ok()); #[case(server(&["-b", "0.0.0.0"]), true, false)]
Ok(()) #[case(server(&["-b", "127.0.0.1", "-b", "::1"]), true, true)]
} fn bind_ipv4_ipv6(
#[case] server: TestServer,
#[rstest] #[case] bind_ipv4: bool,
fn bind_ipv6(#[with(&["-b", "::"])] server: TestServer) -> Result<(), Error> { #[case] bind_ipv6: bool,
) -> Result<(), Error> {
assert_eq!( assert_eq!(
reqwest::blocking::get(format!("http://127.0.0.1:{}", server.port()).as_str()).is_ok(), reqwest::blocking::get(format!("http://127.0.0.1:{}", server.port()).as_str()).is_ok(),
!cfg!(windows) bind_ipv4
); );
assert!(reqwest::blocking::get(format!("http://[::1]:{}", server.port()).as_str()).is_ok()); assert_eq!(
reqwest::blocking::get(format!("http://[::1]:{}", server.port()).as_str()).is_ok(),
bind_ipv6
);
Ok(()) Ok(())
} }
@@ -45,7 +50,7 @@ fn bind_ipv6(#[with(&["-b", "::"])] server: TestServer) -> Result<(), Error> {
#[case(&[] as &[&str])] #[case(&[] as &[&str])]
#[case(&["--path-prefix", "/prefix"])] #[case(&["--path-prefix", "/prefix"])]
fn validate_printed_urls(tmpdir: TempDir, port: u16, #[case] args: &[&str]) -> Result<(), Error> { fn validate_printed_urls(tmpdir: TempDir, port: u16, #[case] args: &[&str]) -> Result<(), Error> {
let mut child = Command::cargo_bin("duf")? let mut child = Command::cargo_bin("dufs")?
.env("RUST_LOG", "false") .env("RUST_LOG", "false")
.arg(tmpdir.path()) .arg(tmpdir.path())
.arg("-p") .arg("-p")
@@ -54,6 +59,8 @@ fn validate_printed_urls(tmpdir: TempDir, port: u16, #[case] args: &[&str]) -> R
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.spawn()?; .spawn()?;
wait_for_port(port);
// WARN assumes urls list is terminated by an empty line // WARN assumes urls list is terminated by an empty line
let url_lines = BufReader::new(child.stdout.take().unwrap()) let url_lines = BufReader::new(child.stdout.take().unwrap())
.lines() .lines()

View File

@@ -5,7 +5,7 @@ use fixtures::{server, Error, TestServer};
use rstest::rstest; use rstest::rstest;
#[rstest] #[rstest]
fn cors(#[with(&["--cors"])] server: TestServer) -> Result<(), Error> { fn cors(#[with(&["--enable-cors"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?; let resp = reqwest::blocking::get(server.url())?;
assert_eq!( assert_eq!(
@@ -21,7 +21,7 @@ fn cors(#[with(&["--cors"])] server: TestServer) -> Result<(), Error> {
} }
#[rstest] #[rstest]
fn cors_options(#[with(&["--cors"])] server: TestServer) -> Result<(), Error> { fn cors_options(#[with(&["--enable-cors"])] server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"OPTIONS", server.url()).send()?; let resp = fetch!(b"OPTIONS", server.url()).send()?;
assert_eq!( assert_eq!(

View File

@@ -29,7 +29,11 @@ pub static FILES: &[&str] = &[
"foo\\bar.test", "foo\\bar.test",
]; ];
/// Directory names for testing purpose /// Directory names for testing diretory don't exist
#[allow(dead_code)]
pub static DIR_NO_FOUND: &str = "dir-no-found/";
/// Directory names for testing diretory don't have index.html
#[allow(dead_code)] #[allow(dead_code)]
pub static DIR_NO_INDEX: &str = "dir-no-index/"; pub static DIR_NO_INDEX: &str = "dir-no-index/";
@@ -55,7 +59,7 @@ pub fn tmpdir() -> TempDir {
} }
for directory in DIRECTORIES { for directory in DIRECTORIES {
for file in FILES { for file in FILES {
if *directory == DIR_NO_INDEX { if *directory == DIR_NO_INDEX && *file == "index.html" {
continue; continue;
} }
tmpdir tmpdir
@@ -79,7 +83,7 @@ pub fn port() -> u16 {
free_local_port().expect("Couldn't find a free local port") free_local_port().expect("Couldn't find a free local port")
} }
/// Run miniserve as a server; Start with a temporary directory, a free port and some /// Run dufs as a server; Start with a temporary directory, a free port and some
/// optional arguments then wait for a while for the server setup to complete. /// optional arguments then wait for a while for the server setup to complete.
#[fixture] #[fixture]
#[allow(dead_code)] #[allow(dead_code)]
@@ -90,7 +94,7 @@ where
{ {
let port = port(); let port = port();
let tmpdir = tmpdir(); let tmpdir = tmpdir();
let child = Command::cargo_bin("duf") let child = Command::cargo_bin("dufs")
.expect("Couldn't find test binary") .expect("Couldn't find test binary")
.env("RUST_LOG", "false") .env("RUST_LOG", "false")
.arg(tmpdir.path()) .arg(tmpdir.path())
@@ -118,7 +122,7 @@ where
{ {
let port = port(); let port = port();
let tmpdir = tmpdir(); let tmpdir = tmpdir();
let child = Command::cargo_bin("duf") let child = Command::cargo_bin("dufs")
.expect("Couldn't find test binary") .expect("Couldn't find test binary")
.env("RUST_LOG", "false") .env("RUST_LOG", "false")
.arg(tmpdir.path()) .arg(tmpdir.path())
@@ -138,7 +142,7 @@ where
} }
/// Wait a max of 1s for the port to become available. /// Wait a max of 1s for the port to become available.
fn wait_for_port(port: u16) { pub fn wait_for_port(port: u16) {
let start_wait = Instant::now(); let start_wait = Instant::now();
while !port_check::is_port_reachable(format!("localhost:{}", port)) { while !port_check::is_port_reachable(format!("localhost:{}", port)) {

View File

@@ -63,7 +63,7 @@ fn head_dir_zip(server: TestServer) -> Result<(), Error> {
} }
#[rstest] #[rstest]
fn get_dir_search(server: TestServer) -> Result<(), Error> { fn get_dir_search(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?q={}", server.url(), "test.html"))?; let resp = reqwest::blocking::get(format!("{}?q={}", server.url(), "test.html"))?;
assert_eq!(resp.status(), 200); assert_eq!(resp.status(), 200);
let paths = utils::retrive_index_paths(&resp.text()?); let paths = utils::retrive_index_paths(&resp.text()?);
@@ -75,7 +75,19 @@ fn get_dir_search(server: TestServer) -> Result<(), Error> {
} }
#[rstest] #[rstest]
fn head_dir_search(server: TestServer) -> Result<(), Error> { fn get_dir_search2(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?q={}", server.url(), "😀.data"))?;
assert_eq!(resp.status(), 200);
let paths = utils::retrive_index_paths(&resp.text()?);
assert!(!paths.is_empty());
for p in paths {
assert!(p.contains(&"😀.data"));
}
Ok(())
}
#[rstest]
fn head_dir_search(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"HEAD", format!("{}?q={}", server.url(), "test.html")).send()?; let resp = fetch!(b"HEAD", format!("{}?q={}", server.url(), "test.html")).send()?;
assert_eq!(resp.status(), 200); assert_eq!(resp.status(), 200);
assert_eq!( assert_eq!(
@@ -105,6 +117,7 @@ fn head_file(server: TestServer) -> Result<(), Error> {
assert_eq!(resp.status(), 200); assert_eq!(resp.status(), 200);
assert_eq!(resp.headers().get("content-type").unwrap(), "text/html"); assert_eq!(resp.headers().get("content-type").unwrap(), "text/html");
assert_eq!(resp.headers().get("accept-ranges").unwrap(), "bytes"); assert_eq!(resp.headers().get("accept-ranges").unwrap(), "bytes");
assert!(resp.headers().contains_key("content-disposition"));
assert!(resp.headers().contains_key("etag")); assert!(resp.headers().contains_key("etag"));
assert!(resp.headers().contains_key("last-modified")); assert!(resp.headers().contains_key("last-modified"));
assert!(resp.headers().contains_key("content-length")); assert!(resp.headers().contains_key("content-length"));
@@ -134,7 +147,7 @@ fn options_dir(server: TestServer) -> Result<(), Error> {
resp.headers().get("allow").unwrap(), resp.headers().get("allow").unwrap(),
"GET,HEAD,PUT,OPTIONS,DELETE,PROPFIND,COPY,MOVE" "GET,HEAD,PUT,OPTIONS,DELETE,PROPFIND,COPY,MOVE"
); );
assert_eq!(resp.headers().get("dav").unwrap(), "1"); assert_eq!(resp.headers().get("dav").unwrap(), "1,2");
Ok(()) Ok(())
} }

45
tests/range.rs Normal file
View File

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

View File

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

View File

@@ -29,7 +29,7 @@ fn tls_works(#[case] server: TestServer) -> Result<(), Error> {
/// Wrong path for cert throws error. /// Wrong path for cert throws error.
#[rstest] #[rstest]
fn wrong_path_cert() -> Result<(), Error> { fn wrong_path_cert() -> Result<(), Error> {
Command::cargo_bin("duf")? Command::cargo_bin("dufs")?
.args(&["--tls-cert", "wrong", "--tls-key", "tests/data/key.pem"]) .args(&["--tls-cert", "wrong", "--tls-key", "tests/data/key.pem"])
.assert() .assert()
.failure() .failure()
@@ -41,7 +41,7 @@ fn wrong_path_cert() -> Result<(), Error> {
/// Wrong paths for key throws errors. /// Wrong paths for key throws errors.
#[rstest] #[rstest]
fn wrong_path_key() -> Result<(), Error> { fn wrong_path_key() -> Result<(), Error> {
Command::cargo_bin("duf")? Command::cargo_bin("dufs")?
.args(&["--tls-cert", "tests/data/cert.pem", "--tls-key", "wrong"]) .args(&["--tls-cert", "tests/data/cert.pem", "--tls-key", "wrong"])
.assert() .assert()
.failure() .failure()

View File

@@ -10,7 +10,7 @@ fn propfind_dir(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"PROPFIND", format!("{}dira", server.url())).send()?; let resp = fetch!(b"PROPFIND", format!("{}dira", server.url())).send()?;
assert_eq!(resp.status(), 207); assert_eq!(resp.status(), 207);
let body = resp.text()?; let body = resp.text()?;
assert!(body.contains("<D:href>/dira</D:href>")); assert!(body.contains("<D:href>/dira/</D:href>"));
assert!(body.contains("<D:displayname>dira</D:displayname>")); assert!(body.contains("<D:displayname>dira</D:displayname>"));
for f in FILES { for f in FILES {
assert!(body.contains(&format!("<D:href>/dira/{}</D:href>", utils::encode_uri(f)))); assert!(body.contains(&format!("<D:href>/dira/{}</D:href>", utils::encode_uri(f))));
@@ -29,7 +29,7 @@ fn propfind_dir_depth0(server: TestServer) -> Result<(), Error> {
.send()?; .send()?;
assert_eq!(resp.status(), 207); assert_eq!(resp.status(), 207);
let body = resp.text()?; let body = resp.text()?;
assert!(body.contains("<D:href>/dira</D:href>")); assert!(body.contains("<D:href>/dira/</D:href>"));
assert!(body.contains("<D:displayname>dira</D:displayname>")); assert!(body.contains("<D:displayname>dira</D:displayname>"));
assert_eq!( assert_eq!(
body.lines() body.lines()