mirror of https://github.com/fafhrd91/actix-web
support serving pre-compressed files for static sites
This commit is contained in:
parent
a9f497d05f
commit
0e4d3b5475
|
@ -1,7 +1,9 @@
|
||||||
# Changes
|
# Changes
|
||||||
|
|
||||||
## Unreleased - 2021-xx-xx
|
## Unreleased - 2021-xx-xx
|
||||||
|
- Added `Files::try_compressed()` to support serving pre-compressed static files [#2615]
|
||||||
|
|
||||||
|
[#2615]: https://github.com/actix/actix-web/pull/2615
|
||||||
|
|
||||||
## 0.6.0-beta.15 - 2022-01-21
|
## 0.6.0-beta.15 - 2022-01-21
|
||||||
- No significant changes since `0.6.0-beta.14`.
|
- No significant changes since `0.6.0-beta.14`.
|
||||||
|
|
|
@ -50,6 +50,7 @@ pub struct Files {
|
||||||
use_guards: Option<Rc<dyn Guard>>,
|
use_guards: Option<Rc<dyn Guard>>,
|
||||||
guards: Vec<Rc<dyn Guard>>,
|
guards: Vec<Rc<dyn Guard>>,
|
||||||
hidden_files: bool,
|
hidden_files: bool,
|
||||||
|
try_compressed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for Files {
|
impl fmt::Debug for Files {
|
||||||
|
@ -74,6 +75,7 @@ impl Clone for Files {
|
||||||
use_guards: self.use_guards.clone(),
|
use_guards: self.use_guards.clone(),
|
||||||
guards: self.guards.clone(),
|
guards: self.guards.clone(),
|
||||||
hidden_files: self.hidden_files,
|
hidden_files: self.hidden_files,
|
||||||
|
try_compressed: self.try_compressed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -120,6 +122,7 @@ impl Files {
|
||||||
use_guards: None,
|
use_guards: None,
|
||||||
guards: Vec::new(),
|
guards: Vec::new(),
|
||||||
hidden_files: false,
|
hidden_files: false,
|
||||||
|
try_compressed: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -321,6 +324,15 @@ impl Files {
|
||||||
self.hidden_files = true;
|
self.hidden_files = true;
|
||||||
self
|
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 {
|
impl HttpServiceFactory for Files {
|
||||||
|
@ -372,6 +384,7 @@ impl ServiceFactory<ServiceRequest> for Files {
|
||||||
file_flags: self.file_flags,
|
file_flags: self.file_flags,
|
||||||
guards: self.use_guards.clone(),
|
guards: self.use_guards.clone(),
|
||||||
hidden_files: self.hidden_files,
|
hidden_files: self.hidden_files,
|
||||||
|
try_compressed: self.try_compressed,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(ref default) = *self.default.borrow() {
|
if let Some(ref default) = *self.default.borrow() {
|
||||||
|
|
|
@ -88,6 +88,49 @@ pub(crate) use tokio_uring::fs::File;
|
||||||
|
|
||||||
use super::chunked;
|
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" => 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 {
|
impl NamedFile {
|
||||||
/// Creates an instance from a previously opened file.
|
/// Creates an instance from a previously opened file.
|
||||||
///
|
///
|
||||||
|
@ -114,47 +157,7 @@ impl NamedFile {
|
||||||
|
|
||||||
// Get the name of the file and use it to construct default Content-Type
|
// Get the name of the file and use it to construct default Content-Type
|
||||||
// and Content-Disposition values
|
// and Content-Disposition values
|
||||||
let (content_type, content_disposition) = {
|
let (content_type, content_disposition) = get_content_type_and_disposition(&path)?;
|
||||||
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" => 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 md = {
|
let md = {
|
||||||
#[cfg(not(feature = "experimental-io-uring"))]
|
#[cfg(not(feature = "experimental-io-uring"))]
|
||||||
|
|
|
@ -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::{
|
use actix_web::{
|
||||||
body::BoxBody,
|
body::BoxBody,
|
||||||
|
@ -39,6 +44,7 @@ pub struct FilesServiceInner {
|
||||||
pub(crate) file_flags: named::Flags,
|
pub(crate) file_flags: named::Flags,
|
||||||
pub(crate) guards: Option<Rc<dyn Guard>>,
|
pub(crate) guards: Option<Rc<dyn Guard>>,
|
||||||
pub(crate) hidden_files: bool,
|
pub(crate) hidden_files: bool,
|
||||||
|
pub(crate) try_compressed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for FilesServiceInner {
|
impl fmt::Debug for FilesServiceInner {
|
||||||
|
@ -62,10 +68,11 @@ impl FilesService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn serve_named_file(
|
fn serve_named_file_with_encoding(
|
||||||
&self,
|
&self,
|
||||||
req: ServiceRequest,
|
req: ServiceRequest,
|
||||||
mut named_file: NamedFile,
|
mut named_file: NamedFile,
|
||||||
|
encoding: header::ContentEncoding,
|
||||||
) -> ServiceResponse {
|
) -> ServiceResponse {
|
||||||
if let Some(ref mime_override) = self.mime_override {
|
if let Some(ref mime_override) = self.mime_override {
|
||||||
let new_disposition = mime_override(&named_file.content_type.type_());
|
let new_disposition = mime_override(&named_file.content_type.type_());
|
||||||
|
@ -74,10 +81,30 @@ impl FilesService {
|
||||||
named_file.flags = self.file_flags;
|
named_file.flags = self.file_flags;
|
||||||
|
|
||||||
let (req, _) = req.into_parts();
|
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)
|
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 {
|
fn show_index(&self, req: ServiceRequest, path: PathBuf) -> ServiceResponse {
|
||||||
let dir = Directory::new(self.directory.clone(), path);
|
let dir = Directory::new(self.directory.clone(), path);
|
||||||
|
|
||||||
|
@ -161,6 +188,16 @@ impl Service<ServiceRequest> for FilesService {
|
||||||
match this.index {
|
match this.index {
|
||||||
Some(ref index) => {
|
Some(ref index) => {
|
||||||
let named_path = path.join(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 {
|
match NamedFile::open_async(named_path).await {
|
||||||
Ok(named_file) => Ok(this.serve_named_file(req, named_file)),
|
Ok(named_file) => Ok(this.serve_named_file(req, named_file)),
|
||||||
Err(_) if this.show_index => Ok(this.show_index(req, path)),
|
Err(_) if this.show_index => Ok(this.show_index(req, path)),
|
||||||
|
@ -174,22 +211,95 @@ impl Service<ServiceRequest> for FilesService {
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
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 {
|
match NamedFile::open_async(&path).await {
|
||||||
Ok(mut named_file) => {
|
Ok(named_file) => Ok(this.serve_named_file(req, 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))
|
|
||||||
}
|
|
||||||
Err(err) => this.handle_err(err, req).await,
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -36,3 +36,93 @@ async fn test_utf8_file_contents() {
|
||||||
Some(&HeaderValue::from_static("text/plain")),
|
Some(&HeaderValue::from_static("text/plain")),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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,);
|
||||||
|
}
|
||||||
|
|
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue