use std::collections::HashMap; use crate::{ controllers::{ admin, auth as auth_controller, i18n::current_lang, media::{detect_image_extension, store_upload, IMAGE_MAX_BYTES, IMAGE_STORAGE_DIR}, }, models::{ _entities::{categories, product_images, products}, users, }, }; use axum::extract::{DefaultBodyLimit, Multipart}; use axum_extra::extract::cookie::CookieJar; use loco_rs::prelude::*; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set, }; use serde_json::json; // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- fn slugify(value: &str) -> String { let mut slug = String::new(); let mut last_was_dash = false; for ch in value.chars().flat_map(char::to_lowercase) { if ch.is_ascii_alphanumeric() { slug.push(ch); last_was_dash = false; } else if !last_was_dash && !slug.is_empty() { slug.push('-'); last_was_dash = true; } } slug.trim_matches('-').to_string() } fn normalize_empty(value: Option) -> Option { value.and_then(|value| { let value = value.trim().to_string(); if value.is_empty() { None } else { Some(value) } }) } /// Parse a price typed in major units ("12", "12.5", "12.34") into integer /// minor units (cents). Rejects negatives and more than two decimals. fn parse_price_to_cents(value: &str) -> Result { let value = value.trim().replace(',', "."); let invalid = || Error::BadRequest("invalid price".to_string()); let (whole, frac) = match value.split_once('.') { Some((w, f)) => (w, f), None => (value.as_str(), ""), }; if frac.len() > 2 || !whole.chars().all(|c| c.is_ascii_digit()) || whole.is_empty() { return Err(invalid()); } if !frac.chars().all(|c| c.is_ascii_digit()) { return Err(invalid()); } let whole: i64 = whole.parse().map_err(|_| invalid())?; let cents: i64 = match frac.len() { 0 => 0, 1 => frac.parse::().map_err(|_| invalid())? * 10, _ => frac.parse().map_err(|_| invalid())?, }; Ok(whole * 100 + cents) } /// Render minor units as a human price string, e.g. `1234` -> `"12.34"`. pub(crate) fn format_price(cents: i64) -> String { format!("{}.{:02}", cents / 100, (cents % 100).abs()) } async fn logged_in_admin(ctx: &AppContext, jar: &CookieJar) -> bool { let Some(cookie) = jar.get(auth_controller::AUTH_COOKIE) else { return false; }; let Ok(jwt_config) = ctx.config.get_jwt_config() else { return false; }; let Ok(claims) = loco_rs::auth::jwt::JWT::new(&jwt_config.secret).validate(cookie.value()) else { return false; }; let Ok(user) = users::Model::find_by_pid(&ctx.db, &claims.claims.pid).await else { return false; }; admin::is_admin(ctx, &user) } async fn unique_slug(base: &str, mut exists: F) -> Result where F: FnMut(String) -> Fut, Fut: std::future::Future>, { let base = if base.is_empty() { "item".to_string() } else { base.to_string() }; let mut slug = base.clone(); let mut suffix = 2; while exists(slug.clone()).await? { slug = format!("{base}-{suffix}"); suffix += 1; } Ok(slug) } /// Collected multipart form: text fields keyed by name, plus the raw bytes of /// an `image` file part if one was uploaded (an empty file input is ignored). struct MultipartForm { fields: HashMap, image: Option>, } impl MultipartForm { fn text(&self, key: &str) -> Option { normalize_empty(self.fields.get(key).cloned()) } fn checked(&self, key: &str) -> bool { matches!(self.fields.get(key).map(String::as_str), Some("on" | "true" | "1")) } } async fn read_multipart_form(mut multipart: Multipart) -> Result { let mut fields = HashMap::new(); let mut image = None; while let Some(mut field) = multipart .next_field() .await .map_err(|err| Error::BadRequest(format!("invalid multipart data: {err}")))? { let name = field.name().unwrap_or("").to_string(); if name == "image" { let mut data = Vec::new(); while let Some(chunk) = field .chunk() .await .map_err(|err| Error::BadRequest(format!("invalid multipart chunk: {err}")))? { data.extend_from_slice(&chunk); if data.len() > IMAGE_MAX_BYTES { return Err(Error::BadRequest(format!( "image is larger than {} MB", IMAGE_MAX_BYTES / 1024 / 1024 ))); } } if !data.is_empty() { image = Some(data); } } else { let value = field .text() .await .map_err(|err| Error::BadRequest(format!("invalid multipart field: {err}")))?; fields.insert(name, value); } } Ok(MultipartForm { fields, image }) } async fn store_image(ctx: &AppContext, data: Vec) -> Result { let extension = detect_image_extension(&data)?; store_upload(ctx, IMAGE_STORAGE_DIR, extension, data).await } async fn category_by_id(ctx: &AppContext, id: i32) -> Result { categories::Entity::find_by_id(id) .one(&ctx.db) .await? .ok_or_else(|| Error::NotFound) } async fn product_by_id(ctx: &AppContext, id: i32) -> Result { products::Entity::find_by_id(id) .one(&ctx.db) .await? .ok_or_else(|| Error::NotFound) } async fn first_image(ctx: &AppContext, product_id: i32) -> Result> { Ok(product_images::Entity::find() .filter(product_images::Column::ProductId.eq(product_id)) .order_by_asc(product_images::Column::Position) .one(&ctx.db) .await? .map(|image| image.image_id)) } /// Shape a product for templates: the model fields plus a formatted price, /// its (optional) primary image filename and category name. fn product_json( product: &products::Model, image: Option, category_name: Option, ) -> serde_json::Value { json!({ "id": product.id, "name": product.name, "slug": product.slug, "description": product.description, "price": format_price(product.price_cents), "currency": product.currency, "sku": product.sku, "stock": product.stock, "published": product.published, "image": image, "category_name": category_name, }) } /// Latest published products (with primary image), shaped for templates. /// Reused by the home page landing grid. pub(crate) async fn featured_products( ctx: &AppContext, limit: u64, ) -> Result> { let list = products::Entity::find() .filter(products::Column::Published.eq(true)) .order_by_desc(products::Column::PublishedAt) .limit(limit) .all(&ctx.db) .await?; let mut rows = Vec::new(); for product in list { let image = first_image(ctx, product.id).await?; rows.push(product_json(&product, image, None)); } Ok(rows) } // --------------------------------------------------------------------------- // Admin: products // --------------------------------------------------------------------------- #[debug_handler] async fn admin_products( auth: auth::JWT, jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { admin::current_admin(auth, &ctx).await?; let list = products::Entity::find() .order_by_desc(products::Column::CreatedAt) .all(&ctx.db) .await?; let mut rows = Vec::new(); for product in list { let image = first_image(&ctx, product.id).await?; let category_name = match product.category_id { Some(id) => category_by_id(&ctx, id).await.ok().map(|c| c.name), None => None, }; rows.push(product_json(&product, image, category_name)); } format::view( &v, "admin/catalog/products.html", json!({ "products": rows, "lang": current_lang(&jar) }), ) } async fn product_form_context(ctx: &AppContext, jar: &CookieJar) -> Result { let categories = categories::Entity::find() .order_by_asc(categories::Column::Position) .order_by_asc(categories::Column::Name) .all(&ctx.db) .await?; Ok(json!({ "categories": categories, "lang": current_lang(jar) })) } #[debug_handler] async fn admin_product_new( auth: auth::JWT, jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { admin::current_admin(auth, &ctx).await?; let mut context = product_form_context(&ctx, &jar).await?; context["product"] = serde_json::Value::Null; format::view(&v, "admin/catalog/product_form.html", context) } async fn parse_product_fields( ctx: &AppContext, form: &MultipartForm, current_id: Option, ) -> Result<(String, String, Option, i64, String, Option, i32, Option, bool)> { let name = form .text("name") .ok_or_else(|| Error::BadRequest("product name is required".to_string()))?; let price_cents = parse_price_to_cents( form.text("price") .ok_or_else(|| Error::BadRequest("price is required".to_string()))? .as_str(), )?; let currency = form.text("currency").unwrap_or_else(|| "EUR".to_string()); let description = form.text("description"); let sku = form.text("sku"); let stock = form .text("stock") .and_then(|s| s.parse::().ok()) .filter(|n| *n >= 0) .unwrap_or(0); let category_id = form.text("category_id").and_then(|s| s.parse::().ok()); let published = form.checked("published"); let desired = form .text("slug") .map(|s| slugify(&s)) .filter(|s| !s.is_empty()) .unwrap_or_else(|| slugify(&name)); let slug = unique_slug(&desired, |candidate| { let ctx = ctx.clone(); async move { let mut query = products::Entity::find().filter(products::Column::Slug.eq(candidate)); if let Some(id) = current_id { query = query.filter(products::Column::Id.ne(id)); } Ok(query.count(&ctx.db).await? > 0) } }) .await?; Ok(( name, slug, description, price_cents, currency, sku, stock, category_id, published, )) } #[debug_handler] async fn admin_product_create( auth: auth::JWT, State(ctx): State, multipart: Multipart, ) -> Result { admin::current_admin(auth, &ctx).await?; let form = read_multipart_form(multipart).await?; let (name, slug, description, price_cents, currency, sku, stock, category_id, published) = parse_product_fields(&ctx, &form, None).await?; let product = products::ActiveModel { name: Set(name), slug: Set(slug), description: Set(description), price_cents: Set(price_cents), currency: Set(currency), sku: Set(sku), stock: Set(stock), view_count: Set(0), published: Set(published), published_at: Set(published.then(|| chrono::Utc::now().into())), category_id: Set(category_id), ..Default::default() } .insert(&ctx.db) .await?; if let Some(data) = form.image { let filename = store_image(&ctx, data).await?; product_images::ActiveModel { product_id: Set(product.id), image_id: Set(filename), position: Set(0), alt: Set(None), ..Default::default() } .insert(&ctx.db) .await?; } format::redirect("/admin/catalog/products") } #[debug_handler] async fn admin_product_edit( auth: auth::JWT, jar: CookieJar, ViewEngine(v): ViewEngine, Path(id): Path, State(ctx): State, ) -> Result { admin::current_admin(auth, &ctx).await?; let product = product_by_id(&ctx, id).await?; let image = first_image(&ctx, id).await?; let mut context = product_form_context(&ctx, &jar).await?; context["product"] = json!({ "id": product.id, "name": product.name, "slug": product.slug, "description": product.description, "price": format_price(product.price_cents), "currency": product.currency, "sku": product.sku, "stock": product.stock, "published": product.published, "category_id": product.category_id, "image": image, }); format::view(&v, "admin/catalog/product_form.html", context) } #[debug_handler] async fn admin_product_update( auth: auth::JWT, Path(id): Path, State(ctx): State, multipart: Multipart, ) -> Result { admin::current_admin(auth, &ctx).await?; let existing = product_by_id(&ctx, id).await?; let was_published = existing.published; let form = read_multipart_form(multipart).await?; let (name, slug, description, price_cents, currency, sku, stock, category_id, published) = parse_product_fields(&ctx, &form, Some(id)).await?; let mut product = existing.into_active_model(); product.name = Set(name); product.slug = Set(slug); product.description = Set(description); product.price_cents = Set(price_cents); product.currency = Set(currency); product.sku = Set(sku); product.stock = Set(stock); product.category_id = Set(category_id); product.published = Set(published); if published && !was_published { product.published_at = Set(Some(chrono::Utc::now().into())); } else if !published { product.published_at = Set(None); } product.update(&ctx.db).await?; if let Some(data) = form.image { let filename = store_image(&ctx, data).await?; let next_position = product_images::Entity::find() .filter(product_images::Column::ProductId.eq(id)) .count(&ctx.db) .await? as i32; product_images::ActiveModel { product_id: Set(id), image_id: Set(filename), position: Set(next_position), alt: Set(None), ..Default::default() } .insert(&ctx.db) .await?; } format::redirect("/admin/catalog/products") } #[debug_handler] async fn admin_product_delete( auth: auth::JWT, Path(id): Path, State(ctx): State, ) -> Result { admin::current_admin(auth, &ctx).await?; product_by_id(&ctx, id).await?.delete(&ctx.db).await?; format::redirect("/admin/catalog/products") } // --------------------------------------------------------------------------- // Admin: categories // --------------------------------------------------------------------------- #[debug_handler] async fn admin_categories( auth: auth::JWT, jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { admin::current_admin(auth, &ctx).await?; let list = categories::Entity::find() .order_by_asc(categories::Column::Position) .order_by_asc(categories::Column::Name) .all(&ctx.db) .await?; let mut rows = Vec::new(); for category in list { let product_count = products::Entity::find() .filter(products::Column::CategoryId.eq(category.id)) .count(&ctx.db) .await?; rows.push(json!({ "category": category, "product_count": product_count })); } format::view( &v, "admin/catalog/categories.html", json!({ "categories": rows, "lang": current_lang(&jar) }), ) } #[debug_handler] async fn admin_category_new( auth: auth::JWT, jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { admin::current_admin(auth, &ctx).await?; format::view( &v, "admin/catalog/category_form.html", json!({ "category": serde_json::Value::Null, "lang": current_lang(&jar) }), ) } async fn parse_category_fields( ctx: &AppContext, form: &MultipartForm, current_id: Option, ) -> Result<(String, String, Option, i32, bool)> { let name = form .text("name") .ok_or_else(|| Error::BadRequest("category name is required".to_string()))?; let description = form.text("description"); let position = form .text("position") .and_then(|s| s.parse::().ok()) .unwrap_or(0); let published = form.checked("published"); let desired = form .text("slug") .map(|s| slugify(&s)) .filter(|s| !s.is_empty()) .unwrap_or_else(|| slugify(&name)); let slug = unique_slug(&desired, |candidate| { let ctx = ctx.clone(); async move { let mut query = categories::Entity::find().filter(categories::Column::Slug.eq(candidate)); if let Some(id) = current_id { query = query.filter(categories::Column::Id.ne(id)); } Ok(query.count(&ctx.db).await? > 0) } }) .await?; Ok((name, slug, description, position, published)) } #[debug_handler] async fn admin_category_create( auth: auth::JWT, State(ctx): State, multipart: Multipart, ) -> Result { admin::current_admin(auth, &ctx).await?; let form = read_multipart_form(multipart).await?; let (name, slug, description, position, published) = parse_category_fields(&ctx, &form, None).await?; let image_id = match form.image { Some(data) => Some(store_image(&ctx, data).await?), None => None, }; categories::ActiveModel { name: Set(name), slug: Set(slug), description: Set(description), image_id: Set(image_id), position: Set(position), published: Set(published), ..Default::default() } .insert(&ctx.db) .await?; format::redirect("/admin/catalog/categories") } #[debug_handler] async fn admin_category_edit( auth: auth::JWT, jar: CookieJar, ViewEngine(v): ViewEngine, Path(id): Path, State(ctx): State, ) -> Result { admin::current_admin(auth, &ctx).await?; format::view( &v, "admin/catalog/category_form.html", json!({ "category": category_by_id(&ctx, id).await?, "lang": current_lang(&jar) }), ) } #[debug_handler] async fn admin_category_update( auth: auth::JWT, Path(id): Path, State(ctx): State, multipart: Multipart, ) -> Result { admin::current_admin(auth, &ctx).await?; let existing = category_by_id(&ctx, id).await?; let form = read_multipart_form(multipart).await?; let (name, slug, description, position, published) = parse_category_fields(&ctx, &form, Some(id)).await?; let mut category = existing.into_active_model(); category.name = Set(name); category.slug = Set(slug); category.description = Set(description); category.position = Set(position); category.published = Set(published); if let Some(data) = form.image { category.image_id = Set(Some(store_image(&ctx, data).await?)); } category.update(&ctx.db).await?; format::redirect("/admin/catalog/categories") } #[debug_handler] async fn admin_category_delete( auth: auth::JWT, Path(id): Path, State(ctx): State, ) -> Result { admin::current_admin(auth, &ctx).await?; category_by_id(&ctx, id).await?.delete(&ctx.db).await?; format::redirect("/admin/catalog/categories") } // --------------------------------------------------------------------------- // Public storefront // --------------------------------------------------------------------------- #[debug_handler] async fn shop_index( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { let list = products::Entity::find() .filter(products::Column::Published.eq(true)) .order_by_desc(products::Column::PublishedAt) .all(&ctx.db) .await?; let mut rows = Vec::new(); for product in list { let image = first_image(&ctx, product.id).await?; rows.push(product_json(&product, image, None)); } let categories = categories::Entity::find() .filter(categories::Column::Published.eq(true)) .order_by_asc(categories::Column::Position) .all(&ctx.db) .await?; format::view( &v, "shop/index.html", json!({ "products": rows, "categories": categories, "logged_in_admin": logged_in_admin(&ctx, &jar).await, "lang": current_lang(&jar), }), ) } #[debug_handler] async fn shop_show( jar: CookieJar, ViewEngine(v): ViewEngine, Path(slug): Path, State(ctx): State, ) -> Result { let product = products::Entity::find() .filter(products::Column::Slug.eq(slug)) .filter(products::Column::Published.eq(true)) .one(&ctx.db) .await? .ok_or_else(|| Error::NotFound)?; let mut active = product.clone().into_active_model(); active.view_count = Set(product.view_count + 1); let product = active.update(&ctx.db).await?; let images = product_images::Entity::find() .filter(product_images::Column::ProductId.eq(product.id)) .order_by_asc(product_images::Column::Position) .all(&ctx.db) .await?; let category = match product.category_id { Some(id) => category_by_id(&ctx, id).await.ok(), None => None, }; format::view( &v, "shop/show.html", json!({ "product": product_json(&product, None, category.as_ref().map(|c| c.name.clone())), "images": images.iter().map(|i| i.image_id.clone()).collect::>(), "category": category, "logged_in_admin": logged_in_admin(&ctx, &jar).await, "lang": current_lang(&jar), }), ) } #[debug_handler] async fn shop_category( jar: CookieJar, ViewEngine(v): ViewEngine, Path(slug): Path, State(ctx): State, ) -> Result { let category = categories::Entity::find() .filter(categories::Column::Slug.eq(slug)) .filter(categories::Column::Published.eq(true)) .one(&ctx.db) .await? .ok_or_else(|| Error::NotFound)?; let list = products::Entity::find() .filter(products::Column::CategoryId.eq(category.id)) .filter(products::Column::Published.eq(true)) .order_by_desc(products::Column::PublishedAt) .all(&ctx.db) .await?; let mut rows = Vec::new(); for product in list { let image = first_image(&ctx, product.id).await?; rows.push(product_json(&product, image, None)); } format::view( &v, "shop/category.html", json!({ "category": category, "products": rows, "logged_in_admin": logged_in_admin(&ctx, &jar).await, "lang": current_lang(&jar), }), ) } pub fn routes() -> Routes { let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024); Routes::new() // public storefront .add("/shop", get(shop_index)) .add("/shop/{slug}", get(shop_show)) .add("/category/{slug}", get(shop_category)) // admin products .add("/admin/catalog/products", get(admin_products)) .add("/admin/catalog/products/new", get(admin_product_new)) .add( "/admin/catalog/products", post(admin_product_create).layer(image_limit.clone()), ) .add("/admin/catalog/products/{id}/edit", get(admin_product_edit)) .add( "/admin/catalog/products/{id}", post(admin_product_update).layer(image_limit.clone()), ) .add( "/admin/catalog/products/{id}/delete", post(admin_product_delete), ) // admin categories .add("/admin/catalog/categories", get(admin_categories)) .add("/admin/catalog/categories/new", get(admin_category_new)) .add( "/admin/catalog/categories", post(admin_category_create).layer(image_limit.clone()), ) .add( "/admin/catalog/categories/{id}/edit", get(admin_category_edit), ) .add( "/admin/catalog/categories/{id}", post(admin_category_update).layer(image_limit), ) .add( "/admin/catalog/categories/{id}/delete", post(admin_category_delete), ) }