diff --git a/README.md b/README.md index 3477382..236e03b 100644 --- a/README.md +++ b/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 USAGE: - dufs [OPTIONS] [--] [path] + dufs [OPTIONS] [--] [root] ARGS: - Specific path to serve [default: .] + Specific path to serve [default: .] OPTIONS: -b, --bind ... 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-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 Use custom assets to override builtin assets --tls-cert Path to an SSL/TLS certificate to serve with HTTPS --tls-key Path to the SSL/TLS certificate's private key --log-format Customize http log format @@ -264,6 +265,22 @@ dufs --log-format '$remote_addr $remote_user "$request" $status' -a /@admin:admi + +### 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. diff --git a/assets/index.html b/assets/index.html index 927d7fc..7a4cbe1 100644 --- a/assets/index.html +++ b/assets/index.html @@ -4,7 +4,12 @@ - __SLOT__ + + + +
diff --git a/src/args.rs b/src/args.rs index 2a2ba74..2eb382c 100644 --- a/src/args.rs +++ b/src/args.rs @@ -43,7 +43,7 @@ pub fn build_cli() -> Command<'static> { .value_name("port"), ) .arg( - Arg::new("path") + Arg::new("root") .default_value(".") .allow_invalid_utf8(true) .help("Specific path to serve"), @@ -126,6 +126,13 @@ pub fn build_cli() -> Command<'static> { 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")] @@ -181,6 +188,7 @@ pub struct Args { pub render_spa: bool, pub render_try_index: bool, pub enable_cors: bool, + pub assets_path: Option, pub log_http: LogHttp, #[cfg(feature = "tls")] pub tls: Option<(Vec, PrivateKey)>, @@ -200,7 +208,7 @@ impl Args { .map(|v| v.collect()) .unwrap_or_else(|| vec!["0.0.0.0", "::"]); let addrs: Vec = 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") @@ -247,6 +255,10 @@ impl Args { .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, @@ -268,6 +280,7 @@ impl Args { render_spa, tls, log_http, + assets_path, }) } @@ -303,4 +316,12 @@ impl Args { }) .map_err(|err| format!("Failed to access path `{}`: {}", path.display(), err,).into()) } + + fn parse_assets_path>(path: P) -> BoxResult { + 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) + } } diff --git a/src/server.rs b/src/server.rs index 47afc58..6fd16ac 100644 --- a/src/server.rs +++ b/src/server.rs @@ -19,6 +19,7 @@ use hyper::header::{ }; 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; @@ -46,6 +47,7 @@ const BUF_SIZE: usize = 65536; pub struct Server { args: Arc, assets_prefix: String, + html: Cow<'static, str>, single_file_req_paths: Vec, running: Arc, } @@ -66,11 +68,16 @@ impl Server { } 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, } } @@ -118,7 +125,7 @@ impl Server { 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); } @@ -496,29 +503,40 @@ impl Server { Ok(()) } - async fn handle_embed_assets(&self, req_path: &str, res: &mut Response) -> BoxResult { + async fn handle_assets( + &self, + req_path: &str, + headers: &HeaderMap, + res: &mut Response, + ) -> BoxResult { 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", @@ -802,23 +820,10 @@ impl Server { 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#" - - - - -"#, - 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() diff --git a/tests/assets.rs b/tests/assets.rs index ecc5662..aa55f21 100644 --- a/tests/assets.rs +++ b/tests/assets.rs @@ -1,8 +1,11 @@ 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> { @@ -91,3 +94,29 @@ fn asset_js_with_prefix( ); 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(()) +} diff --git a/tests/fixtures.rs b/tests/fixtures.rs index 669a1ae..c20c338 100644 --- a/tests/fixtures.rs +++ b/tests/fixtures.rs @@ -27,9 +27,13 @@ pub static DIR_NO_INDEX: &str = "dir-no-index/"; #[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. @@ -44,14 +48,21 @@ pub fn tmpdir() -> TempDir { .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"); + } } } @@ -93,34 +104,6 @@ where TestServer::new(port, tmpdir, child, is_tls) } -/// Same as `server()` but ignore stderr -#[fixture] -#[allow(dead_code)] -pub fn server_no_stderr(#[default(&[] as &[&str])] args: I) -> TestServer -where - I: IntoIterator + Clone, - I::Item: AsRef, -{ - 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(); diff --git a/tests/utils.rs b/tests/utils.rs index 449fbbf..0789d33 100644 --- a/tests/utils.rs +++ b/tests/utils.rs @@ -38,7 +38,8 @@ pub fn encode_uri(v: &str) -> String { fn retrieve_index_paths_impl(index: &str) -> Option> { 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()?