1use std::{
4 future::Future,
5 marker::PhantomData,
6 pin::Pin,
7 task::{Context, Poll},
8};
9
10use actix_http::encoding::Encoder;
11use actix_service::{Service, Transform};
12#[expect(deprecated, reason = "preserve the public ready future type")]
13use actix_utils::future::{ready, Either, Ready};
14use futures_core::ready;
15use mime::Mime;
16use once_cell::sync::Lazy;
17use pin_project_lite::pin_project;
18
19use crate::{
20 body::{EitherBody, MessageBody},
21 http::{
22 header::{self, AcceptEncoding, ContentEncoding, Encoding, HeaderValue},
23 StatusCode,
24 },
25 service::{ServiceRequest, ServiceResponse},
26 Error, HttpMessage, HttpResponse,
27};
28
29#[derive(Debug, Clone, Default)]
77#[non_exhaustive]
78pub struct Compress;
79
80#[expect(deprecated, reason = "preserve the public ready future type")]
81impl<S, B> Transform<S, ServiceRequest> for Compress
82where
83 B: MessageBody,
84 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
85{
86 type Response = ServiceResponse<EitherBody<Encoder<B>>>;
87 type Error = Error;
88 type Transform = CompressMiddleware<S>;
89 type InitError = ();
90 type Future = Ready<Result<Self::Transform, Self::InitError>>;
91
92 fn new_transform(&self, service: S) -> Self::Future {
93 ready(Ok(CompressMiddleware { service }))
94 }
95}
96
97pub struct CompressMiddleware<S> {
98 service: S,
99}
100
101#[expect(deprecated, reason = "preserve the public ready future type")]
102impl<S, B> Service<ServiceRequest> for CompressMiddleware<S>
103where
104 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
105 B: MessageBody,
106{
107 type Response = ServiceResponse<EitherBody<Encoder<B>>>;
108 type Error = Error;
109 #[allow(clippy::type_complexity)]
110 type Future = Either<CompressResponse<S, B>, Ready<Result<Self::Response, Self::Error>>>;
111
112 actix_service::forward_ready!(service);
113
114 #[allow(clippy::borrow_interior_mutable_const)]
115 fn call(&self, req: ServiceRequest) -> Self::Future {
116 let accept_encoding = req.get_header::<AcceptEncoding>();
118
119 let accept_encoding = match accept_encoding {
120 None => {
122 return Either::left(CompressResponse {
123 encoding: Encoding::identity(),
124 fut: self.service.call(req),
125 _phantom: PhantomData,
126 })
127 }
128
129 Some(accept_encoding) => accept_encoding,
131 };
132
133 match accept_encoding.negotiate(SUPPORTED_ENCODINGS.iter()) {
134 None => {
135 let mut res = HttpResponse::with_body(
136 StatusCode::NOT_ACCEPTABLE,
137 SUPPORTED_ENCODINGS_STRING.as_str(),
138 );
139
140 res.headers_mut()
141 .insert(header::VARY, HeaderValue::from_static("Accept-Encoding"));
142
143 Either::right(ready(Ok(req
144 .into_response(res)
145 .map_into_boxed_body()
146 .map_into_right_body())))
147 }
148
149 Some(encoding) => Either::left(CompressResponse {
150 fut: self.service.call(req),
151 encoding,
152 _phantom: PhantomData,
153 }),
154 }
155 }
156}
157
158pin_project! {
159 pub struct CompressResponse<S, B>
160 where
161 S: Service<ServiceRequest>,
162 {
163 #[pin]
164 fut: S::Future,
165 encoding: Encoding,
166 _phantom: PhantomData<B>,
167 }
168}
169
170impl<S, B> Future for CompressResponse<S, B>
171where
172 B: MessageBody,
173 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
174{
175 type Output = Result<ServiceResponse<EitherBody<Encoder<B>>>, Error>;
176
177 fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
178 let this = self.as_mut().project();
179
180 match ready!(this.fut.poll(cx)) {
181 Ok(resp) => {
182 let enc = match this.encoding {
183 Encoding::Known(enc) => *enc,
184 Encoding::Unknown(enc) => {
185 unimplemented!("encoding '{enc}' should not be here");
186 }
187 };
188
189 Poll::Ready(Ok(resp.map_body(move |head, body| {
190 let content_type = head.headers.get(header::CONTENT_TYPE);
191
192 fn default_compress_predicate(content_type: Option<&HeaderValue>) -> bool {
193 match content_type {
194 None => true,
195 Some(hdr) => {
196 match hdr.to_str().ok().and_then(|hdr| hdr.parse::<Mime>().ok()) {
197 Some(mime) if mime.type_() == mime::IMAGE => {
198 matches!(mime.subtype(), mime::SVG)
199 }
200 Some(mime) if mime.type_() == mime::VIDEO => false,
201 _ => true,
202 }
203 }
204 }
205 }
206
207 let enc = if default_compress_predicate(content_type) {
208 enc
209 } else {
210 ContentEncoding::Identity
211 };
212
213 EitherBody::left(Encoder::response(enc, head, body))
214 })))
215 }
216
217 Err(err) => Poll::Ready(Err(err)),
218 }
219 }
220}
221
222static SUPPORTED_ENCODINGS_STRING: Lazy<String> = Lazy::new(|| {
223 #[allow(unused_mut)] let mut encoding: Vec<&str> = vec![];
225
226 #[cfg(feature = "compress-brotli")]
227 {
228 encoding.push("br");
229 }
230
231 #[cfg(feature = "compress-gzip")]
232 {
233 encoding.push("gzip");
234 encoding.push("deflate");
235 }
236
237 #[cfg(feature = "compress-zstd")]
238 {
239 encoding.push("zstd");
240 }
241
242 assert!(
243 !encoding.is_empty(),
244 "encoding can not be empty unless __compress feature has been explicitly enabled by itself"
245 );
246
247 encoding.join(", ")
248});
249
250static SUPPORTED_ENCODINGS: &[Encoding] = &[
251 Encoding::identity(),
252 #[cfg(feature = "compress-brotli")]
253 {
254 Encoding::brotli()
255 },
256 #[cfg(feature = "compress-gzip")]
257 {
258 Encoding::gzip()
259 },
260 #[cfg(feature = "compress-gzip")]
261 {
262 Encoding::deflate()
263 },
264 #[cfg(feature = "compress-zstd")]
265 {
266 Encoding::zstd()
267 },
268];
269
270#[cfg(feature = "compress-gzip")]
272#[cfg(test)]
273mod tests {
274 use std::collections::HashSet;
275
276 use static_assertions::assert_impl_all;
277
278 use super::*;
279 use crate::{http::header::ContentType, middleware::DefaultHeaders, test, web, App};
280
281 const HTML_DATA_PART: &str = "<html><h1>hello world</h1></html";
282 const HTML_DATA: &str = const_str::repeat!(HTML_DATA_PART, 100);
283
284 const TEXT_DATA_PART: &str = "hello world ";
285 const TEXT_DATA: &str = const_str::repeat!(TEXT_DATA_PART, 100);
286
287 assert_impl_all!(Compress: Send, Sync);
288
289 pub fn gzip_decode(bytes: impl AsRef<[u8]>) -> Vec<u8> {
290 use std::io::Read as _;
291 let mut decoder = flate2::read::GzDecoder::new(bytes.as_ref());
292 let mut buf = Vec::new();
293 decoder.read_to_end(&mut buf).unwrap();
294 buf
295 }
296
297 #[track_caller]
298 fn assert_successful_res_with_content_type<B>(res: &ServiceResponse<B>, ct: &str) {
299 assert!(res.status().is_success());
300 assert!(
301 res.headers()
302 .get(header::CONTENT_TYPE)
303 .expect("content-type header should be present")
304 .to_str()
305 .expect("content-type header should be utf-8")
306 .contains(ct),
307 "response's content-type did not match {}",
308 ct
309 );
310 }
311
312 #[track_caller]
313 fn assert_successful_gzip_res_with_content_type<B>(res: &ServiceResponse<B>, ct: &str) {
314 assert_successful_res_with_content_type(res, ct);
315 assert_eq!(
316 res.headers()
317 .get(header::CONTENT_ENCODING)
318 .expect("response should be gzip compressed"),
319 "gzip",
320 );
321 }
322
323 #[track_caller]
324 fn assert_successful_identity_res_with_content_type<B>(res: &ServiceResponse<B>, ct: &str) {
325 assert_successful_res_with_content_type(res, ct);
326 assert!(
327 res.headers().get(header::CONTENT_ENCODING).is_none(),
328 "response should not be compressed",
329 );
330 }
331
332 #[actix_rt::test]
333 async fn prevents_double_compressing() {
334 let app = test::init_service({
335 App::new()
336 .wrap(Compress::default())
337 .route(
338 "/single",
339 web::get().to(move || HttpResponse::Ok().body(TEXT_DATA)),
340 )
341 .service(
342 web::resource("/double")
343 .wrap(Compress::default())
344 .wrap(DefaultHeaders::new().add(("x-double", "true")))
345 .route(web::get().to(move || HttpResponse::Ok().body(TEXT_DATA))),
346 )
347 })
348 .await;
349
350 let req = test::TestRequest::default()
351 .uri("/single")
352 .insert_header((header::ACCEPT_ENCODING, "gzip"))
353 .to_request();
354 let res = test::call_service(&app, req).await;
355 assert_eq!(res.status(), StatusCode::OK);
356 assert_eq!(res.headers().get("x-double"), None);
357 assert_eq!(res.headers().get(header::CONTENT_ENCODING).unwrap(), "gzip");
358 let bytes = test::read_body(res).await;
359 assert_eq!(gzip_decode(bytes), TEXT_DATA.as_bytes());
360
361 let req = test::TestRequest::default()
362 .uri("/double")
363 .insert_header((header::ACCEPT_ENCODING, "gzip"))
364 .to_request();
365 let res = test::call_service(&app, req).await;
366 assert_eq!(res.status(), StatusCode::OK);
367 assert_eq!(res.headers().get("x-double").unwrap(), "true");
368 assert_eq!(res.headers().get(header::CONTENT_ENCODING).unwrap(), "gzip");
369 let bytes = test::read_body(res).await;
370 assert_eq!(gzip_decode(bytes), TEXT_DATA.as_bytes());
371 }
372
373 #[actix_rt::test]
374 async fn retains_previously_set_vary_header() {
375 let app = test::init_service({
376 App::new()
377 .wrap(Compress::default())
378 .default_service(web::to(move || {
379 HttpResponse::Ok()
380 .insert_header((header::VARY, "x-test"))
381 .body(TEXT_DATA)
382 }))
383 })
384 .await;
385
386 let req = test::TestRequest::default()
387 .insert_header((header::ACCEPT_ENCODING, "gzip"))
388 .to_request();
389 let res = test::call_service(&app, req).await;
390 assert_eq!(res.status(), StatusCode::OK);
391 #[allow(clippy::mutable_key_type)]
392 let vary_headers = res.headers().get_all(header::VARY).collect::<HashSet<_>>();
393 assert!(vary_headers.contains(&HeaderValue::from_static("x-test")));
394 assert!(vary_headers.contains(&HeaderValue::from_static("accept-encoding")));
395 }
396
397 fn configure_predicate_test(cfg: &mut web::ServiceConfig) {
398 cfg.route(
399 "/html",
400 web::get().to(|| {
401 HttpResponse::Ok()
402 .content_type(ContentType::html())
403 .body(HTML_DATA)
404 }),
405 )
406 .route(
407 "/image",
408 web::get().to(|| {
409 HttpResponse::Ok()
410 .content_type(ContentType::jpeg())
411 .body(TEXT_DATA)
412 }),
413 );
414 }
415
416 #[actix_rt::test]
417 async fn prevents_compression_jpeg() {
418 let app = test::init_service(
419 App::new()
420 .wrap(Compress::default())
421 .configure(configure_predicate_test),
422 )
423 .await;
424
425 let req =
426 test::TestRequest::with_uri("/html").insert_header((header::ACCEPT_ENCODING, "gzip"));
427 let res = test::call_service(&app, req.to_request()).await;
428 assert_successful_gzip_res_with_content_type(&res, "text/html");
429 assert_ne!(test::read_body(res).await, HTML_DATA.as_bytes());
430
431 let req =
432 test::TestRequest::with_uri("/image").insert_header((header::ACCEPT_ENCODING, "gzip"));
433 let res = test::call_service(&app, req.to_request()).await;
434 assert_successful_identity_res_with_content_type(&res, "image/jpeg");
435 assert_eq!(test::read_body(res).await, TEXT_DATA.as_bytes());
436 }
437
438 #[actix_rt::test]
439 async fn prevents_compression_empty() {
440 let app = test::init_service({
441 App::new()
442 .wrap(Compress::default())
443 .default_service(web::to(move || HttpResponse::Ok().finish()))
444 })
445 .await;
446
447 let req = test::TestRequest::default()
448 .insert_header((header::ACCEPT_ENCODING, "gzip"))
449 .to_request();
450 let res = test::call_service(&app, req).await;
451 assert_eq!(res.status(), StatusCode::OK);
452 assert!(!res.headers().contains_key(header::CONTENT_ENCODING));
453 assert!(test::read_body(res).await.is_empty());
454 }
455
456 #[actix_rt::test]
457 async fn skips_compression_partial_content() {
458 let app = test::init_service({
459 App::new()
460 .wrap(Compress::default())
461 .default_service(web::to(|| {
462 HttpResponse::PartialContent()
463 .insert_header((header::CONTENT_TYPE, "text/plain"))
464 .insert_header((header::CONTENT_RANGE, "bytes 0-10/100"))
465 .body(TEXT_DATA)
466 }))
467 })
468 .await;
469
470 let req = test::TestRequest::default()
471 .insert_header((header::ACCEPT_ENCODING, "gzip"))
472 .to_request();
473 let res = test::call_service(&app, req).await;
474 assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
475 assert!(!res.headers().contains_key(header::CONTENT_ENCODING));
476 assert_eq!(test::read_body(res).await, TEXT_DATA.as_bytes());
477 }
478}
479
480#[cfg(feature = "compress-brotli")]
481#[cfg(test)]
482mod tests_brotli {
483 use super::*;
484 use crate::{test, web, App};
485
486 #[actix_rt::test]
487 async fn prevents_compression_empty() {
488 let app = test::init_service({
489 App::new()
490 .wrap(Compress::default())
491 .default_service(web::to(move || HttpResponse::Ok().finish()))
492 })
493 .await;
494
495 let req = test::TestRequest::default()
496 .insert_header((header::ACCEPT_ENCODING, "br"))
497 .to_request();
498 let res = test::call_service(&app, req).await;
499 assert_eq!(res.status(), StatusCode::OK);
500 assert!(!res.headers().contains_key(header::CONTENT_ENCODING));
501 assert!(test::read_body(res).await.is_empty());
502 }
503}