feat: implement thumbnail processing for image uploads

This commit is contained in:
Jef Roosens 2025-01-17 11:57:36 +01:00
parent 52478379a0
commit efb5b9ebea
Signed by: Jef Roosens
GPG key ID: 21FD3D77D56BAF49
8 changed files with 215 additions and 37 deletions

View file

@ -11,6 +11,7 @@ pub struct Image {
pub id: i32,
pub plant_id: i32,
pub date_taken: NaiveDate,
pub mime_type: String,
pub note: Option<String>,
}
@ -20,14 +21,21 @@ pub struct Image {
pub struct NewImage {
plant_id: i32,
date_taken: NaiveDate,
mime_type: String,
note: Option<String>,
}
impl NewImage {
pub fn new(plant_id: i32, date_taken: NaiveDate, note: Option<String>) -> Self {
pub fn new(
plant_id: i32,
date_taken: NaiveDate,
mime_type: String,
note: Option<String>,
) -> Self {
Self {
plant_id,
date_taken,
mime_type,
note,
}
}

View file

@ -15,6 +15,7 @@ diesel::table! {
id -> Integer,
plant_id -> Integer,
date_taken -> Date,
mime_type -> Text,
note -> Nullable<Text>,
}
}

View file

@ -10,6 +10,7 @@ use std::{
use clap::Parser;
use tera::Tera;
use tokio::sync::Mutex;
use tower_http::compression::CompressionLayer;
use cli::UserCmd;
@ -17,12 +18,26 @@ use db::DbError;
const DB_FILENAME: &str = "db.sqlite3";
const IMG_DIR: &str = "imgs";
const TMP_DIR: &str = "tmp";
#[derive(Clone)]
pub struct Context {
pool: db::DbPool,
tera: Arc<Tera>,
data_dir: PathBuf,
tmp_dir_counter: Arc<Mutex<u16>>,
}
impl Context {
pub async fn tmp_file_path(&self) -> PathBuf {
let mut guard = self.tmp_dir_counter.lock().await;
let path = self.data_dir.join(TMP_DIR).join(guard.to_string());
*guard = guard.wrapping_add(1);
path
}
}
fn run_user_cli(data_dir: impl AsRef<Path>, cmd: UserCmd) -> Result<(), DbError> {
@ -69,6 +84,10 @@ async fn main() {
fs::create_dir(args.data_dir.join(IMG_DIR)).unwrap();
}
if !fs::exists(args.data_dir.join(TMP_DIR)).unwrap() {
fs::create_dir(args.data_dir.join(TMP_DIR)).unwrap();
}
let pool = db::initialize_db(args.data_dir.join(DB_FILENAME), true).unwrap();
let tera =
@ -78,6 +97,7 @@ async fn main() {
pool,
tera: Arc::new(tera),
data_dir: args.data_dir.clone(),
tmp_dir_counter: Arc::new(Mutex::new(0)),
};
let app = server::app(ctx, &args.static_dir)
.layer(CompressionLayer::new().br(true).gzip(true));

View file

@ -83,6 +83,15 @@ impl From<std::io::Error> for AppError {
}
}
impl From<image::ImageError> for AppError {
fn from(value: image::ImageError) -> Self {
match value {
image::ImageError::IoError(err) => Self::IO(err),
_ => Self::BadRequest,
}
}
}
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
match self {

View file

@ -1,18 +1,30 @@
use axum::{
extract::{multipart::Field, Multipart, State},
extract::{DefaultBodyLimit, Multipart, State},
handler::Handler,
response::Html,
routing::post,
Router,
};
use chrono::NaiveDate;
use futures::TryStreamExt;
use image::{codecs::jpeg::JpegEncoder, ImageReader};
use tokio_util::io::StreamReader;
use std::{io::BufWriter, path::PathBuf};
use super::error::AppError;
use crate::db::NewImage;
use crate::{
db::{Image, NewImage},
IMG_DIR,
};
const THUMBNAIL_EXT: &str = "thumb";
pub fn app() -> axum::Router<crate::Context> {
Router::new().route("/", post(post_image))
Router::new().route(
"/",
post(post_image.layer(DefaultBodyLimit::max(1024 * 1024 * 20))),
)
}
async fn post_image(
@ -23,6 +35,9 @@ async fn post_image(
let mut date_taken: Option<NaiveDate> = None;
let mut note: Option<String> = None;
let mut img_paths: Option<(PathBuf, PathBuf)> = None;
let mut mime_type: Option<&'static str> = None;
while let Some(field) = mt.next_field().await? {
match field.name() {
Some("plant_id") => {
@ -47,15 +62,21 @@ async fn post_image(
note = Some(field.text().await?);
}
Some("image") => {
// These fields are required to be provided before the image field (note is
// optional)
if plant_id.is_none() || date_taken.is_none() {
return Err(AppError::BadRequest);
}
let path = ctx.tmp_file_path().await;
receive_image(ctx, plant_id.unwrap(), date_taken.unwrap(), note, field).await?;
let mut f = tokio::fs::File::create(&path).await?;
let mut r = StreamReader::new(field.map_err(std::io::Error::other));
return Ok(Html(String::new()));
tokio::io::copy(&mut r, &mut f).await?;
let jpeg_path = ctx.tmp_file_path().await;
img_paths = Some((path.clone(), jpeg_path.clone()));
mime_type = Some(
tokio::task::spawn_blocking(move || process_image(path, jpeg_path))
.await
.unwrap()?,
);
}
_ => {
return Err(AppError::BadRequest);
@ -63,31 +84,47 @@ async fn post_image(
}
}
Err(AppError::BadRequest)
if let (Some(plant_id), Some(date_taken), Some((img_path, jpeg_path)), Some(mime_type)) =
(plant_id, date_taken, img_paths, mime_type)
{
let image = NewImage::new(plant_id, date_taken, mime_type.to_string(), note);
let image =
tokio::task::spawn_blocking(move || insert_image(&ctx, image, img_path, jpeg_path))
.await
.unwrap()?;
Ok(Html(String::new()))
} else {
Err(AppError::BadRequest)
}
}
async fn receive_image<'a>(
ctx: crate::Context,
plant_id: i32,
date_taken: NaiveDate,
note: Option<String>,
field: Field<'a>,
) -> super::Result<()> {
let image = tokio::task::spawn_blocking(move || {
NewImage::new(plant_id, date_taken, note).insert(&ctx.pool)
})
.await
.unwrap()?;
fn process_image(path: PathBuf, jpg_path: PathBuf) -> super::Result<&'static str> {
let reader = ImageReader::open(path)?.with_guessed_format()?;
let mime_type = reader.format().ok_or(AppError::BadRequest)?.to_mime_type();
let mut r = StreamReader::new(field.map_err(std::io::Error::other));
let mut f = tokio::fs::File::create(
ctx.data_dir
.join(crate::IMG_DIR)
.join(image.id.to_string())
.with_extension("png"),
)
.await?;
tokio::io::copy(&mut r, &mut f).await?;
// Decode image and resize to fit into 720 x 720 square
let img = reader.decode()?.thumbnail(720, 720);
Ok(())
// Write image as compressed JPEG
let jpeg_f = BufWriter::new(std::fs::File::create(jpg_path)?);
let encoder = JpegEncoder::new_with_quality(jpeg_f, 60);
img.write_with_encoder(encoder)?;
Ok(mime_type)
}
fn insert_image(
ctx: &crate::Context,
image: NewImage,
img_path: PathBuf,
jpeg_path: PathBuf,
) -> super::Result<Image> {
let image = image.insert(&ctx.pool)?;
let dest_path = ctx.data_dir.join(IMG_DIR).join(image.id.to_string());
std::fs::rename(img_path, &dest_path)?;
std::fs::rename(jpeg_path, dest_path.with_extension(THUMBNAIL_EXT))?;
Ok(image)
}