diff --git a/src/filters/fs.rs b/src/filters/fs.rs index fdfa70968..0cffa9b38 100644 --- a/src/filters/fs.rs +++ b/src/filters/fs.rs @@ -9,15 +9,17 @@ use std::path::{Path, PathBuf}; use std::pin::Pin; use std::sync::Arc; use std::task::Poll; +use std::time::SystemTime; use bytes::{Bytes, BytesMut}; use futures_util::future::Either; use futures_util::{future, ready, stream, FutureExt, Stream, StreamExt, TryFutureExt}; use headers::{ - AcceptRanges, ContentLength, ContentRange, ContentType, HeaderMapExt, IfModifiedSince, IfRange, - IfUnmodifiedSince, LastModified, Range, + AcceptRanges, ContentLength, ContentRange, ContentType, ETag, HeaderMapExt, IfMatch, + IfModifiedSince, IfNoneMatch, IfRange, IfUnmodifiedSince, LastModified, Range, }; -use http::StatusCode; +use http::header::IntoHeaderName; +use http::{HeaderMap, HeaderValue, StatusCode}; use hyper::Body; use mime_guess; use percent_encoding::percent_decode_str; @@ -29,6 +31,153 @@ use crate::filter::{Filter, FilterClone, One}; use crate::reject::{self, Rejection}; use crate::reply::{Reply, Response}; +type ConfigFn = fn(&Config) -> Option; + +/// Configuration for a file or dir filter +#[derive(Debug, Clone)] +pub struct Config { + /// Set a specific read buffer size (default auto detect) + pub read_buffer_size: Option, + /// Set a specific content-type (default auto detect) + pub content_type: Option, + /// include the LastModified header in the response + pub last_modified: bool, + /// Include the Etag header in the response + pub etag: bool, + /// extra headers to add + pub headers: HeaderMap, + /// Callback to adjust the Config per request (useful for dir()) + pub callback: Option, +} + +impl Default for Config { + fn default() -> Self { + Self { + read_buffer_size: None, + content_type: None, + last_modified: true, + etag: false, + headers: Default::default(), + callback: None, + } + } +} + +impl Config { + /// Override the content_type + pub fn content_type(mut self, content_type: Option>) -> Self { + self.content_type = content_type.map(Into::into); + self + } + + /// Override the last_modified exposure + pub fn last_modified(mut self, last_modified: bool) -> Self { + self.last_modified = last_modified; + self + } + + /// Override the last_modified exposure + pub fn etag(mut self, etag: bool) -> Self { + self.etag = etag; + self + } + + /// Add additional headers + pub fn add_header(mut self, key: impl IntoHeaderName, value: HeaderValue) -> Self { + self.headers.insert(key, value); + self + } + + /// Set a callback to modification the config per request + pub fn callback(mut self, callback: Option) -> Self { + self.callback = callback; + self + } + + /// Creates a `Filter` that serves a File at the `path`. + /// + /// Does not filter out based on any information of the request. Always serves + /// the file at the exact `path` provided. Thus, this can be used to serve a + /// single file with `GET`s, but could also be used in combination with other + /// filters, such as after validating in `POST` request, wanting to return a + /// specific file as the body. + /// + /// For serving a directory, see [dir]. + /// + /// # Example + /// + /// ``` + /// // Always serves this file from the file system. + /// let route = warp::fs::config().file("/www/static/app.js"); + /// ``` + pub fn file( + self, + path: impl Into, + ) -> impl FilterClone, Error = Rejection> { + let path = Arc::new(path.into()); + let config = Arc::new(self); + let config = crate::any().map(move || config.clone()); + + crate::any() + .map(move || { + tracing::trace!("file: {:?}", path); + ArcPath(path.clone()) + }) + .and(conditionals()) + .and(config) + .and_then(file_reply) + } + + /// Creates a `Filter` that serves a directory at the base `path` joined + /// by the request path. + /// + /// This can be used to serve "static files" from a directory. By far the most + /// common pattern of serving static files is for `GET` requests, so this + /// filter automatically includes a `GET` check. + /// + /// # Example + /// + /// ``` + /// use warp::Filter; + /// + /// // Matches requests that start with `/static`, + /// // and then uses the rest of that path to lookup + /// // and serve a file from `/www/static`. + /// let route = warp::path("static") + /// .and(warp::fs::config().dir("/www/static")); + /// + /// // For example: + /// // - `GET /static/app.js` would serve the file `/www/static/app.js` + /// // - `GET /static/css/app.css` would serve the file `/www/static/css/app.css` + /// ``` + pub fn dir( + self, + path: impl Into, + ) -> impl FilterClone, Error = Rejection> { + let base = Arc::new(path.into()); + let config = Arc::new(self); + let config = crate::any().map(move || config.clone()); + + crate::get() + .or(crate::head()) + .unify() + .and(path_from_tail(base)) + .and(conditionals()) + .and(config) + .and_then(file_reply) + } +} + +/// Creates a new configuration for creating a `Filter` that serves a file or directory of static assets. +/// +/// Allows to override configuration before building the final file or dir filter. +/// +/// For serving a single file, see [Config::file] +/// For serving a directory, see [Config::dir] +pub fn config() -> Config { + Config::default() +} + /// Creates a `Filter` that serves a File at the `path`. /// /// Does not filter out based on any information of the request. Always serves @@ -39,21 +188,17 @@ use crate::reply::{Reply, Response}; /// /// For serving a directory, see [dir]. /// +/// See also [config] +/// /// # Example /// /// ``` /// // Always serves this file from the file system. /// let route = warp::fs::file("/www/static/app.js"); /// ``` +#[deprecated(since = "0.3.7", note = "Use config().file(path) instead")] pub fn file(path: impl Into) -> impl FilterClone, Error = Rejection> { - let path = Arc::new(path.into()); - crate::any() - .map(move || { - tracing::trace!("file: {:?}", path); - ArcPath(path.clone()) - }) - .and(conditionals()) - .and_then(file_reply) + config().file(path) } /// Creates a `Filter` that serves a directory at the base `path` joined @@ -63,6 +208,8 @@ pub fn file(path: impl Into) -> impl FilterClone, E /// common pattern of serving static files is for `GET` requests, so this /// filter automatically includes a `GET` check. /// +/// See also [config] +/// /// # Example /// /// ``` @@ -78,14 +225,9 @@ pub fn file(path: impl Into) -> impl FilterClone, E /// // - `GET /static/app.js` would serve the file `/www/static/app.js` /// // - `GET /static/css/app.css` would serve the file `/www/static/css/app.css` /// ``` +#[deprecated(since = "0.3.7", note = "Use config().dir(path) instead")] pub fn dir(path: impl Into) -> impl FilterClone, Error = Rejection> { - let base = Arc::new(path.into()); - crate::get() - .or(crate::head()) - .unify() - .and(path_from_tail(base)) - .and(conditionals()) - .and_then(file_reply) + config().dir(path) } fn path_from_tail( @@ -141,6 +283,8 @@ struct Conditionals { if_unmodified_since: Option, if_range: Option, range: Option, + if_match: Option, + if_none_match: Option, } enum Cond { @@ -149,14 +293,51 @@ enum Cond { } impl Conditionals { - fn check(self, last_modified: Option) -> Cond { + fn check(self, etag: Option<&ETag>, last_modified: Option) -> Cond { + if let Some(tag_match) = self.if_match { + let precondition = etag + .map(|tag| tag_match.precondition_passes(tag)) + .unwrap_or(false); + + tracing::trace!( + "if-match? header = {:?}, file = {:?}, result = {}", + tag_match, + etag, + precondition + ); + if !precondition { + let mut res = Response::new(Body::empty()); + *res.status_mut() = StatusCode::PRECONDITION_FAILED; + return Cond::NoBody(res); + } + } + + if let Some(tag_match) = self.if_none_match { + let precondition = etag + .map(|tag| !tag_match.precondition_passes(tag)) + // no last_modified means its always modified + .unwrap_or(false); + + tracing::trace!( + "if-none-match? header = {:?}, file = {:?}, result = {}", + tag_match, + etag, + precondition + ); + if precondition { + let mut res = Response::new(Body::empty()); + *res.status_mut() = StatusCode::NOT_MODIFIED; + return Cond::NoBody(res); + } + } + if let Some(since) = self.if_unmodified_since { let precondition = last_modified .map(|time| since.precondition_passes(time.into())) .unwrap_or(false); tracing::trace!( - "if-unmodified-since? {:?} vs {:?} = {}", + "if-unmodified-since? header = {:?}, file = {:?}, result = {}", since, last_modified, precondition @@ -169,15 +350,17 @@ impl Conditionals { } if let Some(since) = self.if_modified_since { - tracing::trace!( - "if-modified-since? header = {:?}, file = {:?}", - since, - last_modified - ); let unmodified = last_modified .map(|time| !since.is_modified(time.into())) // no last_modified means its always modified .unwrap_or(false); + + tracing::trace!( + "if-modified-since? header = {:?}, file = {:?}, result = {}", + since, + last_modified, + unmodified + ); if unmodified { let mut res = Response::new(Body::empty()); *res.status_mut() = StatusCode::NOT_MODIFIED; @@ -186,8 +369,15 @@ impl Conditionals { } if let Some(if_range) = self.if_range { - tracing::trace!("if-range? {:?} vs {:?}", if_range, last_modified); - let can_range = !if_range.is_modified(None, last_modified.as_ref()); + let can_range = !if_range.is_modified(etag, last_modified.as_ref()); + + tracing::trace!( + "if-range? header = {:?}, file = {:?},{:?}, result = {}", + if_range, + etag, + last_modified, + can_range + ); if !can_range { return Cond::WithBody(None); @@ -200,15 +390,21 @@ impl Conditionals { fn conditionals() -> impl Filter, Error = Infallible> + Copy { crate::header::optional2() + .and(crate::header::optional2()) + .and(crate::header::optional2()) .and(crate::header::optional2()) .and(crate::header::optional2()) .and(crate::header::optional2()) .map( - |if_modified_since, if_unmodified_since, if_range, range| Conditionals { - if_modified_since, - if_unmodified_since, - if_range, - range, + |if_modified_since, if_unmodified_since, if_range, range, if_match, if_none_match| { + Conditionals { + if_modified_since, + if_unmodified_since, + if_range, + range, + if_match, + if_none_match, + } }, ) } @@ -264,9 +460,10 @@ impl Reply for File { fn file_reply( path: ArcPath, conditionals: Conditionals, + config: Arc, ) -> impl Future> + Send { TkFile::open(path.clone()).then(move |res| match res { - Ok(f) => Either::Left(file_conditional(f, path, conditionals)), + Ok(f) => Either::Left(file_conditional(f, path, conditionals, config)), Err(err) => { let rej = match err.kind() { io::ErrorKind::NotFound => { @@ -305,18 +502,35 @@ fn file_conditional( f: TkFile, path: ArcPath, conditionals: Conditionals, + config: Arc, ) -> impl Future> + Send { file_metadata(f).map_ok(move |(file, meta)| { + let config = config + .callback + .and_then(|callback| callback(config.as_ref())) + .map(Arc::new) + .unwrap_or(config); let mut len = meta.len(); let modified = meta.modified().ok().map(LastModified::from); - - let resp = match conditionals.check(modified) { + let etag = modified.and_then(|modified| { + // do a quick weak etag based on modified stamp + let modified: SystemTime = modified.into(); + let modified = modified.duration_since(SystemTime::UNIX_EPOCH); + modified + .map(|modified| format!("W/\"{:02X?}\"", modified)) + .map(|modified| modified.parse::().expect("Invalid ETag")) + .ok() + }); + + let resp = match conditionals.check(etag.as_ref(), modified) { Cond::NoBody(resp) => resp, Cond::WithBody(range) => { bytes_range(range, len) .map(|(start, end)| { let sub_len = end - start; - let buf_size = optimal_buf_size(&meta); + let buf_size = config + .read_buffer_size + .unwrap_or_else(|| optimal_buf_size(&meta)); let stream = file_stream(file, buf_size, (start, end)); let body = Body::wrap_stream(stream); @@ -331,14 +545,33 @@ fn file_conditional( len = sub_len; } - let mime = mime_guess::from_path(path.as_ref()).first_or_octet_stream(); + let content_type = config.content_type.as_ref().map_or_else( + || { + ContentType::from( + mime_guess::from_path(path.as_ref()).first_or_octet_stream(), + ) + }, + |content_type| content_type.parse().expect("valid ContentType"), + ); resp.headers_mut().typed_insert(ContentLength(len)); - resp.headers_mut().typed_insert(ContentType::from(mime)); + resp.headers_mut().typed_insert(content_type); resp.headers_mut().typed_insert(AcceptRanges::bytes()); - if let Some(last_modified) = modified { - resp.headers_mut().typed_insert(last_modified); + if config.last_modified { + if let Some(last_modified) = modified { + resp.headers_mut().typed_insert(last_modified); + } + } + + if config.etag { + if let Some(etag) = etag { + resp.headers_mut().typed_insert(etag); + } + } + + for (k, v) in config.headers.iter() { + resp.headers_mut().insert(k, v.clone()); } resp @@ -497,7 +730,7 @@ unit_error! { } unit_error! { - pub(crate) FilePermissionError: "file perimission error" + pub(crate) FilePermissionError: "file permission error" } #[cfg(test)] diff --git a/tests/fs.rs b/tests/fs.rs index 4faa933d5..57fca9097 100644 --- a/tests/fs.rs +++ b/tests/fs.rs @@ -1,4 +1,5 @@ #![deny(warnings)] +#![allow(deprecated)] use std::fs; #[tokio::test] @@ -15,6 +16,8 @@ async fn file() { let contents = fs::read("README.md").expect("fs::read README.md"); assert_eq!(res.headers()["content-length"], contents.len().to_string()); assert_eq!(res.headers()["accept-ranges"], "bytes"); + assert!(res.headers().contains_key("last-modified")); + assert!(!res.headers().contains_key("etag")); let ct = &res.headers()["content-type"]; assert!( @@ -26,6 +29,150 @@ async fn file() { assert_eq!(res.body(), &*contents); } +#[tokio::test] +async fn file_overridden_content_type() { + let _ = pretty_env_logger::try_init(); + + let file = warp::fs::config() + .content_type(Some("text/plain")) + .file("README.md"); + + let req = warp::test::request(); + let res = req.reply(&file).await; + + assert_eq!(res.status(), 200); + + let contents = fs::read("README.md").expect("fs::read README.md"); + assert_eq!(res.headers()["content-length"], contents.len().to_string()); + assert_eq!(res.headers()["accept-ranges"], "bytes"); + + assert_eq!(res.headers()["content-type"], "text/plain"); + + assert_eq!(res.body(), &*contents); +} + +#[tokio::test] +async fn file_overridden_exclude_last_modified() { + let _ = pretty_env_logger::try_init(); + + let file = warp::fs::config().last_modified(false).file("README.md"); + + let req = warp::test::request(); + let res = req.reply(&file).await; + + assert_eq!(res.status(), 200); + + let contents = fs::read("README.md").expect("fs::read README.md"); + assert_eq!(res.headers()["content-length"], contents.len().to_string()); + assert_eq!(res.headers()["accept-ranges"], "bytes"); + assert!(!res.headers().contains_key("last-modified")); + assert!(!res.headers().contains_key("etag")); + + assert_eq!(res.body(), &*contents); +} + +#[tokio::test] +async fn file_overridden_expose_etag() { + let _ = pretty_env_logger::try_init(); + + let file = warp::fs::config().etag(true).file("README.md"); + + let req = warp::test::request(); + let res = req.reply(&file).await; + + assert_eq!(res.status(), 200); + + let contents = fs::read("README.md").expect("fs::read README.md"); + assert_eq!(res.headers()["content-length"], contents.len().to_string()); + assert_eq!(res.headers()["accept-ranges"], "bytes"); + assert!(res.headers().contains_key("last-modified")); + assert!(res.headers().contains_key("etag")); + + assert_eq!(res.body(), &*contents); +} + +#[tokio::test] +async fn file_etag_check() { + let _ = pretty_env_logger::try_init(); + + let file = warp::fs::config().etag(true).file("README.md"); + + let req = warp::test::request(); + let res = req.reply(&file).await; + + let etag = res.headers()["etag"].clone(); + + // Make with same etag + let req = warp::test::request().header("if-none-match", etag); + let res = req.reply(&file).await; + + assert_eq!(res.status(), 304); + + // Make with wrong etag + let req = warp::test::request().header("if-none-match", "W/\"Another\""); + let res = req.reply(&file).await; + + assert_eq!(res.status(), 200); +} + +#[tokio::test] +async fn file_overridden_extra_headers() { + let _ = pretty_env_logger::try_init(); + + let file = warp::fs::config() + .add_header("cache-control", "private, no-store".parse().unwrap()) + .file("README.md"); + + let req = warp::test::request(); + let res = req.reply(&file).await; + + assert_eq!(res.status(), 200); + + let contents = fs::read("README.md").expect("fs::read README.md"); + assert_eq!(res.headers()["content-length"], contents.len().to_string()); + assert_eq!(res.headers()["accept-ranges"], "bytes"); + assert!(res.headers().contains_key("last-modified")); + assert!(!res.headers().contains_key("etag")); + assert_eq!(res.headers()["cache-control"], "private, no-store"); + + assert_eq!(res.body(), &*contents); +} + +#[tokio::test] +async fn file_overridden_callback() { + let _ = pretty_env_logger::try_init(); + + let file = warp::fs::config() + .callback(Some(|config| { + Some( + config + .clone() + .add_header("cache-control", "private, no-store".parse().unwrap()), + ) + })) + .file("README.md"); + + let req = warp::test::request(); + let res = req.reply(&file).await; + + assert_eq!(res.status(), 200); + + let contents = fs::read("README.md").expect("fs::read README.md"); + assert_eq!(res.headers()["content-length"], contents.len().to_string()); + assert_eq!(res.headers()["accept-ranges"], "bytes"); + assert!(res.headers().contains_key("last-modified")); + assert!(!res.headers().contains_key("etag")); + assert_eq!(res.headers()["cache-control"], "private, no-store"); + + assert_eq!(res.body(), &*contents); +} + +#[tokio::test] +#[ignore = "Figure out how to test read_buff_size override"] +async fn file_overridden_read_buff_size() { + todo!("Implement this test somehow") +} + #[tokio::test] async fn dir() { let _ = pretty_env_logger::try_init();