From b535297cac52e616644d1f927c3a6208b66fe854 Mon Sep 17 00:00:00 2001 From: Jose Quintana Date: Tue, 18 May 2021 00:48:09 +0200 Subject: [PATCH] feat: preliminary directory listing support --- Cargo.lock | 8 ++++++++ Cargo.toml | 2 ++ src/config.rs | 4 ++++ src/handler.rs | 3 ++- src/static_files.rs | 151 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 165 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7b28003..c72f030 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -387,6 +387,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05842d0d43232b23ccb7060ecb0f0626922c21f30012e97b767b30afd4a5d4b9" [[package]] +name = "humansize" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cab2627acfc432780848602f3f558f7e9dd427352224b0d9324025796d2a5e" + +[[package]] name = "hyper" version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -848,6 +854,7 @@ dependencies = [ "futures", "headers", "http", + "humansize", "hyper", "jemallocator", "mime_guess", @@ -858,6 +865,7 @@ dependencies = [ "pin-project", "signal", "structopt", + "time", "tokio", "tokio-rustls", "tokio-util", diff --git a/Cargo.toml b/Cargo.toml index 6a887d9..b3dc9f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,8 @@ num_cpus = { version = "1.13" } once_cell = "1.7" pin-project = "1.0" tokio-rustls = { version = "0.22" } +humansize = "1.1" +time = "0.1" [target.'cfg(not(windows))'.dependencies.nix] version = "0.14" diff --git a/src/config.rs b/src/config.rs index 5096d37..3481560 100644 --- a/src/config.rs +++ b/src/config.rs @@ -67,4 +67,8 @@ pub struct Config { #[structopt(long, default_value = "", env = "SERVER_HTTP2_TLS_KEY")] /// Specify the file path to read the private key. pub http2_tls_key: String, + + #[structopt(long, short = "z", env = "SERVER_DIRECTORY_LISTING")] + /// Enable directory listing for all requests ending with the slash character (‘/’). + pub directory_listing: Option, } diff --git a/src/handler.rs b/src/handler.rs index ad65b19..7fa45c2 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -9,7 +9,8 @@ pub async fn handle_request(base: &Path, req: &Request) -> Result { // Compression on demand based on`Accept-Encoding` header let mut resp = compression::auto(method, headers, resp)?; diff --git a/src/static_files.rs b/src/static_files.rs index c628dc2..5f925e8 100644 --- a/src/static_files.rs +++ b/src/static_files.rs @@ -8,6 +8,7 @@ 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 percent_encoding::percent_decode_str; use std::fs::Metadata; @@ -18,6 +19,7 @@ use std::path::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; @@ -40,6 +42,7 @@ pub async fn handle_request( headers: &HeaderMap, base: &Path, uri_path: &str, + dir_listing: bool, ) -> Result, StatusCode> { // Reject requests for non HEAD or GET methods if !(method == Method::HEAD || method == Method::GET) { @@ -47,8 +50,30 @@ pub async fn handle_request( } let base = Arc::new(base.into()); - let res = path_from_tail(base, uri_path).await?; - file_reply(headers, res).await + let (path, meta, auto_index) = path_from_tail(base, uri_path).await?; + + // Directory listing + // 1. Check if "directory listing" feature is enabled, + // if current path is a valid directory and + // if it does not contain an `index.html` file + if dir_listing && auto_index && !path.as_ref().exists() { + // Redirect if current path does not end with a slash + let current_path = uri_path; + if !current_path.ends_with('/') { + let uri = [current_path, "/"].concat(); + let loc = HeaderValue::from_str(uri.as_str()).unwrap(); + let mut resp = Response::new(Body::empty()); + + resp.headers_mut().insert(hyper::header::LOCATION, loc); + *resp.status_mut() = StatusCode::PERMANENT_REDIRECT; + + return Ok(resp); + } + + return directory_listing(method, (current_path.to_string(), path)).await; + } + + file_reply(headers, (path, meta, auto_index)).await } fn path_from_tail( @@ -75,6 +100,128 @@ fn path_from_tail( }) } +fn directory_listing( + method: &Method, + res: (String, ArcPath), +) -> impl Future, StatusCode>> + Send { + let (current_path, path) = res; + let is_head = method == Method::HEAD; + let parent = path.as_ref().parent().unwrap(); + let parent = PathBuf::from(parent); + + tokio::fs::read_dir(parent).then(move |res| match res { + Ok(entries) => Either::Left(async move { + match read_directory_entries(entries, ¤t_path, is_head).await { + Ok(resp) => Ok(resp), + Err(err) => { + tracing::error!( + "error during directory entries reading (path={:?}): {} ", + path.as_ref().parent().unwrap().display(), + err + ); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } + }), + Err(err) => { + let status = match err.kind() { + io::ErrorKind::NotFound => { + tracing::debug!("entry file not found: {:?}", path.as_ref().display()); + StatusCode::NOT_FOUND + } + io::ErrorKind::PermissionDenied => { + tracing::warn!( + "entry file permission denied: {:?}", + path.as_ref().display() + ); + StatusCode::FORBIDDEN + } + _ => { + tracing::error!( + "directory entries error (path={:?}): {} ", + path.as_ref().display(), + err + ); + StatusCode::INTERNAL_SERVER_ERROR + } + }; + Either::Right(future::err(status)) + } + }) +} + +// It reads current directory entries and create the index page content. Otherwise returns a status error. +async fn read_directory_entries( + mut entries: tokio::fs::ReadDir, + base_path: &str, + is_head: bool, +) -> crate::error::Result> { + let mut entries_str = String::new(); + if base_path != "/" { + entries_str = String::from(r#"../"#); + } + while let Some(entry) = entries.next_entry().await? { + let meta = entry.metadata().await?; + let mut filesize = meta + .len() + .file_size(file_size_opts::DECIMAL) + .map_err(anyhow::Error::msg)?; + + let mut name = entry + .file_name() + .into_string() + .map_err(|err| anyhow::anyhow!(err.into_string().unwrap_or_default()))?; + + if meta.is_dir() { + name = format!("{}/", name); + filesize = String::from("-") + } + + let uri = format!("{}{}", base_path, name); + let modified = parse_last_modified(meta.modified()?).unwrap(); + + entries_str = format!( + "{}{}{}{}", + entries_str, + uri, + name, + name, + modified.to_local().strftime("%F %T").unwrap(), + filesize + ); + } + + let current_path = percent_decode_str(&base_path).decode_utf8()?.to_string(); + let page_str = format!( + "Index of {}

Index of {}

{}


", current_path, current_path, entries_str + ); + + let mut resp = Response::new(Body::empty()); + let len = page_str.len() as u64; + + if is_head { + resp.headers_mut().typed_insert(ContentLength(len)); + return Ok(resp); + } + + Ok(Response::new(Body::from(page_str))) +} + +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( headers: &HeaderMap, -- libgit2 1.7.2