index : static-web-server.git

ascending towards madness

author Jose Quintana <joseluisquintana20@gmail.com> 2021-01-21 22:58:40.0 +00:00:00
committer Jose Quintana <joseluisquintana20@gmail.com> 2021-01-21 22:58:40.0 +00:00:00
commit
ed0d6ac9989a14704c414eefd1aca16315e2fcca [patch]
tree
bb4dc597e66038a106f37ef9d2cc19ae2a046799
parent
866c7cdd48b8f8467e09794025052e4a883b2027
download
ed0d6ac9989a14704c414eefd1aca16315e2fcca.tar.gz

feat: custom error pages support



Diff

 README.md             |   2 +-
 src/bin/server.rs     | 142 +++++++++++++++++++++++++++++++++------------------
 src/core/cache.rs     |   7 +++-
 src/core/config.rs    |  16 ++++++-
 src/core/helpers.rs   |  14 ++++-
 src/core/rejection.rs |  39 +++++++++-----
 6 files changed, 157 insertions(+), 63 deletions(-)

diff --git a/README.md b/README.md
index e43c974..f41622b 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ For stable `v1` and contributions please refer to [1.x](https://github.com/josel

- Built with [Rust]https://rust-lang.org which is focused on [safety, speed, and concurrency]https://kornel.ski/rust-c-speed.
- Memory safety and very reduced CPU and RAM overhead.
- Blazing fast static files-serving powered by [Warp]https://github.com/seanmonstar/warp/ `v0.2` ([Hyper]https://github.com/hyperium/hyper/ `v0.13`), [Tokio]https://github.com/tokio-rs/tokio `v0.2` and a set of [awesome crates]./Cargo.toml.
- Blazing fast static files-serving powered by [Warp]https://github.com/seanmonstar/warp/ `v0.3` ([Hyper]https://github.com/hyperium/hyper/ `v0.14`), [Tokio]https://github.com/tokio-rs/tokio `v1` and a set of [awesome crates]./Cargo.toml.
- Suitable for lightweight [GNU/Linux Docker containers]https://hub.docker.com/r/joseluisq/static-web-server/tags. It's a fully __5MB__ static binary thanks to [Rust and Musl libc]https://doc.rust-lang.org/edition-guide/rust-2018/platform-and-target-support/musl-support-for-fully-static-binaries.html.
- Opt-in GZip, Deflate and Brotli compression for text-based web files only.
- Compression on demand via [Accept-Encoding]https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding header.
diff --git a/src/bin/server.rs b/src/bin/server.rs
index 8d0292f..c8375e1 100644
--- a/src/bin/server.rs
+++ b/src/bin/server.rs
@@ -16,71 +16,117 @@ use self::static_web_server::core::*;
async fn server(opts: config::Options) -> Result {
    logger::init(&opts.log_level)?;

    // Check a valid root directory
    let root_dir = helpers::get_valid_dirpath(opts.root)?;

    // Read custom error pages content
    let page404 = helpers::read_file_content(opts.page404.as_ref());
    let page50x = helpers::read_file_content(opts.page50x.as_ref());

    // Public HEAD endpoint
    let page404_a = page404.clone();
    let page50x_a = page50x.clone();
    let public_head = warp::head().and(
        warp::fs::dir(opts.root.clone())
        warp::fs::dir(root_dir.clone())
            .map(cache::control_headers)
            .with(warp::trace::request())
            .recover(rejection::handle_rejection),
            .recover(move |r| {
                let page404_a = page404_a.clone();
                let page50x_a = page50x_a.clone();
                async move { rejection::handle_rejection(page404_a, page50x_a, r).await }
            }),
    );

    // Public GET endpoint (default)
    let page404_b = page404.clone();
    let page50x_b = page50x.clone();
    let public_get_default = warp::get().and(
        warp::fs::dir(opts.root.clone())
        warp::fs::dir(root_dir.clone())
            .map(cache::control_headers)
            .with(warp::trace::request())
            .recover(rejection::handle_rejection),
            .recover(move |r| {
                let page404_b = page404_b.clone();
                let page50x_b = page50x_b.clone();
                async move { rejection::handle_rejection(page404_b, page50x_b, r).await }
            }),
    );

    let host = opts.host.parse::<std::net::IpAddr>()?;
    let port = opts.port;

    let accept_encoding = |v: &'static str| warp::header::contains("accept-encoding", v);

    // Public GET/HEAD endpoints with compression (deflate, gzip, brotli, none)
    let page404_c = page404.clone();
    let page50x_c = page50x.clone();
    match opts.compression.as_ref() {
        "brotli" => tokio::task::spawn(
            warp::serve(
                public_head.or(warp::get()
                    .and(accept_encoding("br"))
                    .and(
                        warp::fs::dir(opts.root.clone())
                            .map(cache::control_headers)
                            .with(warp::trace::request())
                            .with(warp::compression::brotli(true))
                            .recover(rejection::handle_rejection),
                    )
                    .or(public_get_default)),
        "brotli" => {
            tokio::task::spawn(
                warp::serve(
                    public_head.or(warp::get()
                        .and(cache::accept_encoding("br"))
                        .and(
                            warp::fs::dir(root_dir.clone())
                                .map(cache::control_headers)
                                .with(warp::trace::request())
                                .with(warp::compression::brotli(true))
                                .recover(move |r| {
                                    let page404_c = page404_c.clone();
                                    let page50x_c = page50x_c.clone();
                                    async move {
                                        rejection::handle_rejection(page404_c, page50x_c, r).await
                                    }
                                }),
                        )
                        .or(public_get_default)),
                )
                .run((host, port)),
            )
            .run((host, port)),
        ),
        "deflate" => tokio::task::spawn(
            warp::serve(
                public_head.or(warp::get()
                    .and(accept_encoding("deflate"))
                    .and(
                        warp::fs::dir(opts.root.clone())
                            .map(cache::control_headers)
                            .with(warp::trace::request())
                            .with(warp::compression::deflate(true))
                            .recover(rejection::handle_rejection),
                    )
                    .or(public_get_default)),
        }
        "deflate" => {
            tokio::task::spawn(
                warp::serve(
                    public_head.or(warp::get()
                        .and(cache::accept_encoding("deflate"))
                        .and(
                            warp::fs::dir(root_dir.clone())
                                .map(cache::control_headers)
                                .with(warp::trace::request())
                                .with(warp::compression::deflate(true))
                                .recover(move |r| {
                                    let page404_c = page404_c.clone();
                                    let page50x_c = page50x_c.clone();
                                    async move {
                                        rejection::handle_rejection(page404_c, page50x_c, r).await
                                    }
                                }),
                        )
                        .or(public_get_default)),
                )
                .run((host, port)),
            )
            .run((host, port)),
        ),
        "gzip" => tokio::task::spawn(
            warp::serve(
                public_head.or(warp::get()
                    .and(accept_encoding("gzip"))
                    .and(
                        warp::fs::dir(opts.root.clone())
                            .map(cache::control_headers)
                            .with(warp::trace::request())
                            .with(warp::compression::gzip(true))
                            .recover(rejection::handle_rejection),
                    )
                    .or(public_get_default)),
        }
        "gzip" => {
            tokio::task::spawn(
                warp::serve(
                    public_head.or(warp::get()
                        .and(cache::accept_encoding("gzip"))
                        .and(
                            warp::fs::dir(root_dir.clone())
                                .map(cache::control_headers)
                                .with(warp::trace::request())
                                .with(warp::compression::gzip(true))
                                .recover(move |r| {
                                    let page404_c = page404_c.clone();
                                    let page50x_c = page50x_c.clone();
                                    async move {
                                        rejection::handle_rejection(page404_c, page50x_c, r).await
                                    }
                                }),
                        )
                        .or(public_get_default)),
                )
                .run((host, port)),
            )
            .run((host, port)),
        ),
        }
        _ => tokio::task::spawn(warp::serve(public_head.or(public_get_default)).run((host, port))),
    };

diff --git a/src/core/cache.rs b/src/core/cache.rs
index c771cb3..148e7d5 100644
--- a/src/core/cache.rs
+++ b/src/core/cache.rs
@@ -36,3 +36,10 @@ pub fn control_headers(res: warp::fs::File) -> warp::reply::WithHeader<warp::fs:
fn duration(n: u64) -> u32 {
    std::cmp::min(n, u32::MAX as u64) as u32
}

/// Warp filter in order to check for an `Accept-Encoding` header value.
pub fn accept_encoding(
    val: &'static str,
) -> impl warp::Filter<Extract = (), Error = warp::Rejection> + Copy {
    warp::header::contains("accept-encoding", val)
}
diff --git a/src/core/config.rs b/src/core/config.rs
index 63e9f23..406f43c 100644
--- a/src/core/config.rs
+++ b/src/core/config.rs
@@ -27,6 +27,22 @@ pub struct Options {
    /// Root directory path of static files
    pub root: String,

    #[structopt(
        long,
        default_value = "./public/50x.html",
        env = "SERVER_ERROR_PAGE_50X"
    )]
    /// HTML file path for 50x errors. If path is not specified or simply don't exists then server will use a generic HTML error message.
    pub page50x: String,

    #[structopt(
        long,
        default_value = "./public/404.html",
        env = "SERVER_ERROR_PAGE_404"
    )]
    /// HTML file path for 404 errors. If path is not specified or simply don't exists then server will use a generic HTML error message.
    pub page404: String,

    #[structopt(long, short = "c", default_value = "gzip", env = "SERVER_COMPRESSION")]
    /// Compression body support for web text-based file types. Values: "gzip", "deflate" or "brotli".
    /// Use an empty value to skip compression.
diff --git a/src/core/helpers.rs b/src/core/helpers.rs
index 96f2eff..9abc9b1 100644
--- a/src/core/helpers.rs
+++ b/src/core/helpers.rs
@@ -1,3 +1,4 @@
use std::fs;
use std::path::{Path, PathBuf};

use super::Result;
@@ -8,8 +9,8 @@ where
    PathBuf: From<P>,
{
    match PathBuf::from(path) {
        v if !v.exists() => bail!("path \"{:?}\" was not found", &v),
        v if !v.is_dir() => bail!("path \"{:?}\" is not a directory", &v),
        v if !v.exists() => bail!("path \"{:?}\" was not found or inaccessible", &v),
        v if !v.is_dir() => bail!("path \"{:?}\" is not a valid directory", &v),
        v => Ok(v),
    }
}
@@ -25,3 +26,12 @@ where
        _ => bail!("directory name for path \"{:?}\" was not determined", path),
    }
}

