feat: add --assets options to override assets (#134)
* feat: add --assets options to override assets * update readme
This commit is contained in:
parent
bde06fef94
commit
a74e40aee5
7 changed files with 142 additions and 81 deletions
21
README.md
21
README.md
|
@ -43,10 +43,10 @@ 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:
|
USAGE:
|
||||||
dufs [OPTIONS] [--] [path]
|
dufs [OPTIONS] [--] [root]
|
||||||
|
|
||||||
ARGS:
|
ARGS:
|
||||||
<path> Specific path to serve [default: .]
|
<root> Specific path to serve [default: .]
|
||||||
|
|
||||||
OPTIONS:
|
OPTIONS:
|
||||||
-b, --bind <addr>... Specify bind address
|
-b, --bind <addr>... Specify bind address
|
||||||
|
@ -64,6 +64,7 @@ OPTIONS:
|
||||||
--render-index Serve index.html when requesting a directory, returns 404 if not found index.html
|
--render-index Serve index.html when requesting a directory, returns 404 if not found index.html
|
||||||
--render-try-index Serve index.html when requesting a directory, returns directory listing if not found index.html
|
--render-try-index Serve index.html when requesting a directory, returns directory listing if not found index.html
|
||||||
--render-spa Serve SPA(Single Page Application)
|
--render-spa Serve SPA(Single Page Application)
|
||||||
|
--assets <path> Use custom assets to override builtin assets
|
||||||
--tls-cert <path> Path to an SSL/TLS certificate to serve with HTTPS
|
--tls-cert <path> Path to an SSL/TLS certificate to serve with HTTPS
|
||||||
--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
|
||||||
|
@ -264,6 +265,22 @@ dufs --log-format '$remote_addr $remote_user "$request" $status' -a /@admin:admi
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
||||||
|
### Customize UI
|
||||||
|
|
||||||
|
Dufs allows users to customize the UI with their own assets.
|
||||||
|
|
||||||
|
```
|
||||||
|
dufs --assets my-assets
|
||||||
|
```
|
||||||
|
|
||||||
|
You assets folder must contains a entrypoint `index.html`.
|
||||||
|
|
||||||
|
`index.html` can use the following planceholder to access internal data.
|
||||||
|
|
||||||
|
- `__INDEX_DATA__`: directory listing data
|
||||||
|
- `__ASSERTS_PREFIX__`: assets url prefix
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Copyright (c) 2022 dufs-developers.
|
Copyright (c) 2022 dufs-developers.
|
||||||
|
|
|
@ -4,7 +4,12 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width" />
|
<meta name="viewport" content="width=device-width" />
|
||||||
__SLOT__
|
<link rel="icon" type="image/x-icon" href="__ASSERTS_PREFIX__favicon.ico">
|
||||||
|
<link rel="stylesheet" href="__ASSERTS_PREFIX__index.css">
|
||||||
|
<script>
|
||||||
|
DATA = __INDEX_DATA__
|
||||||
|
</script>
|
||||||
|
<script src="__ASSERTS_PREFIX__index.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="head">
|
<div class="head">
|
||||||
|
|
25
src/args.rs
25
src/args.rs
|
@ -43,7 +43,7 @@ pub fn build_cli() -> Command<'static> {
|
||||||
.value_name("port"),
|
.value_name("port"),
|
||||||
)
|
)
|
||||||
.arg(
|
.arg(
|
||||||
Arg::new("path")
|
Arg::new("root")
|
||||||
.default_value(".")
|
.default_value(".")
|
||||||
.allow_invalid_utf8(true)
|
.allow_invalid_utf8(true)
|
||||||
.help("Specific path to serve"),
|
.help("Specific path to serve"),
|
||||||
|
@ -126,6 +126,13 @@ pub fn build_cli() -> Command<'static> {
|
||||||
Arg::new("render-spa")
|
Arg::new("render-spa")
|
||||||
.long("render-spa")
|
.long("render-spa")
|
||||||
.help("Serve SPA(Single Page Application)"),
|
.help("Serve SPA(Single Page Application)"),
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
Arg::new("assets")
|
||||||
|
.long("assets")
|
||||||
|
.help("Use custom assets to override builtin assets")
|
||||||
|
.allow_invalid_utf8(true)
|
||||||
|
.value_name("path")
|
||||||
);
|
);
|
||||||
|
|
||||||
#[cfg(feature = "tls")]
|
#[cfg(feature = "tls")]
|
||||||
|
@ -181,6 +188,7 @@ pub struct Args {
|
||||||
pub render_spa: bool,
|
pub render_spa: bool,
|
||||||
pub render_try_index: bool,
|
pub render_try_index: bool,
|
||||||
pub enable_cors: bool,
|
pub enable_cors: bool,
|
||||||
|
pub assets_path: Option<PathBuf>,
|
||||||
pub log_http: LogHttp,
|
pub log_http: LogHttp,
|
||||||
#[cfg(feature = "tls")]
|
#[cfg(feature = "tls")]
|
||||||
pub tls: Option<(Vec<Certificate>, PrivateKey)>,
|
pub tls: Option<(Vec<Certificate>, PrivateKey)>,
|
||||||
|
@ -200,7 +208,7 @@ impl Args {
|
||||||
.map(|v| v.collect())
|
.map(|v| v.collect())
|
||||||
.unwrap_or_else(|| vec!["0.0.0.0", "::"]);
|
.unwrap_or_else(|| vec!["0.0.0.0", "::"]);
|
||||||
let addrs: Vec<IpAddr> = Args::parse_addrs(&addrs)?;
|
let addrs: Vec<IpAddr> = Args::parse_addrs(&addrs)?;
|
||||||
let path = Args::parse_path(matches.value_of_os("path").unwrap_or_default())?;
|
let path = Args::parse_path(matches.value_of_os("root").unwrap_or_default())?;
|
||||||
let path_is_file = path.metadata()?.is_file();
|
let path_is_file = path.metadata()?.is_file();
|
||||||
let path_prefix = matches
|
let path_prefix = matches
|
||||||
.value_of("path-prefix")
|
.value_of("path-prefix")
|
||||||
|
@ -247,6 +255,10 @@ impl Args {
|
||||||
.value_of("log-format")
|
.value_of("log-format")
|
||||||
.unwrap_or(DEFAULT_LOG_FORMAT)
|
.unwrap_or(DEFAULT_LOG_FORMAT)
|
||||||
.parse()?;
|
.parse()?;
|
||||||
|
let assets_path = match matches.value_of_os("assets") {
|
||||||
|
Some(v) => Some(Args::parse_assets_path(v)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
Ok(Args {
|
Ok(Args {
|
||||||
addrs,
|
addrs,
|
||||||
|
@ -268,6 +280,7 @@ impl Args {
|
||||||
render_spa,
|
render_spa,
|
||||||
tls,
|
tls,
|
||||||
log_http,
|
log_http,
|
||||||
|
assets_path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -303,4 +316,12 @@ impl Args {
|
||||||
})
|
})
|
||||||
.map_err(|err| format!("Failed to access path `{}`: {}", path.display(), err,).into())
|
.map_err(|err| format!("Failed to access path `{}`: {}", path.display(), err,).into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_assets_path<P: AsRef<Path>>(path: P) -> BoxResult<PathBuf> {
|
||||||
|
let path = Self::parse_path(path)?;
|
||||||
|
if !path.join("index.html").exists() {
|
||||||
|
return Err(format!("Path `{}` doesn't contains index.html", path.display()).into());
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,7 @@ use hyper::header::{
|
||||||
};
|
};
|
||||||
use hyper::{Body, Method, StatusCode, Uri};
|
use hyper::{Body, Method, StatusCode, Uri};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use std::borrow::Cow;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs::Metadata;
|
use std::fs::Metadata;
|
||||||
use std::io::SeekFrom;
|
use std::io::SeekFrom;
|
||||||
|
@ -46,6 +47,7 @@ const BUF_SIZE: usize = 65536;
|
||||||
pub struct Server {
|
pub struct Server {
|
||||||
args: Arc<Args>,
|
args: Arc<Args>,
|
||||||
assets_prefix: String,
|
assets_prefix: String,
|
||||||
|
html: Cow<'static, str>,
|
||||||
single_file_req_paths: Vec<String>,
|
single_file_req_paths: Vec<String>,
|
||||||
running: Arc<AtomicBool>,
|
running: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
@ -66,11 +68,16 @@ impl Server {
|
||||||
} else {
|
} else {
|
||||||
vec![]
|
vec![]
|
||||||
};
|
};
|
||||||
|
let html = match args.assets_path.as_ref() {
|
||||||
|
Some(path) => Cow::Owned(std::fs::read_to_string(path.join("index.html")).unwrap()),
|
||||||
|
None => Cow::Borrowed(INDEX_HTML),
|
||||||
|
};
|
||||||
Self {
|
Self {
|
||||||
args,
|
args,
|
||||||
running,
|
running,
|
||||||
single_file_req_paths,
|
single_file_req_paths,
|
||||||
assets_prefix,
|
assets_prefix,
|
||||||
|
html,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -118,7 +125,7 @@ impl Server {
|
||||||
let headers = req.headers();
|
let headers = req.headers();
|
||||||
let method = req.method().clone();
|
let method = req.method().clone();
|
||||||
|
|
||||||
if method == Method::GET && self.handle_embed_assets(req_path, &mut res).await? {
|
if method == Method::GET && self.handle_assets(req_path, headers, &mut res).await? {
|
||||||
return Ok(res);
|
return Ok(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -496,9 +503,19 @@ impl Server {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_embed_assets(&self, req_path: &str, res: &mut Response) -> BoxResult<bool> {
|
async fn handle_assets(
|
||||||
|
&self,
|
||||||
|
req_path: &str,
|
||||||
|
headers: &HeaderMap<HeaderValue>,
|
||||||
|
res: &mut Response,
|
||||||
|
) -> BoxResult<bool> {
|
||||||
if let Some(name) = req_path.strip_prefix(&self.assets_prefix) {
|
if let Some(name) = req_path.strip_prefix(&self.assets_prefix) {
|
||||||
match name {
|
match self.args.assets_path.as_ref() {
|
||||||
|
Some(assets_path) => {
|
||||||
|
let path = assets_path.join(name);
|
||||||
|
self.handle_send_file(&path, headers, false, res).await?;
|
||||||
|
}
|
||||||
|
None => match name {
|
||||||
"index.js" => {
|
"index.js" => {
|
||||||
*res.body_mut() = Body::from(INDEX_JS);
|
*res.body_mut() = Body::from(INDEX_JS);
|
||||||
res.headers_mut().insert(
|
res.headers_mut().insert(
|
||||||
|
@ -517,8 +534,9 @@ impl Server {
|
||||||
.insert("content-type", HeaderValue::from_static("image/x-icon"));
|
.insert("content-type", HeaderValue::from_static("image/x-icon"));
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
return Ok(false);
|
status_not_found(res);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
res.headers_mut().insert(
|
res.headers_mut().insert(
|
||||||
"cache-control",
|
"cache-control",
|
||||||
|
@ -802,23 +820,10 @@ impl Server {
|
||||||
dir_exists: exist,
|
dir_exists: exist,
|
||||||
};
|
};
|
||||||
let data = serde_json::to_string(&data).unwrap();
|
let data = serde_json::to_string(&data).unwrap();
|
||||||
let asset_js = format!("{}index.js", self.assets_prefix);
|
let output = self
|
||||||
let asset_css = format!("{}index.css", self.assets_prefix);
|
.html
|
||||||
let asset_ico = format!("{}favicon.ico", self.assets_prefix);
|
.replace("__ASSERTS_PREFIX__", &self.assets_prefix)
|
||||||
let output = INDEX_HTML.replace(
|
.replace("__INDEX_DATA__", &data);
|
||||||
"__SLOT__",
|
|
||||||
&format!(
|
|
||||||
r#"
|
|
||||||
<link rel="icon" type="image/x-icon" href="{}">
|
|
||||||
<link rel="stylesheet" href="{}">
|
|
||||||
<script>
|
|
||||||
DATA = {}
|
|
||||||
</script>
|
|
||||||
<script src="{}"></script>
|
|
||||||
"#,
|
|
||||||
asset_ico, asset_css, data, asset_js
|
|
||||||
),
|
|
||||||
);
|
|
||||||
res.headers_mut()
|
res.headers_mut()
|
||||||
.typed_insert(ContentType::from(mime_guess::mime::TEXT_HTML_UTF_8));
|
.typed_insert(ContentType::from(mime_guess::mime::TEXT_HTML_UTF_8));
|
||||||
res.headers_mut()
|
res.headers_mut()
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
mod fixtures;
|
mod fixtures;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
use fixtures::{server, Error, TestServer};
|
use assert_cmd::prelude::*;
|
||||||
|
use assert_fs::fixture::TempDir;
|
||||||
|
use fixtures::{port, server, tmpdir, wait_for_port, Error, TestServer, DIR_ASSETS};
|
||||||
use rstest::rstest;
|
use rstest::rstest;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
fn assets(server: TestServer) -> Result<(), Error> {
|
fn assets(server: TestServer) -> Result<(), Error> {
|
||||||
|
@ -91,3 +94,29 @@ fn asset_js_with_prefix(
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn assets_override(tmpdir: TempDir, port: u16) -> Result<(), Error> {
|
||||||
|
let mut child = Command::cargo_bin("dufs")?
|
||||||
|
.arg(tmpdir.path())
|
||||||
|
.arg("-p")
|
||||||
|
.arg(port.to_string())
|
||||||
|
.arg("--assets")
|
||||||
|
.arg(tmpdir.join(DIR_ASSETS))
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.spawn()?;
|
||||||
|
|
||||||
|
wait_for_port(port);
|
||||||
|
|
||||||
|
let url = format!("http://localhost:{}", port);
|
||||||
|
let resp = reqwest::blocking::get(&url)?;
|
||||||
|
assert!(resp.text()?.starts_with(&format!(
|
||||||
|
"/__dufs_v{}_index.js;DATA",
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
)));
|
||||||
|
let resp = reqwest::blocking::get(&url)?;
|
||||||
|
assert_resp_paths!(resp);
|
||||||
|
|
||||||
|
child.kill()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
@ -27,9 +27,13 @@ pub static DIR_NO_INDEX: &str = "dir-no-index/";
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub static DIR_GIT: &str = ".git/";
|
pub static DIR_GIT: &str = ".git/";
|
||||||
|
|
||||||
|
/// Directory names for testings assets override
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub static DIR_ASSETS: &str = "dir-assets/";
|
||||||
|
|
||||||
/// Directory names for testing purpose
|
/// Directory names for testing purpose
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub static DIRECTORIES: &[&str] = &["dira/", "dirb/", "dirc/", DIR_NO_INDEX, DIR_GIT];
|
pub static DIRECTORIES: &[&str] = &["dira/", "dirb/", "dirc/", DIR_NO_INDEX, DIR_GIT, DIR_ASSETS];
|
||||||
|
|
||||||
/// Test fixture which creates a temporary directory with a few files and directories inside.
|
/// Test fixture which creates a temporary directory with a few files and directories inside.
|
||||||
/// The directories also contain files.
|
/// The directories also contain files.
|
||||||
|
@ -44,6 +48,12 @@ pub fn tmpdir() -> TempDir {
|
||||||
.expect("Couldn't write to file");
|
.expect("Couldn't write to file");
|
||||||
}
|
}
|
||||||
for directory in DIRECTORIES {
|
for directory in DIRECTORIES {
|
||||||
|
if *directory == DIR_ASSETS {
|
||||||
|
tmpdir
|
||||||
|
.child(format!("{}{}", directory, "index.html"))
|
||||||
|
.write_str("__ASSERTS_PREFIX__index.js;DATA = __INDEX_DATA__")
|
||||||
|
.expect("Couldn't write to file");
|
||||||
|
} else {
|
||||||
for file in FILES {
|
for file in FILES {
|
||||||
if *directory == DIR_NO_INDEX && *file == "index.html" {
|
if *directory == DIR_NO_INDEX && *file == "index.html" {
|
||||||
continue;
|
continue;
|
||||||
|
@ -54,6 +64,7 @@ pub fn tmpdir() -> TempDir {
|
||||||
.expect("Couldn't write to file");
|
.expect("Couldn't write to file");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tmpdir
|
tmpdir
|
||||||
}
|
}
|
||||||
|
@ -93,34 +104,6 @@ where
|
||||||
TestServer::new(port, tmpdir, child, is_tls)
|
TestServer::new(port, tmpdir, child, is_tls)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Same as `server()` but ignore stderr
|
|
||||||
#[fixture]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn server_no_stderr<I>(#[default(&[] as &[&str])] args: I) -> TestServer
|
|
||||||
where
|
|
||||||
I: IntoIterator + Clone,
|
|
||||||
I::Item: AsRef<std::ffi::OsStr>,
|
|
||||||
{
|
|
||||||
let port = port();
|
|
||||||
let tmpdir = tmpdir();
|
|
||||||
let child = Command::cargo_bin("dufs")
|
|
||||||
.expect("Couldn't find test binary")
|
|
||||||
.arg(tmpdir.path())
|
|
||||||
.arg("-p")
|
|
||||||
.arg(port.to_string())
|
|
||||||
.args(args.clone())
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.spawn()
|
|
||||||
.expect("Couldn't run test binary");
|
|
||||||
let is_tls = args
|
|
||||||
.into_iter()
|
|
||||||
.any(|x| x.as_ref().to_str().unwrap().contains("tls"));
|
|
||||||
|
|
||||||
wait_for_port(port);
|
|
||||||
TestServer::new(port, tmpdir, child, is_tls)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wait a max of 1s for the port to become available.
|
/// Wait a max of 1s for the port to become available.
|
||||||
pub fn wait_for_port(port: u16) {
|
pub fn wait_for_port(port: u16) {
|
||||||
let start_wait = Instant::now();
|
let start_wait = Instant::now();
|
||||||
|
|
|
@ -38,7 +38,8 @@ pub fn encode_uri(v: &str) -> String {
|
||||||
fn retrieve_index_paths_impl(index: &str) -> Option<IndexSet<String>> {
|
fn retrieve_index_paths_impl(index: &str) -> Option<IndexSet<String>> {
|
||||||
let lines: Vec<&str> = index.lines().collect();
|
let lines: Vec<&str> = index.lines().collect();
|
||||||
let line = lines.iter().find(|v| v.contains("DATA ="))?;
|
let line = lines.iter().find(|v| v.contains("DATA ="))?;
|
||||||
let value: Value = line[7..].parse().ok()?;
|
let line_col = line.find("DATA =").unwrap() + 6;
|
||||||
|
let value: Value = line[line_col..].parse().ok()?;
|
||||||
let paths = value
|
let paths = value
|
||||||
.get("paths")?
|
.get("paths")?
|
||||||
.as_array()?
|
.as_array()?
|
||||||
|
|
Loading…
Reference in a new issue