Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions test-files/page.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<b>page</b>
10 changes: 10 additions & 0 deletions tower-http/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<ServeFileSystemResponseBody>` conversion to avoid double-boxing when combining `ServeDir` responses with other body types ([#537])

# 0.6.11

## Added
Expand Down
23 changes: 22 additions & 1 deletion tower-http/src/body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,32 @@ where

impl<D, E> UnsyncBoxBody<D, E> {
#[allow(dead_code)]
pub(crate) fn new(inner: http_body_util::combinators::UnsyncBoxBody<D, E>) -> Self {
pub(crate) fn from_inner(inner: http_body_util::combinators::UnsyncBoxBody<D, E>) -> Self {
Self { inner }
}
}

impl<D, E> UnsyncBoxBody<D, E>
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<B>(body: B) -> Self
where
B: Body<Data = D, Error = E> + Send + 'static,
{
Self {
inner: http_body_util::combinators::UnsyncBoxBody::new(body),
}
}
}

impl<D, E> Body for UnsyncBoxBody<D, E>
where
D: Buf,
Expand Down
10 changes: 4 additions & 6 deletions tower-http/src/catch_panic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions tower-http/src/decompression/request/future.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ where
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
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()
Expand All @@ -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();
Expand Down
16 changes: 11 additions & 5 deletions tower-http/src/services/fs/serve_dir/future.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ where
.map_ok(|response| {
response
.map(|body| {
UnsyncBoxBody::new(
UnsyncBoxBody::from_inner(
body.map_err(|err| match err.into().downcast::<io::Error>() {
Ok(err) => *err,
Err(err) => io::Error::new(io::ErrorKind::Other, err),
Expand Down Expand Up @@ -240,6 +240,12 @@ fn build_response(output: FileOpened) -> Response<ResponseBody> {
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());
}
Expand All @@ -258,7 +264,7 @@ fn build_response(output: FileOpened) -> Response<ResponseBody> {
} 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,
Expand Down Expand Up @@ -306,7 +312,7 @@ fn build_response(output: FileOpened) -> Response<ResponseBody> {
// 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 {
Expand All @@ -323,10 +329,10 @@ fn build_response(output: FileOpened) -> Response<ResponseBody> {

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))
}
30 changes: 29 additions & 1 deletion tower-http/src/services/fs/serve_dir/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ impl ServeDir<DefaultServeDirFallback> {
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,
Expand Down Expand Up @@ -107,6 +108,7 @@ impl<F> ServeDir<F> {
match &mut self.variant {
ServeVariant::Directory {
append_index_html_on_directories,
..
} => {
*append_index_html_on_directories = append;
self
Expand All @@ -115,6 +117,22 @@ impl<F> ServeDir<F> {
}
}

/// 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.
Expand Down Expand Up @@ -366,6 +384,7 @@ impl<F> ServeDir<F> {
.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(),
Expand All @@ -381,6 +400,7 @@ impl<F> ServeDir<F> {
negotiated_encodings,
range_header,
buf_chunk_size,
precompression_configured,
));

ResponseFuture::open_file_future(open_file_future, fallback_and_request)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -445,6 +465,7 @@ opaque_future! {
enum ServeVariant {
Directory {
append_index_html_on_directories: bool,
html_as_default_extension: bool,
},
SingleFile {
mime: HeaderValue,
Expand All @@ -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('/');

Expand Down Expand Up @@ -613,6 +635,12 @@ opaque_body! {
pub type ResponseBody = UnsyncBoxBody<Bytes, io::Error>;
}

impl From<ResponseBody> for UnsyncBoxBody<Bytes, io::Error> {
fn from(body: ResponseBody) -> Self {
body.inner
}
}

/// The default fallback service used with [`ServeDir`].
#[derive(Debug, Clone, Copy)]
pub struct DefaultServeDirFallback(Infallible);
Expand Down
22 changes: 18 additions & 4 deletions tower-http/src/services/fs/serve_dir/open_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub(super) struct FileOpened {
pub(super) maybe_encoding: Option<Encoding>,
pub(super) maybe_range: Option<Result<Vec<RangeInclusive<u64>>, RangeUnsatisfiableError>>,
pub(super) last_modified: Option<LastModified>,
pub(super) precompression_configured: bool,
}

pub(super) enum FileRequestExtent {
Expand All @@ -47,6 +48,7 @@ pub(super) async fn open_file(
negotiated_encodings: Vec<(Encoding, QValue)>,
range_header: Option<String>,
buf_chunk_size: usize,
precompression_configured: bool,
) -> io::Result<OpenFileOutput> {
let if_unmodified_since = req
.headers()
Expand All @@ -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
Expand All @@ -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
{
Expand Down Expand Up @@ -108,6 +112,7 @@ pub(super) async fn open_file(
maybe_encoding,
maybe_range,
last_modified,
precompression_configured,
})))
} else {
let (mut file, maybe_encoding) =
Expand Down Expand Up @@ -147,6 +152,7 @@ pub(super) async fn open_file(
maybe_encoding,
maybe_range,
last_modified,
precompression_configured,
})))
}
}
Expand Down Expand Up @@ -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<OpenFileOutput> {
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;
}

Expand Down Expand Up @@ -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<bool> {
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<Uri, OpenFileOutput> {
Expand Down
Loading
Loading