From 5428eb363b693f74285ba8d66edf4bad1778b3ba Mon Sep 17 00:00:00 2001 From: Jose Quintana <1700322+joseluisq@users.noreply.github.com> Date: Wed, 19 May 2021 11:40:56 +0200 Subject: [PATCH] Merge pull request #41 from joseluisq/feature/directory_listing_v2 feat: directory listing support for v2 --- Cargo.lock | 8 ++++++++ Cargo.toml | 2 ++ README.md | 9 +++++++-- src/config.rs | 9 +++++++++ src/handler.rs | 8 ++++++-- src/server.rs | 12 +++++++++--- src/static_files.rs | 177 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---- 7 files changed, 214 insertions(+), 11 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/README.md b/README.md index 9f3840f..5dc91a5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Static Web Server [![CI](https://github.com/joseluisq/static-web-server/workflows/CI/badge.svg)](https://github.com/joseluisq/static-web-server/actions?query=workflow%3ACI) [![Docker Image Version (tag latest semver)](https://img.shields.io/docker/v/joseluisq/static-web-server/1)](https://hub.docker.com/r/joseluisq/static-web-server/) [![Docker Image Size (tag)](https://img.shields.io/docker/image-size/joseluisq/static-web-server/1)](https://hub.docker.com/r/joseluisq/static-web-server/tags) [![Docker Image](https://img.shields.io/docker/pulls/joseluisq/static-web-server.svg)](https://hub.docker.com/r/joseluisq/static-web-server/) -**Status:** WIP `v2` release under **active** development. For the stable `v1` and contributions please refer to [1.x](https://github.com/joseluisq/static-web-server/tree/1.x) branch. +**Status:** `v2` is under **active** development. For the stable `v1` please refer to [1.x](https://github.com/joseluisq/static-web-server/tree/1.x) branch. > A blazing fast static files-serving web server. ⚡ @@ -21,8 +21,9 @@ - [Termination signal](https://www.gnu.org/software/libc/manual/html_node/Termination-Signals.html) handling. - [HTTP/2](https://tools.ietf.org/html/rfc7540) + TLS support. - Customizable number of worker threads. -- Default and custom error pages. +- Optional directory listing. - [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) support. +- Default and custom error pages. - Configurable using CLI arguments or environment variables. - First-class [Docker](https://docs.docker.com/get-started/overview/) support. [Scratch](https://hub.docker.com/_/scratch) and latest [Alpine Linux](https://hub.docker.com/_/alpine) Docker images available. - MacOs binary support thanks to [Rust Linux / Darwin Builder](https://github.com/joseluisq/rust-linux-darwin-builder). @@ -53,6 +54,7 @@ Server can be configured either via environment variables or their equivalent co | `SERVER_HTTP2_TLS_CERT` | Specify the file path to read the certificate. | Default empty | | `SERVER_HTTP2_TLS_KEY` | Specify the file path to read the private key. | Default empty | | `SERVER_CORS_ALLOW_ORIGINS` | Specify a optional CORS list of allowed origin hosts separated by comas. Host ports or protocols aren't being checked. Use an asterisk (*) to allow any host. | Default empty (which means CORS is disabled) | +| `SERVER_DIRECTORY_LISTING` | Enable directory listing for all requests ending with the slash character (‘/’) | Default `false` (disabled) | ### Command-line arguments @@ -73,6 +75,9 @@ OPTIONS: -c, --cors-allow-origins Specify an optional CORS list of allowed origin hosts separated by comas. Host ports or protocols aren't being checked. Use an asterisk (*) to allow any host [env: SERVER_CORS_ALLOW_ORIGINS=] [default: ] + -z, --directory-listing + Enable directory listing for all requests ending with the slash character (‘/’) [env: + SERVER_DIRECTORY_LISTING=] -a, --host Host address (E.g 127.0.0.1 or ::1) [env: SERVER_HOST=] [default: ::] diff --git a/src/config.rs b/src/config.rs index 5096d37..6bbda94 100644 --- a/src/config.rs +++ b/src/config.rs @@ -67,4 +67,13 @@ 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", + parse(try_from_str), + env = "SERVER_DIRECTORY_LISTING" + )] + /// Enable directory listing for all requests ending with the slash character (‘/’). + pub directory_listing: bool, } diff --git a/src/handler.rs b/src/handler.rs index ad65b19..4a9ec48 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -5,11 +5,15 @@ use crate::{compression, control_headers, static_files}; use crate::{error::Result, error_page}; /// Main server request handler. -pub async fn handle_request(base: &Path, req: &Request) -> Result> { +pub async fn handle_request( + base: &Path, + dir_listing: bool, + req: &Request, +) -> Result> { let headers = req.headers(); let method = req.method(); - match static_files::handle_request(method, headers, base, req.uri().path()).await { + match static_files::handle_request(method, headers, base, req.uri().path(), dir_listing).await { Ok(resp) => { // Compression on demand based on`Accept-Encoding` header let mut resp = compression::auto(method, headers, resp)?; diff --git a/src/server.rs b/src/server.rs index 25c47e9..c04fe51 100644 --- a/src/server.rs +++ b/src/server.rs @@ -57,7 +57,6 @@ impl Server { logger::init(&opts.log_level)?; tracing::info!("runtime worker threads {}", self.threads); - tracing::info!("runtime max blocking threads {}", self.threads); let ip = opts.host.parse::()?; let addr = SocketAddr::from((ip, opts.port)); @@ -76,6 +75,9 @@ impl Server { // TODO: CORS support + // Directory listing option + let dir_listing = opts.directory_listing; + // Spawn a new Tokio asynchronous server task with its given options let threads = self.threads; @@ -91,7 +93,9 @@ impl Server { async move { Ok::<_, error::Error>(service_fn(move |req| { let root_dir = root_dir.clone(); - async move { handler::handle_request(root_dir.as_ref(), &req).await } + async move { + handler::handle_request(root_dir.as_ref(), dir_listing, &req).await + } })) } }); @@ -125,7 +129,9 @@ impl Server { async move { Ok::<_, error::Error>(service_fn(move |req| { let root_dir = root_dir.clone(); - async move { handler::handle_request(root_dir.as_ref(), &req).await } + async move { + handler::handle_request(root_dir.as_ref(), dir_listing, &req).await + } })) } }); diff --git a/src/static_files.rs b/src/static_files.rs index c628dc2..63f3896 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,11 +19,14 @@ 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; use tokio_util::io::poll_read_buf; +use crate::error::Result; + /// A small Arch `PathBuf` wrapper since Arc doesn't implement AsRef. #[derive(Clone, Debug)] pub struct ArcPath(pub Arc); @@ -40,6 +44,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 +52,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,13 +102,155 @@ 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, +) -> Result> { + let mut entries_str = String::new(); + if base_path != "/" { + entries_str = String::from(r#"../"#); + } + let mut dirs_count: usize = 0; + let mut files_count: usize = 0; + while let Some(entry) = entries.next_entry().await? { + let meta = entry.metadata().await?; + let filesize = meta.len(); + + let mut filesize_str = filesize + .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_str = String::from("-"); + dirs_count += 1; + } else { + files_count += 1; + } + + 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_str + ); + } + + let current_path = percent_decode_str(&base_path).decode_utf8()?.to_string(); + + let dirs_str = if dirs_count == 1 { + "directory" + } else { + "directories" + }; + let files_str = if files_count == 1 { "file" } else { "files" }; + let summary_str = format!( + "
{} {}, {} {}
", + dirs_count, dirs_str, files_count, files_str + ); + let style_str = r#""#; + let footer_str = r#""#; + let page_str = format!( + "Index of {}{}

Index of {}

{}{}


{}", current_path, style_str, current_path, summary_str, entries_str, footer_str + ); + + let mut resp = Response::new(Body::empty()); + 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( headers: &HeaderMap, res: (ArcPath, Metadata, bool), ) -> impl Future, StatusCode>> + Send { - // TODO: directory listing - let (path, meta, auto_index) = res; let conditionals = get_conditional_headers(headers); TkFile::open(path.clone()).then(move |res| match res { -- libgit2 1.7.2