diff --git a/Cargo.lock b/Cargo.lock index 75a55a3..887ff37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -415,6 +415,7 @@ dependencies = [ "chrono", "clap", "dotenvy", + "image", "isocountry", "once_cell", "open", @@ -450,12 +451,24 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[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.11.1" @@ -836,6 +849,15 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1373,6 +1395,32 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -1596,6 +1644,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "nom" version = "7.1.3" @@ -1863,6 +1921,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags 2.10.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "portpicker" version = "0.1.1" @@ -1921,6 +1992,21 @@ dependencies = [ "psl-types", ] +[[package]] +name = "pxfm" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quinn" version = "0.11.9" @@ -3968,3 +4054,18 @@ name = "zmij" version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 4a8c07c..3a0c58a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ base64 = "0.22" chrono = { version = "0.4", features = ["serde", "clock"] } clap = { version = "4.5", features = ["derive", "env"] } dotenvy = "0.15" +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } isocountry = "0.3" open = "5" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "gzip"] } diff --git a/migrations/0007_entity_images.sql b/migrations/0007_entity_images.sql new file mode 100644 index 0000000..6040b43 --- /dev/null +++ b/migrations/0007_entity_images.sql @@ -0,0 +1,12 @@ +CREATE TABLE entity_images ( + id INTEGER PRIMARY KEY, + entity_type TEXT NOT NULL CHECK (entity_type IN ('roaster', 'roast', 'gear', 'cafe', 'brew', 'cup')), + entity_id INTEGER NOT NULL, + content_type TEXT NOT NULL, + image_data BLOB NOT NULL, + thumbnail_data BLOB NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + UNIQUE(entity_type, entity_id) +); + +CREATE INDEX idx_entity_images_lookup ON entity_images (entity_type, entity_id); diff --git a/src/application/routes/api/images.rs b/src/application/routes/api/images.rs new file mode 100644 index 0000000..e39f11d --- /dev/null +++ b/src/application/routes/api/images.rs @@ -0,0 +1,261 @@ +use axum::body::Body; +use axum::extract::{Path, State}; +use axum::http::{HeaderMap, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use serde::Deserialize; +use tracing::info; + +use crate::application::auth::AuthenticatedUser; +use crate::application::errors::{ApiError, AppError}; +use crate::application::routes::support::{FlexiblePayload, is_datastar_request, render_fragment}; +use crate::application::state::AppState; +use crate::domain::images::EntityImage; +use crate::infrastructure::image_processing::process_data_url; +use crate::presentation::web::templates::ImageUploadTemplate; + +const VALID_ENTITY_TYPES: &[&str] = &["roaster", "roast", "gear", "cafe", "brew", "cup"]; + +#[derive(Debug, Deserialize)] +pub(crate) struct ImageUpload { + pub image: String, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct ImagePath { + pub entity_type: String, + pub id: i64, +} + +fn validate_entity_type(entity_type: &str) -> Result<(), ApiError> { + if VALID_ENTITY_TYPES.contains(&entity_type) { + Ok(()) + } else { + Err(AppError::validation(format!("invalid entity type: {entity_type}")).into()) + } +} + +async fn validate_entity_exists( + state: &AppState, + entity_type: &str, + id: i64, +) -> Result<(), ApiError> { + use crate::domain::ids::{BrewId, CafeId, CupId, GearId, RoastId, RoasterId}; + + match entity_type { + "roaster" => { + state + .roaster_repo + .get(RoasterId::from(id)) + .await + .map_err(AppError::from)?; + } + "roast" => { + state + .roast_repo + .get(RoastId::from(id)) + .await + .map_err(AppError::from)?; + } + "gear" => { + state + .gear_repo + .get(GearId::from(id)) + .await + .map_err(AppError::from)?; + } + "cafe" => { + state + .cafe_repo + .get(CafeId::from(id)) + .await + .map_err(AppError::from)?; + } + "brew" => { + state + .brew_repo + .get(BrewId::from(id)) + .await + .map_err(AppError::from)?; + } + "cup" => { + state + .cup_repo + .get(CupId::from(id)) + .await + .map_err(AppError::from)?; + } + _ => { + return Err(AppError::validation(format!("invalid entity type: {entity_type}")).into()); + } + } + + Ok(()) +} + +#[tracing::instrument(skip(state, _auth_user, headers, payload))] +pub(crate) async fn upload_image( + State(state): State, + _auth_user: AuthenticatedUser, + headers: HeaderMap, + Path(path): Path, + payload: FlexiblePayload, +) -> Result { + validate_entity_type(&path.entity_type)?; + validate_entity_exists(&state, &path.entity_type, path.id).await?; + + let (upload, _source) = payload.into_parts(); + + let processed = process_data_url(&upload.image) + .map_err(|e| AppError::validation(format!("invalid image: {e}")))?; + + let image = EntityImage { + entity_type: path.entity_type.clone(), + entity_id: path.id, + content_type: processed.content_type, + image_data: processed.image_data, + thumbnail_data: processed.thumbnail_data, + }; + + state + .image_repo + .upsert(image) + .await + .map_err(AppError::from)?; + + info!(entity_type = %path.entity_type, entity_id = path.id, "image uploaded"); + + if is_datastar_request(&headers) { + let image_url = format!("/api/v1/{}/{}/image", path.entity_type, path.id); + render_fragment( + ImageUploadTemplate { + entity_type: &path.entity_type, + entity_id: path.id, + image_url: Some(&image_url), + is_authenticated: true, + }, + "#entity-image", + ) + .map_err(ApiError::from) + } else { + Ok(StatusCode::NO_CONTENT.into_response()) + } +} + +#[tracing::instrument(skip(state))] +pub(crate) async fn get_image( + State(state): State, + Path(path): Path, +) -> Result { + validate_entity_type(&path.entity_type)?; + + let image = state + .image_repo + .get(&path.entity_type, path.id) + .await + .map_err(AppError::from)?; + + Ok(image_response(image.image_data, &image.content_type)) +} + +#[tracing::instrument(skip(state))] +pub(crate) async fn get_thumbnail( + State(state): State, + Path(path): Path, +) -> Result { + validate_entity_type(&path.entity_type)?; + + let image = state + .image_repo + .get_thumbnail(&path.entity_type, path.id) + .await + .map_err(AppError::from)?; + + Ok(image_response(image.thumbnail_data, &image.content_type)) +} + +#[tracing::instrument(skip(state, _auth_user))] +pub(crate) async fn delete_image( + State(state): State, + _auth_user: AuthenticatedUser, + headers: HeaderMap, + Path(path): Path, +) -> Result { + validate_entity_type(&path.entity_type)?; + + state + .image_repo + .delete(&path.entity_type, path.id) + .await + .map_err(AppError::from)?; + + info!(entity_type = %path.entity_type, entity_id = path.id, "image deleted"); + + if is_datastar_request(&headers) { + render_fragment( + ImageUploadTemplate { + entity_type: &path.entity_type, + entity_id: path.id, + image_url: None, + is_authenticated: true, + }, + "#entity-image", + ) + .map_err(ApiError::from) + } else { + Ok(StatusCode::NO_CONTENT.into_response()) + } +} + +/// Check if an entity has an image and return its URL if so. +pub(crate) async fn resolve_image_url( + state: &AppState, + entity_type: &str, + entity_id: i64, +) -> Option { + state + .image_repo + .has_image(entity_type, entity_id) + .await + .unwrap_or(false) + .then(|| format!("/api/v1/{entity_type}/{entity_id}/image")) +} + +/// Process and save a deferred image upload (from a create form). +/// Called after entity creation when the form included an image data URL. +/// Accepts `Option<&str>` and no-ops on `None` or empty strings. +pub(crate) async fn save_deferred_image( + state: &AppState, + entity_type: &str, + entity_id: i64, + data_url: Option<&str>, +) { + let Some(data_url) = data_url.filter(|s| !s.is_empty()) else { + return; + }; + match process_data_url(data_url) { + Ok(processed) => { + let image = EntityImage { + entity_type: entity_type.to_string(), + entity_id, + content_type: processed.content_type, + image_data: processed.image_data, + thumbnail_data: processed.thumbnail_data, + }; + if let Err(err) = state.image_repo.upsert(image).await { + tracing::warn!(entity_type, entity_id, error = %err, "failed to save deferred image"); + } + } + Err(err) => { + tracing::warn!(entity_type, entity_id, error = %err, "failed to process deferred image"); + } + } +} + +fn image_response(data: Vec, content_type: &str) -> Response { + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, content_type) + .header(header::CACHE_CONTROL, "public, max-age=604800") + .body(Body::from(data)) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) +} diff --git a/src/application/routes/api/macros.rs b/src/application/routes/api/macros.rs index 04ef3cb..0156311 100644 --- a/src/application/routes/api/macros.rs +++ b/src/application/routes/api/macros.rs @@ -87,6 +87,12 @@ macro_rules! define_enriched_get_handler { /// ``` macro_rules! define_delete_handler { ($fn_name:ident, $id_type:ty, $sort_key:ty, $repo_field:ident, $render_fragment:path, $referer_match:literal, $redirect_url:literal) => { + define_delete_handler!(@inner $fn_name, $id_type, $sort_key, $repo_field, $render_fragment, $referer_match, $redirect_url, None); + }; + ($fn_name:ident, $id_type:ty, $sort_key:ty, $repo_field:ident, $render_fragment:path, $referer_match:literal, $redirect_url:literal, image_type: $image_type:literal) => { + define_delete_handler!(@inner $fn_name, $id_type, $sort_key, $repo_field, $render_fragment, $referer_match, $redirect_url, Some($image_type)); + }; + (@inner $fn_name:ident, $id_type:ty, $sort_key:ty, $repo_field:ident, $render_fragment:path, $referer_match:literal, $redirect_url:literal, $image_type:expr) => { #[tracing::instrument(skip(state, _auth_user, headers, query))] pub(crate) async fn $fn_name( axum::extract::State(state): axum::extract::State, @@ -104,6 +110,12 @@ macro_rules! define_delete_handler { .await .map_err(crate::application::errors::AppError::from)?; + if let Some(img_type) = $image_type { + if let Err(err) = state.image_repo.delete(img_type, i64::from(id)).await { + tracing::warn!(%id, error = %err, "failed to delete entity image"); + } + } + tracing::info!(%id, "entity deleted"); state.stats_invalidator.invalidate(); diff --git a/src/application/routes/api/mod.rs b/src/application/routes/api/mod.rs index 0ae2caf..1d0a4cb 100644 --- a/src/application/routes/api/mod.rs +++ b/src/application/routes/api/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod analytics; pub(crate) mod auth; pub(crate) mod coffee; +pub(crate) mod images; pub(crate) mod macros; pub(crate) mod system; @@ -95,6 +96,14 @@ pub(super) fn router() -> axum::Router { ) .route("/backup/reset", post(backup::reset_database)) .route("/stats/recompute", post(stats::recompute_stats)) + .route( + "/{entity_type}/{id}/image", + get(images::get_image) + .put(images::upload_image) + .delete(images::delete_image) + .layer(DefaultBodyLimit::max(10 * 1024 * 1024)), + ) + .route("/{entity_type}/{id}/thumbnail", get(images::get_thumbnail)) } pub(super) fn webauthn_router() -> axum::Router { diff --git a/src/application/routes/app/bags.rs b/src/application/routes/app/bags.rs index 790c1b9..63d7305 100644 --- a/src/application/routes/app/bags.rs +++ b/src/application/routes/app/bags.rs @@ -4,6 +4,7 @@ use axum::response::{IntoResponse, Response}; use tower_cookies::Cookies; use crate::application::errors::map_app_error; +use crate::application::routes::api::images::resolve_image_url; use crate::application::routes::render_html; use crate::application::state::AppState; use crate::domain::ids::BagId; @@ -36,6 +37,8 @@ pub(crate) async fn bag_detail_page( .await .map_err(|e| map_app_error(e.into()))?; + let image_url = resolve_image_url(&state, "roast", i64::from(roast.id)).await; + let view = BagDetailView::from_parts(bag, &roast, &roaster); let template = BagDetailTemplate { @@ -46,6 +49,7 @@ pub(crate) async fn bag_detail_page( bag: view, roaster_slug: roaster.slug.clone(), roast_slug: roast.slug.clone(), + image_url, }; render_html(template).map(IntoResponse::into_response) diff --git a/src/application/routes/app/brews.rs b/src/application/routes/app/brews.rs index b367e1b..a6b4cb4 100644 --- a/src/application/routes/app/brews.rs +++ b/src/application/routes/app/brews.rs @@ -4,6 +4,7 @@ use axum::response::{IntoResponse, Response}; use tower_cookies::Cookies; use crate::application::errors::map_app_error; +use crate::application::routes::api::images::resolve_image_url; use crate::application::routes::render_html; use crate::application::state::AppState; use crate::domain::ids::BrewId; @@ -42,6 +43,10 @@ pub(crate) async fn brew_detail_page( .await .map_err(|e| map_app_error(e.into()))?; + let image_url = resolve_image_url(&state, "brew", i64::from(id)) + .await + .or(resolve_image_url(&state, "roast", i64::from(roast.id)).await); + let view = BrewDetailView::from_parts(brew_details, &roast, &roaster); let template = BrewDetailTemplate { @@ -52,6 +57,7 @@ pub(crate) async fn brew_detail_page( brew: view, roaster_slug: roaster.slug.clone(), roast_slug: roast.slug.clone(), + image_url, }; render_html(template).map(IntoResponse::into_response) diff --git a/src/application/routes/app/cafes.rs b/src/application/routes/app/cafes.rs index 1ffd528..eff79d2 100644 --- a/src/application/routes/app/cafes.rs +++ b/src/application/routes/app/cafes.rs @@ -4,6 +4,7 @@ use axum::response::{IntoResponse, Response}; use tower_cookies::Cookies; use crate::application::errors::map_app_error; +use crate::application::routes::api::images::resolve_image_url; use crate::application::routes::render_html; use crate::application::state::AppState; use crate::presentation::web::templates::CafeDetailTemplate; @@ -23,6 +24,8 @@ pub(crate) async fn cafe_detail_page( .await .map_err(|e| map_app_error(e.into()))?; + let image_url = resolve_image_url(&state, "cafe", i64::from(cafe.id)).await; + let view = CafeDetailView::from_domain(cafe); let template = CafeDetailTemplate { @@ -31,6 +34,7 @@ pub(crate) async fn cafe_detail_page( version_info: &crate::VERSION_INFO, base_url: crate::base_url(), cafe: view, + image_url, }; render_html(template).map(IntoResponse::into_response) diff --git a/src/application/routes/app/cups.rs b/src/application/routes/app/cups.rs index 0ade4c7..f40fa8a 100644 --- a/src/application/routes/app/cups.rs +++ b/src/application/routes/app/cups.rs @@ -4,6 +4,7 @@ use axum::response::{IntoResponse, Response}; use tower_cookies::Cookies; use crate::application::errors::map_app_error; +use crate::application::routes::api::images::resolve_image_url; use crate::application::routes::render_html; use crate::application::state::AppState; use crate::domain::ids::CupId; @@ -47,6 +48,11 @@ pub(crate) async fn cup_detail_page( .await .map_err(|e| map_app_error(e.into()))?; + let image_url = resolve_image_url(&state, "cup", i64::from(id)) + .await + .or(resolve_image_url(&state, "cafe", i64::from(cafe.id)).await) + .or(resolve_image_url(&state, "roast", i64::from(roast.id)).await); + let view = CupDetailView::from_parts(cup_details, &roast, &roaster, &cafe); let template = CupDetailTemplate { @@ -58,6 +64,7 @@ pub(crate) async fn cup_detail_page( roaster_slug: roaster.slug.clone(), roast_slug: roast.slug.clone(), cafe_slug: cafe.slug.clone(), + image_url, }; render_html(template).map(IntoResponse::into_response) diff --git a/src/application/routes/app/gear.rs b/src/application/routes/app/gear.rs index 40f63d4..a57658f 100644 --- a/src/application/routes/app/gear.rs +++ b/src/application/routes/app/gear.rs @@ -4,6 +4,7 @@ use axum::response::{IntoResponse, Response}; use tower_cookies::Cookies; use crate::application::errors::map_app_error; +use crate::application::routes::api::images::resolve_image_url; use crate::application::routes::render_html; use crate::application::state::AppState; use crate::domain::ids::GearId; @@ -24,6 +25,8 @@ pub(crate) async fn gear_detail_page( .await .map_err(|e| map_app_error(e.into()))?; + let image_url = resolve_image_url(&state, "gear", i64::from(id)).await; + let view = GearDetailView::from_domain(gear); let template = GearDetailTemplate { @@ -32,6 +35,7 @@ pub(crate) async fn gear_detail_page( version_info: &crate::VERSION_INFO, base_url: crate::base_url(), gear: view, + image_url, }; render_html(template).map(IntoResponse::into_response) diff --git a/src/application/routes/app/mod.rs b/src/application/routes/app/mod.rs index 4b42f78..6cc3802 100644 --- a/src/application/routes/app/mod.rs +++ b/src/application/routes/app/mod.rs @@ -58,6 +58,10 @@ pub(super) fn router() -> axum::Router { .route("/static/js/location.js", get(location_js)) .route("/static/js/components/world-map.js", get(world_map_js)) .route("/static/js/components/donut-chart.js", get(donut_chart_js)) + .route( + "/static/js/components/image-upload.js", + get(image_upload_js), + ) .route("/static/favicon-light.svg", get(favicon_light)) .route("/static/favicon-dark.svg", get(favicon_dark)) .route("/static/og-image.png", get(og_image)) @@ -148,6 +152,16 @@ async fn donut_chart_js() -> impl IntoResponse { ) } +async fn image_upload_js() -> impl IntoResponse { + ( + [ + ("content-type", "application/javascript; charset=utf-8"), + ("cache-control", "public, max-age=604800"), + ], + include_str!("../../../../static/js/components/image-upload.js"), + ) +} + async fn favicon_light() -> impl IntoResponse { ( [ diff --git a/src/application/routes/app/roasters.rs b/src/application/routes/app/roasters.rs index 1ee027a..4ee84df 100644 --- a/src/application/routes/app/roasters.rs +++ b/src/application/routes/app/roasters.rs @@ -4,6 +4,7 @@ use axum::response::{IntoResponse, Response}; use tower_cookies::Cookies; use crate::application::errors::map_app_error; +use crate::application::routes::api::images::resolve_image_url; use crate::application::routes::render_html; use crate::application::state::AppState; use crate::presentation::web::templates::RoasterDetailTemplate; @@ -23,6 +24,8 @@ pub(crate) async fn roaster_detail_page( .await .map_err(|e| map_app_error(e.into()))?; + let image_url = resolve_image_url(&state, "roaster", i64::from(roaster.id)).await; + let view = RoasterDetailView::from_domain(roaster); let template = RoasterDetailTemplate { @@ -31,6 +34,7 @@ pub(crate) async fn roaster_detail_page( version_info: &crate::VERSION_INFO, base_url: crate::base_url(), roaster: view, + image_url, }; render_html(template).map(IntoResponse::into_response) diff --git a/src/application/routes/app/roasts.rs b/src/application/routes/app/roasts.rs index b7dc6d1..ed03c59 100644 --- a/src/application/routes/app/roasts.rs +++ b/src/application/routes/app/roasts.rs @@ -4,6 +4,7 @@ use axum::response::{IntoResponse, Response}; use tower_cookies::Cookies; use crate::application::errors::map_app_error; +use crate::application::routes::api::images::resolve_image_url; use crate::application::routes::render_html; use crate::application::state::AppState; use crate::presentation::web::templates::RoastDetailTemplate; @@ -29,6 +30,8 @@ pub(crate) async fn roast_detail_page( .await .map_err(|e| map_app_error(e.into()))?; + let image_url = resolve_image_url(&state, "roast", i64::from(roast.id)).await; + let view = RoastDetailView::from_parts(roast, &roaster); let template = RoastDetailTemplate { @@ -38,6 +41,7 @@ pub(crate) async fn roast_detail_page( base_url: crate::base_url(), roast: view, roaster_slug, + image_url, }; render_html(template).map(IntoResponse::into_response) diff --git a/src/application/state.rs b/src/application/state.rs index 9d46f27..f525b92 100644 --- a/src/application/state.rs +++ b/src/application/state.rs @@ -8,9 +8,9 @@ use crate::application::services::{ }; use crate::domain::repositories::{ AiUsageRepository, BagRepository, BrewRepository, CafeRepository, CupRepository, - GearRepository, PasskeyCredentialRepository, RegistrationTokenRepository, RoastRepository, - RoasterRepository, SessionRepository, StatsRepository, TimelineEventRepository, - TokenRepository, UserRepository, + GearRepository, ImageRepository, PasskeyCredentialRepository, RegistrationTokenRepository, + RoastRepository, RoasterRepository, SessionRepository, StatsRepository, + TimelineEventRepository, TokenRepository, UserRepository, }; use crate::infrastructure::backup::BackupService; use crate::infrastructure::database::Database; @@ -20,6 +20,7 @@ use crate::infrastructure::repositories::brews::SqlBrewRepository; use crate::infrastructure::repositories::cafes::SqlCafeRepository; use crate::infrastructure::repositories::cups::SqlCupRepository; use crate::infrastructure::repositories::gear::SqlGearRepository; +use crate::infrastructure::repositories::images::SqlImageRepository; use crate::infrastructure::repositories::passkey_credentials::SqlPasskeyCredentialRepository; use crate::infrastructure::repositories::registration_tokens::SqlRegistrationTokenRepository; use crate::infrastructure::repositories::roasters::SqlRoasterRepository; @@ -61,6 +62,7 @@ pub struct AppState { pub passkey_repo: Arc, pub registration_token_repo: Arc, pub ai_usage_repo: Arc, + pub image_repo: Arc, pub stats_repo: Arc, pub webauthn: Arc, pub challenge_store: Arc, @@ -108,6 +110,7 @@ impl AppState { Arc::new(SqlRegistrationTokenRepository::new(pool.clone())); let ai_usage_repo: Arc = Arc::new(SqlAiUsageRepository::new(pool.clone())); + let image_repo: Arc = Arc::new(SqlImageRepository::new(pool.clone())); let stats_repo: Arc = Arc::new(SqlStatsRepository::new(pool.clone())); let backup_service = Arc::new(BackupService::new(pool)); @@ -145,6 +148,7 @@ impl AppState { passkey_repo, registration_token_repo, ai_usage_repo, + image_repo, stats_repo, webauthn: config.webauthn, challenge_store: Arc::new(ChallengeStore::new()), diff --git a/src/domain/images.rs b/src/domain/images.rs new file mode 100644 index 0000000..de3d24f --- /dev/null +++ b/src/domain/images.rs @@ -0,0 +1,8 @@ +/// An image associated with an entity (roaster, roast, gear, or cafe). +pub struct EntityImage { + pub entity_type: String, + pub entity_id: i64, + pub content_type: String, + pub image_data: Vec, + pub thumbnail_data: Vec, +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 47b6cb6..36c2c5e 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -5,6 +5,7 @@ pub mod countries; pub mod errors; pub mod formatting; pub mod ids; +pub mod images; pub mod listing; pub mod repositories; diff --git a/src/domain/repositories.rs b/src/domain/repositories.rs index 90413ce..e4e14f3 100644 --- a/src/domain/repositories.rs +++ b/src/domain/repositories.rs @@ -11,6 +11,7 @@ use crate::domain::ids::{ BagId, BrewId, CafeId, CupId, GearId, PasskeyCredentialId, RegistrationTokenId, RoastId, RoasterId, SessionId, TokenId, UserId, }; +use crate::domain::images::EntityImage; use crate::domain::passkey_credentials::{NewPasskeyCredential, PasskeyCredential}; use crate::domain::registration_tokens::{NewRegistrationToken, RegistrationToken}; use crate::domain::roasters::RoasterSortKey; @@ -269,6 +270,19 @@ pub trait AiUsageRepository: Send + Sync { async fn summary_for_user(&self, user_id: UserId) -> Result; } +#[async_trait] +pub trait ImageRepository: Send + Sync { + async fn upsert(&self, image: EntityImage) -> Result<(), RepositoryError>; + async fn get(&self, entity_type: &str, entity_id: i64) -> Result; + async fn get_thumbnail( + &self, + entity_type: &str, + entity_id: i64, + ) -> Result; + async fn delete(&self, entity_type: &str, entity_id: i64) -> Result<(), RepositoryError>; + async fn has_image(&self, entity_type: &str, entity_id: i64) -> Result; +} + #[async_trait] pub trait StatsRepository: Send + Sync { async fn roaster_country_counts(&self) -> Result, RepositoryError>; diff --git a/src/infrastructure/image_processing.rs b/src/infrastructure/image_processing.rs new file mode 100644 index 0000000..2214498 --- /dev/null +++ b/src/infrastructure/image_processing.rs @@ -0,0 +1,112 @@ +use anyhow::{Context, bail}; +use base64::Engine; +use image::ImageReader; +use std::io::Cursor; + +/// Maximum dimension (width or height) for the full-size image. +const MAX_FULL_SIZE: u32 = 1200; + +/// Maximum dimension (width or height) for the thumbnail. +const MAX_THUMBNAIL_SIZE: u32 = 200; + +/// JPEG quality for the full-size image (0-100). +const JPEG_QUALITY_FULL: u8 = 85; + +/// JPEG quality for the thumbnail (0-100). +const JPEG_QUALITY_THUMBNAIL: u8 = 80; + +/// Processed image data ready for storage. +pub struct ProcessedImage { + pub image_data: Vec, + pub thumbnail_data: Vec, + pub content_type: String, +} + +/// Parse a data URL, decode the image, resize to a maximum dimension, and +/// produce both a full-size and thumbnail JPEG. +/// +/// Returns `(image_data, thumbnail_data, content_type)`. +pub fn process_data_url(data_url: &str) -> anyhow::Result { + let raw_bytes = decode_data_url(data_url)?; + process_image_bytes(&raw_bytes) +} + +/// Process raw image bytes (JPEG/PNG/WebP) into resized full + thumbnail JPEGs. +pub fn process_image_bytes(raw_bytes: &[u8]) -> anyhow::Result { + let img = ImageReader::new(Cursor::new(raw_bytes)) + .with_guessed_format() + .context("failed to guess image format")? + .decode() + .context("failed to decode image")?; + + let full = img.resize( + MAX_FULL_SIZE, + MAX_FULL_SIZE, + image::imageops::FilterType::Lanczos3, + ); + + let thumb = img.resize( + MAX_THUMBNAIL_SIZE, + MAX_THUMBNAIL_SIZE, + image::imageops::FilterType::Lanczos3, + ); + + let image_data = encode_jpeg(&full, JPEG_QUALITY_FULL)?; + let thumbnail_data = encode_jpeg(&thumb, JPEG_QUALITY_THUMBNAIL)?; + + Ok(ProcessedImage { + image_data, + thumbnail_data, + content_type: "image/jpeg".to_string(), + }) +} + +/// Decode a `data:image/...;base64,...` URL into raw bytes. +fn decode_data_url(data_url: &str) -> anyhow::Result> { + let Some(rest) = data_url.strip_prefix("data:") else { + bail!("invalid data URL: missing data: prefix"); + }; + + let Some((_mime, encoded)) = rest.split_once(',') else { + bail!("invalid data URL: missing comma separator"); + }; + + base64::engine::general_purpose::STANDARD + .decode(encoded.trim()) + .context("failed to decode base64 image data") +} + +/// Encode a `DynamicImage` as JPEG bytes. +fn encode_jpeg(img: &image::DynamicImage, quality: u8) -> anyhow::Result> { + let mut buf = Vec::new(); + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality); + img.write_with_encoder(encoder) + .context("failed to encode image as JPEG")?; + Ok(buf) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_data_url_valid() { + let data = base64::engine::general_purpose::STANDARD.encode(b"hello"); + let url = format!("data:image/png;base64,{data}"); + let result = decode_data_url(&url); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), b"hello"); + } + + #[test] + fn decode_data_url_missing_prefix() { + let result = decode_data_url("not-a-data-url"); + assert!(result.is_err()); + } + + #[test] + fn decode_data_url_missing_comma() { + let result = decode_data_url("data:image/png;base64"); + assert!(result.is_err()); + } +} diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index 97d0134..4f76220 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -4,5 +4,6 @@ pub mod backup; pub mod client; pub mod database; pub mod foursquare; +pub mod image_processing; pub mod repositories; pub mod webauthn; diff --git a/src/infrastructure/repositories/images.rs b/src/infrastructure/repositories/images.rs new file mode 100644 index 0000000..5186e0a --- /dev/null +++ b/src/infrastructure/repositories/images.rs @@ -0,0 +1,139 @@ +use async_trait::async_trait; +use sqlx::{query, query_as}; + +use crate::domain::RepositoryError; +use crate::domain::images::EntityImage; +use crate::domain::repositories::ImageRepository; +use crate::infrastructure::database::DatabasePool; + +#[derive(Clone)] +pub struct SqlImageRepository { + pool: DatabasePool, +} + +impl SqlImageRepository { + pub fn new(pool: DatabasePool) -> Self { + Self { pool } + } + + fn into_domain(record: ImageRecord) -> EntityImage { + EntityImage { + entity_type: record.entity_type, + entity_id: record.entity_id, + content_type: record.content_type, + image_data: record.image_data, + thumbnail_data: record.thumbnail_data, + } + } + + fn thumbnail_to_domain(record: ThumbnailRecord) -> EntityImage { + EntityImage { + entity_type: record.entity_type, + entity_id: record.entity_id, + content_type: record.content_type, + image_data: Vec::new(), + thumbnail_data: record.thumbnail_data, + } + } +} + +#[derive(sqlx::FromRow)] +struct ImageRecord { + entity_type: String, + entity_id: i64, + content_type: String, + image_data: Vec, + thumbnail_data: Vec, +} + +#[derive(sqlx::FromRow)] +struct ThumbnailRecord { + entity_type: String, + entity_id: i64, + content_type: String, + thumbnail_data: Vec, +} + +#[async_trait] +impl ImageRepository for SqlImageRepository { + async fn upsert(&self, image: EntityImage) -> Result<(), RepositoryError> { + query( + r"INSERT INTO entity_images (entity_type, entity_id, content_type, image_data, thumbnail_data) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (entity_type, entity_id) + DO UPDATE SET content_type = excluded.content_type, + image_data = excluded.image_data, + thumbnail_data = excluded.thumbnail_data, + created_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')", + ) + .bind(&image.entity_type) + .bind(image.entity_id) + .bind(&image.content_type) + .bind(&image.image_data) + .bind(&image.thumbnail_data) + .execute(&self.pool) + .await + .map_err(|e| RepositoryError::unexpected(e.to_string()))?; + + Ok(()) + } + + async fn get(&self, entity_type: &str, entity_id: i64) -> Result { + let record = query_as::<_, ImageRecord>( + r"SELECT entity_type, entity_id, content_type, image_data, thumbnail_data + FROM entity_images + WHERE entity_type = ? AND entity_id = ?", + ) + .bind(entity_type) + .bind(entity_id) + .fetch_optional(&self.pool) + .await + .map_err(|e| RepositoryError::unexpected(e.to_string()))? + .ok_or(RepositoryError::NotFound)?; + + Ok(Self::into_domain(record)) + } + + async fn get_thumbnail( + &self, + entity_type: &str, + entity_id: i64, + ) -> Result { + let record = query_as::<_, ThumbnailRecord>( + r"SELECT entity_type, entity_id, content_type, thumbnail_data + FROM entity_images + WHERE entity_type = ? AND entity_id = ?", + ) + .bind(entity_type) + .bind(entity_id) + .fetch_optional(&self.pool) + .await + .map_err(|e| RepositoryError::unexpected(e.to_string()))? + .ok_or(RepositoryError::NotFound)?; + + Ok(Self::thumbnail_to_domain(record)) + } + + async fn delete(&self, entity_type: &str, entity_id: i64) -> Result<(), RepositoryError> { + query(r"DELETE FROM entity_images WHERE entity_type = ? AND entity_id = ?") + .bind(entity_type) + .bind(entity_id) + .execute(&self.pool) + .await + .map_err(|e| RepositoryError::unexpected(e.to_string()))?; + + Ok(()) + } + + async fn has_image(&self, entity_type: &str, entity_id: i64) -> Result { + let row: (i64,) = + query_as(r"SELECT COUNT(*) FROM entity_images WHERE entity_type = ? AND entity_id = ?") + .bind(entity_type) + .bind(entity_id) + .fetch_one(&self.pool) + .await + .map_err(|e| RepositoryError::unexpected(e.to_string()))?; + + Ok(row.0 > 0) + } +} diff --git a/src/infrastructure/repositories/mod.rs b/src/infrastructure/repositories/mod.rs index f835e4f..49694c3 100644 --- a/src/infrastructure/repositories/mod.rs +++ b/src/infrastructure/repositories/mod.rs @@ -1,6 +1,7 @@ pub mod analytics; pub mod auth; pub mod coffee; +pub mod images; pub(crate) mod macros; pub mod pagination; diff --git a/src/presentation/web/templates.rs b/src/presentation/web/templates.rs index 2092a0f..2319742 100644 --- a/src/presentation/web/templates.rs +++ b/src/presentation/web/templates.rs @@ -221,6 +221,7 @@ pub struct BagDetailTemplate { pub bag: BagDetailView, pub roaster_slug: String, pub roast_slug: String, + pub image_url: Option, } #[derive(Template)] @@ -233,6 +234,7 @@ pub struct BrewDetailTemplate { pub brew: BrewDetailView, pub roaster_slug: String, pub roast_slug: String, + pub image_url: Option, } #[derive(Template)] @@ -246,6 +248,7 @@ pub struct CupDetailTemplate { pub roaster_slug: String, pub roast_slug: String, pub cafe_slug: String, + pub image_url: Option, } #[derive(Template)] @@ -257,6 +260,7 @@ pub struct RoastDetailTemplate { pub base_url: &'static str, pub roast: RoastDetailView, pub roaster_slug: String, + pub image_url: Option, } #[derive(Template)] @@ -267,6 +271,7 @@ pub struct RoasterDetailTemplate { pub version_info: &'static crate::VersionInfo, pub base_url: &'static str, pub roaster: RoasterDetailView, + pub image_url: Option, } #[derive(Template)] @@ -277,6 +282,7 @@ pub struct CafeDetailTemplate { pub version_info: &'static crate::VersionInfo, pub base_url: &'static str, pub cafe: CafeDetailView, + pub image_url: Option, } #[derive(Template)] @@ -287,6 +293,16 @@ pub struct GearDetailTemplate { pub version_info: &'static crate::VersionInfo, pub base_url: &'static str, pub gear: GearDetailView, + pub image_url: Option, +} + +#[derive(Template)] +#[template(path = "partials/image_upload.html")] +pub struct ImageUploadTemplate<'a> { + pub entity_type: &'a str, + pub entity_id: i64, + pub image_url: Option<&'a str>, + pub is_authenticated: bool, } pub fn render_template(template: T) -> Result { diff --git a/static/js/components/image-upload.js b/static/js/components/image-upload.js new file mode 100644 index 0000000..27829f2 --- /dev/null +++ b/static/js/components/image-upload.js @@ -0,0 +1,107 @@ +customElements.define( + "image-upload", + class extends HTMLElement { + connectedCallback() { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/*"; + input.hidden = true; + this.appendChild(input); + + this.addEventListener("click", (e) => { + if (e.target !== input) input.click(); + }); + + this.addEventListener("dragover", (e) => { + e.preventDefault(); + this.classList.add("border-accent"); + }); + + this.addEventListener("dragleave", () => { + this.classList.remove("border-accent"); + }); + + this.addEventListener("drop", (e) => { + e.preventDefault(); + this.classList.remove("border-accent"); + const file = e.dataTransfer?.files[0]; + if (file && file.type.startsWith("image/")) { + this._handleFile(file); + } + }); + + input.addEventListener("change", () => { + const file = input.files[0]; + if (file) this._handleFile(file); + input.value = ""; + }); + } + + _handleFile(file) { + const entityType = this.getAttribute("entity-type"); + const entityId = this.getAttribute("entity-id"); + const mode = this.getAttribute("mode"); + const reader = new FileReader(); + + reader.onload = () => { + const dataUrl = reader.result; + + if (entityType && entityId && mode !== "deferred") { + this._upload(entityType, entityId, dataUrl); + } else { + // Deferred mode: store data URL in a target hidden input + const targetId = this.getAttribute("target-input"); + if (targetId) { + document.getElementById(targetId).value = dataUrl; + } + this._showPreview(dataUrl); + } + }; + + reader.readAsDataURL(file); + } + + _showPreview(dataUrl) { + const container = this.closest("#entity-image") || this.parentElement; + let preview = container.querySelector(".image-upload-preview"); + if (!preview) { + preview = document.createElement("div"); + preview.className = + "image-upload-preview h-48 w-full bg-cover bg-center rounded-lg border mt-2"; + container.appendChild(preview); + } + preview.style.backgroundImage = `url('${dataUrl}')`; + } + + async _upload(entityType, entityId, dataUrl) { + const originalContent = this.innerHTML; + const isReplace = this.getAttribute("mode") === "replace"; + + if (!isReplace) { + this.innerHTML = + 'Uploading...'; + } + + try { + const response = await fetch( + `/api/v1/${entityType}/${entityId}/image`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ image: dataUrl }), + }, + ); + + if (!response.ok) throw new Error("Upload failed"); + window.location.reload(); + } catch { + this.innerHTML = originalContent; + const errEl = document.createElement("p"); + errEl.className = "text-xs text-red-500 mt-1"; + errEl.textContent = "Upload failed. Try again."; + this.parentElement.appendChild(errEl); + setTimeout(() => errEl.remove(), 3000); + } + } + }, +); diff --git a/templates/base.html b/templates/base.html index 0d544cd..6719fc4 100644 --- a/templates/base.html +++ b/templates/base.html @@ -54,6 +54,7 @@ + {% block head %}{% endblock %} +{% endmacro %} diff --git a/templates/partials/image_upload.html b/templates/partials/image_upload.html new file mode 100644 index 0000000..8ebe56a --- /dev/null +++ b/templates/partials/image_upload.html @@ -0,0 +1,52 @@ +
+ {% if let Some(url) = image_url %} +
+ + {% if is_authenticated %} +
+ + Replace + + +
+ {% endif %} +
+ {% else if is_authenticated %} + + + + + Drop image, browse, or take a photo + + {% endif %} +