index : static-web-server.git

ascending towards madness

author Jose Quintana <1700322+joseluisq@users.noreply.github.com> 2021-05-19 9:40:56.0 +00:00:00
committer GitHub <noreply@github.com> 2021-05-19 9:40:56.0 +00:00:00
commit
5428eb363b693f74285ba8d66edf4bad1778b3ba [patch]
tree
d94b11d210805746d2a3175b0309b579a1565eba
parent
6caeb9e10d1ed7dabb5f29d8cc309706434bee05
parent
da67063bcc3cea664608adcc860fc40f34500507
download
5428eb363b693f74285ba8d66edf4bad1778b3ba.tar.gz

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

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 <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: ::]

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<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) => {
            // 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::<IpAddr>()?;
        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<PathBuf> doesn't implement AsRef<Path>.
#[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> {
    // 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<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,
) -> 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 &amp; 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));

    // 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<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>,
    res: (ArcPath, Metadata, bool),
) -> impl Future<Output = Result<Response<Body>, 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 {