diff --git a/test-files/index.html b/test-files/index.html new file mode 100644 index 0000000..ef2c5cc --- /dev/null +++ b/test-files/index.html @@ -0,0 +1 @@ +HTML! diff --git a/tower-http/CHANGELOG.md b/tower-http/CHANGELOG.md index d093dc2..1fd3b78 100644 --- a/tower-http/CHANGELOG.md +++ b/tower-http/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 # Unreleased +- `ServeDir` and `ServeFile`: Changed service types, both now return `ServeFileSystemResponseBody` and `ServeFileSystemResponseFuture` ([#187]) - `AddAuthorization`, `InFlightRequests`, `SetRequestHeader`, `SetResponseHeader`, `AddExtension`, `MapRequestBody` and `MapResponseBody` now requires underlying service to use `http::Request` and `http::Response` as request and responses ([#182]) - `ServeDir` and `ServeFile`: Ability to serve precompressed files ([#156]) @@ -42,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#170]: https://github.com/tower-rs/tower-http/pull/170 [#172]: https://github.com/tower-rs/tower-http/pull/172 [#182]: https://github.com/tower-rs/tower-http/pull/182 +[#187]: https://github.com/tower-rs/tower-http/pull/187 # 0.1.2 (November 13, 2021) diff --git a/tower-http/src/services/fs/mod.rs b/tower-http/src/services/fs/mod.rs index e087955..d0975d2 100644 --- a/tower-http/src/services/fs/mod.rs +++ b/tower-http/src/services/fs/mod.rs @@ -5,7 +5,7 @@ use http::{HeaderMap, Response, StatusCode}; use http_body::{combinators::BoxBody, Body, Empty}; use pin_project_lite::pin_project; use std::fs::Metadata; -use std::{ffi::OsStr, future::Future, path::PathBuf}; +use std::{ffi::OsStr, path::PathBuf}; use std::{ io, pin::Pin, @@ -27,11 +27,12 @@ use crate::content_encoding::{Encoding, SupportedEncodings}; pub use self::{ serve_dir::{ - ResponseBody as ServeDirResponseBody, ResponseFuture as ServeDirResponseFuture, ServeDir, - }, - serve_file::{ - ResponseBody as ServeFileResponseBody, ResponseFuture as ServeFileResponseFuture, ServeFile, + // The response body and future are used for both ServeDir and ServeFile + ResponseBody as ServeFileSystemResponseBody, + ResponseFuture as ServeFileSystemResponseFuture, + ServeDir, }, + serve_file::ServeFile, }; #[derive(Clone, Copy, Debug)] @@ -65,9 +66,6 @@ impl SupportedEncodings for PrecompressedVariants { } } -type FileFuture = - Pin)>> + Send + Sync + 'static>>; - // Returns the preferred_encoding encoding and modifies the path extension // to the corresponding file extension for the encoding. fn preferred_encoding( diff --git a/tower-http/src/services/fs/serve_dir.rs b/tower-http/src/services/fs/serve_dir.rs index 9d2206f..0d11bc5 100644 --- a/tower-http/src/services/fs/serve_dir.rs +++ b/tower-http/src/services/fs/serve_dir.rs @@ -58,14 +58,14 @@ use tower_service::Service; /// [`and_then`](tower::ServiceBuilder::and_then) to change the response: /// /// ``` -/// use tower_http::services::fs::{ServeDir, ServeDirResponseBody}; +/// use tower_http::services::fs::{ServeDir, ServeFileSystemResponseBody}; /// use tower::ServiceBuilder; /// use http::{StatusCode, Response}; /// use http_body::{Body as _, Full}; /// use std::io; /// /// let service = ServiceBuilder::new() -/// .and_then(|response: Response| async move { +/// .and_then(|response: Response| async move { /// let response = if response.status() == StatusCode::NOT_FOUND { /// let body = Full::from("Not Found") /// .map_err(|err| match err {}) @@ -92,9 +92,53 @@ use tower_service::Service; #[derive(Clone, Debug)] pub struct ServeDir { base: PathBuf, - append_index_html_on_directories: bool, buf_chunk_size: usize, precompressed_variants: Option, + // This is used to specialise implementation for + // single files + variant: ServeVariant, +} + +// Allow the ServeDir service to be used in the ServeFile service +// with almost no overhead +#[derive(Clone, Debug)] +enum ServeVariant { + Directory { + append_index_html_on_directories: bool, + }, + SingleFile { + mime: HeaderValue, + }, +} + +impl ServeVariant { + fn full_path(&self, base_path: &Path, requested_path: &str) -> Option { + match self { + ServeVariant::Directory { + append_index_html_on_directories: _, + } => { + let full_path = build_and_validate_path(base_path, requested_path)?; + Some(full_path) + } + ServeVariant::SingleFile { mime: _ } => Some(base_path.to_path_buf()), + } + } +} + +fn build_and_validate_path(base_path: &Path, requested_path: &str) -> Option { + // build and validate the path + let path = requested_path.trim_start_matches('/'); + + let path_decoded = percent_decode(path.as_ref()).decode_utf8().ok()?; + + let mut full_path = base_path.to_path_buf(); + for seg in path_decoded.split('/') { + if seg.starts_with("..") || seg.contains('\\') { + return None; + } + full_path.push(seg); + } + Some(full_path) } impl ServeDir { @@ -105,9 +149,20 @@ impl ServeDir { Self { base, - append_index_html_on_directories: true, buf_chunk_size: DEFAULT_CAPACITY, precompressed_variants: None, + variant: ServeVariant::Directory { + append_index_html_on_directories: true, + }, + } + } + + pub(crate) fn new_single_file>(path: P, mime: HeaderValue) -> Self { + Self { + base: path.as_ref().to_owned(), + buf_chunk_size: DEFAULT_CAPACITY, + precompressed_variants: None, + variant: ServeVariant::SingleFile { mime }, } } @@ -117,8 +172,15 @@ impl ServeDir { /// /// Defaults to `true`. pub fn append_index_html_on_directories(mut self, append: bool) -> Self { - self.append_index_html_on_directories = append; - self + match &mut self.variant { + ServeVariant::Directory { + append_index_html_on_directories, + } => { + *append_index_html_on_directories = append; + self + } + ServeVariant::SingleFile { mime: _ } => self, + } } /// Set a specific read buffer chunk size. @@ -181,6 +243,29 @@ impl ServeDir { } } +async fn maybe_redirect_or_append_path( + full_path: &mut PathBuf, + uri: Uri, + append_index_html_on_directories: bool, +) -> Option { + if !uri.path().ends_with('/') { + if is_dir(full_path).await { + let location = HeaderValue::from_str(&append_slash_on_path(uri).to_string()).unwrap(); + return Some(Output::Redirect(location)); + } else { + return None; + } + } else if is_dir(full_path).await { + if append_index_html_on_directories { + full_path.push("index.html"); + return None; + } else { + return Some(Output::NotFound); + } + } + None +} + impl Service> for ServeDir { type Response = Response; type Error = io::Error; @@ -192,29 +277,15 @@ impl Service> for ServeDir { } fn call(&mut self, req: Request) -> Self::Future { - // build and validate the path - let path = req.uri().path(); - let path = path.trim_start_matches('/'); - - let path_decoded = if let Ok(decoded_utf8) = percent_decode(path.as_ref()).decode_utf8() { - decoded_utf8 - } else { - return ResponseFuture { - inner: Inner::Invalid, - }; - }; - - let mut full_path = self.base.clone(); - for seg in path_decoded.split('/') { - if seg.starts_with("..") || seg.contains('\\') { + let mut full_path = match self.variant.full_path(&self.base, req.uri().path()) { + Some(full_path) => full_path, + None => { return ResponseFuture { inner: Inner::Invalid, - }; + } } - full_path.push(seg); - } + }; - let append_index_html_on_directories = self.append_index_html_on_directories; let buf_chunk_size = self.buf_chunk_size; let uri = req.uri().clone(); @@ -226,29 +297,35 @@ impl Service> for ServeDir { ); let request_method = req.method().clone(); + let variant = self.variant.clone(); let open_file_future = Box::pin(async move { - if !uri.path().ends_with('/') { - if is_dir(&full_path).await { - let location = - HeaderValue::from_str(&append_slash_on_path(uri).to_string()).unwrap(); - return Ok(Output::Redirect(location)); + let mime = match variant { + ServeVariant::Directory { + append_index_html_on_directories, + } => { + // Might already at this point know a redirect or not found result should be + // returned which corresponds to a Some(output). Otherwise the path might be + // modified and proceed to the open file/metadata future. + if let Some(output) = maybe_redirect_or_append_path( + &mut full_path, + uri, + append_index_html_on_directories, + ) + .await + { + return Ok(output); + } + let guess = mime_guess::from_path(&full_path); + guess + .first_raw() + .map(|mime| HeaderValue::from_static(mime)) + .unwrap_or_else(|| { + HeaderValue::from_str(mime::APPLICATION_OCTET_STREAM.as_ref()).unwrap() + }) } - } else if is_dir(&full_path).await { - if append_index_html_on_directories { - full_path.push("index.html"); - } else { - return Ok(Output::NotFound); - } - } - - let guess = mime_guess::from_path(&full_path); - let mime = guess - .first_raw() - .map(|mime| HeaderValue::from_static(mime)) - .unwrap_or_else(|| { - HeaderValue::from_str(mime::APPLICATION_OCTET_STREAM.as_ref()).unwrap() - }); + ServeVariant::SingleFile { mime } => mime, + }; match request_method { Method::HEAD => { @@ -399,7 +476,7 @@ fn empty_body() -> ResponseBody { } opaque_body! { - /// Response body for [`ServeDir`]. + /// Response body for [`ServeDir`] and [`ServeFile`]. pub type ResponseBody = BoxBody; } @@ -435,6 +512,20 @@ mod tests { assert_eq!(body, contents); } + #[tokio::test] + async fn basic_with_index() { + let svc = ServeDir::new("../test-files"); + + let req = Request::new(Body::empty()); + let res = svc.oneshot(req).await.unwrap(); + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(res.headers()[header::CONTENT_TYPE], "text/html"); + + let body = body_into_text(res.into_body()).await; + assert_eq!(body, "HTML!\n"); + } + #[tokio::test] async fn head_request() { let svc = ServeDir::new("../test-files"); diff --git a/tower-http/src/services/fs/serve_file.rs b/tower-http/src/services/fs/serve_file.rs index e60abd8..29b3584 100644 --- a/tower-http/src/services/fs/serve_file.rs +++ b/tower-http/src/services/fs/serve_file.rs @@ -1,42 +1,24 @@ //! Service that serves a file. -use super::{ - file_metadata_with_fallback, open_file_with_fallback, AsyncReadBody, FileFuture, - PrecompressedVariants, -}; -use crate::{ - content_encoding::{encodings, Encoding}, - services::fs::DEFAULT_CAPACITY, -}; -use bytes::Bytes; -use http::{header, method::Method, HeaderValue, Request, Response}; -use http_body::{combinators::BoxBody, Body}; +use super::ServeDir; +use http::{HeaderValue, Request}; use mime::Mime; use std::{ - fs::Metadata, - future::Future, - io, - path::{Path, PathBuf}, - pin::Pin, + path::Path, task::{Context, Poll}, }; use tower_service::Service; /// Service that serves a file. #[derive(Clone, Debug)] -pub struct ServeFile { - path: PathBuf, - mime: HeaderValue, - buf_chunk_size: usize, - precompressed_variants: Option, -} - +pub struct ServeFile(ServeDir); +// Note that this is just a special case of ServeDir impl ServeFile { /// Create a new [`ServeFile`]. /// /// The `Content-Type` will be guessed from the file extension. pub fn new>(path: P) -> Self { - let guess = mime_guess::from_path(&path); + let guess = mime_guess::from_path(path.as_ref()); let mime = guess .first_raw() .map(|mime| HeaderValue::from_static(mime)) @@ -44,14 +26,7 @@ impl ServeFile { HeaderValue::from_str(mime::APPLICATION_OCTET_STREAM.as_ref()).unwrap() }); - let path = path.as_ref().to_owned(); - - Self { - path, - mime, - buf_chunk_size: DEFAULT_CAPACITY, - precompressed_variants: None, - } + Self(ServeDir::new_single_file(path, mime)) } /// Create a new [`ServeFile`] with a specific mime type. @@ -63,14 +38,7 @@ impl ServeFile { /// [header value]: https://docs.rs/http/latest/http/header/struct.HeaderValue.html pub fn new_with_mime>(path: P, mime: &Mime) -> Self { let mime = HeaderValue::from_str(mime.as_ref()).expect("mime isn't a valid header value"); - let path = path.as_ref().to_owned(); - - Self { - path, - mime, - buf_chunk_size: DEFAULT_CAPACITY, - precompressed_variants: None, - } + Self(ServeDir::new_single_file(path, mime)) } /// Informs the service that it should also look for a precompressed gzip @@ -83,11 +51,8 @@ impl ServeFile { /// Both the precompressed version and the uncompressed version are expected /// to be present in the same directory. Different precompressed /// variants can be combined. - pub fn precompressed_gzip(mut self) -> Self { - self.precompressed_variants - .get_or_insert(Default::default()) - .gzip = true; - self + pub fn precompressed_gzip(self) -> Self { + Self(self.0.precompressed_gzip()) } /// Informs the service that it should also look for a precompressed brotli @@ -100,11 +65,8 @@ impl ServeFile { /// Both the precompressed version and the uncompressed version are expected /// to be present in the same directory. Different precompressed /// variants can be combined. - pub fn precompressed_br(mut self) -> Self { - self.precompressed_variants - .get_or_insert(Default::default()) - .br = true; - self + pub fn precompressed_br(self) -> Self { + Self(self.0.precompressed_br()) } /// Informs the service that it should also look for a precompressed deflate @@ -117,31 +79,22 @@ impl ServeFile { /// Both the precompressed version and the uncompressed version are expected /// to be present in the same directory. Different precompressed /// variants can be combined. - pub fn precompressed_deflate(mut self) -> Self { - self.precompressed_variants - .get_or_insert(Default::default()) - .deflate = true; - self + pub fn precompressed_deflate(self) -> Self { + Self(self.0.precompressed_deflate()) } /// Set a specific read buffer chunk size. /// /// The default capacity is 64kb. - pub fn with_buf_chunk_size(mut self, chunk_size: usize) -> Self { - self.buf_chunk_size = chunk_size; - self + pub fn with_buf_chunk_size(self, chunk_size: usize) -> Self { + Self(self.0.with_buf_chunk_size(chunk_size)) } } -enum FileReadFuture { - Open(FileFuture), - Metadata(Pin)>>>>), -} - impl Service> for ServeFile { - type Error = io::Error; - type Response = Response; - type Future = ResponseFuture; + type Error = >>::Error; + type Response = >>::Response; + type Future = >>::Future; #[inline] fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { @@ -149,112 +102,21 @@ impl Service> for ServeFile { } fn call(&mut self, req: Request) -> Self::Future { - let path = self.path.clone(); - // The negotiated encodings based on the Accept-Encoding header and - // precompressed variants - let negotiated_encodings = encodings( - req.headers(), - self.precompressed_variants.unwrap_or_default(), - ); - let file_future = match *req.method() { - Method::HEAD => FileReadFuture::Metadata(Box::pin(file_metadata_with_fallback( - path, - negotiated_encodings, - ))), - _ => FileReadFuture::Open(Box::pin(open_file_with_fallback( - path, - negotiated_encodings, - ))), - }; - - ResponseFuture { - file_future, - mime: Some(self.mime.clone()), - buf_chunk_size: self.buf_chunk_size, - } + self.0.call(req) } } -/// Response future of [`ServeFile`]. -pub struct ResponseFuture { - file_future: FileReadFuture, - mime: Option, - buf_chunk_size: usize, -} - -impl Future for ResponseFuture { - type Output = io::Result>; - - fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - match &mut self.file_future { - FileReadFuture::Open(open_file_future) => { - let (file, maybe_encoding) = match open_file_future.as_mut().poll(cx) { - Poll::Ready(Ok((file, maybe_encoding))) => (file, maybe_encoding), - Poll::Ready(Err(err)) => { - return Poll::Ready( - super::response_from_io_error(err) - .map(|res| res.map(ResponseBody::new)), - ) - } - Poll::Pending => return Poll::Pending, - }; - let chunk_size = self.buf_chunk_size; - let body = AsyncReadBody::with_capacity(file, chunk_size).boxed(); - let body = ResponseBody::new(body); - let mut res = Response::new(body); - - res.headers_mut() - .insert(header::CONTENT_TYPE, self.mime.take().unwrap()); - - if let Some(encoding) = maybe_encoding { - res.headers_mut() - .insert(header::CONTENT_ENCODING, encoding.into_header_value()); - } - Poll::Ready(Ok(res)) - } - FileReadFuture::Metadata(metadata_future) => { - let (metadata, maybe_encoding) = match metadata_future.as_mut().poll(cx) { - Poll::Ready(Ok((metadata, maybe_encoding))) => (metadata, maybe_encoding), - Poll::Ready(Err(err)) => { - return Poll::Ready( - super::response_from_io_error(err) - .map(|res| res.map(ResponseBody::new)), - ) - } - Poll::Pending => return Poll::Pending, - }; - - let mut res = Response::new(ResponseBody::new(BoxBody::default())); - - res.headers_mut() - .insert(header::CONTENT_LENGTH, metadata.len().into()); - res.headers_mut() - .insert(header::CONTENT_TYPE, self.mime.take().unwrap()); - - if let Some(encoding) = maybe_encoding { - res.headers_mut() - .insert(header::CONTENT_ENCODING, encoding.into_header_value()); - } - Poll::Ready(Ok(res)) - } - } - } -} - -opaque_body! { - /// Response body for [`ServeFile`]. - pub type ResponseBody = BoxBody; -} - #[cfg(test)] mod tests { use std::io::Read; + use std::str::FromStr; #[allow(unused_imports)] use super::*; use brotli::BrotliDecompress; use flate2::bufread::DeflateDecoder; use flate2::bufread::GzDecoder; + use http::header; use http::Method; use http::{Request, StatusCode}; use http_body::Body as _; @@ -275,6 +137,20 @@ mod tests { assert!(body.starts_with("# Tower HTTP")); } + #[tokio::test] + async fn basic_with_mime() { + let svc = ServeFile::new_with_mime("../README.md", &Mime::from_str("image/jpg").unwrap()); + + let res = svc.oneshot(Request::new(Body::empty())).await.unwrap(); + + assert_eq!(res.headers()["content-type"], "image/jpg"); + + let body = res.into_body().data().await.unwrap().unwrap(); + let body = String::from_utf8(body.to_vec()).unwrap(); + + assert!(body.starts_with("# Tower HTTP")); + } + #[tokio::test] async fn head_request() { let svc = ServeFile::new("../test-files/precompressed.txt");