From 52478379a045fe9352ee2df53614c2941fa1c46a Mon Sep 17 00:00:00 2001 From: Jef Roosens Date: Thu, 16 Jan 2025 22:19:22 +0100 Subject: [PATCH] feat: implement simple file upload --- Cargo.lock | 61 +++++++++++++++++++++ Cargo.toml | 2 + src/db/mod.rs | 2 + src/db/models/image.rs | 18 +++++-- src/db/models/mod.rs | 2 +- src/main.rs | 13 ++++- src/server/error.rs | 26 ++++++++- src/server/images.rs | 93 +++++++++++++++++++++++++++++++++ src/server/mod.rs | 2 + templates/components/image.html | 16 ++++++ templates/views/plant.html | 6 +++ 11 files changed, 232 insertions(+), 9 deletions(-) create mode 100644 src/server/images.rs create mode 100644 templates/components/image.html diff --git a/Cargo.lock b/Cargo.lock index 0116774..af129db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -331,10 +331,12 @@ dependencies = [ "clap", "diesel", "diesel_migrations", + "futures", "rand", "serde", "tera", "tokio", + "tokio-util", "tower-http", "tracing", "tracing-subscriber", @@ -675,6 +677,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -682,6 +699,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -690,6 +708,34 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -708,10 +754,16 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -1534,6 +1586,15 @@ version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + [[package]] name = "slug" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index 43b4206..da5ef6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,12 +15,14 @@ chrono = { version = "0.4.39", features = ["serde"] } 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" rand = "0.8.5" # this dependency is needed soly because the r2d2_sqlite crate doesn't export # the 'chrono' feature flag serde = { version = "1.0.217", features = ["derive"] } tera = "1.20.0" tokio = { version = "1.42.0", features = ["full"] } +tokio-util = { version = "0.7.13", features = ["io"] } tower-http = { version = "0.6.2", features = ["compression-br", "compression-gzip", "set-header", "fs"] } tracing = "0.1.41" tracing-subscriber = "0.3.19" diff --git a/src/db/mod.rs b/src/db/mod.rs index 4991cad..8e8711e 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,4 +1,5 @@ mod models; +#[rustfmt::skip] mod schema; use diesel::{ @@ -10,6 +11,7 @@ use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; use std::{error::Error, fmt, path::Path}; pub use models::event::{Event, NewEvent, EVENT_TYPES}; +pub use models::image::{Image, NewImage}; pub use models::plant::{NewPlant, Plant}; pub use models::session::Session; pub use models::user::{NewUser, User}; diff --git a/src/db/models/image.rs b/src/db/models/image.rs index ae188c6..592c600 100644 --- a/src/db/models/image.rs +++ b/src/db/models/image.rs @@ -1,6 +1,6 @@ +use chrono::NaiveDate; use diesel::prelude::*; use serde::{Deserialize, Serialize}; -use chrono::NaiveDate; use crate::db::{schema::*, DbPool, DbResult}; @@ -8,10 +8,10 @@ use crate::db::{schema::*, DbPool, DbResult}; #[diesel(table_name = images)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct Image { - id: i32, - plant_id: i32, - date_taken: NaiveDate, - note: Option, + pub id: i32, + pub plant_id: i32, + pub date_taken: NaiveDate, + pub note: Option, } #[derive(Deserialize, Insertable)] @@ -24,6 +24,14 @@ pub struct NewImage { } impl NewImage { + pub fn new(plant_id: i32, date_taken: NaiveDate, note: Option) -> Self { + Self { + plant_id, + date_taken, + note, + } + } + pub fn insert(self, pool: &DbPool) -> DbResult { Ok(self .insert_into(images::table) diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 0b3e098..b1fe1a7 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -1,5 +1,5 @@ pub mod event; +pub mod image; pub mod plant; pub mod session; pub mod user; -pub mod image; diff --git a/src/main.rs b/src/main.rs index f1d0530..ac6a06e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,11 @@ mod cli; mod db; mod server; -use std::{fs, path::Path, sync::Arc}; +use std::{ + fs, + path::{Path, PathBuf}, + sync::Arc, +}; use clap::Parser; use tera::Tera; @@ -12,11 +16,13 @@ use cli::UserCmd; use db::DbError; const DB_FILENAME: &str = "db.sqlite3"; +const IMG_DIR: &str = "imgs"; #[derive(Clone)] pub struct Context { pool: db::DbPool, tera: Arc, + data_dir: PathBuf, } fn run_user_cli(data_dir: impl AsRef, cmd: UserCmd) -> Result<(), DbError> { @@ -59,6 +65,10 @@ async fn main() { fs::create_dir_all(&args.data_dir).unwrap(); } + if !fs::exists(args.data_dir.join(IMG_DIR)).unwrap() { + fs::create_dir(args.data_dir.join(IMG_DIR)).unwrap(); + } + let pool = db::initialize_db(args.data_dir.join(DB_FILENAME), true).unwrap(); let tera = @@ -67,6 +77,7 @@ async fn main() { let ctx = Context { pool, tera: Arc::new(tera), + data_dir: args.data_dir.clone(), }; 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 2765887..eb5f3a8 100644 --- a/src/server/error.rs +++ b/src/server/error.rs @@ -1,6 +1,6 @@ use std::fmt::{self, Write}; -use axum::{http::StatusCode, response::IntoResponse}; +use axum::{extract::multipart::MultipartError, http::StatusCode, response::IntoResponse}; use crate::db; @@ -8,6 +8,9 @@ use crate::db; pub enum AppError { Db(db::DbError), Tera(tera::Error), + Multipart(MultipartError), + IO(std::io::Error), + BadRequest, Unauthorized, NotFound, } @@ -17,6 +20,9 @@ impl fmt::Display for AppError { match self { Self::Db(_) => write!(f, "database error"), Self::Tera(_) => write!(f, "error rendering template"), + Self::Multipart(_) => write!(f, "error processing multipart request"), + Self::IO(_) => write!(f, "io error"), + Self::BadRequest => write!(f, "bad request"), Self::Unauthorized => write!(f, "unauthorized"), Self::NotFound => write!(f, "not found"), } @@ -28,7 +34,9 @@ impl std::error::Error for AppError { match self { Self::Db(err) => Some(err), Self::Tera(err) => Some(err), - Self::NotFound | Self::Unauthorized => None, + Self::IO(err) => Some(err), + Self::Multipart(err) => Some(err), + Self::NotFound | Self::Unauthorized | Self::BadRequest => None, } } } @@ -63,11 +71,25 @@ impl From for AppError { } } +impl From for AppError { + fn from(value: MultipartError) -> Self { + Self::Multipart(value) + } +} + +impl From for AppError { + fn from(value: std::io::Error) -> Self { + Self::IO(value) + } +} + impl IntoResponse for AppError { fn into_response(self) -> axum::response::Response { match self { Self::NotFound => StatusCode::NOT_FOUND.into_response(), Self::Unauthorized => StatusCode::UNAUTHORIZED.into_response(), + Self::BadRequest => StatusCode::BAD_REQUEST.into_response(), + Self::Multipart(err) => err.into_response(), _ => { tracing::error!("{}", self.stack()); diff --git a/src/server/images.rs b/src/server/images.rs new file mode 100644 index 0000000..20b7c0e --- /dev/null +++ b/src/server/images.rs @@ -0,0 +1,93 @@ +use axum::{ + extract::{multipart::Field, Multipart, State}, + response::Html, + routing::post, + Router, +}; +use chrono::NaiveDate; +use futures::TryStreamExt; +use tokio_util::io::StreamReader; + +use super::error::AppError; +use crate::db::NewImage; + +pub fn app() -> axum::Router { + Router::new().route("/", post(post_image)) +} + +async fn post_image( + State(ctx): State, + mut mt: Multipart, +) -> super::Result> { + let mut plant_id: Option = None; + let mut date_taken: Option = None; + let mut note: Option = None; + + while let Some(field) = mt.next_field().await? { + match field.name() { + Some("plant_id") => { + plant_id = Some( + field + .text() + .await? + .parse() + .map_err(|_| AppError::BadRequest)?, + ); + } + Some("date_taken") => { + date_taken = Some( + field + .text() + .await? + .parse() + .map_err(|_| AppError::BadRequest)?, + ); + } + Some("note") => { + 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); + } + + receive_image(ctx, plant_id.unwrap(), date_taken.unwrap(), note, field).await?; + + return Ok(Html(String::new())); + } + _ => { + return Err(AppError::BadRequest); + } + } + } + + 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()?; + + 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?; + + Ok(()) +} diff --git a/src/server/mod.rs b/src/server/mod.rs index cd66418..8d3a472 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,6 +1,7 @@ mod auth; mod error; mod events; +mod images; mod plants; use std::path::Path; @@ -55,6 +56,7 @@ pub fn app(ctx: crate::Context, static_dir: impl AsRef) -> axum::Router { let router = Router::new() .nest("/plants", plants::app()) .nest("/events", events::app()) + .nest("/images", images::app()) .layer(middleware::from_fn_with_state( ctx.clone(), auth::auth_middleware, diff --git a/templates/components/image.html b/templates/components/image.html new file mode 100644 index 0000000..bd05e81 --- /dev/null +++ b/templates/components/image.html @@ -0,0 +1,16 @@ +{% macro form(plant_id, target="#images > ul") %} +
+ + + +
+ + +
+ + +
+ + +
+{% endmacro form %} diff --git a/templates/views/plant.html b/templates/views/plant.html index 7510068..c30e029 100644 --- a/templates/views/plant.html +++ b/templates/views/plant.html @@ -1,7 +1,13 @@ {% import "components/event.html" as comp_event %} {% import "components/plant.html" as comp_plant %} +{% import "components/image.html" as comp_image %} {{ comp_plant::info(plant=plant) }}

Events

{{ comp_event::list(events=events) }} {{ comp_event::form(plant_id=plant.id) }} +

Images

+
+
    +
    +{{ comp_image::form(plant_id=plant.id) }}