semantic bufo search
find-bufo.com
bufo
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}