Dufs is a distinctive utility file server - https://github.com/sigoden/dufs
USAGE:
- dufs [OPTIONS] [--] [path]
+ dufs [OPTIONS] [--] [root]
ARGS:
- <path> Specific path to serve [default: .]
+ <root> Specific path to serve [default: .]
OPTIONS:
-b, --bind <addr>... Specify bind address
--render-index Serve index.html when requesting a directory, returns 404 if not found index.html
--render-try-index Serve index.html when requesting a directory, returns directory listing if not found index.html
--render-spa Serve SPA(Single Page Application)
+ --assets <path> Use custom assets to override builtin assets
--tls-cert <path> Path to an SSL/TLS certificate to serve with HTTPS
--tls-key <path> Path to the SSL/TLS certificate's private key
--log-format <format> Customize http log format
</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
Copyright (c) 2022 dufs-developers.
<head>
<meta charset="utf-8" />
<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>
<body>
<div class="head">
.value_name("port"),
)
.arg(
- Arg::new("path")
+ Arg::new("root")
.default_value(".")
.allow_invalid_utf8(true)
.help("Specific path to serve"),
Arg::new("render-spa")
.long("render-spa")
.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")]
pub render_spa: bool,
pub render_try_index: bool,
pub enable_cors: bool,
+ pub assets_path: Option<PathBuf>,
pub log_http: LogHttp,
#[cfg(feature = "tls")]
pub tls: Option<(Vec<Certificate>, PrivateKey)>,
.map(|v| v.collect())
.unwrap_or_else(|| vec!["0.0.0.0", "::"]);
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_prefix = matches
.value_of("path-prefix")
.value_of("log-format")
.unwrap_or(DEFAULT_LOG_FORMAT)
.parse()?;
+ let assets_path = match matches.value_of_os("assets") {
+ Some(v) => Some(Args::parse_assets_path(v)?),
+ None => None,
+ };
Ok(Args {
addrs,
render_spa,
tls,
log_http,
+ assets_path,
})
}
})
.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)
+ }
}
};
use hyper::{Body, Method, StatusCode, Uri};
use serde::Serialize;
+use std::borrow::Cow;
use std::collections::HashMap;
use std::fs::Metadata;
use std::io::SeekFrom;
pub struct Server {
args: Arc<Args>,
assets_prefix: String,
+ html: Cow<'static, str>,
single_file_req_paths: Vec<String>,
running: Arc<AtomicBool>,
}
} else {
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 {
args,
running,
single_file_req_paths,
assets_prefix,
+ html,
}
}
let headers = req.headers();
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);
}
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) {
- match name {
- "index.js" => {
- *res.body_mut() = Body::from(INDEX_JS);
- res.headers_mut().insert(
- "content-type",
- HeaderValue::from_static("application/javascript"),
- );
- }
- "index.css" => {
- *res.body_mut() = Body::from(INDEX_CSS);
- res.headers_mut()
- .insert("content-type", HeaderValue::from_static("text/css"));
- }
- "favicon.ico" => {
- *res.body_mut() = Body::from(FAVICON_ICO);
- res.headers_mut()
- .insert("content-type", HeaderValue::from_static("image/x-icon"));
- }
- _ => {
- return Ok(false);
+ 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" => {
+ *res.body_mut() = Body::from(INDEX_JS);
+ res.headers_mut().insert(
+ "content-type",
+ HeaderValue::from_static("application/javascript"),
+ );
+ }
+ "index.css" => {
+ *res.body_mut() = Body::from(INDEX_CSS);
+ res.headers_mut()
+ .insert("content-type", HeaderValue::from_static("text/css"));
+ }
+ "favicon.ico" => {
+ *res.body_mut() = Body::from(FAVICON_ICO);
+ res.headers_mut()
+ .insert("content-type", HeaderValue::from_static("image/x-icon"));
+ }
+ _ => {
+ status_not_found(res);
+ }
+ },
}
res.headers_mut().insert(
"cache-control",
dir_exists: exist,
};
let data = serde_json::to_string(&data).unwrap();
- let asset_js = format!("{}index.js", self.assets_prefix);
- let asset_css = format!("{}index.css", self.assets_prefix);
- let asset_ico = format!("{}favicon.ico", self.assets_prefix);
- let output = INDEX_HTML.replace(
- "__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
- ),
- );
+ let output = self
+ .html
+ .replace("__ASSERTS_PREFIX__", &self.assets_prefix)
+ .replace("__INDEX_DATA__", &data);
res.headers_mut()
.typed_insert(ContentType::from(mime_guess::mime::TEXT_HTML_UTF_8));
res.headers_mut()
mod fixtures;
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 std::process::{Command, Stdio};
#[rstest]
fn assets(server: TestServer) -> Result<(), Error> {
);
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(())
+}
#[allow(dead_code)]
pub static DIR_GIT: &str = ".git/";
+/// Directory names for testings assets override
+#[allow(dead_code)]
+pub static DIR_ASSETS: &str = "dir-assets/";
+
/// Directory names for testing purpose
#[allow(dead_code)]
-pub static DIRECTORIES: &[&str] = &["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.
/// The directories also contain files.
.expect("Couldn't write to file");
}
for directory in DIRECTORIES {
- for file in FILES {
- if *directory == DIR_NO_INDEX && *file == "index.html" {
- continue;
- }
+ if *directory == DIR_ASSETS {
tmpdir
- .child(format!("{}{}", directory, file))
- .write_str(&format!("This is {}{}", directory, file))
+ .child(format!("{}{}", directory, "index.html"))
+ .write_str("__ASSERTS_PREFIX__index.js;DATA = __INDEX_DATA__")
.expect("Couldn't write to file");
+ } else {
+ for file in FILES {
+ if *directory == DIR_NO_INDEX && *file == "index.html" {
+ continue;
+ }
+ tmpdir
+ .child(format!("{}{}", directory, file))
+ .write_str(&format!("This is {}{}", directory, file))
+ .expect("Couldn't write to file");
+ }
}
}
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.
pub fn wait_for_port(port: u16) {
let start_wait = Instant::now();
fn retrieve_index_paths_impl(index: &str) -> Option<IndexSet<String>> {
let lines: Vec<&str> = index.lines().collect();
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
.get("paths")?
.as_array()?