feat: implement thumbnail processing for image uploads
parent
52478379a0
commit
efb5b9ebea
|
@ -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",
|
||||
]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ diesel::table! {
|
|||
id -> Integer,
|
||||
plant_id -> Integer,
|
||||
date_taken -> Date,
|
||||
mime_type -> Text,
|
||||
note -> Nullable<Text>,
|
||||
}
|
||||
}
|
||||
|
|
20
src/main.rs
20
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<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));
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue