Compare commits

..

29 Commits

Author SHA1 Message Date
sigoden
7dc0b0e218 chore: release v0.34.2 2023-06-05 11:51:56 +08:00
sigoden
6be36b8e51 fix: webdav only see public folder even logging in (#231) 2023-06-05 11:40:31 +08:00
sigoden
8be545d3da fix: ui refresh page after login (#230) 2023-06-03 10:09:02 +08:00
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
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
19 changed files with 1440 additions and 935 deletions

View File

@@ -2,6 +2,59 @@
All notable changes to this project will be documented in this file.
## [0.34.2] - 2023-06-05
### Bug Fixes
- Ui refresh page after login ([#230](https://github.com/sigoden/dufs/issues/230))
- Webdav only see public folder even logging in ([#231](https://github.com/sigoden/dufs/issues/231))
## [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
### 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

948
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "dufs"
version = "0.32.0"
version = "0.34.2"
edition = "2021"
authors = ["sigoden <sigoden@gmail.com>"]
description = "Dufs is a distinctive utility file server"
@@ -15,33 +15,36 @@ 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.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"
mime_guess = "2.0"
if-addrs = "0.8"
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"]
@@ -52,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.16"
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

@@ -14,7 +14,7 @@ Dufs is a distinctive utility file server that supports static serving, uploadin
- Upload files and folders (Drag & Drop)
- Create/Edit/Search files
- Partial responses (Parallel/Resume download)
- Path level access control
- Access control
- Support https
- Support webdav
- 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
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
@@ -75,8 +75,8 @@ 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
@@ -203,61 +203,62 @@ curl --user user:pass http://192.168.8.10:5000/file # basic auth
### 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
@@ -305,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_`.
`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

View File

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

View File

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

@@ -1,4 +1,4 @@
use anyhow::{anyhow, bail, Result};
use anyhow::{bail, Context, Result};
use clap::builder::PossibleValuesParser;
use clap::{value_parser, Arg, ArgAction, ArgMatches, Command};
use clap_complete::{generate, Generator, Shell};
@@ -25,8 +25,8 @@ pub fn build_cli() -> Command {
env!("CARGO_PKG_REPOSITORY")
))
.arg(
Arg::new("root")
.env("DUFS_ROOT")
Arg::new("serve_path")
.env("DUFS_SERVE_PATH")
.hide_env(true)
.default_value(".")
.value_parser(value_parser!(PathBuf))
@@ -76,9 +76,9 @@ pub fn build_cli() -> Command {
.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(
@@ -264,7 +264,7 @@ impl Args {
.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")
@@ -288,7 +288,7 @@ 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");
@@ -380,7 +380,7 @@ impl Args {
p.push(path); // If path is absolute, it replaces the current path.
std::fs::canonicalize(p)
})
.map_err(|err| anyhow!("Failed to access path `{}`: {}", path.display(), err,))
.with_context(|| format!("Failed to access path `{}`", path.display()))
}
fn parse_assets_path<P: AsRef<Path>>(path: P) -> Result<PathBuf> {

View File

@@ -2,12 +2,16 @@ 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;
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use uuid::Uuid;
use crate::utils::{encode_uri, unix_now};
use crate::utils::unix_now;
const REALM: &str = "DUFS";
const DIGEST_AUTH_TIMEOUT: u32 = 86400;
@@ -21,57 +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) -> Result<Self> {
let mut rules = HashMap::default();
pub fn new(raw_rules: &[&str]) -> Result<Self> {
if raw_rules.is_empty() {
return Ok(Self { rules });
}
for rule in raw_rules {
let parts: Vec<&str> = rule.split('@').collect();
let create_err = || anyhow!("Invalid auth `{rule}`");
match parts.as_slice() {
[path, readwrite] => {
let control = PathControl {
readwrite: Account::new(readwrite).ok_or_else(create_err)?,
readonly: None,
share: false,
};
rules.insert(sanitize_path(path, uri_prefix), control);
}
[path, readwrite, readonly] => {
let (readonly, share) = if *readonly == "*" {
(None, true)
} else {
(Some(Account::new(readonly).ok_or_else(create_err)?), false)
};
let control = PathControl {
readwrite: Account::new(readwrite).ok_or_else(create_err)?,
readonly,
share,
};
rules.insert(sanitize_path(path, uri_prefix), control);
}
_ => return Err(create_err()),
}
}
Ok(Self { rules })
return Ok(AccessControl {
anony: Some(AccessPaths::new(AccessPerm::ReadWrite)),
users: IndexMap::new(),
});
}
pub fn valid(&self) -> bool {
!self.rules.is_empty()
let create_err = |v: &str| anyhow!("Invalid auth `{v}`");
let mut anony = None;
let mut anony_paths = vec![];
let mut users = IndexMap::new();
for rule in raw_rules {
let (user, list) = rule.split_once('@').ok_or_else(|| create_err(rule))?;
if user.is_empty() && anony.is_some() {
bail!("Invalid auth, duplicate anonymous rules");
}
let mut paths = AccessPaths::default();
for value in list.trim_matches(',').split(',') {
let (path, perm) = match value.split_once(':') {
None => (value, AccessPerm::ReadOnly),
Some((path, "rw")) => (path, AccessPerm::ReadWrite),
_ => return Err(create_err(rule)),
};
if user.is_empty() {
anony_paths.push((path, perm));
}
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 {
return Err(create_err(rule));
}
}
for (path, perm) in anony_paths {
for (_, (_, paths)) in users.iter_mut() {
paths.add(path, perm)
}
}
Ok(Self { users, anony })
}
pub fn exist(&self) -> bool {
!self.users.is_empty()
}
pub fn guard(
@@ -80,81 +90,157 @@ impl AccessControl {
method: &Method,
authorization: Option<&HeaderValue>,
auth_method: AuthMethod,
) -> GuardType {
if self.rules.is_empty() {
return GuardType::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
.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 {
return GuardType::ReadOnly;
return (None, Some(AccessPaths::new(AccessPerm::ReadOnly)));
}
let mut controls = vec![];
for path in walk_path(path) {
if let Some(control) = self.rules.get(path) {
controls.push(control);
if let Some(authorization) = authorization {
let Account { user, pass } = &control.readwrite;
if auth_method
.validate(authorization, method.as_str(), user, pass)
.is_some()
{
return GuardType::ReadWrite;
if let Some(paths) = self.anony.as_ref() {
return (None, paths.find(path, !is_readonly_method(method)));
}
}
}
}
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
(None, None)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum GuardType {
Reject,
ReadWrite,
ReadOnly,
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct AccessPaths {
perm: AccessPerm,
children: IndexMap<String, AccessPaths>,
}
impl GuardType {
pub fn is_reject(&self) -> bool {
*self == GuardType::Reject
impl AccessPaths {
pub fn new(perm: AccessPerm) -> Self {
Self {
perm,
..Default::default()
}
}
}
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('/')),
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));
}
}
};
encode_uri(&new_path)
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)
}
}
}
}
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
})
#[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 {
@@ -164,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,
@@ -194,24 +257,21 @@ pub enum AuthMethod {
}
impl AuthMethod {
pub fn www_auth(&self, stale: bool) -> Result<String> {
pub fn www_auth(&self) -> Result<String> {
match self {
AuthMethod::Basic => Ok(format!("Basic realm=\"{REALM}\"")),
AuthMethod::Digest => {
let str_stale = if stale { "stale=true," } else { "" };
Ok(format!(
"Digest realm=\"{}\",nonce=\"{}\",{}qop=\"auth\"",
AuthMethod::Digest => Ok(format!(
"Digest realm=\"{}\",nonce=\"{}\",qop=\"auth\"",
REALM,
create_nonce()?,
str_stale
))
}
)),
}
}
pub fn get_user(&self, authorization: &HeaderValue) -> Option<String> {
match self {
AuthMethod::Basic => {
let value: Vec<u8> = general_purpose::STANDARD_NO_PAD
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();
@@ -227,7 +287,8 @@ impl AuthMethod {
}
}
}
pub fn validate(
fn check(
&self,
authorization: &HeaderValue,
method: &str,
@@ -236,7 +297,7 @@ impl AuthMethod {
) -> Option<()> {
match self {
AuthMethod::Basic => {
let basic_value: Vec<u8> = general_purpose::STANDARD_NO_PAD
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();
@@ -245,12 +306,7 @@ impl AuthMethod {
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(());
}
@@ -273,6 +329,11 @@ 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":");
@@ -285,7 +346,7 @@ impl AuthMethod {
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":");
@@ -308,7 +369,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":");
@@ -317,7 +378,6 @@ impl AuthMethod {
}
};
if correct_response.as_bytes() == *user_response {
// grant access
return Some(());
}
}
@@ -417,3 +477,54 @@ fn create_nonce() -> Result<String> {
let n = format!("{:08x}{:032x}", secs, h.compute());
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

@@ -18,7 +18,7 @@ use crate::server::{Request, Server};
#[cfg(feature = "tls")]
use crate::tls::{TlsAcceptor, TlsStream};
use anyhow::{anyhow, Result};
use anyhow::{anyhow, Context, Result};
use std::net::{IpAddr, SocketAddr, TcpListener as StdTcpListener};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
@@ -35,14 +35,7 @@ use hyper::service::{make_service_fn, service_fn};
use rustls::ServerConfig;
#[tokio::main]
async fn main() {
run().await.unwrap_or_else(|err| {
eprintln!("error: {err}");
std::process::exit(1);
})
}
async fn run() -> Result<()> {
async fn main() -> Result<()> {
logger::init().map_err(|e| anyhow!("Failed to init logger, {e}"))?;
let cmd = build_cli();
let matches = cmd.get_matches();
@@ -94,7 +87,7 @@ fn serve(
match bind_addr {
BindAddr::Address(ip) => {
let incoming = create_addr_incoming(SocketAddr::new(*ip, port))
.map_err(|e| anyhow!("Failed to bind `{ip}:{port}`, {e}"))?;
.with_context(|| format!("Failed to bind `{ip}:{port}`"))?;
match args.tls.as_ref() {
#[cfg(feature = "tls")]
Some((certs, key)) => {
@@ -134,7 +127,7 @@ fn serve(
#[cfg(unix)]
{
let listener = tokio::net::UnixListener::bind(path)
.map_err(|e| anyhow!("Failed to bind `{}`, {e}", path.display()))?;
.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));
@@ -181,8 +174,8 @@ fn print_listening(args: Arc<Args>) -> Result<()> {
}
}
if ipv4 || ipv6 {
let ifaces = if_addrs::get_if_addrs()
.map_err(|e| anyhow!("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() {

View File

@@ -1,18 +1,23 @@
#![allow(clippy::too_many_arguments)]
use crate::auth::{AccessPaths, AccessPerm};
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 anyhow::{anyhow, Result};
use walkdir::WalkDir;
use xml::escape::escape_str_pcdata;
use async_zip::write::ZipFileWriter;
use async_zip::{Compression, ZipEntryBuilder};
use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipDateTime, ZipEntryBuilder};
use chrono::{LocalResult, TimeZone, Utc};
use futures::TryStreamExt;
use headers::{
AcceptRanges, AccessControlAllowCredentials, AccessControlAllowOrigin, Connection,
ContentLength, ContentType, ETag, HeaderMap, HeaderMapExt, IfModifiedSince, IfNoneMatch,
IfRange, LastModified, Range,
AcceptRanges, AccessControlAllowCredentials, AccessControlAllowOrigin, ContentLength,
ContentType, ETag, HeaderMap, HeaderMapExt, IfModifiedSince, IfNoneMatch, IfRange,
LastModified, Range,
};
use hyper::header::{
HeaderValue, AUTHORIZATION, CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE,
@@ -32,6 +37,7 @@ use std::time::SystemTime;
use tokio::fs::File;
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWrite};
use tokio::{fs, io};
use tokio_util::compat::FuturesAsyncWriteCompatExt;
use tokio_util::io::StreamReader;
use uuid::Uuid;
@@ -134,16 +140,32 @@ impl Server {
}
let authorization = headers.get(AUTHORIZATION);
let guard_type = self.args.auth.guard(
req_path,
let relative_path = match self.resolve_path(req_path) {
Some(v) => v,
None => {
status_forbid(&mut res);
return Ok(res);
}
};
let guard = self.args.auth.guard(
&relative_path,
&method,
authorization,
self.args.auth_method.clone(),
);
if guard_type.is_reject() {
let (user, access_paths) = match guard {
(None, None) => {
self.auth_reject(&mut 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_params: HashMap<String, String> = form_urlencoded::parse(query.as_bytes())
@@ -169,8 +191,7 @@ impl Server {
}
return Ok(res);
}
let path = match self.extract_path(req_path) {
let path = match self.join_path(&relative_path) {
Some(v) => v,
None => {
status_forbid(&mut res);
@@ -207,31 +228,38 @@ impl Server {
status_not_found(&mut 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") {
let user = self.retrieve_user(authorization);
self.handle_search_dir(path, &query_params, head_only, user, &mut res)
self.handle_search_dir(
path,
&query_params,
head_only,
user,
access_paths,
&mut res,
)
.await?;
} else {
let user = self.retrieve_user(authorization);
self.handle_render_index(
path,
&query_params,
headers,
head_only,
user,
access_paths,
&mut res,
)
.await?;
}
} else if render_index || render_spa {
let user = self.retrieve_user(authorization);
self.handle_render_index(
path,
&query_params,
headers,
head_only,
user,
access_paths,
&mut res,
)
.await?;
@@ -240,19 +268,32 @@ impl Server {
status_not_found(&mut 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") {
let user = self.retrieve_user(authorization);
self.handle_search_dir(path, &query_params, head_only, user, &mut res)
self.handle_search_dir(
path,
&query_params,
head_only,
user,
access_paths,
&mut res,
)
.await?;
} else {
let user = self.retrieve_user(authorization);
self.handle_ls_dir(path, true, &query_params, head_only, user, &mut res)
self.handle_ls_dir(
path,
true,
&query_params,
head_only,
user,
access_paths,
&mut res,
)
.await?;
}
} else if is_file {
if query_params.contains_key("edit") {
let user = self.retrieve_user(authorization);
self.handle_edit_file(path, head_only, user, &mut res)
.await?;
} else {
@@ -263,8 +304,15 @@ impl Server {
self.handle_render_spa(path, headers, head_only, &mut res)
.await?;
} else if allow_upload && req_path.ends_with('/') {
let user = self.retrieve_user(authorization);
self.handle_ls_dir(path, false, &query_params, head_only, user, &mut res)
self.handle_ls_dir(
path,
false,
&query_params,
head_only,
user,
access_paths,
&mut res,
)
.await?;
} else {
status_not_found(&mut res);
@@ -292,7 +340,14 @@ impl Server {
method => match method.as_str() {
"PROPFIND" => {
if is_dir {
self.handle_propfind_dir(path, headers, &mut res).await?;
let access_paths = if access_paths.perm().indexonly() {
// see https://github.com/sigoden/dufs/issues/229
AccessPaths::new(AccessPerm::ReadOnly)
} else {
access_paths
};
self.handle_propfind_dir(path, headers, access_paths, &mut res)
.await?;
} else if is_file {
self.handle_propfind_file(path, &mut res).await?;
} else {
@@ -399,11 +454,12 @@ impl Server {
query_params: &HashMap<String, String>,
head_only: bool,
user: Option<String>,
access_paths: AccessPaths,
res: &mut Response,
) -> Result<()> {
let mut paths = vec![];
if exist {
paths = match self.list_dir(path, path).await {
paths = match self.list_dir(path, path, access_paths).await {
Ok(paths) => paths,
Err(_) => {
status_forbid(res);
@@ -420,6 +476,7 @@ impl Server {
query_params: &HashMap<String, String>,
head_only: bool,
user: Option<String>,
access_paths: AccessPaths,
res: &mut Response,
) -> Result<()> {
let mut paths: Vec<PathItem> = vec![];
@@ -433,8 +490,9 @@ impl Server {
let hidden = hidden.clone();
let running = self.running.clone();
let search_paths = tokio::task::spawn_blocking(move || {
let mut it = WalkDir::new(&path_buf).into_iter();
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() {
if !running.load(Ordering::SeqCst) {
break;
@@ -464,6 +522,7 @@ impl Server {
}
paths.push(entry_path.to_path_buf());
}
}
paths
})
.await?;
@@ -476,16 +535,16 @@ impl Server {
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 filename = try_get_file_name(path)?;
res.headers_mut().insert(
CONTENT_DISPOSITION,
HeaderValue::from_str(&format!(
"attachment; filename=\"{}.zip\"",
encode_uri(filename),
))?,
);
set_content_diposition(res, false, &format!("{}.zip", filename))?;
res.headers_mut()
.insert("content-type", HeaderValue::from_static("application/zip"));
if head_only {
@@ -495,7 +554,7 @@ impl Server {
let hidden = self.args.hidden.clone();
let running = self.running.clone();
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);
}
});
@@ -511,6 +570,7 @@ impl Server {
headers: &HeaderMap<HeaderValue>,
head_only: bool,
user: Option<String>,
access_paths: AccessPaths,
res: &mut Response,
) -> Result<()> {
let index_path = path.join(INDEX_NAME);
@@ -523,7 +583,7 @@ impl Server {
self.handle_send_file(&index_path, headers, head_only, res)
.await?;
} 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?;
} else {
status_not_found(res)
@@ -638,20 +698,13 @@ impl Server {
None
};
if let Some(mime) = mime_guess::from_path(path).first() {
res.headers_mut().typed_insert(ContentType::from(mime));
} else {
res.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("application/octet-stream"),
HeaderValue::from_str(&get_content_type(path).await?)?,
);
}
let filename = try_get_file_name(path)?;
res.headers_mut().insert(
CONTENT_DISPOSITION,
HeaderValue::from_str(&format!("inline; filename=\"{}\"", encode_uri(filename),))?,
);
set_content_diposition(res, true, filename)?;
res.headers_mut().typed_insert(AcceptRanges::bytes());
@@ -712,7 +765,7 @@ impl Server {
uri_prefix: self.args.uri_prefix.clone(),
allow_upload: self.args.allow_upload,
allow_delete: self.args.allow_delete,
auth: self.args.auth.valid(),
auth: self.args.auth.exist(),
user,
editable,
};
@@ -735,6 +788,7 @@ impl Server {
&self,
path: &Path,
headers: &HeaderMap<HeaderValue>,
access_paths: AccessPaths,
res: &mut Response,
) -> Result<()> {
let depth: u32 = match headers.get("depth") {
@@ -752,7 +806,7 @@ impl Server {
None => vec![],
};
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),
Err(_) => {
status_forbid(res);
@@ -926,7 +980,7 @@ impl Server {
allow_search: self.args.allow_search,
allow_archive: self.args.allow_archive,
dir_exists: exist,
auth: self.args.auth.valid(),
auth: self.args.auth.exist(),
user,
paths,
};
@@ -951,10 +1005,10 @@ impl Server {
}
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()?;
set_webdav_headers(res);
res.headers_mut().typed_insert(Connection::close());
res.headers_mut().insert(WWW_AUTHENTICATE, value.parse()?);
// set 401 to make the browser pop up the login box
*res.status_mut() = StatusCode::UNAUTHORIZED;
Ok(())
}
@@ -976,20 +1030,32 @@ impl Server {
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 guard_type = self.args.auth.guard(
&dest_path,
let guard = self.args.auth.guard(
&relative_path,
req.method(),
authorization,
self.args.auth_method.clone(),
);
if guard_type.is_reject() {
*res.status_mut() = StatusCode::FORBIDDEN;
*res.body_mut() = Body::from("Forbidden");
match guard {
(_, Some(_)) => {}
_ => {
status_forbid(res);
return None;
}
};
let dest = match self.extract_path(&dest_path) {
let dest = match self.join_path(&relative_path) {
Some(dest) => dest,
None => {
*res.status_mut() = StatusCode::BAD_REQUEST;
@@ -1006,49 +1072,61 @@ impl Server {
Some(uri.path().to_string())
}
fn extract_path(&self, path: &str) -> Option<PathBuf> {
let mut slash_stripped_path = path;
while let Some(p) = slash_stripped_path.strip_prefix('/') {
slash_stripped_path = p
fn resolve_path(&self, path: &str) -> Option<String> {
let path = path.trim_matches('/');
let path = decode_uri(path)?;
let prefix = self.args.path_prefix.as_str();
if prefix == "/" {
return Some(path.to_string());
}
let decoded_path = decode_uri(slash_stripped_path)?;
let slashes_switched = if cfg!(windows) {
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))
path.strip_prefix(prefix.trim_start_matches('/'))
.map(|v| v.trim_matches('/').to_string())
}
fn strip_path_prefix<'a, P: AsRef<Path>>(&self, path: &'a P) -> Option<&'a Path> {
let path = path.as_ref();
if self.args.path_prefix.is_empty() {
Some(path)
} else {
path.strip_prefix(&self.args.path_prefix).ok()
fn join_path(&self, path: &str) -> Option<PathBuf> {
if path.is_empty() {
return Some(self.args.path.clone());
}
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![];
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?;
while let Ok(Some(entry)) = rd.next_entry().await {
let entry_path = entry.path();
let base_name = get_file_name(&entry_path);
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);
self.add_pathitem(&mut paths, base_path, &entry_path).await;
}
}
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>> {
let path = path.as_ref();
let (meta, meta2) = tokio::join!(fs::metadata(&path), fs::symlink_metadata(&path));
@@ -1078,10 +1156,6 @@ impl Server {
size,
}))
}
fn retrieve_user(&self, authorization: Option<&HeaderValue>) -> Option<String> {
self.args.auth_method.get_user(authorization?)
}
}
#[derive(Debug, Serialize)]
@@ -1218,17 +1292,15 @@ fn add_cors(res: &mut Response) {
.typed_insert(AccessControlAllowCredentials);
res.headers_mut().insert(
"Access-Control-Allow-Methods",
HeaderValue::from_static("GET,HEAD,PUT,OPTIONS,DELETE,PROPFIND,COPY,MOVE"),
HeaderValue::from_static("*"),
);
res.headers_mut().insert(
"Access-Control-Allow-Headers",
HeaderValue::from_static("Authorization,Destination,Range"),
HeaderValue::from_static("Authorization,*"),
);
res.headers_mut().insert(
"Access-Control-Expose-Headers",
HeaderValue::from_static(
"WWW-Authenticate,Content-Range,Accept-Ranges,Content-Disposition",
),
HeaderValue::from_static("Authorization,*"),
);
}
@@ -1249,16 +1321,18 @@ fn res_multistatus(res: &mut Response, content: &str) {
async fn zip_dir<W: AsyncWrite + Unpin>(
writer: &mut W,
dir: &Path,
access_paths: AccessPaths,
hidden: &[String],
running: Arc<AtomicBool>,
) -> Result<()> {
let mut writer = ZipFileWriter::new(writer);
let mut writer = ZipFileWriter::with_tokio(writer);
let hidden = Arc::new(hidden.to_vec());
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 mut it = WalkDir::new(&dir_path_buf).into_iter();
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() {
if !running.load(Ordering::SeqCst) {
break;
@@ -1291,6 +1365,7 @@ async fn zip_dir<W: AsyncWrite + Unpin>(
}
paths.push(entry_path.to_path_buf());
}
}
paths
})
.await?;
@@ -1299,12 +1374,14 @@ async fn zip_dir<W: AsyncWrite + Unpin>(
Some(v) => v,
None => continue,
};
let builder =
ZipEntryBuilder::new(filename.into(), Compression::Deflate).unix_permissions(0o644);
let (datetime, mode) = get_file_mtime_and_mode(&zip_path).await?;
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_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?;
file_writer.close().await?;
file_writer.into_inner().close().await?;
}
writer.close().await?;
Ok(())
@@ -1363,6 +1440,21 @@ fn status_no_content(res: &mut Response) {
*res.status_mut() = StatusCode::NO_CONTENT;
}
fn set_content_diposition(res: &mut Response, inline: bool, filename: &str) -> Result<()> {
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 {
hidden.iter().any(|v| {
if is_dir_type {
@@ -1382,3 +1474,34 @@ fn set_webdav_headers(res: &mut Response) {
res.headers_mut()
.insert("DAV", HeaderValue::from_static("1,2"));
}
async fn get_content_type(path: &Path) -> Result<String> {
let mut buffer: Vec<u8> = vec![];
fs::File::open(path)
.await?
.take(1024)
.read_to_end(&mut buffer)
.await?;
let mime = mime_guess::from_path(path).first();
let is_text = content_inspector::inspect(&buffer).is_text();
let content_type = if is_text {
let mut detector = chardetng::EncodingDetector::new();
detector.feed(&buffer, buffer.len() < 1024);
let (enc, confident) = detector.guess_assess(None, true);
let charset = if confident {
format!("; charset={}", enc.name())
} else {
"".into()
};
match mime {
Some(m) => format!("{m}{charset}"),
None => format!("text/plain{charset}"),
}
} else {
match mime {
Some(m) => m.to_string(),
None => "application/octet-stream".into(),
}
};
Ok(content_type)
}

View File

@@ -1,4 +1,4 @@
use anyhow::{anyhow, bail, Result};
use anyhow::{anyhow, bail, Context as AnyhowContext, Result};
use core::task::{Context, Poll};
use futures::ready;
use hyper::server::accept::Accept;
@@ -128,12 +128,11 @@ impl Accept for TlsAcceptor {
pub fn load_certs<T: AsRef<Path>>(filename: T) -> Result<Vec<Certificate>> {
// Open certificate file.
let cert_file = fs::File::open(filename.as_ref())
.map_err(|e| anyhow!("Failed to access `{}`, {e}", filename.as_ref().display()))?;
.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(|_| anyhow!("Failed to load certificate"))?;
let certs = rustls_pemfile::certs(&mut reader).with_context(|| "Failed to load certificate")?;
if certs.is_empty() {
bail!("No supported certificate in file");
}
@@ -143,12 +142,12 @@ pub fn load_certs<T: AsRef<Path>>(filename: T) -> Result<Vec<Certificate>> {
// Load private key from file.
pub fn load_private_key<T: AsRef<Path>>(filename: T) -> Result<PrivateKey> {
let key_file = fs::File::open(filename.as_ref())
.map_err(|e| anyhow!("Failed to access `{}`, {e}", filename.as_ref().display()))?;
.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| anyhow!("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)

View File

@@ -1,4 +1,5 @@
use anyhow::{anyhow, Result};
use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Utc};
use std::{
borrow::Cow,
path::Path,
@@ -8,7 +9,7 @@ use std::{
pub fn unix_now() -> Result<Duration> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|err| anyhow!("Invalid system time, {err}"))
.with_context(|| "Invalid system time")
}
pub fn encode_uri(v: &str) -> String {
@@ -28,51 +29,33 @@ pub fn get_file_name(path: &Path) -> &str {
.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> {
path.file_name()
.and_then(|v| v.to_str())
.ok_or_else(|| anyhow!("Failed to get file name of `{}`", path.display()))
}
pub fn glob(source: &str, target: &str) -> bool {
let ss: Vec<char> = source.chars().collect();
let mut iter = target.chars();
let mut i = 0;
'outer: while i < ss.len() {
let s = ss[i];
match s {
'*' => match ss.get(i + 1) {
Some(s_next) => {
for t in iter.by_ref() {
if t == *s_next {
i += 2;
continue 'outer;
}
}
return false;
}
None => return true,
},
'?' => match iter.next() {
Some(_) => {
i += 1;
continue;
}
None => return false,
},
_ => match iter.next() {
Some(t) => {
if s == t {
i += 1;
continue;
}
return false;
}
None => return false,
},
}
}
iter.next().is_none()
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]
@@ -91,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

@@ -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,7 +30,24 @@ fn auth(#[with(&["--auth", "/@user:pass", "-A"])] server: TestServer) -> Result<
}
#[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())?;
assert_eq!(resp.status(), 200);
Ok(())
@@ -37,7 +55,7 @@ fn auth_skip(#[with(&["--auth", "/@user:pass@*"])] server: TestServer) -> Result
#[rstest]
fn auth_skip_on_options_method(
#[with(&["--auth", "/@user:pass"])] server: TestServer,
#[with(&["--auth", "user:pass@/:rw"])] server: TestServer,
) -> Result<(), Error> {
let url = format!("{}index.html", server.url());
let resp = fetch!(b"OPTIONS", &url).send()?;
@@ -47,13 +65,13 @@ fn auth_skip_on_options_method(
#[rstest]
fn auth_check(
#[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"WRITEABLE", &url).send()?;
assert_eq!(resp.status(), 401);
let resp = fetch!(b"WRITEABLE", &url).send_with_digest_auth("user2", "pass2")?;
assert_eq!(resp.status(), 401);
assert_eq!(resp.status(), 403);
let resp = fetch!(b"WRITEABLE", &url).send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 200);
Ok(())
@@ -61,7 +79,7 @@ fn auth_check(
#[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()?;
@@ -72,13 +90,13 @@ 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", "/dir1@user3:pass3", "-A"])]
#[with(&["--auth", "user:pass@/:rw|user2:pass2@/", "--auth", "user3:pass3@/dir1:rw", "-A"])]
server: TestServer,
) -> Result<(), Error> {
let url = format!("{}dir1/file1", server.url());
@@ -97,7 +115,8 @@ fn auth_nest(
#[rstest]
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> {
let url = format!("{}index.html", server.url());
let resp = fetch!(b"GET", &url).send()?;
@@ -106,15 +125,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(())
@@ -122,7 +145,8 @@ fn auth_basic(
#[rstest]
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> {
let origin_url = format!("{}dir1/test.html", server.url());
let new_url = format!("{}test2.html", server.url());
@@ -135,7 +159,8 @@ fn auth_webdav_move(
#[rstest]
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> {
let origin_url = format!("{}dir1/test.html", server.url());
let new_url = format!("{}test2.html", server.url());
@@ -148,7 +173,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()?;
@@ -157,3 +182,34 @@ 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(())
}
#[rstest]
fn no_auth_propfind_dir(
#[with(&["--auth", "user:pass@/:rw", "--auth", "@/dir-assets", "-A"])] server: TestServer,
) -> Result<(), Error> {
let resp = fetch!(b"PROPFIND", server.url()).send()?;
assert_eq!(resp.status(), 207);
let body = resp.text()?;
assert!(body.contains("<D:href>/dir-assets/</D:href>"));
assert!(body.contains("<D:href>/dir1/</D:href>"));
Ok(())
}

View File

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

View File

@@ -46,15 +46,12 @@ pub fn tmpdir() -> TempDir {
let tmpdir = assert_fs::TempDir::new().expect("Couldn't create a temp dir for tests");
for file in FILES {
if *file == BIN_FILE {
tmpdir
.child(file)
.write_binary(b"bin\0\0123")
.expect("Couldn't write to file");
tmpdir.child(file).write_binary(b"bin\0\0123").unwrap();
} else {
tmpdir
.child(file)
.write_str(&format!("This is {file}"))
.expect("Couldn't write to file");
.unwrap();
}
}
for directory in DIRECTORIES {
@@ -62,7 +59,7 @@ pub fn tmpdir() -> TempDir {
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" {
@@ -72,17 +69,37 @@ pub fn tmpdir() -> TempDir {
tmpdir
.child(format!("{directory}{file}"))
.write_binary(b"bin\0\0123")
.expect("Couldn't write to file");
.unwrap();
} else {
tmpdir
.child(format!("{directory}{file}"))
.write_str(&format!("This is {directory}{file}"))
.expect("Couldn't write to 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
}

View File

@@ -148,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"));
@@ -161,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"));
@@ -178,6 +184,17 @@ 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()?;
@@ -259,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,

View File

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