From e63e5ff929e3e5971160e0768d95e4dc14014cf6 Mon Sep 17 00:00:00 2001 From: Jose Quintana Date: Wed, 10 Feb 2021 02:05:41 +0100 Subject: [PATCH] Merge pull request #32 from joseluisq/feature/Directory_listing_support feat: directory listing support --- Cargo.lock | 15 +++++++++++---- Cargo.toml | 1 + README.md | 11 ++++++++--- src/bin/server.rs | 6 ++++++ src/config.rs | 4 ++++ src/server.rs | 3 ++- src/staticfile_middleware/staticfile.rs | 149 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------ src/staticfiles.rs | 4 +++- 8 files changed, 154 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 - 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 + Enable directory listing for all requests ending with the slash character (‘/’) [env: + SERVER_DIRECTORY_LISTING=] -a, --host Host address (E.g 127.0.0.1) [env: SERVER_HOST=] [default: [::]] -g, --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..976cadc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -81,4 +81,8 @@ 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

(root: P, assets: P) -> io::Result + pub fn new

(root: P, assets: P, dir_list: bool) -> io::Result where P: AsRef, { 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> { @@ -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::(); + + // 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(¤t_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("../"); + } + 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!( + "{}{}{}{}", + entries_str, + uri, + name, + name, + modified.to_local().strftime("%F %T").unwrap(), + filesize + ); + } + + let page = format!( + "Index of {}

Index of {}

{}


", 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::()); 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::().cloned() { + resp = match req.headers.get::().cloned() { // Deliver the whole file None => resp, // Try to deliver partial content @@ -234,23 +321,25 @@ impl StaticFileWithMetadata { } pub fn last_modified(&self) -> Result> { - 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> { + 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); -- libgit2 1.7.2