mirror of https://github.com/fafhrd91/actix-web
Merge branch 'main' into master
This commit is contained in:
commit
3bf8b47ec4
|
|
@ -49,7 +49,7 @@ jobs:
|
||||||
toolchain: ${{ matrix.version.version }}
|
toolchain: ${{ matrix.version.version }}
|
||||||
|
|
||||||
- name: Install just, cargo-hack, cargo-nextest, cargo-ci-cache-clean
|
- name: Install just, cargo-hack, cargo-nextest, cargo-ci-cache-clean
|
||||||
uses: taiki-e/install-action@650c5ca14212efbbf3e580844b04bdccf68dac31 # v2.67.18
|
uses: taiki-e/install-action@f176c07a0a40cbfdd08ee9aa8bf1655701d11e69 # v2.67.25
|
||||||
with:
|
with:
|
||||||
tool: just,cargo-hack,cargo-nextest,cargo-ci-cache-clean
|
tool: just,cargo-hack,cargo-nextest,cargo-ci-cache-clean
|
||||||
|
|
||||||
|
|
@ -83,7 +83,7 @@ jobs:
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1.15.2
|
uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1.15.2
|
||||||
|
|
||||||
- name: Install just, cargo-hack
|
- name: Install just, cargo-hack
|
||||||
uses: taiki-e/install-action@650c5ca14212efbbf3e580844b04bdccf68dac31 # v2.67.18
|
uses: taiki-e/install-action@f176c07a0a40cbfdd08ee9aa8bf1655701d11e69 # v2.67.25
|
||||||
with:
|
with:
|
||||||
tool: just,cargo-hack
|
tool: just,cargo-hack
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ jobs:
|
||||||
toolchain: ${{ matrix.version.version }}
|
toolchain: ${{ matrix.version.version }}
|
||||||
|
|
||||||
- name: Install just, cargo-hack, cargo-nextest, cargo-ci-cache-clean
|
- name: Install just, cargo-hack, cargo-nextest, cargo-ci-cache-clean
|
||||||
uses: taiki-e/install-action@650c5ca14212efbbf3e580844b04bdccf68dac31 # v2.67.18
|
uses: taiki-e/install-action@f176c07a0a40cbfdd08ee9aa8bf1655701d11e69 # v2.67.25
|
||||||
with:
|
with:
|
||||||
tool: just,cargo-hack,cargo-nextest,cargo-ci-cache-clean
|
tool: just,cargo-hack,cargo-nextest,cargo-ci-cache-clean
|
||||||
|
|
||||||
|
|
@ -117,7 +117,7 @@ jobs:
|
||||||
toolchain: nightly
|
toolchain: nightly
|
||||||
|
|
||||||
- name: Install just
|
- name: Install just
|
||||||
uses: taiki-e/install-action@650c5ca14212efbbf3e580844b04bdccf68dac31 # v2.67.18
|
uses: taiki-e/install-action@f176c07a0a40cbfdd08ee9aa8bf1655701d11e69 # v2.67.25
|
||||||
with:
|
with:
|
||||||
tool: just
|
tool: just
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ jobs:
|
||||||
components: llvm-tools
|
components: llvm-tools
|
||||||
|
|
||||||
- name: Install just, cargo-llvm-cov, cargo-nextest
|
- name: Install just, cargo-llvm-cov, cargo-nextest
|
||||||
uses: taiki-e/install-action@650c5ca14212efbbf3e580844b04bdccf68dac31 # v2.67.18
|
uses: taiki-e/install-action@f176c07a0a40cbfdd08ee9aa8bf1655701d11e69 # v2.67.25
|
||||||
with:
|
with:
|
||||||
tool: just,cargo-llvm-cov,cargo-nextest
|
tool: just,cargo-llvm-cov,cargo-nextest
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ jobs:
|
||||||
toolchain: ${{ vars.RUST_VERSION_EXTERNAL_TYPES }}
|
toolchain: ${{ vars.RUST_VERSION_EXTERNAL_TYPES }}
|
||||||
|
|
||||||
- name: Install just
|
- name: Install just
|
||||||
uses: taiki-e/install-action@650c5ca14212efbbf3e580844b04bdccf68dac31 # v2.67.18
|
uses: taiki-e/install-action@f176c07a0a40cbfdd08ee9aa8bf1655701d11e69 # v2.67.25
|
||||||
with:
|
with:
|
||||||
tool: just
|
tool: just
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1903,9 +1903,9 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.7.6"
|
version = "2.8.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mime"
|
name = "mime"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- Add `Files::try_compressed()` to support serving pre-compressed static files [#2615]
|
||||||
|
- Fix handling of `bytes=0-`
|
||||||
|
|
||||||
|
[#2615]: https://github.com/actix/actix-web/pull/2615
|
||||||
|
|
||||||
## 0.6.10
|
## 0.6.10
|
||||||
|
|
||||||
### Security Notice
|
### Security Notice
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
read_mode_threshold: u64,
|
read_mode_threshold: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,6 +77,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,
|
||||||
read_mode_threshold: self.read_mode_threshold,
|
read_mode_threshold: self.read_mode_threshold,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -128,6 +130,7 @@ impl Files {
|
||||||
use_guards: None,
|
use_guards: None,
|
||||||
guards: Vec::new(),
|
guards: Vec::new(),
|
||||||
hidden_files: false,
|
hidden_files: false,
|
||||||
|
try_compressed: false,
|
||||||
read_mode_threshold: 0,
|
read_mode_threshold: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -351,6 +354,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 {
|
||||||
|
|
@ -402,6 +414,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,
|
||||||
size_threshold: self.read_mode_threshold,
|
size_threshold: self.read_mode_threshold,
|
||||||
with_permanent_redirect: self.with_permanent_redirect,
|
with_permanent_redirect: self.with_permanent_redirect,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -513,6 +513,30 @@ mod tests {
|
||||||
assert_eq!(content_range.to_str().unwrap(), "bytes */100");
|
assert_eq!(content_range.to_str().unwrap(), "bytes */100");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[actix_rt::test]
|
||||||
|
async fn test_named_file_range_header_from_zero_to_end_returns_partial_content() {
|
||||||
|
let srv = actix_test::start(|| App::new().service(Files::new("/", ".")));
|
||||||
|
|
||||||
|
let response = srv
|
||||||
|
.get("/tests/test.binary")
|
||||||
|
.insert_header((header::RANGE, "bytes=0-"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
|
||||||
|
|
||||||
|
let content_range = response.headers().get(header::CONTENT_RANGE).unwrap();
|
||||||
|
assert_eq!(content_range.to_str().unwrap(), "bytes 0-99/100");
|
||||||
|
|
||||||
|
let content_length = response.headers().get(header::CONTENT_LENGTH).unwrap();
|
||||||
|
assert_eq!(content_length.to_str().unwrap(), "100");
|
||||||
|
|
||||||
|
// Should be no transfer-encoding
|
||||||
|
let transfer_encoding = response.headers().get(header::TRANSFER_ENCODING);
|
||||||
|
assert!(transfer_encoding.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[actix_rt::test]
|
#[actix_rt::test]
|
||||||
async fn test_named_file_content_length_headers() {
|
async fn test_named_file_content_length_headers() {
|
||||||
let srv = actix_test::start(|| App::new().service(Files::new("/", ".")));
|
let srv = actix_test::start(|| App::new().service(Files::new("/", ".")));
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,55 @@ 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 = mime_guess::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,
|
||||||
|
};
|
||||||
|
|
||||||
|
// replace special characters in filenames which could occur on some filesystems
|
||||||
|
let filename_s = filename
|
||||||
|
.replace('\n', "%0A") // \n line break
|
||||||
|
.replace('\x0B', "%0B") // \v vertical tab
|
||||||
|
.replace('\x0C', "%0C") // \f form feed
|
||||||
|
.replace('\r', "%0D"); // \r carriage return
|
||||||
|
let mut parameters = vec![DispositionParam::Filename(filename_s)];
|
||||||
|
|
||||||
|
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.
|
||||||
///
|
///
|
||||||
|
|
@ -117,52 +166,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 = mime_guess::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,
|
|
||||||
};
|
|
||||||
|
|
||||||
// replace special characters in filenames which could occur on some filesystems
|
|
||||||
let filename_s = filename
|
|
||||||
.replace('\n', "%0A") // \n line break
|
|
||||||
.replace('\x0B', "%0B") // \v vertical tab
|
|
||||||
.replace('\x0C', "%0C") // \f form feed
|
|
||||||
.replace('\r', "%0D"); // \r carriage return
|
|
||||||
let mut parameters = vec![DispositionParam::Filename(filename_s)];
|
|
||||||
|
|
||||||
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"))]
|
||||||
|
|
@ -546,6 +550,7 @@ impl NamedFile {
|
||||||
|
|
||||||
let mut length = self.md.len();
|
let mut length = self.md.len();
|
||||||
let mut offset = 0;
|
let mut offset = 0;
|
||||||
|
let mut ranged_req = false;
|
||||||
|
|
||||||
// check for range header
|
// check for range header
|
||||||
if let Some(ranges) = req.headers().get(header::RANGE) {
|
if let Some(ranges) = req.headers().get(header::RANGE) {
|
||||||
|
|
@ -554,6 +559,7 @@ impl NamedFile {
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|ranges| ranges.first().copied())
|
.and_then(|ranges| ranges.first().copied())
|
||||||
{
|
{
|
||||||
|
ranged_req = true;
|
||||||
length = range.length;
|
length = range.length;
|
||||||
offset = range.start;
|
offset = range.start;
|
||||||
|
|
||||||
|
|
@ -602,7 +608,7 @@ impl NamedFile {
|
||||||
|
|
||||||
let reader = chunked::new_chunked_read(length, offset, self.file, self.read_mode_threshold);
|
let reader = chunked::new_chunked_read(length, offset, self.file, self.read_mode_threshold);
|
||||||
|
|
||||||
if offset != 0 || length != self.md.len() {
|
if ranged_req {
|
||||||
res.status(StatusCode::PARTIAL_CONTENT);
|
res.status(StatusCode::PARTIAL_CONTENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -710,3 +716,14 @@ impl HttpServiceFactory for NamedFile {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_files_use_inline_content_disposition() {
|
||||||
|
let (_ct, cd) = get_content_type_and_disposition(Path::new("sound.mp3")).unwrap();
|
||||||
|
assert_eq!(cd.disposition, DispositionType::Inline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
pub(crate) size_threshold: u64,
|
pub(crate) size_threshold: u64,
|
||||||
pub(crate) with_permanent_redirect: bool,
|
pub(crate) with_permanent_redirect: bool,
|
||||||
}
|
}
|
||||||
|
|
@ -64,7 +70,12 @@ impl FilesService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn serve_named_file(&self, req: ServiceRequest, mut named_file: NamedFile) -> ServiceResponse {
|
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 {
|
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_());
|
||||||
named_file.content_disposition.disposition = new_disposition;
|
named_file.content_disposition.disposition = new_disposition;
|
||||||
|
|
@ -72,12 +83,36 @@ 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
|
let mut res = named_file
|
||||||
.read_mode_threshold(self.size_threshold)
|
.read_mode_threshold(self.size_threshold)
|
||||||
.into_response(&req);
|
.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(
|
||||||
|
header::CONTENT_ENCODING,
|
||||||
|
header::HeaderValue::from_static(header_value),
|
||||||
|
);
|
||||||
|
// Response representation varies by Accept-Encoding when serving pre-compressed assets.
|
||||||
|
res.headers_mut().append(
|
||||||
|
header::VARY,
|
||||||
|
header::HeaderValue::from_static("accept-encoding"),
|
||||||
|
);
|
||||||
|
}
|
||||||
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);
|
||||||
|
|
||||||
|
|
@ -138,6 +173,15 @@ impl Service<ServiceRequest> for FilesService {
|
||||||
|
|
||||||
// full file path
|
// full file path
|
||||||
let path = this.directory.join(&path_on_disk);
|
let path = this.directory.join(&path_on_disk);
|
||||||
|
|
||||||
|
// Try serving pre-compressed file even if the uncompressed file doesn't exist yet.
|
||||||
|
// Still handle directories (index/listing) through the normal branch below.
|
||||||
|
if this.try_compressed && !path.is_dir() {
|
||||||
|
if let Some((named_file, encoding)) = find_compressed(&req, &path).await {
|
||||||
|
return Ok(this.serve_named_file_with_encoding(req, named_file, encoding));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Err(err) = path.canonicalize() {
|
if let Err(err) = path.canonicalize() {
|
||||||
return this.handle_err(err, req).await;
|
return this.handle_err(err, req).await;
|
||||||
}
|
}
|
||||||
|
|
@ -163,6 +207,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)),
|
||||||
|
|
@ -184,3 +238,84 @@ impl Service<ServiceRequest> for FilesService {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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::{AcceptEncoding, ContentEncoding, Encoding};
|
||||||
|
|
||||||
|
// 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::<AcceptEncoding>()?;
|
||||||
|
|
||||||
|
let mut supported = SUPPORTED_PRECOMPRESSION_ENCODINGS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(Encoding::Known)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
// Only move the original content-type/disposition into the chosen compressed file once.
|
||||||
|
let mut content_type = Some(content_type);
|
||||||
|
let mut content_disposition = Some(content_disposition);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Select next acceptable encoding (honouring q=0 rejections) from remaining supported set.
|
||||||
|
let chosen = accept_encoding.negotiate(supported.iter())?;
|
||||||
|
|
||||||
|
let encoding = match chosen {
|
||||||
|
Encoding::Known(enc) => enc,
|
||||||
|
// No supported encoding should ever be unknown here.
|
||||||
|
Encoding::Unknown(_) => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Identity indicates there is no acceptable pre-compressed representation.
|
||||||
|
if encoding == ContentEncoding::Identity {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let extension = match encoding {
|
||||||
|
ContentEncoding::Brotli => ".br",
|
||||||
|
ContentEncoding::Gzip => ".gz",
|
||||||
|
ContentEncoding::Zstd => ".zst",
|
||||||
|
ContentEncoding::Identity => unreachable!(),
|
||||||
|
// Only variants in SUPPORTED_PRECOMPRESSION_ENCODINGS can occur here.
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut compressed_path = original_path.to_owned();
|
||||||
|
let mut filename = compressed_path.file_name()?.to_owned();
|
||||||
|
filename.push(extension);
|
||||||
|
compressed_path.set_file_name(filename);
|
||||||
|
|
||||||
|
match NamedFile::open_async(&compressed_path).await {
|
||||||
|
Ok(mut named_file) => {
|
||||||
|
named_file.content_type = content_type.take().unwrap();
|
||||||
|
named_file.content_disposition = content_disposition.take().unwrap();
|
||||||
|
return Some((named_file, encoding));
|
||||||
|
}
|
||||||
|
// Ignore errors while searching disk for a suitable encoding.
|
||||||
|
Err(_) => {
|
||||||
|
supported.retain(|enc| enc != &chosen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,136 @@ async fn test_utf8_file_contents() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn test_compression_encodings() {
|
||||||
|
use actix_web::body::MessageBody;
|
||||||
|
|
||||||
|
let utf8_txt_len = std::fs::metadata("./tests/utf8.txt").unwrap().len();
|
||||||
|
let utf8_txt_br_len = std::fs::metadata("./tests/utf8.txt.br").unwrap().len();
|
||||||
|
let utf8_txt_gz_len = std::fs::metadata("./tests/utf8.txt.gz").unwrap().len();
|
||||||
|
|
||||||
|
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.headers().get(header::VARY),
|
||||||
|
Some(&HeaderValue::from_static("accept-encoding")),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
res.into_body().size(),
|
||||||
|
actix_web::body::BodySize::Sized(utf8_txt_gz_len),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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("gzip;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.headers().get(header::VARY),
|
||||||
|
Some(&HeaderValue::from_static("accept-encoding")),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
res.into_body().size(),
|
||||||
|
actix_web::body::BodySize::Sized(utf8_txt_br_len),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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,);
|
||||||
|
assert_eq!(
|
||||||
|
res.into_body().size(),
|
||||||
|
actix_web::body::BodySize::Sized(utf8_txt_len),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Do not select an encoding explicitly refused via q=0
|
||||||
|
let mut req = TestRequest::with_uri("/utf8.txt").to_request();
|
||||||
|
req.headers_mut().insert(
|
||||||
|
header::ACCEPT_ENCODING,
|
||||||
|
header::HeaderValue::from_static("zstd;q=1, gzip;q=0"),
|
||||||
|
);
|
||||||
|
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,);
|
||||||
|
assert_eq!(
|
||||||
|
res.into_body().size(),
|
||||||
|
actix_web::body::BodySize::Sized(utf8_txt_len),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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/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);
|
||||||
|
}
|
||||||
|
|
||||||
#[actix_web::test]
|
#[actix_web::test]
|
||||||
async fn partial_range_response_encoding() {
|
async fn partial_range_response_encoding() {
|
||||||
let srv = test::init_service(App::new().default_service(web::to(|| async {
|
let srv = test::init_service(App::new().default_service(web::to(|| async {
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
- Minimum supported Rust version (MSRV) is now 1.88.
|
- Minimum supported Rust version (MSRV) is now 1.88.
|
||||||
- Add `HttpRequest::url_for_map` and `HttpRequest::url_for_iter` methods for named URL parameters. [#3895]
|
- Add `HttpRequest::url_for_map` and `HttpRequest::url_for_iter` methods for named URL parameters. [#3895]
|
||||||
|
- Ignore unparsable cookies in `Cookie` request header.
|
||||||
|
|
||||||
[#3895]: https://github.com/actix/actix-web/pull/3895
|
[#3895]: https://github.com/actix/actix-web/pull/3895
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -414,6 +414,9 @@ impl HttpRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load request cookies.
|
/// Load request cookies.
|
||||||
|
///
|
||||||
|
/// Any cookie that cannot be parsed is omitted from the result.
|
||||||
|
/// This includes cookies with an empty name (e.g. `document.cookie = "=value"`).
|
||||||
#[cfg(feature = "cookies")]
|
#[cfg(feature = "cookies")]
|
||||||
pub fn cookies(&self) -> Result<Ref<'_, Vec<Cookie<'static>>>, CookieParseError> {
|
pub fn cookies(&self) -> Result<Ref<'_, Vec<Cookie<'static>>>, CookieParseError> {
|
||||||
use actix_http::header::COOKIE;
|
use actix_http::header::COOKIE;
|
||||||
|
|
@ -422,9 +425,9 @@ impl HttpRequest {
|
||||||
let mut cookies = Vec::new();
|
let mut cookies = Vec::new();
|
||||||
for hdr in self.headers().get_all(COOKIE) {
|
for hdr in self.headers().get_all(COOKIE) {
|
||||||
let s = str::from_utf8(hdr.as_bytes()).map_err(CookieParseError::from)?;
|
let s = str::from_utf8(hdr.as_bytes()).map_err(CookieParseError::from)?;
|
||||||
for cookie_str in s.split(';').map(|s| s.trim()) {
|
for cookie_str in s.split(';').map(|s| s.trim()).filter(|s| !s.is_empty()) {
|
||||||
if !cookie_str.is_empty() {
|
if let Ok(cookie) = Cookie::parse_encoded(cookie_str) {
|
||||||
cookies.push(Cookie::parse_encoded(cookie_str)?.into_owned());
|
cookies.push(cookie.into_owned());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -677,6 +680,22 @@ mod tests {
|
||||||
assert!(cookie.is_none());
|
assert!(cookie.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(feature = "cookies")]
|
||||||
|
fn test_empty_key() {
|
||||||
|
let req = TestRequest::default()
|
||||||
|
.append_header((header::COOKIE, "cookie1=value1; value2; cookie3=value3"))
|
||||||
|
.to_http_request();
|
||||||
|
{
|
||||||
|
let cookies = req.cookies().unwrap();
|
||||||
|
assert_eq!(cookies.len(), 2);
|
||||||
|
assert_eq!(cookies[0].name(), "cookie1");
|
||||||
|
assert_eq!(cookies[0].value(), "value1");
|
||||||
|
assert_eq!(cookies[1].name(), "cookie3");
|
||||||
|
assert_eq!(cookies[1].value(), "value3");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_request_query() {
|
fn test_request_query() {
|
||||||
let req = TestRequest::with_uri("/?id=test").to_http_request();
|
let req = TestRequest::with_uri("/?id=test").to_http_request();
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
- Minimum supported Rust version (MSRV) is now 1.88.
|
- Minimum supported Rust version (MSRV) is now 1.88.
|
||||||
|
- Fix empty streaming request bodies being sent with chunked transfer encoding.
|
||||||
|
|
||||||
## 3.8.1
|
## 3.8.1
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,35 @@ where
|
||||||
B: MessageBody,
|
B: MessageBody,
|
||||||
B::Error: Into<BoxError>,
|
B::Error: Into<BoxError>,
|
||||||
{
|
{
|
||||||
|
actix_rt::pin!(body);
|
||||||
|
|
||||||
|
let orig_length = body.size();
|
||||||
|
let mut length = orig_length;
|
||||||
|
let mut first_chunk = None;
|
||||||
|
|
||||||
|
// This avoids sending `Transfer-Encoding: chunked` for requests with an empty body stream.
|
||||||
|
// https://github.com/actix/actix-web/issues/2320
|
||||||
|
if matches!(orig_length, BodySize::Stream) {
|
||||||
|
enum Peek<E> {
|
||||||
|
Pending,
|
||||||
|
Item(Result<Bytes, E>),
|
||||||
|
Eof,
|
||||||
|
}
|
||||||
|
|
||||||
|
match poll_fn(|cx| match body.as_mut().poll_next(cx) {
|
||||||
|
Poll::Pending => Poll::Ready(Peek::Pending),
|
||||||
|
Poll::Ready(Some(res)) => Poll::Ready(Peek::Item(res)),
|
||||||
|
Poll::Ready(None) => Poll::Ready(Peek::Eof),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Peek::Pending => {}
|
||||||
|
Peek::Eof => length = BodySize::Sized(0),
|
||||||
|
Peek::Item(Ok(chunk)) => first_chunk = Some(chunk),
|
||||||
|
Peek::Item(Err(err)) => return Err(SendRequestError::Body(err.into())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// set request host header
|
// set request host header
|
||||||
if !head.as_ref().headers.contains_key(HOST)
|
if !head.as_ref().headers.contains_key(HOST)
|
||||||
&& !head.extra_headers().iter().any(|h| h.contains_key(HOST))
|
&& !head.extra_headers().iter().any(|h| h.contains_key(HOST))
|
||||||
|
|
@ -67,7 +96,7 @@ where
|
||||||
// Check EXPECT header and enable expect handle flag accordingly.
|
// Check EXPECT header and enable expect handle flag accordingly.
|
||||||
// See https://datatracker.ietf.org/doc/html/rfc7231#section-5.1.1
|
// See https://datatracker.ietf.org/doc/html/rfc7231#section-5.1.1
|
||||||
let is_expect = if head.as_ref().headers.contains_key(EXPECT) {
|
let is_expect = if head.as_ref().headers.contains_key(EXPECT) {
|
||||||
match body.size() {
|
match orig_length {
|
||||||
BodySize::None | BodySize::Sized(0) => {
|
BodySize::None | BodySize::Sized(0) => {
|
||||||
let keep_alive = framed.codec_ref().keep_alive();
|
let keep_alive = framed.codec_ref().keep_alive();
|
||||||
framed.io_mut().on_release(keep_alive);
|
framed.io_mut().on_release(keep_alive);
|
||||||
|
|
@ -86,7 +115,7 @@ where
|
||||||
|
|
||||||
// special handle for EXPECT request.
|
// special handle for EXPECT request.
|
||||||
let (do_send, mut res_head) = if is_expect {
|
let (do_send, mut res_head) = if is_expect {
|
||||||
pin_framed.send((head, body.size()).into()).await?;
|
pin_framed.send((head, length).into()).await?;
|
||||||
|
|
||||||
let head = poll_fn(|cx| pin_framed.as_mut().poll_next(cx))
|
let head = poll_fn(|cx| pin_framed.as_mut().poll_next(cx))
|
||||||
.await
|
.await
|
||||||
|
|
@ -96,18 +125,18 @@ where
|
||||||
// and current head would be used as final response head.
|
// and current head would be used as final response head.
|
||||||
(head.status == StatusCode::CONTINUE, Some(head))
|
(head.status == StatusCode::CONTINUE, Some(head))
|
||||||
} else {
|
} else {
|
||||||
pin_framed.feed((head, body.size()).into()).await?;
|
pin_framed.feed((head, length).into()).await?;
|
||||||
|
|
||||||
(true, None)
|
(true, None)
|
||||||
};
|
};
|
||||||
|
|
||||||
if do_send {
|
if do_send {
|
||||||
// send request body
|
// send request body
|
||||||
match body.size() {
|
match length {
|
||||||
BodySize::None | BodySize::Sized(0) => {
|
BodySize::None | BodySize::Sized(0) => {
|
||||||
poll_fn(|cx| pin_framed.as_mut().flush(cx)).await?;
|
poll_fn(|cx| pin_framed.as_mut().flush(cx)).await?;
|
||||||
}
|
}
|
||||||
_ => send_body(body, pin_framed.as_mut()).await?,
|
_ => send_body(body.as_mut(), pin_framed.as_mut(), first_chunk).await?,
|
||||||
};
|
};
|
||||||
|
|
||||||
// read response and init read body
|
// read response and init read body
|
||||||
|
|
@ -157,15 +186,18 @@ where
|
||||||
|
|
||||||
/// send request body to the peer
|
/// send request body to the peer
|
||||||
pub(crate) async fn send_body<Io, B>(
|
pub(crate) async fn send_body<Io, B>(
|
||||||
body: B,
|
mut body: Pin<&mut B>,
|
||||||
mut framed: Pin<&mut Framed<Io, h1::ClientCodec>>,
|
mut framed: Pin<&mut Framed<Io, h1::ClientCodec>>,
|
||||||
|
first_chunk: Option<Bytes>,
|
||||||
) -> Result<(), SendRequestError>
|
) -> Result<(), SendRequestError>
|
||||||
where
|
where
|
||||||
Io: ConnectionIo,
|
Io: ConnectionIo,
|
||||||
B: MessageBody,
|
B: MessageBody,
|
||||||
B::Error: Into<BoxError>,
|
B::Error: Into<BoxError>,
|
||||||
{
|
{
|
||||||
actix_rt::pin!(body);
|
if let Some(chunk) = first_chunk {
|
||||||
|
framed.as_mut().write(h1::Message::Chunk(Some(chunk)))?;
|
||||||
|
}
|
||||||
|
|
||||||
let mut eof = false;
|
let mut eof = false;
|
||||||
while !eof {
|
while !eof {
|
||||||
|
|
|
||||||
|
|
@ -263,13 +263,9 @@ impl ClientRequest {
|
||||||
/// ```
|
/// ```
|
||||||
#[cfg(feature = "cookies")]
|
#[cfg(feature = "cookies")]
|
||||||
pub fn cookie(mut self, cookie: Cookie<'_>) -> Self {
|
pub fn cookie(mut self, cookie: Cookie<'_>) -> Self {
|
||||||
if self.cookies.is_none() {
|
self.cookies
|
||||||
let mut jar = CookieJar::new();
|
.get_or_insert_with(CookieJar::new)
|
||||||
jar.add(cookie.into_owned());
|
.add(cookie.into_owned());
|
||||||
self.cookies = Some(jar)
|
|
||||||
} else {
|
|
||||||
self.cookies.as_mut().unwrap().add(cookie.into_owned());
|
|
||||||
}
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -125,13 +125,9 @@ impl WebsocketsRequest {
|
||||||
/// Set a cookie
|
/// Set a cookie
|
||||||
#[cfg(feature = "cookies")]
|
#[cfg(feature = "cookies")]
|
||||||
pub fn cookie(mut self, cookie: Cookie<'_>) -> Self {
|
pub fn cookie(mut self, cookie: Cookie<'_>) -> Self {
|
||||||
if self.cookies.is_none() {
|
self.cookies
|
||||||
let mut jar = CookieJar::new();
|
.get_or_insert_with(CookieJar::new)
|
||||||
jar.add(cookie.into_owned());
|
.add(cookie.into_owned());
|
||||||
self.cookies = Some(jar)
|
|
||||||
} else {
|
|
||||||
self.cookies.as_mut().unwrap().add(cookie.into_owned());
|
|
||||||
}
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
use std::{convert::Infallible, time::Duration};
|
||||||
|
|
||||||
|
use actix_rt::net::TcpListener;
|
||||||
|
use awc::Client;
|
||||||
|
use bytes::Bytes;
|
||||||
|
use futures_util::stream;
|
||||||
|
use tokio::{
|
||||||
|
io::{AsyncReadExt as _, AsyncWriteExt as _},
|
||||||
|
time::timeout,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[actix_rt::test]
|
||||||
|
async fn empty_body_stream_does_not_use_chunked_encoding() {
|
||||||
|
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
|
||||||
|
// Minimal HTTP/1.1 server that rejects chunked requests.
|
||||||
|
let srv = actix_rt::spawn(async move {
|
||||||
|
let (mut sock, _) = listener.accept().await.unwrap();
|
||||||
|
|
||||||
|
let mut buf = Vec::with_capacity(1024);
|
||||||
|
let mut tmp = [0u8; 1024];
|
||||||
|
|
||||||
|
let header_end = loop {
|
||||||
|
let n = timeout(Duration::from_secs(2), sock.read(&mut tmp))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
if n == 0 {
|
||||||
|
break None;
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.extend_from_slice(&tmp[..n]);
|
||||||
|
|
||||||
|
if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
|
||||||
|
break Some(pos + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
if buf.len() > 16 * 1024 {
|
||||||
|
break None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.expect("did not receive complete request headers");
|
||||||
|
|
||||||
|
let headers_lower = String::from_utf8_lossy(&buf[..header_end]).to_ascii_lowercase();
|
||||||
|
let has_chunked = headers_lower.contains("\r\ntransfer-encoding: chunked\r\n");
|
||||||
|
|
||||||
|
if has_chunked {
|
||||||
|
// Drain terminating chunk so client doesn't error on write before response is read.
|
||||||
|
let terminator = b"0\r\n\r\n";
|
||||||
|
while !buf[header_end..]
|
||||||
|
.windows(terminator.len())
|
||||||
|
.any(|w| w == terminator)
|
||||||
|
{
|
||||||
|
let n = match timeout(Duration::from_secs(2), sock.read(&mut tmp)).await {
|
||||||
|
Ok(Ok(n)) => n,
|
||||||
|
_ => break,
|
||||||
|
};
|
||||||
|
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.extend_from_slice(&tmp[..n]);
|
||||||
|
|
||||||
|
if buf.len() > 32 * 1024 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = if has_chunked {
|
||||||
|
"400 Bad Request"
|
||||||
|
} else {
|
||||||
|
"200 OK"
|
||||||
|
};
|
||||||
|
let resp = format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
|
||||||
|
sock.write_all(resp.as_bytes()).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let url = format!("http://{addr}/");
|
||||||
|
let res = Client::default()
|
||||||
|
.get(url)
|
||||||
|
.send_stream(stream::empty::<Result<Bytes, Infallible>>())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(res.status().is_success());
|
||||||
|
|
||||||
|
srv.await.unwrap();
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue