From e9a4aa3809c5a77362227cf38adab8236ff5f8df Mon Sep 17 00:00:00 2001 From: Jose Quintana Date: Tue, 13 Sep 2022 22:31:48 +0200 Subject: [PATCH] refactor: directory listing module --- src/directory_listing.rs | 326 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/static_files.rs | 286 ++-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- 3 files changed, 329 insertions(+), 284 deletions(-) create mode 100644 src/directory_listing.rs diff --git a/src/directory_listing.rs b/src/directory_listing.rs new file mode 100644 index 0000000..27cc7ac --- /dev/null +++ b/src/directory_listing.rs @@ -0,0 +1,326 @@ +use futures_util::future::Either; +use futures_util::{future, FutureExt}; +use headers::{ContentLength, ContentType, HeaderMapExt}; +use humansize::{file_size_opts, FileSize}; +use hyper::{Body, Method, Response, StatusCode}; +use mime_guess::mime; +use percent_encoding::percent_decode_str; +use std::cmp::Ordering; +use std::future::Future; +use std::io; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::Result; + +/// Provides directory listing support for the current request. +/// Note that this function highly depends on `static_files::path_from_tail()` function +/// which must be called first. See `static_files::handle()` for more details. +pub fn auto_index<'a>( + method: &'a Method, + current_path: &'a str, + uri_query: Option<&'a str>, + filepath: &'a Path, + dir_listing_order: u8, +) -> impl Future, StatusCode>> + Send + 'a { + let is_head = method == Method::HEAD; + + // Note: it's safe to call `parent()` here since `filepath` + // value always refer to a path with file ending and under + // a root directory boundary. + // See `path_from_tail()` function which sanitizes the requested + // path before to be delegated here. + let parent = filepath.parent().unwrap_or(filepath); + + tokio::fs::read_dir(parent).then(move |res| match res { + Ok(entries) => Either::Left(async move { + match read_dir_entries(entries, current_path, uri_query, is_head, dir_listing_order) + .await + { + Ok(resp) => Ok(resp), + Err(err) => { + tracing::error!( + "error during directory entries reading (path={:?}): {} ", + parent.display(), + err + ); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } + }), + Err(err) => { + let status = match err.kind() { + io::ErrorKind::NotFound => { + tracing::debug!("entry file not found: {:?}", filepath.display()); + StatusCode::NOT_FOUND + } + io::ErrorKind::PermissionDenied => { + tracing::warn!("entry file permission denied: {:?}", filepath.display()); + StatusCode::FORBIDDEN + } + _ => { + tracing::error!( + "directory entries error (filepath={:?}): {} ", + filepath.display(), + err + ); + StatusCode::INTERNAL_SERVER_ERROR + } + }; + Either::Right(future::err(status)) + } + }) +} + +const STYLE: &str = r#""#; +const FOOTER: &str = r#""#; + +/// It reads a list of directory entries and create an index page content. +/// Otherwise it returns a status error. +async fn read_dir_entries( + mut file_entries: tokio::fs::ReadDir, + base_path: &str, + uri_query: Option<&str>, + is_head: bool, + mut dir_listing_order: u8, +) -> Result> { + let mut dirs_count: usize = 0; + let mut files_count: usize = 0; + let mut files_found: Vec<(String, String, u64, Option)> = vec![]; + + while let Some(entry) = file_entries.next_entry().await? { + let meta = entry.metadata().await?; + + let mut name = entry + .file_name() + .into_string() + .map_err(|err| anyhow::anyhow!(err.into_string().unwrap_or_default()))?; + + let mut filesize = 0_u64; + + if meta.is_dir() { + name += "/"; + dirs_count += 1; + } else if meta.is_file() { + filesize = meta.len(); + files_count += 1; + } else if meta.file_type().is_symlink() { + let m = tokio::fs::symlink_metadata(entry.path().canonicalize()?).await?; + if m.is_dir() { + name += "/"; + dirs_count += 1; + } else { + filesize = meta.len(); + files_count += 1; + } + } else { + continue; + } + + let mut uri = None; + // NOTE: Use relative paths by default independently of + // the "redirect trailing slash" feature. + // However, when "redirect trailing slash" is disabled + // and a request path doesn't contain a trailing slash then + // entries should contain the "parent/entry-name" as a link format. + // Otherwise, we just use the "entry-name" as a link (default behavior). + // Note that in both cases, we add a trailing slash if the entry is a directory. + if !base_path.ends_with('/') { + let base_path = Path::new(base_path); + let parent_dir = base_path.parent().unwrap_or(base_path); + let mut base_dir = base_path; + if base_path != parent_dir { + base_dir = base_path.strip_prefix(parent_dir)?; + } + let mut base_str = String::new(); + if !base_dir.starts_with("/") { + let base_dir = base_dir.to_str().unwrap_or_default(); + if !base_dir.is_empty() { + base_str.push_str(base_dir); + } + base_str.push('/'); + } + base_str.push_str(&name); + uri = Some(base_str); + } + + let modified = match parse_last_modified(meta.modified()?) { + Ok(tm) => tm.to_local().strftime("%F %T")?.to_string(), + Err(err) => { + tracing::error!("error determining file last modified: {:?}", err); + String::from("-") + } + }; + files_found.push((name, modified, filesize, uri)); + } + + // Check the query request uri for a sorting type. E.g https://blah/?sort=5 + if let Some(q) = uri_query { + let mut parts = form_urlencoded::parse(q.as_bytes()); + if parts.count() > 0 { + // NOTE: we just pick up the first value (pair) + if let Some(sort) = parts.next() { + if sort.0 == "sort" && !sort.1.trim().is_empty() { + match sort.1.parse::() { + Ok(order_code) => dir_listing_order = order_code, + Err(err) => { + tracing::debug!( + "sorting: query value error when converting to u8: {:?}", + err + ); + } + } + } + } + } + } + + let html = create_auto_index( + base_path, + dirs_count, + files_count, + dir_listing_order, + &mut files_found, + )?; + + let mut resp = Response::new(Body::empty()); + resp.headers_mut() + .typed_insert(ContentType::from(mime::TEXT_HTML_UTF_8)); + resp.headers_mut() + .typed_insert(ContentLength(html.len() as u64)); + + // We skip the body for HEAD requests + if is_head { + return Ok(resp); + } + + *resp.body_mut() = Body::from(html); + + Ok(resp) +} + +/// Create an auto index html content. +fn create_auto_index( + base_path: &str, + dirs_count: usize, + files_count: usize, + dir_listing_order: u8, + files_found: &mut Vec<(String, String, u64, Option)>, +) -> Result { + // Sorting the files by an specific order code and create the table header + let table_header = create_table_header(sort_files(files_found, dir_listing_order)); + + // Prepare table row + let mut table_row = String::new(); + if base_path != "/" { + table_row = String::from(r#"../"#); + } + + for file in files_found { + let (file_name, file_modified, file_size, uri) = file; + let mut filesize_str = file_size + .file_size(file_size_opts::DECIMAL) + .map_err(anyhow::Error::msg)?; + + if *file_size == 0_u64 { + filesize_str = String::from("-"); + } + + let file_uri = uri.clone().unwrap_or_else(|| file_name.to_owned()); + + table_row = format!( + "{}{}{}{}", + table_row, file_uri, file_name, file_modified, filesize_str + ); + } + + let current_path = percent_decode_str(base_path).decode_utf8()?.to_owned(); + let dirs_str = if dirs_count == 1 { + "directory" + } else { + "directories" + }; + let summary = format!( + "
{} {}, {} {}
", + dirs_count, dirs_str, files_count, "file(s)" + ); + + let html_page = format!( + "Index of {}{}

Index of {}

{}
{}{}

{}", + current_path, STYLE, current_path, summary, table_header, table_row, FOOTER + ); + + Ok(html_page) +} + +/// Create a table header providing the sorting attributes. +fn create_table_header(sorting_attrs: (String, String, String)) -> String { + let (name, last_modified, size) = sorting_attrs; + format!( + r#"NameLast modifiedSize"#, + name, last_modified, size, + ) +} + +/// Sort a list of files by an specific order code. +fn sort_files( + files: &mut [(String, String, u64, Option)], + order_code: u8, +) -> (String, String, String) { + // Default sorting type values + let mut name = "0".to_owned(); + let mut last_modified = "2".to_owned(); + let mut size = "4".to_owned(); + + files.sort_by(|a, b| match order_code { + // Name (asc, desc) + 0 => { + name = "1".to_owned(); + a.0.to_lowercase().cmp(&b.0.to_lowercase()) + } + 1 => { + name = "0".to_owned(); + b.0.to_lowercase().cmp(&a.0.to_lowercase()) + } + + // Modified (asc, desc) + 2 => { + last_modified = "3".to_owned(); + a.1.cmp(&b.1) + } + 3 => { + last_modified = "2".to_owned(); + b.1.cmp(&a.1) + } + + // File size (asc, desc) + 4 => { + size = "5".to_owned(); + a.2.cmp(&b.2) + } + 5 => { + size = "4".to_owned(); + b.2.cmp(&a.2) + } + + // Unordered + _ => Ordering::Equal, + }); + + (name, last_modified, size) +} + +fn parse_last_modified(modified: SystemTime) -> Result> { + let since_epoch = modified.duration_since(UNIX_EPOCH)?; + // HTTP times don't have nanosecond precision, so we truncate + // the modification time. + // Converting to i64 should be safe until we get beyond the + // planned lifetime of the universe + // + // TODO: Investigate how to write a test for this. Changing + // the modification time of a file with greater than second + // precision appears to be something that only is possible to + // do on Linux. + let ts = time::Timespec::new(since_epoch.as_secs() as i64, 0); + Ok(time::at_utc(ts)) +} diff --git a/src/lib.rs b/src/lib.rs index 5f9ecda..4a3a7cb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ pub mod compression; pub mod control_headers; pub mod cors; pub mod custom_headers; +pub mod directory_listing; pub mod error_page; pub mod fallback_page; pub mod handler; diff --git a/src/static_files.rs b/src/static_files.rs index f0cfae7..850dac9 100644 --- a/src/static_files.rs +++ b/src/static_files.rs @@ -8,11 +8,8 @@ use headers::{ AcceptRanges, ContentLength, ContentRange, ContentType, HeaderMap, HeaderMapExt, HeaderValue, IfModifiedSince, IfRange, IfUnmodifiedSince, LastModified, Range, }; -use humansize::{file_size_opts, FileSize}; use hyper::{Body, Method, Response, StatusCode}; -use mime_guess::mime; use percent_encoding::percent_decode_str; -use std::cmp::Ordering; use std::fs::Metadata; use std::future::Future; use std::io; @@ -21,13 +18,12 @@ use std::path::{Component, PathBuf}; use std::pin::Pin; use std::sync::Arc; use std::task::Poll; -use std::time::{SystemTime, UNIX_EPOCH}; use std::{cmp, path::Path}; use tokio::fs::File as TkFile; use tokio::io::AsyncSeekExt; use tokio_util::io::poll_read_buf; -use crate::Result; +use crate::{directory_listing, Result}; /// Arc `PathBuf` reference wrapper since Arc doesn't implement AsRef. #[derive(Clone, Debug)] @@ -105,7 +101,7 @@ pub async fn handle<'a>(opts: &HandleOpts<'a>) -> Result, StatusC // if current path is a valid directory and // if it does not contain an `index.html` file (if a proper auto index is generated) if opts.dir_listing && auto_index && !filepath.as_ref().exists() { - return directory_listing( + return directory_listing::auto_index( method, uri_path, opts.uri_query, @@ -144,284 +140,6 @@ fn path_from_tail( }) } -/// Provides directory listing support for the current request. -/// Note that this function highly depends on `path_from_tail()` function -/// which must be called first. See `handle()` for more details. -fn directory_listing<'a>( - method: &'a Method, - current_path: &'a str, - uri_query: Option<&'a str>, - filepath: &'a Path, - dir_listing_order: u8, -) -> impl Future, StatusCode>> + Send + 'a { - let is_head = method == Method::HEAD; - - // Note: it's safe to call `parent()` here since `filepath` - // value always refer to a path with file ending and under - // a root directory boundary. - // See `path_from_tail()` function which sanitizes the requested - // path before to be delegated here. - let parent = filepath.parent().unwrap_or(filepath); - - tokio::fs::read_dir(parent).then(move |res| match res { - Ok(entries) => Either::Left(async move { - match read_directory_entries( - entries, - current_path, - uri_query, - is_head, - dir_listing_order, - ) - .await - { - Ok(resp) => Ok(resp), - Err(err) => { - tracing::error!( - "error during directory entries reading (path={:?}): {} ", - parent.display(), - err - ); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } - } - }), - Err(err) => { - let status = match err.kind() { - io::ErrorKind::NotFound => { - tracing::debug!("entry file not found: {:?}", filepath.display()); - StatusCode::NOT_FOUND - } - io::ErrorKind::PermissionDenied => { - tracing::warn!("entry file permission denied: {:?}", filepath.display()); - StatusCode::FORBIDDEN - } - _ => { - tracing::error!( - "directory entries error (filepath={:?}): {} ", - filepath.display(), - err - ); - StatusCode::INTERNAL_SERVER_ERROR - } - }; - Either::Right(future::err(status)) - } - }) -} - -/// It reads the current directory entries and create an index page content. -/// Otherwise it returns a status error. -async fn read_directory_entries( - mut entries: tokio::fs::ReadDir, - base_path: &str, - uri_query: Option<&str>, - is_head: bool, - mut dir_listing_order: u8, -) -> Result> { - let mut dirs_count: usize = 0; - let mut files_count: usize = 0; - let mut files_found: Vec<(String, String, u64, Option)> = vec![]; - - while let Some(entry) = entries.next_entry().await? { - let meta = entry.metadata().await?; - - let mut name = entry - .file_name() - .into_string() - .map_err(|err| anyhow::anyhow!(err.into_string().unwrap_or_default()))?; - - let mut filesize = 0_u64; - - if meta.is_dir() { - name += "/"; - dirs_count += 1; - } else if meta.is_file() { - filesize = meta.len(); - files_count += 1; - } else if meta.file_type().is_symlink() { - let m = tokio::fs::symlink_metadata(entry.path().canonicalize()?).await?; - if m.is_dir() { - name += "/"; - dirs_count += 1; - } else { - filesize = meta.len(); - files_count += 1; - } - } else { - continue; - } - - let mut uri = None; - // NOTE: Use relative paths by default independently of - // the "redirect trailing slash" feature. - // However, when "redirect trailing slash" is disabled - // and a request path doesn't contain a trailing slash then - // entries should contain the "parent/entry-name" as a link format. - // Otherwise, we just use the "entry-name" as a link (default behavior). - // Note that in both cases, we add a trailing slash if the entry is a directory. - if !base_path.ends_with('/') { - let base_path = Path::new(base_path); - let parent_dir = base_path.parent().unwrap_or(base_path); - let mut base_dir = base_path; - if base_path != parent_dir { - base_dir = base_path.strip_prefix(parent_dir)?; - } - let mut base_str = String::new(); - if !base_dir.starts_with("/") { - let base_dir = base_dir.to_str().unwrap_or_default(); - if !base_dir.is_empty() { - base_str.push_str(base_dir); - } - base_str.push('/'); - } - base_str.push_str(&name); - uri = Some(base_str); - } - - let modified = match parse_last_modified(meta.modified()?) { - Ok(tm) => tm.to_local().strftime("%F %T")?.to_string(), - Err(err) => { - tracing::error!("error determining file last modified: {:?}", err); - String::from("-") - } - }; - files_found.push((name, modified, filesize, uri)); - } - - // Check the query uri for a sorting type. E.g https://blah/?sort=5 - if let Some(q) = uri_query { - let mut parts = form_urlencoded::parse(q.as_bytes()); - if parts.count() > 0 { - // NOTE: we just pick up the first value (pairs) - if let Some(sort) = parts.next() { - if sort.0 == "sort" && !sort.1.trim().is_empty() { - match sort.1.parse::() { - Ok(n) => dir_listing_order = n, - Err(e) => { - tracing::debug!("sorting: query value to u8 error: {:?}", e); - } - } - } - } - } - } - - // Default sorting type values - let mut sort_name = "0".to_owned(); - let mut sort_last_modified = "2".to_owned(); - let mut sort_size = "4".to_owned(); - - files_found.sort_by(|a, b| match dir_listing_order { - // Name (asc, desc) - 0 => { - sort_name = "1".to_owned(); - a.0.to_lowercase().cmp(&b.0.to_lowercase()) - } - 1 => { - sort_name = "0".to_owned(); - b.0.to_lowercase().cmp(&a.0.to_lowercase()) - } - - // Modified (asc, desc) - 2 => { - sort_last_modified = "3".to_owned(); - a.1.cmp(&b.1) - } - 3 => { - sort_last_modified = "2".to_owned(); - b.1.cmp(&a.1) - } - - // File size (asc, desc) - 4 => { - sort_size = "5".to_owned(); - a.2.cmp(&b.2) - } - 5 => { - sort_size = "4".to_owned(); - b.2.cmp(&a.2) - } - - // Unordered - _ => Ordering::Equal, - }); - - // Prepare table header with sorting support - let table_header = format!( - r#"NameLast modifiedSize"#, - sort_name, sort_last_modified, sort_size, - ); - - let mut entries_str = String::new(); - if base_path != "/" { - entries_str = String::from(r#"../"#); - } - - for f in files_found { - let (name, modified, filesize, uri) = f; - let mut filesize_str = filesize - .file_size(file_size_opts::DECIMAL) - .map_err(anyhow::Error::msg)?; - - if filesize == 0 { - filesize_str = String::from("-"); - } - - let entry_uri = uri.unwrap_or_else(|| name.to_owned()); - - entries_str = format!( - "{}{}{}{}", - entries_str, entry_uri, name, modified, filesize_str - ); - } - - let current_path = percent_decode_str(base_path).decode_utf8()?.to_owned(); - let dirs_str = if dirs_count == 1 { - "directory" - } else { - "directories" - }; - let summary_str = format!( - "
{} {}, {} {}
", - dirs_count, dirs_str, files_count, "file(s)" - ); - let style_str = r#""#; - let footer_str = r#""#; - let page_str = format!( - "Index of {}{}

Index of {}

{}
{}{}

{}", current_path, style_str, current_path, summary_str, table_header, entries_str, footer_str - ); - - let mut resp = Response::new(Body::empty()); - resp.headers_mut() - .typed_insert(ContentType::from(mime::TEXT_HTML_UTF_8)); - resp.headers_mut() - .typed_insert(ContentLength(page_str.len() as u64)); - - // We skip the body for HEAD requests - if is_head { - return Ok(resp); - } - - *resp.body_mut() = Body::from(page_str); - - Ok(resp) -} - -fn parse_last_modified(modified: SystemTime) -> Result> { - let since_epoch = modified.duration_since(UNIX_EPOCH)?; - // HTTP times don't have nanosecond precision, so we truncate - // the modification time. - // Converting to i64 should be safe until we get beyond the - // planned lifetime of the universe - // - // TODO: Investigate how to write a test for this. Changing - // the modification time of a file with greater than second - // precision appears to be something that only is possible to - // do on Linux. - let ts = time::Timespec::new(since_epoch.as_secs() as i64, 0); - Ok(time::at_utc(ts)) -} - /// Reply with a file content. fn file_reply<'a>( headers: &'a HeaderMap, -- libgit2 1.7.2