Skip to main content

pingap_imageoptim/
lib.rs

1// Copyright 2024-2025 Tree xie.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use crate::optimizer::{
16    ImageError, load_image, optimize_avif, optimize_jpeg, optimize_png,
17    optimize_webp,
18};
19use async_trait::async_trait;
20use bytes::{Bytes, BytesMut};
21use ctor::ctor;
22use pingap_config::PluginConf;
23use pingap_core::HTTP_HEADER_TRANSFER_CHUNKED;
24use pingap_core::{
25    Ctx, Plugin, PluginStep, RequestPluginResult, ResponsePluginResult,
26};
27use pingap_core::{ModifyResponseBody, ResponseBodyPluginResult};
28use pingap_plugin::{
29    Error, get_hash_key, get_int_conf, get_plugin_factory, get_str_conf,
30};
31use pingora::http::ResponseHeader;
32use pingora::proxy::Session;
33use std::borrow::Cow;
34use std::collections::HashSet;
35use std::convert::TryFrom;
36use std::sync::Arc;
37use tracing::{debug, warn};
38
39mod optimizer;
40
41const PLUGIN_ID: &str = "_image_optimize_";
42
43/// The largest original that is converted, in bytes. A larger one is
44/// passed on as it is.
45const MAX_IMAGE_SIZE: usize = 20 * 1024 * 1024;
46
47/// The formats an image can be converted to.
48const OUTPUT_FORMATS: &[&str] = &["avif", "webp", "jpeg", "png"];
49
50type Result<T, E = Error> = std::result::Result<T, E>;
51
52struct ImageOptimizer {
53    /// The format of the original, `png` or `jpeg`.
54    image_type: String,
55    png_quality: u8,
56    jpeg_quality: u8,
57    avif_quality: u8,
58    avif_speed: u8,
59    webp_quality: u8,
60    /// The format to convert to, one of `OUTPUT_FORMATS`.
61    format_type: String,
62    buffer: BytesMut,
63    /// The original turned out too large to convert: what was held back
64    /// has been released, and the rest goes through as it comes.
65    passthrough: bool,
66}
67
68/// Runs `f`, a long computation, without holding up the other connections
69/// of this worker thread.
70///
71/// Decoding and encoding an image takes from tens of milliseconds to
72/// seconds, and the body filter it happens in is not async. On the
73/// work-stealing runtime `block_in_place` moves the worker's other tasks to
74/// another thread first. A runtime without work stealing has no such
75/// thread to move them to, and there `f` simply runs.
76fn run_blocking<T>(f: impl FnOnce() -> T) -> T {
77    use tokio::runtime::{Handle, RuntimeFlavor};
78    match Handle::try_current() {
79        Ok(handle) if handle.runtime_flavor() == RuntimeFlavor::MultiThread => {
80            tokio::task::block_in_place(f)
81        },
82        _ => f(),
83    }
84}
85
86impl ImageOptimizer {
87    fn optimize(&self, data: &[u8]) -> Result<Vec<u8>, ImageError> {
88        let info = load_image(data, &self.image_type)?;
89        match self.format_type.as_str() {
90            "jpeg" => optimize_jpeg(&info, self.jpeg_quality),
91            "avif" => optimize_avif(&info, self.avif_quality, self.avif_speed),
92            "webp" => optimize_webp(&info, self.webp_quality),
93            _ => optimize_png(&info, self.png_quality),
94        }
95    }
96}
97
98impl ModifyResponseBody for ImageOptimizer {
99    fn handle(
100        &mut self,
101        _session: &Session,
102        body: &mut Option<bytes::Bytes>,
103        end_of_stream: bool,
104    ) -> pingora::Result<()> {
105        if self.passthrough {
106            return Ok(());
107        }
108        if let Some(data) = body {
109            // Without a `Content-Length` the size is only known once it
110            // has been exceeded. The body used to be collected whatever
111            // its size.
112            if self.buffer.len() + data.len() > MAX_IMAGE_SIZE {
113                self.passthrough = true;
114                self.buffer.extend_from_slice(data);
115                *data = self.buffer.split().freeze();
116                return Ok(());
117            }
118            self.buffer.extend_from_slice(data);
119            data.clear();
120        }
121        if !end_of_stream {
122            return Ok(());
123        }
124        // No body at all, the answer to a HEAD request for one.
125        if self.buffer.is_empty() {
126            return Ok(());
127        }
128        let original = self.buffer.split().freeze();
129        let optimized = run_blocking(|| self.optimize(&original));
130        *body = Some(match optimized {
131            Ok(data) => Bytes::from(data),
132            // The original is what there is to send. A failure used to
133            // leave the body empty: a 200 with no image in it.
134            Err(e) => {
135                warn!(
136                    error = %e,
137                    image_type = self.image_type,
138                    format_type = self.format_type,
139                    "optimize image fail, the original is sent"
140                );
141                original
142            },
143        });
144        Ok(())
145    }
146    fn name(&self) -> &str {
147        "image_optimization"
148    }
149}
150
151pub struct ImageOptim {
152    /// The name this instance keeps its body handler under, see
153    /// `new_body_handler_id`.
154    handler_id: String,
155    /// A unique identifier for this plugin instance.
156    /// Used for internal tracking and debugging purposes.
157    hash_value: String,
158    support_types: HashSet<String>,
159    /// The formats to convert to, in order of preference.
160    output_types: Vec<String>,
161    /// The media type of each of `output_types`, in the same order.
162    output_mimes: Vec<String>,
163    /// The cache key components a request can have: each selection of
164    /// `output_mimes`, sorted, as `Accept` may name any of them.
165    key_alternatives: Arc<Vec<Vec<String>>>,
166    png_quality: u8,
167    jpeg_quality: u8,
168    avif_quality: u8,
169    avif_speed: u8,
170}
171
172/// Every selection of `mimes`, the empty one first, each in sorted order.
173fn selections(mimes: &[String]) -> Vec<Vec<String>> {
174    let mut sorted = mimes.to_vec();
175    sorted.sort();
176    sorted.dedup();
177    (0..1usize << sorted.len())
178        .map(|picked| {
179            sorted
180                .iter()
181                .enumerate()
182                .filter(|(index, _)| picked & (1 << index) != 0)
183                .map(|(_, mime)| mime.clone())
184                .collect()
185        })
186        .collect()
187}
188
189impl TryFrom<&PluginConf> for ImageOptim {
190    type Error = Error;
191    fn try_from(value: &PluginConf) -> Result<Self> {
192        debug!(params = value.to_string(), "new image optimizer plugin");
193        let hash_value = get_hash_key(value);
194
195        let output_types: Vec<String> = get_str_conf(value, "output_types")
196            .split(',')
197            .map(|s| s.trim())
198            .filter(|s| !s.is_empty())
199            .map(|s| s.to_string())
200            .collect();
201
202        // A format that is not known can not be produced. It used to be
203        // taken as it was: the response was announced as `image/<format>`
204        // and its body encoded as png.
205        if let Some(format) = output_types
206            .iter()
207            .find(|format| !OUTPUT_FORMATS.contains(&format.as_str()))
208        {
209            return Err(Error::Invalid {
210                category: "image_optim".to_string(),
211                message: format!(
212                    "output type {format} is not supported, expected one of {}",
213                    OUTPUT_FORMATS.join(", ")
214                ),
215            });
216        }
217        let output_mimes: Vec<String> = output_types
218            .iter()
219            .map(|format| format!("image/{}", format))
220            .collect();
221
222        // Unset, or 0, is the default. Anything else outside the range is
223        // an error: cast to a byte as it came, 300 was a quality of 44.
224        let level = |key: &str, max: i64, default: u8| -> Result<u8, Error> {
225            match get_int_conf(value, key) {
226                0 => Ok(default),
227                level if (1..=max).contains(&level) => Ok(level as u8),
228                level => Err(Error::Invalid {
229                    category: "image_optim".to_string(),
230                    message: format!(
231                        "{key} should be between 1 and {max}, got {level}"
232                    ),
233                }),
234            }
235        };
236        let png_quality = level("png_quality", 100, 90)?;
237        let jpeg_quality = level("jpeg_quality", 100, 80)?;
238        let avif_quality = level("avif_quality", 100, 75)?;
239        let avif_speed = level("avif_speed", 10, 3)?;
240        Ok(Self {
241            hash_value,
242            handler_id: pingap_plugin::new_body_handler_id(PLUGIN_ID),
243            support_types: HashSet::from([
244                "jpeg".to_string(),
245                "png".to_string(),
246            ]),
247            output_types,
248            key_alternatives: Arc::new(selections(&output_mimes)),
249            output_mimes,
250            png_quality,
251            jpeg_quality,
252            avif_quality,
253            avif_speed,
254        })
255    }
256}
257
258impl ImageOptim {
259    pub fn new(params: &PluginConf) -> Result<Self> {
260        Self::try_from(params)
261    }
262}
263
264#[async_trait]
265impl Plugin for ImageOptim {
266    /// Returns a unique identifier for this plugin instance
267    fn config_key(&self) -> Cow<'_, str> {
268        Cow::Borrowed(&self.hash_value)
269    }
270    async fn handle_request(
271        &self,
272        step: PluginStep,
273        session: &mut Session,
274        ctx: &mut Ctx,
275    ) -> pingora::Result<RequestPluginResult> {
276        // Ahead of the plugins that answer in the request step. The cache
277        // plugin answers a `PURGE` there, and has to know by then that the
278        // formats are a part of the key and which ones there are: listed
279        // after it, this plugin had not run, and the purge removed the
280        // entry of a request that accepts no image format and no other.
281        if step != PluginStep::EarlyRequest {
282            return Ok(RequestPluginResult::Skipped);
283        }
284
285        let accept = session
286            .get_header(http::header::ACCEPT)
287            .and_then(|accept| accept.to_str().ok())
288            .unwrap_or_default();
289        let mut accept_images: Vec<_> = self
290            .output_mimes
291            .iter()
292            .filter(|mime| accept.contains(*mime))
293            .cloned()
294            .collect();
295        accept_images.sort();
296        // As in `selections`: a format listed twice is one format.
297        accept_images.dedup();
298        ctx.push_cache_key_variant(
299            accept_images,
300            self.key_alternatives.clone(),
301        );
302        Ok(RequestPluginResult::Continue)
303    }
304    fn handle_upstream_response(
305        &self,
306        session: &mut Session,
307        ctx: &mut Ctx,
308        upstream_response: &mut ResponseHeader,
309    ) -> pingora::Result<ResponsePluginResult> {
310        // A partial or an empty response is not an image to convert, and
311        // neither are the bytes of a compressed one: decoding those can
312        // only fail, after the header has promised the new format.
313        if upstream_response.status != http::StatusCode::OK
314            || upstream_response
315                .headers
316                .contains_key(http::header::CONTENT_ENCODING)
317        {
318            return Ok(ResponsePluginResult::Unchanged);
319        }
320        let content_type = if let Some(value) =
321            upstream_response.headers.get(http::header::CONTENT_TYPE)
322        {
323            value.to_str().unwrap_or_default()
324        } else {
325            return Ok(ResponsePluginResult::Unchanged);
326        };
327
328        // The media type without its parameters.
329        let media_type = content_type.split(';').next().unwrap_or_default();
330        let Some(image_type) = media_type.trim().strip_prefix("image/") else {
331            return Ok(ResponsePluginResult::Unchanged);
332        };
333
334        if !self.support_types.contains(image_type) {
335            return Ok(ResponsePluginResult::Unchanged);
336        }
337
338        let Some(accept) = session.get_header(http::header::ACCEPT) else {
339            return Ok(ResponsePluginResult::Unchanged);
340        };
341        let Ok(accept_str) = accept.to_str() else {
342            return Ok(ResponsePluginResult::Unchanged);
343        };
344
345        // The size the upstream announces. It used to be the size of the
346        // buffer allocated right here, whatever it said. Too large an
347        // image is left alone, headers included.
348        let content_length = upstream_response
349            .headers
350            .get(http::header::CONTENT_LENGTH)
351            .and_then(|value| value.to_str().ok())
352            .and_then(|value| value.parse::<usize>().ok());
353        if content_length.is_some_and(|size| size > MAX_IMAGE_SIZE) {
354            return Ok(ResponsePluginResult::Unchanged);
355        }
356
357        let image_type = image_type.to_string();
358        // The first of the configured formats the client accepts, else the
359        // format the image already has. This is the format's name; the
360        // media type, with `image/` in front, used to be kept in its place,
361        // so no conversion ever matched it and everything came out as png,
362        // announced as avif or webp.
363        let format_type = self
364            .output_types
365            .iter()
366            .zip(self.output_mimes.iter())
367            .find(|(_, mime)| accept_str.contains(mime.as_str()))
368            .map(|(format, _)| format.clone())
369            .unwrap_or_else(|| image_type.clone());
370        // Remove content-length since we're modifying the body
371        upstream_response.remove_header(&http::header::CONTENT_LENGTH);
372        // Ranges of the new image are not ranges of the upstream's. The
373        // `ETag` stays the upstream's for the cache to revalidate with,
374        // and is weakened on the way out, in `handle_response`.
375        upstream_response.remove_header(&http::header::ACCEPT_RANGES);
376        // Switch to chunked transfer encoding
377        let _ = upstream_response.insert_header(
378            http::header::TRANSFER_ENCODING,
379            HTTP_HEADER_TRANSFER_CHUNKED.1.clone(),
380        );
381        let _ = upstream_response.insert_header(
382            http::header::CONTENT_TYPE,
383            format!("image/{format_type}"),
384        );
385        let capacity = content_length.unwrap_or(8192);
386
387        ctx.add_modify_body_handler(
388            &self.handler_id,
389            Box::new(ImageOptimizer {
390                image_type,
391                png_quality: self.png_quality,
392                jpeg_quality: self.jpeg_quality,
393                avif_quality: self.avif_quality,
394                avif_speed: self.avif_speed,
395                // only support lossless
396                webp_quality: 100,
397                format_type,
398                buffer: BytesMut::with_capacity(capacity),
399                passthrough: false,
400            }),
401        );
402        Ok(ResponsePluginResult::Modified)
403    }
404    /// The upstream's strong `ETag` names the image it sent, and what
405    /// goes out is that image encoded anew: the validator is made a weak
406    /// one. On the way to the client, for a response from the cache as
407    /// well, and not on the response that is stored: the cache
408    /// revalidates with the validator as the upstream gave it.
409    ///
410    /// Every image of a kind this plugin reads or writes gets it, also
411    /// one that was left as it came (too large, say): which it was is not
412    /// known here, and a weak validator is never wrong.
413    async fn handle_response(
414        &self,
415        _session: &mut Session,
416        _ctx: &mut Ctx,
417        upstream_response: &mut ResponseHeader,
418    ) -> pingora::Result<ResponsePluginResult> {
419        let ours = upstream_response
420            .headers
421            .get(http::header::CONTENT_TYPE)
422            .and_then(|value| value.to_str().ok())
423            .and_then(|value| value.split(';').next())
424            .and_then(|value| value.trim().strip_prefix("image/"))
425            .is_some_and(|kind| {
426                self.support_types.contains(kind)
427                    || self.output_types.iter().any(|output| output == kind)
428            });
429        if ours && pingap_plugin::weaken_etag(upstream_response) {
430            return Ok(ResponsePluginResult::Modified);
431        }
432        Ok(ResponsePluginResult::Unchanged)
433    }
434    fn handle_upstream_response_body(
435        &self,
436        session: &mut Session,
437        ctx: &mut Ctx,
438        body: &mut Option<bytes::Bytes>,
439        end_of_stream: bool,
440    ) -> pingora::Result<ResponseBodyPluginResult> {
441        if let Some(modifier) = ctx.get_modify_body_handler(&self.handler_id) {
442            modifier.handle(session, body, end_of_stream)?;
443            let result = if end_of_stream {
444                ResponseBodyPluginResult::FullyReplaced
445            } else {
446                ResponseBodyPluginResult::PartialReplaced
447            };
448            Ok(result)
449        } else {
450            Ok(ResponseBodyPluginResult::Unchanged)
451        }
452    }
453}
454
455#[ctor(unsafe)]
456fn init() {
457    get_plugin_factory().register("image_optim", |params| {
458        Ok(Arc::new(ImageOptim::new(params)?))
459    });
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use pingap_config::PluginConf;
466    use pingap_core::{Ctx, Plugin};
467    use pingora::modules::http::HttpModules;
468    use pretty_assertions::assert_eq;
469    use tokio_test::io::Builder;
470
471    #[test]
472    fn test_new_image_optimize() {
473        let optim = ImageOptim::try_from(
474            &toml::from_str::<PluginConf>(
475                r###"
476avif_quality = 75
477avif_speed = 3
478category = "image_optim"
479jpeg_quality = 80
480output_types = "avif,webp"
481png_quality = 90
482"###,
483            )
484            .unwrap(),
485        )
486        .unwrap();
487
488        assert_eq!(
489            HashSet::from(["jpeg".to_string(), "png".to_string(),]),
490            optim.support_types
491        );
492        assert_eq!(
493            vec!["image/avif".to_string(), "image/webp".to_string()],
494            optim.output_mimes
495        );
496        assert_eq!(90, optim.png_quality);
497        assert_eq!(80, optim.jpeg_quality);
498        assert_eq!(75, optim.avif_quality);
499        assert_eq!(3, optim.avif_speed);
500    }
501
502    /// The validator of an image this plugin may have encoded anew is a
503    /// weak one for the client, whatever the cache holds.
504    #[tokio::test]
505    async fn test_image_validator_is_weak_on_the_way_out() {
506        let optim = new_optim("avif,webp");
507        let sent = async |content_type: &str| {
508            let mut session = new_session("").await;
509            let mut resp = ResponseHeader::build(200, None).unwrap();
510            resp.insert_header("Content-Type", content_type).unwrap();
511            resp.insert_header("ETag", "\"v1\"").unwrap();
512            optim
513                .handle_response(&mut session, &mut Ctx::default(), &mut resp)
514                .await
515                .unwrap();
516            resp.headers
517                .get("ETag")
518                .unwrap()
519                .to_str()
520                .unwrap()
521                .to_string()
522        };
523        assert_eq!("W/\"v1\"", sent("image/webp").await);
524        assert_eq!("W/\"v1\"", sent("image/jpeg; q=1").await);
525        // Not an image of this plugin's.
526        assert_eq!("\"v1\"", sent("image/svg+xml").await);
527        assert_eq!("\"v1\"", sent("text/html").await);
528    }
529
530    /// Regression: the levels were cast to a byte before they were
531    /// checked, so 300 passed as 44 instead of being out of range.
532    #[test]
533    fn test_levels_out_of_range_are_rejected() {
534        let build = |conf: &str| {
535            ImageOptim::try_from(&toml::from_str::<PluginConf>(conf).unwrap())
536        };
537        for conf in [
538            "png_quality = 300",
539            "png_quality = 101",
540            "jpeg_quality = 256",
541            "avif_quality = -1",
542            "avif_speed = 11",
543            "avif_speed = 266",
544        ] {
545            let message =
546                build(conf).err().map(|e| e.to_string()).unwrap_or_default();
547            assert_eq!(true, message.contains("should be between"), "{conf}");
548        }
549        // Unset or 0 is the default, the ends of the range are in it.
550        let optim = build("jpeg_quality = 0").unwrap();
551        assert_eq!(
552            (90, 80, 75, 3),
553            (
554                optim.png_quality,
555                optim.jpeg_quality,
556                optim.avif_quality,
557                optim.avif_speed
558            )
559        );
560        let optim =
561            build("png_quality = 1\njpeg_quality = 100\navif_speed = 10")
562                .unwrap();
563        assert_eq!(
564            (1, 100, 10),
565            (optim.png_quality, optim.jpeg_quality, optim.avif_speed)
566        );
567    }
568
569    #[tokio::test]
570    async fn test_image_optimize_handle_request() {
571        let optim = ImageOptim::try_from(
572            &toml::from_str::<PluginConf>(
573                r###"
574avif_quality = 75
575avif_speed = 3
576category = "image_optim"
577jpeg_quality = 80
578output_types = "avif,webp"
579png_quality = 90
580"###,
581            )
582            .unwrap(),
583        )
584        .unwrap();
585        // not accept value
586        {
587            let headers = [""].join("\r\n");
588            let input_header = format!(
589                "GET /vicanso/pingap?size=1 HTTP/1.1\r\n{headers}\r\n\r\n"
590            );
591            let mock_io = Builder::new().read(input_header.as_bytes()).build();
592            let mut session = Session::new_h1_with_modules(
593                Box::new(mock_io),
594                &HttpModules::new(),
595            );
596            session.read_request().await.unwrap();
597            let mut ctx = Ctx::default();
598
599            let result = optim
600                .handle_request(
601                    PluginStep::EarlyRequest,
602                    &mut session,
603                    &mut ctx,
604                )
605                .await
606                .unwrap();
607
608            assert_eq!(true, RequestPluginResult::Continue == result);
609            // No format accepted: nothing in the key, and the place of
610            // the formats noted with all that could stand there.
611            let cache = ctx.cache.unwrap();
612            assert_eq!(Some(vec![]), cache.keys);
613            let variants = cache.key_variants.unwrap();
614            assert_eq!((0, 0), (variants[0].at, variants[0].len));
615            assert_eq!(
616                vec![
617                    vec![],
618                    vec!["image/avif".to_string()],
619                    vec!["image/webp".to_string()],
620                    vec!["image/avif".to_string(), "image/webp".to_string()],
621                ],
622                *variants[0].alternatives
623            );
624        }
625
626        // accept avif
627        {
628            let headers = ["Accept: image/avif"].join("\r\n");
629            let input_header = format!(
630                "GET /vicanso/pingap?size=1 HTTP/1.1\r\n{headers}\r\n\r\n"
631            );
632            let mock_io = Builder::new().read(input_header.as_bytes()).build();
633            let mut session = Session::new_h1_with_modules(
634                Box::new(mock_io),
635                &HttpModules::new(),
636            );
637            session.read_request().await.unwrap();
638            let mut ctx = Ctx::default();
639
640            let result = optim
641                .handle_request(
642                    PluginStep::EarlyRequest,
643                    &mut session,
644                    &mut ctx,
645                )
646                .await
647                .unwrap();
648
649            assert_eq!(true, RequestPluginResult::Continue == result);
650            assert_eq!(
651                vec!["image/avif".to_string()],
652                ctx.cache.unwrap().keys.unwrap()
653            );
654        }
655
656        // accept avif, webp
657        {
658            let headers = ["Accept: image/webp, image/avif"].join("\r\n");
659            let input_header = format!(
660                "GET /vicanso/pingap?size=1 HTTP/1.1\r\n{headers}\r\n\r\n"
661            );
662            let mock_io = Builder::new().read(input_header.as_bytes()).build();
663            let mut session = Session::new_h1_with_modules(
664                Box::new(mock_io),
665                &HttpModules::new(),
666            );
667            session.read_request().await.unwrap();
668            let mut ctx = Ctx::default();
669
670            let result = optim
671                .handle_request(
672                    PluginStep::EarlyRequest,
673                    &mut session,
674                    &mut ctx,
675                )
676                .await
677                .unwrap();
678
679            assert_eq!(true, RequestPluginResult::Continue == result);
680            assert_eq!(
681                vec!["image/avif".to_string(), "image/webp".to_string()],
682                ctx.cache.unwrap().keys.unwrap()
683            );
684        }
685    }
686
687    #[tokio::test]
688    async fn test_image_optimize_handle_upstream_response() {
689        let optim = ImageOptim::try_from(
690            &toml::from_str::<PluginConf>(
691                r###"
692avif_quality = 75
693avif_speed = 3
694category = "image_optim"
695jpeg_quality = 80
696output_types = "avif,webp"
697png_quality = 90
698"###,
699            )
700            .unwrap(),
701        )
702        .unwrap();
703
704        let headers = ["Accept: image/webp, image/avif"].join("\r\n");
705        let input_header =
706            format!("GET /vicanso/pingap?size=1 HTTP/1.1\r\n{headers}\r\n\r\n");
707        let mock_io = Builder::new().read(input_header.as_bytes()).build();
708        let mut session = Session::new_h1_with_modules(
709            Box::new(mock_io),
710            &HttpModules::new(),
711        );
712        session.read_request().await.unwrap();
713        let mut ctx = Ctx::default();
714        let mut upstream_response = ResponseHeader::build(200, None).unwrap();
715
716        // no content type
717        let result = optim
718            .handle_upstream_response(
719                &mut session,
720                &mut ctx,
721                &mut upstream_response,
722            )
723            .unwrap();
724        assert_eq!(true, ResponsePluginResult::Unchanged == result);
725
726        // content type is not image
727        upstream_response
728            .append_header("content-type", "application/json")
729            .unwrap();
730        let result = optim
731            .handle_upstream_response(
732                &mut session,
733                &mut ctx,
734                &mut upstream_response,
735            )
736            .unwrap();
737        assert_eq!(true, ResponsePluginResult::Unchanged == result);
738
739        // response image png
740        upstream_response
741            .insert_header("content-type", "image/png")
742            .unwrap();
743        let result = optim
744            .handle_upstream_response(
745                &mut session,
746                &mut ctx,
747                &mut upstream_response,
748            )
749            .unwrap();
750        assert_eq!(
751            "chunked",
752            upstream_response.headers.get("transfer-encoding").unwrap()
753        );
754        assert_eq!(true, ResponsePluginResult::Modified == result);
755        assert_eq!(
756            true,
757            ctx.get_modify_body_handler(&optim.handler_id).is_some()
758        );
759        // The first configured format the client accepts, as a media type.
760        assert_eq!(
761            "image/avif",
762            upstream_response.headers.get("content-type").unwrap()
763        );
764    }
765
766    fn new_optim(output_types: &str) -> ImageOptim {
767        ImageOptim::try_from(
768            &toml::from_str::<PluginConf>(&format!(
769                "category = \"image_optim\"\noutput_types = \"{output_types}\""
770            ))
771            .unwrap(),
772        )
773        .unwrap()
774    }
775
776    async fn new_session(accept: &str) -> Session {
777        let input_header =
778            format!("GET /a.png HTTP/1.1\r\nAccept: {accept}\r\n\r\n");
779        let mock_io = Builder::new().read(input_header.as_bytes()).build();
780        let mut session = Session::new_h1_with_modules(
781            Box::new(mock_io),
782            &HttpModules::new(),
783        );
784        session.read_request().await.unwrap();
785        session
786    }
787
788    fn sample_png() -> Vec<u8> {
789        use image::ImageEncoder;
790        let img = image::RgbaImage::from_fn(8, 6, |x, y| {
791            image::Rgba([(x * 30) as u8, (y * 40) as u8, 0, 255])
792        });
793        let mut png = Vec::new();
794        image::codecs::png::PngEncoder::new(&mut png)
795            .write_image(img.as_raw(), 8, 6, image::ExtendedColorType::Rgba8)
796            .unwrap();
797        png
798    }
799
800    #[test]
801    fn test_unknown_output_type_is_rejected() {
802        let err = ImageOptim::try_from(
803            &toml::from_str::<PluginConf>(
804                "category = \"image_optim\"\noutput_types = \"avif,bmp\"",
805            )
806            .unwrap(),
807        )
808        .err()
809        .unwrap()
810        .to_string();
811        assert_eq!(
812            "Plugin image_optim invalid, message: output type bmp is not supported, expected one of avif, webp, jpeg, png",
813            err
814        );
815    }
816
817    /// Which responses are converted, and what the header says afterwards.
818    #[tokio::test]
819    async fn test_image_optimize_response_header() {
820        let optim = new_optim("avif,webp");
821        let run =
822            async |accept: &str, status: u16, headers: &[(&str, &str)]| {
823                let mut session = new_session(accept).await;
824                let mut ctx = Ctx::default();
825                let mut resp = ResponseHeader::build(status, None).unwrap();
826                for (name, value) in headers {
827                    resp.insert_header(name.to_string(), *value).unwrap();
828                }
829                let result = optim
830                    .handle_upstream_response(&mut session, &mut ctx, &mut resp)
831                    .unwrap();
832                let header = |name: &str| {
833                    resp.headers
834                        .get(name)
835                        .map(|value| value.to_str().unwrap().to_string())
836                };
837                (
838                    result == ResponsePluginResult::Modified,
839                    header("content-type"),
840                    header("content-length"),
841                )
842            };
843        let text = |value: &str| Some(value.to_string());
844        let png = [("content-type", "image/png"), ("content-length", "100")];
845
846        assert_eq!(
847            (true, text("image/webp"), None),
848            run("image/webp", 200, &png).await
849        );
850        // Regression: no configured format accepted, so the image keeps its
851        // own. The header used to say `png`, which is not a media type.
852        assert_eq!(
853            (true, text("image/png"), None),
854            run("image/*", 200, &png).await
855        );
856        // Parameters after the media type do not hide it.
857        assert_eq!(
858            (true, text("image/avif"), None),
859            run(
860                "image/avif",
861                200,
862                &[("content-type", "image/jpeg; charset=binary")]
863            )
864            .await
865        );
866
867        // Left alone, header and all: too large to convert,
868        let too_large = (MAX_IMAGE_SIZE + 1).to_string();
869        assert_eq!(
870            (false, text("image/png"), Some(too_large.clone())),
871            run(
872                "image/webp",
873                200,
874                &[
875                    ("content-type", "image/png"),
876                    ("content-length", &too_large)
877                ]
878            )
879            .await
880        );
881        // part of an image,
882        assert_eq!(
883            (false, text("image/png"), text("100")),
884            run("image/webp", 206, &png).await
885        );
886        // not a format that is converted,
887        assert_eq!(
888            (false, text("image/gif"), None),
889            run("image/webp", 200, &[("content-type", "image/gif")]).await
890        );
891        // compressed, so not the bytes of an image.
892        assert_eq!(
893            (false, text("image/png"), text("100")),
894            run(
895                "image/webp",
896                200,
897                &[
898                    ("content-type", "image/png"),
899                    ("content-length", "100"),
900                    ("content-encoding", "gzip")
901                ]
902            )
903            .await
904        );
905    }
906
907    /// The body that goes out for each target format, and when the
908    /// conversion can not be done.
909    #[tokio::test]
910    async fn test_image_optimize_body() {
911        let session = new_session("image/webp").await;
912        let new_optimizer = |format_type: &str| ImageOptimizer {
913            image_type: "png".to_string(),
914            png_quality: 90,
915            jpeg_quality: 80,
916            avif_quality: 75,
917            avif_speed: 10,
918            webp_quality: 100,
919            format_type: format_type.to_string(),
920            buffer: BytesMut::new(),
921            passthrough: false,
922        };
923        let png = sample_png();
924
925        // Regression: every target used to come out as png, whatever the
926        // header announced.
927        for (format_type, expected) in [
928            ("webp", image::ImageFormat::WebP),
929            ("avif", image::ImageFormat::Avif),
930            ("jpeg", image::ImageFormat::Jpeg),
931            ("png", image::ImageFormat::Png),
932        ] {
933            let mut optimizer = new_optimizer(format_type);
934            // in two chunks: nothing goes out before the last one
935            let (head, tail) = png.split_at(png.len() / 2);
936            let mut body = Some(Bytes::copy_from_slice(head));
937            optimizer.handle(&session, &mut body, false).unwrap();
938            assert_eq!(Some(Bytes::new()), body, "{format_type}");
939            let mut body = Some(Bytes::copy_from_slice(tail));
940            optimizer.handle(&session, &mut body, true).unwrap();
941            assert_eq!(
942                Some(expected),
943                image::guess_format(&body.unwrap()).ok(),
944                "{format_type}"
945            );
946        }
947
948        // Nothing came, as for a HEAD request: nothing is made up.
949        let mut optimizer = new_optimizer("webp");
950        let mut body = None;
951        optimizer.handle(&session, &mut body, true).unwrap();
952        assert_eq!(None, body);
953
954        // Regression: not an image after all. The body used to be empty.
955        let mut optimizer = new_optimizer("webp");
956        let mut body = Some(Bytes::from_static(b"not an image"));
957        optimizer.handle(&session, &mut body, true).unwrap();
958        assert_eq!(Some(Bytes::from_static(b"not an image")), body);
959
960        // Regression: no `Content-Length`, and more than is converted. It
961        // goes out as it came, and is not collected any further.
962        let mut optimizer = new_optimizer("webp");
963        let chunk = Bytes::from(vec![1u8; MAX_IMAGE_SIZE / 2 + 1]);
964        let mut body = Some(chunk.clone());
965        optimizer.handle(&session, &mut body, false).unwrap();
966        assert_eq!(Some(Bytes::new()), body);
967        let mut body = Some(chunk.clone());
968        optimizer.handle(&session, &mut body, false).unwrap();
969        assert_eq!(Some(2 * chunk.len()), body.map(|data| data.len()));
970        assert_eq!(0, optimizer.buffer.len());
971        let mut body = Some(Bytes::from_static(b"tail"));
972        optimizer.handle(&session, &mut body, true).unwrap();
973        assert_eq!(Some(Bytes::from_static(b"tail")), body);
974    }
975
976    /// On the work-stealing runtime the conversion goes through
977    /// `block_in_place`, which is only allowed there.
978    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
979    async fn test_image_optimize_on_multi_thread_runtime() {
980        let png = sample_png();
981        let output = tokio::spawn(async move {
982            let session = new_session("image/webp").await;
983            let mut optimizer = ImageOptimizer {
984                image_type: "png".to_string(),
985                png_quality: 90,
986                jpeg_quality: 80,
987                avif_quality: 75,
988                avif_speed: 10,
989                webp_quality: 100,
990                format_type: "webp".to_string(),
991                buffer: BytesMut::new(),
992                passthrough: false,
993            };
994            let mut body = Some(Bytes::from(png));
995            optimizer.handle(&session, &mut body, true).unwrap();
996            body.unwrap()
997        })
998        .await
999        .unwrap();
1000        assert_eq!(
1001            Some(image::ImageFormat::WebP),
1002            image::guess_format(&output).ok()
1003        );
1004    }
1005}