feat: add image processing handler with resize/thumbnail/rotate/format
This commit is contained in:
parent
8109740e94
commit
1f009f57c8
273
src/api/image.rs
Normal file
273
src/api/image.rs
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
#![allow(clippy::unused_unit)]
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, Query},
|
||||||
|
http::{HeaderValue, StatusCode, header},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
use serde::Deserialize;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
use moka::future::Cache;
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
const MAX_IMAGE_DIMENSION: u32 = 4096;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
const DEFAULT_JPEG_QUALITY: u8 = 85;
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct CachedImage {
|
||||||
|
data: Vec<u8>,
|
||||||
|
content_type: HeaderValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
static IMAGE_CACHE: LazyLock<Cache<String, CachedImage>> = LazyLock::new(|| {
|
||||||
|
Cache::builder()
|
||||||
|
.max_capacity(100)
|
||||||
|
.time_to_idle(std::time::Duration::from_secs(300))
|
||||||
|
.build()
|
||||||
|
});
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
#[derive(Debug, Deserialize, Clone, Hash, Eq, PartialEq, Default)]
|
||||||
|
pub struct ImageParams {
|
||||||
|
pub w: Option<u32>,
|
||||||
|
pub h: Option<u32>,
|
||||||
|
pub thumb: Option<String>,
|
||||||
|
pub rotate: Option<u16>,
|
||||||
|
pub format: Option<String>,
|
||||||
|
pub quality: Option<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
impl ImageParams {
|
||||||
|
fn is_empty(&self) -> bool {
|
||||||
|
self.w.is_none()
|
||||||
|
&& self.h.is_none()
|
||||||
|
&& self.thumb.is_none()
|
||||||
|
&& self.rotate.is_none()
|
||||||
|
&& self.format.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_key(&self, path: &str) -> String {
|
||||||
|
let mut key = format!(
|
||||||
|
"{}?w={:?}&h={:?}&thumb={:?}&rotate={:?}&format={:?}",
|
||||||
|
path, self.w, self.h, self.thumb, self.rotate, self.format,
|
||||||
|
);
|
||||||
|
if let Some(q) = self.quality {
|
||||||
|
key.push_str(&format!("&quality={}", q));
|
||||||
|
}
|
||||||
|
key
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(&self) -> Result<(), StatusCode> {
|
||||||
|
if let Some(dim) = self.w {
|
||||||
|
if dim == 0 || dim > MAX_IMAGE_DIMENSION {
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(dim) = self.h {
|
||||||
|
if dim == 0 || dim > MAX_IMAGE_DIMENSION {
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(r) = self.rotate {
|
||||||
|
if !matches!(r, 0 | 90 | 180 | 270) {
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref fmt) = self.format {
|
||||||
|
if !matches!(fmt.as_str(), "jpeg" | "jpg" | "png" | "webp") {
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(q) = self.quality {
|
||||||
|
if q == 0 || q > 100 {
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
fn detect_format(path: &str) -> image::ImageFormat {
|
||||||
|
let lower = path.to_lowercase();
|
||||||
|
if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {
|
||||||
|
image::ImageFormat::Jpeg
|
||||||
|
} else if lower.ends_with(".png") {
|
||||||
|
image::ImageFormat::Png
|
||||||
|
} else if lower.ends_with(".webp") {
|
||||||
|
image::ImageFormat::WebP
|
||||||
|
} else if lower.ends_with(".gif") {
|
||||||
|
image::ImageFormat::Gif
|
||||||
|
} else {
|
||||||
|
image::ImageFormat::Jpeg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
fn content_type(format: image::ImageFormat) -> HeaderValue {
|
||||||
|
match format {
|
||||||
|
image::ImageFormat::Jpeg => HeaderValue::from_static("image/jpeg"),
|
||||||
|
image::ImageFormat::Png => HeaderValue::from_static("image/png"),
|
||||||
|
image::ImageFormat::WebP => HeaderValue::from_static("image/webp"),
|
||||||
|
image::ImageFormat::Gif => HeaderValue::from_static("image/gif"),
|
||||||
|
_ => HeaderValue::from_static("application/octet-stream"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
fn process_image(
|
||||||
|
img: image::DynamicImage,
|
||||||
|
params: &ImageParams,
|
||||||
|
original_format: image::ImageFormat,
|
||||||
|
) -> Result<(Vec<u8>, HeaderValue), StatusCode> {
|
||||||
|
let mut img = img;
|
||||||
|
|
||||||
|
// Rotate first, then resize
|
||||||
|
if let Some(degrees) = params.rotate {
|
||||||
|
img = match degrees {
|
||||||
|
90 => img.rotate90(),
|
||||||
|
180 => img.rotate180(),
|
||||||
|
270 => img.rotate270(),
|
||||||
|
_ => img,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize by max dimensions (keep aspect ratio)
|
||||||
|
if params.w.is_some() || params.h.is_some() {
|
||||||
|
let max_w = params.w.unwrap_or(img.width());
|
||||||
|
let max_h = params.h.unwrap_or(img.height());
|
||||||
|
if img.width() > max_w || img.height() > max_h {
|
||||||
|
img = img.resize(max_w, max_h, image::imageops::FilterType::Lanczos3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumbnail: fit-in-box (same semantics as resize, but both dimensions required)
|
||||||
|
if let Some(ref thumb_spec) = params.thumb {
|
||||||
|
let parts: Vec<&str> = thumb_spec.split('x').collect();
|
||||||
|
if parts.len() == 2 {
|
||||||
|
let tw: u32 = parts[0].parse().map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
|
let th: u32 = parts[1].parse().map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
|
if tw > 0 && th > 0 && tw <= MAX_IMAGE_DIMENSION && th <= MAX_IMAGE_DIMENSION {
|
||||||
|
img = img.thumbnail(tw, th);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output format
|
||||||
|
let output_format = match params.format.as_deref() {
|
||||||
|
Some("webp") => image::ImageFormat::WebP,
|
||||||
|
Some("png") => image::ImageFormat::Png,
|
||||||
|
Some("jpeg") | Some("jpg") => image::ImageFormat::Jpeg,
|
||||||
|
_ => original_format,
|
||||||
|
};
|
||||||
|
|
||||||
|
let quality = params.quality.unwrap_or(DEFAULT_JPEG_QUALITY);
|
||||||
|
|
||||||
|
let mut buf = std::io::Cursor::new(Vec::new());
|
||||||
|
match output_format {
|
||||||
|
image::ImageFormat::Jpeg => {
|
||||||
|
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality);
|
||||||
|
img.write_with_encoder(encoder)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
img.write_to(&mut buf, output_format)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ct = content_type(output_format);
|
||||||
|
Ok((buf.into_inner(), ct))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
fn is_path_safe(path: &str) -> bool {
|
||||||
|
// Reject paths with parent directory references or null bytes
|
||||||
|
if path.contains("..") || path.contains('\0') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Reject absolute paths
|
||||||
|
if path.starts_with('/') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
pub async fn serve_image(
|
||||||
|
Path(path): Path<String>,
|
||||||
|
Query(params): Query<ImageParams>,
|
||||||
|
) -> Response {
|
||||||
|
// Path traversal protection
|
||||||
|
if !is_path_safe(&path) {
|
||||||
|
return StatusCode::FORBIDDEN.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_path = format!("uploads/{}", path);
|
||||||
|
|
||||||
|
// Validate params
|
||||||
|
if let Err(status) = params.validate() {
|
||||||
|
return status.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
// No processing params: return raw file
|
||||||
|
if params.is_empty() {
|
||||||
|
return match tokio::fs::read(&file_path).await {
|
||||||
|
Ok(data) => {
|
||||||
|
let ct = content_type(detect_format(&path));
|
||||||
|
(StatusCode::OK, [(header::CONTENT_TYPE, ct)], data).into_response()
|
||||||
|
}
|
||||||
|
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
let cache_key = params.cache_key(&path);
|
||||||
|
if let Some(cached) = IMAGE_CACHE.get(&cache_key).await {
|
||||||
|
return (StatusCode::OK, [(header::CONTENT_TYPE, cached.content_type)], cached.data)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read file
|
||||||
|
let data = match tokio::fs::read(&file_path).await {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(_) => return StatusCode::NOT_FOUND.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load image
|
||||||
|
let original_format = detect_format(&path);
|
||||||
|
let img = match image::load_from_memory_with_format(&data, original_format) {
|
||||||
|
Ok(img) => img,
|
||||||
|
Err(_) => {
|
||||||
|
// Not a valid image or unsupported format, return raw
|
||||||
|
let ct = content_type(original_format);
|
||||||
|
return (StatusCode::OK, [(header::CONTENT_TYPE, ct)], data).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process
|
||||||
|
let (processed, content_type) = match process_image(img, ¶ms, original_format) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(status) => return status.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache result
|
||||||
|
let cached = CachedImage {
|
||||||
|
data: processed.clone(),
|
||||||
|
content_type: content_type.clone(),
|
||||||
|
};
|
||||||
|
let _ = IMAGE_CACHE.insert(cache_key, cached).await;
|
||||||
|
|
||||||
|
(StatusCode::OK, [(header::CONTENT_TYPE, content_type)], processed).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "server"))]
|
||||||
|
pub async fn serve_image() {}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod image;
|
||||||
pub mod posts;
|
pub mod posts;
|
||||||
pub mod upload;
|
pub mod upload;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user