feat: preliminary directory listing support
Diff
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(-)
@@ -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"
@@ -67,4 +67,8 @@ pub struct Config {
#[structopt(long, default_value = "", env = "SERVER_HTTP2_TLS_KEY")]
pub http2_tls_key: String,
#[structopt(long, short = "z", env = "SERVER_DIRECTORY_LISTING")]
pub directory_listing: Option<bool>,
}
@@ -9,7 +9,8 @@ pub async fn handle_request(base: &Path, req: &Request<Body>) -> Result<Response
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(), true).await {
Ok(resp) => {
let mut resp = compression::auto(method, headers, resp)?;
@@ -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<HeaderValue>,
base: &Path,
uri_path: &str,
dir_listing: bool,
) -> Result<Response<Body>, StatusCode> {
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?;
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,6 +100,128 @@ 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,
) -> crate::error::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>"#);
}
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!(
"{}<tr><td><a href=\"{}\" title=\"{}\">{}</a></td><td style=\"width: 160px;\">{}</td><td align=\"right\" style=\"width: 140px;\">{}</td></tr>",
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!(
"<html><head><meta charset=\"utf-8\"><title>Index of {}</title></head><body><h1>Index of {}</h1><table style=\"min-width:680px;\"><tr><th colspan=\"3\"><hr></th></tr>{}<tr><th colspan=\"3\"><hr></th></tr></table></body></html>", 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<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>,