index : static-web-server.git

ascending towards madness

author Jose Quintana <joseluisquintana20@gmail.com> 2021-02-10 0:45:44.0 +00:00:00
committer Jose Quintana <joseluisquintana20@gmail.com> 2021-02-10 0:45:44.0 +00:00:00
commit
feac82e1e8b0015c364e721b139d4661acc7852e [patch]
tree
475cde6a165accd4e7db169277da004f0e5f48d1
parent
1c1c2afef5c473bf4889bb49ea498221cd8f83b6
download
feac82e1e8b0015c364e721b139d4661acc7852e.tar.gz

feat: directory listing support



Diff

 Cargo.lock                              |  15 ++-
 Cargo.toml                              |   1 +-
 README.md                               |  11 +-
 src/bin/server.rs                       |   6 +-
 src/config.rs                           |   8 ++-
 src/server.rs                           |   3 +-
 src/staticfile_middleware/staticfile.rs | 149 ++++++++++++++++++++++++++-------
 src/staticfiles.rs                      |   4 +-
 8 files changed, 158 insertions(+), 39 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index d7375e4..ec1f619 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -243,6 +243,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "615caabe2c3160b313d52ccc905335f4ed5f10881dd63dc5699d47e90be85691"

[[package]]
name = "humansize"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6cab2627acfc432780848602f3f558f7e9dd427352224b0d9324025796d2a5e"

[[package]]
name = "humantime"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -366,9 +372,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"

[[package]]
name = "libc"
version = "0.2.85"
version = "0.2.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ccac4b00700875e6a07c6cde370d44d32fa01c5a65cdd2fca6858c479d28bb3"
checksum = "b7282d924be3275cec7f6756ff4121987bc6481325397dde6ba3e7802b1a8b1c"

[[package]]
name = "log"
@@ -942,6 +948,7 @@ dependencies = [
 "chrono",
 "env_logger",
 "flate2",
 "humansize",
 "hyper",
 "hyper-native-tls",
 "iron",
@@ -1119,9 +1126,9 @@ dependencies = [

[[package]]
name = "unicode-normalization"
version = "0.1.16"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13e63ab62dbe32aeee58d1c5408d35c36c392bba5d9d3142287219721afe606"
checksum = "07fbfce1c8a97d547e8b5334978438d9d6ec8c20e38f56d4a4374d181493eaef"
dependencies = [
 "tinyvec",
]
diff --git a/Cargo.toml b/Cargo.toml
index 285f323..08892ca 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -28,6 +28,7 @@ path = "src/bin/server.rs"
chrono = "0.4"
env_logger = "0.7"
flate2 = "1.0"
humansize = "1.1"
hyper-native-tls = "0.3"
iron = "0.6"
iron-cors = "0.8"
diff --git a/README.md b/README.md
index d8e2a73..255f67c 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,7 @@
- [HEAD]https://tools.ietf.org/html/rfc7231#section-4.3.2 responses support.
- [TLS]https://www.openssl.org/ support via [Rust Native TLS]https://docs.rs/native-tls/0.2.3/native_tls/ crate.
- Lightweight and configurable logging.
- Directory listing support.
- 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.
- Server configurable via environment variables or CLI arguments.
- MacOs binary support (`x86_64-apple-darwin`) thanks to [Rust Linux / Darwin Builder]https://github.com/joseluisq/rust-linux-darwin-builder.
@@ -52,7 +53,8 @@ Server can be configured either via environment variables or their equivalent co
| `SERVER_TLS_PKCS12_PASSWD`  | A specified password to decrypt the private key.                                                                                                                                                                                                                                                     | Default empty                                                                                                                   |
| `SERVER_TLS_REDIRECT_FROM`  | Host port for redirecting HTTP requests to HTTPS. This option enables the HTTP redirect feature                                                                                                                                                                                                      | Default empty (disabled)                                                                                                        |
| `SERVER_TLS_REDIRECT_HOST`  | Host name of HTTPS site for redirecting HTTP requests to.                                                                                                                                                                                                                                            | Default host address                                                                                                            |
| `SERVER_CORS_ALLOW_ORIGINS` | Specify a CORS list of allowed origin hosts separated by comas. Host ports or protocols aren't being checked. Use an asterisk (*) to allow any host. See [Iron CORS crate]https://docs.rs/iron-cors/0.8.0/iron_cors/#mode-1-whitelist.                                         | Default empty (which means CORS is disabled)                                                                                    |
| `SERVER_CORS_ALLOW_ORIGINS` | Specify a CORS list of allowed origin hosts separated by comas. Host ports or protocols aren't being checked. Use an asterisk (*) to allow any host. See [Iron CORS crate]https://docs.rs/iron-cors/0.8.0/iron_cors/#mode-1-whitelist.                                                             | Default empty (which means CORS is disabled)                                                                                    |
| `SERVER_DIRECTORY_LISTING`  | Enable directory listing for all requests ending with the slash character (‘/’)                                                                                                                                                                                                                      | Default `false` (which means it's disabled)                                                                                    |

### Command-line arguments

@@ -74,8 +76,11 @@ OPTIONS:
            Assets directory path for add cache headers functionality [env: SERVER_ASSETS=]  [default: ./public/assets]

    -c, --cors-allow-origins <cors-allow-origins>
            Specify a CORS list of allowed origin hosts separated by comas with no whitespaces. Host ports or protocols
            aren't being checked. Use an asterisk (*) to allow any host [env: SERVER_CORS_ALLOW_ORIGINS=]  [default: ]
            Specify a 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: ]
    -i, --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) [env: SERVER_HOST=]  [default: [::]]
    -g, --log-level <log-level>
            Specify a logging level in lower case [env: SERVER_LOG_LEVEL=]  [default: error]
diff --git a/src/bin/server.rs b/src/bin/server.rs
index c771664..5a3579c 100644
--- a/src/bin/server.rs
+++ b/src/bin/server.rs
@@ -71,6 +71,7 @@ mod test {
            page_50x_path: opts.page50x,
            page_404_path: opts.page404,
            cors_allow_origins: "".to_string(),
            directory_listing: false,
        });

        let response = request::head("http://127.0.0.1/", Headers::new(), &files.handle())
@@ -94,6 +95,7 @@ mod test {
            page_50x_path: opts.page50x,
            page_404_path: opts.page404,
            cors_allow_origins: "".to_string(),
            directory_listing: false,
        });

        let res = request::head("http://127.0.0.1/", Headers::new(), &files.handle())
@@ -122,6 +124,7 @@ mod test {
            page_50x_path: opts.page50x,
            page_404_path: opts.page404,
            cors_allow_origins: "".to_string(),
            directory_listing: false,
        });

        let res = request::head("http://127.0.0.1/", Headers::new(), &files.handle())
@@ -144,6 +147,7 @@ mod test {
            page_50x_path: opts.page50x,
            page_404_path: opts.page404,
            cors_allow_origins: "".to_string(),
            directory_listing: false,
        });

        let res = request::head("http://127.0.0.1/unknown", Headers::new(), &files.handle())
@@ -166,6 +170,7 @@ mod test {
            page_50x_path: opts.page50x,
            page_404_path: opts.page404,
            cors_allow_origins: "".to_string(),
            directory_listing: false,
        });

        let response = request::post("http://127.0.0.1/", Headers::new(), "", &files.handle())
@@ -210,6 +215,7 @@ mod test {
            page_50x_path: opts.page50x,
            page_404_path: opts.page404,
            cors_allow_origins: "".to_string(),
            directory_listing: false,
        });

        let res = request::head("http://127.0.0.1/unknown", Headers::new(), &files.handle())
diff --git a/src/config.rs b/src/config.rs
index 7c69457..1d42404 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -81,4 +81,12 @@ pub struct Options {
    )]
    /// Specify a CORS list of allowed origin hosts separated by comas. Host ports or protocols aren't being checked. Use an asterisk (*) to allow any host.
    pub cors_allow_origins: String,

    #[structopt(
        long,
        short = "i",
        env = "SERVER_DIRECTORY_LISTING"
    )]
    /// Enable directory listing for all requests ending with the slash character (‘/’).
    pub directory_listing: bool,
}
diff --git a/src/server.rs b/src/server.rs
index 35202ef..cd7aef1 100644
--- a/src/server.rs
+++ b/src/server.rs
@@ -32,19 +32,20 @@ fn on_server_running(server_name: &str, running_servers: &[RunningServer]) {
    })
}

/// Run HTTP/HTTPS web server
pub fn run(opts: Options) {
    logger::init(&opts.log_level);

    let addr = &format!("{}{}{}", opts.host, ":", opts.port);

    // Configure & launch the HTTP server

    let files = StaticFiles::new(StaticFilesOptions {
        root_dir: opts.root,
        assets_dir: opts.assets,
        page_50x_path: opts.page50x,
        page_404_path: opts.page404,
        cors_allow_origins: opts.cors_allow_origins,
        directory_listing: opts.directory_listing,
    });

    let mut running_servers = Vec::new();
diff --git a/src/staticfile_middleware/staticfile.rs b/src/staticfile_middleware/staticfile.rs
index b8e03fe..06836c9 100644
--- a/src/staticfile_middleware/staticfile.rs
+++ b/src/staticfile_middleware/staticfile.rs
@@ -1,19 +1,18 @@
use std::ffi::OsString;
use std::fs::{File, Metadata};
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use std::{error, io};

use humansize::{file_size_opts, FileSize};
use iron::headers::{
    AcceptEncoding, AcceptRanges, ContentEncoding, ContentLength, Encoding, HttpDate,
    IfModifiedSince, LastModified, Range, RangeUnit,
};

use iron::method::Method;
use iron::middleware::Handler;
use iron::modifiers::Header;
use iron::prelude::*;
use iron::status;
use std::fs::{File, Metadata};
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use std::{error, io};
use std::{ffi::OsString, time::SystemTime};

use crate::staticfile_middleware::helpers;
use crate::staticfile_middleware::partial_file::PartialFile;
@@ -22,17 +21,22 @@ use crate::staticfile_middleware::partial_file::PartialFile;
pub struct Staticfile {
    root: PathBuf,
    assets: PathBuf,
    dir_list: bool,
}

impl Staticfile {
    pub fn new<P>(root: P, assets: P) -> io::Result<Staticfile>
    pub fn new<P>(root: P, assets: P, dir_list: bool) -> io::Result<Staticfile>
    where
        P: AsRef<Path>,
    {
        let root = root.as_ref().canonicalize()?;
        let assets = assets.as_ref().canonicalize()?;

        Ok(Staticfile { root, assets })
        Ok(Staticfile {
            root,
            assets,
            dir_list,
        })
    }

    fn resolve_path(&self, path: &[&str]) -> Result<PathBuf, Box<dyn error::Error>> {
@@ -85,10 +89,93 @@ impl Handler for Staticfile {
            Err(_) => return Ok(Response::with(status::NotFound)),
        };

        // 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 self.dir_list && file_path.is_dir() && !file_path.join("index.html").exists() {
            let encoding = Encoding::Identity;
            let readir = match std::fs::read_dir(file_path) {
                Ok(dir) => dir,
                Err(err) => {
                    error!("{}", err);
                    return Ok(Response::with(status::InternalServerError));
                }
            };

            let mut current_path = req
                .url
                .path()
                .into_iter()
                .map(|i| format!("/{}", i))
                .collect::<String>();

            // Redirect if current path does not end with slash
            if !current_path.ends_with('/') {
                let mut u: url::Url = req.url.clone().into();
                current_path.push('/');
                u.set_path(&current_path);

                let url = iron::Url::from_generic_url(u).expect("Unable to parse redirect url");

                return Ok(Response::with((
                    status::PermanentRedirect,
                    iron::modifiers::Redirect(url),
                )));
            }

            // Read current directory and create the index page
            let mut entries_str = String::new();
            if current_path != "/" {
                entries_str =
                    String::from("<tr><td colspan=\"3\"><a href=\"../\">../</a></td></tr>");
            }
            for entry in readir {
                let entry = entry.unwrap();
                let meta = entry.metadata().unwrap();
                let mut filesize = meta.len().file_size(file_size_opts::DECIMAL).unwrap();
                let mut name = entry.file_name().into_string().unwrap();
                if meta.is_dir() {
                    name = format!("{}/", name);
                    filesize = String::from("-")
                }
                let uri = format!("{}{}", current_path, name);
                let modified = get_last_modified(meta.modified().unwrap()).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 page = format!(
                "<html><head><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 len = page.len() as u64;
            let content_encoding = ContentEncoding(vec![encoding]);
            let mut resp = Response::with((status::Ok, Header(content_encoding), page));

            // Empty current response body on HEAD requests,
            // just setting up the `content-length` header (size of the file in bytes)
            // https://tools.ietf.org/html/rfc7231#section-4.3.2
            if req.method == Method::Head {
                resp.set_mut(vec![]);
                resp.set_mut(Header(ContentLength(len)));
            }

            return Ok(resp);
        }

        // 2. Otherwise proceed with the normal file-response process

        // Get current file metadata
        let accept_gz = helpers::accept_gzip(req.headers.get::<AcceptEncoding>());
        let file = match StaticFileWithMetadata::search(&file_path, accept_gz) {
            Ok(file) => file,
            Ok(f) => f,
            Err(_) => return Ok(Response::with(status::NotFound)),
        };

@@ -118,6 +205,7 @@ impl Handler for Staticfile {
        };
        let encoding = ContentEncoding(vec![encoding]);

        // Prepare response
        let mut resp = match last_modified {
            Some(last_modified) => {
                let last_modified = LastModified(last_modified);
@@ -143,8 +231,7 @@ impl Handler for Staticfile {
        // Partial Content Delivery response
        // Enable the "Accept-Ranges" header on all files
        resp.set_mut(Header(AcceptRanges(vec![RangeUnit::Bytes])));

        let resp = match req.headers.get::<Range>().cloned() {
        resp = match req.headers.get::<Range>().cloned() {
            // Deliver the whole file
            None => resp,
            // Try to deliver partial content
@@ -234,23 +321,25 @@ impl StaticFileWithMetadata {
    }

    pub fn last_modified(&self) -> Result<time::Tm, Box<dyn error::Error>> {
        let modified = self.metadata.modified()?;
        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))
        get_last_modified(self.metadata.modified()?)
    }
}

fn get_last_modified(modified: SystemTime) -> Result<time::Tm, Box<dyn 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))
}

#[cfg(test)]
mod test {
    extern crate hyper;
@@ -301,7 +390,7 @@ mod test {
        let fs2 = TestFilesystemSetup::new();
        fs2.dir("assets");

        let sf = Staticfile::new(fs.path(), fs2.path()).unwrap();
        let sf = Staticfile::new(fs.path(), fs2.path(), false).unwrap();
        let path = sf.resolve_path(&["index.html"]);
        assert!(path.unwrap().ends_with("index.html"));
    }
@@ -314,7 +403,7 @@ mod test {
        let fs2 = TestFilesystemSetup::new();
        fs2.file("assets");

        let sf = Staticfile::new(fs.path(), fs2.path()).unwrap();
        let sf = Staticfile::new(fs.path(), fs2.path(), false).unwrap();
        let path = sf.resolve_path(&["dir", "index.html"]);
        assert!(path.unwrap().ends_with("dir/index.html"));
    }
@@ -327,7 +416,7 @@ mod test {
        let fs2 = TestFilesystemSetup::new();
        let dir2 = fs2.file("assets");

        let sf = Staticfile::new(dir, dir2).unwrap();
        let sf = Staticfile::new(dir, dir2, false).unwrap();
        let path = sf.resolve_path(&["..", "naughty.txt"]);
        assert!(path.is_err());
    }
@@ -336,7 +425,7 @@ mod test {
    fn staticfile_disallows_post_requests() {
        let fs = TestFilesystemSetup::new();
        let fs2 = TestFilesystemSetup::new();
        let sf = Staticfile::new(fs.path(), fs2.path()).unwrap();
        let sf = Staticfile::new(fs.path(), fs2.path(), false).unwrap();

        let response = request::post("http://127.0.0.1/", Headers::new(), "", &sf);

diff --git a/src/staticfiles.rs b/src/staticfiles.rs
index 1db2b0c..4904725 100644
--- a/src/staticfiles.rs
+++ b/src/staticfiles.rs
@@ -21,6 +21,7 @@ pub struct StaticFilesOptions {
    pub page_50x_path: String,
    pub page_404_path: String,
    pub cors_allow_origins: String,
    pub directory_listing: bool,
}

impl StaticFiles {
@@ -60,7 +61,8 @@ impl StaticFiles {

        // Define middleware chain
        let mut chain = Chain::new(
            Staticfile::new(root_dir, assets_dir).expect("Directory to serve files was not found"),
            Staticfile::new(root_dir, assets_dir, self.opts.directory_listing)
                .expect("Directory to serve files was not found"),
        );
        let one_day = Duration::new(60 * 60 * 24, 0);
        let one_year = Duration::new(60 * 60 * 24 * 365, 0);