Compare commits

...

46 Commits

Author SHA1 Message Date
sigoden
1112b936b8 chore: release v0.34.0 2023-06-02 07:16:43 +08:00
sigoden
033d37c4d4 chore: update cli --auth help text 2023-06-02 06:49:06 +08:00
sigoden
577eea5fa4 chore: ui js refactor 2023-06-01 22:22:36 +08:00
sigoden
d22be95dda chore: update deps 2023-06-01 22:06:01 +08:00
sigoden
8d7c1fbf53 fix: ui set default max uploading to 1 (#220) 2023-06-01 21:32:22 +08:00
sigoden
4622c48120 fix: ui path table show move action (#219) 2023-06-01 20:33:21 +08:00
sigoden
f8ea41638f feat: new auth (#218)
The access level path control used by dufs has two disadvantages:

1. One path cannot support multiple users
2. It is very troublesome to set multiple paths for one user

So it needs to be refactored.
The new auth is account based, it closes #207, closes #208.

BREAKING CHANGE: new auth
2023-06-01 18:52:05 +08:00
nq5
2890b3929d chore: correct spelling and grammar in index.js (#216) 2023-06-01 07:35:41 +08:00
sigoden
f5c0aefd8e refactor: cli positional rename root => SERVE_PATH(#215) 2023-05-30 16:49:16 +08:00
Jesse Hu
8a1e7674df feat: show precise file size with decimal (#210) 2023-05-18 12:01:02 +08:00
sigoden
3c6206849f chore: trivial improvements 2023-04-01 16:10:34 +08:00
sigoden
652f836c23 feat: add timestamp metadata to generated zip file (#204) 2023-03-31 23:48:23 +08:00
sigoden
fb5b50f059 fix: URL-encoded filename when downloading in safari (#203)
* fix: URL-encoded filename when downloading in safari

* add test
2023-03-31 22:52:07 +08:00
sigoden
e43554b795 feat: webui editing support multiple encodings (#197) 2023-03-17 11:22:21 +08:00
sigoden
10ec34872d chore(release): version 0.33.0 2023-03-17 09:06:01 +08:00
sigoden
3ff16d254b chore: update deps 2023-03-17 08:54:38 +08:00
sigoden
29a04c8d74 refactor: improve error handle (#195) 2023-03-12 15:20:40 +08:00
sigoden
c92e45f2da fix: basic auth sometimes does not work (#194) 2023-03-12 12:58:36 +08:00
sigoden
8d7a9053e2 chore: update deps 2023-03-06 10:09:24 +08:00
sigoden
0e12b285cd fix: hidden don't works on some files (#188)
like --hidden '*.abc-cba' matches xyz.abc-cba but do not matches 123.xyz.abc-cba
2023-03-03 07:15:46 +08:00
sigoden
45f4f5fc58 feat: guess plain text encoding then set content-type charset (#186) 2023-03-01 09:36:59 +08:00
horizon
6dcb4dcd76 fix: cors allow-request-header add content-type (#184)
* fix: cors allow-request-header add content-type

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

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

* add tests

---------

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

View File

@@ -7,7 +7,9 @@ on:
jobs:
release:
name: Publish to Github Relases
name: Publish to Github Releases
permissions:
contents: write
outputs:
rc: ${{ steps.check-tag.outputs.rc }}

View File

@@ -2,6 +2,59 @@
All notable changes to this project will be documented in this file.
## [0.34.0] - 2023-06-01
### Bug Fixes
- URL-encoded filename when downloading in safari ([#203](https://github.com/sigoden/dufs/issues/203))
- Ui path table show move action ([#219](https://github.com/sigoden/dufs/issues/219))
- Ui set default max uploading to 1 ([#220](https://github.com/sigoden/dufs/issues/220))
### Features
- Webui editing support multiple encodings ([#197](https://github.com/sigoden/dufs/issues/197))
- Add timestamp metadata to generated zip file ([#204](https://github.com/sigoden/dufs/issues/204))
- Show precise file size with decimal ([#210](https://github.com/sigoden/dufs/issues/210))
- [**breaking**] New auth ([#218](https://github.com/sigoden/dufs/issues/218))
### Refactor
- Cli positional rename root => SERVE_PATH([#215](https://github.com/sigoden/dufs/issues/215))
## [0.33.0] - 2023-03-17
### Bug Fixes
- Cors allow-request-header add content-type ([#184](https://github.com/sigoden/dufs/issues/184))
- Hidden don't works on some files ([#188](https://github.com/sigoden/dufs/issues/188))
- Basic auth sometimes does not work ([#194](https://github.com/sigoden/dufs/issues/194))
### Features
- Guess plain text encoding then set content-type charset ([#186](https://github.com/sigoden/dufs/issues/186))
### Refactor
- Improve error handle ([#195](https://github.com/sigoden/dufs/issues/195))
## [0.32.0] - 2023-02-22
### Bug Fixes
- Set the STOPSIGNAL to SIGINT for Dockerfile
- Remove Method::Options auth check ([#168](https://github.com/sigoden/dufs/issues/168))
- Clear search input also clear query ([#178](https://github.com/sigoden/dufs/issues/178))
### Features
- [**breaking**] Add option --allow-archive ([#152](https://github.com/sigoden/dufs/issues/152))
- Use env var for args ([#170](https://github.com/sigoden/dufs/issues/170))
- Hiding only directories instead of files ([#175](https://github.com/sigoden/dufs/issues/175))
- API to search and list directories ([#177](https://github.com/sigoden/dufs/issues/177))
- Support edit files ([#179](https://github.com/sigoden/dufs/issues/179))
- Support new file ([#180](https://github.com/sigoden/dufs/issues/180))
- Ui improves the login experience ([#182](https://github.com/sigoden/dufs/issues/182))
## [0.31.0] - 2022-11-11
### Bug Fixes
@@ -40,7 +93,7 @@ All notable changes to this project will be documented in this file.
### Bug Fixes
- File path contains special charactors ([#114](https://github.com/sigoden/dufs/issues/114))
- File path contains special characters ([#114](https://github.com/sigoden/dufs/issues/114))
### Features
@@ -78,7 +131,7 @@ All notable changes to this project will be documented in this file.
### Bug Fixes
- Unexpect stack overflow when searching a lot ([#87](https://github.com/sigoden/dufs/issues/87))
- Unexpected stack overflow when searching a lot ([#87](https://github.com/sigoden/dufs/issues/87))
### Features
@@ -171,7 +224,7 @@ All notable changes to this project will be documented in this file.
### Refactor
- Trival changes ([#41](https://github.com/sigoden/dufs/issues/41))
- Trivial changes ([#41](https://github.com/sigoden/dufs/issues/41))
## [0.16.0] - 2022-06-12

1112
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "dufs"
version = "0.31.0"
version = "0.34.0"
edition = "2021"
authors = ["sigoden <sigoden@gmail.com>"]
description = "Dufs is a distinctive utility file server"
@@ -11,35 +11,40 @@ categories = ["command-line-utilities", "web-programming::http-server"]
keywords = ["static", "file", "server", "webdav", "cli"]
[dependencies]
clap = { version = "4", features = ["wrap_help"] }
clap = { version = "4", features = ["wrap_help", "env"] }
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"] }
tokio-util = { version = "0.7", features = ["io-util", "compat"] }
hyper = { version = "0.14", features = ["http1", "server", "tcp", "stream"] }
percent-encoding = "2.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
futures = "0.3"
base64 = "0.13"
async_zip = { version = "0.0.8", default-features = false, features = ["deflate"] }
base64 = "0.21"
async_zip = { version = "0.0.15", default-features = false, features = ["deflate", "chrono", "tokio"] }
headers = "0.3"
mime_guess = "2.0"
if-addrs = "0.7"
rustls = { version = "0.20", default-features = false, features = ["tls12"], optional = true }
if-addrs = "0.10.1"
rustls = { version = "0.21", default-features = false, features = ["tls12"], optional = true }
rustls-pemfile = { version = "1", optional = true }
tokio-rustls = { version = "0.23", optional = true }
tokio-rustls = { version = "0.24", 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"
socket2 = "0.4"
socket2 = "0.5"
async-stream = "0.3"
walkdir = "2.3"
form_urlencoded = "1.0"
alphanumeric-sort = "1.4"
content_inspector = "0.2"
anyhow = "1.0"
chardetng = "0.1"
glob = "0.3.1"
indexmap = "1.9"
[features]
default = ["tls"]
@@ -50,12 +55,11 @@ assert_cmd = "2"
reqwest = { version = "0.11", features = ["blocking", "multipart", "rustls-tls"], default-features = false }
assert_fs = "1"
port_check = "0.1"
rstest = "0.15"
rstest = "0.17"
regex = "1"
url = "2"
diqwest = { version = "1", features = ["blocking"] }
predicates = "2"
indexmap = "1.9"
predicates = "3"
[profile.release]
lto = true

View File

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

129
README.md
View File

@@ -5,16 +5,16 @@
Dufs is a distinctive utility file server that supports static serving, uploading, searching, accessing control, webdav...
![demo](https://user-images.githubusercontent.com/4012553/189362357-b2f7aa6b-9df0-4438-a57c-c8f92850fc4f.png)
![demo](https://user-images.githubusercontent.com/4012553/220513063-ff0f186b-ac54-4682-9af4-47a9781dee0d.png)
## Features
- Serve static files
- Download folder as zip file
- Upload files and folders (Drag & Drop)
- Search files
- Create/Edit/Search files
- Partial responses (Parallel/Resume download)
- Path level access control
- Access control
- Support https
- Support webdav
- Easy to use with curl
@@ -33,6 +33,12 @@ cargo install dufs
docker run -v `pwd`:/data -p 5000:5000 --rm -it sigoden/dufs /data -A
```
### With [Homebrew](https://brew.sh)
```
brew install dufs
```
### Binaries on macOS, Linux, Windows
Download from [Github Releases](https://github.com/sigoden/dufs/releases), unzip and add dufs to your $PATH.
@@ -42,23 +48,24 @@ Download from [Github Releases](https://github.com/sigoden/dufs/releases), unzip
```
Dufs is a distinctive utility file server - https://github.com/sigoden/dufs
Usage: dufs [OPTIONS] [root]
Usage: dufs [OPTIONS] [serve_path]
Arguments:
[root] Specific path to serve [default: .]
[serve_path] 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
-a, --auth <rules> Add auth role
--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
@@ -68,19 +75,19 @@ Options:
--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
-h, --help Print help
-V, --version Print version
```
## Examples
Serve current working directory
Serve current working directory in readonly mode
```
dufs
```
Allow all operations like upload/delete/search...
Allow all operations like upload/delete/search/create/edit...
```
dufs -A
@@ -164,66 +171,94 @@ Delete a file/folder
curl -X DELETE http://127.0.0.1:5000/path-to-file-or-folder
```
Create a directory
```
curl -X MKCOL https://127.0.0.1:5000/path-to-folder
```
Move the file/folder to the new path
```
curl -X MOVE https://127.0.0.1:5000/path -H "Destination: https://127.0.0.1:5000/new-path"
```
List/search directory contents
```
curl http://127.0.0.1:5000?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 supports account based 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>@*
dufs -a [user:pass]@path[:rw][,path[:rw]...][|...]
```
- `<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..
1: Multiple rules are separated by "|"
2: User and pass are the account name and password, if omitted, it is an anonymous user
3: One rule can set multiple paths, separated by ","
4: Add `:rw` after the path to indicate that the path has read and write permissions, otherwise the path has readonly permissions.
```
dufs -A -a /@admin:admin
dufs -A -a admin:admin@/:rw
```
`admin` has all permissions for all paths.
```
dufs -A -a /@admin:admin@guest:guest
dufs -A -a admin:admin@/:rw -a guest:guest@/
```
`guest` has readonly permissions for all paths.
```
dufs -A -a /@admin:admin@*
dufs -A -a admin:admin@/:rw -a @/
```
All paths is public, everyone can view/download it.
```
dufs -A -a /@admin:admin -a /user1@user1:pass1 -a /user2@pass2:user2
dufs -A -a admin:admin@/:rw -a user1:pass1@/user1:rw -a user2:pass2@/user2
dufs -A -a "admin:admin@/:rw|user1:pass1@/user1:rw|user2:pass2@/user2"
```
`user1` has all permissions for `/user1*` path.
`user2` has all permissions for `/user2*` path.
`user1` has all permissions for `/user1/*` path.
`user2` has all permissions for `/user2/*` path.
```
dufs -a /@admin:admin
dufs -A -a user:pass@/dir1:rw,/dir2:rw,dir3
```
`user` has all permissions for `/dir1/*` and `/dir2/*`, has readonly permissions for `/dir3/`.
```
dufs -a 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 supports hiding paths from directory listings via option `--hidden <glob>,...`.
```
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
> The glob used in --hidden only matches file and directory names, not paths. So `--hidden dir1/file` is invalid.
```sh
dufs --hidden '.*'
dufs --hidden '*.log,*.lock'
dufs --hidden '.*' # hidden dotfiles
dufs --hidden '*/' # hidden all folders
dufs --hidden '*.log,*.lock' # hidden by exts
```
### Log Format
@@ -267,6 +302,34 @@ dufs --log-format '$remote_addr $remote_user "$request" $status' -a /@admin:admi
2022-08-06T07:04:37+08:00 INFO - 127.0.0.1 admin "GET /" 200
```
## Environment variables
All options can be set using environment variables prefixed with `DUFS_`.
```
[ROOT_DIR] DUFS_ROOT_DIR=/dir
-b, --bind <addrs> DUFS_BIND=0.0.0.0
-p, --port <port> DUFS_PORT=5000
--path-prefix <path> DUFS_PATH_RREFIX=/path
--hidden <value> DUFS_HIDDEN=*.log
-a, --auth <rules> DUFS_AUTH="admin:admin@/:rw|@/"
--auth-method <value> DUFS_AUTH_METHOD=basic
-A, --allow-all DUFS_ALLOW_ALL=true
--allow-upload DUFS_ALLOW_UPLOAD=true
--allow-delete DUFS_ALLOW_DELETE=true
--allow-search DUFS_ALLOW_SEARCH=true
--allow-symlink DUFS_ALLOW_SYMLINK=true
--allow-archive DUFS_ALLOW_ARCHIVE=true
--enable-cors DUFS_ENABLE_CORS=true
--render-index DUFS_RENDER_INDEX=true
--render-try-index DUFS_RENDER_TRY_INDEX=true
--render-spa DUFS_RENDER_SPA=true
--assets <path> DUFS_ASSETS=/assets
--tls-cert <path> DUFS_TLS_CERT=cert.pem
--tls-key <path> DUFS_TLS_KEY=key.pem
--log-format <format> DUFS_LOG_FORMAT=""
```
### Customize UI
Dufs allows users to customize the UI with your own assets.

View File

@@ -1,5 +1,5 @@
html {
font-family: -apple-system,BlinkMacSystemFont,Roboto,Helvetica,Arial,sans-serif;
font-family: -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif;
line-height: 1.5;
color: #24292e;
}
@@ -7,6 +7,7 @@ html {
body {
/* prevent premature breadcrumb wrapping on mobile */
min-width: 500px;
margin: 0;
}
.hidden {
@@ -17,7 +18,10 @@ body {
display: flex;
flex-wrap: wrap;
align-items: center;
padding: 1em 1em 0;
padding: 0.6em 1em;
position: fixed;
width: 100%;
background-color: white;
}
.breadcrumb {
@@ -25,28 +29,28 @@ body {
padding-right: 0.6em;
}
.breadcrumb > a {
.breadcrumb>a {
color: #0366d6;
text-decoration: none;
}
.breadcrumb > a:hover {
.breadcrumb>a:hover {
text-decoration: underline;
}
/* final breadcrumb */
.breadcrumb > b {
.breadcrumb>b {
color: #24292e;
}
.breadcrumb > .separator {
.breadcrumb>.separator {
color: #586069;
padding: 0 0.25em;
}
.breadcrumb svg {
height: 100%;
fill: rgba(3,47,98,0.5);
fill: rgba(3, 47, 98, 0.5);
}
.toolbox {
@@ -54,7 +58,8 @@ body {
margin-right: 10px;
}
.toolbox > div {
.toolbox>a,
.toolbox>div {
/* vertically align with breadcrumb text */
height: 1.1rem;
}
@@ -87,7 +92,6 @@ body {
font-size: 16px;
line-height: 16px;
padding: 1px;
font-family: helvetica neue,luxi sans,Tahoma,hiragino sans gb,STHeiti,sans-serif;
background-color: transparent;
border: none;
outline: none;
@@ -105,11 +109,10 @@ body {
}
.main {
padding: 0 1em;
padding: 3.3em 1em 0;
}
.empty-folder {
padding-top: 1rem;
font-style: italic;
}
@@ -136,7 +139,7 @@ body {
text-decoration: none;
}
.paths-table thead a > span {
.paths-table thead a>span {
padding-left: 2px;
}
@@ -145,7 +148,7 @@ body {
}
.paths-table .cell-actions {
width: 75px;
width: 90px;
display: flex;
padding-left: 0.6em;
}
@@ -163,7 +166,7 @@ body {
.path svg {
height: 16px;
fill: rgba(3,47,98,0.5);
fill: rgba(3, 47, 98, 0.5);
padding-right: 0.5em;
vertical-align: text-top;
}
@@ -189,6 +192,7 @@ body {
.action-btn {
padding-right: 0.3em;
cursor: pointer;
}
.uploaders-table {
@@ -199,6 +203,27 @@ body {
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;
@@ -228,6 +253,10 @@ body {
fill: #fff;
}
.head {
background-color: #111;
}
.searchbar {
background-color: #111;
border-color: #fff6;
@@ -244,4 +273,9 @@ body {
.paths-table tbody tr:hover {
background-color: #1a1a1a;
}
}
.editor {
background: black;
color: white;
}
}

View File

@@ -7,59 +7,126 @@
<link rel="icon" type="image/x-icon" href="__ASSERTS_PREFIX__favicon.ico">
<link rel="stylesheet" href="__ASSERTS_PREFIX__index.css">
<script>
DATA = __INDEX_DATA__
DATA = __INDEX_DATA__
</script>
<script src="__ASSERTS_PREFIX__index.js"></script>
</head>
<body>
<div class="head">
<div class="breadcrumb"></div>
<div class="toolbox">
<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>
<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>
<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"/>
<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" fill="currentColor" viewBox="0 0 16 16"><path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/></svg>
<svg width="16" height="16" viewBox="0 0 16 16">
<path
d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z" />
</svg>
</div>
<input id="search" name="q" type="text" maxlength="128" autocomplete="off" tabindex="1" required>
<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="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 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>

View File

@@ -10,13 +10,20 @@
* @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
*/
var DUFS_MAX_UPLOADINGS = 1;
/**
* @type {DATA} DATA
*/
@@ -31,8 +38,26 @@ var DATA;
*/
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
*/
@@ -56,7 +81,37 @@ let $emptyFolder;
/**
* @type Element
*/
let $newFolder;
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 ${DATA.href} - Dufs`;
document.querySelector(".editor-page").classList.remove("hidden");;
setupEditPage();
}
}
class Uploader {
/**
@@ -78,12 +133,12 @@ class Uploader {
upload() {
const { idx, name } = this;
const url = getUrl(name);
const url = newUrl(name);
const encodedName = encodedStr(name);
$uploadersTable.insertAdjacentHTML("beforeend", `
<tr id="upload${idx}" class="uploader">
<td class="path cell-icon">
${getSvg()}
${getPathSvg()}
</td>
<td class="path cell-name">
<a href="${url}">${encodedName}</a>
@@ -99,13 +154,12 @@ class Uploader {
}
ajax() {
Uploader.runnings += 1;
const url = getUrl(this.name);
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.readyState === 4) {
if (ajax.status >= 200 && ajax.status < 300) {
this.complete();
} else {
@@ -118,7 +172,7 @@ class Uploader {
ajax.open("PUT", url);
ajax.send(this.file);
}
progress(event) {
const now = Date.now();
@@ -134,13 +188,13 @@ class Uploader {
complete() {
this.$uploadStatus.innerHTML = ``;
Uploader.runnings -= 1;
Uploader.runnings--;
Uploader.runQueue();
}
fail() {
this.$uploadStatus.innerHTML = ``;
Uploader.runnings -= 1;
Uploader.runnings--;
Uploader.runQueue();
}
}
@@ -149,16 +203,24 @@ Uploader.globalIdx = 0;
Uploader.runnings = 0;
Uploader.auth = false;
/**
* @type Uploader[]
*/
Uploader.queues = [];
Uploader.runQueue = () => {
if (Uploader.runnings > 2) return;
Uploader.runQueue = async () => {
if (Uploader.runnings >= DUFS_MAX_UPLOADINGS) return;
if (Uploader.queues.length == 0) return;
Uploader.runnings++;
let uploader = Uploader.queues.shift();
if (!uploader) return;
if (!Uploader.auth) {
Uploader.auth = true;
const success = await checkAuth(true);
Uploader.auth = !!success;
}
uploader.ajax();
}
@@ -181,7 +243,7 @@ function addBreadcrumb(href, uri_prefix) {
const name = parts[i];
if (i > 0) {
if (!path.endsWith("/")) {
path += "/";
path += "/";
}
path += encodeURIComponent(name);
}
@@ -199,6 +261,33 @@ function addBreadcrumb(href, uri_prefix) {
}
}
function setupIndexPage() {
if (DATA.allow_archive) {
const $download = document.querySelector(".download");
$download.href = baseUrl() + "?zip";
$download.title = "Download folder as a .zip file";
$download.classList.remove("hidden");
}
if (DATA.allow_upload) {
setupDropzone();
setupUploadFile();
setupNewFolder();
setupNewFile();
}
if (DATA.auth) {
setupAuth();
}
if (DATA.allow_search) {
setupSearch()
}
renderPathsTableHead();
renderPathsTableBody();
}
/**
* Render path table thead
*/
@@ -223,20 +312,20 @@ function renderPathsTableHead() {
$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")}
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>
`);
@@ -267,52 +356,51 @@ function renderPathsTableBody() {
*/
function addPath(file, index) {
const encodedName = encodedStr(file.name);
let url = getUrl(file.name)
let url = newUrl(file.name)
let actionDelete = "";
let actionDownload = "";
let actionMove = "";
if (file.path_type.endsWith("Dir")) {
let actionEdit = "";
let isDir = file.path_type.endsWith("Dir");
if (isDir) {
url += "/";
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>`;
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>
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg>
</a>
<a href="${url}" title="Download file" download>${ICONS.download}</a>
</div>`;
}
if (DATA.allow_delete) {
if (DATA.allow_upload) {
actionMove = `
<div onclick="movePath(${index})" class="action-btn" id="moveBtn${index}" title="Move to new path">
<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>
</div>`;
actionMove = `<div onclick="movePath(${index})" class="action-btn" id="moveBtn${index}" title="Move to new path">${ICONS.move}</div>`;
if (!isDir) {
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">
<svg width="16" height="16" fill="currentColor"viewBox="0 0 16 16"><path d="M6.854 7.146a.5.5 0 1 0-.708.708L7.293 9l-1.147 1.146a.5.5 0 0 0 .708.708L8 9.707l1.146 1.147a.5.5 0 0 0 .708-.708L8.707 9l1.147-1.146a.5.5 0 0 0-.708-.708L8 8.293 6.854 7.146z"/><path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5v2z"/></svg>
</div>`;
<div onclick="deletePath(${index})" class="action-btn" id="deleteBtn${index}" title="Delete">${ICONS.delete}</div>`;
}
let actionCell = `
<td class="cell-actions">
${actionDownload}
${actionMove}
${actionDelete}
${actionEdit}
</td>`
$pathsTableBody.insertAdjacentHTML("beforeend", `
<tr id="addPath${index}">
<td class="path cell-icon">
${getSvg(file.path_type)}
${getPathSvg(file.path_type)}
</td>
<td class="path cell-name">
<a href="${url}">${encodedName}</a>
<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>
@@ -320,6 +408,149 @@ function addPath(file, index) {
</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", () => checkAuth(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 file 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 encoding = getEncoding(res.headers.get("content-type"));
if (encoding === "utf-8") {
$editor.value = await res.text();
} else {
const bytes = await res.arrayBuffer();
const dataView = new DataView(bytes)
const decoder = new TextDecoder(encoding)
$editor.value = decoder.decode(dataView);
}
} catch (err) {
alert(`Failed get file, ${err.message}`);
}
}
/**
* Delete path
* @param {number} index
@@ -328,30 +559,31 @@ function addPath(file, index) {
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");
}
})
}
if (!confirm(`Delete \`${file.name}\`?`)) return;
async function doDeletePath(name, url, cb) {
if (!confirm(`Delete \`${name}\`?`)) return;
try {
const res = await fetch(getUrl(file.name), {
await checkAuth();
const res = await fetch(url, {
method: "DELETE",
});
if (res.status >= 200 && res.status < 300) {
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");
}
} else {
throw new Error(await res.text())
}
await assertResOK(res);
cb();
} catch (err) {
alert(`Cannot delete \`${file.name}\`, ${err.message}`);
}
}
/**
* Move path
* @param {number} index
@@ -360,12 +592,18 @@ async function deletePath(index) {
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("/");
}
}
const fileUrl = getUrl(file.name);
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)
@@ -375,44 +613,63 @@ async function movePath(index) {
const newFileUrl = fileUrlObj.origin + prefix + newPath.split("/").map(encodeURIComponent).join("/");
try {
const res = await fetch(fileUrl, {
await checkAuth();
const res1 = await fetch(newFileUrl, {
method: "HEAD",
});
if (res1.status === 200) {
if (!confirm("Override existing file?")) {
return;
}
}
const res2 = await fetch(fileUrl, {
method: "MOVE",
headers: {
"Destination": newFileUrl,
}
});
if (res.status >= 200 && res.status < 300) {
location.href = newFileUrl.split("/").slice(0, -1).join("/")
} else {
throw new Error(await res.text())
}
await assertResOK(res2);
return newFileUrl;
} catch (err) {
alert(`Cannot move \`${filePath}\` to \`${newPath}\`, ${err.message}`);
}
}
function dropzone() {
["drag", "dragstart", "dragend", "dragover", "dragenter", "dragleave", "drop"].forEach(name => {
document.addEventListener(name, e => {
e.preventDefault();
e.stopPropagation();
});
/**
* Save editor change
*/
async function saveChange() {
try {
await fetch(baseUrl(), {
method: "PUT",
body: $editor.value,
});
document.addEventListener("drop", 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, [])
}
location.reload();
} catch (err) {
alert(`Failed to save file, ${err.message}`);
}
}
async function checkAuth(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 = "";
return true;
} catch (err) {
let message = `Check auth, ${err.message}`;
if (alert) {
alert(message);
} else {
throw new Error(message);
}
}
}
/**
@@ -420,19 +677,34 @@ function dropzone() {
* @param {string} name
*/
async function createFolder(name) {
const url = getUrl(name);
const url = newUrl(name);
try {
await checkAuth();
const res = await fetch(url, {
method: "MKCOL",
});
if (res.status >= 200 && res.status < 300) {
location.href = url;
}
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 checkAuth();
const res = await fetch(url, {
method: "PUT",
body: "",
});
await assertResOK(res);
location.href = url + "?edit";
} catch (err) {
alert(`Cannot create file \`${name}\`, ${err.message}`);
}
}
async function addFileEntries(entries, dirs) {
for (const entry of entries) {
if (entry.isFile) {
@@ -447,23 +719,41 @@ async function addFileEntries(entries, dirs) {
}
function getUrl(name) {
let url = location.href.split('?')[0];
if (!url.endsWith("/")) url += "/";
url += name.split("/").map(encodeURIComponent).join("/");
return url;
function newUrl(name) {
let url = baseUrl();
if (!url.endsWith("/")) url += "/";
url += name.split("/").map(encodeURIComponent).join("/");
return url;
}
function getSvg(path_type) {
function baseUrl() {
return location.href.split('?')[0];
}
function baseName(url) {
return decodeURIComponent(url.split("/").filter(v => v.length > 0).slice(-1)[0])
}
function extName(filename) {
const dotIndex = filename.lastIndexOf('.');
if (dotIndex === -1 || dotIndex === 0 || dotIndex === filename.length - 1) {
return '';
}
return filename.substring(dotIndex);
}
function getPathSvg(path_type) {
switch (path_type) {
case "Dir":
return `<svg height="16" viewBox="0 0 14 16" width="14"><path fill-rule="evenodd" d="M13 4H7V3c0-.66-.31-1-1-1H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1zM6 4H1V3h5v1z"></path></svg>`;
return ICONS.dir;
case "SymlinkFile":
return `<svg height="16" viewBox="0 0 12 16" width="12"><path fill-rule="evenodd" d="M8.5 1H1c-.55 0-1 .45-1 1v12c0 .55.45 1 1 1h10c.55 0 1-.45 1-1V4.5L8.5 1zM11 14H1V2h7l3 3v9zM6 4.5l4 3-4 3v-2c-.98-.02-1.84.22-2.55.7-.71.48-1.19 1.25-1.45 2.3.02-1.64.39-2.88 1.13-3.73.73-.84 1.69-1.27 2.88-1.27v-2H6z"></path></svg>`;
return ICONS.symlinkFile;
case "SymlinkDir":
return `<svg height="16" viewBox="0 0 14 16" width="14"><path fill-rule="evenodd" d="M13 4H7V3c0-.66-.31-1-1-1H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1zM1 3h5v1H1V3zm6 9v-2c-.98-.02-1.84.22-2.55.7-.71.48-1.19 1.25-1.45 2.3.02-1.64.39-2.88 1.13-3.73C4.86 8.43 5.82 8 7.01 8V6l4 3-4 3H7z"></path></svg>`;
return ICONS.symlinkDir;
default:
return `<svg height="16" viewBox="0 0 12 16" width="12"><path fill-rule="evenodd" d="M6 5H2V4h4v1zM2 8h7V7H2v1zm0 2h7V9H2v1zm0 2h7v-1H2v1zm10-7.5V14c0 .55-.45 1-1 1H1c-.55 0-1-.45-1-1V2c0-.55.45-1 1-1h7.5L12 4.5zM11 5L8 2H1v12h10V5z"></path></svg>`;
return ICONS.file;
}
}
@@ -483,11 +773,15 @@ function padZero(value, size) {
}
function formatSize(size) {
if (!size) return []
if (size == null) return []
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
if (size == 0) return [0, "Byte"];
if (size == 0) return [0, "B"];
const i = parseInt(Math.floor(Math.log(size) / Math.log(1024)));
return [Math.round(size / Math.pow(1024, i), 2), sizes[i]];
ratio = 1
if (i >= 3) {
ratio = 100
}
return [Math.round(size * ratio / Math.pow(1024, i), 2) / ratio, sizes[i]];
}
function formatDuration(seconds) {
@@ -498,55 +792,33 @@ function formatDuration(seconds) {
return `${padZero(h, 2)}:${padZero(m, 2)}:${padZero(s, 2)}`;
}
function formatPercent(precent) {
if (precent > 10) {
return precent.toFixed(1) + "%";
function formatPercent(percent) {
if (percent > 10) {
return percent.toFixed(1) + "%";
} else {
return precent.toFixed(2) + "%";
return percent.toFixed(2) + "%";
}
}
function encodedStr(rawStr) {
return rawStr.replace(/[\u00A0-\u9999<>\&]/g, function(i) {
return '&#'+i.charCodeAt(0)+';';
return rawStr.replace(/[\u00A0-\u9999<>\&]/g, function (i) {
return '&#' + i.charCodeAt(0) + ';';
});
}
function ready() {
document.title = `Index of ${DATA.href} - Dufs`;
$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");
$newFolder = document.querySelector(".new-folder");
if (DATA.allow_search) {
document.querySelector(".searchbar").classList.remove("hidden");
if (PARAMS.q) {
document.getElementById('search').value = PARAMS.q;
}
}
addBreadcrumb(DATA.href, DATA.uri_prefix);
renderPathsTableHead();
renderPathsTableBody();
if (DATA.allow_upload) {
dropzone();
if (DATA.allow_delete) {
$newFolder.classList.remove("hidden");
$newFolder.addEventListener("click", () => {
const name = prompt("Enter name of new folder");
if (name) createFolder(name);
});
}
document.querySelector(".upload-file").classList.remove("hidden");
document.getElementById("file").addEventListener("change", e => {
const files = e.target.files;
for (let file of files) {
new Uploader(file, []).upload();
}
});
async function assertResOK(res) {
if (!(res.status >= 200 && res.status < 300)) {
throw new Error(await res.text())
}
}
function getEncoding(contentType) {
const charset = contentType?.split(";")[1];
if (/charset/i.test(charset)) {
let encoding = charset.split("=")[1];
if (encoding) {
return encoding.toLowerCase()
}
}
return 'utf-8'
}

View File

@@ -1,3 +1,4 @@
use anyhow::{bail, Context, Result};
use clap::builder::PossibleValuesParser;
use clap::{value_parser, Arg, ArgAction, ArgMatches, Command};
use clap_complete::{generate, Generator, Shell};
@@ -13,7 +14,6 @@ use crate::log_http::{LogHttp, DEFAULT_LOG_FORMAT};
#[cfg(feature = "tls")]
use crate::tls::{load_certs, load_private_key};
use crate::utils::encode_uri;
use crate::BoxResult;
pub fn build_cli() -> Command {
let app = Command::new(env!("CARGO_CRATE_NAME"))
@@ -25,13 +25,17 @@ pub fn build_cli() -> Command {
env!("CARGO_PKG_REPOSITORY")
))
.arg(
Arg::new("root")
Arg::new("serve_path")
.env("DUFS_SERVE_PATH")
.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")
@@ -41,6 +45,8 @@ pub fn build_cli() -> Command {
)
.arg(
Arg::new("port")
.env("DUFS_PORT")
.hide_env(true)
.short('p')
.long("port")
.default_value("5000")
@@ -50,27 +56,35 @@ pub fn build_cli() -> Command {
)
.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")
.help("Add auth role")
.action(ArgAction::Append)
.value_delimiter(',')
.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"]))
@@ -79,6 +93,8 @@ pub fn build_cli() -> Command {
)
.arg(
Arg::new("allow-all")
.env("DUFS_ALLOW_ALL")
.hide_env(true)
.short('A')
.long("allow-all")
.action(ArgAction::SetTrue)
@@ -86,54 +102,80 @@ pub fn build_cli() -> Command {
)
.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))
@@ -144,6 +186,8 @@ pub fn build_cli() -> Command {
let app = app
.arg(
Arg::new("tls-cert")
.env("DUFS_TLS_CERT")
.hide_env(true)
.long("tls-cert")
.value_name("path")
.value_parser(value_parser!(PathBuf))
@@ -151,6 +195,8 @@ pub fn build_cli() -> Command {
)
.arg(
Arg::new("tls-key")
.env("DUFS_TLS_KEY")
.hide_env(true)
.long("tls-key")
.value_name("path")
.value_parser(value_parser!(PathBuf))
@@ -159,6 +205,8 @@ pub fn build_cli() -> Command {
app.arg(
Arg::new("log-format")
.env("DUFS_LOG_FORMAT")
.hide_env(true)
.long("log-format")
.value_name("format")
.help("Customize http log format"),
@@ -191,6 +239,7 @@ pub struct Args {
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,
@@ -206,16 +255,16 @@ pub struct Args {
impl Args {
/// 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.
pub fn parse(matches: ArgMatches) -> BoxResult<Args> {
pub fn parse(matches: ArgMatches) -> Result<Args> {
let port = *matches.get_one::<u16>("port").unwrap();
let addrs = matches
.get_many::<String>("bind")
.map(|bind| bind.map(|v| v.as_str()).collect())
.unwrap_or_else(|| vec!["0.0.0.0", "::"]);
let addrs: Vec<BindAddr> = Args::parse_addrs(&addrs)?;
let path = Args::parse_path(matches.get_one::<PathBuf>("root").unwrap())?;
let path = Args::parse_path(matches.get_one::<PathBuf>("serve_path").unwrap())?;
let path_is_file = path.metadata()?.is_file();
let path_prefix = matches
.get_one::<String>("path-prefix")
@@ -239,11 +288,12 @@ impl Args {
"basic" => AuthMethod::Basic,
_ => AuthMethod::Digest,
};
let auth = AccessControl::new(&auth, &uri_prefix)?;
let auth = AccessControl::new(&auth)?;
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");
@@ -286,6 +336,7 @@ impl Args {
allow_upload,
allow_search,
allow_symlink,
allow_archive,
render_index,
render_try_index,
render_spa,
@@ -295,7 +346,7 @@ impl Args {
})
}
fn parse_addrs(addrs: &[&str]) -> BoxResult<Vec<BindAddr>> {
fn parse_addrs(addrs: &[&str]) -> Result<Vec<BindAddr>> {
let mut bind_addrs = vec![];
let mut invalid_addrs = vec![];
for addr in addrs {
@@ -313,15 +364,15 @@ impl Args {
}
}
if !invalid_addrs.is_empty() {
return Err(format!("Invalid bind address `{}`", invalid_addrs.join(",")).into());
bail!("Invalid bind address `{}`", invalid_addrs.join(","));
}
Ok(bind_addrs)
}
fn parse_path<P: AsRef<Path>>(path: P) -> BoxResult<PathBuf> {
fn parse_path<P: AsRef<Path>>(path: P) -> Result<PathBuf> {
let path = path.as_ref();
if !path.exists() {
return Err(format!("Path `{}` doesn't exist", path.display()).into());
bail!("Path `{}` doesn't exist", path.display());
}
env::current_dir()
@@ -329,13 +380,13 @@ impl Args {
p.push(path); // If path is absolute, it replaces the current path.
std::fs::canonicalize(p)
})
.map_err(|err| format!("Failed to access path `{}`: {}", path.display(), err,).into())
.with_context(|| format!("Failed to access path `{}`", path.display()))
}
fn parse_assets_path<P: AsRef<Path>>(path: P) -> BoxResult<PathBuf> {
fn parse_assets_path<P: AsRef<Path>>(path: P) -> Result<PathBuf> {
let path = Self::parse_path(path)?;
if !path.join("index.html").exists() {
return Err(format!("Path `{}` doesn't contains index.html", path.display()).into());
bail!("Path `{}` doesn't contains index.html", path.display());
}
Ok(path)
}

View File

@@ -1,15 +1,17 @@
use anyhow::{anyhow, bail, Result};
use base64::{engine::general_purpose, Engine as _};
use headers::HeaderValue;
use hyper::Method;
use indexmap::IndexMap;
use lazy_static::lazy_static;
use md5::Context;
use std::{
collections::HashMap,
time::{SystemTime, UNIX_EPOCH},
path::{Path, PathBuf},
};
use uuid::Uuid;
use crate::utils::encode_uri;
use crate::BoxResult;
use crate::utils::unix_now;
const REALM: &str = "DUFS";
const DIGEST_AUTH_TIMEOUT: u32 = 86400;
@@ -23,53 +25,63 @@ lazy_static! {
};
}
#[derive(Debug)]
#[derive(Debug, Default)]
pub struct AccessControl {
rules: HashMap<String, PathControl>,
}
#[derive(Debug)]
pub struct PathControl {
readwrite: Account,
readonly: Option<Account>,
share: bool,
users: IndexMap<String, (String, AccessPaths)>,
anony: Option<AccessPaths>,
}
impl AccessControl {
pub fn new(raw_rules: &[&str], uri_prefix: &str) -> BoxResult<Self> {
let mut rules = HashMap::default();
pub fn new(raw_rules: &[&str]) -> Result<Self> {
if raw_rules.is_empty() {
return Ok(Self { rules });
return Ok(AccessControl {
anony: Some(AccessPaths::new(AccessPerm::ReadWrite)),
users: IndexMap::new(),
});
}
let create_err = |v: &str| anyhow!("Invalid auth `{v}`");
let mut anony = None;
let mut anony_paths = vec![];
let mut users = IndexMap::new();
for rule in raw_rules {
let parts: Vec<&str> = rule.split('@').collect();
let create_err = || format!("Invalid auth `{}`", rule).into();
match parts.as_slice() {
[path, readwrite] => {
let control = PathControl {
readwrite: Account::new(readwrite).ok_or_else(create_err)?,
readonly: None,
share: false,
};
rules.insert(sanitize_path(path, uri_prefix), control);
let (user, list) = rule.split_once('@').ok_or_else(|| create_err(rule))?;
if user.is_empty() && anony.is_some() {
bail!("Invalid auth, duplicate anonymous rules");
}
let mut paths = AccessPaths::default();
for value in list.trim_matches(',').split(',') {
let (path, perm) = match value.split_once(':') {
None => (value, AccessPerm::ReadOnly),
Some((path, "rw")) => (path, AccessPerm::ReadWrite),
_ => return Err(create_err(rule)),
};
if user.is_empty() {
anony_paths.push((path, perm));
}
[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);
paths.add(path, perm);
}
if user.is_empty() {
anony = Some(paths);
} else if let Some((user, pass)) = user.split_once(':') {
if user.is_empty() || pass.is_empty() {
return Err(create_err(rule));
}
_ => return Err(create_err()),
users.insert(user.to_string(), (pass.to_string(), paths));
} else {
return Err(create_err(rule));
}
}
Ok(Self { rules })
for (path, perm) in anony_paths {
for (_, (_, paths)) in users.iter_mut() {
paths.add(path, perm)
}
}
Ok(Self { users, anony })
}
pub fn valid(&self) -> bool {
!self.users.is_empty() || self.anony.is_some()
}
pub fn guard(
@@ -78,76 +90,157 @@ impl AccessControl {
method: &Method,
authorization: Option<&HeaderValue>,
auth_method: AuthMethod,
) -> GuardType {
if self.rules.is_empty() {
return GuardType::ReadWrite;
}
let mut controls = vec![];
for path in walk_path(path) {
if let Some(control) = self.rules.get(path) {
controls.push(control);
if let Some(authorization) = authorization {
let Account { user, pass } = &control.readwrite;
) -> (Option<String>, Option<AccessPaths>) {
if let Some(authorization) = authorization {
if let Some(user) = auth_method.get_user(authorization) {
if let Some((pass, paths)) = self.users.get(&user) {
if method == Method::OPTIONS {
return (Some(user), Some(AccessPaths::new(AccessPerm::ReadOnly)));
}
if auth_method
.validate(authorization, method.as_str(), user, pass)
.check(authorization, method.as_str(), &user, pass)
.is_some()
{
return GuardType::ReadWrite;
return (Some(user), paths.find(path, !is_readonly_method(method)));
} else {
return (None, None);
}
}
}
}
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;
}
}
}
}
if method == Method::OPTIONS {
return (None, Some(AccessPaths::new(AccessPerm::ReadOnly)));
}
GuardType::Reject
if let Some(paths) = self.anony.as_ref() {
return (None, paths.find(path, !is_readonly_method(method)));
}
(None, None)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum GuardType {
Reject,
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct AccessPaths {
perm: AccessPerm,
children: IndexMap<String, AccessPaths>,
}
impl AccessPaths {
pub fn new(perm: AccessPerm) -> Self {
Self {
perm,
..Default::default()
}
}
pub fn perm(&self) -> AccessPerm {
self.perm
}
fn set_perm(&mut self, perm: AccessPerm) {
if self.perm < perm {
self.perm = perm
}
}
pub fn add(&mut self, path: &str, perm: AccessPerm) {
let path = path.trim_matches('/');
if path.is_empty() {
self.set_perm(perm);
} else {
let parts: Vec<&str> = path.split('/').collect();
self.add_impl(&parts, perm);
}
}
fn add_impl(&mut self, parts: &[&str], perm: AccessPerm) {
let parts_len = parts.len();
if parts_len == 0 {
self.set_perm(perm);
return;
}
let child = self.children.entry(parts[0].to_string()).or_default();
child.add_impl(&parts[1..], perm)
}
pub fn find(&self, path: &str, writable: bool) -> Option<AccessPaths> {
let parts: Vec<&str> = path
.trim_matches('/')
.split('/')
.filter(|v| !v.is_empty())
.collect();
let target = self.find_impl(&parts, self.perm)?;
if writable && !target.perm().readwrite() {
return None;
}
Some(target)
}
fn find_impl(&self, parts: &[&str], perm: AccessPerm) -> Option<AccessPaths> {
let perm = self.perm.max(perm);
if parts.is_empty() {
if perm.indexonly() {
return Some(self.clone());
} else {
return Some(AccessPaths::new(perm));
}
}
let child = match self.children.get(parts[0]) {
Some(v) => v,
None => {
if perm.indexonly() {
return None;
} else {
return Some(AccessPaths::new(perm));
}
}
};
child.find_impl(&parts[1..], perm)
}
pub fn child_paths(&self) -> Vec<&String> {
self.children.keys().collect()
}
pub fn leaf_paths(&self, base: &Path) -> Vec<PathBuf> {
if !self.perm().indexonly() {
return vec![base.to_path_buf()];
}
let mut output = vec![];
self.leaf_paths_impl(&mut output, base);
output
}
fn leaf_paths_impl(&self, output: &mut Vec<PathBuf>, base: &Path) {
for (name, child) in self.children.iter() {
let base = base.join(name);
if child.perm().indexonly() {
child.leaf_paths_impl(output, &base);
} else {
output.push(base)
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum AccessPerm {
#[default]
IndexOnly,
ReadWrite,
ReadOnly,
}
impl GuardType {
pub fn is_reject(&self) -> bool {
*self == GuardType::Reject
impl AccessPerm {
pub fn readwrite(&self) -> bool {
self == &AccessPerm::ReadWrite
}
}
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
})
pub fn indexonly(&self) -> bool {
self == &AccessPerm::IndexOnly
}
}
fn is_readonly_method(method: &Method) -> bool {
@@ -157,29 +250,6 @@ fn is_readonly_method(method: &Method) -> bool {
|| 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,
@@ -187,41 +257,42 @@ pub enum AuthMethod {
}
impl AuthMethod {
pub fn www_auth(&self, stale: bool) -> String {
pub fn www_auth(&self, stale: bool) -> Result<String> {
match self {
AuthMethod::Basic => {
format!("Basic realm=\"{}\"", REALM)
}
AuthMethod::Basic => Ok(format!("Basic realm=\"{REALM}\"")),
AuthMethod::Digest => {
let str_stale = if stale { "stale=true," } else { "" };
format!(
Ok(format!(
"Digest realm=\"{}\",nonce=\"{}\",{}qop=\"auth\"",
REALM,
create_nonce(),
create_nonce()?,
str_stale
)
))
}
}
}
pub fn get_user(&self, authorization: &HeaderValue) -> Option<String> {
match self {
AuthMethod::Basic => {
let value: Vec<u8> =
base64::decode(strip_prefix(authorization.as_bytes(), b"Basic ")?).ok()?;
let value: Vec<u8> = general_purpose::STANDARD
.decode(strip_prefix(authorization.as_bytes(), b"Basic ")?)
.ok()?;
let parts: Vec<&str> = std::str::from_utf8(&value).ok()?.split(':').collect();
Some(parts[0].to_string())
}
AuthMethod::Digest => {
let digest_value = strip_prefix(authorization.as_bytes(), b"Digest ")?;
let digest_vals = to_headermap(digest_value).ok()?;
digest_vals
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(
fn check(
&self,
authorization: &HeaderValue,
method: &str,
@@ -230,20 +301,16 @@ impl AuthMethod {
) -> Option<()> {
match self {
AuthMethod::Basic => {
let basic_value: Vec<u8> =
base64::decode(strip_prefix(authorization.as_bytes(), b"Basic ")?).ok()?;
let basic_value: Vec<u8> = general_purpose::STANDARD
.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 {
if parts[1] == auth_pass {
return Some(());
}
@@ -251,13 +318,13 @@ impl AuthMethod {
}
AuthMethod::Digest => {
let digest_value = strip_prefix(authorization.as_bytes(), b"Digest ")?;
let digest_vals = to_headermap(digest_value).ok()?;
let digest_map = to_headermap(digest_value).ok()?;
if let (Some(username), Some(nonce), Some(user_response)) = (
digest_vals
digest_map
.get(b"username".as_ref())
.and_then(|b| std::str::from_utf8(b).ok()),
digest_vals.get(b"nonce".as_ref()),
digest_vals.get(b"response".as_ref()),
digest_map.get(b"nonce".as_ref()),
digest_map.get(b"response".as_ref()),
) {
match validate_nonce(nonce) {
Ok(true) => {}
@@ -266,27 +333,32 @@ impl AuthMethod {
if auth_user != username {
return None;
}
let mut h = Context::new();
h.consume(format!("{}:{}:{}", auth_user, REALM, auth_pass).as_bytes());
let auth_pass = format!("{:x}", h.compute());
let mut ha = Context::new();
ha.consume(method);
ha.consume(b":");
if let Some(uri) = digest_vals.get(b"uri".as_ref()) {
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_vals.get(b"qop".as_ref()) {
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(&auth_pass);
c.consume(b":");
c.consume(nonce);
c.consume(b":");
if let Some(nc) = digest_vals.get(b"nc".as_ref()) {
if let Some(nc) = digest_map.get(b"nc".as_ref()) {
c.consume(nc);
}
c.consume(b":");
if let Some(cnonce) = digest_vals.get(b"cnonce".as_ref()) {
if let Some(cnonce) = digest_map.get(b"cnonce".as_ref()) {
c.consume(cnonce);
}
c.consume(b":");
@@ -301,7 +373,7 @@ impl AuthMethod {
Some(r) => r,
None => {
let mut c = Context::new();
c.consume(auth_pass);
c.consume(&auth_pass);
c.consume(b":");
c.consume(nonce);
c.consume(b":");
@@ -310,7 +382,6 @@ impl AuthMethod {
}
};
if correct_response.as_bytes() == *user_response {
// grant access
return Some(());
}
}
@@ -322,16 +393,16 @@ impl AuthMethod {
/// Check if a nonce is still valid.
/// Return an error if it was never valid
fn validate_nonce(nonce: &[u8]) -> Result<bool, ()> {
fn validate_nonce(nonce: &[u8]) -> Result<bool> {
if nonce.len() != 34 {
return Err(());
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 = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
let now = unix_now()?;
let secs_now = now.as_secs() as u32;
if let Some(dur) = secs_now.checked_sub(secs_nonce) {
@@ -345,7 +416,7 @@ fn validate_nonce(nonce: &[u8]) -> Result<bool, ()> {
}
}
}
Err(())
bail!("invalid nonce");
}
fn strip_prefix<'a>(search: &'a [u8], prefix: &[u8]) -> Option<&'a [u8]> {
@@ -375,7 +446,7 @@ fn to_headermap(header: &[u8]) -> Result<HashMap<&[u8], &[u8]>, ()> {
}
i += 1;
}
sep.push(i); // same len for both Vecs
sep.push(i);
i = 0;
let mut ret = HashMap::new();
@@ -384,7 +455,7 @@ fn to_headermap(header: &[u8]) -> Result<HashMap<&[u8], &[u8]>, ()> {
i += 1;
}
if a <= i || k <= 1 + a {
//keys and vals must contain one char
//keys and values must contain one char
return Err(());
}
let key = &header[i..a];
@@ -401,12 +472,51 @@ fn to_headermap(header: &[u8]) -> Result<HashMap<&[u8], &[u8]>, ()> {
Ok(ret)
}
fn create_nonce() -> String {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
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());
n[..34].to_string()
Ok(n[..34].to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_access_paths() {
let mut paths = AccessPaths::default();
paths.add("/dir1", AccessPerm::ReadWrite);
paths.add("/dir2/dir1", AccessPerm::ReadWrite);
paths.add("/dir2/dir2", AccessPerm::ReadOnly);
paths.add("/dir2/dir3/dir1", AccessPerm::ReadWrite);
assert_eq!(
paths.leaf_paths(Path::new("/tmp")),
[
"/tmp/dir1",
"/tmp/dir2/dir1",
"/tmp/dir2/dir2",
"/tmp/dir2/dir3/dir1"
]
.iter()
.map(PathBuf::from)
.collect::<Vec<_>>()
);
assert_eq!(
paths
.find("dir2", false)
.map(|v| v.leaf_paths(Path::new("/tmp/dir2"))),
Some(
["/tmp/dir2/dir1", "/tmp/dir2/dir2", "/tmp/dir2/dir3/dir1"]
.iter()
.map(PathBuf::from)
.collect::<Vec<_>>()
)
);
assert_eq!(paths.find("dir2", true), None);
assert!(paths.find("dir1/file", true).is_some());
}
}

View File

@@ -6,7 +6,7 @@ pub const DEFAULT_LOG_FORMAT: &str = r#"$remote_addr "$request" $status"#;
#[derive(Debug)]
pub struct LogHttp {
elems: Vec<LogElement>,
elements: Vec<LogElement>,
}
#[derive(Debug)]
@@ -19,8 +19,8 @@ enum LogElement {
impl LogHttp {
pub fn data(&self, req: &Request, args: &Arc<Args>) -> HashMap<String, String> {
let mut data = HashMap::default();
for elem in self.elems.iter() {
match elem {
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()));
@@ -47,12 +47,12 @@ impl LogHttp {
data
}
pub fn log(&self, data: &HashMap<String, String>, err: Option<String>) {
if self.elems.is_empty() {
if self.elements.is_empty() {
return;
}
let mut output = String::new();
for elem in self.elems.iter() {
match elem {
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("-"))
@@ -67,23 +67,23 @@ impl LogHttp {
}
impl FromStr for LogHttp {
type Err = Box<dyn std::error::Error>;
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut elems = vec![];
let mut elements = vec![];
let mut is_var = false;
let mut cache = String::new();
for c in format!("{} ", s).chars() {
for c in format!("{s} ").chars() {
if c == '$' {
if !cache.is_empty() {
elems.push(LogElement::Literal(cache.to_string()));
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_") {
elems.push(LogElement::Header(value.replace('_', "-").to_string()));
elements.push(LogElement::Header(value.replace('_', "-").to_string()));
} else if let Some(value) = cache.strip_prefix('$') {
elems.push(LogElement::Variable(value.to_string()));
elements.push(LogElement::Variable(value.to_string()));
}
cache.clear();
is_var = false;
@@ -92,8 +92,8 @@ impl FromStr for LogHttp {
}
let cache = cache.trim();
if !cache.is_empty() {
elems.push(LogElement::Literal(cache.to_string()));
elements.push(LogElement::Literal(cache.to_string()));
}
Ok(Self { elems })
Ok(Self { elements })
}
}

View File

@@ -18,6 +18,7 @@ use crate::server::{Request, Server};
#[cfg(feature = "tls")]
use crate::tls::{TlsAcceptor, TlsStream};
use anyhow::{anyhow, Context, Result};
use std::net::{IpAddr, SocketAddr, TcpListener as StdTcpListener};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
@@ -33,15 +34,9 @@ use hyper::service::{make_service_fn, service_fn};
#[cfg(feature = "tls")]
use rustls::ServerConfig;
pub type BoxResult<T> = Result<T, Box<dyn std::error::Error>>;
#[tokio::main]
async fn main() {
run().await.unwrap_or_else(handle_err)
}
async fn run() -> BoxResult<()> {
logger::init().map_err(|e| format!("Failed to init logger, {}", e))?;
async fn main() -> Result<()> {
logger::init().map_err(|e| anyhow!("Failed to init logger, {e}"))?;
let cmd = build_cli();
let matches = cmd.get_matches();
if let Some(generator) = matches.get_one::<Shell>("completions") {
@@ -74,8 +69,8 @@ async fn run() -> BoxResult<()> {
fn serve(
args: Arc<Args>,
running: Arc<AtomicBool>,
) -> BoxResult<Vec<JoinHandle<Result<(), hyper::Error>>>> {
let inner = Arc::new(Server::new(args.clone(), running));
) -> 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() {
@@ -92,7 +87,7 @@ fn serve(
match bind_addr {
BindAddr::Address(ip) => {
let incoming = create_addr_incoming(SocketAddr::new(*ip, port))
.map_err(|e| format!("Failed to bind `{}:{}`, {}", ip, port, e))?;
.with_context(|| format!("Failed to bind `{ip}:{port}`"))?;
match args.tls.as_ref() {
#[cfg(feature = "tls")]
Some((certs, key)) => {
@@ -132,7 +127,7 @@ fn serve(
#[cfg(unix)]
{
let listener = tokio::net::UnixListener::bind(path)
.map_err(|e| format!("Failed to bind `{}`, {}", path.display(), e))?;
.with_context(|| format!("Failed to bind `{}`", path.display()))?;
let acceptor = unix::UnixAcceptor::from_listener(listener);
let new_service = make_service_fn(move |_| serve_func(None));
let server = tokio::spawn(hyper::Server::builder(acceptor).serve(new_service));
@@ -144,7 +139,7 @@ fn serve(
Ok(handles)
}
fn create_addr_incoming(addr: SocketAddr) -> BoxResult<AddrIncoming> {
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() {
@@ -159,7 +154,7 @@ fn create_addr_incoming(addr: SocketAddr) -> BoxResult<AddrIncoming> {
Ok(incoming)
}
fn print_listening(args: Arc<Args>) -> BoxResult<()> {
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() {
@@ -179,8 +174,8 @@ fn print_listening(args: Arc<Args>) -> BoxResult<()> {
}
}
if ipv4 || ipv6 {
let ifaces = if_addrs::get_if_addrs()
.map_err(|e| format!("Failed to get local interface addresses: {}", e))?;
let ifaces =
if_addrs::get_if_addrs().with_context(|| "Failed to get local interface addresses")?;
for iface in ifaces.into_iter() {
let local_ip = iface.ip();
if ipv4 && local_ip.is_ipv4() {
@@ -212,20 +207,15 @@ fn print_listening(args: Arc<Args>) -> BoxResult<()> {
} else {
let info = urls
.iter()
.map(|v| format!(" {}", v))
.map(|v| format!(" {v}"))
.collect::<Vec<String>>()
.join("\n");
println!("Listening on:\n{}\n", info);
println!("Listening on:\n{info}\n");
}
Ok(())
}
fn handle_err<T>(err: Box<dyn std::error::Error>) -> T {
eprintln!("error: {}", err);
std::process::exit(1);
}
async fn shutdown_signal() {
tokio::signal::ctrl_c()
.await

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
use anyhow::{anyhow, bail, Context as AnyhowContext, Result};
use core::task::{Context, Poll};
use futures::ready;
use hyper::server::accept::Accept;
@@ -124,33 +125,29 @@ impl Accept for TlsAcceptor {
}
// Load public certificate from file.
pub fn load_certs<T: AsRef<Path>>(
filename: T,
) -> Result<Vec<Certificate>, Box<dyn std::error::Error>> {
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| format!("Failed to access `{}`, {}", filename.as_ref().display(), e))?;
.with_context(|| format!("Failed to access `{}`", filename.as_ref().display()))?;
let mut reader = io::BufReader::new(cert_file);
// Load and return certificate.
let certs = rustls_pemfile::certs(&mut reader).map_err(|_| "Failed to load certificate")?;
let certs = rustls_pemfile::certs(&mut reader).with_context(|| "Failed to load certificate")?;
if certs.is_empty() {
return Err("No supported certificate in file".into());
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, Box<dyn std::error::Error>> {
pub fn load_private_key<T: AsRef<Path>>(filename: T) -> Result<PrivateKey> {
let key_file = fs::File::open(filename.as_ref())
.map_err(|e| format!("Failed to access `{}`, {}", filename.as_ref().display(), e))?;
.with_context(|| format!("Failed to access `{}`", filename.as_ref().display()))?;
let mut reader = io::BufReader::new(key_file);
// Load and return a single private key.
let keys = rustls_pemfile::read_all(&mut reader)
.map_err(|e| format!("There was a problem with reading private key: {:?}", e))?
.with_context(|| "There was a problem with reading private key")?
.into_iter()
.find_map(|item| match item {
rustls_pemfile::Item::RSAKey(key)
@@ -158,7 +155,7 @@ pub fn load_private_key<T: AsRef<Path>>(
| rustls_pemfile::Item::ECKey(key) => Some(key),
_ => None,
})
.ok_or("No supported private key in file")?;
.ok_or_else(|| anyhow!("No supported private key in file"))?;
Ok(PrivateKey(keys))
}

View File

@@ -1,5 +1,16 @@
use crate::BoxResult;
use std::{borrow::Cow, path::Path};
use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Utc};
use std::{
borrow::Cow,
path::Path,
time::{Duration, SystemTime, UNIX_EPOCH},
};
pub fn unix_now() -> Result<Duration> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.with_context(|| "Invalid system time")
}
pub fn encode_uri(v: &str) -> String {
let parts: Vec<_> = v.split('/').map(urlencoding::encode).collect();
@@ -18,51 +29,33 @@ pub fn get_file_name(path: &Path) -> &str {
.unwrap_or_default()
}
pub fn try_get_file_name(path: &Path) -> BoxResult<&str> {
path.file_name()
.and_then(|v| v.to_str())
.ok_or_else(|| format!("Failed to get file name of `{}`", path.display()).into())
#[cfg(unix)]
pub async fn get_file_mtime_and_mode(path: &Path) -> Result<(DateTime<Utc>, u16)> {
use std::os::unix::prelude::MetadataExt;
let meta = tokio::fs::metadata(path).await?;
let datetime: DateTime<Utc> = meta.modified()?.into();
Ok((datetime, meta.mode() as u16))
}
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()
#[cfg(not(unix))]
pub async fn get_file_mtime_and_mode(path: &Path) -> Result<(DateTime<Utc>, u16)> {
let meta = tokio::fs::metadata(&path).await?;
let datetime: DateTime<Utc> = meta.modified()?.into();
Ok((datetime, 0o644))
}
pub fn try_get_file_name(path: &Path) -> Result<&str> {
path.file_name()
.and_then(|v| v.to_str())
.ok_or_else(|| anyhow!("Failed to get file name of `{}`", path.display()))
}
pub fn glob(pattern: &str, target: &str) -> bool {
let pat = match ::glob::Pattern::new(pattern) {
Ok(pat) => pat,
Err(_) => return false,
};
pat.matches(target)
}
#[test]
@@ -81,6 +74,10 @@ fn test_glob_key() {
assert!(!glob("abc", "abcd"));
assert!(!glob("a?c", "abbc"));
assert!(!glob("*.log", "log"));
assert!(glob("*.abc-cba", "xyz.abc-cba"));
assert!(glob("*.abc-cba", "123.xyz.abc-cba"));
assert!(glob("*.log", ".log"));
assert!(glob("*.log", "a.log"));
assert!(glob("*/", "abc/"));
assert!(!glob("*/", "abc"));
}

View File

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

View File

@@ -11,13 +11,13 @@ use std::process::{Command, Stdio};
fn assets(server: TestServer) -> Result<(), Error> {
let ver = env!("CARGO_PKG_VERSION");
let resp = reqwest::blocking::get(server.url())?;
let index_js = format!("/__dufs_v{}_index.js", ver);
let index_css = format!("/__dufs_v{}_index.css", ver);
let favicon_ico = format!("/__dufs_v{}_favicon.ico", ver);
let 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)));
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(())
}
@@ -67,13 +67,13 @@ fn asset_ico(server: TestServer) -> Result<(), Error> {
fn assets_with_prefix(#[with(&["--path-prefix", "xyz"])] server: TestServer) -> Result<(), Error> {
let ver = env!("CARGO_PKG_VERSION");
let resp = reqwest::blocking::get(format!("{}xyz/", server.url()))?;
let index_js = format!("/xyz/__dufs_v{}_index.js", ver);
let index_css = format!("/xyz/__dufs_v{}_index.css", ver);
let favicon_ico = format!("/xyz/__dufs_v{}_favicon.ico", ver);
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)));
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(())
}
@@ -108,7 +108,7 @@ fn assets_override(tmpdir: TempDir, port: u16) -> Result<(), Error> {
wait_for_port(port);
let url = format!("http://localhost:{}", port);
let url = format!("http://localhost:{port}");
let resp = reqwest::blocking::get(&url)?;
assert!(resp.text()?.starts_with(&format!(
"/__dufs_v{}_index.js;DATA",

View File

@@ -3,10 +3,11 @@ mod utils;
use diqwest::blocking::WithDigestAuth;
use fixtures::{server, Error, TestServer};
use indexmap::IndexSet;
use rstest::rstest;
#[rstest]
fn no_auth(#[with(&["--auth", "/@user:pass", "-A"])] server: TestServer) -> Result<(), Error> {
fn no_auth(#[with(&["--auth", "user:pass@/:rw", "-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?;
assert_eq!(resp.status(), 401);
assert!(resp.headers().contains_key("www-authenticate"));
@@ -17,7 +18,7 @@ fn no_auth(#[with(&["--auth", "/@user:pass", "-A"])] server: TestServer) -> Resu
}
#[rstest]
fn auth(#[with(&["--auth", "/@user:pass", "-A"])] server: TestServer) -> Result<(), Error> {
fn auth(#[with(&["--auth", "user:pass@/:rw", "-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);
@@ -29,15 +30,39 @@ fn auth(#[with(&["--auth", "/@user:pass", "-A"])] server: TestServer) -> Result<
}
#[rstest]
fn auth_skip(#[with(&["--auth", "/@user:pass@*"])] server: TestServer) -> Result<(), Error> {
fn auth_skip(#[with(&["--auth", "@/"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?;
assert_eq!(resp.status(), 200);
Ok(())
}
#[rstest]
fn auth_skip_on_options_method(
#[with(&["--auth", "user:pass@/:rw"])] server: TestServer,
) -> Result<(), Error> {
let url = format!("{}index.html", server.url());
let resp = fetch!(b"OPTIONS", &url).send()?;
assert_eq!(resp.status(), 200);
Ok(())
}
#[rstest]
fn auth_check(
#[with(&["--auth", "user:pass@/:rw|user2:pass2@/", "-A"])] server: TestServer,
) -> Result<(), Error> {
let url = format!("{}index.html", server.url());
let resp = fetch!(b"WRITEABLE", &url).send()?;
assert_eq!(resp.status(), 401);
let resp = fetch!(b"WRITEABLE", &url).send_with_digest_auth("user2", "pass2")?;
assert_eq!(resp.status(), 403);
let resp = fetch!(b"WRITEABLE", &url).send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 200);
Ok(())
}
#[rstest]
fn auth_readonly(
#[with(&["--auth", "/@user:pass@user2:pass2", "-A"])] server: TestServer,
#[with(&["--auth", "user:pass@/:rw|user2:pass2@/", "-A"])] server: TestServer,
) -> Result<(), Error> {
let url = format!("{}index.html", server.url());
let resp = fetch!(b"GET", &url).send()?;
@@ -48,16 +73,16 @@ fn auth_readonly(
let resp = fetch!(b"PUT", &url)
.body(b"abc".to_vec())
.send_with_digest_auth("user2", "pass2")?;
assert_eq!(resp.status(), 401);
assert_eq!(resp.status(), 403);
Ok(())
}
#[rstest]
fn auth_nest(
#[with(&["--auth", "/@user:pass@user2:pass2", "--auth", "/dira@user3:pass3", "-A"])]
#[with(&["--auth", "user:pass@/:rw|user2:pass2@/", "--auth", "user3:pass3@/dir1:rw", "-A"])]
server: TestServer,
) -> Result<(), Error> {
let url = format!("{}dira/file1", server.url());
let url = format!("{}dir1/file1", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 401);
let resp = fetch!(b"PUT", &url)
@@ -73,7 +98,8 @@ fn auth_nest(
#[rstest]
fn auth_nest_share(
#[with(&["--auth", "/@user:pass@*", "--auth", "/dira@user3:pass3", "-A"])] server: TestServer,
#[with(&["--auth", "@/", "--auth", "user:pass@/:rw", "--auth", "user3:pass3@/dir1:rw", "-A"])]
server: TestServer,
) -> Result<(), Error> {
let url = format!("{}index.html", server.url());
let resp = fetch!(b"GET", &url).send()?;
@@ -82,15 +108,19 @@ fn auth_nest_share(
}
#[rstest]
#[case(server(&["--auth", "user:pass@/:rw", "--auth-method", "basic", "-A"]), "user", "pass")]
#[case(server(&["--auth", "u1:p1@/:rw", "--auth-method", "basic", "-A"]), "u1", "p1")]
fn auth_basic(
#[with(&["--auth", "/@user:pass", "--auth-method", "basic", "-A"])] server: TestServer,
#[case] server: TestServer,
#[case] user: &str,
#[case] pass: &str,
) -> Result<(), Error> {
let url = format!("{}file1", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 401);
let resp = fetch!(b"PUT", &url)
.body(b"abc".to_vec())
.basic_auth("user", Some("pass"))
.basic_auth(user, Some(pass))
.send()?;
assert_eq!(resp.status(), 201);
Ok(())
@@ -98,9 +128,10 @@ fn auth_basic(
#[rstest]
fn auth_webdav_move(
#[with(&["--auth", "/@user:pass@*", "--auth", "/dira@user3:pass3", "-A"])] server: TestServer,
#[with(&["--auth", "user:pass@/:rw", "--auth", "user3:pass3@/dir1:rw", "-A"])]
server: TestServer,
) -> Result<(), Error> {
let origin_url = format!("{}dira/test.html", server.url());
let origin_url = format!("{}dir1/test.html", server.url());
let new_url = format!("{}test2.html", server.url());
let resp = fetch!(b"MOVE", &origin_url)
.header("Destination", &new_url)
@@ -111,9 +142,10 @@ fn auth_webdav_move(
#[rstest]
fn auth_webdav_copy(
#[with(&["--auth", "/@user:pass@*", "--auth", "/dira@user3:pass3", "-A"])] server: TestServer,
#[with(&["--auth", "user:pass@/:rw", "--auth", "user3:pass3@/dir1:rw", "-A"])]
server: TestServer,
) -> Result<(), Error> {
let origin_url = format!("{}dira/test.html", server.url());
let origin_url = format!("{}dir1/test.html", server.url());
let new_url = format!("{}test2.html", server.url());
let resp = fetch!(b"COPY", &origin_url)
.header("Destination", &new_url)
@@ -124,7 +156,7 @@ fn auth_webdav_copy(
#[rstest]
fn auth_path_prefix(
#[with(&["--auth", "/@user:pass", "--path-prefix", "xyz", "-A"])] server: TestServer,
#[with(&["--auth", "user:pass@/:rw", "--path-prefix", "xyz", "-A"])] server: TestServer,
) -> Result<(), Error> {
let url = format!("{}xyz/index.html", server.url());
let resp = fetch!(b"GET", &url).send()?;
@@ -133,3 +165,22 @@ fn auth_path_prefix(
assert_eq!(resp.status(), 200);
Ok(())
}
#[rstest]
fn auth_partial_index(
#[with(&["--auth", "user:pass@/dir1:rw,/dir2:rw", "-A"])] server: TestServer,
) -> Result<(), Error> {
let resp = fetch!(b"GET", server.url()).send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 200);
let paths = utils::retrieve_index_paths(&resp.text()?);
assert_eq!(paths, IndexSet::from(["dir1/".into(), "dir2/".into()]));
let resp = fetch!(b"GET", format!("{}?q={}", server.url(), "test.html"))
.send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 200);
let paths = utils::retrieve_index_paths(&resp.text()?);
assert_eq!(
paths,
IndexSet::from(["dir1/test.html".into(), "dir2/test.html".into()])
);
Ok(())
}

View File

@@ -23,7 +23,7 @@ fn cors(#[with(&["--enable-cors"])] server: TestServer) -> Result<(), Error> {
);
assert_eq!(
resp.headers().get("access-control-allow-headers").unwrap(),
"Authorization,Destination,Range"
"Authorization,Destination,Range,Content-Type"
);
assert_eq!(
resp.headers().get("access-control-expose-headers").unwrap(),

View File

@@ -11,9 +11,12 @@ 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"];
pub static FILES: &[&str] = &["test.txt", "test.html", "index.html", BIN_FILE];
/// Directory names for testing directory don't exist
#[allow(dead_code)]
@@ -33,7 +36,7 @@ pub static DIR_ASSETS: &str = "dir-assets/";
/// Directory names for testing purpose
#[allow(dead_code)]
pub static DIRECTORIES: &[&str] = &["dira/", "dirb/", "dirc/", DIR_NO_INDEX, DIR_GIT, DIR_ASSETS];
pub static DIRECTORIES: &[&str] = &["dir1/", "dir2/", "dir3/", DIR_NO_INDEX, DIR_GIT, DIR_ASSETS];
/// Test fixture which creates a temporary directory with a few files and directories inside.
/// The directories also contain files.
@@ -42,29 +45,61 @@ pub static DIRECTORIES: &[&str] = &["dira/", "dirb/", "dirc/", DIR_NO_INDEX, DIR
pub fn tmpdir() -> TempDir {
let tmpdir = assert_fs::TempDir::new().expect("Couldn't create a temp dir for tests");
for file in FILES {
tmpdir
.child(file)
.write_str(&format!("This is {}", file))
.expect("Couldn't write to file");
if *file == BIN_FILE {
tmpdir.child(file).write_binary(b"bin\0\0123").unwrap();
} else {
tmpdir
.child(file)
.write_str(&format!("This is {file}"))
.unwrap();
}
}
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");
.unwrap();
} else {
for file in FILES {
if *directory == DIR_NO_INDEX && *file == "index.html" {
continue;
}
tmpdir
.child(format!("{}{}", directory, file))
.write_str(&format!("This is {}{}", directory, file))
.expect("Couldn't write to file");
if *file == BIN_FILE {
tmpdir
.child(format!("{directory}{file}"))
.write_binary(b"bin\0\0123")
.unwrap();
} else {
tmpdir
.child(format!("{directory}{file}"))
.write_str(&format!("This is {directory}{file}"))
.unwrap();
}
}
}
}
tmpdir.child("dir4/hidden").touch().unwrap();
tmpdir
.child("content-types/bin.tar")
.write_binary(b"\x7f\x45\x4c\x46\x02\x01\x00\x00")
.unwrap();
tmpdir
.child("content-types/bin")
.write_binary(b"\x7f\x45\x4c\x46\x02\x01\x00\x00")
.unwrap();
tmpdir
.child("content-types/file-utf8.txt")
.write_str("世界")
.unwrap();
tmpdir
.child("content-types/file-gbk.txt")
.write_binary(b"\xca\xc0\xbd\xe7")
.unwrap();
tmpdir
.child("content-types/file")
.write_str("世界")
.unwrap();
tmpdir
}
@@ -108,11 +143,11 @@ where
pub fn wait_for_port(port: u16) {
let start_wait = Instant::now();
while !port_check::is_port_reachable(format!("localhost:{}", port)) {
while !port_check::is_port_reachable(format!("localhost:{port}")) {
sleep(Duration::from_millis(100));
if start_wait.elapsed().as_secs() > 1 {
panic!("timeout waiting for port {}", port);
panic!("timeout waiting for port {port}");
}
}
}

View File

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

View File

@@ -1,8 +1,10 @@
mod fixtures;
mod utils;
use fixtures::{server, Error, TestServer};
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> {
@@ -38,7 +40,7 @@ fn head_dir_404(server: TestServer) -> Result<(), Error> {
}
#[rstest]
fn get_dir_zip(server: TestServer) -> Result<(), Error> {
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!(
@@ -50,7 +52,33 @@ fn get_dir_zip(server: TestServer) -> Result<(), Error> {
}
#[rstest]
fn head_dir_zip(server: TestServer) -> Result<(), Error> {
fn get_dir_json(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?json", server.url()))?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/json"
);
let json: Value = serde_json::from_str(&resp.text().unwrap()).unwrap();
assert!(json["paths"].as_array().is_some());
Ok(())
}
#[rstest]
fn get_dir_simple(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?simple", server.url()))?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"text/html; charset=utf-8"
);
let text = resp.text().unwrap();
assert!(text.split('\n').any(|v| v == "index.html"));
Ok(())
}
#[rstest]
fn head_dir_zip(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"HEAD", format!("{}?zip", server.url())).send()?;
assert_eq!(resp.status(), 200);
assert_eq!(
@@ -76,16 +104,25 @@ fn get_dir_search(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
#[rstest]
fn get_dir_search2(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}?q={}", server.url(), "😀.bin"))?;
let resp = reqwest::blocking::get(format!("{}?q={BIN_FILE}", server.url()))?;
assert_eq!(resp.status(), 200);
let paths = utils::retrieve_index_paths(&resp.text()?);
assert!(!paths.is_empty());
for p in paths {
assert!(p.contains("😀.bin"));
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()?;
@@ -111,7 +148,10 @@ fn empty_search(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
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("content-type").unwrap(),
"text/html; charset=UTF-8"
);
assert_eq!(resp.headers().get("accept-ranges").unwrap(), "bytes");
assert!(resp.headers().contains_key("etag"));
assert!(resp.headers().contains_key("last-modified"));
@@ -124,7 +164,10 @@ fn get_file(server: TestServer) -> Result<(), Error> {
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("content-type").unwrap(),
"text/html; charset=UTF-8"
);
assert_eq!(resp.headers().get("accept-ranges").unwrap(), "bytes");
assert!(resp.headers().contains_key("content-disposition"));
assert!(resp.headers().contains_key("etag"));
@@ -141,6 +184,35 @@ fn get_file_404(server: TestServer) -> Result<(), Error> {
Ok(())
}
#[rstest]
fn get_file_emoji_path(server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}{BIN_FILE}", server.url()))?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers().get("content-disposition").unwrap(),
"inline; filename=\"😀.bin\"; filename*=UTF-8''%F0%9F%98%80.bin"
);
Ok(())
}
#[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()?;
@@ -182,7 +254,7 @@ fn put_file_create_dir(#[with(&["-A"])] server: TestServer) -> Result<(), Error>
#[rstest]
fn put_file_conflict_dir(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let url = format!("{}dira", server.url());
let url = format!("{}dir1", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 403);
Ok(())
@@ -204,3 +276,33 @@ fn delete_file_404(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
assert_eq!(resp.status(), 404);
Ok(())
}
#[rstest]
fn get_file_content_type(server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(format!("{}content-types/bin.tar", server.url()))?;
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/x-tar"
);
let resp = reqwest::blocking::get(format!("{}content-types/bin", server.url()))?;
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/octet-stream"
);
let resp = reqwest::blocking::get(format!("{}content-types/file-utf8.txt", server.url()))?;
assert_eq!(
resp.headers().get("content-type").unwrap(),
"text/plain; charset=UTF-8"
);
let resp = reqwest::blocking::get(format!("{}content-types/file-gbk.txt", server.url()))?;
assert_eq!(
resp.headers().get("content-type").unwrap(),
"text/plain; charset=GBK"
);
let resp = reqwest::blocking::get(format!("{}content-types/file", server.url()))?;
assert_eq!(
resp.headers().get("content-type").unwrap(),
"text/plain; charset=UTF-8"
);
Ok(())
}

View File

@@ -11,8 +11,8 @@ 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)]
#[case(&["-a", "user:pass@/:rw", "--log-format", "$remote_user"], false)]
#[case(&["-a", "user:pass@/:rw", "--log-format", "$remote_user", "--auth-method", "basic"], true)]
fn log_remote_user(
tmpdir: TempDir,
port: u16,
@@ -31,7 +31,7 @@ fn log_remote_user(
let stdout = child.stdout.as_mut().expect("Failed to get stdout");
let req = fetch!(b"GET", &format!("http://localhost:{}", port));
let req = fetch!(b"GET", &format!("http://localhost:{port}"));
let resp = if is_basic {
req.basic_auth("user", Some("pass")).send()?
@@ -66,7 +66,7 @@ fn no_log(tmpdir: TempDir, port: u16, #[case] args: &[&str]) -> Result<(), Error
let stdout = child.stdout.as_mut().expect("Failed to get stdout");
let resp = fetch!(b"GET", &format!("http://localhost:{}", port)).send()?;
let resp = fetch!(b"GET", &format!("http://localhost:{port}")).send()?;
assert_eq!(resp.status(), 200);
let mut buf = [0; 1000];

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,7 +37,7 @@ fn wrong_path_cert() -> Result<(), Error> {
.args(["--tls-cert", "wrong", "--tls-key", "tests/data/key.pem"])
.assert()
.failure()
.stderr(contains("error: Failed to access `wrong`"));
.stderr(contains("Failed to access `wrong`"));
Ok(())
}
@@ -49,7 +49,7 @@ fn wrong_path_key() -> Result<(), Error> {
.args(["--tls-cert", "tests/data/cert.pem", "--tls-key", "wrong"])
.assert()
.failure()
.stderr(contains("error: Failed to access `wrong`"));
.stderr(contains("Failed to access `wrong`"));
Ok(())
}

View File

@@ -25,8 +25,32 @@ macro_rules! fetch {
}
#[allow(dead_code)]
pub fn retrieve_index_paths(index: &str) -> IndexSet<String> {
retrieve_index_paths_impl(index).unwrap_or_default()
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)]
@@ -35,24 +59,10 @@ pub fn encode_uri(v: &str) -> String {
parts.join("/")
}
fn retrieve_index_paths_impl(index: &str) -> Option<IndexSet<String>> {
let lines: Vec<&str> = index.lines().collect();
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().ok()?;
let paths = value
.get("paths")?
.as_array()?
.iter()
.flat_map(|v| {
let name = v.get("name")?.as_str()?;
let path_type = v.get("path_type")?.as_str()?;
if path_type.ends_with("Dir") {
Some(format!("{}/", name))
} else {
Some(name.to_owned())
}
})
.collect();
Some(paths)
let value: Value = line[line_col..].parse().unwrap();
Some(value)
}

View File

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