// Read the entire contents of a file into a string if it's valid or empty otherwise.
pub fn read_file_content(p: &str) -> String {
    if !p.is_empty() && Path::new(p).exists() {
        return fs::read_to_string(p).unwrap_or(String::new());
    }

    String::new()
}
diff --git a/src/core/rejection.rs b/src/core/rejection.rs
index 666eaef..78e8502 100644
--- a/src/core/rejection.rs
+++ b/src/core/rejection.rs
@@ -4,22 +4,37 @@ use warp::http::StatusCode;
use warp::{Rejection, Reply};

// It receives a `Rejection` and tries to return a HTML error reply.
pub async fn handle_rejection(err: Rejection) -> Result<impl Reply, Infallible> {
pub async fn handle_rejection(
    page_404: String,
    page_50x: String,
    err: Rejection,
) -> Result<impl Reply, Infallible> {
    let mut content = String::new();
    let code = if err.is_not_found() {
        content = page_404;
        StatusCode::NOT_FOUND
    } else if err
    } else {
        if err
        .find::<warp::filters::body::BodyDeserializeError>()
        .is_some()
    {
        StatusCode::BAD_REQUEST
    } else if err.find::<warp::reject::MethodNotAllowed>().is_some() {
        StatusCode::METHOD_NOT_ALLOWED
    } else {
        StatusCode::INTERNAL_SERVER_ERROR
        {
            StatusCode::BAD_REQUEST
        } else if err.find::<warp::reject::MethodNotAllowed>().is_some() {
            StatusCode::METHOD_NOT_ALLOWED
        } else if err.find::<warp::reject::UnsupportedMediaType>().is_some() {
            StatusCode::UNSUPPORTED_MEDIA_TYPE
        } else {
            content = page_50x;
            StatusCode::INTERNAL_SERVER_ERROR
        }
    };
    let content = format!(
        "<html><head><title>{}</title></head><body><center><h1>{}</h1></center></body></html>",
        code, code
    );

    if content.is_empty() {
        content = format!(
            "<html><head><title>{}</title></head><body><center><h1>{}</h1></center></body></html>",
            code, code
        );
    }

    Ok(warp::reply::with_status(warp::reply::html(content), code))
}