Skip to main content

image_provider/
lib.rs

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; // 1 days in seconds
33    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_or(1.0)
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    // If no resizing is needed, serve the original file directly
133    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 the processed image
172    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    use std::path::Component;
187
188    let cleaned = path_clean::clean(rel_path);
189
190    let is_valid = cleaned
191        .components()
192        .all(|c| matches!(c, Component::Normal(_)));
193    if !is_valid {
194        return Err((StatusCode::NOT_FOUND, "File not found".to_string()));
195    }
196
197    let path = root.join(&cleaned);
198
199    if !path.exists() || !path.is_file() {
200        return Err((StatusCode::NOT_FOUND, "File not found".to_string()));
201    }
202
203    match find_image_mime(MimeGuess::from_path(&path)) {
204        Some(mime) => Ok((path.clone(), mime)),
205        None => Err((StatusCode::BAD_REQUEST, "Unsupported file type".to_string())),
206    }
207}
208
209fn find_image_mime(mime: MimeGuess) -> Option<ImageFormat> {
210    mime.into_iter()
211        .flat_map(|m| ImageFormat::from_mime_type(&m))
212        .next()
213}
214
215fn get_response_headers(image_format: &ImageFormat) -> HeaderMap {
216    debug!("Setting response headers for format: {image_format:?}");
217    let mut headers = HeaderMap::new();
218    for (name, value) in [
219        (CONTENT_TYPE, image_format.to_mime_type()),
220        (CACHE_CONTROL, "public, max-age=31536000"),
221        (X_CONTENT_TYPE_OPTIONS, "nosniff"),
222    ] {
223        debug!("Setting header: {name}: {value}");
224        headers.insert(name, HeaderValue::from_static(value));
225    }
226    debug!("Response headers set: {headers:?}");
227    headers
228}
229
230async fn load_file(path: &PathBuf) -> Result<File> {
231    debug!("Loading file: {path:?}");
232    File::open(&path).await.map_err(|_| {
233        (
234            StatusCode::INTERNAL_SERVER_ERROR,
235            "Failed to read image".to_string(),
236        )
237    })
238}
239
240fn get_output_size(
241    src: (u32, u32),
242    dst: (Option<u32>, Option<u32>),
243    dpr: f32,
244    ce: bool,
245) -> (u32, u32) {
246    let (src_width, src_height) = src;
247    let (dst_width, dst_height) = dst;
248    let aspect_ratio = src_width as f32 / src_height as f32;
249
250    let (mut width, mut height) = match (dst_width, dst_height) {
251        (Some(w), Some(h)) => (w, h),
252        (Some(w), None) => (w, (w as f32 / aspect_ratio).round() as u32),
253        (None, Some(h)) => ((h as f32 * aspect_ratio).round() as u32, h),
254        (None, None) => (src_width, src_height),
255    };
256
257    width = (width as f32 * dpr).round() as u32;
258    height = (height as f32 * dpr).round() as u32;
259
260    if ce {
261        let dst_aspect_ratio = width as f32 / height as f32;
262        if dst_aspect_ratio > aspect_ratio && width > src_width {
263            width = src_width;
264            height = (width as f32 / dst_aspect_ratio).round() as u32;
265        } else if dst_aspect_ratio < aspect_ratio && height > src_height {
266            height = src_height;
267            width = (height as f32 * dst_aspect_ratio).round() as u32;
268        }
269    }
270
271    (width, height)
272}
273
274async fn load_image(mut file: File) -> Result<DynamicImage> {
275    let mut buffer = Vec::new();
276    file.read_to_end(&mut buffer).await.map_err(|_| {
277        (
278            StatusCode::INTERNAL_SERVER_ERROR,
279            "Failed to read image".to_string(),
280        )
281    })?;
282    load_from_memory(&buffer).map_err(|e| {
283        (
284            StatusCode::INTERNAL_SERVER_ERROR,
285            format!("Failed to decode image: {e}"),
286        )
287    })
288}
289
290fn resize_image(
291    config: &ResizeConfig,
292    src_image: &DynamicImage,
293    dst_image: &mut Image<'_>,
294) -> Result<()> {
295    let mut resizer = Resizer::new();
296
297    let algorithm = if cfg!(debug_assertions) {
298        ResizeAlg::Nearest
299    } else {
300        config.resize_algorithm()
301    };
302    let options = ResizeOptions::new()
303        .resize_alg(algorithm)
304        .fit_into_destination(Some((0.5, 0.5)));
305
306    resizer
307        .resize(src_image, dst_image, Some(&options))
308        .map_err(|_| {
309            (
310                StatusCode::INTERNAL_SERVER_ERROR,
311                "Failed to resize image".to_string(),
312            )
313        })
314}
315
316fn encode_image(format: ImageFormat, image: &Image<'_>, color: ColorType) -> Result<Bytes> {
317    macro_rules! match_format {
318        ($format: expr , $( $target: pat => $encoder: expr, )+ ) => {
319            match $format {$(
320                $target => $encoder
321                    .write_image(image.buffer(), image.width(), image.height(), color.into())
322                    .map_err(|e| {
323                        (
324                            StatusCode::INTERNAL_SERVER_ERROR,
325                            format!("Failed to encode image: {e}"),
326                        )
327                    }),
328            )+
329                _ => Err((StatusCode::BAD_REQUEST, "Unsupported output format".to_string())),
330            }
331        };
332    }
333
334    let mut bytes = vec![];
335    match_format! {
336        format,
337        ImageFormat::WebP => WebPEncoder::new_lossless(&mut bytes),
338        ImageFormat::Png => PngEncoder::new(&mut bytes),
339        ImageFormat::Jpeg => JpegEncoder::new(&mut bytes),
340    }?;
341
342    Ok(Bytes::from(bytes))
343}