semantic bufo search find-bufo.com
bufo
at main 221 lines 7.8 kB view raw
1use actix_web::{web, HttpResponse}; 2use image::codecs::jpeg::JpegEncoder; 3use image::codecs::png::PngEncoder; 4use image::{ImageEncoder, ImageReader}; 5use serde::Deserialize; 6use std::io::Cursor; 7 8const ALLOWED_DOMAINS: &[&str] = &["all-the.bufo.zone", "find-bufo.fly.dev"]; 9const DEFAULT_MAX_BYTES: usize = 900_000; 10 11#[derive(Deserialize)] 12pub struct ImageQuery { 13 url: String, 14 max_bytes: Option<usize>, 15} 16 17fn is_allowed_url(url: &str) -> bool { 18 ALLOWED_DOMAINS 19 .iter() 20 .any(|domain| url.contains(domain)) 21} 22 23pub async fn resize_image(query: web::Query<ImageQuery>) -> HttpResponse { 24 let max_bytes = query.max_bytes.unwrap_or(DEFAULT_MAX_BYTES); 25 26 if !is_allowed_url(&query.url) { 27 return HttpResponse::BadRequest().body("domain not allowed"); 28 } 29 30 // fetch original image 31 let response = match reqwest::get(&query.url).await { 32 Ok(r) => r, 33 Err(e) => { 34 tracing::error!("failed to fetch image: {}", e); 35 return HttpResponse::BadGateway().body("failed to fetch image"); 36 } 37 }; 38 39 if !response.status().is_success() { 40 return HttpResponse::BadGateway().body("upstream returned error"); 41 } 42 43 let content_type = response 44 .headers() 45 .get("content-type") 46 .and_then(|v| v.to_str().ok()) 47 .unwrap_or("application/octet-stream") 48 .to_string(); 49 50 let bytes = match response.bytes().await { 51 Ok(b) => b, 52 Err(e) => { 53 tracing::error!("failed to read image bytes: {}", e); 54 return HttpResponse::BadGateway().body("failed to read image"); 55 } 56 }; 57 58 // if already under limit, pass through 59 if bytes.len() <= max_bytes { 60 return HttpResponse::Ok() 61 .insert_header(("content-type", content_type.as_str())) 62 .insert_header(("cache-control", "public, max-age=86400")) 63 .body(bytes); 64 } 65 66 tracing::info!( 67 "image {} bytes exceeds {} limit, resizing", 68 bytes.len(), 69 max_bytes 70 ); 71 72 let is_png = content_type.contains("png") || query.url.ends_with(".png"); 73 74 // try to decode the image 75 let img = match ImageReader::new(Cursor::new(&bytes)) 76 .with_guessed_format() 77 .and_then(|r| r.decode().map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))) 78 { 79 Ok(img) => img, 80 Err(e) => { 81 tracing::error!("failed to decode image: {}", e); 82 // return original if we can't decode 83 return HttpResponse::Ok() 84 .insert_header(("content-type", content_type.as_str())) 85 .insert_header(("cache-control", "public, max-age=86400")) 86 .body(bytes); 87 } 88 }; 89 90 if is_png { 91 // try progressive resize as PNG 92 for scale in &[75u32, 50, 25] { 93 let new_w = img.width() * scale / 100; 94 let new_h = img.height() * scale / 100; 95 let resized = img.resize(new_w, new_h, image::imageops::FilterType::Lanczos3); 96 97 let mut buf = Vec::new(); 98 if PngEncoder::new(&mut buf) 99 .write_image( 100 resized.as_bytes(), 101 resized.width(), 102 resized.height(), 103 resized.color().into(), 104 ) 105 .is_ok() 106 && buf.len() <= max_bytes 107 { 108 tracing::info!("resized PNG to {}x{} ({}%), {} bytes", new_w, new_h, scale, buf.len()); 109 return HttpResponse::Ok() 110 .insert_header(("content-type", "image/png")) 111 .insert_header(("cache-control", "public, max-age=86400")) 112 .body(buf); 113 } 114 } 115 116 // last resort: convert to JPEG 117 for quality in &[85u8, 70, 50, 30] { 118 let mut buf = Vec::new(); 119 if JpegEncoder::new_with_quality(&mut buf, *quality) 120 .write_image( 121 img.as_bytes(), 122 img.width(), 123 img.height(), 124 img.color().into(), 125 ) 126 .is_ok() 127 && buf.len() <= max_bytes 128 { 129 tracing::info!("converted PNG to JPEG q={}, {} bytes", quality, buf.len()); 130 return HttpResponse::Ok() 131 .insert_header(("content-type", "image/jpeg")) 132 .insert_header(("cache-control", "public, max-age=86400")) 133 .body(buf); 134 } 135 } 136 137 // if even JPEG conversion at lowest quality is too big, resize + JPEG 138 for scale in &[75u32, 50, 25] { 139 let new_w = img.width() * scale / 100; 140 let new_h = img.height() * scale / 100; 141 let resized = img.resize(new_w, new_h, image::imageops::FilterType::Lanczos3); 142 143 let mut buf = Vec::new(); 144 if JpegEncoder::new_with_quality(&mut buf, 50) 145 .write_image( 146 resized.as_bytes(), 147 resized.width(), 148 resized.height(), 149 resized.color().into(), 150 ) 151 .is_ok() 152 && buf.len() <= max_bytes 153 { 154 tracing::info!( 155 "resized+converted to JPEG {}x{} q=50, {} bytes", 156 new_w, new_h, buf.len() 157 ); 158 return HttpResponse::Ok() 159 .insert_header(("content-type", "image/jpeg")) 160 .insert_header(("cache-control", "public, max-age=86400")) 161 .body(buf); 162 } 163 } 164 } else { 165 // JPEG: reduce quality progressively 166 for quality in &[85u8, 70, 50, 30] { 167 let mut buf = Vec::new(); 168 if JpegEncoder::new_with_quality(&mut buf, *quality) 169 .write_image( 170 img.as_bytes(), 171 img.width(), 172 img.height(), 173 img.color().into(), 174 ) 175 .is_ok() 176 && buf.len() <= max_bytes 177 { 178 tracing::info!("re-encoded JPEG q={}, {} bytes", quality, buf.len()); 179 return HttpResponse::Ok() 180 .insert_header(("content-type", "image/jpeg")) 181 .insert_header(("cache-control", "public, max-age=86400")) 182 .body(buf); 183 } 184 } 185 186 // resize + quality reduction 187 for scale in &[75u32, 50, 25] { 188 let new_w = img.width() * scale / 100; 189 let new_h = img.height() * scale / 100; 190 let resized = img.resize(new_w, new_h, image::imageops::FilterType::Lanczos3); 191 192 let mut buf = Vec::new(); 193 if JpegEncoder::new_with_quality(&mut buf, 50) 194 .write_image( 195 resized.as_bytes(), 196 resized.width(), 197 resized.height(), 198 resized.color().into(), 199 ) 200 .is_ok() 201 && buf.len() <= max_bytes 202 { 203 tracing::info!( 204 "resized JPEG to {}x{} q=50, {} bytes", 205 new_w, new_h, buf.len() 206 ); 207 return HttpResponse::Ok() 208 .insert_header(("content-type", "image/jpeg")) 209 .insert_header(("cache-control", "public, max-age=86400")) 210 .body(buf); 211 } 212 } 213 } 214 215 // give up, return original 216 tracing::warn!("could not resize image under {} bytes, returning original", max_bytes); 217 HttpResponse::Ok() 218 .insert_header(("content-type", content_type.as_str())) 219 .insert_header(("cache-control", "public, max-age=86400")) 220 .body(bytes) 221}