diff --git a/test-files/page.html b/test-files/page.html new file mode 100644 index 00000000..65c593b0 --- /dev/null +++ b/test-files/page.html @@ -0,0 +1 @@ +page diff --git a/tower-http/CHANGELOG.md b/tower-http/CHANGELOG.md index 54c8ad59..d59ab922 100644 --- a/tower-http/CHANGELOG.md +++ b/tower-http/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 # Unreleased +## Fixed + +- `fs`: `ServeDir` and `ServeFile` now emit a `Vary: Accept-Encoding` response + header when precompressed serving is configured, ensuring caches correctly + distinguish between compressed and uncompressed variants. + ## Changed - `trace`: `DefaultOnRequest`, `DefaultOnResponse`, `DefaultOnFailure`, and @@ -24,6 +30,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#628]: https://github.com/tower-rs/tower-http/pull/628 [#642]: https://github.com/tower-rs/tower-http/pull/642 +## Added + +- `body`: `UnsyncBoxBody::new()` constructor and `From` conversion to avoid double-boxing when combining `ServeDir` responses with other body types ([#537]) + # 0.6.11 ## Added diff --git a/tower-http/src/body.rs b/tower-http/src/body.rs index 815a0d10..45923b69 100644 --- a/tower-http/src/body.rs +++ b/tower-http/src/body.rs @@ -105,11 +105,32 @@ where impl UnsyncBoxBody { #[allow(dead_code)] - pub(crate) fn new(inner: http_body_util::combinators::UnsyncBoxBody) -> Self { + pub(crate) fn from_inner(inner: http_body_util::combinators::UnsyncBoxBody) -> Self { Self { inner } } } +impl UnsyncBoxBody +where + D: Buf + 'static, +{ + /// Create a new `UnsyncBoxBody` by erasing the type of the given body. + /// + /// This is useful when you need a common body type across different response branches + /// without double-boxing bodies that are already boxed (like [`ServeDir`]'s response body, + /// which has a [`From`] impl for zero-cost conversion). + /// + /// [`ServeDir`]: crate::services::ServeDir + pub fn new(body: B) -> Self + where + B: Body + Send + 'static, + { + Self { + inner: http_body_util::combinators::UnsyncBoxBody::new(body), + } + } +} + impl Body for UnsyncBoxBody where D: Buf, diff --git a/tower-http/src/catch_panic.rs b/tower-http/src/catch_panic.rs index 3f1c2279..333c81f1 100644 --- a/tower-http/src/catch_panic.rs +++ b/tower-http/src/catch_panic.rs @@ -267,11 +267,9 @@ where future, panic_handler, } => match ready!(future.poll(cx)) { - Ok(Ok(res)) => { - Poll::Ready(Ok(res.map(|body| { - UnsyncBoxBody::new(body.map_err(Into::into).boxed_unsync()) - }))) - } + Ok(Ok(res)) => Poll::Ready(Ok(res.map(|body| { + UnsyncBoxBody::from_inner(body.map_err(Into::into).boxed_unsync()) + }))), Ok(Err(svc_err)) => Poll::Ready(Err(svc_err)), Err(panic_err) => Poll::Ready(Ok(response_for_panic( panic_handler @@ -295,7 +293,7 @@ where { panic_handler .response_for_panic(err) - .map(|body| UnsyncBoxBody::new(body.map_err(Into::into).boxed_unsync())) + .map(|body| UnsyncBoxBody::from_inner(body.map_err(Into::into).boxed_unsync())) } /// Trait for creating responses from panics. diff --git a/tower-http/src/decompression/request/future.rs b/tower-http/src/decompression/request/future.rs index bdb22f8b..1d1a8925 100644 --- a/tower-http/src/decompression/request/future.rs +++ b/tower-http/src/decompression/request/future.rs @@ -76,7 +76,7 @@ where fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { match self.project().kind.project() { StateProj::Inner { fut } => fut.poll(cx).map_ok(|res| { - res.map(|body| UnsyncBoxBody::new(body.map_err(Into::into).boxed_unsync())) + res.map(|body| UnsyncBoxBody::from_inner(body.map_err(Into::into).boxed_unsync())) }), StateProj::Unsupported { accept } => { let res = Response::builder() @@ -87,7 +87,7 @@ where .unwrap_or(HeaderValue::from_static("identity")), ) .status(StatusCode::UNSUPPORTED_MEDIA_TYPE) - .body(UnsyncBoxBody::new( + .body(UnsyncBoxBody::from_inner( Empty::new().map_err(Into::into).boxed_unsync(), )) .unwrap(); diff --git a/tower-http/src/services/fs/serve_dir/future.rs b/tower-http/src/services/fs/serve_dir/future.rs index 7df32b0d..6386ead4 100644 --- a/tower-http/src/services/fs/serve_dir/future.rs +++ b/tower-http/src/services/fs/serve_dir/future.rs @@ -208,7 +208,7 @@ where .map_ok(|response| { response .map(|body| { - UnsyncBoxBody::new( + UnsyncBoxBody::from_inner( body.map_err(|err| match err.into().downcast::() { Ok(err) => *err, Err(err) => io::Error::new(io::ErrorKind::Other, err), @@ -240,6 +240,12 @@ fn build_response(output: FileOpened) -> Response { builder = builder.header(header::CONTENT_ENCODING, encoding.into_header_value()); } + // Per RFC 9110 ยง12.5.3, Vary must be sent when the response could differ + // based on Accept-Encoding, even if this particular response is uncompressed. + if output.precompression_configured { + builder = builder.header(header::VARY, "accept-encoding"); + } + if let Some(last_modified) = output.last_modified { builder = builder.header(header::LAST_MODIFIED, last_modified.0.to_string()); } @@ -258,7 +264,7 @@ fn build_response(output: FileOpened) -> Response { } else { let body = if let Some(file) = maybe_file { let range_size = range.end() - range.start() + 1; - ResponseBody::new(UnsyncBoxBody::new( + ResponseBody::new(UnsyncBoxBody::from_inner( AsyncReadBody::with_capacity_limited( file, output.chunk_size, @@ -306,7 +312,7 @@ fn build_response(output: FileOpened) -> Response { // Not a range request None => { let body = if let Some(file) = maybe_file { - ResponseBody::new(UnsyncBoxBody::new( + ResponseBody::new(UnsyncBoxBody::from_inner( AsyncReadBody::with_capacity(file, output.chunk_size).boxed_unsync(), )) } else { @@ -323,10 +329,10 @@ fn build_response(output: FileOpened) -> Response { fn body_from_bytes(bytes: Bytes) -> ResponseBody { let body = Full::from(bytes).map_err(|err| match err {}).boxed_unsync(); - ResponseBody::new(UnsyncBoxBody::new(body)) + ResponseBody::new(UnsyncBoxBody::from_inner(body)) } fn empty_body() -> ResponseBody { let body = Empty::new().map_err(|err| match err {}).boxed_unsync(); - ResponseBody::new(UnsyncBoxBody::new(body)) + ResponseBody::new(UnsyncBoxBody::from_inner(body)) } diff --git a/tower-http/src/services/fs/serve_dir/mod.rs b/tower-http/src/services/fs/serve_dir/mod.rs index 1fc82c51..b243202c 100644 --- a/tower-http/src/services/fs/serve_dir/mod.rs +++ b/tower-http/src/services/fs/serve_dir/mod.rs @@ -76,6 +76,7 @@ impl ServeDir { precompressed_variants: None, variant: ServeVariant::Directory { append_index_html_on_directories: true, + html_as_default_extension: false, }, fallback: None, call_fallback_on_method_not_allowed: false, @@ -107,6 +108,7 @@ impl ServeDir { match &mut self.variant { ServeVariant::Directory { append_index_html_on_directories, + .. } => { *append_index_html_on_directories = append; self @@ -115,6 +117,22 @@ impl ServeDir { } } + /// If the requested path doesn't specify a file extension, append `.html`. + /// + /// Defaults to `false`. + pub fn html_as_default_extension(mut self, append: bool) -> Self { + match &mut self.variant { + ServeVariant::Directory { + html_as_default_extension, + .. + } => { + *html_as_default_extension = append; + self + } + ServeVariant::SingleFile { mime: _ } => self, + } + } + /// Set a specific read buffer chunk size. /// /// The default capacity is 64kb. @@ -366,6 +384,7 @@ impl ServeDir { .and_then(|value| value.to_str().ok()) .map(|s| s.to_owned()); + let precompression_configured = self.precompressed_variants.is_some(); let negotiated_encodings: Vec<_> = encodings( req.headers(), self.precompressed_variants.unwrap_or_default(), @@ -381,6 +400,7 @@ impl ServeDir { negotiated_encodings, range_header, buf_chunk_size, + precompression_configured, )); ResponseFuture::open_file_future(open_file_future, fallback_and_request) @@ -415,7 +435,7 @@ where #[cfg(feature = "tracing")] tracing::error!(error = %_err, "Failed to read file"); - let body = ResponseBody::new(UnsyncBoxBody::new( + let body = ResponseBody::new(UnsyncBoxBody::from_inner( Empty::new().map_err(|err| match err {}).boxed_unsync(), )); Response::builder() @@ -445,6 +465,7 @@ opaque_future! { enum ServeVariant { Directory { append_index_html_on_directories: bool, + html_as_default_extension: bool, }, SingleFile { mime: HeaderValue, @@ -456,6 +477,7 @@ impl ServeVariant { match self { ServeVariant::Directory { append_index_html_on_directories: _, + html_as_default_extension: _, } => { let path = requested_path.trim_start_matches('/'); @@ -613,6 +635,12 @@ opaque_body! { pub type ResponseBody = UnsyncBoxBody; } +impl From for UnsyncBoxBody { + fn from(body: ResponseBody) -> Self { + body.inner + } +} + /// The default fallback service used with [`ServeDir`]. #[derive(Debug, Clone, Copy)] pub struct DefaultServeDirFallback(Infallible); diff --git a/tower-http/src/services/fs/serve_dir/open_file.rs b/tower-http/src/services/fs/serve_dir/open_file.rs index 54d778de..d7e76084 100644 --- a/tower-http/src/services/fs/serve_dir/open_file.rs +++ b/tower-http/src/services/fs/serve_dir/open_file.rs @@ -33,6 +33,7 @@ pub(super) struct FileOpened { pub(super) maybe_encoding: Option, pub(super) maybe_range: Option>, RangeUnsatisfiableError>>, pub(super) last_modified: Option, + pub(super) precompression_configured: bool, } pub(super) enum FileRequestExtent { @@ -47,6 +48,7 @@ pub(super) async fn open_file( negotiated_encodings: Vec<(Encoding, QValue)>, range_header: Option, buf_chunk_size: usize, + precompression_configured: bool, ) -> io::Result { let if_unmodified_since = req .headers() @@ -61,6 +63,7 @@ pub(super) async fn open_file( let mime = match variant { ServeVariant::Directory { append_index_html_on_directories, + html_as_default_extension, } => { // 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 @@ -69,6 +72,7 @@ pub(super) async fn open_file( &mut path_to_file, req.uri(), append_index_html_on_directories, + html_as_default_extension, ) .await { @@ -108,6 +112,7 @@ pub(super) async fn open_file( maybe_encoding, maybe_range, last_modified, + precompression_configured, }))) } else { let (mut file, maybe_encoding) = @@ -147,6 +152,7 @@ pub(super) async fn open_file( maybe_encoding, maybe_range, last_modified, + precompression_configured, }))) } } @@ -287,16 +293,23 @@ async fn maybe_redirect_or_append_path( path_to_file: &mut PathBuf, uri: &Uri, append_index_html_on_directories: bool, + html_as_default_extension: bool, ) -> Option { let uri_path = uri.path(); let is_directory = is_dir(path_to_file).await; - if uri_path.ends_with('/') && uri_path != "/" && !is_directory { + if uri_path.ends_with('/') && uri_path != "/" && is_directory != Some(true) { return Some(OpenFileOutput::FileNotFound); } - if !is_directory { + // If the path has no extension and doesn't exist as a file, try appending .html + if html_as_default_extension && is_directory.is_none() && path_to_file.extension().is_none() { + path_to_file.set_extension("html"); + return None; + } + + if is_directory != Some(true) { return None; } @@ -327,10 +340,11 @@ fn try_parse_range( }) } -async fn is_dir(path_to_file: &Path) -> bool { +async fn is_dir(path_to_file: &Path) -> Option { tokio::fs::metadata(path_to_file) .await - .map_or(false, |meta_data| meta_data.is_dir()) + .ok() + .map(|meta_data| meta_data.is_dir()) } fn append_slash_on_path(uri: Uri) -> Result { diff --git a/tower-http/src/services/fs/serve_dir/tests.rs b/tower-http/src/services/fs/serve_dir/tests.rs index 72c567a6..3dd9a086 100644 --- a/tower-http/src/services/fs/serve_dir/tests.rs +++ b/tower-http/src/services/fs/serve_dir/tests.rs @@ -1101,6 +1101,7 @@ fn test_build_and_validate_path_reserved_dos_names() { let variant = ServeVariant::Directory { append_index_html_on_directories: true, + html_as_default_extension: false, }; let base = Path::new("/base"); @@ -1146,3 +1147,150 @@ async fn identity_encoding_does_not_strip_extension_head_request() { assert_eq!(res.status(), StatusCode::NOT_FOUND); } + +#[tokio::test] +async fn precompressed_response_includes_vary_header() { + let svc = ServeDir::new(TEST_FILES_DIR).precompressed_gzip(); + + let req = Request::builder() + .uri("/precompressed.txt") + .header("Accept-Encoding", "gzip") + .body(Body::empty()) + .unwrap(); + let res = svc.oneshot(req).await.unwrap(); + + assert_eq!(res.headers()["content-encoding"], "gzip"); + assert_eq!(res.headers()["vary"], "accept-encoding"); +} + +#[tokio::test] +async fn no_vary_header_without_precompressed_serving() { + let svc = ServeDir::new(REPO_ROOT); + + let req = Request::builder() + .uri("/README.md") + .header("Accept-Encoding", "gzip") + .body(Body::empty()) + .unwrap(); + let res = svc.oneshot(req).await.unwrap(); + + assert_eq!(res.status(), StatusCode::OK); + assert!(res.headers().get("vary").is_none()); +} + +#[tokio::test] +async fn vary_header_present_when_precompressed_configured_but_fallback_to_uncompressed() { + let svc = ServeDir::new(TEST_FILES_DIR).precompressed_gzip(); + + let req = Request::builder() + .uri("/precompressed.txt") + .header("Accept-Encoding", "br") + .body(Body::empty()) + .unwrap(); + let res = svc.oneshot(req).await.unwrap(); + + assert!(res.headers().get("content-encoding").is_none()); + assert_eq!(res.headers()["vary"], "accept-encoding"); +} + +#[tokio::test] +async fn vary_header_present_when_precompressed_configured_but_no_accept_encoding() { + let svc = ServeDir::new(TEST_FILES_DIR).precompressed_gzip(); + + let req = Request::builder() + .uri("/precompressed.txt") + .body(Body::empty()) + .unwrap(); + let res = svc.oneshot(req).await.unwrap(); + + assert!(res.headers().get("content-encoding").is_none()); + assert_eq!(res.headers()["vary"], "accept-encoding"); +} + +#[tokio::test] +async fn precompressed_head_request_includes_vary_header() { + let svc = ServeDir::new(TEST_FILES_DIR).precompressed_gzip(); + + let req = Request::builder() + .uri("/precompressed.txt") + .method(Method::HEAD) + .header("Accept-Encoding", "gzip") + .body(Body::empty()) + .unwrap(); + let res = svc.oneshot(req).await.unwrap(); + + assert_eq!(res.headers()["content-encoding"], "gzip"); + assert_eq!(res.headers()["vary"], "accept-encoding"); +} + +#[tokio::test] +async fn unsync_box_body_new() { + use crate::body::UnsyncBoxBody; + use http_body_util::Full; + + let body: UnsyncBoxBody = + UnsyncBoxBody::new(Full::new(Bytes::from("hello"))); + let collected = body.collect().await.unwrap().to_bytes(); + assert_eq!(collected, "hello"); +} + +#[tokio::test] +async fn response_body_into_unsync_box_body() { + use crate::body::UnsyncBoxBody; + + let svc = ServeDir::new(".."); + let req = Request::builder() + .uri("/README.md") + .body(Body::empty()) + .unwrap(); + let res = svc.oneshot(req).await.unwrap(); + + // Convert the ServeDir response body into UnsyncBoxBody without double-boxing + let boxed: UnsyncBoxBody = res.into_body().into(); + let collected = boxed.collect().await.unwrap().to_bytes(); + + let expected = std::fs::read_to_string("../README.md").unwrap(); + assert_eq!(collected, expected); +} + +#[tokio::test] +async fn html_as_default_extension() { + let svc = ServeDir::new(TEST_FILES_DIR).html_as_default_extension(true); + + let req = Request::builder().uri("/page").body(Body::empty()).unwrap(); + let res = svc.oneshot(req).await.unwrap(); + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(res.headers()["content-type"], "text/html"); + + let body = body_into_text(res.into_body()).await; + assert_eq!(body, "page\n"); +} + +#[tokio::test] +async fn html_as_default_extension_not_found() { + let svc = ServeDir::new(TEST_FILES_DIR).html_as_default_extension(true); + + let req = Request::builder() + .uri("/nonexistent") + .body(Body::empty()) + .unwrap(); + let res = svc.oneshot(req).await.unwrap(); + + assert_eq!(res.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn html_as_default_extension_does_not_apply_when_extension_present() { + let svc = ServeDir::new(TEST_FILES_DIR).html_as_default_extension(true); + + // Request a file that exists with its extension; should serve normally + let req = Request::builder() + .uri("/precompressed.txt") + .body(Body::empty()) + .unwrap(); + let res = svc.oneshot(req).await.unwrap(); + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(res.headers()["content-type"], "text/plain"); +}