Compare commits

..

18 Commits

Author SHA1 Message Date
sigoden
4f3a8d275b chore: release v0.34.1 2023-06-02 19:44:35 +08:00
sigoden
9c412f4276 refactor: ui checkAuth (#226) 2023-06-02 19:35:30 +08:00
sigoden
27c269d6a0 fix: allow all cors headers and methods (#225) 2023-06-02 19:07:43 +08:00
sigoden
57b4a74279 fix: auth logic (#224) 2023-06-02 18:38:59 +08:00
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
15 changed files with 1165 additions and 772 deletions

View File

@@ -2,6 +2,36 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [0.34.1] - 2023-06-02
### Bug Fixes
- Auth logic ([#224](https://github.com/sigoden/dufs/issues/224))
- Allow all cors headers and methods ([#225](https://github.com/sigoden/dufs/issues/225))
### Refactor
- Ui checkAuth ([#226](https://github.com/sigoden/dufs/issues/226))
## [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 ## [0.33.0] - 2023-03-17
### Bug Fixes ### Bug Fixes

812
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "dufs" name = "dufs"
version = "0.33.0" version = "0.34.1"
edition = "2021" edition = "2021"
authors = ["sigoden <sigoden@gmail.com>"] authors = ["sigoden <sigoden@gmail.com>"]
description = "Dufs is a distinctive utility file server" description = "Dufs is a distinctive utility file server"
@@ -15,20 +15,20 @@ clap = { version = "4", features = ["wrap_help", "env"] }
clap_complete = "4" clap_complete = "4"
chrono = "0.4" chrono = "0.4"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util", "signal"]} tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util", "signal"]}
tokio-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"] } hyper = { version = "0.14", features = ["http1", "server", "tcp", "stream"] }
percent-encoding = "2.1" percent-encoding = "2.1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
futures = "0.3" futures = "0.3"
base64 = "0.21" base64 = "0.21"
async_zip = { version = "0.0.12", default-features = false, features = ["deflate"] } async_zip = { version = "0.0.15", default-features = false, features = ["deflate", "chrono", "tokio"] }
headers = "0.3" headers = "0.3"
mime_guess = "2.0" mime_guess = "2.0"
if-addrs = "0.10.1" if-addrs = "0.10.1"
rustls = { version = "0.20", default-features = false, features = ["tls12"], optional = true } rustls = { version = "0.21", default-features = false, features = ["tls12"], optional = true }
rustls-pemfile = { version = "1", 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" md5 = "0.7"
lazy_static = "1.4" lazy_static = "1.4"
uuid = { version = "1.1", features = ["v4", "fast-rng"] } uuid = { version = "1.1", features = ["v4", "fast-rng"] }
@@ -44,6 +44,7 @@ content_inspector = "0.2"
anyhow = "1.0" anyhow = "1.0"
chardetng = "0.1" chardetng = "0.1"
glob = "0.3.1" glob = "0.3.1"
indexmap = "1.9"
[features] [features]
default = ["tls"] default = ["tls"]
@@ -54,12 +55,11 @@ assert_cmd = "2"
reqwest = { version = "0.11", features = ["blocking", "multipart", "rustls-tls"], default-features = false } reqwest = { version = "0.11", features = ["blocking", "multipart", "rustls-tls"], default-features = false }
assert_fs = "1" assert_fs = "1"
port_check = "0.1" port_check = "0.1"
rstest = "0.16" rstest = "0.17"
regex = "1" regex = "1"
url = "2" url = "2"
diqwest = { version = "1", features = ["blocking"] } diqwest = { version = "1", features = ["blocking"] }
predicates = "3" predicates = "3"
indexmap = "1.9"
[profile.release] [profile.release]
lto = true lto = true

View File

@@ -14,7 +14,7 @@ Dufs is a distinctive utility file server that supports static serving, uploadin
- Upload files and folders (Drag & Drop) - Upload files and folders (Drag & Drop)
- Create/Edit/Search files - Create/Edit/Search files
- Partial responses (Parallel/Resume download) - Partial responses (Parallel/Resume download)
- Path level access control - Access control
- Support https - Support https
- Support webdav - Support webdav
- Easy to use with curl - Easy to use with curl
@@ -48,17 +48,17 @@ Download from [Github Releases](https://github.com/sigoden/dufs/releases), unzip
``` ```
Dufs is a distinctive utility file server - https://github.com/sigoden/dufs Dufs is a distinctive utility file server - https://github.com/sigoden/dufs
Usage: dufs [OPTIONS] [root] Usage: dufs [OPTIONS] [serve_path]
Arguments: Arguments:
[root] Specific path to serve [default: .] [serve_path] Specific path to serve [default: .]
Options: Options:
-b, --bind <addrs> Specify bind address or unix socket -b, --bind <addrs> Specify bind address or unix socket
-p, --port <port> Specify port to listen on [default: 5000] -p, --port <port> Specify port to listen on [default: 5000]
--path-prefix <path> Specify a path prefix --path-prefix <path> Specify a path prefix
--hidden <value> Hide paths from directory listings, separated by `,` --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] --auth-method <value> Select auth method [default: digest] [possible values: basic, digest]
-A, --allow-all Allow all operations -A, --allow-all Allow all operations
--allow-upload Allow upload files/folders --allow-upload Allow upload files/folders
@@ -75,8 +75,8 @@ Options:
--tls-key <path> Path to the SSL/TLS certificate's private key --tls-key <path> Path to the SSL/TLS certificate's private key
--log-format <format> Customize http log format --log-format <format> Customize http log format
--completions <shell> Print shell completion script for <shell> [possible values: bash, elvish, fish, powershell, zsh] --completions <shell> Print shell completion script for <shell> [possible values: bash, elvish, fish, powershell, zsh]
-h, --help Print help information -h, --help Print help
-V, --version Print version information -V, --version Print version
``` ```
## Examples ## Examples
@@ -203,41 +203,45 @@ curl --user user:pass http://192.168.8.10:5000/file # basic auth
### Access Control ### 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 [user:pass]@path[:rw][,path[:rw]...][|...]
dufs -a <path>@<readwrite>@<readonly>
dufs -a <path>@<readwrite>@*
``` ```
1: Multiple rules are separated by "|"
- `<path>`: Protected url path 2: User and pass are the account name and password, if omitted, it is an anonymous user
- `<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. 3: One rule can set multiple paths, separated by ","
- `<readonly>`: Account with readonly permissions. The permissions are search/view/download if dufs allow search, otherwise view/download.. 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. `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. `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. 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. `user1` has all permissions for `/user1/*` path.
`user2` has all permissions for `/user2*` 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. Since dufs only allows viewing/downloading, `admin` can only view/download files.
@@ -302,7 +306,29 @@ dufs --log-format '$remote_addr $remote_user "$request" $status' -a /@admin:admi
All options can be set using environment variables prefixed with `DUFS_`. All options can be set using environment variables prefixed with `DUFS_`.
`dufs --port 8080 --allow-all` is equal to `DUFS_PORT=8080 DUFS_ALLOW_ALL=true dufs`. ```
[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 ### Customize UI

View File

@@ -148,7 +148,7 @@ body {
} }
.paths-table .cell-actions { .paths-table .cell-actions {
width: 75px; width: 90px;
display: flex; display: flex;
padding-left: 0.6em; padding-left: 0.6em;
} }

View File

@@ -22,6 +22,8 @@
* @property {string} editable * @property {string} editable
*/ */
var DUFS_MAX_UPLOADINGS = 1;
/** /**
* @type {DATA} DATA * @type {DATA} DATA
*/ */
@@ -103,7 +105,7 @@ function ready() {
setupIndexPage(); setupIndexPage();
} else if (DATA.kind == "Edit") { } else if (DATA.kind == "Edit") {
document.title = `Edit of ${DATA.href} - Dufs`; document.title = `Edit ${DATA.href} - Dufs`;
document.querySelector(".editor-page").classList.remove("hidden");; document.querySelector(".editor-page").classList.remove("hidden");;
setupEditPage(); setupEditPage();
@@ -152,7 +154,6 @@ class Uploader {
} }
ajax() { ajax() {
Uploader.runnings += 1;
const url = newUrl(this.name); const url = newUrl(this.name);
this.lastUptime = Date.now(); this.lastUptime = Date.now();
const ajax = new XMLHttpRequest(); const ajax = new XMLHttpRequest();
@@ -187,13 +188,13 @@ class Uploader {
complete() { complete() {
this.$uploadStatus.innerHTML = ``; this.$uploadStatus.innerHTML = ``;
Uploader.runnings -= 1; Uploader.runnings--;
Uploader.runQueue(); Uploader.runQueue();
} }
fail() { fail() {
this.$uploadStatus.innerHTML = ``; this.$uploadStatus.innerHTML = ``;
Uploader.runnings -= 1; Uploader.runnings--;
Uploader.runQueue(); Uploader.runQueue();
} }
} }
@@ -211,12 +212,17 @@ Uploader.queues = [];
Uploader.runQueue = async () => { Uploader.runQueue = async () => {
if (Uploader.runnings > 2) return; if (Uploader.runnings >= DUFS_MAX_UPLOADINGS) return;
if (Uploader.queues.length == 0) return;
Uploader.runnings++;
let uploader = Uploader.queues.shift(); let uploader = Uploader.queues.shift();
if (!uploader) return;
if (!Uploader.auth) { if (!Uploader.auth) {
Uploader.auth = true; Uploader.auth = true;
await login(); try {
await checkAuth()
} catch {
Uploader.auth = false;
}
} }
uploader.ajax(); uploader.ajax();
} }
@@ -262,7 +268,7 @@ function setupIndexPage() {
if (DATA.allow_archive) { if (DATA.allow_archive) {
const $download = document.querySelector(".download"); const $download = document.querySelector(".download");
$download.href = baseUrl() + "?zip"; $download.href = baseUrl() + "?zip";
$download.title = "Download folder as div .zip file"; $download.title = "Download folder as a .zip file";
$download.classList.remove("hidden"); $download.classList.remove("hidden");
} }
@@ -375,9 +381,8 @@ function addPath(file, index) {
} }
if (DATA.allow_delete) { if (DATA.allow_delete) {
if (DATA.allow_upload) { if (DATA.allow_upload) {
if (isDir) {
actionMove = `<div onclick="movePath(${index})" class="action-btn" id="moveBtn${index}" title="Move to new path">${ICONS.move}</div>`; actionMove = `<div onclick="movePath(${index})" class="action-btn" id="moveBtn${index}" title="Move to new path">${ICONS.move}</div>`;
} else { if (!isDir) {
actionEdit = `<a class="action-btn" title="Edit file" target="_blank" href="${url}?edit">${ICONS.edit}</a>`; actionEdit = `<a class="action-btn" title="Edit file" target="_blank" href="${url}?edit">${ICONS.edit}</a>`;
} }
} }
@@ -388,8 +393,8 @@ function addPath(file, index) {
<td class="cell-actions"> <td class="cell-actions">
${actionDownload} ${actionDownload}
${actionMove} ${actionMove}
${actionEdit}
${actionDelete} ${actionDelete}
${actionEdit}
</td>` </td>`
$pathsTableBody.insertAdjacentHTML("beforeend", ` $pathsTableBody.insertAdjacentHTML("beforeend", `
@@ -437,7 +442,13 @@ function setupAuth() {
} else { } else {
const $loginBtn = document.querySelector(".login-btn"); const $loginBtn = document.querySelector(".login-btn");
$loginBtn.classList.remove("hidden"); $loginBtn.classList.remove("hidden");
$loginBtn.addEventListener("click", () => login(true)); $loginBtn.addEventListener("click", async () => {
try {
await checkAuth()
} catch (err) {
alert(err.message);
}
});
} }
} }
@@ -522,7 +533,7 @@ async function setupEditPage() {
$notEditable.insertAdjacentHTML("afterend", `<iframe src="${url}" sandbox width="100%" height="${window.innerHeight - 100}px"></iframe>`) $notEditable.insertAdjacentHTML("afterend", `<iframe src="${url}" sandbox width="100%" height="${window.innerHeight - 100}px"></iframe>`)
} else { } else {
$notEditable.classList.remove("hidden"); $notEditable.classList.remove("hidden");
$notEditable.textContent = "Cannot edit because it is too large or binary."; $notEditable.textContent = "Cannot edit because file is too large or binary.";
} }
return; return;
} }
@@ -535,8 +546,15 @@ async function setupEditPage() {
try { try {
const res = await fetch(baseUrl()); const res = await fetch(baseUrl());
await assertResOK(res); await assertResOK(res);
const text = await res.text(); const encoding = getEncoding(res.headers.get("content-type"));
$editor.value = text; 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) { } catch (err) {
alert(`Failed get file, ${err.message}`); alert(`Failed get file, ${err.message}`);
} }
@@ -564,7 +582,7 @@ async function deletePath(index) {
async function doDeletePath(name, url, cb) { async function doDeletePath(name, url, cb) {
if (!confirm(`Delete \`${name}\`?`)) return; if (!confirm(`Delete \`${name}\`?`)) return;
try { try {
await login(); await checkAuth();
const res = await fetch(url, { const res = await fetch(url, {
method: "DELETE", method: "DELETE",
}); });
@@ -604,12 +622,12 @@ async function doMovePath(fileUrl) {
const newFileUrl = fileUrlObj.origin + prefix + newPath.split("/").map(encodeURIComponent).join("/"); const newFileUrl = fileUrlObj.origin + prefix + newPath.split("/").map(encodeURIComponent).join("/");
try { try {
await login(); await checkAuth();
const res1 = await fetch(newFileUrl, { const res1 = await fetch(newFileUrl, {
method: "HEAD", method: "HEAD",
}); });
if (res1.status === 200) { if (res1.status === 200) {
if (!confirm("Override exsis file?")) { if (!confirm("Override existing file?")) {
return; return;
} }
} }
@@ -636,14 +654,14 @@ async function saveChange() {
method: "PUT", method: "PUT",
body: $editor.value, body: $editor.value,
}); });
location.reload();
} catch (err) { } catch (err) {
alert(`Failed to save file, ${err.message}`); alert(`Failed to save file, ${err.message}`);
} }
} }
async function login(alert = false) { async function checkAuth() {
if (!DATA.auth) return; if (!DATA.auth) return;
try {
const res = await fetch(baseUrl(), { const res = await fetch(baseUrl(), {
method: "WRITEABLE", method: "WRITEABLE",
}); });
@@ -651,14 +669,6 @@ async function login(alert = false) {
document.querySelector(".login-btn").classList.add("hidden"); document.querySelector(".login-btn").classList.add("hidden");
$userBtn.classList.remove("hidden"); $userBtn.classList.remove("hidden");
$userBtn.title = ""; $userBtn.title = "";
} catch (err) {
let message = `Cannot login, ${err.message}`;
if (alert) {
alert(message);
} else {
throw new Error(message);
}
}
} }
/** /**
@@ -668,7 +678,7 @@ async function login(alert = false) {
async function createFolder(name) { async function createFolder(name) {
const url = newUrl(name); const url = newUrl(name);
try { try {
await login(); await checkAuth();
const res = await fetch(url, { const res = await fetch(url, {
method: "MKCOL", method: "MKCOL",
}); });
@@ -682,7 +692,7 @@ async function createFolder(name) {
async function createFile(name) { async function createFile(name) {
const url = newUrl(name); const url = newUrl(name);
try { try {
await login(); await checkAuth();
const res = await fetch(url, { const res = await fetch(url, {
method: "PUT", method: "PUT",
body: "", body: "",
@@ -766,7 +776,11 @@ function formatSize(size) {
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
if (size == 0) return [0, "B"]; if (size == 0) return [0, "B"];
const i = parseInt(Math.floor(Math.log(size) / Math.log(1024))); const i = parseInt(Math.floor(Math.log(size) / Math.log(1024)));
return [Math.round(size / Math.pow(1024, i), 2), sizes[i]]; ratio = 1
if (i >= 3) {
ratio = 100
}
return [Math.round(size * ratio / Math.pow(1024, i), 2) / ratio, sizes[i]];
} }
function formatDuration(seconds) { function formatDuration(seconds) {
@@ -793,6 +807,17 @@ function encodedStr(rawStr) {
async function assertResOK(res) { async function assertResOK(res) {
if (!(res.status >= 200 && res.status < 300)) { if (!(res.status >= 200 && res.status < 300)) {
throw new Error(await res.text()) throw new Error(await res.text() || `Invalid status ${res.status}`);
} }
} }
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

@@ -25,8 +25,8 @@ pub fn build_cli() -> Command {
env!("CARGO_PKG_REPOSITORY") env!("CARGO_PKG_REPOSITORY")
)) ))
.arg( .arg(
Arg::new("root") Arg::new("serve_path")
.env("DUFS_ROOT") .env("DUFS_SERVE_PATH")
.hide_env(true) .hide_env(true)
.default_value(".") .default_value(".")
.value_parser(value_parser!(PathBuf)) .value_parser(value_parser!(PathBuf))
@@ -76,9 +76,9 @@ pub fn build_cli() -> Command {
.hide_env(true) .hide_env(true)
.short('a') .short('a')
.long("auth") .long("auth")
.help("Add auth for path") .help("Add auth role")
.action(ArgAction::Append) .action(ArgAction::Append)
.value_delimiter(',') .value_delimiter('|')
.value_name("rules"), .value_name("rules"),
) )
.arg( .arg(
@@ -264,7 +264,7 @@ impl Args {
.map(|bind| bind.map(|v| v.as_str()).collect()) .map(|bind| bind.map(|v| v.as_str()).collect())
.unwrap_or_else(|| vec!["0.0.0.0", "::"]); .unwrap_or_else(|| vec!["0.0.0.0", "::"]);
let addrs: Vec<BindAddr> = Args::parse_addrs(&addrs)?; 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_is_file = path.metadata()?.is_file();
let path_prefix = matches let path_prefix = matches
.get_one::<String>("path-prefix") .get_one::<String>("path-prefix")
@@ -288,7 +288,7 @@ impl Args {
"basic" => AuthMethod::Basic, "basic" => AuthMethod::Basic,
_ => AuthMethod::Digest, _ => 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_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_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_search = matches.get_flag("allow-all") || matches.get_flag("allow-search");

View File

@@ -2,12 +2,16 @@ use anyhow::{anyhow, bail, Result};
use base64::{engine::general_purpose, Engine as _}; use base64::{engine::general_purpose, Engine as _};
use headers::HeaderValue; use headers::HeaderValue;
use hyper::Method; use hyper::Method;
use indexmap::IndexMap;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use md5::Context; use md5::Context;
use std::collections::HashMap; use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use uuid::Uuid; use uuid::Uuid;
use crate::utils::{encode_uri, unix_now}; use crate::utils::unix_now;
const REALM: &str = "DUFS"; const REALM: &str = "DUFS";
const DIGEST_AUTH_TIMEOUT: u32 = 86400; const DIGEST_AUTH_TIMEOUT: u32 = 86400;
@@ -21,57 +25,63 @@ lazy_static! {
}; };
} }
#[derive(Debug)] #[derive(Debug, Default)]
pub struct AccessControl { pub struct AccessControl {
rules: HashMap<String, PathControl>, users: IndexMap<String, (String, AccessPaths)>,
} anony: Option<AccessPaths>,
#[derive(Debug)]
pub struct PathControl {
readwrite: Account,
readonly: Option<Account>,
share: bool,
} }
impl AccessControl { impl AccessControl {
pub fn new(raw_rules: &[&str], uri_prefix: &str) -> Result<Self> { pub fn new(raw_rules: &[&str]) -> Result<Self> {
let mut rules = HashMap::default();
if raw_rules.is_empty() { 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 { for rule in raw_rules {
let parts: Vec<&str> = rule.split('@').collect(); let (user, list) = rule.split_once('@').ok_or_else(|| create_err(rule))?;
let create_err = || anyhow!("Invalid auth `{rule}`"); if user.is_empty() && anony.is_some() {
match parts.as_slice() { bail!("Invalid auth, duplicate anonymous rules");
[path, readwrite] => {
let control = PathControl {
readwrite: Account::new(readwrite).ok_or_else(create_err)?,
readonly: None,
share: false,
};
rules.insert(sanitize_path(path, uri_prefix), control);
} }
[path, readwrite, readonly] => { let mut paths = AccessPaths::default();
let (readonly, share) = if *readonly == "*" { for value in list.trim_matches(',').split(',') {
(None, true) 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));
}
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));
}
users.insert(user.to_string(), (pass.to_string(), paths));
} else { } else {
(Some(Account::new(readonly).ok_or_else(create_err)?), false) return Err(create_err(rule));
};
let control = PathControl {
readwrite: Account::new(readwrite).ok_or_else(create_err)?,
readonly,
share,
};
rules.insert(sanitize_path(path, uri_prefix), control);
}
_ => return Err(create_err()),
} }
} }
Ok(Self { rules }) 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 { pub fn valid(&self) -> bool {
!self.rules.is_empty() !self.users.is_empty() || self.anony.is_some()
} }
pub fn guard( pub fn guard(
@@ -80,81 +90,157 @@ impl AccessControl {
method: &Method, method: &Method,
authorization: Option<&HeaderValue>, authorization: Option<&HeaderValue>,
auth_method: AuthMethod, auth_method: AuthMethod,
) -> GuardType { ) -> (Option<String>, Option<AccessPaths>) {
if self.rules.is_empty() { if let Some(authorization) = authorization {
return GuardType::ReadWrite; 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
.check(authorization, method.as_str(), &user, pass)
.is_some()
{
return (Some(user), paths.find(path, !is_readonly_method(method)));
} else {
return (None, None);
}
}
}
} }
if method == Method::OPTIONS { if method == Method::OPTIONS {
return GuardType::ReadOnly; return (None, Some(AccessPaths::new(AccessPerm::ReadOnly)));
} }
let mut controls = vec![]; if let Some(paths) = self.anony.as_ref() {
for path in walk_path(path) { return (None, paths.find(path, !is_readonly_method(method)));
if let Some(control) = self.rules.get(path) {
controls.push(control);
if let Some(authorization) = authorization {
let Account { user, pass } = &control.readwrite;
if auth_method
.validate(authorization, method.as_str(), user, pass)
.is_some()
{
return GuardType::ReadWrite;
} }
}
} (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;
}
}
}
}
}
GuardType::Reject
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Default, Clone, PartialEq, Eq)]
pub enum GuardType { pub struct AccessPaths {
Reject, perm: AccessPerm,
ReadWrite, children: IndexMap<String, AccessPaths>,
ReadOnly,
} }
impl GuardType { impl AccessPaths {
pub fn is_reject(&self) -> bool { pub fn new(perm: AccessPerm) -> Self {
*self == GuardType::Reject Self {
perm,
..Default::default()
} }
} }
fn sanitize_path(path: &str, uri_prefix: &str) -> String { pub fn perm(&self) -> AccessPerm {
let new_path = match (uri_prefix, path) { self.perm
("/", "/") => "/".into(), }
(_, "/") => uri_prefix.trim_end_matches('/').into(),
_ => format!("{}{}", uri_prefix, path.trim_matches('/')), 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));
}
}
}; };
encode_uri(&new_path) child.find_impl(&parts[1..], perm)
} }
fn walk_path(path: &str) -> impl Iterator<Item = &str> { pub fn child_paths(&self) -> Vec<&String> {
let mut idx = 0; self.children.keys().collect()
path.split('/').enumerate().map(move |(i, part)| { }
let end = if i == 0 { 1 } else { idx + part.len() + i };
let value = &path[..end]; pub fn leaf_paths(&self, base: &Path) -> Vec<PathBuf> {
idx += part.len(); if !self.perm().indexonly() {
value 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,
ReadOnly,
ReadWrite,
}
impl AccessPerm {
pub fn readwrite(&self) -> bool {
self == &AccessPerm::ReadWrite
}
pub fn indexonly(&self) -> bool {
self == &AccessPerm::IndexOnly
}
} }
fn is_readonly_method(method: &Method) -> bool { fn is_readonly_method(method: &Method) -> bool {
@@ -164,29 +250,6 @@ fn is_readonly_method(method: &Method) -> bool {
|| method.as_str() == "PROPFIND" || 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)] #[derive(Debug, Clone)]
pub enum AuthMethod { pub enum AuthMethod {
Basic, Basic,
@@ -208,6 +271,7 @@ impl AuthMethod {
} }
} }
} }
pub fn get_user(&self, authorization: &HeaderValue) -> Option<String> { pub fn get_user(&self, authorization: &HeaderValue) -> Option<String> {
match self { match self {
AuthMethod::Basic => { AuthMethod::Basic => {
@@ -227,7 +291,8 @@ impl AuthMethod {
} }
} }
} }
pub fn validate(
fn check(
&self, &self,
authorization: &HeaderValue, authorization: &HeaderValue,
method: &str, method: &str,
@@ -245,12 +310,7 @@ impl AuthMethod {
return None; return None;
} }
let mut h = Context::new(); if parts[1] == auth_pass {
h.consume(format!("{}:{}:{}", parts[0], REALM, parts[1]).as_bytes());
let http_pass = format!("{:x}", h.compute());
if http_pass == auth_pass {
return Some(()); return Some(());
} }
@@ -273,6 +333,11 @@ impl AuthMethod {
if auth_user != username { if auth_user != username {
return None; 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(); let mut ha = Context::new();
ha.consume(method); ha.consume(method);
ha.consume(b":"); ha.consume(b":");
@@ -285,7 +350,7 @@ impl AuthMethod {
if qop == &b"auth".as_ref() || qop == &b"auth-int".as_ref() { if qop == &b"auth".as_ref() || qop == &b"auth-int".as_ref() {
correct_response = Some({ correct_response = Some({
let mut c = Context::new(); let mut c = Context::new();
c.consume(auth_pass); c.consume(&auth_pass);
c.consume(b":"); c.consume(b":");
c.consume(nonce); c.consume(nonce);
c.consume(b":"); c.consume(b":");
@@ -308,7 +373,7 @@ impl AuthMethod {
Some(r) => r, Some(r) => r,
None => { None => {
let mut c = Context::new(); let mut c = Context::new();
c.consume(auth_pass); c.consume(&auth_pass);
c.consume(b":"); c.consume(b":");
c.consume(nonce); c.consume(nonce);
c.consume(b":"); c.consume(b":");
@@ -317,7 +382,6 @@ impl AuthMethod {
} }
}; };
if correct_response.as_bytes() == *user_response { if correct_response.as_bytes() == *user_response {
// grant access
return Some(()); return Some(());
} }
} }
@@ -417,3 +481,54 @@ fn create_nonce() -> Result<String> {
let n = format!("{:08x}{:032x}", secs, h.compute()); let n = format!("{:08x}{:032x}", secs, h.compute());
Ok(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());
}
#[test]
fn test_access_paths_perm() {
let mut paths = AccessPaths::default();
assert_eq!(paths.perm(), AccessPerm::IndexOnly);
paths.set_perm(AccessPerm::ReadOnly);
assert_eq!(paths.perm(), AccessPerm::ReadOnly);
paths.set_perm(AccessPerm::ReadWrite);
assert_eq!(paths.perm(), AccessPerm::ReadWrite);
paths.set_perm(AccessPerm::ReadOnly);
assert_eq!(paths.perm(), AccessPerm::ReadWrite);
}
}

View File

@@ -1,18 +1,23 @@
#![allow(clippy::too_many_arguments)]
use crate::auth::AccessPaths;
use crate::streamer::Streamer; use crate::streamer::Streamer;
use crate::utils::{decode_uri, encode_uri, get_file_name, glob, try_get_file_name}; use crate::utils::{
decode_uri, encode_uri, get_file_mtime_and_mode, get_file_name, glob, try_get_file_name,
};
use crate::Args; use crate::Args;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use walkdir::WalkDir; use walkdir::WalkDir;
use xml::escape::escape_str_pcdata; use xml::escape::escape_str_pcdata;
use async_zip::write::ZipFileWriter; use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipEntryBuilder}; use async_zip::{Compression, ZipDateTime, ZipEntryBuilder};
use chrono::{LocalResult, TimeZone, Utc}; use chrono::{LocalResult, TimeZone, Utc};
use futures::TryStreamExt; use futures::TryStreamExt;
use headers::{ use headers::{
AcceptRanges, AccessControlAllowCredentials, AccessControlAllowOrigin, Connection, AcceptRanges, AccessControlAllowCredentials, AccessControlAllowOrigin, ContentLength,
ContentLength, ContentType, ETag, HeaderMap, HeaderMapExt, IfModifiedSince, IfNoneMatch, ContentType, ETag, HeaderMap, HeaderMapExt, IfModifiedSince, IfNoneMatch, IfRange,
IfRange, LastModified, Range, LastModified, Range,
}; };
use hyper::header::{ use hyper::header::{
HeaderValue, AUTHORIZATION, CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, HeaderValue, AUTHORIZATION, CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE,
@@ -32,6 +37,7 @@ use std::time::SystemTime;
use tokio::fs::File; use tokio::fs::File;
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWrite}; use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWrite};
use tokio::{fs, io}; use tokio::{fs, io};
use tokio_util::compat::FuturesAsyncWriteCompatExt;
use tokio_util::io::StreamReader; use tokio_util::io::StreamReader;
use uuid::Uuid; use uuid::Uuid;
@@ -134,16 +140,32 @@ impl Server {
} }
let authorization = headers.get(AUTHORIZATION); let authorization = headers.get(AUTHORIZATION);
let guard_type = self.args.auth.guard( let relative_path = match self.resolve_path(req_path) {
req_path, Some(v) => v,
None => {
status_forbid(&mut res);
return Ok(res);
}
};
let guard = self.args.auth.guard(
&relative_path,
&method, &method,
authorization, authorization,
self.args.auth_method.clone(), self.args.auth_method.clone(),
); );
if guard_type.is_reject() {
let (user, access_paths) = match guard {
(None, None) => {
self.auth_reject(&mut res)?; self.auth_reject(&mut res)?;
return Ok(res); return Ok(res);
} }
(Some(_), None) => {
status_forbid(&mut res);
return Ok(res);
}
(x, Some(y)) => (x, y),
};
let query = req.uri().query().unwrap_or_default(); let query = req.uri().query().unwrap_or_default();
let query_params: HashMap<String, String> = form_urlencoded::parse(query.as_bytes()) let query_params: HashMap<String, String> = form_urlencoded::parse(query.as_bytes())
@@ -169,8 +191,7 @@ impl Server {
} }
return Ok(res); return Ok(res);
} }
let path = match self.join_path(&relative_path) {
let path = match self.extract_path(req_path) {
Some(v) => v, Some(v) => v,
None => { None => {
status_forbid(&mut res); status_forbid(&mut res);
@@ -207,31 +228,38 @@ impl Server {
status_not_found(&mut res); status_not_found(&mut res);
return Ok(res); return Ok(res);
} }
self.handle_zip_dir(path, head_only, &mut res).await?; self.handle_zip_dir(path, head_only, access_paths, &mut res)
.await?;
} else if allow_search && query_params.contains_key("q") { } else if allow_search && query_params.contains_key("q") {
let user = self.retrieve_user(authorization); self.handle_search_dir(
self.handle_search_dir(path, &query_params, head_only, user, &mut res) path,
&query_params,
head_only,
user,
access_paths,
&mut res,
)
.await?; .await?;
} else { } else {
let user = self.retrieve_user(authorization);
self.handle_render_index( self.handle_render_index(
path, path,
&query_params, &query_params,
headers, headers,
head_only, head_only,
user, user,
access_paths,
&mut res, &mut res,
) )
.await?; .await?;
} }
} else if render_index || render_spa { } else if render_index || render_spa {
let user = self.retrieve_user(authorization);
self.handle_render_index( self.handle_render_index(
path, path,
&query_params, &query_params,
headers, headers,
head_only, head_only,
user, user,
access_paths,
&mut res, &mut res,
) )
.await?; .await?;
@@ -240,19 +268,32 @@ impl Server {
status_not_found(&mut res); status_not_found(&mut res);
return Ok(res); return Ok(res);
} }
self.handle_zip_dir(path, head_only, &mut res).await?; self.handle_zip_dir(path, head_only, access_paths, &mut res)
.await?;
} else if allow_search && query_params.contains_key("q") { } else if allow_search && query_params.contains_key("q") {
let user = self.retrieve_user(authorization); self.handle_search_dir(
self.handle_search_dir(path, &query_params, head_only, user, &mut res) path,
&query_params,
head_only,
user,
access_paths,
&mut res,
)
.await?; .await?;
} else { } else {
let user = self.retrieve_user(authorization); self.handle_ls_dir(
self.handle_ls_dir(path, true, &query_params, head_only, user, &mut res) path,
true,
&query_params,
head_only,
user,
access_paths,
&mut res,
)
.await?; .await?;
} }
} else if is_file { } else if is_file {
if query_params.contains_key("edit") { if query_params.contains_key("edit") {
let user = self.retrieve_user(authorization);
self.handle_edit_file(path, head_only, user, &mut res) self.handle_edit_file(path, head_only, user, &mut res)
.await?; .await?;
} else { } else {
@@ -263,8 +304,15 @@ impl Server {
self.handle_render_spa(path, headers, head_only, &mut res) self.handle_render_spa(path, headers, head_only, &mut res)
.await?; .await?;
} else if allow_upload && req_path.ends_with('/') { } else if allow_upload && req_path.ends_with('/') {
let user = self.retrieve_user(authorization); self.handle_ls_dir(
self.handle_ls_dir(path, false, &query_params, head_only, user, &mut res) path,
false,
&query_params,
head_only,
user,
access_paths,
&mut res,
)
.await?; .await?;
} else { } else {
status_not_found(&mut res); status_not_found(&mut res);
@@ -292,7 +340,8 @@ impl Server {
method => match method.as_str() { method => match method.as_str() {
"PROPFIND" => { "PROPFIND" => {
if is_dir { if is_dir {
self.handle_propfind_dir(path, headers, &mut res).await?; self.handle_propfind_dir(path, headers, access_paths, &mut res)
.await?;
} else if is_file { } else if is_file {
self.handle_propfind_file(path, &mut res).await?; self.handle_propfind_file(path, &mut res).await?;
} else { } else {
@@ -399,11 +448,12 @@ impl Server {
query_params: &HashMap<String, String>, query_params: &HashMap<String, String>,
head_only: bool, head_only: bool,
user: Option<String>, user: Option<String>,
access_paths: AccessPaths,
res: &mut Response, res: &mut Response,
) -> Result<()> { ) -> Result<()> {
let mut paths = vec![]; let mut paths = vec![];
if exist { if exist {
paths = match self.list_dir(path, path).await { paths = match self.list_dir(path, path, access_paths).await {
Ok(paths) => paths, Ok(paths) => paths,
Err(_) => { Err(_) => {
status_forbid(res); status_forbid(res);
@@ -420,6 +470,7 @@ impl Server {
query_params: &HashMap<String, String>, query_params: &HashMap<String, String>,
head_only: bool, head_only: bool,
user: Option<String>, user: Option<String>,
access_paths: AccessPaths,
res: &mut Response, res: &mut Response,
) -> Result<()> { ) -> Result<()> {
let mut paths: Vec<PathItem> = vec![]; let mut paths: Vec<PathItem> = vec![];
@@ -433,8 +484,9 @@ impl Server {
let hidden = hidden.clone(); let hidden = hidden.clone();
let running = self.running.clone(); let running = self.running.clone();
let search_paths = tokio::task::spawn_blocking(move || { let search_paths = tokio::task::spawn_blocking(move || {
let mut it = WalkDir::new(&path_buf).into_iter();
let mut paths: Vec<PathBuf> = vec![]; let mut paths: Vec<PathBuf> = vec![];
for dir in access_paths.leaf_paths(&path_buf) {
let mut it = WalkDir::new(&dir).into_iter();
while let Some(Ok(entry)) = it.next() { while let Some(Ok(entry)) = it.next() {
if !running.load(Ordering::SeqCst) { if !running.load(Ordering::SeqCst) {
break; break;
@@ -464,6 +516,7 @@ impl Server {
} }
paths.push(entry_path.to_path_buf()); paths.push(entry_path.to_path_buf());
} }
}
paths paths
}) })
.await?; .await?;
@@ -476,16 +529,16 @@ impl Server {
self.send_index(path, paths, true, query_params, head_only, user, res) self.send_index(path, paths, true, query_params, head_only, user, res)
} }
async fn handle_zip_dir(&self, path: &Path, head_only: bool, res: &mut Response) -> Result<()> { async fn handle_zip_dir(
&self,
path: &Path,
head_only: bool,
access_paths: AccessPaths,
res: &mut Response,
) -> Result<()> {
let (mut writer, reader) = tokio::io::duplex(BUF_SIZE); let (mut writer, reader) = tokio::io::duplex(BUF_SIZE);
let filename = try_get_file_name(path)?; let filename = try_get_file_name(path)?;
res.headers_mut().insert( set_content_diposition(res, false, &format!("{}.zip", filename))?;
CONTENT_DISPOSITION,
HeaderValue::from_str(&format!(
"attachment; filename=\"{}.zip\"",
encode_uri(filename),
))?,
);
res.headers_mut() res.headers_mut()
.insert("content-type", HeaderValue::from_static("application/zip")); .insert("content-type", HeaderValue::from_static("application/zip"));
if head_only { if head_only {
@@ -495,7 +548,7 @@ impl Server {
let hidden = self.args.hidden.clone(); let hidden = self.args.hidden.clone();
let running = self.running.clone(); let running = self.running.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = zip_dir(&mut writer, &path, &hidden, running).await { if let Err(e) = zip_dir(&mut writer, &path, access_paths, &hidden, running).await {
error!("Failed to zip {}, {}", path.display(), e); error!("Failed to zip {}, {}", path.display(), e);
} }
}); });
@@ -511,6 +564,7 @@ impl Server {
headers: &HeaderMap<HeaderValue>, headers: &HeaderMap<HeaderValue>,
head_only: bool, head_only: bool,
user: Option<String>, user: Option<String>,
access_paths: AccessPaths,
res: &mut Response, res: &mut Response,
) -> Result<()> { ) -> Result<()> {
let index_path = path.join(INDEX_NAME); let index_path = path.join(INDEX_NAME);
@@ -523,7 +577,7 @@ impl Server {
self.handle_send_file(&index_path, headers, head_only, res) self.handle_send_file(&index_path, headers, head_only, res)
.await?; .await?;
} else if self.args.render_try_index { } else if self.args.render_try_index {
self.handle_ls_dir(path, true, query_params, head_only, user, res) self.handle_ls_dir(path, true, query_params, head_only, user, access_paths, res)
.await?; .await?;
} else { } else {
status_not_found(res) status_not_found(res)
@@ -644,10 +698,7 @@ impl Server {
); );
let filename = try_get_file_name(path)?; let filename = try_get_file_name(path)?;
res.headers_mut().insert( set_content_diposition(res, true, filename)?;
CONTENT_DISPOSITION,
HeaderValue::from_str(&format!("inline; filename=\"{}\"", encode_uri(filename),))?,
);
res.headers_mut().typed_insert(AcceptRanges::bytes()); res.headers_mut().typed_insert(AcceptRanges::bytes());
@@ -731,6 +782,7 @@ impl Server {
&self, &self,
path: &Path, path: &Path,
headers: &HeaderMap<HeaderValue>, headers: &HeaderMap<HeaderValue>,
access_paths: AccessPaths,
res: &mut Response, res: &mut Response,
) -> Result<()> { ) -> Result<()> {
let depth: u32 = match headers.get("depth") { let depth: u32 = match headers.get("depth") {
@@ -748,7 +800,7 @@ impl Server {
None => vec![], None => vec![],
}; };
if depth != 0 { if depth != 0 {
match self.list_dir(path, &self.args.path).await { match self.list_dir(path, &self.args.path, access_paths).await {
Ok(child) => paths.extend(child), Ok(child) => paths.extend(child),
Err(_) => { Err(_) => {
status_forbid(res); status_forbid(res);
@@ -949,8 +1001,8 @@ impl Server {
fn auth_reject(&self, res: &mut Response) -> Result<()> { fn auth_reject(&self, res: &mut Response) -> Result<()> {
let value = self.args.auth_method.www_auth(false)?; let value = self.args.auth_method.www_auth(false)?;
set_webdav_headers(res); set_webdav_headers(res);
res.headers_mut().typed_insert(Connection::close());
res.headers_mut().insert(WWW_AUTHENTICATE, value.parse()?); res.headers_mut().insert(WWW_AUTHENTICATE, value.parse()?);
// set 401 to make the browser pop up the login box
*res.status_mut() = StatusCode::UNAUTHORIZED; *res.status_mut() = StatusCode::UNAUTHORIZED;
Ok(()) Ok(())
} }
@@ -972,20 +1024,32 @@ impl Server {
return None; return None;
} }
}; };
let relative_path = match self.resolve_path(&dest_path) {
Some(v) => v,
None => {
*res.status_mut() = StatusCode::BAD_REQUEST;
return None;
}
};
let authorization = headers.get(AUTHORIZATION); let authorization = headers.get(AUTHORIZATION);
let guard_type = self.args.auth.guard( let guard = self.args.auth.guard(
&dest_path, &relative_path,
req.method(), req.method(),
authorization, authorization,
self.args.auth_method.clone(), self.args.auth_method.clone(),
); );
if guard_type.is_reject() {
*res.status_mut() = StatusCode::FORBIDDEN; match guard {
*res.body_mut() = Body::from("Forbidden"); (_, Some(_)) => {}
_ => {
status_forbid(res);
return None; return None;
} }
};
let dest = match self.extract_path(&dest_path) { let dest = match self.join_path(&relative_path) {
Some(dest) => dest, Some(dest) => dest,
None => { None => {
*res.status_mut() = StatusCode::BAD_REQUEST; *res.status_mut() = StatusCode::BAD_REQUEST;
@@ -1002,49 +1066,61 @@ impl Server {
Some(uri.path().to_string()) Some(uri.path().to_string())
} }
fn extract_path(&self, path: &str) -> Option<PathBuf> { fn resolve_path(&self, path: &str) -> Option<String> {
let mut slash_stripped_path = path; let path = path.trim_matches('/');
while let Some(p) = slash_stripped_path.strip_prefix('/') { let path = decode_uri(path)?;
slash_stripped_path = p let prefix = self.args.path_prefix.as_str();
if prefix == "/" {
return Some(path.to_string());
} }
let decoded_path = decode_uri(slash_stripped_path)?; path.strip_prefix(prefix.trim_start_matches('/'))
let slashes_switched = if cfg!(windows) { .map(|v| v.trim_matches('/').to_string())
decoded_path.replace('/', "\\")
} else {
decoded_path.into_owned()
};
let stripped_path = match self.strip_path_prefix(&slashes_switched) {
Some(path) => path,
None => return None,
};
Some(self.args.path.join(stripped_path))
} }
fn strip_path_prefix<'a, P: AsRef<Path>>(&self, path: &'a P) -> Option<&'a Path> { fn join_path(&self, path: &str) -> Option<PathBuf> {
let path = path.as_ref(); if path.is_empty() {
if self.args.path_prefix.is_empty() { return Some(self.args.path.clone());
Some(path)
} else {
path.strip_prefix(&self.args.path_prefix).ok()
} }
let path = if cfg!(windows) {
path.replace('/', "\\")
} else {
path.to_string()
};
Some(self.args.path.join(path))
} }
async fn list_dir(&self, entry_path: &Path, base_path: &Path) -> Result<Vec<PathItem>> { async fn list_dir(
&self,
entry_path: &Path,
base_path: &Path,
access_paths: AccessPaths,
) -> Result<Vec<PathItem>> {
let mut paths: Vec<PathItem> = vec![]; let mut paths: Vec<PathItem> = vec![];
if access_paths.perm().indexonly() {
for name in access_paths.child_paths() {
let entry_path = entry_path.join(name);
self.add_pathitem(&mut paths, base_path, &entry_path).await;
}
} else {
let mut rd = fs::read_dir(entry_path).await?; let mut rd = fs::read_dir(entry_path).await?;
while let Ok(Some(entry)) = rd.next_entry().await { while let Ok(Some(entry)) = rd.next_entry().await {
let entry_path = entry.path(); let entry_path = entry.path();
let base_name = get_file_name(&entry_path); self.add_pathitem(&mut paths, base_path, &entry_path).await;
if let Ok(Some(item)) = self.to_pathitem(entry_path.as_path(), base_path).await {
if is_hidden(&self.args.hidden, base_name, item.is_dir()) {
continue;
}
paths.push(item);
} }
} }
Ok(paths) Ok(paths)
} }
async fn add_pathitem(&self, paths: &mut Vec<PathItem>, base_path: &Path, entry_path: &Path) {
let base_name = get_file_name(entry_path);
if let Ok(Some(item)) = self.to_pathitem(entry_path, base_path).await {
if is_hidden(&self.args.hidden, base_name, item.is_dir()) {
return;
}
paths.push(item);
}
}
async fn to_pathitem<P: AsRef<Path>>(&self, path: P, base_path: P) -> Result<Option<PathItem>> { async fn to_pathitem<P: AsRef<Path>>(&self, path: P, base_path: P) -> Result<Option<PathItem>> {
let path = path.as_ref(); let path = path.as_ref();
let (meta, meta2) = tokio::join!(fs::metadata(&path), fs::symlink_metadata(&path)); let (meta, meta2) = tokio::join!(fs::metadata(&path), fs::symlink_metadata(&path));
@@ -1074,10 +1150,6 @@ impl Server {
size, size,
})) }))
} }
fn retrieve_user(&self, authorization: Option<&HeaderValue>) -> Option<String> {
self.args.auth_method.get_user(authorization?)
}
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -1214,17 +1286,15 @@ fn add_cors(res: &mut Response) {
.typed_insert(AccessControlAllowCredentials); .typed_insert(AccessControlAllowCredentials);
res.headers_mut().insert( res.headers_mut().insert(
"Access-Control-Allow-Methods", "Access-Control-Allow-Methods",
HeaderValue::from_static("GET,HEAD,PUT,OPTIONS,DELETE,PROPFIND,COPY,MOVE"), HeaderValue::from_static("*"),
); );
res.headers_mut().insert( res.headers_mut().insert(
"Access-Control-Allow-Headers", "Access-Control-Allow-Headers",
HeaderValue::from_static("Authorization,Destination,Range,Content-Type"), HeaderValue::from_static("Authorization,*"),
); );
res.headers_mut().insert( res.headers_mut().insert(
"Access-Control-Expose-Headers", "Access-Control-Expose-Headers",
HeaderValue::from_static( HeaderValue::from_static("Authorization,*"),
"WWW-Authenticate,Content-Range,Accept-Ranges,Content-Disposition",
),
); );
} }
@@ -1245,16 +1315,18 @@ fn res_multistatus(res: &mut Response, content: &str) {
async fn zip_dir<W: AsyncWrite + Unpin>( async fn zip_dir<W: AsyncWrite + Unpin>(
writer: &mut W, writer: &mut W,
dir: &Path, dir: &Path,
access_paths: AccessPaths,
hidden: &[String], hidden: &[String],
running: Arc<AtomicBool>, running: Arc<AtomicBool>,
) -> Result<()> { ) -> Result<()> {
let mut writer = ZipFileWriter::new(writer); let mut writer = ZipFileWriter::with_tokio(writer);
let hidden = Arc::new(hidden.to_vec()); let hidden = Arc::new(hidden.to_vec());
let hidden = hidden.clone(); let hidden = hidden.clone();
let dir_path_buf = dir.to_path_buf(); let dir_clone = dir.to_path_buf();
let zip_paths = tokio::task::spawn_blocking(move || { let zip_paths = tokio::task::spawn_blocking(move || {
let mut it = WalkDir::new(&dir_path_buf).into_iter();
let mut paths: Vec<PathBuf> = vec![]; let mut paths: Vec<PathBuf> = vec![];
for dir in access_paths.leaf_paths(&dir_clone) {
let mut it = WalkDir::new(&dir).into_iter();
while let Some(Ok(entry)) = it.next() { while let Some(Ok(entry)) = it.next() {
if !running.load(Ordering::SeqCst) { if !running.load(Ordering::SeqCst) {
break; break;
@@ -1287,6 +1359,7 @@ async fn zip_dir<W: AsyncWrite + Unpin>(
} }
paths.push(entry_path.to_path_buf()); paths.push(entry_path.to_path_buf());
} }
}
paths paths
}) })
.await?; .await?;
@@ -1295,12 +1368,14 @@ async fn zip_dir<W: AsyncWrite + Unpin>(
Some(v) => v, Some(v) => v,
None => continue, None => continue,
}; };
let builder = let (datetime, mode) = get_file_mtime_and_mode(&zip_path).await?;
ZipEntryBuilder::new(filename.into(), Compression::Deflate).unix_permissions(0o644); let builder = ZipEntryBuilder::new(filename.into(), Compression::Deflate)
.unix_permissions(mode)
.last_modification_date(ZipDateTime::from_chrono(&datetime));
let mut file = File::open(&zip_path).await?; let mut file = File::open(&zip_path).await?;
let mut file_writer = writer.write_entry_stream(builder).await?; let mut file_writer = writer.write_entry_stream(builder).await?.compat_write();
io::copy(&mut file, &mut file_writer).await?; io::copy(&mut file, &mut file_writer).await?;
file_writer.close().await?; file_writer.into_inner().close().await?;
} }
writer.close().await?; writer.close().await?;
Ok(()) Ok(())
@@ -1359,6 +1434,21 @@ fn status_no_content(res: &mut Response) {
*res.status_mut() = StatusCode::NO_CONTENT; *res.status_mut() = StatusCode::NO_CONTENT;
} }
fn set_content_diposition(res: &mut Response, inline: bool, filename: &str) -> Result<()> {
let kind = if inline { "inline" } else { "attachment" };
let value = if filename.is_ascii() {
HeaderValue::from_str(&format!("{kind}; filename=\"{}\"", filename,))?
} else {
HeaderValue::from_str(&format!(
"{kind}; filename=\"{}\"; filename*=UTF-8''{}",
filename,
encode_uri(filename),
))?
};
res.headers_mut().insert(CONTENT_DISPOSITION, value);
Ok(())
}
fn is_hidden(hidden: &[String], file_name: &str, is_dir_type: bool) -> bool { fn is_hidden(hidden: &[String], file_name: &str, is_dir_type: bool) -> bool {
hidden.iter().any(|v| { hidden.iter().any(|v| {
if is_dir_type { if is_dir_type {

View File

@@ -1,4 +1,5 @@
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Utc};
use std::{ use std::{
borrow::Cow, borrow::Cow,
path::Path, path::Path,
@@ -28,6 +29,21 @@ pub fn get_file_name(path: &Path) -> &str {
.unwrap_or_default() .unwrap_or_default()
} }
#[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))
}
#[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> { pub fn try_get_file_name(path: &Path) -> Result<&str> {
path.file_name() path.file_name()
.and_then(|v| v.to_str()) .and_then(|v| v.to_str())

View File

@@ -3,10 +3,11 @@ mod utils;
use diqwest::blocking::WithDigestAuth; use diqwest::blocking::WithDigestAuth;
use fixtures::{server, Error, TestServer}; use fixtures::{server, Error, TestServer};
use indexmap::IndexSet;
use rstest::rstest; use rstest::rstest;
#[rstest] #[rstest]
fn no_auth(#[with(&["--auth", "/@user:pass", "-A"])] server: TestServer) -> Result<(), Error> { fn no_auth(#[with(&["--auth", "user:pass@/:rw", "-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?; let resp = reqwest::blocking::get(server.url())?;
assert_eq!(resp.status(), 401); assert_eq!(resp.status(), 401);
assert!(resp.headers().contains_key("www-authenticate")); assert!(resp.headers().contains_key("www-authenticate"));
@@ -17,7 +18,7 @@ fn no_auth(#[with(&["--auth", "/@user:pass", "-A"])] server: TestServer) -> Resu
} }
#[rstest] #[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 url = format!("{}file1", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?; let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 401); assert_eq!(resp.status(), 401);
@@ -29,7 +30,24 @@ fn auth(#[with(&["--auth", "/@user:pass", "-A"])] server: TestServer) -> Result<
} }
#[rstest] #[rstest]
fn auth_skip(#[with(&["--auth", "/@user:pass@*"])] server: TestServer) -> Result<(), Error> { fn auth_and_public(
#[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);
let resp = fetch!(b"PUT", &url)
.body(b"abc".to_vec())
.send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 201);
let resp = fetch!(b"GET", &url).send()?;
assert_eq!(resp.status(), 200);
assert_eq!(resp.text()?, "abc");
Ok(())
}
#[rstest]
fn auth_skip(#[with(&["--auth", "@/"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?; let resp = reqwest::blocking::get(server.url())?;
assert_eq!(resp.status(), 200); assert_eq!(resp.status(), 200);
Ok(()) Ok(())
@@ -37,7 +55,7 @@ fn auth_skip(#[with(&["--auth", "/@user:pass@*"])] server: TestServer) -> Result
#[rstest] #[rstest]
fn auth_skip_on_options_method( fn auth_skip_on_options_method(
#[with(&["--auth", "/@user:pass"])] server: TestServer, #[with(&["--auth", "user:pass@/:rw"])] server: TestServer,
) -> Result<(), Error> { ) -> Result<(), Error> {
let url = format!("{}index.html", server.url()); let url = format!("{}index.html", server.url());
let resp = fetch!(b"OPTIONS", &url).send()?; let resp = fetch!(b"OPTIONS", &url).send()?;
@@ -47,13 +65,13 @@ fn auth_skip_on_options_method(
#[rstest] #[rstest]
fn auth_check( fn auth_check(
#[with(&["--auth", "/@user:pass@user2:pass2", "-A"])] server: TestServer, #[with(&["--auth", "user:pass@/:rw|user2:pass2@/", "-A"])] server: TestServer,
) -> Result<(), Error> { ) -> Result<(), Error> {
let url = format!("{}index.html", server.url()); let url = format!("{}index.html", server.url());
let resp = fetch!(b"WRITEABLE", &url).send()?; let resp = fetch!(b"WRITEABLE", &url).send()?;
assert_eq!(resp.status(), 401); assert_eq!(resp.status(), 401);
let resp = fetch!(b"WRITEABLE", &url).send_with_digest_auth("user2", "pass2")?; let resp = fetch!(b"WRITEABLE", &url).send_with_digest_auth("user2", "pass2")?;
assert_eq!(resp.status(), 401); assert_eq!(resp.status(), 403);
let resp = fetch!(b"WRITEABLE", &url).send_with_digest_auth("user", "pass")?; let resp = fetch!(b"WRITEABLE", &url).send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 200); assert_eq!(resp.status(), 200);
Ok(()) Ok(())
@@ -61,7 +79,7 @@ fn auth_check(
#[rstest] #[rstest]
fn auth_readonly( fn auth_readonly(
#[with(&["--auth", "/@user:pass@user2:pass2", "-A"])] server: TestServer, #[with(&["--auth", "user:pass@/:rw|user2:pass2@/", "-A"])] server: TestServer,
) -> Result<(), Error> { ) -> Result<(), Error> {
let url = format!("{}index.html", server.url()); let url = format!("{}index.html", server.url());
let resp = fetch!(b"GET", &url).send()?; let resp = fetch!(b"GET", &url).send()?;
@@ -72,13 +90,13 @@ fn auth_readonly(
let resp = fetch!(b"PUT", &url) let resp = fetch!(b"PUT", &url)
.body(b"abc".to_vec()) .body(b"abc".to_vec())
.send_with_digest_auth("user2", "pass2")?; .send_with_digest_auth("user2", "pass2")?;
assert_eq!(resp.status(), 401); assert_eq!(resp.status(), 403);
Ok(()) Ok(())
} }
#[rstest] #[rstest]
fn auth_nest( fn auth_nest(
#[with(&["--auth", "/@user:pass@user2:pass2", "--auth", "/dir1@user3:pass3", "-A"])] #[with(&["--auth", "user:pass@/:rw|user2:pass2@/", "--auth", "user3:pass3@/dir1:rw", "-A"])]
server: TestServer, server: TestServer,
) -> Result<(), Error> { ) -> Result<(), Error> {
let url = format!("{}dir1/file1", server.url()); let url = format!("{}dir1/file1", server.url());
@@ -97,7 +115,8 @@ fn auth_nest(
#[rstest] #[rstest]
fn auth_nest_share( fn auth_nest_share(
#[with(&["--auth", "/@user:pass@*", "--auth", "/dir1@user3:pass3", "-A"])] server: TestServer, #[with(&["--auth", "@/", "--auth", "user:pass@/:rw", "--auth", "user3:pass3@/dir1:rw", "-A"])]
server: TestServer,
) -> Result<(), Error> { ) -> Result<(), Error> {
let url = format!("{}index.html", server.url()); let url = format!("{}index.html", server.url());
let resp = fetch!(b"GET", &url).send()?; let resp = fetch!(b"GET", &url).send()?;
@@ -106,8 +125,8 @@ fn auth_nest_share(
} }
#[rstest] #[rstest]
#[case(server(&["--auth", "/@user:pass", "--auth-method", "basic", "-A"]), "user", "pass")] #[case(server(&["--auth", "user:pass@/:rw", "--auth-method", "basic", "-A"]), "user", "pass")]
#[case(server(&["--auth", "/@u1:p1", "--auth-method", "basic", "-A"]), "u1", "p1")] #[case(server(&["--auth", "u1:p1@/:rw", "--auth-method", "basic", "-A"]), "u1", "p1")]
fn auth_basic( fn auth_basic(
#[case] server: TestServer, #[case] server: TestServer,
#[case] user: &str, #[case] user: &str,
@@ -126,7 +145,8 @@ fn auth_basic(
#[rstest] #[rstest]
fn auth_webdav_move( fn auth_webdav_move(
#[with(&["--auth", "/@user:pass@*", "--auth", "/dir1@user3:pass3", "-A"])] server: TestServer, #[with(&["--auth", "user:pass@/:rw", "--auth", "user3:pass3@/dir1:rw", "-A"])]
server: TestServer,
) -> Result<(), Error> { ) -> Result<(), Error> {
let origin_url = format!("{}dir1/test.html", server.url()); let origin_url = format!("{}dir1/test.html", server.url());
let new_url = format!("{}test2.html", server.url()); let new_url = format!("{}test2.html", server.url());
@@ -139,7 +159,8 @@ fn auth_webdav_move(
#[rstest] #[rstest]
fn auth_webdav_copy( fn auth_webdav_copy(
#[with(&["--auth", "/@user:pass@*", "--auth", "/dir1@user3:pass3", "-A"])] server: TestServer, #[with(&["--auth", "user:pass@/:rw", "--auth", "user3:pass3@/dir1:rw", "-A"])]
server: TestServer,
) -> Result<(), Error> { ) -> Result<(), Error> {
let origin_url = format!("{}dir1/test.html", server.url()); let origin_url = format!("{}dir1/test.html", server.url());
let new_url = format!("{}test2.html", server.url()); let new_url = format!("{}test2.html", server.url());
@@ -152,7 +173,7 @@ fn auth_webdav_copy(
#[rstest] #[rstest]
fn auth_path_prefix( 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> { ) -> Result<(), Error> {
let url = format!("{}xyz/index.html", server.url()); let url = format!("{}xyz/index.html", server.url());
let resp = fetch!(b"GET", &url).send()?; let resp = fetch!(b"GET", &url).send()?;
@@ -161,3 +182,22 @@ fn auth_path_prefix(
assert_eq!(resp.status(), 200); assert_eq!(resp.status(), 200);
Ok(()) 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

@@ -19,15 +19,15 @@ fn cors(#[with(&["--enable-cors"])] server: TestServer) -> Result<(), Error> {
); );
assert_eq!( assert_eq!(
resp.headers().get("access-control-allow-methods").unwrap(), resp.headers().get("access-control-allow-methods").unwrap(),
"GET,HEAD,PUT,OPTIONS,DELETE,PROPFIND,COPY,MOVE" "*"
); );
assert_eq!( assert_eq!(
resp.headers().get("access-control-allow-headers").unwrap(), resp.headers().get("access-control-allow-headers").unwrap(),
"Authorization,Destination,Range,Content-Type" "Authorization,*"
); );
assert_eq!( assert_eq!(
resp.headers().get("access-control-expose-headers").unwrap(), resp.headers().get("access-control-expose-headers").unwrap(),
"WWW-Authenticate,Content-Range,Accept-Ranges,Content-Disposition" "Authorization,*"
); );
Ok(()) Ok(())
} }

View File

@@ -184,6 +184,17 @@ fn get_file_404(server: TestServer) -> Result<(), Error> {
Ok(()) 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] #[rstest]
fn get_file_edit(server: TestServer) -> Result<(), Error> { fn get_file_edit(server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"GET", format!("{}index.html?edit", server.url())).send()?; let resp = fetch!(b"GET", format!("{}index.html?edit", server.url())).send()?;

View File

@@ -11,8 +11,8 @@ use std::io::Read;
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
#[rstest] #[rstest]
#[case(&["-a", "/@user:pass", "--log-format", "$remote_user"], false)] #[case(&["-a", "user:pass@/:rw", "--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", "--auth-method", "basic"], true)]
fn log_remote_user( fn log_remote_user(
tmpdir: TempDir, tmpdir: TempDir,
port: u16, port: u16,

View File

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