diff --git a/actix-files/CHANGES.md b/actix-files/CHANGES.md index 4e45ce517..336255809 100644 --- a/actix-files/CHANGES.md +++ b/actix-files/CHANGES.md @@ -7,10 +7,13 @@ ## 0.6.3 - 2023-01-21 - XHTML files now use `Content-Disposition: inline` instead of `attachment`. [#2903] +- Add `Files::try_compressed()` to support serving pre-compressed static files [#2615] - Minimum supported Rust version (MSRV) is now 1.59 due to transitive `time` dependency. - Update `tokio-uring` dependency to `0.4`. [#2903]: https://github.com/actix/actix-web/pull/2903 +[#2615]: https://github.com/actix/actix-web/pull/2615 + ## 0.6.2 - 2022-07-23 diff --git a/actix-files/src/files.rs b/actix-files/src/files.rs index 4a259d233..eaa152b90 100644 --- a/actix-files/src/files.rs +++ b/actix-files/src/files.rs @@ -50,6 +50,7 @@ pub struct Files { use_guards: Option>, guards: Vec>, hidden_files: bool, + try_compressed: bool, } impl fmt::Debug for Files { @@ -74,6 +75,7 @@ impl Clone for Files { use_guards: self.use_guards.clone(), guards: self.guards.clone(), hidden_files: self.hidden_files, + try_compressed: self.try_compressed, } } } @@ -120,6 +122,7 @@ impl Files { use_guards: None, guards: Vec::new(), hidden_files: false, + try_compressed: false, } } @@ -321,6 +324,15 @@ impl Files { self.hidden_files = true; self } + + /// Attempts to search for a suitable pre-compressed version of a file on disk before falling + /// back to the uncompressed version. + /// + /// Currently, `.gz`, `.br`, and `.zst` files are supported. + pub fn try_compressed(mut self) -> Self { + self.try_compressed = true; + self + } } impl HttpServiceFactory for Files { @@ -372,6 +384,7 @@ impl ServiceFactory for Files { file_flags: self.file_flags, guards: self.use_guards.clone(), hidden_files: self.hidden_files, + try_compressed: self.try_compressed, }; if let Some(ref default) = *self.default.borrow() { diff --git a/actix-files/src/named.rs b/actix-files/src/named.rs index c10bc00ed..334bd2645 100644 --- a/actix-files/src/named.rs +++ b/actix-files/src/named.rs @@ -90,6 +90,49 @@ pub(crate) use tokio_uring::fs::File; use super::chunked; +pub(crate) fn get_content_type_and_disposition( + path: &Path, +) -> Result<(mime::Mime, ContentDisposition), io::Error> { + let filename = match path.file_name() { + Some(name) => name.to_string_lossy(), + None => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Provided path has no filename", + )); + } + }; + + let ct = from_path(&path).first_or_octet_stream(); + + let disposition = match ct.type_() { + mime::IMAGE | mime::TEXT | mime::VIDEO => DispositionType::Inline, + mime::APPLICATION => match ct.subtype() { + mime::JAVASCRIPT | mime::JSON => DispositionType::Inline, + name if name == "wasm" || name == "xhtml" => DispositionType::Inline, + _ => DispositionType::Attachment, + }, + _ => DispositionType::Attachment, + }; + + let mut parameters = vec![DispositionParam::Filename(String::from(filename.as_ref()))]; + + if !filename.is_ascii() { + parameters.push(DispositionParam::FilenameExt(ExtendedValue { + charset: Charset::Ext(String::from("UTF-8")), + language_tag: None, + value: filename.into_owned().into_bytes(), + })) + } + + let cd = ContentDisposition { + disposition, + parameters, + }; + + Ok((ct, cd)) +} + impl NamedFile { /// Creates an instance from a previously opened file. /// @@ -116,47 +159,7 @@ impl NamedFile { // Get the name of the file and use it to construct default Content-Type // and Content-Disposition values - let (content_type, content_disposition) = { - let filename = match path.file_name() { - Some(name) => name.to_string_lossy(), - None => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "Provided path has no filename", - )); - } - }; - - let ct = from_path(&path).first_or_octet_stream(); - - let disposition = match ct.type_() { - mime::IMAGE | mime::TEXT | mime::AUDIO | mime::VIDEO => DispositionType::Inline, - mime::APPLICATION => match ct.subtype() { - mime::JAVASCRIPT | mime::JSON => DispositionType::Inline, - name if name == "wasm" || name == "xhtml" => DispositionType::Inline, - _ => DispositionType::Attachment, - }, - _ => DispositionType::Attachment, - }; - - let mut parameters = - vec![DispositionParam::Filename(String::from(filename.as_ref()))]; - - if !filename.is_ascii() { - parameters.push(DispositionParam::FilenameExt(ExtendedValue { - charset: Charset::Ext(String::from("UTF-8")), - language_tag: None, - value: filename.into_owned().into_bytes(), - })) - } - - let cd = ContentDisposition { - disposition, - parameters, - }; - - (ct, cd) - }; + let (content_type, content_disposition) = get_content_type_and_disposition(&path)?; let md = { #[cfg(not(feature = "experimental-io-uring"))] diff --git a/actix-files/src/service.rs b/actix-files/src/service.rs index d94fd5850..d36198a04 100644 --- a/actix-files/src/service.rs +++ b/actix-files/src/service.rs @@ -1,4 +1,9 @@ -use std::{fmt, io, ops::Deref, path::PathBuf, rc::Rc}; +use std::{ + fmt, io, + ops::Deref, + path::{Path, PathBuf}, + rc::Rc, +}; use actix_web::{ body::BoxBody, @@ -39,6 +44,7 @@ pub struct FilesServiceInner { pub(crate) file_flags: named::Flags, pub(crate) guards: Option>, pub(crate) hidden_files: bool, + pub(crate) try_compressed: bool, } impl fmt::Debug for FilesServiceInner { @@ -62,10 +68,11 @@ impl FilesService { } } - fn serve_named_file( + fn serve_named_file_with_encoding( &self, req: ServiceRequest, mut named_file: NamedFile, + encoding: header::ContentEncoding, ) -> ServiceResponse { if let Some(ref mime_override) = self.mime_override { let new_disposition = mime_override(&named_file.content_type.type_()); @@ -74,10 +81,30 @@ impl FilesService { named_file.flags = self.file_flags; let (req, _) = req.into_parts(); - let res = named_file.into_response(&req); + let mut res = named_file.into_response(&req); + + let header_value = match encoding { + header::ContentEncoding::Brotli => Some("br"), + header::ContentEncoding::Gzip => Some("gzip"), + header::ContentEncoding::Zstd => Some("zstd"), + header::ContentEncoding::Identity => None, + // Only variants in SUPPORTED_PRECOMPRESSION_ENCODINGS can occur here + _ => unreachable!(), + }; + if let Some(header_value) = header_value { + res.headers_mut().insert( + actix_web::http::header::CONTENT_ENCODING, + actix_web::http::header::HeaderValue::from_static(header_value), + ); + } + ServiceResponse::new(req, res) } + fn serve_named_file(&self, req: ServiceRequest, named_file: NamedFile) -> ServiceResponse { + self.serve_named_file_with_encoding(req, named_file, header::ContentEncoding::Identity) + } + fn show_index(&self, req: ServiceRequest, path: PathBuf) -> ServiceResponse { let dir = Directory::new(self.directory.clone(), path); @@ -161,6 +188,16 @@ impl Service for FilesService { match this.index { Some(ref index) => { let named_path = path.join(index); + if this.try_compressed { + if let Some((named_file, encoding)) = + find_compressed(&req, &named_path).await + { + return Ok(this.serve_named_file_with_encoding( + req, named_file, encoding, + )); + } + } + // fallback to the uncompressed version match NamedFile::open_async(named_path).await { Ok(named_file) => Ok(this.serve_named_file(req, named_file)), Err(_) if this.show_index => Ok(this.show_index(req, path)), @@ -174,22 +211,95 @@ impl Service for FilesService { )), } } else { - match NamedFile::open_async(&path).await { - Ok(mut named_file) => { - if let Some(ref mime_override) = this.mime_override { - let new_disposition = - mime_override(&named_file.content_type.type_()); - named_file.content_disposition.disposition = new_disposition; - } - named_file.flags = this.file_flags; - - let (req, _) = req.into_parts(); - let res = named_file.into_response(&req); - Ok(ServiceResponse::new(req, res)) + if this.try_compressed { + if let Some((named_file, encoding)) = find_compressed(&req, &path).await { + return Ok( + this.serve_named_file_with_encoding(req, named_file, encoding) + ); } + } + // fallback to the uncompressed version + match NamedFile::open_async(&path).await { + Ok(named_file) => Ok(this.serve_named_file(req, named_file)), Err(err) => this.handle_err(err, req).await, } } }) } } + +/// Flate doesn't have an accepted file extension, so it is not included here. +const SUPPORTED_PRECOMPRESSION_ENCODINGS: &[header::ContentEncoding] = &[ + header::ContentEncoding::Brotli, + header::ContentEncoding::Gzip, + header::ContentEncoding::Zstd, + header::ContentEncoding::Identity, +]; + +/// Searches disk for an acceptable alternate encoding of the content at the given path, as +/// preferred by the request's `Accept-Encoding` header. Returns the corresponding `NamedFile` with +/// the most appropriate supported encoding, if any exist. +async fn find_compressed( + req: &ServiceRequest, + original_path: &Path, +) -> Option<(NamedFile, header::ContentEncoding)> { + use actix_web::HttpMessage; + use header::{ContentEncoding, Encoding, Preference}; + + // Retrieve the content type and content disposition based on the original filename. If we + // can't get these successfully, don't even try to find a compressed file. + let (content_type, content_disposition) = + match crate::named::get_content_type_and_disposition(original_path) { + Ok(values) => values, + Err(_) => return None, + }; + + let accept_encoding = req.get_header::(); + let ranked_encodings = accept_encoding + .map(|encodings| encodings.ranked()) + .unwrap_or_else(Vec::new); + + let ranked_encodings_iter = ranked_encodings + .into_iter() + .filter_map(|e| match e { + Preference::Any | Preference::Specific(Encoding::Unknown(_)) => None, + Preference::Specific(Encoding::Known(encoding)) => Some(encoding), + }) + .filter_map(|encoding| { + if !SUPPORTED_PRECOMPRESSION_ENCODINGS.contains(&encoding) { + return None; + } + let extension = match encoding { + ContentEncoding::Brotli => Some(".br"), + ContentEncoding::Gzip => Some(".gz"), + ContentEncoding::Zstd => Some(".zst"), + ContentEncoding::Identity => None, + // Only variants in SUPPORTED_PRECOMPRESSION_ENCODINGS can occur here + _ => unreachable!(), + }; + let path = match extension { + Some(extension) => { + let mut compressed_path = original_path.to_owned(); + let filename = compressed_path + .file_name() + .unwrap() + .to_str() + .unwrap() + .to_string(); + compressed_path.set_file_name(filename + extension); + compressed_path + } + None => original_path.to_owned(), + }; + Some((path, encoding)) + }); + for (path, encoding) in ranked_encodings_iter { + // Ignore errors while searching disk for a suitable encoding + if let Ok(mut named_file) = NamedFile::open_async(&path).await { + named_file.content_type = content_type; + named_file.content_disposition = content_disposition; + return Some((named_file, encoding)); + } + } + None +} diff --git a/actix-files/tests/encoding.rs b/actix-files/tests/encoding.rs index 7aec25ff9..3e772b711 100644 --- a/actix-files/tests/encoding.rs +++ b/actix-files/tests/encoding.rs @@ -38,6 +38,95 @@ async fn test_utf8_file_contents() { } #[actix_web::test] +async fn test_compression_encodings() { + use actix_web::body::MessageBody; + + let srv = + test::init_service(App::new().service(Files::new("/", "./tests").try_compressed())) + .await; + + // Select the requested encoding when present + let mut req = TestRequest::with_uri("/utf8.txt").to_request(); + req.headers_mut().insert( + header::ACCEPT_ENCODING, + header::HeaderValue::from_static("gzip"), + ); + let res = test::call_service(&srv, req).await; + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers().get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static("text/plain; charset=utf-8")), + ); + assert_eq!( + res.headers().get(header::CONTENT_ENCODING), + Some(&HeaderValue::from_static("gzip")), + ); + assert_eq!(res.into_body().size(), actix_web::body::BodySize::Sized(76),); + + // Select the highest priority encoding + let mut req = TestRequest::with_uri("/utf8.txt").to_request(); + req.headers_mut().insert( + header::ACCEPT_ENCODING, + header::HeaderValue::from_static("gz;q=0.6,br;q=0.8,*"), + ); + let res = test::call_service(&srv, req).await; + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers().get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static("text/plain; charset=utf-8")), + ); + assert_eq!( + res.headers().get(header::CONTENT_ENCODING), + Some(&HeaderValue::from_static("br")), + ); + assert_eq!(res.into_body().size(), actix_web::body::BodySize::Sized(49),); + + // Request encoding that doesn't exist on disk and fallback to no encoding + let mut req = TestRequest::with_uri("/utf8.txt").to_request(); + req.headers_mut().insert( + header::ACCEPT_ENCODING, + header::HeaderValue::from_static("zstd"), + ); + let res = test::call_service(&srv, req).await; + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers().get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static("text/plain; charset=utf-8")), + ); + assert_eq!(res.headers().get(header::CONTENT_ENCODING), None,); + + // Can still request a compressed file directly + let req = TestRequest::with_uri("/utf8.txt.gz").to_request(); + let res = test::call_service(&srv, req).await; + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers().get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static("application/x-gzip")), + ); + assert_eq!(res.headers().get(header::CONTENT_ENCODING), None,); + + // Don't try compressed files + let srv = test::init_service(App::new().service(Files::new("/", "./tests"))).await; + + let mut req = TestRequest::with_uri("/utf8.txt").to_request(); + req.headers_mut().insert( + header::ACCEPT_ENCODING, + header::HeaderValue::from_static("gzip"), + ); + let res = test::call_service(&srv, req).await; + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers().get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static("text/plain; charset=utf-8")), + ); + assert_eq!(res.headers().get(header::CONTENT_ENCODING), None); +} + async fn partial_range_response_encoding() { let srv = test::init_service(App::new().default_service(web::to(|| async { NamedFile::open_async("./tests/test.binary").await.unwrap() diff --git a/actix-files/tests/utf8.txt.br b/actix-files/tests/utf8.txt.br new file mode 100644 index 000000000..c06efd6c9 Binary files /dev/null and b/actix-files/tests/utf8.txt.br differ diff --git a/actix-files/tests/utf8.txt.gz b/actix-files/tests/utf8.txt.gz new file mode 100644 index 000000000..3fbf02264 Binary files /dev/null and b/actix-files/tests/utf8.txt.gz differ