1use std::{f32, io::Cursor, path::PathBuf, sync::Arc};
2
3use axum::{
4 extract::{Path, Query, State},
5 http::{
6 header::{CACHE_CONTROL, CONTENT_TYPE, X_CONTENT_TYPE_OPTIONS},
7 HeaderMap, HeaderValue, StatusCode,
8 },
9 response::{IntoResponse, Response},
10 routing::get,
11 Router,
12};
13use axum_extra::{headers::Range, TypedHeader};
14use axum_range::{KnownSize, Ranged};
15use bytes::Bytes;
16use cached::{Cached, TimedSizedCache};
17use fast_image_resize::{images::Image, IntoImageView, ResizeAlg, ResizeOptions, Resizer};
18use image::{
19 codecs::{jpeg::JpegEncoder, png::PngEncoder, webp::WebPEncoder},
20 load_from_memory, ColorType, DynamicImage, ImageEncoder, ImageFormat,
21};
22use log::{debug, trace};
23use mime_guess::MimeGuess;
24use serde::Deserialize;
25use tokio::{fs::File, io::AsyncReadExt, sync::Mutex};
26
27pub mod config;
28
29pub use config::*;
30
31pub fn get_images_router(root: PathBuf, config: ResizeConfig) -> Router {
32 const CACHE_LIFESPAN: u64 = 24 * 60 * 60; let cache = TimedSizedCache::with_size_and_lifespan_and_refresh(
34 config.cache_size,
35 CACHE_LIFESPAN,
36 true,
37 );
38 let cache = Arc::new(Mutex::new(cache));
39
40 Router::new()
41 .route("/{*path}", get(provide_images))
42 .route(
43 "/",
44 get(|| async { (StatusCode::NOT_FOUND, "File not found".to_string()) }),
45 )
46 .with_state(ImageState {
47 root,
48 config,
49 cache,
50 })
51}
52
53type Error = (StatusCode, String);
54type Result<T, E = Error> = std::result::Result<T, E>;
55
56#[derive(Clone)]
57struct ImageState {
58 root: PathBuf,
59 config: ResizeConfig,
60 cache: Arc<Mutex<TimedSizedCache<(PathBuf, ImageQuery), Bytes>>>,
61}
62
63#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Hash)]
64pub struct ImageQuery {
65 pub output: Option<String>,
66 pub dpr: Option<String>,
67 pub w: Option<u32>,
68 pub h: Option<u32>,
69 pub ce: Option<String>,
70}
71
72impl ImageQuery {
73 fn output(&self) -> Result<Option<ImageFormat>> {
74 self.output
75 .as_ref()
76 .map(|ext| {
77 find_image_mime(MimeGuess::from_ext(ext)).ok_or((
78 StatusCode::BAD_REQUEST,
79 format!("Unsupported output format: {ext}"),
80 ))
81 })
82 .transpose()
83 }
84
85 fn size(&self) -> (Option<u32>, Option<u32>) {
86 (self.w, self.h)
87 }
88
89 fn dpr(&self) -> f32 {
90 self.dpr
91 .clone()
92 .unwrap_or("1".into())
93 .parse::<f32>()
94 .unwrap()
95 .clamp(0.5, 5.)
96 }
97
98 fn ce(&self) -> bool {
99 match self.ce.as_deref() {
100 Some("0") | Some("false") | Some("no") => false,
101 Some(_) => true,
102 None => false,
103 }
104 }
105}
106
107async fn provide_images(
108 State(ImageState {
109 root,
110 config,
111 cache,
112 }): State<ImageState>,
113 Query(query): Query<ImageQuery>,
114 Path(path): Path<PathBuf>,
115 range: Option<TypedHeader<Range>>,
116) -> Result<Response> {
117 let (path, raw_mime) = get_path_and_mime(root, path)?;
118 let dst_mime = query.output()?.unwrap_or(raw_mime);
119 let (dst_width, dst_height) = query.size();
120 let dpr = query.dpr();
121 let ce = query.ce();
122
123 debug!(
124 "Processing image: {path:?} to mime: {dst_mime:?}, size: {:?}x{:?}, dpr: {dpr}, ce: {ce}",
125 dst_width.unwrap_or(0),
126 dst_height.unwrap_or(0)
127 );
128
129 let range = range.map(|TypedHeader(range)| range);
130 let headers = get_response_headers(&dst_mime);
131
132 let eq_raw = dst_width.is_none()
134 && dst_height.is_none()
135 && f32::EPSILON > (dpr - 1.0)
136 && raw_mime == dst_mime;
137 let exclude = matches!(raw_mime, image::ImageFormat::Ico | image::ImageFormat::Gif);
138 if eq_raw || exclude {
139 trace!("Serving original image: {path:?}");
140 let file = load_file(&path).await?;
141 let body = KnownSize::file(file).await.unwrap();
142 let ranged = Ranged::new(range, body);
143 return Ok((headers, ranged).into_response());
144 }
145
146 if let Some(cached) = cache.lock().await.cache_get(&(path.clone(), query.clone())) {
147 trace!(
148 "Serving cached image: {path:?} (mime: {dst_mime:?}, size {:?}x{:?}, dpr: {dpr})",
149 dst_width.unwrap_or(0),
150 dst_height.unwrap_or(0)
151 );
152 let body = KnownSize::seek(Cursor::new(cached.clone())).await.unwrap();
153 return Ok((headers, Ranged::new(range, body)).into_response());
154 }
155
156 let file = load_file(&path).await?;
157 let src_image = load_image(file).await?;
158
159 let (dst_width, dst_height) = get_output_size(
160 (src_image.width(), src_image.height()),
161 (dst_width, dst_height),
162 dpr,
163 ce,
164 );
165
166 let mut dst_image = Image::new(dst_width, dst_height, src_image.pixel_type().unwrap());
167 resize_image(&config, &src_image, &mut dst_image)?;
168
169 let bytes = encode_image(dst_mime, &dst_image, src_image.color())?;
170
171 cache
173 .lock()
174 .await
175 .cache_set((path.clone(), query), bytes.clone());
176
177 let body = KnownSize::seek(Cursor::new(bytes)).await.unwrap();
178
179 trace!(
180 "Serving processed image: {path:?} (mime: {dst_mime:?}, size {dst_width:?}x{dst_height:?}, dpr: {dpr})"
181 );
182 Ok((headers, Ranged::new(range, body)).into_response())
183}
184
185fn get_path_and_mime(root: PathBuf, rel_path: PathBuf) -> Result<(PathBuf, ImageFormat)> {
186 let path = path_clean::clean(rel_path);
187 let path = path.strip_prefix("/").unwrap_or(&path);
188 let path = root.join(path);
189
190 if !path.exists() || !path.is_file() {
191 return Err((StatusCode::NOT_FOUND, "File not found".to_string()));
192 }
193
194 match find_image_mime(MimeGuess::from_path(&path)) {
195 Some(mime) => Ok((path.clone(), mime)),
196 None => Err((StatusCode::BAD_REQUEST, "Unsupported file type".to_string())),
197 }
198}
199
200fn find_image_mime(mime: MimeGuess) -> Option<ImageFormat> {
201 mime.into_iter()
202 .flat_map(|m| ImageFormat::from_mime_type(&m))
203 .next()
204}
205
206fn get_response_headers(image_format: &ImageFormat) -> HeaderMap {
207 debug!("Setting response headers for format: {image_format:?}");
208 let mut headers = HeaderMap::new();
209 for (name, value) in [
210 (CONTENT_TYPE, image_format.to_mime_type()),
211 (CACHE_CONTROL, "public, max-age=31536000"),
212 (X_CONTENT_TYPE_OPTIONS, "nosniff"),
213 ] {
214 debug!("Setting header: {name}: {value}");
215 headers.insert(name, HeaderValue::from_static(value));
216 }
217 debug!("Response headers set: {headers:?}");
218 headers
219}
220
221async fn load_file(path: &PathBuf) -> Result<File> {
222 debug!("Loading file: {path:?}");
223 File::open(&path).await.map_err(|_| {
224 (
225 StatusCode::INTERNAL_SERVER_ERROR,
226 "Failed to read image".to_string(),
227 )
228 })
229}
230
231fn get_output_size(
232 src: (u32, u32),
233 dst: (Option<u32>, Option<u32>),
234 dpr: f32,
235 ce: bool,
236) -> (u32, u32) {
237 let (src_width, src_height) = src;
238 let (dst_width, dst_height) = dst;
239 let aspect_ratio = src_width as f32 / src_height as f32;
240
241 let (mut width, mut height) = match (dst_width, dst_height) {
242 (Some(w), Some(h)) => (w, h),
243 (Some(w), None) => (w, (w as f32 / aspect_ratio).round() as u32),
244 (None, Some(h)) => ((h as f32 * aspect_ratio).round() as u32, h),
245 (None, None) => (src_width, src_height),
246 };
247
248 width = (width as f32 * dpr).round() as u32;
249 height = (height as f32 * dpr).round() as u32;
250
251 if ce {
252 let dst_aspect_ratio = width as f32 / height as f32;
253 if dst_aspect_ratio > aspect_ratio && width > src_width {
254 width = src_width;
255 height = (width as f32 / dst_aspect_ratio).round() as u32;
256 } else if dst_aspect_ratio < aspect_ratio && height > src_height {
257 height = src_height;
258 width = (height as f32 * dst_aspect_ratio).round() as u32;
259 }
260 }
261
262 (width, height)
263}
264
265async fn load_image(mut file: File) -> Result<DynamicImage> {
266 let mut buffer = Vec::new();
267 file.read_to_end(&mut buffer).await.unwrap();
268 load_from_memory(&buffer).map_err(|e| {
269 (
270 StatusCode::INTERNAL_SERVER_ERROR,
271 format!("Failed to decode image: {e}"),
272 )
273 })
274}
275
276fn resize_image(
277 config: &ResizeConfig,
278 src_image: &DynamicImage,
279 dst_image: &mut Image<'_>,
280) -> Result<()> {
281 let mut resizer = Resizer::new();
282
283 let algorithm = if cfg!(debug_assertions) {
284 ResizeAlg::Nearest
285 } else {
286 config.resize_algorithm()
287 };
288 let options = ResizeOptions::new()
289 .resize_alg(algorithm)
290 .fit_into_destination(Some((0.5, 0.5)));
291
292 resizer
293 .resize(src_image, dst_image, Some(&options))
294 .map_err(|_| {
295 (
296 StatusCode::INTERNAL_SERVER_ERROR,
297 "Failed to resize image".to_string(),
298 )
299 })
300}
301
302fn encode_image(format: ImageFormat, image: &Image<'_>, color: ColorType) -> Result<Bytes> {
303 macro_rules! match_format {
304 ($format: expr , $( $target: pat => $encoder: expr, )+ ) => {
305 match $format {$(
306 $target => $encoder
307 .write_image(image.buffer(), image.width(), image.height(), color.into())
308 .map_err(|e| {
309 (
310 StatusCode::INTERNAL_SERVER_ERROR,
311 format!("Failed to encode image: {e}"),
312 )
313 }),
314 )+
315 _ => Err((StatusCode::BAD_REQUEST, "Unsupported output format".to_string())),
316 }
317 };
318 }
319
320 let mut bytes = vec![];
321 match_format! {
322 format,
323 ImageFormat::WebP => WebPEncoder::new_lossless(&mut bytes),
324 ImageFormat::Png => PngEncoder::new(&mut bytes),
325 ImageFormat::Jpeg => JpegEncoder::new(&mut bytes),
326 }?;
327
328 Ok(Bytes::from(bytes))
329}