feat: implement simple file upload
							parent
							
								
									c10b9baa95
								
							
						
					
					
						commit
						52478379a0
					
				|  | @ -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" | ||||
|  |  | |||
|  | @ -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" | ||||
|  |  | |||
|  | @ -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}; | ||||
|  |  | |||
|  | @ -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<String>, | ||||
|     pub id: i32, | ||||
|     pub plant_id: i32, | ||||
|     pub date_taken: NaiveDate, | ||||
|     pub note: Option<String>, | ||||
| } | ||||
| 
 | ||||
| #[derive(Deserialize, Insertable)] | ||||
|  | @ -24,6 +24,14 @@ pub struct NewImage { | |||
| } | ||||
| 
 | ||||
| impl NewImage { | ||||
|     pub fn new(plant_id: i32, date_taken: NaiveDate, note: Option<String>) -> Self { | ||||
|         Self { | ||||
|             plant_id, | ||||
|             date_taken, | ||||
|             note, | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     pub fn insert(self, pool: &DbPool) -> DbResult<Image> { | ||||
|         Ok(self | ||||
|             .insert_into(images::table) | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| pub mod event; | ||||
| pub mod image; | ||||
| pub mod plant; | ||||
| pub mod session; | ||||
| pub mod user; | ||||
| pub mod image; | ||||
|  |  | |||
							
								
								
									
										13
									
								
								src/main.rs
								
								
								
								
							
							
						
						
									
										13
									
								
								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<Tera>, | ||||
|     data_dir: PathBuf, | ||||
| } | ||||
| 
 | ||||
| fn run_user_cli(data_dir: impl AsRef<Path>, 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)); | ||||
|  |  | |||
|  | @ -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<tera::Error> for AppError { | |||
|     } | ||||
| } | ||||
| 
 | ||||
| impl From<MultipartError> for AppError { | ||||
|     fn from(value: MultipartError) -> Self { | ||||
|         Self::Multipart(value) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl From<std::io::Error> 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()); | ||||
| 
 | ||||
|  |  | |||
|  | @ -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<crate::Context> { | ||||
|     Router::new().route("/", post(post_image)) | ||||
| } | ||||
| 
 | ||||
| async fn post_image( | ||||
|     State(ctx): State<crate::Context>, | ||||
|     mut mt: Multipart, | ||||
| ) -> super::Result<Html<String>> { | ||||
|     let mut plant_id: Option<i32> = None; | ||||
|     let mut date_taken: Option<NaiveDate> = None; | ||||
|     let mut note: Option<String> = 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<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()?; | ||||
| 
 | ||||
|     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(()) | ||||
| } | ||||
|  | @ -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<Path>) -> 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, | ||||
|  |  | |||
|  | @ -0,0 +1,16 @@ | |||
| {% macro form(plant_id, target="#images > ul") %} | ||||
| <form hx-post="/images" hx-target="{{ target }}" hx-swap="beforeend" enctype="multipart/form-data"> | ||||
|     <input type="hidden" id="plant_id" name="plant_id" value="{{ plant_id }}"> | ||||
| 
 | ||||
|     <label for="date_taken">Date taken:</label> | ||||
|     <input type="date" id="date_taken" name="date_taken"></br> | ||||
| 
 | ||||
|     <label for="note">Note:</label> | ||||
|     <textarea id="note" name="note" rows=1></textarea></br> | ||||
| 
 | ||||
|     <label for="image">Image:</label> | ||||
|     <input type="file" id="image" name="image" accept="image/*" capture="environment"></br> | ||||
| 
 | ||||
|     <input type="submit"> | ||||
| </form> | ||||
| {% endmacro form %} | ||||
|  | @ -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) }} | ||||
| <h3>Events</h3> | ||||
| {{ comp_event::list(events=events) }} | ||||
| {{ comp_event::form(plant_id=plant.id) }} | ||||
| <h3>Images</h3> | ||||
| <div id="images"> | ||||
|     <ul></ul> | ||||
| </div> | ||||
| {{ comp_image::form(plant_id=plant.id) }} | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue