From 533006fc36d16441e80482f221a01ddbdd134a04 Mon Sep 17 00:00:00 2001 From: Jose Quintana Date: Mon, 8 Feb 2021 01:24:39 +0100 Subject: [PATCH] refactor: structure project modules --- .gitignore | 1 + Cargo.lock | 70 ++++++++++++++++++++++++++++++---------------------------------------- Cargo.toml | 22 +++++++++++++++++----- src/bin/server.rs | 227 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/gzip.rs | 37 +++++++++++++++++++++---------------- src/lib.rs | 21 +++++++++++++++++++++ src/main.rs | 320 -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- src/server.rs | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/staticfile_middleware/cache.rs | 36 ++++++++++++++++++++++++++++++++++++ src/staticfile_middleware/guess_content_type.rs | 47 +++++++++++++++++++++++++++++++++++++++++++++++ src/staticfile_middleware/helpers.rs | 41 +++++++++++++++++++++++++++++++++++++++++ src/staticfile_middleware/http_to_https_redirect.rs | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/staticfile_middleware/mod.rs | 17 +++++++++++++++++ src/staticfile_middleware/modify_with.rs | 24 ++++++++++++++++++++++++ src/staticfile_middleware/partial_file.rs | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/staticfile_middleware/prefix.rs | 45 +++++++++++++++++++++++++++++++++++++++++++++ src/staticfile_middleware/rewrite.rs | 34 ++++++++++++++++++++++++++++++++++ src/staticfile_middleware/staticfile.rs | 346 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/staticfiles.rs | 12 ++++++------ 19 files changed, 1198 insertions(+), 387 deletions(-) create mode 100644 src/bin/server.rs create mode 100644 src/lib.rs delete mode 100644 src/main.rs create mode 100644 src/server.rs create mode 100644 src/staticfile_middleware/cache.rs create mode 100644 src/staticfile_middleware/guess_content_type.rs create mode 100644 src/staticfile_middleware/helpers.rs create mode 100644 src/staticfile_middleware/http_to_https_redirect.rs create mode 100644 src/staticfile_middleware/mod.rs create mode 100644 src/staticfile_middleware/modify_with.rs create mode 100644 src/staticfile_middleware/partial_file.rs create mode 100644 src/staticfile_middleware/prefix.rs create mode 100644 src/staticfile_middleware/rewrite.rs create mode 100644 src/staticfile_middleware/staticfile.rs diff --git a/.gitignore b/.gitignore index 820521f..7b070eb 100644 --- a/.gitignore +++ b/.gitignore @@ -15,5 +15,6 @@ **/*.env release .vscode +TODO !sample.env diff --git a/Cargo.lock b/Cargo.lock index 47e56c2..d7375e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -163,16 +163,16 @@ checksum = "44533bbbb3bb3c1fa17d9f2e4e38bbbaf8396ba82193c4cb1b6445d711445d36" dependencies = [ "atty", "humantime", - "log 0.4.13", + "log 0.4.14", "regex", "termcolor", ] [[package]] name = "flate2" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7411863d55df97a419aa64cb4d2f167103ea9d767e2c54a1868b7ac3f6b47129" +checksum = "cd3aec53de10fe96d7d8c565eb17f2c687bb5518a2ec453b5b1252964526abe0" dependencies = [ "cfg-if 1.0.0", "crc32fast", @@ -238,9 +238,9 @@ dependencies = [ [[package]] name = "httparse" -version = "1.3.4" +version = "1.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd179ae861f0c2e53da70d892f5f3029f9594be0c41dc5269cd371691b1dc2f9" +checksum = "615caabe2c3160b313d52ccc905335f4ed5f10881dd63dc5699d47e90be85691" [[package]] name = "humantime" @@ -315,7 +315,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24b02b8856c7f14e443c483e802cf0ce693f3bec19f49d2c9a242b18f88c9b70" dependencies = [ "iron", - "log 0.4.13", + "log 0.4.14", ] [[package]] @@ -332,20 +332,6 @@ dependencies = [ ] [[package]] -name = "iron_staticfile_middleware" -version = "0.4.2" -source = "git+https://github.com/joseluisq/iron-staticfile-middleware.git?tag=v0.4.2#1681b273edd087ff470d77d2c3fa7d64fc547e6f" -dependencies = [ - "iron", - "log 0.4.13", - "mime", - "mime_guess", - "rustc-serialize", - "time", - "url", -] - -[[package]] name = "jemalloc-sys" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -380,9 +366,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.82" +version = "0.2.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89203f3fba0a3795506acaad8ebce3c80c0af93f994d5a1d7a0b1eeb23271929" +checksum = "7ccac4b00700875e6a07c6cde370d44d32fa01c5a65cdd2fca6858c479d28bb3" [[package]] name = "log" @@ -390,16 +376,16 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e19e8d5c34a3e0e2223db8e060f9e8264aeeb5c5fc64a4ee9965c062211c024b" dependencies = [ - "log 0.4.13", + "log 0.4.14", ] [[package]] name = "log" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcf3805d4480bb5b86070dcfeb9e2cb2ebc148adb753c5cca5f884d1d65a42b2" +checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" dependencies = [ - "cfg-if 0.1.10", + "cfg-if 1.0.0", ] [[package]] @@ -459,7 +445,7 @@ checksum = "b8d96b2e1c8da3957d58100b09f102c6d9cfdfced01b7ec5a8974044bb09dbd4" dependencies = [ "lazy_static", "libc", - "log 0.4.13", + "log 0.4.14", "openssl", "openssl-probe", "openssl-sys", @@ -718,9 +704,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18519b42a40024d661e1714153e9ad0c3de27cd495760ceb09710920f1098b1e" +checksum = "0ef9e7e66b4468674bfcb0c81af8b7fa0bb154fa9f28eb840da5c447baeb8d7e" dependencies = [ "libc", "rand_chacha 0.3.0", @@ -961,14 +947,18 @@ dependencies = [ "iron", "iron-cors", "iron-test", - "iron_staticfile_middleware", "jemallocator", - "log 0.4.13", + "log 0.4.14", + "mime", + "mime_guess", "nix", "openssl", + "rustc-serialize", "signal", "structopt", "tempdir", + "time", + "url", ] [[package]] @@ -1003,9 +993,9 @@ dependencies = [ [[package]] name = "syn" -version = "1.0.59" +version = "1.0.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07cb8b1b4ebf86a89ee88cbd201b022b94138c623644d035185c84d3f41b7e66" +checksum = "c700597eca8a5a762beb35753ef6b94df201c81cca676604f547495a0d7f0081" dependencies = [ "proc-macro2", "quote", @@ -1030,7 +1020,7 @@ checksum = "dac1c663cfc93810f88aed9b8941d48cabf856a1b111c29a40439018d870eb22" dependencies = [ "cfg-if 1.0.0", "libc", - "rand 0.8.2", + "rand 0.8.3", "redox_syscall", "remove_dir_all", "winapi", @@ -1056,9 +1046,9 @@ dependencies = [ [[package]] name = "thread_local" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8208a331e1cb318dd5bd76951d2b8fc48ca38a69f5f4e4af1b6a9f8c6236915" +checksum = "8018d24e04c95ac8790716a5987d0fec4f8b27249ffa0f7d33f1369bdfb88cbd" dependencies = [ "once_cell", ] @@ -1075,9 +1065,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf8dbc19eb42fba10e8feaaec282fb50e2c14b2726d6301dbfeed0f73306a6f" +checksum = "317cca572a0e89c3ce0ca1f1bdc9369547fe318a683418e42ac8f59d14701023" dependencies = [ "tinyvec_macros", ] @@ -1215,9 +1205,9 @@ checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" [[package]] name = "wasi" -version = "0.10.1+wasi-snapshot-preview1" +version = "0.10.2+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93c6c3420963c5c64bca373b25e77acb562081b9bb4dd5bb864187742186cea9" +checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" [[package]] name = "winapi" diff --git a/Cargo.toml b/Cargo.toml index 04a8af3..285f323 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,18 +20,26 @@ categories = ["network-programming", "web-programming::http-server"] edition = "2018" include = ["src/**/*", "Cargo.toml", "Cargo.lock"] +[[bin]] +name = "static-web-server" +path = "src/bin/server.rs" + [dependencies] -iron = "0.6" -log = "0.4" chrono = "0.4" env_logger = "0.7" -structopt = "0.3" flate2 = "1.0" -iron_staticfile_middleware = { git = "https://github.com/joseluisq/iron-staticfile-middleware.git", tag = "v0.4.2" } hyper-native-tls = "0.3" +iron = "0.6" +iron-cors = "0.8" +log = "0.4" +mime = "0.2" +mime_guess = "1.8" nix = "0.14" +rustc-serialize = "0.3" signal = "0.7" -iron-cors = "0.8" +structopt = "0.3" +time = "0.1" +url = "1.4" [target.'cfg(all(target_env = "musl", target_pointer_width = "64"))'.dependencies.jemallocator] version = "0.3" @@ -43,6 +51,10 @@ iron-test = "0.6" tempdir = "0.3" [profile.release] +opt-level = 3 lto = "fat" codegen-units = 1 panic = "abort" +debug = false +rpath = false +debug-assertions = false diff --git a/src/bin/server.rs b/src/bin/server.rs new file mode 100644 index 0000000..c771664 --- /dev/null +++ b/src/bin/server.rs @@ -0,0 +1,227 @@ +#[cfg(all(target_env = "musl", target_pointer_width = "64"))] +#[global_allocator] +static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc; + +extern crate static_web_server; + +use static_web_server::{server, Options}; +use structopt::StructOpt; + +fn main() { + server::run(Options::from_args()) +} + +#[cfg(test)] +mod test { + extern crate hyper; + extern crate iron_test; + extern crate tempdir; + + use super::*; + use static_web_server::config::Options; + use static_web_server::staticfiles::*; + + use std::fs::{DirBuilder, File}; + use std::io::Write; + use std::path::{Path, PathBuf}; + + use self::hyper::header::Headers; + use self::iron_test::{request, response}; + use self::tempdir::TempDir; + use iron::headers::{ContentLength, ContentType}; + use iron::status; + + struct TestFilesystemSetup(TempDir); + + impl TestFilesystemSetup { + fn new() -> Self { + TestFilesystemSetup(TempDir::new("test").expect("Could not create test directory")) + } + + fn path(&self) -> &Path { + self.0.path() + } + + fn dir(&self, name: &str) -> PathBuf { + let p = self.path().join(name); + DirBuilder::new() + .recursive(true) + .create(&p) + .expect("Could not create directory"); + p + } + + fn file(&self, name: &str, body: Vec) -> PathBuf { + let p = self.path().join(name); + + let mut file = File::create(&p).expect("Could not create file"); + file.write_all(&body).expect("Could not write to file"); + + p + } + } + + #[test] + fn staticfile_allow_request_methods() { + let opts = Options::from_args(); + + 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: "".to_string(), + }); + + let response = request::head("http://127.0.0.1/", Headers::new(), &files.handle()) + .expect("Response was a http error"); + + assert_eq!(response.status, Some(status::Ok)); + + let response = request::get("http://127.0.0.1/", Headers::new(), &files.handle()) + .expect("Response was a http error"); + + assert_eq!(response.status, Some(status::Ok)); + } + + #[test] + fn staticfile_empty_body_on_head_request() { + let opts = Options::from_args(); + + 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: "".to_string(), + }); + + let res = request::head("http://127.0.0.1/", Headers::new(), &files.handle()) + .expect("Response was a http error"); + + assert_eq!(res.status, Some(status::Ok)); + + let result_body = response::extract_body_to_bytes(res); + assert_eq!(result_body, vec!()); + } + + #[test] + fn staticfile_valid_content_length_on_head_request() { + let root = TestFilesystemSetup::new(); + root.dir("root"); + root.file("index.html", b"

