diff --git a/src/args.rs b/src/args.rs index 8467a921c..25a35036d 100644 --- a/src/args.rs +++ b/src/args.rs @@ -99,6 +99,10 @@ struct CLIArgs { /// because zip generation is done in memory and cannot be sent on the fly #[structopt(short = "z", long = "enable-zip")] enable_zip: bool, + + /// Shown instead of host in page title and heading + #[structopt(short = "t", long = "title")] + title: Option, } /// Checks wether an interface is valid, i.e. it can be parsed into an IP address @@ -201,6 +205,7 @@ pub fn parse_args() -> crate::MiniserveConfig { file_upload: args.file_upload, tar_enabled: args.enable_tar, zip_enabled: args.enable_zip, + title: args.title, } } diff --git a/src/listing.rs b/src/listing.rs index a70e237a2..d14a188d8 100644 --- a/src/listing.rs +++ b/src/listing.rs @@ -8,7 +8,7 @@ use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, CONTRO use qrcodegen::{QrCode, QrCodeEcc}; use serde::Deserialize; use std::io; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::time::SystemTime; use strum_macros::{Display, EnumString}; @@ -123,6 +123,21 @@ impl Entry { } } +/// One entry in the path to the listed directory +pub struct Breadcrumb { + /// Name of directory + pub name: String, + + /// Link to get to directory, relative to listed directory + pub link: String, +} + +impl Breadcrumb { + fn new(name: String, link: String) -> Self { + Breadcrumb { name, link } + } +} + pub async fn file_handler(req: HttpRequest) -> Result { let path = &req.app_data::().unwrap().path; actix_files::NamedFile::open(path).map_err(Into::into) @@ -143,6 +158,7 @@ pub fn directory_listing( upload_route: String, tar_enabled: bool, zip_enabled: bool, + title: Option, ) -> Result { use actix_web::dev::BodyEncoding; let serve_path = req.path(); @@ -163,23 +179,52 @@ pub fn directory_listing( } let base = Path::new(serve_path); - let random_route = format!("/{}", random_route.unwrap_or_default()); - let is_root = base.parent().is_none() || Path::new(&req.path()) == Path::new(&random_route); + let random_route_abs = format!("/{}", random_route.clone().unwrap_or_default()); + let is_root = base.parent().is_none() || Path::new(&req.path()) == Path::new(&random_route_abs); - let encoded_dir = match base.strip_prefix(random_route) { + let encoded_dir = match base.strip_prefix(random_route_abs) { Ok(c_d) => Path::new("/").join(c_d), Err(_) => base.to_path_buf(), } .display() .to_string(); - let display_dir = { + let breadcrumbs = { + let title = title.unwrap_or_else(|| req.connection_info().host().into()); + let decoded = percent_decode_str(&encoded_dir).decode_utf8_lossy(); - if is_root { - decoded.to_string() - } else { - format!("{}/", decoded) + + let mut res: Vec = Vec::new(); + let mut link_accumulator = + format!("/{}", random_route.map(|r| r + "/").unwrap_or_default()); + + let mut components = Path::new(&*decoded).components().peekable(); + + while let Some(c) = components.next() { + let name; + + match c { + Component::RootDir => { + name = title.clone(); + } + Component::Normal(s) => { + name = s.to_string_lossy().to_string(); + link_accumulator + .push_str(&(utf8_percent_encode(&name, FRAGMENT).to_string() + "/")); + } + _ => unreachable!(), + }; + + res.push(Breadcrumb::new( + name, + if components.peek().is_some() { + link_accumulator.clone() + } else { + ".".to_string() + }, + )); } + res }; let query_params = extract_query_parameters(req); @@ -360,7 +405,7 @@ pub fn directory_listing( &upload_route, &favicon_route, &encoded_dir, - &display_dir, + breadcrumbs, tar_enabled, zip_enabled, ) diff --git a/src/main.rs b/src/main.rs index 7cda9756e..1830a70b7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -79,6 +79,9 @@ pub struct MiniserveConfig { /// If false, creation of zip archives is disabled pub zip_enabled: bool, + + /// Shown instead of host in page title and heading + pub title: Option, } fn main() { @@ -286,6 +289,7 @@ fn configure_app(app: &mut web::ServiceConfig, conf: &MiniserveConfig) { let file_upload = conf.file_upload; let tar_enabled = conf.tar_enabled; let zip_enabled = conf.zip_enabled; + let title = conf.title.clone(); upload_route = if let Some(random_route) = conf.random_route.clone() { format!("/{}/upload", random_route) } else { @@ -315,6 +319,7 @@ fn configure_app(app: &mut web::ServiceConfig, conf: &MiniserveConfig) { u_r.clone(), tar_enabled, zip_enabled, + title.clone(), ) }) .default_handler(web::to(error_404)), diff --git a/src/renderer.rs b/src/renderer.rs index 11fe8e347..43cb22268 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -6,7 +6,7 @@ use std::time::SystemTime; use strum::IntoEnumIterator; use crate::archive::CompressionMethod; -use crate::listing::{Entry, SortingMethod, SortingOrder}; +use crate::listing::{Breadcrumb, Entry, SortingMethod, SortingOrder}; use crate::themes::ColorScheme; /// Renders the file listing @@ -24,7 +24,7 @@ pub fn page( upload_route: &str, favicon_route: &str, encoded_dir: &str, - display_dir: &str, + breadcrumbs: Vec, tar_enabled: bool, zip_enabled: bool, ) -> Markup { @@ -37,10 +37,16 @@ pub fn page( default_color_scheme, ); + let title_path = breadcrumbs + .iter() + .map(|el| el.name.clone()) + .collect::>() + .join("/"); + html! { (DOCTYPE) html { - (page_header(display_dir, color_scheme, file_upload, favicon_route, false)) + (page_header(&title_path, color_scheme, file_upload, favicon_route)) body#drop-container { @if file_upload { div.drag-form { @@ -52,7 +58,19 @@ pub fn page( (color_scheme_selector(sort_method, sort_order, color_scheme, default_color_scheme, serve_path, show_qrcode)) div.container { span#top { } - h1.title { "Index of " (display_dir) } + h1.title { + @for el in breadcrumbs { + @if el.link == "." { + // wrapped in span so the text doesn't shift slightly when it turns into a link + span { (el.name) } + } @else { + a.directory href=(parametrized_link(&el.link, sort_method, sort_order, color_scheme, default_color_scheme)) { + (el.name) + } + } + "/" + } + } div.toolbar { @if tar_enabled || zip_enabled { div.download { @@ -446,7 +464,7 @@ fn css(color_scheme: ColorScheme) -> Markup { }} nav > div {{ position: relative; - margin-left: 0.5rem; + margin-left: 0.5rem; }} nav p {{ padding: 0.5rem 1rem; @@ -836,11 +854,10 @@ fn chevron_down() -> Markup { /// Partial: page header fn page_header( - serve_path: &str, + title: &str, color_scheme: ColorScheme, file_upload: bool, favicon_route: &str, - is_error: bool, ) -> Markup { html! { head { @@ -848,11 +865,8 @@ fn page_header( meta http-equiv="X-UA-Compatible" content="IE=edge"; meta name="viewport" content="width=device-width, initial-scale=1"; link rel="icon" type="image/svg+xml" href={ "/" (favicon_route) }; - @if is_error { - title { (serve_path) } - } @else { - title { "Index of " (serve_path) } - } + title { (title) } + style { (css(color_scheme)) } @if file_upload { (PreEscaped(r#" @@ -944,7 +958,7 @@ pub fn render_error( html! { (DOCTYPE) html { - (page_header(&error_code.to_string(), color_scheme, false, favicon_route, true)) + (page_header(&error_code.to_string(), color_scheme, false, favicon_route)) body { div.error { p { (error_code.to_string()) } diff --git a/tests/navigation.rs b/tests/navigation.rs index 7320f060a..0826aa088 100644 --- a/tests/navigation.rs +++ b/tests/navigation.rs @@ -120,3 +120,63 @@ fn can_navigate_deep_into_dirs_and_back(tmpdir: TempDir, port: u16) -> Result<() Ok(()) } + +#[rstest(use_custom_title, case(true), case(false))] +/// We can use breadcrumbs to navigate. +fn can_navigate_using_breadcrumbs( + tmpdir: TempDir, + port: u16, + use_custom_title: bool, +) -> Result<(), Error> { + let mut command_base = Command::cargo_bin("miniserve")?; + let mut command = command_base.arg("-p").arg(port.to_string()); + + if use_custom_title { + command = command.arg("--title").arg("some title") + } + + let mut child = command.arg(tmpdir.path()).stdout(Stdio::null()).spawn()?; + + sleep(Duration::from_secs(1)); + + // Create a vector of directory names. We don't need to fetch the file and so we'll + // remove that part. + let dir: String = { + let mut comps = DEEPLY_NESTED_FILE + .split("/") + .map(|d| format!("{}/", d)) + .collect::>(); + comps.pop(); + comps.join("") + }; + + let base_url = Url::parse(&format!("http://localhost:{}/", port))?; + let nested_url = base_url.join(&dir)?; + + let resp = reqwest::blocking::get(nested_url.as_str())?; + let body = resp.error_for_status()?; + let parsed = Document::from_read(body)?; + + let title_name = if use_custom_title { + "some title".to_string() + } else { + format!("localhost:{}", port) + }; + + // can go back to root dir by clicking title + let title_link = get_link_from_text(&parsed, &title_name).expect("Root dir link not found."); + assert_eq!("/", title_link); + + // can go to intermediate dir + let intermediate_dir_link = + get_link_from_text(&parsed, "very").expect("Intermediate dir link not found."); + assert_eq!("/very/", intermediate_dir_link); + + // current dir is not linked + let current_dir_link = get_link_from_text(&parsed, "nested"); + assert_eq!(None, current_dir_link); + + child.kill()?; + + Ok(()) +}