semantic bufo search find-bufo.com
bufo

feat(backend): add image resize proxy endpoint

the bot needs images under 900KB for bluesky uploads. adds /api/image
endpoint that fetches from allowed domains and progressively downsizes
(quality reduction, then resize) to fit within max_bytes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+305
+81
Cargo.lock
··· 427 427 checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" 428 428 429 429 [[package]] 430 + name = "bytemuck" 431 + version = "1.25.0" 432 + source = "registry+https://github.com/rust-lang/crates.io-index" 433 + checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" 434 + 435 + [[package]] 436 + name = "byteorder-lite" 437 + version = "0.1.0" 438 + source = "registry+https://github.com/rust-lang/crates.io-index" 439 + checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" 440 + 441 + [[package]] 430 442 name = "bytes" 431 443 version = "1.10.1" 432 444 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 668 680 checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 669 681 670 682 [[package]] 683 + name = "fdeflate" 684 + version = "0.3.7" 685 + source = "registry+https://github.com/rust-lang/crates.io-index" 686 + checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" 687 + dependencies = [ 688 + "simd-adler32", 689 + ] 690 + 691 + [[package]] 671 692 name = "find-bufo" 672 693 version = "0.1.0" 673 694 dependencies = [ ··· 678 699 "anyhow", 679 700 "base64", 680 701 "dotenv", 702 + "image", 681 703 "log", 682 704 "logfire", 683 705 "opentelemetry 0.26.0", ··· 1262 1284 ] 1263 1285 1264 1286 [[package]] 1287 + name = "image" 1288 + version = "0.25.9" 1289 + source = "registry+https://github.com/rust-lang/crates.io-index" 1290 + checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" 1291 + dependencies = [ 1292 + "bytemuck", 1293 + "byteorder-lite", 1294 + "moxcms", 1295 + "num-traits", 1296 + "png", 1297 + "zune-core", 1298 + "zune-jpeg", 1299 + ] 1300 + 1301 + [[package]] 1265 1302 name = "impl-more" 1266 1303 version = "0.1.9" 1267 1304 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1488 1525 "log", 1489 1526 "wasi", 1490 1527 "windows-sys 0.61.2", 1528 + ] 1529 + 1530 + [[package]] 1531 + name = "moxcms" 1532 + version = "0.7.11" 1533 + source = "registry+https://github.com/rust-lang/crates.io-index" 1534 + checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" 1535 + dependencies = [ 1536 + "num-traits", 1537 + "pxfm", 1491 1538 ] 1492 1539 1493 1540 [[package]] ··· 1840 1887 checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 1841 1888 1842 1889 [[package]] 1890 + name = "png" 1891 + version = "0.18.1" 1892 + source = "registry+https://github.com/rust-lang/crates.io-index" 1893 + checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" 1894 + dependencies = [ 1895 + "bitflags", 1896 + "crc32fast", 1897 + "fdeflate", 1898 + "flate2", 1899 + "miniz_oxide", 1900 + ] 1901 + 1902 + [[package]] 1843 1903 name = "portable-atomic" 1844 1904 version = "1.11.1" 1845 1905 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1900 1960 "quote", 1901 1961 "syn", 1902 1962 ] 1963 + 1964 + [[package]] 1965 + name = "pxfm" 1966 + version = "0.1.28" 1967 + source = "registry+https://github.com/rust-lang/crates.io-index" 1968 + checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" 1903 1969 1904 1970 [[package]] 1905 1971 name = "quanta" ··· 3484 3550 "cc", 3485 3551 "pkg-config", 3486 3552 ] 3553 + 3554 + [[package]] 3555 + name = "zune-core" 3556 + version = "0.5.1" 3557 + source = "registry+https://github.com/rust-lang/crates.io-index" 3558 + checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" 3559 + 3560 + [[package]] 3561 + name = "zune-jpeg" 3562 + version = "0.5.12" 3563 + source = "registry+https://github.com/rust-lang/crates.io-index" 3564 + checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" 3565 + dependencies = [ 3566 + "zune-core", 3567 + ]
+1
Cargo.toml
··· 25 25 opentelemetry-instrumentation-actix-web = { version = "0.23", features = ["metrics"] } 26 26 opentelemetry-otlp = { version = "0.26", features = ["trace", "http-proto", "reqwest-client", "reqwest-rustls"] } 27 27 regex = "1.12" 28 + image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
+221
src/image.rs
··· 1 + use actix_web::{web, HttpResponse}; 2 + use image::codecs::jpeg::JpegEncoder; 3 + use image::codecs::png::PngEncoder; 4 + use image::{ImageEncoder, ImageReader}; 5 + use serde::Deserialize; 6 + use std::io::Cursor; 7 + 8 + const ALLOWED_DOMAINS: &[&str] = &["all-the.bufo.zone", "find-bufo.fly.dev"]; 9 + const DEFAULT_MAX_BYTES: usize = 900_000; 10 + 11 + #[derive(Deserialize)] 12 + pub struct ImageQuery { 13 + url: String, 14 + max_bytes: Option<usize>, 15 + } 16 + 17 + fn is_allowed_url(url: &str) -> bool { 18 + ALLOWED_DOMAINS 19 + .iter() 20 + .any(|domain| url.contains(domain)) 21 + } 22 + 23 + pub 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 + }
+2
src/main.rs
··· 1 1 mod config; 2 2 mod embedding; 3 3 mod filter; 4 + mod image; 4 5 mod providers; 5 6 mod scoring; 6 7 mod search; ··· 66 67 .wrap(Governor::new(&governor_conf)) 67 68 .route("/search", web::post().to(search::search)) 68 69 .route("/search", web::get().to(search::search_get)) 70 + .route("/image", web::get().to(image::resize_image)) 69 71 .route("/health", web::get().to(|| async { HttpResponse::Ok().body("ok") })) 70 72 ) 71 73 .service(fs::Files::new("/static", "./static").show_files_listing())