hello

".to_vec()); + + let assets = TestFilesystemSetup::new(); + assets.dir("assets"); + + let opts = Options::from_args(); + + let files = StaticFiles::new(StaticFilesOptions { + root_dir: root.path().to_str().unwrap().to_string(), + assets_dir: assets.path().to_str().unwrap().to_string(), + page_50x_path: opts.page50x, + page_404_path: opts.page404, + cors_allow_origins: "".to_string(), + }); + + let res = request::head("http://127.0.0.1/", Headers::new(), &files.handle()) + .expect("Response was a http error"); + + assert_eq!(res.status, Some(status::Ok)); + + let content_length = res.headers.get::().unwrap(); + + assert_eq!(content_length.0, 27); + } + + #[test] + fn staticfile_zero_content_length_on_404_head_request() { + let opts = Options::from_args(); + + 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: "".to_string(), + }); + + let res = request::head("http://127.0.0.1/unknown", Headers::new(), &files.handle()) + .expect("Response was a http error"); + + assert_eq!(res.status, Some(status::NotFound)); + + let content_length = res.headers.get::().unwrap(); + + assert_eq!(content_length.0, 0); + } + + #[test] + fn staticfile_disallow_request_methods() { + let opts = Options::from_args(); + + 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: "".to_string(), + }); + + let response = request::post("http://127.0.0.1/", Headers::new(), "", &files.handle()) + .expect("Response was a http error"); + + assert_eq!(response.status, Some(status::MethodNotAllowed)); + + let response = request::delete("http://127.0.0.1/", Headers::new(), &files.handle()) + .expect("Response was a http error"); + + assert_eq!(response.status, Some(status::MethodNotAllowed)); + + let response = request::put("http://127.0.0.1/", Headers::new(), "", &files.handle()) + .expect("Response was a http error"); + + assert_eq!(response.status, Some(status::MethodNotAllowed)); + + let response = request::patch("http://127.0.0.1/", Headers::new(), "", &files.handle()) + .expect("Response was a http error"); + + assert_eq!(response.status, Some(status::MethodNotAllowed)); + + let response = request::options("http://127.0.0.1/", Headers::new(), &files.handle()) + .expect("Response was a http error"); + + assert_eq!(response.status, Some(status::MethodNotAllowed)); + } + + #[test] + fn staticfile_valid_content_type_for_404() { + let root = TestFilesystemSetup::new(); + root.dir("root"); + + let assets = TestFilesystemSetup::new(); + assets.dir("assets"); + + let opts = Options::from_args(); + + let files = StaticFiles::new(StaticFilesOptions { + root_dir: root.path().to_str().unwrap().to_string(), + assets_dir: assets.path().to_str().unwrap().to_string(), + page_50x_path: opts.page50x, + page_404_path: opts.page404, + cors_allow_origins: "".to_string(), + }); + + let res = request::head("http://127.0.0.1/unknown", Headers::new(), &files.handle()) + .expect("Response was a http error"); + + assert_eq!(res.status, Some(status::NotFound)); + + let content_type = res.headers.get::().unwrap(); + + assert_eq!( + content_type.0, + "text/html".parse::().unwrap() + ); + } +} diff --git a/src/gzip.rs b/src/gzip.rs index 737c72a..ac06955 100644 --- a/src/gzip.rs +++ b/src/gzip.rs @@ -3,33 +3,38 @@ use flate2::Compression; use iron::headers::{AcceptEncoding, ContentEncoding, ContentType, Encoding}; use iron::prelude::*; use iron::AfterMiddleware; -use iron_staticfile_middleware::helpers; + +use crate::staticfile_middleware::helpers; pub struct GzipMiddleware; impl AfterMiddleware for GzipMiddleware { fn after(&self, req: &mut Request, mut resp: Response) -> IronResult { - // Skip Gzip response on HEAD requests + // Skip Gzip compression for HEAD requests if req.method == iron::method::Head { return Ok(resp); } - // Enable Gzip compression only for known text-based file types - let enable_gz = helpers::is_text_mime_type(resp.headers.get::()); - let accept_gz = helpers::accept_gzip(req.headers.get::()); + // Skip Gzip compression for non-text-based file types + if !helpers::is_text_mime_type(resp.headers.get::()) { + return Ok(resp); + } + + // Skip Gzip compression is there is not gzip accept-encoding value + if !helpers::accept_gzip(req.headers.get::()) { + return Ok(resp); + } - if enable_gz && accept_gz { - let compressed_bytes = resp.body.as_mut().map(|b| { - let mut encoder = GzEncoder::new(vec![], Compression::fast()); - { - let _ = b.write_body(&mut encoder); - } - encoder.finish().unwrap() - }); - if let Some(b) = compressed_bytes { - resp.headers.set(ContentEncoding(vec![Encoding::Gzip])); - resp.set_mut(b); + let compressed_bytes = resp.body.as_mut().map(|b| { + let mut encoder = GzEncoder::new(vec![], Compression::fast()); + { + let _ = b.write_body(&mut encoder); } + encoder.finish().unwrap() + }); + if let Some(b) = compressed_bytes { + resp.headers.set(ContentEncoding(vec![Encoding::Gzip])); + resp.set_mut(b); } Ok(resp) diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..88f30c4 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,21 @@ +#[macro_use] +extern crate log; + +extern crate iron; +extern crate mime; +extern crate mime_guess; +extern crate rustc_serialize; +extern crate time; +extern crate url; + +pub mod config; +pub mod error_page; +pub mod gzip; +pub mod helpers; +pub mod logger; +pub mod server; +pub mod signal_manager; +pub mod staticfile_middleware; +pub mod staticfiles; + +pub use config::Options; diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index ab9bbe7..0000000 --- a/src/main.rs +++ /dev/null @@ -1,320 +0,0 @@ -#[cfg(all(target_env = "musl", target_pointer_width = "64"))] -#[global_allocator] -static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc; - -#[macro_use] -extern crate log; - -use crate::config::Options; -use hyper_native_tls::NativeTlsServer; -use iron::{prelude::*, Listening}; -use iron_staticfile_middleware::HttpToHttpsRedirect; -use staticfiles::*; -use structopt::StructOpt; - -mod config; -mod error_page; -mod gzip; -mod helpers; -mod logger; -mod signal_manager; -mod staticfiles; - -/// Struct for holding a reference to a running iron server instance -#[derive(Debug)] -struct RunningServer { - listening: Listening, - server_type: String, -} - -fn on_server_running(server_name: &str, running_servers: &[RunningServer]) { - // Notify when server is running - running_servers.iter().for_each(|server| { - logger::log_server(&format!( - "{} Server ({}) is listening on {}", - server.server_type, server_name, server.listening.socket - )) - }); - - // Wait for incoming signals (E.g Ctrl+C (SIGINT), SIGTERM, etc - signal_manager::wait_for_signal(|sig: signal::Signal| { - let code = signal_manager::signal_to_int(sig); - - println!(); - warn!("Signal {} caught. Server execution exited.", code); - std::process::exit(code) - }) -} - -fn main() { - let opts = Options::from_args(); - - 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, - }); - - let mut running_servers = Vec::new(); - if opts.tls { - // Launch static HTTPS server - let ssl = NativeTlsServer::new(opts.tls_pkcs12, &opts.tls_pkcs12_passwd).unwrap(); - - match Iron::new(files.handle()).https(addr, ssl) { - Ok(listening) => running_servers.push(RunningServer { - listening, - server_type: "HTTPS".to_string(), - }), - Err(err) => panic!("{:?}", err), - } - - // Launch redirect HTTP server (if requested) - if let Some(port_redirect) = opts.tls_redirect_from { - let addr_redirect = &format!("{}{}{}", opts.host, ":", port_redirect); - let host_redirect = match opts.tls_redirect_host.as_ref() { - Some(host) => host, - None => &opts.host, - }; - let handler = - Chain::new(HttpToHttpsRedirect::new(&host_redirect, opts.port).permanent()); - match Iron::new(handler).http(addr_redirect) { - Ok(listening) => running_servers.push(RunningServer { - listening, - server_type: "Redirect HTTP".to_string(), - }), - Err(err) => panic!("{:?}", err), - } - } - } else { - // Launch static HTTP server - match Iron::new(files.handle()).http(addr) { - Ok(listening) => running_servers.push(RunningServer { - listening, - server_type: "HTTP".to_string(), - }), - Err(err) => panic!("{:?}", err), - } - } - on_server_running(&opts.name, &running_servers); -} - -#[cfg(test)] -mod test { - extern crate hyper; - extern crate iron_test; - extern crate tempdir; - - use super::*; - - use std::fs::{DirBuilder, File}; - use std::io::Write; - use std::path::{Path, PathBuf}; - - use self::hyper::header::Headers; - use self::iron_test::{request, response}; - use self::tempdir::TempDir; - use iron::headers::{ContentLength, ContentType}; - use iron::status; - - struct TestFilesystemSetup(TempDir); - - impl TestFilesystemSetup { - fn new() -> Self { - TestFilesystemSetup(TempDir::new("test").expect("Could not create test directory")) - } - - fn path(&self) -> &Path { - self.0.path() - } - - fn dir(&self, name: &str) -> PathBuf { - let p = self.path().join(name); - DirBuilder::new() - .recursive(true) - .create(&p) - .expect("Could not create directory"); - p - } - - fn file(&self, name: &str, body: Vec) -> PathBuf { - let p = self.path().join(name); - - let mut file = File::create(&p).expect("Could not create file"); - file.write_all(&body).expect("Could not write to file"); - - p - } - } - - #[test] - fn staticfile_allow_request_methods() { - let opts = Options::from_args(); - - 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: "".to_string(), - }); - - let response = request::head("http://127.0.0.1/", Headers::new(), &files.handle()) - .expect("Response was a http error"); - - assert_eq!(response.status, Some(status::Ok)); - - let response = request::get("http://127.0.0.1/", Headers::new(), &files.handle()) - .expect("Response was a http error"); - - assert_eq!(response.status, Some(status::Ok)); - } - - #[test] - fn staticfile_empty_body_on_head_request() { - let opts = Options::from_args(); - - 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: "".to_string(), - }); - - let res = request::head("http://127.0.0.1/", Headers::new(), &files.handle()) - .expect("Response was a http error"); - - assert_eq!(res.status, Some(status::Ok)); - - let result_body = response::extract_body_to_bytes(res); - assert_eq!(result_body, vec!()); - } - - #[test] - fn staticfile_valid_content_length_on_head_request() { - let root = TestFilesystemSetup::new(); - root.dir("root"); - root.file("index.html", b"

