Merge pull request #41 from joseluisq/feature/directory_listing_v2
feat: directory listing support for v2
Diff
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(-)
@@ -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",
@@ -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"
@@ -1,6 +1,6 @@
# Static Web Server [](https://github.com/joseluisq/static-web-server/actions?query=workflow%3ACI) [](https://hub.docker.com/r/joseluisq/static-web-server/) [](https://hub.docker.com/r/joseluisq/static-web-server/tags) [](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
### Command-line arguments
@@ -73,6 +75,9 @@ OPTIONS:
-c, --cors-allow-origins <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 <directory-listing>
Enable directory listing for all requests ending with the slash character (‘/’) [env:
SERVER_DIRECTORY_LISTING=]
-a, --host <host>
Host address (E.g 127.0.0.1 or ::1) [env: SERVER_HOST=] [default: ::]
@@ -67,4 +67,13 @@ pub struct Config {
#[structopt(long, default_value = "", env = "SERVER_HTTP2_TLS_KEY")]
pub http2_tls_key: String,
#[structopt(
long,
short = "z",
parse(try_from_str),
env = "SERVER_DIRECTORY_LISTING"
)]
pub directory_listing: bool,
}
@@ -5,11 +5,15 @@ use crate::{compression, control_headers, static_files};
use crate::{error::Result, error_page};
pub async fn handle_request(base: &Path, req: &Request<Body>) -> Result<Response<Body>> {
pub async fn handle_request(
base: &Path,
dir_listing: bool,
req: &Request<Body>,
) -> Result<Response<Body>> {
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) => {
let mut resp = compression::auto(method, headers, resp)?;
@@ -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::<IpAddr>()?;
let addr = SocketAddr::from((ip, opts.port));
@@ -76,6 +75,9 @@ impl Server {
let dir_listing = opts.directory_listing;
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
}
}))
}
});
@@ -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;
#[derive(Clone, Debug)]
pub struct ArcPath(pub Arc<PathBuf>);
@@ -40,6 +44,7 @@ pub async fn handle_request(
headers: &HeaderMap<HeaderValue>,
base: &Path,
uri_path: &str,
dir_listing: bool,
) -> Result<Response<Body>, StatusCode> {
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?;
if dir_listing && auto_index && !path.as_ref().exists() {
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<Output = Result<Response<Body>, 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))
}
})
}
async fn read_directory_entries(
mut entries: tokio::fs::ReadDir,
base_path: &str,
is_head: bool,
) -> Result<Response<Body>> {
let mut entries_str = String::new();
if base_path != "/" {
entries_str = String::from(r#"<tr><td colspan="3"><a href="../">../</a></td></tr>"#);
}
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!(
"{}<tr><td><a href=\"{}\" title=\"{}\">{}</a></td><td style=\"width: 160px;\">{}</td><td align=\"right\">{}</td></tr>",
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!(
"<div>{} {}, {} {}</div>",
dirs_count, dirs_str, files_count, files_str
);
let style_str = r#"<style>html{background-color:#fff;-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;min-width:20rem;text-rendering:optimizeLegibility;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%}body{padding:1rem;font-family:Consolas,'Liberation Mono',Menlo,monospace;font-size:.875rem;max-width:70rem;margin:0 auto;color:#4a4a4a;font-weight:400;line-height:1.5}h1{margin:0;padding:0;font-size:1.375rem;line-height:1.25;margin-bottom:0.5rem;}table{width:100%}table td{padding:.2rem .5rem;white-space:nowrap;vertical-align:top}table td a{display:inline-block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:95%;vertical-align:top}table tr:hover td{background-color:#f5f5f5}footer{padding-top:0.5rem}</style>"#;
let footer_str = r#"<footer>Powered by <a target="_blank" href="https://git.io/static-web-server">static-web-server</a> | MIT & Apache 2.0</footer>"#;
let page_str = format!(
"<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Index of {}</title>{}</head><body><h1>Index of {}</h1>{}<table><tr><th colspan=\"3\"><hr></th></tr>{}<tr><th colspan=\"3\"><hr></th></tr></table>{}</body></html>", 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));
if is_head {
return Ok(resp);
}
*resp.body_mut() = Body::from(page_str);
Ok(resp)
}
fn parse_last_modified(modified: SystemTime) -> Result<time::Tm, Box<dyn std::error::Error>> {
let since_epoch = modified.duration_since(UNIX_EPOCH)?;
let ts = time::Timespec::new(since_epoch.as_secs() as i64, 0);
Ok(time::at_utc(ts))
}
fn file_reply(
headers: &HeaderMap<HeaderValue>,
res: (ArcPath, Metadata, bool),
) -> impl Future<Output = Result<Response<Body>, StatusCode>> + Send {
let (path, meta, auto_index) = res;
let conditionals = get_conditional_headers(headers);
TkFile::open(path.clone()).then(move |res| match res {