Compare commits

..

215 Commits

Author SHA1 Message Date
sigoden
65da9bedee chore(release): version 0.32.0 (#183) 2023-02-24 08:21:57 +08:00
sigoden
e468d823cc chore: update readme 2023-02-22 11:26:17 +08:00
sigoden
902a60563d chore: ui change edit icon 2023-02-22 10:37:54 +08:00
sigoden
f6c2ed2974 chore: optimize ui 2023-02-22 10:09:34 +08:00
sigoden
8f4cbb4826 chore: use anyhow to handle error 2023-02-21 17:23:24 +08:00
sigoden
2064d7803a chore: bump deps 2023-02-21 16:39:57 +08:00
sigoden
ad0be71557 chore: optimize for test auth 2023-02-21 16:16:49 +08:00
sigoden
6d9758c71d feat: ui improves the login experience (#182)
close #157 #158
2023-02-21 12:42:40 +08:00
sigoden
a61fda6e80 feat: support new file (#180) 2023-02-21 08:45:52 +08:00
sigoden
6625c4d3d0 chore: optimize ui 2023-02-21 08:14:03 +08:00
sigoden
dd6973468c feat: support edit files (#179)
close #172
2023-02-20 22:50:24 +08:00
sigoden
c6c78a16c5 chore: optimize ui 2023-02-20 17:23:31 +08:00
sigoden
111103f26b fix: clear search input also clear query (#178)
close #161
2023-02-20 12:07:40 +08:00
sigoden
7d6d7d49ca feat: API to search and list directories (#177)
use `?simple` to output path name only.
use `?json` to output paths in json format.
By default, output html page.

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

* add tests

---------

Co-authored-by: sigoden <sigoden@gmail.com>
2023-02-19 12:30:14 +08:00
sigoden
47883376c1 chore: fix cargo clippy (#174) 2023-02-19 12:24:42 +08:00
MuXiu1997
fea9bf988a feat: use env var for args (#170)
closed #160
2023-02-19 11:40:14 +08:00
MoonFruit
b6d555158c chore: add install instruction for Homebrew (#163) 2022-12-27 10:16:23 +08:00
sigoden
628d863d2e chore: improve code quanity 2022-12-11 15:18:44 +08:00
sigoden
8d9705caa4 feat: add option --allow-archive (#152)
BREAKING CHANGE: explicitly allow download folder as zip file
2022-12-10 11:09:42 +08:00
Kian-Meng Ang
7eef4407fc docs: fix typos (#147)
Found via `codespell -S target -L crate,nd`
2022-12-10 09:18:54 +08:00
Sylvain Prat
f061365587 fix: set the STOPSIGNAL to SIGINT for Dockerfile 2022-12-10 08:31:46 +08:00
sigoden
d35cea4c36 chore(release): version 0.31.0 2022-11-12 08:43:13 +08:00
sigoden
1329e42b9a chore: upgrade clap to v4 (#146) 2022-11-11 21:46:07 +08:00
sigoden
6ebf619430 feat: support unix sockets (#145) 2022-11-11 08:57:44 +08:00
sigoden
8b4727c3a4 fix: panic on PROPFIND // (#144) 2022-11-10 19:28:01 +08:00
Aneesh Agrawal
604ccc6556 fix: status code for MKCOL on existing resource (#142)
* Fix status code for MKCOL on existing resource

Per https://datatracker.ietf.org/doc/html/rfc4918#section-9.3.1,
MKCOL should return a 405 if the resource already exists.

Impetus for this change:
I am using dufs as a webdav server for [Joplin](https://joplinapp.org/)
which interpreted the previous behavior of returning a 403 as an error,
preventing syncing from working.

* add test

Co-authored-by: sigoden <sigoden@gmail.com>
2022-11-10 18:41:10 +08:00
David Politis
1a9990f04e fix: don't search on empty query string (#140)
* fix: don't search on empty query string

* refactor

Co-authored-by: sigoden <sigoden@gmail.com>
2022-11-10 18:02:55 +08:00
sigoden
bd07783cde chore: cargo clippy 2022-11-10 15:38:35 +08:00
sigoden
dbf2de9cb9 fix: auth not works with --path-prefix (#138)
close #137
2022-10-08 09:14:42 +08:00
sigoden
3b3ea718d9 chore: improve readme 2022-09-09 21:43:40 +08:00
sigoden
3debf88da1 chore: improve readme 2022-09-09 21:37:07 +08:00
sigoden
7eaa6f2484 chore: undo hidden arg changes 2022-09-09 21:30:27 +08:00
sigoden
68def1c1d9 chore: update screenshot.png in readme 2022-09-09 21:22:03 +08:00
sigoden
868f4158f5 chore(release): version 0.30.0 2022-09-09 21:04:05 +08:00
sigoden
3063dca0a6 chore: update readme 2022-09-05 10:34:18 +08:00
sigoden
a74e40aee5 feat: add --assets options to override assets (#134)
* feat: add --assets options to override assets

* update readme
2022-09-05 10:30:45 +08:00
sigoden
bde06fef94 chore: refactor clap multiple_occurrences and multiple_values (#130) 2022-08-27 10:30:08 +08:00
sigoden
31c832a742 feat: support sort by name, mtime, size (#128) 2022-08-23 14:24:42 +08:00
Daniel Flannery
9f8171a22f chore: Corrected type in README (#127) 2022-08-17 07:41:02 +08:00
sigoden
0fb9f3b2c8 chore: update readme 2022-08-06 08:30:19 +08:00
sigoden
3ae75d3558 fix: hide path by ext name (#126) 2022-08-06 07:48:34 +08:00
sigoden
dff489398e chore(release): version v0.29.0 2022-08-03 09:05:39 +08:00
sigoden
64e397d18a chore: update --hidden help message 2022-08-03 08:58:52 +08:00
sigoden
cc0014c183 chore: fix typo 2022-08-03 08:51:12 +08:00
sigoden
a489c5647a fix: table row hover highlighting in dark mode (#122) 2022-08-03 07:02:58 +08:00
sigoden
0918fb3fe4 feat: support ecdsa tls cert (#119) 2022-08-02 09:32:11 +08:00
sigoden
14efeb6360 chore: update readme 2022-08-02 07:07:53 +08:00
sigoden
30b8f75bba chore: update deps and remove dependabot 2022-08-02 07:07:33 +08:00
sigoden
a39065beff chore: update readme 2022-08-01 15:12:25 +08:00
sigoden
a493c13734 chore(release): version v0.28.0 2022-08-01 08:47:18 +08:00
sigoden
ae2f878e62 feat: support customize http log format (#116) 2022-07-31 08:27:09 +08:00
sigoden
277d9d22d4 feat(ui): add table row hover (#115) 2022-07-30 08:04:31 +08:00
sigoden
c62926d19c fix(ui): file path contains special charactors (#114) 2022-07-30 07:53:27 +08:00
sigoden
cccbbe9ea4 chore: update deps 2022-07-29 08:54:46 +08:00
sigoden
147048690f chore(release): version v0.27.0 2022-07-25 09:59:32 +08:00
sigoden
9cfd66dab9 feat: adjust digest auth timeout to 1day (#110) 2022-07-21 11:47:47 +08:00
sigoden
b791549ec7 feat: improve hidden to support glob (#108) 2022-07-19 20:37:14 +08:00
sigoden
f148817c52 chore(release): version v0.26.0 2022-07-11 08:54:29 +08:00
sigoden
00ae36d486 chore: improve readme 2022-07-08 22:36:16 +08:00
sigoden
4e823e8bba feat: make --path-prefix works on serving single file (#102) 2022-07-08 19:30:05 +08:00
sigoden
4e84e6c532 fix: cors headers (#100) 2022-07-08 16:18:10 +08:00
sigoden
f49b590a56 chore: update description of --path-prefix 2022-07-07 15:44:25 +08:00
sigoden
cb1f3cddea chore(release): version v0.25.0 2022-07-07 07:51:51 +08:00
sigoden
05dbcfb2df feat: limit the number of concurrent uploads (#98) 2022-07-06 19:17:30 +08:00
sigoden
76e967fa59 feat: add completions (#97) 2022-07-06 12:11:00 +08:00
sigoden
140a360e37 chore: optimize move path default value 2022-07-05 09:16:21 +08:00
sigoden
604cbb7412 feat: check permission on move/copy destination (#93) 2022-07-04 23:25:05 +08:00
sigoden
c6541b1c36 feat: ui supports move folder/file to new path (#92) 2022-07-04 21:20:00 +08:00
sigoden
b6729a3d64 feat: ui supports creating folder (#91) 2022-07-04 20:12:35 +08:00
sigoden
4f1a35de5d chore(release): version v0.24.0 2022-07-03 06:47:49 +08:00
sigoden
2ffdcdf106 feat: allow search with --render-try-index (#88) 2022-07-02 23:25:57 +08:00
sigoden
1e0cdafbcf fix: unexpect stack overflow when searching a lot (#87) 2022-07-02 22:55:22 +08:00
sigoden
0a03941e05 chore: update deps 2022-07-02 11:48:47 +08:00
sigoden
07a7322748 chore: update readme 2022-07-01 21:37:56 +08:00
sigoden
936d08545b chore(release): version v0.23.1 2022-07-01 06:47:34 +08:00
sigoden
2e6af671ca fix: permissions of unzipped files (#84) 2022-06-30 19:29:47 +08:00
sigoden
583117c01f fix: safari layout and compatibility (#83) 2022-06-30 10:00:42 +08:00
sigoden
6e1df040b4 chore: update deps 2022-06-29 20:36:18 +08:00
sigoden
f5aa3354e1 chore: add github issule templates 2022-06-29 15:16:04 +08:00
sigoden
3ed0d885fe chore(release): version v0.23.0 2022-06-29 11:01:40 +08:00
sigoden
542e9a4ec5 chore: remove aarch64-linux-android platform 2022-06-29 10:58:43 +08:00
sigoden
5ee2c5504c ci: support more platforms (#76) 2022-06-29 10:51:59 +08:00
sigoden
fd02a53823 chore: replace old get-if-addrs with new if-addrs (#78) 2022-06-29 10:01:01 +08:00
sigoden
6554c1c308 feat: use feature to conditional support tls (#77) 2022-06-29 09:19:09 +08:00
sigoden
fe71600bd2 chore(release): version v0.22.0 2022-06-26 12:43:20 +08:00
sigoden
9cfeee0df0 chore: update args help message and readme 2022-06-25 09:58:39 +08:00
sigoden
eb7a536a3f feat: support hiding folders with --hidden (#73) 2022-06-25 08:15:16 +08:00
sigoden
c1c6dbc356 chore(release): version v0.21.0 2022-06-23 19:34:38 +08:00
sigoden
e29cf4c752 refactor: split css/js from index.html (#68) 2022-06-21 23:01:00 +08:00
sigoden
7f062b6705 feat: use custom logger with timestamp in rfc3339 (#67) 2022-06-21 21:19:51 +08:00
sigoden
ea8b9e9cce fix: escape name contains html escape code (#65) 2022-06-21 19:23:34 +08:00
sigoden
d2270be8fb chore: update changelog 2022-06-21 07:56:24 +08:00
sigoden
2d0dfed456 chore(release): version v0.20.0 2022-06-21 07:52:45 +08:00
sigoden
4058a2db72 feat: add option --allow-search (#62) 2022-06-21 07:23:20 +08:00
sigoden
069cb64889 fix: decodeURI searching string (#61) 2022-06-20 21:51:41 +08:00
sigoden
c85ea06785 chore: update cli help message and reamde 2022-06-20 19:40:09 +08:00
sigoden
68139c6263 chore: little improves 2022-06-20 15:11:39 +08:00
Joe Koop
deb6365a28 feat: added basic auth (#60)
* some small css fixes and changes

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

* most tests are passing

* fixed all the tests

* maybe now CI will pass

* implemented sigoden's suggestions

* test basic auth

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

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

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

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

This reverts commit ce154d9ebc.

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

close #16
2022-06-07 08:59:44 +08:00
sigoden
5ce7bde05c fix: send index page with content-type (#26) 2022-06-06 11:20:42 +08:00
sigoden
63a7b530bb feat: support ipv6 (#25) 2022-06-06 10:52:12 +08:00
sigoden
7481db5071 chore(release): version v0.13.2 2022-06-06 08:03:00 +08:00
sigoden
b0cc901416 fix: escape path-prefix/url-prefix different 2022-06-06 08:00:26 +08:00
Joe Koop
ce154d9ebc fix: filename xml escaping 2022-06-06 07:54:12 +08:00
sigoden
7c4c264206 chore(release): version v0.13.1 2022-06-06 07:15:48 +08:00
sigoden
c1e0c6bb2f refactor: use logger (#22) 2022-06-06 07:13:22 +08:00
sigoden
f138915f20 fix: escape filename (#21)
close #19
2022-06-06 06:51:35 +08:00
sigoden
a0b413ef30 chore(release): version v0.13.0 2022-06-05 09:33:10 +08:00
sigoden
fc13d41c17 chore(docker): use scratch as docker base image 2022-06-05 09:30:26 +08:00
sigoden
882a9ae716 fix: ctrl+c not exit sometimes 2022-06-05 09:22:24 +08:00
sigoden
5578ee9190 feat: add webdav proppatch handler (#18) 2022-06-05 07:35:05 +08:00
Ryan Russell
916602ae2d chore: fix typos (#17)
* chore(server.rs): fix `retrieve_listening_addrs`

Signed-off-by: Ryan Russell <git@ryanrussell.org>

* docs(index.js): Fix `breadcrumb`

Signed-off-by: Ryan Russell <git@ryanrussell.org>
2022-06-05 06:12:37 +08:00
sigoden
2f40313a54 feat: use digest auth (#14)
* feat: switch to digest auth

* implement digest auth

* cargo fmt

* no lock
2022-06-05 00:09:21 +08:00
sigoden
05155aa532 feat: implement more webdav methods (#13)
Now you can mount the server as webdav driver on windows.
2022-06-04 19:08:18 +08:00
sigoden
4605701366 chore(release): version v0.12.1 2022-06-04 13:39:03 +08:00
sigoden
b7c550e09b chore(release): version v0.12.0 2022-06-04 13:21:46 +08:00
sigoden
fff8fc3ac5 chore: incorrect icon of uploaded file 2022-06-04 13:20:39 +08:00
sigoden
0616602659 feat: remove unzip uploaded feature (#11)
Use drag&drop/webdav to upload folders
2022-06-04 13:01:17 +08:00
sigoden
0a64762df4 feat: support webdav (#10) 2022-06-04 12:51:56 +08:00
sigoden
f103e15e15 chore(release): version v0.11.0 2022-06-03 11:19:57 +08:00
sigoden
9dda55b7c8 feat: listen 0.0.0.0 by default 2022-06-03 11:19:16 +08:00
sigoden
c3dd0f0ec5 feat: support gracefully shutdown server 2022-06-03 11:00:12 +08:00
sigoden
4167e5c07e chore(ci): publish to docker
* ci: publish to docker

* update release.yaml

* update Dockerfile
2022-06-03 10:36:06 +08:00
sigoden
f66e129985 chore(release): version v0.10.1 2022-06-03 07:21:15 +08:00
sigoden
7c3970480e chore: add type comments to assets/js 2022-06-03 07:18:12 +08:00
sigoden
34bc8d411a fix: panic when bind already used port 2022-06-03 07:15:41 +08:00
sigoden
51cedf2f8a chore(release): version v0.10.0 2022-06-03 06:58:10 +08:00
sigoden
48c3c7ded6 fix: broken ui 2022-06-03 06:57:20 +08:00
sigoden
4491a74b34 docs: refactor readme 2022-06-03 06:51:50 +08:00
sigoden
7c2449cb1a fix: rename --no-auth-read to --no-auth-access 2022-06-03 06:51:03 +08:00
sigoden
0a3d9c391f feat: improve ui 2022-06-03 06:49:55 +08:00
sigoden
07f4e7d0f2 fix: remove unzip file even failed to unzip 2022-06-02 19:43:43 +08:00
sigoden
c50f97925c feat: change auth logic/options 2022-06-02 19:36:04 +08:00
sigoden
ecb3984edc chore(readme): insert cli output 2022-06-02 17:10:15 +08:00
sigoden
24f885164a refactor: small improvement 2022-06-02 17:06:22 +08:00
sigoden
201afa6725 chore(release): version v0.9.0 2022-06-02 16:31:43 +08:00
sigoden
e2d7f996c7 feat: support tls 2022-06-02 12:15:06 +08:00
sigoden
97978719b3 feat: list all ifaces when listening 0.0.0.0 2022-06-02 09:44:40 +08:00
sigoden
9aa16d3a10 docs: improve readme 2022-06-02 09:08:55 +08:00
sigoden
d208b5cb6b feat: support path prefix 2022-06-02 08:32:31 +08:00
sigoden
605c6c56c4 chore: upgrade version 2022-06-01 23:12:52 +08:00
sigoden
8718562889 chore: update description 2022-06-01 22:55:11 +08:00
sigoden
830d3343f4 feat: support render-index/render-spa
closes #5
2022-06-01 22:49:55 +08:00
sigoden
1fe391f9d9 chore: move src/assets out of src 2022-06-01 20:26:03 +08:00
sigoden
b88381167b feat: add some headers to res 2022-06-01 20:02:35 +08:00
sigoden
35ed4394df fix: caught 500 if no permission to access dir
releated #4
2022-06-01 19:59:35 +08:00
sigoden
f43d0b646d fix: some typos
close #6
2022-05-31 22:38:00 +08:00
sigoden
a9294f602c feat: cli add allow-symlink option 2022-05-31 21:22:44 +08:00
sigoden
584d33940a chore: upgrade version 2022-05-31 17:02:55 +08:00
sigoden
fc090b6930 fix: not found dir when allow_upload is false 2022-05-31 16:55:52 +08:00
sigoden
19d7b36462 feat: drag and drop uploads, upload folder
close #3
2022-05-31 16:47:06 +08:00
sigoden
755554d3f2 fix: miss file 500 2022-05-31 16:32:50 +08:00
sigoden
6a097e0496 fix: unzip override existed file in uploadonly mode 2022-05-31 15:20:47 +08:00
sigoden
412d42e338 fix: downloaded zip file has no.zip ext in firefox
close #2
2022-05-31 14:39:07 +08:00
sigoden
ec60752ba2 chore: upgrade version 2022-05-31 11:10:15 +08:00
sigoden
ed7f5e425a feat: support range requests 2022-05-31 10:58:32 +08:00
sigoden
3032052923 feat: distinct upload and delete operation 2022-05-31 09:23:35 +08:00
sigoden
be3ae2fe00 feat: delete confirm
close #1
2022-05-31 08:01:03 +08:00
sigoden
fb03f7ddb8 refactor: improve code quality 2022-05-31 07:56:05 +08:00
sigoden
54df4633e1 chore: upgrade version 2022-05-30 16:06:31 +08:00
sigoden
6b293c0f2e chore: rename src/static to src/assets 2022-05-30 14:32:41 +08:00
sigoden
62696b45fd feat: unzip zip file when unload 2022-05-30 14:30:08 +08:00
sigoden
d9547ad00b feat: add no-auth-read options 2022-05-30 12:40:57 +08:00
sigoden
8fbc742c52 chore: reorganize web static files 2022-05-30 12:31:29 +08:00
sigoden
d8d5aae898 feat: add mime and cache headers to response 2022-05-30 11:22:28 +08:00
sigoden
a263d18963 chore: upgrade version 2022-05-29 20:50:47 +08:00
sigoden
10aabcb2f2 feat: add cors 2022-05-29 17:33:21 +08:00
sigoden
06ce7b0175 feat: replace --static option to --no-edit 2022-05-29 17:01:30 +08:00
sigoden
4841ebb76d docs: update readme demo png 2022-05-29 13:16:58 +08:00
sigoden
586b209c89 feat: support searching 2022-05-29 13:10:41 +08:00
sigoden
2eba975066 styles: optimize css 2022-05-29 11:38:54 +08:00
sigoden
c8a25b54ab feat: Automatically create dir while uploading 2022-05-29 10:53:19 +08:00
sigoden
8900dde7e7 refactor: handler zip 2022-05-29 06:57:16 +08:00
50 changed files with 8367 additions and 1164 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
# Directories
/.git/
/.github/
/target/
/examples/
/docs/
/benches/
/tmp/
# Files
.gitignore
*.md
LICENSE*

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

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

View File

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

View File

@@ -6,32 +6,70 @@ on:
- v[0-9]+.[0-9]+.[0-9]+* - v[0-9]+.[0-9]+.[0-9]+*
jobs: jobs:
all: release:
name: All name: Publish to Github Releases
permissions:
contents: write
outputs:
rc: ${{ steps.check-tag.outputs.rc }}
strategy: strategy:
matrix: matrix:
target:
- aarch64-unknown-linux-musl
- aarch64-apple-darwin
- x86_64-apple-darwin
- x86_64-pc-windows-msvc
- x86_64-unknown-linux-musl
include: include:
- target: aarch64-unknown-linux-musl - target: aarch64-unknown-linux-musl
os: ubuntu-latest os: ubuntu-latest
use-cross: true use-cross: true
cargo-flags: ""
- target: aarch64-apple-darwin - target: aarch64-apple-darwin
os: macos-latest os: macos-latest
use-cross: true use-cross: true
cargo-flags: ""
- target: aarch64-pc-windows-msvc
os: windows-latest
use-cross: true
cargo-flags: "--no-default-features"
- target: x86_64-apple-darwin - target: x86_64-apple-darwin
os: macos-latest os: macos-latest
cargo-flags: ""
- target: x86_64-pc-windows-msvc - target: x86_64-pc-windows-msvc
os: windows-latest os: windows-latest
cargo-flags: ""
- target: x86_64-unknown-linux-musl - target: x86_64-unknown-linux-musl
os: ubuntu-latest os: ubuntu-latest
use-cross: true use-cross: true
cargo-flags: ""
- target: i686-unknown-linux-musl
os: ubuntu-latest
use-cross: true
cargo-flags: ""
- target: i686-pc-windows-msvc
os: windows-latest
use-cross: true
cargo-flags: ""
- target: armv7-unknown-linux-musleabihf
os: ubuntu-latest
use-cross: true
cargo-flags: ""
- target: arm-unknown-linux-musleabihf
os: ubuntu-latest
use-cross: true
cargo-flags: ""
- target: mips-unknown-linux-musl
os: ubuntu-latest
use-cross: true
cargo-flags: "--no-default-features"
- target: mipsel-unknown-linux-musl
os: ubuntu-latest
use-cross: true
cargo-flags: "--no-default-features"
- target: mips64-unknown-linux-gnuabi64
os: ubuntu-latest
use-cross: true
cargo-flags: "--no-default-features"
- target: mips64el-unknown-linux-gnuabi64
os: ubuntu-latest
use-cross: true
cargo-flags: "--no-default-features"
runs-on: ${{matrix.os}} runs-on: ${{matrix.os}}
steps: steps:
@@ -57,14 +95,7 @@ jobs:
target: ${{ matrix.target }} target: ${{ matrix.target }}
toolchain: stable toolchain: stable
profile: minimal # minimal component installation (ie, no documentation) profile: minimal # minimal component installation (ie, no documentation)
- name: Install prerequisites
shell: bash
run: |
case ${{ matrix.target }} in
aarch64-unknown-linux-musl) sudo apt-get -y update ; sudo apt-get -y install gcc-aarch64-linux-gnu ;;
esac
- name: Show Version Information (Rust, cargo, GCC) - name: Show Version Information (Rust, cargo, GCC)
shell: bash shell: bash
run: | run: |
@@ -80,7 +111,7 @@ jobs:
with: with:
use-cross: ${{ matrix.use-cross }} use-cross: ${{ matrix.use-cross }}
command: build command: build
args: --locked --release --target=${{ matrix.target }} args: --locked --release --target=${{ matrix.target }} ${{ matrix.cargo-flags }}
- name: Build Archive - name: Build Archive
shell: bash shell: bash
@@ -123,4 +154,50 @@ jobs:
files: ${{ steps.package.outputs.archive }} files: ${{ steps.package.outputs.archive }}
prerelease: ${{ steps.check-tag.outputs.rc == 'true' }} prerelease: ${{ steps.check-tag.outputs.rc == 'true' }}
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
docker:
name: Publish to Docker Hub
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
needs: release
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v2
with:
build-args: |
REPO=${{ github.repository }}
VER=${{ github.ref_name }}
platforms: |
linux/amd64
linux/arm64
linux/386
linux/arm/v7
push: ${{ needs.release.outputs.rc == 'false' }}
tags: ${{ github.repository }}:latest, ${{ github.repository }}:${{ github.ref_name }}
publish-crate:
name: Publish to crates.io
if: ${{ needs.release.outputs.rc == 'false' }}
runs-on: ubuntu-latest
needs: release
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
- name: Publish
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }}
run: cargo publish

447
CHANGELOG.md Normal file
View File

@@ -0,0 +1,447 @@
# Changelog
All notable changes to this project will be documented in this file.
## [0.32.0] - 2023-02-22
### Bug Fixes
- Set the STOPSIGNAL to SIGINT for Dockerfile
- Remove Method::Options auth check ([#168](https://github.com/sigoden/dufs/issues/168))
- Clear search input also clear query ([#178](https://github.com/sigoden/dufs/issues/178))
### Features
- [**breaking**] Add option --allow-archive ([#152](https://github.com/sigoden/dufs/issues/152))
- Use env var for args ([#170](https://github.com/sigoden/dufs/issues/170))
- Hiding only directories instead of files ([#175](https://github.com/sigoden/dufs/issues/175))
- API to search and list directories ([#177](https://github.com/sigoden/dufs/issues/177))
- Support edit files ([#179](https://github.com/sigoden/dufs/issues/179))
- Support new file ([#180](https://github.com/sigoden/dufs/issues/180))
- Ui improves the login experience ([#182](https://github.com/sigoden/dufs/issues/182))
## [0.31.0] - 2022-11-11
### Bug Fixes
- Auth not works with --path-prefix ([#138](https://github.com/sigoden/dufs/issues/138))
- Don't search on empty query string ([#140](https://github.com/sigoden/dufs/issues/140))
- Status code for MKCOL on existing resource ([#142](https://github.com/sigoden/dufs/issues/142))
- Panic on PROPFIND // ([#144](https://github.com/sigoden/dufs/issues/144))
### Features
- Support unix sockets ([#145](https://github.com/sigoden/dufs/issues/145))
## [0.30.0] - 2022-09-09
### Bug Fixes
- Hide path by ext name ([#126](https://github.com/sigoden/dufs/issues/126))
### Features
- Support sort by name, mtime, size ([#128](https://github.com/sigoden/dufs/issues/128))
- Add --assets options to override assets ([#134](https://github.com/sigoden/dufs/issues/134))
## [0.29.0] - 2022-08-03
### Bug Fixes
- Table row hover highlighting in dark mode ([#122](https://github.com/sigoden/dufs/issues/122))
### Features
- Support ecdsa tls cert ([#119](https://github.com/sigoden/dufs/issues/119))
## [0.28.0] - 2022-08-01
### Bug Fixes
- File path contains special characters ([#114](https://github.com/sigoden/dufs/issues/114))
### Features
- Add table row hover ([#115](https://github.com/sigoden/dufs/issues/115))
- Support customize http log format ([#116](https://github.com/sigoden/dufs/issues/116))
## [0.27.0] - 2022-07-25
### Features
- Improve hidden to support glob ([#108](https://github.com/sigoden/dufs/issues/108))
- Adjust digest auth timeout to 1day ([#110](https://github.com/sigoden/dufs/issues/110))
## [0.26.0] - 2022-07-11
### Bug Fixes
- Cors headers ([#100](https://github.com/sigoden/dufs/issues/100))
### Features
- Make --path-prefix works on serving single file ([#102](https://github.com/sigoden/dufs/issues/102))
## [0.25.0] - 2022-07-06
### Features
- Ui supports creating folder ([#91](https://github.com/sigoden/dufs/issues/91))
- Ui supports move folder/file to new path ([#92](https://github.com/sigoden/dufs/issues/92))
- Check permission on move/copy destination ([#93](https://github.com/sigoden/dufs/issues/93))
- Add completions ([#97](https://github.com/sigoden/dufs/issues/97))
- Limit the number of concurrent uploads ([#98](https://github.com/sigoden/dufs/issues/98))
## [0.24.0] - 2022-07-02
### Bug Fixes
- Unexpected stack overflow when searching a lot ([#87](https://github.com/sigoden/dufs/issues/87))
### Features
- Allow search with --render-try-index ([#88](https://github.com/sigoden/dufs/issues/88))
## [0.23.1] - 2022-06-30
### Bug Fixes
- Safari layout and compatibility ([#83](https://github.com/sigoden/dufs/issues/83))
- Permissions of unzipped files ([#84](https://github.com/sigoden/dufs/issues/84))
## [0.23.0] - 2022-06-29
### Features
- Use feature to conditional support tls ([#77](https://github.com/sigoden/dufs/issues/77))
### Ci
- Support more platforms ([#76](https://github.com/sigoden/dufs/issues/76))
## [0.22.0] - 2022-06-26
### Features
- Support hiding folders with --hidden ([#73](https://github.com/sigoden/dufs/issues/73))
## [0.21.0] - 2022-06-23
### Bug Fixes
- Escape name contains html escape code ([#65](https://github.com/sigoden/dufs/issues/65))
### Features
- Use custom logger with timestamp in rfc3339 ([#67](https://github.com/sigoden/dufs/issues/67))
### Refactor
- Split css/js from index.html ([#68](https://github.com/sigoden/dufs/issues/68))
## [0.20.0] - 2022-06-20
### Bug Fixes
- DecodeURI searching string ([#61](https://github.com/sigoden/dufs/issues/61))
### Features
- Added basic auth ([#60](https://github.com/sigoden/dufs/issues/60))
- Add option --allow-search ([#62](https://github.com/sigoden/dufs/issues/62))
## [0.19.0] - 2022-06-19
### Features
- [**breaking**] Path level access control ([#52](https://github.com/sigoden/dufs/issues/52))
- Serve single file ([#54](https://github.com/sigoden/dufs/issues/54))
- Ui hidden root dirname ([#58](https://github.com/sigoden/dufs/issues/58))
- Reactive webpage ([#51](https://github.com/sigoden/dufs/issues/51))
- [**breaking**] Rename to dufs ([#59](https://github.com/sigoden/dufs/issues/59))
### Refactor
- [**breaking**] Rename --cors to --enable-cors ([#57](https://github.com/sigoden/dufs/issues/57))
## [0.18.0] - 2022-06-18
### Features
- Add option --render-try-index ([#47](https://github.com/sigoden/dufs/issues/47))
- Add slash to end of dir href
## [0.17.1] - 2022-06-16
### Bug Fixes
- Range request ([#44](https://github.com/sigoden/dufs/issues/44))
## [0.17.0] - 2022-06-15
### Bug Fixes
- Webdav propfind dir with slash ([#42](https://github.com/sigoden/dufs/issues/42))
### Features
- Listen both ipv4 and ipv6 by default ([#40](https://github.com/sigoden/dufs/issues/40))
### Refactor
- Trivial changes ([#41](https://github.com/sigoden/dufs/issues/41))
## [0.16.0] - 2022-06-12
### Features
- Implement head method ([#33](https://github.com/sigoden/dufs/issues/33))
- Display upload speed and time left ([#34](https://github.com/sigoden/dufs/issues/34))
- Support tls-key in pkcs#8 format ([#35](https://github.com/sigoden/dufs/issues/35))
- Options method return status 200
### Testing
- Add integration tests ([#36](https://github.com/sigoden/dufs/issues/36))
## [0.15.1] - 2022-06-11
### Bug Fixes
- Cannot upload ([#32](https://github.com/sigoden/dufs/issues/32))
## [0.15.0] - 2022-06-10
### Bug Fixes
- Encode webdav href as uri ([#28](https://github.com/sigoden/dufs/issues/28))
- Query dir param
### Features
- Add basic dark theme ([#29](https://github.com/sigoden/dufs/issues/29))
- Add empty state placeholder to page([#30](https://github.com/sigoden/dufs/issues/30))
## [0.14.0] - 2022-06-07
### Bug Fixes
- Send index page with content-type ([#26](https://github.com/sigoden/dufs/issues/26))
### Features
- Support ipv6 ([#25](https://github.com/sigoden/dufs/issues/25))
- Add favicon ([#27](https://github.com/sigoden/dufs/issues/27))
## [0.13.2] - 2022-06-06
### Bug Fixes
- Filename xml escaping
- Escape path-prefix/url-prefix different
## [0.13.1] - 2022-06-05
### Bug Fixes
- Escape filename ([#21](https://github.com/sigoden/dufs/issues/21))
### Refactor
- Use logger ([#22](https://github.com/sigoden/dufs/issues/22))
## [0.13.0] - 2022-06-05
### Bug Fixes
- Ctrl+c not exit sometimes
### Features
- Implement more webdav methods ([#13](https://github.com/sigoden/dufs/issues/13))
- Use digest auth ([#14](https://github.com/sigoden/dufs/issues/14))
- Add webdav proppatch handler ([#18](https://github.com/sigoden/dufs/issues/18))
## [0.12.1] - 2022-06-04
### Features
- Support webdav ([#10](https://github.com/sigoden/dufs/issues/10))
- Remove unzip uploaded feature ([#11](https://github.com/sigoden/dufs/issues/11))
## [0.11.0] - 2022-06-03
### Features
- Support gracefully shutdown server
- Listen 0.0.0.0 by default
## [0.10.1] - 2022-06-02
### Bug Fixes
- Panic when bind already used port
## [0.10.0] - 2022-06-02
### Bug Fixes
- Remove unzip file even failed to unzip
- Rename --no-auth-read to --no-auth-access
- Broken ui
### Documentation
- Refactor readme
### Features
- Change auth logic/options
- Improve ui
### Refactor
- Small improvement
## [0.9.0] - 2022-06-02
### Documentation
- Improve readme
### Features
- Support path prefix
- List all ifaces when listening 0.0.0.0
- Support tls
## [0.8.0] - 2022-06-01
### Bug Fixes
- Some typos
- Caught 500 if no permission to access dir
### Features
- Cli add allow-symlink option
- Add some headers to res
- Support render-index/render-spa
## [0.7.0] - 2022-05-31
### Bug Fixes
- Downloaded zip file has no.zip ext in firefox
- Unzip override existed file in uploadonly mode
- Miss file 500
- Not found dir when allow_upload is false
### Features
- Drag and drop uploads, upload folder
## [0.6.0] - 2022-05-31
### Features
- Delete confirm
- Distinct upload and delete operation
- Support range requests
### Refactor
- Improve code quality
## [0.5.0] - 2022-05-30
### Features
- Add mime and cache headers to response
- Add no-auth-read options
- Unzip zip file when unload
## [0.4.0] - 2022-05-29
### Features
- Replace --static option to --no-edit
- Add cors
## [0.3.0] - 2022-05-29
### Documentation
- Update readme demo png
### Features
- Automatically create dir while uploading
- Support searching
### Refactor
- Handler zip
### Styling
- Optimize css
## [0.2.1] - 2022-05-28
### Bug Fixes
- Cannot upload in root
- Optimize download zip
### Documentation
- Improve readme
### Features
- Aware RUST_LOG
## [0.2.0] - 2022-05-28
### Documentation
- Update demo png
- Improve readme
### Features
- Add logger
- Download folder as zip file
## [0.1.0] - 2022-05-26
### Bug Fixes
- Caught server error when symlink broken
### Documentation
- Improve readme
- Update readme
### Features
- Add basic auth and readonly mode
- Support delete operation
- Remove parent path
### Styling
- Cargo fmt
- Update index page
### Build
- Remove dev deps
### Ci
- Init ci
<!-- generated by git-cliff -->

1950
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +1,63 @@
[package] [package]
name = "duf" name = "dufs"
version = "0.2.1" version = "0.32.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"
autotests = false
categories = ["command-line-utilities", "web-programming::http-server"] categories = ["command-line-utilities", "web-programming::http-server"]
keywords = ["static", "file", "server", "http", "cli"] keywords = ["static", "file", "server", "webdav", "cli"]
[dependencies] [dependencies]
clap = { version = "3", default-features = false, features = ["std", "cargo"] } clap = { version = "4", features = ["wrap_help", "env"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util"]} clap_complete = "4"
chrono = "0.4"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util", "signal"]}
tokio-util = { version = "0.7", features = ["io-util"] }
hyper = { version = "0.14", features = ["http1", "server", "tcp", "stream"] } hyper = { version = "0.14", features = ["http1", "server", "tcp", "stream"] }
percent-encoding = "2.1" percent-encoding = "2.1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio-util = { version = "0.7", features = ["codec", "io-util"] }
futures = "0.3" futures = "0.3"
base64 = "0.13" base64 = "0.21"
async_zip = { version = "0.0.12", default-features = false, features = ["deflate"] }
headers = "0.3"
mime_guess = "2.0"
if-addrs = "0.8"
rustls = { version = "0.20", default-features = false, features = ["tls12"], optional = true }
rustls-pemfile = { version = "1", optional = true }
tokio-rustls = { version = "0.23", optional = true }
md5 = "0.7"
lazy_static = "1.4"
uuid = { version = "1.1", features = ["v4", "fast-rng"] }
urlencoding = "2.1"
xml-rs = "0.8"
log = "0.4" log = "0.4"
simple_logger = "2.1.0" socket2 = "0.4"
async_zip = "0.0.7" async-stream = "0.3"
async-walkdir = "0.2.0" walkdir = "2.3"
form_urlencoded = "1.0"
alphanumeric-sort = "1.4"
content_inspector = "0.2"
anyhow = "1.0"
[features]
default = ["tls"]
tls = ["rustls", "rustls-pemfile", "tokio-rustls"]
[dev-dependencies]
assert_cmd = "2"
reqwest = { version = "0.11", features = ["blocking", "multipart", "rustls-tls"], default-features = false }
assert_fs = "1"
port_check = "0.1"
rstest = "0.16"
regex = "1"
url = "2"
diqwest = { version = "1", features = ["blocking"] }
predicates = "2"
indexmap = "1.9"
[profile.release] [profile.release]
lto = true lto = true

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM alpine as builder
ARG REPO VER TARGETPLATFORM
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
TARGET="x86_64-unknown-linux-musl"; \
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
TARGET="aarch64-unknown-linux-musl"; \
elif [ "$TARGETPLATFORM" = "linux/386" ]; then \
TARGET="i686-unknown-linux-musl"; \
elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \
TARGET="armv7-unknown-linux-musleabihf"; \
fi && \
wget https://github.com/${REPO}/releases/download/${VER}/dufs-${VER}-${TARGET}.tar.gz && \
tar -xf dufs-${VER}-${TARGET}.tar.gz && \
mv dufs /bin/
FROM scratch
COPY --from=builder /bin/dufs /bin/dufs
STOPSIGNAL SIGINT
ENTRYPOINT ["/bin/dufs"]

316
README.md
View File

@@ -1,19 +1,22 @@
# Duf # Dufs
[![CI](https://github.com/sigoden/duf/actions/workflows/ci.yaml/badge.svg)](https://github.com/sigoden/duf/actions/workflows/ci.yaml) [![CI](https://github.com/sigoden/dufs/actions/workflows/ci.yaml/badge.svg)](https://github.com/sigoden/dufs/actions/workflows/ci.yaml)
[![Crates](https://img.shields.io/crates/v/duf.svg)](https://crates.io/crates/duf) [![Crates](https://img.shields.io/crates/v/dufs.svg)](https://crates.io/crates/dufs)
Duf is a simple file server. Dufs is a distinctive utility file server that supports static serving, uploading, searching, accessing control, webdav...
![demo](https://user-images.githubusercontent.com/4012553/170822562-c6594de5-0bb2-4d5e-ba66-5731ab6481fd.png) ![demo](https://user-images.githubusercontent.com/4012553/220513063-ff0f186b-ac54-4682-9af4-47a9781dee0d.png)
## Features ## Features
- Serve static files - Serve static files
- Download folder as zip file - Download folder as zip file
- Upload files - Upload files and folders (Drag & Drop)
- Delete files - Create/Edit/Search files
- Basic authentication - Partial responses (Parallel/Resume download)
- Path level access control
- Support https
- Support webdav
- Easy to use with curl - Easy to use with curl
## Install ## Install
@@ -21,65 +24,310 @@ Duf is a simple file server.
### With cargo ### With cargo
``` ```
cargo install duf cargo install dufs
```
### With docker
```
docker run -v `pwd`:/data -p 5000:5000 --rm -it sigoden/dufs /data -A
```
### With [Homebrew](https://brew.sh)
```
brew install dufs
``` ```
### Binaries on macOS, Linux, Windows ### Binaries on macOS, Linux, Windows
Download from [Github Releases](https://github.com/sigoden/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.
## Usage ## CLI
You can run this command to start serving your current working directory on 127.0.0.1:5000 by default.
``` ```
duf Dufs is a distinctive utility file server - https://github.com/sigoden/dufs
Usage: dufs [OPTIONS] [root]
Arguments:
[root] Specific path to serve [default: .]
Options:
-b, --bind <addrs> Specify bind address or unix socket
-p, --port <port> Specify port to listen on [default: 5000]
--path-prefix <path> Specify a path prefix
--hidden <value> Hide paths from directory listings, separated by `,`
-a, --auth <rules> Add auth for path
--auth-method <value> Select auth method [default: digest] [possible values: basic, digest]
-A, --allow-all Allow all operations
--allow-upload Allow upload files/folders
--allow-delete Allow delete files/folders
--allow-search Allow search files/folders
--allow-symlink Allow symlink to files/folders outside root directory
--allow-archive Allow zip archive generation
--enable-cors Enable CORS, sets `Access-Control-Allow-Origin: *`
--render-index Serve index.html when requesting a directory, returns 404 if not found index.html
--render-try-index Serve index.html when requesting a directory, returns directory listing if not found index.html
--render-spa Serve SPA(Single Page Application)
--assets <path> Use custom assets to override builtin assets
--tls-cert <path> Path to an SSL/TLS certificate to serve with HTTPS
--tls-key <path> Path to the SSL/TLS certificate's private key
--log-format <format> Customize http log format
--completions <shell> Print shell completion script for <shell> [possible values: bash, elvish, fish, powershell, zsh]
-h, --help Print help information
-V, --version Print version information
``` ```
...or specify which folder you want to serve. ## Examples
Serve current working directory in readonly mode
``` ```
duf folder_name dufs
``` ```
Only serve static files, disable upload and delete operations Allow all operations like upload/delete/search/create/edit...
``` ```
duf --static dufs -A
``` ```
Finally, run this command to see a list of all available option Only allow upload operation
### Curl ```
dufs --allow-upload
```
Serve a specific directory
```
dufs Downloads
```
Serve a single file
```
dufs linux-distro.iso
```
Serve a single-page application like react/vue
```
dufs --render-spa
```
Serve a static website with index.html
```
dufs --render-index
```
Require username/password
```
dufs -a /@admin:123
```
Listen on specific host:ip
```
dufs -b 127.0.0.1 -p 80
```
Listen on unix socket
```
dufs -b /tmp/dufs.socket
```
Use https
```
dufs --tls-cert my.crt --tls-key my.key
```
## API
Upload a file
```
curl -T path-to-file http://127.0.0.1:5000/new-path/path-to-file
```
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
curl -o some-file2 http://127.0.0.1:5000/some-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
```
curl --upload-file some-file http://127.0.0.1:5000/some-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-or-folder
``` ```
Create a directory
```
curl -X MKCOL https://127.0.0.1:5000/path-to-folder
```
Move the file/folder to the new path
```
curl -X MOVE https://127.0.0.1:5000/path -H "Destination: https://127.0.0.1:5000/new-path"
```
List/search directory contents
```
curl http://127.0.0.1:5000?simple # output names only, just like `ls -1`
curl http://127.0.0.1:5000?json # output paths in json format
curl http://127.0.0.1:5000?q=Dockerfile&simple # search for files, just like `find -name Dockerfile`
```
With authorization
```
curl --user user:pass --digest http://192.168.8.10:5000/file # digest auth
curl --user user:pass http://192.168.8.10:5000/file # basic auth
```
<details>
<summary><h2>Advanced topics</h2></summary>
### 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>
dufs -a <path>@<readwrite>@<readonly>
dufs -a <path>@<readwrite>@*
```
- `<path>`: Protected url path
- `<readwrite>`: Account with readwrite permissions. If dufs is run with `dufs --allow-all`, the permissions are upload/delete/search/view/download. If dufs is run with `dufs --allow-upload`, the permissions are upload/view/download.
- `<readonly>`: Account with readonly permissions. The permissions are search/view/download if dufs allow search, otherwise view/download..
```
dufs -A -a /@admin:admin
```
`admin` has all permissions for all paths.
```
dufs -A -a /@admin:admin@guest:guest
```
`guest` has readonly permissions for all paths.
```
dufs -A -a /@admin:admin@*
```
All paths is public, everyone can view/download it.
```
dufs -A -a /@admin:admin -a /user1@user1:pass1 -a /user2@pass2:user2
```
`user1` has all permissions for `/user1*` path.
`user2` has all permissions for `/user2*` path.
```
dufs -a /@admin:admin
```
Since dufs only allows viewing/downloading, `admin` can only view/download files.
### Hide Paths
Dufs supports hiding paths from directory listings via option `--hidden`.
```
dufs --hidden .git,.DS_Store,tmp
```
`--hidden` also supports a variant glob:
- `?` matches any single character
- `*` matches any (possibly empty) sequence of characters
- `**`, `[..]`, `[!..]` is not supported
```sh
dufs --hidden '.*'
dufs --hidden '*.log,*.lock'
```
### Log Format
Dufs supports customize http log format with option `--log-format`.
The log format can use following variables.
| variable | description |
| ------------ | ------------------------------------------------------------------------- |
| $remote_addr | client address |
| $remote_user | user name supplied with authentication |
| $request | full original request line |
| $status | response status |
| $http_ | arbitrary request header field. examples: $http_user_agent, $http_referer |
The default log format is `'$remote_addr "$request" $status'`.
```
2022-08-06T06:59:31+08:00 INFO - 127.0.0.1 "GET /" 200
```
Disable http log
```
dufs --log-format=''
```
Log user-agent
```
dufs --log-format '$remote_addr "$request" $status $http_user_agent'
```
```
2022-08-06T06:53:55+08:00 INFO - 127.0.0.1 "GET /" 200 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36
```
Log remote-user
```
dufs --log-format '$remote_addr $remote_user "$request" $status' -a /@admin:admin -a /folder1@user1:pass1
```
```
2022-08-06T07:04:37+08:00 INFO - 127.0.0.1 admin "GET /" 200
```
## Environment variables
All options can be set using environment variables prefixed with `DUFS_`.
`dufs --port 8080 --allow-all` is equal to `DUFS_PORT=8080 DUFS_ALLOW_ALL=true dufs`.
### Customize UI
Dufs allows users to customize the UI with your own assets.
```
dufs --assets my-assets-dir/
```
Your assets folder must contains a `index.html` file.
`index.html` can use the following placeholder variables to retrieve internal data.
- `__INDEX_DATA__`: directory listing data
- `__ASSERTS_PREFIX__`: assets url prefix
</details>
## License ## License
Copyright (c) 2022 duf-developers. Copyright (c) 2022 dufs-developers.
duf is made available under the terms of either the MIT License or the Apache License 2.0, at your option. dufs is made available under the terms of either the MIT License or the Apache License 2.0, at your option.
See the LICENSE-APACHE and LICENSE-MIT files for license details. See the LICENSE-APACHE and LICENSE-MIT files for license details.

BIN
assets/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

281
assets/index.css Normal file
View File

@@ -0,0 +1,281 @@
html {
font-family: -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif;
line-height: 1.5;
color: #24292e;
}
body {
/* prevent premature breadcrumb wrapping on mobile */
min-width: 500px;
margin: 0;
}
.hidden {
display: none !important;
}
.head {
display: flex;
flex-wrap: wrap;
align-items: center;
padding: 0.6em 1em;
position: fixed;
width: 100%;
background-color: white;
}
.breadcrumb {
font-size: 1.25em;
padding-right: 0.6em;
}
.breadcrumb>a {
color: #0366d6;
text-decoration: none;
}
.breadcrumb>a:hover {
text-decoration: underline;
}
/* final breadcrumb */
.breadcrumb>b {
color: #24292e;
}
.breadcrumb>.separator {
color: #586069;
padding: 0 0.25em;
}
.breadcrumb svg {
height: 100%;
fill: rgba(3, 47, 98, 0.5);
}
.toolbox {
display: flex;
margin-right: 10px;
}
.toolbox>a,
.toolbox>div {
/* vertically align with breadcrumb text */
height: 1.1rem;
}
.toolbox .control {
cursor: pointer;
padding-left: 0.25em;
}
.upload-file input {
display: none;
}
.searchbar {
display: flex;
flex-wrap: nowrap;
width: 246px;
height: 22px;
background-color: #fafafa;
transition: all .15s;
border: 1px #ddd solid;
border-radius: 15px;
margin-bottom: 2px;
}
.searchbar #search {
box-sizing: border-box;
width: 100%;
height: 100%;
font-size: 16px;
line-height: 16px;
padding: 1px;
background-color: transparent;
border: none;
outline: none;
}
.searchbar .icon {
color: #9a9a9a;
padding: 3px 3px;
cursor: pointer;
}
.upload-status span {
width: 70px;
display: inline-block;
}
.main {
padding: 3.3em 1em 0;
}
.empty-folder {
font-style: italic;
}
.uploaders-table th,
.paths-table th {
text-align: left;
font-weight: unset;
color: #5c5c5c;
white-space: nowrap;
}
.uploaders-table td,
.paths-table td {
white-space: nowrap;
}
.uploaders-table .cell-status {
width: 80px;
padding-left: 0.6em;
}
.paths-table thead a {
color: unset;
text-decoration: none;
}
.paths-table thead a>span {
padding-left: 2px;
}
.paths-table tbody tr:hover {
background-color: #fafafa;
}
.paths-table .cell-actions {
width: 75px;
display: flex;
padding-left: 0.6em;
}
.paths-table .cell-mtime {
width: 120px;
padding-left: 0.6em;
}
.paths-table .cell-size {
text-align: right;
width: 70px;
padding-left: 0.6em;
}
.path svg {
height: 16px;
fill: rgba(3, 47, 98, 0.5);
padding-right: 0.5em;
vertical-align: text-top;
}
.path {
list-style: none;
}
.path a {
color: #0366d6;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
display: block;
text-decoration: none;
max-width: calc(100vw - 375px);
min-width: 200px;
}
.path a:hover {
text-decoration: underline;
}
.action-btn {
padding-right: 0.3em;
cursor: pointer;
}
.uploaders-table {
padding: 0.5em 0;
}
.uploader {
padding-right: 1em;
}
.editor {
width: 100%;
height: calc(100vh - 5rem);
border: 1px solid #ced4da;
outline: none;
}
.toolbox2 {
margin-left: auto;
margin-right: 2em;
}
.save-btn {
cursor: pointer;
user-select: none;
}
.not-editable {
font-style: italic;
}
@media (min-width: 768px) {
.path a {
min-width: 400px;
}
}
/* dark theme */
@media (prefers-color-scheme: dark) {
body {
background-color: #000;
}
html,
.breadcrumb>b,
.searchbar #search {
color: #fff;
}
.uploaders-table th,
.paths-table th {
color: #ddd;
}
svg,
.path svg,
.breadcrumb svg {
fill: #fff;
}
.head {
background-color: #111;
}
.searchbar {
background-color: #111;
border-color: #fff6;
}
.searchbar svg {
fill: #fff6;
}
.path a {
color: #3191ff;
}
.paths-table tbody tr:hover {
background-color: #1a1a1a;
}
.editor {
background: black;
color: white;
}
}

132
assets/index.html Normal file
View File

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

798
assets/index.js Normal file
View File

@@ -0,0 +1,798 @@
/**
* @typedef {object} PathItem
* @property {"Dir"|"SymlinkDir"|"File"|"SymlinkFile"} path_type
* @property {string} name
* @property {number} mtime
* @property {number} size
*/
/**
* @typedef {object} DATA
* @property {string} href
* @property {string} uri_prefix
* @property {"Index" | "Edit"} kind
* @property {PathItem[]} paths
* @property {boolean} allow_upload
* @property {boolean} allow_delete
* @property {boolean} allow_search
* @property {boolean} allow_archive
* @property {boolean} auth
* @property {string} user
* @property {boolean} dir_exists
* @property {string} editable
*/
/**
* @type {DATA} DATA
*/
var DATA;
/**
* @type {PARAMS}
* @typedef {object} PARAMS
* @property {string} q
* @property {string} sort
* @property {string} order
*/
const PARAMS = Object.fromEntries(new URLSearchParams(window.location.search).entries());
const IFRAME_FORMATS = [
".pdf",
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".svg",
".mp4", ".mov", ".avi", ".wmv", ".flv", ".webm",
".mp3", ".ogg", ".wav", ".m4a",
];
const dirEmptyNote = PARAMS.q ? 'No results' : DATA.dir_exists ? 'Empty folder' : 'Folder will be created when a file is uploaded';
const ICONS = {
dir: `<svg height="16" viewBox="0 0 14 16" width="14"><path fill-rule="evenodd" d="M13 4H7V3c0-.66-.31-1-1-1H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1zM6 4H1V3h5v1z"></path></svg>`,
symlinkFile: `<svg height="16" viewBox="0 0 12 16" width="12"><path fill-rule="evenodd" d="M8.5 1H1c-.55 0-1 .45-1 1v12c0 .55.45 1 1 1h10c.55 0 1-.45 1-1V4.5L8.5 1zM11 14H1V2h7l3 3v9zM6 4.5l4 3-4 3v-2c-.98-.02-1.84.22-2.55.7-.71.48-1.19 1.25-1.45 2.3.02-1.64.39-2.88 1.13-3.73.73-.84 1.69-1.27 2.88-1.27v-2H6z"></path></svg>`,
symlinkDir: `<svg height="16" viewBox="0 0 14 16" width="14"><path fill-rule="evenodd" d="M13 4H7V3c0-.66-.31-1-1-1H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1zM1 3h5v1H1V3zm6 9v-2c-.98-.02-1.84.22-2.55.7-.71.48-1.19 1.25-1.45 2.3.02-1.64.39-2.88 1.13-3.73C4.86 8.43 5.82 8 7.01 8V6l4 3-4 3H7z"></path></svg>`,
file: `<svg height="16" viewBox="0 0 12 16" width="12"><path fill-rule="evenodd" d="M6 5H2V4h4v1zM2 8h7V7H2v1zm0 2h7V9H2v1zm0 2h7v-1H2v1zm10-7.5V14c0 .55-.45 1-1 1H1c-.55 0-1-.45-1-1V2c0-.55.45-1 1-1h7.5L12 4.5zM11 5L8 2H1v12h10V5z"></path></svg>`,
download: `<svg width="16" height="16" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg>`,
move: `<svg width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M1.5 1.5A.5.5 0 0 0 1 2v4.8a2.5 2.5 0 0 0 2.5 2.5h9.793l-3.347 3.346a.5.5 0 0 0 .708.708l4.2-4.2a.5.5 0 0 0 0-.708l-4-4a.5.5 0 0 0-.708.708L13.293 8.3H3.5A1.5 1.5 0 0 1 2 6.8V2a.5.5 0 0 0-.5-.5z"/></svg>`,
edit: `<svg width="16" height="16" viewBox="0 0 16 16"><path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/></svg>`,
delete: `<svg width="16" height="16" fill="currentColor"viewBox="0 0 16 16"><path d="M6.854 7.146a.5.5 0 1 0-.708.708L7.293 9l-1.147 1.146a.5.5 0 0 0 .708.708L8 9.707l1.146 1.147a.5.5 0 0 0 .708-.708L8.707 9l1.147-1.146a.5.5 0 0 0-.708-.708L8 8.293 6.854 7.146z"/><path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5v2z"/></svg>`,
}
/**
* @type Element
*/
let $pathsTable;
/**
* @type Element
*/
let $pathsTableHead;
/**
* @type Element
*/
let $pathsTableBody;
/**
* @type Element
*/
let $uploadersTable;
/**
* @type Element
*/
let $emptyFolder;
/**
* @type Element
*/
let $editor;
/**
* @type Element
*/
let $userBtn;
function ready() {
$pathsTable = document.querySelector(".paths-table")
$pathsTableHead = document.querySelector(".paths-table thead");
$pathsTableBody = document.querySelector(".paths-table tbody");
$uploadersTable = document.querySelector(".uploaders-table");
$emptyFolder = document.querySelector(".empty-folder");
$editor = document.querySelector(".editor");
$userBtn = document.querySelector(".user-btn");
addBreadcrumb(DATA.href, DATA.uri_prefix);
if (DATA.kind == "Index") {
document.title = `Index of ${DATA.href} - Dufs`;
document.querySelector(".index-page").classList.remove("hidden");
setupIndexPage();
} else if (DATA.kind == "Edit") {
document.title = `Edit of ${DATA.href} - Dufs`;
document.querySelector(".editor-page").classList.remove("hidden");;
setupEditPage();
}
}
class Uploader {
/**
*
* @param {File} file
* @param {string[]} dirs
*/
constructor(file, dirs) {
/**
* @type Element
*/
this.$uploadStatus = null
this.uploaded = 0;
this.lastUptime = 0;
this.name = [...dirs, file.name].join("/");
this.idx = Uploader.globalIdx++;
this.file = file;
}
upload() {
const { idx, name } = this;
const url = newUrl(name);
const encodedName = encodedStr(name);
$uploadersTable.insertAdjacentHTML("beforeend", `
<tr id="upload${idx}" class="uploader">
<td class="path cell-icon">
${getPathSvg()}
</td>
<td class="path cell-name">
<a href="${url}">${encodedName}</a>
</td>
<td class="cell-status upload-status" id="uploadStatus${idx}"></td>
</tr>`);
$uploadersTable.classList.remove("hidden");
$emptyFolder.classList.add("hidden");
this.$uploadStatus = document.getElementById(`uploadStatus${idx}`);
this.$uploadStatus.innerHTML = '-';
Uploader.queues.push(this);
Uploader.runQueue();
}
ajax() {
Uploader.runnings += 1;
const url = newUrl(this.name);
this.lastUptime = Date.now();
const ajax = new XMLHttpRequest();
ajax.upload.addEventListener("progress", e => this.progress(e), false);
ajax.addEventListener("readystatechange", () => {
if (ajax.readyState === 4) {
if (ajax.status >= 200 && ajax.status < 300) {
this.complete();
} else {
this.fail();
}
}
})
ajax.addEventListener("error", () => this.fail(), false);
ajax.addEventListener("abort", () => this.fail(), false);
ajax.open("PUT", url);
ajax.send(this.file);
}
progress(event) {
const now = Date.now();
const speed = (event.loaded - this.uploaded) / (now - this.lastUptime) * 1000;
const [speedValue, speedUnit] = formatSize(speed);
const speedText = `${speedValue}${speedUnit.toLowerCase()}/s`;
const progress = formatPercent((event.loaded / event.total) * 100);
const duration = formatDuration((event.total - event.loaded) / speed)
this.$uploadStatus.innerHTML = `<span>${speedText}</span><span>${progress}</span><span>${duration}</span>`;
this.uploaded = event.loaded;
this.lastUptime = now;
}
complete() {
this.$uploadStatus.innerHTML = ``;
Uploader.runnings -= 1;
Uploader.runQueue();
}
fail() {
this.$uploadStatus.innerHTML = ``;
Uploader.runnings -= 1;
Uploader.runQueue();
}
}
Uploader.globalIdx = 0;
Uploader.runnings = 0;
Uploader.auth = false;
/**
* @type Uploader[]
*/
Uploader.queues = [];
Uploader.runQueue = async () => {
if (Uploader.runnings > 2) return;
let uploader = Uploader.queues.shift();
if (!uploader) return;
if (!Uploader.auth) {
Uploader.auth = true;
await login();
}
uploader.ajax();
}
/**
* Add breadcrumb
* @param {string} href
* @param {string} uri_prefix
*/
function addBreadcrumb(href, uri_prefix) {
const $breadcrumb = document.querySelector(".breadcrumb");
let parts = [];
if (href === "/") {
parts = [""];
} else {
parts = href.split("/");
}
const len = parts.length;
let path = uri_prefix;
for (let i = 0; i < len; i++) {
const name = parts[i];
if (i > 0) {
if (!path.endsWith("/")) {
path += "/";
}
path += encodeURIComponent(name);
}
const encodedName = encodedStr(name);
if (i === 0) {
$breadcrumb.insertAdjacentHTML("beforeend", `<a href="${path}"><svg width="16" height="16" viewBox="0 0 16 16"><path d="M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5z"/></svg></a>`);
} else if (i === len - 1) {
$breadcrumb.insertAdjacentHTML("beforeend", `<b>${encodedName}</b>`);
} else {
$breadcrumb.insertAdjacentHTML("beforeend", `<a href="${path}">${encodedName}</a>`);
}
if (i !== len - 1) {
$breadcrumb.insertAdjacentHTML("beforeend", `<span class="separator">/</span>`);
}
}
}
function setupIndexPage() {
if (DATA.allow_archive) {
const $download = document.querySelector(".download");
$download.href = baseUrl() + "?zip";
$download.title = "Download folder as div .zip file";
$download.classList.remove("hidden");
}
if (DATA.allow_upload) {
setupDropzone();
setupUploadFile();
setupNewFolder();
setupNewFile();
}
if (DATA.auth) {
setupAuth();
}
if (DATA.allow_search) {
setupSearch()
}
renderPathsTableHead();
renderPathsTableBody();
}
/**
* Render path table thead
*/
function renderPathsTableHead() {
const headerItems = [
{
name: "name",
props: `colspan="2"`,
text: "Name",
},
{
name: "mtime",
props: ``,
text: "Last Modified",
},
{
name: "size",
props: ``,
text: "Size",
}
];
$pathsTableHead.insertAdjacentHTML("beforeend", `
<tr>
${headerItems.map(item => {
let svg = `<svg width="12" height="12" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M11.5 15a.5.5 0 0 0 .5-.5V2.707l3.146 3.147a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 1 0 .708.708L11 2.707V14.5a.5.5 0 0 0 .5.5zm-7-14a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L4 13.293V1.5a.5.5 0 0 1 .5-.5z"/></svg>`;
let order = "asc";
if (PARAMS.sort === item.name) {
if (PARAMS.order === "asc") {
order = "desc";
svg = `<svg width="12" height="12" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 15a.5.5 0 0 0 .5-.5V2.707l3.146 3.147a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 1 0 .708.708L7.5 2.707V14.5a.5.5 0 0 0 .5.5z"/></svg>`
} else {
svg = `<svg width="12" height="12" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 1a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L7.5 13.293V1.5A.5.5 0 0 1 8 1z"/></svg>`
}
}
const qs = new URLSearchParams({ ...PARAMS, order, sort: item.name }).toString();
const icon = `<span>${svg}</span>`
return `<th class="cell-${item.name}" ${item.props}><a href="?${qs}">${item.text}${icon}</a></th>`
}).join("\n")}
<th class="cell-actions">Actions</th>
</tr>
`);
}
/**
* Render path table tbody
*/
function renderPathsTableBody() {
if (DATA.paths && DATA.paths.length > 0) {
const len = DATA.paths.length;
if (len > 0) {
$pathsTable.classList.remove("hidden");
}
for (let i = 0; i < len; i++) {
addPath(DATA.paths[i], i);
}
} else {
$emptyFolder.textContent = dirEmptyNote;
$emptyFolder.classList.remove("hidden");
}
}
/**
* Add pathitem
* @param {PathItem} file
* @param {number} index
*/
function addPath(file, index) {
const encodedName = encodedStr(file.name);
let url = newUrl(file.name)
let actionDelete = "";
let actionDownload = "";
let actionMove = "";
let actionEdit = "";
let isDir = file.path_type.endsWith("Dir");
if (isDir) {
url += "/";
if (DATA.allow_archive) {
actionDownload = `
<div class="action-btn">
<a href="${url}?zip" title="Download folder as a .zip file">${ICONS.download}</a>
</div>`;
}
} else {
actionDownload = `
<div class="action-btn" >
<a href="${url}" title="Download file" download>${ICONS.download}</a>
</div>`;
}
if (DATA.allow_delete) {
if (DATA.allow_upload) {
if (isDir) {
actionMove = `<div onclick="movePath(${index})" class="action-btn" id="moveBtn${index}" title="Move to new path">${ICONS.move}</div>`;
} else {
actionEdit = `<a class="action-btn" title="Edit file" target="_blank" href="${url}?edit">${ICONS.edit}</a>`;
}
}
actionDelete = `
<div onclick="deletePath(${index})" class="action-btn" id="deleteBtn${index}" title="Delete">${ICONS.delete}</div>`;
}
let actionCell = `
<td class="cell-actions">
${actionDownload}
${actionMove}
${actionEdit}
${actionDelete}
</td>`
$pathsTableBody.insertAdjacentHTML("beforeend", `
<tr id="addPath${index}">
<td class="path cell-icon">
${getPathSvg(file.path_type)}
</td>
<td class="path cell-name">
<a href="${url}" ${isDir ? "" : `target="_blank"`}>${encodedName}</a>
</td>
<td class="cell-mtime">${formatMtime(file.mtime)}</td>
<td class="cell-size">${formatSize(file.size).join(" ")}</td>
${actionCell}
</tr>`)
}
function setupDropzone() {
["drag", "dragstart", "dragend", "dragover", "dragenter", "dragleave", "drop"].forEach(name => {
document.addEventListener(name, e => {
e.preventDefault();
e.stopPropagation();
});
});
document.addEventListener("drop", async e => {
if (!e.dataTransfer.items[0].webkitGetAsEntry) {
const files = e.dataTransfer.files.filter(v => v.size > 0);
for (const file of files) {
new Uploader(file, []).upload();
}
} else {
const entries = [];
const len = e.dataTransfer.items.length;
for (let i = 0; i < len; i++) {
entries.push(e.dataTransfer.items[i].webkitGetAsEntry());
}
addFileEntries(entries, [])
}
});
}
function setupAuth() {
if (DATA.user) {
$userBtn.classList.remove("hidden");
$userBtn.title = DATA.user;
} else {
const $loginBtn = document.querySelector(".login-btn");
$loginBtn.classList.remove("hidden");
$loginBtn.addEventListener("click", () => login(true));
}
}
function setupSearch() {
const $searchbar = document.querySelector(".searchbar");
$searchbar.classList.remove("hidden");
$searchbar.addEventListener("submit", event => {
event.preventDefault();
const formData = new FormData($searchbar);
const q = formData.get("q");
let href = baseUrl();
if (q) {
href += "?q=" + q;
}
location.href = href;
});
if (PARAMS.q) {
document.getElementById('search').value = PARAMS.q;
}
}
function setupUploadFile() {
document.querySelector(".upload-file").classList.remove("hidden");
document.getElementById("file").addEventListener("change", async e => {
const files = e.target.files;
for (let file of files) {
new Uploader(file, []).upload();
}
});
}
function setupNewFolder() {
const $newFolder = document.querySelector(".new-folder");
$newFolder.classList.remove("hidden");
$newFolder.addEventListener("click", () => {
const name = prompt("Enter folder name");
if (name) createFolder(name);
});
}
function setupNewFile() {
const $newFile = document.querySelector(".new-file");
$newFile.classList.remove("hidden");
$newFile.addEventListener("click", () => {
const name = prompt("Enter file name");
if (name) createFile(name);
});
}
async function setupEditPage() {
const url = baseUrl();
const $download = document.querySelector(".download");
$download.classList.remove("hidden");
$download.href = url;
const $moveFile = document.querySelector(".move-file");
$moveFile.classList.remove("hidden");
$moveFile.addEventListener("click", async () => {
const query = location.href.slice(url.length);
const newFileUrl = await doMovePath(url);
if (newFileUrl) {
location.href = newFileUrl + query;
}
});
const $deleteFile = document.querySelector(".delete-file");
$deleteFile.classList.remove("hidden");
$deleteFile.addEventListener("click", async () => {
const url = baseUrl();
const name = baseName(url);
await doDeletePath(name, url, () => {
location.href = location.href.split("/").slice(0, -1).join("/");
});
})
if (!DATA.editable) {
const $notEditable = document.querySelector(".not-editable");
const url = baseUrl();
const ext = extName(baseName(url));
if (IFRAME_FORMATS.find(v => v === ext)) {
$notEditable.insertAdjacentHTML("afterend", `<iframe src="${url}" sandbox width="100%" height="${window.innerHeight - 100}px"></iframe>`)
} else {
$notEditable.classList.remove("hidden");
$notEditable.textContent = "Cannot edit because it is too large or binary.";
}
return;
}
const $saveBtn = document.querySelector(".save-btn");
$saveBtn.classList.remove("hidden");
$saveBtn.addEventListener("click", saveChange);
$editor.classList.remove("hidden");
try {
const res = await fetch(baseUrl());
await assertResOK(res);
const text = await res.text();
$editor.value = text;
} catch (err) {
alert(`Failed get file, ${err.message}`);
}
}
/**
* Delete path
* @param {number} index
* @returns
*/
async function deletePath(index) {
const file = DATA.paths[index];
if (!file) return;
await doDeletePath(file.name, newUrl(file.name), () => {
document.getElementById(`addPath${index}`)?.remove();
DATA.paths[index] = null;
if (!DATA.paths.find(v => !!v)) {
$pathsTable.classList.add("hidden");
$emptyFolder.textContent = dirEmptyNote;
$emptyFolder.classList.remove("hidden");
}
})
}
async function doDeletePath(name, url, cb) {
if (!confirm(`Delete \`${name}\`?`)) return;
try {
await login();
const res = await fetch(url, {
method: "DELETE",
});
await assertResOK(res);
cb();
} catch (err) {
alert(`Cannot delete \`${file.name}\`, ${err.message}`);
}
}
/**
* Move path
* @param {number} index
* @returns
*/
async function movePath(index) {
const file = DATA.paths[index];
if (!file) return;
const fileUrl = newUrl(file.name);
const newFileUrl = await doMovePath(fileUrl);
if (newFileUrl) {
location.href = newFileUrl.split("/").slice(0, -1).join("/");
}
}
async function doMovePath(fileUrl) {
const fileUrlObj = new URL(fileUrl)
const prefix = DATA.uri_prefix.slice(0, -1);
const filePath = decodeURIComponent(fileUrlObj.pathname.slice(prefix.length));
let newPath = prompt("Enter new path", filePath)
if (!newPath) return;
if (!newPath.startsWith("/")) newPath = "/" + newPath;
if (filePath === newPath) return;
const newFileUrl = fileUrlObj.origin + prefix + newPath.split("/").map(encodeURIComponent).join("/");
try {
await login();
const res1 = await fetch(newFileUrl, {
method: "HEAD",
});
if (res1.status === 200) {
if (!confirm("Override exsis file?")) {
return;
}
}
const res2 = await fetch(fileUrl, {
method: "MOVE",
headers: {
"Destination": newFileUrl,
}
});
await assertResOK(res2);
return newFileUrl;
} catch (err) {
alert(`Cannot move \`${filePath}\` to \`${newPath}\`, ${err.message}`);
}
}
/**
* Save editor change
*/
async function saveChange() {
try {
await fetch(baseUrl(), {
method: "PUT",
body: $editor.value,
});
} catch (err) {
alert(`Failed to save file, ${err.message}`);
}
}
async function login(alert = false) {
if (!DATA.auth) return;
try {
const res = await fetch(baseUrl(), {
method: "WRITEABLE",
});
await assertResOK(res);
document.querySelector(".login-btn").classList.add("hidden");
$userBtn.classList.remove("hidden");
$userBtn.title = "";
} catch (err) {
let message = `Cannot login, ${err.message}`;
if (alert) {
alert(message);
} else {
throw new Error(message);
}
}
}
/**
* Create a folder
* @param {string} name
*/
async function createFolder(name) {
const url = newUrl(name);
try {
await login();
const res = await fetch(url, {
method: "MKCOL",
});
await assertResOK(res);
location.href = url;
} catch (err) {
alert(`Cannot create folder \`${name}\`, ${err.message}`);
}
}
async function createFile(name) {
const url = newUrl(name);
try {
await login();
const res = await fetch(url, {
method: "PUT",
body: "",
});
await assertResOK(res);
location.href = url + "?edit";
} catch (err) {
alert(`Cannot create file \`${name}\`, ${err.message}`);
}
}
async function addFileEntries(entries, dirs) {
for (const entry of entries) {
if (entry.isFile) {
entry.file(file => {
new Uploader(file, dirs).upload();
});
} else if (entry.isDirectory) {
const dirReader = entry.createReader()
dirReader.readEntries(entries => addFileEntries(entries, [...dirs, entry.name]));
}
}
}
function newUrl(name) {
let url = baseUrl();
if (!url.endsWith("/")) url += "/";
url += name.split("/").map(encodeURIComponent).join("/");
return url;
}
function baseUrl() {
return location.href.split('?')[0];
}
function baseName(url) {
return decodeURIComponent(url.split("/").filter(v => v.length > 0).slice(-1)[0])
}
function extName(filename) {
const dotIndex = filename.lastIndexOf('.');
if (dotIndex === -1 || dotIndex === 0 || dotIndex === filename.length - 1) {
return '';
}
return filename.substring(dotIndex);
}
function getPathSvg(path_type) {
switch (path_type) {
case "Dir":
return ICONS.dir;
case "SymlinkFile":
return ICONS.symlinkFile;
case "SymlinkDir":
return ICONS.symlinkDir;
default:
return ICONS.file;
}
}
function formatMtime(mtime) {
if (!mtime) return ""
const date = new Date(mtime);
const year = date.getFullYear();
const month = padZero(date.getMonth() + 1, 2);
const day = padZero(date.getDate(), 2);
const hours = padZero(date.getHours(), 2);
const minutes = padZero(date.getMinutes(), 2);
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
function padZero(value, size) {
return ("0".repeat(size) + value).slice(-1 * size)
}
function formatSize(size) {
if (size == null) return []
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
if (size == 0) return [0, "B"];
const i = parseInt(Math.floor(Math.log(size) / Math.log(1024)));
return [Math.round(size / Math.pow(1024, i), 2), sizes[i]];
}
function formatDuration(seconds) {
seconds = Math.ceil(seconds);
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds - h * 3600) / 60);
const s = seconds - h * 3600 - m * 60
return `${padZero(h, 2)}:${padZero(m, 2)}:${padZero(s, 2)}`;
}
function formatPercent(percent) {
if (percent > 10) {
return percent.toFixed(1) + "%";
} else {
return percent.toFixed(2) + "%";
}
}
function encodedStr(rawStr) {
return rawStr.replace(/[\u00A0-\u9999<>\&]/g, function (i) {
return '&#' + i.charCodeAt(0) + ';';
});
}
async function assertResOK(res) {
if (!(res.status >= 200 && res.status < 300)) {
throw new Error(await res.text())
}
}

View File

@@ -1,128 +1,399 @@
use clap::crate_description; use anyhow::{anyhow, bail, Result};
use clap::{Arg, ArgMatches}; use clap::builder::PossibleValuesParser;
use clap::{value_parser, Arg, ArgAction, ArgMatches, Command};
use clap_complete::{generate, Generator, Shell};
#[cfg(feature = "tls")]
use rustls::{Certificate, PrivateKey};
use std::env; use std::env;
use std::fs::canonicalize; use std::net::IpAddr;
use std::net::SocketAddr;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use crate::BoxResult; use crate::auth::AccessControl;
use crate::auth::AuthMethod;
use crate::log_http::{LogHttp, DEFAULT_LOG_FORMAT};
#[cfg(feature = "tls")]
use crate::tls::{load_certs, load_private_key};
use crate::utils::encode_uri;
const ABOUT: &str = concat!("\n", crate_description!()); // Add extra newline. pub fn build_cli() -> Command {
let app = Command::new(env!("CARGO_CRATE_NAME"))
.version(env!("CARGO_PKG_VERSION"))
.author(env!("CARGO_PKG_AUTHORS"))
.about(concat!(
env!("CARGO_PKG_DESCRIPTION"),
" - ",
env!("CARGO_PKG_REPOSITORY")
))
.arg(
Arg::new("root")
.env("DUFS_ROOT")
.hide_env(true)
.default_value(".")
.value_parser(value_parser!(PathBuf))
.help("Specific path to serve"),
)
.arg(
Arg::new("bind")
.env("DUFS_BIND")
.hide_env(true)
.short('b')
.long("bind")
.help("Specify bind address or unix socket")
.action(ArgAction::Append)
.value_delimiter(',')
.value_name("addrs"),
)
.arg(
Arg::new("port")
.env("DUFS_PORT")
.hide_env(true)
.short('p')
.long("port")
.default_value("5000")
.value_parser(value_parser!(u16))
.help("Specify port to listen on")
.value_name("port"),
)
.arg(
Arg::new("path-prefix")
.env("DUFS_PATH_PREFIX")
.hide_env(true)
.long("path-prefix")
.value_name("path")
.help("Specify a path prefix"),
)
.arg(
Arg::new("hidden")
.env("DUFS_HIDDEN")
.hide_env(true)
.long("hidden")
.help("Hide paths from directory listings, separated by `,`")
.value_name("value"),
)
.arg(
Arg::new("auth")
.env("DUFS_AUTH")
.hide_env(true)
.short('a')
.long("auth")
.help("Add auth for path")
.action(ArgAction::Append)
.value_delimiter(',')
.value_name("rules"),
)
.arg(
Arg::new("auth-method")
.env("DUFS_AUTH_METHOD")
.hide_env(true)
.long("auth-method")
.help("Select auth method")
.value_parser(PossibleValuesParser::new(["basic", "digest"]))
.default_value("digest")
.value_name("value"),
)
.arg(
Arg::new("allow-all")
.env("DUFS_ALLOW_ALL")
.hide_env(true)
.short('A')
.long("allow-all")
.action(ArgAction::SetTrue)
.help("Allow all operations"),
)
.arg(
Arg::new("allow-upload")
.env("DUFS_ALLOW_UPLOAD")
.hide_env(true)
.long("allow-upload")
.action(ArgAction::SetTrue)
.help("Allow upload files/folders"),
)
.arg(
Arg::new("allow-delete")
.env("DUFS_ALLOW_DELETE")
.hide_env(true)
.long("allow-delete")
.action(ArgAction::SetTrue)
.help("Allow delete files/folders"),
)
.arg(
Arg::new("allow-search")
.env("DUFS_ALLOW_SEARCH")
.hide_env(true)
.long("allow-search")
.action(ArgAction::SetTrue)
.help("Allow search files/folders"),
)
.arg(
Arg::new("allow-symlink")
.env("DUFS_ALLOW_SYMLINK")
.hide_env(true)
.long("allow-symlink")
.action(ArgAction::SetTrue)
.help("Allow symlink to files/folders outside root directory"),
)
.arg(
Arg::new("allow-archive")
.env("DUFS_ALLOW_ARCHIVE")
.hide_env(true)
.long("allow-archive")
.action(ArgAction::SetTrue)
.help("Allow zip archive generation"),
)
.arg(
Arg::new("enable-cors")
.env("DUFS_ENABLE_CORS")
.hide_env(true)
.long("enable-cors")
.action(ArgAction::SetTrue)
.help("Enable CORS, sets `Access-Control-Allow-Origin: *`"),
)
.arg(
Arg::new("render-index")
.env("DUFS_RENDER_INDEX")
.hide_env(true)
.long("render-index")
.action(ArgAction::SetTrue)
.help("Serve index.html when requesting a directory, returns 404 if not found index.html"),
)
.arg(
Arg::new("render-try-index")
.env("DUFS_RENDER_TRY_INDEX")
.hide_env(true)
.long("render-try-index")
.action(ArgAction::SetTrue)
.help("Serve index.html when requesting a directory, returns directory listing if not found index.html"),
)
.arg(
Arg::new("render-spa")
.env("DUFS_RENDER_SPA")
.hide_env(true)
.long("render-spa")
.action(ArgAction::SetTrue)
.help("Serve SPA(Single Page Application)"),
)
.arg(
Arg::new("assets")
.env("DUFS_ASSETS")
.hide_env(true)
.long("assets")
.help("Use custom assets to override builtin assets")
.value_parser(value_parser!(PathBuf))
.value_name("path")
);
fn app() -> clap::Command<'static> { #[cfg(feature = "tls")]
let arg_port = Arg::new("port") let app = app
.short('p') .arg(
.long("port") Arg::new("tls-cert")
.default_value("5000") .env("DUFS_TLS_CERT")
.help("Specify port to listen on") .hide_env(true)
.value_name("port"); .long("tls-cert")
.value_name("path")
.value_parser(value_parser!(PathBuf))
.help("Path to an SSL/TLS certificate to serve with HTTPS"),
)
.arg(
Arg::new("tls-key")
.env("DUFS_TLS_KEY")
.hide_env(true)
.long("tls-key")
.value_name("path")
.value_parser(value_parser!(PathBuf))
.help("Path to the SSL/TLS certificate's private key"),
);
let arg_address = Arg::new("address") app.arg(
.short('b') Arg::new("log-format")
.long("bind") .env("DUFS_LOG_FORMAT")
.default_value("127.0.0.1") .hide_env(true)
.help("Specify bind address") .long("log-format")
.value_name("address"); .value_name("format")
.help("Customize http log format"),
let arg_path = Arg::new("path") )
.default_value(".") .arg(
.allow_invalid_utf8(true) Arg::new("completions")
.help("Path to a directory for serving files"); .long("completions")
.value_name("shell")
let arg_static = Arg::new("static") .value_parser(value_parser!(Shell))
.long("static") .help("Print shell completion script for <shell>"),
.help("Only serve static files, disable upload and delete operations"); )
let arg_auth = Arg::new("auth")
.short('a')
.long("auth")
.help("Authenticate with user and pass")
.value_name("user:pass");
let arg_no_log = Arg::new("no-log")
.long("--no-log")
.help("Don't log any request/response information.");
clap::command!()
.about(ABOUT)
.arg(arg_address)
.arg(arg_port)
.arg(arg_path)
.arg(arg_static)
.arg(arg_auth)
.arg(arg_no_log)
} }
pub fn matches() -> ArgMatches { pub fn print_completions<G: Generator>(gen: G, cmd: &mut Command) {
app().get_matches() generate(gen, cmd, cmd.get_name().to_string(), &mut std::io::stdout());
} }
#[derive(Debug, Clone, Eq, PartialEq)] #[derive(Debug)]
pub struct Args { pub struct Args {
pub address: String, pub addrs: Vec<BindAddr>,
pub port: u16, pub port: u16,
pub path: PathBuf, pub path: PathBuf,
pub readonly: bool, pub path_is_file: bool,
pub auth: Option<String>, pub path_prefix: String,
pub log: bool, pub uri_prefix: String,
pub hidden: Vec<String>,
pub auth_method: AuthMethod,
pub auth: AccessControl,
pub allow_upload: bool,
pub allow_delete: bool,
pub allow_search: bool,
pub allow_symlink: bool,
pub allow_archive: bool,
pub render_index: bool,
pub render_spa: bool,
pub render_try_index: bool,
pub enable_cors: bool,
pub assets_path: Option<PathBuf>,
pub log_http: LogHttp,
#[cfg(feature = "tls")]
pub tls: Option<(Vec<Certificate>, PrivateKey)>,
#[cfg(not(feature = "tls"))]
pub tls: Option<()>,
} }
impl Args { impl Args {
/// Parse command-line arguments. /// Parse command-line arguments.
/// ///
/// If a parsing error ocurred, exit the process and print out informative /// If a parsing error occurred, exit the process and print out informative
/// error message to user. /// error message to user.
pub fn parse(matches: ArgMatches) -> BoxResult<Args> { pub fn parse(matches: ArgMatches) -> Result<Args> {
let address = matches.value_of("address").unwrap_or_default().to_owned(); let port = *matches.get_one::<u16>("port").unwrap();
let port = matches.value_of_t::<u16>("port")?; let addrs = matches
let path = matches.value_of_os("path").unwrap_or_default(); .get_many::<String>("bind")
let path = Args::parse_path(path)?; .map(|bind| bind.map(|v| v.as_str()).collect())
let readonly = matches.is_present("static"); .unwrap_or_else(|| vec!["0.0.0.0", "::"]);
let auth = matches.value_of("auth").map(|v| v.to_owned()); let addrs: Vec<BindAddr> = Args::parse_addrs(&addrs)?;
let log = !matches.is_present("no-log"); let path = Args::parse_path(matches.get_one::<PathBuf>("root").unwrap())?;
let path_is_file = path.metadata()?.is_file();
let path_prefix = matches
.get_one::<String>("path-prefix")
.map(|v| v.trim_matches('/').to_owned())
.unwrap_or_default();
let uri_prefix = if path_prefix.is_empty() {
"/".to_owned()
} else {
format!("/{}/", &encode_uri(&path_prefix))
};
let hidden: Vec<String> = matches
.get_one::<String>("hidden")
.map(|v| v.split(',').map(|x| x.to_string()).collect())
.unwrap_or_default();
let enable_cors = matches.get_flag("enable-cors");
let auth: Vec<&str> = matches
.get_many::<String>("auth")
.map(|auth| auth.map(|v| v.as_str()).collect())
.unwrap_or_default();
let auth_method = match matches.get_one::<String>("auth-method").unwrap().as_str() {
"basic" => AuthMethod::Basic,
_ => AuthMethod::Digest,
};
let auth = AccessControl::new(&auth, &uri_prefix)?;
let allow_upload = matches.get_flag("allow-all") || matches.get_flag("allow-upload");
let allow_delete = matches.get_flag("allow-all") || matches.get_flag("allow-delete");
let allow_search = matches.get_flag("allow-all") || matches.get_flag("allow-search");
let allow_symlink = matches.get_flag("allow-all") || matches.get_flag("allow-symlink");
let allow_archive = matches.get_flag("allow-all") || matches.get_flag("allow-archive");
let render_index = matches.get_flag("render-index");
let render_try_index = matches.get_flag("render-try-index");
let render_spa = matches.get_flag("render-spa");
#[cfg(feature = "tls")]
let tls = match (
matches.get_one::<PathBuf>("tls-cert"),
matches.get_one::<PathBuf>("tls-key"),
) {
(Some(certs_file), Some(key_file)) => {
let certs = load_certs(certs_file)?;
let key = load_private_key(key_file)?;
Some((certs, key))
}
_ => None,
};
#[cfg(not(feature = "tls"))]
let tls = None;
let log_http: LogHttp = matches
.get_one::<String>("log-format")
.map(|v| v.as_str())
.unwrap_or(DEFAULT_LOG_FORMAT)
.parse()?;
let assets_path = match matches.get_one::<PathBuf>("assets") {
Some(v) => Some(Args::parse_assets_path(v)?),
None => None,
};
Ok(Args { Ok(Args {
address, addrs,
port, port,
path, path,
readonly, path_is_file,
path_prefix,
uri_prefix,
hidden,
auth_method,
auth, auth,
log, enable_cors,
allow_delete,
allow_upload,
allow_search,
allow_symlink,
allow_archive,
render_index,
render_try_index,
render_spa,
tls,
log_http,
assets_path,
}) })
} }
/// Parse path. fn parse_addrs(addrs: &[&str]) -> Result<Vec<BindAddr>> {
fn parse_path<P: AsRef<Path>>(path: P) -> BoxResult<PathBuf> { let mut bind_addrs = vec![];
let mut invalid_addrs = vec![];
for addr in addrs {
match addr.parse::<IpAddr>() {
Ok(v) => {
bind_addrs.push(BindAddr::Address(v));
}
Err(_) => {
if cfg!(unix) {
bind_addrs.push(BindAddr::Path(PathBuf::from(addr)));
} else {
invalid_addrs.push(*addr);
}
}
}
}
if !invalid_addrs.is_empty() {
bail!("Invalid bind address `{}`", invalid_addrs.join(","));
}
Ok(bind_addrs)
}
fn parse_path<P: AsRef<Path>>(path: P) -> Result<PathBuf> {
let path = path.as_ref(); let path = path.as_ref();
if !path.exists() { if !path.exists() {
bail!("error: path \"{}\" doesn't exist", path.display()); bail!("Path `{}` doesn't exist", path.display());
} }
env::current_dir() env::current_dir()
.and_then(|mut p| { .and_then(|mut p| {
p.push(path); // If path is absolute, it replaces the current path. p.push(path); // If path is absolute, it replaces the current path.
canonicalize(p) std::fs::canonicalize(p)
})
.or_else(|err| {
bail!(
"error: failed to access path \"{}\": {}",
path.display(),
err,
)
}) })
.map_err(|err| anyhow!("Failed to access path `{}`: {}", path.display(), err,))
} }
/// Construct socket address from arguments. fn parse_assets_path<P: AsRef<Path>>(path: P) -> Result<PathBuf> {
pub fn address(&self) -> BoxResult<SocketAddr> { let path = Self::parse_path(path)?;
format!("{}:{}", self.address, self.port) if !path.join("index.html").exists() {
.parse() bail!("Path `{}` doesn't contains index.html", path.display());
.or_else(|err| { }
bail!( Ok(path)
"error: invalid address {}:{} : {}",
self.address,
self.port,
err,
)
})
} }
} }
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum BindAddr {
Address(IpAddr),
Path(PathBuf),
}

419
src/auth.rs Normal file
View File

@@ -0,0 +1,419 @@
use anyhow::{anyhow, bail, Result};
use base64::{engine::general_purpose, Engine as _};
use headers::HeaderValue;
use hyper::Method;
use lazy_static::lazy_static;
use md5::Context;
use std::collections::HashMap;
use uuid::Uuid;
use crate::utils::{encode_uri, unix_now};
const REALM: &str = "DUFS";
const DIGEST_AUTH_TIMEOUT: u32 = 86400;
lazy_static! {
static ref NONCESTARTHASH: Context = {
let mut h = Context::new();
h.consume(Uuid::new_v4().as_bytes());
h.consume(std::process::id().to_be_bytes());
h
};
}
#[derive(Debug)]
pub struct AccessControl {
rules: HashMap<String, PathControl>,
}
#[derive(Debug)]
pub struct PathControl {
readwrite: Account,
readonly: Option<Account>,
share: bool,
}
impl AccessControl {
pub fn new(raw_rules: &[&str], uri_prefix: &str) -> Result<Self> {
let mut rules = HashMap::default();
if raw_rules.is_empty() {
return Ok(Self { rules });
}
for rule in raw_rules {
let parts: Vec<&str> = rule.split('@').collect();
let create_err = || anyhow!("Invalid auth `{rule}`");
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 valid(&self) -> bool {
!self.rules.is_empty()
}
pub fn guard(
&self,
path: &str,
method: &Method,
authorization: Option<&HeaderValue>,
auth_method: AuthMethod,
) -> GuardType {
if self.rules.is_empty() {
return GuardType::ReadWrite;
}
if method == Method::OPTIONS {
return GuardType::ReadOnly;
}
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 {
let new_path = match (uri_prefix, path) {
("/", "/") => "/".into(),
(_, "/") => uri_prefix.trim_end_matches('/').into(),
_ => format!("{}{}", uri_prefix, path.trim_matches('/')),
};
encode_uri(&new_path)
}
fn walk_path(path: &str) -> impl Iterator<Item = &str> {
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;
}
let user = p[0];
let pass = p[1];
let mut h = Context::new();
h.consume(format!("{user}:{REALM}:{pass}").as_bytes());
Some(Account {
user: user.to_owned(),
pass: format!("{:x}", h.compute()),
})
}
}
#[derive(Debug, Clone)]
pub enum AuthMethod {
Basic,
Digest,
}
impl AuthMethod {
pub fn www_auth(&self, stale: bool) -> Result<String> {
match self {
AuthMethod::Basic => Ok(format!("Basic realm=\"{REALM}\"")),
AuthMethod::Digest => {
let str_stale = if stale { "stale=true," } else { "" };
Ok(format!(
"Digest realm=\"{}\",nonce=\"{}\",{}qop=\"auth\"",
REALM,
create_nonce()?,
str_stale
))
}
}
}
pub fn get_user(&self, authorization: &HeaderValue) -> Option<String> {
match self {
AuthMethod::Basic => {
let value: Vec<u8> = general_purpose::STANDARD_NO_PAD
.decode(strip_prefix(authorization.as_bytes(), b"Basic ")?)
.ok()?;
let parts: Vec<&str> = std::str::from_utf8(&value).ok()?.split(':').collect();
Some(parts[0].to_string())
}
AuthMethod::Digest => {
let digest_value = strip_prefix(authorization.as_bytes(), b"Digest ")?;
let digest_map = to_headermap(digest_value).ok()?;
digest_map
.get(b"username".as_ref())
.and_then(|b| std::str::from_utf8(b).ok())
.map(|v| v.to_string())
}
}
}
pub fn validate(
&self,
authorization: &HeaderValue,
method: &str,
auth_user: &str,
auth_pass: &str,
) -> Option<()> {
match self {
AuthMethod::Basic => {
let basic_value: Vec<u8> = general_purpose::STANDARD_NO_PAD
.decode(strip_prefix(authorization.as_bytes(), b"Basic ")?)
.ok()?;
let parts: Vec<&str> = std::str::from_utf8(&basic_value).ok()?.split(':').collect();
if parts[0] != auth_user {
return None;
}
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 digest_map = to_headermap(digest_value).ok()?;
if let (Some(username), Some(nonce), Some(user_response)) = (
digest_map
.get(b"username".as_ref())
.and_then(|b| std::str::from_utf8(b).ok()),
digest_map.get(b"nonce".as_ref()),
digest_map.get(b"response".as_ref()),
) {
match validate_nonce(nonce) {
Ok(true) => {}
_ => return None,
}
if auth_user != username {
return None;
}
let mut ha = Context::new();
ha.consume(method);
ha.consume(b":");
if let Some(uri) = digest_map.get(b"uri".as_ref()) {
ha.consume(uri);
}
let ha = format!("{:x}", ha.compute());
let mut correct_response = None;
if let Some(qop) = digest_map.get(b"qop".as_ref()) {
if qop == &b"auth".as_ref() || qop == &b"auth-int".as_ref() {
correct_response = Some({
let mut c = Context::new();
c.consume(auth_pass);
c.consume(b":");
c.consume(nonce);
c.consume(b":");
if let Some(nc) = digest_map.get(b"nc".as_ref()) {
c.consume(nc);
}
c.consume(b":");
if let Some(cnonce) = digest_map.get(b"cnonce".as_ref()) {
c.consume(cnonce);
}
c.consume(b":");
c.consume(qop);
c.consume(b":");
c.consume(&*ha);
format!("{:x}", c.compute())
});
}
}
let correct_response = match correct_response {
Some(r) => r,
None => {
let mut c = Context::new();
c.consume(auth_pass);
c.consume(b":");
c.consume(nonce);
c.consume(b":");
c.consume(&*ha);
format!("{:x}", c.compute())
}
};
if correct_response.as_bytes() == *user_response {
// grant access
return Some(());
}
}
None
}
}
}
}
/// Check if a nonce is still valid.
/// Return an error if it was never valid
fn validate_nonce(nonce: &[u8]) -> Result<bool> {
if nonce.len() != 34 {
bail!("invalid nonce");
}
//parse hex
if let Ok(n) = std::str::from_utf8(nonce) {
//get time
if let Ok(secs_nonce) = u32::from_str_radix(&n[..8], 16) {
//check time
let now = unix_now()?;
let secs_now = now.as_secs() as u32;
if let Some(dur) = secs_now.checked_sub(secs_nonce) {
//check hash
let mut h = NONCESTARTHASH.clone();
h.consume(secs_nonce.to_be_bytes());
let h = format!("{:x}", h.compute());
if h[..26] == n[8..34] {
return Ok(dur < DIGEST_AUTH_TIMEOUT);
}
}
}
}
bail!("invalid nonce");
}
fn strip_prefix<'a>(search: &'a [u8], prefix: &[u8]) -> Option<&'a [u8]> {
let l = prefix.len();
if search.len() < l {
return None;
}
if &search[..l] == prefix {
Some(&search[l..])
} else {
None
}
}
fn to_headermap(header: &[u8]) -> Result<HashMap<&[u8], &[u8]>, ()> {
let mut sep = Vec::new();
let mut assign = Vec::new();
let mut i: usize = 0;
let mut esc = false;
for c in header {
match (c, esc) {
(b'=', false) => assign.push(i),
(b',', false) => sep.push(i),
(b'"', false) => esc = true,
(b'"', true) => esc = false,
_ => {}
}
i += 1;
}
sep.push(i);
i = 0;
let mut ret = HashMap::new();
for (&k, &a) in sep.iter().zip(assign.iter()) {
while header[i] == b' ' {
i += 1;
}
if a <= i || k <= 1 + a {
//keys and values must contain one char
return Err(());
}
let key = &header[i..a];
let val = if header[1 + a] == b'"' && header[k - 1] == b'"' {
//escaped
&header[2 + a..k - 1]
} else {
//not escaped
&header[1 + a..k]
};
i = 1 + k;
ret.insert(key, val);
}
Ok(ret)
}
fn create_nonce() -> Result<String> {
let now = unix_now()?;
let secs = now.as_secs() as u32;
let mut h = NONCESTARTHASH.clone();
h.consume(secs.to_be_bytes());
let n = format!("{:08x}{:032x}", secs, h.compute());
Ok(n[..34].to_string())
}

View File

@@ -1,125 +0,0 @@
html {
font-family: -apple-system,BlinkMacSystemFont,Helvetica,Arial,sans-serif;
line-height: 1.5;
color: #24292e;
}
.head {
display: flex;
align-items: baseline;
padding: 1em 1em 0;
}
.head input {
display: none;
}
.breadcrumb {
font-size: 1.25em;
}
.breadcrumb > a {
color: #0366d6;
text-decoration: none;
}
.breadcrumb > a:hover {
text-decoration: underline;
}
/* final breadcrumb */
.breadcrumb > b {
color: #24292e;
}
.breadcrumb > .separator {
color: #586069;
padding: 0 0.25em;
}
.breadcrumb svg {
height: 100%;
fill: rgba(3,47,98,0.5);
padding-right: 0.5em;
padding-left: 0.5em;
}
.upload-control {
cursor: pointer;
padding-left: 0.25em;
}
.main {
padding: 0 1em;
}
.main th {
text-align: left;
font-weight: unset;
color: #5c5c5c;
white-space: nowrap;
}
.main td {
white-space: nowrap;
}
.main .cell-name {
width: 300px;
}
.main .cell-mtime {
width: 150px;
padding-left: 0.6em;
}
.main .cell-size {
text-align: right;
width: 100px;
padding-left: 0.6em;
}
.main .cell-actions {
width: 100px;
display: flex;
padding-left: 0.6em;
}
.path svg {
height: 100%;
fill: rgba(3,47,98,0.5);
padding-right: 0.5em;
}
.path {
display: flex;
list-style: none;
}
.path a {
color: #0366d6;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
display: block;
text-decoration: none;
}
.path a:hover {
text-decoration: underline;
}
.action-btn {
padding-left: 0.4em;
}
.uploaders {
display: flex;
flex-wrap: wrap;
padding: 0.5em 0;
}
.uploader {
padding-right: 1em;
}

View File

@@ -1,232 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Duf</title>
__STYLE__
</head>
<body>
<div class="head">
<div class="breadcrumb"></div>
<div>
<a href="?zip" title="Download folder as a .zip file">
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg>
</a>
</div>
</div>
<div class="main">
<div class="uploaders">
</div>
<table>
<thead>
<tr>
<th class="cell-name">Name</th>
<th class="cell-mtime">Date modify</th>
<th class="cell-size">Size</th>
<th class="cell-actions">Actions</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
<script>
const $head = document.querySelector(".head");
const $tbody = document.querySelector(".main tbody");
const $breadcrumb = document.querySelector(".breadcrumb");
const $uploaders = document.querySelector(".uploaders");
const $uploadControl = document.querySelector(".upload-control");
const { breadcrumb, paths, readonly } = __DATA__;
let uploaderIdx = 0;
class Uploader {
idx = 0;
file;
$elem;
constructor(idx, file) {
this.idx = idx;
this.file = file;
}
upload() {
const { file, idx } = this;
let url = location.href.split('?')[0];
if (!url.endsWith("/")) url += "/";
url += encodeURI(file.name);
$uploaders.insertAdjacentHTML("beforeend", `
<div class="uploader path">
<div><svg height="16" viewBox="0 0 12 16" width="12"><path fill-rule="evenodd" d="M6 5H2V4h4v1zM2 8h7V7H2v1zm0 2h7V9H2v1zm0 2h7v-1H2v1zm10-7.5V14c0 .55-.45 1-1 1H1c-.55 0-1-.45-1-1V2c0-.55.45-1 1-1h7.5L12 4.5zM11 5L8 2H1v12h10V5z"></path></svg></div>
<a href="${url}" id="file${idx}">${file.name} (0%)</a>
</div>`);
this.$elem = document.getElementById(`file${idx}`);
const ajax = new XMLHttpRequest();
ajax.upload.addEventListener("progress", e => this.progress(e), false);
ajax.addEventListener("load", e => this.complete(e), false);
ajax.addEventListener("error", e => this.fail(e), false);
ajax.addEventListener("abort", e => this.fail(e), false);
ajax.open("PUT", url);
ajax.send(file);
}
progress(event) {
const percent = (event.loaded / event.total) * 100;
this.$elem.innerHTML = `${this.file.name} (${percent.toFixed(2)}%)`;
}
complete(event) {
this.$elem.innerHTML = `${this.file.name}`;
}
fail(event) {
this.$elem.innerHTML = `<strike>${this.file.name}</strike>`;
}
}
function addBreadcrumb(value) {
const parts = value.split("/").filter(v => !!v);
const len = parts.length;
let path = "";
for (let i = 0; i < len; i++) {
const name = parts[i];
if (i > 0) {
path += "/" + name;
}
if (i === len - 1) {
$breadcrumb.insertAdjacentHTML("beforeend", `<b>${name}</b>`);
} else if (i === 0) {
$breadcrumb.insertAdjacentHTML("beforeend", `<a href="/"><b>${name}</b></a>`);
} else {
$breadcrumb.insertAdjacentHTML("beforeend", `<a href="${encodeURI(path)}">${name}</a>`);
}
$breadcrumb.insertAdjacentHTML("beforeend", `<span class="separator">/</span>`);
}
}
function addPath(file, index) {
const url = encodeURI(file.path);
let actionDelete = "";
let actionDownload = "";
if (file.path_type.endsWith("Dir")) {
actionDownload = `
<div class="action-btn">
<a href="${url}?zip" title="Download folder as a .zip file">
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg>
</a>
</div>`;
} else {
actionDownload = `
<div class="action-btn" >
<a href="${url}" title="Download file" download>
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg>
</a>
</div>`;
}
if (!readonly) {
actionDelete = `
<div onclick="deletePath(${index})" class="action-btn" id="deleteBtn${index}" title="Delete ${file.name}">
<svg width="16" height="16" fill="currentColor"viewBox="0 0 16 16"><path d="M6.854 7.146a.5.5 0 1 0-.708.708L7.293 9l-1.147 1.146a.5.5 0 0 0 .708.708L8 9.707l1.146 1.147a.5.5 0 0 0 .708-.708L8.707 9l1.147-1.146a.5.5 0 0 0-.708-.708L8 8.293 6.854 7.146z"/><path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5v2z"/></svg>
</div>`;
}
const actionCell = `
<td class="cell-actions">
${actionDownload}
${actionDelete}
</td>`
$tbody.insertAdjacentHTML("beforeend", `
<tr id="addPath${index}">
<td class="path cell-name">
<div>${getSvg(file.path_type)}</div>
<a href="${url}" title="${file.name}">${file.name}</a>
</td>
<td class="cell-mtime">${formatMtime(file.mtime)}</td>
<td class="cell-size">${formatSize(file.size)}</td>
${actionCell}
</tr>`)
}
async function deletePath(index) {
const file = paths[index];
if (!file) return;
const ajax = new XMLHttpRequest();
ajax.open("DELETE", encodeURI(file.path));
ajax.addEventListener("readystatechange", function() {
if(ajax.readyState === 4 && ajax.status === 200) {
document.getElementById(`addPath${index}`).remove();
}
});
ajax.send();
}
function addUploadControl() {
$head.insertAdjacentHTML("beforeend", `
<div class="upload-control" title="Upload file">
<label for="file">
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z"/></svg>
</label>
<input type="file" id="file" name="file" multiple>
</div>
`);
}
function getSvg(path_type) {
switch (path_type) {
case "Dir":
return `<svg height="16" viewBox="0 0 14 16" width="14"><path fill-rule="evenodd" d="M13 4H7V3c0-.66-.31-1-1-1H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1zM6 4H1V3h5v1z"></path></svg>`;
case "File":
return `<svg height="16" viewBox="0 0 12 16" width="12"><path fill-rule="evenodd" d="M6 5H2V4h4v1zM2 8h7V7H2v1zm0 2h7V9H2v1zm0 2h7v-1H2v1zm10-7.5V14c0 .55-.45 1-1 1H1c-.55 0-1-.45-1-1V2c0-.55.45-1 1-1h7.5L12 4.5zM11 5L8 2H1v12h10V5z"></path></svg>`;
case "SymlinkDir":
return `<svg height="16" viewBox="0 0 14 16" width="14"><path fill-rule="evenodd" d="M13 4H7V3c0-.66-.31-1-1-1H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1zM1 3h5v1H1V3zm6 9v-2c-.98-.02-1.84.22-2.55.7-.71.48-1.19 1.25-1.45 2.3.02-1.64.39-2.88 1.13-3.73C4.86 8.43 5.82 8 7.01 8V6l4 3-4 3H7z"></path></svg>`;
default:
return `<svg height="16" viewBox="0 0 12 16" width="12"><path fill-rule="evenodd" d="M8.5 1H1c-.55 0-1 .45-1 1v12c0 .55.45 1 1 1h10c.55 0 1-.45 1-1V4.5L8.5 1zM11 14H1V2h7l3 3v9zM6 4.5l4 3-4 3v-2c-.98-.02-1.84.22-2.55.7-.71.48-1.19 1.25-1.45 2.3.02-1.64.39-2.88 1.13-3.73.73-.84 1.69-1.27 2.88-1.27v-2H6z"></path></svg>`;
}
}
function formatMtime(mtime) {
if (!mtime) return ""
const date = new Date(mtime);
const year = date.getFullYear();
const month = padZero(date.getMonth() + 1, 2);
const day = padZero(date.getDate(), 2);
const hours = padZero(date.getHours(), 2);
const minutes = padZero(date.getMinutes(), 2);
return `${year}/${month}/${day} ${hours}:${minutes}`;
}
function padZero(value, size) {
return ("0".repeat(size) + value).slice(-1 * size)
}
function formatSize(size) {
if (!size) return ""
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
if (size == 0) return '0 Byte';
const i = parseInt(Math.floor(Math.log(size) / Math.log(1024)));
return Math.round(size / Math.pow(1024, i), 2) + ' ' + sizes[i];
}
document.addEventListener('DOMContentLoaded', () => {
addBreadcrumb(breadcrumb);
paths.forEach((file, index) => addPath(file, index));
if (!readonly) {
addUploadControl();
document.getElementById("file").addEventListener("change", e => {
const files = e.target.files;
for (const file of files) {
uploaderIdx += 1;
const uploader = new Uploader(uploaderIdx, file);
uploader.upload();
}
});
}
});
</script>
</body>
</html>

99
src/log_http.rs Normal file
View File

@@ -0,0 +1,99 @@
use std::{collections::HashMap, str::FromStr, sync::Arc};
use crate::{args::Args, server::Request};
pub const DEFAULT_LOG_FORMAT: &str = r#"$remote_addr "$request" $status"#;
#[derive(Debug)]
pub struct LogHttp {
elements: Vec<LogElement>,
}
#[derive(Debug)]
enum LogElement {
Variable(String),
Header(String),
Literal(String),
}
impl LogHttp {
pub fn data(&self, req: &Request, args: &Arc<Args>) -> HashMap<String, String> {
let mut data = HashMap::default();
for element in self.elements.iter() {
match element {
LogElement::Variable(name) => match name.as_str() {
"request" => {
data.insert(name.to_string(), format!("{} {}", req.method(), req.uri()));
}
"remote_user" => {
if let Some(user) = req
.headers()
.get("authorization")
.and_then(|v| args.auth_method.get_user(v))
{
data.insert(name.to_string(), user);
}
}
_ => {}
},
LogElement::Header(name) => {
if let Some(value) = req.headers().get(name).and_then(|v| v.to_str().ok()) {
data.insert(name.to_string(), value.to_string());
}
}
LogElement::Literal(_) => {}
}
}
data
}
pub fn log(&self, data: &HashMap<String, String>, err: Option<String>) {
if self.elements.is_empty() {
return;
}
let mut output = String::new();
for element in self.elements.iter() {
match element {
LogElement::Literal(value) => output.push_str(value.as_str()),
LogElement::Header(name) | LogElement::Variable(name) => {
output.push_str(data.get(name).map(|v| v.as_str()).unwrap_or("-"))
}
}
}
match err {
Some(err) => error!("{} {}", output, err),
None => info!("{}", output),
}
}
}
impl FromStr for LogHttp {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut elements = vec![];
let mut is_var = false;
let mut cache = String::new();
for c in format!("{s} ").chars() {
if c == '$' {
if !cache.is_empty() {
elements.push(LogElement::Literal(cache.to_string()));
}
cache.clear();
is_var = true;
} else if is_var && !(c.is_alphanumeric() || c == '_') {
if let Some(value) = cache.strip_prefix("$http_") {
elements.push(LogElement::Header(value.replace('_', "-").to_string()));
} else if let Some(value) = cache.strip_prefix('$') {
elements.push(LogElement::Variable(value.to_string()));
}
cache.clear();
is_var = false;
}
cache.push(c);
}
let cache = cache.trim();
if !cache.is_empty() {
elements.push(LogElement::Literal(cache.to_string()));
}
Ok(Self { elements })
}
}

30
src/logger.rs Normal file
View File

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

View File

@@ -1,46 +1,230 @@
macro_rules! bail { mod args;
($($tt:tt)*) => { mod auth;
return Err(From::from(format!($($tt)*))) mod log_http;
} mod logger;
} mod server;
mod streamer;
#[cfg(feature = "tls")]
mod tls;
#[cfg(unix)]
mod unix;
mod utils;
#[macro_use] #[macro_use]
extern crate log; extern crate log;
mod args; use crate::args::{build_cli, print_completions, Args};
mod server; use crate::server::{Request, Server};
#[cfg(feature = "tls")]
use crate::tls::{TlsAcceptor, TlsStream};
pub type BoxResult<T> = Result<T, Box<dyn std::error::Error>>; use anyhow::{anyhow, Result};
use std::net::{IpAddr, SocketAddr, TcpListener as StdTcpListener};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use log::LevelFilter; use args::BindAddr;
use clap_complete::Shell;
use futures::future::join_all;
use tokio::net::TcpListener;
use tokio::task::JoinHandle;
use crate::args::{matches, Args}; use hyper::server::conn::{AddrIncoming, AddrStream};
use crate::server::serve; use hyper::service::{make_service_fn, service_fn};
#[cfg(feature = "tls")]
use rustls::ServerConfig;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
run().await.unwrap_or_else(handle_err) run().await.unwrap_or_else(|err| {
eprintln!("error: {err}");
std::process::exit(1);
})
} }
async fn run() -> BoxResult<()> { async fn run() -> Result<()> {
let args = Args::parse(matches())?; logger::init().map_err(|e| anyhow!("Failed to init logger, {e}"))?;
let cmd = build_cli();
if std::env::var("RUST_LOG").is_ok() { let matches = cmd.get_matches();
simple_logger::init()?; if let Some(generator) = matches.get_one::<Shell>("completions") {
} else { let mut cmd = build_cli();
let level = if args.log { print_completions(*generator, &mut cmd);
LevelFilter::Info return Ok(());
} else { }
LevelFilter::Error let args = Args::parse(matches)?;
}; let args = Arc::new(args);
simple_logger::SimpleLogger::default() let running = Arc::new(AtomicBool::new(true));
.with_level(level) let handles = serve(args.clone(), running.clone())?;
.init()?; print_listening(args)?;
tokio::select! {
ret = join_all(handles) => {
for r in ret {
if let Err(e) = r {
error!("{}", e);
}
}
Ok(())
},
_ = shutdown_signal() => {
running.store(false, Ordering::SeqCst);
Ok(())
},
} }
serve(args).await
} }
fn handle_err<T>(err: Box<dyn std::error::Error>) -> T { fn serve(
eprintln!("Server error: {}", err); args: Arc<Args>,
std::process::exit(1); running: Arc<AtomicBool>,
) -> Result<Vec<JoinHandle<Result<(), hyper::Error>>>> {
let inner = Arc::new(Server::init(args.clone(), running)?);
let mut handles = vec![];
let port = args.port;
for bind_addr in args.addrs.iter() {
let inner = inner.clone();
let serve_func = move |remote_addr: Option<SocketAddr>| {
let inner = inner.clone();
async move {
Ok::<_, hyper::Error>(service_fn(move |req: Request| {
let inner = inner.clone();
inner.call(req, remote_addr)
}))
}
};
match bind_addr {
BindAddr::Address(ip) => {
let incoming = create_addr_incoming(SocketAddr::new(*ip, port))
.map_err(|e| anyhow!("Failed to bind `{ip}:{port}`, {e}"))?;
match args.tls.as_ref() {
#[cfg(feature = "tls")]
Some((certs, key)) => {
let config = ServerConfig::builder()
.with_safe_defaults()
.with_no_client_auth()
.with_single_cert(certs.clone(), key.clone())?;
let config = Arc::new(config);
let accepter = TlsAcceptor::new(config.clone(), incoming);
let new_service = make_service_fn(move |socket: &TlsStream| {
let remote_addr = socket.remote_addr();
serve_func(Some(remote_addr))
});
let server =
tokio::spawn(hyper::Server::builder(accepter).serve(new_service));
handles.push(server);
}
#[cfg(not(feature = "tls"))]
Some(_) => {
unreachable!()
}
None => {
let new_service = make_service_fn(move |socket: &AddrStream| {
let remote_addr = socket.remote_addr();
serve_func(Some(remote_addr))
});
let server =
tokio::spawn(hyper::Server::builder(incoming).serve(new_service));
handles.push(server);
}
};
}
BindAddr::Path(path) => {
if path.exists() {
std::fs::remove_file(path)?;
}
#[cfg(unix)]
{
let listener = tokio::net::UnixListener::bind(path)
.map_err(|e| anyhow!("Failed to bind `{}`, {e}", path.display()))?;
let acceptor = unix::UnixAcceptor::from_listener(listener);
let new_service = make_service_fn(move |_| serve_func(None));
let server = tokio::spawn(hyper::Server::builder(acceptor).serve(new_service));
handles.push(server);
}
}
}
}
Ok(handles)
}
fn create_addr_incoming(addr: SocketAddr) -> Result<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>) -> Result<()> {
let mut bind_addrs = vec![];
let (mut ipv4, mut ipv6) = (false, false);
for bind_addr in args.addrs.iter() {
match bind_addr {
BindAddr::Address(ip) => {
if ip.is_unspecified() {
if ip.is_ipv6() {
ipv6 = true;
} else {
ipv4 = true;
}
} else {
bind_addrs.push(bind_addr.clone());
}
}
_ => bind_addrs.push(bind_addr.clone()),
}
}
if ipv4 || ipv6 {
let ifaces = if_addrs::get_if_addrs()
.map_err(|e| anyhow!("Failed to get local interface addresses: {e}"))?;
for iface in ifaces.into_iter() {
let local_ip = iface.ip();
if ipv4 && local_ip.is_ipv4() {
bind_addrs.push(BindAddr::Address(local_ip))
}
if ipv6 && local_ip.is_ipv6() {
bind_addrs.push(BindAddr::Address(local_ip))
}
}
}
bind_addrs.sort_unstable();
let urls = bind_addrs
.into_iter()
.map(|bind_addr| match bind_addr {
BindAddr::Address(addr) => {
let addr = match addr {
IpAddr::V4(_) => format!("{}:{}", addr, args.port),
IpAddr::V6(_) => format!("[{}]:{}", addr, args.port),
};
let protocol = if args.tls.is_some() { "https" } else { "http" };
format!("{}://{}{}", protocol, addr, args.uri_prefix)
}
BindAddr::Path(path) => path.display().to_string(),
})
.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{info}\n");
}
Ok(())
}
async fn shutdown_signal() {
tokio::signal::ctrl_c()
.await
.expect("Failed to install CTRL+C signal handler")
} }

File diff suppressed because it is too large Load Diff

68
src/streamer.rs Normal file
View File

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

162
src/tls.rs Normal file
View File

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

31
src/unix.rs Normal file
View File

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

96
src/utils.rs Normal file
View File

@@ -0,0 +1,96 @@
use anyhow::{anyhow, Result};
use std::{
borrow::Cow,
path::Path,
time::{Duration, SystemTime, UNIX_EPOCH},
};
pub fn unix_now() -> Result<Duration> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|err| anyhow!("Invalid system time, {err}"))
}
pub fn encode_uri(v: &str) -> String {
let parts: Vec<_> = v.split('/').map(urlencoding::encode).collect();
parts.join("/")
}
pub fn decode_uri(v: &str) -> Option<Cow<str>> {
percent_encoding::percent_decode(v.as_bytes())
.decode_utf8()
.ok()
}
pub fn get_file_name(path: &Path) -> &str {
path.file_name()
.and_then(|v| v.to_str())
.unwrap_or_default()
}
pub fn try_get_file_name(path: &Path) -> Result<&str> {
path.file_name()
.and_then(|v| v.to_str())
.ok_or_else(|| anyhow!("Failed to get file name of `{}`", path.display()))
}
pub fn glob(source: &str, target: &str) -> bool {
let ss: Vec<char> = source.chars().collect();
let mut iter = target.chars();
let mut i = 0;
'outer: while i < ss.len() {
let s = ss[i];
match s {
'*' => match ss.get(i + 1) {
Some(s_next) => {
for t in iter.by_ref() {
if t == *s_next {
i += 2;
continue 'outer;
}
}
return false;
}
None => return true,
},
'?' => match iter.next() {
Some(_) => {
i += 1;
continue;
}
None => return false,
},
_ => match iter.next() {
Some(t) => {
if s == t {
i += 1;
continue;
}
return false;
}
None => return false,
},
}
}
iter.next().is_none()
}
#[test]
fn test_glob_key() {
assert!(glob("", ""));
assert!(glob(".*", ".git"));
assert!(glob("abc", "abc"));
assert!(glob("a*c", "abc"));
assert!(glob("a?c", "abc"));
assert!(glob("a*c", "abbc"));
assert!(glob("*c", "abc"));
assert!(glob("a*", "abc"));
assert!(glob("?c", "bc"));
assert!(glob("a?", "ab"));
assert!(!glob("abc", "adc"));
assert!(!glob("abc", "abcd"));
assert!(!glob("a?c", "abbc"));
assert!(!glob("*.log", "log"));
assert!(glob("*.log", ".log"));
assert!(glob("*.log", "a.log"));
}

92
tests/allow.rs Normal file
View File

@@ -0,0 +1,92 @@
mod fixtures;
mod utils;
use fixtures::{server, Error, TestServer};
use rstest::rstest;
#[rstest]
fn default_not_allow_upload(server: TestServer) -> Result<(), Error> {
let url = format!("{}file1", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 403);
Ok(())
}
#[rstest]
fn default_not_allow_delete(server: TestServer) -> Result<(), Error> {
let url = format!("{}test.html", server.url());
let resp = fetch!(b"DELETE", &url).send()?;
assert_eq!(resp.status(), 403);
Ok(())
}
#[rstest]
fn default_not_allow_archive(server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?zip", server.url()))?;
assert_eq!(resp.status(), 404);
Ok(())
}
#[rstest]
fn default_not_exist_dir(server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}404/", server.url()))?;
assert_eq!(resp.status(), 404);
Ok(())
}
#[rstest]
fn allow_upload_not_exist_dir(
#[with(&["--allow-upload"])] server: TestServer,
) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}404/", server.url()))?;
assert_eq!(resp.status(), 200);
Ok(())
}
#[rstest]
fn allow_upload_no_override(#[with(&["--allow-upload"])] server: TestServer) -> Result<(), Error> {
let url = format!("{}index.html", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 403);
Ok(())
}
#[rstest]
fn allow_delete_no_override(#[with(&["--allow-delete"])] server: TestServer) -> Result<(), Error> {
let url = format!("{}index.html", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 403);
Ok(())
}
#[rstest]
fn allow_upload_delete_can_override(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let url = format!("{}index.html", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 201);
Ok(())
}
#[rstest]
fn allow_search(#[with(&["--allow-search"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?q={}", server.url(), "test.html"))?;
assert_eq!(resp.status(), 200);
let paths = utils::retrieve_index_paths(&resp.text()?);
assert!(!paths.is_empty());
for p in paths {
assert!(p.contains("test.html"));
}
Ok(())
}
#[rstest]
fn allow_archive(#[with(&["--allow-archive"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?zip", server.url()))?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/zip"
);
assert!(resp.headers().contains_key("content-disposition"));
Ok(())
}

32
tests/args.rs Normal file
View File

@@ -0,0 +1,32 @@
//! Run file server with different args
mod fixtures;
mod utils;
use fixtures::{server, Error, TestServer};
use rstest::rstest;
#[rstest]
fn path_prefix_index(#[with(&["--path-prefix", "xyz"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}{}", server.url(), "xyz"))?;
assert_resp_paths!(resp);
Ok(())
}
#[rstest]
fn path_prefix_file(#[with(&["--path-prefix", "xyz"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}{}/index.html", server.url(), "xyz"))?;
assert_eq!(resp.status(), 200);
assert_eq!(resp.text()?, "This is index.html");
Ok(())
}
#[rstest]
fn path_prefix_propfind(
#[with(&["--path-prefix", "xyz"])] server: TestServer,
) -> Result<(), Error> {
let resp = fetch!(b"PROPFIND", format!("{}{}", server.url(), "xyz")).send()?;
let text = resp.text()?;
assert!(text.contains("<D:href>/xyz/</D:href>"));
Ok(())
}

122
tests/assets.rs Normal file
View File

@@ -0,0 +1,122 @@
mod fixtures;
mod utils;
use assert_cmd::prelude::*;
use assert_fs::fixture::TempDir;
use fixtures::{port, server, tmpdir, wait_for_port, Error, TestServer, DIR_ASSETS};
use rstest::rstest;
use std::process::{Command, Stdio};
#[rstest]
fn assets(server: TestServer) -> Result<(), Error> {
let ver = env!("CARGO_PKG_VERSION");
let resp = reqwest::blocking::get(server.url())?;
let index_js = format!("/__dufs_v{ver}_index.js");
let index_css = format!("/__dufs_v{ver}_index.css");
let favicon_ico = format!("/__dufs_v{ver}_favicon.ico");
let text = resp.text()?;
assert!(text.contains(&format!(r#"href="{index_css}""#)));
assert!(text.contains(&format!(r#"href="{favicon_ico}""#)));
assert!(text.contains(&format!(r#"src="{index_js}""#)));
Ok(())
}
#[rstest]
fn asset_js(server: TestServer) -> Result<(), Error> {
let url = format!(
"{}__dufs_v{}_index.js",
server.url(),
env!("CARGO_PKG_VERSION")
);
let resp = reqwest::blocking::get(url)?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/javascript"
);
Ok(())
}
#[rstest]
fn asset_css(server: TestServer) -> Result<(), Error> {
let url = format!(
"{}__dufs_v{}_index.css",
server.url(),
env!("CARGO_PKG_VERSION")
);
let resp = reqwest::blocking::get(url)?;
assert_eq!(resp.status(), 200);
assert_eq!(resp.headers().get("content-type").unwrap(), "text/css");
Ok(())
}
#[rstest]
fn asset_ico(server: TestServer) -> Result<(), Error> {
let url = format!(
"{}__dufs_v{}_favicon.ico",
server.url(),
env!("CARGO_PKG_VERSION")
);
let resp = reqwest::blocking::get(url)?;
assert_eq!(resp.status(), 200);
assert_eq!(resp.headers().get("content-type").unwrap(), "image/x-icon");
Ok(())
}
#[rstest]
fn assets_with_prefix(#[with(&["--path-prefix", "xyz"])] server: TestServer) -> Result<(), Error> {
let ver = env!("CARGO_PKG_VERSION");
let resp = reqwest::blocking::get(format!("{}xyz/", server.url()))?;
let index_js = format!("/xyz/__dufs_v{ver}_index.js");
let index_css = format!("/xyz/__dufs_v{ver}_index.css");
let favicon_ico = format!("/xyz/__dufs_v{ver}_favicon.ico");
let text = resp.text()?;
assert!(text.contains(&format!(r#"href="{index_css}""#)));
assert!(text.contains(&format!(r#"href="{favicon_ico}""#)));
assert!(text.contains(&format!(r#"src="{index_js}""#)));
Ok(())
}
#[rstest]
fn asset_js_with_prefix(
#[with(&["--path-prefix", "xyz"])] server: TestServer,
) -> Result<(), Error> {
let url = format!(
"{}xyz/__dufs_v{}_index.js",
server.url(),
env!("CARGO_PKG_VERSION")
);
let resp = reqwest::blocking::get(url)?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/javascript"
);
Ok(())
}
#[rstest]
fn assets_override(tmpdir: TempDir, port: u16) -> Result<(), Error> {
let mut child = Command::cargo_bin("dufs")?
.arg(tmpdir.path())
.arg("-p")
.arg(port.to_string())
.arg("--assets")
.arg(tmpdir.join(DIR_ASSETS))
.stdout(Stdio::piped())
.spawn()?;
wait_for_port(port);
let url = format!("http://localhost:{port}");
let resp = reqwest::blocking::get(&url)?;
assert!(resp.text()?.starts_with(&format!(
"/__dufs_v{}_index.js;DATA",
env!("CARGO_PKG_VERSION")
)));
let resp = reqwest::blocking::get(&url)?;
assert_resp_paths!(resp);
child.kill()?;
Ok(())
}

159
tests/auth.rs Normal file
View File

@@ -0,0 +1,159 @@
mod fixtures;
mod utils;
use diqwest::blocking::WithDigestAuth;
use fixtures::{server, Error, TestServer};
use rstest::rstest;
#[rstest]
fn no_auth(#[with(&["--auth", "/@user:pass", "-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?;
assert_eq!(resp.status(), 401);
assert!(resp.headers().contains_key("www-authenticate"));
let url = format!("{}file1", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 401);
Ok(())
}
#[rstest]
fn auth(#[with(&["--auth", "/@user:pass", "-A"])] server: TestServer) -> Result<(), Error> {
let url = format!("{}file1", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 401);
let resp = fetch!(b"PUT", &url)
.body(b"abc".to_vec())
.send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 201);
Ok(())
}
#[rstest]
fn auth_skip(#[with(&["--auth", "/@user:pass@*"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?;
assert_eq!(resp.status(), 200);
Ok(())
}
#[rstest]
fn auth_skip_on_options_method(
#[with(&["--auth", "/@user:pass"])] server: TestServer,
) -> Result<(), Error> {
let url = format!("{}index.html", server.url());
let resp = fetch!(b"OPTIONS", &url).send()?;
assert_eq!(resp.status(), 200);
Ok(())
}
#[rstest]
fn auth_check(
#[with(&["--auth", "/@user:pass@user2:pass2", "-A"])] server: TestServer,
) -> Result<(), Error> {
let url = format!("{}index.html", server.url());
let resp = fetch!(b"WRITEABLE", &url).send()?;
assert_eq!(resp.status(), 401);
let resp = fetch!(b"WRITEABLE", &url).send_with_digest_auth("user2", "pass2")?;
assert_eq!(resp.status(), 401);
let resp = fetch!(b"WRITEABLE", &url).send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 200);
Ok(())
}
#[rstest]
fn auth_readonly(
#[with(&["--auth", "/@user:pass@user2:pass2", "-A"])] server: TestServer,
) -> Result<(), Error> {
let url = format!("{}index.html", server.url());
let resp = fetch!(b"GET", &url).send()?;
assert_eq!(resp.status(), 401);
let resp = fetch!(b"GET", &url).send_with_digest_auth("user2", "pass2")?;
assert_eq!(resp.status(), 200);
let url = format!("{}file1", server.url());
let resp = fetch!(b"PUT", &url)
.body(b"abc".to_vec())
.send_with_digest_auth("user2", "pass2")?;
assert_eq!(resp.status(), 401);
Ok(())
}
#[rstest]
fn auth_nest(
#[with(&["--auth", "/@user:pass@user2:pass2", "--auth", "/dir1@user3:pass3", "-A"])]
server: TestServer,
) -> Result<(), Error> {
let url = format!("{}dir1/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", "/dir1@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(())
}
#[rstest]
fn auth_webdav_move(
#[with(&["--auth", "/@user:pass@*", "--auth", "/dir1@user3:pass3", "-A"])] server: TestServer,
) -> Result<(), Error> {
let origin_url = format!("{}dir1/test.html", server.url());
let new_url = format!("{}test2.html", server.url());
let resp = fetch!(b"MOVE", &origin_url)
.header("Destination", &new_url)
.send_with_digest_auth("user3", "pass3")?;
assert_eq!(resp.status(), 403);
Ok(())
}
#[rstest]
fn auth_webdav_copy(
#[with(&["--auth", "/@user:pass@*", "--auth", "/dir1@user3:pass3", "-A"])] server: TestServer,
) -> Result<(), Error> {
let origin_url = format!("{}dir1/test.html", server.url());
let new_url = format!("{}test2.html", server.url());
let resp = fetch!(b"COPY", &origin_url)
.header("Destination", &new_url)
.send_with_digest_auth("user3", "pass3")?;
assert_eq!(resp.status(), 403);
Ok(())
}
#[rstest]
fn auth_path_prefix(
#[with(&["--auth", "/@user:pass", "--path-prefix", "xyz", "-A"])] server: TestServer,
) -> Result<(), Error> {
let url = format!("{}xyz/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("user", "pass")?;
assert_eq!(resp.status(), 200);
Ok(())
}

86
tests/bind.rs Normal file
View File

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

32
tests/cli.rs Normal file
View File

@@ -0,0 +1,32 @@
//! Run cli with different args, not starting a server
mod fixtures;
use assert_cmd::prelude::*;
use clap::ValueEnum;
use clap_complete::Shell;
use fixtures::Error;
use std::process::Command;
#[test]
/// Show help and exit.
fn help_shows() -> Result<(), Error> {
Command::cargo_bin("dufs")?.arg("-h").assert().success();
Ok(())
}
#[test]
/// Print completions and exit.
fn print_completions() -> Result<(), Error> {
// let shell_enums = EnumValueParser::<Shell>::new();
for shell in Shell::value_variants() {
Command::cargo_bin("dufs")?
.arg("--completions")
.arg(shell.to_string())
.assert()
.success();
}
Ok(())
}

33
tests/cors.rs Normal file
View File

@@ -0,0 +1,33 @@
mod fixtures;
mod utils;
use fixtures::{server, Error, TestServer};
use rstest::rstest;
#[rstest]
fn cors(#[with(&["--enable-cors"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?;
assert_eq!(
resp.headers().get("access-control-allow-origin").unwrap(),
"*"
);
assert_eq!(
resp.headers()
.get("access-control-allow-credentials")
.unwrap(),
"true"
);
assert_eq!(
resp.headers().get("access-control-allow-methods").unwrap(),
"GET,HEAD,PUT,OPTIONS,DELETE,PROPFIND,COPY,MOVE"
);
assert_eq!(
resp.headers().get("access-control-allow-headers").unwrap(),
"Authorization,Destination,Range"
);
assert_eq!(
resp.headers().get("access-control-expose-headers").unwrap(),
"WWW-Authenticate,Content-Range,Accept-Ranges,Content-Disposition"
);
Ok(())
}

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

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

11
tests/data/cert_ecdsa.pem Normal file
View File

@@ -0,0 +1,11 @@
-----BEGIN CERTIFICATE-----
MIIBfTCCASOgAwIBAgIUfrAUHXIfeM54OLnTIUD9xT6FIwkwCgYIKoZIzj0EAwIw
FDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIyMDgwMjAxMjQ1NFoXDTMyMDczMDAx
MjQ1NFowFDESMBAGA1UEAwwJbG9jYWxob3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0D
AQcDQgAEW4tBe0jF2wYSLCvdreb0izR/8sgKNKkbe4xPyA9uNEbtTk58eoO3944R
JPT6S5wRTHFpF0BJhQRfiuW4K2EUcaNTMFEwHQYDVR0OBBYEFEebUDkiMJoV2d5W
8o+6p4DauHFFMB8GA1UdIwQYMBaAFEebUDkiMJoV2d5W8o+6p4DauHFFMA8GA1Ud
EwEB/wQFMAMBAf8wCgYIKoZIzj0EAwIDSAAwRQIhAPJvmzqaq/S5yYxeB4se8k2z
6pnVNxrTT2CqdPD8Z+7rAiBZAyU+5+KbQq3aZsmuNUx+YOqTDMkaUR/nd/tjnnOX
gA==
-----END CERTIFICATE-----

View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
openssl req -subj '/CN=localhost' -x509 -newkey rsa:4096 -keyout key_pkcs8.pem -out cert.pem -nodes -days 3650
openssl rsa -in key_pkcs8.pem -out key_pkcs1.pem
openssl ecparam -name prime256v1 -genkey -noout -out key_ecdsa.pem
openssl req -subj '/CN=localhost' -x509 -key key_ecdsa.pem -out cert_ecdsa.pem -nodes -days 3650

5
tests/data/key_ecdsa.pem Normal file
View File

@@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEILOQ44lHqD4w12HJKlZJ+Y3u91eUKjabu3UKPSahhC89oAoGCCqGSM49
AwEHoUQDQgAEW4tBe0jF2wYSLCvdreb0izR/8sgKNKkbe4xPyA9uNEbtTk58eoO3
944RJPT6S5wRTHFpF0BJhQRfiuW4K2EUcQ==
-----END EC PRIVATE KEY-----

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

@@ -0,0 +1,51 @@
-----BEGIN RSA PRIVATE KEY-----
MIIJKAIBAAKCAgEAo2wdMbFPkX7CAF/Y+hVj5bwm4dlxhwW2Z9Ic2RZFC5w2oK2X
wyasDBEqDlgv/bN4xObAVlDZ/4/SuTVSDrNB8dtQl7GTWptpbFKJUdNocU88wqd4
k/cLZg2aiQqnZKD88w/AxXnYw+F8yU0pFGj9GX0S5at3/V1hrBVxVO8Y99bbgnJA
8NMm0Pw2xYZS++ULuzoECk0xbNdtbtPrIuweI5mMvsJvtiw67EIdl3N9Lj5pL4a7
X1C0Xk5H4mOcwM0qq3m31HsCW91PMCjU6suo764rx5Jqv0n9HCNxdiSEadCwf+Gr
mKtFOw3DcGPETg5AJR8H3rG1agKKjI+vRtL/tZ7coFOhZKXdjGvvUFcWcqO+GppH
h16pzJDXi2qeD9Cu5b2ayM2uBnfV7Q3FjOeDqD+BCJ0ClaqNmAD9TF2htzduInl+
G3OJb4cqaYjaF5YmiZISfrimK5eR2I3et5cqnbuDHMKvDfUd9Jgj/2IqPOHJEguu
XSO7WNKfQmlTv7EN/xrD6jiB/M8ADaSxjCqTbtKNyCbJlu2Wy9WlDXwPkNW8g70T
4Br4U4Iy3N/0w2lAAhiizdC2jkehSKmWE2nmixGSXxkSOMgXQXDJ9RBtDQfd8ym/
ADfyVndUSnHvf9jCH1NPHlFbB7RVSvUHX22Qq63NUvhV32ct+/IyD/qPpl0CAwEA
AQKCAgAPM29DwAp2riO9hSzZlkPEisvTFjbJKG7fGVw1lSy298DdEUicjmxScwZG
b02He7owFoatgLfGXcpsD9miJGpt5MiKU6oxM2OK/+JmChQc9hHgyVMd8EzPIVTO
in8njRH6SezUcZEIJ2FEGDlJ/LoONOQdGOYAWz9KknQIQnVAGGwypg4EWJ+zsMIn
fWcapyOANtVJYATI6wDy3iNxDCWBijbdR5i8iUCx2TSHceai9osyMIYdR5R/cSie
lkVuaacebCP9T7PYd611/VZQwMDmCn1oAuaLBIbWpzVWl+75KMBCJOuhN80owQ78
1UrdN9YfndNNk5ocUkAw8uyK2fWO+TcdFddHrx0tnEIsnkzy+Jtp/j5Eq/JGVlSY
03dck4FIjDSM/M+6HP5R2jfGCsitono03XGjzNsJou0UnordY+VL4qolItoovWkf
N5hudmbste4gS3/dSvtoByto5SAqUGUS0VNjhsU5w+IyMFK+kImlJthb3+GNF/7h
NPn4MwuxIFXEy1cVPu+wwoFoL5+7stp68mlYnrxmEIFOJNcjF1urfqCMAXWXxad+
71TtBiRit5tAZVHjTz9NBkyvCcXOEq3RMEjAzCtTGlduUwNQpmmdCyHk2SnrWieV
LqyTt55r1FhzEZ0AqHiWmHCNRnqz/PJFBIKfX9YKnkK2xVAgAQKCAQEA0jcvZ0cf
GGIo8WG/r5mitpnVeQy9XZ+Ic7Js9T73ZLcG+qo/2XDhEXcR4OKZoSMIJIotMIJ1
TZKdNN9QgFp7IuUWnYpnp2h+Hyfv8h7DHZwohHw4Ys9AJY9j4WVGP/NKVcPrTY/F
kJ3VHKiVd10FXoNn0qEw5y3oa4zRtRYFrp7gvOoRMwoWADLN/hwuQ2QRrBPt0zth
qfbeTtQE4g950tkqMy6V6uahkZEvQmSd1UpD35aGKMwxOpK9ew9CAKduftDVOu9x
3vKAOh0uXs9DxMUfJFKf8ISI2JB3vFmrAJ2l6qSGEdoVdiXkwHdRsaEBJbDrR3uq
R5ovM0qVk2s23QKCAQEAxwPqqv5SuPPMksBCBSds692cEsXA1xbvw1IsOugqG22f
CPDSIr0w9c5xU3QSv2BFmaCLJQEVAPoI/jqPMqIdOWC9lSXEuKw297i0r/GAMcNc
e1N+Xz1ahyVE3Ak65Jwi/vgr0D38thtQJlF//BB0hPFvvt4GQ2E4O5ELwTXIPr46
wQFGf0IfqvufpHoKiszJ5F5liyTtB50J4Is2CKUMUuXq6XlWMrCNLyaGW42cttci
gbNAPagnQANHFUIO9M06dAU9WVnUJG9eNDd/tDw0XDLjRqTRXlNoqWRwWMl38ZXi
HI9oHpOqHjeAXevdu5nkqsmtSQ50LiHOlK9/cO51gQKCAQBHlj9wXkn6lcL3oKAU
fq9om66U0H/UWDWxoLt2MQEyrRmVV1DzDXu35OKTwNcshq+JMfz9ng+wYRNkJABY
FXgFhBpVgAKYgf8hQQp3W356oOkzZNIW5BkmMVSEN2ba9FEGL/f7q9BN1VHztn1f
7q+bZgh/NCFhOMMDjSsFDgDVXImQC+3bgb3IR4Ta2mHu1S8neInu+zPhG47NLWqU
SUzlPsseLuki23N+DQEZDQaq0eWXSL1bO14wYjRgqeuCKYJ5cUiMD2qpz89W+wUF
iHO9mJtoVTLeR2QKy/fajnareQQ9idWWUrwoRfNGj9ukL/4iBcO5ziVIyPr17ppN
X5+JAoIBAClkoCeGlDARzUfsow6tX5NDWZXx+aUDCUVnzvlFlpRz3XMfm6VMEmXd
1WZVKx0Q6gkFAkvlCLhWSQ6PoX8XhtqLS4M9AsiiUSB/E13Q7ifriU3BVPR8L1sS
nlrhtJUeAI1lkr9SVUCPN8FwjB0iUwnfqa1aQpU7IFYLWhWKmSarrE6+dCo915ZZ
lZ/BHnY2F/vewmIJgR9nQ0mnyspLgd+wIIcFDK+oVwUqjyF1t9Wzs2KkpMTuN5Ox
2tQKFFBIa1L8UAFIlL4rR722mWIkb4OJtgnYeA+Va5xn3pIo/UCLOydTkIVjkyuL
wbBHQawmWxBGuDsMvY9myq/UPL6BaoECggEBAJeY5OgVbJHB6YageBtUBPe0tLIb
nrYPYXIPsLycZ+PXo73ASbpbHh6av7CdP288Ouu+zE0P6iAdrIrU41kc+2Tx7K8b
Qb0pDrX0pQZQAIzoBWKouwra8kSeS1dkiLOLiOhnYDn+OYE4tN5ePe7AlBk7b1/x
ybNuCyTYdaH1uPaI56RaPB8aHJXnxtPHUvYm0oMfm3EPjgF/FjGdpE7rPcdYWqKU
Ek5UPmcGVVs+yHRSsEDna5zXBqQoDaLn+7KfgcO8UxhhL2cdcQ2vsC1C7QIPu043
lAIXge5d+1hNwrZjHw/9SkV3UItnEGnxyaZ2NMmRKjdT3g2ilTgkAB2w/Kk=
-----END RSA PRIVATE KEY-----

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

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

176
tests/fixtures.rs Normal file
View File

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

72
tests/hidden.rs Normal file
View File

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

261
tests/http.rs Normal file
View File

@@ -0,0 +1,261 @@
mod fixtures;
mod utils;
use fixtures::{server, Error, TestServer, BIN_FILE};
use rstest::rstest;
use serde_json::Value;
use utils::retrive_edit_file;
#[rstest]
fn get_dir(server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?;
assert_resp_paths!(resp);
Ok(())
}
#[rstest]
fn head_dir(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"HEAD", server.url()).send()?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"text/html; charset=utf-8"
);
assert_eq!(resp.text()?, "");
Ok(())
}
#[rstest]
fn get_dir_404(server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}404/", server.url()))?;
assert_eq!(resp.status(), 404);
Ok(())
}
#[rstest]
fn head_dir_404(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"HEAD", format!("{}404/", server.url())).send()?;
assert_eq!(resp.status(), 404);
Ok(())
}
#[rstest]
fn get_dir_zip(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?zip", server.url()))?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/zip"
);
assert!(resp.headers().contains_key("content-disposition"));
Ok(())
}
#[rstest]
fn get_dir_json(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?json", server.url()))?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/json"
);
let json: Value = serde_json::from_str(&resp.text().unwrap()).unwrap();
assert!(json["paths"].as_array().is_some());
Ok(())
}
#[rstest]
fn get_dir_simple(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?simple", server.url()))?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"text/html; charset=utf-8"
);
let text = resp.text().unwrap();
assert!(text.split('\n').any(|v| v == "index.html"));
Ok(())
}
#[rstest]
fn head_dir_zip(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"HEAD", format!("{}?zip", server.url())).send()?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/zip"
);
assert!(resp.headers().contains_key("content-disposition"));
assert_eq!(resp.text()?, "");
Ok(())
}
#[rstest]
fn get_dir_search(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?q={}", server.url(), "test.html"))?;
assert_eq!(resp.status(), 200);
let paths = utils::retrieve_index_paths(&resp.text()?);
assert!(!paths.is_empty());
for p in paths {
assert!(p.contains("test.html"));
}
Ok(())
}
#[rstest]
fn get_dir_search2(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?q={BIN_FILE}", server.url()))?;
assert_eq!(resp.status(), 200);
let paths = utils::retrieve_index_paths(&resp.text()?);
assert!(!paths.is_empty());
for p in paths {
assert!(p.contains(BIN_FILE));
}
Ok(())
}
#[rstest]
fn get_dir_search3(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?q={}&simple", server.url(), "test.html"))?;
assert_eq!(resp.status(), 200);
let text = resp.text().unwrap();
assert!(text.split('\n').any(|v| v == "test.html"));
Ok(())
}
#[rstest]
fn head_dir_search(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"HEAD", format!("{}?q={}", server.url(), "test.html")).send()?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"text/html; charset=utf-8"
);
assert_eq!(resp.text()?, "");
Ok(())
}
#[rstest]
fn empty_search(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?q=", server.url()))?;
assert_eq!(resp.status(), 200);
let paths = utils::retrieve_index_paths(&resp.text()?);
assert!(paths.is_empty());
Ok(())
}
#[rstest]
fn get_file(server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}index.html", server.url()))?;
assert_eq!(resp.status(), 200);
assert_eq!(resp.headers().get("content-type").unwrap(), "text/html");
assert_eq!(resp.headers().get("accept-ranges").unwrap(), "bytes");
assert!(resp.headers().contains_key("etag"));
assert!(resp.headers().contains_key("last-modified"));
assert!(resp.headers().contains_key("content-length"));
assert_eq!(resp.text()?, "This is index.html");
Ok(())
}
#[rstest]
fn head_file(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"HEAD", format!("{}index.html", server.url())).send()?;
assert_eq!(resp.status(), 200);
assert_eq!(resp.headers().get("content-type").unwrap(), "text/html");
assert_eq!(resp.headers().get("accept-ranges").unwrap(), "bytes");
assert!(resp.headers().contains_key("content-disposition"));
assert!(resp.headers().contains_key("etag"));
assert!(resp.headers().contains_key("last-modified"));
assert!(resp.headers().contains_key("content-length"));
assert_eq!(resp.text()?, "");
Ok(())
}
#[rstest]
fn get_file_404(server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}404", server.url()))?;
assert_eq!(resp.status(), 404);
Ok(())
}
#[rstest]
fn get_file_edit(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"GET", format!("{}index.html?edit", server.url())).send()?;
assert_eq!(resp.status(), 200);
let editable = retrive_edit_file(&resp.text().unwrap()).unwrap();
assert!(editable);
Ok(())
}
#[rstest]
fn get_file_edit_bin(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"GET", format!("{}{BIN_FILE}?edit", server.url())).send()?;
assert_eq!(resp.status(), 200);
let editable = retrive_edit_file(&resp.text().unwrap()).unwrap();
assert!(!editable);
Ok(())
}
#[rstest]
fn head_file_404(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"HEAD", format!("{}404", server.url())).send()?;
assert_eq!(resp.status(), 404);
Ok(())
}
#[rstest]
fn options_dir(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"OPTIONS", format!("{}index.html", server.url())).send()?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("allow").unwrap(),
"GET,HEAD,PUT,OPTIONS,DELETE,PROPFIND,COPY,MOVE"
);
assert_eq!(resp.headers().get("dav").unwrap(), "1,2");
Ok(())
}
#[rstest]
fn put_file(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let url = format!("{}file1", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 201);
let resp = reqwest::blocking::get(url)?;
assert_eq!(resp.status(), 200);
Ok(())
}
#[rstest]
fn put_file_create_dir(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let url = format!("{}xyz/file1", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 201);
let resp = reqwest::blocking::get(url)?;
assert_eq!(resp.status(), 200);
Ok(())
}
#[rstest]
fn put_file_conflict_dir(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let url = format!("{}dir1", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 403);
Ok(())
}
#[rstest]
fn delete_file(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let url = format!("{}test.html", server.url());
let resp = fetch!(b"DELETE", &url).send()?;
assert_eq!(resp.status(), 204);
let resp = reqwest::blocking::get(url)?;
assert_eq!(resp.status(), 404);
Ok(())
}
#[rstest]
fn delete_file_404(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"DELETE", format!("{}file1", server.url())).send()?;
assert_eq!(resp.status(), 404);
Ok(())
}

78
tests/log_http.rs Normal file
View File

@@ -0,0 +1,78 @@
mod fixtures;
mod utils;
use diqwest::blocking::WithDigestAuth;
use fixtures::{port, tmpdir, wait_for_port, Error};
use assert_cmd::prelude::*;
use assert_fs::fixture::TempDir;
use rstest::rstest;
use std::io::Read;
use std::process::{Command, Stdio};
#[rstest]
#[case(&["-a", "/@user:pass", "--log-format", "$remote_user"], false)]
#[case(&["-a", "/@user:pass", "--log-format", "$remote_user", "--auth-method", "basic"], true)]
fn log_remote_user(
tmpdir: TempDir,
port: u16,
#[case] args: &[&str],
#[case] is_basic: bool,
) -> Result<(), Error> {
let mut child = Command::cargo_bin("dufs")?
.arg(tmpdir.path())
.arg("-p")
.arg(port.to_string())
.args(args)
.stdout(Stdio::piped())
.spawn()?;
wait_for_port(port);
let stdout = child.stdout.as_mut().expect("Failed to get stdout");
let req = fetch!(b"GET", &format!("http://localhost:{port}"));
let resp = if is_basic {
req.basic_auth("user", Some("pass")).send()?
} else {
req.send_with_digest_auth("user", "pass")?
};
assert_eq!(resp.status(), 200);
let mut buf = [0; 1000];
let buf_len = stdout.read(&mut buf)?;
let output = std::str::from_utf8(&buf[0..buf_len])?;
assert!(output.lines().last().unwrap().ends_with("user"));
child.kill()?;
Ok(())
}
#[rstest]
#[case(&["--log-format", ""])]
fn no_log(tmpdir: TempDir, port: u16, #[case] args: &[&str]) -> Result<(), Error> {
let mut child = Command::cargo_bin("dufs")?
.arg(tmpdir.path())
.arg("-p")
.arg(port.to_string())
.args(args)
.stdout(Stdio::piped())
.spawn()?;
wait_for_port(port);
let stdout = child.stdout.as_mut().expect("Failed to get stdout");
let resp = fetch!(b"GET", &format!("http://localhost:{port}")).send()?;
assert_eq!(resp.status(), 200);
let mut buf = [0; 1000];
let buf_len = stdout.read(&mut buf)?;
let output = std::str::from_utf8(&buf[0..buf_len])?;
assert_eq!(output.lines().last().unwrap(), "");
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(())
}

80
tests/render.rs Normal file
View File

@@ -0,0 +1,80 @@
mod fixtures;
mod utils;
use fixtures::{server, Error, TestServer, BIN_FILE, DIR_NO_FOUND, DIR_NO_INDEX, FILES};
use rstest::rstest;
#[rstest]
fn render_index(#[with(&["--render-index"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?;
let text = resp.text()?;
assert_eq!(text, "This is index.html");
Ok(())
}
#[rstest]
fn render_index2(#[with(&["--render-index"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}{}", server.url(), DIR_NO_INDEX))?;
assert_eq!(resp.status(), 404);
Ok(())
}
#[rstest]
fn render_try_index(#[with(&["--render-try-index"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?;
let text = resp.text()?;
assert_eq!(text, "This is index.html");
Ok(())
}
#[rstest]
fn render_try_index2(#[with(&["--render-try-index"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}{}", server.url(), DIR_NO_INDEX))?;
let files: Vec<&str> = FILES
.iter()
.filter(|v| **v != "index.html")
.cloned()
.collect();
assert_resp_paths!(resp, files);
Ok(())
}
#[rstest]
fn render_try_index3(
#[with(&["--render-try-index", "--allow-archive"])] 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]
#[case(server(&["--render-try-index"] as &[&str]), false)]
#[case(server(&["--render-try-index", "--allow-search"] as &[&str]), true)]
fn render_try_index4(#[case] server: TestServer, #[case] searched: bool) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}{}?q={}", server.url(), DIR_NO_INDEX, BIN_FILE))?;
assert_eq!(resp.status(), 200);
let paths = utils::retrieve_index_paths(&resp.text()?);
assert_eq!(paths.iter().all(|v| v.contains(BIN_FILE)), searched);
Ok(())
}
#[rstest]
fn render_spa(#[with(&["--render-spa"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?;
let text = resp.text()?;
assert_eq!(text, "This is index.html");
Ok(())
}
#[rstest]
fn render_spa2(#[with(&["--render-spa"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}{}", server.url(), DIR_NO_FOUND))?;
let text = resp.text()?;
assert_eq!(text, "This is index.html");
Ok(())
}

60
tests/single_file.rs Normal file
View File

@@ -0,0 +1,60 @@
//! Run file server with different args
mod fixtures;
mod utils;
use assert_cmd::prelude::*;
use assert_fs::fixture::TempDir;
use fixtures::{port, tmpdir, wait_for_port, Error};
use rstest::rstest;
use std::process::{Command, Stdio};
#[rstest]
#[case("index.html")]
fn single_file(tmpdir: TempDir, port: u16, #[case] file: &str) -> Result<(), Error> {
let mut child = Command::cargo_bin("dufs")?
.arg(tmpdir.path().join(file))
.arg("-p")
.arg(port.to_string())
.stdout(Stdio::piped())
.spawn()?;
wait_for_port(port);
let resp = reqwest::blocking::get(format!("http://localhost:{port}"))?;
assert_eq!(resp.text()?, "This is index.html");
let resp = reqwest::blocking::get(format!("http://localhost:{port}/"))?;
assert_eq!(resp.text()?, "This is index.html");
let resp = reqwest::blocking::get(format!("http://localhost:{port}/index.html"))?;
assert_eq!(resp.text()?, "This is index.html");
child.kill()?;
Ok(())
}
#[rstest]
#[case("index.html")]
fn path_prefix_single_file(tmpdir: TempDir, port: u16, #[case] file: &str) -> Result<(), Error> {
let mut child = Command::cargo_bin("dufs")?
.arg(tmpdir.path().join(file))
.arg("-p")
.arg(port.to_string())
.arg("--path-prefix")
.arg("xyz")
.stdout(Stdio::piped())
.spawn()?;
wait_for_port(port);
let resp = reqwest::blocking::get(format!("http://localhost:{port}/xyz"))?;
assert_eq!(resp.text()?, "This is index.html");
let resp = reqwest::blocking::get(format!("http://localhost:{port}/xyz/"))?;
assert_eq!(resp.text()?, "This is index.html");
let resp = reqwest::blocking::get(format!("http://localhost:{port}/xyz/index.html"))?;
assert_eq!(resp.text()?, "This is index.html");
let resp = reqwest::blocking::get(format!("http://localhost:{port}"))?;
assert_eq!(resp.status(), 404);
child.kill()?;
Ok(())
}

29
tests/sort.rs Normal file
View File

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

46
tests/symlink.rs Normal file
View File

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

55
tests/tls.rs Normal file
View File

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

68
tests/utils.rs Normal file
View File

@@ -0,0 +1,68 @@
use indexmap::IndexSet;
use serde_json::Value;
#[macro_export]
macro_rules! assert_resp_paths {
($resp:ident) => {
assert_resp_paths!($resp, self::fixtures::FILES)
};
($resp:ident, $files:expr) => {
assert_eq!($resp.status(), 200);
let body = $resp.text()?;
let paths = self::utils::retrieve_index_paths(&body);
assert!(!paths.is_empty());
for file in $files {
assert!(paths.contains(&file.to_string()));
}
};
}
#[macro_export]
macro_rules! fetch {
($method:literal, $url:expr) => {
reqwest::blocking::Client::new().request(hyper::Method::from_bytes($method)?, $url)
};
}
#[allow(dead_code)]
pub fn retrieve_index_paths(content: &str) -> IndexSet<String> {
let value = retrive_json(content).unwrap();
let paths = value
.get("paths")
.unwrap()
.as_array()
.unwrap()
.iter()
.flat_map(|v| {
let name = v.get("name")?.as_str()?;
let path_type = v.get("path_type")?.as_str()?;
if path_type.ends_with("Dir") {
Some(format!("{name}/"))
} else {
Some(name.to_owned())
}
})
.collect();
paths
}
#[allow(dead_code)]
pub fn retrive_edit_file(content: &str) -> Option<bool> {
let value = retrive_json(content)?;
let value = value.get("editable").unwrap();
Some(value.as_bool().unwrap())
}
#[allow(dead_code)]
pub fn encode_uri(v: &str) -> String {
let parts: Vec<_> = v.split('/').map(urlencoding::encode).collect();
parts.join("/")
}
fn retrive_json(content: &str) -> Option<Value> {
let lines: Vec<&str> = content.lines().collect();
let line = lines.iter().find(|v| v.contains("DATA ="))?;
let line_col = line.find("DATA =").unwrap() + 6;
let value: Value = line[line_col..].parse().unwrap();
Some(value)
}

217
tests/webdav.rs Normal file
View File

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