index : static-web-server.git

ascending towards madness

author Jose Quintana <joseluisquintana20@gmail.com> 2021-05-17 22:48:09.0 +00:00:00
committer Jose Quintana <joseluisquintana20@gmail.com> 2021-05-17 22:48:09.0 +00:00:00
commit
b535297cac52e616644d1f927c3a6208b66fe854 [patch]
tree
8bb381ee1f13f9a641f4e4f85764e12818499a07
parent
6caeb9e10d1ed7dabb5f29d8cc309706434bee05
download
b535297cac52e616644d1f927c3a6208b66fe854.tar.gz

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(-)

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<bool>,
}
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<Body>) -> Result<Response
    let headers = req.headers();
    let method = req.method();

    match static_files::handle_request(method, headers, base, req.uri().path()).await {
    // TODO: replace `dir_listing` arg with the corresponding config option
    match static_files::handle_request(method, headers, base, req.uri().path(), true).await {
        Ok(resp) => {
            // 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<HeaderValue>,
    base: &Path,
    uri_path: &str,
    dir_listing: bool,
) -> Result<Response<Body>, 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<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, &current_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<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)?;
    // 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<HeaderValue>,