This commit is contained in:
Anton Lazarev 2023-07-16 17:51:28 +02:00 committed by GitHub
commit b67b5b2e53
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 274 additions and 56 deletions

View File

@ -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

View File

@ -50,6 +50,7 @@ pub struct Files {
use_guards: Option<Rc<dyn Guard>>,
guards: Vec<Rc<dyn Guard>>,
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<ServiceRequest> 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() {

View File

@ -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"))]

View File

@ -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<Rc<dyn Guard>>,
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<ServiceRequest> 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<ServiceRequest> 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::<actix_web::http::header::AcceptEncoding>();
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
}

View File

@ -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()

Binary file not shown.

Binary file not shown.