diff --git a/Cargo.lock b/Cargo.lock index af129db..0733513 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -247,6 +247,12 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.6.0" @@ -308,12 +314,24 @@ version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +[[package]] +name = "bytemuck" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef657dfab802224e671f5818e9a4935f9b1957ed18e58292690cc39e7a4092a3" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.9.0" @@ -332,6 +350,7 @@ dependencies = [ "diesel", "diesel_migrations", "futures", + "image", "rand", "serde", "tera", @@ -434,6 +453,12 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.3" @@ -652,6 +677,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "flate2" version = "1.0.35" @@ -787,6 +821,16 @@ dependencies = [ "wasi", ] +[[package]] +name = "gif" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb2d69b19215e18bb912fa30f7ce15846e301408695e44e0ef719f1da9e19f2" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gimli" version = "0.31.1" @@ -812,7 +856,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ - "bitflags", + "bitflags 2.6.0", "ignore", "walkdir", ] @@ -970,6 +1014,22 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "image" +version = "0.25.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6f44aed642f18953a158afeb30206f4d50da59fbc66ecb53c66488de73563b" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + [[package]] name = "indexmap" version = "2.7.0" @@ -1102,6 +1162,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ffbe83022cedc1d264172192511ae958937694cd57ce297164951b8b3568394" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -1328,6 +1389,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1408,7 +1482,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" dependencies = [ - "bitflags", + "bitflags 2.6.0", ] [[package]] @@ -1580,6 +1654,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "siphasher" version = "0.3.11" @@ -1838,7 +1918,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697" dependencies = [ "async-compression", - "bitflags", + "bitflags 2.6.0", "bytes", "futures-core", "futures-util", @@ -2096,6 +2176,12 @@ version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" +[[package]] +name = "weezl" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" + [[package]] name = "winapi" version = "0.3.9" @@ -2247,3 +2333,18 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99a5bab8d7dedf81405c4bb1f2b83ea057643d9cb28778cea9eecddeedd2e028" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index da5ef6e..0efdb25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ clap = { version = "4.5.26", features = ["derive", "env"] } diesel = { version = "2.2.6", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "r2d2", "chrono"] } diesel_migrations = { version = "2.2.0", features = ["sqlite"] } futures = "0.3.31" +image = { version = "0.25.5", default-features = false, features = ["gif", "jpeg", "png"] } rand = "0.8.5" # this dependency is needed soly because the r2d2_sqlite crate doesn't export # the 'chrono' feature flag diff --git a/migrations/2025-01-16-195902_images/up.sql b/migrations/2025-01-16-195902_images/up.sql index 191ca73..28a02d8 100644 --- a/migrations/2025-01-16-195902_images/up.sql +++ b/migrations/2025-01-16-195902_images/up.sql @@ -5,5 +5,6 @@ create table images ( -- Keep entries in the database so the files can be removed later on delete set null, date_taken date not null, + mime_type text not null, note text ); diff --git a/src/db/models/image.rs b/src/db/models/image.rs index 592c600..48e2825 100644 --- a/src/db/models/image.rs +++ b/src/db/models/image.rs @@ -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, } @@ -20,14 +21,21 @@ pub struct Image { pub struct NewImage { plant_id: i32, date_taken: NaiveDate, + mime_type: String, note: Option, } impl NewImage { - pub fn new(plant_id: i32, date_taken: NaiveDate, note: Option) -> Self { + pub fn new( + plant_id: i32, + date_taken: NaiveDate, + mime_type: String, + note: Option, + ) -> Self { Self { plant_id, date_taken, + mime_type, note, } } diff --git a/src/db/schema.rs b/src/db/schema.rs index 71db738..4ae1375 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -15,6 +15,7 @@ diesel::table! { id -> Integer, plant_id -> Integer, date_taken -> Date, + mime_type -> Text, note -> Nullable, } } diff --git a/src/main.rs b/src/main.rs index ac6a06e..5536a58 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, data_dir: PathBuf, + tmp_dir_counter: Arc>, +} + +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, 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)); diff --git a/src/server/error.rs b/src/server/error.rs index eb5f3a8..79370ac 100644 --- a/src/server/error.rs +++ b/src/server/error.rs @@ -83,6 +83,15 @@ impl From for AppError { } } +impl From 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 { diff --git a/src/server/images.rs b/src/server/images.rs index 20b7c0e..414b931 100644 --- a/src/server/images.rs +++ b/src/server/images.rs @@ -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 { - 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 = None; let mut note: Option = 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, - 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 { + 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) }