hello

".to_vec()); - - let assets = TestFilesystemSetup::new(); - assets.dir("assets"); - - let opts = Options::from_args(); - - let files = StaticFiles::new(StaticFilesOptions { - root_dir: root.path().to_str().unwrap().to_string(), - assets_dir: assets.path().to_str().unwrap().to_string(), - page_50x_path: opts.page50x, - page_404_path: opts.page404, - cors_allow_origins: "".to_string(), - }); - - let res = request::head("http://127.0.0.1/", Headers::new(), &files.handle()) - .expect("Response was a http error"); - - assert_eq!(res.status, Some(status::Ok)); - - let content_length = res.headers.get::().unwrap(); - - assert_eq!(content_length.0, 27); - } - - #[test] - fn staticfile_zero_content_length_on_404_head_request() { - let opts = Options::from_args(); - - 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: "".to_string(), - }); - - let res = request::head("http://127.0.0.1/unknown", Headers::new(), &files.handle()) - .expect("Response was a http error"); - - assert_eq!(res.status, Some(status::NotFound)); - - let content_length = res.headers.get::().unwrap(); - - assert_eq!(content_length.0, 0); - } - - #[test] - fn staticfile_disallow_request_methods() { - let opts = Options::from_args(); - - 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: "".to_string(), - }); - - let response = request::post("http://127.0.0.1/", Headers::new(), "", &files.handle()) - .expect("Response was a http error"); - - assert_eq!(response.status, Some(status::MethodNotAllowed)); - - let response = request::delete("http://127.0.0.1/", Headers::new(), &files.handle()) - .expect("Response was a http error"); - - assert_eq!(response.status, Some(status::MethodNotAllowed)); - - let response = request::put("http://127.0.0.1/", Headers::new(), "", &files.handle()) - .expect("Response was a http error"); - - assert_eq!(response.status, Some(status::MethodNotAllowed)); - - let response = request::patch("http://127.0.0.1/", Headers::new(), "", &files.handle()) - .expect("Response was a http error"); - - assert_eq!(response.status, Some(status::MethodNotAllowed)); - - let response = request::options("http://127.0.0.1/", Headers::new(), &files.handle()) - .expect("Response was a http error"); - - assert_eq!(response.status, Some(status::MethodNotAllowed)); - } - - #[test] - fn staticfile_valid_content_type_for_404() { - let root = TestFilesystemSetup::new(); - root.dir("root"); - - let assets = TestFilesystemSetup::new(); - assets.dir("assets"); - - let opts = Options::from_args(); - - let files = StaticFiles::new(StaticFilesOptions { - root_dir: root.path().to_str().unwrap().to_string(), - assets_dir: assets.path().to_str().unwrap().to_string(), - page_50x_path: opts.page50x, - page_404_path: opts.page404, - cors_allow_origins: "".to_string(), - }); - - let res = request::head("http://127.0.0.1/unknown", Headers::new(), &files.handle()) - .expect("Response was a http error"); - - assert_eq!(res.status, Some(status::NotFound)); - - let content_type = res.headers.get::().unwrap(); - - assert_eq!( - content_type.0, - "text/html".parse::().unwrap() - ); - } -} diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..35202ef --- /dev/null +++ b/src/server.rs @@ -0,0 +1,91 @@ +use hyper_native_tls::NativeTlsServer; +use iron::{Chain, Iron, Listening}; + +use crate::signal_manager; +use crate::staticfile_middleware::HttpToHttpsRedirect; +use crate::staticfiles::*; +use crate::{config::Options, logger}; + +/// Struct for holding a reference to a running iron server instance +#[derive(Debug)] +struct RunningServer { + listening: Listening, + server_type: String, +} + +fn on_server_running(server_name: &str, running_servers: &[RunningServer]) { + // Notify when server is running + running_servers.iter().for_each(|server| { + logger::log_server(&format!( + "{} Server ({}) is listening on {}", + server.server_type, server_name, server.listening.socket + )) + }); + + // Wait for incoming signals (E.g Ctrl+C (SIGINT), SIGTERM, etc + signal_manager::wait_for_signal(|sig: signal::Signal| { + let code = signal_manager::signal_to_int(sig); + + println!(); + warn!("Signal {} caught. Server execution exited.", code); + std::process::exit(code) + }) +} + +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, + }); + + let mut running_servers = Vec::new(); + if opts.tls { + // Launch static HTTPS server + let ssl = NativeTlsServer::new(opts.tls_pkcs12, &opts.tls_pkcs12_passwd).unwrap(); + + match Iron::new(files.handle()).https(addr, ssl) { + Ok(listening) => running_servers.push(RunningServer { + listening, + server_type: "HTTPS".to_string(), + }), + Err(err) => panic!("{:?}", err), + } + + // Launch redirect HTTP server (if requested) + if let Some(port_redirect) = opts.tls_redirect_from { + let addr_redirect = &format!("{}{}{}", opts.host, ":", port_redirect); + let host_redirect = match opts.tls_redirect_host.as_ref() { + Some(host) => host, + None => &opts.host, + }; + let handler = + Chain::new(HttpToHttpsRedirect::new(&host_redirect, opts.port).permanent()); + match Iron::new(handler).http(addr_redirect) { + Ok(listening) => running_servers.push(RunningServer { + listening, + server_type: "Redirect HTTP".to_string(), + }), + Err(err) => panic!("{:?}", err), + } + } + } else { + // Launch static HTTP server + match Iron::new(files.handle()).http(addr) { + Ok(listening) => running_servers.push(RunningServer { + listening, + server_type: "HTTP".to_string(), + }), + Err(err) => panic!("{:?}", err), + } + } + on_server_running(&opts.name, &running_servers); +} diff --git a/src/staticfile_middleware/cache.rs b/src/staticfile_middleware/cache.rs new file mode 100644 index 0000000..78375bc --- /dev/null +++ b/src/staticfile_middleware/cache.rs @@ -0,0 +1,36 @@ +use std::time::Duration; +use std::{cmp, u32}; + +use iron::headers::{CacheControl, CacheDirective}; +use iron::modifier::Modifier; +use iron::modifiers::Header; +use iron::prelude::*; +use iron::status; + +/// Sets the Cache-Control header for successful responses. +#[derive(Debug, Copy, Clone)] +pub struct Cache(u32); + +impl Cache { + pub fn new(duration: Duration) -> Cache { + // Capping the value at ~136 years! + let duration = cmp::min(duration.as_secs(), u32::MAX as u64) as u32; + + Cache(duration) + } +} + +impl Modifier for Cache { + fn modify(self, response: &mut Response) { + match response.status { + Some(status::Ok) | Some(status::NotModified) => (), + _ => return, + } + + Header(CacheControl(vec![ + CacheDirective::Public, + CacheDirective::MaxAge(self.0), + ])) + .modify(response) + } +} diff --git a/src/staticfile_middleware/guess_content_type.rs b/src/staticfile_middleware/guess_content_type.rs new file mode 100644 index 0000000..2a4ad50 --- /dev/null +++ b/src/staticfile_middleware/guess_content_type.rs @@ -0,0 +1,47 @@ +use iron::headers::ContentType; +use iron::prelude::*; +use iron::AfterMiddleware; + +use mime::Mime; + +/// Attempts to guess the content type of the response based on the +/// requested URL. Existing content types will not be modified. +pub struct GuessContentType { + default: Mime, +} + +impl GuessContentType { + pub fn new(default: Mime) -> GuessContentType { + GuessContentType { default } + } +} + +impl Default for GuessContentType { + fn default() -> GuessContentType { + let default = "application/octet-stream" + .parse() + .expect("Unable to create default MIME type"); + GuessContentType::new(default) + } +} + +impl AfterMiddleware for GuessContentType { + fn after(&self, req: &mut Request, mut res: Response) -> IronResult { + match res.headers.get::() { + Some(_) => {} + None => { + let new_content_type = req + .url + .path() + .last() + .and_then(mime_guess::guess_mime_type_opt) + .unwrap_or_else(|| self.default.clone()); + + let header = ContentType(new_content_type); + res.headers.set(header); + } + } + + Ok(res) + } +} diff --git a/src/staticfile_middleware/helpers.rs b/src/staticfile_middleware/helpers.rs new file mode 100644 index 0000000..8a56e42 --- /dev/null +++ b/src/staticfile_middleware/helpers.rs @@ -0,0 +1,41 @@ +use iron::headers::{AcceptEncoding, ContentType, Encoding}; +use mime::Mime; +use std::option::Option; + +// Contains a common fixed list of text-based MIME types for Gzip compression +pub const TEXT_MIME_TYPES: [&str; 16] = [ + "text/html", + "text/css", + "text/javascript", + "text/xml", + "text/plain", + "text/x-component", + "application/javascript", + "application/x-javascript", + "application/json", + "application/xml", + "application/rss+xml", + "application/atom+xml", + "font/truetype", + "font/opentype", + "application/vnd.ms-fontobject", + "image/svg+xml", +]; + +// Checks if a `content-type` header is a common text-based MIME type for Gzip compression +pub fn is_text_mime_type(content_type: Option<&ContentType>) -> bool { + match content_type { + Some(content_type) => TEXT_MIME_TYPES + .iter() + .any(|h| h.parse::().unwrap() == content_type.0), + None => false, + } +} + +// Checks if an `accept-encoding` header accepts Gzip encoding. +pub fn accept_gzip(accept_encoding: Option<&AcceptEncoding>) -> bool { + match accept_encoding { + Some(accept) => accept.0.iter().any(|qi| qi.item == Encoding::Gzip), + None => false, + } +} diff --git a/src/staticfile_middleware/http_to_https_redirect.rs b/src/staticfile_middleware/http_to_https_redirect.rs new file mode 100644 index 0000000..61e8022 --- /dev/null +++ b/src/staticfile_middleware/http_to_https_redirect.rs @@ -0,0 +1,56 @@ +use iron::prelude::*; +use iron::{self, status}; + +#[derive(Debug)] +pub struct HttpToHttpsRedirect { + permanent: bool, + host: String, + port: u16, +} + +impl HttpToHttpsRedirect { + pub fn new(host: &str, port: u16) -> Self { + HttpToHttpsRedirect { + permanent: false, + host: host.into(), + port, + } + } + + pub fn temporary(self) -> Self { + HttpToHttpsRedirect { + permanent: false, + ..self + } + } + + pub fn permanent(self) -> Self { + HttpToHttpsRedirect { + permanent: true, + ..self + } + } +} + +impl iron::Handler for HttpToHttpsRedirect { + fn handle(&self, req: &mut Request) -> IronResult { + let mut url: url::Url = req.url.clone().into(); + + url.set_scheme("https") + .expect("Unable to rewrite URL scheme"); + url.set_host(Some(&self.host)) + .expect("Unable to rewrite URL host"); + url.set_port(Some(self.port)) + .expect("Unable to rewrite URL port"); + + let url = iron::Url::from_generic_url(url).expect("Unable to rewrite HTTP URL to HTTPS"); + + let status = if self.permanent { + status::PermanentRedirect + } else { + status::TemporaryRedirect + }; + + Ok(Response::with((status, iron::modifiers::Redirect(url)))) + } +} diff --git a/src/staticfile_middleware/mod.rs b/src/staticfile_middleware/mod.rs new file mode 100644 index 0000000..7c3ce74 --- /dev/null +++ b/src/staticfile_middleware/mod.rs @@ -0,0 +1,17 @@ +mod cache; +mod guess_content_type; +pub mod helpers; +mod http_to_https_redirect; +mod modify_with; +mod partial_file; +mod prefix; +mod rewrite; +mod staticfile; + +pub use self::cache::Cache; +pub use self::guess_content_type::GuessContentType; +pub use self::http_to_https_redirect::HttpToHttpsRedirect; +pub use self::modify_with::ModifyWith; +pub use self::prefix::Prefix; +pub use self::rewrite::Rewrite; +pub use self::staticfile::Staticfile; diff --git a/src/staticfile_middleware/modify_with.rs b/src/staticfile_middleware/modify_with.rs new file mode 100644 index 0000000..04ba9b1 --- /dev/null +++ b/src/staticfile_middleware/modify_with.rs @@ -0,0 +1,24 @@ +use iron::modifier::Modifier; +use iron::prelude::*; +use iron::AfterMiddleware; + +/// Applies a modifier to every request. +pub struct ModifyWith { + modifier: M, +} + +impl ModifyWith { + pub fn new(modifier: M) -> ModifyWith { + ModifyWith { modifier } + } +} + +impl AfterMiddleware for ModifyWith +where + M: Clone + Modifier + Send + Sync + 'static, +{ + fn after(&self, _req: &mut Request, mut res: Response) -> IronResult { + self.modifier.clone().modify(&mut res); + Ok(res) + } +} diff --git a/src/staticfile_middleware/partial_file.rs b/src/staticfile_middleware/partial_file.rs new file mode 100644 index 0000000..e939017 --- /dev/null +++ b/src/staticfile_middleware/partial_file.rs @@ -0,0 +1,138 @@ +// NOTE: +// This file implements Partial Content Delivery which is used as part as this middleware. +// Code below was borrowed from one @Cobrand's PR and adapted to this project. +// More details at https://github.com/iron/staticfile/pull/98 + +use iron::headers::{ByteRangeSpec, ContentLength, ContentRange, ContentRangeSpec}; +use iron::modifier::Modifier; +use iron::response::{Response, WriteBody}; +use iron::status::Status; +use std::cmp; +use std::fs::File; +use std::io::{self, Read, Seek, SeekFrom, Write}; +use std::path::Path; + +pub enum PartialFileRange { + AllFrom(u64), + FromTo(u64, u64), + Last(u64), +} + +pub struct PartialFile { + file: File, + range: PartialFileRange, +} + +struct PartialContentBody { + pub file: File, + pub offset: u64, + pub len: u64, +} + +impl PartialFile { + pub fn new(file: File, range: Range) -> PartialFile + where + Range: Into, + { + let range = range.into(); + PartialFile { file, range } + } + + pub fn from_path, Range>(path: P, range: Range) -> Result + where + Range: Into, + { + let file = File::open(path.as_ref())?; + Ok(Self::new(file, range)) + } +} + +impl From for PartialFileRange { + fn from(b: ByteRangeSpec) -> PartialFileRange { + match b { + ByteRangeSpec::AllFrom(from) => PartialFileRange::AllFrom(from), + ByteRangeSpec::FromTo(from, to) => PartialFileRange::FromTo(from, to), + ByteRangeSpec::Last(last) => PartialFileRange::Last(last), + } + } +} + +impl From> for PartialFileRange { + fn from(v: Vec) -> PartialFileRange { + match v.into_iter().next() { + // in the case no value is in "Range", return + // the whole file instead of panicking + // Note that an empty vec should never happen, + // but we can never be too sure + None => PartialFileRange::AllFrom(0), + Some(byte_range) => PartialFileRange::from(byte_range), + } + } +} + +impl Modifier for PartialFile { + #[inline] + fn modify(self, res: &mut Response) { + use self::PartialFileRange::*; + + let metadata: Option<_> = self.file.metadata().ok(); + let file_length: Option = metadata.map(|m| m.len()); + let range: Option<(u64, u64)> = match (self.range, file_length) { + (FromTo(from, to), Some(file_length)) => { + if from <= to && from < file_length { + Some((from, cmp::min(to, file_length - 1))) + } else { + None + } + } + (AllFrom(from), Some(file_length)) => { + if from < file_length { + Some((from, file_length - 1)) + } else { + None + } + } + (Last(last), Some(file_length)) => { + if last < file_length { + Some((file_length - last, file_length - 1)) + } else { + Some((0, file_length - 1)) + } + } + (_, None) => None, + }; + + if let Some(range) = range { + let content_range = ContentRange(ContentRangeSpec::Bytes { + range: Some(range), + instance_length: file_length, + }); + let content_len = range.1 - range.0 + 1; + res.headers.set(ContentLength(content_len)); + res.headers.set(content_range); + let partial_content = PartialContentBody { + file: self.file, + offset: range.0, + len: content_len, + }; + res.status = Some(Status::PartialContent); + res.body = Some(Box::new(partial_content)); + } else { + if let Some(file_length) = file_length { + res.headers.set(ContentRange(ContentRangeSpec::Bytes { + range: None, + instance_length: Some(file_length), + })); + }; + res.status = Some(Status::RangeNotSatisfiable); + } + } +} + +impl WriteBody for PartialContentBody { + fn write_body(&mut self, res: &mut dyn Write) -> io::Result<()> { + self.file.seek(SeekFrom::Start(self.offset))?; + let mut limiter = ::by_ref(&mut self.file).take(self.len); + io::copy(&mut limiter, res).map(|_| ()) + } +} diff --git a/src/staticfile_middleware/prefix.rs b/src/staticfile_middleware/prefix.rs new file mode 100644 index 0000000..ac7d9ae --- /dev/null +++ b/src/staticfile_middleware/prefix.rs @@ -0,0 +1,45 @@ +use iron::modifier::Modifier; +use iron::prelude::*; +use iron::AfterMiddleware; + +/// Applies a modifier to every request that starts with a given path. +pub struct Prefix { + prefix: Vec, + modifier: M, +} + +impl Prefix { + pub fn new(prefix: P, modifier: M) -> Prefix + where + P: IntoIterator, + S: AsRef, + { + Prefix { + prefix: prefix.into_iter().map(|s| s.as_ref().into()).collect(), + modifier, + } + } + + fn prefix_matches(&self, path: &[&str]) -> bool { + if self.prefix.len() > path.len() { + return false; + } + + path.iter() + .zip(self.prefix.iter()) + .all(|(path, prefix)| path == prefix) + } +} + +impl AfterMiddleware for Prefix +where + M: Clone + Modifier + Send + Sync + 'static, +{ + fn after(&self, req: &mut Request, mut res: Response) -> IronResult { + if self.prefix_matches(&req.url.path()) { + self.modifier.clone().modify(&mut res); + } + + Ok(res) + } +} diff --git a/src/staticfile_middleware/rewrite.rs b/src/staticfile_middleware/rewrite.rs new file mode 100644 index 0000000..681173f --- /dev/null +++ b/src/staticfile_middleware/rewrite.rs @@ -0,0 +1,34 @@ +use iron::prelude::*; + +pub struct Rewrite { + from: Vec>, + to: String, +} + +impl Rewrite { + pub fn new(from_paths: Vec>, to_path: String) -> Self { + Rewrite { + from: from_paths, + to: to_path, + } + } +} + +impl iron::BeforeMiddleware for Rewrite { + fn before(&self, req: &mut Request) -> IronResult<()> { + let should_rewrite = { + let request_path = req.url.path(); + self.from + .iter() + .any(|rewrite_path| request_path == *rewrite_path) + }; + + if should_rewrite { + let mut u: url::Url = req.url.clone().into(); + u.set_path(&self.to); + req.url = iron::Url::from_generic_url(u).expect("Invalid rewritten URL"); + } + + Ok(()) + } +} diff --git a/src/staticfile_middleware/staticfile.rs b/src/staticfile_middleware/staticfile.rs new file mode 100644 index 0000000..b8e03fe --- /dev/null +++ b/src/staticfile_middleware/staticfile.rs @@ -0,0 +1,346 @@ +use std::ffi::OsString; +use std::fs::{File, Metadata}; +use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; +use std::{error, io}; + +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 crate::staticfile_middleware::helpers; +use crate::staticfile_middleware::partial_file::PartialFile; + +/// Recursively serves files from the specified root and assets directories. +pub struct Staticfile { + root: PathBuf, + assets: PathBuf, +} + +impl Staticfile { + pub fn new

