1use crate::optimizer::{
16 load_image, optimize_avif, optimize_jpeg, optimize_png, optimize_webp,
17};
18use async_trait::async_trait;
19use bytes::{Bytes, BytesMut};
20use ctor::ctor;
21use pingap_config::PluginConf;
22use pingap_core::HTTP_HEADER_TRANSFER_CHUNKED;
23use pingap_core::{
24 Ctx, Plugin, PluginStep, RequestPluginResult, ResponsePluginResult,
25};
26use pingap_core::{ModifyResponseBody, ResponseBodyPluginResult};
27use pingap_plugin::{
28 Error, get_hash_key, get_int_conf, get_plugin_factory, get_str_conf,
29};
30use pingora::http::ResponseHeader;
31use pingora::proxy::Session;
32use std::borrow::Cow;
33use std::collections::HashSet;
34use std::convert::TryFrom;
35use std::sync::Arc;
36use tracing::debug;
37
38mod optimizer;
39
40const PLUGIN_ID: &str = "_image_optimize_";
41
42type Result<T, E = Error> = std::result::Result<T, E>;
43
44struct ImageOptimizer {
45 image_type: String,
46 png_quality: u8,
47 jpeg_quality: u8,
48 avif_quality: u8,
49 avif_speed: u8,
50 webp_quality: u8,
51 format_type: String,
52 buffer: BytesMut,
53}
54
55impl ModifyResponseBody for ImageOptimizer {
56 fn handle(
57 &mut self,
58 _session: &Session,
59 body: &mut Option<bytes::Bytes>,
60 end_of_stream: bool,
61 ) -> pingora::Result<()> {
62 if let Some(data) = body {
63 self.buffer.extend(&data[..]);
64 data.clear();
65 }
66 if !end_of_stream {
67 return Ok(());
68 }
69 if let Ok(info) = load_image(&self.buffer, &self.image_type) {
70 let result = match self.format_type.as_str() {
71 "jpeg" => optimize_jpeg(&info, self.jpeg_quality),
72 "avif" => {
73 optimize_avif(&info, self.avif_quality, self.avif_speed)
74 },
75 "webp" => optimize_webp(&info, self.webp_quality),
76 _ => optimize_png(&info, self.png_quality),
77 };
78 if let Ok(data) = result {
79 *body = Some(Bytes::from(data));
80 }
81 }
82 Ok(())
83 }
84 fn name(&self) -> &str {
85 "image_optimization"
86 }
87}
88
89pub struct ImageOptim {
90 hash_value: String,
93 support_types: HashSet<String>,
94 output_mimes: Vec<String>,
95 png_quality: u8,
96 jpeg_quality: u8,
97 avif_quality: u8,
98 avif_speed: u8,
99}
100
101impl TryFrom<&PluginConf> for ImageOptim {
102 type Error = Error;
103 fn try_from(value: &PluginConf) -> Result<Self> {
104 debug!(params = value.to_string(), "new image optimizer plugin");
105 let hash_value = get_hash_key(value);
106
107 let output_types: Vec<String> = get_str_conf(value, "output_types")
108 .split(',')
109 .map(|s| s.trim())
110 .filter(|s| !s.is_empty())
111 .map(|s| s.to_string())
112 .collect();
113
114 let output_mimes = output_types
115 .iter()
116 .map(|format| format!("image/{}", format))
117 .collect();
118
119 let mut png_quality = get_int_conf(value, "png_quality") as u8;
120 if png_quality == 0 || png_quality > 100 {
121 png_quality = 90;
122 }
123 let mut jpeg_quality = get_int_conf(value, "jpeg_quality") as u8;
124 if jpeg_quality == 0 || jpeg_quality > 100 {
125 jpeg_quality = 80;
126 }
127 let mut avif_quality = get_int_conf(value, "avif_quality") as u8;
128 if avif_quality == 0 || avif_quality > 100 {
129 avif_quality = 75;
130 }
131 let mut avif_speed = get_int_conf(value, "avif_speed") as u8;
132 if avif_speed == 0 || avif_speed > 10 {
133 avif_speed = 3;
134 }
135 Ok(Self {
136 hash_value,
137 support_types: HashSet::from([
138 "jpeg".to_string(),
139 "png".to_string(),
140 ]),
141 output_mimes,
142 png_quality,
143 jpeg_quality,
144 avif_quality,
145 avif_speed,
146 })
147 }
148}
149
150impl ImageOptim {
151 pub fn new(params: &PluginConf) -> Result<Self> {
152 Self::try_from(params)
153 }
154}
155
156#[async_trait]
157impl Plugin for ImageOptim {
158 fn config_key(&self) -> Cow<'_, str> {
160 Cow::Borrowed(&self.hash_value)
161 }
162 async fn handle_request(
163 &self,
164 step: PluginStep,
165 session: &mut Session,
166 ctx: &mut Ctx,
167 ) -> pingora::Result<RequestPluginResult> {
168 if step != PluginStep::Request {
169 return Ok(RequestPluginResult::Skipped);
170 }
171
172 if let Some(accept) = session.get_header(http::header::ACCEPT)
173 && let Ok(accept_str) = accept.to_str()
174 {
175 let mut accept_images: Vec<_> = self
176 .output_mimes
177 .iter()
178 .filter(|mime| accept_str.contains(*mime))
179 .cloned()
180 .collect();
181
182 if !accept_images.is_empty() {
183 accept_images.sort();
184 ctx.extend_cache_keys(accept_images);
185 }
186 }
187 Ok(RequestPluginResult::Continue)
188 }
189 fn handle_upstream_response(
190 &self,
191 session: &mut Session,
192 ctx: &mut Ctx,
193 upstream_response: &mut ResponseHeader,
194 ) -> pingora::Result<ResponsePluginResult> {
195 let content_type = if let Some(value) =
196 upstream_response.headers.get(http::header::CONTENT_TYPE)
197 {
198 value.to_str().unwrap_or_default()
199 } else {
200 return Ok(ResponsePluginResult::Unchanged);
201 };
202
203 let Some(image_type) = content_type.strip_prefix("image/") else {
204 return Ok(ResponsePluginResult::Unchanged);
205 };
206
207 if !self.support_types.contains(image_type) {
208 return Ok(ResponsePluginResult::Unchanged);
209 }
210
211 let Some(accept) = session.get_header(http::header::ACCEPT) else {
212 return Ok(ResponsePluginResult::Unchanged);
213 };
214 let Ok(accept_str) = accept.to_str() else {
215 return Ok(ResponsePluginResult::Unchanged);
216 };
217
218 let image_type = image_type.to_string();
219 let mut format_type = image_type.clone();
220 for item in self.output_mimes.iter() {
221 if accept_str.contains(item.as_str()) {
222 format_type = item.clone();
223 break;
224 }
225 }
226 let mut capacity = 8192;
227 if let Some(value) =
230 upstream_response.remove_header(&http::header::CONTENT_LENGTH)
231 && let Ok(size) =
232 value.to_str().unwrap_or_default().parse::<usize>()
233 {
234 capacity = size;
235 }
236 let _ = upstream_response.insert_header(
238 http::header::TRANSFER_ENCODING,
239 HTTP_HEADER_TRANSFER_CHUNKED.1.clone(),
240 );
241 let _ = upstream_response
242 .insert_header(http::header::CONTENT_TYPE, &format_type);
243
244 ctx.add_modify_body_handler(
245 PLUGIN_ID,
246 Box::new(ImageOptimizer {
247 image_type,
248 png_quality: self.png_quality,
249 jpeg_quality: self.jpeg_quality,
250 avif_quality: self.avif_quality,
251 avif_speed: self.avif_speed,
252 webp_quality: 100,
254 format_type,
255 buffer: BytesMut::with_capacity(capacity),
256 }),
257 );
258 Ok(ResponsePluginResult::Modified)
259 }
260 fn handle_upstream_response_body(
261 &self,
262 session: &mut Session,
263 ctx: &mut Ctx,
264 body: &mut Option<bytes::Bytes>,
265 end_of_stream: bool,
266 ) -> pingora::Result<ResponseBodyPluginResult> {
267 if let Some(modifier) = ctx.get_modify_body_handler(PLUGIN_ID) {
268 modifier.handle(session, body, end_of_stream)?;
269 let result = if end_of_stream {
270 ResponseBodyPluginResult::FullyReplaced
271 } else {
272 ResponseBodyPluginResult::PartialReplaced
273 };
274 Ok(result)
275 } else {
276 Ok(ResponseBodyPluginResult::Unchanged)
277 }
278 }
279}
280
281#[ctor(unsafe)]
282fn init() {
283 get_plugin_factory().register("image_optim", |params| {
284 Ok(Arc::new(ImageOptim::new(params)?))
285 });
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use pingap_config::PluginConf;
292 use pingap_core::{Ctx, Plugin};
293 use pingora::modules::http::HttpModules;
294 use pretty_assertions::assert_eq;
295 use tokio_test::io::Builder;
296
297 #[test]
298 fn test_new_image_optimize() {
299 let optim = ImageOptim::try_from(
300 &toml::from_str::<PluginConf>(
301 r###"
302avif_quality = 75
303avif_speed = 3
304category = "image_optim"
305jpeg_quality = 80
306output_types = "avif,webp"
307png_quality = 90
308"###,
309 )
310 .unwrap(),
311 )
312 .unwrap();
313
314 assert_eq!(
315 HashSet::from(["jpeg".to_string(), "png".to_string(),]),
316 optim.support_types
317 );
318 assert_eq!(
319 vec!["image/avif".to_string(), "image/webp".to_string()],
320 optim.output_mimes
321 );
322 assert_eq!(90, optim.png_quality);
323 assert_eq!(80, optim.jpeg_quality);
324 assert_eq!(75, optim.avif_quality);
325 assert_eq!(3, optim.avif_speed);
326 }
327
328 #[tokio::test]
329 async fn test_image_optimize_handle_request() {
330 let optim = ImageOptim::try_from(
331 &toml::from_str::<PluginConf>(
332 r###"
333avif_quality = 75
334avif_speed = 3
335category = "image_optim"
336jpeg_quality = 80
337output_types = "avif,webp"
338png_quality = 90
339"###,
340 )
341 .unwrap(),
342 )
343 .unwrap();
344 {
346 let headers = [""].join("\r\n");
347 let input_header = format!(
348 "GET /vicanso/pingap?size=1 HTTP/1.1\r\n{headers}\r\n\r\n"
349 );
350 let mock_io = Builder::new().read(input_header.as_bytes()).build();
351 let mut session = Session::new_h1_with_modules(
352 Box::new(mock_io),
353 &HttpModules::new(),
354 );
355 session.read_request().await.unwrap();
356 let mut ctx = Ctx::default();
357
358 let result = optim
359 .handle_request(PluginStep::Request, &mut session, &mut ctx)
360 .await
361 .unwrap();
362
363 assert_eq!(true, RequestPluginResult::Continue == result);
364 assert_eq!(true, ctx.cache.is_none());
365 }
366
367 {
369 let headers = ["Accept: image/avif"].join("\r\n");
370 let input_header = format!(
371 "GET /vicanso/pingap?size=1 HTTP/1.1\r\n{headers}\r\n\r\n"
372 );
373 let mock_io = Builder::new().read(input_header.as_bytes()).build();
374 let mut session = Session::new_h1_with_modules(
375 Box::new(mock_io),
376 &HttpModules::new(),
377 );
378 session.read_request().await.unwrap();
379 let mut ctx = Ctx::default();
380
381 let result = optim
382 .handle_request(PluginStep::Request, &mut session, &mut ctx)
383 .await
384 .unwrap();
385
386 assert_eq!(true, RequestPluginResult::Continue == result);
387 assert_eq!(
388 vec!["image/avif".to_string()],
389 ctx.cache.unwrap().keys.unwrap()
390 );
391 }
392
393 {
395 let headers = ["Accept: image/webp, image/avif"].join("\r\n");
396 let input_header = format!(
397 "GET /vicanso/pingap?size=1 HTTP/1.1\r\n{headers}\r\n\r\n"
398 );
399 let mock_io = Builder::new().read(input_header.as_bytes()).build();
400 let mut session = Session::new_h1_with_modules(
401 Box::new(mock_io),
402 &HttpModules::new(),
403 );
404 session.read_request().await.unwrap();
405 let mut ctx = Ctx::default();
406
407 let result = optim
408 .handle_request(PluginStep::Request, &mut session, &mut ctx)
409 .await
410 .unwrap();
411
412 assert_eq!(true, RequestPluginResult::Continue == result);
413 assert_eq!(
414 vec!["image/avif".to_string(), "image/webp".to_string()],
415 ctx.cache.unwrap().keys.unwrap()
416 );
417 }
418 }
419
420 #[tokio::test]
421 async fn test_image_optimize_handle_upstream_response() {
422 let optim = ImageOptim::try_from(
423 &toml::from_str::<PluginConf>(
424 r###"
425avif_quality = 75
426avif_speed = 3
427category = "image_optim"
428jpeg_quality = 80
429output_types = "avif,webp"
430png_quality = 90
431"###,
432 )
433 .unwrap(),
434 )
435 .unwrap();
436
437 let headers = ["Accept: image/webp, image/avif"].join("\r\n");
438 let input_header =
439 format!("GET /vicanso/pingap?size=1 HTTP/1.1\r\n{headers}\r\n\r\n");
440 let mock_io = Builder::new().read(input_header.as_bytes()).build();
441 let mut session = Session::new_h1_with_modules(
442 Box::new(mock_io),
443 &HttpModules::new(),
444 );
445 session.read_request().await.unwrap();
446 let mut ctx = Ctx::default();
447 let mut upstream_response = ResponseHeader::build(200, None).unwrap();
448
449 let result = optim
451 .handle_upstream_response(
452 &mut session,
453 &mut ctx,
454 &mut upstream_response,
455 )
456 .unwrap();
457 assert_eq!(true, ResponsePluginResult::Unchanged == result);
458
459 upstream_response
461 .append_header("content-type", "application/json")
462 .unwrap();
463 let result = optim
464 .handle_upstream_response(
465 &mut session,
466 &mut ctx,
467 &mut upstream_response,
468 )
469 .unwrap();
470 assert_eq!(true, ResponsePluginResult::Unchanged == result);
471
472 upstream_response
474 .insert_header("content-type", "image/png")
475 .unwrap();
476 let result = optim
477 .handle_upstream_response(
478 &mut session,
479 &mut ctx,
480 &mut upstream_response,
481 )
482 .unwrap();
483 assert_eq!(
484 "chunked",
485 upstream_response.headers.get("transfer-encoding").unwrap()
486 );
487 assert_eq!(true, ResponsePluginResult::Modified == result);
488 assert_eq!(true, ctx.get_modify_body_handler(PLUGIN_ID).is_some());
489 }
490}