(root: P, assets: P) -> io::Result + where + P: AsRef, + { + let root = root.as_ref().canonicalize()?; + let assets = assets.as_ref().canonicalize()?; + + Ok(Staticfile { root, assets }) + } + + fn resolve_path(&self, path: &[&str]) -> Result> { + let path_dirname = path[0]; + let asserts_dirname = self.assets.iter().last().unwrap().to_str().unwrap(); + let mut is_assets = false; + + let resolved = if path_dirname == asserts_dirname { + // Assets path validation resolve + is_assets = true; + + let mut res = self.assets.clone(); + for component in path.iter().skip(1) { + res.push(component); + } + + res + } else { + // Root path validation resolve + let mut res = self.root.clone(); + for component in path { + res.push(component); + } + + res + }; + + let resolved = resolved.canonicalize()?; + let path = if is_assets { &self.assets } else { &self.root }; + + // Protect against path/directory traversal + if !resolved.starts_with(&path) { + return Result::Err(From::from(format!("Cannot leave {:?} path", &path))); + } + + Ok(resolved) + } +} + +impl Handler for Staticfile { + fn handle(&self, req: &mut Request) -> IronResult { + // Accept only HEAD and GET methods + if !(req.method == Method::Head || req.method == Method::Get) { + return Ok(Response::with(status::MethodNotAllowed)); + } + + // Resolve path on file system + let file_path = match self.resolve_path(&req.url.path()) { + Ok(file_path) => file_path, + Err(_) => return Ok(Response::with(status::NotFound)), + }; + + // 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, + Err(_) => return Ok(Response::with(status::NotFound)), + }; + + // Apply last modified date time + let client_last_modified = req.headers.get::(); + let last_modified = file.last_modified().ok().map(HttpDate); + + if let (Some(client_last_modified), Some(last_modified)) = + (client_last_modified, last_modified) + { + trace!( + "Comparing {} (file) <= {} (req)", + last_modified, + client_last_modified.0 + ); + + if last_modified <= client_last_modified.0 { + return Ok(Response::with(status::NotModified)); + } + } + + // Add Encoding Gzip header + let encoding = if file.is_gz { + Encoding::Gzip + } else { + Encoding::Identity + }; + let encoding = ContentEncoding(vec![encoding]); + + let mut resp = match last_modified { + Some(last_modified) => { + let last_modified = LastModified(last_modified); + Response::with(( + status::Ok, + Header(last_modified), + Header(encoding), + file.file, + )) + } + None => Response::with((status::Ok, Header(encoding), file.file)), + }; + + // 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(file.metadata.len()))); + return Ok(resp); + } + + // 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() { + // Deliver the whole file + None => resp, + // Try to deliver partial content + Some(Range::Bytes(v)) => { + if let Ok(partial_file) = PartialFile::from_path(&file_path, v) { + Response::with(( + status::Ok, + partial_file, + Header(AcceptRanges(vec![RangeUnit::Bytes])), + )) + } else { + Response::with(status::NotFound) + } + } + Some(_) => Response::with(status::RangeNotSatisfiable), + }; + + Ok(resp) + } +} + +struct StaticFileWithMetadata { + file: File, + metadata: Metadata, + is_gz: bool, +} + +impl StaticFileWithMetadata { + pub fn search

( + path: P, + allow_gz: bool, + ) -> Result> + // TODO: unbox + where + P: Into, + { + let mut file_path = path.into(); + trace!("Opening {}", file_path.display()); + let mut file = StaticFileWithMetadata::open(&file_path)?; + + // Look for index.html inside of a directory + if file.metadata.is_dir() { + file_path.push("index.html"); + trace!("Redirecting to index {}", file_path.display()); + file = StaticFileWithMetadata::open(&file_path)?; + } + + if file.metadata.is_file() { + if allow_gz { + // Find the foo.gz sibling of foo + let mut side_by_side_path: OsString = file_path.into(); + side_by_side_path.push(".gz"); + file_path = side_by_side_path.into(); + trace!("Attempting to find side-by-side GZ {}", file_path.display()); + + match StaticFileWithMetadata::open(&file_path) { + Ok(mut gz_file) => { + if gz_file.metadata.is_file() { + gz_file.is_gz = true; + Ok(gz_file) + } else { + Ok(file) + } + } + Err(_) => Ok(file), + } + } else { + Ok(file) + } + } else { + Err(From::from("Requested path was not a regular file")) + } + } + + fn open

(path: P) -> Result> + where + P: AsRef, + { + let file = File::open(path)?; + let metadata = file.metadata()?; + + Ok(StaticFileWithMetadata { + file, + metadata, + is_gz: false, + }) + } + + 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)) + } +} + +#[cfg(test)] +mod test { + extern crate hyper; + extern crate iron_test; + extern crate tempdir; + + use super::*; + + use std::fs::{DirBuilder, File}; + use std::path::{Path, PathBuf}; + + use self::hyper::header::Headers; + use self::iron_test::request; + use self::tempdir::TempDir; + use iron::status; + + struct TestFilesystemSetup(TempDir); + + impl TestFilesystemSetup { + fn new() -> Self { + TestFilesystemSetup(TempDir::new("test").expect("Could not create test directory")) + } + + fn path(&self) -> &Path { + self.0.path() + } + + fn dir(&self, name: &str) -> PathBuf { + let p = self.path().join(name); + DirBuilder::new() + .recursive(true) + .create(&p) + .expect("Could not create directory"); + p + } + + fn file(&self, name: &str) -> PathBuf { + let p = self.path().join(name); + File::create(&p).expect("Could not create file"); + p + } + } + + #[test] + fn staticfile_resolves_paths() { + let fs = TestFilesystemSetup::new(); + fs.file("index.html"); + let fs2 = TestFilesystemSetup::new(); + fs2.dir("assets"); + + let sf = Staticfile::new(fs.path(), fs2.path()).unwrap(); + let path = sf.resolve_path(&["index.html"]); + assert!(path.unwrap().ends_with("index.html")); + } + + #[test] + fn staticfile_resolves_nested_paths() { + let fs = TestFilesystemSetup::new(); + fs.dir("dir"); + fs.file("dir/index.html"); + let fs2 = TestFilesystemSetup::new(); + fs2.file("assets"); + + let sf = Staticfile::new(fs.path(), fs2.path()).unwrap(); + let path = sf.resolve_path(&["dir", "index.html"]); + assert!(path.unwrap().ends_with("dir/index.html")); + } + + #[test] + fn staticfile_disallows_resolving_out_of_root() { + let fs = TestFilesystemSetup::new(); + fs.file("naughty.txt"); + let dir = fs.dir("dir"); + let fs2 = TestFilesystemSetup::new(); + let dir2 = fs2.file("assets"); + + let sf = Staticfile::new(dir, dir2).unwrap(); + let path = sf.resolve_path(&["..", "naughty.txt"]); + assert!(path.is_err()); + } + + #[test] + fn staticfile_disallows_post_requests() { + let fs = TestFilesystemSetup::new(); + let fs2 = TestFilesystemSetup::new(); + let sf = Staticfile::new(fs.path(), fs2.path()).unwrap(); + + let response = request::post("http://127.0.0.1/", Headers::new(), "", &sf); + + let response = response.expect("Response was an error"); + assert_eq!(response.status, Some(status::MethodNotAllowed)); + } +} diff --git a/src/staticfiles.rs b/src/staticfiles.rs index 7649ac8..07de16f 100644 --- a/src/staticfiles.rs +++ b/src/staticfiles.rs @@ -1,15 +1,15 @@ -use crate::error_page::ErrorPage; -use crate::gzip::GzipMiddleware; -use crate::helpers; -use crate::logger::{log_server, Logger}; - use iron::mime; use iron::prelude::*; use iron_cors::CorsMiddleware; -use iron_staticfile_middleware::{Cache, GuessContentType, ModifyWith, Prefix, Staticfile}; use std::collections::HashSet; use std::time::Duration; +use crate::error_page::ErrorPage; +use crate::gzip::GzipMiddleware; +use crate::helpers; +use crate::logger::{log_server, Logger}; +use crate::staticfile_middleware::{Cache, GuessContentType, ModifyWith, Prefix, Staticfile}; + /// An Iron middleware for static files-serving. pub struct StaticFiles { opts: StaticFilesOptions, -- libgit2 1.7.2